一步步教你实现Spring Cache

Spring cache是对缓存使用的抽象,通过它我们可以在不侵入业务代码的基础上让现有代码即刻支持缓存。但是使用中,我发现Spring cache不支持对每个缓存key设置失效时间(只支持设置一个全局的失效时间),所以我产生了重复造轮子的冲动,于是就有了这篇文章

Spring cache 简介

Spring cache主要提供了以下几个Annotation:

注解 适用的方法类型 作用
@Cacheable 读数据 方法被调用时,先从缓存中读取数据,如果缓存没有找到数据,再调用方法获取数据,然后把数据添加到缓存中
@CachePut 写数据:如新增/修改数据 调用方法时会自动把相应的数据放入缓存
@CacheEvict 删除数据 调用方法时会从缓存中移除相应的数据

网上介绍Spring cache的文章很多,所以本文不想在这里进行太多的介绍。有兴趣的童鞋可以参阅一下以下几篇博文:注释驱动的 Spring cache 缓存介绍Spring Cache抽象详解Spring cache官方文档

Spring cache官方文档里有专门解释它为什么没有提供设置失效时间的功能:

--- How can I set the TTL/TTI/Eviction policy/XXX feature?
--- Directly through your cache provider. The cache abstraction is…​ well, an abstraction not a cache implementation. The solution you are using might support various data policies and different topologies which other solutions do not (take for example the JDK ConcurrentHashMap) - exposing that in the cache abstraction would be useless simply because there would no backing support. Such functionality should be controlled directly through the backing cache, when configuring it or through its native API.

这里简单意会一下:Spring cache只是对缓存的抽象,并不是缓存的一个具体实现。不同的具体缓存实现方案可能会有各自不同的特性。Spring cache作为缓存的抽象,它只能抽象出共同的属性/功能,对于无法统一的那部分属性/功能它就无能为力了,这部分差异化的属性/功能应该由具体的缓存实现者去配置。如果Spring cache提供一些差异化的属性,那么有一些缓存提供者不支持这个属性怎么办? 所以Spring cache就干脆不提供这些配置了。

这就解释了Spring cache不提供缓存失效时间相关配置的原因:因为并不是所有的缓存实现方案都支持设置缓存的失效时间。

为了既能使用Spring cache提供的便利,又能简单的设置缓存失效时间,网上我也搜到了一些别人的解决方案:spring-data-redis 扩展实现时效设置扩展基于注解的spring缓存,使缓存有效期的设置支持方法级别-redis篇。看过别人的解决方案后我觉得都不是太完美,并且我还想进一步实现其他的一些特性,所以我决定还是自己动手了。

实现Spring Cache

首先,我们定义自己的@Cacheable注解:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {

    /**
     * 缓存命名空间
     * 如果是本地缓存,namespace相同的缓存数据存放在同一个map中
     * 如果是redis缓存,缓存的key将会加上namespace作为前缀,为:{namespace}:key
     * @return
     */
    String namespace();

    /**
     * 缓存key表达式,支持常量字符串/请求参数表达式
     * @return
     */
    String key();

    /**
     * 存储类型:Local(本地缓存) or  Redis缓存
     * @return
     */
    Storage storage() default Storage.redis;

    /**
     * 缓存存活时间,单位秒:支持数字/数字表达式或本地配置文件参数
     * 例如:${orderCacheTTL:10},其中orderCacheTTL为本地配置文件中的配置参数,10为默认值
     * @return
     */
    String ttl() default "0";
}
public enum Storage {
    local, redis
}

对比一下Spring cache中对@Cacheable的声明,可以看出二者除了注解名字相同,其它的基本都不一样了。下面对@Cacheable先做一下详细说明:

  • @Cacheable的作用跟Spring中的一样,都是作用于“读方法”之上,能够透明地给该方法加上缓存功能
  • storage属性,支持两种值:local(本地缓存),redis(redis缓存),这样给本地缓存,redis缓存对外提供了一致的使用方式,能够很方便灵活地在二者中切换
  • namespace属性:给缓存增加了命名空间的概念,减少缓存key冲突的问题
  • key属性:缓存的key,支持常量字符串及简单的表达式。例如:我们如果需要缓存的数据是不随请求参数改变而变化的,缓存的key可以是一个常量字符串。如下面的例子:
//将会被缓存在redis中,缓存失效时间是24小时,在redis中的key为:common:books,值为json字符串
@Cacheable(namespace="common", key="books", ttl="24*60*60", storage=Storage.redis)
public List<Book> findBooks(){
    //(代码略)
}
  • key也支持简单的表达式(目前我实现的版本支持的表达式形式没有Spring cache那么强大,因为我觉得够用了):使用$0,$1,$2......分别表示方法的第一个参数值,第二个参数值,第三个参数值......。比如:$0.name,表示第一个请求参数的name属性的值作为缓存的key;$1.generateKey(),表示执行第二个请求参数的generateKey()方法的返回值作为缓存的key。如下面的例子:
//将会被缓存在redis中,缓存失效时间是600s,请求参数为user.id,比如:请求参数user的id=1 则在redis中的key为:accounts:1,值为json字符串
@Cacheable(namespace="accounts", key="$0.id", ttl="600", storage=Storage.redis)
public Account getAccount(User user){
    //(代码略)
}
  • ttl属性:缓存的失效时间。既支持简单的数字,也支持数字表达式,还支持配置文件参数(毕竟是自己实现的,想支持什么都可以实现)

好了,@Cacheable讲解完了,下面来看一下是怎么实现声明式缓存的吧,先贴代码:

@Component
public class CacheableMethodHandler {
    private Logger logger = LoggerFactory.getLogger(getClass());

    public Object handle(MethodInvocation invocation) throws Throwable {
        Object result = null;
        Cacheable cacheable = null;
        String key = null;
        Cache cache = null;
        try {
            Method method = invocation.getMethod();
            cacheable = method.getAnnotation(Cacheable.class);
            key = ExpressParserSupport.parseKey(cacheable.key(), invocation.getArguments());
            cache = CacheFactory.getCache(cacheable.storage(), cacheable.namespace());
            CachedValue cachedValue = cache.get(key);

            if (cachedValue == null || cachedValue.getValue() == null) {
                return this.handleCacheMissing(cacheable, cache, key, invocation);
            }
            if (this.isCacheExpired(cachedValue)) {
                return this.handleCacheExpired(cacheable, cache, key, cachedValue, invocation);
            }
            return this.handleCacheHit(cachedValue, method.getGenericReturnType());
        } catch (MethodExecuteException e1) {
            logger.error("Exception occurred when execute proxied method", e1);
            throw e1.getCause();
        } catch (Exception e2) {
            logger.error("Exception occurred when handle cache", e2);
            result = invocation.proceed();
            this.putIntoCache(cacheable, cache, key, result);
        }
        return result;
    }

    private Object handleCacheHit(CachedValue cachedValue, Type returnType) {
        Object value = cachedValue.getValue();
        return ValueDeserializeSupport.deserialize(value, returnType);
    }

    private Object handleCacheExpired(Cacheable cacheable, Cache cache, String key, CachedValue cachedValue,
                    MethodInvocation invocation) {
        return this.handleCacheMissing(cacheable, cache, key, invocation);
    }

    private Object handleCacheMissing(Cacheable cacheable, Cache cache, String key, MethodInvocation invocation) {
        Object result = this.executeProxiedTargetMethod(invocation);
        if (result != null) {
            this.putIntoCache(cacheable, cache, key, result);
        }
        return result;
    }

    private void putIntoCache(Cacheable cacheable, Cache cache, String key, Object value) {
        try {
            CachedValue cachedValue = this.wrapValue(value, ExpressParserSupport.parseTtl(cacheable.ttl()));
            cache.put(key, cachedValue);
        } catch (Exception e) {
            logger.error("Put into cache error", e);
        }

    }
    
    private Object executeProxiedTargetMethod(MethodInvocation invocation) {
        try {
            return invocation.proceed();
        } catch (Throwable throwable) {
            throw new MethodExecuteException(throwable);
        }
    }
    
    private boolean isCacheExpired(CachedValue cachedValue) {
        return (cachedValue.getExpiredAt() > 0 && System.currentTimeMillis() > cachedValue.getExpiredAt());
    } 

    private CachedValue wrapValue(Object value, int ttl) {
        CachedValue cachedValue = new CachedValue();
        cachedValue.setValue(value);
        cachedValue.setTtl(ttl);
        return cachedValue;
    }
}
@Component
public class CachePointcutAdvisor extends AbstractPointcutAdvisor {

    private final StaticMethodMatcherPointcut pointcut = new StaticMethodMatcherPointcut() {
        @Override
        public boolean matches(Method method, Class<?> targetClass) {
            return method.isAnnotationPresent(Cacheable.class) || method.isAnnotationPresent(CachePut.class)
                            || method.isAnnotationPresent(CacheEvict.class);
        }
    };

    @Autowired
    private CacheMethodInterceptor cacheMethodInterceptor;


    @Override
    public Pointcut getPointcut() {
        return pointcut;
    }

    @Override
    public Advice getAdvice() {
        return cacheMethodInterceptor;
    }
}

可以看到,这里是利用了Spring提供的AOP来实现的。通过AOP来达到对声明了@Cacheable方法的拦截及代理,代码逻辑也比较简单:先根据namespace及key查看是否存在缓存,如果存在且缓存尚未失效则直接返回缓存值;否则直接执行被代理类对象的方法并缓存执行结果然后再返回执行结果。

在这里面,其实也有很多可以优化或扩展的点,比如我们可以针对缓存失效的情况进行进一步扩展:如果发现缓存已失效,可以先返回已“过期”的数据,然后通过后台线程异步刷新缓存

下面贴一下方法拦截器里面使用到的其它关键类代码:

//Cache接口声明
public interface Cache {

    String getName();

    CachedValue get(String key);

    void put(String key, CachedValue value);

    void clear();

    void delete(String key);

}
//根据命名空间及缓存类型查找或新生成对应的Cache
public class CacheFactory {

    private static LocalCacheManager localCacheManager = new LocalCacheManager();
    private static RedisCacheManager redisCacheManager = new RedisCacheManager();

    private CacheFactory() {}

    public static Cache getCache(Storage storage, String namespace) {
        if (storage == Storage.local) {
            return localCacheManager.getCache(namespace);
        } else if (storage == Storage.redis) {
            return redisCacheManager.getCache(namespace);
        } else {
            throw new IllegalArgumentException("Unknown storage type:" + storage);
        }
    }

}
//Redis Cache的实现
public class RedisCache implements Cache {
    
    private String namespace;
    private RedisCacheProvider cacheProvider;

    public RedisCache(String namespace) {
        this.namespace = namespace;
        this.cacheProvider = new RedisCacheProvider();
    }

    @Override
    public String getName() {
        return "Redis:" + this.namespace;
    }

    @Override
    public CachedValue get(String key) {
        String value = this.cacheProvider.get(this.buildCacheKey(key));
        CachedValue cachedValue = null;
        if (value != null) {
            cachedValue = new CachedValue();
            cachedValue.setValue(value);
        }
        return cachedValue;
    }

    @Override
    public void put(String key, CachedValue cachedValue) {
        Object value = cachedValue.getValue();
        if (value != null) {
            String json;
            if (value instanceof String) {
                json = (String) value;
            } else {
                json = JsonUtil.toJson(value);
            }
            String cacheKey = this.buildCacheKey(key);
            if (cachedValue.getTtl() > 0) {
                this.cacheProvider.setex(cacheKey, cachedValue.getTtl(), json);
            } else {
                this.cacheProvider.set(cacheKey, json);
            }
        }
    }

    @Override
    public void clear() {
        //do nothing
    }

    @Override
    public void delete(String key) {
        this.cacheProvider.del(this.buildCacheKey(key));
    }

    private String buildCacheKey(String key) {
        return this.namespace+":"+ key;
    }
}

其它的关于key表达式/ttl表达式的解析(关于表达式的解析大家可以参考Spring Expression Language官方文档),缓存的值的序列化反序列化等代码因为篇幅关系就不贴出来了。有疑问的童鞋可以回帖问我。

当然,这里实现的Cache的功能跟Spring cache还是欠缺很多,但是我觉得已经能覆盖大部分应用场景了。希望通过本文的抛砖,能够给大家一点启发。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,548评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,497评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,990评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,618评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,618评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,246评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,819评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,725评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,268评论 1 320
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,356评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,488评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,181评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,862评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,331评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,445评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,897评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,500评论 2 359

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,696评论 18 139
  • Spring Boot 参考指南 介绍 转载自:https://www.gitbook.com/book/qbgb...
    毛宇鹏阅读 46,847评论 6 342
  • application的配置属性。 这些属性是否生效取决于对应的组件是否声明为Spring应用程序上下文里的Bea...
    新签名阅读 5,381评论 1 27
  • 本文转自被遗忘的博客 Spring Cache 缓存是实际工作中非常常用的一种提高性能的方法, 我们会在许多场景下...
    quiterr阅读 1,080评论 0 8
  • 这些属性是否生效取决于对应的组件是否声明为 Spring 应用程序上下文里的 Bean(基本是自动配置的),为一个...
    发光的鱼阅读 1,431评论 0 14