分类:后端开发 · 作者:管理员 · 发布于 2026-10-15 · 阅读 0 · 标签:Spring Boot / 参数校验 / Bean Validation / 自定义注解 / 后端开发

一、痛点:接口参数校验为什么不能靠 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 统一维护。

八、最佳实践清单

  1. 分层校验:Controller 层做参数合法性校验,Service 层做业务规则校验(如库存是否足够),各司其职。
  2. 所有请求 DTO 字段写清楚 message,避免默认提示暴露字段名给用户。
  3. 创建/更新场景用分组校验,而不是复制两个几乎一样的 DTO。
  4. 全局异常处理统一错误码:40xxx 表示参数错误,50xxx 表示业务错误。
  5. 写单元测试覆盖每个校验分支,防止后续改注解悄悄破坏规则。
  6. 非 Web 层(如 MQ 消费者)无法自动触发校验,可注入 javax.validation.Validator 手动执行:validator.validate(message) 拿到 Set<ConstraintViolation> 自行处理,保证消息体同样被校验。

参数校验是接口质量的底线。把规则从 if/else 中解放出来,交给声明式注解与统一异常处理,代码可读性、可维护性和前端对接效率都会上一个台阶。