コマンドラインアプリケーション(以降CLI)を用意するとき、バッチ処理となるメソッド本体の前後で処理を挟みたいことがあります。例えば、処理の前後にログを出力したい、所要時間を計測したい、処理前に前提を満たせているかチェックしたいなどです。 今回は、C#のCLIフレームワークであるConsoleAppFrameworkで処理実行前後に処理を挟む方法を紹介します。
ConsoleAppFrameworkとは
CLIを書く時、どの言語でも事前作業が結構求められてフレームワークがほしくなります。
- 複数コマンド(サブコマンド)の提供
- コマンドの引数を提供
- コマンドのオプショナル引数を提供
- 実行時の引数解析(引数を型マッピングして処理本体に渡す)
- ヘルプの表示
- 終了コードハンドリング
- 例外終了の終了コードハンドリング
- 非同期ハンドリング
- AOT環境での動作
ConsoleAppFrameworkはC#のクラス・メソッド・コメント・属性をうまく連動させており、メソッドを用意すればCLIコマンドを提供できるように設計されています。先ほどの一覧は次のようにカバーしています。
- クラスのpublicメソッドに
CommandAttribute
属性をつけるとメソッドそれぞれをサブコマンドとして登録 - メソッドのパラメーターをコマンド引数として提供
- メソッドのパラメーター(デフォルト値あり)をオプショナル引数として提供
- 実行時のコマンド引数を解析しメソッドパラメーターにマッピング+型付け
- コマンドに対して
--help
を自動提供、メソッドのコメントをヘルプとして提供 - メソッドの返り値型を
int
、Task<int>
にすることで終了コードを指定可能 - メソッド本体で例外が発生すると終了コードを1に設定して終了
- 非同期メソッド(
async Task
やasync Task<int>
)もサポート - SourceGeneratorを使っておりAOT環境での動作もサポート
ConsoleAppFrameworkの基本的な使い方
紹介した機能がCLIを書くのにどう必要か見てみましょう。今回は最新版であるConsoleAppFramework v5.3.3を用います。
例えば「foobarというサブコマンドでnameという引数を持たせてHello, {name}!
を表示する」コマンドは次のように書けます。
CLIを用意する
// global.cs global using ConsoleApp1; global using ConsoleAppFramework;
// Program.cs var app = ConsoleApp.Create(); app.Add<SampleCommand>(); app.Run(args); namespace ConsoleApp1 { public class SampleCommand { /// <summary> /// サンプルのコマンド /// </summary> /// <param name="name">必須のパラメーター、名前を指定します。</param> [Command("foobar")] public void FooBar(string name) { Console.WriteLine($"Hello {name}!"); } } }
ファイル構成は次のようになります。ミニマムはcsprojとProgram.csだけですが、今回はglobal.csを用意しています。
$ tree ConsoleApp1 ├── ConsoleApp1.csproj ├── Program.cs ├── Properties │ └── launchSettings.json └── global.cs
VSから実行する
Visual Studioから簡単に実行するため、launchSettings.jsonで定義を用意しておきます。それぞれの定義は、サブコマンド+渡したい引数を指定します。
{ "profiles": { "help": { "commandName": "Project", "commandLineArgs": "foobar --help" }, "foo": { "commandName": "Project", "commandLineArgs": "foobar --name foo" } } }
Visual Studioでhelpプロファイルを実行してみましょう。
Usage: foobar [options...] [-h|--help] [--version] サンプルのコマンド Options: --name <string> 必須のパラメーター、名前を指定します。 (Required)
同様にfoobarコマンドを実行してみましょう。fooはlaunchSettings.jsonでfoobar --name foo
を指定しています。
Hello foo!
dotnet runコマンドで実行する
もちろんdotnet runコマンドで実行も簡単です。ビルド不要なので、CIで単発実行するときはこれがいいですね。
# --no-launch-profileはあっても機能します $ dotnet run --project ConsoleApp1.csproj --no-launch-profile -- foobar --name foo Hello foo!
ビルドして実行する
ビルドしたdllでも実行できます。
# 雑にデバッグビルドで $ dotnet publish -o .arttifacts $ dotnet .arttifacts/ConsoleApp1.dll foobar --name foo Hello foo!
CLIが簡単に作れるのはわかりました。
コマンドの前後に処理を簡単にはさみたい
この調子でコマンドを作っていくと、いろいろなコマンドで共通してコマンド本体の実行前後に処理を挟みたくなります。1 例えば「コマンド本体の実行時に起動ログを出力」することを考えましょう。
コマンドFooBarが実行されました。 // コマンド本体のログ // .... // .... コマンドが完了しました。
次のようにメソッドの前後にログ出力をいれてもいいのですが、10、50、100コマンドなど大量にコマンドがあると面倒です、やってられないですね。バッチもノイズが増えて読みにくく感じます。
public void FooBar() { Console.WriteLine($"コマンド{nameof(FooBar)}が実行されました。"); // コマンド // ... // .... Console.WriteLine($"コマンドが完了しました。"); }
このように、バッチの本質じゃないけど前後で処理をはさみたい時に用いるのがConsoleAppFilterです。
ConsoleAppFilterとは
ConsoleAppFilterを使うと、コマンド本体の前後に処理を挟むことができます。前後処理はフィルターに任せることで、コマンド本体には本当にやりたいことだけ書けます。
詳しい使い方はREADMEを見るとして簡単に説明します。
ConsoleAppFilterの基本的な使い方
まずは自前のフィルターを作ります。自前フィルターはConsoleAppFilter(next)
を継承したクラスで、InvokeAsync
メソッドをオーバーライドしてやりたい処理を実装します。2 Next.InvokeAsync
前後に任意の処理を書くことで、コマンド本体に到達する前、コマンド終了後に処理を差し込めます。
フィルターを作る際、3点ポイントがあります。
- internalクラスで作る。ConsoleAppFilterはinternalクラスなのでpublicクラスでは作れません
- コンストラクターで
ConsoleAppFilter next
を受け取りbase(next)
を呼び出す。Primary Constructorで書くと楽 - コマンド本体は
Next.InvokeAsync
を呼び出す。Nextは継承元のConsoleAppFilter
クラスに実装されている3
internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // ctor needs `ConsoleAppFilter next` and call base(next) { // implement InvokeAsync as filter body public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { try { /* on before */ await Next.InvokeAsync(context, cancellationToken); // invoke next filter or command body /* on after */ } catch { /* on error */ throw; } finally { /* on finally */ } } }
フィルターを登録する
フィルターは、グローバル、クラス、メソッドの3つのレベルで登録できます。フィルターは多段設定でき、登録順に実行されます。グローバル > クラス > メソッド
の順に登録したフィルターが呼び出されるのもポイントです。
- グローバル: どのコマンド実行時も呼び出される
- クラス: そのクラスのコマンド実行時に呼び出される
- メソッド: そのメソッドのコマンド実行時に呼び出される
var app = ConsoleApp.Create(); // global filters app.UseFilter<NopFilter>(); //order 1 app.Add<MyCommand>(); app.Run(args); // per class filters [ConsoleAppFilter<NopFilter>] // order 2 public class MyCommand { // per method filters [ConsoleAppFilter<NopFilter>] // order 3 public void Echo(string msg) => Console.WriteLine(msg); }
特定コマンド専用の前後処理を書くならメソッド単位で指定すると便利です。一方、コマンド実行ログを出力するフィルターならグローバルに登録するといいでしょう。
ConsoleAppFilterを使って前後処理を挟む
コマンド本体の前後にコマンド呼び出しログを出力するフィルターを作ってみましょう。どのコマンドでも表示してほしいのでグローバルに登録します。
var app = ConsoleApp.Create(); app.UseFilter<LogFilter>(); // <- global filterとして登録 app.Add<SampleCommand>(); app.Run(args); namespace ConsoleApp1 { public class SampleCommand { // 省略 } // ↓ フィルターを作成 internal class LogFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { try { Console.WriteLine($"[{DateTime.Now:s}] Command '{context.CommandName}' begin."); await Next.InvokeAsync(context, cancellationToken); } finally { Console.WriteLine($"[{DateTime.Now:s}] Command '{context.CommandName}' completed"); } } } }
FooBarコマンドを実行すると、コマンド前後でログが出力されます。
[2025-01-09T00:50:50] Command 'foobar' begin. Hello foo! [2025-01-09T00:50:50] Command 'foobar' completed
利用パターン
フィルターは様々なパターンで使えます。私が便利だと思ったのはZxで呼び出すコマンドが正常に動作する前提条件を満たしているかチェックするフィルターです。コマンドはたびたび呼び出す条件を持っているので、それをフィルターで担保しようという考えです。
ghコマンドの前提条件をチェックする
例えば、ghコマンドをGitHub Actionsで使う時はGH_REPO
とGH_TOKEN
環境変数で認証しておくのが定番です。コマンド実行前に環境変数が登録されているかチェックするフィルターを作成すれば、コマンド本体でエラーが出る前に気づけます。
name: Run Command run: dotnet run --project ConsoleApp1.csproj --no-launch-profile -- list-pr env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
フィルターを用意します。
using ConsoleAppFramework; using Zx; // ConsoleAppは省略... namespace ConsoleApp1 { public class SampleCommand { [ConsoleAppFilter<GitHubCliFilter>] [Command("list-pr")] public async Task<int> ListPR() { // gh cliを用いてPR一覧を表示 await $"gh pr list"; } } // ↓ GitHubActionsにはCI環境変数があるので、これを利用してCI判別 + 必要な環境変数があるかチェックするフィルター internal class GitHubCliFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { // Ensure GH CLI can access on CI. if (Environment.GetEnvironmentVariable("CI") is not null) { _ = Environment.GetEnvironmentVariable("GH_REPO") ?? throw new Exception("Environment Variable 'GH_REPO' is required"); _ = Environment.GetEnvironmentVariable("GH_TOKEN") ?? throw new Exception("Environment Variable 'GH_TOKEN' is required"); } await Next.InvokeAsync(context, cancellationToken); } } }
GitHubActionsの実行コンテキストを取得する
別の例として、GitHubActionsの実行コンテキスト${{ github }}
でとれる値を使いたいケースがあります。コンテキスト情報が多すぎて素朴にメソッド引数から--run-id ${{ github.run_id}} --他 ...
などと渡すのはちょっと耐えきれないでしょう。そんな時はGitHubコンテキストでほしい情報をクラス定義し、環境変数経由で実行コンテキストを取得、フィルターでデシリアライズするとフィルターを付与したコマンドで使いやすくなります。
GitHub Actionsでコマンド実行する際、環境変数GITHUB_CONTEXT
にGitHubの実行コンテキストをJSONで渡します。
name: Run Command run: dotnet run --project ConsoleApp1.csproj --no-launch-profile -- Sample2 env: GITHUB_CONTEXT: ${{ toJson(github) }}
フィルターを用意します。デシリアライズするクラスは、プロパティにrequired
をつけておくとJSONフィールドがない時デシリアライズ失敗します、便利。
using System.Text.Json; using System.Text.Json.Serialization; namespace ConsoleApp1 { public class SampleCommand { [ConsoleAppFilter<GitHubContextFilter>] [Command("Sample2")] public async Task<int> Sample2() { // GitHub Contextのrun_idを表示 Console.WriteLine($"RunId: {GitHubContext.Current.RunId}"); } } internal class GitHubContextFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { GitHubContext.ThrowIfNotAvailable(); await Next.InvokeAsync(context, cancellationToken); } } public record GitHubContext { public static readonly GitHubContext Current = JsonSerializer.Deserialize<GitHubContext>(Environment.GetEnvironmentVariable("GITHUB_CONTEXT") ?? "{}")!; [JsonPropertyName("run_id")] public required string RunId { get; init; } // requiredをつけてJSONにフィールドがないと落ちるようにする */ [JsonPropertyName("event_name")] public required string EventName { get; init; } public static void ThrowIfNotAvailable() { // This should be throw when Environment Variable is missing. _ = ActionsBatchOptions.GitHubContext ?? throw new ArgumentNullException("Environment Variable 'GITHUB_CONTEXT' is missing."); // This should be throw when required property is missing. _ = GitHubContext.Current; } } }
launchSettings.jsonで環境変数に設定すると、Visual Studioデバッグがはかどります。
{ "profiles": { "Sample2": { "commandName": "Project", "commandLineArgs": "Sample2", "environmentVariables": { "GITHUB_CONTEXT": "{\"run_id\":\"12345\",\"event_name\":\"push\"}" } } } }
ConsoleAppFilterの注意点
--helpでも呼び出される
--help
を呼び出しでもフィルターは呼び出されます。フィルターはコマンド本体の前後に処理を挟むため、--help
でも呼び出されるのは仕様です。4
helpが呼び出されたかをフィルターで判定するには、context.Arguments
を見るといいでしょう。
public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { // context.IsHelp とかあると便利そうですね if (context.Arguments.Contains("--help") || context.Arguments.Contains("-h")) { Console.WriteLine("--helpが呼び出された。即終了するとヘルプは表示されない"); return; // ×。ここでreturnするとヘルプは表示されない } await Next.InvokeAsync(context, cancellationToken); }
Next前にreturnするとヘルプは表示されないので注意です。
$ dotnet run --project ConsoleApp1.csproj --no-launch-profile -- foobar --help --helpが呼び出された。即終了するとヘルプは表示されない
まとめ
ConsoleAppFrameworkはCLIとしての基本機能を網羅する以外に、フィルターを使うことでコマンドの前後に処理を挟むことができコマンドの使いやすさを向上させることができます。
コマンド本体に余計な処理は書きたくない、というのは誰しもが考えることでしょう。そんな時にはフィルターを思い出して見てください。