ProcessX - C#でProcessを C# 8.0非同期ストリームで簡単に扱うライブラリ

C#使う人って全然外部プロセス呼び出して処理ってしないよね。というのは、Windowsがなんかそういうのを避ける雰囲気だから、というのもあるのですが、ともあれ実際、可能な限り避けるどころか絶対避ける、ぐらいの勢いがあります。ライブラリになってないと嫌だ、断固拒否、みたいな。しかし最近はLinuxでもばっちし動くのでそういう傾向もどうかなー、と思いつつ。

避けるというのはOSの違いというのもありそうですが、もう一つはそもそも外部プロセスの呼び出しが死ぬほど面倒くさい。ProcessとProcessStartInfoを使ってどうこうするのですが、異常に面倒くさい。理想的にはシェルで書くように一行でコマンドと引数繋げたstringを投げておしまい、と行きたいのですが、全然そうなってない。呼び出すだけでも面倒くさいうぇに、StdOutのリダイレクトとかをやると更に面倒くさい。非同期でStdOutを読み込むとかすると絶望的に面倒くさい、うえに罠だらけでヤバい。この辺の辛さは非同期外部プロセス起動で標準出力を受け取る際の注意点という記事でしっかり紹介されてますが、実際これを正しくハンドリングするのは難儀です。

そこで「シェルを書くように文字列一行投げるだけで結果を」「C# 8.0の非同期ストリームで、こちらも一行await foreachするだけで受け取れて」「ExitCodeやStdErrorなども適切にハンドリングする」ライブラリを作りました。

using Cysharp.Diagnostics; // using namespace
 
// async iterate.
await foreach (string item in ProcessX.StartAsync("dotnet --info"))
{
    Console.WriteLine(item);
}

というように、 await foreach(... in ProcessX.StartAsync(command)) だけで済みます。普通にProcessで書くと30行ぐらいかかってしまう処理がたった一行で!革命的便利さ!C# 8.0万歳!

実際これ、普通のProcessで書くと中々のコード量になります。

var pi = new ProcessStartInfo
{
    // FileNameとArgumentsが別れる(地味にダルい)
    FileName = "dotnet",
    Arguments = "--info",
    // からの怒涛のboolフラグ
    UseShellExecute = false,
    CreateNoWindow = true,
    ErrorDialog = false,
    RedirectStandardError = true,
    RedirectStandardOutput = true,
};
 
using (var process = new Process()
{
    StartInfo = pi,
    // からのここにもboolフラグ
    EnableRaisingEvents = true
})
{
    process.OutputDataReceived += (sender, e) =>
    {
        // nullが終端なのでnullは来ます!のハンドリングは必須
        if (e.Data != null)
        {
            Console.WriteLine(e.Data);
        }
    };
 
    process.Exited += (sender, e) =>
    {
        // ExitCode使ってなにかやるなら
        // ちなみにExitedが呼ばれてもまだOutputDataReceivedが消化中の場合が多い
        // そのため、Exitと一緒にハンドリングするなら適切な待受コードがここに必要になる
    };
 
    process.Start();
 
    // 何故かStart後に明示的にこれを呼ぶ必要がある
    process.BeginOutputReadLine();
 
    // processがDisposeした後にProcess関連のものを触ると死
    // そして↑のようにイベント購読しているので気をつけると触ってしまうので気をつけよう
    // ↓でもWaitForExitしちゃったら結局は同期じゃん、ということで真の非同期にするには更にここから工夫が必要
    process.WaitForExit();
}

↑のものでも少し簡略化しているぐらいなので、それぞれより正確にハンドリングしようとすると相当、厳しい、です……。

さて、await foreachの良いところは例外処理にtry-catchがそのまま使えること、というわけで、ExitCodeが0以外(或いはStdErrorを受信した場合)には、ProcessErrorExceptionが飛んでくるという仕様になっています。

try
{
    await foreach (var item in ProcessX.StartAsync("dotnet --foo --bar")) { }
}
catch (ProcessErrorException ex)
{
    // int .ExitCode
    // string[] .ErrorOutput
    Console.WriteLine(ex.ToString());
}

WaitForExit的に、同期待ちして結果を全部取りたいという場合は、ToTaskが使えます。

// receive buffered result(similar as WaitForExit).
string[] result = await ProcessX.StartAsync("dotnet --info").ToTask();

キャンセルに関しては非同期ストリームのWithCancellationがそのまま使えます。キャンセル時にプロセスが残っている場合はキャンセルと共にKillして確実に殺します。

await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cancellationToken))
{
    Console.WriteLine(item);
}

タイムアウトは、CancellationTokenSource自体が時間と共に発火というオプションがあるので、それを使えます。

using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
    await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cts.Token))
    {
        Console.WriteLine(item);
    }
}

また、ProcessX.StartAsyncのオーバーロードとして、作業ディレクトリや環境変数、エンコードを設定できるものも用意しているので、ほとんどのことは問題なく実装できるはずです。

StartAsync(string command, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(string fileName, string? arguments, string? workingDirectory = null, IDictionary<string, string>? environmentVariable = null, Encoding? encoding = null)
StartAsync(ProcessStartInfo processStartInfo)
 
Task<string[]> ToTask(CancellationToken cancellationToken = default)

System.Threading.Channels

今回、ProcessのイベントとAsyncEnumeratorとのデータの橋渡しにはSystem.Threading.Channelsを使っています。詳しくはAn Introduction to System.Threading.Channels、或いは日本語だとSystem.Threading.Channelsを使うを読むと良いでしょう。

プロデューサー・コンシューマーパターンのためのライブラリなのですが、めっちゃ便利です。シンプルなインターフェイス(ちょっと弄ってれば使い方を理解できる)かつasync/awaitビリティがめっちゃ高い設計になっていて、今まで書きづらかったものがサクッと書けるようになりました。

これはめちゃくちゃ良いライブラリなのでみんな使いましょう。同作者による System.Threading.Tasks.DataFlow(TPL Dataflow) は全く好きじゃなくて全然使うことはなかったのですが、Channelsは良い。めっちゃ使う。

まとめ

Process自体は、C# 1.0世代(10年前!)に設計されたライブラリなのでしょうがないという側面もありつつも、やはり現代は現代なので、ちゃんと現代流に設計したものを再度提供してあげる価値はあるでしょう。設計技法も言語自体も、遥かに進化しているので、ちゃんとしたアップデートは必要です。

こうした隙間産業だけど、C#に今までなくて面倒で、でもそれが一気に解消して超絶便利に、というのはConsoleAppFrameworkに通じるものがあります。C#の面倒と思えるところを片っ端から潰して超絶便利言語にしていく、ことを目指して引き続きどしどし開発していきます。というわけで是非使ってみてください。

Comment (2)

Sss : (01/30 16:27)

バイナリを出力する外部プロセスに適用するのはProcessXのカバー範囲外ですかね?(byte[]を返すような)

neuecc : (01/31 03:04)

なるほど!
読めたほうが良いと思うので、StartReadBinaryAsyncというのを足してみました。
`byte[] bin = await ProcessX.StartReadBinaryAsync($”…”);`
という感じですね。

Name
WebSite(option)
Comment

Trackback(0) | http://neue.cc/2020/01/30_590.html/trackback

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for Developer Technologies(C#)

April 2011
|
July 2020

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc