Java 23 种设计模式:从踩坑到精通 | 模版方法模式 —— 定义算法骨架,交给子类填充细节

Java 23 种设计模式:从踩坑到精通 | 模版方法模式 —— 定义算法骨架,交给子类填充细节

摘要:当多个类有相似的流程,只有部分步骤不同时,复制粘贴代码会导致大量冗余,且修改流程时必须逐个更新所有类。模板方法模式通过在父类中定义一个算法的骨架,将可变部分延迟到子类中实现,从而在保证流程一致性的同时,允许子类灵活定制特定步骤。本文从饮品制作流程出发,完整讲解模板方法模式的原理、UML、钩子方法等实现技巧,与策略模式进行对比,并结合电子面单编排器实战、JDK AbstractList、Servlet HttpServlet、Spring JdbcTemplate 等框架应用,帮你掌握“固定骨架、填充细节”的设计精髓。

🗺️ 本文阅读地图(3 分钟速览)

  • 为什么泡茶和冲咖啡的代码几乎一样却不能复用?
  • 模板方法 vs 抽象方法 vs 钩子方法
  • 手写饮品制作模板(茶、咖啡、钩子控制加调料)
  • 数据同步框架:固定流程 + 可变步骤
  • 实战案例:电子面单编排器——模板方法用继承还是组合?
  • JDK AbstractList / Servlet HttpServlet / Spring JdbcTemplate 如何体现
  • 面试必问:模板方法 vs 策略,到底怎么选?

📖 《Java 23 种设计模式:从踩坑到精通》
开篇:系列介绍与目录 | 上一篇:策略模式 | 当前:模板方法模式 | 下一篇:访问者模式
🔗 返回系列总目录



1. 从“泡茶和冲咖啡”的相似流程说起

泡茶和冲咖啡的步骤非常相似:

  • 泡茶:烧水 → 用热水泡茶叶 → 倒入茶杯 → 加柠檬
  • 冲咖啡:烧水 → 用热水冲咖啡粉 → 倒入咖啡杯 → 加糖和牛奶

如果分别实现这两个类,大量代码重复:烧水、倒杯这些步骤完全一样,只有“泡什么”和“加什么调料”不同。更糟的是,如果某天流程需要调整(比如必须用 80℃ 水),就必须修改所有相关类。如果以后还要增加“泡枸杞”或“冲可可”,复制粘贴会让代码迅速膨胀。

模板方法模式(Template Method Pattern)正是为此而生:它在父类中定义算法的骨架,将某些步骤延迟到子类中实现。子类可以在不改变算法整体结构的情况下,重新定义算法中的某些步骤。

1.1 你的场景该不该用模板方法?

判断标准是 → 用模板方法否 → 用其他方式
多个类有完全相同的流程,仅部分步骤不同
需要控制流程的执行顺序,防止子类篡改
需要在父类中提供“钩子”让子类可选地干预流程
需要动态替换整个算法,而非仅修改部分步骤考虑策略模式

2. 模式定义与 UML 结构

模板方法模式 定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。它属于 行为型设计模式

模板方法模式

图文解析(配合上述 UML 图)

