文章标签:
Java设计模式模板方法模式摘要:查缓存、未命中、加载数据源、写入缓存、返回结果------这套流程在业务系统中反复出现,但Redis缓存、本地缓存的实现细节天差地别。模板方法模式的核心思想是"父类控制流程,子类填充细节 ",正是解决这类"骨架固定、细节多变"问题的利器。
一、问题场景:流程千篇一律,代码各写各的
在业务开发中,缓存是标配。有些数据访问频率极高、数据量小,适合放在JVM缓存里,毫秒级响应;有些数据量大、访问频率没那么高,更适合用Redis做分布式缓存,统一管理过期时间。但不管是哪种方案,操作流程都惊人的一致:
text
调用方请求数据
→ 检查缓存中是否存在
→ 命中:直接返回缓存数据
→ 未命中:从数据源加载数据
→ 写入缓存
→ 返回数据
问题来了:如果每新增一种缓存数据,都要把上面这套流程完整写一遍,代码中会充斥着大量重复的骨架逻辑。更麻烦的是,如果流程本身需要调整(比如增加缓存穿透保护、添加监控埋点),所有实现都要同步修改。
二、直观解法(反面教材):反复复制粘贴的代价
最直观的写法------每个缓存方案各自写一套完整流程:
java
// ❌ 反面教材:Redis 缓存实现,骨架逻辑与细节实现混在一起
public class RedisProductService {
private RedisTemplate<String, String> redisTemplate;
private ProductMapper productMapper;
public Product getProduct(Long skuId) {
// 骨架逻辑:查缓存
String key = "product:" + skuId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
// 命中
return JSON.parseObject(json, Product.class);
}
// 未命中:从数据库加载
Product product = productMapper.selectById(skuId);
if (product != null) {
// 写入缓存(附带过期时间)
redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
}
java
// ❌ 反面教材:本地缓存实现,同样的骨架又写一遍
public class LocalProductService {
private Map<Long, Product> cache;
private ProductMapper productMapper;
public Product getProduct(Long skuId) {
// 同样的骨架逻辑:查缓存
Product product = cache.get(skuId);
if (product != null) {
// 命中
return product;
}
// 未命中:从数据库加载
product = productMapper.selectById(skuId);
if (product != null) {
// 写入缓存
cache.put(skuId, product);
}
return product;
}
}
这样做的问题在哪里?
| 问题 | 具体表现 |
|---|---|
| 代码重复 | "查缓存→判断命中→加载数据→写缓存→返回"这段骨架在每套实现中反复出现 |
| 维护困难 | 如果要增加缓存穿透防护(布隆过滤器检查),N 种高频数据缓存要改 N 个地方 |
本质原因:稳定的流程骨架和变化的实现细节被捆绑在同一个方法里,没有分离。
三、模板方法模式介绍:父类定流程,子类填细节
模板方法模式的核心思想极其简单:父类控制流程,子类填充细节。用一个公式概括:
模板方法模式 = 不变的算法骨架 + 可变的步骤实现
它包含三个关键角色:
- 抽象模板(AbstractTemplate) :定义算法骨架(
templateMethod),声明哪些步骤是抽象方法(子类必须实现),哪些是钩子方法(子类可选覆盖) - 具体实现(ConcreteImpl):实现抽象方法,完成具体步骤的细节
- 钩子方法(Hook):父类提供默认实现,子类根据需要选择是否覆盖,用于在骨架中插入可选逻辑
关键约束:模板方法通常声明为 final,防止子类篡改整体流程。子类只能填充细节,不能改变步骤的执行顺序。
四、模板方法模式重构:让骨架归骨架,细节归细节
1. 定义抽象模板
先从缓存操作中提炼出不变的骨架:
java
/**
* 缓存操作模板-定义"查缓存→加载→写缓存→返回"的标准流程
* 子类只需关注具体缓存技术的实现细节
*/
public abstract class CacheTemplate<Q, T> {
/**
* 缓存键模板
* 缓存键格式:echo:cache:template:{cacheServiceName}:{cacheQueryParam}
*/
private static final String CACHE_KEY_TEMPLATE = "echo:cache:template:%s:%s";
/**
* 模板方法:定义算法骨架,声明为 final 防止子类篡改流程
*
* @param queryParam 查询参数
* @param type 缓存数据类型
* @return 数据
*/
public final T execute(Q queryParam, Class<T> type) {
String cacheKey = getCacheKey(queryParam);
// 步骤一:从缓存中查找
Object cached = findFromCache(cacheKey);
// 步骤二:命中则直接返回
if (cached != null) {
return handleHit(cached, type);
}
// 步骤三:未命中,从数据源加载
T sourceData = loadFromSource(queryParam);
// 钩子方法:是否需要写入缓存(子类可覆盖)
if (shouldCache(sourceData)) {
writeToCache(cacheKey, sourceData);
}
// 步骤四:返回结果
return sourceData;
}
/**
* 从缓存中查找(子类必须实现)
*
* @param cacheKey 缓存键
*/
protected abstract Object findFromCache(String cacheKey);
/**
* 从数据源加载(可在子类中覆盖,但这里提供默认实现)
*/
public abstract T loadFromSource(Q queryParam);
/**
* 写入缓存(子类必须实现)
*
* @param cacheKey 缓存键
* @param sourceData 数据源数据
*/
protected abstract void writeToCache(String cacheKey, T sourceData);
/**
* 处理缓存命中结果(子类可覆盖,默认直接返回)
*/
@SuppressWarnings("unchecked")
protected T handleHit(Object cached, Class<T> type) {
if (type.isInstance(cached)) {
return (T) cached;
}
throw new ClassCastException("缓存数据类型不匹配,期望数据类型: " + type.getName());
}
/**
* 钩子方法:是否应该缓存(子类可选覆盖,默认缓存所有非空值)
*/
protected boolean shouldCache(T sourceData) {
return sourceData != null;
}
/**
* 获取缓存键 (子类可覆盖)
*/
protected String getCacheKey(Q queryParam) {
return String.format(CACHE_KEY_TEMPLATE, this.getClass().getSimpleName(), queryParam);
}
/**
* 清除缓存
*/
public abstract void clearCache();
}
2. Redis 缓存实现
java
// ✅ 模板方法模式:Redis 缓存实现,只关注 Redis 特有的细节
@Setter
public abstract class RedisCacheTemplate<Q, T> extends CacheTemplate<Q, T> {
private static final int DEFAULT_SCAN_COUNT = 500;
private static final String DEFAULT_COMMAND = "UNLINK";
private long cacheTimeout;
private TimeUnit cacheTimeoutUnit;
private RedisTemplate<String, String> redisTemplate;
@Override
protected Object findFromCache(String cacheKey) {
return redisTemplate.opsForValue().get(cacheKey);
}
@Override
protected void writeToCache(String cacheKey, T sourceData) {
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(sourceData),
cacheTimeout,
cacheTimeoutUnit
);
}
@Override
protected T handleHit(Object cached, Class<T> type) {
if (cached instanceof String) {
return JSON.parseObject((String) cached, type);
}
return super.handleHit(cached, type);
}
@Override
public void clearCache() {
String pattern = getClearPattern();
redisTemplate.execute((RedisCallback<Object>) connection -> {
ScanOptions scanOptions = ScanOptions.scanOptions()
.match(pattern)
// 每次 scan 返回的 key 数量,不要太大
.count(DEFAULT_SCAN_COUNT)
.build();
List<byte[]> batch = new ArrayList<>();
Cursor<byte[]> cursor = connection.scan(scanOptions);
try (cursor) {
while (cursor.hasNext()) {
batch.add(cursor.next());
// 攒够一批,pipeline 批量 UNLINK(异步删除)
if (batch.size() >= DEFAULT_SCAN_COUNT) {
connection.execute(DEFAULT_COMMAND, batch.toArray(new byte[0][]));
batch.clear();
}
}
// 处理剩余
if (!batch.isEmpty()) {
connection.execute(DEFAULT_COMMAND, batch.toArray(new byte[0][]));
}
}
return null;
});
}
/**
* 获取清除缓存的 key 匹配模式
* <p>
* 如果重写 getCacheKey 方法,请重写该方法,确保返回的匹配模式符合预期
*
* @return 匹配模式
* @see #getCacheKey(Object)
*/
protected String getClearPattern() {
String cacheKey = getCacheKey(null);
return cacheKey.replaceAll(":null$", ":*");
}
}
3. 本地缓存实现
java
// ✅ 模板方法模式:本地缓存实现
public abstract class LocalCacheTemplate<Q, T> extends CacheTemplate<Q, T> {
private final Map<String, Object> cacheMap = new ConcurrentHashMap<>();
@Override
protected Object findFromCache(String cacheKey) {
return cacheMap.get(cacheKey);
}
@Override
protected void writeToCache(String cacheKey, T sourceData) {
cacheMap.put(cacheKey, Cache.of(cacheKey, sourceData, Instant.now()));
}
@Override
public void clearCache() {
cacheMap.clear();
}
}
4. 重构后的收益对比
至此,已创建缓存框架的基本骨架,需要创建业务数据缓存时,只需继承抽象模板类,并实现loadFromSource(Q queryParam)方法即可。
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 流程一致性 | 每套实现各自写流程,容易走样 | 骨架在父类统一控制,永远不会不一致 |
| 新增缓存类型 | 复制粘贴整套代码,风险高 | 新建子类,只实现 1 个抽象方法 |
| 流程变更 | N 个实现都要改,容易遗漏 | 只改父类的 execute 方法,一处生效 |
| 代码量 | 每套实现缓存逻辑,大量重复 | 每个子类只关注业务差异点 |
| 单元测试 | 测试覆盖流程+细节,用例多 | 骨架测试在父类,子类只测各自细节 |
| 可读性 | 业务逻辑与技术实现混杂 | 一眼看清流程骨架,细节在各自子类中 |
五、现代 Java 中的模板方法模式进阶
Java 8 引入的 Lambda 和函数式接口,可以在某些场景下替代传统的继承式模板方法,用组合代替继承,更轻量:
java
// 函数式版本:用方法参数替代子类继承
public final <T> T executeWithCache(
String key,
Class<T> type,
Supplier<T> sourceLoader, // 数据源加载逻辑
Function<String, Object> cacheReader, // 缓存读取逻辑
BiConsumer<String, T> cacheWriter // 缓存写入逻辑
) {
// 骨架逻辑依然由这里控制
Object cached = cacheReader.apply(key);
if (cached != null) {
return type.cast(cached);
}
T data = sourceLoader.get();
if (data != null) {
cacheWriter.accept(key, data);
}
return data;
}
// 调用方用 Lambda 注入细节
Product product = executeWithCache(
"product:" + skuId,
Product.class,
() -> productMapper.selectById(skuId), // 数据源加载
key -> redisTemplate.opsForValue().get((String) key), // 缓存读取
(key, data) -> redisTemplate.opsForValue().set(
(String) key, JSON.toJSONString(data), 30, TimeUnit.MINUTES
) // 缓存写入
);
但需要注意:函数式版本虽然代码更紧凑,但也有代价:
| 继承式(模板方法) | 函数式(Lambda) |
|---|---|
| 子类可复用多个模板方法 | 每次调用都要传入全部参数 |
| 适合多步骤、复杂骨架 | 适合 1-2 步简单定制 |
| 类型安全,编译期绑定 | Lambda 类型推导有局限性 |
| 钩子方法可扩展性强 | 扩展点需要额外参数 |
经验法则:步骤超过 3 个、或有多个子类共享相同的骨架,用继承式模板方法。简单的一次性定制,用 Lambda。
六、经典应用:你其实早就见过
模板方法模式在 JDK 和各大框架中无处不在:
1. Spring Framework 的 JdbcTemplate
JdbcTemplate 是模板方法模式最经典的实践。它管理了 JDBC 操作的完整骨架:
获取连接 → 创建 Statement → 执行 SQL → 处理 ResultSet → 处理异常 → 释放资源
使用者只需提供一个 RowMapper 或 PreparedStatementCallback 回调,完全不用关心连接的获取、事务管理、异常处理、资源释放等重复逻辑。
java
jdbcTemplate.query("SELECT * FROM product WHERE sku_id = ?",
new Object[]{skuId},
(rs, rowNum) -> new Product(rs.getLong("sku_id"), rs.getString("name")));
2. Spring 的 AbstractApplicationContext.refresh()
Spring IoC 容器的启动过程是一个庞大的模板方法。refresh() 方法定义了容器初始化的 13 个步骤:
prepareRefresh()
→ obtainFreshBeanFactory()
→ prepareBeanFactory()
→ postProcessBeanFactory()
→ invokeBeanFactoryPostProcessors()
→ registerBeanPostProcessors()
→ initMessageSource()
→ initApplicationEventMulticaster()
→ onRefresh()
→ registerListeners()
→ finishBeanFactoryInitialization()
→ finishRefresh()
→ resetCommonCaches()
3. Java 的 AbstractList
java.util.AbstractList 实现了 iterator()、addA()、remove() 等方法的默认行为,子类可根据需要进行重写实现定制集合。
七、使用指南:什么时候用,什么时候别用
✅ 适合用模板方法模式:
- 多个子类有相同的算法骨架,但其中某些步骤实现不同
- 需要严格控制流程顺序,防止子类破坏整体结构
- 存在大量重复的流程代码,可抽取到父类中复用
- 框架性代码:你写好骨架,让别人填充细节(如 JdbcTemplate)
❌ 不要硬套模板方法模式:
- 算法步骤很少变化,没有复用的价值
- 各个子类的实现差异极大,几乎没有公共逻辑可以抽取
- 只有 1-2 个方法需要覆盖,用 Lambda 或函数参数更轻量
- 类层次过深导致维护困难(子类数量膨胀)
💡 重构经验法则:
- 模板方法模式的引入时机是"第三次重复"------第一次直接写,第二次复制粘贴时警觉,第三次必然要抽取骨架。
- 不要在一开始就设计模板方法,让它从重构中自然浮现。
模板方法 vs 策略模式
很多开发者容易混淆这两个模式,这里做一个清晰的对比:
| 维度 | 模板方法模式 | 策略模式 |
|---|---|---|
| 核心思想 | 一样的步骤,不一样的细节 | 一样的接口,完全不同的算法 |
| 复用方式 | 继承复用,子类覆盖特定步骤 | 组合复用,通过委托切换整个算法 |
| 控制权 | 父类控制流程骨架(final 方法) |
调用方控制使用哪个策略 |
| 变化粒度 | 算法中的某几个步骤 | 整个算法 |
| 典型场景 | JdbcTemplate、工作流引擎 | 支付渠道切换、验证规则 |
| 代码结构 | 抽象类 + 多个子类 | 策略接口 + 多个实现类 |
| 运行时切换 | 不适用(编译期绑定) | 可以运行时替换策略 |
一句话概括:模板方法是在"同一个流程"里换细节,策略模式是在"同一个接口"下换算法。前者纵向继承控制流程,后者横向组合替换行为。模板侧重流程复用,策略侧重算法替换。
八、总结
模板方法模式可能是设计模式中最常见却又最容易被忽视的一个,因为它太简单了,以至于很多人觉得"这不就是继承吗"。但正是这种简单,让它成为了框架设计的基石。从 Spring IoC 容器启动,到 JDBC 模板操作,再到本文的缓存方案,模板方法模式的本质始终如一:把不变的部分放在父类里锁定,把可变的部分留给子类去实现。
这套思想的适用范围远不止缓存操作。数据导入导出、批处理任务、单元测试生命周期------只要是"流程固定、细节多变"的场景,模板方法模式都能派上用场。但要记住,模式是重构的结果,不是设计的前提。等你第三次写同样的流程骨架时,再让模板方法模式登场也不迟。