资讯动态

模板方法模式在Spring中的实战应用与源码解析

发布时间:2026/10/17 11:09:46 来源:尧图企业网站定制
作为一个写了多年 Java 的开发者我见过太多人把设计模式挂在嘴边但真正遇到问题的时候又一脸懵。今天想聊聊模板方法模式尤其是它在 Spring 里的实际应用。这个模式不像单例、工厂那样被频繁提及但只要你写过一段时间的业务代码几乎每天都在间接使用它——哪怕你自己没有意识到。模板方法模式的核心思想其实特别朴素把不变的流程放到父类里定义好把容易变化的步骤交给子类去实现。这个思路在 Spring 里被做到了极致从容器的启动流程到 JdbcTemplate 的查询执行到处都能看到它的影子。这篇文章不会只停留在类图和名词解释上。我会先用一个真实的业务场景写一个完整的 Java 示例让你能直接照着抄再深入 Spring 源码看两个最有代表性的应用形态最后聊一些我在实际项目中踩过的坑。不管你是刚接触设计模式的新人还是已经写了几年代码但想系统梳理一下模式应用的老手这篇文章应该都能给你一些启发。1. 模板方法模式到底解决了什么问题1.1 从一段重复代码说起先回到一个我早年做过的项目。当时系统里有好几个导出功能用户列表导出、订单列表导出、对账单导出。表面上看这三个功能各自独立但你把实现代码放在一起对比就会发现它们的流程框架几乎一模一样定义表头、查询目标数据、把数据转换成可写的字符串行、写入文件、日志记录、异常处理。区别只在于表头字段不同、数据来源不同、格式化某些单元格的逻辑不同。当时刚入行的同事把这三个导出功能写成了三份几乎复制的代码每次产品经理提一个新的导出需求他就把之前的代码复制一份改改字段名和查询语句就交付了。看起来效率很高但问题在第二个迭代就暴露了——有一回公司要求所有导出文件的编码统一从 GBK 改成 UTF-8并且需要在文件末尾追加一行生成时间。他拿着这份需求改了第一个导出类改了第二个到第三个的时候漏掉了追加生成时间的逻辑结果线上导出的对账单文件没有时间戳被运营同事找过来排查了半天。这就是典型的重复代码问题。流程骨架是稳定的变化的只是其中几个局部步骤。如果你把骨架也复制一遍那么任何跨功能的修改都要在所有副本上同步执行一遍漏一个就是线上故障。模板方法模式想做的事情就是把这个骨架抽到父类里固化下来只写一次然后把变化的步骤设计成抽象方法或钩子方法让每个子类只关注自己的差异点。1.2 经典定义与角色划分模板方法模式的定义像它的实现一样简洁在一个方法中定义一个算法的骨架把某些步骤延迟到子类中实现使得子类可以在不改变算法整体结构的前提下重新定义算法中的某些特定步骤。注意这句话的重点是“延迟”。就是说父类已经把流程编排好了但它故意不写死某个步骤的具体逻辑而是把这个步骤的细节留给继承它的类去填充。这个模式涉及的经典角色只有两个。一个是抽象模板类它在自己的模板方法里定义了整个流程的步骤顺序同时声明若干个抽象方法或钩子方法这些方法就是留给子类发挥的空间。另一个是具体模板类它继承抽象模板类重写那些抽象方法把骨架中延迟的部分补全。这里的模板方法通常会用final修饰防止子类修改整个流程的编排顺序——这是很容易被忽略的细节后面我会专门展开讲。认识这个模式还有一个特别适合记的口诀叫好莱坞原则“不要给我们打电话我们会给你打电话。”意思是父类控制整个流程子类不要主动去调父类的逻辑而是等待父类在合适的时间回调你重写的那些方法。理解了这个原则你去看 Spring 的源码或者 JDK 里的很多抽象类都会轻松很多因为你会发现它们的结构和模板方法如出一辙。2. 写一个能直接抄作业的 Java 示例2.1 场景设定与抽象骨架设计为了把模板方法讲明白我设计一个导出器的场景把这个模式从定义到落地完整串一遍。假设现在要为用户列表和订单列表各提供一份 CSV 导出功能两者的导出一致流程如下第一步定义表头第二步查询数据库拿到原始数据第三步把原始数据解析成 CSV 的字符串行第四步把表头和所有数据行写入文件第五步根据配置决定是否需要追加汇总信息。通过分析这个流程能明确哪些步骤适合留在父类哪些步骤适合设计成抽象方法。流程本身和最后一步落地到文件的行为对两个导出器来说是相同的所以应该放在父类里。而表头、数据来源、数据格式转换这三块内容是完全不同的需要设计成抽象方法交给子类。模板方法用 final 修饰整个流程的执行入口就在这里。public abstract class DataExporter { /** * 模板方法定义了整个导出流程的骨架子类不得重写 */ public final void export(String filePath) { ListString headers createHeaders(); ListObject rawData loadData(); ListString rows convertToStringRows(headers, rawData); writeToFile(filePath, rows); // 通过钩子方法判断是否需要写汇总行 if (needSummary()) { appendSummary(filePath); } System.out.println(导出完成 filePath); } /** 抽象方法表头必须由子类定义 */ protected abstract ListString createHeaders(); /** 抽象方法数据来源由子类决定 */ protected abstract ListObject loadData(); /** 抽象方法如何把业务对象转成 CSV 行由子类决定 */ protected abstract ListString convertToStringRows(ListString headers, ListObject rawData); /** 公共步骤文件写入逻辑固定在父类包含编码处理、异常兜底 */ private void writeToFile(String filePath, ListString rows) { // 这里统一处理 UTF-8 编码和 IO 异常子类无法干预 } /** 钩子方法默认不需要汇总子类按需重写 */ protected boolean needSummary() { return false; } /** 钩子方法默认实现为空子类可重写追加汇总逻辑 */ protected void appendSummary(String filePath) { // 默认不做任何操作 } }这个设计里有两个关键细节。第一个细节是把共性步骤writeToFile声明成private说明这一步完全由父类掌控子类既不需要知道也不需要干预。第二个细节是预留了两个钩子方法needSummary()决定了是否执行追加汇总的步骤appendSummary()决定了汇总内容怎么生成。两个方法都有默认实现所以子类可以不重写它们——这是钩子方法和抽象方法的本质区别抽象方法你不实现就编译不过而钩子方法像是一个预留的扩展点用不用它完全看子类的意愿。2.2 子类实现差异部分落到具体类抽象模板类定义好之后子类的写法就非常清爽了。用户导出器只需要关注三件事用户的表头是什么、从哪拿到用户数据、用户对象怎么转成 CSV 行。其余的骨架逻辑父类都已经编排完毕子类完全没有必要也不允许去重写export方法。public class UserExporter extends DataExporter { Override protected ListString createHeaders() { return Arrays.asList(用户ID, 用户名, 手机号, 注册时间); } Override protected ListObject loadData() { // 伪代码实际项目中换成真实查询逻辑 return userMapper.selectAllUsers(); } Override protected ListString convertToStringRows(ListString headers, ListObject rawData) { ListString rows new ArrayList(); rows.add(String.join(,, headers)); for (Object obj : rawData) { User user (User) obj; rows.add(String.join(,, String.valueOf(user.getId()), user.getName(), user.getPhone(), user.getRegTime().toString())); } return rows; } Override protected boolean needSummary() { return true; } Override protected void appendSummary(String filePath) { // 追加一行用户总数 int total userMapper.countUsers(); String summary 总用户数, total; // 伪代码定位到文件末尾追加 summary } }订单导出器的写法类似只是把表头换成订单号、下单用户、商品名称、金额、下单时间数据来源换成订单表单元格格式化的时候把金额保留两位小数。两个具体类互不干扰甚至以后加一个商品导出器只需要继承DataExporter补上三个抽象方法就够了。这一处代码设计带来的直接收益就是后续再出现类似导出需求时开发成本从“复制粘贴整段逻辑”降级成了“只写差异代码”。把这段代码和文章开头我提到的社区案例对比就能明显感受到差别。复制粘贴的做法表面上看省事实际上每一次上游需求的调整都会变成一次全量排查而且排查还不一定能排查干净。模板方法的做法把公共变更集中在了父类这一处修改变更一次覆盖所有子类同时每个导出器内部又保有充分的自主空间。2.3 钩子方法让子类选择性地干预流程很多刚接触模板方法的人会问钩子方法到底有什么用它明明有一个空实现重不重写都不影响流程跑通。直接回答这个问题之前可以想一下服务员在餐厅里上菜的流程。服务员需要先确认顾客入座、然后上菜、最后核对账单这三步是固定流程。但要不要询问顾客是否需要加水服务员到底问不问以及加水之后放不放一片柠檬就是可选动作。这些可选动作如果做成流程的一部分强制要求每个人都做那餐厅体验就会变得机械如果做成不用做的动作那一部分追求细节的服务员又会觉得没法施展。钩子方法就是流程中的这些可选动作。它们存在的意义不是强制子类去扩展什么而是给子类提供了一个在不影响骨架的前提下干预流程的口子。在我们这个例子中needSummary()被设计成钩子意味着导出器预留了“汇总”这个能力但默认关闭。用户导出器选择打开这个能力并提供具体的汇总逻辑订单导出器则完全无视钩子直接复用默认行为。骨架逻辑一点都没有变但不同子类的行为已经不一样了。这里要顺便纠正一个很多初学者会犯的毛病把所有步骤都设计成抽象方法。如果我把needSummary也声明成protected abstract boolean needSummary()那么订单导出器就不得不写一个模板似的return false而这类模板代码堆积多了子类会变得越来越臃肿。正确的设计原则是必须由子类提供的核心差异点用抽象方法可选的行为扩展点用钩子方法并给钩子方法提供安全合理的默认实现。3. Spring 框架里的模板方法应用3.1 AbstractApplicationContext.refresh()最好的教材如果要在 Spring 体系里找一个模板方法模式最纯正、最典型的应用我首选AbstractApplicationContext的refresh()方法。这个方法定义了整个 IOC 容器的启动流程在整个 Spring 生命周期中都不可替代。如果你读过相关的源码你会直观地看到它就是一个大骨架排列着十几个步骤每个步骤都是一个方法调用。而其中相当一部分方法要么是抽象方法要么是空实现的钩子方法等待子类去填充。Override public void refresh() throws BeansException, IllegalStateException { synchronized (this.startupShutdownMonitor) { prepareRefresh(); ConfigurableListableBeanFactory beanFactory obtainFreshBeanFactory(); prepareBeanFactory(beanFactory); try { postProcessBeanFactory(beanFactory); invokeBeanFactoryPostProcessors(beanFactory); registerBeanPostProcessors(beanFactory); initMessageSource(); initApplicationEventMulticaster(); onRefresh(); registerListeners(); finishBeanFactoryInitialization(beanFactory); finishRefresh(); } catch (BeansException ex) { // 异常处理逻辑 } finally { // 清理工作 } } }这段代码就是教科书级的模板方法。refresh()是模板方法整个流程被final级别的框架设计固化了下来任何子类都无法改变步骤编排。postProcessBeanFactory默认是一个空实现的方法它的作用是留出空间让子类在标准 Bean 工厂创建之后进行额外的定制。onRefresh同样是一个钩子方法默认不干任何事情但它是整个模板方法中最能说明问题的一个扩展点。说到onRefresh就必须提一下 Spring Boot 的嵌入容器设计。在我们常用的ServletWebServerApplicationContext里子类重写了onRefresh()方法在这里创建内嵌的 Tomcat、Jetty 或者 Undertow 服务器从而完成 web 容器与 Spring 容器的对接。如果只看 Spring 的核心流程你会发现容器启动到某一个环节空空如也好似什么都没做但放到 Spring Boot 的上下文中这个空的位置正是 web 服务器被拉起来的地方。这就是模板方法模式中钩子方法的真实写照——骨架留一个坑位谁需要谁重写不需要的默认忽略。因为refresh()方法承载了 Spring 容器生命周期中几乎所有的核心逻辑所以读懂它的骨架等于读懂了 Spring 的启动流程。建议你可以亲自打开源码对照上面列出的步骤逐个看一遍再重点去查看子类对onRefresh的实现体验会非常深刻。3.2 JdbcTemplate 与 RestTemplate回调式模板方法变体如果只说refresh()你可能会形成一个印象模板方法必须依靠继承必须通过子类重写来实现。这确实是最经典的形式但 Spring 里还有一大批以Template结尾的类比如JdbcTemplate、RestTemplate、TransactionTemplate它们的实现思路是对模板方法的另一种演绎——固定骨架加回调注入。以JdbcTemplate为例一条 SQL 查询不管具体语义是什么执行路径都是雷同的从数据源拿连接、创建预处理语句、绑定参数、执行 SQL、遍历结果集、处理异常、释放资源。这段路径既然对所有 SQL 都是相同的显然应该固化到公共类里。但如果让每个 DAO 都继承一个AbstractJdbcTemplate再重写步骤方法那继承体系会迅速失控因为一个 DAO 通常有太多的数据库操作不可能为每次操作搞一个子类。Spring 给出的解法是把执行过程的骨架放在execute方法里把变化的部分抽象成回调接口。调用方只需要传入PreparedStatementSetter去绑定参数传入ResultSetExtractor或RowMapper去处理结果JdbcTemplate 负责在合适的时间反向调用这些回调。你可以把它理解为模板方法模式的组合版骨架固定和流程编排仍然是模板方法的思想但具体延迟实现的步骤不再通过继承子类来完成而是通过传入的策略对象注入。public T T query(final String sql, final ResultSetExtractorT rse, final Object... args) { // 内部调用 execute 方法完成连接获取、语句创建、参数绑定、执行、异常转换、资源释放 return execute((PreparedStatementCallbackT) ps - { // 框架把解析好的参数绑定到语句上 // 执行查询并获得 ResultSet return rse.extractData(rs); }); } // 实际使用时的形状 ListUser users jdbcTemplate.query( select id, name, phone from user where status ?, (rs, rowNum) - new User(rs.getLong(id), rs.getString(name), rs.getString(phone)), 1 );RestTemplate也是同样的套路。它把 HTTP 请求的执行骨架固化下来即连接管理、请求发送、响应接收、异常处理而变化的部分如 URI 拼装、请求体序列化、响应体反序列化分别让开发者通过requestCallback和responseExtractor这类回调来定制。整个请求流程的顺序由 RestTemplate 主导你写的代码只是一些颗粒度更小的填充这和模板方法的思想完全吻合。所以当你分析 Spring 中Template族类时要有一个清醒的认知它们并不总是“继承式模板方法”更多时候是“模板方法骨架 回调注入”的融合形态。认识到这一点你再看这些源码的时候就不会拿传统的继承关系去死板对照了。3.3 两种形态各有什么取舍既然 Spring 里两种形态都在用那实际项目里到底该选哪一种这个问题没有绝对的标准答案但我可以通过一个表格把这两类做一次对比帮你理清思路。对比维度继承式模板方法回调式模板方法变体扩展方式创建子类重写抽象方法或钩子方法调用方传入回调对象通常是 Lambda 或匿名类变化点隔离变化点集中在子类内部变化点由调用方控制随用随传灵活性相对受限一个子类对应一套固定配置更灵活同一套骨架可以搭配无数回调组合侵入性子类必须继承抽象类破坏现有继承关系时可考虑规避调用方不需要继承特定类组合优于继承适用场景算法骨架非常稳定扩展点有限且子类之间是一族兄弟概念骨架稳定但变化点数量极多且希望外部自由定制就 Spring 本身而言refresh()之所以采用继承式是因为整个容器上下文确实天然存在父子层级各种ApplicationContext实现是一族兄弟它们共享refresh的骨架流程只是个别步骤根据应用类型不同而改变。而JdbcTemplate之所以采用回调式是因为 SQL 操作的变化点太丰富如果全部通过继承来覆盖每个业务 DAO 都可能要为不同的 SQL 建一个子类这种设计显然不下去。我们在自己做框架封装时也可以参考这个取舍逻辑如果你的逻辑是给项目提供少数几个固定变体用继承式模板方法精简如果你的逻辑是要被几十个业务方高度定制地使用用回调式模板方法更合适。这也是我后来在公司内部写公共组件时最常参考的判断标准。3.4 顺手盘点JDK 里那些隐藏的模板方法反过来再看 JDK其实模板方法也到处都在。Servlet 开发是大多数人最早接触模板方法的场景。HttpServlet的service()方法就定义了一个标准的处理流程解析请求方法、调用对应的doGet、doPost、doPut等方法。我们平时写 Controller 或者 Filter 时通常只重写其中一个doXxx方法而不去碰service本身的流程编排这里的doXxx就是模板方法中的钩子方法。java.io.InputStream也是一个不错的例子。类中的设计思路是只留一个抽象核心方法read()给子类实现然后基于这个基础方法派生出一系列公共方法如read(byte[])、read(byte[], int, int)它们内部实际上是把读取一个字节的操作循环调用多次。这个过程体现了模板方法的关键逻辑父类通过一个抽象入口定义了公共读取流程子类只需实现最原子的一步其余行为就都水到渠成。AbstractList也是同理。它把通过下标获取元素的操作定义成抽象方法get(int)然后基于这个抽象方法实现了contains、indexOf、subList等很多通用逻辑。你自定义一个简易集合时只需要继承它并实现get和size几乎免费的得到了全套方法。分析这些 JDK 类时只要带着模板方法的视角就会发现这种“父类编排骨架子类填充细节”的模式是 Java 生态中非常底层的设计习惯。4. 实战避坑与重构要点4.1 用 final 或私有方法稳稳锁住骨架实战中模板方法模式最容易翻车的点就是骨架被破坏。我在某个项目中见过这么一个场景一个业务抽象类里定义了流程骨架process()然后有人觉得某个中间步骤的日志不够详细就在子类里重写了整个process()方法复制了父类一大段逻辑然后插入了自己的日志剩下的步骤调用了super.process()。第一次看好像没问题但下一次父类流程调整时子类里的旧流程没同步改线上行为直接错乱排查了半天才发现是这个重写拦截了更新后的流程。要避免这种问题两个措施缺一不可。第一骨架入口方法尽量声明成final子类无法重写它自然也就断掉了覆盖骨架这条路。第二所有公共步骤但凡子类不应干预的全部声明成private不要给子类留出任何可以挂钩的假象。如果确实想留注入点再显式设计成protected钩子方法并且用 Javadoc 写清楚这个钩子的默认行为、触发时机以及推荐重写的场景。这样可以保证骨架从设计上就是安全的而不是依赖代码评审来拦截违规。4.2 钩子方法要有边界感钩子方法给模板方法带来了灵活性但灵活过头就会牺牲可读性。我把钩子方法泛滥的代码翻出来看常见的情况是一个抽象模板类里塞了六七个钩子每个钩子都能改变流程的某个局部行为。子类到底应该重写哪几个钩子、钩子之间有没有依赖关系、哪些钩子会改变后续节点的判断逻辑——这堆问题连维护者自己都说不清楚。我的实践体会是一个模板类里的钩子最好控制在两三个以内而且每个钩子只负责一件事。如果一个流程里有太多个“可选动作”就可能意味着这个骨架承担了过多的职责模板方法本身就不够内聚。合理的做法是把这些可选动作单独拆分出来交给组合的回调对象或者策略对象去处理而不是全部揉进模板方法里。识别钩子是不是多余的有个简单尺度如果百分之八十以上的子类都要重写某个钩子才能正常工作那它当初就不该是钩子而应该直接被声明为抽象方法让编译器强制子类实现。4.3 模板方法与策略模式的分水岭这两个模式容易被搞混因为它们都在处理“变化”和“复用”的问题。我的区分方法非常简单模板方法模式固定的是整个算法的流程结构变化的只是流程中某个步骤的实现细节策略模式则是整体替换算法的实现连流程结构一起换。还是拿数学运算来举例。如果骨架是“输入两个数、执行一步运算、输出结果”而具体是加法还是乘法只是流程中的某一步不同这属于模板方法的范围。如果你的计算程序允许调用方传入一个完整的“计算引擎”加法引擎、乘法引擎、复杂积分引擎都能整体替换而且替换后引擎内部的流程它自己决定这就是策略模式。一句话模板方法关注的是流程复用策略模式关注的是算法整体互换。Spring 里JdbcTemplate同时给了你一个体会两者差别的绝佳场景。JdbcTemplate 本身的执行骨架属于模板方法思想而你传入的RowMapper这种回调实际上是策略对象它决定的是结果映射这一步采用的策略。一个模板方法里嵌套着策略对象这两者根本不是互斥的在实践中它们经常合作出现。5. 常见问题速查与面试高频题5.1 面试中这题这么答不会丢分模板方法是面试中比较稳妥的题目考察点基本落在三块能不能讲清楚定义和角色、能不能给出一个真实的框架应用、以及能不能准确区分和策略等相近模式的关系。我推荐按照“一句话定义 一个真实案例 一句延伸思考”的结构来答。先概括模板方法是在父类中定义算法骨架把某些步骤延迟到子类实现让子类在不改变整体流程的前提下重定义特定步骤。然后抛出案例比如 Spring 的AbstractApplicationContext.refresh()它在父类定义了完整容器启动流程onRefresh()是空实现的钩子Spring Boot 的ServletWebServerApplicationContext通过重写它完成了内嵌容器的启动。最后做区分强调模板方法与策略的区别在于一个固定骨架、一个可整体替换算法。如果面试官接着追问 Spring 里的JdbcTemplate算不算模板方法可以顺着说明模板方法有两种形态经典继承式和回调变体式。JdbcTemplate属于后者它把骨架相同、变化多端的执行步骤通过回调接口开放出来组合方式更加灵活。能够讲到这一层的同学思路通常已经比较完整了。5.2 快速识别代码里的模板方法读代码的时候怎么快速判断某个类用了模板方法我总结了一套识别信号命中其中两三条基本就没跑了。首先看继承结构父类里是否存在一个可执行的公共方法这个方法内部没有实现全部逻辑而是调用了一部分抽象方法或受保护的空方法。其次看子类行为子类写起来像是一份填空题只实现父类规定的若干个方法却不会主动重写父类流程入口。再次看 final 修饰符如果骨架入口被 final 锁住了几乎可以认定这里刻意使用了模板方法目的是强制子类遵守流程。最后看钩子命名一些方法名中带do前缀的比如doGet、doPost、doCreate、doUpdate很大程度上就是从模板方法里拆出来的扩展步骤。这套识别方法我在评审新人的代码时也经常用。有一次看到一个同事写了一个抽象类里面有一大堆模板方法当时我告诉他单独一个抽象类不能只靠名字分清楚是什么模式要看方法之间的调用关系他说看了之后把骨架入口加锁、抽象步骤单独整理在后续的重构里很快就看到了可读性提升的效果。5.3 重构成模板方法的最优操作顺序日常业务里也有不少重复代码具备重构空间。如果你想领着一批类似流程的代码重构成模板方法我的建议是按这个顺序操作第一步找出两段流程最相似的代码块把反复出现的固定步骤用笔记列出来第二步确定这些固定步骤里哪些会变、哪些不会变把不会变的步骤作为父类的私有方法固定住第三步声明一个抽象类把完整流程复制进模板方法把变化位置替换成抽象方法或钩子方法第四步让原先的两段代码各自变成一个子类只补上差异方法跑通测试对比结果第五步验证后续需求变更时只用修改父类一个地方。这套流程里最耗时的是第二步因为识别“哪些会变、哪些不会变”需要你真正理解业务的变化趋势。我见过不少重构失败案例都是把表头这种高频变化的地方写成了私有方法把编码格式这种很少变的公共逻辑写成了抽象方法导致后续扩展依然要到处改。判断这个得结合业务经验宁可多问几个问题也不要在重构时想当然。从我个人的实践感受来说模板方法不是一个花哨的模式它更像是一个朴素的工程习惯把不变的沉淀下来把变化的暴露出去让别人只关心自己需要关心的那一小块。学习这个模式最大的收获不是记住了几个角色和几个方法名而是学会在写任何有一定重复逻辑的代码时先停下来问自己一句这里哪些步骤是永远不变的哪些步骤是必然要变的想清楚这个自然就会走向模板方法的思路。最后再分享一个看源码的小技巧。你读完这篇文章后去翻一翻 Spring 的AbstractApplicationContext.refresh()方法不需要把每一行都看明白就做一件事把那个大方法里的步骤调用挨个看一遍凡是你能在子类里搜到重写实现的方法都说明它是一个被精心设计出来的扩展点。看懂这一步模板方法的精髓基本上就通了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