Rust 一个 I/O 项目:构建一个命令行程序

本篇在原文基础上有删减和添加,增加了一些细节内容,原文请参考一个 I/O 项目:构建一个命令行程序

接受命令行参数

使用** cargo new** 新建一个项目,命名为 minigrep

cargo new minigrep

让 minigrep 能够接受两个命令行参数:文件路径要搜索的字符串,使用 cargo run 来运行程序的命令如下:

cargo run -- searchstring example-filename.txt

读取参数值

需要使用 Rust 标准库提供的函数 std::env::args:函数返回一个传递给程序的命令行参数的 迭代器(iterator)
这里只需理解迭代器的两个细节:迭代器生成一系列的值,可以在迭代器上调用 collect 方法将其转换为一个集合。

将命令行参数收集到一个 vector 中并打印出来:

//使用 use 语句来将 std::env 模块引入作用域以便可以使用它的 args 函数
use std::env;

fn main() {
    //调用了 env::args,并立即使用 collect 来创建了一个包含迭代器所有值的 vector
    //显式注明 args 的类型来指定我们需要一个字符串 vector
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}

注: std::env::args 函数被嵌套进了两层模块中,当所需函数嵌套了多于一层模块时通常将父模块引入作用域而不是其自身,否则容易被错认成一个定义于当前模块的函数

args 函数和无效的 Unicode
注意 std::env::args 在其任何参数包含无效 Unicode 字符时会 panic,接受包含无效 Unicode 字符的参数需要使用 std::env::args_os 代替,这个函数返回 OsString 值而不是 String 值。

尝试分别用两种方式(不包含参数和包含参数)运行代码:

cargo run

[src\main.rs:7] args = [
    "target\\debug\\minigrep.exe",
]

cargo run -- needle haystack

[src\main.rs:7] args = [
    "target\\debug\\minigrep.exe",
    "needle",
    "haystack",
]

注: vector 的第一个值是二进制文件的名称。这与 C 中的参数列表的行为相匹配,让程序使用在执行时调用它们的名称。

将参数值保存进变量

创建变量来存放查询参数和文件路径参数:

use std::env;

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

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

    println!("Searching for {}", query);
    println!("In file {}", file_path);
}

使用参数 test 和 sample.txt 再次运行这个程序:

cargo run -- test sample.txt

Searching for test
In file sample.txt

读取文件

在项目根目录创建一个文件 测试.txt,内容如下:

张三
李四
AND
王二
麻子

觉得麻烦的可以进入项目根目录,直接在 powershell 终端创建:

New-Item -Path . -Name "测试.txt" -ItemType "file" -Force
"张三
李四
AND
王二
麻子" | 
Out-File -FilePath .\测试.txt -Encoding utf8

创建完这个文件之后,修改 src/main.rs 并增加如下内容:

use std::env;
use std::fs;  //引入标准库中的文件读取的相关部分

fn main() {
    // --snip--
    println!("In file {}", file_path);

    //读取第二个参数所指定的文件内容
    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

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

随意指定一个字符串作为第一个命令行参数而将 测试.txt 文件将作为第二个参数:

cargo run -- 王二 测试.txt 

Searching for 王二
In file 测试.txt
With text:
张三
李四
AND
王二
麻子

重构改进模块性和错误处理

现有的程序有四个问题需要修复,都与程序的组织方式如何处理潜在错误有关:

  • main 现在进行了两个任务:它解析了参数并打开了文件,最好能分离出功能以便每个函数就负责一个任务
  • query 和 file_path 是程序中的配置变量,而像 contents 则用来执行程序逻辑,最好能将配置变量组织进一个结构使它们的目的更明确
  • 如果打开文件失败则使用 expect 来打印出错误信息,不过这个错误信息并没有给予使用者具体的信息
  • 如果所有的错误处理都位于一处,将来需要修改错误处理逻辑时就只需要考虑这一处代码,也有助于确保错误信息是有意义的。

二进制项目的关注分离

Rust 社区开发出一类在 main 函数开始变得庞大时进行二进制程序的关注分离的指导,步骤如下:

  • 将程序拆分成 main.rslib.rs 并将程序的逻辑放入 lib.rs 中。
  • 当命令行解析逻辑比较小时,可以保留在 main.rs 中。
  • 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs 中。

经过这些过程之后保留在 main 函数中的责任应该被限制为:

  • 使用参数值调用命令行解析逻辑
  • 设置任何其他的配置
  • 调用 lib.rs 中的 run 函数
  • 如果 run 返回错误,则处理这个错误

这个模式的一切就是为了关注分离:main.rs 处理程序运行,而 lib.rs 处理所有的真正的任务逻辑,仅仅保留在 main.rs 中的代码将足够小以便阅读就可以验证其正确性。

提取参数解析器

将解析参数的功能提取到一个 main 将会调用的函数中,为将命令行解析逻辑移动到 src/lib.rs 中做准备,从 main 中提取出 parse_config 函数:

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

    //main 不再负责处理命令行参数与变量如何对应
    let (query, file_path) = parse_config(&args);

    // --snip--
}

//将整个 vector 传递给函数,函数决定哪个参数该放入哪个变量的逻辑
fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

组合配置值

可以将这两个值放入一个结构体并给每个字段一个有意义的名字,这会让未来的维护者更容易理解不同的值如何相互关联以及它们的目的。

注意:一些同学将这种在复杂类型更为合适的场景下使用基本类型的反模式称为 基本类型偏执(primitive obsession)

重构 parse_config 返回一个 Config 结构体实例:

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

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

main 中的 args 变量是参数值的所有者并只允许 parse_config 函数借用它们,这意味着如果 Config 尝试获取 args 中值的所有权将违反 Rust 的借用规则,最简单但有些不太高效的方式是调用这些值的 clone 方法。

使用 clone 的权衡取舍

由于其运行时消耗,许多 Rustacean 之间有一个趋势是倾向于避免使用 clone 来解决所有权问题。不过现在,复制一些字符串来取得进展是没有问题的,因为只有一次拷贝,而且字符串都比较短。

创建一个 Config 的构造函数

现在 parse_config 函数的目的是创建一个 Config 实例,可以将 parse_config 从一个普通函数变为一个叫做 new 的与结构体关联的函数,将 parse_config 变为 Config::new

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 file_path = args[2].clone();

        Config { query, file_path }
    }
}

修复错误处理

改善错误信息

在 new 函数中增加了一个检查在访问索引 1 和 2 之前检查 slice 是否足够长。如果 slice 不够长,程序会打印一个更好的错误信息并 panic:

    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

再次不带任何参数运行程序并查看错误内容:

cargo run

thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

从 new 中返回 Result 而不是调用 panic!

可以选择返回一个 Result 值,它在成功时会包含一个 Config 的实例而在错误时会描述问题。还需要把函数名从 new 改为 build ,因为许多程序员希望 new 函数永远不会失败。
从 Config::build 中返回 Result:

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

通过让 Config::build 返回一个 Err 值,这就允许 main 函数处理 build 函数返回的 Result 值并在出现错误的情况更明确的结束进程。

调用 Config::build 并处理错误

更新 main 函数来处理现在 Config::build 返回的 Result,另外还需要手动实现原先由 panic!负责的工作,即以非零错误码退出命令行工具的工作
如果新建 Config 失败则使用错误码退出,修改内容如下:

use std::process; //从标准库中导入 process

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

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        //process::exit 会立即停止程序并将传递给它的数字作为退出状态码
        process::exit(1); 
    });

    // --snip--

unwrap_or_else 方法定义于标准库的 Result<T, E> 上,使用 unwrap_or_else 可以进行一些自定义的非 panic! 的错误处理。

当 Result 是 Ok 时,这个方法的行为类似于 unwrap 返回 Ok 内部封装的值。当其值是 Err 时,该方法会调用一个 闭包(closure),也就是一个作为参数传递给 unwrap_or_else 的匿名函数

从 main 提取逻辑

提取一个叫做 run 的函数来存放目前 main 函数中不属于设置配置或处理错误的所有逻辑,目前只进行小的增量式的提取函数的改进:

fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

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

// --snip--

从 run 函数中返回错误

通过将剩余的逻辑分离进 run 函数而不是留在 main 中,改进错误处理将在出错时返回一个 Result<T, E> ,修改如下:

//使用 use 语句将 std::error::Error 引入作用域
use std::error::Error;

// --snip--

//返回类型变为 Result<(), Box<dyn Error>>
fn run(config: Config) -> Result<(), Box<dyn Error>> {
    //去掉 expect 调用并替换为 ?
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");
    
    //成功时返回一个 Ok 值
    // () 表明调用 run 函数只是为了它的副作用,函数并没有返回什么有意义的值
    Ok(())
}

处理 main 中 run 返回的错误

使用 if let 来检查 run 是否返回一个 Err 值,并在出错时调用 process::exit(1)

fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

因为 run 在成功时返回 (),所以并不需要 unwrap_or_else 来返回未封装的值,因为它只会是 ()。

将代码拆分到库 crate

将所有不是 main 函数的代码从 src/main.rs 移动到新文件 src/lib.rs 中:

  • run 函数定义
  • 相关的 use 语句
  • Config 的定义
  • Config::build 函数定义

先在 powershell 终端创建 src/lib.rs:

New-Item -Path .\src -Name "lib.rs" -ItemType "file" -Force

将 Config 和 run 移动到 src/lib.rs:

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

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

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
}

在 src/main.rs 中将移动到 src/lib.rs 的代码引入二进制 crate 的作用域中:

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

use minigrep::Config;

fn main() {
    // --snip--
    if let Err(e) = minigrep::run(config) {
        // --snip--
    }
}

添加了一行 use minigrep::Config,它将 Config 类型引入作用域,并使用 crate 名称作为 run 函数的前缀

采用测试驱动开发完善库的功能

