Cqrs когда использовать
Перейти к содержимому

Cqrs когда использовать

  • автор:

Паттерны CQRS и Event Sourcing

Паттерны CQRS и Event Sourcing

CQRS – Command and Query Responsibility Segregation паттерн

  • Command ориентированы на задачи, а не на данные. («Забронировать номер в отеле», а не установить для ReservationStatus значение «зарезервировано» ).
  • Command может помещаться в очередь для асинхронной обработки, а не обрабатываться синхронно.
  • Query никогда не должен изменять базу данных. Query возвращает DTO, который не инкапсулирует знания предметной области.

Схематически работу клиента по CQRS модели можно отобразить следующим образом:

Наличие отдельных моделей запросов и команд упрощает проектирование таких систем как независимых друг от друга. Однако один из недостатков заключается в том, что код CQRS не может автоматически формироваться из схемы базы данных с помощью ORM или подобных механизмов. Для дополнительной изоляции часто физически разделяют данные для чтения и данные для записи. Как это описано на диаграмме выше. В этом случае в БД для чтения можно оптимизировать ее работу так, чтобы максимально эффективно выполнять запросы. К примеру заюзать materialized view, чтобы не использовать сложные операции join’ов или сложные связи. Вы можете использовать в том числе другой тип хранилища данных. Например, база данных для записи останется реляционной, а для чтения вы можете применять NoSQL или наоборот, в зависимости от бизнес задач.

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

�� Преимущества CQRS

  • Независимое масштабирование. CQRS позволяет раздельно масштабировать рабочие нагрузки чтения и записи, снижая риск конфликтов блокировки.
  • Оптимизированные схемы данных. Для query применить схему, оптимизированную для запросов, а commands — другую схему, оптимизированную для обновлений.
  • Безопасность. Разделение операций позволит настроить более гибкую систему доступа.
  • Разделение проблем. Разделение операций позволяет получить более гибкие и простые в обслуживании классы.
  • Более простые запросы. Сохраняя в базе данных для чтения materialized view, вы предотвратите использование сложных запросов и join’ов.
  • Не требует 2 хранилища данных. Отдельные хранилища для query и command это одна из реализаций, а не обязательное требование

�� Недостатки CQRS

  • Сложность. Основная идея CQRS звучит просто. Но ее реализация может привести к усложнению проекта приложения, особенно если реализовывать его в связке с Event Sourcing.
  • Обмен сообщениями. Сама по себе модель CQRS не требует месседжинга, но месседж брокеры часто применяются для обработки команд и публикации событий. Это означает, нужно будет реализовывать обработку сбоев и дубликатов при передаче сообщений.
  • Eventual consistency. Если вы разделите базы данных для чтения и записи, в базе данных для чтения могут оставаться устаревшие данные. БД для чтения должна быть up to date, чтобы отражать изменения из БД для записи, и может быть трудно трекать, когда пользователь сделал запрос на основе устаревших данных с БД для чтения.

Event Sourcing

Event sourcing (источники событий, регистрация событий, генерация событий) — это архитектурный паттерн, в котором все изменения, вносимые в состояние приложения, сохраняются в той последовательности, в которой они были выполнены. Эти записи служат как источником для получения текущего состояния, так и audit-log’ом того, что происходило в системе. Event sourcing способствует децентрализованному изменению и чтению данных. Такая архитектура хорошо масштабируется и подходит для систем, которые уже работают с событиями или подходят для миграции на такую архитектуру.

Event sourcing идет в ногу с CQRS. «DELUXE» cхема связки CQRS + Event Sourcing выглядит следующим образом:

event sourcing

У Event sourcing есть следующие преимущества и недостатки:

�� Преимущества Event Sourcing

  • События immutable, и их можно сохранить с помощью append-only операции.
  • События могут раниться на фоне.
  • Event sourcing может помочь в предотвращении конфликтов, вызванных параллельными апдейтами, тк исключает необходимость непосредственного обновления объектов в Data store. Однако доменная модель должна уметь себя защищать от запросов, которые могут вызвать несогласованное состояние.
  • Append-only storage предоставляет audit log, который можно использовать для мониторинга событий, произошедших в Data store, повторного создания текущего состояния в виде materialized view или проекций путем воспроизведения событий в любое время, а также упрощения тестирования и отладки системы.
  • Каждое событие могут обрабатывать несколько задач. Это обеспечивает простую интеграцию с другими службами и системами, которые только слушают новые события, вызванные data stor’ом. Однако event sourcing events зачастую являются низкоуровневыми, из-за чего может потребоваться создание определенных событий интеграции.

�� Недостатки CQRS

  • Самые большие сложности обычно связаны с перестроением мышления разработчиков. Разработчики должны забыть про обычные CRUD-приложения и хранилища сущностей. Теперь основной концепцией становятся события.
  • При Event Sourcing много сил тратится на моделирование событий. После сохранения событий в сторедж они должны быть immutable, иначе история и состояние могут быть повреждены или искажены. Event Log — это исходные данные, а это значит, что необходимо очень внимательно следить за тем, чтобы они содержали всю информацию, необходимую для получения полного состояния системы на определенный момент времени. Также необходимо учитывать, что события могут интерпретироваться повторно, поскольку система (и бизнес, который она представляет) со временем изменяются.
  • Для простой бизнес логики переход на Event Sourcing может быть довольно легким, но для более сложных может стать проблемой (особенно с большим количеством зависимостей и отношений между сущностями). Так же могут возникнуть сложности интеграции с внешними системами, которые не предоставляют данные на определенный момент времени.
  • Event Sourcing может работать хорошо в больших системах, так как паттерн «Event Log» естественным образом масштабируется горизонтально. Например, event log одной сущности необязательно должен физически находиться вместе с журналом событий другой сущности. Однако, такая легкость масштабирования приводит к дополнительным проблемам в виде решения проблем и реализацией eventual consistency.
  • Важно учитывать структуру событий. Структура событий может измениться в какой-то момент, например набор полей. Могут возникнуть ситуации, когда исторические события должны быть обработаны текущей бизнес-логикой. И наличие расширяемой схемы событий поможет в будущем при необходимости отличать новые события от старых. Периодические снапшоты также помогают отделить серьезные изменения структуры событий.

Имплементация

Я не стану приводить примеры кода, их множество в интернете, мне кажется важнее показать схемы что б у вас как читателей этой статьи был helicopter-view и как строить такие системы в будущем.

Давайте начнем с такой диаграммы:

event sourcing diagram

Это не то, как вы используете Event Sourcing.

Однако важная часть состоит в том, что события предметной области живут внутри контекста, потому что они являются неотъемлемой частью модели предметной области. Следовательно, события предметной области редко открываются внешнему миру как есть, поскольку они связывают модель предметной области с контрактом службы, поскольку они становятся контрактом. Это последнее, что мы хотим сделать. Разделение событий, отправляемых во внешний мир, публикация этих событий в виде контракта на обслуживание и поддержание их стабильности — это правильное решение.

Еще одна проблема заключается в том, что возможности Event Sourcing в устранении двухфазных коммитов здесь теряются. На диаграмме видно, что сохранение события и его публикация в шине — это две разные операции. Мы всегда сохраняем события в хранилище и используем хранилище как источник событий. Это возможно с продуктами, которые поддерживают потоки событий в реальном времени, например EventStoreDB. Это также лишает нас необходимости в каком-либо event bus внутри службы для построения модели чтения, которая отображена зеленым на диаграмме. Также важно, что чаще всего проекция на стороне чтения должна обрабатывать события по порядку, и ни один продукт, называемый шиной, не может этого сделать.

Вот исправленная, упрощенная схема того, как это должно быть:

 Event Sourcing

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

Когда следует использовать Event Sourcing

  • Когда в данные необходимо записать намерение, цель или причину. Например, изменения в сущности клиента можно записать как ряд определенных типов событий, таких как Возвращение к исходному, Закрытая учетная запись или Недействительные.
  • Когда очень важно свести к минимуму или полностью избежать конфликта операций обновления данных.
  • Если требуется записывать происходящие события и иметь возможность воспроизвести их для восстановления определенного состояния системы, отката изменений или сохранения истории и audit-log. Например, если задача включает несколько шагов, необходимых для восстановления обновлений и последующего воспроизведения некоторых действий для восстановления согласованного состояния данных.
  • Когда использование событий представляет собой стандартную возможность операции приложения и требует некоторой дополнительной разработки или усилий в отношении реализации.
  • Если нужно разбить процесс ввода или обновления данных из задач, необходимых для применения этих действий. Это может быть в целях улучшения производительности пользовательского интерфейса или распределения событий в другие прослушиватели, выполняющие определенные действия при возникновении событий. Например, интеграция платежной системы с веб-сайтом о расходах требуется для того, чтобы события, вызванные с помощью хранилища событий в ответ на обновления данных, реализованные для веб-сайта, использовались как веб-сайтом, так и платежной системой.
  • Если необходима гибкость для изменения формата материализованных моделей и данных сущности при изменении требований или —использовании в сочетании с CQRS, необходимо адаптировать модель чтения или представления с данными.
  • Если используется в сочетании с CQRS, eventual consistency допустима при обновлении модели чтения или допустимо влияние на производительность при восстановленных сущностях и данных из потока события.

Когда не следуюет использовать Event Sourcing

  • Для небольших или простых доменов, систем, которые обычно хорошо взаимодействуют со стандартными механизмами управления данных CRUD.
  • Систем, где для представления данных требуются согласованность и обновления в режиме реального времени.
  • Систем, где для действий отката и воспроизведения не требуются определенные функции, история и audit-log.
  • Систем, где имеется незначительный конфликт обновлений в базовых данных. Например, это системы, которые преимущественно добавляют данные, а не обновляют их.

Выводы

CQRS и Event Sourcing — это интересный подход, имеющий свои преимущества. Одно из которых — упрощение расширения системы в будущем. Поскольку event log хранит все события, то их можно использовать во внешних системах. Довольно легко интегрироваться через добавление новых обработчиков событий.

Помните, что прежде чем интегрировать у себя CQRS и Event Sourcing нужно определить действительно ли вам это нужно, принцип KISS еще никто не отменял.

Однако, ограничения, связанные со сложностью предметной области, требованиями к согласованности и доступности данных, а также увеличение объема хранимых данных и масштабируемость в долгосрочной перспективе — все это необходимо учитывать при выборе этих паттернов. Так же важно уделять внимание разработчикам, которые будут разрабатывать и поддерживать такую ​​систему на протяжении всего SDLC.

Паттерн CQRS — руководство для чайников

Рассказываем, что такое паттерн CQRS (Command Query Responsibility Segregation), зачем он нужен и как внедрить его для своего проекта.

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

И сейчас, спустя время, хочу поделиться с читателями издания Tproger своим видением построения проекта по этому паттерну. Начнем с небольшой теории.

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

Паттерн CQRS — руководство для чайников 1

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

Проще всего это объяснить на примере стандартных CRUD операций. В CQRS операция чтения (Read) будет являться запросом, т.к. с помощью нее получаются данные и ничего более. Остальные же операции (Create, Update, Delete) в данном подходе будут являться командами, которые так или иначе изменяют состояние.

Почему CQRS

Кратко пройдемся по преимуществам данного подхода:

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

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

Подготовка проекта

В рамках статьи разберем простейший пример приложения с применением паттерна CQRS. Для этого создадим ASP.NET Core Web API проект на .NET 6.0

Паттерн CQRS — руководство для чайников 2

В данном примере прибегну к помощи библиотеки MediatR, поэтому добавлю ее в самом начале.

Паттерн CQRS — руководство для чайников 3

И после этого, согласно документации библиотеки, зарегистрируем ее в контейнере зависимостей.

builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); 

Перед тем, как приступить к прописыванию логики, создадим класс, описывающий объект товара, назовем его “Product” и именно над объектами данного класса будем производить необходимые операции.

public class Product < public int Id < get; set; >public string? Name < get; set; >> 

Далее создадим некое подобие контекста базы данных (или репозитория), в котором инкапсулируем работу с объектами класса “Product”. В нашем случае пропишем в этом классе методы для получения списка данных и добавления элемента в этот список.

public class ProductStore < private Listproducts; public ProductStore() < products = new List() < new Product() < Name = "Апельсины" >, new Product() < Name = "Печенье" >, new Product() < Name = "Молоко" >>; > public async Task GetAllProductsAsync() => await Task.FromResult(products); public async Task GetProductByIdAsync(int id) => await Task.FromResult(products.First(p => p.Id == id)); public async Task AddProductAsync(Product product) < products.Add(product); await Task.CompletedTask; >public async Task GetLastProductIdAsync() => await Task.FromResult(products.Count > 0 ? products.OrderBy(p => p.Id).Last().Id : 0); > 

После этого необходимо зарегистрировать наше хранилище.

builder.Services.AddSingleton(); 

Запрос для получения данных

Теперь можно приступить к написанию первых запросов и команд, которые наглядным образом покажут принцип работы с данными в этом подходе. Для начала создадим 3 папки в корне проекта “Queries”, “Commands” и “Handlers”.

Начнем с базового – запроса для получения списка всех продуктов из нашего хранилища. Для этого в папку “Queries” добавим record, который назовем “GetProductsQuery”, он будет реализовывать интерфейс “IRequest” из пространства имен “MediatR”, передав в этот интерфейс параметр, который будет указывать на тип возвращаемых данных при выполнении запроса – в данном случае это “IEnumerable

”. В конечном варианте это будет выглядеть следующим образом:

public record GetProductsQuery : IRequest>; 

Далее необходимо создать класс-обработчик указанного запроса. Для этого в папку “Handlers” добавим класс “GetProductsQueryHandler”. Данный класс должен реализовывать“IRequestHandler ”, где: TCommand – команда, обработчиком которой будет являться описываемый класс (в нашем случае – это “GetProductsQuery”), а TResponse – тип возвращаемого значения данной команды (тот же параметр, который передавали выше интерфейсу IRequest – “IEnumerable

public class GetProductsQueryHandler : IRequestHandler>

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

private readonly ProductStore _productStore; public GetProductsQueryHandler(ProductStore productStore)

Теперь, реализуя интерфейс “IRequestHandler”, создаем метод Handle

public async Task> Handle(GetProductsQuery request, CancellationToken cancellationToken)

В общем виде класс будет выглядеть следующим образом:

public class GetProductsQueryHandler : IRequestHandler> < private readonly ProductStore _productStore; public GetProductsQueryHandler(ProductStore productStore) < _productStore = productStore; >public async Task> Handle(GetProductsQuery request, CancellationToken cancellationToken) < return await _productStore.GetAllProductsAsync(); >> 

Далее пропишем контроллер “ProductsController”. Для отправки команд будем использовать интерфейс “IMediator”, а конкретно его метод Send(), который принимает параметром объект команды. Контроллер с методом для получения списка продуктов будет выглядеть следующим образом:

[Route("api/products")] [ApiController] public class ProductsController : Controller < private readonly IMediator _mediator; public ProductsController(IMediator mediator) < _mediator = mediator; >[HttpGet] public async Task GetAllProducts() < var result = await _mediator.Send(new GetProductsQuery()); return Ok(result); >> 

Запустив приложение можно протестировать работу данного метода через Postman.

Паттерн CQRS — руководство для чайников 4

Как видим, все работает прекрасно. Теперь можно приступить к написанию команд – операций по изменению данных в хранилище.

Команда для добавления данных в хранилище

Создадим команду для добавления продукта в наше хранилище. Принцип остается тем же: создаем команду, создаем обработчик команды и добавляем метод в наш контроллер.

Отличие команды от запроса заключается в том, что необходимо добавить входной параметр – объект, который будем добавлять, а тип возвращаемого значения мы укажем “Product”, чтобы вернуть новый объект.

Итак, собственно, сама команда:

public record AddProductCommand(Product product) : IRequest; 

Обработчик команды по своей структуре абсолютно идентичен обработчику запроса. Единственное, на что стоит обратить внимание – это метод “Handle”. В нем мы берем из хранилища идентификатор крайнего элемента, чтобы дать новому объекту подходящий ID. Наименование товара берем из входного параметра request, который является экземпляром команды “AddProductCommand”. После записи в хранилище, созданный экземпляр с новым ID возвращаем пользователю.

public class AddProductCommandHandler : IRequestHandler  < private readonly ProductStore _productStore; public AddProductCommandHandler(ProductStore productStore) < _productStore = productStore; >public async Task Handle(AddProductCommand request, CancellationToken cancellationToken) < int lastElementId = await _productStore.GetLastProductIdAsync(); int newElId = lastElementId + 1; Product product = new Product() < Name = request.product.Name >; await _productStore.AddProductAsync(product); return product; > > 

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

public record GetProductByIdQuery(int id) : IRequest; public class GetProductByIdQueryHandler : IRequestHandler  < private readonly ProductStore _productStore; public GetProductByIdQueryHandler(ProductStore productStore) < _productStore = productStore; >public async Task Handle(GetProductByIdQuery request, CancellationToken cancellationToken) => await _productStore.GetProductByIdAsync(request.id); > 

И добавим необходимые методы в наш контроллер. Первый – HttpGet метод для получения объекта по id, который получаем из строки запроса, также этому методу задаем параметр “Name” для того, чтобы после создания объекта в HttpPost методе по этому параметру переадресовать клиента для получения созданного продукта.

В HttpPost методе стоит обратить внимание на последнюю строку. В ней вызывается метод “CreatedAtAction”, который используется для создания ответа HTTP 201 Created, который содержит ссылку на вновь созданный ресурс. Этот метод принимает три параметра: имя действия, параметры запроса и объект, который будет возвращен в качестве результата действия.

[HttpGet("", Name="GetProductById")] public async Task GetProductById(int id) < var product = await _mediator.Send(new GetProductByIdQuery(id)); return Ok(product); >[HttpPost] public async Task AddProduct([FromBody] Product product) < var productToReturn = await _mediator.Send(new AddProductCommand(product)); return CreatedAtAction("GetProductById", new < >, productToReturn); > 

Теперь проверим, как это работает с помощью того же Postman.

Паттерн CQRS — руководство для чайников 5

Объект был успешно создан и возвращен с новым id. Код ответа – 201 Created и если посмотрим заголовки ответа, то увидим созданный методом CreatedAtAction заголовок Location.

Паттерн CQRS — руководство для чайников 6

Таким образом, на примере разобрали принцип построения проекта по паттерну CQRS. Данный пример довольно простой и в реальных проектах все намного сложнее, не все разработчики предпочитают использовать библиотеку MediatR, т.к. это замедляет процесс обработки операций, но это уже тема для другой статьи. Здесь же вы могли увидеть наиболее простой для понимания проект, построенный по принципам CQRS.

Итог

Если кратко подытожить, то стоит отметить следующее:

  • CQRS – это подход к проектированию, который разделяет операции над данными на две категории: запросы и команды.
  • Запросы – это операции получения данных, не изменяющие состояние.
  • Команды – это операции изменения состояния системы.
  • Класс (или record), описывающий операцию (будь то команда или запрос), должен имплементировать интерфейс IRequest , где T – тип возвращаемого значения операции. Поля этого класса (или record’a) – входные параметры операции.
  • Класс-обработчик операции должен реализовывать интерфейс IRequestHandler , где TCommand – команда, обработчиком которой является данный класс, TResponse – тип возвращаемого значения. Для работы с данными традиционно в этом классе присутствует свойство, представляющее объект репозитория, с которым работает обработчик, этот объект инициализируется в конструкторе, куда он приходит из контейнера зависимостей.
  • Основная работа с данными производится в методе Handle(TCommand command, CancellationToken token), типом возвращаемого значения которого является Task.
  • Вызов операции из контроллера производится методом Send интерфейса IMediator (объект которого нужно получить из контейнера зависимостей). В качестве параметра в метод Send передается объект класса (или record’a), описывающего операцию.

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

Следите за новыми постами по любимым темам

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

Архитектурный паттерн CQRS

Архитектурный паттерн CQRS

Архитектурный паттерн Command and Query Responsibility Segregation (CQRS) делит действия системы на команды и запросы.

Command Query Separation (CQS) — разделение команд и запросов — это основная концепция, определяющая два типа операций, обрабатываемых в системе: команда, выполняющая задачу и запрос возвращающий информацию. Никогда не должно быть одной функции, выполняющей обе эти задачи.

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

Разделение ответственности команд и запросов (CQRS) — это разделение обязанностей команд и запросов в системе. Это означает, что мы нарезаем логику нашего приложения по вертикали. В дополнение к этому мы отделяем изменение состояния (обработка команд) от извлечения данных (обработка запросов).

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

«Объекты» в исходном определении связаны не с хранилищем, а с обработчиками. Мы создаем разные конвейеры для разных бизнес-поведений, а не отдельное хранилище.

Контекст и проблема

В традиционных архитектурах одна и та же модель данных используется для запроса и обновления базы данных. Это просто и хорошо работает для основных операций CRUD. Однако в более сложных приложениях этот подход может стать громоздким. Например, на стороне чтения приложение может выполнять множество различных запросов, возвращая объекты передачи данных (DTO) с различными формами. Отображение объектов может стать сложным. На стороне записи модель может реализовывать сложную проверку и бизнес-логику. В результате вы можете получить слишком сложную модель, которая делает слишком много.

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

  • Часто существует несоответствие между представлениями данных для чтения и записи, например, дополнительные столбцы или свойства, которые должны быть обновлены, даже если они не требуются как часть операции.
  • Конфликт данных может возникнуть, когда операции выполняются параллельно с одним и тем же набором данных.
  • Традиционный подход может негативно сказаться на производительности из-за нагрузки на хранилище данных и уровень доступа к данным, а также сложности запросов, необходимых для извлечения информации.
  • Управление безопасностью и разрешениями может стать сложным, поскольку каждый объект подвергается операциям чтения и записи, которые могут предоставлять данные в неправильном контексте.

Решение

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

  • Команды должны быть основаны на задачах, а не на данных. («Забронировать номер в гостинице», а не «установить для ReservationStatus значение «Зарезервировано»).
  • Команды могут быть помещены в очередь для асинхронной обработки, а не для синхронной обработки.
  • Запросы никогда не изменяют базу данных. Запрос возвращает DTO, который не инкапсулирует какие-либо знания предметной области.

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

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

Для большей изоляции можно физически отделить данные чтения от данных записи. В этом случае считываемая база данных может использовать собственную схему данных, оптимизированную для запросов. Например, он может хранить материализованное представление данных, чтобы избежать сложных соединений или сложных отображений O/RM. Он может даже использовать другой тип хранилища данных. Например, база данных записи может быть реляционной, а база данных чтения — базой данных документов.

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

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

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

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

К преимуществам CQRS относятся:

  • Независимое масштабирование. CQRS позволяет независимо масштабировать рабочие нагрузки чтения и записи и может привести к меньшему количеству конфликтов за блокировку.
  • Оптимизированные схемы данных. Сторона чтения может использовать схему, оптимизированную для запросов, а сторона записи — схему, оптимизированную для обновлений.
  • Безопасность. Легче обеспечить, чтобы только нужные сущности домена выполняли запись данных.
  • Разделение забот. Разделение сторон чтения и записи может привести к созданию более удобных и гибких моделей. Большая часть сложной бизнес-логики входит в модель записи. Модель чтения может быть относительно простой.
  • Более простые запросы. Сохраняя материализованное представление в базе данных для чтения, приложение может избежать сложных объединений при выполнении запросов.

Проблемы реализации и решения

Некоторые проблемы реализации этого шаблона включают в себя:

  • Сложность. Основная идея CQRS проста. Но это может привести к более сложному дизайну приложений, особенно если они включают шаблон Event Sourcing.
  • xn--90aiufb сообщениями. Хотя CQRS не требует обмена сообщениями, обмен сообщениями обычно используется для обработки команд и публикации событий обновления. В этом случае приложение должно обрабатывать сбои сообщений или повторяющиеся сообщения. Есть руководство по приоритетным очередям для работы с командами, имеющими разные приоритеты.Конечная согласованность. Если вы разделите базы данных для чтения и записи, считанные данные могут быть устаревшими. Хранилище модели чтения должно быть обновлено, чтобы отразить изменения в хранилище модели записи, и может быть трудно определить, когда пользователь выдал запрос на основе устаревших данных ч

Когда использовать шаблон CQRS

Рассмотрите CQRS для следующих сценариев:

  • Совместные домены, в которых множество пользователей получают доступ к одним и тем же данным параллельно. CQRS позволяет вам определять команды с достаточной степенью детализации, чтобы свести к минимуму конфликты слияния на уровне домена, а возникающие конфликты могут быть объединены командой.
  • Пользовательские интерфейсы на основе задач, в которых пользователи проходят сложный процесс в виде последовательности шагов или сложных моделей предметной области. Модель записи имеет полный стек обработки команд с бизнес-логикой, проверкой ввода и бизнес-проверкой. Модель записи может рассматривать набор связанных объектов как единую единицу для изменения данных (агрегат в терминологии DDD) и гарантировать, что эти объекты всегда находятся в согласованном состоянии. Модель чтения не имеет бизнес-логики или стека проверки и просто возвращает DTO для использования в модели представления. Модель чтения в конечном итоге согласуется с моделью записи.
  • Сценарии, в которых производительность чтения данных необходимо настраивать отдельно от производительности записи данных, особенно когда количество операций чтения значительно превышает количество операций записи. В этом сценарии вы можете масштабировать модель чтения, но запускать модель записи только на нескольких экземплярах. Небольшое количество экземпляров модели записи также помогает свести к минимуму возникновение конфликтов слияния.
  • Сценарии, в которых одна группа разработчиков может сосредоточиться на сложной модели предметной области, являющейся частью модели записи, а другая команда может сосредоточиться на модели чтения и пользовательском интерфейсе.
  • Сценарии, в которых ожидается, что система будет развиваться с течением времени и может содержать несколько версий модели, или где бизнес-правила регулярно меняются.
  • Интеграция с другими системами, особенно в сочетании с источниками событий, когда временный сбой одной подсистемы не должен влиять на доступность других.

Этот шаблон не рекомендуется, когда:

  • Домен или бизнес-правила просты.
  • Достаточно простого пользовательского интерфейса в стиле CRUD и операций доступа к данным.
  • Рассмотрите возможность применения CQRS к ограниченным разделам вашей системы, где это будет наиболее ценно.

Event Sourcing и CQRS pattern

Шаблон CQRS часто используется вместе с шаблоном Event Sourcing. Системы на основе CQRS используют отдельные модели данных для чтения и записи, каждая из которых предназначена для соответствующих задач и часто находится в физически отдельных хранилищах. При использовании с шаблоном Event Sourcing хранилище событий является моделью записи и официальным источником информации. Модель чтения системы на основе CQRS обеспечивает материализованные представления данных, как правило, в виде сильно денормализованных представлений. Эти представления адаптированы к интерфейсам и требованиям к отображению приложения, что помогает максимизировать производительность как отображения, так и запросов.

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

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

При использовании CQRS в сочетании с шаблоном Event Sourcing учитывайте следующее:

  • Как и в любой системе, в которой хранилища для записи и чтения разделены, системы, основанные на этом шаблоне, согласуются только в конечном итоге. Будет некоторая задержка между созданием события и обновлением хранилища данных.
  • Шаблон добавляет сложности, поскольку необходимо создать код для инициирования и обработки событий, а также для сборки или обновления соответствующих представлений или объектов, требуемых запросами или моделью чтения. Сложность шаблона CQRS при использовании с шаблоном Event Sourcing может затруднить успешную реализацию и требует другого подхода к проектированию систем. Однако источник событий может упростить моделирование домена и упростить перестроение представлений или создание новых, поскольку цель изменений в данных сохраняется.
  • Создание материализованных представлений для использования в модели чтения или проекциях данных путем воспроизведения и обработки событий для определенных сущностей или коллекций сущностей может потребовать значительного времени обработки и использования ресурсов. Это особенно верно, если требуется суммирование или анализ значений за длительные периоды, поскольку может потребоваться изучить все связанные события. Решите эту проблему, реализуя моментальные снимки данных через запланированные интервалы времени, такие как общее количество выполненных конкретных действий или текущее состояние объекта.

Источники

  • https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs
  • https://www.eventstore.com/cqrs-pattern
  • https://www.dotnetcurry.com/patterns-practices/1461/command-query-separation-cqs
  • https://www.red-gate.com/simple-talk/development/dotnet-development/getting-started-with-cqrs-part-1/ 

CQRS. Факты и заблуждения

CQRS — это стиль архитектуры, в котором операции чтения отделены от операций записи. Подход сформулировал Грег Янг на основе принципа CQS, предложенного Бертраном Мейером. Чаще всего (но не всегда) CQRS реализуется в ограниченных контекстах (bounded context) приложений, проектируемых на основе DDD. Одна из естественных причин развития CQRS — не симметричное распределение нагрузки и сложности бизнес-логики на read и write — подсистемы Большинство бизнес-правил и сложных проверок находится во write — подсистеме. При этом читают данные зачастую в разы чаще, чем изменяют.

Не смотря на простоту концепции, детали реализации CQRS могут значительно отличаться. И это именно тот случай, когда дьявол кроется в деталях.

От ICommand к ICommandHandler

Многие начинают реализацию CQRS с применения паттерна «команда», совмещая данные и поведение в одном классе.

public class PayOrderCommand < public int OrderId < get; set; >public void Execute() < //. >>

Это усложняет сериализацию / десериализацию команд и внедрение зависимостей.

public class PayOrderCommand < public int OrderId < get; set; >public PayOrderCommand(IUnitOfWork unitOfWork) < // WAT? >public void Execute() < //. >>

Поэтому, оригинальную команду делят на «данные» — DTO и поведение «обработчик команды». Таким образом сама «команда» больше не содержит зависимостей и может быть использована как Parameter Object, в т.ч. в качестве аргумента контроллера.

public interface ICommandHandler  < public void Handle(T command) < //. >> public class PayOrderCommand < public int OrderId < get; set; >> public class PayOrderCommandHandler: ICommandHandler  < public void Handle(PayOrderCommand command) < //. >> 

Если вы хотите использовать сущности, а не их Id в командах, чтобы не заниматься валидацией внутри обработчиков, можно переопределить Model Binding, хотя этот подход сопряжен с недостатками. Чуть позже мы рассмотрим, как вынести валидацию, не меняя стандартный Model Binidng.

ICommandHandler должен всегда возвращать void?

Обработчики не занимаются чтением, для этого есть read — подсистема и часть Query, поэтому всегда должны возвращать void . Но как быть с Id, генерируемыми БД? Например, мы отправили команду «оформить заказ». Номеру заказа соответствует его Id из БД. Id нельзя получить, пока запрос INSERT не выполнен. Чего только не придумают люди, что обойти это выдуманное ограничение:

  1. Последовательный вызов CreateOrderCommandHandler , а затем IdentityQueryHandler
  2. Out — параметры
  3. Добавление в команду специального свойства для возвращаемого значения
  4. События
  5. Отказ от автоинкрементных Id в пользу Guid. Guid приходи в теле команды и записывается в БД

Грег Янг четко обозначает свою позицию по этому вопросу (25 минута): «Должен ли обработчик команды всегда возвращать void ? Нет, список ошибок или исключение может быть результатом выполнения». Обработчик может возвращать результат выполнения операции. Он не должен заниматься работой Query — поиском данных, что не значит, что он не может возвращать значение. Главным ограничением на этот счет являются ваши требования к системе и необходимость использования асинхронной модели взаимодействия. Если вы точно знаете, что команда не будет выполнена синхронно, а вместо этого попадет в очередь и будет обработана позже, не рассчитывайте получить Id в контексте HTTP-запроса. Вы можете получить Guid операции и опрашивать статус, предоставить callback или получить ответ по web sockets. В любом случае, void или не void в обработчике – меньшая из ваших проблем. Асинхронная модель заставит изменить весь пользовательский опыт, включая интерфейс (посмотрите, как выглядит поиск авиабилетов на Ozon или Aviasales).

Не стоит рассчитывать, что void в качестве возвращаемого значения позволит использовать одну кодовую базу для синхронной и асинхронной моделей. Отсутствие же значимого возвращаемого результата может вводить в заблуждение потребителей вашего API. Кстати, используя исключения для control flow вы все-равно возвращаете значение из обработчика, просто делаете это неявно, нарушая принцип структурного программирования.

На всякий случай, на одном из DotNext я спросил мнение Дино Эспозито по этому поводу. Он согласен с Янгом: обработчик может возвращать ответ. Это может быть не void , но это должен быть результат операции, а не данные из БД. CQRS – это высокоуровневый концепт, дающий выигрыш в некоторых ситуациях (разные требования к read и write подсистемам), а не догма.

Грань между void и не void еще менее заметна в F#. Значению void в F# соответствует тип Unit . Unit в функциональных языках программирования – своеобразный синглтон без значений. Таким образом разница между void и не void обусловлена технической реализацией, а не абстракцией. Подробнее о void и unit можно прочесть в блоге Марка Симана

А что с Query?

Query в CQRS чем-то может напомнить Query Object. Однако, на деле это разные абстракции. Query Object – специализированный паттерн для формирования SQL c помощью объектной модели. В .NET с появлением LINQ и Expression Trees паттерн утратил свою актуальность. Query в CQRS — это запрос на получение данных в удобном для клиента виде.

По аналогии с Command CommandHandler логично разделить Query и QueryHandler . И в данном случае QueryHandler уже действительно не может возвращать void . Если по запросу ничего не найдено, мы можем вернуть null или использовать Special Case.

Но в чем тогда принципиальная разница между CommandHandler и QueryHandler ? Их сигнатуры одинаковы. Ответ все тот же. Разница в семантике. QueryHandler возвращает данные и не меняет состояние системы. CommandHandler , наоборот меняет состояние и, возможно, возвращает статус операции.

Если одной семантики вам мало, можно внести такие изменения в интерфейс:

public interface IQuery < >public interface IQueryHandler where TQuery : IQuery

Тип TResult дополнительно подчеркивает, что у запроса есть возвращаемое значение и даже связывает его с ним. Эту реализацию я подсмотрел в блоге разработчика Simple Injector’а и соавтора книги Dependency Injection in .NET Стивена ван Дейрсена. В своей реализации мы ограничились заменой названия метода с Handle на Ask , чтобы сразу видеть на экране IDE, что выполняется запрос без необходимости уточнять тип объекта.

public interface IQueryHandler

А нужны ли другие интерфейсы?

В какой-то момент может показаться, что все остальные интерфейсы доступа к данным можно сдать в утиль. Берем несколько QueryHandler’ов , собираем из них хендлер по больше, из них еще больше и так далее. Компоновать QueryHandler’ы имеет смысл только если у вас существуют отдельно use case’ы A и B и вам нужен еще use case, который вернет данные A + B без дополнительных преобразований. По типу возвращаемого значения не всегда очевидно, что вернет QueryHandler . Поэтому легко запутаться в интерфейсах с разными generic-параметрами. Кроме того C# бывает многословным.

public class SomeComplexQueryHandler < IQueryHandler> findUsers; IQueryHandler> getUsers; IQueryHandler> getHighUsage; public SomeComplexQueryHandler( IQueryHandler> findUsers, IQueryHandler> getUsers, IQueryHandler> getHighUsage) < this.findUsers = findUsers; this.getUsers = getUsers; this.getHighUsage = getHighUsage; >>

Удобнее использовать QueryHandler как точку входа для конкретного use case. А для получения данных внутри создавать специализированные интерфейсы. Так код будет более читаемым.

Если идея компоновки маленьких функций в большие не дает вам покоя, то рассмотрите вариант смены языка программирования. В F# эта идея воплощается гораздо лучше.

Можно ли write-подсистеме использовать read-подсистему и наоборот?

Еще один догмат – никогда нельзя перемешивать write и read – подсистемы. Строго говоря, здесь все верно. Если вам захотелось получить данные из QueryHandler внутри обработчика команды, скорее всего это значит, что CQRS в данной подсистеме не нужен. CQRS решает конкретную проблему: read — подсистема не справляется с нагрузками.

Одним из самых популярных вопросов в DDD-группе до недавнего времени был: «Мы используем DDD и у нас тут есть годовой отчет. Когда мы пытаемся его построить наш слой бизнес-логике поднимает в оперативную память агрегаты и оперативная память заканчивается. Как нам быть?». Ясно как: написать оптимизированный SQL-запрос вручную. Это же касается посещаемых веб-ресурсов. Нет нужды поднимать все ООП-великолепие, чтобы получить данные, закешировать и отобразить. CQRS – предлагает отличный водораздел: в обработчиках команд мы используем доменную логику, потому что команд не так много и потому что мы хотим, чтобы были выполнены все проверки бизнес-правил. В read — подсистеме, наоборот, желательно обойти слой бизнес-логики, потому что он тормозит.

Смешивая read и write подсистемы, мы теряем водораздел. Смысл семантической абстракции теряется даже на уровне одного хранилища. В случае, когда read — подсистема использует другое хранилище данных, вообще нет гарантии, что система находится в согласованном состоянии. Раз актуальность данных не гарантирована, теряется смысл проверок бизнес-слоя. Использование write — подсистемы в read — подсистеме вообще противоречит смыслу операции: команды по определению меняют состояние системы, а query – нет.

У каждого правила, впрочем, есть исключения. В том же видео минутой раньше Грег приводит пример: «вам требуется загрузить миллионы сущностей, чтобы сделать расчет. Вы будете грузить все эти данные в оперативную память или выполните оптимальный запрос?». Если в read — подсистеме уже есть подходящий query handler и вы используете один источник данных никто не посадит вас в тюрьму за вызов query из обработчика команды. Просто держите в голове аргументы против этого.

Возвращать из QueryHandler сущности или DTO?

DTO. Если клиенту требуется весь агрегат из БД что-то не так с клиентом. Более того, обычно требуются максимально плоские данные. Вы можете начать используя LINQ и Queryable Extensions или Mapster на этапе прототипирования. И по необходимости заменять реализации QueryHandler на Dapper и / или другое хранилище данных. В Simple Injector есть удобный механизм: можно зарегистрировать все объекты, реализующие интерфейсы открытых дженериков из сборки, а для остальных оставить fallback с LINQ. Один раз написав такую конфигурацию не придется ее редактировать. Достаточно добавить в сборку новую реализацию и контейнер автоматом подхватит. Для других дженериков будет продолжать работать фолбек на LINQ-реализацию. Mapster , кстати не требует создавать профайлы для маппинга. Если вы соблюдаете соглашения в названиях свойств между Entity и Dto проекции будут строиться автоматом.

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

CommandHandler и QueryHandler — холистические абстракции

Т.е. действующие от начала до конца транзакции. Т.е. типовое использование — один хендлер на запрос. Для доступа к данным лучше использовать другие механизмы, например уже упомянутый QueryObject или UnitOfWork . Кстати, это решает проблему с использованием Query из Command и наоборот. Просто используйте QueryObject и там и там. Нарушение этого правила усложняет управление транзакциями и подключением к БД.

Cross Cutting Concerns и декораторы

У CQRS есть одно большое преимущество над стандартной сервисной архитектурой: у нас всего 2 generic-интерфейса. Это позволяет многократно повысить полезность шаблона «декоратор». Есть ряд функций, необходимых любому приложению, но не являющихся бизнес-логикой в прямом смысле: логирование, обработка ошибок, транзакционность и т.п. Традиционно варианта два:

  1. смириться и замусоривать бизнес-логику такими зависимостями и сопутствующим кодом
  2. посмотреть в сторону АОП: с помощью интерцепторов в runtime, например Castle.Dynamic Proxy или переписывая IL на этапе компиляции, например PostSharp
public class ValidationQueryHandlerDecorator : IQueryHandler where TQuery : IQuery  < private readonly IQueryHandlerdecorated; public ValidationQueryHandlerDecorator(IQueryHandler decorated) < this.decorated = decorated; >public TResult Handle(TQuery query) < var validationContext = new ValidationContext(query, null, null); Validator.ValidateObject(query, validationContext, validateAllProperties: true); return this.decorated.Handle(query); >>

Если нет, — можно сделать небольшую оберточку и использовать Result в качестве возвращаемого значения.

 public class ResultQueryHandler : IQueryHandler> < private readonly IQueryHandler_queryHandler; public ResultQueryHandler(IQueryHandler queryHandler) < _queryHandler = queryHandler; >public Result Ask(TSource param) => Result.Succeed(_queryHandler.Ask(param)); > 

SimpleInjector предлагает удобный способ для регистрации открытых generic’ов и декораторов. Всего одной строчкой кода можно вставить логирование перед выполнением, после выполнения, навесить глобальную транзакционность, обработку ошибок, автоматическую подписку на доменные события. Главное не слишком переусердствовать.

Есть определенное неудобство с двумя интерфейсами IQueryHandler и ICommandHandler . Если мы хотим включить логирование или валидацию в обеих подсистемах, то придется написать два декоратора, с одинаковым кодом. Что-ж, это не типичная ситуация. В read-подсистеме, вряд ли потребуется транзакционность. Тем не менее, примеры с валидацией и логированием вполне себе жизненные. Можно решить эту проблему перейдя от интерфейсов к делегатам.

public abstract class ResultCommandQueryHandlerDecorator : IQueryHandler> , ICommandHandler> < private readonly Func> _func; // Хендлеры превращаются в элегантные делегаты protected ResultCommandQueryCommandHandlerDecorator( Func> func) < _func = func; >// Для Query protected ResultCommandQueryCommandHandlerDecorator( IQueryHandler> query) : this(query.Ask) < >// Для Command protected ResultCommandQueryCommandHandlerDecorator( ICommandHandler> query) : this(query.Handle) < >protected abstract Result Decorate( Func> func, TSource value); public Result Ask(TSource param) => Decorate(_func, param); public Result Handle(TSource command) => Decorate(_func, command); >

Да, в этом случае тоже есть небольшой оверхед: придется объявить два класса только для кастинга передаваемого в конструктор параметра. Это тоже можно решить путем усложнения конфигурации IOC-контейнера, но мне проще объявить два класса.

Альтернативный вариант — использовать интерфейс IRequestHandler для Command и Query , а чтобы не путаться использовать naming convention. Такой подход реализован в библиотеке MediatR.

  • Веб-разработка
  • Анализ и проектирование систем
  • .NET
  • Проектирование и рефакторинг
  • C#

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *