14.3.重构

为了改善这个程序,我们需要修复四个问题,这些问题和程序的结构、潜在发生的错误有关。我们的主函数主要执行两个主要任务:解析参数,读取文件。当程序增长,主函数将处理更多的任务,随着函数承担更多的责任,它将变得越来越难以测试,越来越难以修改,最好的方法是将的功能分拆为独立的功能,每一个功能只负责自己的任务就好。

第二个问题是:尽管需要query和file_path这样的配置变量和contents用于执行程序逻辑的变量,当时主函数会变得更长,作用域内引入的变量越多,作用域内存在的变量就越多,跟踪每一个变量就越难。最好的方式是将配置变量放入一个结构体内是的其目的性更加清晰。

第三个问题是使用expect来显示读取文件失败然后显示错误信息,但是错误信息只能显示一条,不能精确表明详细的错误原因。因为读取文件内容导致出错的原因有很多,例如:没找到,或者没有权限等。但是当前的代码无论哪种错误都只显示"无法读取这个文件",不会在给终端用户更多的信息。

第四个问题是,当用户没有给足参数,系统只会显示"index out of bounds"这个错误信息,并不能清晰的说明这个问题。最好的方式是统一处理所有错误,以便只在一个地方维护错误处理逻辑。这样做也有利于输出更加友好的错误信息给终端用户。

现在让我们通过重构来解决这四个问题吧:

14.3.1 关注分离点

许多项目都存在一个组织性问题,就是将太多的责任分配给了主函数。最终,程序员们发现当主函数变得过大,将其分拆为各自独立的二进制程序是非常实用的。这个过程按下面的步骤进行:

  1. 将你的程序分拆为main.rs和lib.rs两个文件,将业务逻辑迁移至lib.rs文件中。
  2. 如果命令行解析逻辑足够小,他就可以保留在main函数中。
  3. 当解析逻辑变得复杂,可以将其抽取出main函数并保持到其他函数或类型中。

保留在main函数的功能主要为:

  1. 调用命令行解析逻辑,并给其传递参数
  2. 设置其他相关的配置
  3. 调用保存在lib.rs中的run函数
  4. 如果run返回了错误处理这些错误

这种模式就是业务分离:main函数负责程序的运行,lib负责业务逻辑的处理。由于不能直接测试main函数,通过从主函数中抽离业务,这种架构就允许你可以测试所有的业务逻辑。保留在主函数中的代码小的足以通过阅读它就可以验证其正确性。让我们开干吧!

14.3.1.1 剥离参数解析器

我们新建一个parse_config函数,然后将主函数中的解析参数逻辑迁移到这个新建的函数中。

rust 复制代码
use std::env;

fn main() {

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

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

    println!("query={query};file_path={file_path}");

}

fn parse_config(args: &[String]) -> (&str, &str) {

    let query = &args[1];

    let file_path = &args[2];

    (query, file_path)

}

主函数依旧获取命令行参数并保存到向量集合中,但是它已经不再负责解析参数和参数的对应关系,parse_config负责这些处理,将处理结果传回给主函数,主函数依旧创建变量query和file_paht用于保存解析函数传回的参数。

这个重构工作看上去有点裁剪过度了,但是我们的重构是微量的,逐步增加的修改。做完上面的工作,运行程序,查看参数解析是否正常。经常检查程序运行结果是一个非常好的习惯,当问题发生时,有助于找出问题源。

14.3.1.2 配置参数编组

接下来再前进一小步,上一步中我们返回的是元祖,接着又将其进行拆分为各自独立的变量。这意味着我们还没有正确的进行代码抽象。

可以将parse_config返回的元祖数据转化为结构体数据,并分配给每一个变量一个有真实含义的名字,以便后续的代码维护者可以更容易的理解这段代码的含义,以及这两个变量之间的相互关系和它们的目的是什么。

rust 复制代码
use std::env;

use std::fs;

fn main() {

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

    let config:Config = parse_config(&args);


    println!("config={},{}",config.query,config.file_path);

    let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");

}

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,

    }

}

我们增加了一个叫Config的结构体,它包含两个成员,一个叫query,另一个叫file_path。修改parse_config函数的定义,使其返回Config类型。在它的内部不再使用字符串借用作为返回值,而是返回拥有所有权的字符串。args的所有权归主函数所有,如果Config占据args的所有权会违反Rust的借用规则。

有很多方法可以管理字符串数据,但是最容易的是使用clone函数,复制一份现有字符串的数据,虽然会损失一些效率。这将会在生成Config实例时对现有数据进行全拷贝,以便拥有其所有权。相比保存字符串数据的引用,clone操作会花费更多的时间和更多的内存。然而clone操作还是值得的,因为我们不必再费心管理引用的生命周期了。在此场景下,放弃些许性能获取简洁性是值得的。

14.3.1.3 使用clone函数的代价

现下有一种趋势,尽量避免使用clone来修正所有权问题,因为它会有运行时开销。后面我们会介绍这种情况下如何使用其他更有效的方法。但是在此示例中,使用clone函数完全可以,因为这些拷贝工作只需一次,并且数据量非常小。比起第一步过度优化代码而言,损失些许效率让程序快速推进更加值得。当你成为了经验丰富的工程师,你会很容易的使用最有效的代码,但是现在,使用clone时最合适的。

现在代码变得更加清晰,query和file_path是相互关联的,它们代表配置参数,这些参数将决定程序如何工作。使用这些值的代码知道应该在config实例中按名知意去使用。

14.3.1.4 给Config创建构造器

目前,我们将解析参数的业务逻辑从主函数中抽离,放入parse_config函数中。同时query和file_path这两值是有关联的,它们之间的关系应该在代码中一起传递,于是我们又增加了Config结构体保存这两个变量,使其看名知意;最后将其作为parse_config函数的返回参数,保留了这两个变量的关联性。

接下来,我们的目标是将parse_config转换为结构体Config的new函数,这种改变使得代码变得更加符合使用习惯(更加地道,味道更加纯正)。我们使用字符串String类型,也可以调用new函数,类似的我们通过将parse_config转换为Config的new函数,然后就可以调用Config::new函数来创建新的Config实例。

rust 复制代码
use std::env;

use std::fs;

fn main() {

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

    let config: Config = Config::new(&args);


    println!("config={},{}", config.query, config.file_path);

    let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");

}

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 }

    }

}

修改主函数将调用parse_config替换为Config::new函数。修改parse_config的名称为new并移入impl Config块中。编译此代码确保运行正常。

14.3.2 完善错误处理

现在我们需要完善我们的错误处理了,当执行该程序时,携带的参数少于3个,参数索引值1或2将会爆出索引值超界的错误信息,程序会产生panic并停止运行。运行少于三个参数时,程序输出以下错误信息:

rust 复制代码
thread 'main' (28664) panicked at src\main.rs:18: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

error: process didn't exit successfully: `target\debug\lession14_004.exe` (exit code: 101)

这段信息:index out of bounds: the len is 1 but the index is 1是给程序员的提示信息,它对于终端用户来说是没有帮助的,现在让我们修复它。

14.3.2.1 改进错误消息的显示

我们可以在new函数中增加检查信息,用于验证参数是否满足要求,如果不满足程序终止运行并给出明确的且有帮助的错误信息提示。

rust 复制代码
impl Config {

    fn new(args: &[String]) -> Config {

        if args.len() < 3 {

            panic!("命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称");

        }

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

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

        Config { query, file_path }

    }

}

我们检查args的长度是否小于3,如果满足则继续执行,如果不满足,则停止执行,退出程序的运行。执行cargo run命令,程序输出信息为:

rust 复制代码
thread 'main' (38076) panicked at src\main.rs:19:13:

命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

error: process didn't exit successfully: `target\debug\lession14_004.exe` (exit code: 101)

上面的错误信息就十分的完美,明确告知操作失败的原因,并给出正确输入的格式。

14.3.2.2 错误返回Result而不是panic宏

我们使用Result类型作为返回值,而不是直接触发paic,这样就可以在成功时返回Config实例,失败时返回错误信息。我们也将函数名从new修改为build,这是因为大部分程序员任务new函数永不会失败。当使用Config::build和main 函数交互时,可以使用Result告知出现了什么问题。接着,就改变main函数为用户将Err变量转换为更加具有操作性的错误信息而不是哪些专业用语。

下面是修改后build函数:

rust 复制代码
    fn build(args: &[String]) -> Result<Config, &'static str> {

        if args.len() < 3 {

            return Err("文件参数不足!");

        }

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

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

        Ok(Config { query, file_path })

    }

这个build函数在成功时返回config实例,失败时返回字符串字面量。错误中的值将一直是字符串字面量,因为它使用了'static生命周期。

这些修改替换了之前的panic!宏的调用,当用户执行命令参数不足时,返回Err值,成功时返回Ok包裹的Config实例。这些改变符合了函数的签名。

使用Err值使得main函数处理Result时条理更加清晰。我们自己负责实现在错误情况下使用非零的错误码退出程序,而不再使用panic。

14.3.2.3 调用Config::build和错误处理

为了处理build返回的错误并显示出用户友好的信息提示,我们需要修改main函数处理Config::build函数返回的Result数据类型。还要负责在错误时返回非零的退出码。这个非零的状态码会返回给调用的进程。

rust 复制代码
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {

        println!("解析参数出错:{err}");

        process::exit(1);

    });

上面的代码中,我们使用了之前从未涉及到的unwrap_or_else函数,它是有标准库提供给Result的方法。使用这个函数可以让我们有一些个性化的错误处理。如果Result是Ok,这个方法的行为如同unwrap,返回Ok的内部值。但是如果错误值,这个方法将会调用闭包,闭包是一个匿名函数,它会将Err的内部值传递给闭包,闭包就会处理对应的值并显示给用户。闭包的参数封装在两个竖线内,一般称之为管道。

我们从标准库中引入了新的模块:process。在闭包中的代码对错误的处理只有两行:显示错误信息,返回错误状态码并退出程序。退出程序并返回错误码使用process::exit函数,这和panic!宏类似,但我们不会在输出多余的信息。

rust 复制代码
解析参数出错:文件参数不足!

error: process didn't exit successfully: `target\debug\lession14_005.exe` (exit code: 1)

非常棒!这个输出信息就友好多了。

14.3.2 主函数抽离业务逻辑

现在我们完成了配置参数的解析的重构,让我们回到程序的主逻辑中。之前我们将其抽离至run函数中,

我们之前将部分业务逻辑抽离至run函数中,接手了出参数解析和错误处理之外的所有业务处理,之后,主函数就变得足够精简,只需要人工审核就可以验证其正确性。抽离了所有的业务处理之后,就可以写测试程序为这些业务逻辑进行测试。下一步写run函数抽离其他业务。

rust 复制代码
use std::env;

use std::fs;

fn main() {

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

    let config: Config = Config::new(&args);


    println!("config={},{}", config.query, config.file_path);

    let contents = fs::read_to_string(config.file_path).expect("读取文件失败!");

}

struct Config {

    query: String,

    file_path: String,

}

impl Config {

    fn new(args: &[String]) -> Config {

        if args.len() < 3 {

            panic!("命令参数不足,命令格式为:程序名称 -- 要搜索的字符串 要搜索的文件名称");

        }

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

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

        Config { query, file_path }

    }

}

run函数接收Config类型数据作为参数,然后根据接收到的参数读取文件内容,并显示给用户。

14.3.3.1 从run函数返回错误

有了分拆逻辑到run函数中,我们就可以改进错误处理的方式,正如我们在Config::build函数中所做的,不是调用expect函数返回panic,而是返回Result<T,E>。我们必须坚定使用用户友好的方式处理错误的信念。下面是改进后的run函数签名:

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

// --snip--

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

    let contents = fs::read_to_string(config.file_path)?;

    println!("文件读取失败");

    Ok(())

}

这段代码有三处更改,首先更改了函数的返回值,之前返回了空元祖类型,在函数运行成功时依然返回空元祖类型,但是需要使用Ok封装起来。

