关于Spring AOP的灵魂十问

4,048 阅读12分钟

荒腔走板

今天没有这个环节。。。

什么是AOP?

AOP全称是Aspect Oriented Programming,翻译过来是“面向切面”编程。在Java语言里,一切皆对象,所以我们通常说Java语言是一门“面向对象”编程的语言。而面向切面编程,不是要取代面向对象编程,而是对它的一种补充。

AOP要解决的问题是用一个“横切面”的方式,来统一处理很多对象都需要的,相同或相似的功能,减少程序里面的重复代码,让代码变得更干净,更专注于业务。

AOP能做什么?

AOP适合用来做一些比较通用的、与业务关系不大的事情。比较常见的就是调用日志、权限控制、调用时间和性能统计、参数校验等等。

其实Spring基于AOP做了很多有用的东西,比如大家可能会经常使用的事务、缓存、重试、Validation等等,底层都是使用的AOP。

自己用AOP实现一些小功能的需求也比较多。比如之前我们团队就有这样一个需求,我们在服务与服务之间的调用,不使用枚举,而是换成了使用字符串,即枚举相应字段的name。但是这样就带来了一个问题,如果调用方输入了一个不属于这个枚举的字符串,那到后面使用就会抛异常。所以我们希望能够在Controller层就把这个校验做了,该抛异常抛异常。

这是一个参数校验的需求,然而Spring提供的Validation和自定义Validation都无法实现这个需求,因为字段是字符串,但每次需要校验的枚举类型是不同的。所以我们就基于注解和AOP自己开发了这样一个枚举参数校验器。

Spring AOP和AspectJ是什么关系?

其实AOP并不是Spring的专属,AOP最开始是一种编程模型,后来大佬们为了探讨AOP的标准化,统一AOP规范,成立了一个AOP联盟。除了Spring外,AOP的框架有很多,比如AspectJ, AspectWerkz, JBoss-AOP。

最开始,Spring AOP和AspectJ是完全独立的,Spring有自己的实现和使用语法。但是Spring的AOP使用起来太麻烦了,深受大家吐槽。于是Spring支持了广受大家好评的AspectJ语法,通过在配置类上添加@EnableAspectJAutoProxy这个注解来开启对AspectJ的语法。

但Spring仅仅是支持了AspectJ的部分语法(有些语法是不支持的),但底层实现还是自己的一套东西。而且两个框架的目标不同,AspectJ是一套完整的AOP解决方案,更为健壮,但使用起来比较复杂,还需要使用特殊的语法和编译器。而Spring的目的是想要把AOP和IoC框架结合起来,让Spring管理的Bean能够很方便地使用AOP的功能。

所以Spring AOP和AspectJ没啥关系,只是Spring借鉴了Aspect的声明语法。

这里说清楚了AOP、Spring AOP、AspectJ的关系,那下文中我就用AOP代替Spring AOP了,毕竟这是一个比较懒的作者。

如何使用Spring AOP?

学一门技术,首先要学会怎么用,然后再学会原理,最后深入思考,举一反三。而学习怎么用,最好的途径,就是看官方文档

有些同学可能会觉得英文的看起不太方便,所以会去看一些中文的文章或者书。但这些中文的文章或书其实就是把官方文档翻译了一遍,还有可能不全甚至是错误的。所以还是推荐直接看官方文档,哪怕是用自动翻译软件,也能看懂了。何况英语也算是程序员的必修技能之一。

AOP有一些专业的概念和术语。切面、连接点、通知、切点、引入、目标对象、代理对象、织入等。很多都是直接根据英文单词翻译过来的,这里我们就不详细介绍这些概念了,Spring官方文档AOP那一章节开头就有详细的介绍。但是这篇文章会从使用的视角来顺带介绍其中的一些术语。

打开AspectJ支持

本文就不介绍XML的配置了,实在是太古老了。。。

使用@EnableAspectJAutoProxy这个注解可以打开AspectJ支持,以后就可以愉快地在Spring中使用AspectJ语法了。需要注意的是,如果你使用的是SpringBoot,这个注解已经默认加上了,不需要再手动写在你的代码里。

声明一个切面

使用@Aspect注解可以声明一个切面。其实就是一个类,在这个类里面去定义切点、通知等东西。

声明一个切点

所谓“切点”,就是你要去切什么地方。Spring只支持去切被Spring管理的Bean的方法。声明一个切点也很简单,在我们上面声明的切面类里面,用下面这种形式创建一个方法就行了:

@Pointcut("execution(* transfer(..))")
private void anyOldTransfer() {}

使用@Pointcut注解,里面是切点的表达式。需要注意这个方法的返回值必须是void的。关于表达式,有一些Spring支持的关键字,这里就不一一细讲了,官方文档上Supported Pointcut Designators这一节有详细介绍。我们最常用的应该是execution@annotation这两个了。

其中也有一些通配符,包括*, ., (), (..), (*), (*, string)等等,都是有不同的含义和作用。具体可以在官方文档Examples这一节了解。

声明通知

“通知”,指的是在什么时候去执行我们定义的AOP逻辑。Spring提供了这样几种通知:

  • @Before
  • @AfterReturning
  • @AfterThrowing
  • @After,其实质是AfterFinally
  • @Around

大家看名字应该都知道是啥意思了吧。其中,@Around包含上面所有的功能,使用起来更强大、灵活。

一个完整的AOP定义大概长这样:

@Aspect
@Component
public class MyAspect {

    @Pointcut("within(com.example.springbase.dao..*)")
    private void myPointcut() {}


    @Before("myPointcut()")
    public void before() {
        System.out.println("before...");
    }
}

需要注意的是这里的@Component注解是必须要加的,不然Spring不会自动扫描这个类,那你定义的切面、切点和通知也就无效了。

一个切面里面可以有多个切点和多个通知,并且一个切点也可以被多个通知使用。

Spring用什么实现的AOP?

前面我们提到过AspectJ,AspectJ使用的是编译期和类加载时进行织入,而Spring AOP利用的是运行时织入。而如果使用运行时织入,就要用到“动态代理”的技术。

先来聊聊动态代理。AOP其实是设计模式中的“代理模式”的一种应用,那什么是代理模式呢?我们举个很常见的例子,就是游戏代练了。

游戏代练高手收了土豪的钱,登上土豪的账号,一路连胜打上最强王者,然后完成交易,功成身退。而游戏中的队友和对手,甚至游戏官方并不知道这是一个代练,还以为是土豪本人,私下里纷纷夸赞土豪技术了得,满足了土豪的虚荣心。。。

代理模式说简单点,就是通过把原本的对象进行包装,提供一些额外的能力,但是对外部而言是无感知的,并不知道或者并不在意这个对象是不是被代理了。

静态代理和动态代理的区别在于,静态代理把要代理的类型已经写死了,一个代理只能代理一种类型。而动态代理就不一样了,一个代理可以代理多种类型。还是拿游戏代练举例,静态代理可能就是这个代练只能代练LOL这一种游戏。而动态代理,这个代练可以代练所有游戏,是不是一听就是大高玩。

Spring底层使用了两种方式来实现动态代理,一种是Java自带的动态代理,另一种是CGLib。如果是使用JDK动态代理生成的代理对象,Debug可以看到JdkDynamicAopProxy,而如果是CGLib生成的对象,可以看到是EnhancerBySpringCGLIB

那Spring具体是使用的哪种方式呢?网上有很多文章说,Spring默认产生代理对象的行为是:如果你的Bean有对应的接口,是使用的基于JDK的动态代理,否则是使用CGLIB。但这样说其实不准确,Spring用了下面这个配置来控制它,如果这个配置是false,才是上面我们说的这个逻辑。而如果这个配置是true,则所有的要使用AOP的Bean都使用CGLIB代理,不管它是不是有接口。而我们使用最新版的SpringBoot的话,这个值默认就是true。

spring.aop.proxy-target-class=true

所以现在如果使用SpringBoot的话,我们的AOP代理对象都是用CGLIB生成的

JDK和CGLib动态代理有什么区别?

两者实现的原理不同,JDK动态代理是基于Java反射来实现的,而CGLIB动态代理是基于修改字节码,生成子类来实现的,底层是使用的asm开源库。

两者都有一些限制,JDK动态代理,Bean必须要有接口;CGLIB不能对final类或方法做代理。

哪些方法可以被代理?

如果是使用JDK动态代理,那只有public方法可以被代理。而如果使用CGLIB,除了private方法,都可以被代理。(当然,final方法除外)。

另一个比较有意思的问题是,如果两个方法在同一个类里面,一个方法调用另一个方法是不会走代理的。只有一个Bean调用另一个Bean的方法,才会走代理。

上面两个特性也就解释了为什么有时候你的@Transactional不生效的原因:

  • 在私有方法上不生效
  • 在final方法上不生效
  • 同一个类里面方法互相调用不生效

代理对象是什么时候生成的?

在上一篇文章中,我们了解了Spring的Bean是如何生成的。在Spring启动的时候,会去调用getBean方法,完成Bean的初始化工作。而在getBan里面,初始化Bean后,会去调用Bean的BeanPostProcessor。这个代码可以通过getBean方法Debug找进去。这里就不细讲Debug过程了,放一张调用栈的截图吧。

从Debug可以看到,其中有一个BeanPostProcessor是AnnotationAwareAspectJAutoProxyCreator类型的,继续Debug进去可以看到最终是用了CglibAopProxy类的getProxy()方法生成的代理对象。

同一个方法被多次代理怎么办?

一个方法是有可能被多次代理的。Spring AOP不仅仅是基于代理模式,还使用了“拦截器”模式。这个拦截器,有点像Web的拦截器一样,在目标对象上包了一层又一层,形成一个拦截器链。那它们的顺序是如何决定的呢?

我们在前面的源码解析中,有一个分支,逻辑是去除当前这个代理对象的所有“通知”,然后排序。代码在AspectJAwareAdvisorAutoProxyCreator类的方法里。调用栈:

这个方法内部先取出所有的通知,然后给它们都加上一个AspectJPrecedenceComparator。这个Comparator会取出通知所在的Bean的@Order注解定义的优先级,按照这个优先级来排序。其实我们有时候使用其它Bean也会用到这个注解。

所以如果你会对一个方法声明多个通知,那可以使用@Order注解来定义这多个通知的优先级。@Order定义的值越小,其内部定义的通知对应的拦截器就会在调用链的越外层。

注意,如果是同一个切面类里面定义的多个通知,会按照方法声明的先后顺序来排序。

AOP和循环依赖的那些事?

大家可能会遇到过或者听说过Spring的循环依赖的问题。Spring使用了“三级缓存”来解决Bean的循环依赖,但可能很多人不知道为什么要使用三级缓存,其实这个也跟AOP有关。

如果没有AOP,其实Spring使用二级缓存就可以解决循环依赖的问题。若使用二级缓存,在AOP情形下,注入到其他Bean的,不是最终的代理对象,而是原始目标对象。

因为Spring对Bean有一个生命周期的定义,而代理对象是在Bean初始化完成后,执行后置处理器的时候生成的。所以不能在二级缓存的时候就直接生成代理对象,放进缓存。

使用AOP有什么弊端?

AOP不是万能的,使用AOP也是有一些弊端的。个人觉得对大的弊端就是让代码可读性变差了,因为它并不是一个显式的调用,所以很有可能未来接手代码的人并不清楚这个方法被AOP代理了。

笔者之前遇到过一个项目,就是使用了AOP来做权限控制,但这个权限控制不是那种简单的Access,而是要去查数据库里面的一些字段,比如状态之类的,还有复杂的逻辑判断。这种情况下,如果使用AOP来做,代码的可读性就不强,出了问题比较难以排查。

关于作者

我是Yasin,一个有颜有料又有趣的程序员。

微信公众号:编了个程

个人网站:https://yasinshaw.com

关注我的公众号,和我一起成长~

公众号
公众号