博主在调研 Spring Cloud Config 跟 Apollo 配置中心的时候,对其中的热更新机制比较有兴趣,并阅读了相关的源码,在这里进行记录,方便以后查看。
调研时使用的 Spring Boot 跟 Spring Cloud 的版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>1.5.21.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Edgware.SR6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
知识背景
Spring Cloud Config 提供了一套配置中心,结合 Spring Cloud Bus 可实现一套配置的自动热更新。其实通过阅读源码,无论是 Spring Cloud Config 的手动刷新,还是通过 Spring Cloud Bus 通知进行自动刷新配置,最终底层都是使用了Spring Cloud 提供的热更新机制
,换句话说,其实无需使用配置中心,通过手动修改配置文件,触发refresh 端点
也可以触发热更新。
Spring Cloud 热更新机制,支持热更新的配置有三类,分别是:@ConfigurationProperties 注入的配置类
,日志相关
,@RefreshScope 注解的类
。而对于这三类配置,Spring Cloud 提供了两种更新策略,对于前两类,采用类的重新绑定策略
进行更新,而最后的 @RefreshScope,采用了延迟加载跟缓存刷新的策略
。下面将讲解具体的实现方式。
源码阅读
热更新机制的入口为调用ContextRefresher
类的refresh
方法,代码如下:
public synchronized Set<String> refresh() {
// 1. 获取当前配置源信息
Map<String, Object> before = extract(
this.context.getEnvironment().getPropertySources());
// 2. 加载配置
addConfigFilesToEnvironment();
// 3. 获取修改了配置值的kd
Set<String> keys = changes(before,
extract(this.context.getEnvironment().getPropertySources())).keySet();
// 4. 发布变更事件
this.context.publishEvent(new EnvironmentChangeEvent(context, keys));
// 5. 刷新@refreshScope类
this.scope.refreshAll();
return keys;
}
下面对上面的代码进行解读:
1. 获取当前属性源信息
这里获取环境的所有属性源的信息,但从中去除标准属性源:
private Map<String, Object> extract(MutablePropertySources propertySources) {
Map<String, Object> result = new HashMap<String, Object>();
List<PropertySource<?>> sources = new ArrayList<PropertySource<?>>();
for (PropertySource<?> source : propertySources) {
sources.add(0, source);
}
// 除去标准属性源
for (PropertySource<?> source : sources) {
if (!this.standardSources.contains(source.getName())) {
extract(source, result);
}
}
return result;
}
标准属性源,不可修改的属性源。这里的标准属性源指的是 StandardEnvironment 和 StandardServletEnvironment,前者会注册系统变量(System Properties)和环境变量(System Environment),后者会注册 Servlet 环境下的 Servlet Context 和 Servlet Config 的初始参数(Init Params)和 JNDI 的属性。
private Set<String> standardSources = new HashSet<>(
Arrays.asList(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME,
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
StandardServletEnvironment.JNDI_PROPERTY_SOURCE_NAME,
StandardServletEnvironment.SERVLET_CONFIG_PROPERTY_SOURCE_NAME,
StandardServletEnvironment.SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));
2. 加载配置
这里的做法是:通过重新创建一个非 WEB 的 Spring Boot 环境
,用于重新加载一遍属性源。即这一步执行后,环境中的配置源,均为更新后的配置信息。
/* for testing */ ConfigurableApplicationContext addConfigFilesToEnvironment() {
ConfigurableApplicationContext capture = null;
try {
StandardEnvironment environment = copyEnvironment(
this.context.getEnvironment());
SpringApplicationBuilder builder = new SpringApplicationBuilder(Empty.class)
.bannerMode(Mode.OFF).web(false).environment(environment);
// Just the listeners that affect the environment (e.g. excluding logging
// listener because it has side effects)
builder.application()
.setListeners(Arrays.asList(new BootstrapApplicationListener(),
new ConfigFileApplicationListener()));
capture = builder.run();
if (environment.getPropertySources().contains(REFRESH_ARGS_PROPERTY_SOURCE)) {
environment.getPropertySources().remove(REFRESH_ARGS_PROPERTY_SOURCE);
}
MutablePropertySources target = this.context.getEnvironment()
.getPropertySources();
String targetName = null;
for (PropertySource<?> source : environment.getPropertySources()) {
String name = source.getName();
if (target.contains(name)) {
targetName = name;
}
if (!this.standardSources.contains(name)) {
if (target.contains(name)) {
target.replace(name, source);
}
else {
if (targetName != null) {
target.addAfter(targetName, source);
}
else {
// targetName was null so we are at the start of the list
target.addFirst(source);
targetName = name;
}
}
}
}
}
finally {
ConfigurableApplicationContext closeable = capture;
while (closeable != null) {
try {
closeable.close();
}
catch (Exception e) {
// Ignore;
}
if (closeable.getParent() instanceof ConfigurableApplicationContext) {
closeable = (ConfigurableApplicationContext) closeable.getParent();
}
else {
break;
}
}
}
return capture;
}
3. 比较更新前后的属性源配置
进行比较,用一个 Map 记录比较更新前后不同的属性源。
private Map<String, Object> changes(Map<String, Object> before,
Map<String, Object> after) {
Map<String, Object> result = new HashMap<String, Object>();
for (String key : before.keySet()) {
// 放入更新前有,但更新后没有的 KEY
if (!after.containsKey(key)) {
result.put(key, null);
}
// 放入更新前后值不同的 KEY,跟更新后的值
else if (!equal(before.get(key), after.get(key))) {
result.put(key, after.get(key));
}
}
// 放入更新后有,但是更新前没有的 KEY,跟更新后的值
for (String key : after.keySet()) {
if (!before.containsKey(key)) {
result.put(key, after.get(key));
}
}
return result;
}
这里得到了配置更新前后属性源有变化的 KEY,如果当前是用 BUS 进行下发的通知,这些 KEY 最终会反馈到服务端。
到这步结束时,我们的应用中已经是新的配置源,但是对于 Spring Bean 中,他们仍然是旧的配置源,因此接下来,需要使 Spring Bean 中的属性为新的配置源。
4. 发布变更事件
这里使用的消息通知机制,发布了 EnvironmentChangeEvent 环境变更事件:
this.context.publishEvent(new EnvironmentChangeEvent(context, keys));
监听该事件的监听器多个,但需要关注的只有两个:LoggingBebinder
跟ConfigurationPropertiesRebinder
。
4.1 ConfigurationPropertiesRebinder
该类监听 EnvironmentChangeEvent 事件,实现对 @ConfigurationProperties
注解的类进行重绑。
@Override
public void onApplicationEvent(EnvironmentChangeEvent event) {
// ConfigurationPropertiesRebinder 类中监听了 EnvironmentChangeEvent
if (this.applicationContext.equals(event.getSource())
// Backwards compatible
|| event.getKeys().equals(event.getSource())) {
// 触发解绑操作
rebind();
}
}
// 收集使用 @ConfigurationProperties 修饰的类以及父类
private ConfigurationPropertiesBeans beans;
@ManagedOperation
public void rebind() {
this.errors.clear();
for (String name : this.beans.getBeanNames()) {
// 这里的 this.beans,为 ConfigurationPropertiesBeans
// 对所有 @ConfigurationProperties 进行解绑,利用新的环境源绑定新的类
rebind(name);
}
}
@ManagedOperation
public boolean rebind(String name) {
if (!this.beans.getBeanNames().contains(name)) {
return false;
}
if (this.applicationContext != null) {
try {
Object bean = this.applicationContext.getBean(name);
if (AopUtils.isAopProxy(bean)) {
bean = ProxyUtils.getTargetObject(bean);
}
if (bean != null) {
// 销毁原有的 Bean
this.applicationContext.getAutowireCapableBeanFactory().destroyBean(bean);
// 利用新的环境源初始化新的 Bean
this.applicationContext.getAutowireCapableBeanFactory()
.initializeBean(bean, name);
return true;
}
}
catch (RuntimeException e) {
this.errors.put(name, e);
throw e;
}
catch (Exception e) {
this.errors.put(name, e);
throw new IllegalStateException("Cannot rebind to " + name, e);
}
}
return false;
}
4.2 LoggingBebinder
该类监听 EnvironmentChangeEvent 事件,实现了对日志级别的修改。
@Override
public void onApplicationEvent(EnvironmentChangeEvent event) {
if (this.environment == null) {
return;
}
// 调用了 LoggingSystem 的方法重新设置了日志级别
LoggingSystem system = LoggingSystem.get(LoggingSystem.class.getClassLoader());
setLogLevels(system, this.environment);
}
protected void setLogLevels(LoggingSystem system, Environment environment) {
Map<String, String> levels = Binder.get(environment)
.bind("logging.level", STRING_STRING_MAP).orElseGet(Collections::emptyMap);
for (Entry<String, String> entry : levels.entrySet()) {
setLogLevel(system, environment, entry.getKey(), entry.getValue().toString());
}
}
5. 刷新 @RefreshScope
对于@RefreshScope
注解的类,当每次被调用的时候,都会进行初始化,同时采用懒代理的方法,将作用域充当初始值的缓存,当缓存存在时,不会再进行初始化。因此,对于刷新@RefreshScope
注解的类,只需要将其缓存进行清空,则在下一次访问的时候,依赖新的配置源,将生成新的缓存。
public void refreshAll() {
// 清除缓存信息
super.destroy();
// 发布更新事件
this.context.publishEvent(new RefreshScopeRefreshedEvent());
}
public void destroy() {
List<Throwable> errors = new ArrayList<Throwable>();
// 此步清除了缓存信息,通过继续往下翻源码可以发现
// 缓存信息最终存放在 ConcurrentMap 中,通过调用期 clear 方法进行清除
Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
// 获取相关的销毁时的回调函数并执行
for (BeanLifecycleWrapper wrapper : wrappers) {
try {
Lock lock = locks.get(wrapper.getName()).writeLock();
lock.lock();
try {
wrapper.destroy();
}
finally {
lock.unlock();
}
}
catch (RuntimeException e) {
errors.add(e);
}
}
if (!errors.isEmpty()) {
throw wrapIfNecessary(errors.get(0));
}
this.errors.clear();
}
热更新无法保证配置的一致性
热更新需要注意一个问题,配置的热更新无法保证配置的一致性。
- 对于 @ConfigurationProperties 注入的 Bean,刷新配置时,需要每个 Bean 进行重绑
- 对于 @RefreshScope 注解的Bean,底层通过 ConcurrentHashMap 的 clear 方法清空缓存,但该方法为弱一致性。
两类更新策略都非原子性操作,无法保证配置的一致性。
例如:
会出现配置类未完全更新,即对外提供服务的情况,关于这个问题,我咨询了代码的原作者,相关 issue:
https://github.com/spring-cloud/spring-cloud-config/issues/1562
博主个人理解作者的意思是:可以通过自行监听 EnvironmentChangeEvent 事件,来限制请求的进入,已实现配置更新的原子性。例如 Eureka 接收到 EnvironmentChangeEvent 事件会先将服务标记为下线,再重新上线。但这样子就会在 Eureka 上短时间的上下线,具体的方案我也没有想到,如果这个问题没有比较完美的方案,则热更新不适合在生产环境进行使用。
关于不一致这点,如果有不同的看法或好的解决方案,欢迎交流 o( ̄▽ ̄)ブ
总结
- 通过使用 ContextRefresher 可以进行手动的热更新,而并非一定需要配置中心。
- 程序通过创建一个新的非 web 的 SpringBoot 来加载新的配置源。
- 热更新提供了两种机制,类的重绑跟刷新缓存,分别适用于 @ConfigurationProperties 的对象,跟 @RefreshScope 对象。
- 热更新无法保证配置的一致性。
- 通过自行监听 EnvironmentChangeEvent 事件可以实现自己的热更新逻辑。
之前在网上看到的另外一篇讲热更新的文章,觉得讲的不错,推荐:https://www.cnblogs.com/niechen/p/8979578.html