模板方法模式是我在实际开发里用得最顺手的一类行为型设计模式之一它解决的核心痛点是一个业务流程骨架稳定但其中几个步骤在不同场景下表现不同。与其把重复的流程代码在各处复制粘贴不如把流程骨架锁定在父类里把容易变的部分留给子类去填空。这篇文章就围绕这个模式展开从概念、代码、实战到踩坑完整走一遍适合所有想真正把模板方法用到工程里的开发者。1. 模板方法模式先把定义翻译成人话1.1 一套固定流程中间几个步骤我来定官方定义通常会这样说定义一个操作中的算法的骨架而将一些步骤延迟到子类中。模板方法使得子类可以不改变算法的结构即可重新定义算法的某些特定步骤。这个定义听起来略显抽象我习惯用一个生活场景去理解它。你在驾校学车时不管报名的是C1手动挡还是C2自动挡整个学车流程都是固定的报名缴费、预约练车、上车练习、预约考试、实际考试、领取驾照。这个流程的骨架不能乱动总不能跳过去考试先拿驾照。但每个环节的具体内容手动挡和自动挡差异巨大练车内容不一样考试车型不一样连油门刹车配合的逻辑都不一样。驾校把流程骨架定死了学员、教练、考试中心在不同的环节填入不同内容这就是模板方法模式最简单直观的映射。放到程序世界模板方法模式要解决的是典型的重复但又不完全一样的问题。我举一个电商系统里常见的例子创建订单的时候普通订单、秒杀订单、团购订单都要经历校验库存、锁定库存、生成订单、扣减库存、记录日志这个流程但校验规则不同、库存锁定方式不同、后续的库存处理策略也不同。如果没有模板方法大家多半会写三个方法把同样一段骨架代码复制三遍后面流程一旦增加一个步骤比如新增优惠券核销三个地方就得同步改三次改漏一个就是故障。1.2 好莱坞原则别打电话给我们我们会打给你模板方法模式背后的设计思想在行业内有个很有意思的代号叫好莱坞原则Dont call us, well call you. 意思是演员不要主动给导演打电话问自己有没有角色导演看中了自然会联系你。映射到代码里就是父类定义好流程骨架在合适的时机主动调用子类实现的方法子类永远不需要在某个业务逻辑里主动去调用父类的模板方法。为什么这个原则很重要因为它是一种典型的控制反转。普通的继承关系里子类依赖父类提供的能力所以子类主动调用父类是常见操作但模板方法模式把这个关系倒过来了父类在流程里掌握主动它来决定你这些步骤什么时候执行、以什么顺序执行、哪些步骤必须执行、哪些步骤可以跳过。子类只负责把父类需要的能力准备好至于这个能力在流程里什么时候会被用上、会被用几次子类完全不关心。这个思路其实和你要写一个框架的核心思路是一模一样的。框架做的事情就是定义一个固定的处理流程然后在某些特定节点留出口子让使用者插入自定义逻辑。比如一个Web框架处理HTTP请求的骨架从接收请求、路由解析、参数绑定、执行业务逻辑、渲染响应到记录日志这个骨架被框架写死但你可以在特定的中间件位置插入自己的逻辑。你不需要知道请求何时到达、框架何时回调你只负责把逻辑填进该填的位置。这也解释了为什么模板方法模式在框架设计、工作流引擎、代码生成器这类场景里出镜率极高。只要流程稳定、节点可变这个条件成立模板方法基本上就是最优解。2. 代码落地从朴素实现到模板方法模式2.1 最初代码复制粘贴的三遍先看一个没有任何设计模式加持的朴素版本。假设我现在要做一个数据导出功能分别支持CSV、Excel、PDF三种格式。第一版代码通常是这样的public class CsvExporter { public void export() { // 1. 查询数据 ListMapString, Object data queryData(); // 2. 加工数据统一格式 ListString[] processed processData(data); // 3. 渲染CSV文件 String csvContent renderCsv(processed); // 4. 保存文件 saveFile(csvContent, export.csv); // 5. 记录日志 log(CSV导出完成); } } public class ExcelExporter { public void export() { ListMapString, Object data queryData(); ListString[] processed processData(data); Workbook workbook renderExcel(processed); saveWorkbook(workbook, export.xlsx); log(Excel导出完成); } } public class PdfExporter { public void export() { ListMapString, Object data queryData(); ListString[] processed processData(data); byte[] pdfBytes renderPdf(processed); saveBytes(pdfBytes, export.pdf); log(PDF导出完成); } }这段代码最大的问题不是代码重复虽然看起来第二步加工数据逻辑可能重复了三份。真正的问题是业务流程的稳定性没有被显式声明。今天三个导出器都遵守这个流程明天如果业务方要求所有导出在保存之前必须校验文件大小超过阈值要压缩你就得去三个类里各加一段校验逻辑。三个类还算能接受如果是三十个导出器呢你只能靠搜索文件名然后把代码逐段补进去补漏一次就是线上事故。2.2 上模板方法骨架收敛子类填空用模板方法模式重构之后我们把完整的流程骨架搬到抽象的父类里子类只实现真正有差异的步骤。依然以导出功能为例重构后的结构是这样的public abstract class AbstractExporter { public final void export() { ListMapString, Object data queryData(); ListString[] processed processData(data); renderAndSave(processed); log(导出完成); } protected abstract ListMapString, Object queryData(); protected abstract ListString[] processData(ListMapString, Object data); protected abstract void renderAndSave(ListString[] processed); private void log(String message) { System.out.println(now() message); } } public class CsvExporter extends AbstractExporter { Override protected ListMapString, Object queryData() { // CSV的数据来源是数据库查询走分页逻辑 } Override protected ListString[] processData(ListMapString, Object data) { // 把Map格式统一转换成字符串数组 } Override protected void renderAndSave(ListString[] processed) { // 拼CSV字符串并写文件 } }注意我把export方法加上了final修饰。这是模板方法模式一个非常关键的习惯模板方法本身不允许子类覆写。如果你不锁死某个子类心情不好直接 override 了整个 export 方法流程骨架就形同虚设模式直接失效。我见过不少初写这个模式的同学会把这个 final 省略后续维护的人看到 export 是个普通方法顺手就覆写了整个一致性就被破坏了。所以模板方法用 final 锁死别犹豫。在这个结构里父类是全流程的唯一入口子类只负责回答三个问题数据从哪来、数据长什么样、数据以什么格式落盘。将来要新增 docx 导出只需要新写一个子类实现三个填空方法不需要动任何已有代码。这就是模板方法模式最核心的收益算法骨架复用、变更局部化、扩展只增不改。2.3 钩子方法让子类也能干预流程上面的例子把可变的步骤抽成了抽象方法要求所有子类必须实现。但在实际业务里还有一类变点更温柔有的子类需要有的子类不需要甚至同一个子类在不同配置下表现还不一样。这时候如果全部用抽象方法会让子类被迫实现一堆压根用不上的逻辑。这种情况就要引入钩子方法。钩子方法是一种已经被父类提供默认实现的方法默认实现要么为空、要么是一个通行的行为子类可以选择覆写它从而改变流程的某个局部表现子类也可以选择不覆写直接沿用默认行为。我把导出功能再升级一下现在增加一个需求有的导出场景需要在文件内容最前面生成表头行有的场景不需要。同时导出完成之后有的场景需要给管理员发一个通知有的场景不需要。这个需求如果用抽象方法实现每个子类都要写一个是否要表头的判断逻辑很啰嗦。用钩子方法则优雅很多public abstract class AbstractExporter { public final void export() { // 1. 查询数据 ListMapString, Object data queryData(); // 2. 加工数据 ListString[] processed processData(data); // 3. 是否需要表头钩子方法决定 if (needHeader()) { addHeader(processed); } // 4. 渲染并保存 renderAndSave(processed); // 5. 是否需要通知钩子方法决定 if (needNotify()) { sendNotification(); } // 6. 记录日志 log(导出完成); } protected abstract ListMapString, Object queryData(); protected abstract ListString[] processData(ListMapString, Object data); protected abstract void renderAndSave(ListString[] processed); protected boolean needHeader() { return true; // 默认需要表头 } protected boolean needNotify() { return false; // 默认不发通知 } private void addHeader(ListString[] processed) { // 在数据最前面插入表头行 } private void sendNotification() { // 发送站内信或邮件 } } public class CsvExporter extends AbstractExporter { Override protected ListMapString, Object queryData() { ... } Override protected ListString[] processData(ListMapString, Object data) { ... } Override protected void renderAndSave(ListString[] processed) { ... } Override protected boolean needNotify() { return true; // CSV导出的数据量大导出完要通知管理员 } }钩子方法的精髓在于默认行为的存在。子类面对钩子时只有两个选择要么继承默认行为要么覆写它。这个模式给了子类一种可参与但不强制的干预能力也让模板方法在保持骨架稳定的前提下拥有了一定的柔性。在设计钩子方法时有一个原则值得特别注意钩子方法的可见性要设置为 protected因为它本来就不是给外部调用的它是给子类覆写用的。如果你把钩子设成 public外部代码会看到一个空方法或者一个含义模糊的开关方法容易引起误用。我在实际代码审查里经常提醒团队凡是模板方法模式里的钩子一律 protected不要暴露到类的外部接口里。3. 完整实战报表导出中心的模板方法设计3.1 需求拆解与流程制定光靠泡茶泡咖啡、导出文件这些例子还不足以体现模板方法模式在复杂业务里的威力。我拿一个相对完整的实际项目来说明这是我之前在一套后台管理系统里做过的报表导出中心它的需求如下平台每天会生成多种业务报表运营同学可以把任意报表导出成 CSV 或 Excel后续还可能增加 PDF 格式。所有导出任务都要做权限校验、数据量限制、文件上传到对象存储、写入导出记录、发送站内信通知。不同报表的数据来源不同有的是查订单库、有的是统计用户行为、有的是聚合多个接口的数据数据加工规则也不一样有的是直接透传有的是做脱敏有的是把枚举值转成中文。如果按照最原始的写法每个报表类型和每种导出格式组合一套代码假设有10类报表支持2种格式那就是20个相似度超过70%的类。这显然是不可接受的。用模板方法模式后我把整个导出的业务流程在抽象父类里固定下来每个报表类型只实现数据获取和加工两个核心逻辑导出格式的渲染差异单独抽象一层。设计后的流程是这个样子1. 校验导出权限可跳过钩子 2. 组装查询参数强制实现原语操作 3. 执行数据查询强制实现原语操作 4. 对数据做加工处理强制实现原语操作 5. 检查数据量是否超限父类统一实现 6. 渲染指定格式的文件委托给格式渲染器 7. 上传文件到对象存储父类统一实现 8. 写入导出日志父类统一实现 9. 发送通知可跳过钩子3.2 核心代码解析整个抽象父类的核心代码是这样组织的public abstract class AbstractReportExporter { public final void export(ExportRequest request) { // 1. 权限校验钩子方法默认跳过 if (!checkPermission(request)) { throw new ForbiddenException(无导出权限); } // 2. 组装查询参数 QueryParam param buildQueryParam(request); // 3. 查询数据 ListMapString, Object rawData queryData(param); // 4. 数据加工 ListMapString, Object processedData processData(rawData, param); // 5. 数据量限制检查父类统一逻辑 if (processedData.size() getMaxExportRows()) { throw new ExportLimitExceededException(超出最大导出行数); } // 6. 渲染文件委托给格式渲染器 ExportFile exportFile renderer.render(processedData, request.getFormat()); // 7. 上传对象存储 String fileUrl uploadFile(exportFile); // 8. 写日志 writeExportLog(request, fileUrl); // 9. 发送通知钩子方法默认不发 if (needNotify()) { sendNotify(request); } } protected abstract QueryParam buildQueryParam(ExportRequest request); protected abstract ListMapString, Object queryData(QueryParam param); protected abstract ListMapString, Object processData( ListMapString, Object rawData, QueryParam param); protected boolean checkPermission(ExportRequest request) { return true; } protected boolean needNotify() { return false; } protected int getMaxExportRows() { return 50000; } protected final void writeExportLog(ExportRequest request, String fileUrl) { // 统一的日志写入逻辑 } protected final String uploadFile(ExportFile exportFile) { // 统一的对象存储上传逻辑 } }有一个细节我想特别说一下就是uploadFile和writeExportLog这两个方法我用 final 修饰了。在模板方法模式里并不是所有步骤都应该下沉给子类实现。像文件上传、日志记录这类流程如果允许子类各自覆写很容易出现不同的报表导出走了不同的文件上传通道、日志格式不统一的情况给后续排查问题增加成本。我的习惯是步骤阶段性的细节尽量收拢到父类把可变面缩小到真正需要差异化的几个原语操作上。另一个细节是getMaxExportRows。我做成了钩子默认50000行个别超大报表可以覆写调高。这个考虑是业务上确实存在特殊报表需要导出更多数据但又不希望所有报表都放开限制钩子方法在这个场景里非常适合。3.3 扩展点设计加一个新的导出格式要动多少代码这个项目上线后遇到一个新需求运营要增加 PDF 导出格式。如果用多继承加条件分支的实现方式来写这个需求可能要动十几个类。但模板方法模式下这个扩展是一个标准的开闭原则演示。我把渲染环节抽成了一个ReportRenderer接口它本身不依赖模板方法模式但它让模板方法得到更好的扩展性public interface ReportRenderer { ExportFile render(ListMapString, Object data, ExportFormat format); } public class CsvRenderer implements ReportRenderer { Override public ExportFile render(ListMapString, Object data, ExportFormat format) { // CSV 渲染逻辑 return new ExportFile(csv, contentBytes); } } public class ExcelRenderer implements ReportRenderer { Override public ExportFile render(ListMapString, Object data, ExportFormat format) { // Excel 渲染逻辑 return new ExportFile(xlsx, bytes); } }抽象父类通过构造器注入ReportRendererpublic abstract class AbstractReportExporter { private final ReportRenderer renderer; protected AbstractReportExporter(ReportRenderer renderer) { this.renderer renderer; } // export 方法里的渲染步骤就调用 renderer.render(data, format) }将来要支持 PDF只需要新增PdfRenderer再写一个具体的子类继承AbstractReportExporter完成查询参数组装、数据查询和数据加工这三个原语操作。原有代码完全不需要改动这个扩展成本已经低到极致了。我在一开始设计时对格式渲染到底放在模板方法里做还是单独抽接口犹豫过一段时间。最终选择抽接口的理由是同一份数据今天的导出格式是 CSV、Excel 二选一明天可能出现一个报表同时生成多种格式的需求。如果渲染逻辑写死在具体子类里到时候一个子类要依赖多个导出器的代码就会很别扭。单独抽一个渲染器接口可以让模板方法和渲染策略各自演进这个拆分我后来验证是值得的。4. 模板方法 vs 策略模式别再纠缠不清4.1 一个全替换一个局部替换几乎所有学习设计模式的人都会在模板方法和策略模式之间犯一次迷糊因为这两个模式都是在解决算法变化的问题。但它们的本质差异非常清晰策略模式是用组合的方式把整个算法封装成独立的对象运行时可以整体替换模板方法模式是用继承的方式把算法的骨架固定下来只允许对局部步骤进行替换。用一个通俗的比喻你把手机送修如果只是屏幕坏了官方只会换屏幕其他零件不动这相当于模板方法模式——修手机的流程骨架不变只有显示屏这一步被替换如果你的手机已经老旧不堪维修师傅建议你直接换一台新手机这就是策略模式——整个手机算法整体替换只留下相同的能用目标。在代码实现上策略模式的客户端持有策略接口要在运行时动态切换整个策略对象而模板方法模式的客户端拿到的子类对象它的算法骨架和局部实现是编译期就绑定在一起的只能在编译期决定使用哪种子类。两者之间有非常明确的服务边界。这个区别直接决定了使用场景。如果算法整体有多套不同的完整实现未来的变化方向是整体换新策略模式更合适如果算法骨架是稳定的变化只发生在特定的几个步骤上模板方法模式更合适。4.2 选型判据与实战心得我总结了一套自己在做技术选型时的判断标准你可以直接拿去套用。第一看变化粒度。变化是一个方法的完整逻辑还是一个流程中的某个步骤如果是前者优先策略模式如果是后者优先模板方法。第二看骨架的稳定性。你确定的那个流程未来会不会被推翻重构如果骨架本身也经常变那模板方法就不太合适因为父类一变所有子类都会受影响。正所谓父类动一动子类抖三抖。第三看是否需要在运行时切换。策略模式天然支持运行时切换策略模板方法如果想运行时切换只能通过创建不同子类对象间接实现。如果你的业务存在同一个流程、同一个入口、但运行时要根据配置切换行为的需求策略模式组合模板方法会更灵活。第四看组合优于继承在你团队里的接受度。继承层级太深代码会越来越难读。一个模板方法如果下面挂了五六层子类继承每次加一个特性都要担心影响面。这时候你要是能把它改造成策略模式 模板方法合体即骨架做成策略类步骤通过函数接口注入整个结构会扁平很多。我在实际项目里最常用的姿势是用模板方法定义流程用策略模式处理流程中某一步的算法变体也就是把两者组合起来用。前面报表导出中心就是这么做的模板方法管流程渲染器管格式算法两个模式配合得非常好。设计模式之间不是互斥的组合使用往往能发挥更大的价值。5. 踩过的坑与实用建议5.1 这些坑我基本都踩过第一个坑模板方法在构造函数里被调用。父类在构造器中就调用模板方法或某个原语方法会导致子类还没初始化完成就执行了实例方法结果访问到的是 null 或者默认值。比如在某次项目里我在 AbstractReportExporter 的构造函数里调用了getMaxExportRows()其中某个子类覆写了这个方法且依赖自己的成员变量 maxRows结果每次创建该子类的时候 maxRows 都为 null。排查了很久才发现问题根源。解决方案是把这些方法的调用时机挪出构造函数或者在调用前明确初始化顺序。第二个坑子类覆盖了整个模板方法。这个现象出在模板方法没有加 final的代码里。稍微资深一点的开发都知道模板方法模式要求模板方法不允许覆写但新手很容易忽略测试同学一发现某个子类行为不符合预期就直接在子类里覆写了 export 方法模板方法模式立刻变成普通的继承覆盖骨架的约束力完全丧失。我现在的代码规范里明确要求模板方法一律 final。第三个坑把钩子做成了抽象方法。有些开发对钩子理解不到位写出来的钩子是 abstract逼着每个子类都必须实现它。钩子的意义恰恰在于可选参与它应该提供一个默认实现让子类可以置之不理。如果一个钩子所有子类都要覆写才能正常工作那它其实是一个原语操作应该用抽象方法表达用错了抽象特征会让子类增加大量无意义的重复代码。第四个坑对原语操作强行增加参数。模板方法模式里的原语方法参数列表应该保持精简只传该方法真正需要的数据。我第一次设计报表导出器时给 queryData 传了一个巨大的上下文对象里面既有查询参数又有用户信息又有格式信息结果所有原语操作的签名都被污染了一个参数改动要连带改三个子类。后来我改成每个原语方法只接收最小必要参数改动成本大幅下降。第五个坑父类实现了太多非通用逻辑。有些通用的步骤确实应该下沉到父类但如果一个方法只被一半的子类使用那把它放父类就会污染另一半子类。遇到这种情况应该拆成更细粒度的钩子或者把非通用逻辑放回子类。判断标准很简单父类方法至少要被大多数子类用到且实现逻辑对所有子类无副作用。5.2 几个让模板方法更优雅的习惯除了避开上面的坑我还在实践中积累了一些能让模板方法模式用起来更顺手的小习惯这里一并整理给你。第一个习惯给原语操作命名时带上明确的语义提示。原语操作是设计给子类覆写的命名要能让人一眼看懂这一步是干什么的。比如用buildQueryParam而不是step1用processData而不是doSomething。骨架方法里每个步骤的注释也应当保留后续看代码的人能通过注释快速还原整个流程。第二个习惯原语操作的可见性设置为 protected暴露范围越小越好。父类的模板方法是 public final原语操作是 protected abstract 或 protected 钩子这样既保证外部可调用入口又防止业务方误用内部步骤。第三个习惯在模板方法里使用日志或调试输出时加上步骤编号。比如log(Step 3/9: processData)。这样当生产环境出一个问题时能从日志流水快速定位到流程到哪一步炸的。这个习惯在流程较为复杂的模板方法里特别有用。第四个习惯不要让模板方法做得太长。如果流程超过十个步骤我会先把部分步骤合并成子流程比如把渲染 上传 写日志合并为一个persistExportResult方法再把细节放到私有方法里。模板方法本身保持清新简洁逻辑越直接越不容易在后期被改坏。最后再分享一点个人体会设计模式学到最后工具价值反而其次它真正提升的是你识别变化点的能力。模板方法模式让我在拿到任何一段流程化业务时都会下意识问三个问题哪些步骤是稳定不变的、哪些步骤是允许变化的、变化是否可选。这三个问题的答案基本决定了整个类结构怎么搭。模板方法模式只是这个思路的表达形式之一但它给了我最标准的切入点也是我在多次重构中收益最稳定的模式之一。后续如果你的业务开始出现同流程多实现的苗头可以试试先画流程骨架再把每个变化点标出来用模板方法把它落成代码你会感受到代码结构一下子稳定下来的清爽感觉。