Скорость разработки и производительность программистов могут отличаться в зависимости от их уровня и используемых в проектах технологий. Для проектирования ПО нет стандартов и ГОСТов, только вы выбираете, как будете разрабатывать свою программу. Один из лучших способов повысить эффективность работы — применить шаблон проектирования CQRS.
Существует три вида паттерна CQRS: Regular, Progressive и Deluxe. В этой статье я расскажу о первом — классическом паттерне Regular CQRS, который мы используем в DD Planet в рамках разработки онлайн-сервиса «Выберу.ру». Progressive и Deluxe — более сложные архитектуры и влекут за собой использование обширного набора абстракций.
Я поделюсь опытом своей команды: как мы применили паттерн CQRS в бизнес-приложениях и беспроблемно внедрили его в существующие проекты, не переписывая тысячи строк кода.
Классический Onion
Чтобы было понятно, для чего нужен паттерн CQRS, сначала рассмотрим, как выглядит классическая архитектура приложения.
Классическая «луковая» архитектура состоит из нескольких слоев:
Доменный слой — наши сущности и классы.
Слой бизнес-логики, где происходит вся обработка доменной логики.
Слой приложения — логика самого приложения.
Внешние слои: слой UI, базы данных или тестов.
Это идеальная архитектура, которая существует множество лет, но она не создает ограничений для связанности компонентов.
Так произошло с нашим сайтом «Выберу.ру». Мы получили спагетти-код, в котором связанность была на очень высоком уровне. Новые разработчики приходили в шок, когда его видели. Самое страшное, что могло случиться — введение нового сотрудника в приложение. Объяснить, что и почему, казалось просто невозможным.
И мы подошли к моменту, когда перед нами встала важная задача устранить этот недостаток — инкапсулировать доменную логику в одном месте, уменьшить связанность и улучшить связность. Мы начали искать новые паттерны проектирования и остановились на CQRS.
CQRS
Определение и задачи
CQRS (Command Query Responsibility Segregation)— это шаблон проектирования, который разделяет операции на две категории:
команды— изменяют состояние системы;
запросы— не изменяют состояние, только получают данные.
Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни.
Обратите внимание, что это не паттерн кодирования, это паттерн проектирования. В разных компаниях этот паттерн используют по-разному, мы используем его в нашей команде «Выберу.ру», чтобы решить нескольких задач:
повысить скорость разработки нового функционала без ущерба для существующего;
снизить время подключения нового работника к проекту;
уменьшить количество багов;
упростить написание тестов;
повысить качество планирования разработки.
Благодаря CQRS мы получаем архитектуру, в которой все аккуратно разложено и понятно (меньше связанность, больше связности), человек может открыть код команды или запроса, увидеть все его зависимости, понять, что он делает, и продолжать работать над ним в рамках только этой команды/запроса, без копания в других частях программы.
Практика
Хочу поделиться, как мы используем шаблон CQRS на практике, и наглядно показать его плюсы.
Мы используем ASP.NET Core 5.0, поэтому примеры реализации паттерна будут в контексте этого фреймворка.
Помимо встроенных механизмов ASP.NET Core 5.0, нам понадобятся еще две библиотеки:
MediatR— небольшая библиотека, помогающая реализовать паттерн Mediator, который нам позволит производить обмен сообщениями между контроллером и запросами/командами без зависимостей.
FluentValidation— небольшая библиотека валидации для .NET, которая использует Fluent-интерфейс и лямбда-выражения для построения правил валидации.
Реализация REST API с помощью CQRS
Наши команды и запросы очень хорошо ложатся на REST API:
get — это всегда запросы;
post, put, delete — команды.
Добавление и настройка MediatR:
Чтобы добавить библиотеку в наш проект, выполним в консоли команду:
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<Product>
{
/// <summary>
/// Алиас продукта
/// </summary>
public string Alias { get; set; }
/// <summary>
/// Название продукта
/// </summary>
public string Name { get; set; }
/// <summary>
/// Тип продукта
/// </summary>
public ProductType Type { get; set; }
}
}
Далее нам нужно реализовать обработчик нашей команды с помощью IRequestHandler<TCommand, TResponse>.
В конструкторе обработчика мы объявляем все зависимости, которые нужны нашей команде, и пишем бизнес-логику, в этом случае — сохранение сущности в БД.
namespace CQRS.Sample.Features
{
public class AddProductCommand : IRequest<Product>
{
/// <summary>
/// Алиас продукта
/// </summary>
public string Alias { get; set; }
/// <summary>
/// Название продукта
/// </summary>
public string Name { get; set; }
/// <summary>
/// Тип продукта
/// </summary>
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<Product> 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.Net Core смог привязать тело запроса к нашей команде. Теперь достаточно отправить команду через MediatR и вызвать обработчик нашей команды.
namespace CQRS.Sample.Controllers
{
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly ILogger<ProductsController> _logger;
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
...
/// <summary>
/// Создание продукта
/// </summary>
/// <param name="client"></param>
/// <param name="apiVersion"></param>
/// <param name="token"></param>
/// <returns></returns>
[HttpPost]
[ProducesResponseType(typeof(Product), StatusCodes.Status201Created)]
[ProducesDefaultResponseType]
public async Task<IActionResult> 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 для валидации:
namespace CQRS.Sample.Behaviours
{
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<ValidationBehaviour<TRequest, TResponse>> _logger;
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidationBehaviour<TRequest, TResponse>> logger)
{
_validators = validators;
_logger = logger;
}
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
RequestHandlerDelegate<TResponse> next)
{
if (_validators.Any())
{
string typeName = request.GetGenericTypeName();
_logger.LogInformation("----- Validating command {CommandType}", typeName);
ValidationContext<TRequest> context = new ValidationContext<TRequest>(request);
ValidationResult[] validationResults =
await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
List<ValidationFailure> failures = validationResults.SelectMany(result => result.Errors)
.Where(error => error != null).ToList();
if (failures.Any())
{
_logger.LogWarning(
"Validation errors - {CommandType} - Command: {@Command} - Errors: {@ValidationErrors}",
typeName, request, failures);
throw new CQRSSampleDomainException(
$"Command Validation Errors for type {typeof(TRequest).Name}",
new ValidationException("Validation exception", failures));
}
}
return await next();
}
}
}
И зарегистрируем его с помощью 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<AddProductCommand>
{
public AddProductCommandValidator()
{
RuleFor(c => c.Name).NotEmpty();
RuleFor(c => c.Alias).NotEmpty();
}
}
Благодаря возможностям C#, FluentValidation и MediatR нам удалось инкапсулировать логику нашей команды/запроса в рамках одного класса.
namespace CQRS.Sample.Features
{
public class AddProductCommand : IRequest<Product>
{
/// <summary>
/// Алиас продукта
/// </summary>
public string Alias { get; set; }
/// <summary>
/// Название продукта
/// </summary>
public string Name { get; set; }
/// <summary>
/// Тип продукта
/// </summary>
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<Product> 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<AddProductCommand>
{
public AddProductCommandValidator()
{
RuleFor(c => c.Name).NotEmpty();
RuleFor(c => c.Alias).NotEmpty();
}
}
}
}
Это сильно упростило работу с API и решило все основные задачи.
На выходе получился красивый инкапсулированный код, понятный всем сотрудникам. Так, мы можем быстро ввести человека в процесс разработки, сократить затраты и время на его реализацию.
Текущие результаты можно посмотреть на GitHub.