Go 博客

海量数据

Rob Pike
2011 年 3 月 24 日

简介

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

为什么要定义一种新的编码?这是一项非常繁重且冗余的工作。为什么不直接使用现有格式之一呢?好吧,一方面,我们确实使用了!Go 有 支持上面提到的所有编码(协议缓冲区包 位于一个单独的存储库中,但它是下载频率最高的包之一)。对于许多目的,包括与用其他语言编写的工具和系统通信,它们都是正确的选择。

但是对于 Go 特定的环境,例如在两个用 Go 编写的服务器之间进行通信,则有机会构建更易于使用且可能更高效的东西。

Gobs 以一种外部定义的、与语言无关的编码无法实现的方式与语言配合使用。同时,也可以从现有系统中吸取经验教训。

目标

gob 包的设计考虑了若干目标。

首先,也是最明显的,它必须非常易于使用。首先,因为 Go 具有反射功能,所以不需要单独的接口定义语言或“协议编译器”。数据结构本身就是该包需要了解如何对其进行编码和解码的所有信息。另一方面,这种方法意味着 gobs 永远无法与其他语言很好地配合使用,但这没关系:gobs 毫不掩饰地以 Go 为中心。

效率也很重要。以 XML 和 JSON 为例的文本表示形式太慢,无法成为高效通信网络的核心。需要二进制编码。

Gob 流必须是自描述的。从开头读取的每个 gob 流都包含足够的信息,使整个流可以被一个事先对内容一无所知的代理进行解析。此属性意味着您始终能够解码存储在文件中的 gob 流,即使在您忘记它表示的数据很久之后也是如此。

我们还从使用 Google 协议缓冲区的经验中吸取了一些教训。

协议缓冲区的缺点

协议缓冲区对 gobs 的设计产生了重大影响,但它有三个功能被刻意避免。(不考虑协议缓冲区不自描述的属性:如果您不知道用于编码协议缓冲区的数据定义,则可能无法解析它。)

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

接下来,协议缓冲区定义可能会指定字段 T.xT.y 在每次编码或解码类型 T 的值时都必须存在。尽管此类必需字段看起来像个好主意,但它们实现起来成本很高,因为编解码器必须在编码和解码时维护一个单独的数据结构,以便能够报告何时缺少必需字段。它们也是一个维护问题。随着时间的推移,人们可能希望修改数据定义以删除必需字段,但这可能会导致现有数据客户端崩溃。最好根本不在编码中包含它们。(协议缓冲区也具有可选字段。但是,如果我们没有必需字段,则所有字段都是可选的,就是这样。稍后将详细介绍可选字段。)

第三个协议缓冲区缺点是默认值。如果协议缓冲区省略了“默认”字段的值,则解码的结构的行为就像该字段设置为该值一样。当您有 getter 和 setter 方法来控制对字段的访问时,这个想法可以很好地工作,但在容器只是一个普通的习惯用法结构体时,则难以干净地处理。必需字段也难以实现:在哪里定义默认值,它们是什么类型(文本是 UTF-8?未解释的字节?浮点数中有多少位?)以及尽管表面上很简单,但在其设计和实现中存在一些复杂性用于协议缓冲区。我们决定将它们排除在 gobs 之外,并回退到 Go 的微不足道但有效的默认规则:除非您另有设置,否则它具有该类型的“零值” - 并且不需要传输。

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

编码的 gob 数据与 int8uint16 等类型无关。相反,与其类似于 Go 中的常量,其整数值是抽象的、无大小的数字,有符号或无符号。当您编码 int8 时,其值将作为无符号的、可变长度的整数传输。当您编码 int64 时,其值也将作为无符号的、可变长度的整数传输。(有符号和无符号被区分对待,但相同的无大小特性也适用于无符号值。)如果两者都具有值 7,则在网络上传输的位将相同。当接收器解码该值时,它会将其放入接收器的变量中,该变量可能是任意整数类型。因此,编码器可能会发送来自 int8 的 7,但接收器可能会将其存储在 int64 中。这很好:该值是一个整数,只要它适合,一切正常。(如果它不适合,则会发生错误。)这种与变量大小的解耦为编码提供了一些灵活性:我们可以随着软件的演进扩展整数变量的类型,但仍然能够解码旧数据。

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

灵活性也发生在解码结构体时,只有编码器发送的字段才会存储在目标中。给定值

type T struct{ X, Y, Z int } // Only exported fields are encoded and decoded.
var t = T{X: 7, Y: 0, Z: 8}

t 的编码仅发送 7 和 8。因为它是零,所以 Y 的值甚至没有发送;不需要发送零值。

接收器可以改为将值解码到此结构中

type U struct{ X, Y *int8 } // Note: pointers to int8s
var u U

并获取仅设置了 X(设置为 7 的 int8 变量的地址)的 u 值;Z 字段被忽略 - 你会把它放在哪里?在解码结构体时,字段按名称和兼容类型匹配,并且只有同时存在的字段才会受到影响。这种简单的方法解决了“可选字段”问题:随着类型 T 通过添加字段而发展,过时的接收器仍将使用其识别的类型的一部分发挥作用。因此,gobs 提供了可选字段(可扩展性)的重要结果,而无需任何其他机制或符号。

从整数我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至浮点数。浮点值由其 IEEE 754 浮点位模式表示,存储为整数,只要您知道它们的类型,这就可以正常工作,我们总是知道。顺便说一句,该整数以字节反转的顺序发送,因为浮点数的常用值(例如小整数)在低端有很多零,我们可以避免传输它们。

Go 使 gobs 成为可能的一个不错的功能是,它们允许您通过让您的类型满足 GobEncoderGobDecoder 接口来定义您自己的编码,类似于 JSON 包的 MarshalerUnmarshaler 以及来自 包 fmtStringer 接口。此功能使您能够在传输数据时表示特殊功能、强制约束或隐藏机密。有关详细信息,请参阅 文档

网络上的类型

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

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

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

这些类型编号使描述递归类型和发送这些类型的值成为可能。因此,gobs 可以编码诸如树之类的类型

type Node struct {
    Value       int
    Left, Right *Node
}

(让读者作为练习来发现零默认规则如何使此工作正常进行,即使 gobs 不表示指针。)

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

编译机器

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

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

解码类似但更难。当您解码一个值时,gob 包会保存一个字节切片,该切片表示要解码的给定编码器定义类型的值,以及要解码到的 Go 值。gob 包为该对构建一个机器:在网络上传输的 gob 类型与提供的用于解码的 Go 类型。但是,一旦构建了该解码机器,它就是一个无反射引擎,使用 unsafe 方法来获得最大速度。

使用

在幕后发生了很多事情,但结果是一个高效且易于使用的编码系统,用于传输数据。以下是一个完整的示例,显示了不同的编码和解码类型。请注意发送和接收值是多么容易;您只需要向 gob 包 提供值和变量,它就会完成所有工作。

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 包 基于 gob 构建,将此编码/解码自动化转换为跨网络方法调用的传输。这是另一篇文章的主题。

详细信息

gob 包文档,尤其是文件 doc.go,扩展了此处描述的许多详细信息,并包含一个完整的示例,展示了编码如何表示数据。如果您对 gob 实现的内部感兴趣,这是一个不错的起点。

下一篇文章:Godoc:记录 Go 代码
上一篇文章:C?Go?Cgo!
博客索引