« 开发笔记 (3) | 返回首页 | 概率问题 »

Protocol Buffers for C

我一直不太满意 google protocol buffers 的默认设计。为每个 message type 生成一大坨 C++ 代码让我很难受。而且官方没有提供 C 版本,第三方的 C 版本 也不让我满意。

这种设计很难让人做动态语言的 binding ,而大多数动态语言往往又没有强类型检查,采用生成代码的方式并没有特别的好处,反而有很大的性能损失(和通常做一个 bingding 库的方式比较)。比如官方的 Python 库,完全可以在运行时,根据协议,把那些函数生成出来,而不必用离线的工具生成代码。

去年的时候我曾经写过一个 lua 版本的库 。为了独立于官方版本,我甚至还用 lpeg 写了一个 .proto 文件的解析器。用了大约不到 100 行 lua 代码就可以解析出 .proto 文件内的协议内容。可以让 lua 库直接加载文本的协议描述文件。(这个东西这次帮了我大忙)

这次,我重新做项目,又碰到 protobuf 协议解析问题,想从头好好解决一下。上个月一开始,我想用 luajit 好好编写一个纯 lua 版。猜想,利用 luajit 和 ffi 可以达到不错的性能。但是做完以后,发现和 C++ 版本依然有差距 (大约只能达到 C++ 版本的 25% ~ 33% 左右的速度) ,比我去年写的 C + Lua binding 的方式要差。但是,去年写的那一份 C 代码和 Lua 代码结合太多。所以我萌生了重新写一份 C 实现的想法。

做到一半的时候,有网友指出,有个 googler 最近也在做类似的工作。μpb 这个项目在这里 。这里他写了一大篇东西阐述为什么做这样一份东西,大体上和我的初衷一致。不过他的 api 设计的不太好,我觉得太难用。所以这个项目并不妨碍我完成我自己的这一份。


C 版本之所以很难把 api 设计好,是因为 C 缺乏必要的数据结构。而且没有垃圾回收,缺乏数据类型的元信息。

考虑再三,我决定提供两套 api ,满足不同的需求。

当性能要求不太高的时候,仅仅满足 C 语言开发的便捷需要,提供一套简单易用的 api 操作 protobuf 格式的 message 。我称之为 message api 。

大体上有两组 api :

对于编码 protobuf 的消息,使用 rmessage 相关 api

struct pbc_rmessage * pbc_rmessage_new(struct pbc_env * env, const char * typename , struct pbc_slice * slice);
void pbc_rmessage_delete(struct pbc_rmessage *);

uint32_t pbc_rmessage_integer(struct pbc_rmessage * , const char *key , int index, uint32_t *hi);
double pbc_rmessage_real(struct pbc_rmessage * , const char *key , int index);
const char * pbc_rmessage_string(struct pbc_rmessage * , const char *key , int index, int *sz);
struct pbc_rmessage * pbc_rmessage_message(struct pbc_rmessage *, const char *key, int index);
int pbc_rmessage_size(struct pbc_rmessage *, const char *key);

对于解码消息,使用 wmessage 相关 api

struct pbc_wmessage * pbc_wmessage_new(struct pbc_env * env, const char *typename);
void pbc_wmessage_delete(struct pbc_wmessage *);

void pbc_wmessage_integer(struct pbc_wmessage *, const char *key, uint32_t low, uint32_t hi);
void pbc_wmessage_real(struct pbc_wmessage *, const char *key, double v);
void pbc_wmessage_string(struct pbc_wmessage *, const char *key, const char * v, int len);
struct pbc_wmessage * pbc_wmessage_message(struct pbc_wmessage *, const char *key);
void * pbc_wmessage_buffer(struct pbc_wmessage *, struct pbc_slice * slice);

pbc_rmessage_newpbc_rmessage_delete 用来构造和释放 pbc_rmessage 结构。从结构中取出的子消息,字符串,都是由它来保证生命期的。这样不需要用户做过于繁杂的对象构建和销毁工作。

对于 repeated 的数据,没有额外再引入新的数据类型。而是把 message 内部的所有域都视为 repeated 。这种设计,可以极大的精简需要的 api 。

我们用 pbc_rmessage_size 可以查询 message 中某个 field 被重复了多少次。如果消息中并没有编码入这个 field ,它能返回 0 感知到。

我把所有的基本数据类型全部统一成了三种:integer , string , real 。bool 类型被当成 integer 处理。enum 类型即可以是 string ,也可以是 integer 。用 pbc_rmessage_string 时,可以取到 enum 的名字;用 pbc_rmessage_integer 则取得 id 。

pbc_rmessage_message 可以获得一个子消息,这个返回的对象不必显式的销毁,它的生命期挂接在父节点上。即使消息中没有编码入某个子消息,这个 api 依然可以正确的返回。从中取出的子域都将是默认值。

integer 不区分 32bit 数和 64bit 数。当你能肯定你需要的整数可以用 32bit 描述时,pbc_rmessage_integer 的最后一个参数可以传 NULL ,忽略高 32bit 的数据。

wmessage 的用法更像是不断的向一个未关闭的消息包类压数据。当你把整个消息的内容都填完后,可以用 pbc_wmessage_buffer 返回一个 slice 。这个 slice 里包含了 buffer 的指针和长度。

需要注意的是,如果使用 pbc_wmessage_integer 压入一个负数,一定要将高位传 -1 。因为接口一律把传入参数当成是无符号的整数。

考虑到某些内部实现的性能,以及后面讲提到的 pattern api 的方便性(如果你完全拿这个库做 C/S 通讯)。建议所有的 string 都在末尾加上 \0 。因为,这样在解码的时候,可以将字符串指针直接指向数据包内,而不需要额外复制一份出来。

pbc_wmessage_string 可以压入非 \0 结尾的字符串,因为压入的数据长度是由参数制定的。当然你也可以不自己计算长度。如果长度参数传 <=0 的话,库会帮你调用 strlen 检测。并且将最终的长度减去这个负数。即,如果你传 -1 ,就会帮你多压入最后的一个 \0 字节。


Pattern API 可以得到更高的性能。更快的速度和更少的内存占用量。更重要的是,对于比较小的消息包,如果你使用得当,使用 pattern api 甚至不会触发哪怕一次堆上的内存分配操作。api 工作时的所有的临时内存都在栈上。

相关 api 如下:

struct pbc_pattern * pbc_pattern_new(struct pbc_env * , const char * message, const char *format, ...);
void pbc_pattern_delete(struct pbc_pattern *);
int pbc_pattern_pack(struct pbc_pattern *, void *input, struct pbc_slice * s);
int pbc_pattern_unpack(struct pbc_pattern *, struct pbc_slice * s , void * output);

我们首先需要创建一个 pattern 做编码和解码用。一个简单的例子是这样的:

message Person {
  required string name = 1;
  required int32 id = 2; 
  optional string email = 3;
}

这样一个消息,对于在 C 的结构体中,你可能希望是这样: struct Person { pbcslice name; int32t id; pbc_slice email; } 这里使用 pbc_slice 来表示一个 string 。因为对于 message 来说,里面的字符串是有长度的。并且不一定以 \0 结尾。slice 同样可以表示一个尚未解开的子消息。

我们使用 pbc_pattern_new 可以让 pbc 认识这个结构的内存布局。

struct pbc_pattern * Person_p = pbc_pattern_new(env , "Person" ,
  "name %s id %d email %s",
  offsetof(struct Person , name),
  offsetof(struct Person , id),
  offsetof(struct Person , email));

然后就可以用 pbc_pattern_packpbc_pattern_unpack 编码和解码了。pattern 的定义过程冗长而且容易出错(你也可以考虑用机器生成它们)。但我相信在性能及其敏感的场合,这些是值得的,如果你觉得写这些不值得,可以考虑用回上面的 message api 。

对于 repeated 的数据,pattern api 把他们看成一个数组 pbc_array

有这样一组 api 可以用来操作它:

int pbc_array_size(pbc_array);
uint32_t pbc_array_integer(pbc_array array, int index, uint32_t *hi);
double pbc_array_real(pbc_array array, int index);
struct pbc_slice * pbc_array_slice(pbc_array array, int index);

void pbc_array_push_integer(pbc_array array, uint32_t low, uint32_t hi);
void pbc_array_push_slice(pbc_array array, struct pbc_slice *);
void pbc_array_push_real(pbc_array array, double v);

数组是个略微复杂一些的数据结构,但如果你的数据不多的话,它也不会牵扯到堆上的额外内存分配。不过既然有可能调用这些 api 可能额外分配内存,那么你必须手工清除这些内存。而且在第一次使用前,必须初始化这个数据结构 (memset 为 0 是可以的)。

void pbc_pattern_set_default(struct pbc_pattern * , void *data);
void pbc_pattern_close_arrays(struct pbc_pattern *, void *data);

pbc_pattern_set_default 可以把一块内存,以一个 pattern 的形式,初始化所有的域。包括其中的数组的初始化。

pbc_pattern_close_arrays 使用完一块数据,需要手工调用这个 api ,关闭这个数据块中的数组。


关于 Extension ,我最后放弃了直接支持。没有提供类似 get extension 的 api 。这是因为,我们可以更简单的去处理 extension 。我把所有的 extension field 都加了前缀,如果需要,可以用拼接字符串的方式获得消息包内的扩展域。


最后介绍的是 pbc 的环境。

struct pbc_env * pbc_new(void);
void pbc_delete(struct pbc_env *);
int pbc_register(struct pbc_env *, struct pbc_slice * slice);

pbc 库被设计成没有任何全局变量,这样你想在多线程环境用会比较安全。虽然库并没有考虑线程安全问题,但在不同的线程中使用不同的环境是完全没有问题的。

每个环境需要独立注册需要的消息类型,传入一个 protobuf 库官方工具生成的 .pb 数据块即可。以 slice 的形式传入,register 完后,这块数据内存可以释放。

这个数据块其实是以 google.protobuf.FileDescriptorSet 类型来编码的。这个数据类型非常繁杂,使得 bootstrap 过程及其难写,这个在后面会谈到。


全部代码我已经开源方在 github 上了,可以在 https://github.com/cloudwu/pbc 取到代码。详细的用法也可以从那些 test 文件中找到例子。

这个东西很难写,所以代码很乱,在写这篇 blog 的时候我还没有开始整理代码的结构。大家想用的将就用,请善待 bug 和它的朋友们。

用一个复杂的 protobuf 协议来描述协议本身,真的很淡疼。当我们没有任何一个可用的协议解析库前,我们无法理解任何 protobuf 协议。这是一个先有鸡还是先有蛋的问题。就是说,我很难凭空写出一个 pbc_register 的 api ,因为它需要先 register 一个 google.protobuf.FileDescriptorSet 类型才能开始分析输入的包。

不依赖库本身去解析 google.protobuf.FileDescriptorSet 本身的定义是非常麻烦的。当然我可以利用 google 官方的工具生成 google.protobuf.FileDescriptorSet 的 C++ 解析类开始工作。但我偏偏又不希望给这个东西带来过多的依赖。

一开始我希望自定义一种更简单的格式来描述协议本身,没有过多的层次结构,只是一个平坦的数组。这样手工解析就有可能。本来我想给 protoc 写一个 plugin ,生成自定义的协议格式。后来放弃了这个方案,因为希望库用起来更简单一些。

但是这个方案还是部分使用了。这就是源代码中 bootstrap.c 部分的缘由。它读入一个更简单版本的 google.protobuf.FileDescriptorSet 的描述。这块数据是事先生成好的,放在 descriptor.pbc.h 里。生成这块数据使用了我去年完成的 lua 库。相关的 lua 代码就没有放出来了。当然到了今天,pbc 本身足够完善,我们可以用 pbc 写一个 C 版本。有兴趣的同学,可以在 test_pbc.c 的基础上修改。


这个玩意很难写, 主要是那个鸡生蛋,蛋生鸡的问题,导致我在实现过程的很长时间里,脑子里都糨糊一般. 所以很多代码实现的很糟糕, 但又不舍得删(因为难为我把它们写出来了, 重写很难调错). 希望一边写一边优化的坏习惯, 在对于这种比较难实现的东西上, 让我编写的好生痛苦. 为了效率, 我甚至写了三个针对不同情况处理的 map .

中间因为想法改变, api 设计改变,废弃了好几千行代码. 最终也就是这个样子了. 有空再重新理一下.

最终, 还有许多细节可以进一步优化, 比如如果只针对小头的机器做, 许多不必要的代码都可以省略掉. 对于 packed 数组也值得进一步优化. 甚至可以考虑加一点 JIT .

事情总算告一段落了。连续写了 5000 行代码,我需要休息一下。

Comments

看看我用2000行代码实现的protobuff,不需要.proto文件,不需要.lib文件,性能比google的实现快一倍。 http://my.oschina.net/u/2504104/blog/524370
pbc 库的 lua binding,如果我的类型是required,这样我的int类型就不能为0,string类型的不能为"",对于lua来说,nil才是空值,而有些正常情况下0和""应该也能算是正常必须值来传递?请问,这个技术上可以解决吗
为什么将repeated pack 和 message的编码数据不按tag顺序放置呢??岂不是和官方协议不兼容了?
请问您的代码可以跟C++版的protocbuf相互通讯吗?
.proto 文件使用了 enum 会导致 register fail 去除enum 就没有问题了
.proto 文件使用了 enum 会导致 register fail 去除enum 就没有问题了
你那代码是写给人看的?你妈逼上过学没?你C语言老师死的早了教你那么写代码?我真JB奇怪了. 都他妈有脸放出来那代码 还说别人的开源项目怎么地怎么地
使用您的pbc,我的b.proto中有一行: import "MessageType.proto"; 我先register了MessageType.proto文件,但是注册b.proto文件时还是在import "MessageType.proto";这行报错了,请问这咋整
最近打算写个p2p的程序,准备采用google protocol buffers封装消息,官网提供的api种类不多,希望google protocol buffers使用的人更多,感觉这个挺好的。
传入一个 protobuf 库官方工具生成的 .pb 数据块即可 O,应该是protoc -o 输出的那个文件吧?
传入一个 protobuf 库官方工具生成的 .pb 数据块即可 请问一下,这个工具是哪个?我找到生成.pb.cc,.pb.h 文件的工具,没找到生成的 .pb 数据块的工具。有人可以给个链接?
@秋大刀 VS 的版本, char temp[len] 可以考虑用 alloca 而不是 malloc 替代.
@秒大刀 谢谢, 我已经加到 readme 里.
VS2012的移植版: https://github.com/miaodadao/pbc
请问云风,从网络上接收到一段protobuf data,怎么解析出相应的message呢?除了在protobuf data前面加上message type信息外,还有没有更好的解决方法呢?
我认为加 type (通常还要加长度) 最简单 否则你可以考虑打两次包, 把 type 按 pb 协议再包一层. 或者用 extend .
请问云风,从网络上接收到一段protobuf data,怎么解析出相应的message呢?除了在protobuf data前面加上message type信息外,还有没有更好的解决方法呢?
在c下 用fopen读取文件返回给lua, 怎么会 register(buffer) 失败呢 (register fail)
谢谢云风的分享,我个人也非常不喜欢protobuf-c, 正好能用到你的结果,非常感谢!
用vc编译不过啊! 使用的标准vc不支持
请问代码中.pb的文件是指什么?是跟.proto一样的么?
云风大哥,我在使用testparser时,会在parser文件388的位置出错,我是用vc编译的pdb,修改了不少的地方,请问该如何去查找这个问题?另外还有没有别的办法生成pb文件了?
谢谢楼主分享,支持一下!
谢谢博主分享,支持下
谢谢分享
Optional Fields And Default Values As mentioned above, elements in a message description can be labeled optional. A well-formed message may or may not contain an optional element. When a message is parsed, if it does not contain an optional element, the corresponding field in the parsed object is set to the default value for that field. The default value can be specified as part of the message description. For example, let's say you want to provide a default value of 10 for a SearchRequest's result_per_page value. optional int32 result_per_page = 3 [default = 10]; If the default value is not specified for an optional element, a type-specific default value is used instead: for strings, the default value is the empty string. For bools, the default value is false. For numeric types, the default value is zero. For enums, the default value is the first value listed in the enum's type definition. 当 default 值没有给出的时候, 对于数字, 默认 default 值为零. 这是官方的定义. http://code.google.com/intl/zh-CN/apis/protocolbuffers/docs/proto.html
我们在使用pbc的pattern方式pack的时候发现一个问题,当然还没有详查原因。对于一个optional的int32字段,如果将其赋值为0,在用pack打包的时候这些字段将被舍弃,该行为和google的官方实现不一致。
博主说的很与道理
是不是大牛写出来的东西都必须如天书一样? 没有看到文档说明怎么是使用protobuf消息定义文件,我尝试去看自带的例子是怎么使用消息文件的,结果才发现原来Makefile可以那么神奇——可以让那些非Makefile专家完全看不出构建逻辑。
仔细读了一下博主文章觉得非常值得学习!
@tearshark protobuf 支持的类型比你列出来的要全面的多, 也更节省.
当初为了支持各种形式的字符串.写出来下面的代码,一直觉得这代码有点过了 template> struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;}; template> struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;}; template> struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;}; template> struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;}; template struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;}; template struct TypeConvert{enum{Type=XV_WString};typedef VStringW Convert;};
适当的使用C++,还是比C简单的.怕就怕走极端. 所以,我先实现有 operator ,_Ty); 利用这个实现了 struct XbcValue { VALUA_TYPE type; union { char buffer[8]; struct { int32 length; void * data; }; }; }; 而XbcValue的读写依靠 template struct TypeConvert{enum{Type=XV_Binary}; typedef _Ty Convert;}; template> struct TypeConvert{enum{Type=XV_Char};typedef bool Convert;}; 以XbcValue为基础进行森林状数据组织.并且为持久化做了很多工作,包括支持读写内容的函数的进化
两点: 一:我还是喜欢按照数据的字节数细分数据类型,这样可以尽量的节省数据大小,特别是网络传输的时候 二:对于其他一些结构体的支持,比如3D矢量,4x4矩阵等等.不提供的话,有的时候真的很难受.这种数据一般来说,脚本是不会访问的.为了不额外附加太多东西,只好牺牲安全/类型信息.毕竟,这种数据只能存POD的数据类型,因此,不存在特别大的问题.所以,我归类为Binary数据,且只记录数据字节数和内容.读取的时候仅校验字节数.防范式编程过火了也不好,毕竟,程序员都还是想写出没有错误的代码的,你只需要在他犯错的时候,及时提醒就好. 宗上所述: enum VALUA_TYPE { XV_Char, XV_Short, XV_Int, XV_Long, XV_Float, XV_Double, XV_String, XV_WString, XV_Binary, //不认识的结构都往这里面塞 }; VALUA_TYPE type; union { char buffer[8]; struct { int32 length; void * data; }; };
从您的代码中学到很多,谢谢。
看了博主的文章觉得非常值得学习,支持一下!
你提到的用luajit+ffi实现的纯lua的pbc解析器有公开代码么?是否有分析其中的性能瓶颈在哪里?
有json-c的开放接口的简洁,同时又具备protobuf的sdl优越性。设计确实蛮酷的! 这个东西要是应用在服务器端的话,确实不错。客户端的话就不好说了,由于是脱离了code generation,采用类库来进行解析数据,那就不好避免.pb文件与程序一起发布。要是这些.pb放在客户端的话,确实会有很大的安全隐患。protobuf-c给提供的接口太过于丑陋了,如果一个协议里面出现多个repeated字段,那对填充协议的人来说,真的是噩梦,而且对于内存方面作者也将这个问题完全抛给了实现者。个人觉得作者并没有用心去实现他。 回到.pb文件的问题上,有没有一种方案,用protoc生成精简后的源代码,在访问填充数据的接口却像你设计的那样如此简洁?我的主要考虑是在随着协议的增加,必然要对不同的协议生成不同的.pb协议流,而且如果全部都是用打开文件的方式进行加载的话,对移动设备来说可能会有一定的效率问题,而且.pb随着程序一起发布,确实不安全。
谢博主分享,支持一下!
>>不依赖库本身去解析 google.protobuf.FileDescriptorSet 本身的定义是非常麻烦的。 google.protobuf.FileDescriptorSet 是 protobuf 生成用来作为 C++ 类的 metadata 。基于同样的思路,实现过一个基于 c struct 的类似的库,采用了一种自定义的 metadata 描述方式。 http://code.google.com/p/spdatapickle/source/browse/trunk/spdatapickle/spdpmetainfo.hpp 文件包含了描述 metadata 的基本模型。
有没有 pb 的 spec 的中文版? 偶们都不知道是怎么用的
底层总是删了写写了删的~
试试BinSpec,吼吼,自己做代码生成器,爱生成什么样代码都可以:-) https://github.com/bg5sbk/BinSpec
文章有些看不懂,所以没有看完
我为了自己的分布式存储项目,改做了一个protocol buffer的c实现,已经发布到了github上。 libpc: https://github.com/wangeguo/libpb
程序高人,向你学习
"第三方的 C 版本 也不让我满意" protocol-c 除了也是生成C代码外,还有什么让你不满意的呢?请教
这个pbc会不会用到游戏客户端和服务器之间的交互呢? 如果用的话,由于字段描述信息是必需的,做外挂的很容易就从客户端里找到所有协议的格式描述了,从而使制作脱机外挂的门槛大大降低. 云风有没有考虑这个问题呢.
我觉得你这个工作没意思。。。瓶颈难道是语言?
请教下 为什么说: "采用生成代码的方式并没有特别的好处,反而有很大的性能损失(和通常做一个 bingding 库的方式比较)" 你这个实际上在问为什么动态语言比C语言慢。
你们招游戏开发人员吗?
请教下 为什么说: "采用生成代码的方式并没有特别的好处,反而有很大的性能损失(和通常做一个 bingding 库的方式比较)"
唉,可惜老兄你对Go兴趣不大,否则Go那边优质类库这么缺乏,按您这速度,得搞定多少啊,实在是太可惜了
@mos, messagePack 有点意思, 不过我不会使用它. 因为在此之前, 我设计实现过很多版本的类似的东西.采用 pb 绝对不是它性能高, 或是数据紧凑. 如果最终我们没有使用 pb, 也一定是一个自定义的格式, 肯定不会比 messagePack 性能差了. 去年我写过一篇 blog 谈到,我看中 pb 的是它良好完备的 DSL 设计. 采用这个设计下, 其实无所谓具体怎么编码的, 甚至最终我可以把整个编码格式修改掉, 以得到更高的效率. 反正我不需要和 google 的服务用 gpb 的协议通讯不是?
深夜看你的游戏之旅,收到订阅的文章,抢个沙发... 印象中上次提到的的代码量是600多,一周时间就飙到5000了,多产啊
有没有考虑过messagePack http://msgpack.org/

Post a comment

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