背景
一个小系统赶工期,不需要额外处理数据,90% 的场景都是查询展示,但是这个数据有点粗糙,如果进行加工一遍的话,对于查询来说会比较的方便,由于赶工期所以没有进一步的对数据加工。
在实现业务查询的时候由于数据粗糙,有些 SQL 会关联多表,可能会很慢,临时出了一个方案:根据查询条件,将查询出来的结果缓存到数据库。
说说为什么选择这个方案:
- 查询条件:这个查询条件相对来说比较固定,不会说是 n 多条件的场景,可以自由选择组合的场景,目前这个场景大部分是统计图表,所以绝大部分的图表的查询参数都是固定的,就非常适合使用这种缓存方案
- 缓存到数据库也是为了方便管理,毕竟直接使用 数据库可视化工具,就能看到每一条缓存的参数入参、响应值等信息,由于缓存和用户关联,所以要清空某个用户的缓存也是比较容易
实现思路
自定义缓存中我们最核心的就是要知道缓存的 key 如何生成?直接写死硬编码的场景需求很少,所以要动态的获取缓存的 key 或则能以某种方案自动生成 key
比如 spring 提供的缓存组件
其中 key 使用了 SpEL 表达式提取方法入参的 id 的值,有了这个技能,就能实现上面我们的核心需求了。/*** 从本地缓存中获取商品信息*/@Cacheable(value = CACHE_NAME, key = "'key_'+#id")public ProductInfo getLocalCache(Long id) {return null;}
实现
spring boot 版本 2.4.4
实现的功能:
- 缓存 key:
- 手动指定缓存 key :为了减少处理逻辑,只支持 SPEL 表达式
- 自动生成缓存 key:根据缓存名称 + 方法入参的 MD5 再计算 MD5 值
- 缓存名称:
- 手动指定缓存名称:这个只支持静态字符串
- 自动生成:简单的
当前类名#方法名
由于是将结果缓存到数据库,先看看做好后的数据库数据
依赖包
// lombokcompileOnly 'org.projectlombok:lombok:1.18.18'testCompileOnly 'org.projectlombok:lombok:1.18.18'annotationProcessor 'org.projectlombok:lombok:1.18.18'// 工具包,比如类型转换implementation 'cn.hutool:hutool-all:5.8.3'// spring-boot 和 aop 相关依赖implementation 'org.springframework.boot:spring-boot-starter-web:2.4.4'implementation 'org.springframework.boot:spring-boot-starter-aop:2.4.4'
根据业务创建注解类,用于收集缓存 key 、名称等信息,也为了标识哪个方法能被缓存
package cn.mrcode.aspect.result_cache;import java.lang.annotation.*;/*** VOC 缓存,* <pre>* 大致原理:通过 AOP 拦截此注解上的信息,首先检查数据库中是否存在此信息,如果存在则返回结果* </pre>** @author mrcode*/@Documented@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.METHOD)public @interface VocResultCache {/*** 缓存名称,也是业务名称;* <pre>* 如果为 null:则使用 类名#方法名的方式 ,* 需要注意的是:如果有同名的签名就会出现分不清是哪一个业务下的缓存,并且如果方法入参是一样的话,就会导致最终生成的缓存 KEY 是一致的(如果是自动生成的缓存 key)*</pre>* @return*/String value() default "";/*** 缓存 key* <pre>* 如果为 null,则自动计算 key:* 1. MD5(方法入参): 这个也叫做方法参数签名,同时在入库到数据库中的时候,会将参数信息都收集出来,自定义 key 的话,则没有这个操作* 2. MD5(value() + "#" + 第一步中计算出来的值):这一步是防止方法入参一模一样的时候,导致不同缓存业务模块的冲突* </pre>* @return*/String key() default "";// === 后面的是要在缓存入库的时候同时存入的其他额外的信息/*** 可选属性(请使用表 SPEL 表达式),品牌名称** @return*/String brand() default "";/*** 可选属性(请使用表 SPEL 表达式): 开始时间** @return*/String startDate() default "";/*** 可选属性(请使用表 SPEL 表达式):结束时间** @return*/String endDate() default "";}
定义切面,完成缓存操作
package cn.mrcode.aspect.result_cache;import cn.mrcode.VocShowCaseService;import cn.hutool.core.convert.Convert;import cn.hutool.crypto.digest.DigestUtil;import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.JSONObject;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.core.DefaultParameterNameDiscoverer;import org.springframework.expression.EvaluationContext;import org.springframework.expression.Expression;import org.springframework.expression.ExpressionParser;import org.springframework.expression.spel.standard.SpelExpressionParser;import org.springframework.expression.spel.support.StandardEvaluationContext;import org.springframework.stereotype.Component;import org.springframework.dao.DuplicateKeyException;import java.lang.reflect.Method;import java.lang.reflect.Type;@Component@Aspectpublic class VocResultCacheAspect {// 获取方法入参的参数名工具private DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();// SPEL 编程的解析类private ExpressionParser parser = new SpelExpressionParser();// 这里就是你自己用于将 缓存数据存储到数据库的服务了,这个就不贴出来了@Autowiredprivate VocShowCaseService vocShowCaseService;// 这里由于要求注入注解,所以写了参数名称,@annotation 能从参数名称上去关联到注解的类型@Around("@annotation(anno)")public Object access(ProceedingJoinPoint point, VocResultCache anno) throws Throwable {MethodSignature signature = (MethodSignature) point.getSignature();Method method = signature.getMethod();// 方法参数值Object[] args = point.getArgs();// 这种反射方式默认情况是获取不到原始参数名称的,参数名称变成了 arg0、arg1 的名称// 在 Java 8 及之后,编译的时候可以通过 -parameters 为反射生成元信息// Parameter[] parameters = method.getParameters();// Spring 提供的工具:使用 Java 8 标准反射机制(如果可用),如果不可用则回退到基于 ASM 提取类文件中 debug 信息的方法参数名称String[] parameterNames = discoverer.getParameterNames(method);// 构建 SPEL 环境上下文EvaluationContext context = new StandardEvaluationContext();for (int i = 0; i < parameterNames.length; i++) {// 拿到方法入参名称和值,设置到 上下文环境中context.setVariable(parameterNames[i], args[i]);}// 业务逻辑处理VocResultCacheParams params = new VocResultCacheParams();String cacheName = anno.value();if (cacheName.length() == 0) {cacheName = method.getDeclaringClass().getSimpleName() + "#" + method.getName();}String keyEL = anno.key();String paramsBody = "";String paramsSign = "";String cacheKey = "";// 缓存 key 如果没有定义,则根据获取到的参数生成 哈希 值if (keyEL.length() == 0) {JSONObject o = new JSONObject();for (int i = 0; i < parameterNames.length; i++) {// 拿到方法入参名称和值,设置到 上下文环境中o.put(parameterNames[i], args[i]);}paramsBody = o.toJSONString();paramsSign = DigestUtil.md5Hex(paramsBody);cacheKey = DigestUtil.md5Hex(cacheName + "#" + paramsSign);} else {Expression expression = parser.parseExpression(keyEL);Object value = expression.getValue(context);cacheKey = Convert.convert(String.class, value);}// 先从数据库中命中该缓存String resultJson = vocShowCaseService.getResultByKey(cacheKey);// 命中缓存if (resultJson != null) {// 获取到该方法的返回值类型, 该方法包含泛型的类型,比如 java.util.List<cn.mrcode.dto.SoundTrendRes>Type genericReturnType = method.getGenericReturnType();return JSON.parseObject(resultJson, genericReturnType);}// 没有命中缓存,调用方法,拿到结果后,并存储到数据库Object result = point.proceed();if (result == null) {return result;}params.setResultBody(JSON.toJSONString(result));params.setName(cacheName);params.setParamBody(paramsBody);params.setParamSign(paramsSign);params.setKey(cacheKey);String brandEL = anno.brand();if (brandEL.length() > 0) {Expression expression = parser.parseExpression(brandEL);Object value = expression.getValue(context);params.setBrand(Convert.convert(String.class, value));}String startDateEL = anno.startDate();if (startDateEL.length() > 0) {Expression expression = parser.parseExpression(startDateEL);Object value = expression.getValue(context);params.setStartDate(Convert.convert(String.class, value));}String endDateEL = anno.endDate();if (endDateEL.length() > 0) {Expression expression = parser.parseExpression(endDateEL);Object value = expression.getValue(context);params.setEndDate(Convert.convert(String.class, value));}// 这里 try 的目的是,不要让缓存添加失败,导致正常业务都受到了影响try{// 入库vocShowCaseService.add(params);}catch (DuplicateKeyException e) {// 忽略这种异常,在并发下这种异常暂时交给数据库处理log.warn(StrUtil.format("缓存 KEY 重复,添加内容={}", params), e);} catch (Exception e) {log.error(StrUtil.format("缓存添加失败,添加内容={}", params), e);}return result;}}
VocResultCacheParams 需要收集的信息,在入库的使用使用
package cn.mrcode.aspect.result_cache;import lombok.Data;import lombok.ToString;@Data@ToStringpublic class VocResultCacheParams {/*** 缓存名称*/private String name;/*** 缓存 key*/private String key;/*** 缓存的结果 JSON 串*/private String resultBody;// 后面就是自己业务所需要收集的一些自定义信息了private String brand;private String startDate;private String endDate;private String paramBody;private String paramSign;}
如何使用
在 service 这种方法入参比较干净的方法上使用(什么是干净?比如 controller 上,你有其他的注入对象,比如 HttpRequest、session 之类的,这种对你切面处理参数相关的操作就很困难)
// 这种方式就会自动生成缓存 key@VocResultCache(brand = "#brand", startDate = "#startDay", endDate = "#endDay")public List<SoundTrendRes> soundTrend(String brand, BenchmarkAnalysisRequest params, LocalDate startDay, LocalDate endDay) {// 这种方式指定了缓存名称,就会影响到自动生成 key 的结果@VocResultCache(value="模块A#功能A")// 这种方式就手动指定了 key,但是这种在目前写的这个场景中基本上不会出现,常用的还是上面那种指定 缓存名称,让切面自动生成 缓存 key@VocResultCache(value="模块A#功能A", key="#brand")
参考资料
- SpEL 表达式使用方式:本文章的核心
- 简书博客:让我知道了 DefaultParameterNameDiscoverer 类可以获取到方法的名称
