细说SpringAOP的核心用法和原理解析

Java技术迷

共 16999字,需浏览 34分钟

 ·

2021-06-12 11:29


汪伟俊 作者

Java技术迷 | 出品

初识AOP

AOP:Aspect Oriented Programing,意为面向切面编程,对于习惯了传统OOP思想的同学来说,AOP在一开始确实难以接受,但它能够帮助我们在一些特定的业务上提供非常大的帮助。

来看一个例子:

public class Cacluate {

public int add(int num1,int num2){
return num1 + num2;
}

public int subtract(int num1,int num2){
return num1 - num2;
}

public int multiply(int num1,int num2){
return num1 * num2;
}

public int divide(int num1,int num2){
return num1 / num2;
}
}

这是一个做加减乘除运算的工具类,可以通过调用该类的方法计算两个数的运算值:

public static void main(String[] args) {
Cacluate cacluate = new Cacluate();
int addResult = cacluate.add(10,2);
int subtractResult = cacluate.subtract(10,2);
int multiplyResult = cacluate.multiply(10,2);
int divideResult = cacluate.divide(10,2);
System.out.println(addResult + "--" + subtractResult + "--" + multiplyResult + "--" + divideResult);
}

运行结果:

12--8--20--5

现在需求来了,若是想在每次做运算之前输出一个日志信息,记录当前系统的运行情况,我们应该如何实现呢?

public class Cacluate {

public int add(int num1,int num2){
System.out.println("输出日志信息,方法名:add,参数:" + num1 + ";" + num2);
return num1 + num2;
}

public int subtract(int num1,int num2){
System.out.println("输出日志信息,方法名:subtract,参数:" + num1 + ";" + num2);
return num1 - num2;
}

public int multiply(int num1,int num2){
System.out.println("输出日志信息,方法名:multiply,参数:" + num1 + ";" + num2);
return num1 * num2;
}

public int divide(int num1,int num2){
System.out.println("输出日志信息,方法名:divide,参数:" + num1 + ";" + num2);
return num1 / num2;
}
}

这样虽然实现了日志功能,但也能体会到这种方式的愚蠢,你需要在每个方法中添加相同重复的代码,当日志信息需要修改时,所有出现了日志的地方都需要修改,大大增加了维护成本。

为此,AOP思想诞生了,它通过横向抽取机制为这类无法通过纵向继承体系进行抽象的重复性代码提供了解决方案,AOP将日志操作横向抽取出来,再将日志操作融合到业务逻辑中实现功能。

AOP的相关概念

在具体讲解AOP之前,我们需要来了解一下AOP的有关概念。

JoinPoint——连接点

何为连接点?连接点表示的是程序执行的某个特定的位置,比如你要在项目中加入日志的操作,你可以将其设置在类加载前、类加载后、方法执行前、方法执行后,而Spring只支持方法的连接点,即你只能在方法执行前后来设置你需要进行的操作。

PointCut——切点

切点又是什么呢?我们知道,对于方法执行前后的位置我们称之为连接点,但是,我们需要将日志操作设置在哪些类的哪些方法执行前后再执行呢?这一定位称为切点,通常需要通过切点表达式进行过滤。

Advice——通知

通知,也叫增强,当我们通过切点表达式指定了需要切入的位置后,Spring就会在每个切点的位置增强该方法,例如添加上你的日志操作。

Aspect——切面

切面包括横切逻辑的定义,也包括连接点的定义,Spring AOP将切面所定义的横切逻辑切入到切面所指定的连接点中,即它的作用就是将切点和通知结合定位到连接点上。

动态代理

需要了解的是,Spring AOP使用动态代理在运行期切入增强的代码,所以我们需要掌握动态代理的相关知识。

动态代理是反射的高级应用,JDK为我们提供了Proxy和InvocationHandler接口,通过该类和该接口生成代理对象实现方法的增强。

看一个例子:

public interface ICar {
void use();
}

public class Car implements ICar {
@Override
public void use() {
System.out.println("汽车使用汽油");
}
}

定义一个接口和接口的实现类,表示使用汽车需要汽油,但是汽车并不一定只能使用汽油,还能使用电力驱动,为此,可以使用动态代理增强该use()方法:

public class CarInvocatioHandler<T> implements InvocationHandler {

private T t;

public CarInvocatioHandler(T t){
this.t = t;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//若执行的是use()方法,则对该方法进行增强
if(method.getName().equals("use")){
//调用原方法,保持原有的功能
method.invoke(t,args);
//增强方法
System.out.println("汽车使用电力");
}
return null;
}
}

首先需要实现InvocationHandler接口的invoke方法,并调用invoke()方法,保证原有的功能不被破坏,然后再编写需要增强的逻辑,返回值是目标代理方法的返回值,没有就返回null即可,有了该接口的实现类后,接下来:

public static void main(String[] args) {
ICar car = new Car();
CarInvocatioHandler invocatioHandler = new CarInvocatioHandler(car);
ICar proxy = (ICar) Proxy.newProxyInstance(car.getClass().getClassLoader(), car.getClass().getInterfaces(), invocatioHandler);
proxy.use();
}

通过Proxy类创建代理对象,并按照CarInvocatioHandler实现类的规则进行增强,运行结果:

汽车使用汽油
汽车使用电力

我们将这一过程类比到刚才的场景中去,要对四种计算方法设置日志操作,只需要分别对这四个方法进行动态代理,增强它们即可,然而动态代理有一个缺陷,就是只能针对接口做代理,所以我们需要对计算方法做一个处理:

public interface CacluateDao {

int add(int num1,int num2);
int subtract(int num1,int num2);
int multiply(int num1,int num2);
int divide(int num1,int num2);
}

public class Cacluate implements CacluateDao{

@Override
public int add(int num1, int num2) {
return num1 + num2;
}

@Override
public int subtract(int num1, int num2) {
return num1 - num2;
}

@Override
public int multiply(int num1, int num2) {
return num1 * num2;
}

@Override
public int divide(int num1, int num2) {
return num1 / num2;
}
}

这样我们就可以增强这些方法了:

public class CacluateInvocationHandler<T> implements InvocationHandler {

private T t;

public CacluateInvocationHandler(T t){
this.t = t;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
int result = 0;
if(method.getName().equals("add")){
//增强方法
System.out.println("输出日志信息,方法名:" + method.getName() + ",参数:" + Arrays.asList(args));
//保持原有功能
method.invoke(t,args);
result = Integer.valueOf(args[0] + "") + Integer.valueOf(args[1] + "");
}
return result;
}
}

然后编写动态代理:

public static void main(String[] args) {
CacluateDao cacluate = new Cacluate();
CacluateInvocationHandler invocationHandler = new CacluateInvocationHandler(cacluate);
CacluateDao proxy = (CacluateDao) Proxy.newProxyInstance(cacluate.getClass().getClassLoader(), cacluate.getClass().getInterfaces(), invocationHandler);
int result = proxy.add(3, 4);
System.out.println(result);
}

运行结果:

输出日志信息,方法名:add,参数:[3, 4]
7

其它方法的增强方式也是类似的,这里就不重复举例了。

刚才也说了,Java的动态代理只支持接口代理,所以是有一定的缺陷的,对此,Spring并不仅仅采用动态代理来实现方法的增强,还使用了CGLib技术,它可以为一个类创建子类,然后通过子类中的拦截方法拦截所有父类的方法并进行增强。

通知介绍

Advice,确切地说它应该被理解为增强,前面也一直在强调方法的增强,那么接下来我们来看看在Spring AOP中是如何去实现方法的增强的。

前置通知

public class Cacluate{

public int add(int num1, int num2) {
return num1 + num2;
}

public int subtract(int num1, int num2) {
return num1 - num2;
}

public int multiply(int num1, int num2) {
return num1 * num2;
}

public int divide(int num1, int num2) {
return num1 / num2;
}
}

首先我们从实现接口的束缚中脱离出来,然后使用Spring AOP进行增强,比如需要在计算方法之前输出日志信息,你就可以这样做:

@Component
@Aspect
public class LoggingAspect {

@Before("execution(* com.wwj.aop.util.*.*(..))")
public void beforeMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法前的日志信息,方法参数为:" + list);
}
}

定义一个日志切面,并编写一个方法,在该方法上添加@Before注解,然后编写切面表达式,即指定该方法切入到哪个类的哪些方法,这里的execution(* com.wwj.aop.util.*.*(..))则表示将该方法切入到com.wwj.aop.util包下的所有类的所有方法,此时执行计算方法:

public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("spring-config.xml");
Cacluate cacluate = (Cacluate) context.getBean("cacluate");
int addResult = cacluate.add(3, 5);
System.out.println(addResult);
}

运行结果:

执行在add方法前的日志信息,方法参数为:[3, 5]
8

后置通知

学会了前置通知,那么后面的内容就会非常简单了,比如后置通知,只是简单地修改一下注解名就可以了:

@After("execution(* com.wwj.aop.util.*.*(..))")
public void afterMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list);
}

执行测试代码:

执行在divide方法前的日志信息,方法参数为:[10, 2]
执行在divide方法后的日志信息,方法参数为:[10, 2]
5

需要注意的是后置通知不管程序是否发生错误都会被执行,比如:

public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("spring-config.xml");
Cacluate cacluate = (Cacluate) context.getBean("cacluate");
int addResult = cacluate.divide(10,0);
System.out.println(addResult);
}

在数学中,0作为除数是不被允许的,程序在进行计算时肯定会产生异常,然而此时后置通知仍然会被执行:

执行在divide方法前的日志信息,方法参数为:[10, 0]
执行在divide方法后的日志信息,方法参数为:[10, 0]
Exception in thread "main" java.lang.ArithmeticException: / by zero

返回通知

返回通知与后置通知类似,区别在于,返回通知需要在程序正确执行后才会执行,若程序发生异常,则返回通知不会执行:

@AfterReturning(value = "execution(* com.wwj.aop.util.*.*(..))",returning = "result")
public void afterReturningMethod(JoinPoint joinPoint,Object result){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list + ",运行结果为:" + result);
}

运行结果:

执行在divide方法前的日志信息,方法参数为:[10, 5]
执行在divide方法后的日志信息,方法参数为:[10, 5]
执行在divide方法后的日志信息,方法参数为:[10, 5],运行结果为:2
2

返回通知是能够获取到方法的执行结果的,具体做法是在@AfterReturning中指定returning属性值,然后在方法的入参中定义一个与其相同的变量即可。

异常通知

异常通知,顾名思义,只有当程序发生异常时才会执行,异常通知能够获取到方法发生了什么异常:

@AfterThrowing(value = "execution(* com.wwj.aop.util.*.*(..))",throwing = "except")
public void afterThrowingMethod(JoinPoint joinPoint,Exception except){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list + ",异常信息为:" + except);
}

获取异常信息的方式与返回通知获取结果值的方式是一样的,看运行结果:

执行在divide方法前的日志信息,方法参数为:[10, 0]
执行在divide方法后的日志信息,方法参数为:[10, 0]
执行在divide方法后的日志信息,方法参数为:[10, 0],异常信息为:java.lang.ArithmeticException: / by zero

环绕通知

环绕通知的功能比较强大,它能够通过一个方法实现之前的所有通知效果,直接看代码:

@Around("execution(* com.wwj.aop.util.*.*(..))")
public Object aroundMethod(ProceedingJoinPoint joinPoint){
Object result = null;
String methodName = joinPoint.getSignature().getName();
List<Object> args = Arrays.asList(joinPoint.getArgs());
try{
//前置通知
System.out.println("执行在" + methodName + "方法前的日志信息,方法参数为:" + args);
//执行目标方法,保持原有功能
result = joinPoint.proceed();
//返回通知
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + args + ",运行结果为:" + result);
}catch (Throwable e){
//异常通知
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + args + ",异常信息为:" + e);
}
//后置通知
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + args);
return result;
}

运行结果:

执行在divide方法前的日志信息,方法参数为:[10, 2]
执行在divide方法后的日志信息,方法参数为:[10, 2],运行结果为:5
执行在divide方法后的日志信息,方法参数为:[10, 2]
5

从环绕通知应该不难理解,为什么后置通知无论什么情况都会执行,且只有返回通知能够获取到方法执行结果,异常通知如何能够获取到异常信息,一目了然。

需要注意,环绕通知必须携带ProceedingJoinPoint参数并且必须有返回值。

xml配置实现AOP

前面介绍了五种通知的用途和注意事项,顺带着还贴出了注解实现这五种通知的方法,在Spring中,通知还能通过xml文件进行配置实现,下面来看一看。

先来看看前置通知的配置,在这之前,先将注解全部去除:

@Component
public class LoggingAspect {

public void beforeMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法前的日志信息,方法参数为:" + list);
}

public void afterMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list);
}

public void afterReturningMethod(JoinPoint joinPoint,Object result){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list + ",运行结果为:" + result);
}

public void afterThrowingMethod(JoinPoint joinPoint,Exception except){
String methodName = joinPoint.getSignature().getName();
List<Object> list = Arrays.asList(joinPoint.getArgs());
System.out.println("执行在" + methodName + "方法后的日志信息,方法参数为:" + list + ",异常信息为:" + except);
}
}

然后进行配置:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

<context:component-scan base-package="com.wwj"/>

<aop:config proxy-target-class="true">
<!-- 声明切点表达式 -->
<aop:pointcut id="pointCut" expression="execution(* com.wwj.aop.util.*.*(..))"/>
<!-- 配置切面,指定loggingAspect为需要切入的逻辑 -->
<aop:aspect ref="loggingAspect">
<!-- 前置通知,指定beforeMethod为前置通知,并指定将其切入到pointCut中 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>
</beans>

这样前置通知就完成了,运行结果:

执行在divide方法前的日志信息,方法参数为:[10, 2]
5

接下来配置后置通知:

<aop:config proxy-target-class="true">
<!-- 声明切点表达式 -->
<aop:pointcut id="pointCut" expression="execution(* com.wwj.aop.util.*.*(..))"/>
<!-- 配置切面,指定loggingAspect为需要切入的逻辑 -->
<aop:aspect ref="loggingAspect">
<!-- 前置通知,指定beforeMethod为前置通知,并指定将其切入到pointCut中 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
<!-- 后置通知 -->
<aop:after method="afterMethod" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>

运行结果:

执行在add方法前的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2]
12

然后是返回通知、异常通知,由于配置方式与其相同,这里就直接一起配置了:

<aop:config proxy-target-class="true">
<!-- 声明切点表达式 -->
<aop:pointcut id="pointCut" expression="execution(* com.wwj.aop.util.*.*(..))"/>
<!-- 配置切面,指定loggingAspect为需要切入的逻辑 -->
<aop:aspect ref="loggingAspect">
<!-- 前置通知,指定beforeMethod为前置通知,并指定将其切入到pointCut中 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
<!-- 后置通知 -->
<aop:after method="afterMethod" pointcut-ref="pointCut"/>
<!-- 返回通知 -->
<aop:after-returning method="afterReturningMethod" pointcut-ref="pointCut" returning="result"/>
<!-- 异常通知 -->
<aop:after-throwing method="afterThrowingMethod" pointcut-ref="pointCut" throwing="except"/>
</aop:aspect>
</aop:config>

其实用法和注解配置是完全一样的。

运行结果:

执行在add方法前的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2],运行结果为:12
12

