做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 List<String> 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_NULL:
ObjectMapper 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数值变成0,WriteNullBooleanAsFalse、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转空字符串”的内置开关,通常得写JsonSerializer<T>适配器自己处理。好处是逻辑更显式,坏处是代码量上去了。
2.4 三个库默认行为对比表
| 序列化库 | 默认是否输出null | 开启输出null的方式 | 常用配置 |
|---|---|---|---|
| Jackson | 输出 | 全局默认,需要手动排除 | JsonInclude.Include.NON_NULL、NON_EMPTY |
| Fastjson | 不输出 | SerializerFeature.WriteMapNullValue | WriteNullStringAsEmpty、WriteNullNumberAsZero |
| Gson | 不输出 | 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的Bean:
@Bean 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输出,用@JSONField:
public class UserVO { @JSONField(serialzeFeatures = SerializerFeature.WriteMapNullValue) private String couponCode; }Gson相对弱一些。它的字段级控制没有现成注解,最简单的方式是单独为这个字段写一个JsonSerializer,判断null时输出JsonNull:
public class CouponCodeSerializer implements JsonSerializer<String> { @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 JsonSerializer<String> { @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 JsonSerializer<String> { @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字段为null,JSON.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解析对比,字段变化一目了然,比肉眼扫字符串强太多。空字段这个小问题,处理好了没人夸你,处理不好前端、测试、运维全来问你。希望这篇能帮你省下几次半夜排查的时间。