1. 项目概述:OpenClaw.NET 外部 CLI 连接器的核心价值

如果你正在构建一个需要与外部命令行工具深度集成的 .NET 应用,比如一个自动化运维平台、一个 CI/CD 流水线编排器,或者一个需要调用 ffmpeg、Pandoc、Terraform 等外部工具的应用,那么你肯定遇到过这些头疼事:如何优雅地启动一个进程?怎么实时获取并处理它的输出流和错误流?进程卡死了怎么办?不同平台的命令行参数和 shell 环境又该如何适配?OpenClaw.NET 的 External CLI Connectors 组件,就是为了系统性地解决这些问题而生的。它不是简单地包装一下 System.Diagnostics.Process ,而是提供了一套高层次的、面向领域的抽象,让你能用声明式、可组合的方式来描述和执行外部命令,把那些繁琐的进程管理、流处理、超时控制、错误处理等脏活累活都交给框架。

简单来说,它让你的代码从“如何启动一个进程”的细节中解放出来,更专注于“我要用这个命令行工具完成什么业务逻辑”。在我过去参与的多个涉及复杂命令行工具链的项目中,手动管理进程交互的代码往往是 Bug 的重灾区,也是可测试性的噩梦。OpenClaw.NET 的这个组件,通过清晰的接口设计和丰富的内置功能,极大地提升了这类集成代码的健壮性、可读性和可维护性。接下来,我会结合实际的开发经验,深入拆解它的设计思路、核心用法以及那些能让你事半功倍的实战技巧。

2. 架构设计与核心思想拆解

2.1 为什么需要专门的 CLI 连接器?

在深入代码之前,我们先想想直接用 Process 类会面临哪些挑战。假设我们要调用 git log --oneline -n 5 并获取结果。

一个典型的“朴素”实现可能长这样:

var processStartInfo = new ProcessStartInfo
{
    FileName = "git",
    Arguments = "log --oneline -n 5",
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false,
    CreateNoWindow = true,
    WorkingDirectory = @"C:\MyRepo"
};

using var process = new Process { StartInfo = processStartInfo };
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();

process.OutputDataReceived += (sender, args) => outputBuilder.AppendLine(args.Data);
process.ErrorDataReceived += (sender, args) => errorBuilder.AppendLine(args.Data);

process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();

if (!process.WaitForExit(30000)) // 30秒超时
{
    process.Kill();
    throw new TimeoutException("Git command timed out.");
}

if (process.ExitCode != 0)
{
    throw new Exception($"Git failed with exit code {process.ExitCode}: {errorBuilder}");
}

var result = outputBuilder.ToString();

这段代码已经暴露了诸多问题:事件处理逻辑分散、超时和错误处理需要手动编织、输出流和错误流的异步读取容易遗漏或死锁、资源释放需要小心(比如确保 WaitForExit BeginOutputReadLine 之后)。当需要组合多个命令、处理复杂输入(如标准输入流)、或是在不同操作系统上运行时,代码会迅速变得臃肿且难以维护。

OpenClaw.NET 的 External CLI Connectors 的核心思想,就是将“执行一个外部命令”建模为一个完整的、可配置的“操作”。这个操作有明确的输入(命令、参数、工作目录、环境变量、标准输入内容)、执行过程(超时控制、流处理),以及输出(退出码、标准输出、标准错误)。框架负责以可靠、高效的方式执行这个操作,并将结果以结构化的方式返回。

2.2 连接器(Connector)与执行器(Executor)的分层设计

这是该组件一个非常关键的设计模式,理解它有助于你更灵活地使用和扩展框架。

  • 连接器 (ICliConnector) : 这一层关注“描述”命令。它定义了要运行哪个可执行文件、传递哪些参数、在什么环境下运行。你可以把它想象成命令的“配方”或“蓝图”。它不负责实际执行,而是生成一个“命令规格说明”( CommandSpecification )。一个连接器可以对应一个具体的命令行工具(如 GitCliConnector ),并封装该工具特有的参数构建逻辑。
  • 执行器 (ICliExecutor) : 这一层关注“执行”命令。它接收一个 CommandSpecification ,负责与操作系统进程交互的所有底层细节:启动进程、管理生命周期、处理流、实施超时策略、收集结果。框架提供了默认的、经过充分测试的执行器( DefaultCliExecutor ),在绝大多数情况下你直接使用它即可。

