Spring笔记(3)-代理模式与面向切面编程AOP

代理模式

场景分析

在介绍AOP之前,我们需要先了解一下代理模式。考虑以下场景,我们现在需要实现一个计算器的功能,那么我们会先生命一个Calculator接口,在其中声明需要实现的方法,包括加减乘除:

1
2
3
4
5
6
7
8
9
public interface Calculator {
int add(int i, int j);

int sub(int i, int j);

int mul(int i, int j);

int div(int i, int j);
}

之后,我们为Calculator接口创建一个实现类CalculatorImp,在其中完成对应的方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CalculatorImp implements Calculator {
@Override
public int add(int i, int j) {
return i + j;
}

@Override
public int sub(int i, int j) {
return i - j;
}

@Override
public int mul(int i, int j) {
return i * j;
}

@Override
public int div(int i, int j) {
return i / j;
}
}

至此,我们完成了一个简单的计算功能。但是这时候我们想要在每个方法执行的前后都增加日志输出功能,一种实现方法就是在每个方法的最开始和最后增加输出代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
public class CalculatorImp implements Calculator {
@Override
public int add(int i, int j) {
System.out.println("执行开始");
int result = i + j;
System.out.println("执行结束");
return result;
}

// 其他方法类似省略
// ...
}

这种方式的确能够完成需求,但是较为繁琐。在这个需求中,我们的核心代码是加减乘除的实现,日志功能算非核心代码。这种实现方式一些弊端:

  1. 首先需要重复书写很多非核心业务
  2. 其次这些代码对核心业务功能干扰,导致我们在开发核心业务的时候容易被分散精力
  3. 同时这些功能虽然功能相同,但是分散在各个业务方法中,不利于统一维护

我们可以通过解耦的方式来将附加的非业务功能从业务功能中抽取出来,将非业务功能和业务功能分开管理。分析场景我们发现,这里的非业务代码包裹了业务代码,无法通过传统的继承方式进行抽取。我们可以通过代理模式来解决这个问题。

简单来说,代理模式的作用就是通过提供一个代理类来对我们目标类进行包装。我们在调用目标方法的时候,不需要直接对目标方法进行调用,而是通过代理类间接调用。这样,我们可以将非业务代码抽离到代理类中,而让目标类专注于核心代码的实现。这种方式可以减少对目标方法的打扰,同时使得附加功能能够在一起,便于统一维护。

静态代理

在静态代理中,一个代理类对应一个目标类。我们可以创建一个代理类,将目标类中的方法进行包装。由于代理类需要提供目标类中的相关方法,所以两个类需要实现相同的接口。

对于上面的场景,我们可以创建一个静态代理类,实现Calculator接口并对目标类的方法进行包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CalculatorStaticProxy implements Calculator {
private Calculator target; // 一个代理类对应一个目标类

public CalculatorStaticProxy(Calculator target) {
this.target = target;
}

private void beforeInvoke() {
System.out.println("计算开始");
}

private void afterInvoke() {
System.out.println("计算结束");
}

@Override
public int add(int i, int j) {
beforeInvoke();
int result = target.add(i, j);
afterInvoke();
return result;
}

// 其他方法类似省略
// ...
}

这样我们只需要创建一个代理类,然后通过代理类执行相关的方法即可。虽然静态代理实现了一定程度的解耦,但是灵活性不高。于是有了后续的动态代理技术。

动态代理

动态代理并不像静态代理一样将代码全部写死,而是在代码运行的过程中,让JDK动态创建每个类对应的代理类。动态创建则会涉及到反射的使用,并且在JDK中,给我们提供了代理相关的API供我们使用。

我们实现一个代理工厂类,我们给它提供目标类,然后由它产生一个代理类,该代理对应的目标类即为我们给定的目标类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ProxyFactory {
private Object target; // 目标类

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

public Object getProxy() {
ClassLoader classLoader = target.getClass().getClassLoader(); // 代理类需要使用的类加载器
Class<?>[] interfaces = target.getClass().getInterfaces(); // 代理类需要实现的接口
// 代理类需要如何实现目标方法
InvocationHandler invocationHandler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result = null;
try {
System.out.println("[日志]: 计算开始,方法名称为 " + method.getName() + "参数为 " + Arrays.toString(args));
result = method.invoke(target, args); // 调用目标类的方法
System.out.println("[日志]: 计算结束,方法名称为 " + method.getName());
} catch (Exception e) {
e.printStackTrace();
System.out.println("[日志]: 计算异常,方法名称为 " + method.getName());
} finally {
System.out.println("[日志]: 方法执行完毕,方法名称为 " + method.getName());
}

return result;
}
};

return Proxy.newProxyInstance(classLoader, interfaces, invocationHandler);
}
}

其中的重要方法是getProxy(),在其中我们需要返回目标类的代理类对象,使用了反射包下的newProxyInstance()方法,用于创建一个代理实例。其中需要提供三个参数,如下:

  • classLodar:指定加载动态生成的代理类需要的类加载器,可以与使用目标类的类加载器,一般是应用程序类加载器
  • interfaces:指定代理类需要实现的接口,需要与目标对象接口一致,我们可以通过目标对象进行获取
  • invocationHandler:设置代理类中的抽象方法如何重写,在其中需要描述与target相同的方法,在代理对象中该如何实现。

invocationHandler是一个函数式接口,其中只有一个方法invoke需要实现,函数签名如下。其中的实现关键在于如何安排执行方式,目标类的方法调用对应method.invoke(target, args),我们可以在核心方法的前后,try-catch-finally块中的对应位置添加非核心代码。

1
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
  • proxy:代理对象
  • method:代理对象需要实现的方法,即其中需要重写的方法
  • args:method所对应方法的参数

有了代理工厂类,我们就可以很容易得到目标类的代理类,然后调用代理类的相关方法,达到效果。在这里的测试代码中,我们需要对getProxy获取到的代理类进行类型转换。

1
2
3
ProxyFactory proxyFactory = new ProxyFactory(new CalculatorImp());
Calculator proxy = (Calculator) proxyFactory.getProxy();
proxy.div(10, 2);

基于注解的AOP

AOP简介

AOP,全称为Aspect Oriented Programming,意为面向切面编程。这是一种编程思想,是对面向对象的一种补充和完善。利用面向切面编程,我们可以通过预编译方式和动态代理方式,在不修改源代码的情况下下给程序动态添加额外的功能,即在不修改源代码的情况下对其进行功能扩展。

回顾上面的方式,我们的核心代码在Calculator实现类中,我们没有修改它的源代码,但是使用代理的方式扩展了日志打印功能。

下面介绍AOP相关的一些概念:

  1. 横切关注点:从方法中抽取出来的一类非核心业务。在一个项目中,我们可以使用多个横切关注点对相关方法进行多方面的增强
  2. 通知方法:一个横切关注点上需要完成的工作需要使用一个方法来实现,这样的方法叫做通知方法
  3. 切面类:对通知方法进行管理,封装通知方法的类称为切面类
  4. 目标对象:被代理的对象称为目标对象,里面实现的是核心业务逻辑
  5. 代理对象:将通知方法应用在目标对象上,据此创建出的代理对象
  6. 连接点:被抽取出来的通知方法在原方法中的位置,可能是在方法执行前后,或者异常结束,最终结束等位置
  7. 切入点:定位连接点的方式。

AOP的流程可以总结如下:从目标对象中抽取非核心业务代码,即横切关注点,将其封装在类中,即切面类。每一个横切关注点对应一个方法,即通知方法,也就是非核心业务代码。抽取之后还需要完成与核心方法的结合,对应的结合位置为连接点,而连接点择需要利用切入点的方式来定位。

通过AOP,一方面,我们可以简化代码,将方法中位置固定且重复出现的代码抽取出来,让核心方法更加专注于自己功能的实现;另一方面,我们可以实现代码增强,将特定功能封装到切面类中,看哪里有需要就使用,使用了切面逻辑的方法就被增强了。

注解使用

在Spring中,可以基于注解来使用AOP。具体来说,使用的是AspectJ注解层。AspectJ是AOP思想的一种实现,AspectJ本质上是静态代理,它将代理逻辑混入目标类编译得到的字节码文件中。具体实现层中又分为动态代理或cglib。

  • 动态代理:这是JDK原生的实现方式,需要被代理的目标类必须要实现某个接口,而代理类需要和目标类实现相同的接口。最终生成的代理类com.sum.proxy包下,类名为$porxy+数字,与目标类实现相同的接口
  • cglib:通过继承目标类来实现代理,不需要目标类实现接口。最终生成的代理类会继承目标类,并且和目标类在相同的包下

