资讯动态

模板方法模式实战:从Java基础到Spring源码,手写报表导出框架

发布时间:2026/10/17 11:09:55 来源:尧图企业网站定制
从模板方法模式这个“老生常谈”聊起。很多人把它归类为“简单到不需要讲”的模式但我可以负责任地说项目里真正的代码复用、流程统一、扩展点设计大多数时候靠的都是它。你在Spring里天天用的JdbcTemplate、RestTemplate、RedisTemplate骨子里全是模板方法那一套思想。这篇博文从Java基础示例讲到Spring源码级应用再给出一套可直接落地的导出框架代码希望能帮你在新项目里少写几百行重复流程。1. 模板方法模式的核心设计意图1.1 一句话说清楚它到底在干什么模板方法模式的定义非常直白在一个方法中定义算法的骨架而将一些步骤延迟到子类中实现。父类负责“流程编排”子类负责“填空”。这个模式解决的是最痛的重复代码问题——多个业务方法有着相似的步骤顺序比如“校验参数→查询数据→组装结果→输出”如果每个子类都写一遍完整流程你会看到大量只改了中间一两行、前后几乎一模一样的复制粘贴代码。模板方法模式把这些公共流程上提到抽象类里把变化点抽成抽象方法或可覆写方法让子类只管自己的差异。它的本质是“控制反转”的一种体现业界也叫它“好莱坞原则”——不要打电话给我们我们会打给你。子类不再控制流程什么时候执行、以什么顺序执行而是被动地被父类的模板方法调度。1.2 模式结构里的四个关键角色很多人学了模板方法模式只记住抽象类和子类其实完整的结构包含四个部分。AbstractClass抽象类定义模板方法骨架声明若干个抽象方法供子类实现。TemplateMethod模板方法由抽象类实现的不可覆写方法编排整个流程顺序。PrimitiveOperation原语操作抽象类中定义的抽象方法子类必须实现。Hook钩子方法抽象类中提供了默认实现的方法子类可以选择性覆写用来影响流程中的某个分支。模板方法通常建议声明为final。这个细节很多人忽略如果不加final子类一旦把整个流程覆写了前面所有设计全部作废。我在后文的示例里会强制用final。1.3 什么时候适合用什么时候千万别硬套适合场景很典型多个子类共享同一个固定流程流程的步骤顺序基本不变但某几个步骤的具体实现差异很大。比如批量数据导入、多格式报表导出、文件解析、请求处理管道这些都是天然适配场景。用完场景也很明确如果业务步骤不稳定隔三差五要增删步骤或者子类之间的差异大到“除了名字一样其他地方都不像”模板方法模式就不合适了。硬套的结果是抽象类里堆满了if (hook)判断、抽象方法十几个、子类实现时一大半方法是空实现这种代码比复制粘贴还难维护。我的经验是先观察后抽象。等到第三个类出现相同的流程结构时再抽模板方法不要第一版就强行设计骨架。过早抽象出来的骨架往往猜错了变化点。2. Java示例详解从零手写一套模板方法框架2.1 经典实现数据导入处理器这套示例是我在实际项目中用过的场景简化版。需求支持从CSV、Excel、第三方接口三种来源导入数据导入流程完全相同——校验文件、解析数据、转换结构、落库、通知结果。先看抽象基类public abstract class AbstractDataImporter { public final void importData(File file) { validateFile(file); ListRawData rawData parseFile(file); ListDomainData domainData convert(rawData); save(domainData); if (needNotify()) { sendNotification(domainData.size()); } } protected final void validateFile(File file) { if (file null || !file.exists()) { throw new IllegalArgumentException(数据文件不存在); } if (!isLegalFileType(file)) { throw new IllegalArgumentException(不支持的文件类型); } } protected abstract boolean isLegalFileType(File file); protected abstract ListRawData parseFile(File file); protected abstract ListDomainData convert(ListRawData rawData); protected abstract void save(ListDomainData data); protected boolean needNotify() { return false; } protected void sendNotification(int count) { System.out.println(导入成功共 count 条数据); } }模板方法importData把整个流程写死校验→解析→转换→保存→通知。子类只需要实现parseFile、convert、save这三个原语操作。needNotify是钩子方法默认不通知CSV封装类如果想在夜间批量导入时发送消息提醒覆写这个方法返回true即可。需要注意的是validateFile声明为final并在内部调用了抽象方法isLegalFileType这是一个很容易被忽略的设计点——父类的非抽象方法同样可以调用抽象方法这叫“在模板内部调用原语操作”。这样文件类型判断逻辑强制交给子类而公共的非空判断、存在性判断由父类统一保障。2.2 子类实现的三种形态CSV导入器实现起来非常轻量public class CsvDataImporter extends AbstractDataImporter { Override protected boolean isLegalFileType(File file) { return file.getName().endsWith(.csv); } Override protected ListRawData parseFile(File file) { // 使用CSV解析库逐行读取省略具体解析代码 return csvParser.parse(file); } Override protected ListDomainData convert(ListRawData rawData) { return rawData.stream().map(this::toDomain).toList(); } Override protected void save(ListDomainData data) { dataMapper.batchInsert(data); } }接口导入器除了实现三个原语方法之外还覆写了钩子方法needNotify因为接口数据在导入完成后需要立刻通知下游系统public class ApiDataImporter extends AbstractDataImporter { Override protected boolean isLegalFileType(File file) { return true; // 接口数据没有文件类型限制 } Override protected ListRawData parseFile(File file) { // 实际上是从接口拉取数据file参数仅作为触发标记 return apiClient.fetchData(file.getName()); } Override protected ListDomainData convert(ListRawData rawData) { // 字段映射逻辑 return rawData.stream().map(this::convertFromApi).toList(); } Override protected void save(ListDomainData data) { dataMapper.batchUpsert(data); } Override protected boolean needNotify() { return true; } }注意ApiDataImporter的isLegalFileType直接return true这个实现本身有点丑但体现了“抽象方法的语义由子类自行定义”这一特性。在实际项目里接口触发导入一般没有文件校验这层逻辑更合理的做法是父类将validateFile拆成更细的钩子方法让子类整体关闭某些步骤。2.3 钩子方法的关键作用与使用边界钩子方法hook是模板方法模式里最灵活也最容易滥用的点。它本质上提供了一个“可选的扩展点”让子类在不破坏流程的前提下干预某个环节。理解钩子方法有个经典例子泡茶和泡咖啡。父类模板方法是“烧水→冲泡→加料”加料是个钩子茶加柠檬咖啡加糖加奶不想要调料的就不覆写这个方法。它最大的价值在于流程的主干完全不变但枝叶可以被个性定制。当你的某个步骤有时需要、有时不需要时就该考虑用钩子方法。滥用信号也很明显当抽象类里出现三个以上if (xxxHook())判断时这个类的扩展点已经失控了。子类为了正确实现必须先读完所有钩子方法的注释搞清楚每个返回值对流程的影响这种认知负担比直接写一遍完整流程还重。所以我的建议是钩子方法控制在两个以内并且每个钩子方法都要写清楚默认行为和覆写后的影响。3. Spring框架中的模板方法模式应用解剖3.1 JdbcTemplate骨架固定行映射交给你JdbcTemplate里典型的一段骨架是这样的获取数据库连接→创建PreparedStatement→填充SQL参数→执行查询→遍历ResultSet→关闭资源。这中间每一步都涉及大量样板代码尤其异常处理和资源释放手写时漏一个环节就是线上事故。Spring将这个流程封装成模板把“结果如何映射成对象”这个可变化步骤用RowMapper接口抽出来public T ListT query(String sql, RowMapperT rowMapper, Object... args) { return execute((PreparedStatementCreator) connection - { PreparedStatement ps connection.prepareStatement(sql); // 填充参数 return ps; }, preparedStatement - { ResultSet rs preparedStatement.executeQuery(); ListT results new ArrayList(); int rowNum 0; while (rs.next()) { results.add(rowMapper.mapRow(rs, rowNum)); } return results; }); }用户只需要实现RowMapper把一行ResultSet的数据转成一个业务对象。连接从哪来、事务边界在哪、SQL异常怎么转成DataAccessException全部由模板处理。这就是典型的模板方法思想只是Spring没有用继承而是用回调接口实现了同样的效果。3.2 RestTemplate与ClientHttpRequestFactory的模板化设计RestTemplate的doExecute方法是另一个典型模板骨架创建ClientHttpRequest→设置请求头→执行请求→处理响应→关闭底层资源。用户只需要提供RequestCallback来描述请求体怎么写入、通过ResponseExtractor来描述响应怎么转换。我自己调试接口时见过很多类似代码RestTemplate restTemplate new RestTemplate(); ResponseEntityUserDto response restTemplate.exchange( url, HttpMethod.GET, null, UserDto.class );就这么一行背后等于是模板方法把URI拼接、连接池借用、HttpMessageConverter序列化、响应状态判断全部安排好了。如果你想把响应体从JSON换成XML不需要改任何调用代码换个消息转换器就行这套“流程不变、实现可换”的思路比我们自己写HttpClient配置稳得多。3.3 AbstractHandlerMapping钩子方法决定请求走向Spring MVC的AbstractHandlerMapping对模板方法模式的运用更加纯粹。它的getHandler方法定义了完整的算法骨架先解析请求路径、从缓存查Handler、查不到就走lookupHandler、拿到Handler后再包一层HandlerExecutionChain处理器链。子类只需覆写getHandlerInternal这一个方法比如RequestMappingHandlerMapping就实现了“根据注解解析映射关系”的逻辑。还有几个钩子方法如isHandler、getHandlerInternal各有默认实现。这套设计里父类牢牢控制住“缓存处理”“跨域检查”“执行链包装”这些基础设施逻辑各个子类专心做自己的映射规则。这是继承式模板方法在Spring中的一个重要用例也解释了为什么Spring中对HandlerMapping新增一种映射方式的成本那么低——你几乎只需要实现一个查询函数。3.4 RedisTemplate与回调模板的演进RedisTemplate把模板方法模式往前推了一步底层的execute(RedisCallback)方法处理了连接获取、序列化、事务同步把核心逻辑交给回调对象。用的时候redisTemplate.execute((RedisCallbackString) connection - { byte[] result connection.get(user:1.getBytes()); return result null ? null : new String(result); });你需要关心连接、序列化吗不需要模板全部管完了。直接操作原生connection的场景很少但这种结构保证了RedisTemplate既能支撑上层庞大的opsForValue、opsForHash等操作封装也能把特殊操作的控制权留给用户。Spring里大量使用这种回调式模板本质上是因为Java单继承让经典继承式模板方法很难在一个类里复用多个骨架所以把“继承”换成“组合回调”。理解这个演进你就能理解为什么很多优秀框架宁愿让你写一坨回调函数也不愿意让你extends一个厚厚的基类。4. 实战案例手写一套多格式报表导出框架4.1 需求分析与框架设计一个后台系统里常见的痛点是导出功能分散在各个业务模块CSV导出写一遍、Excel导出写一遍、PDF导出又写一遍每个模块里的代码都是“查询数据→拼表头→写内容→落文件”的重复过程偶尔还会出现一个模块的导出文件乱码一个不乱码的诡异差异。我的做法是抽一个BaseExporter把固定流程统一管起来变化点全部拆成子类实现。先看基类定义public abstract class BaseExporter { public final File export(ExportRequest request) { validateRequest(request); ListObject rows queryData(request); prepareEnvironment(request); try (OutputStream out openStream(request)) { writeHeader(out, rows); writeBody(out, rows); out.flush(); } catch (IOException e) { throw new ExportRuntimeException(导出文件写入失败, e); } File target buildTargetFile(request); afterExport(target, rows); return target; } private void validateRequest(ExportRequest request) { if (request null || request.getType() null) { throw new IllegalArgumentException(导出参数不合法); } } protected abstract ListObject queryData(ExportRequest request); protected void prepareEnvironment(ExportRequest request) { File dir new File(request.getSaveDir()); if (!dir.exists()) { dir.mkdirs(); } } protected abstract OutputStream openStream(ExportRequest request) throws IOException; protected abstract void writeHeader(OutputStream out, ListObject rows) throws IOException; protected abstract void writeBody(OutputStream out, ListObject rows) throws IOException; protected File buildTargetFile(ExportRequest request) { String suffix getFileSuffix(); return new File(request.getSaveDir(), request.getFileName() _ System.currentTimeMillis() . suffix); } protected abstract String getFileSuffix(); protected void afterExport(File file, ListObject rows) { } }模板方法export把流程钉死校验→查数→准备环境→打开流→写表头→写内容→落盘→收尾。每个业务模块只需要继承基类实现三个最核心的方法即可其余全部交给父类统一完成。4.2 CSV导出器实现30行搞定CSV导出器是最简单的实现骨架里占大头的文件路径、目录创建、流关闭都不用管了public class CsvExporter extends BaseExporter { Override protected ListObject queryData(ExportRequest request) { return orderService.queryExportList(request.getParams()); } Override protected OutputStream openStream(ExportRequest request) throws IOException { OutputStream rawOut new FileOutputStream(buildTargetFile(request)); outputStream new BufferedOutputStream(rawOut); // 写BOM防止Excel打开乱码 outputStream.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); return outputStream; } Override protected void writeHeader(OutputStream out, ListObject rows) throws IOException { String headerLine 订单号,商品名称,金额,下单时间\n; out.write(headerLine.getBytes(StandardCharsets.UTF_8)); } Override protected void writeBody(OutputStream out, ListObject rows) throws IOException { for (Object row : rows) { out.write(formatRow(row).getBytes(StandardCharsets.UTF_8)); out.write(\n); } } Override protected String getFileSuffix() { return csv; } }这个类里有个容易踩的坑csv文件用Excel打开乱码通常是少了UTF-8 BOM头。如果每个导出模块各自写一遍文件生成逻辑有人记得加BOM有人不记得产线一提交就是bug但模板里把流统一管理子类想漏都漏不掉。4.3 Excel导出器的基础实现与样式扩展Excel导出比CSV多一个维度它不仅写数据还要写列宽、单元格样式、冻结表头。用POI实现时BaseExporter的writeHeader和writeBody正好承担这些差异化逻辑public class ExcelExporter extends BaseExporter { Override protected ListObject queryData(ExportRequest request) { return orderService.queryExportList(request.getParams()); } Override protected OutputStream openStream(ExportRequest request) throws IOException { workbook new XSSFWorkbook(); sheet workbook.createSheet(导出数据); // 设置列宽、冻结窗格 sheet.setColumnWidth(0, 12 * 256); sheet.setColumnWidth(1, 20 * 256); sheet.createFreezePane(0, 1); return new ByteArrayOutputStream(); } Override protected void writeHeader(OutputStream out, ListObject rows) throws IOException { Row header sheet.createRow(0); header.createCell(0).setCellValue(订单号); header.createCell(1).setCellValue(商品名称); header.createCell(2).setCellValue(金额); header.createCell(3).setCellValue(下单时间); } Override protected void writeBody(OutputStream out, ListObject rows) throws IOException { int rowIndex 1; for (Object row : rows) { Row excelRow sheet.createRow(rowIndex); excelRow.createCell(0).setCellValue(...); // 填充其余字段 } } Override protected File buildTargetFile(ExportRequest request) { // 父类默认实现生成文件路径 File file super.buildTargetFile(request); // 写入workbook try (FileOutputStream fout new FileOutputStream(file)) { workbook.write(fout); } catch (IOException e) { throw new ExportRuntimeException(Excel文件写入失败, e); } return file; } Override protected String getFileSuffix() { return xlsx; } }Excel版里我覆写了openStream、writeHeader、writeBody、buildTargetFile四个方法。这看起来实现长度不小但所有流程控制、异常转译、资源关闭、目录准备都在父类统一完成子类实际上只关心“这张表长什么样”。4.4 模板方法模式和策略模式搭配使用BaseExporter只负责流程不负责业务但真实业务里“导出”之后往往还有后续动作比如导出失败要告警、导出完成后要写操作日志、数据量大时要异步通知。我把这些后续动作做成一个NotifyStrategy接口public interface ExportNotifyStrategy { void onSuccess(File file, int count); void onError(ExportRequest request, Exception e); }然后让BaseExporter的构造器接收这个策略在afterExport钩子里调用protected void afterExport(File file, ListObject rows) { notifyStrategy.onSuccess(file, rows.size()); }这样整体的设计就变成了模板方法模式固定流程策略模式处理“流程跑完后的表现”。导出模块再增加一种格式比如PDF导出器只需继承BaseExporter不需要碰任何历史业务代码。5. 避坑心得实践中常见的七个问题5.1 模板方法模式与策略模式到底怎么选这是我被问得最多的问题。很多人看到模板方法模式里有抽象方法、有钩子觉得和策略模式很像但两者解决的问题层次不一样。模板方法模式的语境是“算法步骤固定但分支实现不同”是纵向继承。策略模式的语境是“整个算法可以整体替换”是横向组合。做菜的例子最直观模板方法好比炒菜的固定流程是洗菜→切菜→热油→下锅不同厨师差别在调味手法策略模式是你今天想炒菜还是炖汤整个烹饪方式都变了。有个简单的判断方法当你的代码里重复出现“同样的大流程只有中间一小段不一样”时用模板方法模式当你的代码里出现多个“同样语义但整体实现完全不同”的算法时用策略模式。5.2 模板方法上忘了加final后果很严重我见过团队里有人继承了一个模板基类嫌父类的模板方法里某一步顺序不对直接整个覆写。结果他新写的流程里漏掉了资源关闭、漏掉了异常处理线上跑了半宿报表没出数排查半天才发现是覆写了模板方法。防止这种问题最简单的办法就是像我的示例代码里那样把模板方法声明为final。Java语法层面直接禁止子类覆写流程骨架逼着子类只能在扩展点里做文章。这是模板方法模式里最便宜也最有效的一条防线。5.3 钩子方法泛滥会导致抽象类臃肿不堪有些设计者喜欢在每个步骤前后都留一个钩子比如beforeValidate、afterValidate、beforeParse、afterParse……最后抽象类里一半代码都是空钩子实现子类文档写着“可选择覆写”实际没人知道哪些必须覆写。我建议的原则是只对真正会变化且会影响流程分支的步骤提供钩子只对“绝大部分子类都不需要关心”的步骤提供默认空实现每个钩子方法的Javadoc里必须写清楚“默认做什么、覆写后会影响什么”。如果发现子类覆写率超过70%说明这个钩子设计错了应该改成抽象方法强制实现。5.4 继承层级过深的维护噩梦模板方法模式的基础是继承但继承是一把双刃剑。当你的模板基类衍生出三层以上的子类层次中间任何一层改了某个protected方法的签名上上下下所有子类都要跟着动这个改造成本会指数级上升。我的经验阈值是继承深度超过三层就要开始考虑回退方案。Spring给出了一条很好的替代路线——回调模板。你不需要继承一个庞大的基类只需要传入一个回调对象让框架调用你的回调方法。这种组合式设计在灵活性上远胜深继承这也是为什么现代框架会更偏好回调而不是强制extends。5.5 抽象类单元测试的三个方法很多人不知道怎么给抽象类写单测我的做法有三种。第一通过匿名子类直接实例化抽象类只实现原语操作测试模板方法本身的流程顺序Test void shouldExecuteWholeFlowWhenImportData() { AbstractDataImporter importer new AbstractDataImporter() { Override protected boolean isLegalFileType(File file) { return true; } Override protected ListRawData parseFile(File file) { return List.of(new RawData()); } Override protected ListDomainData convert(ListRawData rawData) { return List.of(new DomainData()); } Override protected void save(ListDomainData data) { savedCount data.size(); } }; importer.importData(file); assertEquals(1, savedCount); }第二用Mockito的spy方法验证父类方法被子类调用的顺序但要注意模板方法本身是final时部分框架无法mock需要配合invoke实际模板方法来验证。第三对子类测试时关注子类自己实现的抽象方法逻辑不再重复测试骨架流程否则会有一堆低价值测试代码。真正需要保证的是骨架流程在父类测试中覆盖扩展点在子类测试中覆盖。5.6 抽象类与接口的边界怎么切模板方法模式的主角是抽象类那接口在这里还有什么作用我的建议是用接口定义能力用抽象类定义流程。BaseExporter定义了一套完整流程同时也可以实现一个Exporter接口接口里只有export方法签名。这样上层业务代码面向接口编程底层实现可以随时替换成非继承体系的实现。这种切割在项目里很有价值。导出服务变成接口后你既能用继承BaseExporter的CsvExporter也能用一个完全不走模板的第三方实现只要它实现了Exporter接口就行。5.7 模板方法模式的固有限制无法绕过Java单继承Java类只能继承一个父类。如果你的业务类已经继承了某个基础Entity或者基础Service那就无法再继承你的模板基类。这是最常见的“为什么我模板方法模式用不上”的原因。解决办法前面提过——回调模板。把你的模板方法里的可变化点声明成一个函数式接口调用方传入Lambda表达式本质效果等同于模板方法模式但突破单继承限制。Spring的JdbcTemplate就是这么干的。public class DataImportTemplate { public void importData(File file, FileParser parser, DataSaver saver) { doValidate(file); ListRawData rawData parser.parse(file); ListDomainData domainData convert(rawData); saver.save(domainData); } }这种设计在Java 8之后的代码库里越来越常见理解了经典模板方法的诞生背景和遇到的限制你才能在设计时做出正确取舍。6. 结尾一点真实体会用了这么多年设计模式模板方法模式是少数我打心眼儿里觉得“每天都在用”的模式。它不炫技、不复杂翻来覆去就是“父类定流程、子类填空”这一件事但恰恰是这一件事让项目里的重复代码得到了系统性清理。我个人的习惯是每当发现第三个类出现相同的方法流程时会停下来抽一个抽象基类把变化点找出来把模板方法加上final。另外一个小技巧如果你在团队里看到有同事又复制了一大段“查询数据→处理数据→写入文件”的逻辑可以提醒他用模板方法模式绝大多数情况下他会发现重构完的代码量能少一半而且bug率明显下降。

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

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

免费获取报价 →
↑