搞定Spring Boot GET传列表:3种方法解决复杂对象绑定
2025-04-09 22:52:30
Spring Boot GET 请求中传递复杂对象列表:踩坑与解决方案
写后端接口时,经常会遇到需要根据多种条件进行筛选查询的场景。一个常见的设计是在 GET 请求中接收一个过滤器列表,每个过滤器包含字段名、操作符和值。听起来很简单?但当你真正在 Spring Boot 中尝试用 List<YourComplexObject>
直接接收参数时,可能会遇到一些意想不到的麻烦。
比如下面这段看起来很直观的代码:
@GetMapping("/departments")
public Page<Department> search(Pageable pageable, List<@Valid FilterCriterion> filters) {
// ... 具体的查询逻辑,这里为了演示省略
System.out.println("接收到的 Pageable: " + pageable);
System.out.println("接收到的 Filters: " + filters);
return Page.empty(); // 仅作示例返回
}
// FilterCriterion 定义
public class FilterCriterion {
@NotBlank @Size(min = 1, max = 255)
private String column;
@NotNull
private FilterOperation operation;
@Size(max = 255)
private String value;
// Getters, Setters, 无参构造, 全参构造...
// ... 省略 стандартные методы ...
// 为了方便调试,添加 toString 方法
@Override
public String toString() {
return "FilterCriterion{" +
"column='" + column + '\'' +
", operation=" + operation +
", value='" + value + '\'' +
'}';
}
}
// FilterOperation 枚举
public enum FilterOperation {
EQUALS, NOT_EQUALS, CONTAINS, LT, LTE, GT, GTE
}
你兴冲冲地构造了一个请求 URL,想传入多个过滤条件:
http://localhost:8080/departments?page=0&size=10&sort=name,asc&filters[0].column=name&filters[0].operation=EQUALS&filters[0].value=foo&filters[1].column=location&filters[1].operation=CONTAINS&filters[1].value=bar
结果呢?啪,一个异常甩脸上:
java.lang.IllegalStateException: No primary or single unique constructor found for interface java.util.List
不死心,想着 List
是个接口,Spring 可能不知道该实例化哪个具体的 List
实现。于是把参数类型改成 ArrayList<@Valid FilterCriterion>
:
@GetMapping("/departments")
public Page<Department> search(Pageable pageable, ArrayList<@Valid FilterCriterion> filters) {
// ...
System.out.println("接收到的 Pageable: " + pageable);
System.out.println("接收到的 Filters: " + filters); // 这里会是空列表 []
return Page.empty();
}
再次发送请求,这次异常没了,服务正常启动和响应。但控制台输出显示 Pageable
对象是正确的,而 filters
却是一个空列表 []
!这到底是咋回事?
到底哪儿出错了?
这个问题主要出在 Spring MVC 处理请求参数到方法参数的绑定机制上,特别是针对 GET 请求中的复杂结构。
GET 请求和 URL 参数的特点
- 参数在 URL 中: GET 请求的所有参数都附加在 URL 后面,形式是
key=value&key=value...
。 - 扁平结构: URL 查询字符串本质上是扁平的键值对结构。虽然可以用
object.property
或list[index].property
这样的命名约定来 暗示 层次结构,但它们最终还是被解析成一个个独立的字符串键值对。 - 直接绑定 List 当 Spring 看到
List<FilterCriterion> filters
参数和像filters[0].column=name
这样的查询参数时,它需要做几件事:- 识别出这些参数都属于
filters
这个集合。 - 知道需要创建一个
List
实例 (这里就遇到了List
接口无法直接实例化的问题)。 - 对于集合中的每个索引(
[0]
,[1]
, ...),需要创建一个FilterCriterion
实例。 - 将对应的
column
,operation
,value
属性值填充到相应的FilterCriterion
实例中。 - 最后把这些
FilterCriterion
实例添加到List
中。
- 识别出这些参数都属于
对于第一步 (List
本身的实例化),将类型改为 ArrayList
解决了。但后续步骤,特别是如何正确地将 filters[index].property
形式的参数聚合到 ArrayList
中的 FilterCriterion
对象里,Spring 的默认机制在这种直接映射的场景下似乎“卡壳”了,没能成功完成绑定。它能成功绑定 Pageable
是因为它有专门的处理器 (PageableHandlerMethodArgumentResolver
)。但对于我们自定义的 List<FilterCriterion>
,默认情况下没有这么智能的、能处理这种索引嵌套属性格式的解析器。
可行的解决方案
别急,解决这个问题有好几种方法。咱们一个个来看。
方案一:使用包装类 (Wrapper Class)
这是最常用也是最符合 Spring 设计习惯的方式。与其直接接收 List<FilterCriterion>
,不如创建一个包装类来持有这个列表。
原理:
Spring 在处理方法参数绑定时,对于自定义的复杂对象类型,它会尝试将请求参数映射到该对象的属性上。如果我们将 List<FilterCriterion>
作为这个包装类的一个属性,Spring 就可以先实例化包装类,然后自然地处理其内部的 filters
列表属性,这时候它处理 filters[0].column
这种形式的参数就顺理成章了。
操作步骤:
-
定义包装类
FilterRequest
:import jakarta.validation.Valid; // 或者 javax.validation.Valid import java.util.List; import java.util.ArrayList; public class FilterRequest { @Valid // 别忘了加上校验注解,这样嵌套的 FilterCriterion 才会进行校验 private List<FilterCriterion> filters = new ArrayList<>(); // 最好初始化一下 // --- Getter and Setter --- public List<FilterCriterion> getFilters() { return filters; } public void setFilters(List<FilterCriterion> filters) { this.filters = filters; } // 添加一个 toString 方便调试 @Override public String toString() { return "FilterRequest{" + "filters=" + filters + '}'; } }
-
修改 Controller 方法签名:
将原来的
List<@Valid FilterCriterion> filters
参数替换成FilterRequest filterRequest
。注意,@Valid
现在应该加在FilterRequest
参数上,或者加在FilterRequest
类内部的filters
字段上(如上例所示),以便级联校验列表中的元素。import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; // 或者 javax.validation.Valid @RestController public class DepartmentController { // 假设你的 Controller 类名 @GetMapping("/departments") public Page<Department> search(Pageable pageable, @Valid FilterRequest filterRequest) { // 现在可以通过 filterRequest.getFilters() 来获取列表了 List<FilterCriterion> filters = filterRequest.getFilters(); System.out.println("接收到的 Pageable: " + pageable); System.out.println("接收到的 FilterRequest: " + filterRequest); System.out.println("提取到的 Filters: " + filters); // ... 执行查询逻辑 ... // 这里可以用 filters 变量进行后续处理 return Page.empty(); // 仅作示例返回 } }
-
发送请求:
请求 URL 保持不变!
http://localhost:8080/departments?page=0&size=10&sort=name,asc&filters[0].column=name&filters[0].operation=EQUALS&filters[0].value=foo&filters[1].column=location&filters[1].operation=CONTAINS&filters[1].value=bar
现在,当请求到达时:
- Spring 看到
FilterRequest
参数,会创建一个FilterRequest
实例。 - 然后它看到
filters[0].column=name
,filters[0].operation=EQUALS
,filters[0].value=foo
这些参数,它知道这些应该设置到FilterRequest
实例的filters
属性(一个 List)的第 0 个元素上。 - 它会自动为你创建
ArrayList
(因为FilterRequest
里的filters
字段被初始化或其类型是ArrayList
),并创建FilterCriterion
对象,然后填充属性。 - 对于索引
[1]
及之后的元素也依此类推。
这次再看控制台输出,就能看到
FilterRequest
对象被正确填充了,filters
列表也包含了我们发送的数据。 - Spring 看到
优点:
- 代码清晰,符合面向对象思想。
- Spring 处理起来很自然,配置最少。
- 校验 (
@Valid
) 可以方便地应用在包装类或其属性上。
缺点:
- 需要额外定义一个包装类。不过这通常是小代价。
方案二:使用单个 JSON 字符串参数和自定义转换
如果你非常不想创建包装类,或者希望 URL 更“紧凑”一点(虽然可能牺牲可读性),可以考虑将整个过滤器列表编码成一个 JSON 字符串,然后作为单个查询参数传递。
原理:
将复杂的结构序列化为字符串(比如 JSON),然后在后端接收这个字符串,再反序列化回原来的对象列表。这可以通过 Spring 的 Converter
机制自动完成,或者在 Controller 方法内部手动处理。
操作步骤(使用 Spring Converter 自动转换):
-
添加 Jackson 依赖(如果尚未添加):
Spring Boot 通常默认包含了 Jackson,用于处理 JSON。确保你的
pom.xml
或build.gradle
中有相关依赖(如spring-boot-starter-web
通常会引入)。 -
定义一个
Converter
:创建一个类实现
org.springframework.core.convert.converter.Converter<String, List<FilterCriterion>>
接口。这个转换器负责将传入的 JSON 字符串解析成List<FilterCriterion>
。import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; import java.util.Collections; import java.util.List; @Component // 让 Spring 扫描并注册这个 Bean public class StringToFilterCriterionListConverter implements Converter<String, List<FilterCriterion>> { private final ObjectMapper objectMapper; // 注入 ObjectMapper,它是线程安全的 public StringToFilterCriterionListConverter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } @Override public List<FilterCriterion> convert(String source) { if (source == null || source.isEmpty()) { return Collections.emptyList(); // 或者返回 null,取决于你的业务逻辑 } try { // 使用 TypeReference 来处理泛型列表 return objectMapper.readValue(source, new TypeReference<List<FilterCriterion>>() {}); } catch (Exception e) { // 处理 JSON 解析异常,可以打印日志、抛出自定义异常等 // 在生产环境中,这里应该有更健壮的错误处理 System.err.println("无法将字符串转换为 FilterCriterion 列表: " + e.getMessage()); // 根据需要决定是返回空列表还是抛出异常 // throw new IllegalArgumentException("无效的 filters JSON 格式", e); return Collections.emptyList(); } } }
- 注意: 这里注入了
ObjectMapper
。Spring Boot 会自动配置一个可用的实例。 - 使用了
TypeReference
,这是 Jackson 处理泛型集合反序列化的标准方式。 - 添加了基本的错误处理。
- 注意: 这里注入了
-
修改 Controller 方法签名:
现在可以直接接收
List<FilterCriterion>
,并使用@RequestParam
注解指定查询参数的名称(例如filtersJson
)。import org.springframework.web.bind.annotation.RequestParam; // ... 其他 import ... @RestController public class DepartmentController { @GetMapping("/departments") public Page<Department> search(Pageable pageable, @RequestParam(name = "filters", required = false) /* @Valid ? */ List<FilterCriterion> filters) { // 注意: @Valid 对通过 Converter 转换的参数可能不会自动生效, // 你可能需要在方法内部手动触 JSR-303 验证,或者在 Converter 内部进行验证。 // 简单起见,这里先省略 @Valid,但实际项目中需要考虑校验。 System.out.println("接收到的 Pageable: " + pageable); System.out.println("通过 Converter 接收到的 Filters: " + filters); // 手动触发校验 (如果需要) if (filters != null) { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator(); for (FilterCriterion criterion : filters) { Set<ConstraintViolation<FilterCriterion>> violations = validator.validate(criterion); if (!violations.isEmpty()) { // 处理校验失败... throw new ConstraintViolationException(violations); } } } // ... 执行查询逻辑 ... return Page.empty(); } }
- 使用
@RequestParam(name = "filters", required = false)
来接收名为filters
的查询参数。required = false
允许该参数不存在。 - 重要: JSR-303 (Bean Validation) 的
@Valid
注解通常作用于由HandlerMethodArgumentResolver
直接绑定的对象。通过Converter
转换得来的参数,@Valid
可能不会自动触发。你需要在方法内部获取Validator
实例并手动校验列表中的每个元素,如上面代码片段中注释掉的部分所示(需要引入jakarta.validation.*
或javax.validation.*
)。
- 使用
-
发送请求:
你需要将
FilterCriterion
列表转换成 JSON 字符串,并对其进行 URL 编码,然后作为filters
参数的值。假设列表是:
[ { "column": "name", "operation": "EQUALS", "value": "foo" }, { "column": "location", "operation": "CONTAINS", "value": "bar" } ]
URL 编码后的字符串会非常长,类似:
%5B%7B%22column%22%3A%22name%22%2C%22operation%22%3A%22EQUALS%22%2C%22value%22%3A%22foo%22%7D%2C%7B%22column%22%3A%22location%22%2C%22operation%22%3A%22CONTAINS%22%2C%22value%22%3A%22bar%22%7D%5D
完整的请求 URL 看起来像这样:
http://localhost:8080/departments?page=0&size=10&sort=name,asc&filters=%5B%7B%22column%22%3A%22name%22%2C%22operation%22%3A%22EQUALS%22%2C%22value%22%3A%22foo%22%7D%2C%7B%22column%22%3A%22location%22%2C%22operation%22%3A%22CONTAINS%22%2C%22value%22%3A%22bar%22%7D%5D
优点:
- URL 参数相对“集中”到一个参数里(虽然值可能很长)。
- 不需要额外的包装类。
缺点:
- URL 可读性差,手动构造或调试比较困难。
- URL 长度限制: GET 请求的 URL 长度是有限制的(不同浏览器和服务器限制不同,通常在 2KB 到 8KB 之间)。如果过滤器列表非常长或者值很复杂,序列化后的 JSON 字符串可能会超出这个限制。
- 需要编写和注册
Converter
。 - 参数校验 (
@Valid
) 可能需要手动处理。 - 安全风险: 直接反序列化来自 URL 参数的 JSON 字符串需要小心。确保
ObjectMapper
配置安全,防止潜在的反序列化漏洞。同时,对反序列化后的对象内容进行严格校验。
方案三:考虑使用 POST 请求
有时候,当 GET 请求的参数变得过于复杂时,就该反思一下:这个操作真的适合用 GET 吗?
原理:
HTTP POST 请求通常用于提交数据给服务器进行处理,数据放在请求体(Request Body)中,而不是 URL 里。请求体可以轻松容纳复杂的结构,如 JSON 对象或数组,并且没有严格的长度限制。
操作步骤:
-
修改 Controller 方法:
- 将
@GetMapping
改为@PostMapping
。 - 在
List<FilterCriterion>
参数前加上@RequestBody
注解,告诉 Spring 从请求体中读取并解析这个列表。 Pageable
通常仍然可以作为查询参数传递,Spring 默认支持。- 加上
@Valid
来启用对请求体内容的校验。
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; // ... 其他 import ... @RestController public class DepartmentController { @PostMapping("/departments/search") // 路径可以不变,或者加个 /search 以示区别 public Page<Department> search(Pageable pageable, // Pageable 仍可通过 URL 参数传递 @RequestBody @Valid List<FilterCriterion> filters) { System.out.println("接收到的 Pageable: " + pageable); System.out.println("从 RequestBody 接收到的 Filters: " + filters); // ... 执行查询逻辑 ... return Page.empty(); } }
- 将
-
发送请求:
使用支持发送 POST 请求的工具(如 Postman, curl, 或者前端 JavaScript fetch/axios)。
-
请求方法:
POST
-
URL:
http://localhost:8080/departments/search?page=0&size=10&sort=name,asc
(分页和排序参数仍在 URL 中) -
请求头:
Content-Type: application/json
-
请求体 (Body):
[ { "column": "name", "operation": "EQUALS", "value": "foo" }, { "column": "location", "operation": "CONTAINS", "value": "bar" } ]
-
优点:
- 处理复杂数据结构是 POST 的强项,非常自然。
- 没有 URL 长度限制的困扰。
- 请求体使用 JSON 格式清晰易懂。
@Valid
对@RequestBody
参数的校验支持良好。
缺点:
- 语义变化: GET 请求通常被认为是幂等的(执行多次结果相同)并且是安全的(不改变服务器状态,虽然查询也可能记录日志等)。POST 通常用于创建或更新资源,不要求幂等。将查询操作改为 POST 在纯粹的 RESTful 风格上可能不那么“正统”。不过,对于复杂的查询,用 POST 传递查询参数在实践中非常普遍。
- 无法简单收藏或分享: POST 请求不能像 GET 请求那样直接通过 URL 被收藏或分享。
- 缓存: GET 请求更容易被浏览器或代理服务器缓存,POST 请求通常不会。
如何选择?
- 首选:方案一 (包装类)。 对于 GET 请求传递结构化参数列表,这是最符合 Spring MVC 设计模式、最简洁也最不容易出错的方式。它保持了 GET 的语义,同时解决了绑定问题。
- 备选:方案二 (JSON 字符串 + Converter)。 如果你对 URL 的结构有特殊要求,或者非常排斥引入额外的包装类,并且能接受其缺点(URL 长度限制、可读性差、手动校验),可以考虑这种方式。要特别注意 URL 编码和长度问题。
- 实用选择:方案三 (POST 请求)。 当你的过滤条件变得非常复杂、数量众多,或者包含敏感信息不宜暴露在 URL 中时,果断使用 POST 请求。虽然牺牲了一些 GET 的特性(如缓存、幂等性保证、易于分享),但换来了处理复杂数据的便利性和强大的表达能力。在实际项目中,用 POST 来处理复杂的搜索/过滤场景非常常见。
选择哪种方案取决于你的具体需求、团队规范以及对 RESTful 原则遵循的严格程度。希望这几种方法能帮你搞定 GET 请求中传递复杂对象列表这个头疼的问题!