0x00 目的背景
电商会员服务的等级、积分、权益等模块中,都使用了大量的规则判断。范式基本上是 达成xxx条件,执行xxx行为
。这很符合规则引擎那一套,因此下面选取了几个业界常见的规则引擎进行分析。
我们分别都从使用角度、原理角度两个方面进行分析。
0x01 easy-rules
项目地址:https://github.com/j-easy/easy-rules
1. 规则模型
比较容易搞混的概念是Rule,Rule包含了条件和行为。Condition才是用来判断true还是false的条件,Rule还包含了满足条件后需要执行的行为。
2. 如何使用
(1) 调用方式
easy-rules提供了很多种调用方式。使用方式大多都只是个范式,在官网很容易看懂。这里只列举一下各种形式。
注解方式
@Rule(name = "my rule1", description = "my rule description", priority = 1)
public class MyRule1 {
@Condition
public boolean when(@Fact("type") Integer type) {
return type == 1;
}
@Action(order = 1)
public void execute1(Facts facts) throws Exception {
log.info("MyRule1 execute1, facts={}", facts);
}
@Action(order = 2)
public void execute2(Facts facts) throws Exception {
log.info("MyRule1 execute2, facts={}", facts);
}
}
流式API
Rule weatherRule = new RuleBuilder()
.name("weather rule")
.description("if it rains then take an umbrella")
.when(facts -> facts.get("rain").equals(true))
.then(facts -> System.out.println("It rains, take an umbrella!"))
.build();
表达式方式
支持 MVEL
, SpEL
and JEXL
Rule weatherRule = new MVELRule()
.name("weather rule")
.description("if it rains then take an umbrella")
.when("rain == true")
.then("System.out.println(\"It rains, take an umbrella!\");");
规则描述文件
---
name: adult rule
description: when age is greater than 18, then mark as adult
priority: 1
condition: "person.age > 18"
actions:
- "person.setAdult(true);"
---
name: weather rule
description: when it rains, then take an umbrella
priority: 2
condition: "rain == true"
actions:
- "System.out.println(\"It rains, take an umbrella!\");"
(2) 规则组合
规则的组合,和规则之间的逻辑关系。
在easy-rules中,规则可以被组合成为 CompositeRule
,并且提供了三种逻辑关系:
- UnitRuleGroup:规则之间是
AND
的关系,所有规则是一个整体,要么应用所有规则,要么不应用任何规则 - ActivationRuleGroup:规则之间是
XOR
的关系,只会执行第一个命中的规则,其它规则均忽略 - ConditionalRuleGroup:具有最高优先级的规则作为
触发条件
,如果满足触发条件,则会继续执行其它规则
(3) 规则引擎
easy-rules提供了两种规则引擎实现:
- DefaultRulesEngine:根据规则的自然顺序(默认为优先级)应用规则。
- InferenceRulesEngine:持续对已知事实应用规则,直到不再应用规则为止。
3. 原理浅析
我们从Rule看起,了解规则整体模型。然后再看RuleEngine,了解规则加载、判断和执行流程。
(1) Rule
Rule是可比较的,这里主要是方便使用优先级进行排序。Rule是个接口,BasicRule相当于一个Adapter,所有其他Rule都继承BaseRule。
public interface Rule extends Comparable<Rule> {
// 省略部分描述和标识性方法
/**
* 用于判断Condition是否满足
*/
boolean evaluate(Facts facts);
/**
* 用于执行Actions
* @throws 执行action(s)时如果发生错误会抛出异常
*/
void execute(Facts facts) throws Exception;
}
来看一下DefaultRule的实现会更清晰:
class DefaultRule extends BasicRule {
private final Condition condition;
private final List<Action> actions;
// 省略构造方法...
@Override
public boolean evaluate(Facts facts) {
return condition.evaluate(facts);
}
@Override
public void execute(Facts facts) throws Exception {
for (Action action : actions) {
action.execute(facts);
}
}
}
所以,条件判断、行为执行都是Condition和Action自己做的。我们可以跟进去看看。
(2) Condition
Condition就是需要由用户实现的条件接口,实现evaluate即可。easy-rules提供了表达式实现,下面章节会简要分析。
(3) Action
Action就是需要由用户实现的条件接口,实现execute即可。easy-rules提供了表达式实现,下面章节会简要分析。
(4) RulesEngine
RuleEngine有两种实现,我们以DefaultRulesEngine来分析整体流程。
抛开那些Listener不谈,RulesEngine有两个主要方法:
public interface RulesEngine {
/**
* 使用facts执行所有已经注册的rules
*/
void fire(Rules rules, Facts facts);
/**
* 只检查规则,但是不执行Action,其实就是调用每个Rule的evaluate
* @return 每个Rule对应的执行结果
*/
default Map<Rule, Boolean> check(Rules rules, Facts facts) {
return Collections.emptyMap();
}
}
来看看DefaultRulesEngine#doFire
:
// 已经省略了部分不重要的代码
void doFire(Rules rules, Facts facts) {
if (rules.isEmpty()) {
return;
}
LOGGER.debug("Rules evaluation started");
for (Rule rule : rules) {
final String name = rule.getName();
final int priority = rule.getPriority();
boolean evaluationResult = false;
try {
evaluationResult = rule.evaluate(facts);
} catch (RuntimeException exception) {
// 异常处理
}
if (evaluationResult) {
LOGGER.debug("Rule '{}' triggered", name);
try {
rule.execute(facts);
LOGGER.debug("Rule '{}' performed successfully", name);
} catch (Exception exception) {
// 异常处理
}
} else {
// 异常处理
}
}
}
(5) 表达式
可以看出,easy-rules的条件判断和Action执行,都是基于 表达式引擎 来实现的,是重度依赖表达式引擎的。不过说到底,规则判断可不就是表达式计算么。下面以SpEL为例分析:
@Override
public boolean evaluate(Facts facts) {
StandardEvaluationContext context = new StandardEvaluationContext();
// 实际值转换为map作为表达式变量
context.setRootObject(facts.asMap());
context.setVariables(facts.asMap());
if (beanResolver != null) {
context.setBeanResolver(beanResolver);
}
// 执行表达式
return compiledExpression.getValue(context, Boolean.class);
}
@Override
public void execute(Facts facts) {
try {
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(facts.asMap());
context.setVariables(facts.asMap());
if (beanResolver != null) {
context.setBeanResolver(beanResolver);
}
compiledExpression.getValue(context);
} catch (Exception e) {
LOGGER.error("Unable to evaluate expression: '" + expression + "' on facts: " + facts, e);
throw e;
}
}
在提供Action和Condition的时候,实际上就是直接提供对应的表达式语句即可,入参都是String类型的。
/**
* Specify the rule's condition as SpEL expression.
* @param condition of the rule
* @return this rule
*/
public SpELRule when(String condition) {
this.condition = new SpELCondition(condition, parserContext, beanResolver);
return this;
}
/**
* Add an action specified as an SpEL expression to the rule.
* @param action to add to the rule
* @return this rule
*/
public SpELRule then(String action) {
this.actions.add(new SpELAction(action, parserContext, beanResolver));
return this;
}
注意condition表达式和action表达式是分开存储的,所以上面evaluate和execute都是在执行表达式,但是执行的内容却是不同的。
4. 总结
easy-rules的代码结构设计的很清晰,代码也很清爽。项目本身很轻量级,基本上只提供了一个规则判断和行为执行的框架,相当于是对计算过程的抽象。
但也正是因为轻量级,easy-rules几乎不包含规则编排等功能,如果规则的条件本身是很复杂的,那么我们只能自己对这些条件进行编排,对于easy-rules来说,它只是一条单一的规则。当然,你也可以借助EL表达式的力量,实现一些线性编排。
0x02 Drools
项目官网:https://www.drools.org/ 源码:https://github.com/kiegroup/drools Drools是一个绝对重量级的规则引擎,很多像金融行业、电信行业的大公司都在使用它作为规则引擎。
1. 规则模型
- 事实(Fact):对象之间及对象属性之间的关系
- 规则(rule):是由条件和结论构成的推理语句,一般表示为if…then。一个规则的if部分称为LHS,then部分称为RHS。
- 模式(module):就是指IF语句的条件。这里IF条件可能是有几个更小的条件组成的大条件。模式就是指的不能在继续分割下去的最小的原子条件。
2. RETE算法
Rete 算法最初是由卡内基梅隆大学的 Charles L.Forgy 博士在 1974 年发表的论文中所阐述的算法 , 该算法提供了专家系统的一个高效实现。自 Rete 算法提出以后 , 它就被用到一些大型的规则系统中 , 像 ILog、Jess、JBoss Rules 等都是基于 RETE 算法的规则引擎。
Forgy的论文原文:RETE Match Algorithm - Forgy OCR.pdf
Rete 在拉丁语中译为”net”,即网络。Rete 匹配算法是一种进行大量模式集合和大量对象集合间比较的高效方法,通过网络筛选的方法找出所有匹配各个模式的对象和规则。
其核心思想是将分离的匹配项根据内容动态构造匹配树,以达到显著降低计算量的效果。Rete 算法可以被分为两个部分:规则编译和规则执行。当Rete算法进行事实的断言时,包含三个阶段:匹配、选择和执行,称做 match-select-act cycle。
我们先来了解一些基础概念,下图是一个基本上包含了所有常见元素的RETE网络:
我将RETE网络中的元素整理如下:
RETE算法就是Drools引擎执行速度快的秘诀。个人认为,RETE算法有以下特点:
- 不同规则之间的相同模式是可以共享节点和存储区的,所以做过的判断不需要重复执行,是典型的空间换时间的做法
- 使用AlphaMemory和BetaMemory存储事实,当事实部分变化时,可以只计算变化的事实,提高了匹配效率
- 事实只有满足当前节点,才会向下传递。不做无意义的匹配。
RETE算法的不足:
因为RETE是空间换时间,所以当规则和事实很多的时候,可能会耗尽系统资源。
举例,平台有如下规则:
- 付费会员生日当天购物满1000元,可获得礼包A
- 付费会员生日当天购物满500元,可获得礼包B
- 付费会员生日当天购物满100元,可获得礼包C
这些规则建立的RETE网络大概是:
RETE网络的创建流程:
- 创建根节点
- 加入一条规则
- 取出规则中的一个模式(模式就是规则中的最小一个匹配项例如:
age>10
、age<20
),检查模式中的参数类型,如果是新类型(也就是新的Fact类型),则加入一个类型节点; - 检查模式对应的 Alpha 节点是否已存在,如果存在则记录下节点位置,如果没有则将模式作为一个 Alpha 节点加入到网络中,同时根据 Alpha 节点的模式建立 Alpha Memory;
- 重复 b 直到所有的模式处理完毕;
- 组合 Beta 节点,按照如下方式: Beta 左输入节点为 Alpha(1),右输入节点为 Alpha(2) 。Beta(i) 左输入节点为 Beta(i-1),右输入节点为 Alpha(i) i>2 并将两个父节点的内存表内联成为自己的内存表;
- 重复 d 直到所有的 Beta 节点处理完毕;
- 将动作(Then 部分)封装成Beta(n) 的输出节点;
- 重复2直到所有规则处理完毕
RETE如何处理模式之间的or关系?由于模式之间只有NOT和EXIST两种关系,Beta Node并不能表述or的关系,所以Drools将其拆分成两条规则来看待。
3. PHREAK算法
PHREAK是由Drools团队设计和实现的一种算法,并在Drools 6中引入。PHREAK是一种慵懒匹配算法,在模型上基本延续了RETE,节点及其作用都是一样的。
如果你想了解性能差异,这里有一篇PHREAK和RATE算法的性能比较结果。
http://ksoong.org/drools-examples/content/docs/phreak.html#_phreak_vs_rateoo
PHREAK算法在RETE之上的改进点:
- 延迟规则评估 :当PHREAK引擎启动后,所有规则都处于一种 unlinked 的状态,这种状态的规则不会被Drools执行。当 insert 、 update 、 delete 等操作修改了KIE会话的状态时,修改只会传播到alpha子网,并在进入beta子网之前排队。与RETEOO不同,在PHREAK中,不会执行Beta节点以用作这些操作的结果。 引擎会先用试探法确定哪个规则最有可能导致匹配,从而在它们之间强加一个执行顺序。
- 面向集合传播 :在RETEOO中,每次insert/update/delete事实时,都会从顶部(入口点)到底部(最佳情况下的规则终端节点)遍历网络。网络中执行评估的每个节点都创建了一个元组,该元组传播到路径中的下一个节点。PHREAK不是这样工作的。一个Beta节点上所有排队的insert/update/delete操作会被批处理评估,并且结果会放到一个Set中。这个Set会被转发到下一个节点,并且执行上面同样形式的评估,并把结果放到同一个Set中。面向集合传播在某些规则上具有性能优势,并且为将来做多线程评估提供了可能。
- 分割网络 :在分段中,一个KIE Base中的节点是可以在不同规则之家共享的。PHREAK将规则是为分段的路径,而不是节点的路径。一个不与其他任何规则共享其节点的规则由单个段组成。路径中每个段都分配有1 bit的标志位。当节点包含足够评估的数据时,该标志位设置为on。当段中所有节点都为on时,段本身将设置为on。当规则的所有段都为on时,该规则会被标记为 linked 。Drools利用这些标志位来避免对已经评估的节点和段进行重新评估,从而让PHREAK网络的评估效率更高。
想了解更多关于PHREAK的新消息,可参考:http://blog.athico.com/2015/12/drools-detailed-description-of-internal.html
4. 总结
Drools发展到今天,其实已经是一整套解决方案了。
如果只是想要简单使用,那就是只用到Drools的核心BRE,引入几个Maven依赖,编写Java代码和规则文件即可。但是如果要编排很复杂的工程,甚至整个业务都重度依赖,需要产品、运营同学一起来指定规则,则需要用到BRMS整套解决方案了,包括Drools Expert(BRE)、Drools Workbench、DMN。
所以我们说Drools太重了,主要是在说:
- Drools相关的组件太多,需要逐个研究才知道是否需要
- Drools逻辑复杂,不了解原理,一旦出现问题排查难度高
- Drools需要编写规则文件,学习成本高
这次分析没有看太多Drools的源码,与easy-rules相比,代码复杂度也不是一个数量级上面的。后面可以再开专题去聊一下Drools的源码,对深入理解RETE或者PHREAK也会有很大帮助。
0x03 总结
时间原因,只找了两个最典型的规则引擎项目进行分析,其他像Urule也是用的RETE算法,与Drools的原理大同小异。还有一些轻量级的规则引擎项目,大多也都是简化了规则编排的能力,并依赖表达式引擎做规则判断。
easy-rules | Drools | |
学习成本 | 低 | 高 |
维护成本 | 低 | 高 |
规则编排能力 | 较弱 | 强 |
执行效率 | 较低 | 高 |
是否开源 | 开源 | 开源 |
0x04 参考资料
- [wiki]《Rete algorithm》
- [博客]《drools -Rete算法》,by 双斜杠少年
- [博客]《RETE算法简述 & 实践》,by RyanLee_
- [论文]《Rete: A Fast Algorithm for the Many Pattern/Many Object Pattern Match Problem》,by Charles, Forgy
- [书籍]《Mastering JBoss Drools 6 for Developers》,by Mauricio Salatino
- [代码] github:easy-rules
- [代码] github:drools