System.CommandLine で引数をハンドラーにバインドする方法

重要

System.CommandLine は現在プレビュー段階であり、このドキュメントはバージョン 2.0 beta 4 を対象としています。 一部の情報は、リリース前に大きく変更される可能性があるプレリリースされた製品に関するものです。 Microsoft は、ここに記載されている情報について、明示または黙示を問わず、一切保証しません。

引数を解析し、コマンド ハンドラー コードに渡す処理を "パラメーター バインド" と呼びます。 System.CommandLine には、多くの組み込みの引数型をバインドする機能があります。 たとえば、FileInfoDirectoryInfo のような整数、列挙型、ファイル システム オブジェクトをバインドできます。 一部の System.CommandLine 型もバインドできます。

組み込み引数の検証

引数には、期待される型とアリティがあります。 System.CommandLine によって、その期待に一致しない引数が拒否されます。

たとえば、整数オプションの引数が整数でない場合は、解析エラーが表示されます。

myapp --delay not-an-int
Cannot parse argument 'not-an-int' as System.Int32.

最大アリティが 1 であるオプションに複数の引数を渡すと、アリティ エラーが表示されます。

myapp --delay-option 1 --delay-option 2
Option '--delay' expects a single argument but 2 were provided.

この動作は、Option.AllowMultipleArgumentsPerTokentrue に設定することでオーバーライドできます。 この場合、最大アリティが 1 のオプションを繰り返すことはできますが、その行の最後の値のみが受け入れられます。 次の例では、値 three がアプリに渡されます。

myapp --item one --item two --item three

最大 8 個のオプションと引数をバインドするパラメーター

次の例は、SetHandler を呼び出して、オプションをコマンド ハンドラーのパラメーターにバインドする方法を示しています。

var delayOption = new Option<int>
    ("--delay", "An option whose argument is parsed as an int.");
var messageOption = new Option<string>
    ("--message", "An option whose argument is parsed as a string.");

var rootCommand = new RootCommand("Parameter binding example");
rootCommand.Add(delayOption);
rootCommand.Add(messageOption);

rootCommand.SetHandler(
    (delayOptionValue, messageOptionValue) =>
    {
        DisplayIntAndString(delayOptionValue, messageOptionValue);
    },
    delayOption, messageOption);

await rootCommand.InvokeAsync(args);
public static void DisplayIntAndString(int delayOptionValue, string messageOptionValue)
{
    Console.WriteLine($"--delay = {delayOptionValue}");
    Console.WriteLine($"--message = {messageOptionValue}");
}

ラムダ パラメーターは、オプションと引数の値を表す変数です。

(delayOptionValue, messageOptionValue) =>
{
    DisplayIntAndString(delayOptionValue, messageOptionValue);
},

ラムダの後に続く変数は、オプションと引数の値のソースであるオプションと引数のオブジェクトを表します。

delayOption, messageOption);

オプションと引数は、ラムダとそれに続くパラメーター内で同じ順序で宣言する必要があります。 順序が不統一な場合、次のいずれかのシナリオが発生します。

  • 順序が異なるオプションまたは引数の型が異なる場合は、実行時例外がスローされます。 たとえば、ソースの一覧で string があるはずの位置に int が表示されることがあります。
  • 順序が異なるオプションまたは引数の型が同じ場合は、エラーなしで、パラメーターの誤った値がハンドラーから渡されます。 たとえば、ソースの一覧に string オプション y があるはずの位置に string オプション x が表示される場合があります。 この場合、オプション y 値の変数はオプション x 値を受け取ります。

最大 8 個のパラメーターをサポートする SetHandler のオーバーロードがあり、同期と非同期の両方のシグネチャがあります。

8 個を超えるオプションと引数をバインドするパラメーター

8 個を超えるオプションを処理する場合、または複数のオプションからカスタム型を構築する場合は、InvocationContext またはカスタム バインダーを使用できます。

InvocationContext を使用します

SetHandler オーバーロードは、InvocationContext オブジェクトへのアクセスを提供します。InvocationContext を使用して、任意の数のオプションと引数値を取得できます。 例については、「終了コードを設定する」およびハンドルの終了に関する記事を参照してください。

カスタム バインダーを使用する

カスタム バインダーを使うと、複数のオプションや引数の値を複合型にまとめ、それを 1 つのハンドラー パラメーターに渡すことができます。 たとえば Person 型があるとします。

