如何編寫一個簡單但強大的規則引擎? – maxant

banq發表於2022-03-29

以下是我的規則引擎幾個基本要求:
  • 使用某種表達語言來編寫規則,
  • 應該可以將規則儲存在資料庫中,
  • 規則需要優先順序,因此只有最好的才能被解僱,
  • 也應該可以觸發所有匹配規則,
  • 規則應該針對一個輸入進行評估,該輸入可以是像樹這樣的物件,包含規則需要評估的所有資訊
  • 當某些規則觸發時,應執行在系統中程式設計的預定義動作。

 
所以為了幫助釐清這些要求,想象一下下面的例子:

1) 在一些論壇系統中,管理員需要能夠配置何時傳送電子郵件:

在這裡,我會寫一些規則,比如 "當名為sendUserEmail的配置標誌被設定為true時,向使用者傳送電子郵件 "和 "當名為sendAdministratorEmail的配置標誌為true且使用者釋出的帖子少於5篇時,向管理員傳送電子郵件"。

2)一個關稅系統需要可配置,以便向客戶提供最佳關稅:

為此,我可以寫這樣的規則。"當這個人小於26歲時,適用青年票價","當這個人大於59歲時,適用老年票價",以及 "當這個人既不是青年,也不是老年,那麼他們應該得到預設票價,除非他們有一個超過24個月的賬戶,在這種情況下,他們應該得到原始票價。"

3) 一張火車票可以被視為一種產品。根據旅行的要求,不同的產品是合適的:

這裡的一個規則可以是這樣的。"如果旅行距離超過100公里,並且需要頭等艙,那麼產品A將被出售。"

最後,一個更復雜的例子,涉及對輸入的一些迭代,而不僅僅是屬性評估。

4) 時間表軟體需要確定學生何時可以離開學校:

這方面的一個規則可能是 "如果一個班級包含任何10歲以下的學生,整個班級就可以提前離開。否則,他們在正常時間離開。"
 
 
因此,考慮到以上這些要求,我設計我的規則引擎是這樣工作的:
1)引擎配置了一些規則。
2) 規則具有以下屬性:
  • – 名稱空間:一個引擎可能包含許多規則,但只有一些可能與特定呼叫相關,並且此名稱空間可用於過濾
  • – 名稱:名稱空間中的唯一名稱
  • – 表示式:MVEL規則表示式
  • – 結果:如果此規則表示式計算結果為真,引擎可能使用的字串
  • – 優先順序:整數。值越大,優先順序越高。
  • – 描述:有助於規則管理的有用描述。


3) 引擎被賦予一個輸入物件並評估所有規則(可選地在名稱空間內),並且:
  • a)返回所有評估為 true 的規則,
  • b)從具有最高優先順序的規則返回結果(字串),其中所有評估為真的規則,
  • c) 執行與具有最高優先順序的規則的結果相關聯的操作(在應用程式中定義),在所有評估為真的規則中。


4) “動作Action”是應用程式設計師可以提供的類的例項。一個動作被賦予了一個名字。當引擎被要求執行基於規則的動作時,匹配“獲勝”規則結果的動作名稱被執行。

5) 一個規則可以由“子規則”組成。子規則僅用作構建更復雜規則的基礎。在評估規則時,引擎永遠不會選擇一個子規則作為最佳(最高優先順序)“獲勝”規則,即一個評估為真的規則。子規則使構建複雜規則變得更加容易,我稍後將展示。

 
首先,讓我們看一下程式碼。

Rule r1 = new Rule("YouthTarif", "input.person.age < 26", "YT2011", 3, "ch.maxant.someapp.tarifs", null);
Rule r2 = new Rule("SeniorTarif", "input.person.age > 59", "ST2011", 3, "ch.maxant.someapp.tarifs", null);
Rule r3 = new Rule("DefaultTarif", "!YouthTarif && !SeniorTarif", "DT2011", 3, "ch.maxant.someapp.tarifs", null);
Rule r4 = new Rule("LoyaltyTarif", "DefaultTarif && input.account.ageInMonths 
List<Rule> rules = Arrays.asList(r1, r2, r3, r4);

Engine engine = new Engine(rules, true);

TarifRequest request = new TarifRequest();
request.setPerson(new Person("p"));
request.setAccount(new Account());

request.getPerson().setAge(24);
request.getAccount().setAgeInMonths(5);
String tarif = engine.getBestOutcome(request);


因此,在上面的程式碼中,我向引擎新增了4條規則,並告訴引擎,如果任何規則不能被預編譯,就丟擲一個異常。
然後,我建立了一個TarifRequest,它是輸入物件。當我要求引擎給我最好的結果時,這個物件被傳入引擎。
在這種情況下,最好的結果是字串 "YT2011",這是我新增到關稅請求中的最適合客戶的關稅名稱。

這一切是如何運作的?當引擎得到規則時,它會對它們進行一些驗證,並預先編譯規則(以提高整體效能)。注意到前兩條規則是如何提到一個叫做 "輸入 "的物件的嗎?那是傳遞到引擎上的 "getBestOutcome "方法的物件。
引擎將輸入物件和每個規則表示式一起傳遞給MVEL類。
任何時候一個表示式被評估為 "真",該規則就會被放在一邊,作為獲勝者的候選人。
最後,候選者按優先順序排序,具有最高優先順序的規則的結果域由引擎返回。

注意第三條和第四條規則是如何包含 "#"字元的。這不是標準的MVEL表示式語言。
當所有的規則被傳遞給它時,引擎會檢查所有的規則,並將任何以雜湊符號開始的標記替換為在規則中發現的與該標記相同的表示式。它將表示式包裹在方括號中。在引用規則被解決和替換後,記錄器會輸出完整的規則,以防你想檢查規則。

在上面的商業案例中,我們只對客戶的最佳價格感興趣。
同樣,我們也可能對可能的價格列表感興趣,這樣我們就可以為客戶提供選擇。在這種情況下,我們可以呼叫引擎上的 "getMatchingRules "方法,這將會返回所有的規則,按優先順序排序。塔裡夫的名字是(在這種情況下)規則的 "結果 "欄位。

在上面的例子中,我想從四條規則中接收任何一個結果。
然而,有時你可能想在積木的基礎上建立複雜的規則,但你可能永遠不希望這些積木成為一個勝利的結果。

上面的火車旅行例子可以用來說明我的意思:

Rule rule1 = new SubRule("longdistance", "input.distance > 100", "ch.maxant.produkte", null);
Rule rule2 = new SubRule("firstclass", "input.map[\"travelClass\"] == 1", "ch.maxant.produkte", null);
Rule rule3 = new Rule("productA", "longdistance && firstclass", "productA", 3, "ch.maxant.produkte", null);
List<Rule> rules = Arrays.asList(rule1, rule2, rule3);

Engine e = new Engine(rules, true);

TravelRequest request = new TravelRequest(150);
request.put("travelClass", 1);
List rs = e.getMatchingRules(request);


在上面的程式碼中,我從兩個子規則中構建規則3。但我不希望這些構建模組的結果從引擎中輸出。
所以我把它們建立為子規則。子規則沒有一個結果欄位或優先順序。它們只是被用來建立更復雜的規則。
當引擎在初始化過程中使用子規則來替換所有以雜湊值開始的標記後,它將丟棄子規則--它們不會被評估。

上面的TravelRequest在建構函式中需要一個距離,幷包含一個附加引數的地圖。MVEL讓你使用規則2中的語法輕鬆地訪問地圖值。

接下來,考慮想要配置一個論壇系統的商業案例。下面的程式碼介紹了動作。行動是由應用程式設計師建立並提供給引擎的。引擎獲取結果(如第一個例子所述),並搜尋與這些結果同名的動作,並在這些動作上呼叫 "執行 "方法(它們都實現了IAction介面)。當一個系統必須具備預定義的能力,但選擇做什麼需要高度可配置且獨立於部署時,這種功能就很有用。

Rule r1 = new Rule("SendEmailToUser", "input.config.sendUserEmail == true", "SendEmailToUser", 1, "ch.maxant.someapp.config", null);
Rule r2 = new Rule("SendEmailToModerator", "input.config.sendAdministratorEmail == true and input.user.numberOfPostings < 5", "SendEmailToModerator", 2, "ch.maxant.someapp.config", null);
List<Rule> rules = Arrays.asList(r1, r2);
        
final List<String> log = new ArrayList<String>();
        
Action<ForumSetup, Void> a1 = new Action<ForumSetup, Void>("SendEmailToUser") {
  @Override
  public Void execute(ForumSetup input) {
    log.add("Sending email to user!");
    return null;
  }
};
Action<ForumSetup, Void> a2 = new Action<ForumSetup, Void>("SendEmailToModerator") {
  @Override
  public Void execute(ForumSetup input) {
    log.add("Sending email to moderator!");
    return null;
  }
};

Engine engine = new Engine(rules, true);

ForumSetup setup = new ForumSetup();
setup.getConfig().setSendUserEmail(true);
setup.getConfig().setSendAdministratorEmail(true);



在上面的程式碼中,當我們呼叫 "executeAllActions "方法時,這些動作被傳遞給引擎。在這種情況下,兩個動作都被執行,因為設定物件導致兩個規則都被評估為真。請注意,這些動作是按照優先順序最高的規則的順序執行的。每個動作只執行一次--執行後它的名字會被記錄下來,並且不會再被執行,直到引擎 "execute*Action*"方法被再次呼叫。另外,如果你只想執行與最佳結果相關的動作,請呼叫 "executeBestAction "方法而不是 "executeAllActions"。
 
最後,讓我們考慮一下教室裡的例子。

String expression = 
    "for(student : input.students){" +
    "    if(student.age < 10) return true;" +
    "}" +
    "return false;";

Rule r1 = new Rule("containsStudentUnder10", expression , "leaveEarly", 1, "ch.maxant.rules", "If a class contains a student under 10 years of age, then the class may go home early");
        
Rule r2 = new Rule("default", "true" , "leaveOnTime", 0, "ch.maxant.rules", "this is the default");


上面的結果是 "leaveEarly",因為教室裡有一個年齡小於10歲的學生。MVEL讓你寫一些相當全面的表示式,而且它本身就是一種程式語言。該引擎只要求一個規則返回真,如果該規則被認為是發射的候選人。

在原始碼中的JUnit測試中有更多的例子。

所以,除了 "應該可以將規則儲存在資料庫中 "之外,其他的要求都得到了滿足。雖然這個庫不支援從資料庫中讀寫規則,但規則是基於字串的。因此,建立一些JDBC或JPA程式碼並不難,這些程式碼可以從資料庫中讀取規則,填充規則物件並將其傳遞給引擎。我還沒有把這些新增到庫中,因為通常這些東西以及規則的管理都是一些特定的專案。而且因為我的庫永遠不會像Drools那樣酷或流行,我不確定是否值得我去新增這樣的功能。
 
 

Java8程式碼實現

支援 Java 8 lambdas 和流,現在它已在Maven Central 中釋出。該程式碼現在也可以在GitHub 上獲得。
首先,Maven 依賴項。以下依賴項適用於與 Java 6 相容的基本規則引擎。

<dependency>
  <groupId>ch.maxant</groupId>
  <artifactId>rules</artifactId>
  <version>2.1.0</version>
</dependency>


如果你想使用Java 8的lambdas,那麼你還需要新增一個依賴關係,如下所示。

<dependency>
  <groupId>ch.maxant</groupId>
  <artifactId>rules-java8</artifactId>
  <version>2.1.0</version>
</dependency>


這使你可以寫出像第15行和第20行那樣的程式碼。

Rule rule1 = new Rule("R1", 
            "input.p1.name == \"ant\" && input.p2.name == \"clare\"", 
            "outcome1", 
            0, 
            "ch.maxant.produits", 
            "Règle spéciale pour famille Kutschera");
Rule rule2 = new Rule("R2", "true", "outcome2", 1, 
                      "ch.maxant.produits", "Régle par défault");
List<Rule> rules = Arrays.asList(rule1, rule2);

//to use a lambda, construct a SamAction and pass it a lambda.
IAction<MyInput, BigDecimal> action1 = 
        new SamAction<MyInput, BigDecimal>(
            "outcome1", 
            i -> new BigDecimal("100.0")
        );
IAction<MyInput, BigDecimal> action2 = 
        new SamAction<MyInput, BigDecimal>(
            "outcome2", 
            i -> new BigDecimal("101.0")
        );

List<IAction<MyInput, BigDecimal>> actions = 
                                 Arrays.asList(action1, action2);

Engine e = new Engine(rules, true);

MyInput input = new MyInput();
Person p1 = new Person("ant");
Person p2 = new Person("clare");
input.setP1(p1);
input.setP2(p2);

BigDecimal price = e.executeBestAction(input, actions);
assertEquals(new BigDecimal("101.0"), price);


  
如果你想把一個流而不是一個集合傳遞給引擎,那麼就使用第二個庫中的子類,例如。

Stream<Rule> streamOfRules = getStreamOfRules();

//to pass in a stream, we need to use a different Engine
Java8Engine e = new Java8Engine(streamOfRules, true);

//use this engine as you would the normal Engine


有關更多詳細資訊,請參閱GitHub 上的測試。

如果您使用的是 Scala,也仍然支援該ScalaEngine功能,可以在以下依賴項中找到。有關更多詳細資訊,請參閱GitHub 上的測試。

<dependency> 
  <groupId>ch.maxant</groupId> 
  <artifactId>rules-scala</artifactId> 
  <version>2.1.0</version> 
</dependency>

  
 

Javascript版本
用 JavaScript ( Nashorn ) 編寫規則。
新特性:

  • 基於 JavaScript 的規則引擎 – 使用JavascriptEngine建構函式建立一個Engine能夠解釋 JavaScript 規則的子類。它使用 Nashorn (Java 8) 作為評估文字規則的 JavaScript 引擎。此外,您可以載入指令碼,例如lodash,這樣您的規則就會非常複雜。有關示例,請參閱 testRuleWithIterationUsingLibrary() and testComplexRuleInLibrary() and testLoadScriptRatherThanFile() 測試。Nashorn 不是執行緒安全的,但規則引擎是!在內部,它使用一個 Nashorn 引擎池。如果需要,您還可以覆蓋池配置。有關示例,請參閱測試。如果需要,您可以讓引擎預載入池,或者讓它懶惰地填充池(預設)。請注意testMultithreadingAndPerformance_NoProblemsExpectedBecauseScriptsAreStateless()and testMultithreadingStatefulRules_NoProblemsExpectedBecauseOfEnginePool(), 該引擎並不完全相容 Rhino (Java 6 / Java 7) - 多執行緒測試不能按預期執行有狀態指令碼,但 Rhino 的效能太差了,您無論如何都不想使用它。
  • 您現在可以覆蓋輸入引數的名稱——以前的版本要求規則將輸入稱為“輸入”,例如“input.people[0].name == 'Jane'”。您現在可以為引擎提供應該使用的名稱,以便您可以建立諸如“ company .people[0].name == 'Jane'”之類的規則。
  • Java 8 Javascript 規則引擎——如果你想使用 Java 8 lambda,那麼你例項化 aJava8JavascriptEngine而不是更普通的JavascriptEngine.
  • 為了您的方便,現在有 and 的構建器JavascriptEngine,Java8JavascriptEngine因為它們的構造器有很多引數。有關示例,請參見testBuilder()測試。
  • input.people[0].nameJavascript 規則可以使用 bean 表示法(例如“ ”)或 Java 表示法(例如“ ”)來引用輸入input.getPeople().get(0).getName()。

該庫可從 Maven Central 獲得:

<dependency>
<groupId>ch.maxant</groupId>
<artifactId>rules</artifactId>
<version>2.2.0</version>
</dependency>


 
Node.Js版本的規則引擎:
更多:https://github.com/maxant/rules/tree/master/rules-js

 

相關文章