ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模板方法模式:解耦变化与不变的架构设计核心

模板方法模式:解耦变化与不变的架构设计核心

1. 为什么模板方法模式是中高级开发者绕不开的“隐性门槛”

我带过不少刚从学校出来的新人,也辅导过不少转行进来的同行,发现一个特别有意思的现象:很多人能熟练写出单例、工厂、观察者这些“看得见摸得着”的模式,但一提到模板方法,要么觉得“不就是写个父类加几个子类吗”,要么在实际项目里反复踩坑——比如改了子类逻辑,父类流程突然崩了;或者新加一个业务分支,硬生生把原本清晰的骨架撑得歪七扭八;更常见的是,团队里五个人写的五个“模板方法”,实现风格天差地别,最后连自己都看不懂当初为什么要那样设计。

其实模板方法模式根本不是教你怎么“继承”,而是教你怎么把变化和不变清晰切开、各自封印、互不干扰。它解决的从来不是“代码怎么写”,而是“当业务规则频繁变动、多人协作边界模糊、系统演进路径不可预测时,如何让核心流程稳如磐石,同时又给扩展留出呼吸空间”。你翻翻主流开源框架的源码——Spring 的JdbcTemplate、MyBatis 的BaseExecutor、JUnit 的TestCase、甚至 Android 的Activity生命周期回调,背后全是模板方法在默默托底。它不抢眼,但一旦缺位,整个架构就容易变成“牵一发而动全身”的脆弱结构。

这个模式最常被低估的,是它对团队协作节奏的调节能力。举个真实例子:某次我们做支付渠道接入,需要同时对接微信、支付宝、银联云闪付三套流程。如果不用模板方法,大家可能各自写三个独立类,结果就是:三个类里重复写着“校验参数→生成签名→发起HTTP请求→解析响应→记录日志→更新状态”这六步,其中四步几乎一模一样,只有签名算法和URL不同。后来我们用模板方法重构成AbstractPaymentProcessor,把共性逻辑全收进execute()模板方法里,只留doSign()和getEndpoint()两个钩子让子类实现。结果是:新来同事加第四种渠道,30分钟就能跑通,而且他根本不用碰主流程,也不会误删日志或漏掉状态更新——因为那部分根本不在他负责的文件里。

所以我说,模板方法不是“学完就扔”的知识点,它是你判断一个系统是否具备可维护性的第一道标尺。当你看到一段逻辑里既有“永远不该变”的骨架,又有“随时可能变”的血肉,而这两者还混在同一个方法里反复 if-else,那基本可以断定:这里正等着你用模板方法来“动一次手术”。

2. 模板方法模式的本质拆解:骨架、血肉与开关的三角关系

2.1 它不是“继承教学”,而是“控制权移交协议”

很多教程一上来就画类图、讲 abstract class 和 final method,这反而掩盖了它的本质。模板方法真正的内核,是一份隐性的控制权移交协议——父类把“谁来执行”和“什么时候执行”牢牢攥在手里,只把“具体怎么执行”这一小块权限,以契约形式交给子类。这种权力分配,比单纯“复用代码”深刻得多。

我们来看一个极简但真实的例子:订单导出功能。业务要求支持 Excel 和 PDF 两种格式,但无论哪种,都必须满足:

  • 导出前校验用户权限
  • 导出中按分页拉取数据(避免内存溢出)
  • 导出后生成唯一文件名并存入对象存储
  • 最后返回下载链接

如果不用模板方法,你可能会这样写:

public class OrderExportService { public String exportToExcel(Long userId, ExportParam param) { checkPermission(userId); List<Order> orders = fetchOrdersByPage(param); String fileName = generateFileName("excel"); byte[] content = buildExcelContent(orders); uploadToOSS(fileName, content); return buildDownloadUrl(fileName); } public String exportToPdf(Long userId, ExportParam param) { checkPermission(userId); // 重复 List<Order> orders = fetchOrdersByPage(param); // 重复 String fileName = generateFileName("pdf"); // 只有后缀不同 byte[] content = buildPdfContent(orders); // 核心差异 uploadToOSS(fileName, content); // 重复 return buildDownloadUrl(fileName); // 重复 } }

问题立刻浮现:重复代码多、新增格式要复制粘贴、权限校验逻辑散落各处、未来加“导出前发送通知”就得改两处。这不是代码坏,是控制权没理清——谁该决定“要不要校验权限”?谁该决定“分页大小”?谁该决定“文件名生成规则”?现在答案是:每个方法自己拍脑袋。

而模板方法强制你回答这些问题:

public abstract class OrderExporter { // 模板方法:定义不变的执行骨架(final 确保子类不能覆盖) public final String export(Long userId, ExportParam param) { checkPermission(userId); // 不变:由父类统一控制 List<Order> orders = fetchOrdersByPage(param); // 不变:策略封装 String fileName = generateFileName(getFileSuffix()); // 半变:调用钩子 byte[] content = buildContent(orders); // 核心变化点:抽象方法 uploadToOSS(fileName, content); // 不变:基础设施复用 return buildDownloadUrl(fileName); // 不变:统一返回格式 } // 钩子方法:子类可选择性覆盖,默认提供安全兜底 protected String getFileSuffix() { return "default"; } // 抽象方法:子类必须实现,定义核心差异 protected abstract byte[] buildContent(List<Order> orders); // 具体方法:父类已实现,子类可直接复用 protected void checkPermission(Long userId) { /* 实现 */ } protected List<Order> fetchOrdersByPage(ExportParam param) { /* 实现 */ } protected String generateFileName(String suffix) { /* 实现 */ } protected void uploadToOSS(String fileName, byte[] content) { /* 实现 */ } protected String buildDownloadUrl(String fileName) { /* 实现 */ } }

看清楚了吗?export()方法里每一行,都是经过深思熟虑的“控制权归属声明”:

  • checkPermission()是强制执行——权限这事没得商量,子类连插嘴资格都没有;
  • generateFileName()是可选覆盖——大部分场景用默认规则,但 PDF 要求加时间戳,Excel 要求加版本号,子类只需重写这个钩子;
  • buildContent()是必须实现——这是你存在的唯一理由,不实现就编译不过;
  • fetchOrdersByPage()是完全托管——分页逻辑复杂(要考虑游标、缓存穿透、慢SQL熔断),子类根本不需要知道细节。

这就是模板方法的底层契约:骨架定生死,血肉定特色,开关定弹性。它不追求“代码最少”,而追求“决策最明”。

2.2 三类方法的实战分工:什么该 final,什么该 abstract,什么该 protected

很多同学卡在“到底哪些方法该设成 abstract,哪些该留 protected”的纠结里。这不是语法问题,是业务语义建模问题。我总结了一套基于真实项目经验的判断口诀,直接可用:

方法类型关键特征判断依据(问自己)实战案例
模板方法(final)整个流程的入口,不可被子类修改“如果子类能随意改这一步的顺序或跳过,会导致核心业务违规吗?”OrderExporter.export()—— 跳过权限校验=越权导出,绝对禁止
抽象方法(abstract)子类必须提供差异化实现的核心环节“没有这个实现,整个流程就无法产出有效结果吗?”buildContent()—— 不生成内容,导出就是空文件,必须实现
钩子方法(protected + 默认实现)子类可选覆盖的扩展点,有合理默认值“90%的子类会用默认行为,只有特殊场景才需要定制?”getFileSuffix()—— 大部分格式用后缀即可,PDF 需要额外加水印标识,才重写

再补充两个易错点:

  • 不要把“可能变化”的地方全设成 abstract。比如导出时“是否压缩文件”,这属于配置项,应该抽成构造参数或配置中心读取,而不是让每个子类都去实现一个shouldCompress()方法。模板方法管的是结构性变化(Excel vs PDF 的本质差异),不是参数级变化(压缩/不压缩)。
  • protected 方法不等于“给子类用的工具”。像logExportStart()这种纯日志方法,如果所有子类都必须调用,就应该放进模板方法里;如果只是“某些子类偶尔想打个调试日志”,那就根本不该暴露——日志是基础设施,不是业务契约。

我见过最典型的反模式,是把整个 HTTP 请求过程拆成buildUrl(),buildHeaders(),buildBody(),parseResponse()四个 abstract 方法。结果子类实现时,buildUrl()里偷偷做了鉴权,parseResponse()里又做了重试,彻底破坏了流程可控性。正确做法是:buildRequest()和parseResponse()设为 abstract,但要求它们只处理“请求构建”和“响应解析”本身,鉴权、重试、熔断这些横切关注,全部由父类在模板方法中统一注入。

2.3 模板方法与策略模式的边界:何时该用哪个?

这两个模式经常被拿来对比,但其实它们解决的是完全不同的问题维度。策略模式回答:“同一问题,有多种算法,运行时动态切换”;模板方法回答:“同一问题,有固定流程,但流程中某些步骤的具体实现因场景而异”。

关键区别在于控制流的主动权在谁手里:

  • 策略模式:客户端(调用方)决定用哪个策略,Context只是执行容器;
  • 模板方法:父类(模板)完全掌控执行顺序,子类只是被动提供零件。

举个支付场景的对照:

  • 如果你的系统要支持“余额支付”、“微信支付”、“银行卡支付”三种方式,并且运营后台能随时开关某一种,这是策略模式——PaymentContext持有PaymentStrategy接口,运行时根据配置选择BalanceStrategy或WechatStrategy。
  • 如果你的“微信支付”本身包含固定流程:生成预支付订单 → 调用微信统一下单接口 → 监听支付结果回调 → 更新本地订单状态 → 发送支付成功通知,而其中“监听回调”这一步,测试环境用轮询、生产环境用消息队列、灰度环境用 webhook,这时就该用模板方法——WechatPaymentTemplate定义骨架,listenCallback()作为 abstract 方法由不同环境子类实现。

一个简单检验法:把子类全部删掉,看父类是否还能编译通过、是否还有业务意义。

  • 模板方法的父类删掉子类后,模板方法本身依然存在(只是 abstract 方法报错),骨架逻辑清晰可见;
  • 策略模式的Context删掉所有策略实现后,就只剩一个空壳,毫无业务价值。

很多团队混淆的根本原因,是没想清楚“变化的粒度”。支付方式切换是业务维度的大变化(用户选微信还是余额),适合策略;而微信支付内部的回调监听方式切换是技术维度的小变化(用MQ还是轮询),适合模板。分不清这个,就会出现“一个类既继承又聚合策略”的怪胎设计。

3. 从零手写一个工业级模板方法:电商优惠券发放系统

3.1 需求还原:不只是“发券”,而是“在复杂约束下精准发券”

我们以一个真实电商场景为例:优惠券发放。表面看就是“用户领券→存数据库→返回成功”,但实际约束远比想象复杂:

  • 风控约束:同一用户7天内最多领3张“满200减50”券,超限要拦截;
  • 库存约束:总库存10000张,但需按城市分片,北京仓5000张,上海仓3000张,广州仓2000张;
  • 渠道约束:APP端领券要校验设备指纹,小程序端要校验OpenID,H5端要校验手机号;
  • 体验约束:高并发下不能锁表,要保证“超发”概率低于0.001%;
  • 审计约束:每次发放必须记录完整上下文(用户ID、设备信息、IP、渠道、库存分片、扣减前后的库存量)。

如果不用模板方法,你会怎么做?大概率是写一个CouponService,里面塞满 if-else:

if (channel.equals("app")) { checkDeviceFingerprint(); } else if (channel.equals("miniapp")) { checkOpenId(); } else if (channel.equals("h5")) { checkPhone(); } // ...后面还有库存扣减的 if-else,风控校验的 if-else

这种代码,加一个新渠道就要改主干,查一个 Bug 要翻十页,性能优化无从下手。

而模板方法,让我们把“变”和“不变”彻底剥离开。

3.2 骨架设计:定义不可撼动的发放主流程

我们先定义顶层抽象类AbstractCouponDistributor,它的模板方法distribute()就是发放的“宪法”:

public abstract class AbstractCouponDistributor { // 模板方法:发放主流程(final 确保顺序和完整性) public final DistributionResult distribute(DistributionContext context) { long startTime = System.currentTimeMillis(); try { // Step 1: 渠道校验(必须由子类实现具体校验逻辑) validateChannel(context); // Step 2: 用户风控校验(父类提供通用能力,子类可增强) validateRisk(context); enhanceRiskCheck(context); // 钩子:子类可插入额外风控 // Step 3: 库存预占(核心差异:不同分片策略) InventoryLock lock = reserveInventory(context); // Step 4: 执行发放(核心差异:DB写入 or 缓存写入) boolean success = executeDistribution(context, lock); // Step 5: 后置处理(统一审计、通知、指标上报) postProcess(context, success, lock, startTime); return buildResult(context, success, lock); } catch (Exception e) { handleDistributionError(context, e, startTime); throw e; } } // ====== 以下为各类方法定义 ====== // 【抽象方法】渠道校验:每个渠道实现完全不同,必须由子类定义 protected abstract void validateChannel(DistributionContext context); // 【具体方法】通用风控校验:防刷、频控等基础能力,父类实现 protected void validateRisk(DistributionContext context) { RiskValidator.validate(context.getUserId(), context.getCouponId()); } // 【钩子方法】增强风控:默认空实现,子类按需覆盖(如APP端加设备风险分) protected void enhanceRiskCheck(DistributionContext context) { // default no-op } // 【抽象方法】库存预占:核心差异点,不同分片策略(Redis原子计数器 / DB乐观锁 / 分布式锁) protected abstract InventoryLock reserveInventory(DistributionContext context); // 【抽象方法】执行发放:最终落库动作,可能涉及DB事务或缓存双写 protected abstract boolean executeDistribution(DistributionContext context, InventoryLock lock); // 【具体方法】后置处理:统一审计日志、发送MQ通知、更新Prometheus指标 protected void postProcess(DistributionContext context, boolean success, InventoryLock lock, long startTime) { auditLogger.log(context, success, lock, startTime); if (success) { notifySuccess(context); } metrics.recordDistributionTime(startTime); } // 【具体方法】构建结果:统一返回结构,避免子类各自造轮子 protected DistributionResult buildResult(DistributionContext context, boolean success, InventoryLock lock) { return DistributionResult.builder() .success(success) .couponCode(success ? generateCouponCode() : null) .remainingStock(lock != null ? lock.getRemaining() : 0) .build(); } // 【具体方法】错误处理:统一异常分类、降级策略、告警触发 protected void handleDistributionError(DistributionContext context, Exception e, long startTime) { errorHandler.handle(e, context, startTime); } // 【工具方法】生成券码:内部使用,不对外暴露 private String generateCouponCode() { return "COUPON_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); } }

这个骨架已经体现了模板方法的精髓:

  • distribute()是铁律,任何人不能改动其步骤顺序;
  • validateChannel()和reserveInventory()是“命门”,必须由子类实现,否则编译失败;
  • enhanceRiskCheck()是“可选插槽”,APP子类可以加设备指纹校验,H5子类可以加短信验证码二次确认;
  • 所有日志、监控、错误处理全部收口,子类专注业务差异。

3.3 子类实现:APP端与小程序端的差异化落地

现在我们写两个子类,看看如何“填空”:

APP端发放器(强设备绑定):

@Component public class AppCouponDistributor extends AbstractCouponDistributor { @Autowired private DeviceFingerprintService fingerprintService; @Autowired private RedisInventoryService redisInventoryService; @Override protected void validateChannel(DistributionContext context) { // APP端必须校验设备指纹 String deviceFp = context.getDeviceFingerprint(); if (!fingerprintService.isValid(deviceFp)) { throw new InvalidChannelException("Invalid device fingerprint: " + deviceFp); } } @Override protected void enhanceRiskCheck(DistributionContext context) { // APP端额外校验设备风险分 double riskScore = fingerprintService.getRiskScore(context.getDeviceFingerprint()); if (riskScore > 0.8) { throw new RiskBlockedException("High risk device: " + riskScore); } } @Override protected InventoryLock reserveInventory(DistributionContext context) { // APP端用Redis分片库存(高性能,允许少量超发) return redisInventoryService.reserve(context.getCouponId(), context.getUserId(), "app"); } @Override protected boolean executeDistribution(DistributionContext context, InventoryLock lock) { // APP端走DB主库写入,强一致性 return couponRepository.insertCouponRecord( context.getUserId(), context.getCouponId(), lock.getLockId(), System.currentTimeMillis() ); } }

小程序端发放器(轻量级,依赖OpenID):

@Component public class MiniAppCouponDistributor extends AbstractCouponDistributor { @Autowired private WechatOpenIdService openIdService; @Autowired private DbInventoryService dbInventoryService; @Override protected void validateChannel(DistributionContext context) { // 小程序端校验OpenID有效性 String openId = context.getOpenId(); if (!openIdService.isValid(openId)) { throw new InvalidChannelException("Invalid wechat openid: " + openId); } } @Override protected InventoryLock reserveInventory(DistributionContext context) { // 小程序端用DB乐观锁(库存精度要求高,不允许超发) return dbInventoryService.reserveWithOptimisticLock( context.getCouponId(), context.getUserId() ); } @Override protected boolean executeDistribution(DistributionContext context, InventoryLock lock) { // 小程序端走缓存+DB双写,兼顾性能与一致性 boolean dbSuccess = couponRepository.insertCouponRecord( context.getUserId(), context.getCouponId(), lock.getLockId(), System.currentTimeMillis() ); if (dbSuccess) { cacheService.setCouponCache(context.getUserId(), context.getCouponId()); } return dbSuccess; } }

对比一下,你会发现:

  • 两个子类只写自己真正关心的逻辑,其他50%的代码(风控、日志、监控、错误处理)全部复用;
  • 新增H5渠道?只需写一个H5CouponDistributor,实现validateChannel()(校验手机号)和reserveInventory()(用分布式锁),30分钟搞定;
  • 某天运营说“APP端库存要改成按城市分片”,你只需要改AppCouponDistributor.reserveInventory()一行代码,其他流程毫发无损;
  • 如果发现风控规则要升级,所有渠道的validateRisk()自动生效,无需逐个修改子类。

这才是模板方法带来的可维护性红利——它不减少代码行数,但极大降低了修改成本和出错概率。

3.4 关键参数设计:为什么 inventoryKey 要包含 channel 和 city?

在reserveInventory()方法里,我们传入的context包含了channel(渠道)、city(城市)、couponId(券ID)等字段。为什么库存 Key 必须组合这三者?这背后是典型的领域建模决策,不是随便拼的。

我们来算一笔账:假设“满200减50”券总库存10000张,如果只用couponId作为库存 Key:

  • 北京用户领券,扣减coupon_1001库存;
  • 上海用户领券,也扣减coupon_1001库存;
  • 结果就是:北京仓库存耗尽,上海仓还有9000张,但上海用户却领不到券——因为库存是全局共享的。

所以必须分片。但分片维度怎么选?

  • 按channel分片?不行。APP和小程序用户可能同属北京,但库存要隔离(APP用户多,小程序用户少,不能互相挤占);
  • 按city分片?也不行。同一城市用户,APP和小程序的领取行为、风控策略、用户体验要求完全不同,库存策略也应独立;
  • 正确答案:按 channel + city 组合分片。

这样设计,库存 Key 就是inventory:coupon_1001:app:beijing,每个渠道每个城市的库存完全独立,互不影响。而这个决策,被完美封装在reserveInventory()的抽象方法里——子类实现时,自然就知道要读取context.getChannel()和context.getCity()来构造 Key。

提示:这种分片策略的变更,是模板方法最擅长应对的。如果哪天业务说“小程序库存要和APP打通”,你只需要在MiniAppCouponDistributor.reserveInventory()里把 Key 构造逻辑从channel+city改成city,其他所有代码(包括父类模板)完全不用动。这就是“变化局部化”的威力。

4. 高阶技巧与避坑指南:那些文档里不会写的实战经验

4.1 钩子方法的“三不原则”:不抛异常、不改状态、不阻塞流程

钩子方法(protected且有默认实现的方法)看似温和,实则是模板方法里最危险的“温柔陷阱”。我见过太多团队在这里栽跟头,总结出三条铁律:

第一不:不抛业务异常
钩子方法的定位是“增强”而非“主干”,如果它抛出CouponOutOfStockException这种业务异常,就会破坏模板方法的流程完整性。正确做法是:钩子只做检查,发现问题用context.addWarning("xxx")记录,由模板方法在后续步骤统一处理。

第二不:不修改共享状态
比如在enhanceRiskCheck()里直接调用context.setUserStatus(Blocked),这会让其他钩子或主流程拿到错误的状态。所有状态变更,必须通过context提供的受控 API,比如context.markAsHighRisk(),由父类统一管理状态流转。

第三不:不引入长耗时操作
钩子方法在主流程中同步执行,如果在里面调用一个3秒的HTTP接口,整个发放流程就卡住了。所有外部依赖,必须提前在validateChannel()等前置步骤完成,钩子只做内存内快速判断。

我曾经重构过一个老系统,原设计在postProcess()钩子里调用短信服务发通知,结果大促时短信网关抖动,导致所有券发放卡在最后一步,库存一直没释放。改成异步MQ通知后,问题彻底解决。记住:钩子是轻量级的装饰器,不是重型发动机。

4.2 模板方法与 Spring 的深度整合:@PostConstruct 不是万能解药

Spring 项目里,很多人喜欢用@PostConstruct在子类初始化后做些事情,比如加载渠道配置。但这和模板方法的哲学冲突——模板方法强调“一切行为都在distribute()流程内显式发生”,而@PostConstruct是隐式的、不可控的、时机不确定的。

正确整合方式是:把 Spring 的生命周期管理,转化为模板方法的可配置钩子。

public abstract class AbstractCouponDistributor { // 新增一个初始化钩子,在 distribute() 执行前自动调用 protected void init() { // default no-op } public final DistributionResult distribute(DistributionContext context) { // 在流程开始前,显式调用初始化钩子 init(); // ...后续流程 } } @Component public class AppCouponDistributor extends AbstractCouponDistributor { private AppConfig appConfig; @Override protected void init() { // 这里可以安全地注入 Spring Bean,调用 service this.appConfig = configService.loadAppConfig(); } }

这样做的好处:

  • 初始化时机完全可控,就在distribute()第一行;
  • 子类不用记@PostConstruct注解,降低认知负担;
  • 单元测试时,可以直接调用init()方法模拟初始化,无需启动 Spring 容器。

注意:init()方法不能是@PostConstruct,因为@PostConstruct是 Spring 管理的,而模板方法的init()是业务流程的一部分,两者职责必须分离。

4.3 单元测试的黄金法则:Mock 抽象方法,验证模板流程

测试模板方法,核心不是测子类,而是验证父类骨架是否坚如磐石。我的测试策略是“三层验证”:

第一层:验证模板方法流程完整性(最重要)
用 Mockito Mock 抽象方法,确保每一步都被调用,且顺序正确:

@Test public void shouldExecuteAllStepsInOrder() { // Given AbstractCouponDistributor distributor = mock(AbstractCouponDistributor.class); DistributionContext context = new DistributionContext(); // Mock 所有抽象方法,用 Answer 记录调用顺序 doAnswer(invocation -> { callSequence.add("validateChannel"); return null; }).when(distributor).validateChannel(any()); doAnswer(invocation -> { callSequence.add("reserveInventory"); return mock(InventoryLock.class); }).when(distributor).reserveInventory(any()); // When distributor.distribute(context); // Then assertThat(callSequence).containsExactly( "validateChannel", "validateRisk", "enhanceRiskCheck", "reserveInventory", "executeDistribution", "postProcess" ); }

第二层:验证钩子方法的默认行为
测试父类提供的enhanceRiskCheck()等钩子,确保空实现不抛异常、不改变状态。

第三层:验证子类核心逻辑
单独测试AppCouponDistributor.validateChannel()是否正确校验设备指纹,与模板流程解耦。

这种测试结构,让你能快速定位问题:如果流程顺序错了,是父类骨架问题;如果某个钩子没生效,是子类覆盖问题;如果渠道校验失败,是子类实现问题。责任边界一清二楚。

4.4 常见问题速查表:从报错信息反推设计缺陷

报错现象最可能的设计缺陷排查思路修复建议
java.lang.AbstractMethodError子类未实现某个 abstract 方法,但编译通过了检查子类是否继承了正确的父类(注意泛型擦除导致的继承链断裂)在 IDE 中 Ctrl+Click 进入父类,确认 abstract 方法签名是否完全匹配(包括泛型)
NullPointerException在distribute()内部某个钩子方法(如enhanceRiskCheck())里访问了未初始化的子类成员变量在子类init()钩子里添加Objects.requireNonNull(this.xxx)断言所有依赖的 Bean,必须在init()钩子里完成注入和校验
新增子类后,原有子类的postProcess()日志格式变了父类postProcess()被子类意外重写了(忘了加@Override)检查所有子类是否严格使用@Override注解,IDE 开启“Missing @Override”警告在父类方法上加final(如果确定不许覆盖),或启用编译器-Xlint:override
高并发下库存超发率超标reserveInventory()的实现用了非原子操作(如先查后写)检查子类reserveInventory()是否真的调用了 RedisINCR或 DBUPDATE ... WHERE stock > 0强制要求reserveInventory()返回InventoryLock对象,包含lockId和remaining字段,杜绝“查-判-写”三步分离
单元测试里distribute()返回 nullbuildResult()方法里generateCouponCode()抛了 NPE(UUID 工具类未 mock)检查buildResult()依赖的所有工具方法,是否在测试中被正确 stub用@Spy代替@Mock,或在测试 setup 中doReturn("test_code").when(spy).generateCouponCode()

最后分享一个血泪教训:我们曾在线上遇到一个诡异问题——小程序领券成功率突然从99.9%降到95%,排查三天才发现,MiniAppCouponDistributor.executeDistribution()里有个cacheService.setCouponCache()调用,而缓存集群刚好那天网络抖动,超时时间设成了5秒,导致整个发放流程卡住。后来我们把所有外部依赖的超时,都移到reserveInventory()和executeDistribution()的实现里统一控制,并增加熔断降级(超时则跳过缓存,只写DB)。模板方法的威力,不仅在于组织代码,更在于它强迫你把所有不确定性,都显式地暴露在契约接口上——只要接口定义清晰,问题就无处遁形。

5. 模板方法的演进:当它不再够用时,下一步是什么?

模板方法不是银弹,它有明确的适用边界。当你的系统出现以下信号,就该考虑升级架构了:

5.1 信号一:子类数量爆炸,超过7个

我们做过统计,当一个模板父类的子类超过7个时,维护成本会指数级上升。比如AbstractCouponDistributor如果衍生出 APP、小程序、H5、PC、邮件、短信、Push、飞书、钉钉……九个子类,光是validateChannel()的实现就占了上千行,distribute()方法的可读性急剧下降。

这时该转向策略模式 + 模板方法组合:

  • 用策略模式管理“渠道路由”,ChannelRouter根据context.getChannel()返回对应的CouponDistributor策略;
  • 每个策略内部,依然用模板方法组织自身流程;
  • 这样,父类只面对“一个策略”,子类只关注“一个渠道”,复杂度被水平切分。

5.2 信号二:流程步骤开始动态编排

比如运营提出需求:“A券发放流程是 校验→库存→发券,B券是 校验→风控→库存→发券→通知,C券是 校验→库存→风控→发券”。步骤顺序和数量都不固定了。

这时模板方法的“固定骨架”就失效了。该引入流程引擎(如 Camunda、Activiti)或轻量级责任链模式:

  • 定义Step接口,每个步骤实现execute();
  • 用List<Step>动态组装流程;
  • 模板方法退化为流程引擎的“执行器”,不再定义具体步骤。

5.3 信号三:子类之间开始相互调用

比如AppCouponDistributor里需要调用MiniAppCouponDistributor的某个方法来复用逻辑。这说明“继承树”已经扭曲——本该是兄弟关系的类,变成了父子关系。

这是典型的继承滥用。正确做法是:把共用逻辑抽成独立的Service,通过组合(Composition)而非继承(Inheritance)来复用。模板方法的父类,应该只包含“所有子类都必须遵守的契约”,而不是“所有子类都可能用到的工具”。

我个人在实际项目中的体会是:模板方法的最佳实践半径,是“3-5个核心子类 + 2-3个关键变化点”。超出这个范围,不是模式错了,而是你该重新思考问题的分解维度了。就像螺丝刀拧不动的螺丝,不是螺丝刀不好,是你该换扳手了。

最后再分享一个小技巧:在团队推行模板方法时,我习惯在父类 JavaDoc 里写明“本类契约有效期至 XXXX 年 XX 月”,并设置一个自动提醒。因为业务在变,今天坚不可摧的骨架,明天可能就成了枷锁。定期审视模板方法的适用性,比死守设计模式更重要——毕竟,模式是为业务服务的,不是反过来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进