« 给 skynet 增加 http 服务器模块 | 返回首页 | sproto 的实现与评测 »

设计一种简化的 protocol buffer 协议

我们一直使用 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 块)。

Comments

(id = 0, ref = 0) 这个条都有 有什么作用呢?

fn=4 dn=2
表示
下面有 4 组 field 数据,
2 组 data 数据

请问一下编码中的(fn = 4, dn = 2)是什么意思?

@tollyee

你可以试一下加浮点数.
同时也可以尝试用定点数. (直接把数字放大 1024 倍)

整个序列化接口的设计非常简洁,很方便实用,我的项目中正好需要序列化大量的浮点数据,请问在现有的代码中添加浮点数的序列化容易吗?会不会破坏现有代码的一致性?

0 压缩:
08 00 00 00 03 00 02 00
-- - - - = = = =
1 0 0 0 1 0 1 0
<-
1(0001) 5(0101)

github上面没有看到性能和Cap'n proto的比较

协议格式也和Cap'n proto差不多

不错,如果数据长度不是 8 的倍数,不足8字节的部分,就不必压缩了,更好处理

不错,如果数据长度不是 8 的倍数,不足8字节的部分,就不必压缩了,更好处理

这主要强调 Schema , map 其实是 schemaless 的.

如果支持 map, 不如直接用 json.

为什么不支持map类型,直接和lua表对应不是更方便?

为什么选择小端序呢?ProtoBuf也选择用小端序,但是约定的网络字节序不是大端序么?是因为只使用x86架构的CPU么?

# 这是一个 preson 类型的数组

错字

写MMO的思路,总也绕不过性能的门槛,总是追求单服在线人数,这样对程序是很有挑战,但是对于整个项目却未必有什么挑战。

我们也有类似协设计,参考了json,可以看成是binary的json,有object类型,这样成员可以带上name,写日志会非常方便(直接转成类似json的文本后再记录)

TLV也可以,protobuffer 太大了。

TLV也可以,protobuffer 太大了。

@dwing

以我写过两个不同版本的 protobuffer 编解码模块的经验, protobuffer 的结构(尤其是 varint 的引入) 非常不易于实现.

它需要引入大量临时内存,编码复杂,性能很低.


如果有路由信息,比如该消息需要从ID1通过服务器转发到ID2,是不是也基于这套机制去定义在request中?

前面的设计还不错, 后面的编码设计反而比pb更复杂了, 而且也没看出压缩后的效果比pb更省空间,除了有重复数据的情况.

不是有个新出的FlatBuffers

我一直再用pbc和pbc Lua绑定....

云风终于设计自己的协议代替掉google pb了...

rpc协议为什么不像google pb一样设计成一组的,一个service下有多个rpc方法?

repeated类型使用 *integer而不是 [integer]是出于什么考虑?解析方便吗?

我们内部讨论的时候谈论过名字问题,可以考虑叫 sproto .

那么 google protocol buffer 这个名字有商业味道么?

cool, 如果能用一个更中立的名字就更好了, ejoyproto 感觉有些商业相关的味道.

设计的很好。。。游戏中就该使用这么简洁的协议。我之前想过N久都没想好怎么搞。

Post a comment

非这个主题相关的留言请到:留言本