Привет! Меня зовут Дарья Андреева, я тимлид в команде бэкенда Биллинга Яндекс 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 в алгоритме

Чтобы алгоритм заработал, нужна третья часть — простой сервис, который работает в три шага: 

  1. Ресторит стейт-машину из консистентного состояния.

  2. Запускает алгоритм и отправляет нужный ивент. Стейт-машина через заданную конфигурацию проводит promo_code_request по состояниям и доводит до промежуточного или финального.

  3. Сохраняет стейт-машину в базу.

Код получившегося сервиса:

@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 система

Переиспользуемый движок

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

  • Ваша система имеет ярко выраженные состояния

  • Алгоритм совершает переходы между состояниями и может проходить через них повторно, то есть система имеет циклические зависимости

Если нужно придумывать дополнительные состояния или в алгоритме нет цикличности — лучше поискать другие решения. 

Бывают задачи, в которых создание графа переходов и зависимостей для стейт-машины только усложняет процесс и делает его более запутанным. Например, есть сервис Доменатор, который выдаёт пользователю домены. Его алгоритм работы представлен на схеме. Если смотреть на левый рисунок, всё понятно: есть четыре задачи, переходы и две системы, с которыми интегрируется алгоритм. 

Схема работы доменатора без стейт-машины и с ней
Схема работы доменатора без стейт-машины и с ней

Если посмотреть на правый рисунок — разве что-то понятно? Получается алгоритм с множеством придуманных состояний: стейт-машина в этом случае делает его более запутанным.

Чем же закончилась наша история: бизнес получил работающий проект, а у команды появилась экспертиза в работе со стейт-машинами. 

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

Расскажите, использовали ли вы стейт-машины и если да, то как именно? С какими сложностями сталкивались при этом?

Комментарии (7)


  1. rukhi7
    30.03.2024 13:31

    чем-то напоминает обсуждение давно забытого проекта:

    https://www.youtube.com/watch?v=VZOnwBIl0Lk

    но явно не хватает эксперта.


  1. jonic
    30.03.2024 13:31

    Хорошо когда можно тянуть яву. А вот сделать stack final state machine на мк с 200кб кучей? :)


    1. DamonV79
      30.03.2024 13:31

      Не холивара ради, мне правда интересно, а в чем проблема? Я когда-то на Плюсах делал. Вполне себе работало. Проблем небыло...


      1. jonic
        30.03.2024 13:31

        Так и я на плюсах делаю и проблем нет. А теперь в те же условия давайте яву засунем - а вот теперь у нас проблема.


        1. DamonV79
          30.03.2024 13:31

          Посыл я понял (про яву), только зачем ее туда совать (я про эти условия)? :-)

          Я понимаю, что изречение банальное и избитое, но ява создавалась для других задач и в своей нише вполне лидирует, благо, пока не приходится экономить на памяти в проде.


          1. jonic
            30.03.2024 13:31

            Да черт его знает о чем я думал когда писал этот комментарий ночью. Плюсы люблю)


  1. veksh
    30.03.2024 13:31

    Ключевые сложности тут, как мне кажется, это 1) "простой сервис, который работает в три шага" и его надежность в вопросах обработки сбоев (а что будет, если машина, на которой он работает, вдруг взорвется?) и 2) вопросы масштабирования всего этого добра.

    workflow engine вроде temporal.io дают примерно то же самое и больше (durable execution с защитой от ошибок инфраструктуры, тайм-ауты для шагов workflow, маштабируемость инфраструктуры worker'ов, версионирование workflows, историю выполнений), только явных state'ов и забот о persistence не нужно, за этим следит сервер. Можно просто писать код в предположении что всегда реализуется только golden path, а вычислительные ресурсы бесконечны :)