Google Aviator——輕量級 Java 表示式引擎實戰

語言: CN / TW / HK

表示式引擎技術及比較

Drools 簡介

Drools(JBoss Rules )是一個開源業務規則引擎,符合業內標準,速度快、效率高。業務分析師或稽核人員可以利用它輕鬆檢視業務規則,從而檢驗是否已編碼的規則執行了所需的業務規則。

除了應用了 Rete 核心演算法,開源軟體 License 和 100% 的Java實現之外,Drools還提供了很多有用的特性。其中包括實現了JSR94 API和創新的規則語義系統,這個語義系統可用來編寫描述規則的語言。目前,Drools提供了三種語義模組

  • Python模組
  • Java模組
  • Groovy模組

Drools的規則是寫在drl檔案中。 對於前面的表示式,在Drools的drl檔案描述為: shell rule "Testing Comments" when // this is a single line comment eval( true ) // this is a comment in the same line of a pattern then // this is a comment inside a semantic code block end

When表示條件,then是滿足條件以後,可以執行的動作,在這裡可以呼叫任何java方法等。在drools不支援字串的contians方法,只能採用正則表示式來代替。

IKExpression 簡介

IK Expression 是一個開源的、可擴充套件的, 基於java 語言開發的一個超輕量級的公式化語言解析執行工具包。IK Expression 不依賴於任何第三方的 java 庫。它做為一個簡單的jar,可以集成於任意的Java 應用中。

對於前面的表示式,IKExpression 的寫法為: java public static void main(String[] args) throws Throwable{ E2Say obj = new E2Say(); FunctionLoader.addFunction("indexOf", obj, E2Say.class.getMethod("indexOf", String.class, String.class)); System.out.println(ExpressionEvaluator.evaluate("$indexOf(\"abcd\",\"ab\")==0?1:0")); } 可以看到 IK 是通過自定義函式 $indexOf 來實現功能的。

Groovy簡介

Groovy經常被認為是指令碼語言,但是把 Groovy 理解為指令碼語言是一種誤解,Groovy 程式碼被編譯成 Java 位元組碼,然後能整合到 Java 應用程式中或者 web 應用程式,整個應用程式都可以是 Groovy 編寫的——Groovy 是非常靈活的。

Groovy 與 Java 平臺非常融合,包括大量的java類庫也可以直接在groovy中使用。對於前面的表示式,Groovy的寫法為: java Binding binding = new Binding(); binding.setVariable("verifyStatus", 1); GroovyShell shell = new GroovyShell(binding); boolean result = (boolean) shell.evaluate("verifyStatus == 1"); Assert.assertTrue(result);

Aviator簡介

Aviator是一個高效能、輕量級的java語言實現的表示式求值引擎,主要用於各種表示式的動態求值。現在已經有很多開源可用的java表示式求值引擎,為什麼還需要Avaitor呢?

Aviator的設計目標是輕量級和高效能,相比於Groovy、JRuby的笨重,Aviator非常小,加上依賴包也才450K,不算依賴包的話只有70K;當然,

Aviator的語法是受限的,它不是一門完整的語言,而只是語言的一小部分集合。

其次,Aviator的實現思路與其他輕量級的求值器很不相同,其他求值器一般都是通過解釋的方式執行,而Aviator則是直接將表示式編譯成Java位元組碼,交給JVM去執行。簡單來說,Aviator的定位是介於Groovy這樣的重量級指令碼語言和IKExpression這樣的輕量級表示式引擎之間。對於前面的表示式,Aviator的寫法為: ```java Map env = Maps.newHashMap(); env.put(STRATEGY_CONTEXT_KEY, context);

// triggerExec(t1) && triggerExec(t2) && triggerExec(t3) log.info("### guid: {} logicExpr: [ {} ], strategyData: {}", strategyData.getGuid(), strategyData.getLogicExpr(), JSON.toJSONString(strategyData));

boolean hit = (Boolean) AviatorEvaluator.execute(strategyData.getLogicExpr(), env, true);

if (Objects.isNull(strategyData.getGuid())) { //若guid為空,為check告警策略,直接返回 log.info("### strategyData: {} check success", strategyData.getName()); return; } ```

效能對比

image.pngimage.png

Drools是一個高效能的規則引擎,但是設計的使用場景和在本次測試中的場景並不太一樣,Drools的目標是一個複雜物件比如有上百上千的屬性,怎麼快速匹配規則,而不是簡單物件重複匹配規則,因此在這次測試中結果墊底。 IKExpression是依靠解釋執行來完成表示式的執行,因此效能上來說也差強人意,和Aviator,Groovy編譯執行相比,還是效能差距還是明顯。

Aviator會把表示式編譯成位元組碼,然後代入變數再執行,整體上效能做得很好。

Groovy是動態語言,依靠反射方式動態執行表示式的求值,並且依靠JIT編譯器,在執行次數夠多以後,編譯成本地位元組碼,因此效能非常的高。對應於eSOC這樣需要反覆執行的表示式,Groovy是一種非常好的選擇。

場景實戰

監控告警規則

監控規則配置效果圖: image.png

最終轉化成表示式語言可以表示為: ```shell // 0.t實體邏輯如下 { "indicatorCode": "test001", "operator": ">=", "threshold": 1.5, "aggFuc": "sum", "interval": 5, "intervalUnit": "minute", ... }

// 1.規則命中表達式 triggerExec(t1) && triggerExec(t2) && (triggerExec(t3) || triggerExec(t4))

// 2.單個 triggerExec 執行內部 indicatorExec(indicatorCode) >= threshold ```

此時我們只需呼叫 Aviator 實現表示式執行邏輯如下: ```shell boolean hit = (Boolean) AviatorEvaluator.execute(strategyData.getLogicExpr(), env, true);

if (hit) { // 告警 } ```

自定義函式實戰

基於上節監控中心內 triggerExec 函式如何實現

先看原始碼: ```java public class AlertStrategyFunction extends AbstractAlertFunction {

public static final String TRIGGER_FUNCTION_NAME = "triggerExec";

@Override
public String getName() {
    return TRIGGER_FUNCTION_NAME;
}

@Override
public AviatorObject call(Map<String, Object> env, AviatorObject arg1) {
    AlertStrategyContext strategyContext = getFromEnv(STRATEGY_CONTEXT_KEY, env, AlertStrategyContext.class);
    AlertStrategyData strategyData = strategyContext.getStrategyData();
    AlertTriggerService triggerService = ApplicationContextHolder.getBean(AlertTriggerService.class);

    Map<String, AlertTriggerData> triggerDataMap = strategyData.getTriggerDataMap();
    AviatorJavaType triggerId = (AviatorJavaType) arg1;
    if (CollectionUtils.isEmpty(triggerDataMap) || !triggerDataMap.containsKey(triggerId.getName())) {
        throw new RuntimeException("can't find trigger config");
    }

    Boolean res = triggerService.executor(strategyContext, triggerId.getName());
    return AviatorBoolean.valueOf(res);
}

} ```

按照官方文件,只需繼承 AbstractAlertFunction ,即可實現自定義函式,重點如下:

  • getName() 返回 函式對應的呼叫名稱,必須實現
  • call() 方法可以過載,尾部引數可選,對應函式入參多個引數分別呼叫使用

實現自定義函式後,使用前需要註冊,原始碼如下: java AviatorEvaluator.addFunction(new AlertStrategyFunction()); 如果在 Spring 專案中使用,只需在 bean 的初始化方法中呼叫即可。

踩坑指南 & 調優

使用編譯快取模式

預設的編譯方法如 compile(script) 、 compileScript(path 以及 execute(script, env) 都不會快取編譯的結果,每次都將重新編譯表示式,生成一些匿名類,然後返回編譯結果 Expression 例項, execute 方法會繼續呼叫 Expression#execute(env) 執行。

這種模式下有兩個問題:

  1. 每次都重新編譯,如果你的指令碼沒有變化,這個開銷是浪費的,非常影響效能。
  2. 編譯每次都產生新的匿名類,這些類會佔用 JVM 方法區(Perm 或者 metaspace),記憶體逐步佔滿,並最終觸發  full gc。

因此,通常更推薦啟用編譯快取模式, compile 、 compileScript 以及 execute 方法都有相應的過載方法,允許傳入一個 boolean cached 引數,表示是否啟用快取,建議設定為 true:

java public final class AviatorEvaluatorInstance { public Expression compile(final String expression, final boolean cached) public Expression compile(final String cacheKey, final String expression, final boolean cached) public Expression compileScript(final String path, final boolean cached) throws IOException public Object execute(final String expression, final Map<String, Object> env, final boolean cached) }

其中的 cacheKey 是用來指定快取的 key,如果你的指令碼特別長,預設使用指令碼作為 key 會佔用較多的記憶體並耗費 CPU 做字串比較檢測,可以使用 MD5 之類唯一的鍵值來降低快取開銷。

快取管理

AviatorEvaluatorInstance 有一系列用於管理快取的方法:

  • 獲取當前快取大小,快取的編譯結果數量 getExpressionCacheSize() 
  • 獲取指令碼對應的編譯快取結果 getCachedExpression(script) 或者根據 cacheKey 獲取 getCachedExpressionByKey(cacheKey) ,如果沒有快取過,返回 null。
  • 失效快取 invalidateCache(script) 或者 invalidateCacheByKey(cacheKey) 。
  • 清空快取 clearExpressionCache() 

效能建議

  • 優先使用執行優先模式(預設模式)。
  • 使用編譯結果快取模式,複用編譯結果,傳入不同變數執行。
  • 外部變數傳入,優先使用編譯結果的 Expression#newEnv(..args) 方法建立外部 env,將會啟用符號化,降低變數訪問開銷。
  • 生產環境切勿開啟執行跟蹤模式。
  • 呼叫 Java 方法,優先使用自定義函式,其次是匯入方法,最後是基於 FunctionMissing 的反射模式。

往期精彩

歡迎關注公眾號:咕咕雞技術專欄 個人技術部落格:https://jifuwei.github.io/ image.png

參考: - [1].Drools, IKExpression, Aviator和Groovy字串表示式求值比較 - [2].AviatorScript 程式設計指南