设计一种简化的 protocol buffer 协议

  • Post author:
  • Post category:IT
  • Post comments:0评论

我们一直使用 google protocol buffer 协议做客户端服务器通讯,为此,我还编写了pbc 库

经过近三年的使用,我发现其实我们用不着那么复杂的协议,里面很多东西都可以简化。而另一方面,我们总需要再其上再封装一层 RPC 协议。当我们做这层 RPC 协议封装的时候,这个封装层的复杂度足以比全新设计一套更合乎我们使用的全部协议更复杂了。

由于我们几乎一直在 lua 下使用它,所以可以按需定制,但也不局限于 lua 使用。这两天,我便构思了下面的东西:

我们只需要提供 boolean integer (32bit signed int) string id (64bit unsigned int) 四种基础类型,和 array 以及 struct 两种用户定义的复合类型即可。

为什么没有 float ? 因为在我们这几年的项目中,使用 float 的地方少之又少,即使有,也完全可以用 string 传输。

为什么没有 enum ? 因为在业务层完全可以自己做 int 到 enum 的互转,没必要把复杂度放在通讯协议中。

为什么不需要 union ? 因为按 protocol buffer 的做法,结构中的每个域都可以用一个数字 tag 来标识,而不是用数据布局来指示。不需要传递的域不需要打包到传输包中。

为什么不需要 default value ? 我们的项目中,依赖 default value 的地方少之又少,反而从我维护 pbc 的大量反馈看,最容易被误用的用法就是通讯协议依赖一个字段有没有最终被打包。所以干脆让(不打包)等价于 default value 就好了。明确这个(在 google protocol buffer 中是错误的)用法。

我设计的这个新协议,命名为 ejoyproto ,它的协议描述成人可读的文本大约是这样的:

.person {
    .address {
        email 0 : string
        phone 1 : string
    }
    name 0 : string
    age 1 : integer
    marital 2 : boolean
    children 3 : *person  #  这是一个 person 类型的数组
    address 4 : address
}

所有涉及命名的地方,都遵循 C 语言规则:以英文字母数字和下划线构成,但不能以数字开头,大小写敏感。

自定义类型用 . 开头。自定义类型可以嵌套。自定义类型的名字不可以是 integer, string 和 boolean 。

每个类型由若干字段构成,每个字段有一个在当前类型中唯一名字和一个唯一 tag 。tag 是一个非负整数,范围是 [0,32767] 。不要求连续,但建议用比较小的数字。

每个字段必须有一个类型,如果希望它是一个数组,在类型前标注 * 。

协议定义次序没有要求,但建议引用一个类型时,类型的定义放在前面。

符号 # 以后是注释。

换行和 tab 没有特别要求,只要是空白符即可。

同时,协议文件里可以描述 RPC 协议,范例如下:

foobar 1 {
    request person
    response {
        ok 0 : boolean
    }
}

这里定义了一条叫做 foobar 的 RPC 协议,赋予它一个唯一的 tag 1 。(在网络传输的时候,可以用 1 代替 foobar )

每条协议都是由两个类型 request 和 response 构成,其中,response 是可选的。

这两个类型都必须是结构,而不能是基本类型或数组。这里可以在 request 或 response 后直接写上引用的类型名,或就地定义一个匿名类型。

这样,一组协议描述数据就可以用 ejoyproto 本身描述了:

.type {
    .field {
        name 0 : string
        type 1 : string
        id 2 : integer
        array 3 : boolean
    }
    name 0 : string
    fields 1 : *field
}

.protocol {
    name 0 : string
    id 1 : integer
    request 2 : string
    response 3 : string
}

.group {
    type 0 : *type
    protocol 1 : *protocol
}

最终提供的 api 会类似这样:

local tag, bytes = encode("foobar.request",
   { name = "alice", age = 13, marital = false })   

可用来打包一个 foobar 请求,返回 foobar 的 tag 以及打包的数据。然后再将它们组合起来构成最终的通讯包(可能还需要置入 session size 等信息)。

Wire Protocol

所有的数字编码都是以小端方式(little-endian) 编码。

打包的单位是一个结构体(用户定义的类型)
每个包分两个部分:1. 字段 2. 数据块

对于数据块,用于描述字段中的大数据。它是由一个 dword 长度 + 按 4 字节对齐的字节串构成。也就是说,每个数据块的长度都一定是 4 的倍数。 对齐时,用 0 填补多余的位置。

字段必须以 tag 的升序排列, 可以不连续; 数据块的次序必须和字段中的引用次序一致。

对于每个字段,由两个 word 构成。

第一个 word 是 tag 。记录的是当前字段的 tag 相较上一个的差值 -1 (对于第一个字段,和 -1 比较)。如果被打包的字段的 tag 是连续的,那么这个位置通常是 0;如果不连续,则记录的跳开的数字差。

第二个 word ,是这个字段的值。如果值为 0 ,表示数据放在数据区;否则这个值减 1 就是这个字段的值(只有是整数和布尔值才有效)。

注:在解码的时候,遇到不能识别的 tag ,解码器应选择跳过(不必确定字段的类型)。这对协议新旧版本兼容有好处。

数据类型在协议描述数据中提供,不在通讯中传输。根据 tag 可以查询到这个字段的类型。如果是对数据块的引用,且数据类型为:

  • integer : 数据块长度一定为 4 ,数据内容就是一个 32bit signed integer 。
  • id : 数据块长度一定为 8 ,数据内容就是 8 个字节的 id 。
  • string : 数据块的长度就是string 的长度, 内容就是字符串。
  • usertype : 那么数据块里就是整个结构。
  • array : 那么数据块就是顺序排列的数据。对于 integer array ,每 4 个字节是一个整数;对于 boolean array ,每个字节可表示 8 个布尔量,从低位向高位排列;对于 string 和 struct ,都是顺序嵌入数据块(长度+内容)。

下面有两个范例:

person { name = "Alice" ,  age = 13, marital = false } :

03 00 01 00 (fn = 3, dn = 1)
00 00 00 00 (id = 0, ref = 0)
00 00 0E 00 (id = 1, value = 13)
00 00 01 00 (id = 2, value = false)
05 00 00 00 (sizeof "Alice")
41 6C 69 63 65 00 00 00 ("Alice" align by 4)
person 
{
    name = "Bob",
    age = 40,
    marital = true,
    children = {
        { name = "Alice" ,  age = 13, marital = false },
    }
}

04 00 02 00 (fn = 4, dn = 2)
00 00 00 00 (id = 0, ref = 0)
00 00 29 00 (id = 1, value = 40)
00 00 02 00 (id = 2, value = true)
00 00 00 00 (id = 3, ref = 1)

03 00 00 00 (sizeof "Bob")
42 6F 62 00 ("Bob" align by 4)

03 00 01 00 (fn = 3, dn = 1)
00 00 00 00 (id = 0, ref = 0)
00 00 0E 00 (id = 1, value = 13)
00 00 01 00 (id = 2, value = false)
05 00 00 00 (sizeof "Alice")
41 6C 69 63 65 00 00 00 ("Alice" align by 4)

0 压缩

这样打包出来的数据的长度必定 4 的倍数,里面会有大量的 0 。我们可以借鉴Cap’n proto 的压缩方案

首先,如果数据长度不是 8 的倍数,就补 4 个 0 。

按 8 个字节一组做压缩,用一个字节 (8bit) 表示各个字节是否为 0 ,然后把非 0 的字节紧凑排列,例如:

unpacked (hex):  08 00 00 00 03 00 02 00   19 00 00 00 aa 01 00 00
packed (hex):  51 08 03 02   31 19 aa 01

当 8 个字节全不为 0 时,这个标识字节为 FF ,这时后面跟一个字节表示有几组 (1~256) 连续非 0 组。
所以,在最坏情况下,如果有 2K 的全不为 0 的数据块,需要增加两个字节 FF FF 的数据头(表示后续有 256 组 8 字节的非 0 块)。

发表评论