Kubernetes 的 CRD 里经常能看到这样一段:
x-kubernetes-validations: - rule: "self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas" message: "replicas 必须在 minReplicas 和 maxReplicas 之间"rule 后面那行字符串不是 Go,也不是 JavaScript,apiserver 却能在每次创建、更新对象时把它算出来。同样长相的表达式还出现在 Google Cloud 的 IAM 条件(request.time < timestamp("2026-12-31T00:00:00Z"))、Envoy 的 RBAC 过滤器、buf 的 protovalidate 里1。它叫 CEL,Common Expression Language。
第一次见到它,自然会有几个疑问:为了一行条件判断,专门造一门语言值得吗?直接嵌一个 Lua 或 JavaScript 不行吗?Go 里已经有 expr、govaluate 这些表达式库,策略领域还有 OPA 的 Rego,CEL 和它们比到底差在哪、强在哪?真要在自己的服务里用,又该怎么用?
我们从一个很普通的需求出发,看看一路会碰到什么。
一、把规则交给用户写
现象:规则写死在代码里
假设我们在做一个运维平台,发布前要做一次放行判断。最早的版本直接写在 Go 里:
func allow(u User, r Resource, hour int) bool { return u.Role == "admin" || (r.Owner == u.ID && hour >= 9 && hour < 18) || slices.Contains(u.Groups, r.Team)}管理员直接放行;服务负责人只能在工作时间发布;服务所属团队的成员也可以发布。很快各个业务线都来提要求:有的要加“周五下午不发布”,有的要“灰度环境不限时间”。每改一次都要发一次平台,规则和代码绑在一起,显然走不远。
第一次尝试:把规则存成配置
最直接的想法是把规则变成数据。如果用 JSON 描述,社区里有现成的格式 JsonLogic2:
{"or": [ {"==": [{"var": "user.role"}, "admin"]}, {"and": [{"==": [{"var": "resource.owner"}, {"var": "user.id"}]}, {">=": [{"var": "hour"}, 9]}, {"<": [{"var": "hour"}, 18]}]}, {"in": [{"var": "resource.team"}, {"var": "user.groups"}]}]}它能工作,前端也好做成拖拽编辑器,但人很难直接读写。规则一复杂,大家还是希望写成一行代码那样的东西。
第二次尝试:嵌入一门脚本语言
于是很多平台走向了下一步:嵌一门现成的脚本语言。Java 世界里最顺手的是 Spring 自带的 SpEL,Go 世界里有 gopher-lua、goja(JavaScript)、Starlark。规则立刻好写了,但新的问题接着出现。我们在同一台机器上试了三条“用户写的规则”:
第一条最危险。规则本来只该看到 user、resource、hour 三个变量,SpEL 却允许用 T(...) 引用任意 Java 类,读系统属性只是演示,换成 Runtime.exec 就是远程执行命令。这不是假想:2017 年 Equifax 的数据泄露,入口是 Struts 把 HTTP 头里的一段 OGNL 表达式拿去求值3;2022 年 Spring Cloud Function 的 CVE-2022-22963,是把请求头里的 SpEL 拿去求值4。表达式语言能力太强,本身就是攻击面。
第二条说明,只要语言里有循环,用户就可能写出停不下来的程序。第三条说明,脚本语言通常到运行时才去找字段,找不到就给一个 nil,nil == "admin" 是 false,规则安静地给出了错误答案。
还有一个问题图里没画:放行判断在发布路径上,网关、准入控制这类场景更是每个请求都要跑,求值本身必须足够便宜。
问题归纳
把这几条放在一起,我们需要的其实是这样一种东西:
- 只能读宿主给它的数据、调用宿主给它的函数,别的什么都碰不到;
- 一定会停,而且最好能事先知道最坏要算多久;
- 写错字段、类型对不上,在保存规则时就报错;
- 每次求值在微秒以内。
这几条单独看都不难,难的是同时做到。CEL 就是为这个组合设计的。
二、CEL 的出现
来历
CEL 的规范仓库 cel-spec 第一个提交是 2017 年 8 月 14 日,提交说明是 “Initial import from go/api-expr”,也就是从 Google 内部的一个表达式项目导出来的5。README 里写得很直白:最初的规范基于开发 Firebase Rules 的经验,又做了很多轮可用性测试。Firebase 的安全规则正是“让用户写规则、由服务端在每个请求上求值”的典型场景,上面那几个问题 Google 早就踩过。
此后 CEL 成了 Google 内外很多系统共用的条件语言。2024 年 6 月,Google 在开源博客上专门介绍了它,概括为快、可移植、可扩展、安全四点6;官方站点 cel.dev 的说法是“非图灵完备,只能访问宿主程序提供的数据”7。
Kubernetes 是最典型的例子。CRD 校验规则(KEP-2876)在 1.23 进入 alpha,1.29 GA8;用 CEL 写准入策略的 ValidatingAdmissionPolicy 在 1.30 GA9,能改对象的 MutatingAdmissionPolicy 在 1.36 GA10。在这之前,想在准入阶段做点校验,就得自己写一个 webhook 服务、部署、配证书,再让 apiserver 每个请求都远程调用它。CEL 让这类规则变成 API 对象上的一个字段,由 apiserver 在进程内直接求值11。
还有两个跟 Go 用户直接相关的变化:2026 年 6 月仓库从 google 迁到了 cel-expr 组织;Go 模块路径改成了 cel.dev/cel-go,v0.32.0 起再写 go get github.com/google/cel-go@latest 会报模块路径不匹配12。
设计上舍弃了什么
CEL 的规范在开头就给自己下了定义:内存安全、无副作用、一定会终止、强类型,类型检查可选但推荐13。落实到语法上,就是一系列“没有”:
- 没有循环,没有赋值,没有函数定义。 一个 CEL 程序只是一个表达式。想遍历列表,只能用几个内置宏:
all、exists、exists_one、map、filter,它们遍历的对象一定是已经存在的、有限的列表或 map。 - 没有 I/O,也没有“引用任意类型”的语法。 表达式能用的变量和函数,全部由宿主程序在环境(
Env)里声明。 - 没有异常处理。 运行时错误是一个值,只能被逻辑运算符吸收,不能被捕获(这一点后面会专门讲)。
保留下来的,是一套和 C、Go、Java、JavaScript 几乎一样的语法,以及对 protobuf 和 JSON 类型的一等支持。所以我们开头那条规则,用 CEL 写出来就是:
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups舍弃了这些,换来了什么?下面两节分别看“写错了何时知道”和“会不会停”。
三、解析、检查、求值
三个阶段
在 Go 里用 CEL,固定是三步:声明环境、编译(解析加类型检查)、生成 Program 后求值。
env, _ := cel.NewEnv( ext.NativeTypes(reflect.TypeOf(User{}), reflect.TypeOf(Resource{}), ext.ParseStructTags(true)), cel.Variable("user", cel.ObjectType("main.User")), cel.Variable("resource", cel.ObjectType("main.Resource")), cel.Variable("hour", cel.IntType),)ast, iss := env.Compile(src) // 解析 + 类型检查prg, _ := env.Program(ast) // 生成可复用、并发安全的 Programout, _, _ := prg.Eval(map[string]any{"user": u, "resource": r, "hour": 23})这个拆分有两层意思。第一层是性能:我们测得每次都重新编译约 100 µs,缓存 Program 后只求值约 0.47 µs,差了 200 多倍。第二层是检查:因为环境里声明了 user 是 main.User,检查器知道它有哪些字段,user.rol 在编译时就报错,hour >= "9" 也会报 found no matching overload for '_>=_' applied to '(int, string)'。
第一次尝试:变量声明成 map
很多人第一次用 CEL,会把输入声明成 map(string, dyn),省得定义类型。这样做也能跑,但检查器就不知道 map 里有哪些键了。user.rol 能通过编译,到运行时才报 no such key: rol。
而且这个错误不一定能看到。我们给一个 9 点到 18 点之间、自己名下的资源发请求:
user.rol == "admin" || hour < 18 => truehour < 18 || user.rol == "admin" => trueuser.rol == "admin" || hour > 18 => error: no such key: roluser.rol == "admin" ? true : hour < 18 => error: no such key: rolhas(user.rol) && user.rol == "admin" || hour < 18 => true原因:逻辑运算符会吸收错误
这是 CEL 和大多数语言不一样的地方。在 C、Go 里,a || b 从左往右求值,a 出错就整个出错。CEL 规范规定 && 和 || 是可交换的:只要有一边已经能决定结果(|| 的一边是 true,&& 的一边是 false),另一边的错误就被忽略,跟左右顺序无关13。规范给的理由是,这样逻辑表达式可以映射到索引查询,和 SQL 的语义一致。想要传统的短路,就改写成三元表达式 a ? true : b。
所以 map 声明加上错误吸收,字段写错的规则可能在大部分请求上“看起来没问题”,只在某些分支上报错。解决办法是把变量声明成具体类型:用 ext.NativeTypes 注册 Go 结构体,或者直接用 protobuf 消息类型。确实需要处理“可能不存在的字段”时,用 has(user.nickname) 或可选值语法 user.?nickname.orValue("") 明确写出来,不要指望错误吸收替你兜底。
类型检查解决了“写错了何时知道”。那“会不会停”呢?没有循环,是不是就万事大吉?
四、会停,但不一定停得快
现象:一条“合法”的规则算了 12 秒
CEL 没有循环,但宏可以嵌套。一个 10 个元素的列表,嵌套 8 层 all:
[0,1,2,3,4,5,6,7,8,9].all(a, [0,1,2,3,4,5,6,7,8,9].all(b, … .all(h, true)))这条表达式只有两百多个字符,却要迭代 次。我们不加任何限制直接跑,cel-go 用了 12.6 秒才返回 true。它确实会停,规范没有骗人,但对一个每个请求都要算的规则来说,12 秒和不停没有区别。规范自己也承认:除了 has() 以外的宏,是 CEL 里唯一可能出现指数代价的地方13。
原因与解决:把“多久”变成一个数
非图灵完备的真正价值,不是“一定会停”,而是代价可以事先算出来。因为没有循环和递归,每个宏的迭代次数都受输入列表大小约束,检查器可以沿着语法树估算出最坏的代价。CEL 用一个抽象的“成本单位”来计数,比较运算算 1,列表字面量算 40,正则匹配按正则长度乘输入长度估算11。
于是有了两道闸门:
运行时计数很直观:求值器每执行一步就累加成本,超过 cel.CostLimit 就中止。上面那条 的表达式,设了 100 万的上限后 97 毫秒就停了,错误是 operation cancelled: actual cost limit exceeded。再配合 cel.InterruptCheckFrequency 和 ContextEval,宏每迭代一定次数就检查一次 context,超时也能及时退出。
静态估算更有意思。env.EstimateCost 不需要任何输入数据,只看语法树和类型声明。问题在于,对一个长度未知的列表,最坏情况就是无穷大:同一条检查镜像地址的规则,不告诉估算器列表多长、字符串多长,估出来是 ;声明最多 20 个容器、镜像名最长 256 个字符,估算立刻降到 4783。
Kubernetes 把这两道闸门都用上了。apiserver 的常量里写着:单条表达式 PerCallLimit = 1000000,注释说大约 0.1 秒;每个自定义资源或每个 ValidatingAdmissionPolicy binding 的总预算 RuntimeCELCostBudget = 10000000,大约 1 秒14。写 CRD 时,如果某条规则的估算成本超过上限,apiserver 直接拒绝这个 CRD,报错会提示你给相关字段加上 maxItems、maxLength。这就是为什么写 CRD 校验规则时,schema 里那些长度限制不是可有可无的。
到这里,CEL 对开头四个问题都给出了答案。但 Go 生态里做表达式和规则的库远不止 CEL 一个,它们各自是怎么回答这几个问题的?
五、和同类语言对比
参加对比的语言
我们把能嵌进程序里写“判断”的东西尽量列全,按作者主要用的 Go 生态排主次,Java 生态单独一组。凡是有 Go 实现的都装进同一个程序里,用同一条放行规则、同一组输入实测;Java 的几个用 JDK 21 单独测。
| 语言 | 出处 · 实测版本 | 定位 | 能不能写循环 |
|---|---|---|---|
| 表达式语言(一个表达式,算出一个值) | |||
| CEL | Google,2017;cel-go v0.32.0,另有 C++、Java | 通用条件语言 | 只有 all / exists / map / filter 宏 |
| expr | expr-lang;v1.17.8 | Go 表达式语言 | 内置 all / map / filter,有区间 1..n |
| govaluate | Knetic;v3.0.0,已多年不更新 | Go 表达式 | 不能 |
| gval | PaesslerAG;v1.2.4 | 可扩展的 Go 表达式 | 不能 |
| JsonLogic | Jeremy Wadhams;Go 移植 v3.10.1 | 规则即 JSON | all / map / reduce |
| JMESPath | AWS CLI 的查询语言;go-jmespath v0.4.0 | JSON 查询 | 只能投影输入 |
| jq | Stephen Dolan,2012;gojq v0.12.19 | JSON 处理 | 能(递归) |
| 策略语言 | |||
| Rego | OPA,CNCF 毕业项目;v1.21.3 | Datalog 风格的策略语言 | 推导式,禁止递归 |
| Cedar | AWS,2023 年开源;cedar-go v1.8.0 | 授权策略 | 不能 |
| 配置语言 | |||
| CUE | Marcel van Lohuizen;v0.17.1 | 配置 + 校验 | 推导式 |
| Jsonnet | Google,2014;go-jsonnet | 配置生成 | 能(递归) |
| 嵌入式脚本 | |||
| Starlark | Bazel 的 Python 方言;starlark-go | 确定性脚本 | for 能;while、递归默认禁止 |
| Lua | gopher-lua v1.1.2 | 通用脚本 | 能 |
| JavaScript | goja | 通用脚本 | 能 |
| Tengo | d5/tengo v2.17.0 | Go 原生脚本 | 能 |
| Java 生态(单独实测) | |||
| SpEL | Spring 7.0.9 | Spring 的表达式语言 | 不能,但能调用任意方法 |
| MVEL | 2.6.0.Final | 表达式 + 脚本 | 能 |
| Aviator | 5.4.4 | 表达式 + 脚本 | 能 |
| JEXL | Apache Commons 3.7.1 | 表达式 + 脚本 | 能 |
| CEL-Java | dev.cel 0.14.0 | CEL 的 Java 实现 | 同 CEL |
expr 和 cel-go 各测了两种输入:map[string]any 和 Go 结构体,所以 Go 侧一共 17 个配置。没有实测的:HashiCorp Sentinel(商业产品)、Apple 的 Pkl、Dhall、Go 解释器 Yaegi、OGNL(它的问题见第一节的 Struts 漏洞)。
场景一:同一条规则怎么写
17 个 Go 配置都写出了开头那条规则,6 个测试用例(管理员、工作时间内的负责人、下班后的负责人、团队成员、无关的人、没有任何组的人)全部通过。写法上的差别,比想象中能说明问题:
// CEL、expr、gval:三者完全一样user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups# Rego:每条 allow 是一条独立规则,命中任意一条即为真package authzdefault allow := falseallow if input.user.role == "admin"allow if { input.resource.owner == input.user.id input.hour >= 9 input.hour < 18}allow if input.resource.team in input.user.groups// Cedar:先建实体模型,团队关系用 in 表达成实体层级permit (principal, action == Action::"deploy", resource)when { principal.role == "admin" || (resource.owner == principal && context.hour >= 9 && context.hour < 18) || principal in resource.team};CEL 和 expr 一模一样,因为它们都选了 C 系语法;Rego 把“或”拆成多条规则,读起来像逻辑程序;Cedar 要求先把用户、团队、服务建模成实体,规则里的 principal in resource.team 是沿着实体层级判断的。Lua、Tengo 没有 in,要自己写一个 contains 循环;JsonLogic 和 JMESPath 只认 JSON 的 float64,传 Go 的 int 会报错或比较失败。
场景二:写错字段、写错类型
接下来把每个引擎里的 user.role 改成 user.rol,再把 hour >= 9 改成 hour >= "9",看谁在什么时候发现:
| 引擎 | user.rol(字段写错) | hour >= "9"(类型写错) |
|---|---|---|
| CEL(Go 结构体) | 编译期:undefined field 'rol' | 编译期:no matching overload for '_>=_' applied to '(int, string)' |
| CEL(map / dyn) | 运行时:no such key;另一边为 true 时被吸收 | |
| expr(Go 结构体) | 编译期:type main.User has no field Rol | 编译期:mismatched types int and string |
| expr(map) | 静默返回 false | |
| Rego + JSON Schema | 编译期:undefined ref: input.user.rol | 静默返回 false |
| Rego(无 schema) | 静默返回 false | 静默返回 false |
| Cedar | 运行时:策略报错,不生效 | 运行时:type error |
| govaluate、Starlark、CUE、Jsonnet | 运行时报错 | 运行时报错,只在走到这一支时 |
| Lua、Tengo | 静默返回 false | 运行时报错,只在走到这一支时 |
| gval、JsonLogic、JavaScript | 静默返回 false | 静默转换,10 >= "9" 为 true |
| jq、JMESPath | 静默返回 false | 静默返回 false |
“运行时报错,只在走到这一支时”:管理员的请求在第一个 || 就决定了结果,这些引擎对它返回 true,不报错;只有走到 hour 比较的请求才报错。
这张表里最该注意的是红色那一片:一半的引擎在字段写错时静默返回 false。对放行规则来说,这意味着管理员突然发不了版,而日志里什么都没有。能在编译期拦住两类错误的,只有声明了具体类型的 CEL 和 expr。Rego 加上 JSON Schema 能发现字段错误,但它的比较运算对不同类型有一个全序(数字总是小于字符串),所以 10 >= "9" 是合法的、结果是 false。
场景三:写一个停不下来的表达式
再给每个引擎写一个“它允许写出来的最坏表达式”:有循环的写死循环,没有循环的写嵌套迭代。先不加限制跑,外面用 timeout 8s 兜底;再打开该引擎自带的限制手段跑一次,上限都设为约 1 秒或 100 万步。
| 引擎 | 最坏写法 | 不设限 | 自带的限制 |
|---|---|---|---|
| CEL | 8 层嵌套 all,10⁸ 次 | 12.6 s 后返回 | 事先估算 4.6 亿;CostLimit 97 ms 中止 |
| expr | 同上,用区间 0..9 | 108 ms 中止:默认内存预算 memory budget exceeded | |
| expr | 同上,用字面量列表 | 31.7 s | 没有 CPU 或步数限制 |
| JsonLogic | 8 层嵌套 all | 15.4 s | 没有 |
| Starlark | for i in range(10**9) | 40.4 s | SetMaxExecutionSteps 45 ms 中止 |
| Rego | 两层 numbers.range(1, 10000) 推导式 | 8 s 未返回 | context 超时,1 s 中止 |
| Lua | while true do end | 8 s 未返回 | SetContext 超时,1 s 中止 |
| JavaScript | for (;;) {} | 8 s 未返回 | Interrupt,1 s 中止 |
| Tengo | for {} | 8 s 未返回 | RunContext,1 s 中止 |
| jq | def f: f; f | 8 s 未返回 | RunWithContext,1 s 中止 |
| CUE | 两层 list.Range(0, 10000, 1) 推导式 | 8 s 未返回 | 没有 |
| Jsonnet | std.foldl 遍历 10⁸ 个元素 | 8 s 未返回 | 没有,只有栈深度限制 |
| govaluate、gval、JMESPath、Cedar | 写不出循环或嵌套迭代,代价只和表达式长度、输入大小成正比 | ||
能停住的引擎其实不少:Lua、JavaScript、Tengo、jq、Rego 都支持 context 或中断,Starlark 有步数上限15。差别在于它们都是“跑起来再看”,一条规则在保存时无法判断它会不会超时,只能上线后靠超时兜底。CEL 是这里唯一能在写入时就给出最坏代价的;expr 有内存预算,但用字面量列表就能绕开,31.7 秒没有任何东西拦它。
场景四:Java 里的沙箱
Java 生态的表达式语言普遍能直接访问 Java 类,这正是第一节那几个漏洞的根源。我们让每个引擎读一次系统属性 user.name,再写一个死循环或阻塞调用:
| 引擎 | 读 System.getProperty("user.name") | 死循环或阻塞 |
|---|---|---|
| SpEL(StandardEvaluationContext) | "root" | Thread.sleep:3 s 后仍在跑 |
| SpEL(SimpleEvaluationContext) | 拦截:Type cannot be found | — |
| MVEL | "root" | while (true):3 s 后仍在跑 |
| Aviator | "root" | while true:3 s 后仍在跑 |
| JEXL(默认权限) | 拦截:undefined property 'class' | while (true):3 s 后仍在跑 |
| JEXL(UNRESTRICTED) | "root" | — |
| CEL-Java | 编译期:undeclared reference to 'java' | 写不出来 |
SpEL 的 SimpleEvaluationContext 是 Spring 官方给不可信表达式准备的受限模式,代价是方法调用也受限:同一条规则里的 List.contains 就因为找不到方法而报错。
SpEL 和 JEXL 都提供了受限模式16,问题是它们默认不开,或者一开就要牺牲常用功能。CEL 的安全性不来自配置,而来自语法:语言里根本没有“引用一个宿主类型”的写法,java.lang.System 只是一个没声明过的标识符。
场景五:每次求值的耗时
最后是性能。所有引擎都先编译好、输入也先转换成各自需要的格式,只测求值本身(用团队成员这个用例,前两个分支都不成立,要一直算到第三个):
表达式类引擎都在 1 µs 以内。expr 用 Go 结构体时最快,163 ns,因为它把字段访问编译成了直接的结构体取值;CEL 在 0.5 µs 左右,map 和结构体两种输入差别不大。Rego 约 11 µs,CUE、Jsonnet 每次都要走一遍完整的求值或合一,在 50–100 µs,适合配置生成,不适合放在每个请求上。要注意的是,这里没有算上把业务数据转换成引擎输入的时间:Cedar 每次要建实体,Jsonnet 每次要序列化成 JSON,加上转换以后 Cedar 约 3 µs。
场景六:只知道一半输入的时候
还有一种场景很少有人提,但很实用。发布列表页要展示“当前用户能发布哪些服务”,如果对每个服务都跑一遍规则,服务一多就慢。更好的办法是:用户的信息已知,服务的信息未知,让规则自己“化简”成一个只和服务有关的条件,再把它变成数据库查询。
CEL 的部分求值(partial evaluation)就是干这个的:
prg, _ := env.Program(ast, cel.EvalOptions(cel.OptTrackState, cel.OptPartialEval))vars, _ := cel.PartialVars(map[string]any{"user": u, "hour": 10}, cel.AttributePattern("resource"))_, det, _ := prg.Eval(vars)residual, _ := env.ResidualAst(ast, det)s, _ := cel.AstToString(residual)// u1、dev、组 [web]、10 点:resource.owner == "u1" || resource.team in ["web"]// 管理员:true剩下的残差表达式 resource.owner == "u1" || resource.team in ["web"] 只涉及服务字段,可以直接翻译成 WHERE owner = 'u1' OR team IN ('web')。这正是第三节提到的、规范让 && 和 || 可交换的理由:逻辑表达式要能映射到索引查询。在所有参加对比的引擎里,只有 CEL 和 Rego 有这个能力;Rego 的部分求值给出的是 "u1" = input.resource.owner 和 internal.member_2(input.resource.team, ["web"]) 两条查询17,OPA 社区也一直用它把策略编译成 SQL 过滤条件。
对照
把六个场景放在一起:
| 场景 | CEL | expr | Rego | Cedar | Starlark / Lua / JS |
|---|---|---|---|---|---|
| 能碰到什么 | 只有声明的变量和函数 | 只有 Env | 有 http.send 等内置函数,可关 | 只有实体和上下文 | 取决于注入了哪些库 |
| 写错字段 | 编译期(需声明类型) | 编译期(需传结构体) | 需配 JSON Schema | 运行时;Rust 版可用 schema 校验 | 运行时或静默 |
| 写错类型 | 编译期 | 编译期 | 静默 | 运行时 | 运行时或静默 |
| 失控 | 事先估算 + 运行时计数 | 内存预算,可绕开 | context 超时 | 写不出循环 | 步数或超时 |
| 每次求值 | 约 0.5 µs | 0.16–0.44 µs | 约 11 µs | 约 0.6 µs(不含建实体) | 0.3 / 6.3 / 1.2 µs |
| 部分求值 | 有 | 无 | 有 | 无 | 无 |
| 跨语言 | Go、C++、Java 等,同一规范 | 只有 Go | Go,另有 Wasm | Rust、Go | 各有多种实现 |
结论可以这样理解:
- CEL 和 expr18 是最接近的一对。 语法相同,都有类型检查,都快。expr 更快、函数更多、写起来更像 Go;CEL 多了成本估算、部分求值和跨语言的同一套规范。规则只在 Go 服务里、由自己团队写,expr 很合适;规则来自用户、要在写入时就判断安全,或者同一条规则要在 Go 后端、Java 服务、前端校验里都能跑,选 CEL。
- Rego 解决的是更大的问题。 它是一个策略引擎:多条规则组合、引用外部数据、统一的策略中心、决策日志。把它当成“一行条件”的求值器来用,既慢又绕。
- Cedar19 专注授权。 实体模型和
permit / forbid结构让它天然适合回答“谁能对什么做什么”,也更容易做形式化分析;但它不是通用的条件语言。 - Starlark、Lua、JavaScript 是脚本。 真需要循环、局部变量、多步计算时才用它们,用的时候必须自己加超时、步数限制,并小心控制注入的库。
- CUE、Jsonnet 是配置语言,jq、JMESPath 是 JSON 查询语言。它们能写判断,但不是为每个请求求值设计的。
选定了 CEL,在 Go 服务里怎么用才能把上面这些好处都拿到?
六、在 Go 里用好 CEL
前面几节已经暗示了大部分做法。把它们按规则的生命周期排一下:
下面逐条说明每个做法解决的是什么问题。
1. 环境声明具体类型
问题:声明成 map(string, dyn) 时,字段写错要到运行时才发现,还可能被 || 吸收(第三节)。
做法:输入用 Go 结构体的话,ext.NativeTypes 加 ext.ParseStructTags(true),字段名用 cel:"id" 这样的标签指定;输入本来就是 protobuf 的话,cel.Types(&pb.Request{}) 加 cel.Variable("request", cel.ObjectType("pkg.Request")),这也是 CEL 原生的方式。环境在进程启动时建一次,所有规则共用。
2. 保存时编译,并检查输出类型
问题:user.role 也是一条合法的 CEL 表达式,结果是字符串。放行规则返回了字符串,求值时才发现就晚了。
做法:保存规则时调用 env.Compile,把 iss.Err() 原样返回给用户(它带行列号和一个指向出错位置的 ^);再检查 ast.OutputType().IsExactType(cel.BoolType)。通过以后把规则原文存起来,进程里缓存 Program。每次请求重新编译要 100 µs,缓存后只剩 0.5 µs。
3. 用尺寸提示估算成本,并设置运行时上限
问题:规则会停,但可能很慢(第四节);而列表长度未知时,估算出的上限没有意义。
做法:实现一个 checker.CostEstimator,在 EstimateSize 里按字段路径返回业务上的最大长度(比如一个用户最多 64 个组),保存时用 env.EstimateCost 拒绝超过上限的规则。生成 Program 时加 cel.CostLimit 和 cel.InterruptCheckFrequency(100),请求时用 ContextEval 带上超时。我们实测 CostLimit 会让这条规则的求值从 0.47 µs 变成 1.47 µs,因为每一步都要计数;如果规则全部由内部人员编写、并且保存时已经估算过,可以只靠估算和超时,省掉这部分开销。
4. 出错一律按拒绝处理
问题:求值出错时(字段缺失、超出成本、超时),返回值不是 bool。
做法:放行类规则 fail closed,任何错误都当作“不允许”,同时把错误记日志。Kubernetes 的 ValidatingAdmissionPolicy 用 failurePolicy 让管理员显式选择出错时是放行还是拒绝,也是同一个思路。
5. 自定义函数要是纯函数
问题:CEL 的安全性来自“只能调用宿主声明的函数”。如果宿主声明了一个 httpGet(url),前面所有保证都失效了:它有副作用,成本也无法估算。
做法:用 cel.Function 注册的函数只做纯计算(解析版本号、判断 CIDR、查一个预先加载好的内存表),不做 I/O。需要外部数据时,在求值前由宿主准备好放进输入。函数的代价不是常数的,在 EstimateCallCost 和运行时成本估算里报上去,否则估算器会按 1 计算。
6. 环境要版本化
问题:规则存进数据库后,平台升级 cel-go、打开新的扩展库,老规则的行为可能变化;反过来,回滚到旧版本时,新语法写的规则可能无法编译。
做法:扩展库都带版本号,例如 ext.Strings(ext.StringsVersion(2)),显式写死版本,升级时再统一评估。Kubernetes 就是这样做的:每个 CEL 库从哪个版本开始可用写在文档里,新功能先只允许已存在的规则继续求值、不允许新写入,等到可以安全回滚了再放开11。
7. 需要“列出所有能访问的对象”时用部分求值
问题:对每个候选对象都跑一遍规则,对象一多就慢。
做法:已知的部分(用户)放进 PartialVars,未知的部分(对象)用 AttributePattern 标出来,拿到残差表达式后翻译成数据库查询,像第五节场景六那样。
在 Kubernetes 里
在 Kubernetes 里写 CEL,上面几条大多已经由 apiserver 做好了:类型来自 OpenAPI schema,成本估算和运行时预算是内置的。我们要做的主要是两件事:给 schema 里的列表和字符串加 maxItems、maxLength,让估算有意义;把重复的子表达式提到 variables 里,给失败写一条有用的 messageExpression。比如要求 Deployment 的镜像都来自公司仓库:
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: image-from-corp-registryspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: ["apps"] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["deployments"] variables: - name: badImages expression: >- object.spec.template.spec.containers .filter(c, !c.image.startsWith('registry.corp/')) .map(c, c.image) validations: - expression: "size(variables.badImages) == 0" messageExpression: "'镜像必须来自 registry.corp:' + variables.badImages.join(', ')"七、回到开头的放行规则
现在可以把第一节的需求完整地做出来了。下面这段程序就是第六节几条做法的组合:环境用具体类型,保存时检查输出类型和成本,请求时带超时和成本上限,出错就拒绝。
package main
import ( "context" "errors" "fmt" "reflect" "time"
"cel.dev/cel-go/cel" "cel.dev/cel-go/checker" "cel.dev/cel-go/ext")
type User struct { ID string `cel:"id"` Role string `cel:"role"` Groups []string `cel:"groups"`}
type Resource struct { Owner string `cel:"owner"` Team string `cel:"team"`}
const costLimit = 1_000_000
// 一个进程只建一次:声明变量的具体类型,写错字段在保存时就报错。var env = must(cel.NewEnv( ext.NativeTypes(reflect.TypeOf(User{}), reflect.TypeOf(Resource{}), ext.ParseStructTags(true)), cel.Variable("user", cel.ObjectType("main.User")), cel.Variable("resource", cel.ObjectType("main.Resource")), cel.Variable("hour", cel.IntType),))
// 保存规则时调用:检查、限定输出类型、估算成本,返回可缓存的 Program。func compileRule(src string) (cel.Program, error) { ast, iss := env.Compile(src) if iss.Err() != nil { return nil, iss.Err() } if !ast.OutputType().IsExactType(cel.BoolType) { return nil, fmt.Errorf("规则必须返回 bool,实际是 %v", ast.OutputType()) } est, err := env.EstimateCost(ast, sizeHints{}) if err != nil { return nil, err } if est.Max > costLimit { return nil, fmt.Errorf("规则最坏成本 %d 超过上限 %d", est.Max, costLimit) } return env.Program(ast, cel.CostLimit(costLimit), cel.InterruptCheckFrequency(100))}
// 每次请求调用:带超时,任何错误都按拒绝处理。func allow(ctx context.Context, prg cel.Program, u User, r Resource, hour int) (bool, error) { ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() out, _, err := prg.ContextEval(ctx, map[string]any{"user": u, "resource": r, "hour": hour}) if err != nil { return false, err } b, ok := out.Value().(bool) if !ok { return false, errors.New("non-bool result") } return b, nil}
// 告诉估算器:groups 最多 64 个,字符串最长 128。type sizeHints struct{}
func (sizeHints) EstimateSize(n checker.AstNode) *checker.SizeEstimate { if p := n.Path(); len(p) > 0 { switch p[len(p)-1] { case "groups": return &checker.SizeEstimate{Min: 0, Max: 64} case "id", "role", "owner", "team", "@items": return &checker.SizeEstimate{Min: 0, Max: 128} } } return nil}
func (sizeHints) EstimateCallCost(fn, overload string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { return nil}
func must[T any](v T, err error) T { if err != nil { panic(err) } return v}拿开头那条规则和几条有问题的规则试一下:
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups 保存成功,求值:trueuser.rol == "admin" 拒绝保存:ERROR: <input>:1:5: undefined field 'rol'hour >= "9" 拒绝保存:ERROR: <input>:1:6: found no matching overload for '_>=_' applied to '(int, string)'user.role 拒绝保存:规则必须返回 bool,实际是 stringuser.groups.all(a, user.groups.all(b, user.groups.all(c, user.groups.all(d, a + b + c + d != "")))) 拒绝保存:规则最坏成本 2081972611 超过上限 1000000第一节图 1 里的三个问题,在这里都在保存时被拦下了:没有办法引用 java.lang.System 这类宿主之外的东西;嵌套四层遍历最多 64 个元素的组,估算出 20 亿的成本,直接拒绝;字段写错报出了具体的行列号。
小结
- 为什么需要一门专门的表达式语言? 把规则交给用户写,嵌入完整的脚本语言会带来四个问题:能碰到宿主、可能停不下来、写错了要到运行时才知道(甚至静默给出错误答案)、求值不够便宜。SpEL、OGNL 的几次远程代码执行漏洞都出在这里。
- CEL 从哪来? 2017 年从 Google 内部的 go/api-expr 导出,基于 Firebase 安全规则的经验设计,后来用在 IAM 条件、Envoy、Kubernetes 的 CRD 校验和准入策略里。
- 它舍弃了什么、换来了什么? 没有循环、赋值、I/O 和引用宿主类型的语法,换来了只能访问宿主给的数据、类型可以在编译期检查、最坏代价可以在写入前估算。
- 非图灵完备就够了吗? 不够。嵌套宏照样能写出 12 秒的表达式,要靠静态估算(配合列表、字符串的最大长度)和运行时成本上限两道闸门。
- 和同类比怎么样? 22 个实现在同一组场景里实测:CEL 和 expr 最接近,expr 更快,CEL 多了成本估算、部分求值和跨语言规范;Rego 是策略引擎,Cedar 专注授权,Starlark、Lua、JavaScript 是脚本,CUE、Jsonnet 是配置语言。CEL 不是每项最好,但每项都不差。
- 在 Go 里怎么用? 环境声明具体类型;保存时编译、检查输出类型、估算成本,缓存 Program;请求时带超时和成本上限,出错就拒绝;自定义函数保持纯函数;扩展库写死版本;列表查询用部分求值。
参考资料
Footnotes
-
Advanced CEL topicsbuf.build ↗“If you've written expressions for conditional IAM, its Secure Web Proxy, or Firebase security rules, you've used CEL.” protovalidate 用 CEL 写字段约束。https://buf.build/docs/protovalidate/cel ↩
-
JsonLogicjsonlogic.com ↗规则本身是 JSON;文中用 Go 移植 diegoholiveira/jsonlogic v3.10.1。https://jsonlogic.com/ ↩
-
CVE-2017-5638:Apache Struts Jakarta Multipart 解析器远程代码执行nvd.nist.gov ↗攻击者在 Content-Type 头里放一段 OGNL 表达式即可执行任意命令,Equifax 2017 年的数据泄露就是从这里进去的。https://nvd.nist.gov/vuln/detail/CVE-2017-5638 ↩
-
CVE-2022-22963:Spring Cloud Function 路由表达式 SpEL 注入nvd.nist.gov ↗
spring.cloud.function.routing-expression请求头里的 SpEL 被直接求值,导致远程代码执行。https://nvd.nist.gov/vuln/detail/CVE-2022-22963 ↩ -
cel-expr/cel-spec:Common Expression Language 规范github.com ↗README 的设计目标:线性时间、无副作用、非图灵完备;“The initial spec was based on the experience of developing Firebase Rules”。第一个提交是 2017-08-14 的 “Initial import from go/api-expr”。https://github.com/cel-expr/cel-spec ↩
-
Google、オープンソースの高速で安全な式言語「Common Expression Language」を発表atmarkit.itmedia.co.jp ↗2024 年 6 月 Google 开源博客专文介绍 CEL 的报道:速度、可移植、可扩展、安全四个特点,以及 Kubernetes、IAM 中的用途。https://atmarkit.itmedia.co.jp/ait/articles/2406/24/news056.html ↩
-
cel.devcel.dev ↗官方站点:Speed、Portability、Safety(Non-Turing complete, only accesses data provided by the host application)、Verifiability。https://cel.dev/ ↩
-
KEP-2876:CRD Validation Expression Languagegithub.com ↗alpha 1.23,beta 1.25,stable 1.29。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/2876-crd-validation-expression-language ↩
-
KEP-3488:CEL for Admission Control(ValidatingAdmissionPolicy)github.com ↗alpha 1.26,beta 1.28,stable 1.30。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control ↩
-
KEP-3962:Mutating Admission Policiesgithub.com ↗alpha 1.32,beta 1.34,stable 1.36。https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3962-mutating-admission-policies ↩
-
Common Expression Language in Kuberneteskubernetes.io ↗Kubernetes 里 CEL 的用途、各扩展库开始支持的版本、运行时成本预算和写入时的估算成本检查。https://kubernetes.io/docs/reference/using-api/cel/ ↩ ↩2 ↩3
-
cel-expr/cel-gogithub.com ↗Go 实现,文中用 v0.32.0。2026-06-16 仓库迁到 cel-expr 组织,模块路径为
cel.dev/cel-go。https://github.com/cel-expr/cel-go ↩ -
CEL Language Definitiongithub.com ↗memory-safe、side-effect-free、terminating、gradually-typed;
&&/||可交换并吸收错误;Performance Limits 一节:除has()外的宏是唯一可能出现指数代价的地方。https://github.com/cel-expr/cel-spec/blob/master/doc/langdef.md ↩ ↩2 ↩3 -
kubernetes:staging/src/k8s.io/apiserver/pkg/apis/cel/config.gogithub.com ↗
PerCallLimit = 1000000(约 0.1 秒)、RuntimeCELCostBudget = 10000000(每个 CR 或 VAP binding,约 1 秒)、CheckFrequency = 100。https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go ↩ -
google/starlark-gogithub.com ↗Bazel 配置语言的 Go 实现;默认不允许 while 和递归,
Thread.SetMaxExecutionSteps限制执行步数。https://github.com/google/starlark-go ↩ -
Spring Framework:SpEL Evaluation Contextdocs.spring.io ↗
SimpleEvaluationContext只支持 SpEL 的一个子集,排除了类型引用、构造器和 bean 引用,用于不可信表达式。https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html ↩ -
Open Policy Agent:Policy Language(Rego)openpolicyagent.org ↗文中用 OPA v1.21.3 的 Go SDK。部分求值(Partial Evaluation)和 JSON Schema 类型检查分别见 docs 的 Partial Evaluation、Schemas 两节。https://www.openpolicyagent.org/docs/policy-language ↩
-
expr-lang/exprgithub.com ↗Go 表达式语言,文中用 v1.17.8。
expr.Env传结构体时做静态类型检查;默认内存预算conf.DefaultMemoryBudget = 1e6。https://github.com/expr-lang/expr ↩ -
Cedar Policy Languagecedarpolicy.com ↗AWS 2023 年开源的授权策略语言,Amazon Verified Permissions 使用;文中用 cedar-go v1.8.0。https://www.cedarpolicy.com/ ↩
延伸阅读
- 其余参加实测的 Go 实现pkg.go.dev ↗Knetic/govaluate v3.0.0、PaesslerAG/gval v1.2.4、yuin/gopher-lua v1.1.2、dop251/goja、d5/tengo v2.17.0、itchyny/gojq v0.12.19、jmespath/go-jmespath v0.4.0、cuelang.org/go v0.17.1、google/go-jsonnet。https://pkg.go.dev/
- 参加实测的 Java 实现central.sonatype.com ↗spring-expression 7.0.9、mvel2 2.6.0.Final、aviator 5.4.4、commons-jexl3 3.7.1、dev.cel:cel 0.14.0,JDK 21。https://central.sonatype.com/