语言限定的序列化方法

前言

我们在 二进制序列化比较JSON及其相关变体 中列举分析了多种二进制序列化方案,而所有列举过的方案,基本都是基于协议的语言无关的方法,这让他们非常通用。

但对于很多项目,并不需要做到 跨语言 ,比如数据就在后端存储,又希望能够快速实现,一个比较好的方式就是使用 语言自带的序列化方法

宏观视角看序列化

  • 序列化的作用

序列化的目的是为了将内存中散乱分布的数据,转变成一种顺序的格式,用于 传输 或者 存储。而数据要传递后使用,就必须实现 序列化反序列化 两个能力。

  • 实现序列化的条件

从反序列化的角度看,要实现把 顺序结构的数据 分配给一个数据结构,就必须得知道两个信息: ① 顺序结构的数据是怎么切分的 ② 目标数据结构与顺序结构中的数据怎么对应上的。

在 json 这类自描述结构中,数据的切分是通过特殊分隔符,对应关系是通过层级结构。
在 pb 这类方案中,数据切分是通过 schema 所决定的解析方法,对应关系也是通过层级结构。

  • 另一种思路

我们可以认为,json 因为要对每一个字段做描述,而导致整体数据体积更大,又因为没有索引进制导致 decode 较慢。

对于一个大型数据而言 (数据类型确定,同种类型的数据实例很多) 的情况,就可以把 数据描述 抽离出来,然后用类型 tag 去指向数据描述。 若把数据描述抽离到代码中,就变成了 pb 这类方案了。

如果把抽离的数据描述,直接放到文件的特定位置,是否就更加轻量了呢?

实际上,顺着这个思路,就是语言限定的序列化方法的底层逻辑了。

golang 的 gob 实现

gob 实现方式

gob 是一个 golang 下的二进制序列化方法,它是一个自描述的格式,它把所有的 字段名/类型/层级 的信息编码在 “类型段”,把实际的值放在 “数据段”。

详细的格式说明可以查看 encoding/gob/doc.go ,里面有一个详细的编码例子。文档可以查看 pkg.go.dev

gob 中的每一个类型,都有一个 typeid,在 encode 的时候,用于判断该类型的 type 是否已经被写入类型描述里了。同时,每一个 value 在开始 encode 前,都会有标识接下来的值是什么类型,这个标识就是 typeid。

从 typeid 的生成规则来看,是在 gob 中注册过的类型 index,代码如下:

1
2
3
4
5
6
7
8
9
func setTypeId(typ gobType) {
// When building recursive types, someone may get there before us.
if typ.id() != 0 {
return
}
nextId := typeId(len(idToTypeSlice))
typ.setId(nextId)
idToTypeSlice = append(idToTypeSlice, typ)
}

这也就意味着,typeid 在不同的程序代码中表示的含义不同,也就无法直接通过 typeid 在 decode 时做关联映射。

encoding/gob/types.go# Types and Values 中有详细的介绍,字段的关联中,根据 field name 做关联,同时做了 无指针化 ,会忽略不匹配的字段,这和 encoding/json 的主要区别就是把 json tag 变成了 field name ,其他差别不大。具体的代码可以参考 encoding/gob/decode.go:compileDec ,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Loop over the fields of the wire type.
for fieldnum := 0; fieldnum < len(wireStruct.Field); fieldnum++ {
wireField := wireStruct.Field[fieldnum]
if wireField.Name == "" {
errorf("empty name for remote field of type %s", wireStruct.Name)
}
ovfl := overflow(wireField.Name)
// Find the field of the local type with the same name.
localField, present := srt.FieldByName(wireField.Name)
// TODO(r): anonymous names
if !present || !isExported(wireField.Name) {
op := dec.decIgnoreOpFor(wireField.Id, make(map[typeId]*decOp), 0)
engine.instr[fieldnum] = decInstr{*op, fieldnum, nil, ovfl}
continue
}
if !dec.compatibleType(localField.Type, wireField.Id, make(map[reflect.Type]typeId)) {
errorf("wrong type (%s) for received field %s.%s", localField.Type, wireStruct.Name, wireField.Name)
}
op := dec.decOpFor(wireField.Id, localField.Type, localField.Name, seen)
engine.instr[fieldnum] = decInstr{*op, fieldnum, localField.Index, ovfl}
engine.numInstr++
}

