一行条件,过一道带成本表的闸门,只出来一个 true 右边几张写着表达式的卡片排队走向一道闸门,闸门上方是一个成本表盘,指针停在低位;穿过闸门后只剩一个打勾的圆形,表示结果是 true。左边写着 true、cost 4783 和 has(self.spec)。 true cost 4783 has(self.spec)

Kubernetes 的 CRD 里经常能看到这样一段:

YAML
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 里:

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:

JSON
{"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。规则立刻好写了,但新的问题接着出现。我们在同一台机器上试了三条“用户写的规则”:

嵌入脚本语言后,三条规则在平台进程里的结果 左边是用户填写的三条规则:SpEL 的 T(java.lang.System).getProperty('user.name');Lua 的 while true do end;Lua 的 user.rol == "admin"。右边是平台进程里的结果:第一条返回 "root",读到了宿主 JVM 的系统属性;第二条 8 秒内没有返回;第三条字段写错,返回 false 且没有任何报错,管理员也被拒绝。 用户在平台里填写的规则 平台进程里求值的结果 SpEL 7.0.9 能调用什么 T(java.lang.System).getProperty('user.name') Lua(gopher-lua 1.1.2) 会不会停 while true do end Lua(gopher-lua 1.1.2) 写错了何时知道 user.rol == "admin" or … 返回值 宿主 JVM 的用户名 "root" 返回值 最后被 timeout 杀掉 8 秒内没有返回 返回值(管理员请求) 没有任何报错 false 三条规则都能顺利保存,问题全部出现在求值的时候。 后两条的问题,平台进程自己察觉不到:一条占着线程不放,一条悄悄给出错误答案。
图 1 · 嵌入一门完整的脚本语言,规则能做的事远远超出了“判断真假”

第一条最危险。规则本来只该看到 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,规则安静地给出了错误答案。

还有一个问题图里没画:放行判断在发布路径上,网关、准入控制这类场景更是每个请求都要跑,求值本身必须足够便宜。

问题归纳

把这几条放在一起,我们需要的其实是这样一种东西:

  1. 只能读宿主给它的数据、调用宿主给它的函数,别的什么都碰不到;
  2. 一定会停,而且最好能事先知道最坏要算多久;
  3. 写错字段、类型对不上,在保存规则时就报错;
  4. 每次求值在微秒以内。

这几条单独看都不难,难的是同时做到。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。

CEL 的时间线和采用者 左边是时间线:2017-08 cel-spec 首次提交,从内部 go/api-expr 导入;2018-03 cel-go 公开,随后有 C++、Java 实现;2021-12 Kubernetes 1.23 引入 CRD 校验规则 alpha;2023-12 Kubernetes 1.29 CRD 校验规则 GA;2024-04 Kubernetes 1.30 ValidatingAdmissionPolicy GA;2024-06 Google 开源博客专文介绍 CEL;2026 年 Kubernetes 1.36 MutatingAdmissionPolicy GA;2026-06 仓库迁到 cel-expr 组织,Go 模块路径 cel.dev/cel-go。右边列出其他采用者:Google Cloud IAM Conditions、Firebase Security Rules、Cloud 的 Secure Web Proxy、Envoy RBAC 过滤器、buf protovalidate、Kyverno 和 Gatekeeper 的 CEL 策略。 时间线 其他采用者(不分先后) 2017-08cel-spec 首次提交:从 go/api-expr 导入 2018-03cel-go 公开,随后有 C++、Java 实现 2021-12K8s 1.23:CRD 校验规则(alpha) 2023-12K8s 1.29:CRD 校验规则 GA 2024-04K8s 1.30:ValidatingAdmissionPolicy GA 2024-06Google 开源博客专文介绍 CEL 2026K8s 1.36:MutatingAdmissionPolicy GA 2026-06仓库迁到 cel-expr,模块 cel.dev/cel-go Google Cloud IAM Conditions Firebase Security Rules Secure Web Proxy、CA Service Envoy RBAC 过滤器 buf protovalidate Kyverno、Gatekeeper (后两者都支持用 CEL 写策略) Kubernetes 一路把 CEL 从 CRD 校验用到了准入控制:写进 API 对象、由 apiserver 求值的规则越来越多。
图 2 · CEL 从 Google 内部的条件语言,长成了云原生里写“判断”的通用格式

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 写出来就是:

JavaScript
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups

舍弃了这些,换来了什么?下面两节分别看“写错了何时知道”和“会不会停”。

三、解析、检查、求值

三个阶段

在 Go 里用 CEL,固定是三步:声明环境、编译(解析加类型检查)、生成 Program 后求值。

Go
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) // 生成可复用、并发安全的 Program
out, _, _ := prg.Eval(map[string]any{"user": u, "resource": r, "hour": 23})
CEL 的三个阶段和各自的耗时 左边“保存规则时”区域有两张卡片。cel.NewEnv 声明 user 为 main.User、resource 为 main.Resource、hour 为 int。env.Compile 解析并检查表达式:正确的规则输出类型为 bool;把 role 写成 rol 时报 undefined field 'rol';耗时约 100 微秒。右边“每次请求”区域:缓存的 Program 接收 user 和 hour 等输入,返回 true,耗时约 0.47 微秒。 保存规则时(每条规则一次) 每次请求(成千上万次) cel.NewEnv 声明 usermain.User resourcemain.Resource hourint 变量和函数只能来自这里 env.Compile 解析 + 检查 user.role == "admin" || … → 输出类型 bool user.rol == "admin" → undefined field 'rol' 约 100 µs prg.Eval 缓存的 Program user{role: "dev", …} hour23 → true 约 0.47 µs 检查和求值分开:贵的那一步(编译)在保存规则时做一次,便宜的那一步(求值)在每个请求上做。 写错的字段在保存时就被拦下,根本到不了请求路径。
图 3 · 编译比求值贵 200 倍,所以 CEL 把它们拆成两个阶段

