ConsoleAppFramework

ConsoleAppFramework

基于源代码生成器的高性能C#命令行框架

ConsoleAppFramework是一个基于C#源代码生成器的命令行应用框架。它实现了零依赖、零开销、零反射和零分配,充分利用.NET 8和C# 12的新特性,提供出色的性能和小巧的二进制文件。框架功能丰富,包括信号处理、过滤器管道、多命令支持和依赖注入等。ConsoleAppFramework保持了灵活性和可扩展性,适合构建各种高效的命令行应用。

ConsoleAppFrameworkCLI框架性能优化源代码生成命令行解析Github开源项目

ConsoleAppFramework

GitHub Actions Releases

ConsoleAppFramework v5是一个零依赖、零开销、零反射、零分配、AOT安全的CLI框架,由C#源代码生成器驱动;实现了极高的性能和最小的二进制大小。利用.NET 8和C# 12的最新特性(IncrementalGenerator托管函数指针params数组和默认值lambda表达式ISpanParsable<T>PosixSignalRegistration等),该库确保了最高性能的同时保持灵活性和可扩展性。

图片

设置RunStrategy=ColdStart WarmupCount=0以计算冷启动基准,适用于CLI应用程序。

神奇的性能是通过静态生成所有内容和内联解析实现的。让我们看一个最小示例:

using ConsoleAppFramework; // args: ./cmd --foo 10 --bar 20 ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}"));

与典型的使用属性作为生成键的源代码生成器不同,ConsoleAppFramework分析提供的lambda表达式或方法引用,并生成Run方法的实际代码体。

internal static partial class ConsoleApp { // 生成与lambda表达式匹配的Run方法本身,包括参数和主体 public static void Run(string[] args, Action<int, int> command) { // 代码主体 } }
<details><summary>完整生成的源代码</summary>
namespace ConsoleAppFramework; internal static partial class ConsoleApp { public static void Run(string[] args, Action<int, int> command) { if (TryShowHelpOrVersion(args, 2, -1)) return; var arg0 = default(int); var arg0Parsed = false; var arg1 = default(int); var arg1Parsed = false; try { for (int i = 0; i < args.Length; i++) { var name = args[i]; switch (name) { case "--foo": { if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); } arg0Parsed = true; break; } case "--bar": { if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); } arg1Parsed = true; break; } default: if (string.Equals(name, "--foo", StringComparison.OrdinalIgnoreCase)) { if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); } arg0Parsed = true; break; } if (string.Equals(name, "--bar", StringComparison.OrdinalIgnoreCase)) { if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); } arg1Parsed = true; break; } ThrowArgumentNameNotFound(name); break; } } if (!arg0Parsed) ThrowRequiredArgumentNotParsed("foo"); if (!arg1Parsed) ThrowRequiredArgumentNotParsed("bar"); command(arg0!, arg1!); } catch (Exception ex) { Environment.ExitCode = 1; if (ex is ValidationException or ArgumentParseFailedException) { LogError(ex.Message); } else { LogError(ex.ToString()); } } } [MethodImpl(MethodImplOptions.AggressiveInlining)] static bool TryIncrementIndex(ref int index, int length) { if (index < length) { index++; return true; } return false; } static partial void ShowHelp(int helpId) { Log(""" Usage: [options...] [-h|--help] [--version] Options: --foo <int> (Required) --bar <int> (Required) """); } }
</details>

如你所见,代码直接明了,很容易想象框架部分的执行成本。没错,它是零。这种技术受到Rust宏的影响。Rust有类属性宏和类函数宏,ConsoleAppFramework的生成可以被视为类函数宏。

ConsoleApp类以及其他所有内容都完全由源代码生成器生成,因此没有任何依赖,包括ConsoleAppFramework本身。这个特性应该有助于减小程序集大小并易于处理,包括支持Native AOT。

此外,CLI应用程序通常涉及从冷启动的单次执行。因此,常见的优化技术如动态代码生成(IL Emit、ExpressionTree.Compile)和缓存(ArrayPool)并不能有效工作。ConsoleAppFramework预先静态生成所有内容,在没有反射或装箱的情况下实现了与优化手写代码相当的性能。

ConsoleAppFramework作为框架提供了丰富的功能集。源代码生成器分析正在使用的模块,并生成实现所需功能的最小代码。

  • 通过CancellationToken处理SIGINT/SIGTERM(Ctrl+C)并优雅关闭
  • 过滤器(中间件)管道以拦截执行前/后
  • 退出代码管理
  • 支持异步命令
  • 注册多个命令
  • 注册嵌套命令
  • 从代码文档注释设置选项别名和描述
  • 基于System.ComponentModel.DataAnnotations属性的验证
  • 通过类型和公共方法进行命令注册的依赖注入
  • Microsoft.Extensions(日志记录、配置等)集成
  • 通过ISpanParsable<T>进行高性能值解析
  • 解析params数组
  • 解析JSON参数
  • 帮助(-h|--help)选项构建器
  • 默认显示版本(--version)选项

