資料驗證是 Web 應用安全的第一道防線。Spring MVC 整合 **Bean Validation (JSR-303/JSR-349/JSR-380)**,提供聲明式驗證、程式化驗證、分組驗證、自定義約束完整體系。本文從標準註解、自定義驗證器、控制器整合、前端 AJAX 協作四個維度系統闡述。
| 註解 | 適用類型 | 語義 | 範例 |
|---|---|---|---|
| @NotNull | 任意 | 不為 null | @NotNull(message = "姓名必填") |
| @NotEmpty | 集合、陣列、Map、String | 不為 null 且 size > 0 | @NotEmpty List |
| @NotBlank | String | 不為 null 且 trim 後長度 > 0 | @NotBlank String username |
| @Size | 集合、陣列、Map、String | 大小在 [min, max] | @Size(min=6, max=20) String password |
| @Min / @Max | 數值類型 | 數值範圍 | @Min(18) @Max(100) int age |
| @DecimalMin / @DecimalMax | BigDecimal、String | 精確數值範圍 | @DecimalMin("0.01") BigDecimal price |
| @Digits | 數值、String | 整數位、小數位限制 | @Digits(integer=10, fraction=2) BigDecimal amount |
| @Pattern | String | 正則匹配 | @Pattern(regexp="^1[3-9]\\d{9}$") String mobile |
| String | 郵箱格式 | @Email String email | |
| @Past / @Future | Date、Calendar、Instant | 過去/未來時間 | @Future LocalDateTime expireTime |
| @AssertTrue / @AssertFalse | Boolean | 必為 true/false | @AssertTrue Boolean agreed |
| @Valid | 巢狀物件 | 遞歸驗證 | @Valid Address address |
public class UserForm {
@NotBlank(message = "用戶名不能為空")
@Size(min = 3, max = 20, message = "用戶名長度 3-20 位")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用戶名只能包含字母、數字、下劃線")
private String username;
@NotBlank(message = "密碼不能為空")
@Size(min = 8, max = 32, message = "密碼長度 8-32 位")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", message = "密碼需含大小寫字母和數字")
private String password;
@Email(message = "郵箱格式不正確")
private String email;
@Min(value = 18, message = "年齡不能小於 18 歲")
@Max(value = 100, message = "年齡不能大於 100 歲")
private Integer age;
@Valid // 巢狀驗證
private AddressForm address;
// Getter/Setter...
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public Result> register(@Validated @RequestBody UserForm form, BindingResult bindingResult) {
// 1. 有錯誤直接返回
if (bindingResult.hasErrors()) {
return Result.error(extractErrors(bindingResult));
}
// 2. 業務邏輯
userService.register(form);
return Result.success();
}
private Map extractErrors(BindingResult br) {
Map errors = new LinkedHashMap<>();
br.getFieldErrors().forEach(e ->
errors.put(e.getField(), e.getDefaultMessage())
);
return errors;
}
}
public interface RegisterGroup {} // 註冊場景
public interface LoginGroup {} // 登入場景
public interface UpdateGroup {} // 更新場景
public class UserForm {
@NotBlank(groups = {RegisterGroup.class, UpdateGroup.class})
private String username;
@NotBlank(groups = RegisterGroup.class)
@Size(min = 8, groups = RegisterGroup.class)
private String password;
@Email(groups = {RegisterGroup.class, UpdateGroup.class})
private String email;
}
@PostMapping("/register")
public Result> register(@Validated(RegisterGroup.class) @RequestBody UserForm form, BindingResult br) { ... }
@PostMapping("/login")
public Result> login(@Validated(LoginGroup.class) @RequestBody UserForm form, BindingResult br) { ... }
@PutMapping("/{id}")
public Result> update(@PathVariable Long id, @Validated(UpdateGroup.class) @RequestBody UserForm form, BindingResult br) { ... }
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
@Documented
public @interface Phone {
String message() default "手機號格式不正確";
Class>[] groups() default {};
Class extends Payload>[] payload() default {};
boolean required() default true; // 自定義屬性
}
public class PhoneValidator implements ConstraintValidator {
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
private boolean required;
@Override
public void initialize(Phone constraintAnnotation) {
this.required = constraintAnnotation.required();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return !required; // 不必填時允許為空
}
return PHONE_PATTERN.matcher(value).matches();
}
}
public class UserForm {
@Phone(required = true, message = "請輸入正確的手機號")
private String phone;
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "兩次密碼不一致";
Class>[] groups() default {};
Class extends Payload>[] payload() default {};
String first() default "password";
String second() default "confirmPassword";
}
public class PasswordMatchValidator implements ConstraintValidator {
private String firstField;
private String secondField;
@Override
public void initialize(PasswordMatch constraintAnnotation) {
firstField = constraintAnnotation.first();
secondField = constraintAnnotation.second();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
Object first = PropertyUtils.getProperty(obj, firstField);
Object second = PropertyUtils.getProperty(obj, secondField);
return Objects.equals(first, second);
} catch (Exception e) {
return false;
}
}
}
// 使用:加在類上
@PasswordMatch(first = "password", second = "confirmPassword")
public class RegisterForm {
private String password;
private String confirmPassword;
}
@Service
public class UserService {
@Autowired
private Validator validator; // JSR-380 Validator 介面
public void register(UserForm form) {
// 驗證並拋出異常
Set> violations = validator.validate(form, RegisterGroup.class);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
// 或收集錯誤
Map errors = violations.stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
ConstraintViolation::getMessage,
(a, b) -> a,
LinkedHashMap::new
));
// 業務邏輯...
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result> handleValidationError(MethodArgumentNotValidException e) {
BindingResult br = e.getBindingResult();
Map errors = br.getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(a, b) -> a,
LinkedHashMap::new
));
return Result.error("驗證失敗", errors);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result> handleConstraintViolation(ConstraintViolationException e) {
Map errors = e.getConstraintViolations().stream()
.collect(Collectors.toMap(
v -> v.getPropertyPath().toString(),
ConstraintViolation::getMessage,
(a, b) -> a,
LinkedHashMap::new
));
return Result.error("驗證失敗", errors);
}
}
// axios 攔截器
axios.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 400 && error.response.data.errors) {
const errors = error.response.data.errors; // {field: message}
Object.entries(errors).forEach(([field, msg]) => {
const input = document.querySelector(`[name="${field}"]`);
if (input) {
showFieldError(input, msg);
}
});
}
return Promise.reject(error);
}
);
function showFieldError(input, message) {
clearFieldError(input);
input.classList.add('is-invalid');
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = message;
input.parentNode.appendChild(feedback);
}
public class Result {
private int code; // 200 成功, 400 驗證失敗, 500 系統錯誤
private String message;
private T data;
private Map errors; // 驗證錯誤詳情
public static Result success(T data) { ... }
public static Result error(String message, Map errors) { ... }
}
Spring MVC Validator 體系 = **JSR-303 標準註解 + 分組驗證 + 自定義 ConstraintValidator + 程式化 Validator + 全域異常處理**。掌握聲明式與程式化雙模式、分組與巢狀驗證、自定義約束開發、前後端錯誤協作協議,才能構建既安全又優雅的資料驗證體系。