Java模板方法模式:缓存操作重复写?把骨架抽出来

文章标签: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 → 处理异常 → 释放资源

使用者只需提供一个 RowMapperPreparedStatementCallback 回调,完全不用关心连接的获取、事务管理、异常处理、资源释放等重复逻辑。

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 模板操作,再到本文的缓存方案,模板方法模式的本质始终如一:把不变的部分放在父类里锁定,把可变的部分留给子类去实现。

这套思想的适用范围远不止缓存操作。数据导入导出、批处理任务、单元测试生命周期------只要是"流程固定、细节多变"的场景,模板方法模式都能派上用场。但要记住,模式是重构的结果,不是设计的前提。等你第三次写同样的流程骨架时,再让模板方法模式登场也不迟。

相关推荐
傅科摆 _ py1 小时前
AI Ping 平台使用教程
java·前端·人工智能
风味蘑菇干1 小时前
JDBC(数据库连接池&DBUtils)
java·数据库
Chengbei111 小时前
CTF & 红队专用 AI 求解AI 引擎 Cairn 系统,化轻量化部署,红队、CTF、漏洞研究一站式解决方案
java·人工智能·安全·web安全·网络安全·系统安全
墨白曦煜1 小时前
算法实战笔记:空间换时间的黑魔法——单调栈全景解析(十一)
java·笔记·算法
AI玫瑰助手1 小时前
Python函数:函数的文档字符串(docstring)编写
android·java·python
周末也要写八哥2 小时前
线程的生命周期之“守护“线程
java·开发语言·jvm
乐之者v2 小时前
地图技术后端开发的知识点
java
亦暖筑序2 小时前
Java 8老系统AI工具接入:API包装成受控工具,只读优先+权限拦截
java·人工智能·aigc·企业架构·mcp协议
砍材农夫2 小时前
物联网实战:Spring Boot + Netty 搭建 MQTT 统一接入层
java·网络·spring boot·后端·物联网·spring