Cloudflare 如何用 Rust 构建一个高性能解释器
引言 —— 背景与问题缘起 解析器的设计 执行引擎的演进 核心方案:闭包动态分发 彩蛋:WebAssembly 支持 在系统编程领域,有一类工程问题天然具有代表性:如何在安全、性能与可维护性之间找到真正的平衡点。Cloudflare 工程团队在构建防火墙规则引擎时,用 Rust 给出了一份值得拆解的答案。 这篇文章基于 Cloudflare 官方博客 Building fast interpreters in Rust,系统梳理其中的工程决策,适合对编译原理、Rust 或系统设计有兴趣的读者。 Cloudflare 的防火墙规则系统,需要让用户用类似 Wireshark 的过滤语法来描述规则,例如: 规则需要在 Go、Lua、C、C++ 以及 Workers(JavaScript)等多种环境中运行,并且要满足以下几个硬性指标: 在这个约束组合下,Cloudflare 选择了 Rust,并将这个库以 wirefilter 的名字开源。 DSL 设计中,解析器是第一道关口。Wireshark 语法看上去简单,但藏着一个不小的歧义问题。 考虑这个值: 它同时是合法的 IPv6 地址,也是合法的字节序列。究竟该如何解析,取决于字段的类型: 类似地, 这意味着解析器不能独立运行——它必须带着一份预先定义好的 Schema(字段名到类型的映射)才能完成解析。 面对这个需求,常见的三类方案分别是: 解析器生成器通常基于独立的词法分析阶段,而这个问题要求词法与类型上下文绑定,大多数生成器做不到。即使用 LALRPOP 这类允许自定义 Lexer 的方案,复杂度也逼近手写,却多了一层黑盒。 最终团队选择了手动解析——在 Rust 中,字符串默认有边界检查,API 丰富,安全性有保证。 团队将解析器设计为实现统一 Trait 的类型: 顺序解析时,直接链式调用: 选择分支时,用 Rust 原生的模式匹配: 对于枚举类型的解析,用宏来减少重复: 这种方式兼具解析器组合子的无状态清晰性,又保留了完整的语言控制权。 最初的执行引擎很直接——让每个 AST 节点实现一个 功能正确,但 关键洞察在于:Schema 是提前已知的,字段数量是固定的。因此完全可以用一个定长数组来存运行时值,用字段在 Schema 中的下标来索引,彻底省掉哈希计算。 实现上,用 执行时直接按下标取值,不再涉及任何哈希。 效果立竿见影: 约 2 倍的性能提升,且类型错误的检测时机提前到了赋值时,而非执行时,可用性也随之改善。 "下一步就加 JIT,性能飞起来"——这是 DSL 工程中几乎人人都有过的想法。 但 Cloudflare 团队经过评估后选择放弃,原因很实际: 存储问题:如果静态编译每条规则,需要为 x86-64、ARM、WASM 等多平台分别编译,并维护存储,规则一旦更新逻辑就要全量重编译。 JIT 的代价:JIT 编译发生在运行时,生成原生代码本身就有相当的时间开销,很容易抵消执行加速带来的收益。 安全风险:动态生成代码并标记为可执行内存,本质上是引入了一个运行时的信任边界,这在边缘安全产品中是需要格外谨慎的事。 既然 JIT 不用,有没有一种方式,在不生成原生代码的前提下,尽量逼近 JIT 的运行时性能? Cloudflare 的答案是:将 AST 编译成一棵闭包树。 Rust 的 核心结构如下: 每个 AST 节点通过 以 IP 范围检查为例,在编译阶段就可以把 IPv4 和 IPv6 分开处理并缓存: 逻辑组合(AND / OR / XOR)同样用闭包嵌套表达: 最终整棵表达式树变成一个单一的顶层闭包,调用它就等于执行整条规则。 这个结果起初令团队意外——动态分发通常被认为有额外开销,但实测中,闭包树消除了大量递归调用的中间状态,缓存局部性反而更好。 唯一的代价是:相比于真正的 JIT 内联展开,每层闭包仍然有一次虚函数调用开销。但对这个场景而言,这个代价完全可以接受。 Cloudflare 的防火墙规则编辑器有一个 UI,需要在前端实时校验用户输入的规则语法。理想状态是前后端共用同一套解析器。 Rust 对 WebAssembly 的支持恰好提供了这个可能。 借助 配合 Cloudflare 这个案例的核心工程价值在于,它展示了几个在实践中常被忽视的判断: 第一,不是所有问题都适合用现成工具解决。 解析器生成器省事,但一旦 DSL 有上下文相关的歧义,生成器的边界就到了。手写解析器在 Rust 里并不可怕。 第二,性能优化要先找对瓶颈。 HashMap 查找看起来"很快",但在高频执行路径上,换成数组下标索引就能带来 2 倍提升。数据结构的选择往往比算法优化影响更直接。 第三,JIT 不是银弹。 编译代价、存储复杂性、安全边界,这些隐性成本在边缘场景下完全可以让 JIT 得不偿失。闭包树在这里是一个工程上更务实的折中。 第四,语言特性可以成为架构工具。 Rust 的 如果你正在设计一个嵌入式规则引擎、DSL 解释器,或者类似的执行框架,这套思路有相当强的参考价值。 原文链接:https://blog.cloudflare.com/building-fast-interpreters-in-rust/内容结构概览
背景:为什么要自己造这个轮子
ip.addr == 192.168.0.1
http.request.uri matches "gl=se$"第一关:解析器怎么写
Wireshark 语法的歧义陷阱
2f:31:32:33:34:35:36:37ipv6.addr == 2f:31:32:33:34:35:36:37 → 解析为 IPv6 地址
http.request.uri == 2f:31:32:33:34:35:36:37 → 解析为字节序列80 既可以是端口号(整数),也可以是 HTTP 响应体中的单字节(0x80)。为什么不用解析器生成器
nom、combine 等pest、LALRPOP 等,通过语法描述自动生成解析器Rust Trait 驱动的解析架构
pub trait Lex<'i>: Sized {
fn lex(input: &'i str) -> LexResult<'i, Self>;
}
pub trait LexWith<'i, E>: Sized {
fn lex_with(input: &'i str, extra: E) -> LexResult<'i, Self>;
}Lex:用于不依赖上下文的解析(如字段名、字面量)LexWith:用于需要 Schema 的上下文感知解析let input = skip_space(input);
let (op, input) = CombinedExpr::lex_with(input, scheme)?;
let input = skip_space(input);
let input = expect(input, ")")?;if let Ok(input) = expect(input, "(") {
// 括号表达式
} else if let Ok((op, input)) = UnaryOp::lex(input) {
// 一元操作符
} else {
// 其他情况
}lex_enum!(#[repr(u8)] OrderingOp {
"eq" | "==" => Equal = EQUAL,
"ne" | "!=" => NotEqual = LESS | GREATER,
"ge" | ">=" => GreaterThanEqual = GREATER | EQUAL,
...
});第二关:执行引擎怎么快
初版:直接 AST 解释
execute 方法:trait Expr<'s> {
fn execute(&self, ctx: &ExecutionContext<'s>) -> bool;
}ExecutionContext 本质上是一个 HashMap,存储字段名到运行时值的映射。执行时递归遍历 AST,在 Map 里查字段值。HashMap 查找的开销不可忽视。优化:用固定数组替代 HashMap
IndexMap(一个保留插入顺序、支持按下标访问的 HashMap 替代品)替换 Schema 内部的 HashMap,在解析阶段就把字段名解析为下标并存入 AST:pub struct ExecutionContext<'e> {
scheme: &'e Scheme,
values: Box<[Option<LhsValue<'e>>]>,
} 匹配耗时 优化前 2,548 ns/iter 优化后 1,227 ns/iter 关于 JIT:想过,但放弃了
核心方案:闭包树取代 AST 解释
Fn trait 与动态分发
Fn trait 系列(Fn、FnMut、FnOnce)允许将闭包装箱为 Box<dyn Fn(...)>,在运行时通过动态分发调用。pub(crate) struct CompiledExpr<'s>(Box<dyn 's + Fn(&ExecutionContext<'s>) -> bool>);
impl<'s> CompiledExpr<'s> {
pub(crate) fn new(closure: impl 's + Fn(&ExecutionContext<'s>) -> bool) -> Self {
CompiledExpr(Box::new(closure))
}
pub fn execute(&self, ctx: &ExecutionContext<'s>) -> bool {
self.0(ctx)
}
}compile() 方法转化为一个闭包,闭包可以捕获所需的数据,也可以嵌套调用其他闭包。闭包套闭包:组合逻辑的自然表达
RhsValues::Ip(ranges) => {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for range in ranges {
match range.clone().into() {
ExplicitIpRange::V4(r) => v4.push(r),
ExplicitIpRange::V6(r) => v6.push(r),
}
}
let v4 = RangeSet::from(v4);
let v6 = RangeSet::from(v6);
CompiledExpr::new(move |ctx| {
match cast!(ctx.get_field_value_unchecked(field), Ip) {
IpAddr::V4(addr) => v4.contains(addr),
IpAddr::V6(addr) => v6.contains(addr),
}
})
}match op {
CombiningOp::And => {
CompiledExpr::new(move |ctx| items.iter().all(|item| item.execute(ctx)))
}
CombiningOp::Or => {
CompiledExpr::new(move |ctx| items.iter().any(|item| item.execute(ctx)))
}
...
}这个方案的收益清单
彩蛋:20 行代码暴露给前端
wasm-bindgen,只需约 20 行代码就能将解析器暴露为 WASM 模块:#[wasm_bindgen]
pub struct Scheme(wirefilter::Scheme);
#[wasm_bindgen]
impl Scheme {
#[wasm_bindgen(constructor)]
pub fn try_from(fields: &JsValue) -> Result<Scheme, JsValue> {
fields.into_serde().map(Scheme).map_err(into_js_error)
}
pub fn parse(&self, s: &str) -> Result<JsValue, JsValue> {
let filter = self.0.parse(s).map_err(into_js_error)?;
JsValue::from_serde(&filter).map_err(into_js_error)
}
}wasm-pack,可以自动生成 npm 包并发布。这意味着同一套 Rust 代码可以同时服务于:总结与思考
Fn trait 和动态分发,不只是语言细节,而是可以用来构建可组合执行引擎的设计原语。