- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 悄然而至的 CLI 复兴
- 第一个 CLI — clap 的 derive API
- 子命令 — git 风格的工具
- 为什么是现在的 Rust — 没有 GC 的性能
- 分发 — 单一静态二进制文件
- 值得拥有的 crate
- 优秀 CLI 遵循的惯例
- 结语
- 参考资料
引言 — 悄然而至的 CLI 复兴
过去几年间,你每天使用的命令行工具中有相当一部分,被悄悄地重写为了 Rust 版本。grep 变成了 ripgrep(rg),find 变成了 fd,cat 变成了 bat,ls 变成了 eza,cd 变成了 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);
}
}
}
这里有几处值得留意。字段 pattern 和 path 会成为位置参数(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 commit、git 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 --all、todo done 3 这样调用。每个子命令都有自己专属的参数和标志,用 match 分支时,编译器会检查是否遗漏处理了任何一个变体。只要新增一个子命令,就必须同步修改 match,所以不可能漏掉处理。
为什么是现在的 Rust — 没有 GC 的性能
这些工具之所以快,原因有很多,但最根本的一点是 没有垃圾回收器。
像 Go、Java、Node 这样的运行时虽然方便,却要征收两种税。第一,程序启动时要花时间初始化运行时,这是一种延迟。命令行工具会被反复调用成千上万次,每次调用的启动延迟一旦累积起来,就会变得能明显感觉到。第二,运行过程中 GC 会介入,造成不可预测的停顿。Rust 用编译期的所有权规则来管理内存,因此根本没有运行时 GC。程序立即启动,并且以稳定的速度运行,不会卡顿。
在此基础上,还加上了两件底层的利器。
- SIMD — 现代 CPU 提供向量指令,可以用一条指令同时处理多个字节。ripgrep 所依赖的
memchrcrate 会用 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 展示进度。最后,只要守住标准流和退出码这类小小的惯例,你的工具也能跻身人们每天顺手使用的那份名单。
参考资料
- clap 官方文档 — https://docs.rs/clap/
- clap derive 教程 — https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html
- The Rust Programming Language, 第 12 章(编写 minigrep) — https://doc.rust-lang.org/book/ch12-00-an-io-project.html
- Command Line Applications in Rust(书籍) — https://rust-cli.github.io/book/
- anyhow crate — https://docs.rs/anyhow/
- indicatif crate — https://docs.rs/indicatif/
- crossterm crate — https://docs.rs/crossterm/
- ripgrep 仓库 — https://github.com/BurntSushi/ripgrep
- fd 仓库 — https://github.com/sharkdp/fd
- bat 仓库 — https://github.com/sharkdp/bat