在 C++ 裡面呼叫外部程式:Boost Process v2

| | 0 Comments| 11:04|
Categories:

很久以前,Heresy 寫過一篇《在 C++ 裡面呼叫外部程式 Boost.Process》來介紹當時還沒有正式被納入 Boost C++ Libraries 的 Boost Process 這個函式庫(官網);而後來到了 Boost 1.64.0、這個函式庫也正式成為 Boost 的一員,後來 Heresy 這邊部分專案也有用到他。

而在前一陣子,Heresy 要把使用的 Boost 版本從 1.87.0 更新到 1.89.0 的時候,卻發現相關的程式都不能編譯了?而且錯誤還滿微妙的,是:

#error:  WinSock.h has already been included

稍微研究一下、這才發現,原來 1.88.0 的時候已經默默地把 Process 升級到 2.0 版、和之前的 API 不相容了… orz


暫時切回 Process v1

如果是要讓本來的程式可以動的話,在 Boost 1.89.0 其實還是可以在相對小修改的狀況下做到。

本來的程式大概會是:

#include <iostream>
 
#include <boost/process.hpp>
 
int main(int argc, char** argv)
{
  boost::process::spawn("notepad");
}

如果要繼續使用 v1 的語法的話,可以改成:

#include <iostream>
 
#include <boost/process/v1.hpp>
 
int main(int argc, char** argv)
{
  boost::process::v1::spawn("notepad");
}

在 Heresy 這邊,基本上只要 header 檔改用 v1.hpp、namespace 也加個 v1、問題就都暫時解決了。

不過,就不知道 Boost 什麼時候會把 Process v1 給移除,所以理論上應該還是要找時間把程式用 v2 改寫就是了。


Process v2 的改變

根據官方的說法(文件)、這次的改版主要改進包括了:

  • 介面的簡化
  • 完整的 asio 整合
  • 移除不可靠的功能
  • 支援 UTF8
  • 獨立編譯
  • Linux 使用 pidfd_open
  • 讓 fd 更安全

老實說,整合 Boost ASIO 這點其實是讓 Heresy 有點怕的就是…

此外,有別於 Process v1 是 header-only 的函式庫,Process v2 是需要編譯、而在使用時也是需要連結編譯出來的函示庫的;而且,在 Windows 版似乎也需要連結一些系統的 lib 才能使用。


基本使用

Process v2 最基本的使用大概如下:

#include <iostream>
#include <boost/process.hpp>
 
int main()
{
  boost::asio::io_context ctx;
  boost::process::process proc(ctx,
    "/usr/bin/ping",
    { "www.google.com", "-c", "4" });
  proc.wait();
  std::cout << proc.exit_code() << "\n";
}

由於 Process v2 在使用上是基於 Boost asio,所以他在建立子程序的時候會需要 asio 的 execution_context 或 executor;而這邊是直接使用 io_context

之後則是建立一個 boost::process::process 的物件 proc;建構子的第一個參數就是 asio 的 context,第二個參數則是要執行的指令、第三個則是要送給程式的參數;所以實際上 proc 這邊會去執行的,就是下面的指令:

/usr/local/ping www.google.com -c 4

由於 process 基本上是 non-block 形式的呼叫,在開始執行後會直接離開;所以如果要等他執行結束的話,會需要去執行他的 wait();而如果有需要,也可以在他執行的時候去做其他的事情。

最後,則是可以透過他的 exit_code() 來取得回傳值、確認程式執行的結果。

不過,實際上 wait() 也會回傳外部程式執行的回傳值,所以其實以這個例子來說,是可以直接用 wait() 的回傳值來取代 exit_code() 的。


要做到同樣的功能,在 Windows 上需要做對應的修改:

#ifdef _WIN32
#include <WinSock2.h>
#pragma comment(lib, "ntdll.lib")
#pragma comment(lib, "user32.lib")
#pragma comment(lib, "shell32.lib")
#endif
 
#include <iostream>
#include <boost/process.hpp>
 
int main()
{
  boost::asio::io_context ctx;
  boost::process::process proc(ctx,
    "c:\\windows\\system32\\ping.exe",
    { "www.google.com", "-n", "4" });
  proc.wait();
  std::cout << proc.exit_code() << "\n";
}

首先,在 Windows 上則稍微麻煩一點的,是在 include process.hpp 之前,要先 include Windows 的 WinSock2.h、否則會出現「WinSock.h has already been included」的錯誤。

此外,以這個例子來說、也會需要手動連結 ntdll.libuser32.libshell32.lib 這三個 Windows 的 lib。

之後則就是要把 ping.exe 執行檔的路徑換成 Windows 的版本,然後參數也要從「-c 4」改成「-n 4」。


透過 shell 執行

相較於 v1 有 child()system()spawn() 三種執行方法,v2 應該使僅有一個 process 可以使用。此外,v2 似乎也沒有像 v1 那樣直接使用系統預設的 shell 來執行的方法,所以如果要透過 shell 來執行的話,應該是得自己呼叫 shell 了。

以 Linux 來說,要透過 shell 執行「ls -la」的話,可以寫成:

boost::process::process proc(ctx,
  "/bin/sh",
  { "-c", "ls -la" });

這邊會透過「/bin/sh」來執行,如果是要用 bash 或其他 shell、就得自己修改。


如果是 Windows 的話,比較單純就是透過 cmd:

boost::process::process proc(ctx,
  "c:\\windows\\system32\\cmd.exe",
  { "/C", "dir"});

而如果想要透過 PowerShell 來當作 shell 的話,則是需要把執行檔改成 PowerShell 的完整路徑、然後後面的參數也要改成 PowerShell 的參數。


Initializer

在建立 process 的時候,除了上面三個參數外,後面其實還可以加上其他 initializer、來做進一步的初始化設定;通用型的 initializer 看起來主要包括了:

  • 起始路徑:process_start_dir
  • 環境變數:process_environment
  • 輸入輸出:process_stdio

而除了這三種通用的 initializer,實際上也還有像是 Windows 專用設定的 initializer;另外有必要似乎是也可以自己建立客製化的 initializer。

在呼叫 process 的建構子的時候,可以在後面加上需要的 initializer,基本上應該是可以無視順序給多個 initializer 的物件。

這一篇應該會先跳過比較複雜的輸入輸出部分,只講其他兩個了。


起始路徑

如果要設定起始路徑的話,基本上是要透過 process_start_dir 來設定;比如說以上面呼叫 dir 的程式來說,如果是要改成在 c:\ 下執行的話,可以改寫成:

boost::process::process proc(ctx,
  "c:\\windows\\system32\\cmd.exe",
  { "/C", "dir"},
  boost::process::process_start_dir("c:\\"));

這樣就可以了,使用上相當簡單。


環境變數

Boost Process 有提供 environment 這個 namespace、專門用來處理環境變數相關的功能。

像是如果要取得、並列出目前的環境變數的話,可以寫成:

auto env = boost::process::environment::current();
for(const auto& kv : env)
  std::cout << kv.key() << "=" << kv.value() << "\n";

尋找可執行檔

如果想要在現有的環境中、去找到特定的可執行檔的話、也可以寫成

auto exePath = boost::process::environment::find_executable("g++");

這邊的 exePath 型別會是 boost::filesystem::path,透過這個函式,就可以很方便地確認執行檔所在的完整路徑。

例如想要透過 PowerShell 來執行指令的話,就可以先透過這個指令來找出 powershell 的完整路徑:

auto pathPwsh = boost::process::environment::find_executable("powershell");
boost::process::process proc(ctx,
  pathPwsh,
  { "-C", "dir"});

這樣也可以某種程度上避免在不同的電腦裡、執行檔所在路徑不同的問題。


建立自己的環境

Process 預設應該是會把目前的環境變數整個帶入,如果不想要這樣做的話,也可以自己設定需要的環境變數。

