Скорость разработки и производительность программистов могут отличаться в зависимости от их уровня и используемых в проектах технологий. Для проектирования ПО нет стандартов и ГОСТов, только вы выбираете, как будете разрабатывать свою программу. Один из лучших способов повысить эффективность работы — применить шаблон проектирования CQRS.
Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. В этой статье я расскажу о первом — классическом паттерне Regular CQRS, который мы используем в DD Planet в рамках разработки онлайн-сервиса «Выберу.ру». Progressive и Deluxe — более сложные архитектуры и влекут за собой использование обширного набора абстракций.
Я поделюсь опытом своей команды: как мы применили паттерн CQRS в бизнес-приложениях и беспроблемно внедрили его в существующие проекты, не переписывая тысячи строк кода.
Чтобы было понятно, для чего нужен паттерн CQRS, сначала рассмотрим, как выглядит классическая архитектура приложения.
Классическая «луковая» архитектура состоит из нескольких слоев:
Это идеальная архитектура, которая существует множество лет, но она не создает ограничений для связанности компонентов.
Так произошло с нашим сайтом «Выберу.ру». Мы получили спагетти-код, в котором связанность была на очень высоком уровне. Новые разработчики приходили в шок, когда его видели. Самое страшное, что могло случиться — введение нового сотрудника в приложение. Объяснить, что и почему, казалось просто невозможным.
И мы подошли к моменту, когда перед нами встала важная задача устранить этот недостаток — инкапсулировать доменную логику в одном месте, уменьшить связанность и улучшить связность. Мы начали искать новые паттерны проектирования и остановились на CQRS.
CQRS (Command Query Responsibility Segregation) — это шаблон проектирования, который разделяет операции на две категории:
запросы — не изменяют состояние, только получают данные.
Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни.
Обратите внимание, что это не паттерн кодирования, это паттерн проектирования. В разных компаниях этот паттерн используют по-разному, мы используем его в нашей команде «Выберу.ру», чтобы решить нескольких задач:
Благодаря CQRS мы получаем архитектуру, в которой все аккуратно разложено и понятно (меньше связанность, больше связности), человек может открыть код команды или запроса, увидеть все его зависимости, понять, что он делает, и продолжать работать над ним в рамках только этой команды/запроса, без копания в других частях программы.
Хочу поделиться, как мы используем шаблон CQRS на практике, и наглядно показать его плюсы.
Мы используем ASP.NET Core 5.0, поэтому примеры реализации паттерна будут в контексте этого фреймворка.
Помимо встроенных механизмов ASP.NET Core 5.0, нам понадобятся еще две библиотеки:
Наши команды и запросы очень хорошо ложатся на REST API:
чтобы добавить библиотеку в наш проект, выполним в консоли команду:
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Далее зарегистрируем все компоненты нашей библиотеки в методе ConfigureServices класса Startup:
namespace CQRS.Sample
{
public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
...
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddControllers();
...
}
}
}
После мы напишем первую команду, пусть это будет команда добавления нового продукта в нашу базу анных. Сначала реализуем интерфейс команды, отнаследовавшись от встроенного в MediatR интерфейса IRequest<TResponse>, в нем мы опишем параметры команды и что она будет возвращать.
namespace CQRS.Sample.Features
{
public class AddProductCommand : IRequest
{
///
/// Алиас продукта
///
public string Alias { get; set; }
///
/// Название продукта
///
public string Name { get; set; }
///
/// Тип продукта
///
public ProductType Type { get; set; }
}
}
Далее нам нужно реализовать обработчик нашей команды с помощью IRequestHandler<TCommand, TResponse>.
В конструкторе обработчика мы объявляем все зависимости, которые нужны нашей команде, и пишем бизнес-логику, в этом случае — сохранение сущности в БД.
namespace CQRS.Sample.Features
{
public class AddProductCommand : IRequest
{
///
/// Алиас продукта
///
public string Alias { get; set; }
///
/// Название продукта
///
public string Name { get; set; }
///
/// Тип продукта
///
public ProductType Type { get; set; }
public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>
{
private readonly IProductsRepository _productsRepository;
public AddProductCommandHandler(IProductsRepository productsRepository)
{
_productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));
}
public async Task Handle(AddProductCommand command, CancellationToken cancellationToken)
{
Product product = new Product();
product.Alias = command.Alias;
product.Name = command.Name;
product.Type = command.Type;
await _productsRepository.Add(product);
return product;
}
}
}
}
Чтобы вызвать исполнение нашей команды, мы реализуем Action в нужном контроллере, пробросив интерфейс IMediator как зависимость. В качестве параметров экшена мы передаем нашу команду, чтобы механизм привязки ASP Core смог привязать тело запроса к нашей команде. Теперь достаточно отправить команду через MediatR и вызвать обработчик нашей команды.
namespace CQRS.Sample.Controllers
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly ILogger _logger;
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
...
///
/// Создание продукта
///
///
///
///
///
[HttpPost]
[ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]
[ProducesDefaultResponseType]
public async Task Post([FromBody] AddProductCommand client, ApiVersion apiVersion,
CancellationToken token)
{
Product entity = await _mediator.Send(client, token);
return CreatedAtAction(nameof(Get), new {id = entity.Id, version = apiVersion.ToString()}, entity);
}
}
}
Благодаря возможностям MediatR мы можем делать самые разные декораторы команд, которые будут выполняться по принципу конвейера, по сути, тот же принцип реализуют Middlewares в ASP.Net Core. Например, мы можем сделать более сложную валидацию для команд или добавить логирование выполнения команд.
Нам удалось упростить написание валидации команд с помощью FluentValidation.
Добавим FluentValidation в наш проект:
dotnet add package FluentValidation.AspNetCore
Создадим Pipeline для валидации:
И зарегистрируем его с помощью DI, добавим инициализацию всех валидаторов для FluentValidation.
namespace CQRS.Sample
{
public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
...
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
...
}
}
}
Теперь напишем наш валидатор.
public class AddProductCommandValidator : AbstractValidator
{
public AddProductCommandValidator()
{
RuleFor(c => c.Name).NotEmpty();
RuleFor(c => c.Alias).NotEmpty();
}
}
Благодаря возможностям C#, FluentValidation и MediatR нам удалось инкапсулировать логику нашей команды/запроса в рамках одного класса.
namespace CQRS.Sample.Features
{
public class AddProductCommand : IRequest
{
///
/// Алиас продукта
///
public string Alias { get; set; }
///
/// Название продукта
///
public string Name { get; set; }
///
/// Тип продукта
///
public ProductType Type { get; set; }
public class AddProductCommandHandler : IRequestHandler<AddProductCommand, Product>
{
private readonly IProductsRepository _productsRepository;
public AddProductCommandHandler(IProductsRepository productsRepository)
{
_productsRepository = productsRepository ?? throw new ArgumentNullException(nameof(productsRepository));
}
public async Task Handle(AddProductCommand command, CancellationToken cancellationToken)
{
Product product = new Product();
product.Alias = command.Alias;
product.Name = command.Name;
product.Type = command.Type;
await _productsRepository.Add(product);
return product;
}
}
public class AddProductCommandValidator : AbstractValidator
{
public AddProductCommandValidator()
{
RuleFor(c => c.Name).NotEmpty();
RuleFor(c => c.Alias).NotEmpty();
}
}
}
}
Это сильно упростило работу с API и решило все основные задачи.
На выходе получился красивый инкапсулированный код, понятный всем сотрудникам. Так, мы можем быстро ввести человека в процесс разработки, сократить затраты и время на его реализацию.
Текущие результаты можно посмотреть на GitHub.