1. 同样一段流程写三遍之后,我开始认真琢磨模板方法
先说个背景。这是我设计模式学习笔记的第15篇,标题里写的是"模版方法模式",规范叫法其实是"模板方法模式"——我当年也经常写错,慢慢才习惯。编号23-13,说明在23个经典设计模式里该轮到行为型这一组了。
最初触发我去认真研究这个模式,是因为一段特别具体的业务代码。当时我手里要交付三个报表导出功能,Excel、CSV、PDF,数据源都一样,只是最终格式不同。正常人第一反应是复制粘贴再改改,我也不例外。改到第三份的时候发现一屏代码里只有一两个方法不同,剩下的全是重复:查数据库、遍历结果集、拼字符串、写文件、打日志、统计耗时。突然冒出一个念头:既然流程不变,为什么不把流程固化下来,只让每个导出器实现自己独特的那部分?
模板方法模式回答的正是这个问题。它把一套算法的骨架定义在父类里,把骨架中某些必须由具体场景决定的步骤留给子类去实现。子类不能修改算法本身的结构,但可以替换算法中的某个环节。用大白话说就是:流程你来定,细节我来补。
这个模式适合任何"流程稳定、步骤可变"的场景。后端写同步任务、前端做页面渲染框架、底层做资源释放模板,都会频繁用到它。这篇文章我会从代码例子讲起,再聊钩子方法、框架里的实际应用,以及我自己踩过的一些坑。
1.1 三个导出器,三段复制粘贴
先还原一下当时我最原始的写法。三个导出器分别是ExcelExporter、CsvExporter、PdfExporter,各自的类里都写着差不多这样的代码:
public void export(String source) { long begin = System.currentTimeMillis(); List<Map<String, Object>> data = queryData(source); String content = buildContent(data); writeToFile(content); System.out.println("export cost " + (System.currentTimeMillis() - begin) + "ms"); }唯一的区别就在buildContent这一步:Excel要拼成表格行的格式,CSV要逗号分隔,PDF要转成某种排版文本。剩下的查询、写文件、耗时统计完全一模一样。
当时我复制到第三份的时候,心里其实已经开始发毛了。不是因为懒,而是因为改了一处公共逻辑,比如把耗时统计换成接监控上报,我就要在三个类里同步修改,一不小心漏一个,后续排查问题成本立马翻倍。这种"相同结构、不同细节"的重复,就是模板方法模式的最佳出场时机。
1.2 模板方法模式到底想解决什么问题
抛开概念,从工程角度看,它解决了两件事。
第一件是消除结构级重复。如果重复的只是几行工具方法,抽一个公共函数就够了;但当你重复的是整个业务步骤的编排顺序,抽公共函数还是会在每个类里继续写那段顺序代码。模板方法模式把编排顺序直接抬到父类里,子类再也看不到流程的全貌,只能看到自己要填空的位置。
第二件是让流程演进只改一处。后来我要在导出流程里增加"导出前校验数据权限"这一环节,只改了父类的模板方法;三个导出器一行代码没动,功能就全上线了。这就是把变与不变分离带来的直接收益。
一句话概括它的思想:**保持算法骨架不变,让子类重定义骨架中的特定步骤。**骨架是"变与不变的边界"里不变的部分,步骤是变化的部分。设计模式里很多问题的本质都是找这条边界,模板方法模式给了你一个非常清晰的切分方式。
2. 模板方法模式的骨架长什么样:父类定流程,子类补细节
在讲代码之前,先把角色理清楚。模板方法模式一共有四个核心概念,理解了这四个东西,后面的代码基本就是顺水推舟。
2.1 四个角色,一张纸说清楚
- AbstractClass(抽象类):定义模板方法,以及若干基本方法。模板方法固定了算法骨架,基本方法是骨架里可以被替换的步骤。
- ConcreteClass(具体子类):实现抽象类定义的基本方法,用自己的逻辑填充骨架中的空缺。
- Template Method(模板方法):抽象类里那个定义流程顺序的方法,一般用final修饰,防止子类把整个流程改掉。
- Basic Method(基本方法):可以是抽象方法(必须由子类实现)、具体方法(父类提供默认行为,子类可以选择覆盖)或钩子方法(默认不做任何事)。
有经验的工程师看到这个划分,马上能意识到:模板方法模式的本质是继承体系中的控制反转。不是你调用父类的方法,而是父类在恰当的时机反过来调用你写的方法。
2.2 一段代码看懂模板方法
回到导出器的例子,用模板方法模式重构后的大致结构是这样:
public abstract class DataExporter { // 模板方法:定义整个导出流程,子类不能修改流程 public final void export(String source) { long begin = System.currentTimeMillis(); try { List<Map<String, Object>> data = fetchData(source); String content = format(data); writeToFile(content); } finally { logCost(begin); } } // 基本方法-抽象:查数据,必须由子类实现 protected abstract List<Map<String, Object>> fetchData(String source); // 基本方法-抽象:格式化数据,必须由子类实现 protected abstract String format(List<Map<String, Object>> data); // 基本方法-具体:写文件,大多数场景下默认实现够用 protected void writeToFile(String content) { // 写入本地文件的默认逻辑 } // 基本方法-具体:记录耗时,父类提供默认实现 private void logCost(long begin) { long cost = System.currentTimeMillis() - begin; System.out.println("export cost " + cost + "ms"); } }子类只需要关注自己真正关心的部分,比如CSV导出器:
public class CsvExporter extends DataExporter { @Override protected List<Map<String, Object>> fetchData(String source) { // 根据source参数查询数据库,返回数据行 return dataSource.query(source); } @Override protected String format(List<Map<String, Object>> data) { StringBuilder sb = new StringBuilder(); for (Map<String, Object> row : data) { sb.append(row.get("name")).append(',').append(row.get("age")).append('\n'); } return sb.toString(); } }整个算法骨架已经固定:查数据、格式化、写文件、记日志。子类要做的就是填空。以后想要加"导出前检查文件是否已存在"这种全局逻辑,只需要改父类export方法,所有导出器同时生效。
2.3 为什么模板方法要用 final 保护
我见过不少新手写模板方法时,没有给方法加final,结果某个子类手一抖直接把整个export方法重写了,骨架失效,所有封装的意义归零。
所以我的建议是:**模板方法默认用final修饰。**这不是Java语法层面的强制要求,而是工程上的自我保护。如果你用的语言不支持final(比如Python),就在类设计上约定这个方法是"流程入口",子类不要覆盖;也可以在源码注释里明确标注"Template Method - Do Not Override"。
另一个细节是访问修饰符。模板方法通常是public,因为是给外部调用的入口。基本方法通常是protected,因为是给子类重写用的,并不希望被客户端直接单独调用。比如上面的format方法,如果客户端绕过export单独调用format,得到的只是一段未写盘的字符串,没什么实际意义。把访问修饰符收窄到protected,就等于从语法层面告诉使用者:这个方法不是让你直接调的,是让你在继承体系里填的。
3. 钩子方法:给流程留一个可选择的"插槽"
如果模板方法模式只是"父类定义流程,子类实现抽象方法",那它就和普通抽象类没什么两样。真正让这个模式变得更灵活的东西是钩子方法(Hook)。
钩子方法的特点:父类给一个默认实现,通常是空实现或者返回一个默认值;子类可以根据需要决定是否覆盖。它的作用不是充当算法中必须的一步,而是给调用方一个"决策点",让算法流程在特定条件下跳过某一步,或者做一点额外的处理。这个设计非常重要,因为它让模板既保持固定,又能应对"部分场景需要,部分场景不需要"的弹性需求。
3.1 钩子方法是什么
很多讲模板方法的资料会把钩子归入"基本方法"里,但它和抽象方法有本质区别:
- 抽象方法:子类必须实现,没有实现就编译不过。
- 钩子方法:子类可以不碰它,一切用父类的默认行为。
如果打个比方,抽象方法像劳动合同里的必填条款,签的时候必须填;钩子方法像选填的补充协议,可填可不填,填了才生效。
在代码层面,钩子方法最常见的形式是一个返回boolean的判断方法。模板方法里写一个if,条件由钩子方法的返回值决定。子类想改变流程走向,不需要改动模板方法本身,只需要重写钩子方法。
3.2 带钩子的下单流程
用一个下单流程来演示。假设所有订单处理器都遵循同一套流程:校验参数、检查库存、计算价格、扣减库存、发通知、记日志。但有一部分特殊订单不需要发通知,另一部分订单需要额外做风控校验。
如果用模板方法模式,就可以给这些"可选环节"设计钩子:
public abstract class OrderHandler { // 模板方法:下单流程骨架 public final void handle(Order order) { validate(order); if (needRiskControl()) { riskControl(order); } checkStock(order); deductStock(order); if (needNotify()) { notifyUser(order); } saveOrder(order); } // 抽象方法:必须由具体处理器实现 protected abstract void checkStock(Order order); protected abstract void deductStock(Order order); // 具体方法:公共步骤,父类提供默认实现 protected void validate(Order order) { // 常规参数校验 } protected void saveOrder(Order order) { // 保存订单到数据库 } // 钩子方法:默认不做风控 protected boolean needRiskControl() { return false; } protected void riskControl(Order order) { // 风控逻辑,默认空实现 } // 钩子方法:默认发通知 protected boolean needNotify() { return true; } protected void notifyUser(Order order) { // 发送站内信、短信等 } }之后写具体订单处理器时,正常订单只需要实现四个抽象方法,风控钩子和通知钩子全部用默认值;需要风控的订单重写needRiskControl返回true,同时实现riskControl方法;不想发通知的订单把needNotify改为false即可。
public class HighRiskOrderHandler extends OrderHandler { @Override protected void checkStock(Order order) { // 高风控订单检查库存时多查一个保留库存 } @Override protected void deductStock(Order order) { // 扣减库存 } @Override protected boolean needRiskControl() { return true; } @Override protected void riskControl(Order order) { // 走额外风控策略 } }这个例子说明,钩子方法让"流程骨架固定"和"场景灵活配置"不再矛盾。你不需要在子类里重写整个流程来插入一段逻辑,只需要用钩子告诉父类"这一步要不要做、怎么做"。
3.3 钩子方法的命名和访问控制
钩子方法在命名上最好能让使用的人一眼看明白。像needXxx、shouldXxx这种返回boolean的,直译就是"是否需要/是否应该";像onXxx、beforeXxx、afterXxx这种空实现钩子的,表达的是"在某个节点做额外处理"。我在项目里见过把钩子方法起成doSomething的,一眼看去分不清它是抽象方法还是钩子,结果子类里要么漏实现,要么以为必须实现。
访问控制上,钩子方法一般用protected,原因和基本方法一样:钩子本来就不是给外部客户端调用的,它是父类在流程内部自己调用、子类在继承时选择性覆盖的。如果钩子写成public,外部调用者就能绕过流程单独触发某个决策点,很容易破坏状态一致性。另外建议钩子方法不要抛受检异常,因为钩子是可选逻辑,强制子类处理异常会严重降低使用意愿,最后大家全都重写钩子去吞异常,得不偿失。
4. 框架里的模板方法:同一个套路反复出现
模板方法模式并不是只在教科书里出现的理论,它在实际框架代码中出现得相当频繁。很多你每天在用的框架,核心流程就是靠模板方法搭起来的,只是你平时写业务代码时没意识到。
4.1 Servlet 的 service 方法就是模板方法
写过Java Web的人对一个流程一定不陌生:继承一个Servlet基类,重写doGet、doPost,然后容器在处理请求时自动调用你的这些方法。
那个基类的service方法,本质上就是一个模板方法。它做了这些事情:解析请求类型、判断是GET还是POST、再分别调用doGet或doPost。你写的doGet、doPost就是这个模板里的抽象方法。至于请求怎么接收、怎么应答,那是父类帮你处理好的骨架,你只需要填空。如果你对整个流程不满意,也可以直接重写service方法——这正是"模板方法必须加final保护"的反例,Servlet规范给开发者留了完全重写的口子,代价是每次都要自己处理所有分支,很容易出错。
框架设计者为什么愿意用这种模式?核心原因是它把"算法结构"和"具体实现"分开了。结构一旦确定,所有使用者都必须遵循同一套生命周期,这样框架才能在关键时刻插入一些固定逻辑——比如事务管理、日志记录、安全检查。如果允许每个子类完全自由地定义流程,框架就没办法保证这些事情一定会被执行到。
4.2 数据库访问封装里的模板方法风格
再举一个更贴近日常的例子:数据库访问的封装。只要是用过主流ORM框架或者自己封装过Dao基类的人,都会遇到一种典型写法:
调用者提供查询逻辑,父类负责打开连接、开启事务、执行、关闭、回滚。
伪代码大概长这样:
public abstract class BaseDao { public final <T> T execute(ConnectionCallback<T> action) throws Exception { Connection conn = getConnection(); boolean oldAutoCommit = conn.getAutoCommit(); try { conn.setAutoCommit(false); T result = action.doInConnection(conn); conn.commit(); return result; } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(oldAutoCommit); closeQuietly(conn); } } }在这个框架里,"开启事务、提交、回滚、释放资源"这套骨架是固定的,子类只要实现doInConnection方法,把你真正要执行的SQL写进去。模板方法模式在这里的最大价值,是把"正确管理连接和事务"这件所有人都容易写错的事情集中到一处处理。你不需要关心什么时候提交、什么时候回滚,因为在模板方法里这些步骤已经被牢牢固定住了。
4.3 好莱坞原则:父类说了算
模板方法模式背后有一条著名的原则,叫好莱坞原则:Don't call us, we'll call you. 翻译过来是"别找我们,我们找你"。
传统思维方式里,子类调用父类的方法,这是常识。但模板方法模式把方向反转了:父类在流程的某个节点主动调用子类的方法。子类做的事情不是主动去发起流程,而是被"嵌入"到父类控制的生命周期里。这也是一种控制反转。
理解了这一点,你再看各种框架的扩展机制就会通透很多。框架不希望你重写整个业务处理入口,而是希望你在它预留的扩展点上做定制。这个扩展点,本质上就是模板方法模式为你预埋好的基本方法或钩子方法。
5. 模板方法和策略模式这对"亲兄弟",到底怎么选
很多人在学了模板方法模式之后会产生一个困惑:模板方法模式和策略模式怎么看怎么像,都是把可变的部分单独拎出来,都符合开闭原则,那到底用哪个?
这个困惑我也有过。实际写多了之后,我总结了一条最简单的判断标准:你的变化点是"算法的步骤",还是"整个算法"?
5.1 一句话记住两者的区别
模板方法模式解决的是"流程相同、局部不同"的问题。你已经有了一套固定的算法骨架,只需要往里替换某个步骤的实现。它是一个"框架内填空"的思路。
策略模式解决的是"一系列行为可以互相替换"的问题。整个算法的结构不是由父类定死的,而是由一组策略对象组成,算法可以在运行时整体切换。
举个例子。支付功能,你可能有支付宝、微信、银行卡三种支付方式,每种方式的整个流程都不一样:参数构造不同、签名算法不同、回调处理不同。这种情况用策略模式合理,支付渠道本身是一个完整的、可替换的算法。
而"下单流程"这种业务,无论哪个渠道进来,都需要"校验、扣库存、记订单、发通知",只是中间某些环节实现不同。这种情况用模板方法模式更顺。流程骨架稳定,变化的是步骤。
5.2 什么时候选模板方法,什么时候选策略
我把自己的判断维度整理成一张表格:
| 维度 | 模板方法模式 | 策略模式 |
|---|---|---|
| 变化粒度 | 算法骨架固定,只替换局部步骤 | 整个算法都可以替换 |
| 实现方式 | 继承,子类重写抽象方法 | 组合,客户端指定策略对象 |
| 控制权 | 父类反向调用子类(好莱坞原则) | 客户端主动选择策略 |
| 代码耦合 | 子类与父类强绑定 | 策略实现与上下文解耦 |
| 典型场景 | 导出器、下单流程、数据处理管道 | 支付渠道、排序算法、促销规则 |
| 扩展方式 | 新增子类,覆盖特定方法 | 新增策略实现,运行时注入 |
我的建议是:**如果你发现要替换的不是"某几步",而是"整个流程",模板方法模式就不合适了,赶紧换策略模式。**反过来,如果你用策略模式做"下单流程",会发现自己一直在写同一个接口下的多个实现类,但实现类内部还是重复的流程编排代码,那其实就是用错了模式。
还有一点想提醒:这两种模式不是互斥的。我在实际项目里写过一种结构,模板方法负责编排流程,流程里的某个环节用策略模式注入具体实现。比如下单模板方法里的价格计算步骤,在子类里用一个PriceStrategy接口实现,算价逻辑可以按用户等级、促销活动动态切换。模板方法管"流程怎么走",策略管"某一步怎么做",两者配合起来非常舒服。
6. 实战中写模板方法容易踩的坑和我的习惯
模板方法模式看起来简单,实际落地时还是有几个高频问题。这些坑我自己都踩过,写出来希望能帮你省点时间。
6.1 模板方法没加 final:被后代类悄悄改写
这个问题在第一节提到过,但值得单独展开。模板方法一旦缺失final保护,整个模式的根基就动摇了。子类可以轻飘飘地重写整个算法流程,原本在父类里统一处理的校验、日志、事务逻辑全部失效。
我见过最隐蔽的一种破坏方式:子类没有直接重写模板方法,但重写了一个父类装饰过的具体方法,并且忘记调用super。比如父类的writeToFile里写了IO异常兜底,子类重写后直接覆盖了兜底逻辑。这种问题不体现在流程层面,非常难排查。
我的习惯是:**用final管住模板方法,用private管住父类内部不可覆盖的步骤,用protected说明"这里可以重写"。**如果你在写一个会被外部扩展的框架,还在接口文档里用注释标注每个方法的生产意图。这样做也许不算强制,但能让继承体系里每个人都有明确的权责边界。
6.2 抽象方法太多:把子类变成"什么都得干"的工具人
模板方法模式的过度设计,最典型的表现就是抽象方法数量失控。父类恨不得把每一个小步骤都定义成抽象方法,结果子类为了实现一个功能,要被迫写十几个只有一行return的方法。
这种设计带来的直接后果是子类非常脆弱。新增一个抽象方法,所有子类都要跟着改,哪怕大多数子类对这个步骤根本不关心。
解决思路是区分"必须填的"和"可选填的":必须由子类决定、且所有场景都无法提供默认值的,才设计成抽象方法;其他步骤一律提供默认实现或钩子方法。遵守一个原则:**能让子类少写一个方法,就少写一个方法。**子类只需要关心真正不同的地方,这才是模板方法模式提升开发效率的本质。
6.3 钩子滥用:模板里塞满 if 分支
钩子方法很好用,但一个模板方法里三四个钩子、嵌套好几层if,会让人读起来很痛苦。我见过一个订单处理模板,里面有needAudit、needNotify、needRiskControl、needSplit,每个钩子还互相影响,最后整个流程变成了一个小的状态机,完全没有模板方法模式该有的清爽。
我的判断标准是:**钩子的数量控制在2个以内,一旦超过这个数,先停下来想一想,是不是该把流程拆成两个模板方法,或者把部分逻辑放进策略模式里。**模板方法的价值在于"一眼看清流程",如果一眼看不到流程,只剩一片if分支,那就得不偿失了。
6.4 层级过深:三层以上继承,阅读成本飙升
模板方法模式建立在继承之上,而继承最大的问题就是层级越深,越难阅读。父类定义了一部分流程,中间类改了一点,子类又改了一点,最后你要搞清楚一个方法真正执行的逻辑,得把三四个类的代码来回翻。
我一般遵守这样一个约定:**模板方法模式的继承深度控制在两层,最多三层。**三层以上的结构,优先考虑组合方式,把流程中变化的部分提取为接口或策略,而不是继续往下继承。
6.5 我实际写模板方法的流程
最后分享一套我自己这些年用下来很顺手的方法。遇到疑似适合模板方法模式的场景,我不会一上来就设计抽象类,而是先老老实实写一份完整实现,把流程固定下来;再在第二个类似功能出现时,对比两者的差异;确定"骨架相同、局部不同"后,才把公共部分抽到父类。
这个流程很多人觉得多此一举,觉得应该一开始就想好。但实际经验是,**太早抽象往往抽错边界。**你第一次写的时候判断不了哪些步骤是稳定的、哪些是可变的,强行设计抽象方法往往会多造几个没用的钩子。等到有了第二个真实案例做对照,变化点和稳定点才会清晰暴露出来。模板方法模式不是让你"先猜再设计",而是让你"先看再提炼"。让第三个类似功能出现时不至于复制第三遍,就是最合适的时机。
这篇文章写下来其实花了我不少功夫。早在学生时代我就见过模板方法模式的类图,但真正理解它,还是工作几年后开始封装各种业务模板才慢慢悟出来的。设计模式这东西,背概念容易,用对场景难。模板方法模式是一个上手门槛很低、但用好很见功力的模式,希望这篇笔记能帮你少走一些弯路。