【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 文件中添加相应依赖。
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2. 确定待增强的方法。 目标:分别统计下面三个方法的执行时间。
@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";}
}
3. 写切面,描述当前遇到的统一问题,增强原方法。
@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
告诉程序对哪些方法来进行功能增强。下面的代码就是切点表达式。
"execution(* com.boilermaker.springaoplearning.controller.*.*(..))"
execution( 访问修饰符 返回类型 包 - 类 - 方法 ( 方法参数 ) 异常 )
其中访问修饰符和异常是选填。
通配符表达:* 可以表示任意一个元素,如一层包、一个类、一个方法、一个返回类型、一个参数。.. 表示任意个连续的元素,如任意层包、任意个参数。
例:
我们也可以基于 @annotation 注解写针对多个无规则方法的切点表达式。比如 Controller1 中的 t1 方法和 Controller2 中的 t2 方法,这两个方法之间没有什么直接关联。此时,我们需要自定义注解,再使用 @annotation 表达切点,再在待增强方法上添加注解。
1. 创建注解类,声明注解。
@Target(ElementType.METHOD) // 描述该注解可以被添加在什么地方
@Retention(RetentionPolicy.RUNTIME) // 注解的生命周期
public @interface TimeMonitor {}
2. 编写切面类,和刚刚的代码基本一致,只是需要使用 @annotation 注解定义切点。下面的切点表达式意为只在加 TimeMonitor 注解的方法上做增强。
@Around("@annotation(com.boilermaker.springaoplearning.aspect.TimeMonitor)")
3. 为目标方法添加 @TimeMonitor 注解。在这里我为 t1 加该注解。
分别请求 t1 和 t2,发现 t1 被增强,t2 无效果,符合预期。
对于相同的切点表达式,我们不需要写很多遍,可以使用 @Pointcut 注解将公共的切点表达式提取出来。
@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 包下全部类的全部方法都是连接点。
"execution(* com.boilermaker.springaoplearning.controller.*.*(..))"
4. Spring AOP 原理
4.1 代理模式
代理模式下,代理对象与真实对象实现相同的接口,客户端无需直接操作真实对象,而是通过代理对象间接访问,从而在访问过程中附加额外功能(如日志记录、权限校验、延迟加载等)或限制访问。代理模式的本质是功能增强。
代理模式中的角色:
Subject 业务接口:定义代理和真实对象的共同接口,客户端通过该接口访问对象。
RealSubject 业务具体实现类:实现接口的具体业务逻辑,是代理的目标对象。
Proxy 代理类:实现抽象接口,持有目标对象的引用,在调用目标对象前后添加额外操作。
代理模式又分为静态代理和动态代理。我们很早就知道,Java 程序的运行需要经历,.java 文件的编写,编译器将其编译为 .class 文件,JVM 读取 .class 文件并执行这几个过程。静态代理就是指,代理类是由开发者自行编写,其对应的 .class 文件在运行前存在。相对的,动态代理的代理类由 JVM 来实现,在程序运行时根据需要动态生成。
4.1.1 静态代理
-----> Subject 抽象接口
public interface UserService {String queryUser(String id);
}-----> RealSubject 实现用户服务的具体逻辑
public class RealUserService implements UserService {@Overridepublic String queryUser(String id) {return "用户信息:id=" + id; // 实际业务逻辑}
}-----> Proxy 代理持有目标对象引用,添加日志功能
public class UserServiceProxy implements UserService {private UserService realUserService; // 持有真实对象public UserServiceProxy(UserService realUserService) {this.realUserService = realUserService;}@Overridepublic String queryUser(String id) {// 调用前:添加日志System.out.println("日志:开始查询用户,id=" + id);// 调用真实对象的方法String result = realUserService.queryUser(id);// 调用后:添加日志System.out.println("日志:查询用户结束,结果=" + result);return result;}
}
客户端通过代理间接访问目标对象
public class Client {public static void main(String[] args) {UserService realService = new RealUserService();UserService proxy = new UserServiceProxy(realService); // 创建代理proxy.queryUser("1001"); // 客户端调用代理}
}
输出:
日志:开始查询用户,id=1001
用户信息:id=1001
日志:查询用户结束,结果=用户信息:id=1001
静态代理不够灵活。假设 Subject 中增加了许多接口,那么 Proxy 也需要相应地进行增加,但是实际上我们对这些方法的增强功能都是一样的,这就是在做重复的工作。我们使用代理就是为了避免重复,但是静态代理实际上只是将代码换了个地方,并没有减少任何工作量。因此静态代理几乎用不上,它只是帮我们更好地理解代理思想。
4.1.2 动态代理
对于动态代理来说,创建代理对象的工作被推迟到程序运行时,由 JVM 根据我们的需要来自行实现。
我们知道,JVM 执行字节码前有一个关键步骤,就是 JVM 通过类加载,将 .class 文件加载到内存,生成 java.lang.Class 对象。通过 Class 对象可以获取类的全部信息,这是 Java 反射机制的基础。Java 的反射 API 本质就是对 Class 中信息的读取和操作,发生在运行时,因此与反射有关的操作我们往往加以动态二字。
动态代理也不例外,它的核心就是反射机制。动态代理在运行时生成的代理类,本质就是动态生成的字节码。
具体原理如下:
先给出业务接口和其具体实现类。
// -----> Subject <-----
public interface UserService {void queryUser(String id);void deleteUser(String id);
}// -----> RealSubject <-----
public class RealUserService implements UserService {@Overridepublic void queryUser(String id) {System.out.println("查询中, id = " + id);}@Overridepublic void deleteUser(String id) {System.out.println("删除中, id = " + id);}
}
在拦截器中定义增强逻辑,JDK 动态代理规定拦截器需实现 InvocationHandler 接口。
public class UserInvocationHandler implements InvocationHandler {private Object target;public UserInvocationHandler(Object target) {this.target = target;}@Overridepublic 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 必须使用它的类加载器,并且它将作为参数传入拦截器。
UserService realUserService = new RealUserService();
接下来,调用 Proxy.newProxyInstance() 方法来创建代理。
UserService proxy = (UserService) Proxy.newProxyInstance(// 类加载器realUserService.getClass().getClassLoader(),// 被代理类实现的接口new Class[]{UserService.class},// 指定拦截器new UserInvocationHandler(realUserService));
代码的核心是方法的第二个参数,目标方法所在类实现的接口。这个参数告知 Proxy,需要生成一个实现 UserService 接口的代理类。它将在运行时动态生成实现该接口全部方法的字节码。实现逻辑类似下面的代码。
// proxy 伪代码
public class proxy extends Proxy implements UserService {@Overridepublic void queryUser(String id) {// Proxy 首先通过反射将方法封装在 Method 中Method method = UserService.class.getMethod("queryUser", String.class);// 调用传入的拦截器,进行增强操作userInvocationHandler.invoke(this, method, new Object[]{id});}@Overridepublic 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"}。
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {}
拦截器中的 invoke 方法由自动生成的 proxy 调用,invoke 方法中依然存在一个 invoke 方法,这个 invoke 方法是连接代理与目标对象的关键,因为它是在通过反射调用 RealSubject 中的方法。
Object ret = method.invoke(target, args);
完整客户端代码如下。后续通过 proxy 调用方法,实际上调用的是 proxy 重写的方法。
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");}
}