protobuf原理(一):编码原理

protobuf自身是语言无关的,但是它所提供的编译器以及插件机制可以将我们编写的proto文件生成任意语言的代码,所以可以用来做IDL定义服务接口,可以很方便地让个类型的语言接入。

protobuf自身也是序列化协议,将结构体对象序列化为二进制数据。protobuf的编码原理其实在我们protobuf的使用中基本上是用不到的,不过了解其原理更方便我们理解protobuf的优势与调优。

Base 128 Varints

可变宽度的整数是protobuf编码格式的核心。可以将任意一个64位无符号整数编码为1~10个字节,值越小则使用的字节数越少。

varint中的每个字节的最高有效位MSB用作标志位,表示这个字节是否是这个varint的一部分。后7位是有效的数据位,varint就是将其对应的多个字节的后7位来构建的。

varint就是根据数值的大小,以7位为单位进行编码。而7位二进制能够表示的最大数为271。编码的过程每次取出7位,根据当前值设置MSB是否为1,来编码成字节。对应的go语言实现编码过程

一个大于221小于228的值的编码示例如下图
image
根据值的大小我们可以知道需要编码成4个字节,前三个字节都需要将MSB位设置为1,最后一个字节的MSB为0,表示为这个varint的最后一个字节。

message

对于整数有上面的varint编码,但是我们在编写proto文件的时候,一般都是以message为单位来声明的,整数只是message中的一个字段。
例如

message Msg {
	int32 type = 1;
	string msg = 2;
}

message是由键值对组成的,有着各种类型的字段,但是序列化的结果是二进制的,所以这些字段都是需要编码成二进制的,并且最终还要能够根据二进制反序列化回message,所以二进制信息中需要能够获取到如何解码这段二进制信息以及这个数据属于message的哪个字段。

message中的每个键值对都被编码为field-number, wire-type,payload的格式。field-number表示了这段二进制数据属于message的哪个字段,wire-type告诉了解析器payload的长度,这样还可以让不支持新类型的解析器跳过。这种格式有时也叫做叫做

message采用Tag-Length-Value的格式编码成二进制。tag是通过(field_number << 3) | wire_type公式编码的varint。tag中的field_number和wire_type告诉来我们数据属于哪个字段以及采用的编码方式。

通过下面的方式编码tag以及从tag中获取field_number和wire_type。

// DecodeTag decodes the field Number and wire Type from its unified form.
// The Number is -1 if the decoded field number overflows int32.
// Other than overflow, this does not check for field number validity.
func DecodeTag(x uint64) (Number, Type) {
	// NOTE: MessageSet allows for larger field numbers than normal.
	if x>>3 > uint64(math.MaxInt32) {
		return -1, 0
	}
	return Number(x >> 3), Type(x & 7)
}

// EncodeTag encodes the field Number and wire Type into its unified form.
func EncodeTag(num Number, typ Type) uint64 {
	return uint64(num)<<3 | uint64(typ&7)
}

Length说明了value的长度,对于proto更新添加新字段,仍然使用旧的proto的解析的时候会根据length跳过不认识的字段,这样可以保证字段兼容。但是不是所有的wire_type都有length这一部分的,例如varint,可以根据MSB来获取数据长度。

ID Name 格式 Userd For
0 varint T-V int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 T-V 固定8字节 fixed64, sfixed64, double
2 LEN T-L-V string, bytes, embedded messages, packed repeated fields
3 SGROUP 已废弃 group start (deprecated)
4 EGROUP 已废弃 group end (deprecated)
5 I32 T-V 固定4字节 fixed32, sfixed32, float

More Integer types

从wire_type的表中可以看出,bool和enum也是采用varint进行编码的,也就是说bool和enum也是作为整形处理的。

还有有符号数,varint编码是无符号的。所以对于负数的编码有所不同。intN类型采用二进制的补码来表示负数,然后使用varint编码,使用全部10个字节,例如-2的编码如下:

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

使用无符号定义的话就是~0-2+1,其中~0表示二进制位全1的64位整数。

SintN采用的则是ZigZap的处理值,然后再使用varint编码。统一使用正数来表示并且,正数n被编码为2n(偶数),负数n被编码为|2n+1|(奇数),例如-2采用ZigZap编码的结果就是3。代码中是采用位运算完成的,这点比较巧妙

uint64((uint32(v)<<1)^uint32((int32(v)>>31)))

这里用的是sint32类型,将值左移以为来乘以2,然后与最高位异或,来决定最后一位是否置1。当然只有负数的最高位位1。

对于浮点数以及fixedN这样的类型,采用了非varint的编码,而是I32和I64这样的编码,固定使用4个字节或者8个字节。例如1使用I32编码后为

1 0 0 0

LEN

与前面的不同就是在于编码的结果多了表示长度的L部分,最常见的就是string类型。

// proto
// message Msg {
//    string str = 1;
// }
func main() {
	msg := hello.Msg{Str: "testing"}
	bs, _ := proto.Marshal(&msg)
	fmt.Println(bs)
}

输出的结果为[10 7 116 101 115 116 105 110 103],10为tag(1<< 3 | 2), 7表示value的长度,后续的部分就是string中的字符的字节码了。

总结

其实到了这里,我们已经知道了message中编码方式,就是按照T-L-V的方式编码一个个键值对,最为核心的就是varint的编码,即使Tag的处理方式,也是大部分整型的处理方式。有关wire-type的其它编码也都进行了介绍,可以尝试在自本地编码对应类型的值,查看是否符合预期。

posted @   三尺山  阅读(445)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示