从零开始实现一个简单的RPC框架(1)


1. 什么是RPC?

RPC(Remote Procedure Call):也就是远程过程调用,RPC关注的是远程调用而非本地调用,它的出现,让你可以调用方法的时候,调用远程方法可以像调用本地方法一样简单

那为什么需要RPC呢?

假设有这样一个场景,服务器A中有一些关键方法,而服务器B想要调用服务器A的方法,这时候有两种选择

  • 在A中编写接口,提供HTTP协议,远程传参,通过请求服务器A,然后服务器A送回响应到B的方式实现

  • 以Java为例,可以通过网络传输,将字节码从A传输到B,然后服务器B的JVM通过加载这些字节码,再通过反射,就可以实现远程调用,这种调用方式本质上还是在本地运行代码

那么这就产生一个问题了,为什么有了HTTP协议,还要使用RPC呢?

要回答这个问题,我们首先要理解,HTTP协议RPC既然都能完成功能,那么他们到底有什么区别?

定义上的不同

HTTP本质上一个网络通信协议,而RPC是远程过程调用,因此从本质上来看,他们就有巨大的不同,一个是通信协议,而RPC是一种调用方式,调用可以分为本地调用和远程调用

你可以使用HTTP,FTP,SFTP甚至是你自己写的一个协议,只要能够实现网络通信即可

服务发现的不同

使用HTTP的时候,我们必须要知道对方的IP地址端口才能和对方建立连接,如果知道域名的话也能通过域名服务器的解析知道对方的IP地址

使用RPC的时候,一般会有专门的中间服务去保存服务名和IP信息,比如说Consul或者ETCD,甚至是Redis,想要获得某个服务,就必须要去这些中间件去获得IP和端口信息,由于DNS也是服务发现的一种,所以也有基于DNS

这两个不同并不能看出什么,因为服务发现的前提都是知道对方的IP地址和端口,而使用HTTP的时候直接从本地存储上取或者使用存储都可以,而RPC是需要通过中间代理服务,转发请求和转发响应的

本质上来说,采用HTTP的服务也同样可以引入代理服务器,比如说引入nginx服务器,实现反向代理,在这一点上,并没有本质上的不同

底层的连接形式

以主流的HTTP1.1协议为例,其默认会复用这条TCP连接,之后的请求和响应都是会复用这条连接的

RPC协议,为了应对请求量大的情况,在使用长连接的基础上还会再建一个连接池,在请求量大的时候,建立多条连接放在连接池中,要发数据的时候就抽取一条连接进行传输,在用完连接之后,还会放回去准备复用。

这两个不同也看不出什么,因为在许多编程语言中对于HTTP的API设计中也会引入连接池,因此这也不是关键

传输的内容

基于TCP传输的内容,无非都是消息头Header和消息体Body

Header是用来标记一些特殊信息的,其中最重要的是消息体的长度

Body是真正的数据内容,而这些内容只能够是二进制的01串,字符串通过编码和反编码可以很轻松的变成二进制,而数字本身在计算机的底层就是数字,因此这两者都能够很简单进行传输

但是对于结构体而言,它并没有一种确切的数据结构,将其变为二进制串的方案有JsonProtobuf

将这个结构体变为二进制串的过程叫做序列化,而将这个二进制串变回结构体的过程就叫做反序列化

采用HTTP协议去传输结构体的时候,由于请求头/响应头的开销,以及各种状态码的规定,使得在使用HTTP协议做远程调用的方案性能较差,因为在服务内部的互相调用并不需要考虑那么多浏览器的复杂响应状态

一句话形容就是,使用HTTP协议实现远程调用:杀鸡用牛刀

最关键的是多了很多不需要的冗余信息,这些冗余可以根据调用设计者的需求,自行定义首部。

而对于RPC而言,它更加小巧,可定制化,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据

因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。

然而,HTTP/2做了相当多的改进,性能甚至能够优于RPC,甚至gRPC底层都是基于HTTP/2进行实现的

总结

  • 纯TCP不能实现远程调用,如果要实现远程调用,那么必须要解决粘包和分包的问题,在此基础上,发明了HTTP协议和各类RPC协议
  • RPC本质上并不是协议,而是实现远程调用的一种思想
  • 所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。
  • RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP/1.1 性能要更好,所以大部分公司内部都还在使用 RPC

2. 讲讲RPC的原理

要了解RPC的原理,我们必须理清楚RPC调用的整个过程以及各个环节的实现

我们按照箭头的顺序开始分析

  • 首先客户端发起远程调用,然后程序的控制权交给client stub,这个类是代理类,负责把客户端的调用方的方法、类、方法参数等信息传递到服务器
  • 在传递到服务器的过程中,中间执行的是网络传输操作,实际的实现方式有socket和或者封装过的socketI/O服务,比如说NIO或者BIO
  • 然后服务端监听到请求,建立连接,那么请求是由谁来处理的呢,就是由server stub进行处理的,你可以把它理解成一个handler,当请求发过来的时候,就是事件驱动了,然后触发执行,然后将方法执行后的结果返回到client
  1. 服务消费端:以本地调用的方式调用远程的服务
  2. 客户端Stub:接收到调用后负责将方法、参数等组装成网络传输的消息体RpcRequest,它在传输中将会被序列化
  3. 客户端Stub:找到远程服务的地址,并且将信息发送到服务提供端
  4. 服务端Stub:根据收到的消息将消息反序列化为一个Java对象,RpcRequest
  5. 服务端Stub:根据RpcRequest中的类、方法、参数等基本信息调用本地的方法
  6. 服务端Stub:根据方法执行后的结果,封装一个响应对象,RpcResponse
  7. 客户端Stub:然后再通过网络传输,获取RpcResponse

3. 实现一个RPC框架需要注意什么?

RPC的核心服务包括:服务发现,负载均衡,容错

一个最基本简单的RPC框架如下图所示

在这个图上,有三种角色

  • 注册中心:它提供了服务发现和服务注册的功能

  • Server:服务提供者,在注册中心注册暴露出来的服务以便客户端能够发现服务,向注册中心推送服务

  • client:服务获取者,从注册中心拉取服务,它通过网络请求服务端提供端Server

一个比较完善的RPC框架服务架构如图:

我们可以看到核心是没有改变的,在此基础上增加了细节

比如说:

  • 增加了服务代理,桩,通过服务代理,调用方和服务提供方对于网络传输是无感的
  • 增加了序列化和反序列化的协议,通过此协议,能够对复杂数据进行规范化传输

可以总结

注册中心

注册中心可以使用ZKNacos甚至是Redis,它的功能主要是提供一个中介站

服务提供者在此推送暴露出来的服务,服务请求者在此拉取需要的服务的相关信息

注册中心就相当于根目录服务,它的作用概括为服务地址的注册和寻找,服务启动的时候会将服务名称及其对应的地址注册到注册中心,服务端根据服务名称找到对应的服务地址,有了服务地址之后,服务消费端就可以通过网络请求服务端了。

Dubbo的架构为例

我们针对这几种角色进行剖析

  • Provider:暴露服务的提供方
  • Consumer:调用远程服务的服务消费方
  • Registry:服务注册与发现的注册中心
  • Monitor:统计服务的调用次数和调用时间的监控中心
  • Container:服务运行容器

调用关系说明:

  1. 服务容器启动,加载,运行服务提供者
  2. 服务提供者在启动的时候,向注册中心注册自己提供的服务
  3. 服务消费者在启动的时候,向注册中心订阅自己所需要的服务
  4. 注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者
  5. 服务消费者,从提供者地址列表中,就软负载均衡算法,选择一台提供者进行调用,如果调用失败,再选另外一台进行调用
  6. 服务消费者和提供者,在内存中累计调用次数和调用时间,定时发送一次统计数据到监控中心

网络传输

既然我们要调用远程的方法,那么就要发送网络请求来传递目标类和方法的信息以及方法的参数到服务提供端

网络传输具体实现可以使用Socket但是这是BIO的方式

最好的选择是基于NIO实现的Netty

  • Netty是一个基于NIOclient-server客户端服务器框架,使用它可以快速简单地开发网络应用程序
  • 极大地简化了TCPUDP套接字服务器等网络编程,并且性能以及安全性很多方面都要更好
  • 支持多种协议,比如FTP,SMTP,HTTP以及各种二进制和基于文本的传统协议

序列化和反序列化

序列化就像对一些特殊的数据结构进行序列化和还原,这个过程必须遵循一个协议

