19 | Spring框架:IoC和AOP是扩展的核心
下载APP
关闭
渠道合作
推荐作者
19 | Spring框架:IoC和AOP是扩展的核心
2020-04-25 朱晔 来自北京
《Java业务开发常见错误100例》
课程介绍
讲述:王少泽
时长17:44大小16.24M
你好,我是朱晔。今天,我们来聊聊 Spring 框架中的 IoC 和 AOP,及其容易出错的地方。
熟悉 Java 的同学都知道,Spring 的家族庞大,常用的模块就有 Spring Data、Spring Security、Spring Boot、Spring Cloud 等。其实呢,Spring 体系虽然庞大,但都是围绕 Spring Core 展开的,而 Spring Core 中最核心的就是 IoC(控制反转)和 AOP(面向切面编程)。
概括地说,IoC 和 AOP 的初衷是解耦和扩展。理解这两个核心技术,就可以让你的代码变得更灵活、可随时替换,以及业务组件间更解耦。在接下来的两讲中,我会与你深入剖析几个案例,带你绕过业务中通过 Spring 实现 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 复杂的模块会让问题排查变得更复杂。因此,今天这一讲,我会带你先打好基础,通过两个案例来重点聊聊 IoC 和 AOP;然后,我会在下一讲中与你分享 Spring 相关的坑。
单例的 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 切面后,这个应用的声明式事务处理居然都是无效的。你可以先回顾下第 6 讲中提到的,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 时,一定要考虑是否会影响其他内部组件。
重点回顾
今天,我通过 2 个案例和你分享了 Spring IoC 和 AOP 的基本概念,以及三个比较容易出错的点。
第一,让 Spring 容器管理对象,要考虑对象默认的 Scope 单例是否适合,对于有状态的类型,单例可能产生内存泄露问题。
第二,如果要为单例的 Bean 注入 Prototype 的 Bean,绝不是仅仅修改 Scope 属性这么简单。由于单例的 Bean 在容器启动时就会完成一次性初始化。最简单的解决方案是,把 Prototype 的 Bean 设置为通过代理注入,也就是设置 proxyMode 属性为 TARGET_CLASS。
第三,如果一组相同类型的 Bean 是有顺序的,需要明确使用 @Order 注解来设置顺序。你可以再回顾下,两个不同优先级切面中 @Before、@After 和 @Around 三种增强的执行顺序,是什么样的。
最后我要说的是,文内第二个案例是一个完整的统一日志监控案例,继续修改就可以实现一个完善的、生产级的方法调用监控平台。这些修改主要是两方面:把日志打点,改为对接 Metrics 监控系统;把各种功能的监控开关,从注解属性获取改为通过配置系统实时获取。
思考与讨论
除了通过 @Autowired 注入 Bean 外,还可以使用 @Inject 或 @Resource 来注入 Bean。你知道这三种方式的区别是什么吗?
当 Bean 产生循环依赖时,比如 BeanA 的构造方法依赖 BeanB 作为成员需要注入,BeanB 也依赖 BeanA,你觉得会出现什么问题呢?又有哪些解决方式呢?
在下一讲中,我会继续与你探讨 Spring 核心的其他问题。我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 24
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
18 | 当反射、注解和泛型遇到OOP时,会有哪些坑?
下一篇
20 | Spring框架:框架帮我们做了很多工作也带来了复杂度
精选留言(23)
- Darren置顶2020-04-26一、注解区别 @Autowired 1、@Autowired是spring自带的注解,通过‘AutowiredAnnotationBeanPostProcessor’ 类实现的依赖注入; 2、@Autowired是根据类型进行自动装配的,如果需要按名称进行装配,则需要配合@Qualifier; 3、@Autowired有个属性为required,可以配置为false,如果配置为false之后,当没有找到相应bean的时候,系统不会抛错; 4、@Autowired可以作用在变量、setter方法、构造函数上。 @Inject 1、@Inject是JSR330 (Dependency Injection for Java)中的规范,需要导入javax.inject.Inject;实现注入。 2、@Inject是根据类型进行自动装配的,如果需要按名称进行装配,则需要配合@Named; 3、@Inject可以作用在变量、setter方法、构造函数上。 @Resource 1、@Resource是JSR250规范的实现,需要导入javax.annotation实现注入。 2、@Resource是根据名称进行自动装配的,一般会指定一个name属性 3、@Resource可以作用在变量、setter方法上。 总结: 1、@Autowired是spring自带的,@Inject是JSR330规范实现的,@Resource是JSR250规范实现的,需要导入不同的包 2、@Autowired、@Inject用法基本一样,不同的是@Autowired有一个request属性 3、@Autowired、@Inject是默认按照类型匹配的,@Resource是按照名称匹配的 4、@Autowired如果需要按照名称匹配需要和@Qualifier一起使用,@Inject和@Name一起使用 二:循环依赖: 直观解决方法时通过set方法去处理,背后的原理其实是缓存。 主要解决方式:使用三级缓存 singletonObjects: 一级缓存, Cache of singleton objects: bean name --> bean instance earlySingletonObjects: 二级缓存, Cache of early singleton objects: bean name --> bean instance 提前曝光的BEAN缓存 singletonFactories: 三级缓存, Cache of singleton factories: bean name --> ObjectFactory展开
作者回复: 👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻👍🏻
共 6 条评论99 - Husiun2020-04-25问题2,循环依赖会抛出异常BeanCurrentlyInCreationException,官网的解决方案是由构造器注入改为setter注入17
- norman2020-04-25@Resource 和 @Autowired @Inject 三者区别: 1 @Resource默认是按照名称来装配注入的,只有当找不到与名称匹配的bean才会按照类型来装配注入。 2 @Autowired默认是按照类型装配注入的,如果想按照名称来转配注入,则需要结合@Qualifier。这个注释是Spring特有的。 3 @Inject是根据类型进行自动装配的,如果需要按名称进行装配,则需要配合@Named展开
作者回复: 👍🏻,也可以参考 https://stackoverflow.com/questions/20450902/inject-and-resource-and-autowired-annotations 这里的回复
7 - 和海明威下棋2020-11-25//@annotation指示器实现对标记了Metrics注解的方法进行匹配 @Pointcut("within(@org.geekbang.time.commonmistakes.springpart1.aopmetrics.Metrics *)" 这里是不是有笔误?我试了下within无法拦截方法的注解,换成@annotation就可以了7
- 左琪2020-04-26这里的代理类不是单例么,还是说会在增强逻辑里不断创建被代理类?
作者回复: 代理类会来判断是否需要创建新的对象
7 - OneDy2020-08-14关于循环依赖的解决,看到了三种处理方式: 1.使用@Lazy 对其中一个bean懒加载 2. 使用setter属性注入,而并不是构造器注入 3. 使用@PostConstruct在依赖注入后执行初始化 具体可以参考:https://www.baeldung.com/circular-dependencies-in-spring5
- 看不到de颜色2020-05-05很干货的文章,收获满满。 使用AOP时确实要注意执行顺序。A_Around-before -> A_Before -> B_Around-before -> B_Before -> B_Around-after -> B_After -> A_Around-after -> A_After 课后答疑: 关于循环依赖,在单例模式下,Spring采用缓存提前暴露后初始化的方式进行解决。但是生产上出现过一次问题,当使用了@Repository注解时,循环依赖是解不了的。SpringBoot中对@Repository做了特殊处理。展开共 1 条评论4
- W2020-04-25MetricsAspect 这个类里面的小技巧学到了5
- Demon.Lee2020-04-25连接点: 程序执行过程中能够应用通知的所有点;通知(增强): 即切面的工作,定义了What以及When;切点定义了Where,通知被应用的具体位置(哪些连接点) ----Spring实战(第4版)
作者回复: 不错
4 - Joker2020-04-25老师,请教一下,那个sayservice里的data有啥用,那个单例是为了一种重复使用data对吧,那换成每次都生成一个新的bean,那个data还有效果吗。。
作者回复: 只是为了模拟SayService是有状态
2 - 龙行秀2020-09-01“架构师一开始定义了这么一个 SayService 抽象类,其中维护了一个类型是 ArrayList 的字段 data,用于保存方法处理的中间数据。每次调用 say 方法都会往 data 加入新数据,可以认为 SayService 是有状态,如果 SayService 是单例的话必然会 OOM” -----为什么单例就会OOM,多例就不会呢?没看懂
作者回复: 容器维护了这个单例,回收不了
共 3 条评论2 - Carisy2020-06-08针对楼上做些补充说明: 1、@Resource 注解是通过CommonAnnotationBeanPostProcessor处理的,并且@Resource注解并不是“先去按名字找,找不到再按类型”而是"根据类型筛选,筛选出的所有的bean根据名字获取" 2、循环依赖可以通过@Lazy注解2
- mgs20022020-05-11项目也写过类似的日志打点切面,学到了一些小技巧,看后续加到项目里面1
- David Mo2020-04-30@sevice 的坑踩过,代理类一开始不行白,后来说动态创建就懂了。当时是用一个类似工厂类解决的
作者回复: 好吧
1 - Geek_3b10962020-04-30很有收获谢谢老师
作者回复: 不客气
1 - coder2021-08-23干货太多了,老师太强了
- Devil May Cry2021-01-06没理解有状态是什么意思,老师可以解答一下吗共 1 条评论
- 小学生2020-10-16老师,您好,您讲 的切面执行顺序好像不对啊,我的执行顺序和你说的不一致! [10:34:11.367] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder10:31 ] - TestAspectWithOrder10 @Around before [10:34:11.377] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder10:21 ] - TestAspectWithOrder10 @Before [10:34:11.377] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder20:31 ] - TestAspectWithOrder20 @Around before [10:34:11.378] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder20:21 ] - TestAspectWithOrder20 @Before [10:34:11.379] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.aopmetrics.MetricsAspect:79 ] - 【入参日志】调用 【class org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController】【public void org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.test()】【http://localhost:45678/test】 的参数是:【[]】 [10:34:11.379] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.aopmetrics.MetricsAspect:88 ] - 【成功打点】调用 【class org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController】【public void org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.test()】【http://localhost:45678/test】 成功,耗时:0 ms [10:34:11.379] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.aopmetrics.MetricsAspect:107 ] - 【出参日志】调用 【class org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController】【public void org.geekbang.time.commonmistakes.springpart1.aopmetrics.TestController.test()】【http://localhost:45678/test】 的返回是:【null】 [10:34:11.380] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder20:26 ] - TestAspectWithOrder20 @After [10:34:11.380] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder20:33 ] - TestAspectWithOrder20 @Around after [10:34:11.380] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder10:26 ] - TestAspectWithOrder10 @After [10:34:11.380] [http-nio-45678-exec-4] [INFO ] [o.g.t.c.s.a.TestAspectWithOrder10:33 ] - TestAspectWithOrder10 @Around after展开
作者回复: 哪里不对?
共 3 条评论1 - 鲁鸣2020-09-24有一个需求,A依赖B中的某个属性,这个属性会通过配置中心变更进来,但是怎么可以做到当B的这个属性初始化完成了,才会对A初始化呢,现在通过注入方式,可能A初始化时用到的B属性是个空值1
- xuyd2020-07-25直接在Metrics里边把异常跑出来可以嘛