Drools 业务规则引擎的完整教程


业务规则可以很好地代表某些领域的逻辑。它们工作得很好,因为它们的结果是直观的,接近许多类型的领域专家的思维方式。其原因是它们允许将一个大问题分解成单个组件。通过这种方式,用户不必处理所有单一规则的协调问题:这是业务规则引擎提供的附加价值。

在这篇文章中,我们将讨论一个使用业务规则编写应用程序的具体例子。我们将编写规则来决定向通讯的订阅者发送哪封电子邮件。我们将看到不同类型的规则,以及我们如何使用Drools规则语言来表达它们。我们还将看到如何配置Drools(剧透:这很容易),并让系统阐述规则以产生一个我们可以使用的结果。

我认为商业规则是非常有趣的,因为它允许我们以不同的方式来看待问题。作为开发者,我们非常习惯于使用命令式范式或功能式范式。然而,还有其他的范式,比如状态机和业务规则,这些范式并不常用,而且在某些情况下可能更适合。

像往常一样,我们在配套的资源库中分享本教程中的代码。EmailSchedulingRules.。

我们要解决什么问题
让我们考虑一下电子邮件营销这个领域。作为营销人员,我们有一个对我们的内容感兴趣的人的电子邮件列表。他们中的每一个人都可能对一个特定的主题表现出兴趣,阅读过我们的一些文章并购买过某些产品。考虑到他们所有的历史和喜好,我们希望在每一次向他们发送最合适的内容。这些内容可能是教育性的,也可能是建议一些交易。问题是,我们要考虑一些限制条件(例如,不在周日发送电子邮件,或不向已经购买产品的人发送促销电子邮件)。

所有这些规则本身是简单的,但其复杂性来自于它们的组合方式以及它们的互动方式。业务规则引擎将为我们处理这种复杂性,我们所要做的就是清楚地表达这些单一的规则。规则将用我们的领域数据来表达,所以让我们首先关注我们的领域模型。

我们要解决什么问题
让我们考虑一下电子邮件营销这个领域。作为营销人员,我们有一个对我们的内容感兴趣的人的电子邮件列表。他们中的每一个人都可能对一个特定的主题表现出兴趣,阅读过我们的一些文章并购买过某些产品。考虑到他们所有的历史和喜好,我们希望在每一次向他们发送最合适的内容。这些内容可能是教育性的,也可能是建议一些交易。问题是,我们要考虑一些限制条件(例如,不在周日发送电子邮件,或不向已经购买产品的人发送促销电子邮件)。

所有这些规则本身是简单的,但其复杂性来自于它们的组合方式以及它们的互动方式。业务规则引擎将为我们处理这种复杂性,我们所要做的就是清楚地表达这些单一的规则。规则将用我们的领域数据来表达,所以让我们首先关注我们的领域模型。

我们的系统应该做什么
我们的系统应该执行所有的规则,使用Drools引擎,为每个用户决定在某一天应该发送哪封邮件。其结果可能是决定不发送任何邮件,或者发送一封邮件,在众多可能的邮件中选择一个。

需要考虑的一个重要问题是,这些规则可能会随着时间的推移而演变。负责营销的人可能想尝试新的规则,看看它们对系统有什么影响。使用Drools,他们应该很容易增加或删除规则,或调整现有的规则。

让我们强调这一点:
这些领域的专家应该能够对系统进行实验,并快速地进行尝试,而不是总是需要开发人员的帮助。

规则
好了,现在我们知道了我们有哪些数据,我们可以根据这个模型来表达规则。

让我们看看我们可能想写的一些规则的例子:

  • 我们可能有电子邮件的序列,例如一个课程的内容。它们必须按顺序发送
  • 我们可能有时间敏感的电子邮件,要么在特定的时间窗口内发送,要么根本就不发送。
  • 我们可能希望避免在一周的特定日子里发送电子邮件,例如在用户所在国家的公共假期。
  • 我们可能希望只向收到某些其他电子邮件的人发送某些类型的电子邮件(例如提议交易)(例如至少有3封关于同一主题的信息性电子邮件)。
  • 我们不希望向已经购买了某一产品的用户推荐该产品的交易。
  • 我们可能想限制我们向用户发送电子邮件的频率。例如,如果我们在过去5天内已经发送过一封电子邮件,我们可能决定不发送电子邮件给用户。


设置drools
设置drools可以非常简单。我们正在研究在一个独立的应用程序中运行drools。根据你的情况,这可能是也可能不是一个可接受的解决方案,在某些情况下,你必须研究一下JBoss,支持Drools的应用服务器。然而,如果你想开始工作,你可以忘掉这些,只需用Gradle(或Maven)配置你的依赖关系。如果你真的需要,你可以稍后再弄清楚那些无聊的配置部分。

