Rust 项目实战:命令行搜索工具 grep

Rust 项目实战:命令行搜索工具 grep

Rust 项目实战:命令行搜索工具 grep

功能介绍

命令行搜索工具 grep 在指定的文件中搜索指定的字符串。为此,grep 接受一个文件路径和一个字符串作为参数。然后它读取文件,在文件中找到包含 string 参数的行,并打印这些行。

在此过程中,我们将展示如何使我们的命令行工具使用许多其他命令行工具使用的终端特性。我们将读取环境变量的值,以允许用户配置工具的行为。我们还将错误消息打印到标准错误控制台流(stderr),而不是标准输出(stdout),用户可以将成功的输出重定向到一个文件,同时仍然在屏幕上看到错误消息。

接受命令行参数

让我们创建一个新项目,命名为minigrep。

rust 复制代码
cargo new minigrep

第一个任务是让 minigrep 接受它的两个命令行参数:文件路径和要搜索的字符串。也就是说,我们希望能够以 cargo run 运行程序,两个连字符表示以下参数是用于我们的程序而不是用于 cargo,一个要搜索的字符串和一个要搜索的文件路径,如下所示:

rust 复制代码
cargo run -- searchstring example-filename.txt

为了使 minigrep 能够读取我们传递给它的命令行参数的值,我们需要 Rust 标准库中提供的 std::env::args 函数。这个函数返回传递给 minigrep 的命令行参数的迭代器。可以在迭代器上调用 collect 方法将其转换为包含迭代器产生的所有元素的集合,例如 vector。

rust 复制代码
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);
}

测试一下功能:

程序运行正常。

读取文件

现在我们将添加读取 file_path 参数中指定的文件的功能。

在项目的根目录下创建一个名为 poem.txt 的文件,有以下内容:

复制代码
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

在代码中引入 std::fs 来处理文件。fs::read_to_string 接受 file_path,打开该文件,并返回一个 std::io::Result<String> 类型的值,其中包含文件的内容。

rust 复制代码
use std::{env, fs};

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);
    
    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

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

测试一下功能:

项目重构

为了改进我们的程序,我们将修复与程序结构以及如何处理潜在错误有关的四个问题:

  1. 将功能分开,以便每个功能负责一个任务。
  2. 将配置变量分组到一个结构中。
  3. 读取文件的错误信息解释失败原因。
  4. 将所有错误处理代码放在一个地方。

二进制项目的责任分离

拆分二进制程序的过程:将程序分成 main.rslib.rs,并将程序的逻辑移到 lib.rs 中。

在此过程之后,main 函数中保留的职责应限于以下内容:

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

最终程序结构:main.rs 运行程序,lib.rs 处理手头任务的所有逻辑。

让我们按照这个过程重新编写程序。

提取参数解析器

我们将把解析参数的功能提取到 main 将调用的函数中,以便为将命令行解析逻辑移动到 src/lib.rs 做准备。

rust 复制代码
use std::{env, fs};

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

    (query, file_path)
}

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

    let (query, file_path) = parse_config(&args);

    println!("Searching for {}", query);
    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);
}

parse_config 函数保存逻辑,以确定哪个参数属于哪个变量,并将值传递回 main 函数。

将配置值打包成结构体

我们可以采取另一小步来进一步改进 parse_config 函数。目前,我们返回的是一个元组,可以新定义一个结构体 Config,它定义了名为 query 和 file_path 的字段。

rust 复制代码
use std::{env, fs};

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 }
}

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

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

有很多方法可以管理 String 数据,最简单(虽然有些低效)的方法是对这些值调用 clone 方法。这样做虽然会增加时间和内存的开销,但这使我们的代码非常简单,因为我们不需要管理引用的生命周期。在这种情况下,放弃一点性能以获得简单性是值得的。

现在我们的代码更清楚地传达了 query 和 file_path 是相关的,它们的目的是配置程序的工作方式。

为 Config 结构体创建构造函数

既然 parse_config 函数的目的是创建一个 Config 实例,那么我们可以将 parse_config 从一个普通函数更改为 Config 的构造函数。

rust 复制代码
use std::{env, fs};

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

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

        Config { query, file_path }
    }
}

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

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

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

优化错误处理

通过 panic! 提示参数不足

检查 args 的长度至少为 3,如果少于 3 个元素,通过 panic! 立即结束程序。

rust 复制代码
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--
改为返回 Result 枚举

调用 panic! 更适合于编程问题而不是使用问题,我们可以返回一个 Result 值,该值在成功情况下包含 Config 实例,在错误情况下描述问题。

为了处理错误情况并打印一个用户友好的消息,我们需要更新 main 函数来处理 Config::build 返回的 Result。

rust 复制代码
use std::{env, fs, process};

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

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

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(1);
    });

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

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

unwrap_or_else 函数是由标准库在 Result<T, E> 上定义的。该函数允许我们定义一些自定义的、非 panic 的错误处理。如果 Result 是 Ok 值,此方法的行为类似于 unwrap:它返回 Ok 正在包装的内部值。如果值是 Err 值,则此方法调用闭包中的代码,闭包是我们定义的匿名函数,unwrap_or_else 将把 Err 的内部值传递给闭包,即闭包中的代码在运行时可以使用 err 值。

process::exit 函数将立即停止程序并返回作为退出状态码传递的数字,约定返回非 0 值表示错误。

我们故意不提供参数运行程序,现在程序的报错带有提示了:

从 main 函数中提取逻辑

现在我们已经完成了配置解析的重构,让我们转向程序的逻辑。

我们把读取文件提取成一个独立的 run 函数:

rust 复制代码
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);
}

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(1);
    });

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

    run(config);
}

更改 run 函数,当出现问题时,run 函数将返回 Result<T, E>,而不是通过调用 expect 让程序恐慌。

rust 复制代码
use std::error::Error;

...

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

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

    Ok(())
}

通过顶部的 use 语句我们将 std::error:: Error 引入了作用域。

对于错误类型,我们使用了 trait 对象 Box<dyn error>,这意味着函数将返回实现 Error trait 的类型,但我们不必指定返回值的特定类型。这使我们能够灵活地在不同的错误情况下返回不同类型的错误值。

dyn 关键字是 dynamic 的缩写。

既然 run 函数现在会返回错误了,我们在 main 函数内就要进行处理:

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

我们使用 if let 而不是 unwrap_or_else 来检查 run 是否返回 Err 值。因为 run 在成功的情况下返回(),我们只关心错误的情况,只关心 Err 里的值。

将部分代码转移到库 crate 中

现在我们拆分 src/main.rs 中 main 函数之外的所有代码,放入src/lib.rs。

src/lib.rs:

rust 复制代码
use std::fs;
use std::error::Error;

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

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();

        Ok(Config { query, file_path })
    }
}

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

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

    Ok(())
}

注意结构体、结构体字段、结构体函数、run 函数都要使用 pub 关键字。

src/main.rs:

rust 复制代码
use std::{env, process};
use minigrep::Config;

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(1);
    });

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

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

注意使用 use minigrep::Config 将库 crate 中的 Config 类型引入到二进制 crate 的作用域中,并在 run 函数前面加上我们的 crate 名。

用 TDD 开发库的功能

测试驱动开发(TDD)流程如下所示:

  1. 编写一个失败的测试,并运行它,确保它失败的原因是意料之内的。
  2. 编写或修改足以使新测试通过的代码。
  3. 重构刚刚添加或更改的代码,并确保测试继续通过。
  4. 重复步骤 1!

虽然它只是许多编写软件的方法之一,但 TDD 可以帮助驱动代码设计。在编写使测试通过的代码之前编写测试有助于在整个过程中保持较高的测试覆盖率。

我们将实现一个在文件内容中搜索查询字符串,并生成与查询匹配的行列表的 search 函数。

编写失败的测试

在 src/lib.rs 中添加一个带有 test 函数的 tests 模块:

rust 复制代码
#[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));
    }
}

根据 TDD 原则,我们将添加足够的代码来编译和运行测试,首先添加一个总是返回空向量的 search 函数的定义:

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

因为 contents 是包含所有文本的参数,我们希望返回文本中匹配的部分,所以我们使用生命周期语法将 contents 参数连接到返回值的参数。

运行 cargo test,当然是失败的:

编写代码以通过测试

我们的 search 函数需要遵循以下步骤:

  1. 遍历每一行内容。
  2. 检查该行是否包含我们的查询字符串。
  3. 如果是,将它添加到我们返回的值列表中。
  4. 如果没有,什么都不要做。
  5. 返回匹配的结果列表。
rust 复制代码
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
}

再次执行测试,这次通过了。

既然搜索函数已经运行并经过了测试,那么我们需要从 run 函数中调用 search 函数。

rust 复制代码
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(())
}

再次运行项目,以下是几个例子,它们都成功达到了目的。

使用环境变量

我们将通过添加一个额外的特性来改进 minigrep:一个不区分大小写的搜索选项,用户可以通过一个环境变量来打开它。我们可以将此特性作为命令行选项,并要求用户每次想要应用它时都输入它,但通过将其作为环境变量,我们允许用户设置一次环境变量,并使其在该终端会话中所有搜索都不区分大小写。

编写不区分大小写搜索函数的失败测试

我们首先添加一个新的 search_case_insensitive 函数,该函数将在环境变量具有值时调用。我们将继续遵循 TDD 流程,因此第一步还是编写失败测试。我们将为新的 search_case_insensitive 函数添加一个新的测试,并将旧的测试从 one_result 重命名为 case_sensitive,以澄清两个测试之间的区别。

rust 复制代码
#[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)
        );
    }
}

不区分大小写搜索的新测试使用 "rUsT" 作为其查询。

这是我们失败的测试,它将无法编译,因为我们还没有定义 search_case_insensitive 函数。

实现 search_case_insensitive 函数

search_case_insensitive 函数与 search 函数几乎相同。唯一的区别是,我们将查询和每行文本转为小写。

rust 复制代码
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
}

注意,query 现在是一个 String 而不是字符串切片,因为调用 to_lowercase 会创建新数据,而不是引用现有数据。当我们将 query 作为参数传递给 contains 方法时,我们需要添加一个 & 号,因为 contains 的签名被定义为接受字符串切片。

让我们看看这个实现是否通过了测试:

我们将在 Config 结构体中添加一个配置选项,以在区分大小写的搜索和不区分大小写的搜索之间切换。

rust 复制代码
pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

修改 run 函数,检查 ignore_case 字段的值,并使用该值来决定是调用 search 函数还是 search_case_insensitive 函数。

rust 复制代码
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 模块中,因此我们将该模块置 于src/lib.rs 顶部的作用域中。然后,我们将使用 env 模块中的 var 函数来检查是否为名为 IGNORE_CASE 的环境变量设置了任何值。

rust 复制代码
    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 })
    }

在这里,我们创建了一个新变量 ignore_case。要设置它的值,我们调用 env::var 函数并将 IGNORE_CASE 环境变量的名称传递给它。如果环境变量被设置为任意值,则 env::var 函数返回的 Result 将是成功的 Ok 变量,其中包含环境变量的值。如果没有设置环境变量,它将返回 Err 变量。

我们在 Result 上使用 is_ok 方法来检查是否设置了环境变量,这意味着程序应该执行不区分大小写的搜索。如果 IGNORE_CASE 环境变量没有设置为任何值,is_ok 将返回 false,程序将执行区分大小写的搜索。

我们不关心环境变量的值,只关心它是设置的还是未设置的,所以我们检查的是 is_ok,而不是使用 unwrap、expect 或我们在 Result 中看到的任何其他方法。

我们将 ignore_case 变量中的值传递给 Config 实例,以便 run 函数可以读取该值并决定是调用 search_case_insensitive 还是 search 函数。

让我们分别使用 IGNORE_CASE 环境变量和不使用该环境变量运行项目:

上面是在 git bush 上执行的命令,如果用 Windows PowerShell,会报错:

你需要换种方式设置 IGNORE_CASE 环境变量,并将程序作为单独的命令运行:

powershell 复制代码
$Env:IGNORE_CASE=1; cargo run -- to poem.txt

这将使 IGNORE_CASE 环境变量在 shell 会话的剩余时间内持续存在。

可以使用 Remove-Item 命令取消设置:

powershell 复制代码
Remove-Item Env:IGNORE_CASE

将错误消息写入标准错误而不是标准输出

在大多数终端中,有两种输出:用于一般信息的标准输出(stdout)和用于错误消息的标准错误(stderr)。

这种区别使用户能够选择将程序的成功输出定向到文件,但仍然将错误消息打印到屏幕上。

println! 宏只能够打印到标准输出,所以我们必须使用其他东西来打印到标准错误。

检查错误写入的位置

标准输出流重定向使用 > 符号:

powershell 复制代码
cargo run > output.txt

> 语法告诉 shell 将标准输出的内容写入 output.txt 而不是屏幕。

这是 output.txt 包含的内容:

错误信息被打印到标准输出了。将这样的错误消息打印为标准错误会有用得多,这样只有成功运行的数据才会出现在文件中。

打印错误到标准错误

标准库提供了 eprintln! 宏打印到标准错误流。

修改 main 函数的两个地方:

rust 复制代码
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);
    });

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

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

再次运行 cargo run > output.txt 命令。

现在我们在屏幕上看到错误,output.txt 不包含任何内容,这是我们期望的命令行程序的行为。

让我们运行 cargo run -- to poem.txt > output.txt 命令,这次不会报错,将标准输出重定向到文件。我们不会看到任何输出到终端,output.txt 将包含我们的结果。

这表明我们现在将标准输出用于成功输出,将标准错误用于错误输出。

相关推荐
susnm1 小时前
classnames-rs 库
前端·rust
UestcXiye4 小时前
Rust 学习笔记:自定义构建和发布配置
rust
猩猩程序员4 小时前
用 Rust 重塑开发体验:Meta 推出 PyreFly 类型检查器
rust
浦东大花菜7 小时前
Rust-代码组织(package crate module)
前端·后端·rust
Source.Liu7 小时前
【PhysUnits】15.6 引入P1后的左移运算(shl.rs)
rust
寻月隐君7 小时前
用 Rust 打造命令行利器:从零到一实现 mini-grep
后端·rust·github
yezipi耶不耶19 小时前
Rust入门之并发编程基础(一)
开发语言·后端·rust
Source.Liu1 天前
【PhysUnits】15.5 引入P1后的标准化表示(standardization.rs)
rust
无名之逆1 天前
[特殊字符]For Speed Enthusiasts: The Ultimate Evolution of Rust HTTP Engines
开发语言·前端·后端·网络协议·http·rust
struggle20252 天前
OramaCore 是您 AI 项目、答案引擎、副驾驶和搜索所需的 AI 运行时。它包括一个成熟的全文搜索引擎、矢量数据库、LLM界面和更多实用程序
人工智能·python·rust