第八章. 高级 HTTP 交互:JSON、上下文与序列化定制 摘要 : 在现代前后端分离的架构中,仅仅依靠 URL 传递参数是远远不够的。本章,我们将深入 HTTP 协议的核心,攻克三个高级交互任务。首先,我们将掌握使用 @RequestBody 接收前端通过请求体发送的复杂 JSON 数据。其次,我们会学习如何通过 @RequestHeader 和 @CookieValue 获取请求头与 Cookie 中的认证令牌、会话 ID 等关键上下文信息。最后,我们将深入探索 Jackson 注解的强大功能,从根源上解决 Java 对象与前端 JSON 之间因命名风格、日期格式、敏感信息等差异引发的“水土不服”问题。
本章学习路径
JSON 数据绑定 :我们将彻底理解 @RequestBody 的工作原理,并亲手实践如何定义 DTO 来接收前端发送的复杂嵌套 JSON 对象。请求上下文捕获 :我们将学习使用 @RequestHeader 和 @CookieValue 这两个利器,精准地从请求的“元数据”中提取出认证 Token、客户端版本号和会话标识等重要信息。Jackson 局部定制 :我们将系统学习 @JsonProperty、@JsonIgnore、@JsonFormat、@JsonInclude 等多个核心 Jackson 注解,学会在 VO/DTO 层面,对单个字段进行重命名、忽略、格式化和空值处理。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
这种方式至少存在三个严重问题:
结构表达力弱 :URL 参数是键值对的线性结构,很难清晰地表达 address 是一个内嵌对象,hobbies 是一个数组。长度与安全限制 :GET 请求的 URL 长度在不同浏览器下有几 KB 的限制。更重要的是,将敏感信息(如密码)直接暴露在 URL 中是极不安全的。数据类型模糊 :所有参数本质上都是字符串,后端需要做大量的类型转换和校验工作。为了解决这些问题,现代 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; @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 中,共同构成了请求的上下文。这些信息对于实现认证、会话管理、版本控制等功能至关重要。
常见应用场景 :
身份认证 :前端在登录后,后续请求通常会在 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 curl -H "X-Custom-Header: my-value" http://localhost:8080/header-info
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 curl --cookie "user-preference=dark-mode; other-cookie=test" http://localhost:8080/cookie-info
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 { @JsonProperty("user_id") private Long userId; 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_id,email 被重命名为 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; @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; } @JsonIgnoreProperties(ignoreUnknown = true) @Data public class UserCreateDTO { private String username; private String password; }
日期时间类型的序列化是一个常见的痛点。Java 8 引入的 LocalDateTime、LocalDate 等类型,默认会被序列化为一个复杂的对象结构或 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; @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; private String nickname; private String bio; private String avatar; }
默认序列化结果:
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; private String avatar; } @Data public class UserVO { private Long userId; private String nickname; @JsonInclude(JsonInclude.Include.NON_NULL) private String bio; @JsonInclude(JsonInclude.Include.NON_EMPTY) 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" }
响应体变得简洁清爽,bio 和 avatar 因为是 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; }
解决方案二:全局配置(推荐)
在 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 { @JsonAlias({"userName", "user_name"}) private String username; @JsonAlias({"pwd", "pass"}) private String password; }
@JsonProperty vs @JsonAlias 的区别 :
@JsonProperty("user_name") 会同时影响序列化和反序列化,输出时字段名变为 user_name@JsonAlias({"user_name"}) 只影响反序列化,输出时字段名仍为 username8.4.4. 本节小结 注解/配置 作用 使用场景 @JsonIgnoreProperties(ignoreUnknown=true)忽略未知字段 增强接口健壮性 fail-on-unknown-properties: false全局忽略未知字段 项目级配置 @JsonAlias字段别名(仅反序列化) 兼容多种命名风格
8.5. Jackson 多态反序列化:一个接口接收多种 JSON 结构 前面我们学习的都是"一个 DTO 对应一种 JSON 结构"的简单场景。但在实际业务中,经常会遇到更复杂的需求:同一个接口需要根据某个字段的值,将 JSON 反序列化为不同的 Java 类型 。这就是 Jackson 的多态反序列化能力。
8.5.1. 场景痛点:统一登录接口如何区分多种认证方式? 假设我们正在开发一个统一认证系统,需要支持多种登录方式:
密码登录 :需要 username 和 password短信登录 :需要 phone 和 code微信登录 :需要 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) { ... }
这种方式可行,但存在明显的问题:
接口分散 :前端需要根据登录方式调用不同的 URL扩展困难 :每新增一种登录方式,就要新增一个接口代码重复 :每个接口的后续处理逻辑(生成 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;@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "authType", visible = true ) @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 { 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") public class PasswordAuthRequest implements AuthRequest { 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 { @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 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: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss property-naming-strategy: SNAKE_CASE default-property-inclusion: non_null serialization: indent-output: true write-dates-as-timestamps: false deserialization: fail-on-unknown-properties: false 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; }
配置前的输出 :
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 spring: jackson: property-naming-strategy: SNAKE_CASE
1 2 3 4 5 6 7 8 9 @Data public class MixedVO { private Long userId; @JsonProperty("userName") private String username; private String emailAddress; }
输出结果 :
1 2 3 4 5 { "user_id" : 1001 , "userName" : "Tom" , "email_address" : "tom@example.com" }
最佳实践 :
将项目中最通用的、普适性的规则(如命名策略、时区、空值处理)配置在 application.yml 中 对于个别需要特殊处理的字段(如某个字段需要保留驼峰命名,或某个日期需要不同的格式),再使用局部注解去覆盖全局配置 这种"全局为主,注解为辅"的策略,是企业级项目中处理 JSON 序列化的最佳实践。
8.6.3. 本节小结 配置项 作用 推荐值 time-zone全局时区 GMT+8date-format日期格式(Date 类型) yyyy-MM-dd HH:mm:ssproperty-naming-strategy命名策略 SNAKE_CASEdefault-property-inclusion空值处理 non_nullfail-on-unknown-properties忽略未知字段 falseindent-output格式化输出 开发 true,生产 false
一个 HTTP 请求,除了包含我们关心的业务数据(在 Body 或 URL 参数中),还携带了大量的"元数据",它们位于 Header(请求头) 和 Cookie 中,共同构成了请求的上下文。这些信息对于实现认证、会话管理、版本控制等功能至关重要。
常见应用场景 :
身份认证 :前端在登录后,后续请求通常会在 Authorization 头中携带一个 Bearer TokenAPI 版本控制 :客户端通过一个自定义的头(如 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); } @GetMapping("/user/me") public String getCurrentUser ( @RequestHeader("Authorization") String authorizationHeader ) { if (authorizationHeader != null && authorizationHeader.startsWith("Bearer " )) { String token = authorizationHeader.substring(7 ); return "Token 解析成功: " + token.substring(0 , Math.min(20 , token.length())) + "..." ; } return "无效的 Authorization 头" ; } }
验证测试 :
1 2 3 4 curl -H "X-Custom-Header: my-value" -H "X-Request-ID: req-12345" http://localhost:8080/header-info
1 2 3 4 curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" http://localhost:8080/user/me
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[] 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); } @GetMapping("/set-cookie") public String setCookie (HttpServletResponse response) { Cookie preferenceCookie = new Cookie ("user-preference" , "dark-mode" ); preferenceCookie.setMaxAge(7 * 24 * 60 * 60 ); preferenceCookie.setPath("/" ); preferenceCookie.setHttpOnly(true ); response.addCookie(preferenceCookie); return "Cookie 设置成功" ; } }
验证测试 :
1 2 3 4 5 6 7 8 9 10 curl -c cookies.txt http://localhost:8080/set-cookie curl -b cookies.txt http://localhost:8080/cookie-info curl --cookie "user-preference=dark-mode; theme=blue" http://localhost:8080/cookie-info
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
需求:前端可能传 username、userName 或 user_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 () ; } @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 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 ) { String token = authorizationHeader.substring(7 ); Long userId = JwtUtils.getUserIdFrom(token); return userService.getProfile(userId); }
8.8.3. 核心避坑指南 问题一:@RequestBody 标注的 DTO 对象属性全是 null
现象 原因 解决方案 确认前端已发送 JSON,但 DTO 所有字段都是 null JSON 格式错误(多/少逗号、引号未闭合) 使用 JSON 校验工具检查格式 同上 JSON 字段名与 DTO 属性名不匹配 检查大小写,使用 @JsonProperty 或 @JsonAlias 同上 缺少无参构造器 确保 DTO 有无参构造器(使用 @Data 会自动生成)
问题二:415 Unsupported Media Type 错误
现象 原因 解决方案 POST/PUT 请求返回 415 状态码 请求头缺少 Content-Type: application/json 添加该请求头
问题三:返回的日期时间与数据库相差 8 小时
现象 原因 解决方案 数据库是 20:00,JSON 返回 12:00 Jackson 默认使用 UTC 时区 配置 spring.jackson.time-zone: GMT+8 同上 单个字段未指定时区 使用 @JsonFormat(timezone = "GMT+8")
问题四:Unrecognized field “xxx” 异常
现象 原因 解决方案 反序列化时抛出 UnrecognizedPropertyException JSON 包含 DTO 中不存在的字段 配置 fail-on-unknown-properties: false 同上 同上 在 DTO 上添加 @JsonIgnoreProperties(ignoreUnknown = true)
问题五:多态反序列化失败
现象 原因 解决方案 无法识别子类型 @JsonSubTypes 中的 name 与 JSON 值不匹配检查大小写是否完全一致 同上 缺少 @JsonTypeName 注解 在子类上添加该注解 类型字段反序列化后为 null @JsonTypeInfo 的 visible 属性为 false设置 visible = true