復雜多變場景下的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語法基本都能兼容。這里對語法不多做介紹,有興趣可以自行閱讀 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM