ASP.NET Core 自訂模型驗證錯誤訊息格式
ASP.NET Core 自訂模型驗證錯誤訊息格式
前言
用 [ApiController] 寫 Web API 時,模型驗證幾乎是免費的——在 ViewModel 上掛幾個 [Required]、[StringLength],
請求進到 Action 之前框架就會自動幫你擋下不合法的輸入,連 if (!ModelState.IsValid) 都不用寫。
public class LoginViewModel
{
/// <summary> 帳號 </summary>
[JsonPropertyName("id")]
[Required(ErrorMessage = "帳號為必填")]
public required string Id { get; set; }
/// <summary> 密碼 </summary>
[JsonPropertyName("password")]
[Required(ErrorMessage = "密碼為必填")]
public required string Password { get; set; }
}少送一個 id,框架自動回你一包 400:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Id": [
"帳號為必填"
]
},
"traceId": "00-200bae18c687dfe47d1ddac560e30c51-965a2222697de138-00"
}這格式很標準(它是 RFC 7807 / 9457 的 ProblemDetails),但實務上常常和團隊既有的 API 回應規格對不上——
前端早就講好所有回應都長 { code, message, data },結果驗證錯誤偏偏自己長一套,前端得為它寫特例處理。
這篇就把「怎麼把模型驗證錯誤改成自己想要的格式」講清楚,提供兩種主流做法,並分析各自的優缺點與適用情境。
請求到 Action 的過程
要改它,得先知道它從哪來。[ApiController] 這顆 Attribute 背後幫你開了好幾個預設行為,其中一個就是
自動模型狀態驗證(Automatic Model State Validation):
Action 真正執行之前,框架會先檢查
ModelState.IsValid。
只要無效,就直接短路回傳400,Action 裡的程式碼一行都不會跑到。
但「Action 之前」到底是哪個時間點?要改得準,得把請求進到 Controller 的這段 pipeline 攤開來看。
中間要經過哪幾關
從 HTTP 請求進來,到你的 Action 方法真正被呼叫,中間其實經過好幾關:
HTTP 請求
│
▼
[ Middleware 管線 ] ← 路由、驗證身分、CORS… 都在這層
│
▼
比對到 Endpoint(某個 Controller 的某個 Action)
│
▼
[ 模型繫結 Model Binding ] ← 把 JSON / 表單 塞進參數物件
│ 繫結 + 驗證的結果都寫進 ModelState
▼
[ Filter 管線 ]
├─ Authorization Filter
├─ Resource Filter
├─ Action Filter ──► ★ 內建的 ModelStateInvalidFilter 就站在這裡
│ 它在 OnActionExecuting 檢查 ModelState,
│ 無效就「短路」直接回 400,後面整串都不跑
▼
[ Action 方法本體 ] ← 驗證沒過的話,根本走不到這裡關鍵在於:模型繫結先跑,把繫結與驗證的結果都寫進 ModelState;接著進入 Filter 管線,
那顆自動回 400 的東西,本身就是一顆 Action Filter——叫 ModelStateInvalidFilter。
也就是說,[ApiController] 的「自動驗證」不是什麼黑魔法,它只是框架幫你預先掛好的一顆 action filter。
它站在 Action 本體之前,ModelState 無效就搶先設定 Result、短路掉整條鏈。
理解這點,後面兩種做法就都通了:
- 做法一:不動這顆 filter,只換掉它「產生 400 內容」用的工廠。
- 做法二:把這顆內建 filter 關掉,換上自己的 action filter 站到同一個位置——本質上是「換人站崗」。
那包 JSON 的內容又是誰決定的?
ModelStateInvalidFilter 決定「何時短路」,但「回什麼內容」則委派給另一個東西:ApiBehaviorOptions 上一個叫 InvalidModelStateResponseFactory 的委派。
它預設指向框架內建的「產生 ProblemDetails」邏輯,那包標準 JSON 就是它生出來的。
所以改格式有兩個下手點,正好對應 pipeline 上的兩個位置:
換掉內容工廠(
InvalidModelStateResponseFactory,做法一)——讓內建 filter 照常短路,只改它吐的東西;
或換掉站崗的 filter 本身(做法二)——把內建的關掉,自己寫一顆站上去。
順帶澄清:模型驗證 vs 模型繫結錯誤
有個容易混淆的點:以下兩種錯誤最後都會落進 ModelState,也都走同一個 400 流程:
| 類型 | 觸發時機 | 例子 |
|---|---|---|
| 模型繫結錯誤(Model Binding) | 框架把 JSON / 表單塞進物件時就失敗 | age 欄位送了 "abc",塞不進 int |
| 模型驗證錯誤(Model Validation) | 物件繫結成功後,跑 DataAnnotation 規則沒過 | [Required] 的欄位是空的 |
兩者都進 ModelState,所以只要在 factory 那一層動手,兩種錯誤就能一起改格式,不用分開處理——
這也是為什麼下面的做法不去區分「繫結」還是「驗證」,統一收在出口改寫即可。
補充:ProblemDetails 到底是什麼?為什麼要改它?
在動手改之前,值得先認識那包預設 JSON 的身世——因為認識它之後,你才有辦法判斷「到底該不該改」。
它是一個 W3C / IETF 的標準
ProblemDetails 不是微軟自己發明的,它是 IETF 訂的網路標準——
最早是 RFC 7807(2016),2023 年由 RFC 9457 取代並補充。
它想解決一個很實際的問題:
HTTP 狀態碼太粗了。一個
400只說「你錯了」,但到底錯在哪、哪個欄位、為什麼,狀態碼講不出來。
各家 API 於是各自發明錯誤格式,前端每接一個後端就得重學一套——標準就是要終結這種各說各話。
於是 RFC 定義了一個「機器可讀的錯誤描述格式」,媒體型別是 application/problem+json,
約定了幾個標準欄位:
| 欄位 | 意義 |
|---|---|
type | 一個 URI,標示這「類」問題是什麼(可點進去看文件) |
title | 人類可讀的問題摘要,同一 type 下應保持一致 |
status | HTTP 狀態碼,與回應的 status code 對應 |
detail | 針對「這一次」發生的細節說明 |
instance | 標示「這一次」問題的 URI |
ASP.NET Core 回的那包,errors 其實是 ValidationProblemDetails——
在標準欄位之外擴充了一個 errors 字典(欄位 → 錯誤訊息陣列)。
RFC 本來就允許這種擴充,這也是它刻意留的彈性。
為什麼要這樣定?
把它拆開看,每個設計都對應一個目的:
- 標準化:不同團隊、不同語言的服務,錯誤都長同一個樣子,前端 / 用戶端可以寫一套通用處理。
- 機器可讀:
status、type、errors都是結構化欄位,程式可以直接判讀、分流,而不是去 parse 一段人話。 - 可擴充:標準欄位之外可以自由加欄位(像
errors、traceId),不必為了客製化就拋棄整套標準。 - 自帶文件入口:
type是 URI,理想上點進去就是這類錯誤的說明,等於把文件掛在錯誤本身上。
優缺點,以及「到底該不該改」
| 面向 | 用預設 ProblemDetails | 換成自訂格式(如 { code, message, data }) |
|---|---|---|
| 標準相容 | ✅ 跨服務、跨語言通用,符合業界慣例 | ❌ 自家獨有,對接方要重學 |
| 與既有 API 規格一致 | ❌ 常和團隊既有的統一回應對不上 | ✅ 整站同一種形狀,前端處理單純 |
| 前端負擔 | 要為驗證錯誤寫特例(它和其他回應不同調) | 所有回應同調,一套邏輯吃到底 |
| 工具友善度 | ✅ Swagger / 標準用戶端能自動辨識 | 需自行描述 schema |
| 對外 / 對內 | 對外公開 API 特別適合(讓人好接) | 內部自用、前後端同一團隊時更省事 |
一句話的取捨:
對外公開的 API——尤其要給第三方串接的——建議盡量沿用
ProblemDetails,因為「符合標準」本身就是一種對接友善。
但若是前後端同一個團隊、整站早已約定統一回應格式,那讓驗證錯誤也跟著統一,反而比堅持標準更實際——
這正是本文後面要做的事。
也就是說,「改」不是因為標準不好,而是因為一致性在你的情境下比「相容業界標準」更重要。先想清楚這點,再決定要不要往下做。
統一的錯誤回應模型
兩種做法都會用到一個共用的回應模型,先定義好。這裡用一個 ErrorResponseViewModel:
public class ErrorResponseViewModel
{
public int Code { get; set; }
public string Message { get; set; }
public object Data { get; set; }
public ErrorResponseViewModel(int code, object data, string message = "驗證失敗")
{
Code = code;
Message = message;
Data = data;
}
}目標:把那包 ProblemDetails 換成這種團隊統一的形狀。
{
"code": 400,
"message": "驗證失敗",
"data": [
{ "field": "Id", "message": "帳號為必填" }
]
}做法一:覆寫 InvalidModelStateResponseFactory
直接換掉產生 400 的那顆 factory。 這是最直球、改動面最小的做法——框架本來就留了這個擴充點給你。
builder.Services.AddControllers();
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = actionContext =>
{
var errors = actionContext.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.Select(e => new
{
Field = e.Key,
Message = e.Value?.Errors.First().ErrorMessage
})
.ToList();
var response = new ErrorResponseViewModel(400, errors);
return new BadRequestObjectResult(response);
};
});重點在於:你不需要停用任何東西。自動驗證照樣在 Action 之前短路,只是「回什麼」改由你的 factory 決定。
它的特性是範圍全域、層級最低:不管哪個 Controller、哪個 Action、是繫結錯誤還是驗證錯誤,
全都會經過這顆 factory。一次設定,全站生效。
做法二:自訂 IActionFilter + 停用內建驗證
另一條路是自己寫一個 Action Filter 來攔 ModelState,但前提是要先把框架的自動 400 關掉,
否則框架會搶在你的 Filter「之前」就把請求短路掉,你的 Filter 根本接不到。
builder.Services.AddControllers(options =>
{
options.Filters.Add<ValidateModelFilter>();
})
.ConfigureApiBehaviorOptions(options =>
{
// 關鍵:停用內建的自動 400 回應,把驗證的主導權交回給我們的 Filter
options.SuppressModelStateInvalidFilter = true;
});public class ValidateModelFilter : IActionFilter
{
// Action 執行「之前」攔截,自己檢查 ModelState
public void OnActionExecuting(ActionExecutingContext context)
{
if (context.ModelState.IsValid)
{
return;
}
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.Select(e => new
{
Field = e.Key,
Message = e.Value?.Errors.First().ErrorMessage
})
.ToArray();
var response = new ErrorResponseViewModel(400, errors);
// 設了 Result,就會短路,Action 不會被執行
context.Result = new BadRequestObjectResult(response);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}這裡有兩個關鍵:
SuppressModelStateInvalidFilter = true:不停用它,框架的自動驗證會先短路,你的 Filter 根本沒機會跑。- 在
OnActionExecuting攔截:這是 Action 執行「之前」的時機,自己檢查ModelState、自己設context.Result。
一旦設了Result,後面的 Action 就被短路掉了——等於手動重現框架原本的行為,只是格式由你決定。
為什麼是
OnActionExecuting而不是OnActionExecuted?
因為驗證要擋在 Action 跑之前。等到OnActionExecuted,Action 早就執行完了,擋也來不及。
兩種做法怎麼選?
把兩條路攤開來比:
| 面向 | 做法一:InvalidModelStateResponseFactory | 做法二:自訂 ActionFilter |
|---|---|---|
| 改動位置 | 一顆委派,Program.cs 設定 | 額外寫一個 Filter 類別 |
| 要不要停用內建驗證 | 不用,沿用自動短路 | 要(SuppressModelStateInvalidFilter = true) |
| 生效範圍 | 全域,無腦套用所有 API | 看你怎麼註冊:全域或掛在特定 Controller / Action |
| 拿得到的上下文 | ActionContext(HttpContext、RouteData、ModelState) | ActionExecutingContext,還能拿到已繫結的參數值 |
| 套用部分 Action | 較不直覺 | 天生支援(Filter 可逐個 Controller / Action 掛) |
| 心智負擔 | 低,就是換個出口 | 較高,要懂 Filter 生命週期與短路機制 |
簡單給結論:
- 只是想換錯誤格式、而且全站統一 → 做法一。最少程式碼、最不容易出錯,框架的短路機制你完全不用碰。
- 需要更多控制——例如想拿到當下繫結的參數值一起記 log、想只對某些 Controller 套用、
或驗證邏輯本身比較複雜 → 做法二。Filter 給你更大的施展空間,代價是要自己接管短路。
對大多數「就是想把 ProblemDetails 換成 { code, message, data }」的需求,做法一已經夠用,也最推薦。
實戰:包成擴充方法,順手加上 Logging
實務上很常在驗證失敗時順便記一筆 log——畢竟「誰、打哪支 API、送了什麼、被哪條規則擋下」是排查問題的黃金資訊。
這時做法一的 factory 就是最好的下手點,因為它一手掌握 HttpContext 與 ModelState。
下面把它包成一個擴充方法,方便在 Program.cs 一行掛上:
public static class ModelStateValidationExtension
{
public static IServiceCollection AddModelStateValidationLogging(this IServiceCollection services)
{
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
// 1. 收集所有驗證錯誤(一個欄位可能有多條錯誤訊息)
var errors = context.ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
);
// 2. 把使用者實際送進來的值也撈出來,記 log 用
var requestData = context.ModelState.ToDictionary(
kvp => kvp.Key,
kvp => (object?)kvp.Value?.AttemptedValue
);
// 3. 記一筆 Warning:方法、路徑、Action、錯誤、請求內容
var actionName = $"{context.RouteData.Values["controller"]}.{context.RouteData.Values["action"]}";
Log.Warning(
"[模型驗證失敗] {Method} {Path} | Action: {ActionName} | 錯誤: {@Errors} | 請求內容: {@RequestData}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
actionName,
errors,
requestData);
// 4. 回傳統一格式
var message = string.Join("; ", errors.SelectMany(e => e.Value));
var response = new ErrorResponseViewModel(400, errors, $"資料驗證失敗: {message}");
return new BadRequestObjectResult(response);
};
});
return services;
}
}掛上去就一行:
builder.Services.AddControllers();
builder.Services.AddModelStateValidationLogging();有個細節值得注意:
ModelState裡的AttemptedValue是使用者實際送進來的原始字串值,
即使繫結失敗(例如int欄位收到"abc")也撈得到。
拿來記 log 特別好用——你能還原「對方到底送了什麼」,而不是只看到一句「驗證失敗」。⚠️ 但也正因為它是原始輸入,密碼之類的敏感欄位記得在記 log 前過濾掉,別把明文密碼寫進 log。
幾個容易踩的坑
做法二忘了
SuppressModelStateInvalidFilter = true:
最常見的誤會。沒停用內建驗證,框架會搶先短路,你的 Filter 永遠不會被觸發,怎麼 debug 都覺得「Filter 沒生效」。以為改了 factory,繫結錯誤就不會進來:
反了。型別轉換失敗(model binding error)一樣會進ModelState、一樣走你的 factory。
所以你的errors處理邏輯要能容忍「沒有ErrorMessage、只有框架預設訊息」的情況。一個欄位可能有多條錯誤:
範例為了簡潔用了.First()只取第一條。若規則較多(同欄位同時掛[Required]與[StringLength]),
建議像實戰那段一樣用Select(...).ToArray()把整包訊息都帶上,別漏掉。[ApiController]才有自動驗證:
如果你的 Controller 沒掛[ApiController],根本沒有自動短路這回事,ModelState得自己檢查——
這兩種做法的前提都是你正在用[ApiController]的 Web API。
小結
- 那包預設的
ProblemDetails400,是[ApiController]的自動模型驗證透過InvalidModelStateResponseFactory產生的。 - 模型繫結錯誤與模型驗證錯誤最後都落進
ModelState,在出口統一改寫即可,不必分開處理。 - 做法一(覆寫 factory):改動最小、全域生效、不用停用任何東西——大多數「只是想換格式」的需求首選。
- 做法二(自訂 ActionFilter):彈性最大,可逐 Action 套用、能拿到繫結後的參數值,代價是要
SuppressModelStateInvalidFilter並自己接管短路。 - 想在驗證失敗時記 log,做法一的 factory 是最佳下手點,
AttemptedValue能還原使用者實際送的值(但敏感欄位記得遮蔽)。 - 最常見的坑:做法二忘了停用內建驗證,導致 Filter 永遠不觸發。