Dubbo笔记 ㉒ :配置中心

文章目录

  • 一、前言
  • 二、外部化配置中心的实现
    • 1. 功能介绍
    • 2. 功能实现
      • 2.1 AbstractConfig#refresh
      • 2.2 AbstractInterfaceConfig#prepareEnvironment
      • 2.3 ConfigManager.getInstance().refreshAll();
  • 三、动态配置的实现
    • 1. 功能介绍
    • 2. 提供者端
      • 2.1 ProviderConfigurationListener & ServiceConfigurationListener
        • 2.1.1 AbstractConfiguratorListener
        • 2.1.2 ProviderConfigurationListener
        • 2.1.3 ServiceConfigurationListener
        • 2.1.4 一点推测
      • 2.2 监听器的初始化
      • 2.3 监听器的触发
      • 2.4 总结
    • 3. 消费者端
      • 3.1 消费者端的监听器
        • 3.1.1 ConsumerConfigurationListener
        • 3.1.2 ReferenceConfigurationListener
      • 3.2 监听器的初始化
      • 3.3 监听器的触发
      • 3.4 总结
  • 五、其他
    • 1. nacos 作为配置中心

一、前言

本系列为个人Dubbo学习笔记衍生篇,是正文篇之外的衍生内容,内容来源于《深度剖析Apache Dubbo 核心技术内幕》, 过程参考官方源码分析文章。仅用于个人笔记记录。本文分析基于Dubbo2.7.0版本,由于个人理解的局限性,若文中不免出现错误,感谢指正。


配置中心的核心功能是作为 Key-Value 存储,Dubbo 框架告知配置中心其关心的 key,配置中心返回该key对应的 value 值。

按照应用场景划分,配置中心在 Dubbo 框架中主要承担以下职责:

  • 作为外部化配置中心,即存储 dubbo.properties 配置文件,此时,key 值通常为文件名如 dubbo.properties,value 则为配置文件内容。
  • 存储单个配置项,如各种开关项、常量值等。
  • 存储服务治理规则,此时key通常按照 “服务名+规则类型” 的格式来组织,而 value 则为具体的治理规则。

为了进一步实现对 key-value 的分组管理,Dubbo 的配置中心还加入了 namespace、group 的概念,这些概念在很多专业的第三方配置中心中都有体现,通常情况下,namespace 用来隔离不同的租户,group 用来对同一租户的key集合做分组。

当前,Dubbo 配置中心实现了对 Zookeeper、Nacos、Etcd、Consul、Apollo 的对接。


目前Dubbo主要版本有两个:Dubbo 2.7和Dubbo 2.6
两个版本最大的差异,在于配置管理中心。Dubbo 2.6版本所有数据都存在注册中心上,Dubbo 2.7版本分成了注册中心,配置中心,和元数据中心。

Dubbo Admin对于配置管理的操作,配置方式和Dubbo 2.7的模式一样,详见Dubbo Admin配置说明,并且兼容Dubbo 2.6的版本,这里的“兼容”是指:

  • 服务级别的配置,都会按照Dubbo 2.7和Dubbo 2.6两种格式分别写入,保证Dubbo 2.6的服务能够正确解析对应的规则
  • 应用级别的配置(包括标签路由),只会按照Dubbo 2.7的格式写入,因为Dubbo 2.6中没有这个特性。
  • Dubbo Admin只会按照Dubbo 2.7的格式去读取配置,因此所有在Admin上配置规则都可以读到,但是之前在注册中心上遗留的Dubbo 2.6格式的规则,不会展示在Dubbo Admin,也不能在Dubbo Admin上进行操作。

我们以 dubbo-admin-0.2.0 为例来创建动态配置
Dubbo笔记 ㉒ :配置中心_第1张图片

在以ZK 为注册中心的情况下,不同的配置在zk上的节点结构如下:

  • 正常的服务节点:

    /dubbo/服务名/providers  
    /dubbo/服务名/routers
    /dubbo/服务名/consumers
    /dubbo/服务名/congurators
    
  • 应用级别的配置:

    /dubbo/config/应用名/configurators 
    /dubbo/config/应用名/routers
    /dubbo/config/应用名/dubbo.properties
    
  • 服务级别的配置:

    /dubbo/config/分组名/服务名:版本号/configurators 
    /dubbo/config/分组名/服务名:版本号/routers
    /dubbo/config/分组名/服务名:版本号/dubbo.properties
    
  • 配置管理(外部化配置中心):

    /dubbo/config/dubbo/dubbo.properties : global 级别
    /dubbo/config/应用名/dubbo.properties : 应用级别。
    

在 zk 上的体现如下图:

Dubbo笔记 ㉒ :配置中心_第2张图片


本文来分析配置中心外部化配置中心 和 服务治理的功实现

二、外部化配置中心的实现

1. 功能介绍

Dubbo 外部化配置即可以通过外部化的方式引入 dubbo 的相关配置,包括 注册中心地址、端口、协议等。官方相关如下:配置中心扩展

我们可以通过dubbo-admin-0.2.0 中的配置管理来进行外部化配置,如下图:
我们可以基于应用级别全局级别来建立配置文件,下图中的 global 配置即为 全局配置,而 spring-dubbo-provider 则是以应用级别的配置文件。

Dubbo笔记 ㉒ :配置中心_第3张图片

其中 spring-dubbo-provider 的配置内容如下,指定了注册中心的地址和超时时间。(global 配置这里只是为了演示,没有配置数据)

Dubbo笔记 ㉒ :配置中心_第4张图片
在 zk 中其节点如下,其中

  • /dubbo/config/dubbo/dubbo.properties 为 global 级别的配置
  • /dubbo/config/spring-dubbo-provider/dubbo.properties 为应用级别的配置
    Dubbo笔记 ㉒ :配置中心_第5张图片

当 应用名为 spring-dubbo-provider 的服务启动后,则会读取上面指定的配置,使用 zookeeper://192.168.99.52:2182 作为注册中心,超时时间设定为 3s。


2. 功能实现

下面我们来分析一下外部配置的功能是如何实现的:

经过之前的系列文章分析,我们知道消费者和提供者在启动时会分别调用 ServiceConfig#checkAndUpdateSubConfigs
和 ReferenceConfig#checkAndUpdateSubConfigs 来检查一些配置信息。而在这其中会调用AbstractInterfaceConfig#startConfigCenter 来启动配置中心,其代码如下:

	// org.apache.dubbo.config.AbstractInterfaceConfig#startConfigCenter
    void startConfigCenter() {
    	
        if (configCenter == null) {
            ConfigManager.getInstance().getConfigCenter().ifPresent(cc -> this.configCenter = cc);
        }
		
        if (this.configCenter != null) {
            // TODO there may have duplicate refresh
            // 1. 刷新配置中心,按照优先级合并配置信息,因为配置文件具有优先级,系统配置优先级最高,如下配置顺序
            // isConfigCenterFirst = true : SystemConfiguration -> ExternalConfiguration -> AppExternalConfiguration -> AbstractConfig -> PropertiesConfiguration
            // isConfigCenterFirst = false : SystemConfiguration -> AbstractConfig -> ExternalConfiguration -> AppExternalConfiguration -> PropertiesConfiguration
            this.configCenter.refresh();
            // 2. 环境准备,读取配置中心的配置加载到环境中
            prepareEnvironment();
        }
        // 3. 刷新全部配置,将外部配置中心的配置应用到本地
        ConfigManager.getInstance().refreshAll();
    }

这里可以分为三步:

  1. this.configCenter.refresh(); :合并当前关于配置中心的属性。如配置中心的地址、协议等可能会在环境变量、外部配置、代码执行等多种方式指定,此时需要根据优先级来获取优先级最高的配置属性参数作为最终参数用于初始化配置中心。
  2. prepareEnvironment(); : 根据第一步中获取到的配置中心属性获取到配置中心实例,并读取配置中心的配置文件的内容,并保存到Environment 中。
  3. ConfigManager.getInstance().refreshAll(); :触发其他配置类的刷新操作,其他配置类会从 Environment 中读取到配置中心设置的内容,以完成自身内容的更新。

下面我们来详细分析上面几步的实现:

2.1 AbstractConfig#refresh

this.configCenter.refresh(); 调用的是 AbstractConfig#refresh。 这里主要是将当前配置中心的属性与已存在的配置中心属性按照配置优先级的规则进行属性合并。

    public void refresh() {
        try {	
        	//  getPrefix() 默认值为 dubbo.config-center。
        	//  1. 这里从 当前的配置级中获取 dubbo.config-center 前缀的复合配置,即获取已存在的配置中心的属性
            CompositeConfiguration compositeConfiguration = Environment.getInstance().getConfiguration(getPrefix(), getId());
            // 2. 构建当前配置中心的配置类 InmemoryConfiguration
            InmemoryConfiguration config = new InmemoryConfiguration(getPrefix(), getId());
            config.addProperties(getMetaData());
            // 3. 按照规则配置将现在的配置中心属性添加到复合配置中。
            if (Environment.getInstance().isConfigCenterFirst()) {
                // The sequence would be: SystemConfiguration -> ExternalConfiguration -> AppExternalConfiguration -> AbstractConfig -> PropertiesConfiguration
                compositeConfiguration.addConfiguration(3, config);
            } else {
                // The sequence would be: SystemConfiguration -> AbstractConfig -> ExternalConfiguration -> AppExternalConfiguration -> PropertiesConfiguration
                compositeConfiguration.addConfiguration(1, config);
            }

            // loop methods, get override value and set the new value back to method
            // 4. 和复合配置中的属性合并
            Method[] methods = getClass().getMethods();
            for (Method method : methods) {
                if (ClassHelper.isSetter(method)) {
                    try {
                        String value = compositeConfiguration.getString(extractPropertyName(getClass(), method));
                        // isTypeMatch() is called to avoid duplicate and incorrect update, for example, we have two 'setGeneric' methods in ReferenceConfig.
                        if (StringUtils.isNotEmpty(value) && ClassHelper.isTypeMatch(method.getParameterTypes()[0], value)) {
                            method.invoke(this, ClassHelper.convertPrimitive(method.getParameterTypes()[0], value));
                        }
                    } catch (NoSuchMethodException e) {
                        logger.info("Failed to override the property " + method.getName() + " in " +
                                this.getClass().getSimpleName() +
                                ", please make sure every property has getter/setter method provided.");
                    }
                }
            }
        } catch (Exception e) {
            logger.error("Failed to override ", e);
        }
    }

我们按照上面代码的注释分析:

  1. Environment#getConfiguration 实现如下,可以看到 CompositeConfiguration 作为一个复合配置类保存了多个配置信息。
  2. 构建一个临时的内存配置 InmemoryConfiguration 来保存当前配置中心的属性。
  3. 由于Dubbo支持多种配置方式,包括 系统配置、全局外部配置、应用级别外部配置、属性配置等方式,所以这里需要按照优先级规则保存到复合配置中。
  4. 通过反射的方式按照优先级合并当前配置中心的一些属性。如系统配置中设置了注册中心地址 address = zookeeper://localhost:2180,而当前配置中心的地址为 zookeeper://localhost:2181。那么当前配置中心的地址会被覆盖为 zookeeper://localhost:2180。因为 系统配置 的优先级高于其他配置。

关于上面的复合配置 :

由于 Dubbo 中存在很多作用域的配置,如注册中心的配置、配置中心的配置、服务接口的配置等, Dubbo将这些配置保存到 Environment 中,不同的配置存在不同前缀,如配置中心的前缀 dubbo.config-center、监控中心的前缀dubbo.monitor 等。当需要加载不同的配置时只需要指定前缀,如果配置精确到服务级别则使用 id来区分不同的服务。又由于 Dubbo 相同配置间存在优先级。所以在 Environment 中每个优先级存在一个 Map,而在上面的代码中,我们看到,如果设置了 configCenterFirst = true。则优先级为 SystemConfiguration -> ExternalConfiguration -> AppExternalConfiguration -> AbstractConfig -> PropertiesConfiguration
否则为 SystemConfiguration -> AbstractConfig -> ExternalConfiguration -> AppExternalConfiguration -> PropertiesConfiguration

在 Environment 中每个优先级声明为 一个 Map,其key 是作用域,value 为对应配置。下图为Environment 中定义的配置Map,其中 systemConfigs 是系统级别配置、externalConfigs 和 appExternalConfigs 是配置中心外部化配置,propertiesConfigs 是 属性配置等。
Dubbo笔记 ㉒ :配置中心_第6张图片

那么这里 我们再来看看这里 Environment#getConfiguration的实现如下:

    public CompositeConfiguration getConfiguration(String prefix, String id) {
        CompositeConfiguration compositeConfiguration = new CompositeConfiguration();
        // Config center has the highest priority
        compositeConfiguration.addConfiguration(this.getSystemConfig(prefix, id));
        compositeConfiguration.addConfiguration(this.getAppExternalConfig(prefix, id));
        compositeConfiguration.addConfiguration(this.getExternalConfig(prefix, id));
        compositeConfiguration.addConfiguration(this.getPropertiesConfig(prefix, id));
        return compositeConfiguration;
    }

这里可以看到,Environment#getConfiguration 返回的符合保存了多个级别针对于 prefix + id 的配置。CompositeConfiguration 使用 一个 List 保存添加的配置。

2.2 AbstractInterfaceConfig#prepareEnvironment

在上一步完成了对配置中心属性的合并。这一步则开始加载配置中心的内容,其逻辑还是比较清晰的,根据配置中心属性获取到配置中心实例后,将配置中心中的 dubbo.properties 配置读取出来并保存到 Environment 中。其实现如下:

    
	// org.apache.dubbo.config.AbstractInterfaceConfig#prepareEnvironment
    private void prepareEnvironment() {
        if (configCenter.isValid()) {
            if (!configCenter.checkOrUpdateInited()) {
                return;
            }
            // 1. 获取动态配置中心
            DynamicConfiguration dynamicConfiguration = getDynamicConfiguration(configCenter.toUrl());
            // 2. 获取 global 的配置,在 dubbo/config/dubbo/dubbo.properties 中
            String configContent = dynamicConfiguration.getConfig(configCenter.getConfigFile(), configCenter.getGroup());
		
            String appGroup = application != null ? application.getName() : null;
            String appConfigContent = null;
            if (StringUtils.isNotEmpty(appGroup)) {
            	// 3. 获取 application 级别的 配置. 在 dubbo/config/当前应用名/dubbo.properties 中
                appConfigContent = dynamicConfiguration.getConfig
                        (StringUtils.isNotEmpty(configCenter.getAppConfigFile()) ? configCenter.getAppConfigFile() : configCenter.getConfigFile(),
                         appGroup
                        );
            }
            try {
            	// 同步配置信息
            	// 设置优先级
                Environment.getInstance().setConfigCenterFirst(configCenter.isHighestPriority());
                // 更新  Environment global 的外部配置。  将配置信息保存到了Environment#appExternalConfigurationMap中
                Environment.getInstance().updateExternalConfigurationMap(parseProperties(configContent));
                // 更新 Environment  application 作用域的 外部配置。将配置信息保存到了Environment#externalConfigurationMap 中
                Environment.getInstance().updateAppExternalConfigurationMap(parseProperties(appConfigContent));
            } catch (IOException e) {
                throw new IllegalStateException("Failed to parse configurations from Config Center.", e);
            }
        }
    }

	// 根据协议类型获取动态配置的 实现类
    private DynamicConfiguration getDynamicConfiguration(URL url) {
    	// 获取 DynamicConfigurationFactory  spi  实现类
        DynamicConfigurationFactory factories = ExtensionLoader
                .getExtensionLoader(DynamicConfigurationFactory.class)
                .getExtension(url.getProtocol());
        // 获取动态配置
        DynamicConfiguration configuration = factories.getDynamicConfiguration(url);
        // 动态配置信息设值到 Environment
        Environment.getInstance().setDynamicConfiguration(configuration);
        return configuration;
    }

这里可以看到,全局配置作为 externalConfiguration 配置保存,应用配置作为 appExternalConfiguration 保存。在后面会根据优先级判定使用那个配置。

2.3 ConfigManager.getInstance().refreshAll();

加载完成配置中心的配置后则开始刷新所有的配置,以应用配置中心中的配置。这里则是调用了各个部分的refresh 来应用配置,具体不再详述。

    public void refreshAll() {
        // refresh all configs here,
        getApplication().ifPresent(ApplicationConfig::refresh);
        getMonitor().ifPresent(MonitorConfig::refresh);
        getModule().ifPresent(ModuleConfig::refresh);

        getProtocols().values().forEach(ProtocolConfig::refresh);
        getRegistries().values().forEach(RegistryConfig::refresh);
        getProviders().values().forEach(ProviderConfig::refresh);
        getConsumers().values().forEach(ConsumerConfig::refresh);
    }

总结一下:在服务启动时会检查缺省参数,在这个过程中会检查是否配置了配置中心,如果设置了,则会去读取配置中心中的配置文件,并将读取到的内容加载到本地环境作为之后操作的参数。

三、动态配置的实现

1. 功能介绍

从2.7.0 版本开始,Dubbo同时支持应用、服务两种粒度的服务治理规则,对于这两种粒度,其key取值规则如下:

  • 应用粒度 :{应用名 + 规则后缀}。如: demo-application.configurators、demo-application.tag-router等
  • 服务粒度 :{服务接口名:[服务版本]:[服务分组] + 规则后缀},其中服务版本、服务分组是可选的,如果它们有配置则在key中体现,没被配置则用":“占位。如org.apache.dubbo.demo.DemoService::.configurators、org.apache.dubbo.demo.DemoService:1.0.0:group1.configurators。

和之前外部化配置中心不同的是,这里的服务治理是动态更新的,也就是说我们这边修改后,服务会立刻感知到,这是因为无论是消费者还是提供者,在服务启动的时候就对配置节点进行了监听。当节点发生变化时,服务监听器可以感知,并重置更新配置。


我们可以通过dubbo-admin 对服务运行时的一些属性进行配置,如路由规则、权重调整等,如下图Dubbo-Admin 服务治理提供了如下选择:

  • 条件路由 :可以按照指定的条件完成服务的路由功能
  • 标签路由 :dubbo 2.7 新功能。可以按照指定的标签完成服务的路由功能
  • 黑白名单 :依赖于条件路由
  • 动态配置 :可以动态调整服务运行时的一些参数和规则
  • 权重调整 :依赖于动态配置
  • 负载均衡 :依赖于动态配置

Dubbo笔记 ㉒ :配置中心_第7张图片


我们可以通过 dubbo-admin-0.2.0 建立动态配置,其在zk节点的映射如下:
Dubbo笔记 ㉒ :配置中心_第8张图片
关于动态配置的具体的配置规则详参 : https://dubbo.apache.org/zh/docs/v2.7/user/examples/config-rule/


下面我们分为提供者和消费者两个方面来看具体实现:

2. 提供者端

虽然dubbo-admin 提供了服务治理的诸多功能,但是对于提供者来说只需要关注动态配置的内容即可。因为条件路由、便签路由、黑白名单、权重调整、负载均衡都是在集群层面的说法,是消费者需要才需要关注的。

在 Dubbo笔记 ⑤ : 服务发布流程 - Protocol#export 中,我们提到过,提供者会通过 RegistryProtocol#export 来暴露服务,并且在暴露服务时会订阅自身节点以便接收到动态配置的更新,其订阅的节点为 /dubbo/服务名/configurators。

以上图为例其订阅节点为 /dubbo/com.kingfish.service.impl.DemoService/configurators 。也就是说,当 /dubbo/com.kingfish.service.impl.DemoService/configurators 节点更新后,提供者就可以感知到并对本地的配置进行更新。


但当我们使用,dubbo-admin-0.2.0 来创建动态配置时,dubbo-admin-0.2.0 会调用 OverrideServiceImpl#saveOverride 来创建节点。如下(该代码是 dubbo-admin-0.2.0 代码):

	// 该代码是 dubbo-admin-0.2.0 代码
 	@java.lang.Override
    public void saveOverride(DynamicConfigDTO override) {
        String id = ConvertUtil.getIdFromDTO(override);
        String path = getPath(id);
        String exitConfig = dynamicConfiguration.getConfig(path);
        List<OverrideConfig> configs = new ArrayList<>();
        OverrideDTO existOverride = new DynamicConfigDTO2OverrideDTOAdapter(override);
        if (exitConfig != null) {
            existOverride = YamlParser.loadObject(exitConfig, OverrideDTO.class);
            if (existOverride.getConfigs() != null) {
                for (OverrideConfig overrideConfig : existOverride.getConfigs()) {
                    if (Constants.CONFIGS.contains(overrideConfig.getType())) {
                        configs.add(overrideConfig);
                    }
                }
            }
        }
        configs.addAll(override.getConfigs());
        existOverride.setEnabled(override.isEnabled());
        existOverride.setConfigs(configs);
        // 创建或更新 /dubbo/config/{服务分组}/{服务接口}:{服务版本}/configurators 节点内容
        dynamicConfiguration.setConfig(path, YamlParser.dumpObject(existOverride));

        //for2.6
        if (StringUtils.isNotEmpty(override.getService())) {
            List<Override> result = convertDTOtoOldOverride(override);
            for (Override o : result) {
            	// 创建或更新 /dubbo/{接口名称}/configurators 节点内容
                registry.register(o.toUrl().addParameter(Constants.COMPATIBLE_CONFIG, true));
            }
        }
    }

上面代码会创建(更新)两个节点,映射到zk 上的节点如下图:

Dubbo笔记 ㉒ :配置中心_第9张图片

即 dubbo-admin-0.2.0 的服务治理会创建(或更新)两个节点

  1. 会更新 /dubbo/{接口名称}/规则后缀 下面的节点的内容,兼容dubbo2.6版本,如上图2标注。
  2. 创建 /dubbo/config/{服务分组}/{服务接口}:{服务版本}/规则后缀 节点。该种方式是dubbo 2.7版本更新方式,如上图1标注。(配置中心是 zk的情况下该节点并不会起到作用,而是使用dubbo2.6版本的方式来更新配置,具体原因下面分析)

第一种方式 我们在之前的文章中已经分析,这里仅看 dubbo 2.7.0 版本的方式:


对于上面第二种方式,服务提供者通过 ProviderConfigurationListener 和 ServiceConfigurationListener 来监听 dubbo-admin-0.2.0 的服务治理内容。这两个监听器类都是 RegistryProtocol 内部类。其区别如下:

  • RegistryProtocol#ProviderConfigurationListener :该监听器监听应用级别的动态配置, 即监听 /dubbo/config/应用名/configurators
  • RegistryProtocol#ServiceConfigurationListener :该监听器监听服务级别的动态配置, 即监听 /dubbo/config/分组/接口:版本/configurators 。但实际上该类在配置中心是 zk 的情况下无效,这一点我们下面细讲。

2.1 ProviderConfigurationListener & ServiceConfigurationListener

ServiceConfigurationListener 和 ProviderConfigurationListener 都继承于AbstractConfiguratorListener,所以我们这里来看一下 AbstractConfiguratorListener 的实现:

2.1.1 AbstractConfiguratorListener

AbstractConfiguratorListener 类非常关键,其实现如下:

public abstract class AbstractConfiguratorListener implements ConfigurationListener {
    private static final Logger logger = LoggerFactory.getLogger(AbstractConfiguratorListener.class);

    protected List<Configurator> configurators = Collections.emptyList();


    protected final void initWith(String key) {
    	// 获取动态配置中心
        DynamicConfiguration dynamicConfiguration = DynamicConfiguration.getDynamicConfiguration();
        // 监听 key 的节点变化,监听器为自身,当key节点发生变化时会触发 AbstractConfiguratorListener#process
        dynamicConfiguration.addListener(key, this);
        // 获取 key 指定的配置内容,如果不为空,则手动触发process 来刷新配置
        String rawConfig = dynamicConfiguration.getConfig(key);
        if (!StringUtils.isEmpty(rawConfig)) {
            process(new ConfigChangeEvent(key, rawConfig));
        }
    }

    @Override
    public void process(ConfigChangeEvent event) {
        if (logger.isInfoEnabled()) {
            logger.info("Notification of overriding rule, change type is: " + event.getChangeType() +
                    ", raw config content is:\n " + event.getValue());
        }
		// 删除事件则清除缓存的配置
        if (event.getChangeType().equals(ConfigChangeType.DELETED)) {
            configurators.clear();
        } else {
            try {
                // parseConfigurators will recognize app/service config automatically.
                // 否则对配置进行解析,保存到 configurators  中
                configurators = Configurator.toConfigurators(ConfigParser.parseConfigurators(event.getValue()))
                        .orElse(configurators);
            } catch (Exception e) {
                logger.error("Failed to parse raw dynamic config and it will not take effect, the raw config is: " +
                        event.getValue(), e);
                return;
            }
        }
		// 通知事件,供子类实现
        notifyOverrides();
    }

    protected abstract void notifyOverrides();
	// 获取当前配置信息
    public List<Configurator> getConfigurators() {
        return configurators;
    }

    public void setConfigurators(List<Configurator> configurators) {
        this.configurators = configurators;
    }
}

我们这里要注意:

  1. 在本篇中,配置中心使用的是 zk,所以这里 DynamicConfiguration 的实现类是 ZookeeperDynamicConfiguration,其部分代码如下:

    public class ZookeeperDynamicConfiguration implements DynamicConfiguration {
        // The final root path would be: /configRootPath/"config"
        // rootPath 获取规则是 url.getParameter("config.namespace", "dubbo") + "/config" 。
        // "config.namespace" 默认为 dubbo
        private String rootPath;
        // 节点监听器。ZookeeperDynamicConfiguration 对 zk 的节点监听依赖于CacheListener 
        // 当 rootPath 节点有变化时会触发 CacheListener#childEvent 方法。
        private CacheListener cacheListener;
    	....
    
        ZookeeperDynamicConfiguration(URL url) {
            this.url = url;
            rootPath = "/" + url.getParameter(CONFIG_NAMESPACE_KEY, DEFAULT_GROUP) + "/config";
            ... 
            this.cacheListener = new CacheListener(rootPath, initializedLatch);
            ...
        }
    

    在 ZookeeperDynamicConfiguration 中我们关注下面几个内容:

    1. ZookeeperDynamicConfiguration#rootPath : 监听器监听的根节点路径,获取规则是 url.getParameter(“config.namespace”, “dubbo”) + “/config”
    2. ZookeeperDynamicConfiguration#cacheListener : 监听 rootPath 节点的监听器,当rootPath 节点变化时会触发CacheListener#childEvent 方法。
    3. ZookeeperDynamicConfiguration#addListener 会委托给 CacheListener#addListener 方法来处理。而 CacheListener#addListener 会将key,listener 保存到 CacheListener#keyListeners 中
  2. CacheListener#childEvent 实现如下:

        @Override
        public void childEvent(CuratorFramework aClient, TreeCacheEvent event) throws Exception {
    		// 获取事件类型和节点数据
            TreeCacheEvent.Type type = event.getType();
            ChildData data = event.getData();
            if (type == TreeCacheEvent.Type.INITIALIZED) {
                initializedLatch.countDown();
                return;
            }
    
            // TODO, ignore other event types
            if (data == null) {
                return;
            }
    
            // TODO We limit the notification of config changes to a specific path level, for example
            //  /dubbo/config/service/configurators, other config changes not in this level will not get notified,
            //  say /dubbo/config/dubbo.properties
            // 被 / 够至少大于 5 才会被处理。因为一个合法的节点 规则应该为 /dubbo/config/应用名/规则后缀 或 /dubbo/config/分组/接口:版本/规则后缀。
            if (data.getPath().split("/").length >= 5) {
                byte[] value = data.getData();
                // 按照规则转换路径,
                // 规则为 path.replace(rootPath + "/", "").replaceAll("/", ".")  :剔除 rootPath 并把 / 替换为 . 
                // 如 /dubbo/config/spring-dubbo-provider/configurators 会转化为 spring-dubbo-provider.configurators
                String key = pathToKey(data.getPath());
                ConfigChangeType changeType;
                // 事件判断
                switch (type) {
                    case NODE_ADDED:
                        changeType = ConfigChangeType.ADDED;
                        break;
                    case NODE_REMOVED:
                        changeType = ConfigChangeType.DELETED;
                        break;
                    case NODE_UPDATED:
                        changeType = ConfigChangeType.MODIFIED;
                        break;
                    default:
                        return;
                }
    			// 封装成事件
                ConfigChangeEvent configChangeEvent = new ConfigChangeEvent(key, new String(value, StandardCharsets.UTF_8), changeType);
                // 从 keyListeners 中获取监听此节点(key) 的监听器,触发其监听事件
                Set<ConfigurationListener> listeners = keyListeners.get(key);
                if (CollectionUtils.isNotEmpty(listeners)) {
                    listeners.forEach(listener -> listener.process(configChangeEvent));
                }
            }
        }
    

    这里注意:

    1. 这里存在一个 节点到 路径的转换。规则为 path.replace(rootPath + “/”, “”).replaceAll("/", “.”) 。如 /dubbo/config/spring-dubbo-provider/configurators 会转化为 spring-dubbo-provider.configurators
    2. keyListeners 集合中保存的就是 CacheListener#addListener 方法添加的监听器。也即是上面通过 DynamicConfiguration#addListener 添加的监听器。

2.1.2 ProviderConfigurationListener

下面我们来看看 ProviderConfigurationListener 的实现。

  private class ProviderConfigurationListener extends AbstractConfiguratorListener {

        public ProviderConfigurationListener() {
        	// 传入 监听的 key。这里为 {应用名}.configurators
            this.initWith(ApplicationModel.getApplication() + CONFIGURATORS_SUFFIX);
        }


         // 获取现有配置规则,并在导出之前覆盖提供程序URL。当服务发布时会调用此方法来对配置进行刷新。
        private <T> URL overrideUrl(URL providerUrl) {
            return RegistryProtocol.getConfigedInvokerUrl(configurators, providerUrl);
        }
		
		// 当监听节点有变化时会触发该方法,此方法会调用监听器的doOverrideIfNecessary 方法来刷新本地缓存的配置。
        @Override
        protected void notifyOverrides() {
        	// 遍历所有的 overrideListeners 并调用 doOverrideIfNecessary 来刷新 配置。
            overrideListeners.values().forEach(listener -> ((OverrideListener) listener).doOverrideIfNecessary());
        }
    }

ProviderConfigurationListener 针对应用级别的服务,由于一个服务只能有一个应用名,所以在 RegistryProtocol 中 ProviderConfigurationListener 作为属性直接初始化。

    private final ProviderConfigurationListener providerConfigurationListener = new ProviderConfigurationListener();
  1. 当服务发布时,会在 RegistryProtocol#overrideUrlWithConfig 中调用ProviderConfigurationListener#overrideUrl 来刷新本地缓存的配置。
  2. 当进行服务治理时,会触发ProviderConfigurationListener#notifyOverrides 方法。在该方法中会调用监听器的doOverrideIfNecessary 方法来刷新本地配置缓存。(这里会调用 OverrideListener#doOverrideIfNecessary 来刷新缓存)

2.1.3 ServiceConfigurationListener

ServiceConfigurationListener 的实现 和 ProviderConfigurationListener 类似

    private class ServiceConfigurationListener extends AbstractConfiguratorListener {
        private URL providerUrl;
        private OverrideListener notifyListener;

        public ServiceConfigurationListener(URL providerUrl, OverrideListener notifyListener) {
            this.providerUrl = providerUrl;
            this.notifyListener = notifyListener;
			//  传入 监听的 key。
            this.initWith(providerUrl.getEncodedServiceKey() + CONFIGURATORS_SUFFIX);
        }
        
		// 获取现有配置规则,并在导出之前覆盖提供程序URL。当服务发布时会调用此方法来对配置进行刷新。
        private <T> URL overrideUrl(URL providerUrl) {
            return RegistryProtocol.getConfigedInvokerUrl(configurators, providerUrl);
        }
		
		// 当监听的节点更新后会触发 notifyListener.doOverrideIfNecessary 来刷新本地 url 并重新发布
        @Override
        protected void notifyOverrides() {
            notifyListener.doOverrideIfNecessary();
        }
    }

ServiceConfigurationListener 针对服务级别的服务,由于可以存在多个服务,所以在 RegistryProtocol中 ServiceConfigurationListener 是以map 的形式存在。其中key对应的是某个服务的唯一值,value对应的是针对该服务的监听器。

    private final Map<String, ServiceConfigurationListener> serviceConfigurationListeners = new ConcurrentHashMap<>();

2.1.4 一点推测

以下是本人推测,正确性无法保证:

在 ServiceConfigurationListener 的构造函数,其中 this.initWith(providerUrl.getEncodedServiceKey() + CONFIGURATORS_SUFFIX); 中 URL#getEncodedServiceKey 的实现如下:

    public String getEncodedServiceKey() {
        String serviceKey = this.getServiceKey();
        serviceKey = serviceKey.replaceFirst("/", "*");
        return serviceKey;
    }

这里会将 第一个 / 替换成 * 。这里导致服务的规则是 {分组}*{服务接口}:{版本号}.{规则后缀},而 CacheListener#pathToKey 的规则是 {分组}.{服务接口}:{版本号}.{规则后缀}。举下面一个例子:

  1. 我们假设服务存在服务 com.kingfish.service.impl.DemoService:1.0.0,分组为 main
  2. 按照上面的分析,在服务启动时会通过 ServiceConfigurationListener 来监听服务级别的动态配置。那么其监听的key为 this.initWith(providerUrl.getEncodedServiceKey() + CONFIGURATORS_SUFFIX);main*com.kingfish.service.impl.DemoService:1.0.0.configurators
  3. 当我们在zk上创建动态配置后,其在zk 上的动态配置的节点为 /dubbo/config/main/com.kingfish.service.impl.DemoService:1.0.0/configurators
  4. /dubbo/config/main/com.kingfish.service.impl.DemoService:1.0.0/configurators 节点发生改变后(dubbo-admin 修改了服务级别的动态配置),会触发 CacheListener#childEvent 方法, CacheListener#childEvent 方法会调用 CacheListener#pathToKey 方法按照规则将 上面的 zk 路径转化为 main.com.kingfish.service.impl.DemoService:1.0.0.configurators, 并使用此作为key去 CacheListener#keyListeners 中寻找监听此节点的监听器。
  5. 而此时 CacheListener#keyListeners 中的监听器 key 却是上面提到的 main*com.kingfish.service.impl.DemoService:1.0.0.configurators。这就导致,由于无法匹配上监听的key,ServiceConfigurationListener#notifyOverrides 就无法被触发。即 ServiceConfigurationListener 没有完成这种情况下服务治理功能。

即总结:

  • 对于 dubbo 2.6 及以下版本来说,并不存在配置中心,所有的数据都是保存在注册中心上。服务级别服务治理的功能是通过监听 /dubbo/{接口名}/{规则节点} 来实现的,当 dubbo-admin 进行服务治理后,dubbo-admin 会修改zk 上对应的 /dubbo/{接口名}/{规则节点} 节点信息。而对应的接口服务会监听 /dubbo/{接口名}/{规则节点} 节点,当节点被修改时服务会感知到从而刷新自身配置。而应用级别的服务治理,dubbo 2.6 版本不支持。
  • 对于配置中心是 zk 且 dubbo 2.7 版本来说,服务级别服务治理的功能 并非是通过ServiceConfigurationListener 完成,而是通过兼容的 dubbo 2.6 版本 来完成,也就是说其实现逻辑和 dubbo 2.6 相同。
  • 对于配置中心是 zk 且 dubbo 2.7 版本来说,应用级别服务治理则是通过 ProviderConfigurationListener 完成。
  • 对于其他配置中心(如nacos)且 dubbo 2.7 版本,服务治理则是完全通过 ServiceConfigurationListener 和 ProviderConfigurationListener 来完成。

2.2 监听器的初始化

在RegistryProtocol 中 serviceConfigurationListenersproviderConfigurationListener 的构造如下,由于一个服务只存在一个应用名,所以应用级别的监听ProviderConfigurationListener 在服务初始化时就创建,而一个服务可以发布多个服务名,所以这里使用Map 结构来保存 服务级别的监听 :

    private final Map<String, ServiceConfigurationListener> serviceConfigurationListeners = new ConcurrentHashMap<>();
    private final ProviderConfigurationListener providerConfigurationListener = new ProviderConfigurationListener();

我们知道,提供者会通过 RegistryProtocol#export 来暴露服务,并且在暴露服务时会订阅自身节点以便接收到动态配置的更新,其订阅的节点为 /dubbo/服务名/configurators。

下面我们来看一下这部分代码:

    @Override
    public <T> Exporter<T> export(final Invoker<T> originInvoker) throws RpcException {
    	// 生成订阅的 url
        final URL overrideSubscribeUrl = getSubscribedOverrideUrl(providerUrl);
        // 订阅 overrideSubscribeUrl  的监听器
        final OverrideListener overrideSubscribeListener = new OverrideListener(overrideSubscribeUrl, originInvoker);
        overrideListeners.put(overrideSubscribeUrl, overrideSubscribeListener);
        // 添加监听器,从配置中心获取配置并应用。
        providerUrl = overrideUrlWithConfig(providerUrl, overrideSubscribeListener);
        ....

		// 进行订阅,当 overrideSubscribeUrl 更新时会触发 overrideSubscribeListener 监听器
        registry.subscribe(overrideSubscribeUrl, overrideSubscribeListener);
		....
        return new DestroyableExporter<>(exporter);
    }

其中会调用 RegistryProtocol#overrideUrlWithConfig 方法,该方法完成了配置同步 并且添加了服务监听。

    private URL overrideUrlWithConfig(URL providerUrl, OverrideListener listener) {
    	// 1. 获取应用级别现有配置规则,并在导出之前覆盖提供程序URL。
        providerUrl = providerConfigurationListener.overrideUrl(providerUrl);
        // 2. 初始化服务级别监听器,并保存到 serviceConfigurationListeners 中,完成了对当前服务的监听。
        ServiceConfigurationListener serviceConfigurationListener = new ServiceConfigurationListener(providerUrl, listener);
        serviceConfigurationListeners.put(providerUrl.getServiceKey(), serviceConfigurationListener);
        // 3. 获取服务级别现有配置规则,并在导出之前覆盖提供程序URL。
        return serviceConfigurationListener.overrideUrl(providerUrl);
    }

RegistryProtocol#overrideUrlWithConfig 添加了服务的监听,并进行了配置同步。这里我们可以总结为:

  1. 使用应用级别的配置信息合并本地配置
  2. 创建针对于当前服务的监听器ServiceConfigurationListener ,用于监听服务级别的配置
  3. 使用服务级别的配置信息合并本地配置

2.3 监听器的触发

当动态配置发生改变后,监听器接收到感知后会触发RegistryProtocol.OverrideListener#doOverrideIfNecessary 方法,该方法会合并配置,如果配置发生改变,则会重新发布服务。其实现如下,其实现如下:

    public synchronized void doOverrideIfNecessary() {
        final Invoker<?> invoker;
      	....
        //The current, may have been merged many times
        // 获取当前 url
        URL currentUrl = exporter.getInvoker().getUrl();
        //Merged with this configuration
        // 合并 /dubbo/接口名/configurators 节点下的配置
        URL newUrl = getConfigedInvokerUrl(configurators, originUrl);
        // 合并  /dubbo/config/分组/接口:版本/configurators 节点的配置
        newUrl = getConfigedInvokerUrl(serviceConfigurationListeners.get(originUrl.getServiceKey())
                .getConfigurators(), newUrl);
        // 合并 /dubbo/config/应用名/configurators 节点的配置
        newUrl = getConfigedInvokerUrl(providerConfigurationListener.getConfigurators(), newUrl);
        // 经过上面三次合并后,如果url变化了,则说明有配置更新,则重新发布
        if (!currentUrl.equals(newUrl)) {
            RegistryProtocol.this.reExport(originInvoker, newUrl);
            logger.info("exported provider url changed, origin url: " + originUrl +
                    ", old export url: " + currentUrl + ", new export url: " + newUrl);
        }
    }

2.4 总结

我们这里来总结一下提供者端的流程:

  1. 当提供者启动后,会调用 RegistryProtocol#export 来暴露自身服务,这其中会初始化 ProviderConfigurationListener 和 ServiceConfigurationListener 。
  2. 由于 ProviderConfigurationListener 和 ServiceConfigurationListener 在构造函数中直接调用了 AbstractConfiguratorListener#initWith 方法,而 该方法会入参作为key 去监听配置中心的配置。所以在服务者端服务启动时,在初始化 RegistryProtocol#providerConfigurationListener 时会监听当前应用级别的配置。 在 RegistryProtocol#export 方法中会添加对当前服务的监听器。
  3. 当通过 dubbo-admin-0.2.0 进行服务治理时会修改配置中心的节点数据。此时会触发 ProviderConfigurationListener 或 ServiceConfigurationListener 的监听方法,而其监听方法则是 RegistryProtocol.OverrideListener#doOverrideIfNecessary,该方法会合并配置,如果配置发生改变,则会重新发布服务。

3. 消费者端

消费者端的实现和 提供者类似,也是依赖于两个监听器类,如下:

  • RegistryDirectory#ReferenceConfigurationListener : 监听消费者引入服务的服务级别的动态配置,即监听 /dubbo/config/分组/接口:版本/configurators 节点。但是对于消费者来说仍然存在 2.1.4 一点推测 中所说的问题,也就是说 在以zk 为配置中心的情况下,动态配置并非通过RegistryDirectory#ReferenceConfigurationListener 完成,而是通过兼容的 dubbo 2.6 版本 来完成。
  • RegistryDirectory#ConsumerConfigurationListener : 监听消费者服务 应用级别的动态配置, 即监听 /dubbo/config/应用名/configurators

3.1 消费者端的监听器

ConsumerConfigurationListener 和 ReferenceConfigurationListener 也都继承了 AbstractConfiguratorListener ,逻辑也基本类似。具体实现如下。

3.1.1 ConsumerConfigurationListener

ConsumerConfigurationListener 实现如下:

    private static class ConsumerConfigurationListener extends AbstractConfiguratorListener {
        List<RegistryDirectory> listeners = new ArrayList<>();

        ConsumerConfigurationListener() {
        	// 订阅 消费者应用的应用级别的动态配置 
            this.initWith(ApplicationModel.getApplication() + Constants.CONFIGURATORS_SUFFIX);
        }

        void addNotifyListener(RegistryDirectory listener) {
            this.listeners.add(listener);
        }
		
		// 通知节点刷新,触发了 RegistryDirectory#refreshInvoker,刷新引用的服务的本地配置
        @Override
        protected void notifyOverrides() {
            listeners.forEach(listener -> listener.refreshInvoker(Collections.emptyList()));
        }
    }

当消费者启动后会订阅自身 应用级别的动态配置信息。当应用配置发生改变后会逐一刷新自身引用的服务的本地配置。

3.1.2 ReferenceConfigurationListener

ReferenceConfigurationListener 实现如下:

    private static class ReferenceConfigurationListener extends AbstractConfiguratorListener {
        private RegistryDirectory directory;
        private URL url;

        ReferenceConfigurationListener(RegistryDirectory directory, URL url) {
            this.directory = directory;
            this.url = url;
            // 订阅引用服务的 服务级别配置,这里存在和提供者一样的问题,该问题在 2.1.4 一点推测里进行了介绍
            this.initWith(url.getEncodedServiceKey() + Constants.CONFIGURATORS_SUFFIX);
        }
        
		// 通知节点刷新,触发了 RegistryDirectory#refreshInvoker
        @Override
        protected void notifyOverrides() {
            // to notify configurator/router changes
            // 通知有配置或路由改变,这里应该只能监听到有动态配置改变
            directory.refreshInvoker(Collections.emptyList());
        }
    }

这里可以看到,配置节点更新会,触发了 RegistryDirectory#refreshInvoker,而完成了本地服务缓存的更新。

3.2 监听器的初始化

在 RegistryDirectory#subscribe 方法中 二者完成了订阅功能,如下:

    public void subscribe(URL url) {
        setConsumerUrl(url);
        // 1. 订阅本应用的配置,当本应用的配置更新时会触发 RegistryDirectory的监听回调方法 refreshInvoker 来刷新本地缓存的配置
        consumerConfigurationListener.addNotifyListener(this);
        // 2. 订阅引入的服务的配置,当引入动态配置更新时会触发 RegistryDirectory的监听回调方法 refreshInvoker 来刷新本地缓存的配置
        serviceConfigurationListener = new ReferenceConfigurationListener(this, url);
        // 订阅当前服务
        registry.subscribe(url, this);
    }

当消费者引用某一个服务时会创建其代理对象,在创建过程中消费者会从订阅如下节点,当节点变化时则会触发其监听回调。而 RegistryDirectory#subscribe 则是订阅的过程。

	/dubbo/服务名/providers  
	/dubbo/服务名/routers
	/dubbo/服务名/congurators

消费者的启动过程详参:

  1. Dubbo笔记 ⑧ : 消费者启动流程 - ReferenceConfig#get
  2. Dubbo笔记 ⑨ : 消费者启动流程 - RegistryProtocol#refer
  3. Dubbo笔记 ⑩ : 消费者启动流程 - DubboProtocol#refer

3.3 监听器的触发

当监听节点发生变化时会触发 RegistryDirectory#refreshInvoker 方法。该方法详参Dubbo笔记 ⑨ : 消费者启动流程 - RegistryProtocol#refer

这里简单看一下:RegistryDirectory#refreshInvoker 中会调用 RegistryDirectory#toInvokers 将 url 转换为 Invoker。其中会调用 RegistryDirectory#mergeUrl 方法来将 参数合并,其实现如下:

  private URL mergeUrl(URL providerUrl) {
  		// 合并消费者端的参数
        providerUrl = ClusterUtils.mergeUrl(providerUrl, queryMap); // Merge the consumer side parameters
		// 合并动态配置的参数
        providerUrl = overrideWithConfigurator(providerUrl);
		// 不检查连接是否成功,一直创建Invoker
        providerUrl = providerUrl.addParameter(Constants.CHECK_KEY, String.valueOf(false)); // Do not check whether the connection is successful or not, always create Invoker!

        // The combination of directoryUrl and override is at the end of notify, which can't be handled here
        // 合并提供者端的参数到 当前服务目录中
        this.overrideDirectoryUrl = this.overrideDirectoryUrl.addParametersIfAbsent(providerUrl.getParameters()); // Merge the provider side parameters

        if ((providerUrl.getPath() == null || providerUrl.getPath()
                .length() == 0) && "dubbo".equals(providerUrl.getProtocol())) { // Compatible version 1.0
            //fix by tony.chenl DUBBO-44
            String path = directoryUrl.getParameter(Constants.INTERFACE_KEY);
            if (path != null) {
                int i = path.indexOf('/');
                if (i >= 0) {
                    path = path.substring(i + 1);
                }
                i = path.lastIndexOf(':');
                if (i >= 0) {
                    path = path.substring(0, i);
                }
                providerUrl = providerUrl.setPath(path);
            }
        }
        return providerUrl;
    }

其中我们看到 providerUrl = overrideWithConfigurator(providerUrl); 合并了动态配置的参数,其实现如下:

  private URL overrideWithConfigurator(URL providerUrl) {
        // override url with configurator from "override://" URL for dubbo 2.6 and before
        //兼容 2.6 版本的配置合并
        providerUrl = overrideWithConfigurators(this.configurators, providerUrl);

        // override url with configurator from configurator from "app-name.configurators"
        // 这里通过 consumerConfigurationListener 获取消费者应用级别的动态配置并覆盖
        providerUrl = overrideWithConfigurators(consumerConfigurationListener.getConfigurators(), providerUrl);

        // override url with configurator from configurators from "service-name.configurators"
        if (serviceConfigurationListener != null) {
        	// 如果有针对于当前引用服务的服务级别动态配置,则使用该配置进行合并。
            providerUrl = overrideWithConfigurators(serviceConfigurationListener.getConfigurators(), providerUrl);
        }

        return providerUrl;
    }

3.4 总结

  1. 消费者在引用一个服务的时候,首先会创建其服务代理,在创建代理的过程中 需要订阅注册中心的节点信息,以便可以方便感知节点变化,订阅的方法为 RegistryDirectory#subscribe。这其中还会添加 ConsumerConfigurationListener 对消费者应用级别配置的监听,和 serviceConfigurationListener对提供者服务级别的监听。
  2. 当对消费者应用级别动态配置或提供者服务级别动态配置时,会触发其监听方法 AbstractConfiguratorListener#notifyOverrides,此时会调用 RegistryDirectory#refreshInvoker 来刷新本地 Invoker,在刷新的过程中会读取动态配置信息并合并到本地。

五、其他

1. nacos 作为配置中心

dubbo:
  application:
    name: spring-dubbo-provider
    monitor: http://localhost:8080
  registry:
    address: zookeeper://localhost:2181
  # 配置中心
  config-center:
  	# 指定nacos
    address: nacos://localhost:8848
    # 指定命名空间
    namespace: 4d2e74c1-b983-4054-b03b-c65eb7cedb78

当 nacos 配置改变后会触发

NacosDynamicConfiguration.NacosConfigListener#receiveConfigInfo 
-> NacosDynamicConfiguration.NacosConfigListener#innerReceive
	-> AbstractConfiguratorListener#process 
	-> AbstractConfiguratorListener#notifyOverrides
	-> 触发 dubbo的刷新操作

以上:内容部分参考
《深度剖析Apache Dubbo 核心技术内幕》
https://dubbo.apache.org/zh/docs/v2.7/dev/source/
https://dubbo.apache.org/zh/docs/v2.7/dev/impls/config-center/
https://blog.csdn.net/huanglei_hacker/article/details/107127558
https://blog.csdn.net/u012881904/article/details/95891448
https://blog.csdn.net/huanglei_hacker/article/details/107127558
https://segmentfault.com/a/1190000039947964?utm_source=sf-similar-article
https://github.com/apache/dubbo-admin/wiki/服务治理兼容性说明
如有侵扰,联系删除。 内容仅用于自我记录学习使用。如有错误,欢迎指正

你可能感兴趣的:(#,Dubbo笔记篇,zookeeper)