nacos 配置中心客户端分析

84 阅读7分钟

背景

这几年使用nacos作为配置中心的团队越来越多,掌握nacos成了我们工作、面试中不可缺少的一道硬菜,同样具有五年配置中心使用的笔者来说也有必要学习下。本文主要就nacos客户端进行分析,涉及到服务端相关的会在后面文章展开。

介绍

Nacos 是阿里巴巴推出来的一个新开源项目,这是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台,Nacos 提供配置统一管理功能,能够帮助我们将配置以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置。

使用

可本地安装单节点测试,也可以安装nacos集群,安装方式,mvn 导入nacos client、如果使用spring可以引入nacos-spring的一些扩展功能jar包,在项目中就可以使用了 image.png image.png 使用就介绍到这里,不清楚的可以通过官方介绍    nacos 使用

总览

image.png 红色框框便是客户端所在角色,而他的功能也很明确拉取Actor配置的最新值

核心介绍

下面分析下客户端是怎么感知服务器值变更的,并且又是怎么通知到我们spring管理的bean,让业务代码感知

拉取方案

image.png NacosConfigService 是程序启动nacos客户端的入口,比如ofc项目中通过引入spring-cloud-starter-alibaba-nacos-config 包,间接通过 NacosConfigManager 启动它

//com.alibaba.cloud.nacos.NacosConfigAutoConfiguration
@Bean
public NacosConfigManager nacosConfigManager(
      NacosConfigProperties nacosConfigProperties) {
   return new NacosConfigManager(nacosConfigProperties);
}

NacosConfigManager中引用了ClientWorker,clientWorker会初始2个线程池,executor和executorService,executorService主要负责轮询nacosServer获取最新的配置变更,executor是进行拉取任务分配,比如你的应用引用了几十万个配置文件,而且会动态删减 那么executor会检测然后将任务拆分为3000个文件一次,这样以达到多线程同时拉取的效果缩短延迟。

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;
    }
});
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 {
            checkConfigInfo();
        } catch (Throwable e) {
        }
    }
}, 1L, 10L, TimeUnit.MILLISECONDS);//这里10毫秒一次

executor是单线程线程池个人感觉 executor 10毫秒的刷新频次太高了,executorService 线程个数和cpu相匹配。

public void checkConfigInfo() {
    int listenerSize = cacheMap.get().size();
    int longingTaskCount = (int) Math.ceil(listenerSize / 3000);
    if (longingTaskCount > currentLongingTaskCount) {
        for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {
            executorService.execute(new LongPollingRunnable(i));
        }
        currentLongingTaskCount = longingTaskCount;
    }
}

按照3000分组提交给executorService线程池,接下去我们看看核心拉取逻辑。提交到executorService的任务是 LongPollingRunnable 类,我们看看run方法都干了些什么

public void run() {
    List<CacheData> cacheDatas = new ArrayList<CacheData>();
    List<String> inInitializingCacheList = new ArrayList<String>();
    try {//第一部分
        for (CacheData cacheData : cacheMap.get().values()) {
            if (cacheData.getTaskId() == taskId) {
                cacheDatas.add(cacheData);
                checkLocalConfig(cacheData);
                if (cacheData.isUseLocalConfigInfo()) {
                    cacheData.checkListenerMd5();
                }
            }
        }//第二部分
        List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);
        for (String groupKey : changedGroupKeys) {
            String[] key = GroupKey.parseKey(groupKey);
            String dataId = key[0];
            String group = key[1];
            String tenant = null;
            if (key.length == 3) {
                tenant = key[2];
            }
                String[] ct = getServerConfig(dataId, group, tenant, 3000L);
                CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant));
                cache.setContent(ct[0]);
                if (null != ct[1]) {
                    cache.setType(ct[1]);
                }
        }
        for (CacheData cacheData : cacheDatas) {
            if (!cacheData.isInitializing() || inInitializingCacheList
                    .contains(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant))) {
                cacheData.checkListenerMd5();//第四部分
                cacheData.setInitializing(false);
            }
        }
        inInitializingCacheList.clear();
        executorService.execute(this);//第五部分
    } catch (Throwable e) {
        executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);
    }
}
List<String> checkUpdateDataIds(List<CacheData> cacheDatas, List<String> inInitializingCacheList) throws Exception {
    boolean isInitializingCacheList = !inInitializingCacheList.isEmpty();
    return checkUpdateConfigStr(sb.toString(), isInitializingCacheList);
}//第三步
List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws Exception {
    Map<String, String> params = new HashMap<String, String>(2);
    params.put(Constants.PROBE_MODIFY_REQUEST, probeUpdateString);
    Map<String, String> headers = new HashMap<String, String>(2);
    headers.put("Long-Pulling-Timeout", "" + timeout);
    try {
        long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
        HttpRestResult<String> result = agent
                .httpPost("/v1/cs/configs/listener", headers, params, agent.getEncode(),readTimeoutMs);
        if (result.ok()) {
            setHealthServer(true);
            return parseUpdateDataIdResponse(result.getData());
        }
    } catch (Exception e) {
        setHealthServer(false);
    }
    return Collections.emptyList();
}
  • 第一部分主要是拿到当前批次任务需要检查更新的groupid,比如我们的application.properties、trade-platform.dubbo.properties 等
  • 第二、三部分headers.put("Long-Pulling-Timeout", "" + timeout);中将timeout 作为header 进行请求组装 timeout 默认是30,000ms,然后设置客户端请求的超时时间为 timeout + (long) Math.round(timeout >> 1) = 45,000ms,这还是比较有意思的,我想了想这样做大概是为了减少网络io次数和节省流量吧,毕竟都在响应降本增效,最后发送post请求到 /v1/cs/configs/listener,请求完成我们会根据返回的结构判断是否有配置变更,判断配置变更程序主要通过md5进行对比,如果有变更那么线程还会通过getServerConfig继续拉取真正的配置文件内容,最后通过checkListenerMd5 方法来通知这里我们放后面分析。
  • 第五部分 又将任务提交到了线程池中,继续检查服务器变更。
自动刷新

自动刷新的方案比较多这里我们着重说下@RefreshScope注解加@Value 方式和@NacosValue和nacos-spring-context 这两种比较典型的方案

@RefreshScope注解加@Value

@RefreshScope 这个注解不是nacos才有的,是在spring中自带的,其含义主要为了标明bean的作用域,默认我们常见的bean主要是单例(singleton),@RefreshScope 包裹了一层@Scope("refresh") 还有独特的proxyMode为targetClass 这两个标识就为@RefreshScope能支持刷新提供了很好的基础, @RefreshScope 从bean的定义和注册起有有所不同我们先来看看beanD定义有没有区别于其他的beanD,我们从beanFactory的 beanDefinitionMap 中看到有2个关于userController关键词

image.png image.png image.png 很明显有2个beanD存在,后面会实例化成2个bean一个是我们自己创建的userController一个是spring的LockedScopedProxyFactoryBean,接下去我们看看最初的doGetBean方法 image.png 很明显我们在调用bean的时候会从else分支的scope中去拿bean,那么我们摄像如果scope.get() 方法取不到bean是不是就会重新创建bean?带着这个问题,后面我们看看 checkListenerMd5 中是否真的会与此结合。 顺着 checkListenerMd5 往下看,后置处理中会调用一个listener.receiveConfigInfo(),说到这里要再介绍一个类NacosContextRefresher 这个类实现了ApplicationListener 往我们的nacosclient中注册了一个监听器

private void registerNacosListener(final String groupKey, final String dataKey) {
   String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey);
   Listener listener = listenerMap.computeIfAbsent(key,
         lst -> new AbstractSharedListener() {
            public void innerReceive(String dataId, String group,
                  String configInfo) {
               refreshCountIncrement();
               nacosRefreshHistory.addRefreshRecord(dataId, group, configInfo);
               applicationContext.publishEvent(
                     new RefreshEvent(this, null, "Refresh Nacos config"));
            }
         }); 
      configService.addListener(dataKey, groupKey, listener);
}

而这个listener正是listener.receiveConfigInfo() 会被执行的listener,有2个作用,一个是统计用,一个是发布RefreshEvent 事件,而 RefreshEvent 是spring中的事件,又由RefreshEventListener 订阅处理

public void handle(RefreshEvent event) {
   if (this.ready.get()) {
      Set<String> keys = this.refresh.refresh();
   }
}
public synchronized Set<String> refresh() {
   Set<String> keys = refreshEnvironment();
   this.scope.refreshAll();
   return keys;
}

我们深入下几层代码,在refresh方法中,会刷新spring环境,然后出现了scope字眼,scope.refreshAll() 方法实际是将@RefreshScope 中的bean全部销毁了,这样程序再次执行就会从环境中获取新的变量,我验证了下果然,每次变量跟新 userController 就会重新初始化,太吓人了,如果初始化方法比较耗时岂不是block流程了

public void destroy() {
   List<Throwable> errors = new ArrayList<Throwable>();
   Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
   for (BeanLifecycleWrapper wrapper : wrappers) {
         Lock lock = this.locks.get(wrapper.getName()).writeLock();
         lock.lock();
            wrapper.destroy();

@NacosValue和nacos-spring-context

在介绍之前大家可以先去熟悉下spring的bean后置处理器BeanPostProcessor,nacos-spring-context包中含有后置处理器的实现类NacosValueAnnotationBeanPostProcessor,我们看看他都做了啥

public Object postProcessBeforeInitialization(Object bean, final String beanName){
   doWithFields(bean, beanName);
   doWithMethods(bean, beanName);
   return super.postProcessBeforeInitialization(bean, beanName);
}
private void doWithFields(final Object bean, final String beanName) {
   ReflectionUtils.doWithFields(bean.getClass(),
         new ReflectionUtils.FieldCallback() {
            public void doWith(Field field) throws IllegalArgumentException {
               NacosValue annotation = getAnnotation(field, NacosValue.class);//部分一
               doWithAnnotation(beanName, bean, annotation, field.getModifiers(),
                     null, field);
            }
         });
}
private void doWithAnnotation(String beanName, Object bean, NacosValue annotation,
      int modifiers, Method method, Field field) {
   if (annotation != null) {
      if (annotation.autoRefreshed()) {
         String placeholder = resolvePlaceholder(annotation.value());
         NacosValueTarget nacosValueTarget = new NacosValueTarget(bean, beanName,method, field, annotation.value());
         put2ListMap(placeholderNacosValueTargetMap, placeholder,
               nacosValueTarget);
      }
   }
}

我们这里只看 doWithFields 方法,部分一代码中获取到NacosValue注解,并将nacos的key通过placeholder 解析出来,put2ListMap map中关联了key和key所关联的bean,将key和bean的关系放在了 placeholderNacosValueTargetMap 中。接着看nacos-spring-context中的 DelegatingEventPublishingListener 类,实现了Listener接口,上文讲过只要实现该接口,key更新就会触发 receiveConfigInfo() 方法。

public void receiveConfigInfo(String content) {
   onReceived(content);
   publishEvent(content);
}
private void publishEvent(String content) {
   NacosConfigReceivedEvent event = new NacosConfigReceivedEvent(configService,
         dataId, groupId, content, configType);
   applicationEventPublisher.publishEvent(event);
}

接收到key变更,方法中发布了 NacosConfigReceivedEvent 事件,而NacosConfigReceivedEvent 事件的订阅却有绕回到了NacosValueAnnotationBeanPostProcessor.onApplicationEvent()方法

public void onApplicationEvent(NacosConfigReceivedEvent event) {
   for (Map.Entry<String, List<NacosValueTarget>> entry : placeholderNacosValueTargetMap
         .entrySet()) {
      String key = environment.resolvePlaceholders(entry.getKey());
      String newValue = environment.getProperty(key);
      List<NacosValueTarget> beanPropertyList = entry.getValue();
      for (NacosValueTarget target : beanPropertyList) {
         String md5String = MD5Utils.md5Hex(newValue, "UTF-8");
         boolean isUpdate = !target.lastMD5.equals(md5String);
         if (isUpdate) {
            target.updateLastMD5(md5String);
            Object evaluatedValue = resolveNotifyValue(target.nacosValueExpr, key, newValue);
            setField(target, evaluatedValue);
         }
      }
   }
}
private void setField(final NacosValueTarget nacosValueTarget,
      final Object propertyValue) {
   final Object bean = nacosValueTarget.bean;
   Field field = nacosValueTarget.field;
   String fieldName = field.getName();
      field.set(bean, convertIfNecessary(field, propertyValue));
}

最后层层下去到了setField(),通过变更的key找到了对应bean,从spring env中得到了最新的值,然后通过反射将我们userController 的属性刷新

扩展

想通过 @Value 也能自动刷新,却没有用@NacosValue,这又是怎么做的?,可以将对@NacosValue 的扫描 改成@Value 来支持@Value的动态刷新。