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.apiKeybuilder 是 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 擦除成 ObjectparseObject 内部 (T) obj 不触发检查;强转只在

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

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

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

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

6. 方案选型决策表

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

附:机制要点速查

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

(END)

相关推荐
圆山猫2 小时前
[Virtualization](四):Linux KVM/RISC-V 的 vCPU 运行路径
java·linux·risc-v
城管不管3 小时前
ReAct、Plan-and-Execute、Reflection 三大智能 Agent 范式核心区别
java·人工智能·算法·spring·ai·动态规划
IT小白杨3 小时前
从环境制备到自动化工作流:多账号运营的工程化架构拆解
java·经验分享·自动化·安全架构·指纹浏览器
豆瓣鸡3 小时前
算法日记 - Day3
java·开发语言·算法
萧瑟余晖4 小时前
Java深入解析篇九之NIO详解
java·网络·nio
The Chosen One9854 小时前
高进度算法模板速记(待完善)
java·前端·算法
极光代码工作室6 小时前
基于SpringBoot的课程预约系统
java·springboot·web开发·后端开发
Leighteen7 小时前
`try-finally` 里的 `return`:为什么 `finally` 会悄悄改掉返回值、吞掉异常
java·开发语言
名字还没想好☜7 小时前
Go 的 time.After 在 select 循环里内存泄漏:定时器堆积原理与 timer.Reset 正确姿势
java·数据库·golang·go·goroutine
圆山猫8 小时前
[Virtualization](三):RISC-V H-extension 与 Guest 执行模式
android·java·risc-v