把一个 Boot 3 时代的 Jackson 2 写法直接升到 Spring Boot 4.1.1,mvn package 第一下就是满屏 package com.fasterxml.jackson.databind does not exist,因为 Boot 4 默认带的是 Jackson 3,包名换成了 tools.jackson。但光改包名远远不够,自定义序列化器、ObjectMapper 的用法、异常处理、spring.jackson.* 配置都得跟着动,其中有一个旧配置会让应用直接启动失败,还有一个会被悄悄忽略。实在来不及迁,Boot 4 也还能切回 Jackson 2,下面每一步都是我在 Boot 4.1.1、Jackson 3.1.5、JDK 21.0.12.1 上编译运行出来的结果。
前几天把 JDK 28 孵化的 JSON API 放进 Spring Boot 和 Jackson 对比时用的已经是 Jackson 3.1.5,那篇没讲老项目怎么升,这次把迁移过程完整走一遍。
先看依赖到底变成了什么
spring-boot-starter-webmvc 在 4.1.1 下拉进来的 Jackson:
\- tools.jackson.core:jackson-databind:jar:3.1.5:compile
+- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
\- tools.jackson.core:jackson-core:jar:3.1.5:compile
databind 和 core 都到了 tools.jackson.core 这个 groupId 下,唯独 jackson-annotations 还是 com.fasterxml.jackson.core。这是 Jackson 3 有意为之:迁移文档写明 Jackson 3 继续使用 2.x 版的 jackson-annotations,groupId 和包名都不变,背后的讨论是为了让 Jackson 2 和 3 能在同一个应用里并存,共用同一份注解依赖。不过 databind 里的 @JsonSerialize、@JsonDeserialize 不在此列,它们搬到了 tools.jackson.databind.annotation。
拿来迁移的老代码
一个订单接口,模型上用 @JsonProperty,金额 Money 用自定义序列化器写成 "39.90 CNY" 这样的字符串,全局用 snake_case,另外有个工具类复制一份 ObjectMapper 专门输出缩进格式:
public record Order(@JsonProperty("order_id") String id, Money total, int quantity, Instant createdAt) {}
多参数构造器那部分在之前那篇文章里单独测过。
@JsonComponent
public class MoneyJson {
public static class Serializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(value.amount().toPlainString() + " " + value.currency());
}
}
public static class Deserializer extends JsonDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String[] parts = p.getValueAsString().split(" ");
return new Money(new BigDecimal(parts[0]), parts[1]);
}
}
}
@Configuration
public class JacksonConfig {
@Bean
Jackson2ObjectMapperBuilderCustomizer snakeCase() {
return builder -> builder.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}
}
@Component
public class OrderCodec {
private final ObjectMapper mapper;
public OrderCodec(ObjectMapper mapper) {
this.mapper = mapper.copy();
this.mapper.enable(SerializationFeature.INDENT_OUTPUT);
}
public String pretty(Order order) {
try {
return mapper.writeValueAsString(order);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
}
public Order parse(String json) {
try {
return mapper.readValue(json, Order.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("bad order json: " + e.getOriginalMessage(), e);
}
}
}
application.properties 里还有一行 Boot 3 项目里很常见的配置:
spring.jackson.serialization.write-dates-as-timestamps=false
第一轮编译:包名
直接编译,报错全是包不存在:
JacksonConfig.java:[3,38] package com.fasterxml.jackson.databind does not exist
JacksonConfig.java:[4,54] package org.springframework.boot.autoconfigure.jackson does not exist
MoneyJson.java:[3,34] package com.fasterxml.jackson.core does not exist
注意第二行,Boot 自己的 Jackson 自动配置也挪了位置,org.springframework.boot.autoconfigure.jackson 已经不存在了。Order.java 里那句 import com.fasterxml.jackson.annotation.JsonProperty 一个字没改,编译从头到尾没报它。
第二轮编译:只改包名之后
把 com.fasterxml.jackson.core 和 com.fasterxml.jackson.databind 全局替换成 tools.jackson.core、tools.jackson.databind,再编译,真正的 API 变化才露出来:
MoneyJson.java:[6,30] cannot find symbol
symbol: class JsonDeserializer
location: package tools.jackson.databind
MoneyJson.java:[7,30] cannot find symbol
symbol: class JsonSerializer
location: package tools.jackson.databind
MoneyJson.java:[8,30] cannot find symbol
symbol: class SerializerProvider
location: package tools.jackson.databind
MoneyJson.java:[11,40] cannot find symbol
symbol: class JsonComponent
location: package org.springframework.boot.jackson
OrderCodec.java:[3,26] cannot find symbol
symbol: class JsonProcessingException
location: package tools.jackson.core
OrderCodec.java:[14,29] cannot find symbol
symbol: method copy()
location: variable mapper of type tools.jackson.databind.ObjectMapper
OrderCodec.java:[15,20] cannot find symbol
symbol: method enable(tools.jackson.databind.SerializationFeature)
location: variable mapper of type tools.jackson.databind.ObjectMapper
下面一项一项改。
自定义序列化器:ValueSerializer 和 SerializationContext
Jackson 3 里 JsonSerializer 改名 ValueSerializer,JsonDeserializer 改名 ValueDeserializer,SerializerProvider 改名 SerializationContext。方法签名也不再声明 IOException,ValueSerializer.serialize 现在声明的是 throws JacksonException,而它是运行时异常,所以实现里干脆不用写 throws。Boot 这边的 @JsonComponent 换成了 @JacksonComponent:
@JacksonComponent
public class MoneyJson {
public static class Serializer extends ValueSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializationContext ctxt) {
gen.writeString(value.amount().toPlainString() + " " + value.currency());
}
}
public static class Deserializer extends ValueDeserializer<Money> {
@Override
public Money deserialize(JsonParser p, DeserializationContext ctxt) {
String[] parts = p.getValueAsString().split(" ");
return new Money(new BigDecimal(parts[0]), parts[1]);
}
}
}
Jackson2ObjectMapperBuilderCustomizer 换成 JsonMapperBuilderCustomizer
Boot 4 自动配置出来的是 JsonMapper,对应的定制接口是 org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer,拿到的是 JsonMapper.Builder:
@Configuration
public class JacksonConfig {
@Bean
JsonMapperBuilderCustomizer snakeCase() {
return builder -> builder.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}
}
PropertyNamingStrategies 的 import 换成 tools.jackson.databind.PropertyNamingStrategies 就行。
ObjectMapper 不能改了,用 rebuild()
copy() 和 enable(...) 报找不到,原因是 Jackson 3 的 mapper 构建完就不可变,所有配置都得在 builder 上做。想在共享的 mapper 基础上派生一个带缩进的,用 rebuild() 拿一个预填好当前配置的 builder,再 build() 出新实例。顺手把注入类型改成 JsonMapper,Boot 4 注册的就是它:
@Component
public class OrderCodec {
private final JsonMapper mapper;
public OrderCodec(JsonMapper mapper) {
this.mapper = mapper.rebuild()
.enable(SerializationFeature.INDENT_OUTPUT)
.build();
}
public String pretty(Order order) {
return mapper.writeValueAsString(order);
}
public Order parse(String json) {
try {
return mapper.readValue(json, Order.class);
} catch (JacksonException e) {
throw new IllegalArgumentException("bad order json: " + e.getOriginalMessage(), e);
}
}
}
这样写还有个好处:老代码里 copy() 一旦被漏掉,enable 改到的就是全局那个 bean,整个应用的 JSON 都会变成缩进格式。现在根本没有改共享实例的入口。测试里两边各输出一次 OrderController.SAMPLE,数一下行数:
pretty lines = 6, shared lines = 1
JsonProcessingException 没了,JacksonException 是运行时异常
pretty() 里的 try/catch 直接删掉了,因为 writeValueAsString 不再抛受检异常。Jackson 3 的根异常是 tools.jackson.core.JacksonException,它继承 RuntimeException,原来 JsonProcessingException 的位置由它接替;解析类异常放在 tools.jackson.core.exc 包下,databind 层的是 DatabindException。想统一兜底,catch JacksonException 就够了。
测试里喂一段截断的 JSON 和一段非法 JSON:
assertThatThrownBy(() -> jsonMapper.readValue("{\"order_id\":", Order.class))
.isInstanceOf(JacksonException.class)
.isInstanceOf(RuntimeException.class);
thrown = tools.jackson.core.exc.UnexpectedEndOfInputException
codec = bad order json: Unexpected character ('o' (code 111)): was expecting double-quote to start property name
改完这几处,编译通过,三个测试也都过了:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
spring.jackson.* 配置:一个启动失败,一个悄悄失效
编译过了,启动却挂了:
Failed to bind properties under 'spring.jackson.serialization' to java.util.Map<tools.jackson.databind.SerializationFeature, java.lang.Boolean>:
Reason: failed to convert java.lang.String to tools.jackson.databind.SerializationFeature (caused by java.lang.IllegalArgumentException: No enum constant tools.jackson.databind.SerializationFeature.write-dates-as-timestamps)
Jackson 3 把日期相关的开关从 SerializationFeature 挪进了单独的 DateTimeFeature,Boot 对应的配置前缀变成 spring.jackson.datatype.datetime.*。而且 Boot 4 默认就不把日期写成时间戳,所以老配置那一行直接删掉即可。删掉后默认输出:
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":"2026-10-14T00:00:00Z"}
想要时间戳的话用新前缀:
### new-date-key: --spring.jackson.datatype.datetime.write-dates-as-timestamps=true
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":1791936000.000000000}
另一个更隐蔽。spring.jackson.generator.* 和 spring.jackson.parser.* 在 spring-boot-jackson 的配置元数据里被标为 error 级别弃用,说明分别是由 spring.jackson.json.write 和 spring.jackson.json.read 部分替代。实际跑起来,旧 key 不报错也不打 WARN,就是不生效:
### old-generator-key: --spring.jackson.generator.write-numbers-as-strings=true
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":"2026-10-14T00:00:00Z"}
### new-json-write-key: --spring.jackson.json.write.write-numbers-as-strings=true
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":"2","created_at":"2026-10-14T00:00:00Z"}
应用日志里一条 WARN 都没有。
spring.jackson.serialization.*、spring.jackson.deserialization.* 本身还在,只是枚举换成了 Jackson 3 的。比如反序列化遇到未知字段,Boot 4 默认忽略,带个多余的 coupon 字段 POST 过去照样返回:
{"order_id":"A-1002","total":"5.00 CNY","quantity":1,"created_at":"2026-10-14T08:00:00Z"}
打开 --spring.jackson.deserialization.fail-on-unknown-properties=true 后同一个请求变成 400:
{"timestamp":"2026-10-14T00:06:30.190Z","status":400,"error":"Bad Request","path":"/orders"}
如果只是担心 Jackson 3 自身默认值的变化影响线上输出,可以先打开 spring.jackson.use-jackson2-defaults=true。按 Boot 4.1.1 的 JacksonAutoConfiguration 源码,这个开关会调用 builder 的 configureForJackson2(),再关掉 DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS、WRITE_DURATIONS_AS_TIMESTAMPS、FAIL_ON_UNKNOWN_PROPERTIES 和 MapperFeature.DEFAULT_VIEW_INCLUSION,也就是 Boot 3 时代的那套默认。我在订单接口上打开它请求了一次示例订单,输出和默认时一样:
### jackson2-defaults: --spring.jackson.use-jackson2-defaults=true
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":"2026-10-14T00:00:00Z"}
暂时不迁,继续用 Jackson 2
Boot 4.1.1 还保留了一个 spring-boot-jackson2 模块。官方文档的 JSON 章节写明 Jackson 2 支持已弃用,会在后续某个 Spring Boot 4.x 版本移除,只是为了方便迁移。我把最初那份老代码原样复制出来,加上依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson2</artifactId>
</dependency>
它会带进 com.fasterxml.jackson.core:jackson-databind:jar:2.21.5,同时 Jackson 3 也还在 classpath 上。老代码只需改两处 import:
import org.springframework.boot.jackson2.JsonComponent;
import org.springframework.boot.jackson2.autoconfigure.Jackson2ObjectMapperBuilderCustomizer;
配置前缀改成 spring.jackson2,并且必须告诉 MVC 用 Jackson 2 的消息转换器:
spring.http.converters.preferred-json-mapper=jackson2
spring.jackson2.serialization.write-dates-as-timestamps=false
编译时会出几条 has been deprecated and marked for removal 警告,运行结果和迁移前一致:
### jackson2:
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":"2026-10-14T00:00:00Z"}
这时 spring.jackson2.* 生效,spring.jackson.* 不再影响 HTTP 输出:
### jackson2-indent: --spring.jackson2.serialization.indent-output=true
$ curl -s http://localhost:8080/orders/sample
{
"order_id" : "A-1001",
"total" : "39.90 CNY",
"quantity" : 2,
"created_at" : "2026-10-14T00:00:00Z"
}
### jackson3-indent: --spring.jackson.serialization.indent-output=true
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":"39.90 CNY","quantity":2,"created_at":"2026-10-14T00:00:00Z"}
最容易踩的是漏掉 preferred-json-mapper。我把它改回 jackson 试了一下,应用照常启动,没有任何报错,但接口走的是 Jackson 3,Jackson 2 那边的 snake_case 和 Money 序列化器全都没用上:
### no-preferred: --spring.http.converters.preferred-json-mapper=jackson
$ curl -s http://localhost:8080/orders/sample
{"order_id":"A-1001","total":{"amount":39.90,"currency":"CNY"},"quantity":2,"createdAt":"2026-10-14T00:00:00Z"}
WebFlux 和响应式 HTTP 客户端对应的属性是 spring.http.codecs.preferred-json-mapper,这一条出自 Boot 官方文档的 JSON 章节和配置元数据,我没有另起 WebFlux 项目实测。
参考
文中 Boot 侧的类名、配置项和弃用说明我都对照了 Spring Boot v4.1.1 源码和官方文档,Jackson 侧的改名与异常体系对照了 Jackson 3 的发布说明和迁移文档:
一起交流
分享你的思考,让讨论更进一步。