Особенности построения CQRS read-модели при использовании доменных событий

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

Микросервисная архитектура в последние годы приобрела большую популярность. Но, несмотря на все ее преимущества, у нее есть и некоторые проблемы. Одна из них — сбор необходимых данных для передачи во фронтэнд. Здесь напрашивается простое и интуитивное решение — паттерн API composition (композиция API). Это решение данной проблемы, но не идеальное. Например, в случае высоконагруженных и высокодоступных систем, вызов нескольких сервисов для создания только одного представления может быть неприемлемым. Нужно придумать что-то другое. Давайте попробуем CQRS.

CQRS

Паттерн CQRS, описанный Крисом Ричардсоном, — альтернатива API Composition. Вместо сбора информации из нескольких источников создается специализированная read-модель (модель чтения, модель представления), хранящая всю необходимую информацию. Read-модель представляет собой отдельную базу данных, используемую только Edge-сервисом (пограничный сервис) и, по сути, дублирующую данные. На рисунке ниже приведен пример, как может выглядеть высокоуровневая архитектура такого подхода.

Однако CQRS — не избавит от всех проблем, связанных с запросами в микросервисной архитектуре. Наполнение read-модели — довольно простая задача. Изменение схемы базы данных (например, добавление нового поля, которое должно отображаться в пользовательском интерфейсе) — куда сложнее.

Построение read-модели

Давайте посмотрим на рисунок ниже. У обоих событий (SomethingHappened, SomethingElseHappened) простая структура из двух полей. Но, допустим, нам нужна только часть информации каждого события. За извлечение необходимой информации из события и добавление его к соответствующей записи в базе данных отвечает Edge-сервис. Запись в БД мы идентифицируем по id доменного события.

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

Возможные проблемы с read-моделью

Недоступность данных

Продукт развивается, и со временем выясняется, что в UI необходимо передать дополнительные данные. Допустим, в событие SomethingHappened добавляется еще одно поле.

На первый взгляд, решение этой проблемы относительно просто:

  1. Повторно обрабатываем все события соответствующего типа.

  2. Находим в базе данных read-модели запись по id события.

  3. Обновляем запись в read-модели.

К сожалению, велик риск, что такое наивное решение не сработает. Например, некоторые события к этому моменту могут быть удалены брокером сообщений. При использовании Apache Kafka срок хранения по умолчанию равен семи дням. В случае AWS Kinesis Data Streams — всего 24 часа. Если мы заранее не предусмотрели такой сценарий, то теперь у нас связаны руки. Здесь нет идеального и красивого решения. Но все-таки как мы можем решить эту новую проблему?

  • Можно вернуться к паттерну API Composition, но он неразрывно связан с предположением, что сервис, публикующий события SomethingHappened, предоставляет API для чтения интересующей нас сущности. Это не всегда так.

  • Если доменные события сохраняются в постоянное хранилище и доступны нам, то у нас еще есть надежда. Иначе мы пропали и будем вынуждены применять "грязные" решения.

Изменения формата события

Предположим, мы построили и заполнили нашу read-модель, обработав все необходимые доменные события. Но тем временем продюсер событий внес изменения в схему события.

С точки зрения потребителя, появились две проблемы:

  • Если мы хотим сохранить информацию, переданную в поле x, то нам нужно как-то найти это поле в старых событиях. Это та же проблема, описанная выше.

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

Очередность событий

Рассмотрим случай, когда для построения read-модели потребуется несколько доменных событий.

  • CompanyCreated

{
  "id": "68d2a8d8-eea1-44ea-bbd3-1533f223b0f4",
  "taxOfficeId": "de6a9b5a-e74c-4145-bfd0-71e3e6ee7689",
  "name": "Easy Invoicing",
  "size": 100
}
  • TaxOfficeClerkChanged

{
  "id": "de6a9b5a-e74c-4145-bfd0-71e3e6ee7689",
  "name": "John Doe"
}

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

{
  "companyId": "68d2a8d8-eea1-44ea-bbd3-1533f223b0f4",
  "taxOfficeId": "de6a9b5a-e74c-4145-bfd0-71e3e6ee7689",
  "companyName": "Easy Invoicing",
  "companySize": 100,
  "taxOfficeClerkName": "John Doe"
}

В этом случае нам нужно убедиться, что событие CompanyCreated мы получили перед событием TaxOfficeClerkChanged. Можно решить эту проблему, сохраняя события TaxOfficeClerkChanged в отдельной временной базе данных, и искать в ней имя клерка при получении события CompanyCreated. Однако стоит отметить, что в этом случае мы значительно увеличиваем дублирование данных, возможно, создавая новые проблемы, которые всплывут в будущем. Например, если событие TaxOfficeClerkChanged содержит персональные данные, и мы сохранили их, то нам нужно это учитывать при обсуждении вопросов соответствия GDPR.

Утечка знаний о предметной области

Помимо чисто технических моментов, описанных ранее, есть еще один, который не виден сразу. Рассмотрим следующий сценарий:

  1. Продюсер генерирует событие InvoicePaid (счет оплачен), в котором присутствует информация об оплаченной сумме.

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

  3. На стороне продюсера происходят изменения: появляется новый способ оплаты с покупкой в рассрочку. Клиент оплачивает счет и генерируется событие InvoicePaid. Счет оплачен и нам неважно, кто оплатил счет (банк или клиент). Отличие только в том, что теперь мы не должны списывать всю сумму со счета единовременно, а делать это частями ежемесячно.

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

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

Альтернативное решение

Как было показано, построение read-модели на основе доменных событий может оказаться долгим и чреватым ошибками. Хотя это не значит, что нет безопасного способа для ее построения. Вместо использования доменных событий можно использовать события сущности (entity event). Они, наряду с другими интересными концепциями, описаны в статье, посвященной Data Mesh. Если вкратце, то на основе событий сущности можно создать полный снимок измененной сущности. Каждое изменение может инициировать как доменные события, так и события сущности. Первые из них используются для организации бизнес-процессов. Вторые — для аналитических целей, а также для построения read-моделей.

Заключение

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


Приглашаем всех желающих на открытое занятие «Микросервисная архитектура – когда нужна, а когда нет?». На занятии рассмотрим плюсы и минусы монолитов и микросервисов. Рассмотрим боли при двух подходах, а также основные паттерны в микросервисной архитектуре. Записаться можно по ссылке.

Источник: https://habr.com/ru/company/otus/blog/717580/


Интересные статьи

Интересные статьи

Даже если у вас большой опыт работы с Apache Kafka, время от времени наверняка случается зайти в тупик. Например, когда вы конфигурируете и изучаете клиенты или настраиваете и отслеживаете брокеры. По...
Продолжаю тему моего коллеги о Keycloak. Кому не нужна вода, а просто пример кода, прыгайте сразу сюда.Keycloak довольно часто используется в качестве решения для управле...
Построение маршрутов типичная задача, люди регулярно этим пользуются, особенно для автомобильных маршрутов, в навигаторах. Решений, для построения маршрута тоже немало, в том числе сущест...
Самыми значимыми на этой неделе стали новости об атаках на компании Belden и E-Land, а также об обнаружении в открытом доступе внушительного списка IP-адресов уязвимых FortiGate SSL VPN-ш...
Сегодня мы поговорим о перспективах становления Битрикс-разработчика и об этапах этого пути. Статья не претендует на абсолютную истину, но даёт жизненные ориентиры.