Rust入门(八):构建一个命令行程序

文章介绍了如何使用Rust语言创建一个类似grep的命令行工具,包括初始化项目、读取参数、处理文件读取、改进模块性和错误处理。通过将功能拆分、使用结构体和Result来增强代码的可读性和错误处理能力。此外,还涉及了测试驱动开发和环境变量的使用。
摘要由CSDN通过智能技术生成

Rust 的运行速度、安全性、单二进制文件输出和跨平台支持使其成为创建命令行程序的绝佳选择,所以我们的项目将创建一个我们自己版本的经典命令行工具:grepgrep 最简单的使用场景是在特定文件中搜索指定字符串。

初始化项目、读取参数

首先使用 cargo new 初始化一个项目,这个项目需要接收两个参数:文件名和需要搜索的字符串:

$ cargo run searchstring example-filename.txt

我们可以使用 std::env::args。这个函数返回一个传递给程序的命令行参数的 迭代器iterator)。迭代器生成一系列的值,可以在迭代器上调用 collect 方法将其转换为一个集合,比如包含所有迭代器产生元素的 vector。

use std::env;
fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

注意 std::env::args 在其任何参数包含无效 Unicode 字符时会 panic。如果你需要接受包含无效 Unicode 字符的参数,使用 std::env::args_os 代替。

在获得了参数之后,需要将这两个参数的值保存进变量,这样就可以在程序的余下部分使用这些值,程序的名称占据了 vector 的第一个值 args[0],所以要使用 args[1]args[2] 来获取文件名字和搜索的字符串:

use std::env;

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

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

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

读取文件

使用fs::read_to_string 函数,它接受一个 filename打开文件,接着返回包含其内容的 Result<String>

use std::env;
use std::fs;

fn main() {
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");

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

存在的问题

我们的程序这里有四个问题需要修复:

  1. main 现在进行了两个任务:它解析了参数并打开了文件。当函数承担了更多责任,它就更难以推导,更难以测试,并且更难以在不破坏其他部分的情况下做出修改,所以最好能分离出功能以便每个函数就负责一个任务。
  2. queryfilename 是程序中的配置变量,随着 main 函数的增长,就需要引入更多的变量到作用域中,而当作用域中有更多的变量时,将更难以追踪每个变量的目的。最好能将配置变量组织进一个结构,这样就能使他们的目的更明确了。
  3. 读取文件失败的原因有多种:例如文件不存在,或者没有打开此文件的权限。目前,无论处于何种情况,我们只是打印出“文件读取出现错误”的信息,这并没有给予使用者具体的信息!
  4. 我们不停地使用 expect 来处理不同的错误,如果用户没有指定足够的参数来运行程序,他们会从 Rust 得到 index out of bounds 错误,而这并不能明确地解释问题

改进模块性

对此,我们可以将程序进行拆分:

  • 将程序拆分成 main.rslib.rs 并将程序的逻辑放入 lib.rs 中。
  • 当命令行解析逻辑比较小时,可以保留在 main.rs 中。
  • 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs 中。
fn main() {
    let args: Vec<String> = env::args().collect();
    let (query, filename) = parse_config(&args);
}

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

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

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

    let config = parse_config(&args);

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

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");

    // --snip--
}

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

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

    Config { query, filename }
}

所以现在 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数变为一个叫做 new 的与结构体关联的函数。可以像标准库中的 String 调用 String::new 来创建一个该类型的实例那样,将 parse_config 变为一个与 Config 关联的 new 函数:

这里将 main 中调用 parse_config 的地方更新为调用 Config::new。我们将 parse_config 的名字改为 new 并将其移动到 impl 块中,这使得 new 函数与 Config 相关联。再次尝试编译并确保它可以工作。

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

        Config { query, filename }
    }
}

错误信息处理

new 函数中增加了一个检查在访问索引 12 之前检查 slice 是否足够长,我们使用一个更好的错误信息 panic 而不是 index out of bounds 信息:

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

之后我们选择使用 Result 值来处理 Config,它在成功时会包含一个 Config 的实例,而在错误时会描述问题

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

为了处理错误情况并打印一个对用户友好的信息,需要手动实现原先由 panic!负责的工作,即以非零错误码退出命令行工具的工作。非零的退出状态是一个惯例信号,用来告诉调用程序的进程:该程序以错误状态退出了。

use std::process;

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

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // --snip--

unwrap_or_else可以进行一些自定义的非 panic! 的错误处理。当 ResultOk 时,这个方法的行为类似于 unwrap:它返回 Ok 内部封装的值。然而,当其值是 Err 时,该方法会调用一个 闭包closureunwrap_or_else 会将 Err 的内部值即 not enough arguments 静态字符串的情况,传递给闭包中位于两道竖线间的参数 err。闭包中的代码在其运行时可以使用这个 err 值。

