这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
1. 规则引擎是什么
可以理解为是解释器模式的一种实现。
所谓的解释器模式,就是在一门语言里内嵌了一门语言。比如我们可以基于go的语法,生成一门语言的解释器。
解释器默认常用于框架中,比如jsp的表达式语言,或者spring的spel。这类表达式语言,其实就是经过了解释器的解释。在gorm里,也有类似的表达式语言。这类解释器仅解释一条表达式的执行,所以虽然涉及到一门解释器的编写,但实际上占用量较少。
而规则引擎也具备如下特点
- 嵌入在应用程序中,内嵌式的解释器,当然也可以作为中间件,以提供给多个业务使用。
- 和常规的解释器具有类似的功能:比如python中的一条语句 ==> 规则引擎可以接受输入(一条语句),解释并输出。
- 开发人员开发规则引擎,业务人员修改引擎的决策,使得开发人员无需对频繁修改的业务要求进行修改源码(满足ocp)
- 我们的规则引擎类似python解释器,将文本直接执行(详见eval方法),这是动态语言具备的基本特点。至于如何实现解释器,详见sicp(??有空看下)我们基于go完全可以实现纯解释文本的解释器,但是python本身如何实现?用python实现python解释器?
如果不使用规则引擎,如何修改策略?
情景:双十一活动结束,产品:把消费积分活动的规则修改一下 ,开发 ==> 修改源代码,修改业务逻辑(全部下线再修改,没个半天拿不下来) ==> 结果:黑产薅羊毛,数据不一致等。
双十二又要来了,产品:把消费积分活动的规则再修改下。开发:???
使用规则引擎以后
- 如果新加入规则,将新规则存储在规则数据库(缓存)中。
- 如果要更新规则,将缓存失效后,更新规则数据库中的规则
- 如果要使规则失效,则删除规则数据库中的规则
业务逻辑和 规则引擎解耦。业务逻辑提供输入,规则引擎从数据库中读到规则表达式,规则引擎解释器能够读懂字符串形式的表达式所表达的意思,并把输入值带入表达式,并进行执行,返回结果。
这样,不需要每一次都修改代码
规则引擎的功能就是快速的更新策略,这样可以防止黑产薅羊毛,也可以通过策略快速拦截黑产性质的请求,黑产携带的指纹信息被采集后,经过规则引擎判断会被拦截,而规则引擎使得规则的更新变得很快。
可以快速运用运营提出的策略,使得用户体验很高。
2. 编译原理
规则引擎的主要功能就是
- 读取规则
- 构造抽象语法树
- 读取输入参数
- 参数注入,类型检查,执行,获取结果
- 返回结果
所以,规则引擎需要完成的就是 理解规则并执行规则。而理解并执行规则,也是规则引擎的核心,而规则本质是字符串表达式,我们的规则引擎就是:能理解和执行一个字符串(go内挂eval())。
2.1 词法分析
词法分析可以把 表达式分割为一个个的词法单元
Token在这一阶段,一切有意义的符号,包括字符串字面量,变量,逻辑符号等,都会被解析为Token。
那么解析Token的过程,实际上是一个 有限自动机。
有限自动机 :
状态有限(无限自动机就是有理论上无限的状态,比如一个web服务器,不断的循环监听客户端请求)。由于表达式长度是有限的,因此解析表达式的过程,自然构成一个有限自动机。
有唯一确定的下一状态。比如当前状态读到了字母
p,下一状态读到字母r,当读到字符>时,则转移到状态s3 ... 。总之经过有限的状态转移,可以把表达式转换为token集合
实际上的词法分析,就是一个字符串的扫描器,这个扫描器会按照我们之前指定好的状态转移规则,对字符串进行扫描
// 扫描器对象,扫描规则字符串source,记录扫描位置索引和扫描位置处的字符 type Scanner struct { source []rune // 规则表达式字符串 position int // 遍历规则表达式过程中的位置 length int // 规则表达式字符串, 用于判断是否扫描结束 ch rune // position 位置对应的字符 }
// 进行一次扫描,获得一个有效token。
func (scanner *Scanner) Scan() (token.Token, error) {
var tok token.Token
var err error
// 清除空白符,我们这个词法扫描工具不扫空格
scanner.skipWhitespace()
// 记录上次扫描的位置,开扫
tok.Position = scanner.position
switch ch := scanner.cur(); {
// 判断当前字符是啥,由于我们制定了字符的转移规则,比如如果当前是eof,直接退出状态机。如果是字符,那么调用scanIdentifier(),这个方法的实现很简单,就是扫描字符串,直到扫到了不符合身为一个标识符的字符(比如扫到了加号),那么退出扫描。同样,我们要判断扫到的东西是标识符还是字面量。我们这个系统就两个字面量:true,false。对于正规的编译器,有很多字面量,对应语言中的关键字,比如while这些。我们这个解释器就只负责解释一个表达式,自然没那么多要求。
case isEof(ch):
tok.Kind = token.Eof
case isLetter(ch):
// if the first character is letter, this token must be an Identifier or BoolLiteral or otherwise
literal := scanner.scanIdentifier()
tok.Kind = token.Lookup(literal)
tok.Value = literal
// boolean?
if tok.Kind == token.BoolLiteral {
tok.Value = parseBool(literal)
}
// 解析是不是合法数字token
case isDecimal(ch) || isDot(ch): // 123 123.4 .678 7.7.7
// Decimal,
// ...
if err != nil {
errorMsg := fmt.Sprintf("Unable to compiler numeric value '%v'", literal)
return tok, errors.New(errorMsg)
}
// 其他token(一般是操作符),如果是(& |) 那么要先看一下,下一个字符是不是还是(& |),对于(> < ! =)这些符号,同样要判断下一个字符是啥,因为>=这类操作符有两个字符组成,因此我们要先看一下当前字符下一个字符和当前字符是否构成一个有效token。
default:
switch ch {
// ...
default:
tok.Kind = token.Illegal
tok.Value = string(ch)
errMsg := fmt.Sprintf("the scan found an illegal character '%v'", ch)
return tok, errors.New(errMsg)
}
}
return tok, err
}
经过词法分析器这个有限状态机,我们提取出了
有效的标识符(对应变量,也就是规则引擎的输入)
字符串字面量(true/false)。在正规的编译器设计,要预留关键字。
有效整型/浮点
有效的操作符(运算操作符,比较操作符,逻辑操作符)
在进行参数注入和类型检查之前,可以先对表达式语法进行校验。主要检查
括号是否匹配
操作符和操作数是否按照正确的状态转移出现(比如已经读到了一个
+,那下一个读取到的肯定不是比较运算符或者数字运算符等,这和我们上面规定的一样)// 以Illegal类型的符号为例,规定了有效的状态转移集。 Illegal: { isEOF: false, validNextKinds: []Kind{ Identifier, // variables BoolLiteral, // true, false IntegerLiteral, // 12345 FloatLiteral, // 123.45 StringLiteral, // "abc" OpenParen, // ( Addition, // + Subtraction, // - Not, // ! }, },
2.2 语法分析
语法分析是根据词法分析获得到的token,建立抽象语法树的过程。
为了建立抽象语法树,我们必须构建语法规则。就比如英语里的句型,语法,英语如果需要构成句子,那么就需要语法,同理,token如果希望构成一个表达式单元,就需要语法。
与自然语言不同的是,表达式语言的目的是为了执行,而自然语言就是为了表达语义。我们的词法分析就是把自然语言分析为可以执行的语法树。
那么编程语言为什么不用自然语言,而是用一种上下文无关的语法?编程语言如果实现了上下文相关,那么就实现了自然语言,也就真达成了人工智能。虽然现代的编程语言,已经具有了一定的人类语言特性,比如while,if这样的,很像人类语言,但是最大的特征就是不具备上下文:if语句仅在块作用域生效,这个if语句块可以出现在任何位置。但是人类语言就不一样,如果同一句话,出现在不同的上下文,那么意思就不一样。而编程语言,一个while循环,一个赋值语句,在哪里其实都一样(指表达的业务逻辑)
同时,如果编程语言具有了上下文,那么理解编程语言将有歧义。这是没必要的
上下文无关语法G:终结符集合T + 非终结符集合N + 产生式集合P + 起始符号S
G由T、N、S和P组成,由语法G推导出来的所有句子的集合称为G语言!
终结符: 组成串的基本符号。可以理解为词法分析器产生的token集合。比如
+Id()等非终结符: 表示token的的集合的语法变量。比如
stmtvarDecl等等非终结符
a = 3经过词法分析后,分析为终结符集合["a", "=", "3"]产生式:由终结符和非终结符推导出的一种语法。
例:G =( { id, +, *, (, ) }, {E}, P, E ) P = { E → E + E , E → E * E , E → ( E ) , E → id } 简写: E → E + E | E * E | ( E ) | id// 由语法 G推导出所有语句的集合,称为G语言。表达式语言,就是说G语言推导出的集合只有表达式,没有语句块这些。 start:blockStmts ; //起始 block : '{' blockStmts '}' ; //语句块 blockStmts : stmt* ; //语句块中的语句 stmt = varDecl | expStmt | returnStmt | block; //语句 varDecl : type Id varInitializer? ';' ; //变量声明 type : Int | Long ; //类型 varInitializer : '=' exp ; //变量初始化 expStmt : exp ';' ; //表达式语句 returnStmt : Return exp ';' ; //return语句 exp : add ; //表达式 add : add '+' mul | mul; //加法表达式 mul : mul '*' pri | pri; //乘法表达式 pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
2.2.1 描述语法的巴特斯范式
巴特斯范式:递归定义,包含定义。优先级层级。括号优先级。
特点:
递归定义。即自己定义自己。比如mul : mul '*' pri 。
包含定义。即上层定义包含下层,比如mul : pri 。
如何体现优先级?
add : add '+' mul | mul 我们发现,无论采取哪种方式,欲得到一个add表达式,必须依赖一个mul表达式,也就是先有mul再有add,这样优先级就体现出来了。而由于递归定义,使得表达式再长,都会被解析为树的结构(建树的过程就是一个递归)
我们的规则引擎的语法为
- 标识符类型为bool值,整型值,浮点值,字符串值,整型值
- 支持基本的运算 + - * / %
- 支持比较操作
- 支持逻辑操作
- 优先级为 标识符/括号 > */% > +- > 比较 > not > and > or
- 左结合
expr: logOr EOF; logOr: logOr '||' logAnd | logAnd; logAnd: logAnd '&&' logNot | logNot; logNot: '!' logNot | cmp; cmp: cmp '>' add | cmp '>=' add | cmp '<' add | cmp '<=' add | cmp '==' add | cmp '!=' add | add; add: add '+' mul | add '-' mul | mul; mul: mul '*' pri | mul '/' pri | mul '%' pri | pri; pri: BooleanLiteral|IntegerLiteral|FloatLiteral|StringLiteral|Identifier|'('expr')';优先级如何体现?
validKindsToSymbols是一个map,包含当前优先级的token类型type precedence struct { validKindsToSymbols map[token.Kind]executor.Symbol // 当前优先级的token类型 nextPrecedence *precedence // 更高优先级的 planner planner }
- 每个产生式都是一个子树,每个子树对应一个解析器(
precedence.planer)- 叶子节点是终结符,非叶子节点则是非终结符
2.2.2 建立抽象语法树的递归下降过程
- 按优先级匹配,依次匹配。
- 如果找到匹配的表达式,直接匹配,否则向上查找
- 整体流程类似一个后序遍历(类似根据遍历结果反向构建一棵树)
varDecl -> types Id varInitializer ';'
varInitializer -> '=' exp
varInitializer -> ε
exp -> add
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
pri -> IntLiteral
pri -> Id
pri -> ( exp )
匹配一个数据类型(types) // types
匹配一个标识符(Id),作为变量名称 // Id
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则: // varInitializer(下降)
匹配一个等号
匹配一个表达式(在这个步骤会导致多层下降:exp->add->mul->pri->IntLiteral)
创建一个varInitializer对应的AST节点并返回
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。 // return to Id
匹配一个分号
创建一个varDecl对应的AST节点并返回
语法树的结构
- 正常的二元运算符具有左右子树
- 单目运算符只有右子树
- 括号成对出现,并且括号结点只有右子树。
何为递归下降
从最低优先级开始,判断当前token的类型。不断的下降,直到匹配到优先级的类型。
匹配成功后,将调用planner方法进行树的构建。每个类型的token都有不同的planner实现。
那么如何表达这个"下降的优先级"呢?我们可以用一个单向链表
// 这个precedence为单向链表的结点,通过nextPrecedence组织为一个优先级列表 type precedence struct { validKindsToSymbols map[token.Kind]executor.Symbol // 当前优先级的token类型 nextPrecedence *precedence // 更高优先级的 planner planner }
// 可以看下这个函数咋体现递归下降的 这个算法很像回溯法
// 这个方法的入口为 p = lowestPrecedence 开始调用
// 分析递归时,看到递归函数不要面向过程思考,也就是跟着递归函数进去,而是面向结果。思考当前调用 向子调用获取了什么内容
// 递归下降:以表达式 a == 1 && b == 2 为例,展示递归下降过程
// 1. 从产生式的最高层(expr: logOr EOF;)开始匹配
// 2. 发现logOr,并非最高优先级,继续下降,logAnd ... 继续下降,匹配到multiplicative
// 3. 目前p = multiplicative,进入分支p.planner != nil
// 4. 执行p.planner(builder, p) ,此时的planner被赋值为函数planValue,这个函数通过builder对象,读取当前token "a",并判断这是一个Identifier,于是返回一个executor.NewNode(nil, nil, executor.VALUE, tok.Value)对象,然后赋值为leftNode
// 5. 目前已经确定了左子树,按照递归下降:开始构建右子树
// 6. 当前的tokens流已经读出了a,下一步扫描tokens流的其他部分。下一步扫描出一个 "==",然而当前处于multiplicative优先级,合法的字符只有"*","%", "/",我们的"=="不存在于multiplicative优先级,所以直接退出,推出前,由于我们已经把"=="读出来了,所以要通过rewind再给人家放回去。
// 7. 退出后,将"a" 返回给上一层,上一层是additive优先级,这里相当于上升了一层
// 8. "==" 仍然不存在于 additive优先级内的任何一个符号,所以再次退出,继续上升一层
// 9. 来到comparator层,"=="终于存在于comparator层了,于是找到符号"=="
// 10. 右子树的构建重复上面的流程,我们要面向结果来看,右子树的构建结果就是对[1, "&&", "b", "==","2"] 这个tokens流来了一次
// 11. 目前处于comparator层,读到字符"1",进入递归下降 com -> add -> mul -> planValue,此时leftNode="1"
// 12. 读到符号"&&",但是目前处于mul层,直接退出,返回"1",此时"=="这个结点获取到了左右子树,构建出
/*
==
/ \
a 1
*/
// 这棵树
// 13. 继续上升,mul -> add -> com -> logNot -> logAnd(上升过程,也是结点的返回过程,在mul层构建出的结点,会返回给logAnd层的结点),匹配到符号"&&",随着上升过程,我们步骤12构造出的树,就传给了"&&"结点作为左子树,同理,对["b", "==", "2"]再来一次 1-12的流程,得到子树
/*
==
/ \
b 2
*/
// 14. 最终,"&&"作为根节点,左子树为12得到的树,右子树为13的到的树。构建完毕
// 整个过程就是对构建好的树来了一次后序遍历。先递归下降,找到基础表达式,然后匹配下一个符号,并不断的向上调整目前所处的优先级,比如目前处于mul优先级,但是下一个符号是"||",在这里,由于不断的读出了符号"||",当发现符号和优先级不匹配时,应当进行回溯,把符号放回去,然后要把优先级递归上升(实际上就是递归返回,在mul优先级构造出的子树会随之返回,作为logOr优先级的左/右子树) 。不断重复上述流程,完成递归下降。
// 这个递归还是面向过程的好分析一点,对于树状dp还是用面向结果分析
// 实际上,分析递归过程时,如果递归图是一个有限状态机,那么面向过程(枚举状态)还是很方便的。实在不行就人为限制问题规模,减少状态转移
// 1. 算法是贪心的,一次表达式的匹配会尽量匹配同样符合,但是多的内容(递归就是这样的,能匹配则匹配)
// 2. 回溯:如果发现匹配不得,会取消匹配
// 3. 算法很像正则表达式,我们的表达式就是正则
// 面向结果来看
// 回溯:第二次递归不能读"1",继续往下读
// 递归问题一定要面向结果来看,要不必然懵逼
func (p *precedence) plan(builder *Builder) (*executor.Node, error) {
var err error
var leftNode, rightNode *executor.Node
// 如果有更高优先级,则递归下降 ==> 就在这里体现了递归下降
if p.nextPrecedence != nil {
leftNode, err = p.nextPrecedence.plan(builder) // recursive down
if err != nil {
return nil, err
}
} else if p.planner != nil {
// 这个方法读取当前符号的类型,并建立一个Node对象。
leftNode, err = p.planner(builder, p)
if err != nil {
return nil, err
}
}
// 目前已经确定了左子树 ==> 遍历剩下的符号
for builder.parser.hasNext() {
tok := builder.parser.next()
// 剩下一个EOF符号,解析到头
if tok.Kind.IsEof() {
break
}
// 这个函数调用,返回当前遍历到的符号,是否位于当前优先级,如果不,则上升一层。
symbol, exist := p.validKindsToSymbols[tok.Kind]
if !exist {
break
}
// 构建好右子树
rightNode, err = p.plan(builder) // 递归
if err != nil {
return nil, err
}
// 构建树根,并返回
node := executor.NewNode(leftNode, rightNode, symbol, nil)
return node, nil
}
// 符号遍历完了,回溯指针。
builder.parser.rewind() // tokens.index -= 1
return leftNode, nil
}
// 根据优先级创建语法树结点
func planValue(builder *Builder, curPre *precedence) (*executor.Node, error) {
if !builder.parser.hasNext() {
return nil, nil
}
tok := builder.parser.next()
switch tok.Kind {
case token.OpenParen:
// 最高优先级
ret, err := builder.Build()
if err != nil {
return nil, err
}
// case xxx, create 相应类型的node
}
}
2.3 语法树执行与类型检查
后序遍历AST 执行语法树