Проекции CQRS, объединение данных из разных агрегатов с помощью команд зонда

В CQRS, когда нам нужно создать индивидуальные прогнозы для наших моделей чтения, мы обычно предпочитаем денормализованные прогнозы (предположим, что мы говорим о проецировании на БД). Нередко информация, необходимая приложению/пользовательскому интерфейсу, поступает из разных агрегатов (возможно, из разных BC).

Представьте, что нам нужна спроецированная таблица, содержащая информацию о клиенте вместе с ее полным адресом, и что Customer и Address — это разные агрегаты в нашей системе (возможно, в разных БК). Это означает, что адреса генерируются и поддерживаются независимо от клиентов. Или, другими словами, при создании нового клиента нет гарантии, что система AddressCreatedEvent впоследствии сгенерирует это событие, возможно, это событие уже было обработано до создания клиента. . Все, что у нас есть на момент CreateCustomerCommand, это UUID существующего адреса.

Здесь у нас есть несколько решений.

  1. Дополните CreateCustomerCommand и последующие CustomerCreatedEvent, чтобы они содержали полный адрес клиента (просматривая эту информацию на лету из пользовательского интерфейса или контроллера). Таким образом, обработчик проекций просто обновит таблицу сразу после получения CustomerCreatedEvent.

  2. Используйте addrUuid, представленный в CustomerCreatedEvent, чтобы выполнить специальный запрос в обработчике проекций, чтобы получить недостающую часть адресной информации перед обновлением таблицы.

Это обычно обсуждаемое решение этой проблемы. Однако, как отмечают многие другие, у каждого подхода есть проблемы. Обогащение событий может быть трудно оправдать, как это описано Энрико Массоне can-fill-highly-denormalize">в этом вопросе, например. Запрос других представлений/проекций (типа JOIN) будет работать, но вводит связь (см. ту же ссылку).

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

Картинка говорит тысячу слов, как говорится:

CQRS объединяет данные в проекциях с помощью тестовых команд

Идея в том, что:

  1. Мы сохраняем CreateCustomerCommand и CustomerCreatedEvent простыми только с атрибутом addrUuid (без обогащения).
  2. В контроллере API мы отправляем две команды в обработчик команд (агрегаты): первая, как обычно, - CreateCustomerCommand для создания информации о клиенте и проекте вместе с addrUuid в таблицу, оставляя остальные столбцы (полный адрес и т. д.) временно пусты. (Предупреждение: см. обновление, у нас может быть проблема параллелизма, и нам нужно выполнить команду probe из Saga.)
  3. Сразу после этого, и после того, как мы получили custUuid вновь созданного клиента, мы выдаем специальный агрегат от ProbeAddrressCommand до Address, запускающий AddressProbedEvent, который будет инкапсулировать полное состояние адреса вместе со специальным атрибутом probeInitiatorUuid, который, конечно же, является нашим custUuid от предыдущая команда.
  4. Затем обработчик проекций воздействует на 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 являются независимыми агрегатами (студенты и курсы могут создаваться и поддерживаться в разное время и в разных местах системы).

Но остается вопрос: как мы можем получить проекцию, содержащую полную информацию о студенте (ФИО и т. д.) и курсах, на которые она зарегистрирована (название, кредиты, ФИО преподавателя, предварительные условия и т. д.) все в ту же таблицу, если этого требует пользовательский интерфейс?


person George    schedule 16.02.2021    source источник


Ответы (1)


Пара мыслей:

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

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

person Levi Ramsey    schedule 18.02.2021
comment
Спасибо за отзыв. См. обновление (2) в отношении выбора агрегатов... Это не очень важно, так как объединение данных из любых двух агрегатов (в проекциях) является обычной задачей в CQRS. Насколько я понял, проекции существуют, чтобы обеспечить удобную, готовую к использованию поддержку пользовательского интерфейса. Если пользовательский интерфейс предписывает одну таблицу, в которой каждая строка имеет вид: custUuid, custName, addrUuid, addrFull (и нельзя делать никаких предположений о последовательности связанных событий, то есть CustCreatedEvt, AddrCreatedEvt), как мы можем это сделать. - person George; 01.03.2021
comment
Проблема с поддержкой модели чтения для всех адресов (чтобы мы могли просто искать полный адрес в тот момент, когда обработчик проекции реагирует на CustCreatedEvt) заключается в том, что нам пришлось бы связать вместе две проекции: CustAddrProjTable, ту, которую мы на самом деле нужен и поддерживающий AddrProjTable, который есть только для того, чтобы снабдить addrFull. Мы должны были бы сначала сделать SELECT addrFull из последнего, а затем сделать INSERT в первый. И мы должны избегать такого объединения разных проекций, поскольку они должны оставаться независимыми для облегчения перестройки. - person George; 01.03.2021