跟我一起学开源设计第15节:规则引擎EasyRule源码基本执行逻辑设计实现流程

37 阅读18分钟

一、背景

在上篇文章中,我分享了EasyRule的基本使用与规则引擎的背景介绍,本节开始分享这个组件的核心执行流程,正如这个组件的名称一样,很Easy,同时这个组件的源码的抽象设计也是值得学习的。

EasyRules这个组件是一个简单易用的规则引擎,用于在 Java 项目中实现基于规则的决策。EasyRules 的设计目标是简化规则引擎的使用,通过易于理解的 API 提供快速实现业务逻辑的途径。

EasyRules 的一些关键特点包括:

  1. 规则的定义与分离:规则的定义与业务逻辑是解耦的,规则可以根据条件执行对应的动作,条件和动作通过接口进行定义。
  2. 轻量级:与 Drools 等复杂的规则引擎相比,EasyRules 是轻量级的,没有复杂的规则语言,也不需要过多的学习成本。
  3. 简单的 API:规则和规则组都可以通过 Java 的普通类来实现。EasyRules 提供了非常简单的 API 来加载、执行规则,易于集成到现有系统中。
  4. 基于 POJO:规则通过 Java 的 POJO 类来实现,这意味着你不需要学习其他的 DSL(领域特定语言)。

在分享前,先来看下EasyRule的入门例子:

在这个例子中,我们可以看到,一个程序由4部分组成:

本文将围绕这四点进行分析。

二、正文

2.1、项目结构

首先,我们从easyrule的Github主页中下载源码, 同时在IDEA中进行构建,构建后的截图如下所示:

从图中可以看到EasyRules源码结构清晰且模块化,主要由以下模块组成:

  1. easy-rules-archetype:这是提供的Maven项目的脚手架的模块。

  2. easyrules-core

    • 这是整个规则引擎的核心模块,提供了所有与规则引擎相关的基础功能。它包含以下几个重要组件:

      • Rule 接口:定义了规则的结构,包括 evaluate()(条件) 和 execute()(动作) 方法。
      • Rules 类:管理多个规则的容器,内部实现了 Set。
      • RulesEngine 接口及其默认实现 DefaultRulesEngine:提供了执行规则的机制。
      • Facts 类:用于传递给规则的输入,类似于上下文信息。
    • 核心模块负责处理基础规则条件的判断和执行。

  3. easyrules-mvel

    • 提供了与 MVEL (一个轻量级的基于 Java 的表达式语言) 的集成。通过这个模块,开发者可以编写基于 MVEL 的规则,而无需用纯 Java 编写规则。
    • 使用 MVEL,开发者可以在外部文件中定义规则逻辑,极大增强了规则的灵活性。
  4. easyrules-spel

    • 提供了与 Spring Expression Language (SpEL) 的集成。开发者可以使用 SpEL 语言在规则中定义条件和动作,这对于依赖 Spring 生态的项目非常有用。
    • 这一模块允许在 Spring 项目中动态定义和解析规则条件。
  5. easy-rules-jexl:

    • 提供了与JEXL表达式的集成
  6. easyrules-support

    • 包含了一些额外的支持类和扩展功能模块。例如,支持 YAML 格式定义规则等。
    • 提供了便利的工具类,帮助开发者在自定义场景中轻松扩展规则引擎。
  7. easyrules-tutorials

    • 这个模块包含了一些教学示例,展示如何在实际项目中使用 EasyRules。它提供了基础和进阶的使用场景,帮助开发者快速上手。
    • 示例代码涵盖了 easyrules-core、easyrules-mvel、easyrules-spel 等模块的使用。

在这几个模块中,我们核心关注的就是如下4个模块:

  • easy-rules-core:规则引擎的核心逻辑
  • easy-rules-mvel:提供MVEL的支持
  • easy-rules-spel:提供SpELl的支持
  • easy-rules-support:将基本规则组合成复合规则

这里大概介绍下,后续我们重点解析下。接下来我们开始从流程的角度来分析

2.2、创建事实

在这个规则引擎组件中,事实本质就是我们写程序时用到的变量数据信息,有些规则引擎中,会把变量称为:特征,因此有些小伙伴在风控系统等使用到规则引擎的系统中经常看到特征这种词,本质就是一些数据指标、变量信息。

示例中的代码如下:

 Facts facts = new Facts();

我们先来看看这个类都干了啥吧。这个类是core工程下面的一个类,当我们看到这个类里面的源码那一刻我们就知道了:

这个类里面维护了一个Set集合,用来存储每一个事实对象,而这个组件中的每一个事实其实就是一个个参数:

private final Set<Fact<?>> facts = new HashSet<>();

也就是说当我们执行类似如下代码的时候,本质就是往集合中添加了这个Fact对象:

facts.put("变量1", "ddd");
facts.put("变量2", "cccc");

EasyRules 里的 Facts 类是规则引擎的核心部分之一,它是一个“事实容器”,负责存储你需要用来条件判断的“事实”(Fact)。Facts 类就是一个“事实收集站”,里面放着各种“事实小盒子”(Fact)。这些事实小盒子里装的就是规则引擎运行时要用的数据。你可以往这个站点里放事实,也可以从里面取出事实。

核心成员:Set<Fact<?>> facts

Facts 内部的这个 Set 就是所有事实盒子的集合。每个盒子都有个标签(name),而标签背后藏着某种“事实真相”(value)。Set 的特性是,盒子的标签不能重复,因此每个事实的名字得是唯一的。

先来看下Put方法:

put(String name, T value)

    • 你可以用这个方法来添加一个新事实。想象一下,你向收集站里交付了一个标有“名字”和“事实”的盒子。如果已经有同名的盒子,它会被替换掉,规则引擎可不喜欢处理重复的事实!
    • 举个例子:如果你说“天气 = 晴”,然后发现错了,说“天气 = 阴”,那么它会自动替换掉“晴”的盒子,保持最新的“阴”的事实。
facts.put("天气", "阴");

再看看添加方法,截图如下:

add(Fact fact)

    • 这个方法和 put 类似,不过它允许你直接给出一个完整的“事实盒子”。不再单独传入 name 和 value,而是直接丢给它一个装好了的盒子。
    • 如果你已经有一个 Fact 对象,比如 Fact weather = new Fact<>("天气", "阴"),就可以直接丢进收集站了。
facts.add(new Fact<>("天气", "阴"));

再看看删除方法,截图如下:

remove(String factName)remove(Fact fact)

    • 这两个方法是用来清除收集站里某个盒子的。比如你决定“天气”的信息不再需要了,就可以移除掉这个盒子。记住,移除盒子是个认真的活儿,得指定具体的名字或者盒子对象。
    • 换句话说:“把‘天气’这个盒子拿走,不然我们规则就搞不清了!”
facts.remove("天气");

再看看这个类里面的get方法,截图如下:

get(String factName)

    • 想要知道某个盒子里装的“事实”是什么?这时候你就用 get 方法,指定名字,它会告诉你盒子里装的东西。如果没找到对应的盒子,那就只能无奈地返回 null。
    • 你问:“今天的天气是什么?”引擎答:“阴。”
String weather = facts.get("天气");

再看看剩余的几个方法,截图如下:

asMap()

    • asMap 是个小偷懒的工具,可以把所有的“事实盒子”打包成一个 Map,方便查询。每个盒子的名字会作为 Map 的键,里面装的事实会是值。
    • 想象一下,你有一张事实表格,上面写着所有盒子的名字和它们里面的内容。非常方便!
Map<String, Object> factsMap = facts.asMap();

iterator()

    • 这个方法让你可以遍历整个事实收集站的所有盒子。你可以想象一下,规则引擎会拿着个小推车走过每个盒子,查看里面装了什么。
    • 不过别想着在它遍历的时候偷拿或者随便移动盒子,规则引擎可不喜欢外部打乱它的计划。
for (Fact<?> fact : facts) {
    System.out.println(fact.getName() + ": " + fact.getValue());
}

