《Java 编程实战》6.3 序列化边界:多态、循环引用与 JSONB

审计事件要按类型还原,领域对象互相引用又会让 Jackson 无限递归。本节真实复现多态类型丢失、嵌套深度 1002 的报错,并演示如何把事件安全写入 PostgreSQL 的 jsonb 列、再用宽松 mapper 读回,最后给出 libraryd 的 AuditEvent 与仓储写法。

本节目标:让 libraryd 的审计事件在多态还原时不丢类型,在对象图有环时不炸栈,并能安全地存进 PostgreSQL 的 jsonb 列再读回来。
适用版本:JDK 25(Temurin 25.0.5)、Jackson 2.22.4、PostgreSQL 17、PostgreSQL JDBC 42.7.14。

承接的问题

6.1 和 6.2 节处理的是扁平 record:字段都是标量或日期,JSON 与 Java 一一对应。真实服务里还有两类结构超出这个范围:

  • 多态事件。审计表要记录「借出」「归还」「新增图书」等事件,它们共享一个 type 字段,但负载不同。读回来时必须根据 type 还原成具体的 record,否则只能拿到一个 Map。
  • 对象图。会员有借阅列表,借阅又引用会员。直接序列化会沿着引用无限展开,直到 Jackson 的嵌套深度保护触发。

libraryd 还有第三个边界:这些事件最终要写进 PostgreSQL 的 jsonb 列。存字符串和存 jsonb 不是一回事:前者不会校验 JSON,后者会解析并规范化键顺序。

这一节把三个边界分开处理,每个都给出可复现的报错或输出。

核心机制一:显式多态,不用默认类型

Jackson 支持多种多态写法。libraryd 选的是显式声明:在基类型上标注 @JsonTypeInfo,再用 @JsonSubTypes 列出允许的子类型:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = Borrowed.class, name = "BORROWED"),
        @JsonSubTypes.Type(value = Returned.class, name = "RETURNED")
})
public sealed interface LibraryEvent permits Borrowed, Returned {
}

public record Borrowed(String isbn, String memberId, LocalDate on)
        implements LibraryEvent {
}

public record Returned(String isbn, LocalDate on, long fineCents)
        implements LibraryEvent {
}

use = Id.NAME 表示 JSON 里写的是逻辑名(BORROWED),不是 Java 类名。property = "type" 指定类型字段的名字。序列化与反序列化的本机输出:

[poly-write] [{"type":"BORROWED","isbn":"9787111213826","memberId":"m-001","on":"2026-10-18"},{"type":"RETURNED","isbn":"9787111213826","on":"2026-10-20","fineCents":0}]
[poly-read ] [Borrowed[isbn=9787111213826, memberId=m-001, on=2026-10-18], Returned[isbn=9787111213826, on=2026-10-20, fineCents=0]]

写出去带 type,读回来能还原成 Borrowed 和 Returned,不需要 instanceof 分支。

如果 JSON 缺少 type,反序列化直接失败,本机报错原文:

[poly-missing] InvalidTypeIdException
[poly-missing] Could not resolve subtype of [simple type, class SerializationBoundaries$LibraryEvent]: missing type id property 'type'

这条错误比「拿到一个字段不全的对象」好得多。审计数据读不回来时,应该立刻暴露,而不是静默降级成空事件。

这里有一个容易踩的坑:根级集合的序列化不会自动带上类型信息。 下面这种写法:

List<LibraryEvent> events = List.of(
        new Borrowed("9787111213826", "m-001", LocalDate.of(2026, 10, 18)));
String json = mapper.writeValueAsString(events);

本机输出是:

[no-type] [{"isbn":"9787111213826","memberId":"m-001","on":"2026-10-18"},{"isbn":"9787111213826","on":"2026-10-20","fineCents":0}]

type 消失了。原因是 Jackson 对根级集合按运行时元素类型选择序列化器,没有使用声明的 List<LibraryEvent> 元素类型。把集合包在一个 record 里,或者用 writerFor 指定静态类型,都能恢复:

String json = mapper
        .writerFor(new TypeReference<List<LibraryEvent>>() {})
        .writeValueAsString(events);

本机输出:

[with-type] [{"type":"BORROWED","isbn":"9787111213826","memberId":"m-001","on":"2026-10-18"},{"type":"RETURNED","isbn":"9787111213826","on":"2026-10-20","fineCents":0}]

这个差异不影响单条事件的 JSONB 存储(根对象会带 type),但会影响「批量事件」接口。写批量响应时,用 writerFor 或外层信封,不要假设类型字段一定存在。

核心机制二:循环引用与嵌套深度保护

对象图有环时,Jackson 不会自动去重。MemberNode 持有 List<LoanNode>,LoanNode 又持有 MemberNode,序列化 LoanNode 会沿着环一直展开,本机报错原文:

[cycle] com.fasterxml.jackson.databind.JsonMappingException
[cycle] Document nesting depth (1002) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`) (...)
[cycle-root] com.fasterxml.jackson.core.exc.StreamConstraintsException: Document nesting depth (1002) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`)

中间那一长串 through reference chain 会列出 member、loans、member 的往返路径,本机输出为了可读性做了截断。根因是 StreamConstraintsException:Jackson 2.22 默认把嵌套深度限制在 1000,超过就抛错,而不是让栈溢出。

两种修法,优先级不同:

修法效果适用场景
在反向引用上加 @JsonIgnore断开环临时救急、内部工具
定义没有反向引用的 DTO从契约上断开HTTP 响应、审计存储

@JsonIgnore 的写法很短:

public static class SafeLoanNode {
    public long id;
    public String isbn;
    @JsonIgnore public MemberNode member;
}

本机输出:

[cycle-fixed] {"id":1,"isbn":"9787111213826"}

但它会在这个类的所有序列化场景里隐藏 member。如果某个接口恰好需要会员信息,就会得到一份缺字段的 JSON,而且编译器不会提醒。更稳妥的做法是定义 LoanDto,只放需要暴露的字段;6.2 节已经这么做了,LoanDto 里没有指回 Member 的引用,环自然不存在。

领域对象之间可以保留双向引用,序列化边界只输出单向 DTO。这样既保留了领域模型的表达力,又不会把对象图的形状泄漏给客户端。

核心机制三:把事件写进 jsonb

PostgreSQL 的 jsonb 不是文本列。插入时它会解析 JSON、去掉重复键、规范化键顺序;查询时可以用 ->>、@> 等操作符。JDBC 里正确的绑定方式是 PGobject:

PGobject payload = new PGobject();
payload.setType("jsonb");
payload.setValue(mapper.writeValueAsString(event));

try (PreparedStatement ps = connection.prepareStatement(
        "insert into audit_events (payload) values (?)")) {
    ps.setObject(1, payload);
    ps.executeUpdate();
}

setType("jsonb") 告诉驱动这个参数的类型;setValue 放 JSON 字符串。不要用 setString 直接插进 jsonb 列,驱动会按 varchar 发送,PostgreSQL 可能报类型不匹配,或者依赖隐式转换。

建表和插入两条事件后,本机查询结果:

 id |   type   |     isbn      |  kind  
----+----------+---------------+--------
  1 | BORROWED | 9787111213826 | object
  2 | RETURNED | 9787111213826 | object
(2 rows)

payload->>'type' 把 JSON 字段取成文本,jsonb_typeof(payload) 确认根节点是对象。再用包含操作符筛出归还事件,本机输出:

 returned_events 
-----------------
               1
(1 row)

对应的 SQL 是:

select count(*) from audit_events
where payload @> '{"type":"RETURNED"}';

@> 是 jsonb 的包含判断,可以配合 GIN 索引加速。审计表数据量大时,给 payload 建 create index ... using gin (payload jsonb_path_ops),按类型或字段筛选就不必全表扫描。

读回来时取 payload::text,再交给 Jackson,本机输出:

[jsonb-read] Borrowed[isbn=9787111213826, memberId=m-001, on=2026-10-18]

注意 PostgreSQL 会把键顺序规范化。写入时 Jackson 输出的顺序是 type、isbn、memberId、on,本机实际输出可能是:

 {"on": "2026-10-18", "isbn": "9787111213826", "type": "BORROWED", "memberId": "m-001"}

JSON 对象本身无序,客户端不应依赖键顺序。如果业务需要稳定排序,应该在应用层格式化,而不是依赖数据库的存储顺序。

核心机制四:不要打开默认类型

Jackson 有一个 activateDefaultTyping,可以在 JSON 里自动写入 Java 类名,读回时按类名实例化。它看起来能省掉 @JsonSubTypes,但不适合 libraryd:

  • JSON 里会出现 com.plume.library.audit.Borrowed 这样的全限定类名,存储格式与包名耦合,重构即失效。
  • 反序列化时按 JSON 里的类名加载类,输入不可信时会扩大攻击面。
  • 白名单配置复杂,漏配一个包就可能允许不该实例化的类型。

显式 @JsonTypeInfo 把「允许哪些子类型」写在代码里,编译器能看到,代码评审也能看到。新增事件类型时必须同时改 @JsonSubTypes,这是一次有意识的契约变更,而不是运行期意外。

如果确实需要兼容历史数据,可以给 @JsonTypeInfo 配 defaultImpl,把未知类型的负载降级成一个「原始 JSON」record。libraryd 不这么做:审计事件读不出来应该报警,而不是悄悄变成空对象。

在 libraryd 里落地

审计事件的类型定义放在 domain 包,序列化注解也放在这里,因为它描述的是事件的 wire format:

package com.plume.library.domain;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

import java.time.LocalDate;

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = AuditEvent.BookBorrowed.class, name = "BORROWED"),
        @JsonSubTypes.Type(value = AuditEvent.BookReturned.class, name = "RETURNED"),
        @JsonSubTypes.Type(value = AuditEvent.BookCreated.class, name = "BOOK_CREATED")
})
public sealed interface AuditEvent {

    record BookBorrowed(String isbn, String memberId, LocalDate on) implements AuditEvent {
    }

