基于 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,应届生都会写。