验证框架 - @Valid/@Validated、JSR-303/380 Bean Validation 与自定义校验器
概述
Spring Framework 提供了一套完整的验证(Validation)体系,既兼容 Java 标准的 JSR-303(Bean Validation 1.0) 和 JSR-380(Bean Validation 2.0) 规范,又提供了自身灵活的验证机制。本文从底层原理到实战应用,深入剖析 Spring 验证框架的各个核心组件。
1. @Valid vs @Validated 区别
@Valid 和 @Validated 是 Spring 验证体系中最常用的两个注解,但它们在来源、能力和行为上存在显著差异。
1.1 来源差异
| 特性 | @Valid | @Validated |
|---|---|---|
| 所属规范 | JSR-303/380(Java 标准) | Spring 自定义 |
| 包路径 | jakarta.validation.Valid | org.springframework.validation.annotation.Validated |
| 支持嵌套验证 | ✅ 原生支持 | ❌ 需配合 @Valid |
| 支持分组校验 | ❌ 不支持 | ✅ 核心特性 |
| 方法级别验证 | ✅ 标记参数 | ✅ 标记类/参数,并指定分组 |
1.2 Spring 对 @Valid 的处理机制
Spring 的 LocalValidatorFactoryBean 适配了 JSR 规范,当检测到 @Valid 注解时,会委托给底层的 Bean Validation 实现(如 Hibernate Validator)执行校验。
@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
// @Valid 触发 User 对象的完整校验
return ResponseEntity.ok(userService.save(user));
}
}1.3 @Validated 的分组校验能力
@Validated 是 Spring 提供的扩展注解,其核心价值在于支持分组校验:
@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<User> createUser(
@Validated(CreateGroup.class) @RequestBody User user) {
return ResponseEntity.ok(userService.save(user));
}
@PutMapping("/users/{id}")
public ResponseEntity<User> updateUser(
@Validated(UpdateGroup.class) @RequestBody User user) {
return ResponseEntity.ok(userService.update(user));
}
}1.4 嵌套校验行为对比
使用 @Valid 触发嵌套校验:
public class Order {
@NotNull
private String orderNo;
@Valid // 嵌套校验 OrderItem 中的约束
private List<OrderItem> items;
}
public class OrderItem {
@NotBlank
private String productId;
@Min(1)
private int quantity;
}使用 @Validated 时,嵌套校验仍需 @Valid 配合:
public class Order {
@NotNull(groups = CreateGroup.class)
private String orderNo;
@Valid // @Validated 本身不触发嵌套校验,必须加 @Valid
private List<OrderItem> items;
}总结:
@Validated不能替代@Valid在嵌套校验中的作用。最佳实践是在属性上使用@Valid触发级联验证,在控制器参数上使用@Validated指定分组。
2. JSR-303/380 Bean Validation 标准注解
JSR-303(Bean Validation 1.0)定义了基础的校验注解,JSR-380(Bean Validation 2.0,Java 8+)在此基础上做了重要扩展。下表列出最常用的内置注解:
2.1 注解速查表
| 注解 | 支持版本 | 作用目标 | 说明 |
|---|---|---|---|
@NotNull | JSR-303 | 任意类型 | 值不能为 null |
@NotEmpty | JSR-303 | 字符串/集合/Map/数组 | 值不能为 null,且长度/大小 > 0 |
@NotBlank | JSR-303 | 字符串 | 值不能为 null,且去除前后空格后长度 > 0 |
@Size | JSR-303 | 字符串/集合/Map/数组 | 长度/大小在指定范围内(min/max) |
@Min | JSR-303 | 数值类型(及包装类) | 值 ≥ 最小值 |
@Max | JSR-303 | 数值类型(及包装类) | 值 ≤ 最大值 |
@DecimalMin | JSR-303 | BigDecimal/数值 | 值 ≥ 最小值(精确比较) |
@DecimalMax | JSR-303 | BigDecimal/数值 | 值 ≤ 最大值(精确比较) |
@Positive | JSR-380 | 数值类型 | 值必须为正数(> 0) |
@PositiveOrZero | JSR-380 | 数值类型 | 值必须为正数或零(≥ 0) |
@Negative | JSR-380 | 数值类型 | 值必须为负数(< 0) |
@NegativeOrZero | JSR-380 | 数值类型 | 值必须为负数或零(≤ 0) |
@Email | JSR-380 | 字符串 | 符合邮箱格式(无需 null 检查) |
@Pattern | JSR-303 | 字符串 | 匹配指定正则表达式 |
@AssertTrue | JSR-303 | boolean | 值必须为 true |
@AssertFalse | JSR-380 | boolean | 值必须为 false |
@Past | JSR-303 | Date/Calendar/时间类型 | 时间必须在过去 |
@PastOrPresent | JSR-380 | 时间类型 | 时间必须在过去或现在 |
@Future | JSR-303 | Date/Calendar/时间类型 | 时间必须在未来 |
@FutureOrPresent | JSR-380 | 时间类型 | 时间必须在未来或现在 |
@Digits | JSR-303 | 数值/字符串 | 整数部分和小数部分位数限制 |
2.2 完整使用示例
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
public class Employee {
@NotNull(message = "ID 不能为空")
private Long id;
@NotBlank(message = "姓名不能为空")
@Size(min = 2, max = 50, message = "姓名长度必须在 {min}-{max} 之间")
private String name;
@Email(message = "邮箱格式不正确")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotNull
@Min(value = 18, message = "年龄不能小于 {value}")
@Max(value = 65, message = "年龄不能大于 {value}")
private Integer age;
@NotNull
@DecimalMin(value = "0.01", message = "薪资必须大于零")
@DecimalMax(value = "999999.99", message = "薪资超出上限")
@Digits(integer = 6, fraction = 2, message = "薪资格式不正确")
private BigDecimal salary;
@Past(message = "出生日期必须是过去的时间")
private LocalDate birthDate;
@Future(message = "合同到期日必须是未来的时间")
private LocalDate contractEndDate;
@AssertTrue(message = "必须同意使用条款")
private boolean agreeToTerms;
// getters / setters 省略
}2.3 JSR-380 的新增特性
JSR-380(Bean Validation 2.0)相较于 JSR-303 的主要增强:
- 支持 Java 8 日期时间类型(
LocalDate、LocalDateTime、Instant等) - 新增
@Email成为一级注解(JSR-303 中通过@Pattern实现) - 新增
@NotEmpty/@NotBlank从 Hibernate Validator 升级为标准注解 - 新增
@Positive/@PositiveOrZero/@Negative/@NegativeOrZero - 新增
@PastOrPresent/@FutureOrPresent - 支持
Optional、List等容器类型的自动拆箱校验
3. Validator 接口与 DataBinder 验证集成
Spring 定义了自己的验证接口 org.springframework.validation.Validator,它独立于 JSR 规范,但在实际应用中常与 JSR 校验器协同工作。
3.1 Validator 接口定义
package org.springframework.validation;
public interface Validator {
/**
* 判断此验证器是否支持验证指定类型的对象
*/
boolean supports(Class<?> clazz);
/**
* 执行验证逻辑,错误信息收集到 Errors 对象中
*/
void validate(Object target, Errors errors);
}3.2 实现自定义 Validator
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
public class UserValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return User.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
User user = (User) target;
// 使用 ValidationUtils 提供的便捷空值检查
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "username", "user.username.required", "用户名不能为空");
ValidationUtils.rejectIfEmpty(errors, "email", "user.email.required", "邮箱不能为空");
// 自定义业务规则校验
if (user.getAge() != null && user.getAge() < 18) {
errors.rejectValue("age", "user.age.min", new Object[]{18}, "年龄不能小于 {0}");
}
// 跨字段校验
if (user.getPassword() != null && user.getConfirmPassword() != null
&& !user.getPassword().equals(user.getConfirmPassword())) {
errors.rejectValue("confirmPassword", "user.password.mismatch", "两次密码不一致");
}
}
}3.3 与 DataBinder 集成
DataBinder 是 Spring MVC 中将请求参数绑定到 Java 对象的核心组件,同时它也负责执行验证:
@RestController
public class UserController {
@PostMapping("/register")
public String registerUser(@Validated User user, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// 处理校验错误
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
return "校验失败:" + fieldErrors.stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
}
return "注册成功";
}
}在 Controller 中手动添加 Validator:
@RestController
@InitBinder
public class UserController {
@InitBinder
public void initBinder(WebDataBinder binder) {
// 添加自定义 Validator(与 JSR 验证共存)
binder.addValidators(new UserValidator());
}
@PostMapping("/register")
public String registerUser(@Validated User user, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// bindingResult 中同时包含 JSR 注解校验和自定义 Validator 的错误
return "校验失败:" + bindingResult.getAllErrors().stream()
.map(e -> e.getDefaultMessage())
.collect(Collectors.joining("; "));
}
return "注册成功";
}
}3.4 Errors 与 BindingResult 接口层次
Errors (接口)
└── BindingResult (接口) extends Errors
└── AbstractBindingResult (抽象类)
├── BeanPropertyBindingResult
└── MapBindingResultErrors:校验结果的顶级接口,提供reject()、rejectValue()等方法BindingResult:扩展了Errors,提供绑定相关的数据访问FieldError:表示字段级别的错误ObjectError:表示对象级别的错误(如跨字段校验)
重要规则: BindingResult 参数必须紧跟在校验对象参数之后,否则 Spring 将无法正确绑定:
// ✅ 正确:BindingResult 紧跟在校验对象之后
public String handle(@Validated User user, BindingResult result) { }
// ❌ 错误:中间插入了其他参数
public String handle(@Validated User user, Model model, BindingResult result) { }4. 分组校验
分组校验是 Bean Validation 中最重要的高级特性之一,允许针对同一实体在不同场景下应用不同的校验规则。
4.1 定义分组接口
分组是普通的 Java 接口(标记接口),无需实现任何方法:
public interface CreateGroup {
}
public interface UpdateGroup {
}
// 用于仅查询场景
public interface QueryGroup {
}4.2 在实体中应用分组
import jakarta.validation.constraints.*;
import jakarta.validation.groups.Default;
public class User {
@Null(groups = CreateGroup.class, message = "创建时 ID 必须为空")
@NotNull(groups = UpdateGroup.class, message = "更新时 ID 不能为空")
private Long id;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class}, message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度在 3-20 之间")
private String username;
@NotBlank(groups = CreateGroup.class, message = "创建时必须填写密码")
@Size(min = 6, max = 32, groups = CreateGroup.class)
@Null(groups = UpdateGroup.class, message = "更新时不能传递密码字段,请使用专用接口")
private String password;
@Email
@NotBlank(groups = Default.class) // Default 分组始终生效
private String email;
// getters / setters
}4.3 在 Controller 中指定分组
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ResponseEntity<?> create(@Validated(CreateGroup.class) @RequestBody User user) {
// 仅验证 CreateGroup 分组(不含 Default)
return ResponseEntity.ok(userService.save(user));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(
@Validated(UpdateGroup.class) @RequestBody User user) {
// 仅验证 UpdateGroup 分组(不含 Default)
return ResponseEntity.ok(userService.update(user));
}
@GetMapping("/search")
public ResponseEntity<?> search(@Validated(QueryGroup.class) UserQuery query) {
return ResponseEntity.ok(userService.search(query));
}
}4.4 分组继承
分组可以继承,子分组将包含父分组的所有约束:
public interface BaseGroup {
}
// OperationGroup 继承了 BaseGroup,验证 OperationGroup 时会同时验证 BaseGroup 的约束
public interface OperationGroup extends BaseGroup {
}
// 在 Controller 中指定 OperationGroup
// 此时 BaseGroup 和 OperationGroup 上的约束都会被执行验证4.5 组序列 @GroupSequence
默认情况下,分组校验按任意顺序执行。@GroupSequence 允许指定分组校验的顺序——如果前一个分组校验失败,后续分组将不再执行:
import jakarta.validation.GroupSequence;
/**
* 定义校验顺序:
* 1. 先执行基础检查(FirstGroup)
* 2. 再执行精确检查(SecondGroup)
*/
public interface FirstGroup {
}
public interface SecondGroup {
}
@GroupSequence({FirstGroup.class, SecondGroup.class, User.class})
public interface UserFullCheck {
}注意:
@GroupSequence中的最后一个元素必须是实体类本身(代表Default分组),或者必须包含所有未分组的约束。
实体类中的应用:
public class User {
@NotNull(groups = FirstGroup.class)
@Size(min = 1, max = 100, groups = SecondGroup.class)
private String name;
@NotNull(groups = FirstGroup.class)
@Email(groups = SecondGroup.class)
private String email;
}
// 使用组序列验证
// @Validated(UserFullCheck.class) 会先执行 FirstGroup,若通过再执行 SecondGroup4.6 @GroupSequence 的行为说明
| 组序列配置 | 执行顺序 | 失败后的行为 |
|---|---|---|
@GroupSequence({A.class, B.class, Bean.class}) | A → B → Default | A 失败则 B 和 Default 不再执行 |
@GroupSequence({A.class, B.class}) | A → B | Default 不执行 |
@GroupSequence({Bean.class}) | Default | 等同于不指定分组 |
5. 嵌套校验
嵌套校验(Cascading Validation)用于校验对象内部的关联对象属性。
5.1 @Valid 级联验证基础用法
public class Order {
@NotNull
private String orderId;
@Valid // 级联验证 ShippingAddress 中的约束
@NotNull
private ShippingAddress address;
@Valid // 级联验证 PaymentInfo 中的约束
private PaymentInfo payment;
}
public class ShippingAddress {
@NotBlank(message = "收件人不能为空")
private String recipient;
@NotBlank(message = "地址不能为空")
private String detail;
@Pattern(regexp = "^\\d{6}$", message = "邮编格式不正确")
private String zipCode;
}
public class PaymentInfo {
@NotBlank
private String cardNumber;
@Future(message = "有效期必须是将来的时间")
private LocalDate expiryDate;
}5.2 集合嵌套验证
对 List、Set 等集合类型同样适用 @Valid:
public class Order {
@NotNull
private String orderId;
@Valid // 验证集合中每一个 OrderItem 对象
@NotEmpty(message = "订单项不能为空")
private List<OrderItem> items;
@Valid
private Set<OrderItem> itemsSet;
@Valid
@Size(min = 1, max = 10)
private OrderItem[] itemArray;
}
public class OrderItem {
@NotBlank
private String sku;
@Min(1)
@Max(999)
private Integer quantity;
@NotNull
@Positive
private BigDecimal price;
}5.3 Map 嵌套校验
JSR-380 不直接支持 Map 值的级联验证,但可以使用自定义容器类型或结合 Spring 的方式处理:
// 使用支持泛型值校验的自定义包装器(需要自定义实现)
// 或者使用清单方式逐个校验 key-value
public class Order {
@Valid
private Map<@Valid String, @Valid OrderItem> orderItemMap;
// 注意:对 Map key 的 @Valid 需要 Bean Validation 2.0+ 和 Hibernate Validator 6.0+
}5.4 嵌套校验的传播规则
| 场景 | 行为 |
|---|---|
属性标记 @Valid,值为 null | 不触发校验(除非属性本身有 @NotNull) |
属性标记 @Valid,值为非 null 对象 | 递归校验该对象的所有受约束字段 |
集合属性标记 @Valid | 对集合中每个元素执行校验 |
| 多层嵌套 | 递归向下传播,直至遇到未标记 @Valid 的属性 |
6. 自定义校验注解
当标准注解无法满足业务需求时,可以通过 @Constraint 元注解创建自定义校验注解。
6.1 自定义校验注解的核心要素
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class) // 指定校验器实现类
@Documented // 生成 JavaDoc 时包含注解
public @interface Phone {
// message:校验失败时的默认提示消息,支持国际化(通过 ValidationMessages.properties)
String message() default "手机号格式不正确";
// groups:分组支持
Class<?>[] groups() default {};
// payload:元数据扩展,一般不常用
Class<? extends Payload>[] payload() default {};
/**
* 可选:允许指定手机号类型(如:中国、美国)
* 此属性是自定义的业务扩展,非必须
*/
String region() default "CN";
}6.2 定义注解属性的最佳实践
| 属性 | 是否必需 | 说明 |
|---|---|---|
message | ✅ 必需 | 错误消息模板,从 ValidationMessages.properties 加载 |
groups | ✅ 必需 | 默认空数组 {} |
payload | ✅ 必需 | 默认空数组 {} |
| 业务属性 | 可选 | 如 region、type 等自定义配置 |
6.3 ConstraintValidator 实现
实现 ConstraintValidator<A, T> 接口,A 为注解类型,T 为校验目标类型:
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class PhoneValidator implements ConstraintValidator<Phone, String> {
private String region;
/**
* 初始化方法,可获取注解中的属性值
*/
@Override
public void initialize(Phone annotation) {
this.region = annotation.region();
}
/**
* 执行校验逻辑
*
* @param value 待校验的字符串
* @param context 校验上下文,可自定义错误信息
* @return true=校验通过,false=校验失败
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// null 值视为有效(由 @NotNull 单独处理)
if (value == null) {
return true;
}
// 根据 region 选择不同的校验规则
if ("CN".equals(region)) {
return isValidChinesePhone(value);
} else if ("US".equals(region)) {
return isValidUSPhone(value);
}
return false;
}
private boolean isValidChinesePhone(String phone) {
return phone.matches("^1[3-9]\\d{9}$");
}
private boolean isValidUSPhone(String phone) {
return phone.matches("^\\+1\\d{10}$");
}
}6.4 自定义错误信息
在 ConstraintValidator.isValid() 中动态设置错误消息:
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
if (!isValidChinesePhone(value)) {
// 禁用默认消息
context.disableDefaultConstraintViolation();
// 构建自定义消息
context.buildConstraintViolationWithTemplate("手机号 " + value + " 不是有效的中国手机号")
.addConstraintViolation();
return false;
}
return true;
}6.5 多字段交叉校验
对于需要比较多个字段值的校验(如密码确认),应在类级别而非字段级别使用注解:
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
@Documented
public @interface PasswordMatch {
String message() default "两次密码输入不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}public class PasswordMatchValidator
implements ConstraintValidator<PasswordMatch, Object> {
@Override
public boolean isValid(Object target, ConstraintValidatorContext context) {
// 使用反射获取字段值
try {
String password = (String) target.getClass()
.getMethod("getPassword").invoke(target);
String confirmPassword = (String) target.getClass()
.getMethod("getConfirmPassword").invoke(target);
if (password == null && confirmPassword == null) {
return true;
}
return password != null && password.equals(confirmPassword);
} catch (Exception e) {
return false;
}
}
}在实体上的应用:
@PasswordMatch(groups = CreateGroup.class)
public class User {
@NotBlank
private String password;
@NotBlank
private String confirmPassword;
// getters / setters
}6.6 组合注解
可以将多个标准注解组合成一个自定义注解:
@NotNull
@Size(min = 8, max = 32)
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
message = "密码必须包含大小写字母和数字")
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StrongPassword {
String message() default "密码强度不足";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}7. 方法级别验证
Spring 支持对 Service 层或任意 Bean 的方法参数和返回值进行验证。
7.1 启用方法级别验证
需要配置 MethodValidationPostProcessor(Spring Boot 中通常无需手动配置):
@Configuration
public class ValidationConfig {
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
return new MethodValidationPostProcessor();
}
}Spring Boot 2.x/3.x 中,只要引入了 spring-boot-starter-validation,上述配置自动生效。
7.2 在类级别使用 @Validated
import org.springframework.validation.annotation.Validated;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
@Service
@Validated // 在类级别启用方法验证
public class UserService {
/**
* 验证参数
*/
public User createUser(
@NotBlank(message = "用户名不能为空") String username,
@Email(message = "邮箱格式不正确") @NotBlank String email,
@Min(18) @Max(120) Integer age) {
// 构建并返回 User
return new User(username, email, age);
}
/**
* 对复杂对象参数使用 @Valid 触发嵌套校验
*/
public User updateUser(@Valid User user) {
// @Valid 触发 User 内部的所有约束
return userRepository.save(user);
}
/**
* 验证返回值
*/
@Valid // 返回值也会被校验
public User getUserById(@Positive Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
// 返回 null 时不触发校验
return null;
}
return user;
}
}7.3 Controller 中的方法级别验证
@RestController
@Validated // 开启类级别的方法验证
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable @Positive(message = "ID 必须为正数") Long id) {
return userService.getUserById(id);
}
@GetMapping("/search")
public List<User> searchUsers(
@RequestParam @NotBlank String keyword,
@RequestParam @Min(1) @Max(100) int page,
@RequestParam @Min(1) @Max(50) int size) {
return userService.search(keyword, page, size);
}
}7.4 方法级别验证的异常处理
当方法参数或返回值校验失败时,Spring 抛出以下异常:
| 异常类型 | 触发场景 |
|---|---|
ConstraintViolationException | 方法参数或返回值违反约束 |
MethodArgumentNotValidException | @RequestBody + @Valid 校验失败 |
BindException | @ModelAttribute + @Valid 校验失败 |
全局异常处理:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleConstraintViolation(
ConstraintViolationException ex) {
Map<String, Object> errors = new HashMap<>();
errors.put("code", 400);
errors.put("message", "参数校验失败");
List<Map<String, String>> details = ex.getConstraintViolations().stream()
.map(violation -> {
Map<String, String> detail = new HashMap<>();
// 获取校验失败的字段路径
String field = violation.getPropertyPath().toString();
detail.put("field", field);
detail.put("message", violation.getMessage());
return detail;
})
.collect(Collectors.toList());
errors.put("details", details);
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(msg1, msg2) -> msg1 // 同字段多个错误取第一个
));
return ResponseEntity.badRequest().body(Map.of(
"code", 400,
"message", "请求参数校验失败",
"errors", fieldErrors
));
}
}8. 实战案例:自定义手机号/身份证/银行卡号校验器 + 分组校验
本节实现一个完整的用户信息验证体系,包含三种自定义校验器,并通过分组实现在创建和更新时使用不同的校验规则。
8.1 定义分组接口
// 创建场景分组
public interface CreateGroup {
}
// 更新场景分组
public interface UpdateGroup {
}8.2 自定义手机号校验注解
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
@Documented
public @interface PhoneNumber {
String message() default "手机号格式无效";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
/**
* 中国手机号正则:
* 1 - 以 1 开头
* 2 - 第二位为 3-9
* 3 - 后跟 9 位数字
*/
private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true; // 空值由 @NotNull/@NotBlank 处理
}
return value.matches(PHONE_REGEX);
}
}8.3 自定义身份证号校验注解
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdCardValidator.class)
@Documented
public @interface IdCard {
String message() default "身份证号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class IdCardValidator implements ConstraintValidator<IdCard, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true;
}
return isValidChineseIdCard(value);
}
/**
* 中国居民身份证号校验(18 位)
* 规则:
* 1-6 位:地址码
* 7-14 位:出生日期码
* 15-17 位:顺序码(第17位为性别码)
* 18 位:校验码(0-9 或 X)
*/
private boolean isValidChineseIdCard(String idCard) {
if (idCard == null || idCard.length() != 18) {
return false;
}
// 基本格式:17位数字 + 1位数字或X
if (!idCard.matches("^\\d{17}[\\dXx]$")) {
return false;
}
// 校验出生日期
String birthStr = idCard.substring(6, 14);
try {
int year = Integer.parseInt(birthStr.substring(0, 4));
int month = Integer.parseInt(birthStr.substring(4, 6));
int day = Integer.parseInt(birthStr.substring(6, 8));
if (year < 1900 || year > 2025) return false;
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
} catch (NumberFormatException e) {
return false;
}
// 校验 18 位校验码(ISO 7064:1983, MOD 11-2 算法)
return verifyChecksum(idCard);
}
private boolean verifyChecksum(String idCard) {
// 权重因子
int[] weights = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
// 校验码映射表
char[] checkCodes = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (idCard.charAt(i) - '0') * weights[i];
}
int mod = sum % 11;
char expectedCheckCode = checkCodes[mod];
char actualCheckCode = Character.toUpperCase(idCard.charAt(17));
return expectedCheckCode == actualCheckCode;
}
}8.4 自定义银行卡号校验注解
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = BankCardValidator.class)
@Documented
public @interface BankCard {
String message() default "银行卡号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class BankCardValidator implements ConstraintValidator<BankCard, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true;
}
// 银行卡号一般为 13-19 位数字
String cardNumber = value.replaceAll("\\s+", ""); // 去掉空格
if (!cardNumber.matches("^\\d{13,19}$")) {
return false;
}
// 使用 Luhn 算法校验银行卡号
return luhnCheck(cardNumber);
}
/**
* Luhn 算法(模 10 算法)
* 步骤:
* 1. 从右向左,将奇数位数字相加
* 2. 偶数位数字乘以 2,若结果 ≥ 10 则减去 9(或各位相加),然后求和
* 3. 将奇数位和与偶数位和相加,结果能被 10 整除则有效
*/
private boolean luhnCheck(String cardNumber) {
int sum = 0;
boolean alternate = false;
for (int i = cardNumber.length() - 1; i >= 0; i--) {
int digit = cardNumber.charAt(i) - '0';
if (alternate) {
digit *= 2;
if (digit > 9) {
digit -= 9; // 等价于各位数字相加
}
}
sum += digit;
alternate = !alternate;
}
return sum % 10 == 0;
}
}8.5 在实体类中应用自定义校验和分组
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
public class UserProfile {
// ====== ID 字段:创建时为空,更新时非空 ======
@Null(groups = CreateGroup.class, message = "新建用户 ID 不允许指定")
@NotNull(groups = UpdateGroup.class, message = "更新用户必须指定 ID")
private Long id;
// ====== 基本字段 ======
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class}, message = "真实姓名不能为空")
@Size(min = 2, max = 50, message = "姓名长度 2-50 个字符")
private String realName;
@NotBlank(groups = CreateGroup.class, message = "创建时必须填写手机号")
@PhoneNumber(groups = {CreateGroup.class, UpdateGroup.class}, message = "手机号格式不正确")
private String phone;
@Email(message = "邮箱格式不正确")
private String email;
// ====== 身份证:创建时必填,更新时可选(但填写则需校验) ======
@NotBlank(groups = CreateGroup.class, message = "创建时必须填写身份证号")
@IdCard(groups = {CreateGroup.class, UpdateGroup.class})
private String idCard;
// ====== 银行卡号:可选,但填写则需校验 ======
@BankCard(groups = {CreateGroup.class, UpdateGroup.class})
private String bankCard;
@Min(value = 1, message = "年龄非法")
@Max(value = 150, message = "年龄非法")
private Integer age;
@Past(message = "出生日期必须为过去的日期")
private LocalDate birthDate;
// ====== 嵌套对象:地址信息 ======
@NotNull(groups = CreateGroup.class, message = "地址信息不能为空")
@Valid
private Address address;
// getters / setters 省略
}地址实体类:
public class Address {
@NotBlank(groups = CreateGroup.class, message = "省份不能为空")
private String province;
@NotBlank(groups = CreateGroup.class, message = "城市不能为空")
private String city;
@NotBlank(groups = CreateGroup.class, message = "详细地址不能为空")
private String detail;
@Pattern(regexp = "^\\d{6}$", message = "邮编格式不正确")
private String zipCode;
// getters / setters
}8.6 Controller 中使用分组校验
@RestController
@RequestMapping("/api/user-profiles")
@Validated
public class UserProfileController {
@PostMapping
public ResponseEntity<UserProfile> create(
@Validated(CreateGroup.class) @RequestBody UserProfile profile) {
// 仅验证 CreateGroup 分组的约束
return ResponseEntity.ok(userProfileService.save(profile));
}
@PutMapping("/{id}")
public ResponseEntity<UserProfile> update(
@Validated(UpdateGroup.class) @RequestBody UserProfile profile) {
// 仅验证 UpdateGroup 分组的约束
return ResponseEntity.ok(userProfileService.update(profile));
}
@GetMapping("/{id}")
public ResponseEntity<UserProfile> get(
@PathVariable @Positive(message = "ID 必须为正数") Long id) {
return ResponseEntity.ok(userProfileService.getById(id));
}
@GetMapping("/validate-phone")
public ResponseEntity<Boolean> validatePhone(
@RequestParam @PhoneNumber String phone) {
// 方法级别参数校验
return ResponseEntity.ok(true);
}
}8.7 校验分组行为汇总
| 场景 | CreateGroup | UpdateGroup | 无分组(Default) |
|---|---|---|---|
id | 必须为 null | 必须非 null | 无约束 |
realName | 必填 | 必填 | 无约束 |
phone | 必填 + 格式 | 格式检查 | 无约束 |
idCard | 必填 + 格式 | 格式检查 | 无约束 |
bankCard | 格式检查 | 格式检查 | 格式检查(如果填写) |
address | 必填 + 嵌套校验 | 不强制 | 无约束 |
8.8 国际化错误消息(ValidationMessages.properties)
在 src/main/resources/ 下创建国际化资源文件:
# ValidationMessages.properties(默认)
PhoneNumber.message=手机号格式不正确
IdCard.message=身份证号格式不正确
BankCard.message=银行卡号格式无效
javax.validation.constraints.NotBlank.message=字段不能为空
javax.validation.constraints.NotNull.message=字段不能为 null
javax.validation.constraints.Email.message=邮箱格式不正确
javax.validation.constraints.Size.message=长度必须在 {min} 到 {max} 之间
javax.validation.constraints.Past.message=必须为过去的日期# ValidationMessages_zh_CN.properties(中文)
PhoneNumber.message=手机号格式不正确,请输入 11 位中国手机号
IdCard.message=身份证号格式不正确,请输入 18 位有效身份证号
BankCard.message=银行卡号格式无效,请检查卡号
javax.validation.constraints.NotBlank.message={field} 不能为空
javax.validation.constraints.NotNull.message={field} 不能为 null
javax.validation.constraints.Email.message=请输入有效的邮箱地址
javax.validation.constraints.Size.message={field} 长度必须在 {min} 到 {max} 之间附录:核心接口与类速查表
| 接口/类 | 包路径 | 用途 |
|---|---|---|
jakarta.validation.Valid | jakarta.validation | JSR 标准校验触发注解 |
org.springframework.validation.annotation.Validated | org.springframework.validation.annotation | Spring 分组校验注解 |
jakarta.validation.Constraint | jakarta.validation | 自定义校验注解的元注解 |
jakarta.validation.ConstraintValidator | jakarta.validation | 自定义校验逻辑实现接口 |
jakarta.validation.Validator | jakarta.validation | JSR 校验器入口(非 Spring 的 Validator) |
org.springframework.validation.Validator | org.springframework.validation | Spring 校验接口(supports + validate) |
org.springframework.validation.Errors | org.springframework.validation | 校验错误收集接口 |
org.springframework.validation.BindingResult | org.springframework.validation | 绑定 + 校验结果容器 |
org.springframework.validation.DataBinder | org.springframework.validation | 数据绑定器,支持添加 Validator |
org.springframework.validation.beanvalidation.LocalValidatorFactoryBean | org.springframework.validation.beanvalidation | 桥接 JSR 规范与 Spring 的核心适配器 |
org.springframework.validation.beanvalidation.MethodValidationPostProcessor | org.springframework.validation.beanvalidation | 方法级别验证的后处理器 |
jakarta.validation.groups.Default | jakarta.validation.groups | 默认分组 |
jakarta.validation.GroupSequence | jakarta.validation | 组序列注解 |
org.springframework.validation.FieldError | org.springframework.validation | 字段级错误 |
org.springframework.validation.ObjectError | org.springframework.validation | 对象级错误 |