Rust 中的命令行应用

Rust 是一种静态编译的、运行速度快的语言,它拥有强大的工具,且其生态也在快速发展。 这使得它非常适合编写命令行应用:小巧、便携且运行速度快。 而命令行应用也是开始进行 Rust 学习,或向你的团队介绍 Rust 的一种很好的方式。

编写一个简单的命令行界面程序(CLI)对于刚接触 Rust 且希望对其有所了解的初学者来说, 是一个很好的练习。不过,这里面也包含了许多方面,后面我们会介绍到它们。

本书的大纲如下: 首先我们从一个快速教程开始,完成后你将得到一个可用的 CLI 工具。通过此教程, 你将接触到 RUST 的一些核心概念及 CLI 应用的主要包括了哪些部分功能。 而下面的章节则是介绍这些方面的实现细节。

最后,也是非常重要的一件事:如果在本书发现了错误,或是想贡献、丰富本书的内容, 项目源码在这里 in the CLI WG repository ,期待你的反馈!

15 分钟编写一个命令行应用来学习 Rust

本教程将引导你使用 Rust 编写 CLI(命令行界面)应用。 只需 15 分钟即可让你得到一个可正常运行的程序(大概 1.3 章之前)。 之后我们将继续调整程序,直到它可以被当作一个工具来发布。

你将学到如何开始所需的所有基本知识,及如何去寻找更多有用信息。 当然,你可随意跳过当前你不需要了解的章节,或之后再翻回查看。

你想要写一个什么样的项目呢?不如我们先从一个简单的开始吧:让我们写一个简单的 grep。我们给这个工具一个字符串和一个文件路径,它将打印出每个包含所查字符串的行。 不如就叫它 grrs 吧。

最后,我们想让它像这样工作:

$ 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(如果还没有,这只需大概几分钟)。 然后,打开终端,切换到你的工作目录,程序源码将放置在这里。

在你的工作目录里,运行 cargo new grrs 来生成软件项目。 进入 grrs 目录,你会看到典型的 Rust 项目目录结构:

  • Cargo.toml 里包含了我们项目所有的元数据,包括我们使用依赖/外部库列表。
  • src/main.rs 是我们程序的二进制入口文件(主程序)。

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() 方法,提供了运行时给定参数的迭代器。 第一项(索引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:这里有两个参数,首先是 pattern(查看的字符串), 而后是 path(查找的文件路径)。

那么,关于这些参数,首先,这两个参数都是程序所必须的,因为我们并未提供默认值, 所以用户需要在使用此程序时提供这两个参数。此外,关于参数的类型:pattern 应该是一个字符串;第二个参数则应是文件的路径。

在 Rust 中,根据所处理的数据去构建程序是很常见的, 因此这种看待参数的方法对我们接下来的工作很有帮助,让我们以此开始 (在 src/main.rsfn main() 之前):

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

这定义了一个新的,存储了 patternpath 两项数据的结构体(struct)。

我们并没有获得程序运行所需的参数,而这正是现在需要去做的。 一种做法是,我们可以手动解析从系统获取的参数列表并以此生成一个结构体,像这样:

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 解析 CLI 参数

使用现成的库来实现参数的解析,这是更明智的选择。 clap 是当前最受欢迎的解析命令行参数的库。它提供了所有你需要的功能, 如子命令、自动补全和完善的帮助信息。

structopt 库基于 clap,并提供了一个“派生”宏来为结构体生成 clap 代码。 这可太棒了:我们只需要去声明一个结构体,它就会生成将参数解析为结构体字段的代码!

首先,我们需要在 Cargo.toml 文件的 [dependencies] 字段里添加上 structopt = "0.3.13" 来导入 structopt

现在,在我们的代码中,导入,use structopt::StructOpt,在之前创建的 struct Cli 的正上方添加上 #[derive(StructOpt)],并顺便写一些文档注释。

它现在看起来是这样的:

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 信息, 并会依错误给出一些建议——输入的参数应该是 --output 而你输入的是 --putput

Wrapping up

你的代码现在看起来应该是这样的:

#![allow(unused)]

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,
}

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

无参数运行时:

$ 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);
    }
}

Wrapping up

你的代码现在看起来应该如此:

#![allow(unused)]

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,
}

fn 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 时很难不注意和而对这个现实:Rust 没有异常, 所有可能的错误状态通常都编码在函数的返回类型中。

结果

read_to_string 这样的函数不会返回字符串。它返回的是一个 Result, 里面可能是一个 String 或是其它错误类型(这里是 std::io::Error)。

那么如何得知是哪种类型呢?因为 Result 亦是 enum 类型, 可以使用 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); }
}
}

展开

现在,我们可以访问文件的内容,但在 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 是一个错误, 字符串就不存在。但好在,程序会在我们使用 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(())
}

