深入理解Dubbo-Dubbo原理理解


1. 为什么要使用RPC?

结合项目来看的话,我认为有下面的几点可以说一下:

第一点,就是编码人员调用接口的编码量下降了,在用RPC之前,我是使用HTTPClient来对计算节点上的client进行操作的,因此在代码中可以看到有大量的POST之类的请求,这个做法也不是不行,但是存在一个非常明显的问题:加大了双方通信的成本,比如说需要通过HTTPCode来标志当前服务的状态,很不直观,但是如果使用RPC框架的时候,就可以将对等端抛出的异常直接在server中显示出来。这样的话调试起来非常方便

第二点,就是减轻了中控节点主链路的服务压力,比如说本来在主链路中要把全部的事情做完,在一开始编写这个中控节点的代码的时候,我也实现了相关的接口,但是这些代码对主节点来说是非常重的负担

比如说创建容器,需要主节点去开辟一个新的线程去创建容器,并且监听它发回来的状态码,做判断,返回给前端…这种其实就是命令式API

但是在使用了RPC之后,就只需要向计算节点发送一条指令,就可以去干别的事情了,这个过程同时也实现了解耦和负载均衡,不至于在一个实例上完成过多的操作,这个其实就是声明式API

第三点,从编码的体验来看,就是Dubbo的使用使得调用远程方法像本地方法一样简单,对于异常之类的处理,更加自然。

这就是使用RPC的意义

2. 如何来理解RPC?

RPC远程过程调用,它的作用通常是将一个不在同一个主机/进程上的方法进行远程调用,就好像调用这些方法就是在本主机/主进程中使用一样。

那么如何来规定这两端之间的通信,就是十分关键的问题了,这些方法之间的调用有相同语言的,有不同语言的,有相同通信底层的,也有不同的,这也就 规定了RPC远程调用必须设计一种协议,这种协议规定了对等端之间是如何通信的,确保两端能够同时解析出正确的信息。

总而言之,RPC实际上就是约定了远程过程的过程中,数据的格式以及数据是如何传输的,RPC协议是应用层之上的协议,可扩展性很强

3. 对注册中心的理解

注册中心的内容通常是这样的:

Map<接口名:List<URL>>

假如说基于Redis做注册中心

说明一下这个Map的含义,它的key就是它的接口名比如说是/dubbo/xxx.xx.XxxService/providers:

然后它的每一个entry就是{key,value},每个hash的字段就是URL->expireTime

实际上,在Dubbo+Redis中,存储的信息通常更多,比如说还有方法名等

通常来说,注册中心分为本地缓存注册中心(存储在主机内存中),还有分为远程注册中心,这是为了避免每次去请求服务都通过网络的I/O来查询服务,那么就设计了本地缓存,通过本地缓存减少了网络请求的次数,提高性能

同时,为了应对注册中心和本地缓存的一致性,可以基于Redis的发布订阅机制,来对变化进行监听,从而及时修改本地缓存中的内容

4. 为什么要使用Dubbo?

当网站流量很小时,只需一个应用,将所有功能都部署在一起,运行在一个服务器上,以减少部署节点和成本。此时,用于简化增删改查工作量的数据访问框架(ORM)是关键。这种架构就被称为是单体架构

传统的单体架构应用拆分成互不相干的几个小应用,这些小应用独立的部署到不同的服务器上,以提升效率此时,用于加速前端页面开发的Web框架(MVC)是关键。

它的缺点是当服务节点发生变化,就需要重新部署,比如说一个商城的控制层controller发生了变化,但是业务层没有发生变化,此时也需要将整个商城的这一个小模块的进程进行重新部署,从而影响了其他的业务。

分布式服务架构下,系统被拆分成不同的服务比如短信服务、安全服务,每个服务独立提供系统的某个核心服务。就相当于将原来十分复杂的大模块,拆分成了不同的模块,并且能够以最小的粒度来管理这些模块

Dubbo能够解决什么问题?

负载均衡:同一个服务器部署在不同的机器时,应该要调用哪一台机器上的服务?

服务调用链路生成: 随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo可以为我们解决服务之间互相是如何调用的。

服务访问压力以及时长统计、资源调度和治理 :基于访问压力实时管理集群容量,提高集群利用率。

5. 说一说Dubbo的架构设计

Dubbo的核心架构设计我认为可以分为以下五个部分

  • Provider:服务的提供者,通常作为服务的被调用方使用
  • Consumer:服务的消费者,通常是服务的调用方
  • Registry:注册中心,是整个RPC的中介和核心,服务于消费者和提供者,对于消费者而言,它是拉取服务的来源,对于提供者而言,它是推送服务的目的地。
  • Container:运行服务的容器,比如说Tomcat等服务容器
  • Minitor:系统监控器,非必须的,主要用来监控服务的调用次数和调用时间

注册中心有什么用?

注册中心相当于根目录服务,它的作用就是来给服务提供者和服务消费者进行寻址,因为服务者进程和消费者进程通常处于不同的进程中,甚至是不同的主机中,需要通过网络通信的方法来进行方法的调用,因此这时候有一个中介站来提供地址的查询服务。一般来说,这个注册中心还需要有一个自动感知服务的上线和服务下限的功能,并且将这些变更及时推送到各个节点上。比如说Redis在对这个功能进行实现的时候,一般是通过发布与订阅的机制进行实现的。

监控中心有什么用?

监控中心负责一个服务监控的功能,它能够统计服务调用次数和调用时间

服务提供者宕机之后,注册中心会做什么?

Redis作为注册中心的为例,分为两种情况,一种情况是进程非正常退出,因此在这个情况下,收尾的代码没有执行,因此在注册中心中是还能看到这个服务提供者的,这是因为在使用Redis做注册中心的时候,每一个dubbo服务提供者都会内置一个心跳维护线程,它默认是1分钟过期,因此就是说,当1分钟没有收到任何心跳,就认为服务挂掉了,将服务下线。

这个收尾代码就是JVM中的shutDownHook

第二种情况就是进程的正常退出,收尾的代码被执行,这个过程的设计是比较复杂的,简单来说,就是对远程注册中心中的key,找到并且删除。对本地缓存的map进行更新。

6. 详细说说使用Redis做注册中心是怎么实现的?

Dubbo为例,我们首先分析它存储在Redis上的信息,比如说是

/dubbo/xxx.xx.XxxService/providers: 以hash类型存放所有提供者列表, 每个hash的字段为 url -> expireTime

然后根据SPI中的配置来创建一个RedisRegistryFactory,这个过程就是选中了Redis作为注册中心了

在Dubbo如何使用SPI机制?

第一步,在resources下创建一个dubbo/META-INF文件夹

第二步,配置SPI配置文件,比如说是redis的,那么文件名就是这个具体使用redis注册中心的全路径类名,内容是一个key->value,key就是redis,value就是全限定类名

Redis作为注册中心,服务提供者是如何注册上去的?

第一步,按照服务名称组装key,然后以全服务路径作为hash的key,然后它的value就是服务的expire

第二步,将key向redis中添加,然后关键的是向客户端发布一条消息,通知有新服务上限,然后所有客户端的本地缓存更新

这个发布的消息就是publish <key> register,也就是说所有订阅服务的客户端,都会订阅register这个频道,以便及时更新本地缓存

Redis作为注册中心,服务消费者是如何进行服务的订阅的?

首先要搞清楚服务订阅是在干啥,首要目标就是要订阅到目标服务的地址,然后还有一个机制,就是要能够动态感知到哪些服务上线了,哪些服务下线了,然后好知道下一次要请求服务的格式是怎么样的,它的订阅要做两件事情

首先判断是不是首次订阅服务,如果是首次订阅服务,那么就要将服务信息全部缓存到本地,否则的话就是将新上限的服务加入到本地缓存中,或者是有服务下线了,这时候触发publish-subscibre的回调函数,将服务从里面删除

Redis作为注册中心,服务消费者是如何进行服务的下线的?

应用服务的主动下线操作是由 ShutdownHookCallbacks 和在判断服务不可用时进行的 invoker.destroy() 来实现优雅下线。然后pushlish一个下线通知其他应用。

Redis作为注册中心,服务心跳是如何处理的?

前面提到,它会通过一个心跳机制来维护自己的服务是否过期,那么具体是怎么做的呢?就是开启一个定时任务,定期地去刷新属于自己的这个服务的URL的{key->value},当注册中心检测到有key过期的时候,就会publish<key>UNREGISTER,将服务删除

服务信息变更通知处理notify

  1. redis 做初次subscribe时,notify会通过redis-keys 命令获取所有需要的key, 然后依次将其提供者、路由、配置等信息都缓存起来。
  2. 针对每个服务,都会开启相关的订阅线程Notifier处理订阅工作。
  3. 最终的listener处理默认会由 RegistryDirectory 处理。

7. 说说JDK的SPI机制

首先在项目就使用了自己的SPI机制,先谈谈我自己的理解:

我运用SPI机制的场景是在自定义负载均衡的时候,由于dubbo提供的四种负载均衡算法都不能够很好的完成我的需求,因此就只好使用自己定义的负载均衡机制了,实现是这样的:首先先找到负载均衡算法定义的接口:

com.alibaba.dubbo.rpc.cluster.LoadBalance

package com.alibaba.dubbo.rpc.cluster;
@SPI("random")
public interface LoadBalance {
    @Adaptive({"loadbalance"})
    <T> Invoker<T> select(List<Invoker<T>> var1, URL var2, Invocation var3) throws RpcException;
}

dubboLoadBalacne上注解了一个SPI,然后它的默认值是random,含义为默认使用随机权重算法

然后如果要实现自己的负载均衡算法,那么就需要定义一个接口

public class LoadBalanceConsistence implements LoadBalance {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public <T> Invoker<T> select(List<Invoker<T>> list, URL url, Invocation invocation) throws RpcException {
        //1. 将containerId拿出来
        Object[] arguments = invocation.getArguments();
        if(!(arguments[0] instanceof String)){
            throw new RpcException("参数有误!");
        }
        //2. 从redis中取出nodeId
        String containerId = (String) arguments[0];
        String ip = stringRedisTemplate.opsForValue().get(CONTAINER_ALIVE + containerId);
        log.info("对应的IP地址是{}",ip);
        //3. 获取对应的服务
        Invoker<T> invoker = null;
        for (Invoker<T> tInvoker : list) {
            if(tInvoker.getUrl().getIp().equals(ip)){
                invoker = tInvoker;
                break;
            }
        }
        return invoker;
    }
}

然后在相关的目录下添加配置,也就是添加一个META-INF的信息

public static void main(String[] args) {
    ServiceLoader<Serializer> serviceLoader = ServiceLoader.load(Serializer.class);
    Iterator<Serializer> iterator = serviceLoader.iterator();
    while (iterator.hasNext()) {
        Serializer serializer= iterator.next();
        System.out.println(serializer.getClass().getName());
    }
}

使用的话就是这样的,通过JDK自带的这个Loader,读取这个配置文件中的信息,就可以获取到这个类的全路径了,然后通过反射就可以获取到这个类,就可以完成动态调用类的效果。

为什么要这样做?

首先来思考一下有没有别的办法?肯定有,我简单说一种,就是你定义一个实现类,然后框架的目标函数的参数中,设置一个参数为这个实现类的接口,然后就可以接受这个实现类作为参数,然后调用方法即可

如果使用SPI机制的话,是这样做的:首先写实现类->在注解上定义你想要用的实现类,结束

两种的区别在于:前一种方面编码者还需要去了解接口的参数,如何传参,显得代码很冗余。

8. 如果让自己实现SPI,你会如何实现呢?

自己实现SPI,可以参考SpringBoot的SPI实现

JDK提供的SPI机制的弊端

  • Java内置方法方式只能够通过遍历来获取具体的实现类
  • 服务提供接口必须放到META-INF/services/目录下
  • 无法按照需求加载实现类

那么Spring中的SPI是如何使用的呢?

interface DBApi{
    Connection getConnection();
}

实现这个接口

#DB2实现
public class DB2DataBase implements DataBaseSPI
{
    @Override
    public void getConnection()
    {
        System.out.println("this database is db2");
    }

}

#Mysql实现
public class MysqlDataBase implements DataBaseSPI
{
    @Override
    public void getConnection()
    {
       System.out.println("this is mysql database");
    }

}

然后在项目的META-INF的目录下,新增一个spring.factories文件中

填写相关的接口信息,内容是:

com.skywares.fw.juc.springspi.DataBaseSPI = com.skywares.fw.juc.springspi.DB2DataBase, com.skywares.fw.juc.springspi.MysqlDataBase

注意,不同的实现类是用逗号隔开的,Spring采用的spring.factories实现SPI和Java实现SPI还是比较相似的,但是spring的spi方式针对java的spi进行的相关优化如下:

Java SPI是一个服务提供接口对应一个配置文件,配置文件中存放当前接口的所有实现类,多个服务提供接口对应多个配置文件,所有配置都在services目录下

就是约定一个目录,根据接口名去那个目录找到文件,文件解析得到实现类的全限定名,然后循环加载实现类和创建其实例。

Spring factories SPI是一个spring.factories配置文件存放多个接口以及对应的实现类,以接口全限定类名作为{key},实现类作为{value},多个实现类用逗号隔开,仅spring.factories一个配置文件

spring是如何通过spring.factories来实现SPI的呢?

第一步:确定类加载器,通常是将加载类的任务由SpringFactoriesLoader来做

第二步:解析和加载META-INF下的文件

第三步:获取所有jar包中META-INF/spring.factories文件路径,以枚举值返回

关于DubboSPI

首先按需求加载的话首先要先给每一个实现类一个名字

通过名字去文件里面找到对应的实现类的全限定路径名然后加载实例化即可

DubboSPI除了可以按照需求加载实现类之外,增加了IOC和AOP的特性,还有一个自适应的扩展机制

Dubbo对配置文件目录的约定,分成了三类目录

**META-INF/services/目录:**该目录下的SPI配置文件是为了用来兼容JDKSPI的

**META-INF/dubbo/目录:**该目录存放的的是用户自定义的SPI配置文件

**META-INF/dubbo/internal/目录:**dubbo内部使用的SPI配置文件

关于ExtensionLoader.getExtension(name)的流程分析

首先拿到你的这个接口的类,然后做了一些判断之后从缓存中找到是否已经存在这个类型的ExtensionLoader,如果没有的话就新建一个放入到缓存中,最后返回接口类对应的ExtensionLoader

然后当拿到这个ExtensionLoader之后,就能够基于getExtension()方法通过名字来找到实例化完的实现类,关于获取类的实例过程一般有两步:

  • 先去缓存看看有没有这个实例,没有的话就要调用createExtension()来创建实例

这个过程是这样描述的:通过getExtensionClasses来得到classes,这就确定了要实例化哪个对象了,然后通过,如果没有得到classes的话,那么就要去目录下寻找了,如果是Adaptive就记录在cachedAdaptiveClass上,如果是Wrapper类就记录在一个map

然后得到了classes之后就通过反射建立一个对象,然后遍历set方法执行依赖注入。

什么是自适应扩展?

根据配置来进行SPI扩展的加载,不想在启动的时候让扩展被加载,想根据请求时的参数来动态选择对应的扩展

Dubbo通过一个代理机制来实现自适应扩展,简单来说就是想扩展的接口生成一个代理类,可以通过JDK或者javassit编译生成的代理类代码,然后通过反射创建实例

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Adaptive {
    String[] value() default {};
}

文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录