November 26, 2022

Command Query Re чё ? Или как понять CQRS

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

Сложности CQRS

Общую концепцию CQRS легко понять, но, как правило, она приводит к усложнению архитектуры приложения, т.к. помимо CQRS для выполнения команд и запросов в проект обычно подтягивается шина сообщений, event sourcing, разделенные хранилища, которые необходимо синхронизировать, шина событий и др.

Реализация

Давайте рассмотрим простую реализую без шин, event sourcing и разделениях хранилищ. Возьмем за пример заказ, содержащий набор позиций. У позиции есть цена, кол-во и скидка. Общая стоимость заказа равна сумме цен позиций, которая рассчитывается цена * кол-во - скидка.

Модель записи Order

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Order\Domain\OrderItem;

class Order
{
public readonly \DateTime $createdAt;
private Collection $items;

public function __construct(
public readonly string $id,
public readonly string $customerId
) {
$this->createdAt = new \DateTime();
$this->items = new ArrayCollection();
}

public static function create(string $customerId): Order
{
return new self(
uniqid('order_'),
$customerId
);
}

public function getTotal(): float
{
$sum = 0;

foreach ($this->items as $item) {
$sum += $item->getTotalPrice();
}

return $sum;
}

public function addItem(string $productId, float $price, float $quantity, float $discount): OrderItem
{
$item = new OrderItem(
uniqid('pos'),
$this,
$productId,
$quantity,
$price,
$discount
);

$this->items->add($item);

return $item;
}

public function getCountItem(): int
{
return $this->items->count();
}
}

class OrderItem
{
public function __construct(
public readonly string $id,
public readonly Order $order,
public readonly string $productId,
public readonly float $price,
public readonly float $quantity,
public readonly float $discount = 0
) {
}

public function getTotalPrice(): float
{
return $this->price * $this->quantity - $this->discount;
}
}

Обратите внимание, что большая часть проверок поведений и ограничений была опущена для краткости. Единственное, возможно, реальное поведение в этой модели это расчет полной суммы заказа.

Определим интерфейс репозитория для Order

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

declare(strict_types=1);

namespace Order\Domain;

interface OrderRepository
{
/**
* Сохранить Заказ.
*/
public function persist(Order $order): void;

/**
* Найти заказ по идентификатору.
*/
public function ofId(string $id): Order;
}

Command (Команда)

В качестве примера, выделим команду создания заказа CreateOrderCommand и её обработчик CreateOrderHandler. Команды обычно отправляются в шину (система сообщений), а обработчики сообщений принимают их и обращаются к интерфейсам домена. Команды рассчитаны на асинхронное выполнение, поэтому, как правило, их обработчики ничего не возвращают.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php

declare(strict_types=1);

namespace Order\Application\Command;

use Order\Domain\Order;
use Order\Domain\OrderRepository;
use Shared\Bus\Command\Command;
use Shared\Bus\Command\CommandHandler;

class CreateOrderCommand implements Command
{
public function __construct(
public readonly string $costumerId,
public readonly array $items
)
{
}
}

class CreateOrderHandler implements CommandHandler
{
public function __construct(
private readonly OrderRepository $orderRepository
)
{
}

public function __invoke(CreateOrderCommand $command)
{
$order = Order::create($command->costumerId);

foreach ($command->items as $item) {
$order->addItem(
$item['id'],
$item['price'],
$item['quantity'],
$item['discount']
);
}

$this->orderRepository->persist($order);
}
}

CreateOrderCommand состоит из двух параметров, это ид покупателя costumerId и массивы позиций заказа items. Конструктор обработчика команда CreateOrderHandler принимает интерфейс репозитория OrderRepository. Метод __invoke принимает в качестве аргумента саму команду, создает модель записи Order, и сохраняет при помощи OrderRepository.

Модель чтения

Типичные требования приложения отображать список последних заказов с номером, датой и общей суммой. Пойдя классическим путем мы бы добавили метод getMostRecent в репозиторий OrderRepository.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php

declare(strict_types=1);

namespace Order\Domain;

interface OrderRepository
{
/**
* Сохранить Заказ.
*/
public function persist(Order $order): void;

/**
* Найти заказ по идентификатору.
*/
public function ofId(string $id): Order;

/**
* Получить последние заказы
*
* @return Order[]
*/
public function getMostRecent(string $customerId, int $limit, int $offset): array;
}

Интерфейс удовлетворяет поставленным требования, но даже в таком простом примере, реализация может вызывать некоторые предостережения. Связь между Заказом и Позициями заказа является “один ко многим”, и в реляционной БД две соответствующие таблицы, связных внешним ключом. Получая заказ из БД, нам необходимо будет сделать выборку из обеих таблиц. Есть несколько подходов, это может быть соединение таблицы при помощи JOIN, или выборка позиций заказа вторым запросом. Для извлечения одного заказа может быть приемлем любой из подходов, но для коллекции это может приводить к проблемам N+1. Хотя ORM предлагают свои решения данной проблемы, они относительно сложные и могут снизить производительность как самой ORM, так и БД. Альтернатива заключается в создании агрегирующего запроса.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SELECT o.id,
o.customer_id,
o.created_at,

-- высчитываем общую сумма заказа
(SELECT SUM(i.price * i.quantity - i.discount)
FROM order_item i
WHERE i.order_id = o.id) total_sum,

-- подсчитываем кол-во позиций в заказе
(SELECT COUNT(i.id)
FROM order_item i
WHERE i.order_id = o.id) count_items
FROM public.order o
ORDER BY o.created_at DESC

Реализуем нашу ReadModel, обратите внимание, что класс представляет собой DTO, отсутствует поведение и логика.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

declare(strict_types=1);

namespace Order\Application\Query;

class OrderReadModel
{
public function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly \Datetime $createdAt,
public readonly float $totalSum,
public readonly int $countItems
)
{
}
}

class OrderReadRepository {
/**
* Получить последние заказы
*
* @return OrderReadModel[]
*/
public function getMostRecent(string $customerId, int $limit, int $offset): array;
}

Query (Запрос)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

declare(strict_types=1);

namespace Order\Application\Command;

use Shared\Bus\Query\Query;

class GetMostRecentQuery implements Query
{
public function __construct(
public readonly string $costumerId,
public readonly int $limit,
public readonly int $offset,
)
{
}
}

use Shared\Bus\Query\QueryHandler;

class GetMostRecentHandler implements QueryHandler
{
public function __construct(
private readonly OrderReadRepository $orderReadRepository
) {
}

public function __invoke(GetMostRecentQuery $query): GetMostRecentResponse
{
return new GetMostRecentResponse(
$this->orderReadRepository->getMostRecent($query->costumerId, $query->limit, $query->offset)
);
}
}


use Shared\Bus\Query\QueryResponse;

class GetMostRecentResponse implements QueryResponse
{
/**
* @param OrderReadModel[] $orders
*/
public function __construct(
public readonly array $orders
)
{
}
}

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.

Выбор за вами 😉

Исходники на Github