这个拆分有两层意思。第一层是性能:我们测得每次都重新编译约 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 => true
hour < 18 || user.rol == "admin" => true
user.rol == "admin" || hour > 18 => error: no such key: rol
user.rol == "admin" ? true : hour < 18 => error: no such key: rol
has(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:

JavaScript
[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)))

这条表达式只有两百多个字符,却要迭代 10810^8 次。我们不加任何限制直接跑,cel-go 用了 12.6 秒才返回 true。它确实会停,规范没有骗人,但对一个每个请求都要算的规则来说,12 秒和不停没有区别。规范自己也承认:除了 has() 以外的宏,是 CEL 里唯一可能出现指数代价的地方13。

原因与解决:把“多久”变成一个数

非图灵完备的真正价值,不是“一定会停”,而是代价可以事先算出来。因为没有循环和递归,每个宏的迭代次数都受输入列表大小约束,检查器可以沿着语法树估算出最坏的代价。CEL 用一个抽象的“成本单位”来计数,比较运算算 1,列表字面量算 40,正则匹配按正则长度乘输入长度估算11。

于是有了两道闸门:

CEL 的两道成本闸门 左边是写入时的静态估算:表达式 containers.all(c, c.image.matches(re)),不声明列表和字符串的最大长度时,估算最大成本为 18446744073709551615,超过 Kubernetes 单条上限 1,000,000,会被拒绝写入;声明 maxItems 20、maxLength 256 后最大成本为 4783,允许写入。右边是求值时的运行时计数:8 层嵌套 all,10 的 8 次方次迭代,估算最大成本 455,555,551;不设限时 12.6 秒后返回 true;设置 CostLimit 1e6 后 97 毫秒中止,报 actual cost limit exceeded。 写入时:静态估算 求值时:运行时计数 containers.all(c, c.image.matches(re)) 不声明最大长度 max = 18446744073709551615 拒绝 maxItems: 20 · maxLength: 256 max = 4783 允许 K8s 单条上限 PerCallLimit = 1,000,000 8 层嵌套 all,10⁸ 次迭代 静态估算 max = 455,555,551 不设限 12.6 s 后返回 true cel.CostLimit(1_000_000) 97 ms 中止:cost limit exceeded 静态估算挡住“可能很贵”的规则,运行时计数挡住“真的很贵”的那一次求值。 列表和字符串没有上限,估算就只能给出 2⁶⁴ − 1,所以 K8s 要求在 schema 里写 maxItems、maxLength。
图 4 · 非图灵完备的真正好处:最坏代价可以在写入前算出来,求值时也能逐步计数

