Rust 的运行速度、安全性、单二进制文件输出和跨平台支持使其成为创建命令行程序的绝佳选择,所以我们的项目将创建一个我们自己版本的经典命令行工具:grep
。grep
最简单的使用场景是在特定文件中搜索指定字符串。
初始化项目、读取参数
首先使用 cargo new
初始化一个项目,这个项目需要接收两个参数:文件名和需要搜索的字符串:
$ cargo run searchstring example-filename.txt
我们可以使用 std::env::args
。这个函数返回一个传递给程序的命令行参数的 迭代器(iterator)。迭代器生成一系列的值,可以在迭代器上调用 collect
方法将其转换为一个集合,比如包含所有迭代器产生元素的 vector。
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{:?}", args);
}
注意 std::env::args
在其任何参数包含无效 Unicode 字符时会 panic。如果你需要接受包含无效 Unicode 字符的参数,使用 std::env::args_os
代替。
在获得了参数之后,需要将这两个参数的值保存进变量,这样就可以在程序的余下部分使用这些值,程序的名称占据了 vector 的第一个值 args[0]
,所以要使用 args[1]
,args[2]
来获取文件名字和搜索的字符串:
use std::env;
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);
}
读取文件
使用fs::read_to_string
函数,它接受一个 filename
打开文件,接着返回包含其内容的 Result<String>
。
use std::env;
use std::fs;
fn main() {
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong reading the file");
println!("With text:\n{}", contents);
}
存在的问题
我们的程序这里有四个问题需要修复:
main
现在进行了两个任务:它解析了参数并打开了文件。当函数承担了更多责任,它就更难以推导,更难以测试,并且更难以在不破坏其他部分的情况下做出修改,所以最好能分离出功能以便每个函数就负责一个任务。query
和filename
是程序中的配置变量,随着main
函数的增长,就需要引入更多的变量到作用域中,而当作用域中有更多的变量时,将更难以追踪每个变量的目的。最好能将配置变量组织进一个结构,这样就能使他们的目的更明确了。- 读取文件失败的原因有多种:例如文件不存在,或者没有打开此文件的权限。目前,无论处于何种情况,我们只是打印出“文件读取出现错误”的信息,这并没有给予使用者具体的信息!
- 我们不停地使用
expect
来处理不同的错误,如果用户没有指定足够的参数来运行程序,他们会从 Rust 得到index out of bounds
错误,而这并不能明确地解释问题
改进模块性
对此,我们可以将程序进行拆分:
- 将程序拆分成 main.rs 和 lib.rs 并将程序的逻辑放入 lib.rs 中。
- 当命令行解析逻辑比较小时,可以保留在 main.rs 中。
- 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs 中。
fn main() {
let args: Vec<String> = env::args().collect();
let (query, filename) = parse_config(&args);
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let filename = &args[2];
(query, filename)
}
之后,我们可以将这两个值放入一个结构体并给每个字段一个有意义的名字。这会让未来的维护者更容易理解不同的值如何相互关联以及他们的目的:
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file");
// --snip--
}
struct Config {
query: String,
filename: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config { query, filename }
}
所以现在 parse_config
函数的目的是创建一个 Config
实例,我们可以将 parse_config
从一个普通函数变为一个叫做 new
的与结构体关联的函数。可以像标准库中的 String
调用 String::new
来创建一个该类型的实例那样,将 parse_config
变为一个与 Config
关联的 new
函数:
这里将 main
中调用 parse_config
的地方更新为调用 Config::new
。我们将 parse_config
的名字改为 new
并将其移动到 impl
块中,这使得 new
函数与 Config
相关联。再次尝试编译并确保它可以工作。
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
// --snip--
}
// --snip--
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config { query, filename }
}
}
错误信息处理
在 new
函数中增加了一个检查在访问索引 1
和 2
之前检查 slice 是否足够长,我们使用一个更好的错误信息 panic 而不是 index out of bounds
信息:
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
之后我们选择使用 Result
值来处理 Config
,它在成功时会包含一个 Config
的实例,而在错误时会描述问题
impl Config {
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 })
}
}
为了处理错误情况并打印一个对用户友好的信息,需要手动实现原先由 panic!
负责的工作,即以非零错误码退出命令行工具的工作。非零的退出状态是一个惯例信号,用来告诉调用程序的进程:该程序以错误状态退出了。
use std::process;
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);
});
// --snip--
unwrap_or_else
可以进行一些自定义的非 panic!
的错误处理。当 Result
是 Ok
时,这个方法的行为类似于 unwrap
:它返回 Ok
内部封装的值。然而,当其值是 Err
时,该方法会调用一个 闭包(closure)unwrap_or_else
会将 Err
的内部值即 not enough arguments
静态字符串的情况,传递给闭包中位于两道竖线间的参数 err
。闭包中的代码在其运行时可以使用这个 err
值。
在错误的情况闭包中将被运行的代码只有两行:我们打印出了 err
值,接着调用了 std::process::exit
。process::exit
会立即停止程序并将传递给它的数字作为退出状态码。
main
我们将提取一个叫做 run
的函数来存放目前 main
函数中不属于设置配置或处理错误的所有逻辑。现在 run
函数包含了 main
中从读取文件开始的剩余的所有逻辑,这样不再通过 expect
允许程序 panic,run
函数将会在出错时返回一个 Result<T, E>
。这让我们进一步以一种对用户友好的方式统一 main
中的错误处理。
fn main() {
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
run(config);
}
use std::error::Error;
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
// --snip--
我们使用 if let
来检查 run
是否返回一个 Err
值,不同于 unwrap_or_else
,并在出错时调用 process::exit(1)
。run
并不返回像 Config::new
返回的 Config
实例那样需要 unwrap
的值。因为 run
在成功时返回 ()
,而我们只关心检测错误,所以并不需要 unwrap_or_else
来返回未封装的值,因为它只会是 ()
。
fn main() {
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
if let Err(e) = run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
拆分到库
我们将所有不是 main
函数的代码从 src/main.rs 移动到新文件 src/lib.rs 中:
// src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
// --snip--
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
// --snip--
}
//src/main.rs
use std::env;
use std::process;
use minigrep::Config;
fn main() {
// --snip--
if let Err(e) = minigrep::run(config) {
// --snip--
}
}
编写测试
现在我们将逻辑提取到了 src/lib.rs 并将所有的参数解析和错误处理留在了 src/main.rs 中,为代码的核心功能编写测试将更加容易。我们可以直接使用多种参数调用函数并检查返回值而无需从命令行运行二进制文件了
我们将遵循测试驱动开发(Test Driven Development, TDD)的模式来逐步测试。这是一个软件开发技术,它遵循如下步骤:
- 编写一个失败的测试,并运行它以确保它失败的原因是你所期望的。
- 编写或修改足够的代码来使新的测试通过。
- 重构刚刚增加或修改的代码,并确保测试仍然能通过。
- 从步骤 1 开始重复!
#[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));
}
}
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
}
需要在 search
的签名中定义一个显式生命周期 'a
并用于 contents
参数和返回值,现在 search
函数是可以工作并测试通过了的,我们需要实际在 run
函数中调用 search
。需要将 config.query
值和 run
从文件中读取的 contents
传递给 search
函数。接着 run
会打印出 search
返回的每一行:
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(())
}
环境变量
之后,我们将增加一个额外的功能,用户可以通过设置环境变量来设置搜索是否是大小写敏感的 。我们希望增加一个新函数 search_case_insensitive
,并将会在设置了环境变量时调用它。
按照我们的开发规范,先写一个TDD测试
#[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)
);
}
}
之后编写search_case_insensitive
函数,与 search
函数基本相同。唯一的区别是它会将 query
变量和每一 line
都变为小写,这样不管输入参数是大写还是小写,在检查该行是否包含查询字符串时都会是小写。
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
}
之后,我们将在 Config
结构体中增加一个配置项来切换大小写敏感和大小写不敏感搜索。
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
接着我们需要 run
函数检查 case_sensitive
字段的值并使用它来决定是否调用 search
函数或 search_case_insensitive
函数
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(())
}
最后需要实际检查环境变量。处理环境变量的函数位于标准库的 env
模块中,接着在 Config::new
中使用 env
模块的 var
方法来检查一个叫做 CASE_INSENSITIVE
的环境变量,env::var
返回一个 Result
,它在环境变量被设置时返回包含其值的 Ok
成员,并在环境变量未被设置时返回 Err
成员。
use std::env;
// --snip--
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,
})
}
}
如果你使用 PowerShell,则需要用两个命令来分别设置环境变量并运行程序:
PS> $Env:CASE_INSENSITIVE=1; cargo run to poem.txt
输出错误
目前为止,我们将所有的输出都通过 println!
写到了终端。大部分终端都提供了两种输出:标准输出(standard output,stdout
)对应一般信息,标准错误(standard error,stderr
)则用于错误信息。
可以通过将标准输出流重定向到一个文件同时有意产生一个错误来做到这一点。我们没有重定向标准错误流,所以任何发送到标准错误的内容将会继续显示在屏幕上。
我们通过 >
和文件名 output.txt 来运行程序,我们期望重定向标准输出流到该文件中:
$ cargo run > output.txt
标准库提供了 eprintln!
宏来打印到标准错误流,所以将两个调用 println!
打印错误信息的位置替换为 eprintln!
:
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {}", e);
process::exit(1);
}
}
将 println!
改为 eprintln!
之后,让我们再次尝试用同样的方式运行程序,不使用任何参数并通过 >
重定向标准输出:
$ cargo run > output.txt
Problem parsing arguments: not enough arguments
如果使用不会造成错误的参数再次运行程序,不过仍然将标准输出重定向到一个文件,像这样:
$ cargo run to poem.txt > output.txt
我们并不会在终端看到任何输出,同时 output.txt
将会包含其结果:
Are you nobody, too?
How dreary to be somebody!