欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

面向切面

程序员文章站 2022-05-29 15:29:23
...

面向切面的Spring

本章主要内容包括:

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

本章展示了Spring对切面的支持,包括如何把普通类声明为一个切面和如何使用注解创建切面。

1 什么是面向切面编程

在软件开发中,散布于应用中多处的功能被称为横切关注点(cross-cutting concern)。通常来讲,这些横切关注点从概念上讲是与业务逻辑相分离的(但是往往会直接嵌入到应用的业务逻辑之中)。把这些横切关注点与业务逻辑相分离正是切面编程(AOP)所要解决的问题。
在使用面向切面编程时,我们仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无需修改受影响的类。横切关注点可以被模块化为特殊的类,这些类被称为切面(aspect)。这样做有两个好处:首先,现在每个关注点都集中于一个地方,而不是分散到多处代码中;其次,服务模块更简洁,因为它们只包含主要关注点(或核心功能)的代码,而次要关注点的代码被转移到切面中了。

1.1 定义AOP术语

AOP已经形成了自己的术语,描述切面的常用术语有通知(advice)、切点(pointcut)和连接点(joinpoint)。现在来了解一下这些术语的基本定义:

通知(Advice)

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

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

连接点(Join point)

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

切点(Pointcut)

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

切面(Aspect)

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

引入(Introduction)

引入允许我们向现有的类添加新方法或属性。

织入(Weaving)

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

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

总的来说,通知包含了需要用于多个应用对象的横切行为;连接点是程序执行过程中能够应用通知的所有点;切点定义了通知被应用的具体位置(在哪些连接点)。其中关键的概念是切点定义了哪些连接点会得到通知。

1.2 Spring对AOP的支持

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

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

前3种都是Spring AOP实现的变体,Spring AOP构建在动态代理基础之上,因此,Spring对AOP的支持局限于方法拦截。Spring的经典AOP编程是基于代理实现的,可以是基于JDK动态代理,也可以是基于CGLib代理。现在Spring提供了更简洁和干净的面向切面编程方式,引入了简单的声明式AOP和基于注解的AOP之后,Spring经典的AOP看起来就显得非常笨重和过于复杂化,直接使用ProxyFactoryBean会非常麻烦。在此,不在多做介绍。
借助Spring的aop命名空间,我们可以将纯POJO转换为切面。实际上,这些POJO只是提供了满足切点条件时所需要调用的方法。遗憾的是,这种技术需要使用XML配置,但这的确是声明式地将对象转换为切面的简便方式。
Spring借鉴了AspectJ的切面,以提供注解驱动的AOP。本质上,它依然是Spring基于代理的AOP,但是编程模型几乎与编写成熟的AspectJ注解切面完全一致。这种AOP风格的好处在于能够不使用XML来完成功能。
如果你的AOP需求超过了简单的方法调用(如构造器或属性拦截),那么需要考虑使用AspectJ来实现切面。在这种情况下,上文所示的第四种类型能够帮助将值注入到AspectJ驱动的切面中。

Spring在运行时通知对象

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

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

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

2 通过切点来选择连接点

切点用于准确定位应该在什么地方应用切面的通知。在SpringAOP中,要使用AspectJ的切点表达式语言来定义切点。关于SpringAOP的AspectJ切点最重要的一点就是Spring仅支持AspectJ切点指示器(pointcut designator)的一个子集。下表列出了SpringAOP所支持的AspectJ切点指示器:

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

当在Spring中尝试使用AspectJ其他指示器时,将会抛出IllegalArgumentException异常。注意,当我们使用以上指示器时,只有execution指示器是实际执行匹配的,而其他的指示器都是用来限制匹配的。

2.1 编写切点

为了阐述Spring中的切面,我们定义一个接口Performance,它可以代表任何类型的现场表演,如舞台剧、电影或音乐会。如下:

package com.godman.aspectJ;

public interface Performance {
    void perform();
}

假设想编写Performance的perform()方法触发的通知,这时需要一个切点表达式来设置当perform()方法执行时触发通知的调用,如下:

