做 Java 后端开发的,十有八九绕不开 Jackson。它不一定是功能最花哨的 JSON 库,但几乎已经是 Java 世界的 JSON 事实标准:默认对象映射、注解驱动配置、扩展模块,一套组合拳下来,大部分序列化和反序列化场景都能覆盖。而 Jackson 真正见功夫的地方,就在注解的使用上。用一个 @JsonProperty 改字段名谁都会,但在继承体系里做多态反序列化、让不可变对象能被 JSON 构建、按接口场景输出不同字段集合,这些才是注解经验的真正分水岭。
这篇文章把我这些年在一个个项目里实际用过的 Jackson 常用注解完整梳理一遍,不光是罗列 API,更会讲清楚每个注解解决什么问题、为什么这么设计、用的时候有哪些坑。适合已经在用 Jackson、想从“能跑”提升到“用得明白”的 Java 开发者,也适合刚开始接手接口层代码、需要快速理解别人类上那些注解意图的新手。内容偏实战,你完全可以把它当成手边的手册来查。
1. 先搞清楚 Jackson 注解是怎么运作的
1.1 注解背后其实是两条处理链路
很多人用 Jackson 注解完全是背公式:时间不对就加 @JsonFormat,字段不要了加 @JsonIgnore,名字对不上加 @JsonProperty。不是说这么用不对,但遇到复杂场景容易翻车。想把这些注解真正用顺手,得先花五分钟理解 Jackson 的注解机制。
ObjectMapper 只是个门面,真正干活的是两个组件:序列化时,ObjectMapper 根据对象的 Class 信息构建一个 BeanSerializer;反序列化时,构建的是 BeanDeserializer。这两个处理器在构建过程中会扫描类的字段、getter、setter、构造器,同时也会扫描它们身上的注解,把注解表达的信息合并进处理器的配置里。也就是说,注解不是运行时魔法,而是在构建 serializer/deserializer 时被读取并转换成属性配置的静态信息。
理解了这一点,很多困惑就解开了。为什么 @JsonProperty 既能影响序列化又能影响反序列化?因为它在两条链路里都会被读取。为什么 @JsonInclude 只对输出生效、@JsonAlias 只对输入生效?因为它们分别只有一条链上有意义。再比如,为什么注解标在 getter 上和标在字段上行为会有细微差别?因为 BeanSerializer 默认把 getter 当作属性来源,BeanDeserializer 默认把 setter 当作属性入口,注解标在哪,哪条链就先看到它。
1.2 按“改名字、改值、改结构、改行为”来建立注解地图
Jackson 的注解数量不少,但本质作用可以归纳成四个维度,这样记忆负担会小很多。
| 作用维度 | 代表注解 | 典型场景 |
|---|---|---|
| 改名字 | @JsonProperty、@JsonAlias、@JsonNaming | 字段名与 JSON key 不一致、兼容历史字段 |
| 改值表达 | @JsonFormat、@JsonInclude、@JsonSerialize、@JsonDeserialize | 时间格式化、空值省略、自定义脱敏 |
| 改结构 | @JsonUnwrapped、@JsonView、@JsonAnyGetter、@JsonAnySetter | 扁平化嵌套对象、按视图裁剪、动态扩展字段 |
| 改行为 | @JsonIgnore、@JsonIgnoreProperties、@JsonTypeInfo、@JsonCreator | 忽略字段、多态还原、不可变对象创建 |
后面的内容就按这个地图展开。你会发现大多数复杂的序列化问题,都能在表里找到对应的一类解法,而不是靠零散记 API。
2. 字段映射与命名规则:最常用的一组
2.1 @JsonProperty:重命名、访问权限、必填标记
@JsonProperty 是最基础的注解,核心作用是把 Java 字段名和 JSON key 名解耦。最常见的用法是给字段起一个符合接口规范的别名:
public class UserProfile { @JsonProperty("user_id") private Long userId; @JsonProperty("nick_name") private String nickName; }这样 userId 在 JSON 里就是user_id,反序列化时也能把user_id映射回 userId。这里有个小建议:能直接标在字段上,就不要只标在 getter 或 setter 上。标在字段上是双向生效的,行为最稳定;只标在 getter 上,序列化生效但反序列化可能因为找不到 setter 映射而失败,尤其在类里没有对应字段只有 getter 方法时更容易踩坑。
@JsonProperty 还有一个容易忽略的access属性,常见取值是READ_ONLY和WRITE_ONLY。READ_ONLY 表示这个属性只输出、不接收输入,适合服务端计算出来的字段;WRITE_ONLY 表示只接收输入、不输出,典型场景是密码字段——前端可以传 password,但任何接口响应里都不能把它带出去。
public class UserProfile { @JsonProperty("user_id") private Long userId; @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; }至于required = true,它表达的更多是文档语义:标记字段在输入时“应该”存在。但注意,Jackson 对 required 的校验并不是强制的,很多版本里即使缺了字段也不会抛异常,真正要保证非空校验,还是得在业务层用 Bean Validation 或者自己的校验逻辑来做。
2.2 @JsonAlias:只读方向的多字段兼容
@JsonAlias 是一个很容易被低估的注解。它允许在反序列化时,让同一个 Java 字段接收多个不同的 JSON key。典型场景是接口字段改名后的兼容期:你希望新老客户端都能正常提交数据。
public class User { @JsonAlias({"userName", "login"}) private String username; }这段代码的意思是:JSON 里出现username、userName、login任何一个 key,都会映射到 Java 的 username 字段。注意只有反序列化方向生效,序列化时输出仍然使用字段名或 @JsonProperty 指定的名字。所以它不会污染响应结构,只是扩大输入容忍度,非常适合做接口演进。
实际项目中我还用它处理过第三方回调参数的多种命名风格——同一个回调,不同商户可能传的参数名不一样,用 @JsonAlias 列出来,反序列化就很省事。
2.3 @JsonNaming:类级和全局的命名策略
如果整个系统的接口规范都是下划线命名,逐个字段加 @JsonProperty 就很啰嗦。此时用 @JsonNaming 指定命名策略,一劳永逸:
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class OrderInfo { private String orderId; private Long buyerId; }序列化后 JSON key 会自动变成order_id、buyer_id,反序列化时同样能把下划线 key 映射回来。除了 SnakeCaseStrategy,Jackson 还提供了 KebabCaseStrategy、UpperCamelCaseStrategy、LowerCaseStrategy 等。注意PropertyNamingStrategies是 2.12 之后推荐的入口,老版本里对应的是PropertyNamingStrategy.SNAKE_CASE。
全局配置则是在 ObjectMapper 上设置:
ObjectMapper mapper = new ObjectMapper(); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);类级注解优先级高于全局配置。所以可以全局默认下划线,个别需要特殊命名的类再用 @JsonProperty 覆盖,组合起来很灵活。
2.4 @JsonPropertyOrder:控制输出顺序
JSON 对象本身的 key 顺序在语义上不重要,但对接口排查、日志阅读、diff 对比来说,稳定的顺序能省不少心。@JsonPropertyOrder 可以在类级别控制字段输出顺序:
@JsonPropertyOrder({"userId", "nickName", "createdAt"}) public class UserProfile { ... }也可以配合alphabetic = true按字母序输出。说实话这个注解的“技术价值”不高,但如果你经常对比两个接口返回的 JSON,你会发现字段顺序一致真的会让排错舒服很多。
3. 忽略与包含:控制字段的出镜率
3.1 @JsonIgnore:双向忽略,但要注意标注位置
@JsonIgnore 的作用是让某个属性完全不参与序列化和反序列化。最安全的用法是直接标在字段上:
public class User { private String username; @JsonIgnore private String internalRemark; }internalRemark 既不会出现在输出 JSON 里,也不会从输入 JSON 中接收值。
但我必须提醒一个细节:标注位置不同,行为有细微差别。如果只标在 getter 上,序列化时会忽略这个属性;但反序列化时,如果类里还有对应的 setter,Jackson 仍然可能通过 setter 给它赋值。反过来,只标在 setter 上,输出不受影响,只是输入被忽略。想彻底双向忽略,直接标字段最省心。
3.2 @JsonIgnoreProperties:类级忽略清单和 ignoreUnknown
这个注解可以放在类上,批量忽略一批字段名:
@JsonIgnoreProperties({"internalCode", "callbackData"}) public class ApiResult { ... }它还有一个更常用的属性ignoreUnknown = true,作用是反序列化时忽略 JSON 中所有未知字段。为什么要单独拎出来讲?因为 Jackson 默认遇到未知字段是会抛UnrecognizedPropertyException的。很多人在本地测试好好的,上线后客户端多传了一个字段,接口直接 500,就是这个原因。加一行:
@JsonIgnoreProperties(ignoreUnknown = true) public class ApiResult { ... }问题就消失了。但这里有个取舍:ignoreUnknown 开太早,字段拼写错误也会被静默吞掉,排查线上问题时少了一个报错信号。我的习惯是:对外的请求 DTO 类尽量开,内部服务间调用的 DTO 不开,宁可让它报错暴露问题,也别让脏数据悄悄流过。
同样要注意,在 Spring Boot 场景下,框架默认可能已经把全局的FAIL_ON_UNKNOWN_PROPERTIES关掉了,所以同样的代码从纯 ObjectMapper 换到容器里,行为会不一样。这个不一致本身就是最常见的坑之一。
3.3 @JsonIgnoreType:整类忽略
@JsonIgnoreType 放在某个类上,表示凡是这个类型的属性,在序列化和反序列化时全部忽略。典型的用途是忽略一些没有业务意义的技术辅助类,比如审计快照、内部上下文对象:
@JsonIgnoreType public class TraceContext { private String traceId; private String spanId; }只要某个业务类里持有 TraceContext 类型的字段,这个字段就会被自动忽略,不用在每个使用处重复写 @JsonIgnore。这个注解在类比较多、技术横切字段散落各处时,能明显减少注解重复。
3.4 @JsonInclude:空值、空集合的按需输出
@JsonInclude 控制属性在什么条件下参与序列化,最常见的两个取值是 NON_NULL 和 NON_EMPTY。
@JsonInclude(JsonInclude.Include.NON_NULL) public class ApiResult { ... }NON_NULL 表示值为 null 的字段不输出;NON_EMPTY 更进一步,空字符串、空集合、空 Optional 也不输出。两者差异在实际接口里很明显:客户端拿到"remark": ""和拿不到 remark 字段,处理逻辑往往不一样。想要更干净的响应体,一般用 NON_EMPTY。
还有一个容易用错的是 NON_DEFAULT:它会把值等于默认零值的字段也忽略掉,比如 int 类型的 0、boolean 类型的 false。对原始类型字段来说,这个行为可能让你“莫名其妙”丢字段,需要格外小心。
全局统一配置可以放在 ObjectMapper 上:
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);类级注解会覆盖全局配置。我一般建议全局先设 NON_NULL,然后对个别响应类用 NON_EMPTY 做精细化控制,这样响应体既干净又不至于把 0 和 false 这种有业务含义的值吞掉。
4. 格式化与时间处理:最容易出问题的一组
4.1 @JsonFormat:时间模式、时区和 Shape
时间格式化是 Jackson 使用中翻车率最高的场景。@JsonFormat 的基本写法:
public class Order { @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createdAt; }对 java.util.Date 类型,timezone 参数是生效的,它告诉 Jackson 在格式化时把时间转换到指定时区,否则会使用 JVM 默认时区。同一个时间戳,在不同时区的机器上可能输出不同的字符串,这就是很多项目“本地没事,上线时间差八小时”的根源。显式指定 timezone 可以消除这种不确定性。
但如果你用的是 Java 8 的 LocalDateTime、LocalDate 这类类型,timezone 参数是不生效的,因为 LocalDateTime 本身不携带时区信息。此时要处理的就是 pattern 本身。举个例子:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt;4.2 Java 8 时间类型必须先注册模块
直接序列化 LocalDateTime,哪怕加了注解,也经常出现两种诡异情况:要么报错说找不到JavaTimeModule,要么输出的是一串数组[2025, 5, 1, 10, 30, 0]。这两种都在提醒你:Java 8 时间类型不在 Jackson 默认支持范围内。
解决办法是先注册模块:
ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule());然后还要决定时间戳的表达方式。Jackson 默认会把 LocalDateTime 序列化成数组形式,很多人不喜欢这种格式,可以关掉:
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);关闭后配合 @JsonFormat 的 pattern,输出就是可读性很好的字符串。这里给出的经验是:每个项目统一一种时间格式,要么全部时间戳,要么全部字符串;千万不要一半字段用默认数组、一半字段用自定义 pattern,客户端解析会非常痛苦。
4.3 @JsonFormat 在反序列化方向的容错
@JsonFormat 的 pattern 在反序列化时同样生效。也就是说客户端传"2025-05-01 10:30:00"这个字符串,Jackson 会按 pattern 解析成对应的 Date 或 LocalDateTime。但要注意 pattern 严格程度:yyyy-MM-dd HH:mm:ss不能解析带毫秒的2025-05-01 10:30:00.123,也不能解析2025-05-01T10:30:00这种 ISO 格式。
如果客户端可能传来多种格式,一个 pattern 是不够的。我常用的做法是自定义一个反序列化器,内部尝试多个 pattern,或者统一要求客户端传时间戳。接口文档里写清楚“所有时间字段一律传毫秒时间戳”,其实是最省事的方案。
5. 多态与类型信息:JSON 里的继承怎么还原
5.1 @JsonTypeInfo + @JsonSubTypes:多态序列化/反序列化
这是 Jackson 里解决继承体系最核心的一组注解。先看一个典型问题:
public class Zoo { private List<Animal> animals; } public abstract class Animal { private String name; } public class Dog extends Animal { private String barkVolume; } public class Cat extends Animal { private String sleepHours; }反序列化时,Jackson 看到 List 元素类型是 Animal,是抽象类,根本不知道应该实例化成 Dog 还是 Cat。不处理的话直接报InvalidDefinitionException。多态注解就是给 JSON 增加一个类型标记,告诉 Jackson 该还原成哪个子类。
@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = Dog.class, name = "dog"), @JsonSubTypes.Type(value = Cat.class, name = "cat") }) public abstract class Animal { private String name; }序列化后 JSON 里会多出"type": "dog";反序列化时 Jackson 看到 type 值为 dog,就实例化 Dog。这里我建议优先用Id.NAME+ 自己的短名字,而不是Id.CLASS。Id.CLASS会把全限定类名直接写进 JSON,例如"com.example.Dog"。这种做法有几个问题:类名一调整,历史数据全部失效;而且类名信息暴露给调用方,从安全角度看也不理想。
include如果不写,默认是As.PROPERTY,即类型标记作为普通属性出现。还有As.WRAPPER_OBJECT、As.WRAPPER_ARRAY等变体,它们会把结构变成嵌套对象或数组,日常用得不多,但要能看懂。As.EXISTING_PROPERTY适合类型标记本身就是业务字段的情况,可以避免额外加字段。
5.2 @JsonTypeName 与 NamedType 注册
@JsonTypeName 可以定义子类的类型名称。和上面用 @JsonSubTypes 里的 name 属性相比,它把名字定义移到了子类自己身上,更内聚:
@JsonTypeName("dog") public class Dog extends Animal { ... }要注意的是,光有 @JsonTypeName 还不够,Jackson 需要知道哪些子类参与多态。你可以继续配合 @JsonSubTypes,也可以走编程式注册:
mapper.registerSubtypes( new NamedType(Dog.class, "dog"), new NamedType(Cat.class, "cat"));这两种方式本质一样。团队规模大、子类经常新增时,我倾向用 @JsonSubTypes 集中管理;子类自己带 @JsonTypeName 的方式更适合子类独立演进、由不同的团队维护的场景。
5.3 多态容器下的类型安全与版本兼容
用多态注解时,有几个现实问题必须提前想清楚。第一,如果 JSON 里的 type 值找不到对应的子类,Jackson 默认会抛异常。这其实是个保护机制,能让你尽早发现数据异常;如果希望降级为 null,需要额外配置,但我不建议在核心链路里静默吞掉这种错误。
第二,子类类型名一旦发布出去就尽量别改,否则历史数据反序列化就会失败。真要改,一般保留一个废弃的 name 映射,或者在反序列化器里做兼容处理。
第三,visible = true这个参数也值得知道。如果 type 字段本身也是业务上需要保留的数据,设置@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type", visible = true)可以让 Jackson 在反序列化时既用它选择子类,又把它保留到子类的对应字段里,不会丢失。
6. 反序列化进阶:构造器、Setter 与未知字段
6.1 @JsonCreator:没有 setter 的对象怎么构建
很多值对象设计成不可变的,只有 final 字段和构造器,没有 setter。Jackson 默认用无参构造器加 setter 来反序列化,这种类它处理不了。@JsonCreator 就是告诉 Jackson:用这个构造器来创建对象。
public class Money { private final BigDecimal amount; @JsonCreator public Money(@JsonProperty("amount") BigDecimal amount) { this.amount = amount; } }注意构造器参数上的 @JsonProperty 不能省,Jackson 需要靠它把 JSON key 映射到构造器参数位置。@JsonCreator 也可以放在静态工厂方法上,比如返回类型是接口或抽象类的场景就常用:
@JsonCreator public static Animal create(String type) { ... }如果 @JsonCreator 只有一个参数,Jackson 从 2.x 开始支持一种更简洁的写法,即不加 @JsonProperty,这种情况下整个 JSON 会作为一个整体传给这个参数。这里要留意mode属性:PROPERTIES模式是按 JSON 属性逐个填充构造器参数,DELEGATING模式是把整个 JSON 交给单个参数处理。不写时 Jackson 会自行推断,但遇到歧义时显式指定更稳妥。
6.2 @JsonAnySetter 和 @JsonAnyGetter:动态字段的收纳与输出
接口对接中最头疼的一类需求是:对方会传一些我们没定义过的字段,但我们希望原样保存下来,而不是直接忽略。@JsonAnySetter 就是干这个的:
public class WebhookPayload { private Map<String, Object> unknownFields = new HashMap<>(); @JsonAnySetter public void setUnknownField(String key, Object value) { unknownFields.put(key, value); } }反序列化时,所有在类里没有对应属性的 JSON key,都会进入 unknownFields 这个 Map。对应地,@JsonAnyGetter 可以把动态字段重新输出到 JSON 里:
@JsonAnyGetter public Map<String, Object> getUnknownFields() { return unknownFields; }这套组合在做回调透传、配置中心扩展字段、半结构化数据存储时非常有用。但要注意,既然是动态字段,类型上就失去了编译期保障。value 是 Object,反序列化出来可能是 LinkedHashMap、ArrayList、String 等各种类型,业务取值时要做好类型判断,最好不要直接强转。
6.3 @JsonUnwrapped:把嵌套对象拍平
有些接口要求扁平的 JSON,但 Java 代码里为了内聚会设计成嵌套对象。@JsonUnwrapped 可以解决这个矛盾:
public class Order { @JsonUnwrapped private Address address; public static class Address { private String street; private String city; } }序列化结果不再是{"address":{"street":"xxx","city":"yyy"}},而是直接展开成{"street":"xxx","city":"yyy"}。反序列化时同样会把这两个 key 映射进 address 对象。
这个注解用起来清爽,但现实中我踩过它的坑:如果父类和子类有同名字段,展开后会产生 key 冲突,Jackson 会在反序列化时表现得很奇怪。另外 @JsonUnwrapped 和一些多态特性搭配时,偶尔会出现属性丢失之类的兼容问题。所以我的原则是,能不用尽量不用,只有接口规范强制扁平化时才引入它。
6.4 @JsonSetter:单独重命名 setter
@JsonSetter 的用途和字段上的 @JsonProperty 很像,区别在于它是直接标注在 setter 方法上的。适合不想污染字段、只想修改反序列化行为的场景:
public class User { private String name; @JsonSetter("nickName") public void setName(String name) { this.name = name; } }这样 JSON 里的nickName会被接收,序列化输出仍然用字段名 name。如果你希望输入输出方向使用不同的名字,这种“字段标注 + setter 重映射”的组合会比你来回改 @JsonProperty 清晰得多。
7. 自定义序列化与反序列化:当注解不够用的时候
7.1 @JsonSerialize 和 @JsonDeserialize:绑定自定义处理器
内置注解覆盖不了业务需求时,就该上自定义处理器了。比如最常见的字段脱敏:手机号、银行卡号、身份证号,序列化时只输出脱敏后的字符串。用 @JsonSerialize 把字段绑定到自定义序列化器上:
public class CreditCard { @JsonSerialize(using = SensitiveSerializer.class) private String cardNo; }对应的序列化器继承JsonSerializer<String>:
public class SensitiveSerializer extends JsonSerializer<String> { @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null || value.length() < 8) { gen.writeString(value); return; } gen.writeString("****" + value.substring(value.length() - 4)); } }注意这里必须用gen.writeString(...)而不是gen.writeObject(...)直接写脱敏后的字符串,否则 Jackson 可能会对字符串再套一层引号,输出来就是错误的。这个细节当初让我排查了半天。
反序列化方向对应的注解是 @JsonDeserialize,用法对称,实现类继承JsonDeserializer<T>,重写deserialize方法。自定义反序列化器的意义不只是格式化数据,还可以做兼容:比如某个老接口传的是字符串枚举,新接口传的是数字,用一个自定义反序列化器就能平滑接收两种格式。
7.2 全局注册模块与注解的优先级
如果某个类型在所有地方都需要自定义处理,那就没必要每个字段都写 using。更优雅的做法是注册一个模块,让这个类型全局走自定义处理器:
SimpleModule module = new SimpleModule(); module.addSerializer(BigDecimal.class, new MoneySerializer()); module.addDeserializer(BigDecimal.class, new MoneyDeserializer()); ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(module);当注解上的 using 和全局模块同时存在时,注解优先。这个优先级特性很实用:全局默认一种处理,个别字段用注解单独覆盖,既有兜底又有例外。
7.3 别让自定义处理器变成性能黑洞
自定义序列化器一旦写得不谨慎,可能比默认序列化慢一个数量级。我见过的常见问题包括:在 serialize 方法里做远程调用、用正则表达式反复编译、或者每次 new 一个中间对象。JsonSerializer 实例是会被复用的,不要在它内部保存有状态的数据;如果必须用正则,预编译成 Pattern 常量;如果脱敏规则复杂,提前把规则在构造阶段加载好。序列化器应该是一个纯粹的无状态函数,输入一个值、输出一段 JSON 片段。
8. @JsonView:按场景输出不同的字段视图
8.1 视图定义与字段标注
同一个对象,给内部管理员看的接口和给普通用户看的接口,字段集合往往不一样。最粗暴的做法是写两个 DTO,但字段一多,复制粘贴就变得很难维护。@JsonView 提供了一种在同一实体上表达多套视图的方案。
先定义视图类,可以设计成继承关系来表达视图的层级:
public class Views { public static class Public {} public static class Internal extends Public {} }然后在实体字段上标注它属于哪个视图:
public class Order { @JsonView(Views.Public.class) private Long id; @JsonView(Views.Public.class) private BigDecimal amount; @JsonView(Views.Internal.class) private String buyerMobile; @JsonView(Views.Internal.class) private String riskLevel; }序列化时通过writerWithView选择输出哪套视图:
ObjectMapper mapper = new ObjectMapper(); String publicJson = mapper.writerWithView(Views.Public.class) .writeValueAsString(order); String internalJson = mapper.writerWithView(Views.Internal.class) .writeValueAsString(order);出于视图继承的关系,选择 Internal 视图时,Public 视图里的字段也会输出,因为 Internal 继承自 Public。这套机制表达“公开字段是所有内部字段的子集”非常自然。
8.2 在接口层与 Spring MVC 中配合使用
在常见的 MVC 框架里,控制器方法也可以直接声明视图:
@JsonView(Views.Internal.class) public Order getOrder(@PathVariable Long id) { ... }框架会用对应的 ObjectMapper 视图来序列化返回值,不需要手动拼 writer。这样实现“同一个实体、多套接口、不同字段”就变得很干净。
8.3 视图的默认包含行为是个隐藏的坑
@JsonView 最容易被误解的地方是:当一个字段没有标注任何视图时,它在开启视图过滤后到底会不会输出?这个行为取决于 ObjectMapper 上的DEFAULT_VIEW_INCLUSION配置。默认情况下,没有标注视图的字段仍然会被输出;如果希望严格按视图裁剪,需要显式配置关闭默认包含,或者给所有需要输出的字段都标上视图。
我在项目里踩过一次:一个实体大部分字段都有 @JsonView,唯独新增字段时忘了标,结果这个不该暴露给前端的字段就被默认输出出去了。所以用 @JsonView 的团队,一定要在代码评审时把“新字段必须声明视图”作为硬性要求。
9. 实战中常见问题与排查实录
9.1 循环引用导致 StackOverflowError
最常见也最吓人的报错:序列化一个双向关联的对象,比如订单引用用户、用户又引用订单列表,运行时报StackOverflowError。根本原因是 Jackson 默认会顺着 getter 无限展开对象图。
解决思路有三个层次。最简单的是一侧加 @JsonIgnore,直接从对象图里剪掉一条边;如果要保留双向关系且信息都需要,用 @JsonManagedReference 和 @JsonBackReference 配对标注,Jackson 会在引用侧直接输出对象、在被引用侧输出一个占位;如果希望同一对象用唯一标识代替重复展开,可以用 @JsonIdentityInfo。
我个人的第一选择是 @JsonIdentityInfo,因为它最不破坏对象图语义:
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class User { ... }序列化时,第一次出现的 User 会完整输出,后面再遇到同一个 User 就只输出 id,JSON 体积小且没有死循环。
9.2 LocalDateTime 序列化结果莫名其妙
前面提过,不注册 JavaTimeModule 时,LocalDateTime 序列化会报错;注册后默认输出数组,很多人看到[2025, 5, 1, 10, 30]还以为数据坏了。这个问题的排查点在两个开关:是否注册了模块,以及WRITE_DATES_AS_TIMESTAMPS是否关闭。
检查顺序建议是:先看 ObjectMapper 注册了哪些模块,再看全局特性配置,最后看字段上的 @JsonFormat。很多时候三者互相打架,全局没关时间戳、字段又写了 pattern,最终行为以字段注解为准,但日志里看到的却是全局默认行为,容易误判。
9.3 枚举反序列化匹配不上
枚举的序列化默认输出 name(),比如ACTIVE。反序列化时默认也只认枚举名,大小写敏感。问题经常出现在:数据库存的是小写active,前端传的是"active",而枚举常量是ACTIVE,结果抛InvalidFormatException。
三种常见解法:
// 方案一:在枚举常量上用 @JsonProperty 指定别名 public enum Status { @JsonProperty("active") ACTIVE, @JsonProperty("inactive") INACTIVE } // 方案二:用 @JsonCreator 自定义解析 public enum Status { ACTIVE, INACTIVE; @JsonCreator public static Status from(String value) { return Status.valueOf(value.trim().toUpperCase()); } }方案一适合别名固定的场景,方案二适合需要做容错、大小写转换、前缀映射的逻辑。注意 @JsonCreator 如果写得太宽泛,未知值的行为要自己兜住,不要返回 null 或者抛一个难懂的原生异常。
9.4 布尔字段命名导致 JSON key 多出 “is”
布尔字段是命名问题的重灾区。典型情况是字段叫isDeleted,Java 里也这么写,IDE 自动生成的 getter 是isIsDeleted(),Jackson 根据 getter 解析出来的属性名就变成了isDeleted。看起来没问题,但如果字段叫deleted,而 getter 叫isDeleted(),Jackson 解析出的属性名其实是deleted,这时候前端按接口文档传isDeleted就会映射失败。
根源在于 Jackson 对 getter 有自己一套命名推断规则:isXxx会去掉 is 得到属性名 xxx,getXxx会去掉 get 得到 xxx。为了避免这类问题,我建议字段命名不要带 is 前缀,boolean 字段就是deleted、active,getter 生成isDeleted()、isActive(),Jackson 推导出的属性名自然就是deleted、active。如果历史代码已经用了isDeleted,直接加 @JsonProperty("isDeleted") 锁死 JSON key,别让 Jackson 猜。
9.5 同一段代码不同环境表现不一致
这个问题排查起来最费时间。同样的 DTO、同样的注解,本地 new ObjectMapper 测没问题,放到容器里运行就报未知字段异常,或者反过来。原因多半是不同环境对 ObjectMapper 的全局配置不一样:容器环境可能默认关闭了FAIL_ON_UNKNOWN_PROPERTIES,也可能自动注册了 JavaTimeModule。
遇到这种不一致,不要盯着注解看,直接打印出 ObjectMapper 的配置快照,或者写个测试用例用同一份配置跑一遍。把 ObjectMapper 的初始化集中到一个配置类里统一创建,是根治这类问题最有效的手段。
9.6 常见问题速查表
| 症状 | 根因 | 解决方向 |
|---|---|---|
| StackOverflowError | 双向引用无限递归 | @JsonIgnore / @JsonManagedReference / @JsonIdentityInfo |
| LocalDateTime 输出数组 | 未关 WRITE_DATES_AS_TIMESTAMPS | 注册 JavaTimeModule 并关闭该特性 |
| 时间少八小时 | 未指定 timezone | @JsonFormat 显式 timezone |
| 未知字段报错 | FAIL_ON_UNKNOWN_PROPERTIES 开启 | @JsonIgnoreProperties(ignoreUnknown=true) |
| 枚举小写匹配失败 | 默认只认枚举名 | @JsonProperty 别名或 @JsonCreator |
| 布尔字段名不对 | getter 命名推断规则 | 统一字段命名或 @JsonProperty |
| 抽象类反序列化失败 | 缺少多态类型信息 | @JsonTypeInfo + @JsonSubTypes |
| 不可变对象无法构建 | 没有无参构造器/setter | @JsonCreator + @JsonProperty |
10. 我给新项目的默认配置模板
10.1 一份可以直接抄的 ObjectMapper 配置
每次新项目启动,我都会先给 ObjectMapper 一份基础配置,把大部分全局性的坑提前堵掉:
ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);这里有几个选择你要结合团队情况决定:SNAKE_CASE 全局命名是否必要,如果历史接口是驼峰,就别为了“好看”全局改,否则所有 @JsonProperty 都要跟着动;FAIL_ON_UNKNOWN_PROPERTIES 关掉代表对外部输入更宽容,代价是拼写错误不再报错。我倾向于在新系统里关掉这个开关,因为新系统对接方多、字段迭代快,宽容一点比频繁出 500 更友好。
10.2 注解选用的个人习惯
最后分享一些我自己的取舍习惯,供参考。
字段重命名我优先用 @JsonProperty 标字段,不标 getter/setter,因为双向生效、行为可预期。兼容老接口的多种字段名用 @JsonAlias,只在反序列化方向放宽。需要隐藏字段时,第一反应是 @JsonIgnore 字段,而不是在 getter 上做文章。时间格式化尽量全项目统一,要么在 DTO 上加 @JsonFormat,要么全局注册自定义序列化器,不要两种混着来。
多态设计我会在一开始就规划好类型标记字段,宁可多一个 ename 字段也不要临时补丁。不可变对象一律用 @JsonCreator + @JsonProperty,明确写出构造参数映射。自定义序列化器只在注解覆盖不了需求时才引入,而且务必写成无状态实现。
在内网接口开发里,这些配置看起来琐碎,但它们决定了接口的稳定性、可维护性,也决定了你半夜被叫起来排查线上问题的概率。Jackson 注解的复杂度不在记忆,而在理解每条链路上发生了什么。把 ObjectMapper 的全局配置和字段级的注解放在一起想,多数问题都能在写入代码前就避免掉。