Привет! Меня зовут Лев, и я инженер в новосибирской команде интеграционных сервисов ДомКлик. Мы разрабатываем (микро)сервисы, которые связывают между собой множество разрозненных систем, а также делают многие процессы быстрыми и прозрачными для конечного пользователя.
Мы используем ставший уже стандартным стек: 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). Они потом прекрасно отлаживаются. Ну и сам воркер для этих задач легко пишется и отлаживается.