Spring 的 prototype scope 你用对了吗?原型模式的三个正确打开方式

15 阅读1分钟

prototype Bean 注入 singleton 里——Spring 的原型模式被悄悄阉割了

有个线上事故我印象特别深:一个报表导出功能,controller 每次请求都 new 了一个 ReportExporter 对象,里面的 queryCondition 字段被多个请求共享了。原因是 ReportExporter 声明了 @Scope("prototype"),但它的调用方是个 @Service(默认 singleton),注入的时候 Spring 只注入了一次——之后每次调用都是同一个实例。

这就是 prototype scope 最经典的坑:你以为是每次拿新对象,实际上只拿了一次。

Spring 的 prototype 到底 prototyped 了什么

GoF 里的原型模式原意是通过 clone 来创建对象,避免反复 new 带来的开销——尤其在对象初始化很重的时候(比如从数据库里加载配置、解析复杂 XML)。

// 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 方法,逐字段复制)。如果你对象里有引用类型字段,浅拷贝就是共享同一块内存:

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

所以正经用法必须手动深拷贝引用类型字段:

@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(不推荐)

@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 的做法)

@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(推荐)

@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 坑多就觉得原型模式没用。这几个场景是实打实的:

  1. 报表模板生成。模板对象初始化很重(读配置、解析规则),但每次导出需要不同的查询条件。clone 模板 → 改条件 → 导出,省掉了重复初始化。
  2. 数据流复制(fork)。处理一个消息流,需要把它分发给多个消费者。用 clone 复制一份消息对象,各自消费者改自己的副本,互不影响。
  3. DTO 转换。虽然现在有 MapStruct 了,但某些场景下(字段完全一致的 VO 转 DO),clone 比写一堆 get/set 更简洁。

我在做一个用卡皮巴拉讲设计模式的小程序「爪爪代码冒险记」,原型模式这章用"橡皮图章"来讲——卡皮巴拉有一个印章模板,每次盖章都是复制模板而不是重刻一个。如果你对设计模式在项目里的实际用法感兴趣,可以搜一下这个小程序。


发表于掘金,用卡皮巴拉讲 23 种设计模式的小程序「爪爪代码冒险记」开发中