在 Spring Boot 4 里,Jackson 3 用多参数构造器反序列化,大多数情况连 @JsonCreator 都不用写。前提是类文件里带着参数名,而 Spring Boot 的 starter parent 默认就打开了 -parameters。一旦把这个编译参数关掉,同一批类几乎全部反序列化失败,HTTP 接口直接 500。

这篇对着 Baeldung 的 JSON Deserialization with Multiple Parameters Constructor Using Jackson 重走了一遍。原文基于 Jackson 2.x,要手动引入 jackson-module-parameter-names、配 ConstructorDetector。我用的是 Spring Boot 4.1.1 自带的 Jackson 3.1.5,有几条结论和原文不一样。下文引用的 Jackson 2 报错文本和原文结论都出自 Baeldung 这篇文章。

环境

JDK 27 GA(build 27+35-2325),Spring Boot 4.1.1,依赖只有 spring-boot-starter-webmvc。打出来的 fat jar 里和 Jackson 相关的就这几个:

BOOT-INF/lib/spring-boot-jackson-4.1.1.jar
BOOT-INF/lib/jackson-databind-3.1.5.jar
BOOT-INF/lib/jackson-annotations-2.21.jar
BOOT-INF/lib/jackson-core-3.1.5.jar

没有 jackson-module-parameter-names。按 Jackson 3.0 的发布说明,这个模块的功能(databind#1775)已经并入 jackson-databind,构造器参数名自动检测默认开启,可以用 MapperFeature.DETECT_PARAMETER_NAMES 关掉。Jackson 3 的包名换成了 tools.jackson,注解还在 com.fasterxml.jackson.annotation,所以下面代码里两种包名都会出现。

-parameters 是 spring-boot-starter-parent 给的。它的 pom 里 maven-compiler-plugin 配了 <parameters>true</parameters>。为了对照,我在项目里加了个 profile 把它关掉:

<profiles>
  <profile>
    <id>no-parameters</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <configuration>
            <parameters>false</parameters>
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

用 javap 看得出区别。默认构建下,构造器带着 MethodParameters 属性:

    MethodParameters:
      Name                           Flags
      currency
      price

用 -Pno-parameters 构建后,同一条 javap ... | grep -A3 MethodParameters 什么都没输出。

测试类

沿用原文的 Ticket 例子,JSON 也一样:

{"event":"Devoxx","guest":"Maria Monroe","currency":"EUR","price":50}

最基本的一个类只有一个两参构造器,不加任何 Jackson 注解,guest 走 setter,eventName 只有字段上的 @JsonProperty("event"):

public class TicketNoAnnotation {
    @JsonProperty("event")
    private String eventName;
    private String guest;
    private final Currency currency;
    private final int price;

    public TicketNoAnnotation(Currency currency, int price) {
        this.currency = currency;
        this.price = price;
    }

    public void setGuest(String guest) { this.guest = guest; }
    public String getEventName() { return eventName; }
    public String getGuest() { return guest; }
    public Currency getCurrency() { return currency; }
    public int getPrice() { return price; }

    @Override
    public String toString() {
        return "Ticket[event=" + eventName + ", guest=" + guest + ", currency=" + currency + ", price=" + price + "]";
    }
}

另外几个类对应原文里的各种情况:TicketAnnotated 在构造器上写全 @JsonCreator 和参数上的 @JsonProperty;TicketTwoCtors 有两参和三参两个构造器,都不加注解;TicketTwoCreators 两个构造器都标 @JsonCreator;还有 record 和枚举,后面单独说。

所有类都在一个 ApplicationRunner 里用 Spring Boot 自动配置的 JsonMapper 反序列化,失败就打印异常类名和第一行消息:

@Component
public class CreatorReport implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(CreatorReport.class);

    static final String TICKET = """
            {"event":"Devoxx","guest":"Maria Monroe","currency":"EUR","price":50}""";
    static final String GUEST = """
            {"firstname":" Maria ","surname":"Monroe","id":7}""";
    static final String CURRENCY = """
            {"fullName":"Pound sterling","fractionalUnit":"penny"}""";

    private final JsonMapper mapper;

    CreatorReport(JsonMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        log.info("Jackson {}, -parameters: {}", tools.jackson.databind.cfg.PackageVersion.VERSION,
                TicketNoAnnotation.class.getConstructors()[0].getParameters()[0].isNamePresent());
        read("TicketNoAnnotation", TICKET, TicketNoAnnotation.class);
        read("TicketAnnotated", TICKET, TicketAnnotated.class);
        read("TicketTwoCtors", TICKET, TicketTwoCtors.class);
        read("TicketTwoCreators", TICKET, TicketTwoCreators.class);
        read("Guest(record + @JsonCreator ctor)", GUEST, Guest.class);
        read("GuestFactory(record + @JsonCreator factory)", GUEST, GuestFactory.class);
        log.info("ObjCurrency.GBP serialized: {}", mapper.writeValueAsString(ObjCurrency.GBP));
        read("ObjCurrency", CURRENCY, ObjCurrency.class);
    }

    private void read(String name, String json, Class<?> type) {
        try {
            log.info("{} -> {}", name, mapper.readValue(json, type));
        } catch (Exception e) {
            String msg = e.getMessage().lines().findFirst().orElse("");
            log.info("{} -> {}: {}", name, e.getClass().getName(), msg);
        }
    }
}

