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

Мы используем ставший уже стандартным стек: 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 в качестве персистентной очереди запросов. Запрос порождает в базе задачу.

Исполнение вышеописанной задачи отдаётся следующим спринг-сервисам:

  1. JobProcessor имеет лишь один метод process(jobs: List<Long>), принимающий на вход список идентификаторов задач и запускающий их на исполнение методом run сервиса jobRunner.

  2. JobRunner тоже имеет лишь один метод run, вызываемый из JobProcessor. Он получает из jobService список задач по идентификаторам, проверяет, не превышено ли максимальное количество попыток вызова, и запускает задачу в launchService.

  3. LaunchService имеет один метод launch. Именно в нём и выполняются все бизнес-операции. При успешном исполнении статус задачи переводится в COMPLETED, иначе, в зависимости от значения runCount, возвращается в READY или завершается со статусом ERROR.

  4. JobService — основной сервис для работы с сущностью задачи. Он может класть в БД новую задачу, выбирать из БД задачу по идентификатору и по статусу, менять статус.

Custom Business Process Engine (+ Phases)

Поскольку количество действий в бизнес-задачах начало расти, а при падении по каким-либо причинам сервис заново повторял весь процесс для задачи (особенно если на каком-то этапе было взаимодействие со stateful-сервисом), мы решили разделять бизнес-процесс на логические модули, назвав их фазами (Phases). Таким образом, сущность задачи получила новое поле phase и метод next(), возвращающий нам следующую фазу исполнения. Кроме того, JobService стал немного умнее и выбирает выполняемую над задачей операцию в зависимости от её текущей фазы. Теперь мы могли перезапускать процесс в случае ошибки не с самого начала, а лишь с фазы, на которой произошла ошибка. К тому же при горизонтальном масштабировании разные фазы задачи могут выполняться разными экземплярами сервиса, поэтому в случае падения одного или нескольких экземпляров сервиса задача будет подхвачена оставшимися.

Теперь процесс исполнения запроса выглядел так. Контроллер сервиса вызывается по REST API и формирует в базе данных в таблице с задачами новую запись. Исполнением задач занимается SpringService JobRunner:

  1. Берёт задачу в статусе READY.

  2. Переводит её в статус IN_PROGRESS и записывает.

  3. Смотрит на фазу, и в зависимости от её значения выполняет какое-то действие. Для передачи данных и сохранения результата используется поле context, сериализованное в JSON.

  4. Если финальная фаза, то сервис переводит задачу в статус COMPLETE, сохраняет в базу и отправляет сообщение об успехе сервису-инициатору. Если фаза не финальная, то сервис переводит задачу в следующую фазу со статусом READY.

В случае ошибки:

  • Если значение retries достигло maxRetries, то отвечаем сервису-инициатору ошибкой и возврашаем в базу задачу со статусом ERROR.

  • Если значение retries меньше maxRetries, то делаем retries++ и возвращаем в базу со статусом READY.

Custom Business Process Engine (+ Phases) (+ Activiti)

Наши сервисы разрастались, переходы между фазами переставали быть линейными, сценарии работы усложнялись, а код launchService и JobEntity.next() начал становиться нечитаемым и трудноподдерживаемым. С этим надо было что-то делать, и мы сделали! Ранее у нас был опыт работы с Activiti, так что мы решили использовать этот BPMN-движок вместо последовательности фаз.

BPMN (Business Process Model and Notation) — нотация для описания бизнес-процессов, позволяющая представлять их визуально.

В интернете существует множество инструкций, как начать работать с Activiti, так что здесь я это описывать не буду. Расскажу лишь про опыт использования. Главным достоинством стала возможность визуально представить процесс выполнения нашего бизнес-конвейера. Логика формирования данных в зависимости от условий теперь была более ясная, и это сократило количество ошибок при разработке. Например, нам требуется формировать разные пакеты документов для разных типов клиентов по сложному набору критериев. И когда таких критериев и типов клиентов становится слишком много, такие ветвления в коде уже перестают восприниматься визуально, а ошибки плодятся в разы быстрее. И тут нас выручает наглядная графическая схема процесса. Теперь LaunchService лишь выбирал и запускал нужный нам сценарий, а вся логика переходов была на картинке.

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

Однако это решение привнесло с собой и некоторые недостатки. Главными из них стали:

  1. Сложность отладки кода блоков. В Activiti используется groovy-script, а сами схемы описываются огромными XML со встроенным groovy-script. Так мы получили трудноотлаживаемую часть проекта, разбросанную по *.bpmn xml-файлам.

  2. Наличие в проекте дополнительного языка (Groovy) усложнило поддержку проекта.

  3. Нет никакой валидации groovy-script в задаче Activiti, а значит можно легко опечататься в названии метода или сигнатуре. При сборке не получится проверить валидность лишь модульными тестами.

  4. Проект не выглядит живым, на заведенную нами уже более года назад багу (https://github.com/Activiti/Activiti/issues/2911) ответ не получен до сих пор.

  5. Конкуренты (Camunda, Flowable) предоставляют более удобную и широкую функциональность и поддержку (а вот об этом в другой раз).