这种分离带来了巨大的好处:

  1. 可测试性 : 你可以轻松地为你的连接器逻辑编写单元测试,通过 Mock 执行器来验证生成的命令规格是否正确,而无需启动真实进程。
  2. 可替换性 : 如果你有特殊的执行需求(例如,需要在容器内执行命令,或需要与某种特定的进程池交互),你可以实现自己的 ICliExecutor ,而无需改动上层的连接器代码。
  3. 可组合性 : 一个执行器可以执行来自任何连接器的命令规格,实现了执行逻辑的复用。

在实际项目中,我通常先为每个需要集成的外部工具(如 docker , kubectl , aws cli )创建一个专用的连接器类。这个类里封装了该工具的命令行模式、常用参数组合,使得业务代码调用时意图更清晰,也避免了命令行字符串拼接错误。

3. 核心组件与 API 深度解析

3.1 命令规格说明(CommandSpecification):执行的蓝图

CommandSpecification 是一个不可变的数据类,包含了执行一个命令所需的全部信息。创建它的典型方式是通过 CommandSpecBuilder ,它提供了流畅的 API。

var spec = CommandSpecBuilder.Create("git")
    .WithArgument("log")
    .WithArgument("--oneline")
    .WithArgument("-n", "5") // 支持键值对参数
    .WithWorkingDirectory(@"C:\MyRepo")
    .WithEnvironmentVariable("GIT_TRACE", "0") // 设置环境变量
    .WithExecutionTimeout(TimeSpan.FromSeconds(30))
    .WithPriority(ProcessPriorityClass.BelowNormal) // 设置进程优先级
    .Build();

关键属性解析:

  • ExecutablePath : 可执行文件的路径。可以是绝对路径,也可以是能在系统 PATH 中找到的命令名。
  • Arguments : 参数列表。框架会负责正确的转义和拼接,这在处理包含空格或特殊字符的文件路径时至关重要,能有效防止注入攻击。
  • WorkingDirectory : 进程的工作目录。未设置时,继承当前进程的工作目录。
  • EnvironmentVariables : 环境变量字典。会与继承自父进程的环境变量合并,同名时覆盖。
  • StandardInputData : 可以是一个字符串或字节数组,作为命令的标准输入。这在需要与工具交互时非常有用,比如向 mysql 客户端传递 SQL 脚本。
  • ExecutionTimeout KillTimeout : 这两个超时设置是实践中的重中之重。 ExecutionTimeout 指从命令开始到正常结束的总等待时间。 KillTimeout 指在尝试强制终止(发送 Kill 信号或调用 Process.Kill )后,等待进程退出的时间。如果进程在 Kill 后仍未退出,可能会成为僵尸进程。合理设置这两个超时,是编写健壮命令行集成的关键。

3.2 执行结果(CliExecutionResult):结构化的输出

命令执行完毕后,你会得到一个 CliExecutionResult 对象,它封装了所有执行结果。

public class CliExecutionResult
{
    public CommandSpecification Specification { get; } // 执行的命令规格
    public int ExitCode { get; } // 退出代码
    public string StandardOutput { get; } // 标准输出内容
    public string StandardError { get; } // 标准错误内容
    public TimeSpan ExecutionDuration { get; } // 实际执行耗时
    public bool IsSuccess => ExitCode == 0; // 是否成功(通常以0为成功)
    // ... 可能还有其他元数据,如开始/结束时间
}

使用心得: 不要只检查 ExitCode == 0 。许多命令行工具即使执行成功,也可能在 StandardError 中输出警告信息(例如, dotnet build 成功时也可能有警告)。一个健壮的处理策略是:如果 IsSuccess true ,则将 StandardError 作为警告日志;如果为 false ,则将 StandardError 作为错误信息的一部分抛出。框架通常也会提供相应的异常类型(如 CliExecutionException ),其中就包含了完整的 CliExecutionResult ,便于调试。

3.3 流处理器(IOutputReceiver):实时处理与增量分析

