冠军

导航

基于 MediatR 和 FluentValidation 的 CQRS 验证管线

基于 MediatR 和 FluentValidation 的 CQRS 验证管线

CQRS Validation Pipeline with MediatR and FluentValidation - Code Maze (code-maze.com)

示例代码地址:https://github.com/CodeMazeBlog/cqrs-validation-mediatr-fluentvalidation/

让我们直接开始。

1. 什么是 CQRS?

CQRS 或者说命令查询职责分离,在近年来变得越来越流行。在 CQRS 背后的思想是将你的应用程序的逻辑处理流程拆分为两个独立的处理流程,一个处理改变,也就是 Command,另一个处理查询,也就是 Query 流程。

Command 用于改变应用程序的状态,如果我们谈论的是 CRUD ( 增、删、改、查 ) 的话,Command 对应的是增、删和修改。

而 Query 则用来获取应用程序的信息,自然而然,它对应的部分是查询。

如果要学习如何在 ASP.NET Core 应用程序中实现基于 MediatR 的 CQRS,那么请查阅 在 ASP.NET Core 中应用 MediatR 和 CQRS。你应该已经熟悉了 CQRS 和 MediatR 来继续阅读本文,所以,如果还没有的话,我们强烈建议你先阅读上面链接的文章。

1.1 CQRS 的优点和缺点

使用 CQRS 有哪些优点呢?为什么我们考虑在应用程序中使用它呢?

CQRS 的优点:

  • 单一职责:Command 和 Query 只需要负责一个任务。或者是改变应用程序的状态,或者是获取应用程序的状态。进而,它们变得非常容易理解
  • 解耦:Command 或者 Query 完全从处理器中解耦出来,从处理器层面给予你充分的灵活性,以最适合你的方式来实现它
  • 扩展性:在如何组织你的数据存储上,CQRS 模式可以非常灵活,给予你巨大的扩展空间,你既可以使用单一一个数据库来处理 Command 和 Query 两者,也可以对读和写使用各自独立的数据库,以改进性能,通过消息通讯或者数据库复制在数据库之间进行同步。
  • 可测试性:由于设计已经变得简单,非常容易测试 Command 或者 Query 的处理器,只需要执行一个任务即可。

当然,不会全是优点,下面是 CQRS 的一些缺点:

  • 复杂性:CQRS 是高级的设计模式,它需要你花费时间才能完全理解。它引入了一系列的复杂性,导致项目的摩擦和潜在问题。在真正在你的项目中使用它之前,确保你真的完全理解它。
  • 学习曲线:尽管看起来是很简单的设计模式,对于 CQRS 仍然存在陡峭的学习曲线。大多数的开发人员熟悉过程式 ( 命令式 ) 的代码风格,而 CQRS 与此非常不同。
  • 难以调试:由于 Command 和 Query 从它们的处理器中解耦出来,这样就不再存在应用程序中自然的命令处理流。这导致它比传统的应用程序难以调试。

2. 使用 MediatR 实现 Command 和 Query

前面我们已经介绍了 Command 和 Query,现在我们看一看如何使用 MediatR 来实现它们。

MediatR 使用 IRequest 接口来表示 Command 或者 Query。对于我们的场景,我们将创建对于 Command 和 Query 分别的抽象。

在 NuGet 包 MediatR.Contracts 中,定义了 MediatR 的接口抽象。

首先,让我们先定义 ICommand 接口来抽象 Command。

using MediatR;

namespace Application.Abstractions.Messaging
{
    public interface ICommand<out TResponse> : IRequest<TResponse>
    {
    }
}

然后,定义 IQuery 接口来表示 Query 的抽象。

using MediatR;

namespace Application.Abstractions.Messaging
{
    public interface IQuery<out TResponse> : IRequest<TResponse>
    {
    }
}

这里我们对泛型类型 TResponse 使用了 out 关键字,这表示它是协变的。这支持我们可以使用各种派生类型,而不只是特定的泛型参数。对于协变和逆变的更多知识,请参考微软的文档

另外,我们还需要对 Command 和 Query 的处理器分别进行抽象,为了完整性。让我们检查一下它们:

Command 的处理器

using MediatR;

namespace Application.Abstractions.Messaging
{
    public interface ICommandHandler<in TCommand, TResponse> : IRequestHandler<TCommand, TResponse>
        where TCommand : ICommand<TResponse>
    {
    }
}

Query 的处理器

using MediatR;

namespace Application.Abstractions.Messaging
{
    public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
        where TQuery : IQuery<TResponse>
    {
    }
}

为什么我们不嫌麻烦,定义定制的 Command 和 Query 处理器,而不使用 MediaR 已经提供的通用处理器呢?MediatR 提供的 IRequest 接口还不够吗?

使用定制的 Command 和 Query 抽象,这种方式可以在未来提供更多的灵活性。考虑一下,如果你希望增强你的 Command 或者 Query 以提供更多的特性呢?

这里有一个简单的示例,我们希望所有的 Command 都是幂等的,幂等意味着它们只能执行一次。

现在,你就可以扩展 ICommand 接口,创建一个新的幂等命令接口 IIdempotentCommand

public interface IIdempotentCommand<out TResponse> : ICommand<TResponse>
{
    Guid RequestId { get; set; }
}

随后,基于该接口实现某些确保幂等的逻辑。不过这确实非常复杂,以后我们再讨论它。

另外,对 Command 和 Query 使用附加的抽象,给予我们使用 MediatR 的处理管道执行过滤的能力,在下一节我们就可以看到。

3. 使用 FluentValidation 实现验证

FluentValidation 库支持我们对自定义的类型,可以简单地定义非常丰富的自定义验证。由于我们正在实现 CQRS,对命令定义验证是很有意义的。

我们不应该为 Query 定义验证程序而烦恼,因为它们不包含任何行为。我们仅使用查询从应用程序获取数据。

例如,让我们看一下 UpdateUserCommand

public sealed record UpdateUserCommand(int UserId, string FirstName, string LastName) : ICommand<Unit>

我们将使用这个 Command 来更新已有用户的姓名。

下面我们实现对于这个 UpdateUserCommand 的验证器

public sealed class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
    public UpdateUserCommandValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();

        RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);

        RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
    }
}

使用这个 UpdateUserCommandValidator,我们希望确保该 Command 的参数不会是空,并且姓名的最大长度不会支持的最大长度。

就这样,非常简单。

在本文中,我们不会再进一步深入到 FluentValidation 库。要是你并不熟悉它,或者希望进一步学习它,请学习 在 ASP.NET Core 中的 FluentValidation

3. 使用 MediatR PipelineBehavior 创建装饰器

CQRS 模式使用 Command 或者 Query 来传递参数,然后获得响应。实质上来说,它表示一个 请求 - 响应 管道。 这支持我们可以更加容易地针对每个穿过管道的请求,引入额外的处理行为的能力,而不需要我们修改原始的请求。

你可能已经基于名称熟悉装饰器模式的技术。另一个使用使用装饰器模式的例子来自 ASP.NET Core 中的中间件概念。

MediaR 有一个类似于中间件的概念,它称为 IPipelineBehavior

public interface IPipelineBehavior<in TRequest, TResponse> where TRequest : notnull
{
        Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next);
}

这个 Pipeline Behavior 是针对 Request 实例的封装,给予你如何实现它的各种灵活性。管道行为非常适合应用程序中面向切面的思想。比较好的面向切面的例子是日志、缓存,当然了,还有验证。

4. 创建验证管道行为

为了实现我们 CQRS 中的验证,我们将使用刚刚讨论的概念,使用 MediatRIPipelineBehavior 和 FluentValidation。

首先看一看 ValidationBehavior 的实现。

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

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

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        if (!_validators.Any())
        {
            return await next();
        }

        var context = new ValidationContext<TRequest>(request);

        var errorsDictionary = _validators
            .Select(x => x.Validate(context))
            .SelectMany(x => x.Errors)
            .Where(x => x != null)
            .GroupBy(
                x => x.PropertyName,
                x => x.ErrorMessage,
                (propertyName, errorMessages) => new
                {
                    Key = propertyName,
                    Values = errorMessages.Distinct().ToArray()
                })
            .ToDictionary(x => x.Key, x => x.Values);

        if (errorsDictionary.Any())
        {
            throw new ValidationException(errorsDictionary);
        }

        return await next();
    }
}

现在,我们介绍在 ValidationBEhavior 的实现。

首先,注意到我们使用了 where 子句应用于实现了 IPipelineBehavior 的类型上,限制 TRequest 必须实现了接口 ICommand<TRequest>。这样,我们只允许 Command 类型穿过该管道。记住,我们前面提到过,我们只对 Command 进行验证,这就是如何实现。

然后,你看到我们通过构造函数注入了一个 IValidator 的集合。FluentValidation 库将扫描在我们项目中针对特定类型实现的所有 AbstractValidator 实现,并在运行时提供出来,这就是我们为什么可以在项目中应用实际的验证器的原因。

最后,如果有任何验证错误出现,我们就会抛出 ValidationException 异常,它包含一个验证错误信息的字典。当因为验证错误抛出异常的时候,管道将会短路,并防止进一步的执行。这里缺失的一块就是,在应用程序的更高层级处理异常,并提供更有意义的表示给消费者。下一节我们就处理这个问题。

5. 处理验证异常

为了处理 ValidationException,它会在我们遇到验证错误的时候跑出来。我们可以使用 ASP.NET Core 的中间件接口,实现一个全局的异常处理器。

