Rust 中的命令行应用程序

Rust 是一种静态编译的快速语言,具有强大的工具和快速增长的生态系统。这使得它非常适合编写命令行应用程序:因为它们本就该是小型的、可移植的和快速运行的。命令行应用程序也是开始学习 Rust 的一个很好的方法;或者向您的团队介绍 Rust!

对于初学者来说,用简单的命令行界面(CLI)编写一个程序,会是一个很好的练习,因为他们对该语言还不太熟悉,而这,能开始对它有一种感觉上的习惯。但现在,这个话题有太多方面了,虽然说,往往只有在以后才会显露出来。

这本书的结构是这样的:我们从一个快速教程开始,之后您将得到一个可工作的 CLI 工具。您将了解 rust 的一些核心概念,以及 CLI 应用程序的主要方面。接下来的章节将更详细地介绍其中一些方面。

在深入研究 CLI 应用程序之前,最后一件事是:如果您在本书中发现了错误,或者希望帮助我们为其编写更多内容,您可以找到其源代码。它在 CLI wg 存储库中。我们很高兴听到您的反馈!谢谢您!

15 分钟编写命令行应用程序,以此学习 Rust

本教程将指导您学习Rust。你大概要花 15 分钟的时间,才能达到你有一个运行程序的点(大约在 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(它应该只需要几分钟)。 之后,我们打开一个终端,并cd到接下来教程代码(你想)放置的目录。

译者:螃蟹是 Rust 的吉祥物,而 Rust 也有螃蟹的意思,所以,让我们开始尝尝螃蟹吧。

在目录中先运行cargo new grrsgrrs是编程存进的目录,若是查看grrs目录(结构),你会发现一个典型的 Rust 项目装置。

  • 一个Cargo.toml文件,它包含我们项目的元数据。还有我们使用的依赖项/外部 库(或者说 crate-箱子)。
  • 一个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),接下去的条目是用户随后写下的。

以这种方式,获得原始参数非常容易:

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(路径)(要查找的文件)。

我们还能对他们说些什么呢?嗯,首先,两者都是必需的。我们还没有讨论任何默认值,因此我们希望用户始终提供两个值。此外,我们可以稍微介绍一下它们的类型:模式应该是一个字符串,而第二个参数应该是一个文件的路径。

在 Rust 中,围绕所处理的数据,构建程序是很常见的,因此这种查看 CLI 参数的方式非常适合。让我们从这个开始:

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

这定义了一个新的结构(一个struct)它有两个字段用于存储数据: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 分析 CLI 参数

一个更好的方法是运用一个可用库,当然还有许多其他可用库。最流行的用于分析命令行参数的库称为clap. 它具有您所期望的所有功能,包括对,子命令的支持、shell 补全和伟大的帮助消息。

这个structopt箱子建立在clap之上,并提供一个“derive”宏,用来生成struct定义的有关clap代码。这很好:我们所要做的就是注释一个结构,而它将生成为,把(命令行)参数解析到字段中的代码。

我们先导入structopt,具体先在Cargo.toml文件的[dependencies]部分,添加structopt = "0.2.10"

现在,在我们的代码中,写use structopt::StructOpt;,并添加#[derive(StructOpt)],到我们struct Cli的上面。同时,我们还将编写一些文档注释。

看起来像这样:

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作为传递参数。

这就是它的样子

在没有任何参数的情况下,运行它:

$ 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 时,很难不注意和不处理这个事实:因为无一例外,所有可能的错误状态,通常都编码在函数的返回类型中。

Result

read_to_string这样的函数,是不返回字符串的。相反,它返回Result,其中(一个是)包含String或(另一种是)某类型的错误(在本例子是std::io::Error

你怎么知道它是什么?因为啊,Result(其实)是一个enum(枚举),您可以使用match,检查它是哪种变体:


# #![allow(unused_variables)]
#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); }
}
#}

展开(Unwrap)

现在,我们可以访问文件的内容,但在match区块之后我们不能肯定(它的返回类型)。为此,我们需要以某种方式处理错误案例。这里的挑战在于match块的所有条件语句(或是臂),需要返回相同类型的内容。但有一个巧妙的方法可以解决这个问题:

译:Rust 常把 match 的条件语句,说成 手臂(arm),看起来还挺像的。


# #![allow(unused_variables)]
#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); // 使用 content
#}

我们可以在match区块之后,使用content的字符串(String 类型)。如果result是错误(Err),则字符串将不存在。但因为程序在到达使用content点之前,就退出了,所以不会有问题。

这可能看起来很刚烈,但很方便。如果您的程序需要读取该文件,并且如果该文件不存在,无法执行任何操作,那么退出是一种有效的策略。甚至还有一个针对Result的快捷方式,就是调用unwrap


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

不必惊慌

当然,中止/崩溃程序并不是处理错误的唯一方法。不用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(())在底部?因它是函数的默认返回值,表示“结果(Result)正常Ok,且没有内容()”。

问号?

就像match中,可在错误臂调用.unwrap(),作为panic!一样,我们的match有另一个能在错误臂中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(())
}

非常简洁!

提供上下文

在你的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)”)

不是说很漂亮,但稍后我们可以为我们的类型,简单调整调试输出。

这种模式实际上很常见。但它有一个问题:我们不存储原始错误,只存储它的字符串表示。常用的failure箱子有一个很好的解决方案:类似于我们的CustomError类型,但它有一个Context会包含说明和原始错误的类型。箱子也带来了一个扩展 trait ResultExt),可以为Result加上context()with_context()方法。

为了将这些打包的错误类型,转换为人类真正想要读取的内容,我们可以进一步添加exitfailure箱子,并使用其类型作为我们main函数的返回类型。

让我们先导入这些箱子,也就是在Cargo.toml文件的[dependencies]部分,添加failure = "0.1.5"exitfailure = "0.5.1"

完整的示例如下:

use failure::ResultExt;
use exitfailure::ExitFailure;

