如何实现 Commen Expression Language (CEL) 自定义函数

common-expression-language

📜 目录

  • 👋 背景
  • 💻 简单了解
  • 💪 解决问题
  • 🦾 内置能力
  • 💪 自定义函数
  • 🧠 总结
  • 🤩 参考

👋 背景

CEL 是Google 提供的通用的表达式解析的语法,cel-spec 中描述了这种语法,各种语言都对这种语法做了相关的实现

这个东西用途比较广泛,拿之前 Knative Eventing 的场景来说,如果我们需要过滤的事件信息比较复杂,一般的过滤手段比如前缀匹配、后缀匹配以及 Knative 提供的 ceSQL 就不够用了

这个时候 CEL 就能更好的处理这个场景,我们也以这个场景为例,看下 CEL 的能力

💻 简单了解

原理说明

cel-go 的实现是将配置构造成了一个语法树,随后根据配置来解析提供的数据,最终返回结果

需要注意的是,这里在构造语法树的时候,最终产出的是 protocol buffer 类型的数据

  • 构建语法树
    Three phases of parsing an expression (parse and check)

  • 对提供的数据进行解析
    Three phases of parsing an expression (evaluate)

快速操作

可以通过如下两个场景来尝试一下

  1. 字符替换构造
  2. 数据过滤

其中基本步骤如下

  1. 构造了一个 env,这里声明了支持解析的数据类型与相关数据
  2. 通过 env.compile 生成了我们提到的语法树
  3. 使用 env.Program 来生成处理使用的 prg
  4. 使用 prg 来处理数据
字符构造

通过 env.Compile(`"Hello world! I'm " + name + "."`)构造的 Ast 处理数据 prg.Eval(map[string]any{ "name": "CEL"}) ,最终输出 Hello world! I'm CEL.

package examples

import (
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
)

func ExampleSimple() {
	env, err := cel.NewEnv(cel.Variable("name", cel.StringType))
	if err != nil {
		log.Fatalf("environment creation error: %v\n", err)
	}
	ast, iss := env.Compile(`"Hello world! I'm " + name + "."`)
	// Check iss for compilation errors.
	if iss.Err() != nil {
		log.Fatalln(iss.Err())
	}
	prg, err := env.Program(ast)
	if err != nil {
		log.Fatalln(err)
	}
	out, _, err := prg.Eval(map[string]any{
		"name": "CEL",
	})
	if err != nil {
		log.Fatalln(err)
	}
	fmt.Println(out)
	// Output:Hello world! I'm CEL.
}
数据过滤

通过 env.Compile(`request.auth.claims.group == 'admin'`)构造的 Ast 处理数据request(auth("user:me@acme.co", claims), time.Now()) ,最终判断结果为 true

func exercise2() {
	// Construct a standard environment that accepts 'request' as input and uses
	// the google.rpc.context.AttributeContext.Request type.
	env, err := cel.NewEnv(
		cel.Types(&rpcpb.AttributeContext_Request{}),
		cel.Variable("request",
			cel.ObjectType("google.rpc.context.AttributeContext.Request"),
		),
	)
	if err != nil {
		glog.Exit(err)
	}
	ast, iss := env.Compile(`request.auth.claims.group == 'admin'`)
	if iss.Err() != nil {
		glog.Exit(iss.Err())
	}

	program, _ := env.Program(ast)

	// Evaluate a request object that sets the proper group claim.
	// Output: true
	claims := map[string]string{"group": "admin"}
	out, _, _ := program.Eval(request(auth("user:me@acme.co", claims), time.Now()))
	fmt.Println(out)

	// Output: true
}

💪 解决问题

以上简单示例中,能够对 CEL 部分能力有个大概了解,对于背景中提到的问题,这里没能解决

cel.NewEnv 中需要提供待处理数据的类型,否则它无法对数据进行处理,cel-go 中无法包含所有的类型,不过它提供了 Declarations 支持自定义一些数据类型,cloudevent 中基本都是 map 类型的数据,所以我们可以自定义一个 map 类型数据

比如我们就可以使用 decls.NewMapType(decls.String, decls.Dyn) 来自定义一个 map[string]any 的类型,这样我们就可以轻松处理上面提到的 cloudEvent 中的数据

package main

import (
	"fmt"
	"github.com/golang/glog"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
)

func main() {
	env, err := cel.NewEnv(
		cel.Declarations(
			decls.NewVar("cloudevent", decls.NewMapType(decls.String, decls.Dyn)),
		),
	)
	if err != nil {
		glog.Exit(err)
	}
	ast, iss := env.Compile(`cloudevent.group.admin.name == 'yugougou'`)
	if iss.Err() != nil {
		glog.Exit(iss.Err())
	}

	program, _ := env.Program(ast)

	claims := map[string]any{"cloudevent": map[string]any{"group": map[string]any{"admin": map[string]any{"name": "yugougou"}}}}

	out, _, _ := program.Eval(claims)
	fmt.Println("filter cloudevent with admin name yugougou")
	fmt.Println(out)
	// Output: true

	claims = map[string]any{"cloudevent": map[string]any{"group": map[string]any{"admin": map[string]any{"name": "yuzp1996"}}}}

	out, _, _ = program.Eval(claims)
	fmt.Println("filter cloudevent with admin name yugougou")
	fmt.Println(out)
	// Output: false
}

输出结果

filter cloudevent with admin name yugougou
true
filter cloudevent with admin name yugougou
false

🦾 内置能力

CEL 定义了很多的语法,帮助我们更自由的过滤数据,不同语言都需要实现这些语法,参见 标准定义列表

cel-go 就实现了这些语法,我们可以方便的使用

简单定义

复杂些的定义

复杂些的定义 中,存在一个 matches 的函数,matches 就是对正则表达式进行匹配处理,判断数据是否符合某个正则表达式

这里我们以 matches 为例,将上面的示例改造一下,就可以通过正则表达式来做匹配,结果也是一致的,符合预期的

package main

import (
	"fmt"
	"github.com/golang/glog"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
)

func main() {
	env, err := cel.NewEnv(
		cel.Declarations(
			decls.NewVar("cloudevent", decls.NewMapType(decls.String, decls.Dyn)),
		),
	)
	if err != nil {
		glog.Exit(err)
	}
	// 由 == 表达式替换成正则表达式
	ast, iss := env.Compile(`cloudevent.group.admin.name.matches('^yugougou$')`)
	if iss.Err() != nil {
		glog.Exit(iss.Err())
	}

	program, _ := env.Program(ast)

	claims := map[string]any{"cloudevent": map[string]any{"group": map[string]any{"admin": map[string]any{"name": "yugougou"}}}}

	out, _, _ := program.Eval(claims)
	fmt.Println("filter cloudevent with admin name yugougou")
	fmt.Println(out)
	// Output: true

	claims = map[string]any{"cloudevent": map[string]any{"group": map[string]any{"admin": map[string]any{"name": "yuzp1996"}}}}

	out, _, _ = program.Eval(claims)
	fmt.Println("filter cloudevent with admin name yugougou")
	fmt.Println(out)
	// Output: false
}

输出结果

filter cloudevent with admin name yugougou
true
filter cloudevent with admin name yugougou
false

💪 自定义函数

内置能力定义 中我们可以看到,CEL 提供了很多内置的处理函数拓展了它的能力,这些能力能够解决我们大多数的问题

但是很多情况下由于业务逻辑复杂,或者为了提供更好的用户体验,我们会需要拓展下这些能力,CEL 提供了自定义函数来拓展我们需要的能力

比如我们从数组中获取某个元素,目前只能通过下标的表达式来获取,这就很不通用,因为这会到之后我们在编写 CEL 表达式的时候,需要提前知道我们要过滤的数据在什么位置

比如有如下数据,我们想获取 detail.first.content = abcd 元素的 issue.count 是否大于 0,那我们只能将 CEL 表达式写成 int(root.data[1].issues.count) > 0,这就会带来上面提到的,需要提前知道要过滤的数据在什么位置

root:
  data:
  - name: "ci-lint"
    issue:
      count: 0
  - name: "ci-lint-1"
    detail:
      first:
        content: abcd
    issue:
      count: 2

以该场景为例,我们可以定义一个自定义函数 kvelement(key,value),这个函数支持通过指定 key 与 value 值来过滤数组中的元素,这样我们就可以通过 int(root.data.kvelement('detail.first.content','abcd').issues.count) > 0 来获取元素,以下是相关的实现

package main

import (
	"fmt"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
	"strings"
)

func main() {
	// 准备待过滤数据, 我们期望获取数组中 name=ci-lint 的元素
	data := map[string]interface{}{
		"root": map[string]interface{}{
			"data": []map[string]interface{}{
				{
					"name":   "ci-lint",
					"issues": map[string]interface{}{"count": 0},
				},
				{
					"name": "ci-lint-1",
					"detail": map[string]interface{}{
						"first": map[string]interface{}{
							"content": "abcd",
						}},
					"issues": map[string]interface{}{"count": 2},
				},
			},
		},
	}

	// 增加一个 map 的 list 的处理方式
	mapType := cel.MapType(cel.StringType, cel.DynType)
	arrayType := cel.ListType(mapType)

	// Env declaration.
	env, _ := cel.NewEnv(
		cel.Declarations(
			decls.NewVar("root", decls.NewMapType(decls.String, decls.Dyn)),
		),
		// 将函数注入到 Env 中,起名为 kvelement,后续 cel 表达式中使用的话,也是使用这个名称
		cel.Function("kvelement",
			cel.MemberOverload(
				"get_contains_key_value_element",
				// 绑定参数,第一个参数是该函数的调用者,后续的参数是调用这个函数时的参数
				[]*cel.Type{arrayType, cel.StringType, cel.DynType},
				mapType,
				// 绑定函数
				cel.FunctionBinding(ElementContainsKeyValue),
			),
		),
	)
	
	// 使用内置能力的方式
	ast, iss := env.Compile("int(root.data[1].issues.count) > 0")
	if iss != nil {
		panic(iss)
	}
	program, _ := env.Program(ast)
	out, _, err := program.Eval(data)
	if err != nil {
		panic(err)
	}
	// out is true
	fmt.Printf("out is %#v\n", out)
	
	// 使用自定义函数的方式
	ast, iss = env.Compile("int(root.data.kvelement('detail.first.content','abcd').issues.count) > 0")
	if iss != nil {
		panic(iss)
	}
	program, _ = env.Program(ast)
	out, _, err = program.Eval(data)
	if err != nil {
		panic(err)
	}
	// out is true
	fmt.Printf("out is %#v", out)
}

func ElementContainsKeyValue(args ...ref.Val) ref.Val {

	// 获取参数,调用者是数组,因此可以通过内置的 Lister 来处理
	// 第一个参数是 key,第二个是期望的 value
	lister := args[0].(traits.Lister)
	key := args[1]
	value := args[2]

	// 处理 lister 的参数,对数组的所有元素进行过滤
	iterator := lister.Iterator()
	for iterator.HasNext() == types.True {
		// 获取 key 值,key 的提供是通过 . 分割的,所以通过 . 获取每一层的 key 值
		keyString, err := getString(key.Value())
		if err != nil {
			return types.WrapErr(err)
		}
		keySplits := strings.Split(keyString, ".")

		// 获取数组中的各个值
		element := iterator.Next()
		elementValue, err := getStringInterfaceMap(element.Value())
		if err != nil {
			return types.WrapErr(err)
		}

		// 查看提供的 key 的第一层在当前数组元素中是否存在
		result := elementValue[keySplits[0]]
		if result == nil {
			continue
		}
		// 循环 key 的剩余层并且获取到对应的值
		for _, currentKey := range keySplits[1:] {
			middleResult, err := getStringInterfaceMap(result)
			if err != nil {
				continue
			}
			if middleResult[currentKey] != nil {
				result = middleResult[currentKey]
			} else {
				continue
			}
		}

		// 使用获取到的值和期望的值进行对比,确认是否找到相关的 element
		getResult, err := getString(result)
		if err != nil {
			return types.WrapErr(err)
		}
		expectResult, err := getString(value.Value())
		if err != nil {
			return types.WrapErr(err)
		}

		if getResult == expectResult {
			return element
		}
	}

	return types.WrapErr(fmt.Errorf("not matched"))
}

func getStringInterfaceMap(val interface{}) (map[string]interface{}, error) {
	msivalue, ok := val.(map[string]interface{})
	if !ok {
		return nil, fmt.Errorf("%#v not map[string]interface{}", msivalue)
	}
	return msivalue, nil
}
func getString(val interface{}) (string, error) {
	stringval, ok := val.(string)
	if !ok {
		return "", fmt.Errorf("%#v is not string", val)
	}
	return stringval, nil
}

如代码示例所示,我们可以将表达式从 int(root.data[1].issues.count) > 0 替换成 int(root.data.kvelement('detail.first.content','abcd').issues.count) > 0,使得 CEL 表达式更加通用

想要了解更多相关内容,官方自定义函数的教程 也推荐参考,官方的示例循序渐进,描述的更加详细清楚

🧠 总结

目前只是简单的描述了 CEL 大概的能力, CEL 还有很多其他能力我还未探索到,比如更多的自定义函数以及更多的自定义类型等等,这些都极大的丰富了 CEL 的拓展性,有兴趣的可以自行探索

另外说一下这个工具我们可以用在什么场景,以我们使用的场景为例

  1. 在事件触发过滤的场景下,可以使用 cel 做复杂的过滤,只要它能用 yaml 表示出来,我就有办法获取到它用作过滤验证,比如代码触发器和镜像触发器中要求代码必须是来自什么分支包含什么 commit message,或者镜像必须满足什么安全要求
  2. 策略校验的场景,比如我们规定只有符合某某条件的资源才能正常被创建或者执行,比如我们要求流水线只能更新某个集群的某个命名空间的资源,或者我们要求流水线中产生的镜像漏洞数目必须小于多少多少

有了这种通用的可拓展的表达式语言,这些功能都可以实现

🤩 参考

语法参考书 cel-spec ( 定义了 common expression language 包含能力,不同语言实现的库会按照这里的定义进行实现 )

Golang CEL 实现库 cel-go (cel-spec Golang 版本的实现)

Golang CEL 代码实验室 cel-go codelabs (可以在这里初步了解 cel 的使用方式)

CEL playground (可以在这里自己造数据和 expression,验证自己的 cel 语法是否正确)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值