规则引擎

规则引擎介绍

什么是规则引擎

规则引擎专家系统的变种,它通过一组规则的集合、通过作用于事实,从而推理得到结果。当一个事实满足某条规则的条件时,就可以认为这个事实与这条规则匹配。比如,如果我的成绩达到 90 分以上就出去旅游,其中的事实是,规则是if(x的成绩达到90分以上){x出去玩}

  • 规则
    可以理解为类似 if-else、switch 这样的代码;
  • 事实
    在 Java 中可以认为就是类的实例;

为什么使用规则引擎

  1. 规则引擎实现了数据同逻辑的完全解耦。
  2. 有助于规则的集中管理,但是也要注意可能发生的冲突。
  3. 每个开发的习惯、水平都不一样,有些人可能会生硬地套用一些设计模式,导致业务逻辑被强行设计得很复杂,而且随着版本、人员迭代越发严重;
    而规则引擎相当于一套实现业务逻辑的规范,它会逼迫需求和研发人员梳理业务,并建立统一的 BOM(业务对象模型)。
  4. 可以使用决策表等形式来展示规则,方便业务人员浏览,减少与技术人员沟通成本。
  5. 方便业务人员修改业务逻辑,甚至可以做到动态修改、实时生效,减少规则变动带来的额外开发工作。

在复杂的大型业务场景下,规则引擎常和流程引擎搭配使用来强化对业务逻辑的管理。

QLExpress

虽然很多文章提到QLExpress是一个规则引擎,但是它本质上是一个脚本引擎,只不过额外提供的自定义关键字功能,使得它的表达能力大大增强,甚至能够达到用中文描述业务逻辑的程度。

  • 简洁而强大
    drl 语法与 Java 很接近,而且可以通过自定义操作符号和别名来实现强大的规则判断。
  • 轻量
    本体只需要引入一个依赖包。

借助 QLExpress,我们可以做到:

  1. 由产品甚至运营、财务给出具体规则的自然语言描述,比如”如果用户年龄小于12岁则弹出防沉迷公告“;
  2. 由程序解析这些自然语言为程序识别的表达式,比如”if(age < 12) {notifyService.push(msg);}“;
  3. 计算结果,最终实现业务逻辑。

相对 Drools 来说,QLExpress 少了很多规则管理功能,比如大量重复的条件匹配、规则的冲突等情况如何应对,落地时需要做很多扩展开发工作。当然,优点也出于其轻量性,实际业务场景并不一定适用于 Drools 这套重量级的规则模型,由于学习成本高、灵活性差,RETE 算法可能并不能发挥其优势,反而因为初始化成本过高而起到反作用。

语法

操作符

基本操作符支持:+,-,*,/,<,>,<=,>=,==,!=,%,++,–,&&,||,!
有几个比较特殊的:<>(等同于!=)、mod(取模等同于%)、in(类似 sql)、like(sql 语法)
三元操作符:? :
程序流控制支持:for、break、continue、if-then-else

1
2
3
4
5
6
7
8
String expression = "sum = 0;"
+ "for (i = 0; i < 10; i++) {"
+ "sum = sum + i;"
+ "}"
+ "return sum;";
ExpressRunner runner = new ExpressRunner(false, true);
Object result = runner.execute(expression, new DefaultContext<>(), null, false, true);
System.out.println(result);

对象操作

1
2
3
4
5
6
7
8
// 系统自动会import java.lang.*,import java.util.*;
import com.ql.util.express.test.OrderQuery;

query = new OrderQuery();//创建class实例,会根据classLoader信息,自动补全类路径
query.setCreateDate(new Date());//设置属性
query.buyer = "张三";//调用属性,默认会转化为setBuyer("张三")
result = bizOrderDAO.query(query);//调用bean对象的方法
System.out.println(result.getId());//静态方法

自定义 function

1
2
3
4
5
6
7
8
9
10
function add(int a,int b){
return a+b;
};

function sub(int a,int b){
return a - b;
};

a=10;
return add(a,4) + sub(a,9);

扩展操作符

替换 if、then、else 等关键字(addOperatorWithAlias):

1
2
3
4
5
6
7
runner.addOperatorWithAlias("如果", "if",null);
runner.addOperatorWithAlias("则", "then",null);
runner.addOperatorWithAlias("否则", "else",null);

exp = "如果 (如果 1==2 则 false 否则 true) 则 {2+2;} 否则 {20 + 20;}";
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
runner.execute(exp,nil,null,false,false,null);

自定义 Operator(Operator):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//定义一个继承自com.ql.util.express.Operator的操作符
public class JoinOperator extends Operator {
public Object executeInner(Object[] list) throws Exception {
Object opdata1 = list[0];
Object opdata2 = list[1];
if(opdata1 instanceof java.util.List){
((java.util.List)opdata1).add(opdata2);
return opdata1;
}else{
java.util.List result = new java.util.ArrayList();
result.add(opdata1);
result.add(opdata2);
return result;
}
}
}

// addOperator
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
runner.addOperator("join",new JoinOperator());
Object r = runner.execute("1 join 2 join 3", context, null, false, false);
System.out.println(r);
// 返回结果 [1, 2, 3]

// replaceOperator
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
runner.replaceOperator("+",new JoinOperator());
Object r = runner.execute("1 + 2 + 3", context, null, false, false);
System.out.println(r);
// 返回结果 [1, 2, 3]

// addFunction
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
runner.addFunction("join",new JoinOperator());
Object r = runner.execute("join(1,2,3)", context, null, false, false);
System.out.println(r);
// 返回结果 [1, 2, 3]

绑定 Java 类或者 method