官方範例定義環境變數的型別是 std::unordered_map<environment::key, environment::value>;例如下面的程式就會建立一個只有 PATHSECRET 兩個變數的環境、然後執行 cmd:

std::unordered_map<
boost::process::environment::key,
boost::process::environment::value
> my_env = { {"SECRET", "My-API-Key"}, {"PATH", "c:\\windows\\system32"} }; boost::process::process proc(ctx, "c:\\windows\\system32\\cmd.exe", { "/C", "set"}, boost::process::process_environment(my_env));

而在 cmd 裡面會去執行 set 這個指令、列出環境變數、結果會是類似下面的狀況:

COMSPEC=c:\windows\system32\cmd.exe
PATHEXT=.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC
PROMPT=$P$G
SECRET=THIS_IS_A_TEST
PATH=c:\windows\system32

除了自己設定的部分外,基本上還是會有一些額外的東西,但是已經算是精簡很多了。

如果把最後的 process_environment(my_env) 拿掉的話,則就會看到更多系統的環境變數了。


不過這邊比較奇怪的,官方給的範例是:

std::unordered_map <environment::key, environment::value> my_env =
{
  {"SECRET", "THIS_IS_A_TEST"},
  {"PATH",   {"/bin", "/usr/bin"}}
};

看起來在設定環境變數的值的時候是可以直接給多個字串的?但是這樣的程式碼 Heresy 這邊拿來編譯雖然可以過、但是在執行時都是會有錯誤的?(Visual C++ 和 g++ 都是)


繼承、修改既有的環境

由於 Process 的環境設定基本上就是一個 key-value 的集合、型別也不限於 unordered_map、所以如果是要基於某個環境變數來新增、修改、也算是相當單純。

像是如果要針對現有的環境變數額外新增變數的話,可以寫成:

auto curEnv = bp::environment::current();
std::vector<bp::environment::key_value_pair> my_env{ curEnv.begin(), curEnv.end() };
my_env.push_back("SECRET=My-API-Key");

這邊的 my_env 的型別是 std::vector<>、他會先把 curEnv 的值全部複製進來、之後再把「"SECRET=My-API-Key"」放在最後。

這樣的 my_env 也是可以直接給 process_environment() 用的。

而如果有必要,也可以自己根據 curEnv 一個一個檢查、自己決定要把那些東西放進去,算是滿有彈性、也想當簡單的。


Windows 額外的視窗控制

Process 在 Windows 環境下,其實還有提供兩組額外的 initializer,可以在呼叫外部程式實作一些額外的控制。

首先,透過 process_creation_flags<> 可以用來設定額外的啟動資訊;像是如果想讓前面 ping 的程式可以另外開一個視窗來跑的話,可以寫成:

#include <iostream>
#include <boost/process.hpp>
#include <boost/process/windows/creation_flags.hpp>
 
int main()
{
  boost::asio::io_context ctx;
  boost::process::process proc(ctx,
    "c:\\windows\\system32\\ping.exe",
    { "www.google.com", "-n", "4" },
    boost::process::windows::create_new_console);
  proc.wait();
}

這樣就會額外開一個 console 來做顯示了。

而如果要控制額外視窗的顯示方式的話,則是可以透過 process_show_window<> 來設定;比如說想要讓新開啟的視窗最大化的話,可以寫成:

#include <iostream>
#include <boost/process.hpp>
#include <boost/process/windows/creation_flags.hpp>
#include <boost/process/windows/show_window.hpp>
 
int main()
{
  boost::asio::io_context ctx;
  boost::process::process proc(ctx,
    "c:\\windows\\system32\\ping.exe",
    { "www.google.com", "-n", "4" },
    boost::process::windows::create_new_console,
    boost::process::windows::show_window_maximized);
  proc.wait();
}

這邊的設定也可以用來控制有圖形介面的程式,比如說想要開啟一個最小化顯示的 Windows 記事本的話,可以寫成:

