前言
最近公司有个需求,需要用 node 实现一个简单的 PRC client 。之间的通信数据格式是使用 Protobuf。
什么是Protobuf
Protocol Buffer 是 Google 提供的一种轻便高效的结构化数据存储格式,可以用于数据序列化。它的设计非常适用于在网络通讯中的数据载体,很适合做数据存储或 RPC 数据交换格式,对比XML,更小,更快,更简单。再加上以 K-V 的方式来存储数据,对消息的版本兼容性非常强,可用于通讯协议、数据存储等领域,和语言、平台无关的可扩展序列化结构数据格式。
Protobuf message
message 由至少一个字段组合而成,类似于 C 语言中的结构。每个字段都有一定的格式
message Name {// 限定修饰符 | 数据类型 | 字段名称 | = | 字段编码值 | [字段默认值]repeated int32 att = 1;}
类似 JS 中的类,TS 的 interface 。结构中的限定修饰符 Required、Optional、Repeated。
- Required: 表示是一个必须字段,必须是限定发送方,在发送消息之前必须设置该字段的值;对于接收方,必须能够识别该字段的意思。发送之前没有设置 required 字段或者无法识别 required 字段都会引发编解码异常,导致消息被丢弃。
- Optional: 表示是一个可选字段,可选是限定发送方,在发送消息时,可以有选择性的设置或者不设置该字段的值;对于接收方,如果能够识别可选字段就进行相应的处理,如果无法识别,则忽略该字段,消息中的其它字段正常处理。很多接口在升级版本中都把后来添加的字段都统一的设置为 optional 字段,这样老的版本无需升级程序也可以正常的与新的软件进行通信,只不过新的字段无法识别而已,因为并不是每个节点都需要新的功能,因此可以做到按需升级和平滑过渡。
- Repeated: 表示该字段可以包含 0 ~ N 个元素,可以看作是在传递数组。
Protobuf 有哪些优点
- 序列化后体积相比 JSON 和 XML 更小,适合网络传输
- 支持跨平台多语言
- 消息格式升级和兼容性不错,“向后” 兼容性好
- 序列化反序列化速度很快,快于 JSON 的处理速度
- Protobuf 语义更清晰,无需类似 XML 解析器的东西(因为 Protobuf 编译器会将 .proto 文件编译生成对应的数据访问类以对 Protobuf 数据进行序列化、反序列化操作)。
Protobuf 的主要优点在于性能高。它以高效的二进制方式存储。
在 Node 中的使用
现在社区里使用较多的有 protobuf.js、google protobuf、protocol-buffers,我这里使用的是 protobuf.js
关于用法这里不过多阐述,框架文档写的比较清楚。这里主要介绍的是类似类似 RPC 的使用。
PRC 流程
因为公司的 RPC 服务选择使用 TCP 通信,由于 TCP 协议处于协议栈的下层,能够更加灵活地对协议字段进行定制,减少网络开销,提高性能,实现更大的吞吐量和并发数。所以我们要调用公司的 RPC ,需要实现一个 Node TCP 的 RPC Client。
这里介绍一下大致的调用流程
- 调用方(Client)通过本地的 RPC 代理(Proxy)调用相应的接口
- 本地代理将 RPC 的服务名,方法名和参数等等信息转换成一个标准的 RPC Request 对象交给 RPC 框架
- RPC 框架采用 RPC 协议(RPC Protocol)将 RPC Request 对象序列化成二进制形式,然后通过 TCP 通道传递给服务提供方 (Server)
- 服务端(Server)收到二进制数据后,将它反序列化成 RPC Request 对象
- 服务端(Server)根据 RPC Request 中的信息找到本地对应的方法,传入参数执行,得到结果,并将结果封装成 RPC Response 交给 RPC 框架
- RPC 框架通过 RPC 协议(RPC Protocol)将 RPC Response 对象序列化成二进制形式,然后通过 TCP 通道传递给服务调用方(Client)
- 调用方(Client)收到二进制数据后,将它反序列化成 RPC Response 对象,并且将结果通过本地代理(Proxy)返回给业务代码
实现过程
从流程中可以看知道,我们需要实现的就是就是 Client 调用的 RPC 代理实现。需要做的就是将 RPC 的服务名,方法名和参数等等信息转换成一个标准的 RPC Request 对象,根据规则将 RPC Request 对象序列化成二进制形式,然后通过 TCP 通道传递给服务提供方 (Server)
因为在 TCP 通道里传输的数据只能是二进制形式的,所以我们必须将数据结构或对象转换成二进制串传递给对方,这个过程就叫「序列化」。而相反,我们收到对方的二进制串后把它转换成数据结构或对象的过程叫「反序列化」。而序列化和反序列化的规则就叫「协议」。
因为公司的 RPC 协议是设计好的,我们只需根据对应的约定实现就可以了。为了更好的理解协议,我们这里尝试设计一个 RPC 通讯协议。设计 PRC 协议
通常它由一个 Header 和一个 Payload(类似于 Body)组成,合起来叫一个包(Packet)。之所有要有包,是因为二进制只完成 Stream 的传输,并不知道一次数据请求和响应的起始和结束,我们需要预先定义好包结构才能做解析。
协议设计就像把一个数据包按顺序切分成若干个单位长度的「小格子」,然后约定每个「小格子」里存储什么样的信息,一个「小格子」就是一个 Byte,它是协议设计的最小单位,1 Byte 是 8 Bit,可以描述 0 ~ 2^8 个字节数,具体使用多少个字节要看实际存储的信息。我们在收到一个数据包的时候首先确定它是请求还是响应,我们需要用一个 Byte 来标记包的类型,比如:0 表示请求,1 表示响应。知道包类型后,我们还需要将请求和它对应的响应关联起来,通常的做法是在请求前生成一个「唯一」的 ID,放到 Header 里传递给服务端,服务端在返回的响应头里也要包含同样的 ID,这个 ID 我们选择用一个 Int32 类型(4 Bytes)自增的数字表示。要能实现包的准确切割,我们需要明确包的长度,Header 长度通常是固定的,而 Payload 长度是变化的,所以要在 Header 留 4 个 Bytes(Int32) 记录 Payload 部分的长度。确定包长度后,我们就可以切分出一个个独立的包。Payload 部分编码规则由应用层协决定,不同的场景采用的协议可能是不一样的,那么接收端如何知道用什么协议去解码 Payload 部分呢?所以,在 Header 里面还需要一个 Byte 标记应用层协议的类型,我们称之为 Codec。现在来看看我们设计的协议长什么样:
这已经是可以工作的简单 RPC 通讯协议了,但随着 RPC 功能的增加我们可能需要记录更多的信息,比如:在请求头里存放超时的时长,告诉服务端如果响应时间超过某个值了就不用再返回了;在响应头里存放响应的状态是成功还是失败等等。另外,虽然通讯层协议很少会变化,但是考虑到后期的平滑升级、向下兼容等问题,一般第一个 Byte 我们都会记录协议的版本信息。0 1 2 3 4 5 6 7 8 9 10+------+------+------+------+------+------+------+------+------+------+| type | requestId | codec| bodyLength |+------+---------------------------+------+---------------------------+| ... payload || ... |+---------------------------------------------------------------------+
Node 中如何实现 RPC 通讯协议
理解了原理之后,就是如何实现的问题。分为编码和解码两部分。下面两段代码就分别实现了上文设计的通讯协议的编码和解码。 ```javascript // 编码 const payload = { service: ‘log.uat.hellowshopee.com’, methodName: ‘XXX’, args: [ 1, 2 ], }; const body = new Buffer(JSON.stringify(payload));
const header = new Buffer(10); header[0] = 0; header.writeInt32BE(1000, 1); header[5] = 3; // codec => 1 根据约定设置,3代表是 JSON 序列化 header.writeInt32BE(body.length, 6);
const packet = Buffer.concat([ header, body ], 10 + body.length);
```javascript// 解码const type = buf[0]; // => 0 (request)const requestId = buf.readInt32BE(1); // => 1000const codec = buf[5];const bodyLength = buf.readInt32BE(6);const body = buf.slice(10, 10 + bodyLength);const payload = JSON.parse(body);
如上,其实都是通过对 Buffer 的操作。可以参考 Node 的官方文档,这里不做过多阐述。
大端序和小端序
在上面的代码中有个 writeInt32BE ,并且在官方文档中许多 Buffer API 都是 xxxBE 和 xxxLE 两个版本成对出现的,比如:readInt32BE 和 readInt32LE,writeDoubleBE 和 writeDoubleLE 等等。BE 和 LE 分别代表什么含义?它们有什么区别?我们应该用 BE 还是 LE 呢?细心的同学可能还会问为什么 writeInt8 这个 API 没有 BE 和 LE 的版本?
它们的区别在于:Int8 只需要一个字节就可以表示,而 Short,Int32,Double 这些类型一个字节放不下,我们就要用多个字节表示,这就要引入「字节序」的概念,也就是字节存储的顺序。对于某一个要表示的值,是把它的低位存到低地址,还是把它的高位存到低地址,前者叫小端字节序(Little Endian),后者叫大端字节序(Big Endian)。大端和小端各有优缺点,不同的CPU厂商并没有达成一致,但是当网络通讯的时候大家必须统一标准,不然无法通讯了。为了统一网络传输时候的字节的顺序,TCP/IP 协议 RFC1700 里规定使用「大端」字节序作为网络字节序,所以,我们在开发网络通讯协议的时候操作 Buffer 都应该用大端序的 API,也就是 BE 结尾的。