运行时计数很直观:求值器每执行一步就累加成本,超过 cel.CostLimit 就中止。上面那条 10810^8 的表达式,设了 100 万的上限后 97 毫秒就停了,错误是 operation cancelled: actual cost limit exceeded。再配合 cel.InterruptCheckFrequency 和 ContextEval,宏每迭代一定次数就检查一次 context,超时也能及时退出。

静态估算更有意思。env.EstimateCost 不需要任何输入数据,只看语法树和类型声明。问题在于,对一个长度未知的列表,最坏情况就是无穷大:同一条检查镜像地址的规则,不告诉估算器列表多长、字符串多长,估出来是 264−12^{64}-1;声明最多 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 单独测。

参加对比的 22 个实现
语言出处 · 实测版本定位能不能写循环
表达式语言(一个表达式,算出一个值)
CELGoogle,2017;cel-go v0.32.0,另有 C++、Java通用条件语言只有 all / exists / map / filter 宏
exprexpr-lang;v1.17.8Go 表达式语言内置 all / map / filter,有区间 1..n
govaluateKnetic;v3.0.0,已多年不更新Go 表达式不能
gvalPaesslerAG;v1.2.4可扩展的 Go 表达式不能
JsonLogicJeremy Wadhams;Go 移植 v3.10.1规则即 JSONall / map / reduce
JMESPathAWS CLI 的查询语言;go-jmespath v0.4.0JSON 查询只能投影输入
jqStephen Dolan,2012;gojq v0.12.19JSON 处理能(递归)
策略语言
RegoOPA,CNCF 毕业项目;v1.21.3Datalog 风格的策略语言推导式,禁止递归
CedarAWS,2023 年开源;cedar-go v1.8.0授权策略不能
配置语言
CUEMarcel van Lohuizen;v0.17.1配置 + 校验推导式
JsonnetGoogle,2014;go-jsonnet配置生成能(递归)
嵌入式脚本
StarlarkBazel 的 Python 方言;starlark-go确定性脚本for 能;while、递归默认禁止
Luagopher-lua v1.1.2通用脚本能
JavaScriptgoja通用脚本能
Tengod5/tengo v2.17.0Go 原生脚本能
Java 生态(单独实测)
SpELSpring 7.0.9Spring 的表达式语言不能,但能调用任意方法
MVEL2.6.0.Final表达式 + 脚本能
Aviator5.4.4表达式 + 脚本能
JEXLApache Commons 3.7.1表达式 + 脚本能
CEL-Javadev.cel 0.14.0CEL 的 Java 实现同 CEL

expr 和 cel-go 各测了两种输入:map[string]any 和 Go 结构体,所以 Go 侧一共 17 个配置。没有实测的:HashiCorp Sentinel(商业产品)、Apple 的 Pkl、Dhall、Go 解释器 Yaegi、OGNL(它的问题见第一节的 Struts 漏洞)。

表 1 · 同类工具:从只能写一个表达式,到完整的脚本语言

场景一:同一条规则怎么写

17 个 Go 配置都写出了开头那条规则,6 个测试用例(管理员、工作时间内的负责人、下班后的负责人、团队成员、无关的人、没有任何组的人)全部通过。写法上的差别,比想象中能说明问题:

JavaScript
// CEL、expr、gval:三者完全一样
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups
# Rego:每条 allow 是一条独立规则,命中任意一条即为真
package authz
default allow := false
allow 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 比较的请求才报错。

表 2 · 能在保存规则时就发现错误的,只有声明了类型的 CEL、expr 和加了 schema 的 Rego

这张表里最该注意的是红色那一片:一半的引擎在字段写错时静默返回 false。对放行规则来说,这意味着管理员突然发不了版,而日志里什么都没有。能在编译期拦住两类错误的,只有声明了具体类型的 CEL 和 expr。Rego 加上 JSON Schema 能发现字段错误,但它的比较运算对不同类型有一个全序(数字总是小于字符串),所以 10 >= "9" 是合法的、结果是 false。

场景三:写一个停不下来的表达式

再给每个引擎写一个“它允许写出来的最坏表达式”:有循环的写死循环,没有循环的写嵌套迭代。先不加限制跑,外面用 timeout 8s 兜底;再打开该引擎自带的限制手段跑一次,上限都设为约 1 秒或 100 万步。

