Nacos配置中心源码浅析

一. 前言

1. nacos相关文章

  • Nacos配置中心使用
  • Nacos注册中心使用 (待出)
  • Nacos配置中心源码浅析 (本文)
  • Nacos注册中心源码浅析 (待出)

2. 说明

  • 本文客户端使用的依赖nacos-client 版本为 1.4.1 (或使用spring-cloud-starter-alibaba-nacos-config 2.2.5.RELEASE)
  • 本文服务端使用的依赖nacos-config版本为 2.2.2
  • 如有疑问, 请查看官方文档: https://nacos.io/zh-cn/docs/what-is-nacos.html
  • nacos源码: https://github.com/alibaba/nacos/releases
  • 本文仅展开核心代码讲解, 非核心代码读者可自行学习了解

3. 客户端获取配置文件的方式

客户端获取配置文件方式有两种

  1. 主动获取配置文件
  2. 长轮询获取配置文件

4. 配置的文件类型

客户端和服务端配置的文件类型如下图:
在这里插入图片描述

说明:

  1. 客户端文件类型
    本地配置文件FailoverFile: 有本地配置文件则会优先使用, 该配置文件需由用户添加, 一般不使用
    本地快照文件SnapshotFile: 请求服务端得到的配置文件存储的快照, 用于访问服务端失败时兜底使用
    缓存CacheData: 记录最新的配置文件信息, 用于和监听器的对比并触发通知
  2. 服务端文件类型
    服务端配置ServerConfig: 这里为了方便, 将数据库的配置文件和文件类型的配置文件统称为服务端配置
    缓存CacheIteam: 服务端内记录每一个配置文件信息的最小单元(存储了内容md5值), 用于和客户端配置md5值对比判断是否配置发生变化

注: 这里说的 配置的文件类型不是指nacos控制面板上显示的项目的配置文件, 而是源码中涉及的文件

二. 客户端主动获取配置文件源码

说明

  1. 涉及到的客户端文件类型如下
    本地配置文件FailoverFile
    本地快照文件SnapshotFile
  2. 涉及到的服务端文件类型如下
    服务端配置ServerConfig
    缓存CacheIteam

流程图

在这里插入图片描述

说明:

  1. 服务端
    1.1 优先使用本地配置
    1.2 本地配置没有则从服务端获取. 服务端请求成功会将最新的配置写入快照中并返回
    1.3 请求服务端失败且异常不是鉴权失败时, 使用快照的配置文件返回
  2. 服务端 (图忘记画出了, 详情见源码)
    2.1 获取读锁(自旋锁)
    2.2 根据beta, autoTag等确定读取什么配置
    2.3 根据PropertyUtil.isDirectRead()判断是读mysql还是读文件
    2.4 解密出配置的真实内容
    2.5 响应客户端
    2.6 释放读锁

客户端

1. 入口

main方法

public class Test {
    public static void main(String[] args) throws NacosException, InterruptedException {
        // 【模拟手动获取配置文件】
        String serverAddr = "localhost:8848";
        Properties properties = new Properties();
        properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr);
        
        // 不设置命名空间时, 默认使用public命名空间
        //String namespace = "013f54f2-582f-4921-a998-46fdabeb0542";
        //properties.put(PropertyKeyConst.NAMESPACE, namespace);
        
        // 创建ConfigService对象
        ConfigService configService = ConfigFactory.createConfigService(properties);

        String dataId = "nacos-config-study.properties";
        String group = "DEFAULT_GROUP";
        // 主动查询配置文件信息
        String config = configService.getConfig(dataId, group, 5000);
        System.out.println("原配置文件信息 -> \n" + config + "\n");
   }

进入ConfigFactory的createConfigService(Properties)方法

    public static ConfigService createConfigService(Properties properties) throws NacosException {
        try {
            Class<?> driverImplClass = Class.forName("com.alibaba.nacos.client.config.NacosConfigService");
            Constructor constructor = driverImplClass.getConstructor(Properties.class);
            // 反射创建NacosConfigService对象
            ConfigService vendorImpl = (ConfigService) constructor.newInstance(properties);
            return vendorImpl;
        } catch (Throwable e) {
            throw new NacosException(NacosException.CLIENT_INVALID_PARAM, e);
        }
    }

说明:

  1. ConfigFactory.createConfigService(Properties)方法是通过反射创建出NacosConfigService对象 (实例化过程我们后面再展开看)
  2. 通过ConfigService的getConfig(String, String, long)方法获取配置文件

2. 获取配置文件

进入ConfigService的getConfig(String, String, long)方法

    public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
        // 核心代码
        return getConfigInner(namespace, dataId, group, timeoutMs);
    }

进入ConfigService的getConfigInner(String, String, String, long)方法

    private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
        // 设置group, 没有值则取默认值DEFAULT_GROUP
        group = null2defaultGroup(group);
        ParamUtils.checkKeyParam(dataId, group);
        ConfigResponse cr = new ConfigResponse();
        
        cr.setDataId(dataId);
        cr.setTenant(tenant);
        cr.setGroup(group);
        
        // 优先使用本地配置.
        String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
        if (content != null) {
            LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}", agent.getName(),
                    dataId, group, tenant, ContentUtils.truncateContent(content));
            cr.setContent(content);
            // 对配置进行过滤. 很少使用不展开
            configFilterChainManager.doFilter(null, cr);
            content = cr.getContent();
            return content;
        }
        
        try {
            // 没有本地配置时, 读取nacos服务端获取配置 (读取到配置文件会写入快照), 请求成功则直接返回
            String[] ct = worker.getServerConfig(dataId, group, tenant, timeoutMs);
            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={}",
                    agent.getName(), dataId, group, tenant, ioe.toString());
        }

        // 请求nacos服务端非鉴权失败则查询本地快照返回
        LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, config={}", agent.getName(),
                dataId, group, tenant, ContentUtils.truncateContent(content));
        content = LocalConfigInfoProcessor.getSnapshot(agent.getName(), dataId, group, tenant);
        cr.setContent(content);
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();
        return content;
    }

说明:

  1. 有本地配置则优先使用
  2. 没有本地配置时通过worker.getServerConfig(String, String, String, long)方法获取服务端配置
  3. 请求nacos服务端非鉴权失败则查询本地快照返回

3. 优先使用本地配置

进入LocalConfigInfoProcessor的getFailover(String, String, String, String)方法

    public static String getFailover(String serverName, String dataId, String group, String tenant) {
        // 获取本地配置文件路径
        File localPath = getFailoverFile(serverName, dataId, group, tenant);
        if (!localPath.exists() || !localPath.isFile()) {
            return null;
        }
        
        try {
            // 将文件读取成字符串返回. 不展开
            return readFile(localPath);
        } catch (IOException ioe) {
            LOGGER.error("[" + serverName + "] get failover error, " + localPath, ioe);
            return null;
        }
    }

进入LocalConfigInfoProcessor的getFailover(String, String, String, String)方法

    static File getFailoverFile(String serverName, String dataId, String group, String tenant) {
        File tmp = new File(LOCAL_SNAPSHOT_PATH, serverName + "_nacos");
        tmp = new File(tmp, "data");
        // 根据是否有命名空间 组装路径
        if (StringUtils.isBlank(tenant)) {
            tmp = new File(tmp, "config-data");
        } else {
            tmp = new File(tmp, "config-data-tenant");
            tmp = new File(tmp, tenant);
        }
        /**
         * 1. 未指定命名空间时
         *  ${user.home}\nacos\config\fixed-{服务端ip}_{服务端端口}_nacos\data\config-data\${group}\${dataId}
         * 2. 指定命名空间时
         *  ${user.home}\nacos\config\fixed-{服务端ip}_{服务端端口}-${namespace}_nacos\data\config-data-tenant\${namespace}\${group}\${dataId}
         * 注: ${user.home}可使用${JM.LOG.PATH}替换
         */

说明:

  1. 客户端的本地配置文件需要用户手动添加, 一般都是不使用的

4. 请求服务端配置

进入ClientWorker的getServerConfig(String, String, String, long)方法

    public String[] getServerConfig(String dataId, String group, String tenant, long readTimeout)
            throws NacosException {
        String[] ct = new String[2];
        if (StringUtils.isBlank(group)) {
            group = Constants.DEFAULT_GROUP;
        }

        HttpRestResult<String> result = null;
        try {
        	// 构建请求参数
            Map<String, String> params = new HashMap<String, String>(3);
            if (StringUtils.isBlank(tenant)) {
                params.put("dataId", dataId);
                params.put("group", group);
            } else {
                params.put("dataId", dataId);
                params.put("group", group);
                params.put("tenant", tenant);
            }
            // 请求服务端接口 /v1/cs/configs
            result = agent.httpGet(Constants.CONFIG_CONTROLLER_PATH, null, params, agent.getEncode(), readTimeout);
        } catch (Exception ex) {
            String message = String
                    .format("[%s] [sub-server] get server config exception, dataId=%s, group=%s, tenant=%s",
                            agent.getName(), dataId, group, tenant);
            LOGGER.error(message, ex);
            throw new NacosException(NacosException.SERVER_ERROR, ex);
        }

        switch (result.getCode()) {
            case HttpURLConnection.HTTP_OK:
                // 请求成功, 将配置文件写入本地快照. 不展开
                LocalConfigInfoProcessor.saveSnapshot(agent.getName(), dataId, group, tenant, result.getData());
                ct[0] = result.getData();
                if (result.getHeader().getValue(CONFIG_TYPE) != null) {
                    ct[1] = result.getHeader().getValue(CONFIG_TYPE);
                } else {
                    ct[1] = ConfigType.TEXT.getType();
                }
                return ct;
            case HttpURLConnection.HTTP_NOT_FOUND:
                // 服务端找不到配置文件, 则删除本地快照. 不展开
                LocalConfigInfoProcessor.saveSnapshot(agent.getName(), dataId, group, tenant, null);
                return ct;
            case HttpURLConnection.HTTP_CONFLICT: {}
            case HttpURLConnection.HTTP_FORBIDDEN: {}
            default: {}
        }
    }

说明:

  1. 构建请求参数
  2. 请求服务端接口 /v1/cs/configs
  3. 请求成功, 则将配置文件写入本地快照. 写入快照方法LocalConfigInfoProcessor.saveSnapshot(String, String, String, String, String)
  4. 请求服务端找不到配置文件(返回的是404状态码), 则删除本地快照

5. 读取本地快照

进入LocalConfigInfoProcessor的getSnapshot(String, String, String, String)方法

    static File getSnapshotFile(String envName, String dataId, String group, String tenant) {
        File tmp = new File(LOCAL_SNAPSHOT_PATH, envName + "_nacos");
        // 根据是否有命名空间 组装路径
        if (StringUtils.isBlank(tenant)) {
            tmp = new File(tmp, "snapshot");
        } else {
            tmp = new File(tmp, "snapshot-tenant");
            tmp = new File(tmp, tenant);
        }
        /**
         * 1. 未指定命名空间时
         *  ${user.home}\nacos\config\fixed-{服务端ip}_{服务端端口}_nacos\snapshot\${group}\${dataId}
         * 2. 指定命名空间时
         *  ${user.home}\nacos\config\fixed-{服务端ip}_{服务端端口}-${namespace}_nacos\snapshot-tenant\${namespace}\${group}\${dataId}
         * 注: ${user.home}可使用${JM.SNAPSHOT.PATH}替换
         */
        return new File(new File(tmp, group), dataId);
    }

服务端

1. 入口

请求服务端接口 /v1/cs/configs
进入ConfigController的getConfig(HttpServletRequest, HttpServletResponse, String, String, String, String)方法

public class Constants {
    public static final String BASE_PATH = "/v1/cs";
    public static final String CONFIG_CONTROLLER_PATH = BASE_PATH + "/configs";
}

@RequestMapping(Constants.CONFIG_CONTROLLER_PATH)
public class ConfigController {
    @GetMapping
    @TpsControl(pointName = "ConfigQuery")
    @Secured(action = ActionTypes.READ, signType = SignType.CONFIG)
    public void getConfig(HttpServletRequest request, HttpServletResponse response,
                          @RequestParam("dataId") String dataId,
                          @RequestParam("group") String group,
                          @RequestParam(value = "tenant", required = false, defaultValue = StringUtils.EMPTY) String tenant,
                          @RequestParam(value = "tag", required = false) String tag)
            throws IOException, ServletException, NacosException {
        // 校验命名空间参数
        ParamUtils.checkTenant(tenant);
        // 解析出最终的命名空间Id(public命名空间是没有Id值的)
        tenant = NamespaceUtil.processNamespaceParameter(tenant);
        // 其他参数校验
        ParamUtils.checkParam(dataId, group, "datumId", "content");
        ParamUtils.checkParam(tag);

        // 获取请求客户端的ip
        final String clientIp = RequestUtil.getRemoteIp(request);
        String isNotify = request.getHeader("notify");
        // 获取配置入口
        inner.doGetConfig(request, response, dataId, group, tenant, tag, isNotify, clientIp);
    }
}

进入ConfigServletInner的doGetConfig(HttpServletRequest, HttpServletResponse, String, String, String, String, String, String)方法

    public String doGetConfig(HttpServletRequest request, HttpServletResponse response, String dataId, String group,
            String tenant, String tag, String isNotify, String clientIp) throws IOException, ServletException {
        return doGetConfig(request, response, dataId, group, tenant, tag, isNotify, clientIp, false);
    }

进入ConfigServletInner的doGetConfig(HttpServletRequest, HttpServletResponse, String, String, String, String, String, String, boolean)方法

    public String doGetConfig(HttpServletRequest request, HttpServletResponse response, String dataId, String group,
            String tenant, String tag, String isNotify, String clientIp, boolean isV2)
            throws IOException, ServletException {
        // 设置notify值
        boolean notify = false;
        if (StringUtils.isNotBlank(isNotify)) {
            notify = Boolean.parseBoolean(isNotify);
        }
        // 接口路径为v2则设置CONTENT_TYPE为json
        if (isV2) {
            response.setHeader(HttpHeaderConsts.CONTENT_TYPE, MediaType.APPLICATION_JSON);
        }
        
        final String groupKey = GroupKey2.getKey(dataId, group, tenant);
        // 获取autoTag值
        String autoTag = request.getHeader("Vipserver-Tag");
        // 获取客户端appName
        String requestIpApp = RequestUtil.getAppName(request);

        // 获取配置的读锁 0-没有数据或失败. 正数-获取锁成功. 负数-获取锁失败
        int lockResult = tryConfigReadLock(groupKey);
        // 获取请求ip地址, 其实就是入参的clientIp
        final String requestIp = RequestUtil.getRemoteIp(request);
        boolean isBeta = false;
        boolean isSli = false;
        if (lockResult > 0) {
            // LockResult > 0 表示cacheItem不为空,其他线程不能删除该cacheItem
            FileInputStream fis = null;
            try {
                String md5 = Constants.NULL;
                long lastModified = 0L;
                CacheItem cacheItem = ConfigCacheService.getContentCache(groupKey);
                if (cacheItem.isBeta() && cacheItem.getIps4Beta().contains(clientIp)) {
                    isBeta = true;
                }

                // 设置response的CONTENT_TYPE, 没有值则默认为text (v2的固定为json)
                final String configType = (null != cacheItem.getType()) ? cacheItem.getType() : FileTypeEnum.TEXT.getFileType();
                response.setHeader("Config-Type", configType);
                FileTypeEnum fileTypeEnum = FileTypeEnum.getFileTypeEnumByFileExtensionOrFileType(configType);
                String contentTypeHeader = fileTypeEnum.getContentType();
                response.setHeader(HttpHeaderConsts.CONTENT_TYPE, contentTypeHeader);
                if (isV2) {
                    response.setHeader(HttpHeaderConsts.CONTENT_TYPE, MediaType.APPLICATION_JSON);
                }
                
                File file = null;
                ConfigInfoBase configInfoBase = null;
                PrintWriter out;
				// 是否为测试版本. 
                if (isBeta) {
                    md5 = cacheItem.getMd54Beta();
                    lastModified = cacheItem.getLastModifiedTs4Beta();
                    if (PropertyUtil.isDirectRead()) {
                        // 读取数据库
                        configInfoBase = configInfoBetaPersistService.findConfigInfo4Beta(dataId, group, tenant);
                    } else {
                        // 读取配置文件
                        file = DiskUtil.targetBetaFile(dataId, group, tenant);
                    }
                    response.setHeader("isBeta", "true");
                } else {
                    //...
                }
                
                response.setHeader(Constants.CONTENT_MD5, md5);
                
                // 设置response的值.
                response.setHeader("Pragma", "no-cache");
                response.setDateHeader("Expires", 0);
                response.setHeader("Cache-Control", "no-cache,no-store");
                if (PropertyUtil.isDirectRead()) {
                    response.setDateHeader("Last-Modified", lastModified);
                } else {
                    fis = new FileInputStream(file);
                    response.setDateHeader("Last-Modified", file.lastModified());
                }

                // 读取数据库的配置返回
                if (PropertyUtil.isDirectRead()) {
                    // 解密出内容
                    Pair<String, String> pair = EncryptionHandler.decryptHandler(dataId, configInfoBase.getEncryptedDataKey(), configInfoBase.getContent());
                    out = response.getWriter();
                    if (isV2) {
                        out.print(JacksonUtils.toJson(Result.success(pair.getSecond())));
                    } else {
                        out.print(pair.getSecond());
                    }
                    out.flush();
                    out.close();
                }
                // 读取配置文件返回
                else {
                    String fileContent = IoUtils.toString(fis, StandardCharsets.UTF_8.name());
                    String encryptedDataKey = cacheItem.getEncryptedDataKey();
                    // 解密出内容
                    Pair<String, String> pair = EncryptionHandler.decryptHandler(dataId, encryptedDataKey, fileContent);
                    String decryptContent = pair.getSecond();
                    out = response.getWriter();
                    if (isV2) {
                        out.print(JacksonUtils.toJson(Result.success(decryptContent)));
                    } else {
                        out.print(decryptContent);
                    }
                    out.flush();
                    out.close();
                }

                // 打印日志
                LogUtil.PULL_CHECK_LOG.warn("{}|{}|{}|{}", groupKey, requestIp, md5, TimeUtils.getCurrentTimeStr());
                final long delayed = System.currentTimeMillis() - lastModified;
                ConfigTraceService.logPullEvent(dataId, group, tenant, requestIpApp, lastModified, ConfigTraceService.PULL_EVENT_OK, delayed, requestIp, notify && isSli);
            } finally {
                // 释放读锁, 关闭文件流
                releaseConfigReadLock(groupKey);
                IoUtils.closeQuietly(fis);
            }
        } else if (lockResult == 0) {
            // 没有数据, 打印拉取日志, 返回404状态码
            ConfigTraceService.logPullEvent(dataId, group, tenant, requestIpApp, -1, ConfigTraceService.PULL_EVENT_NOTFOUND, -1, requestIp, notify && isSli);
            return get404Result(response, isV2);
        } else {
            // 该文件正在读, 打印拉取日志, 返回409状态码
            PULL_LOG.info("[client-get] clientIp={}, {}, get data during dump", clientIp, groupKey);
            return get409Result(response, isV2);
        }
        
        return HttpServletResponse.SC_OK + "";
    }

说明:

  1. 获取读锁,获取不到就自旋重复获取10次
  2. 判断是否读取配置类型. 可以读取数据库或读取服务端本地文件
  3. 根据读取配置类型, 解析出真实的配置文件内容, 响应客户端
  4. 释放读锁
  5. 若服务端找不配置文件, 则返回404状态码

2. 获取读锁

进入ConfigServletInner的tryConfigReadLock(String)方法

    private static final int TRY_GET_LOCK_TIMES = 9;
    /**
     * Try to add read lock.
     * @return 0-没有数据或失败. 正数-获取锁成功. 负数-获取锁失败
     * -1-获取读锁失败(正在写) 0-配置文件不存在(CACHE中不存在对于的CacheItem) 1-获取读锁成功
     */
    private static int tryConfigReadLock(String groupKey) {
        // 默认获取读锁失败
        int lockResult = -1;
        
        // 尝试获取读锁, 最多尝试10次
        for (int i = TRY_GET_LOCK_TIMES; i >= 0; --i) {
            // 尝试获取读锁
            lockResult = ConfigCacheService.tryReadLock(groupKey);
            
            // 数据不存在(CACHE中不存在对于的CacheItem), 直接返回
            if (0 == lockResult) {
                break;
            }
            
            // 获取读锁成功, 直接返回
            if (lockResult > 0) {
                break;
            }
            
            // 先挂起1ms再重试
            if (i > 0) {
                try {
                    Thread.sleep(1);
                } catch (Exception e) {
                    LogUtil.PULL_CHECK_LOG.error("An Exception occurred while thread sleep", e);
                }
            }
        }
        
        return lockResult;
    }

进入ConfigCacheService的tryReadLock(String)方法

    public static int tryReadLock(String groupKey) {
        CacheItem groupItem = CACHE.get(groupKey);
        // CacheItem的rwLock实现类为SimpleReadWriteLock, 较简单不展开
        int result = (null == groupItem) ? 0 : (groupItem.rwLock.tryReadLock() ? 1 : -1);
        if (result < 0) {
            DEFAULT_LOG.warn("[read-lock] failed, {}, {}", result, groupKey);
        }
        return result;
    }

3. 读取数据库

进入EmbeddedConfigInfoBetaPersistServiceImpl的findConfigInfo4Beta(String, String, String)方法

    public ConfigInfoBetaWrapper findConfigInfo4Beta(final String dataId, final String group, final String tenant) {
        String tenantTmp = StringUtils.isBlank(tenant) ? StringUtils.EMPTY : tenant;
        ConfigInfoBetaMapper configInfoBetaMapper = mapperManager.findMapper(dataSourceService.getDataSourceType(),
                TableConstant.CONFIG_INFO_BETA);
        // 构建sql
        final String sql = configInfoBetaMapper.select(
                Arrays.asList("id", "data_id", "group_id", "tenant_id", "app_name", "content", "beta_ips",
                        "encrypted_data_key"), Arrays.asList("data_id", "group_id", "tenant_id"));
        // 执行sql
        return databaseOperate.queryOne(sql, new Object[] {dataId, group, tenantTmp},
                CONFIG_INFO_BETA_WRAPPER_ROW_MAPPER);
    }

4. 读取文件

进入DiskUtil的targetBetaFile(String, String, String)方法

    public static File targetBetaFile(String dataId, String group, String tenant) {
        File file;
        // 拼接出本地文件的路径
        if (StringUtils.isBlank(tenant)) {
            file = new File(EnvUtil.getNacosHome(), BETA_DIR);
        } else {
            file = new File(EnvUtil.getNacosHome(), TENANT_BETA_DIR);
            file = new File(file, tenant);
        }
        file = new File(file, group);
        file = new File(file, dataId);
        return file;
    }

三. 客户端长轮询获取配置文件源码

说明

  1. 涉及到的客户端文件类型如下
    缓存CacheData
  2. 涉及到的服务端文件类型如下
    服务端配置ServerConfig
    缓存CacheIteam

流程图

在这里插入图片描述

说明:

  1. 客户端
    1.1 在NacosConfigService实例化时完成ClientWorker实例化, 对关注的配置文件列表按3000一组进行分片, 为每一个分片开启长轮询LongPollingRunnable任务
    1.2 LongPollingRunnable中, 先检查本地配置. 将本地配置同步到缓存CacheData中, 再对使用本地配置且发生变更的CacheData发起对其下绑定的监听器的通知
    1.3 再检查服务端配置. 先长轮询请求服务端获取关注的配置列表中有发生变更的变更配置列表, 遍历变更的配置列表主动请求服务端获取其最新的配置文件更新到缓存CacheData中
    1.4 对已初始化或 不使用本地配置且还在初始化中的CacheData操作, 修改其为初始化, 若其配置文件发生变更, 则发起对其下绑定的监听器的通知
    1.5 继续轮询执行该LongPollingRunnable任务(异常时候会延迟执行)
  2. 服务端
    2.1 这里服务端涉及两个接口, 一个长轮询的接口和一个主动查询的接口, 后者在本文签名篇幅已提及, 下面不重复讲述, 仅讲述长轮询接口的逻辑
    2.2 长轮询请求过来后, 服务端有两个位置去处理该请求. 一个是发起延迟定时任务处理, 一个是配置变更事件处理
    2.3 延迟定时任务处理. (见下文服务端处讲述)
    2.4 配置变更事件处理. (见下文服务端处讲述)

客户端

完善测试代码

main方法

public class Test {
    public static void main(String[] args) throws NacosException, InterruptedException {
        // 【模拟手动获取配置文件】
        String serverAddr = "localhost:8848";
        Properties properties = new Properties();
        properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr);
        
        // 不设置命名空间时, 默认使用public命名空间
        //String namespace = "013f54f2-582f-4921-a998-46fdabeb0542";
        //properties.put(PropertyKeyConst.NAMESPACE, namespace);
        
        // 创建ConfigService对象
        ConfigService configService = ConfigFactory.createConfigService(properties);

        String dataId = "nacos-config-study.properties";
        String group = "DEFAULT_GROUP";
        // 主动查询配置文件信息
        String config = configService.getConfig(dataId, group, 5000);
        System.out.println("原配置文件信息 -> \n" + config + "\n");


		
		// 【模拟配置文件变更及回调】
        // 添加监听器
        configService.addListener(dataId, group, new Listener(){

            @Override
            public Executor getExecutor() {
                return null;
            }

            @Override
            public void receiveConfigInfo(String configInfo) {
                System.out.println("receiveConfigInfo, configInfo=\n" + configInfo + "\n");
            }
        });

        // 手动修改配置文件. 查看监听事件回调
        String newConfigContent = "user.name=clj\n" +
                "user.age=188";
        configService.publishConfig(dataId, group, newConfigContent);

        // 手动查询配置文件信息
        config = configService.getConfig(dataId, group, 5000);
        System.out.println("修改后配置文件信息 -> \n" + config + "\n");

        // 阻塞, 以便看回调信息
        Thread.sleep(10000);
   }

1. 入口

在NacosConfigService反射实例化时, 完成了ClientWorker的实例化
进入NacosConfigService的NacosConfigService(Properties)构造方法

public class NacosConfigService implements ConfigService {
    public NacosConfigService(Properties properties) throws NacosException {
        // 校验确保contextPath属性不存在(有值时校验)
        ValidatorUtils.checkInitParam(properties);
        // 设置编码格式
        String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE);
        if (StringUtils.isBlank(encodeTmp)) {
            this.encode = Constants.ENCODE;
        } else {
            this.encode = encodeTmp.trim();
        }
        // 初始化命名空间
        initNamespace(properties);
        // 初始化网络通讯组件(实际通讯使用ServerHttpAgent)
        this.agent = new MetricsHttpAgent(new ServerHttpAgent(properties));
        this.agent.start();
        // 核心代码: 开启长轮询更新
        this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties);
    }
}

说明:

  1. 核心代码在this.worker = new ClientWorker(this.agent, this.configFilterChainManager, properties);

2. ClientWorker实例化

进入ClientWorker的ClientWorker(HttpAgent, ConfigFilterChainManager, Properties)方法

public class ClientWorker implements Closeable {
    public ClientWorker1(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager,
                         final Properties properties) {
        this.agent = agent;
        this.configFilterChainManager = configFilterChainManager;
        
		// 初始化长轮询超时时间等参数
        init(properties);

        // 初始化一个定时调度的线程池. 用于检测配置文件总数是否变化, 每多出3000个配置文件会触发一个新的线程(由executorService线程池创建)去处理
        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;
            }
        });

        // 再初始化一个定时调度的线程池. 为每3000个配置文件开启一个长轮询的线程 去检测配置文件是否发生变化及处理
        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;
                    }
                });

        // 使用executor执行一个10ms间隔的定时任务, 检测配置文件总数是否变化
        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);
    }
}

说明:

  1. 这里初始化了两个定时调度的线程池, 一个为executor, 另一个为executorService
  2. executor 用于检测配置文件总数是否变化, 每多出3000个配置文件会触发一个新的线程(由executorService线程池创建)去处理
  3. executorService 用于为每3000个配置文件开启一个长轮询的线程 去检测配置文件是否发生变化及处理

3. 配置文件分片

进入ClientWorker的checkConfigInfo()方法

    public void checkConfigInfo() {
        // 将任务数按3000一组来分片
        int listenerSize = cacheMap.size();
        int longingTaskCount = (int) Math.ceil(listenerSize / ParamUtil.getPerTaskConfigSize());
        // 当前分片任务数 大于 执行中的分片任务数时, 为新的分片 启动执行任务LongPollingRunnable
        if (longingTaskCount > currentLongingTaskCount) {
            for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {
                // 启动一个长轮询的任务. taskId = i, 会检测cacheMap中taskId为i的所有配置文件
                executorService.execute(new LongPollingRunnable(i));
            }
            // 刷新当前执行中的分片任务数
            currentLongingTaskCount = longingTaskCount;
        }
    }

说明:

  1. listenerSize为总订阅的配置文件数, longingTaskCount为当前分片数, currentLongingTaskCount为上次分片数
  2. 将任务数按3000一组来分片, 当当前分片数 大于 上次分片数, 则为多出的分片数创建长轮询LongPollingRunnable任务, 每个分片任务都有一个taskId, taskId也就是分片id

4. 执行长轮询Runnable任务

进入LongPollingRunnable的run()方法

    class LongPollingRunnable implements Runnable {
        private final int taskId;
        public LongPollingRunnable(int taskId) {
            this.taskId = taskId;
        }
        
        @Override
        public void run() {
            List<CacheData> cacheDatas = new ArrayList<CacheData>();
            List<String> inInitializingCacheList = new ArrayList<String>();
            try {
                // 检查本地配置 (同步本地配置信息到缓存, 本地配置变化则通知监听器)
                for (CacheData cacheData : cacheMap.values()) {
                    // 只处理当前分片的配置
                    if (cacheData.getTaskId() == taskId) {
                        // 收录属于当前分片的配置, 以便检查服务端配置时使用
                        cacheDatas.add(cacheData);
                        try {
                            // 同步本地配置信息 到 缓存
                            checkLocalConfig(cacheData);
                            // 本地配置存在时, 若已变化则通知监听器
                            if (cacheData.isUseLocalConfigInfo()) {
                                // 找出内容md5不是最新的监听器, 回调监听器的方法
                                cacheData.checkListenerMd5();
                            }
                        } catch (Exception e) {
                            LOGGER.error("get local config info error", e);
                        }
                    }
                }
                
                // 检查服务端配置, 获取已变更的配置文件列表
                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 {
                        // 请求服务端接口 /v1/cs/configs, 获取配置文件信息(存在时会刷新本地快照)
                        String[] ct = getServerConfig(dataId, group, tenant, 3000L);
                        CacheData cache = cacheMap.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);
                    }
                }
                
                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) {
                // 如果轮询任务异常,将在下次任务延迟执行(默认为2s)
                LOGGER.error("longPolling error : ", e);
                executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);
            }
        }
    }
    //...
}

说明:

  1. 一个LongPollingRunnable 只会处理自己分片taskId下的配置文件
  2. 先同步本地配置到缓存CacheData中
  3. 对使用本地配置的CacheData, 若配置已变更则通知其绑定的监听器
  4. 获取服务端变更的配置文件列表GroupKeys
  5. 主动查询变更的配置文件并同步到CacheData中
  6. 若CacheData为已初始化 或 没使用本地配置且初始化中, 设置初始化已完成. 判断其是否已变更, 是则通知其绑定的监听器
  7. 继续轮询执行(异常时会延迟执行, 默认延迟时间为2s)
CacheData的字段和属性

在这里插入图片描述

5. 同步本地配置到缓存

进入ClientWorker的checkLocalConfig(CacheData)方法

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

        // A.之前没有使用本地配置 && 本地配置存在时, 更新缓存为本地配置的数据
        if (!cacheData.isUseLocalConfigInfo() && path.exists()) {
            String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
            final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);
            // 将缓存中的 版本号更新为文件最后更新时间, 更新配置内容、及设置使用本地配置标识为true
            cacheData.setUseLocalConfigInfo(true);
            cacheData.setLocalConfigInfoVersion(path.lastModified());
            // 设置内容是会重置md5的值
            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;
        }

        // B.之前使用本地配置 && 本地配置已不存在, 则更新使用本地配置标识为false
        if (cacheData.isUseLocalConfigInfo() && !path.exists()) {
            cacheData.setUseLocalConfigInfo(false);
            LOGGER.warn("[{}] [failover-change] failover file deleted. dataId={}, group={}, tenant={}", agent.getName(),
                    dataId, group, tenant);
            return;
        }

        // C.本地内容发生改变 (之前使用本地配置 && 本地配置存在 && 缓存的版本不等于本地配置最近更新时间)
        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));
        }
    }

说明:
先获取出CacheData 对于的本地配置文件, 再根据CacheData 属性、文件是否存在、版本号是否变化进行 CacheData 更新. 下面枚举所有情况

  1. 之前没使用本地配置 && 本地配置存在 (对应上面的A)
  2. 之前没使用本地配置 && 本地配置不存在 (不处理)
  3. 之前使用本地配置 && 本地配置存在
    版本号变化 (对应上面的C)
    版本号不变 (不处理)
  4. 之前使用本地配置 && 本地配置不存在 (对应上面的B)
    具体操作请看代码内注释

补充: CacheData 的setContent(String)方法会更新md5的值

    public void setContent(String content) {
        this.content = content;
        this.md5 = getMd5String(this.content);
    }
    
    public static String getMd5String(String config) {
        return (null == config) ? Constants.NULL : MD5Utils.md5Hex(config, Constants.ENCODE);
    }

6. 同步服务端配置到缓存

进入ClientWorker的checkUpdateDataIds(List< CacheData >, List< String >)方法

    List<String> checkUpdateDataIds(List<CacheData> cacheDatas, List<String> inInitializingCacheList) throws Exception {
        StringBuilder sb = new StringBuilder();
        for (CacheData cacheData : cacheDatas) {
            // 没有使用本地配置才处理.
            if (!cacheData.isUseLocalConfigInfo()) {
                // sb记录所有 没有使用本地配置的cacheData信息
                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);
                }
                // 收录cacheDatas中初始化中的cacheData
                if (cacheData.isInitializing()) {
                    // It updates when cacheData occours in cacheMap by first time.
                    inInitializingCacheList
                            .add(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant));
                }
            }
        }
        // 是否存在初始化中的cacheData
        boolean isInitializingCacheList = !inInitializingCacheList.isEmpty();
        // 调用服务端接口, 查询sb内的配置文件信息
        return checkUpdateConfigStr(sb.toString(), isInitializingCacheList);
    }

说明:

  1. 将所有未使用本地配置的CacheData属性都添加到StringBuilder 中, 供后续批量查询nacos服务端获取这些配置文件是否变更的消息

进入ClientWorker的checkUpdateConfigStr(String, boolean)方法

    List<String> checkUpdateConfigStr(String probeUpdateString, boolean isInitializingCacheList) throws Exception {

        Map<String, String> params = new HashMap<String, String>(2);
        // 添加所有关注的配置文件到Listening-Configs参数上
        params.put(Constants.PROBE_MODIFY_REQUEST, probeUpdateString);
        Map<String, String> headers = new HashMap<String, String>(2);
        // 设置长轮询超时时间
        headers.put("Long-Pulling-Timeout", "" + timeout);

        // 如果存在初始化中的cacheData, 告诉服务器本次请求不需要延迟(不需要长轮询)
        if (isInitializingCacheList) {
            headers.put("Long-Pulling-Timeout-No-Hangup", "true");
        }

        if (StringUtils.isBlank(probeUpdateString)) {
            return Collections.emptyList();
        }

        try {
            long readTimeoutMs = timeout + (long) Math.round(timeout >> 1);
            // 请求服务端接口 /v1/cs/configs/listener
            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. Listening-Configs参数存储的是需要关注的所有配置文件
  2. Long-Pulling-Timeout这个header设置的是长轮询的超时时间
  3. Long-Pulling-Timeout-No-Hangup这个header设置为true时, 服务器本次请求不需要延迟(不需要长轮询)
  4. 请求服务端接口 /v1/cs/configs/listener. 得到的为发生变更的配置文件列表
    之后回到LongPollingRunnable的run()方法中. 上面已有源码, 下面再额外贴一张截图加简单解析
  5. 遍历变更的配置文件列表changedGroupKeys
  6. 主动发起查询获取配置文件信息ClientWorker.getServerConfig(String, String, String, long). 详情请看[4. 请求服务端配置]
  7. 更新对于缓存CacheData的文件内容, md5和文件类型
    在这里插入图片描述

7. 配置变更通知监听器

进入CacheData的checkListenerMd5()方法

    void checkListenerMd5() {
    	// 遍历绑定的监听器列表
        for (ManagerListenerWrap wrap : listeners) {
        	// 判断CacheData的md5值和监听器的md5值是否一致, 不一致则发起通知
            if (!md5.equals(wrap.lastCallMd5)) {
                safeNotifyListener(dataId, group, content, type, md5, wrap);
            }
        }
    }

进入CacheData的safeNotifyListener(String, String, String, String, String, ManagerListenerWrap)方法

    private void safeNotifyListener(final String dataId, final String group, final String content, final String type,
            final String md5, final ManagerListenerWrap listenerWrap) {
        final Listener listener = listenerWrap.listener;
        // 创建一个Runnable任务
        Runnable job = new Runnable() {
            @Override
            public void run() {
                try {
                   //...
                    ConfigResponse cr = new ConfigResponse();
                    cr.setDataId(dataId);
                    cr.setGroup(group);
                    cr.setContent(content);
                    configFilterChainManager.doFilter(null, cr);
                    String contentTmp = cr.getContent();
                    // 执行监听器的receiveConfigInfo(String)方法
                    listener.receiveConfigInfo(contentTmp);
                    
                    if (listener instanceof AbstractConfigChangeListener) {
                        Map data = ConfigChangeHandler.getInstance()
                                .parseChangeData(listenerWrap.lastContent, content, type);
                        ConfigChangeEvent event = new ConfigChangeEvent(data);
                        // 执行监听器的receiveConfigInfo(String)方法
                        ((AbstractConfigChangeListener) listener).receiveConfigChange(event);
                        listenerWrap.lastContent = content;
                    }
                    // 更新监听器的md5值
                    listenerWrap.lastCallMd5 = md5;
                } //...
            }
        };
       
       // 执行Runnable任务
        try {
            if (null != listener.getExecutor()) {
                listener.getExecutor().execute(job);
            } else {
                job.run();
            }
        } catch (Throwable t) {
            //...
        }
        //...
    }

说明:

  1. 创建一个Runnable任务, 用于执行监听器的receiveConfigInfo(String)方法
  2. 执行Runnable任务, 回掉receiveConfigInfo(String)方法, 完成通知

8. 拓展: 监听器添加和回调

下面截图为main方法添加的监听器及其receiveConfigInfo(String)回调方法
在这里插入图片描述

完整代码见 [三. 客户端长轮询获取配置文件源码-客户端]

服务端

        服务端通过开关设置isFixedPolling属性值 (开关配置文件com.alibaba.nacos.meta.switch, 没设置过不确定是否可用). isFixedPolling默认值为false.
        由于isFixedPolling值不不一样, 长轮询和配置变更事件处理也不一样, 下面分别对isFixedPolling不同的值讲述下不同的流程

  • isFixedPolling为false (默认情况)
    在这里插入图片描述

    • 添加长轮询客户端
      • 立即进行一次 客户端请求的配置文件列表是否变更判断. 若存在变更, 则立即响应客户端, 本次请求结束, 不会进入长轮询逻辑, 同时本次请求也不会进入配置变更事件处理流程
      • 若无变更, 则开启本次请求长轮询任务ClientLongPolling, 添加 本次请求长轮询任务 到allSubs列表中
      • 开启延迟定时任务, 延迟时间为超时时间-0.5秒(最大为10秒).
      • 延迟任务到时间后, 从allSubs列表移除本次请求长轮询任务
      • 响应空内容给客户端表示关注的配置文件列表无变化, 并取消延迟定时任务
    • 配置变更事件处理 (服务端启动后就一直在执行, 事件订阅和消费流程请看下文源码)
      • 发生LocalDataChangeEvent事件时, 执行DataChangeTask任务
      • 遍历allSubs列表, 判断 请求长轮询任务是否关注了当前配置文件的变更, 不关注则不处理
      • 本请求长轮询任务关注了当前配置文件的变更时, 从allSubs列表移除本请求长轮询任务
      • 响应最新配置文件内容给客户端表, 并取消本请求长轮询任务的延迟定时任务
  • isFixedPolling为true
    在这里插入图片描述

    • 添加长轮询客户端
      • 开启本次请求长轮询任务ClientLongPolling, 添加 本次请求长轮询任务 到allSubs列表中
      • 开启延迟定时任务, 延迟时间为10秒(默认值, 最大为10秒).
      • 延迟任务到时间后, 从allSubs列表移除本次请求长轮询任务
      • 查询关注的所有配置文件中的变更列表, 响应变更配置文件列表给客户端, 并取消延迟定时任务
    • 配置变更事件处理 (服务端启动后就一直在执行, 事件订阅和消费流程请看下文源码)
      • 发生LocalDataChangeEvent事件时, 跳过不处理

1. 入口

进入ConfigController的listener(HttpServletRequest, HttpServletResponse)方法

public class Constants {
    public static final String BASE_PATH = "/v1/cs";
    public static final String CONFIG_CONTROLLER_PATH = BASE_PATH + "/configs";
}

@RequestMapping(Constants.CONFIG_CONTROLLER_PATH)
public class ConfigController {
    @PostMapping("/listener")
    @Secured(action = ActionTypes.READ, signType = SignType.CONFIG)
    public void listener(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);

        // 获取请求的dataId, group, tenant和md5参数
        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存储内容为 dataId+group{+tenant} -> md5
            clientMd5Map = MD5Util.getClientMd5Map(probeModify);
        } catch (Throwable e) {
            throw new IllegalArgumentException("invalid probeModify");
        }

        // 执行长轮询
        inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());
    }
}

说明:

  1. 主要请求参数在request的Listening-Configs中, 包含客户端本次分片关注的所有配置文件的dataId, group, tenant和md5参数(tenant不一定有值)
  2. 将所有配置文件的属性存储到clientMd5Map 中, 键值对为 dataId+group{+tenant} -> md5
  3. 执行长轮询 inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());

进入ConfigServletInner的doPollingConfig(HttpServletRequest, HttpServletResponse, Map<String, String>, int)方法

    public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,
            Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {
        
        // 长轮询 (这里判断request的header中Long-Pulling-Timeout是否为空)
        if (LongPollingService.isSupportLongPolling(request)) {
            longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);
            return HttpServletResponse.SC_OK + "";
        }
        
        // 非长轮询, 不展开
        //...
    }

说明:

  1. 客户端发起请求时, 已设置Long-Pulling-Timeout的值, 故进入longPollingService.addLongPollingClient(HttpServletRequest, HttpServletResponse, Map<String, String>, int)方法
  2. 客户端请求添加header如下图
    在这里插入图片描述

2. 长轮询前预处理

进入LongPollingService的addLongPollingClient(HttpServletRequest, HttpServletResponse, Map<String, String>, int)方法

    public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
            int probeRequestSize) {

        // 长轮询超时时间
        String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
        // 长轮询超时不需要延迟(不需要长轮询)标志 (客户端header中设置的Long-Pulling-Timeout-No-Hangup值)
        String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
        // 获取服务端固定的延迟时间, 默认值0.5s
        int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
        
        // 为LoadBalance添加延迟时间,将提前500 ms返回一个响应,以避免客户端超时
        long timeout = -1L;
        if (isFixedPolling()) {
            // 定点长轮询时设置超时时间, 默认值10s.  (定点长轮询时到超时时间才响应客户端的, 非定点轮询则在超时时间范围内有变化就立即响应客户端)
            timeout = Math.max(10000, getFixedPollingInterval());
        } else {
            // 超时时间, 客户端设置时间-0.5s, 最大值为10s
            timeout = Math.max(10000, Long.parseLong(str) - delayTime);
            long start = System.currentTimeMillis();

            // 根据请求参数的md5值和服务端md5值判断, 获取md5已变化的 配置groupKey列表
            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;
            }
        }

        // 连接校验, 不展开
        String ip = RequestUtil.getRemoteIp(req);
        ConnectionCheckResponse connectionCheckResponse = checkLimit(req);
        if (!connectionCheckResponse.isSuccess()) {
            generate503Response(req, rsp, connectionCheckResponse.getMessage());
            return;
        }
        
        // 获取异步上下文对象. request和response对象存储在AsyncContext中, 方便后续异步线程中使用
        final AsyncContext asyncContext = req.startAsync();
        // AsyncContext.setTimeout() is incorrect, Control by oneself
        asyncContext.setTimeout(0L);
        
        String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
        String tag = req.getHeader("Vipserver-Tag");
        // 使用线程池执行 ClientLongPolling这个Runnable
        ConfigExecutor.executeLongPolling(
                new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
    }

说明:

  1. 通过判断isFixedPolling值, 设定最终的超时时间(或叫延迟时间). 其中isFixedPolling默认值为false
  2. isFixedPolling为false时, 延迟时间为客户端指定的超时时间-0.5s, 最大值不超过10s.
    立即进行一次 客户端请求的配置文件列表是否变更判断.
    若存在变更, 则立即响应客户端, 本次请求结束, 不会进入长轮询逻辑, 同时本次请求也不会进入配置变更事件处理流程
    若不存在变更, 且客户端header中设置的Long-Pulling-Timeout-No-Hangup值为true, 则结束本次请求 (这种情况为客户端查询的配置文件中存在初始化的CacheData)
  3. isFixedPolling为true时, 延迟时间默认值为10s, 最大值不超过10s
  4. 进行连接校验, 获取异步上下文对象后, 开启ClientLongPolling长轮询任务

3. 长轮询延迟任务

进入ClientLongPolling的run()方法

    
    class ClientLongPolling implements Runnable {
        @Override
        public void run() {
            // 执行定时任务, 定时任务时间间隔为timeoutTime
            asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(() -> {
                try {
                    // 保留当前客户端ip和时间戳 (目前好像没有使用到)
                    getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());

                    // 移除订阅者. 处理已经到timeout时间的客户端请求
                    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 {
                    	// 移除失败. 说明定时任务和事件处理并发执行了, 事件处理先以本定时任务的移除代码响应了客户端, 将本次请求长轮询任务移出了allSubs队列
                        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);
        }

        void sendResponse(List<String> changedGroups) {
            // 取消超时的定时任务
            if (null != asyncTimeoutFuture) {
                asyncTimeoutFuture.cancel(false);
            }
            // 响应客户端
            generateResponse(changedGroups);
        }

        void generateResponse(List<String> changedGroups) {
            if (null == changedGroups) {
                // 告诉web容器发送http响应
                asyncContext.complete();
                return;
            }
            HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();
            try {
                final String respString = MD5Util.compareMd5ResultString(changedGroups);
                
                // Disable cache.
                response.setHeader("Pragma", "no-cache");
                response.setDateHeader("Expires", 0);
                response.setHeader("Cache-Control", "no-cache,no-store");
                response.setStatus(HttpServletResponse.SC_OK);
                // 写入变更的配置文件列表
                response.getWriter().println(respString);
                asyncContext.complete();
            } catch (Exception ex) {
                PULL_LOG.error(ex.toString(), ex);
                asyncContext.complete();
            }
        }
}

说明:

  1. 将本次请求长轮询任务加入队列allSubs, 待超时时间到后再处理
  2. 启动一个延迟的定时任务. 延迟任务中, 会先把本次请求长轮询任务移除出allSubs队列,
  3. 移除失败(说明定时任务和事件处理并发执行且事件处理先于定时任务的移除代码)不处理.
  4. 移除成功, 再判断isFixedPolling的值
    isFixedPolling为true, 则查询关注的所有配置文件中的变更列表, 响应变更配置文件列表给客户端, 并取消延迟定时任务
    isFixedPolling为false, 则仅输出日志, 响应空给客户端并取消延迟定时任务 (该情况说明延迟期间都没有发生请求关注的配置文件变化事件)

4. 配置变更事件订阅与消费

LongPollingService该类贴有@Service注解, 服务端启动时就完成该bean的实例化并注入容器中, 这里就涉及了该类构造方法的调用, 源码如下
进入LongPollingService的LongPollingService()方法

@Service
public class LongPollingService {
	    public LongPollingService() {
        // 初始化所有订阅者(当前所有保持长轮询的客户端)对象
        allSubs = new ConcurrentLinkedQueue<>();

        // 定时任务每10秒统计 所有订阅者allSubs个数, 刷新到MetricsMonitor.longPolling中 (源码未见其作用)
        ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);
        
        // 注册LocalDataChangeEvent事件 到 通知中心NotifyCenter. 即注册生产者, 不展开
        NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);
        
        // 注册一个订阅者 来订阅通知中心的的事件 (仅处理LocalDataChangeEvent事件). 即注册消费者, 不展开
        // 发布事件示例NotifyCenter.publishEvent(new LocalDataChangeEvent(groupKey));
        NotifyCenter.registerSubscriber(new Subscriber() {
            
            @Override
            public void onEvent(Event event) {
                // 服务端定点长轮询, 则不处理 (应该是由客户端超时时间的定时任务处理, 即ClientLongPolling处理)
                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;
            }
        });
    }
}

说明:

  1. 初始化allSubs 对象, 实现类为ConcurrentLinkedQueue, 线程安全, 以确保定时任务和事件处理事并发移除 请求长轮询任务时队列是安全的
  2. 启动定时任务每10秒统计 所有订阅者allSubs个数, 刷新到MetricsMonitor.longPolling中 (源码未见其作用)
  3. 注册LocalDataChangeEvent事件 到 通知中心NotifyCenter. 即注册生产者, 不展开
  4. 注册一个订阅者 来订阅通知中心的的事件 (仅处理LocalDataChangeEvent事件). 即注册消费者, 不展开
    发布事件示例NotifyCenter.publishEvent(new LocalDataChangeEvent(groupKey));
  5. 注册的订阅者重写onEvent方法. isFixedPolling为true时不处理事件, isFixedPolling为false时判断事件是否为LocalDataChangeEvent, 是则执行DataChangeTask任务处理掉事件

进入DataChangeTask的run()方法

    class DataChangeTask implements Runnable {
        
        @Override
        public void run() {
            try {
                // 多余的一行代码
                ConfigCacheService.getContentBetaMd5(groupKey);

                // 遍历所有订阅者(当前所有保持长轮询的客户端)对象
                for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {
                    ClientLongPolling clientSub = iter.next();
                    // 判断当前客户端订阅者是否关注该groupKey配置文件变更
                    if (clientSub.clientMd5Map.containsKey(groupKey)) {
                        // 如果事件是beta(nacos的测试版本), 但客户端ip不在beta的ip列表, 则跳过
                        if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) {
                            continue;
                        }
                        
                        // 如果事件有tag, 但客户端的与其不相等, 则跳过
                        if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {
                            continue;
                        }

                        // 保留当前客户端ip和时间戳 (目前好像没有使用到)
                        getRetainIps().put(clientSub.ip, System.currentTimeMillis());
                        // 移除订阅者(当前保持长轮询的客户端)对象
                        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));
            }
        }
	}

说明:

  1. 遍历所有订阅者(当前所有保持长轮询的客户端)对象
  2. 判断当前客户端订阅者是否关注该groupKey配置文件变更
  3. 事件的beta和tag一些版本判断, 不符合则跳过事件不处理
  4. 移除订阅者(当前保持长轮询的客户端)对象
  5. 关闭延迟定时任务, 响应客户端

四. 拓展

1. spring-cloud-starter-alibaba-nacos-config中nacos配置中心是如何启动的

说明

这里讲述的spring-cloud-starter-alibaba-nacos-config使用的版本为2.2.5.RELEASE
启动配置类 NacosConfigBootstrapConfiguration. 初始化了三个类

  • NacosConfigProperties nacos配置文件读取后寄存的属性类 (配置前缀spring.cloud.nacos.config)
  • NacosConfigManager nacos配置管理类, 主要完成NacosConfigService的初始化
  • NacosPropertySourceLocator nacos属性原定位器, 完成配置的加载(初始化). 后续使用监听器刷新配置, 监听器添加入口类NacosContextRefresher

初始化出NacosConfigService对象 (包含了事件处理启动)

注入NacosConfigManager的bean时创建了NacosConfigManager对象
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
到这里就调用了ConfigFactory的createConfigService(Properties)方法
在这里插入图片描述
反射创建了NacosConfigService对象, 后面执行了本文讲的部分. NacosConfigService实例化时触发了ClientWorker初始化, 从而启动了事件监听和处理

Spring启动时加载所有nacos文件到Spring的Environment中

扫描注解时. 注入NacosPropertySourceLocator的bean
在这里插入图片描述
在Spring容器启动时, 指定到SpringApplication#prepareContext方法时, 遍历所有初始化器, 其中一个初始化器PropertySourceBootstrapConfiguration调用所有的PropertySourceLocator, 执行其locateCollection(Environment)方法
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
然后就会进入NacosPropertySourceLocator的locate(Environment)方法
在这里插入图片描述

进入NacosPropertySourceLocator的loadApplicationConfiguration(CompositePropertySource,String,NacosConfigProperties, Environment)方法
在这里插入图片描述
这里分别加载了无后缀, 有后缀, 有profile三种情况下的配置文件
进入NacosPropertySourceLocator的(final CompositePropertySource,String, String, String,boolean)方法
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
到这里就是调用了NacosConfigService.getConfig(String, String, long)接口获取到配置文件信息
最后在PropertySourceBootstrapConfiguration#insertPropertySources方法中将获取的nacos配置信息添加到Environment中

添加nacos监听器的位置

  1. NacosContextRefresher在NacosConfigAutoConfiguration配置时创建bean加入IOC容器
  2. 然后在SpringApplication#refreshContext中调用了AbstractApplicationContext#registerListeners方法, 会将NacosContextRefresher这个监听器的名字先记录下来
  3. 在SpringApplication就绪后, 发布ApplicationReadyEvent事件时
  4. 执行NacosContextRefresher#onApplicationEvent方法
  5. NacosContextRefresher#registerNacosListener为每个配置文件(dataId)都添加一个匿名监听器
  6. 将监听器添加到ClientWorker的cacheMap属性里的CacheData下的listeners中

NacosContextRefresher#onApplicationEvent(ApplicationReadyEvent) 方法
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
进入NacosConfigService的addListener(String dataId, String group, Listener listener)方法
在这里插入图片描述
进入ClientWorker的addTenantListeners(String dataId, String group, List<? extends Listener> listeners)方法
在这里插入图片描述

2. @RefreshScope如何支持nacos完成属性刷新

这里涉及了SpringCloud方面的源码, 感兴趣的读者可自行去了解
@RefreshScope源码解析
SpringBoot 中 @Value 源码解析

Nacos控制台修改配置文件后执行流程

在这里插入图片描述
主要涉及的类和方法:

  • ClientWorker.LongPollingRunnable#run
  • NacosContextRefresher#registerNacosListener
  • RefreshEvent
  • RefreshEventListener#onApplicationEvent

SpringCloud对RefreshEvent事件的处理

基于pring-cloud-context 2.2.5.RELEASE
在这里插入图片描述
主要涉及的类和方法:

  • RefreshEventListener#onApplicationEvent
  • EnvironmentChangeEvent
  • ConfigurationPropertiesRebinder#rebind
  • RefreshScope
  • GenericScope#destroy

说明:

  1. 贴有@RefreshScope注解的类(例如MyClass)会额外创建出一个代理bean
  2. 获取配置文件属性是就是从该代理bean中获取的 (配置没有变化时一直是这个代理bean)
  3. 当配置文件值变更时, nacos客户端会向上下文发布一个RefreshEvent事件, 最终会删除该代理bean
  4. 当业务代码中再次使用MyClass的对象时, 则会重新创建出其代理bean

事件发布入口: 上文提到添加监听器时, 执行了NacosContextRefresher的registerNacosListener(String, String)方法
在这里插入图片描述
在这里插入图片描述

说明:
添加的监听器为AbstractSharedListener的匿名内部类
当有配置文件变更事件发生时, 会回调匿名内部类的receiveConfigInfo(String)方法, 进而调用到innerReceive(String, String, String)方法. 然后应用上下文发布RefreshEvent事件
RefreshEvent事件触发@RefreshScope注解所在类的代理bean销毁
再次使用@Refresh注解时, 重新创建出代理bean, 通过@Value获取到最新的配置值

3. 项目多个nacos配置文件的优先级

nacos客户端启动后订阅的配置文件示例: 本项目spring.application.name为 nacos-config-studynacos, spring.profiles.active为dev. 订阅的配置文件如下图
在这里插入图片描述
下面优先级仅为nacos读取最终的@Value内容的优先选择值, 与spring项目其他配置优先级无关
不一定准确, 仅手动测试的结果

优先级从低到高如下
bootstrap.yml
bootstrap.properties
bootstrap-{spring.profiles.active}.yml
bootstrap-{spring.profiles.active}.properties
application.yml
application.properties
application-{spring.profiles.active}.yml
application-{spring.profiles.active}.properties
系统环境变量
nacos配置文件 {spring.application.name}
nacos配置文件 {spring.application.name}.{fileExtension}
nacos配置文件 {spring.application.name}-{spring.profiles.active}.{fileExtension}
nacos客户端本地配置文件(几乎不使用)

注: 在配置了spring.cloud.nacos.config.prefix时, 上面的{spring.application.name}改为{spring.cloud.nacos.config.prefix}
    旧版时, spring.application.group有值且spring.cloud.nacos.config.prefix没值, 则为{spring.application.group}:{spring.application.name}

4. 公共配置/多配置

公共配置可以使用以下方式实现, 将公共内容抽取到 一个配置中去

共享配置: spring.cloud.nacos.config.shared-configs[0]=xxx
拓展配置: spring.cloud.nacos.config.extension-configs[0]=xxx
使用详情: Nacos配置中心用法详细介绍-4、共享配置

加载该配置的源码
代码路径: com.alibaba.cloud.nacos.client.NacosPropertySourceLocator#locate
源码截图如下
在这里插入图片描述
配置文件优先级: 见源码, 最后加载的优先级最高, 即: 分享配置 < 拓展配置 < 一般应用配置

五. 参考文档:

  • Nacos——配置中心源码详解: https://blog.csdn.net/weixin_44102992/article/details/127856111
  • java 实现长轮询(LongPolling): https://blog.csdn.net/qq_18478183/article/details/127153915
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Nacos配置中心控制台的源码分析可以帮助我们深入理解其实现细节和工作原理。以下是一个大致的源码分析过程: 1. 入口类分析:首先,我们需要找到Nacos配置中心控制台的入口类。该类通常是一个Spring Boot应用的启动类,负责初始化和启动整个应用。我们可以查找包含main方法的类,或者在启动脚本中找到应用的入口点。 2. 依赖分析:接下来,我们需要分析应用所依赖的第三方库和框架。查看应用的pom.xml文件或者build.gradle文件,可以获取到所依赖的各个库和对应版本。这些依赖通常包括Spring框架、Nacos客户端等。 3. 配置加载与解析:Nacos配置中心控制台需要加载和解析配置,包括数据库配置、Nacos服务地址配置等。我们可以查找相关的配置文件或者代码片段,了解配置的加载和解析过程。 4. 控制器与路由:控制台通常提供了一些Web接口供前端调用。我们可以查找控制器类,分析其中的方法和注解,了解各个接口的功能和路由规则。 5. 页面模板与前端交互:配置中心控制台通常包含一些页面模板和与前端的交互逻辑。我们可以查找相关的HTML、CSS和JavaScript文件,分析页面的结构和交互逻辑。 6. 调用Nacos API:控制台需要与Nacos服务器进行通信,调用Nacos的API获取和修改配置信息。我们可以查找相关的API调用,了解控制台是如何与Nacos服务器进行通信的。 通过以上分析,我们可以逐步了解Nacos配置中心控制台的实现细节和工作原理。需要注意的是,具体的源码分析过程会因项目结构和代码风格而有所不同。以上只是一个大致的指导,具体分析还需根据实际情况来进行。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值