别再把命令行参数写成一团:C#.NET System.CommandLine 实战详解

别再把命令行参数写成一团:C#.NET System.CommandLine 2.0 实战详解

控制台程序刚开始可能只有一个参数:

bash 复制代码
mytool --file app.log

随着功能增加,命令行往往会变成这样:

bash 复制代码
mytool config set --file appsettings.json --key Logging:LogLevel --value Debug

如果所有参数都靠 args[0]args[1] 手动判断,代码很快会遇到这些问题:参数顺序难以记忆,数字和文件路径需要手动转换,错误提示不统一,帮助信息还得另外编写。

System.CommandLine 是 .NET 生态中的命令行应用框架,负责命令树、参数解析、类型转换、校验、帮助信息、响应文件和补全等基础工作。业务代码只需要处理已经解析好的值。

本文以 System.CommandLine 2.0.x 为基础,先完成一个简单的文件读取工具,再逐步扩展成带 scancopy 子命令的文件工具。代码采用 2.0 的新 API,重点使用 SetActionParseResult.GetValue

提示:网上仍然能看到 SetHandlerCommandHandler.Create 等写法,它们主要来自 2.0 beta 版本。新项目应优先参考当前 2.0.x API;旧项目升级时需要阅读官方迁移说明。

System.CommandLine 到底解决什么问题?

命令行输入本质上是一组文本:

text 复制代码
--file app.log --count 10 --verbose

程序真正需要的却是强类型数据:

csharp 复制代码
FileInfo file;
int count;
bool verbose;

System.CommandLine 可以把这层转换和校验统一起来,同时根据命令定义自动生成帮助信息。

它的核心组成如下:

类型 白话解释 命令示例
RootCommand 整个 CLI 程序的入口 mytool
Command 一个具体命令或命令分组 scanconfig
Option<T> 带名称的选项 --file app.log
Argument<T> 按位置传入的参数 copy source.txt
ParseResult 解析后的结果,负责取值和错误信息 GetValue(option)
SetAction 命令匹配成功后执行的业务入口 扫描、复制、读取

结构可以理解成:

text 复制代码
用户输入
   ↓
命令树:RootCommand / Command
   ↓
Option<T> / Argument<T>
   ↓
ParseResult
   ↓
SetAction
   ↓
业务代码和退出码

安装 NuGet 包

创建控制台项目:

bash 复制代码
dotnet new console -n FileTool
cd FileTool
dotnet add package System.CommandLine

截至本文编写时,NuGet 上的稳定版本为 2.0.10。项目文件中也可以显式锁定版本:

xml 复制代码
<PackageReference Include="System.CommandLine" Version="2.0.10" />

版本号会随时间变化,正式项目应以 NuGet 页面为准,并通过依赖升级检查确认兼容性。文章中的 API 按 2.0.x 编写,不混用 beta4 或 beta5 的旧示例。

第一个 Demo:读取一个文件

先完成一个最小工具,支持下面的命令:

bash 复制代码
filetool --file ./README.md

定义 Option

Option<FileInfo> 表示 --file 后面必须是一个可以转换成 FileInfo 的值:

csharp 复制代码
using System.CommandLine;

Option<FileInfo> fileOption = new("--file")
{
    Description = "要读取的文件路径",
    Required = true
};

类型写成 FileInfo 后,业务层不必再手动 new FileInfo(path)。需要注意,FileInfo 能表示一个路径,并不代表文件一定存在;文件存在性仍然属于业务校验或自定义解析逻辑。

创建根命令并绑定动作

Program.cs

csharp 复制代码
using System.CommandLine;

Option<FileInfo> fileOption = new("--file")
{
    Description = "要读取的文件路径",
    Required = true
};

RootCommand rootCommand = new("读取文本文件的命令行工具");
rootCommand.Options.Add(fileOption);

rootCommand.SetAction(parseResult =>
{
    FileInfo file = parseResult.GetValue(fileOption)!;

    if (!file.Exists)
    {
        Console.Error.WriteLine($"文件不存在:{file.FullName}");
        return 2;
    }

    foreach (string line in File.ReadLines(file.FullName))
    {
        Console.WriteLine(line);
    }

    return 0;
});

return rootCommand.Parse(args).Invoke();

运行:

bash 复制代码
dotnet run -- --file ./README.md

代码可以拆成四个动作:

  1. 创建 Option<FileInfo>
  2. 将选项添加到根命令。
  3. SetAction 定义命令成功后的处理逻辑。
  4. 调用 Parse(args) 解析,再调用 Invoke() 执行。

Invoke() 返回 int 退出码。约定 0 表示成功,非 0 表示失败,脚本、流水线和操作系统都可以根据这个值判断结果。

Option<T>:命名选项

命名选项一般长这样:

bash 复制代码
mytool --port 8080

对应代码:

csharp 复制代码
Option<int> portOption = new("--port")
{
    Description = "监听端口",
    DefaultValueFactory = _ => 8080
};

读取选项值:

csharp 复制代码
int port = parseResult.GetValue(portOption);

添加短名称和别名

2.0 API 中,名称和别名可以分别添加:

csharp 复制代码
Option<string> outputOption = new("--output")
{
    Description = "输出文件路径"
};

outputOption.Aliases.Add("-o");
outputOption.Aliases.Add("--out");

下面三种写法都指向同一个选项:

bash 复制代码
mytool --output result.json
mytool --out result.json
mytool -o result.json

长名称表达语义,短名称适合频繁使用。别名不要堆得太多,否则帮助信息和使用习惯都会变复杂。

bool 开关与默认值

csharp 复制代码
Option<bool> verboseOption = new("--verbose")
{
    Description = "输出详细日志"
};

Option<int> retryOption = new("--retry")
{
    Description = "失败后的重试次数",
    DefaultValueFactory = _ => 3
};

调用 --verbose 后值为 true,没有出现时为 false。一般不写成 --verbose true,因为后面的 true 可能被当成额外 token。

读取默认值:

csharp 复制代码
bool verbose = parseResult.GetValue(verboseOption);
int retry = parseResult.GetValue(retryOption);

默认值也可以依赖当前解析结果:

csharp 复制代码
Option<string> formatOption = new("--format")
{
    Description = "输出格式",
    DefaultValueFactory = parseResult =>
        parseResult.GetValue(verboseOption) ? "json" : "text"
};

默认值之间存在依赖时,建议写清楚依赖关系,避免一个默认值偷偷受另一个选项影响。

Argument<T>:位置参数

位置参数不带 --name,而是按照命令定义的顺序传入:

bash 复制代码
mytool copy source.txt backup/source.txt

定义方式:

csharp 复制代码
Argument<FileInfo> sourceArgument = new("source")
{
    Description = "源文件"
};

Argument<FileInfo> targetArgument = new("target")
{
    Description = "目标文件"
};

Command copyCommand = new("copy", "复制文件")
{
    Arguments = { sourceArgument, targetArgument }
};

读取:

csharp 复制代码
FileInfo source = parseResult.GetValue(sourceArgument)!;
FileInfo target = parseResult.GetValue(targetArgument)!;

位置参数适合文件路径、任务 ID、资源名称等命令核心输入。选项很多时,关键配置更适合使用 --source--target,否则参数顺序很容易记错。

子命令:从单功能工具升级为 CLI

只做一件事的工具可以把选项放在根命令上;功能增加后,推荐按照用户动作拆成子命令:

text 复制代码
filetool
├── scan
└── copy

对应调用:

bash 复制代码
filetool scan --root ./logs --extension .log
filetool copy source.txt backup/source.txt --overwrite

创建子命令和全局选项

csharp 复制代码
Option<bool> verboseOption = new("--verbose")
{
    Description = "输出详细信息",
    Recursive = true
};

RootCommand rootCommand = new("文件处理工具");
rootCommand.Options.Add(verboseOption);

Command scanCommand = new("scan", "扫描目录并统计文件数量");
Command copyCommand = new("copy", "复制文件");

rootCommand.Subcommands.Add(scanCommand);
rootCommand.Subcommands.Add(copyCommand);

Recursive = true 让根命令选项对子命令递归可用。--verbose--config--no-color 这类确实适用于所有命令的配置适合做全局选项;只服务某个命令的参数应放在对应子命令上。

完整 Demo:文件扫描与复制工具

下面的完整程序包含:

  • scan:递归扫描目录
  • copy:复制文件
  • Option<T>Argument<T>、枚举和多值参数
  • 全局 --verbose 选项
  • 文件存在性校验和退出码

创建项目

bash 复制代码
dotnet new console -n FileTool
cd FileTool
dotnet add package System.CommandLine --version 2.0.10

完整 Program.cs

csharp 复制代码
using System.CommandLine;

enum OutputFormat
{
    Text,
    Json
}

Option<bool> verboseOption = new("--verbose")
{
    Description = "输出详细信息",
    Recursive = true
};
verboseOption.Aliases.Add("-v");

Option<string[]> extensionOption = new("--extension")
{
    Description = "只匹配指定扩展名,例如 log txt",
    AllowMultipleArgumentsPerToken = true
};
extensionOption.Aliases.Add("-e");

Option<OutputFormat> formatOption = new("--format")
{
    Description = "统计结果格式",
    DefaultValueFactory = _ => OutputFormat.Text
};

Option<bool> overwriteOption = new("--overwrite")
{
    Description = "目标文件存在时覆盖"
};

Argument<DirectoryInfo> rootArgument = new("root")
{
    Description = "要扫描的目录"
};

Argument<FileInfo> sourceArgument = new("source")
{
    Description = "源文件"
};

Argument<FileInfo> targetArgument = new("target")
{
    Description = "目标文件"
};

RootCommand rootCommand = new("一个简单的文件处理命令行工具");
rootCommand.Options.Add(verboseOption);

Command scanCommand = new("scan", "递归扫描目录中的文件");
scanCommand.Arguments.Add(rootArgument);
scanCommand.Options.Add(extensionOption);
scanCommand.Options.Add(formatOption);
rootCommand.Subcommands.Add(scanCommand);

Command copyCommand = new("copy", "复制一个文件");
copyCommand.Arguments.Add(sourceArgument);
copyCommand.Arguments.Add(targetArgument);
copyCommand.Options.Add(overwriteOption);
rootCommand.Subcommands.Add(copyCommand);

scanCommand.SetAction(parseResult =>
{
    DirectoryInfo root = parseResult.GetValue(rootArgument)!;
    string[] extensions = parseResult.GetValue(extensionOption) ?? Array.Empty<string>();
    OutputFormat format = parseResult.GetValue(formatOption);
    bool verbose = parseResult.GetValue(verboseOption);

    if (!root.Exists)
    {
        Console.Error.WriteLine($"目录不存在:{root.FullName}");
        return 2;
    }

    HashSet<string> filters = extensions
        .Select(NormalizeExtension)
        .ToHashSet(StringComparer.OrdinalIgnoreCase);

    List<FileInfo> files = root
        .EnumerateFiles("*", SearchOption.AllDirectories)
        .Where(file => filters.Count == 0 || filters.Contains(file.Extension))
        .ToList();

    if (verbose)
    {
        foreach (FileInfo file in files)
        {
            Console.WriteLine(file.FullName);
        }
    }

    if (format == OutputFormat.Json)
    {
        Console.WriteLine($"{{\"count\":{files.Count}}}");
    }
    else
    {
        Console.WriteLine($"共找到 {files.Count} 个文件。");
    }

    return 0;
});

copyCommand.SetAction(parseResult =>
{
    FileInfo source = parseResult.GetValue(sourceArgument)!;
    FileInfo target = parseResult.GetValue(targetArgument)!;
    bool overwrite = parseResult.GetValue(overwriteOption);

    if (!source.Exists)
    {
        Console.Error.WriteLine($"源文件不存在:{source.FullName}");
        return 2;
    }

    if (target.Exists && !overwrite)
    {
        Console.Error.WriteLine("目标文件已存在,请添加 --overwrite。");
        return 3;
    }

    target.Directory?.Create();
    source.CopyTo(target.FullName, overwrite);
    Console.WriteLine($"已复制:{source.FullName} -> {target.FullName}");
    return 0;
});

return rootCommand.Parse(args).Invoke();

static string NormalizeExtension(string extension)
{
    return extension.StartsWith('.') ? extension : $".{extension}";
}

运行 scan

扫描全部文件:

bash 复制代码
dotnet run -- scan ./logs

只扫描日志和文本文件:

bash 复制代码
dotnet run -- scan ./logs --extension log txt

开启详细输出并生成 JSON 统计:

bash 复制代码
dotnet run -- scan ./logs --extension log --format Json --verbose

运行 copy

bash 复制代码
dotnet run -- copy ./logs/app.log ./backup/app.log

目标文件存在时,程序拒绝覆盖并返回退出码 3

bash 复制代码
dotnet run -- copy ./logs/app.log ./backup/app.log --overwrite

查看帮助

bash 复制代码
dotnet run -- --help
dotnet run -- scan --help
dotnet run -- copy --help

根命令默认提供帮助和版本相关能力。命令、选项、参数的 Description 写得越清楚,自动生成的帮助信息越有价值。

类型转换与自定义校验

框架支持很多常见类型:

csharp 复制代码
Option<int> countOption = new("--count");
Option<bool> enabledOption = new("--enabled");
Option<DateTime> dateOption = new("--date");
Option<FileInfo> fileOption = new("--file");
Option<ConsoleColor> colorOption = new("--color");
Option<OutputFormat> formatOption = new("--format");
Option<string[]> tagsOption = new("--tag");

类型转换只能判断"能不能转成整数",不能判断"这个整数是否适合业务"。例如重试次数不能为负数,端口必须在 165535 之间。

2.0 可以使用 CustomParser 在解析阶段完成参数校验:

csharp 复制代码
Option<int> retryOption = new("--retry")
{
    Description = "重试次数",
    CustomParser = result =>
    {
        if (!int.TryParse(result.Tokens.Single().Value, out int value))
        {
            result.AddError("重试次数必须是整数。");
            return 0;
        }

        if (value < 0 || value > 10)
        {
            result.AddError("重试次数必须在 0 到 10 之间。");
        }

        return value;
    }
};

另一种方式是使用 Validators

csharp 复制代码
Option<int> portOption = new("--port")
{
    Description = "监听端口"
};

portOption.Validators.Add(result =>
{
    int port = result.GetValue(portOption);
    if (port is < 1 or > 65535)
    {
        result.AddError("端口必须在 1 到 65535 之间。");
    }
});

建议按规则分层:单个参数的格式检查放在解析或验证中;文件是否存在、两个路径是否冲突、多个参数能否组合放在命令动作中。这样错误信息更准确,也不会把所有业务逻辑塞进参数定义。

ParseParseResultInvoke

这三个概念可以分成两步:

csharp 复制代码
ParseResult parseResult = rootCommand.Parse(args);
return parseResult.Invoke();

Parse(args) 只负责把文本解析成结果,适合单元测试和需要先检查结果的场景:

csharp 复制代码
ParseResult result = rootCommand.Parse(new[] { "scan", "./logs" });

if (result.Errors.Count > 0)
{
    foreach (ParseError error in result.Errors)
    {
        Console.Error.WriteLine(error.Message);
    }
}
else
{
    DirectoryInfo root = result.GetValue(rootArgument)!;
}

Invoke() 才会执行 SetAction 注册的动作,并返回退出码。异步业务可以使用异步动作和 InvokeAsync

csharp 复制代码
scanCommand.SetAction(async (parseResult, cancellationToken) =>
{
    DirectoryInfo root = parseResult.GetValue(rootArgument)!;
    await ScanAsync(root, cancellationToken);
    return 0;
});

return await rootCommand.Parse(args).InvokeAsync();

异步操作应继续传递 CancellationToken,这样用户按下 Ctrl+C 时,文件扫描、网络请求或数据库操作才有机会及时结束。

响应文件:参数太长时换成文件

当命令包含几十个参数,可以使用响应文件。创建 scan.rsp

text 复制代码
scan
./logs
--extension
log
txt
--format
Json

执行:

bash 复制代码
filetool @scan.rsp

响应文件默认启用,适合构建脚本、批量任务和自动生成命令的场景。响应文件可能继续引用其他响应文件,因此文件来源必须可信;不可信输入环境可以关闭响应文件替换功能。

Tab 补全

System.CommandLine 支持子命令、选项名称、枚举值和固定值的 Tab 补全,也支持运行时动态生成补全项。

自定义 CLI 通常需要安装 dotnet-suggest 并注册应用:

bash 复制代码
dotnet tool install -g dotnet-suggest
dotnet-suggest register --command-path /absolute/path/to/filetool

不同 shell 还需要加载对应的 shim:Bash 加入 profile,Zsh 加入 .zshrc,PowerShell 加入 PowerShell profile。传统 cmd.exe 没有可插拔的 Tab 补全机制,无法使用同样的 shim 方案。

测试命令行代码

命令行项目不应只靠手工输入测试。ParseInvoke 分离后,可以直接检查解析结果:

csharp 复制代码
[Fact]
public void Scan_should_parse_root_directory()
{
    RootCommand command = BuildCommand();

    ParseResult result = command.Parse(new[] { "scan", "./logs" });

    Assert.Empty(result.Errors);
    Assert.Equal("./logs", result.GetValue(rootArgument)!.ToString());
}

还可以通过 InvocationConfiguration 注入输出和错误流,避免测试直接依赖 Console.WriteLine。业务动作单独放在方法或服务中后,参数解析测试和文件处理测试可以分别进行。

2.0 API 与旧版示例的区别

网上大量文章基于 2.0.0-beta4 或更早版本,常见写法包括:

csharp 复制代码
command.SetHandler(...);
CommandHandler.Create(...);
rootCommand.AddCommand(command);

2.0.x 文档中更推荐:

csharp 复制代码
command.SetAction(parseResult =>
{
    int count = parseResult.GetValue(countOption);
    return 0;
});

rootCommand.Subcommands.Add(command);
return rootCommand.Parse(args).Invoke();

升级旧项目时,重点检查:

  • SetHandler 是否改成 SetAction
  • GetValueForOption 是否改成 ParseResult.GetValue
  • AddCommand 是否改成 Subcommands.Add
  • 默认值工厂是否使用 DefaultValueFactory
  • 同步、异步动作和调用方式是否匹配。
  • 自定义帮助、补全和解析器配置是否仍然存在。

常见问题

根命令没有设置动作,直接运行为什么只显示帮助?

根命令包含子命令时,通常要求继续输入一个叶子命令:

bash 复制代码
filetool scan ./logs

只输入 filetool 时,框架会提示需要提供命令,并显示可用的子命令。分组命令一般不绑定动作,真正执行的逻辑绑定在叶子命令上。

FileInfo 为什么没有自动报"文件不存在"?

FileInfo 是路径对象,不是文件内容,也不会自动保证目标存在。文件存在性属于业务规则,可以在动作中检查 Exists,或者通过 CustomParser 在解析阶段添加错误。

为什么不直接使用 args

简单的一次性脚本当然可以直接读取 args。当程序需要必填校验、默认值、多个子命令、自动帮助、补全、响应文件或可靠退出码时,手写解析逻辑会重复实现大量基础设施,维护成本也会逐步上升。

什么时候适合使用 System.CommandLine?

适合的场景:

  • .NET Global Tool 和 Local Tool
  • 数据迁移、代码生成、文件转换工具
  • CI/CD、部署和批处理程序
  • 需要多级子命令的开发者工具
  • 需要自动帮助、补全和响应文件的 CLI
  • 希望兼容 Native AOT、裁剪和轻量发布的控制台应用

如果重点是漂亮的终端表格、进度条、颜色和交互式界面,Spectre.Console 更适合负责呈现;System.CommandLine 负责命令解析,两者也可以组合使用。

总结

System.CommandLine 的核心用法可以压缩成四步:

text 复制代码
创建 RootCommand
    ↓
添加 Command、Option<T>、Argument<T>
    ↓
使用 SetAction 绑定业务动作
    ↓
Parse(args).Invoke() 返回退出码

单功能工具可以从一个根命令开始;功能增加后,用子命令分组;共享配置放全局选项;业务动作中负责文件存在性和参数组合校验;需要测试时,单独调用 Parse 检查结果。

真正值得注意的是版本:System.CommandLine 2.0.x 的新 API 与旧 beta 文章存在明显差异,SetActionSubcommandsDefaultValueFactoryParseResult.GetValue 应作为新项目的优先写法。

参考资料:

相关推荐
hez20101 小时前
让 .NET 11 的泛型虚方法变得更快!
c#·.net·.net core·clr·compiler
宝桥南山3 小时前
Microsoft Agent Framework(.NET) - 尝试一下从MCP Server上获取Agent Skills
ai·微软·c#·aigc·.net·.netcore
少控科技4 小时前
农场设备管理代码(1)
开发语言·c#
海盗12348 小时前
微软技术日报·2026-08-12——Windows 11多通道累积更新推送十余项功能改进;.NET WebSocket DoS紧急修复
windows·microsoft·.net
椒颜皮皮虾྅19 小时前
OpenVINO C# API 3.3.1:支持OpenVINO 2026.3及GenAI增强
人工智能·c#·openvino
半亩码田1 天前
C#转Python第3.1篇:Python 的 class 没有访问修饰符?面向对象的另一条路
开发语言·python·c#
唐青枫1 天前
别再手写 args[0]:C#.NET CommandLineParser 命令行工具实战详解
c#·.net
LccKyI1 天前
C#学习day06(方法/函数及其关键字,附思维导图)
经验分享·学习·c#
.Peter1 天前
.NET/WPF 程序在部分 Windows 电脑无法保存用户环境变量:使用 DPAPI 实现安全兼容
windows·信息安全·c#·.net·wpf·环境变量·dpapi