失控表达式:不设限会怎样,自带的手段能不能停住
引擎最坏写法不设限自带的限制
CEL8 层嵌套 all,10⁸ 次12.6 s 后返回事先估算 4.6 亿;CostLimit 97 ms 中止
expr同上,用区间 0..9108 ms 中止:默认内存预算 memory budget exceeded
expr同上,用字面量列表31.7 s没有 CPU 或步数限制
JsonLogic8 层嵌套 all15.4 s没有
Starlarkfor i in range(10**9)40.4 sSetMaxExecutionSteps 45 ms 中止
Rego两层 numbers.range(1, 10000) 推导式8 s 未返回context 超时,1 s 中止
Luawhile true do end8 s 未返回SetContext 超时,1 s 中止
JavaScriptfor (;;) {}8 s 未返回Interrupt,1 s 中止
Tengofor {}8 s 未返回RunContext,1 s 中止
jqdef f: f; f8 s 未返回RunWithContext,1 s 中止
CUE两层 list.Range(0, 10000, 1) 推导式8 s 未返回没有
Jsonnetstd.foldl 遍历 10⁸ 个元素8 s 未返回没有,只有栈深度限制
govaluate、gval、JMESPath、Cedar写不出循环或嵌套迭代,代价只和表达式长度、输入大小成正比
表 3 · 大部分引擎都能停住,但只有 CEL 能在执行之前就说出最坏要算多久

能停住的引擎其实不少:Lua、JavaScript、Tengo、jq、Rego 都支持 context 或中断,Starlark 有步数上限15。差别在于它们都是“跑起来再看”,一条规则在保存时无法判断它会不会超时,只能上线后靠超时兜底。CEL 是这里唯一能在写入时就给出最坏代价的;expr 有内存预算,但用字面量列表就能绕开,31.7 秒没有任何东西拦它。

场景四:Java 里的沙箱

Java 生态的表达式语言普遍能直接访问 Java 类,这正是第一节那几个漏洞的根源。我们让每个引擎读一次系统属性 user.name,再写一个死循环或阻塞调用:

Java 表达式引擎:能不能碰到宿主
引擎读 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 就因为找不到方法而报错。

表 4 · 在 Java 里,“安全”通常是一个要记得打开的选项;在 CEL 里它是语言本身

SpEL 和 JEXL 都提供了受限模式16,问题是它们默认不开,或者一开就要牺牲常用功能。CEL 的安全性不来自配置,而来自语法:语言里根本没有“引用一个宿主类型”的写法,java.lang.System 只是一个没声明过的标识符。

场景五:每次求值的耗时

最后是性能。所有引擎都先编译好、输入也先转换成各自需要的格式,只测求值本身(用团队成员这个用例,前两个分支都不成立,要一直算到第三个):

同一条规则在 17 个 Go 引擎里每次求值的耗时 对数坐标的横向条形图。最快的是 expr 用 Go 结构体,163 纳秒;govaluate、JMESPath、Starlark、expr 用 map 在 250 到 450 纳秒;CEL 用 map 528 纳秒、用 Go 结构体 568 纳秒;Cedar 591;goja 1.2 微秒;gval、JsonLogic 1.5 到 2 微秒;jq 3.9 微秒;Lua 6.3 微秒;Rego 10.9 微秒;Tengo 19.5 微秒;CUE 52 微秒;Jsonnet 93 微秒。 每次求值耗时(ns/op,对数坐标,已编译、输入已备好) 100 ns 1 µs 10 µs 100 µs expr(Go struct) 163 ns govaluate 256 ns JMESPath 284 ns Starlark 335 ns expr(map) 435 ns CEL(map) 528 ns CEL(Go struct) 568 ns Cedar 591 ns JavaScript(goja) 1.2 µs gval 1.5 µs JsonLogic 2.0 µs jq(gojq) 3.9 µs Lua(gopher-lua) 6.3 µs Rego(OPA) 10.9 µs Tengo 19.5 µs CUE 52.0 µs Jsonnet 92.8 µs Lua 的耗时里包含每次把输入建成 Lua 表;Rego、CUE、Jsonnet 每次都要走完整的求值管线。
图 5 · 表达式类引擎都在 1 µs 以内,CEL 约 0.5 µs;策略、配置语言慢一到两个数量级

