ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模板方法模式实战:从代码复用到框架设计的进阶指南

模板方法模式实战:从代码复用到框架设计的进阶指南 1. 从一次代码评审说起为什么模板方法模式值得单独拎出来讲前阵子帮一个朋友看他的数据处理项目代码里有两个类一个负责处理CSV格式的订单数据一个负责处理JSON格式的订单数据。两个类各自有三百多行我打开一看前一百五十行几乎一模一样——都是读取数据源、校验字段完整性、做数据清洗、计算汇总指标最后输出报表。唯一的区别在于读取和解析那一段以及输出格式的组装方式。我问他为什么不把公共部分抽出来他说“抽过但抽完之后发现子类还是要覆写好多方法改起来更乱。”这个回答其实暴露了一个非常典型的问题很多人知道要复用代码但不知道怎么复用才是对的。继承不是把公共代码往父类一扔就完事了你得设计好哪些是固定的骨架哪些是允许变化的扩展点。这就是模板方法模式要解决的核心问题。模板方法模式属于行为型设计模式它的思路非常朴素在一个抽象类中定义一个算法的骨架把某些步骤的具体实现延迟到子类中。子类可以在不改变算法结构的前提下重新定义算法中的某些特定步骤。说白了就是“流程我来定细节你来填”。这个模式在实际工程中出现的频率极高只是很多人用了却没意识到自己用的是模板方法。比如Java里InputStream的read()方法、Spring框架里JdbcTemplate的执行流程、JUnit里测试方法的生命周期管理背后都是模板方法模式在支撑。它不是什么高深的理论但要用好、用对里面有不少门道值得掰开揉碎讲清楚。这篇文章会从实际编码场景出发把模板方法模式的骨架结构、钩子方法的妙用、与策略模式的取舍、以及真实项目中的踩坑经验都过一遍。不管你是刚接触设计模式的新手还是写过几年代码但没系统梳理过的开发者应该都能从中找到对自己有用的东西。2. 拆开模板方法的骨架抽象类里到底该放什么2.1 模板方法的四个核心角色模板方法模式的类结构不复杂但每个角色的职责边界需要想清楚。我用一个实际的数据导出场景来演示——假设我们要实现一个报表导出功能支持导出为不同格式但导出的流程是固定的。先看抽象类中定义的模板方法public abstract class DataExporter { // 模板方法定义算法骨架用final修饰防止子类篡改流程 public final void export(String source) { validateSource(source); ListRecord records loadData(source); ListRecord cleaned cleanData(records); String formatted format(cleaned); writeOutput(formatted); afterExport(); } // 抽象方法子类必须实现的变化点 protected abstract ListRecord loadData(String source); protected abstract String format(ListRecord records); // 具体方法公共逻辑子类不需要也不应该覆写 private void validateSource(String source) { if (source null || source.isEmpty()) { throw new IllegalArgumentException(数据源不能为空); } } private ListRecord cleanData(ListRecord records) { return records.stream() .filter(r - r.isValid()) .collect(Collectors.toList()); } private void writeOutput(String content) { // 默认写到文件子类可通过钩子改变行为 Files.write(Paths.get(getOutputPath()), content.getBytes()); } // 钩子方法给子类一个选择权默认实现可以是空的 protected void afterExport() { // 默认什么都不做 } protected String getOutputPath() { return output/default.txt; } }这段代码里有四个关键角色抽象类承载了整个算法骨架它知道流程该怎么走但不知道某些步骤的具体细节。模板方法就是那个export方法它把步骤串起来通常用final修饰防止子类改变执行顺序。抽象方法是必须由子类实现的变化点比如loadData和format。具体方法和钩子方法则是父类提供的默认行为子类可以选择性地覆写。2.2 为什么模板方法要用final修饰这个问题我在面试别人的时候经常问。很多人回答说“防止子类修改”这个答案对但不够深。更深层的原因是模板方法定义的是算法的契约。调用方依赖的是这个契约——只要调用了export就保证会按顺序执行校验、加载、清洗、格式化、输出这一整套流程。如果子类能覆写模板方法那这个契约就不成立了调用方就无法信任这个流程。你可以把模板方法想象成一份合同的条款清单子类只能在空白处填写具体内容不能修改条款本身的顺序和结构。这是模板方法模式能够提供“框架复用”而非仅仅“代码复用”的关键所在。2.3 抽象方法与钩子方法的选择标准这是实际编码中最容易纠结的地方某个步骤到底应该定义成抽象方法还是钩子方法我的判断标准很简单如果这个步骤没有默认行为可提供子类必须给出实现那就用抽象方法如果这个步骤有合理的默认行为子类只在特定情况下才需要改变那就用钩子方法。举个例子在数据导出场景中“加载数据”这一步父类不可能知道数据从哪来——可能是数据库、可能是文件、可能是远程接口所以它必须是抽象方法。“导出后是否需要发送通知”这一步大多数情况下不需要只有少数场景才需要所以它适合做成钩子方法默认空实现。钩子方法还有一个常见的变体是返回布尔值的钩子用来控制流程分支protected boolean shouldCompress() { return false; } // 在模板方法中 if (shouldCompress()) { content compress(content); }这种设计让子类可以通过覆写一个简单的方法来影响算法的走向而不需要重写整个流程。在实际项目中这种“条件钩子”的使用频率非常高。3. 钩子方法的实战妙用让框架既有约束又有弹性3.1 钩子方法在开源框架中的典型应用如果你用过Spring的JdbcTemplate其实就已经接触过模板方法模式了。JdbcTemplate的execute方法定义了获取连接、创建Statement、执行SQL、处理结果、释放资源这一整套流程而具体怎么处理结果集则通过ResultSetExtractor或RowMapper接口交给调用方来实现。这里的RowMapper就相当于抽象方法而JdbcTemplate提供的一些默认回调则类似于钩子。再比如JUnit的测试生命周期setUp和tearDown就是典型的钩子方法。父类定义了测试执行的流程setUp → 执行测试方法 → tearDown子类可以选择性地覆写setUp和tearDown来插入自己的初始化和清理逻辑。如果你不覆写父类提供空实现流程照样跑通。3.2 用钩子方法实现可选步骤的优雅控制回到我那个朋友的报表导出项目我帮他重构的时候发现有一个需求是“某些报表导出后需要上传到指定的存储位置某些不需要”。他原来的做法是在每个子类里都写一遍上传逻辑然后通过if-else判断是否需要上传。用钩子方法改造之后是这样的public abstract class ReportExporter { public final void export(ReportRequest request) { ReportData data fetchData(request); String content render(data); saveToLocal(content); if (needUpload()) { uploadToRemote(content); } if (needNotify()) { sendNotification(request.getRecipient()); } } protected abstract ReportData fetchData(ReportRequest request); protected abstract String render(ReportData data); protected boolean needUpload() { return false; } protected boolean needNotify() { return false; } private void saveToLocal(String content) { /* ... */ } private void uploadToRemote(String content) { /* ... */ } private void sendNotification(String recipient) { /* ... */ } }子类只需要覆写needUpload返回true就自动获得了上传能力不需要关心上传的具体实现也不需要重复写调用逻辑。这种设计的好处是流程的控制权在父类但流程的开关在子类。父类保证了“如果要上传一定是在保存到本地之后”这个顺序不会乱。3.3 钩子方法的命名约定与可读性钩子方法的命名很重要好的命名能让子类开发者一眼看出这个方法的用途和调用时机。我总结了几种常见的命名模式命名模式含义示例beforeXxx在某个步骤之前调用beforeExportafterXxx在某个步骤之后调用afterExportshouldXxx返回布尔值控制流程分支shouldCompressgetXxxConfig提供配置参数getOutputPathonXxx事件回调onError遵循这些命名约定子类的开发者不需要翻看父类源码就能猜到方法的调用时机。这在团队协作中能省下大量沟通成本。注意钩子方法不要设计得太多。如果一个抽象类里有七八个钩子方法子类开发者会感到困惑——到底哪些需要覆写哪些不需要钩子方法控制在两到三个以内是比较合理的。4. 模板方法 vs 策略模式什么时候该用哪个4.1 一个容易被混淆的设计选择模板方法模式和策略模式经常被拿来比较因为两者都能实现“同一流程不同实现”的效果。但它们的实现机制和适用场景有本质区别。模板方法模式基于继承算法的骨架在编译期就确定了子类通过覆写方法来改变特定步骤的行为。策略模式基于组合算法的整体被封装成独立的策略对象运行时可以动态替换。用一个生活中的例子来类比模板方法就像做菜的菜谱菜谱规定了先切菜、再热锅、再炒菜、最后调味的流程你可以在“调味”这一步选择放酱油还是放醋但流程本身不会变。策略模式则像是你选择去川菜馆还是粤菜馆吃饭整个做菜的方式都不同但你可以在最后一刻决定去哪家。4.2 用代码对比两种方案的差异假设我们要实现一个订单处理流程不同类型的订单有不同的折扣计算方式和库存扣减方式。用模板方法模式实现public abstract class OrderProcessor { public final void process(Order order) { validate(order); double discount calculateDiscount(order); order.setFinalPrice(order.getPrice() - discount); deductStock(order); persist(order); notifyCustomer(order); } protected abstract double calculateDiscount(Order order); protected abstract void deductStock(Order order); private void validate(Order order) { /* 公共校验逻辑 */ } private void persist(Order order) { /* 公共持久化逻辑 */ } private void notifyCustomer(Order order) { /* 公共通知逻辑 */ } }用策略模式实现public interface DiscountStrategy { double calculate(Order order); } public interface StockStrategy { void deduct(Order order); } public class OrderService { private DiscountStrategy discountStrategy; private StockStrategy stockStrategy; public void process(Order order) { validate(order); double discount discountStrategy.calculate(order); order.setFinalPrice(order.getPrice() - discount); stockStrategy.deduct(order); persist(order); notifyCustomer(order); } // 可以通过setter在运行时替换策略 public void setDiscountStrategy(DiscountStrategy strategy) { this.discountStrategy strategy; } }4.3 选择决策表考量维度模板方法模式策略模式复用机制继承组合变化维度算法中的某些步骤整个算法运行时切换不支持编译期确定支持代码复用程度高公共流程在父类中公共流程需另抽类数量较少一个抽象类N个子类较多每个策略一个类适用场景流程固定、步骤可变算法整体需要灵活替换违反原则风险继承层次过深时违反组合优于继承策略类过多时管理复杂我的经验法则是如果流程是稳定的只有一两个步骤需要变化用模板方法如果整个算法都可能变化或者需要在运行时动态切换用策略模式。两者也可以结合使用——在模板方法的某个步骤中注入一个策略对象这样既有流程的约束又有步骤的灵活性。5. 真实项目中的踩坑记录与排查过程5.1 子类覆写导致流程被破坏这是我遇到过最典型的问题。在一个支付流程的抽象类中模板方法定义了“参数校验 → 风控检查 → 扣款 → 记录日志 → 发送通知”的流程。模板方法没有加final修饰结果有个同事在子类中直接覆写了整个模板方法把风控检查那一步给漏掉了。这个问题的排查过程很有意思。测试环境一切正常因为测试环境的子类没有覆写模板方法。上线之后某个特定支付渠道的订单全部绕过了风控导致了一批异常交易。排查的时候先看日志发现风控服务的调用量突然下降然后顺着调用链往上找才定位到那个子类的覆写。修复方案很简单——给模板方法加上final。但更重要的是建立了一个代码规范所有模板方法必须用final修饰代码评审时重点检查。5.2 抽象方法过多导致子类负担过重另一个项目里我设计了一个数据同步的抽象类定义了八个抽象方法。结果每个子类都要实现八个方法其中有些方法在大部分子类中都是空实现或者相同的简单实现。这导致子类代码大量重复而且新增一个子类的工作量很大。后来我做了重构把其中五个方法改成钩子方法提供默认实现把两个方法的实现上移到父类通过组合的方式注入差异化的行为只保留一个真正需要子类实现的核心方法。重构之后新增一个子类只需要实现一个方法代码量从平均两百多行降到了五十行左右。这个教训让我明白抽象方法的数量应该尽可能少每增加一个抽象方法都是在给所有子类增加负担。能用钩子方法解决的就不要用抽象方法能用组合解决的就不要用继承。5.3 继承层次过深带来的维护噩梦有一次接手一个老项目发现有一个抽象类被继承了四层。最顶层的抽象类定义了模板方法第二层覆写了两个钩子第三层又覆写了一个第四层才是真正干活的子类。要理解一个方法的最终行为得把四层的代码都翻一遍。这种继承层次过深的问题本质上是把模板方法模式当成了万能药什么变化都往继承体系里塞。我的建议是模板方法的继承层次不要超过两层。如果发现需要三层甚至四层继承说明设计出了问题应该考虑用组合来替代部分继承关系。排查这类问题有个实用技巧在IDE里用“Type Hierarchy”视图把整个继承树展开看看每个类覆写了哪些方法。如果发现某个方法的覆写链超过两层那就是需要重构的信号。6. 从代码复用走向框架设计模板方法的进阶思路6.1 用模板方法定义扩展点模板方法模式真正强大的地方不是帮你省几行重复代码而是让你能够设计出可扩展的框架。当你把模板方法用好了你写的就不再是一个类而是一个框架——别人可以在这个框架的基础上快速构建自己的实现而不需要理解框架内部的复杂逻辑。举个实际的例子。我在一个项目中需要处理多种格式的配置文件解析有XML、JSON、YAML、Properties等。如果用模板方法模式来设计抽象类定义解析流程读取文件 → 识别格式 → 解析内容 → 校验必填字段 → 构建配置对象 → 注册监听器。子类只需要实现“解析内容”和“校验必填字段”两个方法。这样一来新增一种配置格式只需要写一个子类实现两个方法整个解析流程自动获得。而且因为流程是父类控制的所有格式的解析都保证了相同的校验和注册逻辑不会出现某种格式漏了校验的情况。6.2 模板方法与回调接口的结合模板方法模式的一个进阶用法是把变化点设计成回调接口而不是抽象方法。这样做的好处是可以用Lambda表达式或者方法引用来提供实现减少类的数量。public class DataPipeline { public final void execute(String source, FunctionString, ListRecord loader, FunctionListRecord, String formatter) { ListRecord records loader.apply(source); ListRecord cleaned cleanData(records); String output formatter.apply(cleaned); writeOutput(output); } private ListRecord cleanData(ListRecord records) { /* ... */ } private void writeOutput(String content) { /* ... */ } }这种写法在Java 8之后越来越常见。它保留了模板方法“流程固定、步骤可变”的核心思想但用组合替代了继承避免了继承体系的膨胀。当然代价是失去了通过覆写钩子方法来微调流程的能力。具体用哪种方式取决于你的变化点是否需要访问父类的内部状态以及是否需要多个钩子方法协同工作。6.3 模板方法在微服务编排中的应用在微服务架构中一个常见的需求是编排多个服务的调用流程。比如下单流程需要调用库存服务、订单服务、支付服务、通知服务。这些服务的调用顺序是固定的但每个服务的具体调用参数和降级策略可能不同。用模板方法模式可以这样设计public abstract class OrderFlowTemplate { public final OrderResult execute(OrderRequest request) { preCheck(request); lockStock(request); createOrder(request); PaymentResult payment pay(request); if (payment.isSuccess()) { confirmStock(request); sendNotification(request); } else { releaseStock(request); } return buildResult(request, payment); } protected abstract void lockStock(OrderRequest request); protected abstract void createOrder(OrderRequest request); protected abstract PaymentResult pay(OrderRequest request); protected abstract void confirmStock(OrderRequest request); protected abstract void releaseStock(OrderRequest request); protected void preCheck(OrderRequest request) { /* 默认校验 */ } protected void sendNotification(OrderRequest request) { /* 默认通知 */ } protected OrderResult buildResult(OrderRequest request, PaymentResult payment) { return new OrderResult(request.getOrderId(), payment.isSuccess()); } }不同的业务线可以继承这个模板实现自己的库存锁定、订单创建和支付逻辑但整体的下单流程——先锁库存、再创建订单、再支付、支付成功确认库存、支付失败释放库存——这个骨架是统一的。这样做的好处是流程的正确性由父类保证业务线只需要关注自己的业务逻辑。提示在微服务编排中使用模板方法时要特别注意异常处理。模板方法中应该定义好每个步骤失败时的回滚逻辑子类实现具体步骤时只需要抛出异常由父类统一处理回滚。7. 我在实际项目中的几点体会模板方法模式是我在日常编码中使用频率最高的设计模式之一没有之一。它的概念简单但要用好需要不少实践经验。我最大的体会是模板方法模式的价值不在于消除重复代码而在于定义流程契约。当你需要保证某个流程在所有实现中都按照相同的顺序执行、都经过相同的校验和清理步骤时模板方法就是最自然的选择。另一个体会是关于抽象方法数量的控制。我现在的习惯是设计抽象类时先问自己这个步骤在大多数子类中是否有相同的实现如果有就做成具体方法如果大多数子类不需要改变这个步骤就做成钩子方法只有当子类必须提供实现且没有合理的默认值时才做成抽象方法。按照这个标准一个模板方法类通常只需要一到两个抽象方法。还有一点关于测试的经验。模板方法模式的测试需要分两层父类的模板方法测试和子类的具体实现测试。父类的测试可以用一个测试用的子类来验证流程的正确性比如验证步骤的执行顺序、验证钩子方法的调用时机。子类的测试则聚焦在具体实现的正确性上。这样分层测试既能保证流程的稳定性又能保证具体实现的正确性。最后说一个容易被忽略的细节模板方法中的异常处理策略。父类应该定义好哪些异常需要捕获、哪些需要向上传播、哪些需要转换成统一的异常类型。如果让每个子类自己决定异常处理方式整个流程的健壮性就无法保证。我的做法是在模板方法中统一捕获子类抛出的异常根据异常类型决定是回滚、重试还是直接失败子类只需要抛出异常即可不需要关心异常的处理逻辑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进