背景
一个小系统赶工期,不需要额外处理数据,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 值
- 缓存名称:
- 手动指定缓存名称:这个只支持静态字符串
- 自动生成:简单的
当前类名#方法名
由于是将结果缓存到数据库,先看看做好后的数据库数据
依赖包
// lombok
compileOnly '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
@Aspect
public class VocResultCacheAspect {
// 获取方法入参的参数名工具
private DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
// SPEL 编程的解析类
private ExpressionParser parser = new SpelExpressionParser();
// 这里就是你自己用于将 缓存数据存储到数据库的服务了,这个就不贴出来了
@Autowired
private 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
@ToString
public 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 类可以获取到方法的名称