Типы CQRS

CQRS — довольно хорошо изученный паттерн. Часто можно слышать, что вы либо следуете CQRS, либо нет, имея ввиду что это что-то вроде бинарного выбора. В этой статье я бы хотел показать, что существует спектр вариаций этого понятия, а также как разные типы CQRS могут выглядеть на практике.

Тип 0: без CQRS

С этим типом, вы не используете паттерн CQRS вообще. Это означает, что ваша доменная модель использует доменные классы для обслуживания как команд (commands), так и запросов (queries).

Рассмотрим класс Customer как пример:

public class Customer
{
    public int Id { get; private set; }
    public string Name { get; private set; }
    public IReadOnlyList<Order> Orders { get; private set; }
    public void AddOrder(Order order)
    {
        /* … */
    }
    /* Other methods */
}

С нулевым типом CQRS вы работаете с классом CustomerRepository, который выглядит следующим образом:

public class CustomerRepository
{
    public void Save(Customer customer) { /* … */ }
    public Customer GetById(int id) { /* … */ }
    public IReadOnlyList<Customer> Search(string name) { /* … */ }
}

Метод Search здесь является запросом. Он используется для выборки данных по кастомерам из БД и возврата этих данных клиенту (который может быть UI-ем или отдельным приложением, обращающимся к вашему приложению через API). Обратите внимание, что этот метод возвращает список доменных объектов.

Преимущество подобного подхода в том, что здесь нет оверхеда по количеству кода. Другими словами, у вас есть единственная модель, которую вы можете использовать и для команд, и для запросов, и вам не приходится дублировать код.

Недостаток здесь в том, что эта единственная модель не оптимизирована под операции чтения. Если вам необходимо показать список кастомеров на UI, вам как правило не нужно отображать их заказы (Orders). Вместо этого, в большинстве случаев вы захотите показать только краткую информацию, такую как id, имя и количество заказов.

Использование доменных классов для транспортировки данных приводит к тому, что все подобъекты (такие как Orders) кастомеров загружаются в память из базы. Это ведет к серьезных накладным расходам, т.к. UI-ю требуется всего лишь количество заказов, а не сами заказы.

Этот тип CQRS хорош для приложений с небольшими (или вообще без) требованиями по производительности. Для остальных типов приложений, мы должны использовать следующие типы CQRS.

Тип 1: отдельная иерархия классов

С этим типом CQRS ваща структура классов разделена для обслуживания операций чтения и записи. Это означает, что вы создаете набор классов DTO для транспортировки данных, загружаемых из базы.

DTO для класса Customer может выглядеть так:

public class CustomerDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int OrderCount { get; set; }
}

Метод Search в репозитории возвращает список DTO вместо списка доменных объектов:

public class CustomerRepository
{
    public void Save(Customer customer) { /* … */ }
    public Customer GetById(int id) { /* … */ }
    public IReadOnlyList<CustomerDto> Search(string name) { /* … */ }
}

Search может использовать как ORM, так и обычный ADO.NET для выборки необходимых данных. Это должно определяться требованиями по производительности в каждом конкретном случае. Нет необходимости откатываться к ADO.NET в случае если производительность метода удовлетворительна.

DTO добавляют некоторое дублирование в том смысле, что нам теперь нужно создавать два класса вместо одного: один раз для команд в форме доменного объекта и еще один раз для запросов в формате DTO. В то же время, они позволяют нам создавать чистые и ясные структуры данных, которые четко ложатся на нужны наших операций чтения, т.к. они содержат только то, что необходимо при отображении. А чем более ясно мы выражаем наши намерения в коде, тем лучше.

По моему мнению, этот тип CQRS достаточен для большинства enterprise приложений, т.к. он дает довольно хороший баланс между простотой и производительностью кода. Также, с этим подхом мы имеем некоторую гибкость в том, какой инструмент выбирать для запросов. Если производительность метода не критична, мы можем использовать ORM и сэкономить время разработчика. Иначе, мы можем использовать ADO.NET напрямую (или же легковесную ORM типа Dapper) и писать сложные и оптимизированные запросы вручную.

Тип 2: отдельные модели

Этот тип CQRS предполагает использование отдельных моделей и набора API для обслуживания запросов на чтение и запись.

image

Это означает, что в дополнение к DTO, мы извлекаем все операции чтения из нашей модели. Репозитории теперь содержат только методы, которые относятся к командам:

public class CustomerRepository
{
    public void Save(Customer customer) { /* … */ }
    public Customer GetById(int id) { /* … */ }
}

А логика поиска находится в отдельном классе:

public class SearchCustomerQueryHandler
{
    public IReadOnlyList<CustomerDto> Execute(SearchCustomerQuery query)
    {
        /* … */
    }
}

Этот подход добавляет больше оверхеда в сравнении с предыдущим по количеству кода, необходимого для обработки запросов, но это хорошее решение в случае если у вас имеются большие нагрузки на чтение данных.

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

Если вам необходимо еще большее увеличение производительности в части операций по чтению, вам нужно двигаться в сторону типа 3.

Тип 3: раздельное хранилище

Это тип, который многими считается «истинным» CQRS. Для масштабирования операций чтения еще больше, мы можем использовать отдельное хранилище, оптимизированное под запросы нашей системы. Часто подобным хранилищем выступает NoSQL БД, к примеру MongoDB, либо набор реплик из нескольких инстансов:

image

Синхронизация здесь происходит в фоновом режиме и может занимать некоторое время. Такие хранилища называются «консистентными в конечном счете» (eventually consistent).

Хорошим примером здесь является индексирование данных клиентов при помощи Elastic Search. Часто мы не хотим использовать полнотекстовый поиск, встроенный в SQL Server, т.к. он не особо хорошо масштабируется. Вместо этого, мы можем использовать нереляционные хранилища данных, оптимизированные для поиска кастомеров.

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

Заключение

Существуют разные градации паттерна CQRS, которые вы можете использовать в своем приложении. Нет ничего плохого в том, чтобы придерживаться типа 1 и не двигаться в сторону типов 2 и 3 если тип 1 удовлетворяет требованиям производительности вашего приложения.

Я бы хотел подчеркнуть этот момент: CQRS не является бинарным выбором. Существуют различные вариации между тем, чтобы не разделять операции чтения и записи вообще (тип 0) и разделением их полностью (тип 3).

Следует придерживаться баланса между степенью сегрегации и сложностью, которую эта сегрегация привносит. Баланс следует искать в каждом конкретном случае отдельно, часто с применением нескольких итераций. Паттерн CQRS не должен применяться просто потому, что «мы можем».