对于长时间运行的命令(如 tail -f 、一个耗时的编译过程),或者输出量巨大的命令,一次性等待所有输出完成可能不现实,也会消耗大量内存。OpenClaw.NET 提供了 IOutputReceiver 接口来支持实时流式处理。

public interface IOutputReceiver
{
    void OnReceivedStandardOutput(string line);
    void OnReceivedStandardError(string line);
}

你可以实现这个接口,例如,创建一个将输出实时显示在 UI 文本框中的接收器,或者一个解析特定模式(如编译错误格式)的接收器。

与执行器的配合使用:

var realTimeReceiver = new MyRealTimeOutputReceiver();
var result = await cliExecutor.ExecuteAsync(specification, realTimeReceiver, cancellationToken);

当传递了 IOutputReceiver 实例后,执行器会在每收到一行输出(或达到缓冲区大小)时立即回调对应的方法。同时,最终的 CliExecutionResult 中的 StandardOutput StandardError 可能为空或为汇总信息,因为内容已经通过接收器处理了。

注意: 流处理模式下的错误处理需要更小心。因为输出和错误是异步到达的,你的接收器需要处理好可能发生的线程安全问题。此外,如果命令执行失败,你通过接收器已经处理的部分输出和错误,需要与你自己的错误处理逻辑整合。

4. 实战:构建一个健壮的 Docker CLI 连接器

让我们通过一个完整的例子,来看看如何利用 OpenClaw.NET 的这套设施,构建一个用于管理 Docker 容器的生产级连接器。

4.1 定义连接器接口与实现

首先,我们定义一个专注于容器操作的连接器接口,这有助于约束行为和提高可测试性。

public interface IDockerContainerCliConnector
{
    Task<CliExecutionResult> ListContainersAsync(bool listAll = false, CancellationToken ct = default);
    Task<CliExecutionResult> RunContainerAsync(string imageName, string containerName = null, IEnumerable<string> ports = null, IEnumerable<string> volumes = null, CancellationToken ct = default);
    Task<CliExecutionResult> StopContainerAsync(string containerIdOrName, CancellationToken ct = default);
    Task<CliExecutionResult> RemoveContainerAsync(string containerIdOrName, bool force = false, CancellationToken ct = default);
    Task<CliExecutionResult> ExecuteInContainerAsync(string containerIdOrName, string command, CancellationToken ct = default);
}

然后,我们实现这个接口。核心是使用 CommandSpecBuilder 来构建 Docker 命令。

public class DockerContainerCliConnector : IDockerContainerCliConnector
{
    private readonly ICliExecutor _executor;
    private const string DockerExecutable = "docker";

    // 依赖注入执行器,默认使用框架提供的
    public DockerContainerCliConnector(ICliExecutor executor = null)
    {
        _executor = executor ?? new DefaultCliExecutor();
    }

    public async Task<CliExecutionResult> ListContainersAsync(bool listAll = false, CancellationToken ct = default)
    {
        var builder = CommandSpecBuilder.Create(DockerExecutable)
            .WithArgument("ps");

        if (listAll)
        {
            builder.WithArgument("-a");
        }

        // 格式化输出,便于解析
        builder.WithArgument("--format");
        builder.WithArgument("\"{{.ID}}\\t{{.Names}}\\t{{.Status}}\\t{{.Image}}\"");

        var spec = builder.Build();
        // 设置一个合理的超时,列表操作应该很快
        spec = spec.WithExecutionTimeout(TimeSpan.FromSeconds(15));

        return await _executor.ExecuteAsync(spec, ct).ConfigureAwait(false);
    }

    public async Task<CliExecutionResult> RunContainerAsync(string imageName, string containerName = null, IEnumerable<string> ports = null, IEnumerable<string> volumes = null, CancellationToken ct = default)
    {
        var builder = CommandSpecBuilder.Create(DockerExecutable)
            .WithArgument("run")
            .WithArgument("-d"); // 后台运行

        if (!string.IsNullOrWhiteSpace(containerName))
        {
            builder.WithArgument("--name", containerName);
        }

        if (ports != null)
        {
            foreach (var portMapping in ports)
            {
                builder.WithArgument("-p", portMapping);
            }
        }

        if (volumes != null)
        {
            foreach (var volumeMapping in volumes)
            {
                builder.WithArgument("-v", volumeMapping);
            }
        }

        builder.WithArgument(imageName);

        var spec = builder.Build();
        // 运行容器可能耗时较长,特别是需要拉取镜像时
        spec = spec.WithExecutionTimeout(TimeSpan.FromMinutes(5));

        return await _executor.ExecuteAsync(spec, ct).ConfigureAwait(false);
    }

