你必须按所想去生活,否则只能按生活去想。 --王小波

大家好,我是柒八九

作为一个前端/Rust/AI知识博主,之前的文章中,大部分篇幅都是关于前端的知识分享,而对RustAI的内容只是做了几篇内容梳理和介绍。

而,我们今后的重心也会逐渐偏移,势必能达到前端/Rust/AI三足鼎立的局面。

这里也和很多精神股东做一次简短的汇报,之前答应大家多出一些Rust相关的文章,由于工作和个人事务侵占大部分学习和总结的时间,所以迟迟没有兑现承诺。也很感谢大部分老粉能不离不弃,在这里先叩谢大家了。

你们的支持也是我输入内容的精神支柱,同时也很感谢有些远在天涯海角的朋友,不停的给出建议和改进意见,Last but not least,由于有些技术能力的有限,在一些表达方式和技术深度方向上,有很多瑕疵。也希望以后大家,互相学习,共同进步。

好了,估计大家不想听我在这里一个人聒噪了,那么就进入我们今天的主题。


前言

在上一篇致所有渴望学习Rust的人的信中我们介绍了Rust可以在命令行工具上也大有建树。

现在就是我们兑现承诺的时候了。

Rust是一种静态编译的、快速的语言,具有出色的工具支持和迅速增长的生态系统。这使它非常适合编写命令行应用程序。

通过编写具有简单CLI的程序,对于那些初学者来说是一个很好的练习,也是我们需要循序渐进的一个过程。毕竟,大家刚开始接触一个新的语言都是从Hello World的入手的,但是这种Demo级别的程序,可以说是闭门造车,没有任何的实际价值。并且这种程序是难登大雅之堂的。

所以,我们今天来通过一个简单的CLI来巩固之前的内容,并且写出的东西也可以在公司应用场景中有用武之地。

所以说选择很重要,我们不要成为别人口中说的你之所以穷,是因为你不够努力的人。

我们在讲解代码中,有一些基础语法会一带而过,也就是说,已经默认大家已经有Rust基础了。如果,你是一个Rust初学者,我们也提供了Rust学习笔记系列,可以快速掌握基础语法。当然,里面的有一些内容也会做一些简单的梳理和讲解。这个就因人而异了,看大家实际情况吧。

由于篇幅的原因,我们打算写三篇文章(上/中/下),来介绍如何用Rust来编写属于自己的命令行工具。 今天是第一篇文章,我们主要的目的是用Rust写出一个可用的命令行工具。属于本地应用级别,现在先不要嗤之以鼻,我们后面的2篇文章,会逐步优化这个项目,然后达到最后发版供别人使用的级别。

你能所学到的知识点

  1. 前置知识点
  2. 项目设置
  3. 解析命令行参数
  4. 解析文件内容
  5. 更人性化的错误报告
  6. 信息输出处理
  7. 代码展示 (这个狠重要) 👈 徐志胜语音包

好了,天不早了,干点正事哇。


1. 前置知识点

前置知识点,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略

同时,由于阅读我文章的群体有很多,所以有些知识点可能我视之若珍宝,尔视只如草芥,弃之如敝履。以下知识点,请酌情使用

grep 简介

grep 是一个常用的命令行工具,用于在文本文件中搜索指定的文本模式返回匹配的行。其名称来源于 global regular expression print(全局正则表达式打印),它最初是在UNIX操作系统中开发的,现在已经成为大多数Unix-like系统(包括Linux)的标准工具之一。grep 的主要功能是查找文件中包含特定文本的行,并将这些行打印到标准输出(通常是终端)上。

以下是 grep 命令的基本语法:

grep [选项] 模式 [文件...]
  • 选项:可以是一些控制搜索行为的可选标志,例如 -i(忽略大小写)、-r(递归搜索目录)、-l(仅显示包含匹配项的文件名)等。
  • 模式:要搜索的文本模式,通常使用正则表达式来指定。
  • 文件:要搜索的文件列表。如果不指定文件,则 grep 将从标准输入中读取数据。

一些常见的 grep 用法示例:

  1. 在文件中搜索特定字符串(不区分大小写):

    grep -i "search_text" file.txt
  2. 在多个文件中递归搜索特定字符串并显示包含匹配项的文件名:

    grep -r -l "search_text" directory/
  3. 使用正则表达式搜索匹配模式:

    grep "pattern.*text" file.txt
  4. 统计匹配的行数:

    grep -c "pattern" file.txt

grep 是一个强大的文本搜索工具,可以在各种情况下用于过滤、查找和处理文本数据。它的灵活性和正则表达式支持使得它在命令行中非常有用。


让我们编写一个小型的类似grep的工具。给它起一个霸气侧漏的名称,那就叫它 - f789吧。

我们可以在我们本地,创建一个文件夹,作为项目的工作目录。(这就看个人喜好,自行决断了)

最终,我们希望能够像这样运行我们的工具:

// 创建一个text.txt文件,并向其写入指定内容
echo "front:789" > text.txt
echo "province:山西" >> text.txt
echo "rust: hello" >> text.txt


$ f789 rust test.txt
rust: hello
$ f789 --help
// 提供一些帮助选项
本文中rustc采用的是1.72.0 (5680fa18f 2023-08-23)的版本。并且在Cargo.toml文件的[package]部分中设置edition = "2021"

如果,版本不对会有一些库的兼容性问题,所以最好大家在运行代码前,做一下代码配置和相关的处理。具体的配置和升级可以参考Rust环境配置和入门指南.

在使用对应命令升级之前,这里有一个小的提示,如果你在Mac中使用brew安装过Rust,你最好检测一下对应的版本信息。可以使用rustc --version命令,会返回指定版本信息。例如:rustc 1.68.2 (9eb3afe9e 2023-03-27) (built from a source tarball)

但是,(built from a source tarball)这一部分表示 Rust 编译器不是通过二进制发布版安装的,而是从 Rust 源代码中编译生成的。这通常是因为我们手动构建 Rust 或从源代码仓库中获取 Rust 的最新版本。这种情况的话,在使用rustup update进行版本更新的时候,会有问题。所以我推荐安装官方的二进制发布版。(也就是官网的处理方式)

2. 项目设置

如果你尚未安装Rust,可以参考我们之前的文章Rust环境配置和入门指南。然后,打开一个终端并导航到我们想要放置应用程序代码的目录。

首先,在存储编程项目的目录中运行以下命令:cargo new f789。如果我们查看新创建的f789目录,我们将会找到一个典型的Rust项目设置:

我们用erdtree进行页面结构展示。当然,我们也可以用tree命令。一切的理所应当都是命运的暗中撮合。因为erdtree也是Rust写的。

  • 一个Cargo.toml文件,其中包含我们项目的元数据,包括我们使用的依赖/外部库列表。
  • 一个src/main.rs文件,它是我们二进制文件的入口点

如果我们可以在f789目录中执行cargo run并获得一个Hello World,那么我们已经设置好了。

项目运行

$ cargo new f789
     Created binary (application) `f789` package
$ cd f789/
$ cargo run
   Compiling f789 v0.1.0 (项目存储路径)
    Finished dev [unoptimized + debuginfo] target(s) in 0.70s
     Running `target/debug/f789`
Hello, world!


3. 解析命令行参数

一般的CLI都支持参数的输入:例如tree -a -L 2或者我们之前的erd -i -I -L 2 -y inverted

我们也想让我们的CLI具有这个功能:

$ f789 front test.txt

我们期望我们的程序查看test.txt并打印出包含front的行。但是我们如何获取这两个值呢?

程序名称后面的文本通常被称为命令行参数命令行标志(特别是当它们看起来像--这样时)。

在操作系统内部通常将它们表示为字符串列表 - 简而言之,它们由空格分隔。

有许多方法可以探查和识别这些参数,以及如何将它们解析成更容易处理的形式。我们还需要告诉使用我们程序的用户需要提供哪些参数以及它们期望的格式是什么。


获得参数

标准库中包含了函数std::env::args(),它提供了给定参数的迭代器。第一项(索引为0)是我们程序被调用的名称(例如,f789),其后的项是用户在后面写的内容。

