构建 CEL 引擎的原生 C# 实现

Hacker News Top 工具

摘要

作者构建了 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编译器

Hacker News Top

Chibil 是一个用C#编写的C编译器,针对.NET IL,能将C代码编译为.NET可执行文件。它基于chibicc,并能运行DOOM。

为什么我们要编写自己的C和C++推理引擎

Lobsters Hottest

LocalAI 解释了为什么它要编写自己的 C/C++ 推理后端,并展示了其 vllm.cpp 移植版本在多个模型和硬件上的基准测试中,能够实现与 vLLM 相当或更高的吞吐量,同时占用空间远小于 vLLM。

Windows CE Dreamcast 社区版 (wince-dc)

Hacker News Top

一个适用于 Sega Dreamcast 的 Windows CE 社区版,它将光盘上的 CE 运行时转变为具有内置工具链的可用多任务桌面。