这篇文章来扒一扒SpringCloud配置中心的核心原理。
不知你是否跟我一样,在刚开始使用SpringCloud配置中心的时候也有很多的疑惑:
- SpringCloud是什么时候去拉取配置中心的?
- 配置中心客户端的配置信息为什么要写在bootstrap文件中?
- 对象中注入的属性是如何动态刷新的?
- 一些开源的配置中心是如何整合SpringCloud的?
- ...
本文就通过探讨上述问题来探秘SpringCloud配置中心核心的底层原理。
从SpringBoot的启动过程说起
在SpringBoot启动的时候会经历一系列步骤,核心就是SpringApplication的run方法的逻辑
图片
整个过程大致可以划分为三个阶段:
图片
ApplicationContext刷新前阶段,这个阶段主要也干三件事
- 准备Environment(注意我这里加粗了,你懂得),也就是准备SpringBoot的整个外部化配置的对象
- 创建一个ApplicationContext
- 为ApplicationContext做一些准备工作
图片
ApplicationContext刷新阶段,这个阶段其实就是调用ApplicationContext#refresh方法来刷新容器
图片
刷新的整个过程可以看我之前写的万字+20张图剖析Spring启动时12个核心步骤这篇文章
ApplicationContext刷新后阶段,这个阶段其实就是收尾的阶段,这个过程其实没有什么非常核心的事
ok,在说完上面这三个阶段之后,思考一个问题
你觉得在上面的三个阶段,哪个阶段最有可能从配置中心拉取配置?
其实稍微思考一下,肯定是想到的就是刷新前阶段
因为我已经明示了,准备Environment
玩笑归玩笑,为什么是这个阶段?
很好理解,因为这个阶段是准备Environment,也就是准备外部化配置
只需要在这个阶段加载配置中心的配置,放到Environment中,后面在整个ApplicationContext刷新阶段创建Bean的时候,就可以使用到配置中心的配置了
其实不光是配置中心的配置,比如配置文件的配置,也是在这里阶段读取的
至于如何实现的,我们接着往下瞅
准备Environment的核心操作
上一节得出一个结论
准备Environment,也就是prepareEnvironment方法的实现,是拉取配置的核心
prepareEnvironment方法
不过在说这个方法之前,先来讲一下一些前置操作
前置操作
在SpringApplication创建的时候,会去加载spring.factories中的一些对象,其中就包括:
- org.springframework.context.ApplicationListener键对应的ApplicationListener的实现
- org.springframework.boot.SpringApplicationRunListener键对应的SpringApplicationRunListener的实现类
SpringApplicationRunListener仅仅只有一个实现EventPublishingRunListener
构造的时候会创建一个SimpleApplicationEventMulticaster,再将加载的ApplicationListener添加进去
SimpleApplicationEventMulticaster是用来发布事件用的,不清楚的话可以看三万字盘点Spring 9大核心基础功能这篇文章
按照传统,画张图来理一下这部分前置操作
图片
prepareEnvironment的核心逻辑
接着来讲一下prepareEnvironment方法
prepareEnvironment方法
这个方法会首先创建一个Environment对象
之后会执行这么一行方法,传入刚刚创建的Environment对象
listeners.environmentPrepared(environment);
EventPublishingRunListener#environmentPrepared
图片
这个方法最终会发布一个ApplicationEnvironmentPreparedEvent事件
而对这个事件有两个特别重要的监听器:
- ConfigFileApplicationListener
- BootstrapApplicationListener
这些监听器都是通过前置操作从spring.factories配置文件中加载的
ConfigFileApplicationListener,用来处理配置文件的,他会解析配置文件的配置,放到Environment中
BootstrapApplicationListener这个跟本文探讨的主题相关了,它是用来专门来跟配置中心交互的
到这,我们就找到了SpringCloud配置中心配置拉取的整个入口逻辑
不过在分析BootstrapApplicationListener是如何从配置中心拉取配置的之前,先来张图总结一下这部分prepareEnvironment的操作
图片
SpringCloud是如何巧妙地拉取配置的?
在BootstrapApplicationListener中,他首先也会创建一个SpringApplication去执行
图片
其实本质上就是创建一个Spring容器,也就是ApplicationContext
这个容器非常重要,这个容器是专门用来跟配置中心交互的
这个容器在创建的时候会给它两个比较重要的配置
第一个就是设置这个容器所用的配置文件的名称
图片
默认就是bootstrap
这就解释了为什么配置中心的配置信息需要写在bootstrap配置文件中
第二个就是会加入一个配置类
BootstrapImportSelectorConfiguration
图片
这个配置类又会通过@Import注解导入另一个配置类
BootstrapImportSelector
图片
BootstrapImportSelector实现了(间接)ImportSelector接口
那么这个容器在启动的时候,就会调用BootstrapImportSelector的selectImports方法的实现获取到一些配置类
而BootstrapImportSelector的selectImports实现从截图中也就可以看出
他会加载所有的spring.factories中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration的配置类
其实这里@BootstrapConfiguration的作用其实跟@EnableAutoConfiguration的作用是差不多的,都是用来导入配置类的
所以,总的来说,这个用来跟配置中心交互的Spring容器最最主要就是干两件事:
- 加载bootstrap配置文件
- 加载所有的spring.factories中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration对应的配置类
图片
而在spring-cloud-context包下,@BootstrapConfiguration会导入一个很重要的配置类
图片
PropertySourceBootstrapConfiguration
图片
PropertySourceBootstrapConfiguration
这个配置类中会注入这么一个集合对象
PropertySourceLocator
PropertySourceLocator
这个接口非常非常重要,先来看看注释
Strategy for locating (possibly remote) property sources for the Environment. Implementations should not fail unless they intend to prevent the application from starting.
我用我的四级英语功力给大家翻译一下
以一种策略的方式为Environment定位(可能是远程)属性配置(PropertySource)。实现不应该失败,除非打算阻止应用程序启动。
从这个翻译后的意思就是说,这个接口是用来定位,也就是说获取属性配置的
并且可能是远程告诉我们一个很重要的信息,那就是获取的配置信息不仅仅可以存在本地,而且还可以存在远程。
远程?作者这里就差直接告诉你可以从配置中心获取了。。
所以这个接口的作用就是用配置中心获取配置的!
那么自然而然不同的配置中心要想整合到SpringCloud就得实现这个接口
当注入完PropertySourceLocator集合之后,在某个阶段会调用所有的PropertySourceLocator,获取配置中心中的配置
之后在把这些配置放到Environment中
这样在ApplicationContext的刷新阶段就可以使用到配置中心的那些配置了
小总结
到这我们就弄明白了在项目启动中加载配置中心的配置了
其实就是项目在启动时会额外创建一个跟配置中心相关的Spring容器
这个容器会去加载bootstrap配置文件和所有的spring.factories中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration对应的配置类
之后会去调用这个容器中所有的PropertySourceLocator对象,从配置中心获取配置
再放到Environment中就完成了启动时从配置中心获取配置的方式
最后,来张全家福概括一下前面整体的步骤
图片
如何动态刷新Bean的属性?
我们都知道,要想实现配置属性的动态刷新,需要在类上加上一个注解
@RefreshScope
图片
重点来了
加了@RefreshScope注解的Bean,就拿上图中的UserService举例
Spring在生成的时候会生成两个UserService的Bean:
- 第一个是UserService的代理动态代理的Bean,后面我称为第一个Bean
- 第二个就是UserService这个Bean,后面我称为第二个Bean
当你在其它类中需要注入一个UserService时,真正注入的是第一个Bean,也就是动态代理的Bean
当你使用这个注入的动态代理的Bean的时候,他会去找第二个Bean,也就是真正的UserService这个Bean,然后调用对应的方法
图片
比如你调用注入的UserService代理对象的getUsername方法,最终就会调用到第二个BeangetUsername方法
获取到的username属性值自然也就是第二个Bean中的username值
那么为什么要生成两个Bean?
接着往下瞅
在SpringCloud中有这么一项规定
当配置中心客户端一旦感知到服务端的某个配置有变化的时候,需要发布一个RefreshEvent事件来告诉SpringCloud配置有变动
图片
在SpringCloud中RefreshEventListener类会去监听这个事件
RefreshEventListener
一旦监听到这个事件,SpringCloud会再次从配置中心拉取配置
这个拉取配置的核心逻辑跟启动时拉取配置的核心逻辑是一样的
也是通过 BootstrapApplicationListener 来实现的
图片
这部分代码逻辑在ContextRefresher类中,顺着RefreshEventListener就能看到,有兴趣可以扒一扒
怕你忘了,我再把上面拉取配置的图拿过来
图片
有了最新的配置之后,就会进行一步骚操作来移花接木”刷新“注入到对象的属性
这个骚操作就是销毁所有的前面提到的第二个Bean,但是第一个Bean,也就是代理对象保持不变
图片
当程序运行调用代理对象的方法的时候,发现第二个Bean没有了,此时他就会去重新创建第二个Bean,也就是重新创建一个UserService对象
由于此时已经拉到最新的配置了,也就是这个被重新创建的UserService对象注入的就是最新的属性了
图片
之后再调用的这个新创建的第二个Bean,拿到的自然就是最新的配置
所以,给你的感觉是对象的属性发生了变化,实际上是真正被调用的对象重新创建了
所以这招移花接木还是有点意思的!
小总结
其实到这就弄明白了Bean的属性动态刷新的原理
其实就是当配置中心客户端发现服务端的配置有变化,需要发送一个RefreshEvent事件来告诉SpringCloud配置有变动
SpringCloud会去监听这个事件,按照项目启动的方式重新拉取配置中心最新的属性配置
当拉取完属性配置之后,就会销毁所有的第二个Bean,也就是真正被使用的Bean
之后当第一个Bean(动态代理的Bean)需要使用这个第二个Bean时,就会重新创建这个第二个Bean
此时由于已经有最新的配置了,那么创建的这个第二个Bean就会被注入最新的属性,这样就实现了属性的”刷新“
图片
补充个东西:@RefreshScope的秘密
上面大致说了@RefreshScope动态刷新的原理
这里我补充一下@RefreshScope代码层面的实现原理
本来这部分原理我是写在前面的,但是我发现这块比较绕,怕打断文章的节奏,所以就准备删除了
但是想想既然都写了,那么就给放到补充里面吧,看不懂也不耽误前面的理解
图片
这个注解是个衍生注解,真正起作用的就是@Scope注解
@Scope注解并不陌生,他其实是定义Bean的作用域
比如多例(原型),就可以加上@Scope("prototype")注解
还有一些八股文常背的作用域,比如session作用域等等
而@RefreshScope也可以看做是一种Bean的作用域,名字叫做refresh
这些除了单例和多例之外的作用域的底层实现逻辑都是一样的
这些带有作用域的Bean相比于普通的单例Bean主要有以下几点不同:
- 会注册两个Bean,这个前面已经提到过
- 保存的地方不同,比如单例Bean最终会存在三级缓存中的第一级缓存中,而不同作用域的Bean是存在不同的地方的
先说会注册两个Bean,还是以前面提到的UserService举个例子,这两个Bean分别是
- 第一个Bean的Bean名称为userService,Bean class为ScopedProxyFactoryBean.class,这个scope为默认,也就是单例
- 第二个Bean的Bean名称为scopedTarget.userService,Bean class为UserService.class,scope为refresh(如果是session作用域就是session)
第一个Bean的class为ScopedProxyFactoryBean,是个FactoryBean的实现
图片
这个最终会生成一个代理对象,上面的例子就是为UserService生成一个代理对象,并且由于是单例的,所以最终这个对象会被放到一级缓存中,我们使用时注入的也就是这个对象
第二个Bean的class是UserService,所以生成的就是真正的UserService对象,但是由于scope为refresh,所以不会存在第一级缓存中
这部分注册两个Bean的代码是在ScopedProxyUtils#createScopedProxy方法中,有兴趣的可以扒扒
再来讲一讲保存的地方不同
不同的作用域都需要实现一个Scope接口来存放对应的Bean
图片
比如refresh、session作用域都有对应的实现
图片
也就是通过Scope就可以管理不同作用域的Bean
所以,对于refresh这个作用域来说,他的所有的Bean都在RefreshScope中
后面说的销毁,只需要移除RefreshScope中的Bean就可以了
图片
代码也在ContextRefresher类中
开源配置中心是如何整合SpringCloud的?
首先我们再来梳理一下拉取配置和刷新配置的核心关键点
拉取配置关键点就是项目启动的时候(也包括重新拉取配置),会去创建一个容器
这个容器只读取bootstrap配置文件和spring.factories中的键为org.springframework.cloud.bootstrap.BootstrapConfiguration对应的配置类
之后会获取这个容器中的PropertySourceLocator,从而获取配置中心的配置
刷新配置关键点就是一旦配置中心配置变动,就需要发送RefreshEvent事件,之后一系列刷新操作都是由SpringCloud的来完成的
所以,配置中心整合到SpringCloud其实就很简单,就两点
第一点就是需要实现PropertySourceLocator,并且配置中心一些相关的Bean需要通过org.springframework.cloud.bootstrap.BootstrapConfiguration来装配到这个容器中
第二点,当配置发生变更需要发送RefreshEvent事件,这部分配置中心一些相关的Bean配置肯定是需要通过自动装配来完成
有了这两点我们来看看Nacos作为配置中心是如何整合到SpringCloud的
我们直接看Nacos的spring.factories文件
图片
NacosConfigBootstrapConfiguration是用来实现第一点的
图片
除了Nacos自己的一些Bean,他还声明了一个NacosPropertySourceLocator这个Bean
NacosPropertySourceLocator
这个Bean就实现了PropertySourceLocator接口
第二点的实现就是通过NacosConfigAutoConfiguration配置类来实现的
这里面有这么一个Bean
图片
这个Bean就实现了配置变化发送事件的操作
图片
除了Nacos,比如说Consul作为配置中心的时候也是这么一套实现逻辑
但是值的注意的是,像Apollo配置中心,他并没有适配SpringCloud这套规范
当然,如果你有兴趣,可以自己实现Apollo适配SpringCloud这套规范