设计模式修炼之路 ⑭ 模板模式——焊死骨架,留白细节
final 焊死流程骨架、四种方法各司其职、钩子是选答题、从 Servlet 到 AQS 全是填空
目录
上一篇代理模式收官结构型时留足了排面:"结构型管'类怎么组装',行为型管'类怎么协作'——模板模式打头阵,干的事你天天在干:父类把流程骨架焊死,子类只往空位里填肉。"今天它来了:模板模式(Template Pattern),GoF 23 人名单正牌选手、行为型十一员的第一位(本系列含编外的空对象共十二篇)。系列正式进入下半场——创建型管"对象从哪来",结构型管"类怎么组装",行为型管"类与类怎么配合干活"。前十三篇的模式你也许还能说"平时真没主动写过",从这一篇开始不行了:HttpServlet、AbstractList、JUnit、JdbcTemplate、AQS,这些你天天摸的东西,骨子里全是它。
一、什么是模板模式?
先看定义:
定义一个操作中算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重定义算法的某些步骤。
翻译成人话:同一套流程在好几个类里重复出现,只有个别步骤不一样——把流程原样搬到父类里焊成 final,不一样的步骤留成空,子类只管填空。步骤顺序、调用时机,全归父类管;每一步"具体怎么干",归子类管。
生活类比是考试卷:题型、题序、分值,在卷子印好的那一刻全定死,你只能在横线上填答案。选择题不会可以蒙个 C(钩子的默认实现),但你说"我不想做选择题"、或者想把大题挪到第一页——监考老师(final)第一个不答应。
它只有两个角色,但父类肚子里装着四种方法——这是本篇最值钱的知识点:
- 模板方法(template method):public final 修饰,定义流程骨架,按顺序调用下面三种方法。final 是灵魂:流程焊死,子类连重写的资格都没有;
- 抽象方法:必填的空。子类不填,编译器不放行;
- 公共方法(concrete method):父类实现好的公共步骤,所有子类共享一份;
- 钩子方法(hook):父类给一个"人畜无害"的默认实现(空方法,或返回默认值),子类想覆盖就覆盖,不覆盖也不碍事。
精髓一句话:变的留给子类填空,不变的焊在父类共享;顺序父类说了算,细节子类说了算。
二、模板模式的优缺点
优点
- 消灭重复,一处修改全局生效:公共步骤只在父类存在一份,"日志要加时间戳"这种需求从改 N 个类变成改 1 个类;
- 流程受编译器保护:"不改变算法结构"不是注释里的君子协定,是 final 关键字执法——先校验后发送这种铁律,子类想破也破不了;
- 加变体就是加子类:新渠道、新格式,extends 一个类填几个空就完事,开闭原则模板分;
- 好莱坞原则(Hollywood Principle):Don't call us, we'll call you——父类调子类,子类不调父类;框架调你,你不调框架。控制权牢牢握在父类手里,这个思想后来有个更响亮的名字:控制反转,Spring IoC 的源头之一。
缺点
- 类数量膨胀:一个变体一个子类,渠道一多,包里全是 XxxSender;
- 继承是硬绑定:父类一动,波及全部子类(脆弱基类问题);而且每个子类都烧掉了 Java 单继承的唯一名额——想同时复用两套骨架,没门;
- 阅读要上下横跳:流程在父类、实现在子类,追一个调用两头跑,IDE 的调用层级树成了标配;
- 骨架太死偶尔憋屈:个别子类想在流程中间插一步?只能指望父类当初埋了钩子——埋钩子是框架作者在赌自己预见得全。
三、模板模式的应用场景
- 多个类流程相同、个别步骤不同:多渠道消息推送、多支付渠道对接、多格式文件导出、一切"对接第 N 方"的活——第四节就拿它当例子;
- 框架定流程、用户填空:框架设计的第一模式,没有之一。框架作者写父类焊骨架,框架用户写子类填空——第五节你会看到 Servlet、JUnit、JdbcTemplate、AQS、Spring Boot 启动全在这条路上;
- 需要强制流程一致性的地方:必须先校验再操作、必须先开事务再执行最后提交、必须 try-finally 关资源——焊进模板方法,新人想写错都难。
什么时候别用:流程只有一套、没有第二个变体——别为"可能有变化"上抽象父类,那是过度设计;变化的不是个别步骤而是整个算法——那是下一篇策略模式的地盘。
四、模板模式的实现方式
示例代码在 design-pattern 项目的 template 包里:
design-pattern/src/main/java
└── template // 模板模式
├── BadSmsSender.java // 反面教材:短信渠道复制粘贴
├── BadEmailSender.java // 反面教材:邮件渠道再复制一遍
├── AbstractMessageSender.java // 抽象类:模板方法 + 三个必填空 + 一个钩子
├── SmsSender.java // 填空 1:短信
├── EmailSender.java // 填空 2:邮件
├── DingTalkSender.java // 填空 3:钉钉,覆盖钩子开回执
└── TemplateTest.java // 测试入口
场景:消息推送。短信、邮件、钉钉三个渠道,流程一模一样——校验目标、渲染内容、真正发送、记录日志,一步不多一步不少;只有每一步"具体怎么干"各不相同。
1. 反面教材:每个渠道复制一遍流程
// 反面教材:短信渠道,四步流程全写在业务里
public class BadSmsSender {
public void send(String target, String userName) {
if (!target.matches("1\\d{10}")) {
System.out.println("[坏例子-短信] 手机号不合法:" + target);
return;
}
String content = "【商城】尊敬的" + userName + ",您的验证码 666888,5 分钟内有效";
System.out.println("[坏例子-短信] 调用短信网关,发往 " + target);
System.out.println("[坏例子-短信] 记录发送日志:" + target + " -> " + content);
}
}
// 反面教材:邮件渠道——结构一模一样,再复制一遍
public class BadEmailSender {
public void send(String target, String userName) {
if (!target.contains("@")) {
System.out.println("[坏例子-邮件] 邮箱不合法:" + target);
return;
}
String content = "<html><body>尊敬的 " + userName + ",您本月的账单已出,请查收</body></html>";
System.out.println("[坏例子-邮件] 通过 SMTP 发往 " + target);
System.out.println("[坏例子-邮件] 记录发送日志:" + target + " -> " + content);
}
}
运行结果:
===== 反面教材:每个渠道把流程复制一遍 =====
[坏例子-短信] 调用短信网关,发往 13800138000
[坏例子-短信] 记录发送日志:13800138000 -> 【商城】尊敬的张三,您的验证码 666888,5 分钟内有效
[坏例子-邮件] 通过 SMTP 发往 zhangsan@example.com
[坏例子-邮件] 记录发送日志:zhangsan@example.com -> <html><body>尊敬的 张三,您本月的账单已出,请查收</body></html>
两个类,四步流程,肉眼可见地重复。三宗罪:
- 公共代码复制了 N 份:"记日志"这步逐字相同。哪天产品说"日志要带时间戳",两个类都得改;渠道加到五个,改五处,漏一处就是线上事故;
- 流程顺序无保障:新同事接手写钉钉渠道,"先发送、后校验"照样编译通过——没有任何东西拦得住他,直到垃圾短信真的发了出去;
- 新渠道只能靠复制:Ctrl+C、Ctrl+V、再逐行改——复制十行改三行,剩下七行就是将来所有 bug 的藏身处。
病根一脉相承:该共享的没共享,该隔离的没隔离——只是这次重复的是流程,变化的是步骤。
2. 正解:流程上提父类,焊成 final
// 抽象类:四种方法各就各位
public abstract class AbstractMessageSender {
// 模板方法:final 焊死流程骨架——校验、渲染、发送、记日志的顺序,谁也改不了
public final void send(String target, String userName) {
if (!checkTarget(target)) {
System.out.println(" [模板] 目标地址不合法,中止发送:" + target);
return;
}
System.out.println(" [模板] 目标校验通过:" + target);
String content = render(userName);
System.out.println(" [模板] 渲染内容:" + content);
doSend(target, content);
System.out.println(" [模板] 记录发送日志:" + target);
if (needReceipt()) {
System.out.println(" [模板] 开启回执确认,等待对方已读");
}
}
// 抽象方法:必填的空——每种渠道的校验规则不同
protected abstract boolean checkTarget(String target);
// 抽象方法:必填的空——每种渠道的内容模板不同
protected abstract String render(String userName);
// 抽象方法:必填的空——每种渠道的发送方式不同
protected abstract void doSend(String target, String content);
// 钩子方法:父类给默认实现(不开回执),子类看需要覆盖
protected boolean needReceipt() {
return false;
}
}
对着第一节那张"四种方法"清单逐个对号:send() 是模板方法,final 修饰,四步顺序焊死;checkTarget() / render() / doSend() 是三个必填的抽象方法;记日志直接写死在骨架里,是公共方法;needReceipt() 是钩子。
子类从此只干填空这一件事:
// 填空 1:短信渠道
public class SmsSender extends AbstractMessageSender {
@Override
protected boolean checkTarget(String target) {
return target.matches("1\\d{10}");
}
@Override
protected String render(String userName) {
return "【商城】尊敬的" + userName + ",您的验证码 666888,5 分钟内有效";
}
@Override
protected void doSend(String target, String content) {
System.out.println(" [短信] 调用短信网关,发往 " + target);
}
}
// 填空 3:钉钉渠道——顺手覆盖钩子
public class DingTalkSender extends AbstractMessageSender {
@Override
protected boolean checkTarget(String target) {
return target.startsWith("ding:");
}
@Override
protected String render(String userName) {
return "【重要告警】" + userName + ",线上服务异常,请立即处理";
}
@Override
protected void doSend(String target, String content) {
System.out.println(" [钉钉] 调用机器人 webhook 推送:" + target);
}
// 覆盖钩子:告警消息必须确认已读
@Override
protected boolean needReceipt() {
return true;
}
}
EmailSender 结构完全相同,不再赘列。测试:
System.out.println("===== 正解:模板模式,骨架焊死、子类填空 =====");
System.out.println("--- 短信渠道 ---");
AbstractMessageSender sms = new SmsSender();
sms.send("13800138000", "张三");
sms.send("12345", "张三");
System.out.println("--- 邮件渠道 ---");
AbstractMessageSender email = new EmailSender();
email.send("zhangsan@example.com", "张三");
System.out.println("--- 钉钉渠道(覆盖钩子,开回执) ---");
AbstractMessageSender dingTalk = new DingTalkSender();
dingTalk.send("ding:9527", "张三");
运行结果:
===== 正解:模板模式,骨架焊死、子类填空 =====
--- 短信渠道 ---
[模板] 目标校验通过:13800138000
[模板] 渲染内容:【商城】尊敬的张三,您的验证码 666888,5 分钟内有效
[短信] 调用短信网关,发往 13800138000
[模板] 记录发送日志:13800138000
[模板] 目标地址不合法,中止发送:12345
--- 邮件渠道 ---
[模板] 目标校验通过:zhangsan@example.com
[模板] 渲染内容:<html><body>尊敬的 张三,您本月的账单已出,请查收</body></html>
[邮件] 通过 SMTP 发往 zhangsan@example.com
[模板] 记录发送日志:zhangsan@example.com
--- 钉钉渠道(覆盖钩子,开回执) ---
[模板] 目标校验通过:ding:9527
[模板] 渲染内容:【重要告警】张三,线上服务异常,请立即处理
[钉钉] 调用机器人 webhook 推送:ding:9527
[模板] 记录发送日志:ding:9527
[模板] 开启回执确认,等待对方已读
三处细节值得咂摸:
- "12345" 那单被拦在校验:校验顺序是父类定的,SmsSender 只提供了"什么算合法手机号",没有机会说"先发了再说"——final 的功劳;
- 三个渠道的日志格式天然一致:因为记日志的代码全程序只有一份,在父类骨架里;
- 钉钉比别家多一行"开启回执确认":它覆盖了钩子 needReceipt() 返回 true,而短信、邮件对"回执"这件事毫不知情、毫发无损——这就是钩子的分寸感:默认不动声色,需要才出手。
3. 钩子方法:必答题和选答题
钩子常见两种形态,上面见了一种,补上另一种:
- boolean 开关:父类骨架里写
if (needReceipt()) {...},子类覆盖返回值,决定"这一步跑不跑"; - 空方法:父类给
protected void afterSend() {}空实现,骨架末尾调用它,子类想要"发送后再干点什么"就覆盖。
钩子和抽象方法的本质区别一句话:抽象方法是必答题,钩子是选答题。子类十有八九用不上某个钩子,但父类必须提前埋好——埋少了子类憋屈,埋多了父类臃肿,这个分寸是框架作者的功力所在。
五、在框架里找它
按"填空姿势"从传统到现代排开,全是你天天摸的东西。
1. HttpServlet:service() 焊死分发骨架
你写 Servlet 十年,从没写过 service()——因为它在 HttpServlet 里焊死了:解析请求 → 取出请求方法 → 是 GET 调 doGet、是 POST 调 doPost。你写的 doGet/doPost 就是填的空。更有意思的是,doGet 在 HttpServlet 里自带默认实现:返回 405 Method Not Allowed——你不覆盖它,容器就替你回一个 405。默认实现返回错误码,这是钩子最硬核的用法:"你要么好好填,要么我明确告诉你这题没答"。
2. AbstractList:JDK 的骨架实现
想实现一个 List,别直接 implements List——那意味着几十个方法全得自己写。继承 AbstractList,只填 get(int) 和 size() 两个空,iterator()、indexOf()、addAll() 父类全用你的 get() 拼好了。ArrayList 是它的子类,LinkedList 的父类 AbstractSequentialList 也是它的子类。同门还有 AbstractMap、AbstractSet、AbstractCollection。《Effective Java》第 20 条专门讲这招,术语叫骨架实现(skeletal implementation):接口定契约,抽象类给骨架,你只填核心那几个空。
3. JUnit:生命周期也是填空
JUnit 4 的 BlockJUnit4ClassRunner 里焊死了一条流水线:跑 @Before 们 → 跑 @Test → 跑 @After 们,超时控制、异常捕获、@Ignore 处理全在骨架里。你写的 @Test 是必填的空,@Before/@After 是选填的空——把"每个用例前后要做的事"做成填空位,这就是测试框架十几年不变的地基。
4. JdbcTemplate:填空从覆盖方法进化成传 Lambda
获取连接 → 创建语句 → 执行 SQL → 遍历 ResultSet → 关资源,五步全是容易写错的事(忘了关、忘了 try-finally),JdbcTemplate 全部焊死,你只填一个 RowMapper:
User u = jdbcTemplate.queryForObject(
"select * from user where id = ?",
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")), // 填空:一行怎么变对象
userId);
注意变化:填空的载体从"继承父类覆盖方法"变成了"传一个 Lambda 进来"。模板思想一点没变——骨架焊死、细节外置——只是实现从继承换成了组合。JDK 8 之后的新框架越来越多走这条路:回调即填空,这是模板模式在 Lambda 时代的进化形态。
5. AQS:并发框架的总骨架
面试必问的 AQS,全名 AbstractQueuedSynchronizer,名字自带 Abstract。acquire() 焊死了"tryAcquire 抢锁 → 抢不到入队 → 阻塞排队等唤醒"整套骨架,ReentrantLock、Semaphore、CountDownLatch 各填各的 tryAcquire/tryRelease。所谓"读懂 AQS 源码",一半就是在读一个巨型模板方法。
6. Spring 的 refresh():一次方法调用启动整个容器
AbstractApplicationContext.refresh() 把 Spring 启动焊成十几步:prepareBeanFactory → invokeBeanFactoryPostProcessors → registerBeanPostProcessors → onRefresh → finishBeanFactoryInitialization……其中 onRefresh() 是留给子类的空方法,默认实现一行"无事可做"。SpringBoot 的 ServletWebServerApplicationContext 重写了它,在那一格空里创建内嵌 Tomcat。所以你每次启动 SpringBoot 应用,本质就是调用了一次模板方法——内嵌 Web 容器,只是它填的一个空。
六、模板 vs 策略:把"变化"抽出去的两种手法
老规矩,把容易混淆的邻居拉出来遛遛。模板和策略干的是同一件事——把变化的部分抽出去——但抽法和粒度完全不同:
| 模板模式 | 策略模式 | |
|---|---|---|
| 手段 | 继承 | 组合 |
| 变化的粒度 | 流程里的个别步骤 | 整个算法整体替换 |
| 有没有固定流程 | 有,骨架焊死 | 没有,每个策略独立完整 |
| 切换时机 | 编译期定死(new 哪个子类就是哪个) | 运行时随便换(set 进去就行) |
| 耦合 | is-a,强绑定 | has-a,松耦合 |
判别一句话:流程不变、只换某几步,用模板;整个打法互换、没有共同骨架,用策略。本篇的推送是模板——"校验→渲染→发送→记日志"为三个渠道共享;排序是策略——快排换冒泡,换的是整个算法,中间没有可共享的流程。
模板还有个先天短板第二节埋过伏笔:靠继承,每个子类烧掉 Java 单继承的唯一名额。JdbcTemplate 已经示范了出路——模板思想完全可以用组合承载,填空从覆盖方法变成传回调。继承版模板是 1994 年的答案,回调版模板是这个时代的答案——但无论哪版,"骨架焊死、细节外置"八个字从没变过。
写在最后
回顾这一篇:模板模式把重复出现的流程上提到父类,焊成 final 模板方法;子类只填抽象方法那些必填的空、按需覆盖钩子那些选填的空——模板方法、抽象方法、公共方法、钩子,四种方法各司其职;final 让"不许改流程"从口头约定变成编译器执法;好莱坞原则"父类调子类"是控制反转的雏形;从 HttpServlet、AbstractList 到 JUnit、JdbcTemplate、AQS、Spring refresh,框架世界的半壁江山都是"框架焊骨架、用户填空"这一个套路;它和策略的分界在粒度——换步骤是模板,换打法是策略。行为型的第一位,也是你每天用得最多的一位。
下一篇 ⑮ 策略模式——模板填的是流程里的某几步,策略换的是整个打法。它还有个响亮的外号:if-else 的解药。你代码里那个嵌套五层、每来一个新需求就得多写一个 else if 的方法,读完下一篇就有解了。