主题
界面
主题
接口的契约有两半:一半是"路径、方法、状态码"(S3-REST 讲过了),另一半是"数据长什么样"——本节讲的就是这一半。
POST /api/orders ← 前半:路径与方法
{"productId":"P-1","quantity":2,"address":"上海"} ← 后半:数据格式(本节)而且这一半里的"默认行为",往往和你以为的不一样:
| 你可能以为 | 实际默认 |
|---|---|
| 日期会序列化成字符串 | 默认是数字时间戳(或者直接报错)✗ |
| 对方多传一个字段没关系 | 默认会报错 ✗ |
| 对象互相引用也能序列化 | 直接抛异常 ✗ |
本节的价值就是:把这些"默认行为"实测出来,让你不再靠猜。
完成本节后,你应该能够:
@JsonProperty / @JsonIgnore / @JsonFormat / @JsonInclude 改默认行为;Content-Type 与请求体吗?(《HTTP 请求响应与 Servlet》 的 5.1 节)@RequestBody 与 @ResponseBody 的作用吗?(《Spring MVC 请求映射与参数绑定》 的 5.3 节)先看一个真实的前后端联调场景:
后端:接口改好了,加了个字段 source(来源渠道)
前端:调用时…… 400 Bad Request ✗前端传的 JSON 完全正确,只是多了一个字段:
{"productId":"P-1","quantity":2,"extraField":"来自新版本客户端"}后端的报错(本节实测):
UnrecognizedPropertyException: Unrecognized field "extraField"
(class cn.kb.learn.web.json.JsonLesson$Product), not marked as ignorable
(5 known properties: "status", "name", "priceInCents", "createdAt", "note")这就是 Jackson 的默认行为:FAIL_ON_UNKNOWN_PROPERTIES = true ✗
它有两面性:
| 立场 | 理由 |
|---|---|
| 严格(默认) | 能及早发现"契约不一致" ✓(如对方拼错了字段名) |
| 宽松 | 兼容性更好(对方加了字段我们也不会崩)✓ |
Spring Boot 的选择是"宽松"(fail-on-unknown-properties: false,本项目也配了这个)✓
但你要知道默认是"严格"——因为在"自己手工 new ObjectMapper"的场景(如工具类、MQ 消费)里,用的就是默认值 ✗
{
"string": "文本",
"number": 19900,
"boolean": true,
"null": null,
"array": [1, 2, 3],
"object": {"nested": "object"}
}四个与 Java 类型对应的注意点:
| JSON | Java | 注意 |
|---|---|---|
| number | int / long / double / BigDecimal | ⚠️ JSON 不区分整数与浮点——1 和 1.0 在语法上都合法;金额一定要用 BigDecimal 或"分"(《表设计与约束》 讲过浮点误差)✗ |
| string | String / 枚举 / 日期 | 日期是约定的字符串格式(见 5.3) |
| null | null | "字段不存在"与"字段是 null"是两件事 ⚠️ |
| object | 类 / record / Map | 字段名即属性名 |
"字段是 null"与"字段不存在"的区别(这个坑很常见):
{"note": null} ← 字段存在,值是 null
{} ← 字段不存在在 Java 里两者都映射成 note == null ✗(除了 Optional 包装或 JsonNullable)⚠️
什么时候需要区分:
| 场景 | 例子 |
|---|---|
| PATCH 局部更新 | {"title": null} 是"把标题设为 null",还是"不改标题"? |
| 审计对比 | "字段被显式清空了"与"没提交这个字段"含义不同 |
实践建议:别设计"需要区分 null 与不存在"的接口(《Spring MVC 请求映射与参数绑定》 的 6.3 节讲 PUT/PATCH 时也提过)✓
一个值得坚持的约定:响应最外层永远是对象,不是数组 ✓
[{"id":1},{"id":2}] // ✗ 顶层是数组
{"data":[{"id":1},{"id":2}]} // ✓ 顶层是对象为什么:老浏览器有"JSON 劫持"的历史问题,且顶层是对象便于以后加字段(如 total、pageInfo)✓
| 概念 | 是什么 | 例子 |
|---|---|---|
ObjectMapper | 核心对象(做序列化/反序列化) | mapper.writeValueAsString(obj) |
Module | 扩展包(让 Mapper 认识新类型) | JavaTimeModule 让它能处理 Instant / LocalDate ★ |
Feature | 开关(改默认行为) | FAIL_ON_UNKNOWN_PROPERTIES、WRITE_DATES_AS_TIMESTAMPS |
Spring Boot 帮你做的事(《Spring Boot 自动配置与项目结构》):它自动配了一个 ObjectMapper,并注册了常用 Module ✓
所以你会遇到一个奇怪的现象:
在 Spring Boot 的 Controller 里:日期序列化成 "2026-09-21T10:00:00Z" ✓(已经配好了)
自己 new ObjectMapper() 时:报错或输出成时间戳 ✗原因就是:Spring Boot 的 JacksonAutoConfiguration 替你注册了 JavaTimeModule 并调了开关 ✓
本节实测的就是"手工 new"时的默认行为——因为那些时刻同样存在(工具类、MQ 消费者、独立程序)✓
坑一:不注册 JavaTimeModule 直接报错
ObjectMapper plain = new ObjectMapper();
plain.writeValueAsString(new Product(..., Instant.parse("2026-09-21T10:00:00Z"), ...));实测输出:
✗ 报错:InvalidDefinitionException
Java 8 date/time type `java.time.Instant` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
(through reference chain: ...Product["createdAt"])java.time 类型是 Java 8 引入的,Jackson 的"核心"并不认识它们——需要额外的模块 ✓
坑二:注册了之后,默认序列化成"数字时间戳"
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());实测输出:
{"name":"机械键盘","priceInCents":19900,"createdAt":1789984800.000000000,"status":"ON_SALE","note":null}注意 createdAt 是 1789984800.000000000 ✗ ——人读不了,且跨语言麻烦 ✓
原因:WRITE_DATES_AS_TIMESTAMPS 默认是 true ✓
坑三:关掉时间戳,得到 ISO 字符串
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);实测输出:
{"name":"机械键盘","priceInCents":19900,"createdAt":"2026-09-21T10:00:00Z","status":"ON_SALE","note":null}✓ 这才是接口该用的形式 ✓
三种写法的对比:
| 配置 | 输出 |
|---|---|
| 不注册 Module | 报错 ✗ |
| 注册 + 默认 | 数字时间戳 1789984800.000000000 ⚠️ |
| 注册 + 关闭时间戳 | "2026-09-21T10:00:00Z" ✓ |
⚠️ 还有一个更隐蔽的坑:时区
LocalDateTime now = LocalDateTime.now(); // ⚠️ 没有时区信息| 类型 | 带时区吗 | 序列化后 |
|---|---|---|
Instant | ✅ 是 UTC 时间点 | "2026-09-21T10:00:00Z" ✓ |
OffsetDateTime | ✅ 带偏移 | "2026-09-21T18:00:00+08:00" ✓ |
LocalDateTime | ❌ 不带 | "2026-09-21T18:00:00" ⚠️ 别人不知道这是哪个时区的 18 点 |
规则:
跨系统传输的时间,一律用
Instant或OffsetDateTime✓
LocalDateTime只用于"与人相关的本地时间"(如"营业时间 9:00-18:00")——它不是时间点 ✓这条规则在 《Java 8 核心特性总览》 讲 Java 8 日期 API 时提过,在接口场景里更关键 ✓
实测输出:
===== 5. 反序列化:多出来的字段会怎样?=====
✗ 报错:UnrecognizedPropertyException
Unrecognized field "extraField" (class ...Product), not marked as ignorable
(5 known properties: "status", "name", "priceInCents", "createdAt", "note"])关掉之后:
关掉 FAIL_ON_UNKNOWN_PROPERTIES 之后:
Product[name=机械键盘, priceInCents=19900, createdAt=null, status=ON_SALE, note=null]
✅ 多出来的字段被忽略两种配置的取舍:
| 配置 | 什么时候用 |
|---|---|
严格(默认 true) | ① 对外提供的接口(能及早发现对方传错字段)② 消费 MQ 消息(消息格式变了要立刻知道)✓ |
宽松(false) | 前后端频繁迭代的内部接口(对方加字段不该让我们 400)✓ |
本项目(和 Spring Boot 默认)用的是宽松:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false⚠️ 但要意识到宽松的代价:
前端把 productId 拼错成 productID
→ 严格模式:400,立刻发现 ✓
→ 宽松模式:字段被忽略 → productId 为 null → 走到业务校验 → 422("商品编号不能为空")✓其实宽松也没那么糟——因为 《参数校验与全局异常处理》 的校验会兜住 ✓ 只是报错信息不如"未知字段"精确 ⚠️
一个实践建议:关键接口(对外、跨团队)用严格模式 + 完整契约测试;内部快速迭代的接口用宽松 ✓
枚举:
枚举默认序列化成名字:{"status":"SOLD_OUT"}| 序列化方式 | 写法 | 什么时候用 |
|---|---|---|
| 名字(默认) | "SOLD_OUT" | ✅ 推荐(可读、稳定) |
| 序号 | 1 | ❌ 绝不(加一个枚举值就全错位)✗ |
| 自定义值 | @JsonValue | 需要与外部约定一致时(如用 "1"/"2")⚠️ |
⚠️ 枚举的兼容性陷阱:对方发来一个我们不认识的枚举值 → 默认报错 ✗
{"status":"PENDING"} // 新版本新增的状态,老版本的服务收到会 400 ✗三种处理:
| 方案 | 做法 |
|---|---|
加 @JsonEnumDefaultValue | 给一个枚举值标上它,未知值映射到它 ✓ |
用 String 接收,业务层自己转 | 可控但啰嗦 |
| 接受报错 | 如果确实要严格 ✓ |
null 字段:
null 字段默认会保留:{"name":"降噪耳机","priceInCents":15900,"createdAt":null,"status":"ON_SALE","note":null}@JsonInclude(Include.NON_NULL) 可以让 null 字段不出现在 JSON 里 ✓
| 配置 | 效果 | 评价 |
|---|---|---|
| 保留 null(默认) | {"note":null} | ✅ 字段稳定——前端不用判 undefined ✓ |
| 忽略 null | {}(没有 note 键) | ⚠️ 前端要同时处理"没有键"与"值是 null"两种情况 |
Spring Boot 里常见的选择是"忽略 null"(省流量、响应干净)——本项目也配了:
spring:
jackson:
default-property-inclusion: non_null⚠️ 但要和前端说清楚:"字段可能不出现" ✗ 否则前端 obj.note.length 会 TypeError ✓
四个常用注解(实测效果):
public record AnnotatedProduct(
@JsonProperty("product_name") String name, // 改字段名
@JsonIgnore String internalCode, // 序列化时忽略
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate onSaleDate, // 自定义日期格式
@JsonInclude(JsonInclude.Include.NON_NULL) String note // null 时不出现在 JSON 里
) { }输出:
{"product_name":"机械键盘","onSaleDate":"2026-09-21"}注意三处:
| 观察 | 对应注解 |
|---|---|
product_name(下划线) | @JsonProperty 改了字段名 ✓ |
internalCode 完全没出现 | @JsonIgnore 生效 ✓ |
note 没出现(它是 null) | @JsonInclude(NON_NULL) 生效 ✓ |
⚠️ @JsonIgnore 的安全价值:它是"防止字段泄漏"的最后一道防线 ✓
public class User {
private String username;
private String passwordHash;
@JsonIgnore // ✓ 即使有人误加了"返回整个 User",密码也不会出去
public String getPasswordHash() { ... }
}但别只靠它——正确的做法是"用 DTO 而不是直接返回实体"(S3-REST 的 8.5 节)✓
@JsonIgnore 是"兜底",不是"设计" ✓
实测:两个对象互相引用
✗ 报错:JsonMappingException
Document nesting depth (1001) exceeds the maximum allowed (1000,
from `StreamWriteConstraints.getMaxNestingDepth()`)
(through reference chain: ...Node["next"]->...Node["next"]->...Node["next"]->...)注意报错内容:"嵌套深度超过 1000"——而不是 StackOverflowError ✓
这是 Jackson 的保护机制:它在递归到 1000 层时主动中断,给出可读的错误信息 ✓(比栈溢出好排查得多)
三种修复:
| 方案 | 做法 | 评价 |
|---|---|---|
| 用 DTO 断开循环 ★ | 响应对象里不放"反向引用" | 最推荐(干净、可读)✓ |
@JsonIgnore | 在反向引用上标注 | 快速修复 ✓ |
@JsonBackReference / @JsonManagedReference | 标注"父子关系" | 专门处理双向关联 ⚠️(容易漏配) |
为什么会循环引用(现实中的例子):
class Order { List<OrderItem> items; }
class OrderItem { Order order; } // ⚠️ 反向引用根因:领域模型里的双向关联——而"接口响应"通常不需要双向 ✓
这又一次印证"DTO 与实体分离"的价值:响应 DTO 里只放前端需要的方向 ✓
实测的三种:
① 缺字段(用默认值):Product[name=鼠标, priceInCents=0, createdAt=null, status=null, note=null]
② 类型不对:InvalidFormatException —— Cannot deserialize value of type `int` from String "九块九": not a valid `int` value
③ 未知字段:UnrecognizedPropertyException(见 5.4)逐一看:
| 原因 | 表现 | 应对 |
|---|---|---|
| 缺字段 | 用默认值(对象是 null、int 是 0)⚠️ 不报错 | 靠校验兜住(《参数校验与全局异常处理》 的 @NotBlank)✓ |
| 类型不对 | InvalidFormatException | 返回 400(《参数校验与全局异常处理》 应把它映射成 400 而不是 500)⚠️ |
| 未知字段 | UnrecognizedPropertyException | 见 5.4 |
| JSON 语法错 | JsonParseException(如少一个 }) | 返回 400 |
⚠️ 第 1 行最危险:"缺字段不报错" ✗
{} // 什么都不传结果是:创建了一个 productId = null、quantity = 0 的请求对象 ✓ ——如果没写校验,这个请求会一路走到数据库 ✗
这就是 《参数校验与全局异常处理》 强调"参数校验必写"的原因 ✓
⚠️ 而"类型不对"默认会变成 500(如果没配处理器):
检查一下你的项目有没有处理它(本项目的 GlobalExceptionHandler 处理了 MethodArgumentTypeMismatchException,但**HttpMessageNotReadableException(JSON 解析失败的总父类)还没处理**)⚠️
建议补上:
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadable(HttpMessageNotReadableException exception) {
// 请求体不是合法 JSON,或字段类型对不上 —— 这是"客户端的问题",应该是 400
return build(HttpStatus.BAD_REQUEST, "MALFORMED_REQUEST_BODY", "请求体格式不正确", List.of());
}⚠️ 注意:不要把 exception.getMessage() 直接返回——它可能包含类名与字段路径(信息量过大)✗ 记日志即可 ✓
性能上要注意的三点:
| 点 | 说明 |
|---|---|
| 大 JSON 的内存 | 序列化会构造完整的字符串(或字节数组)——导出几百万条数据时不能一次性序列化 ✗(要流式输出,如 StreamingResponseBody) |
| 重复序列化 | 同一个对象在一次请求里转两次 JSON(如在 Filter 里打日志时)→ 浪费 ✓(打日志用 toString() 或摘要即可) |
| 深度限制是正确的保护 | 5.6 的"嵌套 1000 层"限制可以调大,但别关 ✗ |
安全上要注意的三点:
| 点 | 说明 |
|---|---|
| 不要直接序列化实体 ★ | 实体会跟着数据库表变化——今天加个 passwordHash,明天就可能出现在响应里 ✗✗ |
脱敏字段用 @JsonIgnore 兜底 | 作为第二道防线 ✓ |
| 反序列化的"多态"要警惕 | Jackson 的 enableDefaultTyping 已被废弃且危险(反序列化漏洞)✗ ——绝不要为了"方便"打开它 |
关于多态的那一条值得展开:
"从 JSON 还原出任意子类"意味着"攻击者可以让你实例化任意类" ✗
历史上有大量反序列化漏洞(Fastjson、Jackson、XStream)都源于此 ✓
原则:反序列化的目标类型必须由"服务端代码"决定,不能由"请求内容"决定 ✓
XML 曾经是接口的主角(SOAP、WebService),现在主要被 JSON 取代。
对比:
| 维度 | JSON | XML |
|---|---|---|
| 可读性 | ✅ 简洁 | ⚠️ 标签冗长 |
| 体积 | ✅ 小 | ❌ 大(标签重复) |
| 解析速度 | ✅ 快 | ❌ 慢 |
| 类型系统 | ⚠️ 弱(数字/字符串靠约定) | ✅ 强(XML Schema 能定义类型与约束) |
| 表达能力 | ⚠️ 基础(属性、数组) | ✅ 强(属性、命名空间、注释、CDATA) |
| 前端友好度 | ✅ 原生支持 | ❌ 要额外解析 |
| 自描述性 | ⚠️ 一般 | ✅ 强(标签名就是语义) |
XML 今天还在用的场景:
| 场景 | 为什么还用 XML |
|---|---|
| 配置文件(Maven、Spring 老项目、Android) | 有 Schema 校验、支持注释 ✓ |
| 文档格式(Word、SVG、OFD) | 结构化 + 富文本表达 |
| 企业系统集成(银行、政务、老 ERP) | 存量系统与行业规范(很多是强制的)✓ |
| RSS / Atom / Sitemap | 历史约定 |
| SOAP WebService | 有些行业(如部分支付、老电商)仍要求 |
Spring Boot 里支持 XML(对同一个接口同时提供 JSON 与 XML):
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>// 同一个接口,按请求的 Accept 头决定返回哪种格式
@GetMapping(value = "/{orderNo}", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public OrderResponse find(@PathVariable String orderNo) { ... }这就是 《Spring MVC 请求映射与参数绑定》 讲的"内容协商" ✓
⚠️ 但要克制:除非对接方明确要求,否则别同时支持两种格式 ✗ ——它会让测试矩阵翻倍,且 XML 的序列化细节(如空元素、属性)容易出坑 ⚠️
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(order); // 对象 → JSON
Order parsed = mapper.readValue(json, Order.class); // JSON → 对象见第 7 节。
@GetMapping("/{id}")
public User find(@PathVariable Long id) {
return userRepository.findById(id); // ✗ 直接返回实体
}三个风险:
| 风险 | 例子 |
|---|---|
| 字段泄漏 | 表里加了 password_hash、id_card,自动出现在响应里 ✗✗ |
| 循环引用 | 实体之间有双向关联 → 序列化崩溃 ✗ |
| 接口随数据库变化 | 加一列就"悄悄"改了接口契约 ⚠️ |
修复:返回 DTO ✓
@GetMapping("/{id}")
public UserResponse find(@PathVariable Long id) {
return toResponse(userService.findById(id)); // ✓ 只暴露该暴露的
}文件:labs/web-basics/src/main/java/cn/kb/learn/web/json/JsonLesson.java
public class JsonLesson {
public enum Status { ON_SALE, SOLD_OUT }
public record Product(String name, int priceInCents, Instant createdAt, Status status, String note) { }
public static void main(String[] args) throws Exception {
// 1. 默认的 ObjectMapper 能不能处理 Instant?→ 报错
// 2. 注册 JavaTimeModule → 数字时间戳
// 3. 关掉 WRITE_DATES_AS_TIMESTAMPS → ISO 字符串
// 4. 枚举与 null 的默认行为
// 5. 注解能改什么
// 6. 反序列化:多字段 / 少字段 / 类型不对
// 7. 循环引用
// 8. 数组序列化
}
}运行命令:
cd labs
./mvnw -pl web-basics compile dependency:build-classpath "-Dmdep.outputFile=target/cp.txt"
java "-Dstdout.encoding=UTF-8" -cp "web-basics/target/classes;$(Get-Content web-basics/target/cp.txt)" cn.kb.learn.web.json.JsonLesson预期输出(真实运行结果,节选):
===== 1. 默认的 ObjectMapper 能不能处理 Instant?=====
✗ 报错:InvalidDefinitionException
Java 8 date/time type `java.time.Instant` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
← 默认的 ObjectMapper 不认识 Java 8 的时间类型(要注册 JavaTimeModule)
===== 2. 注册 JavaTimeModule 之后 =====
默认(WRITE_DATES_AS_TIMESTAMPS 开着):
{"name":"机械键盘","priceInCents":19900,"createdAt":1789984800.000000000,"status":"ON_SALE","note":null}
← Instant 变成了数字时间戳(秒 + 纳秒)
关掉 WRITE_DATES_AS_TIMESTAMPS 之后:
{"name":"机械键盘","priceInCents":19900,"createdAt":"2026-09-21T10:00:00Z","status":"ON_SALE","note":null}
← 变成 ISO-8601 字符串,人能读,跨语言也更通用
===== 3. 枚举与 null 的默认行为 =====
枚举默认序列化成名字:{"status":"SOLD_OUT"}
null 字段默认会保留:{"name":"降噪耳机","priceInCents":15900,"createdAt":null,"status":"ON_SALE","note":null}
===== 4. 注解能改什么 =====
{"product_name":"机械键盘","onSaleDate":"2026-09-21"}
===== 5. 反序列化:多出来的字段会怎样?=====
✗ 报错:UnrecognizedPropertyException
Unrecognized field "extraField" (class ...Product), not marked as ignorable
(5 known properties: "status", "name", "priceInCents", "createdAt", "note"])
← FAIL_ON_UNKNOWN_PROPERTIES 默认是 true:多一个字段就失败 ✗
关掉 FAIL_ON_UNKNOWN_PROPERTIES 之后:
Product[name=机械键盘, priceInCents=19900, createdAt=null, status=ON_SALE, note=null]
✅ 多出来的字段被忽略
===== 6. 反序列化:少字段、类型不对 =====
缺字段(用默认值):Product[name=鼠标, priceInCents=0, createdAt=null, status=null, note=null]
类型不对:InvalidFormatException —— Cannot deserialize value of type `int` from String "九块九": not a valid `int` value
===== 7. 循环引用:两个对象互相引用 =====
✗ 报错:JsonMappingException
Document nesting depth (1001) exceeds the maximum allowed (1000,
from `StreamWriteConstraints.getMaxNestingDepth()`)
← 序列化会无限递归(被 Jackson 的深度限制拦住)
===== 8. 数组/集合的序列化 =====
[{"id":1,"name":"键盘"},{"id":2,"name":"鼠标"}]
← 顶层是数组时,某些老客户端(或安全策略)会拒绝解析(JSON 劫持防护)
← 所以很多团队约定「响应最外层永远是对象」四处最值得记的:
| 输出 | 结论 |
|---|---|
| 第 1 节:直接报错 | java.time 类型需要额外的 Module |
| 第 2 节:时间戳 vs ISO | 默认是数字,disable(WRITE_DATES_AS_TIMESTAMPS) 才是字符串 |
| 第 5 节:未知字段报错 | FAIL_ON_UNKNOWN_PROPERTIES 默认 true |
| 第 7 节:嵌套深度 1001 | 循环引用被"深度限制"拦住(而不是栈溢出) |
writeValueAsString 里打断点:观察它如何遍历对象的 getter(注意:getter 决定序列化内容,这也是"实体直接返回会泄密"的原因之一)⚠️;getInternalSecret() 方法(不是字段):观察它是否出现在 JSON 里 ★ ——会 ✗(序列化看的是 getter)readValue 里打断点,传一个类型错的 JSON:观察异常在哪一层抛出(决定该在哪捕获);AnnotatedProduct 的 @JsonIgnore 去掉:观察 internalCode 出现在输出里(理解这道防线的价值);WRITE_DATES_AS_TIMESTAMPS 的分支注释掉:对比两次输出,确认差异来自这一个开关。见 5.3 节。Spring Boot 里没问题(它配好了),但"手工 new ObjectMapper"时会踩 ✗
LocalDateTime 传输跨时区的时间 见 5.3 节。用 Instant 或 OffsetDateTime ✓
后端:配了宽松模式,多字段没事 ✓
前端:以为后端会报错,不敢加字段 ✗(不必要的谨慎)或反过来:
后端:严格模式 ✗
前端:加了个字段 → 全站 400 ✗✗解法:把"未知字段怎么处理"写进接口文档的约定里(《接口文档与前后端联调》)✓
见 6.3 与 5.8 节。这是最常见的"字段泄漏"来源 ✗
@JsonIgnore 当唯一防线 @JsonIgnore
private String passwordHash; // ⚠️ 只靠它不够为什么不够:
| 问题 | 说明 |
|---|---|
| 换个 DTO 就忘了 | 有人写个新接口直接返回实体,没注意这个注解 |
| 注解在字段上,但序列化看 getter | 如果有个 getPasswordHash(),字段上的注解可能不生效 ⚠️ |
正确的做法:DTO 天然只包含该暴露的字段 ✓ ——"不暴露"应当是"结构上做不到",而不是"记得加了注解" ✓
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<?> handle(Exception e) {
return ResponseEntity.badRequest().body(e.getMessage()); // ✗ 可能含类名、字段路径、内部结构
}修复:返回通用提示,详细内容进日志(《参数校验与全局异常处理》 的 5.8 节)✓
mapper.enableDefaultTyping(); // ✗✗ 已废弃且危险风险:反序列化漏洞(攻击者构造 JSON 让你实例化任意类)✗✗
历史教训:Fastjson 的多起 RCE 漏洞都与"自动类型识别"有关 ✓
原则:反序列化的目标类型必须由服务端代码写死 ✓
null 全部忽略,却没告知前端 见 5.5 节。前端会 TypeError: Cannot read property of undefined ✗
解法:要么保留 null(字段稳定),要么在文档里明确"字段可能不出现" ✓
目标:把日期、未知字段、循环引用三件事亲手验一遍。
步骤:
WRITE_DATES_AS_TIMESTAMPS 的启用/关闭各跑一次,确认差异只来自它;Product 加一个 public String getSummary() { return name + ":" + priceInCents; } 方法——观察输出里多了一个 summary 字段 ★(这才是"实体不能直接返回"的根本原因);JsonFactory 把 StreamWriteConstraints 的深度限制调到 10——观察循环引用在更浅的层级就报错(理解它是一道可调的保护);验收方式:能说出第 3 步为什么多了一个字段、第 6 步谁拦住了它。
扩展任务:给 《参数校验与全局异常处理》 的 GlobalExceptionHandler 补两个处理器:
HttpMessageNotReadableException → 400 + MALFORMED_REQUEST_BODY(不要把异常消息返回给前端);HttpMediaTypeNotSupportedException → 415;curl 制造三种错误(JSON 语法错、类型不对、Content-Type 错),验证状态码与响应体: curl -d '{bad json' -H "Content-Type: application/json" ... → 400curl -d '{"quantity":"九块九"}' ... → 400curl -d '{"x":1}' -H "Content-Type: text/plain" ... → 415基础题
1 和 1.0 是不同的类型。(对 / 错)java.time 类型需要注册 ______;默认它会把时间序列化成 ______,关掉 ______ 开关后才变成 ISO 字符串。代码阅读题
public class Order {
private Long id;
private BigDecimal amount;
private LocalDateTime createdAt;
private List<OrderItem> items;
public String getStatusText() {
return items.isEmpty() ? "空订单" : "正常";
}
}Order 对象,JSON 里会有哪些字段?其中哪一个"字段"是意外的?createdAt 是 LocalDateTime,前端拿到的字符串长什么样?这样做有什么隐患?编程题
Instant,ISO-8601)——写出全局配置;long 的"分",或 BigDecimal + @JsonFormat)——说明你的选择理由;data 字段);double"是绝对不能接受的?(提示:《变量与数据类型》 的浮点精度)综合任务
{"quantity": 2},后端收到的却是 quantity = 0 ✗;@JsonProperty、宽松/严格模式、类型不匹配、包装类 vs 基本类型);提示 1(方向):第 1 题的方向是"JSON 语法上不区分"。
提示 2(关键点):第 4 题的"意外字段"来自一个 getXxx 方法。
提示 3(接近答案):第 7 题里"包装类 vs 基本类型"——Integer 与 int 在"缺字段"时的表现不同(一个是 null,一个是 0)。
number 一种数字类型 ✓——1 与 1.0 都是合法的 number ✓但"映射到 Java 什么类型"是由目标类型决定的:
| Java 字段类型 | 收到 1 | 收到 1.0 |
|---|---|---|
int | 1 ✓ | ⚠️ 可能报错或截断 |
long | 1L ✓ | ⚠️ 同上 |
double | 1.0 ✓ | 1.0 ✓ |
BigDecimal | 1 ✓ | 1.0 ✓(保留精度与标度) |
⚠️ 这带来一个真实的风险:金额用 double 会引入精度误差 ✗
0.1 + 0.2 == 0.30000000000000004 // ✗ 浮点误差所以金额一律用"分(long)"或 BigDecimal ✓ ——本项目所有涉及金额的示例都用 amountInCents(分) ✓
这条规则与 《变量与数据类型》 的浮点精度、《表设计与约束》 的 DECIMAL 类型是同一个话题 ✓
JavaTimeModule(来自 jackson-datatype-jsr310);数字时间戳;WRITE_DATES_AS_TIMESTAMPS。Spring Boot 帮你配好了这两件事 ✓ 但它只对 Spring 容器里的那个 ObjectMapper 生效 ✗ ——手工 new 的那个仍然是默认行为 ⚠️
一个实践建议:在项目里提供一个统一的 ObjectMapper Bean(而不是各处 new),让配置只有一份 ✓
| 理由 | 说明 |
|---|---|
| 字段泄漏 ★ | 表里加一列(password_hash),响应里就多一个字段 ✗✗(可能直接是安全事故) |
| 接口随数据库漂移 | 数据库是"实现细节",不该成为接口契约 ⚠️ |
| 循环引用 | 实体之间有双向关联 → 序列化崩(5.6)✗ |
还有一个更隐蔽的:getter 也会被序列化 ✗
public String getFullName() { return firstName + lastName; } // 也会出现在 JSON 里这解释了一件事:"我只想知道有哪些字段"——你不能只看字段列表,还要看所有 getXxx() 方法 ⚠️
所以引言里那句"结构上做不到"很重要:
用 DTO 时,"该暴露什么"是"你显式写了什么";用实体时,"暴露了什么"是"所有 public getter"——后者的边界不可控 ✗
第 4 题:JSON 里会有 id、amount、createdAt、items,以及 statusText ✗
statusText 是意外字段——它不是字段,只是个 getter,但 Jackson 默认会序列化所有 public getter ✓
这个"意外"可能带来两类问题:
| 问题 | 说明 |
|---|---|
| 契约里没约定的字段 | 前端看到会困惑,文档里也没有 ⚠️ |
| 可能是昂贵的计算 | getStatusText() 里如果查了数据库(懒加载),序列化时会触发 N+1 ✗✗ |
第二条是真实的坑:JPA 的懒加载关联在序列化时会被触发——一次列表查询变成 N+1 次数据库访问 ✗(《MySQL 性能优化基础》 的 5.6 节)
修复方式:
| 方式 | 做法 |
|---|---|
| 用 DTO ★ | 显式列出字段,getter 不外泄 ✓ |
@JsonIgnore | 标注在 getter 上 |
| 改 getter 名(不让它符合 getter 规范) | ⚠️ 治标不治本 |
第 5 题:前端拿到 "2026-09-21T18:00:00"——没有时区信息。
隐患:
| 隐患 | 说明 |
|---|---|
| 不知道是哪个时区的 18 点 ✗ | 服务器在 UTC+8,用户在 UTC-5,双方理解不同 ⚠️ |
| 跨时区计算会错 | "订单创建于 18:00,现在几点" → 无法算出正确的时间差 ✗ |
| 夏令时 | 有些地区有夏令时,本地时间还会"跳变"⚠️ |
正确做法:
private Instant createdAt; // ✓ UTC 时间点(无歧义)
// 或
private OffsetDateTime createdAt; // ✓ 带偏移量输出:
"createdAt": "2026-09-21T10:00:00Z" // Instant:明确的 UTC 时间点 ✓
"createdAt": "2026-09-21T18:00:00+08:00" // OffsetDateTime:带偏移 ✓前端拿到之后自己转本地时间显示 ✓ ——"存什么"与"显示什么"要分开 ✓
一句话规则:
"时间点"(什么时候发生的)用
Instant;"本地时间"(营业时间 9:00-18:00)用LocalTime/LocalDateTime✓两者不能混用 ✗ ——把
LocalDateTime当时间点传输,是跨时区 bug 的常见来源 ✓
《接口数据格式规范》
@Configuration
public class JacksonConfig {
/**
* 规范 1:时间统一用 Instant + ISO-8601
* 理由:Instant 是无歧义的时间点;ISO-8601 所有语言都能解析
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
builder.modules(new JavaTimeModule());
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 规范 3:null 字段保留(字段稳定,前端不用同时处理 undefined 与 null)
builder.serializationInclusion(JsonInclude.Include.ALWAYS);
};
}
}/**
* 规范 2:金额用 long(分)
* 理由:避免浮点误差;与数据库的 BIGINT 或 DECIMAL(18,2) 直接对应;
* 序列化成 JSON number 时不会丢失精度
*/
public record OrderResponse(
String orderNo,
long amountInCents, // ✓ 分
@JsonFormat(shape = JsonFormat.Shape.STRING) BigDecimal discountAmount, // ✓ 需要小数时用 BigDecimal + 字符串
Instant createdAt, // ✓ 规范 1
String status // ✓ 规范 4:枚举用字符串传递
) { }/**
* 规范 4:枚举遇到未知值时降级,而不是报错
* 理由:新增枚举值时,老版本服务不该崩;未知值统一映射到 UNKNOWN,业务层按"未知"处理
*/
public enum OrderStatus {
CREATED, PAID, CANCELLED,
@JsonEnumDefaultValue
UNKNOWN; // ★ 未知值落到这里
// 需要在全局 ObjectMapper 上开启:
// mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
}/**
* 规范 5:响应最外层永远是对象(便于加 meta 字段,也避开 JSON 劫持)
*/
public record ApiResponse<T>(T data, String traceId) { }
public record PageData<T>(List<T> items, int page, int size, long total) { }逐条理由汇总:
| 规范 | 理由 |
|---|---|
时间用 Instant + ISO | 无时区歧义,跨语言可解析 ✓ |
金额用 long(分) | 避免浮点误差;JSON number 不丢精度 ✓ |
| null 保留 | 字段稳定,前端不需要处理"键不存在"的情况 ✓ |
枚举降级到 UNKNOWN | 新增枚举值时老服务不崩 ✓ |
| 外层是对象 | 便于扩展、避开 JSON 劫持 ✓ |
"金额用 double 为什么绝对不能接受":
double a = 0.1;
double b = 0.2;
System.out.println(a + b); // 0.30000000000000004 ✗
double price = 199.99;
System.out.println(price * 3); // 599.9700000000001 ✗后果链:
金额用 double → 累加/乘除产生微小误差 → 存进数据库 → 对账差几分钱 → 财务找上门 ✗更具体的两个坑:
| 坑 | 说明 |
|---|---|
| 累加误差 | 1000 笔订单各差 0.0000001 分,累加后可能差出几分钱 ✗ |
| 比较失败 | 0.1 + 0.2 == 0.3 是 false ✗ → "金额相等"的判断会出错 ✗✗ |
正确的两种做法:
| 做法 | 适用 |
|---|---|
long 存"分" ★ | 本项目所有示例的选择——运算全是整数,无误差 ✓ |
BigDecimal | 需要小数位(如利率、汇率)时——注意必须用字符串构造:new BigDecimal("0.1") ✓(new BigDecimal(0.1) 同样有误差 ✗) |
这三次出现是同一件事:《变量与数据类型》(浮点精度)、《表设计与约束》(DECIMAL vs FLOAT)、本节(接口传输)——"金额不用浮点"是一条贯穿全栈的规则 ✓
五种可能的原因:
| # | 原因 | 怎么验证 |
|---|---|---|
| 1 | 字段名大小写/拼写不一致 | 后端 @JsonProperty 或 yml 里的命名策略 |
| 2 | 前端传的是字符串 "2" | quantity 声明成 int,Jackson 能自动转;但如果是 Integer 且传了 "abc" 就报错 |
| 3 | 前端传的字段嵌套错层 | {"data":{"quantity":2}} 而被解析的对象顶层没有 data |
| 4 | 包装类 vs 基本类型 ★ | int quantity 收到"缺字段"时是 0(不是报错)✗ |
| 5 | 有另一个 quantity 字段在父类/DTO 里被覆盖 | 继承链上的字段遮蔽 |
| 6 | 前端用了 application/x-www-form-urlencoded | 后端用 @RequestBody 解析 JSON → 参数全为默认值 |
第 4 条最容易被忽略,值得展开:
public record CreateOrderRequest(int quantity) { } // 基本类型:缺字段 → 0 ✗ 静默
public record CreateOrderRequest(Integer quantity) { } // 包装类:缺字段 → null ✓ 能被校验拦住所以有一条实用规则:
"必填字段用包装类型 +
@NotNull,让"没传"能被校验发现;基本类型只在"确定有默认值"时用" ✓
int quantity的"缺字段变 0"是一个"静默降级"——它会绕过@Min(1)吗?不会——
0 < 1,校验会拦住它 ✓(所以本项目用int也是安全的)但如果业务上"0"是合法值(如"赠品数量可以为 0"),那就分不清"没传"与"传了 0"了 ✗
排查步骤:
① 加一行日志,打印收到的"原始请求体"(在 Filter 里,用 ContentCachingRequestWrapper)
→ 确认"前端到底传了什么"(而不是猜)
② 在 @RequestBody 的参数上打断点,看反序列化后的对象
→ 确认"后端收到了什么"
③ 对比两者 → 差异点就是原因
④ 如果 ① 里根本没有 quantity 字段 → 是前端没传或字段名错
如果 ① 里有但 ② 里是 0 → 是绑定/类型问题预防方案:
| 手段 | 说明 |
|---|---|
必填字段用包装类型 + @NotNull ★ | 让"没传"变成 422 而不是 0 ✓ |
| 在 Filter 里记录请求体摘要 | 出问题时有据可查(《Filter、Listener 与会话管理》 的编程题)✓ |
| 接口文档写清字段名与类型 | Swagger 的 example 特别有用(《接口文档与前后端联调》)✓ |
| 契约测试 | 前端声明它发的格式、后端声明它期望的格式——CI 里就能拦住 ✓ |
| 统一的 DTO 命名规范 | 全站驼峰,不许混用下划线 ✓ |
"为什么这种 bug 在测试环境可能完全正常":
| 原因 | 说明 |
|---|---|
| 测试用例是"按后端文档写的" | 测试用的字段名是正确的,所以测不出来 ✗ |
| 真实的"前端草稿"没进测试用例 | 前端实际发的格式与文档不一致,但没人验证"前端真的发了什么" ✗✗ |
| 联调时才第一次"真发" | 前面的测试都是"模拟的客户端"✗ |
一句话:
"测试了后端假设的请求"与"测试了前端真实发出的请求"是两件事 ✗
前者靠后端自己的测试用例,后者必须靠"真实前端"或"来自前端的契约" ✓
契约测试能提供什么(T2-007):
| 保障 | 说明 |
|---|---|
| 前端的期望可执行 | 前端写"我发 {productId, quantity},期望 201"→ 后端必须满足它 ✓ |
| 字段名不一致立刻暴露 | 契约里的字段名是一份两边共同维护的约定 ✓ |
| CI 里拦住 | 后端改了字段名 → 契约测试红 → 发不出去 ✓✓ |
没有契约测试时的替代:
| 替代 | 强度 |
|---|---|
| 联调前先"对接一次请求"(前端发一次真实请求,后端记录) | ✅ 实用(成本低、覆盖关键问题) |
| 把"真实前端的请求样本"放进后端测试 | ✅ 好(防止后端单方面改契约) |
| 接口文档 + 强制评审 | ⚠️ 靠人 |
第一条最实用:
"联调的第一件事,不是写完所有代码再对接,而是先用一个真实请求把'能通'这件事验证掉。" ✓
这能在 5 分钟内暴露 90% 的字段名与格式问题 ✓
java.time 类型需要 JavaTimeModule,且默认会序列化成时间戳;Instant / OffsetDateTime,不用 LocalDateTime;FAIL_ON_UNKNOWN_PROPERTIES 默认 true,并能按场景选择策略;@JsonProperty / @JsonIgnore / @JsonFormat / @JsonInclude;double;《JSP 与 JSTL(技术演进理解)》:在前后端分离之前,服务端是怎么渲染页面的。
暂无