ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

模板方法模式实战:用Java重构报表导出模块,代码量减半

模板方法模式实战:用Java重构报表导出模块,代码量减半 模板方法模式在23种经典设计模式里属于那种“平时不起眼、用好了真能救命”的类型。作为一个几乎天天跟 Java 打交道的后端开发我第一次意识到它的价值是在接手一个报表导出模块的时候——六个导出方法逻辑长得几乎一模一样只是输出格式不同打开资源、写表头、遍历写数据、关闭资源这套流程被复制粘贴了无数遍。后来领导提了一个需求所有导出都要加操作日志。我改完一个方法还要再改五个改到第三个的时候我就知道这事必须用设计模式解决。后面我用模板方法模式把这套逻辑彻底重写了一遍代码量几乎少了一半新增导出格式只需要写差异部分。这篇文章就把我这次重构的全过程、踩过的坑、总结的套路原原本本分享出来。内容会从模式原理讲到 Java 实现细节再带一个完整的实战案例最后聊聊框架里的模板方法以及它和策略模式怎么配合。无论你是刚学设计模式的在校生还是写过几年业务代码想提升代码质量的后端开发这篇文章应该都能给你一些实实在在的参考。1. 模板方法模式定义、角色与适用场景1.1 一句话理解模板方法模式模板方法模式的定义其实非常朴素在一个方法中定义算法的骨架而将一些步骤延迟到子类中实现。这里的“算法”不是狭隘的排序、加密算法而是指“完成某件事的固定步骤”。我更喜欢用做饭来打比方。同样是做“番茄炒蛋”步骤永远是固定的备菜、热锅、倒油、炒蛋、加番茄、调味、出锅。这七个步骤就是“算法骨架”但每个人在“炒蛋”这个步骤上手法不同——有人喜欢嫩滑有人喜欢焦香。你完全可以把这套固定流程写到父类里然后把“炒蛋”这个步骤抽象出来让不同的人按自己的口味去实现。这就是模板方法模式。对应到 Java 代码里固定流程就是父类里的“模板方法”被抽象出来的步骤就是“抽象方法”或“钩子方法”。子类不用关心整个流程怎么组织只需要关心自己那部分怎么实现。1.2 三个核心角色的职责划分模板方法模式通常涉及三个角色理解这三个角色基本就理解了这个模式的一半抽象模板类AbstractClass定义算法的骨架包含一个模板方法和若干个抽象方法、具体方法。模板方法负责编排调用顺序它通常被声明为 final防止子类修改流程。具体子类ConcreteClass实现父类声明的抽象方法从而完成算法中与特定子类相关的步骤。子类可以覆盖钩子方法对算法的不同节点做干预。客户端Client面向抽象模板类编程调用模板方法完成整个流程。客户端不需要关心内部步骤的细节只关心最终结果。这三个角色里最容易忽略的是“抽象模板类”的职责。它不只是一个简单的“父类”而是一个流程的制定者。它决定了哪些步骤必须有、哪些步骤可以没有、步骤的顺序是什么、步骤之间怎么衔接。1.3 什么时候该用什么时候别硬用我见过不少人学了设计模式之后恨不得把所有代码都套上模式结果适得其反。模板方法模式不是万能的它有自己的适用边界。适合用的情况多个子类有公共的行为并且这些行为的执行顺序基本一致。比如多个报表导出、多种消息推送、多类数据解析流程都遵循固定的“打开、处理、关闭”套路。你想把公共逻辑抽取出来放到父类避免子类重复代码。这是最直接的动机。你需要控制子类对流程的扩展范围希望子类只修改某些步骤而不是整个流程。不适合硬用的情况各类子类之间的公共逻辑很少强行抽象会让父类变得臃肿子类被迫实现一堆用不上的抽象方法。算法步骤的执行顺序在运行时可能变化模板方法模式是编译期就确定好的结构它不适合流程频繁变化的场景。这种情况更适合用策略模式后续第 5 章会专门对比。项目已经比较稳定复制粘贴的成本反而低于重构风险。这个说法可能有点反直觉但真实项目里“不值得动”的情况确实存在只有在后续还会继续迭代时才值得重构。注意设计模式是为了解决问题而存在的不是为了体现技术水平而存在的。判断标准很简单——如果你的代码明天还要改那重构是值得的如果这模块下个月就要退役记住不动反而是一种明智。2. Java实现的关键细节骨架、钩子与修饰符2.1 模板方法为什么选抽象类而不是接口Java 里实现模板方法模式通常选抽象类而不是接口。这不是随便选的背后有明确的语法原因。模板方法模式的核心是“在父类中写一个完整的模板方法并且让子类无法修改它”。要做到这一点模板方法本身必须用 final 修饰。而接口里的方法默认都是 public abstractJava 8 之后虽然有 default 方法但 default 方法本质上还是可以被实现类覆盖的它没办法约束“不可重写”。抽象类就不同了。你可以声明一个 final 的模板方法把整个流程锁死也可以声明 protected 的钩子方法给子类留出扩展余地还可以声明 abstract 方法强制子类实现。这种“锁死一部分、开放一部分、强制一部分”的灵活度是接口做不到的。再看一个细节模板方法里往往会调用多个步骤方法这些步骤方法有些是 private有些是 protected有些是 public。接口根本没有 private 和 protected 方法的位置Java 9 以后虽然接口可以有 private 方法但 protected 依然不行。所以从访问控制的角度抽象类才是模板方法模式的正确选择。2.2 final、protected、abstract 怎么搭配才合理这一节是比较容易被忽略的但恰恰是模板方法模式写得“专不专业”的分水岭。修饰符搭配不好模式就失去了意义。模板方法本身用 final。这个必须强调。模板方法是流程的骨架如果它被子类重写了整个模式就名存实亡。子类可以重写步骤方法但不能改变步骤的组合顺序。用 final 锁死模板方法是模式生效的前提。必须由子类实现的步骤用 abstract。比如上面报表导出里的“写表头”“写数据”每个导出格式完全不同必须强迫子类实现。一个抽象类里 abstract 方法不宜过多通常控制在 3 到 5 个以内否则子类的实现负担太重也说明你的抽象粒度可能有问题。子类可选的步骤用 protected 钩子方法。钩子方法在父类里通常给一个空实现或默认实现子类按需覆盖。它用 protected 修饰对外部完全隐藏对子类开放。这比 public 更符合封装原则也避免了外部代码直接调用内部步骤。纯内部步骤用 private。如果某个步骤是流程内部的固定逻辑不需要子类参与直接写成 private 方法连 protected 都不要开。这一步很多人会漏掉把不该开放的步骤全部 protected 了结果子类里冒出一堆覆盖错误的方法非常难看。2.3 钩子方法的实战价值钩子方法Hook Method是模板方法模式里最灵活、也最容易被误解的一个概念。很多人学了模板方法只知道 abstract 方法不知道钩子方法结果写出来的代码僵硬得像一块铁板。钩子方法的本质是父类在流程中预留一些“挂钩点”子类可以选择在这些点上挂上自己的逻辑也可以什么都不挂。父类调用钩子时如果子类没覆盖就走默认逻辑覆盖了就走子类逻辑。最常见的钩子用法是用来做“条件判断”。比如一个数据导出模板方法里可以在“写表头”之前加一个钩子shouldWriteHeader()默认返回 true。如果某个导出格式不需要表头子类只要覆盖这个方法返回 false模板方法就会自动跳过写表头的步骤。这种做法的好处是流程的控制权依然在父类手里子类只是“表达意图”而不是“修改流程”。另一个典型钩子是“后置通知”比如afterProcess()。父类在流程跑完后调用它默认实现为空。子类如果需要在导出成功后发送通知、记录日志、清理临时文件只需要覆盖这个钩子即可不会污染主流程代码。钩子方法的价值在于把“变化点”从主流程中剥离出来。主流程永远是稳定的而变化点全部集中在子类的钩子覆盖里代码读起来非常清晰——我只要看父类的模板方法就知道整个流程长什么样再看子类覆盖了哪些钩子就知道这个子类特殊在哪。2.4 让子类“必须做”和“可以做”的分界线设计模板方法模式时最核心的设计决策就是哪些步骤让子类必须实现哪些步骤让子类可以覆盖哪些步骤根本不开放。这个分界线划得准不准直接决定这个模式的可用性。我把这个决策总结成一个三层模型强制层子类必须实现的 abstract 方法。这些方法是算法中真正“因类而异”的部分。比如导出格式不同写数据的方式就必然不同。这一层不能太多太多会让子类的实现者崩溃。开放层子类可选的 protected 钩子方法。这些方法是算法中可以扩展、可以干预的节点。比如导出前的数据校验逻辑不是每个导出都需要但确实可能存在。封闭层父类私有的 final 或 private 方法。这些是算法中完全固定的部分比如资源的关闭、日志的打印。这一层存在的意义是保证子类无法破坏流程的完整性和安全性比如防止子类忘记关闭文件流。实际操作中我建议先列清单把这个业务流程涉及的步骤全部写出来然后逐个问自己两个问题——“这个步骤是否因类而异”“子类如果需要调整这个步骤会不会破坏整个流程”然后按答案归到对应的层里。经验拿不准的时候倾向于少开放。因为开放出去的步骤后续要想收回就很难了。先封闭等真有子类需要扩展时再加钩子成本并不高。3. 实战案例用模板方法模式重构报表导出模块3.1 需求背景与方案设计这个案例是我真实处理过的一个场景。当时系统里有报表导出的功能最先只支持 CSV 格式代码写在一个很长的 Service 方法里。后来产品那边要求增加 Excel 格式导出我没有继续往原方法里塞逻辑而是挑了个时间做了重构。我先把两个导出流程列出来对比流程步骤CSV 导出Excel 导出打开资源创建 FileWriter创建 XSSFWorkbook写表头写一行逗号分隔字符串创建 Header Row写数据逐行拼接字符串写文件遍历数据集创建 Row 对象关闭资源writer.close()workbook.close()对比之后很容易发现四步流程顺序完全一致只是每一步的具体实现不同。这是非常标准的模板方法模式应用场景。于是我设计了一个抽象模板类AbstractReportExporter里面定义export()这个模板方法然后把四步都抽象出来由子类实现。3.2 第一步定义抽象模板类先写抽象模板类。这里我直接把完整的核心代码贴出来每一步都做注释public abstract class AbstractReportExporter { /** * 模板方法定义整个导出流程的骨架。 * 用 final 修饰不允许子类改变流程顺序。 */ public final void export(ReportData data) { // 1. 导出前检查钩子方法子类可选覆盖 if (!validate(data)) { throw new IllegalArgumentException(导出数据校验不通过); } // 2. 打开目标资源 openResource(data); try { // 3. 写入表头 writeHeader(data); // 4. 写入数据体 writeBody(data); } catch (IOException e) { throw new ReportExportException(导出失败, e); } finally { // 5. 关闭资源无论成功失败都必须关闭 closeResource(data); } // 6. 导出后置处理钩子方法默认空实现 afterExport(data); } /** 钩子方法导出前校验默认放行 */ protected boolean validate(ReportData data) { return data ! null; } /** 钩子方法导出后处理默认什么都不做 */ protected void afterExport(ReportData data) { // 预留扩展点 } /** 抽象方法打开目标资源 */ protected abstract void openResource(ReportData data) throws IOException; /** 抽象方法写入表头 */ protected abstract void writeHeader(ReportData data) throws IOException; /** 抽象方法写入数据体 */ protected abstract void writeBody(ReportData data) throws IOException; /** 抽象方法关闭目标资源 */ protected abstract void closeResource(ReportData data); }这个抽象的模板类有几点值得说明。第一模板方法名直接叫export()外部客户端的入口只有它。第二validate()和afterExport()是钩子方法默认实现分别是放行和空操作子类按需覆盖。第三openResource到closeResource这四个方法是 abstract子类必须实现。第四关闭资源放进 finally 里即使写数据抛异常资源也不会泄漏——这个细节是模板方法模式里很容易被忽略的“骨架责任”。3.3 第二步实现CSV导出器CSV 导出器是第一个具体子类它的职责是完成“逗号分隔文本”格式的完整实现public class CsvReportExporter extends AbstractReportExporter { private FileWriter writer; Override protected void openResource(ReportData data) throws IOException { writer new FileWriter(data.getFileName()); } Override protected void writeHeader(ReportData data) throws IOException { writer.write(data.getColumns() \n); } Override protected void writeBody(ReportData data) throws IOException { for (MapString, Object row : data.getRows()) { writer.write(String.join(,, row.values().stream() .map(String::valueOf).collect(Collectors.toList())) \n); } } Override protected void closeResource(ReportData data) throws IOException { writer.close(); } }CSV 这版子类代码量很小因为它只需要专注“CSV 格式怎么写”。这里是整个模式最直观的收益子类只需要实现差异部分公共流程全部由父类管理。要注意的是closeResource()里做了writer.close()父类的 finally 保证了即使中途出错这个方法也会被执行。3.4 第三步实现Excel导出器Excel 导出器稍微复杂一点因为要用到 Apache POI 的 API但逻辑结构跟 CSV 完全一致public class ExcelReportExporter extends AbstractReportExporter { private XSSFWorkbook workbook; private FileOutputStream fos; Override protected void openResource(ReportData data) throws IOException { workbook new XSSFWorkbook(); fos new FileOutputStream(data.getFileName()); } Override protected void writeHeader(ReportData data) throws IOException { Sheet sheet workbook.createSheet(报表); Row headerRow sheet.createRow(0); String[] columns data.getColumns().split(,); for (int i 0; i columns.length; i) { headerRow.createCell(i).setCellValue(columns[i]); } } Override protected void writeBody(ReportData data) throws IOException { Sheet sheet workbook.getSheetAt(0); int rowIndex 1; for (MapString, Object row : data.getRows()) { Row excelRow sheet.createRow(rowIndex); int cellIndex 0; for (Object value : row.values()) { excelRow.createCell(cellIndex).setCellValue(String.valueOf(value)); } } } Override protected void closeResource(ReportData data) throws IOException { workbook.write(fos); workbook.close(); fos.close(); } }看到这里你应该感觉到了两个子类代码风格基本相同都在做同一件事——实现父类声明的四个抽象步骤。这就是模板方法模式的节奏感子类再多代码套路都是一样的阅读和维护成本被大幅降低。后面如果要加 JSON 导出、PDF 导出只需要再新建一个子类不需要碰任何现有代码。3.5 钩子方法在实际业务中的应用上面的两个子类都是中规中矩的实现。但如果业务再复杂一点钩子方法的威力就体现出来了。假设现在 CSV 导出要求增加一个限制当数据量超过 1 万行时直接抛异常拒绝导出。而 Excel 导出不受这个限制。这个需求如果用常规写法你得在 CSV 的逻辑里单独加一段判断。但有了模板方法只需要在CsvReportExporter里覆盖validate()钩子public class CsvReportExporter extends AbstractReportExporter { private static final int MAX_ROWS 10000; Override protected boolean validate(ReportData data) { if (!super.validate(data)) { return false; } if (data.getRows().size() MAX_ROWS) { System.out.println(CSV 导出数据量超过限制 data.getRows().size()); return false; } return true; } // 其他方法不变 }再比如要求所有导出完成后自动记录日志。这个逻辑放模板方法里不合适因为模板方法被 final 锁死了不能改。正确的做法是覆盖afterExport()钩子。如果所有导出都要记日志就在抽象类里改默认实现如果只有部分导出要记就只在对应的子类里覆盖。钩子方法用多了你会发现它的核心价值不是“省代码”而是让变化点集中。所有业务方自定义的逻辑都集中在子类的钩子覆盖里review 代码时一目了然不用在一个几百行的方法里到处找逻辑。4. 常见问题与排查技巧实录4.1 模板方法被重写导致骨架失效这是最隐蔽的一个坑。模板方法在抽象父类里是 final 的但有些新人不理解 final 的意图把模板方法上方的 final 关键字去掉然后在某个子类里重写了整个 export 方法。表面上看子类重写后好像能“自定义更多”但实际上整个模式的骨架被打破了。父类里精心设计的“资源开关顺序”“异常处理流程”“钩子调用点”全部失效。子类里可能自己重新写了一套流程代码量又膨胀起来而且每个子类的流程可能会有细微差别后续维护的人要逐个看非常痛苦。排查这个问题有个简单方法子类里如果出现了方法名和父类模板方法同名的方法就要格外警惕。正常情况下子类不应该有同名方法。代码 review 时也建议把模板方法加上 final 作为强制规范。4.2 抽象方法泛滥导致所有子类都被迫改代码抽象方法太少公共逻辑抽不干净抽象方法太多子类实现负担过大。这是一个典型的平衡问题。我见过一个极端案例抽象方法有 12 个新增一个导出格式光适配这些抽象方法就写了一百多行空实现。出现这种情况通常说明两个问题一是抽象粒度太细把一些本来可以合并的步骤拆开了二是一些“可变点”其实只有少数子类关心根本不应该设计成抽象方法应该降级成钩子方法。比如只有 PDF 导出才需要设置密码但你把“设置密码”设计成了抽象方法那 CSV 和 Excel 导出就不得不写空实现。解决思路也很简单抽象方法应该是“所有子类都必须有的差异化步骤”钩子方法才是“部分子类可能有的扩展点”。拿不准的时候先用钩子等发现所有子类都实现了这个钩子且逻辑几乎不变再考虑是否提升为抽象方法。4.3 模板方法里调用了可被覆盖的方法导致意外行为这个是模板方法模式在 Java 里的一个经典问题源自“[方法重写与构造器之间的微妙关系]”。具体来说如果抽象父类的构造函数里调用了某个可被重写的方法那么子类实例化时父类构造函数会先执行此时子类还没初始化完成调用重写方法可能拿到空值或默认值导致一系列莫名其妙的空指针。放到模板方法模式里虽然模板方法是实例化之后才调用的一般不会遇到这个问题但如果你在模板方法内部调用的步骤方法里又间接调用了子类覆盖的其他方法嵌套调用链变长后代码阅读和调试都会变得非常困难。我自己定了一条规矩模板方法内部的步骤方法要么是 final 的具体方法要么是 abstract 方法尽量不要再出现“父类调用了一个由子类覆盖的普通方法”这种场景。如果一定要在步骤内部预留扩展点用钩子方法并且明确注释清楚调用关系。4.4 误把模板方法模式当成策略模式最后这个坑属于概念层面的混淆。模板方法模式和策略模式在结构上有些相似都是“父类调用子类实现”的方向但设计意图完全不同。模板方法模式的核心是“定义一个固定的流程只允许填充细节”流程的顺序和结构是死的。策略模式的核心是“定义一组算法运行时可替换”客户端可以选择不同的策略来改变整体行为。打个比方模板方法模式更像是“公司规定的标准化作业流程”你作为员工只能优化环节里的执行方式不能改变流程顺序策略模式更像是“打车时选择快车还是专车”整体服务目标相同但实现路径完全不同可以随时切换。实际项目里两者常常结合出现一个大的模板方法流程中某个步骤的具体实现可以用策略模式来封装让这个步骤在运行时能动态替换。这种组合方式很实用后面第 5 章会展开讲。5. 延伸视角框架中的模板方法与其他模式的配合5.1 Spring、JDK 里那些“看不见”的模板方法模板方法模式在 Java 生态里无处不在只是很多时候你没意识到那就是模板方法。JDK 里最典型的例子就是AbstractList。你继承了AbstractList只需要实现get()和size()两个抽象方法就能得到一个可用的 List 实现。iterator()、add()等方法都有默认实现它们内部会调用get()和size()来构建整个集合的行为。这就是模板方法的经典应用。Spring 里更是大量使用。JdbcTemplate把“获取连接、创建 Statement、执行 SQL、处理 ResultSet、关闭连接”这个固定流程封装起来你只需要实现RowMapper这个差异步骤RestTemplate也是类似结构把 HTTP 请求的发送、响应处理流程固定下来你只需要提供 URL、参数和响应类型。这些框架类本质上都是模板方法模式的优秀实践。理解这一点对你读框架源码很有帮助。以后看到abstract class里有一个public final的方法而这个方法里面又调用了一堆protected abstract的方法你就可以直接断定这就是模板方法模式整个流程结构已经定死了框架在引导你只填写那些差异部分。5.2 模板方法模式与策略模式的协作组合我在第 4.4 节说模板方法和策略常常结合使用这里展开讲一个真实的协作场景。假设我们的报表导出模块要做多语言支持。同样的数据导出到不同的地区表头的文字可能不同。用模板方法模式导出流程的骨架不变但“写表头”这个步骤的具体实现如果还是用继承来做会导致子类数量爆炸——CSV 中文版、CSV 英文版、Excel 中文版、Excel 英文版……这时可以用策略模式来优化把“表头翻译”这个步骤抽象成一个策略接口HeaderStrategy抽象模板类中持有这个策略对象“写表头”步骤把翻译任务委托给策略对象。客户端在使用时可以自由组合“CSV 导出器 中文表头策略”或者“Excel 导出器 英文表头策略”。public abstract class AbstractReportExporter { private HeaderStrategy headerStrategy; public AbstractReportExporter(HeaderStrategy headerStrategy) { this.headerStrategy headerStrategy; } protected void writeHeader(ReportData data) throws IOException { ListString headers headerStrategy.translate(data.getColumns()); // 子类通过 doWriteHeader() 真正写入表头 doWriteHeader(headers); } protected abstract void doWriteHeader(ListString headers) throws IOException; }这个组合方式把“流程固定”和“扩展灵活”两种诉求同时满足了。在真实后端项目里这种组合使用比单独使用模板方法更常见因为业务永远比教科书复杂。5.3 与函数式接口Lambda结合的未来形态Java 8 引入 Lambda 之后模板方法模式的另一种演变形态越来越常见不需要继承抽象类而是把变化步骤直接作为函数式参数传入。这种写法在一些新框架里很流行。比如上面导出模块如果 CSV 导出和 Excel 导出的区别只在“写表头”和“写数据”两个步骤那我们未必非要建两个子类可以设计一个更灵活的导出工具类把步骤以函数式接口传入public class ReportExporter { public void export(ReportData data, HeaderWriter headerWriter, BodyWriter bodyWriter) { openResource(); try { headerWriter.write(data); bodyWriter.write(data); } finally { closeResource(); } } FunctionalInterface public interface HeaderWriter { void write(ReportData data) throws IOException; } FunctionalInterface public interface BodyWriter { void write(ReportData data) throws IOException; } }调用方就可以用 Lambda 直接传步骤实现连子类都不用建了ReportExporter exporter new ReportExporter(); exporter.export(data, d - writeCsvHeader(d), d - writeCsvBody(d));这种方式的优点是去掉了类爆炸代码更简洁适合“步骤不多、结构简单”的场景。但要注意它牺牲了模板方法模式的一个核心优势模板方法结构默认是固定的不会被人改乱。当流程变复杂、步骤变多时函数式参数会变得冗长也不便于复用。所以我个人的实践是简单场景用函数式复杂场景还是用抽象类的模板方法更清晰。模板方法模式是那种学的时候觉得简单、用的时候总出问题的模式。我踩过最深的坑就是在重构一开始把模板方法设计得太具体导致新增类型时不得不反过来改父类。后来想明白一个原则父类只管流程不管业务。所有“未来可能变化”的判断都尽量下沉到子类和钩子里父类保持稳定。按这个原则做了几次重构之后我的抽象类很少再动新增需求基本都是加子类、覆盖钩子、完事。最后再分享一个我自己的使用习惯每实现一个模板方法模式的抽象类我都会在类注释里写明“这个流程总共分几步哪些步是强制的哪些步是可选的钩子”。这个习惯起初是为了方便同事 review后来发现对我自己帮助更大——半年后再回来看代码只看类注释就能快速回忆整个设计意图。设计模式不是炫技它是写给下一个维护者的一封清晰的信。
返回列表