
1. 项目概述当设计模式遇上网络热梗最近在代码评审时看到团队里一位年轻同事写的业务处理流程几个模块的处理步骤高度相似但代码却复制粘贴了好几份。我指着代码问他“宝你这代码‘输液’了”他一脸懵。我接着说“输的是‘重复’的液啊。”玩笑归玩笑这其实是开发中非常典型的问题——多个算法或流程骨架相同仅部分步骤有差异导致代码冗余且难以维护。这时就该请出我们今天要聊的“模板方法”模式了。这个模式的名字听起来有点学术但它的思想就像那句“宝我输液了输的想你的夜”一样有一个固定不变的“输液”流程框架想你但每次“输的液”具体想你的内容、场景可以各不相同。它完美解决了在父类中定义算法骨架而将一些步骤延迟到子类中实现的问题是提升代码复用性和扩展性的利器。无论你是刚入门的设计模式新手还是想重温经典的老手理解模板方法都能让你在构建清晰、稳固的代码结构时更加得心应手。2. 核心思想与模式结构拆解2.1 什么是模板方法模式简单来说模板方法模式是一种行为设计模式。它的核心在于在一个抽象类中定义一个操作中的算法骨架即“模板”而将算法中的某些特定步骤通常是变化的步骤抽象出来交由子类去实现。这样不同的子类可以在不改变算法整体结构的情况下重新定义该算法的某些特定步骤。用我们开头的梗来类比“输液”这是一个固定的、标准的流程。就像模板方法中的算法骨架它定义了步骤准备输液工具、消毒、扎针、调节滴速、观察反应、拔针。这个流程是稳定的。“想你的夜”这是每次输液时具体输入的“药液”或“内容”。就像模板方法中那些可变的步骤。今晚输的是“回忆初次相遇的甜蜜”明晚可能是“思念共同经历的坎坷”但“输液”这个动作本身没变。在代码世界里这个“骨架”就是一个包含了多个步骤的方法即“模板方法”其中一些步骤是具体实现的另一些则是抽象的或通过钩子方法提供默认空实现留给子类去填充。2.2 模式的结构角色解析一个标准的模板方法模式通常涉及两个角色抽象类这是模式的灵魂。它负责定义算法的骨架即“模板方法”。这个方法通常被声明为final以防止子类重写整个算法结构。在这个骨架方法内部它会按顺序调用一系列其他方法。这些方法可以分为两类具体方法在抽象类中已经实现好的步骤是所有子类共用的。对应“输液”流程中那些固定的动作如“消毒”、“拔针”。抽象方法在抽象类中声明但不实现必须由子类去实现的步骤。对应“输的液”的具体内容。这是子类需要自定义的部分。钩子方法在抽象类中提供默认通常是空实现的方法。子类可以选择性地覆盖它以影响模板方法的行为。它提供了额外的扩展点比如在“调节滴速”前加一个“询问患者感受”的钩子子类可以决定是否启用这个询问。具体子类继承自抽象类。它的核心任务就是实现父类中定义的所有抽象方法也可能覆盖一些钩子方法。每个具体子类都提供了算法骨架中可变部分的一种具体实现从而使得整个算法表现出不同的行为。结构关系图文字描述 抽象类定义了一个 final 的templateMethod()。在这个方法内部依次调用了concreteStepA(),abstractStepB(),hookStepC(),concreteStepD()。其中abstractStepB()是抽象的hookStepC()有默认空实现。具体子类继承抽象类必须实现abstractStepB()并可以选择性地覆盖hookStepC()。当客户端调用子类实例的templateMethod()时实际上执行的是父类定义的固定流程但其中关键的变化点由子类提供。注意模板方法模式与“策略模式”有时容易被混淆。策略模式是将整个算法策略完全封装成独立对象通过组合来切换侧重的是算法的完全替换。而模板方法模式是基于继承通过子类来改变算法的部分步骤侧重的是算法骨架的复用和步骤的定制。简单说策略模式是“换整个剧本”模板方法是“在同一个剧本框架下换演员的某段台词”。3. 实战场景从生活到代码的映射理解了抽象概念我们来看几个具体的、你可能马上就能用上的场景。3.1 场景一数据报表生成器假设我们需要为不同部门销售部、财务部生成周报。报告的格式是固定的1. 生成标题2. 生成表头3. 填充数据主体4. 生成汇总统计5. 添加页脚。抽象类ReportGenerator:final generateReport(): 模板方法依次调用以下步骤。generateTitle(): 具体方法生成“XX部门周报”的通用标题格式。generateHeader(): 具体方法生成包含“日期”、“报告人”等通用表头。abstract fetchData(): 抽象方法获取部门特定的数据。销售部需要查销售订单财务部需要查流水凭证。formatDataBody(): 具体方法将获取到的数据按照统一的表格格式进行排版。abstract calculateSummary(): 抽象方法计算部门特定的汇总。销售部计算总额、环比财务部计算收支平衡、税费。generateFooter(): 具体方法生成公司统一的版权页脚信息。具体子类SalesReportGenerator:实现fetchData()连接销售数据库执行SELECT * FROM orders WHERE week ?。实现calculateSummary()对查询到的订单数据求和计算与上周的增长率。具体子类FinanceReportGenerator:实现fetchData()连接财务系统API获取指定周期的凭证列表。实现calculateSummary()计算总收入、总支出、净利润。这样我们只需要维护一个报告生成的骨架新的部门需要报告时只需继承ReportGenerator实现两个抽象方法即可极大减少了重复代码。3.2 场景二构建工具与生命周期前端开发中webpack、Vite等构建工具或者像React的类组件生命周期都蕴含着模板方法的思想。以模拟一个简单的构建流程为例抽象类BuildProcess:final run(): 模板方法。initialize(): 具体方法初始化配置和环境。abstract compile(): 抽象方法执行编译。对于TypeScript项目可能是tsc对于Sass项目可能是sass。optimize(): 钩子方法默认空实现。用于代码压缩、混淆等优化子类可选择覆盖。bundle(): 具体方法使用如rollup或webpack进行打包假设打包工具固定。output(): 具体方法将打包结果输出到dist目录。具体子类TypeScriptBuild:实现compile(): 执行tsc --project tsconfig.json。覆盖optimize(): 调用terser进行JavaScript压缩。具体子类SassBuild:实现compile(): 执行sass src/:lib/。不覆盖optimize()因为CSS压缩可能用另外的流程或者本次构建不需要优化。3.3 场景三业务审批流程OA系统中不同的请假审批流程年假、病假、婚假可能步骤相似1. 申请人提交2. 直属上级审批3. 可能部门负责人审批4. HR备案5. 结束。但第3步“部门负责人审批”对于短时间年假可能不需要对于长假则必须。抽象类LeaveApprovalFlow:final process(): 模板方法。apply(): 具体方法创建申请单。directSupervisorApprove(): 具体方法直属上级审批逻辑。hook departmentHeadApprove(): 钩子方法默认返回true表示跳过此步骤。对于需要此步骤的假期类型子类可以覆盖此方法加入实际的审批逻辑。hrRecord(): 具体方法HR系统入档。具体子类AnnualLeaveFlow:覆盖departmentHeadApprove(): 如果请假天数 5天则执行部门负责人审批逻辑否则调用super.departmentHeadApprove()直接跳过。具体子类SickLeaveFlow:不覆盖departmentHeadApprove因为病假通常只需直属上级和HR知晓。通过钩子方法模板方法模式提供了更精细的控制能力允许子类“反向”影响父类算法骨架的执行流。4. 代码实现与关键细节剖析我们用一个更贴近日常开发的例子——制作不同饮品的流程——来编写示例代码。假设我们需要实现制作咖啡和茶的流程。4.1 抽象类的定义制定饮料制作“宪法”/** * 抽象类饮料制作器 * 定义了制作饮料的模板方法骨架。 */ public abstract class BeverageMaker { /** * 模板方法制作饮料的固定流程。 * 声明为 final防止子类篡改算法骨架。 */ public final void makeBeverage() { boilWater(); // 步骤1烧水固定 brew(); // 步骤2冲泡抽象由子类实现 pourInCup(); // 步骤3倒入杯子固定 // 钩子方法控制是否添加调料 if (customerWantsCondiments()) { addCondiments(); // 步骤4添加调料抽象由子类实现 } finalTouch(); // 步骤5最终点缀钩子可选 } // 具体方法烧水 private void boilWater() { System.out.println(将水烧至沸腾95-100°C...); } // 具体方法倒入杯子 private void pourInCup() { System.out.println(将饮料倒入精致的陶瓷杯中...); } // 抽象方法冲泡 - 子类必须实现 protected abstract void brew(); // 抽象方法添加调料 - 子类必须实现 protected abstract void addCondiments(); // 钩子方法顾客是否要调料 - 子类可选覆盖默认返回true protected boolean customerWantsCondiments() { return true; } // 钩子方法最终点缀 - 子类可选覆盖默认空实现 protected void finalTouch() { // 默认什么都不做 } }关键点解析makeBeverage()被声明为final。这是模板方法模式的标志性设计确保了算法骨架的稳定性和权威性任何子类都无法改变“烧水-冲泡-倒杯-可能加料”这个核心顺序。boilWater()和pourInCup()是private具体方法。这表示这些是通用的、不可变的步骤子类既不需要也不应该改变它们。brew()和addCondiments()是protected abstract方法。它们是算法中可变的部分强制子类提供自己的实现。customerWantsCondiments()是一个典型的钩子方法。它提供了一个默认行为返回true但允许子类通过覆盖它来改变模板方法的执行路径例如制作黑咖啡时就不加糖和奶。finalTouch()是另一个钩子提供额外的扩展点比如加片柠檬或拉个花。4.2 具体子类的实现冲泡千滋百味/** * 具体子类咖啡制作器 */ public class CoffeeMaker extends BeverageMaker { Override protected void brew() { System.out.println(用92°C热水冲泡研磨咖啡粉浸泡30秒...); } Override protected void addCondiments() { System.out.println(加入两勺糖和30ml全脂牛奶...); } // 覆盖钩子方法提供额外的点缀 Override protected void finalTouch() { System.out.println(在咖啡表面撒上少许肉桂粉。); } } /** * 具体子类茶制作器 */ public class TeaMaker extends BeverageMaker { Override protected void brew() { System.out.println(用85°C热水浸泡红茶包时长2分钟...); } Override protected void addCondiments() { System.out.println(加入一片柠檬和一小勺蜂蜜...); } // 覆盖钩子方法询问顾客是否要加柠檬模拟 Override protected boolean customerWantsCondiments() { // 这里可以连接一个用户输入或配置 // 为了示例我们模拟一个随机决定 String answer getUserInput(); // 假设这个方法获取用户输入 return answer.toLowerCase().startsWith(y); } private String getUserInput() { // 模拟逻辑实际可能来自GUI、控制台或配置 return Math.random() 0.5 ? yes : no; } }4.3 客户端的调用享受模板的便利public class BeverageShop { public static void main(String[] args) { System.out.println( 制作一杯咖啡 ); BeverageMaker coffeeMaker new CoffeeMaker(); coffeeMaker.makeBeverage(); // 调用最终的模板方法 System.out.println(\n 制作一杯茶 ); BeverageMaker teaMaker new TeaMaker(); teaMaker.makeBeverage(); // 同样的调用不同的表现 } }输出结果可能如下 制作一杯咖啡 将水烧至沸腾95-100°C... 用92°C热水冲泡研磨咖啡粉浸泡30秒... 将饮料倒入精致的陶瓷杯中... 加入两勺糖和30ml全脂牛奶... 在咖啡表面撒上少许肉桂粉。 制作一杯茶 将水烧至沸腾95-100°C... 用85°C热水浸泡红茶包时长2分钟... 将饮料倒入精致的陶瓷杯中... 如果用户输入‘yes’加入一片柠檬和一小勺蜂蜜...实操心得在定义抽象类时要仔细区分哪些步骤是真正稳定的设为具体方法哪些是必然变化的设为抽象方法哪些是可能变化的设为钩子方法。一个常见的错误是把所有方法都设为抽象这会导致子类实现负担过重失去了模板方法统一管理流程的优势。反之如果把变化的步骤也写成具体方法则会导致子类不得不通过重写来修改父类行为违反了里氏替换原则代码也会变得脆弱。5. 模式的优势、代价与适用边界5.1 为什么选择模板方法模式强大的复用性这是最核心的优势。将公共的、不变的代码提升到父类避免了子类中的代码重复。就像“输液”流程只需定义一次所有“夜”都可以复用。良好的扩展性要增加一种新的算法变体只需创建一个新的子类并实现少量的抽象方法即可符合“开闭原则”对扩展开放对修改封闭。反向控制好莱坞原则“别打电话给我们我们会打给你。”父类控制着整个算法的流程子类只需要提供某些细节的实现。这降低了子类与父类之间的耦合度。便于维护算法的公共部分在父类中集中维护当公共逻辑需要修改时只需改动一处所有子类都会生效。规范化流程它强制所有子类都遵循相同的算法骨架保证了行为的一致性对于需要严格流程控制的业务场景如审批、报表非常有用。5.2 需要付出的代价与注意事项继承的固有限制模板方法模式基于继承。在Java等单继承语言中这意味子类无法再继承其他类限制了其灵活性。如果组合可以解决问题应优先考虑组合如策略模式。骨架的僵化性final的模板方法确保了骨架稳定但也意味着一旦设计好算法的大结构就很难改变。如果未来需要大幅调整步骤顺序可能需要重构整个抽象类。子类可能受到父类约束即使通过钩子方法提供了一些灵活性子类的行为仍然被限制在父类定义的框架内。如果子类需要的行为与父类骨架差异巨大使用模板方法模式会非常别扭。增加系统复杂度每多一种变体就需要创建一个新的子类。对于只有细微差别的场景可能会导致类的数量膨胀。5.3 何时使用何时避免适用场景多个类有相同的方法且逻辑大部分相同当你在多个地方看到几乎相同的算法只是其中一两行代码不同时就该考虑提取模板方法了。需要严格控制算法执行顺序当算法的步骤顺序至关重要且你希望确保所有实现都遵循这一顺序时。框架设计框架通常定义程序的主流程而将具体的业务实现留给框架使用者。模板方法模式是构建框架的经典手段。重构以消除重复代码在代码审查或重构时发现重复的流程代码可以运用此模式进行抽象。避免使用的场景算法骨架不稳定经常需要变化如果步骤顺序或核心步骤频繁调整模板方法的维护成本会很高。子类与父类差异过大如果每个子类都需要重写父类的大部分方法说明这个抽象可能不合理。语言不支持继承或继承代价高在某些场景或语言中过度使用继承可能不是最佳选择。需要更动态的组合行为如果需要在运行时灵活地组合不同的行为步骤策略模式或责任链模式可能更合适。6. 常见“坑点”与最佳实践指南6.1 实践中容易踩的坑滥用继承忽视组合看到行为相似就本能地用继承和模板方法但有时用策略模式组合会更灵活。判断标准如果变化的是一整套可互换的算法用策略如果变化的是算法中的某些步骤而骨架稳定用模板方法。过度设计过早抽象在只有一两个类似类且未来扩展性不明朗时就急急忙忙引入抽象类和模板方法反而增加了不必要的复杂度。遵循“三次原则”当第三次出现类似代码时再进行抽象。钩子方法过多或过少钩子方法提供了灵活性但太多会使模板方法逻辑复杂难懂子类实现者困惑太少又可能无法满足合理的扩展需求。设计时应仔细评估哪些点是真正可能变化的为其提供钩子。子类破坏模板方法约束虽然模板方法是final的但子类仍可能通过重写其他具体方法来间接破坏流程。应将不希望子类修改的具体方法设为private或final只暴露需要子类实现的抽象方法和可选的钩子方法。忽视命名清晰性抽象方法和钩子方法的命名应能清晰表达其意图和调用时机。例如doRender()就不如renderContent()清晰。钩子方法常以doXxx、willXxx、shouldXxx、onXxx等前缀开头表示其生命周期或判断属性。6.2 最佳实践与进阶技巧最小化抽象方法抽象方法越多子类的实现负担越重。尽量将稳定的逻辑收归为具体方法只将真正变化的部分抽象出来。提供合理的默认实现对于钩子方法提供一个安全、合理的默认实现通常是空操作或返回true/false可以减少子类的工作量并明确该钩子的默认行为。使用访问控制符强化约束public: 模板方法本身供客户端调用。protected: 抽象方法和钩子方法允许子类访问和覆盖。private: 固定的具体步骤禁止子类访问或修改。final: 模板方法和那些不允许子类更改的具体方法。结合其他模式模板方法模式常与其他模式联用。工厂方法模式模板方法中的某个抽象步骤常常就是一个工厂方法调用用于创建子类所需的对象。策略模式可以将模板方法中某个复杂的可变步骤委托给一个策略对象来完成从而在保持骨架稳定的同时获得策略模式的动态组合能力。单元测试的考量测试模板方法类时可以创建一个用于测试的“存根”子类实现所有抽象方法。重点测试模板方法的执行流程以及钩子方法被覆盖时的影响。一个来自实际项目的教训我曾参与一个电商订单处理系统最初为“普通订单”、“团购订单”、“秒杀订单”分别写了处理流程代码重复率很高。后来我们重构为模板方法模式抽象出OrderProcessor将validateStock()验库存、calculatePrice()算价格、deductInventory()扣库存、notifyUser()通知用户等步骤定义在骨架中。其中calculatePrice()是抽象的因为折扣规则不同notifyUser()是钩子秒杀订单成功后需要额外推送短信。重构后代码量减少了40%新增一种“预售订单”类型也只需两天开发。但我们也曾犯过一个错误把createPayment()创建支付单也做成了具体方法后来接入新的支付渠道时发现流程有差异不得不将这个步骤改为受保护的钩子方法并提供了默认实现。这提醒我们对“可能变化”的判断需要一定的前瞻性。模板方法模式就像一位经验丰富的导演为每一出戏子类搭建好了标准的舞台和流程模板而把最精彩的、个性化的表演抽象方法实现留给演员自己发挥。它用约束换来秩序用抽象换来复用。下次当你发现代码在重复“输液”而每次“输的液”却不同时不妨想想是否可以用模板方法这把“手术刀”来一次优雅的重构。