基於Groovy的規則腳本引擎實戰

由於以前在項目中使用了Groovy對業務進行一些抽象,效果比較好,過程當中也踩了一些坑,因此簡單記錄分享一下本身如何一步一步實現的,在這裏你能夠了解:
javascript

一、爲何選用groovy做爲規則腳本引擎php

二、瞭解Groovy的基本原理和Java如何集成css

三、分析Groovy與java集成的一些問題和坑java

四、在項目中使用時作了哪些性能優化node

五、實際使用時需考慮的一些tipsspring

互聯網時代隨着業務的飛速發展,迭代和產品接入的速度愈來愈快,須要一些靈活的配置。辦法一般有以下幾個方面:typescript

一、最爲傳統的方式是java程序直接寫死提供幾個可調節的參數配置而後封裝成爲獨立的業務模塊組件,在增長參數或簡單調整規則後,從新調上線。數據庫

二、使用開源方案,例如drools規則引擎,此類引擎適合業務較複雜的系統json

三、使用動態腳本引擎:groovy,simpleEl,QLExpress緩存

引入規則腳本對業務進行抽象可大大提高效率。例如,筆者以前開發的貸款審覈系統中,貸款的訂單在收單後會通過多個流程的扭轉:收單後需根據風控系統給出結果決定訂單的流程,而不一樣的產品的訂單的扭轉規則是不一致的,每接入一個新產品,碼農都要寫一堆對於此產品的流程邏輯;現有的產品的規則也常常須要更換。因此想利用腳本引擎的動態解析執行,到使用規則腳本將流程的扭轉抽象出來,提高效率。

考慮到基於自身的業務的複雜性,傳統的開源方案如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中的執行邏輯。

每一個StrategyLogicUnit執行時依賴的數據咱們能夠把它抽象爲一個Context,context中包含兩部分數據:一部分是業務上的數據:例如訂單的產品,訂單依賴的風控數據等,另外一部分是規則執行數據:包括當前執行的node、所屬的策略組信息、當前的流程、下一個流程等,這一部分規則引擎執行數據的context能夠根據不一樣的業務進行設計,設計時主要考慮斷點重跑、策略組等:好比能夠設計不一樣策略組與產品的關聯,這一部分業務耦合性比較大,本文主要focus在groovy上。

能夠把Context理解爲StrategyLogicUnit的輸入和輸出,StrategyLogicUnit在Groovy中進行執行,咱們能夠對每個執行的StrategyLogicUnit進行可配置化的展現和配置。執行過程當中能夠根據context中含有的不一樣的信息進行邏輯判斷,也能夠改變context對象中的值。

那麼基於如上流程,咱們如何結合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能夠查看具體的狀況:

發現class太多了,有2326個,致使metaspace滿了。咱們先回顧一下metaspace ##metaspace和permgen 這是jdk在1.8中才有的東西,而且1.8講將permgen去除了,其中的方法區移到non-heap中的Metaspace。

這個區域主要存放:存儲類的信息、常量池、方法數據、方法代碼等。分析主要問題有兩方面:

問題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 
   * @see 
   * @see 
   */
  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代碼,咱們在使用時需瞭解清楚它的類加載機制,對於內存存儲的基礎爛熟於心,並經過緩存解決一些潛在的問題同時提高性能。適合規則數量相對較小的且不會頻繁更新規則的規則引擎。

轉自:https://juejin.im/post/6844903682639659015

相關文章
相關標籤/搜索