Привет! Меня зовут Павел, я программист-эксперт в отделе разработки серверных решений ЮMoney. Сегодня расскажу и покажу, как менялись наши конечные автоматы в бэкенде — как от большого страшного монстра с файлами классов по 1000+ строк мы пришли к красивым визуальным диаграммам, которые понимают не только разработчики, но и сотрудники других отделов.
Предыстория
В бэкенде мы занимаемся обработкой достаточно сложных операций, которые часто разбиваются на отдельные шаги. Например, собрать нужные данные о пользователе или получателе, проверить баланс и требования комплаенс, выполнить перевод, отправить уведомления о переводе — пользователю, сервису и так далее. И порядок этих шагов не всегда линейный.
С 2016 года мы стали использовать для описания этих шагов конечный автомат. В нём описываем состояния и переходы между ними.
Как выглядел конечный автомат раньше
Разработчик писал код и прямо в нём указывал, в какое состояние переходить дальше. Это можно было назвать конечным автоматом, но его поддержки в коде было минимум, только готовые интерфейсы для установки текущего состояния и чтения/сохранения текущего контекста операции.
Это привело к тому, что файлы классов с описанием процессов превратились в огромных страшных монстров, глядя на которых, ты понимаешь, что разобраться с ними просто невозможно. Процесс какого-нибудь платежа стал занимать класс примерно в 2000 строк. ?
Также было неудобно разрабатывать решения конфликтов при мерже и тестировании, а инструменты оценки качества кода постоянно конфликтовали друг с другом. Зато мы решили свою первую проблему — собрали в одном месте управление всем процессом. Конечно, это не значит, что сам процесс был сделан в одном месте.
С микросервисной архитектурой при управлении процессом есть много вызовов в разные компоненты системы. Если на каком-то шаге мы получаем отказ, мы знаем, что будет происходить дальше. В зависимости от типа ошибки и состояния процесса дальнейшие действия по нему могут быть повтором шага и его завершением. И, что ещё важнее, если ошибка происходит ближе к концу процесса в целом, мы делаем шаги для отмены некоторых действий, которые были выполнены в начале процесса. Этого можно достичь как явным описанием отдельного шага, так и запуском отдельного процесса, который в несколько этапов решает такую задачу (отменяет уже выполненные шаги из неудавшегося процесса).
Мы стали с этим жить
Несмотря на сложности, вызываемые такой структурой огромных классов-процессов, нам понравилась предсказуемая и легко читаемая по логам работа таких процессов. И мы начали пробовать делать так ещё в одном компоненте, потом ещё в одном. Стало понятно, что для этого нам нужна библиотека.
Поначалу мы просто поддерживали в ней то, что уже было сделано, оставив всё тот же императивный подход к управлению процессом: код обработки состояния сам говорит, в какое состояние дальше перейти.
Процессы становились сложнее
Мы не сразу заметили, что наши процессы обычно почти линейные — шаги выполняются один за другим. И структура кода в этих огромных классах повторяла такие шаги. Но в жизни обычно всё немного сложнее, и вот мы начали делать процессы, в которых переходы стали нелинейными. По сути, наши конечные автоматы начали развиваться в более сложные структуры.
И здесь императивный подход стал нам сильно мешать: каждый раз нужно было просматривать весь этот огромный код, чтобы понять, из какого состояния можно попасть в другое и как.
Пример такого кода. По коду стали множиться вставки на проверку условий и выбор следующего шага:
if (context.getProcessCtx().getLoyaltyProgramEnabled()) {
walletProcessContextModifier.updateProcessStage(context, NOTIFY_LOYALTY_GATE_ORDER_CLEARED, UpdateContext.YES);
} else if (context.getProcessCtx().getCreateRecurringPermissionBeforeAviso()) {
walletProcessContextModifier.updateProcessStage(context, CREATE_BINDING_WALLET, UpdateContext.YES);
} else {
walletProcessContextModifier.updateProcessStage(context, MAKE_SHOP_AVISO, UpdateContext.YES);
Это простой пример, многие процессы гораздо сложнее.
Перед нами встала задача придумать способ, как описывать наши процессы иначе.
Декларативное описание процесса
Конечный автомат называется так, потому что представляет собой некоторое конечное множество состояний, между которыми описаны переходы. Нам было важно, чтобы это хорошо ложилось на код, который мы пишем на Java:
1. Состояния описаны как enum, у каждого элемента есть атрибуты, которые помогут это состояние сохранить и восстановить.
2. Выполняемые действия в каждом состоянии описаны как реализация функционального интерфейса, где на вход приходит текущий контекст процесса и возвращается результат, который позволяет автомату понять, в какое следующее состояние перейти. Этот результат никак не связан с самими состояниями. Если возможен переход только в одно состояние, то возвращается просто успех. Если же возможен переход в несколько других состояний, то результат представляет собой локальный enum этого действия, в котором перечислены возможные варианты его результата.
3. Сам конечный автомат собирается билдером, которому передаётся enum состояний. Далее для каждого указывается выполняемое действие и состояние, в которое надо переходить в случае конкретного результата этого действия. В конце сборки происходит валидация получившегося автомата: для каждого состояния описаны действия, указаны начальные и конечные состояния, для каждого результата каждого действия выбрано следующее состояние.
Собственно, вот пример кода:
stateMachine = StateMachine.builder(AutorenewalProcessStage.class, autorenewalProcessContextModifier)
.noAction(INITIAL, CHECK_REQUIREMENTS)
.stage(CHECK_REQUIREMENTS, checkAutorenewalRequirementsAction)
.nextStage(CREATE_CONTRACT, createPaymentContractAction)
.nextStage(PROCESS_PAYMENT, processPaymentAction)
.nextStage(UPDATE_NEXTBILLING, updateNextbillingAction)
.nextStage(SUCCESS_FINAL)
.stage(EXPIRED, nonSuccessAction).nextStage(EXPIRED_FINAL)
.stage(FAILED, nonSuccessAction).nextStage(FAILED_FINAL)
.finalStage(SUCCESS_FINAL)
.finalStage(EXPIRED_FINAL)
.finalStage(FAILED_FINAL)
.onUnrecoverableError(FAILED)
.build();
Здесь AutorenewalProcessStage — enum состояний, дальше — указание необходимого действия в каждом из состояний. Это пример почти линейного процесса.
Если у нас есть переходы из одного состояния в несколько других, то описание требует указания состояния для каждого результата, возвращаемого действием:
stateMachine = StateMachine.builder(ActivateProcessStage.class, activateProcessContextModifier)
.stage(INITIAL, activateInitialAction)
.when(ActivateInitialAction.InitResult.CONFIRM_NEEDED)
.thenNextStage(WAIT_CONFIRM)
.when(ActivateInitialAction.InitResult.SKIP_CONFIRM)
.thenNextStage(CHECK_REQUIREMENTS)
.endConditions()
.stage(WAIT_CONFIRM, waitConfirmAction)
Сами действия реализуют функциональный интерфейс вида:
/**
* Действие выполнения стадии
*
* @param <ProcessT> тип процесса
* @param <StageResultT> тип результата работы стадии.
* От нее зависит дальнейшая стадия выполнения процесса.
*/
public interface StageAction<ProcessT extends Process,
StageResultT extends Enum<StageResultT>> {
/**
* Выполняет стадию
*
* @param process процесс (контекст процесса)
* @return результат работы стадии
*/
StageActionResult<StageResultT> execute(ProcessT process);
}
То есть реализация действия связана только типом процесса, так как он определяет содержимое контекста этого процесса, данные, которыми манипулирует это действие.
Созданные процессы продолжают меняться, в них добавляют новые шаги, а какие-то перестают использовать (новым процессам в них уже не попасть).
Польза от процессов за пределами кода
Декларативное описание процессов ещё сильнее упорядочило подход к решению задач с помощью процессов, описанных как конечный автомат. Если до этого мы делали код, в котором этот автомат терялся, теперь у нас получилось довольно строгое описание конечного автомата, а сам код стал разбит на небольшие классы, описывающие конкретные действия. Плюс мы получили билдер автомата с валидацией.
Дальше вспоминаем, что конечный автомат — это граф с вершинами (состояния) и рёбрами (переходы между этими состояниями). Появляется желание изобразить результат в графическом виде — тогда разговор о процессах можно будет вести в форме, понятной не только бэкенд-разработчику, но и другим коллегам.
Добавляем в код каждого компонента с процессами небольшой тест, который выполняется при релизе, собирает список всех объявленных в компоненте процессов и строит для них граф.
Получаем файлы с описанием процесса на языке DOT. Эти файлы при релизе комитятся в репозиторий с компонентом. В итоге мы имеем каталог всех процессов, где при выборе нужного компонента видим все его процессы и их схемы.
При этом у нас формируется перманентная ссылка на конкретную диаграмму, которой можно делиться в компании во время обсуждений. Переходы между состояниями тоже кликабельны и ведут сразу в исходный код действия в репозитории кода, вызывающего этот переход. Один клик — и видно, что делается в коде при переходе.
Говорят, в отделе аналитики подпрыгивали вместе со стульями, узнав о возможности обозревать то, во что превращается в коде их техническое решение. ?
Сейчас у нас есть каталог всех компонентов со схемами их процессов, который формируется автоматически. Это более 25-ти приложений, где есть процессы на основе state-machine. Итого 200+ схем процессов в каталоге.
С удовольствием отвечу на ваши вопросы — задавайте их в комментариях. ? Кстати, прямо сейчас мы ищем в нашу дружную команду Java-разработчика, знакомьтесь с требованиями и откликайтесь на вакансию.
Комментарии (11)
sturex
25.07.2024 13:08Отличный подход!
А ещё в момент перехода из одного состояния в другое можно собирать различные описатели (категориальные и числовые) и потом, после создания ML-модели, использовать их для прогнозирования вероятности перехода. Использование перечислимого множества имён (enum) для переходов, фичей, дескрипторов, с зашитым внутрь enum-константы поведением, очень сильно снижает вероятность ошибок, позволяет декларативно описать всё в одном-единственном месте. Длинно иногда получается, но не сильно страшно) Делал и использую подобный подход, кому интересно, смотрите тут (github, Java)
dpbm Автор
25.07.2024 13:08+1Необычная у вас задача. Посмотрели ваш код, больше похоже на функциональный подход. Если бы там можно было увидеть примеры использования то, возможно, мы бы даже взяли себе какие-то идеи.
Bender_Rodrigez
25.07.2024 13:08И порядок этих шагов не всегда линейный.
Мы не сразу заметили, что наши процессы обычно почти линейные — шаги выполняются один за другим. И структура кода в этих огромных классах повторяла такие шаги. Но в жизни обычно всё немного сложнее, и вот мы начали делать процессы, в которых переходы стали нелинейными. По сути, наши конечные автоматы начали развиваться в более сложные структуры.
Скажите, как у ваших автоматов обстоят дела с параллельностью?
Хотелось бы увидеть продолжение статьи, отражающее данный аспект - какие сложности встречались, как решались, какие фишки были реализованы.
dpbm Автор
25.07.2024 13:08Каждый процесс выполняется в отдельном потоке, перед началом выполнения захватывается глобальный лок, чтобы процесс не начал выполняться в каком-то другом экземпляре приложения. Все данные процесса локальны для выполняемого потока, поэтому проблем с многопоточностью при обработке множества разных процессов нет.
karrakoliko
Покажите как будет выглядеть в коде обработка 3х состояний, например, processPayment (успех, отказ, обнаружен фрод).
Ветки обработок каждого этого состояния как будут выглядеть в коде?
dpbm Автор
Это не состояния, это результат некоторого вызова. В какие состояния он приведёт, зависит от того, что мы захотим. Пойдёт в один шаг, в другой или третий.
karrakoliko
покажите псевдокод, в котором успешная оплата переводит статус оплаты заказа в "оплачено", а в случае если от банка приходит отказ, то статус оплаты заказа не меняется, а выводится ошибка.
судя по той одной большой цепочке вызовов, что я увидел в статье, это будет проблемой.
или нет?
dpbm Автор
Перейти в нужное состояние после получения некоторого результата на конкретном шаге - не проблема. Для этого каждый шаг заранее объявляет возможные типы результата выполнения шага, на основании значения которого выбирается следующий шаг.
Если говорить о том, что при этом показывается пользователю, то у нас для процесса есть метод, который транслирует текущее содержимое контекста процесса (вместе с текущим шагом) в ответ, который видит пользователь. Пока мы находимся на некотором промежуточном шаге - мы возвращаем Операция в обработке, когда же мы оказываемся на одном из финальных шагов - то начинает глубже анализировать контекст процесса и сообщаем об успехе или причине ошибки (причина не является шагом, хранится отдельно в контексте, т.к. от нее не зависит топология процесса).