本节目标:让
libraryd用一份配置正确的ObjectMapper完成 record 与 JSON 的双向绑定,日期稳定输出为 ISO-8601,未知字段在 API 边界明确报错。
适用版本:JDK 25(Temurin 25.0.5)、Jackson 2.22.4。
承接的问题
第 5 章结束时,BorrowService.borrow 返回的是 Loan 这样的领域 record。下一步要把它放到 HTTP 响应里,客户端期望的是 JSON:
{
"id": 1,
"isbn": "9787111213826",
"memberId": "m-001",
"borrowedOn": "2026-10-18",
"dueOn": "2026-11-01",
"returnedOn": null,
"renewCount": 0,
"status": "ACTIVE",
"overdueDays": 0
}
手写字符串拼接看起来能省一个依赖,但很快会遇到四个问题:
- 标题里有引号、反斜杠或换行时,需要正确转义。
LocalDate要格式化成yyyy-MM-dd,不能直接调用toString()就当成契约。- 字段名必须和客户端约定一致,Java 字段改名时不能悄悄改变 JSON。
- 客户端多传一个字段时,服务端要明确接受还是拒绝。
Jackson 把这些边界收进一个可配置的 ObjectMapper。本节先把基础行为跑通,6.2 节再用它设计 DTO 契约。
核心机制一:ObjectMapper 与 record 绑定
Jackson 2.12 之后原生支持 record。writeValueAsString 把 record 的组件写成同名 JSON 字段,readValue 调用规范构造器还原对象:
public record BookDto(String isbn, String title, String author, int year) {}
ObjectMapper mapper = new ObjectMapper();
BookDto book = new BookDto("9787111213826", "Effective Java", "Joshua Bloch", 2018);
String json = mapper.writeValueAsString(book);
System.out.println("[write] " + json);
BookDto back = mapper.readValue(json, BookDto.class);
System.out.println("[read ] " + back);
本机输出:
[write] {"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}
[read ] BookDto[isbn=9787111213826, title=Effective Java, author=Joshua Bloch, year=2018]
字段顺序是 record 组件的声明顺序,但 JSON 对象本身无序,客户端不应依赖顺序。readValue 走的是规范构造器,所以 record 构造器里的校验会生效:如果某个字段不满足约束,反序列化会抛异常,而不是生成一个坏对象。
ObjectMapper 在配置完成后是线程安全的,应该作为进程级单例复用。每次请求 new ObjectMapper() 不仅浪费,还会让不同请求用上不同配置,产生难以复现的行为差异。
核心机制二:命名策略与 @JsonProperty
Jackson 默认使用 Java 字段名。LoanDto 的 memberId 原样输出为 memberId,符合 libraryd 的 HTTP 契约。
如果对接的是要求 snake_case 的外部 API,可以设置命名策略:
ObjectMapper snake = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.build();
本机输出:
[snake] {"id":1,"isbn":"9787111213826","member_id":"m-001","borrowed_on":"2026-10-18","due_on":"2026-11-01","returned_on":null}
memberId 变成 member_id,borrowedOn 变成 borrowed_on。这正是命名策略的作用:它统一改写所有字段,而不是逐个加注解。
libraryd 不启用 snake_case,因为 BRIEF 的 HTTP 契约写的是 camelCase。命名策略一旦开启,所有 DTO 和错误响应都会跟着变,客户端解析会立刻失败。跨团队对接时,先确认契约,再决定策略。
如果只有个别字段需要改名,用 @JsonProperty 比全局策略更精确。例如 Java 字段叫 member,对外仍叫 memberId:
public record MemberView(
String id,
@JsonProperty("memberId") String member,
String name) {
}
注解只影响序列化和反序列化的字段名,不影响 Java 代码里的访问器。字段少、命名差异明确时,这比引入全局策略更安全。
核心机制三:JavaTimeModule 与 ISO-8601 日期
LocalDate 不是 Jackson 默认认识的类型。不加模块直接序列化,会得到一条明确的错误,本机报错原文:
[no-jsr310] com.fasterxml.jackson.databind.exc.InvalidDefinitionException
[no-jsr310] Java 8 date/time type `java.time.LocalDate` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`)
错误信息已经给出了解决办法:注册 JavaTimeModule。注册之后还有一个开关要注意,WRITE_DATES_AS_TIMESTAMPS 默认是 true,本机输出:
[timestamps-on ] {"borrowedOn":[2026,10,18],"dueOn":[2026,11,1]}
[timestamps-off] {"borrowedOn":"2026-10-18","dueOn":"2026-11-01"}
默认输出是数组 [2026,10,18],不是契约要求的 "2026-10-18"。所以 libraryd 的配置必须同时做两件事:
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
JavaTimeModule 负责类型支持,disable(WRITE_DATES_AS_TIMESTAMPS) 负责把日期写成 ISO-8601 字符串。两者缺一不可。
反序列化方向也要注意:客户端传来 "2026-10-18" 时,注册了模块才能还原成 LocalDate。LocalDate 没有时区,适合借阅日期、到期日这类「日历日」;Instant 适合时间戳,两者不要混用。
核心机制四:未知字段与 FAIL_ON_UNKNOWN_PROPERTIES
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES 默认是 true。客户端多传一个字段时,Jackson 直接报错,本机报错原文:
[unknown-on] com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
[unknown-on] Unrecognized field "price" (class JacksonBasics$BookDto), not marked as ignorable
原文把类名和字段名都带出来了,排查很快。把它关掉之后,同一个 JSON 可以正常读取,本机输出:
[unknown-off] BookDto[isbn=x, title=t, author=a, year=2000]
两种策略各有适用场景:
| 场景 | 建议 | 理由 |
|---|---|---|
| HTTP 请求体 | 保持 true | 客户端拼错字段名时立刻返回 400,而不是静默丢字段 |
| 读取历史 JSONB | 关闭 | 旧数据可能带有已删除的字段,不能因为字段消失就整条读失败 |
| 内部缓存值 | 关闭 | 缓存格式演进时保持向后兼容 |
libraryd 对 API 请求保持默认的严格模式,把 UnrecognizedPropertyException 映射成 INVALID_COMMAND 或 VALIDATION_FAILED。对数据库里 audit_events.payload 这类历史数据,另建一个宽松的 mapper,避免 schema 演进把旧记录读死。6.3 节会用到这个区分。
在 libraryd 里落地:一个 JsonSupport
配置分散在各处是常见问题:测试里 new ObjectMapper()、路由里又 new 一个、某个工具类再注册一次模块。最终同一个服务里出现三种日期格式。解决办法是把配置收进一个类:
package com.plume.library.web;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
public final class JsonSupport {
private static final ObjectMapper API_MAPPER = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE)
.build();
private static final ObjectMapper STORAGE_MAPPER = API_MAPPER.copy()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
private JsonSupport() {
}
public static ObjectMapper apiMapper() {
return API_MAPPER;
}
public static ObjectMapper storageMapper() {
return STORAGE_MAPPER;
}
}
两个 mapper 的职责不同:
apiMapper面向 HTTP 请求和响应,严格校验未知字段,日期输出 ISO-8601。storageMapper面向数据库里已经落盘的 JSON,允许未知字段,方便 schema 演进。
ObjectMapper.copy() 会复制配置,两个实例互不影响。不要在运行期修改 API_MAPPER,配置变更不是线程安全的;需要不同配置就再 copy() 一个。
有了 mapper,BookDto 可以在 DTO 层提供转换方法:
package com.plume.library.web;
import com.plume.library.domain.Book;
public record BookDto(String isbn, String title, String author, int year) {
public static BookDto from(Book book) {
return new BookDto(book.isbn(), book.title(), book.author(), book.year());
}
public Book toDomain() {
return new Book(isbn, title, author, year);
}
}
序列化和反序列化都只碰 DTO,不直接碰领域对象。这样字段名、日期格式和校验规则都集中在边界上,领域模型可以独立演进。
LoanDto 需要额外处理 status 和 overdueDays 这两个派生字段,6.2 节会完整展开。这里先给出与契约一致的形状,确认日期字段能正确输出:
public record LoanDto(
long id, String isbn, String memberId,
LocalDate borrowedOn, LocalDate dueOn, LocalDate returnedOn,
int renewCount, String status, long overdueDays) {}
LoanDto loan = new LoanDto(1, "9787111213826", "m-001",
LocalDate.of(2026, 10, 18), LocalDate.of(2026, 11, 1),
null, 0, "ACTIVE", 0);
System.out.println(JsonSupport.apiMapper().writeValueAsString(loan));
本机使用同样的 mapper 配置输出:
{"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-18","dueOn":"2026-11-01","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}
returnedOn 为 null 时字段仍然出现,值为 null。这比省略字段更稳定:客户端可以始终按同一个结构解析,不需要区分「字段不存在」和「值为空」。
常见错误与边界
错误一:每个请求 new ObjectMapper()。 创建 mapper 有反射和缓存成本,更重要的是配置漂移。把 mapper 作为静态单例,测试需要特殊配置时用 copy()。
错误二:只注册 JavaTimeModule,不关 WRITE_DATES_AS_TIMESTAMPS。 输出会变成 [2026,10,18],客户端按字符串解析立刻失败。两个配置要一起做。
错误三:在运行期修改共享 mapper。 ObjectMapper 的读操作线程安全,但 configure、registerModule 不是。启动后不要再改配置;需要不同行为就复制新实例。
错误四:把未知字段一律关掉。 API 请求体如果静默忽略拼错的字段,客户端会以为参数生效了,实际服务端用的是默认值。严格模式能让错误尽早暴露。
边界:@JsonProperty 与构造器参数。 record 的组件名和 JSON 字段名默认一致。用注解改名时,确保序列化和反序列化都走同一个注解,不要一边用字段、一边用 getter,否则会出现「写出去叫 A、读回来找 B」的错位。
边界:多态与循环引用。 本节只处理扁平 record。领域对象一旦互相引用,或者需要按类型还原子类,默认序列化会遇到循环和类型信息缺失。这些边界留到 6.3 节。
小结
- Jackson 2.22.4 原生支持 record,
readValue走规范构造器,构造器校验会生效。 ObjectMapper配置完成后线程安全,应作为进程级单例复用。- 默认字段名是 Java 组件名;
PropertyNamingStrategies改写全局,@JsonProperty只改单个字段。 - 日期要同时注册
JavaTimeModule并关闭WRITE_DATES_AS_TIMESTAMPS,才能输出yyyy-MM-dd。 FAIL_ON_UNKNOWN_PROPERTIES对 API 请求保持严格,对历史 JSONB 使用宽松 mapper。libraryd用JsonSupport统一提供 API 与存储两种 mapper。
基础绑定跑通之后,下一步是把「哪些字段出现在请求、哪些出现在响应」定成契约。下一节设计 BookDto、LoanDto、PageResponse 和 ApiError,并划清它们与领域对象的边界。
阅读导航:上一节:5.3 并发控制:唯一索引、条件更新与幂等 · 下一节:6.2 DTO 设计:请求、响应与领域对象的边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。