Spring Boot中Jackson JSON解析库实战:ObjectMapper与序列化详解

发布时间:2026/10/6 10:25:15
Spring Boot中Jackson JSON解析库实战:ObjectMapper与序列化详解 从大学写第一行Java代码到现在我大概也换过四五种 JSON 解析库。早期用过 json-lib后来切到 fastjson再后来在 Spring Boot 项目里默认用的就是 Jackson。说实话Jackson 属于那种“刚开始觉得没毛病越用越觉得底子厚”的库。它不像某些库那样一上来给一堆炫酷的注解但你把它的运作机制搞明白之后几乎能处理掉线上 90% 的 JSON 序列化、反序列化问题。这篇文章就以“Java 和 Spring Boot 项目里的 JSON 解析库”为切入点专门聊聊 Jackson。我会从它的设计思路讲起逐步拆解 ObjectMapper 的核心用法、Spring Boot 自动配置机制、常用注解、时间类型处理、循环引用、多态序列化这类硬核问题最后再分享一些我实测过的踩坑记录。无论你是刚接触 Spring Boot 的新手还是想系统性梳理 Jackson 的老手这篇都能给你一个足够完整、可以直接照着落地的参考。1. 内容整体设计与思路拆解1.1 为什么 Spring Boot 默认选择了 JacksonSpring Boot 官方把 Jackson 作为默认的 JSON 处理库这个选择背后是有充分理由的。首先Jackson 的序列化和反序列化基于流式 APIStreaming API底层是JsonParser和JsonGenerator这意味着它不需要把整个 JSON 文档一次性加载到内存里也可以处理性能上限更高。其次Jackson 的模块化生态做得非常成熟比如jackson-datatype-jsr310用来支持 Java 8 时间类型jackson-datatype-jdk8用来支持Optionaljackson-module-kotlin支持 Kotlin 数据类这些都是官方维护的模块兼容性有保障。还有一个容易被忽视的点Jackson 的社区活跃度和版本迭代节奏非常稳。别看它有时候更新不频繁但每次更新基本都是为了解决问题而不是为了刷版本号。对比之下某些国产 JSON 库在快速迭代过程中暴露出过不少安全漏洞修复速度也跟不上。对于框架维护者来说“稳定”比“功能多”更重要。Spring Boot 选择 Jackson本质上是在帮你规避掉大量底层风险。1.2 Jackson 的核心设计围绕 ObjectMapper 展开你要理解 Jackson只需要抓住一个核心类com.fasterxml.jackson.databind.ObjectMapper。这个类几乎承担了所有高层操作。序列化调用objectMapper.writeValueAsString(obj)把 Java 对象转成 JSON 字符串。反序列化调用objectMapper.readValue(json, User.class)把 JSON 字符串转回 Java 对象。树模型调用objectMapper.readTree(json)把 JSON 解析成一棵JsonNode树适合动态解析结构不固定的 JSON。我之前见过一些开发者对 Jackson 的理解停留在“会用writeValueAsString就行”但这种用法在遇到特殊类型、嵌套泛型、自定义序列化需求时会非常被动。真正专业的做法是先理解 Jackson 的三层架构核心层Core提供流式读写 API也就是JsonParser/JsonGenerator。数据类型绑定层Databind也就是 ObjectMapper 所在层级负责把 Java 对象和 JSON 树互相转换。注解模块Annotations通过注解定制序列化行为例如字段重命名、忽略字段等。这三层各司其职你可以单独使用流式 API 做高性能处理也可以用 ObjectMapper 做常规转换还可以在覆盖某个字段时自定义序列化器JsonSerializer和反序列化器JsonDeserializer。熟练之后你会发现自己几乎不需要引入第二个 JSON 库。2. 核心细节解析与实操要点2.1 ObjectMapper 的常用 API 详解我平时最常用的几个方法整理成下面这张表方便你查阅方法作用示例writeValueAsString(Object)对象转 JSON 字符串mapper.writeValueAsString(user)writeValue(File, Object)对象直接写入文件mapper.writeValue(new File(a.json), user)readValue(String, Class)JSON 字符串转对象mapper.readValue(json, User.class)readValue(String, TypeReference)带泛型的反序列化mapper.readValue(json, new TypeReferenceListUser(){})readTree(String)解析为 JsonNode 树JsonNode node mapper.readTree(json)writeValueAsBytes(Object)对象转字节数组用于消息队列、缓存存储readerFor(Class)/writerFor(Class)构建流式读写器配合readValues处理多行 JSON注意一个细节readValue支持的方法重载非常多除了传Class还可以传JavaType。当你需要反序列化ListUser、MapString, ListOrder这类复杂泛型时Class是不够用的必须用TypeReference或者手动构建JavaType。ObjectMapper mapper new ObjectMapper(); String json [{\name\:\张三\,\age\:20}]; // 错误用法类型信息丢失 // ListUser users mapper.readValue(json, List.class); // 正确用法一TypeReference ListUser users mapper.readValue(json, new TypeReferenceListUser() {}); // 正确用法二构建 JavaType JavaType type mapper.getTypeFactory().constructParametricType(List.class, User.class); ListUser users2 mapper.readValue(json, type);这里有个容易踩的坑如果直接传List.classJackson 反序列化出来的是ListLinkedHashMap后续你调用user.getName()时就会抛ClassCastException。我在项目里见过好几次这种低级错误排查了半天才发现是泛型擦除导致的。2.2 常用注解的底层逻辑Jackson 的注解非常多但真正天天用的其实就那几个。我把它们分成三组来理解。第一组是字段映射注解核心是JsonProperty和JsonAlias。JsonProperty显式指定 JSON 字段名。加在字段上表示“序列化和反序列化时都用这个名字”。JsonAlias反序列化时允许接受多个别名但序列化时仍然只输出主名称。比如前端传给你一个字段叫user_name但你的 Java 实体是userName你就可以用JsonProperty(user_name)来映射。如果对接第三方接口对方在不同环境下的字段名不一样就用JsonAlias。第二组是忽略和过滤注解核心是JsonIgnore、JsonIgnoreProperties、JsonInclude。JsonIgnore加在字段上序列化和反序列化时都忽略。JsonIgnoreProperties加在类上可以批量忽略多个字段。更常用的是加在属性上忽略“未知字段”不过这个功能一般通过FAIL_ON_UNKNOWN_PROPERTIES全局配置。JsonInclude控制字段在什么条件下才输出。比如JsonInclude(Include.NON_NULL)表示值为 null 的字段不序列化。第三个是格式注解核心是JsonFormat。public class OrderDTO { JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime; JsonFormat(pattern yyyy-MM-dd) private LocalDate payDate; JsonFormat(shape JsonFormat.Shape.STRING) private BigDecimal amount; }JsonFormat最常见的用法就是格式化时间。但要注意如果项目里用的是LocalDateTime光靠JsonFormat还不够因为 Jackson 默认对 Java 8 时间类型的支持是通过jackson-datatype-jsr310模块实现的你必须先注册JavaTimeModule。这也是后面第 3 节要重点展开的内容。2.3 配置项里的几个关键开关除了注解Jackson 还可以通过ObjectMapper的配置方法做全局设置。我建议你在项目初始化时统一设置好避免“每个接口单独调注解”这种既累又容易漏的做法。ObjectMapper mapper new ObjectMapper(); // 忽略 JSON 中存在、但 Java 对象不存在的字段 mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 空字符串转 null而不是抛出异常 mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); // 枚举反序列化时大小写不敏感 mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); // 时间日期默认格式 mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // 空对象不抛异常输出 {} mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);这些配置为什么重要我给你举个实际例子对外接口经常有“前端少传了一个字段”的情况。默认情况下Jackson 遇到未知字段会直接抛UnrecognizedPropertyException导致接口 500。你不可能要求所有调用方都严格对齐字段所以FAIL_ON_UNKNOWN_PROPERTIES这个开关只要是面向公网的接口我建议一律设为false。但如果是内部系统间高频调用的接口反而建议打开这样可以及时暴露字段不一致的问题。3. 实操过程与核心环节实现3.1 Spring Boot 中 Jackson 的自动配置逻辑Spring Boot 通过JacksonAutoConfiguration自动帮你配置好了一个ObjectMapperBean。这意味着你不需要自己手动new ObjectMapper()直接使用Autowired注入即可。Spring Boot 2.x 和 3.x 的自动配置逻辑基本一致核心都在这几个类JacksonAutoConfiguration入口配置类负责加载所有 Jackson 相关的自动配置。Jackson2ObjectMapperBuilder构建 ObjectMapper 的工厂类内部处理了模块注册、属性命名策略、日期格式等。JacksonProperties对应application.yml里spring.jackson.*系列配置。所以你会发现在 Spring Boot 项目里你什么都不用配置writeValueAsString和readValue就能正常工作。如果你对默认行为不满意可以在application.yml里做基础覆盖spring: jackson: default-property-inclusion: non_null date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 deserialization: fail-on-unknown-properties: false serialization: fail-on-empty-beans: false这里有一个常见误区spring.jackson.date-format只对java.util.Date类型生效对LocalDateTime是不生效的。很多新手配置了date-format后发现LocalDateTime还是输出数组格式于是怀疑配置没生效。实际上LocalDateTime的格式化需要单独注册JavaTimeModule并设置WRITE_DATES_AS_TIMESTAMPSfalse或者借助JsonFormat字段注解。3.2 自定义 ObjectMapper 的正确姿势当application.yml不够用时就需要在代码里自定义 ObjectMapper。我见过两种做法对比一下第一种做法直接覆盖 Bean。Configuration public class JacksonConfig { Bean Primary public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } }第二种做法使用Jackson2ObjectMapperBuilderCustomizer这也是我比较推荐的方式。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializationInclusion(JsonInclude.Include.NON_NULL); builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); builder.modules(new JavaTimeModule()); }; } }第二种方式为什么更好因为 Spring Boot 本身可能还会在内部ObjectMapper上做其他初始化操作比如注册SpringBootJaxrsJsonDeserializer、处理Jackson2ObjectMapperBuilder默认配置等。如果你直接用Primary替换掉整个 Bean可能会导致某些模块没有正确注册。而Jackson2ObjectMapperBuilderCustomizer是在 Spring Boot 构建 ObjectMapper 之后、完成最终配置之前进行回调相当于“在原基础上做定制”更安全。3.3 处理 LocalDateTime 和其他 Java 8 时间类型这是 Jackson 使用里最值得展开的一节。Java 8 引入LocalDate、LocalDateTime、Instant等时间类型之后Jackson 的核心包并不能直接处理它们因为 JDK 8 的时间类型不在 Jackson 的默认类型处理范围内。解决办法是引入jackson-datatype-jsr310并注册JavaTimeModule。如果你用的是 Spring Boot依赖管理已经内置了这个模块只需要直接添加即可不需要写版本号dependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jsr310/artifactId /dependency注册后的常见问题是LocalDateTime默认被序列化成类似[2024, 5, 20, 15, 30, 0]的数组结构或者是一长串时间戳。这显然不符合大多数业务接口的需求。我的处理方式是全局关闭WRITE_DATES_AS_TIMESTAMPS这样 Jackson 就会走 ISO 格式。mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);如果你希望输出yyyy-MM-dd HH:mm:ss这种格式我建议在实体字段上用JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)控制单个接口格式。用ObjectMapper全局配置加上LocalDateTimeSerializer自定义格式实现统一输出。JavaTimeModule module new JavaTimeModule(); LocalDateTimeSerializer localDateTimeSerializer new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); module.addSerializer(LocalDateTime.class, localDateTimeSerializer); mapper.registerModule(module);这个方案适合“全项目统一时间格式”的场景。如果你发现某个字段需要特殊格式再用JsonFormat覆盖即可。3.4 属性命名策略与 DTO 设计JSON 字段命名风格在不同业务里差异很大前端可能传userName第三方接口可能传user_name。如果你不想在每个字段上都加JsonProperty可以调整全局命名策略。spring: jackson: property-naming-strategy: SNAKE_CASE对应的 Java 配置为mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);设置之后Java 类里的userName字段会自动序列化成user_name反序列化时也能自动识别user_name并映射到userName。这里要提醒一下命名策略是全局行为影响所有实体类。如果你只跟某一个第三方接口对接其他接口都是常规驼峰命名那就不建议改全局配置而是在那个 DTO 上逐字段加JsonProperty或者单独构建一个专用于该接口的 ObjectMapper。这种局部 Bean 的做法很实用Bean Qualifier(thirdPartyMapper) public ObjectMapper thirdPartyMapper() { ObjectMapper mapper new ObjectMapper(); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); return mapper; }调用第三方接口时用thirdPartyMapper其他接口用默认ObjectMapper互不干扰。4. 常见问题与排查技巧实录4.1 循环引用导致 StackOverflowError这是 Jackson 序列化中最经典的问题两个对象互相引用比如User里有ListOrderOrder里又有User。序列化时 Jackson 会不断往下递归最终抛出StackOverflowError。我遇到这类问题时第一反应不是改实体结构而是先想清楚业务上到底需不需要双向关系。很多情况下Order里的User字段其实在接口返回时根本用不上直接在字段上加JsonIgnore是最干脆的解法。如果确实需要返回部分信息可以用JsonManagedReference和JsonBackReferencepublic class User { private Long id; private String name; JsonManagedReference private ListOrder orders; } public class Order { private Long id; private String orderNo; JsonBackReference private User user; }序列化User时Jackson 会输出orders数组但每个Order里的user字段会被忽略从而打破循环。还有一种更通用的做法是用JsonIdentityInfo它的逻辑是同一个对象在 JSON 中第一次出现时输出完整内容后续再次出现时只输出唯一标识。JsonIdentityInfo(generator ObjectIdGenerators.PropertyGenerator.class, property id) public class User { private Long id; private String name; private ListOrder orders; }这种方式保留的关联信息更多适合复杂对象图序列化但输出格式对前端来说不够直观需要前端配合处理。综合来看我的建议排序是优先用 DTO 裁剪字段其次用JsonIgnore最后才考虑JsonIdentityInfo。4.2 反序列化时报“无法构造对象”这个报错信息通常是Cannot construct instance of ... (no Creators, like default constructor, ...)。Jackson 反序列化时默认通过无参构造函数创建对象然后逐字段赋值。如果你的实体类只有有参构造器没有无参构造器就会触发这个异常。我见过很多 Lombok 用户踩这个坑类上加了AllArgsConstructor但忘记加NoArgsConstructor。看起来代码编译没问题到了运行时 Jackson 就是创建不了对象。最简单的解决方式是补上无参构造器Data NoArgsConstructor AllArgsConstructor public class User { private String name; }如果你的类是不可变对象必须通过有参构造器创建那可以使用JsonCreator和JsonProperty注解标记构造器参数public class User { private final String name; JsonCreator public User(JsonProperty(name) String name) { this.name name; } public String getName() { return name; } }这种方式在 DDD 领域模型里很常见既能保持对象不可变又能让 Jackson 正确完成反序列化。4.3 Boolean 类型字段的 is 前缀陷阱Java 中 Boolean 字段有一个很常见的编写习惯字段命名用isDeleted但业务上通常只写deleted。Lombok 的Data对boolean类型字段生成的是isDeleted()方法对Boolean包装类型生成的是getDeleted()方法。Jackson 的序列化机制依赖 getter 方法名推导字段名这就会导致同一个字段在不同写法下序列化结果不同。举个例子public class User { private boolean active; public boolean isActive() { return active; } public void setActive(boolean active) { this.active active; } }序列化后Jackson 会把字段名识别为active输出{active: true}。但如果你的 getter 方法写成了getActive()序列化结果就可能变成{active: true}和{isActive: true}两种结果取决于具体配置。这个不一致很容易造成前端解析字段出错。我的建议是不要在Boolean包装类型上乱用is前缀命名尽量保持private Boolean activegetter 统一用getActive()。如果为了兼容某些前端定了isActive字段就用JsonProperty显式固定 JSON 字段名。4.4 BigDecimal 精度与科学计数法金额字段在 JSON 传输中有个隐藏陷阱当BigDecimal数值较大或很小的时候Jackson 默认可能输出为科学计数法比如1.0E8。虽然 Java 那边BigDecimal能正常解析但前端 JavaScript 看到这种格式会非常头疼。通常我用两种方式处理第一种在字段上用JsonFormatJsonFormat(shape JsonFormat.Shape.STRING) private BigDecimal amount;直接让 Jackson 把BigDecimal序列化成字符串前端拿到100000000.00这样的值就不会丢失精度。第二种全局自定义序列化器SimpleModule module new SimpleModule(); module.addSerializer(BigDecimal.class, new JsonSerializerBigDecimal() { Override public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeString(value.setScale(2, RoundingMode.HALF_UP).toPlainString()); } }); mapper.registerModule(module);这种做法的好处是全项目统一把金额输出成字符串坏处是无法区分“金额字段”和“普通数值字段”。如果项目里恰好有精度要求不高的数值字段建议还是用JsonFormat按需处理。4.5 多态类型序列化与反序列化在面向接口编程的项目里经常需要把父类引用序列化成不同的子类对象比如消息推送场景Message是父类TextMessage、ImageMessage是子类。直接序列化父类对象时Jackson 只会输出父类的字段子类的字段全部丢失。反序列化时也无法根据 JSON 内容确定具体的子类类型。Jackson 的多态处理机制是通过JsonTypeInfo来实现的。在父类上标记JsonTypeInfo(use JsonTypeInfo.Id.NAME, include JsonTypeInfo.As.PROPERTY, property type) JsonSubTypes({ JsonSubTypes.Type(value TextMessage.class, name text), JsonSubTypes.Type(value ImageMessage.class, name image) }) public class Message { private String msgId; private Long sendTime; }这样序列化时Jackson 会额外输出一个type字段值为text或image。反序列化时Jackson 会根据type的值决定创建哪个子类实例。我实际用下来有个体会多态确实灵活但也有 AB 兼容负担。一旦JsonSubTypes里的类型标识变化老数据可能就解析不出来了。所以如果需要长期维护的接口我反而会建议直接在前端显示层做一个 DTO 映射而不是把多态结构直接暴露给外部。因为 JSON 本质上是一种数据交换格式强行加入多态信息会导致接口耦合度变高。4.6 性能优化与流式解析最后一个技巧是关于大 JSON 文件解析的。如果你的接口需要处理几百 MB 级别的 JSON 数据用readValue(json, SomeClass.class)把整个文档加载进内存很可能导致 OOM。这时候就要用 Jackson 的流式 API。JsonFactory factory new JsonFactory(); try (JsonParser parser factory.createParser(new File(large.json))) { while (parser.nextToken() ! JsonToken.END_OBJECT) { JsonToken token parser.currentToken(); if (token JsonToken.FIELD_NAME) { String fieldName parser.currentName(); parser.nextToken(); if (name.equals(fieldName)) { String name parser.getValueAsString(); System.out.println(name); } } } }还有一种折中方案用ObjectMapper.readerFor(...).with(...).readValues(JsonParser)结合流式读取、逐条反序列化 Java 对象。JsonParser parser factory.createParser(new File(large.json)); MappingIteratorOrder it mapper.readerFor(Order.class).readValues(parser); while (it.hasNext()) { Order order it.next(); // 逐条处理避免一次性加载 }这个方法非常适合处理大数据量导出、批量回放等场景。平时工作里很少能用上但一旦碰上就能明显体会到 Jackson 流式 API 的价值。5. 进阶实践自定义序列化器的完整示例说到自定义序列化器很多人觉得复杂其实只是需要继承JsonSerializer或JsonDeserializer然后在serialize或deserialize方法里写自己的逻辑。我之前做过一个需求接口返回的加密手机号需要脱敏只保留前三位和后四位。虽然可以在 Service 层做处理但更优雅的做法是直接定义脱敏序列化器这样所有包含手机号的实体在返回时都自动脱敏不需要到处调用工具方法。public class PhoneDesensitizer extends JsonSerializerString { private static final Pattern PHONE_PATTERN Pattern.compile(^(\\d{3})\\d{4}(\\d{4})$); Override public void serialize(String phone, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (phone null || phone.isEmpty()) { gen.writeString(phone); return; } Matcher matcher PHONE_PATTERN.matcher(phone); if (matcher.matches()) { gen.writeString(matcher.group(1) **** matcher.group(2)); } else { gen.writeString(phone); } } }字段上标注JsonSerialize(using PhoneDesensitizer.class) private String phone;类似的用法还有针对日期格式的灵活处理、针对枚举按描述输出的场景。当你掌握了自定义序列化器你才真正开始把 Jackson 当成一个“可以定制”的转换引擎而不仅仅是一个序列化工具。6. 写在最后的一点经验我在一个 Spring Boot 项目里维护了一套统一的接口返回结构所有接口都返回ResultT里面包含code、message、data三个字段。为了配合这套结构我把 Jackson 的配置也做了统一关闭未知字段报错、空值不输出、时间统一格式化、BigDecimal序列化为字符串。项目从上线到现在JSON 这一层几乎没出过线上事故。这里我特别想分享一个经验JSON 解析库的配置最好在项目初期就固化下来不要在每个接口里东调一个注解、西配一个属性。全局配置 少量字段注解才是合理的使用方式。全局配置解决 80% 的通用需求字段注解解决 20% 的特殊场景两者配合既统一又灵活。真遇到问题的时候也别急着加注解先想清楚 Jackson 在这个场景下的底层机制是什么是 getter 方法的问题还是模块没注册还是泛型被擦除了。大多数 JSON 相关的 Bug本质上都是对 Jackson 工作机制理解不透彻导致的。我可以负责任地说只要你把 ObjectMapper 的配置结构、JavaTimeModule 的注册、循环引用和泛型反序列化这几个点彻底吃透Spring Boot 项目里的 JSON 解析就不会再成为你的痛点。