在公司内见到无数的人在前仆后继地造规则引擎,起因比较简单,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 一说了。
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?我想了想,基本的原因有三个:
- 该系统的作者觉得普通的 bool 表达式扩展能力不强,自己造的 DSL 一定更牛逼
- 作者不是很会写基本的 bool 表达式的 parser。。。。
- 单纯的想要造一个轮子。
仔细看看,主观的因素两个,客观的因素是 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,应届生都会写。