ASP.NET Core 7 幾個 Model Validation 的技巧

微軟一直以來都有提供 Model Validation 讓我們更容易的進行前置條件檢查,這次我想要在 ASP.NET Core Web API 回傳一致性的錯誤結構,捨棄預設錯誤的結構,接下來我將演練 Model Validation 以及 FluentValidation  的使用方式。 

開發環境

  • Windows 11
  • Rider 2023.2
  • ASP.NET Core 7

內建的 Model Validation

在 ASP.NET Core Request 有一段流程會做反序列化 Model Binding 以及 Model Validation,如下圖

下圖出自:ASP.NET Core in Action - Filters (andrewlock.net)

Figure 1 Filters run at multiple points in the MvcMiddleware in the normal handling of a request.

 

當我們在 Model 設定官方提供的 Validation Attribute 後,或是自訂驗證,當驗證失敗後,就能得到失敗的錯誤訊息,詳情可以參考以下連結

[Validation] 自訂模型驗證 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)

在 ValidationAttribute 如何使用 Microsoft.Extensions.DependencyInjection Container 的物件 | 余小章 @ 大內殿堂 - 點部落 (dotblogs.com.tw)

 

Model Property 掛上 Validation Attribute

public class CreateMemberRequest
{
   [Required]
   public string Name { get; set; }
   
   [Range(18, 200)]
   public int Age { get; set; }
}

更多的 Validation Attribute,參考 System.ComponentModel.DataAnnotations 命名空间 | Microsoft Learn

 

在 Action 上使用 CreateMemberRequest

[HttpPost(Name = "CreateData")]
public ActionResult Post(CreateMemberRequest request)
{
	return this.NoContent();
}

 

當 Client 送出的請求內容不足的時候,就會得到 HttpStatus = 400,以及相關的失敗訊息

 

自訂驗證失敗錯誤訊息

內建的錯誤訊息不是我要的,也可以把他換掉,我期望不管是甚麼樣的錯誤,回傳的格式欄位都是固定的,定義如下:

public class Failure
{
    public Failure()
    {
    }

    public Failure(FailureCode code, string message)
    {
        this.Code = code;
        this.Message = message;
    }

    public FailureCode Code { get; init; }

    public string Message { get; init; }

    public object Data { get; init; }

    public string TraceId { get; set; }

    public List<Failure> Details { get; init; }
}

 

根據不同的場景定義一個 Code,我期望 API 回傳的是一個有意義的字串,這裡我使用 enum,定義如下:

public enum FailureCode
{
   Unknown = 0,
   InputValid,
   MemberNotFound,
   MemberAlreadyExist,
   ServerError,
   DataConflict,
   DataConcurrency,
   DataNotFound,
   DbError,
   S3Error
}

 

在序列化的設定增加 JsonStringEnumConverter,這樣就能順利的把 enum 輸出成字串

builder.Services.AddControllers().AddJsonOptions(options =>
{
   options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
})

 

法一:換掉 ApiBehaviorOptions.InvalidModelStateResponseFactory 的內容

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
   options.InvalidModelStateResponseFactory = actionContext => ValidationErrorHandler(options, actionContext);
});

 

把 actionContext.ModelState 的內容倒出來,放到 Failure.Data

IActionResult ValidationErrorHandler(ApiBehaviorOptions apiBehaviorOptions, ActionContext actionContext)
{
    var originalFactory = apiBehaviorOptions.InvalidModelStateResponseFactory;
    if (actionContext.ModelState.IsValid)
    {
        return originalFactory(actionContext);
    }

    var traceId = Activity.Current?.Id ?? actionContext.HttpContext.TraceIdentifier;
    
    var errors = actionContext.ModelState.ToDictionary(
        p => p.Key,
        p => p.Value.Errors.Select(e => e.ErrorMessage).ToList());
    
    //複寫錯誤內容
    return new BadRequestObjectResult(new Failure()
    {
        Code = FailureCode.InputInvalid,
        Message = "input invalid",
        Data = errors,
        TraceId = traceId
    });
}

 

最後,回傳結果換成我想要的那個樣子了

 

法二:自訂 Filter

實作一個 ActionFilterAttribute,寫法跟法一相同

public class ModelValidationAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext actionContext)
    {
        if (actionContext.Result != null)
        {
            return;
        }

        if (actionContext.ModelState.IsValid)
        {
            return;
        }

        var traceId = Activity.Current?.Id ?? actionContext.HttpContext.TraceIdentifier;

        var errors = actionContext.ModelState.ToDictionary(
            p => p.Key,
            p => p.Value.Errors.Select(e => e.ErrorMessage).ToList());

        //複寫錯誤內容
        actionContext.Result = new BadRequestObjectResult(new Failure()
        {
            Code = FailureCode.InputInvalid,
            Message = "input invalid",
            Data = errors,
            TraceId = traceId
        });
    }
}

更多的 ActionFilter,參考 ASP.NET Core 中的篩選條件 | Microsoft Learn

 

停用 Model State Invalid Filter,想要換成我自己的 Filter

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
   //停用 Model Validation
   options.SuppressModelStateInvalidFilter = false;
})

 

全域套用

builder.Services
    .AddControllers(p =>
    {
        p.Filters.Add<ModelValidationAttribute>();
    })
    .AddJsonOptions(options =>
    {
        ...
    })

 

或是 Action、Controller 套用

[ModelValidation()]
[HttpPost(Name = "CreateData")]
public ActionResult Post(CreateMemberRequest request)
{
    return this.NoContent();
}

 

最後,執行結果,如下

 

上述兩種方法都可以,看喜歡哪一種

自訂 enum 反序列化失敗的錯誤訊息

CreateMemberRequest 增加 MemberType enum 欄位

public enum MemberType
{
   None,
   Member,
   Vip,
}
public class CreateMemberRequest
{
   [Required]
   public string Name { get; set; }
   [Range(18, 200)]
   public int Age { get; set; }
   [Required]
   public MemberType Type { get; set; }
}

當你的 API 合約用了 eunm,當 ASP.NET Core 反序列化失敗時錯誤訊息如下,

 

把 JSON 反序列化的錯誤訊息拿出來重新排成我想要的那個樣子

IActionResult ValidationErrorHandler(ApiBehaviorOptions apiBehaviorOptions, ActionContext actionContext)
{
    var originalFactory = apiBehaviorOptions.InvalidModelStateResponseFactory;
    if (actionContext.ModelState.IsValid)
    {
        return originalFactory(actionContext);
    }

    var traceId = Activity.Current?.Id ?? actionContext.HttpContext.TraceIdentifier;

    //處理 JSON Path
    var jsonPathKeys = actionContext.ModelState.Keys.Where(e => e.StartsWith("$.")).ToList();
    if (jsonPathKeys.Count > 0)
    {
        var errorData = new Dictionary<string, string>();
        foreach (var key in jsonPathKeys)
        {
            var normalizedKey = key.Substring(2);
            foreach (var error in actionContext.ModelState[key].Errors)
            {
                if (error.Exception != null)
                {
                    actionContext.ModelState.TryAddModelException(normalizedKey, error.Exception);
                }

                actionContext.ModelState.TryAddModelError(normalizedKey, "The provided value is not valid.");
                errorData.Add(normalizedKey, error.ErrorMessage);
            }

            actionContext.ModelState.Remove(key);
        }
        
        //複寫錯誤內容
        return new BadRequestObjectResult(new Failure
        {
            Code = FailureCode.InputInvalid,
            Message = "enum invalid",
            Data = errorData,
            TraceId = traceId
        });
    }

    var errors = actionContext.ModelState.ToDictionary(
        p => p.Key,
        p => p.Value.Errors.Select(e => e.ErrorMessage).ToList());

    //複寫錯誤內容
    return new BadRequestObjectResult(new Failure()
    {
        Code = FailureCode.InputInvalid,
        Message = "input invalid",
        Data = errors,
        TraceId = traceId
    });
}

 

執行結果如下

用 FluentValidation 替換內建的 Model Validation

安裝套件

dotnet add package FluentValidation.AspNetCore

 

實作 Validator

public class CreateMemberRequestValidator : AbstractValidator<CreateMemberRequest>
{
   public CreateMemberRequestValidator()
   {
       this.RuleFor(p => p.Name).NotNull().NotEmpty();
       this.RuleFor(p => p.Age).LessThanOrEqualTo(18).GreaterThan(200);
       this.RuleFor(x => x.Type)
           .IsInEnum()
           .WithMessage("Type is not valid");
   }
}

 

註冊 Validator

更多的註冊方式請參考

 Dependency Injection — FluentValidation documentation

builder.Services
    .AddFluentValidation(p => p.RegisterValidatorsFromAssemblyContaining<CreateMemberRequestValidator>())
    ;

 

執行的結果如下圖,發現一個欄位被驗證了兩次,應該是內建的 Model Validation 沒有被關掉

 

胡亂搗鼓一番,呼叫 ModelValidatorProviders.Clear(),可以解決問題。

builder.Services
   .AddControllers(p =>
   {
       p.ModelValidatorProviders.Clear();
   })
   .AddFluentValidation(p => p.RegisterValidatorsFromAssemblyContaining<CreateMemberRequestValidator>())

 

再執行一次,就可以看到一個欄位只會有一個錯誤了

 

這樣就能把內建的 Model Validation 換成 FluentValidation。

在 Application Layer 執行 Model Validation

上章節,利用 ASP.NET Core 的生命週期,進行了 Model Validation,我打算在處理商業邏輯的 Layer 做驗證,跟 ASP.NET Core 脫鉤。

首先,停用 ModelStateInvalidFilter

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
   //停用 Model State Invalid Filter
   options.SuppressModelStateInvalidFilter = true;
});

 

MemberService 是處理商業邏輯的 Layer,在建構子開一個洞,依賴 IValidator<CreateMemberRequest>

public class MemberService
{
  private readonly IValidator<CreateMemberRequest> _validator;
  public MemberService(IValidator<CreateMemberRequest> validator)
  {
      this._validator = validator;
  }
}

 

CreateMemberAsync 方法用 Tuple 回傳 (Failure Failure, bool Data),錯誤以及結果 

呼叫 this._validator.ValidateAsync(request, cancel); 執行 Model Validation

public async Task<(Failure Failure, bool Data)> CreateMemberAsync(CreateMemberRequest request,
  CancellationToken cancel = default)
{
  var validateResult = await this._validator.ValidateAsync(request, cancel);
  if (validateResult.IsValid == false)
  {
   var failure = validateResult.ToFailure();
   return (failure, false);
  }
  return (null, true);
}

 

用 ToFailure() 集中轉成 Failure 物件 

static class ValidationResultExtension
{
    public static Failure ToFailure(this ValidationResult validateResult)
    {
        if (validateResult.IsValid)
        {
            return null;
        }

        var errors = validateResult.Errors
            .ToDictionary(p => p.PropertyName, p => p.ErrorMessage);
        var failure = new Failure()
        {
            Code = FailureCode.InputInvalid,
            Message = "input invalid",
            Data = errors,
        };
        return failure;
    }
}

 

為了更方便把 Failure 轉成 ActionResult 給 Controller 使用,先準備好 FailureObjectResult

public class FailureObjectResult : ObjectResult
{
   public FailureObjectResult(Failure error, int statusCode = StatusCodes.Status400BadRequest)
       : base(error)
   {
       this.StatusCode = statusCode;
       this.Value = error;
   }
}

 

在 GenericController.GenericFailure 回傳 FailureObjectResult 型別,這裡也做了 FailureCode 對應到 HttpStatusCode 的處理

public class GenericController : ControllerBase
{
   private static readonly Lazy<Dictionary<FailureCode, int>> s_failureLookupLazy = new(() => new()
   {
       { FailureCode.InputInvalid, StatusCodes.Status400BadRequest },
       { FailureCode.MemberAlreadyExist, StatusCodes.Status400BadRequest },
       { FailureCode.MemberNotFound, StatusCodes.Status404NotFound },
       { FailureCode.DataNotFound, StatusCodes.Status404NotFound },
       { FailureCode.DataConcurrency, StatusCodes.Status429TooManyRequests },
       { FailureCode.ServerError, StatusCodes.Status500InternalServerError },
       { FailureCode.DbError, StatusCodes.Status500InternalServerError },
       { FailureCode.S3Error, StatusCodes.Status500InternalServerError },
   });
   private static readonly Dictionary<FailureCode, int> FailureLookup = s_failureLookupLazy.Value;
   [NonAction]
   public FailureObjectResult GenericFailure(Failure failure)
   {
       if (string.IsNullOrWhiteSpace(failure.TraceId))
       {
           failure.TraceId = Activity.Current?.Id ?? this.HttpContext.TraceIdentifier;
       }
       if (FailureLookup.TryGetValue(failure.Code, out int statusCode))
       {
           return new FailureObjectResult(failure, statusCode);
       }
       return new FailureObjectResult(failure);
   }
}

 

在 MembersController 的 Action 處理錯誤就簡單許多

public class MembersController : GenericController
{
    private readonly ILogger<MembersController> _logger;
    private readonly MemberService _memberService;

    public MembersController(ILogger<MembersController> logger,
        MemberService memberService)
    {
        this._logger = logger;
        this._memberService = memberService;
    }

    // [ModelValidation()]
    [HttpPost(Name = "CreateData")]
    public async Task<ActionResult> Post(CreateMemberRequest request,
        CancellationToken cancel)
    {
        var createMemberResult = await this._memberService.CreateMemberAsync(request, cancel);
        if (createMemberResult.Failure != null)
        {
            return this.GenericFailure(createMemberResult.Failure);
        }

        return this.NoContent();
    }
}

結論

  • 這次實作客製化 Error Model,可以讓所有錯誤結構一致,也處理了 Enum 反序列化的錯誤。
  • ValidationAttribute 跟 FluentValidation,更偏好 FluentValidation。
  • 偏好 Model Validation 集中在 Application Layer 執行,而不是 ASP.NET Core Lifecycle。

範例專案

sample.dotblog/ModelValidation/net core model validation at d26c14de28d05e3027ad1dcbe45f50ec63e79abf · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo