Привет! Меня зовут Лев, и я инженер в новосибирской команде интеграционных сервисов ДомКлик. Мы разрабатываем (микро)сервисы, которые связывают между собой множество разрозненных систем, а также делают многие процессы быстрыми и прозрачными для конечного пользователя.
Мы используем ставший уже стандартным стек: Kotlin, Spring Boot, Hibernate, Liquibase и т. д. И нам для наших сервисов (на тот момент пока ещё одного) потребовался механизм исполнения бизнес-процесса. Требования к нему были следующие:
каждое действие как отдельный независимый модуль;
stateless-движок -> stateful-задача;
перезапуск некорректно отработавших задач заранее заданное количество раз;
возможность горизонтального масштабирования;
механизм должен быть асинхронным;
разработать его нужно максимально быстро и просто.
Custom Business Process Engine
Мы немного подсмотрели структуру сервиса у наших коллег, что-то добавили от себя, и получился у нас простейший движок следующего вида. Четыре базовых SpringService: JobProcessor
, JobRunner
, JobService
и LaunchService
, а также базовая сущность задачи — JobEntity
. Разберём их подробнее. Самое первое и главное — сущность задачи (Job), вокруг которой построен весь механизм. Она имеет следующие поля:
id
— идентификатор задачи;status
— статус исполнения задачи (PENDING
,READY
,INPROGRESS
,COMPLETED
,ERROR
);runCount
— текущее количество попыток исполнения текущей задачи;delayedTime
— время, спустя которое можно повторить исполнение задачи (возрастает для каждой следующей попытки);archived
— признак нахождения задачи в архиве;request
— тут хранится сериализованный (JSON) запрос на исполнение задачи.
Поскольку наш сервис должен был быть асинхронным, да ещё и разрабатывался максимально срочно, мы использовали PostgreSQL в качестве персистентной очереди запросов. Запрос порождает в базе задачу.
Исполнение вышеописанной задачи отдаётся следующим спринг-сервисам:
JobProcessor
имеет лишь один методprocess(jobs: List<Long>)
, принимающий на вход список идентификаторов задач и запускающий их на исполнение методомrun
сервисаjobRunner
.JobRunner
тоже имеет лишь один методrun
, вызываемый изJobProcessor
. Он получает изjobService
список задач по идентификаторам, проверяет, не превышено ли максимальное количество попыток вызова, и запускает задачу вlaunchService
.LaunchService
имеет один методlaunch
. Именно в нём и выполняются все бизнес-операции. При успешном исполнении статус задачи переводится вCOMPLETED
, иначе, в зависимости от значенияrunCount
, возвращается вREADY
или завершается со статусомERROR
.JobService
— основной сервис для работы с сущностью задачи. Он может класть в БД новую задачу, выбирать из БД задачу по идентификатору и по статусу, менять статус.
Custom Business Process Engine (+ Phases)
Поскольку количество действий в бизнес-задачах начало расти, а при падении по каким-либо причинам сервис заново повторял весь процесс для задачи (особенно если на каком-то этапе было взаимодействие со stateful-сервисом), мы решили разделять бизнес-процесс на логические модули, назвав их фазами (Phases). Таким образом, сущность задачи получила новое поле phase
и метод next()
, возвращающий нам следующую фазу исполнения. Кроме того, JobService стал немного умнее и выбирает выполняемую над задачей операцию в зависимости от её текущей фазы. Теперь мы могли перезапускать процесс в случае ошибки не с самого начала, а лишь с фазы, на которой произошла ошибка. К тому же при горизонтальном масштабировании разные фазы задачи могут выполняться разными экземплярами сервиса, поэтому в случае падения одного или нескольких экземпляров сервиса задача будет подхвачена оставшимися.
Теперь процесс исполнения запроса выглядел так. Контроллер сервиса вызывается по REST API и формирует в базе данных в таблице с задачами новую запись. Исполнением задач занимается SpringService JobRunner
:
Берёт задачу в статусе
READY
.Переводит её в статус
IN_PROGRESS
и записывает.Смотрит на фазу, и в зависимости от её значения выполняет какое-то действие. Для передачи данных и сохранения результата используется поле
context
, сериализованное в JSON.Если финальная фаза, то сервис переводит задачу в статус
COMPLETE
, сохраняет в базу и отправляет сообщение об успехе сервису-инициатору. Если фаза не финальная, то сервис переводит задачу в следующую фазу со статусомREADY
.
В случае ошибки:
Если значение
retries
достиглоmaxRetries
, то отвечаем сервису-инициатору ошибкой и возврашаем в базу задачу со статусомERROR
.Если значение
retries
меньшеmaxRetries
, то делаем retries++ и возвращаем в базу со статусомREADY
.
Custom Business Process Engine (+ Phases) (+ Activiti)
Наши сервисы разрастались, переходы между фазами переставали быть линейными, сценарии работы усложнялись, а код launchService
и JobEntity.next()
начал становиться нечитаемым и трудноподдерживаемым. С этим надо было что-то делать, и мы сделали! Ранее у нас был опыт работы с Activity, так что мы решили использовать этот BPMN-движок вместо последовательности фаз.
BPMN (Business Process Model and Notation) — нотация для описания бизнес-процессов, позволяющая представлять их визуально.
В интернете существует множество инструкций, как начать работать с Activity, так что здесь я это описывать не буду. Расскажу лишь про опыт использования. Главным достоинством стала возможность визуально представить процесс выполнения нашего бизнес-конвейера. Логика формирования данных в зависимости от условий теперь была более ясная, и это сократило количество ошибок при разработке. Например, нам требуется формировать разные пакеты документов для разных типов клиентов по сложному набору критериев. И когда таких критериев и типов клиентов становится слишком много, такие ветвления в коде уже перестают восприниматься визуально, а ошибки плодятся в разы быстрее. И тут нас выручает наглядная графическая схема процесса. Теперь LaunchService
лишь выбирал и запускал нужный нам сценарий, а вся логика переходов была на картинке.
Это было лишь переходным этапом, и приведя сервис к такой конструкции, мы начали понемногу выпиливать наш кастомный движок. Оставили все бизнес-процессы на Activity, чьи сценарии запускались уже из контроллера. В дальнейшем их обработку полностью отдали Activity.
Однако это решение привнесло с собой и некоторые недостатки. Главными из них стали:
Сложность отладки кода блоков. В Activity используется groovy-script, а сами схемы описываются огромными XML со встроенным groovy-script. Так мы получили трудноотлаживаемую часть проекта, разбросанную по *.bpmn xml-файлам.
Наличие в проекте дополнительного языка (Groovy) усложнило поддержку проекта.
Нет никакой валидации groovy-script в задаче Activity, а значит можно легко опечататься в названии метода или сигнатуре. При сборке не получится проверить валидность лишь модульными тестами.
Проект не выглядит живым, на заведенную нами уже более года назад багу (https://github.com/Activiti/Activiti/issues/2911) ответ не получен до сих пор.
Конкуренты (Camunda, Flowable) предоставляют более удобную и широкую функциональность и поддержку (а вот об этом в другой раз).
ultrinfaern
Я думаю рисовать сложные скрипты внутри процесса — это антипаттерн. Такое лучше выносить в виде внешних задач (external task). Они потом прекрасно отлаживаются. Ну и сам воркер для этих задач легко пишется и отлаживается.