这个过程的目的就是构建 engine.instr ,形成一个用 filed 的 index 做关联的 decoder 具体实现(op)数组,用于后续的 decode value 过程。

type 描述实现

因为自定义 struct 的存在,gob 需要支持不同的自定义类型,但无论怎么自定义,最后都要对应到可以用统一方法处理的 type 描述上来,在 gob 中,这就是 wireType,它的定义是这样:

1
2
3
4
5
6
7
8
9
type wireType struct {
ArrayT *arrayType
SliceT *sliceType
StructT *structType
MapT *mapType
GobEncoderT *gobEncoderType
BinaryMarshalerT *gobEncoderType
TextMarshalerT *gobEncoderType
}

不论是在 encode 时复杂类型的描述,还是在 decode 时决定 decode method,都是根据这个结构体来的。

一个例子

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Hobby struct {
Name string
Level uint16
}

type XHobby struct {
Name string
XX string
XX2 string
Level uint16
LX3 string
}

func TestEasy(t *testing.T) {
hb := Hobby{Name: "cooking", Level: 15}

bf := bytes.NewBuffer([]byte{})

err := gob.NewEncoder(bf).Encode(hb)
if err != nil {
panic(err)
}

util.PrintPerBytes(bf.Bytes(), util.NewOption(true, true, 10,
util.BytesToBitsStr, util.BytesToHexStr,
util.BytesToIntStr, util.BytesToCharStr))

tar := XHobby{}
err = gob.NewDecoder(bf).Decode(&tar)
if err != nil {
panic(err)
}
}

ps: util.PrintPerBytes 的代码可以参见 二进制序列化比较

运行结果如下:

  • 1-38 bytes 为 struct 的定义
    • 1 byte 00100101 表示第一部分数据有 37 bytes 长,也就是到 38 byte 00000000 及之前都是 struct 定义内容。
    • 2 byte 01111111 是 typeid ,这是 wireType 的 id,表示后面的数据要用 wireType 这个类型去解析。
    • 3 byte 00000011 代表 struct 的 filed 向右增加 3 (从 -1 开始,此时对应到 2,在 wireType 中即是 StructT => type structType struct {CommonType;Field []fieldType;})
    • 4 byte 00000001 代表 StructT 的类型 *structType 的 filed 向右增加 1 (从 -1 开始,此时对应到 0,在 structType 中即是 CommonType type CommonType struct {Name string; Id typeId;} )
    • 5 byte 00000001 代表 CommonType 的 field 向右增加 1 ,对应为 Name 字段
    • 6 byte 00000101 代表 Name 字段长度为 5
    • 7-11 bytes 即为 string 的内容 “Hobby”
    • 12 byte 00000001 代表 CommonType 的 filed 向右增加 1,对应为 ID 字段
    • 13-14 bytes 11111111 10000000 为 typeid 的编码结果 ff80
    • 15 byte 00000000 代表结束 CommonType
    • 16 byte 00000001 代表 StructT 的 field 向右增加 1 (此时为 1,对应为 Field)
    • 17 byte 00000010 代表 Field 这个数组的长度为 2
    • 18 byte 00000001 代表 Field 的索引向右增加 1 (从 -1 开始,此时为 0),由于数组的序列化是平铺的,比如 [{name: string, id: string},{name: string, id: string}] 中,0 => [0].name、1 => [0].id、2 => [1].name 、3 => [1].id ,所以此时指向 Field 的 [0].Name
    • 19 byte 00000100 代表 [0].Name 长度为 4
    • 20-23 bytes,为内容 “Name” (Hobby 结构体的第一个字段名)
    • 24 byte 00000001 同 18 byte,向右增加 1,当前指向 Field 结构体的第二个字段 typeId
    • 25 byte 00001100 ,表示 typeId 为 0c,意为 string (应该是(╥﹏╥),实际指代 0c >> 1 = 6)
    • 26 byte 00000000,代表 Field 结束
    • 27 byte 00000001 同 18 byte,指向 [1].Name
    • 28 byte 00000101 长度为 5 bytes
    • 29-33 bytes,为内容 “Level” (Hobby 结构体的第二个字段名)
    • 34 byte 00000001 同 27 byte,指向 [1].typeId
    • 35 byte 00000110 同 25 byte,指代 6 >> 1 = 3,为 uint16
    • 36-38 bytes,为各级的结束符
  • 39-53 bytes 为实际数据
    • 39 byte 00001110 代表第二部分数据有 14 byte 长,也即 40-53 byte。
    • 40-41 byte 10000000-11111111 是编码后的 typeid (实际表示 -64,不过直接当做 ff80 也没关系),对应的 schema 在 13-14 byte。
    • 42 byte 00000001 代表 struct 的 field 向右增加 1 (从 -1 开始,此时应到了 0,代表的是 Name 字段)。
    • 43 byte 00000111 代表数据长度为 7 byte。
    • 44-50 byte,是 string 的具体内容 “cooking”。
    • 51 byte 00000001 代表 struct 的 field 向右增加 1 (从 -1 开始,此时应到了 1,代表的是 Level 字段)。
    • 52 byte 00001111 代表数字 15,即 Level=15。
    • 53 byte 00000000 代表这个部分结束

改进思考

gob 的一大优势就是:几乎无依赖,在 golang 程序中传递起来很方便。 但根据场景的不同,也有一些不足之处,比如:

  1. 没有实现递归引用,可以尝试实现外部引用 (为了特殊场景的方便)
    • encoding/gob/types.go:validUserType 中用了快慢指针类型判环
    • 既然是语言限定的,保存状态若能把引用关系全部保存下来,那就更好了
  2. 可以尝试把 二进制 的 schema 变成 json schema (为了方便查看,type 标识也可以使用字符串表示)
    • 有时候为了调试,希望看一下一些类型是怎么定义的,JSON schema 就比二进制 schema 方便多了,若能支持两种模式,甚好。
  3. 可以尝试在 rpc 建联之时创建 schema 交换协议 (arvo 似乎就是这么做的?)
    • 序列化的一个重要应用场景就是 rpc,而我们往往会在数据层之外还会有一层协议层去标识请求的其他元信息,比如 Method;而对于特定的 Method,则几乎是完全使用同一个解析方法,若能对解析方法做 接口绑定/连接绑定,则 schema 仅需更少次数的传递和解析了。
  4. 这是完全基于反射的实现 (似乎没其他办法了……)

python 的 pickle 实现

pickle 就没有细致做序列化后的数据格式分析了,它的主体不在于序列化格式,而是所提供的能力,可以将 官方文档 细读一番。总结一下主要部分:

一些思考

  • 序列化和反序列化的核心,还是拿到 关联匹配关系,因此不论是外部 schema 信息,还是自描述信息,或者是通过反射拿对象信息,目的都是获得匹配关系。
  • 一般我们认为的 序列化 ,其实指的是 marshal 操作,是一种去除了语言状态的操作,比如不包含 模块导入function 等语言层面信息的。 但 python 的 pickle 在这个思路上更有突破性,它引入了 import 的能力,几乎是实现了把内存状态转移到磁盘中。

我要做的序列化方案

  1. 成环数据也要把关系传递
  2. 支持原地修改 (逻辑上的)
  3. 支持设置 default 值
  4. 支持数据操作协议
  5. 支持 key 的操作事件监听 (采用 event hub 方案)
  6. 支持 schema 版本变更钩子
  7. 支持 text 和 binary 两种模式
  8. 暂不支持自定义序列化方法
  • 基于上述能力,实现一个多端缓存协议,就像一个 http2 的头信息协商一样

The greatest minds are capable of the greatest vices as well as of the greatest virtues.
René Descartes