一步步教你实现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 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还是欠缺很多,但是我觉得已经能覆盖大部分应用场景了。希望通过本文的抛砖,能够给大家一点启发。

你可能感兴趣的:(一步步教你实现Spring Cache)