把一个 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 的发布说明和迁移文档:

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。