CQRS, или Command and Query Responsibility Segregation, означает разделение ответственности команд и запросов. Это задает архитектурный стиль, когда мы разделяем модель данных на модель чтения и модели записи, а типы операций на команду и запрос:
- Команда - это операция, которая изменяет состояние и работает с моделью данных для записи;
- Запрос - это операция, которая возвращает моделей данных для чтения.
Схема ниже показывает классический способ взаимодействия с моделью при помощи CRUD, все операции выполняются над одной моделью, чьё состояние сохраняется и читается из БД.
Теперь давайте взглянем, как изменится схема с применением паттерна CQRS.
Нашу Model мы разделили на Write Model и Read Model.
Write Model - представляет собой доменную модель, агрегат, сущность, которая содержит бизнес-логику вашего приложения. Над данной моделью могут выполняться Команды Create, Delete, Update.
Read Model - модель не содержит бизнес-логику или правила валидации, данная модель является обычной DTO, которая используя для представления (view model). Модель участвует в Запросах, операция Read из CRUD.
CQRS в своем классическом виде:
Для большой изоляции, модель записи и чтения разделяют на физическом уровне. В таком случае, модель чтения имеет собственную схему данных, оптимизированную под запросы.
Например, в рамках проектирования БД мы стремимся к нормализации данных, то для хранилища чтения мы можем пойти в обратном направлении и денормализировать данные, а правильнее сказать, материализовать представление, таким образом, мы стараемся избежать большого кол-ва и сложных JOIN-соединений. Для хранения данных чтения может выступать совершенно другой тип хранилища. Например, если для модели записи мы используем в качестве хранилища реляционную СУБД MySql или PostgreSql, то для модели чтения мы можем взять NoSql хранилище Redis, Elasticsearch и любое другое решение.
И зачем?
В традиционном подходе, в таком как CRUD, мы используем одну модель для записи и чтения. В сложных приложениях это может стать проблемой. Часть модели, отвечающая за чтение может выполнять множество различных запросов к БД, трансформацию данных, которые не нужны для операций записи. Часть, отвечающая за запись, может содержать сложные правила валидации и бизнес-логики, они в свою очередь излишни для чтения. В итоге у нас сложная модель, которая делает много вещей.
Нагрузка на чтение и запись у нас асимметричная: с разными требованиями к производительности и масштабируемости.
Именно сложность общей модели и разные требования к операциям чтения и записи привели к появлению CQRS.
Преимущества CQRS
- Разделение зон ответственности: модель записи может содержать сложную бизнес логику, модель чтения может быть простой и легкой;
- оптимизированная схема данных: схема чтения может быть оптимизирована под запросы выборок, модель записи для обновлений;
- простые запросы: использование материализованного представления избавляет от сложных выборок и тяжелых JOIN запросов;
- безопасность: упрощает контроль над записью в хранилище и чтением за счет разделения моделей;
- независимое масштабирование: позволяет независимо масштабировать ресурсы использованные для чтения или записи, например, увеличить размер кластера Redis для чтения. Также помогает избежать лишних блокировок ресурсов.
Сложности CQRS
Общую концепцию CQRS легко понять, но, как правило, она приводит к усложнению архитектуры приложения, т.к. помимо CQRS для выполнения команд и запросов в проект обычно подтягивается шина сообщений, event sourcing, разделенные хранилища, которые необходимо синхронизировать, шина событий и др.
Реализация
Давайте рассмотрим простую реализую без шин, event sourcing и разделениях хранилищ. Возьмем за пример заказ, содержащий
набор позиций. У позиции есть цена, кол-во и скидка. Общая стоимость заказа равна сумме цен позиций, которая рассчитывается
цена * кол-во - скидка
.
Модель записи Order
1 |
|
Обратите внимание, что большая часть проверок поведений и ограничений была опущена для краткости. Единственное, возможно, реальное поведение в этой модели это расчет полной суммы заказа.
Определим интерфейс репозитория для Order
1 |
|
Command (Команда)
В качестве примера, выделим команду создания заказа CreateOrderCommand
и её обработчик CreateOrderHandler
. Команды
обычно отправляются в шину (система сообщений), а обработчики сообщений принимают их и обращаются к интерфейсам домена.
Команды рассчитаны на асинхронное выполнение, поэтому, как правило, их обработчики ничего не возвращают.
1 |
|
CreateOrderCommand
состоит из двух параметров, это ид покупателя costumerId
и массивы позиций заказа items
.
Конструктор обработчика команда CreateOrderHandler
принимает интерфейс репозитория OrderRepository
. Метод __invoke
принимает в качестве аргумента саму команду, создает модель записи Order
, и сохраняет при помощи OrderRepository
.
Модель чтения
Типичные требования приложения отображать список последних заказов с номером, датой и общей суммой. Пойдя классическим путем
мы бы добавили метод getMostRecent
в репозиторий OrderRepository
.
1 |
|
Интерфейс удовлетворяет поставленным требования, но даже в таком простом примере, реализация может вызывать некоторые предостережения. Связь между Заказом и Позициями заказа является “один ко многим”, и в реляционной БД две соответствующие таблицы, связных внешним ключом. Получая заказ из БД, нам необходимо будет сделать выборку из обеих таблиц. Есть несколько подходов, это может быть соединение таблицы при помощи JOIN, или выборка позиций заказа вторым запросом. Для извлечения одного заказа может быть приемлем любой из подходов, но для коллекции это может приводить к проблемам N+1. Хотя ORM предлагают свои решения данной проблемы, они относительно сложные и могут снизить производительность как самой ORM, так и БД. Альтернатива заключается в создании агрегирующего запроса.
1 | SELECT o.id, |
Реализуем нашу ReadModel, обратите внимание, что класс представляет собой DTO, отсутствует поведение и логика.
1 |
|
Query (Запрос)
1 |
|
Query
реализуется аналогично Command
, за исключением того, что Query
имеет ответ GetMostRecentResponse
и
выполняется синхронно. Запрос взаимодействует с моделью чтения OrderReadModel
и репозиторием OrderReadRepository
.
Повторим
Command | Query |
---|---|
Работает с WriteModel (DomainModel, Aggregate) | Работает с ReadModel (DTO) |
Изменяет состояние | Не меняет никаких состояний |
Возвращает ид команды/агрегата или вообще ничего | Возвращает ReadModel (DTO) |
WriteModel содержит бизнес-логику, правила валидации и другую логику | Не содержит никакую логику |
Может выполняться как синхронно, так и асинхронно | Выполняется синхронно |
Мифы
Для CQRS нужны две БД
Ничего не мешает WriteModel и ReadModel иметь одинаковую структуру или использовать одни те же таблицы.
Самое главное, иначе думать о моделях записи и чтения данных, а не смешивать их вместе.
Нужно использовать очередь сообщений
Так как CQRS позволяет иметь разные хранилища для чтения и записи, то мы должны заботиться о синхронизации и воссоздании моделей чтения. Создание модели чтения должно иметь процесс преобразования и сохранения данных. Обмен сообщений при помощи RabbitMQ, Kafka, и др систем, помогают синхронизировать данные, уведомляя процессы модели чтения о новых изменениях.
Также дает возможность вынести команды в асинхронный поток.
Но это NOT MUST HAVE, потому что, можно начать с меньшего, используя таблицы и представления БД, или очередь в памяти.
Вы столкнетесь с Eventual Consistency
Используя раздельные хранилища и очереди вы приводите приложение к асинхронности, а значит выполняя команду, которая затрагивает несколько моделей чтения, ваша система какое-то время будет в несогласованном состоянии, т.е. одна WriteModel будет говорить, что у пользователя 9 заказов, а вторая 10. Но рано или поздно, ваша система придет к Eventual Consistency, к конечной согласованности.
Яркий пример такого поведения я встречал в мобильном приложении МТС Банка, когда оплачиваешь счет, например, мобильную связь, деньги поступают на счёт оператора, но в приложении МТС Банка отображается старая сумма какое-то время, хотя деньги уже списаны. Богаче от этого я не стал – через какое-то время модель чтения синхронизируется, и сумма на балансе отображается верно.
Также с этим можно столкнуться при репликациях БД, и даже создания представлений в БД.
Чем слабее согласованность, тем быстрее приложение, и наоборот, чем строже согласованность, тем медленнее приложение. Нужно исходить из того, насколько критично, что пользователь может увидеть неправильные данные и найти подходящий компромисс.
Event Sourcing
Когда заходит речь о CQRS, то всегда упоминают Event Sourcing. А если речь заходит о Event Sourcing, то сначала рассказывают про CQRS.
Event Sourcing имеет по определению две модели WriteModel и ReadModel, но особенность заключается в WriteModel, её состояние хранится в виде журнала событий, а из этого журнала уже создаются Модели чтения.
CQRS и Event Sourcing как текила с солью и лаймом. 😉
Event Sourcing - это всего лишь один из вариантов того, как вы можете организовать хранилище данных.
Поэтому, если для вашего приложения Event Sourcing избыточен, то его можно не применять.
CQRS нужно использовать с DDD
Ситуация аналогична с Event Sourcing. CQRS является частью DDD, но не зависит от него, поэтому можно применять CQRS вне подхода DDD.