2019 年 3 月

Volume 34 Number 3

[.NET]

System.CommandLine を使用してコマンド ラインを解析する

Mark Michaelis | 2019 年 3 月

.NET Framework 1.0 が登場した頃、私はアプリケーションのコマンド ラインを解析するためのシンプルな方法が開発者に提供されていないことに驚きました。アプリケーションはまず Main メソッドから実行されますが、引数は配列 (string[] args) として渡され、配列内の各項目がコマンドなのか、オプションなのか、それとも引数なのかは区別されていませんでした。

私は、以前の記事 (「Microsoft のオープン ソース ソフトウェア プロジェクトに貢献する方法」(msdn.com/magazine/mt830359)) でもこの問題について触れ、Microsoft の Jon Sequeira 氏との共同の取り組みについて説明しました。Sequeira 氏はオープンソースの開発者チームのリーダーとして、新しいコマンド ライン パーサーを作成しました。これは、コマンド ライン引数を受け付け、それらを解析して System.CommandLine という API にするというものです。このパーサーでは、次の 3 つのことができます。

  • コマンド ラインの構成を行えるようにする。
  • コマンド ラインの汎用引数 (トークン) を解析して、別のコンストラクトにする。なお、コマンド ライン上の各単語がトークンです(技術的には、コマンド ライン ホストでは引用符を使用して単語を結合し、1 つのトークンにすることもできます)。
  • コマンド ラインの値に基づいて実行するよう構成された機能を呼び出す。

サポートされるコンストラクトとしては、コマンド、オプション、引数、ディレクティブ、区切り記号、エイリアスがあります。以下に、各プロパティの説明を示します。

コマンド:コマンドとは、アプリケーション コマンド ラインによってサポートされているアクションのことです。たとえば、git の場合を考えてみましょう。git の組み込みコマンドには、branch、add、status、commit、push などがあります。技術的に言うと、実行可能ファイル名の後に指定されたコマンドは、実際にはサブコマンドです。ルート コマンドのサブコマンドは、実行可能ファイル自体の名前 (たとえば、git.exe) ですが、それらに独自のサブコマンドを持たせることもできます。たとえば、"dotnet add package" コマンドの場合、"dotnet" がルート コマンド、"add" がサブコマンド、"package" が追加のサブコマンド (言わばサブ-サブコマンド?) となります。

オプション:オプションは、コマンドの動作を変更するために使用します。たとえば、dotnet build コマンドには --no-restore オプションがありますが、これを使用すると、restore コマンドが暗黙的に実行されないようにすることができます (代わりに、前に実行された restore コマンドに依存するようにできます)。名前からもわかるように、オプションは通常、コマンドの必須要素ではありません。

引数:コマンドとオプションには、値を関連付けることができます。たとえば、dotnet new コマンドにはテンプレート名が含まれます。この値は、新しいコマンドを指定する際に必要となります。同様に、オプションにも値が関連付けられる場合があります。dotnet new の場合で言うと、--name オプションには、プロジェクトの名前を指定する引数があります。コマンドやオプションに関連付けられている値のことを、引数と呼びます。

ディレクティブ:ディレクティブは、すべてのアプリケーションにまたがって機能するコマンドです。たとえば、redirect コマンドを使用すると、すべての出力 (stderr と stdout) を .xml 形式にすることができます。ディレクティブは System.CommandLine フレームワークの一部であるため、コマンド ライン インターフェイスの開発者による作業を必要とせず、自動的に含められます。

区切り記号:コマンドやオプションに引数を関連付けるには、区切り記号を使用します。一般的な区切り記号は、スペース、コロン、および等号です。たとえば、dotnet build の詳細度を指定する際には、次の 3 つのうち、いずれかの表記を使用できます: --verbosity=diagnostic、--verbosity diagnostic、--verbosity:diagnostic。

エイリアス:エイリアスは、コマンドやオプションを識別するために使用できる追加の名前です。たとえば、dotnet の場合、"classlib" は "Class library" のエイリアスで、-v は "--verbosity" のエイリアスです。

System.CommandLine が提供される以前は、組み込みの解析機能がサポートされていなかったので、アプリケーションの起動時に開発者が引数の配列を分析し、どれがどの引数型に対応するかを確認した後、すべての値を適切に関連付ける必要がありました。.NET には、この問題を解決するための手段がたくさん追加されてきましたが、これまで既定のソリューションとなっていたものはなく、単純なシナリオと複雑なシナリオの両方に柔軟に対応できるものもありませんでした。これを踏まえて、System.CommandLine はアルファ版で開発され、リリースされました (github.com/dotnet/command-line-api を参照してください)。

