復(fù)雜多變場景下的Groovy腳本引擎實(shí)戰(zhàn)

一、前言

因?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。

image

下面給出腳本的代碼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í)行的可信度。

image

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容