尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

设计模式 15 · 模板方法模式

设计模式 15 · 模板方法模式 上一篇策略模式,是把整个算法抽象成接口、整体替换。这一篇的模板方法模式(Template Method),思路正好互补:算法的大骨架是固定不变的,只有其中个别步骤会变——那就把骨架定死在父类里,只把那几个会变的步骤,留成抽象方法交给子类去填。它是最接地气的行为型模式之一,也是最能体现继承该怎么正确使用的模式。生活里的类比很好懂:泡茶和泡咖啡,流程骨架是一样的——烧水、冲泡、倒进杯子、加调料;只有冲泡什么(茶叶还是咖啡粉)和加什么调料(柠檬还是糖)这两步不同。你完全可以写一个泡饮料的固定流程,把冲泡和加料这两步留给具体的茶或咖啡去实现。流程的骨架只写一次,可变的步骤各自实现——这就是模板方法。我们的订单场景里有个天然的例子:下单流程。不管什么类型的订单,大骨架都是校验 → 计算金额 → 扣减库存 → 保存订单 → 发送通知这几步、这个顺序;但其中如何校验如何计算金额这些步骤,普通订单、团购订单、秒杀订单各不相同。用模板方法,把这个固定骨架定在父类,把会变的步骤交给子类——既保证了流程不被写乱,又允许各类订单定制自己的细节。这篇文章按这条线索展开:先看流程骨架被复制得到处都是的困境;再引出模板方法如何把骨架固定、步骤下放;然后讲清它的两个关键机制——钩子方法和那个不许子类乱改骨架的final;接着看它在框架里无处不在的身影;最后辨析它和策略的区别(继承 vs 组合的经典对照),给出适用边界。贯穿例是下单流程。目录流程骨架被到处复制的困境模板方法:骨架定在父类,步骤留给子类两个关键机制:钩子方法与 final 骨架现实身影:框架的半成品都靠它模板方法 vs 策略,以及什么时候用一、流程骨架被到处复制的困境看下单流程。普通订单的下单逻辑大概是这样:publicclassNormalOrderService{publicvoidplaceOrder(Orderorder){// 1. 校验if(order.getUserId()0)thrownewRuntimeException(用户非法);// 2. 计算金额doubleamountorder.getItems().stream().mapToDouble(Item::getPrice).sum();// 3. 扣减库存inventory.deduct(order);// 4. 保存订单repository.save(order);// 5. 发送通知notify.send(order.getUserId(),下单成功);}}现在要做团购订单。它的下单流程,骨架几乎一样——同样是校验、算金额、扣库存、保存、通知,同样的顺序;只有两处不同:校验时要额外检查是否成团,算金额时要按团购价。于是你复制了一份:publicclassGroupOrderService{publicvoidplaceOrder(Orderorder){// 1. 校验(多了成团检查)if(order.getUserId()0)thrownewRuntimeException(用户非法);if(!checkGroupFormed(order))thrownewRuntimeException(未成团);// 2. 计算金额(按团购价)doubleamountcalcGroupPrice(order);// 3. 扣减库存 —— 和普通订单一模一样inventory.deduct(order);// 4. 保存订单 —— 一模一样repository.save(order);// 5. 发送通知 —— 一模一样notify.send(order.getUserId(),拼团下单成功);}}问题一目了然:大量重复:扣库存、保存、通知这几步,在普通订单和团购订单里一字不差地重复了。再来个秒杀订单,又得抄一遍。骨架容易走样:靠复制粘贴来保证流程一致,是极其脆弱的。哪天有人在团购订单里手滑把保存和通知的顺序写反了,或者漏了扣库存这一步,编译器不会报错,却是一个严重的业务 bug。流程的顺序和完整性,没有任何机制来保证。公共步骤改动要改多处:哪天保存订单后要多加一步记录日志,你得在每一个XxxOrderService里都加一遍。根源是:“固定的流程骨架和可变的步骤”,被混在一起、整体复制。固定的部分本该只写一次、被强制复用;可变的部分才该各自实现。我们需要一种机制,把这两者分开——骨架锁死在一个地方,只把变化的步骤开放出来。这就是模板方法。二、模板方法:骨架定在父类,步骤留给子类模板方法的做法:在父类里定义一个模板方法,它把整个流程的骨架(步骤顺序)写死;骨架里那些会变的步骤,声明成抽象方法,由子类去实现。publicabstractclassAbstractOrderService{// 模板方法:定义流程骨架,顺序固定。用 final 防止子类改写(见第三节)publicfinalvoidplaceOrder(Orderorder){validate(order);// 步骤1:可变,交给子类doubleamountcalcAmount(order);// 步骤2:可变,交给子类inventory.deduct(order);// 步骤3:固定,父类实现repository.save(order);// 步骤4:固定,父类实现sendNotify(order);// 步骤5:可变(文案),交给子类}// 会变的步骤 → 声明成抽象方法,强制子类实现protectedabstractvoidvalidate(Orderorder);protectedabstractdoublecalcAmount(Orderorder);protectedabstractvoidsendNotify(Orderorder);// 固定的步骤 → 父类直接实现,所有子类共享,不用重写privatevoiddeduct(Orderorder){/* 扣库存,略 */}// save 同理}子类只需要填那几个会变的步骤,完全不碰流程骨架:publicclassNormalOrderServiceextendsAbstractOrderService{protectedvoidvalidate(Orderorder){if(order.getUserId()0)thrownewRuntimeException(用户非法);}protecteddoublecalcAmount(Orderorder){returnorder.getItems().stream().mapToDouble(Item::getPrice).sum();}protectedvoidsendNotify(Orderorder){notify.send(order.getUserId(),下单成功);}}publicclassGroupOrderServiceextendsAbstractOrderService{protectedvoidvalidate(Orderorder){if(order.getUserId()0)thrownewRuntimeException(用户非法);if(!checkGroupFormed(order))thrownewRuntimeException(未成团);// 团购特有}protecteddoublecalcAmount(Orderorder){returncalcGroupPrice(order);}// 团购价protectedvoidsendNotify(Orderorder){notify.send(order.getUserId(),拼团下单成功);}}对比第一节,升级点非常清晰:扣库存、保存这些固定步骤,只在父类写了一次,所有子类共享,不再重复;流程的顺序被placeOrder这个模板方法锁死,子类无法打乱;而各类订单的差异,被精准地隔离在validate、calcAmount这几个可变步骤里。加一个秒杀订单?写个子类填那几个步骤就行,骨架和公共步骤一律复用。用一张图看这个骨架固定、步骤下放的结构最清楚:图里最该记住的,是那条骨架在父类、步骤在子类的分工线:父类掌控流程长什么样、步骤什么顺序这个大局(这叫控制反转——不是子类调父类,而是父类的骨架在合适的时机回调子类填的步骤),子类只负责某一步具体怎么做。好莱坞原则那句名言Don’t call us, we’ll call you(别来调我们,我们会调你)说的就是这个:子类别想主导流程,父类会在该调你的时候调你。三、两个关键机制:钩子方法与 final 骨架模板方法有两个容易被忽略、但很重要的细节,讲清楚才算真懂。机制一:钩子方法(Hook Method)。除了必须实现的抽象步骤,模板方法还允许一种可选的、子类想改就改、不改就用默认的步骤,叫钩子方法。它在父类里有一个默认实现(通常是空的,或返回一个默认值),子类可以选择性地覆盖它。比如,我们想让子类能选择性地在下单后做点额外的事,或者控制要不要发通知:publicabstractclassAbstractOrderService{publicfinalvoidplaceOrder(Orderorder){validate(order);doubleamountcalcAmount(order);inventory.deduct(order);repository.save(order);if(needNotify()){// ← 钩子:由子类决定要不要执行这一步sendNotify(order);}afterPlaceOrder(order);// ← 钩子:子类想加后续动作就重写,不想加就不管}// 钩子方法:父类给默认实现,子类可选择性覆盖protectedbooleanneedNotify(){returntrue;}// 默认发通知protectedvoidafterPlaceOrder(Orderorder){}// 默认什么都不做}某个静默下单的子类,就可以重写needNotify()返回false,跳过通知这一步——而不用去改骨架。钩子方法让模板方法在强制统一和灵活定制之间有了弹性:抽象方法是必须填的空,钩子方法是可选的开关。机制二:模板方法应该用final修饰。注意到前面placeOrder一直带着final吗?这不是可有可无的。模板方法的全部价值,就在于保证流程骨架不被破坏。如果不加final,子类就能重写placeOrder、把整个流程改得面目全非——那模板方法锁定骨架的意义就荡然无存了。用final修饰模板方法,等于明确声明:“流程骨架是我父类定的,子类只能填步骤,不许动骨架。” 这也呼应了第 1 篇里氏替换的精神——子类扩展能力,但不该破坏父类定义的核心契约。一句话记这两个机制:抽象方法是必须填的坑,钩子方法是可选的开关,而final是骨架的锁。四、现实身影:框架的半成品都靠它模板方法是框架设计的基石。为什么?因为框架的本质就是我把通用流程的骨架写好,把可变的部分留给你填——这正是模板方法。所以你用过的框架里,它无处不在:AbstractList/AbstractMap等骨架类:JDK 的这些抽象类,把集合的通用操作(骨架)都实现好了,只把get(index)、size()等最核心的几个方法留成抽象的。你写自定义集合时,继承AbstractList、只实现那两三个方法,一整套List功能就有了。这就是模板方法——名字里的 “Abstract” 骨架类,大多是它。HttpServlet的service():它是模板方法,内部根据请求类型,分发调用doGet()、doPost()等方法。你写 Servlet 时只重写doGet/doPost(填步骤),请求分发的骨架由service()定好,你不用管。Spring 的各种Template:JdbcTemplate、RestTemplate、RedisTemplate……名字里的 “Template” 就是模板方法的印记。它们把获取连接、执行、处理异常、释放资源这套繁琐骨架固定好,只把你想执行什么 SQL、怎么处理结果这一步,通过回调留给你。Spring的生命周期回调、AbstractApplicationContext.refresh():容器启动的那一长串固定流程是模板方法,其中留了若干扩展点(钩子)给你介入。一个识别信号:凡是你看到抽象类里有一个定义了完整流程、但调用了几个抽象/可覆盖方法的方法,那基本就是模板方法。框架给你的半成品——写好了骨架、留了几个空让你填——本质都是它。五、模板方法 vs 策略,以及什么时候用模板方法和上一篇的策略,是一对绝佳的对照——它们解决的是相似的问题(封装变化的行为),但手段截然相反,理解这个对照能同时加深对两者的认识:模板方法 Template Method策略 Strategy手段继承(子类重写步骤)组合(持有策略对象)变化的粒度算法里的个别步骤整个算法骨架/流程父类固定,不可变没有固定骨架,整体替换灵活性编译期定死(继承)运行时可换(组合)关系一套骨架 多个变体多个平行、独立的算法一句话对照:模板方法是骨架不变,换几步(用继承);策略是整个算法换掉(用组合)。你会发现,又一次回到了组合优于继承这个贯穿全系列的主题——策略比模板方法更灵活(能运行时替换、不受单继承限制),但模板方法在确实存在一个稳定骨架、只是个别步骤不同时,表达得更自然、更省事。很多时候两者可以配合:模板方法定骨架,其中某个可变步骤内部又用策略来选具体算法。适合用模板方法的信号:多个类有相同的流程骨架,只是个别步骤的实现不同;你想把公共的流程逻辑抽取到一处,避免重复,并强制保证流程的顺序和完整性;你在设计一个框架,想定义好流程、把扩展点留给使用者。不必用的信号:各个变体之间没有共同的骨架,只是一堆平行的独立算法——那是策略的场景;变化的部分需要运行时动态切换——继承做不到,该用策略(组合);就一个实现,未来也不会有变体——直接写,别为想象中的子类先建抽象类。判断的核心还是那句话:先确认真的存在一个稳定的流程骨架 个别可变步骤这个结构,模板方法才合适。硬把没有共同骨架的东西塞进一个抽象类,或者为唯一的实现先建父类,都是过度设计。小结。模板方法模式把固定的流程骨架和可变的步骤分开:骨架用一个final的模板方法定死在父类(保证顺序和完整性不被破坏),可变步骤声明成抽象方法交给子类实现,可选步骤用钩子方法给默认实现。它的精髓是好莱坞原则——父类的骨架在合适的时机回调子类的步骤(控制反转),子类只填空、不主导流程。JDK 的AbstractXxx骨架类、HttpServlet、Spring 的各种Template,都是它的身影,它是框架设计的基石。记住它和策略的分水岭:模板方法用继承换几步,策略用组合换整个算法。下一篇我们讲观察者模式——当一个对象的状态变化,需要自动通知一大群关心它的对象时(比如订单支付成功后,要通知积分、短信、物流多个系统),观察者就是那套发布-订阅的优雅机制。
返回列表