基于 Go 的内置 Parser 打造轻量级规则引擎

在公司内见到无数的人在前仆后继地造规则引擎,起因比较简单,drools 之类的东西是 Java 生态的东西,与 Go 血缘不合,商业规则引擎又大多超重量级,从零开始建设的系统使用起来有很高的学习成本。刚好可能也不是很想写 CRUD,几个人一拍即合,所以就又有了造轮子的师出之名。

要造一个规则引擎,说难实际上也不难。程序员们这时候捡起了学生时代的编译原理书,抄起递归下降、 lex/yacc 或者再先进一点的 antlr 之类的 parser generator 就搞了起来。造的时候说不定还发现噢噢,大多数 parser generator 还有不支持左递归的问题,然后按照它支持的文法写出的 parser 需要自己处理计算表达式的左结合问题,嗯,非常有成就感,不知道比 CRUD 高到哪里去了。

不过多久就写出了一个谁也不是很好看懂的新轮子。

实际上要那么费劲吗?显然是不用的。被很多人选择性忽略的事实是,Go 的 parser api 是直接暴露给用户的。可能接下来你已经知道我要说什么了。

对的,你可以直接使用 Go 的内置 parser 库完成上面一个基本规则引擎的框架。从功能上来讲,规则引擎的基本就是一个 bool 表达式的解析和求值过程。bool 表达式是啥呢?很简单:

   |--bool 表达式--|
if a == 1 && b == 2 {
   // do your business
}

你每天都在写的无聊透顶的 if else 就是各种 bool 表达式啊。你别看他无聊,没有 bool 表达式的话,任何程序都没有办法顺利地组织其逻辑,也就没有什么 control flow 一说了。

先写一个简单的 demo,来 parse 并打印上面代码中的 a == 1 && b == 2:

package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
)

func main() {
	expr := `a == 1 && b == 2`
	fset := token.NewFileSet()
	exprAst, err := parser.ParseExpr(expr)
	if err != nil {
		fmt.Println(err)
		return
	}

	ast.Print(fset, exprAst)
}

凑合看看,bool 逻辑一般解析后就是最最简单的 AST:

     0  *ast.BinaryExpr {
     1  .  X: *ast.BinaryExpr {
     2  .  .  X: *ast.Ident {
     3  .  .  .  NamePos: -
     4  .  .  .  Name: "a"
     5  .  .  .  Obj: *ast.Object {
     6  .  .  .  .  Kind: bad
     7  .  .  .  .  Name: ""
     8  .  .  .  }
     9  .  .  }
    10  .  .  OpPos: -
    11  .  .  Op: ==
    12  .  .  Y: *ast.BasicLit {
    13  .  .  .  ValuePos: -
    14  .  .  .  Kind: INT
    15  .  .  .  Value: "1"
    16  .  .  }
    17  .  }
    18  .  OpPos: -
    19  .  Op: &&
    20  .  Y: *ast.BinaryExpr {
    21  .  .  X: *ast.Ident {
    22  .  .  .  NamePos: -
    23  .  .  .  Name: "b"
    24  .  .  .  Obj: *(obj @ 5)
    25  .  .  }
    26  .  .  OpPos: -
    27  .  .  Op: ==
    28  .  .  Y: *ast.BasicLit {
    29  .  .  .  ValuePos: -
    30  .  .  .  Kind: INT
    31  .  .  .  Value: "2"
    32  .  .  }
    33  .  }
    34  }

这种 AST 实在太常见了以致于我都不是很想解释。。。大多数存储系统的查询 DSL 部分都会有 bool 表达式的痕迹,比如 Elasticsearch,SQL 语句的 where 等等,两年前我曾经造过一个把 SQL 和 Elasticsearch 的 DSL 互相转换的轮子,当时还写了篇文章讲了讲原理:https://elasticsearch.cn/article/114

Elasticsearch 在 7.0 的 xpack 中已经开始渐渐支持 SQL 功能了,所以这个轮子慢慢地也就变成了时代的眼泪。

眼泪归眼泪,这种“逻辑”上的“是”或者“否”的判断表达式,都是可以互相对应的,不管哪类的系统,谁设计的多么丑陋的 DSL,大抵上都是可以通过简单的 (field op value) and/or 连接并且有括号的基本表达式来表达的。为啥还有这么多乱七八糟的 DSL?我想了想,基本的原因有三个:

  1. 该系统的作者觉得普通的 bool 表达式扩展能力不强,自己造的 DSL 一定更牛逼
  2. 作者不是很会写基本的 bool 表达式的 parser。。。。
  3. 单纯的想要造一个轮子。

仔细看看,主观的因素两个,客观的因素是 bool 表达式扩展能力不强。嗯,我们来想想,比较典型的 bool 表达式场景:SQL 的表达能力不强吗?普通需求满足不了时,SQL 是怎么进行扩展的呢?

答案其实也挺简单,SQL 的功能可以通过函数来进行扩展,比如 SQL 里支持 group_concat、date_sub 之类的函数,也支持一些简单的 ETL 功能,比如 from_unixtime,unix_timestamp 等等。这一点,在本文开头提出的使用 Go 内部 parser 来实现的规则引擎中可以支持么?

显然你在 Go 里也写过这种 if 判断里有函数调用的逻辑:

func main() {
	expr := `a == 1 && b == 2 && in_array(c, []int{1,2,3,4})`
	fset := token.NewFileSet()
	exprAst, err := parser.ParseExpr(expr)
	if err != nil {
		fmt.Println(err)
		return
	}

	ast.Print(fset, exprAst)
}

输出内容:

     0  *ast.BinaryExpr {
     1  .  X: *ast.BinaryExpr {
     2  .  .  X: *ast.BinaryExpr {
     3  .  .  .  X: *ast.Ident {
     4  .  .  .  .  NamePos: -
     5  .  .  .  .  Name: "a"
     6  .  .  .  .  Obj: *ast.Object {
     7  .  .  .  .  .  Kind: bad
     8  .  .  .  .  .  Name: ""
     9  .  .  .  .  }
    10  .  .  .  }
    11  .  .  .  OpPos: -
    12  .  .  .  Op: ==
    13  .  .  .  Y: *ast.BasicLit {
    14  .  .  .  .  ValuePos: -
    15  .  .  .  .  Kind: INT
    16  .  .  .  .  Value: "1"
    17  .  .  .  }
    18  .  .  }
    19  .  .  OpPos: -
    20  .  .  Op: &&
    21  .  .  Y: *ast.BinaryExpr {
    22  .  .  .  X: *ast.Ident {
    23  .  .  .  .  NamePos: -
    24  .  .  .  .  Name: "b"
    25  .  .  .  .  Obj: *(obj @ 6)
    26  .  .  .  }
    27  .  .  .  OpPos: -
    28  .  .  .  Op: ==
    29  .  .  .  Y: *ast.BasicLit {
    30  .  .  .  .  ValuePos: -
    31  .  .  .  .  Kind: INT
    32  .  .  .  .  Value: "2"
    33  .  .  .  }
    34  .  .  }
    35  .  }
    36  .  OpPos: -
    37  .  Op: &&
    38  .  Y: *ast.CallExpr {
    39  .  .  Fun: *ast.Ident {
    40  .  .  .  NamePos: -
    41  .  .  .  Name: "in_array"
    42  .  .  .  Obj: *(obj @ 6)
    43  .  .  }
    44  .  .  Lparen: -
    45  .  .  Args: []ast.Expr (len = 2) {
    46  .  .  .  0: *ast.Ident {
    47  .  .  .  .  NamePos: -
    48  .  .  .  .  Name: "c"
    49  .  .  .  .  Obj: *(obj @ 6)
    50  .  .  .  }
    51  .  .  .  1: *ast.CompositeLit {
    52  .  .  .  .  Type: *ast.ArrayType {
    53  .  .  .  .  .  Lbrack: -
    54  .  .  .  .  .  Elt: *ast.Ident {
    55  .  .  .  .  .  .  NamePos: -
    56  .  .  .  .  .  .  Name: "int"
    57  .  .  .  .  .  .  Obj: *(obj @ 6)
    58  .  .  .  .  .  }
    59  .  .  .  .  }
    60  .  .  .  .  Lbrace: -
    61  .  .  .  .  Elts: []ast.Expr (len = 4) {
    62  .  .  .  .  .  0: *ast.BasicLit {
    63  .  .  .  .  .  .  ValuePos: -
    64  .  .  .  .  .  .  Kind: INT
    65  .  .  .  .  .  .  Value: "1"
    66  .  .  .  .  .  }
    67  .  .  .  .  .  1: *ast.BasicLit {
    68  .  .  .  .  .  .  ValuePos: -
    69  .  .  .  .  .  .  Kind: INT
    70  .  .  .  .  .  .  Value: "2"
    71  .  .  .  .  .  }
    72  .  .  .  .  .  2: *ast.BasicLit {
    73  .  .  .  .  .  .  ValuePos: -
    74  .  .  .  .  .  .  Kind: INT
    75  .  .  .  .  .  .  Value: "3"
    76  .  .  .  .  .  }
    77  .  .  .  .  .  3: *ast.BasicLit {
    78  .  .  .  .  .  .  ValuePos: -
    79  .  .  .  .  .  .  Kind: INT
    80  .  .  .  .  .  .  Value: "4"
    81  .  .  .  .  .  }
    82  .  .  .  .  }
    83  .  .  .  .  Rbrace: -
    84  .  .  .  .  Incomplete: false
    85  .  .  .  }
    86  .  .  }
    87  .  .  Ellipsis: -
    88  .  .  Rparen: -
    89  .  }
    90  }

有了这些东西,在 parser 层面你要做的事情其实基本也就没啥了。只不过需要简单查查 Go 的语言 spec,看看 expression 到底支持哪些语法。

实在不是不得已,根本没有必要造新的 DSL 和 parser。况且在一套生态里做出另一种奇怪的语言来,你不觉得别扭吗?

当然,说归说,业务系统中的 DSL 这种东西一般是给程序员来用的,或者可以用在两个系统之间做交互,如果规则引擎的需求方是公司的运营人员或者业务人员,那么显然用 DSL 是不合适的。更好的做法是为他们提供一套 GUI,然后把用户点选的选项存储下来。这时候用 json 更为合适,也不需要你去写 parser 了。

你说你想自己造一个 json parser?

呵呵。

除了构造 AST,规则引擎剩下的工作就是在遍历 AST 的时候,能返回 true 或者 false。其实就是简单的 DFS,应届生都会写。

Xargin

Xargin

If you don't keep moving, you'll quickly fall behind
Beijing