なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

System.CommandLine の Handler で DI 使いつつ InvocationContext もほしい

System.CommandLine という Microsoft による .NET のコマンドラインライブラリがあります。
これを使うと (一生プレビュー版ではあるものの) わりと簡単にコマンドラインアプリを作れるのですが、今回はそれについての記事です。

各サブコマンドを定義後、実際の処理はハンドラーを登録しておこうなうことになるのですが、その際パラメーターから実際の値を受け取る際、ちょっとした加工したりなどすることがあると思いますが、そんなときには BinderBase<T> 経由で DI することでスマートに出来ます。

ただし、 DI を行った場合、ハンドラーデリゲートにはパース結果などが取得できる InvocationContext が渡されなくなります。

using System.CommandLine;

var app = new RootCommand("hello world command");
app.SetHandler(async (logger) => {
   // これは Valid
}, new LoggerBinder()); // LoggerBinder は BinderBase<T> を実装したクラス

app.SetHandler(async (context, logger) => {
   // これは Invalid
   // context もほしい
}, new LoggerBinder());

個人的にはいくつかのケースで InvocationContext がほしいケースがあるので、拡張メソッドを作って InvocationContext も注入しよう、という話です。
SetHandler メソッドそのものは拡張メソッドとして提供されており、中身もシンプルなので、似た実装をすることでうまいことできそうです。

// https://github.com/dotnet/command-line-api/blob/2.0.0-beta4.22272.1/src/System.CommandLine/Handler.Func.cs#L34-L44
    public static void SetHandler<T>(
        this Command command,
        Func<T, Task> handle,
        IValueDescriptor<T> symbol) =>
        command.Handler = new AnonymousCommandHandler(
            context =>
            {
                var value1 = GetValueForHandlerParameter(symbol, context);

                return handle(value1!);
            });

ということで、実装してみましょう。
AnonymousCommandHandler はアクセスできないので、ソースを参考に必要最低限の実装だけすればいいでしょう。

using System.CommandLine.Invocation;

internal class AnonymousCommandHandler(Func<InvocationContext, Task> handler) : ICommandHandler
{
    public int Invoke(InvocationContext context)
    {
        throw new NotSupportedException();
    }

    public async Task<int> InvokeAsync(InvocationContext context)
    {
        var value = (object)handler(context);
        switch (value)
        {
            case Task<int> exitCodeTask:
                return await exitCodeTask;

            case Task task:
                await task;
                return context.ExitCode;

            case int exitCode:
                return exitCode;

            default:
                return context.ExitCode;
        }
    }
}

拡張メソッド側もシンプルです。

using System.CommandLine;
using System.CommandLine.Binding;
using System.CommandLine.Invocation;

internal static class CommandExtensions
{
    // SetHandler にすると大本の SetHandler 自体が System.CommandLine 名前空間にあるので Ambiguous Reference となりコンパイルエラーとなる
    public static void SetHandlerEx<T>(this Command command, Func<InvocationContext, T, Task> handle, IValueDescriptor<T> symbol)
    {
        command.Handler = new AnonymousCommandHandler(context =>
        {
            var value1 = GetValueForHandlerParameter(symbol, context);
            return handle(context, value1!);
        });
    }

    // 必要なら以下のように増やしていけば良いし、最悪ソースジェネレーターで自動生成すれば良い
    public static void SetHandlerEx<T1, T2>(this Command command, Func<InvocationContext, T1, T2, Task> handle, IValueDescriptor<T1> symbol1, IValueDescriptor<T2> symbol2)
    {
        command.Handler = new AnonymousCommandHandler(context =>
        {
            var value1 = GetValueForHandlerParameter(symbol1, context);
            var value2 = GetValueForHandlerParameter(symbol2, context);
            return handle(context, value1!, value2!);
        });
    }

    private static T? GetValueForHandlerParameter<T>(IValueDescriptor<T> symbol, InvocationContext context)
    {
        if (symbol is IValueSource source && source.TryGetValue(symbol, context.BindingContext, out var ret) && ret is T value)
            return value;

        return symbol switch
        {
            Argument<T> argument => context.ParseResult.GetValueForArgument(argument),
            Option<T> option => context.ParseResult.GetValueForOption(option),
            _ => throw new ArgumentOutOfRangeException()
        };
    }
}

これで、後は以下のようにすれば InvocationContext 付きの SetHandler の出来あがり。

app.SetHandlerEx(async (context, logger) => {
   var ct = context.GetCancellationToken(); // こんな感じ
}, new LoggerBinder());

まぁ正直 BinderBase<T> からぶち込めば良い気もするんですが、 BinderBase<T> の役割は引数をうまいことしてコードに渡す、なので、なんか違う気がしてこんな感じになりました。
おしまい。