Web 层面对的输入数据永远不可信——用户可能漏填必填项、传入超长字符串、给出格式错误的手机号。将这些校验逻辑散落在 Controller 方法中,不仅会产生大量重复的 if-else,还会污染业务代码。Spring MVC 与 JSR-380(Bean Validation 2.0)的深度集成,让我们可以用声明式注解完成绝大部分校验,并通过扩展点实现自定义规则,真正做到“校验与业务分离”。
12.3.1 JSR-380 常用校验注解
JSR-380 是 Java 官方的 Bean 校验规范,Hibernate Validator 是其最常用的实现,Spring Boot 已经将其内置,无需额外引入依赖。以下是最常用的注解及其实际含义:
| 注解 | 适用类型 | 说明 | 真实场景举例 |
|------|---------|------|------------|
| @NotNull | 任意类型 | 值不能为 null | 订单 ID 必须传入 |
| @NotEmpty | 字符串、集合、Map、数组 | 不能为 null 且长度/大小 > 0 | 用户名不能为空字符串 |
| @NotBlank | 字符串 | 不能为 null,且去除首尾空格后长度 > 0 | 搜索关键词必须是非空白字符 |
| @Size(min, max) | 字符串、集合、数组 | 长度在指定区间内 | 密码 6~20 位,商品标签不超过 5 个 |
| @Min(value) / @Max(value) | 数字类型 | 数值的最小/最大值 | 年龄不能小于 0,库存不能大于 9999 |
| @DecimalMin / @DecimalMax | 数字、字符串数字 | 支持小数的范围约束 | 折扣金额 0.01~99999.99 |
| @Email | 字符串 | 邮箱格式规范 | 注册邮箱 |
| @Pattern(regexp) | 字符串 | 自定义正则表达式 | 手机号、身份证号格式 |
| @Positive / @Negative | 数字 | 正数/负数 | 商品价格必须 > 0 |
| @Past / @Future | 日期时间 | 过去/未来时间 | 出生日期必须在今天之前 |
对于一个典型的用户注册请求体,可以这样声明:
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度需在3-20位之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 30, message = "密码长度需在6-30位之间")
private String password;
@Email(message = "邮箱格式不正确")
@NotBlank(message = "邮箱不能为空")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;
// getter / setter 略
}
要点:
- 每个注解都可以通过
message属性指定违反时的提示信息。 - 校验顺序通常按照注解声明从上到下执行,一旦失败,默认立即返回,后续注解不再校验(但可配置)。
- 基础类型(如
int)不能标注@NotNull,因为基本类型永远不为null;应使用@Min等数值约束。
12.3.2 在 Controller 中触发校验
Spring MVC 提供了两种触发校验的方式:@Valid 和 @Validated。
1. 使用 @Valid 触发参数校验
在 Controller 方法参数前加上 @Valid 注解,即可在数据绑定完成后自动对新绑定的对象执行 JSR-380 校验。校验结果可以通过紧跟的 BindingResult 参数接收,或者不接收并交由全局异常处理器统一处理。
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// 获取所有错误信息
List<String> errors = bindingResult.getFieldErrors()
.stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(Map.of("errors", errors));
}
// 业务处理
userService.register(request);
return ResponseEntity.ok("注册成功");
}
}
如果不在方法中加入 BindingResult,一旦校验失败,Spring 会直接抛出 MethodArgumentNotValidException 异常,我们可以通过全局异常处理器统一处理(推荐):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors()
.stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(Map.of("errors", errors));
}
}
2. @Validated 与分组校验
Spring 的 @Validated 注解是 @Valid 的增强版,额外支持分组校验和方法级校验。当同一个 DTO 在不同接口需要应用不同的校验规则时,分组是极为实用的工具。
首先定义分组标记接口:
public interface RegisterGroup {}
public interface UpdateGroup {}
在 DTO 字段上指定分组:
public class UserRequest {
@NotNull(groups = UpdateGroup.class, message = "更新时用户ID不能为空")
private Long id;
@NotBlank(groups = {RegisterGroup.class, UpdateGroup.class}, message = "用户名不能为空")
private String username;
// 其他字段...
}
Controller 方法上使用 @Validated 并指定分组:
@PostMapping("/register")
public ResponseEntity<?> register(@Validated(RegisterGroup.class) @RequestBody UserRequest request) { ... }
@PutMapping("/update")
public ResponseEntity<?> update(@Validated(UpdateGroup.class) @RequestBody UserRequest request) { ... }
注册接口只校验 RegisterGroup 分组的约束,更新接口则校验 UpdateGroup 分组的约束(包括 id 必须不为空)。不标注 groups 属性的注解属于默认分组 Default,只在未明确指定分组时生效。通常我们会显式继承 Default 以保证分组不遗漏原始约束:
public interface UpdateGroup extends Default {}
12.3.3 自定义校验注解
内置注解无法覆盖所有业务场景,例如“用户名是否已存在”需要查询数据库,“订单状态只能是特定枚举值”,“身份证号合法性校验”等。此时需要编写自定义校验器。
自定义校验分三步:定义注解、实现校验器、关联两者。
示例1:枚举值校验(如订单状态只能是 PENDING / PROCESSING / COMPLETED)
首先定义注解:
@Documented
@Constraint(validatedBy = EnumValueValidator.class) // 指定校验器
@Target({FIELD, METHOD, PARAMETER})
@Retention(RUNTIME)
public @interface EnumValue {
String message() default "不在允许的枚举值范围内";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass(); // 指定目标枚举类
String[] excludes() default {}; // 排除某些值
}
编写校验器逻辑:
public class EnumValueValidator implements ConstraintValidator<EnumValue, String> {
private Set<String> allowedValues;
@Override
public void initialize(EnumValue constraintAnnotation) {
allowedValues = Arrays.stream(constraintAnnotation.enumClass().getEnumConstants())
.map(Enum::name)
.filter(name -> !Arrays.asList(constraintAnnotation.excludes()).contains(name))
.collect(Collectors.toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value != null && allowedValues.contains(value);
}
}
使用方式与内置注解完全一致:
public class UpdateOrderRequest {
@EnumValue(enumClass = OrderStatus.class, excludes = {"DELETED"}, message = "订单状态错误")
private String status;
}
示例2:数据库唯一性校验(如用户名不可重复)
此类校验需要依赖 Spring Bean(如 UserRepository),因此校验器必须由 Spring 容器管理。可以在自定义注解中引入 @Constraint 的 validatedBy,并让校验器实现 Spring 的 ApplicationContextAware 来获取 Bean,或者通过构造器注入(需确保校验器本身被管理)。更常见的做法是:校验器直接实现 ConstraintValidator,并标注为 @Component,然后在 initialize 中获取 Spring 上下文。
但在简单场景下,可以将唯一性校验放在 Service 层处理,不作为 JSR-380 的一部分,因为 JSR-380 偏向于字段层面的静态约束。如果一定要做,可以采用如下方式:
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // 交给 @NotBlank 处理
return !userRepository.existsByUsername(value);
}
}
并在自定义注解中指定 validatedBy = UniqueUsernameValidator.class。由于 Hibernate Validator 默认不集成 Spring,若校验器需要注入 Bean,需要配置 LocalValidatorFactoryBean 并启用 Spring 的 ConstraintValidatorFactory(Spring Boot 已经自动配置,只需确保校验器本身是 Spring Bean 即可)。实际体验中,在 Spring Boot 项目中直接声明校验器为 @Component 并完成注入,是完全可行的。
12.3.4 智能校验与组合使用
在实际开发中,我们可以组合多种校验手段来应对复杂需求:
- 嵌套校验:当 DTO 内部包含另一个 DTO 对象时,需在字段上标注
@Valid,才会触发对子对象的递归校验。 - 方法级校验:在 Service 层的方法参数上标注
@Validated+ 约束注解,并在类上添加@Validated,即可以 Spring AOP 方式校验方法参数,而不局限于 Controller。需要在配置中启用(Spring Boot 自动配置了MethodValidationPostProcessor)。 - 编程式校验:某些场景需要手动触发校验,可注入
javax.validation.Validator,调用validator.validate(object)并自行处理Set<ConstraintViolation>。
12.3.5 实践建议
- 信息要友好:生产环境中
message应给出明确的业务提示,直接返回给前端时不要暴露内部字段名,可结合国际化。 - 分离校验与业务:简单的格式校验放在 DTO 注解中,复杂的业务规则(如“用户积分是否足够”)放在 Service 层,通过抛出业务异常来处理,避免校验器过于臃肿。
- 善用分组:避免为每个接口新建 DTO 类,分组可以有效复用而不造成
@NotNull在新增和更新场景的矛盾。 - 统一异常处理:无论是
MethodArgumentNotValidException还是ConstraintViolationException(方法级校验),都应由全局@ControllerAdvice统一封装成一致的错误响应格式。
通过 JSR-380 + 自定义校验,接口的输入验证能力得到质的提升:代码更干净,规则更清晰,变更更安全。下一节我们将继续进入 Spring 的异常统一处理实践,将校验失败、业务异常、未知异常的处理规范为统一出口。