rust 使用第三方库构建mini命令行工具

1,182 阅读8分钟

这是上一篇 rust 学习 - 构建 mini 命令行工具的续作,扩展增加一些 crate 库。这些基础库在以后的编程工作中会常用到,他们作为基架存在于项目中,解决项目中的某个问题。

项目示例还是以上一篇的工程为基础做调整修改ifun-grep 仓库地址

怎么去使用已发布的 crate 库

在开发ifun-grep项目时,运行项目命令为cargo run -- hboot hello.txt,测试项目的逻辑正确。在发布到crates.io要如何使用呢,

在项目中使用

作为项目的一个功能函数,逻辑事务调用。在crates.io 中找到需要的库

安装已经发布的示例库ifun-grep . 通过cargo add添加依赖项

这里我们有一个测试示例项目rust-web,这是在另一篇rust 基础中创建的示例项目。

$> cargo add ifun-grep

安装成功后,可以在在项目的Cargo.toml看到依赖

[dependencies]
ifun-grep = "0.1.0"

main.rs导入库使用,这个库包括了一个结构体Config,三个方法find\find_insensitive\run

use ifun_grep;

fn main(){
    let search = String::from("let");
    let config = ifun_grep::Config {
        search,
        file_path: String::from("hello.txt"),
        ignore_case: true,
    };

    let result = ifun_grep::run(config);

    println!("{}", result.is_ok());
}

执行cargo run,可以看到输出了false。因为文件hello.txt不存在,在上一篇文中我们把错误处理统一放到了main.rs文件中处理的。而我们这边作为一个 lib 库,直接调用的run函数,所以这边没有任何的错误输出,只提示我们没有执行成功。

我们可以在项目目录下新建一个测试文件hello.txt

Let life be beautiful like summer flowers.

The world has kissed my soul with its pain.

Eyes are raining for her.

you also miss the stars.

再次运行,可以看到打印的输出内容。Let life be beautiful like summer flowers.

可以通过cargo remove ifun-grepCargo.toml移除依赖

作为脚本命令执行

可以看到作为功能性函数调用时,只能手动去初始化函数调用。不能像执行命令一样,传递参数调用,也就不能执行main.rs中的处理逻辑以及错误打印。

通过cargo install 安装二进制可执行文件的库

$> cargo install ifun-grep

安装完成后,就可以在全局环境中使用命令ifun-grep了。

通过cargo uninstall ifun-grep 移除。

开发时如何测试使用

开发时只能cargo run去执行main.rs文件,不能直接使用ifun-grep命令

可以通过cargo build 构建编译,在target/debug下生成二进制文件

ifun-grep-build.jpg

这样可以通过相对目录地址访问可执行文件执行命令

$> target/debug/ifun-grep Let hello.txt

如果我们的代码 存储在 github 或者 gitee 上,就可以将编译包压缩发布版本,这样需要的人不需要 cargo 就可以下载安装。

构建发布版本

$> cargo build --release

我的代码仓库在 giteeifun-grep 基础版本发布

ifun-grep-release v0.1.0.png

下载压缩包后,需要把可执行文件配置到系统环境中,全局可用。也可以不用配置,直接使用文件路径地址执行命令。

还需要考虑一个问题,就是系统的兼容性,mac、windows、linux 等等,想要发布一个兼容的库,可能还需要针对性构建编译包并发布

这里演示的是 mac 系统下载发布包后,通过路径访问执行命令

ifun-grep-release v0.1.0 -run.png

clap库解析 cli 参数

clap库包含了对子命令、shell 完成和更好的帮助信息。

安装,参数--features表示启动特定功能,

$> cargo add clap --features derive

clap除了提供基础的功能之外,还可以通过--features开启特定功能。derive启动自定义派生功能,可以通过过程宏处理参数。

src/main.rs 中使用

// use std::{env, process};
use clap::Parser;
use ifun_grep::{Config};

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

    let config = Config::parse();

    // let config = Config::build(&args).unwrap_or_else(|err| {
    //     // println!("error occurred parseing args:{err}");
    //     eprintln!("error occurred parseing args:{err}");
    //     process::exit(1);
    // });
}

