写在前面
注:与go语言中用法不同或者增加的都予以注明
? :
(与其他语言中用法一致, eg:逻辑表达式 ?表达式1 : 表达式2)(注意表达式1与表达式2的执行结果类型可以不同)||
、&&
==
、!=
、<
、<=
、>
、>=
、in
(可以类比sql中的in, 用法例子: 1 in [1, 2, 3],其将返回true)+
、-
、*
、/
、%
、**
(幂)|
、^
(按位异或,注意在引擎中其不能作为按位取反, 按位取反为~
)、&
、&^
、<<
、>>
++
, --
, 注意自增与自减只支持前缀, eg: ++2, 而2++是不被允许的, 因为表达式引擎只是运行一个表达式, 后缀的自增自减毫无意义!
(逻辑反)、~
(位取反, 注意不是^
)、-
(负)?
与 :
,注意这其实是2个二元操作符组成的三元操作符,它们优先级最低||
&&
==
、!=
、<
、<=
、>
、>=
、in
+
、-
、 |
、^
*
、/
、%
、&
、&^
、<<
、>>
**
注:使用函数时, 如果函数中返回数值,需要明确的为float64类型(这一点当然也可以改为支持任何数值类型,但是笔者觉得没必要)
变量在表达式执行时以map[string]any形式传入
注:
- 变量的类型不为函数参数的时候,只能传第二点中所述的三种类型(数值、布尔、字符串),且传入的参数为数值类型时, 类型需要为float64
- 变量作为函数的参数时,可以传入任意类型
非函数参数的变量不支持结构体访问(eg:user.a),原因是如果支持,将要利用反射,性能将降低,笔者的应用场景需要较高的性能
注意变量在运算过程中是否改变, 不同使用场景有不同结果:
最后, 附简单的使用例子:
// 第一个参数为表达式字符串,第二个参数为执行时期是否进行类型检查, 当确定自己使用无误测试环境测试无问题后可设置为false加快执行
// 第三个参数为函数, 将在下一节介绍
exp, err := goexpression.NewExpression("a == 1 && b == true", true, nil)
if err != nil {
// ...
}
ret = exp.Bool(map[string]any{
"a": float64(1), // 非函数参数,数值类型一律为 float64
"b": true,
})
// ...
函数的类型定义如下:
type Function func(params ...any) (any, error)
简单的使用例子,例子中忽略错误与类型转换错误与参数个数检查等错误,由例子可以看出,函数支持嵌套
exp, _ := NewExpression("age(get(ctx)) == 1", true, map[string]Function{
"age": func(params ...any) (any, error) {
return params[0].(float64), nil
},
"get": func(params ...any) (any, error) {
ctx := params[0].(context.Context)
return float64(ctx.Value("age").(int)), nil
},
})
ret = exp.Bool(map[string]any{
"ctx": context.WithValue(context.Background(), "age", 1),
})
注:举的例子是实际用法经过处理与精简,不代表业务中一模一样的用法,比如真实的表达式长达几十个子布尔表达式
对于一个用户,当满足其a字段值>=70 且<80,并且b字段值>14时有50%的概率进行一些操作
注:
- 如果只有这一个需求,那么也没必要搞个表达式引擎了,但是如果针对不同用户有不同的表达式且表达式的值易变,才适用。核心点就是多且易变
代码实现:
func init() {
rand.Seed(time.Now().Unix())
}
type User struct {
A int
B int
}
func main() {
// 实际使用中,表达式的值肯定是远程配置的
exp, _ := goexpression.NewExpression(`IntUserA(user) >= 70 && IntUserA(user) < 80 &&
IntUserB(user) > 14 && IntRand() < 50`, true, map[string]goexpression.Function{
"IntUserA": IntUserA,
"IntUserB": IntUserB,
"IntRand": IntRand,
})
// 显然,业务中运行的时候,User肯定是个运行时传入的
ret, _ := exp.Bool(map[string]any{
"user": User{A: 71, B: 15},
})
fmt.Println(ret)
}
func IntUserA(params ...any) (any, error) {
user := params[0].(User)
return float64(user.A), nil
}
func IntUserB(params ...any) (any, error) {
user := params[0].(User)
return float64(user.B), nil
}
func IntRand(params ...any) (any, error) {
return float64(rand.Intn(100)), nil
}
还有一点笔者需要强调,实际使用中A字段与B字段可能不是User结构体中自带的,它们可能是由C、D、E等字段计算得到的,比如计算用户两个切片特征的交集大小,我们使用函数传参而不使用结构体不但可以省略反射,还能让计算延迟,避免不必要的计算(用户的表达式的中包含的函数才会**可能(由于逻辑表达式短路不一定会执行)**运行,eg:UserA(user) > 10 && UserB(user) == 1,当UserA(user) > 10 为假时UserB(user)不执行,用结构体需要提前计算所有值
当然,例子中用了值传递,User结构体很大时将有影响(较小的时候可以自行测试,可能值传递效率还更高),我们可以将其放入ctx中等方法
实际使用中,由于编译较为耗时,我们可以使用一个全局map(选用并发安全map或者读写锁 + map等等),key为string格式的表达式,value为编译好的表达式。这样只会在远程配置变动的时候会有一定量的编译,当有一个协程编译好并放入map后,后续的所有协程都将从map中取用
首先,编译原理不熟悉的同学可以看笔者之前的两篇文章:简析Go词法分析、简析Go语法分析
我们需要实现的只是一个功能不完备的表达式执行,所以代码其实很简单,笔者主要强调其中的一些地方
// 当词法分析遇到字符,可能是:关键字,函数名,变量名
if unicode.IsLetter(char) {
name := l.letters(char) // 获取连续字符
if ok := l.isKeyLetter(name); ok { // 关键字优先级最大, 把true,in这些看做关键字
continue
}
if f, ok := functions[name]; ok { // 注册的函数
l.addToken(f, Func, false)
continue
}
l.addToken(name, Var, false) // 最后才是变量
continue
}
语法分析后为一颗抽象语法树,树节点结构体如下:
type astNode struct {
left, right *astNode // 左右子节点
op Operator // 节点是操作符时记录操作符,执行优化时用到
opFunc opFunc // 节点执行函数
typeCheck typeCheck // 类型检查函数
}
type opFunc func(l any, r any, params map[string]any) (any, error)
type typeCheck func(left, right any) bool
以 1 + 1这个表达式来展示下解析后的抽象语法树
其中,三个节点的执行函数为:
// + 节点的执行函数
func addFunc(left, right any, _ map[string]any) (any, error) {
if IsString(left) && IsString(right) {
return left.(string) + right.(string), nil
}
return left.(float64) + right.(float64), nil
}
// 语法解析的时候传入lit为1,从而返回两个1的执行函数
func makeLitFunc(lit any) opFunc {
return func(l any, r any, params map[string]any) (any, error) {
return lit, nil
}
}
+节点的类型检查函数为如下(代表左右子节点调用计算函数的返回值必须满足同时为float64或者同时为string),显然的,字面量不需要类型检查函数:
func canCmp(left, right any) bool {
return IsFloat64(left) && IsFloat64(right) || IsString(left) && IsString(right)
}
显然的只有操作符需要类型检查函数
比较特殊的三个Token的执行函数如下:
// 获取变量Token的执行函数,如 a + 1,中a获取执行函数时调用makeVarFunc("a")
func makeVarFunc(name string) opFunc {
return func(l, r any, params map[string]any) (any, error) {
ret, ok := params[name]
if !ok {
return nil, fmt.Errorf("execute: %s param not in the passed parameter list", name)
}
return ret, nil
}
}
// 获取函数Token的执行函数
func makeFuncFunc(function Function) opFunc {
return func(left, right any, params map[string]any) (any, error) {
if right == nil {
return function()
}
switch right.(type) {
case []any:
return function(right.([]any)...)
default:
return function(right)
}
}
}
// ","Token 执行函数,函数参数或者数组列表需要用到 "," (如[1, 2, 3]这种)
func commaFunc(left, right any, _ map[string]any) (any, error) {
var ret []any
switch left.(type) {
case []any:
ret = append(left.([]any), right)
default:
ret = []any{left, right}
}
return ret, nil
}
比较特殊的是对于类似于 [1, 2, 3],funcName(4, 5, 6)这两者的处理,有兴趣的同学可以打开源码查看。其中 [1, 2, 3]与(4, 5, 6)都将被解析为一颗树(见下图),这个树执行结果为数组(上面提到了commaFunc这个函数),(4, 5, 6)这颗树将作为funcName这个函数节点的右子树。 [1, 2, 3]可能作为单独的一个表达式,即一颗树或者作为in操作符的右子树
主要执行加速在于逻辑表达式的熔断与类型检查可关闭(初始化表达式的时候的第二个参数)
func (e *Expression) executeASTNode(root *astNode, params map[string]any) (any, error) {
if root == nil {
return nil, nil
}
var (
left, right any
err error
)
// 先调用左子节点,计算左子树的最终计算结果
if left, err = e.executeASTNode(root.left, params); err != nil {
return nil, err
}
// 左边结果可能可以直接决定结果的,eg:true || 2 == 3, 这个2 == 3就没必要执行了
switch root.op {
case AndAnd: // &&
if left == false {
return false, nil
}
case OrOr: // ||
if left == true {
return true, nil
}
case TernaryT: // ?
if left == false {
return nil, nil
}
case TernaryF: // :
if left != nil {
return left, nil
}
default:
}
// 计算右子树计算结果
if right, err = e.executeASTNode(root.right, params); err != nil {
return nil, err
}
// 如果开启了类型检查, 检查左右子树计算结果是否符合预期
if e.NeedCheck && root.typeCheck != nil {
if !root.typeCheck(left, right) {
return nil, fmt.Errorf("execute: type check error")
}
}
// 调用自身的计算函数并返回结果
return root.opFunc(left, right, params)
}