与机器通信
当您能够将命令行工具组合在一起时,它们的力量真正显现出来。这不是一个新想法:事实上,这是来自 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
)以表明它们是目录。如果您将它管道到文件或像 cat
、ls
这样的程序,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_json 的 json!
宏在您的 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 编写的 grep 或 ag 的替代品。默认情况下,它将生成如下输出
$ 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,它会输出帮助文档,以便清楚地说明它为什么不起作用。