本节目标:让
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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。