Skip to content
HeZzz
Go back

在 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 等集合类型接收参数,参数校验机制往往会失效。

// ❌ 这样的校验不会生效
@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

要点:

容器元素约束(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.”

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

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

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

综上,理解规范中关于“类型/实例图”和“容器元素约束”的定义,可以帮助判断何时使用 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 即可触发对每个元素的级联验证。

规范要点:

ValueExtractor 机制示例

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

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));
        }
    }
}

要点说明:

解决方案

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

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

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

或者:

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

必要依赖:

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

说明:

方案1:自定义包装类

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。 而且每次使用的时候都要创建一个包装类实例,稍显繁琐。

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

方案2:DTO包装对象

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;
}

全局异常处理示例:

@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.3Spring 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

最佳实践建议

代码示例

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

参考资料


Share this post on:

上一篇
Java语言-2023fa-回忆版
下一篇
What is JSONB in PostgreSQL?