fastjson2 反序列化 Builder 模式的 Java 类全解

1. 问题:Builder 模式的类,fastjson2 默认「转不动」

Builder 模式(GoF 创建型模式)用一个 Builder 收集参数、最后 build() 产出对象,是 Java 里构造

复杂对象的主流写法。这类类通常长这样:

  • 只有一个 (Builder) 构造器(往往 private 或包级可见)
  • 没有无参构造、没有 setter
  • 只能通过 Xxx.builder().a(..).b(..).build() 创建

这种「只能通过 Builder 构造」的写法在不可变值对象 上最常见(所有字段 final、构造后只读,

Effective Java 第 2 条推荐的形态),但 Builder 模式本身并不等于不可变------可变对象也常用 Builder。

无论目标是否可变,fastjson2 的处理机制完全一致,后文不再专门区分。

以一个典型的 Builder 类 AppConfig 为例:

java 复制代码
public final class AppConfig {
    private final String name;
    private final int retry;
    private final boolean enabled;

    AppConfig(Builder builder) {                 // 包级私有,唯一的构造器
        this.name = builder.name;                // ← builder 为 null 时这里就 NPE
        this.retry = builder.retry;
        this.enabled = builder.enabled;
    }

    public static Builder builder() { return new Builder(); }

    public static final class Builder {
        private String name;
        private int retry;
        private boolean enabled;

        public Builder name(String name) { this.name = name; return this; }
        public Builder retry(int retry) { this.retry = retry; return this; }
        public Builder enabled(boolean enabled) { this.enabled = enabled; return this; }
        public AppConfig build() { return new AppConfig(this); }
    }
}

直接反序列化:

java 复制代码
JSON.parseObject(json, AppConfig.class);

结果必然 NPE:

复制代码
java.lang.NullPointerException: Cannot read field "name" because "builder" is null
    at AppConfig.<init>(AppConfig.java:...)
    at com.alibaba.fastjson2.reader.ConstructorFunction.apply(ConstructorFunction.java:148)
    at com.alibaba.fastjson2.reader.ObjectReaderNoneDefaultConstructor.readObject(...)

2. 为什么会 NPE:fastjson2 的反序列化路径选择

ObjectReaderCreator.createObjectObjectReader 处理一个类时,按优先级依次判断走哪条路:

优先级 条件 路径
1 @JSONType(deserializer=...) 自定义 deserializer
2 @JSONCreator 标注的构造器/工厂方法 creator 路径
3 @JSONType(builder=...) builder 路径(本文主角)
4 其余 找构造器:有无参构造 → 普通路径;无无参构造 → ObjectReaderNoneDefaultConstructor

对没加任何注解的这类 Builder-only 类,前 3 条都不命中,落到第 4 条:

  1. 遍历构造器,没找到无参构造,于是选中唯一的 (Builder) 构造器;
  2. 用 ConstructorFunction 包装它,反射调用;
  3. 但 fastjson2 不知道怎么从 JSON 填充一个 Builder ------它只会按构造器参数名/位置匹配,而 (Builder) 只有一个参数 builder,JSON 里根本没有名为 builder 的字段 → 传 null;
  4. 构造器内 this.apiKey = builder.apiKey → builder 是 null → NPE。

关键认知 :fastjson2 其实原生支持 builder 反序列化 ,但它不会「猜」哪个内部类是 builder------必须用注解显式声明。下面给出三种声明方式,按「能否改目标类」分场景。

3. 方案一:@JSONType(builder=...)(自有类,能加注解)

这是最直接的方式,适用于你能修改源码的类。

3.1 基本用法

在目标类上加 @JSONType(builder = ...),指向它的 Builder 类:

java 复制代码
@JSONType(builder = User.Builder.class)          // ← 关键:声明走 builder 路径
static class User {
    private final String name;
    private final int age;

    private User(Builder b) {
        this.name = b.name;
        this.age = b.age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    public static class Builder {
        private String name;
        private int age;

        // 未开 SupportSmartMatch 时,Builder 方法名默认必须以 with 开头(详见 3.3)
        public Builder withName(String name) { this.name = name; return this; }
        public Builder withAge(int age) { this.age = age; return this; }
        public User build() { return new User(this); }
    }
}

JSON.parseObject("{\"name\":\"张三\",\"age\":28}", User.class);   // ✓ 正确填充

3.2 @JSONBuilder 的作用(可选)

可以在 Builder 类上再加 @JSONBuilder,但它是可选的------不加时 fastjson2 按默认值工作:

配置项 @JSONBuilder 不加时的默认 加了可定制为
build 方法名 "build" 任意,如 "create" / "done"
setter 前缀 "with" 任意,如 "set"

一个常见误解 :以为不加 @JSONBuilder 就不会调 build()。实测(realLombokWithoutJsonBuilder

用例)证明:不加也照常调用 build(),默认方法名就是 "build"。@JSONBuilder 只在你用非标准

的 build 方法名或前缀时才需要。

3.3 setter 匹配规则:为什么是 with 前缀

Builder 的 setter 有两条匹配路径:

  1. with 前缀路径 (默认):withName(String) → 剥前缀得字段 name。无需额外注解。
  2. fluent 路径 :方法名不带 with 前缀(如 Lombok 的 name(String)),只有 满足以下任一才识别:
    • 方法上有 @JSONField 注解;或
    • 类级开了 SupportSmartMatch feature。

3.4 SupportSmartMatch:处理 Lombok / fluent 风格

Lombok @Builder 生成的方法是 fluent 风格------方法名就是字段名,没有 with 前缀 ,方法上也没有 @JSONField:

java 复制代码
// Lombok 生成的 Builder
public static class LombokUserBuilder {
    public LombokUserBuilder name(String name) { this.name = name; return this; }
    public LombokUserBuilder age(int age) { this.age = age; return this; }
    public LombokUser build() { return new LombokUser(name, age); }
}

按 3.3 的规则,这些方法默认会被跳过 → 字段填不进去(build() 仍调用,但 builder 里是默认值)。

解决:在目标类上开 SupportSmartMatch,让 fluent 方法按方法名匹配字段:

java 复制代码
@JSONType(builder = LombokUser.LombokUserBuilder.class,
          deserializeFeatures = JSONReader.Feature.SupportSmartMatch)   // ← 关键
@lombok.Builder
public class LombokUser { ... }
⚠️ 反直觉陷阱:SmartMatch 必须写在类级注解上

很多人会尝试运行时传 feature:

java 复制代码
// ❌ 无效!字段仍然填不进去
JSON.parseObject(json, LombokUser.class, JSONReader.Feature.SupportSmartMatch);

为什么无效 :builder 的 setter 发现发生在 ObjectReader 构建期 (按类缓存),只读

beanInfo.readerFeatures,而后者只来自类级 @JSONType(deserializeFeatures=...)。

运行时 vararg feature 只进 JSONReader 实例,进不了 beanInfo.readerFeatures。

所以 SmartMatch 必须写在类级注解上,per-call 传无效。(见 lombokStyleBuilderPerCallFeatureNoEffect 用例)

3.5 作为字段类型时自动触发

目标类加了 @JSONType(builder=...) 后,作为宿主类的字段类型 时,宿主反序列化会自动触发它的 builder reader------宿主字段无需加任何 @JSONField(deserializeUsing=...):

java 复制代码
static class UserHolder {
    private String id;
    private User user;          // User 是 builder-only 类型
    // getters/setters...
}

// user 字段自动走 User 的 builder 路径
JSON.parseObject("{\"id\":\"U001\",\"user\":{\"name\":\"张三\",\"age\":28}}", UserHolder.class);

也就是说:Builder 类一旦声明了 @JSONType(builder=...),无论直接反序列化还是作为别的类的字段类型,都会自动生效,宿主侧无需任何额外配置。

3.6 Lombok 一行配方

对自有 Lombok 类,两行注解搞定,无需 @JSONBuilder、无需手写 reader:

java 复制代码
@JSONType(builder = LombokUser.LombokUserBuilder.class,
          deserializeFeatures = JSONReader.Feature.SupportSmartMatch)
@lombok.Builder
public class LombokUser { ... }

4. 方案二:Mixin(不可改的类,轻度介绍)

当目标类是第三方/外部类、加不了注解 时,用 Mixin「注入」注解------在一个空壳类上写注解,

注册到目标类上:

java 复制代码
// 空壳 mixin,只承载注解
@JSONType(builder = ExternalUser.Builder.class,
          deserializeFeatures = JSONReader.Feature.SupportSmartMatch)
static class ExternalUserMixin { }

// 注册:把 ExternalUserMixin 的注解当作 ExternalUser 的注解
JSON.mixIn(ExternalUser.class, ExternalUserMixin.class);

JSON.parseObject(json, ExternalUser.class);   // ✓ 现在能转了

Mixin 不仅仅用于 builder,它还能重命名字段、忽略字段、自定义序列化器等。

5. 方案三:子类继承 + @JSONType(不想用 Mixin 时)

第三方类加不了注解、又不想用 Mixin(Mixin 是全局注册,会影响该类所有 反序列化,见第 4 节)时,

还有第三条路:继承目标类,在子类上加 @JSONType 。子类只是注解的载体,反序列化时传入子类 .class

触发 builder 路径,返回值用父类类型接收即可。

java 复制代码
// 不可改的基类(无注解)
static class ExternalUser {
    private final String name;
    private final int age;
    ExternalUser(String name, int age) { ... }
    public static class Builder {
        public ExternalUser build() { return new ExternalUser(...); }   // ← 返回基类
    }
}

// 子类只是注解的载体
@JSONType(builder = ExternalUser.Builder.class,
          deserializeFeatures = JSONReader.Feature.SupportSmartMatch)
static class InheritedUser extends ExternalUser {
    InheritedUser(String name, int age) { super(name, age); }
}

// ✓ 传子类 .class 触发 builder reader,用父类类型接收
ExternalUser user = JSON.parseObject(json, InheritedUser.class);

原理 :传 InheritedUser.class 让 fastjson2 读到子类上的 @JSONType(builder=...),走 builder 路径,

字段正确填充;而 build() 返回的本就是 ExternalUser,用父类类型接收天经地义,没有任何强转。

产出对象的类型由 build() 的返回类型决定,跟注解写在哪个类无关。

唯一陷阱------别用子类类型接收,也不要传入 子类.class:

java 复制代码
// ❌ ClassCastException:实际对象是 ExternalUser,强转成 InheritedUser 才失败
InheritedUser user = JSON.parseObject(json, InheritedUser.class);

反直觉细节:assertThatThrownBy(() -> JSON.parseObject(json, InheritedUser.class)) 抓不到 这个

ClassCastException------泛型 T 擦除成 Object,parseObject 内部 (T) obj 不触发检查;强转只在

赋值给具体子类变量 (InheritedUser u = ...)时由编译器插入。所以测试里光调 parseObject 不赋值

测不出问题,必须真正赋给子类变量。这也正是当初容易误判「继承行不通」的根源。

与 Mixin 的取舍 :继承方案是 per-call 的,不污染全局状态,可以同一类在不同调用用不同配置;代价是

拿到的永远是 build() 的返回类型(通常是父类实例),拿不到子类实例。

6. 方案选型决策表

场景 推荐方案 关键配置
自有类,能加注解,用 with 前缀 @JSONType(builder=...) builder 类无需 @JSONBuilder
自有类,Lombok @Builder(fluent) @JSONType(builder=..., deserializeFeatures=SupportSmartMatch) SmartMatch 必须类级
第三方/外部不可改类,是 builder Mixin (JSON.mixIn) 空壳类承载注解,JSON.mixIn 注册
第三方不可改类,非 builder 但想定制 Mixin 重命名/忽略/自定义序列化器
不想动全局状态、只救一个字段 字段级 @JSONField(deserializeUsing=...) + 手写 reader 宿主字段级定制,不触及目标类
第三方不可改类,不想动全局状态 方案三:子类继承 + @JSONType 传子类 .class、用父类接收;拿不到子类实例

附:机制要点速查

  • builder 路径触发 :@JSONType(builder=X.class)(或 Mixin 注入等价注解)。
  • @JSONBuilder 可选 :默认 build 方法名 "build"、setter 前缀 "with",不加也按默认 build方法名称 和 with前缀方法名称进行工作。
  • setter 默认前缀 with :withName → 字段 name;无前缀的 fluent 方法需 @JSONField 或 SupportSmartMatch。
  • SmartMatch 必须类级:per-call 传 feature 无效(ObjectReader 按类缓存,setter 发生在构建期)。
  • 对象类型由 build() 决定 :不是注解所在类------子类继承 + 传子类 .class 可触发 builder 路径,但要用 build() 的返回类型(通常是父类)接收。
  • 作为字段类型自动触发:无需字段级 reader。

(END)

相关推荐
金銀銅鐵1 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
福建佰胜张工2 小时前
SMC 薄型气爪 MHF2-16D1 深度解析:平行开闭、低剖面结构在自动化装配中的选型与落地
java·服务器·安全·自动化
catoop2 小时前
API Key 设计与安全生成方案:结构拆解、数据库设计与 Java 实现
java·数据库·安全
system_error_x2 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
驭渊的小故事3 小时前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客3 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1883 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn4 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su4 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
我命由我123456 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法