
1. Null值到底怎么就成了序列化路上的拦路虎做Java后端的人几乎每天都在跟Jackson打交道。很多人第一次遇到忽略Null值这个需求都是被接口文档或前端同事逼出来的明明是个空字段返回给前端却是field: null数据量倒是小事关键是前端拿到的对象模型里全是意义不明的null各处都要判空。我自己最早是在对接第三方开放平台时被卡过一道对方接口做了严格的字段校验多传一个显式的null字段直接返回参数非法。从那以后我才认真研究起Jackson序列化JSON时忽略Null值的各种姿势。1.1 一个让我下定决心处理Null值的真实场景2022年做一个电商中台项目订单详情接口要返回给小程序端和内部管理系统复用。一开始图省事直接用实体类序列化返回结果订单对象里有物流信息、发票信息、优惠明细等十几个可空字段序列化出来的JSON长这样{ orderId: ORD20220011, userId: 12890, consignee: 张三, phone: 138****1234, address: 上海市浦东新区, invoice: null, logistics: null, couponList: null, remark: null }小程序端拿到这串数据每次渲染前都要判断invoice ! null invoice.xxx代码里到处是空值守卫。更麻烦的是日志系统要全文检索JSON大量null占着索引空间。后来前端组长直接找过来能不能别把null字段返回给我 我这才意识到忽略Null值不是洁癖问题而是接口设计规范里的一部分。1.2 先搞清楚忽略Null值有四个层次的语义在动手配置之前建议先把忽略Null值拆开看因为它至少包含四个完全不同的语义层次序列化时不输出null字段这是最常见需求即{name:x,age:null}只输出{name:x}。反序列化时忽略JSON中的null字段假设对方传来的JSON里有{name:null}反序列化时不覆盖Java对象现有的name值。忽略空字符串、空集合、空Optional这是空值的外延并非严格意义的null但很多人混在一起处理。忽略值为null的集合元素或Map条目比如Map里有个key对应的value是nullkey要不要保留很多教程只讲第一层导致项目里改了配置后反序列化行为也跟着变踩坑了才知道这几个层次需要单独控制。我在下文的每个章节里会把这几层分开讲因为Jackson对它们的控制机制确实不一样。2. 全局配置ObjectMapper的setSerializationInclusion到底改了什么先讲最直接、也最容易被搜索引擎推到前面的方案在ObjectMapper上做全局配置。ObjectMapper mapper new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);这段代码几乎出现在所有相关博客里但很多人抄完并不知道它背后改了什么更不知道它和setDefaultPropertyInclusion之间其实有细微差别。2.1 四种写法等价但风格不同我见到的全局配置写法至少有四种写法影响范围说明mapper.setSerializationInclusion(NON_NULL)序列化只影响序列化不碰反序列化mapper.setDefaultPropertyInclusion(NON_NULL)序列化反序列化同时影响序列化和反序列化的属性包含策略mapper.setSerializationInclusion(NON_NULL); mapper.setDeserializationInclusion(NON_NULL);序列化反序列化分开设置语义最明确spring.jackson.default-property-inclusionnon_null容器内全局生效Spring Boot配置文件写法作用于自动装配的ObjectMapper为什么同样一个NON_NULLset和setDefault会有区别因为Jackson的配置分两层SerializationConfig序列化配置和DeserializationConfig反序列化配置。setSerializationInclusion只改SerializationConfig而setDefaultPropertyInclusion改的是两个config共享的默认属性包含规则。如果只想控制序列化输出用第一种最安全如果希望反序列化时JSON里的null值也不要覆盖目标对象的已有字段用第二种或第三种。在我维护的项目里我更倾向于分开设置。因为有的接口需要接收第三方传来的null值来置空某个字段一旦全局把反序列化也设成NON_NULL所有DTO里的字段都失去了被置空的能力。这个坑特别隐蔽我在第6章还会再讲。2.2 Include枚举的每个选项都适合什么场景Jackson 2.x里JsonInclude.Include提供了几个可选值很多教程一笔带过实际用起来差别很大ALWAYS无论字段值是什么都输出这是默认行为等于什么都没配。NON_NULL非null才输出。最常用但不处理空字符串。NON_ABSENT在NON_NULL基础上额外处理Optional、AtomicReference等引用包装类型为空的情况。NON_EMPTY在NON_NULL基础上连空字符串、空数组、空集合、空Map都不输出。注意它不等同于非空才输出比如Optional.empty()也会被忽略。NON_DEFAULT字段值不等于默认值时才输出。比如int类型默认0boolean默认falseList默认为null或空集合这些默认值都不输出。USE_DEFAULTS不指定沿用上一级配置。我在做接口规范时推荐一个基本原则对外API的响应对象优先用NON_NULL查询列表或详情时如果某些字段对前端有有没有的语义区别再用NON_EMPTY。举个例子一个退货单对象的refundReason字段为null表示未申请退款为空字符串可能表示申请退款但没填原因如果一刀切用NON_EMPTY这两种语义就合并了前端没法区分。2.3 全局配置的副作用别让整个项目裸奔全局配置最大的问题就是影响全局这四个字。我见过一个兄弟项目架构师在公共配置里加了NON_NULL结果线上一个老接口原来返回{score:null}前端根据score ! null判断该用户未评分配置上线后字段直接消失前端取到undefined判断逻辑瞬间失效事后紧急回滚。所以当你决定动全局ObjectMapper时先想清楚三件事现有接口有哪些字段依赖返回null来表达语义内部服务之间的RPC JSON是否会把null字段当作一种状态传递日志、监控、Mock数据生成器是否按固定JSON结构做校验要是无法确认宁可先在DTO字段上用注解做局部控制也别一上来就改全局。第3章的注解方案就是用来做局部控制的。3. 局部精准控制JsonInclude的注解使用姿势全局配置适合整个项目约定一致的场景但现实里我们经常遇到大部分接口忽略null个别接口必须保留某个null字段的需求。这时候JsonInclude注解就是最趁手的工具。3.1 注解的三个作用位置和优先级JsonInclude可以放在类、字段、getter方法三个位置// 类级别整个User对象的null字段都不输出 JsonInclude(JsonInclude.Include.NON_NULL) public class User { private String name; // 字段级别这个字段特殊处理null也要输出 JsonInclude(JsonInclude.Include.ALWAYS) private String remark; // getter方法级别同样可以单独配置 JsonInclude(JsonInclude.Include.NON_NULL) public String getNickname() { return nickname; } }优先级从高到低是字段级别 getter方法级别 类级别。Jackson在构建序列化器时会先看字段上有没有注解再看getter最后落到类上。这意味着你可以在一个「类级别配了NON_NULL」的DTO里单独用JsonInclude(ALWAYS)把一个关键字段抢救回来。3.2 组合用法既要忽略null又要保留关键字段我做过一个比较典型的接口查询用户订单列表。列表场景下冗余字段越少越好所以类级别配了NON_NULL但有个extraInfo字段是预留给将来扩展的即使现在是null也要求接口输出extraInfo:null防止前端将来取不到字段直接崩溃。Data JsonInclude(JsonInclude.Include.NON_NULL) public class OrderListVO { private String orderId; private BigDecimal amount; JsonInclude(JsonInclude.Include.ALWAYS) private MapString, Object extraInfo; }这样配置后序列化结果是{ orderId: ORD20220011, amount: 99.00, extraInfo: null }同一个对象用同一套配置想忽略的忽略想保留的保留。这个组合用法比全局配置灵活得多也是我在实际项目里用得最多的方案。3.3 继承与多态场景下注解失效的坑JsonInclude注解有个让人意外的地方它没有被Inherited标记。也就是说父类上的JsonInclude注解不会自动继承给子类。假设你定义一个BaseResponseJsonInclude(JsonInclude.Include.NON_NULL) public class BaseResponse { private String code; private String message; }然后子类OrderResponse extends BaseResponse并且子类里新加了字段那么在序列化OrderResponse时只有子类自己声明的字段会走父类的注解默认值吗实际上不会。JsonInclude作为一个类级别的注解Jackson在初始化时读取的是运行时类上的注解。如果子类没有标注JsonIncludeJackson会退回到ObjectMapper的默认配置而不会自动使用父类上的NON_NULL。所以如果你有父类控制公共字段子类增加业务字段的这种继承结构请务必在每个子类上重新标注JsonInclude或者封装一个自定义注解用JacksonAnnotationsInside把JsonInclude组合进去Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) JacksonAnnotationsInside JsonInclude(JsonInclude.Include.NON_NULL) public interface ApiResponse { }这样子类或者字段上直接标注ApiResponse语义更清晰也不容易漏配。4. 特殊类型的边界Optional、空字符串、Map中的null忽略Null值最怕的就是以为配了NON_NULL就万事大吉结果被Optional.empty()、空字符串、Map里的null值狠狠教育了一顿。这些边界情况在真实项目里太常见了单独开一章讲。4.1 NON_ABSENT 到底是干嘛的很多教程提到NON_ABSENT时都一笔带过说它是NON_NULL基础上扩展。实际上它专门处理的是引用包装类型referent types——也就是Optional和AtomicReference这类容器。先看个直观例子public class QueryResult { private String data; private OptionalString extra; }默认情况下如果extra是Optional.empty()Jackson会把它序列化成extra:null。此时配NON_NULL这个字段会被忽略配NON_ABSENT同样会被忽略。看起来两者没什么区别。真正的区别在于Optional里包着一个null。比如OptionalString extra Optional.ofNullable(null);这种情况下Optional.ofNullable(null)实际上返回的是Optional.empty()所以结果还是null。NON_NULL和NON_ABSENT表现一致。但是如果你自己实现了一个类似Optional的包装类或者使用AtomicReferenceNON_NULL只判断外层引用是否为null而NON_ABSENT会通过ReferenceType内部的逻辑判断被包装的值是否为空。对于绝大多数项目直接用NON_NULL就够了如果你的代码里有大量OptionalT字段并且希望Optional.empty()和Optional.of(null)都不输出NON_ABSENT更保险。不过我的建议是DTO里尽量不要用Optional它是Java 8留给流式处理和函数式编程用的放在DTO里只会增加序列化和反序列化的心智负担。4.2 空字符串不是nullNON_EMPTY帮你兜底我曾经处理过一个很典型的假空问题。客户系统的地址字段允许传null也允许传空字符串。数据库里两种值都存在但前端只认null表示没有。当接口配了NON_NULL后空字符串照样会输出address:前端依然要做额外的address.length 0判断。如果业务上确实不需要区分null和空字符串可以直接把Include级别调整成NON_EMPTY。这时候空字符串、空数组、空集合、空Map都会被忽略JsonInclude(JsonInclude.Include.NON_EMPTY) public class AddressDTO { private String city; // 会被忽略 private String detail; // null 会被忽略 private ListString tags; // 空list 会被忽略 }但要注意两个坑NON_EMPTY对自定义类型的判断不是自动的。它依赖isEmpty()方法的识别Jackson默认对Collection、Map、String、数组这些常见类型有内置判断对自定义POJO如果没有isEmpty()方法它不会认为这个对象空。对Optional.empty()NON_EMPTY也会忽略因为Optional内部可以判断是否为空。所以是否要从NON_NULL升级到NON_EMPTY要去业务里核对一下空字符串是否有意义不要为了少几个字符就盲目升级。4.3 Map中的null值不会乖乖消失我遇到的最隐蔽的问题是Map字段的null值过滤。假设DTO里有一个MapString, Object attributes里面某个key对应的value是nullMapString, Object attributes new HashMap(); attributes.put(size, L); attributes.put(color, null);即使类上标注了JsonInclude(NON_NULL)序列化后color这个key依然会出现在JSON里值为null。为什么因为NON_NULL的作用范围是Bean属性而不是Map条目——Jackson序列化Map时是把每个key-value当作一个entry直接输出并不会进入Bean属性的null值判断逻辑。要过滤Map里的null value有几种思路在put之前自己判断value为null就不放进去最简单但侵入业务代码。全局注册自定义MapSerializer在序列化时drop掉null条目。这里给一个参考实现思路用BeanSerializerModifier替换Map的序列化器然后内部判断value null就continue。这个属于进阶玩法我在第5章详细讲。这里先把结论记住字段上的NON_NULL管不住Map里的value列表里的null元素它同样管不住。如果第三方对JSON里显式null很敏感这些边界得单独处理。5. 进阶方案为什么BeanSerializerModifier和FilterProvider要慎用网上关于Jackson忽略Null值的教程大部分止步于注解和全局配置。但到了真实项目里你会遇到更变态的需求某个接口要根据请求参数动态决定是否忽略null、某个Map字段也要过滤null条目、某个第三方SDK要求序列化时首字母不能输出null字段。这时候就得考虑自定义序列化方案了。5.1 BeanSerializerModifier的一个常见误解先辟个谣很多博客推荐用BeanSerializerModifier来修改序列化器说是可以移除null属性。实际上BeanSerializerModifier是静态修改BeanSerializer结构的它发生在序列化器构建阶段根本感知不到运行时某个字段的值是不是null。我在自己的项目里试过这种写法在modifySerializer里遍历beanProperties想找到值为null的字段然后移除——结果是编译期没问题运行时空指针满天飞。原因很简单BeanPropertyWriter包装的是属性定义不是属性值。你想在build阶段做值过滤方向就错了。那么BeanSerializerModifier真正能做什么它可以做结构性修改为某个类型替换一个全新的Serializer增加/删除/排序序列化属性给字段统一加前缀或后缀所以如果你的目标是把所有Map字段的null条目过滤掉可以通过BeanSerializerModifier把MapSerializer替换成自定义版本。这个思路是对的注意它替换的是整个类型的序列化器而不是在Bean属性级别做过滤。5.2 自定义PropertyFilter运行时值过滤的通用解法要实现在运行时判断字段值是否为null并决定是否输出标准做法是实现JsonSerializer或者在FilterProvider里做文章。比较通用的是自定义PropertyFilterpublic class NullAwareFilter extends SimpleBeanPropertyFilter { Override public void serializeAsField(Object pojo, JsonGenerator gen, SerializerProvider prov, PropertyWriter writer) throws Exception { // 通过反射拿到字段当前值 Object value writer.getMember().getValue(pojo); if (value ! null) { writer.serializeAsField(pojo, gen, prov); } } }然后在ObjectMapper上启用FilterProviderObjectMapper mapper new ObjectMapper(); mapper.setFilterProvider(new SimpleFilterProvider() .addFilter(nullAwareFilter, new NullAwareFilter()));需要过滤的类上加注解JsonFilter(nullAwareFilter) public class User { private String name; private String email; }这种做法有一个很明显的缺点通过反射拿值性能比BeanSerializer原生的getter调用要差一些而且如果字段是private且没有getterwriter.getMember().getValue()可能抛异常。所以我在生产环境中很少用这个方案来全局过滤null更多是用在动态决定某个字段是否忽略的场景。5.3 针对Map和List的null元素过滤方案再来解决第4章埋下的坑如何让Map里的null value、List里的null元素在序列化时被过滤掉。既然字段级别的NON_NULL管不住容器内部那就只能customize容器本身的序列化器。以Map为例继承MapSerializer太重了可以直接在一个自定义的JsonSerializerMap里做public class NullSkippingMapSerializer extends JsonSerializerMap?, ? { Override public void serialize(Map?, ? value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); for (Map.Entry?, ? entry : value.entrySet()) { if (entry.getValue() ! null) { gen.writeObjectField(String.valueOf(entry.getKey()), entry.getValue()); } } gen.writeEndObject(); } }然后在SimpleModule里注册专门用于某个字段类型SimpleModule module new SimpleModule(); module.addSerializer(Map.class, new NullSkippingMapSerializer());说实话这个需求我用到的次数很少因为绝大多数情况下我在组装Map之前就做好了null过滤。但是一旦你用Map来承载动态字段比如接口的extend字段这个方案就是救命的。注意如果你直接注册到Map.class会导致项目里所有Map字段的序列化都走这个逻辑需谨慎更好的做法是定义一个Map.class的子类或者用JsonSerialize(using...)只针对特定字段注册。6. 实战踩坑Spring Boot、Redis序列化下的null值问题理论讲完落到实际项目里坑最多的不是怎么配置而是配置完之后的连锁反应。这里分享三个我真实踩过的坑都会让你对忽略Null值有更立体的认知。6.1 Spring Boot中改ObjectMapper的正确姿势在Spring Boot项目中很多人的第一反应是自己在配置类里new一个ObjectMapper返回。我一开始也这么干过结果发现Spring容器里的Jackson2ObjectMapperBuilderCustomizer自定义项、JavaTimeModule、ParameterNamesModule全部失效LocalDateTime反序列化直接报错。正确做法有三种按推荐程度排序// 方式一配置文件简单覆盖大部分场景 spring.jackson.default-property-inclusionnon_null // 方式二自定义Jackson2ObjectMapperBuilderCustomizer推荐 Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializationInclusion(JsonInclude.Include.NON_NULL); // 这里还可以配时间格式等其他规则 builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); }; } } // 方式三完全用Primary替换ObjectMapper除非你确定自己在做什么否则别用方式二比方式一更值得推荐的原因在于它是在Spring Boot已有的ObjectMapper构建链路上追加配置不会破坏自动配置好的模块。我在配置完成后还会用一个单元测试来验证Test void testNullIgnored() throws Exception { ObjectMapper mapper new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); MapString, Object obj new HashMap(); obj.put(a, null); obj.put(b, value); String json mapper.writeValueAsString(obj); assertEquals({\b\:\value\}, json); }别小看这个测试很多人在Spring Boot里配了spring.jackson.default-property-inclusionnon_null却发现在某些接口里依然输出null大概率是因为那个接口手动构造了ObjectMapper绕过了自动配置。6.2 Redis序列化时null值消失引发的数据语义问题另一个高频踩坑场景是Redis缓存。用Jackson2JsonRedisSerializer存对象时如果实体类上配了NON_NULL存入Redis的JSON里就没有null字段读出来反序列化时缺失字段会变成Java对象字段声明的默认值null、0、false等。看起来没毛病但有两类问题一是无法区分字段不存在和字段值为默认值。比如一个优惠券对象usedTime字段为null表示未使用。配置忽略null后缓存里根本没有usedTime字段哪天你改造缓存结构反序列化时usedTime就是null依然能表达未使用。但如果业务里有一些字段是故意不缓存、靠数据库兜底的你从Redis读出来的对象可能看起来完整实际上关键字段全是null或0走到事务层才发现。二是反射和泛型推导会踩坑。部分框架在反序列化时通过Class.getDeclaredField(xxx)来校验字段是否存在字段缺失直接抛异常虽然Jackson本身不会这样但上层封装框架不一定按Jackson的规则来。我的建议是Redis缓存对象和接口返回对象务必分开不要共用同一个VO。缓存对象可以保留null字段用NON_NULL只影响接口出参或者干脆缓存不用JSON格式用JDK序列化、Kryo等二进制方案省掉JSON解析的CPU开销。很多同学为了少写两个类把一个VO用在接口、缓存、MQ消息等所有场景最后每改一次序列化配置就要惊呼一次怎么这里也变了。6.3 反序列化时配置不对称导致的严重事故最后这个坑是第2章提到的setSerializationInclusion和setDefaultPropertyInclusion区别的实战化。我维护的一个旧系统从.NET那边迁移过来对方传JSON里经常带显式null比如{userName:null,age:18}本意是把库里旧的userName清空。由于全局用了setDefaultPropertyInclusion(NON_NULL)反序列化时Jackson看到JSON里的userName:null直接跳过了这个属性Java对象里原来的userName仍然保留旧值数据库里那条记录的userName根本没被清空。这个bug排查了很久因为日志里看到的是DTO对象被正确构建但更新SQL那里用旧对象覆盖了userName。解决办法很简单把全局配置拆开mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 反序列化继续使用默认的ALWAYS即JSON里的null照常覆盖Java字段所以真的不要图省事一把梭。序列化和反序列化的空值处理策略是两个独立的维度配置时务必要分开想清楚接口出参要干净入参却可能需要接收null来表达清空/置空语义。7. 和Fastjson对比忽略Null这种需求两个库的哲学差异热搜词里有jackson和fastjson哪个好正好可以展开对比。虽然这个话题被聊烂了但我发现很多人忽略了一个最基础的差异——这两个库对null字段的默认策略几乎是对着干的。7.1 默认行为就是反的行为JacksonFastjson默认序列化null字段输出field:null默认不输出null字段需要输出null字段时默认配置下自动输出必须指定SerializerFeature.WriteMapNullValue忽略null字段的配置JsonInclude.Include.NON_NULL默认就是忽略无需配置控制空字符串NON_EMPTY通过SerializerFeature.WriteNullStringAsEmpty控制这个差异对忽略Null值这件事的影响很大。如果你从Fastjson切换到Jackson或者反过来很多代码看起来是升个版本实际null字段行为全变了前端可能收到多一倍的null字段或者之前能拿到的字段突然消失。Fastjson默认不输出null字段所以忽略Null这个需求在Fastjson世界里几乎是默认成立的但在Jackson的世界里你需要显式配置。我见过不少团队在迁移时踩了这个默认策略差异的坑最后都是在Jackson里配一个全局NON_NULL才模拟出Fastjson的默认行为。7.2 为什么我最终选择继续用Jackson抛开性能和生态不谈单从忽略Null值这个需求的工程化角度看Jackson的粒度控制明显更细也更规范Jackson用JsonInclude注解表达意图代码即文档看了就知道这个字段将来会不会出现在JSON里。Fastjson常年在类上用JSONField(serialzeFeatures SerializerFeature.WriteMapNullValue)这类配置可读性差一些而且serialzeFeatures拼错了历史包袱至今还挂在API上。从Spring Boot 2.x开始官方默认JSON库就是Jackson意味着你引入spring-boot-starter-web就不需要额外引入任何依赖Fastjson虽然也有starter但那属于第三方适配维护节奏和Spring版本绑定没有那么及时。当然Fastjson在复杂JSONPath提取、大JSON文本解析等场景确实有优势但在一个以Spring Boot为核心的团队里为了一个忽略Null值需求去混用两套JSON库完全不划算。我自己只在写脚本或做数据清洗时用Fastjson工程代码里一律Jackson。7.3 老项目里Fastjson过渡到Jackson的兼容技巧如果你的老项目正在从Fastjson迁移到Jackson并且希望最小化对前端的影响我建议先做这一步Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer fastjsonCompatCustomizer() { return builder - { // 模拟Fastjson默认不输出null字段的行为 builder.serializationInclusion(JsonInclude.Include.NON_NULL); // 如果老代码依赖Fastjson把空字符串序列化为 // builder.serializerByType(String.class, new StringSerializer()); }; } }不过我的真实建议是换就彻底换别做太长时间的兼容层。两套JSON库混跑最怕的就是同样的DTO在A接口用Jackson、B接口用Fastjson前端拿到两种风格完全不同的JSON排查问题时会怀疑人生。8. 最后说几个用得顺手的小习惯这个话题写到最后分享几个我在实际项目中沉淀下来的习惯算是个人的顺手小抄。第一DTO字段的null值策略尽量显式化。如果一个接口明确允许某个字段为null要么在字段上加JsonInclude(ALWAYS)要么在接口文档里重点标注。宁可多写几行注解也别让人靠猜。第二启动时加一个ObjectMapper的自检用例。我每次改动Jackson配置都会跑一个测试专门验证三类场景普通对象是否忽略null、Map里的null条目是否被处理、反序列化时null值是否按预期覆盖字段。这个测试成本极低收益极高。第三不要试图在全局把所有边界都安排得明明白白。Jackson的配置体系本身是分层的类注解覆盖全局配置、字段注解覆盖类注解、自定义序列化器兜底你越是想在全局把所有情况都处理掉后面就越容易被一个例外需求搞得焦头烂额。遇到特殊情况局部打补丁比全局改配置安全得多。第四团队约定优先于技术配置。如果你们的前后端对接是RESTful JSON最好在接口规范里明确空值字段不返回除非特殊说明。有了这条约定再去改代码就不会被这个null、那个空字符串的琐碎细节反复纠缠。Jackson的NON_NULL只是这个约束的技术实现真正的起点是团队一致认可的协议。