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

В этой статье мы увидим, как реализовать шаблон саги без использования специализированных фреймворков (таких как Axon, Eventuate Tram и т. Д., Которые в основном используются в контексте реализаций CQRS / ES / CDC). Наша цель здесь - улучшить понимание механизма и посмотреть, как этого можно достичь даже с использованием ванильного стека, например, с популярными Spring Boot / Spring Data. Для реализации я использовал Kotlin, потому что это отличный шанс попрактиковаться в этом современном и элегантном языке JVM 😊

ПРИМЕЧАНИЕ: в этой статье предполагается, что читатель имеет представление о том, что такое Saga Pattern и как он работает: есть много статей и статей, которые объясняют это всесторонне. Несмотря на это, если вам понравится эта история и вы хотите прочитать от меня подробное описание шаблона и основ распределенных систем, просто оставьте здесь комментарий!

TL; DR
вот код: https://github.com/cingaldi/sagapattern

Фаза анализа

Требования

Позвольте представить наш пример использования:

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

Первое, на что следует обратить внимание:

  • Поездка может быть подтверждена после бронирования авиабилетов и гостиниц. Предполагается, что оба шага выполняются асинхронно. Следовательно, они могут дать нам результат за секунды, часы или никогда.
  • Для повышения производительности системы все взаимодействия должны выполняться без блокировки.
  • Если по какой-либо причине рейс или отель не может быть зарезервирован, мы должны полностью отменить операцию. Поэтому каждое бронирование следует отменять (посмотрите, как возникает концепция согласованности в конечном итоге)

Моделируя эти требования с помощью микро-Event Storming, мы и пытаемся достичь этого.

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

  • всякий раз, когда забронирован рейс и отель, затем подтвердите поездку
  • всякий раз, когда рейс или отель отменяются, затем прервите поездку

Обычно политика превращается в сагу

Архитектура

А теперь представьте, что мы работаем с такой архитектурой.

Мы видим, что готовы обрабатывать рабочий процесс асинхронно и обеспечивать согласованный фасад по отношению к внешним сервисам. Вся транзакция будет координироваться TripService, который предоставляет REST API для создания поездки.

Строительные блоки саги

Менеджер саги

Это является точкой входа саги и действует как связующий код: в двух словах, он реализует прослушиватель для каждого события, связанного с сагой, и решает, какую команду отправить после какого события. Платформа Spring предлагает шину событий из коробки, поэтому мы не будем сильно беспокоиться об этой детали.
Обратите внимание, что Saga Manager принадлежит уровню приложения: он не реализует реальная логика предметной области, нужно только заботиться о хореографии потока

Так почему бы просто не добавить к TripService несколько прослушивателей событий? Ответ, конечно же, - SRP и указание на то, что служба приложения обертывает агрегированный корень, в то время как Saga Manager должен иметь дело не только с одним агрегированным корнем.

Состояние саги и репозиторий саг

Состояние саги (или, для простоты, просто сага) - это то, что лежит в основе и является понятием предметной области, поэтому - в общем - мы можем найти его в нашем повсеместном языке с имя, которое представляет Статус Прогресс, Процесс или что-то подобное. Он представляет собой часть состояния приложения, поэтому мы можем справедливо рассматривать его как объект Модель домена, точно так же, как сущности, агрегированные корни или объекты значений. Он сохраняется, поэтому с ним будет связанный репозиторий saga . Мы ожидаем от саги

  • Создавайте ассоциации данных. Поскольку сага реагирует на события, исходящие от разрозненных агрегатов, нам необходимо связать сагу с каждым из агрегатов, участвующих в хореографии. В нашем коде мы видим, что hotelCode, flightCode, tripId однозначно идентифицирует сагу. По этой причине репозиторий саги определяет методы запроса для получения связанного с ними статуса.
  • Сохраняйте актуальный статус. Сага может сохранять все, что вы сочтете нужным для принятия решений на каждом этапе. Обратите внимание, что мы определяем методы, которые содержат эту логику перехода между состояниями, это хорошая привычка ООП инкапсулировать состояние объекта и поддерживать поведение, близкое к данным.
  • Решайте, что будет дальше каждый раз, когда изменяется статус саги, могут быть отправлены некоторые команды, именно так сага координирует задействованные компоненты. Таким образом, каждый метод решает, какую команду применять дальше.

Командный фасад

Хорошо, это не вопрос жизни или смерти, но если вы хотите четкого разделения задач, выразительного кода и большей расширяемости, этот шаблон как раз для вас. Вкратце, мы реализуем концепцию команды как сообщения, которое перетекает в шину и сопоставляет правильный обработчик, который, в свою очередь, применяет эту команду к агрегату. Если это похоже на то, что делают ApplicationService / UseCase, вы не ошибаетесь: это так, но более независимым образом

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

Сроки обработки

Не забывайте, что сага предназначена для обработки асинхронных процессов. И если есть что-то, что делают все асинхронные вещи, это то, что они не появляются, когда они вам нужны. Сервер отеля может выйти из строя, летная компания может обанкротиться, поэтому иногда нам нужно как-то подвести итоги

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

Вы можете просто использовать cronjob или внешний триггер. Кроме того, некоторые промежуточные программы, ориентированные на сообщения (например, Amazon SQS или, с некоторыми хаками, RabbitMQ), предоставляют функции отложенного сообщения , которые позволяют отправлять команду в начале саги. , например, через 10 минут запускает событие тайм-аута. На этом этапе наша сага будет изящно провалиться.

В нашем случае мы просто примем быстрое и грязное решение с использованием сопрограмм Kotlin. Теперь осталось просто расширить код, чтобы сохранить запланированные задачи и надежно их обслуживать. Более того, как и в случае с оптимизацией, было бы неплохо предоставить менеджеру саги способы найти задачу и отменить ее в случае успешного завершения.

Тестирование саги

Обычно мы можем узнать, насколько хорош код, по тому, как выглядят тесты. Важно иметь простой способ протестировать всю сагу, определяя все возможные пути, по которым может идти рабочий процесс, и утверждать, что результат является ожидаемым. Поработав с Axon Framework, я влюбился в их быстрые инструменты тестирования, ориентированные на поведение. В основном здесь мы хотим протестировать (также) SagaManager, и тест должен быть таким:

given(SomePriorEvents)
when(CertainEventTriggered)
then(DispatchedThisCommand)

Конечно, утверждение против CommandDispatcher очень помогает. Более того, чтобы сохранять состояние между вызовами шага, мы можем использовать поддельный SagaRepository, который содержит реальный экземпляр саги. Это может повысить тестовую уверенность в отношении решения, в котором мы используем заглушки для тестирования отдельных шагов.

Чего не хватает?

  • Мы реализовали только счастливый путь, но что будет, если отель или рейс не подтверждены?
  • На самом деле мы не управляем внутренними сбоями. Как преодолеть исключения из-за одновременной записи? Что должно произойти, если брокер сообщений не сможет распределить задачи по гостиничным / авиационным службам?
  • Рейс (или отель) может быть отменен даже после подтверждения, и это может вызвать политику возврата для клиента. Как мы можем расширить эту сагу, чтобы выполнить эту часть процесса?

Вывод

В этой статье мы реализовали небольшое приложение, которое с помощью Saga Pattern управляет распределенной транзакцией. Сценарий использования был довольно простым, а реализация далека от промышленного уровня. Тем не менее, мне было весело разрабатывать его, а также возникло несколько небольших проблем. Надеясь, что для читателя то же самое, я завершу рассказ подсказкой: если вы хотите узнать что-то получше, просто запрограммируйте это с нуля!