比如说二叉树的序列化和反序列化,如果你按照前序遍历的方式来序列化一棵树,却以后序遍历的方式来反序列化一棵树,那么得到的结果肯定是不一样的

目前常用的序列化库有hessiankryoprotostuff

动态代理(?)

个人认为这一点就是提供一个代理对象,这个代理对象称之为Stub,它会帮助我们完成网络的I/O操作,而且对于调用方是无感的,这一点需要用到代理模式

为什么是动态代理而不是静态代理?

实际上静态代理也能完成操作,但是我们这是一个框架,如果后期用户想要自定义代理功能,比如说他非得用Socket来传输,那总不能让人家改我们框架源代码吧?我们能做的就是让用户实现我们规定的代理类规范,然后在框架底层替换这个代理类,就可以实现自定义了

负载均衡

我们的系统中某个服务的访问量特别大,我们将这个服务部署在了多台服务器上,当客户端发起请求的时候,多台服务器都能够处理这个请求,那么如何选择这个服务器是很重要了!

我们部署集群,就是为了实现程序的高可用,同时使得这些机器的性能得到最大的发挥,让这些请求尽可能分发到合理的实例上,否则全部请求都打到一个实例上

传输协议

设计一个传输协议,这个传输协议是自定义的,不像HTTP那样繁重,它的作用是完成客户端和服务端的交流

通过设计这个传输协议,我们定义传输哪些类型的数据,并且规定每一种类型的数据占多少个字节,这样我们在接收到二进制数据之后,就可以正确地还原这个对象

  • 魔数:通常是4个字节,这个字段通常是为了筛选来到服务端的数据包,有了这个魔数之后,服务端首先去除前4个字节进行对比,能够在第一时间识别出来这个数据包并非是遵循自定义协议的,为了安全考虑可以直接关闭连接以节省资源
  • 序列化器编号:标识序列化的方式,比如说是使用Java自带的序列化还是Json
  • 消息体长度:在运行的时候计算出来

总结

首先第一点要首先一个注册中心,这个注册中心要有一个服务注册的功能和服务发现的功能,服务提供者在注册中心注册它的服务,服务请求者从注册中心获取服务的地址,它就相当于目录服务器

第二点是要选用合适的网络传输技术,比如使用BIO还是NIO还是AIO

第三点是要选择合适的序列化技术,保证复杂数据结构在网络中能够进行传输

第四点非强制,对于一个框架,如果我们想要扩展性好的话,那么可以设计动态代理机制,使得IO技术是可插拔式的

第五点是实现负载均衡,通过负载均衡算法,将请求分发到不同实例上,避免全部请求打到一台实例上导致服务宕机

第六点是实现传输协议,这个协议是客户端和服务端交流的基础,因为有可能是跨语言通信,而这些语言间的实现细节是不同的,比如说数据类型,int在Java中只有一个,但是在golang中将int分为了uint32和uint64,对应的是unsigined int和unsigined long,规定好协议,就能够保证服务端和客户端间信息的正确复原

4. 序列化以及序列化的协议选择

  • 序列化:将复杂数据结构或者对象转化为二进制字节流的过程
  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

其目的是通过网络传输或者磁盘IO的方式,将对象存储到文件系统、数据库、内存中

序列化协议位于TCP/IP四层模型中的那一层?

根据OSI的七层模型,其中的表示层负责数据的编码和解码的操作

那么对应于TCP/IP的四层模型,就是属于应用层的了

常见的序列化协议有哪些?

JDK自带的序列化方式一般不会使用,因为序列化效率低并且存在安全问题,比较常用协议有:

  • Hessian、Kryo、Protobuf、ProtoStuff,这些基于二进制的序列化协议
  • 像Json和XML这些是属于文本类的序列化的方式,虽然可读性较好,但是性能较差

SerialVersionUID有什么作用?

其作用是避免类被更新了,但是存储在文件中的序列化二进制流中的类是旧的,导致数据加载错乱的问题,如果二进制流中识别出来的SerialVersionUID与当前类的SerialVersionUID不一致,则会抛出异常,序列化失败

SerialVersionUID不是被static修饰了吗?为什么还会被序列化?

static修饰的变量是静态变量,位于方法区,本身是不会被序列化的,static变量属于类的而不是属于对象的,而在反序列化之后,static变量的值是通过类赋值给了对象,看着就像static变量被序列化。

有些字段不想序列化?

可以使用关键字transient进行修饰,这些关键字在反序列化为类之后,将会被赋予一个默认的初值

为什么不使用JDK自带的序列化?

  • 不支持跨语言的调用,如果调用的是其他语言开发的服务就不支持了
  • 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输的成本增大
  • 存在安全问题:注意,字节数组是在网络上传输的,如果输入的是一个非法对象,使得反序列化产生非预期的对象,在此过程中构造任意代码,对服务造成破坏

Kryo

Kryo是一个高性能的序列化和反序列工具,其特点是可变长存储并且使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积

Protobuf

Protobuf的特点是支持多种语言跨平台,需要自行定义IDL文件和生成对应的序列化代码,因此Protobuf是没有反序列化的漏洞的,这是因为在网络传输过程中,除非攻击者知道序列化的具体代码,否则无法任意构造对象

syntax = "proto3";
message Person{
	string name = 1;
	int32 age = 2;
}

为什么使用Kryo?

了解过的序列化协议为JDK的序列化协议,Kryo序列化协议以及protobuf协议

先说说各自的优点吧,由于这个RPC框架是基于Java开发的,因此是没有跨语言的,调用JDK的序列化协议也相当简单,但是JDK的序列化后的字节码中,存在着很多诸如类信息之类冗余信息,因此表现出来的就是字节数组体积很大,传输成本也就被同时加大了。

Kryo序列化协议的最大优点就是它所产生的字节码体积很小,但是其缺点是在使用过程中存在线程安全的问题,需要使用ThreadLocal进行保护

protobuf需要自定义IDL文件和生成的序列化代码,因此是没有反序列化漏洞的,但是它的缺点就是:传输每一个类的结构都需要生成对应的proto文件,如果某个类发生了改变,那么就需要重新生成这个类的对应的文件

那么为什么使用Kryo呢?Dubbo底层也是基于Kryo的,它的核心设计理念就是尽最大可能减少序列化后的文件大小,采用变长字节存储来代替Java中使用固定4/8字节的模式,而在Kryo是用变长的字节来进行描述的,就好像Redis中的ZipList一样,其次就是使用了类似缓存的机制,在一次序列化对象之后,相同的对象只会序列化一次,后续的用一个局部的int来代替

Hessian

它是一个轻量级的,自定义描述的RPC协议,Hessain是一个比较老的序列化实现,支持跨语言

基于Kyro实现序列化协议

第一步,首先引入依赖

<!-- https://mvnrepository.com/artifact/com.esotericsoftware/kryo -->
<dependency>
    <groupId>com.esotericsoftware</groupId>
    <artifactId>kryo</artifactId>
    <version>5.4.0</version>
</dependency>

第二步,定义序列化器接口,序列化并且是成对出现的,因此序列化器应该要同时完成序列化和反序列化

public interface Serializer {

    /**
     * 将复杂的数据结构转化为字节数组
     * @param obj
     * @return
     */
    byte[] serialize(Object obj);

    /**
     * 反序列化接口,将字节数组还原成对象
     * @param bytes
     * @param clazz
     * @param <T>
     * @return
     */
    <T> T deserialize(byte[] bytes,Class<T> clazz);
}

第三步:实现序列化接口

如果是基于Kryo实现的话,由于Kryo是线程不安全的,因此需要用ThreadLocal实现线程隔离

@Slf4j
public class KryoSerializer implements Serializer {

    private ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(()->{
        Kryo kryo = new Kryo();
        kryo.register(RpcRequest.class);
        kryo.register(RpcResponse.class);
        return kryo;
    });

    @Override
    public byte[] serialize(Object obj) {
        //将obj序列化为byte[]
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
             Output output = new Output(byteArrayOutputStream)){
            Kryo kryo = kryoThreadLocal.get();
            //将obj 转化为 byte[]
            kryo.writeObject(output,obj);
            kryoThreadLocal.remove();//防止内存泄漏
            return output.toBytes();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new byte[0];
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> clazz) {
        T result = null;
        //将byte[]转化为对象
        try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            Input input = new Input(byteArrayInputStream)){
            Kryo kryo = kryoThreadLocal.get();
            result =  kryo.readObject(input, clazz);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return result;
    }
}

至于为什么线程不安全,涉及到底层的实现原理,这里暂不做讨论


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