Go 博客
GIF 解码器:Go 接口的实践
引言
在 2011 年 5 月 10 日于旧金山举行的 Google I/O 大会上,我们宣布 Go 语言现已在 Google App Engine 上可用。Go 是第一个在 App Engine 上可用的直接编译为机器码的语言,这使其成为图像处理等 CPU 密集型任务的良好选择。
在此背景下,我们演示了一个名为 Moustachio 的程序,它可以轻松地改进像这样的图片

通过添加胡子并分享结果

所有图形处理,包括渲染抗锯齿胡子,都是由运行在 App Engine 上的 Go 程序完成的。(源码可在 appengine-go 项目中获取。)
尽管网络上的大多数图片——至少那些可能需要加胡子的——都是 JPEG 格式,但仍有无数其他格式流通,让 Moustachio 接受其中一些格式的上传图片似乎是合理的。Go 图像库中已经存在 JPEG 和 PNG 解码器,但历史悠久的 GIF 格式尚未实现,所以我们决定在宣布时及时编写一个 GIF 解码器。该解码器包含一些部分,展示了 Go 的接口如何使一些问题更容易解决。本文的其余部分将描述几个例子。
GIF 格式
首先,快速了解一下 GIF 格式。GIF 图像文件是调色板式的,也就是说,每个像素值是文件中包含的一个固定颜色映射表(color map)中的索引。GIF 格式诞生于显示器通常每像素不超过 8 位,并且使用颜色映射表将有限的数值集合转换为点亮屏幕所需的 RGB(红、绿、蓝)三元组的时代。(这与 JPEG 不同,例如,JPEG 没有颜色映射表,因为其编码分别表示不同的颜色信号。)
GIF 图像每像素可以包含 1 到 8 位,包括两端,但最常见的是每像素 8 位。
简单来说,一个 GIF 文件包含一个定义像素深度和图像尺寸的头部,一个颜色映射表(对于 8 位图像是 256 个 RGB 三元组),然后是像素数据。像素数据存储为一维位流,使用 LZW 算法压缩,该算法对于计算机生成的图形非常有效,但对于照片图像效果不佳。然后将压缩数据分割成长度定界的块,每个块以一个字节的计数(0-255)开头,后跟相应数量的字节。

