如何理解AOP

529 阅读18分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

参考文章

  1. Aspect Oriented Programming with Spring
  2. Aspect Oriented Programming-wiki
  3. AspectJ 1.8.12 - Faster Spring AOP
  4. AOP宏观解析
  5. Pointcut切入点表达式介绍

概述

        以下内容都是在阅读了 SpringBoot AOP官方手册 之后,又逛了StackOverFlow等其他论坛中的一些优质回答,尝试写出的自己的理解。当然,本文只是以Spring AOP为出发点,目的是理解AOP这套编程思想,并不是深入Spring的AOP,或者是深入AspectJ,对于上述两个AOP理论的实现方案,本文会解释相关的专业术语以及使用方式,正如标题,是如何理解AOP。

        本文分为三部分,首先,是对AOP编程范式概念的了解,其次,是对AOP两大实现方案AspectJ和SpringAOP的浅析,最后,是一个应用的切面模板。

AOP框架

什么是AOP?

  1. AOP全称(Aspect-Oriented Programming),即面向切面编程。
  2. 用于补充OOP面向对象编程(Object-Oriented Programming),两者所在的层次不同,OOP关注的是类,而AOP则是在程序结构这个层面做的抽象,也就构成了所谓的切面。
  3. 核心理念在于通过分离横切关注点来增加模块化。帮助开发人员在不修改代码的情况下向现有程序中添加非业务逻辑代码,我们称之为织入
  4. AOP只是一个编程思想,他不是特定的框架,和RPC只是一个协议是一样的道理,最广为人知的实现就是AspectJ和Spring AOP。

为什么会出现AOP?

        计算机领域衍生出来的技术必然具有替代性,或者说是更好的抽象,无非就是让编程更优雅,提升性能。AOP的核心作用就是解耦。         在应用程序中,除了主要的业务逻辑之外,还有许多与业务无关但又无处不在的非业务代码,同样值得我们关注,例如日志记录、事务处理、性能监控、安全等等。AOP的专业术语中称之为应用程序的横切关注点

image.png

        在对这些横切关注点的实际实现中,如果在每块业务代码处都加上这些与业务无关的内容,会使得整个业务逻辑变得臃肿,即使是使用静态代理,动态代理等技巧,依旧会存在种种问题,关于各种代理模式的优劣可以看这篇文章(静态代理,动态代理,CGLIB代理),我们需要的是一种性能高,松耦合,方便灵活的解决方案。比如我们通常用到的Spring AOP或者AspectJ,不过这些方案的底层实现机制,也是这些代理模式。

JPM连接点模型

        AOP的通知组件定义了连接点模型,全称Join point models,包含以下三点,有晦涩之处,会在下一节,专业术语中做详细介绍。

  1. 连接点(join points):定义advice可以运行的位置,很多AOP实现能够支持将方法执行和字段引用作为连接点。
  2. 切入点(pointcuts):可以匹配到一个或者一组连接点。
  3. 通知(advice):是在连接点处运行的代码。

image.png

专业术语

        AOP的专业属于其实很难理解,官方也说这些术语不是特别直观,像是数学家定义出来用来迷惑大众的,而且对于我们来说,再翻译成中文,难免会有语意上的偏差。下面介绍一些AOP的专业术语。在我看来,要想很好的理解这些术语,最重要的是区分哪些是概念,哪些才是真正的实体?

Cross Cutting Concern 横切关注点:定义的每个类来都是用来实现不同的功能的,但是总有一些通用且与业务无关的行为,如日志记录,事务处理等,这些称之为横切关注点,这是概念。

JoinPoint 连接点:定义在目标程序的什么位置加入新的逻辑。比如一个目标类,那么这个类的所有行为,如初始化前、初始化后,或者类的某个方法调用前、调用后、以及方法抛出异常后等等。都可以作为一个连接点,也是个概念。

