gzip 是比较常见的压缩方式,web 服务中也经常开启 gzip 压缩。 HTTP 传输的内容通过 gzip 压缩之后,也可以节省带宽,提高数据传输效率,缺点是要耗费些 CPU。这篇文章还会介绍其他的一些编码方式(pb、base64),从编码后内存占用、编码原理等来说一会。

就好像评估一个算法的优劣一样,空间复杂度和时间复杂度不可兼得。通过 gzip 压缩后,降低了存储空间,但也引入了压缩和解压缩的性能开销。不过,编码、解码是否存在跨语言问题,通过 java 编码的字符串,是否能通过 go 正确的解码出来,也同样是我们关注的重点。

在深入 gzip 之前,我们先来拿捏一个软柿子:base64编码。思考一下,base64编码后,数据占用的空间是增加了还是减少了?

base64

阮一峰 base64笔记 写的非常清晰,Base64的字符集共有64个字符,可以使用 0 - 63 的数字表示。

为什么是 0 - 63 呢?因为它的切分标准是按 6 个二进制为 1 组,然后前面补 2 个二进制 0。而 6 个二进制位的数字最小表示 0,最大表示 63。

显然,base64编码之后,数据占用的空间增加了,按照每 6 个 bit 增加 2 个 bit 来计算,编码之后的大小比原来大出了 1/3。

base64 只是一种编码,直接在浏览器中搜索「在线base64编解码」,可以搜索出一堆网站来,它没有加密的性质,只是我们肉眼很难识别罢了。那 base64 的使用场景有哪些呢?主要是来解决二进制流传输中的问题,将二进制流转换为 64 个可打印字符组成的字符串。

[]byte
// ProtoBuf serializes the given struct as ProtoBuf into the response body.
func (c *Context) ProtoBuf(code int, obj interface{}) {
	c.Render(code, render.ProtoBuf{Data: obj})
}

上面的代码来源于 gin 框架,用户返回 protobuf 类型数据,代码内部指定了 application/x-protobuf header 类型。下面,我们开始了解 protobuf。

protobuf

下文中我们都使用 pb 来简称 protobuf。它是 google 开源的编码方案,扩展性也比较好。从设计的角度来说,pb 只具有编码的功能,类似于 xml、json,并不具有数据压缩的功能。所以,不能将 pb 类比做 gzip 类型的工具。

对比 json 编码来说,pb 编码后的字节流不包含 key 的信息,确实在一定程度上节约了部分内存。同时,因为 pb 编码后的内容,需要和对应的 .proto 保持对照关系,编码后的结果中也多出了与 .proto 相关的对照信息。

protoc
syntax = "proto3";
package pb;

option go_package = "protobuf/paper";

message Person {
  string name = 1;
  string email = 2;
}
 protoc -I=./ --go_out=. paper.proto

自动生成的代码中,让人特别好奇的可能要属 file_paper_proto_rawDesc 这个变量,因为它的不可读,让人很不好理解。它究竟在起什么作用呢?好在从代码里也可以找到一些线索,这个内容被 gzip 压缩处理过,那我尝试使用 gzip 对它解压缩试试

var File_paper_proto protoreflect.FileDescriptor

var file_paper_proto_rawDesc = []byte{
	0x0a, 0x0b, 0x70, 0x61, 0x70, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x70,
	0x62, 0x22, 0x44, 0x0a, 0x06, 0x50, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e,
	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12,
	0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
	0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01,
	0x28, 0x03, 0x52, 0x03, 0x61, 0x67, 0x65, 0x42, 0x10, 0x5a, 0x0e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
	0x62, 0x75, 0x66, 0x2f, 0x70, 0x61, 0x70, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
	0x33,
}

var (
	file_paper_proto_rawDescOnce sync.Once
	file_paper_proto_rawDescData = file_paper_proto_rawDesc
)

func file_paper_proto_rawDescGZIP() []byte {
	file_paper_proto_rawDescOnce.Do(func() {
		file_paper_proto_rawDescData = protoimpl.X.CompressGZIP(file_paper_proto_rawDescData)
	})
	return file_paper_proto_rawDescData
}

后文也要继续介绍 gzip,解压缩的方式后文介绍。现在尝试对 file_paper_proto_rawDesc 解压,但并没有得到我想要的。我其实是有预期的,这个 byte 切片应该就是表示 *.proto 文件的字段关系信息。但使用 protoimpl.X.CompressGZIP 方法处理并打印,内容是不可打印的(抱着试一试的态度,对结果再用 gzip 解压一下,内容还是有缺失)。

file_paper_proto_init
// FileDescriptor describes the types in a complete proto file and
// corresponds with the google.protobuf.FileDescriptorProto message.
//
// Top-level declarations:
// EnumDescriptor, MessageDescriptor, FieldDescriptor, and/or ServiceDescriptor.
type FileDescriptor interface {
	Descriptor // Descriptor.FullName is identical to Package

	// Path returns the file name, relative to the source tree root.
	Path() string // e.g., "path/to/file.proto"
	// Package returns the protobuf package namespace.
	Package() FullName // e.g., "google.protobuf"

	// Imports is a list of imported proto files.
	Imports() FileImports

	// Enums is a list of the top-level enum declarations.
	Enums() EnumDescriptors
	// Messages is a list of the top-level message declarations.
	Messages() MessageDescriptors
	// Extensions is a list of the top-level extension declarations.
	Extensions() ExtensionDescriptors
	// Services is a list of the top-level service declarations.
	Services() ServiceDescriptors

	// SourceLocations is a list of source locations.
	SourceLocations() SourceLocations

	isFileDescriptor
}

string

在 main 函数中,我们使用 pb 协议编码结构体,Person 两个字段都是字符串类型,值为 testing。在编码的最后,我们和纯文本 testing 的 byte 流做对比。

输出内容见注释

func main() {
	e := paper.Person{
		Name:  "testing",
		Email: "testing",
	}

	out, _ := proto.Marshal(&e)
	fmt.Println(out)
	fmt.Println([]byte("testing"))
}
// [10 7 116 101 115 116 105 110 103 18 7 116 101 115 116 105 110 103]
// [116 101 115 116 105 110 103]

从编码的结果可以看出,[10 7] 以及 [18 7] 就是用来和 .proto 文件做对应关系额外存储字段。其中,7 表示字符串的长度,10 表示字段编号和字段类型信息。

我们以 18 为例,做简单说明:

(field_number << 3) | wire_type

剩下的二进制 01,表示 wire_type。pb 下的类型可以参照下面的类型对应关系。值为2 表示可能是一个 string 类型。以此类推,10 表示的 field_number 为1,wire_type 为2。

number

我们来再看一下整型的编码,在 pb 中追加一个整形字段,将 age 值赋值为 12,然后打印查看编码内容

syntax = "proto3";
package pb;

option go_package = "protobuf/paper";

message Person {
  string name = 1;
  string email = 2;
  int64 age = 3;
}

编码后 age 对应的字节内容为 [24 12],首先看 24 的二进制表示:0001 1000,低3位为0,表示整形。11 转换为 10 进制为3,表示 field_number 为3。

剩下的 12 就是表示 12 的值,在操作系统中,int64 占用 8 个字节,但在 pb 编码中,其实只占用了 8 个bit,也就是1个字节。

这也让我们想到一个问题,对于字符串类型,pb 中存储了字符串内容的长度信息,可以计算出哪些区间的二进制表示字符串。但对于数值类型,pb 又是如何确定哪些二进制是来表示这个数字的呢?

转换过来,就是一个值边界的问题。如果 age 字段后面还有其它的整形字段,那么,那几个二进制是属于 age 的呢,整形中可没有存储字节长度的信息。我翻看了部分源码,试图查找一下切分的依据:

以文件 google.golang.org/protobuf@v1.28.1/encoding/protowire/wire.go 为入口,我们主要看注释,ConsumeField 函数返回 filed number、wire type 和对应的字节长度。按道理来说,我们应该看 decoding 的源码,但是吧,从 encoding 和 decoding 的对称关系来说,应该也是能说明问题的。

// ConsumeField parses an entire field record (both tag and value) and returns
// the field number, the wire type, and the total length.
// This returns a negative length upon an error (see ParseError).
//
// The total length includes the tag header and the end group marker (if the
// field is a group).
func ConsumeField(b []byte) (Number, Type, int) {
	num, typ, n := ConsumeTag(b)
	if n < 0 {
		return 0, 0, n // forward error code
	}
	m := ConsumeFieldValue(num, typ, b[n:])
	if m < 0 {
		return 0, 0, m // forward error code
	}
	return num, typ, n + m
}

直捣黄龙,根据上面的线索,我们很容易就找到 int64 类型的字节流查找逻辑,直接 show code。这个方法用来将 []byte 转换为 variend-encoded uin64 类型的编码。

编码的关键是 0x80 这个数值限制,转换为二进制是 1000 0000。当检测到某一个字节小于 0x80 的时候,就认为编码结束了。所以,解码也应该一样,检测到 wire_type 后第一个小于 0x80 的字节为止。那么,这个 0x80 在编码中究竟版本的是什么角色呢?

// ConsumeVarint parses b as a varint-encoded uint64, reporting its length.
// This returns a negative length upon an error (see ParseError).
func ConsumeVarint(b []byte) (v uint64, n int) {
	var y uint64
	if len(b) <= 0 {
		return 0, errCodeTruncated
	}
	v = uint64(b[0])
	if v < 0x80 {
		return v, 1
	}
	v -= 0x80

	if len(b) <= 1 {
		return 0, errCodeTruncated
	}
	y = uint64(b[1])
	v += y << 7
	if y < 0x80 {
		return v, 2
	}
	v -= 0x80 << 7

	if len(b) <= 2 {
		return 0, errCodeTruncated
	}
	y = uint64(b[2])
	v += y << 14
	if y < 0x80 {
		return v, 3
	}
	v -= 0x80 << 14

	if len(b) <= 3 {
		return 0, errCodeTruncated
	}
	y = uint64(b[3])
	v += y << 21
	if y < 0x80 {
		return v, 4
	}
	v -= 0x80 << 21

	if len(b) <= 4 {
		return 0, errCodeTruncated
	}
	y = uint64(b[4])
	v += y << 28
	if y < 0x80 {
		return v, 5
	}
	v -= 0x80 << 28

	if len(b) <= 5 {
		return 0, errCodeTruncated
	}
	y = uint64(b[5])
	v += y << 35
	if y < 0x80 {
		return v, 6
	}
	v -= 0x80 << 35

	if len(b) <= 6 {
		return 0, errCodeTruncated
	}
	y = uint64(b[6])
	v += y << 42
	if y < 0x80 {
		return v, 7
	}
	v -= 0x80 << 42

	if len(b) <= 7 {
		return 0, errCodeTruncated
	}
	y = uint64(b[7])
	v += y << 49
	if y < 0x80 {
		return v, 8
	}
	v -= 0x80 << 49

	if len(b) <= 8 {
		return 0, errCodeTruncated
	}
	y = uint64(b[8])
	v += y << 56
	if y < 0x80 {
		return v, 9
	}
	v -= 0x80 << 56

	if len(b) <= 9 {
		return 0, errCodeTruncated
	}
	y = uint64(b[9])
	v += y << 63
	if y < 2 {
		return v, 10
	}
	return 0, errCodeOverflow
}

我们将 age 设置为 150,输出后的 pb 编码为 [24 150 1],转换成二进制的结果是 0001 1000 1001 0110 0000 0001。0001 1000 表示 field_number 为3,类型为 int64。剩下的 2 个字节表示 150。

转换过程在官方文档中已经有说明,通过这个解析过程,我大概有了一个参测,那就是:pb 编码的整数是通过 7 个bit 位来表示的。因为它是:0xxx xxxx,高位的第一个是 0。

哈哈,我猜错了,人家官方文档是这么描述的:

Each byte in the varint has a continuation bit that indicates if the byte that follows it is part of the varint. This is the most significant bit (MSB) of the byte (sometimes also called the sign bit). The lower 7 bits are a payload; the resulting integer is built by appending together the 7-bit payloads of its constituent bytes.

我总结下来:找到 wire_type 为 number 类型后,第一个 msb 为 0 的字节为止。MSB 是什么呢?就是 8 个 bit 中的最高位。

兼容性

pb 支持向前兼容和向后兼容,但兼容都是有限制条件的,就好像没有绝对的自由,所谓的自由都是在规矩内的。

  • 不可以改变已有字段的类型和序号。序号标记了编解码的位置,也就是字段的对应关系,改变序号就完全破坏了 pb 的协议。
  • 新增字段并使用新的序号,是前后兼容的。老版本也可以正常解析新版本编码的数据(解析不到新字段)。
  • 删除字段也是前后兼容的,但我们其实没有必要删除这个字段,编码的时候不赋值就可以

总的来说,编码的类型和序号是解码的关键,也是pb扩展性的基础,这两个属性是不能修改的。

和 json 相比较,pb 的变量名其实是可以修改的,因为变量名并没有被编码到二进制流中,json 是不能修改变量名的,修改了变量名就解析不到对应的数据了。

gzip

gzip 最常见还属 .gz 后缀的压缩文件,或者是 tar.gz,底层使用的是 DEFLATE 算法