规则引擎介绍 什么是规则引擎 规则引擎 是专家系统 的变种,它通过一组规则的集合、通过作用于事实,从而推理得到结果。当一个事实满足某条规则的条件时,就可以认为这个事实与这条规则匹配。比如,如果我的成绩达到 90 分以上就出去旅游,其中的事实是我
,规则是if(x的成绩达到90分以上){x出去玩}
规则 可以理解为类似 if-else、switch 这样的代码;
事实 在 Java 中可以认为就是类的实例;
为什么使用规则引擎
规则引擎实现了数据同逻辑的完全解耦。
有助于规则的集中管理,但是也要注意可能发生的冲突。
每个开发的习惯、水平都不一样,有些人可能会生硬地套用一些设计模式,导致业务逻辑被强行设计得很复杂,而且随着版本、人员迭代越发严重; 而规则引擎相当于一套实现业务逻辑的规范,它会逼迫需求和研发人员梳理业务,并建立统一的 BOM(业务对象模型)。
可以使用决策表 等形式来展示规则,方便业务人员浏览,减少与技术人员沟通成本。
方便业务人员修改业务逻辑,甚至可以做到动态修改、实时生效,减少规则变动带来的额外开发工作。
在复杂的大型业务场景下,规则引擎常和流程引擎 搭配使用来强化对业务逻辑的管理。
QLExpress 虽然很多文章提到QLExpress是一个规则引擎,但是它本质上是一个脚本引擎,只不过额外提供的自定义关键字功能,使得它的表达能力大大增强,甚至能够达到用中文描述业务逻辑的程度。
简洁而强大 drl 语法与 Java 很接近,而且可以通过自定义操作符号和别名来实现强大的规则判断。
轻量 本体只需要引入一个依赖包。
借助 QLExpress,我们可以做到:
由产品甚至运营、财务给出具体规则的自然语言描述,比如”如果用户年龄小于12岁则弹出防沉迷公告
“;
由程序解析这些自然语言为程序识别的表达式,比如”if(age < 12) {notifyService.push(msg);}
“;
计算结果,最终实现业务逻辑。
相对 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 addFunctionOfClassMethod
、addFunctionOfServiceMethod
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); }
执行原理 - 举例 生成指令的流程 1、单词扫描及识别 通过有限状态机识别脚本为一个个 token(ExpressParse#splitWords) 识别单词类型,比如 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 中的代码):
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),像上面的加减乘除语法定义如下: 以下面这个简单的加减乘除脚本为例:
如上图所示,递归深度直到达到 22、23 才开始识别脚本中的乘法和加法表达式。 3、创建指令 不同指令有对应的工厂类负责创建,不同指令的生成方式并不一致,比如:
二元表达式的生成方式是后序遍历语法树,即先把左子树和右子树的结果算出来,再取它们之间的和差积商等(com.ql.util.express.instruction.OperatorInstructionFactory);
函数调用表达式的生成方式是先计算第 2 个及之后的所有子树结果(方法调用实参),然后计算该函数本身。
指令的执行流程 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 个过程。 扫描得到的单词列表如下图所示: 生成语法树如下所示:
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
创建的指令如下所示:
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); }
其中:
运行时上下文包括用户输入、系统变量及 Spring 上下文管理的所有 Bean 等。
Runner 负责对脚本的编译和执行,及高精度、逻辑短路、自定义 field、自定义 function、宏等特性。
单词分解、预处理
wordSplit.parse
第一次 parse 遍历目标字符串,使用预定义的分隔符分割,得到 token。
ExpressParse#dealInclude
预处理,qlexpress 支持使用 include 引入其他脚本文件。
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)。 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 基类自定义操作符的处理逻辑。 2、扫描阶段识别 token,这些 token 会被识别为“关键字”(com.ql.util.express.parse.ExpressParse#transferWord2ExpressNode) 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 和内容容易被消耗殆尽。一种可行的优化方式是引入对象池。 具体地说,就是在执行指令集时,从对象池中获取对象的存储空间,而不是直接创建对象。
每次获取对象的时候,不是直接创建对象,而是从缓冲区获取一个 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; } }
技术选型及实践 需求描述
易于管理 理想情况下,研发人员开发完毕后,管理完全可以由业务人员来实施,包括随时上下线、修改规则的优先级关系等。
减少侵入性 规则引擎管理的是业务流程,因此想要做到对业务流程零侵入几乎是不可能的,但是我们还是希望尽量能减少对业务代码的修改,至少不必将所有 if-else 都重写一遍。
支持线程池
可以打日志,且包含 traceId
支持 Spring
支持SpringBoot
比较
| 易用 | 侵入性 | 扩展
| - | - | - 手动实现规则 | 低(维护到后期往往会出现一大堆嵌套的 if-else、导致业务规则之间的边界模糊) | 强(完全自己实现) | 高(可以利用设计模式来提高扩展性) QLExpress | 高(语法与 Java 的基本一致) | 低(原业务逻辑中的方法可以直接搬到 QLExpress 脚本里用,只需要引入一个 jar 包) | 高(本身是一个脚本引擎有很高的可扩展性,且通过把业务规则模块化可以不断复用) Drools | 低(增加了很多配置文件、规则文件,语法有点奇怪,且需要对 Drools 原理有所了解,不然规则执行效率可能会不高) | 强(需要完全按照 Drools 的语法编写业务逻辑,要引入一堆 jar 包、一堆配置) | 低(需要严格按照 Drools 规定的语法编写业务逻辑)
附 - Drools 规则管理 一些复杂的业务一般包含多个规则、多条执行路径,管理时可能会出现以下几个问题:
优先级问题 比如有两条规则是”满 30 元减 10 元”、”满 50 元减 20 元”,但是用户买了 50 块的东西,这种情况下如何编排规则作用的优先级?还是说两者同时作用?
冲突问题 有两条规则”伤害致人死亡,严重者可能判死刑”和”未满 18 岁,不能判处死刑”,那么系统应该如何判定未成年人的杀人罪?
规则列表管理问题 虽然不必具有普适性,规则模型的设计必须要覆盖我们所有可能碰到的业务场景。 业务人员一般比较熟悉 excel 表格,而不乐于编辑繁琐的规则脚本,所以有必要提供一种映射规则,比如 Drools 的决策表,实际上需要先被框架翻译成.drl
脚本才能生效,所以,我们在设计规则引擎功能时,可以为业务人员提供一个规则编辑的表单界面,并且提供上传决策表的功能。
概念定义 Drools 中,一条规则包含attributes
、Left Hand Side
(LHS
)和Right Hand Side
(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(); }
决策表的使用
决策表一般使用 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:负责管理被匹配规则的执行;
其中,推理机所采用的模式匹配算法包括:Linear
、RETE
、Treat
、Leaps
。
规则引擎 的大致运行原理:
推理机拿到事实(数据)和规则后,进行匹配(利用匹配算法,如 RETE),将匹配的规则和数据传递给 Agenda;
重复第 1 步直到没有匹配项;
由 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 接口,更加易于使用。
参考 方案
再见了 ! if-else !拥抱规则引擎
规则引擎
Java 规则引擎与其 API(JSR-94)
从 0 到 1:构建强大且易用的规则引擎 - 美团
网易考拉规则引擎平台架构设计与实践
Drools
drools 规则引擎初探
drools语法介绍
Drools Spring Integration
github - kiegroup/drools
github - MyHerux/drools-springboot
Drools Documentation
Drools Documentation - Rule 语法参考
Drools Documentation - Decision Tables in Spreadsheets
Drools Documentation - 整合 Spring
Drools Documentation - Rate 算法
RETE 算法简述 & 实践
Rete: A fast algorithm for the many pattern/many object pattern match problem
QLExpress
github - alibaba/QLExpress
QLExpress基本语法
express_wind - CSDN
QLExpress 答疑解惑
QLExpress 代码解读,运行原理解析