ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

模板方法模式精讲:从泡咖啡到订单重构,一次看懂骨架与钩子

模板方法模式精讲:从泡咖啡到订单重构,一次看懂骨架与钩子

1. 从一壶手冲咖啡说开去:模板方法模式到底解决了什么

整理 Design Pattern 学习笔记的时候,我给自己留了一个叫 Demllie Design Pattern 的系列,专门记录那些"看一遍觉得懂了,动手写才发现没懂"的模式。模板方法模式(Template Method)是我在这个系列里最早深入的一个,起因很朴素:我接手的某个项目里,订单同步、报表生成、消息推送三个模块各写了一套流程,流程的头尾几乎一样,只有中间几步不同,三份代码加起来一千多行重复逻辑。改一个参数要同步改三个地方,漏改一次就出一回线上问题。

那段时间我一直在想,如果能把"流程骨架"固定下来,只让每个场景填自己不一样的那几步,是不是就能少写很多重复代码?后来重新翻设计模式,发现模板方法模式早把这件事讲透了:父类定义算法的骨架和顺序,子类只负责实现那些会变化的步骤。这种思路在生活里随处可见——同样是"冲泡饮品"这件事,泡咖啡和泡茶的前半段几乎一模一样:烧水、放原料、冲水、倒杯、加料,区别只在"原料"和"加什么料"。

Java 的类库、服务端框架、测试框架里都能看到它的影子。但很多人学这个模式有个误区:以为它就是"继承 + 抽象方法",于是到处建抽象类,结果把继承树搞得又深又脆。这篇笔记我会从问题场景、结构拆解、完整代码、边界对比、实战重构五个角度讲清楚,它什么时候是良药,什么时候会变成毒药。

适合谁看?如果你已经写过一段时间的面向对象代码,能理解继承和多态,但还没系统看过设计模式;或者你看过概念但不知道代码该怎么组织、钩子方法到底有什么用——这篇内容就是为你准备的。我会把所有步骤拆到能直接照着写。

1.1 泡咖啡和泡茶,两段流程为什么长得这么像

先看一个经典例子。我们用程序去描述泡咖啡和泡茶,最容易想到的写法是写两个独立的方法:

  • 泡咖啡:烧水 -> 用沸水冲泡咖啡粉 -> 把咖啡倒进杯子 -> 加糖加奶
  • 泡茶:烧水 -> 用沸水浸泡茶叶 -> 把茶倒进杯子 -> 加柠檬

两个方法的第一步和第三步都是完全相同的逻辑。如果直接把代码复制两份,第一版跑得通,等产品经理说"烧水之前要加一道水质检测",你就得在两个方法里各改一遍。再往后加一个"泡可可"、"泡奶茶",每加一种饮品,公共逻辑就多一份拷贝。

这不是代码洁癖问题,是维护成本问题。重复出现三次以上的流程性代码,意味着你缺了一层抽象。模板方法模式的思路就是:把"烧水 -> 冲泡 -> 倒杯 -> 加料"这个流程顺序固定下来,作为骨架;把"冲泡什么原料"和"加什么料"这两步开放给子类。

1.2 所谓模板,是先把"步骤"定死,再把"细节"放开

模板方法模式的定义很简短:在一个方法中定义算法的骨架,把某些步骤延迟到子类实现。关键是分清哪些东西该被固定,哪些东西该被放开。

固定的东西是"步骤顺序"和"公共行为"。比如所有饮品都要烧水、都要倒进杯子,这些行为父类直接写好,子类不许改。放开的东西是"可变步骤"。咖啡冲咖啡粉,茶泡茶叶,加料也不同,这些就定义成抽象方法,让子类各自填。

这种设计的最大好处是"复用骨架"。父类的方法调用子类的方法,看起来很平常,但它翻转了控制方向——不是子类调父类,而是父类在某个时机主动调用子类的实现。这个思想后来衍生出一整类框架设计,几乎所有依赖倒置、控制反转的思路都能在这里找到影子。

2. 骨架、钩子和好莱坞规则:模板方法的结构拆解

前面说的是直觉,这一节我们把它落到代码结构上。模板方法模式有三个核心成员:抽象基类(AbstractClass)、具体子类(ConcreteClass)、以及基类里的模板方法(templateMethod)。理解这三者的分工,比记住类图重要得多。

2.1 三种方法的分工:终态方法、抽象方法、钩子方法

先看抽象基类里会出现哪些方法。它们按开放程度分成三类,这也是整个模式最容易混淆的地方:

方法类型在基类中的状态子类能不能改典型用途
具体方法(终态方法)已经有完整实现,通常用final修饰不能改公共步骤、模板方法本身
抽象方法只声明,不实现必须实现每个子类差异最大的步骤
钩子方法(hook)有默认实现,通常是空方法或返回默认值可选覆盖在特定位置给子类"插一脚"

模板方法本身往往就是一个final方法。它内部按固定顺序调用其他方法,包括抽象方法和钩子方法。因为模板方法是整个流程的总调度,如果允许子类重写它,骨架就散了,所以用final锁死。

这里重点说一下钩子方法。它是我觉得模板方法模式里最妙、也最容易被忽略的设计。钩子可以是一个空方法,也可以是一个返回布尔值的判断方法。基类在流程的某个位置调用它,默认什么都不做;子类如果关心这个位置,就覆盖它。

举个例子,某订单流程里有一段"是否需要额外审计"的判断。普通订单不需要,大额会员订单需要。这个判断就可以设计成钩子方法:

// 基类里默认不审计 protected boolean needExtraAudit(Order order) { return false; } // 子类按业务规则覆盖 @Override protected boolean needExtraAudit(Order order) { return order.getAmount() > 5000; }

钩子的价值在于:它让子类可以精准地在流程的某个缝隙里插入逻辑,又不需要改动流程顺序。没有钩子,你要么把所有判断堆在基类里写成 if-else,要么子类重写整个模板方法,破坏骨架的复用。

2.2 好莱坞原则:别调用我们,我们会调用你

模板方法模式背后站着一条著名的设计原则:好莱坞原则(Hollywood Principle)——"别调用我们,我们会调用你"。翻译成代码语言就是:子类不要主动去调用父类的流程,父类的模板方法会在合适的时机调用子类的方法。

很多初学者写继承代码时习惯在子类里super.process()然后前后插入逻辑,这是反好莱坞原则的。模板方法模式下,子类只需要被动地提供"零件",由父类来决定零件的组装顺序和调用时机。这样做的好处是,你很难在子类中把流程顺序改乱——顺序被锁在父类的final方法里。

这一点也是模板方法模式和普通继承的重要区别。普通继承是为了复用字段和公共方法,子类可以随意调用父类方法;模板方法模式则是把"流程控制权"上交给父类,子类只剩下填空权。控制权的转移,才是这个模式真正的威力所在。

2.3 类结构怎么写:一个抽象类 + 一组具体子类

类结构其实很简单。抽象基类里包含:

  • 一个final的模板方法,规定全部执行步骤
  • 一个或多个抽象方法,必须由子类实现
  • 一个或多个钩子方法,子类按需覆盖
  • 若干个具体方法,存放所有子类共用的逻辑

具体子类只做一件事:实现或覆盖那些抽象方法和钩子方法,不触碰模板方法。当你发现自己需要在一个子类里重写模板方法本身时,就要警觉——要么你的骨架设计得不对,要么这个场景根本不适合用模板方法模式。

这一节先讲结构,下一节我用一个电商订单处理的完整案例,把这几类方法全部串起来。看着代码理解结构,比对着类图硬背快得多。

3. 用一份订单处理器验证理论:完整代码带你走一遍

理论说多了容易飘,直接看代码。我选一个电商场景:订单从下单到处理完成,需要经过校验、算折扣、扣库存、发通知四个固定步骤,某些订单还需要额外的人工审计。这是一个非常典型的"流程固定、步骤多变"的场景。

先设计骨架。订单处理的抽象基类如下:

public abstract class OrderProcessor { // 模板方法:定义订单处理的完整骨架 public final void processOrder(Order order) { validate(order); Discount discount = computeDiscount(order); reserveStock(order); notifyCustomer(order, discount); if (needExtraAudit(order)) { audit(order); } } // 抽象方法:每种订单类型都有自己的校验规则 protected abstract void validate(Order order); // 抽象方法:折扣算法差异很大,交给子类 protected abstract Discount computeDiscount(Order order); // 抽象方法:普通订单和会员订单扣库存逻辑不同 protected abstract void reserveStock(Order order); // 具体方法:发通知的逻辑几乎相同,直接实现在父类 protected void notifyCustomer(Order order, Discount discount) { // 组装通知文案、调用消息通道,所有订单共用 System.out.println("订单 " + order.getOrderNo() + " 已处理,折扣金额:" + discount.getAmount()); } // 钩子方法:默认不需要额外审计 protected boolean needExtraAudit(Order order) { return false; } // 钩子配套方法:真正执行审计 protected void audit(Order order) { // 默认空实现,子类按需覆盖 } }

这个基类里,processOrder就是传说中的模板方法。它是final,子类动不了。它固定了七个执行步骤:先校验,再算折扣,再扣库存,再通知,最后看情况审计。注意第6、7行——if (needExtraAudit(order))这个判断就是钩子的典型用法。基类里默认不需要审计,子类一旦覆盖这个方法,流程就会自动多出审计这一步。

3.1 两个子类:普通订单和会员订单

再来写两个具体子类。第一个是普通订单处理器:

public class NormalOrderProcessor extends OrderProcessor { @Override protected void validate(Order order) { if (order.getItems() == null || order.getItems().isEmpty()) { throw new IllegalArgumentException("订单没有商品明细"); } } @Override protected Discount computeDiscount(Order order) { return Discount.none(); } @Override protected void reserveStock(Order order) { // 普通订单:按商品明细逐项扣减库存 for (OrderItem item : order.getItems()) { StockService.deduct(item.getSkuId(), item.getQuantity()); } } }

第二个是会员订单处理器,它复用了基类的大部分流程,只改三处:校验规则增加会员等级判断,折扣变成会员折扣,送积分。另外,它覆写了钩子方法,让超过五千元的大额订单进入人工审计:

public class MemberOrderProcessor extends OrderProcessor { @Override protected void validate(Order order) { if (order.getMemberLevel() < 2) { throw new IllegalArgumentException("会员等级不足"); } } @Override protected Discount computeDiscount(Order order) { return Discount.percent(0.9); // 会员统一九折 } @Override protected void reserveStock(Order order) { // 会员订单扣库存后还额外加积分 for (OrderItem item : order.getItems()) { StockService.deduct(item.getSkuId(), item.getQuantity()); } MemberService.addPoints(order.getMemberId(), 100); } // 覆盖钩子:大额订单触发审计 @Override protected boolean needExtraAudit(Order order) { return order.getAmount() > 5000; } @Override protected void audit(Order order) { AuditService.submit(order.getOrderNo(), "大额会员订单人工复核"); } }

看明白了吗?两个子类没有一个重写processOrder,但最终执行的步骤序列完全不同:普通订单只有四步,会员订单在特定条件下会多出审计这一步。这就是模板方法的价值——子类通过实现抽象方法影响"每步怎么做",通过覆盖钩子方法影响"流程要不要多走一步"。

3.2 运行效果与三个容易忽略的细节

写个调用方看一眼效果:

OrderProcessor processor = new MemberOrderProcessor(); processor.processOrder(memberOrder);

控制台会按顺序输出校验通过、折扣计算、库存扣减、通知发送、审计提交这几个环节的操作。整个流程的顺序是基类定死的,子类想插队是不可能的。

跑通这段代码后,有三个细节值得单独拿出来强调。

第一,模板方法应该用final修饰。很多教材没强调这点,实战中尤其重要。如果子类能随意覆盖模板方法,骨架就不复存在,模式直接失效。一些团队规范里甚至会用代码检查工具强制模板方法是终态方法。

第二,抽象方法不要有默认实现。有时开发者图省事,给抽象方法写了个空实现,然后子类想实现就实现,不想实现就空着。这会让"哪些步骤必须提供"变得模糊,子类写起来没谱。抽象方法就该强制子类必须实现,把差异显式地暴露出来。

第三,钩子方法的打开姿势要克制。钩子越多,子类需要了解的流程细节就越多。我见过一个模板类里塞了七个钩子方法,子类开发人员打开基类的一瞬间就懵了。一般两到三个钩子就够用,钩子太多说明骨架的抽象粒度没找好,该拆出多个模板方法了。

4. 模板方法容易踩的三道边界:和策略模式、工厂方法、回调的关系

设计模式学到中后期,真正困扰人的不是单个模式怎么用,而是多个模式长得太像,不知道该选哪个。模板方法尤其容易和策略模式、工厂方法、回调机制混淆。这几道边界理不清,代码里就会出现"用模板方法的壳,装策略模式的心"之类的缝合怪。

4.1 模板方法 vs 策略模式:一个用继承,一个用组合

策略模式的意图是"把一族算法封装起来,让它们可以互相替换"——执行算法的对象持有一个策略接口,运行时可切换。模板方法模式则把算法骨架放在父类里,通过继承让子类扩展步骤。

选哪个?有一个很实用的判断标准:如果子类之间共享的是"步骤顺序"和"部分步骤实现",且变体数量在可枚举范围内,优先模板方法。如果算法本身是一个完整的、可能运行时切换的选择项,用策略模式。举个例子,折扣计算如果只是"九折、八折、满减"这些固定档位,定义一个策略接口或枚举策略即可;但如果整个订单处理流程有固定骨架、只有校验和扣库存两步不同,用模板方法更自然。

还有一层:继承会带来强耦合,子类一旦创建,它的父类骨架就很难大幅改动。所以模板方法适合"流程相对稳定"的领域。如果你预判业务流程会频繁调整步骤顺序,那不如把所有步骤拆成策略接口,用组合的方式编排。

4.2 模板方法 vs 工厂方法:一个管流程,一个管创建

工厂方法模式里,子类决定"创建什么对象";模板方法模式里,子类决定"步骤怎么做"。两者的共同点是都依赖父类定义骨架、子类填补细节,但关注点完全不同。

有时候它们会合作。比如模板方法里需要创建一个资源对象,这个对象的创建逻辑因订单类型而异,于是模板方法内部调用一个createResource()抽象方法,由子类实现——这一步本身就成了工厂方法。实践中经常出现"模板方法内部嵌套工厂方法"的组合用法,这不冲突,但要注意:一个模板方法里的抽象步骤不要超过三四个,否则子类实现的负担会过重。如果出现这种情况,可以考虑把其中一组相关步骤再抽成一个独立的策略接口,而不是全部压在一个继承体系里。

4.3 模板方法 vs 回调机制:同样都是好莱坞,长相不同

回调机制(Callback),比如接口回调、函数式编程里的Consumer、Runnable,思想内核和好莱坞原则一模一样:框架在合适的时机调用你传入的函数。很多框架文档里说的"扩展点""钩子",本质上就是回调。

区别在于表达方式。模板方法是把扩展点"内嵌"在继承体系里,子类通过覆盖方法来实现扩展;回调是把扩展逻辑作为参数传入,类之间是组合关系,更灵活,但可读性稍差——你要在调用现场看"传进去的函数会在哪个时机被调用",常常得翻框架源码。

我的使用习惯是:自己维护的、规模可控的业务流程,用模板方法,语义清晰;外部框架提供的扩展点,用回调,因为它不需要你继承框架类,侵入性低。模板方法也不是越纯越好,实战中两者常常混用,没必要为了模式纯洁性拒绝另一种写法。

4.4 什么情况下别急着用模板方法

模板方法不是万能药。以下三种信号出现时,我会提醒自己冷静一下。

第一,继承链即将超过两层。三层以上的继承会让任何一个改动都波及一大片子类,排查起来极其痛苦。第二,子类需要覆盖的方法过多,覆盖率超过一半的抽象方法,大概率是骨架设计不合理——你只是借了个壳,没借到真正公用的东西。第三,流程顺序本身不稳定。产品需求三天两头调整步骤顺序,模板方法里每次调顺序都要动基类,远不如把步骤拆成独立组件、用列表编排来得灵活。

这三个信号背后其实是同一个判断:模板方法的收益来自"固定性"——固定的部分越值钱,模式越有用;可变化的部分越多、变化越剧烈,模式的收益就越低。判断一个场景适不适合用,就先数数有哪些是稳定的、有哪些是易变的。

5. 实战重构建议:怎样识别"该用模板方法"的重复代码

最后聊点真正能落地的经验。前面说了一堆理论,但回到日常开发,最现实的问题永远是:我手头这些代码,到底哪些该重构成模板方法?我有一套自己的识别信号和操作步骤,这套方法帮我在两个项目里做了成功的重构,其中一个重构后删掉了四百多行重复代码。

5.1 三个识别信号:步骤顺序相同、中间步骤不同、整体流程固定

我判断一个场景是否适合模板方法,只看三个信号:

  1. 两个以上流程的"步骤顺序"完全相同,只是每步的内部实现不同
  2. 流程里有一部分步骤是多个流程共用的,复制粘贴过至少一次
  3. 流程的先后顺序在未来较长时间内不会大改

三选二基本就能上模板方法了。举个真实的例子:某系统里有月度报表、日度报表、自定义报表三种生成任务。每个任务的执行过程都是"取数 -> 清洗 -> 聚合 -> 导出 -> 归档",但取数的数据源不同、清洗规则不同、聚合维度不同;导出和归档的逻辑几乎完全一样。这三个报表模块每个都各自写了一套完整流程,导出和归档代码复制了三份。

你看,第一和第二两个信号都命中了。重构方案就是抽出ReportGenerator抽象基类,模板方法定义五个步骤,导出和归档作为具体方法放进基类,取数、清洗、聚合变成三个抽象方法,三个报表生成器各写各的实现。改完之后,新增一种报表只需要继承基类实现三个方法,不用再关心导出和归档怎么处理。

5.2 重构中的命名与注释经验:让钩子方法"看得懂"

模板方法模式写起来简单,但维护起来有个隐性问题:子类的开发者能看到的只是一个个方法,他们怎么知道每个方法会在流程的哪个时机被调用?这个问题靠代码本身很难表达清楚,所以要靠命名和注释。

我在基类里有一条硬性规范:模板方法上必须写清楚步骤顺序和每个步骤的意图;每个抽象方法、钩子方法都要在注释里标注"在模板方法的第几步被调用""默认行为是什么""子类覆盖后会影响什么"。比如钩子方法的标准注释是:

// 在 processOrder 第四步 notifyCustomer 之前调用 // 默认不审计;如需人工复核,请重写并返回 true,同时实现 audit protected boolean needExtraAudit(Order order) { return false; }

命名也有套路。钩子方法一般用need、should、skip这类词开头,让别人一眼看出它是个布尔钩子;配套的动作方法用动词开头,比如audit、doClean。这样的命名能让子类开发者不用打开整个基类源码就知道自己该覆写哪些方法。

5.3 测试策略:基类测流程,子类测差异

模板方法模式的测试策略和它整体设计一样分层。我在项目里约定两套测试:一套测基类的"流程编排",另一套测子类的"差异实现"。

基类测试用真实子类或测试替身,验证模板方法内部方法的调用顺序、次数和参数。比如用 Mock 的订单对象,跑一遍processOrder,断言validate在reserveStock之前被调用,notifyCustomer恰好被调用一次,钩子返回默认值时audit没有被调用。这能防止有人不小心改了模板方法的流程顺序而不自知。

子类测试则聚焦每个子类对抽象方法、钩子方法的实现是否符合预期。比如会员订单处理器在大额订单时返回needExtraAudit() == true,普通订单处理器永远返回false。这样分层下来,即使新增一个订单类型,也只需要补新子类的差异测试,基类测试完全不用动。

5.4 最后说句心里话:设计模式不是集邮

学模板方法模式最忌讳的是"为了用而用"。我有段时间看到重复代码就想着建抽象类,结果把一个只出现过两个变体的简单逻辑,拆成了四层继承树,后一个接手的人花了整整两天才理清。

后来我给自己定了一条规矩:至少出现两个真实重复的流程,并且能明确说出"固定步骤"是哪些,才引入模板方法。如果只是预判未来会重复,先忍住,等第二次出现再抽。这个模式真正的力量不是让你少写几个方法,而是强迫你思考:这段流程里,什么才是雷打不动的骨架?什么才是允许变化的血肉?想清楚了,代码会干净很多。

如果你读完这篇,正准备拿手头的代码试试手,我的建议是从一个"你已经复制粘贴过两次"的流程开始,先把公共步骤列出来,再标出每步的差异,最后决定要不要用模板方法收敛。整个过程不需要一次性做到完美,能让自己少改一处重复代码,就已经值了。

RELATED READING

延伸阅读

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