開發一款成功軟件的關鍵是良好的架構設計。優秀的設計不僅允許開發人員輕松地編寫新功能,而且還能絲滑的適應各種變化。
好的設計應該關注應用程序的核心,即領域。
不幸的是,這很容易將領域與不屬於這一層的職責混淆。每增加一個功能,就會使理解核心領域變得更加困難。同樣糟糕的是,將來就更難重構了。
因此,保護領域層不受應用程序邏輯影響是很重要的。其中一個優化是對傳入請求的驗證。為了防止驗證邏輯滲透到領域級別,我們希望在請求到達領域級別之前驗證請求。
在這篇文章中,我們將學習如何從領域層中提取驗證。在我們開始之前,本文假設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