模板方法模式详解:父类定流程,子类补细节
1. 同样一段流程写三遍之后我开始认真琢磨模板方法先说个背景。这是我设计模式学习笔记的第15篇标题里写的是模版方法模式规范叫法其实是模板方法模式——我当年也经常写错慢慢才习惯。编号23-13说明在23个经典设计模式里该轮到行为型这一组了。最初触发我去认真研究这个模式是因为一段特别具体的业务代码。当时我手里要交付三个报表导出功能Excel、CSV、PDF数据源都一样只是最终格式不同。正常人第一反应是复制粘贴再改改我也不例外。改到第三份的时候发现一屏代码里只有一两个方法不同剩下的全是重复查数据库、遍历结果集、拼字符串、写文件、打日志、统计耗时。突然冒出一个念头既然流程不变为什么不把流程固化下来只让每个导出器实现自己独特的那部分模板方法模式回答的正是这个问题。它把一套算法的骨架定义在父类里把骨架中某些必须由具体场景决定的步骤留给子类去实现。子类不能修改算法本身的结构但可以替换算法中的某个环节。用大白话说就是流程你来定细节我来补。这个模式适合任何流程稳定、步骤可变的场景。后端写同步任务、前端做页面渲染框架、底层做资源释放模板都会频繁用到它。这篇文章我会从代码例子讲起再聊钩子方法、框架里的实际应用以及我自己踩过的一些坑。1.1 三个导出器三段复制粘贴先还原一下当时我最原始的写法。三个导出器分别是ExcelExporter、CsvExporter、PdfExporter各自的类里都写着差不多这样的代码public void export(String source) { long begin System.currentTimeMillis(); ListMapString, 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 { ListMapString, Object data fetchData(source); String content format(data); writeToFile(content); } finally { logCost(begin); } } // 基本方法-抽象查数据必须由子类实现 protected abstract ListMapString, Object fetchData(String source); // 基本方法-抽象格式化数据必须由子类实现 protected abstract String format(ListMapString, 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 ListMapString, Object fetchData(String source) { // 根据source参数查询数据库返回数据行 return dataSource.query(source); } Override protected String format(ListMapString, Object data) { StringBuilder sb new StringBuilder(); for (MapString, 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(ConnectionCallbackT 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 好莱坞原则父类说了算模板方法模式背后有一条著名的原则叫好莱坞原则Dont call us, well 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 我实际写模板方法的流程最后分享一套我自己这些年用下来很顺手的方法。遇到疑似适合模板方法模式的场景我不会一上来就设计抽象类而是先老老实实写一份完整实现把流程固定下来再在第二个类似功能出现时对比两者的差异确定骨架相同、局部不同后才把公共部分抽到父类。这个流程很多人觉得多此一举觉得应该一开始就想好。但实际经验是**太早抽象往往抽错边界。**你第一次写的时候判断不了哪些步骤是稳定的、哪些是可变的强行设计抽象方法往往会多造几个没用的钩子。等到有了第二个真实案例做对照变化点和稳定点才会清晰暴露出来。模板方法模式不是让你先猜再设计而是让你先看再提炼。让第三个类似功能出现时不至于复制第三遍就是最合适的时机。这篇文章写下来其实花了我不少功夫。早在学生时代我就见过模板方法模式的类图但真正理解它还是工作几年后开始封装各种业务模板才慢慢悟出来的。设计模式这东西背概念容易用对场景难。模板方法模式是一个上手门槛很低、但用好很见功力的模式希望这篇笔记能帮你少走一些弯路。