用 Rust 写命令行应用

Rust 是一种静态编译的,具有强大的工具和快速成长的生态系统迅捷的语言。这使得其非常适合编写命令行应用:应该小巧、可移植且运行快速。命令行应用程序不失为学习 Rust 的一个好方法;或者可以把它介绍给你的团队。

编写简易命令行界面(CLI)的程序对于刚接触这门语言并希望能找到点感觉的初学者来说是一个相当不错的练习。当然这其中有很多方面,通常只在后面才显露出来。

本书的结构是这样的:我们从一个快速教程开始,然后你会得到一个可以工作的 CLI 。你将会接触到一些 Rust 的核心概念以及 CLI 程序的主要方面。接下来的章节将会对其中一些方面进行更详细的介绍。

在开始 CLI 应用之前,还有最后一件事:如果在本书中发现了错误,或者想帮助我们编写更多内容,你可以在 CLI WG 仓库中找到源码。我们很乐意能有你的反馈!谢谢!

译者留: 1.帮助本文翻译纠错或者帮助更新翻译内容,跟进最新的翻译版本,请在本翻译仓库进行 pr 或建言,谢谢! 2.本书原文使用的一些链接指向的版本可能较旧,例如 Cargo 文档的版本本书用的是 1.39,但最新的版本和1.39还是有许多差别的,望读者在遇到这类指向特定版本的文档链接的时候稍微斟酌对比。

一个十五分钟的命令行应用

本教程将指导你用 Rust 编写 CLI (命令行界面)应用程序。这将花费你十五分钟以使你拥有一个正在运行的程序(大约到 1.3 章)。在那之后,我们将继续调整我们的程序直到可以发布为止。

你将学习关于如何开始的所有要点,以及在哪里能找到更多信息。你可以随意略过现在你不需要知道的部分或者跳到任一位置。

你想要编写哪种项目呢?我们先从简单的事情开始:让我们编写微型的 grep 复刻版。这是一个我们可以给出字符串和路径,且它将仅打印给定字符串的行的工具。我们称其为 grrs (发音为 “grass” )。

最后,我们想要能像这样运行我们的工具:

$ cat test.txt
foo: 10
bar: 20
baz: 30
$ grrs foo test.txt
foo: 10
$ grrs --help
[some help text explaining the available options]

项目设置

如果你还未在你的计算机中安装 Rust,请 安装 Rust (一般只花费几分钟)。在那之后,打开终端并导航到你想要存放应用程序代码的目录。

首先在你的程序项目所在目录中运行 cargo new grrs,你将会看到一个 Rust 项目的典型设置:

  • Cargo.toml 文件包含了项目的元数据,包括我们使用的依赖/外部库的清单。
  • src/main.rs 文件是我们(main) 二进制文件的入口点。

如果你在 grrs 目录执行 cargo run 命令,会得到一个 “hello world” ,说明一切准备就绪。

它可能看起来像

$ cargo new grrs
     Created binary (application) `grrs` package
$ cd grrs/
$ cargo run
   Compiling grrs v0.1.0 (/Users/pascal/code/grrs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.70s
     Running `target/debug/grrs`
Hello, world!

解析命令行参数

我们的 CLI 工具的一个典型调用像这样:

$ grrs foobar test.txt

我们期望我们的程序查看 test.txt 并且打印出含有 foobar 的行。但是我们如何得到这两个值呢?

在程序名称之后的文本通常叫做 “命令行参数” 或 “命令行标签” (特别是当它们看起来像 --this 时)。在内部,操作系统通常将它们表示为 字符串列表——通俗地说,它们用空格分隔。

有许多方法考虑这些参数,以及如何将其解析为更易于使用的参数。您还需要告诉程序的用户他们需要给出哪些参数以及期望的格式。

获取参数

标准库包含了 std::env::args() 函数,它为你提供给定参数的 迭代器iterator 。第一个输入(在索引 0 处)将是你的程序所叫名称 (例如 grrs)。其后是用户随之编写的内容。

用这种方法获取原始参数非常容易(在文件 src/main.rsfn main() { 之后):

let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");

CLI 参数作为数据类型

将 CLI 参数视为输入程序的自定义数据类型而不是视为一串文本,通常会很有意义。

来看 grrs foobar test.txt:有两个参数,第一个是 模式(要查找的字符串),然后是 路径(要查找的文件)。

对两者更多的描述?好吧,首先两者都是必需的。我们还未讨论任何默认值,因此我们希望用户总是提供两个值。除此之外,我们可以说一下它们的类型:模式应该是字符串,而第二个参数应该是文件路径。

在 Rust 中,通常围绕数据处理来构造程序,因此查看 CLI 参数 的方法很合适(译者注:即将参数作为数据类型),让我们这样开始(在文件 src/main.rs 中的 fn main() { 之前):

struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

这定义了一个拥有两个用于储存数据的字段 patternpath 的新结构(一个结构体)。

现在,我们依然需要将我们程序获取的实际参数转换成这种形式。一种选择是手动解析从操作系统获得的字符串列表,然后自己构建结构。 看起来像这样:

let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
let args = Cli {
    pattern: pattern,
    path: std::path::PathBuf::from(path),
};

代码可以工作,但这样非常不方便。你将如何处理支持 --pattern="foo"--pattern "foo" 的需求?你如何实现 --help

用 StructOpt 传递命令行参数

一个更好的办法是使用众多可用库中的一个。解析命令行参数最常用的库叫做 clap 。其具有你所期望的所有功能,包括支持子命令,shell 实现和良好的帮助信息。

structopt 库基于 clap 构建,并提供 “derive” 宏来为 struct 定义生成 clap 代码。非常不错,我们要做的就是注解一个结构体且它会生成将参数解析为字段的代码。

让我们通过在 Cargo.toml 文件的 [dependencies] 块中添加 structopt = "0.3.13" 来首次引入 structopt

现在,我们可以在代码中写入 use structopt::StructOpt; ,并在 struct Cli 上方添加 #[derive(StructOpt)]。我们顺便也写一些文档注释。

它看起来像这样(在 src/main.rs 中的 fn main() { 之前):

use structopt::StructOpt;

/// Search for a pattern in a file and display the lines that contain it.
#[derive(StructOpt)]
struct Cli {
    /// The pattern to look for
    pattern: String,
    /// The path to the file to read
    #[structopt(parse(from_os_str))]
    path: std::path::PathBuf,
}

Cli 结构体下方,我们的模板包含 main 函数。程序启动时,将调用此函数。第一行是:

fn main() {
    let args = Cli::from_args();
}

这将尝试将参数解析到我们的 Cli 结构体中。

但是如果失败了怎么办呢?这时候这种办法的优点就显现出来了:Clap知道期望哪些字段以及期望的格式。它会自动生成一个较好的 --help 消息,同时当你写入 --putput 时,会发出一些错误提示来建议你更改为 --output

这看起来像

运行的时候不使用任何参数:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 10.16s
     Running `target/debug/grrs`
error: The following required arguments were not provided:
    <pattern>
    <path>

USAGE:
    grrs <pattern> <path>

For more information try --help

可以直接在使用 cargo run 时通过在其后写 -- 传递参数:

$ cargo run -- some-pattern some-file
    Finished dev [unoptimized + debuginfo] target(s) in 0.11s
     Running `target/debug/grrs some-pattern some-file`

如你所见,没有输出。这很棒:表示没有错误,程序(运行完)结束了。

首次实现 grrs

在上一章命令行参数之后,我们有了输入数据,可以编写实际的工具了。我们的 “main” 函数现在只包括以下这行:

let args = Cli::from_args();

先从打开我们拿到的文件开始。

let content = std::fs::read_to_string(&args.path)
    .expect("could not read file");

现在,让我们遍历行并打印包含模式的每一行:

for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

尝试一下: cargo run -- main src/main.rs 现在应该能正常工作了。

更好的错误报告

错误总会发生这个事实我们只能选择接受。与其他语言相比,在使用 Rust 时很难不注意和面对这个现实:因为它没有异常,所有可能的错误状态通常都编码在函数的返回类型中。

Results

read_to_string 这样的函数并不返回一个字符串,而是返回一个包含 String 或某种类型的错误(这种情况下为 std::io::Error)的 Result

怎么能知道它是什么呢?因为 Result 是个枚举类型,所以可以使用 match 来检查它是哪种变量:


#![allow(unused)]
fn main() {
let result = std::fs::read_to_string("test.txt");
match result {
    Ok(content) => { println!("File content: {}", content); }
    Err(error) => { println!("Oh noes: {}", error); }
}
}

Unwrapping

现在,我们可以访问文件中的内容,但是在 match 块之后,我们实际上就不能做任何事了(译者注:因为上面代码中 match 到的类型并未在 match 块后记录下来)。为此,我们需要以某种方式处理错误情况。挑战在于 match 块的所有分支都需要返回相同类型的的值。有一个巧妙的技巧可以解决这个问题:


#![allow(unused)]
fn main() {
let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) => { content },
    Err(error) => { panic!("Can't deal with {}, just exit here", error); }
};
println!("file content: {}", content);
}

我们可以在 match 块后使用 content 字符串。如果 result 为 error,这个 content 字符串就不存在。但因为程序会在使用 content 之前退出,所以这个方法没有任何问题。

这看起来很生猛,但非常方便。如果你的程序需要读取文件,且如果文件不存在则不能执行任何操作,退出是一种有效的策略。Result 上甚至还有一个快捷方法,称为解包unwrap) :


#![allow(unused)]
fn main() {
let content = std::fs::read_to_string("test.txt").unwrap();
}

不需要 panic

当然,终止程序不是唯一的处理错误的办法。除了 panic!,我们还可以轻松编写 return

fn main() -> Result<(), Box<std::error::Error>> {
let result = std::fs::read_to_string("test.txt");
let _content = match result {
    Ok(content) => { content },
    Err(error) => { return Err(error.into()); }
};
Ok(())
}

(译者注:以上这种不在自身内部进行错误处理,而将其包装为 Result 往函数调用者处传递,让函数调用者进行正确值或错误处理的写法被称为传播错误,当然,如果你学过 Java 这类带有异常的语言,就会觉得有点像 throw Exception ,然而两者还是有本质上的区别的。) 然而这改变了我们函数所需的返回类型。实际上,在我们的示例中一直隐藏着一些事物:代码所在函数的签名。上一个示例中的 return 十分重要。这儿是一个完整的例子:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = std::fs::read_to_string("test.txt");
    let content = match result {
        Ok(content) => { content },
        Err(error) => { return Err(error.into()); }
    };
    println!("file content: {}", content);
    Ok(())
}