    // ... 其他方法(StopContainerAsync, RemoveContainerAsync, ExecuteInContainerAsync)的实现类似
    // ExecuteInContainerAsync 会使用 `docker exec` 命令
}

4.2 添加高级特性:日志流式输出与解析

对于 docker logs --follow 这样的命令,我们需要使用流处理器。我们来为连接器增加一个获取实时日志的方法。

public async Task StreamContainerLogsAsync(string containerIdOrName, IOutputReceiver outputReceiver, CancellationToken ct = default)
{
    var spec = CommandSpecBuilder.Create(DockerExecutable)
        .WithArgument("logs")
        .WithArgument("-f") // --follow
        .WithArgument("--tail", "100") // 从最后100行开始
        .WithArgument(containerIdOrName)
        .Build();

    // 注意:对于 follow 模式,命令通常不会自行结束,除非容器停止或连接断开。
    // 因此,我们需要依靠 CancellationToken 来终止执行。
    // 执行器的超时机制在这里可能不适用,或者需要设置得非常长。
    spec = spec.WithExecutionTimeout(TimeSpan.FromHours(1)); // 设置一个极长的超时,由 ct 控制

    await _executor.ExecuteAsync(spec, outputReceiver, ct).ConfigureAwait(false);
    // 当 ct 被取消时,执行器会尝试终止 docker logs 进程。
}

实现一个简单的控制台输出接收器:

public class ConsoleOutputReceiver : IOutputReceiver
{
    private readonly string _prefix;
    public ConsoleOutputReceiver(string prefix = "")
    {
        _prefix = prefix;
    }

    public void OnReceivedStandardOutput(string line)
    {
        if (!string.IsNullOrEmpty(line))
        {
            Console.WriteLine($"[OUT]{_prefix}> {line}");
        }
    }

    public void OnReceivedStandardError(string line)
    {
        if (!string.IsNullOrEmpty(line))
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"[ERR]{_prefix}> {line}");
            Console.ResetColor();
        }
    }
}

4.3 集成与使用示例

最后,在应用程序中集成并使用这个连接器。

public class ContainerManagementService
{
    private readonly IDockerContainerCliConnector _dockerConnector;
    private readonly ILogger<ContainerManagementService> _logger;

    public ContainerManagementService(IDockerContainerCliConnector dockerConnector, ILogger<ContainerManagementService> logger)
    {
        _dockerConnector = dockerConnector;
        _logger = logger;
    }

    public async Task<string> StartWebServerAsync(string imageTag, int hostPort, CancellationToken ct)
    {
        var containerName = $"webserver-{Guid.NewGuid():N}";
        var ports = new[] { $"{hostPort}:80" };

        _logger.LogInformation("Starting container {ContainerName} from image {ImageTag}...", containerName, imageTag);

        try
        {
            var result = await _dockerConnector.RunContainerAsync(
                imageTag,
                containerName: containerName,
                ports: ports,
                ct: ct
            ).ConfigureAwait(false);

            if (result.IsSuccess)
            {
                var containerId = result.StandardOutput.Trim(); // `docker run -d` 输出容器ID
                _logger.LogInformation("Container started successfully. ID: {ContainerId}", containerId);
                return containerId;
            }
            else
            {
                // 结果不成功,抛出包含详细信息的异常
                // 框架可能提供了 CliExecutionException,如果没有,可以自己封装。
                throw new ApplicationException($"Failed to start Docker container. ExitCode: {result.ExitCode}, Error: {result.StandardError}");
            }
        }
        catch (CliExecutionException ex) // 假设框架抛出此异常
        {
            _logger.LogError(ex, "CLI execution failed while starting container.");
            throw new ApplicationException("Container startup command failed.", ex);
        }
        catch (TaskCanceledException) when (ct.IsCancellationRequested)
        {
            _logger.LogWarning("Container startup was cancelled.");
            throw new OperationCanceledException("Container startup was cancelled.", ct);
        }
    }

