Spring之AOP实践

IoC可以解决了对象依赖之间的高度耦合,AOP(Aspect Oriented Programming,面向切面编程)则是OOP面向对象编程思想的延续、补充,其是一种可将系统服务与业务服务之间进行解耦的编程范式。而目前Java界流行的Spring FrameWork也很好地支持AOP了,方便易用。本文这里对其进行简要介绍并对其用法实践作具体说明

abstract.png

AOP 面向切面编程

基本概念

一般项目中不仅有具体的业务服务,同时还有一些诸如日志、安全、性能监控等系统级的非业务方面的服务,然后这些非业务的系统级服务需要在各个业务模块中进行调用。从下图可以看到,项目中的系统服务(日志、安全、性能监控)就像一个切面,贯彻各个业务服务(A、B、C模块)。这样实际上会产生两个问题:其一,各个业务模块中存在大量与业务逻辑无关的代码,容易产生混乱;其二,每增加一个业务模块,都需要添加相应的系统服务代码,不仅麻烦,而且容易造成遗漏

figure 1.png

而AOP面向切面编程恰好可以解决这个痛点,将业务模块与系统服务之间进行解耦。这里先对AOP中的相关术语做一个简要介绍:

figure 2.png

  • Aspect切面 : 将需要在业务模块调用的系统服务代码抽取出来封装在一个类中,这个系统服务类就是所谓的Aspect切面(从下文我们可以看到,通知、切点共同定义了切面的全部内涵——即,在何时、何处完成什么功能)
  • Advice通知 : 我们将从业务模块中剥离出来的系统服务代码封装为方法放到切面中,这时切面类中的系统服务方法就被称作为Advice通知。后面我们通过代码示例可以看到通知不仅定义了系统服务的具体行为是什么(即,What),其同时还定义了何时来调用这些系统服务(即,When)
  • Join Point连接点 : 在业务模块中,可以调用Advice通知的地方即被称作Join Point连接点。连接点是业务模块运行过程中能够调用切面(类)的通知方法的一个位置点。这个位置点可以是调用业务模块方法时、业务模块方法抛出异常时、甚至修改业务类的某个字段时。切面通过这些连接点即可将通知方法插入到业务代码的正常流程当中去,为其添加新的行为和功能
  • Pointcut切点 : 虽然业务代码中有很多很多连接点,可供通知方法插入执行。但实际使用中,我们只需要向个别几个我们期望插入的连接点来插入系统服务执行,则这些真正被插入了系统服务的连接点即被称作为Pointcut切点。具体地,切点可以通过指定业务类的类名及其方法名来进行指定,可以看到切点定义了通知方法在何处被插入调用(即,Where)
  • Introduction引入 : 引入允许我们向现有类添加新方法、属性
  • Weaving织入 : 织入指把切面应用到目标对象并创建新的代理对象的过程,切面会在指定的连接点(即切点)织入到目标对象中。在目标对象的生命周期中有多个阶段可以进行织入:
    1. 编译期: 切面在目标类编译时被织入,该方案需要特殊的编译器。AspectJ项目的织入编译器就是通过此种方式织入切面的
    2. 类加载期: 切面在目标类加载到JVM时被织入,该方案需要特殊的类加载器,其可以在目标类被引入应用之前增强该目标类的字节码。AspectJ 5的加载时织入(LTW, load-time weaving)就是通过此种方式织入切面的
    3. 运行期: 切面在应用运行的某个时刻被织入。一般地,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。本文所使用的Spring AOP就是通过此种方式织入切面的

基本原理

Spring 的AOP框架是在运行期通过创建代理类将切面织入到目标对象中,当调用目标对象的方法时,其首先会被代理类拦截以执行额外的切面逻辑,然后再将调用转发给目标对象。由于Spring AOP框架只是对目标方法的调用进行拦截,所以如果期望能够在字段属性、构造器等连接点进行拦截,就需要考虑其他方案(如AspectJ)来实现切面

figure 3.png

Spring AOP

切点表达式

切点,是我们真正需要织入切面通知的连接点。Spring AOP这里借用了AspectJ的切点表达式来定义切点,其支持的AspectJ切点指示器如下。在切点表达式中,支持使用逻辑运算符(&&与、||或、!非)来连接多个切点指示器

figure 4.png

开发中,最常用的就是 execution(),其语法格式如下:

execution([修饰符] <返回类型> <类路径> <方法名>(<参数类型列表>) [异常模式] )

其中:修饰符、异常模式是可选的

用法示例如下:

1
execution(* com.aaron.springbootdemo.controller.AspectController.test1())

该切点表达式匹配 com.aaron.springbootdemo.controller包的AspectController类的test1方法,第一个*号匹配方法连接点的任何返回类型。且该test1方法无形参

1
execution(* com.aaron.springbootdemo.controller.AspectController.test1(Integer, *))

该切点表达式匹配 com.aaron.springbootdemo.controller包的AspectController类的test1方法,第一个*号匹配方法连接点的任何返回类型。且该test1方法形参数量为两个,第一个形参类型必须为Integer,第二个形参为任意类型

1
execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))

该切点表达式匹配 com.aaron.springbootdemo.controller包的AspectController类的所有test1方法,第一个*号匹配方法连接点的任何返回类型,参数列表可用..匹配零个和多个参数类型

1
execution(* com.aaron.springbootdemo.controller.AspectController.*(..))

该切点表达式匹配 com.aaron.springbootdemo.controller包的AspectController类中的所有方法,第二个*匹配任意方法名

1
execution(* com.aaron.springbootdemo.controller.*.*(..))

该切点表达式匹配 com.aaron.springbootdemo.controller包下所有类的所有方法,第二个*匹配任意类名,第三个*匹配任意方法名

1
execution(* com.aaron.springbootdemo.controller..*.*(..))

该切点表达式匹配 com.aaron.springbootdemo.controller包及其子包下所有类的所有方法,可用..*匹配当前包及其子包,第三个*匹配任意方法名

1
execution(* *test1(..)) && !bean(aniService)

该切点表达式匹配所有方法名以test1结尾的方法,且要求方法调用者的bean名不能为aniService

1
execution(* com.aaron.springbootdemo.service.Service+.*(..))

该切点表达式匹配com.aaron.springbootdemo.service包的Service类及其子类的所有方法,可用+匹配子类

通知

Spring AOP中支持5种形式的通知,具体如下:

figure 5.png

Spring AOP 实践

Maven依赖

本文开发环境为SpringBoot FrameWork,故使用AOP只需添加下述依赖即可

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.1.4.RELEASE</version>
</dependency>

前置、后置通知

介绍了这么多,我们现在来通过具体的代码示例实践AOP。这里先写一个Controller业务类

1
2
3
4
5
6
7
8
9
10
@Controller
@RequestMapping("AspectController")
public class AspectController {
@ResponseBody
@RequestMapping("/test1")
public void test1(@RequestParam Integer id) {
System.out.println("This is test1 Method");
int temp = 2/id;
}
}

然后,我们期望在这个业务类的Controller方法调用前后,打印一些日志信息。这时就可以使用AOP写切面类来实现。我们在切面类上添加@Aspect注解标识该类是一个切面类,然后在切面类的方法上添加相应的通知注解(@Before、@After),来确定相应通知方法在切点位置处何时被调用,同时在通知注解中定义切点位置。从下述代码可以看到,startRunTime()、endRunTime()通知方法中通知注解的切点表达式是重复的,为此我们可以通过@Pointcut注解来统一定义切点表达式,同时还需要提供一个空方法(这里是test1PointCut())来让该注解依附即可。现在,我们就可以直接引用该切点表达式了,如通知方法endRunTime2()、endRunTime3()所示。至此,我们通过AOP将业务代码与非业务代码实现了解耦

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
34
35
36
/**
* 切面类
*/
@Component
@Aspect
public class RunTimeAspect {

// 统一定义切点表达式
@Pointcut("execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))")
public void test1PointCut(){
}

// 前置通知
@Before("execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))")
public void startRunTime() {
System.out.println("[Before Advice]");
}

// 后置通知
@After("execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))")
public void endRunTime() {
System.out.println("[After Advice]");
}

// 后置通知,
@AfterReturning("test1PointCut()")
public void endRunTime2() {
System.out.println("[AfterReturning Advice]");
}

// 后置通知
@AfterThrowing("test1PointCut()")
public void endRunTime3() {
System.out.println("[AfterThrowing Advice]");
}
}

这里我们通过Postman向 http://localhost:8088/AspectController/test1?id=1 发送接口请求,验证下效果,可以看到AspectController业务类的test1方法正常运行,且相关通知方法也在预期的时机被执行

figure 6.png

这里我们通过Postman向 http://localhost:8088/AspectController/test1?id=0 发送接口请求,验证下效果,可以看到AspectController业务类的test1方法未正确返回、抛出了除零异常,且相关通知方法也在预期的时机被执行

figure 7.png

环绕通知

环绕通知,顾名思义,其会在目标方法被调用前后均会执行切面代码的一种通知,其可将前置通知、后置通知的代码放在一个通知方法当中,使得代码逻辑更加清晰。这里,我们以测量AspectController业务类的test1方法的运行时长为例来创建一个环绕通知。环绕通知方法需要申明一个ProceedingJoinPoint类型的参数,以便通过调用它的proceed()方法将控制权移交给目标方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 环绕通知
@Around("test1PointCut()")
public void startEndRunTime(ProceedingJoinPoint joinPoint) {
try {
// 调用目标方法之前执行的切面逻辑
System.out.println("Start Time");
Long startTime = System.currentTimeMillis();
// 调用目标方法
joinPoint.proceed();
// 调用目标方法之后执行的切面逻辑
Long endTime = System.currentTimeMillis();
System.out.println("End Time");
System.out.println("Run Time: " + (endTime-startTime) + " ms");
} catch (Throwable e) {
System.out.println("Have a Throwable Exception: " + e);
}
}

测试结果如下所示:

figure 8.png

通知方法获取目标方法的形参

一般情况下,通知方法无需关心目标方法接收到的参数,但不排除在一些性能监控的系统服务中需要统计相关参数的请求次数,这个时候就可以通过args()切点指示器,一方面是为了要求目标方法连接点有相应的同名形参,另一方面可以将该参数值传递给通知方法的同名形参。代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

// 切点表达式2
@Pointcut("execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))" +
" && args(id)")
public void test2PointCut(Integer id){
}

// 前置通知: 获取目标方法参数
@Before("execution(* com.aaron.springbootdemo.controller.AspectController.test1(..))" +
" && args(id)")
public void getParamTargetMethod1(Integer id) {
System.out.println("[Before]: in getParamTargetMethod1 method");
System.out.println("[Target Method Param]: " + id);
}

// 后置通知: 获取目标方法参数
@After("test2PointCut(id)")
public void getParamTargetMethod2(Integer id) {
System.out.println("[After]: in getParamTargetMethod2 method");
System.out.println("[Target Method Param]: " + id);
}

这里我们通过Postman向 http://localhost:8088/AspectController/test1?id=2 发送接口请求,验证下效果,可以看到相关通知方法不仅在预期的时机被执行,且均获得了传递目标方法的参数值

figure 9.png

Note:

由于本文示例代码使用的SpringBoot进行开发,故无需开发者显式指定@EnableAspectJAutoProxy注解

Spring AOP 引入

AOP 通知可以对目标对象已有的方法进行增强;而通过AOP的引入则可以为目标对象添加新的方法。在AOP引入过程中,代理不仅对外提供目标对象的方法接口,还提供了一些新接口。当这些新接口方法被调用时,代理会将调用转发给真正实现了该新接口的某个其他对象。使得从外部调用者的角度来看,该目标对象实现了新的接口、具备了新的方法

figure 10.png

下述代码即是一个向AnimalService类通过AOP引入SysLogService接口新方法的示例。@DeclareParents注解用于向目标类中引入某个接口的新方法,其中value为目标对象所在类的类名(需填写完整包名),defaultImpl为所引入的新接口的实现类。同样地,该SysLogServiceIntroducer引入类上同样需要添加一个@Aspect注解标识其为一个AOP切面。

1
2
3
4
5
6
7
8
9
10
/**
* 切面类: 向 AnimalService 类引入系统日志服务
*/
@Aspect // 标识其为一个AOP切面
@Component
public class SysLogServiceIntroducer {
@DeclareParents(value = "com.aaron.SpringBoot1.service.AnimalService",
defaultImpl = SysLogServiceImpl.class)
public static SysLogService sysLogService;
}

然后,提供所引入的新接口及其实现类即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 引入的新接口
public interface SysLogService {
String getSysMsg();
}
// 引入的新接口的实现类
...
@Service
public class SysLogServiceImpl implements SysLogService {
@Override
public String getSysMsg() {
String msg = "This is a method introduction by AOP";
System.out.println(msg);
return msg;
}
}

现在AnimalService类中就有了通过AOP引入方式添加的SysLogService接口getSysMsg方法,欲调用新方法getSysMsg只需将AnimalService实例强转为SysLogService类型即可,下面即是一个调用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Controller
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@RequestMapping("animal")
public class AnimalController {

private final AnimalService animalService;

@RequestMapping("/test3")
@ResponseBody
public String test3() {
// 调用animalService方法
animalService.test1();
// 调用animalService中通过AOP引入的新方法
if(animalService instanceof SysLogService) {
((SysLogService)animalService).getSysMsg();
}
return "OK";
}
}

测试结果如下所示:

figure 11.png

参考文献

  1. Spring实战 Craig Walls著、张卫滨译
0%