我们的返回类型是一个 Result!这就是为什么我们可以在第二个匹配分支写 return Err(error); 的原因。底部为何有 Ok(()) 呢?这是函数的默认返回值,意味着 “结果没问题,且没有内容”。

问号

就像调用 .unwrap()match 的 error 分支 panic! 的简便写法一样。我们有另一个 match 中 error 分支 return 的简写:?。

是的,就是一个问号。你可以将该操作符附加到 Result 类型的值上,Rust 会将其扩展为与我们刚才编写的 match 非常相似的内容。

尝试一下:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("test.txt")?;
    println!("file content: {}", content);
    Ok(())
}

可以说是非常简洁了!

提供 context

当在 main 函数中使用 ? 获取 error 是没问题的,但是这还不够好。例如:当你运行 std::fs::read_to_string("test.txt")? ,但是 test.txt 并不存在,你会得到这个输出:

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

如果代码中没有字面上包含文件名(译者注:即读取文件函数的参数是一个变量而非字面量),那么很难确定是哪个文件 NotFound。这里有多种解决办法。

例如,我们可以创建自己的 error 类型,然后用它来构建自定义错误消息:

#[derive(Debug)]
struct CustomError(String);

fn main() -> Result<(), CustomError> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .map_err(|err| CustomError(format!("Error reading `{}`: {}", path, err)))?;
    println!("file content: {}", content);
    Ok(())
}

现在,运行会得到我们的自定义错误消息:

Error: CustomError("Error reading `test.txt`: No such file or directory (os error 2)")

不是很完美,但是稍后我们可以很容易地根据我们的类型调整 debug 输出。

这种模式实际上很常见。不过它有个问题:我们并不储存原始 error ,仅储存其字符串表示。常用的 anyhow 库有个巧妙的解决方案:与我们的 CustomError 类型类似,它的 Context trait 可以用来添加描述。除此之外,它还保留了 原始 error ,因此我们会得到指向错误根源的 error “链”。

让我们通过在 Cargo.toml 文件的 [dependencies] 块中添加 anyhow = "1.0" 来首次引入 anyhow crate 。

完整的示例看起来像这样:

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("could not read file `{}`", path))?;
    println!("file content: {}", content);
    Ok(())
}

这会打印出错误:

Error: could not read file `test.txt`

Caused by:
    No such file or directory (os error 2)

输出

打印 “Hello World”


#![allow(unused)]
fn main() {
println!("Hello World");
}

当然,这很容易。很好,我们进入下个主题。

使用 println

你几乎可以用 println! 宏打印所有你喜欢的东西。该宏具有出色的功能, 但也有特殊的语法。它期望编写一个字符串字面量作为第一个参数,其中包括占位符,这些占位符由后续的参数值填充。

例如:


#![allow(unused)]
fn main() {
let x = 42;
println!("My lucky number is {}.", x);
}

会打印

My lucky number is 42.

在上面字符串的花括号({})是这些占位符之一,这是默认的占位符类型,它尝试以人类可读的方式打印给定值。对于数字和字符串这很好用,但并非所有类型都可以这样,这也是为什么还有一个“debug 表示符”的原因,你可以通过填充占位符的大括号,就像这样:{:?} 来获得debug 表示符。

例如:


#![allow(unused)]
fn main() {
let xs = vec![1, 2, 3];
println!("The list is: {:?}", xs);
}

会打印

The list is: [1, 2, 3]

如果想要自己的数据类型可打印以调试和日志记录,大多数情况下可以在其定义上方添加 #[derive(Debug)]

打印 errors

应该通过 stderr 完成错误打印,使得用户和其他工具更容易将其输出传输到文件或更多工具。

在 Rust 中这是用 println!eprintln! 实现的,前者打印到 stdout,后者打印到 stderr


#![allow(unused)]
fn main() {
println!("This is information");
eprintln!("This is an error! :(");
}

关于打印的性能的注意事项

打印到终端是非常慢的!如果你在循环中调用 println! 之类的,它会很容易成为其他快速型程序的瓶颈。为了提升速度,你可以做两件事。

首先,你可能需要减少实际 “刷新” 到终端的写入次数。println! 告诉系统每次都刷新终端,因为打印新行是常见的。如果不需要,可以将 stdout 句柄包装进 BufWriter 中, BufWriter 默认情况下可缓存高达 8kB。(当你想立即打印时,仍然可以在 BufWriter 中调用 .flush() 函数)


#![allow(unused)]
fn main() {
use std::io::{self, Write};

let stdout = io::stdout(); // 获取全局 stdout 实体
let mut handle = io::BufWriter::new(stdout); // 可选: 将句柄包装进缓冲区中
writeln!(handle, "foo: {}", 42); // 如果你关心此处的 error,添加 `?` 。
}

其次,获取对stdout (或 stderr)的锁并使用 writeln! 直接打印它是很有用的。这样可以防止系统反复锁定和解锁 stdout


#![allow(unused)]
fn main() {
use std::io::{self, Write};

let stdout = io::stdout(); // 获取全局 stdout 实体
let mut handle = stdout.lock(); // 获取它的锁
writeln!(handle, "foo: {}", 42); // 如果你关心此处的 error,添加 `?` 。
}

你还可以结合这两种实现。

显示进度条

一些命令行应用程序运行时间不到一分钟,其他的则会花几分钟或几小时。如果要编写后一种类型的程序,则可能需要向用户显示正在发生的事。为此,你应该尝试打印有用的状态更新,最好以一种易于使用的形式打印。

使用 indicatif crate,你可以在程序中添加进度条和小框。这儿是个简单的例子:

fn main() {
    let pb = indicatif::ProgressBar::new(100);
    for i in 0..100 {
        do_hard_work();
        pb.println(format!("[+] finished #{}", i));
        pb.inc(1);
    }
    pb.finish_with_message("done");
}

更多信息请参阅其 文档示例

日志

为了更容易理解程序中发生的事情,我们可能需要添加一些日志语句。在编写应用程序时,这通常很容易。但当半年后再次运行这个程序时,它将变得非常有帮助。在某些方面,日志记录与使用println是相同的,除了可以指定消息的重要性。通常可以使用的级别是 error 、 warn 、 info 、 debug 和 trace ( error 的优先级最高, trace 的优先级最低)。

要将简单的日志记录添加到你的应用程序中,您需要做两件事: log crate (其中包含以日志级别命名的宏)和一个适配器(adapter),该适配器实际上将日志输出写入有用的地方。使用日志适配器的能力非常灵活:例如,您可以使用它们将日志不仅写到终端,也写到syslog或中央日志服务器。

由于我们现在只关心编写命令行应用程序,一个易于使用的适配器是 env_logger 。之所以称为 “ env” logger,是因为您可以使用环境变量来指定要记录的应用程序部分(以及要记录的级别)。它将在你的的日志消息前加上时间戳和日志消息来源的模块。由于库也可以使用 log ,因此您也可以轻松配置其日志输出。

这是一个简单的示例:

use log::{info, warn};

fn main() {
    env_logger::init();
    info!("starting up");
    warn!("oops, nothing implemented!");
}

假设有 src/bin/output-log.rs 这个文件,在 Linux 和 macOS 上,你可以像这样运行:

$ env RUST_LOG=output_log=info cargo run --bin output-log
    Finished dev [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/output-log`
[2018-11-30T20:25:52Z INFO  output_log] starting up
[2018-11-30T20:25:52Z WARN  output_log] oops, nothing implemented!

在 Windows PowerShell 上,你可以像这样运行:

$ $env:RUST_LOG="output_log=info"
$ cargo run --bin output-log
    Finished dev [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/output-log.exe`
[2018-11-30T20:25:52Z INFO  output_log] starting up
[2018-11-30T20:25:52Z WARN  output_log] oops, nothing implemented!

在 Windows CMD 上,你可以像这样运行:

$ set RUST_LOG=output_log=info
$ cargo run --bin output-log
    Finished dev [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/output-log.exe`
[2018-11-30T20:25:52Z INFO  output_log] starting up
[2018-11-30T20:25:52Z WARN  output_log] oops, nothing implemented!

RUST_LOG 是可用于设置日志设置的环境变量的名称。 env_logger 还包含一个构建器(builder),因此你可以以编程方式调整这些设置,而且,例如,默认情况下还显示 info 级别的消息。

有很多其他的日志适配器,以及 log 的扩展和替代方法。如果你知道应用程序有很多 log ,请确保对其进行 review ,并简化用户的使用。

测试

在数十年的软件开发中,人们发现了一个真理:未经测试的软件很少能工作。(许多人甚至会说:“其实大多数经过测试的软件也不能正常工作”,但我们都是乐观主义者,不是吗?)因此,要确保你的程序能实现你期望的功能,明智的办法是测试一下。

一种简单的方是编写一个 README 文件描述程序应该做什么。当你准备发布新版本时,浏览 README 文件并确保其行为仍然符合预期。你还可以写下程序对错误输入的反应,以使操作更加严格。

这是另一个奇特的想法:在你写代码之前先编写 README 文件。

自动化测试

现在,一切看起来都还不错,但是我们需要手动进行测试么?这会浪费很多时间。同时,许多人更喜欢让计算机来做这些。让我们来谈谈如何将测试自动化。

Rust 有内置的测试框架,所以让我们从编写第一个测试开始:

#[test]
fn check_answer_validity() {
    assert_eq!(answer(), 42);
}

你可以将这段代码放入任何文件(这里说的是 .rs 文件)中, cargo test 会找到并运行它。 #[test] 属性是关键,它允许构建系统发现此函数并将其作为测试运行,以验证它们不会 panic 。

现在我们已经知道了如何编写测试。我们还应该搞明白怎样去测试。如你所见,为函数编写断言是相当容易的,但是命令行应用程序通常不止一个函数!更糟的是,它经常处理用户输入,读取文件和输出。

让你的代码可测试

有两种测试函数的互补实现:测试构建的完整应用程序中的小型单元,这被称为 “单元测试”。还有一种“从外部”测试最终的应用程序,这被称作“黑盒测试”或“集成测试”。让我们从第一种开始。

要弄清我们应该测试什么,得知道我们的程序功能是什么。grrs 主要的是 应该打印出与给定模式匹配的行。因此,让我们为此编写单元测试:我们应该确保最重要的逻辑部分正常工作,并且我们希望以不依赖周围设置代码(例如,处理命令行参数的代码)的方式进行测试。

回到我们的 grrs首次实现 ,我们在 main函数中增加了这个代码块:

// ...
for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

不幸的是,这很不容易测试。首先,它在 mian 函数中,所以不能轻易地调用它。这个问题可以通过将此段代码移到函数中来轻松修复:


#![allow(unused)]
fn main() {
fn find_matches(content: &str, pattern: &str) {
    for line in content.lines() {
        if line.contains(pattern) {
            println!("{}", line);
        }
    }
}
​```现在我们可以在测试中调用此函数,并查看其输出:

​```rust,ignore
#[test]
fn find_a_match() {
    find_matches("lorem ipsum\ndolor sit amet", "lorem");
    assert_eq!( // uhhhh
}

或者,我们可以?现在, find_matches 直接打印到 stdout,也就是终端。我们无法在测试中轻松捕捉到这点!当实现之后编写测试时,经常会出现一个问题:编写一个函数,该函数与使用它的上下文紧密地整合在一起。

好了,那我们如何使其可测试呢?我们需要以某种方式捕获输出。 Rust 标准库在处理 I/O (输入/输出)时有一些简洁的抽象,我们将使用一种叫做 std::io::Writetrait ,该 trait 对我们可以写入的事物进行抽象,其中不仅包括字符串,也包括 stdout

如果这是你第一次在 Rust 中听到 “trait” 这个术语,那么你一定会对它感到满意的。 trait 是 Rust 最强大的特性之一,你可以将它看作 Java 中的接口(interface),或者是 Haskell 中的 类型类(type class) (不管你对它们有多熟悉)。它们允许抽象出不同类型之间共享的行为。使用 trait 的代码可以以非常通用和灵活的方式进行表达(译者注:也就是 trait 的定义和实现方式)。不过,这也意味着它很难阅读。不要让它吓到你:因为即使是使用了多年 Rust 的人也不能总是立即知道通用(generic)代码的行为(译者注:也就是说,有些 trait 太难读懂,连老手都要理解半天,别怕,因为对于大家来说都是难点,嘿嘿)。在这种情况下,考虑其具体用途是非常有用的。例如,在我们的例子中,我们抽象的行为是 “写入” 。实现(“impl”)它的类型的示例包括:终端标准输出,文件,内存中的缓冲区,或者 TCP 网络连接。(向下滚动 std::io::Write 的文档 查看 “Implementors” 清单。)

有了这些知识,让我们将函数改为接受第三个参数。它应该是任何实现了 Write 的类型。这样的话,我们就可以在测试中提供一个简单的字符串并对其进行断言。这是我们为此编写的 find_matches 版本。

fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
    for line in content.lines() {
        if line.contains(pattern) {
            writeln!(writer, "{}", line);
        }
    }
}

新参数是 mut writer,即叫做 ”writer“ 的可变变量,它的类型是 impl std::io::Write ,你可以将其(这个参数)理解为 “实现了 Write trait 的任何类型的占位符”。还要注意到我们使用 writeln!(writer, …) 替换了先前使用的 println!(…)println!writeln! 的工作原理相同,但 println! 始终被用于标准输出。

现在我们可以测试输出了:

#[test]
fn find_a_match() {
    let mut result = Vec::new();
    find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
    assert_eq!(result, b"lorem ipsum\n");
}

现在要在我们的应用程序代码中使用它,必须在 mainfind_matches 的调用中通过添加 &mut std::io::stdout() 作为第三个参数。以下是一个 main 函数示例,其基于我们在前几章中看到的内容,并使用我们所提取的 find_matches 函数:

fn main() -> Result<()> {
    let args = Cli::from_args();
    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("could not read file `{}`", args.path.display()))?;

    find_matches(&content, &args.pattern, &mut std::io::stdout());

    Ok(())
}

我们刚才说了如何使该段代码易于测试。我们需要

  1. 明确应用程序的一个核心部分,
  2. 将它变成它自己的函数,
  3. 使他更灵活。

即使我们的目标是使其可测试,但我们最终得到的结果实际上是一段很地道且可重用的 Rust 代码。这太棒了!

将你的代码分割进库和二进制目标

我们到这儿可以再做一件事。到目前为止我们已经将我们所写的一切都写进了 src/main.rs 文件。这意味着我们当前的项目产生单个二进制文件。但是我们也可以将我们的代码作为库,就像这样:

  1. find_matches 函数放入 src/lib.rs 这个新文件。
  2. fn 前添加 pub (所以它现在是 pub fn find_matches) ,使它可以供库用户访问。
  3. src/main.rs 中移除 find_matches
  4. fn main,将 grrs:: 放在 find_matches 之前来调用函数,所以它现在是 grrs::find_matches(…)。这意味着将使用的是我们编写的库中的函数!

Rust 处理项目的方式非常灵活,并且考虑提前将哪些放入你的 crate 的库部分是个好主意。例如,你可以考虑先为应用程序的特定逻辑编写库,之后在 CLI 中像使用其他库一样使用它。或者,如果你的项目有多个二进制文件,你可以将公共的功能放进 crate 的库部分。

通过运行众多测试来测试 CLI 应用程序

到现在,我们已经尽力来测试应用程序的业务逻辑了,即 find_matches 函数。这是非常有价值的,并且是迈向经过良好测试的代码库的第一步。(通常,这类测试被称为“单元测试”。)

然而,有很多代码我们没有测试:所有编写来与外界打交道的代码!想象你编写了 main 函数,但是不小心遗留了硬编码字符串,而不是用户提供的路径参数。我们同样应该为此编写测试!(这种级别的测试通常称为 “集成测试”或“系统测试”)

从本质上说,我们仍旧在编写函数并且用 #[test] 进行注解,这只是在这些函数内所做的事。例如,我们想要使用项目的主二进制文件,并像运行常规程序一样运行它。我们还将把这些测试放进新目录的新文件中: tests/cli.rs

回顾一下, grrs 是在文件中搜索字符串的小工具。我们前面已经测试了可以找到匹配项。让我们考虑一下我们可以测试的其他功能。

这儿是我所想出的几个。

  • 如果文件不存在会发生什么?
  • 没有匹配时输出是什么?
  • 当我们忘记一个(或两个)参数时,程序是否以错误消息的形式退出?

这些都是有效的测试用例。我们还应该为 ”快乐 path“(”happy path”)添加测试用例,也即是我们至少找到一个匹配项并进行打印。

为了简化这些测试,我们将使用 assert_cmd crate ,它有一堆简洁的帮手,允许我们运行我们的主二进制文件并且查看其行为。此外,我们还将添加 predicates crate ,该 crate 帮助我们编写 assert_cmd 可以测试的断言(并且有很好的错误消息)。我们添加这些依赖不是在主清单中(译者注:也就是不在 Cargo.toml的 [dependencies] 块中添加),而是在 Cargo.toml 中的 “dev dependencies” 块。它们只在开发 crate 时需要,而使用时则不需要。

[dev-dependencies]
assert_cmd = "0.10"
predicates = "1"

这听起来像有很多设置。不过让我们深入研究并创建 tests/cli.rs 文件:

use assert_cmd::prelude::*; // Add methods on commands
use predicates::prelude::*; // Used for writing assertions
use std::process::Command; // Run programs

#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("grrs")?;

    cmd.arg("foobar").arg("test/file/doesnt/exist");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("No such file or directory"));

    Ok(())
}

你可以用 cargo test 运行上面我们编写的测试。第一次可能会花费较长时间,因为 Command::cargo_bin("grrs") 需要编译主二进制文件。

生成测试文件

我们刚才所看到的测试仅当输入文件不存在时检查程序是否写入错误信息。这是个很重要的测试:现在让我们测试下,我们将实际打印在文件中找到的匹配项。

我们需要有一个我们知道内容的文件,以便我们可以知道程序应该返回的内容,并且在代码中检查此期望内容。一个想法是将带有自定义内容的文件添加到项目中,并在测试中使用该文件。另一个想法是在我们的测试中创建临时文件。在本教程中,我们将使用后一种方法。主要是因为它更灵活,在其他情况下也是能工作;例如,当你测试更改文件的程序时。

要创建这些临时文件,我们将会使用 tempfile crate 。让我们将其添加到 Cargo.toml 文件的 dev-dependencies 块:

tempfile = "3"

这儿是个新的测试用例(你可以在另一个测试用例下面编写),首先创建临时文件(一个“已命名的”文件,以便我们获取它的路径),用一些文本填充它,然后运行程序看看是否得到了正确的输出。当 file 超出作用域(在函数的末尾)时,实际的临时文件将自动被删除。

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

#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
    let mut file = NamedTempFile::new()?;
    writeln!(file, "A test\nActual content\nMore content\nAnother test")?;

    let mut cmd = Command::cargo_bin("grrs")?;
    cmd.arg("test").arg(file.path());
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("test\nAnother test"));

    Ok(())
}

要测试什么?

当然,编写集成测试固然很有趣,但编写它们也会花费一些时间,同时还要在程序的行为发生变化时更新它们。为了确保你明智地利用时间,你应该问自己该测试什么。

通常,为用户可以观察到的所有类型的行为编写集成测试是一个好主意。这意味着你不用覆盖到所有极端情况:通常只要有不同类型的示例并依靠单元测试来覆盖极端情况就足够了。

不要把测试的重点放在你不能主动控制的事情上是个好点子,测试为你生成的 --help 的确切布局是个坏点子。相反,你可能值只要检查是否存在某些元素。

依赖于程序的性质(Depending on the nature of your program),你还可以尝试添加更多的测试技术。例如,如果你提取了程序的某些部分,并发现自己编写了大量的用例作为单元测试,同时试图找出所有的极端用例,那么你应该研究一下 proptest 。如果你有一个使用任意文件并对其进行解析的程序,那么请尝试编写 fuzzer 用来寻找极端情况下的 bug 。

打包和发布 Rust 工具

如果你确信你的程序已经准备好供其他人使用了,那么是时候打包和发布它了!

有几种打包和发布的办法,我们将会介绍其中三种,从“最快捷设置”到“对用户最方便”

最快捷: cargo publish

最简单的发布应用程序的办法是使用 cargo 。还记得如何将外部依赖添加到我们的项目吗? Cargo 从 crate 默认的 “crate 注册局”(”crate registry”)—— crates.io 下载 crate 。使用 cargo publish ,你也可以将 crates 发布到 crates.io 。这适用于所有 crate ,包括具有二进制目标文件的 crate 。

将 crate 发布到 crates.io 是很简单的:如果你还没有账号,就在 crates.io 上创建一个。目前,这是通过 GitHub 账号授权完成的 ,所以你需要先拥有一个 GitHub 账号(并且已经在浏览器上登录了)。接下来,在本地机器上使用 Cargo 登录。为此,转到你的 crates.io 账户页面 ,创建一个新的令牌(token),然后运行 cargo login <your-new-token> 。每台计算机上只需要做一次。你可以在 Cargo 的发布指南中了解更多关于此的信息。

现在你已经知道了 Cargo 和 crates.io ,那么说明你已经准备好发布 crate 了。在你发布一个新 crate (或版本)之前,最好再次打开 Cargo.toml 并确保添加了必要的元数据。你可以在Cargo 清单格式文档中找到所有你可以设置的可能的字段。以下是一些常见条目的速览:

[package]
name = "grrs"
version = "0.1.0"
authors = ["Your Name <your@email.com>"]
license = "MIT OR Apache-2.0"
description = "A tool to search files"
readme = "README.md"
homepage = "https://github.com/you/grrs"
repository = "https://github.com/you/grrs"
keywords = ["cli", "search", "demo"]
categories = ["command-line-utilities"]

怎样从 crates.io 安装二进制文件

我们已经看到了如何在 crates.io 上发布 crate ,你可能想要知道如何安装它。与库不同,当运行 cargo build (或类似命令)时 cargo 会为你下载并编译,你需要告诉 cargo 明确安装二进制文件。

这通过 cargo install <crate-name> 来完成。默认情况下,它将下载 crate ,编译所有其包含的二进制目标文件(在 “release” 模式下,可能需要多花点时间)并将它们复制进 ~/.cargo/bin/ 目录(请确保你的 shell 能知道在此找到二进制文件!)。(译者注:也就是说你的 ~/.cargo/bin/ 目录得在环境变量中)

也可以从 git 库中安装 crate ,仅安装指定 crate 的二进制文件,并且指定一个其他的目录来安装它们。更多细节请参阅 cargo install --help 命令。

什么时候使用

cargo install 是安装 二进制 crate 的一个简单办法。开发人员使用起来非常方便,但是有一些很明显的缺点:因为它将会从头开始编译源代码,所以你开发的工具的用户需要在他们的机器上安装 Rust、cargo 和 所有其他的你的程序所要求的系统依赖项,同时编译大型的 Rust 代码库也可能会花上一些时间。(译者吐槽:可能俩字去掉,编译时间不是一些好嘛,经历过痛苦的人都懂)

此外,也没有简便的方法来使用 cargo 更新工具:用户需要在某个时候再次运行 cargo install ,并且传递 --force 标签覆盖旧版的二进制文件。这是一项缺失的特性,然而,你可以用这样的子命令来安装并添加它。(译者注:随着 Rust 与 Cargo 版本的推进与问题修复,上面的问题已不再是问题了)

最好将此用于针对其他 Rust 开发者 的发行工具。例如:许多 cargo 子命令例如 cargo-treecargo-outdated 都可以用它来安装。

分发二进制文件

Rust 是一种可编译为本地代码(native code ,实际上就是人类不可读的本地二进制可执行代码) 并且默认会静态链接所有依赖项的语言。当你在包含一个名叫 grrs 的二进制(crate)的程序上运行 cargo build ,你最终会得到一个名叫 grrs 的文件。尝试一下:使用 cargo build,将会在 target/debug/grrs ,并且当你运行 cargo build --release,将会在 target/release/grrs 。除非你使用了明确需要在目标系统上安装外部库的 crate (例如,系统版本的 OpenSSL),否则此二进制文件将只依赖于通用系统库。这意味着你(编译或下载)得到这么一个文件,将它发给与你使用相同操作系统的人,他们就可以运行这个文件。

这已经相当强大了!它解决了我们刚才的 cargo install 的两个缺点:它不需要在用户的机器上安装 Rust ,而且不用花时间编译就能立即运行二进制文件。

所以,正如我们所见,cargo build已经为我们构建了二进制文件。唯一的问题是,这不能保证在所有平台上都能正常工作。如果你的 Windows 机器上运行了 cargo build ,你将不会得到一个在 Mac 上默认可工作的二进制文件。那么有没有一种办法可以为所有感兴趣的平台(也就是你想要生成的平台)自动生成这些二进制文件呢?(译者注:如果你看过官方的或者我所翻译的 rustc 手册,你应该一眼就知道 rustc 或 cargo 对此的解决办法)

在 CI 上构建二进制发行版

如果你的工具是开源的并且托管在 GitHub 上,那么设置免费的 CI(持续集成)服务例如 Travis CI 是非常容易的。(还有运行在其他托管平台上的服务,但是 Travis 十分流行。)每次将代码更改 push 到库的时候就会在虚拟机中运行命令,这些命令和运行的机器类型(译者注:即选择的虚拟机运行环境)是可配置的。例如:一个好想法是在安装了 Rust 和一些通用构建工具的机器上运行 cargo test 。如果失败了,你就可以知道在最近的代码改进中存在问题了。(译者注:本书原项目就是使用 Travis 进行持续集成的)

我们也可以以此构建二进制文件并且上传到 GitHub !缺失,如果我们运行 cargo build --release 并上传二进制文件到某处,就说明我们已经准备完了,对吗?并不完全是的,我们依然需要确保我们构建的二进制文件与尽可能多的系统兼容。例如,在 Linux 上,我们可以不以当前系统为目标进行编译,而是将 x86_64-unknown-linux-musl 作为编译目标,以不依赖默认系统库。在 macOS 上,我们可以设置 MACOSX_DEPLOYMENT_TARGET10.7 以仅依赖于 10.7 和更早版本的系统功能。

你可以在这里看到一个使用该方法构建 Linux 和 macOS 二进制文件和这里的构建 Windows (使用 AppVeyor) 二进制文件的示例。

另一种方法是使用包含所有需要用来构建二进制文件的工具的预构建(Docker)镜像。这也使得我们更简单地将更多平台作为目标。 trust 项目包含了可以包含进项目的脚本,以及关于如何设置的说明。它还包括对使用 AppVeyor 的 Windows 的支持。

如果你更愿意在本地进行设置并且在自个儿的机器上生成发行文件( release files ),你依然有必要看一下 trust 项目。它在内部使用 cross ,其工作方式与 Cargo 类似,但是将命令转发给 Docker 容器中的 cargo 进程。镜像的定义也可以在 cross 的库中找到。 cross’ repository.

如何安装这些二进制文件

你可以将用户指向发行页面,页面可能看起来像这样,(译者注:示例页面拉到最下面)并且她们可以下载我们刚刚创建的组件( artifacts )。我们刚生成的发行组件没什么特殊的:最终它们只是包含了我们二进制文件的归档文件!这意味着你的工具的使用者可以使用其浏览器下载它们,解压缩它们(通常会自动发生),并将二进制文件复制到它们喜欢的位置。

这确实需要一些手动 “安装” 程序的经验,因此你需要在 README 文件中添加有关如何安装此程序的部分。(译者注:说是安装,其实更应该形容为下载)

什么时候使用

拥有二进制发行版本通常是一个好主意,这几乎没有任何缺点。这不能解决用户必须手动安装和更新你的工具的问题,但是他们可以快速获取最新版本而无需安装 Rust 。

除了二进制文件之外还要打包什么

现在,当用户下载了我们的发行版本之后,他们可能会获得一个仅包含二进制文件的 .tar.gz (之类的)文件。所以,在我们的示例项目中,他们只会得到一个可运行的 grrs 文件。但是我们的库中已经有一些他们可能想要的文件。例如,告诉用户如何使用这个工具的 README 文件还有 license 文件。因为在之前已经有了它们,所以很容易添加。

还有一些有趣的文件,特别适用于命令行工具:除了 README 文件之外,还提供一个手册页面,以及向 shell 添加可能的标签补全的配置文件,如何?你可以手动编写这些文件,但是我们使用的参数解析库( clap )可以为我们生成所有的这些文件。有关更多详细信息,请参阅深入讨论中的 这个章节

将应用程序放入包存储库

到目前我们所看到的两种方法都不是通常在机器上安装软件的方法。特别是大多数操作系统上使用全局包管理器安装的命令行工具。这样做的好处对用户来说是显而易见的:如果可以像安装其他的工具一样安装程序,就不需要考虑如何安装程序。这些包管理器还允许用户在新版本可用时更新程序。

遗憾的是,支持不同的系统意味着你必须了解这些不同的系统是如何工作的。对于一些来说,可能就只需要向库中添加一个文件(例如,为 macOS 用户的 brew 添加一个像这样的 formula 文件),但是对于其他的,你可能会经常需要自己发送补丁,并将你的工具添加到它们的存储库中。有一些很有用的工具,例如 cargo-rpmcargo-debcargo-aur,但是描述它们如何工作以及如何正确为这些不同的系统打包工具不在本章的范围内。

相反,让我们看一下用 Rust 编写的工具,可以在许多不同的包管理器中使用。

一个示例: ripgrep

ripgrep 是用 Rust 编写的 grep/ack/ag 的替代品。它相当地成功并且被打包用于许多操作系统:只需要查看它的 README 文件中的 安装部分

注意它列出了一些如何安装的不同选项:它以指向包含二进制文件的 GitHub 发行版本的链接开头,所以你可以直接下载;然后它还列出了如何使用一堆不同的软件包管理器安装它;最后,你还可以使用 cargo install 安装它。

这儿似乎有个好点子:不选择本章节介绍的任何一个方法,而是从 cargo install 开始,添加二进制版本,最后才使用系统包管理器发布你的工具。

一些深入讨论的话题

接下来是一些小章节的集合,其涵盖了一些您在编写命令行应用时可能会关心的很多细节。

信号处理

诸如命令行应用这样的进程需要对操作系统发送的信号作出反应。最常见的就比如 Ctrl+C,该信号通常告诉进程终止。要在 Rust 程序中处理信号,你需要考虑如何接收这些信号以及如何做出反应。

操作系统间差异

在 Unix 类系统( 例如 Linux, macOS, 和 FreeBSD )上,进程可以接收 信号。它可以以默认方式(操作系统所提供)对它们做出反应,捕获信号并以程序所定义的方式对它们进行处理,或者完全忽略信号。

Windows 没有信号,你可以用 Console Handlers 来定义在事件发生时执行的回调。还有 结构化异常处理,可以处理各种类型的系统异常,例如被 0 除,无效访问异常,栈溢出,等等之类的。

首先:处理 Ctrl+C

ctrlc crate 的用途恰如其名:其允许你以跨平台的方式对用户按的 Ctrl+C 做出反应,使用该 crate 的主要方法是:

use std::{thread, time::Duration};

fn main() {
    ctrlc::set_handler(move || {
        println!("received Ctrl+C!");
    })
    .expect("Error setting Ctrl-C handler");

    // Following code does the actual work, and can be interrupted by pressing
    // Ctrl-C. As an example: Let's wait a few seconds.
    thread::sleep(Duration::from_secs(2));
}

当然,这并无帮助:只打印消息,但是不会停止程序(译者注:除非出现错误)。

在实际的程序中,最好是在这个执行信号处理的程序中设置一个变量,然后在程序的各个位置进行检查。例如,可以在信号处理中设置一个 Arc<AtomicBool> ,然后在热循环(hot loops)中,或者当等待一个线程时,你定时地检查它,并在当它变为 true 时中断(break)。

处理其它类型的信号

ctrlc crate 仅处理 Ctrl+C ,或者,在 Unix 系统中被称为 SIGINT (“中断” 信号)。为了对更多的 Unix 信号做出反应,你应该查看 signal-hook这篇博文描述了其设计,它时目前社区所支持的最广泛的库。

这有个简单的例子:

use signal_hook::{iterator::Signals, SIGINT};
use std::{error::Error, thread, time::Duration};

fn main() -> Result<(), Box<dyn Error>> {
    let signals = Signals::new(&[SIGINT])?;

    thread::spawn(move || {
        for sig in signals.forever() {
            println!("Received signal {:?}", sig);
        }
    });

    // Following code does the actual work, and can be interrupted by pressing
    // Ctrl-C. As an example: Let's wait a few seconds.
    thread::sleep(Duration::from_secs(2));

    Ok(())
}

使用 channel

不设置变量并使用程序的其他部分检查它,你可以使用 通道(channel) :创建一个通道,每当接收到信号时,信号处理程序就向该通道发出一个值。在你的应用程序代码中,可以使用这个通道和其他通道作为线程之间的同步点,使用 crossbeam-channel 看起来像这样:

use std::time::Duration;
use crossbeam_channel::{bounded, tick, Receiver, select};
use anyhow::Result;

fn ctrl_channel() -> Result<Receiver<()>, ctrlc::Error> {
    let (sender, receiver) = bounded(100);
    ctrlc::set_handler(move || {
        let _ = sender.send(());
    })?;

    Ok(receiver)
}

fn main() -> Result<()> {
    let ctrl_c_events = ctrl_channel()?;
    let ticks = tick(Duration::from_secs(1));

    loop {
        select! {
            recv(ticks) -> _ => {
                println!("working!");
            }
            recv(ctrl_c_events) -> _ => {
                println!();
                println!("Goodbye!");
                break;
            }
        }
    }

    Ok(())
}

使用 future 和 stream

如果你使用 tokio,说明你很可能已经在你的应用程序中使用了异步模式和事件驱动设计。相比直接使用 crossbeam 的通道,你可以使用 signal-hook 的 tokio-support feature,其允许你在 signal-hook 的类型上调用 .into_async() 来获得实现了 futures::Stream 的新类型。

当你正在处理第一个 Ctrl+C 的时候接收到了其他的 Ctrl+C 时怎么办

大多数用户会按 Ctrl+C,然后给你的程序几秒钟时间退出,或者告诉他们发生了什么。如果这并未发生,他们就会再次按下 Ctrl+C 。当然,最典型的行为是使应用程序立即退出。

使用配置文件

处理配置可能会很烦人,特别是当支持多个操作系统时,他们可能都有自己的存放短期和长期文件的地方。

对此有多种解决方案,有些解决方案比其他的更底层。

最简单的是使用 confy crate。它会要求你提供应用程序的名字,并要求你通过一个 struct(也就是 Serialize, Deserialize) 指定配置布局,并且会找出剩余的部分!

#[derive(Debug, Serialize, Deserialize)]
struct MyConfig {
    name: String,
    comfy: bool,
    foo: i64,
}

fn main() -> Result<(), io::Error> {
    let cfg: MyConfig = confy::load("my_app")?;
    println!("{:#?}", cfg);
    Ok(())
}

这非常简单易用,当然也放弃了可配置性,但是如果你只需要简单的 config ,这个 crate 或许适合你。

配置环境变量

退出码

一个程序并不能总是成功的(也就是毫无 bug),当错误发生时,你应该确保正确地发出必要的信息。除了告诉用户有关错误之外,在大多数系统上,当进程退出时,它还会发出一个退出码(大多数平台所兼容的是一个 0 到 255 的整数)。你应该尝试为程序的状态发出正确的代码。例如,在理想情况下,当程序成功时,它应该以 0 退出。

但是,当错误发生的时候,情况也会变得更复杂一点。在实际情况中,当一个常见的故障发生时,许多工具以 1 退出。目前, Rust 设置了一个当进程 panic 时的退出码 101。除此之外,人们还在自己的程序中做了许多(关于退出码的)事。

那么,该怎么办呢?BSD 系操作系统为它们的退出码设置了一个通用的定义集合 (你可以在这里找到它们)。 Rust 的 exitcode 库提供了这些相同的代码,可以被用在你的应用程序中。可能会使用到的值请参阅其 ADP 文档。

在将 exitcode 依赖添加到 Cargo.toml 之后,你可以像这样使用它:

fn main() {
    // ...actual work...
    match result {
        Ok(_) => {
            println!("Done!");
            std::process::exit(exitcode::OK);
        }
        Err(CustomError::CantReadConfig(e)) => {
            eprintln!("Error: {}", e);
            std::process::exit(exitcode::CONFIG);
        }
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(exitcode::DATAERR);
        }
    }
}

与人交互

请确保你已经先阅读了 CLI 输出的章节 。它涵盖了如何将输出写入到终端,而本章将讨论关于输出什么。

当一切正常

即使一切正常,报告应用程序的进展也是很有用的。尽量提供丰富·简洁的消息,不要在日志中使用过多的术语。记住:应用程序没有崩溃,所以用户没有理由去(日志之类的输出)查找错误。

最重要的是,保持风格一致的沟通。使用相同的前缀和句子结构会使得日志易于浏览。

尝试让你的应用程序的输出告诉你它所做的事以及对用户的影响。这可能会包括显示所涉及步骤的时间线,甚至是进度条和长期运行操作的指示器。用户理应无法感觉到程序正在做一些他们无法理解的隐秘的事情。(译者注:也就是对用户隐藏无关的部分。)

当很难说清发生了什么

在传递不可名状的状态时,保持一致是很重要的。不遵循严格日志级别的记录日志的应用与不记录日志的应用相比,能提供的信息量相同甚至更少。

因此,定义与之相关的事件和消息的严重级别非常重要;然后为它们使用一致的日志级别。以此方式,用户可以通过 --verbose 标签或环境变量(例如 RUST_LOG)选择他们日志的数量。

常用的 log crate 定义 了以下级别(按照严重级别递增排序):

  • trace
  • debug
  • info
  • warning
  • error

一个很好的做法是将 info 视为默认的日志级别。可以将它用于信息输出。(一些输出风格更倾向于安静的应用程序可能默认情况下只显示警告和错误。)

此外,通常一个很好的做法是在日志消息中使用相似的前缀和句子结构,可以轻松地使用如 grep 这类工具来过滤它们。消息本身应提供足够的上下文,以便在过滤后的日志中使用,同时又不太冗长。

日志语句示例

error: could not find `Cargo.toml` in `/home/you/project/`
=> Downloading repository index
=> Downloading packages...

以下日志输出来自于 wasm-pack:

 [1/7] Adding WASM target...
 [2/7] Compiling to WASM...
 [3/7] Creating a pkg directory...
 [4/7] Writing a package.json...
 > [WARN]: Field `description` is missing from Cargo.toml. It is not necessary, but recommended
 > [WARN]: Field `repository` is missing from Cargo.toml. It is not necessary, but recommended
 > [WARN]: Field `license` is missing from Cargo.toml. It is not necessary, but recommended
 [5/7] Copying over your README...
 > [WARN]: origin crate has no README
 [6/7] Installing WASM-bindgen...
 > [INFO]: wasm-bindgen already installed
 [7/7] Running WASM-bindgen...
 Done in 1 second

panic 的时候

时常被遗忘的一个方面是程序在崩溃时也会输出某些内容。在 Rust 中,“崩溃” 通常也是 “panic” (也即是“受控制的崩溃”,而不是 “操作系统 kill 掉了进程”)。默认情况下,panic 发生的时候,”panic 处理程序”会将一些信息打印到控制台。

例如,如果你用 cargo new --bin foo 创建了一个新的二进制项目,并且将fn main 里面的内容替换成了 panic!("Hello World"),当你运行程序的时候你会得到以下输出:

thread 'main' panicked at 'Hello, world!', src/main.rs:2:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.

这对你——开发者来说,是很有用的信息。(Surprise: 程序因你的 main.rs 文件的第二行而崩溃了)。但是对于那些甚至没有访问源代码的用户来说,这并非很有价值。实际上,它很可能只能令人困惑。这就是为什么添加一个自定义的 panic 处理程序是一个很好的想法,它提供了更多的以终端用户为中心的输出。

有个做到了这点(译者注:自定义的 panic 处理程序)的库叫做 human-panic 。要将其添加到 CLI 项目中,你可以导入它并在 main 函数的开头调用 setup_panic!() 宏:

use human_panic::setup_panic;

fn main() {
   setup_panic!();

   panic!("Hello world")
}

这将显示一条非常友好的消息,并告诉用户他们能做什么:

Well, this is embarrassing.

foo had a problem and crashed. To help us diagnose the problem you can send us a crash report.

We have generated a report file at "/var/folders/n3/dkk459k908lcmkzwcmq0tcv00000gn/T/report-738e1bec-5585-47a4-8158-f1f7227f0168.toml". Submit an issue or email with the subject of "foo Crash Report" and include the report as an attachment.

- Authors: Your Name <your.name@example.com>

We take privacy seriously, and do not perform any automated error collection. In order to improve the software, we rely on people to submit reports.

Thank you kindly!

与机器交互

当你能够组合命令行工具的时候,他们的威力就会真正显现出来。这不是一个新的想法:实际上,这是 Unix 哲学中的一句话:

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

如果我们的程序能够满足这个期望,我们的用户也会理所当然地很高兴。为了确保这能很好地工作,我们不仅应该为人提供漂亮的输出,还应该提供适合其他程序需求的版本。让我们看看如何做到这点。

谁来读取输出?

第一个要问的问题是:我们的输出是针对彩色终端前的人还是另一个程序?为了回答这个问题,我们可以使用像 atty 这样的 crate:

use atty::Stream;

if atty::is(Stream::Stdout) {
    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)。如果你将其 pipe 到一个文件,或者像 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 (制表符分隔值,即Tab)这样的格式非常流行,其每个记录都在自己的行中,每行包含了用制表符分隔的内容。这些基于文本行的简单格式允许将像 grep 这类的工具用于 ls 这类工具的输出。| grep Cargo 不关心你的行是来自于 ls 还是文件,它只会逐行过滤。

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

面向机器的 JSON 输出

制表符分隔值是一种简单的输出结构化数据的方式,但是这要求其他程序知道期望哪些字段(以及以哪种顺序),并且很难输出不同类型的消息。例如,假设我们的程序想要向使用者发送消息说正在等待下载,然后输出一条消息描述其(通过下载)获得的数据。这些消息是非常不同类型的消息,如果试图将它们统一进 TSV 输出,就需要我们发明一种区分它们的方法。同样当我们想要打印包含两个不同长度的列表项的消息时,也是这样的。

不过,最好选择一种在大多数编程语言、环境中都可以轻松解析的格式。因此,在过去的几年里,许多应用程序都拥有了以 JSON 输出数据的能力。它足够简单,几乎每种语言都存在其解析器(译者注:即 JSON 解析器);但它又足够强大,,在很多情况下都有用。当然它也是一种人类可读的文本格式,许多人也致力于快速解析 JSON 数据和将数据序列化为 JSON 的实现。

在上面的描述中,我们已经讨论过了由我们程序所写出的 “消息”。这儿有一个考虑输出的好方法:你的程序不一定只输出一个数据块,实际上在运行的时候可能会发出许多不同的信息。支撑此想法的一个简单方法是在输出 JSON 时为每条消息编写一个 JSON 文档 (JSON document),并且将每个 JSON 文档放到新行中(有时称之为 行分割 JSON)。这使得实现像使用常规的 println! 一样简单。

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

use structopt::StructOpt;
use serde_json::json;

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

fn main() {
    let args = Cli::from_args();
    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 可以说是 grepag 的替代,并且是用 Rust 写的。默认情况下,它将会产生如下输出:

$ 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 文档都是一个包含 类型 字段的对象(map,映射)。这将使我们能编写一个简单的 rg 的前端,以便在这些文档到来时读取它们,并显示匹配(以及它们所在的文件),即使 ripgrep 仍在搜索。

人机输出抽象

convey 是一个开发中的库,其试图让适合人类和机器的格式输出消息变得更容易。你定义自己的消息类型,并实现一个 Render trait (手动,借助于宏,或使用 derive 属性)来说明它们应该怎样被格式化。当前,它支持打印人工输出(包括自动检测是否应着色),写入 JSON 文档(到 stdout 或文件中),或同时支持二者。

即使你不适应此库,你或许也应该编写一个适合你的用例的类似抽象。

如何处理我们输入的 pipe 输入

为命令行应用呈现文档

CLI 应用的文档通常由命令中 --help 部分和手册(man)页面组成。

当使用 clapv3(撰写本文时,在未发布的 beta 版本中)时通过 man 后端 两者都可以自动生成。

#[derive(Clap)]
pub struct Head {
    /// file to load
    #[clap(parse(from_os_str))]
    pub file: PathBuf,
    /// how many lines to print
    #[clap(short = "n", default_value = "5")]
    pub count: usize,
}

其次,你需要使用一个 build.rs 在编译时根据应用程序中代码的定义去生成手册文件。

有一些事需要牢记(例如,如何打包二进制文件),但是现在,我们只是将 man 文件简单地放在了 src 文件夹旁。

use clap::IntoApp;
use clap_generate::gen_manuals;

#[path="src/cli.rs"]
mod cli;

fn main() {
    let app = cli::Head::into_app();
    for man in gen_manuals(&app) {
        let name = "head.1";
        let mut out = fs::File::create(name).unwrap();
        use std::io::Write;
        out.write_all(man.render().as_bytes()).unwrap();
    }
}

当你现在编译应用程序时,你的项目目录中会有一个 head.1 文件。

如果你在 man 中打开它,你就会欣赏到你的免费文档了。

一些有用的 crate

总是会有一些新的 crate 发布,有些 crate 在命令行应用的开发中会很有用。

本书中所引用过的 crate

  • anyhow - 提供 anyhow::Error 以进行简单的错误处理
  • asset_cmd - 简化 CLI 的集成测试
  • atty - 检测应用程序是否运行在 tty 上。
  • clap-verbosity-flag - 添加 --verbose 标签到 structopt CLI
  • clap - 命令行参数解析器
  • confy - 无样板的配置管理
  • convey - 简化人机输出
  • crossbeam-channel - 为消息传递提供多生产者——多消费者 channel
  • ctrlc - 简易 ctrl-c 处理程序
  • env_logger - 通过环境变量实现日志配置
  • exitcode - 系统退出码常量
  • human-panic - panic 消息处理程序
  • indicatif - 进度条和微框
  • log - 在实现之上提供日志抽象
  • predicates - 实现布尔值谓词函数(boolean-valued predicate functions)
  • proptest - 属性测试框架
  • serde_json - 序列化、反序列化为 JSON
  • signal-hook - 处理 UNIX 信号
  • structopt - 解析命令行参数为一个结构体
  • tokio - 异步运行时
  • wasm-pack - 用于构建 WebAssembly 的工具

其他 crate

由于众多的 Rust ceate 在持续不断地变化,一个查找 crate 的好地方是 lib.rs 的 crate 索引。以下是一些可能会对构建 CLI 有用的( lib.rs 中索引的)特定类别: