Использование Drools для конфигурирования приложения

80e2e6a9c216f7ed42e79b7ded95f1ab

О чем статья

В данной статье хочу поделиться примером реального применения Drools для реализации требований бизнеса по гибкому конфигурированию сервиса. Здесь не будет полного обзора или пересказа всех фич Drools. Есть достаточно хорошо обновляемая полная официальная документация Drools, много литературы и активное большое комьюнити. Опишу лишь тот функционал, который оказался полезен и применим конкретно к моей задаче.

Описание сервиса и постановка задачи

Имеется сервис работы с задолженностями. На вход ему поступают операции, которые не прошли авторизации в банках по каким-либо причинам — долги. Операцией является структура содержащая множество различной информации необходимой для проведения авторизации в сервисах банка и другие данные. Операция содержит уникальный идентификатор — operationId. Все операции с одним operationId — являются попытками авторизации в банке для одной операции. Задолженности сохраняются во внутреннюю БД для дальнейшей работы с ними. Функционалом сервиса предусмотрено вычисление даты следующей попытки авторизации для каждого из приходящих долгов, если это возможно, и переотправка для повторной попытки авторизации в банке. Также предоставляется Rest API, с помощью которого можно получить списки долгов и пере отправить долг на повторную попытку авторизации. Это API например используются в frontend приложениях для того, чтобы предоставить пользователю возможность погашения долга вручную.

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

retryRule:
  VISA: 82800;259200;864000
  MC: 3600;82800;259200;864000
  MIR: 5400;77400;259200;864000;1209600

Операция содержит свойство paymentSystem и на основе значения этого свойства и номера попытки выбирался заданный интервал из конфига, через который должна быть осуществлена попытка повторной авторизации. При этом попытки ручной повторной авторизации (через Rest API) никак не ограничивались — т.е. любой вызов метода API погашения для конкретной операции инициировал отправку операции на новую попытку авторизации.

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

Выбор реализации

Какая-то статическая конфигурация схожая с тем, как конфигурировались интервалы в разрезе одного параметра — платежной системы, даже при условии, что удастся зафиксировать список параметров, в разрезе которых необходимо сконфигурировать, выглядела бы громоздко с учетом, что таких параметров может быть больше трех. В тоже время было понимания, что часть конфигурации можно задать для одной платежной системы, а другую часть нужно задавать для конкретных значений 3–4х параметров. Т.е. условно для VISA операций конфигурация одинакова, а для MIR — специфичные значения для различных наборов остальных параметров.

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

Какие варианты рассматривались:

  1. Конфигурация с помощью различных уже встроенных интерпретаторов, например spring expr или добавления других например kotlin. Попадались например библиотеки по типу jsonlogic.

  2. Использовать различные rule engines, например: Drools, Camunda Decision Engine, Open Rules.

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

retryRule:
  - name: VISA_rule
  	expr: 
  	intervals: 82800;259200;864000
  - name: VISA_rule
  	expr: 
  	intervals: 3600;82800;259200;864000
  - name: VISA_rule
  	expr: 
  	intervals: 5400;77400;259200;864000;1209600

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

Исходя из этих требований я остановился на 2 м варианте и конкретно на Drools, т.к. там как раз реализована возможность на java-подобном языке описать блок предиката и блок вычислений в случае применения правила к операции. Конкретно Drools я выбрал, т.к. проект мне показался стабильным, популярным и с живым активным сообществом.

Реализация требований с использованием Drools

Как и сказал в начале, не буду делать обзор всей функциональности, а опишу лишь использование Drools применительно к моему кейсу и особенности с которыми столкнулся.

Сервис написан использованием spring boot 2.7.x на kotlin 1.7.

Добавляем зависимости в build.gradle:

dependencies {
	….
api "org.drools:drools-core:8.44.0.Final"
api "org.drools:drools-ruleunits-engine:8.44.0.Final"
api "org.drools:drools-xml-support:8.44.0.Final"
api "org.drools:drools-mvel:8.44.0.Final"
….
}

 В Drools есть возможность использовать stateful и stateless сессии. Конкретно в моем проекте мне понадобились только stateless сессии, т.к. все операции у меня обрабатываются независимо друг от друга и применение правил к одной операции не зависит от того, как применяются правила к другим операциям.

object DrlSessionFactory {


   fun createStatelessSession(vararg drlContent: ByteArray): StatelessKieSession =
       KieHelper().apply {
           drlContent.forEach { drlBytes ->
               addResource(
                   KieServices.Factory.get().resources.newByteArrayResource(drlBytes),
                   ResourceType.DRL
               )
           }
       }.build().newStatelessKieSession()
}

DrlSessionFactory.createStatelessSession — утилитный метод, который принимает набор правил в виде ByteArray и создает stateless сессию. Данный класс используется для инициализации сессии-бина в spring context (конфигурирую директорию с правилами drl-файлами) и в тестах правил.

@Configuration
class DroolsConfig(@Value("\${rules.path}") val rulesDirectory: Resource) {
   @Bean
   fun drlSession(): StatelessKieSession =
       DrlSessionFactory.createStatelessSession(
           *rulesDirectory.file.toPath().listDirectoryEntries("*\\.drl")
               .map { Files.readAllBytes(it) }
               .toTypedArray()
               .also { array -> if (array.isEmpty()) throw RuntimeException("No drools rules were found") }
       ).also { it.addEventListener(DroolsLogListener()) }
}

Возвращаясь к постановке задаче. Мне необходимо рассчитывать по определенным правилам дату следующей отправки на авторизацию в банк для приходящих операций учитывая номер попытки для данной операции. Также необходимо рассчитывать возможна ли ручная авторизация для долга при запросе пользователя. Я разделил все правила на 2 группы:

  1. nextAuthDate.drl — набор правил для вычисления следующей даты автоматической попытки авторизации

  2. checkManualResubmission.drl — набор правил для вычисления возможна ли ручная попытка авторизации в данный момент, не превышен ли лимит на сегодня

Для работы с каждой из групп правил соответственно созданы 2 класса обертки CalcNextAuthDate и CheckManualResubmission. Объекты данных классов с заполненными входными параметрами передаются на вход сессии, rule engine ищет подходящее правило, если оно есть, правило срабатывает и записывает результат в объект, который можно увидеть в нашем приложении.

data class CalcNextAuthDate(
   val transactionOperation: TransactionOperation,
   val firstTimeAuthorization: LocalDateTime, 
   val calculateTime: LocalDateTime = LocalDateTime.now(),
   val attemptsToday: Int
) {
   // Результат вычислений - дата следующей попытки авторизации
   var nextAuthDate: LocalDateTime? = null
}


data class CheckManualResubmission(
   val transactionOperation: TransactionOperation,
   val firstTimeAuthorization: LocalDateTime,
   val calculateTime: LocalDateTime = LocalDateTime.now(),
   val attemptsToday: Int
) {
   // Результат вычислений - флаг о том, возможно ли выполнить авторизацию сейчас
   var isManualReAuthPossible: Boolean? = null


   fun setIsManualReAuthPossible(v: Boolean) {
       isManualReAuthPossible = v
   }
}

Соответственно данные классы содержат набор входных неизменяемых параметров:

  • transactionOperation  — попытка авторизации, операция-долг, операция имеет уникальный идентификатор operationId, transactionOperation с одним operationId являются попытками авторизации для одной операции

  • firstTimeAuthorization  — дата первой попытки, вычисляется с помощью внутренней БД по истории

  • calculateTime  — текущая дата, используется в правилах для вычислений, по умолчанию LocalDate.now (), необходима для тестирования правил

  • attemptsToday — количество попыток авторизаций за текущие сутки, вычисляется с использованием внутренней БД модуля

И перезаписываемые свойства-результат:

  • nextAuthDate — вычисленное значение даты следующей попытки, если возможно

  • isManualReAuthPossible — признак того, возможно отправить на авторизацию в данный момент, не исчерпаны ли все попытки вообще или на текущие сутки

Вместо параметров firstTimeAuthorization и attemptsToday можно было бы передавать на вход правилам всю историю попыток авторизации операции из внутренней БД и уже внутри правил вычислять эти параметры, но пока было принято решение не усложнять правила такими вычислениями и реализовать эту общую логику на стороне приложения. Из-за особенностей цепочки kotlin-java-drools пришлось явно создать метод setIsManualReAuthPossible, чтобы правило смогло записать результат выполнения.

Рассмотрим реализацию самих правил. Я использую mvel dialect. В Drools есть другие опции по оформлению правил. В моем случае правила написаны на java-подобном языке, но хочу обратить внимание, что это все-таки не java.

package my.module.rules

dialect "mvel"

import my.drools.model.CalcNextAuthDate
import java.time.LocalDateTime
import java.util.Arrays
import java.util.List
import java.lang.Integer
import java.time.LocalTime
import java.time.Duration
import java.util.ArrayList
import java.util.stream.IntStream
import java.util.function.IntFunction
import java.util.stream.Collectors
import static my.drools.model.TransactionOperationUtils.isOnUs
import java.util.Collections
import java.time.LocalDate

function void calcDate(Integer maxAuthInDay,List rules,CalcNextAuthDate operation) {
   for(Object el : rules){
     LocalDateTime ruleDateTime = operation.getFirstTimeAuthorization().plus((Duration) el);
     if(ruleDateTime.isAfter(operation.getCalculateTime()) &&
       (!ruleDateTime.toLocalDate().isEqual(operation.getCalculateTime().toLocalDate()) || maxAuthInDay == null || operation.getAttemptsToday() < maxAuthInDay)){
         operation.setNextAuthDate(ruleDateTime);
         break;
     }
   }
}

rule "next auth date for onUs merchants 981000137625,981000127420"
salience 20 // так текущее правила и последующие правила не взаимоисключающие, то ставим этому правил больший приоритет
activation-group "calcNextAuthDate"
   when
     $operation: CalcNextAuthDate(
       transactionOperation.terminalData.terminalMerchantId in ("981000137625" , "981000127420") &&
       transactionOperation.paymentData.issuerId in ("8") && // сбербанк
       isOnUs(transactionOperation)
     )
   then
     List rules = new ArrayList();
     for(int i = 0; i <= 28; ++i ){
       rules.add(Duration.ofHours(1).plus(Duration.ofDays(i)));
     }


     calcDate(
       1,
       rules,
       $operation
      );
   end


rule "next auth date for VISA"
salience 10 // правило для всех карт виза, которые не попали в выборку правил с большим приоритетом
activation-group "calcNextAuthDate"
   when
       $operation : CalcNextAuthDate(transactionOperation.paymentData.paymentSystem == "VISA")
   then
       calcDate(
           null, /* допустимое количество доавторизаций в день - неограничено */
           /* Интервалы автоматических доавторизаций отсчитывается от отложенной авторизации*/
           Arrays.asList(
             Duration.ofHours(23), /* 23 часа */
             Duration.ofDays(3).plus(Duration.ofHours(23)), /* 3 дня 23 часа */
             Duration.ofDays(13).plus(Duration.ofHours(23)) /* 13 дней 23 часа */
           ),
           $operation
       );
   end
  • rule <название правила> — задаем уникальное название правила, при инициализации сессии будет ошибка, если найдутся правила с одинаковыми названиями

  • activation-group <название группы> — с помощью данного блока правила группируются, правила с одинаковым activation-group принадлежат одной группе. По умолчанию активируется одно правило из группы. В примере правила не взаимоисключают друг друга.

  • salience <приоритет> — важно управлять приоритезацией правил, если в группе присутствуют не взаимоисключающие правила, т.к. при одинаковом приоритете таких правил сработает то, которое определит Drools согласно своим внутренним алгоритмам оптимизации.

  • when <условия> — условие применения правила к объекту. В приведенном примере правил условия в группе правил для определения даты следующей попытка написаны относительно операции класса CalcNextAuthDate.

  • then <код активации правила> — код, выполняемый в случае активации правила. В приведенном примере в блоке then выполняется вычисление и проставления значения в результирующее поле соответствующего объекта класса: для CalcNextAuthDate это nextAuthDate.

Как видно в приведенном примере 1е правило по порядку правило не взаимоисключает правило 2е, но у 1 го больший приоритет (salience 20), поэтому соответствие условию правила этого для объекта будет проверяться в первую очередь. Если не проставлять salience этих 2х правил, то у них будет равный приоритет и нет возможности определить какое из правил сработает в runtime. Условия представленных правил конечно можно всегда написать так, чтобы они были взаимоисключающие, но это не всегда удобно.

В блоках when и then возможно использовать классы и их методы из classpath. Иногда также может пригодиться возможность реализации и использования функций внутри правил. Это может пригодиться для избегания дублирования кода как в случае с функцией calcDate в приведенном примере, которая используется для определения даты следующей попытки в обоих приведенных правилах. 

Также стоит упомянуть про особенности интерпретатора Drools. Например я столкнулся с тем, что в блоке then нельзя написать выражения  $operation.setIsManualReauthPossible($operation.getAttemptsToday() > 1) — при инициализации сессии будет ошибка компиляции. Как я понял интерпретатор Drools не понимает выражения, когда над $operation выполняется операция с использованием того же объекта $operation. Для выхода из положения можно реализовать функцию:

function boolean setIsPossible(CheckManualResubmission operation) {
   return operation.setIsManualReauthPossible(operation.getAttemptsToday() > 1);
}

И уже эту функцию вызвать в блоке then:

when 
  $operation : CalcNextAuthDate(transactionOperation.paymentData.paymentSystem == "VISA")
then
  setIsPossible($operation)
end

Реализовывать ли методы внутри правил или имплементировать их внутри сервиса, а затем импортировать в правила, — выбор разработчика. Как мне кажется плюсом реализации как можно большего функционала внутри правил является то, что правила менее связаны с кодом приложения, которое их использует. Но из-за особенностей синтаксиса и интерпретатора Drools не всегда легко можно все сделать внутри правил. Drools имеет свой интерпретатор в байт-код и нельзя воспользоваться например функционалом Stream API. Или кому-то может понадобиться обращение к внешнему источнику данных внутри правил, например к БД. Так что все зависит от конкретной ситуации и предпочтений. Я старался реализовывать функции внутри правил для меньшей связанности с кодом, но при этом часть кода по анализу истории долга все-таки реализовал на стороне сервиса.

Разработка и тестирования правил

В качестве среды разработки правил я использовал Intellij IDEA с плагином для Drools. Но плагин работает не идеально. Иногда подсвечивает некоторые участки кода, как ошибочные — при этом правила компилируются и работают, а иногда наоборот не подсвечивает участки кода — на которые ругается интерпретатор при инициализации сессии.

Есть еще web приложение Drools Workbench, которое судя по описанию можно использовать для разработки, отладки и тестирования бизнес правил. Я запускал локально docker образ с wildfly и , кажется, что для нормальной работы ему требуется большое количество ресурсов, — приложение работало, но с очень большими тормозами. Так что разработку, отладку и тестирование правил сделал пока с помощью стандартных unit-тестов. В целом нетрудно сделать свое специализированное утилитное приложение с более простой логикой по сравнению с Drools Workbench, которое позволит отлаживать правила и прогонять через тесты.

Производительность

С точки зрения производительности Drools Engine оптимизирован для быстрого поиска подходящего правила.  Подходящее правило ищется не полным перебором всех правил условий по порядку. С помощью алгоритма Rete строится сеть узлов, где каждый узел представляет собой часть условия из правил. Засчёт этого общие условия для нескольких правил проверяются только один раз. В моем случае правил не так много, но приятно что в Drools Engine имеется такая оптимизация.

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

Заключение

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

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

© Habrahabr.ru