Rust与基础设施:为什么许多工具用Rust重写
1. 概述
近几年来,许多基础设施工具用Rust重写:文件搜索工具、linter、formatter、JavaScript bundler、Python包管理器、代理和日志收集代理。本文解释为什么Rust适合基础设施领域。之后你将用Rust编写一个简单的CLI,将其打包到容器中,并从Java应用程序调用它。文章末尾将介绍你不应该选择Rust的情况。
2. 为什么基础设施需要像Rust这样的语言
基础设施工具通常有几个共同特点。它们运行频率很高,比如每次保存文件或每次CI构建时。它们处理大量数据,必须在多天内保持稳定。它们还需要易于分发到不同的机器上。
以前的选择通常介于两者之间:
- C/C++:快速且没有重型运行时。代价是内存错误(如use-after-free、buffer overflow或data race)很容易发生且难以发现。
- 带GC或解释器的语言(Java、Python、Node.js):安全且生产力高。代价是启动时间更长,内存消耗更多。分发时,还需要包含运行时。
Rust瞄准这两个群体之间的空隙:接近C的性能,但内存安全由编译器检查。
2.1. 无需GC的内存安全性
Rust用ownership管理内存。每个值都恰好有一个所有者。当所有者离开作用域时,内存被释放。编译器在编译时检查这些规则:
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // ownership转移给`moved`
println!("{moved}");
println!("{order_id}"); // 试图重新使用已被移动的变量
}
第3行将字符串的所有权转移给moved。之后order_id不再有效。第5行试图重新使用它,所以程序无法编译:
error[E0382]: borrow of moved value: `order_id`
在C中,类似的错误可能正常编译然后在运行时崩溃,或者更糟的是成为安全漏洞。对于以高权限运行或接触来自外部数据的基础设施工具(如代理和解析器),这种差异非常重要。
2.2. 并发安全
基础设施工具通常需要利用多核。Rust使用两个trait Send和Sync让编译器拒绝有数据竞争的程序:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let processed = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..4 {
let processed = Arc::clone(&processed);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*processed.lock().unwrap() += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("processed = {}", *processed.lock().unwrap());
}
各部分的含义:
Arc是线程间安全的引用计数指针。Mutex确保一次只有一个线程可以修改计数变量。move将Arc的副本转移到每个线程的闭包中。
结果总是:
processed = 4000
如果你把Arc替换为Rc(线程间不安全的类型),编译器会报错`Rc<Mutex<u64>>` cannot be sent between threads safely。你不需要等到生产环境才能发现竞争条件。
2.3. 单个二进制文件,即时启动
cargo build --release生成本机可执行文件。它不需要JVM、解释器或node_modules。这带来几个好处:
- 安装简单:只需下载一个文件并运行。
- 启动快速:适合linter或formatter在编辑器或pre-commit hook中每分钟被调用数百次。
- 容器镜像小:适合在每个节点上运行的sidecar和代理。
3. 正在重写的工具类型
不需要具体的数字,你仍然可以通过常见的工具组看到趋势:
- 命令行CLI:替代
grep、find、cat、ls。常见例子是ripgrep和fd。 - 其他语言的工具:JavaScript/TypeScript和Python的linter和formatter。例如:Ruff、Biome、SWC。最近还有Python包管理器如uv。
- 代理、负载均衡器、服务网格数据平面:性能和内存安全都很重要的地方。
- 可观测性代理:收集日志和指标,在每台机器上运行,所以需要轻量级。
- 操作系统和运行时组件:驱动程序、容器运行时、WebAssembly运行时。
有一个值得注意的共同点。这些工具大多数不要求用户了解Rust。Rust是实现细节。用户只看到一个运行速度快、易于安装的工具。
4. 实战:用Rust编写CLIlogstat
假设你需要计算OrderService访问日志中每个HTTP状态码出现的次数。每行日志的格式为METHOD PATH STATUS DURATION_MS。
4.1. 声明项目
用cargo new logstat创建项目,然后添加clap库来解析命令行参数:
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. 源代码
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "计算访问日志中的HTTP状态码")]
struct Args {
/// 日志文件路径
path: String,
}
fn parse_status(line: &str) -> Option<u16> {
line.split_whitespace().nth(2)?.parse().ok()
}
fn count_statuses<R: BufRead>(reader: R) -> io::Result<BTreeMap<u16, u64>> {
let mut counts = BTreeMap::new();
for line in reader.lines() {
let line = line?;
if let Some(status) = parse_status(&line) {
*counts.entry(status).or_insert(0) += 1;
}
}
Ok(counts)
}
fn main() -> io::Result<()> {
let args = Args::parse();
let file = File::open(&args.path)?;
let counts = count_statuses(BufReader::new(file))?;
for (status, n) in counts {
println!("{status} {n}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_statuses_and_skips_bad_lines() {
let log = "GET /orders 200 12\nPOST /orders 201 30\nGET /orders 200 9\nrác\n";
let counts = count_statuses(log.as_bytes()).unwrap();
assert_eq!(counts.get(&200), Some(&2));
assert_eq!(counts.get(&201), Some(&1));
assert_eq!(counts.len(), 2);
}
}
关键点:
#[derive(Parser)]从Args结构体生成解析参数和--help内容的代码。parse_status返回Option<u16>。Option上的?操作符帮助跳过缺少列的行。.parse().ok()将解析错误转换为None,因此垃圾行不会使程序崩溃。count_statuses接受任何BufRead。代码逐行读取,所以不需要将整个文件加载到内存中,即使日志很大。BTreeMap按升序保持键,因此输出是稳定的。- 测试函数将字符串作为
&[u8]传递,它也实现了BufRead。这样你可以测试逻辑而无需真实文件。
运行测试和构建:
cargo test
cargo build --release
./target/release/logstat access.log
对于示例日志文件,输出看起来像:
200 2
201 1
500 1
4.3. 打包到容器中
Rust二进制文件适合多阶段构建:
FROM rust:slim AS build
WORKDIR /app
COPY . .
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=build /app/target/release/logstat /usr/local/bin/logstat
ENTRYPOINT ["logstat"]
第一阶段包含完整的编译工具链。最后一个阶段仅包含二进制文件和一些基本C库。最后的镜像因此比附带JDK或Node.js的镜像小得多。
注意: 如果想使用
scratch镜像,你需要用x86_64-unknown-linux-musl目标进行静态构建。然后仔细检查crate是否依赖C库(如OpenSSL)。
5. 将Rust工具集成到Java系统中
你不需要重写整个Spring Boot应用程序。最实用的方法通常是为专用工具使用Rust,而Java将其作为外部进程调用:
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
public class LogStatClient {
private final Path binary;
public LogStatClient(Path binary) {
this.binary = binary;
}
public Map<Integer, Long> count(Path logFile) throws IOException, InterruptedException {
Process process = new ProcessBuilder(binary.toString(), logFile.toString())
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
Map<Integer, Long> result = new TreeMap<>();
try (var reader = process.inputReader()) {
reader.lines().forEach(line -> {
String[] parts = line.split(" ");
result.put(Integer.parseInt(parts[0]), Long.parseLong(parts[1]));
});
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IllegalStateException("logstat exited with code " + exitCode);
}
return result;
}
}
解释:
ProcessBuilder运行二进制文件,日志文件路径作为参数。redirectError(INHERIT)将工具的stderr重定向到Java应用的日志。这样错误消息不会混入stdout,Java在那里解析结果。process.inputReader()按行读取stdout。每行被拆分为状态码和出现次数。- 非0的退出码被视为错误。例如,文件不存在时
main返回Err,Rust程序以代码1退出。
使用JUnit 5和AssertJ的测试:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class LogStatClientTest {
@TempDir
Path tempDir;
@Test
void countsStatusCodesUsingRustBinary() throws Exception {
Path binary = Path.of("tools/logstat/target/release/logstat");
assumeTrue(Files.isExecutable(binary), "Need to build logstat first");
Path log = tempDir.resolve("access.log");
Files.writeString(log, """
GET /orders 200 12
GET /orders 500 40
GET /customers 200 8
""");
var client = new LogStatClient(binary);
assertThat(client.count(log))
.containsExactly(entry(200, 2L), entry(500, 1L));
}
}
assumeTrue允许在未构建二进制文件的机器上跳过测试,而不是导致构建失败。在CI上,你应该在mvn test步骤之前运行cargo build --release。
除了通过进程调用外,你还有两种其他选择:
- 通过FFI直接调用:使用Java 21的Foreign Function & Memory API来调用按C ABI导出的Rust函数(
extern "C")。这避免了进程创建的开销。代价是两种语言边界处的内存和类型管理更复杂。 - 作为单独的服务:Rust工具独立运行,通过HTTP/gRPC通信。这适合工具需要持续运行的情况,如代理或代理。
在大多数情况下,通过进程调用最简单也最容易调试。
6. 何时不应该使用Rust
Rust不是所有情况下的默认选择。在以下情况下要仔细考虑。
- 常规CRUD业务应用:使用Spring Boot管理
Order和Customer的服务通常受数据库和网络限制,而不是CPU。用Rust重写很少能改善用户体验,但肯定会减慢开发速度。 - 团队缺乏经验:借用检查器有陡峭的学习曲线。起初,你会花很多时间与编译器“争执”。由只有一个人懂Rust的内部工具维护是真正的操作风险。
- 生态系统不完整:Java有非常成熟的企业库,如ORM、消息代理集成或安全标准。在某些领域,Rust的选择仍然较少。
- 需要频繁变化的原型:当需求不稳定时,Python或Java脚本会帮助你更快地进行实验。
- 唯一的理由是“Rust很快”:先测量。通常在当前语言中进行profiling和算法优化就足够了。
入门时的一些常见错误:
注意: 用debug版本(
cargo build不带--release)测量性能会给出比实际慢很多的结果。始终用release版本进行基准测试。
注意: 滥用
.unwrap()会在遇到不好的输入时导致工具panic。对于基础设施工具,返回Result并将清晰的错误消息打印到stderr。
注意:
unsafe禁用编译器的部分检查。将其限制在小模块中并仔细审查。不要仅为了“绕过”借用检查器而使用unsafe。
此外,对于大项目,Rust的编译时间相当长。在CI上为target目录和Cargo注册表启用缓存,否则pipeline会明显变慢。
7. 结论
选择Rust进行基础设施是因为它结合了三件以前难以同时拥有的东西:本机性能、由编译器保证的内存安全性,以及作为单个二进制文件分发的能力。这就是为什么许多CLI、linter、bundler、代理和代理用Rust重写。
在本文中,你编写了带测试的CLIlogstat,用多阶段Docker构建打包它,并通过ProcessBuilder从Java调用它。最合理的方法是为专用组件、频繁运行或对资源敏感的组件使用Rust。业务逻辑应保持在团队掌握的语言中。在重写任何东西之前,先测量并问自己:收益是否足以抵消学习和维护成本?