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

Java Jackson 多态 JSON 怎样保留类型信息

来源:17golang原创

时间:2026-10-14 13:06:38 126浏览 收藏

Java 对象声明为父类时,Jackson 需要额外的类型信息,才能在读取 JSON 时确定具体子类。面向接口或持久化消息,通常可以在父类型上配置 @JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "kind"),再通过 @JsonSubTypes 明确映射。保存的是稳定的业务类型名称,不必把 Java 包名当成协议的一部分。

Jackson 官方项目地址:https://github.com/FasterXML/jackson-databind

从普通通知对象到带类型的协议

考虑一个通知接口:同一字段 notice 有时装文字通知,有时装带链接的通知。服务内部有 TextNotice 和 LinkNotice,对外统一声明为 Notice。仅凭共有的标题,读取端无法知道应创建哪个具体对象。

加入 kind 后,变化的是接口契约,不是 Jackson 突然获得了一个新发布的功能。设计目标是让 text 与 link 代表通知语义:以后重命名内部类,外部名称仍可保持不变。代价是双方必须约定类型词表,并处理新增类型时的兼容问题。

先确定类型名称,再映射实现类

结构由六个节点组成:Envelope 是消息外壳,Notice 是父类型,kind 是类型字段,类型映射 关联 TextNotice 和 LinkNotice。这里的映射仅接受程序登记的逻辑名称,不根据输入中的任意类名选择实现。

通知消息类型字段与显式子类型映射结构图
消息边界中的 kind 是协议标识;类型映射把 text、link 关联到已登记实现。连线表达静态包含与映射关系,不是执行步骤。

不要采用“类名改了,JSON 也跟着改”的默认命名约定。显式填写 name = "text" 比依赖类的简单名称更容易保持契约稳定。类型名称也不是权限凭证:识别出链接通知,并不代表用户有权执行某种动作。

一个完整的 Jackson 2.x 往返例子

下面是 NoticeJsonDemo.java 的教学代码,使用 Jackson 2.x 的 com.fasterxml.jackson.databind 包。项目应通过自身依赖管理对齐 databind、core 和 annotations,不能随意混用版本。Jackson 3.x 的 databind 包为 tools.jackson.databind,这份示例不能直接当成 3.x 配置代码照搬。

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;

public class NoticeJsonDemo {
    // 稳定的逻辑类型名称属于 JSON 契约,不使用 Java 类全名。
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY, property = "kind")
    @JsonSubTypes({
        @JsonSubTypes.Type(value = TextNotice.class, name = "text"),
        @JsonSubTypes.Type(value = LinkNotice.class, name = "link")
    })
    public static abstract class Notice {
        public String subject;
    }

    // 教学用公开字段;隐式无参构造允许 Jackson 创建实例。
    public static class TextNotice extends Notice {
        public String text;
    }

    public static class LinkNotice extends Notice {
        public String target;
    }

    public static class Envelope {
        public String messageId;
        public Notice notice;
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        // 明确保留未知或缺失类型的错误,不静默替换为 null。
        mapper.enable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE);

        TextNotice text = new TextNotice();
        text.subject = "演示提醒";
        text.text = "任务材料已准备";
        Envelope source = new Envelope();
        source.messageId = "demo-42";
        source.notice = text;

        String json = mapper.writerFor(Envelope.class)
            .writeValueAsString(source);
        Envelope restored;
        try {
            restored = mapper.readValue(json, Envelope.class);
        } catch (InvalidTypeIdException ex) {
            // 真实接口应映射到受控错误响应,不将内部异常暴露给客户端。
            throw new IllegalArgumentException("通知类型缺失或不受支持", ex);
        }

        // 用显式断言检查对象语义,不依赖 JSON 属性的输出顺序。
        if (!(restored.notice instanceof TextNotice)) {
            throw new IllegalStateException("通知子类型未恢复");
        }
        TextNotice result = (TextNotice) restored.notice;
        if (!"任务材料已准备".equals(result.text)
                || !"demo-42".equals(restored.messageId)) {
            throw new IllegalStateException("消息数据不一致");
        }
        System.out.println(json); // 仅输出本例的虚构数据。
    }
}

按此契约,JSON 的结构应类似下面的示意值,属性顺序不作保证。这不是本轮运行输出:

{
  "messageId": "demo-42",
  "notice": {
    "kind": "text",
    "subject": "演示提醒",
    "text": "任务材料已准备"
  }
}

kind 供类型解析使用,text 是通知的业务内容,二者用途不同。上述模型没有额外声明一个可变的 kind 字段,因此不需要把类型元数据传给普通字段绑定。

保留类型不等于增加一个同名字段

容易出现的误改是给父类添加 public String kind,同时仍要求 Jackson 自动写同名类型元数据。这样会把协议分派与业务字段的赋值责任混在一起。需要在业务模型中访问类型字段时,应先明确它由谁产生、如何防止它与实际对象类型矛盾,再选择适合的包含方式。

官方注解说明中,visible = true 控制读取时是否把类型标识继续交给普通属性绑定;它不是“打开多态”的开关。As.EXISTING_PROPERTY 则依赖常规属性负责写出类型名称,不能和自动生成元数据的方式混为一谈。本例保持默认可见性,不添加重复属性。

集合也应保留元素的声明类型。把整个列表读取为裸 List.class,不能代替 List 的类型信息;可以用带泛型的消息字段,或用 TypeReference> 描述根集合。根列表写出时也应使用明确的元素类型,不只依赖运行时的泛型擦除结果。

旧 JSON 没有 kind,不能靠新注解补出历史信息

迁移中的六个节点是旧消息、适配层、新消息、类型注册、已知类型与未知类型。旧消息缺少标识时,适配层只能依据已有的可信来源约束作转换;新消息携带标识,类型注册决定哪些名称可解析。未知类型必须有明确的拒绝或隔离策略。

旧消息适配与已知未知类型处理的静态契约图
旧消息适配与类型注册属于不同责任边界。图中的静态关系不保证旧消息可被无损推断,也不表示未知类型可以默认执行。

如果旧接口明确只允许文字通知,可以在受控的旧版本入口进行专门转换。如果历史数据本来就混合多种通知,且字段不足以唯一判断,就应补齐业务来源或修复数据,不能随意给所有记录添加 "kind": "text"。

不要把关闭 FAIL_ON_INVALID_SUBTYPE 当成协议兼容方案。在本例的抽象父类型且没有默认实现的设置下,类型缺失或无法映射应作为错误处理;关闭相应失败策略可能让值变成 null,问题只是被推迟到业务层。defaultImpl 也需要具体迁移语义,并不会自动解决字段结构不匹配。

协议变化兼容处理
内部类重命名保留既有逻辑名称,更新内部映射
新增一个 kind先更新读取端能力,再按发布约定启用写出
旧数据没有 kind依据可信旧协议转换,无法判断则隔离处理
已知类型新增字段另行约定字段必选性和未知字段处理
不认识的 kind返回受控错误或保留为不可执行业务的隔离记录

读者可以怎样检查迁移结果

在项目测试中,分别准备 text 和 link 的样本,检查读取后的具体子类与业务字段;再准备缺失 kind 和未知 kind 的输入,检查它们是否进入预期错误分支。还应检查旧版读取端收到新增类型时的行为。不要仅凭“序列化没有抛异常”就宣称双向兼容。

链接通知中的 target 在本例只是字符串,没有网络访问或跳转实现。真实应用还要根据其用途进行地址策略、权限、大小限制及业务字段校验。显式类型名称和有限子类型映射缩小了类型选择范围,但不能替代全部安全控制,也不能为不受信任输入开启任意类名解析。

官方类型处理资料:https://github.com/FasterXML/jackson-docs/wiki/JacksonPolymorphicDeserialization
官方注解定义:https://github.com/FasterXML/jackson-annotations/blob/2.x/src/main/java/com/fasterxml/jackson/annotation/JsonTypeInfo.java

两个相关问题

@JsonSubTypes 单独能开启多态吗?它描述子类型关联,但还需要类型信息策略。不要把类型词表当成完整的读写配置。

多态读取成功,新增类型就对所有客户端兼容吗?不是。未知类型的处理由各读取端决定,新增名称可能让旧端拒绝消息。类型字段稳定、词表发布顺序和错误语义,都属于接口契约的一部分。

保留多态类型信息的可靠做法,是让 JSON 表达业务类型,让代码维护有限映射,再为旧数据和未知类型制定明确规则。类名可以调整,协议名称不应随意漂移。

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