ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

模板方法模式实战:从重复代码到流程骨架的优雅重构

模板方法模式实战:从重复代码到流程骨架的优雅重构 写业务代码这几年我踩过一个特别典型的坑。当时项目里有三种报表导出逻辑订单报表、用户报表、库存报表。三个类里都有一段长得几乎一样的导出流程——查数据、清洗字段、格式化、写文件、发通知唯一不同的就是中间查哪张表、格式化规则不一样。那会儿我刚接触设计模式没往这方面想就是复制粘贴改改中间逻辑就上线了。直到有一天产品经理说订单报表要多加一个字段我改了A类之后忘了同步给B类用户报表少了一个字段运营部拿着旧数据截图来找我。那晚我翻了半个小时的代码除了骂自己蠢就在想一个问题有没有一种办法能把流程固定住只让不同的部分开放出去后来我找到了答案——模板方法模式。这个模式是行为型设计模式里非常“低调”的一个它不像观察者、策略模式那样经常被挂在嘴边但几乎是解决业务代码重复流程的第一选择。Java里的AbstractList、Spring的JdbcTemplate、MyBatis的BaseExecutor底层全是这套思路。对于正在写Java、C、C#的开发者以及准备设计模式期末或者做大作业的同学这篇文章想帮你把模板方法模式从“知道名字”变成“拿得出手”。我会把来龙去脉、多语言实现、钩子方法的玩法、考场上容易踩的辨析坑以及真实的项目取舍一次性讲透。1. 从一次线上事故说起复制粘贴式开发的重灾区1.1 一段让我失眠的重复代码先还原一下当时的场景。三个报表导出类每个都有差不多六七十行重复代码流程骨架完全一样public class OrderExporter { public void export() { String raw queryOrderData(); String formatted formatOrder(raw); writeFile(formatted); sendNotify(订单报表); } // 查询、格式化、写文件各自实现 } public class UserExporter { public void export() { String raw queryUserData(); String formatted formatUser(raw); writeFile(formatted); sendNotify(用户报表); } // 查询、格式化、写文件各自实现 }问题藏得很深。单看某一个类代码写得没毛病。但只要新需求来了要往流程里加一个“数据脱敏”步骤或者把“写文件”改成“写文件归档”你就得同时改三个类。改漏一个就是线上事故。我当时就在“订单报表要加一个渠道字段”的需求里改漏了用户报表。这类代码有三个明显的坏味道复制粘贴的流程代码占60%以上只有中间几个方法不一样。每次改流程都要全量搜索所有实现类漏一个就出问题。新人接手代码看任何一个类都只能看到局部看不到完整的业务主线。1.2 模板方法模式到底在解决什么问题模板方法模式Template Method Pattern解决的核心矛盾就是“把不变的东西和可变的东西分开”。不变的东西是流程骨架。比如导出报表永远是“查数据 → 格式化 → 写文件 → 通知”这个顺序几乎所有报表都一样。可变的东西是具体步骤。订单查的是订单表用户查的是用户表格式化规则也不同。大多数人会想到的第一层方案是抽取公共方法public void doExport(String data) { writeFile(data); sendNotify(报表); }这种方式能解决一部分重复但解决不彻底。因为流程本身还是散落在各个类里每个类还是各自写export()方法只是把尾部重复的几步抽出来了。流程一旦变化你照样要改每个类。模板方法模式的思路更彻底把整个流程骨架放到父类用final锁死不让子类改把变化的步骤抽象成方法让子类去实现。子类不再控制流程只负责填内容。这就是所谓的“好莱坞原则”——别调用我们我们会调用你。和策略模式的区别这里先点一句策略模式是把整个算法封装成对象由外部调用方决定用哪套模板方法模式是父类控制整个算法子类只负责覆盖其中某几步。一个是换算法一个是填步骤。2. 模板方法模式的核心骨架把流程定死把细节放开2.1 四个角色一张图讲明白看名字容易懵但拆开就几个角色。以报表导出为例AbstractClass抽象类比如DataExporter定义了export()模板方法按固定顺序调用queryData()、formatData()、writeFile()。还声明了queryData()、formatData()为抽象方法。ConcreteClass具体子类比如OrderExporter、UserExporter继承抽象类实现父类声明的抽象方法提供具体的查询和格式化逻辑。TemplateMethod模板方法就是父类里那个export()。它通常是final/sealed修饰的不允许子类重写保证流程骨架的一致性。PrimitiveOperation原语操作就是被模板方法调用的抽象方法也就是“变化的步骤”子类必须实现。对应到代码抽象类长这样public abstract class DataExporter { // 模板方法把流程锁死 public final void export() { String rawData queryData(); String formattedData formatData(rawData); saveToFile(formattedData); sendNotify(); } // 原语操作子类各自实现 protected abstract String queryData(); protected abstract String formatData(String rawData); // 固定步骤基类私有实现 private void saveToFile(String content) { System.out.println(写入文件 content); } private void sendNotify() { System.out.println(发送通知); } }引用的结构关系用文字描述就是DataExporter抽象类 ├── export()final模板方法定义流程 ├── queryData()abstract子类实现 ├── formatData()abstract子类实现 ├── saveToFile()private固定实现 └── sendNotify()private固定实现 OrderExporter子类 └── queryData()查订单表 └── formatData()订单字段格式化 UserExporter子类 └── queryData()查用户表 └── formatData()用户字段格式化注意看权限修饰符的分配模板方法对子类“只读不可改”所以是final原语操作对子类“不实现不行”所以是abstract固定步骤“对子类完全隐藏”所以是private。这三个修饰符各司其职一起把骨架和实现隔离得干干净净。2.2 原语操作和模板方法的边界划分设计模板方法模式时最纠结的一件事就是哪些方法该抽象出来交给子类哪些方法该固定死在父类。我现在的经验是先画业务流程图把每个步骤列出来然后做一个判断这个步骤会不会因为业务不同而变化会变就声明成abstract不会变就写到父类里。判断错了也没关系父类里的具体方法改为非final的虚方法一样可以补成“默认可覆盖”的状态但这种后补的方案多了会让模板方法失去约束力所以一开始还是想清楚更好。另外有一条很实用的规则抽象方法数量不要太多。模板方法模式不是让你把所有步骤都变成抽象方法那样子类实现负担会非常重。一般三到五个原语操作是合理的。如果某个子类为了满足父类不得不实现一堆跟自己业务无关的方法说明骨架拆错了该考虑用组合或者拆成几个独立的接口。还有一件小事模板方法里调用原语操作的顺序就是业务顺序写的时候最好用有业务含义的方法名。不要写step1()、step2()这种过两个月你自己都看不懂哪一步是哪一步。3. 多语言落地实操Java、C、C#的差异与坑模板方法模式的核心思路在所有语言里通用但落到具体语言有几个语法层面的坑很容易踩。3.1 Java实现final abstract是黄金组合Java里最标准的写法就是把模板方法标成final原语操作用protected abstract。为什么用protected而不是public因为原语操作是给子类实现的不应该被外部类直接调用用protected可以把可见性限制在继承体系内。public abstract class DataExporter { public final void export() { String rawData queryData(); String formattedData formatData(rawData); saveToFile(formattedData); } protected abstract String queryData(); protected abstract String formatData(String rawData); private void saveToFile(String content) { System.out.println(写入文件 content); } }有个很容易忽略的细节Java里private方法不能被子类看到的但模板方法照样可以调用private的saveToFile()因为是父类内部调用访问控制是允许的。子类如果也想用saveToFile()怎么办把它设成protected就好。我自己一般遵循一个原则模板方法要调用的固定步骤优先用protected final声明这样既不让子类改动又允许子类在自己内部调用。3.2 C实现纯虚函数与析构函数里的坑C的写法类似但有两个非常容易踩的坑。第一个是析构函数一定要声明为virtual否则通过基类指针删除子类对象时子类析构函数不会被调用内存泄漏和资源泄漏在等着你。第二个是C11之后可以用final关键字修饰成员函数防止子类覆盖模板方法class DataExporter { public: virtual ~DataExporter() default; void exportData() final { std::string raw queryData(); std::string formatted formatData(raw); saveToFile(formatted); } protected: virtual std::string queryData() 0; virtual std::string formatData(const std::string raw) 0; private: void saveToFile(const std::string content) { // 写入文件 } };注意exportData()用final修饰之后子类再也不能覆盖这个函数这正好是模板方法模式想要的效果。如果用C写过大型项目的同学应该深有体会不加final的模板方法总会被某个自认为“聪明”的子类重写把完整流程打乱。代码评审的时候千万盯住这点。3.3 C#实现sealed与abstract的配合C#的语法和Java很像但默认行为不同。Java的方法默认是final的除非你显式标virtualC#的方法默认是“非虚”的除非你标virtual子类还得标override才能重写。所以C#里模板方法写法上更安全不加virtual就已经有了“锁死”的效果。不过为了明确指出意图我建议还是加sealed修饰符public abstract class DataExporter { public sealed void Export() { var raw QueryData(); var formatted FormatData(raw); SaveToFile(formatted); } protected abstract string QueryData(); protected abstract string FormatData(string raw); private void SaveToFile(string content) { Console.WriteLine($写入文件{content}); } }C#里还有一个和Java不同的细节protected abstract方法在子类里用override实现而Java是Override注解。虽然只是语法差异但写习惯Java的人切到C#容易漏掉override关键字然后编译报错说“没有实现抽象成员”别问我是怎么知道的。三种语言的核心差异我整理成一张表语言防止模板方法被覆盖定义待实现步骤子类实现语法钩子方法典型写法Javafinalprotected abstract直接覆写 Overrideprotected boolean hook()CfinalC11protected纯虚函数overrideC11protected virtual bool hook()C#sealedprotected abstractoverrideprotected virtual bool hook()4. 钩子方法真正让模板方法活起来的扩展点4.1 空钩子和条件钩子很多人学模板方法模式只盯着抽象方法却忽略了钩子方法Hook Method。钩子方法是父类里一个“有默认实现但不是必须被重写”的方法子类可以选择重写它来影响模板方法的执行路径。钩子方法有两种典型形态。空钩子就是父类给了个默认实现子类爱用不用。最常见的例子就是日志开关protected boolean isNeedAuditLog() { return true; }子类如果不需要审计日志直接重写返回false模板方法内部通过这个返回值决定走哪条分支。条件钩子本质是一样的只不过返回值影响的不再是一个简单的日志开关而是流程是否执行某个模块。比如导出报表前是否需要校验权限、导出后是否发邮件通知。钩子方法的价值在于你不必为了一个“要不要通知”的需求就硬造一个抽象方法逼着所有子类都实现一遍。4.2 一个带钩子的完整案例下面用一个带钩子的完整案例收拢前几章的零散细节。场景还是报表导出但加了一个“是否需要脱敏”的钩子金融类报表默认脱敏普通报表不脱敏。public abstract class DataExporter { public final void export() { String rawData queryData(); String safeData maskSensitiveData(rawData); // 调用钩子 String formattedData formatData(safeData); saveToFile(formattedData); } protected abstract String queryData(); protected abstract String formatData(String rawData); private void saveToFile(String content) { System.out.println(写入文件 content); } // 钩子方法默认脱敏 protected boolean isNeedMask() { return true; } private String maskSensitiveData(String rawData) { if (isNeedMask()) { return rawData.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); } return rawData; } }子类这样实现public class FinancialExporter extends DataExporter { Override protected String queryData() { return 金融账户数据; } Override protected String formatData(String rawData) { return 【金融报表】 rawData; } Override protected boolean isNeedMask() { return true; // 默认就是true可不重写这里显式写出便于阅读 } } public class NormalExporter extends DataExporter { Override protected String queryData() { return 普通用户数据; } Override protected String formatData(String rawData) { return 【普通报表】 rawData; } Override protected boolean isNeedMask() { return false; // 普通报表不脱敏 } }注意钩子方法调用发生在模板方法内部而不是由子类自己调用脱敏逻辑。这正是模板方法模式和“普通继承”的关键区别父类控制一切子类只提供数据和选项。钩子方法用多了也会有副作用。如果一个抽象类里有五六个钩子每个子类都重写其中两三个代码读起来会非常累你要看每个钩子的默认值、子类的重写值才能判断流程到底怎么走。我见过一个老项目模板方法里嵌了四个钩子加起来的if-else分支有七八条维护起来跟读迷宫一样。所以我的经验是钩子尽量控制在1~3个能用默认值解决的就不做钩子钩子只服务于“确实有部分子类需要差异化分支”的场景。5. 期末与大作业视角考点辨析和自我检查清单5.1 模板方法 vs 策略模式考场上最经典的混淆点期末卷子和面试题里几乎必有一道“模板方法模式和策略模式有什么区别”。很多同学背了概念还是分不清因为两者都在处理“算法”和“变化”。其实只要抓住一条主线模板方法模式用继承复用骨架策略模式用组合替换算法。模板方法模式里父类把流程写死子类只能改某几步整个流程的大框架是不能换的。策略模式里上下文类根本不关心策略内部怎么实现你给它一个接口它调接口方法完成工作整个算法是可以整个换掉的。举两个例子泡茶和泡咖啡步骤骨架是“煮水 → 冲泡 → 加料”茶加茶叶咖啡加咖啡粉这是模板方法模式的经典例子。支付方式下单后可以用支付宝、微信、银行卡支付三种支付完全是三套算法选哪个由用户在运行时决定这是策略模式。还有些题目会让你判断某个场景该用模板方法还是策略。我的判断口诀是如果流程步骤一样、只有某几步不同优先模板方法如果算法整体有好几套运行时需要自由切换优先策略模式。5.2 大作业答辩时的展示思路如果你正在做设计模式大作业选了模板方法模式答辩的时候不要一上来就讲UML类图。先讲一个真实的业务痛点比如我文章开头那个线上事故让评委知道你为什么需要这个模式。然后再用一句话概括模板方法模式的本质父类定义骨架子类实现细节。展示代码的时候按这个顺序来先展示“重构前”的重复代码指出重复点。再展示抽象类和模板方法指出final关键字的作用。再展示子类实现指出子类只需要实现抽象方法。最后重点讲如果新加一种报表只需要新增一个子类不需要改任何已有代码这符合开闭原则。评委大概率会问你“这不就是普通的继承吗”你可以这么回答模板方法模式是继承的一种结构化用法关键区别在于父类通过模板方法完全控制算法的执行顺序子类只被允许实现特定的原语操作而不能重写整个流程。普通继承没有这种约束子类可以覆盖任意方法甚至推翻父类的流程。还有一个小技巧准备一个“违规重写模板方法”的反例。你把一个子类里的export()方法直接重写掉绕开父类流程然后告诉评委这种写法破坏了模式的核心目的所以模板方法在父类里要加final。这种正反对比的讲解方式比干巴巴念定义有说服力多了。6. 真实项目中的取舍什么时候别用模板方法6.1 模板方法模式的三个坏味道前几章都在讲怎么用这一章我想聊几句“别用”的时候。用了多年模板方法模式之后我总结出三个明显的坏味道。第一个坏味道是抽象类底下挂了十几个子类但每个子类只实现一两步。比如六个子类五个的queryData()逻辑都差不多只是查询条件不同。这时候继承带来的类爆炸已经超过复用带来的收益更合理的做法是引入策略模式或者直接把差异化步骤做成参数。第二个坏味道是子类需要重写父类的大部分方法。如果一个子类为了让流程走通被迫覆盖了父类里四个方法中的三个说明你的骨架设计错了——你把它当成通用流程但它压根不适合这个子类。真要遇到这种情况先重构骨架把不通用的一步拆出去改成钩子或策略骨架本身就不应该试图覆盖所有场景。第三个坏味道是模板方法里塞了一堆if-else判断钩子。钩子是给少数子类开的口子但如果一个流程里处处是“如果A子类就走这里否则走那里”模板方法很快就变成了一锅粥。这种时候建议直接把有差异的步骤从模板方法里提出来改成接口让子类直接决定。6.2 从复制粘贴到模板方法的完整重构步骤如果你已经有一堆重复代码想重构成模板方法模式我的步骤是这样的画流程图把重复代码里的流程步骤一条条列出来标清楚哪几步所有类都一样哪几步有差异。建抽象类把固定步骤直接写成父类方法把差异步骤声明为abstract原语操作把“可能差异但大多数情况一样”的步骤写成钩子。写模板方法按流程顺序把固定方法和原语操作按顺序组织起来加final锁死。改造现有类所有重复类改继承抽象类删掉自己类里的流程代码只保留原语操作的实现。跑对比测试用一份输入分别跑重构前后的导出逻辑确认输出一致。我在实际项目里最后悔的一件事就是当初没花半小时梳理那段流程让三个报表类各自为政结果线上被运营找上门。改成模板方法之后不光是代码少了更重要的是新需求来了只需要加一个子类。后来新同事接手代码只需要看父类的export()方法就能知道整条业务主线长什么样排错效率也高了一大截。这也是我写这篇文章最想强调的一点模板方法模式的价值不在于让你多用继承而在于让团队里所有人都能快速看懂业务骨架让变化只发生在一个地方。
返回列表