ConsoleAppFramework v3 - より強化されたC#のためのコマンドラインツール用フレームワーク

.NET 5も控えていることだし、というのは関係ないのですが、CLIアプリケーションや大量のバッチをC#で簡単に作れるフレームワークであるところのConsoleAppFrameworkを思い立って更新しました。

基本的な構成である、Generic Hostの上に乗っかるCLIフレームワークというコンセプトには変更ありません。

メソッド定義がそのままコマンドライン引数になって、ヘルプなども自動生成してくれます。Host(ASP.NET Coreなどでも使う)の設定によってロガーやDIの設定、オプションの読み込みとバインディングも可能なので、細かいコンフィグレーションもそれで行えますし、基盤が一緒なためASP.NET Coreなどとの共通化なども可能になります。

一番単純な例を出すとこんな感じになります。

public class Program : ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }

    public void Hello([Option("m", "Message to display.")]string message)
    {
        Console.WriteLine("Hello " + message);
    }
}
> SampleApp.exe help

Usage: SampleApp [options...]

Options:
  -m, -message <String>    Message to display. (Required)

Commands:
  help          Display help.
  version       Display version.

> SampleApp.exe -m World
Hello World

今回の変更内容は

  • 厳密っぽいオプション引数指定
  • version, helpコマンドをデフォルトでヘルプ表示
  • class/methodによる自動コマンド定義を class method コマンド引数で実行可能に(以前はClass.Methodだった)
  • Interceptorを廃止してFilterによる拡張

Interceptorの廃止だけが破壊的変更で、それ以外は互換性取れています。

厳密っぽいオプション引数指定

厳密っぽいというか、 -i, --input のようにショート版の名前を-、ロング版の名前を--で一致を見るスタイルを適用可能にしました。デフォルトは-の数を無視します、つまり-inputでも--inputでも-----inputでも同じ扱いにしています。これ区別するの面倒くさいなーと思っていて、例えばgoのコマンドは全て-o, -outputみたいな-だけで済ませていて、私もそれでいいじゃん、むしろそれがいいじゃん、と思ってはいるのですが(なのでデフォルトはそう)、区別したい人も世の中には大勢いるとは思うので、そーいうオプションを足しました。

public class Program : ConsoleAppBase
{
    static async Task Main(string[] args)
    {
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args, new ConsoleAppOptions
        {
            StrictOption = true, // default is false.
            ShowDefaultCommand = false, // default is true
        });
    }

    public void Hello([Option("m", "Message to display.")]string message)
    {
        Console.WriteLine(message);
    }
}
> SampleApp.exe help

Usage: SampleApp [options...]

Options:
  -m, --message <String>    Message to display. (Required)

デフォルトが -m, --message <String>だったhelpが、 -m, --message になっています。-messageという指定をすると、名前が合わないというエラーが出るようになります。

また、version, helpコマンドがデフォルトでヘルプ表示されるように今回からなりました。これもオプションで ShowDefaultCommand = false にすれば表示されなくなります(表示されなくなるだけで、コマンドとして存在はしています)。

class/methodによる自動コマンド定義

プロジェクトに沿ったバッチを作成する場合に、数十、時に数百個のバッチを作る必要があります。そうなると一々コマンド定義をしてる場合じゃねえ、という感じなので、自動でルーティングしてくれる機能がConsoleAppFrameworkにはあります。 MVCフレームワークがclass/methodでURLルーティングするのと同様に、class methodというサブコマンド階層を自動で生成してくれます。

class Program
{
    static async Task Main(string[] args)
    {
        // <T>を指定しないとアセンブリ全体から実行コマンドとなるクラスを検索して登録する
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args);
    }
}

public class Foo : ConsoleAppBase
{
    public void Echo(string msg)
    {
        Console.WriteLine(msg);
    }

    public void Sum([Option(0)]int x, [Option(1)]int y)
    {
        Console.WriteLine((x + y).ToString());
    }
}

public class Bar : ConsoleAppBase
{
    public void Hello2()
    {
        Console.WriteLine("H E L L O");
    }
}
> SampleApp.exe help
Usage: SampleApp <Command>

Commands:
  foo echo
  foo sum
  bar hello2
  help          Display help.
  version       Display version.

> SampleApp.exe foo sum 10 30
40

前のバージョンでは "Foo.Sum" というコマンド名での呼び出しだったのですが、それはコマンドラインツールとして不自然だろう、ということで、小文字の "class method" で実行されるようになりました。互換性のために "Foo.Sum"といった指定でも実行可能です。

Filterによる拡張

ASP.NET Core や MagicOnion のように、フィルターによって実行前後を拡張できるようになりました。実装はConsoleAppFilterを継承して、await nextを実行するという非同期スタイルです。

public class MyFilter : ConsoleAppFilter
{
    // Filter is instantiated by DI so you can get parameter by constructor injection.

    public async override ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
    {
        try
        {
            /* on before */
            await next(context); // next
        }
        catch
        {
            /* on after */
            throw;
        }
        finally
        {
            /* on finally */
        }
    }
}

// ConsoleAppContext
public class ConsoleAppContext
{
    public string?[] Arguments { get; }
    public DateTime Timestamp { get; }
    public CancellationToken CancellationToken { get; }
    public ILogger<ConsoleAppEngine> Logger { get; }
    public MethodInfo MethodInfo { get; }
    public IServiceProvider ServiceProvider { get; }
    public IDictionary<string, object> Items { get; }
}

フィルターはグローバル(全てのメソッドで呼ばれる)、クラス、メソッド単位で付与することが可能です。

// フィルターの呼び出し順序はOrderで設定可能
await Host.CreateDefaultBuilder()
    .RunConsoleAppFrameworkAsync(args, options: new ConsoleAppOptions
    {
        GlobalFilters = new ConsoleAppFilter[] { new MyFilter2 { Order = -1 }, new MyFilter() }
    });

[ConsoleAppFilter(typeof(MyFilter3))]
public class MyBatch : ConsoleAppBase
{
    [ConsoleAppFilter(typeof(MyFilter4), Order = -9999)]
    [ConsoleAppFilter(typeof(MyFilter5), Order = 9999)]
    public void Do()
    {
    }
}

まとめ

シンプルさと機能性のバランスがうまくとれてるんじゃないでしょうか。すごく細かい調整ができるわけではないので、そこはどうしても割り切りという感じになってしまうのですが、それでもほとんどのユースケースは満たせているんじゃないかと思います。

自動コマンド定義は大量にバッチを量産する場合に便利、でもあるのですが、それと同時にC#のプロジェクト一つで大量のバッチを管理できるようになる、というのも利点です。ファイル単位で管理するとわけわからん、ということになりがちですが、これなら綺麗に整理されますし、ロジックのメソッド化などで共通化もできます。また、フィルターを活用することによっても前処理や後処理などの共通化をより推し進められるでしょう。

大きなプロジェクトの一部としてのバッチアプリの場合、ASP.NET Coreなどのコンフィグに定義されているDBのパスなどが、同じジェネリックホストなのでそのまま読み込めるのも楽になれるポイントです。ロガーのパフォーマンスが必要な場合は、 Cysharp/ZLoggerを使うと良いでしょう、ZLoggerも Microsoft.Extensions.Logging の上に構築されているので、ジェネリックホストが基盤になっているConsoleAppFrameworkではスムーズに使えます。

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.SetMinimumLevel(LogLevel.Trace);
        x.AddZLoggerConsole();
        x.AddZLoggerFile("fileName.log");
    })
    .RunConsoleAppFrameworkAsync(args);

と、いうわけでより強力になったConsoleAppFramework、是非使ってみてください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

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

Twitter:@neuecc GitHub:neuecc

Archive