结构体 Config 派生了一个内部函数parse,可以直接解析参数生成实例 config

还需要修改src/lib.rs,使得结构体 Config 用拥有这种能力

use clap::Parser;

#[derive(Parser, Debug)]
pub struct Config {
    #[arg(long)]
    pub search: String,
    #[arg(long)]
    pub file_path: String,
    #[arg(long)]
    pub ignore_case: bool,
}

首先不再使用std::env去解析 cli 参数,也不需要调用Config的 build 方法去实例化创建 config。

通过clap::Parser的过程式宏 Parser 去解析 cli 参数,并返回结构体Config的实例 config

执行命令

$> cargo run

报错了,如图,首先这个错误信息很友好,告诉我们必填的参数信息

clap-run.png

增加参数配置,调用命令执行

$> cargo run --  --search Let --file-path hello.txt

可以看到结果成功了,对比之前调用方式cargo run -- Let hello.txt,多了一个参数名称定义--search

#[arg(long)] 参数宏是用来定义参数接受的标志,arg还有许多其他的功能

移除掉#[arg(long)],执行命令 cargo run

use clap::Parser;

#[derive(Parser, Debug)]
pub struct Config {
    pub search: String,
    pub file_path: String,
    pub ignore_case: bool,
}

报错了,thread 'main' panicked at 'Argument 'ignore_case is positional, it must take a value,意思是 ignore_case 必须要有一个值,ignore_case是一个布尔值,隐式启动了#[arg(action = ArgAction::SetTrue)],所以需要设置接受标志

布尔值只需要通过设置标志,而不需要设置值,--ignore_case 就表示 true

use clap::Parser;

pub struct Config {
    pub search: String,
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
}

再次执行命令cargo run,

clap-run-noArg.png

还需要必填的两个参数,此时不需要--

$> cargo run --  Let hello.txt

要想开启大小写不敏感,则需要增加--ignore-case

$> cargo run --  let hello.txt --ignore-case

需要注意的是结构体定义的下划线ignore_case,在 clap 接受参数的标志为--ignore-case

增加命令的描述信息

通常 cli 的命令都有一个--help功能,这可以基本说明这个脚本是干嘛的,以及怎么去使用

而这些 clap 正好有。测试一下,代码修改后需执行cargo build

$> target/debug/ifun-grep --help

ifun-grep-help.png

可以看到对于ifun-grep一个基本的使用方式,包括Usage、Arguments、Options。还展示了对于结构体Config的注释说明、例子。 通过简写的-h可以让描述更加紧凑一点。

clap 通过#[command()]可以从Cargo.toml获取到一些基础信息,生成 Command 实例

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Config {
    pub search: String,
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
}

编译后,执行--help

ifun-grep-clap-command.png

也可以自定义这些字段的值。

#[derive(Parser)]
#[command(name = "ifun-grep")]
#[command(author = "hboot <bobolity@163.com>")]
#[command(version = "0.2.0")]
#[command(about="A simple fake grep",long_about=None)]
pub struct Config {
    pub search: String,
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
}

通过执行ifun-grep -V可以查看设置的name、version信息

定义参数非必须

通过Option定义字段数据类型,使得这个字段非必须

#[derive(Parser)]
pub struct Config {
    name: Option<String>,
}

通过--help查看参数是,必须的Arguments:参数是<SEARCH>使用尖括号的;而非必须的是中括号[name].

如果某个参数可以接受多个,则通过集合定义类型

#[derive(Parser)]
pub struct Config {
    name: Vec<String>,
}

在命令执行多余的参数会解析到字段 name 中。隐式的启动了#[arg(action = ArgAction::Set)],处理多个值。

使用标志命名参数

在之前的实例中,已经使用了#[arg(short, long)],它用来标识参数名称,它可以:

  • 意图表达更明确
  • 不用在意参数的顺序
  • 使参数变的可选
#[derive(Parser)]
pub struct Config {
    #[arg(short, long)]
    pub search: String,
    #[arg(short, long)]
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
}

可以通过--help查看变化,所有的参数都变成了Options

ifun-grep-arg.png

子命令