Pointcut 切入点:这个就可以理解为连接点对应的实体了,他是连接点的过滤条件,由于每个类都可以拥有多个连接点,切入点的作用就是匹配到特定的连接点。可以匹配出一个,也可以是一组连接点,然后再在这些连接点上织入代码

Advice 通知:可能是中文翻译的缘故,总感觉翻译为通知会怪怪的,也有人称之为增强的,Advice中是切面的具体实现代码。那些需要织入的代码都写在Advice中。通知类型又分为很多种:前置通知,后置通知,异常通知,环绕通知。

Aspect 切面:Aspect切面只是一个概念,它是Pointcut切入点和Advice 的组合。

Weaving 织入:把切面应用到目标对象来创建新的advised对象的过程。

理解了这些术语,其实也就能够理解AOP这套编程范式是在做什么了。

Weaving织入机制

        AOP有很多种实现方法,但本质就是:拦截、代理、反射。我们也可以自己按照理念写一套AOP框架,主要在于AOP的织入方式,分为静态织入和动态织入,两种方式的实现机制如下

  • 静态织入
  1. 静态代理:如APT,可以在编译期生成代理类,直接修改原类。
  2. 自定义类加载器:如Javassist,启动自定义的类加载器,并通过类加载监听器监听目标类,发现目标类被加载时就织入切入逻辑。
  • 动态织入
  1. 动态代理:如JDK Proxy,在字节码加载后,为接口动态生成代理类,将切面植入到代理类中。
  2. 动态字节码生成:如CGLIB,在类的字节码加载后,通过字节码技术为该类创建子类,并在子类中采用方法拦截技术拦截所有父类方法的调用,再织入逻辑。

        根据这两种织入方式不同,也有对应的AOP方案,比如以AspectJ为代表的静态AOP框架。由于是在编译阶段修改目标类,所以需要使用特定的编译器,还有以Spring AOP为代表的动态AOP框架,是在内存中动态的生成代理类,直接修改字节码,所以不需要使用特定的编译器,但是由于使用了反射,而且创建代理类也增加了方法调用栈的深度,所以动态AOP框架的性能不如静态AOP。

AOP弊端

        没有技术是完美的,总是有改进和优化的空间,AOP也是同样,在被广泛使用的同时,也会有被诟病的地方。

  • 对AOP效果批评最多的就是控制流被模糊了,跟GOTO一样被诟病,织入的代码如果有问题,那么没有任何提示他也会被应用到切入点中,而且和方法的显示调用相反,advice是不可见的。它最终只会出现在字节码中。

  • 基于上述问题,拿到一个应用程序的源代码之后,如果不能可视化程序的静态结构和动态流程,那么理解这个应用程序中的横切关注点会很困难。好在AspectJ提供IDE插件来支持横切关注点的可视化。还有助于切面代码的重构。

  • AOP的切面很强大,可以很广泛,这是一把双刃剑,如果在定义横切时犯了一个逻辑错误,可能导致这个切面切入的所有程序发生故障。而且在多人开发时,如果另一个程序员改变程序中的连接点,比如重命名或移动方法,那么这个切面就失效了。如果沟通到位,那么可以避免这个问题,写切面的那位程序员更改一下切面即可。但是如果没有AOP,此时应用了这个方法的地方都得随之改动。

        最广为人知的AOP实现方案就是AspectJ和Spring AOP了,后面的章节,将分别描述这两套方案。

AspectJ

        它是一种基于Java平台的面向切面编程的语言。几乎和java一样,只不过多了不少关键字,而且完全兼容Java,AspectJ应该就是一种扩展Java,但不是像Groovy那样的拓展。

        AspectJ 是最早、功能比较强大的AOP实现,由Eclipse开源,很多其他语言的AOP实现都借鉴了 AspectJ 中的很多设计。在 Java 领域,AspectJ 中的很多语法结构基本上已成为 AOP 领域的标准。

        使用AspectJ时,可以使用AspectJ语法编码,还需要特定的编译器,java的编译器叫javac,AspectJ的编译器叫ajc,同时AspectJ还支持原生的Java,在使用JAVA开发时加上对应的AspectJ注解即可。

        在纯java项目中使用AspectJ的话,需要下载类似于jre的aspect的jar包,并且代码编译也要使用ajc编译器,鉴于我们目前开发都是基于springboot,所以以下内容都是基于SpringBoot使用AspectJ。

切面语法

  • 切面语法如下所示,关于几种通知机制的解释都写在注释中
@Component
@Aspect
public class AopLoggerAspect {

    /**
     * @Pointcut定义切入点,一个切面中可以有多个切入点,用优先级控制切入顺序
     * execution是连接点的匹配方式,如下是使用正则表达式匹配匹配到具体的方法
     * 标准的AspectJ Aop的pointcut的表达式类型很丰富,最常用的就是execution,除此之外,还包括:
     * within, this, target, args, @target, @args, @within, @annotation。详细解释见官方文档
     * Order 代表优先级,数字越小优先级越高,从1开始
     */
    @Pointcut("execution(public * com.daiaoqi.log.*.service.impl.*Impl.*(..))")
    @Order(1)
    public void pointCut() {

    }

    @Pointcut("@annotation(com.daiaoqi.log.annotation.RedisCache)")
    @Order(2)
    public void annoationPoint(){};

    /**
     * @Before 定义一个advice,需要和切入点关联
     * 在调用业务方法之前先执行doBefore方法
     */
    @Before("pointCut() || annoationPoint()")
    public void doBefore(JoinPoint joinPoint) {
        // ......
    }

    /**
     * @After 定义一个advice,需要和切入点关联
     * 在调用完成业务方法之后执行doBefore方法
     */
    @After("pointCut() && annoationPoint()")
    public void doAfter(JoinPoint joinPoint) {
        // .......
    }

    /**
     * 和 Before、After织入不一样,前者的织入只是在匹配的JoinPoint前后插入Advice方法,仅仅是插入。
     * 而Around则是拆分了业务代码和Advice方法,把业务代码迁移到新函数中,通过一个单独的闭包拆分来执行,相当于对目标JoinPoint进行了一个代理。
     * 所以Around情况下我们除了编写切面逻辑,还需要手动调用joinPoint.proceed()来调用闭包执行原方法。
     */
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        try {
            //......
            //调用 proceed() 方法才会真正的执行实际被代理的方法
            Object result = joinPoint.proceed();
            //......
            return result;
        } catch (Throwable te) {
            throw new RuntimeException(te.getMessage());
        }
    }

    /**
     * 目标方法执行后调用,可以拿到返回结果,执行顺序在 @After 之后
     */
    @AfterReturning(pointcut = "pointCut()",returning = "result")
    public void afterReturn(JoinPoint joinPoint, Object result) {
    }

    /**
     * 目标方法执行异常时调用
     */
    @AfterThrowing(pointcut = "pointCut()",throwing = "throwable")
    public void afterThrowing(JoinPoint joinPoint, Throwable throwable) {
    }
}
  • 如果这些通知类型都同时存在,则执行顺序为:
  1. 执行目标方法前先进入around,再进入before
  2. 目标方法执行完成后先进入around,再进入after,最后进afterreturning

织入方式

        AspectJ 织入方式有两种:一种是使用ajc编译器编译,可以在编译期将切面织入到业务代码中。另一种就是aspectjweaver.jar的agent代理,提供了一个 Java agent 用于在类加载期间织入切面,我们在SpringBoot项目中使用后一种方法,引入这个aspectjweaver依赖即可。

        他更像是一个代码生成工具,用一种特定语言编写切面,通过自己的语法编译工具 ajc 编译器来编译,根据AspectJ语法定义的规则编译出一段代码,再由AspectJ去插入到对应的业务代码位置。

Spring AOP

        Spring两大关键组件AOP和IOC。IoC容器是不依赖AOP的,这也就是为什么平时编码过程中,需要用到AOP的时候,才会去引入AspectJ的依赖,但AOP却是依赖于IoC 并且补充提供类似于中间件的功能。         这也是为什么Spring团队说从来不与 AspectJ 竞争,因为他的目标是与IOC做紧密结合,AspectJ则是专精与AOP方案。同时AspectJ也在致力于帮助Spring AOP变得更快,看到AspectJ官方博客中这样说道:

The aim here is to speed up Spring AOP - or really any system that consumes AspectJ like Spring does. Instead of just using the weaver as-is, Spring uses the pointcut parser and matcher independently of the weaver (unless LTW). Typically when used as a whole, the matching and weaving is all underpinned by type information parsed from class files. There are alternative ways to get that type information and Spring, when consuming just the parser and matcher, actually uses a Java reflection driven system

        SpringAOP使用了 AspectJ,但只是使用了与 AspectJ 一样的注解,没有使用AspectJ的编译器,采用动态代理技术的实现原理来实现动态织入,这是与 AspectJ静态织入最根本的区别。Spring 底层的动态代理分为两种 JDK 动态代理和 CGLib:

  • JDK 动态代理用于对接口的代理,动态产生一个实现指定接口的类,而且目标对象一定是要是实现了接口的,没有接口就不能实现动态代理,只能为接口创建动态代理实例,而不能对类创建动态代理。
  • CGLIB 用于对类的代理,把被代理对象类的 class 文件加载进来,修改其字节码生成一个继承了被代理类的子类。使用 cglib 就是为了弥补动态代理的不足。

Spring AOP提供哪些功能?

  1. 声明式事务管理:Spring可以采用声明的方式来处理事务。事务管理则用AOP来实现。
  2. 切面编程:允许用户实现自定义切面,用AOP补充对OOP的使用。

        下文将对AOP提供的两大功能(事务管理 & 切面编程)做详细解释。

事务管理

  • 什么是事务?
  1. 做web开发的想必都清楚数据库事务这个概念。他是作为单个逻辑工作单元执行的一系列操作。简单理解为,对数据库操作过程中各个细小的步骤组成的一个事务。
  2. 关于事务的特性,隔离级别,以及传播行为无关本文内容,详见此文: 数据库事务
  • Spring的事务处理方式
  1. 编程式事务:允许用户在代码中精确定义事务的边界,可以做到更详细的事务管理,但是对业务代码有较强的入侵。
  2. 基于AOP的声明式事务:有助于将操作与事务规则进行解耦。既能起到事务管理的作用,又可以不影响业务代码的具体实现。
  3. 使用编程式事务可以编写一个事务模板,一般很少有人会在Spring中使用编程式事务,毕竟对业务代码的入侵也是不可小觑的,需要手动的excuate,commit,事务,以及出错之后的rollback。
  4. 通常更建议使用声明式事务管理。SpringBoot会自动配置一个DataSourceTransactionManager,我们只需在方法(或者类)加上 @Transactional 注解,就自动纳入 Spring 的事务管理了。
  • 什么是声明式事务?
  1. 在配置文件中做相关的事务规则声明,或者是基于注解的方式,都可以将事务规则应用到业务逻辑中。因为事务管理本身就是一个典型的横切逻辑。个人感觉,AOP的切面编程灵感来源估计就是Spring团队对事务管理的思考。
  2. 和编程式事务相比,声明式事务唯一不足地方在于只能作用到方法级别,编程式事务可以作用到代码块级别。但是也有很多变通的技巧可以实现这个需求。比如将事务处理的代码块抽成方法。
  3. 声明式事务管理有两种配置方式,一种是基于tx和AOP命名空间的xml配置文件,另一种就是基于@Transactional注解。显然基于注解更加轻松。
  • Spring事务管理的实现原理
  1. 底层建立在AOP之上。其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。
  2. 具体的实现原理需要深入源码分析,占用篇幅会很长,也有悖与本文的主题,第一步在于理解,第二步才是深入,所以不在本文中有深入源码的分析。
  3. 关于spring事务管理源码的探析,参考文章:Spring事务管理深入解析

切面编程

        由于AOP和IOC的紧密结合,AOP要求切入的对象必须是IOC容器中的bean,所以如果要切入的对象不是bean,可能是第三方库中的,则需要想办法将其注册成bean。

        下文是一个简单的切面模板,在Spring中使用AOP还是很简单的。编写切面跟上一个章节AspectJ中描述的切面语法相同,因为都使用了相同的注解,区别在于底层实现的不同。需要先引入依赖支持

<dependency>
     <groupId>org.aspectj</groupId>
     <artifactId>aspectjrt</artifactId>
</dependency>
<dependency>
     <groupId>org.aspectj</groupId>
     <artifactId>aspectjweaver</artifactId>
</dependency>
@Component
@Aspect
public class AopLoggerAspect {

    /**
     * @Pointcut定义切入点,一个切面中可以有多个切入点,用优先级控制切入顺序
     * execution是连接点的匹配方式,如下是使用正则表达式匹配匹配到具体的方法
     * 标准的AspectJ Aop的pointcut的表达式类型很丰富,最常用的就是execution,除此之外,还包括:
     * within, this, target, args, @target, @args, @within, @annotation。详细解释见官方文档
     * Order 代表优先级,数字越小优先级越高,从1开始
     */
    @Pointcut("execution(public * com.daiaoqi.log.*.service.impl.*Impl.*(..))")
    @Order(1)
    public void pointCut() {

    }

    @Pointcut("@annotation(com.daiaoqi.log.annotation.RedisCache)")
    @Order(2)
    public void annoationPoint(){};

    /**
     * @Before 定义一个advice,需要和切入点关联
     * 在调用业务方法之前先执行doBefore方法
     */
    @Before("pointCut() || annoationPoint()")
    public void doBefore(JoinPoint joinPoint) {
        // ......
    }

    /**
     * @After 定义一个advice,需要和切入点关联
     * 在调用完成业务方法之后执行doBefore方法
     */
    @After("pointCut() && annoationPoint()")
    public void doAfter(JoinPoint joinPoint) {
        // .......
    }

    /**
     * 和 Before、After织入不一样,前者的织入只是在匹配的JoinPoint前后插入Advice方法,仅仅是插入。
     * 而Around则是拆分了业务代码和Advice方法,把业务代码迁移到新函数中,通过一个单独的闭包拆分来执行,相当于对目标JoinPoint进行了一个代理。
     * 所以Around情况下我们除了编写切面逻辑,还需要手动调用joinPoint.proceed()来调用闭包执行原方法。
     */
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        try {
            //......
            //调用 proceed() 方法才会真正的执行实际被代理的方法
            Object result = joinPoint.proceed();
            //......
            return result;
        } catch (Throwable te) {
            throw new RuntimeException(te.getMessage());
        }
    }

    /**
     * 目标方法执行后调用,可以拿到返回结果,执行顺序在 @After 之后
     */
    @AfterReturning(pointcut = "pointCut()",returning = "result")
    public void afterReturn(JoinPoint joinPoint, Object result) {
    }

    /**
     * 目标方法执行异常时调用
     */
    @AfterThrowing(pointcut = "pointCut()",throwing = "throwable")
    public void afterThrowing(JoinPoint joinPoint, Throwable throwable) {
    }
}

总结

        即使下平时工作中,很少有机会用到AOP,但是在实现的过程中,如果遇到类似的场景,还是需要思考一下,能否用AOP来做呢?毕竟这么好的工具,包括这其中的原理,静态代理,动态代理,CGLIB,即使不使用AOP,这些代理模式也可以在编码时应用。