fn main() -> Result<(), ExitFailure> {
    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
Info: caused by No such file or directory (os error 2)

输出

打印“Hello World”


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

嗯,很简单。很好,下一个话题。

使用 println

你大概可以用println!宏打印你喜欢的所有东西。这个宏有一些非常惊人的功能,但也有一个特殊的语法。它期望您编写一个字符串文字作为第一个参数,其中包含由后面参数的参数值填充的占位符。

例如:


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

将打印

My lucky number is 42.

上面的字符串中大括号({})是这些占位符之一。这是默认的占位符类型,尝试以人类可读的方式打印值。对于数字和字符串,这很好地工作,但并非所有类型都能做到这一点。这就是为什么还存在一个“调试表示法”,就是通过在大括号填充占位符:{:?}

例如,


# #![allow(unused_variables)]
#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_variables)]
#fn main() {
println!("This is information");
eprintln!("This is an error! :(");
#}

一份打印性能的报告

如果你尝试重复println!的话,你会发现打印到终端非常缓慢!很容易就成为以快速为目标的程序的瓶颈。要加速,这里有两件你能做的事。

第一,您可能想要减少终端实际的刷新(flush)。而每一个println!会告诉系统每次都刷新一下终端,因为常见功能就是要打印一个新行。如果你并不需要这些,你可以用一个默认的 8 kB 缓冲BufWriter,去包裹你的stdout控制器。(你仍可以调用BufWriter.flush(),若是你想要立即打印的话。)


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

let stdout = io::stdout(); // 获得 stdout 实体
let mut handle = io::BufWriter::new(stdout); // 可选: 把  stdout 的 控制权 包裹进一个 buffer
writeln!(handle, "foo: {}", 42); // 可加上 `?`, 若你关心错误的话。
#}

第二,获得stdout(或stderr)的一个锁也有用,并使用writeln!直接打印到它。这可以防止系统一遍遍重复对stdout上锁和解锁。


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

let stdout = io::stdout(); // 获得 stdout 实体
let mut handle = stdout.lock(); // 获得它的一个锁
writeln!(handle, "foo: {}", 42); // 可加上 `?`, 若你关心错误的话。
#}

你也可以把两种方法结合起来。

显示一个进度条

一些 CLI 应用程序运行不到一秒,有些则以分钟或小时计算。 如果你编写的是后者,你可能会想要告诉你的用户,程序的进展如何。 针对这个,你应该试着打印有用的更新状态,想法是能让用户容易接受。

使用indicatif箱,你可以为你的程序添加进度条和小提示。 下面是个快餐示例:

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箱子(配有日志级别命名的宏)和一个 适配器(adapter),也就是把日志输出到有用的地方。日志适配器的功能是很灵活:你想想,不仅可以把日志输出到终端,还可以是syslog,或中央日志服务器。

鉴于我们现在只关心编写一个命令行应用,适配器简单用env_logger就好。它的名称叫做env,因它可能使用环境变量,来指定你命令行想要记录的部分。(当然,还有日志的级别)它会有一个时间戳和来自哪个模块,帮你前缀化你的信息,也可以简单配置日志的输出。

这里有一个简单的例子:

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" //set the env var for the current session
$ 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,您可以像这样运行它:

$ rem set the env var for the current session
$ 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还包含一个构建器,所以你可以用程序调整这个配置,就比如说,默认级别信息为 info

有很多日志适配器的替代品,当然还有log的替换或扩展。 如果你知道你的应用要记录很多,请确保日志清晰明了,让你的用户日子舒服些。

测试

经过几十年的软件开发,人们发现了一个事实:未经测试的软件不会工作。(许多人甚至会说:“大多数测试过的软件也不会工作。”但我们都是乐观主义者,对吧?)因此,为了确保您的程序完成您期望它做的,测试它是明智的选择。

一个简单的方法是写一个README文件,描述程序应执行的操作。当您准备好发布新版本时,请通过README确保行为仍如预期。您也可以写下程序对错误输入的反应,把这当成一个更严格的练习。

还有一个好主意:在你写代码之前,就构思README

自动化测试

现在,这一切都变得很好,且花式多样,但要手工做这些吗?那可能需要很多时间。与此同时,许多人开始喜欢告诉计算机,帮他们做事情。我们来谈谈如何自动化这些测试。

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

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

你可以把这段代码放到几乎所有的文件中,cargo test会找到并运行它。这里的关键是#[test]属性。它允许’构建系统’发现这些函数,并将它们作为测试运行,以验证它们不会恐慌崩溃。

现在我们看到了我们可以怎样编写测试,我们还需要弄清楚要测试什么。正如您所看到的,编写函数断言(assert)相当容易。但是,CLI 应用程序通常不止一个函数!更糟糕的是,它经常需要处理用户输入、读文件和写输出。

使代码具备可测试性

测试功能有两种互补的方法:测试构建完整应用程序的小单元,这些方法称为“单元测试”。还有“从外部”测试最终应用程序,称为“黑盒测试”或“集成测试”。让我们从第一个开始。

为了弄清楚我们应该测试什么,让我们看看我们的程序功能是什么。grrs主要的工作就是,应该打印出与给定(文本)模式匹配的行。那么,让我们为这个(功能)编写单元测试:我们希望确保我们最重要的一段逻辑代码能够正常工作,并且我们希望这,以一种不会依赖于周围的任何设置代码的方式进行(例如,处理 cli 参数的代码)。

回到我们grrs首次实现一文,我们将此代码块添加到main函数:

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

遗憾的是,这并不容易测试。首先,它在 main 函数中,所以我们不能轻易地调用它。通过将这段代码移动到一个函数(本例子是find_matches)中,可以很容易地解决这一问题:


# #![allow(unused_variables)]
#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,它抽象了我们可以写(write)的东西,包括字符串,还有stdout

如果这是接触 rust 后,你第一次听到“trait”(或特质,即行为接口),那你就要认真对待了。trait 是 Rust 最强大的功能之一。你可以把它们想象成 Java 中的接口,或者在 Haskell 中类型类(无论你熟悉什么)。它们允许您抽象不同类型可以共享的行为。使用 trait 的代码可用非常通用和灵活的方式表达思想。这意味着,它也很难(以正常人类的顺序)阅读。但不要让这种情况吓倒你:即使是多年来一直使用 rust 的人,也不总是能立即得到通用代码的行为。在这种情况下,它有助于思考具体的用途。例如,在我们的例子中,我们抽象的行为是“写它”。要实现(“impl”)这个类型的示例包括:终端的标准输出、文件、内存中的缓冲区或 TCP 网络连接。(向下滚动到文件std::io::Write,查看“实现人员”名单。)

有了这些知识,让我们改变函数,接受第三个参数。而它应该是实现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”的可变(mut)事物。它的类型是impl std::io::Write,可以将理解为“实现Writetrait”。还要注意我们如何将我们以前用的println!(…),替换成writeln!(writer, …)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中,通过添加&mut std::io::stdout()作为第三个参数,改成调用find_matches。下面是一个 main 函数的例子,它建立在我们在前几章中所看到的基础上,并使用我们提取的find_matches函数:

fn main() -> Result<(), ExitFailure> {
    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. 添加一个pubfn(也就是pub fn find_matches)使它成为用户可以访问的东西。
  3. src/main.rs中,去除find_matches
  4. fn main,提前调用grrs::find_matches,所以现在变为grrs::find_matches(…). 这意味着它使用了我们刚刚编写的库中的函数!

Rust 处理项目的方式是相当灵活的,尽早考虑将什么放入您的箱子的库中是一个好观念。例如,您可以考虑先为应用程序的特定逻辑编写一个库,然后像其他库一样在 CLI 中使用它。或者,如果您的项目有多个二进制文件,您可以将公共函数放在箱子的库部分。

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

到目前为止,我们已经竭尽全力测试我们应用的业务逻辑,结果就是搞出了find_matches函数。这是非常有价值的,也是向经过良好测试的代码库,迈出的第一步。(通常,这类测试称为“单元测试”。)

但是,我们还有很多代码没有测试:我们为真实世界所编写的所有处理代码!假设您编写了 main 函数,但意外地留有一个硬编码字符串,而不是使用用户提供的路径参数。我们就应该为此编写测试!(这一级别的测试通常称为“集成测试”,或“系统测试”。)

在其核心,我们编写的(测试)函数还是用#[test]声明。重要的只是我们在这些函数中做什么的问题。例如,我们希望使用项目的 main 二进制文件,并像常规程序一样运行它。我们还将把这些测试,放入新目录中的新文件中:tests/cli.rs

回想起来,grrs是一个在文件中,搜索字符串的小工具。我们之前已经测试了匹配项的查找。让我们考虑一下,我们还可以测试哪些其他功能。

这是我想出来的。

  • 当文件不存在时,会发生什么?
  • 当没有匹配项时,输出是什么?
  • 当我们忘记一个(或两个)参数时,程序是否会出错退出?

这些都是有效的测试用例。此外,我们还应该为“正确路径”包含一个测试用例,即我们找到至少一个匹配项,并打印出来。

为了使这些测试更简单,我们将使用assert_cmd箱子。它有一堆整洁的助手,允许我们运行我们的主要二进制文件,并查看它的行为。此外,我们还将添加predicates箱子,有助于assert_cmd的断言测试(并且有很大的错误消息)。我们不会将这些依赖项,添加到依赖主列表,而是添加到我们的Cargo.toml中的”dev dependencies”(开发依赖)部分。只有在开发箱子时才需要它们,而不是在使用箱子时。

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

这听起来像是很多设置。不过,让我们直接上手,创建tests/cli.rs文件:

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

#[test]
fn file_doesnt_exist() -> Result<(), Box<std::error::Error>> {
    let mut cmd = Command::main_binary()?;
    cmd.arg("foobar")
        .arg("test/file/doesnt/exist");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("No such file or directory"));

    Ok(())
}

您可以使用cargo test运行此测试,命令只是我们上面写的测试。第一次可能需要更长的时间,比如Command::main_binary()需要编译你的主要二进制文件。

生成测试文件

测试我们刚刚看到的只是检查我们程序写出的一个错误信息,表示输入文件不存在。 这是个要具备的重要测试,但可能不是最重要的那个。 让我们现在测试下,在文件中,实际找到的匹配项吧。

我们需要有一个文件,它的内容是我们知道的,这样我们才知道我们程序应该返回的正确匹配项,并在我们的测试中检查这个期望。一个想法是添加一个具有自定义内容的文件到项目,并在测试中使用。另一个则是在测试中创建临时文件。鉴于我们教程的情况,我们会搞下后者。具体来说,因为它更灵活,还可以用在其他案例;例如说,要测试一个改变文件的程序。

要创建这些个临时文件,我们会用到tempfile箱子。让我们将它添加到Cargo.tomldev-dependencies:

tempfile = "3"

这是一个新的测试用例(,你可以在下面写另一个),先创建一个临时文件(,命名它我们才能获得它的路径),填充些文本,然后运行我们的程序,看看是不是正确输出。当file 走出作用域(函数的结尾),实际的临时文件会自动删除。

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

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

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

    Ok(())
}

测试什么?

尽管,编写集成测试确实有趣,还也是要花上不少时间,还要让测试持续跟上测试的变化。为了让你时间是花得更有意义,你应该三思下,你应该测试什么。

总的来说,一个的想法是,为所有用户注意到的行为类型,编写集成测试。 这意味着,你不需要覆盖所有的边缘情况。对于不同的类型,通常有一些例子就足够了, 还可以依靠单元测试来覆盖边缘情况。

最好不要把你的测试集中在你不能主动控制的事情上。 测试--help的确切布局是个坏主意, 因为它是为你生成/服务的。 相反,您可能只想检查某些元素是否存在。

根据你程序的本质,你可以尝试更多的测试技术。比如说,如果你已经提取了部分程序,并且想试着为(部分程序)所有的边缘情况,编写许多示例案例作为单元测试的话,那你应该看看proptest。又或是,你有一个消化任意文件并解析它们的程序,那就试着编写一个fuzzer,在边缘发现 bug。

打包和分发一个 Rust 工具

如果你相信你的项目已经准备好了给人用了,那么是时候打包和发布了。

我们会有三种方法,并从”最快设置的“开始说明,直到”用户最方便的“。

最快的:cargo publish

发布您的应用程序的最简单方法就是用 Cargo。还记得如何在项目添加外部依赖吗?Cargo 会从箱子仓库crates.io下载它们。通过cargo publish,你也可以轻松把箱子发布到crates.io。对所有的箱子都适用,还包括那些二进制目标文件。

