Skip to content

필사 모드: 用 Rust 打造 CLI:clap 与 ripgrep·fd·bat 的秘诀

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 悄然而至的 CLI 复兴

过去几年间,你每天使用的命令行工具中有相当一部分,被悄悄地重写为了 Rust 版本。grep 变成了 ripgrep(rg),find 变成了 fdcat 变成了 batls 变成了 ezacd 变成了 zoxide(z)。此外,还有监测文件变化并重新运行测试的 bacon、查看磁盘使用情况的 dust,以及进程查看器 bottom(btm) — 这份名单还在不断增长。

这并非偶然。命令行工具正是 Rust 优势几乎完美契合的领域。它必须启动得快(没有 GC 预热),必须以可预测的方式保持快速(没有 GC 停顿),必须易于分发(不依赖运行时的单一二进制文件),还必须安全(即便处理海量文件和各种编码也不能崩溃)。本文梳理这场复兴的背景,并通过代码展示你亲自动手写 CLI 时真正需要什么。

如果 Rust 语法对你来说还很陌生,可以在阅读本文的同时,前往本站的 Rust 学习实验室并行学习所有权、trait 这类基础知识。

第一个 CLI — clap 的 derive API

在 Rust 生态中,参数解析事实上的标准是 clap。过去常用构建器(builder)风格逐个注册参数,但如今推荐的方式是 derive API。只需定义一个结构体并附上 #[derive(Parser)],字段就会直接变成命令行参数。

先在 Cargo.toml 中添加依赖。

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

现在我们来做一个极小的 grep 克隆,用来查找文件中的模式。

use clap::Parser;
use std::fs;

/// 在文件中查找模式的小工具
#[derive(Parser)]
#[command(version, about)]
struct Cli {
    /// 要查找的模式
    pattern: String,

    /// 要搜索的文件路径
    path: std::path::PathBuf,

    /// 是否忽略大小写
    #[arg(short, long)]
    ignore_case: bool,
}

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

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

    for (num, line) in content.lines().enumerate() {
        let found = if cli.ignore_case {
            line.to_lowercase().contains(&cli.pattern.to_lowercase())
        } else {
            line.contains(&cli.pattern)
        };
        if found {
            println!("{}: {}", num + 1, line);
        }
    }
}

这里有几处值得留意。字段 patternpath 会成为位置参数(positional argument)。附有 #[arg(short, long)]ignore_case 会成为 -i--ignore-case 标志。字段上方的 /// 文档注释会原样变成帮助文本。而多亏了 #[command(version, about)]--version--help 就白白地拿到手了。

编译后运行 --help,就会出现一个你一行都没亲手写过的整洁用法说明。

$ minigrep --help
在文件中查找模式的小工具

Usage: minigrep [OPTIONS] <PATTERN> <PATH>

Arguments:
  <PATTERN>  要查找的模式
  <PATH>     要搜索的文件路径

Options:
  -i, --ignore-case  是否忽略大小写
  -h, --help         Print help
  -V, --version      Print version

关键在于,类型本身就是契约。因为把 path 声明为 PathBuf,clap 会自动把字符串转换成路径;因为把 ignore_case 声明为 bool,标志是否出现就会自动变成真或假。如果参数缺失或格式错误,clap 会自行打印错误信息并退出。

子命令 — git 风格的工具

git commitgit push 那样,一个工具拥有多个子命令的结构非常常见。在 clap 中,用枚举(enum)来表示 — 每个变体(variant)对应一个子命令。

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(version, about)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// 添加一个新项目
    Add {
        /// 要添加的内容
        text: String,
    },
    /// 显示项目列表
    List {
        /// 是否包含已完成的项目
        #[arg(short, long)]
        all: bool,
    },
    /// 将项目标记为已完成
    Done {
        /// 要完成的项目编号
        id: u32,
    },
}

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

    match cli.command {
        Commands::Add { text } => {
            println!("已添加: {}", text);
        }
        Commands::List { all } => {
            println!("列表 (包含已完成: {})", all);
        }
        Commands::Done { id } => {
            println!("已完成: 项目 {}", id);
        }
    }
}

现在这个工具可以像 todo add "买牛奶"todo list --alltodo done 3 这样调用。每个子命令都有自己专属的参数和标志,用 match 分支时,编译器会检查是否遗漏处理了任何一个变体。只要新增一个子命令,就必须同步修改 match,所以不可能漏掉处理。

为什么是现在的 Rust — 没有 GC 的性能

这些工具之所以快,原因有很多,但最根本的一点是 没有垃圾回收器

像 Go、Java、Node 这样的运行时虽然方便,却要征收两种税。第一,程序启动时要花时间初始化运行时,这是一种延迟。命令行工具会被反复调用成千上万次,每次调用的启动延迟一旦累积起来,就会变得能明显感觉到。第二,运行过程中 GC 会介入,造成不可预测的停顿。Rust 用编译期的所有权规则来管理内存,因此根本没有运行时 GC。程序立即启动,并且以稳定的速度运行,不会卡顿。

在此基础上,还加上了两件底层的利器。

  • SIMD — 现代 CPU 提供向量指令,可以用一条指令同时处理多个字节。ripgrep 所依赖的 memchr crate 会用 SIMD 一次性扫描 16、32、64 字节来查找特定字节,这比朴素的逐字节循环快得多。
  • mmap — 不是把文件从内核缓冲区复制出来,而是直接映射到进程的虚拟地址空间,当成一个巨大的字节数组来处理。扫描一个大文件时,复制这一步就消失了。

不过不要误解。「因为是 Rust 所以快」只说对了一半。Rust 只是提供了轻量抽象和安全并发这样的 基础,速度的本质在于算法和设计。如果你好奇 ripgrep 真正快的原因,本博客的ripgrep 为什么这么快一文,从正则引擎的选择到并行遍历都做了详细说明。

分发 — 单一静态二进制文件

CLI 工具的成败不只取决于性能,还取决于 分发的便利性。在这一点上,Rust 拥有决定性的优势。

运行 cargo build --release,就会得到一个可执行文件。这个文件既不需要解释器,也不需要运行时,更不需要 node_modules。用户不必对齐 Python 版本或安装 JVM,只要下载一个二进制文件、赋予执行权限,然后运行就行了。

# 发布构建(优化已开启)
cargo build --release

# 产物只有这一个
./target/release/mytool

# 如果需要完全静态链接,使用 musl 目标
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

特别是用 musl 目标构建时,连 C 标准库都会被静态包含进去,得到一个完全不依赖目标系统库版本的完全静态二进制文件。这样的二进制文件即便直接放进最小化的 Docker 镜像(scratch 或 alpine)里也能运行。分发止步于「复制一个文件」,这对 CLI 作者和用户来说都是极大的吸引力。

值得拥有的 crate

如果说参数解析交给 clap,那么还有一批 crate 负责填补其余常见的需求。优秀的 CLI 通常就是靠这套组合搭出来的。

  • anyhow — 让应用层的错误处理变得轻松。它把各种错误类型统一起来,用 ? 运算符传播,还能在失败路径上附加上下文。
  • indicatif — 绘制进度条(progress bar)和加载动画(spinner)。如果工具要处理大量文件,向用户展示进度会带来很大的差别。
  • crossterm — 跨平台地控制终端。颜色、光标移动、清屏、按键检测等操作,在 Windows、macOS、Linux 上都能以同样的方式处理。

来看一个用 anyhow 给错误附加上下文的例子。

use anyhow::{Context, Result};
use std::fs;

fn load_config(path: &str) -> Result<String> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("无法读取配置文件: {}", path))?;
    Ok(content)
}

fn main() -> Result<()> {
    let config = load_config("config.toml")?;
    println!("已读取配置 {} 字节", config.len());
    Ok(())
}

只要让 main 返回 Result,一旦出错,Rust 就会自动打印消息并以非零退出码结束。多亏了用 with_context 附加的说明,用户看到的不是「文件不存在」这种底层错误,而是「无法读取配置文件」这样能理解的消息。

用 indicatif 绘制进度条的例子同样很简单。

use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;

fn main() {
    let total = 1000;
    let bar = ProgressBar::new(total);

    for _ in 0..total {
        // 实际场景中这里会处理一个文件
        thread::sleep(Duration::from_millis(2));
        bar.inc(1);
    }

    bar.finish_with_message("完成");
}

优秀 CLI 遵循的惯例

光是快和安全还不够。前面提到的这些工具之所以受人喜爱,是因为它们细致地照顾了用户体验。你在打造自己的工具时,最好也记住以下几点。

  • 尊重标准流。 正常输出发到 stdout,错误和诊断信息发到 stderr。这样当用户用 mytool | other 这样的管道连接时,诊断消息才不会污染数据。
  • 检测管道。 当输出连接的是管道而不是终端时,关闭颜色代码才是礼貌的做法。大多数工具都是这样运作的,需要的话也可以用 --color always 之类的选项强制开启。
  • 遵守退出码。 成功为 0,失败为非零值。要让 shell 脚本能把你的工具放进条件语句里使用。
  • 选好默认值。 把用户通常想要的行为设成无需配置的默认值,只为例外情况开放标志位。这就是 ripgrep 默认尊重 .gitignore 的原因。
  • 提供 shell 自动补全。 clap 能生成 bash、zsh、fish 用的自动补全脚本。看似细枝末节,却能大幅提升可用性。

如果想通过操作版本管理工具来培养对 CLI 的直觉,可以到本站的 Git 演练场亲自试验各个命令的行为。

结语

CLI 复兴不是语言粉丝行为的结果,而是契合度使然。快速启动、可预测的性能、单一二进制文件分发、内存安全 — 这四点精准地契合了命令行工具这个问题,正是在这个基础上,才诞生了 ripgrep、fd、bat 这样的工具。

你的第一个工具不需要多么宏大。从一个附有 #[derive(Parser)] 的结构体开始,加上子命令,用 anyhow 打磨错误,需要的话再用 indicatif 展示进度。最后,只要守住标准流和退出码这类小小的惯例,你的工具也能跻身人们每天顺手使用的那份名单。

参考资料

현재 단락 (1/145)

过去几年间,你每天使用的命令行工具中有相当一部分,被悄悄地重写为了 Rust 版本。`grep` 变成了 `ripgrep`(rg),`find` 变成了 `fd`,`cat` 变成了 `bat`,`...

작성 글자: 0원문 글자: 5,807작성 단락: 0/145