《Java 编程实战》6.1 Jackson 基础:绑定、命名与日期

HTTP 层不能靠字符串拼接生成 JSON。本节用 Jackson 2.22.4 真实演示 record 绑定、命名策略、JavaTimeModule 与 ISO-8601 日期,以及 FAIL_ON_UNKNOWN_PROPERTIES 的报错原文,最后把配置收进 libraryd 的单例 JsonSupport。

本节目标:让 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 设计:请求、响应与领域对象的边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发