在 Spring Boot 2.x/3.x 开发中,JSON 数组参数校验失效原因及解决方案

本文将深入分析导致 JSON 数组参数校验失效的原因,并提供几种切实可行的解决方案,帮助开发者在实际项目中正确实现对 JSON 数组参数的校验。

在 Bean Validation 2.0 (JSR 380) 及更高版本中,已经原生支持容器元素约束,可以在类型参数上直接使用 @Valid 注解。但在 Spring Boot 的实际应用中,尤其是使用 @RequestBody 接收 JSON 数组,并希望对数组中的每个元素都进行参数校验时。如果直接使用 java.util.Listjava.util.Set 等集合类型接收参数,参数校验机制往往会失效。

1
2
3
4
5
// ❌ 这样的校验不会生效
@PostMapping("/saveList")
public Result saveList(@RequestBody @Validated List<UserDTO> userList) {
// 无法校验 List 中的每个 UserDTO 对象
}

技术原理

原因分析

Bean Validation 的工作机制(按规范)

规范中对 Bean Validation 的描述指出:

“Bean Validation specification defines a framework for declaring constraints on JavaBean classes, fields and properties. Constraints are declared on types and evaluated against instances or graphs of instances.”

https://beanvalidation.org/2.0-jsr380/spec/#constraintdeclarationvalidationprocess

按该规范,校验器是基于 JavaBean 的约束声明来对实例或实例图进行评估的:约束可以声明在类、字段或属性(getter)上,校验器会沿着对象图递归检查被注解的位置。

规范还对“可被校验的对象”提出了要求,简单摘录如下说明(用于澄清 JavaBean 要求):

Objects hosting constraints and expecting to be validated by Bean Validation providers must fulfill the following requirements:

  • Properties to be validated must follow the method signature conventions for JavaBeans read properties, as defined by the JavaBeans specification. These properties are commonly referred as getters.

  • Static fields and static methods are excluded from validation.

  • Constraints can be applied to interfaces and superclasses.

https://beanvalidation.org/2.0-jsr380/spec/#constraintdeclarationvalidationprocess-requirements

要点:

  • 校验以 JavaBean 风格的属性/字段为目标,静态成员不会被校验;
  • 约束可以声明在接口或父类上,校验会考虑继承关系;
  • 校验器是以“类型”和“实例图”为单位工作的,而不是去扫描集合类的内部实现字段(如 ArrayList 的 elementData)。

容器元素约束(Bean Validation 2.0 起)

规范还在 Bean Validation 2.0 引入并描述了容器元素约束(container element constraints)的能力,原文说明如下:

“As of Bean Validation 2.0, @Valid can be applied to the elements of any generic container by putting it to the type argument(s) when using such container (e.g. MultiMap<String, @Valid Address> addressesByType), provided a value extractor implementation for that container type and the targeted type argument is present.”

这段话的含义与实践要点:

  • 自 Bean Validation 2.0 起,可以在泛型类型实参处声明需要对容器内元素进行校验(例如 List<@Valid Goods>);

  • 要真正生效,需要校验实现(如 Hibernate Validator)提供对应容器类型的 value extractor,负责从容器中抽取元素以供校验器递归校验;

  • 如果使用的是标准集合并且校验实现提供了相应的 value extractor,则在类型使用处声明容器元素约束即可生效;否则需要采用包装类或 DTO 包装等替代方案。

为什么在 Spring MVC 中看到“失效”的现象

Spring MVC 在处理 @RequestBody 参数时,会在参数对象上触发一次校验(如 RequestResponseBodyMethodProcessor 调用 WebDataBinder.validate()),这意味着如果把校验注解放在“参数本身”(例如在方法参数上使用 @Validated)但没有在泛型实参位置声明容器元素约束,校验器不会自动深入集合内部去按元素校验。常见导致“失效”的情形包括:

  • 使用的校验实现或版本不支持容器元素约束或缺少对应的 value extractor;
  • 把 @Valid/@Validated 放在方法参数本身(@RequestBody List<…>)而没有在泛型参数处使用类型级别的容器元素注解(如 List<@Valid Goods>);
  • 直接依赖集合类的内部字段(实现细节),而不是对外部可见的 JavaBean 字段或类型使用约束。

综上,理解规范中关于“类型/实例图”和“容器元素约束”的定义,可以帮助判断何时使用 DTO/包装类、何时直接在泛型类型上声明容器元素约束,以及在项目中需要的 Validator 版本或 value extractor 支持。

实现原理

Spring MVC 参数校验流程

在 Spring Boot 2.3+ 中,当请求处理方法参数上标注了 @Valid@Validated 注解时,Spring 会调用 ModelAttributeMethodProcessorRequestResponseBodyMethodProcessor 中的校验逻辑。对于容器元素,Bean Validation 2.0 规范引入了 ValueExtractor 机制,通过该机制容器元素的值可以被提取并进行校验。也就是说,Spring 触发对方法参数的校验后,真正负责将容器内元素传递给校验引擎的是 Bean Validation 提供者(例如 Hibernate Validator)及其 ValueExtractor 实现。

容器元素约束的工作原理

当在类型参数上使用容器元素约束(例如 List<@Valid Address>)时,校验提供者会查找对应容器类型的 ValueExtractor 实现。ValueExtractor 的职责是从容器中提取出元素并通过 ValueReceiver 回传给校验引擎。对于常见集合类型(如 List、Set、Map 等),Hibernate Validator 等实现已经提供了内置的 ValueExtractor,因此在泛型实参处使用 @Valid 即可触发对每个元素的级联验证。

规范要点:

  • 在类型参数上声明约束(container element constraints)会让校验器对容器内部元素进行校验(前提是存在对应的 ValueExtractor)。
  • ValueExtractor 将容器内部的元素逐个“暴露”给校验框架,校验框架再依据元素类型上的约束进行验证。
  • 对于索引化容器(如 List),ValueExtractor 会把每个元素与其索引一起报告,便于生成明确的属性路径。

ValueExtractor 机制示例

下面是一个简化的 ListValueExtractor 示例,说明 ValueExtractor 如何将集合元素传递给校验引擎(示例来自规范/实现思想):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.validation.valueextraction.ExtractedValue;
import javax.validation.valueextraction.ValueExtractor;
import javax.validation.valueextraction.ValueReceiver;
import java.util.List;

public class ListValueExtractor implements ValueExtractor<List<@ExtractedValue ?>> {
@Override
public void extractValues(List<?> originalValue, ValueReceiver receiver) {
if (originalValue == null) {
return;
}
for (int i = 0; i < originalValue.size(); i++) {
// indexedValue方法接收三个参数:
// 1. 节点名称(可选,通常为"<list element>")
// 2. 索引位置
// 3. 实际元素值
receiver.indexedValue("<list element>", i, originalValue.get(i));
}
}
}

要点说明:

  • extractValues 接收原始容器值并通过 ValueReceiver 的方法(例如 indexedValue、keyedValue、value)回传元素值及其位置信息。
  • 校验引擎接收到这些元素后,会继续对元素对象自身及其字段进行递归校验,最终形成完整的校验路径与约束违例信息。

解决方案

我们需要验证传入的 List<Goods> 列表中每个元素的属性合规性,确保:

  • 每个商品对象的 ID 不为空
  • 商品名称长度在 2-50 个字符之间
  • 商品价格大于 0
  • 商品库存数量非负

方案0:使用容器元素约束(最新推荐方法)

1
2
3
4
5
// ✅ Spring Boot 2.3+ 和 Bean Validation 2.0+ 支持的现代方式
@PostMapping("/saveList")
public Result saveList(@RequestBody @Valid List<@Valid UserDTO> userList) {
// 此时每个UserDTO对象都会被校验
}

或者:

1
2
3
4
5
// ✅ 更简洁的语法(Bean Validation 2.0+)
@PostMapping("/saveList")
public Result saveList(@RequestBody List<@Valid UserDTO> userList) {
// 同样会校验每个元素
}

必要依赖:

1
2
3
4
5
<!-- Spring Boot 2.3+ 需要显式添加 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

说明:

  • 这是Bean Validation 2.0 (JSR 380)引入的特性
  • 通过在类型参数上使用 @Valid,可以触发对容器元素的级联验证
  • Hibernate Validator 6.0+完全支持此特性
  • 更符合规范,代码更简洁

方案1:自定义包装类

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
31
32
33
34
35
36
37
38
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.function.Consumer;
import java.util.stream.Stream;

@Data
@NoArgsConstructor
public class ValidationList<E> implements List<E> {

@Valid
@NotNull(message = "列表不能为空")
private List<E> list;

public ValidationList(List<E> list) {
this.list = list;
}

// 代理List接口的所有方法
@Override
public int size() { return list.size(); }

@Override
public boolean isEmpty() { return list.isEmpty(); }

@Override
public boolean contains(Object o) { return list.contains(o); }

@Override
public Iterator<E> iterator() { return list.iterator(); }

// ... 省略其他 List 方法的实现,均委托给内部 list 对象
}

这种方案适用于较旧的 Spring Boot 版本(<2.3)或 Bean Validation 1.1。
而且每次使用的时候都要创建一个包装类实例,稍显繁琐。

在以下情况仍需要包装类方案:

  • Spring Boot 2.2及以下版本
  • 需要自定义集合行为(如自动过滤无效元素)
  • 需要附加元数据到集合级别(如分页信息、总记录数等)

方案2:DTO包装对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.util.List;

@Data
public class GoodsListRequest {

@NotEmpty(message = "商品列表不能为空")
@Size(max = 100, message = "商品数量不能超过100个")
private List<@Valid Goods> goodsList; // @Valid在类型参数上,用于校验每个Goods对象

// 无需在字段上再加@Valid,因为List本身没有约束注解需要级联校验
private String batchNo;
private LocalDateTime createTime;
}

全局异常处理示例:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
@ControllerAdvice
public class GlobalExceptionHandler {

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public Map<String, Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, Object> response = new HashMap<>();
Map<String, String> errors = new LinkedHashMap<>();

ex.getBindingResult().getFieldErrors().forEach(error -> {
// 处理嵌套属性,如"userList[0].name"
String field = error.getField();
String message = error.getDefaultMessage();
errors.put(field, message);
});

response.put("status", "error");
response.put("message", "参数校验失败");
response.put("errors", errors);
return response;
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
Map<String, Object> response = new HashMap<>();
Map<String, String> errors = new LinkedHashMap<>();

ex.getConstraintViolations().forEach(violation -> {
// 处理更复杂的属性路径,如"users[1].address.city"
String propertyPath = violation.getPropertyPath().toString();
String message = violation.getMessage();
errors.put(propertyPath, message);
});

response.put("status", "error");
response.put("message", "参数校验失败");
response.put("errors", errors);
return response;
}
}

总结

方案对比

参数类型 Spring Boot <2.3 Spring Boot 2.3+ (Bean Validation 2.0+)
List<T> 需要包装类或DTO 支持@NotEmpty List<@Valid T>
Set<T> 需要包装类或DTO 支持@NotEmpty Set<@Valid T>
Map<K,V> 需要包装类或DTO 支持@NotEmpty Map<@Valid K, @Valid V>
T[] (数组) 需要包装类或DTO 支持@NotEmpty @Valid T[]
单个对象 支持@Valid T 支持@Valid T

最佳实践建议

  • 版本兼容性说明:

    • Spring Boot 2.0–2.2:建议使用 DTO 包装或自定义包装类(ValidationList 等)来保证数组/集合元素校验生效。
    • Spring Boot 2.3+:优先使用容器元素约束(List<@Valid T> / Set<@Valid T> 等)。
    • Spring Boot 3.x(Jakarta EE 9+):校验相关包名由 javax.validation 迁移为 jakarta.validation,注意依赖与导入调整。
  • 国际化(i18n)示例:

    1
    2
    3
    4
    5
    6
    // User.java
    public class User {
    @NotBlank(message = "{user.name.notblank}")
    private String name;
    // 其他字段
    }

    在 src/main/resources/ValidationMessages.properties 中:

    user.name.notblank=用户名不能为空

    在 application.yml 中可指定消息资源:

    1
    2
    3
    spring:
    messages:
    basename: ValidationMessages
  • 分组校验示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public interface Create {}
    public interface Update {}

    public class User {
    @NotNull(groups = Create.class)
    private Long id;

    @NotBlank(groups = {Create.class, Update.class})
    private String name;
    }

    控制器使用:

    1
    2
    3
    4
    5
    @PostMapping("/create")
    public ResponseEntity<?> create(@RequestBody @Validated(Create.class) User user) { ... }

    @PutMapping("/{id}")
    public ResponseEntity<?> update(@PathVariable Long id, @RequestBody @Validated(Update.class) User user) { ... }
  • 自定义校验注解示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Constraint(validatedBy = PhoneNumberValidator.class)
    @Documented
    public @interface ValidPhoneNumber {
    String message() default "无效的电话号码格式";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    }

    及对应的 PhoneNumberValidator 实现(实现 ConstraintValidator 接口并在 isValid 中编写逻辑)。

代码示例

  • 依赖说明(区分 2.x 与 3.x):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!-- Spring Boot 2.3+ / 2.x -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Spring Boot 3.x (Jakarta EE 9+) -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <!-- 使用 Jakarta 包名:导入时为 jakarta.validation.* -->
    </dependency>
  • 完整 Controller + 全局异常处理示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @RestController
    @RequestMapping("/api/users")
    @Validated
    public class UserController {

    @PostMapping("/batch")
    public ResponseEntity<List<UserResponse>> createUsers(
    @RequestBody @Valid List<@Valid UserRequest> users) {
    // 处理逻辑
    List<UserResponse> processedUsers = ...;
    return ResponseEntity.ok(processedUsers);
    }

    // 其他端点...
    }

    全局异常处理:

    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
    @ControllerAdvice
    public class GlobalExceptionHandler {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
    errors.put(error.getField(), error.getDefaultMessage())
    );
    return errors;
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseBody
    public Map<String, String> handleConstraintViolation(ConstraintViolationException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getConstraintViolations().forEach(v ->
    errors.put(v.getPropertyPath().toString(), v.getMessage())
    );
    return errors;
    }
    }

通过以上解决方案,我们可以完美解决 JSON 数组参数校验失效的问题,确保每个数据元素都经过严格的参数校验,提高系统的健壮性和安全性。

参考资料