Spring AOP的延伸知识

1. AOP联盟

在 SpringFramework 2.0 之前,它还没有整合 AspectJ ,当时的 SpringFramework 还有一套相对低层级的实现,它也是 SpringFramework 原生的实现,而我们要了解它,首先要先了解一个组织:AOP 联盟

早在很久之前,AOP 的概念就被提出来了。同之前的 EJB 一样,作为一个概念、思想,它要有一批人来制定规范,于是就有了这样一个 AOP 联盟。这个联盟的人将 AOP 的这些概念都整理好,形成了一个规范 AOP 框架底层实现的 API ,并最终总结出了 5 种 AOP 通知类型。

咱要了解的,就是 AOP 联盟提出的这 5 种通知类型。

1.1 AOP联盟制定的通知类型

5 种通知类型分别为:

  • 前置通知

  • 后置通知(返回通知

  • 异常通知

  • 环绕通知

  • 引介通知

注意它跟 AspectJ 规定的 5 种通知类型的区别:它多了一个引介通知,少了一个后置通知。而且还有一个要注意的,AOP 联盟定义的后置通知实际上是返回通知( after-returning ),而 AspectJ 的后置通知是真的后置通知,与返回通知是两码事。

1.2 SpringFramework中对应的通知接口

AOP 联盟定义的 5 种通知类型在 SpringFramework 中都有对应的接口定义:

  • 前置通知:org.springframework.aop.MethodBeforeAdvice

  • 返回通知:org.springframework.aop.AfterReturningAdvice

  • 异常通知:org.springframework.aop.ThrowsAdvice

  • 环绕通知:org.aopalliance.intercept.MethodInterceptor

  • 引介通知:org.springframework.aop.IntroductionAdvisor

注意!环绕通知的接口是 AOP 联盟原生定义的接口(不是 cglib 的那个 MethodInterceptor )!小伙伴们可以先思考一下为什么会是这样。

其实答案不难理解,由于 SpringFramework 是基于 AOP 联盟制定的规范来的,所以自然会去兼容原有的方案。又由于咱之前写过原生的动态代理,知道它其实就是环绕通知,所以 SpringFramework 要在环绕通知上拆解结构,自然也会保留原本环绕通知的接口支持。

了解这部分的知识,在后面咱分析 Spring AOP 的原理时,看到一些特殊的 API 接口时,就不会觉得奇怪或者陌生了,现在小伙伴们只是有个基本的印象即可。

2. 切面类的通知方法参数

在上一章的环绕通知编写中,咱提到了一个特殊的接口 ProceedingJoinPoint ,它的具体使用,以及切面类中的通知方法参数等等,咱都有必要来学习一下。

其实在之前的代码中,或许有的小伙伴就已经产生很强的不适感了:这所有的日志打印都是一样的,我也不知道哪个日志打印是哪个方法触发的,这咋区分呢? 所以,我们得想个办法,把被增强的方法,以及对应的目标对象的信息拿到才行。(原生动态代理都行,到 AOP 就不行了?这肯定不合理)

2.1 JoinPoint的使用

其实切面类的通知方法,咱都可以在方法的参数列表上加上切入点的引用,就像这样:(咱以 beforePrint 方法为例)

这样写之后,重新运行程序不会有任何错误,说明这样写是被允许的,但咱更关心的是,能从这个 JoinPoint 中得到什么呢?

直接在方法中调用 joinPoint 的方法,可以发现它能获取到这么多东西:

img

这么多内容,咱选几个比较重要的来讲解。

2.1.1 getTarget & getThis

getTarget 方法是最容易被理解的,咱可以简单的测试一下效果:

运行 main 方法,控制台会打印 FinanceService 的信息:

包括使用 Debug 打断点,识别到的 FinanceService 也是未经过代理的原始对象:

img

那相对的,getThis 方法返回的就是代理对象咯?咱也可以来打印一下:

重新运行 main 方法,控制台打印了两个一模一样的 FinanceService

??????怎么个情况?难道是我们推理错了吗?用 Debug 看一眼:

img

诶呦吓一跳,getThis 肯定还是获取到代理对象才是啦。那为什么原始的目标对象,与代理对象的控制台打印结果是一样的呢?

其实从上面的截图中也能猜到端倪:它增强了 equals 方法,增强了 hashcode 方法,就是没有增强 toString 方法,那当然就执行目标对象的方法啦,自然也就打印原来的目标对象的全限定名了。

2.1.2 getArgs

这个方法也是超级好理解,它可以获取到被拦截的方法的参数列表。快速的来测试一下吧:

重新运行 main 方法,控制台打印出了 addMoney 方法传入的 123.45 :

2.1.3 getSignature

这个方法,从名上看是获取签名,关键是这个签名是个啥?不知道,猜不出来,干脆先打印一把吧:

重新运行 main 方法,控制台打印的是被拦截的方法的全限定名等信息:

哦,突然明白了,合着它打印的是这个被拦截的方法的签名啊!那是不是还可以顺便拿到方法的信息呢?

直接调用 getSignature() 的方法,发现这里面只有类的信息,没有方法的信息:

img

诶?那可奇了怪了,既然基于 AspectJ 的 AOP 是对方法的拦截,那理所应当的应该能拿到方法的信息才对呀!那当然,肯定能拿到,只是缺少了一点点步骤而已。

既然是基于方法的拦截,那获取到的 Signature 就应该可以强转为一种类似于 MethodSignature ,刚好还真就有这么一个 MethodSignature 的接口!

所以,咱就可以这样写了:

那既然是这样,MethodSignature 中一定能拿到方法的信息了!果不其然,这个接口还真就定义了获取 Method 的方法:

so ,我们就可以打印出这个方法的信息了:

重新运行 main 方法,控制台可以打印出方法的信息:

其实 SignaturegetName 方法,就相当于拿到 Method 后再调 getName 方法了,小伙伴们可以自行测试一下。

至此,其实我们就可以完成前面说的需求了。

2.1.4 需求的改造

重新修改 addMoney 方法的逻辑,就可以很简单轻松的完成一开始说的 “不知道哪个日志打印是哪个方法触发” 的需求了:

重新运行 main 方法,控制台打印出了我们预期的需求效果:

2.2 ProceedingJoinPoint的扩展

上一章中我们提前使用了 ProceedingJoinPoint 这个家伙,而它是基于 JoinPoint 的扩展,它扩展的方法只有 proceed 方法,也就是那个能让我们在环绕通知中显式执行目标对象的目标方法的那个 API 。

不过有一点要注意:proceed 方法还有一个带参数的重载方法:

由此可以说明一点:在环绕通知中,可以自行替换掉原始目标方法执行时传入的参数列表

其实这个一点也不奇怪,想想在之前的动态代理案例中,咱不就是可以随便改参数的嘛。

2.3 返回通知和异常通知的特殊参数

之前我们在写返回通知和异常通知时,还有一个小问题没有解决:返回通知中我们要拿到方法的返回值,异常通知中我们要拿到具体的异常抛出。这个呢,其实非常容易解决。

咱先把之前的代码再拿出来:

想拿到返回值或者异常非常简单,两个步骤。

首先在方法的参数列表中声明一个 result 或者 e :

注意只是这样写了之后,此时运行 main 方法是不好使的,是拿不到返回值的!我们还需要告诉 SpringFramework ,我拿了一个名叫 retval 的参数来接这个方法返回的异常,拿一个名叫 e 的参数来接收方法抛出的异常,反映到代码上就应该是这样:

这样再运行 main 方法,控制台才会打印出方法的返回值:

异常的信息接收同理,小伙伴们可以自行测试。

3. 多个切面的执行顺序

日常开发中,或许我们会碰到一些特殊的情况:一个方法被多个切面同时增强了,这个时候如何控制好各个切面的执行顺序,以保证最终的运行结果能符合最初设计,这个也是非常重要的,咱有必要来研究一下多个切面的执行顺序问题。

3.1 代码准备

咱先把测试的代码准备一下,很简单,咱只声明两个切面和一个 Service 即可:

然后,编写配置类,开启注解 AOP :

最后,编写启动类,用上面的配置类驱动 IOC 容器:

运行 main 方法,控制台可以打印出两个切面的前置通知:

3.2 预设的顺序

观察这个打印的结果,它是打印日志在前,开启事务在后,这难不成是因为我先写的 LogAspect ,后写的 TransactionAspect ,它就按照我的顺序来了?那不可能啊,即便我后写 TransactionAspect ,也是日志打印在前啊!所以它一定有一个默认的预设规则。

小伙伴可以先猜测一下预设的规则是什么,小册在这里再写一个切面,想必写完这个切面后,基本上顺序就非常容易推理了:

重新运行 main 方法,发现 AbcAspect 的前置通知打印在 LogAspect 之前!

由此是不是咱就可以推测出预设的顺序了:默认的切面执行顺序,是按照字母表的顺序来的

严谨一点,排序规则其实是根据切面类的 unicode 编码,按照十六进制排序得来的,unicode 编码靠前的,那自然就会排在前面。(作者个人习惯称其为字典表顺序)

3.3 显式声明执行顺序

那我们不能因为这个毛病,就搞得调整顺序就必须改类名吧,一定有更好的方案才对。

还记得之前在第 26 章,小册讲解 BeanPostProcessor 的 javadoc 中提到的 Ordered 接口吗?而且在 IOC 原理的 BeanPostProcessor 的初始化部分,也提到过有关排序的接口,也涉及到了这个 Ordered 接口。不过前面我们一直都没有实际演示 Ordered 接口的使用,这里咱就来搞一下。

现在咱希望让事务控制的切面提早执行,让它在所有切面之前,那么我们就可以这样写:给 TransactionAspect 实现 Ordered 接口,并声明 getOrder 的返回值:

这个值设置成多少呢?咱先放个 0 试试,运行 main 方法,观察控制台的打印:

咦,发现事务切面的前置通知已经提前执行了,说明 0 这个顺序已经是提早了的,那最晚的时机对应的 order 值是什么呢?

3.3.1 默认的排序值

很简单,Integer 的最大值 2147483647 嘛!其实在 Ordered 接口中,就有这两个常量的定义:

那我们把这个值调到最低试一下?

重新运行 main 方法,发现事务的切面又回去了:

那到底默认值是啥呢?咱把 order 值往上调一个点试试?

再次重新运行 main 方法,事务的切面打印又上去了。。。

所以得出结论:在不显式声明 order 排序值时,默认的排序值是 **Integer.MAX_VALUE**

3.3.2 另一种声明办法

除了使用 Ordered 接口,还有通过注解的方式声明:**@Order**

这次我们在 LogAspect 上标注 @Order 注解,并声明一下排序值:

重新运行 main 方法,发现日志切面的打印提到最早了:

说明 @Order 注解也可以实现同样的效果。

4. 同切面的多个通知执行顺序

除了多个切面的顺序问题,如果同一个切面定义了多个相同类型的通知,它的执行顺序又是怎么样呢?咱也来研究一下。

这次编码的内容就少多了,直接在 AbsAspect 中添加一个方法 def 即可:

直接重新运行 main 方法,控制台先后打印了 abcdef 的内容:

原因是什么呢?估计小伙伴们也能猜到了,跟上面的逻辑一样,都是根据unicode编码顺序(字典表顺序)来的。

至于怎么搞,小伙伴们立马想到办法了吧!直接在方法上标注 **@Order** 注解就 OK (方法没办法实现接口的嘛)!

运行 main 方法,发现 def 的通知内容并没有被提前执行。。。看来这个办法行不通。。。

那怎么办呢?哎,这还真没办法。。。只能靠方法名去区分了。。。(是不是很无奈)

好了,有关切面、通知的执行顺序的研究,咱就到这里了。

5. 代理对象调用自身的方法

有一些特殊的场景下,我们产生的这些代理对象,会出现自身调用自身的另外方法的。下面我们也来演示一下这个现象。

5.1 代码准备

测试代码还是三个类,不再重复了:

然后,依然是编写测试启动类:

这样写完之后,运行 main 方法,发现控制台只打印了一次 LogAspect 的切面打印:

如果需求是每次调用 UserService 的方法都需要打印切面日志,应该怎么处理呢?

5.2 不优雅的解决方案

可能有的小伙伴想到了一个能行但不太优雅的解决方案,就是利用依赖注入的特性,把自己注入进来,之后不用 this.get ,换用 userService.get 方法:

重新运行 main 方法,控制台确实打印了两次切面日志:

但是吧。。。这样写真的好吗。。。有木有感觉怪怪的。。。难不成SpringFramework 就没有考虑到这个问题吗?

5.3 正确的解决方案:AopContext

当然还是得有的,SpringFramework 从一开始就考虑到这个问题了,于是它提供了一个 AopContext 的类,使用这个类,可以在代理对象中取到自身,它的使用方法很简单:

使用 AopContext.currentProxy() 方法就可以取到代理对象的 this 了。

不过这样直接写完之后,运行是不好使的,它会抛出一个异常:

这个异常的大致含义是,没有开启一个 exposeProxy 的属性,导致无法暴露出代理对象,从而无法获取。那开启 exposeProxy 这个属性的位置在哪里呢?好巧不巧,它是在我们一开始学习注解 AOP 的那个 @EnableAspectJAutoProxy 上:

它的默认值是 false ,改为 true 之后,再运行 main 方法,就可以达到同样的效果了,控制台会打印两次切面日志。

6. AOP的引介

前面我们在学习 AOP 的术语时,就说过了,引介只是了解即可,现在用的实在是太少了。不过还是会有对此感兴趣的小伙伴,咱这里也讲解一下。

考虑到整体的难度和使用情况来看,小册只讲解基于 AspectJ 的引介,对于 SpringFramework 原生的引介小册不作讲解,感兴趣的小伙伴可以加群与我交流相关内容哈。

6.1 引介的作用和目标

AOP 的术语中咱提到,引介的作用是给目标对象所在的类,动态的添加属性和方法,这种增强的类型区别于方法级别的通知,它不会影响已有的方法,而是直接给类添加新的方法和属性。

不过话又说回来,如果手头的项目,源码都好好的,谁会闲的没事用这种东西呢?而且即便是因为工程依赖的 jar 包中的代码没办法修改,我们也能把那个类拷出来,自己再任意改造呀,所以这个引介通知的使用就越来越少了。

但是(话锋又一转)!这样直接改造框架的源码,回头每个项目都要这么搞,本身就很麻烦;如果每个项目对于既定源码的扩展内容都不一样,那可就没法搞了。所以,引介通知还是能起到作用的。

注意,引介作为一种特殊的 AOP 通知,它的作用对象是目标对象所属类而不是目标对象本身,这也就意味着引介的织入是对类织入,而不是对方法的逻辑织入。

6.2 SpringFramework中的引介

SpringFramework 中原生的引介通知,是通过 IntroductionInterceptor 来创建的,它本身扩展了 MethodInterceptor ,以及 DynamicIntroductionAdvice 接口,我们开发者可以通过实现 IntroductionInterceptor 的子接口 DelegatintIntroductionInterceptor ,来实现引介通知的编写。

不过由于这种使用方式太过于复杂,小册不作讲解。

6.3 AspectJ的引介

AspectJ 中的引介,有一个专门的注解 **@DeclareParents** 来很方便的实现目标对象所属类的属性和方法增强。它可以指定被增强的类要扩展什么接口,以及扩展的实现类。这种声明引介通知的方式相对比较简单,下面咱来学习这种编写方式。

6.3.1 代码准备

这次的代码准备也不难,三个类就可以,分别是 Service 、切面类、配置类:

最后,编写测试启动类,驱动 IOC 容器后获取 FinanceService 并调用:

运行 main 方法,控制台可以正常打印 IntroductionAspect 中的前置通知,这样代码就准备好了。

6.3.2 需求说明

下面咱说一下要完成的需求哈。转账的动作中,金额一定不能是负的,而目前的代码中并没有这方面的校验逻辑。

相对简单的办法是,在前置通知中编写参数校验的逻辑即可,这个很好写,咱就不多说了。这里咱学习的是如何用引介的方式解决这个问题。

6.3.3 编写校验服务

首先,要用引介通知增强,首先需要一个新的接口 + 实现类,这里咱可以声明一个 MoneyValidator

紧接着,编写一个 MoneyValidator 的实现类:

这样就有了一个金额的校验器。

6.3.4 @DeclareParents的使用

接下来就是给 FinanceService 织入引介通知了。首先咱要回到切面类中,在这里面添加一个 MoneyValidator 的成员,并标注 @DeclareParents 注解:

这个 @DeclareParents 注解有两个参数,value 是即将增强到原有目标类的全限定名,defaultImpl 是引介接口的默认实现类。所以我们可以在这里面这样声明:

但是话又说回来,现在的 FinanceService 是个类,那可以,如果这是个接口呢(FinanceServiceImpl implements FinanceService)?这次该怎么写呢?

AspectJ 当然也考虑到了这一点,只需要在整个接口的全限定名后面带一个 + 就可以了:

这样就代表,对于这个 **FinanceService** 接口下面的所有实现类,全部织入引介通知

6.3.5 编写校验逻辑

剩下的就是使用引介过去的 MoneyValidatorImpl 的逻辑了,这个逻辑也非常的简单,咱先理一下思路哈:首先把方法的请求参数先拿出来,然后拿到目标对象的代理对象(注意此处必须要拿到代理对象,原始目标对象压根就没实现 MoneyValidator 接口),强转为 MoneyValidator 类型,就可以调用它的 validate 方法了。如果 validate 方法返回 true ,则接下来的方法可以执行;如果返回 false ,则代表 money 参数不合法,抛出参数不合法的异常即可。

用代码编写也很简单:

6.3.6 测试运行

修改一下 main 方法的测试代码,我们把正常数据和错误数据都执行一下:

运行 main 方法,控制台可以打印出 100 的成功,和 -1 的异常:

由此可以完成引介通知的增强。

7. LoadTimeWeawer

还记得在第 22 章,配置元信息中提到的 <context:load-time-weaver/> 这个标签吗?这个标签的作用是修改代理对象的构建时机。与之相匹配的注解是 @EnableLoadTimeWeaving

7.1 AOP增强的时机

之前咱一开始讲解 AOP 的时候,说到 SpringFramework 的 AOP 底层是使用运行时动态代理的技术实现,其实这话并不绝对(所以咱一开始说的是可以,而不是一定),因为从原生的 AOP 设计角度来看,通知的织入是有三种时机的,它们分别是:

  • 字节码编译织入:在 javac 的动作中,使用特殊的编译器,将通知直接织入到 Java 类的字节码文件中

  • 类加载时期织入:在类加载时期,使用特殊的类加载器,在目标类的字节码加载到 JVM 的时机中,将通知织入进去;

  • 运行时创建对象织入:在目标对象的创建时机,使用动态代理技术将通知织入到目标对象中,形成代理对象。

所以你看,我们前面编写的所有 AOP 的实例,全部都是基于运行时创建代理对象的方式织入通知的。除此之外,还有上面的两种方式可以选择,只是我们几乎都不用了。

7.2 AspectJ对于增强的时机

AspectJ 作为很早就出现的 AOP 框架,它可以说是非常强大了,以上三种方式它都有提供方案 / 支持:

  • 对于字节码的编译期织入,它可以利用它自己定义的 AspectJ 语言编写好切面,并借助 Maven 等项目管理工具,在工程的编译期使用特殊的编译器(ajc等),将切面类中定义的通知织入到 Java 类中;

  • 对于类加载时期的织入,它的机制就是 LoadTimeWeaving (刚好就是字面意思);

  • 对于运行时创建对象的织入,它在早期有整合一个叫 AspectWerkz 框架,也是在运行时动态代理产生代理对象,只不过我们现在学习的是 Spring 整合 AspectJ ,那最终还是用基于 SpringFramework 底层的动态代理搞定了。

7.3 AspectJ的LoadTimeWeaving

下面咱还是通过一个简单的示例,来了解一下 LoadTimeWeaving 这个机制。

7.3.1 代码准备

还是跟之前的套路一样,一个 Service 一个 Aspect 一个 Configuration :

注意!此处不再使用 @EnableAspectJAutoProxy 注解,它是启用运行时的动态代理织入通知,而开启类加载时期的织入就需要使用另外的注解了,也就是上面提到的 @EnableLoadTimeWeaving 注解(或者在 xml 中声明 <context:load-time-weaver/> 标签)。

最后,编写测试启动类,套路还是都一样:

7.3.2 只声明注解并不会生效

此时运行 main 方法,控制台会抛出一个异常:

大概翻译一下,说是如果使用类加载器阶段的通知织入,要么自定义一个 LoadTimeWeaver ,要么导个 jar 包,而这个 jar 包叫 spring-instrument

这个家伙我们没见过,但它说了,那咱就导吧:

由于一开始我把工程中 SpringFramework 的版本全部定义了 5.2.8.RELEASE ,所以这里也就是 5.2.8 了。

导入之后还不行,注意异常的提示中还需要一个 vm 启动参数,叫 **-javaagent** ,那好吧,我们也把它加上:

注意 jar 包的位置要使用绝对路径,且小伙伴要记得修改这个 jar 包的路径呀。

这样声明好之后,重新运行 main 方法后发现还是不生效,控制台依然没有打印切面日志。。。

7.3.3 aop.xml

在 SpringFramework 整合 AspectJ 的规则中,规定了一点:如果要使用类加载级别的 AOP ,需要在 resources**META-INF** 下编写一个 **aop.xml** 的配置文件:

注意,weaver 中包含的类要把切面类一起包含进去!否则无法正常织入切面。

这样写完之后,就算是终于都搞完了。重新运行 main 方法,LogAspect 的通知就被织入到 UserService 中了:

2.3.4 不好使?

可能有的小伙伴在实际编码时,会遇到按照小册的步骤一步一步来,但最后仍然没有打印切面日志!这种情况就需要另加一个步骤了:

vm-options 中再加入一行 javaagent

这样再执行 main 方法,就可以成功打印切面日志了。

不过这样写完之后,控制台会报一个警告:

这个警告的意思也很明确,LogAspect 这个切面已经被使用过了,已经织入成功了,所以就不要再搞了。。。

出现这个问题的原因,是因为上面的 javaagent 与 @EnableLoadTimeWeaving 同时存在了,所以导致通知织入了两次。解决方法很简单,把注解配置类上的 @EnableLoadTimeWeaving 注解删掉即可。

最后更新于

这有帮助吗?