﻿---
title: "CEL 表达式语言：为什么出现，和同类语言比怎么样，该怎么用"
description: "Kubernetes 的 CRD 里有一行 self.minReplicas <= self.replicas，Google Cloud 的 IAM 条件、Envoy 的 RBAC 里也是同一种写法。它叫 CEL。我们从一个普通需求出发：让用户在平台里自己写放行规则。嵌入脚本语言会碰到能调用什么、会不会停、写错了何时知道、每次多慢四个问题；CEL 用非图灵完备、类型检查和成本估算回答它们。再把 CEL 和 expr、Rego、Cedar、JsonLogic、Starlark、Lua、SpEL 等 22 个同类实现放进同一组场景实测，最后整理出在 Go 里用好 CEL 的做法。"
pubDate: 2026-10-22
tags: ["Go", "CEL", "Kubernetes", "策略", "工具"]
hero: "./hero.svg"
refs:
  - id: cel-spec
    title: "cel-expr/cel-spec：Common Expression Language 规范"
    url: https://github.com/cel-expr/cel-spec
    site: GitHub
    note: "README 的设计目标：线性时间、无副作用、非图灵完备；“The initial spec was based on the experience of developing Firebase Rules”。第一个提交是 2017-08-14 的 “Initial import from go/api-expr”。"
  - id: cel-langdef
    title: "CEL Language Definition"
    url: https://github.com/cel-expr/cel-spec/blob/master/doc/langdef.md
    site: GitHub
    note: "memory-safe、side-effect-free、terminating、gradually-typed；`&&` / `||` 可交换并吸收错误；Performance Limits 一节：除 `has()` 外的宏是唯一可能出现指数代价的地方。"
  - id: cel-go
    title: "cel-expr/cel-go"
    url: https://github.com/cel-expr/cel-go
    site: GitHub
    note: "Go 实现，文中用 v0.32.0。2026-06-16 仓库迁到 cel-expr 组织，模块路径为 `cel.dev/cel-go`。"
  - id: cel-dev
    title: "cel.dev"
    url: https://cel.dev/
    site: Google
    note: "官方站点：Speed、Portability、Safety（Non-Turing complete, only accesses data provided by the host application）、Verifiability。"
  - id: itmedia
    title: "Google、オープンソースの高速で安全な式言語「Common Expression Language」を発表"
    url: https://atmarkit.itmedia.co.jp/ait/articles/2406/24/news056.html
    site: "@IT"
    date: 2024-06-24
    note: "2024 年 6 月 Google 开源博客专文介绍 CEL 的报道：速度、可移植、可扩展、安全四个特点，以及 Kubernetes、IAM 中的用途。"
  - id: buf-cel
    title: "Advanced CEL topics"
    url: https://buf.build/docs/protovalidate/cel
    site: Buf
    note: "“If you've written expressions for conditional IAM, its Secure Web Proxy, or Firebase security rules, you've used CEL.” protovalidate 用 CEL 写字段约束。"
  - id: k8s-cel
    title: "Common Expression Language in Kubernetes"
    url: https://kubernetes.io/docs/reference/using-api/cel/
    site: Kubernetes
    note: "Kubernetes 里 CEL 的用途、各扩展库开始支持的版本、运行时成本预算和写入时的估算成本检查。"
  - id: k8s-cel-config
    title: "kubernetes：staging/src/k8s.io/apiserver/pkg/apis/cel/config.go"
    url: https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go
    site: GitHub
    note: "`PerCallLimit = 1000000`（约 0.1 秒）、`RuntimeCELCostBudget = 10000000`（每个 CR 或 VAP binding，约 1 秒）、`CheckFrequency = 100`。"
  - id: kep-2876
    title: "KEP-2876：CRD Validation Expression Language"
    url: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/2876-crd-validation-expression-language
    site: GitHub
    note: "alpha 1.23，beta 1.25，stable 1.29。"
  - id: kep-3488
    title: "KEP-3488：CEL for Admission Control（ValidatingAdmissionPolicy）"
    url: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3488-cel-admission-control
    site: GitHub
    note: "alpha 1.26，beta 1.28，stable 1.30。"
  - id: kep-3962
    title: "KEP-3962：Mutating Admission Policies"
    url: https://github.com/kubernetes/enhancements/tree/master/keps/sig-api-machinery/3962-mutating-admission-policies
    site: GitHub
    note: "alpha 1.32，beta 1.34，stable 1.36。"
  - id: cve-struts
    title: "CVE-2017-5638：Apache Struts Jakarta Multipart 解析器远程代码执行"
    url: https://nvd.nist.gov/vuln/detail/CVE-2017-5638
    site: NVD
    note: "攻击者在 Content-Type 头里放一段 OGNL 表达式即可执行任意命令，Equifax 2017 年的数据泄露就是从这里进去的。"
  - id: cve-spel
    title: "CVE-2022-22963：Spring Cloud Function 路由表达式 SpEL 注入"
    url: https://nvd.nist.gov/vuln/detail/CVE-2022-22963
    site: NVD
    note: "`spring.cloud.function.routing-expression` 请求头里的 SpEL 被直接求值，导致远程代码执行。"
  - id: spel-simple
    title: "Spring Framework：SpEL Evaluation Context"
    url: https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html
    site: Spring
    note: "`SimpleEvaluationContext` 只支持 SpEL 的一个子集，排除了类型引用、构造器和 bean 引用，用于不可信表达式。"
  - id: expr
    title: "expr-lang/expr"
    url: https://github.com/expr-lang/expr
    site: GitHub
    note: "Go 表达式语言，文中用 v1.17.8。`expr.Env` 传结构体时做静态类型检查；默认内存预算 `conf.DefaultMemoryBudget = 1e6`。"
  - id: opa
    title: "Open Policy Agent：Policy Language（Rego）"
    url: https://www.openpolicyagent.org/docs/policy-language
    site: OPA
    note: "文中用 OPA v1.21.3 的 Go SDK。部分求值（Partial Evaluation）和 JSON Schema 类型检查分别见 docs 的 Partial Evaluation、Schemas 两节。"
  - id: cedar
    title: "Cedar Policy Language"
    url: https://www.cedarpolicy.com/
    site: AWS
    note: "AWS 2023 年开源的授权策略语言，Amazon Verified Permissions 使用；文中用 cedar-go v1.8.0。"
  - id: starlark
    title: "google/starlark-go"
    url: https://github.com/google/starlark-go
    site: GitHub
    note: "Bazel 配置语言的 Go 实现；默认不允许 while 和递归，`Thread.SetMaxExecutionSteps` 限制执行步数。"
  - id: jsonlogic
    title: "JsonLogic"
    url: https://jsonlogic.com/
    site: jsonlogic.com
    note: "规则本身是 JSON；文中用 Go 移植 diegoholiveira/jsonlogic v3.10.1。"
  - id: others-go
    title: "其余参加实测的 Go 实现"
    url: https://pkg.go.dev/
    site: pkg.go.dev
    further: true
    note: "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。"
  - id: others-java
    title: "参加实测的 Java 实现"
    url: https://central.sonatype.com/
    site: Maven Central
    further: true
    note: "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。"
