与机器通信

当你能够将命令行工具组合在一起时,它们的强大之处才真正得以体现。这并不是一个新概念:事实上,这句话出自Unix 哲学

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

如果我们的程序满足这个期望,我们的用户将会很高兴。为了确保这一点能够顺利进行,我们不仅应该为人类提供漂亮的输出,还应该提供一个为其他程序量身定制的版本。让我们来看看如何做到这一点。

谁在阅读?

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

use std::io::IsTerminal;

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

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

$ 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 将会调整其输出。它不会使用适合我终端窗口的列,而是会在自己的行上打印每个条目。它也不会发出任何颜色。

$ 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_jsonjson! 宏在你的 Rust 源代码中快速编写有效的 JSON

use clap::Parser;
use serde_json::json;

/// Search for a pattern in a file and display the lines that contain it.
#[derive(Parser)]
struct Cli {
    /// Output JSON instead of human readable messages
    #[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");
    }
}

这是输出

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

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

实际示例:ripgrep

ripgrep 是用 Rust 编写的 grepag 的替代品。默认情况下,它会产生如下输出:

$ rg default
src/lib.rs
37:    Output::default()

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

但是给定 --json 它会打印:

$ 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 仍在搜索时显示匹配项(以及它们所在的文件)。

如何处理通过管道传递给我们的输入

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

use clap::Parser;
use std::path::PathBuf;

/// Count the number of lines in a file
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// The path to the file to read
    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)
}

它获取文件的路径,逐行读取文件,并计算由空格分隔的单词数。

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

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

但是,如果我们想计算通过管道传递到程序中的单词数怎么办?Rust 程序可以使用 Stdin struct 读取通过 stdin 传入的数据,你可以通过标准库中的 stdin 函数 获取该结构体。与读取文件的行类似,它可以读取来自 stdin 的行。

这是一个计算通过 stdin 管道输入的单词数的程序

use clap::{CommandFactory, Parser};
use std::{
    fs::File,
    io::{stdin, BufRead, BufReader, IsTerminal},
    path::PathBuf,
};

/// Count the number of lines in a file or stdin
#[derive(Parser)]
#[command(arg_required_else_help = true)]
struct Cli {
    /// The path to the file to read, use - to read from stdin (must not be a 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 读取,它将输出单词计数

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

它要求 stdin 不是交互式的,因为我们期望输入通过管道传递到程序,而不是在运行时键入的文本。如果 stdin 是 tty,它会输出帮助文档,以便清楚为什么它不起作用。