Привет! Меня зовут Дарья Андреева, я тимлид в команде бэкенда Биллинга Яндекс 360. Яндекс 360 объединяет такие сервисы, как Диск, Телемост, Почта и другие, а мы собираем их в цельный продукт и реализуем функции оплаты и подписочные модели.
В статье расскажу, как мы запускали промокоды для студентов, а заодно научились проектировать и писать стейт-машины, и поделюсь, в каких ситуациях такое решение точно не подойдёт.
Предыстория: что это был за проект и почему мы решили протестировать в нём стейт-машину
История началась с запроса бизнеса сделать промокоды на Яндекс 360 Премиум для студентов. Нужно было реализовать простой алгоритм: студент фотографируется со студенческим, прикрепляет фотографию к заявке на оформление тарифа и получает письмо с промокодом.
На базовом уровне проработки проект выглядел так: есть форма, в которую подгружается фотография, далее она проходит проверку на фрод в сервисе обработки изображений и сохраняется в таблицу. Мы как Биллинг обрабатываем данные в таблице и либо отправляем промокод, либо сообщаем, что что-то пошло не так и нужно повторно заполнить форму. Рассылкой занимается сервис отправки писем.
Реализовать такой проект в виде кода можно через стандартный алгоритм последовательных проверок — процесс, когда мы поэтапно проходим каждый шаг друг за другом.
Чтобы катить решение в прод, нужно обсудить его с командой. Я рассказала о варианте с последовательным алгоритмом на нашей архитектурной встрече — большом синке, где каждый может задавать вопросы и подсвечивать зоны риска идеи, а тебе нужно защитить решение. В первый раз защититься не получилось — команда обратила внимание на сложности, которые возникают в такой реализации:
-
Повторная обработка запросов. Студент должен в любом случае получить только один промокод. Как избежать ситуации, когда письмо приходит повторно?
-
Нет гарантии доставки сообщения. Во время обработки записи всегда есть риск временного отказа системы. Как обеспечить гарантированную доставку письма и никого не пропустить?
-
Проблемы с рассылятором. Отправляет письма сторонний сервис — рассылятор, который тоже может упасть. Как мы можем отследить, что какие-то промокоды не отправлены, и выслать их получателям?
-
Нет возможности переиспользовать алгоритм. Мы стремимся писать код, который можно использовать повторно в других задачах, — как реализовать этот запрос?
Команда не только обозначила слабые места, но ещё и указала на вариант решения. Сущность promo_code_request, статус которой менялся на разных этапах, иногда возвращалась к предыдущим значениям и заново проходила алгоритм. Это натолкнуло на мысль, что проект можно реализовать как стейт-машину, и я начала разбираться, возможно ли это в наших условиях.
Попытка на бумаге: как выглядел алгоритм со стейт-машиной
Если вы раньше не сталкивались со стейт-машинами, рассказываю — это объект, который в каждый момент времени находится в одном состоянии из некоторого количества возможных и может переходить между этими состояниями. На картинке понятно проиллюстрировано, что это за состояния и переходы:
После первой архитектурной встречи я углубилась в тему и попробовала представить наш алгоритм в виде стейт-машины. Для этого прописала три важные части реализации:
-
Состояния: начальные, промежуточные и терминальные
-
Ивенты: служебные или бизнес-события, которые происходят с системой и которыми можно управлять
-
Бизнес-логика: что будет происходить со стейт-машиной при смене состояний
Такое решение выглядело уже понятнее: не нужно было заново начинать цикл или решать, что делать, когда состояние поменялось.
На второй архитектурной встрече я показала новую схему. Она исключала все риски, которые мы с командой обсудили в прошлый раз:
-
Повторная обработка запросов. При корректном сохранении состояния стейт-машины можно легко восстановить её выполнение после падения.

-
Гарантия доставки сообщения. Есть возможность выполнить Push (служебный ивент), если стейт-машина зависла в нефинальном состоянии: например, так мы можем выяснить, какие письма не были отправлены, и разослать их.

-
Проблемы с рассылятором. При условии дедупликации, когда один запрос обрабатывается одной стейт-машиной, решается проблема с повторной обработкой запросов.

В этот раз я защитила решение. Осталось самое интересное — найти подходящий способ реализации.
С бумаги в жизнь: как мы искали способы реализации стейт-машины
Мы с командой сразу обсудили, как не хотим делать:
-
Использовать неструктурированный код на if, в котором проще запутаться, чем разобраться

-
Применять switch — могут возникнуть проблемы с тем, чтобы определить, в какое состояние перейдет система, и придется погружаться в бизнес-логик

-
Запутывать себя замаскированным switch — без комментариев)

Шутки шутками, но нам нужен был движок, который бы соответствовал всем требованиям проекта:
-
Удобство применения. Разработчик должен писать только бизнес-логику экшенов и задавать граф состояний и переходов, он не пишет boilerplate-код.
-
Лёгкость дебага. Код должен быть читаемым и понятным, нужно автоматическое логирование переходов.
-
Отказоустойчивость. При фейле системы стейт-машина может восстановиться из сохранённого контекста (консистентного состояния).
-
Дедупликация. Не могут выполняться две одинаковые стейт-машины.
-
Расширяемость. Нужна возможность быстро изменить флоу и выкатить апдейт, не поломав старые стейт-машины.
-
Переиспользуемость. С помощью движка написать новую стейт-машину достаточно просто.
-
Удобство покрытия тестами. Стейт-машину можно легко тестировать.
Какие варианты мы рассмотрели
Чтобы написать алгоритм со стейт-машиной, я изучила четыре возможных варианта реализации:
-
Библиотека на Python Pytransitions. Она позволяет визуализировать код и имеет развитый и удобный API, то есть разработчику действительно нужно задавать только бизнес-логику и состояния системы. При этом она не подошла под наш стек: мы реализуем проекты в основном на Java и Spring.

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

-
Библиотека Spring-statemachine. Библиотеку легко интегрировать в проект: у неё красивый API и много возможных конфигураций. А ещё она оказалась совместима с нашим стеком. Но поскольку сфер применения библиотеки много, документация огромная — её пришлось бы штудировать.
-
Собственный движок. Это решение помогает избежать лишних зависимостей и ненужного функционала, сделать всё под себя. При этом написать свой движок — дорогое удовольствие, а переиспользовать его в другом проекте не всегда возможно.
В итоге мы выбрали вариант с библиотекой на Spring. Нас не испугала огромная документация: возможность создать удобное решение, поработать с развитым API и переиспользовать алгоритм свели на нет этот нюанс.
Создали стейт-машину на Spring: как это выглядит и работает
Каким же получился наш алгоритм с промокодами для студентов на spring-state-machine:
-
Конфигурация задаёт переходы. Например, если письмо не отправится, то через Push и конфигурацию мы можем выслать его получателю. По сути, конфигурация — это перенесённый в код граф для реализации алгоритма.
Код для реализации перехода между состояниями
-
Экшены отвечают за то, что происходит в момент перехода из одного состояния в другое. Бизнес-логика принимает promo_code_request и student_promo_code_request в качестве контекста. В нём задаются такие параметры, как Ф. И. О. студента, его email, номер студенческого.
Код для использования данных promo_code_request и student_promo_code_request в алгоритме
Чтобы алгоритм заработал, нужна третья часть — простой сервис, который работает в три шага:
-
Ресторит стейт-машину из консистентного состояния.
-
Запускает алгоритм и отправляет нужный ивент. Стейт-машина через заданную конфигурацию проводит promo_code_request по состояниям и доводит до промежуточного или финального.
-
Сохраняет стейт-машину в базу.
Код получившегося сервиса:
@ALLArgsConstructor public class AbstractStateMachineService<S, E, ID> { private StateMachineFactory<S, E> stateMachineFactory; private StateMachinePersister<S, E, ID> persister; public void handleEvent(ID stateMachineId, E event) throws Expception { StateMachine<S, E> stateMachine = stateMachineFactory.getStateMachine(); persister.restore(stateMachine, stateMachineId); stateMachine.start(); stateMachine.sendEvent(event); persister.persist(stateMachine, stateMachineId); } }
Три части вместе образуют стейт-машину, а реализация позволяет избежать дедупликации: в один момент времени выполняется один запрос, есть лог на promo_code_request.
Как сервис работает в проде
Мы запустили проект, нагнали трафик и получили данные о том, как алгоритм работает в реальности.
Первое, что заметили: чтобы дебажить алгоритм, нужно совершить только одну выгрузку логов. Соответственно, срок дебага сокращается до времени этой выгрузки. Из выгрузки по id promo_code_request мы получаем информацию о переходах каждого экшена и контексте их выполнения.
Далее нам понадобилось внести небольшое изменение в алгоритм: добавить одно состояние generating_promo. Для этого нужно было только добавить сам переход, экшены и бизнес-логику (в нашем случае один гард), покрыть тестами и выкатить.

Когда мы выкатили алгоритм с новым состоянием в прод, все старые стейт-машины в рантайме использовали старый флоу, а новые работали по обновлённой схеме.
Каким получился проект со стейт-машиной у нас и почему это не универсальное решение
В итоге проект успешно работает! А мы оцениваем плюсы и минусы нашей реализации через библиотеку на Spring:
|
Преимущества |
Недостатки |
|
Структурированный код |
Продуктовые гэпы |
|
Удобство дебага |
Много ненужной функциональности |
|
Fail-safe система |
|
|
Переиспользуемый движок |
|
В этой статье я много говорю о том, что стейт-машина позволяет решать сложности в проектах, но на самом деле она не универсальна. Это достаточно специфичная модель, которую стоит использовать, если:
-
Ваша система имеет ярко выраженные состояния
-
Алгоритм совершает переходы между состояниями и может проходить через них повторно, то есть система имеет циклические зависимости
Если нужно придумывать дополнительные состояния или в алгоритме нет цикличности — лучше поискать другие решения.

Бывают задачи, в которых создание графа переходов и зависимостей для стейт-машины только усложняет процесс и делает его более запутанным. Например, есть сервис Доменатор, который выдаёт пользователю домены. Его алгоритм работы представлен на схеме. Если смотреть на левый рисунок, всё понятно: есть четыре задачи, переходы и две системы, с которыми интегрируется алгоритм.
Если посмотреть на правый рисунок — разве что-то понятно? Получается алгоритм с множеством придуманных состояний: стейт-машина в этом случае делает его более запутанным.
Чем же закончилась наша история: бизнес получил работающий проект, а у команды появилась экспертиза в работе со стейт-машинами.
Мысль в завершение: используйте стейт-машины, но тщательно обдумывайте, когда они действительно помогут решить задачу, а когда только усложнят процесс.
Расскажите, использовали ли вы стейт-машины и если да, то как именно? С какими сложностями сталкивались при этом?
ссылка на оригинал статьи https://habr.com/ru/articles/803185/
Добавить комментарий