
做Java后台的同学应该都有过这种经历同一个对象在不同接口里序列化出来的JSON字符串居然长得不一样——有的返回field:null有的干脆把整个字段都省略掉还有的会把null悄悄变成空字符串。空字符字段在Java对象序列化里的处理看着是个小问题真较起真来能牵扯出前端渲染、网络带宽、数据库语义、缓存体积、接口兼容性一整套事。这篇东西就围绕“Java对象里那些空着的字段到底该怎么序列化成JSON字符串”展开把我这些年用Jackson、Fastjson、Gson踩过的坑、总结出的方案一次性讲清楚适合后端开发、接口设计者、以及准备Java面试的同学参考。1. 空字段为什么值得单独拎出来讲1.1 null、空字符串、字段缺失不是一回事很多刚入行的同学以为“空”就是“空”其实在Java对象序列化成JSON字符串的过程中至少要区分三种状态字段值是null、字段值是空字符串、字段干脆不存在。public class OrderVO { private String orderId; // 订单号 private String buyerNote; // 买家备注可能为null private BigDecimal amount; // 金额 private ListString items; // 商品明细 private String couponCode; // 优惠券码可能为 }buyerNote null表示“这个字段没有被赋值”语义上是“未知”或“不适用”。couponCode 表示“用户确实查过优惠券但没有可用券”语义上是“明确为空”。序列化时如果整个字段不输出则消费端无法区分“没传”和“传了null”。三种状态在JSON里的表现和影响完全不同。null、、字段缺失这三者在Java里是三个对象在JSON里是三段不同的文本在数据库里也对应不同的存储策略。很多线上问题就出在“没把三者当回事”上。1.2 前端、后端、数据库对空字段的诉求经常打架前端最怕的是拿到一个对象后发现items是null然后用items.length直接报错为了安全前端只好层层判空。后端同学则希望JSON字符串尽量精简尤其接口并发量大的时候省一个field:null就是省几十字节。数据库那边更直接null和空字符串在SQL查询、索引命中、分组统计里的行为完全不同。我见过最典型的冲突场景是后端把null字段全部隐藏后前端拿到一个“缺胳膊少腿”的对象渲染页面时有些模块直接消失。后端觉得省了流量前端觉得接口契约不稳定。这时候就需要明确一个原则JSON字符串里输出的空字段应该由接口契约决定而不是由序列化工具的默认行为决定。2. 主流序列化库的默认行为Jackson、Fastjson、Gson各玩各的2.1 Jackson默认保留null需要显式排除Jackson是Spring Boot的默认序列化库默认行为是实体类里的null字段会原样输出成field:null。这是最“保险”的做法保证字段完整但也最啰嗦。ObjectMapper mapper new ObjectMapper(); OrderVO order new OrderVO(); order.setOrderId(2024001); // buyerNote和couponCode都没赋值 String json mapper.writeValueAsString(order); // {orderId:2024001,buyerNote:null,amount:null,items:null,couponCode:null}如果想去掉null字段用JsonInclude.Include.NON_NULLObjectMapper mapper new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); String json mapper.writeValueAsString(order); // {orderId:2024001}但注意NON_NULL只去掉null不会去掉空字符串。如果你还想把空字符串也去掉得用NON_EMPTY——不过NON_EMPTY对集合、Optional等类型也有影响用的时候要想清楚。2.2 Fastjson默认隐藏null需要主动开启Fastjson的行为恰好和Jackson相反。它默认不会序列化null字段想要输出null必须显式加上SerializerFeature.WriteMapNullValue。OrderVO order new OrderVO(); order.setOrderId(2024001); String json JSON.toJSONString(order); // {orderId:2024001}buyerNote等null字段直接消失 String jsonWithNull JSON.toJSONString(order, SerializerFeature.WriteMapNullValue); // {orderId:2024001,buyerNote:null,amount:null,items:null,couponCode:null}Fastjson还提供了一组“把null转成某种类型空值”的特性WriteNullStringAsEmpty会把null字符串变成WriteNullNumberAsZero会把null数值变成0WriteNullBooleanAsFalse、WriteNullListAsEmpty也是类似思路。这套设计本意是方便前端直接渲染但副作用是丢了“字段到底是null还是空”的信息使用前必须和前端确认清楚。2.3 Gson默认隐藏null但开起来的方式更简洁Gson的默认行为和Fastjson一样null字段不输出。想输出时调用serializeNulls()即可。Gson gson new GsonBuilder().serializeNulls().create(); String json gson.toJson(order); // {orderId:2024001,buyerNote:null,amount:null,items:null,couponCode:null}Gson没有像Fastjson那样提供“null转空字符串”的内置开关通常得写JsonSerializerT适配器自己处理。好处是逻辑更显式坏处是代码量上去了。2.4 三个库默认行为对比表序列化库默认是否输出null开启输出null的方式常用配置Jackson输出全局默认需要手动排除JsonInclude.Include.NON_NULL、NON_EMPTYFastjson不输出SerializerFeature.WriteMapNullValueWriteNullStringAsEmpty、WriteNullNumberAsZeroGson不输出GsonBuilder.serializeNulls()自定义JsonSerializer这张表建议收藏。实际项目里多序列化库并存时最怕的就是一个库藏null、一个库留null前端对接时一脸懵。我的建议是在项目里明确指定一个主序列化库并把空字段策略写进接口规范。3. 全局配置与字段级策略怎么搭配最顺手3.1 Spring Boot里的全局Jackson配置Spring Boot项目里最常见的需求是全局范围内所有接口返回的JSON字符串都不要输出null字段。配置方式有两种优先推荐在application.yml里配置spring: jackson: default-property-inclusion: non_null这种配置会被Spring MVC自动注入到MappingJackson2HttpMessageConverter里对Controller的返回值、ResponseBody、ResponseEntity全部生效。如果你用了RestControllerAdvice统一包装返回结果这块也仍然生效。第二种方式是自己定义ObjectMapper的BeanBean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return mapper; }但如果项目里还用了Spring Cloud、RedisTemplate、消息队列等组件它们可能持有自己的ObjectMapper实例。此时你定义的这个Bean未必会被所有组件共用容易埋坑。3.2 字段级控制全局NON_NULL个别字段必须返回null全局配置搞定后又会遇到新需求某个接口的某个字段即使为null也必须出现在JSON里。比如前端需要知道couponCode是否存在以此决定要不要展示“去领券”的按钮那couponCode:null就不能省略。用户信息对象可以这样处理public class UserVO { private String userId; JsonInclude(JsonInclude.Include.ALWAYS) private String couponCode; }JsonInclude(Include.ALWAYS)的优先级高于全局配置只要标注了字段不管是不是null都会输出。同一个类里还能混合使用大部分字段跟着全局走个别敏感字段独占规则。3.3 Fastjson和Gson的字段级配置Fastjson里对单个字段开启null输出用JSONFieldpublic class UserVO { JSONField(serialzeFeatures SerializerFeature.WriteMapNullValue) private String couponCode; }Gson相对弱一些。它的字段级控制没有现成注解最简单的方式是单独为这个字段写一个JsonSerializer判断null时输出JsonNullpublic class CouponCodeSerializer implements JsonSerializerString { Override public JsonElement serialize(String value, Type type, JsonSerializationContext context) { return value null ? JsonNull.INSTANCE : new JsonPrimitive(value); } }这么写虽然啰嗦但非常清晰而且可以借此实现更复杂的“null和空字符串区分”逻辑。3.4 配置优先级和常见坑字段级注解优先级高于全局配置这是大家最容易忽略的点。全局NON_NULL之后如果你给某个字段标了JsonInclude(ALWAYS)那这个字段的null还是会出来。还有另一个坑默认的ObjectMapper不会自动启用Java 8时间模块如果实体类里有LocalDateTime直接序列化会报错。需要在pom.xml里引入jackson-datatype-jsr310模块然后注册到ObjectMapper中mapper.registerModule(new JavaTimeModule());不然空字段的问题还没解决时间字段先炸了。4. 进阶让null和空字符串各归其位而不是简单一刀切4.1 业务上为什么要区分null和我做会员系统时遇到过这个场景用户头像avatarUrl字段null代表“从未设置过”前端显示默认头像代表“用户主动清空了头像”前端也要显示默认头像但后台运营需要知道用户操作过。如果序列化时简单把null排除或者把null都变成这个业务差异就丢了。所以正确的做法是用JSON的语义去对齐业务的语义null就是null空字符串就是空字符串两者各发各的。要实现这一点需要针对字符串类型做专门的序列化器。4.2 一个自定义序列化器的完整实现拿Jackson举例写一个“字符串null但还是要输出null字段”的序列化器public class NullStringSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null) { // 既不是排除字段也不是转成, 而是显式输出null gen.writeNull(); } else { gen.writeString(value); } } }把它用在一个按业务规则区分的字段上public class MemberVO { private String nickname; // 业务上需要区分没设置过和主动清空 JsonSerialize(using NullStringSerializer.class) private String avatarUrl; }序列化结果为{nickname:张三,avatarUrl:null}如果换成NON_NULL全局策略avatarUrl也会跟着消失而有了这个自定义序列化器这个字段就“穿越”了全局排除规则。这个玩法比JsonInclude(ALWAYS)更精确因为它还能额外做兜底逻辑比如null时输出未设置。4.3 把null字符串统一变成空字符串的反向场景也有些接口是给低代码平台或报表系统用的它们不喜欢JSON里出现null希望字符串字段只要为null就输出。这个需求用Fastjson一行就能实现String json JSON.toJSONString(order, SerializerFeature.WriteMapNullValue, SerializerFeature.WriteNullStringAsEmpty);Jackson则需要写一个“null转空字符串”的序列化器或者用JsonInclude(NON_NULL)配合字段默认值。我更推荐自定义因为可以在序列化器里控制哪些字段转换、哪些字段不转换。public class NullToEmptyStringSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeString(value null ? : value); } }这里有个细节如果全局已经不再输出null字段那么当字段为null时这个自定义序列化器根本不会被调用。所以要想让“null转成空字符串”真正生效全局配置必须允许null字段进入序列化流程或者用注解强制该字段输出。4.4 反序列化方向要同步考虑序列化只是单行道。如果你把null序列化成了那么在反序列化、存入数据库前要想清楚要不要转回null。很多业务表里avatarUrl是VARCHAR存和存NULL在SQL查询里的WHERE avatarUrl 和WHERE avatarUrl IS NULL完全不是一回事。所以我在项目里通常会写一对对称的序列化器和反序列化器序列化时null转反序列化时转null保持内存对象和数据库数据的语义一致。这个“对称处理”的原则在面试里聊到序列化时也很加分说明你真的理解JSON数据传输全链路而不只是会调一个API。5. 缓存、数据库、日志场景下的空字段实战5.1 Redis缓存里的序列化策略Redis缓存Java对象时我见过太多项目直接使用默认的JdkSerializationRedisSerializer结果缓存里全是带包名类名的二进制串不可读、也不通用。后来统一改成JSON字符串后又面临一个问题对象里有null字段到底存不存我的经验是缓存场景尽量压缩体积用NON_NULL策略让null字段不写入Redis。原因很简单缓存里读出来的对象是为了快速重建数据null字段的语义在反序列化后依然可以通过getter判断不差一个JSON键。而且Redis内存是宝贵的一个订单对象几十个字段一半是null能省下不少空间。Fastjson配合Redis时我习惯这样写String cacheJson JSON.toJSONString(order, SerializerFeature.WriteMapNullValue, SerializerFeature.WriteNullStringAsEmpty);等等缓存里到底保不保留null如果只是给内部服务读建议直接用不含null的版本如果缓存数据要直接返回给前端那就得跟前端约定好字段策略别缓存一套、接口一套前端拿到的数据时有时无。5.2 和MyBatis-Plus实体类对接时的空值陷阱很多项目的实体类既是数据库映射又是接口返回对象。MyBatis-Plus默认的更新策略是NOT_NULL也就是updateById时实体的null字段不会生成到SET子句里。这个机制和“JSON要不要输出null字段”完全是两码事但经常被搞混。比如一个User实体remark字段为nullJSON.toJSONString(user)默认就不输出remark你可能以为数据库里这个字段也“没更新”。实际上MyBatis-Plus照样会更新非null的其他字段而remark保持数据库原值。反过来如果你想把remark在数据库里也清成null直接用实体set一个还不满足NOT_NULL策略得用UpdateWrapper.set(remark, null)才行。所以序列化层和持久层要分开治理JSON输出策略服务于接口契约数据库字段策略服务于数据变更。两者不要混着调。5.3 对象数组去重序列化方式决定比较结果有一次我要对两个订单列表合并去重图省事直接把对象转成JSON字符串后放进HashSet。结果发现同一笔订单一个来源把buyerNote序列化成了另一个来源把buyerNote省略了两条JSON字符串不一样去重失败。解决思路有两种。一是去重前先把所有字段的null和空串统一成同一种状态再序列化比较二是用Jackson的JsonNode解析后按规范化的结构比较。我后来更倾向于第二种ObjectMapper mapper new ObjectMapper(); JsonNode node1 mapper.readTree(json1); JsonNode node2 mapper.readTree(json2); boolean same node1.equals(node2);JsonNode.equals在Jackson内部是按节点字段和值比较的但这里要注意null节点和省略字段在JsonNode里依然不等。想让它们等可以在比较前对节点的null值做归一化。这个坑比较隐蔽一旦线上出现“莫名去不掉重复数据”多半就是对象里null和空串的差异在捣鬼。5.4 反序列化安全的底线提醒谈序列化必然要谈反序列化安全。使用Fastjson这类带autoType机制的库时如果反序列化的JSON字符串来源不受控存在被构造恶意数据触发安全问题的风险。我的实操底线是不反序列化不可信来源的JSON并且尽量不开启自动类型或者用白名单方式只允许特定包名被反序列化。这个问题不是纸上谈兵真的有人在生产环境因为盲目开启了自动类型吃了大亏。安全无小事序列化的另一半——反序列化也必须控制在可靠范围内。6. 最后想收尾的经验之谈做Java这么多年序列化相关的坑遇到太多了。关于空字符字段序列化成JSON字符串我最后总结几句掏心窝的话。不要指望用一种配置解决所有场景。接口返回、Redis缓存、日志输出、Kafka消息它们的消费端不同对null字段的容忍度完全不一样。接口里前端需要字段稳定缓存里需要体积精简日志里需要字段完整方便排查——所以要分层配置。我把顺序固定下来用起来很顺手先全局排除null再在确实需要null的字段上加注解遇到个别业务要null和空串分开的再写自定义序列化器。这个顺序从通用到特殊不会让全局配置和局部逻辑互相打架排查起来也最省心。另外每次改完序列化策略一定要跑一下接口测试直接看JSON字符串对比。我习惯把改动前后的两个JSON用JsonNode解析对比字段变化一目了然比肉眼扫字符串强太多。空字段这个小问题处理好了没人夸你处理不好前端、测试、运维全来问你。希望这篇能帮你省下几次半夜排查的时间。