前言

最近公司有个需求,需要用 node 实现一个简单的 PRC client 。之间的通信数据格式是使用 Protobuf。

什么是Protobuf

Protocol Buffer 是 Google 提供的一种轻便高效的结构化数据存储格式,可以用于数据序列化。它的设计非常适用于在网络通讯中的数据载体,很适合做数据存储或 RPC 数据交换格式,对比XML,更小,更快,更简单。再加上以 K-V 的方式来存储数据,对消息的版本兼容性非常强,可用于通讯协议、数据存储等领域,和语言、平台无关的可扩展序列化结构数据格式。

Protobuf message

message 由至少一个字段组合而成,类似于 C 语言中的结构。每个字段都有一定的格式

  1. message Name {
  2. // 限定修饰符 | 数据类型 | 字段名称 | = | 字段编码值 | [字段默认值]
  3. repeated int32 att = 1;
  4. }

类似 JS 中的类,TS 的 interface 。结构中的限定修饰符 Required、Optional、Repeated。

  • Required: 表示是一个必须字段,必须是限定发送方,在发送消息之前必须设置该字段的值;对于接收方,必须能够识别该字段的意思。发送之前没有设置 required 字段或者无法识别 required 字段都会引发编解码异常,导致消息被丢弃。
  • Optional: 表示是一个可选字段,可选是限定发送方,在发送消息时,可以有选择性的设置或者不设置该字段的值;对于接收方,如果能够识别可选字段就进行相应的处理,如果无法识别,则忽略该字段,消息中的其它字段正常处理。很多接口在升级版本中都把后来添加的字段都统一的设置为 optional 字段,这样老的版本无需升级程序也可以正常的与新的软件进行通信,只不过新的字段无法识别而已,因为并不是每个节点都需要新的功能,因此可以做到按需升级和平滑过渡。
  • Repeated: 表示该字段可以包含 0 ~ N 个元素,可以看作是在传递数组。

    Protobuf 有哪些优点

  1. 序列化后体积相比 JSON 和 XML 更小,适合网络传输
  2. 支持跨平台多语言
  3. 消息格式升级和兼容性不错,“向后” 兼容性好
  4. 序列化反序列化速度很快,快于 JSON 的处理速度
  5. Protobuf 语义更清晰,无需类似 XML 解析器的东西(因为 Protobuf 编译器会将 .proto 文件编译生成对应的数据访问类以对 Protobuf 数据进行序列化、反序列化操作)。

Protobuf 的主要优点在于性能高。它以高效的二进制方式存储。

在 Node 中的使用

现在社区里使用较多的有 protobuf.jsgoogle protobufprotocol-buffers,我这里使用的是 protobuf.js
关于用法这里不过多阐述,框架文档写的比较清楚。这里主要介绍的是类似类似 RPC 的使用。

PRC 流程

因为公司的 RPC 服务选择使用 TCP 通信,由于 TCP 协议处于协议栈的下层,能够更加灵活地对协议字段进行定制,减少网络开销,提高性能,实现更大的吞吐量和并发数。所以我们要调用公司的 RPC ,需要实现一个 Node TCP 的 RPC Client。
这里介绍一下大致的调用流程

  1. 调用方(Client)通过本地的 RPC 代理(Proxy)调用相应的接口
  2. 本地代理将 RPC 的服务名,方法名和参数等等信息转换成一个标准的 RPC Request 对象交给 RPC 框架
  3. RPC 框架采用 RPC 协议(RPC Protocol)将 RPC Request 对象序列化成二进制形式,然后通过 TCP 通道传递给服务提供方 (Server)
  4. 服务端(Server)收到二进制数据后,将它反序列化成 RPC Request 对象
  5. 服务端(Server)根据 RPC Request 中的信息找到本地对应的方法,传入参数执行,得到结果,并将结果封装成 RPC Response 交给 RPC 框架
  6. RPC 框架通过 RPC 协议(RPC Protocol)将 RPC Response 对象序列化成二进制形式,然后通过 TCP 通道传递给服务调用方(Client)
  7. 调用方(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。现在来看看我们设计的协议长什么样:
    1. 0 1 2 3 4 5 6 7 8 9 10
    2. +------+------+------+------+------+------+------+------+------+------+
    3. | type | requestId | codec| bodyLength |
    4. +------+---------------------------+------+---------------------------+
    5. | ... payload |
    6. | ... |
    7. +---------------------------------------------------------------------+
    这已经是可以工作的简单 RPC 通讯协议了,但随着 RPC 功能的增加我们可能需要记录更多的信息,比如:在请求头里存放超时的时长,告诉服务端如果响应时间超过某个值了就不用再返回了;在响应头里存放响应的状态是成功还是失败等等。另外,虽然通讯层协议很少会变化,但是考虑到后期的平滑升级、向下兼容等问题,一般第一个 Byte 我们都会记录协议的版本信息。

    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);

  1. ```javascript
  2. // 解码
  3. const type = buf[0]; // => 0 (request)
  4. const requestId = buf.readInt32BE(1); // => 1000
  5. const codec = buf[5];
  6. const bodyLength = buf.readInt32BE(6);
  7. const body = buf.slice(10, 10 + bodyLength);
  8. 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 结尾的。