buildscript {
    ext.droolsVersion = "7.20.0.Final"

    repositories {
        mavenCentral()
    }
}

plugins {
    id
"org.jetbrains.kotlin.jvm" version "1.3.21"
}

apply plugin: 'java'
apply plugin: 'idea'

group 'com.strumenta'
version '0.1.1-SNAPSHOT'

repositories {
    mavenLocal()
    mavenCentral()
    maven {
        url 'https:
//repository.jboss.org/nexus/content/groups/public/'
    }
}

dependencies {
    compile
"org.kie:kie-api:${droolsVersion}"
    compile
"org.drools:drools-compiler:${droolsVersion}"
    compile
"org.drools:drools-core:${droolsVersion}"
    compile
"ch.qos.logback:logback-classic:1.1.+"
    compile
"org.slf4j:slf4j-api:1.7.+"    
    implementation
"org.jetbrains.kotlin:kotlin-stdlib"
    implementation
"org.jetbrains.kotlin:kotlin-reflect"
    testImplementation
"org.jetbrains.kotlin:kotlin-test"
    testImplementation
"org.jetbrains.kotlin:kotlin-test-junit"
}


在我们的Gradle脚本中,我们使用:

  • Kotlin,因为Kotlin很好用!
  • IDEA,因为它是我最喜欢的IDE
  • Kotlin StdLib,反射和测试
  • Drools


而这就是我们的计划将被构建的方式:

fun main(args: Array<String>) {
    try {
        val kbase = readKnowledgeBase(listOf(
                File("rules/generic.drl"),
                File(
"rules/book.drl")))
        val ksession = kbase.newKieSession()
       
// typically we want to consider today but we may decide to schedule
       
// emails in the future or we may want to run tests using a different date
        val dayToConsider = LocalDate.now()
        loadDataIntoSession(ksession, dayToConsider)

        ksession.fireAllRules()

        showSending(ksession)
    } catch (t: Throwable) {
        t.printStackTrace()
    }
}

  • 我们从文件中加载规则。现在我们只是加载rules/generic.drl这个文件。
  • 我们建立一个新的会话。把会话看作是规则所看到的宇宙:它们可以访问的所有数据都在那里。
  • 我们把我们的数据模型加载到会话中
  • 我们启动所有的规则。他们可以改变会话中的东西
  • 我们读取修改后的数据模型(也就是会话)来计算我们今天应该发送哪些邮件


编写数据模型的类
我们之前已经看到了我们的数据模型是什么样的,现在让我们看看它的代码。

鉴于我们使用的是Kotlin,它将是相当简洁和明显的:

package com.strumenta.funnel

import java.time.DayOfWeek
import java.time.LocalDate
import java.util.*

enum class Priority {
    TRIVIAL,
    NORMAL,
    IMPORTANT,
    VITAL
}

data class Product(val name: String,
                   val price: Float)

data class Purchase(val product: Product,
                    val price: Float,
                    val date: LocalDate)

data class Subscriber(val name: String,
                      val subscriptionDate: LocalDate,
                      val country: String,
                      val email: String = "$name@foo.com",
                      val tags: List<String> = emptyList(),
                      val purchases: List<Purchase> = emptyList(),
                      val emailsReceived: MutableList<EmailSending> = LinkedList()) {

    val actualEmailsReceived
            get() = emailsReceived.map { it.email }

    fun isInSequence(emailSequence: EmailSequence) =
            hasReceived(emailSequence.first)
                    && !hasReceived(emailSequence.last)

    fun hasReceived(email: Email) = emailsReceived.any { it.email == email }

    fun hasReceivedEmailsInLastDays(nDays: Long, day: LocalDate)
            : Boolean {
        return emailsReceived.any {
            it.date.isAfter(day.minusDays(nDays))
        }
    }

    fun isOnHolidays(date: LocalDate) : Boolean {
        return date.dayOfWeek == DayOfWeek.SATURDAY
                || date.dayOfWeek == DayOfWeek.SUNDAY
    }

    fun emailReceivedWithTag(tag: String) =
            emailsReceived.count { tag in it.email.tags }

}

data class Email(val title: String,
                 val content: String,
                 val tags: List<String> = emptyList())

data class EmailSequence(val title: String,
                         val emails: List<Email>,
                         val tags: List<String> = emptyList()) {

    val first = emails.first()
    val last = emails.last()

    init {
        require(emails.isNotEmpty())
    }

    fun next(emailsReceived: List<Email>) =
        emails.first { it !in emailsReceived }
}

data class EmailSending(val email: Email,
                        val subscriber: Subscriber,
                        val date: LocalDate) {
    override fun equals(other: Any?): Boolean {
        return if (other is EmailSending) {
            this.email === other.email && this.subscriber === other.subscriber && this.date == other.date
        } else {
            false
        }
    }

    override fun hashCode(): Int {
        return this.email.title.hashCode() * 7 + this.subscriber.name.hashCode() * 3 + this.date.hashCode()
    }
}

data class EmailScheduling @JvmOverloads constructor(val sending: EmailSending,
                           val priority: Priority,
                           val timeSensitive: Boolean = false,
                           var blocked: Boolean = false) {
    val id = ++nextId

    companion object {
        private var nextId = 0
    }
}


这里没有什么令人惊讶的:我们有我们所期待的七个类。我们在这里和那里有一些实用方法,但没有什么是你不能自己弄清楚的。


编写一个规则来安排一个电子邮件
现在是时候编写我们的第一个业务规则了。这个规则将说明,给定一个序列和一个人,如果这个人还没有收到该序列的邮件,我们将安排该序列的第一封邮件发送给这个人。

dialect "java"
rule
"Start sequence"
   when
      sequence : EmailSequence ()
      subscriber : Subscriber ( !isInSequence(sequence) )

   then
      EmailSending $sending = new EmailSending(sequence.getFirst(), subscriber, day);
      EmailScheduling $scheduling = new EmailScheduling($sending, Priority.NORMAL);
      insert($scheduling);
end

在规则的标题中,我们指定了编写条款所使用的语言。在本教程中,我们将只考虑Java。还有一个可能的值:Mvel。我们将不研究这个问题。另外,虽然在这个例子中我们在规则中指定了方言,但也可以为整个文件指定一次。甚至还有一个更好的选择:完全不指定方言,因为无论如何Java是默认的,而且不鼓励使用mvel。

when部分决定了我们的规则将对哪些元素进行操作。在本例中,我们说明它将对EmailSequence和订阅者进行操作。它不会只对任何一个人起作用,而是只对满足条件 !isInSequence(sequence) 的人起作用。这个条件是基于对isInsequence方法的调用,我们将在下面展示:

data class Subscriber(...) {

    fun isInSequence(emailSequence: EmailSequence) = 
            hasReceived(emailSequence.first) && 
                !hasReceived(emailSequence.last)

    fun hasReceived(email: Email) = 
            emailReceived.any { it.email == email }
}

现在让我们来看看我们的规则的 "然后 "部分。在这部分中,我们指定了规则被触发时的情况。当满足when部分的元素被找到时,该规则将被触发。

在这种情况下,我们将创建一个EmailScheduling并将其添加到会话中。特别是我们希望在考虑的那一天向被考虑的人发送序列中的第一封邮件。我们还指定了这封邮件的优先级(在这种情况下是NORMAL)。当我们有多封邮件时,这对于决定有效地发送哪一封是必要的。事实上,我们将有另一条规则来查看这些值,以决定哪封邮件的优先级(提示:它将是具有最高优先级的邮件)。

一般来说,你可能想在子句中添加一些东西到会话中。另外,你可能想修改作为会话一部分的对象。你也可以调用有副作用的对象上的方法。虽然推荐的方法是限制自己对会话的操作,但你可能想为记录添加副作用,比如说。这在学习Drools和试图理解你的第一个规则时特别有用。

编写一个规则来阻止电子邮件的发送
我们将看到,我们有两种可能的规则:安排新邮件的规则和阻止安排的邮件发送的规则。我们之前已经看到了如何写一个规则来发送电子邮件,现在我们将看到如何写一个电子邮件来阻止一个电子邮件的发送。

在这个规则中,我们要检查是否有一封电子邮件预定要发送给一个在过去三天中已经收到电子邮件的人。如果是这种情况,我们要阻止该邮件的发送。

rule "Prevent overloading"
   when
      scheduling : EmailScheduling(
            sending.subscriber.hasReceivedEmailsInLastDays(3, day),
            !blocked )

   then
      scheduling.setBlocked(true);
end

在when部分,我们指定这个规则将对一个EmailScheduling进行操作。因此,每当另一个规则将添加一个EmailScheduling时,这个规则就会被触发,以决定我们是否必须阻止它的发送。

这个规则将适用于所有针对在过去3天内收到邮件的用户的调度。此外,我们将检查该邮件调度是否已经被阻止。如果是这样的话,我们就不需要应用这个规则。

我们使用调度对象的setBlocked方法来修改一个属于会话的元素。

在这一点上,我们已经看到了我们将使用的模式。

