这篇文章是关于实现SortaSecret.com的系列文章的一部分。
clap
是一个提供解析命令行选项能力的库。对于SortaSecret.com,我们有相对简单的解析需求:两个子命令和一些选项。其中一些选项是可选的,而其他选项则是必须的。而且其中一个子命令必须被选中。我们将演示如何用clap
来进行解析。
请记住,除了我将在下面使用的clap
接口之外,还有一个 structopt
库,可以更直接地将参数解析为结构。这篇文章将完全不涉及structopt
;未来的文章可能会代替它。另外,在未来,看起来structopt
的功能将被并入clap
。
另外,最后说明:clap
的API 文档非常好,包括大量的工作实例。请你也看看这些。
读者的先决条件
这篇博文将假设你有Rust编程语言的基本知识,并且你已经安装了命令行工具(rustup
,cargo
, 等等)。如果你想了解更多信息,请看我们的Rust入门指南。
简单的例子
让我们写一个简单的命令行参数分析器,以确保一切正常。首先用cargo new clap1 --bin
,开始一个新的项目,然后在Cargo.toml
,在[dependencies]
下面添加clap = "2.33"
。在生成的clap1
目录里面,运行cargo run
,它应该构建clap
和它的依赖,然后打印Hello, world!
。或者更全面地说,在我的机器上。
当然,这还没有使用 clap
。让我们来解决这个问题。我们将解析命令行选项--name
,以找出要向谁问好。为了使用clap
,我们要遵循以下基本步骤:
- 在构建器样式中定义一个新的
App
值 - 继续使用该构建器样式,添加我们希望解析的参数
- 获取这些参数与实际命令行参数的匹配值
- 提取匹配的值并使用它们
这是我们的代码,完成了所有这些工作:
extern crate clap;
use clap::{Arg, App};
fn main() {
// basic app information
let app = App::new("hello-clap")
.version("1.0")
.about("Says hello")
.author("Michael Snoyman");
// Define the name command line option
let name_option = Arg::with_name("name")
.long("name") // allow --name
.takes_value(true)
.help("Who to say hello to")
.required(true);
// now add in the argument we want to parse
let app = app.arg(name_option);
// extract the matches
let matches = app.get_matches();
// Extract the actual name
let name = matches.value_of("name")
.expect("This can't be None, we said it was required");
println!("Hello, {}!", name);
}
你可以用cargo run
,这样做的结果应该是这样的:
$ cargo run
Compiling clap1 v0.1.0 (/Users/michael/Desktop/clap1)
Finished dev [unoptimized + debuginfo] target(s) in 1.06s
Running `target/debug/clap1`
error: The following required arguments were not provided:
--name <name>
USAGE:
clap1 --name <name>
For more information try --help
为了将命令行选项传递给我们的可执行文件,我们需要在cargo run
后面添加一个额外的--
,以告诉cargo
,命令行选项的其余部分应逐字传递给clap1
可执行文件,而不是由cargo
自己解析。比如说:
$ cargo run -- --help
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/clap1 --help`
hello-clap 1.0
Michael Snoyman
Says hello
USAGE:
clap1 --name <name>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
--name <name> Who to say hello to
当然,还有
$ cargo run -- --name Rust
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/clap1 --name Rust`
Hello, Rust!
练习
- 修改这个程序,使名称成为可选项,并提供一个合理的默认名称
- 删除
app
和name_option
的中间名称,而使用完全的构建器风格。你最终会得到这样的结果let matches = App::new...
- 支持
name
参数的短名称版本,以便cargo run -- -n Rust
工作
提取到一个结构
获得匹配的东西是很好的。但在我看来,Rust最好的吸引力之一是强类型的应用。为了实现这一点,我倾向于在匹配和实际应用代码之间有一个中间步骤,我们把命令行选项提取到一个结构中。这将我们的解析逻辑隔离到一个区域,让编译器在其他地方帮助我们。
顺便说一下,这正是structopt
为我们做的事情。但是今天,我们将直接用clap
来做。
第一步是定义struct
。在我们的例子中,我们正好有一个真正的参数,一个name
,它是一个必需的String
。因此,我们的struct
将看起来像:
struct HelloArgs {
name: String,
}
然后,我们基本上可以把我们的原始代码复制粘贴到这个struct
,类似于impl
:
impl HelloArgs {
fn new() -> Self {
// basic app information
let app = App::new("hello-clap")
...
// Extract the actual name
let name = matches.value_of("name")
.expect("This can't be None, we said it was required");
HelloArgs { name: name.to_string() }
}
}
然后我们就可以使用它了
fn main() {
let hello = HelloArgs::new();
println!("Hello, {}!", hello.name);
}
由于代码几乎完全没有变化,我不会在这里把它列入内联,但你可以在Github Gist上看到它。
练习
- 像以前一样,修改程序,使名字成为可选项。但要通过修改
struct HelloArgs
,使之有一个Option<String>
字段。
更好的测试
上面的重构并没有给我们带来什么,尤其是在这样一个小程序上。然而,我们已经可以利用这一点来开始做一些测试。为了做到这一点,我们首先要修改我们的new
方法,将命令行参数作为一个参数,而不是从全局可执行文件中读取它。我们还需要修改返回值,使其返回一个Result
,以处理解析失败的情况。到现在为止,我们一直依靠clap
本身来打印错误信息并自动退出进程。
首先,让我们看一下我们的签名是什么:
fn new_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: Iterator<Item = T>,
T: Into<OsString> + Clone,
使用clap::Error
'sexit()
方法,我们可以恢复我们原来的new
函数,根据实际的命令行参数工作,并在解析错误时相当容易地退出程序:
fn new() -> Self {
Self::new_from(std::env::args_os().into_iter()).unwrap_or_else(|e| e.exit())
}
在我们的new_from
方法中,我们只需要将对get_matches
的调用替换为:
// extract the matches
let matches = app.get_matches_from_safe(args)?;
并用Ok
来包裹最终值:
Ok(HelloArgs {
name: name.to_string(),
})
太棒了,现在我们准备写一些测试了。首先,请注意:当我们提供参数列表时,第一个参数总是可执行文件的名称。我们的第一个测试将确保当没有提供参数时,我们得到一个参数解析错误,它看起来像这样:
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_no_args() {
HelloArgs::new_from(["exename"].iter()).unwrap_err();
}
}
我们也可以测试一下,使用没有值的--name
选项是不会解析的:
#[test]
fn test_incomplete_name() {
HelloArgs::new_from(["exename", "--name"].iter()).unwrap_err();
}
最后测试一下,当提供了一个名字时,事情会正常进行:
#[test]
fn test_complete_name() {
assert_eq!(
HelloArgs::new_from(["exename", "--name", "Hello"].iter()).unwrap(),
HelloArgs { name: "Hello".to_string() }
);
}
属性检查
在这整个难题中,还有一个问题困扰着我。new
或new_from
的调用者知道它已经收到了一个很好的类型值。然而,在new_from
中,我们使用expect
来处理那些应该不可能发生的情况。例如,我们的name_option
将required
设置为true
,因此我们知道matches.value_of("name")
的调用不会返回一个None
。然而,我们如何保证我们记得将required
设置为true
?
改善这种情况的一种方法是使用属性测试。在参数解析的情况下,我可以陈述一个简单的属性:对于我可以发送的所有可能的字符串作为输入,解析将返回一个有效的HelloArgs
,或者生成一个clap::Error
。然而,在任何情况下,它都不应该panic
。使用quickcheck
和quickcheck_macros
箱子,我们可以准确地测试这一点。首先,我们在文件的顶部添加以下内容:
#[cfg(test)]
extern crate quickcheck;
#[cfg(test)]
#[macro_use(quickcheck)]
extern crate quickcheck_macros;
然后写上这个漂亮的小属性。
#[quickcheck]
fn prop_never_panics(args: Vec<String>) {
let _ignored = HelloArgs::new_from(args.iter());
}
果然,如果你把required
设为false
,这个属性就会失效。
我将在文章的最后附上这个的完整代码。
仅仅是表面现象
有很多其他的事情你可能想用clap
,我们不打算在这里涵盖所有的事情。它是一个很好的库,可以产生很好的CLI。你可能有点惊讶,这篇文章声称是实现SortaSecret.com系列的一部分。这就是SortaSecret出现的地方。源代码包括cli
模块,它有一个子命令的例子,因此使用enum
来处理不同的变体。
完整的代码
extern crate clap;
use clap::{App, Arg};
use std::ffi::OsString;
#[cfg(test)]
extern crate quickcheck;
#[cfg(test)]
#[macro_use(quickcheck)]
extern crate quickcheck_macros;
#[derive(Debug, PartialEq)]
struct HelloArgs {
name: String,
}
impl HelloArgs {
fn new() -> Self {
Self::new_from(std::env::args_os().into_iter()).unwrap_or_else(|e| e.exit())
}
fn new_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: Iterator<Item = T>,
T: Into<OsString> + Clone,
{
// basic app information
let app = App::new("hello-clap")
.version("1.0")
.about("Says hello")
.author("Michael Snoyman");
// Define the name command line option
let name_option = Arg::with_name("name")
.long("name") // allow --name
.short("n") // allow -n
.takes_value(true)
.help("Who to say hello to")
.required(true);
// now add in the argument we want to parse
let app = app.arg(name_option);
// extract the matches
let matches = app.get_matches_from_safe(args)?;
// Extract the actual name
let name = matches
.value_of("name")
.expect("This can't be None, we said it was required");
Ok(HelloArgs {
name: name.to_string(),
})
}
}
fn main() {
let hello = HelloArgs::new();
println!("Hello, {}!", hello.name);
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_no_args() {
HelloArgs::new_from(["exename"].iter()).unwrap_err();
}
#[test]
fn test_incomplete_name() {
HelloArgs::new_from(["exename", "--name"].iter()).unwrap_err();
}
#[test]
fn test_complete_name() {
assert_eq!(
HelloArgs::new_from(["exename", "--name", "Hello"].iter()).unwrap(),
HelloArgs { name: "Hello".to_string() }
);
}
#[test]
fn test_short_name() {
assert_eq!(
HelloArgs::new_from(["exename", "-n", "Hello"].iter()).unwrap(),
HelloArgs { name: "Hello".to_string() }
);
}
/* This property will fail, can you guess why?
#[quickcheck]
fn prop_any_name(name: String) {
assert_eq!(
HelloArgs::new_from(["exename", "-n", &name].iter()).unwrap(),
HelloArgs { name }
);
}
*/
#[quickcheck]
fn prop_never_panics(args: Vec<String>) {
let _ignored = HelloArgs::new_from(args.iter());
}
}