「源码学习」Nacos配置中心客户端实现原理

157 阅读6分钟

先抛出几个问题

(1)客户端是如何获取端配置的?

(2)客户端是如何监听服务端配置变更的?

(3)客户端与服务端的交互方式是pull还是push呢?

以前写过关于Nacos配置中心如何使用的文章

在平时使用Nacos时,我们可以通过Nacos控制台来发布更新配置,也可以通过Nacos提供的OpenAPI nacos.io/zh-cn/docs/… ,调用接口更新配置。

客户端配置获取

客户端获取配置主要的方法是 com.alibaba.nacos.client.config.NacosConfigService 类的getConfigInner() 方法。

主要流程是

  • 先从本地文件获取配置;
  • 如果本地没有本地文件,则从服务端获取,并保存本地快照;
  • 如果从服务端获取失败,则会从快照中获取文件。

private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
    group = null2defaultGroup(group);
    ParamUtils.checkKeyParam(dataId, group);
    ConfigResponse cr = new ConfigResponse();
    
    cr.setDataId(dataId);
    cr.setTenant(tenant);
    cr.setGroup(group);
    
    // 优先使用本地配置
    String content = LocalConfigInfoProcessor.getFailover(worker.getAgentName(), dataId, group, tenant);
    if (content != null) {
        LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}",
                    worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
        cr.setContent(content);
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
        return content;
    }
    
    try {
       // 从服务端获取配置
        String[] ct = worker.getServerConfig(dataId, group, tenant, timeoutMs, false);
        cr.setContent(ct[0]);
        
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
        
        return content;
    } catch (NacosException ioe) {
        if (NacosException.NO_RIGHT == ioe.getErrCode()) {
            throw ioe;
        }
        LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}",
                    worker.getAgentName(), dataId, group, tenant, ioe.toString());
    }
    
    LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, config={}",
                worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
    //从本地快照获取配置
    content = LocalConfigInfoProcessor.getSnapshot(worker.getAgentName(), dataId, group, tenant);
    cr.setContent(content);
    configFilterChainManager.doFilter(null, cr);
    content = cr.getContent();
    return content;
    }
private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
    group = null2defaultGroup(group);
    ParamUtils.checkKeyParam(dataId, group);
    ConfigResponse cr = new ConfigResponse();
    
    cr.setDataId(dataId);
    cr.setTenant(tenant);
    cr.setGroup(group);
    
    // 优先使用本地配置
    String content = LocalConfigInfoProcessor.getFailover(worker.getAgentName(), dataId, group, tenant);
    if (content != null) {
        LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}",
                    worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
        cr.setContent(content);
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
        return content;
    }
    
    try {
       // 从服务端获取配置
        String[] ct = worker.getServerConfig(dataId, group, tenant, timeoutMs, false);
        cr.setContent(ct[0]);
        
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
        
        return content;
    } catch (NacosException ioe) {
        if (NacosException.NO_RIGHT == ioe.getErrCode()) {
            throw ioe;
        }
        LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}",
                    worker.getAgentName(), dataId, group, tenant, ioe.toString());
    }
    
    LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, config={}",
                worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
    //从本地快照获取配置
    content = LocalConfigInfoProcessor.getSnapshot(worker.getAgentName(), dataId, group, tenant);
    cr.setContent(content);
    configFilterChainManager.doFilter(null, cr);
    content = cr.getContent();
    return content;
    }
  • getConfigInner 方法优先从本地文件获取配置,本地文件默认是不存在的,因此如果想用本地配置覆盖远程配置只需要在本地新建配置文件即可,Nacos 会优先使用本地文件。本地配置文件的路径为:
/{user.home}/nacos/config/fixed-{serverName}/data/config-data/{group}/{dataId}
  • 如果没有本地配置文件,则调用服务端接口获取配置,接口为:
/v1/cs/configs?dataId={dataId}&group={group}
  • 如果从服务端获取失败,则从本地快照数据获取配置,并且每次从服务端获取配置后,都会更新本地快照数据,快照路径为:
/{user.home}/nacos/config/fixed-{serverName}/snapshot/{group}/{dataId}

客户端配置变更

当注册监听器后, com.alibaba.nacos.client.config.NacosConfigService 调用 com.alibaba.nacos.client.config.impl.ClientWork 创建长轮询线程去监听服务端的配置,长轮询链接的默认超时时间是30秒,在30秒内如果监听到数据有任何的变化会立即返回新的配置数据;如果没有任何变化,则会结束当前的监听并开启下一轮监听。

长轮询是什么呢

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

注意:本篇文章nacos源码版本是nacos-client-2.0.0-BATA ,版本不同,代码会有略微的不同。

下面一步步探索nacos 客户端是如何实现动态更新配置

  1. 在 NacosConfigService 初始化的时候,创建了ClientWorker对象,ClientWorker负责获取Nacos的配置和创建长轮询连接监听服务端配置更新。
this.worker = new ClientWorker(this.configFilterChainManager, properties);
  1. getConfigAndSignListener() 比 getConfig() 多了发起长轮询和对dataId数据变更注册监听的操作addTenantListenersWithContent()
@Override
    public String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener)
            throws NacosException {
        String content = getConfig(dataId, group, timeoutMs);
        worker.addTenantListenersWithContent(dataId, group, content, Arrays.asList(listener));
        return content;
    }
  1. 客户端注册监听后,先从cacheMap 根据 dataId、group、tenant 获取对应的 CacheData 。
public void addTenantListenersWithContent(String dataId, String group, String content,
            List<? extends Listener> listeners) throws NacosException {
        group = null2defaultGroup(group);
        String tenant = agent.getTenant();
        //获取dataId对应的CacheData,如没有则向服务端发起长轮询请求获取配置
        CacheData cache = addCacheDataIfAbsent(dataId, group, tenant);
        synchronized (cache) {
            //注册对dataId的数据变更监听
            cache.setContent(content);
            for (Listener listener : listeners) {
                cache.addListener(listener);
            }
            cache.setSyncWithServer(false);
            agent.notifyListenConfig();
        }
        
    }
  1. 如果没有 CacheData 则向服务端发起长轮询获取配置。
public CacheData addCacheDataIfAbsent(String dataId, String group, String tenant) throws NacosException {
        CacheData cache = getCache(dataId, group, tenant);
        if (null != cache) {
            return cache;
        }
        String key = GroupKey.getKeyTenant(dataId, group, tenant);
        synchronized (cacheMap) {
            CacheData cacheFromMap = getCache(dataId, group, tenant);
            if (null != cacheFromMap) {
                cache = cacheFromMap;
                // reset so that server not hang this check
                cache.setInitializing(true);
            } else {
                cache = new CacheData(configFilterChainManager, agent.getName(), dataId, group, tenant);
                int taskId = cacheMap.get().size() / (int) ParamUtil.getPerTaskConfigSize();
                cache.setTaskId(taskId);
                // 请求服务端,默认timeout=30s
                if (enableRemoteSyncConfig) {
                    String[] ct = getServerConfig(dataId, group, tenant, 3000L, false);
                    cache.setContent(ct[0]);
                }
            }
            Map<String, CacheData> copy = new HashMap<String, CacheData>(this.cacheMap.get());
            copy.put(key, cache);
            cacheMap.set(copy);
        }
        return cache;
    }
  1. 通过addListener()注册监听器,将CacheData对象当前最新的md5值赋值给ManagerListenerWrap对象的lastCallMd5属性。
public void addListener(Listener listener) {
        if (null == listener) {
            throw new IllegalArgumentException("listener is null");
        }
        ManagerListenerWrap wrap =
                (listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
                        : new ManagerListenerWrap(listener, md5);
    }

上面已经完成了对dataId设置了监听,那接下来客户端又如何监听服务端的配置变更呢?

  1. 在 NacosConfigService 初始化了 ClientWorker ,在 ClientWorker 的构造器启动了一个线程池来轮询cacheMap。
public void startInternal() throws NacosException {
            executor.schedule(new Runnable() {
                @Override
                public void run() {
                    while (true) {
                        try {
                            listenExecutebell.poll(5L, TimeUnit.SECONDS);
                            executeConfigListen();
                        } catch (Exception e) {
                            LOGGER.error("[ rpc listen execute ] [rpc listen] exception", e);
                        }
                    }
                }
            }, 0L, TimeUnit.MILLISECONDS);
            
        }
  1. 在executeConfigListen()方法中有个 checkListenerMd5() 方法,检查cacheMap中dataId的CacheData对象内,MD5字段与注册的监听listener内的lastCallMd5值,不相同表示配置数据变更则触发safeNotifyListener() 方法,发送配置变更通知。
void checkListenerMd5() {
        for (ManagerListenerWrap wrap : listeners) {
            if (!md5.equals(wrap.lastCallMd5)) {
                safeNotifyListener(dataId, group, content, type, md5, wrap);
            }
        }
    }
  1. safeNotifyListener()方法会单独起线程,向所有对dataId注册过监听的客户端推送变更后的数据内容。

  1. 客户端接收到通知后,直接实现 Listener.receiveConfigInfo() 方法处理回调数据就可以了。

举个栗子

下面写个简单的DEMO,直观的看一下Nacos的配置变更的效果。

通过Nacos控制台,新建配置文件:common.yml

import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.PropertyKeyConst;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import java.util.Properties;
import java.util.concurrent.Executor;

public class NacosConfigTest {
    public static void main(String[] args) throws NacosException, InterruptedException {
        String dataId = "common.yml";
        String group = "DEFAULT_GROUP";
        Properties properties = new Properties();
        properties.put(PropertyKeyConst.SERVER_ADDR, "127.0.0.1:8848");
        ConfigService configService = NacosFactory.createConfigService(properties);
        String content = configService.getConfig(dataId, group, 5000);
        System.out.println("get content: "+ content);
        configService.addListener(dataId, group, new Listener() {
            @Override
            public void receiveConfigInfo(String configInfo) {
                System.out.println("listener receive content:" + configInfo);
            }
            @Override
            public Executor getExecutor() {
                return null;
            }
        });
        // 通过控制台修改
        Thread.sleep(10000);

        // 通过publishConfig修改
        configService.publishConfig(dataId, group, "a: 3333 #publishConfig修改");

        Thread.sleep(10000);
        content = configService.getConfig(dataId, group, 5000);
        System.out.println("get content: "+ content);

    }
}

日志输出>>>>

get content: a: 1111 #初始值
listener receive content:a: 2222 #控制台修改
listener receive content:a: 3333 #publishConfig修改
get content: a: 3333 #publishConfig修改

源码地址

github.com/alibaba/nac…

最后

最后回顾一下刚开始抛出的几个问题

(1)客户端是如何获取端配置的?

过程可以分这几步:

  • 先从本地文件获取配置;
  • 如果本地没有本地文件,则从服务端获取,并保存本地快照;
  • 如果从服务端获取失败,则会从快照中获取文件。

(2)客户端是如何监听服务端配置变更的?

长轮询。

(3)客户端与服务端的交互方式是pull还是push呢?

pull。主动拉pull模式做出了服务端实时推送的效果。


如果文章中有写的不对的地方,感谢大家指出....