---

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 里[^buf-cel]。它叫 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 描述，社区里有现成的格式 JsonLogic[^jsonlogic]：

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 396" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celf1-title celf1-desc">
<title id="celf1-title">嵌入脚本语言后，三条规则在平台进程里的结果</title>
<desc id="celf1-desc">左边是用户填写的三条规则：SpEL 的 T(java.lang.System).getProperty('user.name')；Lua 的 while true do end；Lua 的 user.rol == "admin"。右边是平台进程里的结果：第一条返回 "root"，读到了宿主 JVM 的系统属性；第二条 8 秒内没有返回；第三条字段写错，返回 false 且没有任何报错，管理员也被拒绝。</desc>
<defs><marker id="celf1-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker></defs>
<rect class="dd-frame" width="800" height="396" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="392" height="316" rx="8"/>
<rect class="dd-zone" x="424" y="16" width="360" height="316" rx="8"/>
<text class="t-zone" x="32" y="44">用户在平台里填写的规则</text>
<text class="t-zone" x="440" y="44">平台进程里求值的结果</text>
<line class="dd-edge-acc" x1="392" y1="102" x2="438" y2="102" marker-end="url(#celf1-arrow)"/>
<line class="dd-edge-acc" x1="392" y1="186" x2="438" y2="186" marker-end="url(#celf1-arrow)"/>
<line class="dd-edge-acc" x1="392" y1="270" x2="438" y2="270" marker-end="url(#celf1-arrow)"/>
<rect class="dd-card" x="32" y="64" width="360" height="72" rx="6"/>
<path class="dd-card-head" d="M38 64 H386 Q392 64 392 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">SpEL 7.0.9</text>
<text class="t-note" x="376" y="85" text-anchor="end">能调用什么</text>
<text class="t-mono" x="48" y="121">T(java.lang.System).getProperty('user.name')</text>
<rect class="dd-card" x="32" y="148" width="360" height="72" rx="6"/>
<path class="dd-card-head" d="M38 148 H386 Q392 148 392 154 V180 H32 V154 Q32 148 38 148 Z"/>
<text class="t-name" x="48" y="169">Lua（gopher-lua 1.1.2）</text>
<text class="t-note" x="376" y="169" text-anchor="end">会不会停</text>
<text class="t-mono" x="48" y="205">while true do end</text>
<rect class="dd-card" x="32" y="232" width="360" height="72" rx="6"/>
<path class="dd-card-head" d="M38 232 H386 Q392 232 392 238 V264 H32 V238 Q32 232 38 232 Z"/>
<text class="t-name" x="48" y="253">Lua（gopher-lua 1.1.2）</text>
<text class="t-note" x="376" y="253" text-anchor="end">写错了何时知道</text>
<text class="t-mono" x="48" y="289">user.rol == "admin" or …</text>
<rect class="dd-card" x="440" y="64" width="328" height="72" rx="6"/>
<path class="dd-card-head" d="M446 64 H762 Q768 64 768 70 V96 H440 V70 Q440 64 446 64 Z"/>
<text class="t-name" x="456" y="85">返回值</text>
<text class="t-note" x="752" y="85" text-anchor="end">宿主 JVM 的用户名</text>
<text class="t-mono t-acc" x="456" y="121">"root"</text>
<rect class="dd-card" x="440" y="148" width="328" height="72" rx="6"/>
<path class="dd-card-head" d="M446 148 H762 Q768 148 768 154 V180 H440 V154 Q440 148 446 148 Z"/>
<text class="t-name" x="456" y="169">返回值</text>
<text class="t-note" x="752" y="169" text-anchor="end">最后被 timeout 杀掉</text>
<text class="t-mono t-acc" x="456" y="205">8 秒内没有返回</text>
<rect class="dd-card" x="440" y="232" width="328" height="72" rx="6"/>
<path class="dd-card-head" d="M446 232 H762 Q768 232 768 238 V264 H440 V238 Q440 232 446 232 Z"/>
<text class="t-name" x="456" y="253">返回值（管理员请求）</text>
<text class="t-note" x="752" y="253" text-anchor="end">没有任何报错</text>
<text class="t-mono t-acc" x="456" y="289">false</text>
<text class="t-say" x="16" y="360">三条规则都能顺利保存，问题全部出现在求值的时候。</text>
<text class="t-say" x="16" y="382">后两条的问题，平台进程自己察觉不到：一条占着线程不放，一条悄悄给出错误答案。</text>
</svg>
</div>
<figcaption>图 1 · 嵌入一门完整的脚本语言，规则能做的事远远超出了“判断真假”</figcaption>
</figure>

第一条最危险。规则本来只该看到 `user`、`resource`、`hour` 三个变量，SpEL 却允许用 `T(...)` 引用任意 Java 类，读系统属性只是演示，换成 `Runtime.exec` 就是远程执行命令。这不是假想：2017 年 Equifax 的数据泄露，入口是 Struts 把 HTTP 头里的一段 OGNL 表达式拿去求值[^cve-struts]；2022 年 Spring Cloud Function 的 CVE-2022-22963，是把请求头里的 SpEL 拿去求值[^cve-spel]。表达式语言能力太强，本身就是攻击面。

第二条说明，只要语言里有循环，用户就可能写出停不下来的程序。第三条说明，脚本语言通常到运行时才去找字段，找不到就给一个 `nil`，`nil == "admin"` 是 `false`，规则安静地给出了错误答案。

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

### 问题归纳

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

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

**这几条单独看都不难，难的是同时做到。CEL 就是为这个组合设计的。**

## 二、CEL 的出现

### 来历

CEL 的规范仓库 cel-spec 第一个提交是 2017 年 8 月 14 日，提交说明是 “Initial import from go/api-expr”，也就是从 Google 内部的一个表达式项目导出来的[^cel-spec]。README 里写得很直白：最初的规范基于开发 Firebase Rules 的经验，又做了很多轮可用性测试。Firebase 的安全规则正是“让用户写规则、由服务端在每个请求上求值”的典型场景，上面那几个问题 Google 早就踩过。

此后 CEL 成了 Google 内外很多系统共用的条件语言。2024 年 6 月，Google 在开源博客上专门介绍了它，概括为快、可移植、可扩展、安全四点[^itmedia]；官方站点 cel.dev 的说法是“非图灵完备，只能访问宿主程序提供的数据”[^cel-dev]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 392" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celf2-title celf2-desc">
<title id="celf2-title">CEL 的时间线和采用者</title>
<desc id="celf2-desc">左边是时间线：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 策略。</desc>
<rect class="dd-frame" width="800" height="392" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="488" height="328" rx="8"/>
<rect class="dd-zone" x="520" y="16" width="264" height="328" rx="8"/>
<text class="t-zone" x="32" y="44">时间线</text>
<text class="t-zone" x="536" y="44">其他采用者（不分先后）</text>
<line class="dd-edge" x1="124" y1="76" x2="124" y2="320"/>
<text class="t-mono" x="32" y="80">2017-08</text><circle class="dd-dot" cx="124" cy="76" r="5"/><text class="t-name" x="144" y="81">cel-spec 首次提交：从 go/api-expr 导入</text>
<text class="t-mono" x="32" y="115">2018-03</text><circle class="dd-dot" cx="124" cy="111" r="5"/><text class="t-name" x="144" y="116">cel-go 公开，随后有 C++、Java 实现</text>
<text class="t-mono" x="32" y="150">2021-12</text><circle class="dd-fill-acc" cx="124" cy="146" r="5"/><text class="t-name" x="144" y="151"><tspan class="t-acc">K8s 1.23</tspan>：CRD 校验规则（alpha）</text>
<text class="t-mono" x="32" y="185">2023-12</text><circle class="dd-fill-acc" cx="124" cy="181" r="5"/><text class="t-name" x="144" y="186"><tspan class="t-acc">K8s 1.29</tspan>：CRD 校验规则 GA</text>
<text class="t-mono" x="32" y="220">2024-04</text><circle class="dd-fill-acc" cx="124" cy="216" r="5"/><text class="t-name" x="144" y="221"><tspan class="t-acc">K8s 1.30</tspan>：ValidatingAdmissionPolicy GA</text>
<text class="t-mono" x="32" y="255">2024-06</text><circle class="dd-dot" cx="124" cy="251" r="5"/><text class="t-name" x="144" y="256">Google 开源博客专文介绍 CEL</text>
<text class="t-mono" x="32" y="290">2026</text><circle class="dd-fill-acc" cx="124" cy="286" r="5"/><text class="t-name" x="144" y="291"><tspan class="t-acc">K8s 1.36</tspan>：MutatingAdmissionPolicy GA</text>
<text class="t-mono" x="32" y="325">2026-06</text><circle class="dd-dot" cx="124" cy="321" r="5"/><text class="t-name" x="144" y="326">仓库迁到 cel-expr，模块 cel.dev/cel-go</text>
<text class="t-name" x="536" y="81">Google Cloud IAM Conditions</text>
<text class="t-name" x="536" y="116">Firebase Security Rules</text>
<text class="t-name" x="536" y="151">Secure Web Proxy、CA Service</text>
<text class="t-name" x="536" y="186">Envoy RBAC 过滤器</text>
<text class="t-name" x="536" y="221">buf protovalidate</text>
<text class="t-name" x="536" y="256">Kyverno、Gatekeeper</text>
<text class="t-note" x="536" y="291">（后两者都支持用 CEL 写策略）</text>
<text class="t-say" x="16" y="370">Kubernetes 一路把 CEL 从 CRD 校验用到了准入控制：写进 API 对象、由 apiserver 求值的规则越来越多。</text>
</svg>
</div>
<figcaption>图 2 · CEL 从 Google 内部的条件语言，长成了云原生里写“判断”的通用格式</figcaption>
</figure>

Kubernetes 是最典型的例子。CRD 校验规则（KEP-2876）在 1.23 进入 alpha，1.29 GA[^kep-2876]；用 CEL 写准入策略的 ValidatingAdmissionPolicy 在 1.30 GA[^kep-3488]，能改对象的 MutatingAdmissionPolicy 在 1.36 GA[^kep-3962]。在这之前，想在准入阶段做点校验，就得自己写一个 webhook 服务、部署、配证书，再让 apiserver 每个请求都远程调用它。CEL 让这类规则变成 API 对象上的一个字段，由 apiserver 在进程内直接求值[^k8s-cel]。

还有两个跟 Go 用户直接相关的变化：2026 年 6 月仓库从 `google` 迁到了 `cel-expr` 组织；Go 模块路径改成了 `cel.dev/cel-go`，v0.32.0 起再写 `go get github.com/google/cel-go@latest` 会报模块路径不匹配[^cel-go]。

### 设计上舍弃了什么

CEL 的规范在开头就给自己下了定义：内存安全、无副作用、一定会终止、强类型，类型检查可选但推荐[^cel-langdef]。落实到语法上，就是一系列“没有”：

- **没有循环，没有赋值，没有函数定义。** 一个 CEL 程序只是一个表达式。想遍历列表，只能用几个内置宏：`all`、`exists`、`exists_one`、`map`、`filter`，它们遍历的对象一定是已经存在的、有限的列表或 map。
- **没有 I/O，也没有“引用任意类型”的语法。** 表达式能用的变量和函数，全部由宿主程序在环境（`Env`）里声明。
- **没有异常处理。** 运行时错误是一个值，只能被逻辑运算符吸收，不能被捕获（这一点后面会专门讲）。

保留下来的，是一套和 C、Go、Java、JavaScript 几乎一样的语法，以及对 protobuf 和 JSON 类型的一等支持。所以我们开头那条规则，用 CEL 写出来就是：

```js
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})
```

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 324" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celf3-title celf3-desc">
<title id="celf3-title">CEL 的三个阶段和各自的耗时</title>
<desc id="celf3-desc">左边“保存规则时”区域有两张卡片。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 微秒。</desc>
<defs><marker id="celf3-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker><marker id="celf3-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker></defs>
<rect class="dd-frame" width="800" height="324" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="504" height="244" rx="8"/>
<rect class="dd-zone" x="536" y="16" width="248" height="244" rx="8"/>
<text class="t-zone" x="32" y="44">保存规则时（每条规则一次）</text>
<text class="t-zone" x="552" y="44">每次请求（成千上万次）</text>
<line class="dd-edge" x1="252" y1="148" x2="282" y2="148" marker-end="url(#celf3-arrow)"/>
<line class="dd-edge-acc" x1="504" y1="148" x2="550" y2="148" marker-end="url(#celf3-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="220" height="170" rx="6"/>
<path class="dd-card-head" d="M38 64 H246 Q252 64 252 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">cel.NewEnv</text>
<text class="t-note" x="236" y="85" text-anchor="end">声明</text>
<text class="t-mono" x="48" y="117">user<tspan class="t-val" x="124">main.User</tspan></text>
<line class="dd-hair" x1="32" y1="126" x2="252" y2="126"/>
<text class="t-mono" x="48" y="143">resource<tspan class="t-val" x="124">main.Resource</tspan></text>
<line class="dd-hair" x1="32" y1="152" x2="252" y2="152"/>
<text class="t-mono" x="48" y="169">hour<tspan class="t-val" x="124">int</tspan></text>
<line class="dd-hair" x1="32" y1="178" x2="252" y2="178"/>
<text class="t-note" x="48" y="203">变量和函数只能来自这里</text>
<rect class="dd-card" x="284" y="64" width="220" height="170" rx="6"/>
<path class="dd-card-head" d="M290 64 H498 Q504 64 504 70 V96 H284 V70 Q284 64 290 64 Z"/>
<text class="t-name" x="300" y="85">env.Compile</text>
<text class="t-note" x="488" y="85" text-anchor="end">解析 + 检查</text>
<text class="t-mono t-acc" x="300" y="117">user.role == "admin" || …</text>
<text class="t-val" x="300" y="143">→ 输出类型 bool</text>
<line class="dd-hair" x1="284" y1="152" x2="504" y2="152"/>
<text class="t-mono" x="300" y="169">user.rol == "admin"</text>
<text class="t-val" x="300" y="195">→ undefined field 'rol'</text>
<line class="dd-hair" x1="284" y1="204" x2="504" y2="204"/>
<text class="t-note" x="300" y="225">约 100 µs</text>
<rect class="dd-card" x="552" y="64" width="216" height="170" rx="6"/>
<path class="dd-card-head" d="M558 64 H762 Q768 64 768 70 V96 H552 V70 Q552 64 558 64 Z"/>
<text class="t-name" x="568" y="85">prg.Eval</text>
<text class="t-note" x="752" y="85" text-anchor="end">缓存的 Program</text>
<text class="t-mono" x="568" y="117">user<tspan class="t-val" x="612">{role: "dev", …}</tspan></text>
<line class="dd-hair" x1="552" y1="126" x2="768" y2="126"/>
<text class="t-mono" x="568" y="143">hour<tspan class="t-val" x="612">23</tspan></text>
<line class="dd-hair" x1="552" y1="152" x2="768" y2="152"/>
<text class="t-mono t-acc" x="568" y="175">→ true</text>
<line class="dd-hair" x1="552" y1="204" x2="768" y2="204"/>
<text class="t-note" x="568" y="225">约 0.47 µs</text>
<text class="t-say" x="16" y="288">检查和求值分开：贵的那一步（编译）在保存规则时做一次，便宜的那一步（求值）在每个请求上做。</text>
<text class="t-say" x="16" y="310">写错的字段在保存时就被拦下，根本到不了请求路径。</text>
</svg>
</div>
<figcaption>图 3 · 编译比求值贵 200 倍，所以 CEL 把它们拆成两个阶段</figcaption>
</figure>

这个拆分有两层意思。第一层是性能：我们测得每次都重新编译约 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 点之间、自己名下的资源发请求：

```text
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`），另一边的错误就被忽略，跟左右顺序无关[^cel-langdef]。规范给的理由是，这样逻辑表达式可以映射到索引查询，和 SQL 的语义一致。想要传统的短路，就改写成三元表达式 `a ? true : b`。

所以 map 声明加上错误吸收，字段写错的规则可能在大部分请求上“看起来没问题”，只在某些分支上报错。**解决办法是把变量声明成具体类型**：用 `ext.NativeTypes` 注册 Go 结构体，或者直接用 protobuf 消息类型。确实需要处理“可能不存在的字段”时，用 `has(user.nickname)` 或可选值语法 `user.?nickname.orValue("")` 明确写出来，不要指望错误吸收替你兜底。

**类型检查解决了“写错了何时知道”。那“会不会停”呢？没有循环，是不是就万事大吉？**

## 四、会停，但不一定停得快

### 现象：一条“合法”的规则算了 12 秒

CEL 没有循环，但宏可以嵌套。一个 10 个元素的列表，嵌套 8 层 `all`：

```js
[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)))
```

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

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

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

于是有了两道闸门：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celf4-title celf4-desc">
<title id="celf4-title">CEL 的两道成本闸门</title>
<desc id="celf4-desc">左边是写入时的静态估算：表达式 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。</desc>
<rect class="dd-frame" width="800" height="340" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="264" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="264" rx="8"/>
<text class="t-zone" x="32" y="44">写入时：静态估算</text>
<text class="t-zone" x="424" y="44">求值时：运行时计数</text>
<rect class="dd-card" x="32" y="64" width="344" height="196" rx="6"/>
<path class="dd-card-head" d="M38 64 H370 Q376 64 376 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-mono" x="48" y="85">containers.all(c, c.image.matches(re))</text>
<text class="t-note" x="48" y="121">不声明最大长度</text>
<text class="t-mono" x="48" y="143">max = 18446744073709551615</text>
<text class="t-val" x="360" y="143" text-anchor="end">拒绝</text>
<line class="dd-hair" x1="32" y1="156" x2="376" y2="156"/>
<text class="t-note" x="48" y="181">maxItems: 20 · maxLength: 256</text>
<text class="t-mono t-acc" x="48" y="203">max = 4783</text>
<text class="t-val" x="360" y="203" text-anchor="end">允许</text>
<line class="dd-hair" x1="32" y1="216" x2="376" y2="216"/>
<text class="t-note" x="48" y="243">K8s 单条上限 PerCallLimit = 1,000,000</text>
<rect class="dd-card" x="424" y="64" width="344" height="196" rx="6"/>
<path class="dd-card-head" d="M430 64 H762 Q768 64 768 70 V96 H424 V70 Q424 64 430 64 Z"/>
<text class="t-name" x="440" y="85">8 层嵌套 all，10⁸ 次迭代</text>
<text class="t-note" x="440" y="117">静态估算</text>
<text class="t-mono" x="440" y="137">max = 455,555,551</text>
<line class="dd-hair" x1="424" y1="150" x2="768" y2="150"/>
<text class="t-note" x="440" y="171">不设限</text>
<text class="t-mono" x="440" y="191">12.6 s 后返回 true</text>
<line class="dd-hair" x1="424" y1="204" x2="768" y2="204"/>
<text class="t-note" x="440" y="225">cel.CostLimit(1_000_000)</text>
<text class="t-mono t-acc" x="440" y="245">97 ms 中止：cost limit exceeded</text>
<text class="t-say" x="16" y="308">静态估算挡住“可能很贵”的规则，运行时计数挡住“真的很贵”的那一次求值。</text>
<text class="t-say" x="16" y="330">列表和字符串没有上限，估算就只能给出 2⁶⁴ − 1，所以 K8s 要求在 schema 里写 maxItems、maxLength。</text>
</svg>
</div>
<figcaption>图 4 · 非图灵完备的真正好处：最坏代价可以在写入前算出来，求值时也能逐步计数</figcaption>
</figure>

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

**静态估算**更有意思。`env.EstimateCost` 不需要任何输入数据，只看语法树和类型声明。问题在于，对一个长度未知的列表，最坏情况就是无穷大：同一条检查镜像地址的规则，不告诉估算器列表多长、字符串多长，估出来是 $2^{64}-1$；声明最多 20 个容器、镜像名最长 256 个字符，估算立刻降到 4783。

Kubernetes 把这两道闸门都用上了。apiserver 的常量里写着：单条表达式 `PerCallLimit = 1000000`，注释说大约 0.1 秒；每个自定义资源或每个 ValidatingAdmissionPolicy binding 的总预算 `RuntimeCELCostBudget = 10000000`，大约 1 秒[^k8s-cel-config]。写 CRD 时，如果某条规则的估算成本超过上限，apiserver 直接拒绝这个 CRD，报错会提示你给相关字段加上 `maxItems`、`maxLength`。这就是为什么写 CRD 校验规则时，schema 里那些长度限制不是可有可无的。

**到这里，CEL 对开头四个问题都给出了答案。但 Go 生态里做表达式和规则的库远不止 CEL 一个，它们各自是怎么回答这几个问题的？**

## 五、和同类语言对比

### 参加对比的语言

我们把能嵌进程序里写“判断”的东西尽量列全，按作者主要用的 Go 生态排主次，Java 生态单独一组。凡是有 Go 实现的都装进同一个程序里，用同一条放行规则、同一组输入实测；Java 的几个用 JDK 21 单独测。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="celt1-title">参加对比的 22 个实现</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="celt1-title">
<colgroup><col style="width:20%"><col style="width:30%"><col style="width:22%"><col></colgroup>
<thead>
<tr><th scope="col">语言</th><th scope="col">出处 · 实测版本</th><th scope="col">定位</th><th scope="col">能不能写循环</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="4" scope="rowgroup">表达式语言（一个表达式，算出一个值）</th></tr>
<tr><th scope="row" class="acc">CEL</th><td>Google，2017；cel-go v0.32.0，另有 C++、Java</td><td>通用条件语言</td><td>只有 <span class="mono">all / exists / map / filter</span> 宏</td></tr>
<tr><th scope="row">expr</th><td>expr-lang；v1.17.8</td><td>Go 表达式语言</td><td>内置 <span class="mono">all / map / filter</span>，有区间 <span class="mono">1..n</span></td></tr>
<tr><th scope="row">govaluate</th><td>Knetic；v3.0.0，已多年不更新</td><td>Go 表达式</td><td>不能</td></tr>
<tr><th scope="row">gval</th><td>PaesslerAG；v1.2.4</td><td>可扩展的 Go 表达式</td><td>不能</td></tr>
<tr><th scope="row">JsonLogic</th><td>Jeremy Wadhams；Go 移植 v3.10.1</td><td>规则即 JSON</td><td><span class="mono">all / map / reduce</span></td></tr>
<tr><th scope="row">JMESPath</th><td>AWS CLI 的查询语言；go-jmespath v0.4.0</td><td>JSON 查询</td><td>只能投影输入</td></tr>
<tr><th scope="row">jq</th><td>Stephen Dolan，2012；gojq v0.12.19</td><td>JSON 处理</td><td>能（递归）</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">策略语言</th></tr>
<tr><th scope="row">Rego</th><td>OPA，CNCF 毕业项目；v1.21.3</td><td>Datalog 风格的策略语言</td><td>推导式，禁止递归</td></tr>
<tr><th scope="row">Cedar</th><td>AWS，2023 年开源；cedar-go v1.8.0</td><td>授权策略</td><td>不能</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">配置语言</th></tr>
<tr><th scope="row">CUE</th><td>Marcel van Lohuizen；v0.17.1</td><td>配置 + 校验</td><td>推导式</td></tr>
<tr><th scope="row">Jsonnet</th><td>Google，2014；go-jsonnet</td><td>配置生成</td><td>能（递归）</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">嵌入式脚本</th></tr>
<tr><th scope="row">Starlark</th><td>Bazel 的 Python 方言；starlark-go</td><td>确定性脚本</td><td>for 能；while、递归默认禁止</td></tr>
<tr><th scope="row">Lua</th><td>gopher-lua v1.1.2</td><td>通用脚本</td><td>能</td></tr>
<tr><th scope="row">JavaScript</th><td>goja</td><td>通用脚本</td><td>能</td></tr>
<tr><th scope="row">Tengo</th><td>d5/tengo v2.17.0</td><td>Go 原生脚本</td><td>能</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">Java 生态（单独实测）</th></tr>
<tr><th scope="row">SpEL</th><td>Spring 7.0.9</td><td>Spring 的表达式语言</td><td>不能，但能调用任意方法</td></tr>
<tr><th scope="row">MVEL</th><td>2.6.0.Final</td><td>表达式 + 脚本</td><td>能</td></tr>
<tr><th scope="row">Aviator</th><td>5.4.4</td><td>表达式 + 脚本</td><td>能</td></tr>
<tr><th scope="row">JEXL</th><td>Apache Commons 3.7.1</td><td>表达式 + 脚本</td><td>能</td></tr>
<tr><th scope="row">CEL-Java</th><td>dev.cel 0.14.0</td><td>CEL 的 Java 实现</td><td>同 CEL</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">expr 和 cel-go 各测了两种输入：<span class="mono">map[string]any</span> 和 Go 结构体，所以 Go 侧一共 17 个配置。没有实测的：HashiCorp Sentinel（商业产品）、Apple 的 Pkl、Dhall、Go 解释器 Yaegi、OGNL（它的问题见第一节的 Struts 漏洞）。</p>
</div>
<figcaption>表 1 · 同类工具：从只能写一个表达式，到完整的脚本语言</figcaption>
</figure>

### 场景一：同一条规则怎么写

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

```js
// CEL、expr、gval：三者完全一样
user.role == "admin" || (resource.owner == user.id && hour >= 9 && hour < 18) || resource.team in user.groups
```

```text
# 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
```

