深入理解Dubbo原理系列(二)- Dubbo注册中心Zookeeper

一. Zookeeper注册中心

1.1 Zookeeper原理概述

首先,注册中心是Dubbo的核心组件之一,而Dubbo一般而言用的都是Nacos或者Zookeeper,这里以Zookeeper为例来进行说。(Nacos我有过相关的原理文章,Zookeeper确实还没有写过,这里写个大概)

Zookeeper是一个树形结构的注册中心,每个节点的类型分为:

  • 持久节点:服务注册后保证节点不会丢失,注册中心重启时也会存在。
  • 持久顺序节点:在持久节点特性的基础上增加了节点先后顺序的能力。
  • 临时节点:服务注册后连接丢失或者Session超时的话,注册的节点会自动被移除。
  • 临时顺序节点:在临时节点特性的基础上增加了节点先后顺序的能力。

对于Dubbo相关节点的创建,只会创建两种节点:持久节点临时节点,Dubbo结合Zookeeper后,创建的数据树为:
在这里插入图片描述
xxxService代表服务暴露的接口,这里是该接口的全路径地址,例如下图中的com.pro.service.UserService

在这里插入图片描述
树形结构的关系:

  1. 树的根节点是注册中心分组。下面有多个服务接口,分组值来自用户配置的<dubbo:registry>中的group属性,默认是/dubbo

  2. 每个服务接口下包含4类子目录,分别是providersconsumersconfiguratorsrouters该路径是持久节点。

  3. 服务提供者目录/xxx/providers/下包含的接口中存在多个服务者URL元数据的信息,该路径下是临时节点。,若服务没有启动,数据如下:
    在这里插入图片描述
    若服务启动了,数据如下:
    在这里插入图片描述

  4. 服务消费者目录/xxx/consumers/下包含的接口有多个消费者URL元数据的信息,同理,是个临时节点

  5. 路由配置目录/xxx/routers/下包含多个用于消费者路由策略URL元数据信息

  6. 动态配置目录/xxx/configurators/下包含多个用于服务者动态配置URL元数据信息

二. Zookeeper订阅/发布

既然说注册中心是Dubbo的核心组件之一,那么,对于注册中心而言,其订阅发布功能又是他的一个核心关注点。

在使用RPC调用的时候,服务提供者的上下线需要发布出去,从而订阅者才能够调用服务,同时避免服务调用已经下线的节点。而这就需要订阅发布功能的实现,并且该过程也是全自动的。对于Dubbo而言,Dubbo抽象了这么一个工作流程,同时可以有不同的实现,这里主要从Zookeeper的实现来讲。
从官网下载Dubbo的源码,可以看到有这么一个maven项目(附带Test自动化,改一下自己的IP地址即可,很方便测试):在这里插入图片描述

2.1 发布源码分析

1.其中有一个类叫做ZookeeperRegistry,它主要负责服务的一个注册,看下他的继承关系:
在这里插入图片描述
我们再来看下AbstractRegistry类中的几个重要属性:

// 保存注册的服务
private final Set<URL> registered = new ConcurrentHashSet<URL>();
// 保存订阅url的通知事件
private final ConcurrentMap<URL, Set<NotifyListener>> subscribed = new ConcurrentHashMap<URL, Set<NotifyListener>>();
// 保存通知的url列表,其实是内存中的服务缓存对象
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<URL, Map<String, List<URL>>>();
// 注册中心的url
private URL registryUrl; 

紧接着我们从Test类出发:

public class ZookeeperRegistryTest {
    private TestingServer zkServer;
    private ZookeeperRegistry zookeeperRegistry;
    private String service = "org.apache.dubbo.test.injvmServie";
    private URL serviceUrl = URL.valueOf("zookeeper://192.168.237.130/" + service + "?notify=false&methods=test1,test2");
    private URL anyUrl = URL.valueOf("zookeeper://192.168.237.130/*");
    private URL registryUrl;
    private ZookeeperRegistryFactory zookeeperRegistryFactory;

	// 在Test方法执行之前先执行该方法setUp
    @BeforeEach
    public void setUp() throws Exception {
        int zkServerPort = NetUtils.getAvailablePort();
        this.zkServer = new TestingServer(zkServerPort, true);
        this.zkServer.start();

        this.registryUrl = URL.valueOf("zookeeper://192.168.237.130:" + 2181);
        // 初始化一个Zookeeper注册工厂
        zookeeperRegistryFactory = new ZookeeperRegistryFactory();
        zookeeperRegistryFactory.setZookeeperTransporter(new CuratorZookeeperTransporter());
        // 从注册工厂中获得一个Zookeeper注册器
        this.zookeeperRegistry = (ZookeeperRegistry) zookeeperRegistryFactory.createRegistry(registryUrl);
    }

	// 测试方法完成后执行的方法
    @AfterEach
    public void tearDown() throws Exception {
        zkServer.stop();
    }
    
    @Test
    public void testRegister() {
        Set<URL> registered;

        for (int i = 0; i < 2; i++) {
        	// 进行注册,传入的参数值:zookeeper://192.168.237.130/org.apache.dubbo.test.injvmServie?methods=test1,test2&notify=false
            zookeeperRegistry.register(serviceUrl);
            registered = zookeeperRegistry.getRegistered();
            assertThat(registered.contains(serviceUrl), is(true));
        }

        registered = zookeeperRegistry.getRegistered();
        assertThat(registered.size(), is(1));
    }
}    

2.从zookeeperRegistry.register()方法开始:

// ZookeeperRegistry是FailbackRegistry的子类
// 此时调用的是其父类FailbackRegistry重写的register()方法
public abstract class FailbackRegistry extends AbstractRegistry {
	@Override
    public void register(URL url) {
    	// 检查URL的合法性
        if (!acceptable(url)) {
            logger.info("URL " + url + " will not be registered to Registry. Registry " + url + " does not accept service of this protocol type.");
            return;
        }
        // 调用AbstractRegistry的register()方法,往registryUrl集合中添加一条URL数据
        super.register(url);
        // 删除失败的已经注册过的URL
        removeFailedRegistered(url);
        // 删除失败的未注册过的URL
        removeFailedUnregistered(url);
        try {
            // 发送一个注册请求
            doRegister(url);
        } catch (Exception e) {
            // ..若失败的处理逻辑
        }
    }
}

3.看下doRegister(url)这个发送注册请求的方法,其有4种实现,进入ZookeeperRegistry类:
在这里插入图片描述

public class ZookeeperRegistry extends FailbackRegistry {
	private final ZookeeperClient zkClient;// Zookeeper客户端
	
	@Override
    public void doRegister(URL url) {
        try {
        	//url:zookeeper://192.168.237.130/org.apache.dubbo.test.injvmServie?methods=test1,test2&notify=false
        	// toUrlPath()方法主要是将url进行一个数据的格式化,比如 ”:“转变成 “%3A”
            zkClient.create(toUrlPath(url), url.getParameter(DYNAMIC_KEY, true));
        } catch (Throwable e) {
            throw new RpcException("Failed to register " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
        }
    }
}    

4.ZookeeperClient是一个接口,其接口方法由抽象子类AbstractZookeeperClient来实现,即zkClient.create()方法实则调用的是AbstractZookeeperClient.create()方法。(注意看注释的顺序)

// ZookeeperClient是一个接口,其接口方法由抽象子类AbstractZookeeperClient来实现
public abstract class AbstractZookeeperClient<TargetDataListener, TargetChildListener> implements ZookeeperClient {
	// 已经注册的服务集合
	private final Set<String>  persistentExistNodePath = new ConcurrentHashSet<>();
	@Override
    public void create(String path, boolean ephemeral) {
    	// 1.如果说是保存为持久节点,那么先进入下面的递归调用,ephemeral默认传入的是true
    	//   1.1若第一次调用,那么举个path路径的例子,此时为:/dubbo/org.apache.dubbo.test.injvmServie/providers/zookeeper%3A%2F%2F192.168.237.130%2Forg.apache.dubbo.test.injvmServie%3Fmethods%3Dtest1%2Ctest2%26notify%3Dfalse
    	//   2.1第二次通过递归调用时,path变为:/dubbo/org.apache.dubbo.test.injvmServie/providers,
    	//   2.2此时传入的参数ephemeral==false
        if (!ephemeral) {
        	// 3.此时检查注册过的节点中是否已经存在该节点,若存在则return
            if(persistentExistNodePath.contains(path)){
                return;
            }
            if (checkExists(path)) {
                persistentExistNodePath.add(path);
                return;
            }
        }
        // 2.获取path路径中,从后往前第一个字符为/的下标
        int i = path.lastIndexOf('/');
        if (i > 0) {
        	// 2.递归再次调用create()方法
            create(path.substring(0, i), false);
        }
        // 4. 递归结束,创建临时节点,调用createEphemeral()方法
        if (ephemeral) {
            createEphemeral(path);
        } else {
        	// 否则创建持久节点,调用createPersistent()方法
            createPersistent(path);
            persistentExistNodePath.add(path);
        }
    }
}

此时可以看看Zookeeper上的数据树(还未创建节点):
在这里插入图片描述

5.以创建默认的临时节点为例,即查看createEphemeral(path)方法,该方法最终执行了CuratorZookeeperClient类中的createEphemeral()

public class CuratorZookeeperClient extends AbstractZookeeperClient<CuratorZookeeperClient.CuratorWatcherImpl, CuratorZookeeperClient.CuratorWatcherImpl> {
	@Override
    public void createEphemeral(String path) {
        try {
            client.create().withMode(CreateMode.EPHEMERAL).forPath(path);
        } 
        // ...
    }
}

执行完该方法后,再次查看Zookeeper上的数据树,可以发现path已经注册成功:
在这里插入图片描述
到这里,服务的注册(发布)也就成功了,本质上也就是对Zookeeper上数据的一个添加而已(若注册中心上有,那么就用已存在的),而较为复杂的功能则是订阅。接下来来看看订阅的代码。

2.2 订阅源码分析

订阅通常有Pull和Push两种方式:

  • Pull:客户端定时轮询注册中心拉取配置。
  • Push:注册中心主动推送数据给客户端。

目前Dubbo采用的是:第一次启动采用拉取,后续接收事件重新拉取数据。在服务暴露的时候,服务端会订阅configurators,用于监听动态配置,在消费端启动的时候,消费端则订阅providersroutersconfigurators三个Zookeeper目录,分别对应服务提供者、路由、动态配置变更通知。

无论是服务的提供者还是消费者、服务治理中心,任何一个节点连接到Zookeeper注册中心上都需要一个客户端。,而Dubbo就封装了两种Zookeeper的客户端接口:

  1. Apache Curator。(默认,所以我们发布代码中,最终的create()方法用的是CuratorZookeeperClient类下的)
  2. zkClient
    用户可以在<dubbo:registry>中的client属性设置curator、zkclient来使用不同的客户端实现。默认是curator。

订阅的原理如下:

  1. 客户端在第一次连接上Zookeeper注册中心时,会获取对应目录下全量的数据。
  2. 同时在订阅的节点上注册一个Watcher,客户端和注册中心之间保持TCP的长连接。
  3. 后续每个节点发生任何的数据变化时,注册中心则根据Watcher的回调来主动通知客户端(一次事件通知)。
  4. 客户端接收到通知,把对应节点下的全量数据再进行一次拉取(全量)。

备注:
Zookeeper的每个节点都有一个版本号,只有某个节点的数据发生变化(事务操作)时,该节点的版本号才会发生变化,并触发Watcher事件,推送给订阅方。

说了这么多,接下来从源码角度再来看看:

1.在客户端启动的时候,订阅获取全量数据,会执行到ZookeeperRegistry.doSubscribe()方法:

@Override
public void doSubscribe(final URL url, final NotifyListener listener) {
    try {
        // 1.处理所有服务的订阅,客户端第一次连接上注册中心上时,进行全量数据的拉取
        if (ANY_VALUE.equals(url.getServiceInterface())) {
            String root = toRootPath();
            // 2.获取URL的所有监听器,若url对应的监听器为空,那么初始化一个监听器返回  zkListeners是一个监听器集合
            ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
            // 3.获取ChildListener,若第一次连接时为空,根据computeIfAbsent()方法肯定会新建一个ChildListener
            ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> {
                // 4.遍历所有的子节点,
                for (String child : currentChilds) {
                    child = URL.decode(child);
                    // 5.若有新增服务,那么发起对这个服务的订阅。
                    if (!anyServices.contains(child)) {
                        anyServices.add(child);
                        subscribe(url.setPath(child).addParameters(INTERFACE_KEY, child,
                                Constants.CHECK_KEY, String.valueOf(false)), k);
                    }
                }
            });
            // 6.创建持久化节点,订阅持久化节点的子节点
            zkClient.create(root, false);
            List<String> services = zkClient.addChildListener(root, zkListener);
            // 7.获取到全量service,对每一个service进行订阅
            if (CollectionUtils.isNotEmpty(services)) {
                for (String service : services) {
                    service = URL.decode(service);
                    // anyServices是一个接口全名集合,订阅整个Service层
                    anyServices.add(service);
                    subscribe(url.setPath(service).addParameters(INTERFACE_KEY, service,
                            Constants.CHECK_KEY, String.valueOf(false)), listener);
                }
            }
        } else {
            // 1.客户端非第一次连接上注册中心,对于具体的服务进行订阅
            List<URL> urls = new ArrayList<>();
            // 2.根据URL的类别,获取一组要订阅的路径,类别:providers、routers、consumers、configurators
            for (String path : toCategoriesPath(url)) {
                // 3.获取url对应的监听器,没有则创建
                ConcurrentMap<NotifyListener, ChildListener> listeners = zkListeners.computeIfAbsent(url, k -> new ConcurrentHashMap<>());
                // 4.获取ChildListener,没有则创建,并且在子节点数据发生变更时则调用notify方法进行通知这个listener
                ChildListener zkListener = listeners.computeIfAbsent(listener, k -> (parentPath, currentChilds) -> ZookeeperRegistry.this.notify(url, k, toUrlsWithEmpty(url, parentPath, currentChilds)));
                // 5.创建type的持久化节点,且对于这个节点进行订阅
                zkClient.create(path, false);
                // 6.订阅,返回该节点下的子路径并且保存
                List<String> children = zkClient.addChildListener(path, zkListener);
                if (children != null) {
                    urls.addAll(toUrlsWithEmpty(url, path, children));
                }
            }
            // 7.回调,更新本地缓存信息
            notify(url, listener, urls);
        }
    } catch (Throwable e) {
        throw new RpcException("Failed to subscribe " + url + " to zookeeper " + getUrl() + ", cause: " + e.getMessage(), e);
    }
}

2.来看下回调方法notify()

protected void notify(URL url, NotifyListener listener, List<URL> urls) {
    // ...
    // 用于分类URL
    Map<String, List<URL>> result = new HashMap<>();
    for (URL u : urls) {
        if (UrlUtils.isMatch(url, u)) {
            String category = u.getParameter(CATEGORY_KEY, DEFAULT_CATEGORY);
            List<URL> categoryList = result.computeIfAbsent(category, k -> new ArrayList<>());
            categoryList.add(u);
        }
    }
    if (result.size() == 0) {
        return;
    }
    // 1.获取url在notified集合中的数据,若notified无则创建一个且放入
    Map<String, List<URL>> categoryNotified = notified.computeIfAbsent(url, u -> new ConcurrentHashMap<>());
    for (Map.Entry<String, List<URL>> entry : result.entrySet()) {
        String category = entry.getKey();
        List<URL> categoryList = entry.getValue();
        categoryNotified.put(category, categoryList);
        // 2.对于所有url进行分类的通知
        listener.notify(categoryList);
        // 3.将url进行缓存,因为当我们的注册表由于网络抖动而出现订阅失败时,我们可以返回现有的缓存URL。
        saveProperties(url);
    }
}

3.再回到doSubscribe()方法,可以看到其中传入了一个参数类型是NotifyListener,一旦发生了数据的变更,则发生通知,而消费者则会去进行一次数据拉取:

public interface NotifyListener {

    /**
     * 当收到服务变更通知时触发。
     * <p>
     * 通知需处理契约:<br>
     * 1. 总是以服务接口和数据类型为维度全量通知,即不会通知一个服务的同类型的部分数据,用户不需要对比上一次通知结果。<br>
     * 2. 订阅时的第一次通知,必须是一个服务的所有类型数据的全量通知。
     * 3. 中途变更时,允许不同类型的数据分开通知,比如:providers, consumers, routers, overrides,允许只通知其中一种类型,但该类型的数据必须是全量的,不是增量的。
     * 4. 如果一种类型的数据为空,需通知一个empty协议并带category参数的标识性URL数据。
     * 5. 通知者(即注册中心实现)需保证通知的顺序,比如:单线程推送,队列串行化,带版本对比。
     *
     * @param urls 已注册信息列表,总不为空,含义同{@link com.alibaba.dubbo.registry.RegistryService#lookup(URL)}的返回值。
     */
    void notify(List<URL> urls);
    //..
}

小总结

总的来说,对于Zookeeper的发布流程:

  1. 若发现注册中心上已经注册了某个需要发布的URL,那么返回。
  2. 若没注册,则对需要发布的URL进行一定程度上的格式转换。(/、:等符号的转化)
  3. 根据用户配置的client属性,选择用curator或者zkclient客户端向Zookeeper上创建一个临时节点(即订阅者的相关数据)。

对于Zookeeper的订阅流程:

  1. 客户端第一次启动,会针对/.../providers/这个路径,全量的获取每一个子节点并进行订阅。
  2. 后续则根据URL的具体类别,获取要订阅的路径,通过不同类型的监听器来进行监听,若数据发生了变化,则通过NotifyListener这个监听器的notify()方法进行回调。
  3. notify()则进行分类通知,并且将URL进行本地缓存,同时消费者进行数据的拉取。

对于Zookeeper的订阅,说白了就是对xxx/providers进行监听,一旦数据发生变更,则执行回调函数,让消费者自己去拉取一遍数据。

这里只是一个普通的服务发布即服务订阅的大致流程,对于具体服务的暴露、远程服务的调用则放在后续的博客来讲。

三 缓存

其实1.2小节有提及:

  1. 回调的时候,会对URL进行一次缓存,避免因为网络波动等原因获取不到URL。
  2. 说明消费者或服务治理中心获取注册信息后会做本地缓存。

首先,在服务进行初始化的时候,AbstractRegistry构造函数里会从本地磁盘文件中把持久化的注册数据读到Properties对象里,并加载到内存缓存中。再来看下AbstractRegistry类中的几个重要属性:

// 内存中的服 务缓存对象,URL是消费者的URL,内层Map的key是分类,包括providers、consumers等
// value则对应的服务列表,对于没有服务提供者提供服务的URL,它会以特殊的empty:// 前缀开头。
private final ConcurrentMap<URL, Map<String, List<URL>>> notified = new ConcurrentHashMap<>();
// 盘文件服务缓存对象
private File file;

3.1 缓存的加载

缓存的加载依赖于AbstractRegistry的构造,来看下:

public AbstractRegistry(URL url) {
    // ....
    // 启动订阅中心时,我们需要读取本地缓存文件,以便将来进行注册表容错处理
    loadProperties();
    // ....
}

private void loadProperties() {
    // ....
   	// 读取磁盘上的文件并加载
    in = new FileInputStream(file);
    // 保存服务提供者的URL,key:URL#serviceKey()。value:提供者列表、 路由规则列表、配置规则列表等
    properties.load(in);
    // ....
    }
}

3.2 缓存的保存和更新

缓存的保存有同步和异步两种方式。异步会使用线程池异步保存,如果线程在执行过程中出现异常,则会再次调用线程池不断重试,前面说过,订阅流程中最后一步会对URL进行缓存,也就是调用了saveProperties(url)方法:

private void saveProperties(URL url) {
    // ....
    if (syncSaveFile) {
    	// 同步保存,同时传入一个AtomicLong类型的版本号,为了保证数据是最新的。
         doSaveProperties(version);
     } else {
     	// 异步保存,通过线程池来执行,同时传入一个AtomicLong类型的版本号,为了保证数据是最新的。
         registryCacheExecutor.execute(new SaveProperties(version));
    // ....
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值