在完成的前面的《Redis结合AOP实现限流注解的开发》之后,文中提到了一个配置管理或者字典管理的功能模块,作为一个对外提供快速查询获取配置信息的功能模块,其数据信息本身可以保存的Mysql数据库中。但如果每次查询都要从Mysql中获取数据则又非常影响接口效率和速度,故需要将对应信息在Redis中做一份缓存,帮助快速查询获取。

基础前置

需要的基础前置知识包括《Redis结合AOP实现限流注解的开发》AOP相关,《Java对象序列化常用操作-Protostuff》Java对象的序列化和反序列化,可以前往对应文章查看。

基础思路是这样的

首先我们需要定一个作用于方法上的注解比如@RedisCache(题外话,Spring自身也也有个类似功能的注解@Cacheable,回头可以再开一篇写下相关的使用方法,本文着重是自己开发一套这样的功能)。之后通过一个RedisCacheAspect类,使用@Around注解实现对添加了@RedisCache注解的方法的代理,并将方法的入参,执行结果序列化之后保存到Redis的一个Hash表中去。当下次请求过来的时候,先根据请求参数去Redis的Hash表中查询一下,如果存在,则将查到的信息反序列化为所要查询的对象直接返回给方法调用者。

代码实现

首先是注解类,我们需要新建一个这样的RedisCache注解类

/**
 * @author CheungQ
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface RedisCache {
    String value();

    String key() default "";
}

之后,我们在自行实现一个切面类,用来定义切面,并实现切面上的功能。使用@Around注解,并在@RedisCache注解作用的方法前查询Redis中是否有缓存,如果有,则返回结果,如果没有则执行@RedisCache作用的方法,得到方法执行结果,并将结果缓存到Redis,待下次请求的时候查询获取。基本逻辑就是这样,具体的可以看下代码中的实现,这里用到了上面提到的前几篇文章中的相关技术细节。代码如下

package com.cheungq.demo.cache.aspect;

import com.cheungq.demo.cache.annotation.RedisCache;
import com.cheungq.demo.cache.util.SerializeUtil;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.*;

/**
 * RedisCacheAspect<br>
 *
 * @author CheungQ
 * @date 2023/4/12 10:57
 */
@Aspect
@Component
public class RedisCacheAspect {

    private static Logger logger = LoggerFactory.getLogger(RedisCacheAspect.class);

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    private StringRedisTemplate stringRedisTemplate;

    @PostConstruct
    public void init() {
        stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setConnectionFactory(redisConnectionFactory);
        stringRedisTemplate.setHashKeySerializer(RedisSerializer.byteArray());
        stringRedisTemplate.setHashValueSerializer(RedisSerializer.byteArray());
        stringRedisTemplate.afterPropertiesSet();
    }

    @Pointcut(value = "@annotation(com.cheungq.demo.cache.annotation.RedisCache)")
    public void pointcut(){}

    @SneakyThrows
    @Around(value = "pointcut()")
    public Object redisCacheAround(ProceedingJoinPoint joinPoint){
        MethodSignature joinPointObject = (MethodSignature) joinPoint.getSignature();
        Method method = joinPointObject.getMethod();
        RedisCache cacheAnnotation = method.getAnnotation(RedisCache.class);
        Class<?> returnType = method.getReturnType();
        String key = cacheAnnotation.value();
        Object[] hashKey = joinPoint.getArgs();
        Object fromRedis = getFromRedis(key, Arrays.asList(hashKey), returnType);
        if (null == fromRedis){
            logger.info("new CACHE "+ Arrays.toString(hashKey));
            Object res = joinPoint.proceed();
            cacheToRedis(key,Arrays.asList(hashKey),res);
            return res;
        }
        logger.info("get from CACHE "+ Arrays.toString(hashKey));
        return fromRedis;
    }


    private void cacheToRedis(String key, Object hashKey, Object hashVal){
        if (null == hashVal){
            return;
        }
        try {
            stringRedisTemplate.opsForHash().put(
                    key,
                    SerializeUtil.serialize(hashKey),
                    SerializeUtil.serialize(hashVal)
            );
        }catch (Exception e){
            logger.error("写入Redis异常",e);
        }
    }

    private <T> T getFromRedis(String key, Object hashKey, Class<T> clazz){
        try {
            byte[] res = (byte[])stringRedisTemplate.opsForHash().get(
                    key,
                    SerializeUtil.serialize(hashKey)
            );
            if (res == null) {
                return null;
            }
            return SerializeUtil.deserialize(res,clazz);
        }catch (Exception e){
            logger.error("读取Redis异常",e);
            return null;
        }
    }
}

简单说下这个类中的几个部分

1. stringRedisTemplate的初始化

我们在注解中定义了一个value值作为写入redis的Key,所有相同的value值的注解的数据都将缓存在同一个key中。那么这个key上的数据结构就需要支持KV结构的数据存储,所以自然的就可以使用Redis的hash结构在保存数据,我们根据入参来生产K,并将结果保存到V上(https://www.redis.net.cn/tutorial/3509.htmlRedis hash 是一个string类型的field和value的映射表,hash特别适合用于存储对象。Redis 中每个 hash 可以存储 232 - 1 键值对(40多亿)。)。那么我们就需要把@RedisCache注解作用的方法上的多个参数进行合并序列化生成作为K,并把方法返回结果的对象也序列化存储为V。

在K和V如何生成构建这个问题上,这里我们其实有两种不同的选择,序列化为字符串或者二进制序列化为byte[]数组,所幸Redis的hash结构对这两种都是支持度的。如果你想要在存入Redis后,仍然保持较好的可视化效果,且不存在存储空间方面的担忧的话,完全可以选择将对象序列化为字符串来存储即可。而如果你对序列化、反序列化性能要求较高、且需要存储及其大量的数据,单个对象序列化后的结果较大的话,那么我们就可以选择二进制序列化为byte[]数组来保存。在多个参数的序列化组合方式上这边其实仍然有一定的操作空间,这边先不展开,后面再花一个段落探讨下。

而在这里我暂时选择了二进制byte[]数组的方式来序列化保存,所以我们就需要对这里使用的StringRedisTemplate的Client实例调整一下,不能使用默认的StringRedisTemplate的Client实例了。通常来说,我们一般使用的Client实例是这样构造的

@Bean(name = "stringRedisTemplate")
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
    StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
    stringRedisTemplate.setConnectionFactory(redisConnectionFactory);
    return stringRedisTemplate;
}

此处的默认构造函数中是这样的

public StringRedisTemplate() {
	setKeySerializer(RedisSerializer.string());
	setValueSerializer(RedisSerializer.string());
	setHashKeySerializer(RedisSerializer.string());
	setHashValueSerializer(RedisSerializer.string());
}

这里执行了setHashKeySerializer(RedisSerializer.string());setHashValueSerializer(RedisSerializer.string());,这样我们在调用的时候就必须传入String型的HashKey和HashValue,因为通过查看put方法的源码我们可以发现,在执行put的时候,源码中会调用构造函数中设置的HashKeySerializer和HashValueSerializer对K、V再进行序列化

调用put方法
再调用rawHashKey方法对Key序列化,在这当中调用hashKeySerializer().serialize(hashKey)进行序列化

而StringRedisSerializer的serialize方法参数为String型的,故这里我们需要使用合适的ByteArrayRedisSerializer

org.springframework.data.redis.serializer.StringRedisSerializer#serialize
org.springframework.data.redis.serializer.ByteArrayRedisSerializer#serialize

综上,为了区别于项目中正常使用的StringRedisTemplate,我们在我们的切面类RedisCacheAspect中单独维护了一个StringRedisTemplate的Client,使用@PostConstruct注解来初始化我们这里自己要使用的StringRedisTemplate,使用项目中之前已经配置过的redisConnectionFactory

@Autowired
private RedisConnectionFactory redisConnectionFactory;

并设置对应的RedisSerializer。完结之后别忘了调用下afterPropertiesSet()方法。

stringRedisTemplate.setHashKeySerializer(RedisSerializer.byteArray());
        stringRedisTemplate.setHashValueSerializer(RedisSerializer.byteArray());        stringRedisTemplate.afterPropertiesSet();

2.pointcut()切点的定义

切点我们直接根据注解来定义即可,@annotation(com.cheungq.demo.cache.annotation.RedisCache)同之前的AOP的文章中提到的一样(AOP)

3.@Around注解的切面方法redisCacheAround

此方法中实现具体的读Redis缓存/执行目标方法/写Redis缓存的逻辑。先读Redis,如果没有结果,则执行目标方法获取结果,并将结果再缓存到Redis。当然这里写的比较简单,并没有去做我们在考虑Redis和数据库缓存一致问题上的“双删策略”。如果有这方面的需求,我们可以考虑以后再去做扩展延伸,因为在本文的一开始已经提到了,在目前的应用场景下只是作为一个字典配置项的缓存模块来工作的,并不涉及一些高并发场景下的高频更新的情况,即一次缓存之后基本很长时间内不会再变更。

在该方法中,我们将从Redis读取和写入Redis这两部分逻辑进行了封装,提取出cacheToRedisgetFromRedis两个方法。在这两个方法中调用我们之前java对象序列化的文章中写出的SerializeUtil工具类进行序列化和反序列操作。

在从Redis读取的时候我们将方法的参数合并成hashKey进行序列化,如果读取到了结果则调用SerializeUtil.deserialize进行反序列化为目标方法的返回结果类型的对象。在写入Redis的时候,我们同样将方法的参数合并成hashKey进行序列化,并将目标方法执行后的返回结果也调用SerializeUtil.serialize进行序列化成byte[]数组后写入到Redis。

实际使用

首先我们定义一个字典项的对象类DictionaryInfo

public class DictionaryInfo {
    private String dictCode;

    private String dictName;
    private Map<String,String> dictItemMap;

    //get、set方法省略    
}

同时再新建一个字典配置功能的DictionaryService类,具体读写数据库逻辑不在这里写出了,只做一个演示。这里我们给方法加上了我们自己开发的@RedisCache注解,并给value赋值为“dict_cache”,那么我们回头缓存数据就会写到Redis的”dict_cache”这个Key上

@Service
public class DictionaryService {

    @RedisCache(value = "dict_cache")
    public Result<DictionaryInfo> cacheFunc(String dictionaryCode){
        DictionaryInfo info = new DictionaryInfo();
        info.setDictCode(dictionaryCode);
        info.setDictName("接口参数配置信息");
        Map<String,String> map = new HashMap<>();
        map.put("appId","456123126489");
        map.put("appSecret","gg37r9bu9aio109h21o1289iohngrf12013hbnfsd");
        info.setDictItemMap(map);
        return new Result<>(info);
    }
}

另外我们在写出一个模拟调用的Controller入口来调用当前方法,这里有一点需要注意下,和所有其他基于AOP切面运行(如@Async异步执行注解这种)的注解一样,如果是在当前类内调用了相关需要AOP切面执行的方法,注解效果是并不会生效的。简单提一嘴原因,因为这类注解是依赖于代理对象执行的,而在当前类内部调用执行的时候并不会调用到代理对象,具体的跟详细的说明可以另行拓展了解。那么我们调用的Controller方法基本如下

@RequestMapping(value = "test", params = "dicKey")
public Object testCache(@RequestParam String dicKey) {
    Assert.hasLength(dicKey,"字典编码不能为空");
    return dictionaryService.cacheFunc(dicKey);
}

启动项目,并访问当前这个controller方法,我们可以看到得到了模拟返回的字典项信息

请求controller接口获得返回信息

并且可以看到第一次调用的时候,控制台的对应输出了写入Redis缓存的输出日志。另外再多刷新几遍,则可以看到从Redis缓存读取数据的输出日志

同时我们可以通过RDM在Redis中看到写入的数据,同时对应的key、value都是序列化之后的结果

那么,到这里我们这份代码的基本功能已经全部完成了。

一些不足和问题

  1. @RedisCache注解作用的方法必须要有参数,如果目标方法没有参数的话,则这边的hashKey生成会有问题
  2. 目前这边的hashKey是把所有参数序列化之后生成的,为了是这边hashKey生成更加优雅,且如果目标方法传入参数是一个大对象的时候,大对象的本身大小,或者入参大对象中和获取结果无关的成员变量的值的变化都会造成影响,所以我们这边需要一个更加明确的hashKey生成规则
  3. 在前面的段落中提到的缓存一致性的解决方案问题
  4. 如果目标方法执行结果为空,我们是否该将结果进行缓存,及如何缓存的问题
  5. 除了当前直接缓存数据,我们仍有一些其他的一些场景,比如怎样设定定时过期的缓存(Expire)、如何主动删除/更新缓存的方法
  6. 另外我们将演示下序列化规则更改为JSON序列化成字符串,这样可以在RDM或者类似的工具中获得一个比较好的观察效果