字节跳动踩坑记:一知半解protobuf

Go语言精选

共 975字,需浏览 2分钟

 ·

2020-09-17 02:18

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




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





推荐阅读



学习交流 Go 语言,扫码回复「进群」即可


站长 polarisxu

自己的原创文章

不限于 Go 技术

职场和创业经验


Go语言中文网

每天为你

分享 Go 知识

Go爱好者值得关注



浏览 45
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报