ConsoleAppFramework
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)
{
// 代码主体
}
}
完整生成的源代码
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)
""");
}
}
如你所见,代码直接明了,很容易想象框架部分的执行成本。没错,它是零。这种技术受到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
在内部生成。
Run
或RunAsync
的第一个参数可以是string[] args
,第二个参数可以是任何lambda表达式、方法或函数引用。根据第二个参数的内容,自动生成相应的函数。
using ConsoleAppFramework;
ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));
你可以执行如sampletool --name "foo"
的命令。
- 返回值可以是
void
、int
、Task
或Task<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}");
});
你可以使用Run
或RunAsync
方法进行调用。使用CancellationToken
作为参数是可选的。这成为一个特殊参数,并从命令选项中排除。内部使用PosixSignalRegistration
来处理SIGINT
、SIGTERM
和SIGKILL
。当调用这些信号(例如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
。默认情况下,它显示 AssemblyInformationalVersion
或 AssemblyVersion
。你可以通过 ConsoleApp.Version
配置版本字符串,例如 ConsoleApp.Version = "2001.9.3f14-preview2";
。
命令
如果你想注册多个命令或执行复杂操作(如添加过滤器),不要使用 Run/RunAsync
,而是使用 ConsoleApp.Create()
获取 ConsoleAppBuilder
。在 ConsoleAppBuilder
上多次调用 Add
、Add<T>
或 UseFilter<T>
来注册命令和过滤器,最后使用 Run
或 RunAsync
执行应用程序。
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)]
属性将名称更改为任何所需的值。
如果类实现了 IDisposable
或 IAsyncDisposable
,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
时,你可以在工具提示中查看生成的命令行语法。
关于将参数名转换为选项名、别名以及如何设置文档的规则,请参考 选项别名 部分。
标记有 [Argument]
属性的参数按顺序接收值,无需参数名。此属性只能从开始设置在连续的参数上。
要从字符串参数转换为各种类型,基本原始类型(string
、char
、sbyte
、byte
、short
、int
、long
、uint
、ushort
、ulong
、decimal
、float
、double
)使用 TryParse
。对于实现 ISpanParsable<T>
的类型(DateTime
、DateTimeOffset
、Guid
、BigInteger
、Complex
、Half
、Int128
等),使用 IParsable
对于 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 绑定。然而,CancellationToken
和 ConsoleAppContext
被视为特殊类型并排除在绑定之外。此外,带有 [FromServices]
属性的参数不受绑定。
如果你想更改反序列化选项,可以将 JsonSerializerOptions
设置为 ConsoleApp.JsonSerializerOptions
。
自定义值转换器
要对不支持 ISpanParsable<T>
的现有类型执行自定义绑定,你可以创建并设置自定义解析器。例如,如果你想将 System.Numerics.Vector3
作为逗号分隔的字符串传递,如 1.3,4.12,5.947
,并解析它,你可以创建一个带有 AttributeTargets.Parameter
的 Attribute
,实现 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
作为参数来改变行为。
退出代码
如果方法返回int
或Task<int>
,ConsoleAppFramework
将把返回值设置为退出代码。由于代码生成的性质,在编写lambda表达式时,你需要明确指定int
或Task<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
过滤器作为一种机制提供,用于在执行前后进行钩子。要使用过滤器,定义一个实现ConsoleAppFilter
的internal 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.Log
和 ConsoleApp.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 和配置,可以在构建后设置 IHost
的 IServiceProvider
来共享它们。
// 包导入: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
很方便。build
和 publish
非常相似,所以可以笼统地讨论它们,但很难谈论具体的差异。更多细节,建议查看 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
-> 将ConsoleAppContext
、CancellationToken
注入到方法中
许可证
这个库使用 MIT 许可证。