Redis做缓存,怎么样才是正确的使用方法?
如何使用Redis做缓存
我们都知道Redis作为NoSql数据库的代表之一,通常会用来作为缓存使用。也是我在工作中通常使用的缓存之一。
1、我们什么时候缓存需要用到Redis?
我认为,缓存可以分为两大类:本地缓存和分布式缓存。当我们一个分布式系统就会考虑到缓存一致性的问题,所以需要使用到一个快速的、高并发的、灵活的存储服务,那么Redis就能很好的满足这些。
本地缓存:
即把缓存信息存储到应用内存内部,不能跨应用读取。所以这样的缓存的读写效率上是非常高的,因为节省了http的调用时间。问题是不能跨服务读取,在分布式系统中可能会找成每个机器缓存内容不同的问题。分布式缓存:
即把缓存内容存储到单独的缓存系统中,当调用时,去指定缓存服务取数据,因此就不会出现本地缓存的多系统缓存数据不同的问题。
SpringBoot连接Redis配置(本来懒得写的, 但是我还是追求完美一点):
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId><version>2.4.2</version></dependency>
RedisClient我使用的是SpringBoot2.0后自带的lettuce框架,而并非jredis。
spring.redis.database=0# Redis服务器地址spring.redis.host=81.70.xx.xx记得改喽,如果没有,可以私信我,我吧我的告诉你# Redis服务器连接端口spring.redis.port=6379spring.redis.timeout=5000# Redis服务器连接密码(默认为空)spring.redis.password=zxxxspring.redis.lettuce.pool.max-active=8spring.redis.lettuce.pool.min-idle=1spring.redis.lettuce.pool.max-idle=8spring.redis.lettuce.pool.max-wait=500msspring.redis.lettuce.shutdown-timeout=100ms
2、 缓存雏形 - 根据业务逻辑手撸代码
在不需要大面积使用缓存的系统中,我们通常把Redis作为一种中间工具去使用。需在代码逻辑中加入自己的判断。
public String baseCache(String name) {if(StringUtils.isBlank(name)){logger.error("Into BaseCache Service, Name is null.");return null;}logger.info("Into BaseCache Service, {}", name);//手动加入缓存逻辑String value = stringRedisTemplate.opsForValue().get("cache_sign:" + name);if(!StringUtils.isBlank(value)){return value;}else{value = String.valueOf(++BASE_CACHE_SIGN);stringRedisTemplate.opsForValue().set("cache_sign:" + name, value, 60, TimeUnit.SECONDS);return String.valueOf(BASE_CACHE_SIGN);}}
3、通用缓存 - 使用Aop或者Interceptor实现
个别接口或方法我们可以手撸代码,但是不管是后期维护还是代码的通用性都是比较局限的。所以与其在业务逻辑中增加判断逻辑,不如写一个通用的。
3.1 先定义一个注解
我们通过这个注解来区别方法是否需要缓存,注解放到方法上,此方法的返回结果将会被缓存。
public UseCache {}
3.2 使用SpringMvc的拦截器,对接口结果进行缓存。
我们将从Redis取缓存结果提取到拦截器中,这样我们就可以只通过一个注解去标识是否执行缓存操作。
3.2.1 拦截器: HandleInterceptorAdapter
拦截器的作用我在这里就不过多的说明。如果在拦截器中发现此接口包含UseCache注解,我们需要检查Redis是否存在缓存,如果存在缓存,则直接返回其值即可。
代码如下:
/*** 缓存拦截器*/public class CustomCacheInterceptor extends HandlerInterceptorAdapter {private static final Logger logger = LoggerFactory.getLogger(CustomCacheInterceptor.class);/** RedisClient */private final StringRedisTemplate stringRedisTemplate;public CustomCacheInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}/*** 我们只需要实现preHandle方法即可,此方法会在接口调用前被调用,所以可以在这里判断缓存,如果存在缓存,直接返回即可。*/public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {logger.info("Into Controller Before. This handler :{}", handler.getClass().getName());if (handler instanceof HandlerMethod) {HandlerMethod method = (HandlerMethod) handler;//判断是否存在我们定义的缓存注解boolean useCache = method.hasMethodAnnotation(UseCache.class);//我们只对json进行缓存,其他的同理,所以再判断一下这个Controller是哪种接口方式。(包名+方法名+参数)boolean methodResponseBody = method.hasMethodAnnotation(ResponseBody.class);boolean classResponseBody = method.getBeanType().isAnnotationPresent(ResponseBody.class);boolean restController = method.getBeanType().isAnnotationPresent(RestController.class);if (useCache && (methodResponseBody || classResponseBody || restController)) {logger.info("This Method:{} Is UseCache", method.getMethod().getName());//我们使用一个工具类去生成这个方法的一个唯一key,使用此key当作redisKey。String cacheKey = CacheUtils.keySerialization(request, method.getMethod());//从Redis中取数据String responseValue = stringRedisTemplate.opsForValue().get(cacheKey);if (StringUtils.isNoneBlank(responseValue)) {//此方法存在缓存,且拿到了缓存值,所以直接返回给客户端即可,不需要再继续下一步PrintWriter writer = response.getWriter();writer.append(responseValue);writer.flush();writer.close();response.flushBuffer();return false;}}}return true;}}
3.2.2 ResponseBodyAdvice 和 @ControllerAdvice
上述中我们在拦截器中拦截了使用缓存且存在缓存的请求,直接返回缓存内容。但是还存在一个问题:我们从哪个地方将数据写入Redis?
我之前考虑再重写HandleInterceptorAdapter.postHandle(...)方法,然后在处理完成Controller后,拦截处理结果,将结果放入Redis。但是出现以下问题:
虽然能够正常调用postHandle(...)方法,但是大多进行缓存的都是ResponseBody数据,这样的数据并不会存放到ModleAndView中,当然也不会在DispatcherServlet中处理ModleAndView。所以并不能从ModleAndView中获取执行结果。
我打算从response中找到要返回到客户端的数据。但是从上述方法我们就可以知道,response发送数据是使用流的方式,当Controller执行结束之后,postHandle之前就把数据写入了流中。如果重置输出流太过麻烦。
所以我不能继续使用此拦截器去获取结果。
解决:在调用完Controller之后,response写出之前,Springboot会调用一个通知:org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice。所以我们就可以实现这个接口去对response的body数据进行处理。
代码如下:
/*** SpringBoot提供RestControllerAdvice注解,此注解为使用@ResponseBody的Controller生成一个Aop通知。* 然后我们实现了ResponseBodyAdvice的方法:supports(...) 和 beforeBodyWrite(...)*/@RestControllerAdvicepublic class ControllerResponseBody implements ResponseBodyAdvice<Object> {private static final Logger logger = LoggerFactory.getLogger(ControllerResponseBody.class);/** RedisClient */private final StringRedisTemplate redisTemplate;@Autowiredpublic ApiResponseBody(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}/*** 此方法返回boolean类型值,主要是通过返回值确认是否走beforeBodyWrite方法*/@Overridepublic boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {logger.info("Into supports");Method method = returnType.getMethod();if(method != null){logger.info("Find This method use cache.");return method.isAnnotationPresent(UseCache.class);}return false;}/*** 这个方法调用在response响应之前,且方法参数是包含Controller的处理结果的。*/@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {try{//将返回值转换为json,方便存储到redis。String value = JsonUtils.toGJsonString(body);// 拼接keyString cacheKey = CacheUtils.keySerialization(request, returnType.getMethod());if(StringUtils.isNoneBlank(cacheKey)){// 设置缓存60sredisTemplate.opsForValue().set(cacheKey, value, 60, TimeUnit.SECONDS);}logger.info("cache controller return content.");}catch (Exception e){logger.error("Cache Exception:{}", e.getMessage(), e);}return body;}}
3.2.3 使用测试
我们设置一下redis,使用SpringBoot默认的Lettuce,因为设置比较简便,而且呢,据说性能也不错,毕竟能让Springboot默认至此,不会差到哪里去
# Redis数据库索引(默认为0)spring.redis.database=0# Redis服务器地址spring.redis.host=172.0.0.1# Redis服务器连接端口spring.redis.port=6379spring.redis.timeout=500# Redis服务器连接密码(默认为空)spring.redis.password=xxxspring.redis.lettuce.pool.max-active=8spring.redis.lettuce.pool.min-idle=1spring.redis.lettuce.pool.max-idle=8spring.redis.lettuce.pool.max-wait=500msspring.redis.lettuce.shutdown-timeout=100ms
上面我们家了个拦截器,在Spring中我们通过配置web.xml去注册拦截器,在SpringBoot中更加简单
public class WebConfig implements WebMvcConfigurer {private CustomCacheInterceptor cacheInterceptor;public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(cacheInterceptor).addPathPatterns("/cache/**");}}
对缓存的操作我们分别在通知和拦截其中都已经实现,那么我们就可以使用了,在我们的接口方法中使用@UseCache注解。
public class CacheController {private static final Logger logger = LoggerFactory.getLogger(CacheController.class);private CacheService cacheService;/*** 使用@UseCache注解*/private String interceptorCache(String name){logger.info("Into BaseCache Controller, {}", name);String result = cacheService.incr(name);return "OK" + " - " + name + " - " + result;}}
我就不再复制结果了,自己试一试吧
3.2.4 反馈
这个是一个最基础的缓存了,可以通过自己需求去扩展:如使用spel为注解UseCache自定义缓存key、自定义缓存时间等等。
我们使用拦截器的方法有一个局限,即只能对请求的整个接口去做缓存,但是有些时候我们的需求不是对整个接口进行缓存,可能只想对service缓存,可能想对某个sql缓存。所以局限性还是存在的。
3.3 使用Spring Aop + 注解实现缓存
上面我们说到了使用拦截器实现时,只能对整个接口进行缓存。所以我们换一种思路:面向切面编程,即使用AOP。
SpringBoot Aop专用包:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
3.3.1 我们对上一个的缓存再优化一下吧
通常我们使用缓存会存在各种不同的需求,如缓存key,缓存时间,缓存条件等等。所以我们学着CacheAble注解,使用Spel表达式自定义key和超时时间。
/*** 自定义注解使用缓存*/(RetentionPolicy.RUNTIME)({ElementType.METHOD})public UseAopCache {/*** 自定义缓存Key,支持spel表达式*/String customKey() default "" ;/*** 超时时间*/long timeOut() default 60000;}
3.3.2 AOP解决思路
Spring AOP相对与拦截器来说提供了更好的参数支持,所以我们能够更加全面的进行缓存操作。Aop中有前置通知、后置通知、返回通知、异常通知、环绕通知这几种,具体的区别就不在这里仔细讲解了,关注后续我的文档吧,我会写一个专门介绍Aop的文章。
这里我们就选用环绕通知,因为一个环绕通知就完全解决我们的缓存问题。使得缓存面可以缩小到每个方法上。
实现缓存拦截的切入点 -- 注解方法/类
我们可以直接在AOP中配置切入点,我们使用的是通过注解来判断是否缓存及其缓存策略,恰好AOP同样支持。
如果我们需要对整个类进行拦截缓存,我们的AOP同样可以完美实现。(我的DEMO中就不再细说了,我只说一下方法上注解,关于注解放到类上自己琢磨一下,道理都是一样的)
所以说,我们通过AOP来绑定具体的拦截方法实现缓存 -- 环绕通知
AOP面向切面编程是非常灵活的,我就特别喜欢环绕通知。
选择环绕通知因为:1、一个方法可以满足我们的现在做缓存的需求;2、方法执行前后可控;3、可获取更多的参数,包括但不局限于目标方法、形参、实参、目标类等;4、拥有更全面的参数就可以至此更全面的Spel表达式;5、可直接获取方法返回值;等等
我们可以在执行方法前判断是否存在缓存,不存在缓存我们再继续执行方法,否则直接返回Redis中的缓存数据了。缓存灵活性 -- 注解变量及其Spel表达式
像CacheAble一样支持Spel表达式其实就是为了满足更多的业务需求。比如自定义缓存key、设置不同的缓存时间、设置缓存条件和不缓存条件、设置更新缓存条件等等。所以这里需要使用注解中的一些东西去动态的判断缓存逻辑。
我先举个例子:使用spel自定义缓存key。如果有兴趣,可以根据这个继续扩展。
3.3.3 具体实现
逻辑很简单:
环绕通知前, 解析缓存Key, 判断Redis中是否存在缓存
不存在缓存就执行目标方法
获取到方法执行结果, 进行缓存
返回此次结果
public class CacheAdvice {/** 用来解析Spel表达式, 这个是我自己实现的一个类,下面会具体详解 */private CacheOperatorExpression cacheOperatorExpression = new CacheOperatorExpression();/** Redis */private final StringRedisTemplate redisTemplate;public CacheAdvice(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}(value = "execution(* com.nouser..*.*(..))")public void cachePointcut() { }/*** 我们的切入点就是含有@UseAopCache注解的方法,@annotation里填的是对应的参数的名字,Aop会自动封装。* 当然,我们也可以使用@annotation(com.nouser.config.annotations.UseAopCache), 这样的话, 注解需要我们自己从joinPoint中解析。* 同样支持使用@Pointcut(value = "@annotation(com.nouser.config.annotations.UseAopCache)定义切面。* 我这里也定义了一个切面cachePointcut(), 取了并的关系, 是为了防止注解越界吧, 万一引用的包中存在同名的注解呢.*/(value = "cachePointcut() && @annotation(useAopCache)")public Object aroundAdvice(ProceedingJoinPoint joinPoint, UseAopCache useAopCache) throws Throwable {String keySpel = useAopCache.customKey();//获取Redis缓存KeyString key = getRedisKey(joinPoint, keySpel);//读取redis数据缓存数据String result = redisTemplate.opsForValue().get(key);if(StringUtils.isNoneBlank(result)){//存在缓存结果, 将缓存的json转换成Object返回return JsonUtils.parseObject4G(result);}//不存在缓存数据,执行方法, 获取结果, 再放入Redis中Object returnObject = joinPoint.proceed();//这里我没有对null数据进行缓存, 也可以在注解中设置对应的不缓存策略if(returnObject == null){return returnObject;}// 转换结果为JsonString cacheJson = JsonUtils.toGJsonString(returnObject);// 将Json缓存到Redis, 不要忘记重注解中获取缓存时间, 设置Redis的key过期时间redisTemplate.opsForValue().set(key, cacheJson, useAopCache.timeOut(), TimeUnit.MILLISECONDS);return returnObject;}/*** 从joinPoint中获取方法的上下文环境,然后从Spel表达式中解析出key*/private String getRedisKey(ProceedingJoinPoint joinPoint, String keySpel) {if (StringUtils.isNoneBlank(keySpel)) {return cacheOperatorExpression.generateKey(keySpel, joinPoint);}return defaultKey(joinPoint);}/*** 如果没有在注解的customKey()中设置Spel表达式, 我们总不能报错吧, 这里提供一个默认的Key, 数据都冲joinPoint中获取* packageName + ':' + methodName + '#' + param* 为防止param中存在特殊字符, 这里之保留[a-zA-Z0-9:#_.]*/private String defaultKey(ProceedingJoinPoint joinPoint) {StringBuilder key = new StringBuilder();String className = joinPoint.getTarget().getClass().getName();MethodSignature signature = (MethodSignature) joinPoint.getSignature();Method method = signature.getMethod();String methodName = method.getName();Object[] args = joinPoint.getArgs();key.append(className).append(":").append(methodName).append("#");if (args != null && args.length > 0) {for (Object arg : args) {key.append(arg).append("#");}}return key.toString().replaceAll("[^a-zA-Z0-9:#_.]", "");}}
3.3.4 Spel表达式解析(简单介绍一下, 具体请关注以后的博客)
Spel表达式(赶快画重点了, 这是个非常新奇的东西, 会有很多小妙用的), 全称:Spring Expression Language, 类似于Struts2x中使用的OGNL表达式语言,能在运行时构建复杂表达式、存取对象图属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。SpEL是单独模块,只依赖于core模块,不依赖于其他模块,可以单独使用。
我们主要是对注解中的自定义key进行解析, 生成缓存真正key。解析Spel表达式主要需要两个参数:解析器和上下文环境。
解析器:org.springframework.expression.spel.standard.SpelExpressionParser
上下文环境我看网上大多直接使用的StandardEvaluationContext, 但是我们在这个注解中主要是相关方法的解析, 所以建议使用StandardEvaluationContext的子类MethodBasedEvaluationContext。在Spring中解析CacheAble注解中的key同样是使用MethodBasedEvaluationContext的子类。
MethodBasedEvaluationContext在添加上下文环境的变量时,使用了懒加载, 当我们注解中的key不使用参数时,就不再添加上下文的变量,在使用的时候才去进行懒加载.而且相对于网上的一些实现, 官方实现更加靠谱. 也更加全面.
我对MethodBaseEvaluationContent简单做了一层封装,注释也很详细,有一些需要注意的东西就看看代码吧. 代码如下:
/*** 解析Spel表达式*/public class CacheOperatorExpression {/** 这个是Spring 提供的一个方法, 为了获取程序在运行中获取方法的实参 */private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();/** 这里对targetMethod做了一个缓存, 防止每次都去解析重新获取targetMethod */private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);/** Spel的解析器 */private SpelExpressionParser parser;/** 构造 */public CacheOperatorExpression() {this.parser = new SpelExpressionParser();}/** 构造 */public CacheOperatorExpression(SpelExpressionParser parser) {this.parser = parser;}public SpelExpressionParser getParser(SpelExpressionParser parser) {return this.parser;}private ParameterNameDiscoverer getParameterNameDiscoverer() {return this.parameterNameDiscoverer;}/** 这里创建获取对应的上下文环境 */public EvaluationContext createEvaluationContext(Method method, Object[] args, Object target, Class<?> targetClass, Method targetMethod) {/** rootObject,MethodBasedEvaluationContext的一个参数,可以为null,但是如果为null, 在StandardEvaluationContext构造中会设置rootObject = new TypedValue(rootObject)也就是rootObject = TypedValue.NULL;* 这时我们在Spel表达式中就不能使用#root.xxxx获取对应的值.* 为了能够使用#root我自定义了一个CacheRootObject*/CacheRootObject rootObject = new CacheRootObject(method, args, target, targetClass);return new MethodBasedEvaluationContext(rootObject, targetMethod, args, getParameterNameDiscoverer());}/*** 解析 spel 表达式* @return 执行spel表达式后的结果*/public <T> T parseSpel(String spel, Method method, Object[] args, Object target, Class<?> targetClass, Method targetMethod, Class<T> conversionClazz) {EvaluationContext context = createEvaluationContext(method, args, target, targetClass, targetMethod);return this.parser.parseExpression(spel).getValue(context, conversionClazz);}public String generateKey(String spel, ProceedingJoinPoint joinPoint) {Object[] args = joinPoint.getArgs();Object target = joinPoint.getTarget();Class<?> targetClass = joinPoint.getTarget().getClass();MethodSignature signature = (MethodSignature) joinPoint.getSignature();Method method = signature.getMethod();Method targetMethod = getTargetMethod( method, targetClass);return parseSpel(spel, method, args, target, targetClass, targetMethod, String.class);}/*** 获取targetMethod* TargetMethod和Method?* 我们使用joinPoint获取的Method可能是一个接口方法,也就是我们把Aop的切点放在了接口上或接口的方法上。所以我们需要获取到运行的对应Class上的此方法。* eg: 我们获取的{@code PersonBehavior.eatFood()}的Class可能是{@code ChildBehavior}或者{@code DefaultPersonBehavior}的. 他们都会对eatFood()进行覆盖,* 而如果切点放在Class PersonBehavior上, 那么通过joinPoint获取的Method实际并不是程序调用的Method。* 所以我们需要通过程序调用的Class去反解析出真正调用的Method就是targetMethod.*/private Method getTargetMethod(Method method, Class<?> targetClass) {AnnotatedElementKey methodKey = new AnnotatedElementKey(method, targetClass);Method targetMethod = this.targetMethodCache.get(methodKey);if (targetMethod == null) {targetMethod = AopUtils.getMostSpecificMethod(method, targetClass);if (targetMethod == null) {targetMethod = method;}this.targetMethodCache.put(methodKey, targetMethod);}return targetMethod;}}// ############################################/*** 自定义的RootObject, 让spel表达式至此#root参数, #root就是对应这个Object, #root.method就是对应这个类中的Method*/public class CacheRootObject {private final Method method;private final Object[] args;private final Object target;private final Class<?> targetClass;public CacheRootObject( Method method, Object[] args, Object target, Class<?> targetClass) {this.method = method;this.target = target;this.targetClass = targetClass;this.args = args;}/* get set方法*/}
3.3.5 反馈
毕竟是我们自己实现的代码, 没有千锤百练谁也不能说完美. 请问世间是否存在完美的代码,除了HelloWorld只求产品不改需求.
麻烦!!! 不管多少代码, 不管自己的逻辑有多么完美, 但是还是要自己写啊, 万一改需求了这个缓存逻辑行不通了呢, 程序员事情很多的好吧.
懒, 谁也想不起来那么多的业务逻辑, 老板也不会给你太多时间让你去开发个灵活的“框架??”
有没有更好的方法呢, 就那种配置配置就能使用的那种, 不用担心出现bug的那种, 即使出现了bug能推出去的那种, 特别特别好使用的那种, 反正就不是我写的代码bug就不是我的那种. 反正老板也是只看结果.
如果你使用的是SpringBoot, 还真有.
4、SpringBoot整合Redis缓存
Redis那么一个经典的NoSql数据库,SpringBoot缓存肯定也对它进行支持. SpringBoot的缓存功能已经为我们提供了使用Redis做缓存.
4.1 引入环境
上面我们已经引入了Redis,这里我们还需要引入SpringBoot的Cache包
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId></dependency>
4.2 查找SpringBoot对Redis缓存的支持
随便百度一下或者google一下都能找到SpringBoot对各种缓存的支持都是实现接口:org.springframework.cache.annotation.CachingConfigurer
注释上同样写的大概意思就是:我们使用org.springframework.context.annotation.Configuration配置的实现类, 能够为注解@EnableCaching实现缓存解析和缓存管理器, 所以, 我们只需要实现此接口, 就可以直接使用@EnableCaching注解进行缓存管理.
我们可在Ide上查看CachingConfigurer接口的子类可以看到好像并没有关于Redis的实现。所以我们就需要手动去实现这个接口了。较好的是CachingConfigurere接口中注释的非常清楚。大家可以看一下源码
/*** Interface to be implemented by @{@link org.springframework.context.annotation.Configuration* Configuration} classes annotated with @{@link EnableCaching} that wish or need to* specify explicitly how caches are resolved and how keys are generated for annotation-driven* cache management. Consider extending {@link CachingConfigurerSupport}, which provides a* stub implementation of all interface methods.** <p>See @{@link EnableCaching} for general examples and context; see* {@link #cacheManager()}, {@link #cacheResolver()} and {@link #keyGenerator()}* for detailed instructions.*/public interface CachingConfigurer {/** 缓存管理器 */CacheManager cacheManager();/** 缓存解析器,注解上说是一个比缓存管理器更加强大的实现. 他和cacheManager互斥, 只能存在一个, 两个都有的话会报异常.* 这次我使用的是CacheManager, 因为之前我尝试CacheResolver的时候使用SimpleCacheResolver然后在CacheManager中自定义的缓存过期时间不生效.然后没有研究了, 下次研究完我再补上 */CacheResolver cacheResolver();/** key序列化方式 */KeyGenerator keyGenerator();/** 错误处理 */CacheErrorHandler errorHandler();}
4.3 缓存管理器CacheManager
虽然SpringBoot没有给我们实现CachingConfigurer, 但是缓存管理器是已经帮助我们实现了的。我们引入了cache包后,会存在一个RedisCacheManager, 我们的缓存管理器就使用它来实现.
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory).build();
我们使用RedisCacheManager提供的builder静态方法去创建, 需要参数链接工厂, 即需要一个能够创建Redis链接的对象, 这个对象存在于Spring容器中, 我们直接通过注解获取即可。
我们使用redisCacheConfiguration来做一些配置, 比如key的前缀、key/value的序列化方式、缓存名称和对应的缓存时间等等。redis KeyValue的序列化方式:key就选用的StringRedisSerializer,而value我们大多都会选择使用json. 这些序列化方式都是实现的RedisSerializer
/*** 自定义Redis缓存管理器* 可以参考{@link RedisCacheConfiguration}* 设置过期时间可参考:{@link RedisCacheConfiguration#entryTtl(java.time.Duration)}的return值*/public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {RedisCacheConfiguration defaultCacheConf = RedisCacheConfiguration.defaultCacheConfig()//设置缓存key的前缀生成方式.computePrefixWith(cacheName -> profilesActive + "-" + cacheName + ":" )// 设置key的序列化方式.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))// 设置value的序列化方式.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))// 不缓存null值,但是如果存在空值,org.springframework.cache.interceptor.CacheErrorHandler.handleCachePutError会异常:// 异常内容: Cache 'cacheNullTest' does not allow 'null' values. Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.// .disableCachingNullValues()// 默认60s缓存.entryTtl(Duration.ofSeconds(60));//设置缓存时间,使用@Cacheable(value = "xxx")注解的value值CacheTimes[] times = CacheTimes.values();Set<String> cacheNames = new HashSet<>();//设置缓存时间,使用@Cacheable(value = "user")注解的value值作为key, value是缓存配置,修改默认缓存时间ConcurrentHashMap<String, RedisCacheConfiguration> configMap = new ConcurrentHashMap<>();for (CacheTimes time : times) {cacheNames.add(time.getCacheName());configMap.put(time.getCacheName(), defaultCacheConf.entryTtl(time.getCacheTime()));}//需要先初始化缓存名称,再初始化其它的配置。RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)//设置缓存name.initialCacheNames(cacheNames)//设置缓存配置.withInitialCacheConfigurations(configMap)//设置默认配置.cacheDefaults(defaultCacheConf)//说是与事务同步,但是具体还不是很清晰.transactionAware().build();return redisCacheManager;}
4.4 异常处理
我们在看CachingConfigurer时, 会发现我们会获取一个CacheErrorHandler的类, 这个类就是对缓存过程中出现异常时对异常进行操作的对象.
CacheErrorHandler是一个接口,这个接口提供了对: 获取缓存异常、设置缓存异常、解析缓存异常、清除缓存异常 这五种异常的处理.
官方给出了一个默认实现SimpleCacheErrorHandler,默认实现就像名称一样很简单, 把异常抛出, 不做任何处理, 但是如果抛出异常,就会对我们的业务逻辑存在影响。
eg:我们的缓存Redis突然宕机, 如果仅仅因为缓存宕机就导致服务异常不可用那就太尴尬了,所以不建议使用默认的SimpleCacheErrorHandler, 所以我建议自己去实现这个, 我这里选择了打日志的方式处理. 即使缓存不可用,仍然可以走正常的逻辑去获取. 可能这会对下游服务造成压力,这就看你的实现了.
/*** 异常处理接口*/public interface CacheErrorHandler {void handleCacheGetError(RuntimeException exception, Cache cache, Object key);void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value);void handleCacheEvictError(RuntimeException exception, Cache cache, Object key);void handleCacheClearError(RuntimeException exception, Cache cache);}/** 官方默认实现 */public class SimpleCacheErrorHandler implements CacheErrorHandler {public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {throw exception;}public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {throw exception;}public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {throw exception;}public void handleCacheClearError(RuntimeException exception, Cache cache) {throw exception;}}/*** 我的实现, 效果可能和官方实现相反, 但是都没有对异常进行处理.*/protected class CustomLogErrorHandler implements CacheErrorHandler{public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {String format = String.format("RedisCache Get Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());logger.error(format, exception);}public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {String format = String.format("RedisCache Put Exception:%s, cache customKey:%s, key:%s, value:%s", exception.getMessage(), cache.getName(), key.toString(), JSON.toJSONString(value));logger.error(format, exception);}public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {String format = String.format("RedisCache Evict Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());logger.error(format, exception);}public void handleCacheClearError(RuntimeException exception, Cache cache) {String format = String.format("RedisCache Clear Exception:%s, cache customKey:%s", exception.getMessage(), cache.getName());logger.error(format, exception);}}
4.5 完整代码
上面说了那么多只是为了让大家好理解而已, 在SpringBoot项目中只需要创建一个下面的类即可.
这个依赖Redis的配置, 如何配置Redis在上面
public class RedisCacheConfig extends CachingConfigurerSupport {private static final Logger logger = LoggerFactory.getLogger(RedisCacheConfig.class);/*** redis*/private RedisConnectionFactory connectionFactory;/** 我自定义了一个前缀, 去区分环境 */("${com.nouser.profiles.active}")private String profilesActive;/*** 有点问题#######################自定义过期时间不生效@Bean // important!@Overridepublic CacheResolver cacheResolver() {// configure and return CacheResolver instancereturn new SimpleCacheResolver(cacheManager(connectionFactory));}*//*** 设置com.example.demo.cache.RedisConfig#cacheResolver()就不在是用这个了*/// important!public CacheManager cacheManager() {// configure and return CacheManager instancereturn cacheManager(connectionFactory);}/*** 默认的key生成策略, 包名 + 方法名。建议使用Cacheable注解时使用Spel自定义缓存key.*/public KeyGenerator keyGenerator() {return (o, method, params) -> o.getClass().getName() + ":" + method.getName();}/*** 设置读写缓存异常处理*/public CacheErrorHandler errorHandler() {logger.error("handler redis cache Exception.");return new CustomLogErrorHandler();}/*** 自定义Redis缓存管理器* 可以参考{@link RedisCacheConfiguration}* 设置过期时间可参考:{@link RedisCacheConfiguration#entryTtl(java.time.Duration)}的return值*/public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {RedisCacheConfiguration defaultCacheConf = RedisCacheConfiguration.defaultCacheConfig()//设置缓存key的前缀生成方式.computePrefixWith(cacheName -> profilesActive + "-" + cacheName + ":" )// 设置key的序列化方式.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))// 设置value的序列化方式.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))// 不缓存null值,但是如果存在空值,org.springframework.cache.interceptor.CacheErrorHandler.handleCachePutError会异常:// 异常内容: Cache 'cacheNullTest' does not allow 'null' values. Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.// .disableCachingNullValues()// 默认60s缓存.entryTtl(Duration.ofSeconds(60));//设置缓存时间,使用@Cacheable(value = "xxx")注解的value值CacheTimes[] times = CacheTimes.values();//我把过期时间分阶段做了一个enum类, 然后遍历, 后续使用时也使用这个enum去设置时间Set<String> cacheNames = new HashSet<>();//设置缓存时间,使用@Cacheable(value = "user")注解的value值作为key, value是缓存配置,修改默认缓存时间ConcurrentHashMap<String, RedisCacheConfiguration> configMap = new ConcurrentHashMap<>();for (CacheTimes time : times) {cacheNames.add(time.getCacheName());configMap.put(time.getCacheName(), defaultCacheConf.entryTtl(time.getCacheTime()));}//需要先初始化缓存名称,再初始化其它的配置。RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)//设置缓存name.initialCacheNames(cacheNames)//设置缓存配置.withInitialCacheConfigurations(configMap)//设置默认配置.cacheDefaults(defaultCacheConf)//说是与事务同步,但是具体还不是很清晰.transactionAware().build();return redisCacheManager;}/*** 因为默认key都是字符串,就使用默认的字符串序列化方式,没毛病*/private RedisSerializer<String> keySerializer() {return new StringRedisSerializer();}/*** value值序列化方式* 使用Jackson2Json的方式存入redis* ** 注意,要缓存的类型,必须有 "默认构造(无参构造)" ,否则从json2class时会报异常,提升没有默认构造。*/private GenericJackson2JsonRedisSerializer valueSerializer() {GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer();return redisSerializer;}/*** 其他集合等转换正常,但是不知道为啥啊RespResult转换异常* java.lang.ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.example.demo.util.RespResult*/private FastJsonRedisSerializer valueSerializerFastJson(){FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);return fastJsonRedisSerializer;}/*** 自定义异常处理*/protected class CustomLogErrorHandler implements CacheErrorHandler{public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {String format = String.format("RedisCache Get Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());logger.error(format, exception);}public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {String format = String.format("RedisCache Put Exception:%s, cache customKey:%s, key:%s, value:%s", exception.getMessage(), cache.getName(), key.toString(), JSON.toJSONString(value));logger.error(format, exception);}public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {String format = String.format("RedisCache Evict Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());logger.error(format, exception);}public void handleCacheClearError(RuntimeException exception, Cache cache) {String format = String.format("RedisCache Clear Exception:%s, cache customKey:%s", exception.getMessage(), cache.getName());logger.error(format, exception);}}}
4.6 使用: @Cacheable
配置好了, 我们如何使用呢?
我们现在是使用的SpringBoot缓存整合Redis, 所以我们只需要使用注解@Cacheable, 我们先看一下Cacheable注解, 然后说一下它如何使用.
/** 这里只贴代码, 注释自己去ide看吧, 源码上的注释挺全的 */({ElementType.TYPE, ElementType.METHOD})(RetentionPolicy.RUNTIME)public Cacheable {/** 缓存名,和前面我们设置缓存管理器时初始化缓存名称和配置一一对应, 如果为空, 则取默认配置 */("cacheNames")String[] value() default {};("value")String[] cacheNames() default {};/** 设置缓存的key, 每个缓存key是唯一的, 我们使用Redis缓存, 那么它生成的结果就是我们的Redis Key */String key() default "";/** 指定key生成策略*/String keyGenerator() default "";/** 指定缓存管理器 */String cacheManager() default "";/** 制定解析器 */String cacheResolver() default "";/** 是否走缓存逻辑, 缓存前进行判定, 是否走缓存逻辑, 支持Spel表达式, 如果返回false, 将会跳过缓存逻辑 */String condition() default "";/** 是否进行缓存, 这个是在执行目标方法后进行判断, 支持Spel表达式, 如果为true, 将不会对结果进行缓存 */String unless() default "";/** 是否使用同步 */boolean sync() default false;}
单独说一下sync(), 如果我们设置sync为true, 那么我们执行到获取缓存的get方法时, 这个方法是访问的加锁的同步方法,只能同步调用,但是保证了缓存失效时不会全部请求都到下游服务请求。
注解也非常清楚:参考org.springframework.data.redis.cache.RedisCache.get, 可以自己打断点试一试, 反正这个不建议使用, 除非业务不影响业务的且需要保证下游服务的前提下.
关于Cacheable注解的使用.....我举几个例子吧
/*** 缓存key = packageName + ":" + methodName + "#" + #name + "#" + #id* 如果方法结果为null 或长度 小于1 则不缓存此结果* 参数useCache = true 的时候才走缓存逻辑,*/(value = "xxxx",key = "(#root.targetClass.getName() + ':' + #root.methodName + '#' + #name + '#' + #id).replaceAll('[^0-9a-zA-Z:#._]', '')",unless = "#result == null || #result.size() < 1",condition = "#useCache")public List<String> cache01(String name, String id, boolean useCache){}
点击阅读原文关注我的语雀文档哦。
