Spring实战4—面向切面编程-阿里云开发者社区

开发者社区> 开发与运维> 正文

Spring实战4—面向切面编程

简介:

主要内容

  • 面向切面编程的基本知识
  • 为POJO创建切面
  • 使用@AspectJ注解
  • 为AspectJ的aspects注入依赖关系

在南方没有暖气的冬天,太冷了,非常想念北方有暖气的冬天。为了取暖,很多朋友通过空调取暖,但是空调需要耗电,也就需要交不少电费。没家都会有一个电表,每隔一段时间都会有记录员来家里收取这段时间的电费。

现在做个假设:去掉电表和电费收取员,因此也没有人定期来家里收电费。这时就需要我们隔段时间主动去电力公司交电费,尽管会有执着的家庭主妇会认真得记录每个月各个电器用了多少度电,并计算出应该交给电力公司多少钱,但是大部分人都做不到这么精确。基于诚信的电费计算系统对于消费者来说并不是坏事,但是对于电力公司来说确实灾难。

监控电力的消耗是非常重要的,但它并不是每个家庭主妇头脑中排在第一位的事情。买菜、打扫卫生、做饭、监督孩子的吃穿等等才是最重要的事情。

软件系统中的某些功能就类似于我们家中的电表。我们需要在应用中的每个节点应用这些功能,但是每次都显式得调用它们又感觉太过啰嗦和浪费。日志、安全和事务管理的确特别重要,但是它们是否应该跟业务逻辑写在一起?或者说,业务模块就应该专注于业务逻辑,而把上述这类的模块分别单独交给一个模块处理。

在软件开发中,将这类涉及应用中的多个模块的功能称为交叉关注点。按照惯例,这些交叉关注点应该与业务逻辑代码剥离,但是实际上经常是耦合在一起。面向切面编程要做的工作就是将这些交叉关注点与业务逻辑代码分开。

这篇文章用于探索Spring框架对面向切面编程的支持,包括如何定义需要被切面(aspect)覆盖的类,如何使用注解创建切面;这篇文章还将介绍AspectJ——第三方的AOP实现,看看如何将AspectJ与Spring框架整合使用。

4.1 何为面向切面编程?

如文章开头所说,切面可以用于将交叉关注点模块化。简单来说,交叉关注点值得是那些影响一个应用中多个模块的通用功能。例如,安全处理是一个交叉关注点,在应用中的很多模块中都需要应用一定的安全检查,下图展示了应用中交叉关注点与业务模块的关系。

1240
Aspects 用于模块化交叉关注点

这张图展示的是一个典型的模块化应用,每个模块负责提供针对某个特定领域(domain)的服务,但是每个模块也需要一些相同的辅助功能,例如安全、事务管理等等。

面向对象编程技术常常通过继承和委托实现代码复用。如果在应用中所有对象都继承自一个基类,这样的继承体系并不稳定;使用委托,则在遇到复杂对象时显得比较笨重。

切面则提供了一个更清楚、更轻量级的选择。利用AOP,你可以将一些通用功能集中在一个模块中,并规定在什么地方什么时候将这些功能应用在业务模块上,而且不需要修改业务模块的代码。把交叉关注点模块化到某个特定的类,这个类就称为切面(aspects),这有两个优点:

  • 关注点分离,而不是与业务逻辑代码混合在一起;
  • 业务模块更加清晰,因为它们只需要关注业务逻辑部分;

4.1.1 定义AOP术语

和大多数技术类似,AOP技术也有自己的行话。切面(Aspects)常常通过通知(advice)、切点(pointcuts)和织入点(join points)来描述。下图展示了这几个概念如何被联系在一起。

1240
切面的功能(advice)通过一个或者多个织入点织入到应用的执行流程

很多AOP的术语都不太直观,我作为开发人员也是经历一段时间的使用之后才理解其背后的含义。为了更好得学习AOP技术,最好先认真学习下各个术语的含义,然后带着疑问去阅读。

通知(ADVICE)

电表记录员来家里的目的是读取你家在过去一段时间所用的电量,并反馈给电力公司。他有需要查看记录的清单,并且这些记录十分重要,但是实际上,记录用电量是电表记录员的主要工作。

切面也有它的目的——它真正要做的工作,在AOP术语体系中,切面真正要做的工作称之为通知(advice)

通知负责定义切面的whatwhen——即这个切面负责什么工作,以及何时执行这个工作。应该在方法调用前执行切面的任务?还是在方法调用后执行切面的任务?还是应该在方法调用之前和之后都执行切面的任务?还是仅仅在方法调用抛出异常时执行切面的任务?

Spring切面支持以下五种通知:

  • Before——前置通知,在调用目标方法之前执行通知定义的任务;
  • After——后置通知,在目标方法执行结束后,无论执行结果如何都执行通知定义的任务;
  • After-returning——后置通知,在目标方法执行结束后,如果执行成功,则执行通知定义的任务;
  • After-throwing——异常通知,如果目标方法执行过程中抛出异常,则执行通知定义的任务;
  • Around——环绕通知,在目标方法执行前和执行后,都需要执行通知定义的任务。

织入点(JOIN POINTS)

一个电力公司通常服务于一个城市,每家都有一个电表需要电表记录员定期去抄电表。同样,在应用中可能有很多个机会可以应用通知,这些机会就叫做织入点织入点类似一个插槽,通过织入点可以将切面织入到应用的执行流中。织入点可能是正在调用的方法、正在抛出的异常或者是正在被修改的属性。

切点(POINTCUTS)

让一个电表记录员负责所有家庭的电表显然不太可能,实际情况是,每个电表记录员都有自己负责的区域。同样,切面也不需要涵盖应用中的所有织入点,通过切点可以缩小切面接入应用时需要指定的范围。

如果说通知是定义了切面的whatwhen这两个方面,那么切点就定义了where。切点指定一个或者多个织入点,而通知可以通过切点接入。通常情况下可以使用明确的类名和函数名或者定义了匹配模式的正则表达式来定义切点;还有一些AOP框架支持定义动态切点(dynamic pointcuts),可以在运行时根据函数参数值决定是否应用通知。

ASPECTS

当一名电表记录员开始一天的工作时,他很清楚得知道自己要做什么(记录用电量)和去哪里抄电表。同样地,通知和切点合起来就构成了切面——what、when和where。

INTRODUCTIONS

你可以通过introduction给现有的类增加方法或者属性。例如,可以定义一个通知类Auditable,用于保存某个对象被修改前的上一个状态——定义一个局部变量来保存这个状态,然后使用setLastModified(Date)方法设置状态。类似于设计模式中的装饰者模式——在不改变现有类的基础上为之增加属性和方法。

WEAVING

编织值得是将切面应用于模板对象来创建代理类的过程,切面在指定的织入点被编织入目标对象。在目标对象生命周期的下列几个节点,可能发生“编织”:

  • Compile time——在编译过程中将切面织入到目标对象中,AspectJ的织入编译器是这么做的;
  • Class load time——在将目标类加载到JVM时将切面织入到目标对象中,这需要依赖特定的ClassLoader,并且在织入之前修改目标对象的字节码文件。AspectJ 5的load time weaving(LTW)支持这种方式。
  • Runtime——在应用程序执行过程中将切面织入到目标对象中。一般而言,AOP容器会动态生成目标对象的代理,然后将切面织入到应用的执行过程。Spring AOP是这么做的。

以上就是关于AOP术语的基本介绍,接下来看看这些概念在Spring中的实现。

4.1.2 Spring的AOP支持

几种AOP框架的主要不同在于织入点模型:一些框架允许你在属性修改的层次应用通知,而其他框架则仅仅支持函数调用的层次应用通知;这些框架在如何织入和何时织入这两方面也有所不同。尽管存在这些不同点,但是总体来讲AOP框架的作用是创建切点来定义织入点,使得切面可以被织入到应用程序的执行过程。

Spring对AOP的支持来自以下四种形式:

  • 基于代理的Spring AOP
  • Pure-POJO aspects
  • 基于@AspectJ注解的aspects
  • 注入AspectJ aspects(所有版本的Spring都支持)

