跳转到主内容CLI 输出和退出码 | 物联网民工

CLI 输出和退出码

命令行工具的输出不是随便打印几行文本。对 shell、CI 和其他程序来说,它的接口通常是三件事:

stdout   -> 正常数据
stderr   -> 诊断信息、错误信息
exit code -> 成功或失败

C 里通常写:

printf("%s\n", result);
fprintf(stderr, "tool: %s\n", error);
return 1;

Rust 里也有同样的边界,只是表达方式不止一种。

stdout 写正常结果

简单输出用 println!:

println!("{count}\t{path}");

它写到 stdout,适合给用户或管道消费的正常结果:

tool input.txt > result.txt

如果输出很多行,或者你想显式处理写入错误,可以用 std::io::Write:

use std::io::{self, Write};

fn write_report(rows: &[String]) -> io::Result<()> {
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for row in rows {
        writeln!(out, "{row}")?;
    }

    Ok(())
}

这比到处 println! 更适合库化的输出逻辑,也能处理 pipe 断开这类写入错误。

stderr 写错误和诊断

简单错误输出用 eprintln!:

eprintln!("tool: failed to read {path}: {err}");

它写到 stderr。这样用户可以把正常结果重定向到文件,同时仍然在终端看到错误:

tool input.txt > result.txt

使用 stderr 的常见内容:

  • usage 和参数错误
  • 文件读写失败
  • 调试、警告、进度信息
  • 人读的错误消息

不要把正常机器可消费的数据混进 stderr,也不要把错误消息混进 stdout。命令行工具一旦被脚本调用,这些流就是协议。

main 可以返回 ExitCode

C 里 main 返回整数:

int main(void)
{
    if (failed) {
        return 1;
    }
    return 0;
}

Rust 里可以显式返回 ExitCode:

use std::process::ExitCode;

fn main() -> ExitCode {
    if let Err(err) = run() {
        eprintln!("tool: {err}");
        return ExitCode::FAILURE;
    }

    ExitCode::SUCCESS
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

这比在每个错误点 process::exit(1) 更容易维护。run 仍然是普通函数,能测试、能复用;main 只负责把最终结果变成进程退出状态。

process::exit 会直接结束进程

第一篇里用过:

std::process::exit(1);

它能工作,但要知道它的含义:直接结束当前进程。

这适合放在很薄的 main 边界,不适合放在库函数里:

fn parse_config(path: &str) -> Config {
    // 不要在库函数里直接 process::exit(1)
    todo!()
}

库函数应该返回错误:

fn parse_config(path: &str) -> Result<Config, ConfigError> {
    let text = std::fs::read_to_string(path)?;
    Config::parse(&text)
}

由 CLI 层决定怎么打印、用什么退出码:

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("tool: {err}");
            ExitCode::FAILURE
        }
    }
}

usage 错误和运行错误可以分开

C 工具常用不同退出码:

if (argc != 2) {
    fprintf(stderr, "usage: tool <file>\n");
    return 2;
}

if (read_failed) {
    return 1;
}

Rust 也可以这么做:

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(AppError::Usage(msg)) => {
            eprintln!("{msg}");
            ExitCode::from(2)
        }
        Err(err) => {
            eprintln!("tool: {err}");
            ExitCode::FAILURE
        }
    }
}

这里 AppError 可以是你自己的错误类型:

#[derive(Debug)]
enum AppError {
    Usage(String),
    Io(std::io::Error),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Usage(msg) => write!(f, "{msg}"),
            AppError::Io(err) => write!(f, "{err}"),
        }
    }
}

impl std::error::Error for AppError {}

第一层不用把错误体系做得很复杂。先把“参数用错”和“运行失败”分开,很多 CLI 就更清楚了。

子进程退出状态要检查

调用外部命令时,Command::status() 成功只表示进程启动并等待成功,不表示子进程业务成功:

use std::process::Command;

let status = Command::new("gzip")
    .arg("file.log")
    .status()?;

if !status.success() {
    eprintln!("gzip failed: {status}");
    return ExitCode::FAILURE;
}

如果你捕获输出:

let output = Command::new("git")
    .args(["rev-parse", "--short", "HEAD"])
    .output()?;

if !output.status.success() {
    eprintln!("git failed: {}", output.status);
    return ExitCode::FAILURE;
}

这对应 C 里检查 system() 或 waitpid() 得到的状态。不要只看 API 调用有没有返回 Err,还要看子进程自己的 exit status。

Broken pipe 不一定要吓用户

命令行工具常被管道截断:

tool huge-file | head

head 提前退出后,tool 继续写 stdout 可能遇到 broken pipe。简单工具用 println! 时,默认行为未必总是你想要的。更可控的写法是显式写 stdout 并处理错误:

use std::io::{self, Write};

fn run() -> io::Result<()> {
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for i in 0..1_000_000 {
        writeln!(out, "{i}")?;
    }

    Ok(())
}

在 main 里可以根据错误类型决定是否安静退出。第一层先知道:大量输出的 CLI,不要把 stdout 写入错误完全忽略。

这一层先记住什么

C 到 Rust 的 CLI 边界对应关系:

printf normal output        -> println! / stdout + Write
fprintf(stderr, ...)        -> eprintln! / stderr + Write
return 0                    -> ExitCode::SUCCESS
return 1                    -> ExitCode::FAILURE / ExitCode::from(n)
main 里统一处理错误          -> run() -> Result + main 转 ExitCode
库函数失败                   -> 返回 Result,不直接退出进程
system/wait status          -> Command status / output.status

CLI 程序最外层可以把错误转成文字和退出码。库函数不要替调用方决定进程什么时候结束。stdout、stderr 和 exit code 是命令行工具的外部契约,应该像 API 一样维护。

继续阅读