複雜多變場景下的Groovy腳本引擎實戰

一、前言

因為之前在項目中使用了Groovy對業務能力進行一些擴展,效果比較好,所以簡單記錄分享一下,這裡你可以了解:

  • 為什麼選用Groovy作為腳本引擎

  • 了解Groovy的基本原理和Java如何集成Groovy

  • 在項目中使用腳本引擎時做的安全和性能優化

  • 實際使用的一些建議

二、為什麼使用腳本語言

2.1 腳本語言可解決的問題

互聯網時代隨著業務的飛速發展,不僅產品迭代、更新的速度越來越快,個性化需求也是越來越多,如:多維度(條件)的查詢、業務流轉規則等。辦法通常有如下幾個方面:

  • 最常見的方式是用程式碼枚舉所有情況,即所有查詢維度、所有可能的規則組合,根據運行時參數遍歷查找;

  • 使用開源方案,例如drools規則引擎,此類引擎適用於業務基於規則流轉,且比較複雜的系統;

  • 使用動態腳本引擎,例如Groovy,JSR223。註:JSR即 Java規範請求,是指向JCP(Java Community Process)提出新增一個標準化技術規範的正式請求。任何人都可以提交JST,以向Java平台增添新的API和服務。JSR是Java界的一個重要標準。JSR223提供了一種從Java內部執行腳本編寫語言的方便、標準的方式,並提供從腳本內部訪問Java資源和類的功能,即為各腳本引擎提供了統一的介面、統一的訪問模式。JSR223不僅內置支援Groovy、Javascript、Aviator,而且提供SPI擴展,筆者曾通過SPI擴展實現過Java腳本引擎,將Java程式碼「腳本化」運行。

引入動態腳本引擎對業務進行抽象可以滿足訂製化需求,大大提升項目效率。例如,筆者現在開發的內容平台系統中,下游的內容需求方根據不同的策略會要求內容平台圈選指定內容推送到指定的處理系統,這些處理系統處理完後,內容平台接收到處理結果再根據分發策略(規則)下發給推薦系統。每次圈選內容都要寫一堆對於此次圈選的查詢邏輯,內容下發的策略也經常需要變更。所以想利用腳本引擎的動態解析執行,使用規則腳本將查詢條件以及下發策略抽象出來,提升效率。

2.2 技術選型

對於腳本語言來說,最常見的就是Groovy,JSR233也內置了Groovy。對於不同的腳本語言,選型時需要考慮性能、穩定性、靈活性,綜合考慮後選擇Groovy,有如下幾點原因:

  • 學習曲線平緩,有豐富的語法糖,對於Java開發者非常友好;

  • 技術成熟,功能強大,易於使用維護,性能穩定,被業界看好;

  • 和Java兼容性強,可以無縫銜接Java程式碼,可以調用Java所有的庫。

2.3 業務改造

因為運營、產品同學對於內容的需求在不斷的調整,內容平台圈選內容的能力需要能夠支援各種查詢維度的組合。內容平台起初開發了一個查詢組合為(狀態,入庫時間,來源方,內容類型),並定向分發到內容理解和打標的介面。但是這個介面已經不能滿足需求的變化,為此,最容易想到的設計就是枚舉所有表欄位(如發布時間、作者名稱等近20個),使其成為查詢條件。但是這種設計的開發邏輯其實是很繁瑣的,也容易造成慢查詢;比如:篩選指定合作方和等級S的up主,且對沒有內容理解記錄的影片,調用內容理解介面,即對這部分影片進行內容理解。為了滿足需求,需要重新開發,結果就是write once, run only once,造成開發和發版資源的浪費。

不管是JDBC for Mysql,還是JDBC for MongoDB都是面向介面編程,即查詢條件是被封裝成介面的。基於面向介面的編程模式,查詢條件Query介面的實現可以由腳本引擎動態生成,這樣就可以滿足任何查詢場景。執行流程如下圖3.1。

下面給出腳本的程式碼Demo:

/**
* 構建查詢對象Query
* 分頁查詢mongodb
*/
public Query query(int page){
    String source = "Groovy";
    String articleType = 4; // (source,articleType) 組成聯合索引,提高查詢效率
    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);// 設置分頁
    query.fields().include("authorId"); // 查詢結果返回authorId欄位
    query.fields().include("level"); // 查詢結果返回level欄位
    return query;
}
/**
* 過濾每一頁查詢結果
*/
public boolean filter(UpAuthor upAuthor){
    return !"S".equals(upAuthor.getLevel(); // 過濾掉 level != S 的作者
}
/**
* 對查詢結果集逐條處理
*/
public void handle(UpAuthor upAuthor) {
    UpAthorService upAuthorService = SpringUtil.getBean("upAuthorService"); // 從Spring容器中獲取執行java bean
    if(upAuthorService == null){
        throw new RuntimeException("upAuthorService is null");
    }
    AnalysePlatService analysePlatService =  SpringUtil.getBean("analysePlatService"); // 從Spring容器中獲取執行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()); // 提交影片給內容理解處理
        }  
    })
}

理論上,可以指定任意查詢條件,編寫任意業務邏輯,從而對於流程、規則經常變化的業務來說,擺脫了開發和發版的時空束縛,從而能夠及時響應各方的業務變更需求。

三、Groovy與Java集成

3.1 Groovy基本原理

Groovy的語法很簡潔,即使不想學習其語法,也可以在Groovy腳本中使用Java程式碼,兼容率高達90%,除了lambda、數組語法,其他Java語法基本都能兼容。這裡對語法不多做介紹,有興趣可以自行閱讀 //www.w3cschool.cn/groovy 進行學習。

3.2 在Java項目中集成Groovy

3.2.1 ScriptEngineManager

按照JSR223,使用標準介面ScriptEngineManager調用。

ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");// 每次生成一個engine實例
Bindings binding = engine.createBindings();
binding.put("date", new Date()); // 入參
engine.eval("def getTime(){return date.getTime();}", binding);// 如果script文本來自文件,請首先獲取文件內容
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,執行Groovy腳本片段,GroovyShell每一次執行時程式碼時會動態將程式碼編譯成Java Class,然後生成Java對象在Java虛擬機上執行,所以如果使用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,實例化對象,反射調用指定方法。

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 性能優化

當JVM中運行的Groovy腳本存在大量並發時,如果按照默認的策略,每次運行都會重新編譯腳本,調用類載入器進行類載入。不斷重新編譯腳本會增加JVM記憶體中的CodeCache和Metaspace,引發記憶體泄露,最後導致Metaspace記憶體溢出;類載入過程中存在同步,多執行緒進行類載入會造成大量執行緒阻塞,那麼效率問題就顯而易見了。

為了解決性能問題,最好的策略是對編譯、載入後的Groovy腳本進行快取,避免重複處理,可以通過計算腳本的MD5值來生成鍵值對進行快取。下面我們帶著以上結論來探討。

3.3.1 Class對象的數量

3.3.1.1 GroovyClassLoader載入腳本

上面提到的三種集成方式都是使用GroovyClassLoader顯式地調用類載入方法parseClass,即編譯、載入Groovy腳本,自然地脫離了Java著名的ClassLoader雙親委派模型。

GroovyClassLoader主要負責運行時處理Groovy腳本,將其編譯、載入為Class對象的工作。查看關鍵的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;
    }
}

系統每執行一次腳本,都會生成一個腳本的Class對象,這個Class對象的名字由 “script” + System.currentTimeMillis()+Math.abs(text.hashCode()組成,即使是相同的腳本,也會當做新的程式碼進行編譯、載入,會導致Metaspace的膨脹,隨著系統不斷地執行Groovy腳本,最終導致Metaspace溢出。

繼續往下跟蹤程式碼,GroovyClassLoader編譯Groovy腳本的工作主要集中在doParseClass方法中,如下所示程式碼3.1.1.2(出自JDK源碼):

private Class doParseClass(GroovyCodeSource codeSource) { 
    validate(codeSource); // 簡單校驗一些參數是否為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); // 這裡創建了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; 
}

繼續來看一下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的作用,就是在編譯的過程中,將編譯出來的位元組碼,通過InnerLoader進行載入。另外,每次編譯groovy源程式碼的時候,都會新建一個InnerLoader的實例。那有了 GroovyClassLoader ,為什麼還需要InnerLoader呢?主要有兩個原因:

載入同名的類

類載入器與類全名才能確立Class對象在JVM中的唯一性。由於一個ClassLoader對於同一個名字的類只能載入一次,如果都由GroovyClassLoader載入,那麼當一個腳本里定義了com.vivo.internet.Clazz這個類之後,另外一個腳本再定義一個com.vivo.internet.Clazz類的話,GroovyClassLoader就無法載入了。

回收Class對象

由於當一個Class對象的ClassLoader被回收之後,這個Class對象才可能被回收,如果由GroovyClassLoader載入所有的類,那麼只有當GroovyClassLoader被回收了,所有這些Class對象才可能被回收,而如果用InnerLoader的話,由於編譯完源程式碼之後,已經沒有對它的外部引用,它就可以被回收,由它載入的Class對象,才可能被回收。下面詳細討論Class對象的回收。

3.3.1.2 JVM回收Class對象

什麼時候會觸發Metaspace的垃圾回收?

  • Metaspace在沒有更多的記憶體空間的時候,比如載入新的類的時候;

  • JVM內部又一個叫做_capacity_until_GC的變數,一旦Metaspace使用的空間超過這個變數的值,就會對Metaspace進行回收;

  • FGC時會對Metaspace進行回收。

大家可能這裡會有疑問:就算Class數量過多,只要Metaspace觸發GC,那應該就不會溢出了。為什麼上面會給出Metaspace溢出的結論呢?這裡引出下一個問題:JVM回收Class對象的條件是什麼?

  • 該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例;

  • 載入該類的ClassLoader已經被GC;

  • java.lang.Class對象沒有在任何地方被引用。

條件1,GroovyClassLoader會把腳本編譯成一個類,這個腳本類運行時用反射生成一個實例並調用它的入口函數執行(詳見圖3.1),這個動作一般只會被執行一次,在應用裡面不會有其他地方引用該類或它生成的實例,該條件至少是可以通過規範編程來滿足。條件2,上面已經分析過,InnerClassLoader用完後即可被回收,所以條件可以滿足。條件3,由於腳本的Class對象一直被引用,條件無法滿足。

為了驗證條件3是無法滿足的結論,繼續查看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對象中,導致Class對象不可被回收。

3.3.2 高並發時執行緒阻塞

上面有兩處同步程式碼塊,詳見程式碼3.1.1.1和程式碼3.1.2.1。當高並發載入Groovy腳本時,會造成大量執行緒阻塞,一定會產生性能瓶頸。

3.3.3 解決方案

  • 對於 parseClass 後生成的 Class 對象進行快取,key 為 Groovy腳本的md5值,並且在配置端修改配置後可進行快取刷新。這樣做的好處有兩點:(1)解決Metaspace爆滿的問題;(2)因為不需要在運行時編譯載入,所以可以加快腳本執行的速度。

  • GroovyClassLoader的使用用參考Tomcat的ClassLoader體系,有限個GroovyClassLoader實例常駐記憶體,增加處理的吞吐量。

  • 腳本靜態化:Groovy腳本裡面盡量都用Java靜態類型,可以減少Groovy動態類型檢查等,提高編譯和載入Groovy腳本的效率。

四、安全

4.1 主動安全

4.1.1 編碼安全

Groovy會自動引入java.util,java.lang包,方便用戶調用,但同時也增加了系統的風險。為了防止用戶調用System.exit或Runtime等方法導致系統宕機,以及自定義的Groovy片段程式碼執行死循環或調用資源超時等問題,Groovy提供了SecureASTCustomizer安全管理者和SandboxTransformer沙盒環境。

final SecureASTCustomizer secure = new SecureASTCustomizer();// 創建SecureASTCustomizer
secure.setClosuresAllowed(true);// 禁止使用閉包
List<Integer> tokensBlacklist = new ArrayList<>();
tokensBlacklist.add(Types.**KEYWORD_WHILE**);// 添加關鍵字黑名單 while和goto
tokensBlacklist.add(Types.**KEYWORD_GOTO**);
secure.setTokensBlacklist(tokensBlacklist);
secure.setIndirectImportCheckEnabled(true);// 設置直接導入檢查
List<String> list = new ArrayList<>();// 添加導入黑名單,用戶不能導入JSONObject
list.add("com.alibaba.fastjson.JSONObject");
secure.setImportsBlacklist(list);
List<Class<? extends Statement>> statementBlacklist = new ArrayList<>();// statement 黑名單,不能使用while循環塊
statementBlacklist.add(WhileStatement.class);
secure.setStatementsBlacklist(statementBlacklist);
final CompilerConfiguration config = new CompilerConfiguration();// 自定義CompilerConfiguration,設置AST
config.addCompilationCustomizers(secure);
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(this.getClass().getClassLoader(), config);
​

4.1.2 流程安全

通過規範流程,增加腳本執行的可信度。

4.2 被動安全

雖然SecureASTCustomizer可以對腳本做一定程度的安全限制,也可以規範流程進一步強化,但是對於腳本的編寫仍然存在較大的安全風險,很容易造成cpu暴漲、瘋狂佔用磁碟空間等嚴重影響系統運行的問題。所以需要一些被動安全手段,比如採用執行緒池隔離,對腳本執行進行有效的實時監控、統計和封裝,或者是手動強殺執行腳本的執行緒。

五、總結

Groovy是一種動態腳本語言,適用於業務變化多又快以及配置化的需求實現。Groovy極易上手,其本質也是運行在JVM的Java程式碼。Java程式設計師可以使用Groovy在提高開發效率,加快響應需求變化,提高系統穩定性等方面更進一步。

作者:vivo互聯網伺服器團隊-Gao Xiang