Идея CQRS возникла в 2010 году, когда Грег Янг (Greg Young) опубликовал статью на эту тему. CQRS быстро стал популярным в разработке приложений, и сегодня является одним из ключевых подходов в работе со сложными системами.

CQRS (Command Query Responsibility Segregation) — это архитектурный паттерн, который предлагает разделить операции записи и чтения данных в приложении на две отдельные ветки. Вместо того, чтобы использовать единый интерфейс для обеих операций, CQRS предлагает использовать различные модели данных для команд и запросов. Это позволяет оптимизировать каждую модель для конкретных задач и улучшить производительность приложения.

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

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

Принципы CQRS


Рассмотрим основные принципы.

1. Разделение команд и запросов


Одним из главных принципов CQRS является разделение команд (изменение состояния) и запросов (получение состояния) на разные модели. Это позволяет использовать разные подходы к обработке каждого из них и улучшить производительность и масштабируемость приложений.

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

Пример кода(кодим если что на C#):

// Команда для добавления нового продукта в каталог
public class AddProductCommand
{
    public string Name { get; set; }
    public decimal Price { get; set; }
}

// Обработчик команды для добавления нового продукта в каталог
public class AddProductCommandHandler
{
    private readonly IProductRepository _productRepository;

    public AddProductCommandHandler(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public void Handle(AddProductCommand command)
    {
        var product = new Product { Name = command.Name, Price = command.Price };
        _productRepository.Add(product);
    }
}

// Запрос для получения списка всех продуктов в каталоге
public class GetAllProductsQuery
{
}

// Обработчик запроса для получения списка всех продуктов в каталоге
public class GetAllProductsQueryHandler
{
    private readonly IProductRepository _productRepository;

    public GetAllProductsQueryHandler(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public IEnumerable<Product> Handle(GetAllProductsQuery query)
    {
        return _productRepository.GetAll();
    }
}

В этом примере показано, как можно использовать разные модели для обработки команд и запросов. Команда AddProductCommand обрабатывается с помощью синхронного запроса в обработчике AddProductCommandHandler, а запрос GetAllProductsQuery обрабатывается с помощью синхронного запроса в обработчике GetAllProductsQueryHandler.

2. Отказ от ORM


В CQRS отказываются от ORM (Object-Relational Mapping) в пользу использования агрегатов (Aggregates), которые представляют связанные сущности в приложении и содержат логику их изменения состояния. Это позволяет улучшить производительность и масштабируемость, так как ORM может стать узким местом при работе с большим объемом данных.

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

Пример кода(C#):

// Агрегат для заказа
public class OrderAggregate
{
    private List<OrderLine> _orderLines = new List<OrderLine>();

    public void AddOrderLine(OrderLine orderLine)
    {
        // Добавление новой позиции в заказ
        _orderLines.Add(orderLine);
    }

    public void RemoveOrderLine(OrderLine orderLine)
    {
        // Удаление позиции из заказа
        _orderLines.Remove(orderLine);
    }

    public decimal GetTotal()
    {
        // Вычисление общей суммы заказа по всем позициям
        return _orderLines.Sum(ol => ol.Price * ol.Quantity);
    }
}

// Сущность для позиции заказа
public class OrderLine
{
    public string ProductName { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

Здесь мы рассмотрели как можно использовать агрегаты для представления связанных сущностей в приложении. Агрегат OrderAggregate содержит список позиций заказа (OrderLine) и логику их изменения состояния. Метод GetTotal использует этот список для вычисления общей суммы заказа.

3. Асинхронная обработка


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

Пример кода:

// Асинхронный запрос для получения списка всех продуктов в каталоге
public class GetAllProductsQueryAsync
{
}

// Асинхронный обработчик запроса для получения списка всех продуктов в каталоге
public class GetAllProductsQueryHandlerAsync
{
    private readonly IProductRepository _productRepository;

    public GetAllProductsQueryHandlerAsync(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<IEnumerable<Product>> Handle(GetAllProductsQueryAsync query)
    {
        return await _productRepository.GetAllAsync();
    }
}

Запрос GetAllProductsQueryAsync обрабатывается с помощью асинхронного метода Handle, который использует асинхронный метод GetAllAsync для получения списка всех продуктов в каталоге.

Компоненты CQRS


Компоненты CQRS можно разделить на две категории: компоненты записи и компоненты чтения.

Компоненты записи включают в себя:

1. Command — это объект, который содержит данные, необходимые для выполнения операции записи в системе. Command может быть отправлен из любой части системы, например, из веб-приложения или из другого сервиса. В C# для создания Command часто используют паттерн Command Object:

public class CreateOrderCommand
{
    public string CustomerName { get; set; }
    public decimal TotalAmount { get; set; }
}

2. Command Handler — это компонент, который получает Command и выполняет операцию записи в системе. Command Handler получает данные из Command и передает их в Write Model для сохранения изменений в базе данных. В C# для обработки Command Handler используется паттерн Command Handler:

public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
    private readonly IOrderRepository _orderRepository;

    public CreateOrderCommandHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public void Handle(CreateOrderCommand command)
    {
        var order = new Order
        {
            CustomerName = command.CustomerName,
            TotalAmount = command.TotalAmount
        };
        _orderRepository.Save(order);
    }
}

3. Write Model — это модель данных, которая используется для операций записи в системе. Write Model содержит данные, которые необходимы для сохранения изменений в базе данных. В C# для создания Write Model часто используют паттерн Repository:

public class Order
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public decimal TotalAmount { get; set; }
}

4. Event — это объект, который содержит данные об изменениях, которые произошли в системе после выполнения операции записи. Event может быть отправлен в другие части системы, которые могут быть заинтересованы в этих изменениях. В C# для создания Event часто используют паттерн Domain Event:

public class OrderCreatedEvent
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public decimal TotalAmount { get; set; }
}

Компоненты чтения включают в себя:

1. Read Model — это модель данных, которая предназначена для чтения. Read Model содержит только те данные, которые нужны для отображения пользователю или для выполнения запросов. Read Model может быть оптимизирован для конкретных запросов, что позволяет повысить производительность системы:

public class OrderReadModel
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public decimal TotalAmount { get; set; }
}

2. Event Handler — это компонент, который получает Event и обновляет данные в Read Model в соответствии с изменениями, которые произошли в системе. Event Handler получает данные из Event и обновляет Read Model, чтобы отобразить эти изменения. В C# для обработки Event Handler используется паттерн Event Handler.

public class OrderReadModelGenerator : IEventHandler<OrderCreatedEvent>, IEventHandler<OrderUpdatedEvent>
{
    private readonly List<OrderReadModel> _orders = new List<OrderReadModel>();

    public void Handle(OrderCreatedEvent @event)
    {
        var order = new OrderReadModel
        {
            OrderId = @event.OrderId,
            CustomerName = @event.CustomerName,
            TotalAmount = @event.TotalAmount
        };
        _orders.Add(order);
    }

    public void Handle(OrderUpdatedEvent @event)
    {
        var order = _orders.FirstOrDefault(x => x.OrderId == @event.OrderId);
        if (order != null)
        {
            order.CustomerName = @event.CustomerName;
            order.TotalAmount = @event.TotalAmount;
        }
    }

    public List<OrderReadModel> GetOrders()
    {
        return _orders;
    }
}

3. Query — это объект, который содержит данные, необходимые для выполнения операции чтения в системе. Query может быть отправлен из любой части системы, например, из веб-приложения или из другого сервиса. В C# для создания Query часто используют паттерн Query Object.

public class GetOrdersQuery
{
}

4. Query Handler — это компонент, который получает Query и возвращает данные из Read Model в соответствии с запросом. Query Handler получает данные из Read Model и возвращает их в виде списка или другого формата, который может быть использован для отображения данных пользователю или для выполнения запросов на чтение. В C# для обработки Query Handler используется паттерн Query Handler.

public class GetOrdersQueryHandler : IQueryHandler<GetOrdersQuery, List<OrderReadModel>>
{
    private readonly OrderReadModelGenerator _readModelGenerator;

    public GetOrdersQueryHandler(OrderReadModelGenerator readModelGenerator)
    {
        _readModelGenerator = readModelGenerator;
    }

    public List<OrderReadModel> Handle(GetOrdersQuery query)
    {
        return _readModelGenerator.GetOrders();
    }
}

Компоненты CQRS могут быть реализованы в разных языках программирования, но основные принципы остаются одинаковыми.

Преимущества и недостатки


Преимущества:

Улучшенная производительность: в проектах с большими объемами данных и высокой нагрузкой на сервера, CQRS позволяет разделить логику команд и запросов на отдельные компоненты, чтобы обработка запросов была более быстрой и эффективной.

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

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

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

Недостатки:

Большая сложность: CQRS требует более сложной архитектуры и более высокой квалификации у разработчиков, что может осложнить процесс разработки и поддержки приложений.

Дополнительные затраты: реализация CQRS может потребовать дополнительных затрат на разработку и поддержку, что может повлиять на бюджет проекта.

Не подходит для всех приложений: CQRS может не подходить для всех типов приложений, особенно для тех, которые не имеют сложной логики команд и запросов или для проектов с небольшими объемами данных.

Пример реализации CQRS


Возьмем за основу фиктивный проект онлайн-магазина, который продает электронику.

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

Для реализации архитектуры CQRS мы разделим нашу систему на две части: командную и запросную.

Write Model:
Командная часть системы (Write Model) отвечает за обработку команд от пользователя, например, создание или обновление заказа. Команды обрабатываются с помощью Command Handlers, которые проверяют корректность данных и сохраняют их в Event Store.

Command Handler для создания заказа:

public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
    private readonly IEventStore _eventStore;

    public CreateOrderCommandHandler(IEventStore eventStore)
    {
        _eventStore = eventStore;
    }

    public async Task HandleAsync(CreateOrderCommand command)
    {
        var order = new Order(command.OrderId, command.UserId, command.Products);
        await _eventStore.SaveAsync(order);
    }
}


Реализация Read Model для получения списка продуктов:

public class ProductReadModel
{
    private readonly IDbConnection _dbConnection;

    public ProductReadModel(IDbConnection dbConnection)
    {
        _dbConnection = dbConnection;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync()
    {
        var products = await _dbConnection.QueryAsync<Product>("SELECT Id, Name, Price FROM Products");
        return products;
    }
}

Event Store — это хранилище событий, которые отображают изменения в системе. Каждое событие представляет собой изменение состояния системы, например, создание заказа или изменение статуса заказа. События сохраняются в хронологическом порядке и могут быть использованы для восстановления состояния системы в любой момент времени.

Код Event Store для сохранения событий:

public class EventStore : IEventStore
{
    private readonly IDbConnection _dbConnection;

    public EventStore(IDbConnection dbConnection)
    {
        _dbConnection = dbConnection;
    }

    public async Task SaveAsync<T>(T @event) where T : IEvent
    {
        await _dbConnection.ExecuteAsync("INSERT INTO Events (Type, Data) VALUES (@type, @data)", new { type = @event.GetType().Name, data = JsonConvert.SerializeObject(@event) });
    }

    public async Task<IEnumerable<T>> GetEventsAsync<T>(Guid aggregateId) where T : IEvent
    {
        var events = await _dbConnection.QueryAsync<Event>("SELECT Type, Data FROM Events WHERE AggregateId = @aggregateId", new { aggregateId });
        return events.Select(e => JsonConvert.DeserializeObject<T>(e.Data));
    }
}

Результаты:
Разделение на командную и запросную части позволило улучшить производительность и масштабируемость системы. Данные в Read Model обновляются только при необходимости, что позволяет избежать частых запросов к базе данных и ускорить отображение данных пользователю. Кроме того, использование Event Sourcing позволяет восстановить состояние системы в любой момент времени.

Заключение


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

Эта статья подготовлена в преддверии старта курса Enterprise Architect. Узнать подробнее о курсе и зарегистрироваться на бесплатный урок можно по этой ссылке.

Комментарии (10)


  1. TsarS
    13.07.2023 09:31
    +4

    Интересно, не думал, что CQRS подразумевает отказ от ORM. Думал, что на инфраструктурном уровне вполне в write модель использовать ее допустимо, так как пишется данных меньше, чем читается. А на read уровне, да, можно просто доставать данные запросом.


    1. dopusteam
      13.07.2023 09:31
      +4

      В CQRS отказываются от ORM (Object-Relational Mapping) в пользу использования агрегатов (Aggregates), которые представляют связанные сущности в приложении и содержат логику их изменения состояния

      Непонятно, как ORM можно заменить агрегатом. Более того, агрегаты - это из DDD, что то не припомню ничего такого в CQRS.

      не думал, что CQRS подразумевает отказ от ORM.

      Выглядит как фантазия автора статьи


      1. nronnie
        13.07.2023 09:31
        +2

        У автора смешалось в кучу CQRS, DDD, и ES (Event Sourcing). Оно часто идет бок о бок, но всё же это отдельные вещи.

        Кроме того:

        Команды обычно обрабатываются с помощью синхронных запросов, ... Запросы, с другой стороны, могут обрабатываться с помощью асинхронных запросов

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


    1. mvgeny
      13.07.2023 09:31

      В реляционной бд агрегаты как раз таки удобнее собирать с помощью орм.

      А вообще CQRS как архитектурный принцип отлично существует и без ddd с его агрегатами и без event sourcing’a. Да, CQRS может быть хорошим помощником при построении event source приложения, но это не одно и то же. Тут статья вводит в заблуждение.


  1. gandjustas
    13.07.2023 09:31
    +2

    Расскажите пожалуйста как в вашем примере не продать дважды один товар?

    Если вы считаете остатки по таблице Events, то у вас не будет преимущества в скорости работы. Если вы остатки пересчитываете асинхронно, то вы сможете продать то, чего нет. Если вы остатки пересчитываете синхронно, то смысл в отдельной write-модели пропадает.


    1. nronnie
      13.07.2023 09:31
      +1

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


      1. gandjustas
        13.07.2023 09:31

        Это первый вариант, по блокировкам и скорости работы он эквивалентен просто хранению остатков в таблице и транзакционном обновлении.


  1. AndrewJD
    13.07.2023 09:31

    Идея CQRS возникла в 2010 году, когда Грег Янг

    Этой идее десятки лет и Янг к ней никакого отношения не имеет.


    1. nronnie
      13.07.2023 09:31

      Возможно, вы путаете с CQS (без "R") - тема похожая, но всё-таки несколько другая. CQS он про дизайн какого-то отдельного API, а CQRS про архитектуру системы в целом.


      1. AndrewJD
        13.07.2023 09:31

        Вот такая модель импользуется в нашем софте с начала 200x, И я не думаю что это была какая-то супер идея на тот момент.