表达式类引擎都在 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)就是干这个的:

Go
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 过滤条件。

对照

把六个场景放在一起:

主要候选在六个场景里的表现
场景CELexprRegoCedarStarlark / Lua / JS
能碰到什么只有声明的变量和函数只有 Env有 http.send 等内置函数,可关只有实体和上下文取决于注入了哪些库
写错字段编译期(需声明类型)编译期(需传结构体)需配 JSON Schema运行时;Rust 版可用 schema 校验运行时或静默
写错类型编译期编译期静默运行时运行时或静默
失控事先估算 + 运行时计数内存预算,可绕开context 超时写不出循环步数或超时
每次求值约 0.5 µs0.16–0.44 µs约 11 µs约 0.6 µs(不含建实体)0.3 / 6.3 / 1.2 µs
部分求值有无有无无
跨语言Go、C++、Java 等,同一规范只有 GoGo,另有 WasmRust、Go各有多种实现
表 5 · CEL 不是每一项都最好,但它是唯一每一项都不差的

结论可以这样理解:

  • 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

前面几节已经暗示了大部分做法。把它们按规则的生命周期排一下:

一条 CEL 规则从保存到使用的三个场合 保存时:env.Compile 做类型检查,确认 OutputType 是 bool,EstimateCost 不超过一百万,然后缓存 Program。请求时:按规则 ID 取出缓存的 Program,ContextEval 带 50 毫秒超时和成本上限,出错一律拒绝。列表查询时:PartialVars 只给 user 和 hour,resource 标为未知,ResidualAst 得到 owner == "u1" 或 team in ["web"],翻译成 SQL 的 WHERE owner='u1' OR team IN ('web')。 保存时 请求时 列表查询时 env.Compile(src) 字段、类型在这里检查 OutputType == bool EstimateCost ≤ 1e6 缓存 Program cache[ruleID] Program 并发安全 ContextEval(ctx, vars) 超时 50 ms · CostLimit 1e6 err != nil → 拒绝 PartialVars(user, hour) resource 标为未知 owner == "u1" || team in ["web"] WHERE owner = 'u1' OR team IN ('web') 贵的、会失败的工作都放在保存时;请求路径上只剩一次求值和一个兜底的超时。
图 6 · 一条规则的三个使用场合,各自该做什么

下面逐条说明每个做法解决的是什么问题。

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 的镜像都来自公司仓库:

YAML
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: image-from-corp-registry
spec:
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(', ')"

七、回到开头的放行规则

现在可以把第一节的需求完整地做出来了。下面这段程序就是第六节几条做法的组合:环境用具体类型,保存时检查输出类型和成本,请求时带超时和成本上限,出错就拒绝。

Go
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
保存成功,求值:true
user.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,实际是 string
user.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 亿的成本,直接拒绝;字段写错报出了具体的行列号。

小结

  1. 为什么需要一门专门的表达式语言? 把规则交给用户写,嵌入完整的脚本语言会带来四个问题:能碰到宿主、可能停不下来、写错了要到运行时才知道(甚至静默给出错误答案)、求值不够便宜。SpEL、OGNL 的几次远程代码执行漏洞都出在这里。
  2. CEL 从哪来? 2017 年从 Google 内部的 go/api-expr 导出,基于 Firebase 安全规则的经验设计,后来用在 IAM 条件、Envoy、Kubernetes 的 CRD 校验和准入策略里。
  3. 它舍弃了什么、换来了什么? 没有循环、赋值、I/O 和引用宿主类型的语法,换来了只能访问宿主给的数据、类型可以在编译期检查、最坏代价可以在写入前估算。
  4. 非图灵完备就够了吗? 不够。嵌套宏照样能写出 12 秒的表达式,要靠静态估算(配合列表、字符串的最大长度)和运行时成本上限两道闸门。
  5. 和同类比怎么样? 22 个实现在同一组场景里实测:CEL 和 expr 最接近,expr 更快,CEL 多了成本估算、部分求值和跨语言规范;Rego 是策略引擎,Cedar 专注授权,Starlark、Lua、JavaScript 是脚本,CUE、Jsonnet 是配置语言。CEL 不是每项最好,但每项都不差。
  6. 在 Go 里怎么用? 环境声明具体类型;保存时编译、检查输出类型、估算成本,缓存 Program;请求时带超时和成本上限,出错就拒绝;自定义函数保持纯函数;扩展库写死版本;列表查询用部分求值。

参考资料

Footnotes

  1. Advanced CEL topicsBufbuf.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 ↩

  2. JsonLogicjsonlogic.comjsonlogic.com ↗规则本身是 JSON;文中用 Go 移植 diegoholiveira/jsonlogic v3.10.1。https://jsonlogic.com/ ↩

  3. CVE-2017-5638:Apache Struts Jakarta Multipart 解析器远程代码执行NVDnvd.nist.gov ↗攻击者在 Content-Type 头里放一段 OGNL 表达式即可执行任意命令,Equifax 2017 年的数据泄露就是从这里进去的。https://nvd.nist.gov/vuln/detail/CVE-2017-5638 ↩

  4. CVE-2022-22963:Spring Cloud Function 路由表达式 SpEL 注入NVDnvd.nist.gov ↗spring.cloud.function.routing-expression 请求头里的 SpEL 被直接求值,导致远程代码执行。https://nvd.nist.gov/vuln/detail/CVE-2022-22963 ↩

  5. cel-expr/cel-spec:Common Expression Language 规范GitHubgithub.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 ↩

  6. Google、オープンソースの高速で安全な式言語「Common Expression Language」を発表@IT · 2024-06-24atmarkit.itmedia.co.jp ↗2024 年 6 月 Google 开源博客专文介绍 CEL 的报道:速度、可移植、可扩展、安全四个特点,以及 Kubernetes、IAM 中的用途。https://atmarkit.itmedia.co.jp/ait/articles/2406/24/news056.html ↩

  7. cel.devGooglecel.dev ↗官方站点:Speed、Portability、Safety(Non-Turing complete, only accesses data provided by the host application)、Verifiability。https://cel.dev/ ↩

  8. KEP-2876:CRD Validation Expression LanguageGitHubgithub.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 ↩

  9. KEP-3488:CEL for Admission Control(ValidatingAdmissionPolicy)GitHubgithub.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 ↩

  10. KEP-3962:Mutating Admission PoliciesGitHubgithub.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 ↩

  11. Common Expression Language in KubernetesKuberneteskubernetes.io ↗Kubernetes 里 CEL 的用途、各扩展库开始支持的版本、运行时成本预算和写入时的估算成本检查。https://kubernetes.io/docs/reference/using-api/cel/ ↩ ↩2 ↩3

  12. cel-expr/cel-goGitHubgithub.com ↗Go 实现,文中用 v0.32.0。2026-06-16 仓库迁到 cel-expr 组织,模块路径为 cel.dev/cel-go。https://github.com/cel-expr/cel-go ↩

  13. CEL Language DefinitionGitHubgithub.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

  14. kubernetes:staging/src/k8s.io/apiserver/pkg/apis/cel/config.goGitHubgithub.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 ↩

  15. google/starlark-goGitHubgithub.com ↗Bazel 配置语言的 Go 实现;默认不允许 while 和递归,Thread.SetMaxExecutionSteps 限制执行步数。https://github.com/google/starlark-go ↩

  16. Spring Framework:SpEL Evaluation ContextSpringdocs.spring.io ↗SimpleEvaluationContext 只支持 SpEL 的一个子集,排除了类型引用、构造器和 bean 引用,用于不可信表达式。https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html ↩

  17. Open Policy Agent:Policy Language(Rego)OPAopenpolicyagent.org ↗文中用 OPA v1.21.3 的 Go SDK。部分求值(Partial Evaluation)和 JSON Schema 类型检查分别见 docs 的 Partial Evaluation、Schemas 两节。https://www.openpolicyagent.org/docs/policy-language ↩

  18. expr-lang/exprGitHubgithub.com ↗Go 表达式语言,文中用 v1.17.8。expr.Env 传结构体时做静态类型检查;默认内存预算 conf.DefaultMemoryBudget = 1e6。https://github.com/expr-lang/expr ↩

  19. Cedar Policy LanguageAWScedarpolicy.com ↗AWS 2023 年开源的授权策略语言,Amazon Verified Permissions 使用;文中用 cedar-go v1.8.0。https://www.cedarpolicy.com/ ↩

延伸阅读