遵循测试驱动开发(Test Driven Development, TDD)的模式来逐步增加 minigrep 的搜索逻辑,步骤如下:

  • 编写一个失败的测试,并运行它以确保它失败的原因是你所期望的。
  • 编写或修改足够的代码来使新的测试通过。
  • 重构刚刚增加或修改的代码,并确保测试仍然能通过。
  • 从步骤 1 开始重复!

编写失败测试

去掉 src/lib.rs 和 src/main.rs 中用于检查程序行为的 println! 语句,src/lib.rs 中增加一个 test 模块和一个测试函数。
测试函数指定了 search 函数期望拥有的行为:它会获取一个需要查询的字符串和用来查询的文本,并只会返回包含请求的文本行。

创建一个期望的 search 函数的失败测试:

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

注意:双引号之后的反斜杠,这告诉 Rust 不要在字符串字面值内容的开头加入换行符

根据 TDD 的原则,增加足够的代码来使其能够编译:一个总是会返回空 vector 的 search 函数定义。刚好足够使测试通过编译的 search 函数定义

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

注意:需要在 search 的签名中定义一个显式生命周期 'a 并用于 contents 参数和返回值,表明返回的 vector 中应该包含引用参数 contents(而不是参数query)slice 的字符串 slice。如果不用生命周期编译的话,将编译出错。

cargo test 运行测试,与预期一样测试失败了。

编写使测试通过的代码

修复并实现 search,程序:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    //存储匹配的行
    let mut results = Vec::new();

    //使用 lines 方法遍历每一行
    for line in contents.lines() {
        //用查询字符串搜索每一行
        if line.contains(query) {
            //调用 push 方法在 vector 中存放 line
            results.push(line);
        }
    }

    results
}
  • lines:一行一行遍历字符串的方法
  • contains:检查当前行是否包含查询字符串的功能

在 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.file_path)?;

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

    Ok(())
}

现在整个程序应该可以工作了,执行命令测试一下:

cargo run -- 王二 测试.txt 

处理环境变量

增加一个额外的功能来改进 minigrep:用户可以通过设置环境变量来设置搜索是否是大小写敏感的。

编写一个大小写不敏感 search 函数的失败测试

增加一个新函数 search_case_insensitive,并将会在环境变量有值时调用它。将为新的大小写不敏感搜索函数新增一个测试函数,并将老的测试函数从 one_result 改名为 case_sensitive 来更清楚的表明这两个测试的区别:

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

    //复用测试数据
    fn get_contents() -> &'static str {
        "\
    Rust:
    safe, fast, productive.
    Pick three.
    Duct tape."
    }
    
    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = get_contents();
        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
    
    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = get_contents();
        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

实现 search_case_insensitive 函数

定义 search_case_insensitive 函数,它在比较查询和每一行之前将它们都转换为小写:

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    //将 query 字符串转换为小写,并将其覆盖到同名的变量中
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        //query 现在是一个 String 而不是字符串 slice
        //需要增加一个 & 因为 contains 的签名被定义为获取一个字符串 slice
        //对每一 line 都调用 to_lowercase 将其转为小写
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

注:虽然 to_lowercase 可以处理基本的 Unicode,但它不是 100% 准确,目前足够了。

在 run 函数中实际调用新函数

在 Config 结构体中增加一个配置项来切换大小写敏感和大小写不敏感搜索:

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

根据 config.ignore_case 的值调用 search 或 search_case_insensitive:

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

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

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

    Ok(())
}

使用标准库 env 模块的 var 方法来检查叫做 IGNORE_CASE 的环境变量:

//引入 env
use std::env;
// --snip--

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

env::var 返回一个 Result,它在环境变量被设置时返回包含其值的 Ok 成员,并在环境变量未被设置时返回 Err 成员。

首先不设置环境变量并使用查询 and 运行程序:

cargo run and 测试.txt

将 IGNORE_CASE 设置为 1 并仍使用相同的查询 and :

$Env:IGNORE_CASE=1; cargo run and 测试.txt

可以通过 Remove-Item 命令来取消设置:

Remove-Item Env:IGNORE_CASE

将错误信息输出到标准错误而不是标准输出

大部分终端都提供了两种输出:标准输出(standard output,stdout)对应一般信息,标准错误(standard error,stderr)则用于错误信息,区别在于允许用户选择将程序正常输出定向到一个文件中并仍将错误信息打印到屏幕上。

检查错误应该写入何处

命令行程序被期望将错误信息发送到标准错误流,这样即便选择将标准输出流重定向到文件中时仍然能看到错误信息。

通过 > 和文件路径 output.txt 来运行程序:

cargo run > output.txt

错误信息被写入了文件中,output.txt 内容如下:

Problem parsing arguments: not enough arguments

将错误打印到标准错误

标准库提供了 eprintln! 宏来打印到标准错误流,所以将两个调用 println! 打印错误信息的位置替换为 eprintln!:

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

    let config = Config::build(&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);
    }
}

现在可以看到了屏幕上的错误信息,同时 output.txt 里什么也没有,这正是命令行程序所期望的行为。

热门相关:扑倒老公大人:龙总,我爱你!   暖君   布衣官道   恶明   恶明