如何用clap进行命令行解析

1,049 阅读7分钟

这篇文章是关于实现SortaSecret.com的系列文章的一部分。

clap是一个提供解析命令行选项能力的库。对于SortaSecret.com,我们有相对简单的解析需求:两个子命令和一些选项。其中一些选项是可选的,而其他选项则是必须的。而且其中一个子命令必须被选中。我们将演示如何用clap 来进行解析。

请记住,除了我将在下面使用的clap 接口之外,还有一个 structopt库,可以更直接地将参数解析为结构。这篇文章将完全不涉及structopt ;未来的文章可能会代替它。另外,在未来,看起来structopt 的功能将被并入clap

另外,最后说明:clapAPI 文档非常好,包括大量的工作实例。请你也看看这些。

读者的先决条件

这篇博文将假设你有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!

练习

  • 修改这个程序,使名称成为可选项,并提供一个合理的默认名称
  • 删除appname_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() }
    );
}

属性检查

在这整个难题中,还有一个问题困扰着我。newnew_from 的调用者知道它已经收到了一个很好的类型值。然而,在new_from 中,我们使用expect 来处理那些应该不可能发生的情况。例如,我们的name_optionrequired 设置为true ,因此我们知道matches.value_of("name") 的调用不会返回一个None 。然而,我们如何保证我们记得将required 设置为true

改善这种情况的一种方法是使用属性测试。在参数解析的情况下,我可以陈述一个简单的属性:对于我可以发送的所有可能的字符串作为输入,解析将返回一个有效的HelloArgs ,或者生成一个clap::Error 。然而,在任何情况下,它都不应该panic 。使用quickcheckquickcheck_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());
    }
}