シンプルな操作はシンプルなままに

たとえば、あなたは今、指定された出力名に基づいて画像ファイルを別の形式に変換する、画像変換プログラムを記述しているとします。その場合、コマンド ラインは次のようになります。

imageconv --input sunrise.CR2 --output sunrise.JPG

このコマンドラインの場合、imageconv を起動すると、Main エントリ ポイント (static void Main(string[] args)) で、対応する 4 つの引数の文字列配列が使用されます (代替のコマンド ライン構文につていは、「.NET Core の実行可能ファイルにパラメーターを渡す」をご覧ください)。残念ながら、--input と sunrise.CR2 の間や、--output と sunrise.JPG の間の関連付けはありません。また、--input と --output でオプションが識別されることを示すものも特にありません。

さいわい、新しい System.CommandLine API では、このようなシンプルなシナリオで役立つ大きな機能改善が、これまでになかった方法で提供されています。それは、コマンド ラインに合ったシグネチャを使って、Main エントリ ポイントをシンプルにプログラムできるようになったということです。つまり、Main のシグネチャは次のようになります。

static void Main(string input, string output)

お気づきのように、System.CommandLine では、--input オプションと --output オプションを Main のパラメーターに自動変換できるようになり、標準の Main(string[] args) エントリ ポイントを記述する必要もなくなりました。唯一追加された要件は、このシナリオを有効にするアセンブリを参照することです。何を参照するかについての詳細は、itl.tc/syscmddf で確認できます。なお、ここで説明されている内容は、アセンブリが NuGet でリリースされ次第、最新の情報ではなくなる可能性があります(残念ながら、これをサポートするための言語変更オプションはありません。ただし、参照を追加する際には、プロジェクト ファイルが変更され、あるビルド タスクが追加されます。このタスクによって生成される標準の Main メソッドの本文では、リフレクションを使って "カスタム" のエントリ ポイントが呼び出されます)。

また、引数は文字列に限定されません。組み込みのコンバーター (およびカスタム コンバーターのサポート) が豊富に用意されていて、たとえば、入力と出力のパラメーター型に System.IO.FileInfo を使用する場合には、次のように記述できます。

static void Main(FileInfo input, FileInfo output)

この記事の「System.CommandLine アーキテクチャ」セクションで説明しているように、System.CommandLine はコア モジュールとアプリ プロバイダー モジュールに分かれています。コマンド ラインを Main から構成する操作はアプリ モデルの実装ですが、ここでは、API セット全体を System.CommandLine として参照します。

コマンドライン引数と Main メソッド パラメーターの間のマッピングは現在基本の操作となっていますが、今でもさまざまなプログラムに比較的広く利用できます。ここからは、もう少し複雑な imageconv コマンド ラインを使って、いくつかの追加機能について見ていきましょう。図 1 は、コマンド ライン ヘルプの表示画面です。

図 1 imageconv のサンプル コマンドライン

imageconv:
  Converts an image file from one format to another.
Usage:
  imageconv [options]
Options:
  --input          The path to the image file that is to be converted.
  --output         The target name of the output after conversion.
  --x-crop-size    The X dimension size to crop the picture.
                   The default is 0 indicating no cropping is required.
  --y-crop-size    The Y dimension size to crop the picture.
                   The default is 0 indicating no cropping is required.
  --version        Display version information

図 2 は、対応する Main メソッドを示したものです。これにより、更新後のコマンドラインが有効化されます。この例は、Main メソッドを詳細に文書化したものにすぎませんが、これにより自動で有効化される機能が多数あります。ここからは、System.CommandLine を使用する際に組み込まれる機能について見ていきましょう。

図 2 更新後の imageconv コマンド ラインをサポートする Main メソッド

/// <summary>/// Converts an image file from one format to another./// </summary>/// <param name="input">The path to the image file that is to be
    converted.</param>/// <param name="output">The name of the output from the conversion.
    </param>/// <param name="xCropSize">The x dimension size to crop the picture.
    The default is 0 indicating no cropping is required.</param>/// <param name="yCropSize">The x dimension size to crop the picture.
    The default is 0 indicating no cropping is required.</param>
public static void Main(  FileInfo input, FileInfo output,   int xCropSize = 0, int yCropSize = 0)

最初の機能は、コマンド ラインのヘルプの出力です。これは、Main の XML コメントから推論されます。これらのコメントは、プログラムの全般的な説明 (サマリー XML コメントで指定されます) として役立つだけでなく、パラメーターの XML コメントを使った、各引数のドキュメントとしても利用できます。XML コメントを利用するにはドキュメントの出力を有効にする必要がありますが、これは、Main 経由の構成を有効にするアセンブリを参照する際に、自動的に構成されます。組み込みのヘルプ出力では、3 つのコマンド ライン オプションを使用できます。-h、-?、または --help です。たとえば、図 1 に表示されているヘルプは、System.CommandLine によって自動的に生成されます。

また、Main にはバージョン パラメーターがありませんが、System.CommandLine によって --version オプションが自動的に生成されます。これにより、実行可能ファイルのアセンブリ バージョンを出力することができます。

コマンドライン構文の検証機能では、必須の引数が欠けていないかどうかを検出できます (パラメーターで既定値が指定されていない場合)。必須の引数が指定されていない場合は、System.CommandLine によって次のエラーが自動的に発行されます: "Required argument missing for option: --output"。 あまり直感的ではありませんが、既定では、引数付きのオプションが必須となります。ただし、オプションに関連付けられた引数値が必須ではない場合は、C# の既定のパラメーター値構文を使用することもできます。以下に例を示します。

int xCropSize = 0

コマンド ラインでのオプションの表示順に関係なくオプションを解析するための組み込みサポートもあります。なお、オプションと引数の間の区切り記号は、既定ではスペース、コロン、または等号であることに注意してください。最後に、Main のパラメーター名のキャメル ケースは、Posix スタイルの引数名に変換されます (つまり、xCropSize はコマンドラインでは --x-crop-size に変換されます)。

認識されないコマンドや引数を入力した場合は、System.CommandLine によって次のコマンド ライン エラーが自動的に返されます: "Unrecognized command or argument …." ただし、指定した名前が既存のオプションと似ている場合は、エラー メッセージに入力ミスの修正候補が表示され、選択を求められます。

System.CommandLine を使用するすべてのコマンド ライン アプリケーションから利用できる、組み込みのディレクティブもあります。これらのディレクティブは角かっこで囲まれ、アプリケーション名の直後に表示されます。たとえば、[debug] ディレクティブを使用すると、デバッガーをアタッチするためのブレークポイントをトリガーできます。また [parse] を使用すれば、トークンがどのように解析されるかを次のようにプレビューできます。

imageconv [parse] --input sunrise.CR2 --output sunrise.JPG

さらに、IConsole インターフェイスと TestConsole クラスの実装を使用した自動テストもサポートされています。TestConsole をコマンド ラインのパイプラインに追加するには、次のように、Main に IConsole パラメーターを追加します。

public static void Main(
  FileInfo input, FileInfo output,
  int xCropSize = 0, int yCropSize = 0,
    IConsole console = null)

console パラメーターを利用するには、System.Console への呼び出しを IConsole パラメーターに置き換えます。IConsole パラメーターは、(単体テストからではなく) コマンドラインから直接呼び出された場合、自動的に設定されます。パラメーターには既定で null が割り当てられますが、それをそのままの方法で呼び出すテスト コードを記述するのでなければ、null 値にはしないようにしてください。代わりに、IConsole パラメーターを最初に配置することを検討してください。

私のお気に入りの機能の 1 つに、タブ補完のサポートがあります。これは、エンド ユーザーがコマンドを実行してオプトインすることでアクティブ化できます (bit.ly/2sSRsQq を参照してください)。ユーザーはシェルに暗黙的な変更を加えるのを敬遠する傾向があるので、これはオプトイン シナリオとなっています。オプションやコマンド名のタブ補完は自動的に実行されますが、候補機能を使った引数のタブ補完もあります。コマンドやオプションを構成する際には、タブ補完の値を値の静的リストから取得できます (q、m、n、d、--verbosity の診断値など)。また、実行時に動的に提供されるようにすることもできます (引数が NuGet 参照の場合に、利用可能な NuGet パッケージの一覧を返す REST 呼び出しから取得するなど)。

Main メソッドをコマンドラインの仕様として使う手法は、System.CommandLine を使用して実行できる複数のプログラミング方法の 1 つにすぎません。アーキテクチャが柔軟なので、コマンドラインの定義や操作を他の方法で行うことも可能です。

System.CommandLine のアーキテクチャ

System.CommandLine は、コマンド ラインを構成するための API と、コマンドライン引数をデータ構造へと解決するパーサーを含んだ、コア アセンブリを中心に設計されています。前のセクションで説明した機能はすべて、コア アセンブリを通じて有効にすることができます (Main に対して別のメソッド シグネチャを有効にする場合を除く)。ただし、コマンドラインを構成する機能 (特にドメイン固有の言語 (Main タイプのメソッドなど) を使用するもの) は、アプリ モデルによって有効化されます(上記で説明したような、Main タイプのメソッド実装用に使用されるアプリ モデルは、"DragonFruit" というコードネームで呼ばれます)。ただし、System.CommandLine アーキテクチャでは、追加のアプリ モデルをサポートすることもできます (図 3 を参照)。

System.CommandLine のアーキテクチャ
図 3 System.CommandLine のアーキテクチャ

たとえば、C# クラス モデルを使用してアプリケーションのコマンド ライン構文を定義するアプリ モデルを記述することもできます。このようなモデルでは、プロパティ名がオプション名に対応する場合があります。その場合、プロパティの型は引数の変換先のデータ型に対応します。また、この種のモデルでは、エイリアスを定義するための属性などが使用される場合もあります。これに代えて、docopt ファイル (docopt.org を参照) を構成用に解析するモデルを記述することもできます。これらの各アプリ モデルでは、System.CommandLine 構成 API が呼び出されます。もちろん、アプリ モデル経由ではなく、アプリケーションから直接 System.CommandLine を呼び出したい場合は、そのアプローチをとることもできます。

.NET Core の実行可能ファイルにパラメーターを渡す

コマンドライン引数を dotnet run コマンドと組み合わせで指定する場合、全体のコマンドラインは次のようになります。

dotnet run --project imageconv.csproj -- --input sunrise.CR2
  --output sunrise.JPG

ただし、csproj ファイルが置かれているのと同じディレクトリから dotnet を実行する場合は、次のようなコマンド ラインになります。

dotnet run -- --input sunrise.CR2 --output sunrise.JPG

dotnet run コマンドでは、識別子として "--" が使用されます。これは、他のすべての引数を実行可能ファイルに渡して解析することを示しています。

.NET Core 2.2 以降では、自己完結型アプリケーションもサポートされるようになりました (Linux 上でのサポートを含む)。自己完結型アプリケーションを使用する場合は、dotnet run を使用せず、生成された実行可能ファイルだけに基づいてアプリケーションを起動できます。コマンドは次のようになります。

imageconv.exe --input sunrise.CR2 --output sunrise.JPG

もちろん、これは Windows ユーザーにとっては想定どおりの動作です。

複雑な操作を可能にする

前のセクションでは、シンプルな操作をシンプルに保つための機能が基本であるということを述べました。なぜなら、Main メソッドを通じてコマンドライン解析を有効にする手法には、依然としていくつかの機能が欠けているからです。それらの機能は、開発者によっては重要なものとなる場合があります。たとえば、前者の方法では、(サブ) コマンドやオプションのエイリアスを構成することができません。これらの制限にぶつかった場合は、独自のアプリ モデルを構築するか、Core (System.CommandLine アセンブリ) を直接呼び出します。

System.CommandLine には、コマンド ラインのコンストラクトを表すクラスが含まれています。これには、Command (および RootCommand)、Option、および Argument が含まれます。図 4は、System.CommandLine を直接呼び出し、図 1 のヘルプ テキストで定義された基本機能を使えるように構成するサンプル コードを示したものです。

図 4 System.CommandLine を直接操作する

using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
...
public static async Task<int> Main(params string[] args)
{
  RootCommand rootCommand = new RootCommand(
    description: "Converts an image file from one format to another."
    , treatUnmatchedTokensAsErrors: true);
  Option inputOption = new Option(
    aliases: new string[] { "--input", "-i" }
    , description: "The path to the image file that is to be converted."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(inputOption);
  Option outputOption = new Option(
    aliases: new string[] { "--output", "-o" }
    , description: "The target name of the output file after conversion."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(outputOption);
  Option xCropSizeOption = new Option(
    aliases: new string[] { "--x-crop-size", "-x" }
    , description: "The x dimension size to crop the picture. 
      The default is 0 indicating no cropping is required."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(xCropSizeOption);
  Option yCropSizeOption = new Option(
    aliases: new string[] { "--y-crop-size", "-y" }
    , description: "The Y dimension size to crop the picture. 
      The default is 0 indicating no cropping is required."
    , argument: new Argument<FileInfo>());
  rootCommand.AddOption(yCropSizeOption);
  rootCommand.Handler =
    CommandHandler.Create<FileInfo, FileInfo, int, int>(Convert);
  return await rootCommand.InvokeAsync(args);
}
static public void Convert(
  FileInfo input, FileInfo output, int xCropSize = 0, int yCropSize = 0)
{
  // Convert...
}

この例では、Main アプリ モデルを使用してコマンドライン構成を定義するのではなく、各コンストラクトが明示的にインスタンス化されています。機能上の違いは、各オプションのエイリアスが追加されることだけです。ただし、直接 Core API を利用したほうが、Main タイプのアプローチよりも細かく制御することができます。

たとえば、サブコマンドを定義することもできます (拡張操作に関連するオプションと引数を独自のセットにして含めた、イメージ拡張コマンドなど)。複雑なコマンド ライン プログラムでは、複数のサブコマンドやサブ サブコマンドが使用されます。たとえば、dotnet コマンドには、dotnet sln add コマンドがありますが、このコマンドでは、dotnet がルート コマンドで、sln が多数のサブコマンドの 1 つ、そして add (または list や remove) が sln の子コマンドです。

最後に InvokeAsync を呼び出すことで、多数の機能が暗黙的に自動設定されます。これには以下の機能が含まれます。

  • 解析とデバッグのディレクティブ。
  • ヘルプおよびバージョン オプションの構成。
  • タブ補完と入力ミスの修正。

細かい制御が必要な場合には、各機能の個別の拡張メソッドも使用できます。また、Core API によって公開されているその他の構成機能も多数利用できます。これらのタスクには次が含まれています。

  • 構成によって明示的に不一致とされたトークンの処理。
  • タブ補完を提供するための候補ハンドラー (現在のコマンド ライン文字列とカーソルの位置に応じて、指定可能な値の一覧を返します)。
  • 非表示コマンド (タブ補完やヘルプで検出できないようにすることができます)。

さらに、System.CommandLine でのコマンドライン解析を制御するためのノブやボタンも多数提供されていますし、メソッド優先のアプローチもサポートされています。実際、これは Main タイプのメソッドへのバインドのために、内部的に使用される方法です。メソッド優先のアプローチの場合は、図 4 の下部にある Convert のようなメソッドを使用してパーサーを構成できます (図 5 を参照)。

図 5 メソッド優先のアプローチを使用して System.CommandLine を構成する

public static async Task<int> Main(params string[] args)
{
  RootCommand rootCommand = new RootCommand(
    description: "Converts an image file from one format to another."
    , treatUnmatchedTokensAsErrors: true);
  MethodInfo method = typeof(Program).GetMethod(nameof(Convert));
  rootCommand.ConfigureFromMethod(method);
  rootCommand.Children["--input"].AddAlias("-i");
  rootCommand.Children["--output"].AddAlias("-o");
  return await rootCommand.InvokeAsync(args);
}

この場合、初期構成には Convert メソッドを使用し、その後、ルート コマンドのオブジェクト モデルに移動してエイリアスを追加するということに注意してください。インデックス可能プロパティである Children には、ルート コマンドにアタッチされているすべてのオプションとコマンドが含まれています。

まとめ

System.CommandLine で提供されている機能は非常に便利です。実際、ここで説明したシンプルなシナリオは、きわめて少量のコードで実現できます。また、さまざまな機能 (タブ補完、引数変換、自動テストのサポートなど) が提供されたことにより、フル機能のコマンド ライン サポートを最小限の労力で、すべての dotnet アプリケーションに提供できるようになりました。

System.CommandLine はオープン ソースです。つまり、必要な機能が見つからない場合は、拡張機能を開発し、それをプル要求としてコミュニティに提出することもできます。また、コマンド ラインでオプション名やコマンド名を指定しなくても、引数の位置によってそれらの名前を暗示できるようになったという点も、個人的に気に入っている追加機能の 1 つです。さらに、Main タイプやメソッド優先のアプローチを使用する際、追加のエイリアス (短いエイリアスなど) を宣言的に追加できれるようになればいいと思っています。


Mark Michaelis は、IntelliTect の創設者で、同社でチーフ テクニカル アーキテクト兼トレーナーを務めています。彼は 20 年以上もの間 Microsoft MVP に認定され、2007 年から Microsoft Regional Director を務めています。Michaelis は、C#、Microsoft Azure、SharePoint、Visual Studio ALM など、マイクロソフト ソフトウェアの設計レビュー チームにも所属しています。開発者を対象としたカンファレンスで講演を行い、多数の書籍を執筆しました。最近では、『Essential C# 7.0 (6th Edition)』(Addison-Wesley Professional、2018 年) を執筆しています (itl.tc/EssentialCSharp、英語)。連絡先は、Facebook (facebook.com/Mark.Michaelis、英語)、ブログ (IntelliTect.com/Mark、英語)、Twitter (@markmichaelis、英語)、または電子メール mark@IntelliTect.com (英語のみ) です。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Kevin Bost、Kathleen Dollard、Jon Sequeira に心より感謝いたします