[原]Spring实战(7):面向切面的Spring

贺含悦 18/05/07 22:08:04

在软件开发中,散布于应用中多处的功能被称为横切关注点。通常来讲,这些横切关注点从概念上是与应用的业务逻辑相分离的。把这些横切关注点与业务逻辑相分离正是面向切面编程所要解决的问题。

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

下面我们就来看Spring是如何实现切面的,先从AOP的基础知识开始~

一、什么是面向切面编程?

在之前的Java开发中,如果要重用通用功能,最常见的面向对象技术是继承或委托。但是,如果整个应用中都使用相同的基类,继承往往会导致一个脆弱的对象体系,而使用委托可能需要对委托对象进行复杂的调用。
现在,切面提供了取代继承和委托的另一种可选方案,而且在很多场景下更清晰简洁。在使用面向切面编程时,我们仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无需修改受影响的类。

横切关注点可以被模块化为特殊的类,这些类被称为切面。

定义AOP术语

AOP也形成了自己的术语:通知、切点和连接点。
这里写图片描述
接下来我们就来了解这些术语~

1)通知Advice

切面的工作被称为通知,通知定义了切面是什么以及何时使用。
Spring切面可以应用5种类型的通知:

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

2)连接点Join point

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

3)切点Point

切点定义了切面的地点,即”何处“。切点的定义会匹配通知所要织入的一个或多个连接点。我们通常使用明确的类和方法名称,或是利用正则表达式定义所匹配的类和方法名称来指定这些切点。

4)切面Aspect

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

5)引入Introduction

引入允许我们向现有的类添加新方法或属性。
简单来说就是,现在我们可以通过AOP的引入机制,为当前的类引入新的属性或者方法,而这些新的属性和方法由是不属于原有类的属性和方法,它们不必在原有类中存在和实现就能被现有类使用。这可能听起来有些神奇和不可思议,但是它确实是可以实现的,我们将在后面详细说明并举例。

6)织入Weaving

织入就是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。

我们了解了AOP术语,现在来看看AOP的核心概念是如何在Spring中实现的。

Spring对AOP的支持

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

  • 基于代理的经典Spring AOP
  • 纯POJO切面
  • @AspectJ注解驱动的切面
  • 注入式AspectJ切面

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

第一种经典的Spring AOP的编程模型在现在看来过于复杂,所以不推荐使用。
第二种纯POJO虽然简便,但是这种技术需要依赖XML配置。
第三种Spring借鉴了AspectJ的切面,以提供注解驱动的AOP。本质上,它仍然是Spring基于代理的AOP,但是编程模型几乎与编写成熟的ASpectJ注解切面完全一致,并且不需要XML来完成功能。
第四种就是如果你的AOP需求超过了简单的方法调用(如构造器或者属性拦截),那么就需要使用AspectJ来实现切面了。

在后面的例子中,我们会使用第三种类型的AOP,并且补充一点关于AspectJ的使用方式。

Spring通知是Java编写的

Spring所创建的通知都是使用标准的Java类编写的。这样我们就可以使用与普通Java开发一样的IDE来开发切面。而且,定义通知所应用的切点通常会使用注解或者XML来编写。

而AspectJ与之相反,虽然AspectJ现在也支持基于注解的切面,但是AspectJ最初是以Java语言扩展的方式实现的,也就是有特定的AOP语言。这种方式有好也有坏,好处就是我们可以使用AOP工具集来简化AOP开发,但是坏处就是需要额外学习AOP语言。

Spring在运行时通知对象

通过在代理类中包裹切面,Spring在运行期间把切面织入到Spring管理的Bean中。如下图所示:
这里写图片描述
直到应用需要被代理的Bean时,Spring才创建代理对象。如果使用的是ApplicationContext的话,在ApplicationContext从BeanFactory中加载所有Bean的时候,Spring才会创建被代理的对象。因为Spring运行时才创建代理对象,所以我们不需要特殊的编译器来织入Spring AOP的切面。

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

Spring基于动态代理,所以Spring只支持方法连接点。这与其它的一些AOP框架是不同的,比如AspectJ,除了方法切点,还提供了字段和构造器接入点。但是方法拦截可以满足大多数的需求,如果需要方法拦截之外的连接点拦截功能,我们可以使用AspectJ来补充Spring AOP的功能。


二、通过切点来选择连接点

通知和切点是切面的最基本元素,因此了解如何编写切点非常重要。在Spring AOP中,要使用AspectJ的切点表达式语言来定义切点。关于Spring AOP的AspectJ切点,最重要的一点就是Spring仅支持AspectJ切点指示器的一个子集。

Spring是基于代理的,而某些切点表达式是与基于代理的AOP无关的,下表是Spring AOP所支持的AspectJ切点指示器:

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

在Spring中尝试使用AspectJ其他指示器时,将会抛出IllegalArgumentException异常。
以上指示器只有excution指示器是实际执行匹配的,而其他指示器都是用来限制匹配的。这说明execution指示器是我们在编写切点定义时最主要使用的指示器。

编写切点

为了阐述Spring中的切面,我们需要一个主题来定义切面的切点,所以我们定义一个Performance接口:

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

Performance可以代表任何类型的现场表演,如舞台剧、电影或音乐会。为了处理在表演过程中的一些事情,我们将切点定义为当perform()方法执行时触发通知,执行切面逻辑。

那么这个切点表达式应该这样写:

execution(* concert.Performance.perform(..))

解释这个切点表达式:

  • 使用execution()指示器选择Performance的perform()方法
  • 方法表达式以*开始,表明我们不关心方法返回值的类型
  • 指定了全限定类名和方法名
  • 在方法参数列表中使用两个点号(. .)表明了切点要选择具有任意参数的perform()方法

这个切点表达式仅仅是在perform()方法被执行时触发通知,这也就说我们可以在其他的包里来实现Performance接口的perform()方法,从而触发通知。如果设计一个场景,限制只能匹配concert包,要使用&&操作符:

execution(* concert.Performance.perform(..)) && within(concert.*)

因为&在XML中有特殊含义,所以XML中切点表达式的”&&”为and,”||”为or,”!”为not

在切点中选择Bean

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

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

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

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

三、使用注解创建切面

我们已经定义了Performance接口,它是切面中切点的目标对象。现在,让我们使用AspectJ注解来定义切面。

定义切面

一场演出需要观众,我们将观众定义为切面,在后面将其应用到演出上~
下面我们来看一下抽象为切面的观众类Audience:

@Aspect
public class Audience {

    @Before("execution(** concert.Performance.perform(..))")
    public void silenceCellPhones() {
        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有四个方法,定义了一个观众在观看演出时可能会做的事情。在演出前要就做并将手机静音。演出精彩观众会鼓掌,演出没达到预期观众会要求退款。
我们发现,Aspect提供了五个注解来定义通知:

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

上面的这五种注解都需要给定一个切点表达式作为它的值。
而在Audience这个切面中,四个通知方法的注解都使用了同一个切点表达式,这是令我们不太满意的地方,所以我们来优化一下,只定义一次这个切点,然后在每次需要的时候引用它就可以了。
在一个切面里面使用@Pointcut来定义可重用的切点。

@Aspect
public class Audience {

    @Pointcut("execution(** 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("Demanding a refund");
    }
}

performance()方法的实际内容并不重要,这个方法本身只是一个标识,供@Pointcut注解依附。
我们还观察到,除了注解和没有实际操作的performance()方法,Audience仍然是一个普通的POJO,只不过它通过注解表明会作为切面使用而已。我们可以像使用其它普通的Java类一样去使用它,这也体现出Spring的最小侵略性编程的特点。

到目前,Audience只会是容器的一个Bean,即使使用了AspectJ注解,但它并不会被视为切面,这些注解不会解析,也不会创建将其转换为切面的代理。
我们可以在JavaConfig中使用@EnableAspectJAutoProxy注解启用自动代理功能

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

也可以在XML中使用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 http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

    <!--开启自动扫描-->
    <context:component-scan base-package="concert" />

    <!--启用AspectJ自动代理-->
    <aop:aspectj-autoproxy />

    <!--声明Audience Bean-->
    <bean class="concert.Audience" />

</beans>

不管是使用JavaConfig还是XML,AspectJ自动代理都会为使用@Aspect注解的Bean创建一个代理,这个代理会围绕着所有该切面的切点所匹配的Bean。
我们需要记住的是,Spring的AspectJ自动代理仅仅是使用@AspectJ作为创建切面的指导,但是本质上它仍然是Spring基于代理的切面,这一点很重要,因为这意味着尽管我们使用的@AspectJ注解,但是我们仍然受限于代理方法的调用,如果我们想利用AspectJ的所有能力,就必须使用AspectJ并且不依赖于Spring来创建切面。

创建环绕通知

环绕通知是最为强大的通知类型。它能将你所编写的逻辑将被通知的目标方法完全包装,实际上相当于在一个通知方法中同时编写前置通知和后置通知。
为了阐述环绕通知,我们重写Audience切面:

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

处理通知中的参数

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

execution(* soundsystem.CompactDisc.playTrack(int)) && args(trackNumber)

我们需要关注的是切点表达式中的args(trackNumber)限定符。它表明传递给playTrack()方法的int类型参数也会传递到通知中去。参数的名称trackNumber也与切点方法签名中的参数相匹配。
这个参数会传递到通知方法中,这个通知方法是通过@Before注解和命名切点trackPlayed(trackNumber)定义的。切点定义中的参数与切点方法中的参数名称是一样的,这样就完成了从命名切点到通知方法的参数转移。

方法包装仅仅是切面能实现的功能之一,下面看看如何通过编写切面,为被通知的对象引入全新的功能。


通过注解引入新功能

public intereface Encoreable {
    void performEncore();
}
@Aspect class EncoreableIntroducer {
    @DeclareParents(value="concert.performance+",
                    defaultImpl=DefaultEncoreable.class)
    public static Encoreable encoreable;
}

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

  • value:指明了哪种类型的Bean要引入该接口
  • defaultImpl:指明了为引入功能提供实现的类
  • @DeclareParents注解所标注的静态属性指明了要引入接口

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

<bean class="concert.EncoreableIntorducer" />

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

如果没有源码,或者不想将AspectJ注解放到代码中,Spring也提供了在XML配置文件中声明切面的方案。


四、在XML中声明切面

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

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

声明前置、后置、环绕通知

<aop:config>
    <aop:aspect ref="audience">
        <aop:pointcut
            id="performance"
            expression="execution(** concert.Performance.perform(..))" />
        <aop:before
            pointcut-ref="performance"
            method="silenceCellPhones" />
        <aop:after-returning
            ... />
        <aop:after-throwing>
            ... />
    </aop:aspect>
</aop:config>
<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>

为通知传递参数

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

通过切面引入新的功能

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

五、注入AspectJ切面

虽然Spring AOP能够满足许多应用的切面需求,但是与AspectJ相比,Spring AOP是一个功能比较弱的AOP解决方案。AspectJ提供了Spring AOP所不能支持的许多类型的切点。
例如我们需要在创建对象的时候应用通知,构造器切点就会很方便。但是由于Java的构造方法与普通方法是不同的,使得AOP无法将通知应用于对象的创建过程。所以我们就可以使用AspectJ切面来完成这部分的功能,并借助Spring的依赖注入机制将Bean注入到AspectJ切面中。

举个例子,现在需要给每场演出都创建一个评论员的角色,他会观看演出并且会在演出结束之后提供一些意见。那么下面的CriticAspect就是这样一个评论员的切面:

使用AspectJ实现表演的评论员

//这是一个AspectJ文件,并不是普通的Java文件
public aspect CriticAspect {
    public CriticAspect(){ }

    //定义切点, 并且使得切点匹配perform()方法
    pointcut performamce() : execution(* perform(..));

    //定义通知类型, 并且匹配通知方法
    after() : performance() {
        System.out.println(criticismEngine.getCriticism());
    }

    //依赖注入
    //这里并不是评论员这个类本身来发表评论,发表评论是由CriticismEngine这个接口的实现类提供的.
    //为了避免CriticAspect于CriticismEngine之间产生的不必要的耦合, 
    //我们通过Setter依赖注入为CriticAspect设置CriticismEngine.
    private CriticismEngine criticismEngine;

    public void setCriticismEngine(CriticismEngine criticismEngine){
        this.criticismEngine = criticismEngine;
    }
 }

CriticismEngine接口:

public interface CriticismEngine {
    String getCriticism();
}

CriticismEngineImpl类:

//CriticismEngineImpl类实现了CriticismEngine, 通过从注入的评论池中随机选择一个苛刻的评论
public class CriticismEngineImpl implements CriticismEngine {
    public CriticismEngineImpl(){ }

    public String getCriticism() {
        int i = (int) (Math.random() * criticismPool.length);
        return criticismPool[i];
    }

    private String[] criticismPool;
    public void setCriticismPool(String[] criticismPool){
        this.criticismPool =criticismPool;
    }
}

现在我们已经有了评论员切面CriticAspect,也有了评论类的实现CriticismEngineImpl,现在要做的事情就是在XML中声明CriticismEngineImpl,然后再将CriticismEngine Bean注入到CriticAspect切面中:

    <!--在XML中装配ASpectJ切面-->
    <bean id="criticismEngine" class="Concert.CriticismEngineImpl">
        <property name="criticismPool">
            <list>
                <value>个别演员的表情需要更丰富一点!</value>
                <value>背景音乐太难听了!</value>
                <value>演员肢体动作太僵硬!</value>
                <value>服装太丑了!</value>
            </list>
        </property>
    </bean>

    <bean class="Concert.CriticAspect" factory-method="aspectOf">
        <property name="criticismEngine" ref="criticismEngine" />
    </bean>

在上面的代码中,可以看到最大的不同就是使用了factory-method属性。这是因为在通常情况下,Spring Bean由Spring容器初始化,但是AspectJ切面是由AspectJ在运行期创建的。也就是说,等到Spring有机会为CriticAspect注入criticismEngine时,CriticAspect已经被实例化了。所以Spring需要通过aspectOf()工厂方法获得切面的引用,然后像< bean>元素规定的那样在该对象上执行依赖注入。

aspectOf()方法是所有的AspectJ切面都会提供的一个静态方法,该方法返回切面的一个单例。


现在我们已经覆盖了Spring框架的基础知识,了解到如何配置Spring容器以及如何为Spring管理的对象应用切面。下一章我们开始使用Spring构建真实的应用~

作者:hxllhhy 发表于 2018/05/07 22:08:04 原文链接 https://blog.csdn.net/hxllhhy/article/details/80201636
阅读:10