在生活中,监控用电量是一个很重要的功能,但并不是大多数家庭重点关注的问题。软件系统的一些功能就像家里的电表一样,这些功能需要用到应用程序的多个地方,但是我们又不想在每个点都明确调用它们。日志、安全和事务管理的确都很重要,但它们是否为应用对象主动参与的行为呢?在软件开发中,散布于应用中多处的功能被称为横切关注点(cross-cutting concern
)。通常来讲,这些横切关注点从概念上是与应用的业务逻辑相分离的。
一、什么是面向切面编程
图中展现了一个被划分为模块的典型应用。每个模块的核心功能都是为特定业务领域提供服务,但是这些模块都需要类似的辅助功能,如安全和事务管理。
如果要重用通用的功能的话,最常见的面向对象技术是继承(
inheritance
)或委托(delegation
)。但是,如果在整个应用中都使用相同的基类,继承往往会导致一个脆弱的对象体系;而使用委托可能需要对委托对象进行复杂的调用。切面提供了取代继承和委托的另一种可选方案。在使用面向切面编程时,我们仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无须修改受影响的类。横切关注点可以被模块化为特殊的类,这些类被称为切面(
aspect
)。
1.1 定义AOP术语
描述切面术语有通知(advice
)、切点(pointcut
)和连接点(join point
),如图所示:
1.1.1 通知(advice)
电表抄表员需要统计每家每户的用电量,而记录用电量是其主要的工作。类似的,切面也有目标——它必须要完成的工作。在AOP
术语中,切面的工作被称为通知。其实就是整个程序执行过程中要插入的具体的功能,如日志等。
在Spring
切面中,可以应用五种类型的通知:
- 前置通知(
Before
):在目标方法被调用之前调用通知功能; - 后置通知(
After
):在目标方法完成之后调用通知,此时不会关心方法的输出是什么; - 返回通知(
After-returning
):在目标方法成功执行之后调用通知; - 异常通知(
After-throwing
):在目标方法抛出异常后调用通知; - 环绕通知(
Around
):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
1.1.2 连接点(Join point)
通知可能需要在程序的很多时机应用。这些时机被称为连接点。连接点是应用执行过程中能够插入切面的一个点。这个点可以是调用方法时、抛出异常时、甚至修改一个字段时。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。
1.1.3 切点(Pointcut)
如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”。切点的定义会匹配通知所要织入的一个或多个连接点。一般使用明确的类和方法名称或是利用正则表达式定义所匹配的类和方法名称来指定这些切点。
1.1.4 切面(Aspect)
切面是通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和何处完成其功能。
1.1.5 引入(Introduction)
引入允许我们向现有的类添加新方法或属性。如我们可以创建一个Auditable
通知类,该类记录了对象最后一次修改时的状态。这很简单,只需一个方法setLastModified(Data)
和一个实例变量来保存这个状态。然后,这个新方法和实例变量就可以被引入到现有的类中,从而可以在无需修改这些现有的类的情况下,让它们具有新的行为和状态。
1.1.6 织入(Weaving)
织入是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的声明周期里有多个点可以进行织入:
- 编译期:切面在目标类编译时被织入。这种方式需要特殊的编译器。
AspectJ
的织入编译器就是以这种方式织入切面的。 - 类加载期:切面在目标类加载到
JVM
时被织入。这种方式需要特殊的类加载器(ClassLoader
),它可以在目标类被引入引用之前增强该目标类的字节码。AspectJ 5
的加载时织入(load-time weaving,LTW
)就支持以这种方式织入切面。 - 运行期:切面在应用运行的某个时刻被织入。一般情况下,在织入切面时,
AOP
容器会为目标对象动态地创建一个代理对象。Spring AOP
就是以这种方式织入切面的。
总结:如日志记录、安全性检查等这些问题,我们可以称作横切性关注点,而将这个横切性的问题模块化为一个类,这个类就叫做切面(Aspect
);切面中有很多内容,其中有一个内容就是实际处理如日志记录的方法,这个方法就叫做通知(Advice
);而这个方法要应用在那些地方,这需要规定一个范围,这个范围就是切点(Pointcut
);而这个范围中有很多连接点(Join Point
),即应用在哪些方法或类上(spring
只支持方法);而通知(或切面)应用的过程就叫织入(Weave
)。
1.2 Spring对AOP的支持
在Spring
中提供了四种类型的AOP
支持(其中,前三种都是Spring AOP
实现的变体,Spring AOP
构建在动态代理基础上,因此,Spring
对AOP
的支持局限于方法拦截):
- 基于代理的经典
Spring AOP
- 纯
POJO
切面 -
@AspectJ
注解驱动的切面 - 注入式
AspectJ
切面(适用于Spring
各版本)
由于Spring
经典的AOP
不怎么样,这里就不再细说。借助Spring
的aop
命名空间,我们可以将纯POJO
转换为切面。实际上,这些POJO
只是提供了满足切点条件时所要调用的方法。遗憾的是,这种技术需要XML
配置,但是这的确是声明式地将对象转换为切面的简便方式。
Spring
借鉴了AspectJ
的切面,以提供注解驱动的AOP
。本质上,它依然是Spring
基于代理的AOP
,但是编程模型几乎与编写成熟的AspectJ
注解切面完全一致。这种方式不需要使用XML
配置。
1.2.1 Spring 通知是 Java 编写的
Spring
所创建的通知都是标准的Java
类编写的。而AspectJ
最初是以Java
语言扩展的方式实现的,这种方式有优点也有缺点。通过特有的AOP
语言,我们可以获得更强大和细粒度的控制,以及更丰富的AOP
工具集。
1.2.2 Spring在运行时通知对象
通过在代理类中包裹切面,
Spring
在运行期把切面织入到Spring
的bean
中。如图所示,代理类封装了目标类,并拦截被通知方法的调用,再把调用转发给真正的目标bean
。当拦截到方法调用时,在调用目标bean
方法之前,会执行切面逻辑。直到应用需要被代理的bean
时,才会创建代理对象。如果使用的是ApplicationContext
的话,在ApplicationContext
从BeanFactory
中加载所有bean
的时候,Spring
才会创建被代理的对象。因为Spring
运行时才创建代理对象,所以我们不需要特殊的编译器来织入Spring AOP
的切面。
1.2.3 Spring只支持方法级别的连接点
因为Spring
基于动态代理,所以只支持方法连接点。而像AspectJ
,除了方法切点,还提供了字段和构造器连接点。Spring
缺少对字段连接点的支持,无法让我们创建细粒度的通知,例如拦截对象字段的修改。而且它还不支持构造器连接点,我们就无法在bean
创建时应用通知。
但是方法拦截可以满足绝大部分需求。如果需要方法拦截之外的连接点拦截功能,那么我们可以利用AspectJ
来补充Spring AOP
功能。
二、通过切点来选择连接点
在Spring AOP
中,要使用AspectJ
的切点表达式语言来定义切点。关于Spring AOP
的AspectJ
切点,最重要的一点就是Spring
仅支持AspectJ
切点指示器的一个子集。下表是Spring AOP
所支持的AspectJ
切点指示器。
AspectJ 指示器 |
描述 |
---|---|
arg() |
限制连接点匹配参数为指定类型的执行方法 |
@args() |
限制连接点匹配参数由指定注解标注的执行方法 |
execution() |
用于匹配是连接点的执行方法 |
this() |
限制连接点匹配AOP 代理的bean 引用为指定类型的类 |
target |
限制连接点匹配目标对象为指定类型的类 |
@target() |
限制连接点匹配特定的执行对象,这些对象对应的类要具有指定类的注解 |
within() |
限制连接点匹配的类型 |
@within() |
限制连接点匹配指定注解所标注的类型(当使用Spring AOP 时,方法定义在由指定的注解所标注的类里) |
@annotation |
限定匹配带有指定注解的连接点 |
在Spring
中尝试使用AspectJ
其他指示器时,将会抛出IllegalArgumentException
异常。其中只有execution
指示器是实际执行匹配的,而其他的指示器都是用来限制匹配的,这表示execution
是我们在编写切点定义时最主要使用的指示器,在此基础上,我们使用其他指示器显示所匹配的切点。
2.1 编写切点
为了阐述Spring
中的切面,举例说明。
package concert;
public interface Performance{
public void perform();
}
说明:这个接口可以表示任何类型的现场表演,如果我们想编写Performance
的perform()
方法出发的通知,如图所示:
这里使用
execution()
指示器选择Performance
的perform()
方法。方法表达式以"*"
号开始,表明了我们不关心方法返回值的类型。然后指定了全限定类名和方法名。对于方法参数列表,我们使用两个点号(..)
标明切点要选择任意的perform()
方法,无论该方法的入参是什么。
现在假设需要配置的切点仅匹配concert
包。在此场景下,可以使用within()
指示器来限定匹配,如图所示。
还可以使用
"||"
操作符表示或(or)
关系,而使用"!"
操作符来标识非(not)
操作。因为"&"
在XML
中有特殊的含义,所以在XML
中配置时需要使用and
来替代"&&"
,同样,or
和not
可以分别用来替代"||"
和"!"
。
2.2 在切点中选择bean
可以使用Spring
的bean()
指示器,它允许我们在切点表达式中使用bean
的ID
来标识bean
。如下:
execution( * concert.Performance.perform(..)) and bean('woodstock')
execution( * concert.Performance.perform(..)) and !bean('woodstock')
说明:第一种表示在应用通知时,限定bean
的ID
为woodstock
。第二种表示可以限定为除了特定ID
以外的其他bean
引用通知。
三、使用注解创建切面
3.1 定义切面
使用注解来创建切面是AspectJ 5
所引入的关键特性。
说明:从上图中可以清晰看到如何使用注解来声明通知方法(注意:图中
execution
右边应该是一个星号,书中可能有误)。Spring
使用AspectJ
注解来声明通知方法有几种方式:
注解 | 通知 |
---|---|
@After |
通知方法会在目标方法返回或抛出异常后调用 |
@AfterReturning |
通知方法会在目标方法返回后调用 |
@AfterThrowing |
通知方法会在目标方法抛出异常后调用 |
@Around |
通知方法会将目标方法封装起来 |
@Before |
通知方法会在目标方法调用之前执行 |
可能会注意到,所有的这些注解都给定了一个切点表达式作为它的值,同时,这四个方法的切点表达式都是相同的。其实它们可以设置成不同的切点表达式,但是此处却是一样的。相同的切点表达式重复多次可不是很好,这里我们可以使用@Pointcut
注解定义一个重用的切点。
说明:首先定义了一个切点,这样就可以在任何切点表达式中使用
performance()
方法了,此方法的实际内容并不重要,只是供@Pointcut
注解依附。需要注意的是,除了注解和没有实际操作的performance()
方法,Audience
类依然是一个POJO
,可以像使用其他的Java
类那样调用它的方法,也能够独立的进行测试(注意:图中execution
右边应该是一个星号,书中可能有误)。还可以装配为Spring
中的bean
:
@Bean
public Audience audience(){
return new Audience();
}
但是如果就此止步的话,Audience
只会是Spring
容器中的一个bean
,即便使用了AspectJ
注解,但并不会被视为切面。如果使用JavaConfig
的话,需要使用@EnableAspectJAutoProxy
注解启用自动代理功能。
package concert;
import org.springframework.context.annotation.*;
@Configuration
@EnableAspectJAutoProxy/*启用AspectJ自动代理*/
@ComponentScan
public class ConcertConfig {
@Bean
public Audience andience(){
return new Audience();
}
}
而如果是在XML
中配置,则如下:
<context:component-scan base-package="concert" />
<aop:aspectj-autoproxy />
<bean class="concert.Audience" />
说明:Spring
的AspectJ
自动代理仅仅使用@AspectJ
作为创建切面的指导,切面依然是基于代理的。本质上,它依然是Spring
基于代理的切面。