海量数据

Gobs of data - 海量数据

https://go.dev/blog/gob

Rob Pike 24 March 2011

2011年3月24日

简介

​ 要在网络上传输数据结构或将其存储在文件中,必须将其编码,然后再解码。当然有许多编码可用:JSONXML、Google的协议缓冲区等等。现在,Go的gob包提供了另一种编码。

​ 为什么要定义一种新的编码方式?这是一项很繁重的工作,也是多余的。为什么不只是使用现有的格式之一呢?嗯,首先,我们确实使用了它们!Go有支持所有上述编码的协议缓冲区包在单独的存储库中,但它是最常下载的)。而对于许多用途,包括与使用其他语言编写的工具和系统进行通信,它们是正确的选择。

​ 但是,对于一个特定于Go环境的场景,比如在两个使用Go编写的服务器之间通信,有机会构建一些更易于使用,可能更有效的东西。

​ Gob与语言一起工作,这是一个外部定义的、独立于语言的编码无法做到的。同时,现有系统中也有值得借鉴的地方。

目标

​ gob包的设计考虑了许多目标。

​ 首先,它必须非常容易使用。因为Go具有反射功能,所以不需要单独的接口定义语言或"协议编译器"。数据结构本身就是该包应该需要来确定如何编码和解码它的所有内容。另一方面,这种方法意味着gob将永远无法与其他语言很好地配合使用,但这没关系:gob毫不掩饰地面向Go。

​ 效率也很重要。以 XML 和 JSON 为代表的文本表示形式,过于缓慢,不能成为高效通信网络的核心。必须采用二进制编码。

​ Gob 流必须是自描述的。从开头读取的每个 gob 流都包含足够的信息,以便不知道其内容的代理可以解析整个流。这种属性意味着,即使在很久之后,您已经忘记了数据表示什么,您仍然可以解码存储在文件中的 gob 流。

​ 我们也从使用 Google 协议缓冲区的经验中学到了一些东西。

协议缓冲区的不足之处

​ 协议缓冲区对 Gob 的设计产生了重大影响,但有三个特性被有意避免。(撇开协议缓冲区不是自描述的这一点:如果您不知道用于编码协议缓冲区的数据定义,您可能无法解析它。)

​ 首先,协议缓冲区只能处理我们在 Go 中称为结构体的数据类型。您不能在顶层编码整数或数组,只能编码包含字段的结构体。这似乎是一个毫无意义的限制,至少在 Go 中是这样的。如果您只想发送一个整数数组,为什么必须先将它放入一个结构体中呢?

​ 接下来,协议缓冲区定义可以指定 T.x 和 T.y 字段在编码或解码类型 T 的值时必须存在。虽然这些必需字段可能看起来像是一个好主意,但它们的实现成本很高,因为编解码器必须同时维护一个单独的数据结构,以便能够在必需字段缺失时报告。它们还是维护问题。随着时间的推移,人们可能希望修改数据定义以删除必需字段,但这可能会导致数据的现有客户端崩溃。最好根本不将其包含在编码中。(协议缓冲区还具有可选字段。但如果我们没有必需字段,那么所有字段都是可选的。稍后将有更多关于可选字段的说明。)

​ 第三个协议缓冲区不足是默认值。如果一个 Protocol Buffer 忽略了"默认"字段的值,那么解码后的结构将表现为该字段被设置为该值。当您有 getter 和 setter 方法来控制对字段的访问时,这个想法很好用,但是当容器只是一个简单的惯用结构时,处理起来就比较困难了。需要的字段也很难实现:在哪里定义默认值,它们具有什么类型(是文本 UTF-8 还是未解释的字节?浮点数有多少位?)尽管看起来很简单,但是 Protocol Buffer 的设计和实现中有很多复杂之处。我们决定将它们留在 gobs 之外,并退回到 Go 的简单但有效的默认规则:除非您另外设置了某些内容,否则它具有该类型的"零值"-不需要传输它们。

​ 因此,gobs 最终看起来像一种广义、简化的协议缓冲区。它们是如何工作的?

​ 编码的 gob 数据不是关于像 int8 和 uint16 这样的类型。相反,有些类似于 Go 中的常量,其整数值是抽象的、无大小的数字,可以是有符号的或无符号的。当您编码一个 int8 时,它的值以无大小、可变长度的整数形式传输。当您编码一个 int64 时,它的值也以无大小、可变长度的整数形式传输。(有符号和无符号被分别处理,但是相同的无大小性质也适用于无符号值。)如果它们都具有值 7,则发送到线上的比特将是相同的。当接收者解码该值时,它将它放入接收者的变量中,该变量可以是任意整数类型。因此,编码器可以发送一个来自 int8 的 7,但接收者可以将其存储在 int64 中。这没问题:该值是一个整数,只要它适合,一切都可以工作。(如果不适合,就会产生错误。)与变量的大小分离使得编码具有一定的灵活性:随着软件的演进,我们可以扩展整数变量的类型,但仍能解码旧数据。

​ 这种灵活性也适用于指针。在传输之前,所有指针都被展开。类型为 int8*int8**int8****int8等的值都作为整数值传输,然后可以存储在任何大小的 int*int******int 等中。同样,这允许灵活性。

​ 灵活性也发生在解码结构体时,只有发送端发送的那些字段才会存储在接收端。例如给出值:

1
2
type T struct{ X, Y, Z int } // 只有导出字段被编码和解码。
var t = T{X: 7, Y: 0, Z: 8}

t 的编码只发送 7 和 8,因为 Y 的值为零,所以它甚至不需要发送。接收器可以解码到这个结构体:

1
2
type U struct{ X, Y *int8 } // 注意:指向 int8 的指针。
var u U

并获得一个只设置了 X(指向一个值为 7 的 int8 变量地址)的 u 值;Z 字段被忽略了 - 放在哪里呢?解码结构体时,字段是通过名称和兼容类型匹配的,只有存在于两者中的字段才会受到影响。这种简单的方法可以避免"可选字段"问题:当类型 T 通过添加字段来演化时,过时的接收器仍然可以与它们识别的部分类型一起使用。因此,gob 提供了可选字段的重要结果——可扩展性,而不需要任何额外的机制或符号。

​ 从整数我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至浮点数。浮点值是由它们的 IEEE 754 浮点位模式表示的,存储为一个整数,只要我们知道它们的类型,就可以很好地工作。顺便说一下,这个整数是以字节颠倒的顺序发送的,因为常见的浮点数值,比如小整数,低位有很多零,我们可以避免传输它们。

​ Gob 的一个很好的特性是,它们允许您通过让您的类型满足 GobEncoderGobDecoder接口来定义自己的编码,这类似于 JSON包的 MarshalerUnmarshaler,以及 fmt包Stringer接口。这个功能使得在传输数据时表示特殊特性、强制约束或隐藏机密信息成为可能。详细信息请参见文档

线上类型

​ 第一次发送给定类型时,gob包在数据流中包含了该类型的描述。实际上,发生的是使用编码器将描述该类型的内部结构以标准的gob编码格式进行编码,并为其分配一个唯一的编号。 (基本类型以及类型描述结构的布局由软件预定义,用于引导)。描述完类型后,可以通过其类型编号进行引用。

​ 因此,当我们发送第一个类型T时,gob编码器会发送T的描述,并将其标记为类型编号127。所有值(包括第一个)都以该编号作为前缀,因此T值的流程如下所示:

("define type id" 127, definition of type T)(127, T value)(127, T value), ...

​ 这些类型编号使得可以描述递归类型并发送这些类型的值。因此,gob可以编码诸如树之类的类型:

1
2
3
4
type Node struct {
    Value       int
    Left, Right *Node
}

(读者可以练习一下如何在gob中使用默认规则表示指针。)

​ 有了类型信息,gob流就完全自我描述,除了一组引导类型,这是一个明确定义的起点。

编译机器

​ 第一次编码给定类型的值时,gob包构建了一个特定于该数据类型的小型解释器。它使用反射来构造该机器,但是一旦该机器构建完成,它就不依赖于反射。该机器使用了包unsafe和一些诡计来高速地将数据转换为编码的字节。它可以使用反射并避免unsafe,但速度会明显变慢。 (协议缓冲区支持Go采用了类似的高速方法,其设计受到gob实现的影响。)同一类型的后续值使用已经编译的机器,因此它们可以立即编码。

[更新:从 Go 1.4 开始,gob 包不再使用unsafe包,但性能略有下降。]

​ 解码类似但更困难。当您解码值时,gob 包将保持一个表示给定编码器定义类型的字节切片,再加上要解码的 Go 值。Gob 包为这一对建立了一个机器:在线路上发送的 gob 类型与提供的 Go 类型交叉。但一旦建立了解码机器,它就是一个反射无关的引擎,使用 unsafe 方法以获得最大速度。

用途

​ 底层有很多东西在运作,但结果是一种高效、易于使用的编码系统,用于传输数据。这是一个完整的示例,显示了不同的编码和解码类型。请注意,发送和接收值非常容易;您只需要向 gob 包提供值和变量,它会处理所有工作。

 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
34
35
36
37
38
39
package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "log"
)

type P struct {
    X, Y, Z int
    Name    string
}

type Q struct {
    X, Y *int32
    Name string
}

func main() {
    // Initialize the encoder and decoder.  Normally enc and dec would be
    // bound to network connections and the encoder and decoder would
    // run in different processes.
    var network bytes.Buffer        // Stand-in for a network connection
    enc := gob.NewEncoder(&network) // Will write to network.
    dec := gob.NewDecoder(&network) // Will read from network.
    // Encode (send) the value.
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // Decode (receive) the value.
    var q Q
    err = dec.Decode(&q)
    if err != nil {
        log.Fatal("decode error:", err)
    }
    fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

​ 在 Go Playground 中编译并运行此示例代码。

rpc 包利用 gobs 将此编码/解码自动化转换为跨网络的方法调用的传输。这是另一篇文章的主题。

细节

gob包的文档,特别是doc.go文件,扩展了许多在此处描述的细节,并包括一个完整的工作示例,展示了编码如何表示数据。如果您对gob实现的内部细节感兴趣,那么这是一个很好的开始。