牛哄哄的java大师

硬核资源!清华博士的Spring Boot中AOP与SpEL笔记,码农:膜拜

该文章是介绍《Spring Boot中AOP与SpEL的应用》,根据我以前的学习笔记以及工作中长时间积累所整理的一份笔记,纯干货,希望对大家有帮助。

 

 

Spring Boot中AOP与SpEL的应用

定义

我不想在这里去摘抄百度百科的内容, 以下内容纯属个人理解:

AOP: 面向切面编程?NO, 我们低端点, 它就是一个非常厉害的装饰器, 可以和业务逻辑平行运行, 适合处理一些日志记录/权限校验等操作

SpEL: 全称SpEL表达式, 可以理解为JSP的超级加强版, 使用得当可以为我们节省代码(此话为抄袭), 大家使用它最多的地方其实是引入配置, 例如:

// 你看我熟悉不?
@Value("#{file.root}")

那么什么时候会一起使用它们呢?

其实很多经验丰富的大佬们下意识就能回答, 记录系统日志

没错, AOP是与业务逻辑平行, SpEL是与业务数据平行, 把它们结合起来, 就能让我们在传统的面向对象/过程编程的套路中更上一层楼

接下来我就用一个实际的记录业务日志功能的实现来记录如何在Spring Boot中使用AOP与SpEL

了解它们

想要使用它们, 作为先行者列出其中的重点是个人义务, 我们先来看看其中需要特别在意的几点概念:

AOP

明确概念:

@Aspect: 切面

@Poincut: 切点

JoinPoint: 普通连接点

ProceedingJoinPoint: 环绕连接点

切入时机:

before: 目标方法开始执行之前

after: 目标方法开始执行之后

afterReturning: 目标方法返回之后

afterThrowing: 目标方法抛出异常之后

around: 环绕目标方法, 最为特殊

我们用代码来展示下各个切入时机的位置:

try{
    try{
        @around
        @before
        method.invoke();
        @around
    }catch(){
        throw new Exception();
    }finally{
        @after
    }
    @afterReturning
}catch(){
    @afterThrowing
}
其中的around是最为特殊的切入时机, 它的切入点也必须为ProceedingJoinPoint, 其它均为JoinPoint

我们需要手动调用ProceedingJoinPoint的proceed方法, 它会去执行目标方法的业务逻辑

around最麻烦, 却也是最强的

SpEL

要使用SpEL, 肯定难不住每一位小伙伴, 但它到底是如何从一个简单的文字表达式转换为运行中的数据内容呢?

其实Spring和Java已经提供了大部分功能, 我们只需要手动处理如下部分:

TemplateParserContext: 表达式解析模板, 即如何提取SpEL表达式

RootObject: 业务数据内容, SpEL表达式解析过程中需要的数据都从这其中获取

ParameterNameDiscoverer: 参数解析器, 在SpEL解析的过程中, 尝试从rootObject中直接获取数据

EvaluationContext: 解析上下文, 包含RootObject, ParameterNameDiscoverer等数据, 是整个SpEL解析的环境

那么SpEL的过程我们可以粗略概括为:

设计RootObject->设计SpEL表达式的运行上下文->设计SpEL解析器(包括表达式解析模板和参数解析器)

实践出真知

业务场景

实现一个业务日志记录的功能, 需要记录如下内容:

功能模块

业务描述, 包含业务数据id

目标方法详情

目标类详情

入参

返回值

用户信息, 如用户id/ip等

一目了然, 这些数据都需要在运行的过程中进行获取, 还记得吗?在刚才的介绍里, 运行这个状态词是AOP和SpEL的特点

所以, 我们将使用AOP和SpEL, 来完成这个需求

业务分析

仔细观察需要记录的数据内容, 我们可以分析它们从哪里得到:

功能模块: 通过AOP中切入点的注解获得

业务描述: 将SpEL表达式写入AOP切入点的注解, 在AOP运行过程中翻译表达式获得

目标方法详情: 通过AOP切入点获得

目标类详情: 通过AOP切入点获得

入口: 通过AOP切入点获得

返回值: 通过AOP切入点获得

用户信息: 在AOP运行的过程中通过代码获得

在明确了数据来源后, 我们先进行AOP相关的设计

AOP 注解设计

AOP注解的目的是在代码层面记录数据, 并提供切入点, 上面提及的功能模块和业务描述需要在这里写入, 开始写代码:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface KoalaLog {
    /**
     * 功能模块
     *
     * @return 功能模块
     */
    String type() default "";
    /**
     * 业务描述
     *
     * @return 业务描述
     */
    String content() default "";
}

AOP 切面设计

切面设计其实就两个内容:

切入点

切入时机

有没有发现这和刚才的介绍是差不多的呢?

回归正题, 我们的切入点就是刚才设计的注解, 切入时机是目标方法执行之后, 即afterReturning

细心的小伙伴肯定知道, 方法的执行是可能会出错的, 所以除了afterReturning之外, 我们还需要再加一个关于异常的切入时机, 即afterThrowing

@Aspect
@Component
@Slf4j
public class KoalaLogAspect {
    @Pointcut(value = "@annotation(cn.houtaroy.springboot.koala.starter.log.annotations.KoalaLog)")
    public void logPointCut() {}
    
    @AfterReturning(value = "logPointCut()", returning = "returnValue")
    public void log(JoinPoint joinPoint, Object returnValue) {
        // 记录正常日志
    }
    
    @AfterThrowing(pointcut = "logPointCut()", throwing = "error")
    public void errorLog(JoinPoint joinPoint, Exception error) {
        // 记录异常日志
    }
    
}

以上, AOP的全部设计都结束了, 至于如何实现记录日志的逻辑, 我们要等SpEL设计结束后再进行, 暂且搁置

SpEL 模型设计

为了实现SpEL, 我们需要如下几个模型:

LogRootObject: 运行数据来源

LogEvaluationContext: 解析上下文, 用于整个解析环境

LogEvaluator: 解析器, 解析SpEL, 获取数据

LogRootObject

LogRootObject是SpEL表达式的数据来源, 即业务描述

上文提及在业务描述中需要记录业务数据的id, 它可以通过方法参数获得, 那么:

@Getter
@AllArgsConstructor
public class LogRootObject {
    /**
     * 方法参数
     */
    private final Object[] args;
}
但需要注意的是, 它的结构直接决定了业务描述SpEL表达式翻译完成的结果, 所以务必提前和需求沟通好业务描述最全面的数据范围

比如, 我们还需要记录目标方法/目标类的信息, 那这个设计是不满足, 应该是:

@Getter
@AllArgsConstructor
public class LogRootObject {
    /**
     * 目标方法
     */
    private final Method method;
    /**
     * 方法参数
     */
    private final Object[] args;
    /**
     * 目标类的类型信息
     */
    private final Class targetClass;
}
LogEvaluationContext

Spring提供了MethodBasedEvaluationContext, 我们只需要继承它, 并实现对应的构造方法:

public class LogEvaluationContext extends MethodBasedEvaluationContext {
    /**
     * 构造方法
     *
     * @param rootObject 数据来源对象
     * @param discoverer 参数解析器
     */
    public LogEvaluationContext(LogRootObject rootObject, ParameterNameDiscoverer discoverer) {
        super(rootObject, rootObject.getMethod(), rootObject.getArgs(), discoverer);
    }
}
LogEvaluator

这是我们最核心的解析器, 用于解析SpEL, 返回真正期望的数据内容

我们需要初始化表达式编译器/表达式编译模板/参数解析器

@Getter
public class LogEvaluator {
    /**
     * SpEL解析器
     */
    private final SpelExpressionParser parser = new SpelExpressionParser();
    /**
     * 参数解析器
     */
    private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
    /**
     * 表达式模板
     */
    private final ParserContext template = new TemplateParserContext("${", "}");
    /**
     * 解析
     *
     * @param expression 表达式
     * @param context    日志表达式上下文
     * @return 表达式结果
     */
    public Object parse(String expression, LogEvaluationContext context) {
        return getExpression(expression).getValue(context);
    }
    /**
     * 获取翻译后表达式
     *
     * @param expression 字符串表达式
     * @return 翻译后表达式
     */
    private Expression getExpression(String expression) {
        return getParser().parseExpression(expression, template);
    }
    
}
到此为止, 整个SpEL表达式的全部内容搞定, 再次强调下它的逻辑:

