原文:https://blog.csdn.net/Zong_0915/article/details/113089265
作者:Zong_0915
一. Nacos Config實現原理解析
首先,Nacos Config針對配置的管理提供了4種操作):
獲取配置,從Nacos Config Server中讀取配置。
監聽配置:訂閱感興趣的配置,當配置發生變化的時候可以收到一個事件。
發佈配置:將配置保存到Nacos Config Server中。
刪除配置:刪除配置中心的指定配置。
而從原理層面來看,可以歸類爲兩種類型:配置的CRUD和配置的動態監聽。
1.1 配置的CRUD操作
對於Nacos Config來說,主要是提供了配置的集中式管理功能,然後對外提供CRUD的訪問接口使得應用系統可以完成配置的基本操作。
對於服務端來說:需要考慮的是配置如何存儲,是否需要持久化。
對於客戶端來說:需要考慮的是通過接口從服務器查詢得到相應的數據然後返回。
關係如下:
注意:
Nacos服務端的數據存儲默認採用的是Derby數據庫(也支持Mysql)。
1.2 配置的動態監聽
Nacos的客戶端和服務端之間存在着數據交互的一種行爲(不然怎麼做到實時的更新和數據的查詢呢),而對於這種交互行爲共有兩種方式:
Pull模式:表示客戶端從服務端主動拉取數據。
Pull模式下,客戶端需要定時從服務端拉取一次數據,由於定時帶來的時間間隔,因此不能保證數據的實時性,並且在服務端配置長時間不更新的情況下,客戶端的定時任務會做一些無效的Pull操作。
Push模式:服務端主動把數據推送到客戶端。
Push模式下,服務端需要維持與客戶端的長連接,如果客戶端的數量比較多,那麼服務端需要耗費大量的內存資源來保存每個資源,並且爲了檢測連接的有效性,還需要心跳機制來維持每個連接的狀態。
Nacos採用的是Pull模式(Kafka也是如此),並且採用了一種長輪詢機制。客戶端採用長輪詢的方式定時的發起Pull請求,去檢查服務端配置信息是否發生了變更,如果發生了變更,那麼客戶端會根據變更的數據獲得最新的配置。
長輪詢:客戶端發起輪詢請求後,服務端如果有配置發生變更,就直接返回。
如下圖:
詳細地來說:
如果客戶端發起Pull請求後,發現服務端的配置和客戶端的配置是保持一致的,那麼服務端會“Hold”住這個請求。(服務端拿到這個連接後在指定的時間段內不會返回結果,直到這段時間內的配置發生變化)
一旦配置發生了變化,服務端會把原來“Hold”住的請求進行返回。
工作流程圖如下:
對於流程圖解釋如下:
Nacos服務端收到請求後,會檢查配置是否發生了變更,如果沒有,那麼設置一個定時任務,延期29.5秒執行。同時並且把當前的客戶端長輪詢連接加入到allSubs隊列。 這時候有兩種方式觸發該連接結果的返回:
第一種:等待29.5秒(長連接保持的時間)後觸發自動檢查機制,這時候不管配置有無發生變化,都會把結果返回給客戶端。
第二種:在29.5秒內的任意一個時刻,通過Nacos控制檯或者API的方式對配置進行了修改,那麼觸發一個事件機制,監聽到該事件的任務會遍歷allSubs隊列,找到發生變更的配置項對應的ClientLongPolling任務,將變更的數據通過該任務中的連接進行返回,即完成了一次推送操作。
二. Nacos配置中心源碼分析
2.1 Config實現配置的加載
首先需要了解到,SpringCloud是基於Spring來擴展的,而Spring本身就提供了Environment,用來表示Spring應用程序的環境配置(包括外部環境),並且提供了統一訪問的方法getProperty(String key)來獲取配置。
對於SpringCloud而言,要實現統一配置管理並且動態的刷新配置,需要解決兩個問題:
如何將遠程服務器上的配置(Nacos Config Server)加載到Environment上。
配置變更時,如何將新的配置更新到Environment中。
對於配置的加載而言,需要牽扯到SpringBoot的自動裝配,進行環境的準備工作:
環境的準備
1.在SpringBoot啓動的時候,SpringApplication.run()方法會進行環境準備的工作,來看下該方法(只顯示和環境配置相關的代碼)
public ConfigurableApplicationContext run(String... args) { // .....代碼省略 SpringApplicationRunListeners listeners = this.getRunListeners(args); listeners.starting(); // .....代碼省略 try { ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); // 環境的準備工作 ConfigurableEnvironment environment = this.prepareEnvironment(listeners, applicationArguments); // 將環境的相關信息進行封裝 this.configureIgnoreBeanInfo(environment); // .....代碼省略 } catch (Throwable var10) { this.handleRunFailure(context, var10, exceptionReporters, listeners); throw new IllegalStateException(var10); } // .....代碼省略 }
2.重點來看this.prepareEnvironment(listeners, applicationArguments)
這個方法:
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments) { // .....代碼省略 // 該方法中,主要會發佈一個ApplicationEnvironmentPreparedEvent事件 // 而BootstrapApplicationListener監聽器會監聽這一類的事件,並作出響應的處理 listeners.environmentPrepared((ConfigurableEnvironment)environment); // .....代碼省略 return (ConfigurableEnvironment)environment; }
3.監聽事件後的處理,進行了自動裝配:
public class BootstrapApplicationListener implements ApplicationListener<ApplicationEnvironmentPreparedEvent>, Ordered { public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { // .....代碼省略 // onApplicationEvent()方法都是監聽器監聽到某個事件後,需要執行的方法,即後續處理的邏輯。 // 這裏調用了bootstrapServiceContext()方法 context = this.findBootstrapContext((ParentContextApplicationContextInitializer)initializer, configName); // .....代碼省略 } private ConfigurableApplicationContext bootstrapServiceContext(ConfigurableEnvironment environment, final SpringApplication application, String configName) { // .....代碼省略 // 進行自動裝配,BootstrapImportSelectorConfiguration作爲一個配置類 builder.sources(new Class[]{BootstrapImportSelectorConfiguration.class}); // .....代碼省略 } }
4.自動裝配的類BootstrapImportSelectorConfiguration
:
@Configuration( proxyBeanMethods = false ) @Import({BootstrapImportSelector.class}) public class BootstrapImportSelectorConfiguration { public BootstrapImportSelectorConfiguration() { } }
5.通過@Import導入了BootstrapImportSelector類,負責自動配置信息的加載。(我在Nacos註冊中心原理篇中講到過,spring.factories的作用,這裏就不在多說,只把涉及到的相關類列舉出來)
這裏有spring-cloud-alibaba-nacos-config包
下的NacosConfigBootstrapConfiguration
以及spring-cloud-context
包下
下的PropertySourceBootstrapConfiguration
:
那麼會對這兩個類進行自動裝載。
6.回到步驟中,到這裏,SpringApplication.run()中的環境準備已經完成了,那麼和我們的服務加載有啥關係嘞?環境的準備完成了,意味着相對應的類已經完成了初始化,而其中有一個類就叫做PropertySourceBootstrapConfiguration,他是一個啓動環境配置類,他的初始化就是通過上述自動裝配來完成的!
7.而PropertySourceBootstrapConfiguration類中就有一個initialize()方法,會調用PropertySourceLocators.locate()來獲取遠程配置信息,那麼接下來就開始講環境的加載
環境的加載
1.上文SpringApplication.run()方法中已經介紹完了環境的準備工作,接下里就要進行配置的加載了,繼續從該方法出發(tips:這裏和上文的區別,多了一行代碼this.prepareContext(),同樣爲了方便,把不必要的代碼省去了):
public ConfigurableApplicationContext run(String... args) { // .....代碼省略 SpringApplicationRunListeners listeners = this.getRunListeners(args); listeners.starting(); // .....代碼省略 try { ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); // 環境的準備工作 ConfigurableEnvironment environment = this.prepareEnvironment(listeners, applicationArguments); // 將環境的相關信息進行封裝 this.configureIgnoreBeanInfo(environment); // .....代碼省略 // 開始刷新應用上下文的準備階段。 this.prepareContext(context, environment, listeners, applicationArguments, printedBanner); // .....代碼省略 } catch (Throwable var10) { this.handleRunFailure(context, var10, exceptionReporters, listeners); throw new IllegalStateException(var10); } // .....代碼省略 }
2.prepareContext()方法主要是進行應用上下文的一個準備:
private void prepareContext(ConfigurableApplicationContext context, ConfigurableEnvironment environment, SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments, Banner printedBanner) { // .....代碼省略 this.applyInitializers(context); } // 該方法主要是執行容器中的ApplicationContextInitilaizer,作用是在應用程序上下文初始化的時候做一些額外的操作 // 挺像那個SpringBean初始化後還可以做一些額外操作的意思 protected void applyInitializers(ConfigurableApplicationContext context) { Iterator var2 = this.getInitializers().iterator(); while(var2.hasNext()) { ApplicationContextInitializer initializer = (ApplicationContextInitializer)var2.next(); Class<?> requiredType = GenericTypeResolver.resolveTypeArgument(initializer.getClass(), ApplicationContextInitializer.class); Assert.isInstanceOf(requiredType, context, "Unable to call initializer."); initializer.initialize(context); } }
3.第二步代碼中出現了一個接口:ApplicationContextInitializer
,那麼最終代碼的實現肯定是要跑其子類的代碼,
public interface ApplicationContextInitializer<C extends ConfigurableApplicationContext> { void initialize(C var1); }
4.回到重點:PropertySourceBootstrapConfiguration實現了ApplicationContextInitializer接口。因此上面的方法在執行時,會執行PropertySourceBootstrapConfiguration的initialize()方法
來看下其方法,關注類PropertySourceLocator:
public void initialize(ConfigurableApplicationContext applicationContext) { // .....代碼省略 while(var5.hasNext()) { PropertySourceLocator locator = (PropertySourceLocator)var5.next(); PropertySource<?> source = null; source = locator.locate(environment); if (source != null) { logger.info("Located property source: " + source); composite.addPropertySource(source); empty = false; } } // .....代碼省略 }
5.PropertySourceLocator接口的主要作用:實現應用外部化配置可動態加載,而NacosPropertySourceLocator實現了該接口。因此最終會調用NacosPropertySourceLocator中的locate()方法,實現把Nacos服務上的代碼進行加載。
6.NacosPropertySourceLocator.locate()方法最終得到配置中心上的配置並通過對象封裝來返回:
public PropertySource<?> locate(Environment env) { this.nacosConfigProperties.setEnvironment(env); ConfigService configService = this.nacosConfigManager.getConfigService(); if (null == configService) { log.warn("no instance of config service found, can't load config from nacos"); return null; } else { long timeout = (long)this.nacosConfigProperties.getTimeout(); this.nacosPropertySourceBuilder = new NacosPropertySourceBuilder(configService, timeout); // 讀取一些配置文件,這裏有我們屬性的應用名稱、DataId的設置 String name = this.nacosConfigProperties.getName(); String dataIdPrefix = this.nacosConfigProperties.getPrefix(); if (StringUtils.isEmpty(dataIdPrefix)) { dataIdPrefix = name; } if (StringUtils.isEmpty(dataIdPrefix)) { dataIdPrefix = env.getProperty("spring.application.name"); } // 用於存儲Nacos Config Server上配置信息的一個實例對象 CompositePropertySource composite = new CompositePropertySource("NACOS"); // loadxxx()方法開始進行配置的加載這是共享配置 this.loadSharedConfiguration(composite); // 通過spring.cloud.nacos.config.ext-config[0].data-id=myTest.properties這種方式配置的配置文件 // 加載擴展配置 this.loadExtConfiguration(composite); // 加載應用名稱對應的配置 this.loadApplicationConfiguration(composite, dataIdPrefix, this.nacosConfigProperties, env); // 返回配置信息實例對象 return composite; } }
到這裏,配置的加載流程也就完成了,對於具體加載的內容(也就是this.loadxxx()方法的具體實現)放在2.2節詳細介紹,接下來通過案例來再一次理解這個過程。
案例1:通過Debug來理解Config的配置加載
1.容器啓動後,執行SpringApplication.run()方法:
2.開始準備環境:
3.此時BootstrapApplicationListener
類監聽到事件(這個事件指的是環境準備事件),執行onApplicationEvent()
方法:
4.該方法最後會執行builder.sources(),引入選擇器Selector:
5.環境準備完畢,開始準備應用上下文的信息:
6.執行applyInitializers()方法,最終調用到PropertySourceBootstrapConfiguration的initialize()方法,這裏則調用了Nacos相關的配置類的初始化。
7.由於NacosPropertySourceLocator實現了PropertySourceLocator接口,因此調用其locate()方法,將從Nacos Config Server獲得的配置封裝成CompositePropertySource對象進行返回。
小總結1☆
Nacos Config的配置加載過程如下:
SpringBoot項目啓動,執行SpringApplication.run()方法,先對項目所需的環境做出準備
BootstrapApplicationListener監聽器監聽到環境準備事件,對需要做自動裝配的類進行載入。,導入BootstrapImportSelectorConfiguration配置類,該配置類引入BootstrapImportSelector選擇器,完成相關的初始化操作。
環境準備完成後(所需的相關配置類也初始化完成),執行方法this.prepareContext()完成上下文信息的準備。
this.prepareContext()需要對相關的類進行初始化操作。由於PropertySourceBootstrapConfiguration類實現了ApplicationContextInitializer接口。因此調用其initialize()方法,完成初始化操作。
對於PropertySourceBootstrapConfiguration下的初始化操作,需要實現應用外部化配置可動態加載,而NacosPropertySourceLocator 實現了PropertySourceLocator接口,故執行他的locate()方法。
最終NacosPropertySourceLocator的locate()方法完成從Nacos Config Server上加載配置信息。
寫到這裏,其實目前爲止主要講的是SpringCloud項目從啓動到執行方法獲取配置的這麼一個過程,而對於具體獲取遠程配置的代碼實現並沒有深入去講解。即上文的locate()方法,而該方法還涉及到配置更新時,Nacos如何去做到監聽的操作,因此準備將這一塊內容另起一節來講解。
2.2 Config配置加載核心代碼分析
1.這裏我們從NacosPropertySourceLocator類下的locate()方法開始分析:
public PropertySource<?> locate(Environment env) { this.nacosConfigProperties.setEnvironment(env); // 初始化ConfigService對象,這是Nacos客戶端提供的用於訪問實現配置中心基本操作的類 // 可以把它類比於RedisTemplate這種操作類 ConfigService configService = this.nacosConfigManager.getConfigService(); if (null == configService) { log.warn("no instance of config service found, can't load config from nacos"); return null; } else { // ....代碼省略 CompositePropertySource composite = new CompositePropertySource("NACOS"); // 按照順序分別加載共享配置、擴展配置。應用名稱對應的配置。 this.loadSharedConfiguration(composite); this.loadExtConfiguration(composite); this.loadApplicationConfiguration(composite, dataIdPrefix, this.nacosConfigProperties, env); return composite; } }
2.因爲一般來說我們都是根據應用名稱來獲取配置,所以這裏以loadApplicationConfiguration()
方法爲例來說。
private void loadApplicationConfiguration(CompositePropertySource compositePropertySource, String dataIdPrefix, NacosConfigProperties properties, Environment environment) { // ....代碼省略 this.loadNacosDataIfPresent(compositePropertySource, dataIdPrefix, nacosGroup, fileExtension, true); } private void loadNacosDataIfPresent(final CompositePropertySource composite, final String dataId, final String group, String fileExtension, boolean isRefreshable) { // ....代碼省略 NacosPropertySource propertySource = this.loadNacosPropertySource(dataId, group, fileExtension, isRefreshable); } private NacosPropertySource loadNacosPropertySource(final String dataId, final String group, String fileExtension, boolean isRefreshable) { return NacosContextRefresher.getRefreshCount() != 0L && !isRefreshable ? NacosPropertySourceRepository.getNacosPropertySource(dataId, group) : this.nacosPropertySourceBuilder.build(dataId, group, fileExtension, isRefreshable); } NacosPropertySource build(String dataId, String group, String fileExtension, boolean isRefreshable) { Map<String, Object> p = this.loadNacosData(dataId, group, fileExtension); // ....代碼省略 } // 關注這個方法 private Map<String, Object> loadNacosData(String dataId, String group, String fileExtension) { String data = null; try { // 最終的數據存放於Map當中,而data數據可以發現是調用ConfigService.getConfig()方法從配置中心上加載配置進行填充的。 data = this.configService.getConfig(dataId, group, this.timeout); // ....代碼省略 }
這裏總結下方法的執行流程(關注最後的方法即可!,這裏列出流程中所有涉及到的步驟是爲了怕大家不清楚方法的調用順序):
loadApplicationConfiguration()—>loadNacosDataIfPresent()—>NacosPropertySource.loadNacosPropertySource()
—>NacosPropertySource.build()—>loadNacosData()
到這一步我們只需瞭解到,加載的具體操作是交給ConfigService(當然,它是個接口,具體實現交給NacosConfigService來完成)來加載配置的。(後面會Debug來具體查看)
接下來主要開始說明NacosConfig的事件訂閱機制的實現,分爲多個角度結合上文的圖來進行說明:
如何監聽到事件的變更。
NacosConfigService的初始化。(配置加載方法的執行者)
ClientWorker。(因爲根據上文的說法,Nacos會有一個定時調度的任務存在,而其具體的實現是NacosConfigService)
ClientLongRunnable類有什麼用。
服務端的長連接實現
ClientLongPolling是什麼。
接下來的內容可能比較多,也希望大家能夠耐心的看下去,我最後還會進行一個大總結,將核心代碼進行一個梳理。
2.2.1 事件訂閱機制的實現
我在2.1節講到了SpringBoot在啓動的時候,會執行準備上下文的這麼一個操作。而Nacos有一個類叫做NacosContextRefresher,它實現了ApplicationListener,即他是一個監聽器,負責監聽準備上下文的事件,我們來看下他的結構:
// 這裏監聽的是ApplicationReadyEvent,也就是在上下文準備完畢的時候,會觸發這個事件,執行onApplicationEvent()方法。 public class NacosContextRefresher implements ApplicationListener<ApplicationReadyEvent>, ApplicationContextAware { public void onApplicationEvent(ApplicationReadyEvent event) { if (this.ready.compareAndSet(false, true)) { //這個方法主要用來實現Nacos事件監聽的註冊 this.registerNacosListenersForApplications(); } } }
緊接着來看下它的事件監聽註冊方法:
private void registerNacosListenersForApplications() { // 代碼省略 this.registerNacosListener(propertySource.getGroup(), dataId); } private void registerNacosListener(final String groupKey, final String dataKey) { String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey); Listener listener = (Listener)this.listenerMap.computeIfAbsent(key, (lst) -> { return new AbstractSharedListener() { public void innerReceive(String dataId, String group, String configInfo) { // 代碼省略 // 當收到配置變更的回調後,會通過publishEvent()發佈一個RefreshEvent事件 // 而該事件的監聽,其實現在RefreshEventListener這個監聽器中 NacosContextRefresher.this.applicationContext.publishEvent(new RefreshEvent(this, (Object)null, "Refresh Nacos config")); // 代碼省略 } }; }); // 代碼省略 }
監聽刷新事件的監聽器:
public class RefreshEventListener implements SmartApplicationListener { public void handle(RefreshEvent event) { if (this.ready.get()) { log.debug("Event received " + event.getEventDesc()); // 調用refresh.refresh()方法來完成配置的更新和應用 Set<String> keys = this.refresh.refresh(); log.info("Refresh keys changed: " + keys); } } }
2.2.2 NacosConfigService
先來看一下NacosConfigService
的構造:
public NacosConfigService(Properties properties) throws NacosException { String encodeTmp = properties.getProperty("encode"); if (StringUtils.isBlank(encodeTmp)) { this.encode = "UTF-8"; } else { this.encode = encodeTmp.trim(); } this.initNamespace(properties); // 這裏初始化了一個HttpAgent,用到了裝飾器模式 // 即實際工作的類是ServerHttpAgent,增加監控統計的信息 this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties)); this.agent.start(); // 而這個ClientWorker是客戶端的一個工作類,agent作爲參數傳入其中,即利用HttpAgent來做一些遠程相關的事情。 this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties); }
竟然都提到了裝飾器模式了,那我也囉嗦幾句吧🤣,講一講什麼是裝飾器模式:
按照單一職責原則,某一個對象只專注於幹一件事,而如果要擴展其職能的話,不如想辦法分離出一個類來“包裝”這個對象,而這個擴展出的類則專注於實現擴展功能。裝飾器模式就可以將新功能動態地附加於現有對象而不改變現有對象的功能。
而Java當中的IO流就普遍使用了裝飾器模式,例如BufferedInputStream類,那麼怎麼個包裝法呢,即通常將基本流作爲高級流構造器的參數傳入,將其作爲高級流的一個關聯對象,從而對其功能進行擴展和裝飾。
例如:
new BufferedInputStream(new InputStream());
2.2.3 ClientWorker
同樣的,我們來看下其構造函數,其主要作用是構建兩個定時調度的線程池,並且啓動一個定時任務。
public ClientWorker(final HttpAgent agent, ConfigFilterChainManager configFilterChainManager, Properties properties) { this.agent = agent; this.configFilterChainManager = configFilterChainManager; this.init(properties); // 第一個線程池executor:可以發現其核心線程數爲1。 // 每10秒會執行一次checkConfigInfo()方法,即檢查一次配置信息(參考this.executor.scheduleWithFixedDelay) this.executor = Executors.newScheduledThreadPool(1, new ThreadFactory() { public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("com.alibaba.nacos.client.Worker." + agent.getName()); t.setDaemon(true); return t; } }); // 第二個線程池executorService只完成了初始化,這裏並沒有啓動,他主要用於實現客戶端的定時長輪詢功能。 this.executorService = Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() { public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("com.alibaba.nacos.client.Worker.longPolling." + agent.getName()); t.setDaemon(true); return t; } }); this.executor.scheduleWithFixedDelay(new Runnable() { public void run() { try { ClientWorker.this.checkConfigInfo(); } catch (Throwable var2) { ClientWorker.LOGGER.error("[" + agent.getName() + "] [sub-check] rotate check error", var2); } } }, 1L, 10L, TimeUnit.MILLISECONDS); }
緊接着我們來看下啓動的定時任務中,執行的checkConfigInfo() 方法:
public void checkConfigInfo() { // 1.分配任務 // cacheMap:是用來存儲監聽變更的緩存集合,Key:根據dataId/group/tenant(租戶)拼接的值 // Value:對應的存儲在Nacos服務器上的配置文件的內容。 int listenerSize = ((Map)this.cacheMap.get()).size(); // 2.通過向上取整,取得的數值爲批數 // 默認情況下,每個長輪詢LongPollingRunnable任務處理3000個監聽配置集 // 如果超過3000個,則需要啓動多個LongPollingRunnable去執行 int longingTaskCount = (int)Math.ceil((double)listenerSize / ParamUtil.getPerTaskConfigSize()); if ((double)longingTaskCount > this.currentLongingTaskCount) { for(int i = (int)this.currentLongingTaskCount; i < longingTaskCount; ++i) { // 這裏則是搭建一個長輪詢機制,去監聽變更的數據。 this.executorService.execute(new ClientWorker.LongPollingRunnable(i)); } this.currentLongingTaskCount = (double)longingTaskCount; } }
上面方法中,總結就是啓動一個定時任務,然後通過線程池去建立長輪詢連接,檢查/更新方法的配置。而具體的任務都在LongPollingRunnable
類中了。
public void checkConfigInfo() { // 1.分配任務 // cacheMap:是用來存儲監聽變更的緩存集合,Key:根據dataId/group/tenant(租戶)拼接的值 // Value:對應的存儲在Nacos服務器上的配置文件的內容。 int listenerSize = ((Map)this.cacheMap.get()).size(); // 2.通過向上取整,取得的數值爲批數 // 默認情況下,每個長輪詢LongPollingRunnable任務處理3000個監聽配置集 // 如果超過3000個,則需要啓動多個LongPollingRunnable去執行 int longingTaskCount = (int)Math.ceil((double)listenerSize / ParamUtil.getPerTaskConfigSize()); if ((double)longingTaskCount > this.currentLongingTaskCount) { for(int i = (int)this.currentLongingTaskCount; i < longingTaskCount; ++i) { // 這裏則是搭建一個長輪詢機制,去監聽變更的數據。 this.executorService.execute(new ClientWorker.LongPollingRunnable(i)); } this.currentLongingTaskCount = (double)longingTaskCount; } }
上面方法中,總結就是啓動一個定時任務,然後通過線程池去建立長輪詢連接,檢查/更新方法的配置。而具體的任務都在LongPollingRunnable類中了。
2.2.4 LongPollingRunnable
LongPollingRunnable本質上是一個線程,因此直接看他的run()方法。
public void run() { List<CacheData> cacheDatas = new ArrayList(); ArrayList inInitializingCacheList = new ArrayList(); try { // 遍歷cacheDatas,檢查本地配置 Iterator var3 = ((Map)ClientWorker.this.cacheMap.get()).values().iterator(); while(var3.hasNext()) { CacheData cacheData = (CacheData)var3.next(); //屬於當前長輪詢任務的 if (cacheData.getTaskId() == this.taskId) { cacheDatas.add(cacheData); try { //1. 檢查本地配置 ClientWorker.this.checkLocalConfig(cacheData); if (cacheData.isUseLocalConfigInfo()) { cacheData.checkListenerMd5();//有改變的話則通知 } } catch (Exception var13) { ClientWorker.LOGGER.error("get local config info error", var13); } } } // 2.和服務端建立長輪詢機制,從服務端獲取發生變更的數據 // 即通過長輪詢請求檢查服務端對應配置是否發生了變更 List<String> changedGroupKeys = ClientWorker.this.checkUpdateDataIds(cacheDatas, inInitializingCacheList); Iterator var16 = changedGroupKeys.iterator(); // 3.遍歷變更數據集合changedGroupKeys,調用getServerConfig()方法, // 根據DataId、Group?Tenant(租戶)去服務端讀取對應的配置信息並保存到本地文件中。 while(var16.hasNext()) { String groupKey = (String)var16.next(); String[] key = GroupKey.parseKey(groupKey); String dataId = key[0]; String group = key[1]; String tenant = null; if (key.length == 3) { tenant = key[2]; } try { String content = ClientWorker.this.getServerConfig(dataId, group, tenant, 3000L); CacheData cache = (CacheData)((Map)ClientWorker.this.cacheMap.get()).get(GroupKey.getKeyTenant(dataId, group, tenant)); cache.setContent(content);//設置配置內容 ClientWorker.LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, content={}", new Object[]{ClientWorker.this.agent.getName(), dataId, group, tenant, cache.getMd5(), ContentUtils.truncateContent(content)}); } catch (NacosException var12) { String message = String.format("[%s] [get-update] get changed config exception. dataId=%s, group=%s, tenant=%s", ClientWorker.this.agent.getName(), dataId, group, tenant); ClientWorker.LOGGER.error(message, var12); } } var16 = cacheDatas.iterator(); // 觸發事件的通知 while(true) { CacheData cacheDatax; do { if (!var16.hasNext()) { inInitializingCacheList.clear(); //繼續定時的執行當前的線程 ClientWorker.this.executorService.execute(this); return; } cacheDatax = (CacheData)var16.next(); } while(cacheDatax.isInitializing() && !inInitializingCacheList.contains(GroupKey.getKeyTenant(cacheDatax.dataId, cacheDatax.group, cacheDatax.tenant))); cacheDatax.checkListenerMd5(); // 請求過了後就設置爲不在初始化中,這樣就會被掛起,如果服務器配置有更新,就會立即返回 // 這樣就可以實現動態配置更新,又不會太多的空輪詢消耗,解決Pull模式下的空輪詢消耗問題。 cacheDatax.setInitializing(false); } } catch (Throwable var14) { ClientWorker.LOGGER.error("longPolling error : ", var14); ClientWorker.this.executorService.schedule(this, (long)ClientWorker.this.taskPenaltyTime, TimeUnit.MILLISECONDS); } }
上述方法總結就是:
根據taskId對cacheMap進行數據的分割,再比較本地配置的數據是否存在變更。
如果存在變更,則直接觸發通知。
對於配置的比較,需要注意:在${user}\nacos\config\目錄下會緩存一份服務端的配置信息,而checkLocalConfig()方法會和本地磁盤中的文件內容進行比較。
接下來調用的checkUpdateDataIds()方法,則基於長連接的方式來監聽服務端配置的變化,最後根據變化數據的key來去服務端獲取最新的數據。(key是dataId/group/租戶)
那麼接下來再重點講一講checkUpdateDataIds()方法,該方法最終會調用checkUpdateConfigStr()方法:
List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws IOException { List<String> params = Arrays.asList("Listening-Configs", probeUpdateString); List<String> headers = new ArrayList(2); headers.add("Long-Pulling-Timeout"); headers.add("" + this.timeout); if (isInitializingCacheList) { headers.add("Long-Pulling-Timeout-No-Hangup"); headers.add("true"); } if (StringUtils.isBlank(probeUpdateString)) { return Collections.emptyList(); } else { try { // 通過調用agent.httpPost()方法,調用/v1/cs/configs/listener這個接口實現長輪詢的請求 // 長輪詢請求在實現的層面,設計了一個超時時間,也就是30秒 HttpResult result = this.agent.httpPost("/v1/cs/configs/listener", headers, params, this.agent.getEncode(), this.timeout); // 如果服務端的數據發生了變更,客戶端會收到一個HttpResult,而服務端返回的是存在數據變更的一個Key // 這個key也就是DataId、Group、Tenant(租戶),獲得這些信息後,LongPollingRunnable這個線程會調用getServerConfig()方法 // 也就是去Nacos服務器上讀取具體的配置內容 if (200 == result.code) { this.setHealthServer(true); return this.parseUpdateDataIdResponse(result.content); } this.setHealthServer(false); LOGGER.error("[{}] [check-update] get changed dataId error, code: {}", this.agent.getName(), result.code); } catch (IOException var6) { this.setHealthServer(false); LOGGER.error("[" + this.agent.getName() + "] [check-update] get changed dataId exception", var6); throw var6; } return Collections.emptyList(); } } // 去Nacos服務器上讀取配置內容,竟然是去Nacos上讀取的話,那必然獲得的配置也是最新的。 public String getServerConfig(String dataId, String group, String tenant, long readTimeout) throws NacosException { if (StringUtils.isBlank(group)) { group = "DEFAULT_GROUP"; } HttpResult result = null; try { List<String> params = null; if (StringUtils.isBlank(tenant)) { params = Arrays.asList("dataId", dataId, "group", group); } else { params = Arrays.asList("dataId", dataId, "group", group, "tenant", tenant); } result = this.agent.httpGet("/v1/cs/configs", (List)null, params, this.agent.getEncode(), readTimeout); // 省略。。。
2.2.5 服務端長連接處理機制
tips:這裏我去Github上摘抄的代碼,地址給大家:Nacos-Server,建議大家下載過來,在Idea中直接看比較方便。
前面主要是講了事件的訂閱、WorkClient創建出的線程池幹了什麼、以及長連接的建立,但是這些都是面向客戶端的,因此接下來從服務端的角度來看一看長連接的處理機制。
在Nacos-config模塊下,controller包下有一個類叫做ConfigController,專門用來實現配置的基本操作,其中有一個/listener接口,是客戶端發起數據監聽的接口。
@PostMapping("/listener") @Secured(action = ActionTypes.READ, parser = ConfigResourceParser.class) public void listener(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true); String probeModify = request.getParameter("Listening-Configs"); if (StringUtils.isBlank(probeModify)) { throw new IllegalArgumentException("invalid probeModify"); } probeModify = URLDecoder.decode(probeModify, Constants.ENCODE); Map<String, String> clientMd5Map; try { //獲取客戶端需要監聽的可能發生變化的配置,並計算其MD5值。 clientMd5Map = MD5Util.getClientMd5Map(probeModify); } catch (Throwable e) { throw new IllegalArgumentException("invalid probeModify"); } // 執行長輪詢的請求。 inner.doPollingConfig(request, response, clientMd5Map, probeModify.length()); }
主要幹兩件事:
- 獲取客戶端需要監聽的可能發生變化的配置,並計算其MD5值。
inner.doPollingConfig()
開始執行長輪詢的請求。
接下來來看一下處理長輪詢的方法doPollingConfig()
:
public String doPollingConfig(HttpServletRequest request, HttpServletResponse response, Map<String, String> clientMd5Map, int probeRequestSize) throws IOException { // 首先判斷當前請求是否爲長輪詢,如果是,則調用addLongPollingClient()方法 if (LongPollingService.isSupportLongPolling(request)) { longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize); return HttpServletResponse.SC_OK + ""; } // 兼容短輪詢的邏輯 List<String> changedGroups = MD5Util.compareMd5(request, response, clientMd5Map); // 兼容短輪詢的結果 String oldResult = MD5Util.compareMd5OldResult(changedGroups); String newResult = MD5Util.compareMd5ResultString(changedGroups); // 省略。。 } //addLongPollingClient()方法 public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map, int probeRequestSize) { //獲取客戶端設置的請求超時時間 String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER); String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER); String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER); String tag = req.getHeader("Vipserver-Tag"); int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500); // Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout. // 意思是提前500ms返回響應,爲了避免客戶端超時 long timeout = Math.max(10000, Long.parseLong(str) - delayTime); // 判斷是否爲混合連接,如果是,那麼定時任務將在30秒後開始執行 if (isFixedPolling()) { timeout = Math.max(10000, getFixedPollingInterval()); // Do nothing but set fix polling timeout. } else { // 如果不是,29.5秒後開始執行,也就是所謂的等待期 long start = System.currentTimeMillis(); // 和服務端的數據進行MD5對比,如果發生變化則直接返回 List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map); if (changedGroups.size() > 0) { generateResponse(req, rsp, changedGroups); LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant", RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize, changedGroups.size()); return; } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) { LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup", RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize, changedGroups.size()); return; } } String ip = RequestUtil.getRemoteIp(req); // Must be called by http thread, or send response. // 一定要由HTTP線程調用,否則離開後容器會立即發送響應 final AsyncContext asyncContext = req.startAsync(); // AsyncContext.setTimeout() is incorrect, Control by oneself asyncContext.setTimeout(0L); // 執行ClientLongPolling線程 ConfigExecutor.executeLongPolling( new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag)); }
即將客戶端的長輪詢請求封裝成ClientLongPolling
交給scheduler
執行。
2.2.6 ClientLongPolling
ClientLongPolling
同樣是一個線程,因此也看他的run()
方法:
主要做四件事情:
@Override public void run() { // 1.創建一個調度的任務,調度的延時時間爲 29.5s asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() { @Override public void run() { try { getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis()); // 3.延時時間到了之後,首先將該 ClientLongPolling 自身的實例從 allSubs 中移除 allSubs.remove(ClientLongPolling.this); if (isFixedPolling()) { LogUtil.CLIENT_LOG .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "fix", RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()), "polling", clientMd5Map.size(), probeRequestSize); // 4.獲取服務端中保存的對應客戶端請求的groupKeys,判斷是否變更,並將結果寫入response,返回給客戶端 List<String> changedGroups = MD5Util .compareMd5((HttpServletRequest) asyncContext.getRequest(), (HttpServletResponse) asyncContext.getResponse(), clientMd5Map); if (changedGroups.size() > 0) { sendResponse(changedGroups); } else { sendResponse(null); } } else { LogUtil.CLIENT_LOG .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "timeout", RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()), "polling", clientMd5Map.size(), probeRequestSize); sendResponse(null); } } catch (Throwable t) { LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause()); } } }, timeoutTime, TimeUnit.MILLISECONDS); // 2.將ClientLongPolling自身的實例添加到一個allSubs中 // 他是一個隊列ConcurrentLinkedQueue<ClientLongPolling> allSubs.add(this); }
這裏可以這麼理解:
allSubs這個隊列和ClientLongPolling之間維持了一種訂閱關係,而ClientLongPolling是屬於被訂閱的角色。
那麼一旦訂閱關係刪除後,訂閱方就無法對被訂閱方進行通知了。
服務端直到調度任務的延時時間用完之前,ClientLongPolling都不會有其他的事情可以做,因此這段時間內allSubs隊列會處理相關的邏輯。
爲了我們在客戶端長輪詢期間,一旦更改配置,客戶端能夠立即得到響應數據,因此這個事件的觸發肯定需要發生在服務端上。看下ConfigController下的publishConfig()方法
我在這裏直接以截圖的形式來展示重要的代碼邏輯:
其實老版本的話,這裏的代碼寫的是(當然我們並不會關注老版本,所以大家瞭解下就是):
EventDispatcher.fireEvent(new ConfigDataChangeEvent());
也因此,上文的圖中,有一個fireEvent
這麼一個字段。
到這裏,如果我們從Nacos控制檯上更新了某個配置項後,這裏會調用LongPollingService
的onEvent()
方法:
public LongPollingService() { allSubs = new ConcurrentLinkedQueue<ClientLongPolling>(); ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS); // Register LocalDataChangeEvent to NotifyCenter. NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize); // Register A Subscriber to subscribe LocalDataChangeEvent. NotifyCenter.registerSubscriber(new Subscriber() { @Override public void onEvent(Event event) { if (isFixedPolling()) { // Ignore. } else { if (event instanceof LocalDataChangeEvent) { LocalDataChangeEvent evt = (LocalDataChangeEvent) event; ConfigExecutor.executeLongPolling(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps)); } } } @Override public Class<? extends Event> subscribeType() { return LocalDataChangeEvent.class; } }); }
意思就是通過DataChangeTask
這個任務來通知客戶端:”服務端的數據已經發生了變更!“,接下來看下這個任務幹了什麼:
class DataChangeTask implements Runnable { @Override public void run() { try { ConfigCacheService.getContentBetaMd5(groupKey); // 1.遍歷allSubs隊列,該隊列中維持的是所有客戶端的請求任務 // 那麼需要找到與當前發生變更的配置項的groupKey相等的ClientLongPolling任務 for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) { ClientLongPolling clientSub = iter.next(); if (clientSub.clientMd5Map.containsKey(groupKey)) { // If published tag is not in the beta list, then it skipped. if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) { continue; } // If published tag is not in the tag list, then it skipped. if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) { continue; } getRetainIps().put(clientSub.ip, System.currentTimeMillis()); iter.remove(); // 刪除訂閱關係 LogUtil.CLIENT_LOG .info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance", RequestUtil .getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()), "polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey); // 2.找到對應的ClientLongPolling任務後,將發生變更的groupKey通過該ClientLongPolling寫入到響應對象中,即完成一次數據變更的推送操作。 clientSub.sendResponse(Arrays.asList(groupKey)); } } } catch (Throwable t) { LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t)); } } } void sendResponse(List<String> changedGroups) { // Cancel time out task. // 如果說DataChangeTask完成了數據的推送,ClientLongPolling中的調度任務又開始執行了,那麼會發生衝突 // 因此在進行推送操作之前,現將原來等待執行的調度任務取消掉 // 這樣就可以防止推送操作完成後,調度任務又去寫響應數據,造成衝突。 if (null != asyncTimeoutFuture) { asyncTimeoutFuture.cancel(false); } generateResponse(changedGroups); }
小總結2☆☆
首先請原諒我可能寫的有點亂,所以這裏先做個小總結對上文做一個歸納。
首先我想先說明一點:
- 爲什麼更改了配置信息後客戶端會立即得到響應?
1.首先每個配置在服務端都封裝成一個ClientLongPolling對象。其存儲於隊列當中。
2.客戶端和服務端會建立起一個長連接,並且維持29.5秒的等待時間,這段時間內除非配置發生更改,請求是不會返回的。
3.其次服務端一旦發現配置信息發生更改,在更改了配置信息後,會找到對應的ClientLongPolling任務,並將其更改後的groupKey寫入到響應對象中response,進行立刻返回。
4.之所以稱之爲實時的感知,是因爲服務端主動將變更後的數據通過HTTP的response對象寫入並且立刻返回。
5.而服務端說白了,就是做了一個定時調度任務,在等待調度任務執行的期間(29.5秒)若發生配置變化,則立刻響應,否則等待30秒去返回配置數據給客戶端。
接下來開始說Nacos Config實時更新的一個原理:
這裏,我同樣的準備從多個方面來進行闡述,畢竟內容比較多,也怕大家搞混。
首先,對於客戶端而言,如何感知到服務端配置的變更呢?
同樣的,當SpringBoot項目啓動的時候,會執行”準備上下文“的這麼一個事情。此時NacosContextRefresher會監聽到這個事件,並且註冊一個負責監聽配置變更回調的監聽器registerNacosListener。
registerNacosListener一旦收到配置變更的回調,則發佈一個RefreshEvent事件,對應的RefreshEventListener監聽器檢測到該事件後,將調用refresh.refresh()方法來完成配置的更新。
一旦發現服務端配置的變更,那麼客戶端肯定是要再進行配置的加載(locate())的,而其最終通過NacosConfigService.getConfig()方法來實現,在調用這個方法之前,必定要完成NacosConfigService的初始化操作。 因此這個初始化過程做了什麼?
根據NacosConfigService的構造函數,其做了兩件事:初始化並啓動一個HttpAgent(在Client端用來管理鏈接的持久性和重用的工具),初始化一個ClientWorker。
初始化ClientWorker的過程中,構建了兩個定時調度的線程池executor和executorService,並且啓動executor線程池,負責定時調度checkConfigInfo()方法,即檢查一次配置信息。
checkConfigInfo()方法中,使用了第二步的executorService線程池,負責搭建一個長輪詢機制,去監聽變更的數據。而這個任務通過LongPollingRunnable類來實現。
LongPollingRunnable是一個線程任務,通過調用checkUpdateDataIds()方法,基於長連接的方式來監聽服務端配置的變化 ,同時,如果發生配置的變更,則觸發一個個事件,那麼上述的監聽器發現後,則調用refresh()方法更新配置。
checkUpdateDataIds()方法中,建立的長連接時長30秒,並且一旦服務端發生數據變更,客戶端則收到一個HttpResult,裏面保存的是這個變更配置的最新key。那麼客戶端則根據最新配置的key去服務端獲取配置。
到這裏爲止,客戶端的實時更新配置的原理已經講完了,接下來總結服務端的原理:
首先ConfigController下有一個監聽器相關的接口,是客戶端發起數據監聽的接口,主要做兩件事:獲取客戶端需要監聽的可能發生變化的配置,並計算其MD5值。執行長輪詢的請求。
將長輪詢請求封裝成ClientLongPolling,交給線程池去執行。
執行過程中,可以理解爲一個配置爲一個長輪詢請求,也就對應一個ClientLongPolling,將其放在一個隊列allSubs當中,並且任務總共有29.5秒的等待時間。
如果某一個配置發生改變,會調用LongPollingService的onEvent()方法來通知客戶端服務端的數據已經發生了變更。
這裏所謂的通知也就是從隊列中找到變更配置對應的ClientLongPolling對象,將發生變更的groupKey通過該ClientLongPolling寫入到響應對象response中。
那麼客戶端收到了respones後,自然可以得到更改配置的groupKey,然後去Nacos上查詢即可。
三. 用流程圖來解釋Nacos-Config的實現原理
本篇文章先是講了客戶端方面如何執行遠程配置的加載,再從加載的具體實現細節來一一詳解。而這個實現細節也就包括了Nacos-Config如何實現配置的實時更新。
其實講到這裏已經是寫完了(累🤣),但是我還是挺怕大家看到這裏還是不理解,也怕自己水平有限導致寫的文章並不是很好,因此還是以自己的理解整了這麼一個流程圖給大家:
3.1 客戶端部分流程圖
3.2 服務端部分流程圖
流程圖我真的盡力畫了,只是做一個參考,建議以文章的內容爲主(重點於小總結),也希望能夠幫到大家理解。
關於Nacos具體的加載流程、事件的監聽、長連接的處理我就不通過Debug來展示了。感興趣的小夥伴可以自己Debug下,去官網把Nacos相關的代碼下載下來,遠程Remote下打個斷點試試。
最後,非常感謝所有能夠讀到這裏的讀者!(寫的可能不是很好,若哪裏寫的不對,還望大家指出,我及時更正)