Golang 序列化系列(1)MessagePack

4,446 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第14天,点击查看活动详情

所谓序列化,就是把一个内存中的结构,转换为字节数组。日常开发中大家都对 json 的序列化以及反序列化非常熟悉,单单是 json 的序列化库就能找到一大把。但 json 虽然在可读性上非常优秀,但一切都是 tradeoff,它在性能和存储空间上还是明显劣势的。

这个系列我们就来看看 Golang 中涉及到的序列化框架,后续会陆续讲到 thrift,protobuf,gob 等。今天我们来看看跟 json 距离比较近的一种:MessagePack

MessagePack

从官网我们可以看到,MessagePack 将自己定位为【更快更小的 json】。

MessagePack is an efficient binary serialization format. It lets you exchange data among multiple languages like JSON. But it's faster and smaller. Small integers are encoded into a single byte, and typical short strings require only one extra byte in addition to the strings themselves.

MessagePack 是一种高效的二进制序列化格式。它允许您在 JSON 等多种语言之间交换数据。但它更快更小。较小整数被编码为一个字节,典型的短字符串除了字符串本身之外只需要一个额外的字节。

这一点我们可以参考官方示例来直观感受一下:

image.png

可以看出每个类型需要额外的 0 到 n 个字节来指明(数量依赖对象的大小或者长度)。上面的例子中82指示这个对象是包含两个元素的map (0x80 + 2), A7 代表一个短长度的字符串,字符串长度是7。C3代表true,C2代表false,C0代表nil。00代表是一个小整数。

其实从上面我们也可以看到,MessagePack 是添加了一些字节用来存储类型的。MessagePack 封装了 type system(类型系统),formats(格式)两个概念。

Serialization:
    Application objects
    -->  MessagePack type system
    -->  MessagePack formats (byte array)

Deserialization:
    MessagePack formats (byte array)
    -->  MessagePack type system
    -->  Application objects
  • 序列化,本质是从业务对象,先转成 MessagePack 的类型,然后再转成最终的 formats,也就是我们得到的字节数组。
  • 反序列化,本质是从 MessagePack formats,转成 MessagePack 的类型系统,最后转成业务对象。

这里我们也能看出 type system 其实就是一个中间人的角色。

为了方便大家结合上面的例子理解,我们这里贴一下上面这个示例的 hex 表示:

format namefirst byte (in binary)first byte (in hex)
positive fixint0xxxxxxx0x00 - 0x7f
fixmap1000xxxx0x80 - 0x8f
fixarray1001xxxx0x90 - 0x9f
fixstr101xxxxx0xa0 - 0xbf
nil110000000xc0
(never used)110000010xc1
false110000100xc2
true110000110xc3
bin 8110001000xc4
bin 16110001010xc5
bin 32110001100xc6
ext 8110001110xc7
ext 16110010000xc8
ext 32110010010xc9
float 32110010100xca
float 64110010110xcb
uint 8110011000xcc
uint 16110011010xcd
uint 32110011100xce
uint 64110011110xcf
int 8110100000xd0
int 16110100010xd1
int 32110100100xd2
int 64110100110xd3
fixext 1110101000xd4
fixext 2110101010xd5
fixext 4110101100xd6
fixext 8110101110xd7
fixext 16110110000xd8
str 8110110010xd9
str 16110110100xda
str 32110110110xdb
array 16110111000xdc
array 32110111010xdd
map 16110111100xde
map 32110111110xdf
negative fixint111xxxxx0xe0 - 0xff

第一步,看区间:

  • 这个对象整体来看是个包含两个元素的 map,对应到 MessagePack 的 fixmap,也就是 0x80 - 0x8f。
  • compact 字符串,是个固定长度的字符串,对应 fixstr,也就是 0xa0 - 0xbf。
  • true 这个布尔值在 MessagePack 里面是个固定的 C3,单字节即可
  • schema 同样也是个定长字符串,也是 fixstr,和 compact 的区间一样
  • 0 这个数字对应的是 positive fixint (虽然是0,也算到这个范围了),区间是 0x00 - 0x7f

第二步,算具体的值:

  • map 只有俩元素,所以 0x80 开始算起,就是 0x82;
  • compact 是 7个字符,所以从 0xa0 开始往后算7个,就是 0xa7;
  • true 是 0xc3;
  • schema 是 6个字符,所以是 0xa6;
  • 0 就是这个区间第一个,所以是 0x00。

第三步,计算总体占用字节数:

0x82 + 0xa7 + compact(7个字节) + 0xc3 + 0xa6 + schema(6个字节)+ 0x00

也就是 1 + 1 + 7 + 1 + 1 + 6 + 1 = 18

这就是一开始图里说的 18个字节的来源。理解了么?

原来的{"compact":true,"schema":0} 占用多少个字节呢?其实全英文文本的话,每个字符就是一个字节,看一下长度即可,总共 27个字节。

问题来了,省下来这 9 个字节,去哪儿了呢?

  • 没有前后大括号了;
  • 没有引号,逗号;
  • 原先用 4个字节才能表示的 true,现在用 1个固定的布尔字节表示就可以。

另外,不要忘记,为什么 MessagePack 要存下来类型,长度信息呢?这样才能快速跳过无用信息,比如我一看到 compact 这个字符串是个 fixstr 的类型,说明定长,又读到了这个长度是 7,那么我直接去按照长度全读出来就 ok了,而不是一个个字符去读,碰到引号了再结束。这样性能就好了。

我们暂时不会 cover 太多细节,重在看用法。如果大家对具体的类型映射感兴趣,可以看一下详细的 spec:github.com/msgpack/msg…

需要注意的是,不像 gob 这种只能在 Golang 内部进行序列化和反序列化的框架,MessagePack 本身是跨语言的,和 json 一样。各个语言都提供了相关的开源库,参考 多语言实现

快速上手

image.png 在 Golang 的环境下,我们建议直接用 github.com/tinylib/msg… 来进行 MessagePack 序列化操作,比ugorji/go有更好的性能。

注意 README,tinylib/msgp 是一个 MessagePack 的代码生成器

This is a code generation tool and serialization library for MessagePack.

怎么用呢?

第一步,安装 msgp 工具

go get github.com/tinylib/msgp
go install github.com/tinylib/msgp

你可以在 Terminal 直接运行 msgp 命令,默认将会输出

No file to parse.

这就代表安装成功了。

第二步,像往常一样,我们定一个业务的结构体:

type Person struct {
	Name       string `msg:"name"`
	Address    string `msg:"address"`
	Age        int    `msg:"age"`
	Hidden     string `msg:"-"` // this field is ignored
	unexported bool             // this field is also ignored
}

msgp 需要用到 Golang 强大的 tag 能力,跟之前不同的是,我们需要把 json:"name", json:"address" 这些 json tag 名称换成 msg,后面跟着你预期的序列化后的名称。

第三步,在源码文件中,加上 go:generate msgp 用于生成代码。

然后直接通过 generate 工具生成即可。

你会发现,在项目下生成了 msgp 的代码,在我们原有的 user.go 之外,出现了 user_gen.go 以及 user_gen_test.go

image.png

生成代码解读

user_gen.go 是核心逻辑,基于我们的 Person 对象,生成了对应的 EncodeMsg, DecodeMsg, MarshalMsg, UnmarshalMsg, 以及 Msgsize 方法:

image.png

其实就是实现了下面这些接口:

  • msgp.Marshaler
  • msgp.Unmarshaler
  • msgp.Sizer
  • msgp.Decodable
  • msgp.Encodable

绝大多数情况下,作为开发者,我们直接用 MarshalMsg 以及 UnmarshalMsg 这两个即可。对应到 json 的 Marshal 以及 Unmarshal 方法。

而 user_gen_test.go 则是 tinylib/msgp 为我们额外生成的单测以及benchmark:

image.png

当然,我们还有一些选项可以控制 msgp 的生成策略:

  • -o - output file name (default is {input}_gen.go)
  • -file - input file name (default is $GOFILE, which are set by the go generate command)
  • -io - satisfy the msgp.Decodable and msgp.Encodable interfaces (default is true)
  • -marshal - satisfy the msgp.Marshaler and msgp.Unmarshaler interfaces (default is true)
  • -tests - generate tests and benchmarks (default is true)

这些选项可以放到 go generate 命令中,如 //go:generate msgp -o=stuff.go -tests=false

详细可参考官方 wiki: github.com/tinylib/msg…

除了走 go generate 外,我们当然也可以手动来生成代码:

msgp -file=user.go

效果是一样的。

逻辑浅析

我们回过头来看看生成的 MarshalMsg 和 UnmarshalMsg 方法:

// MarshalMsg implements msgp.Marshaler
func (z Person) MarshalMsg(b []byte) (o []byte, err error) {
	o = msgp.Require(b, z.Msgsize())
	// map header, size 3
	// string "name"
	o = append(o, 0x83, 0xa4, 0x6e, 0x61, 0x6d, 0x65)
	o = msgp.AppendString(o, z.Name)
	// string "address"
	o = append(o, 0xa7, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73)
	o = msgp.AppendString(o, z.Address)
	// string "age"
	o = append(o, 0xa3, 0x61, 0x67, 0x65)
	o = msgp.AppendInt(o, z.Age)
	return
}

// UnmarshalMsg implements msgp.Unmarshaler
func (z *Person) UnmarshalMsg(bts []byte) (o []byte, err error) {
	var field []byte
	_ = field
	var zb0001 uint32
	zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
	if err != nil {
		err = msgp.WrapError(err)
		return
	}
	for zb0001 > 0 {
		zb0001--
		field, bts, err = msgp.ReadMapKeyZC(bts)
		if err != nil {
			err = msgp.WrapError(err)
			return
		}
		switch msgp.UnsafeString(field) {
		case "name":
			z.Name, bts, err = msgp.ReadStringBytes(bts)
			if err != nil {
				err = msgp.WrapError(err, "Name")
				return
			}
		case "address":
			z.Address, bts, err = msgp.ReadStringBytes(bts)
			if err != nil {
				err = msgp.WrapError(err, "Address")
				return
			}
		case "age":
			z.Age, bts, err = msgp.ReadIntBytes(bts)
			if err != nil {
				err = msgp.WrapError(err, "Age")
				return
			}
		default:
			bts, err = msgp.Skip(bts)
			if err != nil {
				err = msgp.WrapError(err)
				return
			}
		}
	}
	o = bts
	return
}

非常简洁,跟我们前面对{"compact":true,"schema":0}的分析是能对上的。在我们的 Person 结构体中,需要 MessagePack 序列化的只有 name, address, age 三个字段。

Carefully-designed applications can use these methods to do marshalling/unmarshalling with zero heap allocations.

它使用了msgp.AppendXXX方法将相应的类型的数据写入到[]byte中,你可以预先分配/重用[]byte,这样可以实现 zero alloc。同时你也注意到,它也将字段的名字写入到序列化字节slice中,因此序列化后的数据包含对象的元数据。

反序列化的时候会读取字段的名字,再将相应的字节反序列化赋值给对象的相应的字段。

与 json 互转

msgp 还提供了与 json 之间的互转,大家可以参考一下官方接口文档:

image.png

image.png

性能场景

The generated methods that deal with []byte are faster for small objects, but the io.Reader/Writer methods are generally more memory-efficient (and, at some point, faster) for large (> 2KB) objects.

生成的和 []byte 交互的方法 MarshalMsg, UnmashalMsg 对于小对象来说是非常快的,如果要用大对象(>2kb),建议使用 io.Reader/Writer 的 DecodeMsg, EncodeMsg。

鸟窝大佬提供了一个各个序列化框架的横向比较,感兴趣的同学可以参考一下:github.com/smallnest/g…

image.png

简单说结论:Json、Xml的序列化和反序列化性能是很差的。相比较而言MessagePack有 10x 的性能的提升。