Skip to content

Spring教程 - 7 面向切面编程AOP - 基于注解配置

下面讲解 Spring 基于注解的配置方式来实现 AOP。

现在的项目基本都使用基于注解的方式了,所以直接讲解基于注解的方式。


7.1 AOP的使用

下面就使用注解实现前置通知,也就是在方法执行之前执行一些操作,我们这里在方法执行之前打印方法的参数。

1 引入依赖

首先在项目的 pom.xml 中引入 AOP 相关的依赖:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.foooor</groupId>
    <artifactId>hello-spring</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>


    <dependencies>
        <!-- spring核心依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>6.2.9</version>
        </dependency>

        <!-- 1.AOP相关 引入spring aop依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aop</artifactId>
            <version>6.2.9</version>
        </dependency>

        <!-- 2.AOP相关 引入spring aspects依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
            <version>6.2.9</version>
        </dependency>


        <!-- junit5依赖 -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.13.4</version>
        </dependency>

        <!-- logback依赖,日志框架实现 -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.5.18</version>
        </dependency>

        <!-- slf4j依赖,日志框架接口门面 -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.17</version>
        </dependency>

    </dependencies>
</project>

2 创建业务类

还是前面介绍的计算用的业务类。

首先定义接口:

ICalculatorService.java

java
package com.foooor.hellospring.service;

public interface ICalculatorService {
    /**
     * 加法
     */
    int add(int a, int b);
    
    /**
     * 减法
     */
    int sub(int a, int b);
    
    /**
     * 乘法
     */
    int mul(int a, int b);
    
    /**
     * 除法
     */
    int div(int a, int b);
}

然后定义实现类:

CalculatorServiceImpl.java

java
package com.foooor.hellospring.service.impl;

import com.foooor.hellospring.service.ICalculatorService;
import org.springframework.stereotype.Service;

@Service
public class CalculatorServiceImpl implements ICalculatorService {

    /**
     * 加法
     */
    public int add(int a, int b) {
        int result = a + b;
        return result;
    }

    /**
     * 减法
     */
    public int sub(int a, int b) {
        int result = a - b;
        return result;
    }

    /**
     * 乘法
     */
    public int mul(int a, int b) {
        int result = a * b;
        return result;
    }

    /**
     * 除法
     */
    public int div(int a, int b) {
        int result = a / b;
        return result;
    }
}
  • 注意添加 @Service 注解,交由Spring管理。
  • 在上面的代码中,去掉了日志相关的操作。

3 创建切面类

切面:就是当前要定义的类,负责实现具体要切入的功能,我们这里就是打印日志的功能。

通知:切面要实现的功能,这里就是打印日志功能,在方法执行之前打印出方法的参数。通知类型为前置通知,也就是在方法之前执行。

切入点:插入到哪里,是插入到上面 Service 中方法之前之前。


前面讲到,通知类型有:前置、后置、返回、异常、环绕五个类型,我们在定义通知的时候,每一种通知都有对应的注解,我们想要实现什么类型的通知,只需要定义一个方法,在上面添加对应的通知类型的注解即可。

在项目中创建包,并创建切面类,在切面类中设置切入点和通知类型。

java
package com.foooor.hellospring.aspect;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Arrays;

/**
 * 日志切面
 */
@Aspect // 标识是切面类
@Component // 交给Spring管理
public class LogAspect {

    // 创建日志对象
    private static final Logger logger = LoggerFactory.getLogger(LogAspect.class);

    /**
     * 定义前置通知,方法名是自定义的
     */
    @Before("execution(* com.foooor.hellospring.service.ICalculatorService.*(..))")  //前置通知
    public void beforeAdvice(JoinPoint jp) {
        // 获取方法名
        String methodName = jp.getSignature().getName();
        // 获取方法参数
        Object[] args = jp.getArgs();

        // 记录日志,打印方法的名称和参数
        logger.info("[Before] 调用方法: {}, 方法参数: {}", methodName, Arrays.toString(args));
    }
}
  • 首先类上要添加相应的注解 @Aspect@Component
  • 然后定义一个方法,方法名是自定义的,方法参数是 JoinPoint 可以获取到目标方法的信息。
  • 然后在方法上添加通知类型的注解,@Before 表示是前置通知,注解有一个切入点表达式的参数,通过表达式指定要在哪些方法执行之前执行通知。上面指定的是对 com.foooor.hellospring.service.ICalculatorService 类中 所有的方法 进行拦截并插入通知。关于切入点表达式,待会再细说
  • 在通知中,获取到了方法的名称和参数,并打印。

切面已经创建好了,并且指定前置通知插入的切入点的位置。

4 创建配置类

因为是全注解开发,所以不用 XML 配置文件了,直接使用配置类。

可以创建config包,在包下创建配置类(包名、类名自定义)。

SpringConfig.java

java
package com.foooor.hellospring.config;

import org.aspectj.lang.annotation.Aspect;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration  // 配置类
@EnableAspectJAutoProxy  // 开启基于 @AspectJ 注解的 AOP
@ComponentScan("com.foooor.hellospring")  // 开启组件扫描,并指定扫描的包
public class SpringConfig {
}
  • 在配置类上添加 @Configuration 注解,标识是配置类;
  • 添加 @EnableAspectJAutoProxy 注解,开启基于 @AspectJ 注解的 AOP;
  • 添加 @ComponentScan 注解,开启 Spring IoC 组件扫描,扫描指定的包。

5 测试

创建一个测试类,测试一下,调用 service 中的方法之前,能否调用切面中的通知。

AdviceTest.java

java
package com.foooor.hellospring;

import com.foooor.hellospring.config.SpringConfig;
import com.foooor.hellospring.service.ICalculatorService;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class AdviceTest {

    @Test
    public void testUserService() {
        // 加载配置类
        ApplicationContext context = new AnnotationConfigApplicationContext(SpringConfig.class);
        // 获取bean
        ICalculatorService userController = context.getBean(ICalculatorService.class);
        // 调用bean中的方法
        int result = userController.add(2, 3);
        System.out.println("result:" + result);
    }
}

通知执行的结果,可以看到,在执行 service 方法之前,先执行了通知,然后在通知中打印了方法的参数:

[main] INFO  c.f.hellospring.aspect.LogAspect - [Before] 调用方法: add, 方法参数: [2, 3]
[main] INFO  com.foooor.hellospring.AdviceTest - result:5

所以我们现在做的工作只是创建了一个切面和通知,并配置在哪些地方插入通知而已,非常的easy。


项目结构如下:

  • 日志配置,Helloworld章节已经介绍了。

7.2 切入点表达式

下面单独介绍一下切入点表达式。

1 表达式语法

从后往前说:

  • 参数列表

    表示参数的类型,如果写成 (..) 表示匹配任意参数,参数类型个数不限;(*) 表示匹配一个参数,类型不限。(String, ..) 表示第一个参数是 String 类型,后面类型和个数不限。

    注意:如果不是基础数据类型或 java.lang 包下的类型,需要写全类名。基础数据类型(例如int)和包装类(Integer)是不匹配的。

  • 方法名

    * 表示匹配任意的方法名;也可以部分使用 * ,例如 get* 匹配 get 开头的方法;*Service* 匹配方法名中包含 Service 的方法。

  • 类名

    * 可以匹配任意类名;也可以匹配部分类名,例如 *Service,匹配 Service 结尾的类或接口。

  • 包名

    * 单层通配符,例如:execution(* com.foooor.*.UserService.*(..)) 匹配 com.foooor.service.UserServicecom.example.repo.UserService 等;

    ..多层通配符,例如 execution(* com.foooor..UserService.*(..)) 匹配 com.foooor 及任意子包下的 UserService

    execution(* com.foooor..*Service.*(..)) 匹配 com.foooor 及任意子包下,类名以 Service 结尾的所有方法。

  • 权限修饰符和返回值

    execution(public * com.foooor..*.*(..)) :只匹配public方法,返回值任意,权限修饰符是可选的,可以不写,表示任意修饰符;

    execution(* com.foooor..*.*(..)) :表示返回值任意,修饰符任意;

    execution(java.util.List com.foooor..*.*(..)) :表示返回值类型为 java.util.List ,注意:如果不是基础类型或 java.lang 包下的类型,需要写全类名。

2 @Pointcut

我们在上面编写表达式的时候,是直接写在通知注解上的。

例如:

java
@Before("execution(* com.foooor.hellospring.service.ICalculatorService.*(..))")  //前置通知
public void beforeAdvice(JoinPoint jp) {
}

如果多个想通知共用一个表达式呢,为了重复使用,你可能想到,既然是一个字符串,直接定义字符串就好了:

java
// 定义一个字符
private static final String executionStr = "execution(* com.foooor.hellospring.service.ICalculatorService.*(..))";

@Before(executionStr)  //前置通知
public void beforeAdvice(JoinPoint jp) {
}

其实是可以的,但是使用字符串,表达式写的有问题的时候,是没有提示的,写在通知注解中是有提示的:


这个时候,我们可以使用 @Pointcut 注解,定义切入点,实现切入点表达式的重用。

使用方法如下:

java
// 1.首先在切面中定义一个切入点方法,方法名自定义,在方法上使用 @Pointcut 注解定义切入点表达式
@Pointcut("execution(* com.foooor.hellospring.service.ICalculatorService.*(..))")
public void userServiceMethods() {}

// 2.然后在通知上使用上面定义的切入点
@Before("userServiceMethods()")
public void beforeAdvice(JoinPoint jp) {
}
  • 首先定义切入点方法,然后在通知上使用切入点方法;
  • 需要注意:切入点方法的方法体 必须为空,只做标记使用;

这样如果有多个通知,可以使用重复使用定义的切入点。

3 @annotation

@annotation 用来匹配 方法上标注了某个注解 的方法。与 execution(...) 不同,它不是按方法签名匹配,而是按 方法上注解 匹配。

举个栗子:

java
@Aspect
@Component
public class LoggingAspect {

    @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void openTransactional() {}

    @Before("openTransactional()")
    public void beforeAdvice(JoinPoint jp) {
        System.out.println("调用方法: " + jp.getSignature().getName());
    }
}
  • 这里切点匹配所有 @Transactional 注解 的方法

  • 不关心方法名、参数、返回值、类名等

4 切入点逻辑运算

切入点表达式还可以使用逻辑运算符:

  • &&:与
  • ||:或
  • !:非

举个栗子:

  1. 与运算 &&

匹配包 com.foooor.service 下类名以 User 开头且方法名以 get 开头的方法:

java
@Pointcut("execution(* com.foooor.service.User*.*(..)) && execution(* *.*.get*(..))")
public void userGetMethods() {}
  • 两个条件都满足才匹配,这里相当于:类名 User* 方法名 get*

再举个例子:

java
// 匹配 Service 中的所有方法,且方法带 @Transactional
@Pointcut("execution(* com.foooor.service.*Service.*(..)) && @annotation(org.springframework.transaction.annotation.Transactional)")
public void transactionalMethods() {}

  1. 或运算 ||

匹配方法名以 getfind 开头的所有方法:

java
@Pointcut("execution(* *.*.get*(..)) || execution(* *.*.find*(..))")
public void getOrFindMethods() {}
  • 任意一个条件满足即可匹配

  1. 非运算 !

匹配方法名 不是set 开头的方法:

java
@Pointcut("!execution(* *.*.set*(..))")
public void notSetMethods() {}
  • 可以用来排除某些方法。

@Pointcut 可以 组合多个切点:

java
@Pointcut("execution(* com.example..*Service.*(..))")
public void serviceMethods() {}

@Pointcut("execution(* com.example..*Controller.*(..))")
public void controllerMethods() {}

// 组合前面两个切入点
@Pointcut("serviceMethods() || controllerMethods()")
public void serviceOrControllerMethods() {}

通知在使用切入点方法的时候,也可以使用逻辑运算符:

java
@Before("userServiceMethods() && getOrFindMethods()")
public void beforeAdvice(JoinPoint jp) {
}

7.3 其他通知类型的使用

上面在介绍 AOP 使用的时候,只是介绍了前置通知,下面来介绍一下其他几种通知类型的使用。

我们就直接在前面定义的切面类中,继续定义其他类型的通知。

和前置通知非常类似,直接看代码:

java
package com.foooor.hellospring.aspect;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Arrays;

/**
 * 日志切面
 */
@Aspect
@Component
public class LogAspect {

    private static final Logger logger = LoggerFactory.getLogger(LogAspect.class);

    /**
     * 定义切入点:匹配 ICalculatorService 接口下所有方法
     */
    @Pointcut("execution(* com.foooor.hellospring.service.ICalculatorService.*(..))")
    public void calculatorServiceMethods() {}

    /**
     * 前置通知:方法执行前
     */
    @Before("calculatorServiceMethods()")
    public void beforeAdvice(JoinPoint jp) {
        String methodName = jp.getSignature().getName();
        Object[] args = jp.getArgs();
        logger.info("[Before] 调用方法: {}, 参数: {}", methodName, Arrays.toString(args));
    }

    /**
     * 后置通知:方法执行后(无论是否异常)
     */
    @After("calculatorServiceMethods()")
    public void afterAdvice(JoinPoint jp) {
        String methodName = jp.getSignature().getName();
        logger.info("[After] 方法执行完成: {}", methodName);
    }

    /**
     * 返回通知:方法正常返回后执行
     * returning:指定返回值参数名,用于接收方法的返回值,方法第二个参数名称必须与returning指定的值一致
     */
    @AfterReturning(value = "calculatorServiceMethods()", returning = "result")
    public void afterReturningAdvice(JoinPoint jp, Object result) {
        String methodName = jp.getSignature().getName();
        logger.info("[AfterReturning] 方法 {} 返回值: {}", methodName, result);
    }

    /**
     * 异常通知:方法抛出异常后执行
     * throwing:指定异常参数名,用于接收方法抛出的异常,方法第二个参数名称必须与throwing指定的值一致
     */
    @AfterThrowing(value = "calculatorServiceMethods()", throwing = "ex")
    public void afterThrowingAdvice(JoinPoint jp, Throwable ex) {
        String methodName = jp.getSignature().getName();
        logger.error("[AfterThrowing] 方法 {} 异常: {}", methodName, ex.getMessage(), ex);
    }

    /**
     * 环绕通知:可控制方法执行前后,以及返回值和异常
     */
    @Around("calculatorServiceMethods()")
    public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
        String methodName = pjp.getSignature().getName();
        Object[] args = pjp.getArgs();

        logger.info("[Around-Before] 调用方法: {}, 参数: {}", methodName, Arrays.toString(args));

        Object result = null;
        try {
            // 执行目标方法
            result = pjp.proceed();
            logger.info("[Around-AfterReturning] 方法 {} 返回值: {}", methodName, result);
        } catch (Throwable ex) {
            logger.error("[Around-AfterThrowing] 方法 {} 出现异常: {}", methodName, ex.getMessage(), ex);
            throw ex; // 必须抛出异常,否则目标方法异常会被吞掉
        } finally {
            logger.info("[Around-Finally] 方法 {} 执行完成", methodName);
        }

        return result;
    }
}
  • 这里有几个点注意一下,返回通知是可以获取到方法的返回值的。
  • 环绕通知需要注意一下,我们可以在环绕通知中使用 try-catch-finlly, 当目标方法不抛出异常的时候,那么环绕通知的代码从前到后执行,不会执行 catch 中的代码;如果目标方法报错,则会执行环绕通知中的 catch 中的代码。
  • 环绕通知使用 try-catch-finlly 可以实现其他通知共同的功能,所以实际的开发中,根据需要判断使用哪一种通知,如果使用了环绕,也就不需要使用其他通知了(针对同一个切入点)。

如果方法没有出现异常,则执行顺序为:

  1. @Around:环绕通知前半段
  2. @Before :前置通知
  3. 执行目标方法(真正的方法体)
  4. @AfterReturning :如果方法执行成功并返回结果,先触发返回通知
  5. @After:无论是否异常,都会执行后置通知(类似 finally
  6. @Around:环绕通知后半段,也就是 pjp.proceed() 之后的代码,然后执行后置环绕通知的 finally 部分。

如果方法抛出异常,则执行顺序为:

  1. @Around :环绕通知前半段
  2. @Before :前置通知
  3. 执行目标方法,抛出异常
  4. @AfterThrowing:异常通知
  5. @After:无论是否异常,都会执行后置通知(类似 finally
  6. @Around:执行环绕通知的异常捕获部分,catch + finally

所以上面的切面,在没有抛出异常的时候,执行结果如下:

[Around-Before] 调用方法: add, 参数: [2, 3]
[Before] 调用方法: add, 参数: [2, 3]
[AfterReturning] 方法 add 返回值: 5
[After] 方法执行完成: add
[Around-AfterReturning] 方法 add 返回值: 5
[Around-Finally] 方法 add 执行完成
result:5

如果抛出异常,则执行结果如下:

[Around-Before] 调用方法: add, 参数: [2, 3]
[Before] 调用方法: add, 参数: [2, 3]
[AfterThrowing] 方法 add 异常: / by zero
[After] 方法执行完成: add
[Around-AfterThrowing] 方法 add 出现异常: / by zero
[Around-Finally] 方法 add 执行完成

7.4 切面优先级

当有多个切面同时作用于一个方法时,可以通过切面的优先级控制切面的调用顺序。

多个切面像一个环,包围目标方法:

在调用目标方法的时候,相当于进入环,优先级高的切面在外面;先执行,优先级低的切面在里面,后执行。

目标方法调用完成,相当于出环,优先级低的切面的返回通知、后置通知、环绕通知后半段、环绕通知finally先执行;


可以通过下面的方式,控制通知的优先级。

方法一:让切面实现 org.springframework.core.Ordered 接口,通过重写 getOrder() 返回切面顺序

java
import org.springframework.core.Ordered;

@Aspect
@Component
public class LogAspect implements Ordered {

    @Override
    public int getOrder() {
        return 1; // 数字越小,优先级越高
    }
}

方法二:在切面上使用 @Order 注解(推荐

java
import org.springframework.core.annotation.Order;

@Aspect
@Component
@Order(1)  // 数字越小,优先级越高
public class LogAspect {
}

7.5 总结

通知类型注解配置执行时机能否阻止目标方法能否修改返回值/异常典型应用场景注意事项
前置通知@Before目标方法调用前执行❌ 不能❌ 不能权限校验、参数验证、日志记录注解示例: @Before("execution(* com..*.*(..))")
后置通知@After目标方法执行后执行 (无论成功或异常)❌ 不能❌ 不能资源清理、统计耗时相当于finally
返回通知@AfterReturning目标方法成功返回后执行❌ 不能❌ 不能记录返回值、结果处理注解需绑定返回值: @AfterReturning(returning="result")
异常通知@AfterThrowing目标方法抛出异常后执行❌ 不能❌ 不能异常日志、错误告警注解需绑定异常: @AfterThrowing(throwing="ex")
环绕通知@Around完全控制目标方法执行 (前后均可干预)✅ 能(通过proceed()✅ 能事务管理、性能监控、缓存必须调用proceed()Object result = joinPoint.proceed();
内容未完......