spring实战 面向切面的Spring

326 阅读33分钟

面向切面的Spring

什么是面向切面编程

切面能帮助我们模块化横切关注点。简而言之,横切关注点可以被描述为影响应用多处的功能。例如,安全就是一个横切关注点, 应用中的许多方法都会涉及到安全规则。下图直观呈现了横切关注点的概念。 上图展现了一个被划分为模块的典型应用。每个模块的核心功能都是为特定业务领域提供服务,但是这些模块都需要类似的辅助功能,例如安全和事务管理。 如果要重用通用功能的话,最常见的面向对象技术是继承(inheritance)或委托(delegation)。但是,如果在整个应用中都使用相同的基类, 继承往往会导致一个脆弱的对象体系;而使用委托可能需要对委托对象进行复杂的调用。

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

定义AOP术语

描述切面的常用术语有通知(advice)、切点(pointcut)和连接点(join point)。下图展示了在一个或多个连接点上,可以把切面的功能(通知)植入到程序的执行过程中。

通知(Advice)

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

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

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

连接点(Join point)

我们的应用可能也有数以千计的时机应用通知。这些时机被称为连接点。连接点是在应用执行过程中能够插入切面的一个点。这个点可以是调用方法时、抛出异常时、甚至修改一个字段时。切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。

切点(Poincut)

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

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

切面(Aspect)

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

引入(Introduction)

引入允许我们向现有的类添加新方法或属性。例如,我们可以创建一个Auditable通知类,该类记录了对象最后一次修改时的状态。这很简单,只需一个方法,setLastModified(Date),和一个实例变量来保存这个状态。然后,这个新方法和实例变量就可以被引入到现有的类中,从而可以在无需修改这些现有的类的情况下,让它们具有新的行为和状态。

织入(Weaving)

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

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

Spring对AOP的支持

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

Spring通知是Java编写的

Spring所创建的通知都是用标准的Java类编写的。这样的话,我们就可以使用与普通Java开发一样的集成开发环境(IDE)来开发切面。而且,定义通知所应用的切点通常会使用注解或在Spring配置文件里采用XML来编写,这两种语法对于Java开发者来说都是相当熟悉的。

AspectJ与之相反。虽然AspectJ现在支持基于注解的切面,但AspectJ最初是以Java语言扩展的方式实现的。这种方式有优点也有缺点。通过特有的AOP语言,我们可以获得更强大和细粒度的控制,以及更丰富的AOP工具集,但是我们需要额外学习新的工具和语法。

Spring在运行时通知对象

通过在代理类中包裹切面,Spring在运行期把切面织入到Spring管理的bean中。如下图所示,代理类封装了目标类,并拦截被通知方法的调用,再把调用转发给真正的目标bean。当代理拦截到方法调用时,在调用目标bean方法之前,会执行切面逻辑。 直到应用需要被代理的bean时,Spring才创建代理对象。如果使用的是ApplicationContext的话, 在ApplicationContext从BeanFactory中加载所有bean的时候,Spring才会创建被代理的对象。因为Spring运行时才创建代理对象,所以我们不需要特殊的编译器来织入Spring AOP的切面。

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

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

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

通过切点来选择连接点

关于Spring AOP的AspectJ切点,最重要的一点就是Spring仅支持AspectJ切点指示器(pointcut designator)的一个子集Spring是基于代理的,而某些切点表达式是与基于代理的AOP无关的。下表列出了Spring AOP所支持的AspectJ切点指示器。

AspectJ指示器描 述
arg()限制连接点匹配参数为指定类型的执行方法
@args()限制连接点匹配参数由指定注解标注的执行方法
execution()用于匹配是连接点的执行方法
this()限制连接点匹配AOP代理的bean引用为指定类型的类
target限制连接点匹配目标对象为指定类型的类
@target()限制连接点匹配特定的执行对象,这些对象对应的类要具有指定类型的注解
within()限制连接点匹配指定的类型
@within()限制连接点匹配指定注解所标注的类型(当使用Spring AOP时,方法定义在由指定的注解所标注的类里)
@annotation限定匹配带有指定注解的连接点

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

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

