今天记录的是IOC&AOP
的一些基础内容案例的学习笔记。
熟悉 Java 的都知道,Spring 的家族庞大,常用的模块就有 Spring Data
、Spring Security
、Spring Boot
、Spring Cloud
等。
其实呢,Spring 体系虽然庞大,但都是围绕 Spring Core
展开的,而 Spring Core 中最核心的就是 IoC(控制反转)
和 AOP(面向切面编程)
。
概括地说,IoC 和 AOP 的初衷是解耦和扩展。理解这两个核心技术,就可以让你的代码变得更灵活、可随时替换,以及业务组件间更解耦。
IoC,其实就是一种设计思想。使用 Spring 来实现 IoC,意味着将你设计好的对象交给 Spring 容器控制,而不是直接在对象内部控制。那,为什么要让容器来管理对象呢?
或许你能想到的是,使用 IoC 方便、可以实现解耦
。相比于这两个原因,更重要的是 IoC 带来了更多的可能性。如果以容器为依托来管理所有的框架、业务对象,我们不仅可以无侵入地调整对象的关系,还可以无侵入地随时调整对象的属性,甚至是实现对象的替换。
这就使得框架开发者在程序背后实现一些扩展不再是问题,带来的可能性是无限的。比如我们要监控的对象如果是 Bean,实现就会非常简单。所以,这套容器体系,不仅被 Spring Core 和 Spring Boot 大量依赖,还实现了一些外部框架和 Spring 的无缝整合。
AOP,体现了松耦合、高内聚的精髓,在切面集中实现横切关注点
(缓存、权限、日志等),然后通过切点配置把代码注入合适的地方。切面、切点、增强、连接点,是 AOP 中非常重要的概念,也是我们这两讲会大量提及的。
为方便理解,把 Spring AOP 技术看作为蛋糕做奶油夹层的工序。
如果我们希望找到一个合适的地方把奶油注入蛋糕胚子中,那应该如何指导工人完成操作呢?
- 首先,我们要提醒他,只能往蛋糕胚子里面加奶油,而不能上面或下面加奶油。这就是
连接点
(Join point),对于 Spring AOP 来说,连接点就是方法执行。 - 然后,我们要告诉他,在什么点切开蛋糕加奶油。比如,可以在蛋糕坯子中间加入一层奶油,在中间切一次;也可以在中间加两层奶油,在 1/3 和 2/3 的地方切两次。这就是
切点
(Pointcut),Spring AOP 中默认使用 AspectJ 查询表达式,通过在连接点运行查询表达式来匹配切入点。 - 接下来也是最重要的,我们要告诉他,切开蛋糕后要做什么,也就是加入奶油。这就是
增强
(Advice),也叫作通知,定义了切入切点后增强的方式,包括前、后、环绕等。Spring AOP 中,把增强定义为拦截器。 - 最后,我们要告诉他,找到蛋糕胚子中要加奶油的地方并加入奶油。为蛋糕做奶油夹层的操作,对 Spring AOP 来说就是
切面
(Aspect),也叫作方面。切面 = 切点 + 增强。
首先说明的是,Spring 相关问题的问题比较复杂,一方面是 Spring 提供的 IoC 和 AOP 本就灵活,另一方面 Spring Boot 的自动装配、Spring Cloud 复杂的模块会让问题排查变得更复杂。
一、单例的 Bean 如何注入 Prototype 的 Bean?
我们虽然知道 Spring 创建的 Bean 默认是单例
的,但当 Bean 遇到继承的时候,可能会忽略这一点。为什么呢?
忽略这一点又会造成什么影响呢?接下来,分享一个由单例引起内存泄露的案例。
架构师一开始定义了这么一个 SayService 抽象类,其中维护了一个类型是 ArrayList 的字段 data,用于保存方法处理的中间数据。每次调用 say 方法都会往 data 加入新数据,可以认为 SayService 是有状态
,如果 SayService 是单例的话必然会 OOM:
但实际开发的时候,开发同学没有过多思考就把 SayHello 和 SayBye 类加上了 @Service 注解,让它们成为了 Bean,也没有考虑到父类是有状态的:
许多开发同学认为,@Service 注解的意义在于,能通过 @Autowired 注解让 Spring 自动注入对象,就比如可以直接使用注入的 List获取到 SayHello 和 SayBye,而没想过类的生命周期:
这一个点非常容易忽略。开发基类的架构师将基类设计为有状态的,但并不知道子类是怎么使用基类的;
而开发子类的同学,没多想就直接标记了 @Service,让类成为了 Bean,通过 @Autowired 注解来注入这个服务。但这样设置后,有状态的基类就可能产生内存泄露或线程安全问题。
正确的方式是,在为类标记上 @Service 注解把类型交由容器管理前,首先评估一下类是否有状态,然后为 Bean 设置合适的 Scope。
好在上线前,架构师发现了这个内存泄露问题,开发同学也做了修改,为 SayHello 和 SayBye 两个类都标记了 @Scope 注解,设置了 PROTOTYPE 的生命周期,也就是多例:
但,上线后还是出现了内存泄漏,证明修改是无效的。
从日志可以看到,第一次调用和第二次调用的时候,SayBye 对象都是 4c0bfe9e,SayHello 也是一样的问题。从日志第 7 到 10 行还可以看到,第二次调用后 List 的元素个数变为了 2,说明父类 SayService 维护的 List 在不断增长,不断调用必然出现 OOM:
这就引出了单例的 Bean 如何注入 Prototype 的 Bean 这个问题。Controller 标记了 @RestController 注解,而 @RestController 注解 =@Controller 注解 +@ResponseBody 注解,又因为 @Controller 标记了 @Component 元注解,所以 @RestController 注解其实也是一个 Spring Bean:
Bean 默认是单例的,所以单例的 Controller 注入的 Service 也是一次性创建的,即使 Service 本身标识了 prototype 的范围也没用。
修复方式是,让 Service 以代理方式注入。这样虽然 Controller 本身是单例的,但每次都能从代理获取 Service。这样一来,prototype 范围
的配置才能真正生效:
通过日志可以确认这种修复方式有效:
调试一下也可以发现,注入的 Service 都是 Spring 生成的代理类:
当然,如果不希望走代理的话还有一种方式是,每次直接从ApplicationContext
中获取 Bean:
如果细心的话,你可以发现另一个潜在的问题。这里 Spring 注入的 SayService 的 List,第一个元素是 SayBye,第二个元素是 SayHello。但,我们更希望的是先执行 Hello 再执行 Bye,所以注入一个 List Bean 时,需要进一步考虑 Bean 的顺序或者说优先级。
大多数情况下顺序并不是那么重要,但对于 AOP,顺序可能会引发致命问题。我们继续往下看这个问题吧。
二、监控切面因为顺序问题导致 Spring 事务失效
实现横切关注点,是 AOP 非常常见的一个应用。我曾看到过一个不错的 AOP 实践,通过 AOP 实现了一个整合日志记录、异常处理和方法耗时打点为一体的统一切面。
但后来发现,使用了 AOP 切面后,这个应用的声明式事务处理居然都是无效的。之前的文章,Spring 事务失效的几种可能性。现在我们来看下这个案例,分析下 AOP 实现的监控组件和事务失效有什么关系,以及通过 AOP 实现监控组件是否还有其他坑。
首先,定义一个自定义注解 Metrics
,打上了该注解的方法可以实现各种监控
功能:
然后,实现一个切面完成 Metrics 注解提供的功能。这个切面可以实现标记了 @RestController 注解的 Web 控制器的自动切入,如果还需要对更多 Bean 进行切入的话,再自行标记@Metrics
注解。
接下来,分别定义最简单的 Controller、Service 和 Repository,来测试 MetricsAspect 的功能。
其中,Service 中实现创建用户的时候做了事务处理,当用户名包含 test 字样时会抛出异常,导致事务回滚。
同时,我们为 Service 中的 createUser 标记了 @Metrics 注解。这样一来,我们还可以手动为类或方法标记 @Metrics 注解,实现 Controller 之外的其他组件的自动监控。
使用用户名“test”测试一下注册功能:
看起来这个切面很不错,日志中打出了整个调用的出入参、方法耗时:
- 第 1、8、9 和 10 行分别是 Controller 方法的入参日志、调用 Service 方法出错后记录的错误信息、成功执行的打点和出参日志。因为 Controller 方法内部进行了 try-catch 处理,所以其方法最终是成功执行的。出参日志中显示最后查询到的用户数量是 0,表示用户创建实际是失败的。
- 第 2、3 和 4~7 行分别是 Service 方法的入参日志、失败打点和异常日志。正是因为 Service 方法的异常抛到了 Controller,所以整个方法才能被 @Transactional 声明式事务回滚。在这里,MetricsAspect 捕获了异常又重新抛出,记录了异常的同时又不影响事务回滚。
一段时间后,开发同学觉得默认的 @Metrics 配置有点不合适,希望进行两个调整:
- 对于 Controller 的自动打点,不要自动记录入参和出参日志,否则日志量太大;
- 对于 Service 中的方法,最好可以自动捕获异常。
为 MetricsController 手动加上了 @Metrics 注解,设置 logParameters 和 logReturn 为 false;然后为 Service 中的 createUser 方法的 @Metrics 注解,设置了 ignoreException 属性为 true:
代码上线后发现日志量并没有减少,更要命的是事务回滚失效了,从输出看到最后查询到了名为 test 的用户:
之前文章,分析了 Spring 通过 TransactionAspectSupport 类实现事务。在 invokeWithinTransaction 方法中设置断点可以发现,在执行 Service 的 createUser 方法时,TransactionAspectSupport 并没有捕获到异常,所以自然无法回滚事务。原因就是,异常被 MetricsAspect 吃掉了。
我们知道,切面本身是一个 Bean,Spring 对不同切面增强的执行顺序是由 Bean 优先级决定的,具体规则是:
- 入操作(Around(连接点执行前)、Before),切面优先级越高,越先执行。一个切面的入操作执行完,才轮到下一切面,所有切面入操作执行完,才开始执行连接点(方法)。
- 出操作(Around(连接点执行后)、After、AfterReturning、AfterThrowing),切面优先级越低,越先执行。一个切面的出操作执行完,才轮到下一切面,直到返回到调用点。
- 同一切面的 Around 比 After、Before 先执行。
对于 Bean 可以通过 @Order
注解来设置优先级,查看 @Order 注解和 Ordered 接口源码可以发现,默认情况下 Bean 的优先级为最低优先级,其值是 Integer 的最大值。其实,值越大优先级反而越低,这点比较反直觉:
我们再通过一个例子,来理解下增强的执行顺序。新建一个 TestAspectWithOrder10 切面,通过 @Order 注解设置优先级为 10,在内部定义 @Before、@After、@Around 三类增强,三个增强的逻辑只是简单的日志输出,切点是 TestController 所有方法;
然后再定义一个类似的 TestAspectWithOrder20 切面,设置优先级为 20:
调用 TestController 的方法后,通过日志输出可以看到,增强执行顺序符合切面执行顺序的三个规则:
因为 Spring 的事务管理也是基于 AOP 的,默认情况下优先级最低也就是会先执行出操作,但是自定义切面 MetricsAspect 也同样是最低优先级,这个时候就可能出现问题:如果出操作先执行捕获了异常,那么 Spring 的事务处理就会因为无法捕获到异常导致无法回滚事务。
解决方式是,明确 MetricsAspect 的优先级,可以设置为最高优先级,也就是最先执行入操作最后执行出操作:
此外,我们要知道切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解的。
修复方式是,改为优先从方法获取,如果获取不到再从类获取,如果还是获取不到再使用默认的注解:
经过这 2 处修改,事务终于又可以回滚了,并且 Controller 的监控日志也不再出现入参、出参信息。
总结下这个案例。利用反射 + 注解 +Spring AOP 实现统一的横切日志关注点时
,我们遇到的 Spring 事务失效问题,是由自定义的切面执行顺序引起的。
这也认识到,因为 Spring 内部大量利用 IoC 和 AOP 实现了各种组件,当使用 IoC 和 AOP 时,一定要考虑是否会影响其他内部组件。
三、总结
- 第一,让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
- 第二,如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把 Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为 TARGET_CLASS。
- 第三,如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。你可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执行顺序,是什么样的。