打開任何一個上了年紀的 Spring 專案,翻到 controller,你大概會撞見這種東西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("/users")
public ResponseEntity<?> create(@RequestBody UserDto dto) {
if (dto.getName() == null || dto.getName().trim().isEmpty()) {
return ResponseEntity.badRequest().body("姓名不能空白");
}
if (dto.getEmail() == null || !dto.getEmail().contains("@")) {
return ResponseEntity.badRequest().body("email 格式錯誤");
}
if (dto.getAge() != null && dto.getAge() < 0) {
return ResponseEntity.badRequest().body("年齡不能是負數");
}
// 真正的商業邏輯要到這裡才開始
userService.save(dto);
...
}

商業邏輯只有一行,前面墊了十幾行檢查。更慘的是這段 if 會複製。update 也要檢查一次、批次匯入又檢查一次,同一條「email 要有 @」的規則散在三個地方,改規則要改三次,漏改一個就是線上事故。這篇就從這堆 if 出發,一段一段換掉它。

第一步:把檢查從程式碼搬到欄位上

老做法是「在流程裡問資料合不合法」,新做法是「在資料自己身上宣告它該長什麼樣」。Bean Validation(現在叫 Jakarta Bean Validation,Spring Boot 3.x 用的是 jakarta.validation 命名空間,實作是 Hibernate Validator 8.x/較新點版 9.x,已驗證)就是幹這件事的規格。它把規則變成註解,貼在 DTO 的欄位上:

1
2
3
4
5
6
7
8
9
10
11
12
public class UserDto {
@NotBlank(message = "姓名不能空白")
private String name;

@Email(message = "email 格式錯誤")
@NotBlank
private String email;

@Min(value = 0, message = "年齡不能是負數")
private Integer age;
// getter / setter 省略
}

controller 剩下一個 @Valid

1
2
3
4
@PostMapping("/users")
public void create(@Valid @RequestBody UserDto dto) {
userService.save(dto); // 進到這裡,dto 一定是乾淨的
}

差別不只是「行數變少」。規則現在只寫一次、貼在資料上,create、update、匯入全部共用同一份宣告。你可以把 DTO 想成一張報名表:以前是櫃檯人員拿到表逐欄唸「這欄沒填、那欄格式錯」,現在是表格本身每個欄位旁邊就印著「必填」「格式:09xxxxxxxx」,收表的人只要看表自己會不會擋。檢查的責任從「處理流程」挪回了「資料邊界」。

有個前提要先講,不然你會踩到一個很安靜的坑:Spring Boot 2.3 之後,spring-boot-starter-web 不再自動帶 validation(已驗證,官方為了精簡預設依賴把它拆出去了)。你得自己加:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

沒加的後果不是報錯,是註解靜默失效——編譯過、跑得動、就是不驗。debug 這種東西會讓你懷疑人生,先記著。

@NotNull、@NotEmpty、@NotBlank:長很像,行為差很多

換成註解之後,第一個要搞清楚的是這三兄弟。名字都在講「不要空」,但空的定義不一樣,選錯就是漏洞。照 Jakarta Bean Validation 3.0 規格原文(已驗證):

@NotNull 只管一件事——不是 null。空字串 ""、全是空白的 " ",它都放行。所以字串欄位單用 @NotNull 其實擋不住「使用者送一個空白過來」。

@NotEmpty 是「不是 null,而且長度大於 0」。"" 會被擋,但 " "(三個空白、長度是 3)照樣通過。它還能用在 CollectionMap、陣列,語意是「至少要有一個元素」。

@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
2
3
4
5
6
7
8
9
10
11
public interface Create {}
public interface Update {}

public class UserDto {
@Null(groups = Create.class) // 新增時 id 必須為空
@NotNull(groups = Update.class) // 更新時 id 必填
private Long id;

@NotBlank(groups = {Create.class, Update.class})
private String name;
}

這裡有個關鍵切換:@Valid 不支援 groups,要分組你得換成 Spring 自己的 @Validated(已驗證,兩者差別就在這)。controller 用 @Validated 指定這次要套哪一組:

1
2
3
4
5
@PostMapping
public void create(@Validated(Create.class) @RequestBody UserDto dto) { ... }

@PutMapping("/{id}")
public void update(@Validated(Update.class) @RequestBody UserDto dto) { ... }

一份 DTO、一份宣告,靠情境切換規則子集。要提醒一點:用了 @Validated(Create.class),沒標 group 的規則預設不會被套(它們屬於 Default 組)。想讓通用規則在各情境都生效,讓 Create extends Default 是常見做法(推測,屬 groups 繼承機制的標準用法)。

內建註解不夠用?自己寫一個

註解就那幾個,總有 cover 不到的。台灣手機號碼要 09 開頭共 10 碼——沒有現成註解。老做法又是回去 controller 寫 regex if,新做法是把這條規則也做成一個註解,讓它跟內建的長得一模一樣、能疊加使用。

自訂 constraint 兩步驟。先定義註解,messagegroupspayload 這三個屬性是規格強制的、少一個都不能用(已驗證):

1
2
3
4
5
6
7
8
9
@Documented
@Constraint(validatedBy = TaiwanMobileValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface TaiwanMobile {
String message() default "手機號碼須為 09 開頭共 10 碼";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

再實作驗證邏輯,ConstraintValidator 的兩個泛型分別是「註解型別」和「被驗欄位型別」:

1
2
3
4
5
6
7
8
public class TaiwanMobileValidator
implements ConstraintValidator<TaiwanMobile, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null 交給 @NotNull,跟內建行為一致
return value.matches("^09\\d{8}$");
}
}

之後 @TaiwanMobile 就是一等公民,跟 @NotBlank 並排掛在欄位上。你等於在替團隊擴充驗證的詞彙表,而不是每次都回 controller 手刻。

錯誤怎麼回:從各自 return 到統一攔截

規則搬走了,還剩最後一塊:驗證失敗要回什麼給前端。老做法每個 method 自己 return ResponseEntity.badRequest(),格式各憑良心,前端接得很痛苦。新做法是驗證失敗會拋例外,你在一個地方統一攔截、統一格式。

這裡有個 Spring Boot 3.2 之後一定要搞清楚的坑,不然你的 exception handler 會抓不到。驗證失敗拋哪個例外,取決於你怎麼掛註解(已驗證):

  • @Valid@Validated 掛在 @RequestBody DTO 上(整包物件驗證)→ 拋 MethodArgumentNotValidException。這個一直沒變。
  • 註解直接掛在散裝參數上(@RequestParam@PathVariable 直接寫 @NotBlank)→ 這裡變了。Spring Framework 6.1(也就是 Spring Boot 3.2)加了內建的 method validation,改拋 HandlerMethodValidationException,取代了以前的 ConstraintViolationException

所以如果你的專案剛升上 Boot 3.2,散裝參數的驗證還走舊的 ConstraintViolationException handler,會發現例外突然沒被接到、直接噴 500。官方的建議很直接:三種都接(已驗證)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestControllerAdvice
public class GlobalValidationExceptionHandler {

public record FieldMessage(String field, String message) {}

// 1) @RequestBody DTO 驗證失敗
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public List<FieldMessage> handleBody(MethodArgumentNotValidException ex) {
return ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldMessage(fe.getField(), fe.getDefaultMessage()))
.toList();
}

// 2) Boot 3.2+ 散裝參數(@RequestParam / @PathVariable)驗證失敗
@ExceptionHandler(HandlerMethodValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public List<FieldMessage> handleMethod(HandlerMethodValidationException ex) {
List<FieldMessage> errors = new ArrayList<>();
ex.getAllValidationResults().forEach(result ->
result.getResolvableErrors().forEach(err ->
errors.add(new FieldMessage(
result.getMethodParameter().getParameterName(),
err.getDefaultMessage()))));
return errors;
}
}

到這一步,整個流程閉環了:規則宣告在 DTO、Spring 在進入 controller 前就攔下不合法的請求、失敗自動拋例外、advice 統一收成一份前端能吃的 JSON。你的 controller 從頭到尾沒再出現一個 if。

真正改變的是什麼

回頭看這一路換掉的東西,容易以為重點是「註解比 if 少打字」。不是。省下的行數只是副作用。

真正變的是驗證邏輯的住址。以前它散在每一個處理流程裡,跟商業邏輯攪在一起,同一條規則被複製到你數不清的角落——你永遠不確定某個入口有沒有漏檢查。現在它只有一個住址:資料的邊界。規則貼在 DTO 上,任何請求想進到你的商業邏輯,都得先通過門口這一關,一個都跑不掉。

從「每次都要記得檢查」變成「不檢查就進不來」,這是心智上的位移。你不再是那個站在流程裡逐欄唸表格的櫃檯人員,你是把「這張表該長怎樣」直接印在表格上、然後放手的人。少寫的那些 if,是這個位移順手掉下來的東西而已。