Java 熱更新 Groovy 實踐及踩坑指南

2022-09-13 12:00:36

Groovy 是什麼?

Apache的Groovy是Java平臺上設計的物件導向程式語言。這門動態語言擁有類似Python、Ruby和Smalltalk中的一些特性,可以作為Java平臺的指令碼語言使用,Groovy程式碼動態地編譯成執行於Java虛擬機器器(JVM)上的Java位元組碼,並與其他Java程式碼和庫進行互操作。

由於其執行在JVM上的特性,Groovy可以使用其他Java語言編寫的庫。Groovy的語法與Java非常相似,大多數Java程式碼也符合Groovy的語法規則,儘管可能語意不同。 Groovy 1.0於2007年1月2日釋出,並於2012年7月釋出了Groovy 2.0。從版本2開始,Groovy也可以靜態編譯,提供型別推論和Java相近的效能。Groovy 2.4是Pivotal軟體贊助的最後一個主要版本,截止於2015年3月。Groovy已經將其治理結構更改為Apache軟體基金會的專案管理委員會(PMC)[1]。

Java 為何需要 Groovy ?

Groovy 特性如下:

  • 語法上支援動態型別,閉包等新一代語言特性
  • 無縫整合所有已經存在的Java類庫
  • 既支援物件導向程式設計也支援程式導向程式設計
  • 執行方式可以將groovy編寫的原始檔編譯成class位元組碼檔案,然後交給JVM去執行,也可以直接將groovy原始檔解釋執行。
  • Groovy可以與Java完美結合,而且可以使用java所有的庫

Groovy 優勢如下:

  • 敏捷
    • groovy 在語法上加入了很多語法糖,很多 Java 嚴格的書寫語法,在 Groovy 中只需要少量的語法糖即可實現
  • Groovy 的靈活性是的它既可以作為變成語言,亦可作為指令碼語言
  • 0成本學習 Groovy,完美適配 Java 語法

熱部署技術設計及實現

使用場景

我將介紹如下幾種常用的適合 Groovy 指令碼熱更新的場景,供您學習

風控安全——規則引擎

風控的規則引擎非常適合用 groovy 來實現,對抗黑產,策略人員每天都都會產出攔截規則,如果每次都需要發版,可能發完觀測完後,該薅的羊毛都被黑產薅沒了。

所以利用 groovy 指令碼引擎的動態解析執行,使用規則指令碼將查攔截規則抽象出來,快速部署,提升效率。

監控中心

大型網際網路系統,伴隨著海量資料進入,各個層級的人員需要時時刻刻關注業務的各個維度指標,此時某個指標異常光靠人肉是沒辦法實現的。此時需要監控中心介入,提前部署好異動規則,當異常發生時,監控中心發出告警通知到對應的規則建立人員,從而儘快查明原因,挽回資損。

此時要保證監控中心異常靈活,可以隨時隨地滿足業務人員或者研發人員設定監控指標,測試我們可以使用 Groovy 條件表示式,滿足靈活監控規則設定需求。

活動行銷

行銷活動設定是我個人覺得最複雜的業務之一。活動模板多樣,千人千面,不同人群看到的活動樣式或者「獎品」不一。且活動上線要快,效果回收,投入產出比等要能立即觀測。

此時需要工程側抽象出整個活動模板,在需要變化的地方嵌入 Groovy 指令碼,這樣就減少了測試和發版的時間,做到活動可線上設定化。

技術實現

指令碼載入/更新

程式碼實現展示:

/**
 * 載入指令碼
 * @param script
 * @return
 */
public static GroovyObject buildScript(String script) {
    if (StringUtils.isEmpty(script)) {
        throw new RuntimeException("script is empty");
    }

    String cacheKey = DigestUtils.md5DigestAsHex(script.getBytes());
    if (groovyObjectCache.containsKey(cacheKey)) {
        log.debug("groovyObjectCache hit");
        return groovyObjectCache.get(cacheKey);
    }

    GroovyClassLoader classLoader = new GroovyClassLoader();
    try {
        Class<?> groovyClass = classLoader.parseClass(script);
        GroovyObject groovyObject = (GroovyObject) groovyClass.newInstance();
        classLoader.clearCache();

        groovyObjectCache.put(cacheKey, groovyObject);
        log.info("groovy buildScript success: {}", groovyObject);
        return groovyObject;
    } catch (Exception e) {
        throw new RuntimeException("buildScript error", e);
    } finally {
        try {
            classLoader.close();
        } catch (IOException e) {
            log.error("close GroovyClassLoader error", e);
        }
    }
}

重點關注:

  • 指令碼開啟快取處理:否則多次會更新可能會導致 Metaspace OutOfMemery
指令碼執行
// 程式內部需要關聯出待執行的指令碼即可
try {
    Map<String, Object> singleMap = GroovyUtils.invokeMethod2Map(s.getScriptObject(), s.getInvokeMethod(), params);
    data.putAll(singleMap);
} catch (Throwable e) {
    log.error(String.format("RcpEventMsgCleanScriptGroovyHandle groovy error, guid: %d eventCode: %s",
            s.getGuid(), s.getEventCode()), e);
}

// 三種執行方式,看 指令碼內部返回的結果是什麼
public static Map<String, Object> invokeMethod2Map(GroovyObject scriptObject, String invokeMethod, Object[] params) {
    return (Map<String, Object>) scriptObject.invokeMethod(invokeMethod, params);
}

public static boolean invokeMethod2Boolean(GroovyObject scriptObject, String invokeMethod, Object[] params) {
    return (Boolean) scriptObject.invokeMethod(invokeMethod, params);
}

public static String invokeMethod2String(GroovyObject scriptObject, String invokeMethod, Object[] params) {
    log.debug("GroovyObject class: {}", scriptObject.getClass().getSimpleName());
    return (String) scriptObject.invokeMethod(invokeMethod, params);
}

生產踩坑指南

Java8 lambda 與 Groovy 語法問題

都說 Groovy 能完美相容 Java 語法,即直接複製 Java 程式碼到 Groovy 檔案內,亦能編譯成功。
事實真的如此麼,我們看如下執行的程式碼:

Set<String> demo = new HashSet<>();
demo.add("111");
demo.add("222");

for (String s : demo) {
    executor.submit({ -> 
        println "submit: " + s;                 
    });
}

for (String s in demo) {
    executor.submit({ -> 
        println "sp submit: " + s;                 
    });
}


// 輸出結果
// submit: 222
// sp submit: 222
// submit: 222
// sp submit: 222

此時程式碼並沒有按照預期的結果輸出 111, 222,這是為什麼呢?

答:lambda 語法在 Groovy 中語意和在Java 中不一致,雖然編譯不出錯,但表達的語意不一致
在 Groovy 中表示閉包概念,此處不熟悉的可以 Google 詳細瞭解 Groovy 語法。

GroovyClassLoader 載入機制導致頻繁gc問題

通常載入 Groovy 類程式碼如下:

GroovyClassLoader groovyLoader = new GroovyClassLoader();
Class<Script> groovyClass = (Class<Script>) groovyLoader.parseClass(groovyScript);
Script groovyScript = groovyClass.newInstance();

每次執行 groovyLoader.parseClass(groovyScript),Groovy 為了保證每次執行的都是新的指令碼內容,會每次生成一個新名字的Class檔案,這個點已經在前文中說明過。當對同一段指令碼每次都執行這個方法時,會導致的現象就是裝載的Class會越來越多,從而導致PermGen被用滿。

同時這裡也存在效能瓶頸問題,如果去分析這段程式碼會發現90%的耗時佔用在Class。

如上實戰過程中,已經給出瞭解決辦法:

  • 對於 parseClass 後生成的 Class 物件進行cache,key 為 groovyScript 指令碼的md5值

指令碼首次執行耗時高

在初期方案上線時,壓測後顯示,首次載入指令碼效能較慢,後續指令碼執行速度非常快,猜測可能是 Groovy 內部在首次腳在指令碼時還做了其他的校驗(本人還沒跟進這塊,如果有讀者感興趣,可以斷點詳細看下鏈路耗時在哪裡)

正對首次載入緩慢問題,解決方法如下:

// 1.載入指令碼,並快取
GroovyObject object = loadClass(classSeq);
cacheMap.put(md5(classSeq), object);

// 2.預熱
// 模擬方法呼叫
cacheMap.get(md5(classSeq)).invoke();

// 3.開放給線上流量使用

往期精彩

個人技術部落格:https://jifuwei.github.io/
公眾號:是咕咕雞

參考:
[1] Groovy Wiki