上篇 使用 PowerArgs 解析 Console / WinForm / WPF 的參數 介紹過怎麼通過 PowerArgs 解析參數、綁定行為,這次要用同事介紹的 Spectre.Console,他除了能解析參數轉成強型別之外還有很多華麗的功能,個人認為若是要設計 cli 它比 PowerArgs 來得容易些。
開發環境
- Rider 2023.1
- .NET 7
- Spectre.Console.Cli
快速開始
新增一個 Console 專案,安裝以下套件
dotnet add package Spectre.Console.Cli --version 0.46.0
- 實作 CommandSettings,把 cli 想要接收的參數轉換成強型別
- 實作 Command<CommandSettings>,把 cli 要執行的動作寫在 Execute 方法
- 建立 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
- 建立 CommandApp 實例
- 呼叫 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>
- add [PROJECT]
當我執行 .\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)
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET