使用 Spectre.Console.Cli 解析 Console / WinForm / WPF 的參數

上篇 使用 PowerArgs 解析 Console / WinForm / WPF 的參數 介紹過怎麼通過 PowerArgs 解析參數、綁定行為,這次要用同事介紹的 Spectre.Console,他除了能解析參數轉成強型別之外還有很多華麗的功能,個人認為若是要設計 cli 它比 PowerArgs 來得容易些。

開發環境

快速開始

新增一個 Console 專案,安裝以下套件

dotnet add package Spectre.Console.Cli --version 0.46.0

 

  1. 實作 CommandSettings,把 cli 想要接收的參數轉換成強型別
  2. 實作 Command<CommandSettings>,把 cli 要執行的動作寫在 Execute 方法
  3. 建立 CommandApp<Command> 執行個體,傳入我們定義的 Command
var app = new CommandApp<FileSizeCommand>();
return app.Run(args);

internal sealed class FileSizeCommand : Command<FileSizeCommand.Settings>
{
    public sealed class Settings : CommandSettings
    {
        [Description("Path to search. Defaults to current directory.")]
        [CommandArgument(0, "[searchPath]")]
        public string? SearchPath { get; init; }

        [CommandOption("-p|--pattern")]
        public string? SearchPattern { get; init; }

        [CommandOption("--hidden")]
        [DefaultValue(true)]
        public bool IncludeHidden { get; init; }
    }

    public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
    {
        var searchOptions = new EnumerationOptions
        {
            AttributesToSkip = settings.IncludeHidden
                ? FileAttributes.Hidden | FileAttributes.System
                : FileAttributes.System
        };

        var searchPattern = settings.SearchPattern ?? "*.*";
        var searchPath = settings.SearchPath ?? Directory.GetCurrentDirectory();
        var files = new DirectoryInfo(searchPath)
            .GetFiles(searchPattern, searchOptions);

        var totalFileSize = files
            .Sum(fileInfo => fileInfo.Length);

        AnsiConsole.MarkupLine($"Total file size for [green]{searchPattern}[/] files in [green]{searchPath}[/]: [blue]{totalFileSize:N0}[/] bytes");

        return 0;
    }
}

 

完成之後就可以試用以下的命令執行

app.exe
app.exe c:\windows
app.exe c:\windows --pattern *.dll
app.exe c:\windows --hidden --pattern *.dll

 

如下圖:

 

綁定參數(Setting)

通過繼承 CommandSettings 建立命令參數,主要是以下 Attribute

CommandArgumentAttribute

建構子有 position、name 兩個參數。name 用來產生說明文件以及參數是否可選,名稱必須用中括號(例如 [name])或角括號(例如 <name>)括起來;角括號表示必需,中括號表示可選。如果兩者均未指定,將拋出異常。

CommandOptionAttribute

當需要在 cli 傳遞參數時,使用 CommandOptionAttribute,它只有一個建構子參數,它必須要用 - 的豎線分隔字符串開頭。以下規則適用:

  • 可以指定任意多個名稱,但不能與其他參數衝突。
  • 具有單個字符的選項前面必須有一個破折號(例如 -c)。
  • 多字符選項前面必須有兩個破折號(例如 --count)。
public sealed class MyCommandSettings : CommandSettings
{
   [CommandArgument(0, "[name]")]
   public string? Name { get; set; }
   [CommandOption("-c|--count")]
   public int? Count { get; set; }
}

 

Flags

綁定的欄位是 boolean 時,可以這樣用 app.exe --debug | app.exe --debug true.

[CommandOption("--debug")]
public bool? Debug { get; set; }

 

Validation

當需要驗證屬性的值,可以透過 Validate 方法,此方法必須返回 ValidationResult.Error 或 ValidationResult.Success。

public class Settings : CommandSettings
{
    [Description("The name to display")]
    [CommandArgument(0, "[Name]")]
    public string? Name { get; init; }

    public override ValidationResult Validate()
    {
        return Name.Length < 2
            ? ValidationResult.Error("Names must be at least two characters long")
            : ValidationResult.Success();
    }
}

 

或者在 Command 檢查

internal sealed class MyCommand : Command<MyCommandSettings>
{
    public override int Execute(CommandContext context, MyCommandSettings settings)
    {
        throw new NotImplementedException();
    }

    public override ValidationResult Validate(CommandContext context, MyCommandSettings settings)
    {
        return settings.Name.Length < 2
            ? ValidationResult.Error("Names must be at least two characters long")
            : ValidationResult.Success();
    }
}

 

更多的內容請參考

Spectre.Console - Specifying Settings (spectreconsole.net)

注意:不能只單獨綁定 Setting,要連同 Command 一起綁定

綁定命令(Command)

設定 CommandSettings後,接下來就是繼承 Command<CommandSettings>,如下範例,實作 HelloCommand,可以看得出來 Command 必須要有 CommandSettings

public class HelloCommand : Command<HelloCommand.Settings>
{
    public class Settings : CommandSettings
    {
        [CommandArgument(0, "[Name]")]
        public string Name { get; set; }
    }


    public override int Execute(CommandContext context, Settings settings)
    {
        AnsiConsole.MarkupLine($"Hello, [blue]{settings.Name}[/]");
        return 0;
    }
}

 

建立 CommandApp

手動實例化 CommandApp

  1. 建立 CommandApp 實例
  2. 呼叫 Run、RunAsync 方法

只有一個 Command 時,就直接把 Command 餵給 Command

var app = new CommandApp<FileSizeCommand>();
app.Run(args);

 

或是透過 Configure 設定

var app = new CommandApp();
app.Configure(config =>
{
   config.AddCommand<HelloCommand>("hello")
       .WithAlias("hola")
       .WithDescription("Say hello")
       .WithExample(new []{"hello", "Phil"})
       .WithExample(new []{"hello", "Phil", "--count", "4"});
});
app.Run(args);

 

配置多個 Command

var app = new CommandApp();
app.Configure(config =>
{
    config.AddCommand<AddCommand>("add");
    config.AddCommand<CommitCommand>("commit");
    config.AddCommand<RebaseCommand>("rebase");
});

 

最特別的是建立樹狀的 Command

var app = new CommandApp();

app.Configure(config =>
{
    config.AddBranch<AddSettings>("add", add =>
    {
        add.AddCommand<AddPackageCommand>("package");
        add.AddCommand<AddReferenceCommand>("reference");
    });
});

app.Run(args);

 

想像一下下面的命令結構

  • app (executable)
    • add [PROJECT]
      • package <PACKAGE_NAME> --version <VERSION>
      • reference <PROJECT_REFERENCE>

 

當我執行  .\app.exe 他會要求我輸入 add 命令

 

當我執行  .\app.exe add 他會要求我輸入 package or reference 的命令

 

詳情請參考  Spectre.Console - Composing Commands (spectreconsole.net)

通過 Dependency Injection Container 取得實例

官方提供的 DI 合約是 TypeRegistrar、TypeResolver,如果要整合 Microsoft.Extensions.DependencyInjection,可以參考 spectre.console/examples/Cli/Injection/Infrastructure at main · spectreconsole/spectre.console (github.com);或者是使用人家包好的 juro-org/Spectre.Console.Extensions.Hosting: Spectre Console CommandApp for Microsoft.Extensions.Hosting (github.com)

安裝指令

dotnet add package Spectre.Console.Extensions.Hosting --version 0.2.0

 

範例如下:

 public static async Task<int> Main(string[] args)
   {
       await Host.CreateDefaultBuilder(args)
           .UseConsoleLifetime()
           .UseSpectreConsole<DefaultCommand>()
           .ConfigureServices(
               (_, services) => { services.AddSingleton<IGreeter, HelloWorldGreeter>(); })
           .RunConsoleAsync();
       return Environment.ExitCode;
   }

 

    Host.CreateDefaultBuilder(args)
        ...
        .UseSpectreConsole(config =>
        {
            config.AddCommand<AddCommand>("add");
            config.AddCommand<CommitCommand>("commit");
            config.AddCommand<RebaseCommand>("rebase");
#if DEBUG
            config.PropagateExceptions();
            config.ValidateExamples();
#endif
        })
        ...

 

實作具有 CancellationToken 的 ExecuteAsync

雖然 AsyncCommand<TSettings> 已經提供了 ExecuteAsync 方法但是並沒有 CancellationToken 參數,我參考了這篇 https://github.com/spectreconsole/spectre.console/issues/701,實作了 CancellableAsyncCommand

public abstract class CancellableAsyncCommand<TSettings> : AsyncCommand<TSettings>
    where TSettings : CommandSettings
{
    private readonly ILogger<CancellableAsyncCommand<TSettings>> _logger;

    protected CancellableAsyncCommand(ILogger<CancellableAsyncCommand<TSettings>> logger)
    {
        this._logger = logger;
    }

    public abstract Task<int> ExecuteAsync(CommandContext context, TSettings settings, CancellationToken cancellation);

    public override async Task<int> ExecuteAsync(CommandContext context, TSettings settings)
    {
        using var cancellationSource = new CancellationTokenSource();

        Console.CancelKeyPress += OnCancelKeyPress;
        AppDomain.CurrentDomain.ProcessExit += OnProcessExit;

        using var _ = cancellationSource.Token.Register(
            () =>
            {
                AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
                Console.CancelKeyPress -= OnCancelKeyPress;
            }
        );
        int exitCode = -1;
        try
        {
            this._logger.LogInformation("執行任務中...");
            var executeTask = this.ExecuteAsync(context, settings, cancellationSource.Token);
            exitCode = await executeTask;
            this._logger.LogInformation("執行完成!!!");
            AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
            Console.CancelKeyPress -= OnCancelKeyPress;
        }
        catch (OperationCanceledException)
        {
            exitCode = 0;
        }
        catch (Exception e)
        {
            this._logger.LogError(e, "執行命令時發生非預期的錯誤");
        }

        return exitCode;

        void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
        {
            // NOTE: cancel event, don't terminate the process
            e.Cancel = true;

            cancellationSource.Cancel();
        }

        void OnProcessExit(object? sender, EventArgs e)
        {
            if (cancellationSource.IsCancellationRequested)
            {
                // NOTE: SIGINT (cancel key was pressed, this shouldn't ever actually hit however, as we remove the event handler upon cancellation of the `cancellationSource`)
                return;
            }

            cancellationSource.Cancel();
        }
    }
}

 

實作 CancellableAsyncCommand,並在 ExecuteAsync 模擬了一個長時間工作的方法

internal sealed class FileSizeAsyncCommand : CancellableAsyncCommand<FileSizeAsyncCommand.Settings>
{
    internal sealed class Settings : CommandSettings
    {
        [Description("Path to search. Defaults to current directory.")]
        [CommandArgument(0, "[searchPath]")]
        public string? SearchPath { get; init; }

        [CommandOption("-p|--pattern")]
        public string? SearchPattern { get; init; }

        [CommandOption("--hidden")]
        [DefaultValue(true)]
        public bool IncludeHidden { get; init; }
    }

    public override async Task<int> ExecuteAsync(CommandContext context,
        Settings settings,
        CancellationToken cancellation)
    {
        await Task.Delay(5000, cancellation);
        return 0;
    }

    public FileSizeAsyncCommand(ILogger<CancellableAsyncCommand<Settings>> logger)
        : base(logger)
    {
    }
}

 

這裡用了 UseSpectreConsole 註冊 AsyncCommand/Command

// See https://aka.ms/new-console-template for more information

using Lab.SpectreConsole;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Templates;
using Spectre.Console.Extensions.Hosting;

// var formatter = new CompactJsonFormatter();
var formatter = new ExpressionTemplate(
    "{ {_t: @t, _msg: @m, _props: @p} }\n");
Log.Logger = new LoggerConfiguration()
    // .MinimumLevel.Information()
    .Enrich.FromLogContext()
    .WriteTo.Console(formatter)
    .WriteTo.File(formatter, "logs/host-.txt", rollingInterval: RollingInterval.Hour)
    .CreateBootstrapLogger();

var currentDomain = AppDomain.CurrentDomain;
currentDomain.UnhandledException += (_, eventArgs) =>
{
    var e = (Exception)eventArgs.ExceptionObject;
    Log.Error(e, "執行命令時發生非預期的錯誤");
};

try
{
    Log.Information("程序開始");
    await Host.CreateDefaultBuilder(args)
            .UseSerilog()
            .UseConsoleLifetime()
            .UseSpectreConsole(config => { config.AddCommand<FileSizeAsyncCommand>("filesize"); })
            .RunConsoleAsync()
        ;
    Console.WriteLine("程序結束");
    return Environment.ExitCode;
}
catch (Exception e)
{
    Log.Error(e, "執行命令時發生非預期的錯誤");
    return -1;
}

 

參考

Spectre.Console - Spectre.Console.Cli (spectreconsole.net)

【笨問題】CLI 參數為什麼有時要加 "--"? POSIX 參數慣例的冷知識-黑暗執行緒 (darkthread.net)

範例位置

sample.dotblog/Args/Lab.SpectreConsole at 3a55d590d0539d1fb9e3ab1a64300f1bf53f2f16 · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo