Статьи

Полное руководство по движку бизнес-правил Drools

Как всегда, мы делимся кодом, представленным в руководстве, в репозитории-компаньоне: EmailSchedulingRules .

Бизнес-правила работают очень хорошо, представляя логику для определенных доменов. Они работают хорошо, потому что они являются интуитивно понятными и близкими к образу мышления многих экспертов в данной области . Причина в том, что они позволяют разложить большую проблему на отдельные компоненты. Таким образом, пользователь не должен иметь дело с оркестровкой всех отдельных правил: это дополнительная ценность, предоставляемая механизмом бизнес-правил.

В этой статье мы обсудим один конкретный пример приложения, написанного с использованием бизнес-правил. Мы напишем правила, чтобы решить, какое письмо отправлять подписчикам на рассылку. Мы увидим различные типы правил и то, как мы могли бы выразить их, используя язык правил Drools. Мы также увидим, как настроить Drools (спойлер: это будет легко) и как система разработает правила для получения результата, который мы можем использовать.

Я думаю, что бизнес-правила довольно интересны, потому что они позволяют взглянуть на проблемы по-другому. Как разработчики, мы очень привыкли к императивной парадигме или функциональной парадигме. Однако существуют и другие парадигмы, такие как конечные автоматы и бизнес-правила, которые не так часто используются и которые могут в некоторых случаях лучше подходить.

Как всегда, мы делимся кодом, представленным в руководстве, в репозитории-компаньоне: EmailSchedulingRules .

Какую проблему мы пытаемся решить

Давайте рассмотрим область почтового маркетинга. Как маркетологи у нас есть список адресов электронной почты людей, заинтересованных в нашем контенте. Каждый из них может проявить интерес к определенной теме, прочитать некоторые наши статьи и купить определенные продукты. Учитывая всю их историю и предпочтения, мы хотим каждый раз присылать им наиболее подходящий контент. Этот контент может быть либо обучающим, либо предлагать какую-то сделку. Проблема в том, что есть ограничения, которые мы хотим рассмотреть (например, не отправлять электронные письма в воскресенье или не отправлять электронные письма, рекламирующие продукт, тому, кто его уже купил).

Все эти правила просты сами по себе, но сложность определяется тем, как они объединяются и как они взаимодействуют. Механизм бизнес-правил будет иметь дело с этой сложностью для нас, все, что нам нужно сделать, это четко сформулировать единые правила. Правила будут выражены в терминах данных нашего домена, поэтому давайте сначала сосредоточимся на нашей модели домена.

Модель нашего домена

В нашей модели предметной области мы имеем:

  • Электронные письма : отдельные письма, которые мы хотим отправить, описанные их заголовком и содержанием
  • Последовательности электронной почты : группы электронных писем, которые должны отправляться в определенном порядке, например, набор электронных писем, представляющих учебное пособие или описывающих различные функции продукта
  • Подписчики : единый подписчик на список рассылки. Нам нужно будет знать, какие электронные письма мы ему отправили, какие вещи ему интересны и какие продукты он купил
  • Продукты : продукты, которые мы продаем
  • Покупки : покупки, совершенные подписчиками
  • Отправка электронной почты: факт, что мы отправили или собираемся отправить определенное электронное письмо в определенную дату определенному подписчику
  • Планирование электронной почты : план отправки электронной почты с дополнительной информацией

Последние два элемента нашей доменной модели могут показаться менее очевидными по сравнению с другими, но мы увидим в реализации, по каким причинам они нам нужны.

механизм бизнес-правил

Что должна делать наша система

Наша система должна выполнить все правила, используя механизм Drools, и определить для каждого пользователя, какое электронное письмо мы должны отправить в определенный день. В результате может быть принято решение не отправлять электронную почту или отправить электронное письмо, выбрав одно из множества возможных электронных писем.

Важно учитывать, что эти правила могут развиваться со временем. Люди, отвечающие за маркетинг, могут захотеть попробовать новые правила и посмотреть, как они влияют на систему. Используя Drools, им будет легко добавлять или удалять правила или изменять существующие правила.

Давайте подчеркнем это:

Эти эксперты должны быть в состоянии экспериментировать с системой и быстро ее опробовать, не прибегая к помощи разработчиков .

Правила

Хорошо, теперь, когда мы знаем, какие данные у нас есть, мы можем выражать правила на основе этой модели.

Давайте посмотрим несколько примеров правил, которые мы можем захотеть написать:

  • У нас могут быть последовательности писем, например, содержание курса. Они должны быть отправлены в порядке
  • У нас могут быть чувствительные ко времени электронные письма, которые должны быть отправлены в определенное время или не отправлены вообще
  • Мы можем не отправлять электронные письма в определенные дни недели, например, в праздничные дни в стране, где находится абонент.
  • Мы можем захотеть отправлять электронные письма определенного типа (например, предлагая сделку) только тем лицам, которые получили определенные другие электронные письма (например, по крайней мере 3 информационных письма на ту же тему)
  • Мы не хотим предлагать сделку по определенному продукту подписчику, который уже купил этот продукт
  • Мы можем захотеть ограничить частоту отправки электронных писем пользователям. Например, мы можем принять решение не отправлять пользователю электронное письмо, если оно уже было отправлено за последние 5 дней.

Настройка слюни

Настройка слюней может быть очень простой. Мы рассматриваем запуск слюни в отдельном приложении. В зависимости от вашего контекста это может быть или не быть приемлемым решением, а в некоторых случаях вам придется заглянуть в JBoss, сервер приложений, поддерживающий Drools. Однако, если вы хотите начать, вы можете забыть обо всем этом и просто настроить свои зависимости с помощью Gradle (или Maven). Вы можете выяснить скучные биты конфигурации позже, если вам действительно нужно.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 {
    }
}
 
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 качается!
  • ИДЕЯ, потому что это моя любимая ИДЕ
  • Kotlin StdLib, отразить и проверить
  • Drools

И вот как наша программа будет структурирована:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
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, это будет довольно кратко и очевидно.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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 = "[email protected]",
                      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
    }
}

Ничего удивительного здесь: у нас есть семь классов, которые мы ожидали. У нас есть несколько полезных методов здесь и там, но ничего, что вы не можете понять самостоятельно.

Написание правила для планирования электронной почты

Настало время написать наше первое бизнес-правило. В этом правиле будет указано, что, учитывая последовательность и данное лицо, мы планируем отправить первое электронное сообщение о последовательности, которое будет отправлено пользователю, если это лицо еще не получило письмо из этой последовательности.

01
02
03
04
05
06
07
08
09
10
11
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 и Subscriber . Он не будет работать только для любого человека, но только для человека, для которого выполняется условие !isInSequence(sequence) . Это условие основано на вызове метода isInsequence который мы покажем ниже:

1
2
3
4
5
6
7
8
9
data class Subscriber(...) {
 
    fun isInSequence(emailSequence: EmailSequence) =
            hasReceived(emailSequence.first) &&
                !hasReceived(emailSequence.last)
 
    fun hasReceived(email: Email) =
            emailReceived.any { it.email == email }
}

Давайте теперь посмотрим на then раздел нашего правила. В таком разделе мы указываем, что происходит, когда правило срабатывает. Правило будет применено, when будут найдены элементы, удовлетворяющие разделу when .

В этом случае мы создадим EmailScheduling и добавим его в сеанс. В частности, мы хотим отправить рассматриваемому лицу первое электронное письмо из последовательности в рассматриваемый день. Мы также указываем приоритет этого письма (в данном случае это NORMAL ). Это необходимо, чтобы решить, какое электронное письмо эффективно отправлять, когда у нас их больше одного. Действительно, у нас будет другое правило, которое будет проверять эти значения, чтобы решить, какие электронные письма иметь приоритет (подсказка: это будет электронное письмо с наивысшим приоритетом).

В общем случае вы можете захотеть добавить вещи в сеанс в предложении then . В качестве альтернативы вы можете изменить объекты, которые являются частью сеанса. Вы также можете вызывать методы для объектов, которые имеют побочные эффекты. Хотя рекомендуемый подход заключается в том, чтобы ограничить себя манипулированием сеансом, вы можете добавить побочные эффекты для ведения журнала, например. Это особенно полезно, когда вы изучаете Drools и пытаетесь обдумать свои первые правила.

Написание правила для блокировки отправки электронной почты

Мы увидим, что у нас есть два возможных типа правил: правила для планирования новых электронных писем и правила для предотвращения отправки запланированных электронных писем. Ранее мы видели, как написать правило для отправки электронного письма, и теперь мы увидим, как написать электронное письмо, чтобы предотвратить отправку электронного письма.

В этом правиле мы хотим проверить, планируется ли отправка электронного письма человеку, который уже получил электронные письма за последние три дня. Если это так, мы хотим заблокировать отправку этого письма.

1
2
3
4
5
6
7
8
9
rule "Prevent overloading"
   when
      scheduling : EmailScheduling(
            sending.subscriber.hasReceivedEmailsInLastDays(3, day),
            !blocked )
 
   then
      scheduling.setBlocked(true);
end

В разделе when мы указываем, что это правило будет работать с EmailScheduling . Таким образом, каждый раз, когда другое правило добавляет EmailScheduling это правило может быть EmailScheduling чтобы решить, нужно ли нам блокировать его отправку.

Это правило будет применяться ко всем графикам, которые предназначены для подписчиков, которые получили электронные письма за последние 3 дня. В дополнение к этому мы проверим, не был ли EmailScheduling уже заблокирован. Если это так, нам не нужно применять это правило.

Мы используем метод setBlocked объекта планирования для изменения элемента, который является частью сеанса.

В этот момент мы увидели шаблон, который будем использовать:

  • Мы создадим EmailScheduling когда EmailScheduling целесообразным отправить электронное письмо пользователю
  • Мы проверим, есть ли у нас причины блокировать эти электронные письма. Если это так, мы установим флаг blocked в true, эффективно удаляя EmailScheduling

Использование флага для маркировки элементов для удаления / аннулирования / блокировки является распространенным шаблоном, используемым в бизнес-правилах. Вначале это может показаться немного незнакомым, но на самом деле это весьма полезно. Вы можете подумать, что вы можете просто удалить элементы из сеанса, однако при этом становится легко создавать бесконечные циклы, в которых вы создаете новые элементы с некоторыми правилами, удаляете их с другими и продолжаете воссоздавать их снова. Шаблон block-flag избегает всего этого.

Сессия

Правила работают с данными, которые являются частью сеанса. Данные обычно вставляются в сеанс во время фазы инициализации. Позже мы могли бы иметь правила, вставляющие больше данных в сеанс, потенциально вызывая другие правила.

Вот как мы могли бы заполнить сеанс некоторыми примерами данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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 :

1
2
3
4
5
fun loadDataIntoSession(ksession: StatefulKnowledgeSession, dayToConsider: LocalDate) : EmailScheduler {
    ...
    ksession.setGlobal("day", dayToConsider)
    ...
}

В правилах мы объявляем глобалы:

01
02
03
04
05
06
07
08
09
10
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;

На данный момент мы можем ссылаться на эти глобалы во всех правилах. В нашем примере мы используем значение day чтобы узнать, какой день мы рассматриваем для планирования. Как правило, это будет завтра, так как мы хотели бы сделать планирование за один день. Однако по причинам тестирования мы можем использовать любой день, когда захотим. Или мы можем захотеть использовать дни в будущем для целей моделирования.

Глобальным не следует злоупотреблять. Лично мне нравится использовать их для указания параметров конфигурации. Другие предпочитают вставлять эти данные в сеанс, и это рекомендуемый подход. Причина, по которой я использую глобальные переменные (осторожно и редко), заключается в том, что мне нравится различать данные, над которыми я работаю (хранящиеся в сеансе), и конфигурацию (для этого я использую глобальные переменные).

Написание общих правил

Давайте теперь посмотрим на весь набор общих правил, которые мы написали. Под общими правилами мы подразумеваем правила, которые могут применяться ко всем календарным планам, которые мы хотим сделать. В дополнение к этим правилам у нас могут быть другие для определенных продуктов или тем, которые мы продвигаем.

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
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

Давайте рассмотрим все эти правила, одно за другим:

  • Продолжить последовательность: если кто-то начал получать последовательность электронной почты, а он еще не получил последнюю электронную почту, он должен получить следующую электронную почту в последовательности
  • Начать последовательность: если кто-то еще не получил первое письмо из последовательности, он должен. Обратите внимание, что с технической точки зрения одно только это правило заставит всех, кто завершил последовательность, немедленно перезапустить ее. Это не происходит из-за того, что никогда не пересылают одно и то же правило электронной почты Однако вы можете решить переписать это правило, чтобы запретить кому-либо, кто уже получил определенную последовательность, быть вставленным в него.
  • Предотвращение перегрузки: если кто-то получил электронное письмо за последние три дня, мы должны заблокировать любое планирование электронной почты, направленное этому человеку
  • Блокировка в праздничные дни: если кто-то в праздничные дни, мы не должны отправлять им письма
  • Приоритет к чувствительным ко времени электронным письмам: учитывая пару расписаний электронной почты, адресованных одному и тому же человеку в одну и ту же дату, если только один из них чувствителен ко времени, мы должны заблокировать другой
  • Приоритет к электронным письмам с более высоким приоритетом: учитывая пару расписаний электронной почты, адресованных одному и тому же лицу в одну и ту же дату, которые чувствительны ко времени или обоим, но не чувствительны ко времени, мы должны заблокировать письмо с меньшей важностью
  • Ограничьте одно письмо в день: мы не должны планировать отправлять более одного письма в день одному и тому же человеку. Если это произойдет, мы должны выбрать один из них. Мы используем внутренний идентификатор, чтобы различать два
  • Никогда не отправляйте одно и то же письмо: если кто-то уже получил определенное письмо, он не должен получать его снова в будущем

Написание правил, характерных для электронных книг книги

Наши маркетологи могут написать конкретные правила для конкретных продуктов или тем. Давайте предположим, что они хотят создать набор электронных писем для продвижения и продажи книги. Мы могли бы написать эти правила в отдельном файле, возможно, поддерживаемом специалистом по маркетингу, отвечающим за продажу этой книги.

Чтобы написать правила, относящиеся к определенной теме, мы воспользуемся тегами, механизмом, который даст нам определенную гибкость. Давайте посмотрим правила, которые мы можем написать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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

Давайте рассмотрим наши правила:

  • Отправляйте предложение книги только после как минимум 3 электронных писем с презентацией книги: мы хотим заблокировать любое электронное письмо с продажей книги, если подписчик не получил по крайней мере три электронных письма с объяснением содержания книги
  • Блокировать предложения книг в понедельник: мы хотим заблокировать предложения книг, которые будут отправлены в понедельник, например, потому что мы увидели, что подписчики менее склонны покупать в этот день недели
  • Блок книги предлагает для людей, которые купили: мы не хотим предлагать сделку по книге для подписчиков, которые уже купили ее

Тестирование бизнес-правил

Существуют различные типы тестов, которые мы можем захотеть написать, чтобы убедиться, что наши правила ведут себя так, как ожидается. С одной стороны спектра нам могут потребоваться тесты, которые проверяют сложные сценарии и проверяют наличие неожиданных взаимодействий между правилами. Эти тесты будут выполняться с учетом сложных наборов данных и всего набора бизнес-правил. С другой стороны спектра мы можем захотеть написать простые модульные тесты для проверки отдельных правил. Мы увидим пример этих модульных тестов, но большая часть того, что мы увидим, может быть адаптирована для тестирования всего набора правил вместо отдельных правил.

Что мы хотим сделать в наших модульных тестах?

  1. Настраиваем базу знаний
  2. Мы хотим загрузить некоторые данные в сессию
  3. Мы хотим запустить бизнес-механизм правил, включающий только одно бизнес-правило, которое мы хотим протестировать
  4. Мы хотим убедиться, что итоговые расписания электронной почты ожидаемые

Чтобы выполнить пункт 1, мы загружаем все файлы, содержащие наши правила, и проверяем, нет ли проблем.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
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 . Такая функция может работать с данными, прежде чем мы загрузим их в сеанс. Это наш крючок для настройки данных в каждом тесте.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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, указав фильтр на правила, которые должны быть выполнены:

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

На данный момент мы можем просто проверить результаты.

Как только эта инфраструктура будет внедрена, тесты, которые мы напишем, будут выглядеть следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@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)
}

В первом тесте мы ожидаем, что Амели получит первое письмо из последовательности, поскольку она еще не получила. Во втором тесте вместо этого мы установили в сеансе, что Амели уже получила первое электронное письмо с последовательностью, поэтому мы ожидаем, что оно не получит его снова (никаких расписаний электронной почты вообще не ожидается).

Это весь код тестового класса:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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 позволил бы манипулировать моделью доменного имени, которую мы видели (подписчики, электронные письма и т. Д.), И выполнять два действия, в которых заинтересованы маркетологи: планирование и блокирование электронных писем. Мы могли бы предоставить простой редактор с автозаполнением и проверкой ошибок, а также интегрировать в него среду тестирования и моделирования. В этом сценарии маркетологи были бы полностью независимы и могли бы быстро разрабатывать и проверять свои правила с очень ограниченной необходимой поддержкой.

Подтверждения

Марио Фуско (чемпион Java) и Лука Молтени, оба работающие над Drools в RedHat, были очень любезны, чтобы прочитать статью и предложить значительные улучшения. Я чрезвычайно благодарен им.

Спасибо!

Посмотрите оригинальную статью здесь: Полное руководство по движку бизнес-правил Drools

Мнения, высказанные участниками Java Code Geeks, являются их собственными.