设计RootObject->设计SpEL表达式的运行上下文->设计SpEL解析器(包括表达式解析模板和参数解析器)

AOP业务逻辑

完成了SpEL的设计, 我们可以把目光回归到刚才AOP中没有实现的业务代码, 这里的流程非常简单:

解析SpEL->生成日志实体->保存日志

这里的内容不再赘述, 小伙伴们只需要认真看一遍代码就全部明白了

@Aspect
@Slf4j
public class KoalaLogAspect {
    /**
     * 日志SpEL解析器
     */
    private final LogEvaluator evaluator = new LogEvaluator();
    /**
     * jackson
     */
    @Autowired
    private ObjectMapper objectMapper;
    /**
     * 日志切入点
     */
    @Pointcut(value = "@annotation(cn.houtaroy.springboot.koala.starter.log.annotations.KoalaLog)")
    public void logPointCut() {
    }
    /**
     * 方法返回后切入点
     *
     * @param joinPoint   切入点
     * @param returnValue 返回值
     */
    @AfterReturning(value = "logPointCut()", returning = "returnValue")
    public void log(JoinPoint joinPoint, Object returnValue) {
        // 记录正常日志
        Log koalaLog = generateLog(joinPoint);
        try {
            koalaLog.setReturnValue(objectMapper.writeValueAsString(returnValue));
            // 记录日志代码...
        } catch (JsonProcessingException e) {
            log.error("KOALA-LOG: 序列化返回值失败", e);
        }
    }
    /**
     * 方法抛出异常后切入点
     *
     * @param joinPoint 切入点
     * @param error     异常
     */
    @AfterThrowing(pointcut = "logPointCut()", throwing = "error")
    public void errorLog(JoinPoint joinPoint, Exception error) {
        // 记录异常日志
        Log koalaLog = generateLog(joinPoint);
        koalaLog.setReturnValue(error.getMessage());
        // 记录日志代码...
    }
    /**
     * 生成日志实体
     *
     * @param joinPoint 切入点
     * @return 日志实体
     */
    private Log generateLog(JoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        Object[] args = joinPoint.getArgs();
        Class targetClass = AopProxyUtils.ultimateTargetClass(joinPoint.getTarget());
        KoalaLog annotation = method.getAnnotation(KoalaLog.class);
        LogRootObject rootObject = new LogRootObject(method, args, targetClass);
        LogEvaluationContext context = new LogEvaluationContext(rootObject, evaluator.getDiscoverer());
        Object content = evaluator.parse(annotation.content(), context);
        Log koalaLog = Log.builder().type(annotation.type()).content(content.toString()).createTime(new Date()).build();
        try {
            koalaLog.setArguments(objectMapper.writeValueAsString(args));
        } catch (JsonProcessingException e) {
            log.error("KOALA-LOG: 序列化方法参数失败", e);
        }
        return koalaLog;
    }
}

上面的内容缺少记录日志的具体代码, 各位根据实际情况进行补充(我不会承认是自己拖延症还没有将ORM封装写完)

总结

AOP和SpEL是专注于运行状态下的数据处理, 在简单的业务中, 完全没有必须使用的必要

代码是我们的工具, 如何正确和便捷地使用它们也是一种能力

  • 以上就是《Spring Boot中AOP与SpEL》的分享。

  • 也欢迎大家交流探讨,该文章若有不正确的地方,希望大家多多包涵。

  • 你们的支持就是我最大的动力,如果对大家有帮忙给个赞哦~

 

 

posted on 2021-05-07 14:04  牛哄哄的java大师  阅读(190)  评论(0编辑  收藏  举报