internal sealed class ExceptionHandlingMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger) => _logger = logger;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception e)
        {
            _logger.LogError(e, e.Message);

            await HandleExceptionAsync(context, e);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception)
    {
        var statusCode = GetStatusCode(exception);

        var response = new
        {
            title = GetTitle(exception),
            status = statusCode,
            detail = exception.Message,
            errors = GetErrors(exception)
        };

        httpContext.Response.ContentType = "application/json";

        httpContext.Response.StatusCode = statusCode;

        await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response));
    }

    private static int GetStatusCode(Exception exception) =>
        exception switch
        {
            BadRequestException => StatusCodes.Status400BadRequest,
            NotFoundException => StatusCodes.Status404NotFound,
            ValidationException => StatusCodes.Status422UnprocessableEnttity,
            _ => StatusCodes.Status500InternalServerError
        };

    private static string GetTitle(Exception exception) =>
        exception switch
        {
            ApplicationException applicationException => applicationException.Title,
            _ => "Server Error"
        };

    private static IReadOnlyDictionary<string, string[]> GetErrors(Exception exception)
    {
        IReadOnlyDictionary<string, string[]> errors = null;

        if (exception is ValidationException validationException)
        {
            errors = validationException.ErrorsDictionary;
        }

        return errors;
    }
}

6. 设置依赖注入

在我们可以运行应用程序之前,我们需要确保在依赖注入容器中注入了所有需要的服务。

首先看一下,如何基于 MediatR 注册我们的 Command 和 Query,在 StartUp 类中的 ConfigureService() 方法中,我们填充如下代码:

services.AddMediatR(typeof(Application.AssemblyReference).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

在 .NET 6 中,我们需要修改 Program 类。

builder.Services.AddMediatR(typeof(Application.AssemblyReference).Assembly); 
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

第一行调用将会扫描我们的 Application 程序集并添加所有的 Command、Query 和它们相应的处理器到 DI 容器中。

第二行方法调用是我们的 ValidationBehavior 注册步骤,没有它,验证管线就完全不会执行。

然后,我们需要确保注册我们使用 FluentValidation 定义的验证器:

services.AddValidatorsFromAssembly(typeof(Application.AssemblyReference).Assembly);

//Or in .NET 6 and above

builder.Services.AddValidatorsFromAssembly(typeof(Application.AssemblyReference).Assembly);

最后,我们需要在 ConfigureService() 方法中注册定义的全局异常处理器。

services.AddTransient<ExceptionHandlingMiddleware>();

//or in .NET 6 and above

builder.Services.AddTransient<ExceptionHandlingMiddleware>();

Configure() 方法中,或者在 .NET 6 的 Program 类中,注册到 ASP.NET Core 的请求处理管道中

app.UseMiddleware<ExceptionHandlingMiddleware>();

就是这样,我们完成了在依赖注入容器中注册服务。现在所有的部分已经就绪,我们可以测试我们的实现了。

7. 使用验证管道

现在,我们可以在项目 Presentation 中的 UsersController 中使用它。

/// <summary>
/// The users controller.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class UsersController : ControllerBase
{
    private readonly ISender _sender;

    /// <summary>
    /// Initializes a new instance of the <see cref="UsersController"/> class.
    /// </summary>
    /// <param name="sender"></param>
    public UsersController(ISender sender) => _sender = sender;

    /// <summary>
    /// Updates the user with the specified identifier based on the specified request, if it exists.
    /// </summary>
    /// <param name="userId">The user identifier.</param>
    /// <param name="request">The update user request.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>No content.</returns>
    [HttpPut("{userId:int}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> UpdateUser(int userId, [FromBody] UpdateUserRequest request, CancellationToken cancellationToken)
    {
        var command = request.Adapt<UpdateUserCommand>() with
        {
            UserId = userId
        };

        await _sender.Send(command, cancellationToken);

        return NoContent();
    }
}

上面的代码并不是 UserController 的完全实现,这里我们只关注更新用户的端点,完整的实现请查阅:our source code repository

可以看到 UpdateUser() 方法非常简单,它通过路由收集用户的标识,从请求体中获得用户名称,然后创建一个新的 UpdateUserCommand 实例,随后通过管线发送了这个命令,最后返回一个 204 - 没有内容的响应。

在 API 端点开发中,我们完全不顾虑验证问题,这个问题由 ValidationBehavior 处理。

通过 Swagger 界面发送一个请求

这里是服务器返回的响应内容

一旦我们提供正确的请求体,重新发布请求。

响应提示我们,数据库中没有对应的用户。

如果我们使用存在的用户标识,我们就会得到期望的响应 204,没有内容的状态码。

8. 总结

在本文中,我们学习两使用 CQRS 模式的高级概念,以及如何基于面向切面的编程实现验证问题。

我们从说明 CQRS 是什么开始,它的优点和缺点。然后我们学习如何使用 MediatR 库来创建 Command 和 Query。

然后,我们总结了如何使用 FluentValidation 库来验证我们创建的 Command

我们还学习了如何实现 ValidationBehavior 来封装以面向切面方式编程。我们说明了为什么我们仅仅希望验证 Command,以及如何在管道中实现过滤来不对 Query 进行验证。

最后,我们展示了如何将它与 ExceptionHandlingMiddleware 集成,以及如何通过依赖注入容器将这些内容变成整体。

posted on 2023-03-17 20:51  冠军  阅读(193)  评论(0编辑  收藏  举报