Spring实战之四面向切面的Spring

整理自《Spring实战》

Spring中AOP的使用方式主要有两种:注解式拦截与方法规则拦截。下面对方法规则拦截进行了介绍,关于注解式拦截,可查看SpringBoot —— AOP注解式拦截与方法规则拦截

四、面向切面的Spring

4.1 何为面向切面编程

4.1.1 专业术语

在使用面向切面编程时,我们仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无需修改受影响的类横切关注点可以被模块化为特殊的类,这些类被称为切面(aspect)。这样做有两个好处:首先,现在每个关注点都集中于一个地方,而不是分散到多处代码中;其次,服务模块更简洁,因为它们只包含主要关注点(或核心功能)的代码,而次要关注点的代码被转移到切面中了。

AOP术语:

  • 通知(Advice)切面必须要完成的工作
    Spring切面可以应用5种类型的通知
    前置通知(Before):在目标方法被调用之前调用通知功能;
    后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么;
    返回通知(After-returning):在目标方法成功执行之后调用通知;
    异常通知(After-throwing):在目标方法抛出异常后调用通知;
    环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
  • 连接点(Join point)在应用执行过程中能够插入切面的一个点。这个点可以是调用方法时、抛出异常时、甚至修改一个字段时。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。
  • 切点(Pointcut):一个切面并不需要通知应用的所有连接点。切点有助于缩小切面所通知的连接点的范围。切点的定义会匹配通知所要织入的一个或多个连接点。我们通常使用明确的类和方法名称,或是利用正则表达式定义所匹配的类和方法名称来指定这些切点。有些AOP框架允许我们创建动态的切点,可以根据运行时的决策(比如方法的参数值)来决定是否应用通知。
  • 切面(Aspect)通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和何处完成其功能。
  • 引入(Introduction)引入允许我们向现有的类添加新方法或属性。例如,我们可以创建一个Auditable通知类,该类记录了对象最后一次修改时的状态。这很简单,只需一个方法,setLastModified(Date),和一个实例变量来保存这个状态。然后,这个新方法和实例变量就可以被引入到现有的类中,从而可以在无需修改这些现有的类的情况下,让它们具有新的行为和状态。
  • 织入(Weaving)织入是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期里有多个点可以进行织入:
    编译期:切面在目标类编译时被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
    类加载期:切面在目标类加载到JVM时被织入。这种方式需要特殊的类加载器(ClassLoader),它可以在目标类被引入应用之前增强该目标类的字节码。AspectJ 5的加载时织入(load-time weaving,LTW)就支持以这种方式织入切面。
    运行期:切面在应用运行的某个时刻被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。Spring AOP就是以这种方式织入切面的
4.1.2 Spring对AOP的支持

并不是所有的AOP框架都是相同的,它们在连接点模型上可能有强弱之分。有些允许在字段修饰符级别应用通知,而另一些只支持与方法调用相关的连接点。它们织入切面的方式和时机也有所不同。但是无论如何,创建切点来定义切面所织入的连接点是AOP框架的基本功能。

Spring提供了4种类型的AOP支持:

  • 基于代理的经典Spring AOP;
  • 纯POJO切面;
  • @AspectJ注解驱动的切面;
  • 注入式AspectJ切面(适用于Spring各版本)。

前三种都是Spring AOP实现的变体,Spring AOP构建在动态代理基础之上,因此,Spring对AOP的支持局限于方法拦截

补充:因为Spring基于动态代理,所以Spring只支持方法连接点。这与一些其他的AOP框架是不同的,例如AspectJ和JBoss,除了方法切点,它们还提供了字段和构造器接入点。Spring缺少对字段连接点的支持,无法让我们创建细粒度的通知,例如拦截对象字段的修改。而且它不支持构造器连接点,我们就无法在bean创建时应用通知。

但是方法拦截可以满足绝大部分的需求。如果需要方法拦截之外的连接点拦截功能,那么我们可以利用Aspect来补充Spring AOP的功能。

4.2 通过切点来选择连接点

通知和切点是切面的最基本元素。因此,了解如何编写切点非常重要。

在Spring AOP中,要使用AspectJ的切点表达式语言来定义切点。

