一.什么是AOP?
在之前的文章中,提出了这样一个场景,在计算机运行计算方法时添加日志记录。我们刚开始想到的就是在这些方法中添加日志记录,但是这样操作就会导致业务逻辑与辅助功能耦合。接下来我们又提出可以将日志记录作为一个模块,在核心功能运行时,将其加入。这就是AOP要解决的问题。
AOP(Aspect Oriented Programming)称为面向切面编程,在程序开发中主要用来解决一些系统层面上的问题,比如日志,事务等AOP可以说是OOP(Object Oriented Programming,面向对象编程)的补充和完善。OOP引用封装,继承,多态等概念来建立一种对象层次结构,用于模拟公共行为的一种集合。不过OOP允许开发者定义纵向的关系,但并不适合定义横向的关系,例如上面提到的日志记录功能。日志记录代码往往横向散布在所有对象层次中,而与它对应的对象的核心功能毫无关系。这种散布在各处的无关的代码被称为横切,在OOP设计中,导致了大量代码的重复,不利于各个模块的重用。AOP技术恰恰相反,它利用一种称为“横切”的技术,剖解封装对象的内部,并将那些影响多个类的公共行为封装到一个可重用模块,并将其命名为“Aspect”,即切面。所谓切面,简单说就是那种与业务无关,却为业务模块所共同调用的逻辑或责任封装起来,便于减少系统的重复代码,降低模块之间的耦合度,有利于未来的操作性和可维护性。
在软件开发中,散步于应用中多处的功能被称为横切关注点。通常来说,这些横切关注点从概念上是与应用的业务逻辑相分离的。把这些横切关注点与业务逻辑相分离正是面向切面编程(AOP)所要解决的问题。
这种在运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。
二.定义AOP术语
在之前的实验中,提出了在计算器运行方法时添加日志记录的场景。我们首先从这个场景中了解AOP的术语的含义。
2.1 通知Advice
在AOP术语中,切面的工作被称为通知。通知定义了切面什么时候以及何时使用。除了描述切面要完成的工作,通知还解决了何时执行这个问题。它应该应用在某个方法被调用之前?之后?之前和之后都调用?还是只在方法抛出异常时调用?
Spring切面可以应用5种类型的通知:
- 前置通知(Before):在目标方法被调用之前调用此通知功能;
- 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么;
- 返回通知(After-returning):在目标方法成功执行之后调用通知;
- 异常通知(After-throwing):在目标方法抛出异常后调用通知;
- 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
2.2 连接点JoinPoint
连接点是在应用执行过程中能插入切面的一个点。这个点可以是调用方法时,抛出异常时,甚至修改一个字段时。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。
程序执行过程中明确的点,一般是方法的调用。被拦截的点,因为Spring只支持方法类型的连接点,所以在Spring中连接点指的就是被拦截的方法,实际上连接点还可以是字段或者构造器。
2.3 切点Poincut
如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”。切点的定义会匹配通知所要织入的一个或多个连接点。通常使用明确的类和方法名称,或者利用正则表达式所匹配的类和方法名称来指定这些切点。
2.4 切面Aspect
切面是通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和何处完成其功能。
2.5 引入Introduction
引入允许我们向现有的类添加新的方法或属性。例如,我们创建了一个通知类,该类记录了对象最后一次修改时的状态,只需要一个方法,setLastModified(Date),和一个实例变量来保存这个状态。然后这个新方法和实例变量就可以被引入到现有的类中,从而可以在无需修改这些类的情况下,让它们具有新的行为和状态。
2.6 织入Weaving
织入是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期里有多个点可以进行织入:
- 编译期:切面在目标类编译时被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
- 类加载期:切面在目标类加载到JVM时被织入。这种方式需要特殊的类加载器,它可以在目标类被引入之前增强该目标类的字节码。
- 运行期:切面在应用运行的某个时刻被织入。一般情况下,在织入切面时,AOP容器会为了目标对象动态创建一个代理对象,Spring AOP就是以这种方式织入切面的。
2.7 AOP代理(AOP Proxy)
AOP框架创建的对象,代理就是目标对象的加强。Spring中的AOP代理可以使用JDK动态代理,也可以是CGLIB代理,前者基于接口,后者基于子类。
三.Spring对AOP支持
Spring提供了4种类型的AOP支持:
- 基于代理的经典Spring AOP;
- 纯POJO切面(使用XML);
- @AspectJ注解驱动的切面;
- 注入式AspectJ切面(适用Spring各版本);
前三种都是Spring AOP实现的变体,Spring AOP构建在动态代理的基础之上,因此,Spring AOP的支持局限于方法拦截。
借助Spring的aop空间,可以将纯POJO转换为切面。实际上,这些POJO只是提供了满足切点条件所要调用的方法。遗憾的是,这种技术需要XML配置,但这的确是将声明式地将对象转换为切面的简便方式。
需要了解一下Spring AOP的一些关键知识:
- Spring所创建通知都是用标准的Java类编写的。定义通知所应有的切点通常会使用注解或在Spring配置文件里采用XML来编写。
- Spring在运行时通知对象。通过在代理对象类中包裹切面,Spring在运行期把切面织入到Spring管理的bean中。如下图所示,代理类封装了目标类,并拦截被通知方法的调用,再把调用转发给真正的目标bean。当代理拦截到方法调用时,在调用目标方法bean之前,会执行切面逻辑。直到应用需要被代理的bean时,Spring才创建代理对象。如果使用的是ApplicationContext时,在ApplicationContext从BeanFactory中加载所有bean的时候,Spring才会创建被代理的对象。因为Spring运行时才创建代理对象,不需要特殊的编译器来织入Spring AOP的切面。
- Spring只支持方法级别的连接点。
四.通过切点来选择连接点
在Spring AOP中,要使用AspectJ的切点表达式语言来定义切点。我们可以知道AspectJ有许多的切点指示器,但是在Spring中尝试使用AspectJ其他指示器时,将会抛出IllegalArgument-Exception异常。只有一个指示器就是execution指示器是实际执行匹配的,而其他的指示器都是用来限制匹配的。这说明execution指示器时编写切点定义时最主要使用的指示器。在此基础上,使用其他指示器类限制所匹配的切点。
AspectJ指示器 | 描述 |
arg() | 限制连接点匹配参数为指定类型的执行方法 |
@args() | 限制连接点匹配残守由指定注解标注的执行方法 |
execution() | 用于匹配是连接点的执行方法 |
this | 限制连接点匹配AOP代理的bean为指定类型的类 |
target | 限制连接点匹配目标对象为指定类型的类 |
@target() | 限制连接点匹配特定的执行对象,这些对象对应的类要具有指定类型的注解 |
within() | 限制连接点匹配指定的类型 |
@within() | 限制连接点匹配指定注解所标注的类型(当使用Spring AOP时,方法定义在由指定注解所标注的类里) |
@annotation | 限定匹配带有指定注解的连接点 |
4.1 编写切点
在之前的实验中,细节二描述了切点表达式的写法。现在再次对其进行总结。
由于Spring切面粒度最小是达到方法级别,而execution表达式可以用于明确指定方法返回类型,类名,方法名及参数名等与方法相关的部件,并且在Spring中,大部分需要使用AOP的业务场景也只需要达到方法级别即可,因而execution表达式的使用是最广泛的,下面是execution表达式的语法:
execution(modifiers-pattern?ret-type-pattern declaring-type-pattern?name-pattern(param-pattern) thorws-pattern?)
问号表示当前项可以有也可以没有,其中各项的语义如下:
- modifiers-pattern:方法的可见性,如public,protected;
- ret-type-pattern:方法的返回值类型,如int,void;
- declaring-type-pattern:方法所在类的全路径名,如com.test.test;
- name-pattern:方法名称;
- param-pattern:方法参数类型;
- throws-pattern:方法抛出异常类型。
如下是一个使用execution表达式的例子:
execution(public int com.test.impl.MyMathCalculator.*(int, int))
上述表达式将会匹配使用public修饰,返回类型为int,并且是com.test.impl.MyMathCalculator类中的任意方法,方法有两个参数,两个参数的类型都是int。上述示例中我们使用了*通配符,关于通配符的类型,主要有两种:
- *通配符,该通配符主要匹配单个单词,或者是以某个词为前缀和后缀的单词。
- ..通配符,该通配符主要表示0个或多个项,主要用于declaring-type-pattern和param-pattern中,如果用于declaring-type-pattern中,则表示匹配当前包或子包,如果用于param-pattern中,则表示匹配0个或多个参数。
其他指示器的使用可以看看这篇文章:Spring AOP切点表达式用法总结
4.2 在切点中选择bean
除了前面所列的指示器外,Spring还引入了一个新的bean()指示器,它允许在切点表达式中使用bean的ID来标识bean。bean()使用bean ID或bean名称作为参数来限制切点只匹配特定的bean。
五.使用注解创建切面
使用注解来创建切面是AspectJ 5所引入的关键特性。
要使用注解来创建切面,首先要在配置文件中配置:
<!-- 开启基于注解的AOP功能:aop名称空间 -->
<aop:aspectj-autoproxy></aop:aspectj-autoproxy>
5.1 定义切面
在实验中使用AOP步骤的第二步写配置中说到还应该告诉Spring哪个是切面类,这就是使用注解@Aspect定义切面。
LogUtils 类使用了@AspectJ注解进行了标注。该注解说明LogUtils不仅仅是一个POJO,还是一个切面。LogUtils类中的方法都使用注解来定义切面的具体行为。
LogUtils中有四个方法,定义了计算方法运行时要进行的日志记录行为。在计算方法之前,要输出参与计算的方法名和参数(logStart)。在计算方法成功结束后,输出计算结果和方法名(logReturn)。在方法抛出异常的时候,输出方法名和异常信息(logException)。在方法结束后,输出方法名(logEnd)。(细节四可以看到代码)
可以看到这些方法都使用了通知注解来表明它们应该在什么时候调用。AspectJ提供了五个注解来表示通知:
注解 | 通知 |
@After | 通知方法会在目标方法返回或者抛出异常后调用 |
@AfterThrowing | 通知方法会在目标方法抛出异常后调用 |
@AfterReturning | 通知方法会在目标方法返回后调用 |
@Around | 通知方法会将目标方法封装起来 |
@Before | 通知方法会在目标方法调用前执行 |
可以注意到所有的注解都给定了一个切点表达式作为它的值,同时,这4个方法的切点表达式都是相同的(可以设置成不同的表达式)。这样的重复有些麻烦,可以这样做:@Pointcut注解能够在一个@AspectJ切面定义可重用的切点。
实验中的细节四就是利用@Pointcut抽取可重用的表达式。如下:
/*
* 抽取可重用的切入点表达式:
* 1.随便声明一个没有实现的返回void的空方法
* 2.给方法上标注@Pointcut注解
*/
@Pointcut("execution(public int com.test.impl.MyMathCalculator.add(int, int))")
public void haMyPoint(){
}
@Before("haMyPoint()")
public static void logStart(JoinPoint joinPoint){...}
@AfterReturning(value=" haMyPoint()",returning="result")
public static void logReturn(JoinPoint joinPoint,Object result){...}
5.2 创建环绕通知
环绕通知是最强大的通知类型。它能够让你所编写的逻辑将被通知的目标方法完全包装起来。实际上就像在一个通知方法中编写前置通知和后置通知。
实验中的细节八就是在一个方法中实现了环绕通知。
/*
* @Around环绕通知:是Spring中强大的通知
* @Around:动态代理
* try{
* //前置通知
* method.invoke(obj,args);
* //返回通知
* }catch(e){
* //异常通知
* }finally{
* //后置通知
* }
*
* 四合一就是环绕通知
* 环绕通知有一个参数:ProceedingJoinPoint
*/
//记得注销掉其他的通知
@Pointcut("execution(public int com.test.impl.MyMathCalculator.add(int, int))")
public void haMyPoint(){
}
@Around("haMyPoint()")
public Object myAround(ProceedingJoinPoint pjp) throws Throwable{
Object[] args=pjp.getArgs();
String name=pjp.getSignature().getName();
Object proced=null;
try {
//就是利用反射调用目标方法,就是method.invoke(obj,args)
//@Before
System.out.println("【环绕前置通知】+"+name+"方法开始了");
proced = pjp.proceed(args);
//@AfterReturning
System.out.println("【环绕返回通知】+"+name+"方法返回了,返回值"+proced);
} catch (Exception e) {
//@AfterThrowing
System.out.println("【环绕异常通知】+"+name+"方法出现了异常"+e);
}finally{
//@After
System.out.println("【环绕后置通知】+"+name+"方法结束了");
}
//反射调用的返回值也一定返回出去
return proced;
}
关于这个通知方法,它接受ProceedingJoinPoint 作为参数。与其他的通知不同的是,这个对象是必须有的。因为要在通知中通过它调用被通知的方法。通知方法中可以做任何事情,当要将控制权交给被通知的方法时,需要调用ProceedingJoinPoint 的proceed()方法。
需要注意的是,别忘记调用proceed()方法。如果不调用这个方法,那么你的通知实际上会阻塞对被通知方法的调用。
六.基于配置的AOP
实验第四节就是基于配置的AOP实验。在这个实验中去掉LogUtils类中的所有注解。
基于注解的AOP分为以下步骤:
1.将目标类和切面类都加入到ioc容器中@Component
2.告诉Spring哪个是切面@Aspect
3.在切面类中使用五个通知注解来配置切面中的这些通知方法都何时何地运行
4.开启基于注解的AOP功能
<?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-4.0.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd">
<!-- 基于配置的AOP -->
<!-- 将目标类和切面类都加入到ioc容器中@Component -->
<bean id="myMathCalculator" class="com.test.impl.MyMathCalculator"></bean>
<bean id="validate" class="com.test.utils.ValidateApsect"></bean>
<bean id="logUtils" class="com.test.utils.LogUtils"></bean>
<!-- 需要AOP名称空间 -->
<aop:config>
<aop:pointcut expression="execution(* com.test.impl.*.*(..))" id="point"/>
<!-- 谁配置在前,谁在前 。也可用order属性来表示-->
<!-- 指定切面 :@Aspect-->
<aop:aspect ref="logUtils" order="1">
<!-- 配置哪个方法是前置通知,method指定方法名
logStart@Before("切入点表达式")
-->
<!-- 可重用表达式 ,当前切面用-->
<aop:pointcut expression="execution(* com.test.impl.*.*(..))" id="myPoint"/>
<aop:before method="logStart" pointcut="execution(* com.test.impl.*.*(..))"/>
<aop:after-returning method="logReturn" pointcut-ref="myPoint" returning="result"/>
<aop:after-throwing method="logException" pointcut-ref="myPoint" throwing="exception"/>
<aop:after method="logEnd" pointcut-ref="myPoint" />
<aop:around method="myAround" pointcut-ref="myPoint"/>
<!-- 会影响环绕前置顺序 -->
</aop:aspect>
<aop:aspect ref="validate" order="0">
<aop:before method="logStart" pointcut-ref="point"/>
<aop:after-returning method="logReturn" pointcut-ref="point" returning="result"/>
<aop:after-throwing method="logException" pointcut-ref="point" throwing="exception"/>
<aop:after method="logEnd" pointcut-ref="point"/>
</aop:aspect>
<!-- 在切面类中使用五个通知注解来配置切面中的这些通知方法都何时何地运行 -->
</aop:config>
<!-- 注解:快速方便
配置:功能完善,重要的用配置,不重要的用注解
-->
</beans>
在Spring的aop空间中,提供了多个元素用来在XML中声明切面,如下:
AOP配置元素 | 用途 |
<aop:advisor> | 定义AOP通知器 |
<aop:after> | 定义AOP后置通知(不管被通知的方法是否执行成功) |
<aop:after-returning> | 定义AOP返回通知 |
<aop:after-throwing> | 定义AOP异常通知 |
<aop:around> | 定义AOP环绕通知 |
<aop:aspect> | 定义一个切面 |
<aop:aspectj-autoproxy> | 启用@AspectJ注解驱动的切面 |
<aop:before> | 定义一个AOP前置通知 |
<aop:config> | 顶层的AOP配置的元素。大多数的<aop:*>元素必须包含在这个元素中 |
<aop:declare-parents> | 以透明的方式为被通知的对象引入额外的接口 |
<aop:poincut> | 定义一个切点 |
在之前的实验中,已经了解到<aop:autoproxy>元素,能够自动代理AspectJ注解的通知类。aop命名空间的其他元素能够让我们直接在Spring配置中声明切面,而不需要使用注解。
关于Spring AOP 配置元素,第一个需要注意的事项是大多数的AOP配置元素必须在<aop:config>元素的上下文内使用。这条规则有几种例外的场景,但是把一个bean声明为一个切面时,总是从<aop:config>元素开始配置的。