    public async Task MonitorContainerLogsAsync(string containerId, CancellationToken ct)
    {
        var receiver = new ConsoleOutputReceiver($"[{containerId[..12]}]"); // 显示短ID
        await _dockerConnector.StreamContainerLogsAsync(containerId, receiver, ct).ConfigureAwait(false);
    }
}

5. 高级配置、错误处理与性能优化

5.1 执行器配置与自定义

DefaultCliExecutor 可以通过 CliExecutorOptions 进行配置,以适应不同的场景。

var options = new CliExecutorOptions
{
    // 默认编码,用于解码进程输出流
    StandardOutputEncoding = Encoding.UTF8,
    StandardErrorEncoding = Encoding.UTF8,

    // 输出缓冲区大小(字节)。影响流处理器回调的频率。
    OutputBufferSize = 8192,

    // 是否在进程启动失败或超时时,自动尝试杀死可能产生的子进程。
    // 这对于某些会派生子进程的命令(如某些脚本)很重要。
    KillChildProcessesOnFailure = true,

    // 当使用流处理器(IOutputReceiver)时,是否仍然在结果中保留完整的输出字符串。
    // 如果为false,可以节省内存,但结果对象中的StandardOutput/StandardError可能为空或摘要。
    RetainCompleteOutputWhenUsingReceiver = false,
};

var configuredExecutor = new DefaultCliExecutor(options);

什么时候需要自定义执行器?

  1. 特殊环境 : 如果你的应用运行在 Docker 容器内,需要执行宿主机上的命令,可能需要一个通过 docker exec 或 SSH 来代理执行的执行器。
  2. 资源池 : 如果你需要严格限制并发执行的进程数量,可以实现一个带有限流队列的执行器。
  3. 特殊日志/审计需求 : 你需要记录所有执行的命令及其完整上下文(用户、时间、结果),可以在自定义执行器中添加审计逻辑。

5.2 全面的错误处理策略

与外部进程交互,错误是常态而非例外。一个健壮的系统需要分层处理错误。

  1. CLI 执行层面错误

    • CliExecutionException : 这是框架可能抛出的主要异常,通常包含 CliExecutionResult 。你应该捕获它,并根据业务逻辑决定是重试、降级还是直接失败。
    • TimeoutException : 当命令执行超时时抛出。你需要决定是重试、通知用户还是执行清理操作(比如尝试停止一个可能已经半启动的容器)。
    • IOException / Win32Exception : 当可执行文件找不到、没有执行权限或进程启动失败时可能抛出。
  2. 业务逻辑层面错误 : 即使 CLI 命令成功执行(ExitCode=0),结果也可能不符合业务预期。例如, docker ps 成功了,但没有找到你想要的容器。你需要解析 StandardOutput 来判断业务状态。

推荐的错误处理模式:

public async Task<OperationResult> SafeCliOperationAsync(Func<Task<CliExecutionResult>> operation, string operationName)
{
    try
    {
        var result = await operation().ConfigureAwait(false);
        
        if (!result.IsSuccess)
        {
            _logger.LogError("CLI operation '{OperationName}' failed with exit code {ExitCode}. Stderr: {Stderr}",
                operationName, result.ExitCode, result.StandardError);
            return OperationResult.Failure($"External tool failed: {result.StandardError}");
        }

        // 可选:进一步检查标准输出是否符合预期
        if (string.IsNullOrWhiteSpace(result.StandardOutput) && operationName.Contains("list"))
        {
            _logger.LogWarning("Operation '{OperationName}' succeeded but returned no data.", operationName);
        }

        return OperationResult.Success(result.StandardOutput);
    }
    catch (CliExecutionException ex)
    {
        _logger.LogError(ex, "CLI execution exception during '{OperationName}'.", operationName);
        return OperationResult.Failure($"Command execution error: {ex.Message}");
    }
    catch (TimeoutException ex)
    {
        _logger.LogError(ex, "Timeout during CLI operation '{OperationName}'.", operationName);
        return OperationResult.Failure("Operation timed out.");
    }
    catch (Exception ex) when (ex is IOException || ex is Win32Exception)
    {
        _logger.LogError(ex, "System error starting process for '{OperationName}'. Is the tool installed?", operationName);
        return OperationResult.Failure("Required command-line tool is not available or accessible.");
    }
    catch (OperationCanceledException)
    {
        _logger.LogInformation("Operation '{OperationName}' was cancelled.", operationName);
        return OperationResult.Cancelled();
    }
}

5.3 性能考量与最佳实践

  1. 进程创建开销 : 频繁启动和销毁进程开销很大。对于需要反复调用的简单命令(例如,多次调用 git status ),考虑是否可以通过单次调用获取更多信息,或者在应用层缓存结果。
  2. 输出处理内存 : 对于会产生海量输出的命令(如 cat huge_file.log ), 务必使用 IOutputReceiver 进行流式处理 ,避免将全部内容读入内存。将 RetainCompleteOutputWhenUsingReceiver 设置为 false
  3. 并发控制 DefaultCliExecutor 本身是线程安全的,可以并发调用。但操作系统对进程数量可能有限制。如果你的应用会触发大量并发的外部命令,考虑使用一个自定义的、带有限流机制的执行器,或者使用 SemaphoreSlim 在业务层控制并发度。
  4. 超时设置的艺术
    • 短命令 (如 ls , echo ): 设置较短的超时(如 30 秒),快速失败。
    • 长命令 (如 docker build , 大型数据备份 ): 根据历史数据或预估设置一个合理的长时间超时(如 1 小时),并考虑增加心跳或进度报告机制,而不是单纯依赖一个总超时。
    • 交互式/持续命令 (如 docker logs -f , tail -f ): 这类命令设计上不会自行结束。应该设置一个极长的超时或 Timeout.InfiniteTimeSpan ,并通过 CancellationToken 来控制其生命周期。确保在取消时,执行器能正确终止进程。
  5. 工作目录与环境 : 明确设置 WorkingDirectory ,避免依赖不可靠的当前目录。谨慎覆盖 EnvironmentVariables ,以免破坏子进程所需的环境(如 PATH )。通常的做法是复制当前进程的环境变量字典,然后进行修改。

6. 测试策略:如何为 CLI 集成代码编写可靠测试

测试与外部进程交互的代码是出了名的困难。OpenClaw.NET 的分层设计在这里展现了巨大优势。

6.1 单元测试连接器逻辑

连接器的职责是构建正确的 CommandSpecification 。我们可以完全 Mock 掉 ICliExecutor ,来验证连接器产生的命令规格。

[Test]
public void ListContainersAsync_BuildsCorrectCommandSpec_WithAllFlag()
{
    // Arrange
    var mockExecutor = new Mock<ICliExecutor>();
    var connector = new DockerContainerCliConnector(mockExecutor.Object);

    // Act
    // 我们并不真正执行,只是获取连接器内部构建的spec。
    // 在实际中,可能需要通过反射或修改连接器设计(例如提供一个BuildSpec方法)来获取spec。
    // 这里假设我们为了测试,稍微调整了连接器,使其有一个可测试的BuildListCommandSpec方法。
    var spec = connector.BuildListCommandSpec(true);

    // Assert
    Assert.That(spec.ExecutablePath, Is.EqualTo("docker"));
    Assert.That(spec.Arguments, Contains.Item("ps"));
    Assert.That(spec.Arguments, Contains.Item("-a"));
    Assert.That(spec.Arguments, Contains.Item("--format"));
    // 验证参数顺序和格式
}

为了便于测试,可以考虑将命令规格的构建逻辑提取到单独的方法中,或者使用测试专用的子类。

6.2 模拟执行器(Mocking)进行集成逻辑测试

对于使用连接器的服务类(如 ContainerManagementService ),我们可以 Mock IDockerContainerCliConnector ,模拟成功、失败、超时等各种场景,来测试服务层的业务逻辑和错误处理。

