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>
の役割は引数をうまいことしてコードに渡す、なので、なんか違う気がしてこんな感じになりました。
おしまい。