通过这种方式获取原始参数非常容易(在文件src/main.rs中,在fn main() {之后):

let pattern = std::env::args().nth(1).expect("未提供模式");
let path = std::env::args().nth(2).expect("未提供路径");

这里,pattern将包含用户输入的第一个参数,path将包含用户输入的第二个参数。如果用户没有提供这些参数,程序将会报错并显示相应的错误消息。


将 CLI 参数自定义数据类型

与将CLI参数视为一堆文本相比,将其视为表示程序输入的自定义数据类型通常更有帮助。

看看 f789 front test.txt:有两个参数,首先是模式(要查找的字符串),然后是路径(要查找的文件)。

此外还有其它需要注意的点?首先,它们都是必需的。我们还没有讨论默认值,因此我们期望用户始终提供两个值。此外,我们还可以谈谈它们的类型:模式应该是一个字符串,而第二个参数应该是文件的路径

Rust中,通常以处理的数据为中心来构建程序,因此以这种方式看待CLI参数非常合适。让我们做一层数据抽象(在文件src/main.rs中,在fn main() {之前):

struct Cli {
    pattern: String,
    path: std::path::PathBuf,
}

这定义了一个新的结构体(struct),它有两个字段来存储数据:patternpath

注意:PathBuf类似于String,但用于跨平台的文件系统路径。

现在,我们需要将我们的程序接收到的实际参数转换为这种形式。一种选项是手动解析操作系统获取的字符串列表并自己构建结构。代码可能如下所示:

let pattern = std::env::args().nth(1).expect("未提供模式");
let path = std::env::args().nth(2).expect("未提供路径");
let args = Cli {
    pattern: pattern,
    path: std::path::PathBuf::from(path),
};

这种方法是可行的,但不够方便。上面的方式无法满足,用户天马行空的创造力。例如:遇到类似--pattern="front"--pattern "front"--help 的参数形式上面的代码就捉襟见肘了。

也就是说,上面的代码不够优雅。


使用 Clap 解析 CLI 参数

站在巨人的肩膀上,你会看的更高。是不是很熟悉的名言警句,是否勾起你儿时那种贴满走廊的校园回忆。

我们可以使用别人写好的工具库。而用于解析命令行参数的最流行库称为clap。它具备我们所期望的所有功能,包括支持子命令、Shell自动完成以及出色的帮助消息。

首先,通过将clap = { version = "4.0", features = ["derive"] }添加到我们的Cargo.toml文件的[dependencies]部分来导入clap

[dependencies]
clap = { version = "4.4.2", features = ["derive"] }

现在,我们可以在代码中使用use clap::Parser;,并在我们的struct Cli上方添加#[derive(Parser)]。让我们还顺便写一些文档注释。

代码看起来像这样(在文件src/main.rs中,在fn main() {之前):

use clap::Parser;

/// 在文件中搜索模式并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: std::path::PathBuf,
}

简单解释其中的关键部分:

  1. use clap::Parser;: 这是导入 clap 库中的 Parser trait,它用于定义命令行参数和解析命令行输入。
  2. #[derive(Parser)]: 这是一个自定义属性(attribute),用于自动实现 Parser trait。通过这个属性,我们可以在结构体上使用 Parser 的功能,使其成为一个可以解析命令行参数的类型。

通过使用 clap 库中的 Parser trait,我们可以轻松地为我们的命令行工具定义参数和解析用户提供的命令行输入。这有助于使命令行工具更加灵活和易于使用,同时提供了自动生成帮助文档和解析命令行参数的功能。

关于trait可以参考我们之前的Rust 泛型、trait 与生命周期中的内容

注意:我们可以在字段上添加许多自定义属性。例如,要表示我们希望将此字段用作-o--output之后的参数,我们可以添加#[arg(short = 'o', long = "output")]。有关更多信息,请参阅clap文档

Cli结构体下方,我们的模板包含了其main函数。当程序启动时,将调用此函数。第一行是:

fn main() {
    let args = Cli::parse();
}

这将尝试将参数解析为我们的Cli结构。

但如果失败怎么办?这就是这种方法的美妙之处:Clap知道期望哪些字段以及它们的预期格式。它可以自动生成漂亮的--help消息,并提供一些出色的错误提示,以建议我们在写--putput时传递--output


代码实操

我们的代码现在应该如下所示:

#![allow(unused)]

use clap::Parser;

/// 在文件中搜索模式并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();
}

在没有任何参数的情况下运行它:

$ cargo run
   Compiling f789 v0.1.0 (/Users/xxxx/RustWorkSpace/cli/f789)
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/f789`
error: the following required arguments were not provided:
  <PATTERN>
  <PATH>

Usage: f789 <PATTERN> <PATH>

For more information, try '--help'.

我们可以在使用cargo run时通过在--后面写参数来传递参数:

$ cargo run -- some-pattern some-file
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/f789 some-pattern some-file`

如我们所见,没有输出。这是好事:这意味着没有错误,我们的程序已经结束。


4. 解析文件内容

利用Clap进行参数处理后,我们轻而易举可以获取到用户输入数据。可以实现f789的内部逻辑了。我们的main函数现在只包含以下这行代码:

let args = Cli::parse();

接下来,我们逐步完善我们的内部逻辑,现在从打开我们得到的文件开始:

let content = std::fs::read_to_string(&args.path).expect("无法读取文件");

注意:看到这里的.expect方法了吗?这是一个快速退出的快捷函数,当值(在这种情况下是输入文件)无法读取时,它会立即使程序退出。具体的使用情况,参看Rust错误处理

然后,让我们迭代每一行,并打印包含我们模式的每一行:

for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

代码实操

我们的代码现在应该如下所示:

#![allow(unused)]

use clap::Parser;

/// 在文件中搜索模式并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path).expect("无法读取文件");

    for line in content.lines() {
        if line.contains(&args.pattern) {
            println!("{}", line);
        }
    }
}

试一试:cargo run -- main src/main.rs 现在应该可以工作了!

上面的代码,虽然能满足我们的业务需求,但是还不够完美。有一个弊端:它会将整个文件读入内存 - 无论文件有多大。如果我们想在一个庞然大物中搜索我们需要的内容,那就有点不爽了。

我们可以使用 BufReader 来优化上面的代码:

#![allow(unused)]

use clap::Parser;
use std::io::{self, BufRead};
use std::fs::File;

/// 在文件中搜索模式并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: std::path::PathBuf,
}

fn main() {
    let args = Cli::parse();

    // 打开文件并创建一个 BufReader 来逐行读取
    let file = File::open(&args.path).expect("无法打开文件");
    let reader = io::BufReader::new(file);

    for line in reader.lines() {
        let line = line.expect("无法读取行");
        if line.contains(&args.pattern) {
            println!("{}", line);
        }
    }
}

这个版本的代码使用 BufReader 来逐行读取文件,而不是一次性读取整个文件内容,这样可以更有效地处理大文件。BufReader 在内部缓冲读取的数据,以提高性能,并且适合用于逐行处理文本文件。


5. 更人性化的错误报告

使用其它语言时候,我们时刻会担心会存在莫名其妙的错误,从而使得我们自诩健壮的代码,变得一文不值。而Rust不一样,当使用Rust时,我们可以放心的去写相关逻辑。因为它没有异常,所有可能的错误状态通常都编码在函数的返回类型中

Result

read_to_string这样的函数不会返回一个字符串。相反,它返回一个Result,其中包含一个String或某种类型的错误(在这种情况下是std::io::Error)。

Result是一个枚举,我们可以使用match来检查它是哪个变体:

let result = std::fs::read_to_string("test.txt");
match result {
    Ok(content) => { println!("文件内容: {}", content); }
    Err(error) => { println!("出错了: {}", error); }
}

想了解Rust中枚举和它如何工作的,可以参考Rust枚举和匹配模式

Unwrapping

现在,我们已经能够访问文件的内容,但实际上我们无法在match块之后对其进行任何操作。为此,我们需要以某种方式处理错误情况。挑战在于match块的所有分支都需要返回相同类型的内容。但有一个巧妙的技巧可以绕过这个问题:

let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) => { content },
    Err(error) => { panic!("无法处理错误:{},在这里退出", error); }
};
println!("文件内容:{}", content);

match块之后,我们可以使用content中的String。如果result是一个错误,String将不存在。但由于程序在达到使用content的地方之前会退出,所以没问题。

Rust 将错误组合成两个主要类别:<span style="font-weight:800;color:#FFA500;font-size:18px">{可恢复错误|recoverable}</span>和 <span style="font-weight:800;color:#FFA500;font-size:18px">{不可恢复错误|unrecoverable}</span>。

  • 可恢复错误通常代表向用户报告错误和重试操作是合理的情况,比如未找到文件
  • 不可恢复错误通常是 bug 的同义词,比如尝试访问超过数组结尾的位置。

    • Rustpanic!宏。<span style="font-weight:800;color:red;font-size:18px">当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出</span>

这可能看起来有点激进,但非常方便。如果我们的程序需要读取该文件,如果文件不存在无法执行任何操作,那么退出是一种有效的策略。甚至在Result上还有一个快捷方法,称为unwrap

let content = std::fs::read_to_string("test.txt").unwrap();

panic的替代方案

当然,中止程序并不是处理错误的唯一方法。除了使用panic!之外,我们也可以轻松地使用return

let result = std::fs::read_to_string("test.txt");
let content = match result {
    Ok(content) => { content },
    Err(error) => { return Err(error.into()); }
};

然而,这改变了我们的函数需要的返回类型。所以,我们需要处理一下函数签名。

以下是完整示例:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = std::fs::read_to_string("test.txt");
    let content = match result {
        Ok(content) => { content },
        Err(error) => { return Err(error.into()); }
    };
    println!("文件内容:{}", content);
    Ok(())
}

我们来简单对每行代码做一次解释:

  1. fn main() -> Result<(), Box<dyn std::error::Error>>: 这是程序的入口点 main 函数的签名。它返回一个 Result 类型,表示程序的执行结果。

    • Result 的成功值是 (),表示成功执行而没有返回值。
    • 错误值是一个包装了实现了 std::error::Error trait 的错误对象的 Box
  2. let result = std::fs::read_to_string("test.txt");: 这行代码尝试打开并读取文件 "test.txt" 的内容。它使用了标准库中的 std::fs::read_to_string 函数,该函数返回一个 Result<String, std::io::Error>,表示读取文件内容的结果。
  3. let content = match result { ... }: 这是一个模式匹配语句,用于处理文件读取的结果 result

    • 如果读取成功 (Ok(content)),则将读取的内容存储在 content 变量中。
    • 如果读取失败 (Err(error)),则将错误转换为 Result,并将其返回作为程序的错误结果。
  4. println!("文件内容:{}", content);: 如果成功读取文件内容,程序将打印文件的内容到标准输出,使用 {} 占位符来插入 content 变量的值。
  5. Ok(()): 最后,程序返回一个成功的 Result,表示程序执行成功。
注意:为什么这不写作return Ok(());?它完全可以这样写,这也是完全有效的。在Rust中,任何块的最后一个表达式都是它的返回值,习惯上省略不必要的返回。

?操作

就像调用.unwrap()是与panic!在错误分支中的匹配的快捷方式一样,我们还有另一个与在错误分支返回的匹配的快捷方式:?

你没有看错,就是一个问号。我们可以将此操作符附加到Result类型的值上,Rust将在内部将其扩展为与我们刚刚编写的match非常相似的东西

可以将对应的代码部分改成如下格式:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("test.txt")?;
    println!("文件内容:{}", content);
    Ok(())
}

难道这就是传说中,从天而降的掌法嘛。这也太丝滑了。

这里有一些Rust开发中的潜规则。例如,我们main函数中的错误类型是Box<dyn std::error::Error>。但是我们已经看到read_to_string返回的是std::io::Error。这是因为?扩展为转换错误类型的代码。

同时,Box<dyn std::error::Error>也是一个有趣的类型。它是一个Box,可以包含任何实现标准Error trait的类型。这意味着基本上所有错误都可以放入这个Box中,因此我们可以在所有通常返回Result的函数上使用?

有关Box的使用原理和介绍可以参考Rust智能指针


为错误提供合适的语境提示

使用?在主函数中时,得到的错误是可以接受的,但不是很好。例如:当我们运行std::fs::read_to_string("test.txt")?但文件test.txt不存在时,我们会得到以下输出:

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

在代码中不包含文件名的情况下,很难确定哪个文件是NotFound。有多种处理方式。

创建自己的错误类型

我们可以创建自己的错误类型,然后使用它来构建自定义错误消息:

#[derive(Debug)]
struct CustomError(String);

fn main() -> Result<(), CustomError> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .map_err(|err| CustomError(format!("在读取`{}`时: {}", path, err)))?;
    println!("文件内容:{}", content);
    Ok(())
}

我们来简单解释一下上面的代码

  1. #[derive(Debug)] struct CustomError(String);: 这个代码定义了一个自定义的错误类型 CustomError,它包含一个字符串字段用于存储错误消息。#[derive(Debug)] 属性宏为这个结构体自动生成了 Debug trait 的实现,以便在打印错误时更容易调试。
  2. fn main() -> Result<(), CustomError> { ... }: 这是程序的入口点 main 函数的签名。与之前的代码不同,它返回一个 Result,其中成功值是 (),表示成功执行而没有返回值,错误值是自定义错误类型 CustomError
  3. let content = std::fs::read_to_string(path) ... ?;:与之前的代码不同,这里使用了 map_err 方法来处理可能的错误情况。

    • .map_err(|err| CustomError(format!("在读取{}时: {}", path, err))): 这部分使用 map_err 方法来处理可能的错误情况。map_err 方法接受一个闭包(匿名函数),该闭包接受一个错误对象 err,并返回一个新的错误对象。在这个闭包中,它将原始的 std::io::Error 错误转换为自定义的 CustomError 错误类型,并添加了一条包含错误信息的自定义错误消息。
    • ?: 这个问号 ?Rust 中的错误处理操作符。它用于处理 Result 类型的返回值。如果 Result 是一个 Ok,则 ? 不会执行任何操作,它会将成功的值提取出来。如果 Result 是一个 Err,则 ? 会立即将错误返回给调用者,作为整个函数的返回值,就好像使用 return Err(...) 一样。

现在,运行这个程序将会得到我们自定义的错误消息:

Error: CustomError("在读取`test.txt`时: No such file or directory (os error 2)")

虽然不太美观,但我们可以稍后轻松调整我们类型的调试输出。

使用anyhow库

上面的模式非常常见。但它有一个问题:我们没有存储原始错误,只有它的字符串表示。我们可以使用anyhow库对此有一个巧妙的解决方案:与我们的CustomError类型类似,它的Context trait 可以用来添加描述。此外,它还保留了原始错误,因此我们得到一个指出根本原因的错误消息“链”。

首先,通过在Cargo.toml文件的[dependencies]部分添加anyhow = "1.0.75"来导入anyhow crate

然后,完整的示例将如下所示:

use anyhow::{Context, Result};

fn main() -> Result<()> {
    let path = "test.txt";
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("无法读取文件 `{}`", path))?;
    println!("文件内容:{}", content);
    Ok(())
}

这将打印一个错误:

Error: 无法读取文件 `test.txt`

Caused by:
    No such file or directory (os error 2)

6. 信息输出处理

使用 println!

我们几乎可以使用println!宏打印所有我们喜欢的内容。这个宏具有一些非常惊人的功能,但也有特殊的语法。它希望我们将一个字符串字面量作为第一个参数,该字符串包含占位符,这些占位符将由后面的参数的值作为进一步的参数填充

例如:

let x = 789;
println!("我的幸运数字是 {}。", x);

将打印:

我的幸运数字是 789。

上述字符串中的花括号{})是其中的一个占位符。这是默认的占位符类型,它尝试以人机友好的方式打印给定的值。对于数字和字符串,这个方法非常有效,但并不是所有类型都可以这样做。这就是为什么还有一种调试模式(debug representation) --{:?}

例如:

let xs = vec![1, 2, 3];
println!("列表是:{:?}", xs);

将打印:

列表是:[1, 2, 3]

如果希望我们自己的数据类型能够用于调试和记录,大多数情况下可以在它们的定义之上添加#[derive(Debug)]

用户友好(User-friendly)打印使用Display trait调试输出(面向开发人员的输出)使用Debug trait。我们可以在std::fmt模块的文档中找到有关可以在println!中使用的语法的更多信息。


打印错误信息

通过stderr来打印错误,以使用户和其他工具更容易将其输出重定向到文件或其他工具。

在大多数操作系统上,程序可以写入两个输出流,stdoutstderr

  • stdout用于程序的实际输出
  • stderr允许将错误和其他消息与stdout分开

这样,可以将输出存储到文件或将其管道传输到另一个程序,而错误将显示给用户。

Rust中,可以通过println!eprintln!来实现这一点,前者打印到stdout,后者打印到stderr
println!("这是正常信息");
eprintln!("这是一个错误! :(");

在打印转义代码时,会使用户的终端处于奇怪现象,所以,当处理原始转义代码时,应该使用像ansi_term这样的crate来使我们的输出更加顺畅。


打印优化

向终端打印的速度出奇地慢!如果在循环中调用类似println!的函数,它可能成为程序运行的瓶颈。为了加快速度,有两件事情可以做。

1. 减少写入次数

首先,我们可能希望减少实际刷新到终端的写入次数。

println!在每次调用时都会告诉系统刷新到终端,因为通常会打印每一行。

如果我们不需要这样做,可以将stdout句柄包装在默认情况下缓冲最多8 KBBufWriter中。(当我们想立即打印时,仍然可以在此BufWriter上调用.flush()。)

use std::io::{self, Write};

let stdout = io::stdout(); // 获取全局stdout实体
let mut handle = io::BufWriter::new(stdout); // 可选:将该句柄包装在缓冲区中
writeln!(handle, "front: {}", 789); // 如果我们关心此处的错误,请添加`?`

2.使用锁

其次,可以获取stdout(或stderr)的锁,并使用writeln!直接打印到它。这可以防止系统一遍又一遍地锁定和解锁stdout

use std::io::{self, Write};

let stdout = io::stdout(); // 获取全局stdout实体
let mut handle = stdout.lock(); // 获取它的锁
writeln!(handle, "front: {}", 789); // 如果我们关心此处的错误,请添加`?`

我们还可以结合两种方法。

具体代码如下:

use std::io::{self, Write};

fn main() -> io::Result<()> {
    let stdout = io::stdout(); // 获取全局stdout实体
    let stdout_lock = stdout.lock(); // 获取stdout的锁
    
    // 将锁包装在BufWriter中
    let mut handle = io::BufWriter::new(stdout_lock);
    
    writeln!(handle, "front: {}", 789)?; // 如果我们关心此处的错误,请添加`?`

    Ok(())
}

在这个示例中,首先获取了 stdout 的锁,然后将锁传递给 io::BufWriter,最后使用 writeln!handle 写入数据。


显示一个进度条

某些CLI运行时间不到一秒,而其他一些可能需要几分钟或几小时。如果我们正在编写后者类型的程序,我们可能希望向用户显示正在发生的事情。为此,我们可以尝试打印有用的状态更新,最好以易于消耗的形式呈现。

使用indicatif crate,我们可以向我们的程序添加进度条和小的旋转器。

在使用之前,我们需要在Cargo.toml中引入对应的库。

[dependencies]
indicatif = { version = "*", features = ["rayon"] }

下面是使用indicatif的一个小示例。

fn main() {
    let pb = indicatif::ProgressBar::new(100);
    for i in 0..100 {
        do_hard_work();
        pb.println(format!("[+] 完成了第 #{}项", i));
        pb.inc(1);
    }
    pb.finish_with_message("任务完成");
}
fn do_hard_work() {
    use std::thread;
    use std::time::Duration;

    thread::sleep(Duration::from_millis(250));
}

有关更多信息,请参阅indicatif文档示例

日志

为了更容易理解程序中发生的情况,我们可能想要添加一些日志语句。通常在编写应用程序时这很容易。但在半年后再次运行此程序时,日志将变得非常有帮助。在某种程度上,日志记录与使用 println! 相同,只是你可以指定消息的重要性

通常可以使用的日志级别有 errorwarninfodebugtraceerror 优先级最高,trace 优先级最低)。

要向应用程序添加日志记录,你需要两样东西:

  1. log crate(其中包含了根据日志级别命名的宏)
  2. 一个实际将日志输出写到有用位置的适配器

由于我们现在只关心编写一个 CLI ,一个易于使用的适配器是 env_logger。它被称为env logger,因为你可以使用环境变量来指定你想要记录的应用程序部分(以及你想要记录它们的级别)。它将在日志消息前加上时间戳和消息来源的模块。由于库也可以使用 log,因此我们可以轻松配置它们的日志输出。

