Java常用类库-E04-Gson与Jackson的同一JSON契约
JSON 语法相同,不代表对象协议相同
商品接口收到 quantity 和 name,需要保留未提供与显式 null 的差别,拒绝不允许的结构,并把时间写成规定格式。将 Gson 换成 Jackson,或反向替换,不能只检查“同一个样例可以解析”:未知字段、默认值、字段发现、数字转换和重复键都可能改变结果。
本篇固定 Gson 2.14.0,源码 3ff35d6269894901ab8006258395aafc4b9765cd;Jackson Databind 与 JavaTime 模块固定 2.20.1,Databind 源码 4164a2b47d3795a28f7cb838a02c27b77f9adaf4。实验在 Java 8 与 Java 21 使用同一输入集。完整代码见 Extension04Test.java,命令见 RUN.md。
比较分成默认行为与显式对齐两步。默认差异帮助识别迁移风险;对齐后的断言才验证业务协议。没有针对这些样本运行 JSON 性能基准,因此本文不从 API 风格或默认功能多少推断哪个库更快。
缺失字段与显式 null 先从类型模型区分
测试 Product.quantity 使用 Integer,初始化为 7。两库读取空对象时都保留 7,读取 quantity 为 null 时都得到 null。这个结果同时依赖包装类型和对象初始化方式;不能直接推广到 primitive int、record 或使用不同构造器的模型。
如果业务把“未提供”理解为保留旧值,把 null 理解为清空,就不能仅靠反序列化后的普通字段值推导两者。字段没有初始化且本身默认为 null 时,缺失与显式 null 可能落到同一结果。可以使用树模型检查字段是否存在,或使用明确表达三态的请求模型。
测试的树模型保存 quantity:null。Gson JsonObject.has 与 Jackson JsonNode.has 都返回 true,取值节点分别是各自的 null 节点;不存在的 name 则 has=false。树节点上的 null 表示与 Java 引用为 null 不同,调用方应使用相应谓词,避免空指针检查覆盖了错误层次。
第一项可迁移模式是先定义请求状态,再选择对象表示。缺失、显式空值和具体值是接口语义,不能期待任何一个 JSON 库自动从相同 Java 字段中恢复已丢失的信息。更新接口尤其需要把这个选择写进协议和回归样本。
未知字段政策是配置差异,不是格式差异
输入只有 unknown 字段时,Gson 默认忽略它,quantity 保持 7;Jackson 默认抛 UnrecognizedPropertyException。关闭 Jackson 的 FAIL_ON_UNKNOWN_PROPERTIES 后,同一输入也得到默认 quantity。这个对照说明“能兼容额外字段”是明确的接收政策,而不是 JSON 本身规定的唯一行为。
忽略未知字段有利于某些向前兼容场景,却可能掩盖拼写错误。严格拒绝则能更早发现错误,但发布新字段时需要协调接收端升级。选择哪一种取决于接口演进规则,不能把任一默认值直接当作所有系统的最佳实践。
业务白名单与库的字段映射还可能不一致。一个字段可以存在于内部对象中,却不应由外部请求赋值。用专用请求模型限定允许字段,通常比复用带内部状态的大对象更容易审查。开启或关闭未知字段异常,并不能自动保护所有内部字段。
字段发现方式会改变暴露范围
实验的 Exposure 有一个 private secret,内容只是 synthetic,以及一个返回 public 的 getDisplay 方法。Gson 默认输出字段值,结果包含 synthetic;Jackson 默认按照本例可见属性输出 display,没有包含 private secret。
这不是在比较真正的敏感数据保护能力,而是在展示默认属性发现模型。Gson 可通过排除策略、注解或适配器收窄字段;Jackson 也可通过可见性配置扩大到字段。迁移时如果只看业务 getter,就可能漏掉 Gson 的字段访问;如果只看字段列表,又可能漏掉 Jackson 的 getter 结果。
Gson ReflectiveTypeAdapterFactory 固定源码展示字段绑定与读取写出流程。默认反射范围只是起点,实际协议还受注解、继承、排除和自定义适配器影响。稳定外部格式应使用明确的模型与字段测试,不应把当前对象恰好生成的 JSON 当成自动稳定接口。
泛型信息必须传给解析入口
样本是包含商品对象的数组。Gson 使用 TypeToken<List
如果只传 List.class,运行时没有 Product 这个元素类型信息,库只能采用自己的通用对象模型。解析成功并不意味着集合元素就是业务类;把返回值强制转成 List
这个边界属于 Java 泛型与库入口配合的问题,不能通过换一个 JSON 实现自动解决。公共封装方法如果把 Type 信息提前擦除,底层库支持再丰富的类型令牌也无济于事。封装接口应保留所需类型参数或 Type,而不是统一只接受 Class<?>。
数字精度与整数转换分别对齐
测试使用小数 0.1234567890123456789。Gson 对 Object 数字设置 ToNumberPolicy.BIG_DECIMAL,Jackson 对通用浮点数字启用 USE_BIG_DECIMAL_FOR_FLOATS,两者在 Map 中都保留为精确 BigDecimal。精度来自解析政策和目标类型,没有经过 double 再转换回来。
另一个输入把 quantity 写成 1.5。Gson 对本例 Integer 字段拒绝,Jackson 默认会接受并转换为 1;关闭 ACCEPT_FLOAT_AS_INT 后,Jackson 也拒绝。这说明“严格 JSON”与“禁止有损类型转换”是不同维度。1.5 是合法 JSON 数字,是否可赋给整数商品数量由类型绑定政策决定。
对金额、库存或标识符,还需要规定范围、比例和允许表示形式。BigDecimal 保存小数,不会自动拒绝负金额,也不会决定货币的最大小数位;Integer 解析成功也不能保证库存非负。解析负责把表示转成类型,领域校验仍要检查类型中的合法值域。
测试没有穷举科学计数法、超范围整数、NaN 扩展与不同数字字符串强制转换。它验证指定小数精度和小数到整数的拒绝政策,不把这两个样本写成所有数字兼容性已经证明。
Gson 2.14 的时间支持不等于 ISO 字符串
Gson 2.14 增加 java.time 类型适配,但 Instant 默认输出 seconds 与 nanos 对象。本例时刻 2026-10-02T00:00:00Z 对应 {"seconds":1790899200,"nanos":0}。Jackson 注册 JavaTimeModule 并关闭 WRITE_DATES_AS_TIMESTAMPS 后,输出 ISO 字符串。
最初直接比较这两个结果的断言失败。回读 JavaTimeTypeAdapters 固定源码,Instant 的适配器明确采用 seconds/nanos 整数字段。它能够还原时间值,却没有选择与本接口相同的线格式。
最终示例给 Gson 注册一个显式 Instant TypeAdapter,写出 Instant.toString,读取时用 Instant.parse,并使用 nullSafe 包装。两库在相同样本上得到相同 ISO 表示,Gson 自身也能往返。这才是“同一 JSON 契约”的证据,不能把新增类型支持当成默认协议已经对齐。
时间值还可能包含时区、精度和偏移信息。Instant 表达时间线上的瞬间,不能替代需要保留原始时区的其他类型。本组只验证一个整秒 UTC 时刻,不将结果外推到全部日期类型、纳秒舍入或区域时区规则。
重复键需要单独处理
输入 quantity 先为 1、后为 2,两库默认对象绑定都得到 2。Gson 设置 Strictness.STRICT 后也不会因此自动拒绝这个对象,因为语法严格度与字段唯一性不是同一个开关。
Jackson 示例在 JsonFactory 上启用 STRICT_DUPLICATE_DETECTION,再要求同一输入失败。Gson 示例使用实际 JsonReader 流式读取根对象名称,用 Set 记录已见名称,发现重复即抛 IOException;字段值仍交给 Gson 的 JsonParser 读取,没有自行实现 JSON 字符串分割。
这个小型 Gson 检查器只保证根对象的字段唯一性。嵌套对象交给现有解析入口,没有递归维护每层名称集合,因此不能声称对所有嵌套重复字段提供同样保护。若协议允许任意嵌套结构并要求各层唯一,应实现并测试逐层检查,或采用具备相应严格检测能力的解析配置。
第二项可迁移模式是按维度列出接收规则:语法、重复字段、未知字段、类型转换和资源规模分别验收。一个名叫 strict 的配置只覆盖其文档定义的维度,不能作为其他规则已经成立的证据。
流式入口仍需要资源预算
树模型方便检查字段存在性,但会物化节点。示例在解析前对 UTF-8 原始字节使用 BoundedIo,预算四字节时拒绝一个更长商品对象,不让完整内容继续进入树解析。预算限制实际读取字节,不信任外部声明长度。
字节上限不是完整解析资源证明。深层嵌套、字段数量与对象创建仍可能有自己的成本;本例没有测量最坏堆分配。Gson 根对象检查器虽使用 JsonReader,字段值仍被构造成 JsonElement,因此“使用流式 API”并不等于全程不构造树。
真正逐项处理大型数组时,可以在每项完成后执行业务操作,但还要考虑后续错误是否允许前面的操作已经提交。数据解析、资源释放与业务事务必须分层设计,不能把 parser.close 视为数据库回滚机制。
循环对象不是默认身份图
实验构造一个对象,其 self 字段直接指向自身。Gson 的输出是空对象,Jackson 默认拒绝直接自引用。Gson 固定源码在字段值恰好等于源对象时跳过该字段,解释了这个特定结果。
跳过直接自引用不等于一般循环检测。两个或更多对象形成的环不是同一个分支,本组没有把它作为可安全序列化图的证据。如果业务需要共享引用与对象身份,应定义标识符引用格式或使用明确配置的图表达方式,不能假定普通 JSON 对象结构自动保留 Java 引用关系。
选择条件与练习
升级还需要核对历史行为变化。Gson 2.14 的日期支持来自新的类型适配器,旧版本中依赖反射访问日期内部字段的代码不能据此假定输出不变。固定版本、保留输入集和比较具体字段,比仅让两个库解析同一份最终字符串更容易发现迁移回归。兼容验收必须同时检查读取与写出,不能只证明其中一个方向。
两库都能满足多种业务 JSON 契约,关键在于所需类型、现有注解模型、树与流接口以及严格政策的组合成本。JDK 8 本身没有直接等价的通用对象到 JSON 绑定器,手工字符串拼接不构成本文这些协议的等价替代。
四组测试在双 JDK 上通过。默认差异、显式对齐、根对象流检查与输入预算均留有断言;没有运行性能比较。可独立复跑工程见 下载实验工程。
手算题:quantity 缺失时保留 7,显式 null 时得到 null,这两个结果能否用于所有没有默认值的模型?不能。改动练习是在嵌套对象中重复 quantity,观察根对象检查器未覆盖的范围,再补逐层检测;不要把只检查根名称的实现改名为“完全严格解析”。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| missing/null | 请求状态先于类型绑定 | 树存在性或显式三态模型 |
| private/getter | 默认发现规则不是外部协议 | 专用模型与字段暴露断言 |
| strict | 接收规则按维度定义 | 重复、未知、数字分别配置 |
| java.time | 类型支持与线格式分开 | 默认差异保留,适配后再比较 |
系列起点:可复现基线。

