ConsoleAppFramework v4 - Minimal API for CommandLine tool

皆さん .NET 6で追加されたMinimal API使ってみました?最初は別にいらんやろ、とか思ってたんですが、いや、これ正直めっちゃ凄い、いい。まぁDelegateベースで書くかどうかは別として(書かないかなー)、謎Startupを葬り去ってBuilder/Runが素直に繋がった形が美しい。Top level statementとの相性も良いので、もうこっちのAPI以外で作る気しないなあ。

さて、ところでConsoleAppFrameworkです。今までクラスが必要だったんですよね、たった一個のメソッドを実装するにも。それがTop level statementとの相性が悪い。Top level statementだけで完結できるとき、クラスって作りたくないんですよね。と、いうわけで、そろそろ大改修が必要かなーと思っていたところにMinimal APIですよ。特にその場でラムダ式でばしばしAPI作っていくスタイルは、むしろコマンドラインツールのほうがマッチするじゃんどう考えても?

と、いうわけで大改修して、Minimal APIベースになったv4、作りました。何が凄いって、一行でコマンドライン引数をパースしてハンドラー定義できちゃうんですね。

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

これは嘘偽りなくNuGetからダウンロードしたら、そのままでこう書けます。C# 10.0のglobal using(をNuGetのライブラリ側に埋め込むというEvilな手法を使ってます)と、ラムダ式の推論の向上によって実現しました。内側では、Minimal APIの実現のために Microsoft.Extensions.* 側にもかなり改修が入っていたので、それをそっくりそのまま利用できました。そういう意味で、 .NET 6になった今だからようやく作れた形になりますね。もちろんv1~v3までの蓄積のお陰というところもあります。集大成……!

さて、Runはちょっとウケ狙いなところもあるんですが、それ以外のAPIもBuilderベースになったので、だいぶ様変わりしています。ただし特徴としてGeneric Hostの上に乗っているというのは変わらないので、DbContext埋めたりappconfig.jsonから取ったりというのは、変わらずスムーズにできます。

// You can use full feature of Generic Host(same as ASP.NET Core).

var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx,services) =>
{
    // Register EntityFramework database context
    services.AddDbContext<MyDbContext>();

    // Register appconfig.json to IOption<MyConfig>
    services.Configure<MyConfig>(ctx.Configuration);

    // Using Cysharp/ZLogger for logging to file
    services.AddLogging(logging =>
    {
        logging.AddZLoggerFile("log.txt");
    });
});

var app = builder.Build();

// setup many command, async, short-name/description option, subcommand, DI
app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y));
app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) =>
{
    await Task.Delay(TimeSpan.FromSeconds(time));
});
app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'"));

// You can insert all public methods as sub command => db select / db insert
// or AddCommand<T>() all public methods as command => select / insert
app.AddSubCommands<DatabaseApp>();

app.Run();

単独のコマンドラインツール用に使ってもいいのですが、ASP.NETのウェブアプリが他にあって、それのバッチを作りたいみたいなときに、こうしたコンフィグの共通化はめっちゃ便利に使えるはずです。ConfigureServicesのコードはまんま一緒にできて、そのままDIできますからね。

また、引き続き AddCommands<T>AddAllCommandType によって、メソッド定義するだけで大量のコマンドを一括追加も可能になっています。

v3 -> v4の破壊的変更

破壊的変更、は沢山あるのですが、基本的に今までの使い方をしている場合は互換オプションで動くようにしたので、アップデートしたから壊れるということはない、はずです。v4からはConsoleApp.Create/CreateBuilder 経由で作るのが基本なのですが、v3は Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<T>() 経由なので、ちょうど互換性オプションを突っ込むのに都合が良かったんですね。なお、RunConsoleAppFrameworkAsyncはエディタから見えないようにしてます。今後は非推奨で、本当に互換のためだけに残してます。

まず変わったところは、デフォルトで長いオプション名が--、短いオプション名が-になりました。v3では-が幾つついていてもいいというゆるふわマッチングだったのですが、(dotonet toolsと同じように)厳格化しています。

また、デフォルトのコマンド/オプション名の変換ルールが単純なlower化から、hoge-hugaというlowerなkebab-caseになりました。これもdotnet tools合わせですね。

また、AddCommands<T>した場合の挙動(v3ではRunConsoleAppFrameworkAsync<T>した場合)が、全てのpublicメソッドをコマンドとして追加するようになりました。デフォルト(ルート)コマンドにしたい場合は[RootCommand]属性を付与してくださいということで。これはAddSubCommand<T>した時と挙動を合わせたかったからです、違うと一貫性がなくて戸惑うので。

と、いうわけで、互換性モードで動かした場合はConsoleAppOptionsは以下のような変更で動くようになっています。よきかなよきかな。(それとargsのコマンド名でHoge.Hugaが来てたらHoge Hugaに分解するのも、この互換性モードだけの挙動です)

options.StrictOption = false;
options.NoAttributeCommandAsImplicitlyDefault = true;
options.NameConverter = x => x.ToLower();
options.ReplaceToUseSimpleConsoleLogger = false;

そうだ、それとCtrl+Cした場合に、正しくCancellationTokenをハンドリングしていない場合でも、タイムアウトをハンドリングしてabortするようになりました。これは、なんか強制終了できなくてウゼーってなりがちというか、私自身よく引っかかってヤバかったので。むしろこれは今までがバグに近くて、正しくHostOptions.ShutdownTimeoutを処理していないせいでした。

ちなみにこのタイムアウト時間はデフォルトは5秒で、ConfigureHostOptions(地味にこれは.NET 6(というかMicrosoft.Extensionsのv6)からの新API)で変更できます。

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureHostOptions(options =>
    {
        // change timeout.
        options.ShutdownTimeout = TimeSpan.FromMinutes(30);
    })
    .Build();

まとめ

無計画にアドホックに作っていったせいで、どうにもクソコードすぎて、改修にめっちゃ手間取ったというか内部的にはほぼ作り直した……。弄るのだるくて嫌だなあと内心実際今まで思ってたんですが、やはりとても嫌なコードであった。v1の時の最初の発想が Class.Method にパラメータ分解してバッチを大量に作りたい(そもそもライブラリ名もMicroBatchFrameworkだったし)というものだけだったのが、徐々に汎用コマンドラインツールに進化していって、都度、適当に追加していった結果ではある。

今回がっつし仕切り直したので、しばらくはメンテが楽になれるかなあ、という感じで、よきかなよきかな。

まぁしかしC# 10.0は地味にヤバいですよ!使えば使うほど味が出てくるというか、最近ようやく手に馴染んで、よくわかってきた感じです。なんというか、とにかく、めっちゃいい。それとC# 10.0 + ConsoleAppFrameworkは全言語見渡しても最強のコマンドラインツール作成ライブラリじゃないです?いや、API自体のできの良さはほとんど ASP .NET CoreのMinimal APIのコピーにすぎないんですが、まぁしかしそれでもやっぱ、これはかなり良い感じじゃないかという手応えがあります。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

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

Twitter:@neuecc GitHub:neuecc

Archive