模板方法模式的核心角色:

  • 抽象父类(AbstractClass:定义模板方法和抽象/钩子方法。模板方法定义算法骨架,并调用抽象方法和钩子方法。模板方法通常被声明为 final,防止子类篡改流程。
  • 具体子类(ConcreteClass:实现抽象类中定义的抽象方法,并根据需要重写钩子方法。

核心机制:好莱坞原则——“Don’t call us, we’ll call you.” 父类控制整体流程,在需要的时候回调子类的方法。子类永远不需要主动调用父类的模板方法,父类会在合适时机调用子类的实现。


3. 代码实现:饮品制作模板

3.1 抽象父类

public abstract class BeverageMaker {

    // 模板方法,声明为 final 防止子类改变流程
    public final void makeBeverage() {
        boilWater();              // 通用步骤
        brew();                   // 可变步骤(子类实现)
        pourInCup();              // 通用步骤
        if (wantCondiments()) {   // 钩子方法决定是否加调料
            addCondiments();      // 可变步骤(子类实现)
        }
    }

    // 通用步骤(父类实现,子类不可重写)
    private void boilWater() {
        System.out.println("把水烧到 100°C");
    }

    private void pourInCup() {
        System.out.println("倒入杯中");
    }

    // 抽象方法:子类必须实现
    protected abstract void brew();
    protected abstract void addCondiments();

    // 钩子方法:子类可以选择重写,默认返回 true
    protected boolean wantCondiments() {
        return true;
    }
}

💬 白话:模板方法 makeBeverage() 就是“泡饮品的标准流程”,final 保证没人能改流程顺序。brew()addCondiments() 是子类必须实现的“填空”。wantCondiments() 是钩子,可以控制是否加调料。

3.2 具体子类:茶

public class TeaMaker extends BeverageMaker {
    @Override
    protected void brew() {
        System.out.println("用热水浸泡茶叶");
    }

    @Override
    protected void addCondiments() {
        System.out.println("加柠檬");
    }
}

3.3 具体子类:咖啡

public class CoffeeMaker extends BeverageMaker {
    @Override
    protected void brew() {
        System.out.println("用热水冲泡咖啡粉");
    }

    @Override
    protected void addCondiments() {
        System.out.println("加糖和牛奶");
    }

    // 重写钩子:有些人喝黑咖啡不加东西
    @Override
    protected boolean wantCondiments() {
        return false;  // 演示用,默认不加
    }
}

💬 白话:咖啡通过重写 wantCondiments() 告诉父类“我不要加调料”,父类就会跳过加调料的步骤。

3.4 客户端

System.out.println("=== 制作茶 ===");
BeverageMaker tea = new TeaMaker();
tea.makeBeverage();

System.out.println("\n=== 制作咖啡 ===");
BeverageMaker coffee = new CoffeeMaker();
coffee.makeBeverage();

输出:

=== 制作茶 ===
把水烧到 100°C
用热水浸泡茶叶
倒入杯中
加柠檬

=== 制作咖啡 ===
把水烧到 100°C
用热水冲泡咖啡粉
倒入杯中

咖啡跳过了加调料步骤,因为 wantCondiments() 返回了 false


4. 代码实现:数据同步框架

4.1 抽象父类

public abstract class DataSyncTemplate {
    public final void sync() {
        connect();
        extractData();
        transformData();
        loadData();
        disconnect();
        logSync();
    }

    private void connect() { System.out.println("【通用】建立数据库连接"); }
    protected abstract void extractData();

    // 钩子方法:默认不转换
    protected void transformData() { System.out.println("【默认】无需数据转换"); }

    protected abstract void loadData();
    private void disconnect() { System.out.println("【通用】关闭数据库连接"); }
    private void logSync() { System.out.println("【通用】记录同步日志"); }
}

💬 白话:数据同步的标准流程:连上 → 取数据 → 转格式 → 存数据 → 断开 → 记日志。extractData()loadData() 交给子类实现,transformData() 是可选的转换步骤。

4.2 具体子类

public class MySQLToRedisSync extends DataSyncTemplate {
    @Override
    protected void extractData() {
        System.out.println("从 MySQL 查询待同步数据");
    }

    @Override
    protected void transformData() {
        System.out.println("将 MySQL 行数据转为 Redis Hash 结构");
    }

    @Override
    protected void loadData() {
        System.out.println("批量写入 Redis");
    }
}

4.3 客户端

new MySQLToRedisSync().sync();

流程固定,细节可变。新增一个 PostgreSQLToElasticsearchSync 只需继承父类,实现两个抽象方法即可。


5. 实战案例:电子面单编排器——模板方法用继承还是组合?

理论学完了,来看看模板方法模式在真实项目中是怎么用的。在多平台电子面单系统中,取号流程是一个标准的“骨架+可变步骤”场景:校验订单 → 构建请求 → 调用API → 判断成功 → 解析响应 → 持久化。这个骨架对所有平台都一样,但每一步的具体实现因平台而异。

5.1 我们的选择:用组合实现模板方法

传统模板方法模式用继承实现——父类定义骨架,子类覆写抽象方法。但在我们的电子面单架构中,选择了组合方式。核心编排器 WaybillFetchTemplate 是一个普通的类,通过构造函数注入三个策略对象:

public class WaybillFetchTemplate {
    private final RequestStrategy requestStrategy;
    private final ParseStrategy parseStrategy;
    private final ExceptionStrategy exceptionStrategy;
    private final ApiInvoker apiInvoker;
    private final WaybillPersistence persistence;

    // 通过构造函数注入策略
    public WaybillFetchTemplate(RequestStrategy req, ParseStrategy parse,
            ExceptionStrategy ex, ApiInvoker invoker, WaybillPersistence persist) {
        this.requestStrategy = req;
        this.parseStrategy = parse;
        this.exceptionStrategy = ex;
        this.apiInvoker = invoker;
        this.persistence = persist;
    }

    // 模板方法:固定取号流程骨架
    public boolean execute(WaybillContext ctx) {
        // 1. 构建请求(策略)
        Object request = requestStrategy.buildRequest(ctx);
        // 2. 调用API(统一)
        String response = apiInvoker.invoke(ctx, request, traceId);
        // 3. 业务异常判断(策略)
        if (!exceptionStrategy.isBusinessSuccess(response)) {
            markException(ctx.getTicket(), exceptionStrategy.extractErrorMsg(response));
            return false;
        }
        // 4. 解析响应(策略)
        List<Detail> details = parseStrategy.parseResponse(ctx, response);
        if (details.isEmpty()) {
            markException(ctx.getTicket(), "未获取到运单号");
            return false;
        }
        // 5. 持久化(统一)
        persistence.saveAndBind(ctx.getTicket(), details, ctx.isFirst());
        return true;
    }
}

使用方式——运行时注入不同平台的策略:

// 奇门取号
RequestStrategy req = new QiMenRequestStrategy();
ParseStrategy parse = new QiMenParseStrategy();
ExceptionStrategy ex = new QiMenExceptionStrategy();
WaybillFetchTemplate template = new WaybillFetchTemplate(req, parse, ex, invoker, persistence);
template.execute(ctx);

// 抖音取号
req = new DouYinRequestStrategy();
parse = new DouYinParseStrategy();
ex = new DouYinExceptionStrategy();
template = new WaybillFetchTemplate(req, parse, ex, invoker, persistence);
template.execute(ctx);

5.2 为什么用组合而不是继承?

维度继承方式组合方式
类数量N 个平台 → N 个子类1 个模板类 + 3 个策略接口
灵活性编译期绑定运行时动态替换策略
测试性需创建子类实例直接 Mock 策略注入
复用性子类间无法复用策略策略可跨平台组合

💡 关键设计:策略已经通过接口独立,组合方式避免了类爆炸,同时保留了运行时替换策略的灵活性。这就是“组合优于继承”的最佳实践。

5.3 模板层分支:顺丰子母件

组合方式还有一个额外优势——在模板层增加流程分支非常自然。顺丰超过 10 件需要走子母件模式(分批调用 API),我们直接在模板类中增加一个分支方法:

public boolean execute(WaybillContext ctx) {
    if (isSFMoreTen(ctx)) {
        return executeSFMoreTen(ctx);  // 子母件分支:多次调用
    }
    return executeNormal(ctx);         // 普通分支:单次调用
}

如果用继承,子母件逻辑要么重复在每个子类中实现,要么在父类中增加复杂的分支判断,很容易失控。组合方式让模板层成为唯一的流程控制点。

📖 这套架构的完整设计、以及“模板方法用继承还是组合”的深度对比,详见电子面单实战系列的《多平台统一架构设计》和《模板方法的组合与继承抉择》。


6. 模板方法模式 vs 策略模式

对比维度模板方法模式策略模式
定义方式继承:子类重写父类方法组合:接口实现
控制权父类控制整体流程,子类只填充细节客户端选择策略,策略控制算法
粒度控制算法的骨架封装整个算法
复用方式复用父类的通用代码复用策略接口和具体策略类
典型应用HttpServletJdbcTemplateAbstractListComparator、支付方式、折扣策略

💡 简单记忆:模板方法是“继承复用”(固定骨架,填空),策略模式是“组合复用”(替换整个算法)。模板方法强调“流程一致性”,策略模式强调“算法可替换”。


7. 优缺点一览

优点缺点
代码复用:公共逻辑集中到抽象类,避免代码重复继承限制:子类必须继承抽象类,Java 单继承限制灵活性
流程控制:父类控制整体流程,保证一致性钩子过多导致复杂度:钩子方法太多会让父类难以理解
易扩展:新增子类只需实现可变部分,符合开闭原则子类受限:子类必须遵循父类定义的算法骨架,无法彻底改变流程
符合开闭原则:骨架固定,可变部分灵活扩展调试困难:调用栈在父类与子类之间跳转,排查问题需要跨类追踪

8. 框架与实践中的应用

8.1 Servlet:HttpServlet

这是模板方法模式最经典的例子。HttpServletservice() 方法是模板方法,它根据 HTTP 方法类型调用 doGet()doPost() 等钩子方法。开发者只需继承 HttpServlet 并重写这些方法,无需关心请求解析和响应分发。

public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        // 处理 GET 请求
    }
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
        // 处理 POST 请求
    }
}

