一、前言
因?yàn)橹霸陧?xiàng)目中使用了Groovy對業(yè)務(wù)能力進(jìn)行一些擴(kuò)展,效果比較好,所以簡單記錄分享一下,這里你可以了解:
為什么選用Groovy作為腳本引擎
了解Groovy的基本原理和Java如何集成Groovy
在項(xiàng)目中使用腳本引擎時做的安全和性能優(yōu)化
實(shí)際使用的一些建議
二、為什么使用腳本語言
2.1 腳本語言可解決的問題
互聯(lián)網(wǎng)時代隨著業(yè)務(wù)的飛速發(fā)展,不僅產(chǎn)品迭代、更新的速度越來越快,個性化需求也是越來越多,如:多維度(條件)的查詢、業(yè)務(wù)流轉(zhuǎn)規(guī)則等。辦法通常有如下幾個方面:
最常見的方式是用代碼枚舉所有情況,即所有查詢維度、所有可能的規(guī)則組合,根據(jù)運(yùn)行時參數(shù)遍歷查找;
使用開源方案,例如drools規(guī)則引擎,此類引擎適用于業(yè)務(wù)基于規(guī)則流轉(zhuǎn),且比較復(fù)雜的系統(tǒng);
使用動態(tài)腳本引擎,例如Groovy,JSR223。注:JSR即 Java規(guī)范請求,是指向JCP(Java Community Process)提出新增一個標(biāo)準(zhǔn)化技術(shù)規(guī)范的正式請求。任何人都可以提交JST,以向Java平臺增添新的API和服務(wù)。JSR是Java界的一個重要標(biāo)準(zhǔn)。JSR223提供了一種從Java內(nèi)部執(zhí)行腳本編寫語言的方便、標(biāo)準(zhǔn)的方式,并提供從腳本內(nèi)部訪問Java資源和類的功能,即為各腳本引擎提供了統(tǒng)一的接口、統(tǒng)一的訪問模式。JSR223不僅內(nèi)置支持Groovy、Javascript、Aviator,而且提供SPI擴(kuò)展,筆者曾通過SPI擴(kuò)展實(shí)現(xiàn)過Java腳本引擎,將Java代碼“腳本化”運(yùn)行。
引入動態(tài)腳本引擎對業(yè)務(wù)進(jìn)行抽象可以滿足定制化需求,大大提升項(xiàng)目效率。例如,筆者現(xiàn)在開發(fā)的內(nèi)容平臺系統(tǒng)中,下游的內(nèi)容需求方根據(jù)不同的策略會要求內(nèi)容平臺圈選指定內(nèi)容推送到指定的處理系統(tǒng),這些處理系統(tǒng)處理完后,內(nèi)容平臺接收到處理結(jié)果再根據(jù)分發(fā)策略(規(guī)則)下發(fā)給推薦系統(tǒng)。每次圈選內(nèi)容都要寫一堆對于此次圈選的查詢邏輯,內(nèi)容下發(fā)的策略也經(jīng)常需要變更。所以想利用腳本引擎的動態(tài)解析執(zhí)行,使用規(guī)則腳本將查詢條件以及下發(fā)策略抽象出來,提升效率。
2.2 技術(shù)選型
對于腳本語言來說,最常見的就是Groovy,JSR233也內(nèi)置了Groovy。對于不同的腳本語言,選型時需要考慮性能、穩(wěn)定性、靈活性,綜合考慮后選擇Groovy,有如下幾點(diǎn)原因:
學(xué)習(xí)曲線平緩,有豐富的語法糖,對于Java開發(fā)者非常友好;
技術(shù)成熟,功能強(qiáng)大,易于使用維護(hù),性能穩(wěn)定,被業(yè)界看好;
和Java兼容性強(qiáng),可以無縫銜接Java代碼,可以調(diào)用Java所有的庫。
2.3 業(yè)務(wù)改造
因?yàn)檫\(yùn)營、產(chǎn)品同學(xué)對于內(nèi)容的需求在不斷的調(diào)整,內(nèi)容平臺圈選內(nèi)容的能力需要能夠支持各種查詢維度的組合。內(nèi)容平臺起初開發(fā)了一個查詢組合為(狀態(tài),入庫時間,來源方,內(nèi)容類型),并定向分發(fā)到內(nèi)容理解和打標(biāo)的接口。但是這個接口已經(jīng)不能滿足需求的變化,為此,最容易想到的設(shè)計(jì)就是枚舉所有表字段(如發(fā)布時間、作者名稱等近20個),使其成為查詢條件。但是這種設(shè)計(jì)的開發(fā)邏輯其實(shí)是很繁瑣的,也容易造成慢查詢;比如:篩選指定合作方和等級S的up主,且對沒有內(nèi)容理解記錄的視頻,調(diào)用內(nèi)容理解接口,即對這部分視頻進(jìn)行內(nèi)容理解。為了滿足需求,需要重新開發(fā),結(jié)果就是write once, run only once,造成開發(fā)和發(fā)版資源的浪費(fèi)。
不管是JDBC for Mysql,還是JDBC for MongoDB都是面向接口編程,即查詢條件是被封裝成接口的?;诿嫦蚪涌诘木幊棠J剑樵儣l件Query接口的實(shí)現(xiàn)可以由腳本引擎動態(tài)生成,這樣就可以滿足任何查詢場景。執(zhí)行流程如下圖3.1。
下面給出腳本的代碼Demo:
/**
* 構(gòu)建查詢對象Query
* 分頁查詢mongodb
*/
public Query query(int page){
String source = "Groovy";
String articleType = 4; // (source,articleType) 組成聯(lián)合索引,提高查詢效率
Query query = Query.query(where("source").is(source)); // 查詢條件1:source="Groovy"
query.addCriteria(where("articleType").is(articleType)); // 查詢條件2:articleType=4
Pageable pageable = new PageRequest(page, PAGESIZE);
query.with(pageable);// 設(shè)置分頁
query.fields().include("authorId"); // 查詢結(jié)果返回authorId字段
query.fields().include("level"); // 查詢結(jié)果返回level字段
return query;
}
/**
* 過濾每一頁查詢結(jié)果
*/
public boolean filter(UpAuthor upAuthor){
return !"S".equals(upAuthor.getLevel(); // 過濾掉 level != S 的作者
}
/**
* 對查詢結(jié)果集逐條處理
*/
public void handle(UpAuthor upAuthor) {
UpAthorService upAuthorService = SpringUtil.getBean("upAuthorService"); // 從Spring容器中獲取執(zhí)行java bean
if(upAuthorService == null){
throw new RuntimeException("upAuthorService is null");
}
AnalysePlatService analysePlatService = SpringUtil.getBean("analysePlatService"); // 從Spring容器中獲取執(zhí)行java bean
if(analysePlatService == null){
throw new RuntimeException("analysePlatService is null");
}
List<Article> articleList = upAuthorService.getArticles(upAuthor);// 獲取作者名下所有視頻
if(CollectionUtils.isEmpty(articleList)){
return;
}
articleList.forEach(article->{
if(article.getAnalysis() == null){
analysePlatService.analyse(article.getArticleId()); // 提交視頻給內(nèi)容理解處理
}
})
}
理論上,可以指定任意查詢條件,編寫任意業(yè)務(wù)邏輯,從而對于流程、規(guī)則經(jīng)常變化的業(yè)務(wù)來說,擺脫了開發(fā)和發(fā)版的時空束縛,從而能夠及時響應(yīng)各方的業(yè)務(wù)變更需求。
三、Groovy與Java集成
3.1 Groovy基本原理
Groovy的語法很簡潔,即使不想學(xué)習(xí)其語法,也可以在Groovy腳本中使用Java代碼,兼容率高達(dá)90%,除了lambda、數(shù)組語法,其他Java語法基本都能兼容。這里對語法不多做介紹,有興趣可以自行閱讀 https://www.w3cschool.cn/groovy 進(jìn)行學(xué)習(xí)。
3.2 在Java項(xiàng)目中集成Groovy
3.2.1 ScriptEngineManager
按照J(rèn)SR223,使用標(biāo)準(zhǔn)接口ScriptEngineManager調(diào)用。
ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");// 每次生成一個engine實(shí)例
Bindings binding = engine.createBindings();
binding.put("date", new Date()); // 入?yún)?engine.eval("def getTime(){return date.getTime();}", binding);// 如果script文本來自文件,請首先獲取文件內(nèi)容
engine.eval("def sayHello(name,age){return 'Hello,I am ' + name + ',age' + age;}");
Long time = (Long) ((Invocable) engine).invokeFunction("getTime", null);// 反射到方法
System.out.println(time);
String message = (String) ((Invocable) engine).invokeFunction("sayHello", "zhangsan", 12);
System.out.println(message);
3.2.2 GroovyShell
Groovy官方提供GroovyShell,執(zhí)行Groovy腳本片段,GroovyShell每一次執(zhí)行時代碼時會動態(tài)將代碼編譯成Java Class,然后生成Java對象在Java虛擬機(jī)上執(zhí)行,所以如果使用GroovyShell會造成Class太多,性能較差。
final String script = "Runtime.getRuntime().availableProcessors()";
Binding intBinding = new Binding();
GroovyShell shell = new GroovyShell(intBinding);
final Object eval = shell.evaluate(script);
System.out.println(eval);
3.2.3 GroovyClassLoader
Groovy官方提供GroovyClassLoader類,支持從文件、url或字符串中加載解析Groovy Class,實(shí)例化對象,反射調(diào)用指定方法。
GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
String helloScript = "package com.vivo.groovy.util" + // 可以是純Java代碼
"class Hello {" +
"String say(String name) {" +
"System.out.println(\"hello, \" + name)" +
" return name;"
"}" +
"}";
Class helloClass = groovyClassLoader.parseClass(helloScript);
GroovyObject object = (GroovyObject) helloClass.newInstance();
Object ret = object.invokeMethod("say", "vivo"); // 控制臺輸出"hello, vivo"
System.out.println(ret.toString()); // 打印vivo
3.3 性能優(yōu)化
當(dāng)JVM中運(yùn)行的Groovy腳本存在大量并發(fā)時,如果按照默認(rèn)的策略,每次運(yùn)行都會重新編譯腳本,調(diào)用類加載器進(jìn)行類加載。不斷重新編譯腳本會增加JVM內(nèi)存中的CodeCache和Metaspace,引發(fā)內(nèi)存泄露,最后導(dǎo)致Metaspace內(nèi)存溢出;類加載過程中存在同步,多線程進(jìn)行類加載會造成大量線程阻塞,那么效率問題就顯而易見了。
為了解決性能問題,最好的策略是對編譯、加載后的Groovy腳本進(jìn)行緩存,避免重復(fù)處理,可以通過計(jì)算腳本的MD5值來生成鍵值對進(jìn)行緩存。下面我們帶著以上結(jié)論來探討。
3.3.1 Class對象的數(shù)量
3.3.1.1 GroovyClassLoader加載腳本
上面提到的三種集成方式都是使用GroovyClassLoader顯式地調(diào)用類加載方法parseClass,即編譯、加載Groovy腳本,自然地脫離了Java著名的ClassLoader雙親委派模型。
GroovyClassLoader主要負(fù)責(zé)運(yùn)行時處理Groovy腳本,將其編譯、加載為Class對象的工作。查看關(guān)鍵的GroovyClassLoader.parseClass方法,如下所示代碼3.1.1.1(出自JDK源碼)。
public Class parseClass(String text) throws CompilationFailedException {
return parseClass(text, "script" + System.currentTimeMillis() +
Math.abs(text.hashCode()) + ".groovy");
}
public Class parseClass(GroovyCodeSource codeSource, boolean shouldCacheSource) throws CompilationFailedException {
synchronized (sourceCache) { // 同步塊
Class answer = sourceCache.get(codeSource.getName());
if (answer != null) return answer;
answer = doParseClass(codeSource);
if (shouldCacheSource) sourceCache.put(codeSource.getName(), answer);
return answer;
}
}
系統(tǒng)每執(zhí)行一次腳本,都會生成一個腳本的Class對象,這個Class對象的名字由 "script" + System.currentTimeMillis()+Math.abs(text.hashCode()組成,即使是相同的腳本,也會當(dāng)做新的代碼進(jìn)行編譯、加載,會導(dǎo)致Metaspace的膨脹,隨著系統(tǒng)不斷地執(zhí)行Groovy腳本,最終導(dǎo)致Metaspace溢出。
繼續(xù)往下跟蹤代碼,GroovyClassLoader編譯Groovy腳本的工作主要集中在doParseClass方法中,如下所示代碼3.1.1.2(出自JDK源碼):
private Class doParseClass(GroovyCodeSource codeSource) {
validate(codeSource); // 簡單校驗(yàn)一些參數(shù)是否為null
Class answer;
CompilationUnit unit = createCompilationUnit(config, codeSource.getCodeSource());
SourceUnit su = null;
if (codeSource.getFile() == null) {
su = unit.addSource(codeSource.getName(), codeSource.getScriptText());
} else {
su = unit.addSource(codeSource.getFile());
}
ClassCollector collector = createCollector(unit, su); // 這里創(chuàng)建了GroovyClassLoader$InnerLoader
unit.setClassgenCallback(collector);
int goalPhase = Phases.CLASS_GENERATION;
if (config != null && config.getTargetDirectory() != null) goalPhase = Phases.OUTPUT;
unit.compile(goalPhase); // 編譯Groovy源代碼
answer = collector.generatedClass; // 查找源文件中的Main Class
String mainClass = su.getAST().getMainClassName();
for (Object o : collector.getLoadedClasses()) {
Class clazz = (Class) o;
String clazzName = clazz.getName();
definePackage(clazzName);
setClassCacheEntry(clazz);
if (clazzName.equals(mainClass)) answer = clazz;
}
return answer;
}
繼續(xù)來看一下GroovyClassLoader的createCollector方法,如下所示代碼3.1.1.3(出自JDK源碼):
protected ClassCollector createCollector(CompilationUnit unit, SourceUnit su) {
InnerLoader loader = AccessController.doPrivileged(new PrivilegedAction<InnerLoader>() {
public InnerLoader run() {
return new InnerLoader(GroovyClassLoader.this); // InnerLoader extends GroovyClassLoader
}
});
return new ClassCollector(loader, unit, su);
}
public static class ClassCollector extends CompilationUnit.ClassgenCallback {
private final GroovyClassLoader cl;
// ...
protected ClassCollector(InnerLoader cl, CompilationUnit unit, SourceUnit su) {
this.cl = cl;
// ...
}
public GroovyClassLoader getDefiningClassLoader() {
return cl;
}
protected Class createClass(byte[] code, ClassNode classNode) {
GroovyClassLoader cl = getDefiningClassLoader(); // GroovyClassLoader$InnerLoader
Class theClass = cl.defineClass(classNode.getName(), code, 0, code.length, unit.getAST().getCodeSource()); // 通過InnerLoader加載該類
this.loadedClasses.add(theClass);
// ...
return theClass;
}
// ...
}
ClassCollector的作用,就是在編譯的過程中,將編譯出來的字節(jié)碼,通過InnerLoader進(jìn)行加載。另外,每次編譯groovy源代碼的時候,都會新建一個InnerLoader的實(shí)例。那有了 GroovyClassLoader ,為什么還需要InnerLoader呢?主要有兩個原因:
加載同名的類
類加載器與類全名才能確立Class對象在JVM中的唯一性。由于一個ClassLoader對于同一個名字的類只能加載一次,如果都由GroovyClassLoader加載,那么當(dāng)一個腳本里定義了com.vivo.internet.Clazz這個類之后,另外一個腳本再定義一個com.vivo.internet.Clazz類的話,GroovyClassLoader就無法加載了。
回收Class對象
由于當(dāng)一個Class對象的ClassLoader被回收之后,這個Class對象才可能被回收,如果由GroovyClassLoader加載所有的類,那么只有當(dāng)GroovyClassLoader被回收了,所有這些Class對象才可能被回收,而如果用InnerLoader的話,由于編譯完源代碼之后,已經(jīng)沒有對它的外部引用,它就可以被回收,由它加載的Class對象,才可能被回收。下面詳細(xì)討論Class對象的回收。
3.3.1.2 JVM回收Class對象
什么時候會觸發(fā)Metaspace的垃圾回收?
Metaspace在沒有更多的內(nèi)存空間的時候,比如加載新的類的時候;
JVM內(nèi)部又一個叫做_capacity_until_GC的變量,一旦Metaspace使用的空間超過這個變量的值,就會對Metaspace進(jìn)行回收;
FGC時會對Metaspace進(jìn)行回收。
大家可能這里會有疑問:就算Class數(shù)量過多,只要Metaspace觸發(fā)GC,那應(yīng)該就不會溢出了。為什么上面會給出Metaspace溢出的結(jié)論呢?這里引出下一個問題:JVM回收Class對象的條件是什么?
該類所有的實(shí)例都已經(jīng)被GC,也就是JVM中不存在該Class的任何實(shí)例;
加載該類的ClassLoader已經(jīng)被GC;
java.lang.Class對象沒有在任何地方被引用。
條件1,GroovyClassLoader會把腳本編譯成一個類,這個腳本類運(yùn)行時用反射生成一個實(shí)例并調(diào)用它的入口函數(shù)執(zhí)行(詳見圖3.1),這個動作一般只會被執(zhí)行一次,在應(yīng)用里面不會有其他地方引用該類或它生成的實(shí)例,該條件至少是可以通過規(guī)范編程來滿足。條件2,上面已經(jīng)分析過,InnerClassLoader用完后即可被回收,所以條件可以滿足。條件3,由于腳本的Class對象一直被引用,條件無法滿足。
為了驗(yàn)證條件3是無法滿足的結(jié)論,繼續(xù)查看GroovyClassLoader中的一段代碼3.1.2.1(出自JDK源碼):
/**
* this cache contains the loaded classes or PARSING, if the class is currently parsed
*/
protected final Map<String, Class> classCache = new HashMap<String, Class>();
protected void setClassCacheEntry(Class cls) {
synchronized (classCache) { // 同步塊
classCache.put(cls.getName(), cls);
}
}
加載的Class對象,會緩存在GroovyClassLoader對象中,導(dǎo)致Class對象不可被回收。
3.3.2 高并發(fā)時線程阻塞
上面有兩處同步代碼塊,詳見代碼3.1.1.1和代碼3.1.2.1。當(dāng)高并發(fā)加載Groovy腳本時,會造成大量線程阻塞,一定會產(chǎn)生性能瓶頸。
3.3.3 解決方案
對于 parseClass 后生成的 Class 對象進(jìn)行緩存,key 為 Groovy腳本的md5值,并且在配置端修改配置后可進(jìn)行緩存刷新。這樣做的好處有兩點(diǎn):(1)解決Metaspace爆滿的問題;(2)因?yàn)椴恍枰谶\(yùn)行時編譯加載,所以可以加快腳本執(zhí)行的速度。
GroovyClassLoader的使用用參考Tomcat的ClassLoader體系,有限個GroovyClassLoader實(shí)例常駐內(nèi)存,增加處理的吞吐量。
腳本靜態(tài)化:Groovy腳本里面盡量都用Java靜態(tài)類型,可以減少Groovy動態(tài)類型檢查等,提高編譯和加載Groovy腳本的效率。
四、安全
4.1 主動安全
4.1.1 編碼安全
Groovy會自動引入java.util,java.lang包,方便用戶調(diào)用,但同時也增加了系統(tǒng)的風(fēng)險。為了防止用戶調(diào)用System.exit或Runtime等方法導(dǎo)致系統(tǒng)宕機(jī),以及自定義的Groovy片段代碼執(zhí)行死循環(huán)或調(diào)用資源超時等問題,Groovy提供了SecureASTCustomizer安全管理者和SandboxTransformer沙盒環(huán)境。
final SecureASTCustomizer secure = new SecureASTCustomizer();// 創(chuàng)建SecureASTCustomizer
secure.setClosuresAllowed(true);// 禁止使用閉包
List<Integer> tokensBlacklist = new ArrayList<>();
tokensBlacklist.add(Types.**KEYWORD_WHILE**);// 添加關(guān)鍵字黑名單 while和goto
tokensBlacklist.add(Types.**KEYWORD_GOTO**);
secure.setTokensBlacklist(tokensBlacklist);
secure.setIndirectImportCheckEnabled(true);// 設(shè)置直接導(dǎo)入檢查
List<String> list = new ArrayList<>();// 添加導(dǎo)入黑名單,用戶不能導(dǎo)入JSONObject
list.add("com.alibaba.fastjson.JSONObject");
secure.setImportsBlacklist(list);
List<Class<? extends Statement>> statementBlacklist = new ArrayList<>();// statement 黑名單,不能使用while循環(huán)塊
statementBlacklist.add(WhileStatement.class);
secure.setStatementsBlacklist(statementBlacklist);
final CompilerConfiguration config = new CompilerConfiguration();// 自定義CompilerConfiguration,設(shè)置AST
config.addCompilationCustomizers(secure);
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(this.getClass().getClassLoader(), config);
4.1.2 流程安全
通過規(guī)范流程,增加腳本執(zhí)行的可信度。
4.2 被動安全
雖然SecureASTCustomizer可以對腳本做一定程度的安全限制,也可以規(guī)范流程進(jìn)一步強(qiáng)化,但是對于腳本的編寫仍然存在較大的安全風(fēng)險,很容易造成cpu暴漲、瘋狂占用磁盤空間等嚴(yán)重影響系統(tǒng)運(yùn)行的問題。所以需要一些被動安全手段,比如采用線程池隔離,對腳本執(zhí)行進(jìn)行有效的實(shí)時監(jiān)控、統(tǒng)計(jì)和封裝,或者是手動強(qiáng)殺執(zhí)行腳本的線程。
五、總結(jié)
Groovy是一種動態(tài)腳本語言,適用于業(yè)務(wù)變化多又快以及配置化的需求實(shí)現(xiàn)。Groovy極易上手,其本質(zhì)也是運(yùn)行在JVM的Java代碼。Java程序員可以使用Groovy在提高開發(fā)效率,加快響應(yīng)需求變化,提高系統(tǒng)穩(wěn)定性等方面更進(jìn)一步。
作者:vivo互聯(lián)網(wǎng)服務(wù)器團(tuán)隊(duì)-Gao Xiang