博主在調研 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