在上文中介绍了nacos的基本使用,在本文中将对nacos进行更深一步的介绍。
在生产环境,为了保证服务的稳定,我们一般采用集群的部署方式,在上一篇文章中也介绍了集群的部署方式,那么对于nacos集群之间是如何进行数据同步的呢?通过前文中nacos集群部署的方式可以看出,nacos没有主节点与从节点之分,在nacos集群中每个节点的权重是一样的,都可以进行读写。
nacos是由配置中心和注册/与发现中心两部分功能组成。对于配置中心,nacos使用的cp,是强一致性,咱们不多做介绍;对于注册/发现中心,nacos使用的ap,采用的是阿里自研的distro协议来实现,下面对distro协议进行进一步的介绍。
ap我们知道他是最终一致性,那么distro协议是如何进行保证最终一致性的呢?主要是下面的四个方面来进行的。
该场景指的是,当前distro集群有正在运行的服务节点,在此时再新增一个distro服务节点,新增的distro服务节点是如何进行数据同步呢?
各个distro服务节点之间是进行相互通信的,当新增一个distro服务节点以后,它会轮询拉取当前distro服务集群中其它的所有服务节点,并且进行全量拉取,然后将注册表信息缓存到本地,并且会进行对应的服务注册。
distro服务节点之间每5s会发送一次心跳校验,校验信息不是注册表信息而是元数据信息,如果采用注册表信息,会导致心跳包中的请求数据太大,因此心跳校验中的请求数据为元数据信息。在校验过程中发现当前心跳请求数据与本地缓存数据不一致的时候,那么该服务器会触发一次全量拉取操作,来进行数据同步。
当客户端发出写操作以后,它不会直接链接distro服务节点进行写操作,它会先到一个一个前置的filter中,filter根据ip+port进行计算,找到其所属的distro服务节点,然后通过路由转发到对应的filter上,然后再调用对应的distro服务节点进行写操作,该写操作只要保证在该节点写完成即可,不需要等待同步写到其它distro服务节点。进行写操作的服务节点会定期将该改信息同步到其它服务节点,通过增量的方式。
filter的路由转发是distro服务协议实现的一个核心点,它保证客户端的所属distro服务节点是不变的,每次该客户端进行读写操作的时候,所请求的distro服务都是同一个。
读操作与写操作其实是类似的,都是先到前置filter,然后根据ip+port进行计算,然后路由到其所属的distro服务节点,上面的介绍中说过nacos集群是ap,所以直接读取该distro服务的本地缓存,然后返回。ap保证的是最终一致性,通过上面说过的数据校验心跳机制,可以进行保证。
下面看一下集群数据同步的部分源码,只有入口和一些关键步骤的源码,有兴趣的同学可以自己好好看一下。
下面为开启数据验证以及初始化数据的部分源码:
// 入口 com.alibaba.nacos.core.distributed.distro.DistroProtocol
// 在入口类的构造方法中,调用了startDistroTask()方法,接下来我们看一下这个方法
/**
*该方法主要是开始两个任务,一个是验证任务,一个是初始化任务
*/
private void startDistroTask() {
if (EnvUtil.getStandaloneMode()) {
isInitialized = true;
return;
}
// 验证任务
startVerifyTask();
// 初始化任务
startLoadTask();
}
/**
* 开启数据验证的定时任务,每5s发起一次
* DEFAULT_DATA_VERIFY_INTERVAL_MILLISECONDS = 5000L;
*/
private void startVerifyTask() {
GlobalExecutor.schedulePartitionDataTimedSync(new DistroVerifyTimedTask(memberManager, distroComponentHolder,
distroTaskEngineHolder.getExecuteWorkersManager()),
DistroConfig.getInstance().getVerifyIntervalMillis());
}
/**
* 初始化任务,通过线程池执行任务,并传入一个回调函数,用来标识是否完成
*/
private void startLoadTask() {
DistroCallback loadCallback = new DistroCallback() {
@Override
public void onSuccess() {
isInitialized = true;
}
@Override
public void onFailed(Throwable throwable) {
isInitialized = false;
}
};
GlobalExecutor.submitLoadDataTask(
new DistroLoadDataTask(memberManager, distroComponentHolder, DistroConfig.getInstance(), loadCallback));
}
// 接下来再看一下DistroLoadDataTask这个类的实现,在它的run方法中主要是执行了一个load方法
// 我们直接看一下这个load()方法
// 这个方法主要是进行了一些条件的判断,需要注意它使用的while循环操作,如果前面的天剑一直满足
// 则会进入死循环中,因此必须打破前面的两个while条件才会进入最终的数据初始化任务
private void load() throws Exception {
// 除了自身节点以外没有其它节点,则休眠1s
while (memberManager.allMembersWithoutSelf().isEmpty()) {
Loggers.DISTRO.info("[DISTRO-INIT] waiting server list init...");
TimeUnit.SECONDS.sleep(1);
}
// 若数据类型为空,说明distroComponent的组件注册还未初始化完毕
while (distroComponentHolder.getDataStorageTypes().isEmpty()) {
Loggers.DISTRO.info("[DISTRO-INIT] waiting distro data storage register...");
TimeUnit.SECONDS.sleep(1);
}
// 加载每个类型的数据
for (String each : distroComponentHolder.getDataStorageTypes()) {
if (!loadCompletedMap.containsKey(each) || !loadCompletedMap.get(each)) {
// 调用加载方法,标记已处理
loadCompletedMap.put(each, loadAllDataSnapshotFromRemote(each));
}
}
}
// 本方法就是具体的去拉取各个distro服务节点,并更新数据的方法
private boolean loadAllDataSnapshotFromRemote(String resourceType) {
// 获取数传输对象
DistroTransportAgent transportAgent = distroComponentHolder.findTransportAgent(resourceType);
// 获取数据处理器
DistroDataProcessor dataProcessor = distroComponentHolder.findDataProcessor(resourceType);
if (null == transportAgent || null == dataProcessor) {
Loggers.DISTRO.warn("[DISTRO-INIT] Can't find component for type {}, transportAgent: {}, dataProcessor: {}",
resourceType, transportAgent, dataProcessor);
return false;
}
// 向每个节点请求数据
for (Member each : memberManager.allMembersWithoutSelf()) {
try {
Loggers.DISTRO.info("[DISTRO-INIT] load snapshot {} from {}", resourceType, each.getAddress());
// 获取数据
DistroData distroData = transportAgent.getDatumSnapshot(each.getAddress());
// 解析数据并且更新数据
boolean result = dataProcessor.processSnapshot(distroData);
Loggers.DISTRO
.info("[DISTRO-INIT] load snapshot {} from {} result: {}", resourceType, each.getAddress(),
result);
// 如果解析成功,标记此类型数据已经加载完毕
if (result) {
distroComponentHolder.findDataStorage(resourceType).finishInitial();
return true;
}
} catch (Exception e) {
Loggers.DISTRO.error("[DISTRO-INIT] load snapshot {} from {} failed.", resourceType, each.getAddress(), e);
}
}
return false;
}
下面为增量数据同步的源码
// 入口为com.alibaba.nacos.naming.consistency.ephemeral.distro.v2.DistroClientDataProcessor
// 增量数据同步是采用发布订阅的方式进行的数据同步
// 主要关注下面的客户端变更事件即可
@Override
public List> subscribeTypes() {
List> result = new LinkedList<>();
// 客户端变更的时事件
result.add(ClientEvent.ClientChangedEvent.class);
// 客户端断开的事件
result.add(ClientEvent.ClientDisconnectEvent.class);
// 服务验证失败的事件
result.add(ClientEvent.ClientVerifyFailedEvent.class);
return result;
}
// 当事件触发的时候,会调用该类的onEvent()方法
@Override
public void onEvent(Event event) {
if (EnvUtil.getStandaloneMode()) {
return;
}
if (!upgradeJudgement.isUseGrpcFeatures()) {
return;
}
if (event instanceof ClientEvent.ClientVerifyFailedEvent) {
syncToVerifyFailedServer((ClientEvent.ClientVerifyFailedEvent) event);
} else {
// 将该事件同步到其它distro服务节点,
// 延迟1s进行同步,DEFAULT_DATA_SYNC_DELAY_MILLISECONDS = 1000L
syncToAllServer((ClientEvent) event);
}
}
在上一篇文章中对nacos作为配置中心时的简单使用做了一个基本介绍,本文再介绍两种进阶使用。
进阶使用主要是针对以下两种场景进行的配置:
第一种场景是,针对一些配置无论是在dev,test还是prod环境,他们的配置都是相同的,针对这种配置就没有必要在各个环境都配置一遍,可以采用公共的配置文件来配置,可以通过在配置文件中添加以下配置来实现
spring:
cloud:
nacos:
config:
server-addr: 172.30.10.103:8848
file-extension: yaml
namespace: 4b57e563-2039-42f4-86b1-9c4c7cf58bfc
# 公共配置文件,可以配置多个
shared-configs[0]:
# 公共配置文件名称
dataId: file.yaml
# 公共配置文件所属组
group: DEFAULT_GROUP
# 公共配置文件是否刷新
refresh: true
第二种场景是,在一个项目中为了对配置文件负责内容进行区分,需要使用多个配置文件,例如订单相关配置,用户相关配置,可以通过下面的配置实现
spring:
cloud:
nacos:
config:
server-addr: 172.30.10.103:8848
file-extension: yaml
namespace: 4b57e563-2039-42f4-86b1-9c4c7cf58bfc
# 扩展配置文件,允许配置多个
extension-configs[0]:
# 扩展配置文件名称
dataId: order.yaml
# 扩展配置文件所属组
group: DEFAULT_GROUP
# 扩展配置文件动态刷新
refresh: true
extension-configs[1]:
dataId: user.yaml
group: DEFAULT_GROUP
refresh: true
我们都知道,客户端能够从配置中心动态获取到相关配置,无非就是由客户端主动拉取或者是服务端主动推送这两种方式,那么nscos的客户端从服务端获取配置是采用的拉还是推呢?
nacos客户端是通过拉的方式来获取动态配置,客户端通过与服务端建立长轮询的方式,在长轮询建立期间,如果服务端的配置发生变更,则告诉客户端配置发生变更,然后客户端再主动发起请求,获取配置的具体内容;如果未发生变更,则返回空。大致流程看一下下面的流程图:
从上图中我们可以看到,客户端会和服务端建立长链接,长链接进行响应的情况有两种:一种是超过29.5s等待期;一种是配置内容发生变更。关于里面的超时时间,等待时间,响应时间等相关内容,我们会在源码分析中体现。通过上面这个图,我们对nacos配置中心的动态配置获取原理有了一定的了解,那么接下来我们简单的看一下源码,如果有兴趣的同学可以详细了解。源码内容分为两部分内容,一部分为客户端建立长链接获取配置更新配置,一部分为服务端接收配置变更并响应客户端。
2.1客户端源码(nacos-config-2.2.5.release, nacos-client-1.4.1)
下面的源码主要介绍了客户端源码的入口,以及引出ClientWorker这个重要的类。
// 入口为com.alibaba.cloud.nacos.NacosConfigBootstrapConfiguration
// 通过jar包进入,这个类创建了三个bean,我们主要关注的是第二个bean:NacosConfigManager
// 通过查看该类的构造方法,发现其调用了createConfigService方法,在这个方法中通过工厂的方式创建
// service,最终是通过反射的方式创建:
// Class> driverImplClass = Class.forName("com.alibaba.nacos.client.config.NacosConfigService")
// 我们的源码从NacosConfigService的构造方法开始
public NacosConfigService(Properties properties) throws NacosException {
// 参数校验
ValidatorUtils.checkInitParam(properties);
String encodeTmp = properties.getProperty("encode");
if (StringUtils.isBlank(encodeTmp)) {
this.encode = "UTF-8";
} else {
this.encode = encodeTmp.trim();
}
// 根据配置文件获取namespace
this.initNamespace(properties);
this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties));
this.agent.start();
// 该构造方法的主要目的就是创建ClientWorker对象,所有的相关操作都是在该对象中实现的
this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties);
}
在ClientWorker这个类的构造方法中,进行了一些关键参数的初始化,就包含了超时时间,已经创建了两个定时线程池,并启动了配置检查的任务。
public ClientWorker(final HttpAgent agent, ConfigFilterChainManager configFilterChainManager, Properties properties) {
this.agent = agent;
this.configFilterChainManager = configFilterChainManager;
// 初始化参数
this.init(properties);
// 创建第一个线程池,用于启动配置检查任务
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;
}
});
// 启动第一个定时任务线程池,用于检查配置,通过线程池参数可以发现,该任务每10s执行一次
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);
}
// 根据配置信息,初始化相关参数
private void init(Properties properties) {
// 超时时间,properties这个参数是根据配置文件生成的,如果没有配置超时时间,我们可以发现,超时时间为30s
this.timeout = (long)Math.max(ConvertUtils.toInt(properties.getProperty("configLongPollTimeout"), 30000), 10000);
this.taskPenaltyTime = ConvertUtils.toInt(properties.getProperty("configRetryTime"), 2000);
this.enableRemoteSyncConfig = Boolean.parseBoolean(properties.getProperty("enableRemoteSyncConfig"));
}
接下来就是checkConfigInfo方法,在这个方法中主要就是进行长轮询任务的分组,然后通过上面创建的executorService线程池执行长轮询任务,LongPollingRunnable,我们来直接查看这个长轮询任务,即它的run方法。在这段代码中,主要做了三件事情:第一步,检查本地配置,并根据不同的情况进行相关赋值;第二步,与服务端建立长链接,获取有变更的配置;第三步,在上一步中返回的变更信息并不是配置内容,而是有变更的dataId+group+teanant相关信息,这一步就是根据这些信息调用服务端获取具体的配置内容并进行本地更新:
public void run() {
List cacheDatas = new ArrayList();
ArrayList inInitializingCacheList = new ArrayList();
try {
// cacheMap即为缓存的配置信息
Iterator var3 = ClientWorker.this.cacheMap.values().iterator();
// 第一个for循环,比较本地配置
while(var3.hasNext()) {
CacheData cacheData = (CacheData)var3.next();
if (cacheData.getTaskId() == this.taskId) {
cacheDatas.add(cacheData);
try {
// 第一步,检查本地配置,并根据配置的不同信息,进行相关赋值
ClientWorker.this.checkLocalConfig(cacheData);
if (cacheData.isUseLocalConfigInfo()) {
cacheData.checkListenerMd5();
}
} catch (Exception var13) {
ClientWorker.LOGGER.error("get local config info error", var13);
}
}
}
// 第二步 与服务端建立长轮询,获取变更的配置信息
List changedGroupKeys = ClientWorker.this.checkUpdateDataIds(cacheDatas, inInitializingCacheList);
if (!CollectionUtils.isEmpty(changedGroupKeys)) {
ClientWorker.LOGGER.info("get changedGroupKeys:" + changedGroupKeys);
}
Iterator var16 = changedGroupKeys.iterator();
// 第二个for循环,根据上面获取到的变更的配置信息集合,更新本地配置
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 {
// 第三步 调用服务端获取变更的配置信息,根据dataId, group以及tenant,调用地址为/v1/cs/configs
String[] ct = ClientWorker.this.getServerConfig(dataId, group, tenant, 3000L);
CacheData cache = (CacheData)ClientWorker.this.cacheMap.get(GroupKey.getKeyTenant(dataId, group, tenant));
cache.setContent(ct[0]);
if (null != ct[1]) {
cache.setType(ct[1]);
}
ClientWorker.LOGGER.info("[{}] [data-received] dataId={}, group={}, tenant={}, md5={}, content={}, type={}", new Object[]{ClientWorker.this.agent.getName(), dataId, group, tenant, cache.getMd5(), ContentUtils.truncateContent(ct[0]), ct[1]});
} 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();
cacheDatax.setInitializing(false);
}
} catch (Throwable var14) {
ClientWorker.LOGGER.error("longPolling error : ", var14);
ClientWorker.this.executorService.schedule(this, (long)ClientWorker.this.taskPenaltyTime, TimeUnit.MILLISECONDS);
}
}
接下来看一下第二步中,与服务端建立长链接的内容
// 在checkUpdateDataIds里面主要是组装了调用服务端时的请求信息,
// 每个配置文件对应的相关信息为dataId++group++md5+(+teaant)
// 组装完请求参数以后,调用checkUpdateConfigStr方法,接下来我们看一下相关代码
List checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws Exception {
// 设置请求参数以及请求头
Map params = new HashMap(2);
params.put("Listening-Configs", probeUpdateString);
Map headers = new HashMap(2);
// 超时时间在前面的源码中说过,默认为30s
headers.put("Long-Pulling-Timeout", "" + this.timeout);
// 如果是第一次请求,不需要进行挂起
if (isInitializingCacheList) {
headers.put("Long-Pulling-Timeout-No-Hangup", "true");
}
if (StringUtils.isBlank(probeUpdateString)) {
return Collections.emptyList();
} else {
try {
long readTimeoutMs = this.timeout + (long)Math.round((float)(this.timeout >> 1));
// 这里的agent就是在最开始的NcaosConfigService的构造方法中创建的agent
// 开始远程调用服务端的服务,记住这个地址,后面查看服务端源码的时候,即从这个地址入手
HttpRestResult result = this.agent.httpPost("/v1/cs/configs/listener", headers, params, this.agent.getEncode(), readTimeoutMs);
if (result.ok()) {
this.setHealthServer(true);
// 格式化响应信息
return this.parseUpdateDataIdResponse((String)result.getData());
}
this.setHealthServer(false);
LOGGER.error("[{}] [check-update] get changed dataId error, code: {}", this.agent.getName(), result.getCode());
} catch (Exception var8) {
this.setHealthServer(false);
LOGGER.error("[" + this.agent.getName() + "] [check-update] get changed dataId exception", var8);
throw var8;
}
return Collections.emptyList();
}
}
客户端的源码就分析到这里,如果想进一步了解里面的具体实现,可以按照上面这个顺序,详细去查看。
2.2 服务端代码(2.1.0)
在上面客户端源码分析的过程中我们知道,客户端与服务端建立轮询的地址为:/v1/cs/configs/listener,协议为post,那么我们在服务端去找到对应的位置,在这个方法中我们主要关注inner.doPollingConfig方法:
// com.alibaba.nacos.config.server.controller.ConfigController的listener方法
// 然后再改方法中,我们关注的重点是doPollingConfig方法调用
public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
Map clientMd5Map, int probeRequestSize) throws IOException {
.
// 判断当前请求是否为长轮询,通过对客户端源码的分析,这里走的是长轮询的机制
if (LongPollingService.isSupportLongPolling(request)) {
longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
return HttpServletResponse.SC_OK + "";
}
...
}
通过对上面代码的分析,我们知道要去走到长轮询的分支中,在该分支中主要做了三件事情:1.获取超时时间;2.将同步请求转换为异步请求,降低服务端的同步请求数量;3.开始执行长轮询
public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map 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);
// 提前500s返回一个响应,避免客户端出现超时,超时时间计算为29.5s
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 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);
// 把当前请求转换为一个异步请求(意味着tomcat线程被释放,最后需要asyncContext来手动完成响应)
final AsyncContext asyncContext = req.startAsync();
asyncContext.setTimeout(0L);
// 开始执行长轮询,通过线程池创建执行任务,线程池类型为SingleScheduledExecutorService
ConfigExecutor.executeLongPolling(
new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
}
接下来我们需要关注的就是这个长轮询任务,它是由线程池执行,接下来我们要去关注ClientLongPolling的run()方法,在该方法中主要分为两部分,一部分是延迟29.5秒以后开始进行长链接响应;一部分是将长链接放入allsubs。
public void run() {
// 构建一个异步任务,延后29.5s执行,如果达到29.5秒以后没有做任何配置的修改,则自行触发执行,即进行长链接响应
asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(() -> {
try {
getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());
// 获取删除标识,该标识是一个防止重复响应的标识,在前面讲述原理的时候说过,
// 长链接的响应有两种情况,一种是超时响应,一种是变更响应,就是使用该标识进行判断
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 changedGroups = MD5Util
.compareMd5((HttpServletRequest) asyncContext.getRequest(),
(HttpServletResponse) asyncContext.getResponse(), clientMd5Map);
if (changedGroups.size() > 0) {
sendResponse(changedGroups);
} else {
// 没有变更,返回null
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);
}
以上就是在原理讲述中,关于超时响应的源码信息,那么关于操作响应的源码在哪里呢?现在让我们转到LongPollingService的构造方法中,在该注册方法中注册了订阅事件,用来监听数据变化
public LongPollingService() {
allSubs = new ConcurrentLinkedQueue<>();
ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);
NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);
// 注册订阅事件,用来监听配置变化
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,找到其中的run方法,该方法就是操作响应的源码:
public void run() {
try {
ConfigCacheService.getContentBetaMd5(groupKey);
// 遍历所有客户端建立的获取配置变化信息的长轮询
for (Iterator iter = allSubs.iterator(); iter.hasNext(); ) {
ClientLongPolling clientSub = iter.next();
// 判断当前的ClientLongPolling中,请求的key是否包含当前修改的groupkey
if (clientSub.clientMd5Map.containsKey(groupKey)) {
if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) {
continue;
}
if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {
continue;
}
getRetainIps().put(clientSub.ip, System.currentTimeMillis());
// 将该长轮询从等待队列中移除,当移除以后,上面源码中的removeFlag则为false
iter.remove();
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));
}
}
nacos作为注册中心的时候,使用的是ap,保证了项目的可用性。在2.0版本以后,客户端与服务端建立了grpc协议的长链接,客户端每隔5s向服务端发送心跳任务,服务端会对心跳任务进行定时检查,根据没有心跳任务的时间间隔,将注册实例标记为不健康或者移除该实例。
服务端以及每个客户端都会存在注册表信息,客户端之间再进行服务调用的时候,是从本地读取的注册表信息,而不是去远程拉取。
客户端与服务端建立长链接,在2.0之前是客户端向服务端发送心跳任务,客户端每隔5s向服务端发送一次心跳任务,服务端会定时对心跳任务进行检测,超过15s没有发送心跳任务,则将该客户端标位不健康实例,超过30s没有发送心跳任务,则将该实例移除;但是在2.0之后使用grpc长链接,不再使用心跳,由服务端主动创建定时任务,没3s执行一次,检查超过20s没有发生过通讯的客户端,然后想起发送探活请求,如果1s内进行响应,则检测通过,否则移除链接。
当某个客户端主动下线或者被服务端移除时,服务端会给各个健康实例发送注册表变更事件,然后客户端重新拉取。在客户端会存在一个定时任务,每隔6s从注册中心拉取注册列表,发布变更事件,然后订阅者进行本地注册表的数据更新。
3.1 客户端
我们通过nacos源码中的NamingTest这个类来进行模拟客户端注册,当然也可以通过你项目中对应的nacos的jar包进行分析,入口在NacosServiceRegistryAutoConfiguation#nacosServiceRegistry,感兴趣的同学可以通过这个入口去查看。
之所以使用源码中的NamingTest类进行分析,对于没有源码阅读经验的同学比较友好,它里面的相关信息以及步骤都比较清晰明显。在入口中主要是做了以下相关事情:1.设置服务端的相关相关信息;2.设置要注册的实例信息;3.获取NacosNamingService并进行注册。
public void testServiceList() throws Exception {
// nacos服务端的地址以及用户名和密码
Properties properties = new Properties();
properties.put(PropertyKeyConst.SERVER_ADDR, "127.0.0.1:8848");
properties.put(PropertyKeyConst.USERNAME, "nacos");
properties.put(PropertyKeyConst.PASSWORD, "nacos");
// 客户端实例的相关信息,包含ip,port,原信息等相关数据
Instance instance = new Instance();
instance.setIp("1.1.1.1");
instance.setPort(800);
instance.setWeight(2);
// 元数据信息,对客户端的相关描述
Map map = new HashMap();
map.put("netType", "external");
map.put("version", "2.0");
instance.setMetadata(map);
// 通过工厂方法创建namingService,最终是通过反射的方式进行创建,com.alibaba.nacos.client.naming.NacosNamingService
NamingService namingService = NacosFactory.createNamingService(properties);
// 进入具体的注册流程
namingService.registerInstance("nacos.test.1", instance);
...
}
接下来进入具体的注册流程,跳转到NacosNamingService#registerInstance的重载方法中,在该方法中主要做了两件事情:1.校验心跳时间;2.通过代理进行实例注册。为什么要是用代理呢?是为了兼容以前的版本,在2.0之前是采用http协议进行注册,而在2.0以后采用grpc协议进行注册,咱们的源码分析是以2.x的版本,也就是长链接版本。
public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
// 检查心跳,心跳间隔时间以及服务删除时间必须大于心跳间隔,否则抛出异常
NamingUtils.checkInstanceIsLegal(instance);
// 通过代理
clientProxy.registerService(serviceName, groupName, instance);
}
// 看一下NamingUtils.checkInstanceIsLegal(instance);的相关代码
public static void checkInstanceIsLegal(Instance instance) throws NacosException {
// 心跳超时时间以及服务删除时间必须大于心跳时间,否则抛出异常
// 当我们点击进入心跳时间,心跳超时时间,实例删除时间就会发现,这些时间与上面的原理图上面的时间是对应的
if (instance.getInstanceHeartBeatTimeOut() < instance.getInstanceHeartBeatInterval()
|| instance.getIpDeleteTimeout() < instance.getInstanceHeartBeatInterval()) {
throw new NacosException(NacosException.INVALID_PARAM,
"Instance 'heart beat interval' must less than 'heart beat timeout' and 'ip delete timeout'.");
}
}
// 查看一下心跳的相关时间
// 心跳间隔时间,默认为5s
public long getInstanceHeartBeatInterval() {
return getMetaDataByKeyWithDefault(PreservedMetadataKeys.HEART_BEAT_INTERVAL,
Constants.DEFAULT_HEART_BEAT_INTERVAL);
}
// 心跳超时时间,默认为15s
public long getInstanceHeartBeatTimeOut() {
return getMetaDataByKeyWithDefault(PreservedMetadataKeys.HEART_BEAT_TIMEOUT,
Constants.DEFAULT_HEART_BEAT_TIMEOUT);
}
// 删除时间,默认为30s
public long getIpDeleteTimeout() {
return getMetaDataByKeyWithDefault(PreservedMetadataKeys.IP_DELETE_TIMEOUT,
Constants.DEFAULT_IP_DELETE_TIMEOUT);
}
clientProxy是一个代理,通过构造方法中的init方法,我们会发现它的实现类型为NamingClientProxyDelegate,在这里我们会找到真正负责注册的实现类
// 通过代理获取到真正进行注册的类
public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
getExecuteClientProxy(instance).registerService(serviceName, groupName, instance);
}
// 会根据当前的实例类型进行区分,获取到真正的实现类,如果是瞬时对象(也就是注册实例),
// 则会采用grpcClientProxy,这里默认为true
private NamingClientProxy getExecuteClientProxy(Instance instance) {
return instance.isEphemeral() ? grpcClientProxy : httpClientProxy;
}
通过上面这段代码,我们找到的真正负责注册的类,那么我们继续跟进,跟进到NamingGrpcClientProxy#registerService,在该方法做了两件事情:1.缓存当前实例信息,2.grpc远程调用
public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
NAMING_LOGGER.info("[REGISTER-SERVICE] {} registering service {} with instance {}", namespaceId, serviceName,
instance);
// 缓存当前注册的实例信息,key为服务信息+组信息,value为服务信息,组信息,实例信息
redoService.cacheInstanceForRedo(serviceName, groupName, instance);
// grpc远程调用
doRegisterService(serviceName, groupName, instance);
}
public void doRegisterService(String serviceName, String groupName, Instance instance) throws NacosException {
InstanceRequest request = new InstanceRequest(namespaceId, serviceName, groupName,
NamingRemoteConstants.REGISTER_INSTANCE, instance);
// grpc远程调用
requestToServer(request, Response.class);
// 注册完成以后,将缓存中的注册状态变为true
redoService.instanceRegistered(serviceName, groupName);
}
grpc协议里面的具体源码这里暂时不进行深入分析,感兴趣的同学可以通过NamingGrpcClientProxy构造函数中的start()方法,定位到rpcClient.start(),通过这个入口进行源码查看。
3.2 服务端
通过nacous官网中Open API 指南中的服务注册,我们可以找到其调用地址,对应到源码中的controller也就是InstanceController,找到其中的register方法,里面鬼关键的就是注册实例这个方法,分析如下:
// getInstanceOperator().registerInstance(namespaceId, serviceName, instance);调用实例注册
// getInstanceOperator()方法会根据使用的协议不同,选择不同的service
private InstanceOperator getInstanceOperator() {
// 现在使用的grpc,因此选择instanceServiceV2,类型为InstanceOperatorClientImpl
return upgradeJudgement.isUseGrpcFeatures() ? instanceServiceV2 : instanceServiceV1;
}
接下来我们进入具体的服务注册流程,主要做了两件事情:第一件,建立与客户端的连接,通过生成的clientId;第二件,注册客户端实例
public void registerInstance(String namespaceId, String serviceName, Instance instance) {
// 判断是否为临时实例
boolean ephemeral = instance.isEphemeral();
// 获取客户端id,ip:port+#+true
String clientId = IpPortBasedClient.getClientId(instance.toInetAddr(), ephemeral);
// 创建与客户端的链接
createIpPortClientIfAbsent(clientId);
// 获取服务
Service service = getService(namespaceId, serviceName, ephemeral);
// 注册服务实例
clientOperationService.registerInstance(service, instance, clientId);
}
在进行实例注册的时候,会进行一个路由选择,是根据当前实例是临时实例还是永久实例来进行路由的,对于服务注册而言,注册的实例为临时实例,所以会走临时实例的路由,在该路由中完成最后的实例注册
public void registerInstance(Service service, Instance instance, String clientId) {
Service singleton = ServiceManager.getInstance().getSingleton(service);
if (!singleton.isEphemeral()) {
throw new NacosRuntimeException(NacosException.INVALID_PARAM,
String.format("Current service %s is persistent service, can't register ephemeral instance.",
singleton.getGroupedServiceName()));
}
Client client = clientManager.getClient(clientId);
if (!clientIsLegal(client, clientId)) {
return;
}
// 获取实例信息
InstancePublishInfo instanceInfo = getPublishInfo(instance);
// 将instance添加到client中
client.addServiceInstance(singleton, instanceInfo);
client.setLastUpdatedTime();
// 建立service与clientId关系
NotifyCenter.publishEvent(new ClientOperationEvent.ClientRegisterServiceEvent(singleton, clientId));
NotifyCenter
.publishEvent(new MetadataEvent.InstanceMetadataEvent(singleton, instanceInfo.getMetadataId(), false));
}
好了,naocs的源码暂时就先分析到这里,后续有机会的话,再对健康检测,服务发现以及客户端与服务端的发布订阅事件进行相关的源码分析