切面优先级

这里再强调一个切面优先级的问题,比如现在又需要在项目中添加一个输出当前日期的操作,我们就需要再编写一个切面类:

@Component
public class TimeAspect {

public void beforeMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
System.out.println(methodName + "当前执行时间为:" + LocalDateTime.now());
}
}

接下来配置一下:

<aop:config proxy-target-class="true">
<aop:pointcut id="pointCut" expression="execution(* com.wwj.aop.util.*.*(..))"/>
<aop:aspect ref="loggingAspect">
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
<aop:after method="afterMethod" pointcut-ref="pointCut"/>
<aop:after-returning method="afterReturningMethod" pointcut-ref="pointCut" returning="result"/>
<aop:after-throwing method="afterThrowingMethod" pointcut-ref="pointCut" throwing="except"/>
</aop:aspect>
<!-- 配置时间切面 -->
<aop:aspect ref="timeAspect">
<!-- 前置通知 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>

你也可以使用注解进行配置,这里就以xml配置方式举例了,执行以下测试方法:

执行在add方法前的日志信息,方法参数为:[10, 2]
add当前执行时间为:2020-10-03T17:54:08.297
执行在add方法后的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2],运行结果为:12
12

需求来了,我若是想让时间操作总是在日志操作执行被执行,该怎么办呢?

为此,Spring为我们提供了切面的优先级,能够非常灵活地解决切面的执行顺序问题,比如这里的需求,就可以这样配置:

<aop:config proxy-target-class="true">
<!-- 声明切点表达式 -->
<aop:pointcut id="pointCut" expression="execution(* com.wwj.aop.util.*.*(..))"/>
<!-- 配置日志切面,指定loggingAspect为需要切入的逻辑 -->
<aop:aspect ref="loggingAspect" order="2">
<!-- 前置通知,指定beforeMethod为前置通知,并指定将其切入到pointCut中 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
<!-- 后置通知 -->
<aop:after method="afterMethod" pointcut-ref="pointCut"/>
<!-- 返回通知 -->
<aop:after-returning method="afterReturningMethod" pointcut-ref="pointCut" returning="result"/>
<!-- 异常通知 -->
<aop:after-throwing method="afterThrowingMethod" pointcut-ref="pointCut" throwing="except"/>
</aop:aspect>
<!-- 配置时间切面 -->
<aop:aspect ref="timeAspect" order="1">
<!-- 前置通知 -->
<aop:before method="beforeMethod" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>

这里将日志切面的order设置为2,将时间切面的order设置为1,因为order总是值越小,优先级越高,所以时间切面的优先级就会高于日志切面,看运行结果:

add当前执行时间为:2020-10-03T17:57:36.779
执行在add方法前的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2]
执行在add方法后的日志信息,方法参数为:[10, 2],运行结果为:12
12

事实证明确实如此。

而对于注解实现,order是这样配置的:

@Component
@Aspect
@Order(1)
public class TimeAspect {

@Before("execution(* com.wwj.aop.util.*.*(..))")
public void beforeMethod(JoinPoint joinPoint){
String methodName = joinPoint.getSignature().getName();
System.out.println(methodName + "当前执行时间为:" + LocalDateTime.now());
}
}

直接通过@Order注解并设置value属性值即可。

本文作者:汪伟俊 为Java技术迷专栏作者 投稿,未经允许请勿转载。


1、Intellij IDEA这样 配置注释模板,让你瞬间高出一个逼格!
2、基于SpringBoot的迷你商城系统,附源码!
3、最牛逼的 Java 日志框架,性能无敌,横扫所有对手!
4、把Redis当作队列来用,真的合适吗?
5、惊呆了,Spring Boot居然这么耗内存!你知道吗?
6、全网最全 Java 日志框架适配方案!还有谁不会?
7、Spring中毒太深,离开Spring我居然连最基本的接口都不会写了

点分享

点收藏

点点赞

点在看

浏览 33
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报