ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Jackson序列化忽略Null值的五种方案与原理详解

Jackson序列化忽略Null值的五种方案与原理详解 最近在做一个老项目的接口联调发现返回的 JSON 动不动就几十 KB打开一看全是couponAmount: null、deliveryTime: null这种字段。数据量大倒是其次关键是前端同事拿着接口文档对着字段一个个判空代码写得又长又绕半夜还在群里艾特我“这个字段到底什么时候有值”后来我把 Jackson 序列化 JSON 时的 Null 值处理重新捋了一遍才发现这问题其实有非常清晰的解法。Jackson 作为 Java 生态里最主流的 JSON 处理库几乎每个 Spring Boot 项目都在用但很多人对它的 Null 值处理策略还停留在“写个注解试试”的阶段。这篇博文我就把 Jackson 序列化时忽略 Null 值的所有方案、原理、坑位一次性说清楚顺便分享一些我在实际项目里踩过的教训。1. 先搞清楚为什么大家都想让 JSON 里没有 Null 字段1.1 一个典型的联调现场我在前一家公司做交易中台的时候订单详情接口返回的对象里有三十多个字段。这三十多个字段里有优惠券抵扣金额、配送员姓名、预计送达时间、发票信息、赠品列表……但实际上一个普通订单里能有值的字段往往只有十几个剩下的全是null。前端拿到这个 JSON 之后为了渲染页面需要写一大堆判空逻辑if (resp.data resp.data.couponAmount ! null resp.data.couponAmount ! undefined) { // 显示优惠金额 }一个字段一套判断三十个字段就是三十套。后端看着自己的代码觉得挺干净因为 Java 对象里该有的字段都在前端看着接口返回的数据心里骂娘因为null字段填满了整个控制台。这类问题本质上不是技术问题而是接口契约的设计问题。接口在下发数据的时候到底应该“字段全部在但值为 null”还是应该“只有有值的字段才出现”站在后端的角度前者更省事站在前端的角度后者更友好站在整个团队的角度必须统一规则否则两边各自为政联调成本直线上升。1.2 Null 存在的合理性和不合理性我并不是说所有null都必须从 JSON 里拿掉。有些场景下字段缺失和字段为 null 代表完全不同的业务含义这种情况下贸然忽略 null 反而会引发线上问题。举个例子一个登录接口返回token字段如果登录失败JackSon 序列化时忽略 Null 值后token这个键就直接不存在了。前端拿到响应后如果判断if (resp.token)失败时进不了分支这没问题但如果前端判断的是if (token in resp)失败时就会直接报 “token is undefined” 之类的错误。所以在做忽略 Null 的改造之前我一般先梳理一遍字段类型必须保留 null 的字段业务上需要区分“没有值”和“值不存在”的字段比如错误信息、备用联系方式应该忽略 null 的字段可选字段、冗余展示字段、内部计算字段比如优惠券明细、统计数值、默认空列表需要转换成默认值的字段比如列表类字段null和[]在前端语义里通常是一样的干脆在后端就输出空数组压根不该返给前端的字段这类字段不应该通过忽略 Null 来处理而是直接用 DTO 裁剪掉。做这一轮梳理其实是在给团队定接口规范。忽略 Null 值不是目的让接口返回的数据干净、可预期、减少前后端无谓的沟通成本才是目的。1.3 序列化和反序列化别搞混讨论 Jackson 忽略 Null 值的时候总有人把“序列化时忽略 null”和“反序列化时缺失字段”混在一起聊。这两个是完全不同的处理链路。序列化Java 对象 → JSON指的是调用ObjectMapper.writeValueAsString()的过程。我们说“忽略 Null 值”管的是这个方向输出的时候决定要不要把null字段写到 JSON 字符串里。反序列化JSON → Java 对象指的是调用ObjectMapper.readValue()的过程。JSON 里没有某个字段对应的 Java 对象字段就是null这跟“忽略 Null”没有关系。网上经常刷到的“反序列化攻击”“反序列化漏洞”属于另一套安全攻防话题和业务层面的 null 处理完全是两码事别被热搜词带偏了。我们在配置里说的NON_NULL、NON_EMPTY都只影响序列化方向。如果你发现反序列化时字段值不对那是另外一套问题排查思路完全不同这个后面我会专门讲。2. 解决 Null 字段的五种主流做法2.1 类级别注解一个注解控制整个 VO最直接的办法是在实体类或者 VO 类上标注JsonIncludeimport com.fasterxml.jackson.annotation.JsonInclude; JsonInclude(JsonInclude.Include.NON_NULL) public class OrderVO { private Long orderId; private String orderNo; private BigDecimal couponAmount; private String deliveryManName; // getter/setter 省略 }这个注解的作用范围是当前类。序列化OrderVO的时候Jackson 会自动跳过值为 null 的字段。比如couponAmount是 null输出的 JSON 里就完全没有couponAmount这个键而不是输出couponAmount: null。类级别注解适合用在对外输出的 DTO 上。我自己的习惯是所有返回给前端或第三方系统的 VO都默认加上这个注解因为没人愿意在下游去处理一堆无意义的 null 字段。2.2 字段级别注解只针对个别字段如果不想整个类都忽略 Null只想针对某一个字段做处理可以把注解加在字段上public class OrderVO { private Long orderId; private String orderNo; JsonInclude(JsonInclude.Include.NON_NULL) private BigDecimal couponAmount; }这种情况一般比较少见通常是在“大部分字段需要输出 null只有个别字段需要忽略”的场景下使用。比如做数据对账报表时所有指标字段都要原样输出唯独某个可选附加字段需要忽略。字段级别的优先级高于类级别。如果一个类上标注了ALWAYS某个字段上标注了NON_NULL那这个字段还是会按NON_NULL处理。2.3 ObjectMapper 全局配置一劳永逸如果整个项目的接口返回都用同一个ObjectMapper最推荐的做法是直接改全局配置import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; ObjectMapper objectMapper new ObjectMapper(); objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);在 Spring Boot 项目里通常会直接提供一个自定义的ObjectMapperBeanimport com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class JacksonConfig { Bean public ObjectMapper objectMapper() { ObjectMapper objectMapper new ObjectMapper(); objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return objectMapper; } }这样配置之后Spring MVC 在把 Controller 返回值序列化成 JSON 的时候就会使用这个 ObjectMapper所有接口同时生效不需要每个 VO 都加注解。不过这里有一个容易踩的坑如果你的项目里其他地方也在用new ObjectMapper()那这些地方并不会自动应用全局配置。ObjectMapper不是 Spring 管理的单例 Bean每次 new 出来的都是独立的实例。后面讲 Redis 序列化的时候我会详细说这个事。2.4 Spring Boot 配置文件零代码改造Spring Boot 还有一个更省事的方式直接在application.yml里配spring: jackson: default-property-inclusion: non_null这一行配置的作用和全局ObjectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)是一样的底层原理也是给自动配置的 ObjectMapper 设置 inclusion 策略。好处是完全不用写 Java 代码配置文件里一目了然运维、同事接手项目的时候一眼就能看到这个约定。我个人的建议是如果是新项目优先用application.yml配置如果是老项目改造担心自定义 ObjectMapper Bean 会影响现有逻辑也建议用配置文件的方式这样改动最小出问题回滚也快。需要注意default-property-inclusion这个配置项在 Spring Boot 不同版本里的写法略有差异。我常用的写法是non_null对应枚举JsonInclude.Include.NON_NULL。如果你用的是 Spring Boot 2.x 以上版本基本都没问题更老的项目可以查一下自己的版本对应文档。2.5 MixIn 方案不能改源码时的兜底方案有时候项目里用了第三方依赖的类比如某个 SDK 里直接返回给前端的数据对象你没法在类上添加JsonInclude注解这时候可以用 Jackson 的 MixIn 功能import com.fasterxml.jackson.annotation.JsonInclude; // 定义一个伪装的抽象类只用于配置序列化规则 JsonInclude(JsonInclude.Include.NON_NULL) public abstract class ThirdPartyDtoMixIn { }然后在配置里注册objectMapper.addMixIn(ThirdPartyDto.class, ThirdPartyDtoMixIn.class);这样 Jackson 在序列化ThirdPartyDto的时候会参照ThirdPartyDtoMixIn上的注解规则但ThirdPartyDto本身的代码不需要做任何改动。这个方案我平时用得不算多但遇到“想改但改不了源码”的第三方类时简直救命。2.6 五种方案对比把我常用的这几种方式整理成一个表格方便你们直接对照选型方案作用范围侵入性适用场景类级别 JsonInclude单个类需要改实体类代码业务 VO、DTO字段级别 JsonInclude单个字段需要改实体类代码个别字段特殊处理ObjectMapper 全局配置整个 ObjectMapper需要写配置类统一处理所有序列化application.yml 配置Spring Boot 全局零代码新项目、老项目快速改造MixIn 方案指定类型不改目标类第三方 SDK 类、无法改源码的类这五种方案不是互斥的实际项目里经常是组合使用的。比如全局用NON_NULL个别接口反而不想忽略 null就在那个 VO 上标注ALWAYS覆盖全局策略或者全局用NON_NULL某个第三方 SDK 类用 MixIn 单独指定策略。3. 原理层面Jackson 是如何决定字段去留的3.1 JsonInclude.Include 的五个成员JsonInclude.Include枚举定义了五个值搞清楚它们的区别才能针对不同场景做正确的选择。我在代码里直接对应解释ALWAYS始终包含字段不管值是不是 null。这是 Jackson 的默认行为。NON_NULL值为 null 的字段不输出。注意只有null才不输出空字符串、空集合[]、数字0都会正常输出。NON_ABSENT在 NON_NULL 的基础上额外处理“空引用”类型比如Optional.empty()、原子引用类。Java 8 项目里用了Optional字段的话这个选项很有用。NON_EMPTY更加“激进”值为 null、空字符串、空数组、空集合、Optional 为空、空 Map 都不输出。这个选项用的场景要谨慎因为和[]在业务上可能是有意义的。NON_DEFAULT字段值如果是“默认值”就不输出。所谓默认值对基本类型来说是0、false对引用类型来说是null对 Bean 来说是构造器初始化后的值。这个用的场景最少因为容易造成不可预期的行为我一般不推荐。日常项目里NON_NULL 是最常用、最不容易出错的选项。NON_EMPTY 看起来很爽但一旦遇到“前端需要区分空字符串和 null”的业务就会埋雷。3.2 从源码看注解是怎么生效的Jackson 序列化一个 Bean 时大致流程是这样的ObjectMapper拿到要序列化的对象根据类型找到对应的JsonSerializer对普通 Bean 来说一般是BeanSerializerBeanSerializer会列出所有需要序列化的属性每个属性带有一个BeanPropertyWriter每个BeanPropertyWriter在写值之前会检查当前属性的JsonInclude配置决定是否跳过JsonInclude注解由JacksonAnnotationIntrospector读取转换成JsonInclude.Value配置对象然后附加到对应属性上代码执行到BeanPropertyWriter.serializeAsField()时如果配置要求NON_NULL会先判断value null为 true 就跳过整个字段的写入。我自己脱坑的时候翻过源码BeanPropertyWriter.serializeAsField()这个方法里有一段核心逻辑大意是先判断“是否为空”为空就不执行writer.write(value)。整个判断过程就是一个简单的if性能开销很小所以不必担心加了忽略 Null 配置后接口变慢。3.3 空字符串、空集合、Optional 的处理差异这三个类型是容易被忽略的重灾区专门拉出来说一下。空字符串NON_NULL不会忽略它只有NON_EMPTY会。如果后端某个字段允许空字符串配置了名非空后前端看不到这个字段反而会困惑“这个字段是没返回还是值为空”。所以空字符串的处理一定要结合业务。空集合[]NON_NULL同样不会忽略空集合只有NON_EMPTY会。不过在订单详情返回赠品列表的场景里赠品列表: []比完全没有这个字段更友好所以我一般不建议对列表字段用NON_EMPTY。OptionalJava 8 的Optional.empty()在NON_NULL下并不会被忽略因为Optional.empty()本身不是 null而是一个 Optional 类型的实例。Jackson 序列化 Optional 时会把内部的 value 取出来如果 value 是 null最终输出的还是null。想连 Optional 空值一起忽略得用NON_ABSENT。4. 实操落地从接口到 Redis 再到旧系统改造4.1 实例给一个查询接口的返回 VO 开启忽略 Null模拟一个真实的订单查询接口先看改造前的样子RestController RequestMapping(/order) public class OrderController { GetMapping(/{orderId}) public OrderVO getOrder(PathVariable Long orderId) { OrderVO vo new OrderVO(); vo.setOrderId(orderId); vo.setOrderNo(202501010001); // couponAmount、deliveryManName 均为 null不设置 return vo; } }OrderVO如果没有做任何配置Jackson 默认序列化结果是这样{ orderId: 1001, orderNo: 202501010001, couponAmount: null, deliveryManName: null }现在在OrderVO上加上JsonInclude(JsonInclude.Include.NON_NULL)重新执行{ orderId: 1001, orderNo: 202501010001 }输出里直接少了两个 null 字段。前端拿到这个 JSON遍历字段或者直接用orderNo都不用再担心 undefined 的问题。如果你用的是全局配置连 VO 上的注解都不用加Controller 的代码也完全不用动改一下配置文件就生效。4.2 Spring Boot 全局配置的正确姿势我在前面提过Spring Boot 项目里可以通过自定义 ObjectMapper Bean 来做全局配置。不过这里有一个细节值得展开Spring Boot 内部使用的是 MappingJackson2HttpMessageConverter它有自己的 ObjectMapper。如果你只是定义了一个ObjectMapperBeanSpring Boot 的自动配置会在创建MappingJackson2HttpMessageConverter时把它注入进去所以 Controller 返回的 JSON 会用到你的配置。这一点 Spring Boot 做得确实贴心。但如果你在代码里手动 new 了一个MappingJackson2HttpMessageConverter并且没有把自定义 ObjectMapper 给它那 Controller 序列化时用的还是默认配置。这个坑我用过一次后来排查了半天才发现是 message converter 的问题。用application.yml配置的话就不存在这个问题因为 Spring Boot 自动配置会读取spring.jackson.*配置项并应用到它内部创建的 ObjectMapper 上推荐优先使用这个方式。4.3 与 Redis 序列化器联动时最容易踩的坑Redis 序列化是另一个高频场景。很多人在 Spring Boot 项目里往 Redis 里写入一个 Java 对象结果发现取出来的时候字段和存进去时不一致。这里面的坑和忽略 Null 值有很大关系。先说结论Redis 序列化器是一个独立的 ObjectMapper 实例它不会自动继承你在 Spring Boot 里配置的全局 ObjectMapper。比如你配置了spring.jackson.default-property-inclusion: non_nullController 返回的 JSON 很干净。但用RedisTemplateString, Object写入数据时如果RedisTemplate使用的是Jackson2JsonRedisSerializer或GenericJackson2JsonRedisSerializer这套序列化器内部通常自己 new 了一个 ObjectMapper默认策略是ALWAYS所以 Redis 里存的数据还是会带上null字段。解决方案是给 Redis 的序列化器单独指定一个配置好的 ObjectMapperimport com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; ObjectMapper redisObjectMapper new ObjectMapper(); redisObjectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); redisObjectMapper.activateDefaultTyping( redisObjectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(redisObjectMapper);这里需要提醒的是Redis 里序列化对象时如果启用了多态类型信息activateDefaultTyping序列化出来的字符串里会带class字段这是为了反序列化时能定位到具体类。忽略 Null 值的配置只是决定字段去留不会影响class的写入。还有一点Redis 的序列化器配置了NON_NULL之后反序列化时如果 JSON 里没有某个字段对应的 Java 对象字段就是 null这一点完全符合预期不需要额外处理。4.4 和 fastjson 简单对比搜到这个标题的人很多都会顺手搜一下“jackson 和 fastjson 哪个好”。这两个库各有拥趸。单就“忽略 Null 值”这件事来说fastjson 的默认行为就比较激进默认序列化时就会忽略 null 字段如果需要输出 null反而要特意开启WriteMapNullValue。Jackson 则默认全部输出 null需要开发者显式配置NON_NULL或NON_EMPTY。所以你要是从 fastjson 切换过来会发现 Jackson 的 JSON 里“多了好多 null”这不是 Jackson 不行而是两者的设计哲学不同fastjson 追求开箱即用Jackson 追求显式可控。我在自己的项目里是坚定的 Jackson 派。原因很简单Spring Boot 默认集成 Jackson生态成熟社区活跃而且它是 Spring 官方首选的 JSON 库。除非你有非常特殊的性能需求否则没必要引入额外的 JSON 库增加依赖复杂度。4.5 对现有代码的影响评估老项目改造时最担心的是“把所有 null 忽略掉之后前端会不会挂掉”。在动手之前我通常做这样几件事第一先找全消费这个接口的下游方。内部前端团队、外部合作方、定时任务、消息订阅者所有这些使用方都要纳入评估范围。第二梳理字段类型把必须保留 null 的字段、可以忽略的字段、需要换成默认值的字段分别列出来。这一步最好拉上对业务最熟的人一起过一遍比如订单领域的“预计送达时间”在未分配配送员时是 null前端目前就靠这个字段判断“是否显示配送信息”那这个字段短期内就不能忽略。第三分阶段灰度。先把配置加在最新的接口版本上跑几天看看有没有报错确认没问题后再推给历史接口。如果历史接口涉及面太广宁可保持现状也坚决不贸然全局开启。5. 常见问题与排查技巧实录5.1 配了注解但没生效这个问题我至少接到过三次同事的求助。明明在 VO 上加了JsonInclude(JsonInclude.Include.NON_NULL)调试的时候断点也打了字段确实是 null但返回的 JSON 里还是带field: null。排查思路一般是这样确认注解是不是加在了正确的类上。有时候前端调用的不是这个 VO而是另一个包装类比如ApiResponseTApiResponse里包了一层datadata才是你的 VO确认字段名和方法名是否匹配。Jackson 默认通过 getter 和 setter 推断属性如果你在 getter 上加了JsonInclude但字段本身没加也没有全局配置那这个注解是不生效的确认是不是 Lombok 生成的 getter/setter。有些团队用了 Lombok加注解的时候习惯加在字段上但有个别版本对JsonInclude的字段注解支持不好这时候改成在 getter 方法上加注解可以绕过问题确认项目里有没有自定义ObjectMapper覆盖了 Spring Boot 自动配置。如果你在配置类里定义了一个ObjectMapperBean但又没有调用setSerializationInclusion那么application.yml里的配置会被忽略。因为 Spring Boot 一旦发现你自定义了 ObjectMapper就不会再用自己创建的配置。5.2 全局配置只对部分接口生效还有一次同事把配置加到application.yml里测试发现部分接口的 null 字段消失了另一部分接口还是老样子。排查之后发现这些“老样子”的接口用了自定义的返回类型包装比如ResultT而这个ResultT类本身又被另一个自定义序列化器接管了绕过了 Jackson 默认的BeanSerializer。遇到这种情况解决方案要么是把ResultT上的自定义序列化器去掉要么是给ResultT加JsonInclude。这类老代码可以慢慢改但排查的方向一定要对。5.3 空字符串还在配置了NON_NULL但发现 JSON 里依然是{ remark: }这是正常的因为NON_NULL只忽略 null不忽略空字符串。如果你的业务上要求空字符串也不输出得用NON_EMPTY。不过用NON_EMPTY之前先想清楚前端能不能接受“空字符串字段完全消失”如果前端判断逻辑是if (resp.data.remark)那字段消失和空字符串效果一样如果前端判断的是if (resp.data.remark ! )那字段一旦消失这个条件就变成undefined ! 永远为 true逻辑就错了。所以我在项目里对空字符串的态度是尽量从源头规避后端入库时规范空值处理该存 null 存 null该存空字符串存空字符串别让同一个字段时而 null 时空字符串。5.4 前端确实需要 null 怎么办有时候下游真的需要知道“这个字段有没有值”不能只靠字段是否缺失来判断。这种情况我见过比较多的处理方式有三种一种是保留 null 字段在全局配置里不要开启 NON_NULL只针对特定 VO 开启。但这样维护成本高需要记住哪些 VO 开了、哪些没开。另一种是显式输出默认值比如数值类型返回0字符串类型返回自定义一个JsonSerializer或者在 setter 里做转换。这个方法适合字段比较少的情况。还有一种是改接口契约干脆新增一个字段表示“这个值是否可用”或者用-1、这类业务上明确的占位值。这个方法看起来最绕但实际上很多大厂接口都在用因为契约清晰前端写起来最省心。具体选哪一种取决于你们团队的协作习惯。技术方案没有绝对的对错关键是要让前后端对字段语义达成一致。5.5 性能考量配置了忽略 Null 之后序列化时每个字段多一次 null 判断这个开销小到可以忽略。我之前用 JMH 简单测过十万次序列化ALWAYS和NON_NULL的耗时差异基本在 1% 以内。所以不要担心性能问题放心配置。真正的性能隐患是自定义序列化器写得不好。比如有人为了让 null 输出成给每个字段都写了一个JsonSerializer序列化的时候走大量字符串拼接反而比默认实现慢很多。遇到这种情况优先考虑在全局配置层面解决不要一上来就搞自定义。6. 我的实践经验总结做了这么多年 Java 开发我个人的总结是Jackson 序列化时忽略 Null 值这件事本质上是接口契约设计的一部分不能只从后端视角拍脑袋决定。我现在接到一个新的查询类接口需求第一件事就是打开前端同事的代码看看他是怎么消费这些字段的。只要确认字段缺失不会导致前端逻辑报错我就放心地在 VO 上加JsonInclude(JsonInclude.Include.NON_NULL)并在接口文档里标注“值为 null 的字段不返回”。如果团队统一使用了全局配置我会额外强调所有新写的 VO要么遵循全局配置要么在类级别显式覆盖绝不依赖“碰巧没传 null”这种运气。最后再分享一个小技巧。如果你用的是 Spring Boot并且项目里已经有自定义的 ObjectMapper Bean可以顺手在启动的时候打印一下当前 inclusion 策略方便排障import javax.annotation.PostConstruct; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; Configuration public class JacksonConfig { private final ObjectMapper objectMapper; public JacksonConfig(ObjectMapper objectMapper) { this.objectMapper objectMapper; } PostConstruct public void printInclusion() { JsonInclude.Include inclusion objectMapper.getSerializationConfig() .getDefaultPropertyInclusion() .getValueInclusion(); System.out.println(Jackson serialization inclusion: inclusion); } }日志里看到Jackson serialization inclusion: NON_NULL就说明配置生效了。如果看到ALWAYS那就要回头检查是不是有配置被覆盖了。我在实际项目里踩过不少坑尤其是全局配置和局部注解混用的时候经常出现“这里生效、那里不生效”的诡异现象。最有效的排障办法就是先把官方文档和源码打开把BeanPropertyWriter和JacksonAnnotationIntrospector这两个类的逻辑读一遍很多问题一眼就能看出来。希望这篇文章能帮你少走一些弯路。
返回列表