Микросервисная архитектура в последние годы приобрела большую популярность. Но, несмотря на все ее преимущества, у нее есть и некоторые проблемы. Одна из них — сбор необходимых данных для передачи во фронтэнд. Здесь напрашивается простое и интуитивное решение — паттерн 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‑модели на основе доменных событий. Однако поднятые мною вопросы не выдуманы. Они встретились в моей практике, и я хотел бы рассказать вам о них. Спасибо, что дочитали до конца и удачных экспериментов!


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

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