Golang:从0到1手写一个简易RPC框架

1,528 阅读11分钟

前言

“造轮子”是开发人员提升自己技术水平的一个很好的手段,本文作为自己从0到1手写一个简易的RPC框架学习的一个总结,讲述了我对于RPC框架学习的大致流程,技术选型,设计思路,并展示最终的代码成果。

什么是RPC

RPC的基本定义

如果我们需要调用远程服务器上的方法时,正常情况需要涉及组装请求+网络传输,而这些代码的编写无疑增加了开发的复杂度,而RPC框架可以使你调用远程服务器的方法时,就和调用本地的方法一样。相当于,RPC为你屏蔽了底层所有网络传输的细节。 所以,一个最简RPC框架,就是可以满足:为你屏蔽网络传输的一切细节,使得开发人员调用远程服务器上的方法时,就和调用本地内存中的方法一样,让使用者不必显式的区分本地调用和远程调用。

RPC架构

image.png

如图,RPC框架的核心就是这个所谓的“stub”,中文名称叫做“桩”,也可以叫做“proxy”等等。作为开发者而言,你只需要调用client stub的方法,就可以拿到结果,就好像你调用了一个本地方法一样简单,而实际上你所调用的方法实现,却是在一台远程服务器上。

// 我们只需要这样调用一个方法,就可以拿到返回结果,完全没有感知到服务提供者是在另一台服务器上
res, err := clientStub.GetInfoById(123)
...

那么这个stub究竟该如何生成,就是RPC框架实现的核心了。

“stub”该怎么实现

市面上的RPC框架层出不穷,但是核心原理却是万变不离其宗。stub的实现,本质上都是采用了一种设计模式:代理模式。 代理模式为目标类生成一个代理类,由代理类来实现针对目标类的控制,可以用在权限验证、风险控制、调用链跟踪等等很多场景中。

也就是说,我们需要给服务端真正提供方法实现的“被代理类”生成一个“代理类”,这个“代理类”是放在客户端的,代理类需要实现含有用户定义的方法的接口,之后,客户端仅仅需要和这个RPC框架生成的“代理类”进行交流,即可拿到结果,而由代理类去和远程服务器上的“被代理类”进行跨主机交流。

那么,代理模式,在现有成熟的RPC框架中,是怎么实现的呢?

调研

dubbo

dubbo作为Java实现的一款RPC框架,采用的是:“动态代理” 来生成RPC的桩。也就是,dubbo会根据用户定义的interface,动态生成一个代理实例。其原理是: Java语言在编译之后,会生成一堆.class文件,这些文件具有固定的格式,本质上就是对你所写的代码的另一种描述。而JVM在运行时可以读取这些.class文件,并加载对应的实例。 动态代理的本质,就是按照.class文件的格式,运行时动态生成class文件,这样就可以不用经过源代码编译的阶段,在运行时动态加载一个新的实例了。

grpc && thrift

grpc和thrift采用的则是:代码生成策略。本质上就是需要你先编写IDL文件,之后根据IDL文件生成目标源代码文件,这些源代码文件就是用于描述生成的桩的。也就是:你来编写IDL,框架再将你的IDL转化成描述桩的代码。这些生成的源代码会跟着一起被编译。

思路&&设计

分析

因为我们是使用golang来实现RPC框架,动态代理是Java语言独有的特性,go根本不支持动态代理。也就是说:go不可能在运行时,凭空出现一个新的类,在编译时有哪些,运行时就有哪些。 所以,这条思路显然不合适。

其次,代码生成策略需要涉及IDL的编写,之后根据IDL生成目标源代码文件,那么显然这里需要涉及到解析IDL文件等十分复杂的操作。我目前还没有调研清楚grpc等代码生成策略的原理是怎样的,但是如果让我来做,我可能会按照 模板+数据 的方式来生成代码,也就是将具体的数据填入事先写好的模板中,但是这种做法显然无法称得上优雅。

决策

既然go没法在运行时生成一个新的类,那么,我们可不可以在运行时不生成一个新的客户端的代理类,而是篡改掉已有类的内部实现,将一个已有的类变成一个代理类呢,也就是说,你dubbo是在运行时为interface新生成一个代理类,那么我用go,可不可以不新生成一个编译时没有的类,而是在运行时篡改掉一个编译期已存在的类的内部实现,将他变成一个远程服务的代理类呢?显然是可以的。

type ClientStub struct {
GetInfo func(ctx context.Context, req *GetInfoReq) (*GetInfoResp, error)
}

如图,我们定义了一个结构体,结构体内有一个func类型的成员变量,我们可以把这个结构体理解为一个stub,里面有一个方法,是这个stub下支持被调用的方法,这个方法规定了入参和出参。

之后,我们可以通过反射,为这个方法类型的成员变量注入调用逻辑,也就是说,通过反射,为这个方法注入:序列化->网络传输->拿到结果->反序列化,这样一个过程。

这样,客户端只需要做的操作就是:

// 定义stub
type ClientStub struct {
GetInfo func(ctx context.Context, req *GetInfoReq) (*GetInfoResp, error)
}

client := ClientStub{}

// 初始化stub,在这里使用反射,为方法成员变量注入调用逻辑
err := InitStub(&client)

// 调用方法,拿到返回结果
res, err := client.GetInfo(ctx, req)

协议设计

可以看到,无论是http协议还是TCP协议,我了解到的所有的网络协议都是分为协议头和协议体两部分,协议头用于放置一些元数据,以及解析协议体所依赖的数据,协议体一般就是放置请求数据了。所以毫无疑问,我们的协议也会分为协议头和协议体两个部分。例如dubbo的协议结构如下:

image.png

我最终的自定义协议如下:

type Request struct {
   // 头部长度
   HeadLength uint32
   // 消息体长度
   BodyLength uint32
   // 消息id 多路复用使用
   MessageId uint32
   // 版本
   Version byte
   // 压缩算法
   Compressor byte
   // 序列化协议
   Serializer byte
   // ping探活
   Ping byte
   // 服务名
   ServiceName string
   // 方法名
   MethodName string
   // 元数据 可扩展
   Meta map[string]string 
   // 消息体
   Data []byte    
}

type Response struct {
   // 头部长度
   HeadLength uint32
   // 消息体长度
   BodyLength uint32
   // 消息id 多路复用使用
   MessageId uint32
   // 版本
   Version byte
   // 压缩算法
   Compressor byte
   // 序列化协议
   Serializer byte
   // pong探活
   Pong byte
   // 错误信息 可以是业务error,也可以是框架error
   Error []byte
   // 协议体
   Data []byte
}

序列化协议

所有的RPC框架必须要支持序列化,因为RPC需要将对象转变成二进制流在网络中传输,也需要将二进制流解析成对象实例。我们的RPC框架显然也需要支持这个功能。所以我们定义了通用的序列化方法,所有的序列化协议都需要实现我们定义的方法。

type Serializer interface { // 序列化协议只是用来序列化协议体的,不涉及头部
   Code() byte
   Encode(val interface{}) ([]byte, error)
   Decode(data []byte, val interface{}) error
}

连接健康检测

我们采用了连接池来保管客户端到服务端建立的TCP连接,那么这个连接在不使用的时候,会一直放在池子里,就有可能因为各种原因导致连接失效,当我们再次从池中拿出这个已经损坏的连接进行数据传输时,就会有问题,而如果采用TCP自带的keep-alive机制去检测连接的健康状况,需要至少两个小时才能发现连接的异常,所以我们需要实现一个应用层的连接健康检测机制。我的实现本质上就是模拟TCP自带的健康检测机制,发送一个含有极少数据的报文,服务端检测到这是一个心跳报文,也会回复一个含有极少数据的心跳回复,当客户端能够接收到这个心跳回复,就表明连接是健康的。

所以我们在每次从连接池获取一个连接时,都要先使用这个连接ping一下服务端,如果这个连接是健康的,那就可以被使用,反之就需要丢弃这个连接。

异常问题&&处理方法

粘包/拆包

由于我们采用的是TCP连接,TCP协议可能会因为它自身为了提高传输效率,亦或者是接收端未能及时从socket缓冲区读取数据,从而发生TCP粘包/拆包现象。这是应用层无法避免的。所以我们需要一个解决办法:常规的解决思路,就是在协议中加上本次协议的长度,例如加上协议头,协议体的长度。在读取时期望能够根据长度读取到本次传输所有的数据。

如图,是数据在网络协议栈中传递的流程。

image.png

那如果没有读够呢?例如我期望读取100字节长度,但是只读到了50字节怎么办?首先可以确定的是,TCP的超时重传,滑动窗口的特性可以保证后50个字节一定会在未来的某一时刻按顺序读到,所以我们在读取数据时本身可以启动一个新的goroutine,如果没有读够可以尝试等待一会,继续读取socket缓冲区的数据,直到读够100字节再交给应用程序。

我们这里采用了一个更加简单的办法,如果当下没有读够期望的字节,就直接关闭掉这条连接。

TCP连接异常

假如某一端的进程突然崩溃了,或者直接宕机了,又该怎么办呢?

如果是进程崩溃:Linux内核在回收进程资源时,会向对端发送FIN报文试图完成四次挥手,那么此时的连接是可以被关闭的。

如果是宕机:此时连同操作系统也一块没了,另一端在没有数据传输的情况下,是无法感知到的。所以如果一直没有数据传输,那么到达90分钟时,会触发TCP自带的keep-alive机制,向对端发送心跳报文,对端一直不回复,那么重试超过阈值之后,就会认为此链接已损坏。 而如果有数据传输,假如数据传输时,宕机的一端未能重启,则发送方会在重传超过一定阈值之后,认为连接已损坏。而如果宕机的一端已经重启了,那么此时的内存中已经不存在对应的socket结构了,所以重启的一端会回复RST报文试图重置连接。

所以可见,连接异常时,可能会触发四次挥手,也可能只会在传输数据时才能发现。所以我们的服务需要处理这两种情况,针对四次挥手,可以直接close掉对应的socket。而如果是宕机,那么只能在真正使用这条连接之前先ping一下,如果可以ping通,则可以正常使用,否则也需要丢掉这条连接。

总结

以上就是实现一个最简RPC框架的调研,以及思考,设计过程。我实现的仅仅是一个最基础的RPC框架,在工业界,一个生产级别的RPC框架还需要支持很多的功能,这些功能的本质都是为了应对生产环境的大流量特点以及保障服务稳定性健壮性而必须具备的,例如服务发现,负载均衡,熔断限流,异常重试,链路追踪,路由分组等等,这些都是我在未来会不断学习和完善的点。

结语

上述基础RPC框架完整代码的地址是 Zhang-hs-home/RPC: A single RPC framework based on Go (github.com)。 这个项目目前已经支持:

  • 采用自定义协议,分为协议头和协议体,手动对协议进行编码和解码。
  • 基于TCP进行网络通信。
  • 支持轻松的扩展序列化协议作用于协议体,源代码已支持json,protobuf协议。
  • 采用连接池管理客户端连接。
  • 采用ping探活检测连接的健康状态,如果连接池中的连接有问题,则会丢弃掉连接。

如果对你有帮助的话,期待你能够点亮star,也期待你提出宝贵的建议,如有错误,也欢迎指正。