当我们认为向用户发送电子邮件有意义时,我们将创建`EmailScheduling`:
我们将检查我们是否有理由阻止这些邮件的发送。如果是这样的话,我们将把封锁的标志设置为真,有效地删除EmailScheduling
使用一个标志来标记要删除/无效/阻止的元素是业务规则中常用的模式。它在开始时听起来有点陌生,但它实际上是非常有用的。你可能认为你可以直接从会话中删除元素,然而这样做很容易造成无限循环,你用一些规则创建新的元素,用另一些规则删除它们,并不断重新创建它们。块状标志模式避免了所有这些。

会话
规则对作为会话一部分的数据进行操作。数据通常是在初始化阶段插入会话的。后来我们可以有规则将更多的数据插入会话中,可能会触发其他规则。

这就是我们如何用一些例子数据来填充会话:

fun loadDataIntoSession(ksession: KieSession,
                        dayToConsider: LocalDate) {
    val products = listOf(
            Product("My book", 20.0f),
            Product(
"Video course", 100.0f),
            Product(
"Consulting package", 500.0f)
    )
    val persons = listOf(
            Subscriber(
"Mario",
                    LocalDate.of(2019, Month.JANUARY, 1),
                   
"Italy"),
            Subscriber(
"Amelie",
                    LocalDate.of(2019, Month.FEBRUARY, 1),
                   
"France"),
            Subscriber(
"Bernd",
                    LocalDate.of(2019, Month.APRIL, 18),
                   
"Germany"),
            Subscriber(
"Eric",
                    LocalDate.of(2018, Month.OCTOBER, 1),
                   
"USA"),
            Subscriber(
"Albert",
                    LocalDate.of(2016, Month.OCTOBER, 12),
                   
"USA")
    )
    val sequences = listOf(
            EmailSequence(
"Present book", listOf(
                    Email(
"Present book 1", "Here is the book...",
                            tags= listOf(
"book_explanation")),
                    Email(
"Present book 2", "Here is the book...",
                            tags= listOf(
"book_explanation")),
                    Email(
"Present book 3", "Here is the book...",
                            tags= listOf(
"book_explanation"))
            )),
            EmailSequence(
"Present course", listOf(
                    Email(
"Present course 1", "Here is the course...",
                            tags= listOf(
"course_explanation")),
                    Email(
"Present course 2", "Here is the course...",
                            tags= listOf(
"course_explanation")),
                    Email(
"Present course 3", "Here is the course...",
                            tags= listOf(
"course_explanation"))
            ))
    )
    ksession.insert(Email(
"Question to user",
           
"Do you..."))
    ksession.insert(Email(
"Interesting topic A",
           
"Do you..."))
    ksession.insert(Email(
"Interesting topic B",
           
"Do you..."))
    ksession.insert(Email(
"Suggest book",
           
"I wrote a book...",
            tags= listOf(
"book_offer")))
    ksession.insert(Email(
"Suggest course",
           
"I wrote a course...",
            tags= listOf(
"course_offer")))
    ksession.insert(Email(
"Suggest consulting",
           
"I offer consulting...",
            tags= listOf(
"consulting_offer")))

    ksession.setGlobal(
"day", dayToConsider)

    ksession.insert(products)
    persons.forEach {
        ksession.insert(it)
    }
    sequences.forEach {
        ksession.insert(it)
    }
}

当然,在一个真实的应用中,我们会访问一些数据库或某种形式的存储来检索数据,以用于填充会话。

全局对象
在规则中,我们不仅要访问作为会话一部分的元素,还要访问全局对象。
全局对象是通过setGlobal插入会话的。我们已经在loadDataIntoSession中看到一个例子:

fun loadDataIntoSession(ksession: StatefulKnowledgeSession, dayToConsider: LocalDate) : EmailScheduler {
    ...
    ksession.setGlobal("day", dayToConsider)
    ...
}

在规则中声明了全局变量:

package com.strumenta.funnellang

import com.strumenta.funnel.Email;
import com.strumenta.funnel.EmailSequence;
import com.strumenta.funnel.EmailScheduling
import com.strumenta.funnel.EmailScheduler;
import com.strumenta.funnel.Person
import java.time.LocalDate;

global LocalDate day;

在这一点上,我们可以在所有的规则中引用这些球状物。在我们的例子中,我们使用日值来知道我们正在考虑哪一天进行调度。通常情况下是明天,因为我们想提前一天进行调度。然而出于测试的原因,我们可以使用任何我们想要的日子。或者我们可能想使用未来的几天来进行模拟。

全局不应该被滥用。我个人喜欢用它们来指定配置参数。其他人更喜欢把这些数据插入会话中,这也是推荐的方法。我之所以使用globals(谨慎且很少),是因为我喜欢区分我正在处理的数据(存储在会话中)和配置(为此我使用globals)。

编写通用规则
现在让我们看看我们所写的整套通用规则。我们所说的通用规则是指可以应用于我们想要做的所有电子邮件调度的规则。为了补充这些规则,我们可能会有其他的规则用于我们正在推广的特定产品或主题。

package com.strumenta.funnellang

import com.strumenta.funnel.Email;
import com.strumenta.funnel.EmailSequence;
import com.strumenta.funnel.EmailScheduling
import com.strumenta.funnel.EmailSending;
import com.strumenta.funnel.Subscriber
import java.time.LocalDate;
import com.strumenta.funnel.Priority

global LocalDate day;

rule "Continue sequence"
   when
      sequence : EmailSequence ()
      subscriber : Subscriber ( isInSequence(sequence) )

   then
      EmailSending $sending = new EmailSending(sequence.next(subscriber.getActualEmailsReceived()), subscriber, day);
      EmailScheduling $scheduling = new EmailScheduling($sending, Priority.IMPORTANT, true);
      insert($scheduling);
end

rule
"Start sequence"
   when
      sequence : EmailSequence ()
      subscriber : Subscriber ( !isInSequence(sequence) )

   then
      EmailSending $sending = new EmailSending(sequence.getFirst(), subscriber, day);
      EmailScheduling $scheduling = new EmailScheduling($sending, Priority.NORMAL);
      insert($scheduling);
end

rule
"Prevent overloading"
   when
      scheduling : EmailScheduling(
            sending.subscriber.hasReceivedEmailsInLastDays(3, day),
            !blocked )

   then
      scheduling.setBlocked(true);
end

rule
"Block on holidays"
   when
      scheduling : EmailScheduling( sending.subscriber.isOnHolidays(scheduling.sending.date), !blocked )

   then
      scheduling.setBlocked(true);
end

rule
"Precedence to time sensitive emails"
   when
      scheduling1 : EmailScheduling( timeSensitive == true, !blocked )
      scheduling2 : EmailScheduling( this != scheduling1,
                !blocked,
                sending.subscriber == scheduling1.sending.subscriber,
                sending.date == scheduling1.sending.date,
                timeSensitive == false)
   then
      scheduling2.setBlocked(true);
end

rule
"Precedence to higher priority emails"
  when
     scheduling1 : EmailScheduling( !blocked )
     scheduling2 : EmailScheduling( this != scheduling1,
               !blocked,
               sending.subscriber == scheduling1.sending.subscriber,
               sending.date == scheduling1.sending.date,
               timeSensitive == scheduling1.timeSensitive,
               priority < scheduling1.priority)

   then
      scheduling2.setBlocked(true);
end

rule
"Limit to one email per day"
  when
     scheduling1 : EmailScheduling( blocked == false )
     scheduling2 : EmailScheduling( this != scheduling1,
               blocked == false,
               sending.subscriber == scheduling1.sending.subscriber,
               sending.date == scheduling1.sending.date,
               timeSensitive == scheduling1.timeSensitive,
               priority == scheduling1.priority,
               id > scheduling1.id)

   then
      scheduling2.setBlocked(true);
end

rule
"Never resend same email"
  when
     scheduling : EmailScheduling( !blocked )
     subscriber : Subscriber( this == scheduling.sending.subscriber,
            hasReceived(scheduling.sending.email) )
   then
      scheduling.setBlocked(true);
end

让我们逐一审查所有这些规则。

  • Continue sequence:如果某人开始收到一个电子邮件序列,并且他还没有收到最后一封邮件,那么他应该收到该序列的下一封邮件。
  • Start sequence:如果某人还没有收到序列中的第一封邮件,他应该收到。请注意,从技术上讲,仅这一规则就会导致每个已经完成序列的人立即重新开始。由于从不重发同一封邮件的规则,这种情况不会发生。然而,你可以决定重写这个规则,明确禁止已经收到某个序列的人被重新插入其中。
  • Prevent overloading:如果某人在过去三天内收到过一封邮件,那么我们应该阻止任何针对该人的邮件调度。
  • Block on holiday:如果某人在节假日,我们不应该向他发送电子邮件
  • Precedence to time sensitive emails优先考虑时间敏感的电子邮件:给定一对在同一日期指向同一个人的电子邮件调度,如果其中只有一个是时间敏感的,我们应该阻止另一个。
  • Precedence to higher priority emails优先考虑高优先级的电子邮件:如果在同一日期有一对针对同一个人的电子邮件安排,并且都是时间敏感的或者都不是时间敏感的,我们应该阻止重要性较低的那封。
  • Limit to one email per day限制每天发送一封邮件:我们不应该安排每天向同一个人发送超过一封邮件。如果发生这种情况,我们必须以某种方式选择一个。我们使用内部ID来区分这两个人。
  • Never resend same email:如果某人已经收到了某封邮件,那么他在未来就不应该再收到。


编写书籍邮件的具体规则
我们的营销专家可能想为特定的产品或主题编写特定的规则。让我们假设他们想创建一套电子邮件来推广和销售一本书。我们可以将这些规则写在一个单独的文件中,也许由负责销售该书的营销专家维护。

为了编写有关特定主题的规则,我们将利用标签的优势,这种机制将给我们带来一定的灵活性。让我们看看我们可以写的规则。

package com.strumenta.funnellang

import com.strumenta.funnel.Subscriber;
import com.strumenta.funnel.EmailScheduling;
import java.time.DayOfWeek;

rule "Send book offer only after at least 3 book presentation emails"
   when
      subscriber : Subscriber (
          emailReceivedWithTag(
"book_explanation") < 3
      )
      scheduling : EmailScheduling(
        !blocked,
        sending.subscriber == subscriber,
        sending.email.tags contains
"book_offer"
      )
   then
        scheduling.setBlocked(true);
end

rule
"Block book offers on monday"
   when
      scheduling : EmailScheduling(
        !blocked,
        sending.date.dayOfWeek == DayOfWeek.MONDAY,
        sending.email.tags contains
"book_offer"
      )
   then
        scheduling.setBlocked(true);
end

rule
"Block book offers for people who bought"
   when
      subscriber : Subscriber (
          tags contains
"book_bought"
      )
      scheduling : EmailScheduling(
        !blocked,
        sending.subscriber == subscriber,
        sending.email.tags contains
"book_offer"
      )
   then
        scheduling.setBlocked(true);
end

让我们审查一下我们的规则。

  • Send book offer only after at least 3 book presentation emails只有在至少3封图书介绍邮件之后才发送图书报价:如果订阅者没有收到至少3封解释图书内容的邮件,我们要阻止任何销售图书的邮件。
  • Block book offers on monday阻止周一的图书提供:我们想阻止在周一发送图书报价,例如,我们已经看到用户在一周的那一天不太愿意购买。
  • Block book offers for people who bought阻止向已购买的人提供图书:我们不想向已购买的订户提出图书交易。


测试业务规则
我们可能想写不同类型的测试来验证我们的规则是否符合预期。在光谱的一边,我们可能希望有测试来验证复杂的场景,并检查规则之间的意外互动。这些测试将考虑复杂的数据集和整个业务规则集的运行。在光谱的另一边,我们可能想写简单的单元测试来验证单个规则。我们将看到这些单元测试的一个例子,但我们看到的大部分内容可以调整为测试整个规则集而不是单一规则。

我们在单元测试中想做什么?

  1. 我们设置知识库
  2. 我们想把一些数据加载到会话中
  3. 我们要运行规则业务引擎,只启用一个我们要测试的业务规则
  4. 我们要验证所产生的电子邮件调度是否与预期的一样。
  5. 为了满足第1点,我们加载所有包含规则的文件,并验证没有问题。

private fun prepareKnowledgeBase(files: List<File>): InternalKnowledgeBase {
    val kbuilder = KnowledgeBuilderFactory.newKnowledgeBuilder()

    files.forEach { kbuilder.add(ResourceFactory.newFileResource(it), ResourceType.DRL) }

    val errors = kbuilder.errors

    if (errors.size > 0) {
        for (error in errors) {
            System.err.println(error)
        }
        throw IllegalArgumentException("Could not parse knowledge.")
    }

    val kbase = KnowledgeBaseFactory.newKnowledgeBase()
    kbase.addPackages(kbuilder.knowledgePackages)

    return kbase
}


我们如何将数据加载到会话中?我们通过加载一些默认的数据,然后在每个测试中给这个数据一点改变的可能性。在下面这段代码中,你会看到我们可以传递一个函数作为dataTransformer参数。这样的函数可以在我们将数据加载到会话之前对其进行操作。这就是我们在每次测试中调整数据的钩子。

fun loadDataIntoSession(ksession: KieSession,
                        dayToConsider: LocalDate, dataTransformer: ((Subscriber, Email) -> Unit)? = null) {

    val amelie = Subscriber("Amelie",
            LocalDate.of(2019, Month.FEBRUARY, 1),
           
"France")
    val bookSeqEmail1 = Email(
"Present book 1", "Here is the book...",
            tags= listOf(
"book_explanation"))

    val products = listOf(
            Product(
"My book", 20.0f),
            Product(
"Video course", 100.0f),
            Product(
"Consulting package", 500.0f)
    )
    val persons = listOf(amelie)
    val sequences = listOf(
            EmailSequence(
"Present book", listOf(
                    bookSeqEmail1,
                    Email(
"Present book 2", "Here is the book...",
                            tags= listOf(
"book_explanation")),
                    Email(
"Present book 3", "Here is the book...",
                            tags= listOf(
"book_explanation"))
            ))
    )
    dataTransformer?.invoke(amelie, bookSeqEmail1)

    ksession.insert(Email(
"Question to user",
           
"Do you..."))
    ksession.insert(Email(
"Interesting topic A",
           
"Do you..."))
    ksession.insert(Email(
"Interesting topic B",
           
"Do you..."))
    ksession.insert(Email(
"Suggest book",
           
"I wrote a book...",
            tags= listOf(
"book_offer")))
    ksession.insert(Email(
"Suggest course",
           
"I wrote a course...",
            tags= listOf(
"course_offer")))
    ksession.insert(Email(
"Suggest consulting",
           
"I offer consulting...",
            tags= listOf(
"consulting_offer")))

    ksession.setGlobal(
"day", dayToConsider)

    ksession.insert(products)
    persons.forEach {
        ksession.insert(it)
    }
    sequences.forEach {
        ksession.insert(it)
    }
}

我们通过对要执行的规则指定一个过滤器来实现第3点:

ksession.fireAllRules { match -> match.rule.name in rulesToKeep }

在这一点上,我们可以简单地检查结果。

一旦这个基础设施到位,我们要写的测试将看起来像这样:

@test fun startSequencePositiveCase() {
    val schedulings = setupSessionAndFireRules(
            LocalDate.of(2019, Month.MARCH, 17), listOf("Start sequence"))
    assertEquals(1, schedulings.size)
    assertNotNull(schedulings.find {
        it.sending.email.title ==
"Present book 1"
                && it.sending.subscriber.name ==
"Amelie" })
}

@test fun startSequenceWhenFirstEmailReceived() {
    val schedulings = setupSessionAndFireRules(
            LocalDate.of(2019, Month.MARCH, 17),
            listOf(
"Start sequence")) { amelie, bookSeqEmail1 ->
        amelie.emailsReceived.add(
                EmailSending(bookSeqEmail1, amelie,
                        LocalDate.of(2018, Month.NOVEMBER, 12)))
    }

    assertEquals(0, schedulings.size)
}


在第一个测试中,我们希望Amelie能收到一个序列的第一封邮件,因为她还没有收到。在第二个测试中,我们在会话中设置Amelie已经收到了该序列的第一封邮件,所以我们期望它不会再收到它(根本不期望有邮件调度)。

这就是测试类的全部代码:

package com.strumenta.funnel

import org.drools.core.impl.InternalKnowledgeBase
import org.drools.core.impl.KnowledgeBaseFactory
import org.kie.api.io.ResourceType
import org.kie.api.runtime.KieSession
import org.kie.internal.builder.KnowledgeBuilderFactory
import org.kie.internal.io.ResourceFactory
import java.io.File
import java.time.LocalDate
import java.time.Month
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import org.junit.Test as test

class GenericRulesTest {

    private fun prepareKnowledgeBase(files: List<File>): InternalKnowledgeBase {
        val kbuilder = KnowledgeBuilderFactory.newKnowledgeBuilder()

        files.forEach { kbuilder.add(ResourceFactory.newFileResource(it), ResourceType.DRL) }

        val errors = kbuilder.errors

        if (errors.size > 0) {
            for (error in errors) {
                System.err.println(error)
            }
            throw IllegalArgumentException("Could not parse knowledge.")
        }

        val kbase = KnowledgeBaseFactory.newKnowledgeBase()
        kbase.addPackages(kbuilder.knowledgePackages)

        return kbase
    }

    fun loadDataIntoSession(ksession: KieSession,
                            dayToConsider: LocalDate, dataTransformer: ((Subscriber, Email) -> Unit)? = null) {

        val amelie = Subscriber(
"Amelie",
                LocalDate.of(2019, Month.FEBRUARY, 1),
               
"France")
        val bookSeqEmail1 = Email(
"Present book 1", "Here is the book...",
                tags= listOf(
"book_explanation"))

        val products = listOf(
                Product(
"My book", 20.0f),
                Product(
"Video course", 100.0f),
                Product(
"Consulting package", 500.0f)
        )
        val persons = listOf(amelie)
        val sequences = listOf(
                EmailSequence(
"Present book", listOf(
                        bookSeqEmail1,
                        Email(
"Present book 2", "Here is the book...",
                                tags= listOf(
"book_explanation")),
                        Email(
"Present book 3", "Here is the book...",
                                tags= listOf(
"book_explanation"))
                ))
        )
        dataTransformer?.invoke(amelie, bookSeqEmail1)

        ksession.insert(Email(
"Question to user",
               
"Do you..."))
        ksession.insert(Email(
"Interesting topic A",
               
"Do you..."))
        ksession.insert(Email(
"Interesting topic B",
               
"Do you..."))
        ksession.insert(Email(
"Suggest book",
               
"I wrote a book...",
                tags= listOf(
"book_offer")))
        ksession.insert(Email(
"Suggest course",
               
"I wrote a course...",
                tags= listOf(
"course_offer")))
        ksession.insert(Email(
"Suggest consulting",
               
"I offer consulting...",
                tags= listOf(
"consulting_offer")))

        ksession.setGlobal(
"day", dayToConsider)

        ksession.insert(products)
        persons.forEach {
            ksession.insert(it)
        }
        sequences.forEach {
            ksession.insert(it)
        }
    }

    private fun setupSessionAndFireRules(dayToConsider: LocalDate, rulesToKeep: List<String>,
                                         dataTransformer: ((Subscriber, Email) -> Unit)? = null) : List<EmailScheduling> {
        val kbase = prepareKnowledgeBase(listOf(File(
"rules/generic.drl")))
        val ksession = kbase.newKieSession()
        loadDataIntoSession(ksession, dayToConsider, dataTransformer)

        ksession.fireAllRules { match -> match.rule.name in rulesToKeep }

        return ksession.selectScheduling(dayToConsider)
    }

    @test fun startSequencePositiveCase() {
        val schedulings = setupSessionAndFireRules(
                LocalDate.of(2019, Month.MARCH, 17), listOf(
"Start sequence"))
        assertEquals(1, schedulings.size)
        assertNotNull(schedulings.find {
            it.sending.email.title ==
"Present book 1"
                    && it.sending.subscriber.name ==
"Amelie" })
    }

    @test fun startSequenceWhenFirstEmailReceived() {
        val schedulings = setupSessionAndFireRules(
                LocalDate.of(2019, Month.MARCH, 17),
                listOf(
"Start sequence")) { amelie, bookSeqEmail1 ->
            amelie.emailsReceived.add(
                    EmailSending(bookSeqEmail1, amelie,
                            LocalDate.of(2018, Month.NOVEMBER, 12)))
        }

        assertEquals(0, schedulings.size)
    }

}

结论
营销人员应该能够很容易地实验和尝试他们的策略和想法:例如,他们是否想创建一个特别的优惠,只是在每天20个用户发送?他们想向某个国家的用户发送特别优惠吗?他们是否想考虑用户的生日或国家节日来给他发送特别的信息?我们的领域专家,也就是这里的营销人员,应该有一个工具来把这些想法注入系统,并看到它们的应用。由于业务规则的存在,他们可以自己实现其中的大部分。不需要通过开发人员或其他 "守门人",这意味着他们可以自由地进行试验,尝试一些东西,最终使企业获利。

有一些事情需要考虑:仅仅提供编写业务规则的可能性是不够的。为了使我们的领域专家对他们所写的规则有信心,我们应该给他们提供机会,让他们在一个安全的环境中进行游戏和尝试:应该建立一个测试或模拟机制。通过这种方式,他们可以尝试一些东西,看看他们是否将他们心中的想法正确地转化为代码。

当然,与典型的代码相比,业务规则更容易编写。之所以如此,是因为它们有一个预定义的格式。通过这种方式,我们可以挑选一个现有的规则,然后进行一些调整。但是,这仍然需要对领域专家进行一些培训,以适应他们。他们需要培养将自己的想法正式化的能力,这可能很容易也很难,这取决于他们的背景。例如,对于营销人员来说,这可能是可以做到的,而对于其他专业人士来说,这可能需要更多的锻炼。为了简化他们的生活,使领域专家更有效率,我们可以做的是在我们的业务规则前面加上一个领域专用语言。

通过创建一个简单的DSL,我们可以使我们的营销人员的工作更容易。这个DSL将允许操作我们所看到的领域模型(订阅者、电子邮件等),并执行营销人员感兴趣的两个动作:安排和阻止电子邮件。我们可以提供一个简单的编辑器,带有自动完成和错误检查功能,并在其中集成一个测试和模拟环境。在这种情况下,营销人员将是完全独立的,能够快速设计和验证他们的规则,而且所需的支持非常有限。