再加一个接口,看 Spring MVC 收请求体时是不是同样的行为:

@RestController
public class TicketController {

    @PostMapping("/tickets")
    public String create(@RequestBody TicketNoAnnotation ticket) {
        return ticket.toString();
    }
}

默认构建:不写注解也能用

2026-10-14T08:06:28.933+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : Jackson 3.1.5, -parameters: true
2026-10-14T08:06:28.993+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : TicketNoAnnotation -> Ticket[event=Devoxx, guest=Maria Monroe, currency=EUR, price=50]
2026-10-14T08:06:28.996+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : TicketAnnotated -> Ticket[event=Devoxx, guest=Maria Monroe, currency=EUR, price=50]

Baeldung 原文第 3 节说,只有两参构造器、没有无参构造器时,Jackson 2 会抛 MismatchedInputException,说找不到 Creator。在 Jackson 3 加 -parameters 的组合下,这个类直接就反序列化成功了,构造器参数按名字对上 currency 和 price,guest 走 setter,event 通过字段注解写进 eventName。这和发布说明里参数名检测并入 databind 的变化对得上:类文件里有参数名,单个公开构造器就能被当成按属性构造的 Creator。

HTTP 接口也一样:

$ curl -s -X POST localhost:18082/tickets -H 'Content-Type: application/json' -d '{"event":"Devoxx","guest":"Maria Monroe","currency":"EUR","price":50}'
Ticket[event=Devoxx, guest=Maria Monroe, currency=EUR, price=50]

但构造器一多,自动检测就不干了:

2026-10-14T08:06:28.998+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : TicketTwoCtors -> tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.spring4all.jackson.model.TicketTwoCtors` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

有两个可选的构造器,Jackson 3 不会替你选,这时候还得用 @JsonCreator 指定一个。两个都标上,报的是原文提到的那个冲突:

2026-10-14T08:06:28.999+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : TicketTwoCreators -> tools.jackson.databind.exc.InvalidDefinitionException: Conflicting property-based creators: already had explicit creator [constructor for `com.spring4all.jackson.model.TicketTwoCreators` (2 args), annotations: {interface com.fasterxml.jackson.annotation.JsonCreator=@com.fasterxml.jackson.annotation.JsonCreator(mode=DEFAULT)}, encountered another: [constructor for `com.spring4all.jackson.model.TicketTwoCreators` (3 args), annotations: {interface com.fasterxml.jackson.annotation.JsonCreator=@com.fasterxml.jackson.annotation.JsonCreator(mode=DEFAULT)}

Baeldung 原文贴的 Jackson 2 异常是 com.fasterxml.jackson.databind.JsonMappingException,消息里写的是 already had explicitly marked creator。到了 Jackson 3.1.5,异常类型变成 tools.jackson.databind.exc.InvalidDefinitionException,消息变成 already had explicit creator。项目里有按异常类名或消息做判断的代码,升级时要一起改。

record 的非规范构造器会被用上

Baeldung 原文第 5 节说,record 上用 @JsonCreator 标一个非规范构造器,Jackson 会忽略它,改用规范构造器。我照着写了一个,构造器把 id 拼到 surname 后面,方便看到底走了哪个:

public record Guest(String firstname, String surname) {

    @JsonCreator
    public Guest(String firstname, String surname, int id) {
        this(firstname, surname + "#" + id);
    }
}

输入是 {"firstname":" Maria ","surname":"Monroe","id":7},结果:

2026-10-14T08:06:29.002+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : Guest(record + @JsonCreator ctor) -> Guest[firstname= Maria , surname=Monroe#7]

surname 变成了 Monroe#7,说明 Jackson 3.1.5 用的就是这个三参构造器,和原文的结论相反。这个变化不是 Jackson 3 才有:jackson-databind 2.15 起通过 PR #3724 让 record 改走和普通 POJO 相同的 Creator 检测机制,显式标了 @JsonCreator 的构造器会被采用,Jackson 3 沿用了这套机制。静态工厂方法也能用,我在工厂里顺手 trim 了一下:

public record GuestFactory(String firstname, String surname) {

    @JsonCreator
    public static GuestFactory fromJson(String firstname, String surname, int id) {
        return new GuestFactory(firstname.trim(), surname.trim() + "#" + id);
    }
}
2026-10-14T08:06:29.004+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : GuestFactory(record + @JsonCreator factory) -> GuestFactory[firstname=Maria, surname=Monroe#7]

枚举按对象形状反序列化

枚举加上 @JsonFormat(shape = JsonFormat.Shape.OBJECT) 后会序列化成对象,反序列化要靠一个 @JsonCreator 静态工厂。Baeldung 原文的工厂方法里把 fractionalUnit 拼成了 franctionalUnit,照抄编译不过,修正后是这样:

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ObjCurrency {
    EUR("Euro", "cent"),
    GBP("Pound sterling", "penny");

    private final String fullName;
    private final String fractionalUnit;

    ObjCurrency(String fullName, String fractionalUnit) {
        this.fullName = fullName;
        this.fractionalUnit = fractionalUnit;
    }

    public String getFullName() { return fullName; }
    public String getFractionalUnit() { return fractionalUnit; }

    @JsonCreator
    public static ObjCurrency fromJson(String fullName, String fractionalUnit) {
        for (ObjCurrency c : values()) {
            if (c.fullName.equalsIgnoreCase(fullName) && c.fractionalUnit.equalsIgnoreCase(fractionalUnit)) {
                return c;
            }
        }
        throw new IllegalArgumentException("Unknown currency: " + fullName + " " + fractionalUnit);
    }
}
2026-10-14T08:06:29.017+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : ObjCurrency.GBP serialized: {"fullName":"Pound sterling","fractionalUnit":"penny"}
2026-10-14T08:06:29.018+08:00  INFO 781558 --- [           main] com.spring4all.jackson.CreatorReport     : ObjCurrency -> GBP

工厂方法的参数上同样没写 @JsonProperty。

关掉 -parameters 之后

用 mvn -q clean package -DskipTests -Pno-parameters 重新打包,同一个 jar 再跑一次:

2026-10-14T08:06:35.126+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : Jackson 3.1.5, -parameters: false
2026-10-14T08:06:35.172+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : TicketNoAnnotation -> tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.spring4all.jackson.model.TicketNoAnnotation` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
2026-10-14T08:06:35.183+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : TicketAnnotated -> Ticket[event=Devoxx, guest=Maria Monroe, currency=EUR, price=50]
2026-10-14T08:06:35.186+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : TicketTwoCtors -> tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.spring4all.jackson.model.TicketTwoCtors` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
2026-10-14T08:06:35.187+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : TicketTwoCreators -> tools.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.spring4all.jackson.model.TicketTwoCreators`: More than one argument (#0 and #1) left as delegating for Creator [constructor for `com.spring4all.jackson.model.TicketTwoCreators` (2 args), annotations: {interface com.fasterxml.jackson.annotation.JsonCreator=@com.fasterxml.jackson.annotation.JsonCreator(mode=DEFAULT)}: only one allowed
2026-10-14T08:06:35.189+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : Guest(record + @JsonCreator ctor) -> tools.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.spring4all.jackson.model.Guest`: More than one argument (#0 and #1) left as delegating for Creator [constructor for `com.spring4all.jackson.model.Guest` (3 args), annotations: {interface com.fasterxml.jackson.annotation.JsonCreator=@com.fasterxml.jackson.annotation.JsonCreator(mode=DEFAULT)}: only one allowed
2026-10-14T08:06:35.190+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : GuestFactory(record + @JsonCreator factory) -> tools.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.spring4all.jackson.model.GuestFactory`: More than one argument (#0 and #1) left as delegating for Creator [method com.spring4all.jackson.model.GuestFactory#fromJson(3 params)]: only one allowed
2026-10-14T08:06:35.204+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : ObjCurrency -> tools.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.spring4all.jackson.model.ObjCurrency`: More than one argument (#0 and #1) left as delegating for Creator [method com.spring4all.jackson.model.ObjCurrency#fromJson(2 params)]: only one allowed

只有在参数上写了 @JsonProperty 的 TicketAnnotated 还能用。其余几个要么找不到 Creator,要么报 More than one argument (#0 and #1) left as delegating:Jackson 拿不到参数名,就把 @JsonCreator 当成委托式 Creator 处理,而委托式只允许一个参数。这和 Baeldung 原文第 4.2 节讲的情况是一回事,只是 Jackson 3 的报错文案不同。

接口这边返回的是 500,不是 400:

{"timestamp":"2026-10-14T00:06:36.160Z","status":500,"error":"Internal Server Error","path":"/tickets"}
2026-10-14T08:06:36.150+08:00 ERROR 781906 --- [io-18082-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class com.spring4all.jackson.model.TicketNoAnnotation]] with root cause

InvalidDefinitionException 属于类型定义错误,Spring MVC 把它当成服务端问题,记 ERROR 日志并返回 500。

序列化结果也变了:

2026-10-14T08:06:35.203+08:00  INFO 781906 --- [           main] com.spring4all.jackson.CreatorReport     : ObjCurrency.GBP serialized: {"fractionalUnit":"penny","fullName":"Pound sterling"}

带 -parameters 时输出是 fullName 在前,关掉之后变成 fractionalUnit 在前。Jackson 会参考 Creator 参数的顺序来排属性,拿不到参数名时这层信息就没了。有接口对比 JSON 字符串、或者对字段顺序有约定的,这一点要留意。

-parameters 不只是 Maven 的事。用 Gradle、或者在 IDE 里直接编译而没有走 starter parent 的配置时,都可能不带这个参数。Gradle 的 Spring Boot 插件会给 JavaCompile 任务加上 -parameters,不用 starter parent 的 Maven 项目则需要自己在 maven-compiler-plugin 里配。

之前那篇把 JDK 28 孵化的 JSON API 和 Jackson 放在一起对比的文章讲的是解析的严格程度,这篇关心的是对象怎么构造出来,两篇用的都是 Spring Boot 4.1.1 自带的 Jackson 3.1.5。

参考:Baeldung,JSON Deserialization with Multiple Parameters Constructor Using Jackson(Ticket、Guest、Currency 示例与对照的结论来自此文)。

— 感谢阅读 —
广告

一起交流

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