模板方法模式 (Template Method) —— 照着流程办事
前言
在深入探讨模板方法模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。
1. 模板方法模式是什么?
模板方法模式(Template Method Pattern)定义了一个操作中的算法骨架(流程),而将一些步骤延迟到子类中。使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
简单来说,就是父类定规矩,子类去执行。
1.1 核心概念
模板方法模式主要包含两个角色: AbstractClass(抽象模板类) 是核心。它定义了一系列基本操作(PrimitiveOperations),这些操作可以是抽象的(留给子类实现),也可以是具体的(父类已实现)。更重要的是,它定义并实现了一个模板方法(Template Method)。这个方法就像一个指挥官,给出了逻辑骨架,按顺序调用那些基本操作。通常,我们会把这个模板方法声明为 final,以防止子类篡改流程。
ConcreteClass(具体实现类) 则是干活的兵。它负责实现父类定义的抽象基本操作,或者覆盖父类的钩子方法,从而完成特定的业务逻辑。
1.2 为什么我们需要它?
场景: 去银行办业务,流程通常是固定的:先取号,然后排队,轮到你了就办理业务,最后评价。 在这个流程中,取号、排队、评价对所有人都是一样的;唯独“办理业务”这一步,有人存钱,有人取钱,有人理财。
痛点:代码重复。 如果我们要为“存钱”写一个类,为“取钱”写一个类,那么“取号、排队、评价”这三个步骤的代码在每个类里都要复制粘贴一遍。一旦银行规定要先“刷脸”再取号,你就得去修改所有的类,这简直是维护噩梦。
解决方案: 把公共的、固定的流程提炼到父类中,把变化的步骤留成抽象方法,让子类去填空。这样既保证了流程的统一,又实现了细节的差异化。
2. Java 实战:制作豆浆
制作各种口味的豆浆,流程大概是:
- 选材(黄豆、黑豆...)
- 添加配料(糖、红枣、花生...)
- 浸泡
- 打碎
其中,浸泡和打碎的流程是一样的,但选材和配料不同。
2.1 代码实现
Step 1: 抽象模板类
public abstract class SoyaMilk {
// 模板方法:定义制作流程。final 禁止重写
public final void make() {
select();
if (customerWantsCondiments()) { // 钩子方法
addCondiments();
}
soak();
beat();
}
// 1. 选材(基本是黄豆,也可以让子类改)
void select() {
System.out.println("第一步:选择好的新鲜黄豆");
}
// 2. 添加配料:抽象方法,子类必须实现
abstract void addCondiments();
// 3. 浸泡
void soak() {
System.out.println("第三步:黄豆和配料开始浸泡,需要3小时");
}
// 4. 打碎
void beat() {
System.out.println("第四步:黄豆和配料放入豆浆机打碎");
}
// 钩子方法 (Hook):决定是否执行某一步骤
// 默认是 true,子类可以覆盖
boolean customerWantsCondiments() {
return true;
}
}Step 2: 具体子类
红枣豆浆
public class RedDateSoyaMilk extends SoyaMilk {
@Override
void addCondiments() {
System.out.println("第二步:加入上好的红枣");
}
}花生豆浆
public class PeanutSoyaMilk extends SoyaMilk {
@Override
void addCondiments() {
System.out.println("第二步:加入香脆的花生");
}
}纯豆浆(不加料)
public class PureSoyaMilk extends SoyaMilk {
@Override
void addCondiments() {
// 空实现,因为钩子方法返回 false,这里其实不会执行
}
@Override
boolean customerWantsCondiments() {
return false; // 覆盖钩子方法
}
}Step 3: 客户端调用
public class Client {
public static void main(String[] args) {
System.out.println("----制作红枣豆浆----");
SoyaMilk redDate = new RedDateSoyaMilk();
redDate.make();
System.out.println("\n----制作纯豆浆----");
SoyaMilk pure = new PureSoyaMilk();
pure.make();
}
}输出结果:
----制作红枣豆浆----
第一步:选择好的新鲜黄豆
第二步:加入上好的红枣
第三步:黄豆和配料开始浸泡,需要3小时
第四步:黄豆和配料放入豆浆机打碎
----制作纯豆浆----
第一步:选择好的新鲜黄豆
第三步:黄豆和配料开始浸泡,需要3小时
第四步:黄豆和配料放入豆浆机打碎3. 核心技术点:钩子方法 (Hook)
在模板方法模式中,Hook 是一个非常巧妙的设计。
- 定义:父类提供一个默认实现(通常是空实现或返回 true/false)的方法。
- 作用:
- 控制流程:如上面的
customerWantsCondiments(),子类可以通过覆盖它来“挂钩”进算法的某个点,决定是否执行某段逻辑。 - 可选扩展:父类预留一个空方法(如
void afterPropertiesSet()),子类如果需要在某个时间点做点事,就重写它;不想做就不用管。
- 控制流程:如上面的
4. 源码中的模板方法模式
模板方法模式在框架设计中无处不在,尤其是那些名字带 Abstract 或 Base 的类,或者带 Template 后缀的类(虽然 Spring 的 Template 类更多是回调模式,但思想类似)。
4.1 JDK java.util.AbstractList
我们要实现一个不可变的 List,只需要继承 AbstractList 并重写 get(int) 和 size() 两个方法,其他的 indexOf, iterator, subList 等方法,JDK 已经在 AbstractList 里帮我们写好了模板。
4.2 Java Servlet HttpServlet
HttpServlet 的 service() 方法就是最典型的模板方法。
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
// ...
doGet(req, resp); // 留给子类实现
// ...
} else if (method.equals(METHOD_POST)) {
doPost(req, resp); // 留给子类实现
}
// ... 其他 HTTP 方法处理
}Tomcat 调用 service() 处理请求,而具体的业务逻辑(怎么处理 GET/POST),留给我们写的 Servlet 子类去实现 doGet/doPost。
4.3 Spring AbstractApplicationContext.refresh()
Spring 容器启动的核心方法 refresh() 是一个巨大的模板方法,它定义了 IOC 容器启动的 12 个标准步骤,包括 prepareRefresh(), obtainFreshBeanFactory(), prepareBeanFactory() 等等。
其中,postProcessBeanFactory(beanFactory) 就是一个典型的钩子方法。在 AbstractApplicationContext 中,这个方法是空的。但是,像 GenericWebApplicationContext 这样的子类会重写它,以便在容器启动的特定阶段添加 Web 相关的处理逻辑。这使得 Spring 容器既有统一的启动流程,又能灵活适应不同的应用场景。
5. 模板方法 vs 策略模式
这两个模式经常让人混淆,因为它们都封装了算法。
| 特性 | 模板方法模式 (Template Method) | 策略模式 (Strategy) |
|---|---|---|
| 核心机制 | 继承 (Inheritance) | 组合 (Composition) |
| 算法结构 | 固定骨架,子类改变细节 | 整体替换,客户端选择具体算法 |
| 复用方式 | 代码复用在父类 | 代码复用在具体的策略类中 |
| 灵活性 | 较低(编译期绑定) | 较高(运行期切换) |
一句话总结:
- 模板方法:我要做一道菜,步骤是【切 -> 炒 -> 装盘】,具体切什么、炒什么你来定,但流程不能变。
- 策略模式:我要去旅行,我可以选择【坐飞机】或者【坐火车】,这是两种完全不同的出行方式,互不干扰。
6. 优缺点与适用场景
6.1 优点
- 封装不变部分,扩展可变部分:把公共代码复用到了极致。
- 行为由父类控制,子类实现:符合“开闭原则”和“里氏替换原则”。
6.2 缺点
- 类数量膨胀:每一个不同的实现都需要一个子类。
- 继承的局限性:Java 是单继承,如果子类已经继承了别的类,就很难再用模板方法模式了(这时候可以考虑用策略模式或回调接口来代替)。
6.3 适用场景
- 算法的整体步骤很固定,但其中个别步骤在不同场景下有不同实现。
- 需要通过子类来决定父类算法中某个步骤是否执行(Hook)。
- 重构时,把相同的代码抽取到父类中。
7. 实验实操
在 design-patterns-web 项目中,我们模拟了一个数据挖掘工具(DataMiner)。 这个 Demo 展示了如何用模板方法处理不同格式的文件:
- 标准流程:无论是处理 PDF 还是 CSV,整体流程(Template Method)都是固定的:
Open File->Extract Data->Parse Data->Close File。 - 差异实现:点击 "Process PDF" 和 "Process CSV",你会发现
Close File的日志是一样的(父类通用实现),而Open/Extract/Parse的日志内容不同(子类具体实现)。这直观地体现了“骨架固定,细节可变”的模式特点。 截图建议:点击 "Process PDF" 按钮,等待执行完成。截取右侧的 "Execution Steps" 日志面板,清晰展示出特定的 PDF 处理步骤和通用的关闭步骤。