开发一款成功软件的关键是良好的架构设计。优秀的设计不仅允许开发人员轻松地编写新功能,而且还能丝滑的适应各种变化。
好的设计应该关注应用程序的核心,即领域。
不幸的是,这很容易将领域与不属于这一层的职责混淆。每增加一个功能,就会使理解核心领域变得更加困难。同样糟糕的是,将来就更难重构了。
因此,保护领域层不受应用程序逻辑影响是很重要的。其中一个优化是对传入请求的验证。为了防止验证逻辑渗透到领域级别,我们希望在请求到达领域级别之前验证请求。
在这篇文章中,我们将学习如何从领域层中提取验证。在我们开始之前,本文假设API使用command模式将传入请求转换为命令或查询。本文中所有的代码片段都使用了MediatR。
command模式的好处是将核心逻辑从API层分离出来。大多数实现command模式的库也公开了可以连接到其中的中间件。这很有用,因为它提供了一个解决方案,可以添加需要与每个命令一起执行的应用程序逻辑。
MediatR请求
使用C# 9中引入的record类型,它可以把请求变成一行代码。另一个好处是,实例是不可变的,这使得一切变得可预测和可靠。
record AddProductToCartCommand(Guid CartId, string Sku, int Amount) : MediatR.IRequest;
为了分发上述命令,可以将传入的请求映射到控制器中。
[ApiController] [Route("[controller]")] public class CustomerCartsController : ControllerBase { private readonly IMediator _mediator; public CustomerCartsController(IMediator mediator) => _mediator = mediator; [HttpPost("{cartId}")] public async Task<IActionResult> AddProductToCart(Guid cartId, [FromBody] CartProduct cartProduct) { await _mediator.Send(new AddProductToCartCommand(cartId, cartProduct.Sku, cartProduct.Amount)); return Ok(); } }
MediatR验证
我们将使用MediatR管道,而不是在控制器中验证AddProductToCartCommand。
通过使用管道,可以在处理程序处理命令之前或之后执行一些逻辑。在这种情况下,提供一个集中的位置,在命令到达处理程序(领域)之前在该位置对其进行验证。当命令到达它的处理程序时,我们不再需要担心命令是否有效。
虽然这看起来是一个微不足道的更改,但它清理了领域层中每个处理程序。
理想情况下,我们只希望在领域中处理业务逻辑。删除验证逻辑解放了我们的思想,这样我们就可以更关注业务逻辑。由于验证逻辑是集中的,它确保所有命令都得到验证,而没有一条命令漏过漏洞。
在下面的代码片段中,我们创建了一个ValidatorPipelineBehavior来验证命令。当命令被发送时,ValidatorPipelineBehavior处理程序在它到达领域层之前接收命令。ValidatorPipelineBehavior通过调用对应于该类型的验证器来验证该命令是否有效。只有当请求有效时,才允许将请求传递给下一个处理程序。如果没有,则抛出InputValidationException异常。
我们将看看如何使用FluentValidation在验证中创建验证器。现在,重要的是要知道,当请求无效时,将返回验证消息。验证的细节被添加到异常中,稍后将用于创建响应。
public class ValidatorPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> { private readonly IEnumerable<IValidator<TRequest>> _validators; public ValidatorPipelineBehavior(IEnumerable<IValidator<TRequest>> validators) => _validators = validators; public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next) { // Invoke the validators var failures = _validators .Select(validator => validator.Validate(request)) .SelectMany(result => result.Errors) .ToArray(); if (failures.Length > 0) { // Map the validation failures and throw an error, // this stops the execution of the request var errors = failures .GroupBy(x => x.PropertyName) .ToDictionary(k => k.Key, v => v.Select(x => x.ErrorMessage).ToArray()); throw new InputValidationException(errors); } // Invoke the next handler // (can be another pipeline behavior or the request handler) return next(); } }
使用FluentValidation进行验证
为了验证请求,我喜欢使用FluentValidation库。使用FluentValidation,通过实现AbstractValidator抽象类来为每个“IRequest”定义“验证规则”。
我喜欢使用FluentValidation的原因是:
-
验证规则与模型是分离的
-
易写易读
-
除了许多内置验证器之外,还可以创建自己的(可重用的)自定义规则
-
可扩展性
public class AddProductToCartCommandValidator : FluentValidation.AbstractValidator<AddProductToCartCommandCommand> { public AddProductToCartCommandValidator() { RuleFor(x => x.CartId) .NotEmpty(); RuleFor(x => x.Sku) .NotEmpty(); RuleFor(x => x.Amount) .GreaterThan(0); } }
注册MediatR和FluentValidation
现在我们有了验证的方法,也创建了一个验证器,我们可以把它们注册到DI容器中。
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); // Register all Mediatr Handlers services.AddMediatR(typeof(Startup)); // Register custom pipeline behaviors services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>)); // Register all Fluent Validators services .AddMvc() .AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>()); }
HTTP API问题详细信息
现在一切都准备好了,可以发出第一个请求了。当我们尝试发送一个无效请求时,我们会收到一个内部服务器错误(500)响应。这很好,但这并不是的良好体验。
为了给用户(用户界面)、开发人员(或者你自己),甚至是第三方创造更好的体验,优化后的结果将使请求失败的原因变得清晰。这种做法使与API的集成更容易、更好,而且可能更快。
当我不得不与第三方服务集成,他们却没有考虑到这一点。这导致了我的许多挫折,当整合最终结束时,我很高兴。我确信,如果能更多的考虑对失败请求的响应,实现会更快,最终结果也会更好。遗憾的是,大多数与第三方服务的集成都是糟糕的体验。
因为这次经历,我尽最大的努力通过提供更好的响应来帮助未来的自己和其他开发者。更好的操作是,一个标准化的响应,我称为HTTP api的问题详细信息。
. net框架已经提供了一个类来实现问题详细信息的规范,即ProblemDetails。事实上,. net API会为一些无效的请求返回一个问题详细信息响应。例如,当在路由中使用了一个无效参数时,. net返回如下响应。
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "traceId": "00-6aac4e84d1d4054f92ac1d4334c48902-25e69ea91f518045-00", "errors": { "id": ["The value 'one' is not valid."] } }
将响应(异常)映射到问题详细信息
为了规范我们的问题详细信息,可以用异常中间件或异常过滤器重写响应。
在下面的代码片段中,当应用程序中出现异常时,我们将使用中间件检索异常的详细信息。根据这些异常详细信息,构建问题详细信息对象。
所有抛出的异常都由中间件捕获,因此你可以为每个异常创建特定的问题详细信息。在下面的例子中,只有InputValidationException异常被映射,其余的异常都被同等对待。
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var errorFeature = context.Features.Get<IExceptionHandlerFeature>(); var exception = errorFeature.Error; // https://tools.ietf.org/html/rfc7807#section-3.1 var problemDetails = new ProblemDetails { Type = $"https://example.com/problem-types/{exception.GetType().Name}", Title = "An unexpected error occurred!", Detail = "Something went wrong", Instance = errorFeature switch { ExceptionHandlerFeature e => e.Path, _ => "unknown" }, Status = StatusCodes.Status400BadRequest, Extensions = { ["trace"] = Activity.Current?.Id ?? context?.TraceIdentifier } }; switch (exception) { case InputValidationException validationException: problemDetails.Status = StatusCodes.Status403Forbidden; problemDetails.Title = "One or more validation errors occurred"; problemDetails.Detail = "The request contains invalid parameters. More information can be found in the errors."; problemDetails.Extensions["errors"] = validationException.Errors; break; } context.Response.ContentType = "application/problem+json"; context.Response.StatusCode = problemDetails.Status.Value; context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() { NoCache = true, }; await JsonSerializer.SerializeAsync(context.Response.Body, problemDetails); }); }); app.UseHttpsRedirection(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
有了异常处理程序,当检测到无效命令时,将返回以下响应。例如,当AddProductToCartCommand命令(参见MediatR命令)以负数发送时。
{ "type": "https://example.com/problem-types/InputValidationException", "title": "One or more validation errors occurred", "status": 403, "detail": "The request contains invalid parameters. More information can be found in the errors.", "instance": "/customercarts", "trace": "00-22fde64da9b70a4691e8c536aafb2c49-f90b88a19f1dca47-00", "errors": { "Amount": ["'Amount' must be greater than '0'."] } }
除了创建自定义异常处理程序并将异常映射到问题详细信息之外,还可以使用Hellang.Middleware.ProblemDetails包。Hellang.Middleware.ProblemDetails包可以很容易地将异常映射到问题详细信息,几乎不需要任何代码。
一致的问题详细信息
还有最后一个问题。上面的代码片段期望应用程序在控制器中创建MediatR请求。在body中包含该命令的API终结点将自动被. net模型验证器验证。当终结点接收到无效命令时,我们的管道和异常处理不会处理请求。这意味着将返回默认的. net响应,而不是我们的问题详细信息。
例如,AddProductToCart直接接收AddProductToCartCommand命令,并将该命令发送到MediatR管道。
[ApiController] [Route("[controller]")] public class CustomerCartsController : ControllerBase { private readonly IMediator _mediator; public CustomerCartsController(IMediator mediator) => _mediator = mediator; [HttpPost] public async Task<IActionResult> AddProductToCart(AddProductToCartCommand command) { await _mediator.Send(command); return Ok(); } }
我一开始并没有预料到这一点,花了一段时间才弄清楚为什么会发生这种情况,以及如何确保响应对象保持一致。作为一种可能的修复,我们可以抑制这种默认行为,这样无效的请求将由我们的管道处理。
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); // Register all Mediatr Handlers services.AddMediatR(typeof(Startup)); // Register custom pipeline behaviors services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>)); // Register all Fluent Validators services .AddMvc() .AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>()); services.Configure<ApiBehaviorOptions>(options => { options.SuppressModelStateInvalidFilter = true; }); }
但这也有一个缺点。不能捕获无效的数据类型。因此,关闭无效的模型过滤器可能会导致意想不到的错误。以前,这个操作会导致一个bad request(400)。这就是为什么我更喜欢接收到错误输入时抛出InputValidationException异常。
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); // Register all Mediatr Handlers services.AddMediatR(typeof(Startup)); // Register custom pipeline behaviors services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidatorPipelineBehavior<,>)); // Register all Fluent Validators services .AddMvc() .AddFluentValidation(s => s.RegisterValidatorsFromAssemblyContaining<Startup>()); services.Configure<ApiBehaviorOptions>(options => { options.InvalidModelStateResponseFactory = context => { var problemDetails = new ValidationProblemDetails(context.ModelState); throw new InputValidationException(problemDetails.Errors); }; }); }
总结
在这篇文章中,我们已经看到了如何通过MediatR管道行为在命令到达领域层之前集中验证逻辑。这样做的好处是,所有的命令都是有效的,当一个命令到达它的处理程序时,它将是有效的。换句话说,领域将保持干净和简单。
因为有一个清晰的分离,开发人员只需要关注显而易见的任务。在开发过程中,还可以保证单元测试更有针对性,也更容易编写。
将来,如果需要的话,还可以更容易地替换验证层。
欢迎关注我的公众号,如果你有喜欢的外文技术文章,可以通过公众号留言推荐给我。
原文链接:https://timdeschryver.dev/blog/creating-a-new-csharp-api-validate-incoming-requests