编写切点

为了阐述Spring中的切面,我们需要有个主题来定义切面的切点。

package concert;
public interface Performance {
    public void perform();
}

Performance可以代表任何类型的现场表演,如舞台剧、电影或音乐会。假设我们想编写Performance的perform()方法触发的通知。

下图展现了一个切点表达式,这个表达式能够设置当perform()方法执行时触发通知的调用。 使用execution()指示器选择Performance的perform()方法。方法表达式以“*”号开始,表明了我们不关心方法返回值的类型。然后,我们指定了全限定类名和方法名。对于方法参数列表,我们使用两个点号(..)表明切点要选择任意的perform()方法,无论该方法的 入参是什么。

现在假设我们需要配置的切点仅匹配concert包。在此场景下,可以使用within()指示器来限制匹配,如下图: 注意使用了“&&”操作符把execution()和within()指示器连接在一起形成与(and)关系(切点必须匹配所有的指示器)。类似地,可以使用“||”操作符来标识或(or)关系,而使用“!”操作符来标识非(not)操作。

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

在切点中选择bean

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

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

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

在某些场景下,限定切点为指定的bean或许很有意义,还可以使用非操作为除了特定ID以外的其他bean应用通知:

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

在此场景下,切面的通知会被编织到所有ID不为woodstock的bean中。

使用注解创建切面

定义切面

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

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

package concert;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Aspect
public class Audience {
    @Before("execution(** concert.Performance.perform(..))")
    public void sileneceCellPhones() {
        System.out.Println("Silencing cell phones");
    }
    
    @Before("execution(** concert.Performance.perform(..))")
    public void takeSeats() {
        System.out.Println("Taking seats");
    }
    
    @AfterReturning("execution(** concert.Performance.perform(..))")
    public void applause() {
        System.out.Println("CLAP CLAP CLAP!!!");
    }
    
    @AfterThrowing("execution(** concert.Performance.perform(..))")
    public void demandRefund() {
        System.out.Println("Demanding a refund");
    }
}

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

Audience有四个方法,定义了一个观众在观看演出时可能会做的事情。在演出之前,观众要就坐(takeSeats())并将手机调至静音状态 (silenceCellPhones())。如果演出很精彩的话,观众应该会鼓掌喝彩(applause())。不过,如果演出没有达到观众预期的话,观众会要求退款(demandRefund())。

AspectJ提供了五个注解来定义通知:

注 解通 知
@After通知方法会在目标方法返回或抛出异常后调用
@AfterReturning通知方法会在目标方法返回后调用
@AfterThrowing通知方法会在目标方法抛出异常后调用
@Around通知方法会将目标方法封装起来
@Before通知方法会在目标方法调用之前执行

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

所有的这些注解都给定了一个切点表达式作为它的值,同时,这四个方法的切点表达式都是相同的。其实,它们可以设 置成不同的切点表达式,但是在这里,这个切点表达式就能满足所有通知方法的需求。看一下这个设置给通知注解的切点表达 式,我们发现它会在Performance的perform()方法执行时触发。

相同的切点表达式重复了四遍,这可真不是什么光彩的事情。这样的重复让人感觉有些不对劲。如果我们只定义这个切点一次,然后每次 需要的时候引用它,那么这会是一个很好的方案。

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

package concert;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class Audience {
    @Pointcut("execution(** concert.Performance.perform(..))")
    public void performance() {}
    @Before("performance()")
    public void sileneceCellPhones() {
        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!!!");
    }
    
    @AfterThrowing("performance()")
    public void demandRefund() {
        System.out.Println("Demanding a refund");
    }
}

在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注解启用自动代理功能:

package concert;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration
@EnableAspectJAutoProxy
@ComponentScan
public class ConcertConfig {
    @Bean
    public Audience audience() {
    	return new Audience();
	}
}

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

<?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/aop http://www.springframework.org/schema/aop/spring-aop.xsd  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/spring-context.xsd">
	<context:component-scan bease-package="concert" />
    <aop:aspectj-autoproxy />
    <bean class="concert.Audience" />
</beans>

不管你是使用JavaConfig还是XML,AspectJ自动代理都会为使用@Aspect注解的bean创建一个代理,这个代理会围绕着所有该切面的切点所匹配的bean。在这种情况下,将会为Concertbean创建一个代理,Audience类中的通知方法将会在perform()调用前后执行。

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

创建环绕通知

package concert;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public class Audience {
    @Pointcut("execution(** concert.Performance.perform(..))")
    public void performance() {}
    @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作为参数。这个对象是必须要有的,因为你要在通知中通过它来调用被通知的方法。通知方法中可以做任何的事情,当要将控制权交给被通知的方法时,它需要调用ProceedingJoinPoint的proceed()方法。

需要注意的是,别忘记调用proceed()方法。如果不调这个方法的话,那么你的通知实际上会阻塞对被通知方法的调用。有可能这就是你想要的效果,但更多的情况是你希望在某个点上执行被通知的方法。

有意思的是,你可以不调用proceed()方法,从而阻塞对被通知方法的访问,与之类似,你也可以在通知中对它进行多次调用。要这样做的 一个场景就是实现重试逻辑,也就是在被通知方法失败后,进行重复尝试。

处理通知中的参数

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

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

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

package soundsystem;
import java.util.HashMap;
import java.util.Map;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.pointcut;

@Aspect
public class TrackCounter {
    private Map<Integer, Integer> trackCounts = 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);
        tracks.put(trackNumber, currentCount + 1);
    }
    
    public int getPlayCount(int trackNumber) {
        return trackCounts.containKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
    }
}

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

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

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

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

package soundsystem;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration
@EnableAspectJAutoProxy
public class TrackCounterConfig {
    @Bean
    public CompactDisc sgtPeppers() {
    	BlankDisc cd = new BlankDisc();
        cd.setTitle("Sgt. pepper's Lonely Hearts Club Band");
        cd.setArtist("The Beatles");
        List<String> tracks = new ArrayList<String>();
        tracks.add("Sgt. pepper's Lonely Hearts Club Band");
        tracks.add("With a Little Help from My Friends");
        tracks.add("Lucy in the Sky with Diamonds");
        tracks.add("Getting Better");
        tracks.add("Fixing a Hole");
        //...other tracks omitted for brevity...
        cd.setTracks(tracks);
        return cd;
	}
    
    @Bean
    public TrackCounter trackCounter() {
        return new TrackCounter;
    }
}

为了证明它能正常工作,你可以编写如下的简单测试。它会播放几个磁道并通过TrackCounter断言播放的数量。

package soundsystem;

import static org.junit.Assert.*;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.StandardOutputStreamLog;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes=TrackCounterConfig.class)
public class TrackCounterTest {

  @Rule
  public final StandardOutputStreamLog log = new StandardOutputStreamLog();

  @Autowired
  private CompactDisc cd;
  
  @Autowired
  private TrackCounter counter;
  
  @Test
  public void testTrackCounter() {
  	cd.playTrack(1);
    cd.playTrack(2);
    cd.playTrack(3);
    cd.playTrack(3);
    cd.playTrack(3);
    cd.playTrack(3);
    cd.playTrack(7);
    cd.playTrack(7);
      
    assertEquals(1, counter.getPlayCount(1));
    assertEquals(1, counter.getPlayCount(2));
    assertEquals(4, counter.getPlayCount(3));
    assertEquals(0, counter.getPlayCount(4));
    assertEquals(0, counter.getPlayCount(5));
    assertEquals(0, counter.getPlayCount(6));
    assertEquals(2, counter.getPlayCount(7));
  }

}

到目前为止,在我们所使用的切面中,所包装的都是被通知对象的已有方法。但是,方法包装仅仅是切面所能实现的功能之一。

通过注解引入新功能

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

下图展示了使用Spring AOP,可以为bean引入新的方法。代理拦截调用并委托给实现该方法的其他对象。 我们需要注意的是,当引入接口的方法被调用时,代理会把此调用委托给实现了新接口的某个其他对象。实际上,一个bean的实现被拆分到了多个类中。

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

package concert;
public interface Encoreable {
    void performEncore;
}

暂且先不管Encoreable是不是一个真正存在的单词,我们需要有一种方式将这个接口应用到Performance实现中。我们现在假设你能够访问Performance的所有实现,并对其进行修改,让它们都实现Encoreable接口。但是,从设计的角度来看,这并不是最好的做法,并不是所有的Performance都是具有Encoreable特性的。另外一方面,有可能无法修改所有的Performance实现,当使用第三方实现并且没有源码的时候更是如此。

值得庆幸的是,借助于AOP的引入功能,我们可以不必在设计上妥协或者侵入性地改变现有的实现。为了实现该功能,我们要创建一个新的切面:

package concert;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.DeclareParents;

@Aspect
public class EncoreableIntroducer {
  @DeclareParents(value="concert.Performance+", defaultImpl=DefaultEncoreable.calss)
  public static Encoreable encoreable;  
}

到,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配置。但是,面向注解的切面声明有一个明显的劣势:你必须能够为通知类添加注解。为了做到这一点,必须要有源码。

在XML中声明切面

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

在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:declare-parents >以透明的方式为被通知的对象引入额外的接口
<aop:pointcut >定义一个切点

我们已经看过了<aop:aspectj-autoproxy >元素,它能够自动代理AspectJ注解的通知类。aop命名空间的其他元素能够让我们直接在Spring配置中声明切面,而不需要使用注解。

重新看一下Audience类,这一次我们将它所有的AspectJ注解全部移除掉:

package concert;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

public class Audience {
    
    public void sileneceCellPhones() {
        System.out.Println("Silencing cell phones");
    }
    
    public void takeSeats() {
        System.out.Println("Taking seats");
    }
    
    public void applause() {
        System.out.Println("CLAP CLAP CLAP!!!");
    }
    
    public void demandRefund() {
        System.out.Println("Demanding a refund");
    }
}

正如所看到的,Audience类并没有任何特别之处,它就是有几个方法的简单Java类。我们可以像其他类一样把它注册为Spring应用上下文中的bean。 尽管看起来并没有什么差别,但Audience已经具备了成为AOP通知的所有条件。我们再稍微帮助它一把,它就能够成为预期的通知了

声明前置和后置通知

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

<aop:config>
    <aop:aspect ref="audience">
        <aop:before pointcut="execution(** concert.Performance.perform(..))" method="silenceCellPhones" />
        <aop:before pointcut="execution(** concert.Performance.perform(..))" method="takeSeats" />
        <aop:after-returning pointcut="execution(** concert.Performance.perform(..))" method="applause" />
        <aop:after-throwing pointcut="execution(** concert.Performance.perform(..))" method="demandRefund" />
            
    </aop:aspect>
</aop:config>

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

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

该切面应用了四个不同的通知。两个<aop:before >元素定义了匹配切点的方法执行之前调用前置通知方法—也就是Audience bean的takeSeats()和turnOffCellPhones()方法(由method属性所声明)。<aop:after-returning >元素定义了一个返回 (after-returning)通知,在切点所匹配的方法调用之后再调用applaud()方法。同样,<aop:after-throwing >元素定义了异常 (after-throwing)通知,如果所匹配的方法执行时抛出任何的异常,都将会调用demandRefund()方法。

下图展示了Audience切面包含四种通知,他们把通知逻辑织入进匹配切面切点的方法中 在所有的通知元素中,pointcut属性定义了通知所应用的切点,它的值是使用AspectJ切点表达式语法所定义的切点。

所有通知元素中的pointcut属性的值都是一样的,这是因为所有的通知都要应用到相同的切点上。

在基于AspectJ注解的通知中,当发现这种类型的重复时,使用@Pointcut注解消除了这些重复的内容。而在基于XML的切面声明中,需要使用<aop:pointcut >元素。如下的XML展现了如何将通用的切点表达式抽取到一个切点声明中,这样这个声明就能在所有的通知元素中使用了。

<aop:config>
    <aop:aspect ref="audience">
        <aop:pointcut id="performance" expression="execution(** concert.Performance.perform(..))" />
        <aop:before pointcut-ref="performance" method="silenceCellPhones" />
        <aop:before pointcut-ref="performance" method="takeSeats" />
        <aop:after-returning pointcut-ref="performance" method="applause" />
        <aop:after-throwing pointcut-ref="performance" method="demandRefund" />
            
    </aop:aspect>
</aop:config>

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

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

声明环绕通知

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

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

package concert;
import org.aspectj.lang.ProceedingJoinPoint;

public class Audience {

    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");
        }
    }
}

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

声明环绕通知与声明其他类型的通知并没有太大区别。我们所需要做的仅仅是使用<aop:around >元素:

<aop:config>
    <aop:aspect ref="audience">
        <aop:pointcut id="performance" expression="execution(** concert.Performance.perform(..))" />
        <aop:around pointcut-ref="performance" method="watchPerformance" />
            
    </aop:aspect>
</aop:config>

像其他通知的XML元素一样,指定了一个切点和一个通知方法的名字。在这里,我们使用跟之前一样的切点,但是为该切点所设置的method属性值为watchPerformance()方法。

为通知传递参数

使用@AspectJ注解创建了一个切面,这个切面能够记录CompactDisc上每个磁道播放的次数。现在,我们使用XML来配置切面,那就看一下如何完成这一相同的任务。

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

package soundsystem;
import java.util.HashMap;
import java.util.Map;

public class TrackCounter {
    private Map<Integer, Integer> trackCounts = new HashMap<Integer, Integer>();
    
    public void countTrack(int trackNumber) {
        int currentCount = getPlayCount(trackNumber);
        tracks.put(trackNumber, currentCount + 1);
    }
    
    public int getPlayCount(int trackNumber) {
        return trackCounts.containKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
    }
}

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

<?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:aop="http://www.springframework.org/schema/aop"   xsi:schemaLocation="http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd  http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
	<bean id="trackCounter" class="soundsystem.TrackCounter" />
    <bean id="cd" class="soundsystem.BlankDisc" />
    <property name="title" value="Sgt. pepper's Lonely Hearts Club Band" />
    <property name="artist" value="The Beatles" />
    <property name="tracks">
        <list>
        	<value>Sgt. pepper's Lonely Hearts Club Band</value>
        	<value>With a Little Help from My Friends</value>
        	<value>Lucy in the Sky with Diamonds</value>
        	<value>Getting Better</value>
        	<value>Fixing a Hole</value>
        <!-- ...other tracks omitted for brevity... -->
        </list>
    </property>
    <aop:config>
    <aop:aspect ref="trackCounter">
        <aop:pointcut id="trackPlayed" expression="execution(* soundsystem.CompactDisc.playTrack(int)) and args(trackNumber)" />
        <aop:before pointcut-ref="trackPlayed" method="countTrack" />
            
    </aop:aspect>
</aop:config>
</beans>

可以看到,我们使用了和前面相同的aop命名空间XML元素,它们会将POJO声明为切面。唯一明显的差别在于切点表达式中包含了一个参数,这个参数会传递到通知方法中。这里使用and关键字而不是“&&”(因为在XML中,“&”符号会被解析为实体的开始)。

通过切面引入新的功能

