Java HTTP可选参数请求详解
HTTP 协议本身没有 「可选参数」这一说法------可选性来自客户端构造请求 或服务端声明参数可缺省 时的约定。常见需求是:某些 Query 字段、表单键或 JSON 属性有值才发送 ,缺省时不出现该键,避免 age=、city=null 之类脏参数。
速览
- GET :
URIBuilder/UriComponentsBuilder;Spring 可用queryParamIfPresent。- POST 表单 :
NameValuePair/MultiValueMap,只 add 需要的键。- REST JSON :DTO +
@JsonInclude(NON_NULL)(类级或全局配置)。- Spring 接口 :
@RequestParam(required = false);JSON 体缺字段 → null。- Spring 列表筛选 :
@ModelAttributePOJO 绑定 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)
- [6.3 Retrofit:
- [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);
WebClient:BodyInserters.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 Patch (application/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 键 → 字段 null ;age: 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
列表/搜索接口常有一组可选筛选 Query (name、status、createdAfter...),再加 分页 (page、size、sort)。逐个写 @RequestParam 冗长,可用 POJO 一次绑定。
数据绑定(Spring MVC) :对非简单类型的 Controller 参数,Spring 默认按 @ModelAttribute 把 Query 绑定到 POJO 字段------未出现在 Query 中的字段为 null ,不会参与后续条件拼接。显式标注 @ModelAttribute 更清晰(GraalVM Native 等场景也建议显式写)。
OpenAPI 文档(springdoc-openapi) :@ParameterObject(org.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
| 参数来源 | 绑定目标 | 缺省行为 |
|---|---|---|
name、age... |
UserSearchCriteria 字段 |
未出现在 Query → 字段 null |
page、size、sort |
Pageable(Spring Data) |
未传 page → 默认 0;未传 size → @PageableDefault 或全局配置 |
Service 层按 null 决定是否加 WHERE 条件(MyBatis 动态 SQL、Specification、CriteriaBuilder 等):
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 仍用
@RequestBodyDTO,不用@ModelAttribute。 - springdoc 的
@ParameterObject不支持嵌套 POJO 展开;复杂筛选宜扁平化字段。 Pageable的sort与自定义筛选字段同名时会冲突,复杂排序建议白名单字段。
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
发送
实践要点
- REST 能用 JSON 就别滥用 Query------复杂结构、可选字段多时用 JSON 体更清晰。
- DTO +
@JsonInclude(NON_NULL)控制可选 JSON 字段,避免"field": null污染契约。 - 不要手拼 Query 字符串 ------encode、
?/&、空值边界易错;Spring 优先queryParamIfPresent。 - 「可选」= 键/参数可不存在 ,不是强行传
null或空串(除非 API 文档明确要求)。 - RestTemplate URI 模板
?name={name}&age={age}在 age 未放入Map时行为易踩坑,优先 Builder 拼完整 URI。 - 对接前对齐三态语义:missing、null、空串;PATCH 与查询接口规则往往不同。
- 多值 Query 用重复键还是逗号分隔,以 OpenAPI/联调为准,客户端与服务端勿各写一套。
- 列表搜索接口 用
@ModelAttributePOJO 收筛选、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、RestTemplate、WebClient 与 Feign 行为随 Spring Boot 版本变化,以项目依赖文档为准。