一個規則引擎的視覺化方案

Ronzy發表於2021-04-17

背景

最近有個新專案可能會用到規則引擎,所以花了些時間對相關技術做調研,在百度、google用“規則引擎”作為關鍵字進行搜尋,可以找到很多關於這方面的資料,絕大部分都會提到 drools、urules、easy-rules等等這麼些開源專案,有一些文章也提到他們是採用groovy指令碼來實現的。通過對專案需求的評估,初步判定groovy指令碼已經可以滿足實際的場景。

然而,在這些資料或者方案之中,除了urules,大部分只是關注框架的效能和使用上的簡便,很少探討如何讓業務人員可以自行進行規則定義的方案。而urules雖然自帶了視覺化的規則管理介面,但是介面樣式不好自定義,無法跟現有後臺管理介面不突兀的融合。

通過不斷嘗試變換關鍵字在搜尋引擎搜尋,最終在stackoverflow找到了一個探討這個問題的帖子,特此將帖子中提到的方案分享一下,如果你跟我一樣在研究同樣的問題,也許對你有用。不過在介紹這個方案之前,得先簡單瞭解一下什麼是規則引擎

什麼是規則引擎?

簡單的說,規則引擎所負責的事情就是:判定某個資料或者物件是否滿足某個條件,然後根據判定結果,執行不同的動作。例如:

對於剛剛在網站上完成購物的一個使用者(物件),如果她是 "女性使用者 並且 (連續登入天數大於10天 或者 訂單金額大於200元 )" (條件) , 那麼系統就自動給該使用者發放一張優惠券(動作)。

在上面的場景中,規則引擎最重要的一個優勢就是實現“條件“表示式的配置化。如果條件表示式不能配置,那麼就需要程式設計師在程式碼裡面寫死各種if...else... ,如果條件組合特別複雜的話,程式碼就會很難維護;同時,如果不能配置化,那麼每次條件的細微變更,就需要修改程式碼,然後通過運維走釋出流程,無法快速響應業務的需求。

在groovy指令碼的方案中,上面的場景可以這麼實現:

  • 1)定義一個groovy指令碼:
def validateCondition(args){return args.使用者性別 == "女性" && (args.連續登入天數>10 || args.訂單金額 > 200);}
  • 2)通過Java提供的 ScriptEngineManager 物件去執行
    <dependency>
      <groupId>org.codehaus.groovy</groupId>
      <artifactId>groovy</artifactId>
      <version>3.0.7</version>
    </dependency>
/*
 *
 * @params condition  從資料庫中讀出來的條件表示式
 */
private Boolean validateCondition(String condition){
    //實際使用上,ScriptEngineManager可以定義為單例
    ScriptEngineManager engineManager = new ScriptEngineManager();
    ScriptEngine engine = engineManager.getEngineByName(scriptLang);
    Map<String, Object> args = new HashMap<>();
    data.put("使用者性別", "女性");
    data.put("連續登入天數", 11);
    data.put("訂單金額", 220);
    engine.eval(script);
    return ((Invocable) engine).invokeFunction(functionName, args);
}

在上面的groovy指令碼中,經常需要變動的部分就是 ”args.使用者性別 == "女性" && (args.連續登入天數>10 || args.訂單金額 > 200)“ 這個表示式,一個最簡單的方案,就是在後臺介面提供一個文字框,在文字框中錄入整個groovy指令碼,然後儲存到資料庫。但是這種方案有個缺點:表示式的定義有一定門檻。對於程式設計師來說,這自然是很簡單的事,但是對於沒接觸過程式設計的業務人員,就有一定的門檻了,很容易錄入錯誤的表示式。這就引出了本文的另一個話題,如何實現bool表示式的視覺化編輯?

如何實現bool表示式的視覺化編輯?

一種方案就是對於一個指定的表示式,前端人員進行語法解析,然後渲染成介面,業務人員編輯之後,再將介面元素結構轉換成表示式。然而,直接解析語法有兩個確定:

  • 1)需要考慮的邊界條件比較多,一不小心就解析出錯。
  • 2)而且也限定了後端可以選用的指令碼語言。例如,在上面的方案中選用的是groovy,它使用的"與"運算子是 && , 假如某天有一種效能更好的指令碼語言,它的"與"運算子定位為 and ,那麼就會需要修改很多表示式解析的地方。

另一種方案,是定義一個資料結構來描述表示式的結構(說了這麼多,終於來到重點了):

    { "all": [
        { "any": [
            { "gl": ["連續登入天數", 10] },
            { "gl": ["訂單金額", 200] }
        ]},
        { "eq": ["使用者性別", "女性"] }
    ]}

然後,使用遞迴的方式解析該結構,對於前端開發,可以在遞迴解析的過程中渲染成對應的介面元素;對於後端人員,可以生成對應的bool表示式,有了bool表示式,就可以使用預定的指令碼模板,生成最終的規則。

// 模板的例子
def validateCondition(args){return $s;}
/**
 * 動態bool表示式解析器
 */
public class RuleParser {
    private static final Map<String, String> operatorMap = new HashMap<>();
    private static final ObjectMapper objectMapper = new ObjectMapper();

    static {
        operatorMap.put("all", "&&");
        operatorMap.put("any", "||");
        operatorMap.put("ge", ">=");
        operatorMap.put("gt", ">");
        operatorMap.put("eq", "==");
        operatorMap.put("ne", "!=");
        operatorMap.put("le", "<=");
        operatorMap.put("lt", "<");
    }

    /**
     * 解析規則字串,轉換成表示式形式
     * 示例:
     * 輸入:
     *    { "any": [
     *        { "all": [
     *            { "ge": ["A", 10] },
     *            { "eq": ["B", 20] }
     *        ]},
     *        { "lt": ["C", 30] },
     *        { "ne": ["D", 50] }
     *    ]}
     *
     * 輸出:
     *    ( A >= 10 && B == 20 ) || ( C < 30 ) || ( D != 50 )
     * @param rule 規則的json字串形式
     * @return 返回 bool 表示式
     * @throws IOException 解析json字串異常
     */
    public static String parse(String rule) throws IOException {

        JsonNode jsonNode = objectMapper.readTree(rule);
        return parse(jsonNode);
    }

    /**
     * 解析規則節點,轉換成表示式形式
     * @param node Jackson Node
     * @return 返回bool表示式
     */
    private static String parse(JsonNode node) {
        // TODO: 支援變數的 ”arg.“ 字首定義
        if (node.isObject()) {
            Iterator<Map.Entry<String, JsonNode>> it = node.fields();
            if(it.hasNext()){
                Map.Entry<String, JsonNode> entry = it.next();
                List<String> arrayList = new ArrayList<>();
                for (JsonNode jsonNode : entry.getValue()) {
                    arrayList.add(parse(jsonNode));
                }

                return "(" + String.join(" " + operatorMap.get(entry.getKey()) + " ", arrayList) + ")";
            } else {
                // 相容空節點:例如 {"all": [{}, "eq":{"A","1"}]}
                return " 1==1";
            }
        } else if (node.isValueNode()) {
            return node.asText();
        }

        return "";
    }

結語

以上就是本文要闡述的全部內容,對於這個話題,如果你有這方面的經驗或者更好的方案,也請多多指教,謝謝!

相關文章