prototype Bean 注入 singleton 里------Spring 的原型模式被悄悄阉割了
有个线上事故我印象特别深:一个报表导出功能,controller 每次请求都 new 了一个 ReportExporter 对象,里面的 queryCondition 字段被多个请求共享了。原因是 ReportExporter 声明了 @Scope("prototype"),但它的调用方是个 @Service(默认 singleton),注入的时候 Spring 只注入了一次------之后每次调用都是同一个实例。
这就是 prototype scope 最经典的坑:你以为是每次拿新对象,实际上只拿了一次。
Spring 的 prototype 到底 prototyped 了什么
GoF 里的原型模式原意是通过 clone 来创建对象,避免反复 new 带来的开销------尤其在对象初始化很重的时候(比如从数据库里加载配置、解析复杂 XML)。
java
// GoF 原型模式的标准写法
public class ReportTemplate implements Cloneable {
private String header;
private String footer;
private List<Column> columns; // 这是个重对象,初始化要读数据库
public ReportTemplate(String reportType) {
// 假设这里要查数据库,很重
this.header = loadHeader(reportType);
this.footer = loadFooter(reportType);
this.columns = loadColumns(reportType);
}
@Override
public ReportTemplate clone() {
ReportTemplate copy = new ReportTemplate();
copy.header = this.header; // 浅拷贝 header
copy.footer = this.footer; // 浅拷贝 footer
copy.columns = new ArrayList<>(this.columns); // 深拷贝 columns,不然多个报表共享同一个 List
return copy;
}
}
关键点:clone() 里面你要自己决定哪些字段深拷贝、哪些浅拷贝。GoF 把这种选择权交给你------共享不变的数据,拷贝会变的数据。
Spring 的 prototype scope 不是 clone,是每次 getBean() 都调用构造函数重新创建一个。它借了"原型"这个名字,但实现方式完全不同。Spring 的意思是"每次都要新的",GoF 的意思是"拷贝一份现成的改一改"。
clone() 的三层地狱
JDK 自带的 Object.clone() 是个浅拷贝(native 方法,逐字段复制)。如果你对象里有引用类型字段,浅拷贝就是共享同一块内存:
java
public class Order implements Cloneable {
private String orderId;
private List<OrderItem> items; // 这是个引用!
@Override
public Order clone() {
try {
return (Order) super.clone(); // 浅拷贝:items 还是同一个 List
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
// 测试
Order original = new Order("001", new ArrayList<>());
Order cloned = original.clone();
cloned.getItems().add(new OrderItem("SKU-001", 2));
// original.getItems() 里也多了一个!因为两个对象共享同一个 List
所以正经用法必须手动深拷贝引用类型字段:
java
@Override
public Order clone() {
try {
Order cloned = (Order) super.clone();
cloned.items = new ArrayList<>(this.items); // 手动深拷贝
return cloned;
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
这就是原型模式最烦人的地方:每加一个引用类型字段,你都要记得在 clone() 里处理。忘一个就是线上 bug。而且 Cloneable 接口没有声明 clone() 方法------它是从 Object 继承过来的 protected 方法,编译器不会提醒你没有实现。
我自己在项目里基本不用 Cloneable 了。要么用拷贝构造函数(new Order(existingOrder)),要么用序列化(JSON 序列化再反序列化),代码意图更明确,也不用跟 CloneNotSupportedException 较劲。
prototype Bean 的正确用法:别注入,用工厂
回到开头那个坑。prototype Bean 注入 singleton Bean 的解决方案有几种:
方案一:每次 getBean(不推荐)
java
@Service
public class ReportService {
@Autowired
private ApplicationContext context;
public void export() {
ReportExporter exporter = context.getBean(ReportExporter.class);
exporter.setCondition(condition);
exporter.doExport();
}
}
能用,但 ApplicationContext 直接注入到业务代码里会让测试很难写,而且你失去了类型安全。
方案二:@Lookup(Spring 的做法)
java
@Service
public class ReportService {
@Lookup
public ReportExporter getExporter() {
return null; // Spring 会覆盖这个方法
}
public void export() {
ReportExporter exporter = getExporter();
exporter.setCondition(condition);
exporter.doExport();
}
}
@Lookup 是 Spring 的"方法注入",CGLIB 动态代理会在运行时拦截 getExporter() 的调用,每次都返回一个新的 prototype Bean。缺点也很明显:这个类必须是 Spring 管理的 Bean,不能是 new 出来的;而且 return null 这种写法看着实在别扭。
方案三:ObjectFactory(推荐)
java
@Service
public class ReportService {
@Autowired
private ObjectFactory<ReportExporter> exporterFactory;
public void export() {
ReportExporter exporter = exporterFactory.getObject();
exporter.setCondition(condition);
exporter.doExport();
}
}
ObjectFactory 是 Spring 内置的函数式接口,getObject() 每次都返回新的 prototype 实例。代码意图清楚,类型安全,测试也好 mock。
原型模式真正有用的场景
别因为 clone 坑多就觉得原型模式没用。这几个场景是实打实的:
- 报表模板生成。模板对象初始化很重(读配置、解析规则),但每次导出需要不同的查询条件。clone 模板 → 改条件 → 导出,省掉了重复初始化。
- 数据流复制(fork)。处理一个消息流,需要把它分发给多个消费者。用 clone 复制一份消息对象,各自消费者改自己的副本,互不影响。
- DTO 转换。虽然现在有 MapStruct 了,但某些场景下(字段完全一致的 VO 转 DO),clone 比写一堆 get/set 更简洁。
我在做一个用卡皮巴拉讲设计模式的小程序「爪爪代码冒险记」,原型模式这章用"橡皮图章"来讲------卡皮巴拉有一个印章模板,每次盖章都是复制模板而不是重刻一个。如果你对设计模式在项目里的实际用法感兴趣,可以搜一下这个小程序。
发表于掘金,用卡皮巴拉讲 23 种设计模式的小程序「爪爪代码冒险记」开发中