Project Icon

ConsoleAppFramework

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

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

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)
    {
        // 代码主体
    }
}
完整生成的源代码
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在内部生成。

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.TryParseISpanParsable.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 许可证。

项目侧边栏1项目侧边栏2
推荐项目
Project Cover

豆包MarsCode

豆包 MarsCode 是一款革命性的编程助手,通过AI技术提供代码补全、单测生成、代码解释和智能问答等功能,支持100+编程语言,与主流编辑器无缝集成,显著提升开发效率和代码质量。

Project Cover

AI写歌

Suno AI是一个革命性的AI音乐创作平台,能在短短30秒内帮助用户创作出一首完整的歌曲。无论是寻找创作灵感还是需要快速制作音乐,Suno AI都是音乐爱好者和专业人士的理想选择。

Project Cover

有言AI

有言平台提供一站式AIGC视频创作解决方案,通过智能技术简化视频制作流程。无论是企业宣传还是个人分享,有言都能帮助用户快速、轻松地制作出专业级别的视频内容。

Project Cover

Kimi

Kimi AI助手提供多语言对话支持,能够阅读和理解用户上传的文件内容,解析网页信息,并结合搜索结果为用户提供详尽的答案。无论是日常咨询还是专业问题,Kimi都能以友好、专业的方式提供帮助。

Project Cover

阿里绘蛙

绘蛙是阿里巴巴集团推出的革命性AI电商营销平台。利用尖端人工智能技术,为商家提供一键生成商品图和营销文案的服务,显著提升内容创作效率和营销效果。适用于淘宝、天猫等电商平台,让商品第一时间被种草。

Project Cover

吐司

探索Tensor.Art平台的独特AI模型,免费访问各种图像生成与AI训练工具,从Stable Diffusion等基础模型开始,轻松实现创新图像生成。体验前沿的AI技术,推动个人和企业的创新发展。

Project Cover

SubCat字幕猫

SubCat字幕猫APP是一款创新的视频播放器,它将改变您观看视频的方式!SubCat结合了先进的人工智能技术,为您提供即时视频字幕翻译,无论是本地视频还是网络流媒体,让您轻松享受各种语言的内容。

Project Cover

美间AI

美间AI创意设计平台,利用前沿AI技术,为设计师和营销人员提供一站式设计解决方案。从智能海报到3D效果图,再到文案生成,美间让创意设计更简单、更高效。

Project Cover

AIWritePaper论文写作

AIWritePaper论文写作是一站式AI论文写作辅助工具,简化了选题、文献检索至论文撰写的整个过程。通过简单设定,平台可快速生成高质量论文大纲和全文,配合图表、参考文献等一应俱全,同时提供开题报告和答辩PPT等增值服务,保障数据安全,有效提升写作效率和论文质量。

投诉举报邮箱: service@vectorlightyear.com
@2024 懂AI·鲁ICP备2024100362号-6·鲁公网安备37021002001498号