Golang 序列化系列(1)MessagePack

携手创作,共同成长!这是我参与「掘金日新计划 · 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 name first byte (in binary) first byte (in hex)
positive fixint 0xxxxxxx 0x00 - 0x7f
fixmap 1000xxxx 0x80 - 0x8f
fixarray 1001xxxx 0x90 - 0x9f
fixstr 101xxxxx 0xa0 - 0xbf
nil 11000000 0xc0
(never used) 11000001 0xc1
false 11000010 0xc2
true 11000011 0xc3
bin 8 11000100 0xc4
bin 16 11000101 0xc5
bin 32 11000110 0xc6
ext 8 11000111 0xc7
ext 16 11001000 0xc8
ext 32 11001001 0xc9
float 32 11001010 0xca
float 64 11001011 0xcb
uint 8 11001100 0xcc
uint 16 11001101 0xcd
uint 32 11001110 0xce
uint 64 11001111 0xcf
int 8 11010000 0xd0
int 16 11010001 0xd1
int 32 11010010 0xd2
int 64 11010011 0xd3
fixext 1 11010100 0xd4
fixext 2 11010101 0xd5
fixext 4 11010110 0xd6
fixext 8 11010111 0xd7
fixext 16 11011000 0xd8
str 8 11011001 0xd9
str 16 11011010 0xda
str 32 11011011 0xdb
array 16 11011100 0xdc
array 32 11011101 0xdd
map 16 11011110 0xde
map 32 11011111 0xdf
negative fixint 111xxxxx 0xe0 - 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 的性能的提升。

猜你喜欢

转载自juejin.im/post/7129864618339467301