В CQRS, когда нам нужно создать индивидуальные прогнозы для наших моделей чтения, мы обычно предпочитаем денормализованные прогнозы (предположим, что мы говорим о проецировании на БД). Нередко информация, необходимая приложению/пользовательскому интерфейсу, поступает из разных агрегатов (возможно, из разных BC).
Представьте, что нам нужна спроецированная таблица, содержащая информацию о клиенте вместе с ее полным адресом, и что Customer
и Address
— это разные агрегаты в нашей системе (возможно, в разных БК). Это означает, что адреса генерируются и поддерживаются независимо от клиентов. Или, другими словами, при создании нового клиента нет гарантии, что система AddressCreatedEvent
впоследствии сгенерирует это событие, возможно, это событие уже было обработано до создания клиента. . Все, что у нас есть на момент CreateCustomerCommand
, это UUID существующего адреса.
Здесь у нас есть несколько решений.
Дополните
CreateCustomerCommand
и последующиеCustomerCreatedEvent
, чтобы они содержали полный адрес клиента (просматривая эту информацию на лету из пользовательского интерфейса или контроллера). Таким образом, обработчик проекций просто обновит таблицу сразу после полученияCustomerCreatedEvent
.Используйте
addrUuid
, представленный вCustomerCreatedEvent
, чтобы выполнить специальный запрос в обработчике проекций, чтобы получить недостающую часть адресной информации перед обновлением таблицы.
Это обычно обсуждаемое решение этой проблемы. Однако, как отмечают многие другие, у каждого подхода есть проблемы. Обогащение событий может быть трудно оправдать, как это описано Энрико Массоне can-fill-highly-denormalize">в этом вопросе, например. Запрос других представлений/проекций (типа JOIN) будет работать, но вводит связь (см. ту же ссылку).
Я хотел бы описать здесь еще один метод, который, как мне кажется, прекрасно решает эти проблемы. Я заранее извиняюсь за то, что не дал должного кредита, если это известная техника. Честно говоря, я не видел, чтобы это было описано где-либо еще (по крайней мере, не так явно).
Картинка говорит тысячу слов, как говорится:
Идея в том, что:
- Мы сохраняем
CreateCustomerCommand
иCustomerCreatedEvent
простыми только с атрибутомaddrUuid
(без обогащения). - В контроллере API мы отправляем две команды в обработчик команд (агрегаты): первая, как обычно, -
CreateCustomerCommand
для создания информации о клиенте и проекте вместе сaddrUuid
в таблицу, оставляя остальные столбцы (полный адрес и т. д.) временно пусты. (Предупреждение: см. обновление, у нас может быть проблема параллелизма, и нам нужно выполнить команду probe из Saga.) - Сразу после этого, и после того, как мы получили
custUuid
вновь созданного клиента, мы выдаем специальный агрегат отProbeAddrressCommand
доAddress
, запускающийAddressProbedEvent
, который будет инкапсулировать полное состояние адреса вместе со специальным атрибутомprobeInitiatorUuid
, который, конечно же, является нашимcustUuid
от предыдущая команда. - Затем обработчик проекций воздействует на
AddressProbedEvent
, просто заполняя недостающие фрагменты информации в таблице, ища нужную строку, сопоставляя предоставленныеprobeInitiatorUuid
(т. е.custUuid
) иaddrUuid
.
Итак, у нас есть две фазы: создать Customer
и исследовать связанный Address
. На схеме они обозначены цифрами (1) и (2) соответственно.
Очевидно, что мы можем посылать столько таких команд зонда (параллельно), сколько необходимо нашей проекции: ProbeBillingCommand
, ProbePreferencesCommand
и т. д., эффективно заполняя денормализованную проекцию недостающими данными из каждого обработанного события зонда.
Преимущество этого метода заключается в том, что мы сохраняем простые команды/события на первом этапе (только UUID для других агрегатов), избегая при этом синхронной связи (объединения) проекций. Весь подход имеет хорошее ощущение EDA.
Тогда мой вопрос: это известная техника? Кажется, я такого не видел... А что может пойти не так с таким подходом?
Я был бы более чем счастлив обновить этот вопрос с любыми ссылками на другие источники, описывающие этот метод.
ОБНОВЛЕНИЕ 1:
В этом подходе есть один существенный недостаток, который я уже вижу: команда ProbeAddrressCommand
не может быть выполнена до обработчика проекции, чтобы обработать CustomerCreatedEvent
. Но это невозможно узнать из API-шлюза (или контроллера).
Решение, вероятно, будет включать сагу, скажем, CustomerAddressJoinProjectionSaga
, которая запустится после получения CustomerCreatedEvent
и только после этого выдаст ProbeAddrressCommand
. Сага закончится после регистрации AddressProbedEvent
. Или, если в зондировании участвует много других агрегатов, когда все такие события получены.
Итак, вот обновленная схема.
ОБНОВЛЕНИЕ 2:
Как заметил Леви Рэмси (см. ответ ниже), мой пример довольно запутан в отношении выбора агрегатов. Действительно, Customer
и Address
часто представляются как принадлежащие друг другу (один и тот же совокупный корень). Так что лучшей иллюстрацией проблемы будет вместо этого подумать о чем-то вроде Student
и Course
, предположив для простоты, что между ними существует прямая связь: студент проходит курс. Таким образом, более очевидно, что Student
и Course
являются независимыми агрегатами (студенты и курсы могут создаваться и поддерживаться в разное время и в разных местах системы).
Но остается вопрос: как мы можем получить проекцию, содержащую полную информацию о студенте (ФИО и т. д.) и курсах, на которые она зарегистрирована (название, кредиты, ФИО преподавателя, предварительные условия и т. д.) все в ту же таблицу, если этого требует пользовательский интерфейс?