目录
- 前言
- 一. 基础概念
- 1-1. Spring
- 1-2. SpringBoot
- 二. 自动装配概览
- 2-1. 效果(目的)
- 2-2. 猜想
- 2-3. SpringBoot的实现方案
- 2-4. 对比及分析
- 2-4-1. starter里为什么没有pom文件
- 2-4-2. 配置类为什么没写在starter里
- 三. 自动装配细节
- 3-1. 流程图
- 3-2. 各部分细节
- 3-2-1. @Import注入AutoConfigurationImportSelector
- 3-2-2. AutoConfigurationImportSelector
- 3-2-3. Spring容器启动并调用selectImports
- 3-3. AutoConfiguration
- 四. invokeBeanFactoryPostProcessors
- 4-1. 先拆解一下invokeBeanFactoryPostProcessors的大体结构
- 4-2. 实际详细过程
- 看源码可能产生的疑问
- 五. 特殊组件:Tomcat
- 5-1. 创建
- 5-2 启动
- 5-3. 关闭
- addShutdownHook
- 5-4. 单独Tomcat和SpringBoot的区别
- 5-5. 策略模式
- Tomcat启动真的在Spring代码里?
- 六. 总结
- 6-1. SpringBoot的自动装配
- 6-2. 一般组件自动装配过程
- 6-3. 一些注意点
- 参考
前言
本文假设读者已经有了Spring和SpringBoot的使用基础。全文一万多字,深入探讨了源码的设计模式、模块之间的关系以及容易混淆的细节。
通过剖析自动装配机制的前因后果,由浅入深的看看SpringBoot主要做了些什么事。【期间还发现了一个让人忽视的Maven特性】
都是干货,无关紧要以及说了也让人记不住的繁琐过程基本都被省略了。如果觉得文章太长,可以只看总结。如果觉得总结的还有点意思,再往前看。
一. 基础概念
概念很重要,但也很抽象空洞,在深入细节之后,再回过头看,会有新的理解。
1-1. Spring
- 是什么:一个IOC(控制反转)容器框架。
- 什么原理:通过依赖注入(DI,开发人员使用控制反转的方式),切面编程(AOP)实现了模块之间的解耦,模块的复用。让开发人员不再关心模块的创建(其实是不需要关心模块从生成到销毁的整个生命周期),而只需要专注于使用。
- 目的:简化企业级java项目开发。
1-2. SpringBoot
- 是什么:一个开箱即用的Spring集成框架(或者说脚手架。所谓脚手架,指一种支持快速搭建项目的的工具或框架。这里的意思就是说,除去Spring和其他集成的框架,SpringBoot就只是一个搭建项目的辅助工具。这里的开箱即用除了有“什么都不缺”,还有一层意思是“不需要写main方法,这个东西可以拿来直接跑”。Boot即“引导启动”,指的就是这层意思)。
- 什么原理:通过自动装配,约定大于配置原则实现(体现在两方面:1. 命名。2. 一些默认配置)。启动比较简单,就是加个main方法入口,实现可独立运行。
- 目的:在Spring的基础上,进一步简化配置,开箱即用。(Spring是用配置简化开发,SpringBoot想把配置也简化掉)
二. 自动装配概览
2-1. 效果(目的)
- 在项目pom.xml文件里添加相应的starter依赖,代码里就直接可以用各种组建(已经被注入Spring容器)。
- 所有组件的配置信息都可以在SpringBoot的配置文件application.yml里统一管理。
2-2. 猜想
- 引入组件jar包:Maven可以轻松实现这一步骤。只需要在starter中的pom文件中添加依赖,就可以满足组件所需要的jar包。
- 自动注入:在starter里写一个配置类(@Configuration + @Bean),然后让Spring扫描到starter的包路径。配置类里把需要的Bean都创建出来。
- 统一读配置:通常情况下,创建出组件对象之后,把各种配置参数set进对象就可以了(或者在new的时候就把参数传入构造器)。所以这部分代码同样写在配置类里就可以。按照Spring的配置规则,去classpath下的application.yml文件里读配置。
2-3. SpringBoot的实现方案
- 读配置文件spring.factories。然后通过反射实例化xxxAutoConfigure(配置类),并将其注入到Spring容器。其实就是SpringBoot实现的SPI机制。
- 其他和我们猜想的基本一致。
- 有两点和我们猜想的不太一样:
- 配置类以及读配置的代码都没写在starter包里(都写在了spring-boot-autoconfigure包里)。
- starter包里没有pom文件。
- 也就是说:starter里什么都没有(只有一个MANIFEST.MF:jar包信息文件)。
这是spring-boot-starter-data-redis包里的情况。
2-4. 对比及分析
2-4-1. starter里为什么没有pom文件
经过对比之后,其实最让人困惑的就是这个问题。
加载类、实例化对象和注入容器这些操作是由classloader和Spring来完成的,那么没有POM文件的情况下,依赖的jar包从何而来呢?
SpringBoot当然不会无论是否需要,统统将其依赖进来。我们通过实验,也可以明显发现:加入starter,重新构建,starter依赖的包就能加进来,否则就会被删掉。
经过网上查(没查到)/和人交流/问chatgpt/做实验,得出一个结论:
是因为jar包外的xxx.pom文件(如spring-boot-starter-data-redis-2.2.6.RELEASE.pom),starter才能依赖的其他包。
chatgpt给出解释是:Maven首先看jar包内的pom.xml,如果jar包内没有,则使用和jar包同名的xxx.pom文件。【虽然不能确认是否正确,但确实可以解释现象】
所以我总结xxx.pom文件的作用:
-
在使用IDEA时,当我们点击依赖包的名称时,IDEA会显示该依赖包对应的pom文件,这个pom文件就是xxx.pom文件。如果把这个文件删除,那么IDEA就无法找到该文件,从而无法显示依赖包的信息【之前一直以为idea打开的是jar包里pom.xml】
-
当jar包内没有pom.xml时,maven以它为依据获取依赖【本次学到的】
猜想:由于存在外部的xxx.pom文件,因此并不需要使用jar包中的pom.xml文件。这样做可以省去解压jar包的步骤,从而使下载更加快速和方便。
那么,Maven是否使用的正是xxx.pom文件,而不是pom.xml文件?
由于缺乏官方的证据和参考资料,我对之前ChatGPT所提供的回答表示怀疑。【如果您能够提供官方的参考资料,请留言分享】
2-4-2. 配置类为什么没写在starter里
为了拓展性。
是的,如果所有组件都是SpringBoot管理,这个设计确实没问题。Spring扫描包的时候,注解@ComponentScan(basePackages = {"com.*"})
只要覆盖到starter包里的配置类即可。
但是,Spring的很多“好东西”都会考虑可扩展:我能用,用户也要可以用。
如果用户也想使用这套机制,要引入的组件包路径就不是Spring的包路径了,Spring就可能扫描不到。
所以提供一个配置文件,在其中指定自己配置类路径,这样做能最大程度提高灵活性,让用户也可以轻松使用并扩展自己的组件。
看一下spring的这个配置文件长什么样:
文件采用key-value形式存储。
只在一部分jar包里有这个文件,如spring-boot, spring-boot-autoconfigure等包内。
读的时候会把classpath下所有存在的spring.factories统统读进来。
其中自动装配需要的就是key为org.springframework.boot.autoconfigure.EnableAutoConfiguration
注意右侧的value值,该值通常很长,并用逗号隔开,而且名字一个个都叫“xxxAutoConfiguration”,表示这都是配置类(带注解@Configuration的)。
那文件里其他的key呢?
SpringBoot启动过程中还有很多流程,也会用到这个文件。比如各种Listener。也都是读配置,然后反射加载然后实例化的。
三. 自动装配细节
3-1. 流程图
3-2. 各部分细节
3-2-1. @Import注入AutoConfigurationImportSelector
找到SpringBoot的启动类Application上的注解@SpringBootApplication
,点进去。
找到@EnableAutoConfiguration
,点进去。
最后就可以看到注解@Import(AutoConfigurationImportSelector.class)
注解和普通类一样具有继承特性,所以相当于Application也使用了注解@Import,并注入了AutoConfigurationImportSelector。
3-2-2. AutoConfigurationImportSelector
AutoConfigurationImportSelector
不是一个普通类,它继承了DeferredImportSelector
。
实现了其中的方法selectImports
可以看到selectImports返回了一个String[],其实返回的就是上图中的A(配置文件spring.factories)里的内容。
那么,这个selectImports方法是被谁调用的呢?就是上图中所示[3]的位置。简单来说,在SpringBoot启动过程中被调用,但这个过程非常复杂。因此,我们需要逐层剖析SpringBoot和Spring的启动过程,直到找到调用该方法的位置。
3-2-3. Spring容器启动并调用selectImports
这部分,我们从一个大家都熟悉的地方开始讲起,比较容易让读者理解。
这里之所以还要讲Spring的启动流程,是因为讲SpringBoot的启动流程,绕不开Spring的启动流程。从某种程度上说,SpringBoot并没有什么独创性的功能,即便是核心的自动装配,也是依赖Spring才得以实现的。
SpringBoot这种非常不独立,以及存在感弱的特点。也就导致很多人用了许久,也说不清楚SpringBoot到底是个什么东西。
比起Spring,它似乎就是多了一个main方法,配置比较方便而已(事实上,这确实就是SpringBoo的全部功能)。但功能简单并不代表意义平凡。Spring说起来,实现机制那么复杂,但目的无非也是简化开发。
两个核心方法:
-
Spring启动核心方法:
org.springframework.context.support.AbstractApplicationContext
类里的refresh()。AbstractApplicationContext也是Spring的核心类之一,属于spring-context包。
启动Spring容器的时候,无论是用xml还是注解,都会走到refresh()方法。
-
SpringBoot启动核心方法:
org.springframework.boot.SpringApplication
类里的run(String… args)。SpringApplication是SpringBoot核心类,属于spring-boot包。
启动springBoot时,执行SpringApplication.run(Application.class, args),很快就会进入run方法。
先直观的看一下着两个核心方法,左边的是SpringBoot的,右边是Spring的
注意看黄色箭头标注的位置,就是SpringBoot通向Spring核心启动方法的位置。
SpringBoot属于spring的子项目。如果按照java对象的父子关系,子类包含父类来类比。
放在这里,就是SpringBoot内包含了spring。 启动过程也是这样的包含关系。
后面再说源码的时候,我都会以这两块代码为“坐标”来说,这样就不至于出现“搞不清身在何处”的问题了。
右边箭头标注的invokeBeanFactoryPostProcessors(beanFactory);
就是处理selectImports方法的入口。
所以:spring.factories里的AutoConfiguration配置类是Spring容器启动过程中的PostProcessor注入的。
如果要继续深入下去,找具体的调用点,请看后面第四节[invokeBeanFactoryPostProcessors]。
因为PostProcessor代码很复杂,我单独分出一块来讲。这里不再赘述。
3-3. AutoConfiguration
SpringBoot费那么大的劲,通过SPI机制,实例化出来个什么东西呢。
以spring-boot-starter-data-redis(用redis时需要加的starter)为例
这是在spring.factories里,"RedisAutoConfiguration"是value里一员。
为什么默认就在配置里了,我还没想用redis呢。
因为要让一个starter真正用起来,spring.factories的配置和pom.xml里的依赖缺一不可(SpringBoot就等着你在pom.xml里加redis的starter呢)。
当这个配置类被实例化并注入Spring容器之后。其中配置的两个Bean(Redis组件):RedisTemplate<Object, Object> 和StringRedisTemplate也就被自动注入到了Spring容器。
其中注解@EnableConfigurationProperties是用来将properties或yml配置文件属性转化为Bean对象。括号里的属性写要封装的Bean类型。打开这个类
通过这个@ConfigurationProperties(prefix = "spring.redis")
注解,就可以自动从配置文件中读配置并封装到当前对象中。redis的配置规则就是以spring.redis开头。
注意:什么叫配置类?前面展示两个类,哪个是配置类?
在Spring中,前者(注入Bean的)叫配置类。就是使用@Configuration或者@Component、@ComponentScan、@Import、@ImportResource等叫配置类(一般我们习惯只把@Configuration叫配置类)。
而@ConfigurationProperties的类是用来封装配置信息的,却不叫配置类。
四. invokeBeanFactoryPostProcessors
这部分主要是讲Spring的重要组成部分:Bean后置处理器PostProcessor的源码。
4-1. 先拆解一下invokeBeanFactoryPostProcessors的大体结构
这张图只是抽象出了一个非常简单的过程(实际过程比这复杂的多):
在PostProcessorRegistrationDelegate这个类中
- 先遍历调用接口BeanDefinitionRegistryPostProcessor的方法。其中本次关注的主角selectImports就是在这部分里完成的。
- 然后遍历调用BeanFactoryPostProcessor的方法(用户大部分的扩展都是通过继承这个接口,在这个过程中执行的)
看一下这两个接口的注释:
先看第二个接口的,BeanFactoryPostProcessor
注意看标注的位置,核心就是说这个单词:hook。
很常规的扩展手段,通过钩子方法来让用户来插入自己自定义内容。也是PostProcessor功能的核心。
然后看BeanDefinitionRegistryPostProcessor
首先,它继承了前面那个BeanFactoryPostProcessor。
关键单词:SPI。就是说这是SpringBoot实现的SPI机制。通过一个配置文件(spring.factories)来加载其他类。
意思就是说:这个接口也是一个处理Bean的钩子(因为继承了那个钩子),只不过功能相对更加单一。具有通过SPI机制,读配置批量加注入Bean的功能。
4-2. 实际详细过程
过程相对复杂。尤其是最终解析配置类的类ConfigurationClassParser(上图下半部分)。递归调用(为了一层层往下找注解),代码绕来绕去的。【这个图仅供看源码时参考,这也仅仅是一部分关键的节点】
看源码可能产生的疑问
- selectImports被调用的地方藏的那么深,你是怎么找到的?
对我来说,首先是查资料,知道了关键代码selectImports。然后就是就是搜代码,找selectImports被谁调用的(理解了后面两个问题之后才会知道为什么在哪个位置)。 - selectImports为什么在“处理配置类”的代码里?
前面我们说“什么叫配置类”。配置类其实包含的内容很广,不仅仅是@Configuration的。@Import也属于配置类的范畴(具体看这篇博客)。 - selector是什么意思?
Spring的“约定大于配置”,其中一个就是命名上的约定。Spring会有很多“常见的名词”,如Ware,Configure。而Selector表示一种“选择器”,是一种选择或筛选机制。这里就是选择Bean的机制(配置文件里写上全类名)。
五. 特殊组件:Tomcat
其实就是说的Web容器。对于普通组件,就是new一下,set一些参数就能用了。但Web容器还多了一步:启动。而且要包含在SpringBoot的启动过程中。
我们猜想,Tomcat的创建和启动应该是在SpringBoot代码里,毕竟Tomcat太特殊了。
但实际上Tomcat的创建和启动都是在Spring容器创建过程中。
5-1. 创建
在Spring启动的核心方法里,进入onRefresh()里,就能找到创建Tomcat对象的位置。
5-2 启动
很多人认为,前面代码执行完之后就算Tomcat启动了(相关日志也打印出来了),但实际并没有。前面只是创建了Tomcat实例并初始化。
启动代码在这里面
是不是很意外,Tomcat的创建启动不但放在Spring里,而且还拆的那么散。
5-3. 关闭
还有更意外的。Tomcat启动了,我们还有考虑关闭的问题。SpringBoot关闭之后,还要考虑连带着把Tomcat一并关掉。
这个就涉及到JVM关闭前的钩子函数。位置在启动Spring容器之后
addShutdownHook
Runtime.getRuntime().addShutdownHook(进程);
作用:在jvm关闭前,执行这个线程。
问题:强制杀进程,这个进程也会被执行吗?
实验结果:不会。强制执行,这个进程不会被执行。
实验方式:
- 对照组:启动SpringBoot,然后正常关闭(idea里点关闭按钮),看日志。
- 实验组:启动SpringBoot,然后找到进程,强制kill掉,看日志。
实验结果:
正常关闭的有这样的日志:
2023-03-13 12:28:06.440 [SpringContextShutdownHook] INFO o.s.scheduling.concurrent.ThreadPoolTaskExecutor-Shutting down ExecutorService 'applicationTaskExecutor'
而强制kill的没有(自己写demo也能验证)。
思考:那所谓正常关闭,是怎么关的?
其实就和SpringBoot的启动方法run一样,都在SpringApplication类里,叫exit
5-4. 单独Tomcat和SpringBoot的区别
- 单独启动Tomcat。Tomcat是一个单独的jvm进程。
- SpringBoot里的Tomcat成为了SpringBoot程序的一部分。像new普通组件一样,new出来的,只不过多了一步start。
可以类比为 你的项目需要对外开放一个webservice接口。那么此时,你的项目就多占用了一个端口。
但整个SpringBoot项目还是一个完整的jvm进程。
使用ps -ef | grep java
可以看一下,只有SpringBoot进程,并没有多出来一个Tomcat进程。
5-5. 策略模式
思考:Tomcat那么特殊的组件放在Spring里真的合适吗?不会把Spring正常的流程搞的一团糟吗?
现象:有些人可能找不到启动Tomcat的代码在哪里。因为在refresh方法里的finishRefresh(),点进去,发现并不是我上面截图的那个方法。
以上两件事共同引出了:Spring启动过程采用了策略模式。
先说找不到方法的问题。那个方法其实在ServletWebServerApplicationContext类中。如果你是debug,就会自然点进这个方法里。否则就会点进AbstractApplicationContext自己的finishRefresh()。看下面这个继承关系
也就是说,父类调用了子类的重写的finishRefresh()方法(这个说法其实不准确。如果你对这种现象比较困惑,请看我的另一篇博客复杂父子继承相互调用的深入理解)
再回头看一下SpringBoot的启动核心代码
在这里,也就是把context传入Spring启动流程之前。context的真实类型就已经是子类ServletWebServerApplicationContext。所以进入Spring流程,后面的很多Web相关的个性化代码都是在这个子类里执行的。
这是一个策略模式应用方式:
- 有一个公共抽象父类(抽象策略类),实现一些通用的方法。【如AbstractApplicationContext】
- 一堆子类(具体策略类)去继承。【如ServletWebServerApplicationContext】
- 最后把披着父类外衣的子类传到一个上下文环境类中执行【如SpringApplication】。会让父类表现出某个子类的行为特性。
这样子类的个性化行为就不会影响到公共父类的代码了。
Tomcat启动真的在Spring代码里?
前面看似“自圆其说”了,但其实并不对。
事实上,Tomcat启动的过程不但使用了策略模式,单独另外写了一个子类。而且这个子类实际上属于SpringBoot的代码。这是全类名
org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext
现在是不是就很圆满了:SpringBoot没有动Spring代码。只是在自己的包下重写了一个AbstractApplicationContext的子类。看起来就成功侵入了Spring的流程,把Tomcat启动等一众Web特殊操作给塞进去了。
这才是策略模式的正确打开方式。
六. 总结
6-1. SpringBoot的自动装配
- StringBoot主要就是实现了一个自动装配功能。通过添加starter的方式,让用户快速使用组件。
- 特殊的web组件,如Tomcat。是通过策略模式的方式嵌入在Spring容器启动的过程中。
一般组件如redis。是通过配置类的方式注入。配置类相关代码写在spring-boot-autoconfigure包里。配置类本身又是通过SPI的机制来加载的。用户也可以用这套机制添加自己的扩展。
6-2. 一般组件自动装配过程
- SpringBoot通过@Import标签,注入AutoConfigurationImportSelector对象。
- AutoConfigurationImportSelector类中有个ImportSelector方法,读取spring.factories配置文件。
- 在Spring启动过程中,PostProcessor阶段调用了其中的ImportSelector接口方法,通过反射实例化了配置文件中的配置类。
- 配置类创建并注入了starter需要的组件Bean。
- 创建Bean的过程中,统一去SpringBoot的配置中读取组件自己的配置。
6-3. 一些注意点
- SpringBoot对Spring使用了策略模式。所以SpringBoot并不是简单嵌套了一下Spring。它是确实对Spring动了一些手脚,只不过是无侵入的。
- 别把SpringBoot中的Tomcat当作独立的进程。在SpringBoot里,Tomcat也是一个组件。所有组件都相当于SpringBoot代码的一部分。所以我前文写到“SpringBoot关闭之后,还要考虑连带着把Tomcat一并关掉 ”,其实是不对的。SpringBoot和Tomcat是同一个进程,这里所说的关闭,其实是他们各自的一些资源,比如连接什么。
- 由上面两点,我们再回忆一下没有SpringBoot的时代,看看发生了什么:
- 过去:本质上,JavaWeb其实是在“完善Tomcat程序”(包括Spring在内的,我们写所有程序都是Tomcat的一部分)。
- 现在:本质上,包括Tomcat在内的所有组件,以及我们写的代码,都是SpringBoot的一部分。
- 所以:SpringBoot真的不像我们感觉的那么简单。Spring做到了Java行业事实上的标准。而SpringBoot在开发形式上逆转上位,做到了霸主地位(内在强大了,外在的地位也要跟上嘛)。
- starter包里什么都没有,甚至没有pom文件。用的是Maven仓库中和starter同名的xxx.pom文件做的依赖【也是本文遗留的不太确定的解释】。
- Runtime.getRuntime().addShutdownHook()在使用强制杀进程(kill -9 pid)时不会被调用。具体看这篇博客。
参考
SpringBoot自动装配原理分析二
spring注解之@Import注解的三种使用方式
Spring 使用 @Import 的好处
spring解析配置类
Runtime.getRuntime().addShutdownHook()