登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  java教程

Java 多态 JSON 映射的类型边界

来源:17golang原创

时间:2026-10-17 13:08:09 478浏览 收藏

我第一次把“订单事件”和“用户事件”放进同一个 JSON 字段时,真正难处理的不是序列化,而是边界:Java 代码知道有哪些子类型,JSON 却可能带来未知的 kind。比较稳的做法是把两层约束同时写出来:用 sealed interface 固定 Java 允许的事件集合,用 Jackson 的逻辑类型名完成 JSON 映射,并让未知类型在反序列化入口明确失败。

这篇只讨论判别字段、反序列化和未知类型,不把 JSON 中的类名当成协议。Java 17 已支持 sealed 类型,下面的语法在 JDK 17 及更高版本都可作为迁移基线。

Java 官方资料:https://docs.oracle.com/en/java/javase/26/

Jackson 多态类型资料:https://github.com/FasterXML/jackson-docs/wiki/JacksonPolymorphicDeserialization

先把升级范围和类型边界说清楚

这次调整可以看成一次小型协议迁移:旧模型只声明一个抽象父类型,新的模型额外声明允许的子类型和 JSON 判别字段。sealed 只限制 Java 类型层次,不会自动生成 JSON 字段,也不会替 Jackson 判断外部输入是否可信。

位置迁移前的风险迁移后的约束
Java 类型任意实现类都可能进入事件字段sealed interface + permits 固定直接子类型
JSON 判别依赖类名或隐式推断,协议易泄漏实现细节使用稳定的 kind 逻辑名称
未知输入缺少或新增类型时行为不清楚在反序列化边界保留无效类型检查并记录失败

这里的“类型边界”有两条:一条是编译期的 permits 集合,另一条是运行时 JSON 的 kind 值。只有两条边界都明确,迁移后的代码才不会把协议问题推迟到业务分支。

把 sealed 子类型映射成稳定的 kind

我更倾向于把 JSON 中的类型名设计成业务协议的一部分,例如 user-created 和 order-paid,而不是使用完整 Java 类名。Jackson 的 @JsonTypeInfo 负责声明判别字段,@JsonSubTypes 把逻辑名称绑定到允许的 Java 子类型。

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

// 只允许协议明确列出的两种领域事件进入这个抽象类型。
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "kind"
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = UserCreated.class, name = "user-created"),
    @JsonSubTypes.Type(value = OrderPaid.class, name = "order-paid")
})
sealed interface DomainEvent permits UserCreated, OrderPaid {
}

// record 让事件字段保持不可变,避免反序列化后再被随意改写。
record UserCreated(String userId) implements DomainEvent {
}

record OrderPaid(String orderId, long amount) implements DomainEvent {
}
Java 类型域、JSON kind 字段与 ObjectMapper 的多态映射关系说明图
图1:静态结构说明图,查看 Java 允许的事件子类型如何对应 JSON 的 kind 逻辑名称。

这段声明解决了三个容易混淆的问题:DomainEvent 表达允许集合,kind 表达线上协议,Jackson 注解表达两者的映射。它没有声称 JSON 本身可信,也没有把任意类名交给反射机制。

把反序列化失败点放在输入边界

迁移时最容易漏掉的是“未知类型怎么办”。下面的配置把 JSON 读成抽象类型,并显式保留无效子类型检查。示例里的 JSON 是协议样例,不是本机运行输出。

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;

// 在输入边界拒绝未知 kind,避免未知事件悄悄变成错误的默认对象。
var mapper = JsonMapper.builder()
    .enable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE)
    .build();

// kind=user-created 会映射到 UserCreated,kind=order-paid 会映射到 OrderPaid。
String json = "{\"kind\":\"order-paid\",\"orderId\":\"O-17\",\"amount\":199}";
DomainEvent event = mapper.readValue(json, DomainEvent.class);

// 业务分支只处理编译器知道的事件集合,不接收任意实现类。
String description = switch (event) {
    case UserCreated created -> "用户 " + created.userId();
    case OrderPaid paid -> "订单 " + paid.orderId();
};

当 kind 是 invoice-issued 这类未知值时,Jackson 应在读取阶段报告无效类型标识;当判别字段缺失时,也应把它当作协议不完整处理。不要为了“兼容”随便设置一个默认子类,否则新增消息可能被错误地解释成旧事件。

Jackson 官方文档把多态处理定义为给反序列化器补充足够的类型信息。这里选择 Id.NAME,就是把稳定的逻辑名称放进 JSON,而不是把包名、实现类名和内部重构细节暴露给调用方。

有效 kind、缺失 kind、未知 kind 与 Jackson 失败边界的静态说明图
图2:静态边界说明图,查看有效、缺失和未知 kind 在 JSON 输入域与 Java 类型域之间的关系。

从旧模型迁移时要回归哪些情况

我会把回归样例分成四组,而不是只测一条正常 JSON。这样可以把“协议新增”和“代码新增”区分开,也能提前发现旧消息在判别字段上的兼容问题。

  1. 已有类型:保留一条 user-created 和一条 order-paid,确认字段名称、数值类型和业务分支都没有变化。
  2. 缺少 kind:验证输入被拒绝,并把异常转换为接口层的明确错误,不让它进入领域事件处理器。
  3. 未知 kind:使用一个尚未登记的逻辑名称,确认不会落到默认对象,也不会因为类名猜测而放宽范围。
  4. 新增子类型:先更新 permits、Jackson 类型映射和回归样例,再发布能产生新 kind 的生产方。

还要注意 Java 自身的兼容边界:Oracle 的语言规范说明,改变 sealed 接口的允许子类型集合可能影响已有二进制;如果旧实现不在新的 permits 列表中,升级不能只改 JSON 注解,还要安排实现方同步升级。

检查项通过标准不通过时的处理
类型集合Java 子类型和协议文档一致先补齐 permits 与映射,再开放新 kind
判别字段kind 稳定且不含 Java 包名保留旧值,新增值走版本化迁移
异常边界缺失或未知 kind 进入统一错误处理禁止默认对象兜底,补充回归样例

这套边界适合什么项目

如果事件集合相对稳定、消息由多个服务交换,而且你希望编译器提醒新增分支,sealed + 逻辑 kind 是一套清楚的组合。它的代价也很明确:新增子类型要同时改 Java 类型集合、JSON 映射和回归测试;如果你的插件系统必须允许外部团队自由扩展,就不应把 sealed 当作通用扩展机制。

我的迁移结论是:先确定协议里的判别字段,再把它绑定到受控的 Java 子类型集合,最后决定未知输入是拒绝、隔离还是进入专门的兼容事件。不要让 Jackson 的默认推断替你做这个架构决定。

相关问题

sealed interface 能自动完成 JSON 多态映射吗?

不能。sealed 约束 Java 的继承集合,JSON 的判别字段仍需要 Jackson 注解或显式模块配置。

为什么不直接把 Java 类名写进 JSON?

类名会把包结构和实现细节变成外部协议,重构和跨服务迁移的成本更高。稳定的逻辑名称更适合长期消息格式。

未知 kind 是否应该映射到一个 UnknownEvent?

只有在业务明确需要保留原始消息时才这样设计,并单独定义隔离模型。不要用默认子类掩盖协议升级或输入错误。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>