~~~~本文是手写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次心跳包都没有收到请求结果,可以认为该服务节点已经下线
- 服务提供者在启动时,会向注册中心注册自己的信息,并同时启动一份心跳发送线程
- 心跳发送线程会定期地向注册中心发送心跳包,告知该服务提供者处于存活状态 (心跳包中包含了服务提供者的唯一标识和当前时间戳等信息)
- 注册中心接收到心跳包后,会更新服务提供者的心跳时间戳,表示该服务提供者处于存活状态
- 注册中心还会定期检查每个服务的时间戳,并与当前时间比较,判断是否超过阈值,如果超过则将该服务从txt文件中移除,直到服务提供者再次发送心跳包,经注册中心检查之后重新恢复
2.服务提供者
- 服务提供者启动服务,并暴露服务端口
- 启动时扫描需要对外发布的服务,并将服务信息注册到注册中心
- 接收RPC请求,解码后得到请求信息
- 根据请求信息找到对应类中的对应方法,然后执行
- 处理完毕后 将结果响应给客户端
3.服务消费者
- 从注册中心拉取服务节点列表,将列表缓存到本地,根据负载均衡选择其中一个节点去调用
- 向服务端发起请求
- 得到服务端的响应
四、负载均衡
在项目中,我实现了五种负载均衡算法:
加权随机、简单轮询、加权轮询、平滑加权轮询、最小活跃数算法
1.加权随机
假设有一组服务器servers=[A,B,C],对应的权重weights=[5,3,2],权重总和为10
(1)现在把这些权重平铺在一维坐标上,那么服务器A在[0,5]区间内,服务器B在[5,8]区间内,服务器C在[8,10]区间内
(2)接下来在[0,totalWeight]内生成一个随机数,该随机数落在哪个区间上,就选择哪个区间上的服务器
步骤:
- 先计算出总权重,判断所有权重是否相等
- 若不相等:则在[0,totalWeight]内生成一个随机数random
- 若相等:随机
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.加权轮询
- 计算出服务器的请求次数
- 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
- 遍历服务器列表,让currentWeight = currentWeight + weight
- 找到那个currentWeight最大的服务器
- 让最大的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 是入站操作
----客户端和服务端一次完整的请求应答过程可以分为三个步骤:
- 客户端出站(请求数据)
- 服务端入站(解析数据并执行业务逻辑)
- 服务端出站(响应结果)
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底层机制
底层调用了操作系统的三个函数
- epoll_create()函数:创建了一个c语言实现的epoll结构体,包装成了selector
- 所有注册过的channel会放到selector内部的一个数组中
epoll_ctl()函数:监听注册的channel的事件收发,如果有事件发生,就会把channel从数组中移除,挪到就绪列表中
3.epoll_wait()函数:监听就绪列表中的事件,如果有事件,立刻去处理
over