tech.guitarrapc.cóm

Technical updates

ConsoleAppFrameworkの処理実行前後に処理を挟む

コマンドラインアプリケーション(以降CLI)を用意するとき、バッチ処理となるメソッド本体の前後で処理を挟みたいことがあります。例えば、処理の前後にログを出力したい、所要時間を計測したい、処理前に前提を満たせているかチェックしたいなどです。 今回は、C#のCLIフレームワークであるConsoleAppFrameworkで処理実行前後に処理を挟む方法を紹介します。

ConsoleAppFrameworkとは

CLIを書く時、どの言語でも事前作業が結構求められてフレームワークがほしくなります。

  • 複数コマンド(サブコマンド)の提供
  • コマンドの引数を提供
  • コマンドのオプショナル引数を提供
  • 実行時の引数解析(引数を型マッピングして処理本体に渡す)
  • ヘルプの表示
  • 終了コードハンドリング
  • 例外終了の終了コードハンドリング
  • 非同期ハンドリング
  • AOT環境での動作

ConsoleAppFrameworkはC#のクラス・メソッド・コメント・属性をうまく連動させており、メソッドを用意すればCLIコマンドを提供できるように設計されています。先ほどの一覧は次のようにカバーしています。

  • クラスのpublicメソッドにCommandAttribute属性をつけるとメソッドそれぞれをサブコマンドとして登録
  • メソッドのパラメーターをコマンド引数として提供
  • メソッドのパラメーター(デフォルト値あり)をオプショナル引数として提供
  • 実行時のコマンド引数を解析しメソッドパラメーターにマッピング+型付け
  • コマンドに対して--helpを自動提供、メソッドのコメントをヘルプとして提供
  • メソッドの返り値型をintTask<int>にすることで終了コードを指定可能
  • メソッド本体で例外が発生すると終了コードを1に設定して終了
  • 非同期メソッド(async Taskasync 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

image

VSから実行する

Visual Studioから簡単に実行するため、launchSettings.jsonで定義を用意しておきます。それぞれの定義は、サブコマンド+渡したい引数を指定します。

{
  "profiles": {
    "help": {
      "commandName": "Project",
      "commandLineArgs": "foobar --help"
    },
    "foo": {
      "commandName": "Project",
      "commandLineArgs": "foobar --name foo"
    }
  }
}

Visual Studioでhelpプロファイルを実行してみましょう。

image

Usage: foobar [options...] [-h|--help] [--version]

サンプルのコマンド

Options:
  --name <string>    必須のパラメーター、名前を指定します。 (Required)

同様にfoobarコマンドを実行してみましょう。fooはlaunchSettings.jsonでfoobar --name fooを指定しています。

image

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_REPOGH_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としての基本機能を網羅する以外に、フィルターを使うことでコマンドの前後に処理を挟むことができコマンドの使いやすさを向上させることができます。

コマンド本体に余計な処理は書きたくない、というのは誰しもが考えることでしょう。そんな時にはフィルターを思い出して見てください。


  1. Webサーバーでリクエストを受ける前後に処理をかぶせるミドルウェアのようなものをイメージしてください
  2. ASP.NET Coreのミドルウェアとほぼ同じシグネチャなので見慣れている人も多いことでしょう
  3. ASP.NET Coreミドルウェアとは違いますね
  4. ヘルプもヘルプというコマンドなので