正如你从生成的输出中看到的,帮助显示也很快。在典型框架中,帮助字符串是在调用帮助后构建的。然而,在ConsoleAppFramework中,帮助作为字符串常量嵌入,实现了无法超越的绝对最大性能!

入门

该库通过NuGet分发,最低要求是.NET 8和C# 12。

PM> Install-Package ConsoleAppFramework

ConsoleAppFramework是一个分析器(源代码生成器),没有任何dll引用。引用时,入口点类ConsoleAppFramework.ConsoleApp在内部生成。

RunRunAsync的第一个参数可以是string[] args,第二个参数可以是任何lambda表达式、方法或函数引用。根据第二个参数的内容,自动生成相应的函数。

using ConsoleAppFramework; ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

你可以执行如sampletool --name "foo"的命令。

  • 返回值可以是voidintTaskTask<int>
    • 如果返回int,该值将设置为Environment.ExitCode
  • 默认情况下,选项参数名称转换为--lower-kebab-case
    • 例如,jsonValue变为--json-value
    • 选项参数名称不区分大小写,但小写匹配速度更快

传递方法时,可以这样写:

ConsoleApp.Run(args, Sum); void Sum(int x, int y) => Console.Write(x + y);

此外,对于静态函数,你可以将它们作为函数指针传递。在这种情况下,将生成托管函数指针参数,从而实现最大性能。

unsafe { ConsoleApp.Run(args, &Sum); } static void Sum(int x, int y) => Console.Write(x + y);
public static unsafe void Run(string[] args, delegate* managed<int, int, void> command)

不幸的是,目前静态lambda不能分配给函数指针,所以需要定义一个命名函数。

使用lambda表达式定义异步方法时,需要async关键字。

// --foo, --bar await ConsoleApp.RunAsync(args, async (int foo, int bar, CancellationToken cancellationToken) => { await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); Console.WriteLine($"Sum: {foo + bar}"); });

你可以使用RunRunAsync方法进行调用。使用CancellationToken作为参数是可选的。这成为一个特殊参数,并从命令选项中排除。内部使用PosixSignalRegistration来处理SIGINTSIGTERMSIGKILL。当调用这些信号(例如Ctrl+C)时,CancellationToken被设置为CancellationRequested。如果不使用CancellationToken作为参数,将不会处理这些信号,程序将立即终止。有关更多详细信息,请参阅CancellationToken和优雅关闭部分。

选项别名和帮助、版本

默认情况下,如果提供了-h--help,或者没有传递参数,将调用帮助显示。

ConsoleApp.Run(args, (string message) => Console.Write($"Hello, {message}"));
Usage: [options...] [-h|--help] [--version] Options: --message <string> (Required)

在ConsoleAppFramework中,不使用属性,而是通过编写文档注释来为函数提供描述和别名。这避免了框架中常见的参数被属性填满导致代码难以阅读的问题。通过这种方法,实现了自然的书写风格。

ConsoleApp.Run(args, Commands.Hello); static class Commands { /// <summary> /// 显示Hello。 /// </summary> /// <param name="message">-m, 要显示的消息。</param> public static void Hello(string message) => Console.Write($"Hello, {message}"); }
Usage: [options...] [-h|--help] [--version] 显示Hello。 Options: -m|--message <string> 要显示的消息。(Required)

要为参数添加别名,在注释中在逗号前列出用 | 分隔的别名。例如,如果你写一个注释像 -a|-b|--abcde, 描述。,那么 -a-b--abcde 将被视为别名,而 描述。 将是描述。

不幸的是,由于当前 C# 规范,lambda 表达式和局部函数不支持文档注释,所以需要一个类。

除了 -h|--help,还有另一个特殊的内置选项:--version。默认情况下,它显示 AssemblyInformationalVersionAssemblyVersion。你可以通过 ConsoleApp.Version 配置版本字符串,例如 ConsoleApp.Version = "2001.9.3f14-preview2";

命令

如果你想注册多个命令或执行复杂操作(如添加过滤器),不要使用 Run/RunAsync,而是使用 ConsoleApp.Create() 获取 ConsoleAppBuilder。在 ConsoleAppBuilder 上多次调用 AddAdd<T>UseFilter<T> 来注册命令和过滤器,最后使用 RunRunAsync 执行应用程序。

var app = ConsoleApp.Create(); app.Add("", (string msg) => Console.WriteLine(msg)); app.Add("echo", (string msg) => Console.WriteLine(msg)); app.Add("sum", (int x, int y) => Console.WriteLine(x + y)); // --msg // echo --msg // sum --x --y app.Run(args);

Add 的第一个参数是命令名。如果指定空字符串 "",它就成为根命令。与参数不同,命令名区分大小写,不能有多个名称。

使用 Add<T>,你可以使用基于类的方法一次添加多个命令,其中公共方法被视为命令。如果你想为多个命令写文档注释,这种方法允许更清晰的代码,所以推荐使用。此外,如后面提到的,你也可以使用构造函数注入为依赖注入(DI)编写清晰的代码。

var app = ConsoleApp.Create(); app.Add<MyCommands>(); app.Run(args); public class MyCommands { /// <summary>根命令测试。</summary> /// <param name="msg">-m, 要显示的消息。</param> [Command("")] public void Root(string msg) => Console.WriteLine(msg); /// <summary>显示消息。</summary> /// <param name="msg">要显示的消息。</param> public void Echo(string msg) => Console.WriteLine(msg); /// <summary>参数求和。</summary> /// <param name="x">左值。</param> /// <param name="y">右值。</param> public void Sum(int x, int y) => Console.WriteLine(x + y); }

当你用 --help 检查注册的命令时,它会看起来像这样。注意你可以注册多个 Add<T> 并且也可以使用 Add 添加命令。

用法: [命令] [选项...] [-h|--help] [--version] 根命令测试。 选项: -m|--msg <string> 要显示的消息。(必需) 命令: echo 显示消息。 sum 参数求和。

默认情况下,命令名是从方法名转换为 lower-kebab-case 得到的。但是,你可以使用 [Command(string commandName)] 属性将名称更改为任何所需的值。

如果类实现了 IDisposableIAsyncDisposable,Dispose 或 DisposeAsync 方法将在命令执行后被调用。

嵌套命令

你可以通过在注册时添加用空格( )分隔的路径来创建深层命令层次结构。这允许你在嵌套级别添加命令。

var app = ConsoleApp.Create(); app.Add("foo", () => { }); app.Add("foo bar", () => { }); app.Add("foo bar barbaz", () => { }); app.Add("foo baz", () => { }); // 命令: // foo // foo bar // foo bar barbaz // foo baz app.Run(args);

Add<T> 也可以通过传递 string commandPath 参数将命令添加到层次结构中。

var app = ConsoleApp.Create(); app.Add<MyCommands>("foo"); // 命令: // foo 根命令测试。 // foo echo 显示消息。 // foo sum 参数求和。 app.Run(args);

命令的性能

ConsoleAppFramework 中,注册的命令的数量和类型在编译时静态确定。例如,让我们注册以下四个命令:

app.Add("foo", () => { }); app.Add("foo bar", (int x, int y) => { }); app.Add("foo bar barbaz", (DateTime dateTime) => { }); app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { });

Source Generator 生成四个字段并以特定类型持有它们。

partial struct ConsoleAppBuilder { Action command0 = default!; Action<int, int> command1 = default!; Action<global::System.DateTime> command2 = default!; Func<string, global::System.Threading.CancellationToken, Task> command3 = default!; partial void AddCore(string commandName, Delegate command) { switch (commandName) { case "foo": this.command0 = Unsafe.As<Action>(command); break; case "foo bar": this.command1 = Unsafe.As<Action<int, int>>(command); break; case "foo bar barbaz": this.command2 = Unsafe.As<Action<global::System.DateTime>>(command); break; case "foo baz": this.command3 = Unsafe.As<Func<string, global::System.Threading.CancellationToken, Task>>(command); break; default: break; } } }

这确保了最快的执行速度,没有任何额外的不必要的分配,如数组,也没有任何装箱,因为它持有静态委托类型。

命令路由也生成了一个嵌套的字符串常量 switch。

partial void RunCore(string[] args) { if (args.Length == 0) { ShowHelp(-1); return; } switch (args[0]) { case "foo": if (args.Length == 1) { RunCommand0(args, args.AsSpan(1), command0); return; } switch (args[1]) { case "bar": if (args.Length == 2) { RunCommand1(args, args.AsSpan(2), command1); return; } switch (args[2]) { case "barbaz": RunCommand2(args, args.AsSpan(3), command2); break; default: RunCommand1(args, args.AsSpan(2), command1); break; } break; case "baz": RunCommand3(args, args.AsSpan(2), command3); break; default: RunCommand0(args, args.AsSpan(1), command0); break; } break; default: ShowHelp(-1); break; } }

C# 编译器对字符串常量 switch 进行复杂的生成,使其极快,很难实现比这更快的路由。

解析和值绑定

方法参数名和类型决定了如何从命令行参数解析和绑定值。当使用 lambda 表达式时,C# 12 支持的可选值和 params 数组也被支持。

ConsoleApp.Run(args, ( [Argument]DateTime dateTime, // 参数 [Argument]Guid guidvalue, // int intVar, // 必需 bool boolFlag, // 标志 MyEnum enumValue, // 枚举 int[] array, // 数组 MyClass obj, // 对象 string optional = "abcde", // 可选 double? nullableValue = null, // 可空 params string[] paramsArray // params ) => { });

当使用 ConsoleApp.Run 时,你可以在工具提示中查看生成的命令行语法。

image

关于将参数名转换为选项名、别名以及如何设置文档的规则,请参考 选项别名 部分。

标记有 [Argument] 属性的参数按顺序接收值,无需参数名。此属性只能从开始设置在连续的参数上。

要从字符串参数转换为各种类型,基本原始类型(stringcharsbytebyteshortintlonguintushortulongdecimalfloatdouble)使用 TryParse。对于实现 ISpanParsable<T> 的类型(DateTimeDateTimeOffsetGuidBigIntegerComplexHalfInt128 等),使用 IParsable<TSelf>.TryParseISpanParsable<TSelf>.TryParse

对于 enum,使用 Enum.TryParse(ignoreCase: true) 进行解析。

bool 被视为标志,始终是可选的。当传递参数名时,它变为 true

数组

数组解析有三种特殊模式。

对于普通的 T[],如果值以 [ 开头,则使用 JsonSerializer.Deserialize 解析。否则,解析为逗号分隔的值。例如,[1,2,3]1,2,3 都是允许的值。要设置空数组,传递 []

对于 params T[],所有后续参数都成为数组的值。例如,如果有像 --paramsArray foo bar baz 这样的输入,它将被绑定到一个像 ["foo", "bar", "baz"] 这样的值。

对象

如果以上情况都不适用,则使用 JsonSerializer.Deserialize<T> 进行 JSON 绑定。然而,CancellationTokenConsoleAppContext 被视为特殊类型并排除在绑定之外。此外,带有 [FromServices] 属性的参数不受绑定。

如果你想更改反序列化选项,可以将 JsonSerializerOptions 设置为 ConsoleApp.JsonSerializerOptions

自定义值转换器

要对不支持 ISpanParsable<T> 的现有类型执行自定义绑定,你可以创建并设置自定义解析器。例如,如果你想将 System.Numerics.Vector3 作为逗号分隔的字符串传递,如 1.3,4.12,5.947,并解析它,你可以创建一个带有 AttributeTargets.ParameterAttribute,实现 IArgumentParser<T>static bool TryParse(ReadOnlySpan<char> s, out Vector3 result) 如下:

[AttributeUsage(AttributeTargets.Parameter)] public class Vector3ParserAttribute : Attribute, IArgumentParser<Vector3> { public static bool TryParse(ReadOnlySpan<char> s, out Vector3 result) { Span<Range> ranges = stackalloc Range[3]; var splitCount = s.Split(ranges, ','); if (splitCount != 3) { result = default; return false; } float x; float y; float z; if (float.TryParse(s[ranges[0]], out x) && float.TryParse(s[ranges[1]], out y) && float.TryParse(s[ranges[2]], out z)) { result = new Vector3(x, y, z); return true; } result = default; return false; } }

通过在参数上设置此属性,在解析参数时将调用自定义解析器。

ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position));

语法解析策略和性能

虽然命令行参数有一些标准,如UNIX工具和POSIX,但并没有绝对的规范。System.CommandLine的命令行语法概述解释了System.CommandLine采用的规范。然而,ConsoleAppFramework虽然在一定程度上参考了这些规范,但并不一定完全遵循它们。

例如,基于-x-X改变行为或允许将-f -d -x捆绑为-fdx的规范不容易理解,而且解析时间较长。System.CommandLine的性能不佳可能受到其遵循复杂语法的影响。因此,ConsoleAppFramework优先考虑性能和明确的规则。它以小写烤串式命名为基础,同时允许不区分大小写的匹配。它不支持无法单程处理或需要时间解析的模糊语法。

System.CommandLine似乎在.NET 9和.NET 10中瞄准新方向,但从性能角度来看,它永远无法超越ConsoleAppFramework。

CancellationToken(优雅关闭)和超时

在ConsoleAppFramework中,当你传递一个CancellationToken作为参数时,它可以用于检查中断命令(SIGINT/SIGTERM/SIGKILL - Ctrl+C),而不是被视为参数。为了处理这种情况,当参数中包含CancellationToken时,ConsoleAppFramework会执行特殊的代码生成。

using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout); var arg0 = posixSignalHandler.Token; await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken);