发布一个箱子到crates.io很简单:如果你还没有试过,那要先在crates.io创建一个账号。目前,可以通过 Github 账号授权完成,所以你会需要一个 Github 账号(并登陆)。下一步,通过在本地机器,使用 cargo 登陆,而要做到这一步,你要去到crates.io 账户页面创建一个新的令牌,然后运行cargo login <your-new-token>。每台电脑只需要做这一次。你可以在 cargo 的发布指南上,了解更多。

现在,你知道了 cargo 还有 crates.io。那你就准备好发布箱子了。在你匆忙发布一个新箱子之前,请确保你为你的箱子,添加了必要的元信息。你可以在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,您可能想知道如何安装它。与库不同的是,当你运行cargo build(或类似的命令)的时候, Cargo 会为你下载和编译哪些,您需要显式告诉它安装二进制文件。

这是用cargo install <crate-name>完成的。 默认情况下,它将下载箱子,编译它包含的所有二进制目标文件(“发布”模式,因此可能需要一段时间),并将它们复制到~/.cargo/bin/目录。(确保您的 shell ,知道在那里查找二进制文件!)

也可以从 Git 存储库安装箱子,只安装一个箱子的特定二进制文件,并指定一个备选目录来安装它们。查看cargo install --help有关详细信息。

何时使用

cargo install是发布二进制箱子的简单方法。Rust 开发人员使用起来非常方便,但也有一些明显的缺点:因为它总是从头开始编译源代码,所以您的工具的用户将需要拥有 Rust、Cargo 以及您的项目需要的所有其他系统依赖项,都要安装在用户机器上。编写大型 Rust 代码库也需要一些时间。

此外,没有简单的方法来更新用 cargo 安装的工具:用户需要在某个时刻运行cargo install,并通过--force覆盖旧二进制文件的标志。这是一个missing 功能,不过还有像这个一样子命令,您可以通过安装来添加它。

最好将其用于分发,针对其他 Rust 开发人员的工具。例如:有很多 Cargo 的子命令 cargo-treecargo-outdated,可以和它一起安装。

分发二进制文件

Rust 是一种编译为本机代码的语言,默认情况下静态链接所有依赖项。当你在grrs项目中运行cargo build,那么它会(构建)一个grrs二进制文件。尝试一下:使用cargo build,构建在target/debug/grrs。而当你运行cargo build --release,则会构建在target/release/grrs。除非使用显式需要在目标系统上,安装外部库的箱子(如使用系统的 OpenSSL 版本),否则此二进制文件将仅依赖于公共系统库。这就是说,你只需要把一个文件,发送给和你运行相同操作系统的人,他们就可以运行它了。

这已经很强大了!它可以解决我们刚才在cargo install上看到的两个缺点:不需要在用户的机器上安装 Rust,他们可以立即运行二进制文件,而不需要花一分钟来编译。

