0%

Rust 权威指南(11):I/O 项目:编写一个命令行程序

这里将开发一个能够和文件系统交互并处理命令行输入、输出的工具。Rust 非常适合编写命令行工具,因为它具有快速、安全、跨平台以及产出物为单一二进制文件的特点。

接收命令行参数

首先创建一个新的项目:

1
cargo new minigrep

为了使得 minigrep 能够读取传递给它的命令行参数值,需要使用 Rust 标准库提供的 std::env::args 函数,它返回一个传递给 minigrep 的命令行参数迭代器,通过迭代器的 collect 方法可以生成一个包含所有产出值的集合(可创建多种类型的集合,例如动态数组)。

1
2
3
4
5
6
use std::env;

fn main() {
let args: Vec<String> = env::args().collect();
println!("{:?}", args);
}
1
2
3
4
5
6
7
8
9
# cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/minigrep`
["target/debug/minigrep"]

# cargo run 1 2
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/minigrep 1 2`
["target/debug/minigrep", "1", "2"]

需要注意 std::env::args 函数会因为命令行参数中包含非法 Unicode 字符而发生 panic,如果需要处理 Unicode 字符,需要使用 std::env::args_os 函数。

读取文件

如下继续添加文件读取代码:

  • 使用 std::fs 模块来处理文件相关事务
  • args[0]是程序名,而 args[1] 才是第一个命令行参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::env;
use std::fs;

fn main() {
let args: Vec<String> = env::args().collect();

let query = &args[1];
let filename = &args[2];

println!("searching for {}", query);
println!("In file {}", filename);

let contents = fs::read_to_string(filename)
.expect("something wrong in reading file");

println!("With text:\n{}", contents);
}

重构代码以增强模块化程度和错误处理能力

很多二进制项目都面临同样的组织结构问题:将过多的功能、过多的任务放到了 main 函数中。为此 Rust 社区开发了一套为 将会逐渐臃肿的二进制程序进行关注点分离 的指导性原则:

  • 将程序拆分为 main.rslib.rs,将实际业务逻辑放入 lib.rs
  • 当命令行解析逻辑相对简单时,放入 main.rs 也无妨
  • 当命令行解析逻辑变得复杂时,需要将它从 main.rs 提取到 lib.rs

拆分之后,保留在 main 中功能应当只有:

  • 调用命令行解析的代码处理参数值
  • 准备所有其他配置
  • 调用 lib.rs 中的 run 函数
  • 处理 run 函数可能出现的错误

这样,main.rs 负责运行程序,而 lib.rs 则负责处理所有真正的业务逻辑。保留在 main.rs 的代码量应该小到能够直接通过阅读来进行正确性检查。

如下对代码进行了重构。首先对 main.rs 进行了修改,主要重构了命令行参数解析的逻辑,这也为接下来把它拆分到 lib.rs 打下基础,同时也增加了错误处理能力。最后从 main 中分离逻辑:把 main 函数中除了配置解析和错误处理之外的所有逻辑都提取到单独的 run 函数中。

如下是分离后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/lib.rs

use std::fs;
use std::error::Error;

pub struct Config {
query: String,
filename: String,
}

impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();

Ok(Config{query, filename})
}
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;

println!("With text:\n{}", contents);
Ok(())
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
let args: Vec<String> = env::args().collect();

let config = Config::new(&args).unwrap_or_else( |err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});

if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
  • 我们倾向于使用 panic! 来暴露程序内部问题而非用法问题,因此 Config::new() 用 Result 类型来表明当前的参数是否合法
  • 使用标准库的 Result<T, E>unwrap_or_else() 方法:当 Result 的值是 Ok 时,该方法的行为与 unwrap 相同,即返回 Ok 中的值。但是当值为 Err 时,该方法会运行闭包中的代码。此时 Err 中的值会传递给闭包中相应的参数(即 err)
  • Box<dyn Error> 意味着这是一个实现了 Error trait 的类型,但是并不需要关心指定具体的类型是什么。这意味着我们可以在不同的错误场景下返回不同的错误类型,dyn 关键字也即表达动态的含义
  • ? 可以将错误值返回给函数的调用者来进行处理
  • 使用 if let 来检查 run() 的返回值,因为我们只需要关注错误的情形
  • 为了将代码包中的 Config 类型引入二进制包的作用域中,使用 use minigrep::Config

使用测试驱动开发来编写库功能

接下来会按照测试驱动开发(test-driven development, TDD)的流程来为 minigrep 程序添加搜索逻辑,TDD 通常遵循如下步骤:

  1. 编写一个会失败的测试,运行该测试,确保它会如期运行失败
  2. 编写代码让新测试通过
  3. 在保证测试始终通过的前提下重构刚刚编写的代码
  4. 返回步骤 1,进行下一轮开发

如下在 src/lib.rs 中添加了 search() 代码以及相应的测试的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();

for line in contents.lines() {
if line.contains(query) {
results.push(line)
}
}

results
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
";

assert_eq!(
vec!["safe, fast, productive."],
search(query, contents)
);
}
}

run() 函数中新增对 search() 的调用:

1
2
3
4
5
6
7
8
9
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;

for line in search(&config.query, &contents) {
println!("{}", line);
}

Ok(())
}

处理环境变量

接下来继续完善 minigrep:用户可以通过设置环境变量来进行不区分大小写的搜索。如下是新的 src/lib.rs 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
use std::fs;
use std::error::Error;
use std::env;

pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}

impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

Ok(Config{query, filename, case_sensitive})
}
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;

let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};

for line in results {
println!("{}", line);
}

Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();

for line in contents.lines() {
if line.contains(query) {
results.push(line)
}
}

results
}

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();

for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line)
}
}

results
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.
";

assert_eq!(
vec!["safe, fast, productive."],
search(query, contents)
);
}

#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.
";

assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}

以上程序使用了 std::env 模块来处理环境变量。

将错误提示信息打印到标准错误而不是标准输出

大多数终端都提供两种输出:用于输出一般信息的标准输出(stdout),以及用于输出错误提示信息的标准错误(stderr)。这种区分可以让用户将正常输出重定向到文件的同时仍然将错误提示信息打印到屏幕上。

println! 宏只能用来打印到标准输出,而 eprintln! 宏则用来向标准错误打印信息。我们需要合理地使用标准输出和标准错误来区分正常结果和错误提示信息。