【Java EE】Spring AOP

1. 初识 AOP

AOP 全称 Aspect Oriented Programming,意为面向切面编程。切面指一类特定的,可以被统一处理的问题,因此 AOP 可以理解为面向特定问题编程。Spring 提供的拦截器就是对 AOP 思想的一种实现。除了解决登录校验的问题,我们还可能遇到许多其他的,需要被统一处理的问题,这些问题就需要使用 Spring 对 AOP 的支持来实现。

因此,简单来说 AOP 就是一种将一类问题集中处理的思想。

Spring AOP 是 AOP 思想的具体实现,或者说 Spring 对 AOP 的支持。除此之外,AspectJ、CGLIB 等都是 AOP 思想的实现。

假设现在有一个需求,记录项目中所有业务方法的执行时间。我们需要在每一个业务方法的执行前后分别记录时间戳,这是很大的工作量,并且它们是重复的劳动。而如果使用 AOP 的思想,将这些工作提取出来统一处理,就能减少大部分重复工作。并且 AOP 可以在不对原有方法做改动的基础上,只进行功能增强,因此这也是一种低耦合的开发方式。

2. Spring AOP 的使用

  1. 在 pom 文件中添加相应依赖。
XML 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  1. 确定待增强的方法。 目标:分别统计下面三个方法的执行时间。
java 复制代码
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @RequestMapping("/t1")
    public Integer t1() {
        log.info("执行 t1");
        return 1;
    }

    @RequestMapping("/t2")
    public Boolean t2() {
        log.info("执行 t2");
        return true;
    }

    @RequestMapping("/t3")
    public String t3() {
        log.info("执行 t3");
        return "hello";
    }
}
  1. 写切面,描述当前遇到的统一问题,增强原方法。
java 复制代码
@Aspect // 标识这是一个切面类
@Component // 需要将该类交给 Spring 管理
@Slf4j
public class AspectDemo1 {

    // Around 环绕通知,表示通知可以在方法执行前,也可以在方法执行后
    @Around("execution(* com.boilermaker.springaoplearning.controller.*.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) {
        // 前置通知(打印时间戳)
        log.info("目标方法执行前...");
        long begin = System.currentTimeMillis();

        // 使用 Object 接收原始方法
        Object result;
        try {
            result = pjp.proceed(); // 执行原始方法
        } catch (Throwable e) {
            log.info("目标方法抛出异常..."); // 异常后通知
            throw new RuntimeException(e);
        }

        // 后置通知(打印时间戳,计算耗时)
        log.info("目标方法执行后...");
        long end = System.currentTimeMillis();
        log.info(pjp.getSignature() + " 执行耗时:" + (end - begin) + " ms");

        return result;
    }
}

3. 代码详解

3.1 切点 Pointcut

告诉程序对哪些方法来进行功能增强。下面的代码就是切点表达式。

java 复制代码
"execution(* com.boilermaker.springaoplearning.controller.*.*(..))"

execution( 访问修饰符 返回类型 包 - 类 - 方法 ( 方法参数 ) 异常 )

其中访问修饰符和异常是选填。

通配符表达:* 可以表示任意一个元素,如一层包、一个类、一个方法、一个返回类型、一个参数。.. 表示任意个连续的元素,如任意层包、任意个参数。

例:

我们也可以基于 @annotation 注解写针对多个无规则方法的切点表达式。比如 Controller1 中的 t1 方法和 Controller2 中的 t2 方法,这两个方法之间没有什么直接关联。此时,我们需要自定义注解,再使用 @annotation 表达切点,再在待增强方法上添加注解。

  1. 创建注解类,声明注解。
java 复制代码
@Target(ElementType.METHOD) // 描述该注解可以被添加在什么地方
@Retention(RetentionPolicy.RUNTIME) // 注解的生命周期
public @interface TimeMonitor {

}
  1. 编写切面类,和刚刚的代码基本一致,只是需要使用 @annotation 注解定义切点。下面的切点表达式意为只在加 TimeMonitor 注解的方法上做增强。
java 复制代码
@Around("@annotation(com.boilermaker.springaoplearning.aspect.TimeMonitor)")
  1. 为目标方法添加 @TimeMonitor 注解。在这里我为 t1 加该注解。

分别请求 t1 和 t2,发现 t1 被增强,t2 无效果,符合预期。

对于相同的切点表达式,我们不需要写很多遍,可以使用 @Pointcut 注解将公共的切点表达式提取出来。

java 复制代码
    @Pointcut("execution(* com.boilermaker.springaoplearning.controller.*.*(..))")
    public void controllerPointcut() {
    }

    ...

    // 后续使用时直接引用即可   
    @Around("controllerPointcut()")

3.2 切面 Aspect

使用切面来描述整个 AOP 程序,包括它针对于哪些方法,在什么时候执行什么样的操作。

切面所在的类,称为切面类,需要添加 @Aspect 注解。

当我们对同一个目标方法,应用多个切面类时,应使用 @Order 注解控制多个切面的执行顺序。其优先级遵循下图所示规则。

3.3 通知 Advice

通知就是我们具体要增强的那部分功能,也就是提取出的统一问题。

除了 @Around 环绕通知外,Spring 也单独提供了 @Before 前置通知、@After 后置通知、@AfterReturning 返回后通知和 @AfterThrowing 异常后通知。

其中 @After 和 @AfterReturning 的区别仅仅在于目标方法是否成功执行。如果目标方法执行成功,它们都可以正常增强,如果目标方法执行失败,@AfterReturning 将会失效,@After 依然可以正常增强。

@Around 更加自由,因为它在代码中调用 pjp.proceed() 来自由控制目标方法执行时间。如果目标方法执行失败,那么写在 pjp.proceed() 后的逻辑不会执行。所以 @Around 和 @After 这两个通知类型就可以应对全部情况了。

3.4 连接点 Join Point

连接点是满足切点表达式的元素,相当于切点的真子集。

比如在下面的切点表达式中,controller 包下全部类的全部方法都是连接点。

java 复制代码
"execution(* com.boilermaker.springaoplearning.controller.*.*(..))"

4. Spring AOP 原理

4.1 代理模式

代理模式下,代理对象与真实对象实现相同的接口,客户端无需直接操作真实对象,而是通过代理对象间接访问,从而在访问过程中附加额外功能(如日志记录、权限校验、延迟加载等)或限制访问。代理模式的本质是功能增强。

代理模式中的角色:

Subject 业务接口:定义代理和真实对象的共同接口,客户端通过该接口访问对象。

RealSubject 业务具体实现类:实现接口的具体业务逻辑,是代理的目标对象。

Proxy 代理类:实现抽象接口,持有目标对象的引用,在调用目标对象前后添加额外操作。

代理模式又分为静态代理和动态代理。我们很早就知道,Java 程序的运行需要经历,.java 文件的编写,编译器将其编译为 .class 文件,JVM 读取 .class 文件并执行这几个过程。静态代理就是指,代理类是由开发者自行编写,其对应的 .class 文件在运行前存在。相对的,动态代理的代理类由 JVM 来实现,在程序运行时根据需要动态生成。

4.1.1 静态代理

java 复制代码
-----> Subject 抽象接口
public interface UserService {
    String queryUser(String id);
}

-----> RealSubject 实现用户服务的具体逻辑
public class RealUserService implements UserService {
    @Override
    public String queryUser(String id) {
        return "用户信息:id=" + id; // 实际业务逻辑
    }
}

-----> Proxy 代理持有目标对象引用,添加日志功能
public class UserServiceProxy implements UserService {
    private UserService realUserService; // 持有真实对象

    public UserServiceProxy(UserService realUserService) {
        this.realUserService = realUserService;
    }

    @Override
    public String queryUser(String id) {
        // 调用前:添加日志
        System.out.println("日志:开始查询用户,id=" + id);
        // 调用真实对象的方法
        String result = realUserService.queryUser(id);
        // 调用后:添加日志
        System.out.println("日志:查询用户结束,结果=" + result);
        return result;
    }
}
java 复制代码
客户端通过代理间接访问目标对象
public class Client {
    public static void main(String[] args) {
        UserService realService = new RealUserService();
        UserService proxy = new UserServiceProxy(realService); // 创建代理
        proxy.queryUser("1001"); // 客户端调用代理
    }
}
diff 复制代码
输出:
日志:开始查询用户,id=1001  
用户信息:id=1001  
日志:查询用户结束,结果=用户信息:id=1001  

静态代理不够灵活。假设 Subject 中增加了许多接口,那么 Proxy 也需要相应地进行增加,但是实际上我们对这些方法的增强功能都是一样的,这就是在做重复的工作。我们使用代理就是为了避免重复,但是静态代理实际上只是将代码换了个地方,并没有减少任何工作量。因此静态代理几乎用不上,它只是帮我们更好地理解代理思想。

4.1.2 动态代理

对于动态代理来说,创建代理对象的工作被推迟到程序运行时,由 JVM 根据我们的需要来自行实现。

我们知道,JVM 执行字节码前有一个关键步骤,就是 JVM 通过类加载,将 .class 文件加载到内存,生成 java.lang.Class 对象。通过 Class 对象可以获取类的全部信息,这是 Java 反射机制的基础。Java 的反射 API 本质就是对 Class 中信息的读取和操作,发生在运行时,因此与反射有关的操作我们往往加以动态二字。

动态代理也不例外,它的核心就是反射机制。动态代理在运行时生成的代理类,本质就是动态生成的字节码。

具体原理如下:

先给出业务接口和其具体实现类。

java 复制代码
// -----> Subject <-----
public interface UserService {
    void queryUser(String id);

    void deleteUser(String id);
}

// -----> RealSubject <-----
public class RealUserService implements UserService {

    @Override
    public void queryUser(String id) {
        System.out.println("查询中, id = " + id);
    }

    @Override
    public void deleteUser(String id) {
        System.out.println("删除中, id = " + id);
    }
}

在拦截器中定义增强逻辑,JDK 动态代理规定拦截器需实现 InvocationHandler 接口。

java 复制代码
public class UserInvocationHandler implements InvocationHandler {
    private Object target;

    public UserInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        System.out.println("前置通知: ...");

        Object ret = method.invoke(target, args);

        System.out.println("后置通知: ...");

        return ret;
    }
}

现在的需求是增强 queryUser 和 deleteUser 方法。

使用动态代理,第一步,我们需要一个目标方法所在类的实例。它有两个作用,首先 Proxy 必须使用它的类加载器,并且它将作为参数传入拦截器。

java 复制代码
UserService realUserService = new RealUserService();

接下来,调用 Proxy.newProxyInstance() 方法来创建代理。

java 复制代码
        UserService proxy = (UserService) Proxy.newProxyInstance(
                // 类加载器
                realUserService.getClass().getClassLoader(),
                // 被代理类实现的接口
                new Class[]{UserService.class},
                // 指定拦截器
                new UserInvocationHandler(realUserService)
        );

代码的核心是方法的第二个参数,目标方法所在类实现的接口。这个参数告知 Proxy,需要生成一个实现 UserService 接口的代理类。它将在运行时动态生成实现该接口全部方法的字节码。实现逻辑类似下面的代码。

java 复制代码
// proxy 伪代码
public class proxy extends Proxy implements UserService {

    @Override
    public void queryUser(String id) {

        // Proxy 首先通过反射将方法封装在 Method 中
        Method method = UserService.class.getMethod("queryUser", String.class);

        // 调用传入的拦截器,进行增强操作
        userInvocationHandler.invoke(this, method, new Object[]{id});
    }

    @Override
    public void updateUser(String id) {
        Method method = UserService.class.getMethod("updateUser", String.class);
        userInvocationHandler.invoke(this, method, new Object[]{id});
    }
}

对于拦截器中的 invoke 方法。第一个参数是代理对象本身,这个参数一般不会用到。第二个参数是 proxy 封装的方法对象,它用于 method.invoke(realUserService, args),即反射调用目标对象的真实方法。第三个参数是当前方法调用的参数列表,比如调用 queryUser("1010") 时,args 就是 new Object [] {"1010"}。

java 复制代码
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {}

拦截器中的 invoke 方法由自动生成的 proxy 调用,invoke 方法中依然存在一个 invoke 方法,这个 invoke 方法是连接代理与目标对象的关键,因为它是在通过反射调用 RealSubject 中的方法。

java 复制代码
Object ret = method.invoke(target, args);

完整客户端代码如下。后续通过 proxy 调用方法,实际上调用的是 proxy 重写的方法。

java 复制代码
public class Client {
    public static void main(String[] args) {
        UserService realUserService = new RealUserService();

        UserService proxy = (UserService) Proxy.newProxyInstance(
                realUserService.getClass().getClassLoader(),
                new Class[]{UserService.class},
                new UserInvocationHandler(realUserService)
        );

        proxy.queryUser("1010");
        proxy.deleteUser("1010");
    }
}
相关推荐
保加利亚的风7 分钟前
【Java】使用FreeMarker来实现Word自定义导出
java·word
SteveCode.11 分钟前
SpringBoot 2.x 升 3.x 避坑指南:企业级项目的实战问题与解决方案
java·spring boot
Yang-Never21 分钟前
Kotlin -> object声明和object表达式
android·java·开发语言·kotlin·android studio
风萧萧199922 分钟前
Java 实现poi方式读取word文件内容
java·开发语言·word
喵手32 分钟前
如何实现一个简单的基于Spring Boot的用户权限管理系统?
java·spring boot·后端
C4程序员1 小时前
北京JAVA基础面试30天打卡01
java·开发语言·面试
Aczone281 小时前
数据结构(三)双向链表
java·数据结构·链表
菜菜的后端私房菜1 小时前
Dubbo2到Dubbo3服务发现机制的优化
java·后端·dubbo
33255_40857_280591 小时前
RocketMQ实战指南:Java开发者的分布式消息中间件全解析
java·rocketmq
Seven972 小时前
剑指offer-19、顺时针打印矩阵
java