public class Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
}

BinderBase<T> から派生したクラスを作成しします。この T はコマンド ライン入力に基づいて構築する型です。

public class PersonBinder : BinderBase<Person>
{
    private readonly Option<string> _firstNameOption;
    private readonly Option<string> _lastNameOption;

    public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
    {
        _firstNameOption = firstNameOption;
        _lastNameOption = lastNameOption;
    }

    protected override Person GetBoundValue(BindingContext bindingContext) =>
        new Person
        {
            FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
            LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
        };
}

カスタム バインダーを使うと、オプションと引数の値を取得するのと同じ方法で、カスタム型をハンドラーに渡すことができます。

rootCommand.SetHandler((fileOptionValue, person) =>
    {
        DoRootCommand(fileOptionValue, person);
    },
    fileOption, new PersonBinder(firstNameOption, lastNameOption));

完成したプログラムは次のとおりです。前述の例は、ここから引用したものです。

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

public class Program
{
    internal static async Task Main(string[] args)
    {
        var fileOption = new Option<FileInfo?>(
              name: "--file",
              description: "An option whose argument is parsed as a FileInfo",
              getDefaultValue: () => new FileInfo("scl.runtimeconfig.json"));

        var firstNameOption = new Option<string>(
              name: "--first-name",
              description: "Person.FirstName");

        var lastNameOption = new Option<string>(
              name: "--last-name",
              description: "Person.LastName");

        var rootCommand = new RootCommand();
        rootCommand.Add(fileOption);
        rootCommand.Add(firstNameOption);
        rootCommand.Add(lastNameOption);

        rootCommand.SetHandler((fileOptionValue, person) =>
            {
                DoRootCommand(fileOptionValue, person);
            },
            fileOption, new PersonBinder(firstNameOption, lastNameOption));

        await rootCommand.InvokeAsync(args);
    }

    public static void DoRootCommand(FileInfo? aFile, Person aPerson)
    {
        Console.WriteLine($"File = {aFile?.FullName}");
        Console.WriteLine($"Person = {aPerson?.FirstName} {aPerson?.LastName}");
    }

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }

    public class PersonBinder : BinderBase<Person>
    {
        private readonly Option<string> _firstNameOption;
        private readonly Option<string> _lastNameOption;

        public PersonBinder(Option<string> firstNameOption, Option<string> lastNameOption)
        {
            _firstNameOption = firstNameOption;
            _lastNameOption = lastNameOption;
        }

        protected override Person GetBoundValue(BindingContext bindingContext) =>
            new Person
            {
                FirstName = bindingContext.ParseResult.GetValueForOption(_firstNameOption),
                LastName = bindingContext.ParseResult.GetValueForOption(_lastNameOption)
            };
    }
}

終了コードを設定する

SetHandlerFunc オーバーロードを返す Task があります。 ハンドラーが非同期コードから呼び出される場合、次の例のように、これらのいずれかを使うハンドラーから Task<int> を返し、int 値を使ってプロセスの終了コードを設定できます。

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler((delayOptionValue, messageOptionValue) =>
        {
            Console.WriteLine($"--delay = {delayOptionValue}");
            Console.WriteLine($"--message = {messageOptionValue}");
            return Task.FromResult(100);
        },
        delayOption, messageOption);

    return await rootCommand.InvokeAsync(args);
}

ただし、ラムダ自体を非同期にする必要がある場合は、Task<int> を返すことはできません。 その場合は、InvocationContext.ExitCode を使います。 InvocationContext を唯一のパラメーターとして指定する SetHandler オーバーロードを使用することで、ラムダに挿入された InvocationContext インスタンスを取得できます。 この SetHandler オーバーロードでは、IValueDescriptor<T> オブジェクトを指定できません。ただし、次の例に示すように、InvocationContextParseResult プロパティからオプションと引数値を取得できます。

static async Task<int> Main(string[] args)
{
    var delayOption = new Option<int>("--delay");
    var messageOption = new Option<string>("--message");

    var rootCommand = new RootCommand("Parameter binding example");
    rootCommand.Add(delayOption);
    rootCommand.Add(messageOption);

    rootCommand.SetHandler(async (context) =>
        {
            int delayOptionValue = context.ParseResult.GetValueForOption(delayOption);
            string? messageOptionValue = context.ParseResult.GetValueForOption(messageOption);
        
            Console.WriteLine($"--delay = {delayOptionValue}");
            await Task.Delay(delayOptionValue);
            Console.WriteLine($"--message = {messageOptionValue}");
            context.ExitCode = 100;
        });

    return await rootCommand.InvokeAsync(args);
}

非同期処理を行わない場合は、Action のオーバーロードを使用できます。 その場合は、非同期ラムダと同じように InvocationContext.ExitCode を使って終了コードを設定します。

終了コードの既定値は 1 です。 明示的に設定しない場合、ハンドラーが正常に終了したときにその値は 0 に設定されます。 例外がスローされた場合は、既定値のままになります。

サポートされている型

よく使われるいくつかの型をバインドするコードの例を次に示します。

列挙型

enum 型の値は名前でバインドによってバインドされます。バインドでは大文字と小文字が区別されません。

var colorOption = new Option<ConsoleColor>("--color");

var rootCommand = new RootCommand("Enum binding example");
rootCommand.Add(colorOption);

rootCommand.SetHandler((colorOptionValue) =>
    { Console.WriteLine(colorOptionValue); },
    colorOption);

await rootCommand.InvokeAsync(args);

前述の例のサンプル コマンドライン入力と、その結果の出力は次のとおりです。

myapp --color red
myapp --color RED
Red
Red

配列と一覧

IEnumerable を実装する多くの一般的な型がサポートされています。 次に例を示します。

var itemsOption = new Option<IEnumerable<string>>("--items")
    { AllowMultipleArgumentsPerToken = true };

var command = new RootCommand("IEnumerable binding example");
command.Add(itemsOption);

command.SetHandler((items) =>
    {
        Console.WriteLine(items.GetType());

        foreach (string item in items)
        {
            Console.WriteLine(item);
        }
    },
    itemsOption);

await command.InvokeAsync(args);

前述の例のサンプル コマンドライン入力と、その結果の出力は次のとおりです。

--items one --items two --items three
System.Collections.Generic.List`1[System.String]
one
two
three

AllowMultipleArgumentsPerTokentrue に設定しているので、次の入力は同じ出力になります。

--items one two three

ファイル システムの種類

ファイル システムを使用するコマンド ライン アプリケーションでは、FileSystemInfoFileInfoDirectoryInfo 型を使用でききます。 FileSystemInfo を使う例を次に示します。

var fileOrDirectoryOption = new Option<FileSystemInfo>("--file-or-directory");

var command = new RootCommand();
command.Add(fileOrDirectoryOption);

command.SetHandler((fileSystemInfo) =>
    {
        switch (fileSystemInfo)
        {
            case FileInfo file                    :
                Console.WriteLine($"File name: {file.FullName}");
                break;
            case DirectoryInfo directory:
                Console.WriteLine($"Directory name: {directory.FullName}");
                break;
            default:
                Console.WriteLine("Not a valid file or directory name.");
                break;
        }
    },
    fileOrDirectoryOption);

await command.InvokeAsync(args);

FileInfoDirectoryInfo では、パターン マッチング コードは必要ありません。

var fileOption = new Option<FileInfo>("--file");

var command = new RootCommand();
command.Add(fileOption);

command.SetHandler((file) =>
    {
        if (file is not null)
        {
            Console.WriteLine($"File name: {file?.FullName}");
        }
        else
        {
            Console.WriteLine("Not a valid file name.");
        }
    },
    fileOption);

await command.InvokeAsync(args);

サポートされている他の型

1 つの文字列パラメーターを受け取るコンストラクターを持つ多くの型は、この方法でバインドできます。 たとえば、FileInfo で動作するコードは、代わりに Uri で動作します。

var endpointOption = new Option<Uri>("--endpoint");

var command = new RootCommand();
command.Add(endpointOption);

command.SetHandler((uri) =>
    {
        Console.WriteLine($"URL: {uri?.ToString()}");
    },
    endpointOption);

await command.InvokeAsync(args);

ファイル システムの型と Uri の他に、次の型がサポートされています。

  • bool
  • byte
  • DateTime
  • DateTimeOffset
  • decimal
  • double
  • float
  • Guid
  • int
  • long
  • sbyte
  • short
  • uint
  • ulong
  • ushort

System.CommandLine オブジェクトを使用する

InvocationContext オブジェクトへのアクセスを付与する SetHandler オーバーロードがあります。 このオブジェクトを使用すると、他の System.CommandLine オブジェクトにアクセスできます。 たとえば、次のオブジェクトにアクセスできます。

InvocationContext

例については、「終了コードを設定する」およびハンドルの終了に関する記事を参照してください。

CancellationToken

CancellationToken の使用方法については、「CancellationToken」を参照してください。

IConsole

IConsole を使うと、多くの拡張性シナリオをテストできるだけでなく、System.Console よりも使い方が簡単です。 これは InvocationContext.Console プロパティで使用可能です。

ParseResult

ParseResult オブジェクトは InvocationContext.ParseResult プロパティで使用可能です。 これは、コマンド ライン入力の解析結果を表すシングルトン構造体です。 これを使って、コマンド ライン上にオプションまたは引数が存在するかどうかを確認することや、ParseResult.UnmatchedTokens プロパティを取得することができます。 このプロパティにはトークンの一覧が含まれています。解析はされましたが、構成されているコマンド、オプション、引数のいずれにも一致しなかったものです。

一致しなかったトークンの一覧は、ラッパーのように動作するコマンドで役に立ちます。 ラッパー コマンドはトークンのセットを受け取り、別のコマンドまたはアプリに転送します。 Linux の sudo コマンドはその一例です。 なりすますユーザーの名前に続いて、実行するコマンドを受け取ります。 次に例を示します。

sudo -u admin apt update

このコマンド ラインは、ユーザー admin として apt update コマンドを実行します。

このようなラッパー コマンドを実装するには、コマンド プロパティ TreatUnmatchedTokensAsErrorsfalse に設定します。 そうすると、ParseResult.UnmatchedTokens プロパティには、明示的にコマンドに属していないすべての引数が含まれます。 前述の例では、ParseResult.UnmatchedTokens には aptupdate のトークンが含まれます。 コマンド ハンドラーからは、たとえば UnmatchedTokens を新しいシェル呼び出しに転送できます。

カスタムの検証とバインド

カスタム検証コードを指定するには、次の例に示すように、コマンド、オプションまたは引数で AddValidator を呼び出します。

var delayOption = new Option<int>("--delay");
delayOption.AddValidator(result =>
{
    if (result.GetValueForOption(delayOption) < 1)
    {
        result.ErrorMessage = "Must be greater than 0";
    }
});

入力の解析だけでなく検証も行う場合は、次の例のように ParseArgument<T> のデリゲートを使います。

var delayOption = new Option<int>(
      name: "--delay",
      description: "An option whose argument is parsed as an int.",
      isDefault: true,
      parseArgument: result =>
      {
          if (!result.Tokens.Any())
          {
              return 42;
          }

          if (int.TryParse(result.Tokens.Single().Value, out var delay))
          {
              if (delay < 1)
              {
                  result.ErrorMessage = "Must be greater than 0";
              }
              return delay;
          }
          else
          {
              result.ErrorMessage = "Not an int.";
              return 0; // Ignored.
          }
      });

前述のコードでは isDefaulttrue に設定し、ユーザーがこのオプションに値を入力しなかった場合でも parseArgument のデリゲートが呼び出されるようにしています。

ParseArgument<T> ではできて、AddValidator ではできないことの例をいくつか示します。

  • 次の例の Person クラスのような、カスタム型の解析。

    public class Person
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }
    
    var personOption = new Option<Person?>(
          name: "--person",
          description: "An option whose argument is parsed as a Person",
          parseArgument: result =>
          {
              if (result.Tokens.Count != 2)
              {
                  result.ErrorMessage = "--person requires two arguments";
                  return null;
              }
              return new Person
              {
                  FirstName = result.Tokens.First().Value,
                  LastName = result.Tokens.Last().Value
              };
          })
    {
        Arity = ArgumentArity.OneOrMore,
        AllowMultipleArgumentsPerToken = true
    };
    
  • 他の種類の入力文字列の解析 (たとえば、"1,2,3" を int[] に解析するなど)。

  • 動的アリティ。 たとえば、文字列配列として定義した 2 つの引数があり、コマンド ライン入力で一連の文字列を処理する必要があるとします。 ArgumentResult.OnlyTake メソッドを使うと、引数間の入力文字列を動的に分割できます。

関連項目

System.CommandLine の概要