Cloudflare 如何用 Rust 构建一个高性能解释器

4 阅读9分钟

内容结构概览

  1. 引言 —— 背景与问题缘起

    • Cloudflare Firewall Rules 的需求
    • 为什么选择 Rust
  2. 解析器的设计

    • Wireshark DSL 语法的歧义性挑战
    • 三种解析方案的对比
    • 为何放弃解析器生成器,选择手动解析
    • Rust Trait 驱动的解析器架构
  3. 执行引擎的演进

    • 初版:直接 AST 解释执行
    • 优化一:用固定数组替代 HashMap,实现 2x 加速
    • 关于 JIT 的思考与放弃原因
  4. 核心方案:闭包动态分发

    • Fn trait 与动态分发的原理
    • 将 AST 编译为闭包树
    • 性能、安全、灵活性三者的平衡
  5. 彩蛋:WebAssembly 支持

    • wasm-bindgen 与 wasm-pack
    • 20 行代码暴露解析器给前端

在系统编程领域,有一类工程问题天然具有代表性:如何在安全、性能与可维护性之间找到真正的平衡点。Cloudflare 工程团队在构建防火墙规则引擎时,用 Rust 给出了一份值得拆解的答案。

这篇文章基于 Cloudflare 官方博客 Building fast interpreters in Rust,系统梳理其中的工程决策,适合对编译原理、Rust 或系统设计有兴趣的读者。


背景:为什么要自己造这个轮子

Cloudflare 的防火墙规则系统,需要让用户用类似 Wireshark 的过滤语法来描述规则,例如:

ip.addr == 192.168.0.1
http.request.uri matches "gl=se$"

规则需要在 Go、Lua、C、C++ 以及 Workers(JavaScript)等多种环境中运行,并且要满足以下几个硬性指标:

  • 性能:规则执行路径在关键链路上,延迟敏感
  • 内存安全:生产环境不容许内存漏洞
  • 低内存占用:边缘节点资源受限
  • 可复用性:需要被多个产品集成

在这个约束组合下,Cloudflare 选择了 Rust,并将这个库以 wirefilter 的名字开源。


第一关:解析器怎么写

Wireshark 语法的歧义陷阱

DSL 设计中,解析器是第一道关口。Wireshark 语法看上去简单,但藏着一个不小的歧义问题。

考虑这个值:2f:31:32:33:34:35:36:37

同时是合法的 IPv6 地址,也是合法的字节序列。究竟该如何解析,取决于字段的类型:

ipv6.addr == 2f:31:32:33:34:35:36:37   → 解析为 IPv6 地址
http.request.uri == 2f:31:32:33:34:35:36:37  → 解析为字节序列

类似地,80 既可以是端口号(整数),也可以是 HTTP 响应体中的单字节(0x80)。

这意味着解析器不能独立运行——它必须带着一份预先定义好的 Schema(字段名到类型的映射)才能完成解析。

为什么不用解析器生成器

面对这个需求,常见的三类方案分别是:

  1. 手动字符解析:用状态机、正则或原生字符串 API
  2. 解析器组合子(Parser Combinators):如 nomcombine
  3. 解析器生成器:如 pestLALRPOP 等,通过语法描述自动生成解析器

解析器生成器通常基于独立的词法分析阶段,而这个问题要求词法与类型上下文绑定,大多数生成器做不到。即使用 LALRPOP 这类允许自定义 Lexer 的方案,复杂度也逼近手写,却多了一层黑盒。

最终团队选择了手动解析——在 Rust 中,字符串默认有边界检查,API 丰富,安全性有保证。

Rust Trait 驱动的解析架构

团队将解析器设计为实现统一 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, ")")?;

选择分支时,用 Rust 原生的模式匹配:

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 解释

最初的执行引擎很直接——让每个 AST 节点实现一个 execute 方法:

trait Expr<'s> {
    fn execute(&self, ctx: &ExecutionContext<'s>) -> bool;
}

ExecutionContext 本质上是一个 HashMap,存储字段名到运行时值的映射。执行时递归遍历 AST,在 Map 里查字段值。

功能正确,但 HashMap 查找的开销不可忽视。

优化:用固定数组替代 HashMap

关键洞察在于:Schema 是提前已知的,字段数量是固定的。因此完全可以用一个定长数组来存运行时值,用字段在 Schema 中的下标来索引,彻底省掉哈希计算。

实现上,用 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

2 倍的性能提升,且类型错误的检测时机提前到了赋值时,而非执行时,可用性也随之改善。

关于 JIT:想过,但放弃了

"下一步就加 JIT,性能飞起来"——这是 DSL 工程中几乎人人都有过的想法。

但 Cloudflare 团队经过评估后选择放弃,原因很实际:

存储问题:如果静态编译每条规则,需要为 x86-64、ARM、WASM 等多平台分别编译,并维护存储,规则一旦更新逻辑就要全量重编译。

JIT 的代价:JIT 编译发生在运行时,生成原生代码本身就有相当的时间开销,很容易抵消执行加速带来的收益。

安全风险:动态生成代码并标记为可执行内存,本质上是引入了一个运行时的信任边界,这在边缘安全产品中是需要格外谨慎的事。


核心方案:闭包树取代 AST 解释

既然 JIT 不用,有没有一种方式,在不生成原生代码的前提下,尽量逼近 JIT 的运行时性能?

Cloudflare 的答案是:将 AST 编译成一棵闭包树

Fn trait 与动态分发

Rust 的 Fn trait 系列(FnFnMutFnOnce)允许将闭包装箱为 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)
    }
}

每个 AST 节点通过 compile() 方法转化为一个闭包,闭包可以捕获所需的数据,也可以嵌套调用其他闭包。

闭包套闭包:组合逻辑的自然表达

以 IP 范围检查为例,在编译阶段就可以把 IPv4 和 IPv6 分开处理并缓存:

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),
        }
    })
}

逻辑组合(AND / OR / XOR)同样用闭包嵌套表达:

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)))
    }
    ...
}

最终整棵表达式树变成一个单一的顶层闭包,调用它就等于执行整条规则。

这个方案的收益清单

  • 解耦:执行逻辑与 AST 结构完全分离,可以各自演进
  • 零原生代码生成:运行时只调用静态验证过的 Rust 函数,没有安全边界问题
  • 编译开销极低:装箱闭包的代价远低于真正的代码生成
  • 性能意外提升:相较于原始 AST 解释,动态分发带来了约 10~15% 的运行时性能改善

这个结果起初令团队意外——动态分发通常被认为有额外开销,但实测中,闭包树消除了大量递归调用的中间状态,缓存局部性反而更好。

唯一的代价是:相比于真正的 JIT 内联展开,每层闭包仍然有一次虚函数调用开销。但对这个场景而言,这个代价完全可以接受。


彩蛋:20 行代码暴露给前端

Cloudflare 的防火墙规则编辑器有一个 UI,需要在前端实时校验用户输入的规则语法。理想状态是前后端共用同一套解析器。

Rust 对 WebAssembly 的支持恰好提供了这个可能。

借助 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 代码可以同时服务于:

  • 后端边缘节点的规则执行
  • 前端编辑器的实时语法校验
  • 甚至 Cloudflare Workers 中的规则运行

总结与思考

Cloudflare 这个案例的核心工程价值在于,它展示了几个在实践中常被忽视的判断:

第一,不是所有问题都适合用现成工具解决。 解析器生成器省事,但一旦 DSL 有上下文相关的歧义,生成器的边界就到了。手写解析器在 Rust 里并不可怕。

第二,性能优化要先找对瓶颈。 HashMap 查找看起来"很快",但在高频执行路径上,换成数组下标索引就能带来 2 倍提升。数据结构的选择往往比算法优化影响更直接。

第三,JIT 不是银弹。 编译代价、存储复杂性、安全边界,这些隐性成本在边缘场景下完全可以让 JIT 得不偿失。闭包树在这里是一个工程上更务实的折中。

第四,语言特性可以成为架构工具。 Rust 的 Fn trait 和动态分发,不只是语言细节,而是可以用来构建可组合执行引擎的设计原语。

如果你正在设计一个嵌入式规则引擎、DSL 解释器,或者类似的执行框架,这套思路有相当强的参考价值。


原文链接:blog.cloudflare.com/building-fa…

开源仓库:github.com/cloudflare/…