В этом посте я представил проект, в котором используются шаблоны CQRS и Event Sourcing. Он организован с использованием луковой архитектуры и написан с помощью TypeScript.

«Гибкий» как?

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

  • отделить основную бизнес-логику от деталей реализации
  • быть независимым от какой-либо базы данных, фреймворка или сервиса
  • по возможности используйте простые чистые функции
  • облегчить масштабирование проекта «по горизонтали»
  • сделать проект легким для тестирования
  • использовать систему типов в первую очередь для передачи «вездесущего языка» основного домена

Предупреждение: некоторые части проекта могут быть переработаны!

Шаблон CQRS можно использовать без источника событий, и вам не нужно следовать «луковой архитектуре» или использовать систему типов.
Однако я объединяю все это вместе, прежде всего, чтобы протестировать и узнать о них так что я надеюсь, что вы сможете выбрать приемы, которые сочтете полезными.

Подробности проекта

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

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

  • статья - материал (например, сообщение в блоге или видео на YouTube), продвигаемый автором.
  • журнал - сборник статей, которые принимаются только при соблюдении набора правил, определенных журналом.
  • пользователь - автор или рецензент с рейтингом, предоставляющим им определенные привилегии (аналогично системе рейтинга StackOverflow).

Каждое действие вызывает реакцию

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

В поиске событий (ES) мы можем посмотреть на систему и сказать, что есть действия, и каждое действие вызывает реакцию. В этом случае действие может быть реализовано как команды, а реакции - как события. ("источник")

Однако гораздо проще представить проект наоборот - от реакции к действиям.

Итак, без лишних слов, вот список событий, которые в настоящее время используются в проекте:

Примечание. Журнал, Статья и Пользователь - это отдельные единицы, которые я буду называть агрегатами (даже если его » объектно-ориентированное определение » Не вписывается в мою модель, его общая цель та же)

После того, как я был удовлетворен списком событий, я определил каждый тип более подробно (используя TypeScript):

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

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

Короче говоря, это типичное приложение CQRS, в котором мы разделяем ответственность между командами (запросы на запись) и запросы (запросы на чтение):

В этом посте я буду писать в основном о стороне записи приложения (которая обычно более сложна).

Ментальная модель для хранения событий

Мысленная модель для хранения событий в системе источников событий проста - новые события добавляются к списку, а затем могут быть извлечены из него. Кроме того, при сохранении событие никогда не удаляется и не обновляется.

Итак, по сути, это переход от модели CRUD (создание, чтение, обновление, удаление) к модели CR (создание, чтение).

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

Но есть проблема с этим подходом, которая приводит к проблемам с блокировкой на уровне базы данных или неудачным обновлениям (из-за пессимистичного параллелизма).

Примечание. Для более подробного объяснения границ согласованности я предлагаю прочитать Шаблоны, принципы и практики DDD (глава 19 - Агрегаты).

Короче говоря, лучший способ думать о хранении событий таков:

Существует несколько списков событий (потоки событий), и каждый из них содержит события, соответствующие разным агрегатам.

Например, для журналов, которые будут выглядеть примерно так:

Примечание. Точные сведения о том, как события на самом деле сохраняются в базе данных SQL или NoSQL или как осуществляется пессимистическая или оптимистическая блокировка, выходят за рамки этого сообщения в блоге.

Большая картинка

Несмотря на то, что выражение «действие вызывает реакцию» является правильным, на самом деле оно мало что говорит вам о том, как создается, фиксируется или проверяется команда, как гарантируются инварианты (бизнес-правила) или как с ними бороться. соединение и разделение проблем.

Чтобы это объяснить, полезно заранее знать «общую картину»:

Но с таким количеством компонентов в системе может быть трудно «увидеть лес за деревьями».

Вот почему, прежде чем объяснять, что делает каждый компонент, я сначала представлю архитектуру, основная цель которой - отделить технические сложности от сложности предметной области.

Это называется «луковая архитектура»:

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

  • В основе архитектуры лежит модель предметной области (содержащая бизнес-правила), реализованная с использованием только чистых функций (легко тестируемых).
  • Обработчики команд могут использовать модель предметной области и взаимодействовать с внешним миром только с помощью встроенного репозитория, который реализует интерфейс репозитория (легко имитировать).
  • Самый внешний слой имеет доступ ко всем внутренним слоям. Он обеспечивает реализацию интерфейсов репозитория, точек входа в систему (REST API), подключение к базе данных (хранилище событий) и тому подобное.

Проблемы представления, сохранения и логики предметной области приложения будут меняться с разной скоростью и по разным причинам; Архитектура, которая разделяет эти проблемы, может приспособиться к изменениям, не вызывая нежелательных эффектов в несвязанных областях кодовой базы. (Шаблоны, принципы и практика DDD)

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

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

Аутентификация и формирование команды

Команда - это объект, запускаемый пользователем, запрашивающим какое-либо изменение.

Обычно он отображает «один в один» с результирующим событием:

CreateJournal => JournalCreated
AddJournalEditor => JournalEditorAdded
ConfirmJournalEditor => JournalEdditorConfirmed
...

Но иногда это может вызвать несколько событий:

ReviewArticle => [ArticleReviewed, ArticlePromoted, ArticleAccepted]
ReviewArticle => [ArticleReviewed, ArticleRejected]

Есть много способов создания и обработки команды, но для этого проекта я использую одну конечную точку REST (/command), которая принимает объект JSON:

{
  name: 'AddJournalEditor',
  payload: {
    journalId: 'journal-1',
    editorInfo: {
      email: '[email protected]'
    },
    timestamp: 1511865224832
  }
}

Этот объект получен как запрос POST, а затем преобразован в:

{
  userId: 'xyz',
  payload: {
    journalId: 'journal-1',
    editorInfo: {
      email: '[email protected]'
    },
    timestamp: 1511865224832
  }
}

Примечание. За свойством userId находится весь процесс аутентификации, который нетривиален. Для этого я решил использовать сервис Auth0 (аналог Firebase Authentication или Amazon Cognito), но, конечно, вы можете использовать свою собственную реализацию.
Важный вывод здесь - что обработчик команд не перегружен сложностью аутентификации и предполагает, что userId, отправленный из этой службы, можно доверять.

Затем командный объект (содержащий userId) передается соответствующему обработчику команд (который находится по имени команды).

Вот упрощенный пример этого процесса:

Обработчик команд - проверка входных данных

Как представлено в этом FAQ по CQRS, вот обычная последовательность шагов, которую выполняет обработчик команд (немного изменен по сравнению с оригиналом):

  1. Проверить команду по достоинству
  2. Проверить команду на текущее состояние агрегата
  3. Если проверка прошла успешно, попытайтесь сохранить новые события. Если на этом этапе возникает конфликт параллелизма, откажитесь или попробуйте еще раз.

На первом этапе («проверка команды на ее достоинства») обработчик команд проверяет объект команды на наличие каких-либо недостающих свойств, недопустимых электронных писем, URL-адресов и т.п.

Для этой цели я использую io-ts - систему типов времени выполнения для проверки ввода-вывода, которая совместима с TypeScript (но может использоваться и без нее).

Обновление: в одном из недавних проектов я использовал валидатор данных Joi. Он не так интегрирован с Typescript, как io-ts, но проще в использовании и более активно поддерживается.

Он работает путем объединения таких простых типов (полный пример):

на более сложные, типы команд (полный пример):

которые затем используются для проверки входных данных, отправленных REST API.

Примечание. Если проверка прошла успешно, TypeScript определит типы команд:

На этом этапе обработчик команд должен выполнить второй шаг: «Проверить команду на текущее состояние агрегата».

Или, другими словами, он должен решить, какое событие (события) следует сохранить или следует ли отклонить команду, выдав ошибку (если какое-либо бизнес-правило нарушено).

Это решение принимается с помощью - модели предметной области.

Использование модели предметной области для проверки бизнес-правил

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

Продолжая пример «добавления редактора», вот обработчик команд (с выделенным разделом, в котором используется функция из модели предметной области):

addEditor принадлежит journal агрегату и реализован как простая чистая функция, которая либо возвращает результирующее событие, либо выдает ошибку (если какое-либо бизнес-правило нарушено):

Параметры userInfo и timestamp происходят из объекта команды. «текущее состояние агрегата» представлено извлекаемыми объектами user и journal. используя другой компонент в системе - Репозиторий.

Примечание. Если вам не нравится видеть жестко запрограммированные строки, имейте в виду, что я использую TypeScript, который «закричит», если использовать его неправильно:

Помимо ошибок времени компиляции, переименование любого свойства или строки с помощью «функции переименования символа» работает для всех файлов в проекте (проверено в коде vs).

Получение текущего состояния агрегата с репозиторием

userState и journalState извлекаются с использованием внедренных зависимостей: userRepository и journalRepository:

Эти репозитории обычно содержат метод под названием getById.

Как вы уже догадались, задача этого метода заключается в получении агрегированного состояния по его идентификатору.

Итак, в случае агрегата журнала он должен возвращать объект такого типа:

Однако хранилище событий ничего не знает о формате агрегированного журнала и, как было показано ранее, хранит только события:

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

Примечание. Помните, что для получения текущего агрегированного состояния вам не нужно использовать источник событий. Иногда более целесообразно получить объект целиком (с помощью MongoDB или аналогичного) и пропустить часть сокращения и сохранения событий.
Но если вы похожи на меня и хотите, чтобы ваша модель была «гибкой» (так что вы может легко изменить формат агрегированного состояния в любой момент в будущем) вам придется иметь дело с «редукторами».

Редуктор - это просто (чистая) функция (похожая на Redux reducer), также определенная в модели предметной области:

Примечание. Опять же, с TypeScript вы можете безопасно использовать жестко запрограммированные строки, где для каждого случая должен быть выведен тип события:

Сохранение событий в магазине событий

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

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

Под капотом события сохраняются с использованием оптимистичного управления параллелизмом для обеспечения согласованности (аналогично подходу mongoDB, но без автоповтора).

Примечание. Я использую оптимистическую блокировку, основанную на версии события, полученной на стороне записи, а не на стороне чтения. Это сознательное решение, которое я принял для своего домен, и если вы попытаетесь использовать это решение для себя, убедитесь, что вы понимаете компромиссы (которые я не буду объяснять в этом сообщении в блоге, поскольку он уже достаточно длинный)
Если вы, тем не менее, решите использовать полученные версии на стороне чтения вы можете передать номер версии следующим образом:
save(events, expectedVersion)

Сводка потока приложения на стороне записи

  1. Команда - это объект, отправленный пользователем (из пользовательского интерфейса).
  2. REST API получает команду и выполняет аутентификацию пользователя.
  3. Затем «аутентифицированная команда» отправляется в обработчик команд.
  4. Обработчик команд отправляет запрос в репозиторий для агрегированного состояния.
  5. Репозиторий извлекает события из хранилища событий и преобразует их в агрегированное состояние с помощью reducer, определенного в доменная модель
  6. Обработчик команд проверяет команду на текущее состояние агрегата, используя модель предметной области, которая отвечает результирующими событиями.
  7. Обработчик команд отправляет результирующие события в репозиторий.
  8. Репозиторий пытается сохранить полученные данные в хранилище событий, обеспечивая согласованность с помощью оптимистической блокировки.

Сторона чтения

Использование событий для воссоздания агрегированного состояния не очень сложно и не дорого.

Однако хранилище событий НЕ подходит для выполнения запросов по агрегатам. Например, запрос типа: «выбрать все журналы, имена которых начинаются с xyz» потребует перестройки всех агрегатов, что слишком дорого (не говоря уже о более сложных запросах, которые являются большим источником «утечки денег». в монолитном приложении CRUD).

Эта проблема решена на стороне чтения.

Короче говоря, сторона чтения прослушивает события, опубликованные на стороне записи, проецирует эти события как изменения в локальную модель и позволяет выполнять запросы в этой модели (source).

Построив модель (или модель s) , которая поддерживает базу данных, специализированную для любого типа запросов, вы можете значительно сэкономить вычислительную мощность.

Я думаю, это стоит повторить:

Если ваш счет за хостинг неоправданно МОЛОДОЙ! в основном из-за сложных запросов - вам следует учитывать архитектуру CQRS / ES.

Поскольку сторона чтения всегда «отстает» от стороны записи (даже на долю секунды), приложение становится «в конечном итоге согласованным». Это основная причина того, что оно дешевле и легко масштабируется, а также причина того, что сторона записи более сложна по сравнению с монолитным приложением CRUD.

Заключение

Мне нравится думать о событиях. Это заставляет меня сосредоточиться на предметной области, а не на схеме базы данных, и вам даже не нужно быть программистом, чтобы понять их. Этот факт облегчает общение с экспертами в предметной области (большая часть DDD).

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

Но, как и все остальное, у этих паттернов есть своя цена. Если вы не видите, что используете их все вместе, возможно, вы сможете использовать их по частям.

Как я уже упоминал; шаблон CQRS можно использовать без источника событий, и вам не нужно следовать «луковой архитектуре» или использовать систему типов. Например, вы можете:

  • использовать аналогичную модель, в которой события заменяются объектами, сохраненными в базе данных NoSQL (без источника событий)
  • использовать редукторы из модели записи для клиентских запросов (без CQRS)
  • использовать луковичную архитектуру для более простого создания # безсерверных приложений (с «ламдами» или «облачными функциями») (путем имитации уровня инфраструктуры на «стадии разработки»)
  • использовать типы аналогичным образом, когда домен представлен мелкозернистым, самодокументированным способом (разработка с первым типом)
  • использовать систему типов времени выполнения для проверки ввода-вывода (например, io-ts)

Ресурсы

  • Мартин Фаулер - Event Sourcing (видео, статья)
  • Мартин Фаулер - CQRS (статья)
  • Грег Янг - Event Sourcing (видео)
  • Альберто Брандолини - Event Storming (статья)
  • Крис Ричардсон - Разработка микросервисов с агрегатами (видео)
  • Скотт Миллетт - Паттерны, принципы и практика DDD (книга)
  • CQRS.nu - FAQ (статья)
  • MSDN - Введение в поиск событий (статья)
  • Скотт Влашин - Domain Driven Design (видео, статья)
  • Марк Зееманн - Функциональная архитектура - это порты и адаптеры (статья, видео)