1. 程式人生 > >如何建立一個驗證請求的API框架

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

​開發一款成功軟體的關鍵是良好的架構設計。優秀的設計不僅允許開發人員輕鬆地編寫新功能,而且還能絲滑的適應各種變化。

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

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

因此,保護領域層不受應用程式邏輯影響是很重要的。其中一個優化是對傳入請求的驗證。為了防止驗證邏輯滲透到領域級別,我們希望在請求到達領域級別之前驗證請求。

在這篇文章中,我們將學習如何從領域層中提取驗證。在我們開始之前,本文假設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-requ