学习cel-go了解一下通用表达语言评估是什么

文章目录

1. 前言

最近因为在项目里面实现的一个使用+和||来组合获取字段值的功能有点儿弱了,就想着没有什么库可以改进这一些东西,增强数据获取的解析能力,让获取不同字段拼接值的数据变得更加简单。

于是去github看了一下,发现一个cel-go的库,这个库支持对表达式进行取值和判断,可以满足我的要求,于是就学习了一下这个库相关的使用。

2. cel-go

cel这个名字的全称是Common Expression Language,简单翻译就是通用表达语言。官方给的解释如下:

The Common Expression Language (CEL) is a non-Turing complete language designed for simplicity, speed, safety, and portability. CEL's C-like syntax looks nearly identical to equivalent expressions in C++, Go, Java, and TypeScript.

通用表达语言(CEL)是一种非图灵完备性语言,旨在简单、快速、安全和可移植。CEL的类C语法看起来与C++、Go、Java和TypeScript中的等效表达式几乎相同。

CEL程序是单个表达式,cel-go推荐在使用此库的时候去Codelab学习下,然后评估是否应该选择此库。

这个CodeLab库主要是介绍关于CEL库的一些概念、如何判断CEL是否可用于你的项目、cel-go运行的相关概念以及给出了一些练习的case,帮助你快速了解CEL是什么,以及cel-go如何使用。

这里单独拎出来cel-go关键概念来学习一下。

2.1 cel-go关键概念

**注:**下面内容从2. Key concepts章节翻译摘抄过来。

Applications(应用)

CEL是通用的,可以用于各种应用,从路由RPC到定义安全策略。CEL可扩展、与应用程序无关、并且针对一次编译多次评估的工作流程进行了优化。

许多服务和应用程序都会评估声明性配置。例如,基于角色的控制访问(RABC)是一种声明性配置,可以在给定角色和一组用户的情况下生成访问决策,如果声明性配置占80%的用例,当用户需要更强的表达能力时,CEL是一个非常有用的工具,可以补充剩余的20%。

K8s中就利用cel-go做了一些关于配置数据的验证以及一些状态的验证逻辑。具体可以参考:Common Expression Language in Kubernetes

Compilation(编译)

表达式是针对环境编译的。编译步骤会生成protobuf形式的抽象语法树(AST)。编译后的表达式通常会被存储以供将来使用,以尽可能快地进行计算。可以使用许多不同的输入来计算单个编译表达式。(这里有个实践应用的优化tips,在项目中,一般都会将编译后生成的AST用本地缓存存储起来,后续使用的时候直接取出,而不是每次使用都编译。)

Expressions(表达式)

用户定义表达式,服务和应用程序定义其运行的环境。函数签名声明输入,并写在CEL表达式之外。CEL可用的函数库是自动导入的(这里从代码里面看的话,有个标准函数库,在使用cel.NewEnv构建env的时候会自动导入)。

下面示例中,表达式采用请求对象,并且该请求包含声明令牌,表达式返回一个布尔值,指示声明令牌是否仍然有效。

go 复制代码
// Check whether a JSON Web Token has expired by inspecting the 'exp' claim.
//
// Args:
//   claims - authentication claims.
//   now    - timestamp indicating the current system time.
// Returns: true if the token has expired.
//
timestamp(claims["exp"]) < now
Environment环境

环境由服务定义。嵌入CEL的服务和应用程序声明表达式环境。环境是可以在表达式中使用的变量和函数的集合。

CEL类型检查器使用基于原型的声明来确保表达式中所有标识符和函数引用均已正确声明和使用。

解析表达式的三个阶段

处理表达式分为三个阶段:解析、检查和评估。CEL最常见的模式是控制平面(Control Plane)在配置时解析和检查表达式,并存储AST。

在运行时,数据平面重复检索并评估AST。CEL针对运行时效率进行了优化,但解析和检查不应在延迟关键代码路径中进行。(这里就对应里面的Store AST,意思是解析和检查需要保存下来,而且处理的路径应该是旁路,而不是关键代码路径中)

使用ANTLR词法分析器/解析器语法将CEL从人类可读的表达式解析为抽象语法树。解析阶段发出一个基于原型的抽象语法树,其中AST中的每个Expr节点都包含一个整数id,用于索引解析和检查期间生成的元数据。解析过程中生成的syntax.proto忠实地表示了以表达式的字符串形式键入的内容的抽象表达。

一旦表达式被解析,就可以根据环境进行检查,以确保表达式中的所有变量和函数标识符都已声明并正确使用。类型检查器生成一个checked.proto,其中包含类型、变量和函数解析元数据,可以极大地提高评估效率。

CEL评估员(用户)需要做的三件事:

  • 任何自定义扩展的函数绑定
  • 变量绑定
  • 要评估的AST

函数和变量绑定应与编译AST所使用的绑定相匹配。这些输入中的任何一个都可以在多个评估中重复使用,例如跨多组变量评估的AST,或者针对许多AST使用相同的变量,或者在进程的声明周期中使用的函数绑定(常见情况)。

3. cel-go的使用

关于cel-go的练习例子,可以直接通过4. Hello, World!经典的Hello,World章节进行练习。这些case主要的学习知识如下:

  • exercise1
    • 主要是学习表达式的编译以及评估
  • exercise2
    • 主要是学习在定义环境的时候引入新的变量,以及如何引入变量的数据类型
  • exercise3
    • 主要是学习如何使用逻辑与或表达式的使用
  • exercise4
    • 主要是学习如何自定义函数逻辑,虽然cel-go的std.lib提供了许多有用的类型转换、时间计算等相关的函数,但涉及到一些自定义的业务逻辑,就需要一些自定义函数来支撑了。
  • exercise5
    • 主要是学习如何构建出一个json数据,主要是为了表达CEL并非只能输出布尔类型结果,同样可以输出其他数据类型的结果
  • exercise6
    • 主要是学习构建protos,这里主要是将具体的数据编译到我们的proto定义的message结构中去。这里会学习cel.Container(name string) 的概念,主要是为具体的proto的message准备的。

通过上面6个case的学习基本可以知道cel-go的使用。

4. cel-go使用

在使用之前我们可以结合上面的6个case,给出一个具体的需求,然后在此需求之上利用cel-go进行不同表达式的实现。

需求:需要实现从一个json string的字符串中读取出内部json字段的数据,与"hello"字符串通过"-"进行拼接,同时对于json string中含有的时间字段与当前时间进行时间对比,如果比当前时间小则拼接"before",否则拼接"after"。

go 复制代码
package main

import (
	"encoding/json"
	"fmt"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
	"google.golang.org/protobuf/encoding/prototext"
	"google.golang.org/protobuf/proto"
	"log"
	"reflect"
	"sort"
	"strings"
	"time"

	rpcpb "google.golang.org/genproto/googleapis/rpc/context/attribute_context"
	structpb "google.golang.org/protobuf/types/known/structpb"
	tpb "google.golang.org/protobuf/types/known/timestamppb"
)

func main() {
	exercise7()
}

func exercise7() {
	fmt.Println("==== Exercise 7: self-coding ====")
	// json数据字符串
	dataStr := `{
			"payment":{"payment_id":111,"ctime":1717834001},
			"transaction":{"transaction_id":333}
		}
`
	// 新构建一个运行时环境
	env, err := cel.NewEnv(
		// 定义变量名以及数据类型,表示data的数据类型为map,key为string,值是动态的不确定
		cel.Variable("data", cel.MapType(cel.StringType, cel.DynType)),
		// 定义变量名为now, 类型为时间戳类型
		cel.Variable("now", cel.TimestampType),
		// define a single args function
		// 定义函数,函数名称为json_int_to_str,接收一个double类型的参数,返回一个string参数
		cel.Function("json_int_to_str",
			cel.Overload("double_to_str", []*cel.Type{cel.DoubleType}, cel.StringType,
				cel.UnaryBinding(func(value ref.Val) ref.Val {
					return value.ConvertToType(cel.StringType)
				}))),
		// 定义函数compare_time比较时间,接受一个double类型的参数和一个时间类型的参数,返回布尔值
		cel.Function("compare_time",
			cel.Overload("compare_time", []*cel.Type{cel.DoubleType, cel.TimestampType}, cel.BoolType,
				cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
					v := rhs.Value().(time.Time).Unix()-lhs.ConvertToType(cel.IntType).Value().(int64) >= 0
					return types.Bool(v)
				}))),
	)
	if err != nil {
		log.Fatalf("loading env error: %v", err)
	}
	// 将表达式编译成ast树(这里的compile会执行编译&检查)
	ast, issues := env.Compile(`json_int_to_str(data.payment.payment_id) + "-" + "hello you! " + (compare_time(data.payment.ctime, now - duration('500s')) ? "before":"after")`)
	if issues.Err() != nil {
		log.Fatal(issues.Err())
	}
	// 利用当前的运行环境构建出一个Ast的评估实例
	program, err := env.Program(ast)
	if err != nil {
		log.Fatalf("program error: %v", err)
	}
	// 对数据进行pb结构的反序列化
	structData := &structpb.Struct{}
	err = json.Unmarshal([]byte(dataStr), structData)
	if err != nil {
		log.Fatalf("unmarshal dataStr error: %v", err)
	}
	// 最后数据的评估阶段
	val, details, err := program.Eval(map[string]interface{}{"data": structData, "now": &tpb.Timestamp{Seconds: time.Now().Unix()}})
	fmt.Println(val, details, err)
}

解释:

  1. 运行的环境,如果在表达式中需要使用一些变量作为表达式的数据获取,则此变量一定需要再构建env的时候声明,否则会报错undeclared reference to 'data' (in container '') 表示data字段未声明。
  2. 自定义函数,如果我们想要将int转成str,实际std.lib是有对应的函数支持的,但如果我们非要自己实现,就需要实现函数的声明与逻辑的实现,否则会报错 undeclared reference to 'json_int_to_str' (in container '') 表示这个函数未声明
  3. 编译阶段和构建Ast实例这里,是解析表达式的必经之路,这里主要是利用词法分析器将我们的表达式解析为抽象语法树,即从人能够理解的表达式转变为解析器能够理解的语法树,从而为后续的表达式评估做准备。
  4. 数据的反序列化,数据是序列化的json字符串,我们就可以利用pb中的Struct结构进行反序列化,后在评估的时候将数据显式通过之前在构建env时候声明过的变量传入即可。

最后输出的结果为

go 复制代码
==== Exercise 7: self-coding ====
111-hello you! before <nil> <nil>

上面的自定义int转为string的函数,在std.lib中有声明,所以可以直接使用string(data.payment.payment_id)也是可以实现数字转字符串的效果。

go 复制代码
// String conversion functions.
const (
	StringToString    = "string_to_string"
	BoolToString      = "bool_to_string"
	IntToString       = "int64_to_string"
	UintToString      = "uint64_to_string"
	DoubleToString    = "double_to_string"
	BytesToString     = "bytes_to_string"
	TimestampToString = "timestamp_to_string"
	DurationToString  = "duration_to_string"
)

stdFunctions = []*decls.FunctionDecl{
// String conversions
	function(overloads.TypeConvertString,
		decls.Overload(overloads.StringToString, argTypes(types.StringType), types.StringType,
			decls.UnaryBinding(identity)),
		decls.Overload(overloads.BoolToString, argTypes(types.BoolType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.BytesToString, argTypes(types.BytesType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.DoubleToString, argTypes(types.DoubleType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.DurationToString, argTypes(types.DurationType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.IntToString, argTypes(types.IntType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.TimestampToString, argTypes(types.TimestampType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType))),
		decls.Overload(overloads.UintToString, argTypes(types.UintType), types.StringType,
			decls.UnaryBinding(convertToType(types.StringType)))),
}

func convertToType(t ref.Type) functions.UnaryOp {
	return func(val ref.Val) ref.Val {
		return val.ConvertToType(t)
	}
}

// Type conversion methods and overloads
const (
	TypeConvertInt       = "int"
	TypeConvertUint      = "uint"
	TypeConvertDouble    = "double"
	TypeConvertBool      = "bool"
	TypeConvertString    = "string"
	TypeConvertBytes     = "bytes"
	TypeConvertTimestamp = "timestamp"
	TypeConvertDuration  = "duration"
	TypeConvertType      = "type"
	TypeConvertDyn       = "dyn"
)

5. 说明

除了我们上面的使用例子中说的cel-go的使用用法,cel-go还支持自定义env的构建:

go 复制代码
// NewCustomEnv creates a custom program environment which is not automatically configured with the
// standard library of functions and macros documented in the CEL spec.
//
// The purpose for using a custom environment might be for subsetting the standard library produced
// by the cel.StdLib() function. Subsetting CEL is a core aspect of its design that allows users to
// limit the compute and memory impact of a CEL program by controlling the functions and macros
// that may appear in a given expression.
//
// See the EnvOption helper functions for the options that can be used to configure the
// environment.
func NewCustomEnv(opts ...EnvOption) (*Env, error) {
	// ...
}

支持类型的自定义

go 复制代码
func init() {
	// 这是初始化时候定义的list和map类型
	paramA := types.NewTypeParamType("A")
	paramB := types.NewTypeParamType("B")
	listOfA := types.NewListType(paramA)
	mapOfAB := types.NewMapType(paramA, paramB)
}

同样,我们也可以使用自定义的数据进行函数的定义与特定逻辑的处理

go 复制代码
env, err := cel.NewEnv(
		// 省略...
		cel.Function("in_concat",
			cel.Overload("in_concat", []*cel.Type{cel.DynType, cel.ListType(types.NewTypeParamType("C"))}, cel.BoolType,
				cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
					list := rhs.ConvertToType(types.ListType)
					strArray := make([]string, 0)
					for _, l := range list.Value().([]ref.Val) {
						if v, ok := l.Value().(int64); ok {
							strArray = append(strArray, strconv.FormatInt(v, 10))
						}
					}
					sType := lhs.ConvertToType(types.StringType)
					return types.Bool(sType.Value().(string) == strings.Join(strArray, ""))
				}))),
	)
ast, issues := env.Compile(`in_concat(data.payment.payment_id, [1,11])`)

我们通过自定义出ListType,从而实现判断payment_id是否在数组拼接的结果中,从而实现特定的逻辑。注意由于在标准库的init()函数中已经声明了ListType,如果我们没有使用自定义env的方法,而使用NewEnv的方法的同时,有自定义ListType,这样会导致type在注册校验的时候报错:

go 复制代码
	paramTypeC := types.NewTypeParamType("C")
	customListType := types.NewListType(paramTypeC)

	env, err := cel.NewEnv(
		cel.Types(customListType)
	)
	
	// 报错:loading env error: type registration conflict. found: list(), input: list(C)

另外关于优化的部分,这里有一些在评估表达式时候的优化,对于一些常规的表达式,我们可以看到它的判断都是在Optimize这里的逻辑进行判断和评估的,主要是一些逻辑与或或者元素的in相关的逻辑,可以看下:

go 复制代码
// Optimize applies a sequence of optimizations to an Ast within a given environment.
//
// If issues are encountered, the Issues.Err() return value will be non-nil.
func (opt *StaticOptimizer) Optimize(env *Env, a *Ast) (*Ast, *Issues) {
	// 省略....
}

func maybePruneBranches(ctx *OptimizerContext, expr ast.NavigableExpr) bool {
	call := expr.AsCall()
	args := call.Args()
	switch call.FunctionName() {
	case operators.LogicalAnd, operators.LogicalOr:
		return maybeShortcircuitLogic(ctx, call.FunctionName(), args, expr)
	case operators.Conditional:
		cond := args[0]
		truthy := args[1]
		falsy := args[2]
		if cond.Kind() != ast.LiteralKind {
			return false
		}
		if cond.AsLiteral() == types.True {
			ctx.UpdateExpr(expr, truthy)
		} else {
			ctx.UpdateExpr(expr, falsy)
		}
		return true
	case operators.In:
		haystack := args[1]
		if haystack.Kind() == ast.ListKind && haystack.AsList().Size() == 0 {
			ctx.UpdateExpr(expr, ctx.NewLiteral(types.False))
			return true
		}
		needle := args[0]
		if needle.Kind() == ast.LiteralKind && haystack.Kind() == ast.ListKind {
			needleValue := needle.AsLiteral()
			list := haystack.AsList()
			for _, e := range list.Elements() {
				if e.Kind() == ast.LiteralKind && e.AsLiteral().Equal(needleValue) == types.True {
					ctx.UpdateExpr(expr, ctx.NewLiteral(types.True))
					return true
				}
			}
		}
	}
	return false
}

6. 小结

因为想要增强词法解析能力,花时间去学习了一下cel-go,发现cel-go是非常强大的,虽然自己只是学习了一些使用的基本功能,要想把cel-go学好,还是需要去看更多的cel-go的源码。

这里主要是针对cel-go的基本功能进行了学习,让自己可以通过cel-go构建出声明了变量、函数、自定义函数等的环境,然后将自己需要实现功能的逻辑变成表达式,利用cel-go的编译和程序功能生成AST抽象语法树,最后将定义变量的数据送入到环境评估器中,得到最终的表达式结果。

7. 参考

相关推荐
真的想上岸啊9 分钟前
学习51单片机02
嵌入式硬件·学习·51单片机
小刘要努力呀!13 分钟前
嵌入式开发学习(第二阶段 C语言基础)
c语言·学习·算法
圈圈编码1 小时前
MVVM框架
android·学习·kotlin
全栈派森2 小时前
云存储最佳实践
后端·python·程序人生·flask
CircleMouse2 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
獨枭3 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架3 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱3 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
关于不上作者榜就原神启动那件事3 小时前
Java基础学习
java·开发语言·学习
☞无能盖世♛逞何英雄☜3 小时前
Flask框架搭建
后端·python·flask