分块处理像素数据
要在 Go 中解码 GIF 像素数据,我们可以使用 compress/lzw
包中的 LZW 解压器。它有一个 NewReader 函数,该函数返回一个对象,正如文档所说,“通过解压从 r 读取的数据来满足读取操作”
func NewReader(r io.Reader, order Order, litWidth int) io.ReadCloser
这里的 order
定义了位打包顺序,而 litWidth
是字长(以位为单位),对于 GIF 文件而言,它对应于像素深度,通常为 8。
但我们不能直接将输入文件作为第一个参数传递给 NewReader
,因为解压器需要一个字节流,而 GIF 数据是一个必须解包的块流。为了解决这个问题,我们可以用一些代码来封装输入 io.Reader
,以便对其进行分块处理,并让这段代码再次实现 Reader
接口。换句话说,我们将分块处理代码放入一个新类型(我们称之为 blockReader
)的 Read
方法中。
以下是 blockReader
的数据结构。
type blockReader struct {
r reader // Input source; implements io.Reader and io.ByteReader.
slice []byte // Buffer of unread data.
tmp [256]byte // Storage for slice.
}
读取器 r
将是图像数据的来源,可能是一个文件或 HTTP 连接。slice
和 tmp
字段将用于管理分块处理。以下是完整的 Read
方法。它是 Go 中切片和数组使用的一个很好的例子。
1 func (b *blockReader) Read(p []byte) (int, os.Error) {
2 if len(p) == 0 {
3 return 0, nil
4 }
5 if len(b.slice) == 0 {
6 blockLen, err := b.r.ReadByte()
7 if err != nil {
8 return 0, err
9 }
10 if blockLen == 0 {
11 return 0, os.EOF
12 }
13 b.slice = b.tmp[0:blockLen]
14 if _, err = io.ReadFull(b.r, b.slice); err != nil {
15 return 0, err
16 }
17 }
18 n := copy(p, b.slice)
19 b.slice = b.slice[n:]
20 return n, nil
21 }
第 2-4 行只是一个健全性检查:如果没有地方放置数据,则返回零。这应该永远不会发生,但安全起见总是好的。
第 5 行通过检查 b.slice
的长度来判断是否有上次调用剩余的数据。如果没有,切片的长度将为零,我们需要从 r
读取下一个块。
GIF 块以一个字节计数开头,在第 6 行读取。如果计数为零,GIF 将其定义为终止块,因此我们在第 11 行返回 EOF
。
现在我们知道应该读取 blockLen
个字节,所以我们将 b.slice
指向 b.tmp
的前 blockLen
个字节,然后使用辅助函数 io.ReadFull
读取相应数量的字节。如果该函数无法准确读取指定数量的字节,它将返回错误,这应该永远不会发生。否则,我们就有了 blockLen
个字节可供读取。
第 18-19 行将 b.slice
中的数据复制到调用者的缓冲区。我们实现的是 Read
方法,而不是 ReadFull
,因此允许返回少于请求的字节数。这使得实现变得简单:我们只需将 b.slice
中的数据复制到调用者的缓冲区(p
),而 copy
的返回值就是传输的字节数。然后我们通过对 b.slice
进行切片操作来丢弃前 n
个字节,为下次调用做好准备。
在 Go 编程中,将切片(b.slice
)与数组(b.tmp
)结合使用是一种很好的技巧。在这种情况下,这意味着 blockReader
类型的 Read
方法永远不会进行任何分配。这也意味着我们不需要维护一个计数(计数隐含在切片长度中),并且内置的 copy
函数保证我们永远不会复制超出应有的数据。(有关切片的更多信息,请参阅Go 博客中的这篇文章。)
有了 blockReader
类型,我们只需通过封装输入读取器(例如文件)即可对图像数据流进行分块处理,如下所示:
deblockingReader := &blockReader{r: imageFile}
这种封装将一个以块为分隔符的 GIF 图像流转换为一个简单的字节流,可以通过调用 blockReader
的 Read
方法来访问。
连接各部分
实现了 blockReader
并且库中提供了 LZW 解压器后,我们就拥有了解码图像数据流所需的所有部分。我们用以下代码片段将它们连接起来:
lzwr := lzw.NewReader(&blockReader{r: d.r}, lzw.LSB, int(litWidth))
if _, err = io.ReadFull(lzwr, m.Pix); err != nil {
break
}
就是这样。
第一行创建一个 blockReader
并将其传递给 lzw.NewReader
来创建一个解压器。这里的 d.r
是包含图像数据的 io.Reader
,lzw.LSB
定义了 LZW 解压器中的字节序,而 litWidth
是像素深度。
有了解压器后,第二行调用 io.ReadFull
来解压数据并将其存储到图像的 m.Pix
中。当 ReadFull
返回时,图像数据已被解压并存储在图像 m
中,随时可以显示。
这段代码一次就成功了。真的。
我们可以通过将 NewReader
调用直接放入 ReadFull
的参数列表中来避免使用临时变量 lzwr
,就像我们在调用 NewReader
内部构建 blockReader
一样,但这可能将太多东西打包到一行代码中。
结论
Go 的接口使得通过像这样组装零件来重组数据从而构建软件变得容易。在这个例子中,我们使用 io.Reader
接口将分块处理器和解压器链接起来实现了 GIF 解码,这类似于一个类型安全的 Unix 管道。此外,我们将分块处理器编写为 Reader
接口的一个(隐式)实现,这使得它无需额外的声明或模板代码即可融入处理管道。在大多数语言中,很难如此紧凑、清晰且安全地实现这个解码器,但在 Go 中,接口机制加上一些约定使其变得几乎自然而然。
这值得再放一张图片,这次是 GIF 格式的

下一篇文章:聚焦 Go 外部库
上一篇文章:Google I/O 2011 上的 Go:视频
博客索引