05-与机器沟通

结构化输出与管道协作

译文 · 基于 Command Line Applications in Rust

与机器沟通

原文链接: https://rust-cli.github.io/book/in-depth/machine-communication.html

命令行工具的真正威力,在你能把它们组合起来时才会显现。这并不是新想法:事实上,Unix 哲学里有这样一句话:

期望每个程序的输出都能成为另一个(尚且未知的)程序的输入。

如果我们的程序满足这一期望,用户就会开心。为确保这一点运作良好,我们不应只提供给人看的漂亮输出,还应提供为其它程序量身定制的版本。来看看可以怎么做。

谁在读?

首先要问的是:我们的输出是给坐在彩色终端前的人看的,还是给另一个程序看的?要回答这个问题,可以用 IsTerminal trait:

1
2
3
4
5
6
7
use std::io::IsTerminal;

if std::io::stdout().is_terminal() {
    println!("I'm a terminal");
} else {
    println!("I'm not");
}

取决于谁会读我们的输出,我们可以添加额外信息。人类往往喜欢颜色,例如,在某个随机的 Rust 项目中运行 ls,你可能会看到类似这样的内容:

1
2
3
4
5
$ ls
CODE_OF_CONDUCT.md   LICENSE-APACHE       examples
CONTRIBUTING.md      LICENSE-MIT          proptest-regressions
Cargo.lock           README.md            src
Cargo.toml           convey_derive        target

因为这种风格是为人设计的,在多数配置下它甚至会用颜色打印一些名字(如 src)以表明它们是目录。如果你把输出管道到文件,或像 cat 这样的程序,ls 会调整其输出。它不会再用适合我终端窗口的列排版,而是每个条目单独一行。它也不会发出任何颜色。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ls | cat
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Cargo.lock
Cargo.toml
LICENSE-APACHE
LICENSE-MIT
README.md
convey_derive
examples
proptest-regressions
src
target

面向机器的简单输出格式

历史上,命令行工具产生的唯一输出类型就是字符串。这对坐在终端前的人通常没问题,他们能阅读文本并理解其含义。其它程序通常没有这种能力:它们要理解像 ls 这样工具的输出,唯一办法是程序作者内置了一个恰好能解析 ls 当前输出的解析器。

这往往意味着输出被限制在易于解析的范围内。诸如 TSV(制表符分隔值)这类格式——每条记录一行,每行包含制表符分隔的内容——非常流行。这些基于文本行的简单格式,让像 grep 这样的工具可以用于 ls 一类工具的输出。| grep Cargo 并不关心你的行来自 ls 还是文件,它只是逐行过滤。

这样做的缺点是,你不能用简单的 grep 调用过滤出 ls 给你的所有目录。要做到那一点,每个目录项需要携带额外数据。

面向机器的 JSON 输出

制表符分隔值是输出结构化数据的简单方式,但它要求另一个程序知道要期望哪些字段(以及以何种顺序),并且难以输出不同类型的消息。例如,假设我们的程序想告诉消费者它正在等待下载,之后再输出一条描述所获数据的消息。那是非常不同的消息类型,试图把它们统一到 TSV 输出中会要求我们发明一种区分它们的方法。当我们想打印一条包含两个长度不等的列表的消息时也一样。

即便如此,选择一种在大多数编程语言/环境中都易于解析的格式仍是好主意。因此,过去几年里许多应用获得了以 JSON 输出数据的能力。它足够简单,几乎每种语言都有解析器,又足够强大,在很多场景下都有用。虽然它是人类也能读的文本格式,也有很多人致力于实现能非常快速地解析 JSON、以及把数据序列化为 JSON 的实现。

在上面的描述中,我们谈到了程序「写出」的「消息」。这是思考输出的好方式:你的程序不一定只输出一大块数据,实际上可能在运行过程中发出许多不同信息。在输出 JSON 时支持这种做法的一种简单方式,是每条消息写一个 JSON 文档,并把每个 JSON 文档放在新的一行(有时称为按行分隔的 JSON)。这可以让实现简单到只需使用普通的 println!。

下面是一个简单例子,使用 serde_json 的 json! 宏,在 Rust 源码中快速写出有效的 JSON:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use clap::Parser;
use serde_json::json;

/// 在文件中搜索模式,并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 输出 JSON,而不是人类可读的消息
    #[arg(long = "json")]
    json: bool,
}

fn main() {
    let args = Cli::parse();
    if args.json {
        println!(
            "{}",
            json!({
                "type": "message",
                "content": "Hello world",
            })
        );
    } else {
        println!("Hello world");
    }
}

输出如下:

1
2
3
4
$ cargo run -q
Hello world
$ cargo run -q -- --json
{"content":"Hello world","type":"message"}

(用 -q 运行 cargo 会抑制其通常的输出。-- 之后的参数会传给我们的程序。)

实践例子:ripgrep

ripgrep 是用 Rust 编写的 grep 或 ag 替代品。默认情况下它会产出类似这样的输出:

1
2
3
4
5
6
$ rg default
src/lib.rs
37:    Output::default()

src/components/span.rs
6:    Span::default()

但加上 --json 后它会打印:

1
2
3
4
5
6
7
8
$ rg default --json
{"type":"begin","data":{"path":{"text":"src/lib.rs"}}}
{"type":"match","data":{"path":{"text":"src/lib.rs"},"lines":{"text":"    Output::default()\n"},"line_number":37,"absolute_offset":761,"submatches":[{"match":{"text":"default"},"start":12,"end":19}]}}
{"type":"end","data":{"path":{"text":"src/lib.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":137622,"human":"0.000138s"},"searches":1,"searches_with_match":1,"bytes_searched":6064,"bytes_printed":256,"matched_lines":1,"matches":1}}}
{"type":"begin","data":{"path":{"text":"src/components/span.rs"}}}
{"type":"match","data":{"path":{"text":"src/components/span.rs"},"lines":{"text":"    Span::default()\n"},"line_number":6,"absolute_offset":117,"submatches":[{"match":{"text":"default"},"start":10,"end":17}]}}
{"type":"end","data":{"path":{"text":"src/components/span.rs"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":22025,"human":"0.000022s"},"searches":1,"searches_with_match":1,"bytes_searched":5221,"bytes_printed":277,"matched_lines":1,"matches":1}}}
{"data":{"elapsed_total":{"human":"0.006995s","nanos":6994920,"secs":0},"stats":{"bytes_printed":533,"bytes_searched":11285,"elapsed":{"human":"0.000160s","nanos":159647,"secs":0},"matched_lines":2,"matches":2,"searches":2,"searches_with_match":2}},"type":"summary"}

如你所见,每个 JSON 文档都是一个包含 type 字段的对象(映射)。这让我们可以为 rg 写一个简单的前端,在这些文档陆续到来时读取它们,并显示匹配项(以及它们所在的文件),即使 ripgrep 仍在搜索中。

如何处理管道传入的输入

假设我们有一个读取文件中单词数的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use clap::Parser;
use std::path::PathBuf;

/// 统计文件中的行数
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// 要读取的文件路径
    file: PathBuf,
}

fn main() {
    let args = Cli::parse();
    let mut word_count = 0;
    let file = args.file;

    for line in std::fs::read_to_string(&file).unwrap().lines() {
        word_count += line.split(' ').count();
    }

    println!("Words in {}: {}", file.to_str().unwrap(), word_count)
}

它接受文件路径,逐行读取,并统计以空格分隔的单词数。

运行时,它会输出文件中的总单词数:

1
2
$ cargo run README.md
Words in README.md: 47

但如果我们想统计管道传入程序的单词数呢?Rust 程序可以通过标准库的 stdin 函数 获得 Stdin 结构体 来读取经 stdin 传入的数据。与读取文件的行类似,它也可以从 stdin 读取行。

下面是一个统计经 stdin 管道传入内容单词数的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
use clap::{CommandFactory, Parser};
use std::{
    fs::File,
    io::{BufRead, BufReader, IsTerminal, stdin},
    path::PathBuf,
};

/// 统计文件或 stdin 中的行数
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// 要读取的文件路径;使用 - 从 stdin 读取(stdin 不能是 tty)
    file: PathBuf,
}

fn main() {
    let args = Cli::parse();

    let word_count;
    let mut file = args.file;

    if file == PathBuf::from("-") {
        if stdin().is_terminal() {
            Cli::command().print_help().unwrap();
            ::std::process::exit(2);
        }

        file = PathBuf::from("<stdin>");
        word_count = words_in_buf_reader(BufReader::new(stdin().lock()));
    } else {
        word_count = words_in_buf_reader(BufReader::new(File::open(&file).unwrap()));
    }

    println!("Words from {}: {}", file.to_string_lossy(), word_count)
}

fn words_in_buf_reader<R: BufRead>(buf_reader: R) -> usize {
    let mut count = 0;
    for line in buf_reader.lines() {
        count += line.unwrap().split(' ').count()
    }
    count
}

如果你用管道传入文本运行该程序,并用 - 表示要从 stdin 读取,它会输出单词数:

1
2
$ echo "hi there friend" | cargo run -- -
Words from stdin: 3

它要求 stdin 不是交互式的,因为我们期望的是通过管道传入程序的输入,而不是运行时键入的文本。如果 stdin 是 tty,它会输出帮助文档,以便说明为何无法工作。

最后修改 August 11, 2026: 更新 (70a5af133)