设计模式 15 · 模板方法模式

上一篇策略模式,是把"整个算法 "抽象成接口、整体替换。这一篇的模板方法模式(Template Method) ,思路正好互补:算法的大骨架是固定不变的,只有其中个别步骤会变------那就把骨架定死在父类里,只把那几个会变的步骤,留成抽象方法交给子类去填。

它是最"接地气"的行为型模式之一,也是最能体现"继承该怎么正确使用"的模式。生活里的类比很好懂:泡茶和泡咖啡,流程骨架是一样的------烧水、冲泡、倒进杯子、加调料;只有"冲泡什么"(茶叶还是咖啡粉)和"加什么调料"(柠檬还是糖)这两步不同。你完全可以写一个"泡饮料"的固定流程,把"冲泡"和"加料"这两步留给具体的茶或咖啡去实现。流程的骨架只写一次,可变的步骤各自实现------这就是模板方法。

我们的订单场景里有个天然的例子:下单流程。不管什么类型的订单,大骨架都是"校验 → 计算金额 → 扣减库存 → 保存订单 → 发送通知"这几步、这个顺序;但其中"如何校验""如何计算金额"这些步骤,普通订单、团购订单、秒杀订单各不相同。用模板方法,把这个固定骨架定在父类,把会变的步骤交给子类------既保证了流程不被写乱,又允许各类订单定制自己的细节。

这篇文章按这条线索展开:先看"流程骨架被复制得到处都是"的困境;再引出模板方法如何把骨架固定、步骤下放;然后讲清它的两个关键机制------钩子方法 和那个"不许子类乱改骨架"的 final;接着看它在框架里无处不在的身影;最后辨析它和策略的区别(继承 vs 组合的经典对照),给出适用边界。贯穿例是下单流程。

目录

  1. 流程骨架被到处复制的困境
  2. 模板方法:骨架定在父类,步骤留给子类
  3. [两个关键机制:钩子方法与 final 骨架](#两个关键机制:钩子方法与 final 骨架)
  4. 现实身影:框架的"半成品"都靠它
  5. [模板方法 vs 策略,以及什么时候用](#模板方法 vs 策略,以及什么时候用)

一、流程骨架被到处复制的困境

看下单流程。普通订单的下单逻辑大概是这样:

java 复制代码
public class NormalOrderService {
    public void placeOrder(Order order) {
        // 1. 校验
        if (order.getUserId() <= 0) throw new RuntimeException("用户非法");
        // 2. 计算金额
        double amount = order.getItems().stream().mapToDouble(Item::getPrice).sum();
        // 3. 扣减库存
        inventory.deduct(order);
        // 4. 保存订单
        repository.save(order);
        // 5. 发送通知
        notify.send(order.getUserId(), "下单成功");
    }
}

现在要做团购订单。它的下单流程,骨架几乎一样------同样是校验、算金额、扣库存、保存、通知,同样的顺序;只有两处不同:校验时要额外检查"是否成团",算金额时要"按团购价"。于是你复制了一份:

java 复制代码
public class GroupOrderService {
    public void placeOrder(Order order) {
        // 1. 校验(多了成团检查)
        if (order.getUserId() <= 0) throw new RuntimeException("用户非法");
        if (!checkGroupFormed(order)) throw new RuntimeException("未成团");
        // 2. 计算金额(按团购价)
        double amount = calcGroupPrice(order);
        // 3. 扣减库存 ------ 和普通订单一模一样
        inventory.deduct(order);
        // 4. 保存订单 ------ 一模一样
        repository.save(order);
        // 5. 发送通知 ------ 一模一样
        notify.send(order.getUserId(), "拼团下单成功");
    }
}

问题一目了然:

  • 大量重复 :扣库存、保存、通知这几步,在普通订单和团购订单里一字不差地重复了。再来个秒杀订单,又得抄一遍。
  • 骨架容易走样 :靠"复制粘贴"来保证流程一致,是极其脆弱的。哪天有人在团购订单里手滑把"保存"和"通知"的顺序写反了,或者漏了"扣库存"这一步,编译器不会报错,却是一个严重的业务 bug。流程的顺序和完整性,没有任何机制来保证。
  • 公共步骤改动要改多处 :哪天"保存订单"后要多加一步"记录日志",你得在每一个 XxxOrderService 里都加一遍。

根源是:"固定的流程骨架"和"可变的步骤",被混在一起、整体复制。 固定的部分本该只写一次、被强制复用;可变的部分才该各自实现。我们需要一种机制,把这两者分开------骨架锁死在一个地方,只把变化的步骤开放出来。这就是模板方法。

二、模板方法:骨架定在父类,步骤留给子类

模板方法的做法:在父类里定义一个"模板方法",它把整个流程的骨架(步骤顺序)写死;骨架里那些会变的步骤,声明成抽象方法,由子类去实现。

java 复制代码
public abstract class AbstractOrderService {

    // 模板方法:定义流程骨架,顺序固定。用 final 防止子类改写(见第三节)
    public final void placeOrder(Order order) {
        validate(order);                 // 步骤1:可变,交给子类
        double amount = calcAmount(order);// 步骤2:可变,交给子类
        inventory.deduct(order);         // 步骤3:固定,父类实现
        repository.save(order);          // 步骤4:固定,父类实现
        sendNotify(order);               // 步骤5:可变(文案),交给子类
    }

    // 会变的步骤 → 声明成抽象方法,强制子类实现
    protected abstract void validate(Order order);
    protected abstract double calcAmount(Order order);
    protected abstract void sendNotify(Order order);

    // 固定的步骤 → 父类直接实现,所有子类共享,不用重写
    private void deduct(Order order) { /* 扣库存,略 */ }
    // save 同理
}

子类只需要填那几个会变的步骤,完全不碰流程骨架:

java 复制代码
public class NormalOrderService extends AbstractOrderService {
    protected void validate(Order order) {
        if (order.getUserId() <= 0) throw new RuntimeException("用户非法");
    }
    protected double calcAmount(Order order) {
        return order.getItems().stream().mapToDouble(Item::getPrice).sum();
    }
    protected void sendNotify(Order order) { notify.send(order.getUserId(), "下单成功"); }
}

public class GroupOrderService extends AbstractOrderService {
    protected void validate(Order order) {
        if (order.getUserId() <= 0) throw new RuntimeException("用户非法");
        if (!checkGroupFormed(order)) throw new RuntimeException("未成团");   // 团购特有
    }
    protected double calcAmount(Order order) { return calcGroupPrice(order); }  // 团购价
    protected void sendNotify(Order order) { notify.send(order.getUserId(), "拼团下单成功"); }
}

对比第一节,升级点非常清晰:扣库存、保存这些固定步骤,只在父类写了一次,所有子类共享,不再重复;流程的顺序被 placeOrder 这个模板方法锁死,子类无法打乱;而各类订单的差异,被精准地隔离在 validatecalcAmount 这几个可变步骤里。 加一个秒杀订单?写个子类填那几个步骤就行,骨架和公共步骤一律复用。用一张图看这个"骨架固定、步骤下放"的结构最清楚:

图里最该记住的,是那条"骨架在父类、步骤在子类 "的分工线:父类掌控"流程长什么样、步骤什么顺序"这个大局(这叫控制反转------不是子类调父类,而是父类的骨架在合适的时机回调子类填的步骤),子类只负责"某一步具体怎么做"。好莱坞原则那句名言"Don't call us, we'll call you(别来调我们,我们会调你)"说的就是这个:子类别想主导流程,父类会在该调你的时候调你。

三、两个关键机制:钩子方法与 final 骨架

模板方法有两个容易被忽略、但很重要的细节,讲清楚才算真懂。

机制一:钩子方法(Hook Method)。 除了"必须实现的抽象步骤",模板方法还允许一种"可选的、子类想改就改、不改就用默认 "的步骤,叫钩子方法。它在父类里有一个默认实现(通常是空的,或返回一个默认值),子类可以选择性地覆盖它。

比如,我们想让子类能"选择性地"在下单后做点额外的事,或者控制"要不要发通知":

java 复制代码
public abstract class AbstractOrderService {
    public final void placeOrder(Order order) {
        validate(order);
        double amount = calcAmount(order);
        inventory.deduct(order);
        repository.save(order);
        if (needNotify()) {          // ← 钩子:由子类决定要不要执行这一步
            sendNotify(order);
        }
        afterPlaceOrder(order);      // ← 钩子:子类想加后续动作就重写,不想加就不管
    }

    // 钩子方法:父类给默认实现,子类可选择性覆盖
    protected boolean needNotify() { return true; }        // 默认发通知
    protected void afterPlaceOrder(Order order) { }         // 默认什么都不做
}

某个"静默下单"的子类,就可以重写 needNotify() 返回 false,跳过通知这一步------而不用去改骨架。钩子方法让模板方法在"强制统一"和"灵活定制"之间有了弹性:抽象方法是"必须填的空",钩子方法是"可选的开关"。

机制二:模板方法应该用 final 修饰。 注意到前面 placeOrder 一直带着 final 吗?这不是可有可无的。模板方法的全部价值 ,就在于"保证流程骨架不被破坏 "。如果不加 final,子类就能重写 placeOrder、把整个流程改得面目全非------那模板方法"锁定骨架"的意义就荡然无存了。用 final 修饰模板方法,等于明确声明:"流程骨架是我父类定的,子类只能填步骤,不许动骨架。" 这也呼应了第 1 篇里氏替换的精神------子类扩展能力,但不该破坏父类定义的核心契约。

一句话记这两个机制:抽象方法是"必须填的坑",钩子方法是"可选的开关",而 final 是"骨架的锁"。

四、现实身影:框架的"半成品"都靠它

模板方法是框架设计的基石 。为什么?因为框架的本质就是"我把通用流程的骨架写好,把可变的部分留给你填"------这正是模板方法。所以你用过的框架里,它无处不在:

  • AbstractList / AbstractMap 等骨架类 :JDK 的这些抽象类,把集合的通用操作(骨架)都实现好了,只把 get(index)size() 等最核心的几个方法留成抽象的。你写自定义集合时,继承 AbstractList、只实现那两三个方法,一整套 List 功能就有了。这就是模板方法------名字里的 "Abstract" 骨架类,大多是它。
  • HttpServletservice() :它是模板方法,内部根据请求类型,分发调用 doGet()doPost() 等方法。你写 Servlet 时只重写 doGet/doPost(填步骤),请求分发的骨架由 service() 定好,你不用管。
  • Spring 的各种 Template :JdbcTemplateRestTemplateRedisTemplate......名字里的 "Template" 就是模板方法的印记。它们把"获取连接、执行、处理异常、释放资源"这套繁琐骨架固定好,只把"你想执行什么 SQL、怎么处理结果"这一步,通过回调留给你。
  • Spring 的生命周期回调、AbstractApplicationContext.refresh():容器启动的那一长串固定流程是模板方法,其中留了若干扩展点(钩子)给你介入。

一个识别信号:凡是你看到抽象类里有一个"定义了完整流程、但调用了几个抽象/可覆盖方法"的方法,那基本就是模板方法。 框架给你的"半成品"------写好了骨架、留了几个空让你填------本质都是它。

五、模板方法 vs 策略,以及什么时候用

模板方法和上一篇的策略,是一对绝佳的对照------它们解决的是相似的问题(封装变化的行为),但手段截然相反,理解这个对照能同时加深对两者的认识:

模板方法 Template Method 策略 Strategy
手段 继承(子类重写步骤) 组合(持有策略对象)
变化的粒度 算法里的个别步骤 整个算法
骨架/流程 父类固定,不可变 没有固定骨架,整体替换
灵活性 编译期定死(继承) 运行时可换(组合)
关系 一套骨架 + 多个变体 多个平行、独立的算法

一句话对照:模板方法是"骨架不变,换几步"(用继承);策略是"整个算法换掉"(用组合)。 你会发现,又一次回到了"组合优于继承"这个贯穿全系列的主题------策略比模板方法更灵活(能运行时替换、不受单继承限制),但模板方法在"确实存在一个稳定骨架、只是个别步骤不同"时,表达得更自然、更省事。很多时候两者可以配合:模板方法定骨架,其中某个可变步骤内部又用策略来选具体算法。

适合用模板方法的信号:

  • 多个类有相同的流程骨架 ,只是个别步骤的实现不同;
  • 你想把公共的流程逻辑抽取到一处 ,避免重复,并强制保证流程的顺序和完整性;
  • 你在设计一个框架,想定义好流程、把扩展点留给使用者。

不必用的信号:

  • 各个变体之间没有共同的骨架 ,只是一堆平行的独立算法------那是策略的场景;
  • 变化的部分需要运行时动态切换------继承做不到,该用策略(组合);
  • 就一个实现,未来也不会有变体------直接写,别为想象中的子类先建抽象类。

判断的核心还是那句话:先确认真的存在"一个稳定的流程骨架 + 个别可变步骤"这个结构,模板方法才合适。 硬把没有共同骨架的东西塞进一个抽象类,或者为唯一的实现先建父类,都是过度设计。


小结 。模板方法模式把"固定的流程骨架"和"可变的步骤"分开:骨架用一个 final 的模板方法定死在父类(保证顺序和完整性不被破坏),可变步骤声明成抽象方法交给子类实现,可选步骤用钩子方法给默认实现。它的精髓是"好莱坞原则 "------父类的骨架在合适的时机回调子类的步骤(控制反转),子类只填空、不主导流程。JDK 的 AbstractXxx 骨架类、HttpServlet、Spring 的各种 Template,都是它的身影,它是框架设计的基石。记住它和策略的分水岭:模板方法用继承换"几步",策略用组合换"整个算法" 。下一篇我们讲观察者模式------当一个对象的状态变化,需要自动通知一大群关心它的对象时(比如订单支付成功后,要通知积分、短信、物流多个系统),观察者就是那套"发布-订阅"的优雅机制。

相关推荐
今天的砖头有点烫手啊7 小时前
AI Agent 开发实战(十):Agent 设计模式(ReAct / Plan-Execute / Reflection)
人工智能·react.js·设计模式
董员外8 小时前
RAG 系统进化论(十):可运营 RAG,从原型到长期运行的知识系统
人工智能·后端·设计模式
岭南灯火21 小时前
前端通用交互式几何编辑器的开发经验
前端·设计模式·架构
岭南灯火21 小时前
前端通用交互式几何编辑器的设计法则 7 - 设计模式与原则
前端·设计模式·架构
岭南灯火21 小时前
前端通用交互式几何编辑器的设计法则 4 - 绘制、悬停与编辑
前端·设计模式·架构
岭南灯火21 小时前
前端通用交互式几何编辑器的设计法则 6 - 配套设施与工程化
前端·设计模式·架构
岭南灯火21 小时前
前端通用交互式几何编辑器的设计法则 5 - 辅助元素的设计
前端·设计模式·架构
日暮南城故里1 天前
装饰器模式深度解析:从Java IO流源码到实战应用
java·设计模式·装饰器模式