所以,正如我们所看到的,cargo build 已经为我们构建二进制文件。唯一的问题是,它们不能保证在所有平台上都能工作。如果cargo build在您的 Windows 机器上,默认情况下,就不会得到在 Mac 上工作的二进制文件。有没有一种方法,可以为所有感兴趣的平台自动生成这些二进制文件?

在 CI 上构建二进制发布

如果您的工具是开源的,并且托管在 GitHub 上,那么很容易建立一个免费的 CI(持续集成)服务,比如Travis CI。(还有其他服务也可以在其他平台上工作,但 Travis 非常流行。)这基本上是,在每次将更改推送到存储库时,就在虚拟机中,运行所设置命令。这些命令是什么,以及它们运行的机器类型都是可配置的。例如:一个好主意是在机器上运行cargo test,配有 Rust 和一些常用的工具。如果失败,您就知道最近的更改中存在问题。

我们还可以使用它,来构建二进制文件,并将它们上载到 Github!事实上,如果我们运行cargo build --release,并把二进制文件上传到某个地方,那么我们应该都设置好了,对吗?不完全是。我们仍然需要确保我们构建的二进制文件,与尽可能多的系统兼容。例如,在 Linux 上,我们可以不设为当前系统编译,而是为x86_64-unknown-linux-musl目标,不依赖默认系统库。在 MacOS 上,我们可以设置MACOSX_DEPLOYMENT_TARGET10.7,仅依赖于 10.7 及更高版本中的系统功能。

您可以看到使用这种方法构建二进制文件的一个示例,这是针对 Linux 和 MacOS ,以及这是针对 Windows(使用 AppVeyor)。

另一种方法是使用预构建(Docker)映像,其中包含构建二进制文件所需的所有工具。这也使我们能够轻松瞄准,更具异国情调的平台。这个trustProject 包含的脚本,可以 include 你的项目,以及有关如何设置的说明。它还包括对使用 AppVeyor 的 Windows 的支持。

如果您希望在本地设置它,并在您的计算机上生成发布文件,那么您仍然可以查看 trust。它在内部使用cross,它的工作原理类似于 Cargo,但将命令转发给 Docker 容器内的 Cargo 处理。镜像的定义也在cross 的存储库可用。

如何安装这些二进制文件

你把你的用户指向你的发布页面,像这个一样。他们就可以下载我们刚刚创建的工件。我们刚刚生成的发布工件,没有什么特别的:最后,它们只是包含我们的二进制文件的存档文件!这意味着,工具的用户可以用浏览器下载它们,提取它们(通常是自动执行的),并将二进制文件复制到他们喜欢的地方。

这确实需要一些手动“安装”程序的经验,因此您需要在 REAMDE 文件中,添加有关如何安装此程序的部分。

何时使用

一般来说,使用二进制发布是一个好主意,几乎没有任何缺点。当然,它不能解决用户必须手动安装,和更新您的工具的问题,但他们可以快速获得最新版本,而无需安装 rust。

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

现在,当用户下载我们的发布版本时,他们将获得.tar.gz,一个只包含二进制文件的(压缩)文件。所以,在我们的示例项目中,他们只会得到一个他们可以运行的grrs文件。但我们的存储库中,已经有了一些他们可能想要的更多文件。如:告诉他们如何使用这个工具的 REAMDE 文件,还有许可证文件。因为我们已经有了它们,所以它们很容易添加。

不过,还有一些更有趣的文件,特别是对于命令行工具来说更为合理:除了这个 REAMDE 文件之外,我们还提供了一个手册,以及向 shell 添加补全可能标志的配置文件如何?你可以用手写,但是鼓掌吧(clap),我们的参数解析库(也是 structopt 的基础库)有一种为我们生成所有这些文件的方法。见深入的章节,了解更多详细信息。

将应用程序放入软件包存储库

到目前为止,我们看到的两种方法,都不是您常在计算机上安装软件的方法。尤其是在大多数操作系统上,会使用全局包管理器安装命令行工具。用户的优势是显而易见的:如果可以像安装其他工具一样安装程序,就不必考虑如何安装程序。这些,包管理器还允许用户在新版本可用时,更新其程序。

不幸的是,支持不同的系统意味着,你必须看看这些不同的系统是如何工作的。对于某某来说,这可能跟往存储库内,添加文件一样简单(例如,添加一个 Formula 文件,如给 MacOSbrew使用的这个文件),但对于其他的,您通常需要自己发送补丁,并将您的工具,添加到他们的存储库中。有一些有用的工具,比如cargo-rpmcargo-deb。但是,描述它们是如何工作的,以及如何为这些不同的系统,正确地打包您的工具超出了本章的范围。

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

