vlambda博客
学习文章列表

踩坑记:一知半解protobuf

本篇写个小坑,别期望太高…




在广告系统里,对延迟是毫秒必争(毕竟省下来的每一毫秒都可以用在后端优化效果),因此我们和外部媒体之间的通信往往使用 protobuf 。


相比 json、xml,protobuf 确实节省了不少编解码的时间以及网络开销,不过相应的代价是牺牲了便利性不能用 vi 等文本编辑器查看/修改,遇到问题时排查也比较麻烦。




- 入坑 -


比如 7 月份,某媒体希望一次请求中拉到多条广告(用于信息流场景),因此在 imp 添加一个 ads_count 字段,用于标识本次请求需要的广告数量。


过程是这样,在 xxx.proto 里给 Impression 类型添加一个新字段

package com.xxx;message BidRequest {  string id = 1;  int32 ver = 2; message Impression {    ...    int32 ads_count = 9; }  Impression imp = 3; ...}


然后用 protoc 编译,生成新版的 xxx.pb.go 

$ protoc --go_out=. xxx.proto


看起来挺简单一个流程,结果还是出了问题:不论媒体请求中填了什么值,这边 decode 出来,imp.GetAdsCount() 得到的总是 1 。




- 排查 -


由于我方代码是自测过的,能够正常取到 ads_count 的值,因此猜测是对方请求有点啥问题。


于是将对方的请求录下来,存到文件 req.pb 中,然后用 protoc 暴力解码:

$ protoc --decode-raw req.pb1 { 6: 0x3938373635343332}2: 13 { 1: 1 2: "6f63bd4df111480" 3: 1}...


可以看到,我们什么也没看懂。



不过还好我们有 xxx.proto,借助已知信息,可以更好地解码请求:

$ protoc --decode=com.xxx.BidRequest xxx.proto  < req.pbid: "123456789"ver: 1imp {  id: 1  ... ads_count: 1 10: 3}...


看到了点不太对的东西。




- 填坑 -


在 imp 里面,除了 ads_count 之外,还看到了个 "10: 3"。


由于 protobuf 的变量名不能是纯数字,所以这应当是某个在类型定义里没有出现的字段,decode时只能用其序号代替,由此可知,应该是的 proto 文件应该有些差异。


经过沟通,媒体确实在 ads_count 之前还加了另一个字段(可能是和其他合作方使用到的);双方对齐以后,问题顺利解决:


修正 ads_count 的序号:

 message Impression {    ...    int32 ads_count = 10; }


用正确的 proto 来 decode:

$ protoc --decode=com.xxx.BidRequest xxx.proto  < req.pbid: "123456789"ver: 1imp {  id: 1 ...  ads_count: 3}...


MISSION COMPLETED.




- encoding -


问题是解决了,但是只写这些就显得太应付了,就再介绍下 proto 文件是怎么编解码的吧。


官方有一篇很详细的文档介绍了编码的过程(详见文末“阅读原文”),这里摘一些重点。


以一个简单的类型为例:

message Test1 {  optional int32 a = 1;}

如果给 a 赋值 150 并序列化,会得到3个字节(16进制):

08 96 01

其中第一个字节(08)是一个 varint(每个字节的最高位 = 1 表示该 int 还需要拼上后续字节的低 7 bits),其内容包含了第一个元素的序号(field number)和类型(wire type)。


将 08 的二进制 "0000 1000" 拆分成三部分来解释:

  • 0

    • 表示这个 varint 到这个字节就结束了

  • 0001

    • 表示其序号是1

  • 000

    • 表示其值类型也是个 varint


注意,不管这个 varint 有多大,其末3位总是用于表示类型(wire type),可能的取值有:

  • 0: varint

  • 1: 64-bit,如 fixed64, sfixed64, double

  • 2: 指定长度类型,如 string, bytes, 内嵌类型

  • 5: 32-bit,如 fixed32, sfixed32, float


第2、3个字节(96 01)是 a 的值,其二进制表示是

1001 0110 0000 0001

第 2 字节的最高位是 1 ,我们知道这个 varint 还没结束;而第 3 字节的最高位是 0 ,这个 varint 就到此结束了。


将两个最高位去掉,拼出一个完整的二进制数:

0000001 0010110 = 150

注意:varint 按字节序是小端存储,因此第 3 个字节的 0000001 放在高位。




- signed integers -


varint 看起来是个好东西,因为实践中经常会用到一些枚举值,可能的取值范围很小,使用 varint 只需要少量的空间。


不过如果我们需要用 -1 的时候怎么办呢?不管是用反码还是补码,都需要考虑符号位的问题  —— 对于 int32/int64,负数的编码总是要占用 10 个字节。


protobuf 的解决方案是为 sint32/sint64 引入 "ZigZag encoding",简单来说就是交替使用 0,1,2,3,... 来表示 0,-1,1,-2,...,从而将较小的负数编码为较小的无符号数,再使用 varint 编码。




- 没了 -


就这样吧,更多细节(string、内嵌类型以及数组的编码),请参考官方文档(文末“阅读原文”)。


最后一个小问题,下面这个编码后的消息,表示什么意思呢?

12 03 36 36 36




推荐阅读