然而,这改变了我们函数的返回值类型。实际上,一直以来我们的示例都隐藏了一些东西: 函数的签名(或者说返回值类型)。在最后的含有 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!这也就是为什么我们可以在 match 的第二个分支里写 return Err(error)。看到最下面的 Ok(()) 了么?它是函数的默认返回值, 意为“结果没问题,没有内容”。

? 标识

正如调用 .unwrap() 相当于 match 中快捷设置错误分支中 panic!, 我们还有另一个快捷的调用 ? 使得在 match 的错误分支中直接返回。

是的,就是这个问号。你可以在 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(())
}

很简洁是吧!

Providing Context

main 函数中使用 ? 来获取错误,可以正常工作,但它有一些不足。 比如:若使用 std::fs::read_to_string("test.txt")? 时,test.txt 文件不存在, 你将获得以下错误信息:

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

在这里你的代码里没有包含文件名,这会使得确认是哪个文件 NotFound 变得很麻烦。 但我们有许多种办法可以改进它。

比如,我们可以创建一个自己的错误类型,然后使用它去生成自定义的错误信息:

#[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)")

尽管不是很完美,但我们稍后可以很轻松地为我们的类型调试输出。

这种模式很觉,但它有一个问题:我们并没有保存下原始错误,而只存储了它呈现的字符串。 一个名为 anyhow 的常用库可巧妙的解决这个问题:类似于 CustomError 类型, 它的 Context 特征可以用来添加描述信息。此外,它还能存储下原始错误, 所以我们得到了一个指出根本原因的错误信息链。

Cargo.toml 中的 [dependencies] 字段添加上 anyhow = "1.0"

一个完整的示例如下:

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 representation”, 你可以使用这个占位符来调用它 {:?}

比如,


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

会打印

The list is: [1, 2, 3]

如果你想在调试和日志中打印自己构建的类型,大部分情况下你可以在类型定义上添加 #[derive(Debug)] 属性。

打印错误

打印错误应通过 stderr 完成, 以便用户和其它工具更方便的地将输出通过管道传输到文件或更多的工具中。

在 Rust 中,使用 println!eprintln!,前者对应 stdout 而后者 stderr


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

关于打印性能的说明

打印到终端时出奇的慢!如果你在循环中调用 println! 之类的东西, 它很容易成为其它运行速度快的程序的瓶颈。你可以做两件事来为它提提速。

首先,你需要尽量减少实际“刷新”到终端的写入次数。_每次_调用 println!时, 它都会告诉系统刷新到终端,因为打印每个新行是很常见的。如果你不需要如此, 你可以使用 BufWriter 来包装一下 stdout 的句柄,它的默认缓存为 8 kB。 (当你想立即打印时,在 BufWriter 上调用 .flush() 即可。


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

let stdout = io::stdout(); // get the global stdout entity
let mut handle = io::BufWriter::new(stdout); // optional: wrap that handle in a buffer
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here
}

其次,为 stdout(或 stderr)申请一把锁并使用 writeln! 来直接打印很有用。 它会阻止系统不停地锁死和解锁 stdout


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

let stdout = io::stdout(); // get the global stdout entity
let mut handle = stdout.lock(); // acquire a lock on it
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here
}

你也可以结合使用这两种方式。

显示进度条

一些 CLI 程序的运行时间很长,会花费几分钟甚至数小时。 如果你在编写这种程序,你可能希望向用户展示,其正在正常工作中。 因此,你需要打印出有用的状态更新信息,最好是使用易于使用的方式打印。

你可以使用 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");
}

细节可查看 indicatif 文档示例

日志

为了更方便了解到我们的程序做了什么,我们需要给它添加上一些日志语句,这很简单。 但在长时间后,例半年后再运行这个程序时,日志就变得非常有用了。在某些方面来说, 日志的使用方法同 println 类似,只是它可以指定消息的重要性(级别)。 通常可以使用的级别包括 error, warn, info, debug, and traceerror 优先级最高,trace 最低)。

只需这两样东西,你就可以给你的程序添加简单的日志功能: Log 箱(其中包含以日志级别命名的宏)和一个 adapter, 它会将日志写到有用的地方。日志适配器的使用是十分灵活的: 例如,你可以不仅将日志写入终端,同时也可写入 syslog 或其它日志服务器。

因为我们现在最关心的是写一个 CLI 程序,所以选一个易于使用的适配器 env_logger。 它之所以叫 “env” 日志记录器,因为你可以使用环境变量来指定, 程序中哪部分日志需要记录及记录哪种级别日志。 它会在你的日志信息前加上时间戳及所在模块信息。 由于库也可以使用 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=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="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=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 是设置 log 的环境变量名。 env_logger 还包含一个构建器,因此你可以以编程的方式调整这些设置, 例如,还默认显示 info 级别的日志。

