CliWrap
该项目的开发完全由社区资助。考虑捐赠以支持! |
CliWrap是一个用于与外部命令行界面交互的库。它提供了一个便捷的模型,用于启动进程、重定向输入和输出流、等待完成、处理取消等。
使用条款[?]
通过使用本项目或其源代码,无论出于何种目的和以何种形式,您默认同意以下所有声明:
- 您谴责俄罗斯及其对乌克兰的军事侵略
- 您认识到俄罗斯是非法入侵主权国家的占领者
- 您支持乌克兰的领土完整,包括其对克里米亚和顿巴斯临时被占领土的主张
- 您拒绝俄罗斯国家宣传perpetuated的虚假叙事
要了解更多关于战争的信息以及如何提供帮助,请点击这里。荣耀属于乌克兰!🇺🇦
安装
- 📦 NuGet:
dotnet add package CliWrap
特性
- 对
System.Diagnostics.Process
的严密抽象 - 流畅的配置接口
- 灵活支持管道
- 完全异步和支持取消的API
- 使用中断信号进行优雅取消
- 设计时考虑了严格的不可变性
- 提供对典型死锁场景的安全保护
- 在Windows、Linux和macOS上测试通过
- 目标框架为.NET Standard 2.0+、.NET Core 3.0+、.NET Framework 4.6.2+
- 无外部依赖
用法
视频指南
您可以观看以下视频之一来了解如何使用该库:
快速概览
与shell类似,CliWrap的基本工作单元是命令—一个封装了运行进程指令的对象。要构建一个命令,首先调用Cli.Wrap(...)
并传入可执行文件路径,然后使用提供的流畅接口来配置参数、工作目录或其他选项。一旦命令配置完成,您可以通过调用ExecuteAsync()
来运行它:
using CliWrap;
var result = await Cli.Wrap("path/to/exe")
.WithArguments(["--foo", "bar"])
.WithWorkingDirectory("work/dir/path")
.ExecuteAsync();
// 结果包含:
// -- result.IsSuccess (bool)
// -- result.ExitCode (int)
// -- result.StartTime (DateTimeOffset)
// -- result.ExitTime (DateTimeOffset)
// -- result.RunTime (TimeSpan)
上面的代码使用配置的命令行参数和工作目录启动一个子进程,然后异步等待它退出。任务完成后,它会解析为一个CommandResult
对象,其中包含进程退出代码和其他相关信息。
警告: 如果底层进程返回非零退出代码,CliWrap将抛出异常,因为这通常表示发生了错误。 您可以通过使用
WithValidation(CommandResultValidation.None)
禁用结果验证来覆盖此行为。
默认情况下,进程的标准输入、输出和错误流被路由到CliWrap等效的空设备,它表示一个空源和一个丢弃所有数据的目标。
您可以通过调用WithStandardInputPipe(...)
、WithStandardOutputPipe(...)
或WithStandardErrorPipe(...)
来为相应的流配置管道,从而更改此行为:
using CliWrap;
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();
var result = await Cli.Wrap("path/to/exe")
.WithArguments(["--foo", "bar"])
.WithWorkingDirectory("work/dir/path")
// 这可以通过`ExecuteBufferedAsync()`简化
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.ExecuteAsync();
// 以字符串形式访问内存中缓冲的stdout和stderr
var stdOut = stdOutBuffer.ToString();
var stdErr = stdErrBuffer.ToString();
这个示例命令被配置为将写入标准输出和错误流的数据解码为文本,并将其附加到相应的StringBuilder
缓冲区。执行完成后,可以检查这些缓冲区以查看进程在控制台上打印的内容。
处理命令输出是一个非常常见的用例,因此CliWrap提供了一些高级执行模型来简化这些场景。特别是,上面显示的相同内容也可以使用ExecuteBufferedAsync()
扩展方法更简洁地实现:
using CliWrap;
using CliWrap.Buffered;
// 调用`ExecuteBufferedAsync()`而不是`ExecuteAsync()`
// 隐式配置将写入内存缓冲区的管道。
var result = await Cli.Wrap("path/to/exe")
.WithArguments(["--foo", "bar"])
.WithWorkingDirectory("work/dir/path")
.ExecuteBufferedAsync();
// 结果包含:
// -- result.IsSuccess (bool)
// -- result.StandardOutput (string)
// -- result.StandardError (string)
// -- result.ExitCode (int)
// -- result.StartTime (DateTimeOffset)
// -- result.ExitTime (DateTimeOffset)
// -- result.RunTime (TimeSpan)
警告: 使用
ExecuteBufferedAsync()
时要谨慎。 程序可以向输出和错误流写入任意数据(包括二进制数据),将其存储在内存中可能不切实际。 对于更高级的场景,CliWrap还提供了其他管道选项,这些在管道部分中有介绍。
命令配置
命令对象提供的流畅接口允许您配置其执行的各个方面。本节涵盖了所有可用的配置方法及其用法。
注意:
Command
是一个不可变对象 — 这里列出的所有配置方法都会创建一个新实例,而不是修改现有实例。
WithArguments(...)
设置传递给子进程的命令行参数。
默认值:空。
示例:
- 使用数组设置参数:
var cmd = Cli.Wrap("git")
// 每个元素被格式化为单独的参数。
// 等效于:`git commit -m "my commit"`
.WithArguments(["commit", "-m", "my commit"]);
- 使用构建器设置参数:
var cmd = Cli.Wrap("git")
// 每个 Add(...) 调用会自动处理格式化。
// 等同于: `git clone https://github.com/Tyrrrz/CliWrap --depth 20`
.WithArguments(args => args
.Add("clone")
.Add("https://github.com/Tyrrrz/CliWrap")
.Add("--depth")
.Add(20)
);
var forcePush = true;
var cmd = Cli.Wrap("git")
// 参数也可以以命令式方式构造。
// 等同于: `git push --force`
.WithArguments(args =>
{
args.Add("push");
if (forcePush)
args.Add("--force");
});
注意: 构建器重载允许您定义自定义扩展方法以实现可重用的参数模式。 了解更多。
- 直接设置参数:
var cmd = Cli.Wrap("git")
// 除非必要,否则避免使用此重载。
// 等同于: `git commit -m "my commit"`
.WithArguments("commit -m \"my commit\"");
警告: 除非绝对必要,否则避免直接从字符串设置命令行参数。 此方法要求所有参数都提前正确转义和格式化 — 自己做这件事可能会很麻烦。 格式化错误可能导致意外的错误和安全漏洞。
WithWorkingDirectory(...)
设置子进程的工作目录。
默认值: 当前工作目录,即 Directory.GetCurrentDirectory()
。
示例:
var cmd = Cli.Wrap("git")
.WithWorkingDirectory("c:/projects/my project/");
WithEnvironmentVariables(...)
设置暴露给子进程的额外环境变量。
默认值: 空。
示例:
- 使用构建器设置环境变量:
var cmd = Cli.Wrap("git")
.WithEnvironmentVariables(env => env
.Set("GIT_AUTHOR_NAME", "John")
.Set("GIT_AUTHOR_EMAIL", "john@email.com")
);
- 直接设置环境变量:
var cmd = Cli.Wrap("git")
.WithEnvironmentVariables(new Dictionary<string, string?>
{
["GIT_AUTHOR_NAME"] = "John",
["GIT_AUTHOR_EMAIL"] = "john@email.com"
});
注意: 使用
WithEnvironmentVariables(...)
配置的环境变量是在从父进程继承的环境变量之上应用的。 如果需要删除继承的变量,请将相应的值设置为null
。
WithCredentials(...)
设置应该以其身份启动子进程的用户的域、名称和密码。
默认值: 无凭据。
示例:
- 使用构建器设置凭据:
var cmd = Cli.Wrap("git")
.WithCredentials(creds => creds
.SetDomain("some_workspace")
.SetUserName("johndoe")
.SetPassword("securepassword123")
.LoadUserProfile()
);
- 直接设置凭据:
var cmd = Cli.Wrap("git")
.WithCredentials(new Credentials(
domain: "some_workspace",
userName: "johndoe",
password: "securepassword123",
loadUserProfile: true
));
警告: 在不同用户名下运行进程在所有平台上都得到支持,但其他选项仅在 Windows 上可用。
WithValidation(...)
设置验证执行结果的策略。
接受的值:
CommandResultValidation.None
— 不验证CommandResultValidation.ZeroExitCode
— 确保进程退出时退出代码为零
默认值: CommandResultValidation.ZeroExitCode
。
示例:
- 启用验证:
var cmd = Cli.Wrap("git")
.WithValidation(CommandResultValidation.ZeroExitCode);
- 禁用验证:
var cmd = Cli.Wrap("git")
.WithValidation(CommandResultValidation.None);
如果您想在进程以非零退出代码退出时抛出自定义异常,请不要禁用结果验证,而是捕获默认的 CommandExecutionException
并在您自己的异常中重新抛出它。
这样您可以保留原始异常提供的信息,同时用额外的上下文扩展它:
try
{
await Cli.Wrap("git").ExecuteAsync();
}
catch (CommandExecutionException ex)
{
// 重新抛出原始异常以保留有关失败命令的附加信息
// (退出代码、参数等)。
throw new MyException("无法运行 git 命令行工具。", ex);
}
WithStandardInputPipe(...)
设置将用于进程标准输入流的管道源。
默认值: PipeSource.Null
。
在管道部分中阅读有关此方法的更多信息。
WithStandardOutputPipe(...)
设置将用于进程标准输出流的管道目标。
默认值: PipeTarget.Null
。
在管道部分中阅读有关此方法的更多信息。
WithStandardErrorPipe(...)
设置将用于进程标准错误流的管道目标。
默认值: PipeTarget.Null
。
在管道部分中阅读有关此方法的更多信息。
管道
CliWrap 提供了一个非常强大和灵活的管道模型,允许您重定向进程的流、转换输入和输出数据,甚至以最小的努力将多个命令链接在一起。
它的核心基于两个抽象:为标准输入流提供数据的 PipeSource
和读取来自标准输出流或标准错误流的数据的 PipeTarget
。
默认情况下,命令的输入管道设置为 PipeSource.Null
,输出和错误管道设置为 PipeTarget.Null
。
这些对象有效地代表无操作存根,分别提供空输入和丢弃所有输出。
您可以通过在命令上调用相应的配置方法来指定自己的 PipeSource
和 PipeTarget
实例:
await using var input = File.OpenRead("input.txt");
await using var output = File.Create("output.txt");
await Cli.Wrap("foo")
.WithStandardInputPipe(PipeSource.FromStream(input))
.WithStandardOutputPipe(PipeTarget.ToStream(output))
.ExecuteAsync();
或者,也可以使用管道运算符以稍微简洁的方式配置管道:
await using var input = File.OpenRead("input.txt");
await using var output = File.Create("output.txt");
await (input | Cli.Wrap("foo") | output).ExecuteAsync();
PipeSource
和 PipeTarget
都有许多工厂方法,可以让您为不同的场景创建管道实现:
PipeSource
:PipeSource.Null
— 代表一个空的管道源PipeSource.FromStream(...)
— 从任何可读流中管道数据PipeSource.FromFile(...)
— 从文件中管道数据PipeSource.FromBytes(...)
— 从字节数组中管道数据PipeSource.FromString(...)
— 从文本字符串中管道数据PipeSource.FromCommand(...)
— 从另一个命令的标准输出中管道数据
PipeTarget
:PipeTarget.Null
— 代表丢弃所有数据的管道目标PipeTarget.ToStream(...)
— 将数据管道到任何可写流PipeTarget.ToFile(...)
— 将数据管道到文件PipeTarget.ToStringBuilder(...)
— 将数据作为文本管道到StringBuilder
PipeTarget.ToDelegate(...)
— 将数据作为文本,逐行管道到Action<string>
、Func<string, Task>
或Func<string, CancellationToken, Task>
委托PipeTarget.Merge(...)
— 通过将相同的数据复制到所有管道来合并多个出站管道
警告: 使用
PipeTarget.Null
会导致底层进程根本不打开相应的流(stdout 或 stderr)。 在绝大多数情况下,这种行为在功能上应该等同于管道到空流,但没有消耗和丢弃不需要的数据的性能开销。 在某些情况下这可能是不希望的 — 在这种情况下,建议使用PipeTarget.ToStream(Stream.Null)
显式地管道到空流。
以下是一些使用 CliWrap 的管道功能可以实现的示例:
- 将字符串管道到 stdin:
var cmd = "Hello world" | Cli.Wrap("foo");
await cmd.ExecuteAsync();
- 将 stdout 作为文本管道到
StringBuilder
:
var stdOutBuffer = new StringBuilder();
var cmd = Cli.Wrap("foo") | stdOutBuffer;
await cmd.ExecuteAsync();
- 将二进制 HTTP 流管道到 stdin:
using var httpClient = new HttpClient();
await using var input = await httpClient.GetStreamAsync("https://example.com/image.png");
var cmd = input | Cli.Wrap("foo");
await cmd.ExecuteAsync();
- 将一个命令的 stdout 管道到另一个命令的 stdin:
var cmd = Cli.Wrap("foo") | Cli.Wrap("bar") | Cli.Wrap("baz");
await cmd.ExecuteAsync();
- 将 stdout 和 stderr 管道到父进程的 stdout 和 stderr:
await using var stdOut = Console.OpenStandardOutput();
await using var stdErr = Console.OpenStandardError();
var cmd = Cli.Wrap("foo") | (stdOut, stdErr);
await cmd.ExecuteAsync();
- 将 stdout 管道到委托:
var cmd = Cli.Wrap("foo") | Debug.WriteLine;
await cmd.ExecuteAsync();
- 将 stdout 管道到文件,将 stderr 管道到
StringBuilder
:
var buffer = new StringBuilder();
var cmd = Cli.Wrap("foo") |
(PipeTarget.ToFile("output.txt"), PipeTarget.ToStringBuilder(buffer));
await cmd.ExecuteAsync();
- 同时将 stdout 管道到多个文件:
var target = PipeTarget.Merge(
PipeTarget.ToFile("file1.txt"),
PipeTarget.ToFile("file2.txt"),
PipeTarget.ToFile("file3.txt")
);
var cmd = Cli.Wrap("foo") | target;
await cmd.ExecuteAsync();
- 将字符串管道输入到一个命令的标准输入,将该命令的标准输出管道输入到另一个命令的标准输入,然后将最后一个命令的标准输出和标准错误管道输入到父进程的标准输出和标准错误:
var cmd =
"Hello world" |
Cli.Wrap("foo").WithArguments(["aaa"]) |
Cli.Wrap("bar").WithArguments(["bbb"]) |
(Console.WriteLine, Console.Error.WriteLine);
await cmd.ExecuteAsync();
执行模型
CliWrap提供了几种高级执行模型,为命令提供了替代的思考方式。 这些本质上只是利用前面展示的管道功能的扩展方法。
缓冲执行
这种执行模型允许你运行一个进程,同时将其标准输出和错误流作为文本缓冲在内存中。 缓冲的数据可以在命令执行完成后访问。
要使用缓冲执行命令,调用ExecuteBufferedAsync()
扩展方法:
using CliWrap;
using CliWrap.Buffered;
var result = await Cli.Wrap("foo")
.WithArguments(["bar"])
.ExecuteBufferedAsync();
var exitCode = result.ExitCode;
var stdOut = result.StandardOutput;
var stdErr = result.StandardError;
默认情况下,ExecuteBufferedAsync()
假设底层进程使用默认编码(Console.OutputEncoding
)将文本写入控制台。
要覆盖此设置,请使用可用的重载方法之一明确指定编码:
// 将stdout和stderr都视为UTF8编码的文本流
var result = await Cli.Wrap("foo")
.WithArguments(["bar"])
.ExecuteBufferedAsync(Encoding.UTF8);
// 将stdout视为ASCII编码,stderr视为UTF8编码
var result = await Cli.Wrap("foo")
.WithArguments(["bar"])
.ExecuteBufferedAsync(Encoding.ASCII, Encoding.UTF8);
注意: 如果底层进程返回非零退出代码,
ExecuteBufferedAsync()
将抛出异常,类似于ExecuteAsync()
,但异常消息还将包含标准错误数据。
基于拉取的事件流
除了将命令作为任务执行外,CliWrap还支持另一种模型,其中执行表示为事件流。 这让你可以启动一个进程并实时响应它产生的事件。
这些事件包括:
StartedCommandEvent
— 仅在命令开始执行时接收一次(包含进程ID)StandardOutputCommandEvent
— 每当底层进程向输出流写入新行时接收(包含文本字符串)StandardErrorCommandEvent
— 每当底层进程向错误流写入新行时接收(包含文本字符串)ExitedCommandEvent
— 仅在命令完成执行时接收一次(包含退出代码)
要将命令作为_基于拉取_的事件流执行,请使用ListenAsync()
扩展方法:
using CliWrap;
using CliWrap.EventStream;
var cmd = Cli.Wrap("foo").WithArguments(["bar"]);
await foreach (var cmdEvent in cmd.ListenAsync())
{
switch (cmdEvent)
{
case StartedCommandEvent started:
_output.WriteLine($"进程已启动; ID: {started.ProcessId}");
break;
case StandardOutputCommandEvent stdOut:
_output.WriteLine($"输出> {stdOut.Text}");
break;
case StandardErrorCommandEvent stdErr:
_output.WriteLine($"错误> {stdErr.Text}");
break;
case ExitedCommandEvent exited:
_output.WriteLine($"进程已退出; 代码: {exited.ExitCode}");
break;
}
}
ListenAsync()
方法启动命令并返回IAsyncEnumerable<CommandEvent>
类型的对象,你可以使用C# 8中引入的await foreach
构造进行迭代。
使用此执行模型时,通过在每次循环迭代之间锁定管道来实现反压,防止不必要的数据在内存中缓冲。
注意: 就像
ExecuteBufferedAsync()
一样,你可以使用ListenAsync()
的重载之一为其指定自定义编码。
基于推送的事件流
与基于拉取的流类似,你也可以将命令作为_基于推送_的事件流执行:
using System.Reactive;
using CliWrap;
using CliWrap.EventStream;
var cmd = Cli.Wrap("foo").WithArguments(["bar"]);
await cmd.Observe().ForEachAsync(cmdEvent =>
{
switch (cmdEvent)
{
case StartedCommandEvent started:
_output.WriteLine($"进程已启动; ID: {started.ProcessId}");
break;
case StandardOutputCommandEvent stdOut:
_output.WriteLine($"输出> {stdOut.Text}");
break;
case StandardErrorCommandEvent stdErr:
_output.WriteLine($"错误> {stdErr.Text}");
break;
case ExitedCommandEvent exited:
_output.WriteLine($"进程已退出; 代码: {exited.ExitCode}");
break;
}
});
在这种情况下,Observe()
返回一个冷IObservable<CommandEvent>
,表示命令事件的可观察流。
你可以使用Rx.NET提供的一系列扩展来转换、过滤、限制或以其他方式操作这个流。
与基于拉取的事件流不同,此执行模型不涉及任何反压,这意味着数据以可用的速率推送给观察者。
注意: 与
ExecuteBufferedAsync()
类似,你可以使用Observe()
的重载之一为其指定自定义编码。
将执行模型与自定义管道结合
上面显示的不同执行模型基于管道模型,但这两个概念并不互斥。
使用内置执行模型之一运行命令时,现有的管道配置会被保留并使用PipeTarget.Merge(...)
进行扩展。
这意味着你可以,例如,将命令管道输出到文件,同时将其作为事件流执行:
var cmd =
PipeSource.FromFile("input.txt") |
Cli.Wrap("foo") |
PipeTarget.ToFile("output.txt");
// 作为事件流迭代并同时管道输出到文件
// (执行模型保留配置的管道)
await foreach (var cmdEvent in cmd.ListenAsync())
{
// ...
}
超时和取消
命令执行本质上是异步的,因为它涉及一个完全独立的进程。 在许多情况下,实现一个中止机制来在执行完成之前停止执行可能很有用,无论是通过手动触发还是超时。
要做到这一点,发出相应的CancellationToken
并在调用ExecuteAsync()
时包含它:
using System.Threading;
using CliWrap;
using var cts = new CancellationTokenSource();
// 10秒后取消
cts.CancelAfter(TimeSpan.FromSeconds(10));
var result = await Cli.Wrap("foo").ExecuteAsync(cts.Token);
在取消请求的情况下,底层进程将被终止,ExecuteAsync()
将抛出OperationCanceledException
类型的异常(或其派生类TaskCanceledException
)。
你需要在代码中捕获这个异常以从取消中恢复:
try
{
await Cli.Wrap("foo").ExecuteAsync(cts.Token);
}
catch (OperationCanceledException)
{
// 命令被取消
}
除了直接终止进程外,你还可以通过发送中断信号来以更优雅的方式请求取消。
要做到这一点,向ExecuteAsync()
传递一个额外的取消令牌,该令牌对应于该请求:
using var forcefulCts = new CancellationTokenSource();
using var gracefulCts = new CancellationTokenSource();
// 10秒后强制取消。
// 这作为优雅取消耗时过长时的后备方案。
forcefulCts.CancelAfter(TimeSpan.FromSeconds(10));
// 7秒后优雅取消。
// 如果进程响应优雅取消的时间过长,
// 它将在3秒后被强制取消终止(如上面配置)。
gracefulCts.CancelAfter(TimeSpan.FromSeconds(7));
var result = await Cli.Wrap("foo").ExecuteAsync(forcefulCts.Token, gracefulCts.Token);
在CliWrap中请求优雅取消在功能上等同于在控制台窗口中按Ctrl+C
。
底层进程可以处理这个信号,在最后执行一些关键工作,然后按自己的方式退出。
优雅取消本质上是合作性的,所以进程可能需要太长时间来满足请求或选择完全忽略它。 在上面的例子中,通过额外安排延迟的强制取消来缓解这种风险,防止命令挂起。
如果你在方法内执行命令,并且不想向调用者暴露这些实现细节,你可以依赖以下模式,使用提供的令牌进行优雅取消,并用强制回退扩展它:
public async Task GitPushAsync(CancellationToken cancellationToken = default)
{
using var forcefulCts = new CancellationTokenSource();
// 当取消令牌被触发时,
// 安排强制取消作为后备。
await using var link = cancellationToken.Register(() =>
forcefulCts.CancelAfter(TimeSpan.FromSeconds(3))
);
await Cli.Wrap("git")
.WithArguments(["push"])
.ExecuteAsync(forcefulCts.Token, cancellationToken);
}
注意: 与
ExecuteAsync()
类似,ExecuteBufferedAsync()
、ListenAsync()
和Observe()
也支持取消。
检索进程相关信息
ExecuteAsync()
和ExecuteBufferedAsync()
返回的任务实际上不是普通的Task<T>
,而是CommandTask<T>
的实例。
这是一个专门的可等待对象,包含与执行命令相关联的进程的额外信息:
var task = Cli.Wrap("foo")
.WithArguments(["bar"])
.ExecuteAsync();
// 获取进程ID
var processId = task.ProcessId;
// 等待任务完成
await task;