前言
因為之前在專案中使用了Groovy對業務進行一些抽象,效果比較好,過程中也踩了一些坑,所以簡單記錄分享一下自己如何一步一步實現的,在這裡你可以瞭解:
1、為什麼選用groovy作為規則指令碼引擎
2、瞭解Groovy的基本原理和Java如何整合
3、分析Groovy與java整合的一些問題和坑
4、在專案中使用時做了哪些效能優化
5、實際使用時需考慮的一些tips
規則指令碼可解決的問題
網際網路時代隨著業務的飛速發展,迭代和產品接入的速度越來越快,需要一些靈活的配置。辦法通常有如下幾個方面:
1、最為傳統的方式是java程式直接寫死提供幾個可調節的引數配置然後封裝成為獨立的業務模組元件,在增加引數或簡單調整規則後,重新調上線。
2、使用開源方案,例如drools規則引擎,此類引擎適合業務較複雜的系統
3、使用動態指令碼引擎:groovy,simpleEl,QLExpress
引入規則指令碼對業務進行抽象可大大提升效率。
例如,筆者之前開發的貸款稽核系統中,貸款的訂單在收單後會經過多個流程的扭轉:收單後需根據風控系統給出結果決定訂單的流程,而不同的產品的訂單的扭轉規則是不一致的,每接入一個新產品,碼農都要寫一堆對於此產品的流程邏輯;現有的產品的規則也經常需要更換。所以想利用指令碼引擎的動態解析執行,到使用規則指令碼將流程的扭轉抽象出來,提升效率。
如何選輪子
考慮到基於自身的業務的複雜性,傳統的開源方案如Acitivities和drools,對於我的業務來說,過於重了。
再對於指令碼引擎來說最常見的其實就是groovy了,ali有一些開源專案 ,對於不同的規則指令碼,選型時需要考慮效能、穩定性、語法靈活性,綜合考慮下選擇Groovy有如下幾點原因:
1、歷史悠久、使用範圍大,坑少
2、和java相容性強:無縫銜接java程式碼,即使不懂groovy語法也沒關係
3、語法糖
4、專案週期短,上線時間緊急?
專案流程的抽象
因為不同業務在流程扭轉時對於邏輯的處理是不一致的。我們先考慮一種簡單的情況:
本身的專案在業務上會對不同的貸款訂單進行流程扭轉,例如訂單可以從流程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和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("
");
}
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){
" +
" context.nextScenario=`A`
" +
" return true
" +
" }
" +
" ";
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):
1、該類所有的例項都已經被GC,也就是JVM中不存在該Class的任何例項。
2、載入該類的ClassLoader已經被GC。
3、該類的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值,並且在配置端修改配置後可進行快取重新整理。
這樣做的好處有兩點:
1、解決metaspace爆滿的問題
2、因為不需要在執行時編譯載入,所以可以加快指令碼執行的速度
總結
Groovy適合在業務變化較多、較快的情況下進行一些可配置化的處理,它容易上手:其本質上也是執行在jvm的java程式碼,我們在使用時需瞭解清楚它的類載入機制,對於記憶體儲存的基礎爛熟於心,並通過快取解決一些潛在的問題同時提升效能。適合規則數量相對較小的且不會頻繁更新規則的規則引擎。
已整理模板至github:
github.com/loveurwish/…