clear()

    • 这个方法就是清空收集站的“大扫除”。所有的盒子都会被清空,规则引擎会从头开始,不再依赖旧的事实。

这样在第一步中,我们定义事实,本质就是将我们的规则中的特征信息作为变量传递给组件,然后组件针对每一个规则信息,封装为一个个的事实对象存储起来。

Facts 类就像你家中的一个小储物间。你可以往里面放各种各样的小盒子(Fact),每个盒子上都贴着标签(name),里面装着你知道的“真相”(value)。当你需要这些事实来做决定(比如决定是不是要执行某条规则时),你就可以从储物间里拿出来看看。

有意思的是,Facts 类不仅支持你灵活地管理这些盒子(添加、删除、查看),还会帮你避免重复的盒子,让你的小储物间始终整齐有序。这就像当你给朋友讲了两次相同的笑话,朋友可不会觉得好笑——而是会自动忽略第二个笑话!

2.3、创建规则

对于我们经常使用的代码:

HelloWorldRule helloWorldRule = new HelloWorldRule();
        
Rules rules = new Rules();

我们会通过自定义一个规则类的方式来声明一个规则,同时观察Rules源码可以发现在设计方面,它和Facts事实类基本设计思想一致,内部维护了多个规则集合信息:

这里需要关注这个Rule规则接口,这个接口统一定义了规则的抽象信息,如下所示:

Rule 接口是 EasyRules 中的规则抽象,它定义了一个可被规则引擎执行的规则。每个规则都必须有一个唯一的名称、描述和优先级,默认名称为“rule”,描述为“description”,优先级值越小优先级越高。该接口主要包括两个方法:evaluate 用于定义规则的条件,返回布尔值,表示是否应该执行规则;execute 则执行与规则相关的操作。规则通过 evaluate(Facts facts) 方法判断是否满足条件,当条件为真时,规则的动作通过 execute(Facts facts) 执行。每条规则的优先级决定了其在规则集合中的执行顺序,数字越小优先级越高。Rule 接口的设计使得开发者可以灵活定义各种业务规则,并通过规则引擎自动管理它们的执行流程,实现条件判断和自动化决策。

在上一篇文章中分享过创建规则的几种方式,本节简单介绍下通过注解的方式创建规则。我们通过在一个普通的类上面指定几个注解,就可以完成一个规则的定义。

2.4、注册规则

注册规则也是当前组件的核心代码,使用的时候的代码如下:

Rules rules = new Rules();
rules.register(helloWorldRule);

我们来看看register方法吧:

可以看到,对于诸恶的过程本质就是将Rule对象添加到Set集合中,取消注册也是。这里我们需要关注的是这个代码:

RuleProxy.asRule(rule)

通过字面意思我们知道,这个可能是一个代理类,传递一个类,返回代理后的规则对象。

RuleProxy 的作用是通过动态代理将使用注解定义的类适配为 Rule 接口的实现类,这样 EasyRules 引擎可以处理这些规则。

它使得开发者可以通过简单的注解来定义规则,而不必显式实现 Rule 接口,极大简化了规则的创建和管理。

这就像是一个规则的“翻译器”,把注解转换为引擎可以理解和执行的规范接口,使得复杂的逻辑实现可以隐藏在背后,开发者只需要专注于业务规则本身。

你可以想象 RuleProxy 就像是个“规则代理人”。你只需要用简单的注解写个规则草稿,剩下的都交给它——它会帮你检查规则合法不合法,然后把规则翻译成引擎能听懂的语言,最后站在前台帮你“打电话”执行各种条件和动作。真正让你“动动手指,规则搞定”,后台全由它来跑腿。

再来看看源码:

通过源码,可以非常清晰的看到这个会先判断是否是Rule的对象,如果不是则通过动态代理的方式来创建一个规则类,类上面也继承了InvocationHandler,就是一个动态代理实现的类:

代码的核心是 RuleProxy 类,它在 EasyRules 规则引擎中用于将带有注解的对象转换为实现了 Rule 接口的代理对象。其主要目的是通过动态代理的方式,让使用注解定义的规则类能够符合 Rule 接口的规范,从而可以被规则引擎处理和执行。

