如何建立一個驗證請求的API框架

​開發一款成功軟件的關鍵是良好的架構設計。優秀的設計不只容許開發人員輕鬆地編寫新功能,並且還能絲滑的適應各類變化。html

好的設計應該關注應用程序的核心,即領域。json

不幸的是,這很容易將領域與不屬於這一層的職責混淆。每增長一個功能,就會使理解核心領域變得更加困難。一樣糟糕的是,未來就更難重構了。api

所以,保護領域層不受應用程序邏輯影響是很重要的。其中一個優化是對傳入請求的驗證。爲了防止驗證邏輯滲透到領域級別,咱們但願在請求到達領域級別以前驗證請求。服務器

在這篇文章中,咱們將學習如何從領域層中提取驗證。在咱們開始以前,本文假設API使用command模式將傳入請求轉換爲命令或查詢。本文中全部的代碼片斷都使用了MediatR。架構

command模式的好處是將核心邏輯從API層分離出來。大多數實現command模式的庫也公開了能夠鏈接到其中的中間件。這頗有用,由於它提供了一個解決方案,能夠添加須要與每一個命令一塊兒執行的應用程序邏輯。app

MediatR請求框架

使用C# 9中引入的record類型,它能夠把請求變成一行代碼。另外一個好處是,實例是不可變的,這使得一切變得可預測和可靠。async

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

相關文章
相關標籤/搜索