更新请求里,缺失和 null 是两个动作

商品更新接口接受名称和价格。请求没有 name 字段时,服务应保留旧名称;显式提交 name:null 时,可以约定清空名称。若直接绑定到一个带 String name 字段的普通对象,两种请求都会得到 name=null。映射成功,但用于选择更新动作的信息已经消失。

问题不在于 JSON 是否语法正确,而在于目标类型能否表达输入状态。文本中的字段存在性、数值表示、类型结构与对象引用,都需要在映射边界作出明确选择。Jackson 可以完成转换,却不能根据字段名称推断业务更新协议。

本篇固定 Jackson databind 与 jsr310 模块 2.20.1,使用 Java 8 API,在 Zulu 8.0.472 和 Corretto 21.0.11 运行。完整测试包含树、绑定、流式读取及失败路径,复跑说明记录依赖与命令。缺失状态的业务定义可参照 第 01 篇。

树模型保留存在性,普通字段可能合并状态

对空对象读取 name,JsonNode.get 返回 Java null;path 返回 MissingNode。对含 name:null 的对象,has 为 true,get 返回 NullNode,isNull 为 true,而 hasNonNull 为 false。测试分别断言这些结果,没有用 asText 后的字符串代替存在性判断。

固定 ObjectNode 实现中,get 直接访问子节点映射,path 在找不到子节点时返回 MissingNode。显式 JSON null 由一个节点表示,因此能够与映射中没有此键区分。把这两种节点提前转换成同一个默认文本,会再次丢失区别。

绑定示例使用公开的 String name 字段,没有自定义 setter、构造器或注解,两种输入都得到 Java null。这一结果限定于本章模型;带字段默认值、自定义反序列化或空值策略的模型可以有不同表现,不能把这个实验概括为所有对象绑定都无法表达存在性。

对更新接口,可以先用树检查字段是否存在,再把存在的字段按目标类型读取;也可以定义包含 presence 的专用请求模型。关键是业务动作作出决定之前,不要把“未提供”和“提供空值”折叠。创建接口与更新接口也未必适合复用同一份 DTO。

这是第一条可迁移模式:映射目标必须容纳业务需要区分的状态。数据库 nullable 列、可选配置与补丁请求都有相同问题;容器能保存 null,不代表它同时表达“未提交”这一维度。

未知字段和重复字段需要不同规则

默认 ObjectMapper 将包含 extra 的 JSON 绑定到本章 Product 时,抛 UnrecognizedPropertyException。使用配置了关闭 FAIL_ON_UNKNOWN_PROPERTIES 的 ObjectReader,则忽略 extra 并得到对象。两条路径都可以作为协议选择,但忽略未知字段意味着拼写错误可能不再被发现。

DeserializationFeature 固定定义把未知字段失败默认设为开启。框架或应用可能重新配置 mapper,因此不能只根据 Jackson 默认值断言生产服务一定拒绝未知字段,应检查实际注入的实例和 reader。

重复字段是另一种问题。输入连续两次 name,分别为 first 和 last,本章默认树读取保留 last。固定上游 ObjectNodeTest 对重复键也检查了默认覆盖行为,以及树专用的 FAIL_ON_READING_DUP_TREE_KEY 开关。本地实验则在 JsonFactory 开启 STRICT_DUPLICATE_DETECTION,让解析器拒绝重复字段。

树专用开关与解析器重复检测的作用层次不同。前者描述树读取策略,后者在 token 解析层检查重复名称。若同一入口既有树读取又有对象绑定,不能只为树开启严格检查后就认为所有路径等价;应把约束放在覆盖目标路径的层次,并为每条入口建立断言。

签名验证、缓存键提取与业务绑定若采用不同重复字段策略,可能从同一份文本读出不同值。更容易审查的接口是明确拒绝重复字段,或者明确约定唯一的解析规则,再让所有参与方使用一致结果。未知字段决定接受哪些名字,重复字段决定同名出现多次怎样处理,二者不能混为一项“严格模式”。

泛型信息决定元素会成为哪种对象

将商品数组读取为 List.class,Jackson 只能知道外层是列表,元素在本实验中成为 Map。改用 TypeReference<List> 后,元素才按 Product 绑定。强制类型转换不会补回丢失的类型信息;把原始列表转成 List,只会把失败推迟到元素使用位置。

TypeReference 保存的类型结构供数据绑定使用,不是让 Java 运行时恢复所有被擦除的泛型。若类型本身来自动态组合,可以通过 Jackson 的类型工厂构造目标结构。无论采用哪种方式,验收都应检查元素类型和字段值,而不只检查列表长度。

接口返回分页包装时,这个区别还会继续嵌套。外层 Page 类正确,内部 items 若仍按原始 List 读取,问题只是移到下一层。选择类型描述时应覆盖完整对象结构,尤其是 Map 值、集合元素以及可选嵌套对象。

目标模型也会影响内存与耦合。只读取两个字段却绑定整个大型对象图,可能增加不必要的创建工作;但改用树也会为整棵结构建立节点。这里没有做分配量或吞吐基准,因此只从需要保留的信息与处理方式比较,不宣称某一种 API 在所有输入上更快。

金额与日期依赖明确类型和模块

Product.price 声明为 BigDecimal。读取文本中的 0.10,实验得到与 new BigDecimal(“0.10”) 相等的值,包括该测试关心的小数位表示。若读取为 Object,0.1 默认成为 Double;开启 USE_BIG_DECIMAL_FOR_FLOATS 的 reader 则产生 BigDecimal。此开关面向未明确指定数值目标类型的绑定,不能代替业务字段的类型设计。

金额计算还需要币种、舍入规则、精度和范围。BigDecimal 避免了把十进制文本先变为二进制浮点的这一步,但并不会自动规定这些业务条件。0.10 与 0.1 的数值比较和 equals 比较也有区别;测试选择哪一种断言,应由协议是否保留小数位语义决定。

日期示例注册 JavaTimeModule,关闭 WRITE_DATES_AS_TIMESTAMPS。文本 availableOn:“2026-10-02” 绑定为 LocalDate,写回后保持日期字符串。这个样本只表示日历日期,没有时区和时刻,不应拿它推导跨时区事件时间的正确性。

事件发生时刻可能需要 Instant,带本地偏移的交换格式可能需要 OffsetDateTime。选型应从协议含义出发,再固定模块和格式配置。只看到序列化结果像日期,就认定时间语义完整,容易漏掉偏移、精度或本地时区解释。

循环引用也不是 JSON 自带的对象身份。实验让一个对象的 self 指回自身,默认写出路径抛 IOException 的子类。若业务需要交换图结构,可以使用明确的标识与引用协议;不能通过忽略异常或盲目扩大深度限制解决对象身份表达问题。本章只验证直接自引用,没有宣称覆盖全部循环对象图。

流式读取减少保留范围,但不会自动校验协议

流式实验读取两个商品价格,以 getDecimalValue 累加得到 4.00。parser 在 try-with-resources 中关闭。它不先建立 Product 列表,也不保留完整 JsonNode 树,因此处理过程可以只保存累计状态。

示例通过字段名找到 price,并断言紧随其后的是浮点数 token。这个简化循环并不构成完整商品协议校验:其他层级出现同名字段,也可能被处理。生产格式若要求“顶层数组中每个对象恰好一个 price”,就需要显式跟踪数组、对象层次和已出现字段,不能把流式接口当成自动 schema 验证器。

流式计算仍然可能面对深层嵌套、超长字符串、大量 token 或无限来源。实验为工厂设置最大嵌套深度三,读取四层数组时抛 StreamConstraintsException;另一个入口在解析前限制已收到的字节数组不超过一百二十八字节。所有对抗样本都很小,没有使用无界 JSON 负载。

字节数组检查也有清楚的边界:数组已经分配完成,它只限制进入解析器的输入,不限制网络接收之前的内存。真实网络入口应在读取过程中累计字节,在超过预算时停止读取;压缩输入还需要区分压缩前后的预算。嵌套深度约束与总字节约束解决不同问题,不能互相替代。

第二条可迁移模式是按阶段设置预算。接收阶段限制字节,解析阶段限制结构复杂度,业务阶段限制记录数和处理成本。一个 max 参数只有覆盖它实际检查的那一阶段,才能成为可验证的边界。

类型转换之后仍需业务校验

绑定得到 Product 只说明字段能够转换为指定 Java 类型,不说明它是一件可以发布的商品。负价格、缺少币种、日期早于业务允许范围,都可能在语法与类型层面完全合法。入口应在绑定后执行清楚的业务约束,并把失败定位到对应字段。

同样,忽略未知字段不能代替版本协商。如果生产者增加了一个会改变价格含义的字段,旧消费者静默忽略它,虽然没有解析异常,仍可能按旧规则执行错误动作。是否接受新增字段,应结合该接口的演进约定,而非为了减少异常统一关闭检查。

这也说明树、绑定和流式读取并不是三个互相替代的业务校验器。它们提供不同的信息访问方式,最终接受条件仍需要由应用定义,并通过同一组业务样本验证。

配置与结果都进入回归契约

同一份 JSON 在不同配置下可以得到不同结果,因此 mapper、模块和 reader 配置是协议的一部分。构建实例后统一配置,再交给调用方使用,比在请求处理中临时开关某个全局选项更容易审查。局部读取差异可以由独立 reader 表达,避免一次调用的宽松规则影响其他入口。

升级测试应保留原始输入和目标类型,同时检查接受或拒绝、数值类型、字段存在性以及输出格式。只比较格式化后的字符串,可能漏掉 Java 对象类型的变化;只比较对象值,又可能漏掉协议需要保留的字段结构。

Chapter32Test 在两个 JDK 各通过四个测试方法,证据位于 evidence/32。结果覆盖本篇列出的样本,不包含性能基准、全部日期格式、多态反序列化或网络接收预算。示例没有启用默认多态类型实例化,不将不可信字段解释为任意 Java 类型。

反例题:树中 name:null 满足 has(“name”),是否同时满足 hasNonNull(“name”)?前者为 true,后者为 false。若更新协议规定 null 表示清空,用 hasNonNull 作为唯一更新条件会跳过清空动作。

改动练习:让流式入口只接受顶层商品数组,每个商品恰好一个 price,最多两条记录。加入嵌套对象中的同名 price、缺失 price、重复 price 和第三条记录,断言都在约定阶段被拒绝,再保留合法两条记录求和结果。

需求信号 判断模式 检查内容
补丁更新 保留存在性 missing、null、非空分别验收
泛型容器 描述完整目标结构 元素类型与字段值
金额日期 类型和配置属于协议 BigDecimal、模块、输出格式
大输入 分阶段预算 字节、深度、记录数
类库升级 同输入核对完整结果 接受策略、类型、异常与输出