【轉】Spring Cloud 是如何實現熱更新的

轉自:http://www.scienjus.com/spring-cloud-refresh/

作為一篇源碼分析的文章,本文雖然介紹 Spring Cloud 的熱更新機制,但是實際全文內容都不會與 Spring Cloud Config 以及 Spring Cloud Bus 有關,因為前者只是提供了一個遠端的配置源,而后者也只是提供了集群環境下的事件觸發機制,與核心流程均無太大關系。

ContextRefresher

顧名思義,ContextRefresher 用于刷新 Spring 上下文,在以下場景會調用其 refresh 方法。

  1. 請求 /refresh Endpoint。
  2. 集成 Spring Cloud Bus 后,收到 RefreshRemoteApplicationEvent 事件(任意集成 Bus 的應用,請求 /bus/refresh Endpoint 后都會將事件推送到整個集群)。

這個方法包含了整個刷新邏輯,也是本文分析的重點。

首先看一下這個方法的實現:

public synchronized Set<String> refresh() {
  Map<String, Object> before = extract(
      this.context.getEnvironment().getPropertySources());
  addConfigFilesToEnvironment();
  Set<String> keys = changes(before,
      extract(this.context.getEnvironment().getPropertySources())).keySet();
  this.context.publishEvent(new EnvironmentChangeEvent(keys));
  this.scope.refreshAll();
  return keys;
}

首先是第一步 extract,這個方法接收了當前環境中的所有屬性源(PropertySource),并將其中的非標準屬性源的所有屬性匯總到一個 Map 中返回。

這里的標準屬性源指的是 StandardEnvironment 和 StandardServletEnvironment,前者會注冊系統變量(System Properties)和環境變量(System Environment),后者會注冊 Servlet 環境下的 Servlet Context 和 Servlet Config 的初始參數(Init Params)和 JNDI 的屬性。個人理解是因為這些屬性無法改變,所以不進行刷新。

第二步 addConfigFilesToEnvironment 是核心邏輯,它創建了一個新的 Spring Boot 應用并初始化:

SpringApplicationBuilder builder = new SpringApplicationBuilder(Empty.class)
    .bannerMode(Banner.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();

這個應用只是為了重新加載一遍屬性源,所以只配置了 BootstrapApplicationListener 和 ConfigFileApplicationListener,最后將新加載的屬性源替換掉原屬性源,至此屬性源本身已經完成更新了。
此時屬性源雖然已經更新了,但是配置項都已經注入到了對應的 Spring Bean 中,需要重新進行綁定,所以又觸發了兩個操作:

  1. 將刷新后發生更改的 Key 收集起來,發送一個 EnvironmentChangeEvent 事件。
  2. 調用 RefreshScope.refreshAll 方法。

EnvironmentChangeEvent

在上文中,ContextRefresher 發布了一個 EnvironmentChangeEvent 事件,接下來看看這個事件產生了哪些影響。

The application will listen for an EnvironmentChangeEvent and react to the change in a couple of standard ways (additional ApplicationListeners can be added as @Beans by the user in the normal way). When an EnvironmentChangeEvent is observed it will have a list of key values that have changed, and the application will use those to:

  1. Re-bind any @ConfigurationProperties beans in the context
  2. Set the logger levels for any properties in logging.level.*

官方文檔的介紹中提到,這個事件主要會觸發兩個行為:

  1. 重新綁定上下文中所有使用了 @ConfigurationProperties 注解的 Spring Bean。
  2. 如果 logging.level.* 配置發生了改變,重新設置日志級別。

這兩段邏輯分別可以在 ConfigurationPropertiesRebinder 和 LoggingRebinder 中看到。

ConfigurationPropertiesRebinder

這個類乍一看代碼量特別少,只需要一個 ConfigurationPropertiesBeans 和一個ConfigurationPropertiesBindingPostProcessor,然后調用 rebind 每個 Bean 即可。但是這兩個對象是從哪里來的呢?

public void rebind() {
  for (String name : this.beans.getBeanNames()) {
    rebind(name);
  }
}

ConfigurationPropertiesBeans 需要一個 ConfigurationBeanFactoryMetaData, 這個類邏輯很簡單,它是一個 BeanFactoryPostProcessor 的實現,將所有的 Bean 都存在了內部的一個 Map 中。

而 ConfigurationPropertiesBeans 獲得這個 Map 后,會查找每一個 Bean 是否有 @ConfigurationProperties 注解,如果有的話就放到自己的 Map 中。

繞了一圈好不容易拿到所有需要重新綁定的 Bean 后,綁定的邏輯就要簡單許多了:

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.isCglibProxy(bean)) {
        bean = getTargetObject(bean);
      }
      this.binder.postProcessBeforeInitialization(bean, name);
      this.applicationContext.getAutowireCapableBeanFactory()
          .initializeBean(bean, name);
      return true;
    }
    catch (RuntimeException e) {
      this.errors.put(name, e);
      throw e;
    }
  }
  return false;
}

其中 postProcessBeforeInitialization 方法將 Bean 重新綁定了所有屬性,并做了校驗等操作。

而 initializeBean 的實現如下:

protected Object initializeBean(final String beanName, final Object bean, RootBeanDefinition mbd) {
  Object wrappedBean = bean;
  if(mbd == null || !mbd.isSynthetic()) {
    wrappedBean = this.applyBeanPostProcessorsBeforeInitialization(bean, beanName);
  }
  try {
    this.invokeInitMethods(beanName, wrappedBean, mbd);
  } catch (Throwable var6) {
    throw new BeanCreationException(mbd != null?mbd.getResourceDescription():null, beanName, "Invocation of init method failed", var6);
  }
  if(mbd == null || !mbd.isSynthetic()) {
    wrappedBean = this.applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
  }
  return wrappedBean;
}

其中主要做了三件事:

  1. applyBeanPostProcessorsBeforeInitialization:調用所有 BeanPostProcessor 的 postProcessBeforeInitialization 方法。
  2. invokeInitMethods:如果 Bean 繼承了 InitializingBean,執行 afterPropertiesSet 方法,或是如果 Bean 指定了 init-method 屬性,如果有則調用對應方法
  3. applyBeanPostProcessorsAfterInitialization:調用所有 BeanPostProcessor 的 postProcessAfterInitialization 方法。

之后 ConfigurationPropertiesRebinder 就完成整個重新綁定流程了。

LoggingRebinder

相比之下 LoggingRebinder 的邏輯要簡單許多,它只是調用了 LoggingSystem 的方法重新設置了日志級別,具體邏輯就不在本文詳述了。

RefreshScope

首先看看這個類的注釋:

Note that all beans in this scope are only initialized when first accessed, so the scope forces lazy initialization semantics. The implementation involves creating a proxy for every bean in the scope, so there is a flag
If a bean is refreshed then the next time the bean is accessed (i.e. a method is executed) a new instance is created. All lifecycle methods are applied to the bean instances, so any destruction callbacks that were registered in the bean factory are called when it is refreshed, and then the initialization callbacks are invoked as normal when the new instance is created. A new bean instance is created from the original bean definition, so any externalized content (property placeholders or expressions in string literals) is re-evaluated when it is created.

這里提到了兩個重點:

  1. 所有 @RefreshScope 的 Bean 都是延遲加載的,只有在第一次訪問時才會初始化
  2. 刷新 Bean 也是同理,下次訪問時會創建一個新的對象

再看一下方法實現:

public void refreshAll() {
  super.destroy();
  this.context.publishEvent(new RefreshScopeRefreshedEvent());
}

這個類中有一個成員變量 cache,用于緩存所有已經生成的 Bean,在調用 get 方法時嘗試從緩存加載,如果沒有的話就生成一個新對象放入緩存,并通過 getBean 初始化其對應的 Bean:

public Object get(String name, ObjectFactory<?> objectFactory) {
  if (this.lifecycle == null) {
    this.lifecycle = new StandardBeanLifecycleDecorator(this.proxyTargetClass);
  }
  BeanLifecycleWrapper value = this.cache.put(name,
      new BeanLifecycleWrapper(name, objectFactory, this.lifecycle));
  try {
    return value.getBean();
  }
  catch (RuntimeException e) {
    this.errors.put(name, e);
    throw e;
  }
}

所以在銷毀時只需要將整個緩存清空,下次獲取對象時自然就可以重新生成新的對象,也就自然綁定了新的屬性:

public void destroy() {
  List<Throwable> errors = new ArrayList<Throwable>();
  Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
  for (BeanLifecycleWrapper wrapper : wrappers) {
    try {
      wrapper.destroy();
    }
    catch (RuntimeException e) {
      errors.add(e);
    }
  }
  if (!errors.isEmpty()) {
    throw wrapIfNecessary(errors.get(0));
  }
  this.errors.clear();
}

清空緩存后,下次訪問對象時就會重新創建新的對象并放入緩存了。

而在清空緩存后,它還會發出一個 RefreshScopeRefreshedEvent 事件,在某些 Spring Cloud 的組件中會監聽這個事件并作出一些反饋。

Zuul

Zuul 在收到這個事件后,會將自身的路由設置為 dirty 狀態:

private static class ZuulRefreshListener implements ApplicationListener<ApplicationEvent> {
  @Autowired
  private ZuulHandlerMapping zuulHandlerMapping;
  
  @Override
  public void onApplicationEvent(ApplicationEvent event) {
    if (event instanceof ContextRefreshedEvent
        || event instanceof RefreshScopeRefreshedEvent
        || event instanceof RoutesRefreshedEvent) {
      this.zuulHandlerMapping.setDirty(true);
    }
  }
}

并且當路由實現為 RefreshableRouteLocator 時,會嘗試刷新路由:

public void setDirty(boolean dirty) {
  this.dirty = dirty;
  if (this.routeLocator instanceof RefreshableRouteLocator) {
    ((RefreshableRouteLocator) this.routeLocator).refresh();
  }
}

當狀態為 dirty 時,Zuul 會在下一次接受請求時重新注冊路由,以更新配置:

if (this.dirty) {
  synchronized (this) {
    if (this.dirty) {
      registerHandlers();
      this.dirty = false;
    }
  }
}

Eureka

在 Eureka 收到該事件時,對于客戶端和服務端都有不同的處理方式:

protected static class EurekaClientConfigurationRefresher {
  @Autowired(required = false)
  private EurekaClient eurekaClient;
  @Autowired(required = false)
  private EurekaAutoServiceRegistration autoRegistration;
  @EventListener(RefreshScopeRefreshedEvent.class)
  public void onApplicationEvent(RefreshScopeRefreshedEvent event) {
    //This will force the creation of the EurkaClient bean if not already created
    //to make sure the client will be reregistered after a refresh event
    if(eurekaClient != null) {
      eurekaClient.getApplications();
    }
    if (autoRegistration != null) {
      // register in case meta data changed
      this.autoRegistration.stop();
      this.autoRegistration.start();
    }
  }
}

對于客戶端來說,只是調用了下 eurekaClient.getApplications,理論上這個方法是沒有任何效果的,但是查看上面的注釋,以及聯想到 RefreshScope 的延時初始化特性,這個方法調用應該只是為了強制初始化新的 EurekaClient。

事實上這里很有趣的是,在 EurekaClientAutoConfiguration 中,實際為了 EurekaClient 提供了兩種初始化方案,分別對應是否有 RefreshScope,所以以上的猜測應該是正確的。

而對于服務端來說,EurekaAutoServiceRegistration 會將服務端先標記為下線,在進行重新上線。

總結

至此,Spring Cloud 的熱更新流程就到此結束了,從這些源碼中可以總結出以下結論:

  1. 通過使用 ContextRefresher 可以進行手動的熱更新,而不需要依靠 Bus 或是 Endpoint。
  2. 熱更新會對兩類 Bean 進行配置刷新,一類是使用了 @ConfigurationProperties 的對象,另一類是使用了 @RefreshScope 的對象。
  3. 這兩種對象熱更新的機制不同,前者在同一個對象中重新綁定了所有屬性,后者則是利用了 RefreshScope 的緩存和延遲加載機制,生成了新的對象。
  4. 通過自行監聽 EnvironmentChangeEvent 事件,也可以獲得更改的配置項,以便實現自己的熱更新邏輯。
  5. 在使用 Eureka 的項目中要謹慎的使用熱更新,過于頻繁的更新可能會使大量項目頻繁的標記下線和上線,需要注意。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,431評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,637評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,555評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,900評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,629評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,976評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,976評論 3 448
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,139評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,686評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,411評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,641評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,129評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,820評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,233評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,567評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,362評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,604評論 2 380

推薦閱讀更多精彩內容