Spring AOP 详解

80 阅读11分钟

一、介绍AOP

1. AOP的定义

AOP是Aspect Oriented Programming,意为面向切面编程。
那什么是AOP?
我们先回顾一下OOP:Object Oriented Programming,意为面向对象编程。OOP的主要功能是封装、继承、多态。
而AOP是一种新的编程方式,它和OOP不同,OOP把系统看做多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。简单来说,AOP是一种思想,是对某一类事情的集中处理
AOP采取横向抽取机制,取代了传统纵向继承体系重复性代码。如下图所示:

aop.png

AOP的作用:在程序运行期间,在不修改程序源代码的基础上,对已有的方法进行增强。(无侵入性,解耦)。

1.1 举例说明

要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:

  • createBook(): 添加新的Book
  • updateBook(): 修改已有的Book
  • deleteBook(): 删除Book

对每个业务方法,除了业务逻辑,还需要安全检查、日志记录和事务处理。它的代码像这样:

public class BookService {
    public void createBook(Book book) {
        securityCheck();
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("created book: " + book);
    }
    
    public void updateBook(Book book) {
        securityCheck();
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("updated book: " + book);
    }
}

对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。
BookService关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。

一种可行的方式是使用Proxy模式:将某个功能(例如:权限检查)视作一种切面(Aspect),把日志、事务也视为切面,然后,以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。

如果我们以AOP的视角来编写上述业务,可以依次实现:

  1. 核心逻辑,即BookService;
  2. 切面逻辑,即:
    2.1. 权限检查的Aspect;
    2.2. 日志的Aspect;
    2.3. 事务的Aspect。

然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来,就不必编写复杂而冗长的Proxy模式。

2. AOP原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。

在Java平台上,对于AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ是一种基于Java语言的AOP框架,它扩展了Java编译器,使用关键字aspect在编译时实现织入。
  2. 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”。
  3. 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。Spring AOP就是在运行期通过代理方式向目标类织入增强代码。

最简单的方式就是第三种。

什么是Spring AOP?
Spring AOP是AOP的一种实现方式,它的实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者javassist这些三方库实现。

3. AOP的组成

3.1 几个核心概念

  • PointCut: 切(入)点。它的作用是提供一组规则(使用切点表达式)定义对应用程序的哪些方法进行增强。
  • JoinPoint: 连接点。满足切点规则的具体方法就是连接点,也就是可以插入切面的方法。切点是一组连接点的集合
  • advice: 通知。代码增强,指连接点上执行的动作。
  • Aspect:切面。即一个横跨多个核心逻辑的功能,或者称之为系统关注点。切面 = 切点 + 通知。
  • Introduction:引介。指为一个已有的Java对象动态的增加新的接口。
  • Weaving: 织入,指将切面整合到应用程序的执行流程中。
  • Interceptor: 拦截器,是一种实现增强的方式。
  • Target Object:目标对象,即真正执行业务的核心逻辑对象。
  • AOP Proxy: AOP代理,是客户端持有的增强后的对象引用。

3.2 Advice(通知)的类型

上面我们讲了什么是通知,接下来学习通知的类型。Spring Aop中通知类型有以下几种:

  • @Around:环绕通知。此注解标注的通知方法在目标方法前后都被执行。
  • @Before:前置通知。此注解标注的通知方法在目标方法前被执行。
  • @AfterReturning: 返回后通知。此注解标注的通知方法在目标方法正常返回后被执行,有异常不会执行。
  • @AfterThrowing: 异常后通知。此注解标注的通知方法在目标方法发生异常后被执行。
  • @After:后置通知。此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行。
3.2.1 没有异常时,通知的运行顺序

程序正常运行的情况下,@AfterThrowing标识的通知方法不会执行。

image.png

3.2.2 有异常时,通知的运行顺序
  • @AfterReturning标识的通知方法不会执行。
  • @AfterThrowing标识的通知方法执行了。
  • @Around环绕通知中原始方法调用有异常,通知中的环绕后的代码逻辑也不会再执行了。 image.png

注意:

  • @Around 环绕通知需要调用ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行。
  • @Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。
  • 一个切面类可以有多个切点。

二、Spring AOP的使用示例

我们以UserServiceMailService为例,这两个属于核心业务逻辑,现在,我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:

1. 引入Spring对AOP的支持

  1. 如果是SpringBoot项目,就引入org.springframework.boot:spring-boot-starter-aop包。
  2. 如果是Spring项目但不是SpringBoot,就引入org.springframework:spring-aspects包。

2. 代码实现

Spring AOP有两种实现方式:基于@Aspect、基于自定义注解

2.1 基于@Aspect

  1. 我们定义一个LoggingAspect
@Aspect
@Component
public class LoggingAspect {
    // 在执行UserService的每个方法前执行:
    @Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }

    // 在执行MailService的每个方法前后执行:
    @Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
    public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
        System.err.println("[Around] start " + pjp.getSignature());
        // 执行真正的业务逻辑
        Object retVal = pjp.proceed();
        System.err.println("[Around] done " + pjp.getSignature());
        return retVal;
    }
}

观察doAccessCheck()方法,我们定义了一个@Before注解,里面的execution是切点表达式,告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService的每个public方法执行doAccessCheck()代码。

再观察doLogging()方法,我们定义了一个@Around注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。

LoggingAspect类的声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前,@Around标注的方法需要注入到MailService的每个public方法执行前后。

  1. 如果不是SpringBoot项目,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
    ...
}

Spring的IoC容器看到这个注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before、@Around等注解把AOP注入到特定的Bean中。

在SpringBoot项目中,不需要显示的添加@EnableAspectJAutoProxy注解。在我们的主类或配置类上有@SpringBootApplication注解,SpringBoot的自动配置就会生效,包括AOP的自动配置。这样,切面就会被自动识别并应用。

执行代码,我们可以看到以下输出:

[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
Welcome, test!
[Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
[Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)

这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect的方法)。

使用切入点表达式(execution(public * com.itranswarp.learnjava.service.UserService.*(..)))这种方式基本能实现无差别全覆盖,即某个包下面的所有Bean的所有方法都会被这个doAccessCheck()方法拦截,非精准打击误伤面非常大。

我们在使用AOP时,要注意到虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且,后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。

使用AOP时,被装配的Bean最好自己能清清楚楚地知道自己被安排了。例如,Spring提供的@Transactional就是一个非常好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional

@Component
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }

    // 无事务:
    public boolean isValidName(String name) {
        ...
    }
}

因此,装配AOP的时候,使用注解是最好的方式。

2.2 基于自定义注解

我们以一个实际例子演示如何使用注解实现AOP装配。

  1. 为了监控应用程序的性能,我们定义一个性能监控的注解:
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
    String value();
}

2. 在需要被监控的关键方法上标注该注解:

@Component
public class UserService {
    // 监控register()方法性能:
    @MetricTime("register")
    public User register(String email, String password, String name) {
        ...
    }
    ...
}

3. 然后,我们定义切面MetricAspect

@Aspect
@Component
public class MetricAspect {
    @Around("@annotation(metricTime)")
    public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
        String name = metricTime.value();
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long t = System.currentTimeMillis() - start;
            // 写入日志或发送至JMX:
            System.err.println("[Metrics] " + name + ": " + t + "ms");
        }
    }
}

注意metric()方法标注了@Around("@annotation(metricTime)"),它的意思是,符合条件的目标方法是带有@MetricTime注解的方法,因为metric()方法参数类型是MetricTime(注意参数名是metricTime不是MetricTime),我们通过它获取性能监控的名称。

有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。
运行代码,输出结果如下:

Welcome, Bob!
[Metrics] register: 16ms

三、Spring AOP的原理

那么LoggingAspect定义的方法,是如何注入到其他Bean的呢?
我们以LoggingAspect.doAccessCheck()为例,要把它注入到UserService的每个public方法中,最简单的方法是编写一个子类,并持有原始实例的引用:

public UserServiceAopProxy extends UserService {
    private UserService target;
    private LoggingAspect aspect;

    public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
        this.target = target;
        this.aspect = aspect;
    }

    public User login(String email, String password) {
        // 先执行Aspect的代码:
        aspect.doAccessCheck();
        // 再执行UserService的逻辑:
        return target.login(email, password);
    }

    public User register(String email, String password, String name) {
        aspect.doAccessCheck();
        return target.register(email, password, name);
    }

    ...
}

这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService(原始的UserService实例作为内部变量隐藏在UserServiceAopProxy中)。如果我们打印从Spring容器获取的UserService实例类型,它类似UserService$$EnhancerBySpringCGLIB$$1f44e01c,实际上是Spring使用CGLIB动态创建的子类,但对于调用方来说,感觉不到任何区别。

Spring AOP的原理:Spring AOP对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。

四、Spring AOP的使用场景

  1. 日志记录
    在方法执行前后自动记录日志,无需在每个方法中手动添加日志代码。
  2. 事务管理
    确保数据操作的一致性和完整性。例如在数据库操作前后自动开启和提交事务。
  3. 安全检查
    在方法执行前进行权限验证。例如检查用户是否有权访问某个资源。
  4. 性能监控
    测量方法的执行时间,帮助识别性能瓶颈。
  5. 缓存处理
    在方法执行前后自动缓存结果,减少重复计算。
  6. 错误处理
    捕获方法执行中抛出的异常,并进行统一处理或记录。
  7. 数据校验
    在请求接口时,先校验参数的合法性。