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 編程指南