Написать пост

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

Аватарка пользователя Nikita Gerasimov

Рассказываем, что такое паттерн 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 List<Product> products;
        public ProductStore()
        {
            products = new List<Product>()
            {
                new Product() { Id = 1, Name = "Апельсины" },
                new Product() { Id = 2, Name = "Печенье" },
                new Product() { Id = 3, Name = "Молоко" }
            };
        }


        public async Task<IEnumerable<Product>> GetAllProductsAsync() => await Task.FromResult(products);


        public async Task<Product> 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<int> GetLastProductIdAsync() => await Task.FromResult(products.Count > 0 ? products.OrderBy(p => p.Id).Last().Id : 0);
    }
		

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

			builder.Services.AddSingleton<ProductStore>();
		

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

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

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

			public record GetProductsQuery : IRequest<IEnumerable<Product>>;
		

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

			public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, IEnumerable<Product>>
    {
        …
    }
		

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

			private readonly ProductStore _productStore;
public GetProductsQueryHandler(ProductStore productStore)
{
     _productStore = productStore;
}
		

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

			public async Task<IEnumerable<Product>> Handle(GetProductsQuery request, CancellationToken cancellationToken)
        {
            return await _productStore.GetAllProductsAsync();
        }
		

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

			public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, IEnumerable<Product>>
    {
        private readonly ProductStore _productStore;
        public GetProductsQueryHandler(ProductStore productStore)
        {
            _productStore = productStore;
        }
        public async Task<IEnumerable<Product>> 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<IActionResult> GetAllProducts()
        {
            var result = await _mediator.Send(new GetProductsQuery());
            return Ok(result);
        }
    }
		

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

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

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

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

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

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

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

			public record AddProductCommand(Product product) : IRequest<Product>;
		

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

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

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

			public record GetProductByIdQuery(int id) : IRequest<Product>;


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

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

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

			[HttpGet("{id:int}", Name="GetProductById")]
        public async Task<IActionResult> GetProductById(int id)
        {
            var product = await _mediator.Send(new GetProductByIdQuery(id));
            return Ok(product);
        }


        [HttpPost]
        public async Task<IActionResult> AddProduct([FromBody] Product product)
        {
            var productToReturn = await _mediator.Send(new AddProductCommand(product));
            return CreatedAtAction("GetProductById", new { id = productToReturn.Id }, 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.

Следите за новыми постами
Следите за новыми постами по любимым темам
14К открытий16К показов