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。业务逻辑应保持在团队掌握的语言中。在重写任何东西之前,先测量并问自己:收益是否足以抵消学习和维护成本?

rusttech-trendsinfrastructureclidevops