还有很多可选的日志适配器,以及日志库或其扩展。 如果你确定你的应用将生成很多日志,请务必查看它们,以便解决发现的问题。

测试

在数十年的软件开发中,人们发现了一个真理:未经测试的软件很难正常工作。 (许多人也会说,经过测试的软件也可能不工作,但我们都是乐观主义者,不是么) 所以,为了确保你的程序可如你预期的那样工作,最好先对其进行测试。

一种简单的方式是在 README 文件中写明你的软件将如何工作。 当你准备好进行一次新的发布时,再过一遍 README 的功能并确定其仍能正常工作。 你还可以写入你的程序如何应对错误的输入,使得这个测试变得更加严格。

另一个绝妙的主意是:在写代码前先写 README

自动化测试

现在一切都妥了,但如果手动去完成测试,会耗费大量的时间。 现在,人们更喜欢让计算机来协助完成这些工作,下面我们来谈谈自动化测试。

Rust 有内建的测试框架,让我们试着写出一个测试吧:

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

你可以把这段代码放在几乎任何文件,cargo test 将运行它。 这里的关键是 #[test] 属性。构建系统会寻找这类函数,并当作测试去运行它们, 确认他们不会出错(panic)。

现在我们已经了解了如何去编写测试项,那么下一个问题就是要测试什么? 如你所见,为函数写断言非常简单。但 CLI 程序通常不止一个功能函数! 更麻烦的是,它通常要处理用户的输入、读取文件并写入输出。

编写可测试的代码

测试功能有两种互补的方法: 测试构建成完整程序的功能小单元,叫作“单元测试”。 还有就是从外部测试最终的程序,称为“黑盒测试”或“集成测试”。 让我们先从单元测试开始。

为了弄清楚我们要测试什么,让我们先看看我们程序的功能。 grrs 应该打印出,文件中包含符合匹配字符串的行。 所以,让我们给匹配功能写一个单元测试: 我们希望确保我们最重要的逻辑部分能正常工作, 并且以一种不依赖于任何需要设置代码、变量的方式实现(比如,处理 CLI 参数)。

回到我们的 grrs第一个实现, 当时我们在 main 函数中添加了如下代码块:

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

遗憾的是,这样的代码很难测试。 首先,因为它在 main 函数中,所以我们不能简单的直接调用它。 而把这段代码移动到一个函数中,就可以很简单地解决这个问题了:


#![allow(unused)]
fn main() {
fn find_matches(content: &str, pattern: &str) {
    for line in content.lines() {
        if line.contains(pattern) {
            println!("{}", line);
        }
    }
}
}

现在我们可以在测试中调用这个函数了,让我们来看一下它的输出是什么:

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

或者,我们还不能……现在,find_matches 会直接将输出打印到 stdout,比如在终端。 我们不能简单地在测试中捕获它的输出! 这是在实现功能后再编写测试时经常遇到的问题: 我们编写了一个牢固地集成在它所使用的上下文中的函数。

那么,我们怎样才能让这个函数变得可测呢?我们得能获取它的输出。 Rust 的标准库有一些简单的抽象来处理 I/O,在这里我们将使用 std::io::Write。 这是一个 trait, 它可以抽象我们可以写入的事物,包括字符串和标准输出。

如果你是第一次在 Rust 中看到 “trait” 这个词,也没关系。 特性(trait)是 Rust 最强大的特性之一。 你可以把它看成 Java 中的接口,或 Haskell 的 type classes(看你了解哪个)。 它允许你抽象可由不同类型共享的行为。 使用 trait 的代码可以以非常通用和灵活的方式来实现功能, 但这也意味着它可能难以阅读。 但请不要被它吓到:即使是 Rust 中的老手也不一定能马上理解通用代码的作用。 在这种情况下,考虑具体用途会有所帮助。 比如,现在,我们抽象的行为是“写入”。实现了(“impl”)了它的类型有: 终端的标准输出、文件、内存中的缓存或 TCP 网络连接。 (在std::io::Write 文档 中下翻可查看实现此 trait 的列表)

有了这些知识,让我们修改函数以便接受第三个参数。它应该是一个实现了 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!,只不过它总是写入到标准输出里。

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

#[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");
}

现在我们必须修改 main 函数中对 find_matches 的调用,给它加 &mut std::io::stdout() 作为第三个参数。 下面是修改完成之后,使用新版 find_matchesmian 函数:

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 去调用我们在库中的 find_matches 函数。

Rust 处理项目的方式非常灵活,尽早地考虑清楚哪些功能要放到库中是个好主意。 例如,你可以考虑先为用于特定程序的逻辑编写一个库,然后像调用任意其它库一样, 在你的 CLI 程序中使用它。又或者,若你的项目会生成多个二进制程序, 你可以将其通用的功能放到库里,提高代码的复用性。

通过运行 CLI 程序来测试它们

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

但还有许多代码是我们没有测试到的,因为: 我们写的一切都是为了与外界打交道! 想像一下,在你写 main 函数时,不小心留下了一段硬编码的路径字符串, 软件运行时会使用它而非用户提供的参数! 我们也需要编写这类的测试!(这个级别的测试一般被称为“集成测试”或“系统测试”)

从本质上讲,我们仍要编写函数并使用 #[test] 注释它们。 现在的问题是我们要在这些函数中做什么? 例如,我们想像运行一个普通程序一样使用我们项目的主程序。 我们还要将这些测试放入到一个全新的路径下:tests/cli.rs

回想一下,grrs 是一个在文件中搜索字符串的小工具。 我们已经测试过了查找匹配项功能。让我们再想想还能测试哪些功能。

这里我想到了一些。

  • 如果文件不存在会怎样?
  • 当没有搜索到匹配项时返回什么?
  • 当我们少写一个(或都没写)参数时,我们的程序会以错误状态退出?

这些都是有效的测试用例。 此外,我们还应该有一个成功的测试用例,即程序正常运行且找到至少一个匹配并打印。

为了让这类的测试更容易,我们将使用到 assert_cmd 箱。 它提供了许多简洁的帮助程序,可以让我们运行我们的主程序并查看它的行为。 此外,我们还将添加 predicates 箱, 来帮助我们为 assert_cmd 的测试项编写断言(且具有很棒的错误消息)。 我们不会将这些依赖放到程序的主依赖中, 而是放到 Cargo.tomldev dependencies 部分。 它们只会在开发时被使用到,而使用时则不会。


[dependencies]
structopt = "0.3.22"

设置完成后,让我们来创建我们的 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("could not read file"));

    Ok(())
}

你可以像我们之前做测试时一样,通过运行 cargo test 来运行这个测试。 在第一次运行时可能会稍慢,因为我们要编译出项目的主程序,它在 Command::cargo_bin("grrs") 被调用。

生成测试文件

我们刚编写的测试,只会在检查我们的程序的输入的文件参数不存在时输出的错误信息。 这是很重要的一个测试项,却不是最重要的: 让我们测试下,正确运行程序并打印出文件中匹配项的用例。

首先我们要有一个已知内容的文件,这样我们才能确认正确的输出是什么从而进行测试。 当然我们可以在项目中添加一个文件专门用来进行测试,或者也可以创建临时文件来测试。 在本教程中,我们选择后面一种做法。因为它相对更为灵活且也适用于其它测试用例; 比如,当你要测试去修改一个文件的时候。

为了生成这些临时文件,我们要使用到 assert_fs 箱, 让我们把它添加到 Cargo.tomldev-dependencies 中。

anyhow = "1.0"

这就是新的测试用例,首先去创建一个临时文件(并获取到它的路径), 然后在里面填充一些内容,再去运行我们的程序来检查我们能否获得正确的输出。 当执行完这个代码后,file 将自动被删除。

use assert_fs::prelude::*;

#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
    let file = assert_fs::NamedTempFile::new("sample.txt")?;
    file.write_str("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(())
}

去测试什么?

What to test?

虽然编写集成测试很有意思,但毕竟编写测试是要消耗时间的, 而且当你的程序行为有所变动时可能也需要去更新这些测试。 为了让我们花费的时间变得更有意义,我们应该问下自己,我们要测试什么?

一般来讲,为用户可以观察到的所有类型的行为编写集成测试是一个好主意。 这意味着你不需要去涵盖所有的边界情况:通常会有不同类型的示例, 再依赖于单元测试即可覆盖边界情况。

同样,尝试去测试你不能掌控的东西,并不是一个好主意。 测试 --help 的确切输出布局,会是一个坏主意, 相反,你可能只想检查其中某些元素是否存在。

根据程序的特性,你还可以尝试添加更多的测试技术。 比如,当你尝试去测试到所有的边界情况时,如果你提取了你程序的一部分, 并且发现你已经写了许多作为单元测试的示例情景,你可以看看 proptest。 如果你的程序会使用任意一个文件并解析它们,请试着写一个 fuzzer 来查找边界条件下的 bugs。

打包并发布一个 Rust 工具

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

我们可以有许多种办法,现在我们从“最快设置”到“对用户最方便”看其中的三种。

最快的方法:cargo publish

发布你的程序最简单的办法就是使用 cargo。 你还记得我们怎么往我们的项目中添加依赖么? cargo 从默认的 “crate 仓库”crates.io 下载它们。 使用 cargo publish,你可以在 crates.io 上发布你的 crate。 这适用于包括二进制程序在内的所有 crates。

如果你已经在 crates.io 上创建了一个账号, 那么在上面发布一个 crate 是非常简单的。 目前,它是通过 GitHub 来认证的,所以你需要一个 GitHub 账号, 并在 crates.io 上登录。然后,你需要在你本地的机器上登录(终端登录)。 然后,crates.io 的账号设置页面,生成一个新的 token, 在你的本地终端运行 cargo login <your-new-token>。 每台机器上只需运行一次即可一直使用。 有兴趣可以去看 cargo 的 publishing guide

现在你已经了解了 cargo 和 crates.io,可以去发布你的 crate 了。 在你急急忙忙地想着发布一个新的(或更新版本)crate 前, 你最好打开 Cargo.toml 并检查是否提供了必要的元数据。 你可以在 cargo’s manifest format 的文档中找到所有需要设置的字段, 下面展示了其中的常见条件:

[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 命令,运行 cargo install <crate-name>。 它默认会下载这个 crate,编译其中所有的二进制程序(使用 release 模式, 所以可能会稍慢)并将程序拷贝到 ~/.cargo/bin 目录。 (需确保你的 PATH 中有这个路径,才可正常使用安装的命令)

我们还可以直接在源码仓库下安装程序,使用这种文件可以指定安装哪些二进制程序, 及程序安装到什么位置。详情可查看 cargo install --help

什么时候使用它

使用 cargo install 可以很方便的安装一个 crate 程序。 但你需要了解到它的几个不足之处: 因为它总是会从头开始编译你的源码,所以使用你的工具的用户, 在他们的机器上需要安装上 Rust,Cargo 及其它所需的依赖。 而且编译一个大型的 Rust 代码库也需要较长的时间。

最好使用它来分发面向其它 Rust 开发者的工具。 比如,许多 cargo 的子命令, 像 cargo-treecargo-outdated 可以使用这种方法来安装。

发布二进制程序

Rust 会默认编译出使用静态链接的机器代码。 当你运行 cargo build时,若你的项目中有包含一个叫 grrs 的二进制 target, 你将得到一个叫 grrs 的二进制文件。 来试试吧:使用 cargo build,二进制文件在 target/debug/grrs, 若使用 cargo build --release 则生成的文件在 target/release/grrs。 除非你使用的 crates 中明确地说明了需要在目标系统上安装外部依赖库 (如需要使用系统提供的 OpenSSL),否则这个二进制文件只会依赖于通用系统库。 这意味着,将此二进制程序拷贝到任何相同的操作系统上,它都能完美地运行!

这已经很强大了!它解决了我们刚刚提到的,关于 cargo isntall 的两个不足: 它不需要用户的机器上安装 Rust,也不需要再去编译而可直接运行。

所以,cargo build _已经_为我们编译出了二进制程序。现在唯一的问题是, 还不能保证其可在所有的平台上都可用。如果你在 Windows 上运行 cargo build, 你并不会得到一个可在 Mac 上运行程序。 那么有没有办法为所有的常见平台自动生成这些二进制程序呢?

使用 CI 构建二进制版本

如果你的工具已经开源且托管在 GitHub 上,可以非常容易地设置一个免费的 CI (持续集成)—— Travis CI。(或其它平台上使用其它的服务,Travis 是最受欢迎的) 在你每次 push 新代码到你的仓库时,它会在一个虚拟机上运行设置命令。 至于是执行什么命令,或使用哪种虚拟机,这都是可以配置的。 比如,在一个安装了 Rust 和常用构建工具的机器上,执行 cargo test 是一个好主意。 如果这个命令失败了,你就知道最近的提交的代码是有问题的。

我们还可以使用它还构建二进制程序并上传到 GitHub 上! 那么,如果我们使用 cargo build --release 生成并将程序上传到某个地方, 就一切 OK 了么?也不尽然,我们需要确保我们的程序适用于尽可能多的系统。 比如,在 Linux 上我们可以不为当前系统编译,而是针对 x86_64-unknown-linux-musl 编译,从而使程序不依赖于默认的系统库。 在 macOS 上,我们可以设置 MACOSX_DEPLOYMENT_TARGET10.7, 使得程序只会依赖于 10.7 及其之后版本上存在的系统功能。

你可以在这儿看到使用此方法在 Linux 和 macOS 上编译的例子, 若使用 Windows,请看这个(使用 AppVeyor)。

另一种方法是使用包含我们编译程序所需要到的所有工具的 pre-built (Docker) 镜像。 这也使我们能够轻松地编译适配于更多不同的平台。 在trust 项目中包含可以在你的项目中使用的脚本及其如何设置使用的说明。 它还支持使用 AppVeyor 的 Windows 系统。

如果你更愿意在本地设置,并在本机上生成要发布的文件,你也可以看看 trust。 它在里面使用了 cross,cross 的工作原理类似于 cargo, 它会将命令转发到 Docker 容器中的 cargo 进程。 此镜像的定义相关可在其仓库查看 cross’ repository

如何安装这些二进制程序

你的发布页面也许是像这样的, 用户可以在这个页面下载我们刚生成的文件。 我们刚刚生成的发布文件没什么特别的:它们只是一个包括我们的二进制程序的压缩包! 这意味着你的工具的用户可以从浏览器上下载、解压,并将程序拷贝到任何地方。

这需要用户有手动安装程序的经验,所以你最好在你的 README 文件中加上安装方法说明。

什么时候使用它

一般来说使用 release 的二进制文件来安装是非常好的主意,它几乎没啥缺点。 虽然它没解决自动安装及更新的问题, 但可以在不安装 Rust 的情况下得到并使用相应的程序。

除了二进制程序外还要打包什么

现在,当一个用户下载了我们的 release 构建程序,他会得到一个 .tar.gz 文件。 里面只包含了一些二进制文件。而在我们的示例项目中,只有一个可运行的 grrs 文件。 但在我们的仓库中,也许还有一些其它文件是用户想要的, 比如 README 中有程序的使用方法,license 文件有程序的许可信息。 我们可以非常容易地将这些已有的文件添加到 release 的压缩包中。

这里还有一些非常有意思且有用的文件,尤其是在命令行工具中: 除了 README 文件,我们再提供一个帮助手册怎么样,然后再添加一个支持 SHELL 自动补全功能的配置文件?你可以自己来手写这些, 或者使用 clap, 这个解析参数的库(structopt 就是基于它)提供了一个方法来帮我们生成这些文件。 详情可查看深入了解这一章。

将你的软件放到软件仓库中

到目前为止,我们看到的两种方式都不是通常情况下你在计算机上安装软件的方式。 尤其在大多数操作系统上,会使用全局包管理器来进行命令行工具的安装。 这种包管理器对用户来说,优势非常明显: 他不需要去考虑如何安装你的软件,因为其安装方法和安装其它工具一样。 这些包管理器同样允许用户在软件有新版本时去更新它们。

不过,要支持不同的系统意味着你必须了解它们是如何工作的。 有一些是比较简单的 (比如,为 macOS 的 brew 添加一个 Formula 像这个), 还有的需要你自己来发送补丁并将你的工具添加他们的软件仓库中。 这里有一些很有用的工具,像 cargo-rpmcargo-debcargo-aur。 但它们是如何工作的及你要如何正确地为不同的系统打包你的工具不在本章的讨论范围中。。

现在,让我们看一下一个使用 Rust 编写,且在许多不同的包管理器中都存在的一个工具。

一个示例:ripgrep

ripgrep 是一个用 Rust 编写的,类似于 grep/ack/ag 的工具。 这是一个十分成功的项目,且打包分发支持了许多不同的操作系统: 请查看项目中 README 的安装 部分。

注意,其中列出了几种安装它的办法: 首先它提供了一个指向 GitHub releases 页面的链接,在这儿你可以直接下载二进制程序; 然后,也列出了,如何使用不同的软件管理器来安装; 最后,你也可以使用 cargo install 来安装它!

不要只选择这里介绍的一种方法,而是先使用 cargo install, 然后再发布二进制 releases,最后再尝试使用系统包管理器来分发你的工具。 相信我,这会是一个好主意!

再深入谈谈

一个小章节,涵盖了你在编写命令行程序时可能关心的更多细节问题。

信号处理

像命令行程序这种进程需要接受操作系统发出的信号并做出相应的反应。 最常见的就是 Ctrl+C 了,这是典型的终止进程的信号。 在 Rust 程序中处理信号,你需要考虑如何获取这些信号及如何做出反应。

操作系统之间的差异

在 Unix 系统(比如 Linux、macOS 和 FreeBSD),进程可以接受 signals。 它可以以默认的方式(系统提供)来接收信号并以默认的方式处理它, 或者直接不理会这个信号。

Windows 没有信号。你可以使用 Console Handlers 来定义某事件发生后的回调。 还有 structured exception handling,它处理所有各种类型的系统异常, 如被零除,无效访问异常,堆栈溢出等。

首先:处理 Ctrl+C

ctrlc 箱如其名:它允许你在用户按下 Ctrl+C 时, 去做出相应的反应,且它是跨平台的。其使用方法如下:

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> (一个可在线程中共享的 boolean),并在一个无限循环中,或在等待线程时, 周期性地检查它的值,当它为真时退出程序。

处理其它类型的信号

ctrlc 箱只会处理 Ctrl+C,或者在 Unix 系统中,称为 SIGINT(中断信号)。signal-hook 可以去处理更多的 Unix 信号。 在 this blog post 中描述了它的设计原理, 且它是目前社区里支持最为广泛的库。

一个简单的示例:

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

fn main() -> Result<(), Box<dyn Error>> {
    let mut 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(())
}

使用通道(channels)

除了设置一个变量并在程序其它部分去检查它,你还可以使用通道: 创建一个通道,在信号处理器接收到信号后,向里面发送一个值。 在你的程序代码中,使用此通道和其他通道作为线程之间的同步点。 像这样使用 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(())
}

使用 futures 和 streams

如果你使用 tokio,那你应该已经写了一个使用异步编程的事件驱动型的程序。 除了直接使用 crossbeam 的通道之外, 你还可以启用 signal-hook 的 tokio-support feture。 它允许你在 signal-hook 的 Signals 类上调用 .into_async() 来获得一个实现了 futures::Stream 的新类型。

当处理 Ctrl+C 时又接收到新的 Ctrl+C 时怎么办

大部分用户会按下 Ctrl+C, 等待你的程序退出(也许需要几秒钟)或告诉他们接下来要怎么办。 如果没效果,他们会再次按下 Ctrl+C。 一般情况下,此时程序会立即退出!

使用配置文件

处理配置可能会很麻烦,尤其是当你支持多个操作系统, 而这些操作系统有不同的固定或临时配置文件存放位置。

有许多种办法可以解决这个问题,一些的方式会更接近低层。 There are multiple solutions to this, some being more low-level than others.

最容易使用的 crate 是 confy。 它要求你提供程序的名称,并要求你通过 struct 来定义配置 (需实现 SerializeDeserialize),剩下的事儿交给它就可以了!

#[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(())
}

在你放弃可配置性时,毫无疑问它是极其方便好用的。 所以在你需要简单的配置时,使用这个 crate 会非常合适。

配置环境

退出状态码

程序不总是能成功地运行。当发生错误时,你需要确保正确地发出必要的信息。 此外如 telling the user about errors 中所说, 在大部分系统中,当一个进程退出,它会发出一个退出状态码 (在大部分平台上是一个 0 至 255 的数字)。 你应该为你的程序发出正确的退出码。 比如,当你的程序成功运行后,它应该生成 0 的退出码。

但当发生错误时,它会变得更复杂一些。 大多数情况下,许多工具在发生一般性错误时会以 1 为退出码。 目前,Rust 为 panicked 的进程设置了 101 的退出状态码。 除此之外,人们在他们的程序中做了许多事情。

所以,要如何去做呢?BSD 生态系统为其退出码做了一个通用的定义 (你可以在这里找到它们)。 Rust 的 exitcode 库也提供了一样的代码,且你可在你的程序中使用。 请参阅其 API 文档以了解其用法。

当你在你的 Cargo.toml 中添加 exitcode 依赖后,你可以这样使用:

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);
        }
    }
}

与人交互

你需要先去阅读输出这一章节。 它介绍了如何在终端中写入输入,而本节将谈谈写入哪些输出。

当一切都正常时

即使一切正常,报告程序的进度也很有用。但要尽量提供简洁有用的信息。 不要在日志中使用过于专业的术语。 请记住,此时程序没有崩溃,所以用户没必要去查找错误。

最重要的是,要保证简洁,使用相同的前缀和语句结构以便日志更易于阅读。

要试着让你的程序的输出讲清楚它在做什么,及对用户有什么影响。 这可能涉及显示其步骤的时间线,甚至在长时间运行的程序中显示一个进度条和指示器。 用户在任何时候都不应该有程序在做一些他们无法理解的神秘的事情的感觉。

当很难说清楚发生了什么时

在传达非名义状态时,保持一致很重要。 与无日志应用相比,不严格遵循日志记录级别且生成大量日志的程序, 其提供的信息量会相同,甚至于更少。

所以,定义与之相关的事件和消息的严重性很重要;然后为它们使用一致的日志级别。 使用这种方法使得用户可以使用 --verbose 或设置环境变量(如 RUST_LOG)来得到大量的 log 信息。

通常会使用到 log 箱来 defines 这些 log 等级(按严重级别排列):

  • 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

崩溃的时候

一个常被忽略的点是——程序在崩溃的时候也要输出一些东西。 在 Rust 中,崩溃通常是程序的 panics(“可控崩溃”不“操作系统直接杀死进程”不同)。 默认情况下,当 panic 发生时,“panic handler” 会在终端中打印一些有用信息。

比如,你使用 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.

这对于你,开发者来说,是有用的信息。(哈哈:你的程序在 main.rs 第二行崩溃了) 但对于那些没源码的人,它没什么价值,而且事实上,它只会让人感到更为困惑。 所以,添加一个更为面向终端用户的自定义 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 哲学

程序应该只关注一个目标,并尽可能把它做好。让程序能够互相协同工作。应该让程序处理文本数据流,因为这是一个通用的接口。

如果我们的程序能做到这些,那么我们的用户将会非常高兴的。 为了能做到这点,我们要保证我们的输出信息不止适用于人类阅读, 也要是对其它程序来说是可用的。现在让我们看看,如何做到这点。