如果没有传递CancellationToken,当收到中断命令(Ctrl+C)时,应用程序会立即被强制终止。但是,如果存在CancellationToken,它会在内部使用PosixSignalRegistration来钩住SIGINT/SIGTERM/SIGKILL,并将CancellationToken设置为取消状态。此外,它会阻止强制终止以允许优雅关闭。

如果正确处理CancellationToken,应用程序可以根据应用程序的处理执行适当的终止处理。然而,如果CancellationToken处理不当,即使收到中断命令,应用程序也可能不会终止。为了避免这种情况,中断命令后会启动超时计时器,在指定时间后再次强制终止应用程序。

默认超时时间为5秒,但可以使用ConsoleApp.Timeout进行更改。例如,设置ConsoleApp.Timeout = Timeout.InfiniteTimeSpan;可以禁用由超时引起的强制终止。

使用PosixSignalRegistration的钩子行为由CancellationToken的存在决定(或如果设置了过滤器,则始终生效)。因此,即使对于同步方法,也可以通过包含CancellationToken作为参数来改变行为。

退出代码

如果方法返回intTask<int>,ConsoleAppFramework将把返回值设置为退出代码。由于代码生成的性质,在编写lambda表达式时,你需要明确指定intTask<int>

// 返回随机退出代码... ConsoleApp.Run(args, int () => Random.Shared.Next());
// 返回状态码 await ConsoleApp.RunAsync(args, async Task<int> (string url, CancellationToken cancellationToken) => { using var client = new HttpClient(); var response = await client.GetAsync(url, cancellationToken); return (int)response.StatusCode; });

如果方法抛出未处理的异常,ConsoleAppFramework总是将退出代码设置为1。此外,在这种情况下,会将Exception.ToString输出到ConsoleApp.LogError(默认为Console.WriteLine)。如果你想修改这个代码,请创建自定义过滤器。更多详情,请参阅过滤器部分。

基于属性的参数验证

当参数标记有来自System.ComponentModel.DataAnnotations的验证属性时(更准确地说,实现ValidationAttribute的属性),ConsoleAppFramework会执行验证。验证发生在参数绑定之后,命令执行之前。如果验证失败,它会抛出ValidationException

ConsoleApp.Run(args, ([EmailAddress] string firstArg, [Range(0, 2)] int secondArg) => { });

例如,如果你传递像args = "--first-arg invalid.email --second-arg 10".Split(' ');这样的参数,你会看到类似这样的验证失败消息:

firstArg字段不是有效的电子邮件地址。 secondArg字段必须在0和2之间。

在这种情况下,默认情况下ExitCode设置为1。

过滤器(中间件)管道 / ConsoleAppContext

过滤器作为一种机制提供,用于在执行前后进行钩子。要使用过滤器,定义一个实现ConsoleAppFilterinternal class

internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // 构造函数需要`ConsoleAppFilter next`并调用base(next) { // 实现InvokeAsync作为过滤器主体 public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { try { /* 执行前 */ await Next.InvokeAsync(context, cancellationToken); // 调用下一个过滤器或命令主体 /* 执行后 */ } catch { /* 出错时 */ throw; } finally { /* 最后执行 */ } } }

过滤器可以使用UseFilter<T>[ConsoleAppFilter<T>]多次附加到"全局"、"类"或"方法"。过滤器的顺序是全局 → 类 → 方法,执行顺序由定义顺序从上到下决定。

var app = ConsoleApp.Create(); // 全局过滤器 app.UseFilter<NopFilter>(); //顺序1 app.UseFilter<NopFilter>(); //顺序2 app.Add<MyCommand>(); app.Run(args); // 每个类的过滤器 [ConsoleAppFilter<NopFilter>] // 顺序3 [ConsoleAppFilter<NopFilter>] // 顺序4 public class MyCommand { // 每个方法的过滤器 [ConsoleAppFilter<NopFilter>] // 顺序5 [ConsoleAppFilter<NopFilter>] // 顺序6 public void Echo(string msg) => Console.WriteLine(msg); }

过滤器允许共享各种处理。例如,测量执行时间的过程可以这样写:

internal class LogRunningTimeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { var startTime = Stopwatch.GetTimestamp(); ConsoleApp.Log($"在 {DateTime.UtcNow.ToLocalTime()} 执行命令"); // 本地时间便于人类阅读 try { await Next.InvokeAsync(context, cancellationToken); ConsoleApp.Log($"命令在 {DateTime.UtcNow.ToLocalTime()} 成功执行, 耗时: " + (Stopwatch.GetElapsedTime(startTime))); } catch { ConsoleApp.Log($"命令在 {DateTime.UtcNow.ToLocalTime()} 执行失败, 耗时: " + (Stopwatch.GetElapsedTime(startTime))); throw; } } }

在发生异常的情况下,ExitCode通常为1,并且还会显示堆栈跟踪。然而,通过应用异常处理过滤器,可以改变这种行为。

internal class ChangeExitCodeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { try { await Next.InvokeAsync(context, cancellationToken); } catch (Exception ex) { if (ex is OperationCanceledException) return; Environment.ExitCode = 9999; // 更改自定义退出代码 ConsoleApp.LogError(ex.Message); // .ToString()显示堆栈跟踪,.Message可以避免向用户显示堆栈跟踪。 } } }

过滤器在命令名路由完成后执行。如果你想禁止每个命令名的多次执行,可以使用ConsoleAppContext.CommandName作为键。

internal class PreventMultipleSameCommandInvokeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { var basePath = Assembly.GetEntryAssembly()?.Location.Replace(Path.DirectorySeparatorChar, '_'); var mutexKey = $"{basePath}$$${context.CommandName}"; // 每个命令名锁定 using var mutex = new Mutex(true, mutexKey, out var createdNew); if (!createdNew) { throw new Exception($"命令:{context.CommandName} 已在另一个进程中运行。"); } await Next.InvokeAsync(context, cancellationToken); } }

如果你想在过滤器之间或向命令传递值,可以使用ConsoleAppContext.State。例如,如果你想执行身份验证处理并传递ID,可以这样写代码。由于ConsoleAppContext是不可变的记录,你需要使用with语法将重写的上下文传递给Next。

internal class AuthenticationFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { var requestId = Guid.NewGuid(); var userId = await GetUserIdAsync(); // 设置新状态到上下文 var authedContext = context with { State = new ApplicationContext(requestId, userId) }; await Next.InvokeAsync(authedContext, cancellationToken); } // 从数据库/认证服务/其他获取用户ID async Task<int> GetUserIdAsync() { await Task.Delay(TimeSpan.FromSeconds(1)); return 1999; } } record class ApplicationContext(Guid RequiestId, int UserId);

命令可以接受ConsoleAppContext作为参数。这允许使用由过滤器处理的值。

var app = ConsoleApp.Create(); app.UseFilter<AuthenticationFilter>(); app.Add("", (int x, int y, ConsoleAppContext context) => { var appContext = (ApplicationContext)context.State!; var requestId = appContext.RequiestId; var userId = appContext.UserId; Console.WriteLine($"请求:{requestId} 用户:{userId} 总和:{x + y}"); }); app.Run(args);

ConsoleAppContext还有一个ConsoleAppContext.Arguments属性,允许你获取传递给Run/RunAsync的(string[] args)。

在项目间共享过滤器

ConsoleAppFilter由Source Generator为每个项目定义为internal。因此,提供了一个额外的库,用于跨项目引用公共过滤器定义。

PM> Install-Package ConsoleAppFramework.Abstractions 这个库包含以下类:

