Note 08. 高级 HTTP 交互:Header、Cookie 与 JSON 定制

第八章. 高级 HTTP 交互:JSON、上下文与序列化定制

摘要: 在现代前后端分离的架构中,仅仅依靠 URL 传递参数是远远不够的。本章,我们将深入 HTTP 协议的核心,攻克三个高级交互任务。首先,我们将掌握使用 @RequestBody 接收前端通过请求体发送的复杂 JSON 数据。其次,我们会学习如何通过 @RequestHeader@CookieValue 获取请求头与 Cookie 中的认证令牌、会话 ID 等关键上下文信息。最后,我们将深入探索 Jackson 注解的强大功能,从根源上解决 Java 对象与前端 JSON 之间因命名风格、日期格式、敏感信息等差异引发的“水土不服”问题。

本章学习路径

  1. JSON 数据绑定:我们将彻底理解 @RequestBody 的工作原理,并亲手实践如何定义 DTO 来接收前端发送的复杂嵌套 JSON 对象。
  2. 请求上下文捕获:我们将学习使用 @RequestHeader@CookieValue 这两个利器,精准地从请求的“元数据”中提取出认证 Token、客户端版本号和会话标识等重要信息。
  3. Jackson 局部定制:我们将系统学习 @JsonProperty@JsonIgnore@JsonFormat@JsonInclude 等多个核心 Jackson 注解,学会在 VO/DTO 层面,对单个字段进行重命名、忽略、格式化和空值处理。
  4. Jackson 全局配置:我们将学习一种更高级、更优雅的解决方案——在 application.yml 中配置全局的 Jackson 序列化规则,实现一次配置,整个项目受益,彻底告别重复的注解。

8.1. 处理请求体:@RequestBody 与 JSON 的完美结合

在上一章,我们学会了使用 @RequestParam 和 POJO 接收 URL 查询参数。这种方式非常适合处理简单的、扁平化的数据。但当业务变得复杂时,它的弊端就会暴露无遗。

8.1.1. 场景痛点:为何 URL 参数已无法胜任?

我们来构思一个“创建新用户”的业务场景。一个用户不仅有基础信息(用户名、密码),还可能有关联的地址信息(一个内嵌的对象),以及多个兴趣爱好(一个字符串列表)。

如果用 URL 参数来描述这些数据,URL 可能会变成这样:

POST /users?username=alice&password=...&address.province=北京&address.city=海淀区&hobbies=coding&hobbies=reading

这种方式至少存在三个严重问题:

  1. 结构表达力弱:URL 参数是键值对的线性结构,很难清晰地表达 address 是一个内嵌对象,hobbies 是一个数组。
  2. 长度与安全限制:GET 请求的 URL 长度在不同浏览器下有几 KB 的限制。更重要的是,将敏感信息(如密码)直接暴露在 URL 中是极不安全的。
  3. 数据类型模糊:所有参数本质上都是字符串,后端需要做大量的类型转换和校验工作。

为了解决这些问题,现代 Web 开发普遍采用一种更强大的数据交换格式——JSON (JavaScript Object Notation),并将其放置在 HTTP 请求体 (Request Body) 中进行传输。

8.1.2. 实战:使用 @RequestBody 接收 JSON 数据

Spring MVC 提供了一个核心注解 @RequestBody,它能够自动读取 HTTP 请求体中的内容,并借助内置的 Jackson 库,将 JSON 字符串反序列化(Deserialization)成一个我们预先定义好的 Java 对象。

第一步:定义承载数据的 DTO

为了接收上述复杂的用户信息,我们创建一个 UserCreateDTO

文件路径: src/main/java/com/example/demo/dto/UserCreateDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.demo.dto;

import lombok.Data;
import java.util.List;

@Data
public class UserCreateDTO {
private String username;
private String password;
private AddressDTO address; // 嵌套对象
private List<String> hobbies; // 列表

@Data
public static class AddressDTO {
private String province;
private String city;
private String district;
}
}

第二步:在 Controller 中编写接收方法

我们在 UserController 中增加一个 createUser 的方法,这次,我们在参数前加上 @RequestBody 注解。

文件路径: src/main/java/com/example/demo/controller/UserController.java (追加新方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.example.demo.dto.UserCreateDTO; // 确保导入

// ... 在 UserController 类中 ...
/**
* @RequestBody 的核心作用:
* 1. 它告诉 Spring MVC,不要去 URL 的查询参数里找数据。
* 2. 而是应该去读取整个 HTTP 请求的 Body 部分。
* 3. Spring MVC 会找到处理 application/json 类型的消息转换器 (HttpMessageConverter),
* 默认是 MappingJackson2HttpMessageConverter。
* 4. 这个转换器会负责将 Body 中的 JSON 文本,转换成 UserCreateDTO 类型的 Java 对象。
*/
@PostMapping("/create")
public String createUser(@RequestBody UserCreateDTO userDTO) {
System.out.println("成功接收并解析 JSON 数据: " + userDTO.toString());
return "用户创建成功: " + userDTO.getUsername();
}

8.1.3. 验证与测试

要测试这个接口,我们必须使用能够自定义请求体和请求头的工具,如 cURL 或 Postman。

关键前提:发送 JSON 数据时,必须在 HTTP 请求头中明确指定 Content-Type: application/json。这是客户端与服务器之间的一个“契约”,告知服务器请求体中的内容是 JSON 格式,服务器应该找 JSON 解析器来处理。如果缺失或错误,服务器会因无法识别数据类型而返回 415 Unsupported Media Type 错误。

使用 cURL 发送一个包含嵌套对象和数组的 JSON 数据:

1
2
3
4
5
6
7
8
9
10
11
12
curl -X POST 'http://localhost:8080/users/create' \
-H 'Content-Type: application/json' \
-d '{
"username": "alice",
"password": "secure_password_123",
"address": {
"province": "北京",
"city": "北京市",
"district": "海淀区"
},
"hobbies": ["编程", "阅读", "健身"]
}'

预期控制台输出
成功接收并解析 JSON 数据: UserCreateDTO(username=alice, password=secure_password_123, address=UserCreateDTO.AddressDTO(province=北京, city=北京市, district=海淀区), hobbies=[编程, 阅读, 健身])

预期浏览器或工具返回
用户创建成功: alice


一个 HTTP 请求,除了包含我们关心的业务数据(在 Body 或 URL 参数中),还携带了大量的“元数据”,它们位于 Header (请求头)Cookie 中,共同构成了请求的上下文。这些信息对于实现认证、会话管理、版本控制等功能至关重要。

8.2.1. 获取请求头:@RequestHeader

常见应用场景

  • 身份认证:前端在登录后,后续请求通常会在 Authorization 头中携带一个 Bearer Token
  • API 版本控制:客户端通过一个自定义的头(如 X-API-VERSION)来告知服务器它期望使用的 API 版本。
  • 客户端信息:获取 User-Agent 头可以了解用户的浏览器和操作系统信息。

代码示例

我们创建一个 HeaderController 来演示如何获取这些信息。

文件路径: src/main/java/com/example/demo/controller/HeaderController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
public class HeaderController {

@GetMapping("/header-info")
public String getHeaderInfo(
// 1. 获取特定的、必需的 Header
@RequestHeader("User-Agent") String userAgent,

// 2. 获取可选的 Header,并提供默认值
@RequestHeader(value = "X-Custom-Header", defaultValue = "default") String customHeader,

// 3. 将所有 Header 注入到一个 Map 中
@RequestHeader Map<String, String> allHeaders
) {
System.out.println("所有请求头: " + allHeaders);
return String.format("User-Agent: %s, Custom-Header: %s", userAgent, customHeader);
}
}

验证

1
2
3
4
5
# 同时发送一个自定义 Header
curl -H "X-Custom-Header: my-value" http://localhost:8080/header-info

# 预期返回: User-Agent: curl/7.79.1, Custom-Header: my-value
# (User-Agent 的值取决于你本地的 curl 版本)

8.2.2. 获取 Cookie:@CookieValue

常见应用场景

  • 传统会话管理:获取由服务器设置的 JSESSIONID 来维持用户登录状态。
  • 用户偏好:读取存储在 Cookie 中的用户语言偏好(如 lang=en-US)。
  • 追踪与分析:获取第三方分析工具(如 Google Analytics)设置的追踪 ID。

代码示例

创建一个 CookieController

文件路径: src/main/java/com/example/demo/controller/CookieController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.demo.controller;

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;

@RestController
public class CookieController {

@GetMapping("/cookie-info")
public String getCookieInfo(
// 1. 获取特定的 Cookie,如果不存在会报错
// @CookieValue("JSESSIONID") String sessionId,

// 2. 获取可选的 Cookie,不存在时参数为 null
@CookieValue(value = "user-preference", required = false) String preference,

// 3. 通过 HttpServletRequest 获取所有 Cookie (更底层的方式)
HttpServletRequest request
) {
String allCookies = "无";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
allCookies = Arrays.toString(cookies);
}
System.out.println("所有 Cookies: " + allCookies);
return "用户偏好设置: " + (preference == null ? "未设置" : preference);
}
}

验证

1
2
3
4
# 使用 --cookie 参数来模拟浏览器发送 Cookie
curl --cookie "user-preference=dark-mode; other-cookie=test" http://localhost:8080/cookie-info

# 预期返回: 用户偏好设置: dark-mode

8.3. Jackson 序列化定制:控制 JSON 输出格式

在上一节中,我们学会了使用 @RequestBody 接收前端发送的 JSON 数据。但数据的流动是双向的——当我们需要将 Java 对象返回给前端时,Spring Boot 会自动调用 Jackson 库将对象序列化为 JSON 字符串。默认的序列化行为能满足基本需求,但在企业级项目中,后端 Java 对象的命名规范与前端期望的 JSON 格式之间往往存在诸多差异。本节我们将学习如何通过 Jackson 注解对序列化过程进行精细控制。

8.3.1. 字段重命名:@JsonProperty

Java 开发规范推荐使用驼峰命名法(如 userId),而许多前端项目或第三方 API 则偏好下划线命名法(如 user_id)。@JsonProperty 注解可以在不修改 Java 字段名的前提下,指定其在 JSON 中的名称。

📄 文件src/main/java/com/example/demo/vo/UserVO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.demo.vo;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

@Data
public class UserVO {

// 序列化时:Java 的 userId 字段会输出为 JSON 的 "user_id"
// 反序列化时:JSON 的 "user_id" 会映射到 Java 的 userId 字段
@JsonProperty("user_id")
private Long userId;

// 未标注 @JsonProperty 的字段,JSON 键名与 Java 字段名一致
private String nickname;

@JsonProperty("email_address")
private String email;
}

创建一个测试接口来验证效果:

📄 文件src/main/java/com/example/demo/controller/JsonTestController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demo.controller;

import com.example.demo.vo.UserVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class JsonTestController {

@GetMapping("/user/profile")
public UserVO getUserProfile() {
UserVO vo = new UserVO();
vo.setUserId(1001L);
vo.setNickname("GeekMaster");
vo.setEmail("geek@example.com");
return vo;
}
}

验证测试

访问 http://localhost:8080/user/profile,返回结果:

1
2
3
4
5
{
"user_id": 1001,
"nickname": "GeekMaster",
"email_address": "geek@example.com"
}

userId 成功被重命名为 user_idemail 被重命名为 email_address,而 nickname 保持原样。

8.3.2. 字段忽略:@JsonIgnore 与 @JsonIgnoreProperties

在实际业务中,Java 对象中的某些字段不应该暴露给前端。最典型的例子就是用户密码、加密盐值、内部状态标记等敏感信息。Jackson 提供了两个注解来实现字段忽略。

@JsonIgnore:标注在单个字段上,使该字段在序列化和反序列化时都被忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class UserVO {

private Long userId;

private String nickname;

// 无论序列化还是反序列化,password 字段都会被完全忽略
// 即使前端传了 password,也不会被赋值到对象中
// 即使对象中有 password 值,也不会输出到 JSON 中
@JsonIgnore
private String password;

// 同样忽略加密盐值
@JsonIgnore
private String salt;
}

@JsonIgnoreProperties:标注在类上,可以一次性忽略多个字段,也可以配置忽略未知字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方式一:忽略指定的多个字段
@JsonIgnoreProperties({"password", "salt", "internalFlag"})
@Data
public class UserVO {
private Long userId;
private String nickname;
private String password;
private String salt;
private Integer internalFlag;
}

// 方式二:忽略 JSON 中存在但 Java 对象中不存在的字段(反序列化时)
// 这可以增强接口的健壮性,即使前端多传了字段,后端也不会报错
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class UserCreateDTO {
private String username;
private String password;
// 如果前端多传了 "extraField",不会导致反序列化失败
}

8.3.3. 日期格式化:@JsonFormat

日期时间类型的序列化是一个常见的痛点。Java 8 引入的 LocalDateTimeLocalDate 等类型,默认会被序列化为一个复杂的对象结构或 ISO 格式字符串,这通常不是前端期望的格式。

问题演示

1
2
3
4
5
@Data
public class OrderVO {
private Long orderId;
private LocalDateTime createTime;
}

默认序列化结果可能是:

1
2
3
4
{
"orderId": 1001,
"createTime": "2025-12-15T20:30:00"
}

或者更糟糕的数组格式:

1
2
3
4
{
"orderId": 1001,
"createTime": [2025, 12, 15, 20, 30, 0]
}

使用 @JsonFormat 解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
public class OrderVO {

private Long orderId;

// pattern: 定义输出的日期时间格式
// timezone: 指定时区,GMT+8 表示东八区(北京时间)
// 如果不指定 timezone,可能会出现 8 小时的时差问题
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;

// 只需要日期部分
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate deliveryDate;

// 自定义格式:中文风格
@JsonFormat(pattern = "yyyy年MM月dd日 HH时mm分", timezone = "GMT+8")
private LocalDateTime displayTime;
}

格式化后的结果

1
2
3
4
5
6
{
"orderId": 1001,
"createTime": "2025-12-15 20:30:00",
"deliveryDate": "2025-12-20",
"displayTime": "2025年12月15日 20时30分"
}

时区陷阱:如果不指定 timezone = "GMT+8",Jackson 默认使用 UTC 时区进行序列化。这会导致数据库中存储的 20:00:00 在 JSON 中变成 12:00:00,产生 8 小时的时差。这是一个非常常见的 Bug,务必注意。

8.3.4. 空值处理:@JsonInclude

默认情况下,即使 Java 对象的某个字段值为 null,Jackson 也会将其序列化到 JSON 中。这会导致响应体中出现大量无意义的 "fieldName": null,既浪费带宽,也不够优雅。

问题演示

1
2
3
4
5
6
7
@Data
public class UserVO {
private Long userId; // 值: 1001
private String nickname; // 值: "Tom"
private String bio; // 值: null
private String avatar; // 值: null
}

默认序列化结果:

1
2
3
4
5
6
{
"userId": 1001,
"nickname": "Tom",
"bio": null,
"avatar": null
}

使用 @JsonInclude 解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 方式一:在类级别配置,对所有字段生效
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserVO {
private Long userId;
private String nickname;
private String bio; // 为 null 时不输出
private String avatar; // 为 null 时不输出
}

// 方式二:在字段级别配置,只对特定字段生效
@Data
public class UserVO {
private Long userId;
private String nickname;

@JsonInclude(JsonInclude.Include.NON_NULL)
private String bio;

@JsonInclude(JsonInclude.Include.NON_EMPTY) // null 或空字符串都不输出
private String avatar;
}

常用的 Include 策略

策略说明
ALWAYS默认值,总是输出,即使为 null
NON_NULL字段值不为 null 时才输出
NON_EMPTY字段值不为 null 且不为空(空字符串、空集合)时才输出
NON_DEFAULT字段值不等于默认值时才输出(如 int 不为 0,boolean 不为 false)

配置后的结果

1
2
3
4
{
"userId": 1001,
"nickname": "Tom"
}

响应体变得简洁清爽,bioavatar 因为是 null 而被自动忽略。

8.3.5. 本节小结

注解作用使用场景
@JsonProperty重命名字段驼峰转下划线、对接第三方 API
@JsonIgnore忽略单个字段隐藏密码、盐值等敏感信息
@JsonIgnoreProperties忽略多个字段或未知字段批量忽略、增强反序列化健壮性
@JsonFormat格式化日期时间统一日期输出格式、解决时区问题
@JsonInclude控制空值输出减小响应体积、提升 JSON 整洁度

8.4. Jackson 反序列化定制:控制 JSON 输入解析

上一节我们学习了如何控制 Java 对象到 JSON 的输出格式(序列化)。本节我们将关注相反的方向——如何控制 JSON 到 Java 对象的输入解析(反序列化)。在实际开发中,前端传来的 JSON 结构可能与后端 DTO 存在差异,Jackson 提供了多种注解来灵活应对这些情况。

8.4.1. 宽松解析:忽略未知字段

当前端发送的 JSON 中包含后端 DTO 中不存在的字段时,Jackson 默认会抛出异常:

1
2
3
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "extraField" (class com.example.demo.dto.UserDTO),
not marked as ignorable

这种严格模式在某些场景下会带来麻烦。比如,前端升级后多传了一个字段,或者对接第三方回调时对方新增了字段,都会导致接口报错。

解决方案一:在 DTO 类上添加注解

1
2
3
4
5
6
7
8
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserCreateDTO {
private String username;
private String password;
// 即使前端传了 {"username":"tom", "password":"123", "extraField":"xxx"}
// extraField 会被静默忽略,不会报错
}

解决方案二:全局配置(推荐)

application.yml 中配置,对所有 DTO 生效:

1
2
3
4
spring:
jackson:
deserialization:
fail-on-unknown-properties: false

8.4.2. 字段别名:@JsonAlias

有时候,同一个字段可能有多种命名方式。比如用户名字段,有的前端传 username,有的传 userName,有的传 user_name@JsonAlias 可以为一个字段定义多个可接受的别名。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class LoginDTO {

// 反序列化时,以下三种 JSON 键名都能映射到 username 字段:
// {"username": "tom"} ✅
// {"userName": "tom"} ✅
// {"user_name": "tom"} ✅
@JsonAlias({"userName", "user_name"})
private String username;

@JsonAlias({"pwd", "pass"})
private String password;
}

@JsonProperty vs @JsonAlias 的区别

  • @JsonProperty("user_name") 会同时影响序列化和反序列化,输出时字段名变为 user_name
  • @JsonAlias({"user_name"}) 只影响反序列化,输出时字段名仍为 username

8.4.4. 本节小结

注解/配置作用使用场景
@JsonIgnoreProperties(ignoreUnknown=true)忽略未知字段增强接口健壮性
fail-on-unknown-properties: false全局忽略未知字段项目级配置
@JsonAlias字段别名(仅反序列化)兼容多种命名风格

8.5. Jackson 多态反序列化:一个接口接收多种 JSON 结构

前面我们学习的都是"一个 DTO 对应一种 JSON 结构"的简单场景。但在实际业务中,经常会遇到更复杂的需求:同一个接口需要根据某个字段的值,将 JSON 反序列化为不同的 Java 类型。这就是 Jackson 的多态反序列化能力。

8.5.1. 场景痛点:统一登录接口如何区分多种认证方式?

假设我们正在开发一个统一认证系统,需要支持多种登录方式:

  • 密码登录:需要 usernamepassword
  • 短信登录:需要 phonecode
  • 微信登录:需要 code(微信授权码)

最直观的做法是为每种登录方式创建一个独立的接口:

1
2
3
4
5
6
7
8
@PostMapping("/login/password")
public Result login(@RequestBody PasswordLoginDTO dto) { ... }

@PostMapping("/login/sms")
public Result login(@RequestBody SmsLoginDTO dto) { ... }

@PostMapping("/login/wechat")
public Result login(@RequestBody WechatLoginDTO dto) { ... }

这种方式可行,但存在明显的问题:

  1. 接口分散:前端需要根据登录方式调用不同的 URL
  2. 扩展困难:每新增一种登录方式,就要新增一个接口
  3. 代码重复:每个接口的后续处理逻辑(生成 Token、记录日志等)高度相似

更优雅的方案是:提供一个统一的 /login 接口,根据 JSON 中的 authType 字段自动识别并反序列化为对应的 DTO 类型

1
2
3
4
5
6
7
8
// 密码登录
{"authType": "PASSWORD", "username": "admin", "password": "123456"}

// 短信登录
{"authType": "SMS", "phone": "13800138000", "code": "123456"}

// 微信登录
{"authType": "WECHAT", "code": "wx_auth_code_xxx"}

这正是 Jackson 多态反序列化要解决的问题。

8.5.2. 核心注解:@JsonTypeInfo 与 @JsonSubTypes

Jackson 通过两个核心注解实现多态反序列化:

@JsonTypeInfo:定义类型识别的策略

属性说明
use类型标识的方式,常用 JsonTypeInfo.Id.NAME(使用名称标识)
include类型标识在 JSON 中的位置,常用 As.EXISTING_PROPERTY(使用已有字段)
property作为类型标识的字段名
visible类型标识字段是否在反序列化后可见,通常设为 true

@JsonSubTypes:注册所有子类型及其对应的类型名称

1
2
3
4
5
@JsonSubTypes({
@JsonSubTypes.Type(value = PasswordAuthRequest.class, name = "PASSWORD"),
@JsonSubTypes.Type(value = SmsAuthRequest.class, name = "SMS"),
@JsonSubTypes.Type(value = WechatAuthRequest.class, name = "WECHAT")
})

8.5.3. 实战:构建多态认证请求体系

第一步:定义认证类型枚举

📄 文件src/main/java/com/example/demo/enums/AuthType.java

1
2
3
4
5
6
7
8
9
10
package com.example.demo.enums;

/**
* 认证类型枚举
*/
public enum AuthType {
PASSWORD, // 账号密码登录
SMS, // 手机验证码登录
WECHAT // 微信扫码登录
}

第二步:定义认证请求接口(核心)

📄 文件src/main/java/com/example/demo/dto/AuthRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.demo.dto;

import com.example.demo.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

/**
* 认证请求接口
* 所有登录方式的请求 DTO 都必须实现此接口
*/
// @JsonTypeInfo: 指定多态类型的识别方式
// use = Id.NAME: 使用类型名称作为标识
// include = As.EXISTING_PROPERTY: 使用 JSON 中已存在的字段作为类型标识,不额外添加 @type 字段
// property = "authType": 类型标识的字段名,对应 JSON 中的 authType 字段
// visible = true: 反序列化后 authType 字段仍然可见,可以通过 getter 获取
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "authType",
visible = true
)
// @JsonSubTypes: 注册所有子类型
// name 的值必须与 JSON 中 authType 字段的值完全匹配(区分大小写)
@JsonSubTypes({
@JsonSubTypes.Type(value = PasswordAuthRequest.class, name = "PASSWORD"),
@JsonSubTypes.Type(value = SmsAuthRequest.class, name = "SMS"),
@JsonSubTypes.Type(value = WechatAuthRequest.class, name = "WECHAT")
})
public interface AuthRequest {

/**
* 获取认证类型
* @return 认证类型枚举
*/
AuthType getAuthType();
}

第三步:实现各种认证请求 DTO

📄 文件src/main/java/com/example/demo/dto/PasswordAuthRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.demo.dto;

import com.example.demo.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 账号密码登录请求
*/
@Data
@JsonTypeName("PASSWORD") // 指定类型名称,与 @JsonSubTypes 中的 name 一致
public class PasswordAuthRequest implements AuthRequest {

/**
* 认证类型
* 前端必须传此字段,用于 Jackson 多态反序列化识别
*/
private AuthType authType = AuthType.PASSWORD;

/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
private String username;

/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;

@Override
public AuthType getAuthType() {
return authType;
}
}

📄 文件src/main/java/com/example/demo/dto/SmsAuthRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.demo.dto;

import com.example.demo.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

/**
* 手机验证码登录请求
*/
@Data
@JsonTypeName("SMS")
public class SmsAuthRequest implements AuthRequest {

private AuthType authType = AuthType.SMS;

@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;

@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "^\\d{6}$", message = "验证码必须是6位数字")
private String code;

@Override
public AuthType getAuthType() {
return authType;
}
}

📄 文件src/main/java/com/example/demo/dto/WechatAuthRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.demo.dto;

import com.example.demo.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 微信扫码登录请求
*/
@Data
@JsonTypeName("WECHAT")
public class WechatAuthRequest implements AuthRequest {

private AuthType authType = AuthType.WECHAT;

@NotBlank(message = "微信授权码不能为空")
private String code;

@Override
public AuthType getAuthType() {
return authType;
}
}

第四步:创建统一登录接口

📄 文件src/main/java/com/example/demo/controller/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.demo.controller;

import com.example.demo.dto.AuthRequest;
import com.example.demo.dto.PasswordAuthRequest;
import com.example.demo.dto.SmsAuthRequest;
import com.example.demo.dto.WechatAuthRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

/**
* 统一登录接口
* 根据 authType 字段自动反序列化为对应的 DTO 类型
*/
@PostMapping("/login")
public String login(@RequestBody AuthRequest request) {
// 打印实际接收到的类型
System.out.println("接收到的请求类型: " + request.getClass().getSimpleName());
System.out.println("认证方式: " + request.getAuthType());

// 根据实际类型进行不同的处理
if (request instanceof PasswordAuthRequest passwordReq) {
return String.format("密码登录 - 用户名: %s", passwordReq.getUsername());
} else if (request instanceof SmsAuthRequest smsReq) {
return String.format("短信登录 - 手机号: %s, 验证码: %s", smsReq.getPhone(), smsReq.getCode());
} else if (request instanceof WechatAuthRequest wechatReq) {
return String.format("微信登录 - 授权码: %s", wechatReq.getCode());
}

return "未知的登录方式";
}
}

8.5.4. 验证多态反序列化效果

启动应用后,我们使用 curl 测试三种不同的登录请求。

测试一:密码登录

1
2
3
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"authType": "PASSWORD", "username": "admin", "password": "123456"}'

预期控制台输出:

1
2
接收到的请求类型: PasswordAuthRequest
认证方式: PASSWORD

预期返回:

1
密码登录 - 用户名: admin

测试二:短信登录

1
2
3
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"authType": "SMS", "phone": "13800138000", "code": "123456"}'

预期控制台输出:

1
2
接收到的请求类型: SmsAuthRequest
认证方式: SMS

预期返回:

1
短信登录 - 手机号: 13800138000, 验证码: 123456

测试三:微信登录

1
2
3
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"authType": "WECHAT", "code": "wx_auth_code_xxx"}'

预期控制台输出:

1
2
接收到的请求类型: WechatAuthRequest
认证方式: WECHAT

预期返回:

1
微信登录 - 授权码: wx_auth_code_xxx

三种不同结构的 JSON,通过同一个接口,被自动反序列化为对应的 Java 类型。这就是 Jackson 多态反序列化的威力。

8.5.5. 本节小结

注解作用关键属性
@JsonTypeInfo定义类型识别策略use、include、property、visible
@JsonSubTypes注册子类型映射value(子类)、name(类型名)
@JsonTypeName指定子类的类型名称value

多态反序列化的典型应用场景

场景类型标识字段子类型示例
统一登录接口authType密码登录、短信登录、微信登录
支付回调处理payType支付宝、微信、银联
消息推送系统msgType文本消息、图片消息、视频消息
审批流程引擎nodeType审批节点、抄送节点、条件节点

8.6. Jackson 全局配置:一劳永逸的优雅方案

在前面的章节中,我们学习了如何使用注解对单个字段或类进行定制。但如果项目中所有接口都需要遵循相同的规范(例如,所有驼峰都转下划线,所有日期都按特定格式输出,所有空值都不返回),在每个 VO/DTO 上重复写同样的注解就显得非常笨拙。Spring Boot 允许我们通过 application.yml 文件,对 Jackson 的行为进行全局配置。

8.6.1. 在 application.yml 中配置 Jackson

打开 src/main/resources/application.yml 文件,添加以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
spring:
jackson:
# 1. 全局时区设置
# 解决所有日期时间类型序列化时的 8 小时时差问题
# 设置为东八区(北京时间)
time-zone: GMT+8

# 2. 全局日期格式化
# 注意:此配置对 java.util.Date 类型有效
# 对于 LocalDateTime 等 Java 8 日期类型,建议使用 @JsonFormat 或自定义序列化器
date-format: yyyy-MM-dd HH:mm:ss

# 3. 全局命名策略
# 自动将 Java 的驼峰命名 (camelCase) 转换为下划线命名 (snake_case)
# 有了这个配置,就无需在每个字段上都写 @JsonProperty("user_id") 了
# 可选值:SNAKE_CASE, UPPER_CAMEL_CASE, LOWER_CAMEL_CASE, KEBAB_CASE 等
property-naming-strategy: SNAKE_CASE

# 4. 全局空值处理
# 相当于在所有类上加了 @JsonInclude(JsonInclude.Include.NON_NULL)
# 可选值:always, non_null, non_absent, non_default, non_empty
default-property-inclusion: non_null

# 5. 序列化配置
serialization:
# 格式化输出 JSON(开发环境便于调试,生产环境建议关闭以减小体积)
indent-output: true
# 日期不要输出为时间戳
write-dates-as-timestamps: false

# 6. 反序列化配置
deserialization:
# 当 JSON 的字段在 Java 对象中不存在时,是否失败
# 设置为 false 可以让接口更健壮,忽略前端多传的无用字段
fail-on-unknown-properties: false
# 当 JSON 中的枚举值无法匹配时,是否失败
# 设置为 false 时,无法匹配的枚举会被设为 null
read-unknown-enum-values-as-null: true

配置生效后的效果

假设我们有一个简单的 VO 类,没有添加任何 Jackson 注解:

1
2
3
4
5
6
7
8
@Data
public class ProductVO {
private Long productId;
private String productName;
private BigDecimal unitPrice;
private LocalDateTime createTime;
private String description; // 假设值为 null
}

配置前的输出

1
{"productId":1001,"productName":"iPhone 15","unitPrice":7999.00,"createTime":"2025-12-15T20:30:00","description":null}

配置后的输出

1
2
3
4
5
6
{
"product_id": 1001,
"product_name": "iPhone 15",
"unit_price": 7999.00,
"create_time": "2025-12-15 20:30:00"
}

可以看到:

  • 字段名自动从驼峰转为下划线
  • 日期格式化为易读的字符串
  • description 字段因为是 null 而被忽略
  • JSON 输出带有缩进,便于阅读

8.6.2. 全局配置 vs 局部注解的优先级

当全局配置和局部注解同时存在时,局部注解的优先级更高,会覆盖全局配置。

示例

1
2
3
4
# application.yml
spring:
jackson:
property-naming-strategy: SNAKE_CASE # 全局下划线命名
1
2
3
4
5
6
7
8
9
@Data
public class MixedVO {
private Long userId; // 输出为 user_id(遵循全局配置)

@JsonProperty("userName") // 局部注解覆盖全局配置
private String username; // 输出为 userName(遵循局部注解)

private String emailAddress; // 输出为 email_address(遵循全局配置)
}

输出结果

1
2
3
4
5
{
"user_id": 1001,
"userName": "Tom",
"email_address": "tom@example.com"
}

最佳实践

  • 将项目中最通用的、普适性的规则(如命名策略、时区、空值处理)配置在 application.yml
  • 对于个别需要特殊处理的字段(如某个字段需要保留驼峰命名,或某个日期需要不同的格式),再使用局部注解去覆盖全局配置

这种"全局为主,注解为辅"的策略,是企业级项目中处理 JSON 序列化的最佳实践。

8.6.3. 本节小结

配置项作用推荐值
time-zone全局时区GMT+8
date-format日期格式(Date 类型)yyyy-MM-dd HH:mm:ss
property-naming-strategy命名策略SNAKE_CASE
default-property-inclusion空值处理non_null
fail-on-unknown-properties忽略未知字段false
indent-output格式化输出开发 true,生产 false

一个 HTTP 请求,除了包含我们关心的业务数据(在 Body 或 URL 参数中),还携带了大量的"元数据",它们位于 Header(请求头)Cookie 中,共同构成了请求的上下文。这些信息对于实现认证、会话管理、版本控制等功能至关重要。

8.7.1. 获取请求头:@RequestHeader

常见应用场景

  • 身份认证:前端在登录后,后续请求通常会在 Authorization 头中携带一个 Bearer Token
  • API 版本控制:客户端通过一个自定义的头(如 X-API-VERSION)来告知服务器它期望使用的 API 版本
  • 客户端信息:获取 User-Agent 头可以了解用户的浏览器和操作系统信息
  • 请求追踪:获取 X-Request-ID 用于分布式链路追踪

📄 文件src/main/java/com/example/demo/controller/HeaderController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
public class HeaderController {

@GetMapping("/header-info")
public String getHeaderInfo(
// 1. 获取特定的、必需的 Header
// 如果请求中没有 User-Agent 头,会返回 400 Bad Request
@RequestHeader("User-Agent") String userAgent,

// 2. 获取可选的 Header,并提供默认值
// 如果请求中没有 X-Custom-Header,则使用默认值 "default"
@RequestHeader(value = "X-Custom-Header", defaultValue = "default") String customHeader,

// 3. 获取可选的 Header,不存在时为 null
@RequestHeader(value = "X-Request-ID", required = false) String requestId,

// 4. 将所有 Header 注入到一个 Map 中
@RequestHeader Map<String, String> allHeaders
) {
System.out.println("所有请求头: " + allHeaders);
System.out.println("请求追踪ID: " + requestId);

return String.format("User-Agent: %s, Custom-Header: %s, Request-ID: %s",
userAgent, customHeader, requestId);
}

/**
* 实际业务场景:从 Authorization 头获取 Token
*/
@GetMapping("/user/me")
public String getCurrentUser(
@RequestHeader("Authorization") String authorizationHeader
) {
// Authorization 头的格式通常是 "Bearer eyJhbGciOi..."
// 需要去掉 "Bearer " 前缀获取真正的 Token
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
// 这里应该调用 JWT 工具类解析 Token,获取用户信息
return "Token 解析成功: " + token.substring(0, Math.min(20, token.length())) + "...";
}
return "无效的 Authorization 头";
}
}

验证测试

1
2
3
4
# 测试基本的 Header 获取
curl -H "X-Custom-Header: my-value" -H "X-Request-ID: req-12345" http://localhost:8080/header-info

# 预期返回: User-Agent: curl/7.79.1, Custom-Header: my-value, Request-ID: req-12345
1
2
3
4
# 测试 Authorization 头
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" http://localhost:8080/user/me

# 预期返回: Token 解析成功: eyJhbGciOiJIUzI1Ni...

8.7.2. 获取 Cookie:@CookieValue

常见应用场景

  • 传统会话管理:获取由服务器设置的 JSESSIONID 来维持用户登录状态
  • 用户偏好:读取存储在 Cookie 中的用户语言偏好(如 lang=en-US
  • 追踪与分析:获取第三方分析工具(如 Google Analytics)设置的追踪 ID
  • 记住登录:读取 remember-me Cookie 实现自动登录

📄 文件src/main/java/com/example/demo/controller/CookieController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.demo.controller;

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;

@RestController
public class CookieController {

@GetMapping("/cookie-info")
public String getCookieInfo(
// 1. 获取可选的 Cookie,不存在时参数为 null
@CookieValue(value = "user-preference", required = false) String preference,

// 2. 获取可选的 Cookie,提供默认值
@CookieValue(value = "theme", defaultValue = "light") String theme,

// 3. 通过 HttpServletRequest 获取所有 Cookie(更底层的方式)
HttpServletRequest request
) {
// 打印所有 Cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
System.out.println("所有 Cookies:");
for (Cookie cookie : cookies) {
System.out.println(" " + cookie.getName() + " = " + cookie.getValue());
}
} else {
System.out.println("没有收到任何 Cookie");
}

return String.format("用户偏好: %s, 主题: %s",
preference == null ? "未设置" : preference,
theme);
}

/**
* 设置 Cookie 的示例
*/
@GetMapping("/set-cookie")
public String setCookie(HttpServletResponse response) {
// 创建一个 Cookie
Cookie preferenceCookie = new Cookie("user-preference", "dark-mode");
preferenceCookie.setMaxAge(7 * 24 * 60 * 60); // 有效期 7 天
preferenceCookie.setPath("/"); // 对整个站点有效
preferenceCookie.setHttpOnly(true); // 防止 JavaScript 访问,提高安全性

// 将 Cookie 添加到响应中
response.addCookie(preferenceCookie);

return "Cookie 设置成功";
}
}

验证测试

1
2
3
4
5
6
7
8
9
10
# 先设置 Cookie
curl -c cookies.txt http://localhost:8080/set-cookie

# 然后带着 Cookie 访问
curl -b cookies.txt http://localhost:8080/cookie-info

# 或者手动指定 Cookie
curl --cookie "user-preference=dark-mode; theme=blue" http://localhost:8080/cookie-info

# 预期返回: 用户偏好: dark-mode, 主题: blue

8.7.3. 本节小结

注解作用常用属性
@RequestHeader获取 HTTP 请求头value、required、defaultValue
@CookieValue获取 Cookie 值value、required、defaultValue
应用场景推荐方式示例
JWT Token 认证@RequestHeader("Authorization")Bearer Token
API 版本控制@RequestHeader("X-API-VERSION")v1、v2
会话管理@CookieValue("JSESSIONID")传统 Session
用户偏好@CookieValue("preference")主题、语言

8.8. 本章总结与 JSON 交互速查

8.8.1. 核心注解速查表

序列化注解(Java → JSON)

注解作用示例
@JsonProperty重命名字段@JsonProperty("user_id")
@JsonIgnore忽略字段标注在 password 字段上
@JsonFormat格式化日期@JsonFormat(pattern = "yyyy-MM-dd")
@JsonInclude控制空值输出@JsonInclude(Include.NON_NULL)

反序列化注解(JSON → Java)

注解作用示例
@JsonIgnoreProperties忽略未知字段@JsonIgnoreProperties(ignoreUnknown = true)
@JsonAlias字段别名@JsonAlias({"userName", "user_name"})
@JsonCreator构造器反序列化标注在构造方法上

多态反序列化注解

注解作用示例
@JsonTypeInfo定义类型识别策略use = Id.NAME, property = "type"
@JsonSubTypes注册子类型映射@Type(value = XxxDTO.class, name = "XXX")
@JsonTypeName指定子类类型名@JsonTypeName("PASSWORD")

8.8.2. 场景化代码模板

场景一:返回给前端的 JSON 需要精细化控制

需求:字段名从驼峰转下划线,隐藏密码,格式化日期,忽略所有 null 值字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDetailVO {

@JsonProperty("user_id")
private Long id;

private String nickname;

@JsonIgnore
private String password;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
}

场景二:接收前端多种命名风格的 JSON

需求:前端可能传 usernameuserNameuser_name,后端都能正确接收。

1
2
3
4
5
6
7
8
9
10
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginDTO {

@JsonAlias({"userName", "user_name"})
private String username;

@JsonAlias({"pwd", "pass"})
private String password;
}

场景三:统一接口接收多种类型的请求

需求:登录接口根据 authType 字段区分密码登录、短信登录、微信登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 接口定义
@JsonTypeInfo(use = Id.NAME, include = As.EXISTING_PROPERTY, property = "authType", visible = true)
@JsonSubTypes({
@Type(value = PasswordAuthRequest.class, name = "PASSWORD"),
@Type(value = SmsAuthRequest.class, name = "SMS"),
@Type(value = WechatAuthRequest.class, name = "WECHAT")
})
public interface AuthRequest {
AuthType getAuthType();
}

// Controller
@PostMapping("/login")
public Result login(@RequestBody AuthRequest request) {
if (request instanceof PasswordAuthRequest req) {
// 处理密码登录
} else if (request instanceof SmsAuthRequest req) {
// 处理短信登录
}
// ...
}

场景四:为整个项目设定统一的 JSON 风格

需求:所有接口默认驼峰转下划线,不返回 null 字段,统一时区。

1
2
3
4
5
6
7
8
# application.yml
spring:
jackson:
property-naming-strategy: SNAKE_CASE
default-property-inclusion: non_null
time-zone: GMT+8
deserialization:
fail-on-unknown-properties: false

场景五:从请求头获取认证 Token

需求:在受保护的接口中,获取 Authorization 请求头的值。

1
2
3
4
5
6
7
8
9
@GetMapping("/user/me")
public UserProfile getCurrentUser(
@RequestHeader("Authorization") String authorizationHeader
) {
// Token 格式通常是 "Bearer eyJhbGciOi..."
String token = authorizationHeader.substring(7);
Long userId = JwtUtils.getUserIdFrom(token);
return userService.getProfile(userId);
}

8.8.3. 核心避坑指南

问题一:@RequestBody 标注的 DTO 对象属性全是 null

现象原因解决方案
确认前端已发送 JSON,但 DTO 所有字段都是 nullJSON 格式错误(多/少逗号、引号未闭合)使用 JSON 校验工具检查格式
同上JSON 字段名与 DTO 属性名不匹配检查大小写,使用 @JsonProperty@JsonAlias
同上缺少无参构造器确保 DTO 有无参构造器(使用 @Data 会自动生成)

问题二:415 Unsupported Media Type 错误

现象原因解决方案
POST/PUT 请求返回 415 状态码请求头缺少 Content-Type: application/json添加该请求头

问题三:返回的日期时间与数据库相差 8 小时

现象原因解决方案
数据库是 20:00,JSON 返回 12:00Jackson 默认使用 UTC 时区配置 spring.jackson.time-zone: GMT+8
同上单个字段未指定时区使用 @JsonFormat(timezone = "GMT+8")

问题四:Unrecognized field “xxx” 异常

现象原因解决方案
反序列化时抛出 UnrecognizedPropertyExceptionJSON 包含 DTO 中不存在的字段配置 fail-on-unknown-properties: false
同上同上在 DTO 上添加 @JsonIgnoreProperties(ignoreUnknown = true)

问题五:多态反序列化失败

现象原因解决方案
无法识别子类型@JsonSubTypes 中的 name 与 JSON 值不匹配检查大小写是否完全一致
同上缺少 @JsonTypeName 注解在子类上添加该注解
类型字段反序列化后为 null@JsonTypeInfo 的 visible 属性为 false设置 visible = true