SpringBoot4系列23 - 使用Jackson 3与空安全
前段时间我帮订单服务做过一次对账排查:财务那边的解析脚本按「字段有值才算有效记录」的口径读订单 JSON。游客单没留电话、没用优惠券、没填备注,响应体里这些字段带着 null 原样输出,对账脚本把整批订单判成无效数据,夜里重跑了三次,我第二天早上才看到告警。另一件事更隐蔽:金额字段 discountCents 在下单链路里偶尔是 null,对账方拿到 null 按数值 0 参与汇总,月初的折扣报表少算了两万块,排查的时候我才搞明白,null 与 0 在他们的口径里是两个东西,这么混着用迟早闹笑话。
这两件事指向同一个机制:JSON 序列化时对空值的处理策略。空字段省略还是输出 null、请求体里的 null 落成什么值、null 打进 primitive 怎么办,这些决策都写死在框架默认行为里,我不显式声明,框架就替业务做主。所以我把 Jackson 3 的空值处理在 Spring Boot 4.1.1 上完整实测了一遍:默认行为取证、三种空值策略同场对比、自定义序列化器做金额分转元、反序列化容错逐项验证,11 个端到端用例全绿,全部数字来自同一次运行,原始记录我都留了底。
包名与坐标事实清单
动笔前我把相关制品全部解包核对了一遍:tools.jackson.core:jackson-databind 3.1.5、jackson-core 3.1.5、jackson-annotations 2.21、spring-boot-jackson 4.1.1,下面这些事实都来自制品与我自己的真实运行。
第一个事实是坐标。Jackson 3 的 Maven groupId 换成了 tools.jackson.core,Java 包名前缀跟着换成 tools.jackson,Boot 4.1.1 的 BOM 管理的小版本是 3.1.5,pom 里一个版本号都不用写,我一开始还想手动指定,后来发现纯属多余。注解是个例外:jackson-annotations 的 groupId 还是 com.fasterxml.jackson.core,版本线还是 2.21,包名也还是 com.fasterxml.jackson.annotation。这不是残留,databind 3 的代码大量引用这个包里的注解,它是两个包名世界之间的一座桥。
第二个事实是类名。构建 mapper 的推荐入口在 tools.jackson.databind.json 包:
| |
ObjectMapper 本身仍是可实例化的类,JsonMapper 继承它并补上 JSON 专属的工厂方法。异常体系在 Jackson 3 里全部改成了非受检:JacksonException 继承 RuntimeException,DatabindException 继承 JacksonException,序列化与反序列化方法签名里不再出现 throws。我第一次写自定义序列化器时顺手在方法上写了 throws,编译器当场给我上了一课。自定义序列化器的基类也换了名字,叫做 ValueSerializer 与 ValueDeserializer,方法参数里的上下文类型是 SerializationContext 与 DeserializationContext。
第三个事实是注解的归属,我把它们整理成一张当前事实清单:
| |
常用的 null 处理注解全在 com.fasterxml.jackson.annotation 包里,写法与记忆一致;需要指定自定义序列化器时,@JsonSerialize 与 @JsonDeserialize 在 tools.jackson.databind.annotation 包下,import 的时候认准路径。
空安全主题里两个注解的取值集合同样值得列成清单,签名我都在 javap 里核对过:
| |
序列化侧的 NON_NULL 与反序列化侧的 AS_EMPTY 是一对常被混用的取值:前者管「出去时省略」,后者管「进来时补空值」,方向相反,语义也不同,我后文的实测会把这层差异摆出来。

依赖与配置
依赖零新增,这是我这次实测里最省心的一环。starter-webmvc 自带 Jackson 3,传递链是 spring-boot-starter-webmvc → spring-boot-starter-jackson → spring-boot-jackson → tools.jackson.core:jackson-databind 3.1.5,我跑了一遍 dependency:tree,没有任何可选件缺席:
| |
单模块只想用 JSON 不起 Web 时,坐标是 spring-boot-starter-jackson。没有 starter-json 这个名字,我在官方文档里翻了半天才确认,九成的人第一次都会在这里扑空。
application.yml 里与 Jackson 相关的配置只有端口和日志,全局空值策略我故意留成注释,后面的对比实测会把它打开:
| |
spring.jackson 命名空间在 Boot 4.1.1 共有 34 条属性,这是我从 configuration metadata 里一条条数出来的。与空值直接相关的一条是 default-property-inclusion,绑定的类型是 com.fasterxml.jackson.annotation.JsonInclude$Include 枚举,yml 里写 non_null。serialization 与 deserialization 两条 Map 属性还在,键的类型已经换成 tools.jackson 的枚举,比如给 deserialization 配 FAIL_ON_UNKNOWN_PROPERTIES: true 就能打开未知字段报错。
默认行为:先看 Jackson 3 怎么处理 null
空值策略的第一步是搞清楚默认值,不摸清基准,后面的对比都没有参照。工程里我放了一个探针端点,注入 Boot 容器里的 ObjectMapper,回显三个 Feature 的开关状态:
| |
实测输出一行顶三段解释:
三个值里有一个值得停下来看:FAIL_ON_NULL_FOR_PRIMITIVES 默认是开着的。JSON null 打进 int 字段会直接抛异常,金额字段出过事的项目对这个默认值会有体感,我第一次看到它默认开启时还挺意外。FAIL_ON_UNKNOWN_PROPERTIES 默认关闭,请求体里多出来的字段被静默忽略。FAIL_ON_EMPTY_BEANS 默认关闭,没有可输出属性的对象序列化成空对象,前端拿到 {} 的场景多半与它有关。
三个开关都能经 spring.jackson 属性调整。deserialization 与 serialization 是 Map 类型,键写 Feature 枚举名,比如把 primitive 的严格模式关掉:
| |
键名与探针端点回显的枚举名一一对应,宽松绑定不改动枚举常量本身。这条属性与探针端点读的是同一组 Feature 开关,我后续的实测全部保持在默认值状态。
序列化方向的默认值靠一个不贴任何注解的 DTO 实测。订单 2 是一张游客单,customerPhone、couponCode、remark、discountCents 四个字段为 null:
null 字段原样输出,10 个字段一个不少,字段名按字母序排列。Jackson 3 的默认 inclusion 仍是 ALWAYS,想省掉空字段必须显式声明,框架不会替业务做这个决定,我觉得这个设计立场挺有意思:它宁可让响应冗余,也不悄悄丢字段。
三种空值策略同场对比
策略一,类级 @JsonInclude(NON_NULL)。注解贴在 DTO 类上,整类所有 null 字段都不输出:
| |
订单 2 经过这个 DTO 输出,4 个 null 字段全部消失,10 个字段剩 6 个:
策略二,字段级 @JsonInclude(NON_NULL)。注解只贴在 couponCode 一个字段上,其余字段维持默认行为:
| |
实测输出里 couponCode 整个键消失了,remark 与 discountCents 仍是 null。字段级注解只作用于贴了它的字段,类里其他 null 字段不受影响。这个粒度适合「个别字段有契约要求、其余保持完整结构」的场景,比如对账方要求某个可空字段缺席时代表未启用,而不是 null。
策略三,全局属性。yml 里打开我留的那行注释,或者验证脚本里用命令行参数起服务:
| |
全局生效后,订单 2 的默认 DTO 输出与类级注解的效果一致,4 个 null 字段省略;record DTO 的输出也同步变化。三种策略同场对比如下:
| 策略 | 写法位置 | 作用域 | 订单 2 实测 |
|---|---|---|---|
| 默认行为 | 无 | 整个应用 | 10 字段,4 个 null 输出 |
| 字段级 NON_NULL | 字段上 | 贴注解的那个字段 | 仅 couponCode 消失 |
| 类级 NON_NULL | DTO 类上 | 当前 DTO | 4 个 null 全省,剩 6 字段 |
| 全局 non_null | spring.jackson 属性 | 整个应用 | 与类级一致,record 同步受控 |
作用域从小到大,选型的依据是契约的归属:字段契约写在字段上,接口契约写在 DTO 上,应用级口径才走全局属性。三者叠加时的求值顺序由 USE_DEFAULTS 串起来,字段注解最先表态,没表态再看类注解,然后落到全局属性,没有一层声明时才是默认的 ALWAYS。我把这条判定链画成了一张图:

自定义序列化器:金额分转元
金额在数据库里存分(long),对外接口输出元(两位小数),我把换算逻辑收在序列化器里,DTO 字段不出现重复换算代码:
| |
基类 ValueSerializer 在 tools.jackson.databind 包,第二个参数是 JsonGenerator,第三个是 SerializationContext。DTO 字段上用 @JsonSerialize 指定(注解在 tools.jackson.databind.annotation 包):
| |
订单 1 的实测输出,24182 分变成 241.82 元,2000 分变成 20.00 元,尾零保留(BigDecimal 按 scale 2 构造):
订单 2 的 discountYuan 是 null,输出仍是 “discountYuan”:null。null 值不进自定义序列化器,走框架的 NullSerializer,这点我是踩过才记住的:「字段可能为空的金额」场景要单独想清楚,要么接受 null 出现在响应里,要么给 DTO 补 NON_NULL,要么像下一节那样在反序列化方向处理空值。
反方向是请求体里的元转分。ValueDeserializer 的子类把 241.82 解析成 24182:
| |
取数用的是 JsonParser 的 getDecimalValue()。请求体里写 “amount”: 241.82,实测解析落库 amountCents 为 24182。这两个自定义类与 Boot 侧的装配关系,我画成了一张类图:

反序列化容错
反序列化方向的空值由 @JsonSetter 控制,它管「JSON 里显式写了 null 时字段落什么值」:
| |
Nulls 枚举共五个值:SET、SKIP、FAIL、AS_EMPTY、DEFAULT。AS_EMPTY 把 null 落成类型的空值,String 落空字符串、Long 落 0。我构造了一个请求体,带一个未知字段 extraField,加上 null 值与元金额一起发过去:
| |
四件事同一次请求里验证完:未知字段 extraField 被静默忽略(FAIL_ON_UNKNOWN_PROPERTIES 默认关闭);@JsonAlias 让别名 orderNo 命中 merchantOrderNo;couponCode 的 null 落成空字符串;discountCents 的 null 落成 0;241.82 换算成 24182 分。
AS_EMPTY 的语义边界要清楚:它给的是「空值」,空字符串与 0 在对账口径里依然是有效值,与「字段缺席」不同。null 想被跳过保留字段原值用 SKIP,想直接报错用 FAIL,FAIL 会抛出携带字段名的 InvalidNullException。我把三个取值的实测差异钉在同一行字段上:
| |
AS_EMPTY 适合「把 null 规整成可计算的缺省值」的入口,比如优惠券字段 null 落空串后,下游代码不再需要判 null;FAIL 适合契约严格的内部接口,null 一出现立刻暴露调用方问题;SKIP 的用途最窄,字段有 Java 侧默认值时才用得上。
primitive 的行为我单独实测。quantity 是 int,请求体显式给 “quantity”: null:
| |
返回 400,没有静默落 0,错误文本里直接给出了关闭开关的名字,这是本次实测里我最欣赏的一个默认值:null 与 0 的区分在 primitive 字段上也被守住,代价是客户端必须保证不该为 null 的字段别发 null。捕获侧的代码同样有 Jackson 3 的痕迹:DatabindException 是 RuntimeException 的子类,@RestControllerAdvice 的处理方法不需要 throws 声明:
| |
一个请求从进来到落地,中间要过未知字段、别名、空值语义、自定义换算、primitive 严格模式五道关口,出口只有 200 与 400 两种,我把整条容错链路画了出来:

完整案例:一次运行的全部输出
验证脚本起两次服务。A 轮默认配置发 11 个请求,B 轮开全局 non_null 再发 2 个,输出我逐条实录。三种策略对同一张游客单(订单 2)的输出对比:
| |
record 的输出单独看一眼。组件按声明序排列,与普通 DTO 的字母序不同:
LocalDateTime 组件零配置输出 ISO-8601 文本,Jackson 3 的 java.time 支持已内置在 databind 里,不再需要单独注册模块。全局 non_null 打开后,这张 record 输出里的 4 个 null 同样消失,customerPhone 与 couponCode 整行不见:
record 的空值策略与普通 DTO 共用同一套注解与全局属性,@JsonInclude 贴在 record 声明上、@JsonProperty 定名、@JsonSetter 控制组件的反序列化空值,写法完全一致。区别只剩两处:输出顺序是声明序,组件不可变所以没有 setter,反序列化走构造器参数,@JsonSetter 贴在组件上同样生效。
13 个场景的完整行为汇总成一张表:
| # | 请求 | 实测结果 |
|---|---|---|
| 1 | GET /api/jackson/defaults | JsonMapper + 三 Feature 默认值实证 |
| 2 | GET /api/orders/2(默认 DTO) | 10 字段,4 个 null 原样输出 |
| 3 | GET /api/orders/1(默认 DTO) | 全字段非空,输出与字段值一致 |
| 4 | GET /api/orders/2/non-null | 4 个 null 省略,剩 6 字段 |
| 5 | GET /api/orders/2/field-include | 仅 couponCode 消失 |
| 6 | GET /api/orders/2/record | 声明序输出,null 照常 |
| 7 | GET /api/orders/1/amount | 24182→241.82,2000→20.00 |
| 8 | GET /api/orders/2/amount | discountYuan 为 null,输出 null |
| 9 | POST /api/orders/parse(混合容错) | 未知字段忽略,AS_EMPTY 生效,241.82→24182 |
| 10 | POST /api/orders/parse(quantity:null) | 400,提示 FAIL_ON_NULL_FOR_PRIMITIVES |
| 11 | POST /api/orders(下单) | 201,amount 99.90 落库 9990 分 |
| 12 | B 轮 GET /api/orders/2 | 全局 non_null 生效,null 全省 |
| 13 | B 轮 GET /api/orders/2/record | record 同步受控,null 全省 |
下单入口把容错规则串成完整链路,请求体 “amount”: 99.90 落库 9990 分,HTTP 201 返回建好的订单:
| |
响应 DTO 用的是类级 NON_NULL 的那个,没填的 customerPhone 不出现在响应里。对账脚本按「字段缺席即未启用」的口径解析,null 判定的分支整体消失。说实话,开头那两桩对账事故,在接口契约这一层就都能拦下来。
测试怎么写
11 个用例起真实 Tomcat 跑 HTTP 断言,客户端是 RestTestClient,RANDOM_PORT 下手工绑定地址:
| |
断言直接对响应字符串做 contains 与 doesNotContain,JSON 结构变化(字段消失)比数值比较更贴空值策略的本意,这是我写完才体会到的。反序列化容错用一个用例覆盖四个点,断言每个容错点落进字段的值:
| |
payload 里的 extraField 我故意不删,它就是「未知字段被忽略」这条断言的前提,删掉后用例还能过,但容错覆盖少了一半。primitive 的 400 与元转分的 201 各占一个用例,九个用例合起来把探针、序列化、反序列化三个方向全部钉住。全局策略轮用测试属性单独起一个上下文,验证 default-property-inclusion 对普通 DTO 与 record 同时生效:
| |
mvn test 一次跑完的对账:
| |
应用日志 ERROR 0 行。全部数字来自同一次运行,原始记录我都留了底。
避坑指南
坑一,FAIL_ON_NULL_FOR_PRIMITIVES 默认是开的,我第一次实测就撞上了。 JSON null 打进 int、long 字段直接 400,错误文本自带修复提示。想让 null 落 0,显式关掉这个 Feature,或者把字段类型换成包装类,业务代码里对包装类自己判空。
坑二,自定义反序列化器取小数要用 getDecimalValue()。 getNumberValue() 对 241.82 这类小数返回的是 Double,直接强转 BigDecimal 会抛 ClassCastException,我就这么栽过一次。取 BigDecimal 走 getDecimalValue(),取整型再按需转换。
坑三,null 不进自定义序列化器。 字段值为 null 时 Jackson 走 NullSerializer,@JsonSerialize 指定的序列化器不会被调用。空字段想输出缺省值(比如金额输出 0.00),要么在序列化器里用 Nulls.AS_EMPTY 的空值语义,要么接受 null 出现并交给客户端判断。
坑四,字段级 @JsonInclude 只管贴了注解的字段。 想整类省略 null 用类级注解,只想动一个字段用字段级,两者混用时字段级优先。我排查时就以为贴一个字段全类生效,盯着其余 null 字段还在输出看了半天,方向从一开始就错了。
坑五,默认 inclusion 是 ALWAYS,空字段省略必须显式声明。 Jackson 3 在这一点上取的是保守默认值,没配策略的接口照常输出 null 字段。对账、开放平台这类按字段解析的外部消费方,上线前先对一遍口径:null、缺字段、空字符串是三种不同的值。
坑六,record 的输出顺序是声明序。 普通 POJO 的字段按字母序输出,record 组件按声明序。契约方如果按字节序或字段位置对账,两种 DTO 的输出顺序不同,换 DTO 形态前先确认对方的解析方式。
坑七,未知字段默认静默忽略。 FAIL_ON_UNKNOWN_PROPERTIES 在 Jackson 3 默认 false,请求体多打一个字段名不会有任何提示。契约收敛严格的接口要显式打开这个 Feature,让多出来的字段在测试期就报错。
坑八,定制 mapper 的入口是 JsonMapperBuilderCustomizer。 Boot 侧想调 Feature、注册 Module,实现 org.springframework.boot.jackson.autoconfigure 包下的 JsonMapperBuilderCustomizer 接口,方法签名 customize(JsonMapper.Builder)。注解风格的序列化器注册用 @JacksonComponent(org.springframework.boot.jackson 包)。
小结
空值策略怎么选,看契约的归属:单个字段有约定就贴字段级注解,整个 DTO 有统一口径就贴类级注解,应用级要对齐一种口径才动 spring.jackson 的全局属性,三者叠加时字段级最先表态。反序列化入口的 null 优先考虑 AS_EMPTY,把 null 规整成下游可计算的缺省值;契约严格的内部接口用 FAIL,让调用方的问题当场暴露。金额这类有换算口径的字段,把换算收进自定义序列化器,DTO 里别出现重复代码。
没解决的事也直说:null 打进对象类型字段时走的是框架的 NullSerializer,自定义 NullSerializer 能接管到什么程度、「null 字段输出占位符」这类需求怎么落,我这次没测,等遇到真实场景再补一轮。
参考链接
- Spring Boot JSON 特性文档:spring.jackson 属性与 Jackson 3 自动配置说明
- Jackson 3.0 Wiki:包名、JsonMapper 与默认值变化的权威说明
- tools.jackson javadoc:ValueSerializer / ValueDeserializer / Nulls 的 API 参考
- spring-boot-jackson 4.1.1(Maven Central):本文属性清单的制品实证来源