RPC框架的核心设计
- 模式封装,如何做到像调用本地一样调用远程服务
- 协议设计,如何设计RPC的网络传输协议
- 通讯管理,如何选择序列化协议、如何组织路由、选择服务发现、选择连接管理等
- 横向特性,安全与鉴权、服务治理、插件体系、可用性设计、运维管理等等。
本篇文章介绍模式封装与协议设计。
golang的RPC调用模式封装
RPC是网络通讯的进一步演进,在更接近用户层面上封装网络通讯调用,方便开发者聚焦核心的服务端与客户端业务逻辑。
RPC期望能像调用本地服务一样调用远程,但是底层其实还是基于网络通讯的。客户端传递服务名、方法名、参数、(参数类型)等信息给服务端,服务端如何通过这些信息调用本地服务,并将结果通过网络传输回客户端呢?
有人说最直接就是在服务端写清楚服务映射,客户端发起请求后,服务端通过参数找到服务映射然后调用对应的服务...但是,你确定这一套说的不是HTTP请求?既然有客户端发起网络请求这个概念了,那么就不属于RPC——RPC就是要屏蔽掉客户端的网络控制。
这里我们可以发现一个RPC的关键设计点,就是要隐藏掉发起请求的概念,让客户端像调用本地一样调用服务端。而计算机中完全可以通过加一个中间层来屏蔽掉双方的细节。
神秘中间层
要在客户端中调用服务端的服务,而且要将底层的网络传输隐藏,所以,客户端侧肯定需要有一个“服务端的替身”,客户端调用这个替身就相当于调用了服务端,而这个替身的工作就是将请求信息封装,通过网络传输,接收响应并转换,最后传递结果给客户端。
听起来好像很简单,但是核心真就这样吗?我问一个问题。不管客户端调服务A还是调用服务B,是不是都是调用这个“替身”?调用这个替身的话,那么,替身怎么知道你调用A方法就是调用服务端的A方法呢?如果是你自己手写实现这个“替身”,那么相当于在手写rpc的核心流程,也就不需要rpc框架。
但是,如果是写一个rpc框架,框架开发者是不知道框架使用者的具体服务的,如何未卜先知“替身”应该怎么写?目前golang的rpc框架有两种方案解决这个问题:
- 一是grpc为首的代码生成,通过proto生成客户端与服务端的代码——也就是“替身”,生成的代码就是替身要做的逻辑,框架只需要直接调用就可以;
- 另外一种方式是通过反射完成运行时动态代理,代表项目是rpcx,提供了一个“替身”的实现,只是在初始化的服务注册时,会通过反射将“替身”里表示服务端的各个方法都包装一层——将调用信息序列化后走网络传输,客户端调用“替身”的时候就执行包装后的逻辑。这种实现的弊端就是服务端方法的参数要固定,详情可以看看rpcx中定义服务方法的规范。
所以,实现RPC框架的一个核心点就是如何在客户端构造一个“服务端的替身”,让客户端可以像调用本地方法那样调用服务端方法,并且对底层的网络通讯屏蔽。
从数据流程来分析,RPC框架执行过程应该是这样:
- 客户端:接收请求结构体,调用本地对应的服务方法,请求结构封装转化为调用信息的结构并构造成协议请求,通过网络将协议内容发送。接收到请求的响应后,从协议结构体中解码出具体的响应信息,返回给调用者。
- 服务端:接收网络请求,获取协议内容,根据协议中的请求信息,调用特定服务,得到响应结果。将结果转化为协议结构体,通过网络返回给客户端。
要实现这种本地方式调用远程的模式,目前行业内依靠的是下面两种技术:
-
代码生成:通过定义协议文件——protocol,然后对应生成客户端代码与服务端代码,通过框架链接两者,然后可以直接调用。
就像grpc通过protobuf文件生成的代码,其实是client与server之间形成一个中间连接层,生成的代码逻辑属于服务在客户端中的"替身“。 client会直接请求生成的代码,将结构通过网络传输到服务端。生成代码中的服务端逻辑会接收并解析请求,随后调用服务得到响应, 最后响应客户端。 -
动态代理:不通过代码生成的方式统一调用,而是依靠运行时动态更改或者生成新的内容。比如,golang中不支持java那样的动态代理,但是可以通过反射,修改某一些值,这样将动态代理技术应用在客户端上,就可以实时获取某次调用的时候,传递的调用参数信息,然后转换参数信息为请求结构(或者转为协议的字节格式)再发起调用,随后接收响应,再通过动态代理的流程把响应从协议结构转化为响应的结构体。
这就是RPC用来屏蔽客户端网络层调用的核心模式,要么代码生成固定的行为,要么通过动态代理实时修改客户端调用的具体逻辑。模式封装完成后,下一步是协议设计。
协议设计
RPC协议一般是基于TCP层面自定义协议内容,另外一种方式就是基于HTTP协议完成信息发送与信息响应。如果要自定义协议,一般就基于TCP层面设计,因为基于HTTP的话,就基本只是往HTTP里填充内容。如何设计协议,就类似于tcp协议。
先看看顶级rpc框架的协议是如何设计的,然后可以总结核心点。
grpc
grpc的协议是基于http协议,header与body分别放在http的header与body中。
当客户端调用服务端的时候,先建立tcp连接,传输http2初始化帧,完成后将元数据写入header中——path字段的服务地址与名称、认证令牌、调用截止时间等meta。然后在http2的body中存放request对象的二进制。服务端收到请求后,根据header的信息找到对应的服务,将data反序列化后调用实际的业务逻辑来处理这个请求,完成后在header设置相关状态信息后,借助http2协议返回给客户端。
dubbo2
cn.dubbo.apache.org/zh-cn/docs/…
dubbo的协议经过不断升级,在dubbo3的时候构造了兼容grpc、基于http2的Triple协议,其实已经和grpc十分相似了。但介绍rpc的协议设计时,dobbo2的设计思路还是十分值得分析的。
dubbo2区分为header设计与data设计:
header:
- 魔数 (Magic Number, 2 bytes): 固定为 0xdabb,用于标识这是一个Dubbo协议的数据包,帮助接收方快速识别和校验数据流
- 序列化标志与调用类型 (1 byte): 这个字节的高几位通常用于表示消息的序列化方式(如Hessian2),低几位(通过掩码操作)用于标识消息是请求、响应、心跳还是事件。
- 状态 (Status, 1 byte): 在响应报文中使用,用于指示调用结果的状态,例如成功(OK)、系统错误等。
- 请求ID (Request ID, 8 bytes): 一个长整型的唯一标识符,用于匹配请求和响应。客户端发出请求时生成一个ID,服务端在响应中携带相同的ID,从而实现异步调用下的关联
- 消息体长度 (Body Length, 4 bytes): 一个32位整数,指明了后续消息体部分的字节数。这使得接收方可以准确地读取完整的消息体。
紧跟着header内容的是data数据:
- 对于请求消息体:包含要调用的服务接口名、方法名、参数类型列表、参数值以及附加的附件(Attachments)等信息。这些数据会根据头部指定的序列化协议(默认为Hessian2)被序列化成二进制流
- 对于响应消息体:包含调用结果,可能是返回值或异常信息,同样采用指定的序列化方式进行编码
协议核心设计点
-
协议一般分为header与body的部分,body部分属于业务数据,是不固定长度的。
-
header是否变长?固定长度表示只支持特定的字段,而变长的设计最大的特点就在于框架使用者可以自定义header中的信息,具体看业务取舍。
-
头部应该可以包含什么信息?一般包含服务名称、压缩方式、请求ID、数据长度、序列化协议等,存在特定的规范与字节划分。在设计之初就需要想好哪些字节要放什么信息,比如版本号给几个bit等。
-
头部信息如何分割以便于读取?如何分割的问题,因为切取出来的是字节数组,所以一般都是通过特定的字节符号来分割,比如http的换行符等。
-
如果分割符出现在了用户数据中怎么办?
一般冲突会发生在header或者在body中,要么就做规避,要么就做校验。
对于控制不了的情况,就是做规避,比如body中会出现什么其实是不可控的,那么读取的时候就只读取header的长度来操作其中的数据,规避掉body中数据的影响。
对于可控的header数据,就是做校验,禁止用户在其中使用某些字符。 -
如果要自定义协议,一般就是在header中增加相应的版本信息与元数据,然后服务端识别并执行具体自定义协议的解析。
不过,现在大多数RPC框架(如go-zero、karatos)都没有自定义rpc协议,而是在grpc的基础上封装,提供更常见的功能与某些最佳实践,除非是公司内部自研的、为了兼容与统一旧服务的情况,才会自定义设计RPC协议。
附录
中间层的动态代理
现在有一个服务GetName(id string) string 还有另外一个服务UpdateName(name string, id int), 客户端期望通过RPC的方式调用服务端的服务。那么,其实客户端应该就是直接调用“替身”的GetName方法或者UpdateName方法,并传递对应的参数。如果不使用动态代理的方式,那么,调用过程参数就无法与服务方法相适配。所以,要么使用动态代理在运行时绑定方法的具体逻辑,要么通过代码生成技术,主动生成对应的调用逻辑。