手写RPC远程调用框架实现原理


~~~~本文是手写RPC框架后的个人感悟与总结,如有见解,欢迎评论~~~~


一、什么是RPC?

                想象一个场景,假如前面有一条河,我们想要去往对岸,可能要借助划船的方式;

倘若两岸之间架有一座小桥,我们就可以从桥上走,能拥有在路面上行走的体验感到达对岸

而RPC就扮演了“桥”的角色,它的作用在于:

  • 像调用本地服务一样调用远程服务,屏蔽远程调用跟本地调用的区别
  • 隐藏底层网络通信的复杂性,让我们更专注于业务逻辑

手写RPC想要实现的效果:

// rpc-facade服务接口 # HelloFacade
public interface HelloFacade {
    String hello(String name);
}

// rpc-provider服务提供者 # HelloFacadeImpl
@RpcService(serviceInterface = HelloFacade.class, serviceVersion = "1.0.0")
public class HelloFacadeImpl implements HelloFacade {

    @Override
    public String hello(String name) {
        return "hello" + name;
    }
}

// rpc-consumer服务消费者 # HelloController
@RestController
public class HelloController {

    @RpcReference(serviceVersion = "1.0.0", timeout = 3000)
    private HelloFacade helloFacade;

    @RequestMapping(value = "/hello", method = RequestMethod.GET)
    public String sayHello() {
     
        return helloFacade.hello("mini rpc");
    }
}

在开始之前,先思考一下,实现RPC框架应该由哪些部分组成?

        最先想到的就是---服务注册与发现,服务提供者注册中心注册服务信息,服务消费者从注册中心拉取服务节点列表,根据负载均衡选择其中一个节点发起调用。

        当然,实现起来远不止这么简单,只是先大致描绘一个轮廓,后面我会详细说明~

二、自定义通信协议        

        再想,RPC既然是一个远程调用,那肯定就需要通过网络来传输数据,并且RPC常用于业务系统之间的数据交互,需要保证可靠性,所以RPC一般默认采用TCP来传输,我们常用的HTTP协议也是建立在TCP之上

        调用方请求的入参出参都是对象,但是网络传输的数据必须是二进制数据,所以我们需要把对象转换成二进制数据,这个过程就是“序列化

        调用方把请求参数序列化成二进制后,经过TCP传输给了服务提供方,服务提供方再从TCP通道里面收到二进制数据,那如何知道一个请求的数据到哪里结束?是一个什么类型的请求呢?

        这时候就需要一个“指令牌”----协议,来帮助服务提供者正确地解析请求

        大多数的协议会分成两部分,分别是数据头和消息体:

  • 数据头一般用于身份识别,包括协议标识、数据大小、请求类型、序列化类型等信息 
  • 消息体主要是请求的业务参数信息和扩展属性等

根据协议,服务提供者就可以正确地从二进制数据中解析出不同的请求了·(●'◡'●)

同时根据请求类型和序列化类型,把二进制的消息体还原成请求对象,这个过程称之为“反序列化

(当然,我的项目使用的还是现成的http协议)


三、服务注册与发现

1.注册中心

        一开始学nacos我还在想,为什么微服务都要注册到里面,好麻烦;后来感觉注册中心就像一个“中介”,是一座连接提供者和消费者的桥梁。

        假如我们想去一家餐厅吃饭,怎么找到位置?当然是打开百度地图啦,轻轻一点就有了;但是如果没有地图呢,一家一家地去跑吗?可能就变成环城旅行了哈哈。

        地图,就起到了注册中心的作用,每一家餐厅开业后都需要把自己的地址上传到地图,那顾客想要去吃饭,直接从地图中拉取餐厅的位置就好了~~废话结束--


注册中心的功能:

  • 服务提供者节点上线后向注册中心注册信息
  • 服务提供者节点下线时,注册中心需要将服务节点信息移除,并且服务节点发生变更时,要及时告知给服务消费者

注册中心我通过HashMap来实现,key是服务名称,value是地址

注册进去的信息存放在本地txt文件中,也就是说这个txt文件当成一个存储信息的“服务器”

ps:如何下线?--心跳检测

        例如注册中心可以向服务节点每 60s 发送一次心跳包,如果3次心跳包都没有收到请求结果,可以认为该服务节点已经下线

  1. 服务提供者在启动时,会向注册中心注册自己的信息,并同时启动一份心跳发送线程
  2. 心跳发送线程会定期地向注册中心发送心跳包,告知该服务提供者处于存活状态 (心跳包中包含了服务提供者的唯一标识和当前时间戳等信息)
  3. 注册中心接收到心跳包后,会更新服务提供者的心跳时间戳,表示该服务提供者处于存活状态
  4. 注册中心还会定期检查每个服务的时间戳,并与当前时间比较,判断是否超过阈值,如果超过则将该服务从txt文件中移除,直到服务提供者再次发送心跳包,经注册中心检查之后重新恢复

2.服务提供者

  1. 服务提供者启动服务,并暴露服务端口
  2. 启动时扫描需要对外发布的服务,并将服务信息注册到注册中心
  3. 接收RPC请求,解码后得到请求信息
  4. 根据请求信息找到对应类中的对应方法,然后执行
  5. 处理完毕后 将结果响应给客户端

3.服务消费者

  1. 从注册中心拉取服务节点列表,将列表缓存到本地,根据负载均衡选择其中一个节点去调用
  2. 向服务端发起请求
  3. 得到服务端的响应

四、负载均衡

在项目中,我实现了五种负载均衡算法:

        加权随机、简单轮询、加权轮询、平滑加权轮询、最小活跃数算法

1.加权随机

假设有一组服务器servers=[A,B,C],对应的权重weights=[5,3,2],权重总和为10

(1)现在把这些权重平铺在一维坐标上,那么服务器A在[0,5]区间内,服务器B在[5,8]区间内,服务器C在[8,10]区间内

(2)接下来在[0,totalWeight]内生成一个随机数,该随机数落在哪个区间上,就选择哪个区间上的服务器

步骤:

  1. 先计算出总权重,判断所有权重是否相等
  2. 若不相等:则在[0,totalWeight]内生成一个随机数random
  3. 若相等:随机
pubic static String getServer(){
    
    int totalWeight = 0;//总权重
    boolean sameWeight = true;//判断所有权重是否相同
    Object[] weights = ServerIps.WEIGHT_LIST.values().toArray();
    
    //1.计算总权重,并判断所有权重是否相等
    for(int i=0;i<weights.length;i++){
        Integer weight = (Integer)weights[i];
        totalWeight += weight;
        if(sameWeight && i>0 && !weight.equals(weights[i-1])){
             sameWeight = false;       
        }    
    }
    //2.在[0,totalWeight]区间内生成一个随机数random
    Random random = new Random();
    int randomPos = random.nextInt(totalWeight);
    //如果权重不等:
    if(!sameWeight){
        for(String ip : ServerIps.WEIGHT_LIST.keySet()){
                Integer weight = ServerIps.WEIGHT_LIST.get(ip);  
                if(randomPos < weight){
                    return ip;//假如random=7,服务A的权重是5,7<5,false,random就变成7-5=2                
                }else{        //再进入下一波循环,遍历到服务B,权重是3,random=2<3,true,此时选用服务器B
                              //所以,random在哪个区间就选择哪个区间上的服务器
                      randomPos = randomPos - weight;         
                }
        }    
    }
    //如果所有权重都相等:随机
    randomPos = random.nextInt(ServerIps.WEIGHT_LIST.size());
    String ip = ServerIps.WEIGHT_LIST.keySet().toArray()[randomPos];
    return ip;
}

2.简单轮询

这个就很简单了,依次调用,非常公平

但是我们想让性能比较好的服务器处理更多的请求,能者多劳,所以就有了下面的加权轮询算法


3.加权轮询

  1. 计算出服务器的请求次数
  2. offset = 请求次数%总权重

其实和加权随机的逻辑差不多,只不过加权随机的offset是[0,totalWeight]生成的随机数

比如一组服务器servers=[A,B,C],对应的权重weights=[5,3,2],权重总和为10

[0,5]区间属于服务器A,[5,8]区间属于服务器B,[8,10]区间属于服务器C

如果是第6次请求,应该选择服务器B吧,是怎么选的呢?

--offset = 6%10 = 6,遍历到服务器A时,6<5,false,所以offset=6-5=1,再遍历到服务器B,offset=1<服务器B权重3,true

但是按顺序访问,调用序列就是AAAAABBBCC

这种算法有一个缺点:一台服务器的权重特别大的时候,他需要连续的处理请求,好像就失去了负载均衡的意义呜呜┭┮﹏┭┮

所以,就引出了下面的平滑加权轮询


4.平滑加权轮询

给服务器设置两个权重,weight和currentWeight

  • weight是固定权重
  • currentWeight是可以动态调整的,初始值为0
  1. 遍历服务器列表,让currentWeight = currentWeight + weight
  2. 找到那个currentWeight最大的服务器
  3. 让最大的currentWeight减去总权重

经过平滑加权处理后,调用顺序AABACAA,相比刚才普通加权轮询的情况,分布性更均衡一些~

初始情况currentWeight=[0,0,0] ,在第7个请求处理完后,currentWeight再次变回[0,0,0]

你会惊讶的发现在第8次的时候,当前currentWeight数组又变回了[5,1,1] !!!


5.最小活跃数算法

每个服务提供者对应一个活跃数,初始情况下,活跃数为0

当服务提供者收到请求时,活跃数+1,处理完毕后,活跃数-1

性能好的服务器处理请求速度快,因此活跃数下降的也越快,更空闲,此时这样的服务提供者能优先获得新的请求,这就是最小活跃数算法的基本思想

除了活跃数,这种算法还引入了权重值,

如果多个服务器有相同的最小活跃数,处理请求都很快 不分伯仲,那么就会根据权重分配请求:

  • 权重值大的服务器获得新请求的概率就越大
  • 如果权重相等,则随机选择

五、动态代理

使用RPC的时候,我们一般的做法是先找服务提供方要接口,把接口注入到我们的项目中

然后通过调用接口的方法就能实现对服务提供者方法的调用

可是,接口里并不包含真正的业务逻辑,业务逻辑都在服务提供方那里啊,但是我们通过调用接口方法,确确实实拿到了想要的结果

这是咋回事?

其实幕后操纵者,就是动态代理(●ˇ∀ˇ●)

RPC会自动给接口生成一个代理类,当注入接口时,运行过程中实际绑定的是这个接口的代理类

在接口方法被调用时,它实际上是被代理类拦截到了,我们可以在代理类里面添加远程调用逻辑

因此屏蔽了远程调用的细节,从而达到了调用远程与调用本地没有区别的体验感

---------------------------------------------------------------------------------------------------------------------------------

你看,在我的项目中,我就是这么操作的(最终达到绿色框的效果):

1.我创建了HelloService接口的代理类helloService

2.在代理类中添加了远程调用的逻辑:消费者想要调用服务提供者的哪个类中的哪个方法?

                                                             参数类型是什么?传入的参数值是什么?

3.运行过程中绑定的就是接口的代理类,然后当我们使用代理对象调用某个方法的时候,最终都会被转发到 invoke() 方法

所以在调用sayHello()方法时,就会进入到invoke()中执行远程调用的逻辑

---------------------------------------------------------------------------------------------------------------------------------

其实动态代理离我们并不遥远,就在身边,我们可能只是把它忽略了....

像监控、事务管理、权限校验这些功能,我们是怎么实现的?

你可能会回答,用Sring的AOP呀

没错,进一步再想,在 Spring AOP 里面我们是怎么实现统一拦截的效果呢?

它是怎么做到在不改变原有代码的基础上添加一些功能的增强,实现核心业务逻辑与非业务逻辑的解耦呢?

核心就是动态代理,通过对字节码进行增强,在调用方法时进行拦截,以便于在方法调用前后,增加一些前置/后置功能

最后再说一点jdk动态代理和CGLib动态代理:

  • JDK动态代理:目标对象必须实现一个接口,生成的代理类也是接口的实现类,然后通过反射去调用代理类中的方法
  • CGLib动态代理:更灵活一些,目标对象不用必须实现接口,让代理类成为目标类的子类,重写方法,在继承父类功能的基础上做一些创新和增强

六、网络通信框架Netty

0.概要

        Netty是一种高性能、异步的事件驱动的网络编程框架,异步非阻塞式,可处理大量并发的连接,并且还提供了易于使用的API接口,支持数据协议、序列化、零拷贝等等

 根据Netty的逻辑架构,我简练地说一下流程,下面再细说:

  • 在网络通信层,当网络数据读取到内核缓冲区后,会触发各种网络事件
  • 这些事件交给事件调度层去处理
  • 任务调度完毕后,实际的业务逻辑处理由服务编排层的相关Handler来完成


1.网络通信层

网络通信层的职责是执行网络 I/O 的操作

当数据读取到内核缓冲区后,会触发各种网络事件,这些网络事件会分发给事件调度层进行处理

网络通信层的核心组件包含BootStrap、Channel2个组件

1.Bootstrap:

        Bootstrap用来启动和配置Netty程序,串联各个组件

  • 一种用于客户端,叫 Bootstrap,可用于连接远端服务器,只绑定一个 EventLoopGroup
  • 另一种用于服务端,叫 ServerBootStrap,会绑定两个EventLoopGroup,通常称为 Boss(主Reactor) 和 Worker(子Reactor)

它们都继承自抽象类 AbstractBootstrap

2.Channel:

字面意思就是“通道”,Channel提供了基本的 API 用于网络 I/O 操作

  • 获得当前⽹络连接通道的状态
  • ⽹络连接的配置参数
  • 提供异步的⽹络IO操作,⽐如建⽴连接、绑定端⼝、读写操作等
  • 获得ChannelFuture实例,并注册监听器到ChannelFuture上,⽤于监听IO操作的成功、失败、取消时的事件回调


2.事件调度层

事件调度层的职责是处理事件,通过 Selector 主循环线程集成多种事件( I/O 事件、信号事件、定时事件等)

事件调度层的核心组件包括 EventLoopGroup、EventLoop:

1.EventLoop

        用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write等 I/O事件

2.EventLoopGroup

        本质是一个线程池,主要负责接收 I/O 请求,并分配线程执行处理请求

3.EventLoopGroup、EventLoop、Channel 的关系:

  • 一个 EventLoopGroup 往往包含一个或者多个 EventLoop
  • 每新建一个 Channel,EventLoopGroup 会选择一个 EventLoop 与其绑定

(该 Channel 在生命周期内都可以对 EventLoop 进行多次绑定和解绑)

  • EventLoop 同一时间会与一个线程绑定,每个 EventLoop 负责处理多个 Channel


3.服务编排层

服务编排层的职责是处理实际的业务逻辑

1.ChannelPineLine

        可以理解为ChannelHandler的实例列表,内部通过双向链表将不同的ChannelHandler链接在一起

        当 I/O 读写事件触发时,ChannelPipeline会依次调用 ChannelHandler列表对Channel的数据进行拦截和处理

以客户端为例,数据从客户端发向服务端,该过程称为出站,反之则称为入站

数据入站会由一系列 InBoundHandler 处理,出站会由OutboundHandler处理

我们经常使用的编码 Encoder 是出站操作,解码 Decoder 是入站操作

----客户端和服务端一次完整的请求应答过程可以分为三个步骤:

  1. 客户端出站(请求数据)
  2. 服务端入站(解析数据并执行业务逻辑)
  3. 服务端出站(响应结果)

2.ChannelHandler

        数据的编解码工作以及其他转换工作都是通过 ChannelHandler来处理

3.ChannelContext

        每创建一个Channel都会绑定一个新的Pipeline,Pipeline中每加入一个Handler都会绑定一个Context

        像connect、bind、read、flush、write、close 等事件都可以交给ChannelContext来记录,保存ChannelHandler的上下文


4.流转过程

        1.服务端启动,初始化时有Boss EnventLoopGroup 和 Worker EnventLoopGroup,Boss负责连接;

        当有新的网络连接事件到达时,就把channel转交给Worker EnventLoopGroup

        2.Worker EnventLoopGroup会选择一个EnventLoop与Channel进行绑定,EnventLoop负责处理该Channel上的读写事件

        3.当客户端发起I/O读写事件时,服务端EnventLoop会读取数据

        然后通过PipeLine依次调用 ChannelHandler 列表,对 Channel 的数据进行拦截和处理

        4.处理完毕要写回客户端时,会将处理结果在 ChannelPipeline 的 ChannelOutboundHandler 中传播,最后到达客户端


5.同步阻塞与IO多路复用

  • 同步阻塞 (BIO)

服务端的⼀条线程必须处理完⼀个客户端的所有请求后 才能接着处理另⼀个客户端的请求

比如:客户端1来了,但是它还没写入数据,服务端线程就会阻塞,等待客户端1把数据写入通道;

           处理完客户端1的所有请求后,才能再处理客户端2

            服务端线程在等待的过程中是处于空闲状态的

  • IO多路复用

多路复用器实现的Epoll底层机制

底层调用了操作系统的三个函数

  1. epoll_create()函数:创建了一个c语言实现的epoll结构体,包装成了selector
  2. 所有注册过的channel会放到selector内部的一个数组中

        epoll_ctl()函数:监听注册的channel的事件收发,如果有事件发生,就会把channel从数组中移除,挪到就绪列表中

        3.epoll_wait()函数:监听就绪列表中的事件,如果有事件,立刻去处理


over

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值