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 条:
- 遍历构造器,没找到无参构造,于是选中唯一的
(Builder)构造器; - 用
ConstructorFunction包装它,反射调用; - 但 fastjson2 不知道怎么从 JSON 填充一个 Builder ------它只会按构造器参数名/位置匹配,而
(Builder)只有一个参数builder,JSON 里根本没有名为builder的字段 → 传null; - 构造器内
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 有两条匹配路径:
with前缀路径 (默认):withName(String)→ 剥前缀得字段name。无需额外注解。- fluent 路径 :方法名不带
with前缀(如 Lombok 的name(String)),只有 满足以下任一才识别:- 方法上有
@JSONField注解;或 - 类级开了
SupportSmartMatchfeature。
- 方法上有
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)