Who’s reading this?

The first question to ask is: Is our output for a human in front of a colorful terminal, or for another program? To answer this, we can use a crate like atty:

use atty::Stream;

if atty::is(Stream::Stdout) {
    println!("I'm a terminal");
} else {
    println!("I'm not");
}

Depending on who will read our output, we can then add extra information. Humans tend to like colors, for example, if you run ls in a random Rust project, you might see something like this:

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

Because this style is made for humans, in most configurations it’ll even print some of the names (like src) in color to show that they are directories. If you instead pipe this to a file, or a program like cat, ls will adapt its output. Instead of using columns that fit my terminal window it will print every entry on its own line. It will also not emit any colors.

$ 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

Easy output formats for machines

Historically, the only type of output command-line tools produced were strings. This is usually fine for people in front of terminals, who are able to read text and reason about its meaning. Other programs usually don’t have that ability, though: The only way for them to understand the output of a tool like ls is if the author of the program included a parser that happens to work for whatever ls outputs.

This often means that output was limited to what is easy to parse. Formats like TSV (tab-separated values), where each record is on its own line, and each line contains tab-separated content, are very popular. These simple formats based on lines of text allow tools like grep to be used on the output of tools like ls. | grep Cargo doesn’t care if your lines are from ls or file, it will just filter line by line.

The downside of this is that you can’t use an easy grep invocation to filter all the directories that ls gave you. For that, each directory item would need to carry additional data.

JSON output for machines

Tab-separated values is a simple way to output structured data but it requires the other program to know which fields to expect (and in which order) and it’s difficult to output messages of different types. For example, let’s say our program wanted to message the consumer that it is currently waiting for a download, and afterwards output a message describing the data it got. Those are very different kinds of messages and trying to unify them in a TSV output would require us to invent a way to differentiate them. Same when we wanted to print a message that contains two lists of items of varying lengths.

Still, it’s a good idea to choose a format that is easily parsable in most programming languages/environments. Thus, over the last years a lot of applications gained the ability to output their data in JSON. It’s simple enough that parsers exist in practically every language yet powerful enough to be useful in a lot of cases. While its a text format that can be read by humans, a lot of people have also worked on implementations that are very fast at parsing JSON data and serializing data to JSON.

In the description above, we’ve talked about “messages” being written by our program. This is a good way of thinking about the output: Your program doesn’t necessarily only output one blob of data but may in fact emit a lot of different information while it is running. One easy way to support this approach when outputting JSON is to write one JSON document per message and to put each JSON document on new line (sometimes called Line-delimited JSON). This can make implementations as simple as using a regular println!.

Here’s a simple example, using the json! macro from serde_json to quickly write valid JSON in your Rust source code:

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

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

And here is the output:

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

(Running cargo with -q suppresses its usual output. The arguments after -- are passed to our program.)

Practical example: ripgrep

ripgrep is a replacement for grep or ag, written in Rust. By default it will produce output like this:

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

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

But given --json it will print:

$ 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"}

As you can see, each JSON document is an object (map) containing a type field. This would allow us to write a simple frontend for rg that reads these documents as they come in and show the matches (as well the files they are in) even while ripgrep is still searching.

How to deal with input piped into us

为你的 CLI 程序生成文档

CLI 程序的文档通常包括调用命令时使用 --helpman 手册。

当你使用 clap v3(目前还是 unreleased 状态),会通过 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 打开它,就可以看到你的文档了。

好用的 crates

总会有新的可适用于开发命令行工具程序的 crates 被发布出来。

本书引用到的 crates

  • anyhow - provides anyhow::Error for easy error handling
  • assert_cmd - simplifies integration testing of CLIs
  • assert_fs - Setup input files and test output files
  • atty - detected whether application is running in a tty
  • clap-verbosity-flag - adds a --verbose flag to structopt CLIs
  • clap - command line argument parser
  • confy - boilerplate-free configuration management
  • crossbeam-channel - provides multi-producer multi-consumer channels for message passing
  • ctrlc - easy ctrl-c handler
  • env_logger - implements a logger configurable via environment variables
  • exitcode - system exit code constants
  • human-panic - panic message handler
  • indicatif - progress bars and spinners
  • log - provides logging abstracted over implementation
  • predicates - implements boolean-valued predicate functions
  • proptest - property testing framework
  • serde_json - serialize/deserialize to JSON
  • signal-hook - handles UNIX signals
  • structopt - parses command line arguments into a struct
  • tokio - asynchronous runtime
  • wasm-pack - tool for building WebAssembly

其它 crates

Rust 的 crates 景观是不断变化的,你可以在 lib.rs 查找 crates: 以下是一些可能对构建 CLIs 有用的特定的类别: