如何编写一个简单但强大的规则引擎? – maxant


以下是我的规则引擎几个基本要求:

  • 使用某种表达语言来编写规则,
  • 应该可以将规则存储在数据库中,
  • 规则需要优先级,因此只有最好的才能被解雇,
  • 也应该可以触发所有匹配规则,
  • 规则应该针对一个输入进行评估,该输入可以是像树这样的对象,包含规则需要评估的所有信息
  • 当某些规则触发时,应执行在系统中编程的预定义动作。

 
所以为了帮助厘清这些要求,想象一下下面的例子:

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