例如:ripgrep

ripgrep是用 Rust 编写的grep/ack/ag替代。它非常成功,适用于许多操作系统:请看它 REAMDE 文件的“安装”部分

请注意,它列出了几个不同的选项,如何去安装它:它从一个指向包含二进制文件的 GitHub 发行版的链接开始,这样您就可以直接下载它们;然后它列出了,如何使用一组不同的包管理器安装它;最后,您还可以使用cargo install

这似乎是一个很好的主意:不去选择这里介绍的方法,而是从cargo install开始,加上二进制版本,最后开始使用(各个)系统包管理器,来分发工具。

深入的主题

信号处理

像命令行应用程序这样的进程,需要对操作系统发送的信号作出反应。最常见的例子可能是Ctrl +C,通常指示进程终止的信号。要在 Rust 程序中处理信号,您需要考虑如何接收这些信号,以及如何对它们作出反应。

操作系统之间的差异

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

Windows 没有信号。你可以用控制台处理程序定义在事件发生时,执行的回调。还有结构化异常处理,它处理所有类型的系统异常,如除数为零、无效访问异常、堆栈溢出等。

第一步:处理 ctrl+c

这个ctrlc箱子所做的,正是它的名字所暗示的:它允许你对用户按下Ctrl +C,以跨平台的方式。使用箱子的主要方法是:

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

    // ...
}

当然,这并没有那么有帮助:它只打印一条消息,并且不会停止程序。

在一个真实的程序中,最好在信号处理程序中,设置一个变量,然后在程序中的各个地方进行检查。例如,可以在信号处理程序中设置Arc<AtomicBool>(线程之间可共享的布尔值),在热循环中,或者在等待线程时,您会定期检查其值,并在该值变为真时,中断。

处理其他类型的信号

这个ctrlc箱子仅处理Ctrl +C或者,在 UNIX 系统上称为SIGINT(中断信号)。要对更多的 Unix 信号,作出反应,您应该看看信号钩子。 其设计在这篇博客文章上有所描述,并且这是目前社区支持最广泛的库了。

下面是一个简单的例子:

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

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

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

    Ok(())
}

使用通道

您可以使用通道,而不是设置一个变量并让程序的其他部分检查它:您创建一个通道,每当接收到信号时,信号处理程序就向该通道发送一个值。在应用程序代码中,您可以使用此通道与其他通道的联系,作为线程之间的同步桥梁。使用crossbeam-channel箱子,它看起来像这样:

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

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

    Ok(receiver)
}

fn main() -> Result<(), exitfailure::ExitFailure> {
    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,您很可能已经用异步模式和事件驱动设计,编写了应用程序。您可以启用信号钩子的tokio-support功能,而不是直接使用 crossbeam 的 channels。这可以让你在信号钩子的Signals类型上,调用.into_async(),以获取实现了futures::Stream的新类型。

当您在处理第一个 ctrl+c 时,收到另一个 ctrl+c 时要做什么?

大多数用户会按Ctrl +C,然后给你的程序几秒钟退出,或者告诉他们发生了什么。如果那不发生,他们再一次按Ctrl +C。典型的处理行为是让应用程序立即退出。

使用配置文件

处理配置可能很烦人,特别是如果您支持多个操作系统,这些操作系统都有自己存放临时和长期文件的位置。

对此有多种解决方案,其中一些方案的级别较低。

最容易使用的箱子是confy. 它要求您输入应用程序的名称,并要求您通过struct指定配置层级(那是Serialize序列化,Deserialize反序列化),剩下的交给它解决了!

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

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

如果你会放弃可配置性,这当然是非常容易使用的。而如果你确实就要一个简单的配置,这个箱子就是为你准备!

配置环境

退出代码

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

但是,当一个错误发生时,它会变得更加复杂。在真实世界,许多工具(的退出代码)会是1当发生常见故障时。目前当进程恐慌时,Rust 设置的退出代码为101。除此之外,人们在他们的程序中,还做了很多事情。

那么,该怎么办?BSD 生态系统收集了它们退出代码的通用定义(您可以在这里找到它们)。Rust 箱子exitcode提供这些相同的代码,可以在应用程序中使用。有关可能使用的值,请参阅其 API 文档。

一种使用方法是这样的:

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箱子定义的以下级别(按严重性,增序):

  • 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 中,“崩溃”通常是“恐慌(panic)”(即,“崩溃控制”,与“操作系统杀死了进程”有所不同)。默认情况下,当发生紧急情况时,“崩溃处理程序”将向控制台打印一些信息。

例如,如果使用cargo new --bin foo创建一个新的二进制项目,并用panic!("Hello World")替代fn main中的内容,运行程序时会得到:

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

这对你和开发者来说,都是有用的信息。(意外:程序因您的main.rs文件的第2行而崩溃)。但是对于一个,甚至不访问源代码的用户来说,这并不是很有价值。事实上,它很可能只是令人困惑。这就是为什么添加一个定制的崩溃处理程序是一个好的主意,它提供了一个更加注重最终用户的输出。

有一个箱子就是这么做的,它叫做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这样的箱子:

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),并以彩色显示它们是目录。如果您改为将这个输出,经过管道传输到文件或类似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 分隔值)这样的格式非常流行,其中每个记录都在自己的行上,并且每一行包含 tab 分隔的内容。这些基于文本行的简单格式,允许grep能用在像ls这样会输出的工具上。| grep Cargo才不管你的文本是不是来自ls或者文件,它都会逐行过滤。

缺点是你不能用简单调用grep,就筛选ls给你的所有目录。因为,每个目录项,都需要携带额外的数据。

机器的 JSON 输出

tab 分隔值是输出结构化数据的一种简单方法,但它要求另一个程序知道要预想哪个字段(以及顺序),并且很难输出不同类型的消息。例如,假设我们的程序,想向用户发送消息,告诉他们,它当前正在等待下载,然后输出一条消息,描述它得到的数据。这些都可能是非常不同的类型的消息,试图在 TSV 输出中统一它们,需要我们发明一种方法来区分它们。同样的情况还有,当我们想要打印包含两个不同长度项的列表的消息时。

不过,最好选择一种在大多数编程语言/环境中,都易于解析的格式。因此,在过去的几年中,许多应用程序都发展JSON的解析能力。JSON 很简单,几乎每种语言都存在它的解析器,但其强大程度,足以在许多情况下发挥作用。虽然它是一种人类可以读取的文本格式,但许多人也在,解析 JSON 数据,和将数据序列化为 JSON 方面做了许多工作,现已是速度非常快的实现了。

在上面的描述中,我们已经讨论过程序要编写“消息”。这是一种考虑输出的好方法:其实程序不一定只输出一个数据块,而实际上,在运行的时候可能会发出许多不同(类型)的信息。在输出 JSON 时,支持这种方法的一个简单方法,是为每条消息编写一个 JSON 文档,并将每个 JSON 文档放到新的一行(有时,调用下行-分隔 JSON)。这可以让(信息)实现像使用常规的println!一样简单。

下面是一个简单的例子,使用来自serde_jsonjson!宏,用来在您的 Rust 源代码中,快速编写有效的 JSON:

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

下面是输出:

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

(运行cargo-q,能抑制其正常输出。--后面的参数被传递到我们的程序。)

实例: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 文档都是一个包含type字段的对象(map)。这将允许我们为rg编写一个简单前端,读取它们所在的文档,并显示匹配项(以及它们所在的文件)时,即便ripgrep仍在搜索。

对人和机器输出的摘要

convey是一个正在开发的库,它试图让输出消息更容易,以适合人类和机器格式。您定义自己的消息类型,并实现一个Rendertrait(可在宏的帮助下,手动编写,或者使用派生属性)来说明它们应该如何格式化。目前,它支持打印人类输出(包括,自动检测是否应该上色)、写 JSON 文档(可以是stdout或者指向一个文件)或者是兼顾两者。

即使您不使用这个库,编写一个适合您用例的相仿抽象,也是一个好主意。

如何处理流入我们的输入

CLI 应用程序呈现的文档

CLIS 的文档通常包括--help命令,和手册(man)页。

两者都可以在使用clapv3 时自动生成(在未发布的 alpha 中,在编写时),会用到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("head.1").unwrap();
        use std::io::Write;
        out.write_all(man.render().as_bytes()).unwrap();
    }
}

现在,你编译应用程序后,head.1文件就会你项目目录中。

如果你用man打开,就能够欣赏您零成本的文档。