addFunctionOfClassMethodaddFunctionOfServiceMethod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Test
public void testBindJavaClassAndMethod() throws Exception {
ExpressRunner runner = new ExpressRunner();
runner.addFunctionOfClassMethod("取绝对值", Math.class.getName(), "abs",
new String[]{"double"}, null);
runner.addFunctionOfClassMethod("转换为大写", BeanExample.class.getName(),
"upper", new String[]{"String"}, null);

runner.addFunctionOfServiceMethod("打印", System.out, "println", new String[]{"String"}, null);
runner.addFunctionOfServiceMethod("contains", new BeanExample(), "anyContains",
new Class[]{String.class, String.class}, null);

DefaultContext<String, Object> context = new DefaultContext<String, Object>();
String exp = "取绝对值(-100);转换为大写(\"hello world\");打印(\"你好吗?\"); contains(\"helloworld\", \"asd\")";
runner.execute(exp, context, null, false, false);
}

public class BeanExample {

public static String upper(String abc) {
return abc.toUpperCase();
}

public boolean anyContains(String str, String searchStr) {

char[] s = str.toCharArray();
for (char c : s) {
if (searchStr.contains(c + "")) {
return true;
}
}
return false;
}
}

macro 宏定义

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testMicro() throws Exception {
ExpressRunner runner = new ExpressRunner();
runner.addMacro("计算平均成绩", "(语文+数学+英语)/3.0");
runner.addMacro("是否优秀", "计算平均成绩>90");
IExpressContext<String, Object> context = new DefaultContext<>();
context.put("语文", 88);
context.put("数学", 99);
context.put("英语", 95);
Object result = runner.execute("是否优秀", context, null, false, false);
System.out.println(result);
}

编译脚本,查询上下文中需要提供的变量

1
2
3
4
5
6
7
8
9
@Test
public void testOutParam() throws Exception {
String express = "int 平均分 = (语文+数学+英语+综合考试.科目2)/4.0;return 平均分";
ExpressRunner runner = new ExpressRunner(true, true);
String[] names = runner.getOutVarNames(express);
for (String s : names) {
System.out.println("var : " + s);
}
}

不定参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testMethodReplace() throws Exception {
ExpressRunner runner = new ExpressRunner();
IExpressContext<String, Object> expressContext = new DefaultContext<String, Object>();
runner.addFunctionOfServiceMethod("getTemplate", this, "getTemplate", new Class[]{Object[].class}, null);

// 默认的不定参数可以使用数组来代替
Object r = runner.execute("getTemplate([11,'22',33L,true])", expressContext, null, false, false);
System.out.println(r);
// 像java一样,支持函数动态参数调用,需要打开以下全局开关,否则以下调用会失败
DynamicParamsUtil.supportDynamicParams = true;
r = runner.execute("getTemplate(11,'22',33L,true)", expressContext, null, false, false);
System.out.println(r);
}

// 等价于getTemplate(Object[] params)
public Object getTemplate(Object... params) throws Exception {
StringBuilder result = new StringBuilder();
for (Object obj : params) {
result.append(obj).append(" ");
}
return result.toString();
}

集合操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
public void testSet() throws Exception {
ExpressRunner runner = new ExpressRunner(false, false);
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
String express = "abc = NewMap(1:1,2:2); return abc.get(1) + abc.get(2);";
Object r = runner.execute(express, context, null, false, false);
System.out.println(r);
express = "abc = NewList(1,2,3); return abc.get(1)+abc.get(2)";
r = runner.execute(express, context, null, false, false);
System.out.println(r);
express = "abc = [1,2,3]; return abc[1]+abc[2];";
r = runner.execute(express, context, null, false, false);
System.out.println(r);
}

// 集合遍历,不支持for(obj:list){}的语法,只能通过下标访问
@Test
public void testTraversal() throws Exception {
String expression = "map = new HashMap();\n"
+ "map.put(\"a\", \"a_value\");\n"
+ "map.put(\"b\", \"b_value\");\n"
+ "keySet = map.keySet();\n"
+ "objArr = keySet.toArray();\n"
+ "for (i = 0; i < objArr.length; i++) {\n"
+ " key = objArr[i];\n"
+ " System.out.println(map.get(key));\n"
+ "}";
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<>();
runner.execute(expression, context, null, false, false);
}

执行原理 - 举例

生成指令的流程

QLExpress指令生成
1、单词扫描及识别
通过有限状态机识别脚本为一个个 token(ExpressParse#splitWords)
QLExpressDFA例子
识别单词类型,比如 100d 是 double 类型浮点数,而 100l 是 long 类型的整数,上一步并不会确定这二者的区别(ExpressParse#transferWord2ExpressNode)。
2、语法分析
采用递归向下分析语法(com.ql.util.express.match.QLPattern#findMatchStatement)。
以一个简单的加减乘除为例,假设 Expr 表示识别一个表达式,Term 表示乘积或商,Factor 是表达式中的数字:

1
2
3
4
5
6
7
Expr -> Expr + Term
| Expr - Term
| Term

Term -> Term * Factor
| Term / Factor
| Factor

这里的箭头”->”有”由……组成”的含义,”Expr -> Expr + Term”即表达式是由表达式和某个 Term 之间的和组成,这个定义是递归的。示例实现如下(下面代码并非 QLExpress 中的代码):
QLExpress-语法树例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
static TreeNode *additive_expression(void) {
TreeNode *root = term();
while(token == PLUS || token == MINUS) {
/* 将+ -符号当作新的根 */
TreeNode *newRoot = newExpNode(OpE);
if(newRoot) {
newRoot->child[0] = root;
newRoot->attr.op = token;
root = newRoot;
}
match(token);
if(root) {
root->child[1] = additive_expression();
}
}
return root;
}
static TreeNode *term(void) {
TreeNode *root = factor();
while(token == TIMES || token == OVER) {
/* 将* /符号当作新的根 */
TreeNode *newRoot = newExpNode(OpE);
if(newRoot) {
newRoot->child[0] = root;
newRoot->attr.op = token;
root = newRoot;
}
match(token);
if(root) {
root->child[1] = term();
}
}
return root;
}
static TreeNode *factor(void) {
TreeNode *root = NULL;
switch(token) {
case LPAREN:
match(LPAREN);
root = expression();
match(RPAREN);
break;
case NUM:
root = newExpNode(ConstE);
if(root) { root->attr.val = atoi(tokenString); }
match(NUM);
break;
case ID:
root = var_call();
break;
default:
syntaxError("unexpected token -> ");
printToken(token, tokenString);
token = getToken();
}
return root;
}

实际 QLExpress 的语法定义比较复杂(com.ql.util.express.parse.KeyWordDefine4Java#nodeTypeDefines),像上面的加减乘除语法定义如下:
QLExpress-加减乘除的语法定义
以下面这个简单的加减乘除脚本为例:

1
a+b*c

QLExpress-递归解析乘除语法
QLExpress-递归解析加减语法
如上图所示,递归深度直到达到 22、23 才开始识别脚本中的乘法和加法表达式。
3、创建指令
QLExpress指令创建
不同指令有对应的工厂类负责创建,不同指令的生成方式并不一致,比如:

  • 二元表达式的生成方式是后序遍历语法树,即先把左子树和右子树的结果算出来,再取它们之间的和差积商等(com.ql.util.express.instruction.OperatorInstructionFactory);
  • 函数调用表达式的生成方式是先计算第 2 个及之后的所有子树结果(方法调用实参),然后计算该函数本身。

指令的执行流程

QLExpress指令执行流程
1、将用户传参保存到上下文(com.ql.util.express.InstructionSetRunner#execute)
很多指令需要用到上下文中的参数,比如像”*”这样的操作符(com.ql.util.express.Operator)。
通过自定义这个上下文,我们还可以实现在脚本里使用 Spring 容器中的 Bean,后面我们会给出例子。
2、执行指令(com.ql.util.express.InstructionSet#executeInnerOrigiInstruction)
指令计算遵循栈模型:计算表达式时,从栈顶取参,并将结果压回栈顶。

  • 常量表达式就是将常量压入栈顶(com.ql.util.express.instruction.detail.InstructionConstData)
  • 类似乘法这样的二元操作符,计算时一般使用上下文提供的参数计算结果,然后压回栈中(com.ql.util.express.instruction.detail.InstructionOperator)
    比如,”*”计算的是乘积(com.ql.util.express.instruction.op.OperatorMultiDiv)

例:一个脚本的执行过程

1
2
3
4
5
6
7
8
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
context.put("a",1);
context.put("b",2);
context.put("c",3);
String express = "a+b*c";
Object r = runner.execute(express, context, null, true, false);
System.out.println(r);

可见,脚本内容很简单,为”a+b*c”,并且传入了 a、b、c 三个参数的值。
1、脚本编译
脚本编译的目标是生成中间指令,会经过单词扫描识别、语法分析、创建指令 3 个过程。
扫描得到的单词列表如下图所示:
QLExpress单词扫描例子
生成语法树如下所示:

1
2
3
4
5
6
7
1:   STAT_BLOCK:STAT_BLOCK                                                         	STAT_BLOCK
2: STAT_SEMICOLON:STAT_SEMICOLON STAT_SEMICOLON
3: +:+ +
4: a:ID ID
4: *:* *
5: b:ID ID
5: c:ID ID

QLExpress-语法树例子
创建的指令如下所示:

1
2
3
4
5
1:LoadAttr:a
2:LoadAttr:b
3:LoadAttr:c
4:OP : * OPNUMBER[2]
5:OP : + OPNUMBER[2]

2、运行指令
第一步:LoadAttr 指令从上下文获取变量 a 的值——这里即 1,压入运行时栈中(com.ql.util.express.instruction.detail.InstructionLoadAttr)
第二步:同理
第三步:同理
第四步:”*”操作符的指令会先从栈顶弹出俩参数,计算乘积再压回栈中(com.ql.util.express.instruction.detail.InstructionOperator)
第五步:同理

执行原理 - 源码

QLExpress 本质上是一个解释器,下面我们来分析一下它的执行流程。
下面这个例子是官方提供的,会在执行过程中打出关键步骤的执行结果,可作为原理分析的参考:

1
2
3
4
5
6
7
8
@Test
public void testDemo() throws Exception{
String express = "10 * 10 + 1 + 2 * 3 + 5 * 2";
ExpressRunner runner = new ExpressRunner(false,true); // 显示执行编译过程
Object r = runner.execute(express,null, null, false,true); // 显示指令执行过程
Assert.assertTrue("表达式计算", r.toString().equalsIgnoreCase("117"));
System.out.println("表达式计算:" + express + " = " + r);
}

QLExpress执行流程

其中:

  • 运行时上下文包括用户输入、系统变量及 Spring 上下文管理的所有 Bean 等。
  • Runner 负责对脚本的编译和执行,及高精度、逻辑短路、自定义 field、自定义 function、宏等特性。

单词分解、预处理

  1. wordSplit.parse
    第一次 parse
    遍历目标字符串,使用预定义的分隔符分割,得到 token。
  2. ExpressParse#dealInclude
    预处理,qlexpress 支持使用 include 引入其他脚本文件。
  3. ExpressParse#fetchSelfDefineClass
    获取用户自定义的 class。

单词类型分析

ExpressParse#transferWord2ExpressNode

语法分析

QLPattern#findMatchStatement

生成运行期指令集合

ExpressRunner#createInstructionSet(ExpressNode root, String type)

执行生成的指令集合

InstructionSetRunner#executeOuter

自定义函数

1、添加函数定义(com.ql.util.express.ExpressRunner#addFunction、addFunctionOfClassMethod、addFunctionOfServiceMethod)
添加到 OperatorFactory 和 NodeTypeManager,前者用于创建指令,后者用于单词识别时判断节点是否是函数。
使用 SpringBean 时,Bean 的生命周期仍然由 Spring 控制,QLExpress 只是使用了该 Bean 的引用。
2、扫描时识别 token 为方法并做标记(com.ql.util.express.parse.ExpressParse#transferWord2ExpressNode)
创建一个新的语法树节点(ExpressNode)。
QLExpress-创建一个新的语法树节点
3、创建指令时,对应这种节点(ExpressNode)创建一个函数调用指令(com.ql.util.express.instruction.CallFunctionInstructionFactory)
4、执行自定义函数对应的指令(com.ql.util.express.instruction.detail.InstructionOperator#execute)
通过反射调用对象中的方法(com.ql.util.express.instruction.op.OperatorSelfDefineClassFunction)。

###自定义操作符
1、添加操作符定义(com.ql.util.express.ExpressRunner#addOperator)
与自定义函数类似,这些指令会被添加到 OperatorFactory 和 NodeTypeManager。
自定义操作符的优先级、语法和乘法”*”一致,后面生成指令时用的是同一个 OperatorInstructionFactory,需要实现 Operator 基类自定义操作符的处理逻辑。
QLExpress自定义操作符与乘号类似
2、扫描阶段识别 token,这些 token 会被识别为“关键字”(com.ql.util.express.parse.ExpressParse#transferWord2ExpressNode)
QLExpress-识别token为关键字
3、创建操作指令(com.ql.util.express.instruction.OperatorInstructionFactory)
4、执行自定义操作符(com.ql.util.express.instruction.detail.InstructionOperator#execute)
调用自定义的 Operator 实现类中的模板方法。

QLExpress & Spring

引入依赖:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>QLExpress</artifactId>
<version>3.2.0</version>
</dependency>

QLExpress脚本中所需的所有对象都需要通过上下文来传递,换言之,需要将这个上下文接入到 Spring 的上下文中。
第一种方式是手动注入 Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {SpringexpressdemoApplication.class})// 指定启动类
public class SpringTest {

@Resource
private CancelService cancelService;

@Test
public void testDirect() throws Exception {
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<>();
context.put("cancelService", cancelService);
context.put("orderId", 1);
Object result = runner.execute("cancelService.cancel(orderId)", context, null, true, false);
System.out.println(result);
}
}

@Service
public class CancelService {

public boolean cancel(Long orderId) {
System.out.println("订单[" + orderId + "]已被取消。");
return true;
}
}

或者也可以通过对 QLExpressRunner 和 IExpressContext 做一个简单的封装来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {SpringexpressdemoApplication.class})// 指定启动类
public class SpringTest {

@Resource
private QLExpressRunner runner;

@Test
public void testSpringContext() throws Exception {
Map<String, Object> context = new HashMap<>();
context.put("orderId", 1);
Object result = runner.execute("cancelService.cancel(orderId)", context);
System.out.println(result);
}
}

public class QLExpressContext extends HashMap<String, Object> implements IExpressContext<String, Object> {

private ApplicationContext context;

public QLExpressContext(ApplicationContext context) {
this.context = context;
}

public QLExpressContext(Map<String, Object> properties, ApplicationContext context) {
super(properties);
this.context = context;
}

@Override
public Object get(Object name) {
Object result;
result = super.get(name);
try {
if (result == null && this.context != null && this.context.containsBean((String) name)) {
//如果在Spring容器中包含bean,则返回String的Bean
result = this.context.getBean((String) name);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return result;
}

@Override
public Object put(String name, Object object) {
throw new RuntimeException("未实现");
}
}

@Component
public class QLExpressRunner implements ApplicationContextAware {

private Logger logger = LoggerFactory.getLogger(QLExpressRunner.class);

private static final ExpressRunner RUNNER;

private static boolean isInitialRunner = false;

private ApplicationContext applicationContext;

private static final Map<String, Object> EMPTY_CLIENT_CONTEXT = new HashMap<>();

static {
RUNNER = new ExpressRunner();
}

/**
* @param statement 需要执行的语句
* @param context 上下文
*/
@SuppressWarnings("unchecked")
public Object execute(String statement, Map<String, Object> context) throws Exception {
initRunner();
IExpressContext expressContext = new QLExpressContext(context != null ? context : EMPTY_CLIENT_CONTEXT, applicationContext);
statement = initStatement(statement);
try {
return RUNNER.execute(statement, expressContext, null, true, false);
} catch (Exception e) {
logger.error("QLExpress执行出错", e);
}
return null;
}

/**
* 在此处把一些中文符号替换成英文符号
*/
private String initStatement(String statement) {
return statement.replace("(", "(").replace(")", ")").replace(";", ";").replace(",", ",").replace("“", "\"").replace("”", "\"");
}

private void initRunner() {
if (isInitialRunner) {
return;
}
synchronized (RUNNER) {
if (isInitialRunner) {
return;
}
try {
//在此可以加入预定义函数
// 1、令原有业务方法可以通过更容易理解的中文脚本语言调用
// cancelService.cancel(orderId) -> 取消订单(orderId)
RUNNER.addFunctionOfServiceMethod("取消订单",
applicationContext.getBean("cancelService"), "cancel", new Class[]{Long.class}, null);
} catch (Exception e) {
throw new RuntimeException("初始化失败表达式", e);
}
}
isInitialRunner = true;
}

@Override
public void setApplicationContext(ApplicationContext aContext)
throws BeansException {
applicationContext = aContext;
}
}

优化 - 脚本缓存

ExpressRunner#execute(String expressString, IExpressContext<String, Object> context, List<String> errorList, boolean isCache, boolean isTrace)
execute 方法的第四个参数表明是否使用 Cache 中的指令集参数,它可以缓存单词分解、单词类型分析、语法分析、生成运行期指令集合这四个过程的执行结果,即缓存编译的指令,从而第二次执行时可以直接使用这些指令,从而提升性能。

1
2
3
4
5
6
7
8
9
10
ExpressRunner runner = new ExpressRunner();
String express = "10 * 10 + 1 + 2 * 3 + 5 * 2";
int num = 100000;
runner.execute(express, null, null, true, false);
long start = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
runner.execute(express, null, null, true, false);
}
System.out.println("执行" + num + "次\"" + express + "\" 耗时:"
+ (System.currentTimeMillis() - start));

以上代码使用了缓存,运行时间在百毫秒级,但是如果关掉缓存,速度会直接降低到近 10 秒。

另外,上边代码中,脚本默认加载在本地缓存(是个 Map),如果有比较高的脚本隔离性和安全性需求,可以考虑将脚本缓存到外部缓存处理器上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ExpressRunner runner = new ExpressRunner();
//ExpressRemoteCacheRunner将编译生成的指令集合放到了一个外部缓存中,保证只能执行原有脚本内容,且脚本之间相互隔离
ExpressRemoteCacheRunner cacheRunner = new LocalExpressCacheRunner(runner);
cacheRunner.loadCache("计算平均成绩", "(语文+数学+英语)/3.0");
cacheRunner.loadCache("是否优秀", "计算平均成绩>90");

IExpressContext<String, Object> context = new DefaultContext<String, Object>();
context.put("语文", 88);
context.put("数学", 99);
context.put("英语", 95);
System.out.println(cacheRunner.execute("计算平均成绩", context, null, false, false, null));
try {
System.out.println(cacheRunner.execute("计算平均成绩>90", context, null, false, false, null));
} catch (Exception e) {
System.out.println("ExpressRemoteCacheRunner只支持预先加载的脚本内容");
}
try {
System.out.println(cacheRunner.execute("是否优秀", context, null, false, false, null));
} catch (Exception e) {
System.out.println("ExpressRemoteCacheRunner不支持脚本间的相互调用");
}

使用外部缓存脚本可以带来以下好处:

  • 隔离性:一个脚本内无法使用另一个脚本;
  • 安全性(独立性):只能使用预加载的脚本,execute 执行的脚本不会被加载到缓存中,这减少了脚本被篡改的可能。

但是同样会削弱复用性,因为脚本不能利用其它脚本,只能使用预定义的系统函数。

需要注意的是,缓存存在于ExpressRunner#expressInstructionSetCache中,务必要保证ExpressRunner单例性,否则缓存将没有意义。

优化 - 对象池

几乎所有动态脚本语言在运行时都会创建很多对象,然后执行完毕后等着被 GC 回收掉,在多线程运行的环境下,CPU 和内容容易被消耗殆尽。一种可行的优化方式是引入对象池。
具体地说,就是在执行指令集时,从对象池中获取对象的存储空间,而不是直接创建对象。

QLExpress类图

  • 每次获取对象的时候,不是直接创建对象,而是从缓冲区获取一个 OperateDataField,成员变量被赋值;
  • 每次脚本运行完、资源要被回收的时候,会把这些 OperateDataField 会被显式置为 null,使所有的外部链接对象被 java 虚拟机马上释放。
  • 缓冲区空间是有限的,且不会动态伸缩,如果超过了允许范围,fetchOperateDataField 会直接调用 new OperateDataField(Object aFieldObject,String aFieldName)返回,回收内存也就只能等 jvm 定期去 gc 了。

实践 - 显示出错信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
String text = "100 < 99 and 100 <= 99 and 100 > 1";
// 将操作符替换成中文
text = text.replaceAll("<=", "小于或等于")
.replaceAll("<", "小于")
.replaceAll(">", "大于");
ExpressRunner runner = new ExpressRunner();
runner.setShortCircuit(true);
// 指定操作符的别名,注意最后一个参数指定了操作符计算失败时的返回信息
runner.addOperatorWithAlias("小于", "<", "$1 小于 $2");
runner.addOperatorWithAlias("小于或等于", "<=", "$1 小于 $2");
runner.addOperatorWithAlias("大于", ">", "$1 小于 $2");

// 如果计算发现不满足条件,则将操作符对应的errorInfo添加到下面这个list里
List<String> errorInfo = new ArrayList<>();
IExpressContext<String, Object> expressContext = new DefaultContext<>();
boolean result = (boolean) runner.execute(text, expressContext, errorInfo, true, false);
if (result) {
System.out.println("result is success!");
} else {
System.out.println("result is fail!");
for (String error : errorInfo) {
error = error.replaceAll("小于", "<").replaceAll("小于或等于", "<=").replaceAll("大于", ">");
System.out.println(error);
}
}

OperatorBase.execute执行计算后,如果结果是 Boolean 且值为 false,说明未满足要求,于是将 errorInfo 添加到参数的errorList中返回。

实践 - 短路

仍然是上面那个例子,注意runner.setShortCircuit(true);这行代码指定了指令集执行的短路特性。
OperatorInstructionFactory#createInstruction构建指令集时,如果判断是短路的,则在对应指令后面加入一条条件跳转指令(InstructionGoToWithCondition):

  • 如果&&计算的结果是 false 则跳转到指令集的末尾;
  • ||相反、是计算结果为 true 的时候才会跳转到末尾。

实践 - 高精度计算

1
2
3
4
5
6
7
8
String expression = "a = (b.subtract(c)).divide(d.subtract(c), java.math.BigDecimal.ROUND_HALF_UP);";
ExpressRunner runner = new ExpressRunner();
IExpressContext<String, Object> context = new DefaultContext<>();
context.put("b", new BigDecimal("0.1694915254237288"));
context.put("c", new BigDecimal("0.15384615384615385"));
context.put("d", new BigDecimal("1"));
BigDecimal result = (BigDecimal) runner.execute(expression, context, null, true, false);
System.out.println(result);

上边是直接采用原生 JavaAPI 的写法,非常臃肿,实际上ExpressRunner已经提供了高精度的一个简单开关:

1
2
3
4
5
6
7
8
9
String expression = "a=(b-c)/(d-c)";
// 第一个参数为true开启高精度计算
ExpressRunner runner = new ExpressRunner(true, false);
DefaultContext<String, Object> context = new DefaultContext<>();
context.put("b", new BigDecimal("0.1694915254237288"));
context.put("c", new BigDecimal("0.15384615384615385"));
context.put("d", new BigDecimal("1"));
Object result = runner.execute(expression, context, null, false, false);
System.out.println(result);

相对原生 API 来说简洁了一些,但是也少了很多灵活性,需要注意以下几点区别:

  • QLExpress 中精度控制为RoundingMode=HALF_UP
  • QLExpress 中大数计算精确到小数点后 10 位,即scale=10

实践 - 方法绑定

1
2
3
4
5
6
7
8
String express = "阶梯1 = 阶梯(0.0,100.0,0.2);阶梯2 = 阶梯(100.0,200.0,0.15);阶梯3 = 阶梯(200.0,10000.0,0.1);阶梯取值(220,阶梯1,阶梯2,阶梯3)";
ExpressRunner runner = new ExpressRunner(false, true);
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
// 定义方法的别名
runner.addFunctionOfClassMethod("阶梯", "full.path.to.Step", "createStep", new Class[]{double.class, double.class, double.class}, null);
runner.addFunctionOfClassMethod("阶梯取值", "full.path.to.Step", "chooseStep", new Class[]{double.class, Step.class, Step.class, Step.class}, null);
Object r = runner.execute(express, context, null, false, true);
System.out.println(r);

下面这个类中定义了上边用到的一些基础函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class Step {

private double min;
private double max;
private double value;

public double getMin() {
return min;
}

public void setMin(double min) {
this.min = min;
}

public double getMax() {
return max;
}

public void setMax(double max) {
this.max = max;
}

public double getValue() {
return value;
}

public void setValue(double value) {
this.value = value;
}

public static Step createStep(double _min, double _max, double _value) {
Step step = new Step();
step.min = _min;
step.max = _max;
step.value = _value;
return step;
}

public static double chooseStep(double input, Step step1, Step step2, Step step3) {
Step[] steps = {step1, step2, step3};
for (Step step : steps) {
if (step.min <= input && step.max >= input) {
return step.value;
}
}
return -1;
}
}

技术选型及实践

需求描述

  1. 易于管理
    理想情况下,研发人员开发完毕后,管理完全可以由业务人员来实施,包括随时上下线、修改规则的优先级关系等。
  2. 减少侵入性
    规则引擎管理的是业务流程,因此想要做到对业务流程零侵入几乎是不可能的,但是我们还是希望尽量能减少对业务代码的修改,至少不必将所有 if-else 都重写一遍。
  3. 支持线程池
  4. 可以打日志,且包含 traceId
  5. 支持 Spring
  6. 支持SpringBoot

比较

  • | 易用 | 侵入性 | 扩展
  • | - | - | -
    手动实现规则 | 低(维护到后期往往会出现一大堆嵌套的 if-else、导致业务规则之间的边界模糊) | 强(完全自己实现) | 高(可以利用设计模式来提高扩展性)
    QLExpress | 高(语法与 Java 的基本一致) | 低(原业务逻辑中的方法可以直接搬到 QLExpress 脚本里用,只需要引入一个 jar 包) | 高(本身是一个脚本引擎有很高的可扩展性,且通过把业务规则模块化可以不断复用)
    Drools | 低(增加了很多配置文件、规则文件,语法有点奇怪,且需要对 Drools 原理有所了解,不然规则执行效率可能会不高) | 强(需要完全按照 Drools 的语法编写业务逻辑,要引入一堆 jar 包、一堆配置) | 低(需要严格按照 Drools 规定的语法编写业务逻辑)

附 - Drools

规则管理

一些复杂的业务一般包含多个规则、多条执行路径,管理时可能会出现以下几个问题:

  1. 优先级问题
    比如有两条规则是”满 30 元减 10 元”、”满 50 元减 20 元”,但是用户买了 50 块的东西,这种情况下如何编排规则作用的优先级?还是说两者同时作用?
  2. 冲突问题
    有两条规则”伤害致人死亡,严重者可能判死刑”和”未满 18 岁,不能判处死刑”,那么系统应该如何判定未成年人的杀人罪?
  3. 规则列表管理问题
    虽然不必具有普适性,规则模型的设计必须要覆盖我们所有可能碰到的业务场景。
    业务人员一般比较熟悉 excel 表格,而不乐于编辑繁琐的规则脚本,所以有必要提供一种映射规则,比如 Drools 的决策表,实际上需要先被框架翻译成.drl脚本才能生效,所以,我们在设计规则引擎功能时,可以为业务人员提供一个规则编辑的表单界面,并且提供上传决策表的功能。

概念定义

Drools 中,一条规则包含attributesLeft Hand SideLHS)和Right Hand SideRHS):

  • attributes
    Drools 中的 attributes 包括:salience、agenda-group、no-loop、auto-focus、duration、activation-group。

  • LHSRHS
    LHS由一个或多个Conditions组成。当所有的条件Conditions都满足并为真时,RHS将被执行,因此RHS被称为Consequence

    1
    2
    3
    if(${LHS}) {
    ${RHS}
    }

使用示例

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<drools.version>7.29.0.Final</drools.version>
<!--Drools-->
<!--kie api 用于构建kie虚拟文件系统,关联decisiontable和drl文件-->
<dependency>
<groupId>org.kie</groupId>
<artifactId>kie-api</artifactId>
<version>${drools.version}</version>
</dependency>
<!--drools core 包含RETE引擎和LEAPS引擎-->
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-core</artifactId>
<version>${drools.version}</version>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-compiler</artifactId>
<version>${drools.version}</version>
</dependency>
<!--决策表依赖-->
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-decisiontables</artifactId>
<version>${drools.version}</version>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-templates</artifactId>
<version>${drools.version}</version>
</dependency>

使用 kmodule 配置 drools 规则,文件位置为/resources/META-INF/kmodule.xml

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<kmodule xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.drools.org/xsd/kmodule">
<!-- name:唯一标识 packages:资源文件所在的目录 -->
<kbase name="testBase" packages="com.tallate.drools.rule.test">
<!-- name:唯一标识 -->
<ksession name="testSession"/>
</kbase>
</kmodule>

定义规则,文件位置为/resources/com/tallate/drools/rule/test/test.drl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 指定命名空间,和java中的包名无关,也不必对应物理路径
package com.tallate.drools

// import要使用的Java类
import com.tallate.drools.bean.User;

// 规则名,不可重复
rule "unexisted condition"
// 定义规则的优先级
salience 10
when
// LHS部分,定义规则条件,即是否存在姓名为"Mike"的User类型的Fact对象
not(User(name == "Mike"));
then
// RHS部分,定义当规则满足后执行的操作,可以直接写Java代码,这里逻辑是插入一个新对象
System.out.println("User unexisted");
User user = new User();
user.setName("Mike");
System.out.println(user);
insert(user);
end

rule "existed condition"
salience 10
when
exists(User(name == "Mike"))
then
System.out.println("User existed");
end

上面引入了一个 Bean,需要提供 getter/setter,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class User {

private String name;

private int age;

private List<String> cards;

public String getName() {
return name;
}

public User setName(String name) {
this.name = name;
return this;
}

public int getAge() {
return age;
}

public User setAge(int age) {
this.age = age;
return this;
}

public List<String> getCards() {
return cards;
}

public User setCards(List<String> cards) {
this.cards = cards;
return this;
}

@Override
public String toString() {
return new StringJoiner(", ", User.class.getSimpleName() + "[", "]")
.add("name='" + name + "'")
.add("age=" + age)
.add("cards=" + cards)
.toString();
}
}

启动规则引擎:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
KieContainer kc = KieServices.Factory.get().getKieClasspathContainer();
System.out.println(kc.verify().getMessages());
execute(kc);
}

private static void execute(KieContainer kc) {
KieSession kieSession = kc.newKieSession("testSession");
// 可以插入Fact对象到Working Memory
kieSession.insert(new User().setName("Joe").setAge(1));
kieSession.fireAllRules();
kieSession.dispose();
}

Drools例子的执行过程

决策表的使用

  • 决策表一般使用 xlsx、csv 格式表示,相对来说可读性会更高;
  • 决策表与上述的 drl 并无本质区别,实际上运行时决策表会被翻译成 drl 再实际加载到推理机中使用。

首先在kmodule.xml文件中引入 excel 文件:

1
2
3
<kbase name="testDecisionTable" packages="drools.decisiontable">
<ksession name="testDecisionTableSession"/>
</kbase>

然后在/resources/drools/decisiontable目录下增加一个 rule.xlsx 文件:

RuleSet mydecision
Import com.tallate.drools.bean.User
Notes test decision table
RuleTable TestTable
CONDITION ACTION
p:User()
name==”$param” System.out.println(“$param”);
注释 入参 动作
hello Mike 10
goodbye Joe 11
  • 第一行,类似 drl 中的 package
  • 第二行,import 使用到的 Java 类
  • 第三行,决策表说明
  • 第四行,指定这是一个决策表
  • 第五行,Condition表示此列是条件,Action表示具体的操作,Action可以有多个列, 单个Action里的多个操作用逗号分隔,末尾要加分号
  • 第六行,紧挨着 Condition 的一行,可以在这里声明下面要用的到对象,对应 drl 文件里的$student:Student()
  • 第七行,如user.getAge()==$param对应 drl 里的age==12,这里$param是对应列每个单元格的值。注意:针对于非字符串,如整数,小数等,可以直接使用$param,但是如果单元格里是字符串,则需要加双引号;如果有多个值,可以用逗号隔开,然后使用$1,$2来引用变量值。
  • 第八行,注释行
  • 下面的每一行,即这些条件和动作具体的取值了

下面附上源文件:
rule.xlsx

使用原生API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 1.获取一个KieServices:KieServices是kie的整体入口,可以用来创建Container,resource,fileSystem等
KieServices kieServices = KieServices.Factory.get();

// 2.创建kiemodule.xml对应的class,可以不用xml文件的方式来构建
KieModuleModel kieModuleModel = kieServices.newKieModuleModel();

// 3.创建KieFileSystem虚拟文件系统:KieFileSystem是一个完整的文件系统,包括资源和组织结构
KieFileSystem kieFileSystem = kieServices.newKieFileSystem();

// 4.添加具体的KieBase标签
KieBaseModel kieBaseModel = kieModuleModel.newKieBaseModel("mydecision").
// kie fileSystem 中资源文件的文件夹
addPackage("mydecision");
// <KieBase></KieBase>标签添加KieSession属性,这里和下面获取KieSession时传的sessionName必须一致
kieBaseModel.newKieSessionModel("kiession-mydecision");

// 5.添加kiemodule.xml文件到虚拟文件系统
String kieModuleModelXml = kieModuleModel.toXML();
// kieModuleModel
kieFileSystem.writeKModuleXML(kieModuleModelXml);

// 6.把规则文件加载到虚拟文件系统
Resource resource = kieServices.getResources().newClassPathResource("drools/decisiontable/rule.xlsx");
String fileName = "mydecision-table." + resource.getResourceType().getDefaultExtension();
// 这里是把规则文件添加到虚拟系统,第一个参数是文件在虚拟系统中的路径,这里的文件目录和4处的addPackage必须一致,否则失败
String kieFilePath = "src/main/resources/mydecision/" + fileName;
kieFileSystem.write(kieFilePath, resource);

// 7.构建所有的KieBase并把所有的KieBase添加到仓库里:KieBase就是一个知识仓库,包含了若干的规则、流程、方法等,在Drools中主要就是规则和方法,KieBase本身并不包含运行时的数据之类的,如果需要执行规则KieBase中的规则的话,就需要根据KieBase创建KieSession
// KieBuilder在构建完虚拟文件系统后,会自动创建KieModule,KieModule是一个包含了多个KieBase定义的容器,一般用kmodule.xml来表示
kieServices.newKieBuilder(kieFileSystem).buildAll();
// 创建kie容器:KieContainer是KieBase的容器,它封装了KieModule,注意这里的参数ReleaseId,实际上KieModule都由一个KieRepository管理,用一个ReleaseId作区分
KieContainer kieContainer = kieServices.newKieContainer(kieServices.getRepository().getDefaultReleaseId());

// 8.从容器中获取一个会话,找不到时会报异常:KieSession是一个与Drools引擎交互的会话,其基于KieBase创建,它会包含运行时数据,包含"事实 Fact",并对运行时数据事实进行规则运算
KieSession kieSession = kieContainer.newKieSession("kiession-mydecision");
kieSession.insert(new User().setName("Mike").setAge(21));
kieSession.insert(new User().setName("Joe").setAge(20));
kieSession.fireAllRules();
System.out.println("done");

LHS

  • package
    TODO

  • import

  • rule

  • no-loop

RHS

TODO

Drools + Spring

TODO

源码分析

-> org.drools.core.common.DefaultAgenda#getNextFocus
-> DefaultAgenda#fireNextItem(AgendaFilter filter, int fireCount, int fireLimit, InternalAgendaGroup group):执行下一个规则
TODO

规则引擎运行原理

在描述 RETE 算法之前我们必须先解释几个术语。

  • Pattern Matching(模式匹配):对新的数据和被修改的数据进行规则的匹配;
  • Inference Engine(推理机):进行匹配的引擎;
  • Production Memory(规则 rules):被访问的规则;
  • Working Memory(事实 facts):被推理机进行匹配的数据;
  • Agenda:负责管理被匹配规则的执行;

其中,推理机所采用的模式匹配算法包括:LinearRETETreatLeaps

规则引擎架构

规则引擎的大致运行原理:

  1. 推理机拿到事实(数据)和规则后,进行匹配(利用匹配算法,如 RETE),将匹配的规则和数据传递给 Agenda;
  2. 重复第 1 步直到没有匹配项;
  3. 由 Agenda 负责规则的执行。规则并不能被直接调用,因为它们不是方法或函数,规则的激发是对 Working Memory 中数据变化的响应(Listening)。数据被插入 Working Memory 后,会和RuleBase中的规则的LHS进行匹配,如果匹配成功则这条规则连同和它匹配的数据(此时称为Activation)一起被放入 Agenda,等待 Agenda 来激活执行规则的RHS,Agenda 会根据冲突解决策略来安排Activation的执行顺序。

规则匹配算法 - RETE

RETE 算法利用动态规划思想来提高规则匹配的效率,复杂的规则会被组织成一张有向无环图,重叠的节点会被合并,合并意味着结果是可以重用的,这些条件判断结果会被保存到缓存中,供其他规则计算时使用。
TODO

规则引擎规范 - JSR-94

JSR-94 定义了规则引擎的 Java 运行时 API,它并没有规定如何实现,但规范使得代码与规则的底层实现解耦,提升了规则引擎的可移植性。
JSR-94 的常见实现是Drools
Drools 是基于 RETE 算法实现的规则引擎,遵循 JSR-94 协议提供 OO 接口,更加易于使用。

参考

方案

  1. 再见了 ! if-else !拥抱规则引擎
  2. 规则引擎
  3. Java 规则引擎与其 API(JSR-94)
  4. 从 0 到 1:构建强大且易用的规则引擎 - 美团
  5. 网易考拉规则引擎平台架构设计与实践

Drools

  1. drools 规则引擎初探
  2. drools语法介绍
  3. Drools Spring Integration
  4. github - kiegroup/drools
  5. github - MyHerux/drools-springboot
  6. Drools Documentation
  7. Drools Documentation - Rule 语法参考
  8. Drools Documentation - Decision Tables in Spreadsheets
  9. Drools Documentation - 整合 Spring
  10. Drools Documentation - Rate 算法
  11. RETE 算法简述 & 实践
  12. Rete: A fast algorithm for the many pattern/many object pattern match problem

QLExpress

  1. github - alibaba/QLExpress
  2. QLExpress基本语法
  3. express_wind - CSDN
  4. QLExpress 答疑解惑
  5. QLExpress 代码解读,运行原理解析