Protocol Buffers 是 Google 提出的一种序列化数据格式,最早的 public release 是在08年的7月份推出。相比于 XML 和 JSON ,它的传输大小更小,速度更快,结构更简单,在数据传输和存储的场景下更具有优势。同时 Google 非常友好的提供了不同平台的版本供开发者们使用,其对外的简洁性旨在让开发者们更注重业务的细节。不过本文并没有对源码进行剖析,我们重点分析的是 Protocol Buffers 是如何做到减小体积,也就是 Encoding 的过程。

为了便于理解,以下均已官网例子为例。

Message 定义

1
2
3
message Test1 {
optional int32 a = 1;
}

我们从 Message 定义入手,这是一个简单的 Test1 Message 定义,里面有一个 int32 类型的 a 字段。假设我们将

a 赋值为150,那么在序列化后它在流中的内容是08 96 01,大小只有3字节,而经过 JSON 序列化后有17字节。相比之下大小减小了很多,那08 96 01的含义是什么呢?我们先来认识一下 varints

Base 128 Varints

Varints 是一种用一个或多个字节来表示一个整数的方法。

简单一点,比如整数1用一个字节就表示为

1
0000 0001

当超过128时,比如整数300,它的表示方式则稍微复杂一点了

1
1010 1100 0000 0010

这个是不是有点看不懂。首先 varint 中除去最后一个字节,其余都设有一个最高有效位,用来表示是否有其他的字节出现。而一个字节中的低七位用于保存一个数的二补数。在多组字节进行计算的时候,低位有效组优先,也就是将字节的顺序反序。

根据 varint 的三条规则,我们推导一下1010 1100 0000 0010是如何得到300的。

首先去掉每个字节中的最高有效位(因为最后一个字节的最高有效位是0,说明这是数字的尾端)

1
2
1010 1100 0000 0010
010 1100 000 0010

再将字节反序后计算

1
2
3
4
000 0010  010 1100
000 0010 ++ 010 1100
100101100
256 + 32 + 8 + 4 = 300

Message 结构

回到最初的例子,我们可以看到每个字段由四部分组成:field rulefield typefield namefield number。具体可见:Language Guide (proto3)

1
2
3
message Test1 {
optional int32 a = 1;
}

一个 protocol buffer message 是由一系列的键值对组成,在转换成二进制的过程中只使用 field numberwire type 作为键,name 和定义的 type 需配合 .proto 文件解析得到,这样可以极大的减小传输体积,不过需要留意的是 .proto 文件中字段的定义和顺序,否则会解析出错。

wire type 这里实在不知道该如何翻译…我将其叫做编码后的类型。下表是定义的 type 和编码后类型的对应关系:

上面提到的键则是经过 (field_number << 3) | wire_type 的计算得出,也就是后三位表示 wire type。

现在我们来分析一下08 96 01 。它是一个 key/value 对,我们将其拆成两块0896 01

08转换为二进制表示0000 1000,首先后三位的值为0,对应上表中 Varint,用于 int32, int64等等,a 定义的类型 int32 属于其中。然后再右移三位得到0000 0001 ,值为1,也就是 a 的 field numer 。对照 .proto 文件就解析出 a 字段的位置了。

96 01转换为二进制表示1001 0110 0000 0001,根据 varint 的三条规则,我们来还原它的值

1
2
3
4
96 01 = 1001 0110  0000 0001
000 0001 ++ 001 0110
10010110
128 + 16 + 4 + 2 = 150

综合一下这就是我们想要得到的 a = 150

Messages 嵌套

Message 定义也支持嵌套,比如

1
2
3
message Test3 {
optional Test1 c = 3;
}

同样 Test1 中 a 的值为150,它在流中的内容是1a 03 08 96 01 ,最后三个字节就是上面 a 的内容。1a 03 我们同样拆分为1a03

1a 转换为二进制表示0001 1010,对应的 wire type 为2,对应到 embedded messages,对应的 field number 为3。

03 这个的含义有所不同 ,它表示后面有三个字节,也就是08 96 01

WebSocket + Protocol Buffer

客户端用 SocketRocket ,服务端用 Node ,搭建一套 WebSocket 的环境。

以及用 Pods 安装 protobuf OC 版本,npm 安装 JS 版本

首先我们定义一个简单的 Message 结构 Person,里面包含了 name 和 age 两个字段,生成 .proto 文件,在客户端和服务端各自维护一份。

1
2
3
4
5
6
syntax = "proto3";

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

然后我们启动后端服务,监听1234端口,并对接收到的数据根据 Person.proto 文件定义的结构进行 decode ,然后打印数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const WebSocket = require('ws');
const pro = require('protobufjs');

const wss = new WebSocket.Server({port: 1234});

wss.on('connection', function connection(ws) {
console.log("conntected");

ws.on('message', function incoming(message) {
pro.load('Person.proto', function (err, root) {
if (err) {
throw err;
}

var Person = root.lookupType('personPackage.Person');
var data = Person.decode(message);
console.log('name is %s', data.name);
console.log('age is %i', data.age);
});

console.log('received: %s', message);
});

ws.send('something');
});

服务启动成功之后,开始在客户端建立连接,在收到连接成功的回调之后,将 person 对象进行encode 并发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_ws = [[SRWebSocket alloc] initWithURL:[NSURL URLWithString:@"ws://localhost:1234"]];
_ws.delegate = self;
[_ws open];

#pragma mark - SRWebSocketDelegate
- (void)webSocketDidOpen:(SRWebSocket *)webSocket {
NSLog(@"%s", __func__);
Person *person = [Person new];
person.name = @"yzy";
person.age = 28;

NSData *data = [person data];
NSLog(@"%@", data);

[webSocket send:data];
}

在客户端 log 中我们看到输出的 data 内容为 0a03797a 79101c ,你可以尝试一下对其 decode ,看它是否和传输的内容一致。

同时服务端接收到数据后,进行 decode ,在对应的 log 中也正确输出了

1
2
name is yzy
age is 28

OK,这样一个简单的 Protobuf 的使用就完成了。