错误类型,我们使用特性对象Box<dyn Error>(Error是标准库中的std::error:Error)。它表示函数返回的类型必须实现了Error接口。我们不必指定返回的值类型。这就拥有了非常大的灵活性,可以在不同的错误场景下返回不同的类型。dyn是单词dynamic的缩写。

  • 我们使用"?"替换了expect函数的调用,我们之前学习过:?返回错误的值给调用者处理。
  • run函数在成功时返回Ok类型的值,在此,它返回空单元类型("()"),这就意味着需要使用Ok封装这个空单元类型。即:Ok(())。第一次看到这个符号可能会很奇怪,但是空单元类型是一个习惯用法,它表明调用run函数只需要它的附带功能,不是真的需要返回一个值。

当执行以上代码,会出现下面的警告信息:

rust 复制代码
  --> src\main.rs:27:5

   |

27 |     run(config);

   |     ^^^^^^^^^^^

   |

   = note: this `Result` may be an `Err` variant, which should be handled

   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default

help: use `let _ = ...` to ignore the resulting value

   |

27 |     let _ = run(config);

   |     +++++++

上面的信息告诉我们,代码会忽略掉Result值,尽管Result可能正表明一个错误发生。但是代码并没有检查错误是否发生了,编译器提醒我们,我们也许希望有一些代码处理错误。

14.3.3.2 处理返回的错误

让我们处理错误吧,首先检查错误然后处理错误,过程类似Config::build但是还是有些不同的:

rust 复制代码
fn main() {

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

    let config = Config::build(&args).unwrap_or_else(|err| {

        println!("解析参数出错:{err}");

        process::exit(1);

    });

    println!("要搜索的字符串是:{}",config.query);

    println!("要搜素的文件是:{}",config.file_path);

    if let Err(e) =run(config){

        println!("错误信息是:{e}");

        process::exit(1);

    };

}

我们使用if let替换unwrap_or_else来检查run函数是否返回了Err值,之后调用process::exit(1)退出程序运行。在成功时run函数返回一个值,它只会返回包裹在Ok中的空单元类型。

if let 和unwrap_or_else接下来的工作是相同的,都是显示错误信息并退出。

14.3.4 将代码分拆到库中

现在这个项目看上去已经很完美了,但是我们还要将main.rs文件的一些代码迁移到lib.rs中。只有这样才能进行代码测试,并且进一步精简main.rs文件。

在lib文件中,我们定义搜索文本的功能,这样其他人也可以调用这个库或者其他场景下调用搜索功能。

首先在lib.rs中定义一个search函数签名,函数内部使用unimplemented!宏,之后会详细说明该函数的签名:

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

    unimplemented!();

}

我们使用pub关键字定义search作为库的公共API。现在我们有了库箱,我们可以使用它进行测试。

rust 复制代码
use lession14_005::search;

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

}

在main.rs中使用use引入库箱的search函数,这样在二进制箱中就可以使用该函数了。然后在run函数中,调用search函数,并传入config.query和contents作为参数。run使用一个loop循环来显示出搜素匹配的行。此时可以删除掉main函数中显式query和文件路径的代码了,以便程序只显示搜素结果(如果不发生错误)。

注意搜索会将所有的结果放置在一个向量集合中,然后返回,在这之前不会有任何信息显示。当搜素大型文件时,可能会很慢,因为搜素结果不会立即显示;之后的章节中会讨论如何改善这一缺陷。

相关推荐
XovH6 小时前
Redis 从入门到精通:分片之道 —— Redis Cluster
后端
XovH6 小时前
Redis 从入门到精通:Redis Sentinel 哨兵
后端
昭昭颂桉a6 小时前
TypeScript 前端的必修课,从 JS 到 TS
开发语言·前端·javascript·typescript
用户938515635076 小时前
从零实现一个 Todos 应用:原生 Ajax + Node 服务,顺便吃透 JSON.stringify
前端·javascript·后端
霸道流氓气质6 小时前
Spring Boot 文件上传大小限制配置全解析
spring boot·后端·firefox
Java面试题总结6 小时前
SpringBoot API参数校验
java·spring boot·后端
何以解忧,唯有..6 小时前
Go 语言安装与环境配置完整指南
开发语言·后端·golang
alwaysrun6 小时前
C++之常量体系const
c++·后端·程序员
guyoung6 小时前
BoxAgnts 工具系统(5)——WASM 工具开发:从 Hello World 到生产部署
rust·agent·ai编程
Java面试题总结6 小时前
MarkItDown 再次登顶GitHub榜
开发语言·c#·github