前言
经过前两节,我们的minigrep已经可以成功的打开指定的文本文件,并且读取其中的内容。
考虑到我们的程序后面会增加更多的功能,一些程序上的问题就出现了,如我们一直用expect输出错误信息,但是无法知道错误是如何出错的,出错会有很多原因,比如文件不存在,或者没有权限,等等其他问题,我们要重构项目,以达到优化项目的模块和对错误的处理。
一、任务目的
目前来说,项目可能存在一下四个问题,影响后续的程序,
- main现在只是做解析了参数并打开了文件,这对于一个小的函数来说是没有问题的,但是随着软件功能不断壮大,函数就变得复杂起来,变得难以调试和修改,更不利于阅读,因此要分理出多个函数,每个函数负责一个功能。
- query 和 filename 是程序中的配置变量, contents 用来执行程序逻辑。随着main函数变得复杂,会有更多的变量,就会导致难以清楚每个变量的意义。因此将配置变量组织进一个结构,明确变量的目的。
- 如果文件打开失败,总是提示Something went wrong reading the file,但是文件打开失败会有多种情况,就比如文件不存在,没有文件的权限等。因此我们要尽量给出详细的错误信息。
- 我们自己是知道程序有两个参数的,但是如果别人不知道要传两个参数,Rust就会报错,我们的程序就不够健壮。考虑将错误处理放在一块,优化错误提示。
为此,我们需要重构我们的项目。
二、项目拆分
Rust 社区中大型的项目拆分有着共同的原则,
- 将程序拆分成 main.rs 和 lib.rs 并将程序的逻辑放入 lib.rs 中。
- 当命令行解析逻辑比较小时,可以保留在 main.rs 中。
- 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs 中。
经过以上步骤,main的功能就应该是,
- 使用参数值调用命令行解析逻辑
- 设置任何其他的配置
- 调用 lib.rs 中的 run 函数
- 如果 run 返回错误,则处理这个错误
以上的目的就是为了实现,main,rs专门处理程序运行,lib.rs处理功能的逻辑。
三、重构项目
接下来我们就遵循以上原则对项目进行拆分。
提取参数解析器
新建个函数parse_config,专门用来拆分获取到的参数,并且返回query和filename
fn parse_config(args: &[String]) -> (&str, &str) { let query = &args[1]; let filename = &args[2]; (query, filename) }
接下来我们在main函数中使用parse_config来获取程序所需要的参数,下面注释了的两行代码是之前用来获取query 和filename 的,我们把它注释了,然后加入上面的let (query, filename) = parse_config(&args);
来获取参数。
fn main() { let args: Vec<String> = env::args().collect(); println!("{:#?}", args); let (query, filename) = parse_config(&args); // let query = &args[1]; // let filename = &args[2]; // 其他代码 }
这个方法现在看起来是有点大材小用的,然而并不是,这个会在定位问题的时候给予你极大的方便。
导出独立配置
接下来继续对parse_config进行改进,这个函数返回了一个元组类型的数据,为了正确抽象参数,给维护带来方便,我们对参数的返回值进行抽离,让其有可见的意义。
新建一个结构体Config,里面的字段就是我们的参数,
struct Config { query: String, filename: String, }
然后修改parse_config函数,
fn parse_config(args: &[String]) -> Config { let query = args[1].clone(); let filename = args[2].clone(); Config { query, filename } }
这里我们采用clone方法来复制参数的完整数据,这样Config实例就是独立得了,使得代码显得更加直白因为无需管理引用的生命周期,但是会比储存字符串数据的引用消耗更多的时间和内存。在这种情况下牺牲一小部分性能来换取简洁性的取舍是值得的。
接下来我们再次对Config 进行改造,我们使用标准库时,都会使用new来创造实例,为了符合我们的编程习惯,我们为其写一个构造函数,首先将parse_config函数改名成new,然后移入impl中,
impl Config { fn new(args: &[String]) -> Config { let query = args[1].clone(); let filename = args[2].clone(); Config { query, filename } } }
然后修改在main中的调用,这样我们以后就可以使用config加点来调用了。
// 其他代码 let config = Config::new(&args); // let query = &args[1]; // let filename = &args[2]; // 其他代码
优化错误处理
当程序接收参数个数不等于2时,我们的程序就会报错,错误信息为
index out of bounds: the len is 1 but the index is 1
这种错误是程序的错误,作为一个用户是难以理解的。为此我们在读取参数时判断参数个数,优化这里的错误,使得人可以直观的看到是什么错误。
在Config的new中进行判断参数个数,
// 其他代码 fn new(args: &[String]) -> Config { if args.len() < 3 { panic!("参数个数不足"); } // 其他代码
在这里返回了panic,程序就会直接退出,这样的错误提示确实很明显了,但不是最好的,因为他还会输出一些调试信息,导致对于用户来讲是不够友好的,因此我们考虑使用Result ,我们对impl做以下更改
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 }) } }
现在修改main函数
use std::process; fn main() { let args: Vec<String> = env::args().collect(); let config = Config::new(&args).unwrap_or_else(|err| { println!("参数拆分错误: {}", err); process::exit(1); }); // 其他代码
现在我们测试错误,这个错误提示就很具体了。
unwrap_or_else,它定义于标准库的 Result<T, E> 上。使用 unwrap_or_else 可以进行一些自定义的非 panic! 的错误处理。当 Result 是 Ok 时,这个方法的行为类似于 unwrap:它返回 Ok 内部封装的值。然而,当其值是 Err 时,该方法会调用一个 闭包(closure),也就是一个我们定义的作为参数传递给 unwrap_or_else 的匿名函数。
我们使用了标准库中的process来处理程序的退出,导入了std::process,然后调用process::exit,并且传入状态码会立即停止程序并将传递给它的数字作为退出状态码。
抽离读取文件
我们将读取文件部分抽离出来,成为一个函数run,传入对应的config,进行文件读取
fn run(config: Config) { let contents = fs::read_to_string(config.filename) .expect("读取文件失败"); println!("文件内容:\n{}", contents); }
为了使错误提示变得更加友好,对run继续进行修改,让其返回Result。
这里我们做了三个明显的修改。首先,将 run 函数的返回类型变为 Result<(), Box<dyn Error>>
。之前这个函数返回 unit 类型 (),现在它仍然保持作为 Ok 时的返回值。
对于错误类型,使用了 trait 对象 Box<dyn Error>
,这部分内容会在以后说明。你只需知道 Box<dyn Error>
意味着函数会返回实现了 Error trait 的类型,不过无需指定具体将会返回的值的类型。
第二个改变是去掉了 expect 调用并替换为 ?。不同于遇到错误就 panic!,? 会从函数中返回错误值并让调用者来处理它。
第三个修改是现在成功时这个函数会返回一个 Ok 值。
fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.filename)?; println!("文件内容:\n{}", contents); Ok(()) }
然后在main中处理这个错误,因为这里是只关心出错的情况,所以使用 if let来处理。
fn main() { // 其他代码 if let Err(e) = run(config) { println!("程序运行出错: {}", e); process::exit(1); } }
将代码拆分到crate
新建文件lib.rs,将main中的 Config 和 run 移动到 src/lib.rs,
注意
lib.rs 里面的函数和结构体都要用pub关键字修饰
use std::fs; use std::error::Error; pub fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.filename)?; println!("文件内容:\n{}", contents); Ok(()) } pub struct Config { query: String, filename: String, } impl Config { pub fn new(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("参数个数不足"); } let query = args[1].clone(); let filename = args[2].clone(); Ok(Config { query, filename }) } }
然后修改main.rs,主要是添加了use minigrep::Config
,这样使用的时候就可以使用minigrep来调用lib.rs中的run了,同时也可以直接调用其中的Config。
use std::{env, process}; use minigrep::Config; fn main() { let args: Vec<String> = env::args().collect(); println!("{:#?}", args); let config = Config::new(&args).unwrap_or_else(|err| { println!("参数拆分错误: {}", err); process::exit(1); }); if let Err(e) = minigrep::run(config) { println!("程序运行出错: {}", e); process::exit(1); } }
总结
通过本小节,你已经了解了如何对项目进行拆分,如何优雅的输出错误,并且将项目拆分到crate。虽然本节工作量大,但是对后续开发的好处也是非常大的,为将来的成功打下了基础。
完整代码
main.rs
use std::{env, process}; use minigrep::Config; fn main() { let args: Vec<String> = env::args().collect(); println!("{:#?}", args); let config = Config::new(&args).unwrap_or_else(|err| { println!("参数拆分错误: {}", err); process::exit(1); }); if let Err(e) = minigrep::run(config) { println!("程序运行出错: {}", e); process::exit(1); } }
lib.rs
use std::fs; use std::error::Error; pub fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.filename)?; println!("文件内容:\n{}", contents); Ok(()) } pub struct Config { query: String, filename: String, } impl Config { pub fn new(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("参数个数不足"); } let query = args[1].clone(); let filename = args[2].clone(); Ok(Config { query, filename }) } }