    record BookReturned(String isbn, LocalDate on, long fineCents) implements AuditEvent {
    }

    record BookCreated(String isbn, String title, String author, int year)
            implements AuditEvent {
    }
}

仓储负责把事件写成 jsonb。序列化失败属于内部错误,不应该让调用方看到 Jackson 的异常类型:

package com.plume.library.persistence;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.plume.library.domain.AuditEvent;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import com.plume.library.web.JsonSupport;
import org.postgresql.util.PGobject;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public final class JdbcAuditRepository {

    public void append(Connection connection, AuditEvent event) throws SQLException {
        PGobject payload = new PGobject();
        payload.setType("jsonb");
        try {
            payload.setValue(JsonSupport.storageMapper().writeValueAsString(event));
        } catch (JsonProcessingException e) {
            throw new LibraryException(ErrorCode.INTERNAL, "审计事件序列化失败", e);
        }
        try (PreparedStatement ps = connection.prepareStatement(
                "insert into audit_events (payload) values (?)")) {
            ps.setObject(1, payload);
            ps.executeUpdate();
        }
    }
}

persistence 依赖 web 里的 JsonSupport 并不理想,模块方向是 persistence 和 web 都依赖 domain。更干净的做法是把 mapper 工厂下沉到 domain 或一个共享的 libraryd-json 小模块。这里用 JsonSupport 是为了让读者看到同一份配置,实际拆分时按依赖方向调整。

读取端用 storageMapper,允许历史负载里出现已删除的字段:

public List<AuditEvent> recent(Connection connection, int limit) throws SQLException {
    List<AuditEvent> events = new ArrayList<>();
    try (PreparedStatement ps = connection.prepareStatement(
            "select payload::text from audit_events order by id desc limit ?")) {
        ps.setInt(1, limit);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                try {
                    events.add(JsonSupport.storageMapper()
                            .readValue(rs.getString(1), AuditEvent.class));
                } catch (JsonProcessingException e) {
                    throw new LibraryException(
                            ErrorCode.INTERNAL, "审计事件反序列化失败", e);
                }
            }
        }
    }
    return events;
}

如果这个方法将来暴露成 HTTP 接口返回 List<AuditEvent>,不要直接 writeValueAsString(events);用 writerFor 或包一层 AuditPage,否则根级集合会丢掉 type,客户端拿到一堆无法区分的事件。

常见错误与边界

错误一:新增子类型但忘了改 @JsonSubTypes。 序列化可能仍然写出对象,反序列化却会抛 InvalidTypeIdException: Could not resolve type id 'RENEWED'。类型注册和子类型定义要放在同一个文件,评审时一起看。

错误二:根级 List<Base> 直接序列化。 输出的元素没有 type,读回来必然失败。用 writerFor(new TypeReference<List<Base>>() {}),或者定义带 items 字段的信封。

错误三:用 @JsonIgnore 处理所有循环。 它会在所有序列化场景隐藏字段,包括你真正需要它的那个接口。优先用 DTO 断开环,把 @JsonIgnore 留给内部调试工具。

错误四:用字符串拼接生成 JSON 再写 jsonb。 引号、反斜杠、日期格式任何一处出错,数据库都会拒绝或存进错误结构。用 mapper 生成,用 PGobject 绑定。

错误五:用严格 mapper 读历史 jsonb。 事件格式演进后删掉一个字段,旧记录里仍有该字段,FAIL_ON_UNKNOWN_PROPERTIES=true 会让整条读取失败。存储方向用宽松 mapper,API 请求方向保持严格。

边界:JSONB 不是文档数据库。 jsonb 适合存储结构不固定、需要按字段查询的负载,不适合替代关系表。借阅、会员、图书的核心字段仍然放在普通列里,审计负载才进 jsonb。否则外键、唯一索引和约束都帮不上忙。

边界:大负载。 jsonb 值会被完整读入内存,几 MB 的事件不适合放进审计表。需要存大对象时,放到对象存储,jsonb 里只放引用和摘要。

小结

  • 多态用显式 @JsonTypeInfo 与 @JsonSubTypes,JSON 里写逻辑名,不写 Java 类名。
  • 根级 List<Base> 序列化会丢类型信息,用 writerFor 或信封恢复。
  • 对象图有环时 Jackson 会触发嵌套深度保护,报 StreamConstraintsException;优先用 DTO 断环。
  • jsonb 用 PGobject 绑定,查询用 ->> 和 @>,读取用宽松 mapper 兼容历史字段。
  • 不要打开默认类型;类型白名单应该是代码里显式、可评审的列表。
  • 审计事件的核心字段仍放在关系列,jsonb 只承载结构不固定的负载。

序列化边界收口后,libraryd 已经有了领域模型、数据库访问、事务、并发控制和 JSON 契约。下一章把这些能力接到 Javalin 上:路由、路径参数、请求体与响应。

阅读导航:上一节:6.2 DTO 设计:请求、响应与领域对象的边界 · 下一节:7.1 Javalin 起步:路由、路径参数与 JSON 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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