GUI 기반으로 작성한 프로그램은 직관적이지만, 해당 기능을 사용하기 위해 마우스를 사용해야 하며, 이로 인해 자동화하기 어렵다는 문제점이 있다. 물론 매크로 등을 활용할 수 있지만, 사람이 직접 수행하지 않을 뿐, 결국 프로그램으로 마우스 클릭 등을 반복하는 것이다.
GUI 기반으로 개발된 파일을 변환해주는 프로그램들을 CI/CD 과정에 사용하려 한다고 가정해보자.
GUI를 실행하기 위한 Desktop Environment가 없는 Container/Build server일 수 있다.
GUI가 있는 환경이라도, 해당 환경에 맞춰 매크로를 기록해야 한다.
매크로를 사용해도 Build server에 RDP 등으로 접속 시, 해상도 변경, 좌표 변경 등 이슈가 발생한다.
여러 GUI 기반 프로그램들을 실행할 때, 마우스 위치가 점유 자원으로서 병렬성을 방해한다.
해당 프로그램의 실행 결과도 GUI로 나타나므로, 이를 쉽게 확인할 수 없다.
위의 상황은 극단적으로 가정한 것이지만, 최소 1개 이상 실제 문제가 될 것이라 예상한다.
목표
Window Manager(Windows Explorer, Gnome 등)나 Shell(Bash, Command Prompt 등)에서 실행 시, GUI 환경으로 실행
Shell에서 실행할 때 특정 인자를 입력하면(혹은 Windows Manager의 Shortcut에 인자 추가) GUI를 띄우지 않고, 해당 인자에 따라 기능을 수행한 뒤 종료.
필요에 따라 진행 과정에 대한 Log를 출력한다.
해당 프로그램의 성공/실패 여부에 따라 확인할 수 있게 Error code를 반환하며 종료한다.
위의 목표를 개발자 기준의 요구사항으로 해석하면 아래와 같다.
기존의 GUI 실행에 관련된 코드를 뒤엎지 않고 기능을 추가해야 한다.
해당 프로그램 실행의 인자를 읽고, 특정 조건 시 GUI를 실행시키는 함수를 호출하지 않아야 한다.
stdio (특히 stdout, stderr)가 해당 프로그램을 실행시킨 shell과 연결되어 있어야 한다.
GUI를 실행시키지 않은 상태에서 원하는 exit code로 프로그램을 종료시켜야 한다.
MFC같이 안 좋은 GUI framework에서 개발해야 한다.
진단
이제 MFC 프로그램을 작성하고, Shell에서 실행시켜보자. MFC 응용 프로그램 프로젝트를 ConsoleGuiTool로 가정하겠다. (대화 상자 기반으로 작성하였으나, 다른 종류도 크게 문제되지 않는다.) 빌드하고 Command Prompt에서 실행시켜보면 아래 스크린샷과 같은 상황을 확인할 수 있을 것이다.
분명 해당 프로그램이 종료되지 않았음에도 Shell과의 연결이 끊어졌다. 요구사항 3, 4의 상황에서 문제가 생길 수 있다. (자세한 설명은 Command Prompt 명령어 설명에서 다루도록 한다.)
Console 실행 모드 추가하기
일단 수정해야 할 파일은 현재 MFC 프로그램의 진입점 부분이다. CWinApp을 상속하고 있는 클래스의 소스 코드를 찾자. (특별히 이름을 변경하지 않았다면, 프로젝트 이름과 같은 *.h, *.cpp일 것이다.)
// ConsoleGuiTool.h : main header file for the PROJECT_NAME application
//
#pragma once
#ifndef __AFXWIN_H__
#error "include 'stdafx.h' before including this file for PCH"
#endif
#include"resource.h"// main symbols
// CConsoleGuiToolApp:
// See ConsoleGuiTool.cpp for the implementation of this class
//
classCConsoleGuiToolApp:publicCWinApp{public:CConsoleGuiToolApp();// Overrides
public:virtualBOOLInitInstance();// Implementation
DECLARE_MESSAGE_MAP()};externCConsoleGuiToolApptheApp;
// ConsoleGuiTool.cpp : Defines the class behaviors for the application.
//
#include"stdafx.h"#include"ConsoleGuiTool.h"#include"ConsoleGuiToolDlg.h"#ifdef _DEBUG
#define new DEBUG_NEW
#endif
// CConsoleGuiToolApp
BEGIN_MESSAGE_MAP(CConsoleGuiToolApp,CWinApp)ON_COMMAND(ID_HELP,&CWinApp::OnHelp)END_MESSAGE_MAP()// CConsoleGuiToolApp construction
CConsoleGuiToolApp::CConsoleGuiToolApp(){// TODO: add construction code here,
// Place all significant initialization in InitInstance
}// The one and only CConsoleGuiToolApp object
CConsoleGuiToolApptheApp;// CConsoleGuiToolApp initialization
BOOLCConsoleGuiToolApp::InitInstance(){// InitCommonControlsEx() is required on Windows XP if an application
// manifest specifies use of ComCtl32.dll version 6 or later to enable
// visual styles. Otherwise, any window creation will fail.
INITCOMMONCONTROLSEXInitCtrls;InitCtrls.dwSize=sizeof(InitCtrls);// Set this to include all the common control classes you want to use
// in your application.
InitCtrls.dwICC=ICC_WIN95_CLASSES;InitCommonControlsEx(&InitCtrls);CWinApp::InitInstance();// Create the shell manager, in case the dialog contains
// any shell tree view or shell list view controls.
CShellManager*pShellManager=newCShellManager;// Activate "Windows Native" visual manager for enabling themes in MFC controls
CMFCVisualManager::SetDefaultManager(RUNTIME_CLASS(CMFCVisualManagerWindows));// Standard initialization
// If you are not using these features and wish to reduce the size
// of your final executable, you should remove from the following
// the specific initialization routines you do not need
// Change the registry key under which our settings are stored
// TODO: You should modify this string to be something appropriate
// such as the name of your company or organization
SetRegistryKey(_T("Local AppWizard-Generated Applications"));CConsoleGuiToolDlgdlg;m_pMainWnd=&dlg;INT_PTRnResponse=dlg.DoModal();if(nResponse==IDOK){// TODO: Place code here to handle when the dialog is
// dismissed with OK
}elseif(nResponse==IDCANCEL){// TODO: Place code here to handle when the dialog is
// dismissed with Cancel
}elseif(nResponse==-1){TRACE(traceAppMsg,0,"Warning: dialog creation failed, so application is terminating unexpectedly.\n");TRACE(traceAppMsg,0,"Warning: if you are using MFC controls on the dialog, you cannot #define _AFX_NO_MFC_CONTROLS_IN_DIALOGS.\n");}// Delete the shell manager created above.
if(pShellManager!=NULL){deletepShellManager;}// Since the dialog has been closed, return FALSE so that we exit the
// application, rather than start the application's message pump.
returnFALSE;}
해당 class에 기존 GUI환경의 init을 담당할 InitGuiWindow()와 콘솔모드로 실행할 RunConsole() 함수를 추가한다. 이후 기존의 InitInstance()함수를 InitGuiWindow()로 교체하고, 새로운 InitInstance()를 아래와 같이 작성한다.
// ConsoleGuiTool.h : main header file for the PROJECT_NAME application
//
#pragma once
#ifndef __AFXWIN_H__
#error "include 'stdafx.h' before including this file for PCH"
#endif
#include"resource.h"// main symbols
// CConsoleGuiToolApp:
// See ConsoleGuiTool.cpp for the implementation of this class
//
classCConsoleGuiToolApp:publicCWinApp{public:CConsoleGuiToolApp();// Overrides
public:virtualBOOLInitInstance();// Implementation
DECLARE_MESSAGE_MAP()BOOLInitGuiWindow();BOOLRunConsole();};externCConsoleGuiToolApptheApp;
// ConsoleGuiTool.cpp : Defines the class behaviors for the application.
//
#include"stdafx.h"#include"ConsoleGuiTool.h"#include"ConsoleGuiToolDlg.h"#ifdef _DEBUG
#define new DEBUG_NEW
#endif
// CConsoleGuiToolApp
BEGIN_MESSAGE_MAP(CConsoleGuiToolApp,CWinApp)ON_COMMAND(ID_HELP,&CWinApp::OnHelp)END_MESSAGE_MAP()// CConsoleGuiToolApp construction
CConsoleGuiToolApp::CConsoleGuiToolApp(){// TODO: add construction code here,
// Place all significant initialization in InitInstance
}// The one and only CConsoleGuiToolApp object
CConsoleGuiToolApptheApp;// CConsoleGuiToolApp initialization
BOOLCConsoleGuiToolApp::InitInstance(){intnArgCnt=__argc;if(nArgCnt==1){returnInitGuiWindow();}else{returnRunConsole();}}BOOLCConsoleGuiToolApp::InitGuiWindow(){// InitCommonControlsEx() is required on Windows XP if an application
// manifest specifies use of ComCtl32.dll version 6 or later to enable
// visual styles. Otherwise, any window creation will fail.
INITCOMMONCONTROLSEXInitCtrls;InitCtrls.dwSize=sizeof(InitCtrls);// Set this to include all the common control classes you want to use
// in your application.
InitCtrls.dwICC=ICC_WIN95_CLASSES;InitCommonControlsEx(&InitCtrls);CWinApp::InitInstance();// Create the shell manager, in case the dialog contains
// any shell tree view or shell list view controls.
CShellManager*pShellManager=newCShellManager;// Activate "Windows Native" visual manager for enabling themes in MFC controls
CMFCVisualManager::SetDefaultManager(RUNTIME_CLASS(CMFCVisualManagerWindows));// Standard initialization
// If you are not using these features and wish to reduce the size
// of your final executable, you should remove from the following
// the specific initialization routines you do not need
// Change the registry key under which our settings are stored
// TODO: You should modify this string to be something appropriate
// such as the name of your company or organization
SetRegistryKey(_T("Local AppWizard-Generated Applications"));CConsoleGuiToolDlgdlg;m_pMainWnd=&dlg;INT_PTRnResponse=dlg.DoModal();if(nResponse==IDOK){// TODO: Place code here to handle when the dialog is
// dismissed with OK
}elseif(nResponse==IDCANCEL){// TODO: Place code here to handle when the dialog is
// dismissed with Cancel
}elseif(nResponse==-1){TRACE(traceAppMsg,0,"Warning: dialog creation failed, so application is terminating unexpectedly.\n");TRACE(traceAppMsg,0,"Warning: if you are using MFC controls on the dialog, you cannot #define _AFX_NO_MFC_CONTROLS_IN_DIALOGS.\n");}// Delete the shell manager created above.
if(pShellManager!=NULL){deletepShellManager;}// Since the dialog has been closed, return FALSE so that we exit the
// application, rather than start the application's message pump.
returnFALSE;}BOOLCConsoleGuiToolApp::RunConsole(){printf("Program is running in console mode\n");Sleep(5000);printf("It seems program did some job, 5 second passed\n");returnFALSE;}
일단 콘솔 모드 진입 조건은 실행파일 뒤에 인자가 하나라도 붙는 것으로 설정했다. 콘솔 모드 진입시, 콘솔 모드로 실행된다는 메시지를 출력하고, 5초 이후 무언가 완료되었다고 하고 종료되게 설정하였다. 빌드하고 Command Prompt에서 실행시켜보면 아래 스크린샷과 같은 상황을 확인할 수 있을 것이다.
일단 실행 인자 옵션에 따라 기존 GUI 구동을 유지한 상태에서, 콘솔 모드 실행에 성공했다. 하지만 printf()를 통한 메시지 출력이 되지 않는 것을 확인할 수 있다.
프로그램을 실행시킨 shell과 stdio 연결
해당 증상의 원인은 MFC 프로그램 실행 시, stdio가 기존 shell에서 떨어지기 때문이다. MFC 프로그램을 실행시킨 부모 프로세스(Command Prompt 혹은 PowerShell)의 stdio와 연결해야한다. 기존의 RunConsole() 함수를 아래와 같이 수정한다. (코드 상단에 #include <iostream>을 해야한다.)
BOOLCConsoleGuiToolApp::RunConsole(){if(AttachConsole(ATTACH_PARENT_PROCESS)){FILE*pfStdin;FILE*pfStdout;FILE*pfStderr;freopen_s(&pfStdin,"CONIN$","r",stdin);freopen_s(&pfStdout,"CONOUT$","w",stdout);freopen_s(&pfStderr,"CONOUT$","w",stderr);std::cin.clear();std::cout.clear();std::cerr.clear();}printf("Program is running in console mode\n");Sleep(5000);printf("It seems program did some job, 5 second passed\n");returnFALSE;}
L116~128의 내용은 부모 프로세스의 콘솔에 연결하고 stdin, stdout, stderr를 다시 여는 작업을 수행한다.
임의의 Exit Code로 종료시키기
원칙적으로는 main() 함수의 return 값으로 exit code를 보내는 것이 정석이나, 우리가 현재 수정 가능한 코드 범위에서는 main() 함수를 수정할 수 없기 때문에 차선책인 exit() 함수를 사용하면 된다.
stdin의 연결 상태를 확인할 겸, 아래와 같이 RunConsole() 함수를 수정한다.
BOOLCConsoleGuiToolApp::RunConsole(){intnExitCode;if(AttachConsole(ATTACH_PARENT_PROCESS)){FILE*pfStdin;FILE*pfStdout;FILE*pfStderr;freopen_s(&pfStdin,"CONIN$","r",stdin);freopen_s(&pfStdout,"CONOUT$","w",stdout);freopen_s(&pfStderr,"CONOUT$","w",stderr);std::cin.clear();std::cout.clear();std::cerr.clear();}printf("Program is running in console mode\n");Sleep(5000);printf("It seems program did some job, 5 second passed\n");printf("Enter exit code to get: ");std::cin>>nExitCode;exit(nExitCode);returnFALSE;}
위 목표를 모두 달성했다. 이제 실행인자를 잘 입력 받았는지 확인하고, 이를 비교하기만 하면 된다.
실행 인자 사용을 위한 추가 설명
이전의 설명은 CWinApp::InitInstance() 메서드를 수정하여 GUI를 띄우지 않고 콘솔에서 입출력을 수행하는 부분을 설명했다. 실행 인자를 입력받는 방법은 __argc, __argv, __wargv 전역변수를 활용한다. 해당 변수는 stdlib.h에서 제공하며, Microsoft에서만 제공하는 확장 기능이다. (C,C++ 표준이 아님) (Multi-Byte Character Set으로 빌드한 경우 __argv를, Unicode Character Set으로 빌드한 경우 __wargv를 사용)
사실 Windows에서 제공하는 Command Prompt, PowerShell에서 GUI를 사용하는 프로그램을 실행시키면 자동으로 해당 shell에서 detach하게 되어있다. 현재 수정한 방식은 GUI를 화면에 출력하기 전에 기능을 수행하고 종료시킨 것이므로, 똑같이 shell에서 detach하게 되어있다.
Command Prompt에서 콘솔 모드 사용을 위해서는 아래와 같은 옵션을 고려해야 한다.
Batch file을 실행할 때, 환경변수 등은 %VAR%를 통해 그 값으로 치환할 수 있다. 하지만 위 예시와 같이 프로그램의 실행 시간을 측정하기 위해 한 명령행 안에 %VAR%로 넣을 경우, 즉시 치환된다. (!time! 대신 %time%로 입력 시, 실제 소요시간과 관계없이 같은 시간이 나타난다.) 해당 변수에 접근하는 순간 치환되길 원한다면, 위와 같이 !VAR!로 표기해야 한다. 또한 위와 같은 delayed environment variable expansion을 사용하려면 현재 콘솔에 옵션을 설정하거나 cmd로 실행시킬 때, /v:on 옵션을 주고 실행시켜야 한다.
해설
C/C++로 작성한 프로그램의 기본 진입점은 main()함수이다. main()함수의 매개변수로 int argc, char** argv를 사용하여 실행 인자를 프로그램 내에서 확인할 수 있다.
하지만 MFC로 프로젝트를 생성할 경우, main()함수를 개발자가 직접 편집할 수 없다. 이전의 코드에서 봤던 것 처럼 개발자가 수정 가능한 프로그램의 최초 진입 지점은 CWinApp::InitInstance() 메서드이다.
main()함수 역할을 수행하는 WinAPI에서 제공하는 진입점은 _tWinMain() 함수이다. MFC에서 작성된 _tWinMain()함수의 정의는 VC\atlmfc\src\mfc\appmodul.cpp에서 확인할 수 있다.
// This is a part of the Microsoft Foundation Classes C++ library.
// Copyright (C) Microsoft Corporation
// All rights reserved.
//
// This source code is only intended as a supplement to the
// Microsoft Foundation Classes Reference and related
// electronic documentation provided with the library.
// See these sources for detailed information regarding the
// Microsoft Foundation Classes product.
#include"stdafx.h"#include"sal.h"/////////////////////////////////////////////////////////////////////////////
// export WinMain to force linkage to this module
externintAFXAPIAfxWinMain(HINSTANCEhInstance,HINSTANCEhPrevInstance,_In_LPTSTRlpCmdLine,intnCmdShow);extern"C"intWINAPI_tWinMain(HINSTANCEhInstance,HINSTANCEhPrevInstance,_In_LPTSTRlpCmdLine,intnCmdShow)#pragma warning(suppress: 4985)
{// call shared/exported WinMain
returnAfxWinMain(hInstance,hPrevInstance,lpCmdLine,nCmdShow);}/////////////////////////////////////////////////////////////////////////////
// initialize app state such that it points to this module's core state
BOOLAFXAPIAfxInitialize(BOOLbDLL,DWORDdwVersion){AFX_MODULE_STATE*pModuleState=AfxGetModuleState();pModuleState->m_bDLL=(BYTE)bDLL;ASSERT(dwVersion<=_MFC_VER);UNUSED(dwVersion);// not used in release build
#ifdef _AFXDLL
pModuleState->m_dwVersion=dwVersion;#endif
:
_tWinMain()에서 호출하는 AfxWinMain()의 정의는 VC\atlmfc\src\mfc\winmain.cpp에서 확인할 수 있다.
// This is a part of the Microsoft Foundation Classes C++ library.
// Copyright (C) Microsoft Corporation
// All rights reserved.
//
// This source code is only intended as a supplement to the
// Microsoft Foundation Classes Reference and related
// electronic documentation provided with the library.
// See these sources for detailed information regarding the
// Microsoft Foundation Classes product.
#include"stdafx.h"#include"sal.h"/////////////////////////////////////////////////////////////////////////////
// Standard WinMain implementation
// Can be replaced as long as 'AfxWinInit' is called first
intAFXAPIAfxWinMain(HINSTANCEhInstance,HINSTANCEhPrevInstance,_In_LPTSTRlpCmdLine,intnCmdShow){ASSERT(hPrevInstance==NULL);intnReturnCode=-1;CWinThread*pThread=AfxGetThread();CWinApp*pApp=AfxGetApp();// AFX internal initialization
if(!AfxWinInit(hInstance,hPrevInstance,lpCmdLine,nCmdShow))gotoInitFailure;// App global initializations (rare)
if(pApp!=NULL&&!pApp->InitApplication())gotoInitFailure;// Perform specific initializations
if(!pThread->InitInstance()){if(pThread->m_pMainWnd!=NULL){TRACE(traceAppMsg,0,"Warning: Destroying non-NULL m_pMainWnd\n");pThread->m_pMainWnd->DestroyWindow();}nReturnCode=pThread->ExitInstance();gotoInitFailure;}nReturnCode=pThread->Run();InitFailure:#ifdef _DEBUG
// Check for missing AfxLockTempMap calls
if(AfxGetModuleThreadState()->m_nTempMapLock!=0){TRACE(traceAppMsg,0,"Warning: Temp map lock count non-zero (%ld).\n",AfxGetModuleThreadState()->m_nTempMapLock);}AfxLockTempMaps();AfxUnlockTempMaps(-1);#endif
AfxWinTerm();returnnReturnCode;}/////////////////////////////////////////////////////////////////////////////
AfxGetApp()를 통해 개발자가 정의한 Application 변수의 주소를 얻고, 해당 변수에서 InitInstance() 메서드를 호출한다. 해당 함수는 개발자가 재정의할 수 있으며, 우리는 이 InitInstance()함수를 수정하여 MFC dialog를 띄우지 않고 콘솔에서 처리할 수 있도록 변형한 것이다.
:// CConsoleGuiToolApp construction
CConsoleGuiToolApp::CConsoleGuiToolApp(){// TODO: add construction code here,
// Place all significant initialization in InitInstance
}// The one and only CConsoleGuiToolApp object
CConsoleGuiToolApptheApp;// CConsoleGuiToolApp initialization
BOOLCConsoleGuiToolApp::InitInstance():
다른 GUI framework에서의 진입점 (main 함수)
아래는 각 C/C++로 작성된 GUI framework의 진입점이다. 대부분의 GUI framework는 main() 함수를 편집할 수 있다.
WxWidget은 MFC와 같이 main() 함수를 편집할 수 없으므로, wxApp이 제공하는 메서드를 사용해야 한다.
#include<QApplication>#include<QCommandLineParser>#include<QCommandLineOption>#include"mainwindow.h"intmain(intargc,char*argv[]){Q_INIT_RESOURCE(application);QApplicationapp(argc,argv);QCoreApplication::setOrganizationName("QtProject");QCoreApplication::setApplicationName("Application Example");QCoreApplication::setApplicationVersion(QT_VERSION_STR);QCommandLineParserparser;parser.setApplicationDescription(QCoreApplication::applicationName());parser.addHelpOption();parser.addVersionOption();parser.addPositionalArgument("file","The file to open.");parser.process(app);MainWindowmainWin;if(!parser.positionalArguments().isEmpty())mainWin.loadFile(parser.positionalArguments().first());mainWin.show();returnapp.exec();}