与机器通信

当您能够将命令行工具组合在一起时,它们的力量真正显现出来。这不是一个新想法:事实上,这是来自 Unix 哲学 的一句话。

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

如果我们的程序满足了这个期望,我们的用户会很高兴。为了确保这一点,我们应该不仅为人类提供漂亮的输出,还要提供一个针对其他程序需求的版本。让我们看看如何做到这一点。

谁在阅读本文?

第一个要问的问题是:我们的输出是面向彩色终端前的人类,还是面向另一个程序?为了回答这个问题,我们可以使用像 is-terminal 这样的板条箱。

use is_terminal::IsTerminal as _;

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)以表明它们是目录。如果您将它管道到文件或像 catls 这样的程序,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 结构体 读取通过 stdin 传递的数据,您可以通过标准库中的 stdin 函数 获取它。类似于读取文件的行,它可以读取 stdin 的行。

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

use clap::{CommandFactory, Parser};
use is_terminal::IsTerminal as _;
use std::{
    fs::File,
    io::{stdin, BufRead, BufReader},
    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,它会输出帮助文档,以便清楚地说明它为什么不起作用。