一、为什么需要模板方法模式
你正在写一个数据处理框架,支持 CSV、JSON、XML 三种格式的导入。三种格式的处理流程完全一样:读取文件、解析数据、校验字段、写入数据库、发送通知。区别只在于「解析数据」这一步——CSV 按逗号分割,JSON 用反序列化,XML 用 DOM 解析。
最直接的做法是在每个导入器里把完整流程写一遍:
func ImportCSV(path string) error { raw, err := readFile(path) if err != nil { return err } data, err := parseCSV(raw) // CSV 特有 if err != nil { return err } if err := validate(data); err != nil { return err } if err := writeToDB(data); err != nil { return err } return notify("import done")}
func ImportJSON(path string) error { raw, err := readFile(path) if err != nil { return err } data, err := parseJSON(raw) // JSON 特有 if err != nil { return err } if err := validate(data); err != nil { return err } if err := writeToDB(data); err != nil { return err } return notify("import done")}五个步骤,两个函数只有第二步不同,其余四步完全重复。如果以后要在「校验」和「写入」之间加一个「转换」步骤,你得改所有导入器。如果某个导入器忘了发通知,bug 就悄悄溜进去了。
模板方法模式解决的就是这个问题:把算法骨架放在基类里,把可变的步骤留给子类实现。基类定义一个 Import 方法,按固定顺序调用 ReadFile、Parse、Validate、WriteToDB、Notify。其中 Parse 是抽象方法,子类各自实现;其余步骤有默认实现,子类也可以选择性覆写。
重构后,新增一种格式只需要实现 Parse 方法,骨架流程完全不用碰。
二、现实类比
做菜。一道红烧肉的步骤是固定的:切块、焯水、炒糖色、加调料、炖煮、收汁。但「加调料」这一步因人而异——有人放八角桂皮,有人只放酱油冰糖。步骤骨架不变,具体某一步的内容可以替换。如果你把做菜写成代码,整个流程就是模板方法,「加调料」就是留给子类的钩子。
简历模板。不管谁写简历,结构都差不多:教育背景、工作经历、项目经验、技能特长。模板把章节顺序定死了,但每个章节填什么内容完全取决于个人。你不需要每次从空白文档开始排版,只需要在对应位置填上自己的信息。
流水线。汽车工厂的装配流程固定:底盘、发动机、车身、内饰、检测。不同车型走同一条流水线,区别只在具体零件——SUV 装大排量发动机,轿车装小排量。流水线的步骤顺序不会因为车型而改变,改变的只是每一步投入的物料。
这三个类比的共同点:流程骨架稳定,具体步骤可变。这正是模板方法模式的适用场景。
三、核心思想
模板方法模式的核心结构很简单:一个抽象类定义模板方法(Template Method),模板方法按固定顺序调用若干原语操作(Primitive Operations)。其中部分原语操作是抽象的——子类必须实现;部分是钩子(Hook)——子类可以覆写,也可以用默认行为。
3.1 模板方法与原语操作
模板方法本身是 final 或不可覆写的——它定义了算法的骨架,子类不能改变执行顺序。原语操作分两类:
- 抽象方法:子类必须实现,没有默认行为。比如数据解析——每种格式必须自己实现
- 钩子方法:子类可选覆写,有默认行为(通常是空实现)。比如发送通知——默认发邮件,子类可以改成发短信,也可以什么都不做
这种区分很实用。如果所有步骤都是抽象的,子类的工作量太大;如果所有步骤都有默认实现,又失去了扩展点。抽象方法保证关键步骤一定被实现,钩子方法提供灵活的定制空间。
3.2 好莱坞原则
模板方法模式体现了好莱坞原则(Hollywood Principle):“Don’t call us, we’ll call you”(别找我们,我们会找你)。
在传统的调用关系中,子类主动调用基类的方法来复用逻辑。在模板方法中,关系反过来了——基类的模板方法调用子类实现的原语操作。子类不需要知道整个算法的流程,只需要实现被调用的那几个步骤。控制权在基类手里,子类被动响应。
这和框架的设计哲学一致:框架定义整体流程,用户代码填充具体逻辑。你写 Spring Controller 的时候不需要知道 DispatcherServlet 怎么分发请求,只需要实现 handleRequest。框架会来调用你,而不是你去调用框架。
3.3 Go 的实现方式
Go 没有类继承,也没有 abstract 关键字。模板方法在 Go 里通过结构体嵌入实现:基结构体定义模板方法和默认实现,子结构体嵌入基结构体后覆写需要定制的方法。
关键技巧是:基结构体的模板方法通过调用自身方法来触发子结构体的覆写。Go 的方法解析规则是——如果嵌入结构体覆写了某个方法,调用时会优先使用覆写版本。这和 OOP 的虚方法分派效果一致。
3.4 JUnit 的生命周期钩子
JUnit 的 @Before / @After 是模板方法在测试框架中的经典应用。测试框架的骨架是固定的:初始化环境、执行测试、清理环境。每个测试用例只需要写「执行测试」这一步,@Before 和 @After 是可选的钩子——你可以覆写它们来定制初始化和清理逻辑,也可以不写,走默认的空实现。
四、变体与对比
4.1 模板方法 vs 策略模式
这两个模式经常被拿来比较,因为它们解决的是类似的问题——把算法的可变部分抽出来。但实现方式完全不同:
| 维度 | 模板方法 | 策略模式 |
|---|---|---|
| 机制 | 继承——子类覆写步骤 | 组合——替换整个策略对象 |
| 粒度 | 算法骨架不变,替换个别步骤 | 替换整个算法 |
| 绑定时机 | 编译时(类定义时确定) | 运行时(可动态切换策略) |
| 扩展性 | 新增步骤需改基类 | 新增策略只需加新类 |
模板方法通过继承控制算法骨架,子类只能替换步骤的实现,不能改变步骤的顺序和数量。策略模式通过组合替换整个算法,灵活性更高,但也意味着你需要自己保证不同策略的接口一致。
选择依据:如果算法骨架稳定、只是个别步骤不同,用模板方法;如果整个算法都可能替换,用策略模式。
4.2 钩子方法 vs 抽象方法
钩子方法有默认实现,子类可选覆写;抽象方法没有默认实现,子类必须覆写。这个区分看似简单,实际设计时需要仔细权衡:
- 把一个步骤设计成抽象方法,意味着所有子类都必须实现它——即使某些子类根本不需要这个步骤,也得写一个空实现
- 把一个步骤设计成钩子方法,子类可能忘记覆写——因为编译器不会提醒你
实用建议:核心步骤用抽象方法,辅助步骤用钩子方法。「核心」指的是没有它算法就不完整——比如数据解析,不解析就没法继续。「辅助」指的是锦上添花——比如日志记录、通知发送,没有它们算法照样能跑。
4.3 函数式模板
在支持高阶函数的语言里,模板方法可以用函数参数替代继承。模板方法变成一个接受函数参数的高阶函数,调用者传入具体的步骤实现:
// 函数式模板:把步骤作为参数传入function processData( raw: string, parse: (raw: string) => Data[], validate?: (data: Data[]) => void, // 可选钩子 notify?: (msg: string) => void, // 可选钩子): void { const data = parse(raw); validate?.(data); // 有就调用,没有就跳过 writeToDB(data); notify?.("import done");}函数式写法更轻量——不需要定义抽象类和继承关系,直接传函数就行。缺点是缺少了类继承带来的代码复用:如果多个调用者共享某些步骤的实现,用继承可以在基类里写一次,用函数参数就得各自传一遍。
五、多语言实现
5.1 Go:数据处理管道
用 Go 的结构体嵌入实现模板方法,模拟三种数据格式的导入管道:
package pipeline
import "fmt"
// DataImporter 数据导入管道——基结构体,定义模板方法type DataImporter struct { // 嵌入的子结构体会覆写 Parse 方法 parser func(raw string) ([]map[string]any, error)}
// Import 模板方法——定义算法骨架,不可覆写func (d *DataImporter) Import(path string) error { // 步骤 1:读取文件(固定步骤) raw, err := d.readFile(path) if err != nil { return fmt.Errorf("读取失败: %w", err) }
// 步骤 2:解析数据(抽象步骤,子类必须提供) data, err := d.parser(raw) if err != nil { return fmt.Errorf("解析失败: %w", err) }
// 步骤 3:校验数据(钩子方法,有默认实现) if err := d.Validate(data); err != nil { return fmt.Errorf("校验失败: %w", err) }
// 步骤 4:写入数据库(固定步骤) if err := d.writeToDB(data); err != nil { return fmt.Errorf("写入失败: %w", err) }
// 步骤 5:发送通知(钩子方法,默认空实现) d.Notify("import done: " + path) return nil}
// readFile 固定步骤——所有子类共享func (d *DataImporter) readFile(path string) (string, error) { // 实际实现省略,返回文件内容 return "file content", nil}
// writeToDB 固定步骤——所有子类共享func (d *DataImporter) writeToDB(data []map[string]any) error { // 实际实现省略 return nil}
// Validate 钩子方法——子类可选覆写,默认不做校验func (d *DataImporter) Validate(data []map[string]any) error { return nil // 默认:跳过校验}
// Notify 钩子方法——子类可选覆写,默认不发通知func (d *DataImporter) Notify(msg string) { // 默认:静默,什么都不做}package pipeline
import "encoding/json"
// CSVImporter CSV 导入器——覆写解析步骤,定制校验钩子type CSVImporter struct { DataImporter // 嵌入基结构体,继承模板方法}
func NewCSVImporter() *CSVImporter { c := &CSVImporter{} // 提供解析实现——相当于「抽象方法」的具体化 c.parser = c.parseCSV return c}
func (c *CSVImporter) parseCSV(raw string) ([]map[string]any, error) { // CSV 解析逻辑 return []map[string]any{{"format": "csv"}}, nil}
// Validate 覆写钩子方法——CSV 需要特殊校验func (c *CSVImporter) Validate(data []map[string]any) error { if len(data) == 0 { return fmt.Errorf("CSV 数据为空") } return nil}
// JSONImporter JSON 导入器——只覆写解析步骤type JSONImporter struct { DataImporter}
func NewJSONImporter() *JSONImporter { j := &JSONImporter{} j.parser = j.parseJSON return j}
func (j *JSONImporter) parseJSON(raw string) ([]map[string]any, error) { var result []map[string]any if err := json.Unmarshal([]byte(raw), &result); err != nil { return nil, err } return result, nil}
// Notify 覆写钩子方法——JSON 导入需要发通知func (j *JSONImporter) Notify(msg string) { fmt.Println("[JSON Import]", msg)}Go 没有继承,所以用函数字段代替抽象方法——parser 字段在构造时赋值,效果等同于子类覆写。钩子方法则通过方法覆写实现:CSVImporter 覆写了 Validate,JSONImporter 覆写了 Notify,其余步骤走基结构体的默认实现。
这种写法的好处是:新增格式只需要新建一个结构体、提供 parser 函数、选择性覆写钩子方法。Import 方法的流程完全不用碰。
5.2 TypeScript:抽象类与函数式对比
先用经典的抽象类方式实现,再用函数式写法对比:
// 抽象类方式——模板方法模式的标准实现abstract class DataImporter { // 模板方法——final,子类不能覆写 public import(path: string): void { const raw = this.readFile(path); const data = this.parse(raw); // 抽象方法,子类必须实现 this.validate(data); // 钩子方法,可选覆写 this.writeToDB(data); this.notify('import done: ' + path); // 钩子方法,可选覆写 }
// 固定步骤 private readFile(path: string): string { return 'file content'; // 实际实现省略 }
private writeToDB(data: Record<string, unknown>[]): void { // 实际实现省略 }
// 抽象方法——子类必须实现 protected abstract parse(raw: string): Record<string, unknown>[];
// 钩子方法——有默认实现,子类可选覆写 protected validate(_data: Record<string, unknown>[]): void { // 默认:跳过校验 }
protected notify(_msg: string): void { // 默认:静默 }}
// CSV 导入器class CSVImporter extends DataImporter { protected parse(raw: string): Record<string, unknown>[] { // CSV 解析逻辑 return [{ format: 'csv' }]; }
// 覆写钩子——CSV 需要校验 protected validate(data: Record<string, unknown>[]): void { if (data.length === 0) throw new Error('CSV 数据为空'); }}
// JSON 导入器class JSONImporter extends DataImporter { protected parse(raw: string): Record<string, unknown>[] { return JSON.parse(raw); }
// 覆写钩子——JSON 需要通知 protected notify(msg: string): void { console.log('[JSON Import]', msg); }}TypeScript 的 abstract 关键字让模板方法的意图更清晰——parse 必须实现,validate 和 notify 可选覆写。编译器会检查子类是否实现了所有抽象方法,遗漏就报错。这比 Go 的函数字段更安全。
接下来看函数式写法:
// 函数式模板——用高阶函数替代继承function createImporter(config: { parse: (raw: string) => Record<string, unknown>[]; validate?: (data: Record<string, unknown>[]) => void; notify?: (msg: string) => void;}) { return function importData(path: string): void { const raw = readFile(path); const data = config.parse(raw); config.validate?.(data); // 可选钩子 writeToDB(data); config.notify?.('import done: ' + path); // 可选钩子 };}
// 使用——不需要定义类,直接传函数const importCSV = createImporter({ parse: (raw) => [{ format: 'csv' }], validate: (data) => { if (data.length === 0) throw new Error('CSV 数据为空'); },});
const importJSON = createImporter({ parse: (raw) => JSON.parse(raw), notify: (msg) => console.log('[JSON Import]', msg),});函数式写法更简洁——没有类、没有继承、没有 abstract。createImporter 返回一个闭包,闭包捕获了 config 中的步骤实现。可选步骤用 TypeScript 的可选属性 + 可选链 ?. 处理,效果等同于钩子方法的默认空实现。
两种写法的选择取决于场景:如果步骤之间有共享状态或需要代码复用,抽象类更合适;如果步骤都是无状态的纯函数,函数式写法更轻量。
六、生产验证
-
JUnit 5 —— junit-team/junit5 测试生命周期是模板方法的经典应用。
@BeforeAll/@BeforeEach/@Test/@AfterEach/@AfterAll构成了测试执行的骨架。框架控制调用顺序,测试类只需要写@Test方法,@BeforeEach和@AfterEach是可选的钩子——你可以覆写它们来初始化和清理测试环境,也可以不写,走默认的空实现。JUnit 5 的@BeforeAll甚至支持用default方法在接口中提供默认初始化逻辑,这和模板方法中钩子方法的默认实现如出一辙。 -
Android Activity 生命周期 —— Android 源码
Activity的生命周期回调onCreate→onStart→onResume→onPause→onStop→onDestroy是模板方法的典型实现。Android 框架在ActivityThread中按固定顺序调用这些方法,开发者只需要覆写关心的回调。onCreate是「抽象方法」——每个 Activity 必须实现它来初始化界面;onPause和onDestroy是「钩子方法」——你可以覆写它们来保存状态和释放资源,也可以不写,走默认实现。整个生命周期的流转完全由框架控制,开发者无法改变调用顺序。 -
Spring Framework —— spring-projects/spring-framework
AbstractController的handleRequest方法是模板方法,内部按顺序调用checkRequest、handleRequestInternal、handleInvalidRequest。handleRequestInternal是抽象方法,子类必须实现;checkRequest和handleInvalidRequest是钩子方法,有默认实现。Spring 中大量使用这种模式:AbstractView的render方法、AbstractApplicationContext的refresh方法、AbstractHandlerMapping的getHandler方法——框架定义流程,开发者填充逻辑。
七、小结
何时使用:
- 框架生命周期——框架控制整体流程,用户代码填充具体步骤。JUnit 的测试生命周期、Android 的 Activity 生命周期、Spring 的 Controller 处理流程,都是模板方法在框架中的经典应用
- 多个子类共享算法骨架——当多个实现只有个别步骤不同时,把骨架提到基类,避免重复代码
- 需要控制扩展点——钩子方法让子类只能在你允许的地方定制,防止子类破坏算法的整体结构
何时不用:
- 算法整体需要替换——如果不同场景的算法骨架本身就不同,模板方法帮不了你,应该用策略模式
- 步骤经常变化——如果算法的步骤顺序或数量频繁调整,每次改基类会影响所有子类,维护成本很高
- 子类很少——如果只有一两个子类,抽象基类的开销不值得,直接写两个独立函数更简单
核心取舍:模板方法用继承换来了骨架的复用和扩展点的控制,代价是子类和基类的耦合。基类改了,所有子类都可能受影响。如果这种耦合可以接受——比如框架和用户代码的关系——模板方法就是最自然的选择。
八、参考资料
- Design Patterns: Elements of Reusable Object-Oriented Software - GoF 原著,模板方法模式的经典定义
- JUnit 5 User Guide - Lifecycle - JUnit 测试生命周期钩子的设计与使用
- Android Activity Lifecycle - Activity 生命周期回调的官方文档
- Spring Framework AbstractController - Spring 模板方法在 Controller 中的应用
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






