主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ
書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。
2023/10/29
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>
の役割は引数をうまいことしてコードに渡す、なので、なんか違う気がしてこんな感じになりました。
おしまい。