Java HTTP可选参数请求详解

Java HTTP可选参数请求详解

HTTP 协议本身没有 「可选参数」这一说法------可选性来自客户端构造请求服务端声明参数可缺省 时的约定。常见需求是:某些 Query 字段、表单键或 JSON 属性有值才发送 ,缺省时不出现该键,避免 age=city=null 之类脏参数。

速览

  • GETURIBuilder / UriComponentsBuilder;Spring 可用 queryParamIfPresent
  • POST 表单NameValuePair / MultiValueMap,只 add 需要的键。
  • REST JSONDTO + @JsonInclude(NON_NULL)(类级或全局配置)。
  • Spring 接口@RequestParam(required = false);JSON 体缺字段 → null
  • Spring 列表筛选@ModelAttribute POJO 绑定 Query;springdoc @ParameterObject 用于 OpenAPI 展开文档。
  • 声明式客户端 :OpenFeign @SpringQueryMap ;Retrofit @Query (null 省略)/ @QueryMap(Map 内不得含 null,须先过滤)。
  • 原则 :REST 优先 JSON;不要手拼 Query ;对接前确认 缺省 / null / 空串 三种语义。

目录

  • [1. 概念:可选 ≠ 传 null](#1. 概念:可选 ≠ 传 null)
  • [2. GET:可选 Query 参数](#2. GET:可选 Query 参数)
  • [3. POST 表单:application/x-www-form-urlencoded](#3. POST 表单:application/x-www-form-urlencoded)
  • [4. POST JSON:可选字段(REST 推荐)](#4. POST JSON:可选字段(REST 推荐))
  • [5. Spring 服务端:接收可选参数](#5. Spring 服务端:接收可选参数)
  • [6. 多值 Query 与声明式客户端](#6. 多值 Query 与声明式客户端)
    • [6.3 Retrofit:@Query / @QueryMap](#6.3 Retrofit:@Query / @QueryMap)
    • [6.4 Spring 列表筛选:@ModelAttribute@ParameterObject](#6.4 Spring 列表筛选:@ModelAttribute 与 @ParameterObject)
  • [7. 场景选型与最佳实践](#7. 场景选型与最佳实践)
  • [8. 延伸阅读](#8. 延伸阅读)

1. 概念:可选 ≠ 传 null

说法 实际含义
Query 可选 URL 里不出现 &age=18,而不是 &age=
JSON 可选 请求体里没有 "age" 键,而不是 "age": null
表单可选 表单字段列表里不包含该 name
Optional<T> 业务层表达「可能没有值」;序列化时仍要配合 NON_NULL 或条件 add

1.1 缺省、null、空串:三种不同语义

对接 API 或设计接口时,三者常被混用,但含义不同:

形态 GET Query 示例 JSON 示例 常见服务端解读
缺省(missing) &age "age" 「未指定」→ 不参与筛选 / 用默认值
显式 null 少见(age= 不等价) "age": null PATCH 场景可能表示「清空字段」
空串 city= "city": "" 有的接口当「空值搜索」;有的与 missing 等同

Jackson 默认:JSON 里没有的键 → Java 字段 null无法仅靠 null 区分「未传」与「传了 null」 。需要区分时见 §4.4
#mermaid-svg-Kf1WUzaXuE8ef4ZD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .error-icon{fill:#552222;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .marker.cross{stroke:#333333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD p{margin:0;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster-label text{fill:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster-label span{color:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster-label span p{background-color:transparent;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .label text,#mermaid-svg-Kf1WUzaXuE8ef4ZD span{fill:#333;color:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .node rect,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node circle,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node ellipse,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node polygon,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .rough-node .label text,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node .label text,#mermaid-svg-Kf1WUzaXuE8ef4ZD .image-shape .label,#mermaid-svg-Kf1WUzaXuE8ef4ZD .icon-shape .label{text-anchor:middle;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .rough-node .label,#mermaid-svg-Kf1WUzaXuE8ef4ZD .node .label,#mermaid-svg-Kf1WUzaXuE8ef4ZD .image-shape .label,#mermaid-svg-Kf1WUzaXuE8ef4ZD .icon-shape .label{text-align:center;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .node.clickable{cursor:pointer;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .arrowheadPath{fill:#333333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Kf1WUzaXuE8ef4ZD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Kf1WUzaXuE8ef4ZD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster text{fill:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .cluster span{color:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Kf1WUzaXuE8ef4ZD rect.text{fill:none;stroke-width:0;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .icon-shape,#mermaid-svg-Kf1WUzaXuE8ef4ZD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .icon-shape p,#mermaid-svg-Kf1WUzaXuE8ef4ZD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .icon-shape .label rect,#mermaid-svg-Kf1WUzaXuE8ef4ZD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Kf1WUzaXuE8ef4ZD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Kf1WUzaXuE8ef4ZD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Kf1WUzaXuE8ef4ZD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

参数有值?
加入请求
不加入该键/Query 段
GET Query
表单字段
JSON 属性


2. GET:可选 Query 参数

2.1 手拼 URL(仅参数极少时)

java 复制代码
StringBuilder url = new StringBuilder("https://api.example.com/user");
url.append("?name=").append(URLEncoder.encode("Tom", StandardCharsets.UTF_8));

if (age != null) {
    url.append("&age=").append(age);
}
if (city != null && !city.isBlank()) {
    url.append("&city=").append(URLEncoder.encode(city, StandardCharsets.UTF_8));
}

HttpGet request = new HttpGet(url.toString());

直观,但 encode、首尾 ?/&、空字符串边界容易写错,不推荐作为默认方案

2.2 Apache HttpClient:URIBuilder(推荐)

java 复制代码
URIBuilder builder = new URIBuilder("https://api.example.com/user");
builder.addParameter("name", "Tom");

if (age != null) {
    builder.addParameter("age", age.toString());
}
if (city != null && !city.isBlank()) {
    builder.addParameter("city", city);
}

HttpGet request = new HttpGet(builder.build());

自动 URL encode,可读性好,Apache HttpClient 项目里很常见。

2.3 Spring:UriComponentsBuilder + RestTemplate / WebClient

推荐写法 (按条件追加 Query,避免模板里写死 &age={age} 却在 age 为空时仍占位):

java 复制代码
UriComponentsBuilder builder = UriComponentsBuilder
        .fromHttpUrl("https://api.example.com/user")
        .queryParam("name", "Tom");

if (age != null) {
    builder.queryParam("age", age);
}
if (city != null && !city.isBlank()) {
    builder.queryParam("city", city);
}

String uri = builder.build().encode().toUriString();
ResponseEntity<String> resp = restTemplate.getForEntity(uri, String.class);

Spring 6+ 可用 WebClient 同样链式 queryParam

java 复制代码
WebClient client = WebClient.create("https://api.example.com");
client.get()
    .uri(uriBuilder -> {
        uriBuilder.path("/user").queryParam("name", "Tom");
        if (age != null) uriBuilder.queryParam("age", age);
        return uriBuilder.build();
    })
    .retrieve()
    .bodyToMono(String.class);

2.4 Spring:queryParamIfPresent(更 idiomatic)

Spring Framework 5.1+ 提供 queryParamIfPresent,与 Optional 配合,避免手写 if

java 复制代码
String uri = UriComponentsBuilder.fromHttpUrl("https://api.example.com/user")
        .queryParam("name", "Tom")
        .queryParamIfPresent("age", Optional.ofNullable(age))
        .queryParamIfPresent("city",
                Optional.ofNullable(city).filter(s -> !s.isBlank()))
        .build()
        .encode()
        .toUriString();

Optional.empty() 时该 Query 键不会出现 ,语义与条件 queryParam 一致。

2.5 OkHttp

java 复制代码
HttpUrl.Builder urlBuilder = Objects.requireNonNull(
        HttpUrl.parse("https://api.example.com/user")).newBuilder();
urlBuilder.addQueryParameter("name", "Tom");
if (age != null) {
    urlBuilder.addQueryParameter("age", String.valueOf(age));
}
if (city != null && !city.isBlank()) {
    urlBuilder.addQueryParameter("city", city);
}

Request request = new Request.Builder().url(urlBuilder.build()).get().build();

OkHttp 自动 encode;addQueryParameter 只在调用时追加,未调用即缺省。

2.6 Java 11+ HttpClient(标准库)

URI 仍建议用 UriComponentsBuilder 拼好,再交给标准库客户端:

java 复制代码
URI uri = UriComponentsBuilder.fromHttpUrl("https://api.example.com/user")
        .queryParam("name", "Tom")
        .queryParamIfPresent("age", Optional.ofNullable(age))
        .build()
        .encode()
        .toUri();

HttpRequest request = HttpRequest.newBuilder(uri).GET().build();
HttpResponse<String> response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString());

3. POST 表单:application/x-www-form-urlencoded

只把需要的键放进实体,不传即无该字段

java 复制代码
List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("username", "tom"));

if (email != null && !email.isBlank()) {
    params.add(new BasicNameValuePair("email", email));
}

HttpPost post = new HttpPost(url);
post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));

Spring RestTemplate / WebClient 侧示例:

java 复制代码
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "tom");
if (email != null && !email.isBlank()) {
    form.add("email", email);
}

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

restTemplate.postForEntity(
        url,
        new HttpEntity<>(form, headers),
        String.class);

WebClientBodyInserters.fromFormData(form),同样只 add 有值的键。


4. POST JSON:可选字段(REST 推荐)

REST 接口最常用:请求体 DTO + 序列化时忽略 null

4.1 定义 DTO(类级 @JsonInclude 推荐)

java 复制代码
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDTO {
    private String name;
    private Integer age;   // 可选
    private String city;   // 可选
    // getter / setter
}

类上标注 NON_NULL 比全局改 ObjectMapper 更安全,只影响该 DTO,不波及其他序列化场景。

4.2 Jackson:只序列化非 null 字段

全局配置(Spring Boot application.yml

yaml 复制代码
spring:
  jackson:
    default-property-inclusion: non_null

或单次 ObjectMapper (Jackson 2.12+ 推荐 setDefaultPropertyInclusion):

java 复制代码
ObjectMapper mapper = new ObjectMapper();
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
// 亦可用 setSerializationInclusion(NON_NULL),仍常见;2.12+ 更推荐显式 setDefaultPropertyInclusion

UserDTO dto = new UserDTO();
dto.setName("Tom");
// age / city 不 set → JSON 里不会出现这两个键

String json = mapper.writeValueAsString(dto);
// {"name":"Tom"}

Spring 客户端直接 POST DTO (由 HttpMessageConverter 序列化,需配合 Jackson 配置或类级 @JsonInclude):

java 复制代码
UserDTO dto = new UserDTO();
dto.setName("Tom");

restTemplate.postForEntity(url, dto, String.class);  // 或 postForEntity<String>(...)

// WebClient
webClient.post()
    .uri("/user")
    .contentType(MediaType.APPLICATION_JSON)
    .bodyValue(dto)
    .retrieve()
    .bodyToMono(String.class);

4.3 与 Optional 字段

DTO 字段类型用 Optional<Integer> 时,Jackson 默认行为需额外模块或自定义;工程里更常见的是 包装类型 Integer + 不 set ,或 @JsonInclude(NON_EMPTY) 处理空集合,而不是在 DTO 上堆 Optional

4.4 区分「未传字段」与「显式 null」

需求 做法
调用方:有值才出现在 JSON @JsonInclude(NON_NULL) + 不 set 字段
服务端:读 JSON 区分 missing / null @JsonSetter(nulls = Nulls.SKIP)JsonNode、或 OpenAPI JsonNullable<T>org.openapitools:jackson-databind-nullable
PATCH 部分更新 只提交变更字段的 DTO;或 RFC 7396 JSON Merge Patchapplication/merge-patch+json

JsonNullable 示例(三态:undefined / null / value):

java 复制代码
public class PatchUserDTO {
    private JsonNullable<Integer> age = JsonNullable.undefined();
    // getter / setter
}

5. Spring 服务端:接收可选参数

接口(而非调接口)时,用注解声明参数可缺省:

java 复制代码
@GetMapping("/user")
public List<User> getUser(
        @RequestParam String name,
        @RequestParam(required = false) Integer age,
        @RequestParam(required = false) String city) {

    return userService.find(name, age, city);
}
注解组合 缺省 Query 时行为
required = false,无 defaultValue 参数为 null(包装类型)
required = false, defaultValue = "" 得到 空串String)或需配合类型转换
required = true(默认) 缺参 → 400 Bad Request

调用示例:

text 复制代码
GET /user?name=tom
GET /user?name=tom&age=18
GET /user?name=tom&age=18&city=sh

POST 表单接收可选字段@ModelAttribute UserForm form,form 里未提交的字段一般为 null;或逐个 @RequestParam(required = false)

JSON 请求体@RequestBody UserDTO dto,未传的 JSON 键 → 字段 nullage: 0 与「未传 age」在 Integer 字段上可区分,但「未传」与「传 null」仍见 §4.4

@RequestParam Optional<Integer> age :Spring MVC 较新版本支持;老项目更常用 required = false + null 判断,兼容性更好。


6. 多值 Query 与声明式客户端

6.1 同一键多次出现(multi-value Query)

筛选条件常需要 tag=java&tag=spring,而不是单个逗号拼接(除非 API 文档规定 tags=java,spring)。

客户端(Apache HttpClient):

java 复制代码
URIBuilder builder = new URIBuilder(baseUrl);
builder.addParameter("name", "Tom");
for (String tag : tags) {          // tags 非空才循环
    builder.addParameter("tag", tag);
}

服务端

java 复制代码
@GetMapping("/user")
public List<User> search(
        @RequestParam String name,
        @RequestParam(required = false) List<String> tag) {
    // /user?name=tom&tag=java&tag=spring → tag = ["java", "spring"]
    // 无 tag 参数 → tag 为 null(非空 List)
}

Spring 对重复键绑定为 List / Set;是否与「缺省」区分,要在 Service 层显式判断 tag == null || tag.isEmpty()

6.2 OpenFeign:@SpringQueryMap

微服务里声明式 HTTP 客户端常用 OpenFeign 。Query 对象字段为 null 时,Spring Cloud OpenFeign 默认不生成对应 Query 键(与可选参数需求一致):

java 复制代码
@FeignClient(name = "user-service")
public interface UserClient {

    @GetMapping("/user")
    List<User> getUser(@SpringQueryMap UserQuery query);
}

public class UserQuery {
    private String name;
    private Integer age;   // null → 不出现在 URL
    private String city;
}

注意:Feign 对 空串null 的处理取决于版本与编码器配置;对接前用日志或单元测试确认生成的 URL。

6.3 Retrofit:@Query / @QueryMap

Android 与不少 Java 移动端/网关项目用 Retrofit 声明 HTTP 接口。可选 Query 的惯用写法有两种。

方式 A:逐个 @Query(null 自动省略)

java 复制代码
public interface UserApi {

    @GET("user")
    Call<List<User>> search(
            @Query("name") String name,
            @Query("age") Integer age,       // null → 不出现在 URL
            @Query("city") String city);
}

方式 B:@QueryMap + 按需组 Map

@QueryMap 绑定 Map (如 Map<String, String> / Map<String, Object>)。官方文档写明:Map 本身、键、值均不允许为 null ------若 put 了 null value,运行时会报错,不会自动跳过。

因此可选参数必须在组 Map 时自行过滤,只放入非 null 的键值对(与下方示例一致):

java 复制代码
public interface UserApi {

    @GET("user")
    Call<List<User>> search(@QueryMap Map<String, Object> params);
}

// 调用前组 Map:只 put 有值的键(切勿 params.put("age", null))
Map<String, Object> params = new HashMap<>();
params.put("name", "Tom");
if (age != null) {
    params.put("age", age);
}
if (city != null && !city.isBlank()) {
    params.put("city", city);
}
api.search(params).enqueue(callback);

POJO 字段较多时,先用 Bean 工具或手写方法把非 null 字段转成 Map,再交给 @QueryMap------Retrofit 不会 像 Feign 的 @SpringQueryMap 那样直接接受任意 POJO。

与 OkHttp 栈的关系 :Retrofit 底层通常走 OkHttp。@Query 参数为 null 时由 Retrofit 省略 该 Query;@QueryMap 则要求 Map 在传入前已不含 null,过滤发生在客户端组 Map 阶段。

6.4 Spring 列表筛选:@ModelAttribute@ParameterObject

列表/搜索接口常有一组可选筛选 QuerynamestatuscreatedAfter...),再加 分页pagesizesort)。逐个写 @RequestParam 冗长,可用 POJO 一次绑定

数据绑定(Spring MVC) :对非简单类型的 Controller 参数,Spring 默认按 @ModelAttribute 把 Query 绑定到 POJO 字段------未出现在 Query 中的字段为 null ,不会参与后续条件拼接。显式标注 @ModelAttribute 更清晰(GraalVM Native 等场景也建议显式写)。

OpenAPI 文档(springdoc-openapi)@ParameterObjectorg.springdoc.core.annotations.ParameterObject不负责绑定 ,而是让 Swagger UI 把 POJO 展开为多个 Query 参数 展示;与 Spring MVC 绑定兼容。自 springdoc v1.6.0 起与 Pageable@PageableDefault 搭配常见。

java 复制代码
@GetMapping("/users")
public Page<User> search(
        @ParameterObject @ModelAttribute UserSearchCriteria criteria,
        @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
        Pageable pageable) {

    return userService.search(criteria, pageable);
}

未使用 springdoc 时,去掉 @ParameterObject,保留 @ModelAttribute(或依赖 Spring 对复杂类型的隐式 @ModelAttribute)即可。

java 复制代码
public class UserSearchCriteria {
    private String name;           // 未传 → null,不参与 SQL 条件
    private Integer age;
    private UserStatus status;
    private LocalDate createdAfter;
    // getter / setter
}

请求示例(只传部分筛选 + 分页):

text 复制代码
GET /users?name=tom
GET /users?name=tom&status=ACTIVE&page=0&size=10
GET /users?page=1&size=20&sort=createdAt,desc
参数来源 绑定目标 缺省行为
nameage... UserSearchCriteria 字段 未出现在 Query → 字段 null
pagesizesort Pageable(Spring Data) 未传 page → 默认 0;未传 size@PageableDefault 或全局配置

Service 层按 null 决定是否加 WHERE 条件(MyBatis 动态 SQL、SpecificationCriteriaBuilder 等):

java 复制代码
public Page<User> search(UserSearchCriteria c, Pageable pageable) {
    return userRepo.findAll((root, query, cb) -> {
        List<Predicate> ps = new ArrayList<>();
        if (c.getName() != null) {
            ps.add(cb.like(root.get("name"), "%" + c.getName() + "%"));
        }
        if (c.getAge() != null) {
            ps.add(cb.equal(root.get("age"), c.getAge()));
        }
        return cb.and(ps.toArray(new Predicate[0]));
    }, pageable);
}

注意

  • 绑定 靠 Spring MVC @ModelAttribute文档展开 靠 springdoc @ParameterObject------二者职责不同。
  • POST JSON 仍用 @RequestBody DTO,不用 @ModelAttribute
  • springdoc 的 @ParameterObject 不支持嵌套 POJO 展开;复杂筛选宜扁平化字段。
  • Pageablesort 与自定义筛选字段同名时会冲突,复杂排序建议白名单字段。

7. 场景选型与最佳实践

场景 推荐方式 说明
GET 调第三方 API URIBuilder / UriComponentsBuilder 自动 encode,条件 addParameter
POST 老式表单 NameValuePair / MultiValueMap 只 add 存在的键
REST JSON API DTO + NON_NULL 最清晰,与 OpenAPI 文档一致
Spring 提供 HTTP 接口 @RequestParam(required=false) / 可空 DTO 字段 与调用方 Query/JSON 约定对齐
OkHttp HttpUrl.Builder.addQueryParameter 与 URIBuilder 同思路
Java 11+ HttpClient UriComponentsBuilder 拼 URI + HttpClient.send 无第三方依赖
OpenFeign 调下游 @SpringQueryMap POJO null 字段默认省略 Query
Retrofit(Android 等) @Query null 省略 / @QueryMap 先过滤 Map Map 内 null 会报错,勿依赖自动跳过
Spring 列表筛选 API @ModelAttribute POJO + Pageable 未传 Query 键 → 字段 null;springdoc 加 @ParameterObject 展开文档
多选筛选 tag 同键多次 addParameter / List 接收 勿假设逗号分隔 unless 文档写明

#mermaid-svg-xaP7B7RGvrLgEBOD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xaP7B7RGvrLgEBOD .error-icon{fill:#552222;}#mermaid-svg-xaP7B7RGvrLgEBOD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xaP7B7RGvrLgEBOD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xaP7B7RGvrLgEBOD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xaP7B7RGvrLgEBOD .marker.cross{stroke:#333333;}#mermaid-svg-xaP7B7RGvrLgEBOD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xaP7B7RGvrLgEBOD p{margin:0;}#mermaid-svg-xaP7B7RGvrLgEBOD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster-label text{fill:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster-label span{color:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster-label span p{background-color:transparent;}#mermaid-svg-xaP7B7RGvrLgEBOD .label text,#mermaid-svg-xaP7B7RGvrLgEBOD span{fill:#333;color:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD .node rect,#mermaid-svg-xaP7B7RGvrLgEBOD .node circle,#mermaid-svg-xaP7B7RGvrLgEBOD .node ellipse,#mermaid-svg-xaP7B7RGvrLgEBOD .node polygon,#mermaid-svg-xaP7B7RGvrLgEBOD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xaP7B7RGvrLgEBOD .rough-node .label text,#mermaid-svg-xaP7B7RGvrLgEBOD .node .label text,#mermaid-svg-xaP7B7RGvrLgEBOD .image-shape .label,#mermaid-svg-xaP7B7RGvrLgEBOD .icon-shape .label{text-anchor:middle;}#mermaid-svg-xaP7B7RGvrLgEBOD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xaP7B7RGvrLgEBOD .rough-node .label,#mermaid-svg-xaP7B7RGvrLgEBOD .node .label,#mermaid-svg-xaP7B7RGvrLgEBOD .image-shape .label,#mermaid-svg-xaP7B7RGvrLgEBOD .icon-shape .label{text-align:center;}#mermaid-svg-xaP7B7RGvrLgEBOD .node.clickable{cursor:pointer;}#mermaid-svg-xaP7B7RGvrLgEBOD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xaP7B7RGvrLgEBOD .arrowheadPath{fill:#333333;}#mermaid-svg-xaP7B7RGvrLgEBOD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xaP7B7RGvrLgEBOD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xaP7B7RGvrLgEBOD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xaP7B7RGvrLgEBOD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xaP7B7RGvrLgEBOD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xaP7B7RGvrLgEBOD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster text{fill:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD .cluster span{color:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xaP7B7RGvrLgEBOD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xaP7B7RGvrLgEBOD rect.text{fill:none;stroke-width:0;}#mermaid-svg-xaP7B7RGvrLgEBOD .icon-shape,#mermaid-svg-xaP7B7RGvrLgEBOD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xaP7B7RGvrLgEBOD .icon-shape p,#mermaid-svg-xaP7B7RGvrLgEBOD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xaP7B7RGvrLgEBOD .icon-shape .label rect,#mermaid-svg-xaP7B7RGvrLgEBOD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xaP7B7RGvrLgEBOD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xaP7B7RGvrLgEBOD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xaP7B7RGvrLgEBOD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET
POST
form-urlencoded
application/json
构造 HTTP 请求
方法?
UriComponentsBuilder / URIBuilder

有值才 queryParam
Content-Type?
NameValuePair 列表

有值才 add
DTO + Jackson NON_NULL
发送

实践要点

  1. REST 能用 JSON 就别滥用 Query------复杂结构、可选字段多时用 JSON 体更清晰。
  2. DTO + @JsonInclude(NON_NULL) 控制可选 JSON 字段,避免 "field": null 污染契约。
  3. 不要手拼 Query 字符串 ------encode、?/&、空值边界易错;Spring 优先 queryParamIfPresent
  4. 「可选」= 键/参数可不存在 ,不是强行传 null 或空串(除非 API 文档明确要求)。
  5. RestTemplate URI 模板 ?name={name}&age={age} 在 age 未放入 Map 时行为易踩坑,优先 Builder 拼完整 URI
  6. 对接前对齐三态语义:missing、null、空串;PATCH 与查询接口规则往往不同。
  7. 多值 Query 用重复键还是逗号分隔,以 OpenAPI/联调为准,客户端与服务端勿各写一套。
  8. 列表搜索接口@ModelAttribute POJO 收筛选、Pageable 收分页;用 springdoc 时再加 @ParameterObject 改善 OpenAPI 展示。Service 层对 null 字段不加条件

8. 延伸阅读

资源 说明
Apache HttpClient URIBuilder GET Query 构造与 encode
Spring UriComponentsBuilder queryParam / queryParamIfPresent
Jackson JsonInclude NON_NULL / NON_EMPTY
OkHttp HttpUrl 链式 Query 参数
RFC 7396 JSON Merge Patch PATCH 部分更新语义
Spring Cloud OpenFeign @SpringQueryMap 与 Query 编码
Retrofit @QueryMap Map / 键 / 值均不允许 null
Spring @ModelAttribute Query 绑定到 POJO
springdoc @ParameterObject FAQ OpenAPI 中展开 Query 对象(见 FAQ「extract fields from parameter object」)
Spring Data Pageable page / size / sort 解析

落地时注意:第三方 API 对「缺省参数」「空字符串」「重复键」语义可能不同,对接前对照 OpenAPI/Swagger 或联调样例;Jackson、RestTemplateWebClient 与 Feign 行为随 Spring Boot 版本变化,以项目依赖文档为准。