Nacos Config深入+原理

Nacos Config深入+原理

Nacos之服务配置中心

基础配置

Nacos不仅仅可以作为注册中心来使用,同时它支持作为配置中心
在这里插入图片描述

首先我们还是新建Model:cloudalibaba-config-3377

pom文件

​ 这里我们主要要引入的是此依赖,这个依赖依据在官网上可以找到:https://spring-cloud-alibaba-group.github.io/github-pages/greenwich/spring-cloud-alibaba.html#_an_example_of_using_nacos_discovery_for_service_registrationdiscovery_and_call

<dependency> 
    <groupId> com.alibaba.cloud </groupId> 
    <artifactId> spring-cloud-starter-alibaba-nacos-config </artifactId> 
</dependency>

YML配置

​ 要注意的是这里我们要配置两个,因为Nacos同SpringCloud-config一样,在项目初始化时,要保证先从配置中心进行配置拉取,拉取配置之后,才能保证项目的正常启动。

​ springboot中配置文件的加载是存在优先级顺序的,bootstrap优先级高于application

​ 分别要配置的是,这里bootstrap.yml配置好了以后,作用是两个,第一个让3377这个服务注册到Nacos中,第二个作用就是去Nacos中去读取指定后缀为yaml的配置文件:

bootstrap.yml

# nacos配置
server:
  port: 3377

spring:
  application:
    name: nacos-config-client
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 #Nacos服务注册中心地址
      config:
        server-addr: localhost:8848 #Nacos作为配置中心地址
        file-extension: yaml #指定yaml格式的配置

application.yml

spring:
  profiles:
    active: dev # 表示开发环境

主启动

package com.mashibing.cloudalibabaconfig3377;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class CloudalibabaConfig3377Application {

    public static void main(String[] args) {
        SpringApplication.run(CloudalibabaConfig3377Application.class, args);
    }

}

业务类

​ 这里的@RefreshScope实现配置自动更新,意思为如果想要使配置文件中的配置修改后不用重启项目即生效,可以使用@RefreshScope配置来实现

package com.mashibing.cloudalibabaconfig3377.controller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RefreshScope //支持Nacos的动态刷新功能
public class ConfigClientController {

    @Value("${config.info}")
    private String configInfo;

    @Value("${/config/info}")
    public String getConfigInfo(){
        return configInfo;
    }

}

Nacos配置规则

​ 在 Nacos Spring Cloud 中,dataId 的完整格式如下(详情可以参考官网 https://nacos.io/zh-cn/docs/quick-start-spring-cloud.html):

${prefix}-${spring.profiles.active}.${file-extension}
1. `prefix` 默认为 `spring.application.name` 的值,也可以通过配置项 `spring.cloud.nacos.config.prefix`来配置。
2. `spring.profiles.active` 即为当前环境对应的 profile,注意:**当 `spring.profiles.active` 为空时,对应的连接符 `-` 也将不存在,dataId 的拼接格式变成 `${prefix}.${file-extension}`**(不能删除)
3. `file-exetension` 为配置内容的数据格式,可以通过配置项 `spring.cloud.nacos.config.file-extension` 来配置。目前只支持 `properties` 和 `yaml` 类型。
4. 通过 Spring Cloud 原生注解 `@RefreshScope` 实现配置自动更新:
5. 所以根据官方给出的规则我们最终需要在Nacos配置中心添加的配置文件的名字规则和名字为:
# ${spring.application.name}-${spring.profiles.active}.${file-extension}
# nacos-config-client-dev.yaml
# 微服务名称-当前环境-文件格式

在这里插入图片描述

Nacos平台创建配置操作

增加配置

在这里插入图片描述
在这里插入图片描述

config: 
    info: nacos config center,version = 1

然后在配置中心就会看到刚刚发布的配置

在这里插入图片描述

自动配置更新

修改Nacos配置,不需要重启项目即可自动刷新

在这里插入图片描述

修改版本号为2,点击发布

在这里插入图片描述

测试

启动服务访问服务来测试(没有修改之前是1,修改之后不需要重启项目既可以直接获取最新配置):http://localhost:3377/config/info

在这里插入图片描述

Nacos Config进阶

解决不同环境相同配置问题-自定义Data ID配置

在实际的开发过程中,我们的项目所用到的配置参数有的时候并不需要根据不同的环境进行区分,生产、测试、开发环境所用到的参数值是相同的。那么解决同一服务在多环境中,引用相同的配置的问题?Nacos Config也提供了相应的解决方案。

在这里插入图片描述

那么我们可以通过服务名+拓展名的方式,来实现同一个微服务下不同的环境,共享的配置文件。

具体配置

我们在Nacos Config中添加配置,data_id为configdemo.yaml
在这里插入图片描述

控制器代码更改

@RestController
@RefreshScope //支持Nacos动态刷新功能
public class ConfigController {

    @Value("${config.info}")
    private String configInfo;

    //通用
    @Value("${config.common}")
    private String configCommon;

    @GetMapping("/config/info")
    public String getConfigInfo(){
        return configInfo;
    }

    //通用
    @GetMapping("/config/common")
    public String getCommon(){
        return configCommon;
    }
}

测试可以直接读取:http://localhost:3377/config/common

在这里插入图片描述

不同微服务之间如何共享配置

当前这种配置方式是最基础的配置方式,但是在实际开发中骂我们一般会涉及到多个微服务之间共享配置。比如说redis地址,服务注册中心公共组件等等,那么这些组件是多个微服务共享的,所以我们可以使用Nacos Config提供的共享配置方式来配置共享的配置文件。

配置文件名字没有固定要求

在这里插入图片描述

通过shard-configs方式

具体实现

在nacos-config中添加 redis.yml,添加配置 redisip: 111.11.11.01
在这里插入图片描述

更改yml配置

spring:
  application:
    name: nacos-config-client
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 #Nacos服务注册中心地址
      config:
        server-addr: localhost:8848 #Nacos作为配置中心地址
        file-extension: yaml #指定yaml格式的配置
        shared-configs[0]:  #shared-configs是一个列表,可以添加多项
          data_id: redis.yml #具体配置
          group: DEFAULT_GROUP #默认可以不写
          refresh: true #是否开启自动刷新,默认为false,必须搭配@RefreshScope注解

更改测试类

@RestController
@RefreshScope //支持Nacos动态刷新功能
public class ConfigController {

    @Value("${config.info}")
    private String configInfo;

    //通用
    @Value("${config.common}")
    private String configCommon;

    @GetMapping("/config/info")
    public String getConfigInfo(){
        return configInfo;
    }

    //共享
    @Value("${redisip}")
    private String redisIp;

    //通用
    @GetMapping("/config/common")
    public String getCommon(){
        return configCommon;
    }

    //通用
    @GetMapping("/config/redisip")
    public String getRedisIp(){
        return redisIp;
    }
}

测试结果:

在这里插入图片描述

当然也支持多个配置,只需要在shared-configs[n],增加n的数值即可

cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 #Nacos服务注册中心地址
      config:
        server-addr: localhost:8848 #Nacos作为配置中心地址
        file-extension: yaml #指定yaml格式的配置
        shared-configs[0]:  #shared-configs是一个列表,可以添加多项
          data_id: redis.yml #具体配置
          group: DEFAULT_GROUP #默认可以不写
          refresh: true #是否开启自动刷新,默认为false,必须搭配@RefreshScope注解
        shared-configs[1]: #shared-configs是一个列表,可以添加多项
          data_id: common.yml #具体配置
          group: DEFAULT_GROUP #默认可以不写
          refresh: true #是否开启自动刷新,默认为false,必须搭配@RefreshScope注解

注意:多个 Data Id 同时配置时,他的优先级关系是 spring.cloud.nacos.config.extension-configs[n].data-id 其中 n 的值越大,优先级越高。

通过extension-configs方式

其实以上的实现还可以通过extension-configs方式来完成,其实作用基本一致,只不过语义上可以更好的区分,如果我们需要在一个微服务上配置多个配置文件,可以使用extension-configs,如果需要多个配置文件共享,可以使用shard-configs配置方式,当然其实两种方式所实现的效果和配置方法基本一致。

所以通过自定义扩展的 Data Id 配置,既可以解决多个应用间配置共享的问题,又可以支持一个应用有多个配置文件。

具体实现

更改yml,只需要将shared-configs改为extension-configs即可

spring:
  application:
    name: nacos-config-client
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848 #Nacos服务注册中心地址
      config:
        server-addr: localhost:8848 #Nacos作为配置中心地址
        file-extension: yaml #指定yaml格式的配置
        extension-configs[0]:  #shared-configs是一个列表,可以添加多项
          data_id: redis.yml #具体配置
          group: DEFAULT_GROUP #默认可以不写
          refresh: true #是否开启自动刷新,默认为false,必须搭配@RefreshScope注解
        extension-configs[1]: #shared-configs是一个列表,可以添加多项
          data_id: common.yml #具体配置
          group: DEFAULT_GROUP #默认可以不写
          refresh: true #是否开启自动刷新,默认为false,必须搭配@RefreshScope注解

整体配置优先级

Spring Cloud Alibaba Nacos Config 目前提供了三种配置能力从 Nacos 拉取相关的配置。

  • A: 通过 spring.cloud.nacos.config.shared-configs[n].data-id 支持多个共享 Data Id 的配置
  • B: 通过 spring.cloud.nacos.config.extension-configs[n].data-id 的方式支持多个扩展 Data Id 的配置
  • C: 通过内部相关规则(应用名、应用名+ Profile )自动生成相关的 Data Id 配置

当三种方式共同使用时,他们的一个优先级关系是:A < B < C

Nacos Config动态刷新原理

动态监听

所谓动态监听,简单理解就是指Nacos会自动找到那些服务已经注册,而对比来说静态监听,就是指需要有指定配置指定的服务。

其实在这里我们就要说一下客户端和服务端的交互方式,无非就是推和拉

  • Push:表示服务端主动将数据变更信息推送给客户端
    • 服务需要维持客户端的长连接,因为需要知道具体推送的客户端
    • 客户端耗费内存高,因为需要保存所有客户端的连接,并且需要检测连接有效性(心跳机制)
  • Pull:表示客户端主动去服务端拉取数据
    • 需要定时拉取数据
    • 缺点:时效性,数据实时性,无效请求

Nacos Config动态刷新机制

在这里插入图片描述

核心:Nacos动态刷新机制,采用推和拉的优点,避免缺点。

Nacos做配置中心的时候,配置数据的交互模式是有服务端push推送的,还是客户端pull拉取的?

Nacos客户端发送一个请求连接到服务端,然后服务端中会有一个29.5+0.5s的一个hold期,然后服务端会将此次请求放入到allSubs队列中等待,触发服务端返回结果的情况只有两种,第一种是时间等待了29.5秒,配置未发生改变,则返回未发生改变的配置;第二种是操作Nacos Dashboard或者API对配置文件发生一次变更,此时会触发配置变更的事件,发送一条LocalDataEvent消息,此时服务端监听到消息,然后遍历allSubs队列,根据对应的groupId找到配置变更的这条ClientLongPolling任务,并且通过连接返回给客户端

Nacos动态刷新避免了服务端对客户端进行push操作时需要保持双方的心跳连接,同样也避免了客户端对服务端进行pull操作时数据的时效性问题,不必频繁去拉去服务端的数据

通过上面原理的初步了解,显而易见,答案是:客户端主动拉取的,通长轮询的方式(Long Polling)的方式来获取配置数据。

短轮询

不管服务端的配置是否发生变化,不停发起请求去获取配置,比如支付订单场景中前端不断轮询订单支付的状态,这样的坏处显而易见,由于配置并不会频繁发生变更,如果是一直发请求,一定会对服务端造成很大的压力。还会造成数据推送的延迟,比如每10秒请求一次配置,如果在第11秒的时候配置更新,那么推送将会延迟9秒,等待下一次请求这就是短轮询,为了解决短轮询的问题,有了长轮询的方案

长轮询

长轮询不是什么新技术,它其实就是由服务端控制响应客户端请求结果的返回时间,来减少客户端无效请求的一种优化手段,其实对于客户端来说,长轮询的使用并没有本质上的区别,客户端发起请求后,服务端不会立即返回请求结果,而是将请求hold挂起一段时间,如果此时间段内配置数据发生变更,则立即响应客户端,若一直无变更则等到指定超时时间后响应给客户端结果,客户端重新发起长链接

Nacos Config动态刷新机制-源码分析-客户端

入口分析

分析的入口为NacosConfigBootstrapConfiguration,那么其实这个类型就是我们在写对应Nacos.config配置时候走的对应类型,从@ConditionalOnProperty注解中就可以看到,它对应加载的配置名称,就是我们在使用配置中心时候对应的配置,代表是否使用配置中心,默认为true

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(name = "spring.cloud.nacos.config.enabled", matchIfMissing = true)
public class NacosConfigBootstrapConfiguration {

	@Bean
	@ConditionalOnMissingBean
	public NacosConfigProperties nacosConfigProperties() {
		return new NacosConfigProperties();
	}

	@Bean
	@ConditionalOnMissingBean
	public NacosConfigManager nacosConfigManager(
			NacosConfigProperties nacosConfigProperties) {
		return new NacosConfigManager(nacosConfigProperties);
	}

	@Bean
	public NacosPropertySourceLocator nacosPropertySourceLocator(
			NacosConfigManager nacosConfigManager) {
		return new NacosPropertySourceLocator(nacosConfigManager);
	}

}

那么这里我们主要要关注的是nacosConfigManager这个类型,关注它的原因就是因为这其中创建了一个单例的ConfigService

public class NacosConfigManager {

	private static final Logger log = LoggerFactory.getLogger(NacosConfigManager.class);

	private static ConfigService service = null;

	private NacosConfigProperties nacosConfigProperties;

	public NacosConfigManager(NacosConfigProperties nacosConfigProperties) {
		this.nacosConfigProperties = nacosConfigProperties;
		// Compatible with older code in NacosConfigProperties,It will be deleted in the
		// future.
		createConfigService(nacosConfigProperties);
	}

	/**
	 * Compatible with old design,It will be perfected in the future.
	 */
    // 静态方法,单例创建 NacosConfigProperties为我们在微服务中配置的对应内容
	static ConfigService createConfigService(
			NacosConfigProperties nacosConfigProperties) {
		if (Objects.isNull(service)) {
			synchronized (NacosConfigManager.class) {
				try {
					if (Objects.isNull(service)) {
						service = NacosFactory.createConfigService(
								nacosConfigProperties.assembleConfigServiceProperties());
					}
				}
				catch (NacosException e) {
					log.error(e.getMessage());
					throw new NacosConnectionFailureException(
							nacosConfigProperties.getServerAddr(), e.getMessage(), e);
				}
			}
		}
		return service;
	}

	public ConfigService getConfigService() {
		if (Objects.isNull(service)) {
			createConfigService(this.nacosConfigProperties);
		}
		return service;
	}

	public NacosConfigProperties getNacosConfigProperties() {
		return nacosConfigProperties;
	}
}    

此时我们通过这里可以了解到,其实说白了就是在这个位置会创建一个ConfigService,那么我们可以继续跟踪发现,这个ConfigService其实是一个接口,那么它对应的实现类就是NacosConfigService,首先我们关注这个类型的初始化(构造方法)

当这个类型被实例化后,会做两件事

  1. 初始化一个HttpAgent,这里又用到了装饰器模式,实际工作的类是ServerHttpAgent, MetricsHttpAgent
  2. ClientWorker, 客户端的一个工作类,agent作为参数传入到clientworker,可以基本猜测到里面会用到agent做一些远程相关的操作
public NacosConfigService(Properties properties) throws NacosException {
    ValidatorUtils.checkInitParam(properties);
    String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE);
    if (StringUtils.isBlank(encodeTmp)) {
        this.encode = Constants.ENCODE;
    } else {
        this.encode = encodeTmp.trim();
    }
    initNamespace(properties);
	
    // 初始化网络通讯组件
    this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties));
    this.agent.start();
    // 核心点:ClientWorker
    this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties);
}

ClientWorker

既然找到了核心点ClientWorker,那么我们继续向下来看

第一个线程池是只拥有一个线程用来执行定时任务的 executor,executor 每隔 10ms 就会执行一次 checkConfigInfo() 方法,从方法名上可以知道是每 10 ms 检查一次配置信息。

第二个线程池是一个普通的线程池,从 ThreadFactory 的名称可以看到这个线程池是做长轮询的。

public ClientWorker(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager,
                    final Properties properties) {
    this.agent = agent;
    this.configFilterChainManager = configFilterChainManager;//初始化配置过滤器

    // Initialize the timeout parameter
	//初始化配置
    init(properties);

--------------------先看init方法--------------------------------
private void init(Properties properties) {
	// 超时时间CONFIG_LONG_POLL_TIMEOUT 30秒
    timeout = Math.max(ConvertUtils.toInt(properties.getProperty(PropertyKeyConst.CONFIG_LONG_POLL_TIMEOUT),
                                          Constants.CONFIG_LONG_POLL_TIMEOUT), Constants.MIN_CONFIG_LONG_POLL_TIMEOUT);

    taskPenaltyTime = ConvertUtils
        .toInt(properties.getProperty(PropertyKeyConst.CONFIG_RETRY_TIME), Constants.CONFIG_RETRY_TIME);

    this.enableRemoteSyncConfig = Boolean
        .parseBoolean(properties.getProperty(PropertyKeyConst.ENABLE_REMOTE_SYNC_CONFIG));
} 
    
    
----------------------------------------------------
    

    // 初始化一个定时调度的线程池,线程数量为1
    this.executor = Executors.newScheduledThreadPool(1, new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r);
            t.setName("com.alibaba.nacos.client.Worker." + agent.getName());
            t.setDaemon(true);
            return t;
        }
    });

    //初始化一个定时调度的线程池,从里面的name名字来看,似乎和长轮训有关系。
    this.executorService = Executors
        .newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {
            @Override
            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;
            }
        });

    // 设置定时任务的执行频率,并且调用checkConfigInfo这个方法
    // 首次执行延迟时间为1毫秒、延迟时间为10毫秒
    this.executor.scheduleWithFixedDelay(new Runnable() {
        @Override
        public void run() {
            try {
                checkConfigInfo();
            } catch (Throwable e) {
                LOGGER.error("[" + agent.getName() + "] [sub-check] rotate check error", e);
            }
        }
    }, 1L, 10L, TimeUnit.MILLISECONDS);
}

checkConfigInfo方法

ClientWorker构造初始化中,启动了一个定时任务去执行checkConfigInfo()方法,这个方法主要是定时检查本地配置和服务器上的配置的变更情况,这个方法定义如下.

public void checkConfigInfo() {
    // Dispatch taskes.
    // 监听的配置数量:key:DataID+Group拼接的值 value:是对应Nacos服务器上配置的文件内容
    // 储存监听变更的缓存集合
    int listenerSize = cacheMap.get().size();
    // Round up the longingTaskCount.
    // 向上取整,监听的配置数量除以3000,得到一个整数,代表长轮训任务的数量
    int longingTaskCount = (int) Math.ceil(listenerSize / ParamUtil.getPerTaskConfigSize());
    if (longingTaskCount > currentLongingTaskCount) {
        for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {
            // The task list is no order.So it maybe has issues when changing.
            executorService.execute(new LongPollingRunnable(i));
        }
        currentLongingTaskCount = longingTaskCount;
    }
}

cacheMap: AtomicReference<Map<String, CacheData>> cacheMap 用来存储监听变更的缓存集合。key是根据dataID/group得到的值。Value是对应存储在nacos服务器上的配置文件的内容。

默认情况下,每个长轮训LongPullingRunnable任务默认处理3000个监听配置集。如果超过3000, 则需要启动多个LongPollingRunnable去执行。

currentLongingTaskCount保存已启动的LongPullingRunnable任务数

executorService就是在ClientWorker构造方法中初始化的线程池

LongPollingRunnable.run()

LongPollingRunnable长轮训任务的实现逻辑,这里内容比较多,我们一点点分析

class LongPollingRunnable implements Runnable {
    
    private final int taskId;//表示当前任务批次id
    
    public LongPollingRunnable(int taskId) {
        this.taskId = taskId;
    }
    
    @Override
    public void run() {
        
        List<CacheData> cacheDatas = new ArrayList<CacheData>();
        List<String> inInitializingCacheList = new ArrayList<String>();
        try {
            // check failover config
            // 遍历CacheMap,把CacheMap中和当前任务id相同的缓存没保存到caheDates中
            // 通过checkLocalConfig方法
            for (CacheData cacheData : cacheMap.get().values()) {
                if (cacheData.getTaskId() == taskId) {
                    cacheDatas.add(cacheData);
                    try {
                    	// 检查本地配置
                        checkLocalConfig(cacheData);
                        if (cacheData.isUseLocalConfigInfo()) { //这里表示数据有变化,需要通知监听器
                            cacheData.checkListenerMd5(); //通知所有针对当前配置设置了监听的监听器
                        }
                    } catch (Exception e) {
                        LOGGER.error("get local config info error", e);
                    }
                }
            }
            
           。。。。。。
        } catch (Throwable e) {
            
            // If the rotation training task is abnormal, the next execution time of the task will be punished
            LOGGER.error("longPolling error : ", e);
            executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);
        }
    }
}

checkLocalConfig

检查本地配置,这里面有三种情况

  • 如果isUseLocalConfigInfo为false,表示不使用本地配置,但是本地缓存路径的文件是存在的,于是把isUseLocalConfigInfo设置为true,并且更新cacheData的内容以及文件的更新时间
  • 如果isUseLocalConfigInfo为true,表示使用本地配置文件,但是本地缓存文件不存在,则设置为false,不通知监听器。
  • 如果isUseLocalConfigInfo为true,并且本地缓存文件也存在,但是缓存的的时间和文件的更新时间不一致,则更新cacheData中的内容,并且isUseLocalConfigInfo设置为true。
private void checkLocalConfig(CacheData cacheData) {
    final String dataId = cacheData.dataId;
    final String group = cacheData.group;
    final String tenant = cacheData.tenant;
    File path = LocalConfigInfoProcessor.getFailoverFile(agent.getName(), dataId, group, tenant);
    // 没有本地文件->有
    if (!cacheData.isUseLocalConfigInfo() && path.exists()) {
        String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
        final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
        cacheData.setUseLocalConfigInfo(true);
        cacheData.setLocalConfigInfoVersion(path.lastModified());
        cacheData.setContent(content);	
        
        LOGGER.warn(
                "[{}] [failover-change] failover file created. dataId={}, group={}, tenant={}, md5={}, content={}",
                agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content));
        return;
    }
    
    // If use local config info, then it doesn't notify business listener and notify after getting from server.
    // 有本地文件->没有,不会通知业务监听器,从server拿到配置后通知。
    if (cacheData.isUseLocalConfigInfo() && !path.exists()) {
        cacheData.setUseLocalConfigInfo(false);
        LOGGER.warn("[{}] [failover-change] failover file deleted. dataId={}, group={}, tenant={}", agent.getName(),
                dataId, group, tenant);
        return;
    }
    
    // When it changed.
    // 发生变更时
    if (cacheData.isUseLocalConfigInfo() && path.exists() && cacheData.getLocalConfigInfoVersion() != path
            .lastModified()) {
        String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
        final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
        cacheData.setUseLocalConfigInfo(true);
        cacheData.setLocalConfigInfoVersion(path.lastModified());
        cacheData.setContent(content);
        LOGGER.warn(
                "[{}] [failover-change] failover file changed. dataId={}, group={}, tenant={}, md5={}, content={}",
                agent.getName(), dataId, group, tenant, md5, ContentUtils.truncateContent(content));
    }
}

checkListenerMd5()

遍历用户自己添加的监听器,如果发现MD5值不同,则发送通知

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

检测服务端配置

在LongPollingRunnable.run中,先通过本地配置的读取和检查来判断数据是否发生变化从而实现变化的通知,然后当前的线程还需要去远程服务器上获得最新的数据,检查哪些数据发生了变化

// check server config
// 从服务端获取发生变化的数据DateID列表,保存在List<String>集合中
List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);
if (!CollectionUtils.isEmpty(changedGroupKeys)) {
    LOGGER.info("get changedGroupKeys:" + changedGroupKeys);
}

//遍历发生了变更的配置项
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];
    }
    try {
        //逐项根据这些配置项获取配置信息
        String[] ct = getServerConfig(dataId, group, tenant, 3000L);
        //把配置信息保存到CacheData
        CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant));
        cache.setContent(ct[0]);
        if (null != ct[1]) {
            cache.setType(ct[1]);
        }
        LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, content={}, type={}",
                    agent.getName(), dataId, group, tenant, cache.getMd5(),
                    ContentUtils.truncateContent(ct[0]), ct[1]);
    } catch (NacosException ioe) {
        String message = String
            .format("[%s] [get-update] get changed config exception. dataId=%s, group=%s, tenant=%s",
                    agent.getName(), dataId, group, tenant);
        LOGGER.error(message, ioe);
    }
}
//再遍历CacheData这个集合,找到发生变化的数据进行通知
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);
checkUpdateDataIds()
  • 这个方法从cacheDatas集合中找到isUseLocalConfigInfo为false的

  • 把需要检查的配置项,拼接成一个字符串,调用checkUpdateConfigStr进行验证

List<String> checkUpdateDataIds(List<CacheData> cacheDatas, List<String> inInitializingCacheList) throws Exception {
    StringBuilder sb = new StringBuilder();
    for (CacheData cacheData : cacheDatas) {
        if (!cacheData.isUseLocalConfigInfo()) {
            sb.append(cacheData.dataId).append(WORD_SEPARATOR);
            sb.append(cacheData.group).append(WORD_SEPARATOR);
            if (StringUtils.isBlank(cacheData.tenant)) {
                sb.append(cacheData.getMd5()).append(LINE_SEPARATOR);
            } else {
                sb.append(cacheData.getMd5()).append(WORD_SEPARATOR);
                sb.append(cacheData.getTenant()).append(LINE_SEPARATOR);
            }
            if (cacheData.isInitializing()) {
                // It updates when cacheData occours in cacheMap by first time.
                // 当cacheData首次出现在cacheMap中首次更新
                inInitializingCacheList
                        .add(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant));
            }
        }
    }
    boolean isInitializingCacheList = !inInitializingCacheList.isEmpty();
    // 效验是否需要更新
    return checkUpdateConfigStr(sb.toString(), isInitializingCacheList);
}
checkUpdateConfigStr()

效验更新和发起远程调用方法,并且更新数据

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);

    // told server do not hang me up if new initializing cacheData added in
    // 第一次获取,直接更新
    if (isInitializingCacheList) {
        headers.put("Long-Pulling-Timeout-No-Hangup", "true");
    }
	
    // 判断拼接的字符串如果为空,直接返回
    if (StringUtils.isBlank(probeUpdateString)) {
        return Collections.emptyList();
    }

    try {
        // In order to prevent the server from handling the delay of the client's long task,
        // increase the client's read timeout to avoid this problem.
		// 设置readTimeoutMs,也就是本次请求等待响应的超时时间,默认是30s
        long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
        // 发起远程调用
        HttpRestResult<String> result = agent
            .httpPost(Constants.CONFIG_CONTROLLER_PATH + "/listener", headers, params, agent.getEncode(),
                      readTimeoutMs);

        if (result.ok()) {//响应成功
            setHealthServer(true);
            //解析并且更新数据
            return parseUpdateDataIdResponse(result.getData());
        } else {//响应失败
            setHealthServer(false);
            LOGGER.error("[{}] [check-update] get changed dataId error, code: {}", agent.getName(),
                         result.getCode());
        }
    } catch (Exception e) {
        setHealthServer(false);
        LOGGER.error("[" + agent.getName() + "] [check-update] get changed dataId exception", e);
        throw e;
    }
    return Collections.emptyList();
}

客户端长轮询机制总结

  1. 对本地缓存的配置做任务拆分,每一个批次最多3000条
  2. 每批次创建一个线程去执行
  3. 把每一个批次的缓存和本地磁盘文件中的数据进行比较
    1. 如果和本地配置不一致,则表示该缓存发生了更新,直接通知客户端监听
    2. 如果本地缓存和磁盘数据一致,则需要发起远程请求检查配置变化
  4. 以tenent/groupId/dataId拼接成字符串,发送到服务端进行检查,返回发生了变更的配置
  5. 客户端收到变更配置列表,更新本地配置。

Nacos Config动态刷新机制-源码分析-服务端

分析完客户端以后,我们来分析服务端,那么现在服务端这面的入口我们就需要从客户端访问的接口来进行分析

 HttpRestResult<String> result = agent
            .httpPost(Constants.CONFIG_CONTROLLER_PATH + "/listener", headers, params, agent.getEncode(),readTimeoutMs);

其实这个接口的地址为/v1/cs/configs/listener,所以我们直接找到这个接口来查看 ConfigController

@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)) {
        LOGGER.warn("invalid probeModify is blank");
        throw new IllegalArgumentException("invalid probeModify");
    }

    probeModify = URLDecoder.decode(probeModify, Constants.ENCODE);

    Map<String, String> clientMd5Map;
    try {
        //解析客户端传递过来的可能发生变化的配置项目,转换完Map集合(key=DataID,value=md5)
        clientMd5Map = MD5Util.getClientMd5Map(probeModify);
    } catch (Throwable e) {
        throw new IllegalArgumentException("invalid probeModify");
    }

    // do long-polling
    // 开始长轮询
    inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());
}

doPollingConfig()

首先这个方法主要是用来做长轮询和短轮训的判断的

// 判断当前请求是否是长轮询,通过判断请求头是否有超时时间标记
if (LongPollingService.isSupportLongPolling(request)) {
    longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
    return HttpServletResponse.SC_OK + "";
}

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");
    // 延期时间,默认为500ms
    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);
    if (isFixedPolling()) {
        timeout = Math.max(10000, getFixedPollingInterval());
        // Do nothing but set fix polling timeout.
    } else {
        long start = System.currentTimeMillis();
        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;
        }
    }
    // 获取客户端ip
    String ip = RequestUtil.getRemoteIp(req);

    // Must be called by http thread, or send response.
    // 把当前请求转换称为一个异步请求(意味着此时Tomcat线程被释放,最后需要asyncContext来手动完成响应)
    final AsyncContext asyncContext = req.startAsync();

    // AsyncContext.setTimeout() is incorrect, Control by oneself
    asyncContext.setTimeout(0L);
    // 执行长轮询请求
    ConfigExecutor.executeLongPolling(
        new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}

ClientLongPolling()

这里具体执行了什么那,其实我们可以按照之前讲的原理来分析一下

  • 这个任务要阻塞29.5s才能执行,因为立刻执行就没有意义了
  • 如果在29.5s之内,数据发生变化,需要提前通知,需要有一个监控机制

这里就是证明阻塞29.5s执行

class ClientLongPolling implements Runnable {

    @Override
    public void run() {
        // 构建一个异步任务,延后29.5s执行 timeoutTime29.5
        asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() {
            @Override
            public void run() {//如果达到29.5s,说明这个期间没有做任何配置的修改,则自行触发执行
                try {
                    getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());

                    // Delete subscriber's relations.
                    boolean removeFlag = allSubs.remove(ClientLongPolling.this);//移除订阅关系

                    if (removeFlag) {
                        if (isFixedPolling()) {
                            LogUtil.CLIENT_LOG
                                .info("{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - createTime), "fix",
                                      RequestUtil.getRemoteIp((HttpServletRequest) asyncContext.getRequest()),
                                      "polling", clientMd5Map.size(), probeRequestSize);
                            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);
                        }
                    } else {
                        LogUtil.DEFAULT_LOG.warn("client subsciber's relations delete fail.");
                    }
                } catch (Throwable t) {
                    LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause());
                }

            }

        }, timeoutTime, TimeUnit.MILLISECONDS);

        allSubs.add(this);//把当前线程添加到订阅时间队列中
    }

allSubs

之前我们在讲原理的时候就提供这个队列,那么现在我们也看到这个队列了,那么这个队列缓存了客户端和服务的长轮询

当用户在nacos 控制台修改了配置之后,必须要从这个订阅关系中取出对应的客户端长连接,然后把变更的结果返回。于是我们去看LongPollingService的构造方法查找订阅关系

/**
* ClientLongPolling subscibers.
* 长轮询订阅关系
*/
final Queue<ClientLongPolling> allSubs;

LongPollingService构造方法

在LongPollingService的构造方法中,使用了一个NotifyCenter订阅了一个事件,事件的实例为LocalDataChangeEvent也就是服务端数据发生变更的事件,就会执行一个DataChangeTask的线程。

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.
    // 注册LocalDataChangeEvent订阅时间
    NotifyCenter.registerSubscriber(new Subscriber() {
        
        @Override
        public void onEvent(Event event) {
            if (isFixedPolling()) {
                // Ignore.
            } else {
                // 如果出发了LocalDataChangeEvent,则执行下面代码
                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

此任务会立刻响应客户端,完成数据同步

如果 DataChangeTask 任务完成了数据的 “推送” 之后,ClientLongPolling 中的调度任务又开始执行了怎么办呢?
很简单,只要在进行 “推送” 操作之前,先将原来等待执行的调度任务取消掉就可以了,这样就防止了推送操作写完响应数据之后,调度任务又去写响应数据,这时肯定会报错的。所以,在ClientLongPolling方法中,最开始的一个步骤就是删除订阅事件

class DataChangeTask implements Runnable {

    @Override
    public void run() {
        try {
            ConfigCacheService.getContentBetaMd5(groupKey);
            // 遍历所有订阅事件
            for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
                // 得到ClientLongPolling
                ClientLongPolling clientSub = iter.next();
                //判断当前的ClientLongPolling中,请求的key是否包含当前修改的groupKey
                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(); // Delete subscribers' relationships.
                    LogUtil.CLIENT_LOG
                        .info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance",
                              RequestUtil
                              .getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()),
                              "polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey);
                    // 响应客户端
                    clientSub.sendResponse(Arrays.asList(groupKey));
                }
            }

        } catch (Throwable t) {
            LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));
        }
    }

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(); // Delete subscribers' relationships.
                LogUtil.CLIENT_LOG
                    .info("{}|{}|{}|{}|{}|{}|{}", (System.currentTimeMillis() - changeTime), "in-advance",
                          RequestUtil
                          .getRemoteIp((HttpServletRequest) clientSub.asyncContext.getRequest()),
                          "polling", clientSub.clientMd5Map.size(), clientSub.probeRequestSize, groupKey);
                // 响应客户端
                clientSub.sendResponse(Arrays.asList(groupKey));
            }
        }

    } catch (Throwable t) {
        LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));
    }
}

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值