构建 CEL 引擎的原生 C# 实现
摘要
作者构建了 Celly,这是 Google 通用表达式语言 (CEL) 的原生 C# 实现,通过了 100% 的官方一致性测试套件,并在此过程中发布了首个用于 .NET 的 protovalidate 库。
暂无内容
查看缓存全文
缓存时间: 2026/07/30 16:51
# 为 .NET 构建 CEL 引擎
来源:https://bsid.io/writing/building-a-cel-engine-for-net
**为什么我写了 Celly——一个从零实现、通过一致性测试的 .NET CEL 实现——以及由此诞生的 protovalidate 库**
TL;DR——我构建了 **Celly** (https://github.com/bsidio/celly),一个 Google Common Expression Language 的原生 C# 实现。它 100% 通过了官方一致性测试套件,并且在过程中我最终发布了第一个 .NET 的 protovalidate 库。它已上架 NuGet,文档在 bsid.io/celly (https://bsid.io/celly/)。
## 一条规则,一次编写
```
user.age >= 18 && user.country in ['US','CA']
```
**针对变化的数据求值**
```
{ age: 25, country: 'US' } -> allow
{ age: 16, country: 'US' } -> deny
{ age: 30, country: 'FR' } -> deny
```
**同一个引擎运行在:**
- Kubernetes
- Envoy
- Cloud IAM
- protovalidate
## 什么是 CEL,我为什么关心?
CEL (https://github.com/cel-expr/cel-spec)(通用表达式语言)是 Google 开发的一个小巧、安全的表达式语言。即使你从未听说过它,也可能用过基于它构建的东西——它是 Kubernetes 准入策略、Envoy 的 RBAC 规则、Google Cloud IAM 条件以及 gRPC 的 protovalidate 背后的语言。思想很简单:让人们将诸如 `request.auth.groups.exists(g, g == "admin") || resource.owner == request.auth.subject` 这样的表达式作为*数据*——配置文件中的字符串——在运行时安全地求值。没有任意代码执行,保证终止,强类型。
## 为什么用 CEL,而不是直接用脚本语言?
任何时候你需要让逻辑脱离编译代码——“哪些请求被允许”、“这个告警何时触发”、“这个载荷是否有效”——你都有几种选择,而大多数都是陷阱:
- **硬编码它。** 现在每次规则变更都是一次代码变更、评审和部署。你的规则跟着发布流程走,而不是业务速度。
- **嵌入真正的脚本语言**(Lua、JavaScript、Python)。恭喜,你已经把任意代码执行权交给了写规则的人——`while (true)`、读文件系统、耗尽内存。一条规则现在成了攻击面。
- **自己发明迷你语言。** 现在你要永远维护一个解析器、类型检查器和规范,而且它只在一个服务里工作。
CEL 是绝佳平衡点,并且它流行的原因经过深思熟虑:
- **它总是终止。** CEL *不是*图灵完备的,这是刻意的。没有无界循环,没有递归——求值是有界的。正因为这个特性,Google、Kubernetes 和 Envoy 才放心地在请求路径中直接运行不受信任的用户提供表达式。一条规则绝不会让你的服务器卡住。
- **它天生安全。** 没有 I/O,没有副作用,没有环境权限。一个表达式只能查看你给它的数据,并返回一个值。恶意规则最坏的结果就是*错误*。
- **规则是数据,不是代码。** 表达式就是字符串——它们存放在数据库、Kubernetes CRD、配置映射中——并且无需重新部署即可变更。产品可以在几秒钟内发布新策略。
- **一次编写,到处运行。** 同一个表达式在 Go、C++、Java、Rust——现在是 .NET——中含义相同。在你的 `.proto` 中声明一条验证规则,每个服务的每种语言都一致地执行它。这正是 protovalidate 的全部前提。
- **快速且类型化。** 编译一次,求值百万次。类型检查器在规则部署前就能捕获错误,求值则是遍历预解析数据的紧凑过程。
关键在于 CEL 提供了*恰好足够*的语言来表达真实逻辑,并有意地保留了一切可能使其危险的东西。这种克制正是其核心——也是为什么它不断出现在你已经在用的基础设施背后。
官方实现有 Go、C++、Java 和 Rust,但 .NET 没有。已有的选择都是部分移植,没有一个通过一致性测试套件。所以如果你在 .NET 上想要 CEL,你就陷入困境了。我想要它,所以我决定自己写。
## 我给自己定的一条规则
取巧的方法是把 Go 实现编译成 WebAssembly,然后从 C# 调用它。或者发布一个原生绑定。这样“能工作”,但你的纯托管库现在拖着一个 WASM 运行时或一个原生的 `.so`,你还继承了一整套打包和平台问题。所以我定了一条规则:**纯托管 C#。没有 WASM,没有 cgo,没有 Go 编译的制品。** 一切——词法分析器、递归下降解析器、类型检查器、树遍历求值器——都用 C# 从头编写。如果不能用托管代码实现,就不发布。
事实证明这是正确的约束,但它让正确性的门槛高得多。你不能依赖别人的引擎;你必须自己把每一个细节都做对。
## 证明它真的有效
关于声称“完整的 CEL 支持”有这么一件事——这是一个你必须能够*证明*的声明。CEL 充满了微妙的陷阱:
- 跨类型数值比较(`1 == 1u == 1.0`)在同一个数轴上,且不能通过 double 转换而丢失 2^53 以上的精度。
- `size()`、`charAt`、`substring` 是基于**Unicode 码点**的,但 .NET 字符串是 UTF-16 编码——所以代理对会静默地破坏朴素实现。
- 时间戳需要纳秒精度,但 .NET 的 tick 是 100 纳秒。
- `string(1e23)` 必须像 Go 的 `strconv` 那样格式化,包括科学记数法。
- `&&` 和 `||` 是可交换且错误吸收的——`false && error` 结果是 `false`,而不是错误。
每一个都是可能悄悄出错的地方。所以从第一个提交开始,Celly 就接入了官方 cel-spec 一致性测试套件 (https://github.com/cel-expr/cel-spec) —— 与 cel-go、cel-cpp 和 cel-java 跑的是相同的 2,456 个测试。我用了棘轮式跳过列表:失败的测试可以被列出并忽略,但一旦它开始通过,构建就会*失败*直到我从列表中移除它。进展只能向前,CI 始终为绿。现在这个列表是空的。**2,456 / 2,456。**
因为“相信我,100% 通过”还不够,我通过三种独立方式验证了它:仓库的测试套件、一个完全分开的审计(从 NuGet 拉取 Celly 并用不同工具转换测试数据)以及一个差分模糊测试器(生成数万个随机表达式,将 Celly 的输出与 cel-go 逐字节对比)。零差异。
## 给我看代码
```csharp
using Celly;
using Celly.Checking;
using Celly.Types;
var env = CelEnv.Create(new CelEnvSettings
{
Declarations = [
new VariableDecl("request", CelType.Map(CelType.String, CelType.Dyn)),
],
});
var program = env.Compile("request.user.startsWith('admin-') && size(request.groups) > 0");
var result = program.Eval(new Dictionary<string, object>
{
["request"] = new Dictionary<string, object>
{
["user"] = "admin-alice",
["groups"] = new[] { "ops" },
},
});
// result => true
```
编译一次,求值多次——每个策略引擎都想要的模式。编译后的程序是不可变且线程安全的,所以一个实例可以服务于每个请求。
```
'x + 1 > 3'
-> Compile
-> CelProgram
-> Eval(data)
-> result
lex · parse · check · plan (一次)
重用 · 线程安全 · 多次
```
## 让它变快
策略引擎编译一条规则一次,然后在每个请求上对它求值——有时每秒数百万次。所以真正重要的数字不是你解析有多快,而是稳态求值性能。Celly 结果是 .NET 上最快的 CEL 引擎——大约比次优的托管选项快 1.2 倍,比其他选项快几倍。但惊喜是:在处理 comprehension 密集型表达式时,它*比 cel-go 本身还快*,cel-go 是参考 Go 实现(100 个元素的 `filter`/`map` 大约 15μs vs 22μs)。一个管理树遍历器打败原生 Go 可不在我的预料之中。
几个决策让它达到了这个水平:
- **编译一次,求值多次。** 解析、类型检查和规划全部提前完成,产生一个不可变的 `CelProgram`。求值就是遍历这个预构建的计划,其中重载已经解析,常量子表达式已经折叠——所以热路径几乎不做簿记工作,只做真正的工作。程序不可变且线程安全,一个实例服务所有线程,无需每个线程的设置。
- **绳结技巧。** CEL 的 comprehension(`map`、`filter`)通过反复做 `accumulator + [element]` 来构建结果。如果直接那么做,每次迭代都复制整个列表 —— O(n²),这正是 comprehension 成本堆积的地方。Celly 使用一种 rope 式列表,使追加操作为 O(1),从而将累积过程变回 O(n)。这一个改动就是 comprehension 基准测试打败 Go 引用的原因。
- **扩展走相同路径。** 字符串和数学扩展函数不是带调度开销的附加层——它们与内置函数一样接入相同的树遍历。在扩展密集型表达式上,Celly 比 cel-go 快大约 2 倍。而且打开全部九个扩展库在求值时不产生任何开销:一个简单表达式运行大约 125ns,无论环境是零个库还是有全部库,因为加载库是一次编译步骤,热路径只触及你的表达式实际使用的函数。
## 我没想到的部分:protovalidate
核心引擎稳定后,我注意到了一些事情。protovalidate (https://github.com/bufbuild/protovalidate) —— buf 对 protoc-gen-validate 的继任者 —— 是你如何根据直接在 `.proto` 中声明的规则来验证 protobuf 消息的方法:
```protobuf
string email = 1 [(buf.validate.field).string.email = true];
int32 age = 2 [(buf.validate.field).int32 = {gte: 0, lte: 150}];
```
它有 Go、Java、Python、C++ 的运行时……但没有 .NET 的。而这里有个巧妙的点:protovalidate 的规则*本身是用 CEL 编写的*,作为选项嵌入到 proto 内部。这意味着我已经有了困难的部分——一个符合标准的 CEL 引擎。剩下要做的就是通过反射读取这些规则并求值。
```
.proto
Validator
Celly engine
Violations
buf.validate 规则 (CEL)
通过反射读取规则
规则本身就是 CEL
field · rule · message
```
“剩下要做”变成了一场真正的苦战——protovalidate 有自己的一致性测试套件,包含 2872 个案例,覆盖所有标量规则、well-known 类型、map、oneof、editions 等等。但我用相同的方式让它变绿:运行 buf 的官方一致性测试器,修复失败项,重复。现在它达到了 **2,872 / 2,872**,并且已上架 NuGet,名称为 `Celly.Protovalidate`。据我所知,这是第一个 .NET 的 protovalidate 实现。
```csharp
var validator = new Validator(Person.Descriptor.File);
foreach (var violation in validator.Validate(person))
Console.WriteLine($"{violation.RuleId}: {violation.Message}");
```
## 试试吧
```bash
dotnet add package Celly # CEL 引擎
dotnet add package Celly.Protobuf # 如果数据是 protobuf
dotnet add package Celly.Protovalidate # protobuf 验证
```
- NuGet:
- Celly (https://www.nuget.org/packages/Celly)
- Celly.Protobuf (https://www.nuget.org/packages/Celly.Protobuf)
- Celly.Protovalidate (https://www.nuget.org/packages/Celly.Protovalidate)
- 代码:github.com/bsidio/celly (https://github.com/bsidio/celly)
- 文档:bsid.io/celly (https://bsid.io/celly/) —— 包括一个深入的“内部实现”专题,如果你想了解解析器、检查器和求值器实际上是如何工作的。
如果你在 .NET 上并且曾经想要 CEL——用于规则引擎、功能开关、授权策略或 protobuf 验证——我希望你试试它。
*寻找既懂硬件又懂软件的工程师?可以通过 [[email protected]](https://bsid.io/cdn-cgi/l/email-protection#b5d8d4dcd9f5d7c6dcd19bdcda) 联系我。*
相似文章
Chibil:针对.NET IL的C编译器
Chibil 是一个用C#编写的C编译器,针对.NET IL,能将C代码编译为.NET可执行文件。它基于chibicc,并能运行DOOM。
@QuixiAI: 在推动跨平台的过程中,我创建了一个原生构建系统(Jai风格),并正在构建一个 --emit-c 选项来补充…
开发者创建了一个原生构建系统,并正在构建一个 --emit-c 选项以补充 'with migrate' 功能,旨在实现跨平台支持并消除 Makefile 和 shell 脚本。
为Orange Pi AIPro(Ascend 310B)上的MiniCPM-V 4.6编写自定义C++引擎以绕过框架开销
为Orange Pi AIPro(Ascend 310B NPU)上的MiniCPM-V 4.6开发了自定义C++推理引擎,通过为matmul和causal-conv1d编写优化的AscendC内核,实现了相比原始框架2倍的加速,达到5.90 tokens/s。
为什么我们要编写自己的C和C++推理引擎
LocalAI 解释了为什么它要编写自己的 C/C++ 推理后端,并展示了其 vllm.cpp 移植版本在多个模型和硬件上的基准测试中,能够实现与 vLLM 相当或更高的吞吐量,同时占用空间远小于 vLLM。
Windows CE Dreamcast 社区版 (wince-dc)
一个适用于 Sega Dreamcast 的 Windows CE 社区版,它将光盘上的 CE 运行时转变为具有内置工具链的可用多任务桌面。