重构以改进模块化和错误处理

为了改进我们的程序,我们将修复四个与程序结构以及它如何处理潜在错误有关的问题。首先,我们的 main 函数现在执行两个任务:解析参数和读取文件。随着程序增长,main 函数处理的独立任务数量会增加。随着一个函数承担更多职责,它变得更难理解,更难测试,以及更难在不破坏其中一部分的情况下进行修改。最好的做法是分离功能,以便每个函数负责一个任务。

这个问题也与第二个问题相关:虽然 queryfile_path 是程序的配置变量,但像 contents 这样的变量是用来执行程序逻辑的。main 越长,我们就需要把越多变量带入作用域;作用域中的变量越多,跟踪每个变量目的就越困难。最好的做法是将配置变量分组到一个结构体中,以使其目的明确。

第三个问题是,我们使用 expect 在读取文件失败时打印错误消息,但错误消息只打印 Should have been able to read the file。读取文件可能以多种方式失败:例如,文件可能丢失,或者我们可能没有打开文件的权限。目前,无论情况如何,我们都会为所有情况打印相同的错误消息,这不会向用户提供任何信息!

第四,我们使用 expect 来处理错误,如果用户在未指定足够参数的情况下运行程序,他们将收到 Rust 抛出的 index out of bounds 错误,该错误未能清晰地解释问题。最好的做法是将所有错误处理代码放在一个地方,这样未来的维护者只需要查阅一个地方的代码,如果错误处理逻辑需要更改的话。将所有错误处理代码放在一个地方也将确保我们打印的消息对最终用户有意义。

让我们通过重构项目来解决这四个问题。

二进制项目的关注点分离

将多任务责任分配给 main 函数的组织问题在许多二进制项目中都很常见。因此,Rust 社区制定了当 main 开始变得臃肿时,如何分离二进制程序不同关注点的指导方针。此过程包含以下步骤:

  • 将程序拆分为一个 *main.rs* 文件和一个 *lib.rs* 文件,并将程序逻辑移至 *lib.rs*。
  • 只要命令行解析逻辑很小,它可以保留在 *main.rs* 中。
  • 当命令行解析逻辑开始变得复杂时,将其从 *main.rs* 中提取出来并移至 *lib.rs*。

此过程后保留在 main 函数中的职责应仅限于以下几点:

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

此模式旨在分离关注点:*main.rs* 负责运行程序,而 *lib.rs* 处理当前任务的所有逻辑。由于不能直接测试 main 函数,此结构通过将程序的所有逻辑移至 *lib.rs* 中的函数中,使得测试成为可能。保留在 *main.rs* 中的代码将足够小,可以通过阅读来验证其正确性。让我们按照此过程重构程序。

提取参数解析器

我们将把解析参数的功能提取到一个函数中,main 将调用该函数来准备将命令行解析逻辑移至 *src/lib.rs*。清单 12-5 显示了新的 main 开头,它调用了一个新函数 parse_config,我们暂时在 *src/main.rs* 中定义它。

文件名:src/main.rs
use std::env;
use std::fs;

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

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

    // --snip--

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

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

    (query, file_path)
}
清单 12-5:从 `main` 中提取 `parse_config` 函数

我们仍然将命令行参数收集到一个向量中,但不再在 main 函数内将索引 1 处的参数值赋给变量 query,将索引 2 处的参数值赋给变量 file_path,而是将整个向量传递给 parse_config 函数。parse_config 函数包含确定哪个参数赋给哪个变量的逻辑,并将这些值传回给 main。我们仍然在 main 中创建 queryfile_path 变量,但 main 不再负责确定命令行参数和变量如何对应。

对于我们的小程序来说,这种重构可能看起来有点小题大做,但我们正在以小而增量的步骤进行重构。进行此更改后,再次运行程序,验证参数解析是否仍然有效。经常检查进度是个好习惯,有助于在问题发生时确定原因。

对配置值进行分组

我们可以再迈出一小步,进一步改进 parse_config 函数。目前,我们返回一个元组,但随后立即又将该元组解构为各个部分。这表明也许我们还没有找到合适的抽象。

另一个表明有改进空间的迹象是 parse_config 中的 config 部分,这意味着我们返回的两个值是相关的,并且都是一个配置值的一部分。我们目前没有在数据结构中传达这个含义,只是将这两个值分组到一个元组中;我们将改为将这两个值放入一个结构体中,并给结构体的每个字段一个有意义的名称。这样做将使未来的代码维护者更容易理解不同值之间的关系以及它们的目的是什么。

清单 12-6 展示了对 parse_config 函数的改进。

文件名:src/main.rs
use std::env;
use std::fs;

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

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

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 }
}
清单 12-6:重构 `parse_config` 以返回 `Config` 结构体的一个实例

我们添加了一个名为 Config 的结构体,定义了名为 queryfile_path 的字段。parse_config 的签名现在表明它返回一个 Config 值。在 parse_config 的函数体中,我们以前返回引用 argsString 值的字符串切片,现在我们定义 Config 包含拥有的 String 值。main 中的 args 变量是参数值的所有者,并且只允许 parse_config 函数借用它们,这意味着如果 Config 试图取得 args 中值的所有权,我们将违反 Rust 的借用规则。

管理 String 数据有多种方式;最简单(尽管效率有点低)的方法是调用这些值的 clone 方法。这将为 Config 实例创建一个完整的数据副本以供其拥有,这比存储字符串数据的引用花费更多时间和内存。然而,克隆数据也使我们的代码非常直观,因为我们不必管理引用的生命周期;在这种情况下,牺牲一点性能来换取简洁性是值得的权衡。

使用 `clone` 的权衡

许多 Rustaceans 倾向于避免使用 clone 来解决所有权问题,因为它有运行时开销。在第 13 章,你将学习在这种情况下如何使用更有效的方法。但现在,复制几个字符串以继续取得进展是可以接受的,因为你只需要复制一次,而且你的文件路径和查询字符串都非常小。拥有一个有点效率不高的工作程序,比第一次尝试就过度优化代码要好。随着你对 Rust 越来越有经验,从最有效率的解决方案开始会更容易,但现在,完全可以接受调用 clone

我们更新了 main 函数,使其将 `parse_config` 返回的 Config 实例放入名为 config 的变量中,并更新了之前使用单独的 queryfile_path 变量的代码,使其现在改用 Config 结构体上的字段。

现在,我们的代码更清晰地传达了 queryfile_path 是相关的,并且它们的目的是配置程序如何工作。任何使用这些值的代码都知道在 config 实例中找到它们,其字段名称表明了它们的用途。

为 `Config` 创建构造函数

到目前为止,我们已经从 main 中提取了负责解析命令行参数的逻辑,并将其放入 parse_config 函数中。这样做帮助我们看到 queryfile_path 值是相关的,并且这种关系应该在代码中传达。然后我们添加了一个 Config 结构体来命名 queryfile_path 的相关目的,并能够从 parse_config 函数中以结构体字段名的形式返回这些值的名称。

因此,既然 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数更改为一个名为 new 的与 Config 结构体关联的函数。进行此更改将使代码更符合习惯用法。我们可以通过调用 String::new 来创建标准库中类型(例如 String)的实例。类似地,通过将 parse_config 更改为与 Config 关联的 new 函数,我们将能够通过调用 Config::new 来创建 Config 的实例。清单 12-7 显示了我们需要进行的更改。

文件名:src/main.rs
use std::env;
use std::fs;

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

    // --snip--
}

// --snip--

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 }
    }
}
清单 12-7:将 `parse_config` 更改为 `Config::new`

我们更新了调用 parse_configmain 函数,改为调用 Config::new。我们将 parse_config 的名称更改为 new,并将其移到了一个 impl 块内,这使得 new 函数与 Config 关联起来。尝试再次编译此代码以确保它能工作。

修复错误处理

现在我们将着手修复错误处理。回想一下,如果 args 向量包含少于三个项,尝试访问索引 1 或索引 2 处的值会导致程序 panic。尝试不带任何参数运行程序;它看起来会是这样:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

index out of bounds: the len is 1 but the index is 1 是面向程序员的错误消息。它不会帮助我们的最终用户理解他们应该怎么做。现在来修复它。

改进错误消息

在清单 12-8 中,我们在 new 函数中添加一个检查,该检查将在访问索引 1 和索引 2 之前验证切片是否足够长。如果切片不够长,程序将 panic 并显示更好的错误消息。

文件名:src/main.rs
use std::env;
use std::fs;

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

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

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

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

        Config { query, file_path }
    }
}
清单 12-8:添加参数数量检查

此代码类似于我们在清单 9-13 中编写的 Guess::new 函数,在该函数中,当 value 参数超出有效值范围时,我们调用了 panic!。在这里,我们不是检查范围,而是检查 args 的长度是否至少为 3,函数的其余部分可以在满足此条件的前提下运行。如果 args 的项少于三个,此条件将为 true,并且我们调用 panic! 宏立即终止程序。

new 中添加了这几行额外的代码后,让我们再次不带参数运行程序,看看错误现在是什么样子:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

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

此输出更好:我们现在有一个合理的错误消息。然而,我们也有一些不希望提供给用户的额外信息。也许我们在清单 9-13 中使用的技术不是这里最好的使用方法:如第 9 章所讨论的,调用 panic! 更适合编程问题而不是使用问题。相反,我们将使用你在第 9 章学到的另一种技术——返回一个 Result指示成功或错误。表明成功或错误。

返回 `Result` 而不是调用 `panic!`

我们可以改为返回一个 Result 值,在成功情况下包含一个 Config 实例,并在错误情况下描述问题。我们还将函数名从 new 更改为 build,因为许多程序员期望 new 函数永不失败。当 Config::buildmain 通信时,我们可以使用 Result 类型来表示有问题发生。然后我们可以更改 main,将 Err 变体转换为对用户来说更实用的错误,而没有调用 panic! 引起的关于 thread 'main'RUST_BACKTRACE 的额外文本。

清单 12-9 展示了我们需要对现在称为 Config::build 的函数返回值和返回 Result 所需的函数体进行更改。请注意,这在更新 main 之前不会编译,我们将在下一个清单中进行更新。

文件名:src/main.rs
use std::env;
use std::fs;

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

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 })
    }
}
清单 12-9:从 `Config::build` 返回 `Result`

我们的 build 函数返回一个 Result,在成功情况下包含一个 Config 实例,在错误情况下包含一个字符串字面量。我们的错误值将始终是具有 'static 生命周期 的字符串字面量。

我们在函数体中做了两个更改:当用户未传递足够参数时,不再调用 panic!,而是返回一个 Err 值;并且将 Config 返回值包装在 Ok 中。这些更改使函数符合其新的类型签名。

Config::build 返回 Err 值允许 main 函数处理 build 函数返回的 Result 值,并在错误情况下更干净地退出进程。

调用 `Config::build` 并处理错误

为了处理错误情况并打印用户友好的消息,我们需要更新 main 来处理 Config::build 返回的 Result,如清单 12-10 所示。我们还将从 panic! 中移除以非零错误代码退出命令行工具的责任,改为手动实现。非零退出状态是向调用我们程序的进程发出的信号,表示程序以错误状态退出。

文件名:src/main.rs
use std::env;
use std::fs;
use std::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(1);
    });

    // --snip--

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

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 })
    }
}
清单 12-10:如果构建 `Config` 失败,则以错误代码退出

在此清单中,我们使用了标准库在 Result<T, E> 上定义的一个方法,我们尚未详细介绍:unwrap_or_else。使用 unwrap_or_else 允许我们定义一些自定义的、非 panic! 的错误处理。如果 ResultOk 值,此方法的行为类似于 unwrap:它返回 Ok 包装的内部值。然而,如果值是 Err 值,此方法会调用 *闭包* 中的代码,闭包是我们定义并作为参数传递给 unwrap_or_else 的匿名函数。我们将在第 13 章更详细地介绍闭包。目前,你只需知道 unwrap_or_else 会将 Err 的内部值(在这种情况下是我们在清单 12-9 中添加的静态字符串 "not enough arguments")传递给我们的闭包,作为出现在竖线之间的参数 err。闭包中的代码在运行时可以使用 err 值。

我们添加了一行新的 use 来将标准库中的 process 引入作用域。将在错误情况下运行的闭包中的代码只有两行:我们打印 err 值,然后调用 process::exitprocess::exit 函数将立即停止程序,并返回作为退出状态码传递的数字。这类似于我们在清单 12-8 中使用的基于 panic! 的处理,但我们不再获得所有额外输出。让我们试试:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

太好了!这个输出对我们的用户来说友好得多。

从 `main` 中提取逻辑

既然我们已经完成了配置解析的重构,接下来转向程序的逻辑。正如我们在“二进制项目的关注点分离”中所述,我们将提取一个名为 run 的函数,该函数将包含当前在 main 函数中所有不涉及设置配置或处理错误的逻辑。完成后,main 将变得简洁且易于通过检查验证,并且我们将能够为所有其他逻辑编写测试。

清单 12-11 展示了提取出的 run 函数。目前,我们只是做小而增量的改进来提取函数。我们仍然在 *src/main.rs* 中定义该函数。

文件名:src/main.rs
use std::env;
use std::fs;
use std::process;

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

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

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

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 })
    }
}
清单 12-11:提取包含其余程序逻辑的 `run` 函数

run 函数现在包含了 main 中从读取文件开始的所有剩余逻辑。run 函数接受 Config 实例作为参数。

从 `run` 函数返回错误

将剩余的程序逻辑分离到 run 函数后,我们可以改进错误处理,就像我们在清单 12-9 中对 Config::build 所做的那样。run 函数将返回一个 Result<T, E>(而不是通过调用 expect 允许程序 panic),当出现问题时。这将使我们能够进一步整合围绕错误处理的逻辑到 main 中,以用户友好的方式进行。清单 12-12 展示了我们需要对 run 的签名和函数体进行的更改。

文件名:src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


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

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

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

    Ok(())
}

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 })
    }
}
清单 12-12:将 `run` 函数更改为返回 `Result`

我们在此处进行了三个重大更改。首先,我们将 run 函数的返回类型更改为 Result<(), Box<dyn Error>>。此函数之前返回单元类型 (),我们在 Ok 情况下仍将其作为返回值。

对于错误类型,我们使用了 *特征对象* Box<dyn Error>(并在顶部使用 use 语句将 std::error::Error 带入作用域)。我们将在第 18 章中介绍特征对象。目前,你只需知道 Box<dyn Error> 意味着函数将返回实现了 Error 特征的类型,但我们不必指定返回值的具体类型。这赋予了我们在不同错误情况下返回不同类型错误值的灵活性。dyn 关键字是 *dynamic* 的缩写。

第二,我们删除了对 expect 的调用,转而使用 ? 运算符,正如我们在第 9 章中讨论的那样。不是在发生错误时调用 panic!? 将返回错误值,以便调用者处理。

第三,run 函数现在在成功情况下返回一个 Ok 值。我们在签名中将 run 函数的成功类型声明为 (),这意味着我们需要将单元类型值包装在 Ok 值中。Ok(()) 这种语法起初可能看起来有点奇怪,但这样使用 () 是惯用的方式,表示我们调用 run 仅是为了其副作用;它不返回我们需要的值。

当你运行此代码时,它会编译,但会显示一个警告:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
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!

Rust 告诉我们代码忽略了 Result 值,并且 Result 值可能表明发生了错误。但我们没有检查是否发生了错误,编译器提醒我们可能 intended 在这里编写一些错误处理代码!现在来解决这个问题。

在 `main` 中处理从 `run` 返回的错误

我们将使用与我们在清单 12-10 中处理 Config::build 类似的技术来检查和处理错误,但略有不同:

文件名:src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

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

    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) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

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

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

    Ok(())
}

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

我们使用 if let 而不是 unwrap_or_else 来检查 run 是否返回 Err 值,如果返回则调用 process::exit(1)run 函数不返回我们需要像 Config::build 返回 Config 实例那样进行 unwrap 的值。因为 run 在成功情况下返回 (),我们只关心检测错误,所以我们不需要 unwrap_or_else 来返回解包的值,它只会是 ()

if letunwrap_or_else 函数体在这两种情况下是相同的:我们打印错误并退出。

将代码拆分为库 crate

到目前为止,我们的 minigrep 项目看起来不错!现在我们将拆分 *src/main.rs* 文件,并将一些代码放入 *src/lib.rs* 文件中。这样,我们可以测试代码,并且 *src/main.rs* 文件的职责更少。

让我们将 *src/main.rs* 中不在 main 函数内的所有代码移动到 *src/lib.rs*:

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

*src/lib.rs* 的内容应具有清单 12-13 中所示的签名(为简洁起见,我们省略了函数体)。请注意,这在修改清单 12-14 中的 *src/main.rs* 之前不会编译。

文件名: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--
        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>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

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

    Ok(())
}
清单 12-13:将 `Config` 和 `run` 移入 *src/lib.rs*

我们大量使用了 pub 关键字:在 Config 上、在其字段及其 build 方法上,以及在 run 函数上。我们现在有了一个库 crate,它具有我们可以测试的公共 API!

现在我们需要将移至 *src/lib.rs* 的代码引入 *src/main.rs* 中二进制 crate 的作用域,如清单 12-14 所示。

文件名:src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    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) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}
清单 12-14:在 *src/main.rs* 中使用 `minigrep` 库 crate

我们添加一行 use minigrep::Config 将库 crate 中的 Config 类型带入二进制 crate 的作用域,并且我们在 run 函数前加上 crate 名称。现在所有功能都应该连接起来并且可以工作。使用 cargo run 运行程序并确保一切正常工作。

呼!工作量很大,但我们为未来的成功奠定了基础。现在处理错误容易多了,而且代码也更加模块化。从现在起,我们几乎所有的工作都将在 *src/lib.rs* 中完成。

让我们利用这种新获得的模块化特性来做一些用旧代码很难做到,但用新代码很容易的事情:编写一些测试!