ConsoleAppFramework v5.3.0 - NuGet参照状況からのメソッド自動生成によるDI統合の強化、など

ConsoleAppFramework v5の比較的アップデートをしました!v5自体の詳細は以前に書いたConsoleAppFramework v5 - ゼロオーバーヘッド・Native AOT対応のC#用CLIフレームワークを参照ください。v5はかなり面白いコンセプトになっていて、そして支持されたと思っているのですが、幾つか使い勝手を犠牲にした点があったので、今回それらをケアしました。というわけで使い勝手がかなり上がった、と思います……!

名前の自動変換を無効にする

コマンドネームとオプションネームは、デフォルトでは自動的にkebab-caseに変換されます。これはコマンドラインツールの標準的な命名規則に従うものですが、内部アプリケーションで使うバッチファイルの作成に使ったりする場合などには、変換されるほうが煩わしく感じるかもしれません。そこで、アセンブリ単位でオフにする機能を今回追加しました。

using ConsoleAppFramework;

[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]

var app = ConsoleApp.Create();
app.Add<MyProjectCommand>();
app.Run(args);

public class MyProjectCommand
{
    public void Execute(string fooBarBaz)
    {
        Console.WriteLine(fooBarBaz);
    }
}

[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]によって自動変換が無効になります。この例では ExecuteCommand --fooBarBaz がコマンドとなります。

実装面でいうと、Source Generatorにコンフィグを与えるのはAdditionalFilesにjsonや独自書式のファイル(例えばBannedApiAnalyzersのBannedSymbols.txt)を置くパターンが多いですが、ファイルを使うのは結構手間が多くて面倒なんですよね。boolの1つや2つを設定するぐらいなら、アセンブリ属性を使うのが一番楽だと思います。

実装手法としてはCompilationProviderからAssembly.GetAttributesで引っ張ってこれます。

var generatorOptions = context.CompilationProvider.Select((compilation, token) =>
{
    foreach (var attr in compilation.Assembly.GetAttributes())
    {
        if (attr.AttributeClass?.Name == "ConsoleAppFrameworkGeneratorOptionsAttribute")
        {
            var args = attr.NamedArguments;
            var disableNamingConversion = args.FirstOrDefault(x => x.Key == "DisableNamingConversion").Value.Value as bool? ?? false;
            return new ConsoleAppFrameworkGeneratorOptions(disableNamingConversion);
        }
    }

    return new ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion: false);
});

これを他のSyntaxProviderからのSourceとCombineしてやれば、生成時に属性の値を参照できるようになります。

ConfigureServices/ConfigureLogging/ConfigureConfiguration

ゼロディペンデンシーを掲げている都合上、特定のライブラリに依存したコードを生成することができないという制約がConsoleAppFramework v5にはありました。そのため、DIとの統合時に自分でServiceProviderをビルドしなければならないなの、利用には一手間必要でした。そこで、NuGetでのDLLの参照状況を解析し、Microsoft.Extensions.DependencyInjectionが参照されていると、ConfigureServicesメソッドがConsoleAppBuilderから使えるという実装を追加しました。

var app = ConsoleApp.Create()
    .ConfigureServices(service =>
    {
        service.AddTransient<MyService>();
    });

app.Add("", ([FromServices] MyService service, int x, int y) => Console.WriteLine(x + y));

app.Run(args);

これによりフレームワークそのものはゼロディペンデンシーでありながら、ライブラリ依存のコードも生成することができるという、新しい体験を提供します。これはMetadataReferencesProviderから引っ張ってきて生成処理に回すことで実現しました。

var hasDependencyInjection = context.MetadataReferencesProvider
    .Collect()
    .Select((xs, _) =>
    {
        var hasDependencyInjection = false;

        foreach (var x in xs)
        {
            var name = x.Display;
            if (name == null) continue;

            if (!hasDependencyInjection && name.EndsWith("Microsoft.Extensions.DependencyInjection.dll"))
            {
                hasDependencyInjection = true;
                continue;
            }

            // etc...
        }

        return new DllReference(hasDependencyInjection, hasLogging, hasConfiguration, hasJsonConfiguration, hasHost);
    });

context.RegisterSourceOutput(hasDependencyInjection, EmitConsoleAppConfigure);

参照の解析は複数のものに対して行っていて、他にもMicrosoft.Extensions.Loggingが参照されていればConfigureLoggingが使えるようになります。なのでZLoggerと組み合わせれば

// Package Import: ZLogger
var app = ConsoleApp.Create()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.SetMinimumLevel(LogLevel.Trace);
        x.AddZLoggerConsole();
        x.AddZLoggerFile("log.txt");
    });

app.Add<MyCommand>();
app.Run(args);

// inject logger to constructor
public class MyCommand(ILogger<MyCommand> logger)
{
    public void Echo(string msg)
    {
        logger.ZLogInformation($"Message is {msg}");
    }
}

といったように、比較的すっきりと設定が統合できます。

appsettings.jsonから設定ファイルを引っ張ってくるというのも最近では定番パターンですが、これもMicrosoft.Extensions.Configuration.Jsonを参照しているとConfigureDefaultConfigurationが使えるようになり、これはSetBasePath(System.IO.Directory.GetCurrentDirectory())AddJsonFile("appsettings.json", optional: true)を自動的に行います(追加でActionでconfigureすることも可能、また、ConfigureEmptyConfigurationもあります)。

なのでコンフィグを読み込んでクラスにバインドしてコマンドにDIで渡す、などといった処理もシンプルに書けるようになりました。

// Package Import: Microsoft.Extensions.Configuration.Json
var app = ConsoleApp.Create()
    .ConfigureDefaultConfiguration()
    .ConfigureServices((configuration, services) =>
    {
        // Package Import: Microsoft.Extensions.Options.ConfigurationExtensions
        services.Configure<PositionOptions>(configuration.GetSection("Position"));
    });

app.Add<MyCommand>();
app.Run(args);

// inject options
public class MyCommand(IOptions<PositionOptions> options)
{
    public void Echo(string msg)
    {
        ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}");
    }
}

Microsoft.Extensions.Hostingでビルドしたい場合は、ToConsoleAppBuilderが、これもMicrosoft.Externsions.Hostingを参照すると追加されるようになっています。

// Package Import: Microsoft.Extensions.Hosting
var app = Host.CreateApplicationBuilder()
    .ToConsoleAppBuilder();

また、今回から設定されているIServiceProviderRunまたはRunAsync終了後に自動的にDisposeするようになりました。

RegisterCommands from Attribute

コマンドの追加はAddまたはAdd<T>が必要でしたが、クラスに属性を付与することで自動的に追加される機能をいれました。

[RegisterCommands]
public class Foo
{
    public void Baz(int x)
    {
        Console.Write(x);
    }
}

[RegisterCommands("bar")]
public class Bar
{
    public void Baz(int x)
    {
        Console.Write(x);
    }
}

これらは自動で追加されています。

var app = ConsoleApp.Create();

// Commands:
//   baz
//   bar baz
app.Run(args);

これらとは別に追加でAdd, Add<T>することも可能です。

なお、実装の当初予定では任意の属性を使えるようにする予定だったのですが、IncrementalGeneratorのAPIの都合上難しくて、固定のRegisterCommands属性のみを対象としています。また、継承することもできません……。なので独自の処理用属性がある場合は、組み合わせてもらう必要があります。例えば以下のように。

[RegisterCommands, Batch("0 10 * * *")]
public class MyCommands
{
}

この辺はConsoleAppFrameworkとAWS CDKで爆速バッチ開発を読んで、うーん、v5を使ってもらいたい!なんとかしたい!と思って色々考えたのですが、この辺が現状の限界でした……。名前変換オフりたいのもわかるー、とか今回の更新内容はこの記事での利用例を参考にさせていただきました、ありがとうございます!

まとめ

v5のリリース以降もフィルターを外部アセンブリに定義できるようになったり、Incremental Generatorの実装を見直して高速化するなど、Improvmentは続いています!非常に良いフレームワークに仕上がってきました!

ところでSystem.CommandLine、現状うまくいってないからResettting System.CommandLineだ!と言ったのが今年の3月。例によって想像通り進捗は無です。知ってた。そうなると思ってた。何も期待しないほうがいいし、普通にConsoleAppFramework使っていくで良いでしょう。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive