Jackson实战:@JsonProperty与@JsonInclude全解析
做后端开发的朋友一定被这类问题纠缠过接口文档上明明写着userName前端偏要传username数据库里存得好好的值返回给App的时候突然变成了null更有甚者一个本该只有十几个字段的对象序列化出来却带着几十个空荡荡的key把报文撑得又大又脏。这些问题的根源大多绕不开Java生态里最常用的JSON处理库Jackson以及它身上两个极易被轻视的注解——JsonProperty和JsonInclude。这两个注解在网上随便一搜都是零散片段你抄一行我贴一段很少有人把它们的边界、组合行为、失效场景讲透。我在项目里用这两个注解踩过不少坑也帮同事排查过几回诡异的JSON输出问题这篇就结合真实场景把JsonProperty和JsonInclude从底层原理到实战搭配完整梳理一遍。适合正在做接口开发、接手老项目维护、或者刚把Jackson引入项目的同学阅读看完你至少能少加三天班。1. 先分清它们各自管的活字段映射与内容过滤很多初学者会把这两个注解当成可以互相替代的东西实际上它们负责的是完全不同的两个环节。JsonProperty管的是怎么映射JsonInclude管的是哪些字段要输出。先把这个认知建立起来后面的一切才不会乱。1.1 字段映射问题是怎么产生的Java类的属性命名规范和JSON字段命名规范之间存在天然鸿沟。Java偏向驼峰命名比如userName、phoneNumber而前端、App端、第三方接口往往习惯下划线风格比如user_name、phone_number。如果接口对接方不配合你的命名习惯你又不能为了一个字段去改数据库映射实体这时候JsonProperty就是最直接的解决方案。另外还有一种常见情况类里某个属性叫isDelete因为JavaBean规范里布尔类型的getter会变成isDelete()Jackson默认通过getter推断序列化字段名时会把它变成delete而不是isDelete。这种你以为字段叫AJackson认为它叫B的认知错位只有真正跑到线上看到报文才反应过来。JsonProperty就是用来纠正这种错位的锚点。1.2 内容过滤问题是怎么产生的内容过滤问题则出现在序列化输出阶段。一个对象里有20个字段其中12个是业务上允许为空的如果全部输出每个字段都要占几个字节的key名称数据量小的时候无所谓接口被反复调用、报文累积起来就是实打实的带宽浪费。更麻烦的是给前端带来了无谓的判空成本。前端拿到一个null字段要用v ! null || v ! undefined去兜底拿到一个缺失的字段又要考虑对象结构是否完整。很多协作摩擦其实都是后端把一个完整的空对象结构抛给了前端造成的。JsonInclude的核心价值就是让开发者按需决定空值到底要不要出现在JSON里把脏数据挡在序列化之前。这两个注解虽然经常在一个类里同时出现但它们没有任何上下级或者依赖关系。JsonProperty改的是名字JsonInclude管的是去留。搞清楚分工之后我们来逐个拆解。2. JsonProperty 使用细节改名、双向绑定与失效陷阱JsonProperty最常见的用法是直接挂在字段上括号里写一个字符串表示序列化和反序列化时这个字段在JSON里应该叫什么名字。我最初以为它就是简单改个名深入用了才发现它在不同位置的生效逻辑、对访问权限的控制、以及和Lombok配合时的微妙关系每一项都值得单独说道。2.1 字段改名与JSON数据格式的双向映射先看一个最典型的例子。某个内部系统的用户实体类长这样public class UserInfo { JsonProperty(user_name) private String userName; JsonProperty(phone_number) private String phoneNumber; }当后端把这个对象返回给前端时Jackson序列化出的JSON是{ user_name: 张三, phone_number: 13800138000 }当前端把JSON传回来给后端反序列化时user_name这个key也能正确落回userName属性。这里有个细节容易被忽视JsonProperty同时作用于序列化和反序列化两个方向。也就是说它不仅告诉你出去的时候叫什么还告诉你进来的时候从哪里取值。这种双向绑定在某些场景下非常有用比如对接一个老系统的接口对方用的是createDate而你实体里叫createTime。你在字段上写好映射两边互不迁就问题就解决了。不过也正是因为双向绑定它和另一个注解JsonAlias容易混淆。JsonAlias只负责反序列化时的别名接收序列化时并不会把字段名改成别名。public class Task { JsonProperty(task_name) private String taskName; JsonAlias(aliasName) private String extraName; }上面这段代码里taskName序列化出去是task_name反序列化时也只认task_name而extraName如果有JsonAlias(aliasName)反序列化时既能接收extraName也能接收aliasName但序列化出去依旧叫extraName。这两者的差别如果你没搞清很容易出现改了一个注解结果接口返回字段全变了的事故。2.2 access参数控制读写通道JsonProperty还有一个不太起眼但非常实用的参数access。它接受一个Access枚举值主要包含READ_ONLY、WRITE_ONLY、ALWAYS和READ_WRITE。默认是ALWAYS也就是读写都生效。READ_ONLY表示这个字段只能被序列化输出反序列化时不接收对应JSON值。听起来有点反直觉但实际场景相当常见比如用户ID是后端生成的前端传什么ID我们都不该信那就应该在userId字段上标记JsonProperty(access Access.READ_ONLY)这样就算请求体里带了userIdJackson也会自动忽略。WRITE_ONLY则反其道而行之只接收反序列化不输出到响应。典型场景是密码、手机号这类的只输入不输出字段public class LoginRequest { JsonProperty(value login_name, access Access.WRITE_ONLY) private String loginName; JsonProperty(access Access.WRITE_ONLY) private String password; }这样处理之后密码永远不会出现在任何日志或接口返回里但又能正常接收前端传过来的值。如果你正在做一个登录接口却看到密码出现在响应报文里第一反应就该去检查这个字段是不是没有加WRITE_ONLY。2.3 常见失效场景Lombok与方法级注解用JsonProperty最容易踩的第一个坑是IDE在编译时自动生成了一些额外的方法或字段导致注解没有作用在Jackson真正读取的那个属性上。比如在类里同时存在一个成员变量name以及一个名叫getName的getter方法。如果只把JsonProperty(realName)加在成员变量上而getter方法没有加注解Jackson某些版本下会出现一种诡异现象反序列化时新JSON里的realName能正常映射到name序列化输出时却可能输出成name。这是因为Jackson对属性的认定逻辑是合并字段和getter/setter的元信息两者的注解优先级又不一样。解决的思路也很简单要么字段和getter/setter一视同仁都加上注解要么统一把注解放在getter方法或setter方法上避免Jackson在合并元信息时产生歧义。另一个坑是Lombok的Data。Lombok在编译期生成getter/setter时有些版本的注解处理顺序会覆盖你的手写配置。我遇到过把JsonProperty加在字段上、同时类上又用了Data和Builder结果序列化出来字段名还是原来的驼峰形式。排查下来是Builder模式生成的一个withName(xx)之类的方法干扰了Jackson对属性链路的判断。这类问题没有通用灵药最实用的排查手段是把对象的实际字段、实际getter/setter全部列出来确认注解到底加在哪个环节上必要时在getter上单独补一个JsonProperty。2.4 枚举类与构造方法参数的映射JsonProperty还能用来修饰枚举常量、构造方法参数这在处理一些刁钻需求时很有用。比如枚举public enum OrderStatus { JsonProperty(pending) PENDING, JsonProperty(paid) PAID, JsonProperty(cancelled) CANCELLED }这样序列化的时候PENDING不再是默认的字符串PENDING而是pending。反序列化时传paid也能正确解析回PAID。这个技巧在对接不同团队、不同命名规范的接口时能省很大的对接成本。构造方法参数上的JsonProperty主要用于实现不可变对象。比如一个只有全参构造、没有默认构造函数的类public class ResultData { private final String code; private final String message; JsonCreator public ResultData(JsonProperty(code) String code, JsonProperty(message) String message) { this.code code; this.message message; } }如果不加JsonPropertyJackson在反序列化这种不可变类时经常因为找不到参数名编译时未开启-parameters而报错加上之后就能精确绑定JSON字段和构造参数。3. JsonInclude 的生效范围与每种取值策略JsonInclude解决的问题不是叫什么而是出不出。它的核心是一个Value枚举也就是Include里面定义了多种空值判定策略。不同策略适配不同场景用错了要么报文冗余要么字段凭空消失所以值得逐个理解。3.1 Include 枚举的六个取值Jackson的JsonInclude默认只作用于序列化输出它支持的取值包括这些Include取值行为说明典型场景ALWAYS无论值是什么一律输出需要保持字段结构完整时NON_NULL值为null就不输出最常用去除null字段NON_ABSENTnull和Optional.empty不输出Java8 Optional容器NON_EMPTY空字符串、空集合、空数组都不输出清理空字符串和空列表NON_DEFAULT值等于默认值0、false、空串时不输出减少无意义默认值字段USE_DEFAULTS沿用全局或父类设置局部声明的兜底策略这六种策略看似简单但实际判定逻辑里有不少弯弯绕绕。NON_EMPTY不是简单判断是不是null它对String、Collection、Map、数组、Optional等类型都有各自的空的定义。String的算空List的[]算空Map的{}算空一个元素都没有的数组也算空。这非常符合接口瘦身的诉求。3.2 NON_NULL 与 NON_EMPTY 的选择困难项目里最经典的争论就是到底用NON_NULL还是NON_EMPTY。NON_NULL只去掉null值如果一个字段是空字符串它照样会出现在JSON里。很多场景下这没问题因为空字符串和null在业务上可能都代表没有值。但前端拿到时有的框架会把它当作有效字符串参与逻辑判断这就会埋下隐患。NON_EMPTY则更激进它连空串、空数组一起干掉。输出格式更干净但也意味着前端判断字段是否存在时需要更谨慎。如果一个字段在空字符串和缺失之间具有不同业务含义就不能简单用NON_EMPTY一刀切。比如优惠券的备注字段约定空字符串代表没有备注而缺失字段可能代表不显示备注区域。这种细微差别一旦被NON_EMPTY抹平前端就失去了区分能力。我通常会这样把握内部系统接口、字段量大且空值多的用NON_EMPTY对外开放API、需要保证结构稳定性的用NON_NULL字段语义敏感、空串和null有业务区别的既不轻易上NON_EMPTY也最好逐字段精细控制。3.3 类级别与全局配置的影响范围JsonInclude可以加在类上也可以加在单个字段上。加在类上的写法是JsonInclude(JsonInclude.Include.NON_NULL) public class UserResponse { private String name; private String email; }类级别的注解会作用到整个类所有属性字段级别的注解可以单独覆盖类级别的设置。优先级上字段级的JsonInclude大于类级类级大于ObjectMapper全局配置。这里要给一个小提示JsonInclude中的Include.NON_NULL只解决序列化端反序列化时如果JSON里某个字段缺失已有的Java对象属性不会因为缺了null就被特别处理。换句话说它不会在反序列化阶段帮你强制给属性填null。理解这一点很重要因为很多人会把JsonInclude理解成JSON里没有的字段在Java对象里就是null——这个推理只在反序列化新创建对象时部分成立在更新已有对象字段的场景里并不成立。3.4 使用 Optional 时的注意点Java 8之后很多代码风格喜欢用Optional包装可空字段。Jackson对Optional有专门的适配逻辑。如果字段类型是OptionalString序列化出来可能是一段{present: true, value: ...}的结构而不是你想要的普通字符串除非你注册了JDK8模块。很多人因此把Optional用在实体类上后接口输出的JSON结构直接变了样。如果确实要在实体里用Optional并且希望输出成普通值可以考虑配合JsonInclude(Include.NON_ABSENT)。这个策略在遇到Optional.empty()时会直接把字段过滤掉遇到有值的Optional则输出内部值。不过这里面的门道很多我的建议是普通DTO里尽量别用Optional做字段类型它不是用来定义持久化或传输结构的而是用来给调用方提示可空性的。用Optional做实体字段往往是为了应对编译检查而绕道最终坑的是序列化这层。4. 叠加使用时的优先级与踩坑排查实录前面把两个注解分开讲清楚了现在要处理的是它们同时出现时的行为。这也是项目中最容易出幺蛾子的地方既有改名需求又有空值过滤需求叠加在一起的时候你会遇到一些从注解定义本身看不出结果的诡异情况。4.1 一个真实场景的排查过程我曾经接手过一个接口实体字段长这样JsonInclude(JsonInclude.Include.NON_NULL) public class OrderVO { JsonProperty(order_id) private String orderId; JsonProperty(remark) private String remark; }需求很明确序列化时如果remark为null就不要出现在JSON里如果orderId有值就以order_id的key输出。看起来天衣无缝但同事反馈说当remark为null时它依然出现在了JSON里值为null。这个问题的排查过程很有代表性。我先怀疑是类上的JsonInclude没生效于是单独写了一个单元测试直接实例化OrderVO给remark设为null再用ObjectMapper序列化。结果发现序列化输出确实没有remark字段。这说明注解本身是生效的。那问题就只能出在为什么这个例子有效、线上无效。我继续翻代码发现这个OrderVO被用在了某段XML配置映射里而那一段逻辑在序列化前先做了一次Bean拷贝把原对象的字段拷贝到了另一个版本的对象而那个目标对象并没有JsonInclude注解。于是输出端使用的是全新对象自然丢失了注解信息。这类问题的排查链路其实就一句话序列化的是哪一个对象注解就必须在哪一个类上。你以为你往接口里传的是OrderVO实际上可能在Service层已经被转换成了OrderDTO一堆注解早就在转换过程中失效了。4.2 注解同时存在时的生效优先级当JsonProperty和JsonInclude同时作用于一个字段时它们之间不存在互相覆盖的关系而是各管各的一段流程。Jackson序列化一个对象时内部大概分两步第一步是确定输出哪些属性这一步JsonInclude参与决策第二步是确定每个属性的输出名字这一步JsonProperty参与决策。所以实际效果就是先过滤掉不需要输出的字段再对留下的字段做命名映射。一个字段即使成功通过JsonInclude的过滤如果JsonProperty给它起了另一个名字输出时依然按照新名字来。反过来一个字段即使有JsonProperty改名如果值这个值被NON_NULL判定为null它依然不会输出。需要注意的是JsonInclude的过滤发生在值判定阶段它不会因为一个字段没有JsonProperty就特殊对待也不会因为一个字段有多个注解就增加额外开销。4.3 字段级与类级配置的覆盖关系有一种组合写法是类上写了JsonInclude(NON_NULL)某个字段上单独写了JsonInclude(ALWAYS)。这时候即使字段值为null它也应被输出。这是合法的也是字段级配置覆盖类级配置的体现。反之如果类上写了JsonInclude(ALWAYS)字段上写了NON_NULL那就以字段级为准。这个覆盖关系理解起来不复杂但它带来一个坑团队协作时如果一个人改了类级别的注解很容易影响到很多单独配置字段的实际输出。比如A字段本来配置了ALWAYS想让前端始终拿到这个key结果类级别被人改成NON_EMPTYA的空值字段被过滤前端因为拿不到该key而出现NPE。遇到这类问题排查方向不是去翻前端代码而是先用JSON序列化测试确认当前输出再逐层检查类级和字段级注解的覆盖关系。4.4 方法级JsonInclude你不知道的隐藏入口JsonInclude除了加在字段和类上还能加在getter方法上。有些同事的代码风格是用Lombok自动生成getter同时自己手写了一个getRemark()方法里面做了字段拼接或格式化。如果把JsonInclude加在这个自定义getter上它控制的其实是getter返回值的空值判定而不是字段本身的空值判定。我见过一个排查了很久的问题字段summary明明有值接口返回里却没有summary这个key。最后定位到那个类里有一个getSummary()方法返回的是一个计算拼接后的字符串恰好为空字符串。这个getter方法上标了JsonInclude(NON_EMPTY)于是把整条输出链路的summary给过滤了。这提醒我Jackson里的属性不仅仅指字段getter方法也可以被视为一个属性来源注解标注的位置不同影响的对象就不同。5. 高频配置速查与最佳实践建议了解了原理和坑之后接下来是真正能提升效率的部分。我根据自己的项目经验整理了一套常用配置方案和几条铁律你可以直接抄作业。5.1 常用组合速查表场景推荐配置说明后端返回列表数据字段可能为空类级JsonInclude(NON_NULL)减少null字段对前端干扰对接第三方接口必须保持结构完整类级JsonInclude(ALWAYS)或用全局ALWAYS避免字段缺失引起对方解析异常单字段脱敏只接收不返回JsonProperty(access Access.WRITE_ONLY)密码、密钥、身份证号等单字段只返回不接收JsonProperty(access Access.READ_ONLY)后端生成的ID、创建时间空列表不想输出字段级JsonInclude(NON_EMPTY)集合为空时整个key消失返回字段改下划线命名JsonProperty(user_name)适用对接旧系统或跨语言团队全局过滤null在ObjectMapper上配置setSerializationInclusion(NON_NULL)一处配置全局生效5.2 关于ObjectMapper全局配置很多项目会在启动类或配置类里构建一个自定义的ObjectMapper做全局序列化设置。比如用Spring Boot时可以通过配置文件spring.jackson.default-property-inclusionnon_null这个配置的效果是所有通过Spring MVC返回的JSON都自动过滤null字段。它和类级别的JsonInclude可以叠加类级别的优先级更高。实际项目中我比较倾向的做法是全局用NON_NULL打底个别需要特殊处理的类或字段再加注解微调。这样既保证了大多数接口的干净输出又不牺牲特殊场景下的灵活性。但全局配置有一个副作用需要留意它会影响所有接口的所有返回。如果你有一个老接口前端一直依赖null字段判断该字段未设置全局NON_NULL一开这个接口的行为就悄无声息地变了。所以做全局配置前务必先梳理线上接口的字段依赖情况。5.3 三条基于踩坑经验的操作建议第一不要在一个类上同时混用字段级注解和getter级注解。如果你已经用了Lombok的Data就别手写和字段名相近的getter方法这会让Jackson的属性发现机制变得复杂也是很多注解好像没生效的深层原因。保持风格统一字段级就用字段级getter级就全部用getter级。第二每次改动序列化配置后用一段独立的最小化JSON做对照测试。序列化配置的坑往往是隐性的代码层面看不出问题只有把对象真实序列化成字符串才能暴露真相。我在项目里会专门维护一个序列化测试类里面放着每个容易出问题的实体改动完注解后跑一遍前后对比JSON输出几秒钟就能确认行为是否变化。第三反序列化时注意JsonInclude不会自动补救缺失字段。如果业务要求前端不传就保留原值你得自己写字段合并逻辑不能指望Jackson一个注解搞定。我遇到过同事把JsonInclude(NON_NULL)误当成反序列化时null可以跳过更新结果前端传一个JSON里只有name字段的对象Java对象里其他的值直接被重置成null酿成线上数据被覆盖的事故。5.4 适合直接采用的代码模板最后给一个适合日常开发直接套用的模板。假设你要做一个用户信息更新接口要求入参字段名下划线、密码不参与序列化、返回时自动过滤null字段。Data JsonInclude(JsonInclude.Include.NON_NULL) public class UserUpdateRequest { JsonProperty(user_name) private String userName; JsonProperty(phone_number) private String phoneNumber; JsonProperty(value password, access JsonProperty.Access.WRITE_ONLY) private String password; }对应的ControllerRestController RequestMapping(/api/user) public class UserController { PostMapping(/update) public UserUpdateRequest update(RequestBody UserUpdateRequest request) { // 业务处理省略 return request; } }这个模板里userName和phoneNumber在入参和出参都用下划线JSON字段名password只接收不入参出参时所有null字段都会被过滤掉。你会发现这个组合能覆盖大部分常规接口需求而且代码量极小。如果你正在维护一个老项目里面有很多手写的getter/setter建议在动手批量加注解前先跑一遍现有接口的JSON样例。改一个注解可能影响很多调用方尤其是那些看报文猜字段的对接方。先用最小改动验证再逐步推广到全局这是我在多次踩坑后总结出的最稳妥路线。