Spring Boot教程(17) – 快速理解AOP

AOP,Aspect-oriented Programming,面向切面编程,是你接触Spring之后比较难理解的概念。网上也有很多文章来介绍它,但是我老是看不懂,上来就一堆术语,头大。在我详细解释AOP之前,先用一句话说明他的典型用法:

把对象修改一下或者包装起来,在它方法执行的前后,额外运行点代码

很多时候你不知不觉就使用了AOP:在@Component类中,你在方法上加了@Transactional注解之后,方法执行前会开始事务,方法执行之后结束事务,方法发生异常之后回退事务;加上@Async注解之后,方法最终被调用的时候是异步的;加上@Cacheable注解之后,方法的返回值会被缓存起来,下次调用的时候直接返回缓存值。这些都是框架提供给你的,你只要加个注解声明一下就能用。

同时你也可以编写AOP代码来实现自己需求,比如在方法执行之前开始计时,在方法结束之后停止计时,来得到方法的运行时间;比如在业务的关键地方加上log;比如权限控制、懒加载等等。使用AOP的特点就是侵入性比较小,你的业务代码不用动,降低耦合度,方便团队分工。

AOP实现的选择

Spring自带的实现叫做Spring AOP,还有个实现叫做AspectJ

Spring AOP并不像AspectJ一样实现了丰富的完备的AOP特性,Spring AOP更倾向于配合Spring容器帮助你解决一些常见的问题,“够用就行”。

Spring AOP不打算成为一个全面的AOP实现,也不打算和AspectJ竞争,相反,他俩还是相互补充的关系。如果你觉得Spring AOP不够用,可以换用AspectJ,在一个项目里,你可以只用两者之一,也可以两者都用。

Spring AOP的原理

上面我们说了,AOP把对象包装起来,那具体是怎么包装的呢?

Spring AOP是基于代理(proxy based)的。我们知道Java里有动态代理的机制,可以在运行时生成某个接口的对象。比如你有个@Service类,叫做UserServiceImpl,它实现了接口IUserService,如果这个类用了AOP的特性,那么Spring AOP会通过动态代理,生成一个对象,暂且叫它userSerivceProxyuserSerivceProxy是IUserService类型的对象,并且把UserServiceImpl对象给包起来。这样其他容器对象需要通过@Autowired引入IUserService的时候,容器实际上提供的是userSerivceProxy,你调用IUserService方法的时候,实际上调用的是userSerivceProxy对象的方法,它再去调用UserServiceImpl对象的方法,顺便在调用前,调用后做点事情。

但是动态代理还不够用,我写@Service类的时候,通常不写接口,直接怼一个UserService类上去,不实现任何接口。这时候,动态代理就不能用了,Spring AOP会使用cglib来生成一个类,暂且叫它UserServiceChildUserServiceChild继承自UserService。这样其他容器对象需要通过@Autowired引入UserService的时候,容器实际上提供的是UserServiceChild对象,调用UserService方法的时候,实际调用的是UserServiceChild的方法,你原本提供的UserService对象被包裹在UserServiceChild中,所以可以在实际方法执行的时候做点事情。

默认情况下,Spring AOP会看看你的类有没有实现接口,有的话使用动态代理,没有的话使用cglib。

AspectJ的原理

AspectJ和Spring AOP就不一样了,不是基于代理的。它直接把代码搞到你的类里,所以,你运行程序的时候,运行的类已经不是你写出来的那个类 了,是经过修改的了。通常来说,你写的AOP相关的代码,和对象的结合过程叫做织入(weave)。AspectJ的织入过程,有可能发生在三个阶段:

  • 编译时织入(Compile-time weaving) 用AspectJ的编译器ajc,在项目编译的阶段就将代码织入目标类
  • 编译后织入(Post-compile weaving) 用AspectJ的编译器ajc,向javac编译出来的.class或者.jar织入代码
  • 加载时织入(Load-time weaving) 类加载器将字节码加载到JVM前织入

从上面可以看出,在程序的运行阶段,你的类都是已经织好了的,而不像Spring AOP,需要在运行的时候去生成动态代理或者生成类。所以说,AspectJ的运行效率还是很高的。

既然AspectJ高效又全面,为啥不直接用呢?因为用起来麻烦,编译时织入和编译后织入需要用AspectJ的编译器ajc,侵入性太强,原来有jdk搞搞就行的东西还要调整工具链,这不是大多数人能接受的。加载时候织入看起来好一点,不过它需要运行时候添加个-javaagent:aspectjweaver.jar参数(开发和部署的时候都需要),如果javaagent参数也不想加,倒是还有一种修改类加载器相关的方法,Spring文档写的不是太详细,至今我仍然没搞懂怎么用。希望有一天Spring Boot能出个@EnableAspectJ注解,一加上就帮你配置好加载时织入,用户直接去用就行。

AOP的基本概念

再来看AOP的一些基本概念:

  • 连接点(Join point) 表示你想在什么地方插代码,比如方法执行的地方或者处理异常的地方
  • 切入点(Pointcut) 用来匹配一堆连接点
  • 通知(Advice) 在连接点上干的事儿(执行的代码)
  • 切面(Aspect) 切面文件里具体指明在哪个切入点执行什么通知(Advice)
  • 引入(Introduction) 给类新增方法或者成员
  • 目标对象(Target object) 在谁身上切
  • 织入(weaving) 将切面和目标对象绑一块儿

通知(Advice)的运行方式还有好几种:

  • 连接点之前
  • 连接点之后
  • 围绕连接点
  • 发生异常的时候
  • 管它正常还是异常

我已经用尽可能简练的语言来描述概念了,如果你还不懂,那就来写点代码熟悉下吧:

Spring AOP的使用

有两种方式使用Spring AOP,一种是Spring框架自带的,比如文章开头提到的使用@Transactional@Async@Cacheable注解。另一种是自定义切面,利用到上面说到的一堆概念。前一种大家都会用,我们来说说后一种。

自定义切面也有两种方式,一种是使用xml文件,一种是注解。对于我这种开始学些Java Web时xml配置已经过时了的人,再去接受xml,不太接受的了。那就直接使用注解吧。Spring借用了AspectJ的注解(仅仅是注解),来定义切面。在Spring Boot中,引入AOP Starter依赖:

implementation 'org.springframework.boot:spring-boot-starter-aop'

添加完依赖之后,自动配置就起作用了,自动配置类AopAutoConfiguration会添加@EnableAspectJAutoProxy注解以开启AspectJ注解的使用,也就是说加了@Aspect注解的切面类,一放到容器中,Spring AOP就自动完成织入。

以上是一个很简单的实例DemoAspect是个切面文件,切谁呢?切目标类DemoObject类。DemoAspect通过@Pointcut定义了切入点,@Before定义了在切入点之前执行什么操作。当图右边通过@Autowired注入DemoObject的时候,实际上注入的是cglib生成的类(继承于DemoObject)的对象,即代理。最后执行run方法的时候,实际上执行的是代理的run方法,这会执行切面的beforeRun方法,然后再是DemoObject的run方法。这就是典型的AOP的用法,DemoObject就相当于你的业务逻辑代码,不用动其代码就可以在其前后运行额外代码。

@Pointcut注解的参数是切入点表达式(pointcut expression),他是AspectJ语言的一部分,具体可查看Spring AOP切入点文档,或者AspectJ的文档

Spring AOP的最大缺点

前面我们提到,Spring AOP使用了代理作为其实现方式,这种方式有个缺点,类内部调用自己的方法的时候,是不经过代理的。比如你的@Service类,里面有个A方法,调用了B方法,B方法有@Transactional注解,而A没有,那么你在外部调用A方法,执行到B方法的时候是没有事务关联的。我最早是因为遇到了@Transactional的这个问题的时候,才想到去了解AOP,毕竟这种缺陷有点反直觉,容易出错。

想要规避这个问题,有两种方式:一种是将B方法放到其他的类里,这种方法能用是能用,但是同一个功能模块的代码分开放有点多余;一种是通过AopContext.currentProxy()获取到当前的代理,再调用代理的B方法,这种方式丑,真丑。。

还有没有办法?有,用AspectJ。

你上面都说了AspectJ麻烦了,咋弄?没办法,只能权衡利弊。

之后我再专门写一篇文章讲讲如何用传递javaagent的方式使用AspectJ。另外,本文内容是我这几天现学现卖的,难免有纰漏,希望路过的大牛,能指出有问题地方,避免误导更多读者。

参考资料:

发表评论

电子邮件地址不会被公开。