模板方法模式深度解析:从代码评审到实战应用
1. 从一次代码评审说起为什么模板方法模式值得单独拎出来讲前阵子帮一个朋友看他的数据处理项目代码里有两个类一个处理CSV格式的订单数据一个处理JSON格式的订单数据。两个类各自有三百多行我打开一看前八十行几乎一模一样——都是读取文件、校验字段、过滤无效记录、计算汇总指标只有中间解析具体格式的那几行不同。我问他为什么不把公共部分抽出来他说“抽过但抽到一半发现两个流程的步骤顺序不太一样就放弃了。”这个场景我见过太多次了。很多人对设计模式的理解停留在“知道有这么个东西”但真到用的时候要么觉得套不上去要么套上去之后代码反而更乱。模板方法模式就是其中最容易被低估的一个——它看起来简单到只有“父类定义骨架、子类填充细节”这一句话但真正用好它需要想清楚很多边界问题哪些步骤该固定、哪些该开放、钩子方法什么时候用、子类到底该覆写什么。这篇文章我会把模板方法模式从“是什么”到“怎么用”再到“什么时候别用”完整拆一遍。不管你是刚接触设计模式的新手还是写过几年代码但总觉得抽象类用得别扭的老手应该都能从中找到一些可以直接拿去用的东西。文章里会涉及大量代码示例语言以Java为主但思路对所有面向对象语言都通用。2. 模板方法模式的骨架到底长什么样2.1 一个最朴素的实现冲泡饮料的经典案例几乎所有讲模板方法的资料都会用“冲泡咖啡和茶”的例子虽然老套但它确实能把核心结构讲清楚。我们不用它讲概念而是用它来拆解结构。假设要做一个冲泡系统咖啡和茶的冲泡流程都是四步把水烧开、把主料放进杯子、倒入开水、加调料。其中“把主料放进杯子”和“加调料”两步咖啡和茶不一样其余两步完全一致。public abstract class Beverage { // 模板方法定义流程骨架用final防止子类篡改 public final void prepareRecipe() { boilWater(); brew(); pourInCup(); addCondiments(); // 钩子方法子类可选择是否挂钩 if (customerWantsCondiments()) { addCondiments(); } } // 公共步骤父类直接实现 private void boilWater() { System.out.println(把水烧开); } private void pourInCup() { System.out.println(把水倒入杯子); } // 抽象步骤子类必须实现 protected abstract void brew(); protected abstract void addCondiments(); // 钩子方法子类可覆写默认返回true protected boolean customerWantsCondiments() { return true; } }咖啡的实现public class Coffee extends Beverage { Override protected void brew() { System.out.println(用沸水冲泡咖啡粉); } Override protected void addCondiments() { System.out.println(加糖和牛奶); } }茶同理只是brew和addCondiments的内容不同。调用方只需要new Coffee().prepareRecipe()整个流程就会按照父类定义的顺序执行。这个结构里有四个关键角色我逐个拆开说。抽象类AbstractClass定义模板方法和若干抽象步骤。模板方法本身通常是final的这不是语法要求而是设计意图的表达——流程骨架不应该被子类改写否则模板方法模式就失去了意义。具体子类ConcreteClass实现父类留下的抽象步骤。子类不需要关心流程怎么走只需要关心“我这一步具体做什么”。模板方法Template Method那个final方法它把各个步骤按固定顺序编排起来。这是整个模式的灵魂也是“模板”二字的来源。钩子方法Hook Method父类提供默认实现、子类可选择覆写的方法。它让模板方法在固定骨架的同时保留了一定的弹性。上面例子里的customerWantsCondiments()就是一个典型钩子——默认加调料但子类可以改成不加。2.2 为什么抽象步骤用protected而不是public这是一个容易被忽略但很重要的细节。抽象步骤声明为protected意味着它们只对子类和同包可见外部调用者无法直接调用brew()或addCondiments()。这样做的好处是强制外部只能通过prepareRecipe()这个入口来使用保证了流程的完整性。如果你把抽象步骤声明为public调用方就可能绕过模板方法直接调brew()流程的约束就形同虚设了。我在实际项目中见过有人把抽象步骤写成public结果维护的时候发现有人直接调了中间步骤导致状态不一致排查了半天。2.3 模板方法和普通继承的区别在哪有人会问这不就是继承吗跟普通的抽象类有什么区别区别在于控制权的方向。普通继承中子类通常负责扩展父类的行为调用方直接调用子类的方法。而模板方法模式中控制权在父类手里——父类定义了“什么时候调用哪些步骤”子类只是被调用的对象。这种“父类调用子类”的机制在框架设计里有个专门的名字叫好莱坞原则Hollywood Principle“Dont call us, well call you.”这个反转很关键。它意味着流程的控制逻辑集中在父类一处子类只负责填充变化点。当流程需要调整时比如在冲泡之前加一步“清洗杯子”只需要改父类所有子类自动生效。3. 钩子方法模板方法模式里最容易被用错的部分3.1 钩子的本质是“可选的扩展点”钩子方法的设计意图是给子类提供一个“可以介入但不是必须介入”的扩展点。它和抽象方法的区别在于抽象方法是“你必须做”钩子是“你可以做也可以不做”。常见的钩子有三种用法。第一种是条件控制型上面customerWantsCondiments()就是这种。父类在模板方法里根据钩子的返回值决定是否执行某个步骤。第二种是空实现型父类给一个空的钩子方法子类需要的时候覆写它。比如protected void afterProcess() { // 默认什么都不做 }第三种是默认行为型父类给一个合理的默认实现子类觉得不合适再覆写。比如日志记录、资源清理这类操作。3.2 钩子用多了会怎样我见过一个项目父类的模板方法里塞了七八个钩子子类覆写了其中三四个代码读起来像在走迷宫——你看到process()方法得跳到每个子类去看哪些钩子被覆写了才能搞清楚实际执行路径。钩子是好东西但要有节制。我的经验是一个模板方法里的钩子不超过两个。超过两个说明这个流程的变化点太多可能不适合用模板方法模式应该考虑策略模式或者把流程拆成多个模板方法。3.3 钩子和抽象方法的选型判断什么时候用抽象方法什么时候用钩子判断标准很简单这个步骤是不是所有子类都必须有自己的实现如果每个子类的实现都不一样而且没有合理的默认行为用抽象方法。如果大部分子类的行为一致只有少数需要特殊处理用钩子。举个例子一个报表导出功能所有报表都需要“查询数据”和“格式化输出”但只有部分报表需要“发送邮件通知”。那“查询数据”和“格式化输出”用抽象方法“发送邮件通知”用钩子默认不发送。4. 把模板方法模式放进真实项目里三个场景的完整拆解4.1 场景一多数据源的数据同步任务假设你在做一个数据同步系统需要从MySQL、MongoDB、REST API三个数据源拉取数据经过清洗、转换后写入数据仓库。三个数据源的连接方式、查询语法完全不同但清洗、转换、写入的逻辑高度相似。用模板方法模式父类定义同步流程public abstract class DataSyncTask { public final SyncResult execute() { Connection conn null; try { conn openConnection(); ListRawRecord raw fetchData(conn); ListCleanRecord cleaned clean(raw); ListTransformedRecord transformed transform(cleaned); int count writeToWarehouse(transformed); afterSync(count); return new SyncResult(true, count); } catch (Exception e) { handleError(e); return new SyncResult(false, 0); } finally { closeConnection(conn); } } protected abstract Connection openConnection(); protected abstract ListRawRecord fetchData(Connection conn); // 清洗逻辑通用父类直接实现 protected ListCleanRecord clean(ListRawRecord raw) { return raw.stream() .filter(r - r.isValid()) .map(r - r.trim()) .collect(Collectors.toList()); } // 转换逻辑大部分通用子类可覆写 protected ListTransformedRecord transform(ListCleanRecord cleaned) { return cleaned.stream() .map(CleanRecord::toTransformed) .collect(Collectors.toList()); } // 写入逻辑通用 private int writeToWarehouse(ListTransformedRecord records) { // 批量写入逻辑 return records.size(); } // 钩子同步后的通知默认不做 protected void afterSync(int count) {} // 钩子错误处理默认记录日志 protected void handleError(Exception e) { log.error(同步失败, e); } protected abstract void closeConnection(Connection conn); }这个设计里clean和writeToWarehouse是固定的openConnection、fetchData、closeConnection是变化的transform和afterSync、handleError是钩子。MySQL子类只需要实现连接和查询public class MySQLSyncTask extends DataSyncTask { Override protected Connection openConnection() { return DriverManager.getConnection(mysqlUrl, user, password); } Override protected ListRawRecord fetchData(Connection conn) { // 执行SQL查询 } Override protected void closeConnection(Connection conn) { conn.close(); } }MongoDB子类同理只是连接和查询的实现不同。整个同步流程的骨架、异常处理、资源释放都在父类统一管理子类不需要关心。这个场景里有一个容易踩的坑资源释放的顺序。父类的finally块里调用closeConnection但如果openConnection本身抛异常了conn是nullcloseConnection(null)可能会NPE。所以closeConnection的实现里要判空或者父类在finally里加判断。这种细节在写模板方法的时候特别容易漏因为父类不知道子类的连接对象长什么样。4.2 场景二支付流程的标准化支付是一个典型的流程固定但渠道各异的场景。不管走哪个支付渠道流程都是校验订单、创建支付单、调用渠道接口、处理回调、更新订单状态。其中“调用渠道接口”和“处理回调”因渠道而异。public abstract class PaymentFlow { public final PaymentResult pay(PaymentRequest request) { // 1. 校验订单 validateOrder(request); // 2. 创建支付单 PaymentOrder order createPaymentOrder(request); // 3. 调用渠道抽象方法 ChannelResponse response invokeChannel(order); // 4. 处理结果 if (response.isSuccess()) { handleSuccess(order, response); } else { handleFailure(order, response); } // 5. 钩子后置处理 afterPayment(order, response); return buildResult(order, response); } private void validateOrder(PaymentRequest request) { if (request.getAmount() 0) { throw new IllegalArgumentException(金额必须大于0); } // 其他通用校验 } private PaymentOrder createPaymentOrder(PaymentRequest request) { // 创建订单的通用逻辑 } protected abstract ChannelResponse invokeChannel(PaymentOrder order); protected void handleSuccess(PaymentOrder order, ChannelResponse response) { order.setStatus(PaymentStatus.SUCCESS); order.setChannelTradeNo(response.getTradeNo()); } protected void handleFailure(PaymentOrder order, ChannelResponse response) { order.setStatus(PaymentStatus.FAILED); order.setFailReason(response.getFailReason()); } protected void afterPayment(PaymentOrder order, ChannelResponse response) {} private PaymentResult buildResult(PaymentOrder order, ChannelResponse response) { // 构建返回结果 } }这个设计的好处是新增一个支付渠道只需要写一个子类实现invokeChannel流程中的校验、订单创建、状态更新、结果构建全部复用。而且因为模板方法是final的不可能有人不小心改乱了支付流程的顺序。这里有个实际经验支付流程里的“处理回调”往往不是同步的。渠道返回成功不代表最终成功真正的结果要通过异步回调通知。这种情况下模板方法模式依然适用但需要把“处理回调”单独抽成一个模板方法而不是塞在pay()里面。也就是说一个类里可以有多个模板方法分别对应不同的流程阶段。4.3 场景三自动化测试用例的执行框架测试框架是模板方法模式的重度应用场景。一个测试用例的执行流程通常是setup准备环境、execute执行测试、assert断言结果、teardown清理环境。其中execute和assert因用例而异setup和teardown有通用逻辑也有特殊逻辑。public abstract class TestCase { public final TestResult run() { TestResult result new TestResult(); try { setUp(); result.startTimer(); execute(); result.stopTimer(); verify(); result.markPassed(); } catch (AssertionError e) { result.markFailed(e.getMessage()); } catch (Exception e) { result.markError(e); } finally { tearDown(); } return result; } protected void setUp() { // 默认空实现子类可覆写 } protected abstract void execute(); protected abstract void verify(); protected void tearDown() { // 默认空实现 } }这个设计里setUp和tearDown是钩子execute和verify是抽象方法。测试框架负责控制执行流程、计时、异常捕获、结果记录测试用例只负责写具体的测试逻辑。我在实际项目里用这个模式写过一套接口自动化测试框架大概两百多个测试用例每个用例只需要继承基类实现execute和verify代码量从平均每个用例八十多行降到了二十行左右。而且因为流程统一了后来加“失败重试”和“超时控制”功能时只改了父类一个地方所有用例自动生效。5. 模板方法模式和其他模式的边界在哪里5.1 和策略模式的对比继承 vs 组合策略模式和模板方法模式经常被拿来比较因为两者都能实现“流程固定、细节可变”的效果。核心区别在于模板方法用继承策略模式用组合。模板方法模式中变化的部分通过子类覆写实现编译期就确定了。策略模式中变化的部分封装成独立的策略对象运行期可以动态替换。举个例子一个文本格式化功能如果格式化的方式在编译期就确定比如这个类就是处理Markdown的用模板方法。如果需要在运行时根据用户选择切换格式化方式Markdown、HTML、纯文本用策略模式。我个人的选型经验是变化维度只有一个且变化在编译期确定用模板方法变化维度有多个或者需要运行时切换用策略模式。两者也可以结合使用——模板方法里的某个步骤通过策略模式来注入具体算法。5.2 和工厂方法模式的关系工厂方法模式经常作为模板方法模式中的一个步骤出现。比如父类的模板方法里有一个createProcessor()的抽象方法子类返回不同的处理器对象。这其实就是工厂方法模式在模板方法模式中的应用。两者解决的问题不同模板方法解决的是“流程编排”问题工厂方法解决的是“对象创建”问题。它们可以自然地组合在一起不冲突。5.3 什么时候不该用模板方法模式模板方法模式最大的问题是继承带来的耦合。子类和父类是强绑定的父类改了模板方法的流程所有子类都受影响。如果继承层次超过两层代码会变得很难维护。以下几种情况我建议不要用模板方法模式流程步骤经常变化如果流程本身不稳定今天三步明天五步那模板方法反而会成为负担。这种情况下用责任链模式或者管道模式更合适。子类数量很少且稳定如果只有两个子类而且未来不太可能增加直接写两个类可能比抽象一个父类更简单。需要运行时动态改变流程模板方法的流程在编译期就固定了运行时改不了。需要动态编排用工作流引擎或者组合模式。还有一个常见的误用为了用模式而用模式。有些人学了模板方法之后看到两个方法有相似之处就想抽个父类出来。但如果这两个方法的相似只是巧合未来可能各自演化那强行抽象反而会增加维护成本。抽象的前提是“变化的方向是一致的”而不是“现在看起来像”。6. 实战中踩过的坑和对应的处理方案6.1 父类构造方法里调用抽象方法这是一个Java层面的经典陷阱。如果父类的构造方法里调用了抽象方法子类覆写的版本会在子类字段初始化之前被执行导致子类字段还是默认值。public abstract class Base { public Base() { init(); // 危险 } protected abstract void init(); } public class Child extends Base { private String name default; Override protected void init() { System.out.println(name); // 输出null不是default } }模板方法模式里模板方法本身不应该在构造方法里调用。模板方法应该由外部调用者触发而不是在对象创建时自动执行。如果你确实需要在对象创建后执行某些初始化逻辑用一个单独的start()或execute()方法让调用方显式触发。6.2 抽象方法的粒度太细导致子类负担过重我见过一个模板方法定义了十二个抽象步骤每个子类都要实现十二个方法其中大部分方法的实现是空的或者只有一行。这种设计的问题在于抽象方法的数量应该和“真正有差异的步骤”数量匹配而不是和“流程中的步骤”数量匹配。如果一个步骤在所有子类中的实现都一样那它就不应该是抽象方法应该是父类的私有方法。如果一个步骤只有少数子类需要特殊处理那它应该是钩子而不是抽象方法。我的经验法则是抽象方法的数量控制在3到5个。超过5个说明这个流程的差异点太多可能需要拆分成多个模板方法或者考虑其他模式。6.3 子类覆写钩子方法时忘记调用super钩子方法如果有默认实现子类覆写时很容易忘记调用super.xxx()导致父类的默认行为丢失。比如父类的handleError默认会记录日志子类覆写后只做了自己的处理日志就没了。这个问题没有语法层面的解决方案只能靠代码规范和代码评审。一个可行的做法是在父类的钩子方法上加注释明确说明“覆写时请调用super”或者在团队规范里约定钩子方法的覆写必须调用super除非有明确理由不调用。6.4 模板方法的性能开销模板方法模式本身几乎没有性能开销——它只是方法调用JIT编译器对这类多态调用优化得很好。真正可能影响性能的是模板方法里包含的耗时操作比如IO、网络请求。这些操作不管用不用模板方法都会有开销和模式本身无关。唯一需要注意的是如果模板方法被频繁调用比如在循环里而其中某些步骤的开销较大可以考虑把不变的部分缓存起来。但这是性能优化问题不是模式问题。7. 从代码结构到设计思维模板方法模式真正教给我们的事模板方法模式表面上是一个关于继承的设计模式但它背后体现的是一种更通用的设计思维把不变的部分和变化的部分分离用稳定的骨架去容纳不稳定的细节。这种思维在很多地方都能用。比如写文档时先定大纲再填内容做项目管理时先定里程碑再排具体任务甚至做菜时先定流程再调整调料。模板方法模式只是把这种思维用代码的形式表达出来了。回到代码层面我觉得模板方法模式最大的价值不是“减少重复代码”——减少重复代码有很多种方式——而是把流程的控制权集中到一个地方。当流程需要调整时你只需要改一个地方所有子类自动跟随。这种“一处修改、处处生效”的能力在大型项目中非常宝贵。但它也有代价继承带来的耦合、抽象层次的维护成本、子类对父类的隐式依赖。所以用之前要想清楚这个流程是不是真的稳定变化点是不是真的只有这几个子类是不是真的共享同一个流程骨架想清楚这些问题模板方法模式会是一个很顺手的工具。想不清楚就往上套它也会变成一个很顺手的坑。