以下是简单示例:

配置Cargo.toml

[dependencies]
log = "0.4.20"
env_logger = "0.10.0"
use log::{info, warn};

fn main() {
    env_logger::init();
    info!("项目启动");
    warn!("这是一个警告信息");
}

假设你将此文件保存为 src/bin/output-log.rs,在 LinuxmacOS 上,你可以这样运行它:

$ env RUST_LOG=info cargo run --bin output-log

Windows PowerShell 中,你可以这样运行:

$ $env:RUST_LOG="info"
$ cargo run --bin output-log

Windows CMD 中,你可以这样运行:

$ set RUST_LOG=info
$ cargo run --bin output-log

上面的代码是在运行 Rust 项目中的二进制文件(通过指定 --bin 标志)并设置日志级别(通过 RUST_LOG 环境变量)。

针对主要的代码,做一下解释:

  1. env RUST_LOG=info: 这部分设置了一个环境变量 RUST_LOG,用于控制 Rust 项目中的日志记录级别。具体来说,它将日志级别设置为 info

    • Rust 项目通常使用日志库(例如 logenv_logger)来记录不同级别的日志消息。
    • info 是一个中等详细的级别,它会记录一些有用的信息,但不会过于冗长。你可以根据需要将日志级别设置为不同的值,如 debugwarnerror 等。
  2. --bin output-log: 这部分告诉 cargo 运行项目中名为 output-log 的二进制文件。Rust 项目通常包含多个二进制文件,这个选项指定要运行的二进制文件的名称。output-log 应该是你的 Rust 项目中一个二进制文件的名称。

综合起来,这行代码的作用是设置日志级别为 info,然后运行 Rust 项目中名为 output-log 的二进制文件。这有助于控制日志记录的详细程度,并查看项目中的输出日志。如果你的 Rust 项目使用了日志库,并且在代码中有相应的日志记录语句,那么设置日志级别为 info 会让你看到 info 级别的日志消息。


代码展示

我们上面通过几节的内容,从项目配置/参数获取/解析文件内容/处理错误信息/信息输出处理等方面。可以构建出在本地,兼容错误提示,并且有很好的输出形式的本地搜索工具。

让我们就上面的内容,从代码上做一次梳理和汇总。

use anyhow::{Context, Result};
use clap::Parser;
use indicatif::ProgressBar;
use std::fs::File;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;

/// 在文件中搜索模式并显示包含它的行。
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: PathBuf,
}

fn main() -> Result<()> {
    let args = Cli::parse();

    // 打开文件并创建一个 BufReader 来逐行读取
    let file = File::open(&args.path).with_context(|| format!("无法打开文件 {:?}", &args.path))?;
    let reader = io::BufReader::new(file);

    let stdout = io::stdout();
    let stdout_lock = stdout.lock();
    let mut handle = io::BufWriter::new(stdout_lock);
    let pb = ProgressBar::new(100);
    for line in reader.lines() {
        do_hard_work();
        pb.println(format!("[+] 查找到了 #{:?}项", line));
        pb.inc(1);
        let line = line.with_context(|| "无法读取行")?;
        if line.contains(&args.pattern) {
            writeln!(handle, "{}", line)?;
        }
    }

    Ok(())
}

fn do_hard_work() {
    thread::sleep(Duration::from_millis(250));
}

对应的Cargo.toml如下

[package]
name = "f789"
version = "0.1.0"
edition = "2021"


[dependencies]
clap = { version = "4.4.2", features = ["derive"] }
anyhow = "1.0.75"
indicatif = { version = "0.17.6", features = ["rayon"] }
log = "0.4.20"
env_logger = "0.10.0"

对应的运行结果如下:

在上文中我们手动创建了一个text.txt文件。我们只是创建了,没告诉它放置的位置。我们将与src目录同级。

使用erd -L 1 -y inverted命令查看目录信息

Cargo会默认把所有的源代码文件保存到src目录下,而项目根目录只被用来存储诸如README文档/许可声明/配置文件等与源代码无关的文件。

如果,我们想看针对大文件的处理方式,我们可以新建一个更大的项目。用于做代码实验。


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。


前端柒八九
18 声望3 粉丝