使用 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, }
简单解释其中的关键部分:
use clap::Parser;
: 这是导入clap
库中的Parser
trait
,它用于定义命令行参数和解析命令行输入。#[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'.
如我们所见,没有输出。这是好事:这意味着没有错误,我们的程序已经结束。
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
将错误组合成两个主要类别:{可恢复错误|recoverable}和 {不可恢复错误|unrecoverable}。
- 可恢复错误通常代表向用户报告错误和重试操作是合理的情况,比如未找到文件
- 不可恢复错误通常是
bug
的同义词,比如尝试访问超过数组结尾的位置。
Rust
有panic!宏
。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出
这可能看起来有点激进,但非常方便。如果我们的程序需要读取该文件,如果文件不存在无法执行任何操作,那么退出是一种有效的策略。甚至在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(()) }
我们来简单对每行代码做一次解释:
fn main() -> Result<(), Box<dyn std::error::Error>>
: 这是程序的入口点main
函数的签名。它返回一个Result
类型,表示程序的执行结果。
Result
的成功值是()
,表示成功执行而没有返回值。- 错误值是一个包装了实现了
std::error::Error
trait 的错误对象的Box
。
let result = std::fs::read_to_string("test.txt");
: 这行代码尝试打开并读取文件 "test.txt" 的内容。它使用了标准库中的std::fs::read_to_string
函数,该函数返回一个Result<String, std::io::Error>
,表示读取文件内容的结果。let content = match result { ... }
: 这是一个模式匹配语句,用于处理文件读取的结果result
。
- 如果读取成功 (
Ok(content)
),则将读取的内容存储在content
变量中。 - 如果读取失败 (
Err(error)
),则将错误转换为Result
,并将其返回作为程序的错误结果。
println!("文件内容:{}", content);
: 如果成功读取文件内容,程序将打印文件的内容到标准输出,使用{}
占位符来插入content
变量的值。Ok(())
: 最后,程序返回一个成功的Result
,表示程序执行成功。
注意:为什么这不写作
return Ok(())
;?它完全可以这样写,这也是完全有效的。在Rust
中,任何块的最后一个表达式都是它的返回值,习惯上省略不必要的返回。