Go 博客

数据块

Rob Pike
2011 年 3 月 24 日

介绍

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

为什么定义一种新的编码?这工作量很大,而且很冗余。为什么不直接使用现有的格式呢?嗯,首先,我们确实使用了!Go 有支持所有刚才提到的编码(protocol buffer 包在一个单独的仓库中,但它是下载最频繁的包之一)。对于许多目的,包括与用其他语言编写的工具和系统通信,它们是正确的选择。

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

Gobs 以一种外部定义、语言无关的编码无法做到的方式与语言协同工作。同时,也可以从现有系统中吸取经验。

目标

gob 包在设计时考虑了许多目标。

首先,也是最明显的,它必须非常易于使用。首先,因为 Go 具有反射机制,所以不需要单独的接口定义语言或“协议编译器”。数据结构本身就是包弄清楚如何编码和解码它所需要的全部信息。另一方面,这种方法意味着 gobs 与其他语言的协作效果不会那么好,但这没关系:gobs 毫不讳言地以 Go 为中心。

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

Gob 流必须是自描述的。每个 gob 流从头开始读取时,都包含足够的信息,以便完全不了解其内容的代理可以解析整个流。此属性意味着您将始终能够解码存储在文件中的 gob 流,即使您早已忘记它代表什么数据。

从我们使用 Google protocol buffers 的经验中,还有一些东西需要学习。

Protocol buffer 的缺点

Protocol buffers 对 gobs 的设计产生了重大影响,但有三个特性是故意避免的。(撇开 protocol buffers 不是自描述的特性不谈:如果你不知道用于编码 protocol buffer 的数据定义,你可能无法解析它。)

首先,protocol buffers 只处理我们在 Go 中称为 struct 的数据类型。你不能在顶层编码整数或数组,只能编码一个包含字段的 struct。这似乎是一个毫无意义的限制,至少在 Go 中是这样。如果你只想发送一个整数数组,为什么必须先把它放到一个 struct 中呢?

其次,protocol buffer 定义可以指定每当编码或解码类型 T 的值时,字段 T.xT.y 必须存在。尽管这些必需字段看起来是个好主意,但实现成本很高,因为编解码器在编码和解码时必须维护一个单独的数据结构,以便能够报告必需字段缺失的情况。它们也是一个维护问题。随着时间的推移,人们可能想要修改数据定义以删除必需字段,但这可能会导致现有数据的客户端崩溃。最好根本不要在编码中包含它们。(Protocol buffers 也有可选字段。但如果我们没有必需字段,那么所有字段都是可选的,就这样。关于可选字段,稍后还会详细介绍。)

第三个 protocol buffer 的缺点是默认值。如果 protocol buffer 省略了“默认”字段的值,那么解码后的结构会表现得好像该字段已设置为该值一样。当你有 getter 和 setter 方法来控制字段访问时,这个想法效果很好,但当容器只是一个普通的惯用 struct 时,就很难干净地处理了。必需字段也难以实现:默认值在哪里定义,它们有什么类型(文本是 UTF-8 吗?未解释的字节?浮点数有多少位?),尽管看起来很简单,但 protocol buffers 在设计和实现中存在一些复杂性。我们决定在 gobs 中省略它们,转而采用 Go 简单但有效的默认规则:除非你另行设置,否则它会具有该类型的“零值”——并且不需要传输。

因此,gobs 最终看起来像一种通用、简化的 protocol buffer。它们是如何工作的?

编码后的 gob 数据与 int8uint16 等类型无关。相反,与 Go 中的常量有点类似,它的整数值是抽象的、无大小的数字,可以是带符号或无符号的。当你编码一个 int8 时,它的值会作为无大小、可变长度的整数传输。当你编码一个 int64 时,它的值也会作为无大小、可变长度的整数传输。(带符号和无符号是分开处理的,但同样的无大小特性也适用于无符号值。)如果两者都有值 7,那么在线上传输的比特位将是相同的。当接收方解码该值时,它会将其放入接收方的变量中,该变量可以是任意整数类型。因此,编码器可以发送一个来自 int8 的 7,但接收方可以将其存储在 int64 中。这很好:值是一个整数,只要它能容纳,一切都能正常工作。(如果不能容纳,就会出错。)这种与变量大小的解耦为编码带来了一些灵活性:随着软件的发展,我们可以扩展整数变量的类型,但仍然能够解码旧数据。

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

灵活性还体现在,在解码 struct 时,只有编码器发送的字段才存储在目标中。给定值

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 字段被忽略——你会把它放在哪里?在解码 struct 时,字段按名称和兼容类型匹配,只有在两者中都存在的字段会受到影响。这种简单的方法巧妙地解决了“可选字段”问题:随着类型 T 通过添加字段而演进,旧的接收方仍然可以处理它们识别的那部分类型。因此,gobs 在没有任何额外机制或符号的情况下,提供了可选字段的重要结果——可扩展性。

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

Go 使 gobs 成为可能的一个很好的特性是,它们允许您通过让您的类型实现 GobEncoderGobDecoder 接口来定义自己的编码,其方式类似于 JSON 包的 MarshalerUnmarshaler 以及 fmt 包Stringer 接口。此功能使得在传输数据时可以表示特殊特性、强制执行约束或隐藏秘密。详情请参见文档

在线传输的类型

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

因此,当我们发送第一个类型 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 对 protocol buffer 的支持也采用了类似的高速方法,其设计受到了 gobs 实现的影响。)相同类型的后续值使用已编译的机器,因此可以立即进行编码。

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

详情

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

下一篇文章:Godoc:为 Go 代码编写文档
上一篇文章:C? Go? Cgo!
博客索引