本文将从 Nacos 配置中心的基本使用入手, 详细介绍 Nacos 客户端发布配置, 拉取配置, 订阅配置的过程以及服务器对应的处理过程;
配置订阅以及热更新原理相关的部分, 我看了主流的博客网站, 绝对没有比这更详细的讲解;
如果在阅读过程中对文中提到的 SpringBoot 启动过程以及扩展机制不太了解, 或者出现了没见过的词, 在这篇文章 SpringBoot启动流程与配置类处理机制详解, 附源码与思维导图 中都能找到答案, 强烈建议学习后再来读本文;
对SpringBoot 属性加载机制不了解的可以看这里 SpringBoot 属性加载机制
Nacos配置中心
基本使用
一个大型项目, 服务数量会非常多, 每个服务都需要进行配置, 这会导致: 公共的一些配置需要修改时, 需要修改大量服务的配置文件; 并且需要重新启动涉及的所有服务;
Nacos就提供了作为配置中心的功能; 将配置(不仅是公共的, 服务独有的配置也可以保存到Nacos)保存在Nacos服务器中, 实现以中心化 (都在Nacos里)、外部化 (配置独立于服务之外保存) 和动态化 (动态化即可以实时刷新配置) 的方式管理配置。
基本原理
将配置以键值对的形式保存到Nacos中;
服务启动时, 从 Nacos 读取配置, 生成 PropertySource
对象, 保存到 Spring 容器中, 可以使用 @Value注解 等方式进行注入;
配置模型
为了更好地管理配置, 使得一个Nacos配置中心能支持多个应用, 多个服务, Nacos划分了几个不同的维度来组织保存的配置:
命名空间 Namespace
- 用于用户粒度的配置隔离, 每一个命名空间都有一个唯一的ID与之对应, 默认情况下使用
public
命名空间; - 创建一个命名空间时, Nacos 会自动分配一个唯一的ID, 不需要用户指定;
分组 Group
- 每个分组都有一个唯一的组名, 默认使用
DEFAULT-GROUP
分组; - 创建分组时, 由用户指定分组名称;
配置集 Data ID
- 配置集是一组配置项的集合, 类比一个服务的一个配置文件;
- 每个配置集都有一个唯一的 Data ID, 需要用户在创建配置集时手动指定;
- SpringBoot项目启动时, 会自动生成一个配置集的DataID, 并从Nacos下载对应的配置;
- 生成的DataID由三部分构成
${prefix}-${spring.profiles.active}.${file-extension}
- 第一个字段 默认取
spring.application.name
的值, 也可以通过配置项spring.cloud.nacos.config.prefix
来配置。 - 第二个字段, 取
spring.profiles.active
的值, 并且在最前面拼接一个短横杠, 如果该属性没有配置, 这个字段为空, 也没有短横杠;
spring.profiles.active
用于指定激活的 Spring 配置文件。application.yml
文件始终有效, 可以选择是否激活其它的配置文件; 可以根据不同的环境(例如开发、测试、生产等)来加载不同的配置,从而实现环境隔离和配置管理。
- 第三个字段通过
spring.cloud.nacos.config.file-extension
属性配置,可以是properties
或yaml
; - 例如一个名称为 student-service 的服务, 激活了dev 配置文件, 配置的后缀是 yaml, 那么生成的DataID是
student-service-dev.yaml
;
配置项 Key - Value
- 一个配置项就对应一个配置, 将配置的参数名与取值以键值对的形式保存;
使用Nacos配置中心
导包
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
在单个服务中, 添加配置文件bootstrap.yml
, 将和 Nacos-Config 相关的配置放到bootstrap.yml
中;
bootstrap.yml
文件中的配置在应用启动的早期阶段 (引导阶段) 就会被加载;application.yml
文件在 PropertySourceList 中的位置在bootstrap.yml
之前,优先级更高, 因此可以覆盖bootstrap.yml
中的配置;- Nacos 配置中心的相关配置需要在应用启动的早期阶段加载,以便应用能够尽早连接到 Nacos 服务器获取配置。
spring:
application:
# 生成DataID需要
name: facade-service
profiles:
# 生成DataID需要
active: dev
cloud:
nacos:
config:
# 服务器地址
server-addr: localhost:8848
# 生成DataID需要
file-extension: yaml
# 指定group
group: DEFAULT_GROUP
# 指定namespace, 值应该取命名空间对应的ID, 而不是空间名
namespace: eaf7318f-2016-4fb2-a37e-3d528087e550
对 Nacos 配置集中的配置, 可以直接注入并使用; 此时还无法实现配置的热更新; 修改Nacos中保存的配置后, 不会在服务中立即生效, 需要重启服务;
@Value("${nacos.test.config0}")
String nacosTestConfig0;
@GetMapping("/facade/nacosconfig")
public String nacosConfig() {
return nacosTestConfig0;
}
在使用了配置的类上添加@RefreshScope
注解开启热更新;
开启热更新后, 无需重启服务, 即可实现配置更新;
@RefreshScope
public class FacadeController {
@Value("${nacos.test.config0}")
String nacosTestConfig0;
@GetMapping("/facade/nacosconfig")
public String nacosConfig() {
return nacosTestConfig0;
}
}
持久化
Derby 是一个基于JAVA开发关系型数据库, 是可嵌入的。应用程序可以将 Derby 嵌入应用程序进程中,从而无需管理单独的数据库进程或服务。
我们在Nacos服务器上写入的配置,会被持久化保存到Nacos自带的derby中,因此当我们重启Nacos之后,仍然可以看到之前的配置信息。
Nacos还支持将配置信息写入Mysql中:
- 在Mysql中,创建名为
nacos
的数据库 ; - 在nacos数据库中,执行 nacos 安装目录的 conf 目录下的
mysql-schema.sql
脚本, 创建表结构; - 修改 Nacos安装目录
conf/application.properties
文件
spring.datasource.platform=mysql
db.num=1
# 这里的url要改成你自己的mysql数据库地址,并在你的mysql中创建名为nacos的数据库
db.url.0=jdbc:mysql://11.162.196.16:3306/nacos?
characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true
# 这里要改成你自己登录mysql的用户名和密码
db.user.0=nacos_devtest
db.password.0=youdontkno
监听多个配置文件
假设 bootstrap.yaml
有如下配置, 那么 Nacos 客户端启动时, 不仅会监听order-service-dev.yaml
, 还会自动监听order-service
和 order-service.yaml
两个文件;
spring:
application:
name: order-service
profiles:
active: dev
cloud:
nacos:
config:
server-addr: localhost:8848
file-extension: yaml
如果要监听更多配置文件, 例如公共的 MySQL 配置文件, 例如公共的 Redis 配置文件, 进行如下配置
server:
port: 7770
spring:
application:
name: order-service
profiles:
active: dev
cloud:
nacos:
config:
server-addr: localhost:8848
file-extension: yaml
shared-configs:
- data-id: mysql.yaml
refresh: true
- data-id: redis.yaml
refresh: true
发布配置原理2.0.4
客户端
Properties properties = new Properties();
properties.put(PropertyKeyConst.SERVER_ADDR, "localhost");
ConfigService configService = NacosFactory.createConfigService(properties);
configService.publishConfig("test-mysql.yaml", "DEFAULT_GROUP", "my: content");
- 客户端通过 NacosConfigService 向 Nacos 配置中心发出
ConfigPublishRequest
请求; - 这个请求里封装了命名空间, Group, dataId, 配置内容等信息;
- 2.X 版本, 默认是使用的 gRPC 的方式发送;
服务端
以下分析基于开启了 MySQL 外置存储的情况;
服务端由 ConfigPublishRequestHandler
来处理发布请求;
- 将命名空间, 分组, dataId, 内容等信息从请求中提取出来, 然后插入到数据库中;
- 然后发布一个
ConfigDataChangeEvent
, 这个事件被异步地处理, 将配置的发布同步给 Nacos 集群中的其它结点; - 然后再异步地将配置作为一个文件持久化保存到服务器的文件系统中;
- 然后根据命名空间, 组名和data-id去获取内存中, 当前发布的配置集对应的缓存
CacheItem
, 这个缓存保存了配置集的一些描述信息, 例如由配置项计算出的MD5值, 例如上次修改时间, 例如配置文件的类型(比如yaml
)等, 如果没有, 就新创建一个缓存;
磁盘文件存内容, 内存缓存存描述信息;
MD5 值作为配置的一种版本标记; 当一个配置文件的 MD5 改变, 认为该文件发生了更新;
- 检查缓存的 MD5 值(如果是新建的, MD5 = null) 与最新的配置集内容计算出的MD5值是否相同, 如果不同, 发布一个
LocalDataChangeEvent
, 推送新发布的配置给订阅了这个配置的客户端;
拉取配置原理
基于2.0.4版本
客户端
- 在
ApplicationContext
初始化之前, 会有一个独立于应用程序主上下文的BootstrapContext
先被创建并初始化, 这个上下文读取的是bootstrap.yaml
文件; - 这个上下文用于引导配置属性源;
- 了解: 在主上下文的
prepareEnvironment
阶段, 会发布一个EnvironmentPrepared
事件; 这个事件会被BootstrapApplicationListener
( 也是在 spring-cloud- context.spring.facories文件中声明的 ) 处理; 它会创建并初始化一个BootstrapContext
- 在
BootstrapContext
初始化时, 会加载spring.factories
文件中声明的BootstrapConfiguration
类型的组件, 进行自动配置; - SpringCloud 官方团队在
spring-cloud-context-3.1.4.jar\META-INF\spring.factories
文件中提供了一个BoostrapConfiguration
, 叫PropertySourceBootstrapConfiguration
; 同时, 它也是一个初始化器 ApplicationContextInitializer; - 这个初始化器会自动注入
BootstrapContext
中的所有PropertySourceLocator
, 包括NacosPropertySourceLocator
NacosPropertySourceLocator
是由 Nacos提供的NacosConfigBootstrapConfiguration
注册的, 这个配置类也是一个BootstrapConfiguration
;- 这个已经注入
NacosPropertySourceLocator
的初始化器PropertySourceBootstrapConfiguration
会放到SpringApplication
对象中保存; - 这样, 就通过 SpringApplication 对象在 BootstrapContext 和应用的主上下文之间传递了
PropertySourceBootstrapConfiguration
- 应用的主上下文在
prepareContext
阶段, 调用applyInitializers
时, 会调用所有初始化器的初始化方法, 这其中就包括PropertySourceBootstrapConfiguration
的初始化方法; 这个初始化方法会遍历自己持有的PropertySourceLocator
, 并调用他们的locateCollection
方法
为什么采用初始化器
ApplicationContextInitializer
和BootstrapConfiguration
的方式去发送拉取配置的请求? 因为要确保在ApplicationContext初始化之前就能拿到 Nacos 配置中心中的配置, 这样才能在refreshContext的时候去使用这些配置
- 所以, Nacos 自动配置类注入的
NacosPropertySourceLocator
就派上用场了; - 在
NacosPropertySourceLocator
中, 从 Environment 中获取到 bootstrap.yaml 文件对应的 PropertySource, 进而拿到 Nacos 配置中心的地址, 和要拉取的配置文件的 DataId 等信息, - 创建
NacosConfigService
, 调用其getConfig
方法拉取配置; - 在
getConfig
方法中, 会首先尝试从本地的一个特定路径下读取配置文件, 这个文件目录中间有一级是data
开头的; - 但是, 客户端在生成本地缓存的时候, 是放到的
snapshot
开头的文件夹下; 所以除非你手动创建data
开头的本地缓存, 这里永远读不到数据;
根据 Github Issues 中 Nacos 作者的官方回答, 这是一种容灾机制, 防止连不上 Nacos 配置中心, 应用程序无法启动;
连不上的时候, 你就手动创建data 开头的缓存目录, 然后把 snapshot 文件夹下自动保存的配置文件复制进去;
- 如果从本地缓存读到了, 直接返回, 但一般都是读不到;
- 如果读不到, 发送拉取配置的请求, 从 Nacos 服务器获取配置集;
- 将从服务器获取到的配置集保存到
snapshot
文件夹下; - 将获取到的配置集封装成属性源, 保存到 Environment 中; 以后就可以使用了; 每一个 dataId, 都会对应一个属性源 PropertySource;
- 并且, 来自 Nacos 配置中心的属性源, 会在 Environment 内的
PropertySources
内的 List 中排最前; - 另外, 这些来自Nacos 的属性源, 还会在这时被添加到一个
NacosPropertySourceRepository
中, 后面订阅的时候就是从这里取的要订阅的属性源, 获取Group和 DataId (为什么没有命名空间? 因为一个客户端获取的配置集的命名空间是固定的, 保存在一个固定的地方, 发请求的时候当然也会携带上命名空间);
服务端
- 在
ConfigQueryRequestHandler
中处理拉取请求; - 默认情况下, 读取服务器本地的缓存文件, 而不是数据库; 读取后返回给客户端; 如果本地缓存没有读到, 也不会去数据库中读, 返回空;
可以进行配置, 让服务器从数据库读取, 而不是本地缓存;
- 在服务器启动的时候, 会从数据库拉取配置信息, 保存到服务器文件系统中;
默认情况下, 手动修改数据库中的配置集内容, 只会影响到控制台, 不会影响到Nacos本地缓存, 也就不会改变客户端拉取配置的结果;
只有Nacos Server 重启的时候, 才会将数据库中的内容装载到服务器本地;
控制台
控制台显示的配置信息是直接从数据库中读取的;
这里强烈建议大家自己动手验证, 手动修改数据库中保存的配置, 然后去看控制台的显示和客户端拉到的配置是否一致;
这里是一个大坑, 有时候明明控制台看到的是 variable = a, 但项目中读到的可能是 variable = b;
订阅配置
客户端发起订阅
- 和拉取配置不同, 你得先准备好了 Web 容器, 才能去订阅配置, 所以应用启动时自动订阅配置, 是由 Nacos-Config 的普通自动配置类向 Spring 容器中注册了一个
ApplicationListener
, 叫NacosContextRefresher
; - 由这个监听器, 监听
ApplicationReady
事件 ( 这个事件是在 callRunners 之后发布的 ); - 监听到之后, 就会遍历
NacosPropertySourceRepository
, 获取每个 Nacos 属性源的组名和DataId, 然后注册与之对应的监听器:
String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey);
Listener listener = listenerMap.computeIfAbsent(key,
// 回调函数
lst -> new AbstractSharedListener() {
@Override
public void innerReceive(String dataId, String group,
String configInfo) {
refreshCountIncrement();
nacosRefreshHistory.addRefreshRecord(dataId, group, configInfo);
applicationContext.publishEvent(
new RefreshEvent(this, null, "Refresh Nacos config"));
if (log.isDebugEnabled()) {
log.debug(String.format(
"Refresh Nacos config group=%s,dataId=%s,configInfo=%s",
group, dataId, configInfo));
}
}
});
try {
// 向服务器注册监听器;
configService.addListener(dataKey, groupKey, listener);
log.info("[Nacos Config] Listening config: dataId={}, group={}", dataKey,
groupKey);
}
服务器处理监听请求
- 抽取出客户端的 ConnectionId, 和要监听的文件的标识
groupKey
, 虽然叫groupKey
, 但实际是DataId-Group-namespace
构成的;
- 在
ConfigChangeListenContext
中维护订阅关系;
public class ConfigChangeListenContext {
/**
* <GroupKey, Set<监听了这个GroupKey的ConnnectId>>
*/
private ConcurrentHashMap<String, HashSet<String>> groupKeyContext = new ConcurrentHashMap<String, HashSet<String>>();
/**
* <客户端ID, Map<这个客户端监听的GroupKey, 配置的MD5>值
*/
private ConcurrentHashMap<String, HashMap<String, String>> connectionIdContext = new ConcurrentHashMap<String, HashMap<String, String>>();
}
服务器推送更新
- 这个事件被
RpcConfigChangeNotifier
监听器处理, 从事件中提取出对应的配置集的 DataId, group, groupKey 等等信息; - 从维护监听关系的对象
ConfigChangeListenContext
中, 根据GroupKey
取出监听了这个配置集的所有客户端的ConnectionId; - 遍历所有 ConnectionId, 将新的配置, 封装成一个配置变化的通知请求, 通过 RPC 工具将请求 Push 给客户端;
客户端热更新
使用 Java API 监听某个来自 Nacos 的配置文件, 配置文件重新发布后, Environment 中的 PropertySource 确实更新了, 但是使用 @Value 注解注入了某个属性值的Bean, 他的属性值没有改变; 因为 @Value 通过反射将属性值注入后, 这个属性值就保存在 Bean 中了, 修改 Environment 不会影响到它;
Nacos 采取的策略是将配置了热更新 ( @RefreshScope
) 的 Bean 销毁, 重新创建;
客户端收到配置变化的通知请求后, 发布一个刷新事件
String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey);
Listener listener = listenerMap.computeIfAbsent(key,
// 回调函数
lst -> new AbstractSharedListener() {
@Override
public void innerReceive(String dataId, String group,
String configInfo) {
refreshCountIncrement();
nacosRefreshHistory.addRefreshRecord(dataId, group, configInfo);
applicationContext.publishEvent(
new RefreshEvent(this, null, "Refresh Nacos config"));
if (log.isDebugEnabled()) {
log.debug(String.format(
"Refresh Nacos config group=%s,dataId=%s,configInfo=%s",
group, dataId, configInfo));
}
}
});
try {
// 向服务器注册监听器;
configService.addListener(dataKey, groupKey, listener);
log.info("[Nacos Config] Listening config: dataId={}, group={}", dataKey,
groupKey);
}
这个刷新事件最终来到ContextRefresher
进行处理, 做了两个重要操作
一是更新 Environment 里面对应的 PropertySource, 更新完以后会发布一个EnvironmentChangeEvent
, 是一个扩展点; 例如有一个监听器监听到这个事件后, 会更新 @ConfigurationProperties
注解修饰的参数类;
二是把有@RefreshScope
注解修饰的 Bean 销毁重建; 完成后发布一个RefreshScopeRefreshedEvent
, Gateway 就是通过对这个事件的监听, 完成路由配置的热更新;
重建bean
@RefreshScope
注解的本质是@Scope("refresh")
; 一个 Bean 的 Scope 可以是 singleton, 可以是 prototype, 可以是 Session (每个 Session 创建一个新的 Bean); 在创建 bean的时候, 会根据 BeanDefinition 中的 Scope 进行不同的创建逻辑;- 总得来说: 所有 Scope 为 refresh 的 Bean, 创建时都会被放到同一个缓存中;
- 更新 Environment 后, 发布一个环境更新事件, 由
ConfigurationPropertiesRebinder
处理这个事件; - 销毁 refreshScope 缓存中的 bean, 然后重新初始化这些被销毁的 bean;
安全问题
- 销毁时加锁, 防止多个线程同时销毁;
- 销毁之前, 创建一个代理, 在新的 bean 没有准备好之前, 用这个代理;