一、痛点:接口参数校验为什么不能靠 if/else 堆
写过电商下单接口的人都知道,一个 CreateOrderRequest 动辄十几个字段:用户 ID 不能为空、商品列表不能为空、每个商品的 skuId 不能为空、数量必须大于 0、收货人手机号要匹配格式、优惠券 ID 可选但存在时要校验存在性……如果全用 if/else 硬写,一个方法轻松超过 100 行,且每个接口的校验逻辑各不相同、重复代码满天飞。更要命的是:校验失败返回的错误提示五花八门,前端无法统一解析。
Bean Validation(JSR 380)配合 Spring Boot 的 spring-boot-starter-validation,可以把校验规则声明式地写在 DTO 字段上,由框架统一执行、统一收集错误。本文围绕一个真实的订单创建场景,讲透分组校验、嵌套对象校验、自定义校验注解、统一异常处理四个核心能力,以及我踩过的 8 个坑。
二、基础:依赖与 @Validated / @Valid 的区别
引入依赖(Spring Boot 2.3+ 需要手动加,3.x 集成在 web starter 中):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
先给 DTO 加规则:
public class CreateOrderRequest {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotEmpty(message = "商品列表不能为空")
@Size(min = 1, max = 50, message = "单次最多购买50种商品")
private List<OrderItem> items;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String receiverPhone;
// getter / setter 省略
}
关键区别:@Validated 是 Spring 提供的注解,支持分组校验;@Valid 是 JSR 标准注解,不支持分组,但能触发嵌套对象递归校验。在 Controller 方法参数上,推荐两者组合使用:
@PostMapping("/orders")
public Result<Long> create(@Validated @RequestBody CreateOrderRequest request) {
// 业务逻辑
}
三、分组校验:创建与更新共用 DTO 的不同规则
同一个订单 DTO 在「创建」和「更新」场景下规则不同:创建时订单号必填、更新时订单状态必填。做法是定义两个 marker 接口:
public interface CreateGroup {}
public interface UpdateGroup {}
public class OrderDTO {
@Null(groups = CreateGroup.class, message = "创建时订单号由系统生成")
@NotBlank(groups = UpdateGroup.class, message = "更新时必须携带订单号")
private String orderNo;
@NotNull(groups = CreateGroup.class, message = "创建时必须指定用户")
private Long userId;
@NotNull(groups = UpdateGroup.class, message = "更新时必须指定状态")
private Integer status;
}
Controller 分层指定 group:
@PostMapping("/orders")
public Result<Long> create(@Validated(CreateGroup.class) @RequestBody OrderDTO dto) { ... }
@PutMapping("/orders/{id}")
public Result<Void> update(@Validated(UpdateGroup.class) @RequestBody OrderDTO dto) { ... }
坑 1:分了组之后,字段上没有指定 groups 的约束默认属于 Default 组,而在 @Validated(CreateGroup.class) 时 Default 组不会生效!也就是说 CreateGroup 下的约束会校验,但只标了 @NotNull 没写 groups 的字段会被跳过。解决方案:要么所有约束都显式写 groups,要么用 @Validated({CreateGroup.class, Default.class}) 两个都带上。
四、嵌套对象与 List 校验:最常见的"校验失效"
订单里每个商品也是一个对象,很多人以为写了 @Valid 就会递归校验:
public class OrderItem {
@NotNull(message = "skuId不能为空")
private Long skuId;
@Min(value = 1, message = "数量必须大于0")
private Integer quantity;
}
坑 2:List 里的泛型元素默认不会被校验!必须写成 @Valid @NotEmpty List<OrderItem> items,并且在泛型参数上再加一层 List<@Valid OrderItem>:
@NotEmpty(message = "商品列表不能为空")
@Valid
private List<@Valid OrderItem> items;
只有 @Valid 加在集合字段上,框架才会对集合内的每个元素做校验,再配合泛型上的 @Valid 才会递归进入元素内部的字段。缺任何一个,skuId 为空都不会报错,直接进入业务层 NPE。
五、自定义校验注解:手机号、枚举、金额精度
内置注解不够用时(比如校验订单状态必须是指定枚举、金额最多两位小数),用 ConstraintValidator 自定义。以「校验必须是合法枚举值」为例:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
public @interface EnumValue {
String message() default "参数必须是合法的枚举值";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
}
public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {
private Class<? extends Enum<?>> enumClass;
@Override
public void initialize(EnumValue annotation) {
this.enumClass = annotation.enumClass();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) {
return true; // 是否允许为空交给 @NotNull 决定
}
for (Enum<?> e : enumClass.getEnumConstants()) {
if (e.name().equals(value.toString()) || String.valueOf(e.ordinal()).equals(value.toString())) {
return true;
}
}
return false;
}
}
@EnumValue(enumClass = OrderStatus.class, message = "订单状态不合法")
private Integer status;
注意:自定义注解上 @Constraint(validatedBy = ...) 必须写;groups() 和 payload() 两个方法必须保留,否则框架初始化报错;isValid 里对 null 返回 true,空值校验交给 @NotNull 单一职责,避免重复报错。
六、统一异常处理:把错误变成前端友好的结构
校验失败时 Spring 抛出的异常分三种:MethodArgumentNotValidException(@RequestBody)、BindException(表单绑定)、ConstraintViolationException(方法参数/路径变量校验)。统一收口到一个异常处理器:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ":" + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.error(40001, msg);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleViolation(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ":" + v.getMessage())
.collect(Collectors.joining("; "));
return Result.error(40002, msg);
}
@ExceptionHandler(BindException.class)
public Result<Void> handleBind(BindException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ":" + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.error(40003, msg);
}
}
前端拿到统一的 {code, message, data} 结构,按 code 区分「参数错误」与「业务错误」,不用再解析千奇百怪的字符串。
七、补充:快速失败配置与类级校验
默认情况下 Hibernate Validator 会校验所有字段并把全部错误聚合返回,这对「一次告诉前端所有问题」的体验更好;但有些场景(如高并发下单、秒杀)希望第一个错误立即返回,减少无效计算。在 classpath 下放一个 META-INF/validation.xml 即可全局开启快速失败:
<validation-config xmlns="http://jboss.org/xml/ns/javax/validation/configuration">
<property name="hibernate.validator.fail_fast">true</property>
</validation-config>
用 Java Config 的方式更可控(LocalValidatorFactoryBean 是 Spring Boot 自动配置默认使用的实现,重写后全项目生效):
@Bean
public LocalValidatorFactoryBean validator() {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setProviderClass(HibernateValidator.class);
Map<String, Object> props = new HashMap<>();
props.put("hibernate.validator.fail_fast", "true");
bean.setValidationPropertyMap(props);
return bean;
}
另外,方法参数(@PathVariable / @RequestParam)的校验依赖类级 @Validated:
@Validated
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public Result<OrderVO> get(@Min(value = 1, message = "订单ID必须大于0") @PathVariable Long id) {
// 路径参数校验,失败抛 ConstraintViolationException
}
}
这样 GET /orders/0 会直接返回参数错误,而不是进入业务层查不到数据再兜底。
八、8 个高频踩坑记录
- 坑 3:@PathVariable / @RequestParam 校验不生效。Controller 类上没加
@Validated时,方法参数上的@Min/@NotBlank完全不生效。需要在类级别加@Validated,才会触发ConstraintViolationException。 - 坑 4:message 不支持参数拼接。想返回「数量必须大于0,实际是{x}」这种动态提示,用
{min}占位符:@Min(value = 1, message = "数量不能小于{min}"),不要自己写字符串拼接。 - 坑 5:正则校验的性能。
@Pattern中不要写复杂的回溯型正则,校验频次高的接口建议把 Pattern 编译为静态常量复用,避免每次匹配都重新编译。 - 坑 6:分组校验+嵌套对象。嵌套对象的字段若要参与分组,需要把分组接口一路传到嵌套字段上(如
@Validated(CreateGroup.class)时内层也写groups = CreateGroup.class),否则内层约束同样被跳过。 - 坑 7:DTO 直接当实体。请求 DTO、数据库实体、响应 VO 三者混用,会导致校验注解污染实体、update 场景误伤历史数据。
- 坑 8:message 硬编码中文。多语言或规范化场景下,message 应引用国际化 key(如
@NotBlank(message = "{order.no.required}")),在messages.properties统一维护。
八、最佳实践清单
- 分层校验:Controller 层做参数合法性校验,Service 层做业务规则校验(如库存是否足够),各司其职。
- 所有请求 DTO 字段写清楚
message,避免默认提示暴露字段名给用户。 - 创建/更新场景用分组校验,而不是复制两个几乎一样的 DTO。
- 全局异常处理统一错误码:40xxx 表示参数错误,50xxx 表示业务错误。
- 写单元测试覆盖每个校验分支,防止后续改注解悄悄破坏规则。
- 非 Web 层(如 MQ 消费者)无法自动触发校验,可注入
javax.validation.Validator手动执行:validator.validate(message)拿到Set<ConstraintViolation>自行处理,保证消息体同样被校验。
参数校验是接口质量的底线。把规则从 if/else 中解放出来,交给声明式注解与统一异常处理,代码可读性、可维护性和前端对接效率都会上一个台阶。