在执行ifun-grep时,携带子命令执行。通过#[derive(Subcommand)]标志属性,子命令也可以有自己的版本、作者信息、参数等等

use clap::{Args, Parser, Subcommand};

#[derive(Parser)]
pub struct Config {
    #[arg(short, long)]
    pub search: String,
    #[arg(short, long)]
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand)]
pub enum Commands {
    Add(AddArgs),
}

#[derive(Args)]
pub struct AddArgs {
    name: Option<String>,
}

默认值

可以通过#[arg(default_value_t)]定义默认值,定义字段file_path默认值

#[derive(Parser)]
pub struct Config {
    #[arg(short, long)]
    pub search: String,
    #[arg(short, long, default_value = "hello.txt")]
    pub file_path: String,
    #[arg(short, long)]
    pub ignore_case: bool,
}

调用命令执行时,可以不在设置该字段

$> target/debug/ifun-grep -s Let

命令执行是查询成功的.

其他的功能比如:数据校验、自定义值解析逻辑、自定义校验等等。

anyhow 处理错误

提供了一种错误类型anyhow::Error. 处理出现的错误。

之前处理文件读取的逻辑,使用了?

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

    Ok(())
}

当文件不存在时,会打印出错误信息something error:No such file or directory (os error 2).但不知道具体哪个文件不存在。

可以通过自定义错误类型IfunError,来构建自己的错误信息

#[derive(Debug)]
pub struct IfunError(String);

pub fn run(config: Config) -> Result<(), IfunError> {
    let file_path = config.file_path.clone();
    let content = fs::read_to_string(config.file_path)
        .map_err(|err| IfunError(format!("could not read file {} - {}", file_path, err)))?;

    // ...
    Ok(())
}

再次执行访问不存在的文件,报错信息为something error:IfunError("could not read file 1.txt - No such file or directory (os error 2)")

anyhow正好做了事情,可以通过 anyhow 的特征context可以附加错误内容信息,也保留了原始错误。

安装 crate anyhow

$> cargo add anyhow

调整处理读取文件的函数run,在src/lib.rs文件中修改:

use anyhow::{Context, Result};

pub fn run(config: Config) -> Result<()> {
    let file_path = config.file_path.clone();
    let content = fs::read_to_string(config.file_path)
        .with_context(|| format!("could not read file {}", file_path))?;

    // ...
    Ok(())
}

还需要修改src/main.rs文件,将错误输出方式改为{:?}

fn main() {
    // ...
    if let Err(e) = run(config) {
        // println!("something error:{e}");
        eprintln!("something error:{:?}", e);
        process::exit(1);
    }
}

再次执行命令,可以看到错误更加的友好。

ifun-grep-anyhow.png

使用anyhow!()宏,输出错误信息

src/main.rs,读取文件之前增加错误输出

fn main(){
    // ...
    println!("{}", anyhow!("anyhow error {}", "running"));
    //...
}

使用bail!()宏,中断执行

调用执行返回错误,中断程序执行

src/main.rs,读取文件之前增加错误输出

use anyhow::{anyhow, bail};

fn main() -> Result<(), anyhow::Error> {
    // ...
    println!("{}", anyhow!("anyhow error {}", "running"));

    bail!("permission denied for accessing {}", config.file_path)
    //...
}

调用bail!时,返回值必须是Result<(), anyhow::Error>类型

bail!同等于return Err(anyhow!())

跟踪错误栈

打印出错误信息,我们可以知道发生了错误,想知道是哪个文件、那行代码发生的错误,则需要开启错误栈追踪。

这是一个特性功能,需要指定特性启用

$> cargo add anyhow --features backtrace

然后通过设置环境变量,

  • RUST_BACKTRACE=1 panics和 error 都有错误栈输出
  • RUST_LIB_BACKTRACE=1 仅打开错误输出
  • RUST_BACKTRACE=1RUST_LIB_BACKTRACE=0 仅 panic 时

在执行命令时,设置环境变量,打开错误输出时的错误追踪

$> RUST_LIB_BACKTRACE=1 cargo run -- -s let -f 1.txt

thiserror 自定义自己的错误类型

anyhow不同,thiserror可以用来自定义错误类型。

通过过程式宏#[derive(Error)],它是由 std::error::Error派生而来。

$> cargo add thiserror

定义一个文件不能存在的错误类型,并用于读取文件时的逻辑

use thiserror::Error;

#[derive(Error, Debug)]
pub enum IfunError {
    #[error("the file is't exist")]
    FileNotExist(#[from] std::io::Error),
}

pub fn run(config: Config) -> Result<(), IfunError> {
    let content = fs::read_to_string(config.file_path)?;

    // ...

    Ok(())
}

执行命令,访问不存在的文件。错误信息输出会被自定义的类型包裹:

ifun-grep-thiserror.png

ansi_term 更好的打印输出

ansi_term控制台上的打印输出,包括字体样式、格式化。

安装

$> cargo add ansi_term

包括对文本的字体颜色、背景色、是否加粗、是否闪烁等等。

通过ansi_term::Colour控制字体样式

我们将ifun-grep的 参数打印使用颜色标记输出

use ansi_term::Colour::{Green, Yellow};

fn main(){
    //...
    println!(
        "will search {} in {}",
        Green.paint(&config.search),
        Yellow.paint(&config.file_path)
    );
}

执行命令cargo run -- -s Let -f hello.txt

ifugn-grep-ansi_term.png

加粗bold()、加下划线underline()、背景色on()

use ansi_term::Colour::{Green, Yellow};

fn main(){
    //...
    println!(
        "will search {} in {}",
        Green.bold().paint(&config.search),
        Yellow.underline().paint(&config.file_path)
    );
}

给程序查询出的行数据加背景色、闪烁

use ansi_term::Colour::{Red, Yellow};

pub fn run(config: Config) -> Result<(), anyhow::Error> {
    //...
    for line in result {
        println!("{}", Red.on(Yellow).blink().paint(line));
    }

    Ok(())
}

通过ansi_term::Style控制样式

Colour是一个枚举类型,专门针对颜色样式处理;Style是结构体类型,是字体样式的集合。

设置字体颜色,结构体需要实例化一个实例对象,然后再调用对应的方法。

use ansi_term::Colour::{Green, Yellow};
use ansi_term::Style;

fn main(){
    //...

    println!(
        "will search {} in {}",
        Green.bold().paint(&config.search),
        // Yellow.underline().paint(&config.file_path)
        Style::new().fg(Yellow).paint(&config.file_path)
    );
}

颜色扩展ansi_term::Colour::Fixed

除了内置枚举的颜色,还可以通过色码值设置颜色。0-255

use ansi_term::Colour::Fixed;

Fixed(154).paint("other color");

也可以通过ansi_term::Colour::RGB,设置三个不同的值

use ansi_term::Colour::RGB;

RGB(154, 56, 178).paint("other color");

此外还有内置ANSIStrings类型,可以通过to_string()方法转换为String;

支持格式化输出\[u8]字节字符串,对于不知道编码的文本输出很有用。会生成ANSIByteString类型,通过write_to方法写入输出流中。

Green.paint("ansi_term".as_bytes()).write_to(&mut std::io::stdout()).unwrap();

indicatif展示进度条

处理任务时,显示任务的执行进度。会让人感觉良好,更有耐心等待执行完毕

$> cargo add indicatif

手动创建一个进度条,为了看到进度条的进度效果,可以使用std::thred线程休眠一段时间。

use indicatif::ProgressBar;
use std::{thread, time};

fn main(){
    let bar = ProgressBar::new(100);
    let ten_millis = time::Duration::from_millis(10);
    for _ in 0..100 {
        bar.inc(1);
        thread::sleep(ten_millis);
        // ...
    }

    bar.finish();
}

通过ProgressBar类型创建了一个进度条的实例对象,然后通过实例bar.inc()逐步增加进度。完成后调用bar.finish()表示进度完成,并保留显示进度信息。

ifun-grep-indicatif.jpg

也支持多进度条的MultiProgress

log日志记录

一个程序运行时期的日志打印,非常重要,这对于运行监测喝解决有问题都有很到的帮助。

$> cargo add log

通常可以将日志按照登记划分,比如错误、警告、信息等。还需要一个日志输出的适配器 env_logger,可以将日志写入终端、日志服务器等。

$> cargo add env_logger

美化输出,将接受到的参数作为信息info!()输出,将产生的错误使用error!()输出

env_logger默认输出日志到终端,

use log;

fn main() {
    env_logger::init();

    // ...
    log::info!(
        "will search {} in {}",
        Green.bold().paint(&config.search),
        Style::new().fg(Yellow).paint(&config.file_path)
    );

    // ...

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

}

必须在程序之前初始化完毕日志环境变量配置。默认只展示error错误类型的日志

执行cargo run -- -s Let -f 1.txt命令访问不存在的文件,可以看到只有 error 错误输出打印。

通过设置变量RUST_LOG=info,查看

$> RUST_LOG=info cargo run -- -s Let -f 1.txt

ifun-grep-log.png

初始化指定信息类型

在执行命令前加上RUST_LOG=info很麻烦,有遗忘的可能,可以通过初始化env_logger::init()调用时,设定一个默认值

use env_logger::Env;

fn mian(){
    env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();

    // ...
}

通过终端设置的变量优先级比默认值高,可以通过执行时设置变量覆盖默认值。

自定义输出模板

可以看到默认的输出打印包括了时间、类型以及模块名。可以通过改变模板自定义输出格式

use std::io::Write;

fn main(){
    env_logger::builder()
        .format(|buf, record| writeln!(buf, "{} - {}", record.level(), record.args()))
        .init();
}

输出格式改变为信息类型 - 信息。使用默认的挺好,现在好多编辑器的日志输出都是这种格式。

测试

之前的单元测试示例都是和逻辑代码放在一起的,并用#[test]注释。可以将这些测试放在tests目录中

新建tests/lib.rs用于存放单元测试用例。

use ifun_grep::{find, find_insensitive};

#[test]
fn case_sensitive() {
    let search = "rust";
    let content = "\
nice. rust
I'm hboot.
hello world.
Rust
";

    assert_eq!(vec!["nice. rust"], find(search, content));
}

#[test]
fn case_insensitive() {
    let search = "rust";
    let content = "\
nice. rust
I'm hboot.
hello world.
Rust
";

    assert_eq!(
        vec!["nice. rust", "Rust"],
        find_insensitive(search, content)
    );
}

通过借助第三饭库来使得测试更容易,

assert_cmd

可以处理结果进行断言;也可以测试调用命令进行测试。一起配合使用的还有predicates用来断言布尔值类型结果值

因为测试示例只在开发阶段需要,则在安装时加参数--dev

$> cargo add assert_cmd predicates --dev

新增一个处理文件不存在的的测试示例。日志打印输出时会包含有could not read file字符串。

use assert_cmd::prelude::*;
use ifun_grep::{find, find_insensitive};
use predicates::prelude::*;
use std::{error::Error, process::Command};

#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn Error>> {
    let mut cmd = Command::cargo_bin("ifun-grep")?;

    cmd.arg("-s let").arg("-f 1.txt");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("could not read file"));

    Ok(())
}

通过运行cargo test,测试示例是运行成功的。

assert_fs用于测试文件系统的断言

刚才测试了文件不存在的错误输出,还需要增加文件存在的测试,并写入内容。

$> cargo add assert_fs --dev

生成要测试的文件;断言测试生成的文件。tests/lib.rs增加测试用例

use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use ifun_grep::{find, find_insensitive};
use predicates::prelude::*;
use std::{error::Error, process::Command};

#[test]
fn file_content_exist() -> Result<(), Box<dyn Error>> {
    let file = assert_fs::NamedTempFile::new("1.txt")?;
    file.write_str("hello world \n Rust-web \n good luck for you!")?;

    let mut cmd = Command::cargo_bin("ifun-grep")?;

    cmd.arg("-s good").arg("-f").arg(file.path());
    cmd.assert()
        .success()
        .stderr(predicate::str::contains("good luck for you!"));

    Ok(())
}

这样书写的单元测试用例更能直接、明了。和实际使用ifun-grep时同样的命令操作,而不是使用开发时运行cargo run