Стейт-машины: The Good, The Bad and The Ugly
Привет! Меня зовут Дарья Андреева, я тимлид в команде бэкенда Биллинга Яндекс 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 по состояниям и доводит до промежуточного или финального.
Сохраняет стейт-машину в базу.
Код получившегося сервиса:
@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 система | |
Переиспользуемый движок |
В этой статье я много говорю о том, что стейт-машина позволяет решать сложности в проектах, но на самом деле она не универсальна. Это достаточно специфичная модель, которую стоит использовать, если:
Ваша система имеет ярко выраженные состояния
Алгоритм совершает переходы между состояниями и может проходить через них повторно, то есть система имеет циклические зависимости
Если нужно придумывать дополнительные состояния или в алгоритме нет цикличности — лучше поискать другие решения.
Бывают задачи, в которых создание графа переходов и зависимостей для стейт-машины только усложняет процесс и делает его более запутанным. Например, есть сервис Доменатор, который выдаёт пользователю домены. Его алгоритм работы представлен на схеме. Если смотреть на левый рисунок, всё понятно: есть четыре задачи, переходы и две системы, с которыми интегрируется алгоритм.
Если посмотреть на правый рисунок — разве что-то понятно? Получается алгоритм с множеством придуманных состояний: стейт-машина в этом случае делает его более запутанным.
Чем же закончилась наша история: бизнес получил работающий проект, а у команды появилась экспертиза в работе со стейт-машинами.
Мысль в завершение: используйте стейт-машины, но тщательно обдумывайте, когда они действительно помогут решить задачу, а когда только усложнят процесс.
Расскажите, использовали ли вы стейт-машины и если да, то как именно? С какими сложностями сталкивались при этом?