乐于分享
好东西不私藏

模板方法模式:Spring 源码里用得最多却最少被讲透的设计模式

模板方法模式:Spring 源码里用得最多却最少被讲透的设计模式

大家好,我是折腾派程序员

如果让我说 Spring 源码里出现频率最高的设计模式是哪个,代理算一个,观察者算一个,但模板方法可能才是真正的第一——JdbcTemplateRedisTemplateRestTemplateAbstractApplicationContextAbstractBeanFactory,随手翻几个核心类都是它的影子。

但这个模式在面试题里的出镜率,远不如单例和代理。很多人对它的理解停留在"父类定骨架子类填实现"这一句话,细节上经不住追问:钩子方法是什么?和策略模式到底有什么本质区别?Spring 里具体是怎么用的?

今天把这些都说清楚。


从一段真实的糟糕代码说起

这是一个很典型的场景:系统需要支持报表多格式导出,同一份数据,用户可以下载 Excel、CSV、PDF 三种格式。

第一个同学接了这个需求,先做 Excel:

publicclassExcelExportService{
publicvoidexport(Long reportId){
// 第一步:查询数据
        ReportData data = reportDao.findById(reportId);
// 第二步:权限校验
        permissionService.checkExportPermission(data);
// 第三步:数据预处理(金额保留两位、日期格式化)
        dataProcessor.preprocess(data);
// 第四步:写入 Excel 格式(核心差异点)
        Workbook workbook = new XSSFWorkbook();
        Sheet sheet = workbook.createSheet("报表");
        fillSheet(sheet, data);
        workbook.write(outputStream);
// 第五步:记录导出日志
        exportLogDao.save(new ExportLog(reportId, "EXCEL", LocalDateTime.now()));
    }
}

然后 CSV 需求来了,新建一个 CsvExportService,把上面的代码复制一遍,改改第四步的写入逻辑。PDF 来了,再来一遍。

结果是三个类,四个步骤是完全一样的,只有第四步不同。

三个月后产品说权限校验规则要改,你需要找到三个地方分别修改。改了两个漏了一个,上线发现 CSV 导出没有走新的权限逻辑,这就是一个线上 Bug。更难受的是,这种错误在测试阶段很难发现,因为功能本身是正常的,只是权限判断逻辑跑的是旧的。


模板方法:把不变的放进父类,把变的留给子类

模板方法要解决的问题就是这个:一批对象的处理流程高度相似,只有其中某几个步骤的具体实现不同。

核心结构有三个东西:

模板方法(Template Method):定义算法的整体流程,用 final 锁死,子类无法改变顺序。

抽象步骤(Abstract Methods):流程里那些"变化点",强制子类实现,父类不知道也不应该知道具体怎么做。

钩子方法(Hook Methods):流程里那些"可选扩展点",父类给一个空实现或默认实现,子类可以选择覆盖,也可以不管。

先看基础结构:

// 抽象基类:定义导出流程的骨架
publicabstractclassReportExporter{

// 模板方法:final 锁死,流程顺序不允许子类改变
publicfinalvoidexport(Long reportId){
        ReportData data = reportDao.findById(reportId);
        permissionService.checkExportPermission(data);
        dataProcessor.preprocess(data);

// 唯一的变化点:交给子类实现
        doExport(data);

        exportLogDao.save(new ExportLog(reportId, getFormatName(), LocalDateTime.now()));
    }

// 抽象步骤1:怎么写入,子类决定
protectedabstractvoiddoExport(ReportData data);

// 抽象步骤2:叫什么格式名,子类决定(写日志用)
protectedabstract String getFormatName();
}

子类只需要关心自己那部分,其余的一行不用写:

@Service
publicclassExcelExporterextendsReportExporter{

@Override
protectedvoiddoExport(ReportData data){
        Workbook workbook = new XSSFWorkbook();
        Sheet sheet = workbook.createSheet("报表");
// Excel 特有的写入逻辑
        fillSheet(sheet, data);
        workbook.write(outputStream);
    }

@Override
protected String getFormatName(){
return"EXCEL";
    }
}

@Service
publicclassCsvExporterextendsReportExporter{

@Override
protectedvoiddoExport(ReportData data){
        CsvWriter writer = new CsvWriter(outputStream, StandardCharsets.UTF_8);
// CSV 特有的写入逻辑
for (ReportRow row : data.getRows()) {
            writer.writeRecord(row.toArray());
        }
        writer.close();
    }

@Override
protected String getFormatName(){
return"CSV";
    }
}

权限校验逻辑改了?ReportExporter 里改一次,所有格式的导出全部生效。新增 PDF 格式?加一个子类,实现两个方法,其余都是现成的。


钩子方法:给骨架留弹性

abstract 方法是强制子类实现的,但有些步骤是"可选的"——多数情况用默认行为,少数情况需要定制。这时候用钩子方法,父类给一个空实现或者返回默认值,子类可以选择性覆盖。

还是导出的场景,比如有些格式导出时需要加水印(比如"内部资料,禁止外传"),有些不需要:

publicabstractclassReportExporter{

publicfinalvoidexport(Long reportId){
        ReportData data = reportDao.findById(reportId);
        permissionService.checkExportPermission(data);
        dataProcessor.preprocess(data);

// 钩子方法控制流程分支
if (needWatermark()) {
            watermarkService.apply(data);
        }

        doExport(data);
        exportLogDao.save(new ExportLog(reportId, getFormatName(), LocalDateTime.now()));
    }

protectedabstractvoiddoExport(ReportData data);
protectedabstract String getFormatName();

// 钩子方法:默认不加水印,子类可以覆盖
protectedbooleanneedWatermark(){
returnfalse;
    }
}

// Excel 不需要水印,什么都不用写
publicclassExcelExporterextendsReportExporter{
// needWatermark() 用父类默认的 false
// ...
}

// PDF 需要水印
publicclassPdfExporterextendsReportExporter{
@Override
protectedbooleanneedWatermark(){
returntrue// 覆盖钩子,开启水印
    }
// ...
}

钩子方法让骨架有了弹性。子类不用改变流程结构,只需要覆盖钩子,就能影响某个分支的走向。

这是很多人不知道的细节:模板方法模式里不只有 abstract 方法,钩子方法同样重要,Spring 源码里用得很多。


Spring 源码里的模板方法,随手就是

JdbcTemplate:把 JDBC 的套路流程封死

不用框架手写 JDBC,固定要做的事是这些:

Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
    conn = dataSource.getConnection();    // 获取连接
    pstmt = conn.prepareStatement(sql);  // 创建语句
    setParameters(pstmt, params);        // 设置参数 ← 变化点
    rs = pstmt.executeQuery();
return mapResult(rs);                // 处理结果 ← 变化点
catch (SQLException e) {
// 异常处理
finally {
// 关闭 rs, pstmt, conn(顺序不能乱,每个关闭都可能抛异常)
}

整段代码里真正因业务而不同的只有两个地方:SQL 参数怎么设置结果集怎么转成对象。剩下的全是固定的、繁琐的、容易出错的样板代码。

JdbcTemplate 就是把这个骨架封装进去,把两个变化点暴露出来:

// 你只需要告诉它:怎么把结果集里的一行映射成你的对象
List<User> users = jdbcTemplate.query(
"SELECT * FROM users WHERE age > ?",
new Object[]{18},
    (rs, rowNum) -> {             // ← 这个 lambda 就是在实现抽象步骤
        User user = new User();
        user.setId(rs.getLong("id"));
        user.setName(rs.getString("name"));
return user;
    }
);

你传进去的那个 RowMapper lambda,就是模板骨架里留出来的"抽象步骤"。获取连接、管理事务、关闭资源、处理 SQLException 全是框架干的,你一行也不用写。


AbstractApplicationContext.refresh():Spring 容器启动的总指挥

这是 Spring 里最核心的一个方法,每次容器启动必走,简化版如下:

publicabstractclassAbstractApplicationContext{

@Override
publicvoidrefresh()throws BeansException {
// 12 个步骤,顺序固定,这就是模板方法
        prepareRefresh();

        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory(); // ← 抽象方法

        prepareBeanFactory(beanFactory);

        postProcessBeanFactory(beanFactory); // ← 钩子方法

        invokeBeanFactoryPostProcessors(beanFactory);
        registerBeanPostProcessors(beanFactory);

        initMessageSource();
        initApplicationEventMulticaster();

        onRefresh(); // ← 钩子方法,非常关键

        registerListeners();
        finishBeanFactoryInitialization(beanFactory);
        finishRefresh();
    }

// 抽象方法:如何获取/刷新 BeanFactory,不同子类实现不同
protectedabstract ConfigurableListableBeanFactory obtainFreshBeanFactory();

// 钩子方法:默认空实现,子类可以扩展
protectedvoidonRefresh()throws BeansException {}
}

两个地方值得特别注意:

obtainFreshBeanFactory() 是抽象方法——ClassPathXmlApplicationContext 的实现是解析 XML 配置文件,AnnotationConfigApplicationContext 的实现是扫描注解。两种配置方式,容器启动的 12 个步骤完全一样,只有这一步的具体实现不同。

onRefresh() 是钩子方法——AbstractApplicationContext 里这个方法是空的。SpringBoot 的 ServletWebServerApplicationContext 覆盖了它,在这一步启动内嵌 Tomcat。就这一个钩子,让 Spring 容器和 Web 服务器的启动顺序被无缝串联在一起。

这就是 Spring 能同时支持 XML 配置和注解配置、能既跑普通 Java 应用又跑 Web 应用,但核心启动流程保持统一的底层原因。


和策略模式的区别,这个问题必须想清楚

这两个模式的表面目标很像,都是处理"变化的部分",面试里很容易被拿来对比。

维度
模板方法
策略模式
实现方式
继承:子类继承父类,覆盖变化步骤
组合:上下文持有策略接口引用
变化粒度
算法整体流程固定,只有某几个步骤不同
整个算法都可以替换
扩展方式
加子类
加策略实现类
运行时替换
不行(编译时就确定了用哪个子类)
可以,运行时注入不同策略
代码耦合
子类和父类强耦合(继承关系)
上下文和策略弱耦合(依赖接口)

选哪个,看场景:

流程步骤固定,只有某几个环节的具体实现不同 → 模板方法。就像上面的报表导出,查询数据 → 权限校验 → 预处理 → 导出 → 记日志,这个顺序是业务固定的,不允许子类随便改。

整个处理逻辑都可能不同,没有固定骨架 → 策略模式。比如不同促销活动的计算逻辑:满减、折扣、秒杀,三种算法从头到尾完全独立,没有共同的"骨架"可以提取。

还有一个角度:谁决定用哪个实现

模板方法是父类在骨架里调用子类的方法,是自上而下的"父类驱动"。策略模式是上下文在运行时持有一个策略对象,是"客户端注入"。前者更适合流程编排,后者更适合算法切换。

《设计模式》那本书里有一句话:"优先使用组合而非继承。"策略模式是组合,可以运行时替换,理论上更灵活。但灵活性不是免费的——策略模式需要额外定义接口和上下文类,代码量更多。模板方法在"流程固定、细节可变"这个场景里,代码量更少,逻辑更直观。不存在哪个更好,只有哪个更合适。


总结

模板方法的本质只有一句话:把不变的放进父类,把变的留给子类。

这不只是一个设计模式,而是面向对象里"抽象"最直接的体现。你在写一批流程相似的类时,都应该问自己:这些类里,哪些步骤是固定的,哪些步骤是变化的?固定的提到父类,变化的留给子类——模板方法就自然用对了。

最后说一句:看 Spring 源码时,遇到带 Abstract 前缀的类,大概率就是模板方法模式。带 Template 后缀的类(JdbcTemplateRedisTemplate),名字里都明确告诉你了。以后看到这些类,知道从这个角度去理解它们的设计意图,代码会清晰很多。


👇 聊几个问题:

  1. 你的项目里有没有用过模板方法?还是遇到类似场景时直接选择了策略模式?两者各有什么感受,评论区交流。
  2. AbstractApplicationContext.refresh() 那 12 个步骤,你之前了解多少?这是读 Spring 源码必须过的一关,有兴趣可以留言,我后面单独写一篇 Spring 容器启动流程的深度解析。
  3. 设计模式系列到这里就完整了。后续想看什么方向?Spring 源码深度解读、JVM 调优实战、分布式系统设计,还是 AI 工程落地?留言告诉我,票数最多的我优先写。点个在看 👍

折腾派程序员:专注 Java 后端、AI 工程、架构设计。 更多干货,关注公众号持续更新 ↓