从Nacos客户端视角来分析一下配置中心实现原理

时间:2022-03-10 08:48:47

从Nacos客户端视角来分析一下配置中心实现原理

一 动态配置

  • 1. 环境准备
  • 2.新建配置
  • 3.导入配置
  • 4.配置客户端
  • 5. 修改配置信息
  • 6.小结

二 配置中心原理(推还是拉)

  • 1.实例化 ConfigService
  • 2.添加 Listener
  • 3.CacheData
  • 4.触发回调
  • 5.Md5何时变更
  • 6.拉的优势

三 总结

Hello,大家好,我是麦洛,今天我们一起从Nacos客户端视角来看看配置中心实现原理;整理这篇文章时候,也参照学习了部分大佬的博客,这里致谢;

在开始阅读文章之前,有些思路我按我的理解先阐述一些,方便大家更快理清思路,不对的地方还请大家批评指正;

  1. Nacos客户端会在在本地缓存服务端配置文件,防止服务器奔溃情况下,导致服务不可用;
  2. 本地缓存类在代码中的体现就是我们下面提到的CacheData,我们知道对应服务端一个配置,肯定可以同时被多个客户端所使用,当这个配置发生变更,如何去通知到每一个客户端?
  3. 客户端启动之后,回去注册监视器,监视器最终会被保存到CacheData类中CopyOnWriteArrayList listeners字段,那么,反过来,当执行监视器回调方法时,就可以找到所有客户端
  4. 长轮询左右主要就是刷新配置,保持服务端配置和本地缓存配置保持一致;

首先,我们来看看Nacos官网给出的Nacos地图,我们可以清楚的看到,动态配置服务是 Nacos 的三大功能之一;

从Nacos客户端视角来分析一下配置中心实现原理

这里借用官网的描述,一起来看看Nacos 为我们带来什么黑科技?

动态配置服务可以让您以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置。动态配置消除了配置变更时重新部署应用和服务的需要,让配置管理变得更加高效和敏捷。配置中心化管理让实现无状态服务变得更简单,让服务按需弹性扩展变得更容易。

所以,有了Nacos ,可能我们以前上线打包弄错配置文件,改配置需要重启服务等一系列问题,都会显著改观

一 动态配置

 

下面我将来和大家一起来了解下 Nacos 的动态配置的能力,看看 Nacos 是如何以简单、优雅、高效的方式管理配置,实现配置的动态变更的。

我们用一个简单的例子来了解下 Nacos 的动态配置的功能。

1. 环境准备

首先,我们需要搭建一个Nacos 服务端,由于官网的quick-start已经对此做了详细的解读,我们这里就不在赘述

从Nacos客户端视角来分析一下配置中心实现原理
  1. https://nacos.io/zh-cn/docs/quick-start.html 

安装完成之后启动,我们就可以访问 Nacos 的控制台了,如下图所示:

从Nacos客户端视角来分析一下配置中心实现原理

Nacos控制台做了简单的权限控制,默认的账号和密码都是 nacos。

登录进去之后,是这样的:

从Nacos客户端视角来分析一下配置中心实现原理

2.新建配置

接下来我们在控制台上创建一个简单的配置项,如下图所示:

从Nacos客户端视角来分析一下配置中心实现原理

3.导入配置

Nacos支持导入配置,可以直接将配置文件压缩包导入,这里我们以人人开源的微服务项目为例

从Nacos客户端视角来分析一下配置中心实现原理

从Nacos客户端视角来分析一下配置中心实现原理
从Nacos客户端视角来分析一下配置中心实现原理

4.配置客户端

下面我以自己搭建的子服务为例,一起来看看Nacos配置中心的使用

首先我们需要配置一下,大家只需关注config节点配置就可以,discovery节点可以忽略

  1. cloud: 
  2.   nacos: 
  3.     discovery: 
  4.       metadata: 
  5.         management: 
  6.           context-path: ${server.servlet.context-path}/actuator 
  7.       server-addr: ${nacos-host:nacos-host}:${nacos-port:8848} 
  8.       #nacos的命名空间ID,默认是public 
  9.       namespace: ${nacos-namespace:} 
  10.       service: ets-web 
  11.     config: 
  12.       server-addr: ${spring.cloud.nacos.discovery.server-addr} 
  13.       namespace: ${spring.cloud.nacos.discovery.namespace} 
  14.       group: RENREN_CLOUD_GROUP 
  15.       file-extension: yaml 
  16.       #指定共享配置,且支持动态刷新 
  17.       extension-configs: 
  18.         - data-id: datasource.yaml 
  19.           group: ${spring.cloud.nacos.config.group
  20.           refresh: true 
  21.         - data-id: common.yaml 
  22.           group: ${spring.cloud.nacos.config.group
  23.           refresh: true 

其实extension-configs节点的配置信息对应的是下面的类

从Nacos客户端视角来分析一下配置中心实现原理

接下来我们启动服务,来看看控制台日志

 从Nacos客户端视角来分析一下配置中心实现原理

5. 修改配置信息

接下来我们在 Nacos 的控制台上将我们的配置信息改为如下图所示:

 从Nacos客户端视角来分析一下配置中心实现原理

修改完配置,点击 “发布” 按钮后,客户端将会收到最新的数据,如下图所示:

从Nacos客户端视角来分析一下配置中心实现原理

至此一个简单的动态配置管理功能已经讲完了,删除配置和更新配置操作类似,这里不再赘述。

6.小结

通过上面的小案例,我们大概了解了Nacos动态配置的服务的使用方法,Nacos服务端将配置信息保存到其配置文件所配置的数据库中,客户端连接到服务端之后,根据 dataID,Group可以获取到具体的配置信息,当服务端的配置发生变更时,客户端会收到通知。当客户端拿到变更后的最新配置信息后,就可以做自己的处理了,这非常有用,所有需要使用配置的场景都可以通过 Nacos 来进行管理。

二 配置中心原理(推还是拉)

 

现在我们了解了 Nacos 的动态配置服务的功能了,但是有一个问题我们需要弄明白,那就是 Nacos 客户端是怎么实时获取到 Nacos 服务端的最新数据的。

其实客户端和服务端之间的数据交互,无外乎两种情况:

  • 服务端推数据给客户端
  • 客户端从服务端拉数据

那到底是推还是拉呢,从 Nacos 客户端通过 Listener 来接收最新数据的这个做法来看,感觉像是服务端推的数据,但是不能想当然,要想知道答案,最快最准确的方法就是从源码中去寻找。

官方示例代码

  1. try { 
  2.     // 传递配置 
  3.     String serverAddr = "{serverAddr}"
  4.     String dataId = "{dataId}"
  5.     String group = "{group}"
  6.     Properties properties = new Properties(); 
  7.     properties.put("serverAddr", serverAddr); 
  8.  
  9.     // 新建 configService 
  10.     ConfigService configService = NacosFactory.createConfigService(properties); 
  11.     String content = configService.getConfig(dataId, group, 5000); 
  12.     System.out.println(content); 
  13.  
  14.     // 注册监听器 
  15.     configService.addListener(dataId, group, new Listener() { 
  16.     @Override 
  17.     public void receiveConfigInfo(String configInfo) { 
  18.         System.out.println("recieve1:" + configInfo); 
  19.     } 
  20.     @Override 
  21.     public Executor getExecutor() { 
  22.         return null
  23.     } 
  24. }); 
  25. } catch (NacosException e) { 
  26.     // TODO  
  27.     -generated catch block 
  28.     e.printStackTrace(); 

1.实例化 ConfigService

当我们引包结束以后,会发现下面三个关于Nacos的包

从Nacos客户端视角来分析一下配置中心实现原理

从我的理解来说,api包会调用client包的能力来和Nacos服务端进行交互.那再交互时候,主要就会用到我们接下来分析的实现了ConfigService接口的NacosConfigService 类

现在我们来看下 NacosConfigService 的构造方法,看看 ConfigService 是怎么实例化的,如下图所示:

  1. public class NacosConfigService implements ConfigService { 
  2.      
  3.     private static final Logger LOGGER = LogUtils.logger(NacosConfigService.class); 
  4.      
  5.     private static final long POST_TIMEOUT = 3000L; 
  6.      
  7.     /** 
  8.      * http agent. 
  9.      */ 
  10.     private final HttpAgent agent; 
  11.      
  12.     /** 
  13.      * long polling.  这里是长轮询 
  14.      */ 
  15.     private final ClientWorker worker; 
  16.      
  17.     private String namespace; 
  18.      
  19.     private final String encode; 
  20.     //省略其他代码 

  1. //构造方法 
  2. ic NacosConfigService(Properties properties) throws NacosException { 
  3.     ValidatorUtils.checkInitParam(properties); 
  4.     String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE); 
  5.     if (StringUtils.isBlank(encodeTmp)) { 
  6.         this.encode = Constants.ENCODE; 
  7.     } else { 
  8.         this.encode = encodeTmp.trim(); 
  9.     } 
  10.     initNamespace(properties); 
  11.     //对象1 
  12.     this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties)); 
  13.     this.agent.start(); 
  14.     //对象2 
  15.     this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties); 

实例化时主要是初始化了两个对象,他们分别是:

  • HttpAgent
  • ClientWorker

HttpAgent

其中 agent 是通过装饰器模式实现的,ServerHttpAgent 是实际工作的类,MetricsHttpAgent 在内部也是调用了 ServerHttpAgent 的方法,另外加上了一些统计操作,所以我们只需要关心 ServerHttpAgent 的功能就可以了。

不熟悉的同学,可以看菜鸟教程对装饰器模式的解读

agent 实际是在 ClientWorker 中发挥能力的,而 ClientWorker 也是真正的打工人,下面我们来看下 ClientWorker 类。

ClientWorker

以下是 ClientWorker 的构造方法,如下图所示:

  1. public ClientWorker(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager, 
  2.         final Properties properties) { 
  3.     this.agent = agent; 
  4.     this.configFilterChainManager = configFilterChainManager; 
  5.      
  6.     // Initialize the timeout parameter 
  7.      
  8.     init(properties); 
  9.     //创建了一个定时任务的线程池 
  10.     this.executor = Executors.newScheduledThreadPool(1, new ThreadFactory() { 
  11.         @Override 
  12.         public Thread newThread(Runnable r) { 
  13.             Thread t = new Thread(r); 
  14.             t.setName("com.alibaba.nacos.client.Worker." + agent.getName()); 
  15.             t.setDaemon(true); 
  16.             return t; 
  17.         } 
  18.     }); 
  19.     //创建了一个保持长轮询的线程池 
  20.     this.executorService = Executors 
  21.             .newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() { 
  22.                 @Override 
  23.                 public Thread newThread(Runnable r) { 
  24.                     Thread t = new Thread(r); 
  25.                     t.setName("com.alibaba.nacos.client.Worker.longPolling." + agent.getName()); 
  26.                     t.setDaemon(true); 
  27.                     return t; 
  28.                 } 
  29.             }); 
  30.      
  31.     //创建了一个延迟任务线程池来每隔10ms来检查配置信息的线程池 
  32.     this.executor.scheduleWithFixedDelay(new Runnable() { 
  33.         @Override 
  34.         public void run() { 
  35.             try { 
  36.                 checkConfigInfo(); 
  37.             } catch (Throwable e) { 
  38.                 LOGGER.error("[" + agent.getName() + "] [sub-check] rotate check error", e); 
  39.             } 
  40.         } 
  41.     }, 1L, 10L, TimeUnit.MILLISECONDS); 

可以看到 ClientWorker 除了将 HttpAgent 维持在自己内部,还创建了两个线程池:

  1. final ScheduledExecutorService executor; 
  2.      
  3. final ScheduledExecutorService executorService; 
  • 第一个线程池负责与配置中心进行数据的交互,并且启动后延迟1ms,之后每隔10ms对配置信息进行定时检查
  • 第二个线程池则是负责保持一个长轮询链接

接下来让我们来看下 executor 每 10ms 执行的方法到底做了什么工作,如下图所示:

  1. /** 
  2.  * groupKey -> cacheData. 
  3.  */ 
  4. private final AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>( 
  5.         new HashMap<String, CacheData>()); 

  1. /** 
  2.   * Check config info. 检查配置信息 
  3.   */ 
  4.  public void checkConfigInfo() { 
  5.      // 分任务(解决大数据量的传输问题) 
  6.      int listenerSize = cacheMap.get().size(); 
  7.      // 向上取整为批数,分批次进行检查 
  8.      //ParamUtil.getPerTaskConfigSize() =3000 
  9.      int longingTaskCount = (int) Math.ceil(listenerSize / ParamUtil.getPerTaskConfigSize()); 
  10.      if (longingTaskCount > currentLongingTaskCount) { 
  11.          for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) { 
  12.              // 要判断任务是否在执行 这块需要好好想想。 任务列表现在是无序的。变化过程可能有问题 
  13.              executorService.execute(new LongPollingRunnable(i)); 
  14.          } 
  15.          currentLongingTaskCount = longingTaskCount; 
  16.      } 
  17.  } 

这里主要是先去拿缓存中 Map

现在我们来看看 LongPollingRunnable 做了什么,主要分为两部分,

  • 第一部分是检查本地的配置信息,
  • 第二部分是获取服务端的配置信息然后更新到本地。

1.本地检查

首先取出与该 taskId 相关的 CacheData,然后对 CacheData 进行检查,包括本地配置检查和缓存数据的 md5 检查,本地检查主要是做一个故障容错,当服务端挂掉后,Nacos 客户端可以从本地的文件系统中获取相关的配置信息,如下图所示:

  1. public void run() { 
  2.              
  3.             List<CacheData> cacheDatas = new ArrayList<CacheData>(); 
  4.             List<String> inInitializingCacheList = new ArrayList<String>(); 
  5.             try { 
  6.                 // 
  7.                 for (CacheData cacheData : cacheMap.get().values()) { 
  8.                     if (cacheData.getTaskId() == taskId) { 
  9.                         cacheDatas.add(cacheData); 
  10.                         try { 
  11.                             //执行检查本地配置 
  12.                             checkLocalConfig(cacheData); 
  13.                             if (cacheData.isUseLocalConfigInfo()) { 
  14.                                 //缓存数据的md5的检查 
  15.                                 cacheData.checkListenerMd5(); 
  16.                             } 
  17.                         } catch (Exception e) { 
  18.                             LOGGER.error("get local config info error", e); 
  19.                         } 
  20.                     } 
  21.                 } 
  22.                
  23.         } 

  1. //检查本地配置 
  2.      
  3. private void checkLocalConfig(CacheData cacheData) { 
  4.         final String dataId = cacheData.dataId; 
  5.         final String group = cacheData.group
  6.         final String tenant = cacheData.tenant; 
  7.     //本地缓存文件 
  8.         File path = LocalConfigInfoProcessor.getFailoverFile(agent.getName(), dataId, group, tenant); 
  9.         //不使用本地配置,但是持久化文件存在,需要读取文件加载至内存 
  10.         if (!cacheData.isUseLocalConfigInfo() && path.exists()) { 
  11.             String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant); 
  12.             final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE); 
  13.             cacheData.setUseLocalConfigInfo(true); 
  14.             cacheData.setLocalConfigInfoVersion(path.lastModified()); 
  15.             cacheData.setContent(content); 
  16.              
  17.             LOGGER.warn( 
  18.                     "[{}] [failover-change] failover file created. dataId={}, group={}, tenant={}, md5={}, content={}"
  19.                     agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content)); 
  20.             return
  21.         } 
  22.          
  23.        // 有 -> 没有。不通知业务监听器,从server拿到配置后通知。 
  24.         //使用本地配置,但是持久化文件不存在  
  25.         if (cacheData.isUseLocalConfigInfo() && !path.exists()) { 
  26.             cacheData.setUseLocalConfigInfo(false); 
  27.             LOGGER.warn("[{}] [failover-change] failover file deleted. dataId={}, group={}, tenant={}", agent.getName(), 
  28.                     dataId, group, tenant); 
  29.             return
  30.         } 
  31.          
  32.         // 有变更 
  33.         //使用本地配置,持久化文件存在,缓存跟文件最后修改时间不一致 
  34.         if (cacheData.isUseLocalConfigInfo() && path.exists() && cacheData.getLocalConfigInfoVersion() != path 
  35.                 .lastModified()) { 
  36.             String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant); 
  37.             final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE); 
  38.             cacheData.setUseLocalConfigInfo(true); 
  39.             cacheData.setLocalConfigInfoVersion(path.lastModified()); 
  40.             cacheData.setContent(content); 
  41.             LOGGER.warn( 
  42.                     "[{}] [failover-change] failover file changed. dataId={}, group={}, tenant={}, md5={}, content={}"
  43.                     agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content)); 
  44.         } 
  45.     } 

本地检查主要是通过是否使用本地配置,继而寻找持久化缓存文件,再通过判断文件的最后修改事件与本地缓存的版本是否一致来判断是否由变更

通过跟踪 checkLocalConfig 方法,可以看到 Nacos 将缓存配置信息保存在了

  1. ~/nacos/config/fixed-{address}_8848_nacos/snapshot/DEFAULT_GROUP/{dataId} 

这个文件中,我们看下这个文件中保存的内容,如下图所示:

从Nacos客户端视角来分析一下配置中心实现原理

2.服务端检查

然后通过 checkUpdateDataIds() 方法从服务端获取值变化的 dataId 列表,

通过 getServerConfig 方法,根据 dataId 到服务端获取最新的配置信息,接着将最新的配置信息保存到 CacheData 中。

最后调用 CacheData 的 checkListenerMd5 方法,可以看到该方法在第一部分也被调用过,我们需要重点关注一下。

  1. // 检查服务器配置 
  2.   List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList); 
  3.   if (!CollectionUtils.isEmpty(changedGroupKeys)) { 
  4.       LOGGER.info("get changedGroupKeys:" + changedGroupKeys); 
  5.   } 
  6.    
  7.   for (String groupKey : changedGroupKeys) { 
  8.       String[] key = GroupKey.parseKey(groupKey); 
  9.       String dataId = key[0]; 
  10.       String group = key[1]; 
  11.       String tenant = null
  12.       if (key.length == 3) { 
  13.           tenant = key[2]; 
  14.       } 
  15.       try { 
  16.           //从服务器端获取相关id的最新配置 
  17.           String[] ct = getServerConfig(dataId, group, tenant, 3000L); 
  18.           CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant)); 
  19.           cache.setContent(ct[0]); 
  20.           if (null != ct[1]) { 
  21.               cache.setType(ct[1]); 
  22.           } 
  23.           LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, content={}, type={}"
  24.                   agent.getName(), dataId, group, tenant, cache.getMd5(), 
  25.                   ContentUtils.truncateContent(ct[0]), ct[1]); 
  26.       } catch (NacosException ioe) { 
  27.           String message = String 
  28.                   .format("[%s] [get-update] get changed config exception. dataId=%s, group=%s, tenant=%s"
  29.                           agent.getName(), dataId, group, tenant); 
  30.           LOGGER.error(message, ioe); 
  31.       } 
  32.   } 
  33.   for (CacheData cacheData : cacheDatas) { 
  34.       if (!cacheData.isInitializing() || inInitializingCacheList 
  35.               .contains(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant))) { 
  36.           //校验MD5值 
  37.           cacheData.checkListenerMd5(); 
  38.           cacheData.setInitializing(false); 
  39.       } 
  40.   } 
  41.   inInitializingCacheList.clear(); 
  42.    
  43.   executorService.execute(this); 
  44.    
  45. catch (Throwable e) { 
  46.    
  47.   // If the rotation training task is abnormal, the next execution time of the task will be punished 
  48.   LOGGER.error("longPolling error : ", e); 
  49.   executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS); 

这里大家也发现,当客户端从服务器拉去配置文件之后,会将配置文件在本地进行缓存,所以,一般会优先使用本地配置,如果本地文件不存在或者内容为空,则再通过 HTTP GET 方法从远端拉取配置,并保存到本地缓存中

  1. private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException { 
  2.       group = null2defaultGroup(group); 
  3.       ParamUtils.checkKeyParam(dataId, group); 
  4.       ConfigResponse cr = new ConfigResponse(); 
  5.        
  6.       cr.setDataId(dataId); 
  7.       cr.setTenant(tenant); 
  8.       cr.setGroup(group); 
  9.        
  10.       // 优先使用本地配置 
  11.       String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant); 
  12.       if (content != null) { 
  13.           LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}", agent.getName(), 
  14.                   dataId, group, tenant, ContentUtils.truncateContent(content)); 
  15.           cr.setContent(content); 
  16.           configFilterChainManager.doFilter(null, cr); 
  17.           content = cr.getContent(); 
  18.           return content; 
  19.       } 
  20.        
  21.       try { 
  22.           String[] ct = worker.getServerConfig(dataId, group, tenant, timeoutMs); 
  23.           cr.setContent(ct[0]); 
  24.            
  25.           configFilterChainManager.doFilter(null, cr); 
  26.           content = cr.getContent(); 
  27.            
  28.           return content; 
  29.       } catch (NacosException ioe) { 
  30.           if (NacosException.NO_RIGHT == ioe.getErrCode()) { 
  31.               throw ioe; 
  32.           } 
  33.           LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}"
  34.                   agent.getName(), dataId, group, tenant, ioe.toString()); 
  35.       } 
  36.        
  37.       LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, config={}", agent.getName(), 
  38.               dataId, group, tenant, ContentUtils.truncateContent(content)); 
  39.       content = LocalConfigInfoProcessor.getSnapshot(agent.getName(), dataId, group, tenant); 
  40.       cr.setContent(content); 
  41.       configFilterChainManager.doFilter(null, cr); 
  42.       content = cr.getContent(); 
  43.       return content; 
  44.   } 

2.添加 Listener

好了现在我们可以为 ConfigService 来添加一个 Listener 了,最终是调用了 ClientWorker 的 addTenantListeners 方法,如下图所示:

  1. /** 
  2.  * Add listeners for tenant. 
  3.  * 
  4.  * @param dataId    dataId of data 
  5.  * @param group     group of data 
  6.  * @param listeners listeners 
  7.  * @throws NacosException nacos exception 
  8.  */ 
  9. public void addTenantListeners(String dataId, String group, List<? extends Listener> listeners) 
  10.         throws NacosException { 
  11.     //设置默认组 
  12.     group = null2defaultGroup(group); 
  13.     String tenant = agent.getTenant(); 
  14.     CacheData cache = addCacheDataIfAbsent(dataId, group, tenant); 
  15.     for (Listener listener : listeners) { 
  16.         cache.addListener(listener); 
  17.     } 

该方法分为两个部分,首先根据 dataId,group 和tenant获取一个 CacheData 对象,然后将当前要添加的 listener 对象添加到 CacheData 中去。

接下来,我们要重点关注下 CacheData 类了。

3.本地缓存CacheData

首先让我们来看一下 CacheData 中的成员变量,如下图所示:

  1. private final String name
  2.  
  3. private final ConfigFilterChainManager configFilterChainManager; 
  4.  
  5. public final String dataId; 
  6.  
  7. public final String group
  8.  
  9. public final String tenant; 
  10. //监听器 
  11.  
  12. private final CopyOnWriteArrayList<ManagerListenerWrap> listeners; 
  13.  
  14. private volatile String md5; 
  15.  
  16. /** 
  17.  * whether use local config. 
  18.  */ 
  19. private volatile boolean isUseLocalConfig = false
  20.  
  21. /** 
  22.  * last modify time
  23.  */ 
  24. private volatile long localConfigLastModified; 
  25.  
  26. private volatile String content; 
  27.  
  28. private int taskId; 
  29.  
  30. private volatile boolean isInitializing = true
  31.  
  32. private String type; 

我们可以看到,成员变量包括tenant ,dataId,group,content,taskId等,还有两个值得我们关注的:

  • listeners
  • md5

listeners 是该 CacheData 所关联的所有 listener,不过不是保存的原始的 Listener对象,而是包装后的 ManagerListenerWrap 对象,该对象除了持有 Listener 对象,还持有了一个 lastCallMd5 和lastContent属性。

  1. private static class ManagerListenerWrap { 
  2.       
  3.      final Listener listener; 
  4.       
  5.      //关注 
  6.      String lastCallMd5 = CacheData.getMd5String(null); 
  7.       
  8.      String lastContent = null
  9.       
  10.      ManagerListenerWrap(Listener listener) { 
  11.          this.listener = listener; 
  12.      } 
  13.       
  14.      ManagerListenerWrap(Listener listener, String md5) { 
  15.          this.listener = listener; 
  16.          this.lastCallMd5 = md5; 
  17.      } 
  18.       
  19.      ManagerListenerWrap(Listener listener, String md5, String lastContent) { 
  20.          this.listener = listener; 
  21.          this.lastCallMd5 = md5; 
  22.          this.lastContent = lastContent; 
  23.      } 
  24.       
  25.  } 

另外一个属性 md5 就是根据当前对象的 content 计算出来的 md5 值。

4.触发监听器回调

现在我们对 ConfigService 有了大致的了解了,现在剩下最后一个重要的问题还没有答案,那就是 ConfigService 的 Listener 是在什么时候触发回调方法 receiveConfigInfo 的。

现在让我们回过头来想一下,在 ClientWorker 中的定时任务中,启动了一个长轮询的任务:LongPollingRunnable,该任务多次执行了 cacheData.checkListenerMd5() 方法,那现在就让我们来看下这个方法到底做了些什么,如下图所示:

  1. void checkListenerMd5() { 
  2.     for (ManagerListenerWrap wrap : listeners) { 
  3.         if (!md5.equals(wrap.lastCallMd5)) { 
  4.             safeNotifyListener(dataId, group, content, type, md5, wrap); 
  5.         } 
  6.     } 

到这里应该就比较清晰了,该方法会检查 CacheData 当前的 md5 与 CacheData 持有的所有 Listener 中保存的 md5 的值是否一致,如果不一致,就执行一个安全的监听器的通知方法:safeNotifyListener,通知什么呢?我们可以大胆的猜一下,应该是通知 Listener 的使用者,该 Listener 所关注的配置信息已经发生改变了。现在让我们来看一下 safeNotifyListener 方法,如下图所示:

  1. private void safeNotifyListener(final String dataId, final String group, final String content, final String type, 
  2.           final String md5, final ManagerListenerWrap listenerWrap) { 
  3.       final Listener listener = listenerWrap.listener; 
  4.        
  5.       Runnable job = new Runnable() { 
  6.           @Override 
  7.           public void run() { 
  8.               ClassLoader myClassLoader = Thread.currentThread().getContextClassLoader(); 
  9.               ClassLoader appClassLoader = listener.getClass().getClassLoader(); 
  10.               try { 
  11.                   if (listener instanceof AbstractSharedListener) { 
  12.                       AbstractSharedListener adapter = (AbstractSharedListener) listener; 
  13.                       adapter.fillContext(dataId, group); 
  14.                       LOGGER.info("[{}] [notify-context] dataId={}, group={}, md5={}"name, dataId, group, md5); 
  15.                   } 
  16.                   // 执行回调之前先将线程classloader设置为具体webapp的classloader,以免回调方法中调用spi接口是出现异常或错用(多应用部署才会有该问题)。 
  17.                   Thread.currentThread().setContextClassLoader(appClassLoader); 
  18.                    
  19.                   ConfigResponse cr = new ConfigResponse(); 
  20.                   cr.setDataId(dataId); 
  21.                   cr.setGroup(group); 
  22.                   cr.setContent(content); 
  23.                    
  24.                   //重点关注,在这里调用 
  25.                   //重点关注,在这里调用 
  26.                   //重点关注,在这里调用 
  27.                    
  28.                   configFilterChainManager.doFilter(null, cr); 
  29.                   String contentTmp = cr.getContent(); 
  30.                   listener.receiveConfigInfo(contentTmp); 
  31.                    
  32.                    
  33.                    
  34.                    
  35.                   // compare lastContent and content 
  36.                   if (listener instanceof AbstractConfigChangeListener) { 
  37.                       Map data = ConfigChangeHandler.getInstance() 
  38.                               .parseChangeData(listenerWrap.lastContent, content, type); 
  39.                       ConfigChangeEvent event = new ConfigChangeEvent(data); 
  40.                       ((AbstractConfigChangeListener) listener).receiveConfigChange(event); 
  41.                       listenerWrap.lastContent = content; 
  42.                   } 
  43.                    
  44.                   listenerWrap.lastCallMd5 = md5; 
  45.                   LOGGER.info("[{}] [notify-ok] dataId={}, group={}, md5={}, listener={} "name, dataId, group, md5, 
  46.                           listener); 
  47.               } catch (NacosException ex) { 
  48.                   LOGGER.error("[{}] [notify-error] dataId={}, group={}, md5={}, listener={} errCode={} errMsg={}"
  49.                           name, dataId, group, md5, listener, ex.getErrCode(), ex.getErrMsg()); 
  50.               } catch (Throwable t) { 
  51.                   LOGGER.error("[{}] [notify-error] dataId={}, group={}, md5={}, listener={} tx={}"name, dataId, 
  52.                           group, md5, listener, t.getCause()); 
  53.               } finally { 
  54.                   Thread.currentThread().setContextClassLoader(myClassLoader); 
  55.               } 
  56.           } 
  57.       }; 
  58.        
  59.       final long startNotify = System.currentTimeMillis(); 
  60.       try { 
  61.           if (null != listener.getExecutor()) { 
  62.               listener.getExecutor().execute(job); 
  63.           } else { 
  64.               job.run(); 
  65.           } 
  66.       } catch (Throwable t) { 
  67.           LOGGER.error("[{}] [notify-error] dataId={}, group={}, md5={}, listener={} throwable={}"name, dataId, 
  68.                   group, md5, listener, t.getCause()); 
  69.       } 
  70.       final long finishNotify = System.currentTimeMillis(); 
  71.       LOGGER.info("[{}] [notify-listener] time cost={}ms in ClientWorker, dataId={}, group={}, md5={}, listener={} "
  72.               name, (finishNotify - startNotify), dataId, group, md5, listener); 
  73.   } 

可以看到在 safeNotifyListener 方法中,重点关注下红框中的三行代码:获取最新的配置信息,调用 Listener 的回调方法,将最新的配置信息作为参数传入,这样 Listener 的使用者就能接收到变更后的配置信息了,最后更新 ListenerWrap 的 md5 值。和我们猜测的一样, Listener 的回调方法就是在该方法中触发的。

5.Md5何时变更

那 CacheData 的 md5 值是何时发生改变的呢?我们可以回想一下,在上面的 LongPollingRunnable 所执行的任务中,在获取服务端发生变更的配置信息时,将最新的 content 数据写入了 CacheData 中,我们可以看下该方法如下:

  1. public void setContent(String content) { 
  2.      this.content = content; 
  3.      this.md5 = getMd5String(this.content); 
  4.  } 

可以看到是在长轮询的任务中,当服务端配置信息发生变更时,客户端将最新的数据获取下来之后,保存在了 CacheData 中,同时更新了该 CacheData 的 md5 值,所以当下次执行 checkListenerMd5 方法时,就会发现当前 listener 所持有的 md5 值已经和 CacheData 的 md5 值不一样了,也就意味着服务端的配置信息发生改变了,这时就需要将最新的数据通知给 Listener 的持有者。

至此配置中心的完整流程已经分析完毕了,可以发现,Nacos 并不是通过推的方式将服务端最新的配置信息发送给客户端的,而是客户端维护了一个长轮询的任务,定时去拉取发生变更的配置信息,然后将最新的数据推送给 Listener 的持有者。

6.为什么要拉?

客户端拉取服务端的数据与服务端推送数据给客户端相比,优势在哪呢,为什么 Nacos 不设计成主动推送数据,而是要客户端去拉取呢?如果用推的方式,服务端需要维持与客户端的长连接,这样的话需要耗费大量的资源,并且还需要考虑连接的有效性,例如需要通过心跳来维持两者之间的连接。而用拉取的方式,客户端只需要通过一个无状态的 http 请求即可获取到服务端的数据。

三 总结

 

 从Nacos客户端视角来分析一下配置中心实现原理

现在,我们来简单复盘一下Nacos客户端视角下的配置中心实现原理

首先我们假设Nacos服务端一切正常,Nacos客户端启动以后

第一步是根据我们配置的服务端信息,新建 ConfigService 实例,它的实现就是我们文中提到的NacosConfigService;

第二步可以通过相应的接口获取配置和注册配置监听器,

考虑到服务端故障的问题,客户端将最新数据获取后会保存在本地的 缓存文件中,以后会优先从文件中获取配置信息的值,如果获取不到,会直接从服务器拉去,并保存到缓存中;

其实真正干活的就是ClientWorker类;客户端是通过一个定时的长轮询来检查自己监听的配置项的数据的,一旦服务端的数据发生变化时,会从服务端获取到dataID的列表,

客户端根据dataID列表从服务端获取到最新的数据,并将最新的数据保存在一个 CacheData 对象中,在轮询过程中,如果决定使用本地配置,就会比较当前CacheData 的MD5值是否和所有监听者所持有的MD5值相等,如果不相等,,此时就会对该 CacheData 所绑定的 Listener 触发 receiveConfigInfo 回调,来通知使用者此配置信息已经变更;

原文地址:https://mp.weixin.qq.com/s/qmT-SsYr6yPmqEtN-4XAoQ