【规则引擎】

2年前 (2022) 程序员胖胖胖虎阿
421 0 0

规则引擎

  • 一、什么是规则引擎
  • 二、应用场景
  • 三、带来的好处
  • 四、常见的规则引擎
  • 五、规则引擎对比
    • Drools
    • URule
    • Easy Rules
    • 基于Groovy实现轻量级规则引擎
    • AviatorScript
    • QLExpress
  • 六、按实现分类
  • 七、实现
    • 1. AviatorScript
      • 1.1 原理和特点
      • 1.2 Hello World
        • 1.2.1 AviatorScript 引擎
        • 1.2.2 编译脚本文件
        • 1.2.3 编译脚本文本
        • 1.2.4 执行
      • 1.3 关系

一、什么是规则引擎

规则引擎由推理引擎发展而来,是一种嵌入在应用程序中的组件实现了将业务决策从应用程序代码中分离出来并使用预定义的语义模块编写业务决策

规则引擎具体执行可以分为接受数据输入解释业务规则根据业务规则做出业务决策几个过程。

二、应用场景

在线申请信用卡的业务场景,用户需要录入个人信息,如下图所示

【规则引擎】

通过上图可以看到,用户录入的个人信息包括姓名、性别、年龄、学历、电话、月收入、是否有房、是否有车、是否有信用卡等。录入完成后点击申请按钮提交即可。

用户提交申请后,需要在系统的服务端进行用户信息合法性检查(是否有资格申请信用卡),只有通过合法性检查的用户才可以成功申请到信用卡(注意:不同用户有可能申请到的信用卡额度不同)。

检查用户信息合法性的规则如下:

规则编号 名称 描述
1 检查学历与薪水1 如果申请人既没房也没车,同时学历为大专以下,并且月薪少于5000,那么不通过
2 检查学历与薪水2 如果申请人既没房也没车,同时学历为大专或本科,并且月薪少于3000,那么不通过
3 检查学历与薪水3 如果申请人既没房也没车,同时学历为本科以上,并且月薪少于2000,同时之前没有信用卡的,那么不通过
4 检查申请人已有的信用卡数量 如果申请人现有的信用卡数量大于10,那么不通过

用户信息合法性检查通过后,还需要根据如下信用卡发放规则确定用户所办信用卡的额度:

规则编号 名称 描述
1 规则1 如果申请人有房有车,或者月收入在20000以上,那么发放的信用卡额度为15000
2 规则2 如果申请人没房没车,但月收入在10000~20000之间,那么发放的信用卡额度为6000
3 规则3 如果申请人没房没车,月收入在10000以下,那么发放的信用卡额度为3000
4 规则4 如果申请人有房没车或者没房但有车,月收入在10000以下,那么发放的信用卡额度为5000
5 规则5 如果申请人有房没车或者是没房但有车,月收入在10000~20000之间,那么发放的信用卡额度为8000

思考:如何实现上面的业务逻辑呢?

我们最容易想到的就是使用分支判断(if else)来实现,例如通过如下代码来检查用户信息合法性:

//检查用户信息合法性,返回true表示检查通过,返回false表示检查不通过
public boolean checkUser(User user){
    //如果申请人既没房也没车,同时学历为大专以下,并且月薪少于5000,那么不通过
    if(user.getHouse() == null 
       && user.getcar() == null 
       && user.getEducation().equals("大专以下") 
       && user.getSalary < 5000){
        return false;
    }
    //如果申请人既没房也没车,同时学历为大专或本科,并且月薪少于3000,那么不通过
    else if(user.getHouse() == null 
       && user.getcar() == null 
       && user.getEducation().equals("大专或本科") 
       && user.getSalary < 3000){
        return false;
    }
    //如果申请人既没房也没车,同时学历为本科以上,并且月薪少于2000,同时之前没有信用卡的,那么不通过
    else if(user.getHouse() == null 
       && user.getcar() == null 
       && user.getEducation().equals("本科以上") 
       && user.getSalary < 2000 
       && user.getHasCreditCard() == false){
        return false;
    }
    //如果申请人现有的信用卡数量大于10,那么不通过
    else if(user.getCreditCardCount() > 10){
        return false;
    }
    return true;
}

如果用户信息合法性检查通过后,还需要通过如下代码确定用户所办信用卡的额度:

//根据用户输入信息确定信用卡额度
public Integer determineCreditCardLimit(User user){
    //如果申请人有房有车,或者月收入在20000以上,那么发放的信用卡额度为15000
    if((user.getHouse() != null && user.getcar() != null) 
       || user.getSalary() > 20000){
        return 15000;
    }
    //如果申请人没房没车,并且月收入在10000到20000之间,那么发放的信用卡额度为6000
    else if(user.getHouse() == null 
       && user.getcar() == null
       && user.getSalary() > 10000 
       && user.getSalary() < 20000){
        return 6000;
    }
    //如果申请人没房没车,并且月收入在10000以下,那么发放的信用卡额度为3000
    else if(user.getHouse() == null 
       && user.getcar() == null
       && user.getSalary() < 10000){
        return 3000;
    }
    //如果申请人有房没车或者没房但有车,并且月收入在10000以下,那么发放的信用卡额度为5000
    else if((((user.getHouse() != null && user.getcar() == null) || (user.getHouse() == null && user.getcar() != null))
       && user.getSalary() < 10000){
        return 5000;
    }
    //如果申请人有房没车或者没房但有车,并且月收入在10000到20000之间,那么发放的信用卡额度为8000
    else if((((user.getHouse() != null && user.getcar() == null) || (user.getHouse() == null && user.getcar() != null))
       && (user.getSalary() > 10000 && user.getSalary() < 20000)){
        return 8000;
    }
}

通过上面的伪代码我们可以看到,我们的业务规则是通过Java代码的方式实现的。这种实现方式存在如下问题:

  1. 硬编码实现业务规则难以维护

  2. 硬编码实现业务规则难以应对变化

  3. 业务规则发生变化需要修改代码,重启服务后才能生效

那么面对上面的业务场景,还有什么好的实现方式吗?

答案是规则引擎。

规则引擎实现了将业务决策从应用程序代码中分离出来,接受数据输入,解释业务规则,并根据业务规则做出业务决策。规则引擎其实就是一个输入输出平台。

上面的申请信用卡业务场景使用规则引擎后效果如下:

【规则引擎】
系统中引入规则引擎后,业务规则不再以程序代码的形式驻留在系统中,取而代之的是处理规则的规则引擎,业务规则存储在规则库中,完全独立于程序。业务人员可以像管理数据一样对业务规则进行管理,比如查询、添加、更新、统计、提交业务规则等。业务规则被加载到规则引擎中供应用系统调用。

业务系统运行过程中难免会发生业务规则变化的情形,有了规则引擎,业务规则部分采用的是规则引擎实现,这样在系统正常运行的情况就可以利用规则引擎对业务规则进行修改,从而实现业务规则的随需应便。

三、带来的好处

1、业务规则与系统代码分离,实现业务规则的集中管理

2、在不重启服务的情况下可随时对业务规则进行扩展和维护

3、可以动态修改业务规则,从而快速响应需求变更

4、规则引擎是相对独立的,只关心业务规则,使得业务分析人员也可以参与编辑、维护系统的业务规则

5、减少了硬编码业务规则的成本和风险

6、使用规则引擎提供的规则编辑工具,使复杂的业务规则实现变得的简单

四、常见的规则引擎

开源的代表是Drools,商业的代表是VisualRules ,iLog。

Java 开源的规则引擎有:Drools、URule、Easy Rules。使用最为广泛 Drools。

通过类型对规则引擎进行简单分类,一般有以下3类:

  • 通过界面配置的成熟规则引擎:这种规则引擎相对来说就比较重,但是因为功能全,也有部分业务会选择这个,比较出名的有:Drools、URule
  • 基于JVM脚本语言:这种其实不是一个成熟的规则引擎,应该算是规则引擎中的核心技术,因为Drools这种相对太重了,很多互联网公司会基于一些jvm的脚本语言,开发一个轻量级的规则引擎,比较出名的有:Groovy、AviatorScript、QLExpress。
  • 基于java代码的规则引擎:上面是基于jvm脚本语言去做的,会有一些语法学习的成本,所以就有基于java代码去做的规则引擎,比如通过一些注解实现抽象的方式去做到规则的扩展,比较出名的有: Easy Rules

五、规则引擎对比

Drools

Drools 是用 Java 语言编写的开放源码规则引擎,基于Apache协议,基于RETE算法,于2005年被JBoss收购。

特性:

  • 简化系统架构,优化应用。
  • 提高系统的可维护性和维护成本。
  • 方便系统的整合。
  • 减少编写“硬代码”业务规则的成本和风险。

Drools发展到今天,其实已经是一整套解决方案了。

如果只是想要简单使用,那就是只用到Drools的核心BRE,引入几个Maven依赖,编写Java代码和规则文件即可。但是如果要编排很复杂的工程,甚至整个业务都重度依赖,需要产品、运营同学一起来指定规则,则需要用到BRMS整套解决方案了,包括Drools Expert(BRE)、Drools Workbench、DMN。

所以我们说Drools太重了,主要是在说:

  • Drools相关的组件太多,需要逐个研究才知道是否需要
  • Drools逻辑复杂,不了解原理,一旦出现问题排查难度高
  • Drools需要编写规则文件,学习成本高

Drools 官网 : https://www.drools.org
Drools 中文网 : http://www.drools.org.cn
Drools GitHub : https://github.com/kiegroup/drools

URule

URule是一款纯Java规则引擎,提供基于Apache协议开源版本和商业版本,它以RETE算法为基础,提供了向导式规则集、脚本式规则集、决策表、交叉决策表(PRO版提供)、决策树、评分卡及决策流共六种类型的规则定义方式,配合基于WEB的设计器,可快速实现规则的定义、维护与发布。

URule的优点:

  • 向导式规则配置:可以通过web页面配置规则,不依赖开发人员即可配置规则
  • 决策表、决策树:提供更加友好的、直观的、支持复杂的规则配置
  • 快速测试:在页面可快速构建规则测试
  • 集成方便

缺点:

  • 开源版本提供的功能有限,无高级功能

URule 官网 : http://www.bstek.com
URule GitHub: https://github.com/youseries/urule

Easy Rules

Easy Rules 是一款 Java 规则引擎,它的诞生启发自有Martin Fowler 一篇名为 “Should I use a Rules Engine?” 文章。Easy Rules 提供了规则抽象来创建带有条件和操作的规则,以及运行一组规则来评估条件和执行操作的RulesEngine API。

Easy Rules是一个简单而强大的Java规则引擎,提供以下功能:

  • 轻量级框架和易于学习的API
  • 基于POJO的开发与注解的编程模型
  • 定义抽象的业务规则并轻松应用它们
  • 支持从简单规则创建组合规则的能力
  • 支持使用表达式语言(如MVEL和SpEL)定义规则的能力

Easy Rules GitHub : https://github.com/j-easy/easy-rules

基于Groovy实现轻量级规则引擎

Groovy是动态语言,依靠反射方式动态执行表达式的求值,并且依靠JIT编译器,在执行次数够多以后,编译成本地字节码,因此性能非常的高,适应于反复执行的表达式,用Groovy脚本动态调整线上代码,无须发版。

Groovy编译器先将.groovy文件编译成.class文件,然后调用JVM执行*.class文件,可以在Java项目中集成Groovy并充分利用Groovy的动态功能;

Groovy兼容几乎所有的java语法,开发者完全可以将Groovy当做Java来开发,甚至可以不使用Groovy的特有语法,仅仅通过引入Groovy并使用它的动态能力;

Groovy可以直接调用项目中现有的Java类(通过import导入),通过构造函数构造对象并直接调用其方法并返回结果。

Groovy 官网 : http://www.groovy-lang.org
Groovy GitHub : https://github.com/groovy

AviatorScript

Aviator是一个高性能、轻量级的java语言实现的表达式求值引擎,主要用于各种表达式的动态求值。现在已经有很多开源可用的java表达式求值引擎,为什么还需要Avaitor呢?

Aviator的设计目标是轻量级和高性能 ,相比于Groovy、JRuby的笨重,Aviator非常小,加上依赖包也才450K,不算依赖包的话只有70K;当然,Aviator的语法是受限的,它不是一门完整的语言,而只是语言的一小部分集合。

其次,Aviator的实现思路与其他轻量级的求值器很不相同,其他求值器一般都是通过解释的方式运行,而Aviator则是直接将表达式编译成Java字节码,交给JVM去执行。

支持大部分运算操作符,包括算术操作符、关系运算符、逻辑操作符、位运算符、正则匹配操作符(=~)、三元表达式?: ,并且支持操作符的优先级和括号强制优先级,具体请看后面的操作符列表。

Aviator的限制:

没有if else、do while等语句,没有赋值语句,仅支持逻辑表达式、算术表达式、三元表达式和正则匹配。

不支持八进制数字字面量,仅支持十进制和十六进制数字字面量。

AviatorScript 文档 : https://www.yuque.com/boyan-avfmj/aviatorscript
AviatorScript GitHub : https://github.com/killme2008/aviatorscript

QLExpress

QLExpress从一开始就是从复杂的阿里电商业务系统出发,并且不断完善的脚本语言解析引擎框架,在不追求java语法的完整性的前提下(比如异常处理,foreach循环,lambda表达式,这些都是Groovy的强项),定制了很多普遍存在的业务需求解决方案(比如变量解析,spring打通,函数封装,操作符定制,宏替换),同时在高性能、高并发、线程安全等方面也下足了功夫,久经考验。

QLExpress GitHub : https://github.com/alibaba/QLExpress
参考 : https://developer.aliyun.com/article/621206

六、按实现分类

可分为3类:

  • 低配版:没有配置界面,靠业务人员编写引擎规则DSL,一般存储在数据库或者文件中,这种没有彻底解放业务人员和开发人员的耦合,但是加快了业务代码的上线速度,以及很容易就能进行规则变更。
  • 进阶版:这个一般是某种特定的系统,我们针对这种系统设置一些有针对性的页面,比如下面是某风控系统的截图,风控系统的规则引擎是相对来说比较简单的,只需要判断某些参数是否符合某些条件即可,然后返回固定的值即可。

【规则引擎】

  • 完全版:在进阶版中规则引擎只是其中的一个部件,一般这种都很难复用于其他场景。但是一个完全版的规则引擎,追求的超高的通用性,下面是从一个商业的规则引擎中截图:

【规则引擎】

可以看见提供了多种规则引擎的表达:比如决策集,决策表,决策树等等,适用于我们很多需要使用规则引擎的地方,下面展示了一下决策树的配置,这个就和我们上面风控的配置有点类似,只不过通用性更强。

【规则引擎】

七、实现

1. AviatorScript

AviatorScript 文档 : https://www.yuque.com/boyan-avfmj/aviatorscript
AviatorScript GitHub : https://github.com/killme2008/aviatorscript

1.1 原理和特点

Aviator 的基本过程是将表达式直接翻译成对应的 java 字节码执行,整个过程最多扫两趟(开启执行优先模式,如果是编译优先模式下就一趟),这样就保证了它的性能超越绝大部分解释性的表达式引擎,测试也证明如此;其次,除了依赖 commons-beanutils 这个库之外(用于做反射)不依赖任何第三方库,因此整体非常轻量级,整个 jar 包大小哪怕发展到现在 5.0 这个大版本,也才 430K。同时, Aviator 内置的函数库非常“节制”,除了必须的字符串处理、数学函数和集合处理之外,类似文件 IO、网络等等你都是没法使用的,这样能保证运行期的安全,如果你需要这些高阶能力,可以通过开放的自定义函数来接入。因此总结它的特点是:

● 高性能
● 轻量级
● 一些比较有特色的特点:
	○ 支持运算符重载
	○ 原生支持大整数和 BigDecimal 类型及运算,并且通过运算符重载和一般数字类型保持一致的运算方式。
	○ 原生支持正则表达式类型及匹配运算符 =~ 
	○ 类 clojure 的 seq 库及 lambda 支持,可以灵活地处理各种集合
● 开放能力:包括自定义函数接入以及各种定制选项

1.2 Hello World

AviatorScript 是一门寄生在 JVM (Hosted on the JVM)上的语言。

1.2.1 AviatorScript 引擎

AviatorScript 编译和执行的入口是 AviatorEvaluatorInstance 类,该类的一个实例就是一个编译和执行的单元,这个单元我们称为一个 AviatorScript 引擎,你可以多个引擎,每个引擎可以设置不同的编译和运行选项。

AviatorEvaluator.getInstance() 返回一个全局共享的 AviatorScript 引擎。

1.2.2 编译脚本文件

首先,在你的 java 项目里引用下 AviatorScript 的依赖:

<dependency>
  <groupId>com.googlecode.aviator</groupId>
  <artifactId>aviator</artifactId>
  <version>5.3.1</version>
</dependency>

接下来编写你的第一个 AviatorScript 脚本,AviatorScript 脚本源码文件约定以 .av 作为文件后缀

## av/hello.av

println("hello, AviatorScript!");

连续的两个 # 号引入一行注释。这段代码非常简单,调用 println 函数,打印字符串 hello, AviatorScript! 。

其次,编写一个类来运行测试脚本:

package com.example.springbootdemo.test;

import com.googlecode.aviator.AviatorEvaluator;
import com.googlecode.aviator.AviatorEvaluatorInstance;
import com.googlecode.aviator.Expression;

/**
 * AviatorScrip
 *
 * @author: AviatorScrip
 * @date: 2022年06月14日 14:23
 */
public class AviatorScriptDemo {

    public static void main(String[] args) throws Exception {

        /**
         * 编译脚本文件
         */
        // Compile the script into a Expression instance.
        Expression exp = AviatorEvaluator.getInstance().compileScript("av/hello.av");
        // Run the exprssion.
        exp.execute();
    }
}

这段代码逻辑很简单:

  1. 使用 AviatorEvaluatorInstance#compileScript 方法编译脚本到 Expression 对象
  2. 调用 Expression#execute() 方法执行

输出

hello, AviatorScript!

AviatorEvaluatorInstance 有诸多方法,我们用 compileScript(path) 方法编译一个脚本文件,这个方法对文件路径查找按照下列顺序:

  • path 指定的文件系统绝对或者相对路径
  • user.dir 项目的根目录开始的相对路径
  • classpath 下的绝对和相对路径

找到就尝试读取脚本并动态实时地编译成 JVM 字节码,最终的结果是一个 Expression 对象(为什么叫 Expression 而不是 Script ,这是历史遗留问题,因为 aviator 一开始只是一个表达式引擎)。所有的脚本最终编译的结果都是一个 Expression 对象,它经过:

  • Lexer 文法分析
  • Parser 语法解析
  • 一趟优化:常量折叠、常量池化等简单优化。
  • 第二趟生成 JVM 字节码,并最终动态生成一个匿名 Class
  • 实例化 Class,最终的 Expression 对象。

每次调用 compileScript(path) 都生成一个新的匿名类和对象,因此如果频繁调用会占满 JVM 的 metaspace,可能导致 full gc 或者 OOM,因此还有一个方法 compileScript(path, cached) 可以通过第二个布尔值参数决定是否缓存该编译结果。

// 通过第二个布尔值参数决定是否缓存该编译结果
Expression expCached = AviatorEvaluator.getInstance().compileScript("av/hello.av", true);
expCached.execute();

1.2.3 编译脚本文本

假设你的脚本存储在其他地方,比如数据库的文本字段,或者远程文件,获取后是一个 String 字符串对象,你可以通过 AviatorEvaluatorInstance#compile(script) 方法来编译,同样结果是一个 Expression 对象。事实上 compileScript 方法最终调用的也是 compile 方法。

/**
 * 编译脚本文本
 */
// Compile a script
Expression script = AviatorEvaluator.compile("println('Hello, AviatorScript!');");
script.execute();

AviatorEvaluator.compile 是 AviatorEvaluator.getInstance().compile 的等价方法。

运行结果同样是打印了 Hello, AviatorScript! 。注意,这里字符串 Hello, AviatorScript! 是用单引号括起来的,在 AviatorScript 中字符串可以是双引号括起来,也可以是单引号括起来,作为字面量表达就省去了转义的麻烦。

compile 方法默认不缓存编译结果,同样有缓存的重载版本方法 compile(final String expression, final boolean cached) ,如果第二个参数为 true 将以 script 文本为 key 来缓存编译结果,但是如果你的脚本特别长,用来做缓存 key 会占用比较多的内存,这时候你可以指定缓存 key ,只要调用 compile(final String cacheKey, final String expression, final boolean cached) 方法即可。

1.2.4 执行

编译产生的 Expression 对象,最终都是调用 execute() 方法执行,得到结果。但是 execute 方法还可以接受一个变量列表组成的 map,来注入执行的上下文,我们来一个例子:

/**
 * execute 方法还可以接受一个变量列表组成的 map,来注入执行的上下文
 */
String expression = "a-(b-c) > 100";
Expression compiledExp = AviatorEvaluator.compile(expression);
// Execute with injected variables.
Boolean result = (Boolean) compiledExp.execute(compiledExp.newEnv("a", 100.3, "b", 45, "c", -199.100));
System.out.println(result);

我们编译了一段脚本 a-(b-c) > 100 ,这是一个简单的数字计算和比较,最终返回一个布尔值。a, b, c 是三个变量,它们的值都是未知,没有在脚本里明确赋值,那么可以通过外部传参的方式,将这些变量的值注入进去,同时求得结果,比如例子是通过 Expression#newEnv 方法创建了一个 Map<String, Object 的上下文 map,将 a 设置为 100.3,将 b 设置为 45,将 c 设置为 -199.100,最终代入的执行过程如下:

a-(b-c) > 100 
=> 100.3 - (45 - -199.100) > 100
=> 100.3 - 244.1 > 100
=> -143.8 > 100
=> false

因此返回的 result 就是 false。

这是一个很典型的动态表达式求值的例子,通过复用 Expression 对象,结合不同的上下文 map,你可以对一个表达式反复求值。

1.3 关系

// AviatorEvaluator.getInstance()返回一个全局共享的 AviatorScript 引擎,得到AviatorEvaluatorInstance
AviatorEvaluator.getInstance() => AviatorEvaluatorInstance
// compileScript()编译脚本文件,compileScript 方法最终调用的也是 compile 方法
AviatorEvaluator.getInstance().compileScript("") => compile(cacheKey, Utils.readFully(reader), file.getName(), cached)
// compile()编译脚本文本
AviatorEvaluator.getInstance().compile("")
// AviatorEvaluator.compile 是 AviatorEvaluator.getInstance().compile 的等价方法        
AviatorEvaluator.compile("") => compile(expression, false) => getInstance().compile(expression, cached)

版权声明:程序员胖胖胖虎阿 发表于 2022年9月21日 上午4:00。
转载请注明:【规则引擎】 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...