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

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

В конце концов, когда мы обработаем все доменные события, будет построена первая версия read-модели, которую можно использовать для передачи данных в UI. Обратите внимание, здесь мы предполагали доступность всех доменных событий, но это не всегда так. Причины этого рассмотрим далее.
Возможные проблемы с read-моделью
Недоступность данных
Продукт развивается, и со временем выясняется, что в UI необходимо передать дополнительные данные. Допустим, в событие SomethingHappened добавляется еще одно поле.
На первый взгляд, решение этой проблемы относительно просто:
Повторно обрабатываем все события соответствующего типа.
Находим в базе данных read-модели запись по
idсобытия.Обновляем запись в 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.
Утечка знаний о предметной области
Помимо чисто технических моментов, описанных ранее, есть еще один, который не виден сразу. Рассмотрим следующий сценарий:
Продюсер генерирует событие
InvoicePaid(счет оплачен), в котором присутствует информация об оплаченной сумме.Мы обрабатываем это событие и, исходя из оплаченной суммы, списываем часть денег со счета клиента.
На стороне продюсера происходят изменения: появляется новый способ оплаты с покупкой в рассрочку. Клиент оплачивает счет и генерируется событие
InvoicePaid. Счет оплачен и нам неважно, кто оплатил счет (банк или клиент). Отличие только в том, что теперь мы не должны списывать всю сумму со счета единовременно, а делать это частями ежемесячно.Допустим, мы не знали об этом изменении и не внесли правок на нашей стороне. В итоге пользователи увидят как новый договор с банком, так и списание полной суммы с их счета. Это серьезная проблема, мы бы не хотели, чтобы наши клиенты хоть на мгновение сомневались в том, что мы можем управлять их деньгами.
Приведенный выше пример показывает, что, даже если мы преодолеем все ранее описанные проблемы, все равно что-то может пойти не так. Однако между этими двумя проблемами есть существенная разница. Первую мы сможем обнаружить при функциональном тестировании, а в случае утечки знаний предметной области, как бы мы ни старались, не поймаем ошибку, так как на момент тестирования все работает нормально…
Альтернативное решение
Как было показано, построение read-модели на основе доменных событий может оказаться долгим и чреватым ошибками. Хотя это не значит, что нет безопасного способа для ее построения. Вместо использования доменных событий можно использовать события сущности (entity event). Они, наряду с другими интересными концепциями, описаны в статье, посвященной Data Mesh. Если вкратце, то на основе событий сущности можно создать полный снимок измененной сущности. Каждое изменение может инициировать как доменные события, так и события сущности. Первые из них используются для организации бизнес-процессов. Вторые — для аналитических целей, а также для построения read-моделей.
Заключение
В заключение я бы хотел сказать, что, конечно, вы можете и не встретиться с указанными проблемами при построении read-модели на основе доменных событий. Однако поднятые мною вопросы не выдуманы. Они встретились в моей практике, и я хотел бы рассказать вам о них. Спасибо, что дочитали до конца и удачных экспериментов!
Приглашаем всех желающих на открытое занятие «Микросервисная архитектура – когда нужна, а когда нет?». На занятии рассмотрим плюсы и минусы монолитов и микросервисов. Рассмотрим боли при двух подходах, а также основные паттерны в микросервисной архитектуре. Записаться можно по ссылке.