boost::process::process proc(ctx,
  boost::process::environment::find_executable("notepad"),{},
  boost::process::windows::show_window_minimized);

不過這樣執行記事本實際上 proc 會馬上結束、沒辦法去追蹤執行的狀態;這點不確定有沒有辦法改變?

而除了最大化最小化之外,他也還有一些額外的控制。比如說想要呼叫的外部程式有圖形介面、但是不想讓使用者看到的話,則可以透過 show_window_hide 讓視窗隱藏起來;但是相對地,這邊就得注意這個外部程式後會不會關閉了,否則很有可能會持續以看不見的形式在背景執行。


Process 的控制

process 的物件建立出來後,也還有提供一些成員函式,可以用來檢查狀態、或是進一步控制。

像是可以透過 proc.running() 來檢查外部程序是否還在執行中,在有的時候也是必要的。

此外,這邊也有提供 suspend()resume() 這兩個函式、可以用來暫停、繼續外部程序的執行。

而必要的話,process 也有提供三種中斷的方法、可以要求外部程序結束:

  • terminate():強制中斷、在所有平台都一定可以中斷程序的函式。(SIGKILL
  • request_exit():要求外部程序結束。(SIGTERM
  • interrupt():送出中斷訊號、應該是相當於 Ctrl + C。(SIGINT

其中,terminate() 基本上一定會把外部程序結束,不會有問題。

request_exit() 則是要看外部程式有沒有去接受這種訊號,如果沒有的話就不會結束。
以前面的 ping 的例子來說,Heresy 這邊測試在 Windows 似乎是不會結束。

interrupt() 的話,在官方文件是說在 Windows 平台的話、要在建立 process 的時候設定「windows::create_new_process_group」這個 initializer 才會接受;但是以前面的 ping 例子來說,Heresy 這邊測試好像也還是沒用?

最後,request_exit()interrupt() 在呼叫後可能都不會立刻結束,所以之後最好還是要呼叫 wait()、確認已經結束。


execute 和 async_execute

execute()async_execute() 應該算是用來輔助、簡化執行用的。

使用 execute() 本身是 block call、基本上就是用來等 process 結束;下面是官方的範例:

assert(execute(process(ctx, "/bin/ls", {})) == 0);

這樣會確保外部程序已經結束、並直接取得回傳值來做確認。

不過老實說,個人會覺得直接寫成下面的樣子不就好了?

assert(process(ctx, "/bin/ls", {}).wait() == 0);

async_execute() 的部分,則是可以在非同步執行的狀況下、設定類似逾時結束的效果。下面是官方範例:

async_execute(process(ctx, "/usr/bin/g++", { "hello_world.cpp" }))
  (asio::cancel_after(std::chrono::seconds(10), asio::cancellation_type::partial))
  (asio::cancel_after(std::chrono::seconds(20), asio::cancellation_type::terminal))
  (asio::detached);

這邊會建立出 process、然後設定在 10 秒後送出 partial 的停止要求、然後在 20 秒後送出 terminate;同時,這邊也設定他用 detached 的形式來執行。

而這邊的取消型別有三種,分別對應前面的中斷要求:

  • cancellation_type::totalinterrupt()
  • cancellation_type::partialrequest_exit()
  • cancellation_type::terminalterminate()

不過 Heresy 這邊在測試的時候,感覺這樣寫、就算沒加 detached,輸出也不會顯示在標準輸出上了?相對地,也很難判斷程式到底運作的怎麼樣…這部分可能就等以後有需要這樣用再來認真研究吧。


這篇大概就先這樣了。

針對外部程序的輸入輸出的部分,接下來應該會再寫一篇文章。

另外,其實 process 其實可以指定「執行器」(Launcher、文件)、要求 Process 針對 Windows 或 POSIX 使用特定的方法來執行外部程序;不過這部分應該會先跳過,等真的有碰到再說了。


本系列文章目錄:

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *