第 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.mzph.cn/news/852114.shtml

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

相关文章

创邻科技张晨:期待解锁图技术在供应链中的关联力

近日&#xff0c;创邻科技创始人兼CEO张晨博士受浙江省首席信息官协会邀请&#xff0c;参加数字化转型与企业出海研讨会。 此次研讨会旨在深入探讨数字经济时代下&#xff0c;企业如何有效应对成本提升与环境变化所带来的挑战&#xff0c;通过数字化转型实现提效增益&#xff…

4090显卡 安装cuda 11.3 版本

文章目录 cuda 安装安装过程中会要求选择安装的内容更改cuda地址到你安装的地方 cuda 安装 cuda官网寻找cuda11.3 版本 https://developer.nvidia.com/cuda-11.3.0-download-archive?target_osLinux&target_archx86_64&DistributionUbuntu&target_version20.04&…

yolo-inference多后端+多任务+多算法+多精度模型 框架开发记录(python版)

先贴出github地址&#xff0c;欢迎大家批评指正&#xff1a;https://github.com/taifyang/yolo-inference 不知不觉LZ已经快工作两年了&#xff0c;由于之前的工作内容主要和模型部署相关&#xff0c;想着利用闲暇时间写一些推理方面的经验总结&#xff0c;于是有了这个工程。其…

边缘计算网关在智慧厕所远程监测与管理的应用

随着智慧城市建设的不断深入&#xff0c;城市公共设施的智慧化管理成为了提升城市品质和居民生活质量的关键建设。公厕作为城市基础设施的重要组成部分&#xff0c;其管理效率和卫生状况直接影响着市民的日常生活体验。在公厕设施建设背景下&#xff0c;边缘计算网关技术的应用…

【JS重点09】JS闭包(面试重点)

本文核心目标&#xff1a;阅读完本文能说出什么是闭包&#xff0c;闭包作用以及如何利用闭包 一&#xff1a;闭包概览 1 闭包是什么 闭包&#xff08;closure&#xff09;是一个函数以及其捆绑的周边环境状态&#xff08;lexical environment&#xff0c;词法环境&#xff09…

如何将接口返回/n替换为react.js中的换行符

将每个/n替换为ReactJS中的一个<br>标记。cpa_ability为后端返回的字段名

时间类:Calendar

一.Calendar概述 1.Calendar代表了系统当前时间的日历对象,可以单独修改,获取时间中的年&#xff0c;月&#xff0c;日 2.细节:Calendar是一个抽象类,不能直接创建对象。 二.获取Calendar日历类对象的方法 // 会根据系统的不同时区来获取不同的日历对象 // 会根据系统的不同…

6.每日LeetCode-数组类,找到所有数组中消失的数字(Go)

题目 448找到所有数组中消失的数字.go 给你一个含 n 个整数的数组 nums &#xff0c;其中 nums[i] 在区间 [1, n] 内。请你找出所有在 [1, n] 范围内但没有出现在 nums 中的数字&#xff0c;并以数组的形式返回结果。 示例 1&#xff1a; 输入&#xff1a;nums [4,3,2,7,8,2,…

双喜临门 | 两大权威报告发布,开源网安实力登榜

近日&#xff0c;开源网安凭借出色表现和实力&#xff0c;荣获两大荣誉&#xff1a;入选《中国信息安全》杂志社联合数说安全发布的《2024 年中国金融行业网络安全市场全景图》&#xff0c;并登榜安在发布的《2024安在新榜网络安全产品“大众点评”百强榜》。 2024年中国金融行…

Java开发规范

1.接口命名规范–Restful API 原本格式是动词资源by传参&#xff0c;后来进化为Restful API&#xff0c;思想是以资源为中心。 动词用get,post,put,delete请求方法代替&#xff0c;by后面的名词用传参代替。 并且GET方法传参资源ID采用路径传参&#xff0c;除了资源ID外的GET…

【智能算法应用】基于A星算法求解六边形栅格地图路径规划

目录 1.算法原理2.结果展示3.参考文献4.代码获取 1.算法原理 精准导航&#xff1a;用A*算法优化栅格地图的路径规划【附Matlab代码】 六边形栅格地图 分析一下地图&#xff1a; 六边形栅格地图上移动可以看做6领域运动&#xff0c;偶数列与奇数列移动方式有所差异&#xff0…

使用 Elasticsearch 调用 OpenAI 函数

作者&#xff1a;来自 Elastic Ashish Tiwari 介绍 OpenAI 中的函数调用是指 AI 模型与外部函数或 API 交互的能力&#xff0c;使它们能够执行文本生成之外的任务。此功能使模型能够通过调用预定义函数来执行代码、从数据库检索信息、与外部服务交互等。 该模型根据用户提示智…

连接·共享·成长:15大顶尖峰会热议AI,逾万商家聚焦实在Agent

在数字化浪潮的推动下&#xff0c;人工智能&#xff08;AI&#xff09;和机器人流程自动化&#xff08;RPA&#xff09;正成为行业创新和效率提升的强大引擎。作为自动化领域的领航者&#xff0c;实在智能不仅在AI技术革新上保持领先地位&#xff0c;更通过一系列行业活动&…

[13] CUDA_Opencv联合编译过程

CUDA_Opencv联合编译过程 详细编译过程可见我之前的文章&#xff1a;Win10下OpencvCUDA联合编译详细教程&#xff08;版本455、460、470,亲测可用&#xff01;&#xff01;&#xff01;&#xff09;本文给出Windows\linux下的opencvcuda的编译总结&#xff0c;摘自 <基于GP…

[面试题]Java【虚拟机】

[面试题]Java【基础】[面试题]Java【虚拟机】 因为 Java 并发涉及到的内容会非常多&#xff0c;本面试题可能很难覆盖到所有的知识点&#xff0c;所以推荐 《深入拆解 Java 虚拟机》 。 走近 Java 什么是虚拟机&#xff1f; Java 虚拟机&#xff0c;是一个可以执行 Java 字…

Pygame的基本应用

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 Pygame有很多模块&#xff0c;每个模块又有很多方法&#xff0c;在此不能够逐一讲解&#xff0c;所以&#xff0c;我们通过一个实例来学习Pygame&…

SpringBoot接入RS-232串口通讯实现数据交互

目录 一、什么是RS-232&#xff1f; 先看看硬件通讯接口长啥样 RS-232 二、方案一 1.前期准备 a.配置 RXTX 1&#xff09;下载 RXTX 包并解压 2&#xff09;拷贝动态库到对应的jdk目录下 Windows平台 Linux平台 3&#xff09;在工程根目录下创建 lib 文件夹&#x…

Whisper语音识别 -- 自回归解码分析

前言 Whisper 是由 OpenAI 开发的一种先进语音识别系统。它采用深度学习技术&#xff0c;能够高效、准确地将语音转换为文本。Whisper 支持多种语言和口音&#xff0c;并且在处理背景噪音和语音变异方面表现出色。其广泛应用于语音助手、翻译服务、字幕生成等领域&#xff0c;为…

unidbg讲解V1

前言 unidbg是什么? unidbg是一个Java项目,可以帮助我们去模拟一个安卓或IOS设备,用于去执行so文件中的算法,从而不需要再去逆向他内部的算法。最终会产出一个jar包,可以被python进行调用。 如何使用unidbg? 下载github上开源的项目:https://github.com/zhkl0228/un…

【因果推断python】32_合成控制2

目录 合成控制作为线性回归的一种实现​编辑 合成控制作为线性回归的一种实现 为了估计综合控制的治疗效果&#xff0c;我们将尝试构建一个类似于干预期之前的治疗单元的“假单元”。然后&#xff0c;我们将看到这个“假单位”在干预后的表现。合成控制和它所模仿的单位之间的…