[Test]
public async Task StartWebServerAsync_OnSuccess_ReturnsContainerId()
{
    // Arrange
    var mockConnector = new Mock<IDockerContainerCliConnector>();
    var fakeContainerId = "abc123def456";
    var mockResult = new CliExecutionResult(
        specification: It.IsAny<CommandSpecification>(),
        exitCode: 0,
        standardOutput: fakeContainerId + "\n", // docker run -d 输出ID加换行
        standardError: "",
        duration: TimeSpan.FromSeconds(1)
    );
    mockConnector
        .Setup(c => c.RunContainerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync(mockResult);

    var service = new ContainerManagementService(mockConnector.Object, Mock.Of<ILogger<ContainerManagementService>>());

    // Act
    var resultId = await service.StartWebServerAsync("nginx:latest", 8080, CancellationToken.None);

    // Assert
    Assert.That(resultId, Is.EqualTo(fakeContainerId));
    mockConnector.Verify(c => c.RunContainerAsync("nginx:latest", It.IsAny<string>(), It.Is<string[]>(p => p.Contains("8080:80")), null, It.IsAny<CancellationToken>()), Times.Once);
}

[Test]
public void StartWebServerAsync_OnCliFailure_ThrowsApplicationException()
{
    // Arrange
    var mockConnector = new Mock<IDockerContainerCliConnector>();
    var mockResult = new CliExecutionResult(
        specification: It.IsAny<CommandSpecification>(),
        exitCode: 125, // Docker 常见的客户端错误码
        standardOutput: "",
        standardError: "Error response from daemon: conflict: container name already in use",
        duration: TimeSpan.FromSeconds(1)
    );
    mockConnector
        .Setup(c => c.RunContainerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync(mockResult);

    var service = new ContainerManagementService(mockConnector.Object, Mock.Of<ILogger<ContainerManagementService>>());

    // Act & Assert
    var ex = Assert.ThrowsAsync<ApplicationException>(() => service.StartWebServerAsync("nginx:latest", 8080, CancellationToken.None));
    Assert.That(ex.Message, Does.Contain("Failed to start Docker container"));
}

6.3 (谨慎使用的)真实进程测试

对于执行器本身( DefaultCliExecutor )的测试,或者对端到端流程的集成测试,可能需要运行真实的命令。这类测试应该:

  • 标记为集成测试( [Category("Integration")] ),并与单元测试分开运行。
  • 具有可预测性和幂等性。使用像 echo , dir / ls , whoami 这样无害且结果确定的命令。
  • 处理好环境差异。测试中使用的命令必须在所有目标平台(Windows, Linux, macOS)的测试环境中都存在。
  • 清理资源。如果测试创建了文件、进程或网络连接,必须在测试完成后彻底清理。
[Test]
[Category("Integration")]
public async Task DefaultCliExecutor_CanExecuteSimpleEchoCommand()
{
    // Arrange
    var executor = new DefaultCliExecutor();
    var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
    var spec = CommandSpecBuilder.Create(isWindows ? "cmd" : "sh")
        .WithArgument(isWindows ? "/c" : "-c")
        .WithArgument("echo Hello World")
        .Build();

    // Act
    var result = await executor.ExecuteAsync(spec);

    // Assert
    Assert.That(result.IsSuccess, Is.True);
    Assert.That(result.ExitCode, Is.EqualTo(0));
    // 注意:输出可能包含换行符或平台特定的结尾
    Assert.That(result.StandardOutput.Trim(), Does.Contain("Hello World"));
    Assert.That(result.StandardError, Is.Empty);
}

7. 常见问题排查与实战技巧

7.1 问题速查表

问题现象 可能原因 排查步骤与解决方案
CliExecutionException :退出码不为0 1. 命令本身执行失败(参数错误、资源不足等)。
2. 可执行文件路径错误。
3. 环境变量缺失(如 PATH 中找不到命令)。
1. 检查 result.StandardError ,通常包含具体的错误信息。
2. 在 Shell 中手动执行相同的命令,验证其正确性。
3. 在 CommandSpecification 中指定可执行文件的绝对路径。
4. 检查并设置必要的环境变量(如 JAVA_HOME , ANDROID_HOME )。
TimeoutException :命令执行超时 1. 命令本身执行时间过长。
2. 命令等待用户输入(挂起)。
3. 命令产生了大量输出,填满了管道缓冲区导致死锁。
1. 根据命令性质增加 ExecutionTimeout
2. 检查命令是否需要交互式输入。如果需要,通过 StandardInputData 提供输入,或使用伪终端(PTY)模拟交互(这需要更高级的配置,可能超出默认执行器能力)。
3. 确保同时读取标准输出和标准错误流 。使用 IOutputReceiver 或确保 BeginOutputReadLine / BeginErrorReadLine 模式正确。框架的 DefaultCliExecutor 已正确处理此问题。
进程启动失败,抛出 Win32Exception IOException 1. 可执行文件不存在或路径错误。
2. 当前用户没有执行权限。
3. 文件不是有效的可执行文件。
1. 使用 File.Exists() 验证路径。
2. 尝试在 Shell 中以相同用户身份执行。
3. 在 Windows 上,确保文件扩展名(如 .exe , .bat , .cmd )正确,或在 FileName 中包含扩展名。
输出内容乱码 进程输出的编码与控制台或应用程序期望的编码不一致。 CliExecutorOptions 中设置正确的 StandardOutputEncoding StandardErrorEncoding 。对于中文 Windows,常用 Encoding.GetEncoding("GBK") ;对于跨平台应用,优先使用 Encoding.UTF8
命令在 Shell 中能运行,在代码中失败 1. Shell 提供了额外的环境变量或别名。
2. 命令依赖于 Shell 的内置功能(如通配符 * 扩展、管道 `
`)。
3. 工作目录不同。
在 Linux/macOS 上执行需要 sudo 的命令失败 权限不足。 1. 最佳实践:避免在应用代码中直接调用 sudo 。应通过系统服务(如 systemd)或配置 sudoers 文件让特定命令无需密码运行,然后直接执行该命令。
2. 如果必须,可以考虑使用 ProcessStartInfo UserName Password 属性(Windows)或通过 expect 等工具自动化(不推荐,安全性差)。

7.2 实战技巧与心得

  1. 日志记录一切 : 在执行任何外部命令前,记录完整的 CommandSpecification (至少是 FileName Arguments )。发生错误时,记录整个 CliExecutionResult 。这是调试的黄金信息。但要注意,日志中可能包含敏感信息(如密码、密钥),在记录 Arguments EnvironmentVariables 时要进行脱敏处理。
  2. 为命令设置“指纹” : 在分布式系统中,为了追踪一个外部命令的执行,可以为其生成一个唯一 ID(如 ActivityId 或自定义的 CorrelationId ),并将其作为环境变量传递给子进程(例如 MY_APP_CORRELATION_ID=abc123 )。这样,子进程及其可能产生的日志也能与你的主应用请求关联起来。
  3. 谨慎处理用户输入 : 如果命令参数来自用户输入,必须进行严格的验证和转义,以防止命令注入攻击。 CommandSpecBuilder WithArgument 方法通常会自动处理参数的转义,但最安全的方式是避免将用户输入直接拼接为命令行参数,而是通过环境变量或标准输入传递。
  4. 考虑使用包装脚本 : 对于极其复杂、参数繁多或需要复杂前置/后置处理的命令,与其在 C# 代码中构建长长的参数列表,不如编写一个简单的 Shell 脚本或批处理文件。然后你的连接器只需要调用这个脚本,并传递几个关键参数。这可以简化 .NET 代码,并将命令行逻辑集中在一个更易于维护的脚本中。
  5. 处理僵尸进程 : 确保你的执行逻辑总是能妥善终止进程。 DefaultCliExecutor 配合 KillTimeout 通常能处理好。但在极端情况下(如进程进入 D 状态不可中断睡眠),可能需要更激进的操作系统级清理。在你的应用程序关闭时,确保所有由它创建的外部进程都已终止。
  6. 跨平台兼容性 : 路径分隔符( \ vs / )、换行符( \r\n vs \n )、可执行文件扩展名等都是坑。尽量使用 Path.Combine() Environment.NewLine ,并对平台特定的命令进行抽象。例如,一个“打开文件所在文件夹”的功能,在 Windows 上是 explorer /select, ,在 macOS 上是 open -R ,在 Linux 上可能是 xdg-open 。你的连接器应该检测当前操作系统并调用相应的命令。

更多推荐