展现了如何借助AspectJ的@DeclareParents注解为被通知的方法神奇地引入新的方法。但是AOP引入并不是AspectJ特有的。使用Spring aop命名空间中的<aop:declare-parents >元素,我们可以实现相同的功能。

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

<aop:aspect>
    <aop:declar-parents types-matching="concert.Performance+" implement-interface="concert.Encoreable" default-impl="concert.DefaultEncoreable" />
</aop:aspect>

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

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

<aop:aspect>
    <aop:declar-parents types-matching="concert.Performance+" implement-interface="concert.Encoreable" delegate-ref="encoreableDelegate" />
</aop:aspect>

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

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

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

注入AspectJ切面

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

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

为上面的演出创建一个新切面。具体来讲,我们以切面的方式创建一个评论员的角色,他会观看演出并且会在演出之后提供一些批评意见。下面的CriticAspect就是一个这样的切面。

package concert;
public aspect CriticAspect {
    public CriticAspect() {}
    pointcut performance() : execution(* perform(..));
    afterReturning() : performance() {
        System.out.println(criticismEngine.getCriticism());
    }
    private CriticismEngine criticismEngine;
    public void setCriticismEngine(CriticismEngine criticismEngine) {
        this.criticismEngine = criticismEngine;
    }
}

CriticAspect的主要职责是在表演结束后为表演发表评论。performance()切点匹配perform()方法。当它 与afterReturning()通知一起配合使用时,我们可以让该切面在表演结束时起作用。

上面程序有趣的地方在于并不是评论员自己发表评论,实际上,CriticAspect与一个CriticismEngine对象相协作,在表演结束时,调用该对象的getCriticism()方法来发表一个苛刻的评论。为了避免CriticAspect和CriticismEngine之间产生不必要的耦 合,我们通过Setter依赖注入为CriticAspect设置CriticismEngine。

下图展示了切面也需要注入。像其他bean一样,Spring可以为AspectJ切面注入依赖: CriticismEngine自身是声明了一个简单getCriticism()方法的接口。CriticismEngine的实现:

package com.springinaction.springidol;
public class CriticismEngineImpl implements CriticismEngine {
    public CriticismEngineImpl() {}
    public String getCriticism() {
        int i = (int)(Math.random()*criticismPool.length);
        return criticismPool[i];
    }
    
    //injected
    private String[] criticismPool;
    public void setCriticismPool(String[] criticismPool) {
        this.criticismPool = criticismPool;
    }
}

CriticismEngineImpl实现了CriticismEngine接口,通过从注入的评论池中随机选择一个苛刻的评论。这个类可以使用如下的XML声明为一个Spring bean。

<bean id="criticismEngine" class="com.springinaction.springidol.CriticismEngineImpl">
    <property name="criticism">
    	<list>
    		<value>Wrost performance ever!</value>
    		<value>Ilaughed,Icried,then Irealized Iwas at the wrong show.</value>
    		<value>A must see show!</value>
    	</list>
    </property>
</bean>

现在有了一个要赋予CriticAspect的Criticism-Engine实现。剩下的就是为CriticAspect装配CriticismEngineImple。

在展示如何实现注入之前,我们必须清楚AspectJ切面根本不需要Spring就可以织入到我们的应用中。如果想使用Spring的依赖注入为AspectJ 切面注入协作者,那我们就需要在Spring配置中把切面声明为一个Spring配置中的。如下的声明会把criticismEnginebean注入到CriticAspect中:

<bean class="com.springinaction.springidol.CriticAspect" factory-method="aspectof">
    <property name="criticismEngine" ref="criticismEngine" />
</bean>

很大程度上,的声明与我们在Spring中所看到的其他配置并没有太多的区别,但是最大的不同在于使用了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不能像之前那样使用声明来创建一个CriticAspect实例——它已经在运行时由AspectJ创建完成了。Spring需要通过aspectOf()工厂方法获得切面的引用,然后像元素规定的那样在该对象上执行依赖注入。