Spring中的核心还是IOC,要使用AOP,我们首先还是需要引入IOC相关的依赖,同时引入下面的依赖:

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
<version>5.3.1</version>
</dependency>

我们对上面计算器的场景重新进行实现。现在仍然有Calculator接口以及CalculatorImp实现类,在实现类中,只有最基本的功能实现,即核心代码。日志相关的代码并不在其中实现。不过这里实现类我们要使用@Component注解进行配置,将其交给IOC容器进行管理。

之后,创建切面类,并在里面实现相关通知方法。

1
2
3
4
5
6
7
8
9
@Aspect // 标识为切面类
@Component // 交由IOC容器管理
public class LogAspect {
// 标识通知方法,并指定位置
@Before("execution(public int com.syh.spring.aop.Calculator.add(int, int))")
public void beforeMethod() {
System.out.println("方法开始之前");
}
}

首先,我们利用@Aspect标注表明这个类是一个切面类,然后利用@Component表示当前类要交给IOC容器管理。我们通过@Before标注指明对应方法是一个通知方法。这里的@Before注解表示通知方法的对应位置为方法执行之前,后续还会介绍其他位置的对应注解。注解中的值为切入点表达式,定位通知方法执行的位置。这里表示该通知方法要在add方法执行前执行。

之后,我们创建Spring的配置文件,首先配置扫描标签配合注解管理Bean,然后使用aspectj-autoproxy来开启基于注解的AOP功能。

1
2
<context:component-scan base-package="com.syh.spring.aop"/>
<aop:aspectj-autoproxy/>

至此,我们已经完成了基本的配置,测试代码如下。这里我们需要通过IOC容器来获取到代理类。我们并不知道由Spring生成的代理类的真实类型,但是我们知道它一定是实现了Calculator接口的,因此可以通过接口类型来访问。

1
2
3
4
5
public void testAOP() {
ApplicationContext context = new ClassPathXmlApplicationContext("aopContext.xml");
Calculator calculator = context.getBean(Calculator.class);
calculator.add(1, 2);
}

需要注意的是,虽然前面我们将CalculatorImp类交给了IOC容器进行管理,但是不能直接通过该类进行获取,会直接报错NoSuchBeanDefinitionException。AOP中通过代理方式实现,实际上IOC中管理的是对应的代理类。

上面的代码已经能够达到相应的效果,在add方法执行之前,输出了对应的日志信息。

通知方法

根据通知方法的位置不同,有如下的通知类型:

  1. 前置通知:使用@Before注解进行标识,在目标方法执行前执行
  2. 返回通知:使用@AfterRunning注解进行标识,在目标方法正常退出后执行
  3. 异常通知:使用@AfterThrowing注解进行标识,在目标方法异常介结束后执行,对应catch代码块功能
  4. 后置通知:使用@After注解进行标识,在目标方法最终结束后执行,对应finally代码块功能
  5. 环绕通知:使用@Around注解进行标识,使用try-catch-finally结构围绕整个被代理的目标方法,定义了代理目标方法的全流程

通知的执行顺序为前置通知 -> 目标操作 -> 返回通知或异常通知 -> 后置通知,与正常try-catch-finally的执行逻辑类似。

在通知方法中,我们可以获取连接点的相关信息,包括目标方法的签名信息,参数,返回值等。

要想获取连接点信息,我们需要在通知方法的参数位置设置JoinPoint类型的形参,然后通过该参数获取相关信息:

1
2
3
4
5
6
7
@After("pointCut()")
public void afterMethod(JoinPoint joinPoint){
// 获取连接点的签名信息
Signature signature = joinPoint.getSignature();
System.out.println("方法名称: " + signature.getName());
System.out.println("参数列表: " + Arrays.toString(joinPoint.getArgs()));
}

@AfterReturning中可以指定属性returning,并在形参列表中指定对应的参数,接收目标方法的返回值:

1
2
3
4
5
@AfterReturning(value = "pointCut()", returning = "result")
public void afterReturningMethod(JoinPoint joinPoint, Object result) {
System.out.println("方法名称: " + joinPoint.getSignature().getName());
System.out.println("返回结果为: " + result);
}

@AfterThrowing中,也可以通过类似的方式,指定属性throwing来接收目标方法的异常:

1
2
3
4
@AfterThrowing(value = "pointCut()", throwing = "exception")
public void afterThrowingMethod(Throwable exception) {
System.out.println("执行过程中遇见异常: " + exception);
}

环绕通知相当于动态代理的手动实现,我们可以在其中实现相关的逻辑。一般情况下是使用try-catch-finally进行包裹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Around("pointCut()")
public Object aroundMethod(ProceedingJoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
String args = Arrays.toString(joinPoint.getArgs());
Object result = null;

try {
System.out.println("对象方法执行前");
result = joinPoint.proceed();
System.out.println("对象方法正常返回后");
} catch (Throwable throwable) {
throwable.printStackTrace();
System.out.println("对象方法出现异常");
} finally {
System.out.println("对象方法执行完毕");
}
return result;
}

这里的核心步骤在于目标方法的执行,即joinPoint.proceed()。注意这里的方法需要有返回值,目标方法的返回值一定要返回给外界调用者。

切入点

在目标方法的注解中,需要提供切入点表达式,表明该通知方法要在哪些被代理的方法前后执行。切入点表达式语法如下,在括号中,需要提供方法的签名描述,让Spring能够找到对应的一个或者多个方法。

1
2
3
execution(...)
// 举例
@Before("execution(* com.syh.spring.aop.Calculator.*(..))")
  1. 可以使用*代替修饰符+返回值,表示不限(注意是两者一起被代替,而不是其中一个)
  2. 如果要明确指定一个返回类型,必须同时写明权限修饰符
  3. 在包名的部分,一个*只能代表层次结构中的一层
  4. 在包名的部分,使用*..表示包名任意,包的层次深度任意
  5. 在类名或方法名部分,可以整体使用*代替,表示类名或方法名任意
  6. 在类名后方法名部分,可以用*代替类名或方法名的一部分,用于部分匹配
  7. 在参数列表部分,使用(..)表示参数列表任意
  8. 在参数列表部分,使用(int,..)表示参数列表应该以一个int类型的参数开头
  9. 基本数据类型与对应的包装类类型是不一致的,不能相互匹配

切入点表达式比较冗长,重复书写较为麻烦。但是Spring中提供了切入点表达式的重用。我们只需要对切入表达式进行声明,后续直接使用即可。

1
2
3
4
5
6
7
8
// 声明
@Pointcut("execution(* com.syh.spring.aop.Calculator.*(..))")
public void pointCut() {
}

// 使用
@Before("pointCut()")
@Before("com.syh.spring.aop.LogAspect.pointCut()")

声明好的切入点表达式既可以在本地方法中使用,也可以在其他切面类中进行使用,因为我们的切入点表达式是标记在方法上的,要访问切入点表达式,只需要找到对应的方法即可。

切面的优先级

如果相同的目标方法上同时存在多个切面类,那么会存在切面的优先级。切面执行的优先级控制切面的内外嵌套顺序,如果切面类的优先级更高,则该切面类会先执行,后退出。

我们可以使用@Order注解来控制切面的优先级,其中需要提供一个整数,整数值越小表示优先级越高,默认值为INT_MAX

1
2
3
4
5
6
@Aspect
@Component
@Order(1)
public class LogAspect {
//...
}

基于XML的AOP

上面我们是使用了基于注解实现的AOP,同样我们也可以基于XML来使用AOP。主要在配置文件中进行完成,主要标签为aop:config。此时不需要开启基于注解的AOP,即aop:aspectj-autoproxy标签:

1
2
3
4
5
6
7
8
9
10
11
<aop:config>
<!--配置切面类-->
<aop:aspect ref="logAspect" order="1">
<aop:pointcut id="pointCut" expression="execution(* com.syh.spring.aop.Calculator.*(..))"/>
<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="exception"/>
<aop:around method="aroundMethod" pointcut-ref="pointCut"/>
</aop:aspect>
</aop:config>

在config标签中进行切面类的配置,一个aspect对应一个切面类。其中的ref属性对应Bean对象的引用,由于我们通过注解将类对象交给了IOC进行管理,这里会提供logAspect的Bean对象id。这里的order属性对应优先级的设置。

之后通过不同的子标签设置不同的通知方法,与注解类似,也是需要指定方法,切入点,如果是returning或者thowing,则还需要指定对应接收的形参。这里的切入点可以使用pointcut-ref,也可以使用pointcut,前者对应一个定义好的切入点,后者则是提供切入点表达式。


Spring笔记(3)-代理模式与面向切面编程AOP
http://example.com/2022/10/14/Spring笔记-3-代理模式与面向切面编程AOP/
作者
EverNorif
发布于
2022年10月14日
许可协议