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#の面倒と思えるところを片っ端から潰して超絶便利言語にしていく、ことを目指して引き続きどしどし開発していきます。というわけで是非使ってみてください。

Unityによるリアルタイム通信とMagicOnionによるC#大統一理論の実現 - フォローアップ

先週の土曜日にUnity道場 京都スペシャル4というイベントで登壇してきました。関西にはめったに行かないので、良い機会を頂いて感謝です。参加者応募も231名、場所もかなり大きなホールでいい感じでした。また、主催されたクラウドクリエイティブスタジオさんはサーバー開発もC#でしてる企業さんでもありますね……!すばらすばら。

【Unity道場京都スペシャル4】Unityによるリアルタイム通信とMagicOnionによるC#大統一理論の実現 from UnityTechnologiesJapan002

動画もUnity Learning Material(YouTube)に公開されています。

Unity……?まぁ、Unity、です、ええ。どちらかというと、MagicOnionが何を目指しているのか、みたいなところを説明できたんじゃないかなー、と思ってます。色々、思っているところを入れました。

このスライドを踏まえて、更に今後考えていること、というか「ハードコアを緩和する」というのが当分のテーマなのですが、なにやるか、というと……RPC以外の便利コンポーネントを作る、という意味ではありません。

あまり便利コンポーネントには関心がなくて、というのもどうせ無駄ばっかでパフォーマンスでないから使わん、とかになるんで、それだといらないなー、と。何のかんので私は割と理想主義者なので、良いものを作るための道具を提供したい、という思いがあります。性能の追求とかもその一環ですよね。というわけで、そこに反するものはちょっとねー、と。そこはアプリケーション実装側の責務だと思うので、自分で作り込んで欲しい……!

導入のヘヴィさやインフラ側は緩和していきたいです。特に、現在ネックになっているのがネイティブgRPCなので、それを引っ剥がしたいと思ってます。これを引っ剥がすと、つまり私の方で提供するPure C#なHTTP/2, gRPC実装に置き換えることでクライアント側は完全にプラットフォームフリー!サイズも低減!依存も消滅!そして完全なチューニングが可能になる!サーバー側はMicrosoft実装の ASP.NET Coreによるgrpc-dotnetベースに置き換えます。そうすると、実は通信層が自由に置き換えられるようになるので、TCPだけじゃなくてQUIC(これは実際、MicrosoftがExperimentalな実装をやってる最中なのでそれをすぐ投下できる)や、RUDPとかを入れ込むこともできます。

インフラ周りは、特にKuberenetes + Dedicated Server的に使うと、プラクティスがなさすぎて死にます。これはAgonesというGoogleの開発しているKuberenetes用のミドルウェアで解決すると思ってるんですが、現状だとまだ厳しいんですねー。というわけでAgonesにIssue立てたりもしてるんですが、さてはて。というわけでまだもう少し大変です。

それとアーキテクチャ的に、まずはRPCになってるのですが、これをサーバーループによる駆動に変換するためのブリッジ層を作り込みたいかなあ、と。現状でも自作すればできる状態なんですが、このぐらいは標準で用意してあげたほうがすわりがよさそうだ、と。

理想的な状態までの絵図は描けていますし、かなりいいところまでは来てると思ってます。ので、あともう一歩強化できれば、というところなのでやっていきます、はい。

ConsoleAppFramework - .NET Coreコンソールアプリ作成のためのマイクロフレームワーク(旧MicroBatchFramework)

以前にMicroBatchFramework - クラウドネイティブ時代のC#バッチフレームワークという名前でリリースしていたライブラリですが、リブランディング、ということかでConsoleAppFrameworkに変更しました。それに伴い名前変更による多数の破壊的変更と、全体の挙動の調整を行っています。

当初の想定ではバッチ、特に機能紹介にあるMulti Batchをメイン機能と捉えて作っていたのですが、最終的には汎用的なコンソールアプリケーション用のフレームワークとして出来上がっていたので、より適正な名前にすることで、多くの人に正しく捉えてもらって、届けられるのではないかと思い、今回の変更に至りました。

といったように、 Microsoft.Extensions の仕組みに乗ってLogging, Configuration, DIなどをカバーしつつ、CLI用にパラメーターバインディング、メソッドルーティング、ライフサイクル管理を乗っけているのがConsoleAppFrameworkの意義となります。一度使ってもらえば、もう素のConsoleAppを作ることはなくなります、というぐらいには便利なのではないかと……!

同様のコンセプトとしては、PHPではLaravel Zeroという、Micro-framework for console applicationsがあります。Laravelのロギングやコンフィグレーションを共有しつつも、コマンドラインアプリケーションで使いやすいような処理が施されています。Microsoftによる実装ではdotnet/command-line-apiの System.CommandLine.Hosting + System.CommandLine.DragonFruit が近い機能を持っていますが、ConsoleAppFrameworkのほうがよりプロダクティビティが高いです。というかMSのはダメです。こういうの作るのにMicrosoftはセンスないんですよねー。

あらためてConsoleAppFrameworkの単純な例ですが

using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
using System;
using System.Threading.Tasks;

// Entrypoint, create from the .NET Core Console App.
class Program : ConsoleAppBase // inherit ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        // target T as ConsoleAppBase.
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }

    // allows void/Task return type, parameter is automatically binded from string[] args.
    public void Run(string name, int repeat = 3)
    {
        for (int i = 0; i < repeat; i++)
        {
            Console.WriteLine($"Hello My ConsoleApp from {name}");
        }
    }
}

> SampleApp.exe -name "foo" -repeat 5.

といったように、Mainに毛が生えた程度の記述だけで、気の利いたコマンドラインアプリケーションが作れます。今回からヘルプのフォーマットに気合いを入れているので、

public void Run(
    [Option("n", "name of send user.")]string name, 
    [Option("r", "repeat count.")]int repeat = 3)
{
    // ...
}

といったようにいい感じのショートカットと説明を属性で追加してあげると、

> SampleApp.exe help
Usage: SampleApp [options...]

Options:
  -n, -name <String>     name of send user. (Required)
  -r, -repeat <Int32>    repeat count. (Default: 3)

いい感じのhelpが表示されるようになりました。ちなみにこれは dotnet コマンドのフォーマットに近いものです。

.NET Core 3.0からはランタイム不要での単一ファイルのバイナリ作成がやっとできるようになったので、配布もよりやりやすくなりました。また、パッケージマネージャー経由での .NET Core Global Toolsという仕組み(.NET Core 2.1から)や、プロジェクト単位で設定してバージョン固定などがやりやすい.NET Core Local Tools(.NET Core 3.0から)といった仕組みも整備されているので、かなりいけてます。

また、ConsoleAppFrameworkの持つ複数のバッチ(コマンド/メソッド)を単一アプリケーションで管理する実行可能にする機能は、プロジェクト固有のバッチ(大量にあるはず!)やインフラ管理スクリプトなどを一本化して、CIなどではgit pull後に dotnet run command で済ませられたりするなどは、実際私自身も有用に使っています。

リブランディングについて

MicroBatchFramework、正直なところもう少しウケてもいいと思ってたんですが、あんま伸びなかったんですよねー。コンセプトは良いはずだし実際機能的にもいいのになんでー?と思ったんですが、ようは"Cloud Native Batch Framework" というのが全然ピンときてないんですよねー。Cloud Nativeとか言っておけば喰い付くだろうとかいう安易なネーミングがダメ。あとBatchってのがやっぱダメだよね。バッチ。バッチって。

というわけで、ずっと気になってたんで、結果、今回の名前変えたのは本質をより表していていいんじゃないかなー、と思いますがどうでしょう?ReadMeも全体的に見直して、ウケる雰囲気になったと思うので、これでもう一発逆転狙いたいです(?)

それと、こういう名前変えるみたいなのも決断の一種なわけですが、名前を変えること自体は誰でもできるし、変えた名前も安易で誰でも決めれるわけですし、実際に変えてみるとピタッとピースがはまったように見える。けれど、じゃあいざ変えましょう、と踏み出すのはとてもむずかしい。というわけで、あ、シャッチョさん仕事したな、みたいな気になりました。まる。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive