CQRS
CQRS (Command Query Responsibility Segregation) разводит запись и чтение по двум разным моделям. Модель записи принимает команды, проверяет правила и хранит данные так, чтобы их было удобно защищать. Модель чтения хранит те же данные уже в том виде, в каком их показывает конкретный экран, и отвечает на запросы, ничего не меняя. Между ними ходят события, поэтому модель чтения отстаёт от записи на какое-то время. Это приём для отдельной части системы, где чтение и запись действительно тянут модель в разные стороны, а не архитектура всего приложения.
Проблема
В событийной архитектуре из прошлой темы сервис аккаунтов хранит адреса ради правил. Когда экрану нужны данные, проще всего спросить их у того же сервиса, и одна модель начинает тянуться в две стороны. Записи нужны правила и защита от противоречий: адрес уникален, формат верен. Экрану нужен список пользователей, счётчики по доменам и последние регистрации - и всё это пришлось бы собирать при каждом запросе из данных, которые хранятся ради правил. Каждый новый экран добавляет в модель ещё один метод для чтения, и модель, которая должна охранять правила, превращается в склад отчётов.
Решение
Сторона записи - сервис аккаунтов - принимает команду register, проверяет правила, пишет адрес в своё хранилище записи и публикует событие UserRegistered с номером версии. В ответ команда получает только «принято» с версией или отказ, данных для экрана в ответе нет. Сторона чтения - проектор: он подписан на события и строит модель чтения, уже разложенную под экран. Запрос читает только её и ничего не меняет. Модель чтения обновляется позже записи - согласованность итоговая, поэтому страница ждёт, пока модель чтения догонит версию из ответа. Хранить само состояние как журнал событий - это event sourcing, и CQRS его не требует.
Как это работает
Корень паттерна - разделение команд и запросов (Command-Query Separation, CQS) Бертрана Мейера из книги «Object-Oriented Software Construction» (первое издание - 1988): метод либо меняет состояние и ничего не возвращает, либо возвращает данные и ничего не меняет. Грег Янг, которому приписывают сам термин CQRS, пишет в «CQRS Documents» (2010), что сначала CQRS считали просто CQS на более высоком уровне, пока не признали отдельным паттерном. В посте 16 февраля 2010 года он сформулировал разницу так: CQRS - это создание двух объектов там, где раньше был один. CQS делит методы одного объекта, CQRS делит модель и хранилище на две.
Две модели нужны потому, что у записи и чтения разные заботы. Модель записи проверяет правила и защищает инварианты - условия, которые не должны нарушаться никогда, как уникальный адрес. Янг пишет, что такой модели удобно хранить данные нормализованно, без повторов, а стороне чтения - денормализованно, чтобы не собирать ответ из многих таблиц. Уди Даан в «Clarified CQRS» (9 декабря 2009) доводит это до «одной таблицы на каждое представление», из которой экран читает всё одним запросом. В примере это users, byDomain и latest: готовые данные одного экрана.
Команда описывает задачу пользователя, а не правку полей: Azure Architecture Center приводит «забронировать номер» вместо «поставить статус Reserved». Даан подчёркивает, что клиент команду отправляет конкретному адресату, а не публикует, и что сервер проверяет её сам, не доверяя клиенту. Ответ команды - принято или отказ, в примере ещё версия; данных для экрана в нём нет. Запрос, по той же документации Azure, никогда не меняет данные и возвращает готовый к показу объект.
Модели связывают события из событийной архитектуры: модель записи публикует факт, а проектор обновляет по нему модель чтения - у Даана это обработчик событий на каждую таблицу представления. Отсюда итоговая согласованность: какое-то время модель чтения отстаёт. Янг пишет, что большинство систем переносят это на стороне чтения, а на стороне записи с согласованными данными работать гораздо проще. Запись в базу и публикация - две системы, и Azure советует для них outbox, а для модели чтения - идемпотентного получателя, переживающего повторную доставку.
Самая заметная цена - read-your-writes, «прочитать свою запись»: пользователь нажал «зарегистрироваться» и не нашёл себя в списке. Azure прямо называет это устаревшими данными и предупреждает, что пользователь может действовать на их основе. Обходы известные: вернуть из команды версию и ждать, пока модель чтения её догонит (как в примере), показать изменение на экране сразу, не дожидаясь модели чтения, или прочитать свою запись со стороны записи. Каждый обход - ещё код, и выбирать его надо под конкретный экран.
Модель чтения - производные данные, и если события хранятся, её можно выбросить и перестроить. В примере брокер держит журнал, как Kafka, и кнопка перестройки проигрывает его с начала в пустой проектор. Так же появляется новый экран: ещё один проектор читает тот же журнал, а модель записи не меняется. Если журнала нет, новую модель чтения придётся заполнить из хранилища записи отдельной миграцией.
Event sourcing - другой приём: состояние хранится как последовательность событий, а текущее получают, проигрывая их по порядку. Его часто применяют вместе с CQRS - Azure описывает связку, где журнал событий и есть модель записи, - но одно не требует другого. Фаулер в «What do you mean by "Event-Driven"?» (2017) пишет, что CQRS можно применять вообще без событий, а Янг в посте 2010 года отдельно говорит, что CQRS - это не event sourcing. В примере модель записи хранит текущее состояние, emails, а журнал брокера нужен только стороне чтения.
Главное предупреждение - у Мартина Фаулера в bliki «CQRS» (14 июля 2011): «for most systems CQRS adds risky complexity», для большинства систем CQRS добавляет рискованную сложность. Там же: применять его стоит только к отдельным частям системы (bounded context в терминах DDD), а не к системе целиком. Янг в 2010 году писал о том же с другой стороны: CQRS - не архитектура, а паттерн, который открывает архитектурные возможности. В отличие от событийной архитектуры и микросервисов, которые описывают, как устроена система, CQRS - решение для одного её участка.
Нюансы выбора
Одна часть, а не вся система - CQRS выбирают для участка, где запись охраняет сложные правила, а чтение нужно в нескольких формах. Соседние части, где хватает обычного CRUD (создать, прочитать, изменить, удалить через одну модель), остаются простыми.
Совместная работа - Даан называет главным признаком подходящей системы то, что с одними данными одновременно работают многие пользователи. Для личных данных одного пользователя, по его же словам, обычно лучше простой CRUD.
Отставание допустимо - прежде чем разводить модели, спрашивают, может ли экран показать данные с опозданием в секунды. Если нет - нужна модель чтения в той же транзакции, что и запись, или CQRS здесь не нужен.
Свою запись - сразу - для экрана, куда пользователь смотрит после своего действия, заранее решают способ: ждать версию, показать изменение сразу или прочитать его со стороны записи.
CQRS без event sourcing - начинают с двух моделей и событий между ними. Хранить само состояние журналом событий - отдельное решение со своей ценой, и принимать его вместе с CQRS не обязательно.
Примеры и история
Bertrand Meyer, «Object-Oriented Software Construction» (Prentice Hall, первое издание 1988) - книга, где сформулировано разделение команд и запросов (CQS) для методов объекта; Фаулер отмечает, что влияние оказало именно первое издание.
Greg Young, «CQRS Documents» (2010) - происхождение CQRS из CQS, сторона чтения и сторона записи, нормализованное и денормализованное хранение, task-based UI, события как способ хранения и связка CQRS с event sourcing.
Udi Dahan, «Clarified CQRS» (udidahan.com, 9 декабря 2009) - команды, которые клиент отправляет, а не публикует, отдельное хранилище для запросов с таблицей на каждое представление, обработчики событий, обновляющие эти таблицы, и совместная работа как главный признак подходящей системы.
Martin Fowler, «CQRS» (martinfowler.com, bliki, 14 июля 2011) - определение через раздельные модели, связь с task-based UI и event sourcing, итоговая согласованность и предупреждение применять CQRS только к отдельным частям системы.
Microsoft, Azure Architecture Center, «CQRS pattern» - одно хранилище с двумя моделями и отдельные хранилища, outbox и идемпотентный получатель, итоговая согласованность, связка с event sourcing и случаи, когда паттерн не подходит.
Когда применять
- Когда в одной части системы чтение и запись сильно расходятся: запись защищает сложные правила, а экраны хотят те же данные в нескольких разных формах.
- Когда чтений намного больше, чем записей, и экран может прожить несколько секунд с данными, которые отстают от последней записи.
Примеры из практики
- Microsoft patterns & practices, «CQRS Journey» (июль 2012, книга «Exploring CQRS and Event Sourcing», предисловие Грега Янга) - команда без опыта CQRS строит на Azure учебную систему управления конференциями Contoso и описывает свои ошибки. В книге есть разборы опыта других команд, среди них Twilio и Lokad Hub.
- Azure Architecture Center, паттерн CQRS - пример с товарами: команда
RateProductидёт в модель записи, а экран читает отдельную модельProductDisplay. Там же советуют для отдельных хранилищ outbox и идемпотентного получателя модели чтения и предупреждают, что для простых CRUD-задач паттерн не подходит.
Похожие паттерны
Событийная архитектура
Событийная архитектура (Event-Driven Architecture) связывает части системы через события: компонент сообщает, что уже произошло, например «пользователь зарегистрирован», и не ждёт ответа. Событие уходит в брокер, а брокер доставляет копию каждому, кто на него подписан. Отправитель не знает, сколько у него подписчиков и что они сделают, поэтому новую реакцию можно добавить, не трогая его код. За эту свободу платят: событие может прийти дважды или с опозданием, данные у частей системы какое-то время расходятся, а путь одного действия через систему труднее проследить.
Микросервисная архитектура
Микросервисная архитектура (Microservices Architecture) строит одно приложение как набор небольших сервисов: каждый работает в своём процессе, развёртывается независимо от остальных и сам владеет своими данными. Систему режут по бизнес-возможностям - учётные записи, письма, оплата, - а не по техническим слоям, и сервисы общаются только по сети через API. Общей базы данных нет: чужие данные можно получить, только спросив их владельца. За независимость платят распределённостью: любой вызов может опоздать или потеряться, а согласованность приходится держать без общей транзакции.