关于Spring AOP的AspectJ切点,最重要的一点就是Spring仅支持AspectJ切点指示器(pointcut designator)的一个子集。让我们回顾下,Spring是基于代理的,而某些切点表达式是与基于代理的AOP无关的。下表列出了Spring AOP所支持的AspectJ切点指示器。
在这里插入图片描述
在Spring中尝试使用AspectJ其他指示器时,将会抛出IllegalArgument-Exception异常。

另外,注意只有execution指示器是实际执行匹配的,而其他的指示器都是用来限制匹配的。这说明execution指示器是我们在编写切点定义时最主要使用的指示器。在此基础上,我们使用其他指示器来限制所匹配的切点。

4.2.1 编写切点
package concert;
public interface Performance {
	public void perform();
}

假设我们想编写Performance的perform()方法触发的通知。下面展现了一个切点表达式,这个表达式能够设置当perform()方法执行时触发通知的调用。
在这里插入图片描述
现在假设我们需要配置的切点仅匹配concert包。在此场景下,可以使用within()指示器来限制匹配:
在这里插入图片描述
请注意我们使用了“&&”操作符把execution()和within()指示器连接在一起形成与(and)关系(切点必须匹配所有的指示器)。类似地,我们可以使用“||”操作符来标识或(or)关系,而使用“!”操作符来标识非(not)操作。

4.2.2 在切点中选择bean

除了上面表所列的指示器外,Spring还引入了一个新的bean()指示器,它允许我们在切点表达式中使用bean的ID来标识bean。bean()使用bean ID或bean名称作为参数来限制切点只匹配特定的bean。如:
在这里插入图片描述
在这里,我们希望在执行Performance的perform()方法时应用通知,但限定bean的ID为woodstock。
在这里插入图片描述
在此场景下,切面的通知会被编织到所有ID不为woodstock的bean中。

4.3 使用注解创建切面

使用注解来创建切面是AspectJ 5所引入的关键特性。AspectJ 5之前,编写AspectJ切面需要学习一种Java语言的扩展,但是AspectJ面向注解模型可以非常简便地通过少量注解把任意类转变为切面。

我们已经定义了Performance接口,它是切面中切点的目标对象。现在,将观众定义为一个切面,并将其应用到演出上。

Audience类:观看演出的切面
在这里插入图片描述
上面相同的切点表达式我们重复了四遍。如果我们只定义这个切点一次,然后每次需要的时候引用它,那么这会是一个很好的方案。

@Pointcut注解能够在一个@AspectJ切面内定义可重用的切点
在这里插入图片描述
在Audience中,performance()方法使用了@Pointcut注解。为@Pointcut注解设置的值是一个切点表达式,就像之前在通知注解上所设置的那样。通过在performance()方法上添加@Pointcut注解,我们实际上扩展了切点表达式语言,这样就可以在任何的切点表达式中使用performance()了,如果不这样做的话,你需要在这些地方使用那个更长的切点表达式。我们现在把所有通知注解中的长表达式都替换成了performance()。

performance()方法的实际内容并不重要,在这里它实际上应该是空的。其实该方法本身只是一个标识,供@Pointcut注解依附。

需要注意的是,除了注解和没有实际操作的performance()方法,Audience类依然是一个POJO。我们能够像使用其他的Java类那样调用它的方法,它的方法也能够独立地进行单元测试,这与其他的Java类并没有什么区别。Audience只是一个Java类,只不过它通过注解表明会作为切面使用而已。

像其他的Java类一样,它可以装配为Spring中的bean:

@Bean
public Audience audience() {
	return new Audience();
}

如果你就此止步的话,Audience只会是Spring容器中的一个bean。即便使用了AspectJ注解,但它并不会被视为切面,这些注解不会解析,也不会创建将其转换为切面的代理。

如果你使用JavaConfig的话,可以在配置类的类级别上通过使用EnableAspectJ-AutoProxy注解启用自动代理功能。程序4.3展现了如何在JavaConfig中启用自动代理。
在这里插入图片描述

假如你在Spring中要使用XML来装配bean的话,那么需要使用Springaop命名空间中的<aop:aspectj-autoproxy>元素。

我们需要记住的是,Spring的AspectJ自动代理仅仅使用@AspectJ作为创建切面的指导,切面依然是基于代理的。在本质上,它依然是Spring基于代理的切面。这一点非常重要,因为这意味着尽管使用的是@AspectJ注解,但我们仍然限于代理方法的调用。如果想利用AspectJ的所有能力,我们必须在运行时使用AspectJ并且不依赖Spring来创建基于代理的切面。

上面切面再定义时,使用了不同的通知方法来实现前置和后置通知。环绕通知与其他类型的通知有所不同,因此值得花点时间来介绍如何进行编写。

4.3.2 创建环绕通知

在这里插入图片描述
关于这个新的通知方法,你首先注意到的可能是它接受ProceedingJoinPoint作为参数。这个对象是必须要有的,因为你要在通知中通过它来调用被通知的方法。通知方法中可以做任何的事情,当要将控制权交给被通知的方法时,它需要调用ProceedingJoinPoint的proceed()方法。如果忘记调用proceed()方法,那么你的通知实际上会阻塞对被通知方法的调用。

4.3.3 处理通知中的参数

到目前为止,我们的切面都很简单,没有任何参数。唯一的例外是我们为环绕通知所编写的watchPerformance()示例方法中使用了ProceedingJoinPoint作为参数。除了环绕通知,我们编写的其他通知不需要关注传递给被通知方法的任意参数。这很正常,因为我们所通知的perform()方法本身没有任何参数。

但是,如果切面所通知的方法确实有参数该怎么办呢?切面能访问和使用传递给被通知方法的参数吗

为了阐述这个问题,让我们重新看一下2.4.4小节中的BlankDisc样例。play()方法会循环所有的磁道并调用playTrack()方法。但是,我们也可以通过playTrack()方法直接播放某一个磁道中的歌曲。

为了记录每个磁道所播放的次数,我们创建了TrackCounter类,它是通知playTrack()方法的一个切面。
在这里插入图片描述
这里的不同点在于切点还声明了要提供给通知方法的参数。
在这里插入图片描述
在图4.6中需要关注的是切点表达式中的args(trackNumber)限定符。它表明传递给playTrack()方法的int类型参数也会传递到通知中去。参数的名称trackNumber也与切点方法签名中的参数相匹配。

这个参数会传递到通知方法中,这个通知方法是通过@Before注解和命名切点trackPlayed(trackNumber)定义的。切点定义中的参数与切点方法中的参数名称是一样的,这样就完成了从命名切点到通知方法的参数转移。

测试:
在这里插入图片描述
在这里插入图片描述

4.3.4 通过注解引入新功能

利用被称为引入的AOP概念,切面可以为Spring bean添加新方法

在Spring中,切面只是实现了它们所包装bean相同接口的代理。如果除了实现这些接口,代理也能暴露新接口的话,会怎么样呢?那样的话,切面所通知的bean看起来像是实现了新的接口,即便底层实现类并没有实现这些接口也无所谓。下图展示了它们是如何工作的。
在这里插入图片描述
我们需要注意的是,当引入接口的方法被调用时,代理会把此调用委托给实现了新接口的某个其他对象。实际上,一个bean的实现被拆分到了多个类中。

为了验证该主意能行得通,我们为示例中的所有的Performance实现引入下面的Encoreable接口:

package concert;
public interface Encorable {
	void performEncore();
}

访问Performance的所有实现并对其进行修改,让它们都实现Encoreable接口是不现实的。值得庆幸的是,借助于AOP的引入功能,我们可以不必在设计上妥协或者侵入性地改变现有的实现。为了实现该功能,我们要创建一个新的切面:
在这里插入图片描述
可以看到,EncoreableIntroducer是一个切面,通过@DeclareParents注解,将Encoreable接口引入到Performance bean中
@DeclareParents注解由三部分组成:

  • value属性指定了哪种类型的bean要引入该接口。在本例中,也就是所有实现Performance的类型。(标记符后面的加号表示是Performance的所有子类型,而不是Performance本身。)
  • defaultImpl属性指定了为引入功能提供实现的类。在这里,我们指定的是DefaultEncoreable提供实现。
  • @DeclareParents注解所标注的静态属性指明了要引入了接口。在这里,我们所引入的是Encoreable接口。

和其他的切面一样,我们需要在Spring应用中将EncoreableIntroducer声明为一个bean:

<bean class="concert.EncoreableIntroducer"/>

Spring的自动代理机制将会获取到它的声明,当Spring发现一个bean使用了@Aspect注解时,Spring就会创建一个代理,然后将调用委托给被代理的bean或被引入的实现,这取决于调用的方法属于被代理的bean还是属于被引入的接口

在Spring中,注解和自动代理提供了一种很便利的方式来创建切面。它非常简单,并且只涉及到最少的Spring配置。但是,面向注解的切面声明有一个明显的劣势:你必须能够为通知类添加注解。为了做到这一点,必须要有源码。
如果你没有源码的话,或者不想将AspectJ注解放到你的代码之中,Spring为切面提供了另外一种可选方案。让我们看一下如何在SpringXML配置文件中声明切面。

通过注解为类中引入新方法这部分可参考:spring-AOP通过注解@DeclareParents引入新的方法

4.4 在XML中声明切面

如果你需要声明切面,但是又不能为通知类添加注解的时候,那么就必须转向XML配置了。

在Spring的aop命名空间中,提供了多个元素用来在XML中声明切面,如下表所示。
在这里插入图片描述
在这里插入图片描述
我们重新看一下Audience类,这一次我们将它所有的AspectJ注解全部移除掉。这时它就是有几个方法的简单Java类。我们可以像其他类一样把它注册为Spring应用上下文中的bean。

4.4.1 声明前置和后置通知

在这里插入图片描述
关于Spring AOP配置元素,第一个需要注意的事项是大多数的AOP配置元素必须在<aop:config>元素的上下文内使用。这条规则有几种例外场景,但是把bean声明为一个切面时,我们总是从<aop:config>元素开始配置的。

上面四个方法都应用到了相同的切点上,在基于XML的切面声明中,我们可以使用<aop:pointcut>元素来消除这种重复性。
在这里插入图片描述
现在切点是在一个地方定义的,并且被多个通知元素所引用。<aop:pointcut>元素定义了一个id为performance的切点。同时修改所有的通知元素,用pointcut-ref属性来引用这个命名切点。

正如上面程序所示,<aop:pointcut>元素所定义的切点可以被同一个<aop:aspect>元素之内的所有通知元素引用。如果想让定义的切点能够在多个切面使用,我们可以把<aop:pointcut>元素放在<aop:config>元素的范围内。

4.4.2 声明环绕通知

目前Audience的实现工作得非常棒,但是前置通知和后置通知有一些限制。具体来说,如果不使用成员变量存储信息的话,在前置通知和后置通知之间共享信息非常麻烦

例如,假设除了进场关闭手机和表演结束后鼓掌,我们还希望观众确保一直关注演出,并报告每个参赛者表演了多长时间。使用前置通知和后置通知实现该功能的唯一方式是在前置通知中记录开始时间并在某个后置通知中报告表演耗费的时间。但这样的话我们必须在一个成员变量中保存开始时间。因为Audience是单例的,如果像这样保存状态的话,将会存在线程安全问题。

相对于前置通知和后置通知,环绕通知在这点上有明显的优势。使用环绕通知,我们可以完成前置通知和后置通知所实现的相同功能,而且只需要在一个方法中 实现。因为整个通知逻辑是在一个方法内实现的,所以不需要使用成员变量保存状态。
在这里插入图片描述
声明环绕通知与声明其他类型的通知并没有太大区别。我们所需要做的仅仅是使用<aop:around>元素。
在这里插入图片描述

4.4.3 为通知传递参数

在4.3.3小节中,我们使用@AspectJ注解创建了一个切面,这个切面能够记录CompactDisc上每个磁道播放的次数。现在,我们使用XML来配置切面,那就看一下如何完成这一相同的任务。首先移除掉TrackCounter上所有的@AspectJ注解。
在这里插入图片描述
借助一点Spring XML配置,我们能够让TrackCounter重新变为切面。我们在下面这个配置中声明了TrackCounter bean和BlankDisc bean,并将TrackCounter转化为切面。
在这里插入图片描述
可以看到,我们使用了和前面相同的aop命名空间XML元素,它们会将POJO声明为切面。唯一明显的差别在于切点表达式中包含了一个参数,这个参数会传递到通知方法中。如果你将这个表达式与程序清单4.6中的表达式进行对比会发现它们几乎是相同的。唯一的差别在于这里使用and关键字而不是“&&”(因为在XML中,“&”符号会被解析为实体的开始)。

4.4.4 通过切面引入新的功能

使用Spring aop命名空间中的<aop:declare-parents>元素,我们可以实现与AspectJ中@DeclareParents注解相同的功能。
在这里插入图片描述
顾名思义,<aop:declare-parents>声明了此切面所通知的bean要在它的对象层次结构中拥有新的父类型。具体到本例中,类型匹配Performance接口(由types-matching属性指定)的那些bean在父类结构中会增加Encoreable接口(由implementinterface属性指定)。最后要解决的问题是Encoreable接口中的方法实现要来自于何处。

这里有两种方式标识所引入接口的实现。在本例中,我们使用default-impl属性用全限定类名来显式指定Encoreable的实现。或者,我们还可以使用delegate-ref属性来标识。
在这里插入图片描述
delegate-ref属性引用了一个Spring bean作为引入的委托。这需要在Spring上下文中存在一个ID为encoreableDelegate的bean。
在这里插入图片描述
使用default-impl来直接标识委托和间接使用delegate-ref的区别在于后者是Spring bean,它本身可以被注入、通知或使用其他的Spring配置。

4.5 注入AspectJ切面

虽然Spring AOP能够满足许多应用的切面需求,但是与AspectJ相比,Spring AOP 是一个功能比较弱的AOP解决方案。AspectJ提供了Spring AOP所不能支持的许多类型的切点。

例如,当我们需要在创建对象时应用通知,构造器切点就非常方便。不像某些其他面向对象语言中的构造器,Java构造器不同于其他的正常方法。这使得Spring基于代理的AOP无法把通知应用于对象的创建过程

对于大部分功能来讲,AspectJ切面与Spring是相互独立的。虽然它们可以织入到任意的Java应用中,这也包括了Spring应用,但是在应用AspectJ切面时几乎不会涉及到Spring。

但是精心设计且有意义的切面很可能依赖其他类来完成它们的工作。如果在执行通知时,切面依赖于一个或多个类,我们可以在切面内部实例化这些协作的对象。但更好的方式是,我们可以借助Spring的依赖注入把bean装配进AspectJ切面中。

为了演示,我们为上面的演出创建一个新切面。具体来讲,我们以切面的方式创建一个评论员的角色,他会观看演出并且会在演出之后提供一些批评意见。下面的CriticAspect就是一个这样的切面。
在这里插入图片描述
上面CriticAspect与一个CriticismEngine对象相协作,在表演结束时,调用该对象的getCriticism()方法来发表一个苛刻的评论。为了避免CriticAspect和CriticismEngine之间产生不必要的耦合,我们通过Setter依赖注入为CriticAspect设置CriticismEngine。

CriticismEngine自身是声明了一个简单getCriticism()方法的接口。下面为CriticismEngine的实现。
在这里插入图片描述
CriticismEngineImpl类通过如下XML声明为一个Spring bean:
在这里插入图片描述
我们现在已经有了一个要赋予CriticAspect的Criticism-Engine实现。剩下的就是为CriticAspect装配CriticismEngineImple。

在展示如何实现注入之前,我们必须清楚AspectJ切面根本不需要Spring就可以织入到我们的应用中。如果想使用Spring的依赖注入为AspectJ切面注入协作者,那我们就需要在Spring配置中把切面声明为一个Spring配置中的<bean>。如下的<bean>声明会把criticismEnginebean注入到CriticAspect中:
在这里插入图片描述
很大程度上,<bean>的声明与我们在Spring中所看到的其他<bean>配置并没有太多的区别,但是最大的不同在于使用了factorymethod属性通常情况下,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>元素规定的那样在该对象上执行依赖注入。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值