複雜多變場景下的Groovy指令碼引擎實戰

vivo網際網路技術發表於2021-08-03

一、前言

因為之前在專案中使用了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語法基本都能相容。這裡對語法不多做介紹,有興趣可以自行閱讀 https://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

相關文章