什么是CEL?
CEL 是一种非图灵完备的表达式语言
,旨在快速、可移植且执行安全。CEL 可以单独使用,也可以嵌入到其他的产品中。
CEL被设计成一种可以安全地执行用户代码的语言。虽然盲目调用用户的python代码是危险的,但您可以安全地执行用户的CEL代码。由于CEL防止了会降低其性能的行为,因此它的评估安全性在纳秒到微秒之间;它非常适合性能关键型应用程序。eval()
CEL 计算表达式,类似于单行函数或 lambda
表达式。虽然 CEL 通常用于布尔决策,但它也可用于构造更复杂的对象,如 JSON
或 protobuf
消息。
关键概念
应用
CEL是通用的,已用于各种应用程序,从路由RPC到定义安全策略。CEL是可扩展的,与应用程序无关,并针对一次编译、多次评估的工作流进行了优化。 许多服务和应用程序评估声明性配置。例如,基于角色的访问控制(RBAC)是一种声明性配置,它在给定角色和一组用户的情况下生成访问决策。如果声明性配置是80%的用例,那么当用户需要更强的表达能力时,CEL是一个有用的工具,可以将剩余的20%取整。
编译
表达式是针对环境编译的。编译步骤生成protobuf
形式的抽象语法树(AST)。编译后的表达式通常会存储起来以备将来使用,从而使求值尽可能快。单个编译表达式可以使用许多不同的输入进行求值。
抽象语法树:
在计算机科学中,抽象语法树 (A bstract S yntax T ree,AST),或简称语法树 (Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是"抽象"的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then
这样的条件跳转语句,可以使用带有三个分支的节点来表示。
和抽象语法树相对的是具体语法树(通常称作分析树)。一般的,在源代码的翻译和编译过程中,语法分析器创建出分析树,然后从分析树生成AST。一旦AST被创建出来,在后续的处理过程中,比如语义分析阶段,会添加一些信息。
表达式
用户定义表达式;服务和应用程序定义了它运行的环境。函数签名声明输入,并在CEL
表达式之外编写。CEL
可用的函数库是自动导入的。 在下面的示例中,表达式采用一个请求对象,该请求包括一个声明令牌。该表达式返回一个布尔值,指示声明令牌是否仍然有效。
scss
// 通过检查"exp"声明来检查JSON Web令牌是否已过期。
//
// Args:
// claims - authentication claims.
// now - timestamp indicating the current system time.
// 如果令牌已过期,则返回:true
//
timestamp(claims["exp"]) < now
环境
环境是由服务定义的。嵌入CEL
的服务和应用程序声明表达式环境。环境是可以在表达式中使用的变量和函数的集合。 CEL
类型检查器使用基于原型的声明来确保表达式中的所有标识符和函数引用都得到了正确的声明和使用。
解析表达式的三个阶段
处理表达式有三个阶段:解析
、检查
和求值
。CEL
最常见的模式是控制平面在配置时解析和检查表达式,并存储AST
。
在运行时,数据平面会重复检索和评估AST。CEL
针对运行时效率进行了优化,但不应在延迟关键的代码路径中进行解析和检查。
CEL使用ANTLR lexer/parser
语法从人类可读的表达式解析为抽象语法树。解析阶段发出一个基于原型的抽象语法树,其中AST中的每个Expr
节点都包含一个整数id,用于索引解析和检查期间生成的元数据。解析过程中生成的syntax.proto
忠实地表示了以字符串形式键入的内容的抽象表示。
一旦解析了表达式,就可以根据环境对其进行检查,以确保表达式中的所有变量和函数标识符都已声明并正确使用。类型检查器生成一个checked.proto
,其中包括类型、变量和函数解析元数据,可以显著提高评估效率。
评估CEL需要3件事:
-
任何自定义扩展的函数绑定
-
变量绑定
-
AST评估
函数和变量绑定应该与用于编译AST的绑定相匹配。这些输入中的任何一个都可以在多个评估中重复使用,例如在多组变量绑定中评估AST,或者在多个AST中使用相同的变量,或者在进程的整个生命周期中使用函数绑定(常见情况)。
CEL 适合您的项目吗?
由于 CEL 以纳秒到微秒为单位评估 AST 的表达式,因此 CEL 的理想用例是具有性能关键路径的应用程序。不应在关键路径中将 CEL 代码编译到 AST 中;理想的应用程序是经常执行配置且修改频率相对较低的应用程序。
例如,对服务的每个 HTTP 请求执行安全策略是 CEL 的理想用例,因为安全策略很少更改,并且 CEL 对响应时间的影响可以忽略不计。在这种情况下,CEL 将返回一个布尔值(无论是否允许该请求),但它可能会返回更复杂的消息。
在 golang 中如何使用 CEL
一下代码我们使用 golang 的 cel 包 github.com/google/cel-go/cel
使用 cel 进行字符串拼接:
字符串 str = "Hello world! I'm " + name + "."
中存在变量 name
,在我们的程序中,这个 name 是一个变量,需要在程序中替换为具体的值,比如:张三
步骤如下:
1、先初始化 env,也就是我们上面说的需要配置执行的环境
2、在环境中绑定变量 name 以及类型
3、env.Compile(str)
就是做了我们上面所说的编译并解析表达式,返回 str
所对应的ast
4、程序求值。我们将 name 需要的具体值传到 program 中执行 values := map[string]interface{}{"name": "CEL"}
5、获取到最后的结果
go
func Test_exprReplacement(t *testing.T) {
var str = `"Hello world! I'm " + name + "."`
env, err := cel.NewEnv(
cel.Variable("name", cel.StringType), // 参数类型绑定
)
if err != nil {
t.Fatal(err)
}
ast, iss := env.Compile(str) // 编译,校验,执行 str
if iss.Err() != nil {
t.Fatal(iss.Err())
}
program, err := env.Program(ast)
if err != nil {
t.Fatal(err)
}
// 初始化 name 变量的值
values := map[string]interface{}{"name": "CEL"}
// 传给内部程序并返回执行的结果
out, detail, err := program.Eval(values)
if err != nil {
t.Fatal(err)
}
fmt.Println(detail)
fmt.Println(out)
}
测试结果:
shell
Running tool: /usr/local/go/bin/go test -timeout 10s -run ^Test_exprReplacement$ github.com/demo007x/goexpr -v
=== RUN Test_exprReplacement
Hello world! I'm CEL.
<nil>
--- PASS: Test_exprReplacement (0.00s)
PASS
ok github.com/demo007x/goexpr 0.010s
计算一个表达式的逻辑结果:
返回表达式 var str = 100 + 200 >= 300
的执行结果:
执行步骤跟上面的一样,这里就省略了。
go
func Test_LogicExpr1(t *testing.T) {
var str = `100 + 200 >= 300`
env, err := cel.NewEnv()
if err != nil {
t.Fatal(err)
}
ast, iss := env.Compile(str)
if iss.Err() != nil {
t.Fatal(iss.Err())
}
prog, err := env.Program(ast)
if err != nil {
t.Fatal(err)
}
out, detail, err := prog.Eval(map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
fmt.Println(out)
fmt.Println(detail)
}
输出结果:
shell
Running tool: /usr/local/go/bin/go test -timeout 10s -run ^Test_LogicExpr1$ github.com/demo007x/goexpr -v
=== RUN Test_LogicExpr1
true
<nil>
--- PASS: Test_LogicExpr1 (0.00s)
PASS
ok github.com/demo007x/goexpr 0.010s
执行一个有函数的表达式会是咋样的呢?
先定一一个函数:
go
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// 求所有传入参数的和
func Add[T Integer](param1, param2 T, ints ...T) T {
sum := param1 + param2
for _, v := range ints {
sum += v
}
return sum
}
执行有函数的表达式:6 == Add(age1, age2, age3)
go
func Test_add(t *testing.T) {
str := `6 == Add(age1, age2, age3)`
env, _ := cel.NewEnv(
cel.Variable("age1", cel.IntType),
cel.Variable("age2", cel.IntType),
cel.Variable("age3", cel.IntType),
cel.Function("Add", cel.Overload(
"Add",
[]*cel.Type{cel.IntType, cel.IntType, cel.IntType},
cel.IntType,
cel.FunctionBinding(func(vals ...ref.Val) ref.Val {
var xx []int64
for _, v := range vals {
xx = append(xx, v.Value().(int64))
}
return types.Int(Add[int64](xx[0], xx[1], xx[2:]...))
}),
)),
)
ast, iss := env.Compile(str)
if iss.Err() != nil {
t.Fatal(iss.Err())
}
prog, err := env.Program(ast)
if err != nil {
t.Fatal(err)
}
val, detail, err := prog.Eval(map[string]interface{}{"age1": 1, "age2": 2, "age3": 3})
if err != nil {
t.Fatal(err)
}
fmt.Print(detail)
fmt.Println(val)
}
执行步骤跟上面的一样:
- 首先需要申明传入函数参数的类型
age1
,age2
age3
- 申明
Add
函数,并申明函数的参数类型,以及返回结果 env.Compile(str)
字符串的编译、校验、执行,返回ast
prog.Eval
传入参数执行并返回结果
执行结果:
go
Running tool: /usr/local/go/bin/go test -timeout 10s -run ^Test_add$ github.com/demo007x/goexpr -v
=== RUN Test_add
<nil>true
--- PASS: Test_add (0.00s)
PASS
ok github.com/demo007x/goexpr 0.014s
场景:
一般情况下项目中都很少会去执行一个 CEL 的表达式,我们都会按照固定好的逻辑去编写代码。
目前低代码盛行的时代,项目中的功能都可以自定义,这样一个功能就需要足够的灵活,将一些程序执行的逻辑交给用户去控制。
比如我们目前的项目中:一个复杂的流程具体要怎么执行,需要谁去审批,需要在那一步的时候跳过等这些都是可以灵活配置每一个节点的逻辑条件,满足条件的就去执行节点流程,不满足的就去跳过执行下一个流程处理。这时候配置条件就可以使用 cel 的表达式去配置,通过表单中的多个字段的值组成一个 bool 条件。
比如请假流程:请假天数 <= 3
流程需要走到部门领导审批, 请假天数 > 3
流程需要部门领导审批完成后继续流转到部门领导的上级审批。
这样一个条件中请假天数
是一个表单中某个字段的值,这样配置条件就很灵活。这就是 cel
在我们项目中实际使用的例子的一部分。
cel 在 k8s中的使用
CEL 的每个 Kubernetes API 字段都在 API 文档中声明了字段可使用哪些变量。例如,在 CustomResourceDefinitions 的 x-kubernetes-validations[i].rules
字段中,self
和 oldSelf
变量可用, 并且分别指代要由 CEL 表达式验证的自定义资源数据的前一个状态和当前状态。 其他 Kubernetes API 字段可能声明不同的变量。请查阅 API 字段的 API 文档以了解该字段可使用哪些变量。
K8S 中 CEL 表达式示例:
规则 | 用途 |
---|---|
self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas |
验证定义副本的三个字段被正确排序 |
'Available' in self.stateCounts |
验证映射中存在主键为 'Available' 的条目 |
(self.list1.size() == 0) != (self.list2.size() == 0) |
验证两个列表中有一个非空,但不是两个都非空 |
self.envars.filter(e, e.name = 'MY_ENV').all(e, e.value.matches('^[a-zA-Z]*$') |
验证 listMap 条目的 'value' 字段,其主键字段 'name' 是 'MY_ENV' |
has(self.expired) && self.created + self.ttl < self.expired |
验证 'expired' 日期在 'create' 日期加上 'ttl' 持续时间之后 |
self.health.startsWith('ok') |
验证 'health' 字符串字段具有前缀 'ok' |
self.widgets.exists(w, w.key == 'x' && w.foo < 10) |
验证具有键 'x' 的 listMap 项的 'foo' 属性小于 10 |
type(self) == string ? self == '99%' : self == 42 |
验证 int-or-string 字段是否同时具备 int 和 string 的属性 |
self.metadata.name == 'singleton' |
验证某对象的名称与特定的值匹配(使其成为一个特例) |
self.set1.all(e, !(e in self.set2)) |
验证两个 listSet 不相交 |
self.names.size() == self.details.size() && self.names.all(n, n in self.details) |
验证 'details' 映射是由 'names' listSet 中的各项键入的 |
思考
cel 的执行流程都是固定的,不管是简单的字符串还是内嵌函数的执行。那是不是我们就可以基于 go-cel
的功能来封装一次,将相同的逻辑代码抽取出来。这样使用的时候就不需要每执行一个 cel 的表达式就去写一遍实现了呢?
我们分析下相同点和不同点:
相同点:
cel.NewEnv
初始化 envenv.Compile
检测,编译 cel 表达式env.Program
ast 执行prog.Eval
执行并返回结果
不同的地方就是需要明确的标明变量的类型,以及返回值(函数),而且参数个数不能多,也不能少,prog.Eval
传入的实参只能多不能少,少了就会报错。
如果我们将需要传递的参数以及类型提前解析出来并动态的传入以上几个步骤中,那我们的封装是有意义的。代码量也减少很多。哪如何抽取动态参数以及类型呢?
ps: 掘友们有没有好的办法可以一起谈论