返回

搞定Spring Boot GET传列表:3种方法解决复杂对象绑定

java

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 参数的特点

  1. 参数在 URL 中: GET 请求的所有参数都附加在 URL 后面,形式是 key=value&key=value...
  2. 扁平结构: URL 查询字符串本质上是扁平的键值对结构。虽然可以用 object.propertylist[index].property 这样的命名约定来 暗示 层次结构,但它们最终还是被解析成一个个独立的字符串键值对。
  3. 直接绑定 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 这种形式的参数就顺理成章了。

    操作步骤:

    1. 定义包装类 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 +
                     '}';
          }
      }
      
    2. 修改 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(); // 仅作示例返回
          }
      }
      
    3. 发送请求:

      请求 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 处理起来很自然,配置最少。
    • 校验 (@Valid) 可以方便地应用在包装类或其属性上。

    缺点:

    • 需要额外定义一个包装类。不过这通常是小代价。

    方案二:使用单个 JSON 字符串参数和自定义转换

    如果你非常不想创建包装类,或者希望 URL 更“紧凑”一点(虽然可能牺牲可读性),可以考虑将整个过滤器列表编码成一个 JSON 字符串,然后作为单个查询参数传递。

    原理:

    将复杂的结构序列化为字符串(比如 JSON),然后在后端接收这个字符串,再反序列化回原来的对象列表。这可以通过 Spring 的 Converter 机制自动完成,或者在 Controller 方法内部手动处理。

    操作步骤(使用 Spring Converter 自动转换):

    1. 添加 Jackson 依赖(如果尚未添加):

      Spring Boot 通常默认包含了 Jackson,用于处理 JSON。确保你的 pom.xmlbuild.gradle 中有相关依赖(如 spring-boot-starter-web 通常会引入)。

    2. 定义一个 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 处理泛型集合反序列化的标准方式。
      • 添加了基本的错误处理。
    3. 修改 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.*)。
    4. 发送请求:

      你需要将 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 对象或数组,并且没有严格的长度限制。

    操作步骤:

    1. 修改 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();
          }
      }
      
    2. 发送请求:

      使用支持发送 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 请求中传递复杂对象列表这个头疼的问题!