8.2 Spring:JdbcTemplateRestTemplateTransactionTemplate

这些 XXXTemplate 类都遵循模板方法模式。以 JdbcTemplate 为例,它定义了数据库操作的流程:获取连接 → 准备语句 → 执行查询 → 处理结果 → 释放资源。开发者只需通过回调接口实现结果映射等可变部分。

8.3 JDK:AbstractList / AbstractSet

Java 集合框架中,AbstractList 提供了 get(int index)size() 等抽象方法,子类只需实现这些方法,indexOf()contains() 等通用方法自动可用。这是模板方法模式的典型应用。


9. 面试必问 + 面试官追问连环炮

基础必问

  • 模板方法模式的核心角色? → 抽象父类(定义模板方法)、具体子类(实现可变步骤)。
  • 模板方法为什么通常声明为 final → 防止子类篡改算法骨架,保证流程一致性。
  • HttpServlet.service() 是什么模式? → 模板方法模式。

面试官追问

  • “模板方法模式和策略模式如何选择?”
    👉 流程固定、只需修改部分步骤 → 模板方法;需要整体替换算法、动态切换 → 策略模式。
  • “钩子方法越多越好吗?”
    👉 不是。钩子过多会让父类的意图模糊,子类难以理解哪些是必须实现、哪些是可选的。适量即可。
  • “模板方法模式如何避免子类忘写关键步骤?”
    👉 关键步骤定义为 abstract,编译器会强制子类实现;可选步骤使用钩子方法并提供默认值。
  • “你在项目中用过模板方法模式吗?能举个具体的例子吗?”
    👉 可以讲电子面单的编排器设计——取号流程骨架固定,策略接口填充平台差异,并用组合代替继承,避免了类爆炸。完整的架构设计见电子面单实战系列《多平台统一架构设计》和《模板方法的组合与继承抉择》。

🎉 恭喜:如果你能立刻说出 HttpServlet 是模板方法模式,JdbcTemplate 也遵循模板方法思想,并清楚模板方法 vs 策略的区别,还能用电子面单案例回答“项目里怎么用的”,你已经掌握了 Java 框架中最核心的“继承复用”设计思想。


10. 六大设计原则在模板方法模式中的体现

设计原则在模板方法模式中的体现
单一职责原则(SRP)抽象父类管理算法骨架,子类负责具体步骤
开闭原则(OCP)新增不同实现只需新增子类,无需修改抽象父类
里氏替换原则(LSP)所有子类都可替换抽象父类
依赖倒置原则(DIP)父类依赖抽象方法,子类实现具体逻辑
接口隔离原则(ISP)抽象父类只定义必要的抽象方法和钩子,不臃肿
迪米特法则(LoD)客户端只与抽象父类交互,不需要了解子类细节

🧭 《Java 23 种设计模式:从踩坑到精通》快速导航


实战配套:电商多平台电子面单对接实战

本文第5节展示的电子面单编排器设计,在我们的电子面单实战系列中有完整的落地代码和设计演进过程。如果你对以下问题感兴趣,推荐延伸阅读:

  • 模板方法用继承还是组合? 编排器为什么选择了组合方式?
  • 策略模式 + 模板方法模式:如何配合实现流程与策略的完全解耦?
  • 顺丰子母件:如何在模板层优雅地增加流程分支?

📖 《电商多平台电子面单对接实战》

💡 学习建议:设计模式系列讲“为什么这么用”,电子面单系列讲“怎么用”。两者搭配,理论+实战闭环。

🔔 关注《Java 23 种设计模式:从踩坑到精通》,用 25 篇文章彻底吃透设计模式。
📦 福利预告:全系列代码及 UML 源码将在完结时统一打包开放,点击「关注」「收藏」第一时间获取。
🚀 下一篇访问者模式:数据结构稳定但操作多变?试试访问者!

📌 除了设计模式,我也在深挖智能物流实战(WMS、托盘调度、机器学习落地)。欢迎点击头像,看看专栏 《出版社物流WMS智能调度实战》。技术相通,思路可鉴。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值