校验该写在哪一层?
很多项目里随处可见这样的代码:
1
2
3
4
5
6
7
8
9
10
11
12
| public Result createUser(UserDTO dto) {
if (dto.getName() == null || dto.getName().isEmpty()) {
return Result.fail("姓名不能为空");
}
if (dto.getAge() == null || dto.getAge() < 0 || dto.getAge() > 200) {
return Result.fail("年龄不合法");
}
if (!Pattern.matches("^1[3-9]\\d{9}$", dto.getPhone())) {
return Result.fail("手机号格式错误");
}
// ... 真正的业务逻辑
}
|
每个 Controller 都重复一遍,手工拼错误消息,堆得密不透风。这种代码不该出现在业务方法里——校验是输入边界的责任,应该在数据进入业务方法前就被自动拦截。
Bean Validation(JSR-380)和它的事实标准实现 Hibernate Validator 就是为这件事而生。今天我们把它的常见用法和"骚姿势"过一遍。
基础:常用注解一览
| 注解 | 作用 | 适用类型 |
|---|
@NotNull | 不能为 null | 任意 |
@NotEmpty | 不能 null 且不能为空 | String / Collection / Array |
@NotBlank | 不能 null 且 trim 后非空 | String |
@Size(min,max) | 长度/大小范围 | String / Collection |
@Min / @Max | 数值最小/最大 | 数字 |
@DecimalMin/Max | 支持 BigDecimal 的最小最大 | 数字 |
@Positive / @Negative | 正数 / 负数 | 数字 |
@Pattern(regexp) | 正则匹配 | String |
@Email | 邮箱格式 | String |
@Past / @Future | 过去 / 未来时间 | 时间类型 |
@AssertTrue | 必须为 true | boolean |
@Valid | 触发嵌套校验 | 对象 / 集合 |
一份典型的 DTO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public class UserCreateDTO {
@NotBlank(message = "姓名不能为空")
@Size(max = 20, message = "姓名最长 20 个字")
private String name;
@NotNull(message = "年龄必填")
@Min(value = 0, message = "年龄不能为负")
@Max(value = 200, message = "年龄太离谱了")
private Integer age;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不对")
private String phone;
@Email(message = "邮箱格式不对")
private String email;
@Valid // 嵌套校验
private AddressDTO address;
}
|
接入 Spring:一个 @Valid 全自动
Spring MVC 集成是开箱即用的——参数前加 @Valid,校验失败会抛 MethodArgumentNotValidException:
1
2
3
4
5
| @PostMapping("/users")
public Result createUser(@RequestBody @Valid UserCreateDTO dto) {
// 进到这里,所有约束都已经通过
return userService.create(dto);
}
|
Path Variable / Request Param 上需要 @Validated 在 Controller 类上配合:
1
2
3
4
5
6
7
8
9
| @RestController
@Validated
public class UserController {
@GetMapping("/users/{id}")
public Result get(@PathVariable @Min(1) Long id) {
return userService.get(id);
}
}
|
Path/Param 校验失败会抛 ConstraintViolationException,异常类型和 @RequestBody 不一样,需要分别处理。
全局异常处理:让校验报错优雅返回
Controller 内部不该处理校验异常,应该用全局异常处理器统一处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @RestControllerAdvice
public class GlobalExceptionHandler {
// @RequestBody 校验失败
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleBody(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail(400, msg);
}
// @RequestParam / @PathVariable 校验失败
@ExceptionHandler(ConstraintViolationException.class)
public Result handleParam(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
return Result.fail(400, msg);
}
}
|
至此基础就齐了。下面才是真正出彩的"骚姿势"。
骚姿势一:分组校验(Validation Groups)
最常见的场景:新增和修改用的是同一个 DTO,但字段约束不同。新增时 id 不能传,修改时 id 必填——硬拆两个 DTO 又冗余。
分组校验解决这个问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public interface CreateGroup {}
public interface UpdateGroup {}
public class UserDTO {
@Null(message = "新增时不能传 ID", groups = CreateGroup.class)
@NotNull(message = "修改时必须传 ID", groups = UpdateGroup.class)
private Long id;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
private String name;
}
@PostMapping("/users")
public Result create(@RequestBody @Validated(CreateGroup.class) UserDTO dto) { ... }
@PutMapping("/users")
public Result update(@RequestBody @Validated(UpdateGroup.class) UserDTO dto) { ... }
|
注意——分组要用 @Validated 不能用 @Valid,因为 @Valid 不支持分组参数。
骚姿势二:嵌套校验
DTO 嵌 DTO 时,光在外层加 @Valid 是不够的,必须在内层字段上也加 @Valid,校验才会递归下去:
1
2
3
4
5
6
7
8
9
10
11
| public class OrderDTO {
@NotEmpty
@Valid // ← 关键
private List<OrderItemDTO> items;
}
public class OrderItemDTO {
@NotNull private Long skuId;
@Min(1) private Integer count;
}
|
如果集合里有元素违规,错误路径会是 items[2].count,前端能精确定位。
骚姿势三:跨字段校验(自定义类级注解)
校验"密码和确认密码必须一致"这种字段间约束,单字段注解搞不定,需要写一个类级注解:
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
28
29
30
| @Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "两次密码不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, RegisterDTO> {
@Override
public boolean isValid(RegisterDTO dto, ConstraintValidatorContext context) {
if (dto.getPassword() == null) return true; // 让 @NotBlank 去管 null
boolean match = dto.getPassword().equals(dto.getConfirmPassword());
if (!match) {
// 让错误"挂"在 confirmPassword 字段上而不是整个对象上
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("两次密码不一致")
.addPropertyNode("confirmPassword")
.addConstraintViolation();
}
return match;
}
}
@PasswordMatch
public class RegisterDTO {
@NotBlank private String password;
@NotBlank private String confirmPassword;
}
|
骚姿势四:自定义字段约束
校验"枚举值必须在指定集合内"是另一个常见需求。写一个 @Enum 自定义约束:
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
| @Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValue {
String message() default "值不在允许范围内";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
String method() default "name"; // 用 enum 的哪个方法取值
}
public class EnumValidator implements ConstraintValidator<EnumValue, Object> {
private Set<Object> values;
@Override
public void initialize(EnumValue ann) {
values = Arrays.stream(ann.enumClass().getEnumConstants())
.map(e -> ReflectUtil.invoke(e, ann.method()))
.collect(Collectors.toSet());
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext ctx) {
return value == null || values.contains(value);
}
}
|
用法:
1
2
| @EnumValue(enumClass = OrderStatus.class, method = "getCode")
private String status;
|
写一次,全项目复用。
骚姿势五:Service 层方法参数校验
@Validated 不只能用在 Controller,也能用在 Service:
1
2
3
4
5
6
7
8
9
10
11
12
| @Service
@Validated
public class UserService {
public User getById(@Min(1) Long id) {
// ...
}
public void batchCreate(@NotEmpty @Valid List<UserDTO> users) {
// ...
}
}
|
被 @Validated 标注的类,方法参数上的约束会通过 AOP 自动校验,失败抛 ConstraintViolationException。这意味着——业务方法可以放心地把"非法输入"假设成不可能发生。
骚姿势六:错误消息国际化
把 message 写成 {key} 形式,Hibernate Validator 会自动从 messages.properties 读取:
1
2
| @NotBlank(message = "{user.name.notBlank}")
private String name;
|
src/main/resources/messages.properties:
1
| user.name.notBlank=姓名不能为空
|
src/main/resources/messages_en.properties:
1
| user.name.notBlank=Name is required
|
Spring Boot 默认就开启了国际化支持,根据请求 Accept-Language 自动切换。
骚姿势七:编程式 API 与 failFast
少数场景注解搞不定——例如根据运行时参数切换分组、或希望"遇到第一个错误就停"。可以用编程式 Validator:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // 1. 自定义 ValidatorFactory,开启 failFast
@Bean
public Validator validator() {
return Validation.byProvider(HibernateValidator.class)
.configure()
.failFast(true) // 任一字段失败立即返回,不再校验后续
.buildValidatorFactory()
.getValidator();
}
// 2. 编程式调用,按运行时条件选 group
@Autowired private Validator validator;
public void create(UserDTO dto, boolean strict) {
Set<ConstraintViolation<UserDTO>> errors = strict
? validator.validate(dto, StrictGroup.class)
: validator.validate(dto);
if (!errors.isEmpty()) throw new IllegalArgumentException(...);
}
|
failFast 适合批量导入这种"快报快停"的场景;编程式 API 适合规则需要根据上下文动态切换。注解和编程式 API 完全可以混用——80% 用注解、20% 用 API 兜底。
一些工程上的踩坑提醒
@NotEmpty ≠ @NotBlank——前者认空白字符串为有效,后者会 trim@Valid 和 @Validated 不要混用——分组场景必须 @Validated,嵌套校验只能 @Valid- 基本类型的
@NotNull 是没用的——int、long 这些永远有默认值;要校验"必填"就得用包装类型 groups 默认值是 Default.class——不指定 groups 的注解只在不传分组时生效;你给某个注解加了 groups = Update.class,新增校验时它就不参与了- 错误消息别堆业务逻辑——消息只描述"为什么不合法",不要写"请联系管理员"这种和校验无关的话
- DTO 复用别贪心——三种以上场景共用一个 DTO 时,强烈建议拆开,分组校验维护成本会爆炸
小结
Hibernate Validator 的价值不只是"少写 if",它把输入校验从业务逻辑中剥离,让业务方法可以放心地假设"参数都是合法的"。
把今天的内容压一句:
校验是边界的责任,不是业务方法的责任。让框架帮你拦在门口,业务里只关心业务。
至于使用层级:
- 能用注解就用注解——分组、嵌套、自定义约束都属于注解能力
- 跨字段或动态规则——写自定义类级注解或编程式 API
- Service 层也别放过——加
@Validated 让内部接口也享受框架保护
把这些招数都用上,Controller 里再也不会有那一行行傻乎乎的 if (xxx == null)。