不想再看到前后端因为字段名撕逼的场面?不想让接口返回里塞满一堆没用的null和空数组?@JsonProperty和@JsonInclude这俩注解就是干这个用的。做 Java 后端,天天跟 Jackson 打交道,这两个注解是绕不过去的核心工具。一个管“字段叫什么名字”,一个管“字段什么时候出现”,搞懂了它们,你写出来的接口不光干净,还能省掉不少跟前端对字段名的口水战。
这篇文章不整虚的,直接把这两个注解的参数、坑、配合使用场景全部扒开讲透。你用 Spring Boot 也好,纯 Java 项目也罢,只要序列化走的是 Jackson,内容都通用。看完你至少能解决这几个问题:Java 字段和 JSON 名字对不上怎么搞?反序列化时如何兼容多个不同的字段名?null、空字符串、空集合到底该不该返回?全局配置和局部注解打架了听谁的?
1. 先从序列化和反序列化说起
很多人用这两个注解,只知道“加上就行”,但不知道为什么加、什么时候加、加了为什么会生效。在进入注解细节之前,非常有必要把 Jackson 处理 JSON 的底层逻辑捋一遍。
Jackson 把 Java 对象转成 JSON 的过程叫做序列化。这个过程的本质,是 Jackson 去“读取”一个 Java 对象的状态,然后按照自己的规则把状态写成 JSON 字符串。它读什么?优先读 getter(getXxx()或isXxx()),没有 getter 就找字段,再不行就找 setter 或者构造器参数。反过来,把 JSON 字符串变成 Java 对象叫做反序列化,Jackson 优先通过 setter 或者带参构造器去赋值,没有就把值直接映射到字段上。
这里面的关键点在于,Jackson 默认使用Java 字段名作为 JSON 的 key。比如你有个字段叫lastLoginTime,序列化出来 JSON 里就是"lastLoginTime"。这本身没问题,问题出在现实世界里。前端那边可能管这个字段叫last_login_time,另一个老接口可能叫lastLogin,第三方平台给你返回的可能是LAST_LOGIN_TIME。字段名一不对齐,数据就接不上。@JsonProperty就是那个“对齐工具”,它能在序列化和反序列化时,让 Java 字段名和 JSON 字段名脱离绑定关系,各叫各的。
再说序列化时 Jackson 输出哪些字段。默认情况下,只要你这个对象有值,哪怕是null,Jackson 也会把null原样输出。字段多的时候,JSON 会变得特别冗长,前端还要在代码里不断地判!= null。@JsonInclude的作用就是给字段设置“出场门槛”,比如null不输出,空集合不输出,默认值不输出。这两个注解,一个解决“名字对不对得上”,一个解决“内容好不好看”,在实际项目中常常是配合使用的。
2.@JsonProperty的核心功能与应用细节
@JsonProperty是com.fasterxml.jackson.annotation包下的注解,它可以标注在字段、getter、setter 或者构造器参数上。下面把它的几个核心参数分开讲,每个参数会有对应的代码示例。
2.1 用value属性重命名字段名
这是最常见的用法。假设有个用户对象,Java 字段叫realName,但接口文档里要求返回的是name。
public class UserInfo { @JsonProperty("name") private String realName; @JsonProperty("userId") private Long id; private Integer age; }序列化之后,JSON 长这样:
{ "name" : "张三", "userId" : 1001, "age" : 28 }反序列化时同样有效。前端给你传一个{"name":"李四","userId":1002},Jackson 也能正确地把name的值塞进realName字段、userId的值塞进id字段。
这个注解还有个隐藏功能:当字段是private且类上没有@JsonAutoDetect时,给字段加@JsonProperty也能强制让 Jackson 认这个字段。相当于你给了 Jackson 一个明确的指示:这个字段是要参与序列化的。有些老项目里不喜欢写 getter/setter,用这个注解就能把需要暴露的字段显式标记出来。
实操中的注意事项:
value属性不要用奇怪的字符。一般用驼峰、下划线或短横线,前端能接受就行。不要用空格、引号这类非 JSON 安全字符。- 类名上如果配了
@JsonNaming策略,比如SnakeCaseStrategy,这时候手动加的@JsonProperty("xxx")优先级更高,手动指定的名字不会被全局命名策略覆盖。
2.2 用alias属性兼容多个历史字段名
有些老系统迭代了很多年,同一个含义的字段在不同版本接口里有不同叫法。比如之前叫phone,后来升级成了mobile,但还有老客户端在传phone。反序列化时你只写一个@JsonProperty只能认一个名字,这时候就该用alias。
public class UserContact { @JsonProperty("mobile") @JsonAlias({"phone", "telephone"}) private String mobile; }这样传mobile、phone、telephone任意一个都能映射到mobile字段上。注意一点:alias只在反序列化时生效,序列化时永远只会输出value指定的名字。这很合理,总不能让人家接口返回三个一样的字段吧。
有个小陷阱要提醒:如果同一个 JSON 里同时出现了mobile和phone两个字段,Jackson 会以最后一个被解析到的为准。代码逻辑里千万别写出“JSON 里同时传两个字段,我取非空的那个”这种不靠谱的依赖。
2.3 用access属性控制读写权限
某些场景下,字段只允许输出,不允许输入。比如用户表的createTime创建时间,一般是服务端自己生成的,前端传什么都没用;再比如一些内部状态字段,服务端要返回给前端看,但绝不能接受前端传进来的值。这时候用access来限制读写方向。
public class OrderInfo { @JsonProperty(value = "orderId", access = JsonProperty.Access.READ_ONLY) private String orderId; @JsonProperty(value = "userRemark", access = JsonProperty.Access.WRITE_ONLY) private String remark; }READ_ONLY的意思是:这个字段只能“读”(序列化输出),不能“写”(反序列化接收)。前端往你接口传orderId,会被忽略掉,这样能防止用户篡改订单号。WRITE_ONLY相反,只能接收前端传值,序列化时不输出。某些敏感字段比如内部备注、内部处理人,就可以用WRITE_ONLY保证不出现在接口返回里。
有一种情况需要特别小心:如果一个字段你既想让它输出,又不想让它接收,用READ_ONLY没问题。但有少数开发者会把READ_ONLY和setter 缺失搞混,以为加了READ_ONLY之后传给这个字段的值就不报错了。实际上如果 JSON 里存在未知字段,能否忽略不报错取决于全局的FAIL_ON_UNKNOWN_PROPERTIES配置,和@JsonProperty没有直接关系。
2.4 用index和required控制顺序与必填
index属性可以控制字段在序列化输出时的顺序,数字小的先输出。虽然这在日常开发中用得比较少,但如果你有“字段顺序影响签名校验”或者“需要按固定顺序输出给老旧系统”的需求,它就有用了。
public class ApiResponse { @JsonProperty(value = "code", index = 1) private int code; @JsonProperty(value = "msg", index = 2) private String msg; @JsonProperty(value = "data", index = 3) private Object data; }required属性我在实际项目中几乎不用,它并不是强制校验字段有没有传,而只是在反序列化时如果没有这个字段就报一个更明确的错误信息。字段是否必填,应该在业务代码里做校验,不能指望一个注解帮你挡掉所有非法请求。
3.@JsonInclude的过滤策略与使用场景
@JsonInclude解决的核心问题是:序列化时,什么条件下的字段要被输出,什么条件下要被忽略。它能标在类上,也能标在具体字段上。
3.1 五个常用 Include 策略逐个说
Include.ALWAYS:无论字段什么值,都输出。这就是默认行为,null也输出。Include.NON_NULL:字段为null时不输出。Include.NON_EMPTY:字段为null、空字符串、空集合、空数组、空 Optional 时都不输出。Include.NON_DEFAULT:字段值等于 Java 类型的默认值时(比如int的 0、boolean的false、引用类型的null)不输出。Include.USE_DEFAULT:比较少用,表示按全局默认配置来。
注意:如果你用的是
Include.NON_DEFAULT,对int类型字段来说,值为0就不会被输出。前端如果恰好需要判断“数量是否为 0”,把 0 丢掉可能引发业务判断问题。
@JsonInclude(JsonInclude.Include.NON_NULL) public class Product { private String skuId; private String name; private String description; }如果description是null,输出结果只有skuId和name。这个策略在接口返回里最常用,可以大大压缩 JSON 体积,也减少前端判空的负担。
NON_EMPTY比NON_NULL更激进一点,它把空字符串也过滤掉了。有些前端框架对“字段不存在”和“字段为空串”的处理逻辑不一样,你可能需要保留空串,那就慎用NON_EMPTY。我自己做过一个对接老系统的项目,对方要求每个字段都必须返回,哪怕为空,这时候用INCLUDE.ALWAYS反而更合适。所以选哪个策略,要看你接口的消费端是什么风格,不能一刀切。
3.2 注解放在字段上还是类上,效果不一样
类上加了@JsonInclude,对类里所有字段生效。单个字段上再加,字段级覆盖类级。
@JsonInclude(JsonInclude.Include.NON_NULL) public class UserVO { private Long id; private String name; @JsonInclude(JsonInclude.Include.ALWAYS) private String nickname; }类级要求所有null都不输出,但是nickname这个字段强制输出,哪怕它是null。这种场景多用在“某些字段前端必须有值,哪怕你给个 null 占位也行”的接口设计中。字段级注解的优先级高于类级,这条规则要记牢。
还有一点容易忽略:@JsonInclude可以用在Map 类型字段的 value 上,用的是content属性。比如你有一个Map<String, Object>字段,希望 map 中 value 为 null 的那些键值对不要输出,写法是这样的:
public class ExtInfo { @JsonInclude(value = JsonInclude.Include.NON_NULL, content = JsonInclude.Include.NON_NULL) private Map<String, Object> properties; }不加content的话,@JsonInclude只对 Map 本身这个字段生效(比如 Map 为 null 时不输出),不会管 Map 内部的键值对。加了content = Include.NON_NULL之后,value 为 null 的键值对会被过滤掉。这个细节很多人不知道,处理动态扩展字段时能帮你挡掉不少坑。
3.3 全局配置与注解配置的覆盖关系
如果你用的是 Spring Boot,完全可以在配置文件里给全局的 Jackson 设置一个默认的non-null策略,这样不用在每个类上都累赘地加注解。
spring: jackson: default-property-inclusion: non_null全局配置生效后,所有接口返回值里为null的字段都会被忽略。但如果某个类上明确写了@JsonInclude(JsonInclude.Include.ALWAYS)或者某个字段级别写了其他值,注解会覆盖全局配置。
如果用的是非 Spring 项目,手动构建ObjectMapper时也可以设置:
ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);优先级从高到低:字段级注解 > 类级注解 > ObjectMapper 全局配置 > 默认行为。这四条链路的优先级必须记牢,否则排查“明明全局配了 NON_NULL,为什么会话里冒出大量 null”这类问题时,会绕很大的弯子。
4. 这两个注解组合使用的经典实战场景
单独理解每个注解容易,难的是在真实项目中知道该在哪个位置组合使用。这一节列举我在实际项目中遇到的几类典型场景,每个都能直接照着改。
4.1 场景一:对外 VO 重命名字段并过滤无意义值
后端有个内部领域模型,字段名是accountBalance,含义是“账户余额”,前端协议里叫balance。同时有些内部字段比如deletedFlag不应该出现在对外接口里,而memo为 null 时也不需要输出。
@JsonInclude(JsonInclude.Include.NON_NULL) public class AccountVO { @JsonProperty("balance") private BigDecimal accountBalance; @JsonProperty(value = "internalStatus", access = JsonProperty.Access.READ_ONLY) private Integer deletedFlag; private String memo; }这个组合很典型:@JsonProperty负责对外改名,让内部字段结构和协议结构各干各的互不影响;@JsonInclude负责把所有为null的可选项全部丢掉;access负责把敏感字段做成只输出不可接收。这类 VO 建议放在vo或dto包里,不要直接拿实体类做接口返回,不然改字段名的时候会带出一堆连锁问题。
4.2 场景二:对接第三方回调时兼容多种字段名
第三方平台的回调消息里,同一个业务 ID 在不同环境下叫法不同。有叫orderNo的,有叫outTradeNo的,还有叫bizOrderId的。你在反序列化时想要一个 Java 字段搞定所有情况。
public class CallbackPayload { @JsonProperty("orderNo") @JsonAlias({"outTradeNo", "bizOrderId"}) private String orderNo; @JsonProperty(value = "notifyType", access = JsonProperty.Access.WRITE_ONLY) private String notifyType; }value指定了你期望的标准名字,alias则把历史遗留的其他名字也一并兼容。notifyType用WRITE_ONLY可以只在接收时生效,不会在你主动给第三方发消息时把这个字段带出去,避免产生循环引用或多余的上下文字段。
对接外部系统时,还有一种常见问题:JSON 里某个字段在 Java 对象中不存在,默认 Jackson 会抛异常。要兼容这种情况,你得在全局配置里关掉FAIL_ON_UNKNOWN_PROPERTIES,或者直接在类上加@JsonIgnoreProperties(ignoreUnknown = true)。这和@JsonProperty是两码事,但经常在同一个场景里被提到,顺带说一句防止踩坑。
4.3 场景三:校验字段是否真的“被忽略”
很多人以为@JsonProperty(access = Access.READ_ONLY)是把该字段从序列化里拿掉,其实不是。它是“可以输出,但不能接收”。真正要从序列化里彻底拿掉的注解是@JsonIgnore,二者经常被搞混。
public class InternalData { @JsonProperty(value = "secretKey", access = JsonProperty.Access.WRITE_ONLY) private String secretKey; @JsonIgnore private String innerTrace; }secretKey能接收前端传入的值,但响应里永远看不到。innerTrace则完全从 JSON 序列化和反序列化中消失。你要做的判断标准是:字段是“不想给别人看”(用WRITE_ONLY或者@JsonIgnore),还是“别人传了也不该影响我”(用READ_ONLY)。两者方向完全不同。
5. 常见问题排查与排障技巧
这两个注解的参数倒是不复杂,但真正到项目里出问题的时候,总会冒出一些奇怪的状况。下面整理几个我见过的高频问题,每个都会有排查思路。
5.1 注解没生效,序列化还是按字段原名走
这种情况多半不是注解写错了,而是类没有被 Jackson 识别。如果类上没有公开的 getter/setter,字段是private,而类上又没有@JsonAutoDetect时,Jackson 默认不会去碰这些字段。虽然你把@JsonProperty标上去了,但如果注解只标在 setter 上而 getter 没标,序列化时仍然按 getter 推导的名字输出。
排查步骤:先看这个类的字段是不是private,再看有没有 getter/setter,最后确认注解是标在字段上还是方法上。我的建议是统一标在字段上,这样无论序列化还是反序列化都走同一个规则,不容易出现“输出对但接收不对”的割裂情况。
还要检查项目里是不是有多个ObjectMapper实例。Spring Boot 默认的ObjectMapper是经过自动配置的,但如果你在代码里自己new了一个ObjectMapper并且用它来序列化,注解依然有效,因为注解是 Jackson 内置处理的。这种情况问题多半出在类加载或方法可见性上,而不是注解本身。
5.2 反序列化时报 unknown property 错误
这种错误通常和@JsonProperty没关系,而是 JSON 里出现了 Java 对象中不存在的字段。比如你定义了一个UserDTO只有name和age,却收到了{"name":"张三","age":18,"gender":"男"}。Jackson 默认遇到未知字段会报UnrecognizedPropertyException。
解决办法有三个,按项目情况选:
- 在类上加
@JsonIgnoreProperties(ignoreUnknown = true),忽略当前类未知字段。 - 在全局
ObjectMapper上配置:
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);- 在 Spring Boot 的 yaml 里统一关闭:
spring: jackson: deserialization: fail-on-unknown-properties: false有一点要说清楚:如果 JSON 里的字段名和你期望的 Java 字段名对不上,比如 JSON 里是user_name,Java 字段是userName,这不属于 unknown property,而是名字不匹配。你需要用@JsonProperty("user_name")来映射,或者用全局的PropertyNamingStrategy.SNAKE_CASE统一处理。
5.3 加了@JsonInclude(NON_EMPTY)但空字符串还在输出
先说结论:如果你确认自己用的是NON_EMPTY,空字符串一定会被过滤掉。如果还在输出,首先要查你是不是把注解放错了地方,比如你想过滤 Map 内部的空 value,却只对了 Map 字段本身设置NON_EMPTY,而 Map 里的 value 仍然是"",那它当然还会出现。
真正需要检查的几个点:
- 字段值是不是空字符串,注意区分
" "这种空格字符串,它不是""。 - 字段类型是不是自定义类型,
NON_EMPTY对自定义类型的判定没有那么乐观,必要时需要自己给该类型实现isEmpty()逻辑。 - 如果你用的是
NON_NULL,那空字符串本来就还会输出,这不是 bug,是你策略选错了。
5.4 枚举类型的序列化控制
枚举类型是@JsonProperty在实战中很容易被忽略的使用场景。假设你有一个订单状态枚举:
public enum OrderStatus { @JsonProperty("pending") PENDING, @JsonProperty("paid") PAID, @JsonProperty("cancelled") CANCELLED }不写@JsonProperty时,这个枚举序列化出来是"PENDING"、"PAID"这种大写字母名,前端通常不喜欢这种风格。加上注解之后,输出就变成了"pending"这种更容易符合接口约定的风格,反序列化时前端传"paid"也能正确转成PAID。
如果你需要更灵活的反序列化逻辑,比如前端传1代表PAID、传2代表CANCELLED,那就不能用@JsonProperty了,你要在枚举里写@JsonCreator工厂方法。这里当个扩展知识点:@JsonProperty解决“名字对不上”,@JsonCreator解决“值要做转换”。
5.5 长期维护项目中的命名策略默认值
如果你的项目组统一用下划线风格定义接口字段,并且在全局配置里设置了spring.jackson.property-naming-strategy: SNAKE_CASE,那类里的userName会自动输出为user_name。这种全局命名策略在团队协作里很省心。
但它也有副作用:你如果某个字段必须要输出为特定名字,比如uId,而全局策略会让你变成u_id,这时候唯一的拯救办法就是手动在字段上加@JsonProperty("uId")。优先级规则刚才讲过:显式注解永远高于全局命名策略。
这种场景在大型系统中很常见,因为一部分老接口已经定死了字段名,另一部分新接口在按新规范走。需要灵活兼容时,全局策略加上局部手动覆盖,这一套组合是最稳定的。
6. 结合 Spring Boot 的全局配置与性能影响
最后补充一下和 Spring Boot 整合时容易忽视的配置细节,以及这两个注解对序列化性能有没有影响。
6.1 配置文件级别统一控制
@JsonInclude的全局配置在 Spring Boot 里有两种写法,一种在 yaml 里:
spring: jackson: default-property-inclusion: non_null另一种是用Jackson2ObjectMapperBuilderCustomizer在代码里做定制:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.serializationInclusion(JsonInclude.Include.NON_NULL); builder.failOnUnknownProperties(false); }; } }第二种方式更灵活,因为它除了serializationInclusion以外,还能配置很多其他 Jackson 属性,适合团队里统一管理序列化行为。大型项目我更推荐这种方式,因为所有 Jackson 的全局配置都有迹可循,不用在 yaml 里翻来翻去地找。
6.2 对性能的影响到底有多大
很多人担心加了@JsonInclude之后,因为要做空值判断,会影响序列化性能。实际上 Jackson 对@JsonInclude的处理是在生成序列化器时就已经确定好的,每个字段的过滤逻辑会被编译进对应序列化器里,运行时的额外开销几乎可以忽略。
相比之下,真正影响性能的反而是重量级的自定义Serializer或者无脑地在 getter 里写复杂逻辑。@JsonProperty和@JsonInclude在框架内部都有成熟的优化路径,放心用即可。真正要关注的是这样一件事:过度使用NON_EMPTY会不会把本应该让前端感知的字段状态给吞了。如果部分字段是“有值就返、没值也要返个 null 占位”的语义,那过滤掉 null 可能引发前端渲染异常。性能不用愁,业务语义才是你选策略时要深思的。
7. 我在实际项目中踩过的几个坑
前几年做过一个对外提供查询服务的系统,要求响应里所有字段都不能出现null。当时图省事,在全局配置里直接设了default-property-inclusion: non_null,结果前端反馈说某个“是否会员”的字段消失了,因为他们判断字段名是否存在的代码风格是if ("memberFlag" in res)。字段被过滤之后,memberFlag不存在,他们走了 else 分支。原来是前端认为“字段存在但值为 null”和“字段不存在”是两种语义。从那次以后我就明白了,过滤策略不是越激进越好,要看消费端的判断习惯。
还有一个和@JsonProperty相关的教训。某次对接外部厂商的回调,对方文档里写的是userID,我们实体里写的是userId,当时图快直接在 getter 上加了一个@JsonProperty("userID"),结果序列化没问题,反序列化时因为 setter 上没有对应的注解,又出了名字匹配不了的问题。后来统一改成把注解放在字段上,一套逻辑两边生效,再也没有这种“输出正常、接收报错”的割裂问题。
把一个项目里所有接口的字段命名、空值策略统一梳理清楚,比盲目堆注解有用得多。我现在的习惯是先和前端约定一份接口字段规范,能覆盖 80% 的常规场景;剩下的特殊场景才用@JsonProperty打补丁。这样既不会注解满天飞,也能在出现冲突时快速定位问题。希望这篇解析能让你少踩几个我当年踩过的坑。