【Spring实战】面向切面编程

本章内容:

  • 面向切面编程的基本原理
  • 通过POJO创建切面
  • 使用@AspectJ注解
  • 为AspectJ切面注入依赖

软件系统中的一些功能需要用到应用程序的多个地方,但是我们又不想在每个点都明确调用它们。日志、安全和事务管理的确都很重要,但它们不应是应用对象主动参与的行为,应该要让应用对象只关注于自己所针对的业务领域问题,其他方面的问题由其他应用对象来处理。

在软件开发中,散布于应用中多处的功能被称为横切关注点(cross-cutting concern)。通常,这些横切关注点从概念上与应用的业务逻辑分离的(但是往往会直接嵌入到应用的业务逻辑之中)。把这些横切关注点与业务逻辑相分离正是面向切面编程(AOP)所要解决的问题。

装配Bean介绍了如何使用依赖注入(DI)管理和配置我们的应用对象。DI有助于应用对象之间的解耦,而AOP可以实现横切关注点与它们所影响的对象之间的解耦

本章展示了Spring对切面的支持,包括如何把普通类声明为一个切面和如何使用注解创建切面。除此之外,还会看到AspectJ(另一种流行的AOP实现)如何补充AOP框架的功能。

什么是面向切面编程

切面能帮助我们模块化横切关注点。横切关注点可以被描述为影响应用多处的功能。下图呈现了横切关注点的概念:

切面实现了横切关注点(跨多个应用对象的逻辑)的模块化

上图展现了一个被划分为模块的典型应用。每个模块的核心功能都是为特定业务领域提供服务,但是这些模块都需要类似的辅助功能。

重用通用功能最常见的面向对象技术是继承(inheritance)或委托(delegation)。但是,如果在整个应用中都使用相同的基类,继承往往会导致一个脆弱的对象体系;而使用委托可能需要对委托对象进行复杂的调用。

切面提供了取代继承和委托的另一种可选方案,而且在很多场景下更清晰简洁。在使用面向切面编程时,仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,不需要修改受影响的类。横切关注点可以被模块化为特殊的类,这些类被称为切面(aspect)。

这样做有两个好处:

  1. 现在每个关注点都集中于一个地方,而不是分散到多处代码中;
  2. 服务模块更简洁,因为它们只包含主要关注点(或核心功能)的代码,而次要关注点的代码被转移到切面中了。
定义AOP术语

描述切面的常用术语有通知(advice)、切点(pointcut)和连接点(join point)。下图展示了这些概念是如何关联在一起的:

在一个或多个连接点上,可以把切面的功能(通知)织入到程序的执行过程中
通知(Advice)

在AOP术语中,切面要完成的工作被称为通知。

通知定义了切面是什么以及何时使用。除了描述切面要完成的工作,通知还解决了何时执行这个工作的问题。

Spring切面可以应用5种类型的通知:

  • 前置通知(Before):在目标方法被调用之前调用通知功能;
  • 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么;
  • 返回通知(After-returning):在目标方法成功执行之后调用通知;
  • 异常通知(After-throwing):在目标方法抛出异常后调用通知;
  • 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。
连接点(Join point)

应用可能有数以千计的时机应用通知。这些时机被称为连接点。连接点是在应用执行过程中能够插入切面的一个点。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。

切点(Poincut)

一个切面并不需要通知应用的所有连接点。切点有助于缩小切面所通知的连接点的范围。

如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”。切点的定义会匹配通知所要织入的一个或多个连接点。通常使用明确的类和方法名称,或是利用正则表达式定义所匹配的类和方法名称来指定这些切点。有些AOP框架允许创建动态的切点,可以根据运行时的决策来决定是否应用通知。

切面(Aspect)

切面是通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和何处完成其功能。

引入(Introduction)

引入允许向现有的类添加新方法或属性。从而可以在无需修改这些现有的类的情况下,让它们具有新的行为和状态。

织入(Weaving)

织入是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期里有多个点可以进行织入:

  • 编译期:切面在目标类编译时被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。
  • 类加载期:切面在目标类加载到JVM时被织入。这种方式需要特殊的类加载器(ClassLoader),它可以在目标类被引入应用之前增强该目标类的字节码。AspectJ 5的加载时织入(load-time weaving,LTW)就支持以这种方式织入切面。
  • 运行期:切面在应用运行的某个时刻被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。Spring AOP就是以这种方式织入切面的。
Spring对AOP的支持

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

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

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

Spring的经典AOP编程模型曾经的确很棒,但是现在Spring提供了更简洁和干净的面向切面编程方式。。引入了简单的声明式AOP和基于注解的AOP之后,Spring经典的AOP看起来就显得非常笨重和过于复杂,直接使用ProxyFactory Bean会让人感觉厌烦。

借助Spring的aop命名空间,可以将纯POJO转换为切面。实际上这些POJO只是提供了满足切点条件时所要调用的方法。这种技术需要XML配置

Spring借鉴了AspectJ的切面,以提供注解驱动的AOP。本质上依然是Spring基于代理的AOP,但是编程模型几乎与编写成熟的AspectJ注解切面完全一致。这种AOP风格的好处在于能够不使用XML来完成功能

如果AOP需求超过了简单的方法调用,那么需要考虑使用AspectJ来实现切面。注入式AspectJ切面能够帮助你将值注入到AspectJ驱动的切面中。

开始学习Spring AOP技术之前,必须要了解Spring AOP框架的一些关键知识。

Spring通知是Java编写的

Spring所创建的通知都是用标准的Java类编写的。这样就可以使用与普通Java开发一样的集成开发环境(IDE)来开发切面。定义通知所应用的切点通常会使用注解或在Spring配置文件里采用XML来编写。

AspectJ与之相反。AspectJ最初是以Java语言扩展的方式实现的。

Spring在运行时通知对象

通过在代理类中包裹切面,Spring在运行期把切面织入到Spring管理的bean中。如下图所示:

Spring的切面由包裹了目标对象的代理类实现。代理类处理方法的调用,执行额外的切面逻辑,并调用目标方法

代理类封装了目标类,并拦截被通知方法的调用,再把调用转发给真正的目标bean。当代理拦截到方法调用时,在调用目标bean方法之前,会执行切面逻辑。

直到应用需要被代理的bean时,Spring才创建代理对象。如果使用的是ApplicationContext的话,在ApplicationContext从BeanFactory中加载所有bean的时候,Spring才会创建被代理的对象。

Spring只支持方法级别的连接点

通过使用各种AOP方案可以支持多种连接点模型。因为Spring基于动态代理,所以Spring只支持方法连接点。Spring缺少对字段连接点的支持,无法让我们创建细粒度的通知,例如拦截对象字段的修改。而且它不支持构造器连接点,我们就无法在bean创建时应用通知。

方法拦截可以满足绝大部分的需求。其他连接点拦截功能可以利用Aspect来补充Spring AOP的功能。

通过切点来选择连接点

切点用于准确定位应该在什么地方应用切面的通知。

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

是Spring仅支持AspectJ切点指示器(pointcut designator)的一个子集。因为Spring是基于代理的,而某些切点表达式是与基于代理的AOP无关的。

下表列出了Spring AOP所支持的AspectJ切点指示器:

Spring借助AspectJ的切点表达式语言来定义Spring切面

尝试使用AspectJ其他指示器时,将会抛出IllegalArgument-Exception异常。

这些Spring支持的指示器,只有execution指示器是实际执行匹配的,而其他的指示器都是用来限制匹配的。

编写切面

定义一个Performance接口:

package concert;

public interface Performance{
    public void perform();
}

Performance可以代表任何类型的现场表演。假设想编写Performance的perform()方法触发的通知。下图展现了一个切点表达式,这个表达式设置当perform()方法执行时触发通知的调用。

使用AspectJ切点表达式来选择Performance的perform()方法

我们使用execution()指示器选择Performance的perform()方法。方法表达式以“*”号开始,表明不关心方法返回值的类型。然后,指定了全限定类名和方法名。对于方法参数列表,使用两个点号(..)表明切点选择任意的perform()方法,无论该方法的入参是什么。

假设需要配置的切点仅匹配concert包。可以使用within()指示器来限制匹配:

使用within()指示器限制切点范围

注意使用了“&&”操作符把execution()和within()指示器连接在一起形成与(and)关系。类似地,可以使用“||”操作符来标识或(or)关系,使用“!”操作符来标识非(not)操作。

由于“&”在XML中有特殊含义,在Spring的XML配置里面描述切点时,可以使用and来代替“&&”。同样,or和not可以分别用来代替“||”和“!”。

在切点中选择bean

除了上表所示的AspectJ指示器,Spring还引入了一个新的bean()指示器,它允许我们在切点表达式中使用bean的ID来标识bean。bean()使用Bean ID或bean名称作为参数来限制切点只匹配特定的bean。

例如:

excution(* concert.Performance.perform()) 
    and bean('woodstock')

在执行Performance的perform()方法时应用通知,限定的bean的ID为woodstock。

还可以使用非操作除了特定ID以外的其他bean应用通知:

excution(* concert.Performance.perform()) 
    and !bean('woodstock')

切面的通知会被编织到所有ID不为woodstock的bean中。

使用注解创建切面

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

定义切面

从演出的角度来看,观众非常重要,但是对演出本身的功能来讲,它并不是核心,这是一个单独的关注点。因此,将观众定义为一个切面,并将其应用到演出上就是较为明智的做法。

定义一个Audience类,它定义了我们所需的一个切面:

Audience切面定义

Audience类使用@AspectJ注解进行了标注。该注解表明Audience是一个切面。Audience类中的方法都使用注解来定义切面的具体行为。

Audience有四个方法,定义了一个观众在观看演出时可能会做的事情。这些方法都使用了通知注解来表明它们应该在什么时候调用。AspectJ提供了五个注解来定义通知,如下表所示:

使用AspectJ注解来声明通知方法

Audience使用到了前面五个注解中的三个。takeSeats()silenceCellPhones()方法都用到了@Before注解,表明它们应该在演出开始之前调用。applause()方法使用了@AfterReturning注解,它会在演出成功返回后调用。demandRefund()方法上添加了@AfterThrowing注解,这表明它会在抛出异常以后执行。

代码中所有的这些注解都给定了一个切点表达式作为它的值,这四个方法的切点表达式都是相同的。如果我们只定义这个切点一次,然后每次需要的时候引用它,效果可能会更好。

@Pointcut注解能够在一个@AspectJ切面内定义可重用的切点

通过@Pointcut注解声明频繁使用的切点表达式

在Audience中,performance()方法使用了@Pointcut注解。为@Pointcut注解设置的值是一个切点表达式。通过在performance()方法上添加@Pointcut注解,扩展了切点表达式语言,这样就可以在任何的切点表达式中使用performance()。

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

需要注意的是,除了注解和没有实际操作的performance()方法,Audience类依然是一个POJO。能够像使用其他的Java类那样调用它的方法,它的方法也能够独立地进行单元测试。

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

@Bean

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

但是目前Audience仍然只是Spring容器中的一个bean。即便使用了AspectJ注解,也并不会被视为切面,这些注解不会解,也不会创建将其转换为切面的代理。

Java Config启用AspectJ注解的自动代理

暂时略过

XML启用AspectJ自动代理

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

在XML中,通过aop命名空间启用AspectJ自动代理

AspectJ自动代理会为使用@Aspect注解的bean创建一个代理,这个代理会围绕着所有该切面的切点所匹配的bean。

,Spring的AspectJ自动代理仅仅使用@Aspect作为创建切面的指导,切面依然是基于代理的。本质上依然是Spring基于代理的切面。

创建环绕通知

环绕通知是最为强大的通知类型。它能够让你所编写的逻辑将 被通知的目标方法完全包装起来。就像在一个通知方法中同时编写前置通知和后置通知。

重新实现Audience切面:

环绕通知Audience

@Around注解表明watchPerformance()方法会作为performance()切点的环绕通知。在这个通知中,观众在演出之前会将手机调至静音并就坐,演出结束后会鼓掌喝彩。如果演出失败的话,观众会要求退款。

新的通知方法接受ProceedingJoinPoint作为参数,这个对象是必要的,因为要在通知中通过它来调用被通知的方法。通知方法中当要将控制权交给被通知的方法时,调用ProceedingJoinPoint的proceed()方法。

如果不调proceed()方法,通知实际上会阻塞对被通知方法的调用。

处理通知中的参数

目前为止编写的切面都很简单,没有任何参数。如果切面所通知的方法确实有参数该怎么办呢?切面怎么访问和使用传递给被通知方法的参数?

重新看一下装配Bean章节的BlankDisc样例:

package soundsystem.properties;

import java.util.List;

import soundsystem.CompactDisc;

public class BlankDisc implements CompactDisc {

  private String title;
  private String artist;
  private List<String> tracks;

  public void setTitle(String title) {
    this.title = title;
  }

  public void setArtist(String artist) {
    this.artist = artist;
  }

  public void setTracks(List<String> tracks) {
    this.tracks = tracks;
  }

  public void play() {
    System.out.println("Playing " + title + " by " + artist);
    for (String track : tracks) {
      System.out.println("-Track: " + track);
    }
  }

}

play()方法会循环所有的磁道并调用playTrack()方法。但是,我们也可以创建一个playTrack()方法直接播放某一个磁道中的歌曲。

假设想记录每个磁道被播放的次数。一种方法就是修改playTrack()方法,直接在每次调用的时候记录这个数量。但是,记录磁道的播放次数与播放本身是不同的关注点,因此不应该属于playTrack()方法。这应该是切面要完成的任务。

为了记录每个磁道所播放的次数,创建了TrackCounter类,它是是通知playTrack()方法的一个切面:

使用参数化的通知来记录磁道播放的次数

切面使用@Pointcut注解定义命名的切点,并使用@Before将一个方法声明为前置通知。这里的不同点在于切点还声明了要提供给通知方法的参数。

在切点表达式中声明参数,这个参数传入到通知方法中

需要关注的是切点表达式中的args(trackNumber)限定符。它表明传递给playTrack()方法的int类型参数也会传递到通知中去。参数的名称trackNumber也与切点方法签名中的参数相匹配。

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

现在,可以在Spring配置中将BlankDisc和TrackCounter定义为bean,并启用AspectJ自动代理:

配置TrackCount记录每个磁道播放的次数

编写一个简单测试。播放几个磁道并通过TrackCounter断言播放的数量。

测试TrackCounter切面

目前为止,所使用的切面中,所包装的都是被通知对象的已有方法。方法包装仅仅是切面所能实现的功能之一。接下来看如何通过编写切面,为被通知的对象引入全新的功能。

通过注解引入新功能

我们还没有为对象增加任何新的方法,但是已经为对象拥有的方法添加了新功能。如果切面能够为现有的方法增加额外的功能,为什么不能为一个对象增加新的方法呢?利用被称为引入的AOP概念,切面可以为Spring bean添加新方法。

在Spring中,切面只是实现了它们所包装bean相同接口的代理。如果除了实现这些接口,代理也能暴露新接口的话,切面所通知的bean看起来像是实现了新的接口,即便底层实现类并没有实现这些接口也无所谓。

使用Spring AOP,可以为bean引入新的方法。代理拦截调用并委托给实现该方法的其他对象

当引入接口的方法被调用时,代理会把此调用委托给实现了新接口的某个其他对象。实际上,一个bean的实现被拆分到了多个类中。

为了验证这个思路,为示例中的所有的Performance实现引入下面的Encoreable接口:

package concert;

public interface Encoreable {
    void performEncore();
}

需要有一种方式将这个接口应用到Performance实现中。现在假设能够访问Performance的所有实现,并对其进行修改,让它们都实现Encoreable接口。从设计的角度来看,这并不是最好的做法,并不是所有的Performance都是具有Encoreable特性的。另外一方面,有可能无法修改所有的Performance实现。

借助于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注解和自动代理提供了一种很便利的方式来创建切面。但是面向注解的切面声明有一个明显的劣势:必须能够为通知类添加注解。为了做到这一点,必须要有源码。

如果没有源码的话,或者不想将AspectJ注解放到代码之中,Spring为切面提供了另外一种可选方案。

在XML中声明切面

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

在Spring的aop命名空间中,提供了多个元素用来在XML中声明切面。

image.png

aop命名空间的其他元素能够直接在Spring配置中声明切面,而不需要使用注解。

现在将Audience类的所有AspectJ注解全部移除掉:

尽管看起来并没有什么差别,但Audience已经具备了成为AOP通知的所有条件。

声明前置和后置通知

使用Spring aop命名空间中的一些元素,将没有注解的Audience类转换为切面:

通过XML将无注解的Audience声明为切面

在<aop:config>元素内,可以声明一个或多个通知器、切面或者切点。在上面的例子中,使用<aop:aspect>元素声明了一个简单的切面。ref元素引用了一个POJO bean,该bean实现了切面的功能。ref元素所引用的bean提供了在切面中通知所调用的方法。

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

示例切面应用了四个不同的通知。两个<aop:before>元素定义了匹配切点的方法执行之前调用前置通知方法(由method属性声明)。<aop:after-returning>元素定义了一个返回(after-returning)通知,在切点所匹配的方法调用之后再调用后置通知方法。<aop:after-throwing>元素定义了异常(after-throwing)通知,如果所匹配的方法执行时抛出任何的异常,都将调用demandRefund()方法。下图展示了通知逻辑如何织入到业务逻辑中:

Audience切面包含四种通知,它们把通知逻辑织入进匹配切面切点的方法中

在所有的通知元素中,pointcut属性定义了通知所应用的切点,它的值是使用AspectJ切点表达式语法所定义的切点。

在基于AspectJ注解的通知中,当发现这种类型的重复时,我们使用@Pointcut注解消除了这些重复的内容。在基于XML的切面声明中,需要使用<aop:pointcut>元素。

如下的XML展现了如何将通用的切点表达式抽取到一个切点声明中,,这样这个声明就能在所有的通知元素中使用了。

使用<aop:pointcut>定义命名切点

现在切点在一个地方定义的,并被多个通知元素引用。<aop:pointcut>元素定义了一个id为performance的切点。同时修改所有的通知元素,用 pointcut-ref 属性来引用这个命名切点。

声明环绕通知

前置通知和后置通知有一些限制。如果不使用成员变量存储信息,在前置通知和后置通知之间共享信息会非常麻烦。

希望并报告每个节目表演了多长时间。使用前置通知和后置通知实现该功能的唯一方式是在前置通知中记录开始时间并在某个后置通知中报告表演耗费的时间。这样的话我们必须在一个成员变量中保存开始时间。因为Audience是单例的,如果像这样保存状态的话,将会存在线程安全问题。

环绕通知相比于前置通知和后置通知在这点上有明显的优势,使用环绕通知,我们可以完成前置通知和后置通知所实现的相同功能,且只需要在一个方法中实现。由于整个通知逻辑是在一个方法内实现的,所以不需要使用成员变量保存状态。

修改watchPerformance类:

watchPerformance()方法提供了AOP环绕通知

在切面中,watchPerformance()方法包含了之前四个通知方法的所有功能。且所有的功能都放在了这一个方法中,因此这个方法还要负责自身的异常处理。

声明环绕通知所需要做的仅仅是使用<aop:around>元素。

在XML中使用<aop:around>元素声明环绕通知
使用XML为通知传递参数

使用XML来配置切面,看一下如何完成这个任务。

首先,要移除掉TrackCounter上所有的@AspectJ注解。

无注解的TrackCounter

去掉@AspectJ注解后,除非显式调用countTrack()方法,否则TrackCounter不会记录磁道播放的数量。借助一点Spring XML配置,能够让TrackCounter重新变为切面。

如下的程序展现了完整的Spring配置,在这个配置中声明了TrackCounter bean和BlankDisc bean,并将TrackCounter转化为切面:

在XML中将TrackCounter配置为参数化的切面

切点表达式中包含了一个参数,这个参数会传递到通知方法中。(不使用“&&”是因为在XML中,“&”符号会被解析为实体的开始)。

通过切面引入新的功能

AOP引入并不是AspectJ特有的。使用Spring aop命名空间中的<aop:declare-parents>元素,可以实现相同的功能。

下面的XML代码片段与之前基于AspectJ的引入功能相同:

<aop:declare-parents>声明了此切面所通知的bean要在它的对象层次结构中拥有新的父类型。类型匹配Performance接口(由types-matching属性指定)的那些bean在父类结构中会增加Encoreable接口(由implementinterface属性指定)。最后要解决的问题是Encoreable接口中的方法实现要来自于何处。

有两种方式标识所引入接口的实现。本例中,使用default-impl属性用全限定类名显式指定Encoreable的实现。还可以使用delegate-ref属性来标识。

delegate-ref属性引用了一个Spring bean作为引入的委托。需要在Spring上下文中存在一个ID为encoreableDelegate的bean。

<bean id="encoreableDelegate"
    class="concert.DefaultEncoreable" />

使用default-impl来直接标识委托和间接使用delegate-ref的区别在于后者是Spring bean,它本身可以被注入、通知或使用其他的Spring配置。

注入AspectJ切面

暂时跳过

小结

AOP是面向对象编程的一个强大补充。通过AspectJ,现在可以把之前分散在应用各处的行为放入可重用的模块中。这有效减少了代码冗余,并让我们的类关注自
身的主要功能。

Spring提供了一个AOP框架,把切面插入到方法执行的周围。

关于在Spring应用中如何使用切面,可以有多种选择。

当Spring AOP不能满足需求时,我们必须转向更为强大的AspectJ。

推荐阅读更多精彩内容