微軟一直以來都有提供 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,如下圖
當我們在 Model 設定官方提供的 Validation Attribute 後,或是自訂驗證,當驗證失敗後,就能得到失敗的錯誤訊息,詳情可以參考以下連結
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
更多的註冊方式請參考
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。
範例專案
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET