Skip to content

使用MediatR Pipeline和FluentValidation进行CQRS校验

Published: at 12:00 AM

使用MediatR Pipeline和FluentValidation进行CQRS校验

摘录

校验是你需要在应用程序中解决的一个基本的横切关注点。你希望在处理请求之前确保请求是有效的。 另一个你需要回答的重要问题是你如何处理不同类型的校验。例如,我认为输入校验和业务校验是不同的,每种都应该有一个具体的解决方案。 我想向你展示一个使用MediatR和FluentValidation进行校验的优雅解决方案。 如果你没有使用CQRS与MediatR,不用担心。我解释的关于校验的所有内容都可以很容易地适应于其他范式。


校验是你需要在应用程序中解决的一个基本的横切关注点。你希望在处理请求之前确保请求是有效的。

另一个你需要回答的重要问题是你如何处理不同类型的校验。例如,我认为输入校验和业务校验是不同的,每种都应该有一个具体的解决方案。

我想向你展示一个使用MediatRFluentValidation.进行校验的优雅解决方案。

如果你没有使用CQRS与MediatR,不用担心。我解释的关于校验的所有内容都可以很容易地适应于其他范式。

以下是我在本周通讯中将讨论的内容:

让我们开始吧。

标准命令校验方法

实现校验的标准方法是在处理命令之前进行校验。校验与命令处理器紧密耦合,这可能会引起问题。

我发现随着校验复杂性的增加,这种方法很难维护。每次对校验逻辑的更改也都会触及处理器,处理器本身可能会失控。

它还使得区分输入和业务校验变得更加困难。

以下是一个ShipOrderCommandHandler的例子,它检查ShippingAddress.Country是否为受支持的国家之一:

internal sealed class ShipOrderCommandHandler
    : IRequestHandler<ShipOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IShippingService _shippingService;
    private readonly ShipmentSettings _shipmentSettings;

    public async Task Handle(
        ShipOrderCommand command,
        CancellationToken cancellationToken)
    {
        if (!_shipmentSettings
                .SupportedCountries
                .Contains(command.ShippingAddress.Country))
        {
            throw new ArgumentException(nameof(ShipOrderCommand.Address));
        }

        var order = _orderRepository.Get(command.OrderId);

        _shippingService.ShipTo(
            command.ShippingAddress,
            command.ShippingMethod);
    }
}

如果我们能将命令校验与命令处理分开会怎样呢?

输入校验与业务校验

我在前一节提到了输入和业务校验。

以下是我认为它们之间的不同之处:

另一种比较它们的方式是成本低与成本高。输入校验通常执行成本低,并且可以在内存中完成。而业务校验涉及到读取状态,速度较慢。

因此,输入校验位于用例的入口点,在处理请求之前。完成后,我们就有了一个有效的命令。这是我一直遵循的规则 - 无效的命令永远不应达到处理器。

使用FluentValidation进行输入校验

FluentValidation是一个用于.NET的出色校验库,它使用流畅的接口和lambda表达式来构建强类型的校验规则。

以下是我们想要校验的ShipOrderCommand

public sealed record ShipOrderCommand : IRequest
{
    public Guid OrderId { get; set; }

    public string ShippingMethod { get; set; }

    public Address ShippingAddress { get; set; }
}

要用FluentValidation实现一个校验器,你需要创建一个继承自AbstractValidator<T>基类的类。然后,你可以从构造函数中使用RuleFor添加校验规则:

public sealed class ShipOrderCommandValidator
    : AbstractValidator<ShipOrderCommand>
{
    public ShipOrderCommandValidator(ShipmentSettings settings)
    {
        RuleFor(command => command.OrderId)
            .NotEmpty()
            .WithMessage("订单标识符不能为空。");

        RuleFor(command => command.ShippingMethod)
            .NotEmpty()
            .WithMessage("运送方式不能为空。");

        RuleFor(command => command.ShippingAddress)
            .NotNull()
            .WithMessage("运送地址不能为空。");

        RuleFor(command => command.ShippingAddress.Country)
            .Must(country => settings.SupportedCountries.Contains(country))
            .WithMessage("不支持的运送国家。");
    }
}

我喜欢使用的命名约定是命令的名称并附加Validator。你也可以通过编写架构测试来强制执行这一点。

要自动从一个程序集注册所有校验器,你需要调用AddValidatorsFromAssembly方法:

services.AddValidatorsFromAssembly(ApplicationAssembly.Assembly);

从用例中运行校验

要运行ShipOrderCommandValidator,你可以使用IValidator<T>服务并从构造函数中注入它。

校验器提供了几个你可以调用的方法,如ValidateValidateAsyncValidateAndThrow

Validate方法返回一个ValidationResult对象,其中包含两个属性:

或者,调用ValidateAndThrow方法将在校验失败时抛出ValidationException异常。

internal sealed class ShipOrderCommandHandler
    : IRequestHandler<ShipOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IShippingService _shippingService;
    private readonly IValidator<ShipOrderCommand> _validator;

    public async Task Handle(
        ShipOrderCommand command,
        CancellationToken cancellationToken)
    {
        _validator.ValidateAndThrow(command);

        var order = _orderRepository.Get(command.OrderId);

        _shippingService.ShipTo(
            command.ShippingAddress,
            command.ShippingMethod);
    }
}

这种方法强制你在每个命令处理器中显式定义对IValidator的依赖。

如果我们能以更通用的方式实现这个横切关注点会怎样呢?

以下是使用FluentValidation和MediatR的IPipelineBehavior完整实现的ValidationBehavior

ValidationBehavior充当请求管道的中间件并执行校验。如果校验失败,它将抛出一个包含ValidationError对象集合的自定义ValidationException异常。

我还想强调使用ValidateAsync的重要性,它允许你定义异步校验规则。如果你有异步规则,你必须调用ValidateAsync方法。否则,校验器将抛出异常。

public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommandBase
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var context = new ValidationContext<TRequest>(request);

        var validationFailures = await Task.WhenAll(
            _validators.Select(validator => validator.ValidateAsync(context)));

        var errors = validationFailures
            .Where(validationResult => !validationResult.IsValid)
            .SelectMany(validationResult => validationResult.Errors)
            .Select(validationFailure => new ValidationError(
                validationFailure.PropertyName,
                validationFailure.ErrorMessage))
            .ToList();

        if (errors.Any())
        {
            throw new Exceptions.ValidationException(errors);
        }

        var response = await next();

        return response;
    }
}

不要忘记通过调用AddOpenBehaviorValidationBehavior注册到MediatR:

services.AddMediatR(config =>
{
    config.RegisterServicesFromAssemblyContaining<ApplicationAssembly>();

    config.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

处理校验异常

以下是只处理自定义ValidationException的自定义ValidationExceptionHandlingMiddleware中间件。它将异常转换为ProblemDetails响应,并包含任何校验错误。

你可以轻松地将其扩展为通用的全局异常处理器。

public sealed class ValidationExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ValidationExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exceptions.ValidationException exception)
        {
            var problemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Type = "ValidationFailure",
                Title = "校验错误",
                Detail = "发生了一个或多个校验错误"
            };

            if (exception.Errors is not null)
            {
                problemDetails.Extensions["errors"] = exception.Errors;
            }

            context.Response.StatusCode = StatusCodes.Status400BadRequest;

            await context.Response.WriteAsJsonAsync(problemDetails);
        }
    }
}

你还需要通过调用UseMiddleware将中间件包含在请求管道中:

app.UseMiddleware<ExceptionHandlingMiddleware>();

结论

这种ValidationBehavior的实现是我在真实项目中使用的,它非常有效。如果我不想抛出异常,我可以更新ValidationBehavior以返回结果对象代替。

如果你不使用MediatR怎么办?

我正在使用IPipelineBehavior,它允许我实现一个中间件封装每个请求。

所以,你需要的只是一种实现中间件的方式,并将你的校验放入其中。而且我喜欢有选择,所以在这里有三种在ASP.NET Core中创建中间件的方法。