```text
// 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"`，看谁在什么时候发现：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="celt2-title">字段写错、类型写错，什么时候发现</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="celt2-title">
<colgroup><col style="width:26%"><col><col></colgroup>
<thead>
<tr><th scope="col">引擎</th><th scope="col"><span class="mono">user.rol</span>（字段写错）</th><th scope="col"><span class="mono">hour &gt;= "9"</span>（类型写错）</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="acc">CEL（Go 结构体）</th><td class="good">编译期：<span class="mono">undefined field 'rol'</span></td><td class="good" rowspan="2">编译期：<span class="mono">no matching overload for '_&gt;=_' applied to '(int, string)'</span></td></tr>
<tr><th scope="row" class="acc">CEL（map / dyn）</th><td class="warn">运行时：<span class="mono">no such key</span>；另一边为 true 时被吸收</td></tr>
<tr><th scope="row">expr（Go 结构体）</th><td class="good">编译期：<span class="mono">type main.User has no field Rol</span></td><td class="good" rowspan="2">编译期：<span class="mono">mismatched types int and string</span></td></tr>
<tr><th scope="row">expr（map）</th><td class="bad">静默返回 false</td></tr>
<tr><th scope="row">Rego + JSON Schema</th><td class="good">编译期：<span class="mono">undefined ref: input.user.rol</span></td><td class="bad">静默返回 false</td></tr>
<tr><th scope="row">Rego（无 schema）</th><td class="bad">静默返回 false</td><td class="bad">静默返回 false</td></tr>
<tr><th scope="row">Cedar</th><td class="warn">运行时：策略报错，不生效</td><td class="warn">运行时：<span class="mono">type error</span></td></tr>
<tr><th scope="row">govaluate、Starlark、CUE、Jsonnet</th><td class="warn">运行时报错</td><td class="warn">运行时报错，只在走到这一支时</td></tr>
<tr><th scope="row">Lua、Tengo</th><td class="bad">静默返回 false</td><td class="warn">运行时报错，只在走到这一支时</td></tr>
<tr><th scope="row">gval、JsonLogic、JavaScript</th><td class="bad">静默返回 false</td><td class="bad">静默转换，<span class="mono">10 &gt;= "9"</span> 为 true</td></tr>
<tr><th scope="row">jq、JMESPath</th><td class="bad">静默返回 false</td><td class="bad">静默返回 false</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“运行时报错，只在走到这一支时”：管理员的请求在第一个 <span class="mono">||</span> 就决定了结果，这些引擎对它返回 true，不报错；只有走到 <span class="mono">hour</span> 比较的请求才报错。</p>
</div>
<figcaption>表 2 · 能在保存规则时就发现错误的，只有声明了类型的 CEL、expr 和加了 schema 的 Rego</figcaption>
</figure>

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

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

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="celt3-title">失控表达式：不设限会怎样，自带的手段能不能停住</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="celt3-title">
<colgroup><col style="width:17%"><col style="width:33%"><col style="width:20%"><col></colgroup>
<thead>
<tr><th scope="col">引擎</th><th scope="col">最坏写法</th><th scope="col">不设限</th><th scope="col">自带的限制</th></tr>
</thead>
<tbody>
<tr><th scope="row" class="acc">CEL</th><td>8 层嵌套 <span class="mono">all</span>，10⁸ 次</td><td>12.6 s 后返回</td><td class="good">事先估算 4.6 亿；<span class="mono">CostLimit</span> 97 ms 中止</td></tr>
<tr><th scope="row">expr</th><td>同上，用区间 <span class="mono">0..9</span></td><td colspan="2" class="good">108 ms 中止：默认内存预算 <span class="mono">memory budget exceeded</span></td></tr>
<tr><th scope="row">expr</th><td>同上，用字面量列表</td><td>31.7 s</td><td class="bad">没有 CPU 或步数限制</td></tr>
<tr><th scope="row">JsonLogic</th><td>8 层嵌套 <span class="mono">all</span></td><td>15.4 s</td><td class="bad">没有</td></tr>
<tr><th scope="row">Starlark</th><td><span class="mono">for i in range(10**9)</span></td><td>40.4 s</td><td class="good"><span class="mono">SetMaxExecutionSteps</span> 45 ms 中止</td></tr>
<tr><th scope="row">Rego</th><td>两层 <span class="mono">numbers.range(1, 10000)</span> 推导式</td><td class="bad">8 s 未返回</td><td class="good">context 超时，1 s 中止</td></tr>
<tr><th scope="row">Lua</th><td><span class="mono">while true do end</span></td><td class="bad">8 s 未返回</td><td class="good"><span class="mono">SetContext</span> 超时，1 s 中止</td></tr>
<tr><th scope="row">JavaScript</th><td><span class="mono">for (;;) {}</span></td><td class="bad">8 s 未返回</td><td class="good"><span class="mono">Interrupt</span>，1 s 中止</td></tr>
<tr><th scope="row">Tengo</th><td><span class="mono">for {}</span></td><td class="bad">8 s 未返回</td><td class="good"><span class="mono">RunContext</span>，1 s 中止</td></tr>
<tr><th scope="row">jq</th><td><span class="mono">def f: f; f</span></td><td class="bad">8 s 未返回</td><td class="good"><span class="mono">RunWithContext</span>，1 s 中止</td></tr>
<tr><th scope="row">CUE</th><td>两层 <span class="mono">list.Range(0, 10000, 1)</span> 推导式</td><td class="bad">8 s 未返回</td><td class="bad">没有</td></tr>
<tr><th scope="row">Jsonnet</th><td><span class="mono">std.foldl</span> 遍历 10⁸ 个元素</td><td class="bad">8 s 未返回</td><td class="bad">没有，只有栈深度限制</td></tr>
<tr><th scope="row">govaluate、gval、JMESPath、Cedar</th><td colspan="3">写不出循环或嵌套迭代，代价只和表达式长度、输入大小成正比</td></tr>
</tbody>
</table>
</div>
</div>
<figcaption>表 3 · 大部分引擎都能停住，但只有 CEL 能在执行之前就说出最坏要算多久</figcaption>
</figure>

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

### 场景四：Java 里的沙箱

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="celt4-title">Java 表达式引擎：能不能碰到宿主</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="celt4-title">
<colgroup><col style="width:28%"><col><col style="width:30%"></colgroup>
<thead>
<tr><th scope="col">引擎</th><th scope="col">读 <span class="mono">System.getProperty("user.name")</span></th><th scope="col">死循环或阻塞</th></tr>
</thead>
<tbody>
<tr><th scope="row">SpEL（StandardEvaluationContext）</th><td class="bad"><span class="mono">"root"</span></td><td class="bad"><span class="mono">Thread.sleep</span>：3 s 后仍在跑</td></tr>
<tr><th scope="row">SpEL（SimpleEvaluationContext）</th><td class="good">拦截：<span class="mono">Type cannot be found</span></td><td>—</td></tr>
<tr><th scope="row">MVEL</th><td class="bad"><span class="mono">"root"</span></td><td class="bad"><span class="mono">while (true)</span>：3 s 后仍在跑</td></tr>
<tr><th scope="row">Aviator</th><td class="bad"><span class="mono">"root"</span></td><td class="bad"><span class="mono">while true</span>：3 s 后仍在跑</td></tr>
<tr><th scope="row">JEXL（默认权限）</th><td class="good">拦截：<span class="mono">undefined property 'class'</span></td><td class="bad"><span class="mono">while (true)</span>：3 s 后仍在跑</td></tr>
<tr><th scope="row">JEXL（UNRESTRICTED）</th><td class="bad"><span class="mono">"root"</span></td><td>—</td></tr>
<tr><th scope="row" class="acc">CEL-Java</th><td class="good">编译期：<span class="mono">undeclared reference to 'java'</span></td><td class="good">写不出来</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">SpEL 的 SimpleEvaluationContext 是 Spring 官方给不可信表达式准备的受限模式，代价是方法调用也受限：同一条规则里的 <span class="mono">List.contains</span> 就因为找不到方法而报错。</p>
</div>
<figcaption>表 4 · 在 Java 里，“安全”通常是一个要记得打开的选项；在 CEL 里它是语言本身</figcaption>
</figure>

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

### 场景五：每次求值的耗时

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 544" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celp-title celp-desc">
<title id="celp-title">同一条规则在 17 个 Go 引擎里每次求值的耗时</title>
<desc id="celp-desc">对数坐标的横向条形图。最快的是 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 微秒。</desc>
<rect class="dd-frame" width="800" height="544" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="512" rx="8"/>
<text class="t-zone" x="32" y="44">每次求值耗时（ns/op，对数坐标，已编译、输入已备好）</text>
<line class="dd-hair" x1="208.0" y1="60" x2="208.0" y2="476"/>
<text class="t-note" x="208.0" y="494" text-anchor="middle">100 ns</text>
<line class="dd-hair" x1="372.0" y1="60" x2="372.0" y2="476"/>
<text class="t-note" x="372.0" y="494" text-anchor="middle">1 µs</text>
<line class="dd-hair" x1="536.0" y1="60" x2="536.0" y2="476"/>
<text class="t-note" x="536.0" y="494" text-anchor="middle">10 µs</text>
<line class="dd-hair" x1="700.0" y1="60" x2="700.0" y2="476"/>
<text class="t-note" x="700.0" y="494" text-anchor="middle">100 µs</text>
<text class="t-name" x="196" y="80" text-anchor="end">expr（Go struct）</text>
<rect class="dd-dot" x="208" y="69" width="34.8" height="14" rx="2"/>
<text class="t-mono" x="250.8" y="80">163 ns</text>
<text class="t-name" x="196" y="104" text-anchor="end">govaluate</text>
<rect class="dd-dot" x="208" y="93" width="67.0" height="14" rx="2"/>
<text class="t-mono" x="283.0" y="104">256 ns</text>
<text class="t-name" x="196" y="128" text-anchor="end">JMESPath</text>
<rect class="dd-dot" x="208" y="117" width="74.3" height="14" rx="2"/>
<text class="t-mono" x="290.3" y="128">284 ns</text>
<text class="t-name" x="196" y="152" text-anchor="end">Starlark</text>
<rect class="dd-dot" x="208" y="141" width="86.1" height="14" rx="2"/>
<text class="t-mono" x="302.1" y="152">335 ns</text>
<text class="t-name" x="196" y="176" text-anchor="end">expr（map）</text>
<rect class="dd-dot" x="208" y="165" width="104.7" height="14" rx="2"/>
<text class="t-mono" x="320.7" y="176">435 ns</text>
<text class="t-name t-acc" x="196" y="200" text-anchor="end">CEL（map）</text>
<rect class="dd-fill-acc" x="208" y="189" width="118.5" height="14" rx="2"/>
<text class="t-mono t-acc" x="334.5" y="200">528 ns</text>
<text class="t-name t-acc" x="196" y="224" text-anchor="end">CEL（Go struct）</text>
<rect class="dd-fill-acc" x="208" y="213" width="123.7" height="14" rx="2"/>
<text class="t-mono t-acc" x="339.7" y="224">568 ns</text>
<text class="t-name" x="196" y="248" text-anchor="end">Cedar</text>
<rect class="dd-dot" x="208" y="237" width="126.5" height="14" rx="2"/>
<text class="t-mono" x="342.5" y="248">591 ns</text>
<text class="t-name" x="196" y="272" text-anchor="end">JavaScript（goja）</text>
<rect class="dd-dot" x="208" y="261" width="176.4" height="14" rx="2"/>
<text class="t-mono" x="392.4" y="272">1.2 µs</text>
<text class="t-name" x="196" y="296" text-anchor="end">gval</text>
<rect class="dd-dot" x="208" y="285" width="191.5" height="14" rx="2"/>
<text class="t-mono" x="407.5" y="296">1.5 µs</text>
<text class="t-name" x="196" y="320" text-anchor="end">JsonLogic</text>
<rect class="dd-dot" x="208" y="309" width="215.1" height="14" rx="2"/>
<text class="t-mono" x="431.1" y="320">2.0 µs</text>
<text class="t-name" x="196" y="344" text-anchor="end">jq（gojq）</text>
<rect class="dd-dot" x="208" y="333" width="260.4" height="14" rx="2"/>
<text class="t-mono" x="476.4" y="344">3.9 µs</text>
<text class="t-name" x="196" y="368" text-anchor="end">Lua（gopher-lua）</text>
<rect class="dd-dot" x="208" y="357" width="294.8" height="14" rx="2"/>
<text class="t-mono" x="510.8" y="368">6.3 µs</text>
<text class="t-name" x="196" y="392" text-anchor="end">Rego（OPA）</text>
<rect class="dd-dot" x="208" y="381" width="334.4" height="14" rx="2"/>
<text class="t-mono" x="550.4" y="392">10.9 µs</text>
<text class="t-name" x="196" y="416" text-anchor="end">Tengo</text>
<rect class="dd-dot" x="208" y="405" width="375.6" height="14" rx="2"/>
<text class="t-mono" x="591.6" y="416">19.5 µs</text>
<text class="t-name" x="196" y="440" text-anchor="end">CUE</text>
<rect class="dd-dot" x="208" y="429" width="445.4" height="14" rx="2"/>
<text class="t-mono" x="661.4" y="440">52.0 µs</text>
<text class="t-name" x="196" y="464" text-anchor="end">Jsonnet</text>
<rect class="dd-dot" x="208" y="453" width="486.7" height="14" rx="2"/>
<text class="t-mono" x="702.7" y="464">92.8 µs</text>
<text class="t-say" x="32" y="514">Lua 的耗时里包含每次把输入建成 Lua 表；Rego、CUE、Jsonnet 每次都要走完整的求值管线。</text>
</svg>
</div>
<figcaption>图 5 · 表达式类引擎都在 1 µs 以内，CEL 约 0.5 µs；策略、配置语言慢一到两个数量级</figcaption>
</figure>

表达式类引擎都在 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"])` 两条查询[^opa]，OPA 社区也一直用它把策略编译成 SQL 过滤条件。

### 对照

把六个场景放在一起：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="celt5-title">主要候选在六个场景里的表现</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="celt5-title">
<colgroup><col style="width:16%"><col class="hl"><col><col><col><col></colgroup>
<thead>
<tr><th scope="col">场景</th><th scope="col" class="acc">CEL</th><th scope="col">expr</th><th scope="col">Rego</th><th scope="col">Cedar</th><th scope="col">Starlark / Lua / JS</th></tr>
</thead>
<tbody>
<tr><th scope="row">能碰到什么</th><td class="good">只有声明的变量和函数</td><td class="good">只有 Env</td><td class="warn">有 <span class="mono">http.send</span> 等内置函数，可关</td><td class="good">只有实体和上下文</td><td class="warn">取决于注入了哪些库</td></tr>
<tr><th scope="row">写错字段</th><td class="good">编译期（需声明类型）</td><td class="good">编译期（需传结构体）</td><td class="warn">需配 JSON Schema</td><td class="warn">运行时；Rust 版可用 schema 校验</td><td class="bad">运行时或静默</td></tr>
<tr><th scope="row">写错类型</th><td class="good">编译期</td><td class="good">编译期</td><td class="bad">静默</td><td class="warn">运行时</td><td class="bad">运行时或静默</td></tr>
<tr><th scope="row">失控</th><td class="good">事先估算 + 运行时计数</td><td class="warn">内存预算，可绕开</td><td class="warn">context 超时</td><td class="good">写不出循环</td><td class="warn">步数或超时</td></tr>
<tr><th scope="row">每次求值</th><td>约 0.5 µs</td><td>0.16–0.44 µs</td><td>约 11 µs</td><td>约 0.6 µs（不含建实体）</td><td>0.3 / 6.3 / 1.2 µs</td></tr>
<tr><th scope="row">部分求值</th><td class="good">有</td><td class="bad">无</td><td class="good">有</td><td class="bad">无</td><td class="bad">无</td></tr>
<tr><th scope="row">跨语言</th><td class="good">Go、C++、Java 等，同一规范</td><td>只有 Go</td><td>Go，另有 Wasm</td><td>Rust、Go</td><td>各有多种实现</td></tr>
</tbody>
</table>
</div>
</div>
<figcaption>表 5 · CEL 不是每一项都最好，但它是唯一每一项都不差的</figcaption>
</figure>

结论可以这样理解：

- **CEL 和 expr[^expr] 是最接近的一对。** 语法相同，都有类型检查，都快。expr 更快、函数更多、写起来更像 Go；CEL 多了成本估算、部分求值和跨语言的同一套规范。规则只在 Go 服务里、由自己团队写，expr 很合适；规则来自用户、要在写入时就判断安全，或者同一条规则要在 Go 后端、Java 服务、前端校验里都能跑，选 CEL。
- **Rego 解决的是更大的问题。** 它是一个策略引擎：多条规则组合、引用外部数据、统一的策略中心、决策日志。把它当成“一行条件”的求值器来用，既慢又绕。
- **Cedar[^cedar] 专注授权。** 实体模型和 `permit / forbid` 结构让它天然适合回答“谁能对什么做什么”，也更容易做形式化分析；但它不是通用的条件语言。
- **Starlark、Lua、JavaScript 是脚本。** 真需要循环、局部变量、多步计算时才用它们，用的时候必须自己加超时、步数限制，并小心控制注入的库。
- **CUE、Jsonnet 是配置语言**，jq、JMESPath 是 JSON 查询语言。它们能写判断，但不是为每个请求求值设计的。

**选定了 CEL，在 Go 服务里怎么用才能把上面这些好处都拿到？**

## 六、在 Go 里用好 CEL

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 336" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="celf6-title celf6-desc">
<title id="celf6-title">一条 CEL 规则从保存到使用的三个场合</title>
<desc id="celf6-desc">保存时：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')。</desc>
<defs><marker id="celf6-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker></defs>
<rect class="dd-frame" width="800" height="336" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="248" height="264" rx="8"/>
<rect class="dd-zone" x="276" y="16" width="248" height="264" rx="8"/>
<rect class="dd-zone" x="536" y="16" width="248" height="264" rx="8"/>
<text class="t-zone" x="32" y="44">保存时</text>
<text class="t-zone" x="292" y="44">请求时</text>
<text class="t-zone" x="552" y="44">列表查询时</text>
<path class="dd-edge-acc" d="M248 225 H260 Q268 225 268 217 V109 Q268 101 276 101 H290" fill="none" marker-end="url(#celf6-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="216" height="196" rx="6"/>
<rect class="dd-card" x="292" y="64" width="216" height="196" rx="6"/>
<rect class="dd-card" x="552" y="64" width="216" height="196" rx="6"/>
<text class="t-mono" x="48" y="92">env.Compile(src)</text>
<text class="t-note" x="48" y="112">字段、类型在这里检查</text>
<line class="dd-hair" x1="32" y1="124" x2="248" y2="124"/>
<text class="t-mono" x="48" y="148">OutputType == bool</text>
<line class="dd-hair" x1="32" y1="160" x2="248" y2="160"/>
<text class="t-mono" x="48" y="184">EstimateCost ≤ 1e6</text>
<line class="dd-hair" x1="32" y1="196" x2="248" y2="196"/>
<text class="t-mono t-acc" x="48" y="229">缓存 Program</text>
<text class="t-mono t-acc" x="308" y="92">cache[ruleID]</text>
<text class="t-note" x="308" y="112">Program 并发安全</text>
<line class="dd-hair" x1="292" y1="124" x2="508" y2="124"/>
<text class="t-mono" x="308" y="148">ContextEval(ctx, vars)</text>
<line class="dd-hair" x1="292" y1="160" x2="508" y2="160"/>
<text class="t-mono" x="308" y="184">超时 50 ms · CostLimit 1e6</text>
<line class="dd-hair" x1="292" y1="196" x2="508" y2="196"/>
<text class="t-mono" x="308" y="229">err != nil → 拒绝</text>
<text class="t-mono" x="568" y="92">PartialVars(user, hour)</text>
<text class="t-note" x="568" y="112">resource 标为未知</text>
<line class="dd-hair" x1="552" y1="124" x2="768" y2="124"/>
<text class="t-mono" x="568" y="148">owner == "u1" ||</text>
<text class="t-mono" x="568" y="168">team in ["web"]</text>
<line class="dd-hair" x1="552" y1="184" x2="768" y2="184"/>
<text class="t-mono" x="568" y="210">WHERE owner = 'u1'</text>
<text class="t-mono" x="568" y="230">  OR team IN ('web')</text>
<text class="t-say" x="16" y="308">贵的、会失败的工作都放在保存时；请求路径上只剩一次求值和一个兜底的超时。</text>
</svg>
</div>
<figcaption>图 6 · 一条规则的三个使用场合，各自该做什么</figcaption>
</figure>

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

### 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 库从哪个版本开始可用写在文档里，新功能先只允许已存在的规则继续求值、不允许新写入，等到可以安全回滚了再放开[^k8s-cel]。

### 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
}
```

拿开头那条规则和几条有问题的规则试一下：

```text
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；请求时带超时和成本上限，出错就拒绝；自定义函数保持纯函数；扩展库写死版本；列表查询用部分求值。

## 参考资料
