大家好,这篇文章跟大家聊下 SpringCloudAlibaba 中的微服务组件 Nacos。Nacos 既能做注册中心,又能做配置中心,这篇文章主要来聊下做配置中心时 client 端的一些设计,主要从源码层面进行分析,相信看完这篇文章你对 Nacos client 端的工作原理应该有比较深刻的了解。
SpringCloud 应用启动拉去配置
我们之前写过一篇文章,介绍了一些 Spring 提供的扩展机制。其中说到了 ApplicationContextInitializer,该扩展是在上下文准备阶段(prepareContext),容器刷新之前做一些初始化工作,比如我们常用的配置中心 client 基本都是继承该初始化器,在容器刷新前将配置从远程拉到本地,然后封装成 PropertySource 放到 Environment 中供使用。
在 SpringCloud 场景下,SpringCloud 规范中提供了 PropertySourceBootstrapConfiguration 继承 ApplicationContextInitializer,另外还提供了个 PropertySourceLocator,二者配合完成配置中心的接入。
从上述截图可以看出,在 PropertySourceBootstrapConfiguration 这个单例对象初始化的时候会将 Spring 容器中所有的 PropertySourceLocator 实现注入进来。然后在 initialize 方法中循环所有的 PropertySourceLocator 进行配置的获取,从这儿可以看出 SpringCloud 应用是支持我们引入多个配置中心实现的,获取到配置后调用 insertPropertySources 方法将所有的 PropertySource(封装的一个个配置文件)添加到 Spring 的环境变量 environment 中。
上图展示了在 spring-cloud-starter-alibaba-nacos-config 包提供的自动装配类中进行了 NacosPropertySourceLocator 的定义,该类继承自上述说的 PropertySourceLocator,重写了 locate 方法进行配置的读取。
我们来分析下 NacosPropertySourceLocator,locate 方法只提取了主要流程代码,可以看到 Nacos 启动会加载以下三种配置文件,也就是我们在 bootstrap.yml 文件里配置的扩展配置 extension-configs、共享配置 shared-configs 以及应用自己的配置,加载到配置文件后会封装成 NacosPropertySource 返回。
public PropertySource<?> locate(Environment env) {
// 生成 NacosConfigService 实例,后续配置操作都是围绕该类进行
ConfigService configService = nacosConfigManager.getConfigService();
if (null == configService) {
log.warn("no instance of config service found, can't load config from nacos");
return null;
}
long timeout = nacosConfigProperties.getTimeout();
// 配置获取(使用 configService)、配置封装、配置缓存等操作
nacosPropertySourceBuilder = new NacosPropertySourceBuilder(configService,
timeout);
CompositePropertySource composite = new CompositePropertySource(
NACOS_PROPERTY_SOURCE_NAME);
loadSharedConfiguration(composite);
loadExtConfiguration(composite);
loadApplicationConfiguration(composite, dataIdPrefix, nacosConfigProperties, env);
return composite;
}
loadApplicationConfiguration 加载应用配置时,同时会加载以下三种配置,分别是
不带扩展名后缀,application
带扩展名后缀,application.yml
带环境,带扩展名后缀,application-prod.yml
并且从上到下,优先级依次增高
加载的核心方法是 loadNacosDataIfPresent -> loadNacosPropertySource
build 方法调用 loadNacosData 获取配置,然后封装成 NacosPropertySource,并且将该对象缓存到 NacosPropertySourceRepository 中,后续会用到。
loadNacosData 方法中会将实际配置加载请求委托给 configService 去做,然后解析返回的字符串,解析器实现了 PropertySourceLoader 接口,支持 yml、properties、xml、json 这几种。
getConfig 方法会调用到 getConfigInner 方法,通过 namespace, dataId, group 唯一定位一个配置文件
首先获取本地缓存文件的配置内容,如果有直接返回
如果步骤 1 从本地没找到相应配置文件,开始从远处拉去,Nacos 2.0 以上版本使用 Grpc 协议进行远程通信,1.0 及以下使用 Http 协议进行远程通信,我们这边以 1.x 为例来解读
getServerConfig 方法会构造最终的 http 请求参数进行调用,如果返回 ok,则将返回内容写入到本地缓存文件中,并进行返回。
至此,在项目启动的时候(上下文准备阶段)我们就拉到了远程 Nacos 中的配置,并且封装成 NacosPropertySource 放到了 Spring 的环境变量里。
监听器注册
上面章节我们说了服务启动的时候从远程 Nacos 服务端拉到配置,这个章节我们来说下配置变动怎么实时通知到客户端,首先需要注册监听器。
主要看 NacosContextRefresher 类,该类会监听服务启动发布的 ApplicationReadyEvent 事件,然后进行配置监听器的注册。
registerNacosListenersForApplications 方法里会进行判断,如果自动刷新机制是开启的,则进行监听器注册。上个章节我们说到了会将拉到的配置缓存到 NacosPropertySourceRepository 中, 这儿就从缓存中获取所有的配置,然后循环进行监听器注册(如果配置文件中配置 refresh 字段为 false,则不注册监听器)。
我们可以看到,监听器是以 dataId + groupId + namespace 为维度进行注册的,监听器的主要操作就三步。
REFRESH_COUNT ++,在上述说的 loadNacosPropertySource 方法有用到
往 NacosRefreshHistory#records 中添加一条刷新记录
发布一个 RefreshEvent 事件,该事件是 SpringCloud 提供的,主要就是用来做环境变更刷新用的
注册操作经过 ConfigService,在 ClientWorker 中处理,这块会创建一个 CacheData 对象,该对象主要就是用来管理监听器的,也是非常重要的一个类。
CacheData 中字段如下图,ManagerListenerWrap 对 Listener 做层包装,内部会保存 listener、上次变更的 content 以及 md5(用来判断配置有没有变更用)。
并且在 addCacheDataIfAbsent 方法中会将刚才创建的 CacheData 缓存到 ClientWorker 中的一个 Map 中,后续会用到。
至此,在服务启动后向每一个需要支持热更新的配置都注册了一个监听器,用来监听远程配置的变动,以及做相应的处理
配置热更新
上面章节我们讲了服务启动的时候从远程 Nacos 服务端拉到配置,以及服务启动后对需要支持热更新的配置都注册了一个监听器,这个章节我们来说下配置变动后具体是怎么处理的。
回到上述说过的 NacosPropertySourceLocator 的 locate 方法看看,该方法首先会获取一个 ConfigService。
NacosConfigManager 中会进行一个 ConfigService 单例对象的创建,创建流程最终会委托给 ConfigFactory,使用反射方式创建一个 NacosConfigService 的实例对象,NacosConfigService 是一个很核心的类,配置的获取,监听器的注册都需要经此。
我们看下 NacosConfigService 的构造函数,会去创建一个 ClientWorker 类的对象,这个类是实现配置热更新的核心类。
ClientWorker 的构造函数里会去创建两个线程池,executor 会每隔 10ms 进行一次配置变更的检查,executorService 主要是用来处理长轮询请求的。
checkConfigInfo 方法中会创建一个长轮询任务丢到 executorService 线程池中去处理。
LongPollingRunnable 的 run 方法代码有点多,主要流程如下:
- 获取上个章节中说到的缓存 cacheMap,然后遍历,判断如果该配置使用的是本地缓存模式,则调用 checkListenerMd5 去检查读到的本地缓存文件中内容的 Md5 跟上次更新的 Md5 是不是一样,不一样则调用 safeNotifyListener 去通知监听器处理,并且更新 listenerWrap 中的 content、Md5
- checkUpdateDataIds 该方法中,会将所有的 dataId 按定义格式拼接出一个字符串,构造一个长轮询请求,发给服务端,Long-Pulling-Timeout 超时时间默认 30s,如果服务端没有配置变更,则会保持该请求直到超时,有配置变更则直接返回有变更的 dataId 列表。
- 拿到第二步有变更的 dataId 后会调用 getServerConfig 获取最新的配置内容,然后遍历调用 checkListenerMd5 去检查最新拉取的配置内容的 Md5 跟上次更新的 Md5 是不是一样,不一样则调用 safeNotifyListener 去通知监听器处理,并且更新 listenerWrap 中的 content、Md5
checkListenerMd5 方法如下,主要就是判断两个 md5 是不是相同,不同则调用 safeNotifyListener 处理。
safeNotifyListener 方法主要就是调用监听器的 receiveConfigInfo 方法,然后更新监听器包装器中的 lastContent、lastCallMd5 字段。
监听器要执行的方法我们上面也已经讲过了,这边再贴下截图,主要就是发布 RefreshEvent 事件。
至此,Nacos 的处理流程已经结束了,RefreshEvent 事件主要由 SpringCloud 相关类来处理。
RefreshEvent 事件处理
RefreshEvent 事件会由 RefreshEventListener 来处理,该 listener 含有一个 ContextRefresher 的对象。
如下图所示,refreshEnvironment 会去刷新 Spring 环境变量,实际上是交给 updateEnvironment 方法去做的刷新,具体刷新思想就是重新创建一个 Spring 容器,然后将这个新容器中的环境信息设置到原有的 Spring 环境中。拿到所有变化的配置项后,发布一个环境变化的 EnvironmentChangeEvent 事件。
ConfigurationPropertiesRebinder 会监听 EnvironmentChangeEvent 事件,监听到事件后会对所有的标注有 ConfigurationProperties 注解的配置类进行销毁后重新初始化的操作,完之后我们的配置类中的属性就是最新的了。
这里我们说到了会对标有 ConfigurationProperties 注解的配置类进行 rebind,那对于普通组件类里标有 @Value 注解的属性要怎么生效呢?这个其实需要配合 @RefreshScope 注解来生效的。
我们继续回到上述的 refresh() 方法,接着会有一步 refreshAll 的操作,会调用父类的 destroy 方法。
父类就是 GenericScope,我们知道 Spring 中的 Bean 是有Scope 的概念的,Spring 默认 Scope 有单例和原型两种,同时提供了 Scope 扩展接口,通过实现该接口我们可以定义自己的 Scope。
通过doGetBean 方法可以看出,这些自定义 Scope 类型对象的管理会交给相应的 Scope 实现去管理。
SpringCloud 实现的 RefreshScope 就是用来在运行时动态刷新 Bean 用的,RefreshScope 继承 GenericScope,提供 get 和 destroy 方法。
GenericScope 内部有一个 cache,用来保存所有该 Scope 类型的对象。
回到主线,所以在 refreshAll 中调用 super.destroy 方法时会将该 scope 的这些 Bean 都销毁掉,在下次 get 的时候在重新创建 Bean,新创建的 Bean 就有了我们最新的配置。
至此,我们就实现了配置热更新的效果了。
总结
文章从服务启动时的配置拉取,服务启动后的配置监听器注册,以及配置变动后的热更新实现三个方面从源码层面解析了整个的原理,希望对大家有所帮助。
个人开源项目
DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。
目前累计 2.2k star,欢迎大家试用,感谢你的 star,欢迎 pr,业务之余一起给开源贡献一份力量
gitee 地址:https://gitee.com/dromara/dynamic-tp
github 地址:https://github.com/dromara/dynamic-tp