其他解释如下:

RuleProxy 实现了 InvocationHandler 接口

InvocationHandler 是 Java 动态代理机制的一部分,允许我们拦截对目标对象方法的调用。RuleProxy 使用这一机制来拦截对带注解规则类的方法调用,并根据规则定义执行特定逻辑(如条件检查和动作执行)。

asRule 方法

这是 RuleProxy 类的核心方法。它将一个带有注解的规则对象转换为一个 Rule 接口的代理对象。该方法首先检查对象是否已经实现了 Rule 接口。如果已经实现,则直接返回原对象;否则,它使用 Java 的动态代理机制将该对象包装成一个实现了 Rule 和 Comparable 接口的代理对象。

ruleDefinitionValidator.validateRuleDefinition(rule)

该行代码用于验证传入的对象是否是有效的规则定义,确保带有正确的注解并且符合规则引擎的要求(例如,必须有一个条件和至少一个动作)。

代理对象的执行逻辑

当代理对象的方法被调用时,RuleProxy 会处理这些调用。具体来说,它会根据对象上定义的注解(如 @Condition 和 @Action),决定应该执行哪些逻辑。这让使用注解定义规则变得更灵活和简洁。

说道这里,我们可以先猜一下未来的代码的执行流程:

接下来我们再看下规则的触发。

2.5、注册触发

在客户端使用的时候,我们的调用代码如下:

// create a rules engine and fire rules on known facts
RulesEngine rulesEngine = new DefaultRulesEngine();
rulesEngine.fire(rules, facts);

代码截图如下:

这里可以看到定义了多种规则引擎的执行逻辑:

然后我们使用的是默认的规则引擎行为。

InferenceRulesEngine 和 DefaultRulesEngine 是 EasyRules 规则引擎中的两个核心类,它们都实现了 RulesEngine 接口,但在规则执行的方式上有所不同。

1. DefaultRulesEngine

  • 作用: DefaultRulesEngine 是最常用的规则引擎,按照预定义的顺序一次性执行所有规则。

  • 执行逻辑: 它会遍历所有规则,根据优先级从高到低执行。在每个规则执行之前,都会执行该规则的条件 (evaluate 方法),如果条件成立则执行动作 (execute 方法)。

  • 适用场景: 适用于大多数场景,特别是规则之间没有复杂的依赖关系时。

  • 特点:

    • 执行流程简单,所有符合条件的规则会按顺序执行。
    • 每个规则只判断条件和执行一次。

例子

  • 如果你有一系列独立的规则,比如判断天气、发送通知等,DefaultRulesEngine 可以依次执行这些规则,不会有复杂的依赖关系。

2. InferenceRulesEngine

  • 作用: InferenceRulesEngine 是一个基于推理的规则引擎,支持循环推理规则的执行。

  • 执行逻辑: 该引擎不仅会遍历规则,还会在每个规则执行后重新判断条件的所有的规则。这样,规则的执行可能会触发其他规则,从而形成一个规则链。这个过程会持续,直到所有规则都不再触发为止。

  • 适用场景: 适用于规则之间有依赖关系,或者规则的执行可能改变其他规则的条件判断结果的情况。比如,规则 A 的执行可能会影响规则 B 的条件。

  • 特点:

    • 基于“前向推理”的思想,规则会反复执行,直到没有任何新的规则可以触发。
    • 适合规则间有复杂关系或递归依赖的情况。

例子

  • 如果规则 A 的执行改变了事实库中的数据,使得规则 B 可以触发,则 InferenceRulesEngine 会重新条件判断所有规则,直到没有新的规则可以触发。

3. 区别总结

  • 执行方式:

    • DefaultRulesEngine 只条件判断和执行一次规则,不会重新执行条件判断。
    • InferenceRulesEngine 支持推理,会在每次规则执行后重新执行条件判断所有规则,直到没有新的规则触发。
  • 适用场景:

    • DefaultRulesEngine 适合独立的、不相互依赖的规则。
    • InferenceRulesEngine 适合复杂的、相互依赖的规则,需要反复推理条件判断的场景。

4. 联系

  • 两者都实现了 RulesEngine 接口,基本执行逻辑相同,即通过规则的 evaluate 和 execute 方法来实现规则的条件判断和动作执行。
  • 都依赖于 Rules 和 Facts 来执行规则。

简单总结:

  • DefaultRulesEngine 是“一次性执行完”的简单模式。
  • InferenceRulesEngine 则是“反复条件判断、逐步推理”的循环模式。

接下来我们再看看核心的fire方法,这个方法设计的不错,值得每个程序员学习,在阅读之前可以先看看整体流程描述:

doFire 方法负责执行一组注册规则 (rules) 对给定事实 (facts) 的条件判断和执行。流程如下:

  1. 规则检查: 如果没有注册任何规则,记录警告并返回。
  2. 日志记录: 记录引擎参数、规则和事实的状态。
  3. 规则条件判断: 遍历所有规则,依次执行每条规则的条件。
  4. 条件执行: 如果规则条件成立,则执行相应的动作,并记录成功或失败的状态。
  5. 控制流程: 根据配置参数决定是否跳过后续规则的执行。

首先我们来看看方法的上半段:

  1. 检查规则是否为空:

    • if (rules.isEmpty()) { ... }: 如果没有规则,发出警告。
  2. 记录日志:

    • 记录引擎参数和输入的规则及事实。
  3. 遍历规则:

    • for (Rule rule : rules) { ... }: 对每条规则进行迭代。
  4. 优先级检查:

    • if (priority > parameters.getPriorityThreshold()) { ... }: 检查规则优先级是否超过阈值,超过则跳过后续规则。
  5. 前置监听拦截检查:

    • !shouldBeEvaluated(rule, facts): 检查规则的前置监听器是否可以被执行,这个就相当于拦截器,通过观察者的方式提前注册了观察者,然后再前置动作中进行扩展处理,设计的思路不错。

然后我们再来看下后半段:

后半段的逻辑也非常清楚:

  1. 规则条件判断:

    • boolean evaluationResult = rule.evaluate(facts);: 调用规则的 evaluate 方法来判断条件是否成立。
  2. 异常处理:

    • 使用 try-catch 捕获过程中的异常,并根据配置决定是否跳过后续规则。
  3. 执行规则:

    • rule.execute(facts);: 如果条件执行的结果为真,执行相应的动作,并记录执行成功或失败。

之前我说过动态代理,这里遍历Rule对象,也就是说实际上我们在执行evaluate方法和execute方法的时候,会执行到RuleProxy中的动态代理的方法:

可以看到在invoke方法中,判断方法名称是不是条件执行的方法,是不是执行具体动作的方法。

再看下执行条件的evaluateMethod方法实现,基本就是获取到当前规则的包含@Condition注解的方法,然后调用下,从而获取返回结果:

真正执行的方法,也是如此:

这样我们就知道了,当前这个HelloWorld这种规则是如何执行的了,我相信此时你看到如下代码,脑子中会时刻想起刚才的这些代码的底层原理:

 // create facts
        Facts facts = new Facts();
        

        // create rules
        HelloWorldRule helloWorldRule = new HelloWorldRule();
        
        // register
        Rules rules = new Rules();
        rules.register(helloWorldRule);

        // create a rules engine and fire rules on known facts
        RulesEngine rulesEngine = new DefaultRulesEngine();
        rulesEngine.fire(rules, facts);

三、总结

本文分享了EasyRule中的源码中的一个基本的执行流程,本质就是将我们的规则和事实信息存储到集合中,然后通过动态代理创建一个Rule对象,在规则执行的时候,先执行包含@Condition注解的方法,在执行@Action注解的方法,从而实现了一个规则的触发和执行,这里面用到了大量的设计思路,对我们个人开发设计能力的提示是非常有帮助的。

感兴趣的小伙伴可以交流、学习哦。