  • IArgumentParser<T>
  • ConsoleAppContext
  • ConsoleAppFilter
  • ConsoleAppFilterAttribute<T>

在内部引用 ConsoleAppFramework.Abstractions 时,会添加 USE_EXTERNAL_CONSOLEAPP_ABSTRACTIONS 编译符号。这会禁用由源代码生成器生成的上述类,优先使用库中的类。

过滤器的性能

在一般框架中,过滤器是在运行时动态添加的,导致过滤器数量可变。因此,需要使用动态数组分配。在 ConsoleAppFramework 中,过滤器的数量在编译时静态确定,无需任何额外的分配,如数组或 lambda 表达式捕获。分配量等于正在使用的过滤器类的数量加 1(用于包装命令方法),从而产生最短的执行路径。

app.UseFilter<NopFilter>(); app.UseFilter<NopFilter>(); app.UseFilter<NopFilter>(); app.UseFilter<NopFilter>(); app.UseFilter<NopFilter>(); // 上述代码将生成以下代码: sealed class Command0Invoker(string[] args, Action command) : ConsoleAppFilter(null!) { public ConsoleAppFilter BuildFilter() { var filter0 = new NopFilter(this); var filter1 = new NopFilter(filter0); var filter2 = new NopFilter(filter1); var filter3 = new NopFilter(filter2); var filter4 = new NopFilter(filter3); return filter4; } public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { return RunCommand0Async(context.Arguments, args, command, context, cancellationToken); } }

async Task 同步完成时,它返回等同于 Task.CompletedTask 的内容,因此不需要 ValueTask

依赖注入(日志记录、配置等)

ConsoleAppFramework 的执行处理完全支持 DI。当你想使用日志记录器、读取配置或与 ASP.NET 项目共享处理时,使用 Microsoft.Extensions.DependencyInjection 或其他 DI 库可以使处理变得方便。

传递给 Run 的 lambda 表达式、类构造函数、方法和过滤器构造函数可以注入从 IServiceProvider 获得的服务。让我们看一个最小示例。将任何 System.IServiceProvider 设置为 ConsoleApp.ServiceProvider 可以在整个系统中启用 DI。

// Microsoft.Extensions.DependencyInjection var services = new ServiceCollection(); services.AddTransient<MyService>(); using var serviceProvider = services.BuildServiceProvider(); // 只要能创建 IServiceProvider,就可以使用任何 DI 库 ConsoleApp.ServiceProvider = serviceProvider; // 传递给 lambda 表达式/方法时,使用 [FromServices] 表示它是通过 DI 传递的,而不是作为参数 ConsoleApp.Run(args, ([FromServices]MyService service, int x, int y) => Console.WriteLine(x + y));

传递给 lambda 表达式或方法时,使用 [FromServices] 属性来区分命令参数。传递类时,可以使用构造函数注入,从而使外观更简单。

让我们尝试注入一个日志记录器并启用文件输出。使用的库是 Microsoft.Extensions.Logging 和 Cysharp/ZLogger(一个建立在 MS.E.Logging 之上的高性能日志记录器)。

// 包导入:ZLogger var services = new ServiceCollection(); services.AddLogging(x => { x.ClearProviders(); x.SetMinimumLevel(LogLevel.Trace); x.AddZLoggerConsole(); x.AddZLoggerFile("log.txt"); }); using var serviceProvider = services.BuildServiceProvider(); // 使用 using 进行日志刷新(重要!) ConsoleApp.ServiceProvider = serviceProvider; var app = ConsoleApp.Create(); app.Add<MyCommand>(); app.Run(args); // 将日志记录器注入构造函数 public class MyCommand(ILogger<MyCommand> logger) { [Command("")] public void Echo(string msg) { logger.ZLogInformation($"Message is {msg}"); } }

ConsoleApp 有可替换的默认日志记录方法 ConsoleApp.LogConsoleApp.LogError,用于帮助显示和异常处理。如果使用 ILogger<T>,最好也替换这些。

using var serviceProvider = services.BuildServiceProvider(); // 使用 using 进行清理(重要) ConsoleApp.ServiceProvider = serviceProvider; // 设置 ConsoleApp 系统日志记录器 var logger = serviceProvider.GetRequiredService<ILogger<Program>>(); ConsoleApp.Log = msg => logger.LogInformation(msg); ConsoleApp.LogError = msg => logger.LogError(msg);

在从 appsettings.json 读取应用程序配置时,DI 也可以有效使用。例如,假设你有以下 JSON 文件。

{ "Position": { "Title": "Editor", "Name": "Joe Smith" }, "MyKey": "My appsettings.json Value", "AllowedHosts": "*" }

使用 Microsoft.Extensions.Configuration.Json,可以按如下方式进行读取、绑定和 DI 注册。

// 包导入:Microsoft.Extensions.Configuration.Json var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); // 绑定到服务(包导入:Microsoft.Extensions.Options.ConfigurationExtensions) var services = new ServiceCollection(); services.Configure<PositionOptions>(configuration.GetSection("Position")); using var serviceProvider = services.BuildServiceProvider(); ConsoleApp.ServiceProvider = serviceProvider; var app = ConsoleApp.Create(); app.Add<MyCommand>(); app.Run(args); // 注入选项 public class MyCommand(IOptions<PositionOptions> options) { [Command("")] public void Echo(string msg) { ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}"); } } public class PositionOptions { public string Title { get; set; } = ""; public string Name { get; set; } = ""; }

如果整个项目中有其他应用程序(如 ASP.NET),并且想使用通过 Microsoft.Extensions.Hosting 设置的共同 DI 和配置,可以在构建后设置 IHostIServiceProvider 来共享它们。

// 包导入:Microsoft.Extensions.Hosting var builder = Host.CreateApplicationBuilder(); // 不要传递 args using var host = builder.Build(); // 使用 using 管理主机生命周期 using var scope = host.Services.CreateScope(); // 创建执行范围 ConsoleApp.ServiceProvider = scope.ServiceProvider; // 使用主机范围的 ServiceProvider ConsoleApp.Run(args, ([FromServices] ILogger<Program> logger) => logger.LogInformation("Hello World!"));

ConsoleAppFramework 有自己的生命周期管理(参见 CancellationToken(优雅关闭) 和 Timeout 部分),所以不需要 Host 的 Start/Stop。但是,请确保使用 Host 本身。

这样,DI 作用域就没有设置,但通过使用全局过滤器,可以为每个命令执行添加一个作用域。ConsoleAppFilter 也可以通过构造函数注入注入服务,所以让我们获取 IServiceProvider

var app = ConsoleApp.Create(); app.UseFilter<ServiceProviderScopeFilter>(); internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { // 创建 Microsoft.Extensions.DependencyInjection 作用域 await using var scope = serviceProvider.CreateAsyncScope(); await Next.InvokeAsync(context, cancellationToken); } }

然而,由于过滤器的构建是在执行之前进行的,使用作用域的自动注入只对命令体本身有效。

发布为可执行文件

在 .NET 中运行 CLI 应用程序有多种方式:

当你想直接执行 csproj,例如在 CI 中启动命令工具时,run 很方便。buildpublish 非常相似,所以可以笼统地讨论它们,但很难谈论具体的差异。更多细节,建议查看 build vs publish -- can they be friends? · Issue #26247 · dotnet/sdk

此外,要使用 Native AOT 运行,请参考 Native AOT deployment overview。无论如何,ConsoleAppFramework 彻底实现了无依赖和无反射的方法,所以不应该成为执行的障碍。

v4 -> v5 迁移指南

v4 是在 Microsoft.Extensions.Hosting 之上运行的,所以以相同的方式构建 Host 并设置 ServiceProvider。

using var host = Host.CreateDefaultBuilder().Build(); // 使用 using 管理主机生命周期 using var scope = host.Services.CreateScope(); // 创建执行作用域 ConsoleApp.ServiceProvider = scope.ServiceProvider;
  • var app = ConsoleApp.Create(args); app.Run(); -> var app = ConsoleApp.Create(); app.Run(args);
  • app.AddCommand/AddSubCommand -> app.Add(string commandName)
  • app.AddRootCommand -> app.Add("")
  • app.AddCommands<T> -> app.Add<T>
  • app.AddSubCommands<T> -> app.Add<T>(string commandPath)
  • app.AddAllCommandType -> 不支持(手动使用 Add<T>
  • [Option(int index)] -> [Argument]
  • [Option(string shortName, string description)] -> Xml 文档注释
  • ConsoleAppFilter.Order -> 不支持(全局 -> 类 -> 方法声明顺序)
  • ConsoleAppOptions.GlobalFilters -> app.UseFilter<T>
  • ConsoleAppBase -> 将 ConsoleAppContextCancellationToken 注入到方法中

许可证

这个库使用 MIT 许可证。

编辑推荐精选

Keevx

Keevx

AI数字人视频创作平台

Keevx 一款开箱即用的AI数字人视频创作平台,广泛适用于电商广告、企业培训与社媒宣传,让全球企业与个人创作者无需拍摄剪辑,就能快速生成多语言、高质量的专业视频。

即梦AI

即梦AI

一站式AI创作平台

提供 AI 驱动的图片、视频生成及数字人等功能,助力创意创作

扣子-AI办公

扣子-AI办公

AI办公助手,复杂任务高效处理

AI办公助手,复杂任务高效处理。办公效率低?扣子空间AI助手支持播客生成、PPT制作、网页开发及报告写作,覆盖科研、商业、舆情等领域的专家Agent 7x24小时响应,生活工作无缝切换,提升50%效率!

TRAE编程

TRAE编程

AI辅助编程,代码自动修复

Trae是一种自适应的集成开发环境(IDE),通过自动化和多元协作改变开发流程。利用Trae,团队能够更快速、精确地编写和部署代码,从而提高编程效率和项目交付速度。Trae具备上下文感知和代码自动完成功能,是提升开发效率的理想工具。

热门AI工具生产力协作转型TraeAI IDE
蛙蛙写作

蛙蛙写作

AI小说写作助手,一站式润色、改写、扩写

蛙蛙写作—国内先进的AI写作平台,涵盖小说、学术、社交媒体等多场景。提供续写、改写、润色等功能,助力创作者高效优化写作流程。界面简洁,功能全面,适合各类写作者提升内容品质和工作效率。

AI助手AI工具AI写作工具AI辅助写作蛙蛙写作学术助手办公助手营销助手
问小白

问小白

全能AI智能助手,随时解答生活与工作的多样问题

问小白,由元石科技研发的AI智能助手,快速准确地解答各种生活和工作问题,包括但不限于搜索、规划和社交互动,帮助用户在日常生活中提高效率,轻松管理个人事务。

聊天机器人AI助手热门AI工具AI对话
Transly

Transly

实时语音翻译/同声传译工具

Transly是一个多场景的AI大语言模型驱动的同声传译、专业翻译助手,它拥有超精准的音频识别翻译能力,几乎零延迟的使用体验和支持多国语言可以让你带它走遍全球,无论你是留学生、商务人士、韩剧美剧爱好者,还是出国游玩、多国会议、跨国追星等等,都可以满足你所有需要同传的场景需求,线上线下通用,扫除语言障碍,让全世界的语言交流不再有国界。

讯飞智文

讯飞智文

一键生成PPT和Word,让学习生活更轻松

讯飞智文是一个利用 AI 技术的项目,能够帮助用户生成 PPT 以及各类文档。无论是商业领域的市场分析报告、年度目标制定,还是学生群体的职业生涯规划、实习避坑指南,亦或是活动策划、旅游攻略等内容,它都能提供支持,帮助用户精准表达,轻松呈现各种信息。

热门AI工具AI办公办公工具讯飞智文AI在线生成PPTAI撰写助手多语种文档生成AI自动配图
讯飞星火

讯飞星火

深度推理能力全新升级,全面对标OpenAI o1

科大讯飞的星火大模型,支持语言理解、知识问答和文本创作等多功能,适用于多种文件和业务场景,提升办公和日常生活的效率。讯飞星火是一个提供丰富智能服务的平台,涵盖科技资讯、图像创作、写作辅助、编程解答、科研文献解读等功能,能为不同需求的用户提供便捷高效的帮助,助力用户轻松获取信息、解决问题,满足多样化使用场景。

模型训练热门AI工具内容创作智能问答AI开发讯飞星火大模型多语种支持智慧生活
Spark-TTS

Spark-TTS

一种基于大语言模型的高效单流解耦语音令牌文本到语音合成模型

Spark-TTS 是一个基于 PyTorch 的开源文本到语音合成项目,由多个知名机构联合参与。该项目提供了高效的 LLM(大语言模型)驱动的语音合成方案,支持语音克隆和语音创建功能,可通过命令行界面(CLI)和 Web UI 两种方式使用。用户可以根据需求调整语音的性别、音高、速度等参数,生成高质量的语音。该项目适用于多种场景,如有声读物制作、智能语音助手开发等。

下拉加载更多