SpEL+Spring AOP+Redis 实现自定义缓存注解

301 阅读5分钟

前序

自从在工作中使用面向切面编程(AOP)后,很多业务共同使用的逻辑都可以从业务代码中抽离出来,省略了很多重复的工作量。今天来抛砖引玉,简单使用Spring AOP构造一个缓存注解组件。

实现功能:

  • 在业务方法执行前判断是否存在缓存,没有则创建。
  • 支持自定义key+业务id使用hash表存储(通过SpEL读取方法参数作为hash表字段)。
  • 支持设置过期时间

AOP简述

曾在背八股文的时候,提到Spring两个重要特性:IOC(控制反转)、AOP(面向切面编程)。

AOP(Aspect Orient Programming),它是一种编程思想:面向切面编程。把程序抽象成一个个切面,通过横切将多个不同模块的行为封装入一个重用模块中,增强程序代码的复用性,减少了程序员的重复编码行为。

AOP相关名词:

  • 通知(Advice):AOP中的增强处理。相当于在“切面”中具体什么时机、执行什么逻辑的地方。
  • 切面(Aspect):通知和切点的结合。是我们的封装类。
  • 切入点(Pointcut):定义“通知”执行的规则。
  • 连接点(Join point):与切入点中匹配的具体执行地点。

简单理解:通知(Advice)负责具体做什么、如何做;
切面(Aspect)中存放了具体的方法逻辑和规则定义;
切入点(Pointcut)定义了通知执行的一系列规则;
连接点(Join point)是“通知”增强下的某个业务方法。

其他名词:织入(Proxy)、代理(Proxy)等。

AOP实现例子:Web程序中需要记录每个方法执行的时长、出入参数、方法名称的日志时,只需要编写一个切面,切入点定义为控制层下的所有方法,通知中记录日志。在程序员眼里,封装一个类即可完成其他业务方法执行后记录日志的操作。

AOP示例

SpEL简述

Spring Expression Language(简称SpEL)是一种强大的表达式语言,支持在运行时查询和操作对象。该语言的语法类似于EL表达式,但提供了额外的特性,最显著的是方法调用和基本的字符串模板功能。

此处使用SpEL的作用是,将程序方法中的入参使用SpEL的语法进行读取,并作为缓存的Key存入。

点此跳转SpringEL文档

The Spring Expression Language (SpEL for short) is a powerful expression language that supports querying and manipulating an object graph at runtime. The language syntax is similar to Unified EL but offers additional features, most notably method invocation and basic string templating functionality.

SpEL简单例子

ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(" 'Hello World' ");
String message = (String) exp.getValue();

代码实现

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

创建RedisCache注解

使用在方法上,标识需要执行缓存的方法。

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisCache {

    /**
     * 缓存Key
     */
    String key();

    /**
     * 业务id,key+id存在时使用Hashset
     */
    String item();

    /**
     * 过期时间
     */
    long expire() default -1;

    /**
     * 是否清除缓存
     * 0-不清除 1-执行后 2-执行后
     */
    int cleanOnInvoke() default 0;
}

创建CacheAspect

定义缓存切面,定义切点,使用环绕通知(方法执行时增强)。 切点为被RedisCache注解注释下的所有方法,当方法执行前判断是否存在缓存, 存在则直接返回缓存。

@Slf4j
@Aspect // 定义切面
@Component // 交给Spring管理
public class CacheAspect {

    @Resource
    private RedisUtils redisUtils;

    // @annotation(...RedisCache)表示切点为被RedisCache注解注释的所有方法
    @Around("@annotation(org.nott.common.annotation.RedisCache)") // 环绕,在方法执行时(实际上在切点匹配JoinPoint时,在@Before前面执行)
    //@Before("") // 前置,方法执行前
    //@AfterReturning("") // 后置,在返回返回值后执行
    //@AfterThrowing("") // 后置,在方法抛出异常执行
    //@After("") // 后置,在方法执行完毕后执行
    public Object around(ProceedingJoinPoint point) throws Throwable {
        // 获取连接点的方法名称、类、实际参数
        String name = point.getSignature().getName();
        Object target = point.getTarget();
        MethodSignature signature = (MethodSignature) point.getSignature();
        Class[] parameterTypes = signature.getParameterTypes();
        Object[] args = point.getArgs();

        // 通过反射获取业务方法
        Method method = target.getClass().getMethod(name, parameterTypes);
        Object redisResult;
        ResponseEntity<?> methodResult;
        String elValue = "";
        boolean hasArg = parameterTypes.length > 0;
        RedisCache annotation = method.getAnnotation(RedisCache.class);
        String annotationItem = annotation.item();

        // 存在参数并带有业务id值
        if (hasArg && StringUtils.isNotEmpty(annotationItem)) {
            // 以下步骤:获取注解中的SpEL表达式,并通过格式化获取参数值
            // 获取方法的形参名称,例:getUserName(String name),则获取到name
            DefaultParameterNameDiscoverer defaultParameterNameDiscoverer = new DefaultParameterNameDiscoverer();
            String[] parameterNames = defaultParameterNameDiscoverer.getParameterNames(method);
            // StandardEvaluationContext:SpEL上下文组件
            // 用作定义变量,将形参和实际参数设置为StandardEvaluationContext的variable(类似map{name:value})
            StandardEvaluationContext ctx = new StandardEvaluationContext();
            for (int i = 0; i < parameterNames.length; i++) {
                ctx.setVariable(parameterNames[i], args[i]);
            }
            // SpEL格式化器
            SpelExpressionParser parser = new SpelExpressionParser();
            // 将注解中的SpEL表达式格式化并获取值
            Expression expression = parser.parseExpression(annotationItem);
            elValue = expression.getValue(ctx, String.class);

            // 获取缓存
            redisResult = redisUtils.hget(annotation.key(), elValue);
        } else {
            redisResult = redisUtils.get(annotation.key());
        }
        // 缓存存在时直接返回
        if (HutuUtils.isNotEmpty(redisResult)) {
            return ResponseEntity.successData(redisResult);
        }
        // 不存在时存入Redis
        methodResult = (ResponseEntity<?>) point.proceed();
        Object data = methodResult.getData();
        Type[] genericInterfaces = data.getClass().getGenericInterfaces();
        Type type = Arrays.stream(genericInterfaces)
                .filter(r -> Serializable.class == r)
                .findAny().orElse(null);
        HutuUtils.requireNotNull(type, "使用缓存的方法返回值必须实现Serializable接口");

        if (hasArg) {
            redisUtils.hset(method.getName(), elValue, data, annotation.expire());
        } else {
            redisUtils.set(method.getName(), data, annotation.expire());
        }
        return methodResult;
    }

}

使用示例

以下面的业务方法为例,示例方法为获取门店下的菜单分类,每个门店存在不一样的菜单分类,并且作为不常修改的热点数据,存入缓存中。 以方法名作为缓存Key,每个门店传入的shopId作为Hash table字段,实现各自门店的缓存存取。

@GetMapping("listByShop/{shopId}")
@RedisCache(key = "listByShop", item = "#shopId")
@ApiOperation(value = "门店菜单分类列表", notes = "根据门店id获取菜单分类")
public ResponseEntity<?> listByShop(@PathVariable Long shopId) {
    List<MenuCatalogVo> vos = bizMenuCatalogService.getCatalogByShopId(shopId);
    return ResponseEntity.successData(vos);
}

小结

实际上SpringCache、Guava Cache、Caffeine等优秀的框架已经实现了各种高性能的Java缓存方案,本文只是借用AOP的概念,简单写个Demo展示下其强大之处,还有业务方法执行前后清除缓存的操作没有实现,各位读者有心可以自己私下借用此例子进行拓展。

篇幅有限,后续空闲时可继续深入AOP原理,探索动态代理等内容。此篇文章已经收录入[学习]专栏,各位读者可以关注一下,谢谢。