execution(* com.godman.aspectJ.Performance.perform(..))

我们使用execution()指示器选择Performance的perform()方法。方法表达式以”*”号开头,表示不关心返回值的类型。然后指定了全限定类名和方法名。对于方法参数列表,我们使用两个点号(..)表明切点要选择任意的perform()方法,无论该方法的入参是什么。
现在假设我们要匹配的切点仅匹配com.godman.aspectJ包。在此场景下可以使用within()指示器来限制匹配:

execution(* com.godman.aspectJ.Performance.perform(..)) && within(com.godman.aspectJ.*)

这里使用了”&&”操作符把execution()和within()指示器连接在一起形成与(and)关系(切点必须匹配所有的指示器)。除此之外,还可以使用”||”操作符来标识或(or)关系,而使用”!”操作符来标识非(not)操作。因为”&”在XML中有特殊含义,所以在Spring的XML配置里描述切点时,可以使用”and”来代替”&&”,使用”or”来代替”||”,使用”not”来代替”!”。

2.2 在切点中选择bean

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

execution(* com.godman.aspectJ.Performance.perform(..)) and bean('woodstock')

这个表达式表示希望在执行Performance的perform()方法时应用通知,但限制bean的ID为woodstock。
或者也可以使用非操作为除了特定ID以外的其他bean应用通知:

execution(* com.godman.aspectJ.Performance.perform(..)) and !bean('woodstock')

3 使用注解创建切面

使用注解来创建切面是AspectJ5所引入的关键特性。AspectJ面向注解的模型可以非常简单地通过少量注解把任意类转变为切面。前面已经定义了Performance接口,它是切面中切点的目标对象。现在,让我们使用AspectJ注解来定义切面。

3.1 定义切面

以下代码展示了Audience类,它定义了一个我们所需要的切面。

package com.godman.aspectJ;

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(* com.godman.aspectJ.Performance.perform(..))")
    public void silenceCellPhones() {
        System.out.println("Silencing cell phones");
    }

    @Before("execution(* com.godman.aspectJ.Performance.perform(..))")
    public void takeSeats(){
        System.out.println("Taking seats");
    }

    @AfterReturning("execution(* com.godman.aspectJ.Performance.perform(..))")
    public void applause(){
        System.out.println("CLAP! CLAP! CLAP!");
    }

    @AfterThrowing("execution(* com.godman.aspectJ.Performance.perform(..))")
    public void demandRefund(){
        System.out.println("Demanding a refund");
    }
}

Audience类使用@Aspect注解进行了标注,这表明Audience不仅仅是一个POJO,还是一个切面。Audience类中的方法都是用注解来定义切面的具体行为。这些方法使用的通知注解来表明它们应该在什么时候调用。AspectJ提供了五个注解来定义通知,如下所示:

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

Audience类使用了其中3个。所有这些注解都给定了一个切点表达式作为它的值,同时这里的四个方法的切点表达式都是相同的。其实,也可以设置成不同的切点表达式,在这里这一个表达式就能满足所有同志方法的需求。
其实,可以使用@Pointcut注解,它能够在一个@AspectJ切面内定义可重用的切点。如下所示:

package com.godman.aspectJ;

import org.aspectj.lang.annotation.*;

@Aspect
public class Audience {

    @Pointcut("execution(* com.godman.aspectJ.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!");
    }

    @AfterThrowing("performance()")
    public void demandRefund(){
        System.out.println("Demanding a refund");
    }
}

在Audience中,performance()方法使用了@Pointcut注解。为@Pointcut注解设置的值是一个切点表达式,就像之前直接在通知注解上设置的那样。通过在performance()方法上添加@Pointcut注解,我们实际上扩展了切点表达式语言,这样就可以在任何的切点表达式中使用performance()了,如果不这样做的话,就需要这每个地方都使用更长的切点表达式。在这里,performance()方法的实际内容并不重要,其实应该是空的,它只是一个标识,供@Pointcut注解依附。
需要注意的是,此时Audience仍然是一个POJO。我们能够像其他的Java类一样调用它的方法,它的方法也能够独立的进行单元测试,这与其他的Java类并没有什么区别。像其他的Java类一样,它可以装配为Spring中的bean:

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

如果就此止步的话,Audience只会是Spring容器中的一个bean。即使使用了AspectJ注解也不会被视为切面,这些注解不会得到解析,也不会将其转换为切面的代理。
如果使用JavaConfig的话,可以在配置类的类级别上通过使用@EnableAspectJAutoProxy注解启用自动代理功能,如下所示:

package com.godman.aspectJ;

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 AudienceConfig {

    @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/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <!-- 开启自动扫描-->
    <context:component-scan base-package="com.godman"/>
    <!--启用AspectJ自动代理-->
    <aop:aspectj-autoproxy/>
    <!--声明Audience bean-->
    <bean class="com.godman.aspectJ.Audience"/>

</beans>

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

package test.com.godman.aspectJ; 

import com.godman.aspectJ.AudienceConfig;
import com.godman.aspectJ.Performance;
import org.junit.Test;
import org.junit.Before; 
import org.junit.After;
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;

/** 
 * AudienceConfig Tester. 
 * 
 * @author <Authors name> 
 * @since <pre>八月 2, 2018</pre> 
 * @version 1.0 
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = AudienceConfig.class)
public class AudienceConfigTest { 

    @Autowired
    private Performance performance;

    @Test
    public void testAspect(){
        performance.perform();
    }
} 

结果如下:
面向切面

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

3.2 创建环绕通知

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

@Around("performance()")
public void watchPerformance(ProceedingJoinPoint joinPoint){
    try {
        System.out.println("Silencing cell phones");
        System.out.println("Taking seats");
        joinPoint.proceed();
        System.out.println("CLAP! CLAP! CLAP!");
    } catch (Throwable throwable) {
        System.out.println("Demanding a refund");
    }
}

在这里,@Around注解表明watchPerformance()方法会作为perfoemance()切点的环绕通知。可以看到这个通知所达到的效果与之前的前置通知和后置通知是一样的。只不过现在它们位于同一个方法中,而不像之前那样分散在4个不同的通知方法里。
关于这个新的通知方法,它接受ProceedingJoinPoint作为参数。这个对象是必须要有的,因为你需要在通知中通过它来调用被通知的方法。通知方法中可以做任何的事情,当需要将控制权交给被通知的方法时,它需要调用ProceedingJoinPoint的proceed()方法。如果忘记调用proceed()方法,那么实际上你的通知会阻塞对被通知方法的调用。

3.3 处理通知中的参数

到目前为止,我们的切面没有任何参数。唯一例外的是我们为环绕通知所编写的watchPerformance()方法中使用了ProceedingJoinPoint作为参数。除了环绕通知,我们编写的其他通知不需要关注传递给被通知方法的任意参数。这很正常,因为我们所通知的perform()方法本身没有任何参数。
但是,如果切面通知的方法确实有参数该怎么办?切面能访问和使用传递给被通知方法的参数吗?为了阐述这个问题,我们重新看之前的BlankDisc样例。play()方法会循环所有的磁道。但是,我们也可以通过play()方法直接播放某个磁道中的歌曲。

package com.godman.aspectJ;

import java.util.List;

public class BlankDisc implements CompactDisc {

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


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

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

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

    @Override
    public void play() {
        System.out.println("开始播放" + artist + "的" + title);
        for(int i=0; i< tracks.size(); i++){
            System.out.println("第" + i + "首:" + tracks.get(i));
        }
    }

    @Override
    public void play(int trackNumber) throws IllegalAccessException {
        if(trackNumber < 0 || trackNumber > tracks.size() - 1){
            throw new IllegalAccessException();
        }
        String track = tracks.get(trackNumber);
        System.out.println("开始播放" + artist + "的" + title);
        System.out.println("第" + trackNumber + "首:" + track);
    }
}

假设,现在需要记录每个磁道被播放的次数。一种方法是修改playTrack()方法,直接在每次调用的时候记录这个数量。但是,记录磁道的播放次数与播放本身是不同的关注点,因此不应该属于playTrack()方法。看起来这应该是切面完成的任务。
为了记录每个磁道所播放的次数,我们创建了TrackCounter类,它是通知playTrack()方法的一个切面。

package com.godman.aspectJ;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;

import java.util.HashMap;
import java.util.Map;

@Aspect
public class TrackCounter {

    private Map<Integer, Integer> trackCounts = new HashMap<>();

    @Pointcut("execution(* com.godman.aspectJ.BlankDisc.play(int)) && args(trackNumber)")
    public void trackPlayed(int trackNumber){

    }

    @Before("trackPlayed(trackNumber)")
    public void countTrack(int trackNumber){
        int correntCount = getPlayCount(trackNumber);
        trackCounts.put(trackNumber, correntCount + 1);
    }

    public int getPlayCount(int trackNumber){
        return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
    }
}

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

execution(* com.godman.aspectJ.BlankDisc.play(int)) && args(trackNumber)

需要注意的是这个切点表达式中的args(trackNumber)限定符。它表明传递给play()方法的int类型参数也会传递到通知中去。参数的名称trackNumber也与切点方法签名中的参数相匹配。这个参数会传递到通知方法中,这个通知方法是通过@Before注解和命名切点trackPlayed(int trackNumber)定义的。切点定义中的参数与切点方法中的参数名称一样,这样就完成了从命名切点到通知方法的参数转移。
现在,使用JavaConfig将BlankDisc和TrackCounter定义为bean,并启用AspectJ自动代理:

package com.godman.aspectJ;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import java.util.ArrayList;
import java.util.List;

@Configuration
@EnableAspectJAutoProxy
public class TrackCounterConfig {

    @Bean
    public CompactDisc compactDisc(){
        BlankDisc blankDisc = new BlankDisc();
        blankDisc.setArtist("周杰伦");
        blankDisc.setTitle("七里香");
        List<String> tracks = new ArrayList<>();
        tracks.add("我的地盘");
        tracks.add("七里香");
        tracks.add("借口");
        tracks.add("外婆");
        tracks.add("将军");
        tracks.add("搁浅");
        tracks.add("乱舞春秋");
        tracks.add("困兽之斗");
        tracks.add("园游会");
        tracks.add("止战之殇");
        blankDisc.setTracks(tracks);
        return blankDisc;
    }

    @Bean
    public TrackCounter trackCounter(){
        return new TrackCounter();
    }
}

最后测试一下:

package test.com.godman.aspectJ; 

import com.godman.aspectJ.CompactDisc;
import com.godman.aspectJ.TrackCounter;
import com.godman.aspectJ.TrackCounterConfig;
import org.junit.Assert;
import org.junit.Test;
import org.junit.Before; 
import org.junit.After;
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;

/** 
 * TrackCounterConfig Tester. 
 * 
 * @author <Authors name> 
 * @since <pre>八月 4, 2018</pre> 
 * @version 1.0 
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = TrackCounterConfig.class)
public class TrackCounterConfigTest { 

    @Autowired
    private CompactDisc compactDisc;

    @Autowired
    private TrackCounter trackCounter;

    @Test
    public void testTrackCounter() throws IllegalAccessException {
        compactDisc.play(3);
        compactDisc.play(3);
        compactDisc.play(1);
        compactDisc.play(2);
        compactDisc.play(3);
        compactDisc.play(4);
        compactDisc.play(5);
        compactDisc.play(6);
        compactDisc.play(7);

        Assert.assertEquals(0, trackCounter.getPlayCount(0));
        Assert.assertEquals(1, trackCounter.getPlayCount(1));
        Assert.assertEquals(1, trackCounter.getPlayCount(2));
        Assert.assertEquals(3, trackCounter.getPlayCount(3));
        Assert.assertEquals(1, trackCounter.getPlayCount(4));
        Assert.assertEquals(1, trackCounter.getPlayCount(5));
        Assert.assertEquals(1, trackCounter.getPlayCount(6));
        Assert.assertEquals(1, trackCounter.getPlayCount(7));
        Assert.assertEquals(0, trackCounter.getPlayCount(8));
        Assert.assertEquals(0, trackCounter.getPlayCount(9));
    }
}

测试结果如下:
面向切面

3.4 通过注解引入新功能

一些编程语言,例如Ruby和Groovy。有开放类的理念。它们可以不用直接修改对象或类的定义就能够为对象或类增加新的方法。不过,Java并不是动态语言。一旦类编译完成了,就很难再为该类添加新的功能了。
但是如果仔细想想,我们之前不是一直都在使用切面这样做吗?当然,我们还没有为对象新增任何新的方法,但是已经为对象拥有的方法添加了新的功能。如果切面能够为现有的方法增加额外的功能,为什么不能为一个对象增加新的方法呢?实际上,利用被称为引入的AOP概念,切面可以为Spring bean添加新方法。
回顾一下,在Spring中,切面只是实现了它们所包装bean相同接口的代理。如果除了实现这些接口,代理也能够暴露新接口的话,会怎么样呢?那样的话,切面所通知的bean看起来像是实现了新的接口,即便底层实现类并没有实现这些接口也无所谓。需要注意的是,当引入接口的方法被调用时,代理会把此调用委托给实现了新接口的某个其他对象。实际上,一个bean的实现被拆分到了多个类中。
为了验证该注意能够行得通,为示例中的所有Performance实现引入下面的Encoreable接口:

package com.godman.aspectJ;

public interface Encoreable {
    void performEncore();
}

现在假设能够访问Performance的所有实现,并对其进行修改,让它们都实现Encoreable接口。但是,从设计角度来看,这并不是最好的做法,并不是所有的Performance都具有Encoreable特性的。另外一个方面,有可能无法修改所有的Performance实现,当使用第三方实现并且没有源码的时候更是如此。
值得庆幸的是,借助于AOP的引入功能,我们可以不必在设计上妥协或者侵入性地改变现有的实现。为了实现该功能,我们要创建一个新的切面:

package com.godman.aspectJ;

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

@Aspect
public class EncoreableIntroducer {

    @DeclareParents(value = "com.godman.aspectJ.Performance+", defaultImpl = DefaultEncoreable.class)
    public static Encoreable encoreable;
}

可以看到EncoreableIntroducer是一个切面。但是它与我们之前提供的切面并不相同,它没有提供前置、后置或者环绕通知,而是通过@DeclareParents注解将Encoreable接口引入到Performance bean中。@DeclareParents注解由三部分组成:

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

DefaultEncoreable实现类如下所示:

package com.godman.aspectJ;

public class DefaultEncoreable implements Encoreable {
    @Override
    public void performEncore() {
        System.out.println("我在代理的接口实现类点评表演...");
    }
}

同样的我们需要将EncoreableIntroducer声明成一个bean:

package com.godman.aspectJ;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration
@EnableAspectJAutoProxy
public class EncoreableConfig {

    @Bean
    public EncoreableIntroducer encoreableIntroducer(){
        return new EncoreableIntroducer();
    }
}

Spring的自动代理机制将会获取到它的声明,当Spring发现一个bean使用@AspectJ注解时,Spring就会创建一个代理,然后将调用委托给被代理的bean或被引入的实现,这取决于调用的方法属于被代理的bean还是属于被引入的接口。在Spring中,注解和自动代理提供了一种很方便的方式来创建切面。它非常简单,并且只涉及到最少的Spring配置。但是,面向注解的切面声明有一个明显的劣势,必须能够为通知类添加注解,因此必须得有源码。如果没有源码。或者不想将AspectJ注解放入到你的代码之中,Spring提供了XML配置的解决方案。

4 在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:*>元素必须包含在<aop:config>元素内
<aop:pointcut> 定义一个切点

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

package com.godman.aspectXml;

public class Audience {

    public void silenceCellPhones() {
        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通知的所有天剑,再稍微做些操作就可以成为预期的通知了。

4.1 声明前置和后置通知

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

<bean id="audience" class="com.godman.aspectXml.Audience"/>

<aop:config>
    <aop:aspect ref="audience">
        <!--前置通知:表演之前-->
        <aop:before pointcut="execution(* com.godman.aspectXml.Performance.perform(..))" method="silenceCellPhones"/>
        <!--前置通知:表演之前-->
        <aop:before pointcut="execution(* com.godman.aspectXml.Performance.perform(..))" method="takeSeats"/>
        <!--返回通知:表演之后-->
        <aop:after-returning pointcut="execution(* com.godman.aspectXml.Performance.perform(..))" method="applause"/>
        <!--异常通知:表演失败之后-->
        <aop:after-throwing pointcut="execution(* com.godman.aspectXml.Performance.perform(..))" method="demandRefund"/>
    </aop:aspect>
</aop:config>

关于Spring AOP 配置元素,第一个需要注意的事项是大多数的AOP配置元素必须要在<aop:config>元素的上下文内使用。这条规则有几种例外场景,但是把bean声明为一个切面时,我们总是从<aop:config>元素开始配置的。在<aop:config>元素内,我们可以声明一个或多个通知器、切面或者切点。在以上配置中,我们使用<aop:config>元素声明了一个简单的切面。ref元素引用了POJO bean,该bean实现了切面的功能–在这里就是audience。ref元素所引用的bean提供了在切面中通知所调用的方法。
该切面应用了四个不同的通知,在这里不做多余的介绍。在所有的通知元素中,pointcut属性定义了通知所应用的切点,它的值是使用AspectJ切点表达式语法所定义的切点。在这里四个表达式的值是一样的,所以它们应用于相同的切点。在基于AspectJ注解的通知中,当发现这种类型的重复时,我们使用@Pointcut注解消除了这些重复的内容。而在基于XML的切面声明中,我们需要使用<aop:pointcut>元素。如下的XML展示了如何将通用的切点表达式抽取到一个切点声明中,这样这个声明就能在所有的通知元素中使用了:

<aop:config>
    <aop:aspect ref="audience">
        <!--定义切点-->
        <aop:pointcut id="performance" expression="execution(* com.godman.aspectXml.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>必须放在<aop:config>范围之内,否则将无法引用。

4.2 声明环绕通知

使用环绕通知,我们可以完成前置通知和后置通知所实现的相同功能,而且只需要在一个方法中实现。因为整个通知是在一个方法中实现的,所以不需要使用成员变量保存状态。如下:

public void watchPerformance(ProceedingJoinPoint joinPoint){
    try {
        System.out.println("Silencing cell phones");
        System.out.println("Taking seats");
        joinPoint.proceed();
        System.out.println("CLAP! CLAP! CLAP!");
    } catch (Throwable throwable) {
        System.out.println("Demanding a refund");
    }
}

在观众切面中,watchPerformance()方法包含了之前通知方法中的所有功能。不过,所有功能都放在了这一个方法中,因此这个方法还要负责自身的异常处理。声明环绕通知与声明其他类型的通知并没有太大区别。我们所需要的仅仅是使用<aop:around>元素。

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

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

4.3 为通知传递参数

在3.3小节中,我们使用@AspectJ注解创建了一个切面,这个切面能够记录CompactDisc上每个磁道播放的次数。现在,我们使用XML来配置切面。首先,移除掉TrackCounter上所有的@AspectJ注解:

package com.godman.aspectXml;

import java.util.HashMap;
import java.util.Map;

public class TrackCounter {

    private Map<Integer, Integer> trackCounts = new HashMap<>();

    public void countTrack(int trackNumber){
        int playCount = getPlayCount(trackNumber);
        trackCounts.put(trackNumber, playCount + 1);
    }

    public int getPlayCount(int trackNumber){
        return trackCounts.containsKey(trackNumber) ? trackCounts.get(trackNumber) : 0;
    }
}

去掉@AspectJ注解后,TrackCounter变得有些单薄。现在,除非显示调用countTrack()方法,否则TrackCounter不会记录磁道播放的数量。但是,借助Spring XML配置,我们能够让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/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop.xsd">

    <bean id="trackCounter" class="com.godman.aspectXml.TrackCounter"/>
    <bean id="blankDisc" class="com.godman.aspectXml.BlankDisc">
        <property name="title" value="七里香"/>
        <property name="artist" value="周杰伦"/>
        <property name="tracks">
            <list>
                <value>我的地盘</value>
                <value>七里香</value>
                <value>借口</value>
                <value>外婆</value>
                <value>将军</value>
                <value>搁浅</value>
                <value>乱舞春秋</value>
                <value>困兽之斗</value>
                <value>园游会</value>
                <value>止战之殇</value>
            </list>
        </property>
    </bean>

    <aop:config>
        <aop:aspect ref="trackCounter">
            <aop:pointcut id="trackPlayed" expression="execution(* com.godman.aspectXml.CompactDisc.playTrack(int)) and args(trackNumber)"/>
            <aop:before pointcut-ref="trackPlayed" method="countTrack"/>
        </aop:aspect>
    </aop:config>

</beans>

可以看到,我们使用了和前面相同的aop命名空间XML元素,它们会将POJO元素声明为切面。唯一明显的差别在于切点表达式中包含了一个参数,这个参数会传递到通知方法中。测试代码如下:

package test.com.godman.aspectXml; 

import com.godman.aspectXml.CompactDisc;
import com.godman.aspectXml.TrackCounter;
import org.junit.Assert;
import org.junit.Test;
import org.junit.Before; 
import org.junit.After;
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;

/** 
 * TrackCounter Tester. 
 * 
 * @author <Authors name> 
 * @since <pre>八月 4, 2018</pre> 
 * @version 1.0 
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "/SpringTrackCounter-config.xml")
public class TrackCounterTest { 

    @Autowired
    private CompactDisc compactDisc;

    @Autowired
    private TrackCounter trackCounter;

    @Test
    public void testTrackPlayed(){
        compactDisc.playTrack(0);
        compactDisc.playTrack(1);
        compactDisc.playTrack(2);
        compactDisc.playTrack(2);
        compactDisc.playTrack(2);
        compactDisc.playTrack(5);
        compactDisc.playTrack(6);

        Assert.assertEquals(3,trackCounter.getPlayCount(2));
    }
}

测试结果如下:
面向切面

4.4 通过切面引入新功能

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

<aop:config>
    <aop:aspect>
        <aop:declare-parents types-matching="com.godman.aspectXml.Performance+" implement-interface="com.godman.aspectJ.Encoreable" default-impl="com.godman.aspectJ.DefaultEncoreable"/>
    </aop:aspect>
</aop:config>

顾名思义,<aop:declare-parents>声明了此切面所通知的bean要在它的对象层级结构中拥有新的父类型。具体到本例中,类型匹配Performance接口(由types-matching属性指定)的那些bean在父类结构中会增加Encoreable接口(由implement-interface属性指定)。最后要解决的问题是Encoreable接口中方法实现要来自于何处。
这里有两种方式标示所引入接口的实现。在本例中,我们使用default-impl属性用来全限定类名来显示指定Encoreable的实现。或者,我们还可以使用delegate-ref属性来标识。它引用一个Spring bean 作为引入的委托:

<bean id="defaultEncoreable" class="com.godman.aspectJ.DefaultEncoreable"/>
<aop:config>
    <aop:aspect>
        <aop:declare-parents types-matching="com.godman.aspectXml.Performance+" implement-interface="com.godman.aspectJ.Encoreable" delegate-ref="defaultEncoreable"/>
    </aop:aspect>
</aop:config>

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