在错误的情况闭包中将被运行的代码只有两行:我们打印出了 err 值,接着调用了 std::process::exitprocess::exit 会立即停止程序并将传递给它的数字作为退出状态码。

main

我们将提取一个叫做 run 的函数来存放目前 main 函数中不属于设置配置或处理错误的所有逻辑。现在 run 函数包含了 main 中从读取文件开始的剩余的所有逻辑,这样不再通过 expect 允许程序 panic,run 函数将会在出错时返回一个 Result<T, E>。这让我们进一步以一种对用户友好的方式统一 main 中的错误处理。

fn main() {
    // --snip--
    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);
    run(config);
}
use std::error::Error;

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

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

    Ok(())
}

// --snip--

我们使用 if let 来检查 run 是否返回一个 Err 值,不同于 unwrap_or_else,并在出错时调用 process::exit(1)run 并不返回像 Config::new 返回的 Config 实例那样需要 unwrap 的值。因为 run 在成功时返回 (),而我们只关心检测错误,所以并不需要 unwrap_or_else 来返回未封装的值,因为它只会是 ()

fn main() {
    // --snip--
    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

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

拆分到库

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

// src/lib.rs
use std::error::Error;
use std::fs;

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

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

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

//src/main.rs
use std::env;
use std::process;

use minigrep::Config;

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

编写测试

现在我们将逻辑提取到了 src/lib.rs 并将所有的参数解析和错误处理留在了 src/main.rs 中,为代码的核心功能编写测试将更加容易。我们可以直接使用多种参数调用函数并检查返回值而无需从命令行运行二进制文件了

我们将遵循测试驱动开发(Test Driven Development, TDD)的模式来逐步测试。这是一个软件开发技术,它遵循如下步骤:

  1. 编写一个失败的测试,并运行它以确保它失败的原因是你所期望的。
  2. 编写或修改足够的代码来使新的测试通过。
  3. 重构刚刚增加或修改的代码,并确保测试仍然能通过。
  4. 从步骤 1 开始重复!
#[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));
    }
}

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
}

需要在 search 的签名中定义一个显式生命周期 'a 并用于 contents 参数和返回值,现在 search 函数是可以工作并测试通过了的,我们需要实际在 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.filename)?;

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

    Ok(())
}

环境变量

之后,我们将增加一个额外的功能,用户可以通过设置环境变量来设置搜索是否是大小写敏感的 。我们希望增加一个新函数 search_case_insensitive,并将会在设置了环境变量时调用它。

按照我们的开发规范,先写一个TDD测试

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

之后编写search_case_insensitive 函数,与 search 函数基本相同。唯一的区别是它会将 query 变量和每一 line 都变为小写,这样不管输入参数是大写还是小写,在检查该行是否包含查询字符串时都会是小写。

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
}

之后,我们将在 Config 结构体中增加一个配置项来切换大小写敏感和大小写不敏感搜索。

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

接着我们需要 run 函数检查 case_sensitive 字段的值并使用它来决定是否调用 search 函数或 search_case_insensitive 函数

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

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

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

    Ok(())
}

最后需要实际检查环境变量。处理环境变量的函数位于标准库的 env 模块中,接着在 Config::new 中使用 env 模块的 var 方法来检查一个叫做 CASE_INSENSITIVE 的环境变量,env::var 返回一个 Result,它在环境变量被设置时返回包含其值的 Ok 成员,并在环境变量未被设置时返回 Err 成员。

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

impl Config {
    pub 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();

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query,
            filename,
            case_sensitive,
        })
    }
}

如果你使用 PowerShell,则需要用两个命令来分别设置环境变量并运行程序:

PS> $Env:CASE_INSENSITIVE=1; cargo run to poem.txt

输出错误

目前为止,我们将所有的输出都通过 println! 写到了终端。大部分终端都提供了两种输出:标准输出(standard outputstdout)对应一般信息,标准错误(standard errorstderr)则用于错误信息。

可以通过将标准输出流重定向到一个文件同时有意产生一个错误来做到这一点。我们没有重定向标准错误流,所以任何发送到标准错误的内容将会继续显示在屏幕上。

我们通过 > 和文件名 output.txt 来运行程序,我们期望重定向标准输出流到该文件中:

$ cargo run > output.txt

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

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

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

println! 改为 eprintln! 之后,让我们再次尝试用同样的方式运行程序,不使用任何参数并通过 > 重定向标准输出:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

如果使用不会造成错误的参数再次运行程序,不过仍然将标准输出重定向到一个文件,像这样:

$ cargo run to poem.txt > output.txt

我们并不会在终端看到任何输出,同时 output.txt 将会包含其结果:

Are you nobody, too?
How dreary to be somebody!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

摸鱼老萌新

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值