本文已参与「新人创作礼」活动,一起开启掘金创作之路。
AOP 编程
AOP 即面向切面编程, 通过预编译方式和运行期动态代理, 实现程序功能统一维护的一种技术, 是 OOP 的补充和完善. AOP 本质上是针对方法调用的编程思路.
AOP 编程使得业务模块更简洁, 只包含核心业务代码, 减少了代码冗余.
日常开发中,AOP 常见的应用场景有:权限控制,缓存控制 ( 如 Spring 提供的缓存注解 ),事务控制 (如 Spring 提供的事务注解 ),记录日志,性能监控,分布式追踪,异常处理 ( 如 SringMVC 提供的统一异常处理注解 ) 等。
代理模式
代理模式较长,不是本文重点,请自行了解。
AOP 设计原理
Java 代码如何在 JVM 中运行
当执行一段 Java 代码的时候, JVM 会创建一个线程来执行这段代码. 每一个线程在 JVM 中都会维护一个属于自己的线程栈, 线程栈记录着整个代码执行的过程.
线程栈里的每一个元素称为栈帧, 栈帧表示着某个方法的调用, 记录方法调用的信息;在代码中调用一个方法的时候, 在对应的线程栈中就对应着一个栈帧的入栈和出栈.
从线程栈的角度来看, JVM 执行 Java 代码的基本单位是方法调用. 按照方法执行的顺序, 线程栈将方法调用的时间先后顺序连成一串, 就构成了 Java 程序的执行流.
基于时间序列, 可以将方法调用顺序排成一条线. 而每个方法调用则可以看成 Java 执行流中的一个节点. 这个节点在 AOP 中被称为 Join Point, 即连接点. 一个 Java 程序的运行的过程, 就是若干个连接点连接起来依次执行的过程.
AOP 则是从另外一个角度来考虑整个程序的, AOP 将每一个方法调用, 即连接点作为编程的入口, 针对方法调用进行编程. 从执行的逻辑上来看, 相当于在之前纵向的按照时间轴执行的程序中横向切入一部分逻辑代码. 相当于将之前的程序横向切割成若干的面, 即 Aspect. 每个面则被称为切面. 所以, AOP 本质上是针对方法调用的编程思路.
整个思路如下图所示:
总结:AOP 就是让你在执行某个方法之前或者之后,可以插入其他运行逻辑、
切面如何选择
既然 AOP 是针对切面进行的编程的, 那么, 选择哪些切面作为你的编程对象呢?
一个切面本质上就是一个方法调用, 选择切面的过程实际上就是选择方法的过程. 被选择的切面 Aspect 在 AOP 术语里被称为切入点 Point Cut.
切入点实际上也是从所有的连接点 Join point 中选择合适的连接点的过程. 在 Spring AOP 框架中通过方法匹配表达式来表示切入点.
动态代理后的代码执行流
假设在我们的 Java 代码里, 都为实例对象通过代理模式创建了代理对象, 访问这些实例对象必须要先通过代理对象, 那加入了 proxy 对象的 Java 程序执行流会变得稍微复杂起来.
想调用某一个实例对象的方法时, 都会经过这个实例对象相对应的代理对象, 即执行的控制权先交给代理对象. 如下图:
加入了代理模式的 Java 程序执行流, 使得所有的方法调用都经过了代理对象. Spring AOP 框架负责控制着整个容器内部的代理对象. 当我们调用了某一个实例对象的任何一个非 final 的 public 方法时, 整个 Spring 框架都会知晓. 那么, Spring 就可以在这个代理的过程中插入 Spring 的自己的业务代码.
Spring 框架提供的 AOP
AspectJ 是 Java 中最流行的 AOP 框架. Spring AOP 提供了对 AspectJ 的支持. 在 Spring 中, 可以使用基于 AspectJ 注解或基于 XML 配置的 AOP.
AOP 的 连接点 Joinpoint 可以有许多种类型, 但是在 Spring AOP 中, 仅支持方法执行类型的 Joinpoint. 虽然功能相对简单, 但是使用频率高, 代码实现简单.
Spring 中 AOP 代理由 Spring 的 IOC 容器负责生成并管理, 其依赖关系也由 IOC 容器负责管理. 因此, AOP 代理可以直接使用容器中的其它 bean 实例作为目标.
Spring 创建代理的规则
默认使用JDK 动态代理来创建代理对象. 但是当需要代理的类不是代理接口的时候, Spring 会切换为使用 CGLIB 代理, 当然也可以强制使用 CGLIB.
限制规则:
- 不要把重要的业务逻辑放在 AOP 中.
- AOP 对 static , final , private 方法失效.
只对 public 方法有效. 对内部方法调用失效.
Spring AOP 的使用方式
编写切面类
切面类必须是 IOC 中的 bean 实例, 所以需要在切面类上添加 @Component 注解. 同时为了表示它是一个切面,还需要添加 @Aspect 注解。当在 IOC 容器中初始化 AspectJ 切面之后, IOC 容器就会为那些与 AspectJ 切面相匹配的 Bean 创建代理.
切面类中一般包含两部分内容:切入点表达式 和 通知 代码如下:
@Aspect // 声明为切面类
@Component // 切面类必须为IOC 容器的一个 bean 实例
public class LoggingAspect {
// 切入点表达式
// 通知
}
编写切入点表达式
什么是切入点表达式?简单来说就是当前切面的代码逻辑对哪些方法才会生效的一个规则。用来指定哪些类的哪些方法在执行时, 可以成功匹配并执行额外逻辑.
切入点表达式通常根据方法的参数签名或者所在的包,所在的类,或者方法上拥有的注解等规则来配置。
(1) 常见的切入点表达式:
① 方法匹配
//表达式语法:
execution(方法的修饰符 方法的返回值类型 包名.类名.方法名(参数列表))
//只会匹配 it.dao.impl 包下的 Calculator 类的 public int add() 方法. 使用 .. 匹配任意数量的参数.
execution(public int it.dao.impl.CalculatorImpl.add(..))
//匹配 it.dao.impl 包下的 Calculator 类的所有的 public int 类型的方法. 任意方法名
execution(public int it.dao.impl.CalculatorImpl.*(..))
//匹配 it.dao.impl 包下的 Calculator 类的所有的 public int 类型的, 并且两个参数都是 int 类型的方法. 指定参数.
execution(public int it.dao.impl.CalculatorImpl.*(int,int))
//匹配 it.dao.impl 包下的 Calculator 类的所有的 public int 类型的, 并且第一个参数是 int 类型的方法, 参数数量不限.
execution(public int it.dao.impl.CalculatorImpl.*(int,*))
//匹配 it.dao.impl 包下的 Calculator 类的所有方法. 使用 * 匹配任意修饰符和任意返回值类型.
execution(* it.dao.impl.CalculatorImpl.*(..))
//匹配 it.dao.impl 包下的 Calculator 类的所有 public 类型的方法.
execution(public * it.dao.impl.CalculatorImpl.*(..))
② 匹配包/类型
//匹配 ProductService 类里的所有方法
@Ponitcut("within(it.service.ProductService)")
//匹配 it.service包以及子包下的所有类的方法
@Ponitcut("within(it.service...*)")
③ 匹配对象
//匹配 AOP 对象的目标对象为指定类型的方法
@Ponintcut("this(it.dao.UserDaoImpl)")
//匹配实现该接口的目标对象
@Ponintcut("target(it.dao.UserDao)")
//匹配所有以 Dao 结尾的 bean 里的所有方法
@Ponintcut("bean(*Dao)")
④ 参数匹配
//匹配任何只有一个 Long 参数的方法
@Pointcut("args(Long)")
//匹配任何第一个参数为 Lon g的方法
@Pointcut("args(Long,..)")
⑤ 返回值类型匹配
// 按照方法返回值切入的话, 需要注意, 返回值类型要写全类名.
//重用切入点表达式
@Pointcut(value = "execution(public com.cebbank.common.Result com.cebbank.controller.*.*(..))")
public void pointCut() {
}
⑥ 注解匹配
//匹配方法上标有 @AdminOnly 注解的方法 (这是自定义注解)
@Pointcut("@annotation(it.anno.AdminOnly)")
//匹配方法参数上标有 @Respository 注解的方法
@Pointcut("@agrs(org.springframework.strreotype.Repository)")
//匹配类上(不能是接口)标有 @AdminOnly 注解的方法 (这是自定义注解) , 要求注解为 CLASS 级别
@Pointcut("@within(it.anno.AdminOnly)")
//匹配类上(不能是接口)标有 @AdminOnly 注解的方法 (这是自定义注解) , 要求注解为 RUNTIME 级别
@Pointcut("@target(it.anno.AdminOnly)")
(2) 切入点表达式支持使用 操作符 &&, ||, ! 等进行结合。
//方法上有 @Select 注解,并且类名以 Mapper 结尾的作为切入点
@Pointcut("@annotation(org.apache.ibatis.annotations.Select) && bean(*Mapper)")
public void selectPointCut(){ }
(3) 重用切入点表达式
同一个切入点表达式可能会在多个通知中使用. 在 AspectJ 切面中, 可以在一个空方法上添加 @Pointcut 注解, 将一个切入点声明成简单的切入点方法. 在通知上通过切入点方法的方法名来引入该切入点. 实现切入点表达式的重用.
代码如下:
// 重用切入点表达式
@Pointcut("execution(* it.dao.impl.CalculatorImpl.div(..))")
public void declarePointCut(){ }
// 对应的通知
@Before("declarePointCut()")
@After("declarePointCut()")
@AfterReturning(value="declarePointCut()",returning="result")
@AfterThrowing(value="declarePointCut()",throwing="ex")
@Around("declarePointCut()")
如果切入点要在多个切面中共用, 最好将它们集中在一个公共的类中. 这时, 使用切入点需要 完整类名.切入点方法名 来使用。
@Before("it.aspect.LoggingAspect.declarePointCut()")
编写通知
什么是通知?简答来说就是需要额外执行的代码。
AspectJ 支持 5 种类型的通知注解 :
(1) @Before 前置通知
@Before 前置通知, 表示在目标方法执行之前先执行的代码.
前置通知不会影响连接点的执行, 除非是在前置通知中抛出了异常.
(2) @After 后置通知
@After 后置通知, 表示在目标方法执行之后执行 ( 目标方法刚被调用完毕就执行的代码 ) 的代码.
不管是目标方式是正常执行完毕, 还是抛出了异常, 后置通知都会执行. 但是后置通知不能得到目标方法的返回值, 这需要在返回通知中才能得到.
(3) @AfterRunning 正常返回通知
@AfterRunning 正常返回通知, 表示在目标方法返回结果之后执行 (也就是执行结束之后) 的通知. 正常返回通知是要先于后置通知的。
返回通知只有在方法正常执行不抛出异常时才执行. 在返回通知中可以获取连接点的返回值. 但是不可以更改返回值.
只要将returning属性添加到 @AfterReturning 注解中, 就可以访问连接点的返回值. 该属性的值 result 即为用来传入返回值的参数名称.
必须在通知方法的签名中添加一个同名参数 result. 在运行时, Spring AOP 会通过这个参数传递返回值.
(4) @AfterThrowing 异常通知
@AfterThrowing 异常通知, 在目标方法抛出异常之后执行的通知. 只在连接点抛出异常时才执行异常通知. 其他情况都不会执行.
将throwing属性添加到 @AfterThrowing 注解中, 便可以访问连接点抛出的异常.
Throwable 是所有错误和异常类的超类. 所以在异常通知方法可以捕获到任何错误和异常. 如果只对某种特殊的异常类型感兴趣, 可以将方法中的参数声明为该种异常类型. 然后通知就只在抛出这个类型及其子类的异常时才会被执行.
(5) @Around 环绕通知
@Around 环绕通知, 其实是上面这几种通知的一个合并. 功能最强.
环绕通知是所有通知类型中功能最为强大的, 能够全面地控制连接点. 甚至可以控制是否执行连接点.环绕通知类似于动态代理的全过程, 可以使用环绕通知实现前置通知, 后置通知, 返回通知, 异常通知的功能, 十分强大, 但并不常用.
对于环绕通知来说, 连接点的参数类型必须是ProceedingJoinPoint. 它是 JoinPoint 的子接口, 允许控制何时执行, 是否执行连接点.
在环绕通知中需要明确调用 ProceedingJoinPoint 的proceed()方法来执行被代理的方法. 如果忘记这样做就会导致通知被执行了, 但目标方法没有被执行.
注意 : 环绕通知的方法必须返回目标方法执行之后的结果, 即调用 joinPoint.proceed() 的返回值, 否则会出现空指针异常.
@Aspect //声明为切面类
@Component //切面类必须为IOC容器的一个bean实例
public class LoggingAspect {
//添加div()方法的环绕通知, 并修改调用div()方法的参数为12, 3:
@Around("execution(* it.dao.impl.CalculatorImpl.div(..))")
public Object aroundMethod(ProceedingJoinPoint pjd){
Object result = null;
String methodName = pjd.getSignature().getName();
try {
//前置通知
System.out.println("环绕通知:这是" + methodName+"()方法的前置通知,参数列表为:" + Arrays.asList(pjd.getArgs()));
//执行目标方法
result = pjd.proceed();
//返回通知
System.out.println("环绕通知:这是" + methodName + "()方法的返回通知,返回值为:" + result);
} catch (Throwable e) {
// 异常通知
System.out.println("环绕通知:这是" + methodName + "()方法的异常通知,发生了 " + e.getMessage() +" 异常");
}
//后置通知
System.out.println("环绕通知:这是" + methodName + "()方法的后置通知");
return result;
}
}
// 输出:
环绕通知:这是div()方法的前置通知,参数列表为:[12, 3]
这是div()方法的前置通知...参数列表: [12, 3]
环绕通知:这是div()方法的返回通知,返回值为:4
环绕通知:这是div()方法的后置通知
这是div()方法的后置通知...
这是div()方法的返回通知...该方法的返回值为:4
div result=4
五种通知的执行顺序
单个切面的通知执行顺序如下所示:
环绕通知是优先于普通通知执行的:
- 正常情况 : 环绕前置 -> 前置 -> 目标方法 -> 环绕返回 -> 环绕后置 -> 后置 -> 返回
- 抛出异常 : 环绕前置. -> 前置 -> 目标方法 -> 环绕异常 -> 环绕后置 -> 后置 ->异常
切面执行优先级
同一个方法上可能存在多个切面,那么这些切面的执行顺序是怎样的呢?
当在同一个切入点上有多个切面时, 除非明确指定了先后顺序, 否则它们的优先级是不确定的.
切面的优先级可以通过切面类实现 Ordered 接口或切面类上使用 @Order 注解来指定.
- 若实现 Ordered 接口, getOrder() 方法的
返回值越小, 优先级越高. - 若使用 @Order 注解, 序号出现在注解中.
序号越小, 优先级越高.
@Order(1) //指定切面类的优先级
@Aspect //声明为切面类
@Component //切面类必须为IOC容器的一个bean实例
public class LoggingAspect {
...
}
那么问题来了,当存在多个切面时,各个切面的通知执行顺序又是怎么样的呢?
当多切面执行时,采用了责任链模式. 切面的配置顺序决定了切面的执行顺序,多个切面执行的过程,类似于方法调用的过程,在环绕通知的 proceed() 执行时,去执行下一个切面或如果没有下一个切面执行目标方法.
对同一个类型的通知, order 越小越先执行, 执行结束后, order 越小就越后退出 :
多个切面的通知执行顺序如下:
切入点 JoinPoint 详解
在任意一个通知方法中添加一个 JoinPoint类型的参数, 就可以获取到方法的签名和方法的参数信息.
注意: 这个 JoinPoint 是位于 org.aspectj.lang.JoinPoint; 包内, 别导错了, 否则报错: 0 formal unbound in pointcut
JoinPonit 接口的常用方法 :
● Object[] getArgs() 获取连接点方法运行时的参数列表.
● Object getTarget() 获取被代理的对象.
● Object getThis() 获取代理对象本身.
● Signature getSignature() 获取封装了署名信息的对象, 在该对象中可以获取到目标方法名, 所属类的 Class 等信息.
● String getSignature().getDeclaringTypeName() 获取连接点的方法所在类的全类名.
● String getSignature().getName() 获取连接点的方法名
举个例子,如下:
//前置通知
@Before(value = "controllerPointCut()")
public void beforeMethod(JoinPoint joinPoint){
log.info("调用了ControllerAop的前置通知");
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
log.info("请求的URL:{}", request.getRequestURL().toString());
log.info("请求的HTTP_METHOD:{}",request.getMethod());
log.info("请求的IP:{}", request.getRemoteAddr());
String typeName = joinPoint.getSignature().getDeclaringTypeName();
log.info("当前调用的类为:{}",typeName);
String methodName = joinPoint.getSignature().getName();
log.info("当前调用的方法为:{}",methodName);
Object[] objects = joinPoint.getArgs();
log.info("方法的请求参数为:{}", Arrays.asList(objects));
}
获取切入点方法上的指定注解 :
@Before(value = "selectPointCut()")
public void selectBefore(JoinPoint joinPoint){
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); // MethodSignature 是 Signature 的子类
//获取到方法上的注解
Select annotation = methodSignature.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
log.info("执行的SQL语句:{}",annotation.value());
}
在环绕通知中,切入点一般使用 ProceedingJoinPoint,它是 JoinPoint 的子接口, 该对象只用在 @Around 的切面方法中. 相比 JoinPoint 新增了如下两个方法.
● Object proceed() 执行目标方法
● Object proceed(Object[] var1) 传入的新的参数去执行目标方法