前三种属于Spring自己的AOP实现:Spring AOP基于动态代理机制构建,也正是因为这个原因,Spring AOP仅仅支持函数调用级别的拦截。

classic(经典)一词常常代表优秀的作品,然而Spring的经典AOP编程模型并不是这样。该模型在它的时代是最好的,但是现在的Spring已经支持更加清晰和容易的方法来面向切面编程。相对于更易于定义的AOP和基于注解定义的AOP,Spring的经典AOP显得过于笨重和复杂了,因此在这里我也不会详细介绍Spring AOP。

通过Spring的aop名字空间,可以将pure pojo转换成切面。实际上,这些POJO仅仅提供需在切点织入并执行的方法,尽管这需要基于XML配置文件,但这确实是一种声明式得奖任何对象转换成切面的方法。

Spring借鉴AspectJ框架的设计,引入了基于注解的AOP。本质上还是基于代理的AOP,但是编程模型则类似于AspectJ框架中被注解修饰的切面。这种AOP形式的最大优点是不需要XML配置。

Spring AOP技术可以完成简单的函数级拦截,例如构造函数、属性修改等等,但是如果需要实现更复杂的AOP功能,则应使用AspectJ框架。这篇文章侧重介绍Spring AOP技术,在开始之前,首先了解几个重要的点。

SPRING ADVICES IS WRITTEN IN JAVA

在Spring中创建的所有通知都是标准的Java类,切点可以通过注解或者XML文件定义,但是对于Java开发人员来说这两种方式都比较熟悉。

尽管AspectJ支持注解驱动的切面,它实际上是对Java的扩展。这有好有坏:通过AOP-sepcific语言,你能够实现更细致的控制和更丰富的功能,但是你也需要学习一门新的工具和语法。

SPRING ADVICES OBJECT AT RUNTIME

在Spring AOP框架中,通过在Spring管理的beans的外围包含一个代理类来将切面织入到这些beans。如下图所示,调用者跟代理类直接联系,代理类拦截函数调用,然后执行切面逻辑之后再调用真正的目标对象的方法。

1240
基于代理机制实现AOP

只有在应用需要使用被代理bean时,Spring才会创建代理对象。如果你使用ApplicationContext,代理的对象会在Spring从BeanFactory中加载bean的时候创建。由于Spring在运行时创建代理对象,因此Spring AOP中不需要特定的编译器。

SPRING ONLY SUPPORTS METHOD JOIN POINTS

Spring AOP仅仅支持函数级别的织入点,这不同于其他AOP框架,例如AspectJ和JBoss除了提供函数级别的织入点外,还支持属性和构造器级别的织入点。使用Spring AOP不能实现细粒度的通知,例如拦截对某个属性的更新;同样也不能在某个bean初始化的时候应用切通知。不过,基于函数级别的拦截已经足够满足开发者的大多数需求了。

4.2 利用切点选择织入点

正如之前提到的,切点的功能是指出切面的通知应该从哪里织入应用的执行流。和通知已于,切点也是构成切面的基本概念。

在Spring AOP中,使用AspectJ的切点表达式语言定义切点。如果你已经熟练使用AspectJ,那么在Spring中定义切点对你来说就很自然。如果你是AspectJ的新手,那么这节内容可以教会你如何快速上手,写出AspectJ-style的切点。如果你希望详细学习AspectJ和AspectJ's expression language,那么我推荐AspectJ inAction, Second Edition

下列这个表格列出了在Spring AOP中可用的AspectJ的切点描述符:

1240
Spring使用AspectJ的切点表达式语言定义切面

除了上面列出的之外,如果你试图使用AspectJ的其他描述符就会导致IllegalArgumentException异常。在上面这些描述符中,只有execution()实际执行匹配操作,这是最重要的描述符,其他描述符用于辅助。

4.2.1 编写切点

首先定义一个Performance接口:

package concert;

public interface Performance {
    public void perform();
}

Performance代表任何现场表演,例如舞台剧、电影或音乐会。假设你需要写一个切面,该切面会覆盖Performanceperform()方法。下图展示了如何定义一个切点,满足这个切点定义的方法在执行时会触发通知任务执行。

1240
利用切点表达式选择要影响的方法


在这里使用execution()描述符选择Performanceperform方法:第一个*表示不关心函数的返回类型;接下来需要列出完整的类签名和方法名;对于函数参数列表,使用".."表示不关心函数的参数列表。

假设你需要限制切点的作用范围仅在concert包种,可以使用within()描述符,如下图所示:

1240
通过within()描述符限制切点的作用范围

使用&&符号表示与关系,类似得,使用||表示或关系、使用!表示非关系。在XML文件中使用andornot这三个符号。

4.2.2 在切点中引用bean

除了表4.1中列出的描述符,Spring还提供了一个bean()描述符,用于在切点表达式中引用bean。举个例子,如下所示的代码表示:你需要将切面应用于Performanceperform方法上,但是仅限于ID为woodstock的bean。

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

同样也可以排除指定的bean,例子代码如下:

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

4.3 利用注解创建切面

在AspectJ 5中引入的最重要的特性就是使用注解创建切面。

4.3.1 定义切面

如果没有观众,一场表演不能称之为真正的表演。当你站着表演的角度思考,观众是重要的,但是那并不是表演应该处理的最主要的工作,这两个关注点不同。因此,需要将观众定义为一个切面,然后应用在表演上。

Audience类的代码如下所示:

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

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

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

    @AfterThrowing("execution(* com.spring.sample.concert.Performance.perform( .. ))")
    public void demandRefund() {
        System.out.println("Demand a refund");
    }
}

在这里使用@Aspect注解修饰Audience类,表示该类是一个切面,该类中定义的方法都用于执行该切面的不同功能。

Audience类中的四个方法定义了观众在观看演出时可能有的反应。在演出开始之前,观众应该按时就坐(takeSeats())并将手机静音(silenceCellPhones());如果演出很精彩,观众就会鼓掌(applause()),如果演出出现故障和意外情况,观众就会要求退票(demandRefund())。

这些方法都被通知注解修饰,用于指定何时调用对应的方法。AspectJ提供了五种定义通知的注解,如下表所示:

1240
Spring使用AspectJ的注解定义通知

Audience类用到了其中的三种,takeSeats()silenceCellPhones()方法都是由@Before注解修饰,表示这两个方法应该在演出开始之前被调用;applause()方法被@AfterReturning注解修饰,表示该方法是在演出圆满结束之后被调用;demandRefund()方法被@AfterThrowing注解修饰,表示如果演出过程中出现意外,则会调用该方法。

所有这些通知注解都传入了一个切点表达式作为参数,这些参数可能会不同,但是在我们现在的这个例子中是相同的,为了消除代码重复,可以使用@Pointcut注解定义可重复使用的切点,下列是我修改过后的Audience代码。

package com.spring.sample.concert;

import org.aspectj.lang.annotation.*;

@Aspect
public class Audience {
    @Pointcut("execution(* com.spring.sample.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!!!");
    }

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

除了作为标记的performance()方法,Audience类完全是一个POJO,因此它也可以像普通Java类一样使用:

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

到此为止,Audience仅仅是位于Spring容器中的一个bean,即使它被AspectJ注解修饰,如果没有别的配置解释这个注解,并创建能够将它转换成切面的代理,则它不会被当做切面使用。

如果你使用JavaConfig,则可以通过类级别的@EnableAspectJAutoProxy注解开启自动代理机制,例子代码如下所示:

package com.spring.sample.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 //开启AspectJ的自动代理机制
@ComponentScan
public class ConcertConfig {
    @Bean
    public Audience audience() { //定义Audience的bean
        return new Audience();
    }
}

如果你使用XML配置,则可以使用<aop: aspectj-autoproxy />元素开启AspectJ的自动代理机制,对应的配置代码如下:

<?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"
       xmlns:context="http://www.springframework.org/schema/context"       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

       <context:component-scan base-package="com.spring.sample.concert" /> 

       <aop:aspectj-autoproxy />

       <bean class="com.spring.sample.concert.Audience" />
</beans>

无论使用JavaConfig还是XML配置文件,AspectJ的自动代理机制使用由@Aspect注解修饰的bean为那些被切点指定的beans创建代理。在这个例子中,将会为Performance接口创建代理,并在perform()方法调用前或者调用后应用切面中的通知方法。

特别要记住:Spring中的AspectJ自动代理机制本质上还是Spring中基于代理的切面,因此,虽然你使用了@Aspect注解,但是仍然仅能支持函数调用级别的拦截。如果你希望使用AspectJ的功能,那么你得使用AspectJ的运行时并且不要使用Spring创建基于代理的切面。

环绕通知(around advice)与其他通知类型不同,因此值得用一小节单独论述。

4.3.2 创建环绕通知

环绕通知本质上是将前置通知、后置通知和异常通知整合成一个单独的通知。为了演示环绕通知的用法,我们需要再次重写Audience切面——仅使用一个单独的通知,代替多个分开的通知方法。

package com.spring.sample.concert;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;

@Aspect
public class Audience {
    @Pointcut("execution(* com.spring.sample.concert.Performance.perform( .. ))")
    public void performance() {}

    @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 e) {
            System.out.println("Demanding a refund");
        }
    }
}

@Around注解表示watchPerformance()方法将作为环绕通知应用在与切点——performance()匹配的方法上。这个方法实现的效果跟之前的四个函数完全相同,但是有一点不同,即该函数有一个参数——ProceedingJoinPoint实例,这里需要通过这个参数主动调用业务函数——joinPoint.proceed();。在环绕通知中必须调用proceed()方法,如果没有,则应用的执行会阻塞在通知方法中。

你还可以在一个通知中多次调用proceed()方法,从而可以实现重试逻辑——业务逻辑可能失败,可以限定失败重试的次数。

4.3.3 处理通知中的参数

截止目前为止,我们编写的切面都非常简单——没有接收输入参数。仅有的例外是环绕通知中需要使用ProceedingJoinPoint参数,除此之外其他通知都没有携带任何参数传入被通知的方法中,那是因为perform()方法本身不需要任何参数。

如果你的切面要通知的是一个带参数的函数?切面是否能访问传入函数的参数并使用它们?
举个例子说明,BlankDisc类中有一个play()方法,该方法的功能是遍历所有的tracks并利用每个track对象调用playTrack()方法。

package com.spring.sample.soundsystem;

import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class BlankDisc implements CompactDisc {
    private String title;
    private String artist;
    private List<String> tracks;

    public BlankDisc() {
    }

    public BlankDisc(String artist, String title, List<String> tracks) {
        this.artist = artist;
        this.title = title;
        this.tracks = tracks;
    }

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

    public void playTrack(int num) {
        System.out.println("-Track: " + tracks.get(num));
    }
    //setter和getter在此处省略
}

现在你希望记录每个track被调用的次数,一种方法是直接修改playTrack()方法,通过一个全局变量(例如Map数据结构)记录每个track被调用的次数。但是,track-counting这个逻辑跟play track实际上是两个不同的关注点,因此应该考虑通过AOP实现。

首先定义一个切面,即TrackCounter类,并在playTrack()方法出进行通知,代码可列举如下:

package com.spring.sample.soundsystem;

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<Integer, Integer>();

    @Pointcut(
            "execution(* com.spring.sample.soundsystem.CompactDisc.playTrack( .. )) " +
            "&& 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;
    }
}

跟上一小节创建的切面类似,首先利用@Pointcut注解定义一个切点,然后利用@Before注解定义前置通知。不同的地方在于切点的定义,除了指定被通知的方法,还指定了被通知方法需要的参数trackNumber。下图展示如何理解切点的定义。

关键在于args(trackNumber)标识符,这表示每个传入业务函数playTrack()int参数也将被传入通知,而且,args()中参数的名称必须跟切点方法的签名中的参数名称相同,例如:

@Pointcut(
        "execution(* com.spring.sample.soundsystem.CompactDisc.playTrack( .. )) " +
        "&& args(ex)")
public void trackPlayed(int ex) {}

同样,@Before注解中利用切点函数定义的参数名称,也必须与通知方法签名中的参数完全相同,例如:

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

然后在Spring的配置文件中配置BlankDiscTrackCounter,并开启AspectJ自动代理机制,配置文件代码如下:

package com.spring.sample.soundsystem;

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 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("Lucky in the Sky with Diamonds");
        tracks.add("Getting Better");
        tracks.add("Fixing a Hole");
        tracks.add("testtest");
        tracks.add("hhhhhhhhhh");
        cd.setTracks(tracks);
        return cd;
    }

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

最后,为了验证我们的想法,需要写个单元测试用例进行验证,代码如下:

package com.spring.sample.soundsystem;

import org.junit.Test;
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;
import static org.junit.Assert.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = TrackCounterConfig.class)
public class TrackCounterTest {
    @Autowired
    private CompactDisc cd;

    @Autowired
    private TrackCounter counter;

    @Test
    public void testTrackCounter() {
        cd.playTrack(0);
        cd.playTrack(1);
        cd.playTrack(2);
        cd.playTrack(2);
        cd.playTrack(2);
        cd.playTrack(2);
        cd.playTrack(6);
        cd.playTrack(6);

        assertEquals(1, counter.getPlayCount(0));
        assertEquals(1, counter.getPlayCount(1));
        assertEquals(4, counter.getPlayCount(2));
        assertEquals(0, counter.getPlayCount(3));
        assertEquals(0, counter.getPlayCount(4));
        assertEquals(0, counter.getPlayCount(5));
        assertEquals(2, counter.getPlayCount(6));
    }
}

TrackCounter这个切面可以在显存函数的基础上进行进一步封装,不过除了函数封装,还可以利用切面给被通知的对象引入新的功能。

4.3.4 使用基于注解的切面引入新功能

在一些动态语言(Ruby、Groovy)中,存在开放类的特性,这种特性支持在不修改原来类或者对象的基础上为该类添加新方法。不过,Java不是动态语言,一旦一个类被编译,你几乎不能再对它进行修改。

不过,仔细思考下,上述说的这个需求:在不修改原有类的基础上为该类添加新方法,这不正是切面可以完成的工作么?在上个小节的例子中我们是为原有类的方法添加了新的功能,同样,也可以为原来的类添加新的方法。这里通过AOP引出一个新的概念引入(introductions),即通过切面为Spring的beans增加新方法。

Spring中切面的本质就是一个代理对象,这个代理对象与目前对象实现同一个接口。既然如此,那么可以扩展一下,如果代理对象实现新的接口呢?这样被这个切面通知的bean就好像又实现了一个新的接口——增加了新的功能,即使底层并没有修改原来的类。下图展示了这个思路:

1240
通过Spring AOP可以给bean引入新的方法

当introduced接口的某个方法被调用时,代理对象会把这个调用委托给一个实现了该introduced接口的对象。对于外部而言,就好像一个bean实现了多个接口。

举个例子,假设你要把下面这个Encoreable接口引入给Performance接口的任何实现。

public interface Encoreable {
    void performEncore();
}

你当然可以让原来Performance接口的实现也同时实现这个接口,但是关键是并不是所有的Performance实现都需要引入Encoreable;而且,从应用维护的角度看,全部修改Performance的实现容易引入新的bug;另外,如果Performance接口来自第三方库,你也没有办法接触到源码。

那么利用Spring AOP如何操作呢?
首先准备一个introduced接口的默认实现类,代码如下:

package com.spring.sample.concert;

public class DefaultEncoreable implements Encoreable {
    public void performEncore() {
        System.out.println("perform the encore!");
    }
}

然后新建一个切面,即EncoreableIntroducer类,代码列举如下:

package com.spring.sample.concert;

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

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

EncoreableIntroducer是一个切面,但和之前学过的切面不同在于它没有定义各种通知,它通过@DeclareParents注解将Encoreable接口引入到Performance接口的实现中。

@DeclareParents注解的组成包括三点:

  • value属性用于匹配那些beans需要被引入这个新的接口。在这个例子中是所有Performance的实现都被引入了新的接口(最后的那个+表示,所有Performance的子类型,除了Performance自己)。
  • defaultImpl属性用于指定一个新引入的接口的实现,在这里我们提供了DefaultEncoreable类;
  • 引入的新接口被定义为public static的属性,这里引入了Encoreable接口

跟其他切面的用法类似,需要在Spring应用上下文中定义EncoreableIntroducer bean,如果使用JavaConfig,则代码如下:

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

Spring 的自动代理机制从这里获取这个bean。当Spring发现一个被@Aspect注解修饰的bean,就会自动为它创建一个代理对象,负责将外部的函数调用委托给目标bean或者新引入接口的实现,至于由哪个实现负责执行,取决于这个函数属于原接口还是新引入的接口。

书中没有的
如果这个小节只说到这,你可能会有疑惑,那你说的这个引入新接口这么牛,什么场景下怎么使用呢?针对这个疑惑,我写了一个单元测试,代码如下:

package com.spring.sample.soundsystem;

import com.spring.sample.concert.ConcertConfig;
import com.spring.sample.concert.Encoreable;
import com.spring.sample.concert.Performance;
import org.junit.Test;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 = ConcertConfig.class)
public class EncoreIntroducerTest {
    @Autowired
    private Performance musicPerformance;

    @Test
    public void testEncore() {
        Encoreable encoreable = (Encoreable)musicPerformance; //使用方法
        encoreable.encore();
    }
}

可以看到,本来musicPerformancePerformance的实现,通过强转,我可以调用新接口中的方法了,而且没有修改原来的类和接口;而中间负责将函数调用委托给不同的实现对象的任务就是由切面自动完成。

4.4 在XML文件中定义切面

如果不通过注解定义切面和通知,那么就只能选择使用XML文件。Spring的aop名字空间提供了下列这些元素定义切面。

1240
Spring AOP的配置元素
1240
Spring AOP的配置元素(续)

在此之前我们已经了解过可以利用<aop: aspectj-autoproxy>元素启用AspectJ自动代理机制。这里将了解下其他与注解定义方式等价的XML元素的用法。

首先,将之前的Audience中的注解都去掉,留下的代码如下:

package com.spring.sample.concert;

public class Audience {
    public void silecneCellPhones() {
        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类,如果不定义额外的通知和切点,就没法让Audience作为一个切面去起作用。

4.4.1 定义前置和后置通知

在XML文件中定义前置和后置通知的代码如下:

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

首先需要明白,大部分Spring AOP配置元素需要在<aop:config>元素的上下文中使用。除了定义切面对应的bean,否则一般都以<aop:config>开始。

<aop:config>中,一般需要定义一个或者多个通知,切面和切点。在上面的代码中,首先定义了一个切面,该切面引用了audience这个bean;在切面中定义了前置通知、后置通知和异常通知:method属性指定某个通知对应的方法,pointcut用于指定切点,即在哪里应用通知。下图演示了如何将这些通知编织进具体的业务逻辑。

1240
包含四个通知的切面Audience将通知的逻辑织入到业务方法的执行过程

@Pointcut注解对应的XML元素是<aop: pointcut>,可以消除重复代码,下列的XML配置可以实现同样的功能:

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

在这里,利用<aop:pointcut>元素定义了一个切点,然后在通知中利用pointcut-ref引用它。这里的切点定义在切面audience的作用范围内,也可以定义一个切点让几个切面共用。

4.4.2 创建环绕通知

环绕通知@Around在XML这边的对应元素是<aop: around>
首先修改Audienc类,代码如下:

package com.spring.sample.concert;

import org.aspectj.lang.ProceedingJoinPoint;

public class Audience {
    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 e) {
            System.out.println("Demanding a refund");
        }
    }
}

然后在XML配置文件中修改,用环绕通知代替之前的四个通知,之前用过的切点performance可以使用,method属性改成watchPerformance即可,配置代码如下:

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

4.4.3 给通知传递参数

在4.3.3中,可以使用AspectJ注解创建一个切面,用于记录每个track被调用的次数,同样可以使用XML完成这个功能。

首先将TrackCounter中的注解全部去掉,剩下的POJO的代码如下所示:

package com.spring.sample.soundsystem;

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;

public class TrackCounter {
    private Map<Integer, Integer> trackCounts = new HashMap<Integer, Integer>();

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

原书是将bean的定义全部在xml中重新定义,我为了省事就继续使用(不过是将AOP相关的配置放在XML文件中),bean的配置代码如下:

package com.spring.sample.soundsystem;

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

@Configuration
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("Lucky in the Sky with Diamonds");
        tracks.add("Getting Better");
        tracks.add("Fixing a Hole");
        tracks.add("testtest");
        tracks.add("hhhhhhhhhh");
        cd.setTracks(tracks);
        return cd;
    }
    @Bean
    public TrackCounter trackCounter() {
        return new TrackCounter();
    }
}

然后在XML文件中引入上述TrackCounterConfig配置文件,并定义AOP相关的配置,XML文件如下所示:

<?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-4.0.xsd"> 

   <bean class="com.spring.sample.soundsystem.TrackCounterConfig" /> 
   <aop:config>
              <aop:aspect ref="trackCounter">
                  <aop:pointcut id="trackPlayed"
                                expression="execution(* com.spring.sample.soundsystem.CompactDisc.playTrack(int))                                 and args(trackNumber) "/>
                  <aop:before method="countTrack"
                              pointcut-ref="trackPlayed"/>
              </aop:aspect>
    </aop:config>
    <aop:aspectj-autoproxy />
</beans>

仍旧使用之前的测试用例TrackCounterTest进行测试,唯一需要改动的是:在测试用例头部导入xml配置文件。我们在这里将concer.xml文件作为总的配置文件,部分代码如下:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:concert.xml")
public class TrackCounterTest {
   ....
}

还有一个需要注意的,在XML配置文件中,一般使用and、or和not代替Java文件中使用的&&、||和!

4.4.4 使用切面引入新的功能

在4.3.4小节,我们介绍了如何利用Spring的AOP技术为现有类增加额外的方法——通过@DeclareParents注解给被通知的方法引入新的方法,可以利用<aop: declare-parent>元素实现同样的功能。

下列这个XML代码片段跟之前的注解形式的引入功能相同:

<aop:aspect>
    <aop:declare-parents types-matching="com.spring.sample.concert.Performance+"
                         implement-interface="com.spring.sample.concert.Encoreable"
                         default-impl="com.spring.sample.concert.DefaultEncoreable"/>
</aop:aspect>
  • types-match属性的作用和之前的value属性相同,用于指定被通知的bean;
  • implement-interface属性的作用和之前的静态变量相同,用于指定新接口;
  • defautl-impl属性的作用是指定新接口的一个默认实现类;这个属性还可以使用delegate-ref属性代替,不过需要在spring上下文中定义DefaultEncoreable的bean。

4.5 注入AspectJ的切面

Spring AOP的上述功能已经足以应付大部分需求,此处暂不深究。

4.6 总结

对于面向对象编程技术,AOP是一个功能强大的补充。利用切面,你可以将那些涉及应用多个模块的通用功能集中在一个模块中。你可以定义在哪里以及如何应用这些功能。这降低了代码重复,并且使得业务逻辑类专注于核心业务。

在pom文件中要加的依赖有:

<dependency>
   <groupId>org.springframework</groupId>
   <artifactId>spring-aop</artifactId>
   <version>${spring.version}</version>
</dependency>
<!-- AspectJ -->
<dependency>
   <groupId>org.aspectj</groupId>
   <artifactId>aspectjrt</artifactId>
   <version>${aspectj.version}</version>
</dependency>
<dependency>
   <groupId>org.aspectj</groupId>
   <artifactId>aspectjweaver</artifactId>
   <version>${aspectj.version}</version>
</dependency>

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章