基于Groovy的规则脚本引擎实战

前言

由于以前在项目中使用了Groovy对业务进行一些抽象,效果比较好,过程当中也踩了一些坑,因此简单记录分享一下本身如何一步一步实现的,在这里你能够了解:java

一、为何选用groovy做为规则脚本引擎node

二、了解Groovy的基本原理和Java如何集成git

三、分析Groovy与java集成的一些问题和坑github

四、在项目中使用时作了哪些性能优化spring

五、实际使用时需考虑的一些tips数据库

规则脚本可解决的问题

互联网时代随着业务的飞速发展,迭代和产品接入的速度愈来愈快,须要一些灵活的配置。办法一般有以下几个方面:缓存

一、最为传统的方式是java程序直接写死提供几个可调节的参数配置而后封装成为独立的业务模块组件,在增长参数或简单调整规则后,从新调上线。性能优化

二、使用开源方案,例如drools规则引擎,此类引擎适合业务较复杂的系统bash

三、使用动态脚本引擎:groovy,simpleEl,QLExpressapp

引入规则脚本对业务进行抽象可大大提高效率。 例如,笔者以前开发的贷款审核系统中,贷款的订单在收单后会通过多个流程的扭转:收单后需根据风控系统给出结果决定订单的流程,而不一样的产品的订单的扭转规则是不一致的,每接入一个新产品,码农都要写一堆对于此产品的流程逻辑;现有的产品的规则也常常须要更换。因此想利用脚本引擎的动态解析执行,到使用规则脚本将流程的扭转抽象出来,提高效率。

如何选轮子

考虑到基于自身的业务的复杂性,传统的开源方案如Acitivities和drools,对于个人业务来讲,过于重了。 再对于脚本引擎来讲最多见的其实就是groovy了,ali有一些开源项目 ,对于不一样的规则脚本,选型时须要考虑性能、稳定性、语法灵活性,综合考虑下选择Groovy有以下几点缘由:

一、历史悠久、使用范围大,坑少

二、和java兼容性强:无缝衔接java代码,即便不懂groovy语法也不要紧

三、语法糖

四、项目周期短,上线时间紧急😢

项目流程的抽象

由于不一样业务在流程扭转时对于逻辑的处理是不一致的。咱们先考虑一种简单的状况: 自己的项目在业务上会对不一样的贷款订单进行流程扭转,例如订单能够从流程A扭到流程B或者流程C,取决于每个Strategy Unit的执行状况(以下图):每一个Strategy Unit执行后会返回Boolean值。具体的逻辑能够本身定义,在这里咱们假设:若是知足全部Strategy Unit A的的条件(即每一个执行单元都返回true),那么订单就会扭转至Scenario B;若是知足全部Strategy Unit B的的条件,那么订单就会扭转至Scenario C。

为何设计成多个StrategyLogicUnit呢?是由于个人项目中,为了方便配置,将整个流程的StrategyLogicUnit的配置展现在了UI上,可读性更强、修改时也只须要修改某一个unit中的执行逻辑。

1536844912066.jpg

每一个StrategyLogicUnit执行时依赖的数据咱们能够把它抽象为一个Context,context中包含两部分数据:一部分是业务上的数据:例如订单的产品,订单依赖的风控数据等,另外一部分是规则执行数据:包括当前执行的node、所属的策略组信息、当前的流程、下一个流程等,这一部分规则引擎执行数据的context能够根据不一样的业务进行设计,设计时主要考虑断点重跑、策略组等:好比能够设计不一样策略组与产品的关联,这一部分业务耦合性比较大,本文主要focus在groovy上。

能够把Context理解为StrategyLogicUnit的输入和输出,StrategyLogicUnit在Groovy中进行执行,咱们能够对每个执行的StrategyLogicUnit进行可配置化的展现和配置。执行过程当中能够根据context中含有的不一样的信息进行逻辑判断,也能够改变context对象中的值。

基于流程将Groovy与Java的集成

那么基于如上流程,咱们如何结合Groovy和java呢? 基于上面的设计,Groovy脚本的执行本质上只是接受context对象,而且基于context对象中的关键信息进行逻辑判断,输出结果。而结果也保存在context中。 先看看Groovy与java集成的方式:

GroovyClassLoader

用 Groovy 的 GroovyClassLoader ,它会动态地加载一个脚本并执行它。GroovyClassLoader是一个Groovy定制的类装载器,负责解析加载Java类中用到的Groovy类。

GroovyShell

GroovyShell容许在Java类中(甚至Groovy类)求任意Groovy表达式的值。您可以使用Binding对象输入参数给表达式,并最终经过GroovyShell返回Groovy表达式的计算结果。

GroovyScriptEngine

GroovyShell多用于推求对立的脚本或表达式,若是换成相互关联的多个脚本,使用GroovyScriptEngine会更好些。GroovyScriptEngine从您指定的位置(文件系统,URL,数据库,等等)加载Groovy脚本,而且随着脚本变化而从新加载它们。如同GroovyShell同样,GroovyScriptEngine也容许您传入参数值,并能返回脚本的值。

以GroovyClassLoader为例

三种方式均可以实现,如今咱们以GroovyClassLoader为例,展现一下如何实现与java的集成:

例如:咱们假设申请金额大于20000的订单进入流程B 在SpringBoot项目中maven中引入

<dependency>
            <groupId>org.codehaus.groovy</groupId>
            <artifactId>groovy-all</artifactId>
            <version>2.4.10</version>
</dependency>
复制代码

定义Groovy执行的java接口:

public interface EngineGroovyModuleRule {
    boolean run(Object context);
}
复制代码

抽象出一个Groovy模板文件,放在resource下面以便加载:

import com.groovyexample.groovy.*
class %s implements EngineGroovyModuleRule {
    boolean run(Object context){
        %s //业务执行逻辑:可配置化
    }
}
复制代码

接下来主要是解析Groovy的模板文件,能够将模板文件缓存起来,解析我是经过spring的PathMatchingResourcePatternResolver进行的;下面的StrategyLogicUnit这个String就是具体的业务规则的逻辑,把这一部分的逻辑进行一个配置化。 例如:咱们假设执行的逻辑是:申请订单的金额大于20000时,走流程A,代码简单实例以下:

//解析Groovy模板文件
        ConcurrentHashMap<String,String> concurrentHashMap = new ConcurrentHashMap(128);
        final String path = "classpath*:*.groovy_template";
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        Arrays.stream(resolver.getResources(path))
                .parallel()
                .forEach(resource -> {
                    try {
                        String fileName = resource.getFilename();
                        InputStream input = resource.getInputStream();
                        InputStreamReader reader = new InputStreamReader(input);
                        BufferedReader br = new BufferedReader(reader);
                        StringBuilder template = new StringBuilder();
                        for (String line; (line = br.readLine()) != null; ) {
                            template.append(line).append("\n");
                        }
                        concurrentHashMap.put(fileName, template.toString());
                    } catch (Exception e) {
                        log.error("resolve file failed", e);
                    }
                });
        String scriptBuilder = concurrentHashMap.get("ScriptTemplate.groovy_template");
        String scriptClassName = "testGroovy";
        //这一部分String的获取逻辑进行可配置化
        String StrategyLogicUnit = "if(context.amount>=20000){\n" +
                " context.nextScenario='A'\n" +
                " return true\n" +
                " }\n" +
                " ";
        String fullScript = String.format(scriptBuilder, scriptClassName, StrategyLogicUnit);


复制代码
GroovyClassLoader classLoader = new GroovyClassLoader();
    Class<EngineGroovyModuleRule> aClass = classLoader.parseClass(fullScript);
    Context context = new Context();
    context.setAmount(30000);
    try {
        EngineGroovyModuleRule engineGroovyModuleRule = aClass.newInstance();
        log.info("Groovy Script returns:{} "+engineGroovyModuleRule.run(context));
        log.info("Next Scenario is {}"+context.getNextScenario());
    }
    catch (Exception e){
       log.error("error...")
    }
复制代码

执行上述代码:

Groovy Script returns: true
Next Scenario is A
复制代码

关键的部分是StrategyLogicUnit这个部分的可配置化,咱们是经过管理端UI上展现不一样产品对应的StrategyLogicUnit,并可进行CRUD,为了方便配置同时引进了策略组、产品策略复制关联、一键复制模板等功能。

集成过程当中的坑和性能优化

项目在测试时就发现随着收单的数量增长,进行频繁的Full GC,测试环境复现后查看日志显示:

[Full GC (Metadata GC Threshold) [PSYoungGen: 64K->0K(43008K)] [ParOldGen: 3479K->3482K(87552K)] 3543K->3482K(130560K), [Metaspace: 15031K->15031K(1062912K)], 0.0093409 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
复制代码

日志中能够看出是mataspace空间不足,而且没法被full gc回收。 经过JVisualVM能够查看具体的状况:

1537278779824.jpg
发现class太多了,有2326个,致使metaspace满了。咱们先回顾一下metaspace ##metaspace和permgen 这是jdk在1.8中才有的东西,而且1.8讲将permgen去除了,其中的方法区移到non-heap中的Metaspace。
1537279213725.jpg
这个区域主要存放:存储类的信息、常量池、方法数据、方法代码等。 分析主要问题有两方面:

问题1:Class数量问题:多是引入groovy致使加载的类过多了,但实际上项目只配置了10个StrategyLogicUnit,不一样的订单执行同一个StrategyLogicUnit时应该对应同一个class。class的数量过于异常。

问题2:就算Class数量过多,Full GC为什么没有办法回收?

下面咱们带着问题来学习。

GroovyClassLoader的加载

咱们先分析Groovy执行的过程,最关键的代码是以下几部分:

GroovyClassLoader classLoader = new GroovyClassLoader();
 Class<EngineGroovyModuleRule> aClass = classLoader.parseClass(fullScript);
 EngineGroovyModuleRule engineGroovyModuleRule = aClass.newInstance();
engineGroovyModuleRule.run(context)
复制代码

GroovyClassLoader是一个定制的类装载器,在代码执行时动态加载groovy脚本为java对象。你们都知道classloader的双亲委派,咱们先来分析一下这个GroovyClassloader,看看它的祖先分别是啥:

def cl = this.class.classLoader  
while (cl) {  
    println cl  
    cl = cl.parent  
}  
复制代码

输出:

groovy.lang.GroovyClassLoader$InnerLoader@13322f3  
groovy.lang.GroovyClassLoader@127c1db  
org.codehaus.groovy.tools.RootLoader@176db54  
sun.misc.Launcher$AppClassLoader@199d342  
sun.misc.Launcher$ExtClassLoader@6327fd  
复制代码

从而得出:

Bootstrap ClassLoader  
             ↑  
sun.misc.Launcher.ExtClassLoader      // 即Extension ClassLoader  
             ↑  
sun.misc.Launcher.AppClassLoader      // 即System ClassLoader  
             ↑  
org.codehaus.groovy.tools.RootLoader  // 如下为User Custom ClassLoader  
             ↑  
groovy.lang.GroovyClassLoader  
             ↑  
groovy.lang.GroovyClassLoader.InnerLoader  
复制代码

查看关键的GroovyClassLoader.parseClass方法,发现以下代码:

public Class parseClass(String text) throws CompilationFailedException {
        return parseClass(text, "script" + System.currentTimeMillis() +
                Math.abs(text.hashCode()) + ".groovy");
    }
复制代码
protected ClassCollector createCollector(CompilationUnit unit, SourceUnit su) {
        InnerLoader loader = AccessController.doPrivileged(new PrivilegedAction<InnerLoader>() {
            public InnerLoader run() {
                return new InnerLoader(GroovyClassLoader.this);
            }
        });
        return new ClassCollector(loader, unit, su);
    }
复制代码

这两处代码的意思是: groovy每执行一次脚本,都会生成一个脚本的class对象,这个class对象的名字由 "script" + System.currentTimeMillis() + Math.abs(text.hashCode()组成,对于问题1:每次订单执行同一个StrategyLogicUnit时,产生的class都不一样,每次执行规则脚本都会产品一个新的class。

接着看问题2InnerLoader部分: groovy每执行一次脚本都会new一个InnerLoader去加载这个对象,而对于问题2,咱们能够推测:InnerLoader和脚本对象都没法在fullGC的时候被回收,所以运行一段时间后将PERM占满,一直触发fullGC。

为何须要有innerLoader呢?

结合双亲委派模型,因为一个ClassLoader对于同一个名字的类只能加载一次,若是都由GroovyClassLoader加载,那么当一个脚本里定义了C这个类以后,另一个脚本再定义一个C类的话,GroovyClassLoader就没法加载了。

因为当一个类的ClassLoader被GC以后,这个类才能被GC。

若是由GroovyClassLoader加载全部的类,那么只有当GroovyClassLoader被GC了,全部这些类才能被GC,而若是用InnerLoader的话,因为编译完源代码以后,已经没有对它的外部引用,除了它加载的类,因此只要它加载的类没有被引用以后,它以及它加载的类就均可以被GC了。

Class回收的条件(摘自《深刻理解JVM虚拟机》)

JVM中的Class只有知足如下三个条件,才能被GC回收,也就是该Class被卸载(unload):

一、该类全部的实例都已经被GC,也就是JVM中不存在该Class的任何实例。

二、加载该类的ClassLoader已经被GC。

三、该类的java.lang.Class

对象没有在任何地方被引用,如不能在任何地方经过反射访问该类的方法. 一个一个分析这三点:

第一点被排除:

查看GroovyClassLoader.parseClass()代码,总结:Groovy会把脚本编译为一个名为Scriptxx的类,这个脚本类运行时用反射生成一个实例并调用它的MAIN函数执行,这个动做只会被执行一次,在应用里面不会有其余地方引用该类或它生成的实例;

第二点被排除:

关于InnerLoader:Groovy专门在编译每一个脚本时new一个InnerLoader就是为了解决GC的问题,因此InnerLoader应该是独立的,而且在应用中不会被引用;

只剩下第三种可能:

该类的Class对象有被引用,继续查看代码:

/**
     * sets an entry in the class cache.
     *
     * @param cls the class
     * @see #removeClassCacheEntry(String)
     * @see #getClassCacheEntry(String)
     * @see #clearCache()
     */
    protected void setClassCacheEntry(Class cls) {
        synchronized (classCache) {
            classCache.put(cls.getName(), cls);
        }
    }
复制代码

能够复现问题并查看缘由:具体思路是无限循环解析脚本,jmap -clsstat查看classloader的状况,并结合导出dump查看引用关系。 因此总结缘由是:每次groovy parse脚本后,会缓存脚本的Class,下次解析该脚本时,会优先从缓存中读取。这个缓存的Map由GroovyClassLoader持有,key是脚本的类名,value是class,class对象的命名规则为:

"script" + System.currentTimeMillis() + Math.abs(text.hashCode()) + ".groovy"

所以,每次编译的对象名都不一样,都会在缓存中添加一个class对象,致使class对象不可释放,随着次数的增长,编译的class对象将PERM区撑满。

解决方案

大多数的状况下,Groovy都是编译后执行的,实际在本次的应用场景中,虽然是脚本是以参数传入,但其实大多数脚本的内容是相同的。解决方案就是在项目启动时经过InitializingBean接口对于 parseClass 后生成的 Class 对象进行缓存,key 为 groovyScript 脚本的md5值,而且在配置端修改配置后可进行缓存刷新。 这样作的好处有两点:

一、解决metaspace爆满的问题

二、由于不须要在运行时编译加载,因此能够加快脚本执行的速度

总结

Groovy适合在业务变化较多、较快的状况下进行一些可配置化的处理,它容易上手:其本质上也是运行在jvm的java代码,咱们在使用时需了解清楚它的类加载机制,对于内存存储的基础烂熟于心,并经过缓存解决一些潜在的问题同时提高性能。适合规则数量相对较小的且不会频繁更新规则的规则引擎。

已整理模板至github: github.com/loveurwish/…

相关文章
相关标签/搜索