第 3 章:Spring Framework 中的 AOP

第 3 章:Spring Framework 中的 AOP

讲完了 IoC,我们再来聊聊 Spring Framework 中的另一个重要内容——面向切面编程,即 AOP。它是框架中众多功能的基础,例如声明式事务就是依靠 AOP 来实现的。此外,Spring 还为我们提供了简单的方式来使用 AOP,这有助于简化业务代码中一些共性功能的开发。本章我们会一起去了解 AOP 的基本概念,以及 AOP 在 Spring Framework 中的实现,并学习如何通过使用注解和 XML 文件的方式来配置 AOP 相关的功能。

3.1 Spring 中的 AOP

为了能更好地理解 AOP,本节会先带大家了解一下什么是 AOP,它能做什么,随后展开解释其中的一些核心概念,最后再剖析一下 Spring Framework 中 AOP 的实现原理。

3.1.1 AOP 的核心概念

AOP 是 Aspect Oriented Programming(面向切面编程)的首字母缩写,是一种编程范式,它的目的是通过分离横切关注点(cross-cutting concerns)来提升代码的模块化程度。AOP 的概念最早是由 Xerox PARC提出的,我第一次接触到这个概念则是在 2004 年左右,当时我还在上大学,恰逢学院的一位博士生导师来给本科生上课,课程中他向我们介绍了 AOP,那时主要的 AOP 框架还是 AspectJ。

AOP 中提到的 关注点,其实就是一段 特定的功能,有些关注点出现在多个模块中,就称为 横切关注点。这么说可能有点抽象,举个例子,一个后台客服系统的每个模块都需要记录客服的操作日志,这就是一个能从业务逻辑中分离出来的横切关注点,完全不用交织在每个模块的代码中,可以作为一个单独的模块存在。

整理一下,可以发现 AOP 解决了两个问题:第一是 代码混乱,核心的业务逻辑代码还必须兼顾其他功能,这就导致不同功能的代码交织在一起,可读性很差;第二是 代码分散,同一个功能的代码分散在多个模块中,不易维护。在引入 AOP 之后,一切就变得不一样了。

虽然 AOP 同 OOP(Object-Oriented Programming,面向对象编程)一样,都是一种编程范式,但它并非站在 OOP 的对立面,而是对 OOP 的一个很好的补充。Spring Framework 就是一个例子,它很好地将两者融合在了一起。

在 AOP 中有几个重要的概念,在开始实践前,我们先通过表 3-1 来了解一下这些概念。

表 3-1 AOP 中的几个重要概念

概念说明
切面(aspect)按关注点进行模块分解时,横切关注点就表示为一个切面
连接点(join point)程序执行的某一刻,在这个点上可以添加额外的动作
通知(advice)切面在特定连接点上执行的动作
切入点(pointcut)切入点是用来描述连接点的,它决定了当前代码与连接点是否匹配

借助表 3-1,我们可以将这些概念串联起来:通过切入点来匹配程序中的特定连接点,在这些连接点上执行通知,这种通知可以是在连接点前后执行,也可以是将连接点包围起来。

3.1.2 Spring AOP 的实现原理

在 Spring Framework 中,虽然 Spring AOP 的使用方式发生过很大的变化,但其背后的核心技术却从未改变,那就是 动态代理技术。代理模式是 GoF 提出的 23 种经典设计模式之一,我们可以为某个对象提供一个代理,控制对该对象的访问,代理可以在两个有调用关系的对象之间起到中介的作用——代理封装了目标对象,调用者调用了代理的方法,代理再去调用实际的目标对象,如图 3-1 所示。

image.png

图 3-1 代理模式示意图

动态代理 就是在运行时动态地为对象创建代理的技术。在 Spring 中,由 AOP 框架创建、用来实现切面的对象被称为 AOP 代理(AOP Proxy),一般采用 JDK 动态代理或者是 CGLIB4 代理,两者在使用时的区别具体如表 3-2 所示。

表 3-2 JDK 动态代理与 CGLIB 代理的区别

必须要实现接口支持拦截 public 方法支持拦截 protected 方法拦截默认作用域方法
JDK 动态代理
CGLIB 代理

虽然 CGLIB 支持拦截非 public 作用域的方法调用,但在不同对象之间交互时,建议还是以 public 方法调用为主。

Spring 容器在为 Bean 注入依赖时,会自动将被依赖 Bean 的 AOP 代理注入进来,这就让我们感觉是在使用原始的 Bean,其实不然。

被切面拦截的对象称为 目标对象(target object)或 通知对象(advised object),因为 Spring 用了动态代理,所以目标对象就是要被代理的对象。

以 JDK 动态代理为例,假设我们希望在代码示例 3-1 的方法执行前后增加两句日志,可以采用下面这套代码,先实现调用 Hello 的主流程。

代码示例 3-1 要被动态代理的 Hello 接口及其实现片段

 public interface Hello {void say();}public class SpringHello implements Hello {@Overridepublic void say() {System.out.println("Hello Spring!");}}

随后,我们可以像代码示例 3-2 那样设计一个 InvocationHandler,于是对代理对象的调用都会转为调用 invoke 方法,传入的参数中就包含了所调用的方法和实际的参数。

代码示例 3-2 在 Hello.say() 前后打印日志的 InvocationHandler

    public class LogHandler implements InvocationHandler {private Hello source;public LogHandler(Hello source) {this.source = source;}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {System.out.println("Ready to say something.");try {return method.invoke(source, args);} finally {System.out.println("Already say something.");}}}

最后,再通过 Proxy.newProxyInstance()Hello 实现类的 Bean 实例创建使用 LogHandler 的代理,如代码示例 3-3 所示。

代码示例 3-3 创建 JDK 动态代理并调用方法

    public class Application {public static void main(String[] args) {Hello original = new SpringHello();Hello target = (Hello) Proxy.newProxyInstance(Hello.class.getClassLoader(),original.getClass().getInterfaces(), new LogHandler(original));target.say();}}

这段代码的运行效果如下:

    Ready to say something.Hello Spring!Already say something.

Spring AOP 的实现方式与我们的例子大同小异,相信通过这个例子大家已经能够对其背后的实现原理了解一二了。感兴趣的朋友可以阅读一下 ProxyFactoryBean 的源码,若是采用 JDK 动态代理, AopProxyFactory 会创建 JdkDynamicAopProxy;若是采用 CGLIB 代理,则是创建 ObjenesisCglibAopProxy,前者的逻辑就和我们的例子差不多。

茶歇时间:使用代理模式过程中的小坑

在上面的例子中,我们调用的是代理对象 target 上的方法,并不直接操作原始对象。在 Spring AOP 中,为了能用到被 AOP 增强过的方法,我们应该始终与代理对象交互。如果存在一个类的内部方法调用,这个调用的对象不是代理,而是其本身,则无法享受 AOP 增强的效果。

比如,下面这个类中的 foo() 方法调用了 bar(),哪怕 Spring AOP 对 bar() 做了拦截,由于调用的不是代理对象,因而看不到任何效果,大家需要特别注意这种情况。

    public class Hello {public void foo() {bar();}public void bar() {...}}

3.2 基于 @AspectJ 的配置

回想我第一次接触 AOP 时,AspectJ 的使用体验并不理想。AspectJ 不仅需要编写单独的 Aspect 代码,还要通过 ajc 命令做编译。当然,尽管现在的 AspectJ 也有了长足进步,但 Spring AOP 中所有的东西都是 Java 类,对开发者来说用起来更为统一,体验更好。Spring Framework 同时支持 @AspectJ 注解和 XML Schema 两种方式来使用 AOP,虽然官方并没有明显的偏好,但个人认为注解的方式更贴近 Java 的风格,所以先来介绍一下基于注解的方式。

首先,需要引入 org.springframework:spring-aspects 依赖,以便使用 AspectJ 相关的注解和功能。要开启 @AspectJ 支持,可以在 Java 配置类上增加 @EnableAspectJAutoProxy 注解,比如像下面这样:

    @Configuration@EnableAspectJAutoProxypublic class Config {...}

@EnableAspectJAutoProxy 有两个属性, proxyTargetClass 用于选择是否开启基于类的代理(是否使用 CGLIB 来做代理); exposeProxy 用于选择是否将代理对象暴露到 AopContext 中,两者默认值都是 false

我们也可以通过 XML Schema 的方式来实现相同的效果,如代码示例 3-4 所示,注意要正确地引入 aop 命名空间。

代码示例 3-4 通过 <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"xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsdhttp://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd"><aop:aspectj-autoproxy/></beans>

接下来,在完成配置后,我们就可以使用 @Aspect 注解来声明切面了,将这个注解加到类上即可:

    @Aspectpublic class MyAspect {...}

注意 有两点内容需要重点说明。

(1) 添加 @Aspect 注解只是告诉 Spring“这个类是切面”,但并没有把它声明为 Bean,因此需要我们手动进行配置,例如添加 @Component 注解,或者在 Java 配置类中进行声明。

(2) Spring Framework 会对带有 @Aspect 注解的类做特殊对待,因为其本身就是一个切面,所以不会被别的切面自动拦截。

在声明了切面后,我们就可以配置具体的切入点和通知了,本章的后面会对这些做具体的展开。

3.2.1 声明切入点

注解方式的切入点声明由两部分组成—— 切入点表达式切入点方法签名。前者用来描述要匹配的连接点,后者可以用来引用切入点,方便切入点的复用,具体如代码示例 3-5 所示。

代码示例 3-5 一些简单的切入点声明

    package learning.spring.helloworld;public class HelloPointcut {@Pointcut("target(learning.spring.helloworld.Hello)")public void helloType() {} // 目标对象是learning.spring.helloworld.Hello类型@Pointcut("execution(public * say())")public void sayOperation() {} // 执行public的say()方法@Pointcut("helloType() && sayOperation()") // 复用其他切入点public void sayHello() {} // 执行Hello类型中public的say()方法}

@Pointcut 注解中使用的就是 AspectJ 5 的表达式,其中一些常用的 PCD(pointcut designator,切入点标识符)如表 3-3 所示。

表 3-3 @Pointcut 中的一些常用 PCD

PCD说明
execution最常用的一个 PCD,用来匹配特定方法的执行
within匹配特定范围内的类型,可以用通配符来匹配某个 Java 包内的所有类
thisSpring AOP 代理对象这个 Bean 本身要匹配某个给定的类型
target目标对象要匹配某个给定的类型,比 this 更常用一些
args传入的方法参数要匹配某个给定的类型,它也可以用于绑定请求参数
beanSpring AOP 特有的一个 PCD,匹配 Bean 的 ID 或名称,可以用通配符

因为 execution 用得非常多,下面详细描述一下它的表达式, [] 代表可选项, <> 代表必选项:

    execution([修饰符] <返回类型> [全限定类名.]<方法>(<参数>) [异常])

其中,

  • 每个部分都可以使用 * 通配符
  • 类名中使用 .* 表示包中的所有类, ..* 表示当前包与子包中的所有类
  • 参数主要分为以下几种情况:
    • () 表示方法无参数
    • (..) 表示有任意个参数
    • (*) 表示有一个任意类型的参数
    • (String) 表示有一个 String 类型的参数
    • (String,String) 代表有两个 String 类型的参数

在 Java 中,为了方便标识,我们也经常使用注解,如果类上带了特定的注解,也可以用表 3-4 中的这些 PCD。

表 3-4 针对注解的常用 PCD

PCD说明
@target执行的目标对象带有特定类型注解
@args传入的方法参数带有特定类型注解
@annotation拦截的方法上带有特定类型注解

切入点表达式支持与、或、非运算,运算符分别为 &&、||和 !,还可以进行灵活组合。

最后,我们再提供一些示例:

    // learning.spring.helloworld及其子包中所有类里的say方法// 该方法可以返回任意类型,第一个参数必须是String,后面可以跟任意参数execution(* learning.spring.helloworld..*.say(String,..))// learning.spring.helloworld及其子包within(learning.spring.helloworld..*)// 方法的参数仅有一个Stringargs(java.lang.String)// 目标类型为Hello及其子类target(learning.spring.helloworld.Hello+)// 类上带有@AopNeeded注解@target(learning.spring.helloworld.AopNeeded)

茶歇时间:Spring AOP 与 AspectJ 中 PCD 的不同之处

Spring AOP 中虽然使用了 AspectJ 的切入点表达式,也共用了不少 AspectJ 的 PCD,但其实两者还是有区别的。比如,Spring AOP 中仅支持有限的 PCD,AspectJ 中还有很多 PCD 是 Spring AOP 不支持的。

由于 Spring AOP 的实现基于动态代理,因而只能匹配普通方法的执行,像静态初始化、静态方法、构造方法、属性赋值等操作都是拦截不到的。所以说相比 AspectJ 而言,Spring AOP 的功能弱很多,但在大部分场景下也基本够用。

出于上述差异,在表 3-4 中我们并没有列出 @within 这个 PCD,因为在 Spring AOP 中, @target@within 两者在使用上感受不到什么区别。前者要求运行时的目标对象带有注解,这个注解的 @RetentionRetentionPolicy.RUNTIME,即运行时的;后者要求被拦截的类上带有 @RetentionRetentionPolicy.CLASS 的注解。但 Spring AOP 只能拦截到非静态 public 方法的执行,两个 PCD 的效果一样,所以还是老老实实用 @target 吧。

3.2.2 声明通知

Spring AOP 中有多种通知类型,可以帮助我们在方法的各个执行阶段进行拦截,例如,可以在方法执行前、返回后、抛出异常后添加特定的操作,也可以完全替代方法的实现,甚至为一个类添加原先没有的接口实现。

  1. 前置通知

    @Before 注解可以用来声明一个前置通知,注解中可以引用事先定义好的切入点,也可以直接传入一个切入点表达式,在被拦截到的方法开始执行前,会先执行通知中的代码:

        @Aspectpublic class BeforeAspect {@Before("learning.spring.helloworld.HelloPointcut.sayHello()")public void before() {System.out.println("Before Advice");}// 同一个切面类里还可以有其他通知方法// 这就是一个普通的Java类,没有太多限制}
    

    前置通知的方法没有返回值,因为它在被拦截的方法前执行,就算有返回值也没地方使用,但是它可以对被拦截方法的参数进行加工,通过 args 这个 PCD 能明确参数,并将其绑定到前置通知方法的参数上。例如,要在 sayHello(AtomicInteger) 这个方法前对 AtomicInteger 类型的参数进行数值调整,就可以这样做:

     @Before("learning.spring.helloworld.HelloPointcut.sayHello() && args(count)")public void before(AtomicInteger count) {// 操作count}
    

    要是同时存在多个通知作用于同一处,可以让切面类实现 Ordered 接口,或者在上面添加 @Order 注解。指定的值越低,优先级则越高,在最终的代理对象执行时也会先执行优先级高的逻辑。

  2. 后置通知

    在方法执行后,可能正常返回,也可能抛出了异常。如果想要拦截正常返回的调用,可以使用

    @AfterReturing 注解。例如像下面这样:

     @AfterReturning("execution(public * say(..))")public void after() {}@AfterReturning(pointcut = "execution(public * say(..))", returning = "words")public void printWords(String words) {System.out.println("Say something: " + words);}
    

    printWords() 方法的参数 words 就是被拦截方法的返回值,而且此处限定了该通知只拦截返回值是 String 类型的调用。需要提醒的是, returning 中给定的名字必须与方法的参数名保持一致。

    如果想要拦截抛出异常的调用,可以使用 @AfterThrowing 注解,这个注解的用法与 @AfterReturing 极为类似。例如:

     @AfterThrowing("execution(public * say(..))")public void afterThrow() {}@AfterThrowing(pointcut = "execution(public * say(..))", throwing = "exception")public void printException(Exception exception) {}
    

    如果不关注执行是否成功,只是想在方法结束后做些动作,可以使用 @After 注解:

     @After("execution(public * say(..))")public void afterAdvice() {}
    

    添加了 @After 注解的方法必须要能够处理正常与异常这两种情况,但它又获取不到返回值或异常对象,所以一般只被用来做一些资源清理的工作。

  3. 环绕通知

    还有一种通知类型是环绕通知,它的功能比较强大,不仅可以在方法执行前后加入自己的逻辑,甚至可以完全替换方法本身的逻辑,或者替换调用参数。我们可以添加 @Around 注解来声明环绕通知,这个方法的签名需要特别注意,它的第一个参数必须是 ProceedingJoinPoint 类型的,方法的返回类型是被拦截方法的返回类型,或者直接用 Object 类型。

    例如,我们希望统计 say() 方法的执行时间,可以像代码示例 3-6 那样来声明环绕通知。

    代码示例 3-6 统计方法耗时的环绕通知

     @Aspectpublic class TimerAspect {@Around("execution(public * say(..))")public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {long start = System.currentTimeMillis();try {return pjp.proceed();} finally {long end = System.currentTimeMillis();System.out.println("Total time: " + (end - start) + "ms");}}}
    

    其中的 pjp.proceed() 就是调用具体的连接点进行的处理, proceed() 方法也接受 Ojbect[] 参数,可以替代原先的参数。

    环绕通知虽然很强大,但在日常开发过程中,我们选择能满足需求的通知类型就好,如果 @After 够用,那就不用 @Around 了。

  4. 引入通知

    与前面介绍的几种相比,下面要介绍的最后一种 Spring AOP 通知不太常用。我们可以为 Bean 添加新的接口,并为新增的方法提供默认实现,这种操作被称为 引入(Introduction)。在切面类里声明一个成员属性,该属性的类型就是要引入的类型,在上面添加 @DeclareParents 注解就可以声明引入,可以像下面这样为 Hello 及其子类实现 GoodBye 接口:

     @Aspectpublic class MyAspect {@DeclareParents(value = "learning.spring.helloworld.Hello+", defaultImpl = DefaultGoodByeImpl.class)private GoodBye goodBye;}
    

    引入其实是针对类型进行的增强, value 中仅可填入要匹配的类型,可以使用 AspectJ 类型匹配模式。引入声明后,在 Spring 容器中取到的 Bean 就已经完成了增强,哪怕在前置通知中也是如此。

3.2.3 基于 @AspectJ 的示例

为了便于大家能更好地掌握 Spring AOP 的用法,本节为大家准备了一个基于 @AspectJ 注解的 AOP 示例,如代码示例 3-7 所示,假设这里我们有一个 Hello 接口及其对应实现 SpringHello

代码示例 3-7 Hello 接口及其实现代码片段

 public interface Hello {// 为了方便演示改变参数内容,此处使用StringBufferString sayHello(StringBuffer words);}@Componentpublic class SpringHello implements Hello {@Overridepublic String sayHello(StringBuffer words) {return "Hello! " + words;}}

第一个切面拦截 Hello 类型中的方法执行,我们在传入的 StringBuffer 中追加了一段文字,为了演示多个通知的执行顺序,还增加了 @Order 注解,如代码示例 3-8 所示。

代码示例 3-8 HelloAspect 切面代码片段

    @Aspect@Component@Order(1)public class HelloAspect {@Before("target(learning.spring.helloworld.Hello) && args(words)")public void addWords(StringBuffer words) {words.append("Welcome to Spring! ");}}

第二个切面 SayAspect 中有三部分内容(如代码示例 3-9 所示):

(1) 拦截所有 say 打头的方法,在 StringBuffer 参数中追加目前为止说过的话的计数;

(2) 为 learning.spring.helloworld 包内的类引入了一个 GoodBye 接口;

(3) 通过环绕通知改变了 sayHello() 方法的执行结果,追加了对引入的 GoodBye 接口的调用。

代码示例 3-9 SayAspect 切面代码片段

    @Aspect@Component@Order(2)public class SayAspect {@DeclareParents(value = "learning.spring.helloworld.*",defaultImpl = DefaultGoodBye.class)private GoodBye bye;private int counter = 0;@Before("execution(* say*(..)) && args(words)")public void countSentence(StringBuffer words) {words.append("[" + ++counter + "]\n");}@Around("execution(* sayHello(..)) && this(bye)")public String addSay(ProceedingJoinPoint pjp, GoodBye bye)throws Throwable {return pjp.proceed() + bye.sayBye();}public void reset() {counter = 0;}public int getCounter() {return counter;}}

这个切面中所引入的 GoodBye 接口及其默认实现内容如代码示例 3-10 所示。

代码示例 3-10 GoodBye 接口及其实现的代码片段

    public interface GoodBye {String sayBye();}public class DefaultGoodBye implements GoodBye {@Overridepublic String sayBye() {return "Bye! ";}}

为了验证这个示例的运行结果是否如我们预期的那样,可以编写一个执行类,直接去调用 SpringHellosayHello() 方法。但在实际工作中,大家要写的代码远比例子中的复杂,而且很多时候需要进行各种测试来做验证——有了充分的单元测试,才能保障代码质量。因此,从本节开始,我们的示例中会加入测试用例来验证代码是否符合预期。接下来,就让我们来看看这两种方式的代码该如何编写。

  1. 直接运行代码

    我们通过 AnnotationConfigApplicationContext 可以构建一个基于注解的 Spring 容器,再配合简单的 Java 配置类,这个代码就能运行了,如代码示例 3-11 所示。

    代码示例 3-11 Application 类的代码片段

        @Configuration@EnableAspectJAutoProxy@ComponentScan("learning.spring.helloworld")public class Application {public static void main(String[] args) {AnnotationConfigApplicationContext applicationContext =new AnnotationConfigApplicationContext(Application.class);Hello hello = applicationContext.getBean("springHello", Hello.class);System.out.println(hello.sayHello(new StringBuffer("My Friend. ")));System.out.println(hello.sayHello(new StringBuffer("My Dear Friend. ")));}}
    

    上述代码的执行输出如下:

     Hello! My Friend. Welcome to Spring! [1]Bye!Hello! My Dear Friend. Welcome to Spring! [2]Bye
    
  2. 单元测试

    直接运行代码,然后通过肉眼查看输出内容来判断逻辑是否正确,这种方法虽然简单直观,但不具备在大规模项目中使用的条件——每次改动代码都要人肉测试,既不高效,又浪费人力资源。所以,能用代码来验证的事,我们就要把它们写成自动化测试。

    Maven 工程默认将生产代码和测试代码分开了,生产代码在 main 目录中,而测试代码则写在 test 目录中。为了在项目中使用 JUnit 5 进行单元测试,pom.xml 文件需要引入 spring-testjunit-jupiter 依赖,就像下面这样:

        <dependencies><!-- 省略其他内容 --><dependency><groupId>org.springframework</groupId><artifactId>spring-test</artifactId><version>5.3.15</version><scope>test</scope></dependency><dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.8.2</version><scope>test</scope></dependency></dependencies>
    

    下面我们编写一个 ApplicationTest 类,通过其中的断言(assertion)来判断结果,如代码示例 3-12 所示。

    代码示例 3-12 ApplicationTest 类的代码片段

        @ExtendWith(SpringExtension.class)@ContextConfiguration(classes = Application.class)// 这个@SpringJUnitConfig可以代替上述两行// @SpringJUnitConfig(Application.class)public class ApplicationTest {@Autowiredprivate Hello hello;@Autowiredprivate SayAspect sayAspect;@BeforeEachpublic void setUp() {// Spring容器是同一个,因此SayAspect也是同一个// 重置计数器,方便进行断言判断sayAspect.reset();}@Test@DisplayName("springHello不为空")public void testNotEmpty() {assertNotNull(hello);}@Test@DisplayName("springHello是否为GoodBye类型")public void testIntroduction() {assertTrue(hello instanceof GoodBye);}@Test@DisplayName("通知是否均已执行")public void testAdvice() {StringBuffer words = new StringBuffer("Test. ");String sentence = hello.sayHello(words);assertEquals("Test. Welcome to Spring! [1]\n", words.toString());assertEquals("Hello! Test. Welcome to Spring! [1]\nBye! ", sentence);}@Test@DisplayName("说两句话,检查计数")public void testMultipleSpeaking() {assertEquals("Hello! Test. Welcome to Spring! [1]\nBye! ",hello.sayHello(new StringBuffer("Test. ")));assertEquals("Hello! Test. Welcome to Spring! [2]\nBye! ",hello.sayHello(new StringBuffer("Test. ")));}}
    

    在 IDEA 中执行测试后,可以看到如图 3-2 的测试结果。如果某项测试失败,那么对应测试就不会有绿色的对勾。大家可以通过点击选中某项测试,查看其具体执行情况。

    image.png
    图 3-2 IDEA 中的测试结果

    也可以在命令行中通过 Maven 来执行测试,由于 JUnit 5 对 Maven 及其插件的版本有要求,测试者最好安装 3.6.0 版本以上的 Maven,并在 pom.xml 中修改 maven-surefire-plugin 的版本,比如使用 2.22.0 以上的版本,像下面这样:

       <build><plugins><!-- 为了支持JUnit 5, 使用2.22.0的插件 --><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>2.22.0</version></plugin></plugins></build>
    

    随后在工程目录中执行 mvn test 命令,如果一切顺利,我们就可以在输出中看到类似如下的内容(如果有断言失败,也会在输出中有所提示):

    [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.523 s - in learning.spring.helloworld.ApplicationTest
    [INFO]
    [INFO] Results:
    [INFO]
    [INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
    

3.3 基于 XML Schema 的配置

Spring Framework 除了支持以 @AspectJ 注解的方式来配置 AOP,还支持通过 <aop/> XML Schema 的方式。如果大家习惯使用 XML,也可以考虑采用这种方式。

Spring AOP 相关的 XML 配置,都放在 <aop:config/> 中,比如要声明切面,就可以像代码示例 3-13 那样。切面类的内容和上一节介绍的类似,但无须添加注解。

代码示例 3-13 用 <aop:aspect/> 声明切面

    <?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 https://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd"><aop:config><aop:aspect id="helloAspect" ref="aspectBean"><!-- 其他内容省略 --></aop:aspect></aop:config><bean id="aspectBean" class="..." /></beans>

3.3.1 声明切入点

<aop:config/> 中,我们可以通过 <aop:pointcut/> 来配置切入点。它既可以配置在 <aop:config/> 中,也可以出现在 <aop:aspect/> 中。切入点的 id 可以方便复用, expression 中的切入点表达式就和 3.2.1 节中介绍的一致。例如像下面这样:

    <aop:config><aop:aspect id="helloAspect" ref="aspectBean"><aop:pointcut id="helloType" expression="target(learning.spring.helloworld.Hello)" /><!-- 其他内容省略 --></aop:aspect></aop:config>

<aop:pointcut/>expression 中既可以直接写表达式,也可以写带有 @Pointcut 注解的全限定方法。表达式同样支持运算,可以用 &&||!,或者 andornot 进行组合,考虑到 XML 中用前一种方式比较麻烦,这里建议大家还是尽量使用 andornot。需要注意一点,组合表达式中不能通过 id 来引用其他已经定义的切入点。

3.3.2 声明通知

在 XML 中的通知也和 @AspectJ 注解的类似,只不过换成了 <aop:before/><aop:after-returning/> 等 XML 而已。如果有多个通知要执行,可以让切面类实现 Ordered 接口或者添加 @Order 注解, <aop:aspect/> 中有一个 order 属性也可以配置切面的顺序。

  1. 前置通知

    <aop:before/> 可以用来声明前置通知, method 属性的值是切面的具体方法,其中包含了前置通知的代码逻辑; pointcut 属性的值是切入点表达式,也可以通过 pointcut-ref 属性来使用事先定义好的切入点。例如,代码示例 3-7 的前置通知,可以改写为如下 XML 格式:

        <aop:aspect id="beforeAspect" ref="beforeAspectBean"><aop:before pointcut="learning.spring.helloworld.HelloPointcut.sayHello()" method="before" /></aop:aspect>
    

    pointcut 中也可以使用绑定的方式向方法传递参数,比如用 args()this()target()

  2. 后置通知

    与基于 @AspectJ 注解的方式一样,基于 XML Schema 的后置通知同样分为三类。

    • 正常返回: <aop:after-returning/>
    • 抛出异常: <aop:after-throwing/>
    • 无所谓正常返回还是抛出异常: <aop:after/>

三个标签中都有 pointcutpointcut-refmethod 属性,其作用与 <aop:before/> 中介绍的一样。

<aop:after-returning/> 中还有一个 returning 属性,用来将方法的执行返回传递到通知方法中,属性值需要与方法的参数名一致。当然,我们也可以忽略这个属性,不关心返回值。3.2.2 节中的例子可以改写成下面这样:

    <aop:after-returning pointcut="execution(public * say(..))" returning="words" method="printWords" />

<aop:after-throwing/> 中也与注解一样,有一个 throwing 属性,用来向通知方法中传递抛出的异常。3.2.2 节中的例子同样可以改写成下面这样:

 <aop:after-throwing pointcut="execution(public * say(..))" method="afterThrow" /><aop:after-throwing pointcut="execution(public * say(..))" throwing="exception" method="printException" />

<aop:after/> 则相对简单,没有额外的属性可以配置。上面的例子改写为 XML 后就像下面这样:

   <aop:after pointcut="execution(public * say(..))" method="afterAdvice" />
  1. 环绕通知

    环绕通知的代码实现与使用 @AspectJ 注解时是一样的,只不过将注解换成了 <aop:around/> 的 XML,代码示例 3-8 的声明可以改写成如下 XML:

       <aop:around pointcut="execution(public * say(..))" method="recordTime" />
    

    至于具体的方法定义,可以回顾一下 3.2.2 节中的相关内容和代码示例 3-8。

  2. 引入通知

    XML 中同样也可以声明引入,在 <aop:aspect/> 中通过 <aop:declare-parents/> 就可以实现和 @DeclareParents 注解一样的效果, <aop:declare-parents/> 里有三个属性。

    • types-matching:用来匹配类型,比如 learning.spring.helloworld.*+
    • implement-interface:要引入的接口。
    • default-impl:接口的默认实现。

3.2.2 节中的 @DeclareParents 示例可以改写成下面这样:

   <aop:aspect id="myAspect" ref="myAspectBean"><aop:declare-parents types-matching="learning.spring.helloworld.Hello+"implement-interface="learning.spring.helloworld.GoodBye"default-impl="learning.spring.helloworld.DefaultGoodByeImpl"/><!-- 其他省略 --></aop:aspect>

3.3.3 通知器

如果觉得 XML Schema 的配置方式比较繁琐,在 <aop:config/> 中又有 <aop:aspect/>,又有 <aop:pointcut/>,还有各种通知。为此,Spring Framework 为我们提供了一套通知器(advisor)的 XML 元素,通过 <aop:advisor/> 可以简单地配置出一个仅包含单个通知的切面,通知器中引用的 Bean 要实现如下的 AOP 通知接口。

  • MethodInterceptor:环绕通知。
  • MethodBeforeAdvice:前置通知。
  • AfterReturningAdvice:正常返回的后置通知。
  • ThrowsAdvice:抛出异常的后置通知。

随后,可以像下面这样来定义通知器:

   <aop:config><aop:pointcut id="sayMethod" expression="execution(public * say(..))" /><aop:advisor pointcut-ref="sayMethod" advice-ref="aroundAdvice" /></aop:config><bean id="aroundAdvice" class="learning.spring.helloworld.SayMethodInterceptor" />

3.3.4 基于 XML Schema 的示例

与 3.2 节一样,本节也提供了一个示例帮助大家理解并掌握基于 XML Schema 的 AOP 使用方式。有了 3.2.3 节的基础,本节的例子可以基本照搬 3.2.3 节中的代码,去除所有 @AspectJ 相关的注解,同时将 Bean 配置方式从注解换成 XML。

在项目的 resources 目录中添加一个 applicationContext.xml,内容如代码示例 3-14 所示。可以看到 XML 文件可以完全取代注解来实现 AOP 相关的配置。

代码示例 3-14 完整的 applicationContext.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/beanshttps://www.springframework.org/schema/beans/spring-beans.xsdhttp://www.springframework.org/schema/aophttps://www.springframework.org/schema/aop/spring-aop.xsd"><aop:config><aop:aspect ref="helloAspect" order="1"><aop:before pointcut="target(learning.spring.helloworld.Hello) and args(words)"method="addWords"/></aop:aspect><aop:aspect ref="sayAspect" order="2"><aop:before pointcut="execution(* say*(..)) and args(words)" method="countSentence" /><aop:around pointcut="execution(* sayHello(..)) and this(bye)" method="addSay" /><aop:declare-parents types-matching="learning.spring.helloworld.*"implement-interface="learning.spring.helloworld.GoodBye"default-impl="learning.spring.helloworld.DefaultGoodBye" /></aop:aspect></aop:config><bean id="springHello" class="learning.spring.helloworld.SpringHello" /><bean id="helloAspect" class="learning.spring.helloworld.HelloAspect" /><bean id="sayAspect" class="learning.spring.helloworld.SayAspect" /></beans>

由于容器的配置使用了 XML 文件,所以在 Application 类中也要使用对应的类来加载容器配置,本次我们选择了 ClassPathXmlApplicationContext,具体的执行代码如代码示例 3-15 所示。运行后可以看到与 3.2.3 中一样的输出。

代码示例 3-15 Application 类的代码片段

   public class Application {public static void main(String[] args) {ApplicationContext applicationContext = new ClassPathXmlApplicationContext("applicationContext.xml");Hello hello = applicationContext.getBean("springHello", Hello.class);System.out.println(hello.sayHello(new StringBuffer("My Friend. ")));System.out.println(hello.sayHello(new StringBuffer("My Dear Friend. ")));}}

对于单元测试,我们需要做的改动也非常小,之前的 @ContextConfiguration 中给的是 Java 配置类,这次将其改为提供 CLASSPATH 中的 XML 配置文件,其余不动,具体如下所示:

   @ExtendWith(SpringExtension.class)@ContextConfiguration("classpath:applicationContext.xml")public class ApplicationTest {// 省略}

茶歇时间:超简洁的 JUnit 单元测试入门

在这两节的例子中,我们都使用了 JUnit 5 来进行自动化测试。有了自动化测试的保障,我们就可以在每次修改代码后快速进行验证,这样既能保障质量,又能节省大量人力。因此,很有必要为系统编写测试代码,其中单元测试和集成测试缺一不可。

通过代码示例 3-12 可以看到,带有 @Test 注解的方法会被视为测试方法,在测试方法中务必使用断言进行判断,而不要用输出日志的方式进行人工观察,否则测试代码的价值会大打折扣。 org.junit.jupiter.api.Assertions 类中提供了大量的断言静态方法,比如:

  • 判断两者是否相等的 assertEquals()assertNotEquals()
  • 判断布尔值的 assertTrue()assertFalse()
  • 判断对象是否为空的 assertNull()assertNotNull()

在每个测试方法执行前后,都可以执行一些初始化和清理的逻辑:添加了 @BeforeEach@AfterEach 的方法会分别在测试方法执行前后被 JUnit 执行;如果要在所有测试方法执行前进行总的初始化,可以使用 @BeforeAll 注解,对应的还有所有测试方法执行后执行的 @AfterAll

JUnit 5 可以通过 @ExtendWith 注解来添加扩展,在我们的例子中, @ExtendWith(SpringExtension.class) 就添加了 Spring 的测试支持, @ContextConfiguration 注解指定了用来初始化 Spring 容器的配置类或配置文件。

值得一提的是,JUnit 4 和 JUnit 5 在 API 层面存在不少差异,比如 @Before@After 分别对应了 @BeforeEach@AfterEach@RunWith 对应了 @ExtendWith,两个版本的 assertXxx() 静态方法放在了不同的类里等。如果大家还在使用 JUnit 4,可以查阅官方文档了解具体的用法。鉴于 JUnit 5 在功能上更胜一筹,如果可以的话,建议大家还是使用 JUnit 5,在本书后面的章节也会有更多关于 Spring 的测试支持的例子。

3.4 小结

通过本章的学习,相信大家已经对 Spring AOP 有了一个基本的认识:了解了 AOP 的核心概念以及 Spring Framework 中 AOP 的实现原理;学习了 Spring Framework 提供的两种配置方式,大家可以根据实际情况选择使用基于 @AspectJ 注解的方式,或者基于 <aop/> XML Schema 的方式(无论哪种方式,其中对切面、切入点和通知的定义大同小异)。

此外,本章的两个 Hello 示例,都提供了基于 JUnit 5 的自动化测试代码,演示了如何通过单元测试来验证代码的逻辑。希望大家在日常工作中能更多地使用这种测试方式,本书后续章节也会有更多这方面的内容。

下一章,我们会从 Spring Framework 进入 Spring Boot 的领域,为大家介绍 Spring Boot 的几个核心功能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/348267.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

NSS题目练习8

[SWPUCTF 2022 新生赛]numgame 打开发现不能直接更改数值&#xff0c;会变成负数&#xff0c;快捷键不能用&#xff0c;输入view-source查看源代码&#xff0c;发现js文件 点开后发现最下面有个酷似flag的东西 提交后是错的&#xff0c;看着像是base64&#xff0c;解码后得到另…

Leetcode 100.相同的树

1.题目要求&#xff0c;如图所示: 我们可以用两个数组去解决此题: 1.首先我们要用malloc函数去构造两个数组&#xff0c;还要去构造两个数组的长度,代码块如下图所示: int* p_length (int*)malloc(sizeof(int));*p_length 0;int* q_length (int*)malloc(sizeof(int));*q_l…

果园预售系统的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;管理员管理&#xff0c;用户管理&#xff0c;果树管理&#xff0c;果园管理&#xff0c;果园预约管理 前台账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;论坛&#xff0c;公告&a…

SwiftUI中自定义Shape与AnimateableData的使用

上一篇文章主要介绍了一下在SwiftUI中如何自定义Shape&#xff0c;本篇文章主要介绍Shape中的 一个关键的属性AnimatableData&#xff0c;它用于定义可以被动画化的数据。通过实现 Animatable 协议&#xff0c;可以让自定义视图或图形响应动画变化。 AnimatableData 是 Animata…

盲盒小程序 跨平台兼容性测试策略:打造无缝体验

在盲盒小程序的开发过程中&#xff0c;跨平台兼容性测试是确保应用在不同设备和操作系统上都能提供无缝体验的重要步骤。本文将探讨一些关键的跨平台兼容性测试策略&#xff0c;以助力开发者打造稳定、流畅的小程序。 一、明确测试目标 在进行跨平台兼容性测试之前&#xff0…

2024年汉字小达人活动还有4个多月开赛:来做18道历年选择题备考

结合最近几年的活动安排&#xff0c;预计2024年第11届汉字小达人比赛还有4个多月就启动&#xff0c;那么孩子们如何利用这段时间有条不紊地准备汉字小达人比赛呢&#xff1f; 我的建议是充分利用即将到来的暑假&#xff1a;①把小学1-5年级的语文课本上的知识点熟悉&#xff0…

IDEA创建简单web(servlet)项目(server为tomcat)

引言 鉴于网上很少有关于IDEA开发servlet项目的教程&#xff08;24版idea&#xff0c;并且servlet技术十分复古&#xff0c;很少有人用到&#xff0c;能够理解&#xff0c;该文章旨在为在校的学生提供一个参考&#xff0c;项目技术简单&#xff09;本人在此总结从头开始到项目…

电脑意外出现user32.dll丢失的八种修复方法,有效解决user32.dll文件丢失

遇到与 user32.dll 相关的错误通常是因为该文件已损坏、丢失、或者与某些软件冲突。今天这篇文章寄给大家介绍八种修复user32.dll丢失的方法&#xff0c;下面是一步步的详细教程来解决这个问题。 1. 重新启动电脑 第一步总是最简单的&#xff1a;重新启动你的电脑。许多小问题…

SQL Server 安装后,服务器再改名,造成名称不一致,查询并修改数据库服务器真实名称

SELECT SERVERNAME -- 1.查询旧服务器名称 SELECT serverproperty(servername) AS new --2.查询新服务器名称 -- 3.更新服务器名称 IF SERVERPROPERTY(servername) <> 新服务器名称替换 BEGIN DECLARE server_name NVARCHAR(128) SET server_name 新服务器…

美国空军发布类ChatGPT产品—NIPRGPT

6月11日&#xff0c;美国空军研究实验室&#xff08;AFRL&#xff09;官网消息&#xff0c;空军部已经发布了一款生成式AI产品NIPRGPT。 据悉&#xff0c;NIPRGPT是一款类ChatGPT产品&#xff0c;可生成文本、代码、摘要等内容&#xff0c;主要为为飞行员、文职人员和承包商提…

springboot的WebFlux 和Servlet

Spring Boot 中的 Servlet 定义&#xff1a; 在 Spring Boot 中&#xff0c;Servlet 应用程序通常基于 Spring MVC&#xff0c;它是一个基于 Servlet API 的 Web 框架。Spring MVC 提供了模型-视图-控制器&#xff08;MVC&#xff09;架构&#xff0c;用于构建 Web 应用程序。…

u-boot(二) - 配置

一&#xff0c;u-boot的默认配置 xxx_defconfig 在顶层的Makefile中找到如下规则 %config: scripts_basic outputmakefile FORCE $(Q)$(MAKE) $(build)scripts/kconfig $ target -> %config -> mx6ull_14x14_evk_defconfig command -> $(Q)$(MAKE) $(build)scripts…

最快安装zabbix

部署zabbix 6.x 建议使用红帽系统。 https://download.rockylinux.org/pub/rocky/8/isos/x86_64/Rocky-8.9-x86_64-minimal.iso1> 配置安装yum源 [rootzabbix ~]# yum install https://mirrors.huaweicloud.com/zabbix/zabbix/6.2/rhel/8/x86_64/zabbix-release-6.2-3.el8…

【PIXEL】2024年 Pixel 解除 4G限制

首先在谷歌商店下载 Shizuku 和 pixel IMS 两个app 然后打开shizuku &#xff0c;按照它的方法启动 推荐用adb 启动&#xff08; 电脑连手机 &#xff0c;使用Qtscrcpy最简洁&#xff09; 一条指令解决 shell sh /storage/emulated/0/Android/data/moe.shizuku.privileged.ap…

Shell环境下的脚本编程与应用

Shell是什么&#xff1f; Shell 是一个命令行解释器&#xff0c;它接收用户输入的命令&#xff08;如 ls、cd、mkdir 等&#xff09;&#xff0c;然后执行这些命令。Shell 同时还是一种功能强大的编程语言&#xff0c;允许用户编写由 shell 命令组成的脚本&#xff08;script&…

【沟通管理】项目经理《葵花宝典》之跨部门沟通

为什么每次跟其它部门的沟通总是不欢而散&#xff1f; 为什么每次想好好的就事论事的时候&#xff0c;却总是像在吵架&#xff1f; 为什么沟通总是不同频&#xff1f; 这是不是你作为项目经理在跨部门沟通时经常会遇到的问题&#xff1f; 在企业项目管理中&#xff0c;跨部门沟…

C++ 14 之 宏函数

c14宏函数.cpp #include <iostream> using namespace std;// #define PI 3.14 // 宏函数 // 宏函数缺陷1: 必须用括号保证运算的完整性 #define MY_ADD(x,y) ((x)(y))// 宏函数缺陷2&#xff1a;即使加了括号&#xff0c;有些运算依然与预期不符 #define MY_COM(a,b) ((…

2. 音视频H264

视频软件基本流程 1.什么是H264 H.264是由ITU-T视频编码专家组&#xff08;VCEG&#xff09;和ISO/IEC动态图像专家组&#xff08;MPEG&#xff09;联合组成的联合视频组&#xff08;JVT&#xff0c;Joint Video Team&#xff09;提出的高度压缩数字视频编解码器标准 H265又名高…

蓝牙耳机怎么连接电脑?轻松实现无线连接

蓝牙耳机已经成为许多人生活中不可或缺的一部分&#xff0c;不仅可以方便地连接手机&#xff0c;还能轻松连接电脑&#xff0c;让我们在工作和娱乐时享受无线的自由。然而&#xff0c;对于一些用户来说&#xff0c;将蓝牙耳机与电脑连接可能会遇到一些问题。本文将介绍蓝牙耳机…

RAG系统进阶(五)文本分割优化技巧及代码

背景 前边在介绍RAG系统时提到了文本分割&#xff08;或分段&#xff09;的作用和重要性。也提到了分段后所带来的一些问题&#xff0c;比如由于分段导致检索出来的TOP-n的结果可能未包含完整的答案。 粒度太大可能导致检索不精准&#xff0c;粒度太小可能导致信息不全面问题的…