面向切面编程(AOP)
什么是面向切面编程
AOP是面向切面的编程,其编程思想是把散布于不同业务但功能相同的代码从业务逻辑中抽取出来,封装成独立的模块,这些独立的模块被称为切面,切面的具体功能方法被称为关注点。在业务逻辑执行过程中,AOP会把分离出来的切面和关注点动态切入到业务流程中,这样做的好处是提高了功能代码的重用性和可维护性。
散布于应用中多处的功能被称为横切关注点
这些横切关注点在概念上是与应用中的业务逻辑相分离的。把这些横切关注点与业务逻辑相分离正是面向切面编程所要解决的问题
切面能帮我们模块化横切关注点这张图展现了一个被划分为模块的典型应用,每个模块的核心功能都是为特定业务领域提供服务,但是这些模块都需要类似的辅助功能,例如安全和事务管理。
切面提供了取代继承和委托的另一种可选方案,而且在很多场景下更清晰简洁,使用面向切面编程时,我们仍在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无需修改受影响的类,横切关注点可以被模块化为特殊的类,这些类被称为切面。这样做有两个好处,首先,现在每个关注点都集中在一个地方,而不是分散到多处代码中,其次模块服务更简洁,因为它们只包含主要关注点的代码,而次要关注点的代码被转移到切面中去了。
AOP术语
描述切点的常用术语有通知(advice),切点(pointcut),连接点(jionpoint)
通知(Advice)
Spring切面可以应用5种类型的通知
- 前置通知(before):在目标方法被调用之前调用通知功能
- 后置通知(after):在目标方法完成后调用通知,此时不会关心方法的输出结果
- 返回通知(after-returning):在目标方法成功执行后调用通知
- 异常通知(after-throwing):在目标方法抛出异常后调用通知
- 环绕通知(around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义行为
连接点(Joinpoint)
应用可能有数以千计的时机应用通知,这些时机被称为连接点 连接点是在应用执行过程中能够插入切面的一个点,这个点可以是调用方法时,抛出异常时,甚至修改一个字符时,切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为
切点(Pointcut)
一个切面并不需要通知应用的所有连接点,切点有助于缩小切面所通知的连接点的范围
如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”,切点的定义会匹配通知所要织入的一个或多个连接点,通常使用明确的类和方法名称或者是利用正责表达式定义所匹配的类和方法名称来指定这些切点,有些AOP框架允许创建动态的切点,来根据运行时的决策来决定是否运用通知
切面(Aspect)
切面时通知和切点的结合,通知和切点共同定义了切面的全部内容--它是什么,在何时和何处完成其功能
引入(Introduction)
引入允许我们向现有的类添加新方法或属性。例如,创建一个通知类Auditable,该类记录了对象最后一次修改时的状态,只需要一个方法setLastModified(Date),和一个实例变量来保存这个状态,然后这个新方法和实例变量就会被引入到现有的类中,从而在无需修改这些现有的类的情况下让它们具有性的行为和状态
织入(Weaving)
织入时把切面应用到目标对象并创建新的代理对象的过程,切面在指定的连接被织入到目标对象中。在目标对象的生命周期里有多个点可以进行织入
- 编译期: 切面在目标类编译时被织入 这种方式需要特殊的编译器,AspectJ的织入编译器就是以这种方式织入切面的
- 类加载期: 切面在目标类加载到JVM时被织入,这种方式需要特殊的类加载器,它可以在目标类被引入应用之前增强该目标类的字节码 AspectJ 5 的加载时织入就支持以这种方式织入切面
- 运行期: 切面在应用运行的某个时刻被织入 一般情况下 在织入切面时 AOP容器会为目标对象动态的创建一个代理对象 SpringAOP就是以这种方式织入切面中的
通知包含了需要用于多个应用对象的横切行为,连接点时程序执行过程中能够应用通知的所有点,切点定义了通知被应用的具体位置 其中关键时切点定义了那些连接点会得到通知
Spring对AOP的支持
Spring提供了4种类型的AOP支持
- 基于代理的经典SpringAOP
- 纯POJO切面
- @AspectJ注解驱动的切面
- 注入式AspectJ切面
前三种都是SpringAOP实现的变体,SpringAOP构建在动态代理基础之上,因此Spring对AOP的支持局限于方法拦截
借助Spring的aop命名空间 我们可以将纯POJO转化为切面,实际上这些POJO只是提供了满足切点条件时所需调用的方法,这种技术需要XML配置
Spring借鉴了AspectJ的切面,以提供注解驱动的AOP,本质上,它任然时Spring基于代理的AOP 但是编程模型几乎与编写成熟的AspectJ注解切面完全一致,这种AOP风格的好处在于能过不使用XML
如果AOP需求超过了简单的方法调用(如构造器或属性拦截),那么需要上文的第四种类型来将值注入到AspectJ驱动的切面中。
通过切点来选择连接点
在SpringAOP中,要使用AspectJ的切点表达式语言来定义切点
SpringAOP所支持的AspectJ切点指示器
arg() 限制连接点匹配参数为指定类型的执行方法
@args() 限制连接点匹配参数由指定注解标注的执行方法
execution() 用于匹配是连接点的执行方法
this() 限制连接点匹配AOP代理的bean引用为指定类型的类
target 限制连接点匹配目标对象为指定类型的类
@target() 限制连接点匹配特定的执行对象,这些对象对应的类要具有指定类型的注解
within() 限制连接点匹配指定的类型
@within() 限制连接点匹配指定注解所标注的类型(当使用Spring AOP时,方法定义在由指定的注解所标
注的类里)@annotation 限定匹配带有指定注解的连接点
在Spring中尝试使用AspectJ其他指示器时会抛出IllegalArgumentException异常
编写切点
定义一个Performance接口
public interface Performance
{
public void perform();
}
performance可以代表任何类型的现场表演,假设我们想编写performance的perform()方法触发的通知
我们使用exexution()指示器选择Performance的perform()方法 ,方法表达式以“*”开始,表明了我们不关心方法返回值的类型,然后我们制定了全限定类名和方法名,对于方法参数列表我们使用两个点号(..)表明切点要选择任意的perform()方法,无论该方法的入参是什么
现在假设需要配置的切点仅匹配concert包,在此场景下可以使用within()指示器来限制匹配
使用了“&&”操作符把execuntion()和within()指示器连接在一起形成与关系
Spring还引入了一个新的Bean()指示器,它允许我们在切点表达式中使用bean的ID来标识bean。bean()使用bean ID 或bean名称作为参数来限制切入点只匹配特定的bean
例
execution(* concert.Performance.perform()) and bean('woodstock')
这个示例表示我们希望在执行Performance的perform()方法时应用通知,但限定bean的ID为woodstock
我们还可以使用非操作为除了特定ID以外的其他bean应用通知
execution(* concert.Performance.perform()) and !bean('woodstock')
在这个示例中,切面的通知会被编织到所有ID不为woodstock的bean中
使用注解创建切面
例
@Aspect
public class Audience
{
//表演之前
@Before("execution(** concert.Performance.perform(..))")
public void silenceCellPhones()
{
System.out.println("Silencing cell phones");
}
//表演之前
@Before("execution(** concert.Performence.perform(..))")
public void takeSeats()
{
System.out.println("Taking seats");
}
//表演之后
@AfterReturning("execution(** concert.Performance.perform(..))")
public void applause()
{
System.out.println("CLAP CLAP CLAP!!!");
}
//表演失败之后
@AfterReturning("execution(** concert.Performance.perform(..))")
public void demandRefund()
{
System.out.println("Demanding a refund");
}
}
Audience类使用了@Aspect注解,表明Audience不仅是一个POJO,还是一个切面,类中的方法用来定义切面的具体行为
Audience有四个方法,定义了一个观众在观看演出时可能会做出的事情,在演出开始前,观众要就坐(takeseats)并将手机调到静音状态(silenceCellPhones)如果演出很精彩的话,观众应该会鼓掌(applause)如果演出没有达到观众的预期,就会要求退款(demandRefound)
@AspectJ提供了五个注解来定义通知
- @After 通知方法会在目标方法返回或抛出异常后调用
- @AfterReturning 通知方法会在目标方法返回后调用
- @AfterThrowing 通知方法会在目标方法抛出异常后调用
- @Around 通知方法会将目标方法封装起来
- @Before 通知方法会在目标方法调用之前执行
在上面的代码中,同一个切点表达式我们重复了四遍,我们可以只定义这个切点一次,然后每次需要的时候引用它,@Pointcut注解能够在一个@AspectJ切面内定义可重用的切点
@Aspect
public class Audience
{
@Pointcut("execution(** concert.performance.perform(..))")
public void performance(){}
//表演之前
@Before("performance()")
public void silenceCellPhones()
{
System.out.println("Silencing cell phones");
}
//表演之前
@Before("performance()")
public void takeSeats()
{
System.out.println("Taking seats");
}
//表演之后
@AfterReturning("performance()")
public void applause()
{
System.out.println("CLAP CLAP CLAP!!!");
}
//表演失败之后
@AfterReturning("performance()")
public void demandRefund()
{
System.out.println("Demanding a refund");
}
}
在JavaConfig中启动AspectJ的自动代理
@Configuration
@EnableAspectJAutoProxy //启用AspectJ自动代理
@ComponentScan
public class ConcertConfig
{
@Bean //声明Audience bean
public Audience audience()
{
return new Audience();
}
}
AspectJ自动代理会为使用@AspectJ注解的bean创建一个代理,这个代理会围绕着所有该切面的切点所匹配的bean,在这种情况下,将会为Concert bean创建一个代理,Audience类的通知方法将会在performance()方法调用前后执行
Spring的AspectJ自动代理仅仅使用@AspectJ作为创建切面的指导,切面仍然时基于代理的,在本质上它仍然是Spring基于代理的切面,这意味着尽管使用的是@AspectJ注解,但我们然让限于代理方法的调用,如果想用到AspectJ的全部能力,我们必须在运行时使用AspectJ并且不依赖Spring来创建基于代理的切面
创建环绕通知
环绕通知是最为强大的通知类型,它能够让你所编写的逻辑将被通知的目标方法完全包装起来,实际上就像是在一个通知方法中同时编写前置通知和后置通知。
使用环绕通知重新实现Audience切面
@Aspect
public class Audience
{
@Pointcut("execution(** concent.Performance.perform(..))")
public void perform(){}
@Around("performance()") //环绕通知方法
public void watchPerformance(ProceedingJoinPoint jp)
{
try
{
System.out.println("Silencing cell phones");
System.out.println("Taking seats");
jp.proceed();
System.out.println("CLAP CLAP CLAP!!!!");
}catch (Throwable e)
{
System.out.println("Demanding a refund");
}
}
}
在这里,@Around注解表明watchPerformance()方法会作为performance()切点的环绕通知,在这个通知中,观众会在演出之前将手机调到静音并就坐,演出结束后会鼓掌喝彩,如果演出失败的话,观众就会要求退款,
这个通知所达到的效果与前面的前置通知和后置通知是一样的,但是现在它们位于同一个方法中
这个新的通知方法接受ProceedingJoinPoint 作为参数,这个对象是必须要有的,因为需要在通知中通过它来调用被通知的方法,通知方法中可以做任何的事,当要要将控制权交给被通知的方法时,他需要调用proceed()方法,如果不调用这个方法的话,那么你的通知实际上会阻塞被通知方法的调用,同样的也可以在通知中对它进行多次调用,这样做可以实现在被通知的方法失败后进行多次尝试
处理通知中的参数
在前面的BlankDisc样例中,play()方法会循环所有磁道并调用playTrack()方法,我们也可以直接调用playTrack()方法来播放某一磁道中的歌曲
如果需要记录每个磁道被播放的次数,一种方法就是修改playTrack()方法,直接在每次调用的时候记录这个数量,但记录磁道的播放次数与播放本身是不同的关注点,因此不应该属于playTrack方法,这应该是切面要完成的任务
为了记录每个磁道所播放的次数,创建TrackCounter类,他是通知playTrack()方法的一个切面
@Aspect
public class TrackCounter
{
private Map<Integer, Integer> trackCounter = new HashMap<Integer,Integer>();
@Pointcut("execution(* soundsystem.CompactDisc.playTrack(int))" + "&& args(trackNumber)")
public void trackPlayed(int trackNumber){}
@Before("trackPlayed(trackNumber)") //在播放前为磁道计数
public void countTrack(int trackNumber)
{
int currentCount = getPlayCount(trackNumber);
trackCounts.put(trackNumber,currentCount + 1);
}
public int getPlayCount(int trackNumber)
{
return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
}
}
切点表达式中的args(trackNumber)限定符,它表明传递给playTrack()方法的int 类型参数也会传递到通知中去,参数的名称trackNumber也与切点方法签名中的参数相匹配
这个参数会传递到通知方法中-,这个通知方法是通过@Before注解和命名切点trackPlayed(trackNumber)定义的,切点定义中的参数与切点方法中的参数名称是一样的,这样就完成了从命名切点到通知方法的参数转移
通过注解引入新功能
public interface Encoreable
{
void performEncore();
}
@AspectJ
public class EncoreableIntroducer
{
@DeclareParents(value="concert.performance+",defaultImpl=DefaultEncoreable.class)
public static Encoreable encoreable;
}
这个切面并没有提供前置后置或环绕通知而是通过@DeclareParents注解将Encoreable接口引入到Performance bean中
@DeclareParents注解由三部分组成
- value 属性指定了那种类型的bean要引入该接口,在本例中是所有实现Performance的类型(标记符后面的加号表示是Performance的所有子类型)
- defaultImpl 属性指定了为引入功能提供实现的类 在这里我们指定的是DefaultEncoreable提供实现
- @DeclarParents注解所标注的静态属性指明了要引入接口 在这里引入的是Encoreable接口
和其他的切面一样,我们需要在Spring应用中将其声明为一个Bean
<bean class = "concert.EncoreableIntroducer"/>
Spring的自动代理机制将会获取到它的声明,当Spring发现一个bean使用了@Aspect注解时,就会为其创建一个代理,然后将调用委托给被代理的bean或被引入的实现,这取决于调用的方法属于被代理的bean还是属于被引入的接口
注入AspectJ切面
AspectJ提供了Spring AOP所不能支持的许多类型的切点,如 当需要在创建对象时应用通知,构造器切点就非常方便。Java构造器不同于其他的正常方法,这使得Spring基于代理的AOP无法把通知应用于对象的创建过程,对于大部分功能来讲,AspectJ切面与Spring是相互独立的,虽然它们可以织入到任意的Java应用中,这也包括了Spring应用,但是在应用AspectJ切面时几乎不会涉及到Spring,我们可以借助Spring的依赖注入把bean装配进AspectJ中
我们为上面的演出创建一个新的评论员切面
public aspect CriticAspect
{
public CriticAspect() {}
ponintcut performance() : execution(* perform(..));
afterReturning() : performance()
{
System.out.println(criticismEngine.getCriticism());
}
private CriticismEngine criticismEngine;
public void setCriticismEngine(CriticismEngine criticismEngine) //注入CriticismEngine
{
this.criticismEngine = criticismEngine;
}
}
CriticAspect的主要职责是在表演结束后为表演发表评论,performance()切点匹配perform()方法,当它与afterReturning()通知一起配合使用时,我们可以让该切面在表演结束时起作用
评论员并不自己发表评论,实际上,CriticAspect与一个CriticismEngine对象相协作,在表演结束时,调用该对象的getCriticism()方法来发表评论,为了避免产生耦合,我们通过setter依赖注入为CriticAspect设置CriticismEngine
要注入到CriticAspect中的CriticismEngine实现
public class CriticismEngineImpl impements CriticismEngine
{
public CriticismEngineImpl() {}
public String getCriticism()
{
int i = (int) (Math.random() * criticismPool.length);
return crtticismPool[i];
}
private String[] criticismPool;
public void setCriticismPool(String[] criticismPool)
{
this.criticismPool = criticismPool;
}
}
CriticismEngineImpl实现了CriticismEngine接口,通过从注入的评论池中随机选择一个苛刻的评论。这个类可以使用如下的XML声明为一个Spring bean。
到目前为止,一切顺利。我们现在有了一个要赋予CriticAspect的Criticism-Engine实 现。剩下的就是为CriticAspect装配CriticismEngineImple。
在展示如何实现注入之前,我们必须清楚AspectJ切面根本不需要Spring就可以织入到我们的应用中。如果想使用Spring的依赖注入为AspectJ切面注入协作者,那我们就需要在Spring配置中把切面声明为一个Spring配置中的<bean>。如下的<bean>声明会把
criticismEnginebean注入到CriticAspect中:
很大程度上,<bean>的声明与我们在Spring中所看到的其他<bean>配置并没有太多的区别, 但是最大的不同在于使用了factory-method属性。通常情况下,Spring bean由Spring容器初始化,但是AspectJ切面是由AspectJ在运行期创建的。等到Spring有机会为CriticAspect注入CriticismEngine时,CriticAspect已经被实例化了。
因为Spring不能负责创建CriticAspect,那就不能在 Spring中简单地把CriticAspect声明为一个bean。相反,我们需要一种方式为Spring获得已经由AspectJ创建的CriticAspect实 例的句柄,从而可以注入CriticismEngine。幸好,所有的AspectJ切面都提供了一个静态的aspectOf()方法,该方法返回切面的一个单例。所以为了获得切面的实例,我们必须使
用factory-method来调用asepctOf()方法而不是调用CriticAspect的构造器方法。
简而言之,Spring不能像之前那样使用<bean>声明来创建一个CriticAspect实例——它已 经在运行时由AspectJ创建完成了。Spring需要通过aspectOf()工厂方法获得切面的引用,然 后像<bean>元素规定的那样在该对象上执行依赖注入。