Spring Boot 參數驗證完整教學:別再讓手寫 if 塞滿你的 controller
打開任何一個上了年紀的 Spring 專案,翻到 controller,你大概會撞見這種東西:
1 |
|
商業邏輯只有一行,前面墊了十幾行檢查。更慘的是這段 if 會複製。update 也要檢查一次、批次匯入又檢查一次,同一條「email 要有 @」的規則散在三個地方,改規則要改三次,漏改一個就是線上事故。這篇就從這堆 if 出發,一段一段換掉它。
第一步:把檢查從程式碼搬到欄位上
老做法是「在流程裡問資料合不合法」,新做法是「在資料自己身上宣告它該長什麼樣」。Bean Validation(現在叫 Jakarta Bean Validation,Spring Boot 3.x 用的是 jakarta.validation 命名空間,實作是 Hibernate Validator 8.x/較新點版 9.x,已驗證)就是幹這件事的規格。它把規則變成註解,貼在 DTO 的欄位上:
1 | public class UserDto { |
controller 剩下一個 @Valid:
1 |
|
差別不只是「行數變少」。規則現在只寫一次、貼在資料上,create、update、匯入全部共用同一份宣告。你可以把 DTO 想成一張報名表:以前是櫃檯人員拿到表逐欄唸「這欄沒填、那欄格式錯」,現在是表格本身每個欄位旁邊就印著「必填」「格式:09xxxxxxxx」,收表的人只要看表自己會不會擋。檢查的責任從「處理流程」挪回了「資料邊界」。
有個前提要先講,不然你會踩到一個很安靜的坑:Spring Boot 2.3 之後,spring-boot-starter-web 不再自動帶 validation(已驗證,官方為了精簡預設依賴把它拆出去了)。你得自己加:
1 | <dependency> |
沒加的後果不是報錯,是註解靜默失效——編譯過、跑得動、就是不驗。debug 這種東西會讓你懷疑人生,先記著。
@NotNull、@NotEmpty、@NotBlank:長很像,行為差很多
換成註解之後,第一個要搞清楚的是這三兄弟。名字都在講「不要空」,但空的定義不一樣,選錯就是漏洞。照 Jakarta Bean Validation 3.0 規格原文(已驗證):
@NotNull 只管一件事——不是 null。空字串 ""、全是空白的 " ",它都放行。所以字串欄位單用 @NotNull 其實擋不住「使用者送一個空白過來」。
@NotEmpty 是「不是 null,而且長度大於 0」。"" 會被擋,但 " "(三個空白、長度是 3)照樣通過。它還能用在 Collection、Map、陣列,語意是「至少要有一個元素」。
@NotBlank 只給字串用,它會先 trim 再看還剩不剩非空白字元。null、""、" " 全部擋掉。
講白了:**字串必填,用 @NotBlank**;集合不能為空,用 @NotEmpty;物件參考不能是 null,才用 @NotNull。這是最常被寫錯的一組。
還有個容易忽略的規則:除了這三兄弟,其他所有 constraint 碰到 null 一律放行(已驗證)。@Min、@Size、@Email 遇到 null 都當作「沒東西可驗,過」。所以「這欄必填、而且要符合格式」得兩個註解疊著用,像上面 email 那樣 @Email 配 @NotBlank。
同一個 DTO,新增和更新的規則不一樣
真實世界很快會冒出這種需求:新增使用者時 id 應該是空的(由資料庫發號),更新時 id 卻是必填的。同一個 UserDto,兩種情境兩套規則。手寫 if 的時代你會開兩個 method 各寫各的檢查;註解的時代有更乾淨的解法——分組驗證(groups)。
先定義兩個空 interface 當標記,再用 groups 屬性標記每條規則屬於哪個情境:
1 | public interface Create {} |
這裡有個關鍵切換:@Valid 不支援 groups,要分組你得換成 Spring 自己的 @Validated(已驗證,兩者差別就在這)。controller 用 @Validated 指定這次要套哪一組:
1 |
|
一份 DTO、一份宣告,靠情境切換規則子集。要提醒一點:用了 @Validated(Create.class),沒標 group 的規則預設不會被套(它們屬於 Default 組)。想讓通用規則在各情境都生效,讓 Create extends Default 是常見做法(推測,屬 groups 繼承機制的標準用法)。
內建註解不夠用?自己寫一個
註解就那幾個,總有 cover 不到的。台灣手機號碼要 09 開頭共 10 碼——沒有現成註解。老做法又是回去 controller 寫 regex if,新做法是把這條規則也做成一個註解,讓它跟內建的長得一模一樣、能疊加使用。
自訂 constraint 兩步驟。先定義註解,message、groups、payload 這三個屬性是規格強制的、少一個都不能用(已驗證):
1 |
|
再實作驗證邏輯,ConstraintValidator 的兩個泛型分別是「註解型別」和「被驗欄位型別」:
1 | public class TaiwanMobileValidator |
之後 @TaiwanMobile 就是一等公民,跟 @NotBlank 並排掛在欄位上。你等於在替團隊擴充驗證的詞彙表,而不是每次都回 controller 手刻。
錯誤怎麼回:從各自 return 到統一攔截
規則搬走了,還剩最後一塊:驗證失敗要回什麼給前端。老做法每個 method 自己 return ResponseEntity.badRequest(),格式各憑良心,前端接得很痛苦。新做法是驗證失敗會拋例外,你在一個地方統一攔截、統一格式。
這裡有個 Spring Boot 3.2 之後一定要搞清楚的坑,不然你的 exception handler 會抓不到。驗證失敗拋哪個例外,取決於你怎麼掛註解(已驗證):
@Valid/@Validated掛在@RequestBodyDTO 上(整包物件驗證)→ 拋MethodArgumentNotValidException。這個一直沒變。- 註解直接掛在散裝參數上(
@RequestParam、@PathVariable直接寫@NotBlank)→ 這裡變了。Spring Framework 6.1(也就是 Spring Boot 3.2)加了內建的 method validation,改拋HandlerMethodValidationException,取代了以前的ConstraintViolationException。
所以如果你的專案剛升上 Boot 3.2,散裝參數的驗證還走舊的 ConstraintViolationException handler,會發現例外突然沒被接到、直接噴 500。官方的建議很直接:三種都接(已驗證)。
1 |
|
到這一步,整個流程閉環了:規則宣告在 DTO、Spring 在進入 controller 前就攔下不合法的請求、失敗自動拋例外、advice 統一收成一份前端能吃的 JSON。你的 controller 從頭到尾沒再出現一個 if。
真正改變的是什麼
回頭看這一路換掉的東西,容易以為重點是「註解比 if 少打字」。不是。省下的行數只是副作用。
真正變的是驗證邏輯的住址。以前它散在每一個處理流程裡,跟商業邏輯攪在一起,同一條規則被複製到你數不清的角落——你永遠不確定某個入口有沒有漏檢查。現在它只有一個住址:資料的邊界。規則貼在 DTO 上,任何請求想進到你的商業邏輯,都得先通過門口這一關,一個都跑不掉。
從「每次都要記得檢查」變成「不檢查就進不來」,這是心智上的位移。你不再是那個站在流程裡逐欄唸表格的櫃檯人員,你是把「這張表該長怎樣」直接印在表格上、然後放手的人。少寫的那些 if,是這個位移順手掉下來的東西而已。










