这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天
本课程重难点
重点
- 规则引擎的设计 。明确设计目标、完成步骤拆解、完成各部分状态机的详细设计
- 规则引擎的实现。基于项目工程完成词法分析、语法分析、抽象语法树的执行功能
难点
- 规则引擎的核心原理(理论)。词法分析、语法分析、类型检查、语法树执行
1.认识规则引擎
探究规则擎的由来、优点以及应用场景。简单了解规则引擎的组成和实现原理。
1.1 什么是规则引擎
1.1.1 规则引擎的理解
举个栗子
抖音商城要搞活动啦~ 活动期间用户购买相应的产品会获得商城积分!
过了几天,运营同学说,这个效果不太好,我们再改下规则,变成 100-200 元的赠送 20 ,200 - 500 元赠送 90,500-800 赠送 100.…如此类推一直到1w。
用 if else 解决不了,按照区间去判定:
然后后面产品又脑洞大开了,觉得这个条件不够精细化,还要根据商品的标签属性,用户标签来判断。如果是新用户就乘二,如果商品是xx活动的特卖商品,积分就加多 20
所以我们就想,能不能有这样一个系统,把一些条件,把它翻译成一些计算的规则,再把一些所需要的属性,比如价格或者标签之类的,一股脑的全给它放进去,系统它就会自动给你计算出来你这次所会获用户会获得的积分
这样一个系统,就是规则引擎实现的一个期望
1.1.2 规则引擎的定义
规则引擎是一种嵌入在应用程序中的组件,实现了将业务决策从应用程序代码中分离出来,并使用预定义的语义模块编写业务决策。接受数据输入,解释业务规则,并根据业务规则做出业务决策。
一个规则引擎的引入,他希望我们能够把业务的决策和用户的,也就是我们的程序本身是能够做一个解耦,这样我们会有一些效率上的提高,以及一些程序稳定性上的提高。
传统的服务发布,策略的修改的路径是由由业务人员提出我们要修改规则决策了,给到我们的开发同学就把代码写好之后,然后测试上线。这里的代码开发当然是一个非常繁琐,而且是一个重复的劳动,最后最终上线之后才能实现我们的业务逻辑。
引入规则引擎之后,我们的业务人员可以直接去修改规则和决策,直接把它修改了之后的结果给到规则引擎就可以了。
开发人员也是减少一些工作量,我们的开发人员就可以只负责来维护我们的规则引擎,这样也就是规则引擎在只需要得到一些规则和输入之后,他就可以自己去执行来实现一些业务的逻辑。
好处:
- 解决开发人员重复编码的问题
- 业务决策与服务本身解耦,提高服务的可维护性
- 缩短开发路径,提高效率
1.2 规则引擎的组成
1.2.1 数据输入
支持接受使用预定义的语义编写的规则作为策略集。比如 “price>500” 。 接受业务的数据作为执行过程中的参数,比如价格、标签等
什么叫做预定义的语法?其实就是我们预先按照一定的规则去让大家都按照一定的规范去写一条表达式。表达式是规则引擎所能理解所接受的。
1.2.2 规则理解
能够按照预先定义的词法、语法、优先级、运算符等正确理解业务规则所表达的语义。
1.2.3 规则执行
根据执行时输入的参数对策略集中的规则进行正确的解释和执行。同时对规则执行过程中的数据类型进行检查,确保执行结果正确
1.3 规则引擎的应用场景
1.3.1 风控对抗
与黑灰产的对抗过程中,策略研发和产品需要能够根据黑灰产特征进行快速识别和对抗。规则引擎作为风控系统的核心,使产研人员能够不断的调整和优化对抗策略,以实现最好的风控识别效果。
比如某次活动中,我们的黑灰产去过来薅羊毛了,比如一些可能过年的什么摇红包的活动,本次的活动本来就持续没几天对吧?可能一天两天我们的就要到下一轮活动了。按照我们之前如果没有引入我们的规则引擎的逻辑,之前,我们可能需要去先去识别和对抗,先去识别出黑灰产的一些特征,再去设计一些策略,再去推抗。从开发到上线,可能我们半天或者一天就过去了,我们黑灰产已经把所有的好处都给拿到了,这个时候上线,其实我们的意义也就没有那么大了。如果在引入规则引擎之后,我们的产研人员能够在分钟级别就去把策略给修改好,去观察我们一些效策略的效果,最终实现我们对于黑灰产的一些打击。
1.3.2 活动策略运营
业务活动的运营需要及时根据用户效果反馈进行运营策略的优化和调整。引入规则引擎后,可以将服务代码与业务运营逻辑解耦,提高运营策略的迭代效率。方便新玩法的探索和效果验证
业务运营也是需要根据用户的效果、用户的反馈来去及时的调整和优化。在引入规则引擎之后,我们将可以把这些部分逻辑与我们本身就是服务本身自己的逻辑做一个解耦,这样我们运营本身的效率就有一个非常大的提高,并且还可以在基础上去快速的去探索一些新的玩法,以及做一些效果验证。
1.3.3 数据分析和清洗
在数据分析系统中使用规则引擎可以便捷的实现对数据进行整理、清洗和转换。数据分析师可以根据不同的需求来自定义数据处理的规则,方便快捷的产出所需要的数据。
归类引擎的引入可以实现我们对不同的数据分析师。对于同一份数据,它可以根据自己的需求来去定义它的处理规则,这样就可以方便快捷地去产生出他自己所需要的一些数据。
2.编译原理基本概念
程序员的三大浪漫之一:编泽原理。
介绍编泽、词法分析、语法分析、抽象语法树等概念。
编译原理本身其实就是介绍一些编译和执行一些的相关知识。我们知道其实编译本身它是一个翻译的过程,像一种类型的源代码,就是一种特定语法的一些源代码,翻译成另外一种可以在另外一种语法体系里去理解的一些结构。我们规则引擎其实本身就是做的这个事儿。按照我们刚才所讲的内容,就是一个规则引擎,它大概有三个部分执行。首先它是有可以理解规则,执行规则,同时能够去跟外界做一个输入,输出。
2.0 什么是编译
编译的过程就是 把某种语言的源程序,在不改变语义的条件下,转换成另一种语言程序(目标语言程序)
- 如果源代码编译后要在操作系统上运行,那目标代码就是汇编/机器代码。
- 如果编译后是在虚拟机里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。
解释型语言和编译型语言
-
有的语言提前把代码一次性转换完毕,这种就是编译型语言,用的转换工具就叫编译器,比如C、C++、Go。一次编译可重复执行
- 编译后产物不能跨平台,不同系统对可执行文件的要求不同。.exe
- 特殊的,c、c++、汇编、源代码也不能跨平台
-
有的语言则可以一边执行一边转化,用到哪里了就转哪里,这种就是解释性语言,用的转化工具叫虚拟机或者解释器,比如java python、javascript
关于 Java 和 Python .
- Java既有编译又有解释。但是编译并没有直接编译成机器码,而是编译成字节码,然后再放到虚拟机中执行。
- Python执行过程也是经过两个阶段,先编译成字节码 .pyc 再放到虚拟机中去执行
JVM 和 Python解释器 | 为什么一个叫虚拟机一个叫解释器
- “虚拟机”对二进制字节码进行解释,而“解释器”是对程序文本进行解释。
- 从历史上看,Java 是为解释二进制字节码而设计和实现的,而 Python 最初是为解释程序文本而设计和实现的。因此,“Java 虚拟机”这个术语在 Java 社区中具有历史意义并且非常成熟,“Python 解释器”这个术语在 Python 社区中具有历史意义并且非常成熟。人们倾向于延长传统并使用很久以前使用的相同术语。
- 对于 Java,二进制字节码解释是程序执行的主要方式,而 JIT 编译只是一种可选的和透明的优化。而对于 Python,目前,程序文本解释是 Python 程序执行的主要方式,而编译成 Python VM 字节码只是一种可选的透明优化。
2.1 词法分析(Lexical Analysis)
词法分析就是把源代码字符串转换为词法单元(Token)的这个过程,
什么叫做词法单元?举个例子。你比如我们这里有一句话叫做 李想通过了青训营的选拔,我们很容易根据一些句子的成分来对它做一些切分。切分之后,“李想” “通过了” “青训营选拔”,那我们整个切分的逻辑是什么?是按照主谓宾的结构来去做一个切分。
规则引擎其实本身它的词法分析的阶段也大概是做一个类似的事情。不过我们可能我们会有一套自己的词法来去定义我们到底是按照什么去做切分的。
如何识别 Token ? ———— 确定的有限自动机 DFA | Deterministic Finite Automaton
有限自动机就是一个状态机,它的状态数量是有限的。该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。
简单来讲,它可能有 n 个状态,当它处在某一个状态的时候,你只要随便给它一个输入,它都能够确定的转移到另外一个状态中,而且操作是幂等的。无论你怎样去做转换,它的结果都是一样的。
以上面给出的表达式为例。比如开始的时候,我们可能处于 start 状态。我们在读到第一个单词第一个字母的时候,是 p 字母的时候,我们会转移到我们的 s1 状态。我们接下来就去读转换到这个状态之后,我们再去接着读 r、i、c、e 。我们会发现我们读一直读到单词结束,它都是在 s1 状态里,之后再读到一个空格或者一个大于号的时候,我们发现它可能从 s1 状态会转移出去。转移到时候我们现在已经判定它为一个参数了,我们已经认识它是一个参数了。这样其实相当于我们把 price token 给切分出来了。大于号其实也是一样的逻辑,你比如大于号,我们是从 start 的节点开始,我们会转移到 S3 ,大于号之后我们后面无论又是一个空格的输入,之后就会得到一个符号的这么一个token。500 的话也是一样的逻辑。
词法分析阶段会有一个状态机,其实状态机是对于我们规则引擎所支持的词法的一个描述,这样我们在接下来的去设计规则引擎的阶段,我们只要能够画出一个比较合理的状态机,我们的词法设计的阶段其实就已经完成了。
词法分析的阶段,其实就是基于状态机去挨个的去遍历我们的整个表达式,输出我们的一系列的 token 来给我们的下一阶段使用。
词法分析它的流程以及它的目的。也就是我们刚才讲的这些词法分析结束之后,我们会得到一系列的 token 流。之后就到了语法分析阶段。
2.2 语法分析(Syntax Analysis)
词法分析是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构。这个结构是一个树状结构。这棵树叫做抽象语法树(Abstract Syntax Tree,AST)。树的每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。
Token -> AST
“李想” “通过了” “青训营的选拔”。它的一些语法的结构其实就是主语、谓语和宾语,它是根据表达这个句子的成分来去做的。
比如 price 大于500,我们可以认为它是一个小的子表达式,它是首先我们去给了一个定义,我们需要认识一种叫做比较表达式。比较表达式它的结构是什么?它有一个操作符,它的操作符是大于号。它有两个操作数,左操作数和右操作数,可以是一些常量,一些参数等等。我们在比如在看括号里面的东西,括这里面的东西,它其实是一个或的逻辑或表达式的语法结构。它语法结构首先它也是需要有我们的操作符是吧?它的操作符是确定的,它有左操作数和右操作数,同时右操作的数本身也可以是一些子表达式,也就是说它的节点既可以是单个的节点,也可以是一个子树。
2.3 抽象语法树(Abstract Syntax Tree)
表达式的语法结构可以用树来表示,其每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。
规则引擎去理解一个表达式,最重要的一点就是希望能够从一个字符串开始来去,转化为它内部所能理解的结构。它内部认识的结构是怎么样的?我们这里就会用抽象语法术这种形式来表示。比如上面表是我们做一个简单的转换,我们把它翻译成一个抽样语法树,之后就是图中所示的结构。这个结构对于抽样语法树的定义是什么样的?每个节点它是一个语法单元。你会发现其实每个节点都是我们第一步通过词法分析产生的一个 token,单元就构成了所谓的这一部分的语法。比如大于号,也就是左子数,以大于号为根节点的左子数,它其实就是比较表达式的一个词法单元,也就是我们的大于符号的一个词法单元,每个节点其实还是还可以有它的下级节点,比如 或表达式 isNew 。我们发现它的右操作数其实又是一个小的子数。
语法分析的主要目的就是能够把我们词法分析所产生的一系列的 token 来转化成我们所期望的这么一个这么一棵树。
2.3.1 上下文无关语法(Context-Free Grammar)
某个语言当中的一些句子,不需要去考虑上下文就可以判断它的正确性,它的含义一定是唯一确定的。
比如 r := a > b 这么一个表达式,无论它的上一条表达式或者是它的后一条表达式都是什么内容,我们都不需要去关心我们,只只要我们只知道这个表达式,它一定表示的是我们会比较 a 和 b 的值,并把值赋给 r
编程语言为什么不用人类的语言(自然语言),而是用上下文无关的文法呢? 因为
- 便于设计编译器。 客观上技术目前无法实现,如果使用了上下文相关文法,那就是真正实现了人工智能,NLP领域将会有重大突破。
- 便于代码开发维护。 如果开发出来的代码像高考的语文阅读理解一样,每个人都有不同的理解,那么,到底哪个才是作者真正想要表达的?如果人类都确定不了含义,那计算机同样也确定不了,最终结果就是错误执行或无法执行。
- 汇编语言/机器语言是上下文无关的。CPU执行指令时,读到哪条执行哪条。如果CPU需要考虑上下文,来决定一个语句到底要做什么,那么CPU执行一条语句会比现在慢千倍万倍。考虑上下文的事情,完全可以用户在编程的时候用算法实现。既然机器语言是上下文无关的,那高级语言也基本上是上下文无关的,可能有某些个别语法为了方便使用,设计成了上下文相关的,比如脚本语言的弱类型。在便于使用的同时,增加了解析器的复杂度。
上下文无关语法G:终结符集合T + 非终结符集合N + 产生式集合P + 起始符号S
G由T、N、S和P组成,由语法G推导出来的所有句子的集合称为G语言!
终结符: 组成串的基本符号。可以理解为词法分析器产生的token集合。比如 + Id ( ) 等
非终结符: 表示token的的集合的语法变量。比如 stmt varDecl 等等
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 ')' ; //基础表达式
复制代码
产生式:表示形式,S : AB ,就是说S的含义可以用语法AB进行表达
S : AB
A : aA | ε
B : b | bB
复制代码
展开(expand):将P(A->u )应用到符号串vAw中,得到新串vu **w
折叠(reduce):将P(A->uu )应用到符号串vuu w中,得到新串vAw
推导(derivate):符号串u 应用一系列产生式,变成符号串v ,则u =>v:S => ab | b | bb
巴科斯范式
BNF是描述上下文无关理论的一种具体方法,通过BNF可以实现上下文无关文法的具体化、公式化、科学化,是实现代码解析的必要条件。
<expr> ::= <expr> + <term>
| <expr> - <term>
| <term>
<term> ::= <term> * <factor>
| <term> / <factor>
| <factor>
<factor> ::= ( <expr> )
| Num
复制代码
BNF本质上就是树形分解,分解成一棵抽象语法树
- 每个产生式就是一个子树,在写编译器时,每个子树对应一个解析函数。
- 叶子节点叫做 终结符,非叶子节点叫做 非终结符。
简单来说巴克斯范式,其实它就是一个推导产生式。我们可以结合图上的例子来看一个表达式也。我们可以发现它是有两部分组成冒号,中间以冒号作分割,有左边和右边的内容。
冒号左边的类型其实就是我们定义的一些表达式的类型,它是可以由冒号右边的一些东西来表示,这些东西就是一些已知类型的表达式,或者是我们已知的一些符号来去推导产生的。
什么叫做已知类型的表达式和符号?首先我们说其实我们任何一门语言,你比如我们在学 c 语言或者是其他一些 Java 的时候,我们都会有内置的一些关键字,比如加减乘除,布尔 false 以及一些 Int,这种都是一些关键字。这种我们其实认为是一些内置的符号,当然也可能会包括一些字面量,你比如我们的string 类型的常量,布尔类型的常量以及数字类型的常量。这些内置符号其实我们在编译里过程中还有一个名字叫做中阶符。
首先我们要确定一个点,内置符号,也一些字面量标识符合运算符是我们这么语言,或者是我们规则引擎自己已经定义好的,我们是可以认识它的。产生式是需要根据这些符号或者是由这些符号推导出来的一些表达式,去做一个递归的推导。
比如我们的基础表达式叫 primary。有个基础表达式,我们可以有一些数字类型,或者是字符串,或者是布尔值的一些类型的常量,以及一些个标识符来去组成的。举个例子,比如 weight 是一个基础表达式 ;20 是一个数字类型的常量,包括a,b,c,d,e。它们是一个字符串类型的常量。这个情况下,我们都认为这些是一个基础的表达式。
2.3.2 递归下降算法(Recursive Descent Parsing)
递归下降算法就是自顶向下构造语法树,不断的对 Token 进行语法展开(下降),展开过程中可能会遇到递归的情况。
所谓的递归下降,其实我们根据我们前面定义的这些帕克斯范式,我们就可以看出,它其实从表达式从头开始,从根节点开始,挨个的置顶向下的去构造出来一个语法树的过程。这个算法的名称叫做递归下降。首先我们知道递归,我们来刚才上下无关语法已经去做了展示,首先它的语法就是由递归的去定义,或者是去产生的一个下降。我们所谓的下降的概念,其实就是我们在当前一层的表达式里满足不了,也就是在当前的优先级里满足不了的时候,我们就可以往下降一层,下降到一个低优先级的一个过程。其实我们不断的去一层一层的往下推,一直推到我们认识,也就是一些内置符号的这么一个过程。
基本思路就是按照语法规则去匹配 Token 串。比如说,变量声明语句的规则如下:
varDecl : types Id varInitializer? ';' ; //变量声明
varInitializer : '=' exp ; //变量初始化
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
复制代码
如果写成产生式格式,是下面这样:
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)
匹配一个标识符(Id),作为变量名称
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则:
匹配一个等号
匹配一个表达式(在这个步骤会导致多层下降:exp->add->mul->pri->IntLiteral)
创建一个varInitializer对应的AST节点并返回
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。
匹配一个分号
创建一个varDecl对应的AST节点并返回
int a = 2
- 对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。
- 在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式。
- 如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做回溯(Backtracking)。
接下来就需要根据这些 token 的流来去通过递归下降算法来去构建我们这么一个抽象语法树。
首先我们发现留的第一个是我们 price 的 token, 那 price 这个 token 我们第一步要怎么做?OK。其实我们会根据我们定义的语法,我们的巴克斯范式所表示的语法,挨个的去一层一层的去低估的下降我们匹配的规则。
我们首先我们可会去说 price 是不是一个表达式本身,我们发现表达式只能由逻辑表达式来组成,这样不是不是,我们就看它是不是一个逻辑表达式,就是 logic 逻辑表达式本身。我们发现好像不是我们,因为我们逻辑表达式必须是有一个逻辑表达式,后面跟了一个逻辑语或逻辑符号的,或的符号,后面跟了一个比较表达式或者是逻辑表达式,它是由一个单独的比较表达式来组成的。这样我们就接着去下降,我们下降的依据它可以解释为一个加法表达式。加法表达式发现这里还是不认识它,我们就接着再往下降去,相当于我们每一次的推倒都是往下去做了一层递归,这样我们一直到发现它是一个标识符,对吧?它是price。到这里,其实我们已经认识它了,OK?为什么认识他?我们刚才说了对吧?我们因为归类引擎本身需要通过一些内置的符号来去表示我们认识price,它是个标识符。我们接下来因为我们是递归下降的,所以有了递归,对吧?我们肯定会一层一层的往上回缩,也就是我们看它后面跟它后面的加起来,它能够符合我们所定义的哪一条的语法。
OK,你比如我们第二个是一个大于号,我们到现在到了大于号,这我们能构造出什么样的?其实我们整个回溯的过程,我们要尽可能的去匹配,而且是一个贪量的算法,我们尽可能多的去匹配,尽可能早的去匹配到它符合的一个语法。你。比如现在我们在基础表达式我们发,我们会看一下大于号是不是符合基础表达式的定义,就是我们的 price 加上一个大于号是不是只符合一个基础表达式的定义,我们发现它不符合,因为没有我们的定义里面大于号不在这。我们接着乘法表的实体没有,我们发现也没有,对吧?一直走发现诶。
到了比较表达式,我们会发现表达比较表达式,它是由一个比较表达式后面跟了一个大于号组成的。我们知道大于号它会放在这里了,OK,放在这里之后,我们其实大于号我们就找到它的位置了,对吧?我们接下来该怎么办?我们是接着往上还是接着往下?这时候其实可能有同学会疑问,或者到这了我该怎么办?其实我们在可以回到我们的所定义的语法本身来看,我们现在所期望的就是大于号,它所找到的位置是一个比较表达式的位置。我们就看一下比较表达式它长什么样对吧。
一个比较表达式是前面一个比较表达式,后面跟了一个大于号,又跟了一个加法表达式。我们这样我们就会发现我们到了大于号这一步。我们的 price 其实是可以解释为一个比较表达式的,因为它是一层一层往下推的,它也可以一层一层的往上。回溯过来, price 本身是一个比较表达式,后面跟了一个大于号对吧?大于号后面我们就要去根据我们定义的一些语法再去。我们期望得到一个什么样的东西?我们期望一个什么东西?我们期望一个加法表达式,对吧?我们看下面是不是有一个加法表达式。我们再次从位置去下降,也要去再去递归,我们会发现这里我们就直接到了 500。我们会发现 500 它确实它是一个常量,对吧?它是一个常量,它是一个基础表达式,它是一个乘法表达式,它也是一个加法表达式。
OK,我们会发现这样一个词法语法单元,其实我们就已经分析完了。 price 大于 500 语法单元我们就已经分析完了。OK,这样其实我们现在你假设我们有一个指针,我们分析的过程中有另外一个指针,应该是停留在 500 位置的对吧?因为我们现在找到了 500 是一个基础表达式。这个情况之后,我们就看下一个 token,它是一个逻辑与的符号。逻辑与的符号它会出现在哪?对,我们还是重复刚才的步骤对吧。其实我们刚才步骤已经是一个非常完整的步骤了。我们重复刚才的步骤就一直往上找你,比如 price,它是一个 primary 表达式,这样我就一直往上找。
我发现原来逻辑与它是需要跟在逻辑表达式后面。我们也会发现,其实逻辑表达式它也是有一个可以由一个比较表达式来组成的。这样的情况下我们就找到逻辑语的位置。OK,接下来我们就简单过一下我们逻辑与之后,我们再希望得到一个什么东西,其实是一个比较表达式,它接下来要接着往下推,一直推推推推,推到一个括号。我们会发现括号其实我们是定义在了基础表达式这里的。我们的基础表达式是可以有一个括号括起来的一个表达式的本身,这样其实括号我们要知道它是有最高优先级的,这样我们的其实优先级的表示在这里也就出来了。因为屏幕的篇幅的问题,我们就直接说再去分析一个表达式就可以了。其实它是应该按照我们之前所讲的流程,它是应该先一层一层下降到基础表达式之后才会找到括号的。中间的过程我们省略一下,大家知道就可以了。
OK,这样我们就还是从一个逻辑表达式开始下降去找,我们会发现能找到 is new user token,它是一个基础表达式。OK。再接着走,我们或符号就是逻辑或它是会在这个位置。下面我就不详细一步一步的去搞了,反正都是在重复前面一层一层的往下递归,一层一层的回溯的这个位置顺序。
OK,这样其实我们等到把所有的 token 都已经结束之后,我们会发现括号我们又回到原来这个地方,对吧?因为我们是从括号开始的,我们就从括号结束。这样的情况下,我们就a,我们就先把中间的一些步骤给省略了,我们来把它去连线。虚线是因为我们刚才说过了,省略了一层的推导,其实它应该是括号,其实是应该跟 500 它们是一层的。这样的情况下,我们会发现它跟我们所谓预先一开始所希望的那棵树长得一模一样,对吧?这里我们只不过是中间括号多了一个节点,但是因为括号它是一个表示一线节动型,其实我们就完全可以把它给忽略掉。
OK,我们现在只要通过我们的预先定义的一些语法词法,我们会用一个状态机做一个表示,先定义,再去定义一些文法,也就所谓的我们所谓的文法,也就是会用巴克斯范式的形式来表达。同时我们会通过递归下降的算法,一步一步的去构造出了我们的抽象语法树。这样其实我们可以看一下,整个过程中,我们会从一个简单的字符串,现在就变成了这个样子的数据结构,也就是我们右边这棵树的数据结构。这个数据结构其实我们在规的引擎内部,也就是我们计算机可以理解的一个结构。OK,规则引擎的理解这一步就已经完成了,它现在已经知道了,你给它一个字符串,它能够分析出来这个规则长什么样子,以及它的一些语法结构了。接下来我们就要去开始做一些执行。
2.4 类型检查
我们执行的时候要去做类型检查。为什么要去做类型检查?你想你比如 a 大于b,我们刚才也知道了, a b 肯定得是一个数字对吧?要不然你是一个字符串,我们也能勉强能接受。但如果你,比如你给我了一个布尔值,你比如 true or false 对吧? true 到底和 false 哪个大?其实这个可能不同的语言的支持是不一样的。我们我们的规则引擎认为这个是不合法的。但是有一些你比如 true 是1, false 是0,这种是可以比较的,但我们规则引擎认为它是不合法的。或者是出刨去这种异常case,你比如一个字符串跟一个数字去比较,你比如 a b, c 是不是大于100,这个东西我们就没有办法去得出一个结论,对吧,到底是不是对的,你说对的是合理的,都不对的也是合理的。这种情况下,我们对于这种无法去确定的判断,我们就给出一个 error 就可以了。
2.4.1 类型综合
根据子表达式的类型构造出父表达式的类型。例如,表达式 A + B 的类型是根据 A 和 B 的类型定义的
我们知道 a 和 b 的类型,我们就知道 a 加 b 的类型,对吧?因为如果是 a, b 都是Int,它的结果 a 加 b 的结果肯定是个Int。如果a, b 是个 string,我们就认为 a 加 b 的结果也是个 string。
比如 29 大于10,我们知道这棵树的执行结果,这个子树也就是我们的语法单元,所它的执行结果一定是个布尔值对吧?没有问题。你看再看我们右边这棵树,你比如10,加了一个字符串,这里你就会有问题了,对吧?两个类型不一样,你一加我们归类引擎本身其实也不知道是什么类型,所以说我们不能按照左边的说给一个 10 ,一个 Int 类型,不能按照右边的给一个 string 的类型。所以其实这棵树的它类型检查就会暴露一个失败,对吧?我们整个类型检查的过程中,需要去发现这种 case,并且做一个错误的处理,来防止我们得到一些不可预期的结果。
2.4.2 编译时检查&运行时检查
类型检查可以发生在表达式的编译阶段,即在构造语法树的阶段,也可以发生在执行时的阶段
一种就是编译时的检查,也就是我们前面所讲的那一大堆理解的过程,就是从一个字符串构造出一个抽换语法处的过程中。在我们构造数的过程中,就需要去检查它的左子树和右子树是不是都是合法的。
另外一种其实就是在运行时的检查。运行时的检查,在真正的拿到我们所谓的数据的时候,再去做一个检查,你比如 10 加 S1 结果到底能不能跑出来?我们再真正执行遍历树的时候,再去做这两种检查,它有什么区别?我们就拿下面这个抽象语法树来举个例子
3.设计一个规则引擎
从零开始设计一个规测引擎 Young Engine,明确其对词法、语法的支持,设计编译和执行的流程。
3.1 设计目标
设计一个规则引擎,支持特定的词法、运算符、数据类型和优先级。并且支持基于以上预定义语法的规则表达式的编译和执行。
3.1.1 词法(合法Token)
3.1.2 运算符
3.1.3 数据类型
3.1.4 优先级
3.2 词法分析
设计词法分析的状态机
如果是参数,比如我们会从 0 状态可以迁移到 1 这个状态,因为参数我们要求它是字母数字的下划线,并且不能以数字开头对吧?我们可以迁移到 1 这个状态。我们接着往下读,一直读到,也就是读到一个其他字符,你比如读到一个空格或者什么大于号小于号这种符号,我们就从状态 1 迁移到下一个节点。其实我们这里发现我们其实做了一个判断,因为我们发现如果是一个标识符,它机器可能有一些是内置的关键字。你比如我们在 c 语言里面可能有一些什么 float Int 这种内置的关键字,是不能够被我们的名称和变量所使用的。还有一些你比如 true or false 这种布尔类型,其实是我们已经内置好的,不能够被,其他的参数和变量去使用。这样其实我们就通过一个判断来判断一下它是不是布尔类型,如果不是,我们就认为它是一个参数。
这个判断其实我们实现起来也非常简单,因为我们想既然是系统的关键字,我们一定是可以枚举的,这样我们就做一个简单判断就可以了,OK,这样其实你像这个数字常用的 float 和Int,其实我们也是通过这么一个流程去做的。OK,以此类推。后面我们不太详细看了,我们挑几个特殊的看一下你。比如我们现在有一个运算符是大于等于号对吧?这两个就像我的现在读到一个大于号之后,我们知道会从状态 0 迁移到 11 节点对吧?因为我们上面画的会从状态 0 迁移到 11 这个节点,我们发现 11 节点好像长得跟其他输入节点是一样的,对吧?都是一个同性缘,这种情况下我们是要输出还是不输出?这样我们就有一个方法叫做向右,向后再去看一位,我们会预读一位你,比如大于号后面跟了是不是一个等于号,如果是等于号,我们会迁移到 12 状态再输出大于等于这么一个token,不是我们就在 11 状态直接去输出这么一个大于号了对吧。
3.3 语法分析
优先级的表达
我们会类似于定义一个 presidents 的结构,这个结构我们可以首先它当前优先级支持哪些类型符号?你比如是一个逻辑表达式,它当前就支持大于、大于等于、小于、小于等于、等于、不等于,对吧?OK,它会指向下一个更高优先级的函数结构。你比如逻辑或的更高优先级就是逻辑语对吧?递归一下,降的时候我们根据结构去一层一层去展开的。
语法树结构
一元运算符:左子树为空,右子树为右操作数 二元运算符:左子树为左操作数,右子树为右操作数 括号:左子树为空,右子树为内部表达式的 AST
3.4 语法树执行与类型检查
语法树执行:
预先定义好每种操作符的执行逻辑。 对抽象语法树进行后续遍历执行,即:
- 先执行左子树,得到左节点的值,
- 再执行右子树,得到有节点的值:
- 最后根据根节点的操作符执行得到根节点的值。
类型检查:
检查时机:执行时检查 检查方法:在一个节点的左右子节点执行完成后,分别校验左右子节点的类型是否符合对应操作符的类型检查预设规则。
- '>' 符号要求左右子节点的值都存在且为 int 或 float.
- '!' 符号要求左节点为空且右节点的值为 bool
4.规则引擎的实现
实战演练部分,实现规则引擎 YoungEngine 的各个部分并介绍其中的几个重点实现思路。
git clone https://github.com/qimengxingyuan/young_engine.git
chmod a+x ./setup.sh
./setup.sh
个人总结
- 规则引擎的核心原理得多加学习,理解透彻。
- 规则引擎的设计和实现根据具体项目具体实现,同时贯彻规则引擎的基本原理。