Go-实战-全-

Go 实战(全)

原文:Go in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章. Go 语言介绍

本章内容

  • 使用 Go 解决现代计算挑战

  • 使用 Go 工具

计算机已经发展,但编程语言并没有跟上同样的进化速度。我们携带的手机可能比我们最初使用的第一台计算机拥有更多的 CPU 核心。现在,高性能服务器拥有 64、128 或更多的核心,但我们仍然使用着为单核设计的编程技术。

编程艺术也在不断发展。大多数程序不再是单个开发者编写的:它们是由不同时区、不同时间工作的人们组成的团队编写的。大型项目被分解成更小的部分,分配给程序员,然后他们以库或包的形式将他们的工作交付给团队,这些库或包可以在整个应用程序套件中使用。

今天,程序员和公司比以往任何时候都更相信开源软件的力量。Go 是一种使代码共享变得容易的编程语言。Go 附带了一些工具,使使用他人编写的包变得简单,同时 Go 也使共享我们自己的包变得容易。

本章中,你将看到 Go 语言与其他编程语言的不同之处。Go 重新思考了你可能习惯的传统面向对象开发,同时仍然提供了一种高效的代码重用方法。Go 使你能够更有效地使用昂贵的服务器上的所有核心,并消除了编译一个非常大的项目的惩罚。

阅读本章时,你会感受到塑造 Go 语言创建的众多决策,从其并发模型到其闪电般的编译器。我们在前言中提到过,但值得再次强调:本书是为有一定其他编程语言经验的初级开发者编写的,他们希望学习 Go 语言。我们编写本书的目标是为你提供一个密集、全面和地道的语言视角。我们关注语言的规范和实现,包括语言语法、Go 的类型系统、并发、通道、测试等多个广泛的主题。我们相信这本书非常适合那些想要快速入门学习 Go 语言或希望更深入理解语言及其内部机制的人。

书中示例的源代码可在 github.com/goinaction/code 获取。

我们希望你会欣赏 Go 语言附带的各种工具,使你的开发者生活更加轻松。最终,你会理解为什么许多开发者在启动新项目时选择 Go 语言。

1.1. 使用 Go 解决现代编程挑战

Go 团队付出了巨大的努力来解决当今软件开发者面临的问题。当开发者选择项目语言时,必须在快速开发和性能之间做出不舒服的选择。像 C 和 C++这样的语言提供快速执行,而像 Ruby 和 Python 这样的语言则提供快速开发。Go 连接了这些竞争的世界,并提供了具有使开发快速的功能的高性能语言。

随着我们探索 Go,你会发现精心设计的特性和简洁的语法。作为一个语言,Go 不仅由它包含的内容定义,还由它不包含的内容定义。Go 具有简洁的语法,关键字数量少,易于记忆。Go 有一个非常快的编译器,有时你会忘记它在运行。作为一个 Go 开发者,你将花费显著更少的时间等待项目构建。由于 Go 内置的并发特性,你的软件将能够扩展以使用可用的资源,而无需强迫你使用特殊的线程库。Go 使用简单而有效的类型系统,从面向对象开发中移除了大部分开销,让你能够专注于代码重用。Go 还有一个垃圾回收器,因此你不需要管理自己的内存。让我们快速看一下这些关键特性。

1.1.1. 开发速度

在 C 或 C++中编译一个大型应用程序所需的时间比喝一杯咖啡还要长。图 1.1 展示了 XKCD 经典的一个在办公室闲逛的借口。

图 1.1. 工作努力吗?(via XKCD)

Go 通过使用智能编译器和简化的依赖解析算法,实现了闪电般的快速编译。当你构建一个 Go 程序时,编译器只需要查看你直接包含的库,而不是像 Java、C 和 C++那样遍历整个依赖链中包含的所有库的依赖。因此,许多 Go 应用程序的编译时间不到一秒。在现代化硬件上,整个 Go 源代码树编译时间不到 20 秒。

使用动态语言编写应用程序可以让你快速变得高效,因为编写代码和执行代码之间没有中间步骤。代价是动态语言不提供静态语言那样的类型安全,并且通常需要一个全面的测试套件来避免在运行时发现不正确的类型错误。

想象一下,在像 JavaScript 这样的动态语言中编写一个大型应用程序,并遇到一个期望接收名为ID的字段的函数。这是一个整数、一个字符串还是一个 UUID?找出答案的方法是查看源代码。你可以尝试用数字或字符串执行该函数,看看会发生什么。在 Go 中,你不会浪费时间猜测,因为编译器会为你捕获类型差异。

1.1.2. 并发

作为程序员,最难的事情之一是编写一个能够有效利用运行其硬件可用资源的应用程序。现代计算机拥有许多核心,但大多数编程语言都没有有效利用这些额外资源的工具。它们通常需要大量的线程同步代码,这些代码容易出错。

Go 的并发支持是其最强大的功能之一。Goroutines 类似于线程,但使用更少的内存,并且使用起来需要更少的代码。Channels 是一种数据结构,它允许您在 goroutine 之间发送类型化的消息,并内置了同步机制。这促进了一种编程模型,在该模型中,您在 goroutine 之间发送数据,而不是让 goroutine 争夺使用相同的数据。现在让我们更详细地看看这些功能。

Goroutines

Goroutines 是与包括程序入口点在内的其他 goroutine 并发运行的函数。在其他语言中,您会使用线程来完成相同的事情,但在 Go 中,许多 goroutine 在单个线程上执行。例如,如果您编写一个 Web 服务器并且想要同时处理不同的 Web 请求,您将不得不编写大量的额外代码来在 C 或 Java 中使用线程。在 Go 中,net/http 库内置了使用 goroutine 的并发功能。每个传入请求都会自动在其自己的 goroutine 上运行。Goroutines 比线程使用更少的内存,Go 运行时将自动将 goroutine 的执行调度到一组配置的逻辑处理器。每个逻辑处理器绑定到单个 OS 线程(参见图 1.2)。这使得您的应用程序更加高效,并且开发工作量显著减少。

图 1.2. 许多 goroutine 在单个 OS 线程上执行

图片

如果您想在执行其他任务的同时并发执行一些代码,goroutine 是完美的选择。以下是一个快速示例:

func log(msg string){
    ... some logging code here
}

// Elsewhere in our code after we've discovered an error.
go log("something dire happened")

关键字 go 是您调度 log 函数作为 goroutine 运行以及使该 goroutine 与其他 goroutine 并发运行所需的所有内容。这意味着您可以在日志并发执行的同时继续执行应用程序的其他部分,这通常会导致最终用户感知的性能显著提升。正如之前所述,goroutine 的开销最小,因此产生成千上万的 goroutine 并不罕见。我们将在第六章深入探讨 goroutine 和并发。

Channels

Channels 是一种数据结构,它允许 goroutine 之间安全地交换数据。Channels 帮助您避免在允许共享内存访问的编程语言中通常遇到的问题。

并发编程中最困难的部分是确保你的数据不会被同时运行的过程、线程或 goroutines 意外修改。当多个线程在没有锁或同步的情况下修改相同的数据时,总是会有痛苦随之而来。在其他语言中,当你有全局变量和共享内存时,你必须使用复杂的锁定纪律来防止对同一变量的不同步更改。

Channels 通过提供一种使数据免受并发修改的模式来帮助解决这个问题。Channels 帮助强制执行这样的模式:在任何时候只有一个 goroutine 应该修改数据。你可以在图 1.3 中看到一个这样的流程示例,其中使用了 channels 在几个运行中的 goroutines 之间发送数据。想象一个应用程序,其中许多不同的进程需要按顺序了解或修改数据。使用 goroutines 和 channels,你可以安全地模拟这个过程。

图 1.3. 使用 channels 在 goroutines 之间安全地传递数据

图 1.3

在图 1.3 中,你可以看到三个 goroutines 和两个无缓冲的 channels。第一个 goroutine 通过 channel 将一个数据值传递给一个已经等待的第二个 goroutine。这两个 goroutines 之间的数据交换是同步的,一旦交接发生,两个 goroutines 都知道交换已经发生。第二个 goroutine 使用数据执行其任务后,然后将数据发送给一个等待的第三个 goroutine。这个交换也是同步的,两个 goroutines 都可以保证交换已经完成。这种 goroutines 之间的安全数据交换不需要其他锁或同步机制。

重要的是要注意,channels 不会在 goroutines 之间提供数据访问保护。如果通过 channel 交换数据的副本,那么每个 goroutine 都有自己的副本,并且可以安全地对该数据进行任何更改。当交换数据的指针时,如果不同的 goroutines 将执行读写操作,那么每个 goroutine 仍然需要同步。

1.1.3. Go 的类型系统

Go 提供了一个灵活的无层次类型系统,它通过最小化重构开销来促进代码重用。它仍然是面向对象开发,但没有传统头痛的问题。如果你曾经在一个复杂的 Java 或 C++程序中花费一周时间来规划你的抽象类和接口,你会欣赏 Go 的类型系统的简单性。Go 开发者简单地嵌入类型,以在称为组合的设计模式中重用功能。其他语言也使用组合,但它通常与继承紧密相连,这可能会使其变得复杂且难以使用。在 Go 中,类型是由更小的类型组合而成的,这与传统的基于继承的模型形成对比。

此外,Go 还有一个独特的接口实现,允许你建模行为,而不是建模类型。你不需要在 Go 中声明你正在实现接口;编译器会做这项工作,确定你的类型的值是否满足你使用的接口。Go 标准库中的许多接口都非常小,仅暴露了几个函数。在实践中,这需要一些时间来适应,尤其是如果你一直在使用像 Java 这样的面向对象语言编写代码。

类型很简单

Go 有内置类型如 intstring,以及用户定义的类型。Go 中的典型用户定义类型将具有类型字段以存储数据。如果你见过 C 中的结构体,Go 的用户定义类型看起来会非常熟悉,并且操作方式相似。但类型也可以声明操作该数据的方法。而不是构建一个长的继承结构——Client 扩展 User 扩展 Entity——Go 开发者构建小的类型——Customer 和 Admin——并将它们嵌入到更大的类型中。图 1.4 展示了继承与组合之间的区别。

图 1.4. 继承与组合

图片

Go 接口建模小行为

接口允许你表达类型的行怍。如果一个类型的值实现了接口,这意味着该值具有一组特定的行为。你甚至不需要声明你正在实现接口;你只需要编写实现即可。其他语言称这为 鸭子类型——如果它像鸭子叫,那么它就可以 一只鸭子——Go 也做得很好。在 Go 中,如果你的类型实现了接口的方法,你的类型的值可以存储在接口类型的值中。不需要特殊的声明。

在一个严格面向对象的编程语言如 Java 中,接口是无所不包的。在开始编写代码之前,你通常需要仔细思考一个大的继承链。以下是一个 Java 接口的示例:

interface User {
   public void login();
   public void logout();
}

在 Java 中实现此接口需要你创建一个类,该类满足 User 接口中的所有承诺,并明确声明你实现了该接口。相比之下,Go 接口通常只代表一个单一的操作。你将在 Go 中使用最频繁的接口之一是 io.Readerio.Reader 接口提供了一种简单的方式来声明你的类型具有以标准库中的其他函数理解的方式可读的数据。以下是定义:

type Reader interface {
    Read(p []byte) (n int, err error)
}

要编写一个实现 io.Reader 接口的类型,你只需要实现一个接受字节数组并返回整数和可能的错误的 Read 方法。

这与其他面向对象编程语言中使用的接口系统有根本的不同。Go 的接口更小,更符合单一操作。在实践中,这允许在代码重用和可组合性方面有显著的优势。您几乎可以在任何具有可用数据的数据类型上实现 io.Reader,然后将其传递给任何知道如何从 io.Reader 读取的 Go 函数。

Go 中的整个网络库都是使用 io.Reader 接口构建的,因为它允许它将每个不同网络操作所需的网络实现与您应用程序的功能分离。这使得接口变得有趣、优雅且灵活。相同的 io.Reader 还允许与文件、缓冲区、套接字和其他任何数据源进行简单操作。使用单个接口允许您高效地操作数据,无论数据源是什么。

1.1.4. 内存管理

不当的内存管理会导致应用程序崩溃和内存泄漏,甚至可能使操作系统崩溃。Go 拥有一个现代的垃圾回收器,它会为您完成繁重的工作。在其他系统语言,如 C 或 C++ 中,您在使用内存之前需要分配一块内存,并在完成时释放它。如果您未能正确执行这两者中的任何一个,程序可能会崩溃或发生内存泄漏。当内存不再需要时,跟踪内存并不总是容易;线程和重并发使它变得更加困难。当您考虑垃圾回收编写代码时,Go 的垃圾回收器对程序执行时间几乎没有开销,但显著减少了开发工作量。Go 去掉了编程中的繁琐,让会计师们去数豆子。

1.2. Hello, Go

通过看到编程语言的实际应用,更容易获得其感觉。让我们看看用 Go 编写的传统 Hello World! 应用程序:

当您运行此示例程序时,它会在屏幕上打印出一个熟悉的短语。但您应该如何运行它呢?在不安装 Go 到您的计算机上,您可以直接从您的网页浏览器使用 Go 提供的大部分功能。

1.2.1. 介绍 Go Playground

Go Playground 允许您通过网页浏览器编辑和运行 Go 代码。打开一个网页浏览器,并导航到 play.golang.org。浏览器窗口中的代码可以直接在屏幕上编辑(见 图 1.5)。点击运行,看看会发生什么!

图 1.5. Go Playground

您甚至可以更改代码,使问候文本输出为不同的语言。请继续更改 fmt.Println() 函数内的问候语,然后再次点击运行。

共享 Go 代码

Go 开发者使用演示场来分享代码想法、测试理论以及调试他们的代码,你很快也会这样做。每次你在演示场创建一个新的应用程序时,你都可以点击“分享”来获取一个可分享的 URL,任何人都可以打开。试试这个:play.golang.org/p/EWIXicJdmz

Go 演示场是向试图学习新东西的同事或朋友展示想法的完美方式,或者寻求帮助。在 Go 的 IRC 频道、Slack 群组、邮件列表以及 Go 开发者之间发送的无数电子邮件中,你会看到正在创建、修改和共享的 Go 演示场程序。

1.3. 摘要

  • Go 语言现代、快速,并附带强大的标准库。

  • Go 语言内置了并发功能。

  • Go 语言使用接口作为代码重用的构建块。

第二章. Go 快速入门

本章内容

  • 复习全面的 Go 程序

  • 声明类型、变量、函数和方法

  • 启动和同步 goroutines

  • 使用接口编写泛型代码

  • 将错误处理作为正常程序逻辑

Go 有其独特的优雅和编程习语,使得语言既高效又编程起来有趣。语言设计者旨在创建一种语言,让他们在保持对所需底层编程结构访问的同时提高生产力。这种平衡是通过最小化关键词、内置函数和语法来实现的。Go 还提供了一个全面的标凈库。标准库提供了程序员构建真实世界基于 Web 和网络的程序所需的所有核心包。

为了看到这个动作,我们将审查一个完整的 Go 程序,该程序实现了许多当前正在开发的 Go 程序中可以找到的功能。该程序从网络上拉取不同的数据源,并将内容与搜索词进行比较。匹配的内容随后在终端窗口中显示。该程序读取文本文件,进行网络调用,并将 XML 和 JSON 解码为结构体类型值,所有这些操作都是通过 Go 并发来实现的,以加快速度。

你可以通过导航到本章的书本仓库来下载并使用你喜欢的编辑器审查代码:

https://github.com/goinaction/code/tree/master/chapter2/sample

不要觉得你需要第一次、第二次甚至第三次阅读和复习本章内容时就能完全理解。尽管你今天所知道的许多编程概念在学习 Go 时都可以应用,但 Go 也有其独特的习语和风格。如果你能从当前的编程语言中解放出来,用全新的视角和清晰的头脑来看待 Go,你会发现它更容易理解和欣赏,你也会看到 Go 的优雅。

2.1. 程序架构

在我们深入代码之前,让我们回顾一下程序背后的架构(如图 2.1 所示)以及如何完成对所有不同源进行搜索。

图 2.1. 程序架构流程

程序被分解成几个不同的步骤,这些步骤在不同的 goroutines 中运行。我们将探索代码,从主 goroutine 流入搜索和跟踪 goroutines,然后再返回主 goroutine。首先,这是项目的结构。

列表 2.1. 应用程序的项目结构
cd $GOPATH/src/github.com/goinaction/code/chapter2

- sample
    - data
        data.json   -- Contains a list of data feeds
    - matchers

        rss.go      -- Matcher for searching rss feeds
    - search
        default.go  -- Default matcher for searching data
        feed.go     -- Support for reading the json data file
        match.go    -- Interface support for using different matchers
        search.go   -- Main program logic for performing search
    main.go         -- Programs entry point

代码被组织到这四个文件夹中,按字母顺序列出。数据文件夹包含一个 JSON 文档,它为程序提供数据源,程序将检索和处理这些数据以匹配搜索词。匹配器文件夹包含程序支持的各类数据源的代码。目前程序只支持一个处理 RSS 类型数据源的匹配器。搜索文件夹包含使用不同匹配器搜索内容的业务逻辑。最后,我们有父文件夹 sample,其中包含 main.go 代码文件,它是程序的入口点。

现在你已经看到了程序所有代码的位置,你可以开始探索和理解程序是如何工作的。让我们从程序的入口点开始。

2.2. 主包

程序的入口点可以在 main.go 代码文件中找到。尽管只有 21 行代码,但还有一些事情正在进行,我们必须提及。

列表 2.2. main.go
01 package main
02
03 import (
04    "log"
05    "os"
06
07   _ "github.com/goinaction/code/chapter2/sample/matchers"
08    "github.com/goinaction/code/chapter2/sample/search"
09 )
10
11 // init is called prior to main.
12 func init() {
13     // Change the device for logging to stdout.
14     log.SetOutput(os.Stdout)
15 }
16
17 // main is the entry point for the program.
18 func main() {
19     // Perform the search for the specified term.
20     search.Run("president")
21 }

每个生成可执行文件的 Go 程序都有两个独特的特性。其中之一可以在第 18 行找到。在那里你可以看到函数 main 的声明。为了构建工具生成可执行文件,必须声明函数 main,它成为程序的入口点。第二个特性可以在程序的第 01 行找到。

列表 2.3. main.go: 行 01
01 package main

你可以看到函数 main 位于名为 main 的包中。如果你的 main 函数不在 main 包中,构建工具将不会生成可执行文件。

Go 中的每个代码文件都属于一个包,main.go 也不例外。我们将在 第三章 中详细介绍包,因为包是 Go 的重要特性。现在,理解包定义了一个编译单元,它们的名称有助于提供一种间接层,类似于命名空间。这使得能够在不同导入的包中区分具有完全相同名称的标识符。

现在将你的注意力转向 main.go 代码文件的第 03 行至第 09 行,这些行声明了导入。

列表 2.4. main.go: 行 03–09
03 import (
04    "log"
05    "os"
06
07   _ "github.com/goinaction/code/chapter2/sample/matchers"
08    "github.com/goinaction/code/chapter2/sample/search"
09 )

导入就是那样:它们导入代码,并允许你访问诸如类型、函数、常量和接口等标识符。在我们的例子中,由于第 08 行的导入,main.go 代码文件中的代码现在可以引用 search 包中的 Run 函数。在第 04 和第 05 行,我们导入了标准库中的 logos 包的代码。

文件夹中的所有代码文件必须使用相同的包名,通常的做法是将包名命名为文件夹名。正如之前所述,一个包定义了一个编译单元,每个代码单元代表一个包。如果你快速回顾一下 列表 2.1,你会看到在这个项目中有一个名为 search 的文件夹,它与第 08 行的导入路径相匹配。

你可能已经注意到,在第 07 行我们导入了matchers包,并在列出导入路径之前使用了空标识符。

列表 2.5. main.go: 第 07 行
07    _ "github.com/goinaction/code/chapter2/sample/matchers"

这是 Go 语言中的一种技术,允许即使没有直接使用包中的任何标识符,也能从包中进行初始化。为了使你的程序更易于阅读,Go 编译器不会让你声明一个未使用的包。空标识符允许编译器接受导入并调用该包内不同代码文件中可以找到的任何init函数。对于我们的程序来说,这是必需的,因为matchers包中的 rss.go 代码文件包含一个用于注册 RSS 匹配器以便使用的init函数。我们将在稍后回到这一切是如何工作的。

main.go 代码文件还有一个在 12 到 15 行声明的init函数。

列表 2.6. main.go: 第 11-15 行
11 // init is called prior to main.
12 func init() {
13     // Change the device for logging to stdout.
14     log.SetOutput(os.Stdout)
15 }

程序中任何代码文件中的所有init函数都会在main函数之前被调用。这个init函数将标准库中的记录器设置为写入stdout设备。默认情况下,记录器设置为写入stderr设备。在第七章中,我们将更详细地讨论log包和其他标准库中的重要包。

最后,让我们看看main函数在第 20 行执行的一个语句。

列表 2.7. main.go: 第 19-20 行
19     // Perform the search for the specified term.
20     search.Run("president")

这里你可以看到一个调用search包中的Run函数。这个函数包含了程序的核心业务逻辑,它需要一个字符串作为搜索项。一旦Run函数返回,程序将终止。

现在,我们可以查看属于search包的代码。

2.3. 搜索包

search包包含了程序框架和业务逻辑。该包组织成四个不同的代码文件,每个文件都有独特的职责。随着我们继续跟踪程序的逻辑,我们将探索这些不同的代码文件。

让我们简要地谈谈什么是匹配器,因为整个程序都是围绕匹配器的执行来进行的。在我们的程序中,匹配器是一个包含特定智能以处理特定类型源的数据值。在我们的程序中,我们有两个匹配器。框架实现了一个默认的匹配器,它没有智能,而在matchers包中,我们有一个 RSS 匹配器的实现。RSS 匹配器知道如何获取、读取和搜索 RSS 源。稍后我们可以扩展程序以使用能够读取 JSON 文档或 CSV 文件的匹配器。我们将在稍后详细讨论如何实现匹配器。

2.3.1. search.go

下面是可以在 search.go 代码文件中找到的前九行代码。这是Run函数所在的代码文件。

列表 2.8. search/search.go: 第 01-09 行
01 package search
02
03 import (
04     "log"
05     "sync"
06 )
07
08 // A map of registered matchers for searching.
09 var matchers = make(map[string]Matcher)

正如你所见,每个代码文件顶部都会包含 package 关键字和包的名称。搜索文件夹中的每个代码文件都将包含 search 作为包名。从第 03 行到第 06 行导入标准库中的 logsync 包。

当你从标准库导入代码时,你只需要引用包名,这与从标准库外部导入代码不同。编译器总是会查找你导入的包在 GOROOTGOPATH 环境变量引用的位置。

列表 2.9. GOROOTGOPATH 环境变量
GOROOT="/Users/me/go"
GOPATH="/Users/me/spaces/go/projects"

log 包提供了将日志消息输出到 stdoutstderr 或甚至自定义设备的支持。sync 包提供了同步 goroutines 的支持,这是我们的程序所必需的。在第 09 行你会看到我们的第一个变量声明。

列表 2.10. search/search.go: 行 08–09
08 // A map of registered matchers for searching.
09 var matchers = make(map[string]Matcher)

这个变量位于任何函数的作用域之外,因此被视为包级变量。变量使用 var 关键字声明,并声明为 Matcher 类型值的 map,其中键的类型为 stringMatcher 类型的声明可以在 match.go 代码文件中找到,我们将在后面描述这个类型的作用。这个变量声明的另一个重要方面是变量名 matchers 以小写字母开头。

在 Go 语言中,标识符要么从包中导出,要么未导出。导出的标识符可以在导入相应包时被其他包中的代码直接访问。这些标识符以大写字母开头。未导出的标识符以小写字母开头,不能被其他包中的代码直接访问。但是,即使标识符未导出,并不意味着其他包不能间接访问这些标识符。例如,一个函数可以返回一个未导出类型的值,而这个值可以被任何调用函数访问,即使调用函数在另一个包中声明。

这个变量声明还包含通过赋值运算符和特殊内置函数 make 对变量进行初始化。

列表 2.11. 创建一个 map
make(map[string]Matcher)

在 Go 语言中,map 是一种引用类型,你需要使用 make 关键字来创建它。如果你没有先创建 map 并将其分配给变量,当你尝试使用 map 变量时将会收到错误。这是因为 map 变量的零值是 nil。在第四章中,我们将更详细地介绍 map

在 Go 中,所有变量都被初始化为其零值。对于数值类型,该值是 0;对于字符串,它是空字符串;对于布尔值,它是 false;对于指针,零值是 nil。对于引用类型,存在初始化为零值的基本数据结构。但是,声明为引用类型并设置为零值的变量将返回 nil 的值。

现在,让我们逐步分析由 main 函数调用的 Run 函数,您之前已经见过它。

列表 2.12. search/search.go: 行 11–57
11 // Run performs the search logic.
12 func Run(searchTerm string) {
13     // Retrieve the list of feeds to search through.
14     feeds, err := RetrieveFeeds()
15     if err != nil {
16         log.Fatal(err)
17     }
18
19     // Create a unbuffered channel to receive match results.
20     results := make(chan *Result)
21
22     // Setup a wait group so we can process all the feeds.
23     var waitGroup sync.WaitGroup
24
25     // Set the number of goroutines we need to wait for while

26     // they process the individual feeds.
27     waitGroup.Add(len(feeds))
28
29     // Launch a goroutine for each feed to find the results.
30     for _, feed := range feeds {
31         // Retrieve a matcher for the search.
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }
36
37         // Launch the goroutine to perform the search.
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)
42     }
43
44     // Launch a goroutine to monitor when all the work is done.
45     go func() {
46         // Wait for everything to be processed.
47         waitGroup.Wait()
48
49         // Close the channel to signal to the Display
50         // function that we can exit the program.
51         close(results)
52     }()
53
54     // Start displaying results as they are available and
55     // return after the final result is displayed.
56     Display(results)
57 }

Run 函数包含程序的主要控制逻辑。它是 Go 程序如何结构化以处理并发运行的 goroutine 的良好示例。让我们逐节分析逻辑,然后探索提供支持的其它代码文件。

让我们回顾一下 Run 函数是如何声明的。

列表 2.13. search/search.go: 行 11–12
11 // Run performs the search logic.
12 func Run(searchTerm string) {

在 Go 中声明函数时,使用关键字 func 后跟函数名、任何参数以及任何返回值。对于 Run 来说,您有一个名为 searchTerm 的单个参数,其类型为 string。程序将要搜索的术语传递给 Run 函数,如果您再次查看 main 函数,您可以看到这一点。

列表 2.14. main.go: 行 17–21
17 // main is the entry point for the program.
18 func main() {
19     // Perform the search for the specified term.
20     search.Run("president")
21 }

Run 函数首先做的事情是检索数据源列表。这些源用于从互联网上拉取内容,然后与指定的搜索词进行匹配。

列表 2.15. search/search.go: 行 13–17
13     // Retrieve the list of feeds to search through.
14     feeds, err := RetrieveFeeds()
15     if err != nil {
16         log.Fatal(err)
17     }

这里有几个重要的概念需要我们逐一说明。您可以看到在第 14 行,我们调用了 RetrieveFeeds 函数。这个函数属于 search 包,并返回两个值。第一个返回值是 Feed 类型值的切片。切片是一个实现了动态数组的引用类型。在 Go 中,您使用切片来处理数据列表。第四章 对切片进行了更详细的介绍。

第二个返回值是一个错误。在第 15 行,错误值被评估以检查错误,如果确实发生了错误,则调用 log 包中的 Fatal 函数。Fatal 函数接受一个错误值,并在终止程序之前将其记录到终端窗口。

虽然这并不是 Go 独有的特性,但您可以看到我们的函数可以有多个返回值。声明返回值和错误值的函数,就像 RetrieveFeeds 函数一样,是很常见的。如果发生错误,永远不要相信函数返回的其他值。它们应该始终被忽略,否则您可能会使代码产生更多错误或恐慌。

让我们更仔细地看看函数返回的值是如何分配给变量的。

列表 2.16. search/search.go: 行 13–14
13     // Retrieve the list of feeds to search through.
14     feeds, err := RetrieveFeeds()

在这里,你可以看到短变量声明运算符(:=)的使用。这个运算符用于同时声明和初始化变量。返回的每个值的类型被编译器用来确定每个变量的类型。短变量声明运算符只是简化代码并使代码更易读的一种快捷方式。它声明的变量与使用 var 关键字声明的任何其他变量没有区别。

现在我们有了数据源列表,我们可以继续到下一行代码。

列表 2.17. search/search.go: 行 19–20
19     // Create a unbuffered channel to receive match results.
20     results := make(chan *Result)

在第 20 行,我们使用内置函数 make 创建一个无缓冲通道。我们使用短变量声明运算符通过调用 make 声明和初始化通道变量。在声明变量时,一个很好的经验法则是,当声明将初始化为零值的变量时使用 var 关键字,当你提供额外的初始化或进行函数调用时使用短变量声明运算符。

在 Go 语言中,通道(Channels)也是一种类似于映射(Maps)和切片(Slices)的引用类型,但通道实现了一个类型化的值队列,用于在 goroutines 之间通信数据。通道提供了固有的同步机制以确保通信的安全性。在第六章中,我们将更详细地介绍通道和 goroutines。

接下来的两行代码将在稍后用于防止程序在所有搜索处理完成之前终止。

列表 2.18. search/search.go: 行 22–27
22     // Setup a wait group so we can process all the feeds.
23     var waitGroup sync.WaitGroup
24
25     // Set the number of goroutines we need to wait for while
26     // they process the individual feeds.
27     waitGroup.Add(len(feeds))

在 Go 语言中,一旦 main 函数返回,程序就会终止。此时仍在运行的任何已启动的 goroutines 也将由 Go 运行时终止。当你编写并发程序时,最好在让 main 函数返回之前干净地终止所有已启动的 goroutines。编写能够干净启动和关闭的程序有助于减少错误并防止资源损坏。

我们的程序正在使用 sync 包中的 WaitGroup 来跟踪我们打算启动的所有 goroutines。WaitGroup 是跟踪 goroutine 完成其工作的一种很好的方式。WaitGroup 是一个计数信号量,我们将用它来计数完成工作的 goroutines。

在第 23 行,我们声明了一个来自 sync 包的 WaitGroup 类型的变量。然后在第 27 行,我们将 WaitGroup 变量的值设置为我们将要启动的 goroutines 的数量。正如你很快就会看到的,我们将使用各自的 goroutine 并发处理每个源数据。随着每个 goroutine 完成其工作,它将减少 WaitGroup 变量的计数,一旦变量变为零,我们就知道所有工作都完成了。

接下来,让我们看看启动每个源数据的 goroutines 的代码。

列表 2.19. search/search.go: 行 29–42
29     // Launch a goroutine for each feed to find the results.
30     for _, feed := range feeds {
31         // Retrieve a matcher for the search.
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }
36
37         // Launch the goroutine to perform the search.
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)
42     }

从第 30 行到第 42 行的代码遍历了我们之前检索到的数据流列表,并为每个数据流启动了一个 goroutine。为了遍历 feed 的切片,我们使用了for range关键字。range关键字可以与数组、字符串、切片、图和通道一起使用。当我们使用for range遍历切片时,我们每次迭代会返回两个值。第一个是我们正在迭代的元素的索引位置,第二个是该元素的值的副本。

如果你仔细看第 30 行的for range语句,你会再次看到空白标识符的使用。

列表 2.20. search/search.go: 行 29–30
29     // Launch a goroutine for each feed to find the results.
30     for _, feed := range feeds {

这是第二次看到空白标识符的使用。你第一次在main.go中看到它,当时我们导入了matchers包。现在它被用作替换为 range 调用索引值的变量。当你有一个返回多个值的函数,而你不需要其中一个值时,你可以使用空白标识符来忽略这些值。在我们的这个 range 中,我们不会使用索引值,所以空白标识符允许我们忽略它。

在循环中我们首先做的事情是检查图中是否存在用于处理特定数据类型数据流的Matcher值。

列表 2.21. search/search.go: 行 31–35
31         // Retrieve a matcher for the search.
32         matcher, exists := matchers[feed.Type]

33         if !exists {
34             matcher = matchers["default"]
35         }

我们还没有讨论这张图是如何获取其值的。你稍后将会看到程序是如何初始化自身并填充这张图的。在第 32 行,我们检查图中是否存在与数据类型匹配的键。在图中查找键时,你有两种选择:你可以为查找调用分配一个变量或两个变量。第一个变量始终是键查找返回的值,如果指定,第二个值是一个布尔标志,报告键是否存在。当键不存在时,图将返回存储在图中的值的零值。当键存在时,图将返回该键值的副本。

在第 33 行,我们检查键是否在图中找到,如果没有找到,我们分配默认的匹配器以供使用。这允许程序在没有引起任何问题或中断当前程序不支持的数据流的情况下运行。然后我们启动一个 goroutine 来执行搜索。

列表 2.22. search/search.go: 行 37–41
37         // Launch the goroutine to perform the search.
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)

在第六章中,我们将更详细地介绍 goroutines,但到目前为止,goroutine是一个独立于程序中其他函数运行的函数。使用go关键字来启动和调度 goroutines 以并发运行。在第 38 行,我们使用go关键字启动一个匿名函数作为 goroutine。匿名函数是一个没有声明的函数。在我们的for range循环中,我们为每个 feed 启动一个匿名函数作为 goroutine。这允许每个 feed 以并发方式独立处理。

匿名函数可以接受参数,我们为这个匿名函数声明了这些参数。在第 38 行,我们声明了匿名函数接受一个类型为Matcher的值和一个类型为Feed的值的地址。这意味着变量feed是一个指针变量。指针变量非常适合在函数之间共享变量。它们允许函数访问和更改在另一个函数的作用域内(可能是一个不同的 goroutine)声明的变量的状态。

在第 41 行,matcherfeed变量的值被传递到匿名函数中。在 Go 中,所有变量都是按值传递的。由于指针变量的值是指向被指向内存的地址,因此函数之间传递指针变量仍然被视为按值传递。

在第 39 行和第 40 行,你可以看到每个 goroutine 正在执行的工作。

列表 2.23. search/search.go: 第 39-40 行
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()

goroutine 首先调用一个名为Match的函数,该函数位于match.go代码文件中。Match函数接受一个类型为Matcher的值、一个类型为Feed的值的指针、搜索词以及将结果写入的通道。我们稍后会查看这个函数的内部结构,但就目前而言,知道Match将搜索 feed 并将匹配项输出到results通道就足够了。

一旦Match函数调用完成,我们执行第 40 行的代码,即递减WaitGroup计数。一旦每个 goroutine 完成调用Match函数和Done方法,程序将知道每个 feed 都已处理。关于Done方法调用的另一个有趣之处是:WaitGroup值从未作为参数传递给匿名函数,但匿名函数仍然可以访问它。

Go 支持闭包,你正在看到这一功能的应用。实际上,searchTermresults变量也通过闭包被匿名函数访问。多亏了闭包,函数可以直接访问这些变量,而无需将它们作为参数传递。匿名函数并没有得到这些变量的副本;它直接访问的是在外部函数作用域中声明的相同变量。这就是为什么我们不使用闭包来处理matcherfeed变量。

列表 2.24. search/search.go: 第 29-32 行
29     // Launch a goroutine for each feed to find the results.
30     for _, feed := range feeds {
31         // Retrieve a matcher for the search.
32         matcher, exists := matchers[feed.Type]

feedmatcher变量的值在每次循环迭代中都会改变,正如你在第 30 行和第 32 行所看到的。如果我们为这些变量使用闭包,由于这些变量在外部函数中的值发生了变化,这些变化会在匿名函数中反映出来。所有 goroutine 都会因为闭包而与外部函数共享相同的变量。除非我们将这些值作为函数参数传递,否则大多数 goroutine 最终会使用相同的 matcher 处理相同的 feed——很可能是feeds切片中的最后一个。

当所有的搜索 goroutine 正在运行,在 results 通道上发送结果并递减 waitGroup 计数时,我们需要一种方式来显示这些结果,并保持 main 函数在所有处理完成之前保持活跃。

列表 2.25. search/search.go: 行 44–57
44     // Launch a goroutine to monitor when all the work is done.
45     go func() {
46         // Wait for everything to be processed.

47         waitGroup.Wait()
48
49         // Close the channel to signal to the Display
50         // function that we can exit the program.
51         close(results)
52     }()
53
54     // Start displaying results as they are available and
55     // return after the final result is displayed.
56     Display(results)
57 }

在第 45 行到 56 行之间的代码难以解释,直到我们深入到 search 包中的其他一些代码。目前,让我们描述一下我们看到的内容,稍后再回来理解其机制。在第 45 行到 52 行,我们启动了另一个匿名函数作为 goroutine。这个匿名函数不接受任何参数,并使用闭包来访问 WaitGroupresults 变量。这个 goroutine 调用 WaitGroup 值上的 Wait 方法,这导致 goroutine 阻塞,直到 WaitGroup 的计数达到零。一旦发生这种情况,goroutine 就会在通道上调用内置函数 close,正如你将看到的,这会导致程序终止。

Run 函数中的最后一部分代码在第 56 行。这是一个调用 Display 函数的调用,该函数可以在 match.go 代码文件中找到。一旦这个函数返回,程序就会终止。这不会发生,直到通道中的所有结果都被处理。

2.3.2. feed.go

现在你已经看到了 Run 函数,让我们看看在 search.go 代码文件的第 14 行对 RetrieveFeeds 函数调用的代码。这个函数读取 data.json 文件,并返回数据源切片。这些源驱动着不同匹配器将要搜索的内容。以下是可以在 feed.go 代码文件中找到的前八行代码。

列表 2.26. feed.go: 行 01–08
01 package search
02
03 import (
04     "encoding/json"
05     "os"
06 )
07
08 const dataFile = "data/data.json"

这个代码文件存在于 search 文件夹中,在第 01 行代码文件被声明为包 search。你可以看到,在第 03 行到 06 行,我们从标准库中导入了两个包。json 包提供对编码和解码 JSON 的支持,而 os 包提供对访问操作系统功能(如读取文件)的支持。

你可能已经注意到,为了导入 json 包,我们需要指定一个包含编码文件夹的路径。无论我们指定什么路径,包的名称都是 json。包在标准库中的物理位置不会改变这一事实。当我们从 json 包访问功能时,我们将只使用名称 json

在第 08 行,我们声明了一个名为 dataFile 的常量,它被分配了一个字符串,指定了磁盘上数据文件的相对路径。由于 Go 编译器可以从赋值操作符右侧的值中推断出类型,因此在声明常量时指定类型是不必要的。我们还使用小写字母为常量命名,这意味着这个常量是未导出的,并且只能由 search 包内的代码直接访问。

接下来让我们看看 data.json 数据文件的一部分。

列表 2.27. data.json
[
    {
        "site" : "npr",
        "link" : "http://www.npr.org/rss/rss.php?id=1001",
        "type" : "rss"
    },
    {
        "site" : "cnn",
        "link" : "http://rss.cnn.com/rss/cnn_world.rss",
        "type" : "rss"
    },
    {
        "site" : "foxnews",
        "link" : "http://feeds.foxnews.com/foxnews/world?format=xml",
        "type" : "rss"
    },
    {
        "site" : "nbcnews",
        "link" : "http://feeds.nbcnews.com/feeds/topstories",
        "type" : "rss"
    }
]

实际的数据文件包含超过四个数据源,但列表 2.27 展示了数据文件的有效版本。数据文件包含一个 JSON 文档数组。数据文件中的每个文档都提供了我们从哪里获取数据的站点名称、数据链接以及我们期望接收的数据类型。

这些文档需要解码成结构体类型的切片,这样我们就可以在程序中使用这些数据。让我们看看将用于解码此数据文件的结构体类型。

列表 2.28. feed.go: 第 10-15 行
10 // Feed contains information we need to process a feed.
11 type Feed struct {
12     Name string `json:"site"`

13     URI  string `json:"link"`
14     Type string `json:"type"`
15 }

在第 11 行到第 15 行,我们声明了一个名为Feed的结构体类型,这是一个导出类型。该类型声明了三个字段,每个字段都是与数据文件中每个文档的字段匹配的字符串。如果你查看每个字段声明,标签已经包含在内,以提供 JSON 解码函数所需的元数据,以创建Feed类型值的切片。每个标签将结构体类型中的字段名称映射到文档中的字段名称。

现在,我们可以回顾在 search.go 代码文件的第 14 行调用的RetrieveFeeds函数。这是读取数据文件并将每个文档解码成Feed类型值切片的函数。

列表 2.29. feed.go: 第 17-36 行
17 // RetrieveFeeds reads and unmarshals the feed data file.
18 func RetrieveFeeds() ([]*Feed, error) {
19    // Open the file.
20    file, err := os.Open(dataFile)
21    if err != nil {
22        return nil, err
23    }
24
25    // Schedule the file to be closed once
26    // the function returns.
27    defer file.Close()
28
29    // Decode the file into a slice of pointers
30    // to Feed values.
31    var feeds []*Feed
32    err = json.NewDecoder(file).Decode(&feeds)
33
34    // We don't need to check for errors, the caller can do this.
35    return feeds, err
36 }

让我们从第 18 行的函数声明开始。该函数不接受任何参数,并返回两个值。第一个返回值是Feed类型值的指针切片。第二个返回值是错误值,用于报告函数调用是否成功。正如你将继续看到的那样,返回错误值是此代码示例和整个标准库中的常见做法。

现在,让我们看看第 20 行到第 23 行,我们在这里使用os包打开数据文件。对Open方法的调用接受我们数据文件的相对路径,并返回两个值。第一个返回值是指向类型为File的值的指针,第二个返回值是错误值,用于检查Open调用是否成功。在第 21 行立即检查错误值,如果确实有问题打开文件,则返回错误。

如果我们成功打开了文件,那么我们就转到第 27 行。在这里,你可以看到关键字defer的使用。

列表 2.30. feed.go: 第 25-27 行
25    // Schedule the file to be closed once
26    // the function returns.
27    defer file.Close()

关键字defer用于安排在函数返回后立即执行函数调用。一旦我们处理完文件,关闭文件就是我们的责任。通过使用关键字defer来安排对close方法的调用,我们可以保证该方法将被调用。即使函数发生恐慌并意外终止,也会发生这种情况。关键字defer让我们将此语句放置在文件打开附近,这有助于提高可读性并减少错误。

现在,我们可以回顾函数中的最后几行代码。让我们看看第 31 行到第 35 行。

列表 2.31. feed.go: 第 29-36 行
29    // Decode the file into a slice of pointers
30    // to Feed values.
31    var feeds []*Feed
32    err = json.NewDecoder(file).Decode(&feeds)
33
34    // We don't need to check for errors, the caller can do this.
35    return feeds, err
36 }

在第 31 行,我们声明了一个名为 feeds 的空切片,其中包含指向 Feed 类型值的指针。然后在第 32 行,我们调用 json 包中 NewDecoder 函数返回的值的 Decode 方法。NewDecoder 函数接受我们通过 Open 方法调用的文件句柄,并返回一个指向 Decoder 类型值的指针。从该值中,我们调用 Decode 方法,传递切片的地址。然后 Decode 方法解码数据文件,并将一组 Feed 类型值填充到我们的切片中。

由于其声明,Decode 方法可以接受任何类型的值。

列表 2.32. 使用空接口
func (dec *Decoder) Decode(v interface{}) error

Decode 方法的参数接受 interface{} 类型的值。这是 Go 语言中的一个特殊类型,它与 reflect 包中可找到的反射支持一起工作。在第九章 [kindle_split_017.html#ch09] 中,我们将更详细地介绍反射以及这个方法是如何工作的。

第 35 行的最后一行代码将切片和错误值返回给调用函数。在这种情况下,调用 Decode 后不需要函数检查错误值。函数已经完成,调用函数可以检查错误值并确定下一步要做什么。

现在是时候通过查看匹配器代码来了解搜索代码是如何支持不同类型的馈电实现的。

2.3.3. match.go/default.go

match.go 代码文件包含创建不同类型匹配器的支持,这些匹配器可以被搜索 Run 函数使用。让我们回到 Run 函数的代码,该函数使用不同类型的匹配器执行搜索。

列表 2.33. search/search.go: 行 29 - 42
29     // Launch a goroutine for each feed to find the results.
30     for _, feed := range feeds {
31         // Retrieve a matcher for the search.
32         matcher, exists := matchers[feed.Type]
33         if !exists {
34             matcher = matchers["default"]
35         }
36
37         // Launch the goroutine to perform the search.
38         go func(matcher Matcher, feed *Feed) {
39             Match(matcher, feed, searchTerm, results)
40             waitGroup.Done()
41         }(matcher, feed)
42     }

第 32 行的代码根据数据类型查找匹配器值;然后使用该值来处理对该特定数据源的搜索。然后在第 38 行到第 41 行,为该匹配器和数据源启动了一个 goroutine。使这段代码能够正常工作的关键是这个框架代码能够使用接口类型来捕获并调用每个匹配器值的特定实现。这允许代码以一致和通用的方式处理不同类型的匹配器值。让我们看看 match.go 中的代码,看看我们是如何实现这个功能的。

下面是 match.go 的前 17 行代码。

列表 2.34. search/match.go: 行 01–17
01 package search
02
03 import (
04     "log"
05 )
06
07 // Result contains the result of a search.
08 type Result struct {
09     Field   string
10     Content string
11 }
12

13 // Matcher defines the behavior required by types that want
14 // to implement a new search type.
15 type Matcher interface {
16     Search(feed *Feed, searchTerm string) ([]*Result, error)
17 }

让我们跳转到第 15 行到第 17 行,看看名为 Matcher 的接口类型的声明。到目前为止,我们只声明了结构体类型,但在这里你可以看到声明接口类型的代码。我们将在第五章 [kindle_split_013.html#ch05] 中详细介绍接口,但到目前为止,我们知道接口声明了结构体或命名类型必须实现的行为以满足接口。接口的行为由接口类型内声明的函数定义。

Matcher 接口的情况下,只声明了一个方法,即 Search,它接受一个指向 Feed 类型的值的指针和一个 string 类型的搜索词。该方法还返回两个值:一个指向 Result 类型值的指针切片和一个错误值。Result 类型在第 08 到 11 行声明。

在 Go 语言中命名接口时,你需要遵循一个命名约定。如果接口类型只包含一个方法,那么接口的名称应该以 er 后缀结尾。在我们的接口中,这正是这种情况,因此接口的名称是 Matcher。当在接口类型中声明多个方法时,接口的名称应该与其一般行为相关。

为了让用户定义的类型实现一个接口,该类型需要实现接口类型中声明的所有方法。让我们切换到 default.go 代码文件,看看默认匹配器是如何实现 Matcher 接口的。

列表 2.35. search/default.go: 行 01–15
01 package search
02
03 // defaultMatcher implements the default matcher.
04 type defaultMatcher struct{}
05
06 // init registers the default matcher with the program.
07 func init() {
08     var matcher defaultMatcher
09     Register("default", matcher)
10 }
11
12 // Search implements the behavior for the default matcher.
13 func (m defaultMatcher) Search(feed *Feed, searchTerm string)
                                                   ([]*Result, error) {
14     return nil, nil
15 }

在第 04 行,我们使用一个空的 struct 声明了一个名为 defaultMatcher 的 struct 类型。空的 struct 在创建此类类型的值时分配零字节。当你需要一个类型但不需要任何状态时,它们很棒。对于默认匹配器,我们不需要维护任何状态;我们只需要实现接口。

在第 13 到 15 行,你可以看到 defaultMatcher 类型实现了 Matcher 接口。接口方法 Search 的实现只是对两个返回值都返回 nil。其他实现,例如 RSS 匹配器的实现,将在这个方法的版本中实现特定的业务规则来处理搜索。

Search 方法的声明使用了一个类型为 defaultMatcher 的值接收者。

列表 2.36. search/default.go: 行 13
13 func (m defaultMatcher) Search

在任何函数声明中使用接收者声明了一个与指定接收者类型绑定的方法。在我们的例子中,Search 方法的声明现在绑定到了 defaultMatcher 类型的值。这意味着我们可以从 defaultMatcher 类型的值和指针中调用 Search 方法。无论我们使用接收者类型的值还是指针来调用方法,编译器都会在必要时引用或取消引用值以支持调用。

列表 2.37. 方法调用的示例
// Method declared with a value receiver of type defaultMatcher
func (m defaultMatcher) Search(feed *Feed, searchTerm string)

// Declare a pointer of type defaultMatch
dm := new(defaultMatch)

// The compiler will dereference the dm pointer to make the call
dm.Search(feed, "test")

// Method declared with a pointer receiver of type defaultMatcher
func (m *defaultMatcher) Search(feed *Feed, searchTerm string)

// Declare a value of type defaultMatch
var dm defaultMatch

// The compiler will reference the dm value to make the call
dm.Search(feed, "test")

使用指针接收者声明方法是一种最佳实践,因为许多你实现的方法需要操作用于方法调用的值的内部状态。在 defaultMatcher 类型的例子中,我们希望使用值接收者,因为创建 defaultMatcher 类型的值会导致零分配。使用指针没有意义,因为没有要操作的状态。

与直接从值和指针调用方法不同,当你通过接口类型值调用方法时,规则是不同的。具有指针接收器的声明的方法只能由包含指针的接口类型值调用。具有值接收器的声明的方法可以由包含值和指针的接口类型值调用。

列表 2.38. 接口方法调用限制示例
// Method declared with a pointer receiver of type defaultMatcher
func (m *defaultMatcher) Search(feed *Feed, searchTerm string)

// Call the method via an interface type value
var dm defaultMatcher
var matcher Matcher = dm     // Assign value to interface type
matcher.Search(feed, "test") // Call interface method with value

> go build
cannot use dm (type defaultMatcher) as type Matcher in assignment

// Method declared with a value receiver of type defaultMatcher
func (m defaultMatcher) Search(feed *Feed, searchTerm string)

// Call the method via an interface type value
var dm defaultMatcher
var matcher Matcher = &dm    // Assign pointer to interface type
matcher.Search(feed, "test") // Call interface method with pointer

> go build
Build Successful

defaultMatcher类型不需要做任何事情来实现接口。从现在开始,类型为defaultMatcher的值和指针满足接口,可以用作类型Matcher的值。这是使这一切工作的关键。类型为defaultMatcher的值和指针现在也是类型Matcher的值,可以分配或传递给接受类型Matcher的值的函数。

让我们来看看在 match.go 代码文件中声明的Match函数的实现。这是在 search.go 代码文件的第 39 行由Run函数调用的函数。

列表 2.39. search/match.go: 行 19–33
19 // Match is launched as a goroutine for each individual feed to run
20 // searches concurrently.
21 func Match(matcher Matcher, feed *Feed, searchTerm string,
                                              results chan<- *Result) {
22     // Perform the search against the specified matcher.
23     searchResults, err := matcher.Search(feed, searchTerm)
24     if err != nil {
25         log.Println(err)
26         return
27     }
28
29     // Write the results to the channel.
30     for _, result := range searchResults {
31         results <- result
32     }
33 }

这是使用实现Matcher接口的值或指针执行实际搜索的函数。这个函数接受类型为Matcher的值作为第一个参数。只有实现Matcher接口的值或指针会被接受作为此参数。由于defaultMatcher类型现在实现了具有值接收器的接口,因此可以传递类型为defaultMatcher的值或指针到这个函数中。

在第 23 行,从传递给函数的Matcher类型值中调用了Search方法。这里执行了分配给Matcher变量的Search方法的具体实现。一旦Search方法返回,就会检查第 24 行的错误值是否存在错误。如果有错误,函数将错误写入日志并返回。如果没有返回错误并且有结果,结果会被写入通道,以便它们可以被监听该通道的主函数获取。

match.go 中的最后一部分代码是Display函数,它在 main 函数的第 56 行被调用。这是防止程序在收到并记录所有搜索 goroutine 的结果之前终止的函数。

列表 2.40. search/match.go: 行 35–43
35 // Display writes results to the terminal window as they
36 // are received by the individual goroutines.
37 func Display(results chan *Result) {
38     // The channel blocks until a result is written to the channel.
39     // Once the channel is closed the for loop terminates.
40     for result := range results {
41         fmt.Printf("%s:\n%s\n\n", result.Field, result.Content)
42     }
43 }

一点通道魔法允许这个函数在返回之前处理所有结果。它基于通道关闭时通道和关键字range的行为。让我们再次简要看看Run函数中的代码,该函数关闭results通道并调用Display函数。

列表 2.41. search/search.go: 行 44–57
44     // Launch a goroutine to monitor when all the work is done.
45     go func() {
46         // Wait for everything to be processed.
47         waitGroup.Wait()
48
49         // Close the channel to signal to the Display
50         // function that we can exit the program.
51         close(results)
52     }()
53
54     // Start displaying results as they are available and
55     // return after the final result is displayed.
56     Display(results)
57 }

第 45 至 52 行的 goroutine 等待waitGroup,直到所有搜索 goroutine 调用Done方法。一旦最后一个搜索 goroutine 调用DoneWait方法返回,然后第 51 行的代码关闭results通道。一旦通道关闭,goroutine 终止,不再存在。

您在 match.go 代码文件的第 30 至 32 行看到了搜索结果被写入通道。

列表 2.42. search/match.go: 行 29–32
29     // Write the results to the channel.
30     for _, result := range searchResults {
31         results <- result
32     }

如果我们回顾 match.go 代码文件的第 40 至 42 行的for range循环,我们可以将结果的写入、通道的关闭以及结果的处理全部联系起来。

列表 2.43. search/match.go: 行 38–42
38     // The channel blocks until a result is written to the channel.
39     // Once the channel is closed the for loop terminates.
40     for result := range results {
41         log.Printf("%s:\n%s\n\n", result.Field, result.Content)
42     }

match.go 代码文件第 40 行的for range循环将阻塞,直到通道中写入结果。随着每个搜索 goroutine 将结果写入通道(如代码文件 match.go 中的第 31 行所示),for range循环会唤醒并得到这些结果。然后,结果立即写入日志。这似乎表明for range循环陷入了一个无限循环,但实际上并非如此。一旦在 search.go 代码文件的第 51 行关闭通道,for range循环就会终止,Display函数返回。

在我们查看 RSS 匹配器的实现之前,让我们回顾一下程序启动时不同匹配器的初始化方式。为了看到这一点,我们需要回顾默认的.go 代码文件的第 07 至 10 行。

列表 2.44. search/default.go: 行 06–10
06 // init registers the default matcher with the program.
07 func init() {
08     var matcher defaultMatcher
09     Register("default", matcher)
10 }

默认的.go 代码文件中声明了一个特殊函数,称为init。您也曾在main.go代码文件中看到过这个函数的声明,我们讨论了程序中所有init函数在main函数开始之前会被调用的方式。让我们再次从main.go代码文件中查看导入。

列表 2.45. main.go: 行 07–08
07   _ "github.com/goinaction/code/chapter2/sample/matchers"
08    "github.com/goinaction/code/chapter2/sample/search"

第 08 行的search包导入允许编译器在默认的.go 代码文件中找到init函数。一旦编译器看到init函数,它就会被安排在调用main函数之前被调用。

默认的.go 代码文件中的init函数正在执行一个特殊任务。它正在创建一个defaultMatcher类型的值,并将该值传递给可以在search.go代码文件中找到的Register函数。

列表 2.46. search/search.go: 行 59–67
59 // Register is called to register a matcher for use by the program.
60 func Register(feedType string, matcher Matcher) {
61     if _, exists := matchers[feedType]; exists {
62         log.Fatalln(feedType, "Matcher already registered")
63     }
64
65     log.Println("Register", feedType, "matcher")
66     matchers[feedType] = matcher
67 }

此函数负责将Matcher值添加到已注册匹配器的映射中。所有这些注册都需要在调用main函数之前完成。使用init函数是完成此类初始化注册的绝佳方式。

2.4. RSS 匹配器

最后要审查的代码是 RSS 匹配器的实现。到目前为止我们所审查的一切都是为了允许不同的匹配器类型在程序框架内运行和搜索内容。RSS 匹配器的结构类似于默认匹配器的结构。不同之处在于接口方法 Search 的实现,最终为每个匹配器赋予其独特性。

列表 2.47 中的 RSS 文档展示了当我们使用任何以 RSS 格式输入的数据源中的链接时,我们期望接收到的样本。

列表 2.47. 预期 RSS 文档
<rss  xmlns:nprml="http://api
    <channel>
        <title>News</title>
        <link>...</link>
        <description>...</description>

        <language>en</language>
        <copyright>Copyright 2014 NPR - For Personal Use
        <image>...</image>
        <item>
            <title>
                Putin Says He'll Respect Ukraine Vote But U.S.
            </title>
            <description>
                The White House and State Department have called on the
            </description>

如果你从 列表 2.47 中取任何链接并在浏览器中打开,你将能够看到预期 RSS 文档的完整视图。RSS 匹配器的实现会下载这些 RSS 文档,搜索标题和描述字段中的搜索词,并通过 results 通道发送结果。让我们先看看 rss.go 代码文件的前 12 行代码。

列表 2.48. matchers/rss.go: 行 01–12
01 package matchers
02
03 import (
04     "encoding/xml"
05     "errors"
06     "fmt"
07     "log"
08     "net/http"
09     "regexp"
10
11     "github.com/goinaction/code/chapter2/sample/search"
12 )

与每个代码文件一样,我们从第 01 行开始,写下包的名称。这个代码文件可以在名为 matchers 的文件夹中找到,因此包名是 matchers。接下来,我们有六个来自标准库的导入和一个到 search 包的导入。同样,我们还有一些来自标准库的包是从标准库内的子文件夹导入的,例如 xmlhttp。就像对 json 包一样,路径中的最后一个文件夹的名称代表包的名称。

有四种结构类型用于解码 RSS 文档,因此我们可以在程序中使用文档数据。

列表 2.49. matchers/rss.go: 行 14–58
14 type (
15     // item defines the fields associated with the item tag
16     // in the rss document.
17     item struct {
18         XMLName     xml.Name `xml:"item"`
19         PubDate     string   `xml:"pubDate"`
20         Title       string   `xml:"title"`
21         Description string   `xml:"description"`

22         Link        string   `xml:"link"`
23         GUID        string   `xml:"guid"`
24         GeoRssPoint string   `xml:"georss:point"`
25     }
26
27     // image defines the fields associated with the image tag
28     // in the rss document.
29     image struct {
30         XMLName xml.Name `xml:"image"`
31         URL     string   `xml:"url"`
32         Title   string   `xml:"title"`
33         Link    string   `xml:"link"`
34     }
35
36     // channel defines the fields associated with the channel tag
37     // in the rss document.
38     channel struct {
39         XMLName        xml.Name `xml:"channel"`
40         Title          string   `xml:"title"`
41         Description    string   `xml:"description"`
42         Link           string   `xml:"link"`
43         PubDate        string   `xml:"pubDate"`
44         LastBuildDate  string   `xml:"lastBuildDate"`
45         TTL            string   `xml:"ttl"`
46         Language       string   `xml:"language"`
47         ManagingEditor string   `xml:"managingEditor"`
48         WebMaster      string   `xml:"webMaster"`
49         Image          image    `xml:"image"`
50         Item           []item   `xml:"item"`
51    }
52
53    // rssDocument defines the fields associated with the rss document
54    rssDocument struct {
55         XMLName xml.Name `xml:"rss"`
56         Channel channel  `xml:"channel"`
57    }
58 )

如果你将这些结构与任何来自 feed 链接的 RSS 文档进行匹配,你将看到它们是如何相互关联的。在 feed.go 代码文件中解码 XML 与我们解码 JSON 的方式相同。接下来我们可以看看 rssMatcher 类型的声明。

列表 2.50. matchers/rss.go: 行 60–61
60 // rssMatcher implements the Matcher interface.
61 type rssMatcher struct{}

再次,这就像我们声明 defaultMatcher 类型一样。我们使用一个空的 struct,因为我们不需要维护任何状态;我们只是实现了 Matcher 接口。接下来是匹配器 init 函数的实现。

列表 2.51. matchers/rss.go: 行 63–67
63 // init registers the matcher with the program.
64 func init() {
65     var matcher rssMatcher
66     search.Register("rss", matcher)
67 }

就像你看到默认匹配器一样,init 函数将 rssMatcher 类型的值注册到程序中以供使用。让我们再次看看 main.go 代码文件中的导入。

列表 2.52. main.go: 行 07–08
07   _ "github.com/goinaction/code/chapter2/sample/matchers"
08    "github.com/goinaction/code/chapter2/sample/search"

main.go代码文件中的代码不直接使用matchers包中的任何标识符。然而,我们需要编译器在rss.go代码文件中调度对init函数的调用。在第 07 行,我们通过使用空标识符作为导入的别名名称来完成此操作。这允许编译器不产生导入声明错误,并定位到init函数。在设置完所有导入、类型和初始化后,让我们看看剩下的两个支持实现Matcher接口的方法。

列表 2.53. matchers/rss.go: 行 114–140
114 // retrieve performs a HTTP Get request for the rss feed and decodes
115 func (m rssMatcher) retrieve(feed *search.Feed)
                                                 (*rssDocument, error) {
116     if feed.URI == "" {
117         return nil, errors.New("No rss feed URI provided")
118     }
119
120     // Retrieve the rss feed document from the web.
121     resp, err := http.Get(feed.URI)
122     if err != nil {
123         return nil, err
124     }
125
126     // Close the response once we return from the function.
127     defer resp.Body.Close()
128
129     // Check the status code for a 200 so we know we have received a
130     // proper response.
131     if resp.StatusCode != 200 {
132         return nil, fmt.Errorf("HTTP Response Error %d\n",
                                                        resp.StatusCode)
133     }
134
135     // Decode the rss feed document into our struct type.

136     // We don't need to check for errors, the caller can do this.
137     var document rssDocument
138     err = xml.NewDecoder(resp.Body).Decode(&document)
139     return &document, err
140 }

未导出的retrieve方法执行从网络中拉取每个单独的 feed 链接的 RSS 文档的逻辑。在第 121 行,你可以看到http包中Get方法的使用。在第八章([kindle_split_016.html#ch08])中,我们将更深入地探讨这个包,但到目前为止,Go 使用http包使网络请求变得非常容易。当Get方法返回时,我们将得到一个指向Response类型值的指针。在检查错误后,我们需要调度对Close方法的调用,我们在第 127 行这样做。

在第 131 行,我们检查Response值的StatusCode字段,以验证我们收到了200。任何不是200的值都必须被视为错误,我们正是这样做的。如果值不是200,我们使用fmt包中的Errorf函数返回一个自定义错误。代码的最后三行与解码 JSON 数据文件的方式类似。这次我们使用xml包并调用名为NewDecoder的相同函数,它返回一个指向Decoder值的指针。有了这个指针,我们调用Decode方法,传递名为document的本地变量的地址,该变量是rssDocument类型。然后返回rssDocument类型值的地址和Decode方法调用的错误。

最后一种方法实现了Matcher接口。

列表 2.54. matchers/rss.go: 行 69–112
 69 // Search looks at the document for the specified search term.
 70 func (m rssMatcher) Search(feed *search.Feed, searchTerm string)
                                            ([]*search.Result, error) {
 71     var results []*search.Result
 72
 73     log.Printf("Search Feed Type[%s] Site[%s] For Uri[%s]\n",
                                        feed.Type, feed.Name, feed.URI)
 74
 75     // Retrieve the data to search.
 76     document, err := m.retrieve(feed)
 77     if err != nil {
 78         return nil, err
 79     }
 80
 81     for _, channelItem := range document.Channel.Item {
 82         // Check the title for the search term.
 83         matched, err := regexp.MatchString(searchTerm,
                                                     channelItem.Title)
 84         if err != nil {
 85             return nil, err
 86         }
 87
 88         // If we found a match save the result.
 89         if matched {
 90            results = append(results, &search.Result{
 91                Field:   "Title",
 92                Content: channelItem.Title,

 93            })
 94         }
 95
 96         // Check the description for the search term.
 97         matched, err = regexp.MatchString(searchTerm,
                                               channelItem.Description)
 98         if err != nil {
 99             return nil, err
100         }
101
102         // If we found a match save the result.
103         if matched {
104             results = append(results, &search.Result{
105                 Field:   "Description",
106                 Content: channelItem.Description,
107             })
108         }
109     }
110
111     return results, nil
112 }

我们从第 71 行的results变量的声明开始,该变量将用于存储和返回可能找到的任何结果。

列表 2.55. matchers/rss.go: 行 71
71      var results []*search.Result

我们使用关键字var并声明一个指向Result类型值的nil切片。Result类型的声明可以在match.go代码文件的第 08 行再次找到。接下来在第 76 行,我们使用我们刚刚审查的retrieve方法进行网络调用。

列表 2.56. matchers/rss.go: 行 75–79
75      // Retrieve the data to search.
76      document, err := m.retrieve(feed)
77      if err != nil {
78          return nil, err
79      }

retrieve方法的调用返回一个指向rssDocument类型值的指针和一个错误值。然后,正如你在代码中看到的,我们检查错误值以查找错误,并在存在错误时返回。如果没有错误,我们接着遍历结果,将搜索词与检索到的 RSS 文档的标题和描述进行匹配。

列表 2.57. matchers/rss.go: 行 81–86
81      for _, channelItem := range document.Channel.Item {
82          // Check the title for the search term.
83          matched, err := regexp.MatchString(searchTerm,
                                                      channelItem.Title)

84          if err != nil {
85              return nil, err
86          }

由于document.Channel.Item的值是item类型值的切片,我们在第 81 行使用for range循环遍历所有项目。在第 83 行,我们使用regexp包中的MatchString函数将搜索词与channelItem值中的Title字段内容进行匹配。然后我们在第 84 行检查错误。如果没有错误,我们转到第 89 行至第 94 行以检查匹配的结果。

列表 2.58. 匹配器/rss.go:第 88-94 行
88          // If we found a match save the result.
89          if matched {
90             results = append(results, &search.Result{
91                 Field:   "Title",
92                 Content: channelItem.Title,
93             })
94          }

在调用MatchString方法后,如果matched的值为true,我们使用内置函数append将搜索结果添加到results切片中。内置函数append将在需要时增加切片的长度和容量。你将在第四章中了解更多关于内置函数append的信息。append的第一个参数是你想要附加的切片的值,第二个参数是你想要附加的值。在我们的情况下,我们使用结构字面量声明并初始化一个类型为Result的值,然后我们使用反引号(&)运算符获取这个新值的地址,该地址存储在切片中。

标题检查匹配后,第 97 行至第 108 行再次对描述字段执行相同的逻辑。最后,在第 111 行,该方法将结果返回给调用函数。

2.5. 摘要

  • 每个代码文件都属于一个包,并且该包名应该与代码文件所在的文件夹名称相同。

  • Go 提供了多种声明和初始化变量的方式。如果一个变量的值没有明确初始化,编译器将变量初始化为其零值。

  • 指针是跨函数和 goroutine 共享数据的一种方式。

  • 通过启动 goroutine 和使用通道来实现并发和同步。

  • Go 提供了内置函数来支持使用 Go 的内部数据结构。

  • 标准库包含许多包,这些包将允许你做一些强大的事情。

  • Go 中的接口允许你编写通用代码和框架。

第三章. 打包和工具

本章内容

  • 理解 Go 代码的组织方式

  • 使用 Go 命令

  • 进一步使用其他 Go 开发者工具

  • 与其他 Go 开发者协作

在 第二章 中,你了解了 Go 的语法和语言结构概述。现在,你将深入了解代码是如何组织成包以及如何与这些包交互的。包是 Go 中的一个关键概念。想法是将功能语义单元分离到不同的包中。当你这样做时,你就可以实现代码重用并控制每个包内部数据的使用。

在我们深入细节之前,你应该已经熟悉命令提示符或系统外壳,并且应该根据本书前言中的指南安装了 Go。如果你准备好了,让我们先了解什么是包以及为什么它在 Go 生态系统中很重要。

3.1. 包

所有 Go 程序都组织成称为 的文件组,这样代码就有能力作为更小的可重用部分被包含到其他项目中。让我们看看组成 Go 标准库中 http 功能的包:

net/http/
    cgi/
    cookiejar/
        testdata/
    fcgi/
    httptest/
    httputil/
    pprof/
    testdata/

这些目录包含一系列具有 .go 扩展名的相关文件,并为与 HTTP 服务器、客户端的实现以及测试和性能分析相关的较小代码单元提供了清晰的分离。例如,cookiejar 包包含与从网络会话中存储和检索 Cookie 相关的代码。每个包都可以单独导入和使用,这样开发者就可以只导入他们需要的特定功能。如果你正在实现 HTTP 客户端,你只需要导入 http 包。

所有 .go 文件都必须在文件的第一行(不包括空白和注释)声明它们所属的包。包包含在单个目录中。你不可能在同一个目录中有多个包,也不能将一个包拆分到多个目录中。这意味着一个目录中的所有 .go 文件都必须声明相同的包名。

3.1.1. 包命名约定

为你的包命名时,应使用包含它的目录的名称。这样做的好处是,当你导入它时,可以清楚地知道包名。如果我们继续以 net/http 包为例,http 目录中包含的所有文件都是 http 包的一部分。在命名你的包及其目录时,应使用简短、简洁的小写名称,因为你在开发过程中会经常输入它们。net/http 下的包是简洁命名的好例子,如 cgihttputilpprof

请记住,不需要唯一的名称,因为你可以使用其完整路径来导入包。你的包名在导入包时用作默认名称,但可以被覆盖。当你需要导入具有相同名称的多个包时,这很有用。我们将在 3.2 节 中讨论如何实现这一点。

3.1.2. main 包

在 Go 语言中,包名 main 具有特殊含义。它指定了该包将被编译成二进制可执行文件的 Go 命令。你用 Go 语言构建的所有可执行程序都必须有一个名为 main 的包。

当编译器遇到 main 包时,它还必须找到一个名为 main() 的函数;否则不会创建二进制可执行文件。main() 函数是程序的入口点,所以如果没有它,程序就没有起始点。最终二进制文件的名字将采用声明 main 包的目录的名称。

命令和包

Go 语言的文档经常使用术语 命令 来指代可执行程序——就像命令行应用程序。这对阅读文档的新 Go 开发者来说可能会有些困惑。记住,在 Go 语言中,命令是任何可执行程序,而与包相对,包通常意味着一个可导入的功能语义单元。

尝试一下。首先在 GOPATH/src/hello/ 目录下创建一个名为 hello.go 的文件,并将 列表 3.1 的内容输入进去。这是传统的 Hello World! 应用程序,但当你查看它时,请注意包声明和导入语句。

列表 3.1. 传统的 Hello World! 应用程序

获取包文档

不要忘记,你可以通过访问 golang.org/pkg/fmt/ 或在终端运行 godoc fmt 来获取有关包的更多详细信息。

保存文件后,你可以在 GOPATH/src/hello/ 目录下运行 go build 命令。完成之后,你应该会看到一个二进制文件。在 Unix、Linux 和 Mac OS X 上,该文件将被命名为 hello,而在 Windows 上则会被命名为 hello.exe。现在你可以运行这个应用程序,并看到 Hello World! 打印到你的控制台。

如果你将包命名为 main 以外的名称,比如 hello,那么你就是在告诉编译器这只是一个包,而不是一个命令。

列表 3.2. 包含 main 函数的无效 Go 程序
01 package hello
02
03 import "fmt"
04
05 func main(){
06     fmt.Println("Hello, World!")
07 }

3.2. 导入

现在我们已经了解了代码组织成包的方式,我们将看看如何导入这些单个包,这样你就可以访问它们包含的代码。import语句告诉编译器在哪里查找磁盘以找到你想要导入的包。你通过使用关键字import来导入包,这告诉编译器你想要引用该文件位置中包包含的代码。如果你需要导入多个包,按照惯例,你可以将导入语句包裹在一个导入块中,如下所示。

列表 3.3. 导入语句块

图片

包是根据它们相对于 Go 环境引用的目录的相对路径在磁盘上找到的。标准库中的包可以在你的计算机上 Go 安装的位置找到。你或其他 Go 开发者创建的包位于GOPATH中,这是你自己的个人包工作区。

让我们来看一个例子。如果 Go 安装在/usr/local/go下,你的GOPATH设置为/home/myproject:/home/mylibraries,编译器将按以下顺序寻找net/http包:

图片

编译器一旦找到满足导入语句的包,就会停止搜索。重要的是要记住,Go 安装目录是编译器首先搜索的地方,然后是按照列表顺序在你的GOPATH中列出的每个目录。

如果编译器搜索你的GOPATH却找不到你引用的包,当你尝试运行或构建程序时,你会得到一个错误。你将在本章后面看到如何使用go get命令来解决这些问题。

3.2.1. 远程导入

通过分布式版本控制系统(DVCS)如 GitHub、Launchpad 和 Bitbucket 等共享代码的趋势正在迅速增长。Go 工具集内置了对从这些站点和其他站点获取源代码的支持。导入路径可以被 Go 工具集用来确定需要从网络上获取的代码的位置。

例如:

import "github.com/spf13/viper"

当你尝试使用这个导入路径构建程序时,go build命令将在磁盘上搜索这个包的位置。对于go build命令来说,它代表 GitHub 上的存储库的 URL 是不相关的。当一个导入路径包含 URL 时,可以使用 Go 工具从 DVCS 获取包,并将代码放置在GOPATH中与 URL 匹配的位置。这个获取操作是通过go get命令完成的。go get可以获取任何指定的 URL,也可以用来获取包导入的依赖项,这些依赖项是可以通过go get获取的。由于go get是递归的,它可以遍历包的源代码树并获取它找到的所有依赖项。

3.2.2. 命名导入

当你需要导入具有相同名称的多个包时会发生什么?例如,你可能需要一个network/convert包来转换从网络读取的数据,以及一个file/convert包来转换从文本文件读取的数据。在这种情况下,这两个包都可以通过使用命名导入来导入。这是通过在import语句左侧给其中一个包赋予一个新名称来实现的。

例如,假设你已经使用了标准库中的fmt包。现在你需要导入一个名为fmt的包,它是你项目的一部分。你可以通过重命名导入来导入你自己的fmt包,如下一列表所示。

列表 3.4. 重命名导入
01 package main
02
03 import (
04     "fmt"
05     myfmt "mylib/fmt"
06 )
07
08 func main() {
09     fmt.Println("Standard Library")
10     myfmt.Println("mylib/fmt")
11 }

当你导入一个你未使用的包时,Go 编译器将失败构建并输出错误。Go 团队认为这是一个特性,用于消除未使用但导入的包中的代码膨胀。尽管这个特性有时会令人烦恼,但 Go 团队已经投入了大量精力来做出决策,以防止你在其他语言中遇到的一些问题。你不想有一个不必要的大的二进制文件,充满了未使用的库,他们认为如果编译器告诉你这一点,那么构建失败也是值得的。任何编译过大型 C 程序的人都知道,在编译器警告的海洋中确定重要的事情是多么困难。

有时候你可能需要导入一个你不需要引用标识符的包。你将在下一节中看到这可能会很有用。在这种情况下,你可以使用空白标识符_来重命名一个导入。

空白标识符

_(下划线字符)被称为空白标识符,在 Go 中有许多用途。当你想要丢弃一个值的赋值,包括将导入赋值给其包名,或者当你只对其他返回值感兴趣时忽略函数的返回值时,就会用到它。

3.3. 初始化

每个包都有能力提供必要的init函数,以便在执行时间开始时调用。编译器发现的所有的init函数都安排在main函数执行之前执行。init函数非常适合设置包、初始化变量或执行程序运行之前可能需要的任何其他引导操作。

这里的一个例子是数据库驱动程序。当它们的init函数在启动时执行时,它们会将自己注册到sql包中,因为sql包在编译时无法知道存在的驱动程序。让我们看看一个init函数可能执行的操作的例子。

列表 3.5. init函数使用

图片

这段代码位于你虚构的 PostgreSQL 数据库驱动程序内部。当程序导入这个包时,init 函数将被调用,导致数据库驱动程序在 Go 的 sql 包中注册为可用驱动程序。

在我们使用这个新数据库驱动程序编写的程序中,我们将使用空标识符来导入包,以便新驱动程序与 sql 包一起包含。如前所述,你不能导入你未使用的包,因此使用空标识符重命名导入允许 init 函数被找到并安排运行,而不会导致编译器发出关于未使用导入的错误。

现在,我们可以告诉 sql.Open 方法使用这个驱动程序。

列表 3.6. 空标识符导入别名

3.4. 使用 Go 工具

我们现在已经使用 go 工具工作了几个章节,但我们还没有探索它所能做的所有事情。让我们更深入地研究这个名字简短的强大工具,并探索更多它的功能。从 shell 提示符中,不带参数输入 go 命令:

$ go

如 图 3.1 所示,go 工具集中隐藏了很多功能。

图 3.1. go 命令帮助文本的输出

查看列表,你会发现其中确实有一个编译器;它被 build 命令所使用。buildclean 命令确实做了你期望它们做的事情。现在,请使用 列表 3.2 中的源代码尝试它们:

go build hello.go

当你准备将代码检查到源代码控制时,可能不希望那个文件还挂在那里。要删除它,请使用 clean 命令:

go clean hello.go

在调用 clean 命令后,可执行程序将消失。让我们更深入地了解一下 go 工具的一些特性,以及在使用它时可以节省时间的方法。在接下来的示例中,我们将使用以下列表中的示例代码。

列表 3.7. 使用 io
01 package main
02
03 import (
04     "fmt"
05     "io/ioutil"
06     "os"
07
08     "github.com/goinaction/code/chapter3/words"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     filename := os.Args[1]
14
15     contents, err := ioutil.ReadFile(filename)
16     if err != nil {
17         fmt.Println(err)
18         return
19     }
20
21     text := string(contents)
22
23     count := words.CountWords(text)
24     fmt.Printf("There are %d words in your text. \n", count)
25 }

如果你已经下载了本书的源代码,这个包应该在 GOPATH/src/github.com/goinaction/code/chapter3/words。确保你已经在那里,以便跟随。

Go 工具集的大部分命令都接受包指定符作为参数。更仔细地看看我们刚刚使用的命令,你会发现工具集内置了一个快捷方式。你可以省略你想要构建的源代码文件的文件名,go 工具将默认为 当前包

go build

构建一个包是一种常见的做法,你也可以直接指定包:

go build github.com/goinaction/code/chapter3/wordcount

你也可以在你的包指定符中指定通配符。在你的包指定符中有三个点表示匹配任何字符串的模式。例如,以下命令将构建 chapter3 目录下的所有包:

go build github.com/goinaction/code/chapter3/...

除了包指定符之外,你还可以使用路径快捷方式作为大多数 Go 命令的参数。例如,你可以使用这两个命令达到相同的效果:

go build wordcount.go
go build .

要执行此程序,你需要运行在构建后创建的 wordcountwordcount.exe 程序。但有一个不同的命令可以在单个调用中执行这两个操作:

go run wordcount.go

go run 命令既构建又执行 wordcount.go 中包含的程序,这大大减少了输入量。

当你在开发时,你将最频繁地使用 go buildgo run 命令。让我们看看一些其他可用的命令,看看它们能做什么。

3.5. 使用 Go 开发者工具进一步探索

你已经看到了如何使用方便的 go 工具编译和运行你的 Go 程序。但这个小巧的开发者工具里面还隐藏着很多其他技巧。

3.5.1. go vet

它不会为你编写代码,但一旦你编写了一些代码,vet 命令将检查你的代码中的常见错误。让我们看看 vet 可以捕获的错误类型:

  • Printf 风格函数调用中的参数错误

  • 常见方法定义的方法签名错误

  • 结构体标签错误

  • 未标记的复合字面量

让我们看看许多新 Go 开发者会犯的一个错误。fmt.Printf 函数是生成格式化输出的好方法,但该函数要求你记住所有不同的格式说明符。以下是一个示例。

列表 3.8. 使用 go vet
01 package main
02
03 import "fmt"
04
05 func main() {
06     fmt.Printf("The quick brown fox jumped over lazy dogs", 3.14)
07 }

此程序插入浮点数 3.14,但格式化字符串中没有占位符。如果你对此源代码运行 go vet,你会得到以下消息:

go vet main.go

main.go:6: no formatting directive in Printf call

go vet 工具不会阻止你在逻辑上犯大错误,或创建有缺陷的代码。然而,正如你从最后一个示例中看到的,它确实很好地捕获了一些常见错误。在你将代码提交到源代码库之前运行 go vet 是一个好主意。

3.5.2. Go 格式

fmt 命令是 Go 社区中的最爱。而不是争论花括号应该放在哪里,或者缩进时是使用制表符还是空格,fmt 工具通过应用预定的布局到 Go 源代码来使这些决定变得无关紧要。要调用此代码格式化工具,请输入 go fmt 后跟文件或包规范。fmt 命令将自动格式化你指定的源代码文件并将它们保存。以下是经过 go fmt 处理的几行代码的前后快照:

if err != nil { return err }

在对此代码运行 go fmt 之后,你会得到以下结果:

if err != nil {
    return err
}

许多 Go 开发者将他们的开发环境配置为在保存或提交到代码仓库之前执行 go fmt。现在就为自己做这件事吧。

3.5.3. Go 文档

还有另一个工具可以使您的 Go 开发过程更加容易。Go 有两种方式将文档提供给开发者。如果您在命令提示符下工作,可以使用 go doc 命令直接将文档打印到您的终端会话。您可以在不离开终端的情况下查看命令或包的快速参考。但如果浏览界面更符合您的速度,您可以使用 godoc 程序启动一个带有可点击索引的 Go 包的网络服务器。godoc 网络服务器为您提供系统上安装的所有 Go 源代码的文档的完全可导航的网页版本。

在命令行中获取文档

如果您是那种同时打开文本编辑器和终端会话(或在终端会话中打开文本编辑器)的开发者,那么 go doc 将是您首选的工具。当您第一次需要从您的 Go 应用程序中读取 Unix tar 文件时,您会很高兴地发现,只需输入以下内容即可通过 archive/tar 包的文档:

go doc tar

运行此命令将在终端中直接产生以下输出:

PACKAGE DOCUMENTATION

package tar // import "archive/tar"

Package tar implements access to tar archives. It aims to cover most of the
variations, including those produced by GNU and BSD tars.

References:

    http://www.freebsd.org/cgi/man.cgi?query=tar&sektion=5
    http://www.gnu.org/software/tar/manual/html_node/Standard.html
    http://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html
var ErrWriteTooLong = errors.New("archive/tar: write too long") ...
var ErrHeader = errors.New("archive/tar: invalid tar header")
func FileInfoHeader(fi os.FileInfo, link string) (*Header, error)
func NewReader(r io.Reader) *Reader
func NewWriter(w io.Writer) *Writer
type Header struct { ... }
type Reader struct { ... }
type Writer struct { ... }

您可以浏览文档并找到所需的信息,而不会打断您的流程。

浏览文档

Go 文档也以可浏览的格式提供。有时,当您可以点击并查看所有相关细节时,更容易了解一个包或函数的全貌。在这种情况下,您会想使用 godoc 作为网络服务器。如果您更喜欢以可点击的格式从网页浏览器中获取文档,那么这将是最受您欢迎的获取文档的方式。

要启动自己的文档服务器,请在终端会话中输入以下命令:

godoc -http=:6060

此命令指示 godoc 在端口 6060 上启动一个网络服务器。如果您打开网页浏览器并导航到 http://localhost:6060,您将看到一个包含 Go 标准库和您 GOPATH 中任何 Go 源代码的文档的网页。

如果 图 3.2 中的文档对您来说很熟悉,那是因为 godoc 的一个略微修改的版本正在提供 Go 网站的文档。要导航到特定包的文档,只需点击页面顶部的“包”链接。

图 3.2. 本地 Go 文档

03fig02_alt.jpg

Go 文档工具的最佳之处在于它也适用于您的代码。如果您在编写代码时遵循简单的约定,它将自动将您的注释包含在由 godoc 生成的 Go 文档中。

要包含在 godoc 生成的文档中,您的代码需要通过添加遵循特定约定的注释来进行文档化。我们不会在本章中详细介绍整个约定,但我们会介绍要点。

首先在你想记录的标识符上方添加注释。这适用于包、函数、类型和全局变量。注释可以使用两个斜杠或斜杠星号风格开始。

// Retrieve connects to the configuration repository and gathers
// various connection settings, usernames, passwords. It returns a
// config struct on success, or an error.
func Retrieve() (config, error) {
    // ... omitted
}

在这个例子中,我们展示了在 Go 中记录函数的惯用方法。函数的文档紧随函数之后,并以完整的句子书写。如果你想添加大量文本来记录你的包,请包含一个名为 doc.go 的文件,该文件声明与你的项目相同的包,并在包声明之前将包介绍作为注释:

/*
    Package usb provides types and functions for working with USB
    devices.  To connect to a USB device start by creating a new USB
    connection with NewConnection
    ...
*/
package usb

这个包的文档将在显示你的包的任何类型或函数文档之前显示。它还演示了使用斜杠星号类型的注释。你可以在 Google 中搜索 golang documentation 来了解更多关于为你的代码创建良好文档的信息。

3.6. 与其他 Go 开发者协作

现代开发者不会在真空中编码,Go 工具承认并接受这一事实。由于 go 工具的存在,包的概念超越了你的本地开发环境。让我们看看在分布式开发环境中成为良好公民应遵循的一些约定。

3.6.1. 创建用于共享的仓库

一旦你开始编写出色的 Go 代码,你很可能会想要与 Go 社区分享这些代码。只要你遵循几个简单的步骤,这其实非常容易。

包应该位于仓库的根目录

当你使用 go get 时,你指定要导入的包的完整路径。这意味着当你创建一个打算共享的仓库时,包名应该是仓库名,并且包的源文件应该位于仓库目录结构的根目录中。

新的 Go 开发者常犯的一个错误是在他们的公共仓库中创建一个 codesrc 目录。这样做会使包的公共导入路径更长。相反,只需将包源文件放在公共仓库的根目录下。

包可以很小

在 Go 中,看到相对较小的包是很常见的,这在其他编程语言的标准下。不要害怕创建一个具有小型 API 或仅执行单个任务的包。这是正常且预期的。

在代码上运行 go fmt

就像任何其他开源仓库一样,人们在尝试之前会查看你的代码以评估其质量。在提交任何内容之前,你需要运行 go fmt。这使得代码可读,并在阅读源代码时使每个人都处于同一页面上。

记录代码

Go 开发者使用 godoc 来阅读文档,并通过 godoc.org 阅读开源包的文档。如果你已经遵循了 go doc 的最佳实践来记录你的代码,你的包在本地或在线查看时将显示为良好文档化的,人们会发现它更容易使用。

3.7. 依赖项管理

自 Go 1.0 发布以来,社区一直在努力工作,提供使开发者生活更轻松的 Go 工具。其中许多工具专注于帮助依赖项管理。目前最受欢迎的工具是 Keith Rarick 的 godep,Daniel Theophanes 的 vendor,以及 Gustavo Niemeyer 开发的名为 gopkg.in 的工具,该工具帮助包作者发布他们包的不同版本。

作为行动号召,Go 语言团队从版本 1.5 开始尝试新的构建选项和功能,以提供更好的内部工具支持以进行依赖项管理。虽然我们现在还不知道这些实验将走向何方,但已有现有工具能够以可重复的方式管理、构建和测试 Go 代码。

3.7.1. 依赖项的 vendoring

社区工具如 godep 和 vendor 通过使用称为 vendoring 和导入路径重写的技巧解决了依赖项问题。其想法是将所有依赖项复制到项目仓库内的一个目录中,然后通过提供项目内部的位置来重写引用这些依赖项的任何导入路径。

列表 3.9. 使用 godep 的项目
$GOPATH/src/github.com/ardanstudios/myproject
    |-- Godeps
    |   |-- Godeps.json
    |   |-- Readme
    |   |-- _workspace
    |       |-- src
    |           |-- bitbucket.org
    |           |-- ww
    |           |   |-- goautoneg
    |           |       |-- Makefile
    |           |       |-- README.txt
    |           |       |-- autoneg.go
    |           |       |-- autoneg_test.go
    |           |-- github.com
    |               |-- beorn7
    |                   |-- perks
    |                       |-- README.md
    |                       |-- quantile

    |                           |-- bench_test.go
    |                           |-- example_test.go
    |                           |-- exampledata.txt
    |                           |-- stream.go
    |
    |-- examples
    |-- model
    |-- README.md
    |-- main.go

列表 3.9 展示了使用 godep 为项目 vendoring 依赖项时的典型源代码树。您可以看到 godep 创建了一个名为 Godeps 的目录。工具 vendored 的依赖项源代码位于另一组名为 _workspace/src 的目录中。

接下来,如果您查看 main.go 中声明的这些依赖项的 import 语句,您会看到一些内容需要更改。

列表 3.10. vendoring 之前
01 package main
02
03 import (
04     "bitbucket.org/ww/goautoneg"
05     "github.com/beorn7/perks"
06 )
列表 3.11. vendoring 之后
01 package main
02
03 import (
04     "github.ardanstudios.com/myproject/Godeps/_workspace/src/
                                             bitbucket.org/ww/goautoneg"
05     "github.ardanstudios.com/myproject/Godeps/_workspace/src/
                                                github.com/beorn7/perks"
06 )

在依赖项 vendoring 之前,import 语句使用了包的规范路径。代码在 GOPATH 范围内磁盘上物理位置。在 vendoring 之后,导入路径重写变得必要,以引用现在物理上位于项目内部的包。您可以看到这些导入非常庞大且使用起来繁琐。

通过 vendoring,您能够创建可重复构建,因为构建二进制文件所需的所有源代码都存放在单个项目仓库中。vendoring 和导入路径重写的另一个好处是项目仓库仍然可以通过 go-get 获取。当对项目仓库调用 go get 时,工具可以找到每个包并将包存储在项目内部所需的确切位置。

3.7.2. 介绍 gb

Gb 是由 Go 社区成员开发的一种全新的构建工具。Gb 采用不同的方法来解决可重复构建问题,其出发点是理解包装 Go 工具并不是一个可行的选择。

Gb 背后的哲学源于 Go 没有可重复构建的想法,因为import语句。import语句驱动go get,但import不包含足够的信息来识别在调用go get时应该获取哪个版本的包。go get可以在任何给定包上任何时间获取不同版本的代码的可能性,使得在任何可重复解决方案中支持 Go 工具集变得复杂且繁琐。你使用godep时看到的一些繁琐之处就是这种繁琐性的体现。

这种理解导致了 Gb 构建工具的创建。Gb 既不包装 Go 工具集,也不使用GOPATH。Gb 用基于项目的方法替换了 Go 工具集的工作空间隐喻。这天然地允许供应商代码,无需重新编写导入路径,这是go getGOPATH工作空间所要求的。

让我们看看如何将最后一个项目转换为 Gb 项目。

列表 3.12. Gb 项目的示例
/home/bill/devel/myproject ($PROJECT)
|-- src
|   |-- cmd
|   |   |-- myproject
|   |   |   |-- main.go
|   |-- examples
|   |-- model
|   |-- README.md
|-- vendor
    |-- src
        |-- bitbucket.org
        |   |-- ww
        |       |-- goautoneg
        |       |-- Makefile
        |       |-- README.txt
        |       |-- autoneg.go
        |       |-- autoneg_test.go
        |-- github.com
            |-- beorn7
                |-- perks
                |-- README.md
                |-- quantile
                |-- bench_test.go
        |-- example_test.go
        |-- exampledata.txt
        |-- stream.go

Gb 项目只是一个包含名为src/的子目录的磁盘目录。符号$PROJECT指的是src/目录所在的磁盘根目录,并且仅用作描述项目在磁盘上的位置的快捷方式。$PROJECT不是一个需要设置的环境变量。实际上,Gb 根本不需要设置任何环境变量。

Gb 项目区分了你所编写的代码和你代码所依赖的代码。你所依赖的代码被称为供应商代码。Gb 项目在你的代码和供应商代码之间做出了明确的区分。

列表 3.13. 为项目编写的代码的位置
$PROJECT/src/
列表 3.14. 供应商代码的位置
$PROJECT/vendor/src/

Gb 最好的事情之一是无需重新编写导入路径。看看 main.go 内部声明的import语句——没有任何需要改变的内容来引用供应商依赖项。

列表 3.15. gb 项目的导入路径
01 package main
02
03 import (
04     "bitbucket.org/ww/goautoneg"
05     "github.com/beorn7/perks"
06 )

如果这些导入项在$PROJECT/src/目录内找不到,Gb 工具将查找$PROJECT/vendor/src/目录。整个项目的源代码位于磁盘上的单个仓库和目录中,分布在src/vendor/src/子目录之间。这一点,结合无需重新编写导入路径和可以在磁盘上的任何位置放置项目的自由,使得 Gb 成为社区中开发需要可重复构建项目的流行工具。

需要注意的一点是:Gb 项目与 Go 工具集不兼容,包括go get。由于无需GOPATH,Go 工具集也不理解 Gb 项目的结构,因此无法用于构建、测试或获取。构建和测试 Gb 项目需要导航到$PROJECT目录并使用 gb 工具。

列表 3.16. 构建 Gb 项目
gb build all

Go 工具支持的大多数功能在 gb 中也得到了支持。Gb 还有一个插件系统,允许社区扩展支持。其中一个这样的插件叫做 vendor,它为管理 $PROJECT/vendor/src/ 目录中的依赖提供了便利,这是 Go 工具目前所不具备的。要了解更多关于 gb 的信息,请访问网站:getgb.io。

3.8. 摘要

  • 包是 Go 中代码组织的基本单元。

  • 你的 GOPATH 决定了 Go 源代码在磁盘上的保存、编译和安装位置。

  • 你可以为每个不同的项目设置你的 GOPATH,这样就可以将所有源代码和依赖项分开。

  • go 工具是你在命令行工作时最好的朋友。

  • 你可以通过使用 go get 来获取并安装其他人创建的包到你的 GOPATH 中。

  • 如果你将它们托管在公共源代码仓库中并遵循一些简单的规则,那么很容易为他人创建可用的包。

  • Go 语言的设计将代码共享作为语言的核心驱动功能。

  • 建议你使用 vendoring 来管理依赖。

  • 有几个社区开发的依赖管理工具,如 godep、vendor 和 gb。

第四章 数组、切片和映射

本章内容

  • 数组的内部结构和基本概念

  • 使用切片管理数据集合

  • 使用映射处理键/值对

编写不需要存储和读取数据集合的程序是困难的。如果你使用数据库或文件,或访问网络,你需要一种处理接收和发送的数据的方法。Go 有三种不同的数据结构,允许你管理数据集合:数组、切片和映射。这些数据结构内置于语言中,并在标准库中使用。一旦你了解了这些数据结构的工作原理,使用 Go 编程将变得有趣、快速且灵活。

4.1. 数组的内部结构和基本概念

从数组开始是有意义的,因为它们是切片和映射的基础数据结构。了解数组的工作原理将帮助你欣赏切片和映射提供的优雅和强大。

4.1.1. 内部结构

在 Go 中,数组是一种固定长度的数据类型,包含相同类型元素的连续块。这可以是内置类型,如整数和字符串,也可以是结构体类型。

在图 4.1 中,你可以看到数组的表示。数组的元素被标记为灰色框,并依次连接。每个元素包含相同类型,在这种情况下是整数,并且可以通过唯一的索引位置访问。

图 4.1. 数组内部结构

数组是有价值的结构,因为内存是按顺序分配的。以连续形式存储内存可以帮助你使用的内存更长时间地保持在 CPU 缓存中。使用索引运算,你可以快速遍历数组的所有元素。数组类型信息提供了你找到每个元素所需的内存移动距离。由于每个元素都是同一类型并且依次排列,因此遍历数组是一致的且快速的。

4.1.2. 声明和初始化

声明数组时,指定要存储的数据类型和所需的总元素数,也称为数组的长度。

列表 4.1. 声明并初始化为零值的数组
// Declare an integer array of five elements.
var array [5]int

一旦声明了数组,存储的数据类型及其长度都不能更改。如果你需要更多元素,你需要创建一个新的数组,其长度为所需长度,然后将一个数组中的值复制到另一个数组中。

当在 Go 中声明变量时,它们总是初始化为其各自类型的零值,数组也不例外。当数组初始化时,属于数组的每个单独的元素都被初始化为其零值。在图 4.2 中,你可以看到一个整数数组,数组中的每个元素都被初始化为 0,这是整数的零值。

图 4.2. 数组变量声明后的数组值

创建和初始化数组的一个快速简单的方法是使用数组字面量。数组字面量允许你声明所需的元素数量并指定这些元素的值。

列表 4.2. 使用数组字面量声明数组
// Declare an integer array of five elements.
// Initialize each element with a specific value.
array := [5]int{10, 20, 30, 40, 50}

如果长度以...给出,Go 将根据初始化的元素数量确定数组的长度。

列表 4.3. 使用 Go 计算大小的数组声明
// Declare an integer array.
// Initialize each element with a specific value.
// Capacity is determined based on the number of values initialized.
array := [...]int{10, 20, 30, 40, 50}

如果你已知所需数组的长度,但只准备初始化特定元素,你可以使用这种语法。

列表 4.4. 声明并初始化特定元素的数组
// Declare an integer array of five elements.
// Initialize index 1 and 2 with specific values.
// The rest of the elements contain their zero value.
array := [5]int{1: 10, 2: 20}

在声明并初始化数组后,列表 4.4 中声明的数组的值将类似于图 4.3。

图 4.3. 声明数组变量后的数组值

图片

4.1.3. 使用数组

正如我们讨论的,数组是高效的数据结构,因为内存是按顺序排列的。这使得数组在访问单个元素时具有效率优势。要访问单个元素,请使用[ ]运算符。

列表 4.5. 访问数组元素
// Declare an integer array of five elements.
// Initialize each element with a specific value.
array := [5]int{10, 20, 30, 40, 50}

// Change the value at index 2.
array[2] = 35

在完成数组操作后,列表 4.5 中声明的数组的值将类似于图 4.4。

图 4.4. 改变索引 2 的值后数组的值

图片

你可以有指针数组。就像在第二章中一样,你使用*运算符来访问每个元素指针指向的值。

列表 4.6. 访问数组指针元素
// Declare an integer pointer array of five elements.
// Initialize index 0 and 1 of the array with integer pointers.
array := [5]*int{0: new(int), 1: new(int)}

// Assign values to index 0 and 1.
*array[0] = 10
*array[1] = 20

在完成数组操作后,列表 4.6 中声明的数组的值将类似于图 4.5。

图 4.5. 指向整数的指针数组

图片

在 Go 中,数组是一个值。这意味着你可以在赋值操作中使用它。变量名表示整个数组,因此数组可以被赋值给相同类型的其他数组。

列表 4.7. 将一个数组赋值给相同类型的另一个数组
// Declare a string array of five elements.
var array1 [5]string

// Declare a second string array of five elements.
// Initialize the array with colors.
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}

// Copy the values from array2 into array1.
array1 = array2

复制后,你将有两个具有相同值的数组,如图图 4.6 所示。

图 4.6. 复制后的两个数组

图片

数组变量的类型包括长度和每个元素可以存储的数据类型。只有相同类型的数组才能被赋值。

列表 4.8. 分配不同类型的数组时的编译错误
// Declare a string array of four elements.
var array1 [4]string

// Declare a second string array of five elements.
// Initialize the array with colors.
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"}

// Copy the values from array2 into array1.
array1 = array2

Compiler Error:
cannot use array2 (type [5]string) as type [4]string in assignment

复制指针数组会复制指针值,而不是指针指向的值。

列表 4.9. 将一个指针数组赋值给另一个
// Declare a string pointer array of three elements.
var array1 [3]*string

// Declare a second string pointer array of three elements.
// Initialize the array with string pointers.
array2 := [3]*string{new(string), new(string), new(string)}

// Add colors to each element
*array2[0] = "Red"
*array2[1] = "Blue"
*array2[2] = "Green"

// Copy the values from array2 into array1.
array1 = array2

复制后,你将有两个指向相同字符串的数组,如图图 4.7 所示。

图 4.7. 指向相同字符串的两个指针数组

图片

4.1.4. 多维数组

数组始终是一维的,但可以通过组合来创建多维数组。当需要管理可能具有父子关系或与坐标系相关联的数据时,多维数组非常有用。

列表 4.10. 声明二维数组
// Declare a two dimensional integer array of four elements
// by two elements.
var array [4][2]int

// Use an array literal to declare and initialize a two
// dimensional integer array.
array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}

// Declare and initialize index 1 and 3 of the outer array.
array := [4][2]int{1: {20, 21}, 3: {40, 41}}

// Declare and initialize individual elements of the outer
// and inner array.
array := [4][2]int{1: {0: 20}, 3: {1: 41}}

图 4.8 展示了声明和初始化这些数组后每个数组包含的值。

图 4.8. 二维数组和它们的内外值

要访问单个元素,再次使用 [ ] 操作符和一些组合。

列表 4.11. 访问二维数组的元素
// Declare a two dimensional integer array of two elements.
var array [2][2]int

// Set integer values to each individual element.
array[0][0] = 10
array[0][1] = 20
array[1][0] = 30
array[1][1] = 40

只要多维数组具有相同的类型,就可以将它们相互复制。多维数组的类型基于每个维度的长度以及每个元素可以存储的数据类型。

列表 4.12. 赋值相同类型的多维数组
// Declare two different two dimensional integer arrays.
var array1 [2][2]int
var array2 [2][2]int

// Add integer values to each individual element.
array2[0][0] = 10
array2[0][1] = 20
array2[1][0] = 30
array2[1][1] = 40

// Copy the values from array2 into array1.
array1 = array2

因为数组是一个值,所以可以复制单个维度。

列表 4.13. 通过索引赋值多维数组
// Copy index 1 of array1 into a new array of the same type.
var array3 [2]int = array1[1]

// Copy the integer found in index 1 of the outer array
// and index 0 of the interior array into a new variable of
// type integer.
var value int = array1[1][0]

4.1.5. 在函数间传递数组

在函数间传递数组在内存和性能方面可能是一个昂贵的操作。当你将变量在函数间传递时,它们总是通过值传递。当你的变量是数组时,这意味着无论其大小如何,整个数组都会被复制并传递给函数。

为了看到这个操作的实际效果,让我们创建一个包含一百万个 int 类型元素的数组。在 64 位架构上,这将需要八百万字节,即八兆字节,的内存。当你声明这样一个大小的数组并将其传递给函数时会发生什么?

列表 4.14. 在函数间通过值传递大数组
// Declare an array of 8 megabytes.
var array [1e6]int

// Pass the array to the function foo.
foo(array)

// Function foo accepts an array of one million integers.
func foo(array [1e6]int) {
    ...
}

每次调用函数 foo 时,必须在栈上分配八兆字节的内存。然后必须将数组的值,即所有八兆字节的内存,复制到这个分配中。Go 可以处理这种复制操作,但还有更好的、更有效的方法来做这件事。你可以传递数组的指针,只需复制八字节,而不是在栈上复制八兆字节的内存。

列表 4.15. 在函数间通过指针传递大数组
// Allocate an array of 8 megabytes.
var array [1e6]int

// Pass the address of the array to the function foo.
foo(&array)

// Function foo accepts a pointer to an array of one million integers.
func foo(array *[1e6]int) {
    ...
}

这次函数 foo 接收一个指向一百万个整数元素的数组的指针。函数调用现在传递数组的地址,这只需要在栈上为指针变量分配八字节内存。

这种操作在内存方面更有效率,并且可能带来更好的性能。你只需要意识到,因为你现在使用的是指针,改变指针指向的值将改变共享的内存。真正令人兴奋的是,切片天生就处理了这些类型的问题,正如你将看到的。

4.2. 切片内部和基础

切片是一种数据结构,它提供了一种方式,让您可以处理和管理数据集合。切片围绕动态数组的概念构建,可以根据您的需要增长和缩小。在增长方面,它们非常灵活,因为它们有自己的内置函数append,可以高效地快速增长切片。您还可以通过从底层内存中切出部分来减小切片的大小。切片为您提供了索引、迭代和垃圾回收优化的所有好处,因为底层内存是在连续块中分配的。

4.2.1. 内部结构

切片是小型对象,它们抽象并操作底层数组。它们是包含 Go 需要操作底层数组的元数据的三个字段的数据结构(参见图 4.9)。

图 4.9. 带底层数组的切片内部结构

这三个字段是底层数组的指针、切片的长度或切片可以访问的元素数量,以及切片的容量或切片可以用于增长的元素数量。长度和容量之间的差异将在稍后变得更有意义。

4.2.2. 创建和初始化

在 Go 中有几种创建和初始化切片的方法。提前知道您需要的容量通常将决定您如何创建您的切片。

make 和切片字面量

创建切片的一种方法是通过内置函数make。当您使用make时,您可以选择指定切片的长度。

列表 4.16. 通过长度声明字符串切片
// Create a slice of strings.
// Contains a length and capacity of 5 elements.
slice := make([]string, 5)

当您只指定长度时,切片的容量相同。您也可以分别指定长度和容量。

列表 4.17. 通过长度和容量声明整数切片
// Create a slice of integers.
// Contains a length of 3 and has a capacity of 5 elements.
slice := make([]int, 3, 5)

当您分别指定长度和容量时,您可以在底层数组中创建具有可用容量的切片,而您最初无法访问这些容量。图 4.9 描述了在列表 4.17 中声明的整数切片在用一些值初始化后的可能样子。

列表 4.17 中的切片可以访问三个元素,但底层数组有五个元素。与切片长度不相关的两个元素可以合并,以便切片也可以使用这些元素。还可以创建新的切片来共享相同的底层数组并使用任何现有容量。

不允许创建容量小于长度的切片。

列表 4.18. 设置容量小于长度的编译器错误
// Create a slice of integers.
// Make the length larger than the capacity.
slice := make([]int, 5, 3)

Compiler Error:
len larger than cap in make([]int)

创建切片的一种惯用方法是使用切片字面量。它与创建数组类似,只是您不需要在[ ]操作符内指定值。初始长度和容量将基于您初始化的元素数量。

列表 4.19. 使用切片字面量声明切片
// Create a slice of strings.
// Contains a length and capacity of 5 elements.
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}

// Create a slice of integers.
// Contains a length and capacity of 3 elements.
slice := []int{10, 20, 30}

当使用切片字面量时,你可以设置初始长度和容量。你需要做的就是初始化代表所需长度和容量的索引。以下语法将创建一个长度和容量为 100 个元素的切片。

列表 4.20. 使用索引位置声明切片
// Create a slice of strings.
// Initialize the 100th element with an empty string.
slice := []string{99: ""}

记住,如果你在 [ ] 操作符内指定了一个值,你正在创建一个数组。如果你没有指定值,你正在创建一个切片。

列表 4.21. 数组和切片的声明差异
// Create an array of three integers.
array := [3]int{10, 20, 30}

// Create a slice of integers with a length and capacity of three.
slice := []int{10, 20, 30}
nil 切片和空切片

有时在你的程序中你可能需要声明一个 nil 切片。一个 nil 切片是通过声明一个没有初始化的切片来创建的。

列表 4.22. 声明一个 nil 切片
// Create a nil slice of integers.
var slice []int

一个 nil 切片是你在 Go 中创建切片最常见的方式。它们可以与许多标准库和内置函数一起使用,这些函数与切片一起工作。当你想表示一个不存在的切片时,它们很有用,例如当函数返回一个切片时发生异常(参见 图 4.10)。

图 4.10. nil 切片的表示

图片 4.10

你也可以通过声明一个带有初始化的切片来创建一个空切片。

列表 4.23. 声明一个空切片
// Use make to create an empty slice of integers.
slice := make([]int, 0)

// Use a slice literal to create an empty slice of integers.
slice := []int{}

一个空切片包含一个零元素底层数组,它不分配任何存储空间。空切片在你想要表示一个空集合时很有用,例如当数据库查询返回零结果时(参见 图 4.11)。

图 4.11. 空切片的表示

图片 4.11

无论你是使用 nil 切片还是空切片,内置函数 appendlencap 的工作方式都是相同的。

4.2.3. 使用切片

现在你已经知道了切片是什么以及如何创建它们,你可以学习如何在程序中使用它们。

赋值和切片

将值赋给切片中的任何特定索引与数组中的操作方式相同。要更改单个元素的值,请使用 [ ] 操作符。

列表 4.24. 使用数组字面量声明数组
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}

// Change the value of index 1.
slice[1] = 25

切片被称为切片,因为你可以从底层数组中切取一部分来创建一个新的切片。

列表 4.25. 从切片中取切片
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}

// Create a new slice.
// Contains a length of 2 and capacity of 4 elements.
newSlice := slice[1:3]

在 列表 4.25 中执行切片操作后,我们有两个共享相同底层数组的切片。然而,每个切片以不同的方式查看底层数组(参见 图 4.12)。

图 4.12. 两个共享相同底层数组的切片

图片 4.12

原始 slice 将底层数组视为具有五个元素的容量,但 newSlice 的视图不同。对于 newSlice,底层数组具有四个元素的容量。newSlice 无法访问其指针之前的底层数组的元素。对 newSlice 来说,那些元素甚至不存在。

计算任何新切片的长度和容量是通过以下公式完成的。

列表 4.26. 长度和容量的计算方法
For slice[i:j] with an underlying array of capacity k

Length:   j - i
Capacity: k - i

如果你将此公式应用于 newSlice,你将得到以下结果。

列表 4.27. 计算新的长度和容量
For slice[1:3] with an underlying array of capacity 5

Length:   3 - 1 = 2
Capacity: 5 - 1 = 4

另一种看待这个问题的方式是,第一个值代表新切片将开始的元素起始索引位置——在这种情况下,是 1。第二个值代表起始索引位置(1)加上你想要包含的元素数量(2);1 加 2 等于 3,所以第二个值是 3。容量将是与切片相关的元素总数。

你需要记住,你现在有两个切片共享同一个底层数组。一个切片对底层数组共享部分的修改,另一个切片是可以看到的。

列表 4.28. 修改切片的潜在后果
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}

// Create a new slice.
// Contains a length of 2 and capacity of 4 elements.
newSlice := slice[1:3]

// Change index 1 of newSlice.
// Change index 2 of the original slice.
newSlice[1] = 35

在将数字 35 分配给 newSlice 的第二个元素后,这种变化也可以在原始 slice 的元素 3 中看到(参见 图 4.13)。

图 4.13. 分配操作后的底层数组

切片只能访问其长度范围内的索引。尝试访问长度之外的元素将导致运行时异常。与切片容量相关的元素仅可用于增长。它们必须被纳入切片的长度中,才能被使用。

列表 4.29. 索引越界时的运行时错误
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}

// Create a new slice.
// Contains a length of 2 and capacity of 4 elements.
newSlice := slice[1:3]

// Change index 3 of newSlice.
// This element does not exist for newSlice.
newSlice[3] = 45

Runtime Exception:
panic: runtime error: index out of range

有容量是很好的,但如果不能将其融入切片的长度中,那就毫无用处。幸运的是,当你使用内置函数 append 时,Go 语言使这个过程变得简单。

切片增长

使用切片而不是数组的一个优点是,你可以根据需要增长切片的容量。当你使用内置函数 append 时,Go 语言会处理所有操作细节。

要使用 append,你需要一个源切片和一个要附加的值。当你的 append 调用返回时,它为你提供一个带有更改的新切片。append 函数总是会增加新切片的长度。另一方面,容量可能会受到影响,也可能不会受到影响,这取决于源切片的可用容量。

列表 4.30. 使用 append 向切片添加元素
// Create a slice of integers.
// Contains a length and capacity of 5 elements.
slice := []int{10, 20, 30, 40, 50}

// Create a new slice.
// Contains a length of 2 and capacity of 4 elements.
newSlice := slice[1:3]

// Allocate a new element from capacity.
// Assign the value of 60 to the new element.
newSlice = append(newSlice, 60)

在 列表 4.30 中的 append 操作之后,切片和底层数组将看起来像 图 4.14。

图 4.14. 附加操作后的底层数组

因为在底层数组中为 newSlice 有可用容量,所以 append 操作将可用元素纳入切片长度,并分配了值。由于原始 slice 是共享底层数组,所以 slice 也会看到索引 3 的变化。

当切片在底层数组中没有可用容量时,append 函数将创建一个新的底层数组,复制现有引用的值,并分配新值。

列表 4.31. 使用 append 增加切片的长度和容量
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}

// Append a new value to the slice.
// Assign the value of 50 to the new element.
newSlice := append(slice, 50)

在这个 append 操作之后,newSlice 被赋予了它自己的底层数组,并且数组的容量从原始大小加倍(参见 图 4.15)。

图 4.15. append 操作后的新底层数组

图片 4.15

当切片的现有容量小于 1,000 个元素时,append 操作在增长底层数组的容量时非常巧妙。容量总是加倍。一旦元素数量超过 1,000,容量就增加 1.25 倍,即 25%。这种增长算法可能会随着时间的推移而改变。

三个索引切片

在切片时,我们还没有提到的一个第三个索引选项,你可以使用。这个第三个索引让你可以控制新切片的容量。目的不是增加容量,而是限制容量。正如你将看到的,能够限制新切片的容量为底层数组提供了一定程度的保护,并让你对 append 操作有更多的控制。

让我们从包含你可以在当地超市找到的水果的五个字符串切片开始。

列表 4.32. 使用切片字面量声明字符串切片
// Create a slice of strings.
// Contains a length and capacity of 5 elements.
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}

如果你检查这个水果切片的值,它看起来可能像 图 4.16。

图 4.16. 字符串切片的表示

图片 4.16 替代

现在,让我们使用第三个索引选项来执行一个切片操作。

列表 4.33. 执行三个索引切片
// Slice the third element and restrict the capacity.
// Contains a length of 1 element and capacity of 2 elements.
slice := source[2:3:4]

在这个切片操作之后,我们有一个新的切片,它引用了底层数组中的一个元素,并且容量为两个元素。具体来说,新的切片引用了 Plum 元素,并且容量扩展到 Banana 元素,如 图 4.17 所示。

图 4.17. 操作后的新切片表示

图片 4.17 替代

我们可以应用之前定义的相同公式来计算新切片的长度和容量。

列表 4.34. 长度和容量是如何计算的
For slice[i:j:k]  or  [2:3:4]

Length:   j - i  or  3 - 2 = 1
Capacity: k - i  or  4 - 2 = 2

再次,第一个值代表新切片将开始的元素起始索引位置——在这种情况下,2。第二个值代表起始索引位置(2)加上你想要包含的元素数量(1);2 加 1 等于 3,所以第二个值是 3。对于设置容量,你取起始索引位置 2,加上你想要包含在容量中的元素数量(2),得到值为 4。

如果你尝试设置一个比可用容量更大的容量,你将得到一个运行时错误。

列表 4.35. 设置大于现有容量的容量时的运行时错误
// This slicing operation attempts to set the capacity to 4.
// This is greater than what is available.
slice := source[2:3:6]

Runtime Error:
panic: runtime error: slice bounds out of range

正如我们之前讨论的,内置函数append会首先使用任何可用的容量。一旦达到这个容量,它将分配一个新的底层数组。很容易忘记哪些切片共享相同的底层数组。当这种情况发生时,对切片的更改可能会导致随机和奇怪的错误。突然之间,多个切片中出现了变化。

通过有选择地设置新切片的容量与长度相同,你可以强制第一次append操作将新切片从底层数组中分离出来。将新切片从其原始源数组中分离出来使其更改变得安全。

列表 4.36. 设置长度和容量相同的优点
// Create a slice of strings.
// Contains a length and capacity of 5 elements.
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}

// Slice the third element and restrict the capacity.
// Contains a length and capacity of 1 element.
slice := source[2:3:3]

// Append a new string to the slice.
slice = append(slice, "Kiwi")

没有这个第三个索引,将Kiwi添加到我们的切片中将会改变底层数组索引 3 处的Banana的值,因为所有剩余的容量仍然属于切片。但在列表 4.36 中,我们限制了切片的容量为 1。当我们第一次在切片上调用append时,它将创建一个新的包含两个元素的底层数组,复制水果Plum,添加新的水果Kiwi,并返回一个新的切片,该切片引用这个底层数组,如图 4.18 所示。

图 4.18. 在append操作之后的新切片的表示

图片

由于新切片现在有自己的底层数组,我们避免了潜在的问题。我们现在可以继续向我们的新切片添加水果,而不用担心是否不恰当地更改了其他切片中的水果。此外,为切片分配新的底层数组既简单又干净。

内置函数append也是一个可变参数函数。这意味着你可以在单个切片调用中传递多个要添加的值。如果你使用...运算符,你可以将一个切片的所有元素添加到另一个切片中。

列表 4.37. 从另一个切片向切片中添加元素
// Create two slices each initialized with two integers.
s1 := []int{1, 2}
s2 := []int{3, 4}

// Append the two slices together and display the results.
fmt.Printf("%v\n", append(s1, s2...))

Output:
[1 2 3 4]

如输出所示,切片s2的所有值都已添加到切片s1中。然后通过Printf调用显示append函数返回的新切片的值。

遍历切片

由于切片是一个集合,你可以遍历其元素。Go 有一个特殊的关键字range,你可以与for关键字一起使用来遍历切片。

列表 4.38. 使用for range遍历切片
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}

// Iterate over each element and display each value.
for index, value := range slice {
  fmt.Printf("Index: %d  Value: %d\n", index, value)
}

Output:
Index: 0  Value: 10
Index: 1  Value: 20
Index: 2  Value: 30
Index: 3  Value: 40

当遍历切片时,关键字range将返回两个值。第一个值是索引位置,第二个值是该索引位置值的副本(参见图 4.19)。

图 4.19. 使用range遍历切片会复制每个元素。

图片

重要的是要知道range正在复制值,而不是返回一个引用。如果你使用值变量的地址作为每个元素的指针,你将会犯一个错误。让我们看看为什么。

列表 4.39. range提供了每个元素的副本
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}

// Iterate over each element and display the value and addresses.
for index, value := range slice {
   fmt.Printf("Value: %d  Value-Addr: %X  ElemAddr: %X\n",
       value, &value, &slice[index])
}

Output:
Value: 10  Value-Addr: 10500168  ElemAddr: 1052E100
Value: 20  Value-Addr: 10500168  ElemAddr: 1052E104
Value: 30  Value-Addr: 10500168  ElemAddr: 1052E108
Value: 40  Value-Addr: 10500168  ElemAddr: 1052E10C

value 变量的地址始终相同,因为它是一个包含副本的变量。可以使用切片变量和索引值捕获每个单独元素的地址。

如果你不需要索引值,你可以使用下划线字符来丢弃该值。

列表 4.40. 使用空白标识符忽略索引值
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}

// Iterate over each element and display each value.
for _, value := range slice {
    fmt.Printf("Value: %d\n", value)
}

Output:
Value: 10
Value: 20
Value: 30
Value: 40

关键字 range 总是从切片的开始迭代。如果你需要更多控制地迭代切片,你总是可以使用传统的 for 循环。

列表 4.41. 使用传统的 for 循环迭代切片
// Create a slice of integers.
// Contains a length and capacity of 4 elements.
slice := []int{10, 20, 30, 40}

// Iterate over each element starting at element 3.
for index := 2; index < len(slice); index++ {
    fmt.Printf("Index: %d  Value: %d\n", index, slice[index])
}

Output:
Index: 2  Value: 30
Index: 3  Value: 40

有两个特殊的内置函数 lencap 与数组、切片和通道一起工作。对于切片,len 函数返回切片的长度,而 cap 函数返回容量。在列表 4.41 中,我们使用了 len 函数来确定何时停止迭代切片。

现在你已经知道了如何创建和使用切片,你可以使用它们来组合和迭代多维切片。

4.2.4. 多维切片

与数组一样,切片是一维的,但它们可以组合起来创建多维切片,原因与之前讨论的相同。

列表 4.42. 声明一个多维切片
// Create a slice of a slice of integers.
slice := [][]int{{10}, {100, 200}}

现在我们有一个包含整数内部切片的外部切片,其切片的切片整数值看起来像图 4.20。

图 4.20. 我们切片的切片整数的值

在图 4.20 中,你可以看到如何通过组合将切片嵌入到切片中。外部切片包含两个元素,每个元素都是切片。第一个元素中的切片初始化为单个整数 10,第二个元素中的切片包含两个整数,100 和 200。

组合允许你创建非常复杂和强大的数据结构。你学到的关于内置函数 append 的所有规则仍然适用。

列表 4.43. 组合切片的切片
// Create a slice of a slice of integers.
slice := [][]int{{10}, {100, 200}}

// Append the value of 20 to the first slice of integers.
slice[0] = append(slice[0], 20)

append 函数和 Go 在处理增长并将新的整数切片分配回外部切片的第一个元素方面非常优雅。当列表 4.43 中的操作完成时,将分配一个新的整数切片和一个新的底层数组,然后将其复制回外部切片的索引 0,如图 4.21 所示。

图 4.21. 外部切片索引 0 在 append 操作后的样子

即使在这个简单的多维切片中,也涉及许多层和值。在函数之间传递这样的数据结构可能会显得复杂。但切片成本低廉,在函数之间传递它们是微不足道的。

4.2.5. 在函数之间传递切片

在两个函数之间传递切片只需通过值传递切片。由于切片的大小很小,复制和传递到函数之间很便宜。让我们创建一个大的切片,并通过值将这个切片传递给我们的foo函数。

列表 4.44。在函数之间传递切片
// Allocate a slice of 1 million integers.
slice := make([]int, 1e6)

// Pass the slice to the function foo.
slice = foo(slice)

// Function foo accepts a slice of integers and returns the slice back.
func foo(slice []int) []int {
    ...
    return slice
}

在 64 位架构上,切片需要 24 字节的内存。指针字段需要 8 字节,长度和容量字段分别需要 8 字节。由于与切片关联的数据包含在底层数组中,因此将切片的副本传递给任何函数时没有问题。只有切片被复制,而不是底层数组(见图 4.22)。

图 4.22。函数调用后两个切片都指向底层数组

在函数之间传递 24 字节是快速且简单的。这就是切片的美丽之处。您不需要传递指针并处理复杂的语法。您只需创建切片的副本,进行所需的更改,然后传递一个新的副本。

4.3。映射的内部结构和基本原理

映射是一种数据结构,它为您提供无序的键/值对集合。

您可以根据键将值存储到映射中。图 4.23 展示了您可能存储在映射中的键/值对示例。映射的强大之处在于它能够根据键快速检索数据。键就像一个索引,指向与该键关联的值。

图 4.23。键/值对的关系

4.3.1。内部结构

映射是集合,您可以像处理数组和切片一样遍历它们。但映射是无序集合,无法预测键/值对返回的顺序。即使您以相同的顺序存储键/值对,每次遍历映射都可能返回不同的顺序。这是因为映射是使用哈希表实现的,如图 4.24 所示。

图 4.24。映射内部结构的简单表示

映射的哈希表包含一系列桶。当您存储、删除或查找键/值对时,一切从选择一个桶开始。这是通过将指定在映射操作中的键传递给映射的哈希函数来完成的。哈希函数的目的是生成一个索引,该索引将键/值对均匀分布在所有可用的桶中。

分布越好,随着映射的增长,您查找键/值对的速度就越快。如果您在映射中存储了 10,000 个项,您不想查看 10,000 个键/值对来找到您想要的那个。您希望查看尽可能少的键/值对。在 10,000 个项的映射中只查看 8 个键/值对是一个良好且平衡的映射。在正确数量的桶中跨键/值对平衡的列表使这成为可能。

为 Go 映射生成的哈希键比你在图 4.25 中看到的要长一些,但它的工作方式相同。在我们的例子中,键是表示颜色的字符串。这些字符串被转换成在可用存储桶数量范围内的数值。然后使用这个数值来选择一个桶以存储或查找特定的键/值对。在 Go 映射的情况下,生成的哈希键的一部分,即 低位位 (LOB),用于选择桶。

图 4.25. 哈希函数工作原理的简单视图

图 4.25 替代

如果你再次查看图 4.24,你可以看到桶的内部结构。有两个数据结构包含映射的数据。首先,有一个数组,包含用于选择桶的相同哈希键的前八个 高位位 (HOB)。这个数组区分了存储在相应桶中的每个单独的键/值对。其次,有一个字节数组,用于存储键/值对。字节数组将所有键打包在一起,然后为相应桶打包所有值。键/值对的打包是为了最小化每个桶所需的内存。

关于映射的许多其他低级实现细节超出了本章的范围。你不需要理解所有内部结构来学习如何创建和使用映射。只需记住一件事:映射是无序键/值对的集合。

4.3.2. 创建和初始化

在 Go 中创建和初始化映射有几种方法。你可以使用内置函数 make,或者你可以使用映射字面量。

列表 4.45. 使用 make 声明映射
// Create a map with a key of type string and a value of type int.
dict := make(map[string]int)

// Create a map with a key and value of type string.
// Initialize the map with 2 key/value pairs.
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

使用映射字面量是创建映射的惯用方式。初始长度将基于初始化期间指定的键/值对数量。

映射键可以是任何内置或结构体类型的值,只要该值可以用 == 操作符在表达式中使用。切片、函数以及包含切片的结构体类型不能用作映射键。这将产生编译器错误。

列表 4.46. 使用映射字面量声明一个空映射
// Create a map using a slice of strings as the key.
dict := map[[]string]int{}

Compiler Exception:
invalid map key type []string

没有什么阻止你使用切片作为映射值。当你需要将单个映射键与一组数据相关联时,这可能会很有用。

列表 4.47. 声明存储字符串切片的映射
// Create a map using a slice of strings as the value.
dict := map[int][]string{}

4.3.3. 与映射一起工作

向映射赋值是通过指定正确的键类型并将值分配给该键来执行的。

列表 4.48. 向映射赋值
// Create an empty map to store colors and their color codes.
colors := map[string]string{}

// Add the Red color code to the map.
colors["Red"] = "#da1337"

你可以通过声明一个没有初始化的映射来创建一个 nil 映射。nil 映射不能用于存储键/值对。尝试这样做将产生运行时错误。

列表 4.49. 将运行时错误分配给 nil 映射
// Create a nil map by just declaring the map.
var colors map[string]string

// Add the Red color code to the map.
colors["Red"] = "#da1337"

Runtime Error:
panic: runtime error: assignment to entry in nil map

测试映射键是否存在是处理映射的重要部分。它允许你编写逻辑,以确定你是否执行了操作或是否在映射中缓存了某些特定数据。它还可以用于比较两个映射,以确定哪些键/值对匹配或缺失。

当从映射中检索值时,你有两种选择。你可以检索值和一个标志,该标志明确地让你知道键是否存在。

列表 4.50. 从映射中检索值并测试存在性。
// Retrieve the value for the key "Blue".
value, exists := colors["Blue"]

// Did this key exist?
if exists {
    fmt.Println(value)
}

另一个选项是只返回值并测试零值以确定键是否存在。这仅当零值不是映射的有效值时才有效。

列表 4.51. 从映射中检索值并测试值的存在性
// Retrieve the value for the key "Blue".
value := colors["Blue"]

// Did this key exist?
if value != "" {
    fmt.Println(value)
}

当你在 Go 中索引映射时,它总是会返回一个值,即使键不存在。在这种情况下,返回值类型的零值。

遍历映射与遍历数组或切片相同。你使用关键字 range;但是当涉及到映射时,你不会得到索引/值,而是得到键/值对。

列表 4.52. 使用 for 循环遍历映射
// Create a map of colors and color hex codes.
colors := map[string]string{
    "AliceBlue":   "#f0f8ff",
    "Coral":       "#ff7F50",
    "DarkGray":    "#a9a9a9",
    "ForestGreen": "#228b22",
}

// Display all the colors in the map.
for key, value := range colors {
    fmt.Printf("Key: %s  Value: %s\n", key, value)
}

如果你想从映射中删除一个键/值对,请使用内置函数 delete

列表 4.53. 从映射中删除一个项
// Remove the key/value pair for the key "Coral".
delete(colors, "Coral")

// Display all the colors in the map.

for key, value := range colors {
    fmt.Printf("Key: %s  Value: %s\n", key, value)
}

这次当你遍历映射时,颜色珊瑚不会显示在屏幕上。

4.3.4. 在函数之间传递映射

在两个函数之间传递映射不会复制映射。实际上,你可以将映射传递给一个函数并修改映射,这些更改将通过映射的所有引用反映出来。

列表 4.54. 在函数之间传递映射
func main() {
    // Create a map of colors and color hex codes.
    colors := map[string]string{
       "AliceBlue":   "#f0f8ff",
       "Coral":       "#ff7F50",
       "DarkGray":    "#a9a9a9",
       "ForestGreen": "#228b22",
    }

    // Display all the colors in the map.
    for key, value := range colors {
        fmt.Printf("Key: %s  Value: %s\n", key, value)
    }

    // Call the function to remove the specified key.
    removeColor(colors, "Coral")

    // Display all the colors in the map.
    for key, value := range colors {
        fmt.Printf("Key: %s  Value: %s\n", key, value)
    }
}

// removeColor removes keys from the specified map.
func removeColor(colors map[string]string, key string) {
    delete(colors, key)
}

如果你运行这个程序,你会得到以下输出。

列表 4.55. 列表 4.54 的输出
Key: AliceBlue Value: #F0F8FF
Key: Coral Value: #FF7F50
Key: DarkGray Value: #A9A9A9
Key: ForestGreen Value: #228B22

Key: AliceBlue Value: #F0F8FF
Key: DarkGray Value: #A9A9A9
Key: ForestGreen Value: #228B22

你可以看到,在调用 removeColor 完成后,颜色珊瑚不再出现在 main 引用的映射中。映射的设计与切片类似,成本低廉。

4.4. 摘要

  • 数组是切片和映射的构建块。

  • 在 Go 中,切片是处理数据集合的惯用方式。映射是处理数据键/值对的途径。

  • 内置函数 make 允许你创建具有初始长度和容量的切片和映射。也可以使用映射字面量,并支持为使用设置初始值。

  • 切片有容量限制,但可以使用内置函数 append 来扩展。

  • 映射没有容量或对增长的任何限制。

  • 内置函数 len 可以用来获取切片或映射的长度。

  • 内置函数 cap 只适用于切片。

  • 通过组合的使用,你可以创建多维数组切片。你还可以创建具有切片和其他映射值的映射。切片不能用作映射键。

  • 将切片或映射传递给函数成本低廉,并且不会复制底层的数据结构。

第五章. Go 的类型系统

本章

  • 声明新的用户定义类型

  • 使用方法向类型添加行为

  • 了解何时使用指针和值

  • 使用接口实现多态

  • 通过组合扩展和更改类型

  • 导出和非导出标识符

Go 是一种静态类型编程语言。这意味着编译器总是想要知道程序中每个值的数据类型。当编译器提前知道类型信息时,它可以帮助确保程序以安全的方式处理值。这有助于减少潜在的内存损坏和错误,并为编译器提供了生成更高效代码的机会。

值的类型为编译器提供两份数据:首先,分配多少内存——即值的大小——其次,该内存表示什么。对于许多内置类型,大小和表示是类型名称的一部分。int64 类型的值需要 8 字节内存(64 位)并表示一个整数值。float32 需要占用 4 字节内存(32 位)并表示一个 IEEE-754 二进制浮点数。bool 需要占用 1 字节内存(8 位)并表示布尔值 truefalse

一些类型的表示是基于为代码构建的机器的架构。例如,int 类型的值可以是 8 字节(64 位)或 4 字节(32 位),这取决于架构。还有其他特定于架构的类型,例如 Go 中的所有引用类型。幸运的是,你不需要知道这些信息来创建或使用值。但如果编译器不知道这些信息,它就无法保护你免受在程序及其运行的机器上可能造成损害的操作。

5.1. 用户定义类型

Go 允许你声明自己的类型。当你声明一个新类型时,声明被构造为向编译器提供大小和表示信息,类似于内置类型的工作方式。在 Go 中声明用户定义类型有两种方式。最常见的方式是使用关键字 struct,这允许你创建一个复合类型。

结构体类型是通过组合一组唯一的字段声明的。结构体中的每个字段都声明了一个已知类型,这可以是内置类型或另一个用户定义类型。

列表 5.1. 结构体类型的声明
01 // user defines a user in the program.
02 type user struct {
03    name       string
04    email      string
05    ext        int
06    privileged bool
07 }

在 列表 5.1 中,你可以看到结构体类型的声明。声明从关键字 type 开始,然后是新类型的名称,最后是关键字 struct。这个结构体类型包含四个字段,每个字段基于不同的内置类型。你可以看到字段是如何组合在一起来构成数据结构的。一旦声明了类型,就可以从该类型创建值。

列表 5.2. 声明结构体类型的变量并设置为它的零值
09 // Declare a variable of type user.
10 var bill user

在 列表 5.2 的第 10 行,关键字 var 创建了一个名为 bill 的类型为 user 的变量。当你声明变量时,变量所代表的值总是被初始化。该值可以用特定的值初始化,也可以初始化为其零值,这是该变量类型的默认值。对于数值类型,零值将是 0;对于字符串,它将是空字符串;对于布尔值,它将是 false。在结构的情况下,零值将适用于结构中的所有不同字段。

任何时间创建并初始化为零值的变量时,使用关键字 var 是惯用的。保留关键字 var 的使用,以表示变量正在被设置为它的零值。如果变量将被初始化为非零值,则使用带有结构字面量的短变量声明运算符。

列表 5.3. 使用结构字面量声明结构类型变量的声明
12 // Declare a variable of type user and initialize all the fields.
13 lisa := user{
14     name:       "Lisa",
15     email:      "lisa@email.com",
16     ext:        123,
17     privileged: true,
18 }

列表 5.3 展示了如何声明一个类型为 user 的变量,并将其值初始化为非零值。在第 13 行,我们提供了一个变量名,后面跟着短变量声明运算符。这个运算符是带有等号的冒号(:=)。短变量声明运算符在一个操作中完成两个目的:它既声明又初始化一个变量。根据运算符右侧的类型信息,短变量声明运算符可以确定变量的类型。

由于我们正在创建和初始化结构类型,我们使用结构字面量来执行初始化。结构字面量的形式是带有初始化声明在其内的花括号。

列表 5.4. 使用结构字面量创建结构类型值
13 user{
14     name:       "Lisa",
15     email:      "lisa@email.com",
16     ext:        123,
17     privileged: true,
18 }

结构字面量对于结构类型可以采用两种形式。列表 5.4 展示了第一种形式,即在每个单独的行上声明结构要初始化的字段和值。使用冒号来分隔两者,并且需要一个尾随逗号。字段的顺序不重要。第二种形式没有字段名,只是声明值。

列表 5.5. 不声明字段名创建结构类型值
12 // Declare a variable of type user.
13 lisa := user{"Lisa", "lisa@email.com", 123, true}

值也可以放在单独的行上,但传统上,使用这种形式时,值通常放在同一行上,且后面没有逗号。在这种情况下,值的顺序很重要,需要与结构声明中字段的顺序相匹配。在声明结构类型时,你不仅限于使用内置类型。你还可以使用其他用户定义的类型来声明字段。

列表 5.6. 基于其他结构类型声明字段
20 // admin represents an admin user with privileges.
21 type admin struct {
22     person user
23     level  string
24 }

列表 5.6 展示了一个名为 admin 的新结构体类型。这个结构体类型有一个名为 person 的字段,其类型为 user,然后声明了一个名为 level 的第二个字段,其类型为 string。当创建具有类似 person 字段的结构体类型的变量时,使用结构体字面量初始化类型会有所不同。

列表 5.7. 使用结构体字面量创建字段值
26 // Declare a variable of type admin.
27 fred := admin{
28     person: user{
29         name:       "Lisa",
30         email:      "lisa@email.com",
31         ext:        123,
32         privileged: true,
33     },
34     level: "super",
35 }

为了初始化 person 字段,我们需要创建一个 user 类型的值。这正是我们在 listing 5.7 的第 28 行所做的事情。使用结构体字面量形式,创建了一个 user 类型的值并将其赋给 person 字段。

声明用户定义类型的第二种方式是使用现有类型并将其用作新类型的类型规范。当你需要一个可以由现有类型表示的新类型时,这些类型非常出色。标准库使用这种类型声明从内置类型创建高级功能。

列表 5.8. 基于 int64 声明的新类型
type Duration int64

列表 5.8 展示了从标准库的 time 包中声明的一个类型。Duration 是一个表示时间持续到纳秒级别的类型。该类型从内置类型 int64 获取其表示形式。在 Duration 的声明中,我们说 int64Duration 的基本类型。尽管 int64 作为基本类型,但这并不意味着 Go 将它们视为相同。Durationint64 是两个不同且不同的类型。

为了更好地阐明这意味着什么,请看这个无法编译的小程序。

列表 5.9. 分配不同类型值的编译器错误
01 package main
02
03 type Duration int64
04
05 func main() {
06     var dur Duration
07     dur = int64(1000)
08 }

在 listing 5.9 中的程序在第 03 行声明了一个名为 Duration 的类型。然后在第 06 行,声明了一个名为 durDuration 类型的变量并将其设置为它的零值。然后在第 07 行,我们编写了当程序构建时会产生以下编译器错误的代码。

列表 5.10. 实际编译器错误
prog.go:7: cannot use int64(1000) (type int64) as type Duration
           in assignment

编译器清楚地说明了问题所在。int64 类型的值不能用作 Duration 类型的值。换句话说,尽管 int64 类型是 Duration 的基本类型,但 Duration 仍然是它自己的独特类型。不同类型的值不能相互赋值,即使它们是兼容的。编译器不会隐式转换不同类型的值。

5.2. 方法

方法提供了一种向用户定义类型添加行为的方式。方法实际上是包含一个额外参数的函数,该参数在关键字 func 和函数名之间声明。

列表 5.11. listing11.go
01 // Sample program to show how to declare methods and how the Go
02 // compiler supports them.
03 package main
04
05 import (
06     "fmt"
07 )
08
09 // user defines a user in the program.
10 type user struct {
11     name  string

12     email string
13 }
14
15 // notify implements a method with a value receiver.
16 func (u user) notify() {
17     fmt.Printf("Sending User Email To %s<%s>\n",
18         u.name,
19         u.email)
20 }
21
22 // changeEmail implements a method with a pointer receiver.
23 func (u *user) changeEmail(email string) {
24     u.email = email
25 }
26
27 // main is the entry point for the application.
28 func main() {
29     // Values of type user can be used to call methods
30     // declared with a value receiver.
31     bill := user{"Bill", "bill@email.com"}
32     bill.notify()
33
34     // Pointers of type user can also be used to call methods
35     // declared with a value receiver.
36     lisa := &user{"Lisa", "lisa@email.com"}
37     lisa.notify()
38
39     // Values of type user can be used to call methods
40     // declared with a pointer receiver.
41     bill.changeEmail("bill@newdomain.com")
42     bill.notify()
43
44     // Pointers of type user can be used to call methods
45     // declared with a pointer receiver.
46     lisa.changeEmail("lisa@comcast.com")
47     lisa.notify()
48 }

listing 5.11 的第 16 行和第 23 行展示了两种不同的方法。关键字 func 和函数名之间的参数被称为 接收者,它将函数绑定到指定的类型。当一个函数有接收者时,该函数被称为 方法。当你运行程序时,你会得到以下输出。

列表 5.12. 列表 11.go 的输出
Sending User Email To Bill<bill@email.com>
Sending User Email To Lisa<lisa@email.com>
Sending User Email To Bill<bill@newdomain.com>
Sending User Email To Lisa<lisa@comcast.com>

让我们来看看程序在做什么。在第 10 行,程序声明了一个名为 user 的结构体类型,然后声明了一个名为 notify 的方法。

列表 5.13. listing11.go: 行 09–20
09 // user defines a user in the program.
10 type user struct {
11     name  string
12     email string
13 }
14
15 // notify implements a method with a value receiver.
16 func (u user) notify() {
17     fmt.Printf("Sending User Email To %s<%s>\n",
18         u.name,
19         u.email)
20 }

Go 中有两种接收者类型: 接收者和 指针 接收者。在 列表 5.13 的第 16 行,notify 方法声明了一个值接收者。

列表 5.14. 声明具有值接收者的方法
func (u user) notify() {

notify 的接收者被声明为 user 类型的值。当你使用值接收者声明一个方法时,该方法将始终针对用于创建方法调用的值的副本进行操作。让我们跳到程序 列表 5.11 的第 32 行,看看对 notify 的方法调用。

列表 5.15. listing11.go: 行 29–32
29     // Values of type user can be used to call methods
30     // declared with a value receiver.
31     bill := user{"Bill", "bill@email.com"}
32     bill.notify()

列表 5.15 展示了使用 user 类型的值调用 notify 方法。在第 31 行,声明了一个名为 billuser 类型变量,并用一个名字和电子邮件地址进行初始化。然后在第 32 行,使用变量 bill 调用了 notify 方法。

列表 5.16. 从变量调用方法
bill.notify()

语法看起来与从包中调用函数时的语法相似。然而,在这种情况下,bill 不是一个包名,而是一个变量名。当我们在这种情况下调用 notify 方法时,bill 的值是调用时的接收者值,而 notify 方法是在这个值的副本上操作的。

你也可以使用指针调用声明为值接收者的方法。

列表 5.17. listing11.go: 行 34–37
34     // Pointers of type user can also be used to call methods
35     // declared with a value receiver.
36     lisa := &user{"Lisa", "lisa@email.com"}
37     lisa.notify()

列表 5.17 展示了使用 user 类型的指针调用 notify 方法。在第 36 行,声明了一个名为 lisa 的指针类型 user 变量,并用一个名字和电子邮件地址进行初始化。然后在第 37 行,使用指针变量调用了 notify 方法。为了支持方法调用,Go 调整指针值以符合方法的接收者。你可以想象 Go 正在执行以下操作。

列表 5.18. Go 在代码之下所做的工作
(*lisa).notify()

列表 5.18 展示了 Go 编译器为了支持方法调用所做的基本工作。指针值被解引用,使得方法调用符合值接收者的要求。再次强调,notify 是在副本上操作的,但这次是 lisa 指针所指向的值的副本。

你也可以声明具有指针接收者的方法。

列表 5.19. listing11.go: 行 22–25
22 // changeEmail implements a method with a pointer receiver.
23 func (u *user) changeEmail(email string) {
24     u.email = email
25 }

列表 5.19 展示了 changeEmail 方法的声明,该方法使用指针接收者。这次,接收者不是一个 user 类型的值,而是一个 user 类型的指针。当你调用声明为指针接收者的方法时,用于调用的值与方法共享。

列表 5.20. listing11.go: 行 36, 44–46
36     lisa := &user{"Lisa", "lisa@email.com"}

44     // Pointers of type user can be used to call methods
45     // declared with a pointer receiver.
46     lisa.changeEmail("lisa@newdomain.com")

在 列表 5.20 中,您可以看到 lisa 指针变量的声明,随后是第 46 行对 changeEmail 方法的调用。一旦 changeEmail 的调用返回,对 lisa 指针所指向的值的任何更改将在调用之后反映出来。这要归功于指针接收者。值接收者操作的是用于进行方法调用的值的副本,而指针接收者操作的是实际值。

您还可以使用值调用声明为指针接收者的方法。

列表 5.21. listing11.go: 第 31 行
31     bill := user{"Bill", "bill@email.com"}

39     // Values of type user can be used to call methods
40     // declared with a pointer receiver.
41     bill.changeEmail("bill@newdomain.com")

在 列表 5.21 中,您可以看到变量 bill 的声明,然后是对具有指针接收者的 changeEmail 方法的调用。同样,Go 调整了值以符合方法接收者,以支持调用。

列表 5.22. Go 在代码底下的操作
(&bill).notify()

列表 5.22 展示了 Go 编译器为了支持方法调用所做的基本操作。在这种情况下,值被引用,因此方法调用符合接收者类型。这是 Go 提供的一个极大的便利,允许使用与方法接收者类型不匹配的值和指针进行方法调用。

确定是否使用值或指针接收者有时可能会令人困惑。您可以遵循一些基本准则,这些准则直接来自标准库。

5.3. 类型本质

在为类型声明方法之前,尝试回答这个问题。从这种类型的值中添加或删除某些内容是否需要创建一个新的值或修改现有值?如果答案是创建新值,那么为您的函数使用值接收者。如果答案是修改值,那么使用指针接收者。这也适用于如何将此类型的值传递到程序的其它部分。保持一致性很重要。这个想法是不要关注方法对值做了什么,而要关注值的本质。

5.3.1. 内置类型

内置类型是语言提供的一组类型。我们将其称为数值、字符串和布尔类型的集合。这些类型具有原始性质。正因为如此,当从这些类型之一的值中添加或删除某些内容时,应该创建一个新的值。基于这一点,当将这些类型的值传递给函数和方法时,应该传递值的副本。让我们看看标准库中的一个处理内置值的函数。

列表 5.23. golang.org/src/strings/strings.go: 第 620–625 行
620 func Trim(s string, cutset string) string {
621     if s == "" || cutset == "" {
622         return s
623     }
624     return TrimFunc(s, makeCutsetFunc(cutset))
625 }

在列表 5.23 中,你看到了来自标准库中strings包的Trim函数。Trim函数接收一个要操作的字符串值和一个要查找的字符字符串值。然后它返回一个新的字符串值,这是操作的结果。该函数在调用者的原始字符串值的副本上操作,并返回新字符串值的副本。字符串,就像整数、浮点数和布尔值一样,是原始数据值,应该在传入和传出函数或方法时进行复制。

让我们看看内置类型被视为具有原始性质的第二个例子。

列表 5.24. golang.org/src/os/env.go: 行 38–44
38 func isShellSpecialVar(c uint8) bool {
39     switch c {
40     case '*', '#', '$', '@', '!', '?', '0', '1', '2', '3', '4', '5',
                                                     '6', '7', '8', '9':
41         return true
42     }
43     return false
44 }

列表 5.24 展示了来自env包的isShellSpecialVar函数。这个函数接收一个类型为uint8的值,并返回一个类型为bool的值。注意,没有使用指针来共享参数或返回值的值。调用者传递了他们的uint8值的副本,并接收了truefalse的值。

5.3.2. 引用类型

Go 中的引用类型包括切片、映射、通道、接口和函数类型。当你从这些类型中声明一个变量时,创建的值被称为头值。技术上,字符串也是一种引用类型值。所有不同引用类型的不同头值都包含一个指向底层数据结构的指针。每个引用类型还包含一组用于管理底层数据结构的唯一字段。你永远不会共享引用类型值,因为头值被设计为可复制的。头值包含一个指针;因此,你可以传递任何引用类型值的副本,并内在地共享底层数据结构。

让我们看看net包中的一个类型。

列表 5.25. golang.org/src/net/ip.go: 行 32
32 type IP []byte

列表 5.25 展示了名为IP的类型,它被声明为一个字节数组切片。当你想要声明围绕内置或引用类型的行为时,声明此类类型是有用的。编译器只允许你为命名用户定义类型声明方法。

列表 5.26. golang.org/src/net/ip.go: 行 329–337
329 func (ip IP) MarshalText() ([]byte, error) {
330     if len(ip) == 0 {
331         return []byte(""), nil
332     }
333     if len(ip) != IPv4len && len(ip) != IPv6len {
334         return nil, errors.New("invalid IP address")
335     }
336     return []byte(ip.String()), nil
337 }

列表 5.26 中的MarshalText方法使用类型为IP的值接收器声明。正如你所期望的,这是一个值接收器,因为你不会共享引用类型值。这也适用于将引用类型值作为参数传递给函数和方法。

列表 5.27. golang.org/src/net/ip.go: 行 318–325
318 // ipEmptyString is like ip.String except that it returns
319 // an empty string when ip is unset.
320 func ipEmptyString(ip IP) string {
321     if len(ip) == 0 {
322         return ""
323     }
324     return ip.String()
325 }

在列表 5.27 中,你看到了ipEmptyString函数。这个函数接收一个类型为IP的值。再次,你可以看到调用者为此参数的引用类型值没有与函数共享。函数传递了调用者的引用类型值的副本。这也适用于返回值。最终,引用类型值被处理得像原始数据值一样。

5.3.3. 结构类型

结构类型可以表示可能具有原始或非原始性质的数据值。当决定结构类型值在需要向值中添加或从值中删除某些内容时不应被修改时,它应遵循内置和引用类型的指南。让我们先看看标准库实现的一个具有原始性质的结构类型。

列表 5.28. golang.org/src/time/time.go: 行 39–55
39 type Time struct {
40     // sec gives the number of seconds elapsed since
41     // January 1, year 1 00:00:00 UTC.
42     sec int64
43
44     // nsec specifies a non-negative nanosecond
45     // offset within the second named by Seconds.
46     // It must be in the range [0, 999999999].
47     nsec int32
48
49     // loc specifies the Location that should be used to
50     // determine the minute, hour, month, day, and year
51     // that correspond to this Time.
52     // Only the zero Time has a nil Location.
53     // In that case it is interpreted to mean UTC.
54     loc *Location
55 }

列表 5.28 中的 Time 结构来自 time 包。当你想到时间时,你会意识到任何给定的时间点都不是可以改变的东西。这正是标准库实现 Time 类型的样子。让我们看看创建 Time 类型值的 Now 函数。

列表 5.29. golang.org/src/time/time.go: 行 781–784
781 func Now() Time {
782     sec, nsec := now()
783     return Time{sec + unixToInternal, nsec, Local}
784 }

列表 5.29 中的代码显示了 Now 函数的实现。此函数创建一个 Time 类型的值,并将该 Time 值的副本返回给调用者。没有使用指针来共享函数创建的 Time 值。接下来,让我们看看针对 Time 类型声明的某个方法。

列表 5.30. golang.org/src/time/time.go: 行 610–622
610 func (t Time) Add(d Duration) Time {
611     t.sec += int64(d / 1e9)
612     nsec := int32(t.nsec) + int32(d%1e9)
613     if nsec >= 1e9 {
614         t.sec++
615         nsec -= 1e9
616     } else if nsec < 0 {
617         t.sec--
618         nsec += 1e9
619     }
620     t.nsec = nsec
621     return t
622 }

列表 5.30 中的 Add 方法是标准库如何将 Time 类型视为具有原始性质的一个很好的例子。该方法使用值接收者声明,并返回一个新的 Time 值。该方法在其自己的副本上操作调用者的 Time 值,并将其本地 Time 值的副本返回给调用者。调用者决定是否用返回的值替换他们的 Time 值,或者声明一个新的 Time 变量来保存结果。

在大多数情况下,结构类型不表现出原始性质,而是非原始性质。在这些情况下,向类型值中添加或从值中删除某些内容应该会改变值。当这种情况发生时,你想要使用指针与需要它的程序的其他部分共享值。让我们看看标准库实现的一个具有非原始性质的结构类型。

列表 5.31. golang.org/src/os/file_unix.go: 行 15–29
15 // File represents an open file descriptor.
16 type File struct {
17     *file
18 }
19
20 // file is the real representation of *File.
21 // The extra level of indirection ensures that no clients of os
22 // can overwrite this data, which could cause the finalizer
23 // to close the wrong file descriptor.
24 type file struct {
25     fd int
26     name string
27     dirinfo *dirInfo // nil unless directory being read
28     nepipe int32 // number of consecutive EPIPE in Write
29 }

在 列表 5.31 中,你可以看到标准库中 File 类型的声明。这种类型的性质是非原始的。这种类型的值实际上是不安全的进行复制的。未导出类型的注释清楚地说明了这一点。由于无法阻止程序员进行复制,File 类型的实现使用了一个未导出类型的嵌入指针。我们将在本章后面讨论嵌入类型,但这一额外的间接层提供了对复制的保护。并非每个结构类型都需要或应该使用这种额外的保护来实现。程序员应该尊重每种类型的性质,并相应地使用它。

让我们看看 Open 函数的实现。

列表 5.32. golang.org/src/os/file.go: 行 238–240
238 func Open(name string) (file *File, err error) {
239     return OpenFile(name, O_RDONLY, 0)
240 }

在 列表 5.32 中 Open 函数的实现展示了如何使用指针来与函数的调用者共享 File 类型的值。Open 创建了一个 File 类型的值并返回对该值的指针。当工厂函数返回一个指针时,这是一个很好的迹象,表明返回值的本质是非原始的。

即使一个函数或方法永远不会直接改变非原始值的状体,它仍然应该被共享。

列表 5.33. golang.org/src/os/file.go: 行 224–232
224 func (f *File) Chdir() error {
225     if f == nil {
226         return ErrInvalid
227     }
228     if e := syscall.Fchdir(f.fd); e != nil {
229         return &PathError{"chdir", f.name, e}
230     }
231     return nil
232 }

在 列表 5.33 中的 Chdir 方法展示了即使在接收器值上没有进行任何修改,也声明了指针接收器。由于 File 类型的值具有非原始的本质,它们总是被共享,而不是被复制。

使用值接收器或指针接收器的决定不应基于方法是否正在修改接收的值。这个决定应该基于类型的本质。这个指导原则的一个例外是当你需要值类型接收器在处理接口值时提供的灵活性。在这些情况下,即使类型的本质是非原始的,你也可以选择使用值接收器。这完全基于接口值调用存储在其内部的值的方法的机制。在下一节中,你将了解接口值是什么以及如何使用它们调用方法的机制。

5.4. 接口

多态性是能够通过类型的实现来展现不同行为的代码能力。一旦一个类型实现了接口,就可以为该类型的值打开一个全新的功能世界。标准库是一个很好的例子。io 包提供了一套令人难以置信的接口和函数,使得将数据流应用到我们的代码中变得容易。只需实现两个接口,我们就可以利用 io 包背后的所有工程。

但是,在声明和实现接口以供我们自己的程序使用时,有很多细节需要考虑。即使是现有接口的实现也需要理解接口是如何工作的。在我们深入了解接口的工作原理和实现方法之前,让我们快速看一下标准库中接口使用的一个例子。

5.4.1. 标准库

让我们从查看一个实现了一个名为 curl 的流行程序版本的示例程序开始。

列表 5.34. listing34.go
01 // Sample program to show how to write a simple version of curl using
02 // the io.Reader and io.Writer interface support.
03 package main
04
05 import (
06     "fmt"
07     "io"
08     "net/http"
09     "os"
10 )
11
12 // init is called before main.
13 func init() {
14     if len(os.Args) != 2 {
15         fmt.Println("Usage: ./example2 <url>")
16         os.Exit(-1)
17     }
18 }
19
20 // main is the entry point for the application.
21 func main() {
22     // Get a response from the web server.
23     r, err := http.Get(os.Args[1])
24     if err != nil {
25         fmt.Println(err)
26         return
27     }
28
29     // Copies from the Body to Stdout.
30     io.Copy(os.Stdout, r.Body)
31     if err := r.Body.Close(); err != nil {
32         fmt.Println(err)
33     }
34 }

列表 5.34 展示了接口及其在标准库中的使用的力量。在几行代码中,我们通过利用两个与接口值一起工作的函数,创建了一个 curl 程序。在第 23 行,我们调用 http 包中的 Get 函数。http.Get 函数在成功与服务器通信后返回一个 http.Request 类型的指针。http.Request 类型包含一个名为 Body 的字段,它是一个类型为 io.ReadCloser 的接口值。

在第 30 行,将 Body 字段作为第二个参数传递给 io.Copy 函数。io.Copy 函数接受接口类型 io.Reader 的值作为其第二个参数,这个值代表数据流出的源。幸运的是,Body 字段实现了 io.Reader 接口,因此我们可以将 Body 字段传递给 io.Copy 并使用网络服务器作为我们的源。

io.Copy 的第一个参数代表目的地,必须是一个实现了 io.Writer 接口的值。对于我们的目的地,我们传递了来自 os 包的一个特殊接口值,称为 Stdout。这个接口值代表标准输出设备,并且已经实现了 io.Writer 接口。当我们将 BodyStdout 值传递给 io.Copy 函数时,该函数以小数据块的形式将来自网络服务器的数据流式传输到终端窗口。一旦读取并写入最后一个数据块,io.Copy 函数返回。

io.Copy 函数可以为标准库中许多已存在的不同类型执行此工作流程。

列表 5.35. listing35.go
01 // Sample program to show how a bytes.Buffer can also be used
02 // with the io.Copy function.
03 package main
04
05 import (
06     "bytes"
07     "fmt"
08     "io"
09     "os"
10 )
11
12 // main is the entry point for the application.
13 func main() {
14     var b bytes.Buffer
15
16     // Write a string to the buffer.
17     b.Write([]byte("Hello"))
18
19     // Use Fprintf to concatenate a string to the Buffer.
20     fmt.Fprintf(&b, "World!")
21
22     // Write the content of the Buffer to stdout.
23     io.Copy(os.Stdout, &b)
24 }

列表 5.35 展示了一个使用接口来拼接并将数据流式传输到标准输出的程序。在第 14 行,创建了一个来自 bytes 包的 Buffer 类型的变量,然后在第 17 行使用 Write 方法将字符串 Hello 添加到缓冲区中。在第 20 行,调用 fmt 包中的 Fprintf 函数将第二个字符串追加到缓冲区中。

fmt.Fprintf 函数接受一个类型为 io.Writer 的接口值作为其第一个参数。由于 bytes.Buffer 类型的指针实现了 io.Writer 接口,因此它可以被传入,并且 fmt.Fprintf 函数执行拼接操作。最后,在第 23 行再次使用 io.Copy 函数将字符写入终端窗口。由于 bytes.Buffer 类型的指针也实现了 io.Reader 接口,因此可以使用 io.Copy 函数将缓冲区的内容显示到终端窗口。

这两个小例子可能希望向您展示接口的一些好处以及它们在标准库中的使用方式。接下来,让我们更详细地探讨接口是如何实现的。

5.4.2. 实现

接口是仅声明行为的类型。这种行为不是由接口类型直接实现,而是通过用户定义的类型通过方法实现的。当用户定义的类型实现了接口类型声明的集合方法时,用户定义类型的值可以被赋值给接口类型的值。这种赋值将用户定义类型的值存储到接口值中。

如果对接口值进行方法调用,则执行存储的用户定义值的等效方法。由于任何用户定义类型都可以实现任何接口,因此对接口值的方法调用在本质上具有多态性。在这个关系中,用户定义类型通常被称为 具体类型,因为接口值在没有存储的用户定义值实现的情况下没有具体的行为。

关于用户定义类型的值或指针是否满足接口实现,有一些规则。并非所有值都是平等的。这些规则来自名为方法集的章节下的规范。在您开始调查方法集的细节之前,了解接口类型值的外观以及用户定义类型值如何在其中存储是有帮助的。

在 图 5.1 中,您可以看到 user 类型值赋值后接口变量的值。接口值是双字数据结构。第一个字包含指向一个称为 iTable 的内部表的指针,该表包含存储值的类型信息。iTable 包含已存储的值的类型以及与该值相关的方法列表。第二个字是指向存储值的指针。类型信息和指针的组合将两个值之间的关系绑定在一起。

图 5.1. 具体类型值赋值后接口值的简单视图

图 5.1

图 5.2 展示了将指针赋值给接口值时发生的情况。在这种情况下,类型信息将反映已存储的分配类型的指针,并且被分配的地址存储在接口值的第二个字中。

图 5.2. 指针赋值后接口值的简单视图

图 5.2

5.4.3. 方法集

方法集定义了接口合规性的规则。看看以下代码,以帮助您理解方法集在接口中扮演的重要角色。

列表 5.36. listing36.go
01 // Sample program to show how to use an interface in Go.
02 package main
03
04 import (
05     "fmt"
06 )
07
08 // notifier is an interface that defined notification
09 // type behavior.
10 type notifier interface {
11     notify()
12 }
13
14 // user defines a user in the program.
15 type user struct {
16     name  string
17     email string
18 }
19
20 // notify implements a method with a pointer receiver.
21 func (u *user) notify() {
22     fmt.Printf("Sending user email to %s<%s>\n",
23         u.name,
24         u.email)
25 }
26
27 // main is the entry point for the application.
28 func main() {
29     // Create a value of type User and send a notification.
30     u := user{"Bill", "bill@email.com"}
31
32     sendNotification(u)
33

34     // ./listing36.go:32: cannot use u (type user) as type
35     //                     notifier in argument to sendNotification:
36     //   user does not implement notifier
37     //                          (notify method has pointer receiver)
38 }
39
40 // sendNotification accepts values that implement the notifier
41 // interface and sends notifications.
42 func sendNotification(n notifier) {
43     n.notify()
44 }

在 列表 5.36 中,您会看到您预期可以编译的代码,但它却不能编译。在第 10 行,我们声明了一个名为 notifier 的接口,它有一个名为 notify 的单一方法。然后在第 15 行,我们有名为 user 的具体类型的声明以及通过第 21 行的方法声明来实现 notifier 接口。该方法使用 user 类型的指针接收器来实现。

列表 5.37. listing36.go: 行 40–44
40 // sendNotification accepts values that implement the notifier
41 // interface and sends notifications.
42 func sendNotification(n notifier) {
43     n.notify()
44 }

在 列表 5.37 的第 42 行,声明了一个名为 sendNotification 的函数,它接受一个接口类型 notifier 的单一值。然后使用接口值调用 notify 方法来针对存储的值。任何实现了 notifier 接口的值都可以传递给 sendNotification 函数。现在让我们看看 main 函数。

列表 5.38. listing36.go: 行 28–38
28 func main() {
29     // Create a value of type User and send a notification.
30     u := user{"Bill", "bill@email.com"}
31
32     sendNotification(u)
33
34     // ./listing36.go:32: cannot use u (type user) as type
35     //                     notifier in argument to sendNotification:
36     //   user does not implement notifier
37     //                          (notify method has pointer receiver)
38 }

main 函数中,创建了一个具体类型 user 的值,并将其分配给变量 u,在 listing 5.38 中的第 30 行。然后,将 u 的值传递给 send-Notification 函数,在第 32 行。但 sendNotification 的调用导致编译器错误。

列表 5.39. 将类型 user 的值存储到接口值中的编译器错误
./listing36.go:32: cannot use u (type user) as type
                   notifier in argument to sendNotification:
  user does not implement notifier (notify method has pointer receiver)

那么为什么我们在第 21 行实现 notify 方法时收到编译器错误呢?让我们再次查看那段代码。

列表 5.40. listing36.go: 行 08–12, 21–25
08 // notifier is an interface that defined notification
09 // type behavior.
10 type notifier interface {
11     notify()
12 }

21 func (u *user) notify() {
22     fmt.Printf("Sending user email to %s<%s>\n",
23         u.name,
24         u.email)
25 }

列表 5.40 展示了接口是如何实现的,但编译器告诉我们类型 user 的值不实现接口。如果你仔细查看编译器消息,它实际上告诉我们原因。

列表 5.41. 编译器错误的更详细查看
(notify method has pointer receiver)

要理解为什么在用指针接收器实现接口时,类型 user 的值不实现该接口,你需要了解什么是 方法集。方法集定义了与给定类型的值或指针相关联的方法集合。使用的接收器类型将决定一个方法是否与值、指针或两者相关联。

让我们从 Go 规范中记录的方法集规则开始解释。

列表 5.42. 规范中描述的方法集
Values                    Methods Receivers
-----------------------------------------------
    T                        (t T)
   *T                        (t T) and (t *T)

列表 5.42 展示了规范如何描述方法集。它说,类型 T 的值只具有声明了值接收器的方法,作为其方法集的一部分。但类型 T 的指针具有声明了值和指针接收器的方法,作为其方法集的一部分。从值的角度看这些规则是令人困惑的。让我们从接收器的角度看看这些规则。

列表 5.43. 从接收器类型的角度看方法集
Methods Receivers         Values
-----------------------------------------------
   (t T)                     T and *T
   (t *T)                    *T

列表 5.43 展示了相同的规则,但是从接收器的角度。它说,如果你使用指针接收器实现接口,那么只有该类型的指针实现该接口。如果你使用值接收器实现接口,那么该类型的值和指针都实现该接口。如果你再次查看 listing 5.36 中的代码,你现在有了理解编译器错误的上下文。

列表 5.44. listing36.go: 行 28–38
28 func main() {
29     // Create a value of type User and send a notification.
30     u := user{"Bill", "bill@email.com"}
31
32     sendNotification(u)
33
34     // ./listing36.go:32: cannot use u (type user) as type
35     //                     notifier in argument to sendNotification:
36     //   user does not implement notifier
37     //                          (notify method has pointer receiver)
38 }

我们使用指针接收器实现了接口,并尝试将类型 user 的值传递给 sendNotification 函数。在 listing 5.44 中的第 30 和 32 行清楚地展示了这一点。但如果我们将 user 值的地址传递过去,你会看到现在它可以编译并正常工作。

列表 5.45. listing36.go: 行 28–35
28 func main() {
29     // Create a value of type User and send a notification.
30     u := user{"Bill", "bill@email.com"}
31
32     sendNotification(&u)
33
34     // PASSED THE ADDRESS AND NO MORE ERROR.
35 }

在 listing 5.45 中,我们现在有一个可以编译和运行的程序。只有类型 user 的指针可以传递给 sendNotification 函数,因为接口是用指针接收器实现的。

现在的问题是为什么有这种限制?答案来自于这样一个事实,即并不总是能够获取到一个值的地址。

列表 5.46. listing46.go
01 // Sample program to show how you can't always get the
02 // address of a value.
03 package main
04
05 import "fmt"
06
07 // duration is a type with a base type of int.
08 type duration int
09
10 // format pretty-prints the duration value.
11 func (d *duration) pretty() string {
12     return fmt.Sprintf("Duration: %d", *d)
13 }
14
15 // main is the entry point for the application.
16 func main() {
17     duration(42).pretty()
18
19     // ./listing46.go:17: cannot call pointer method on duration(42)
20     // ./listing46.go:17: cannot take the address of duration(42)
21 }

listing 5.46 中的代码尝试获取类型为 duration 的值的地址,但无法做到。这表明并不总是能够获取到一个值的地址。让我们再次查看方法集规则。

列表 5.47. 再次查看方法集规则
Values                    Methods Receivers
-----------------------------------------------
    T                        (t T)
   *T                        (t T) and (t *T)

  Methods Receivers         Values
-----------------------------------------------
   (t T)                     T and *T
   (t *T)                    *T

因为并不总是能够获取到一个值的地址,所以为值设置的方法只包括使用值接收器实现的方法。

5.4.4. 多态

现在你已经理解了接口和方法集背后的机制,让我们来看一个最终的例子,展示接口的多态行为。

列表 5.48. listing48.go
01 // Sample program to show how polymorphic behavior with interfaces.
02 package main
03
04 import (
05     "fmt"
06 )
07
08 // notifier is an interface that defines notification
09 // type behavior.
10 type notifier interface {
11     notify()
12 }
13
14 // user defines a user in the program.
15 type user struct {
16     name  string
17     email string
18 }
19
20 // notify implements the notifier interface with a pointer receiver.
21 func (u *user) notify() {
22     fmt.Printf("Sending user email to %s<%s>\n",
23         u.name,
24         u.email)
25 }
26
27 // admin defines a admin in the program.
28 type admin struct {
29     name  string
30     email string
31 }
32
33 // notify implements the notifier interface with a pointer receiver.
34 func (a *admin) notify() {
35     fmt.Printf("Sending admin email to %s<%s>\n",
36         a.name,
37         a.email)
38 }
39
40 // main is the entry point for the application.
41 func main() {
42     // Create a user value and pass it to sendNotification.
43     bill := user{"Bill", "bill@email.com"}
44     sendNotification(&bill)
45
46     // Create an admin value and pass it to sendNotification.
47     lisa := admin{"Lisa", "lisa@email.com"}
48     sendNotification(&lisa)
49 }
50
51 // sendNotification accepts values that implement the notifier
52 // interface and sends notifications.

53 func sendNotification(n notifier) {
54     n.notify()
55 }

在 listing 5.48 中,我们有一个接口如何提供多态行为的最终示例。在第 10 行,我们有之前列表中声明的相同的 notifier 接口。然后在第 15 到 25 行,我们有名为 user 的结构体的声明,它使用指针接收器实现了 notifier 接口。在第 28 到 38 行,我们有名为 admin 的结构体的声明,它同样实现了 notifier 接口。我们有两个具体类型实现了 notifier 接口。

在第 53 行,我们再次看到了多态的 sendNotification 函数,它接受实现了 notifier 接口值的参数。由于任何具体的类型值都可以实现接口,这个函数可以执行传入的任何具体类型值的 notify 方法,从而提供多态行为。

列表 5.49. listing48.go: 行 40–49
40 // main is the entry point for the application.
41 func main() {
42     // Create a user value and pass it to sendNotification.
43     bill := user{"Bill", "bill@email.com"}
44     sendNotification(&bill)
45
46     // Create an admin value and pass it to sendNotification.
47     lisa := admin{"Lisa", "lisa@email.com"}
48     sendNotification(&lisa)
49 }

最后,在 listing 5.49 中,你看到所有内容都汇集在一起。在 main 函数的第 43 行创建了一个类型为 user 的值,然后在该值的地址在第 44 行传递给 send-Notification。这导致 user 类型声明的 notify 方法被执行。然后我们在第 47 和 48 行对类型为 admin 的值做同样的操作。最后,因为 sendNotification 接受类型为 notifier 的接口值,所以函数可以执行 useradmin 都实现的行为。

5.5. 类型嵌入

Go 允许你使用现有的类型,并扩展和改变它们的行为。这种能力对于代码重用以及为了满足新的需求而改变现有类型的行为非常重要。这是通过 类型嵌入 实现的。它的工作原理是,通过在新的结构体类型声明中声明一个现有类型,从而使用现有类型。然后,被嵌入的类型被称为新的 外部 类型的 内部 类型。

通过内部类型提升,内部类型的标识符被提升到外部类型。这些提升的标识符成为外部类型的一部分,就像它们被类型本身显式声明一样。然后外部类型由内部类型包含的所有内容组成,可以添加新的字段和方法。外部类型还可以声明与内部类型相同的标识符,并覆盖它需要的任何字段或方法。这就是现有类型如何被扩展和更改的方式。

让我们从展示类型嵌入基本原理的示例程序开始。

列表 5.50. listing50.go
01 // Sample program to show how to embed a type into another type and
02 // the relationship between the inner and outer type.
03 package main
04
05 import (
06     "fmt"
07 )
08
09 // user defines a user in the program.
10 type user struct {
11     name  string
12     email string
13 }
14
15 // notify implements a method that can be called via
16 // a value of type user.
17 func (u *user) notify() {
18     fmt.Printf("Sending user email to %s<%s>\n",
19     u.name,
20     u.email)
21 }
22
23 // admin represents an admin user with privileges.
24 type admin struct {
25     user  // Embedded Type
26     level string
27 }
28
29 // main is the entry point for the application.
30 func main() {
31     // Create an admin user.
32     ad := admin{
33         user: user{
34             name:  "john smith",
35             email: "john@yahoo.com",
36         },
37         level: "super",
38     }
39
40     // We can access the inner type's method directly.
41     ad.user.notify()
42
43     // The inner type's method is promoted.
44     ad.notify()
45 }

在列表 5.50 中,我们有一个程序,展示了如何嵌入类型并访问嵌入的标识符。我们从第 10 行和第 24 行声明两个结构体类型开始。

列表 5.51. listing50.go: 行 09–13, 23–27
09 // user defines a user in the program.
10 type user struct {
11     name  string
12     email string
13 }

23 // admin represents an admin user with privileges.
24 type admin struct {
25     user  // Embedded Type
26     level string
27 }

在列表 5.51 的第 10 行,我们声明了一个名为user的结构体类型,然后在第 24 行我们声明了第二个名为admin的结构体类型。在admin类型的声明(第 25 行)中,我们将user类型作为admin的内部类型嵌入。要嵌入一个类型,只需声明类型名即可。在第 26 行,我们声明了一个名为level的字段。注意声明字段和嵌入类型之间的区别。

一旦我们在admin内部嵌入user类型,我们就可以说user是外部类型admin的内部类型。拥有内部和外部类型的概念使得理解两者之间的关系变得更容易。

列表 5.52. listing50.go: 行 15–21
15 // notify implements a method that can be called via
16 // a value of type user.
17 func (u *user) notify() {
18     fmt.Printf("Sending user email to %s<%s>\n",
19     u.name,
20     u.email)
21 }

列表 5.52 展示了使用类型user的指针接收器声明了一个名为notify的方法。该方法仅显示一条友好的消息,说明正在向特定的用户和电子邮件地址发送电子邮件。现在让我们看看main函数。

列表 5.53. listing50.go: 行 30–45
30 func main() {
31     // Create an admin user.
32     ad := admin{
33         user: user{
34             name:  "john smith",
35             email: "john@yahoo.com",
36         },

37         level: "super",
38     }
39
40     // We can access the inner type's method directly.
41     ad.user.notify()
42
43     // The inner type's method is promoted.
44     ad.notify()
45 }

列表 5.53 中的main函数展示了类型嵌入的机制。在第 32 行,创建了一个类型为admin的值。内部类型的初始化是通过结构体字面量来执行的,要访问内部类型,我们只需使用类型的名称。关于内部类型的一个特殊之处在于,它始终独立存在。这意味着内部类型永远不会失去其身份,并且始终可以直接访问。

列表 5.54. listing50.go: 行 40–41
40     // We can access the inner type's method directly.
41     ad.user.notify()

在列表 5.54 的第 41 行,你可以看到一个对notify方法的调用。这个调用是通过直接通过admin外部类型变量ad访问user内部类型来实现的。这展示了内部类型如何独立存在并且始终可访问。但多亏了内部类型提升,notify方法也可以直接从ad变量中访问。

列表 5.55. listing50.go: 行 43–45
43     // The inner type's method is promoted.
44     ad.notify()
45 }

列表 5.55 在第 44 行显示了从外部类型变量调用 notify 方法。由于内部类型的标识符被提升到外部类型,我们可以通过外部类型的价值访问内部类型的标识符。让我们通过添加一个接口来修改示例。

列表 5.56. listing56.go
01 // Sample program to show how embedded types work with interfaces.
02 package main
03
04 import (
05     "fmt"
06 )
07
08 // notifier is an interface that defined notification
09 // type behavior.
10 type notifier interface {
11     notify()

12 }
13
14 // user defines a user in the program.
15 type user struct {
16     name  string
17     email string
18 }
19
20 // notify implements a method that can be called via
21 // a value of type user.
22 func (u *user) notify() {
23     fmt.Printf("Sending user email to %s<%s>\n",
24     u.name,
25     u.email)
26 }
27
28 // admin represents an admin user with privileges.
29 type admin struct {
30     user
31     level string
32 }
33
34 // main is the entry point for the application.
35 func main() {
36     // Create an admin user.
37     ad := admin{
38         user: user{
39             name:  "john smith",
40             email: "john@yahoo.com",
41         },
42         level: "super",
43     }
44
45     // Send the admin user a notification.
46     // The embedded inner type's implementation of the
47     // interface is "promoted" to the outer type.
48     sendNotification(&ad)
49 }
50
51 // sendNotification accepts values that implement the notifier
52 // interface and sends notifications.
53 func sendNotification(n notifier) {
54     n.notify()
55 }

列表 5.56 中的示例代码使用了之前的相同代码,但做了一些修改。

列表 5.57. listing56.go: 第 08–12 行,第 51–55 行
08 // notifier is an interface that defined notification
09 // type behavior.
10 type notifier interface {
11     notify()
12 }

51 // sendNotification accepts values that implement the notifier
52 // interface and sends notifications.
53 func sendNotification(n notifier) {
54     n.notify()
55 }

在 列表 5.57 的第 08 行,我们有 notifier 接声明的声明。然后在第 53 行,我们有 sendNotification 函数,它接受一个类型为 notifier 的接口值。从之前的代码中我们知道,user 类型声明了一个名为 notify 的方法,该方法使用指针接收器实现了 notifier 接口。因此,我们可以继续到对 main 函数所做的修改。

列表 5.58. listing56.go: 第 35–49 行
35 func main() {
36     // Create an admin user.
37     ad := admin{
38         user: user{
39             name:  "john smith",
40             email: "john@yahoo.com",
41         },
42         level: "super",
43     }
44
45     // Send the admin user a notification.
46     // The embedded inner type's implementation of the
47     // interface is "promoted" to the outer type.
48     sendNotification(&ad)
49 }

这就是事情变得有趣的地方。在 列表 5.58 的第 37 行,我们创建了 admin 外部类型变量 ad。然后在第 48 行,我们将外部类型变量的地址传递给 sendNotification 函数。编译器接受将外部类型指针赋值为一个实现了 notifier 接口的价值。但如果你查看整个示例程序,你不会看到 admin 类型实现了接口。

多亏了内部类型提升,内部类型对接口的实现已经提升到外部类型。这意味着外部类型现在实现了接口,这是由于内部类型的实现。当我们运行这个示例程序时,我们得到以下输出。

列表 5.59. 列表 56.go 的输出
Output:
Sending user email to john smith<john@yahoo.com>

20 // notify implements a method that can be called via
21 // a value of type user.
22 func (u *user) notify() {
23     fmt.Printf("Sending user email to %s<%s>\n",
24     u.name,
25     u.email)
26 }

你可以在 列表 5.59 中看到调用了内部类型的接口实现。

如果外部类型不想使用内部类型的实现,因为它需要自己的实现,那会怎样呢?让我们看看另一个示例程序,它解决了这个问题。

列表 5.60. listing60.go
01 // Sample program to show what happens when the outer and inner
02 // types implement the same interface.
03 package main
04
05 import (
06     "fmt"
07 )
08
09 // notifier is an interface that defined notification
10 // type behavior.
11 type notifier interface {
12     notify()
13 }
14
15 // user defines a user in the program.
16 type user struct {
17     name  string
18     email string
19 }
20
21 // notify implements a method that can be called via
22 // a value of type user.
23 func (u *user) notify() {
24     fmt.Printf("Sending user email to %s<%s>\n",
25         u.name,
26         u.email)
27 }
28
29 // admin represents an admin user with privileges.
30 type admin struct {
31     user
32     level string
33 }
34
35 // notify implements a method that can be called via
36 // a value of type admin.
37 func (a *admin) notify() {
38     fmt.Printf("Sending admin email to %s<%s>\n",
39         a.name,
40         a.email)
41 }
42
43 // main is the entry point for the application.
44 func main() {
45     // Create an admin user.

46     ad := admin{
47         user: user{
48             name:  "john smith",
49             email: "john@yahoo.com",
50         },
51         level: "super",
52     }
53
54     // Send the admin user a notification.
55     // The embedded inner type's implementation of the
56     // interface is NOT "promoted" to the outer type.
57     sendNotification(&ad)
58
59     // We can access the inner type's method directly.
60     ad.user.notify()
61
62     // The inner type's method is NOT promoted.
63     ad.notify()
64 }
65
66 // sendNotification accepts values that implement the notifier
67 // interface and sends notifications.
68 func sendNotification(n notifier) {
69     n.notify()
70 }

列表 5.60 中的示例代码使用了之前的相同代码,但做了一些修改。

列表 5.61. listing60.go: 第 35–41 行
35 // notify implements a method that can be called via
36 // a value of type admin.
37 func (a *admin) notify() {
38     fmt.Printf("Sending admin email to %s<%s>\n",
39         a.name,
40         a.email)
41 }

这个代码示例通过 admin 类型添加了 notifier 接口的实现。当调用 admin 类型的实现时,它将显示 "Sending admin email",而不是显示 "Sending user email"user 类型实现。

main 函数也有一些其他修改。

列表 5.62. listing60.go: 第 43–64 行
43 // main is the entry point for the application.
44 func main() {
45     // Create an admin user.
46     ad := admin{
47         user: user{
48             name:  "john smith",
49             email: "john@yahoo.com",

50         },
51         level: "super",
52     }
53
54     // Send the admin user a notification.
55     // The embedded inner type's implementation of the
56     // interface is NOT "promoted" to the outer type.
57     sendNotification(&ad)
58
59     // We can access the inner type's method directly.
60     ad.user.notify()
61
62     // The inner type's method is NOT promoted.
63     ad.notify()
64 }

在列表 5.62 的第 46 行,我们再次创建了外部的ad类型变量。在第 57 行,将ad变量的地址传递给sendNotification函数,并且该值被接受为实现了接口。在第 60 行,代码直接从user内部类型调用notify方法。最后,在第 63 行使用外部类型变量ad调用notify方法。当你查看这个示例程序的输出时,你会看到不同的故事。

列表 5.63. listing60.go 的输出
Sending Admin Email To john smith<john@yahoo.com>
Sending user email to john smith<john@yahoo.com>
Sending admin email to john smith<john@yahoo.com>

这次你看到了admin类型实现notifier接口是如何通过sendNotification函数和通过使用外部类型变量ad来执行的。这表明一旦外部类型实现了notify方法,内部类型的实现就不会被提升。但内部类型始终存在,因此代码仍然可以直接调用内部类型的实现。

5.6. 导出和未导出标识符

将可见性规则应用于你声明的标识符的能力对于良好的 API 设计至关重要。Go 支持从包中导出和未导出标识符以提供此功能。在第三章中,我们讨论了打包以及如何从一个包中导入标识符到另一个包。有时,你可能不希望类型、函数或方法等标识符成为包的公共 API 的一部分。在这些情况下,你需要一种方法来声明这些标识符,使它们在包外部未知。你需要声明它们为未导出。

让我们从展示如何从包中未导出标识符的示例程序开始。

列表 5.64. listing64/
counters/counters.go
-----------------------------------------------------------------------
01 // Package counters provides alert counter support.
02 package counters
03
04 // alertCounter is an unexported type that
05 // contains an integer counter for alerts.
06 type alertCounter int

listing64.go
-----------------------------------------------------------------------
01 // Sample program to show how the program can't access an
02 // unexported identifier from another package.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing64/counters"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a variable of the unexported type and initialize
14     // the value to 10.
15     counter := counters.alertCounter(10)
16
17     // ./listing64.go:15: cannot refer to unexported name
18     //                                         counters.alertCounter
19     // ./listing64.go:15: undefined: counters.alertCounter
20
21     fmt.Printf("Counter: %d\n", counter)
22 }

在这个例子中,我们有两个代码文件。一个是名为counters.go,位于名为counters的独立包中。第二个代码文件名为listing64.go,它导入了counters包。让我们从counters包内部的代码开始。

列表 5.65. counters/counters.go
01 // Package counters provides alert counter support.
02 package counters
03
04 // alertCounter is an unexported type that
05 // contains an integer counter for alerts.
06 type alertCounter int

列表 5.65 将counters包的代码单独列出。你应该注意到的第一件事是第 02 行。到目前为止,所有代码示例都使用了package main,但在这里你看到了package counters。当你编写的代码将存在于自己的包中时,将包的名称与代码所在的文件夹名称相同是一个好习惯。所有的 Go 工具都期望这种约定,因此遵循它是一个好习惯。

counters包中,我们在第 06 行声明了一个名为alertCounter的单个标识符。这个标识符是一个使用int作为其基本类型的类型。这个标识符的一个重要方面是它已经被未导出。

当一个标识符以小写字母开头时,该标识符是未导出的或对包外部的代码来说是未知的。当一个标识符以大写字母开头时,它是导出的或对包外部的代码来说是已知的。让我们看看导入这个包的代码。

列表 5.66. listing64.go
01 // Sample program to show how the program can't access an
02 // unexported identifier from another package.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing64/counters"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a variable of the unexported type and initialize
14     // the value to 10.
15     counter := counters.alertCounter(10)
16
17     // ./listing64.go:15: cannot refer to unexported name
18     //                                         counters.alertCounter
19     // ./listing64.go:15: undefined: counters.alertCounter
20
21     fmt.Printf("Counter: %d\n", counter)
22 }

列表 5.66 中的 listing64.go 代码在第 03 行声明了 main 包,然后在第 08 行导入了 counters 包。导入 counters 包后,我们转到 main 函数的第 15 行。

列表 5.67. listing64.go: 行 13–19
13     // Create a variable of the unexported type and initialize
14     // the value to 10.
15     counter := counters.alertCounter(10)
16
17     // ./listing64.go:15: cannot refer to unexported name
18     //                                         counters.alertCounter
19     // ./listing64.go:15: undefined: counters.alertCounter

在列表 5.67 的第 15 行,代码尝试创建一个未导出类型 alertCounter 的值。但这段代码产生了编译器错误,指出第 15 行的代码不能引用未导出的标识符 counters.alertCounter。这个标识符是未定义的。

由于 counters 包中的 alertCounter 类型是用小写字母声明的,因此它是未导出的,因此在列表 64 中的代码中是未知的。如果我们将类型改为以大写字母开头,那么编译器错误就会消失。让我们看看实现 counters 包工厂函数的新示例程序。

列表 5.68. listing68/
counters/counters.go
-----------------------------------------------------------------------
01 // Package counters provides alert counter support.
02 package counters
03
04 // alertCounter is an unexported type that
05 // contains an integer counter for alerts.
06 type alertCounter int
07
08 // New creates and returns values of the unexported
09 // type alertCounter
10 func New(value int) alertCounter {
11     return alertCounter(value)
12 }

listing68.go
-----------------------------------------------------------------------
01 // Sample program to show how the program can access a value
02 // of an unexported identifier from another package.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing68/counters"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a variable of the unexported type using the exported
14     // New function from the package counters.
15     counter := counters.New(10)
16
17     fmt.Printf("Counter: %d\n", counter)
18 }

这个例子已经被修改为使用工厂函数来创建未导出的 alertCounter 类型的值。让我们首先看看 counters 包中的代码。

列表 5.69. counters/counters.go
01 // Package counters provides alert counter support.
02 package counters
03
04 // alertCounter is an unexported type that
05 // contains an integer counter for alerts.
06 type alertCounter int
07
08 // New creates and returns values of the unexported
09 // type alertCounter.
10 func New(value int) alertCounter {
11     return alertCounter(value)
12 }

列表 5.69 展示了我们针对 counters 包所做的修改。alertCounter 类型仍然未导出,但现在在第 10 行我们有一个名为 New 的函数。在 Go 中,给工厂函数命名通常使用 New。这个 New 函数做了一些有趣的事情:它创建了一个未导出类型的值,并将该值返回给调用者。让我们看看列表 68 中的 main 函数。

列表 5.70. listing68.go
11 // main is the entry point for the application.
12 func main() {
13     // Create a variable of the unexported type using the exported
14     // New function from the package counters.
15     counter := counters.New(10)
16
17     fmt.Printf("Counter: %d\n", counter)
18 }

在列表 5.70 的第 15 行,你可以看到对 counters 包中 New 函数的调用。New 函数返回的值被分配给一个名为 counter 的变量。这个程序可以编译和运行,但为什么?New 函数返回的是一个未导出类型 alertCounter 的值,而 main 能够接受这个值并创建一个未导出类型的变量。

这是因为两个原因。首先,标识符是导出或未导出的,而不是值。其次,短变量声明运算符能够推断类型并创建一个未导出类型的变量。你永远不能显式创建一个未导出类型的变量,但短变量声明运算符可以。

让我们看看一个新的示例程序,它展示了结构体类型的字段如何受到这些可见性规则的影响。

列表 5.71. listing71/
entities/entities.go
-----------------------------------------------------------------------
01 // Package entities contains support for types of
02 // people in the system.

03 package entities
04
05 // User defines a user in the program.
06 type User struct {
07     Name  string
08     email string
09 }

listing71.go
-----------------------------------------------------------------------
01 // Sample program to show how unexported fields from an exported
02 // struct type can't be accessed directly.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing71/entities"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a value of type User from the entities package.
14     u := entities.User{
15         Name:  "Bill",
16         email: "bill@email.com",
17     }
18
19     // ./example69.go:16: unknown entities.User field 'email' in
20     //                    struct literal
21
22     fmt.Printf("User: %v\n", u)
23 }

列表 5.71 中的代码做了一些改变。现在我们有一个名为 entities 的包,它声明了一个名为 User 的结构体类型。

列表 5.72. entities/entities.go
01 // Package entities contains support for types of
02 // people in the system.
03 package entities
04
05 // User defines a user in the program.
06 type User struct {
07     Name  string
08     email string
09 }

在列表 5.72 的第 06 行,User 类型被声明为导出。声明了两个具有 User 类型的字段,一个名为 Name 的导出字段和一个名为 email 的未导出字段。让我们看看 listing71.go 中的代码。

列表 5.73. listing71.go
01 // Sample program to show how unexported fields from an exported
02 // struct type can't be accessed directly.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing71/entities"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a value of type User from the entities package.
14     u := entities.User{
15         Name:  "Bill",
16         email: "bill@email.com",
17     }
18
19     // ./example71.go:16: unknown entities.User field 'email' in
20     //                    struct literal
21
22     fmt.Printf("User: %v\n", u)
23 }

在第 08 行,entities 包被导入到 列表 5.73 中。在第 14 行,声明了一个名为 u 的变量,它是来自 entities 包的导出类型 User,并初始化了其字段。但是有一个问题。在第 16 行,代码尝试初始化未导出的字段 email,编译器抱怨该字段未知。该标识符无法在 entities 包外部访问,因为它已被未导出。

让我们来看一个最终的例子,以展示嵌入式类型的导出和非导出是如何工作的。

列表 5.74. listing74/
entities/entities.go
-----------------------------------------------------------------------
01 // Package entities contains support for types of
02 // people in the system.
03 package entities
04
05 // user defines a user in the program.
06 type user struct {
07     Name  string
08     Email string
09 }
10
11 // Admin defines an admin in the program.
12 type Admin struct {
13     user   // The embedded type is unexported.
14     Rights int
15 }

listing74.go
-----------------------------------------------------------------------
01 // Sample program to show how unexported fields from an exported
02 // struct type can't be accessed directly.
03 package main
04
05 import (
06     "fmt"
07
08     "github.com/goinaction/code/chapter5/listing74/entities"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a value of type Admin from the entities package.
14     a := entities.Admin{
15         Rights: 10,
16     }
17
18     // Set the exported fields from the unexported
19     // inner type.
20     a.Name = "Bill"
21     a.Email = "bill@email.com"
22
23     fmt.Printf("User: %v\n", a)
24 }

现在,在 列表 5.74 中,entities 包包含两个结构体类型。

列表 5.75. entities/entities.go
01 // Package entities contains support for types of
02 // people in the system.
03 package entities
04
05 // user defines a user in the program.
06 type user struct {
07     Name  string
08     Email string
09 }
10
11 // Admin defines an admin in the program.
12 type Admin struct {
13     user   // The embedded type is unexported.
14     Rights int
15 }

在 列表 5.75 的第 06 行,声明了一个未导出的结构体类型 user。它包含两个名为 NameEmail 的导出字段。在第 12 行,声明了一个名为 Admin 的导出结构体类型。Admin 有一个名为 Rights 的导出字段,但它还嵌入了一个未导出的 user 类型。让我们看看列表 74.go 中的 main 函数中的代码。

列表 5.76. listing74.go: 行 11–24
11 // main is the entry point for the application.
12 func main() {
13     // Create a value of type Admin from the entities package.
14     a := entities.Admin{
15         Rights: 10,
16     }
17
18     // Set the exported fields from the unexported
19     // inner type.
20     a.Name = "Bill"
21     a.Email = "bill@email.com"
22
23     fmt.Printf("User: %v\n", a)
24 }

main 函数从第 14 行开始,在 列表 5.76 中创建了一个来自 entities 包的 Admin 类型的值。由于 user 内部类型未导出,此代码无法访问内部类型并在结构字面量内部初始化它。尽管内部类型未导出,但内部类型中声明的字段是导出的。由于内部类型的标识符提升到外部类型,因此通过外部类型的值可以知道这些导出字段。

因此,在第 20 行和第 21 行,可以通过未导出的内部类型 userNameEmail 字段通过外部类型变量 a 访问和初始化。由于 user 类型未导出,因此无法直接访问内部类型。

5.7. 摘要

  • 可以使用关键字 struct 或指定现有类型来声明用户定义类型。

  • 方法提供了一种向用户定义类型添加行为的方式。

  • 将类型视为具有两种性质之一:原始或非原始。

  • 接口是声明行为并提供多态性的类型。

  • 类型嵌入提供了一种在不使用继承的情况下扩展类型的能力。

  • 标识符要么从包中导出,要么未导出。

第六章. 并发

本章内容

  • 使用 goroutines 运行代码

  • 检测和修复竞态条件

  • 使用通道共享数据

通常,一个程序可以编写为一条执行单一任务并完成的线性代码路径。当这是可能的时候,总是选择这个选项,因为这种类型的程序通常更容易编写和维护。但是,有时同时执行多个任务会带来更大的好处。一个例子是具有能够同时接收多个数据请求的 Web 服务,这些请求可以在同一时间针对不同的套接字。每个套接字请求都是唯一的,并且可以独立于其他任何请求进行处理。能够并发执行请求的能力可以显著提高这类系统的性能。考虑到这一点,并发支持已经被直接构建到 Go 的语言和运行时中。

Go 中的并发是函数能够独立运行的能力。当一个函数被创建为 goroutine 时,它被视为一个独立的作业单元,该单元会被调度并在可用的逻辑处理器上执行。Go 运行时调度器是一块复杂的软件,它管理所有创建并需要处理器时间的 goroutines。调度器位于操作系统之上,将操作系统的线程绑定到逻辑处理器上,这些处理器反过来执行 goroutines。调度器控制着在任何给定时间哪些 goroutines 在哪些逻辑处理器上运行的所有相关事宜。

并发同步来自一个称为通信顺序进程CSP的范式。CSP 是一个通过在 goroutines 之间传递数据而不是锁定数据来同步访问的消息传递模型。用于在 goroutines 之间同步和传递消息的关键数据类型称为通道。对于许多从未使用通道编写并发程序的开发者来说,他们可能会感受到一种敬畏和兴奋的氛围,你也许也会体验到这种感觉。使用通道使得编写并发程序变得更加容易,并使它们更不容易出错。

6.1. 并发与并行

首先,让我们从高层次了解操作系统中的进程线程是什么。这将帮助你理解稍后如何 Go 运行时调度器与操作系统协同工作以并发运行 goroutines。当你运行一个应用程序,例如 IDE 或编辑器时,操作系统会为该应用程序启动一个进程。你可以将进程想象成一个容器,它持有应用程序使用并维护的所有资源。

图 6.1 展示了一个包含任何进程可能分配的常见资源的进程。这些资源包括但不限于内存地址空间、文件、设备和线程的句柄。线程是操作系统调度以运行你在函数中编写的代码的执行路径。每个进程至少包含一个线程,每个进程的初始线程称为主线程。当主线程终止时,应用程序也会终止,因为这条执行路径是应用程序的起源。操作系统调度线程在处理器上运行,无论它们属于哪个进程。不同操作系统用于调度线程的算法始终在变化,并且对程序员来说是抽象的。

图 6.1. 运行应用程序的进程及其线程的简单视图

06fig01_alt.jpg

操作系统调度线程在物理处理器上运行,而 Go 运行时调度 goroutines 在逻辑处理器上运行。每个逻辑处理器单独绑定到单个操作系统线程。截至版本 1.5,默认为为每个可用的物理处理器分配一个逻辑处理器。在版本 1.5 之前,默认只分配一个逻辑处理器。这些逻辑处理器用于执行创建的所有 goroutines。即使只有一个逻辑处理器,也可以调度数十万个 goroutines 并发运行,效率惊人,性能卓越。

在图 6.2 中,你可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。随着 goroutines 的创建和准备运行,它们被放置在调度器的全局运行队列中。不久之后,它们被分配给一个逻辑处理器,并放置在该逻辑处理器的本地运行队列中。从那里,goroutine 等待轮到它被分配逻辑处理器进行执行。

图 6.2. Go 调度器如何管理 goroutines

06fig02_alt.jpg

有时正在运行的 goroutine 可能需要执行阻塞系统调用,例如打开文件。当这种情况发生时,线程和 goroutine 会从逻辑处理器上分离,线程继续阻塞等待系统调用返回。与此同时,存在一个没有线程的逻辑处理器。因此,调度器创建一个新的线程并将其附加到逻辑处理器上。然后调度器将选择本地运行队列中的另一个 goroutine 进行执行。一旦系统调用返回,goroutine 将被放回本地运行队列,线程将被放在一边以备将来使用。

如果 goroutine 需要执行网络 I/O 调用,过程略有不同。在这种情况下,goroutine 将从逻辑处理器分离出来,并移动到运行时集成的网络轮询器。一旦轮询器指示读取或写入操作已准备好,goroutine 将被分配回逻辑处理器以处理操作。调度器中并没有内置对可以创建的逻辑处理器数量的限制。但是,运行时默认将每个程序限制在最多 10,000 个线程。这个值可以通过从runtime/debug包调用SetMaxThreads函数来更改。如果任何程序尝试使用更多线程,程序将崩溃。

并发不是并行。只有在多个代码块同时针对不同的物理处理器执行时才能实现并行。并行是关于同时做很多事情。并发是关于同时管理很多事情。在许多情况下,并发可以超越并行,因为对操作系统和硬件的压力要小得多,这使得系统可以做更多的事情。这种少即是多的哲学是语言的座右铭。

如果你想要并行运行 goroutines,你必须使用多个逻辑处理器。当有多个逻辑处理器时,调度器将在逻辑处理器之间平均分配 goroutines。这将导致 goroutines 在不同的线程上运行。但要实现真正的并行,你仍然需要在具有多个物理处理器的机器上运行你的程序。如果不是这样,那么 goroutines 将并行运行在单个物理处理器上,尽管 Go 运行时正在使用多个线程。

图 6.3 展示了在单个逻辑处理器上并发运行 goroutines 与在两个逻辑处理器上并行运行的差异。不建议盲目更改逻辑处理器的运行时默认值。调度器包含智能算法,这些算法会随着 Go 的每个版本更新和改进。如果你看到性能问题,你认为可以通过更改逻辑处理器的数量来解决,你有能力这样做。你很快就会了解更多关于这方面的信息。

图 6.3. 并发与并行之间的差异

6.2. Goroutines

让我们深入了解调度器的行为以及如何创建 goroutines 并管理它们的生命周期。我们将从使用单个逻辑处理器运行的示例开始,然后再讨论如何并行运行 goroutines。以下是一个创建两个 goroutines 的程序,这两个 goroutines 以并发方式显示英文字母的大小写。

列表 6.1. listing01.go
01 // This sample program demonstrates how to create goroutines and
02 // how the scheduler behaves.
03 package main
04
05 import (
06     "fmt"
07     "runtime"
08     "sync"
09 )
10
11 // main is the entry point for all Go programs.
12 func main() {
13     // Allocate 1 logical processor for the scheduler to use.
14     runtime.GOMAXPROCS(1)
15
16     // wg is used to wait for the program to finish.
17     // Add a count of two, one for each goroutine.
18     var wg sync.WaitGroup
19     wg.Add(2)
20
21     fmt.Println("Start Goroutines")
22

23     // Declare an anonymous function and create a goroutine.
24     go func() {
25         // Schedule the call to Done to tell main we are done.
26         defer wg.Done()
27
28         // Display the alphabet three times
29         for count := 0; count < 3; count++ {
30             for char := 'a'; char < 'a'+26; char++ {
31                 fmt.Printf("%c ", char)
32             }
33         }
34     }()
35
36     // Declare an anonymous function and create a goroutine.
37     go func() {
38         // Schedule the call to Done to tell main we are done.
39         defer wg.Done()
40
41         // Display the alphabet three times
42         for count := 0; count < 3; count++ {
43             for char := 'A'; char < 'A'+26; char++ {
44                 fmt.Printf("%c ", char)
45             }
46         }
47     }()
48
49     // Wait for the goroutines to finish.
50     fmt.Println("Waiting To Finish")
51     wg.Wait()
52
53     fmt.Println("\nTerminating Program")
54 }

在 列表 6.1 的第 14 行,你可以看到对 runtime 包中的 GOMAXPROCS 函数的调用。这是允许程序更改调度器使用的逻辑处理器数量的函数。还有一个可以设置相同名称的环境变量,如果我们不想在代码中特别调用这个函数。通过传递值 1,我们告诉调度器为这个程序使用单个逻辑处理器。

在第 24 行和 37 行,我们声明了两个显示英语字母的匿名函数。第 24 行的函数显示小写字母的字母表,而第 37 行的函数显示大写字母的字母表。这两个函数都是通过使用关键字 go 创建为 goroutines 的。你可以通过 列表 6.2 中的输出看到,每个 goroutine 中的代码都在单个逻辑处理器内并发运行。

列表 6.2. 列表 01.go 的输出
Create Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m
n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z
Terminating Program

第一个 goroutine 完成显示字母表所需的时间非常短,以至于它可以在调度器将其与第二个 goroutine 交换之前完成其工作。这就是为什么你首先看到大写字母的整个字母表,然后是小写字母的字母表。我们创建的两个 goroutines 是并发运行的,一个接一个,执行它们各自的显示字母表的任务。

一旦创建了两个匿名函数作为 goroutines,main 中的代码将继续运行。这意味着 main 函数可以在 goroutines 完成其工作之前返回。如果发生这种情况,程序将在 goroutines 有机会运行之前终止。因此,在第 51 行,main 函数使用 WaitGroup 等待两个 goroutines 完成其工作。

列表 6.3. listing01.go: 行 17–19,23–26,49–51
16     // wg is used to wait for the program to finish.
17     // Add a count of two, one for each goroutine.
18     var wg sync.WaitGroup
19     wg.Add(2)

23     // Declare an anonymous function and create a goroutine.
24     go func() {
25         // Schedule the call to Done to tell main we are done.
26         defer wg.Done()

49     // Wait for the goroutines to finish.
50     fmt.Println("Waiting To Finish")
51     wg.Wait()

WaitGroup 是一个计数信号量,可以用来记录正在运行的 goroutines。当 WaitGroup 的值大于零时,Wait 方法将会阻塞。在第 18 行创建了一个 WaitGroup 类型的变量,然后在第 19 行我们将 WaitGroup 的值设置为 2,表示有两个正在运行的 goroutines。为了减少 WaitGroup 的值并最终释放 main 函数,我们在第 26 行和 39 行的 defer 语句范围内调用了 Done 方法。

关键字 defer 用于在执行函数内部调度其他函数,以便在函数返回时调用。在我们的示例程序中,我们使用关键字 defer 来保证每个 goroutine 完成其工作后都调用一次 Done 方法。

根据调度器的内部算法,一个正在运行的 goroutine 可以在完成工作之前被停止并重新调度以再次运行。调度器这样做是为了防止任何单个 goroutine 限制逻辑处理器。它将停止当前正在运行的 goroutine,并给另一个可运行的 goroutine 一个运行的机会。

图 6.4 从逻辑处理器的角度展示了这一场景。在第 1 步中,调度器开始执行 goroutine A,而 goroutine B 则在运行队列中等待轮到它。然后,在第 2 步中,调度器突然将 goroutine A 与 goroutine B 交换。由于 goroutine A 没有完成,它被放回运行队列。接着,在第 3 步中,goroutine B 完成其工作并消失。这允许 goroutine A 重新开始工作。

图 6.4. 逻辑处理器线程上的 goroutines 交换

你可以通过创建一个需要较长时间来完成其工作的 goroutine 来看到这种行为。

列表 6.4. listing04.go
01 // This sample program demonstrates how the goroutine scheduler
02 // will time slice goroutines on a single thread.
03 package main
04
05 import (
06     "fmt"
07     "runtime"
08     "sync"
09 )
10
11 // wg is used to wait for the program to finish.
12 var wg sync.WaitGroup
13
14 // main is the entry point for all Go programs.
15 func main() {
16     // Allocate 1 logical processors for the scheduler to use.
17     runtime.GOMAXPROCS(1)
18
19     // Add a count of two, one for each goroutine.
20     wg.Add(2)
21
22     // Create two goroutines.
23     fmt.Println("Create Goroutines")

24     go printPrime("A")
25     go printPrime("B")
26
27     // Wait for the goroutines to finish.
28     fmt.Println("Waiting To Finish")
29     wg.Wait()
30
31     fmt.Println("Terminating Program")
32 }
33
34 // printPrime displays prime numbers for the first 5000 numbers.
35 func printPrime(prefix string) {
36     // Schedule the call to Done to tell main we are done.
37     defer wg.Done()
38
39 next:
40     for outer := 2; outer < 5000; outer++ {
41         for inner := 2; inner < outer; inner++ {
42             if outer%inner == 0 {
43                 continue next
44             }
45         }
46         fmt.Printf("%s:%d\n", prefix, outer)
47     }
48     fmt.Println("Completed", prefix)
49 }

列表 6.4 中的程序创建了两个 goroutine,它们打印出 1 到 5,000 之间可以找到的所有质数。找到并显示质数需要一些时间,这会导致调度器在找到所有要查找的质数之前对第一个正在运行的 goroutine 进行时间切片。

当程序启动时,它在第 12 行声明了一个WaitGroup变量,然后在第 20 行将WaitGroup的值设置为 2。在第 24 和第 25 行创建了两个 goroutine,通过在go关键字后指定函数printPrime的名称。第一个 goroutine 被赋予前缀 A,第二个 goroutine 被赋予前缀 B。像任何调用函数一样,可以将参数传递给创建为 goroutine 的函数。当 goroutine 终止时,没有返回参数。当你查看列表 6.5 中的输出时,你可以看到调度器对第一个 goroutine 的交换。

列表 6.5. listing04.go 的输出
Create Goroutines
Waiting To Finish
B:2
B:3
...
B:4583
B:4591
A:3             ** Goroutines Swapped
A:5
...

A:4561
A:4567
B:4603          ** Goroutines Swapped
B:4621
...
Completed B
A:4457          ** Goroutines Swapped
A:4463
...
A:4993
A:4999
Completed A
Terminating Program

Goroutine B 开始首先显示质数。一旦 goroutine B 打印出质数 4591,调度器将交换 goroutine,将 goroutine A 放入线程中,并再次将线程交换给 goroutine B。允许 goroutine B 完成所有工作。一旦 goroutine B 返回,你会看到 goroutine A 再次获得线程以完成其工作。每次运行此程序时,调度器都会稍微改变时间切片发生的位置。

列表 6.1 和 6.4 中的两个示例程序都展示了调度器如何在单个逻辑处理器内并发运行 goroutines。如前所述,Go 标准库在runtime包中有一个名为GOMAXPROCS的函数,允许你指定调度器使用的逻辑处理器数量。这就是如何将运行时更改为为每个可用的物理处理器分配一个逻辑处理器。下一个列表将展示我们的 goroutines 并行运行。

列表 6.6. 如何更改逻辑处理器的数量
import "runtime"

// Allocate a logical processor for every available core.
runtime.GOMAXPROCS(runtime.NumCPU())

runtime 包提供了更改 Go 运行时配置参数的支持。在 代码清单 6.6 中,我们使用两个 runtime 函数来更改调度器使用的逻辑处理器数量。NumCPU 函数返回可用的物理处理器数量;因此,对 GOMAXPROCS 的函数调用为每个可用的物理处理器创建一个逻辑处理器。需要注意的是,使用多个逻辑处理器并不一定意味着更好的性能。需要进行基准测试才能了解在更改任何 runtime 配置参数时程序的性能。

如果我们给调度器提供多个逻辑处理器来使用,我们将在示例程序的输出中看到不同的行为。让我们将逻辑处理器的数量更改为 2 并重新运行第一个打印英语字母的示例。

代码清单 6.7. listing07.go
01 // This sample program demonstrates how to create goroutines and
02 // how the goroutine scheduler behaves with two logical processors.
03 package main
04
05 import (
06     "fmt"
07     "runtime"
08     "sync"
09 )
10
11 // main is the entry point for all Go programs.
12 func main() {
13     // Allocate two logical processors for the scheduler to use.
14     runtime.GOMAXPROCS(2)
15
16     // wg is used to wait for the program to finish.
17     // Add a count of two, one for each goroutine.
18     var wg sync.WaitGroup
19     wg.Add(2)
20
21     fmt.Println("Start Goroutines")
22
23     // Declare an anonymous function and create a goroutine.
24     go func() {
25         // Schedule the call to Done to tell main we are done.
26         defer wg.Done()
27
28         // Display the alphabet three times.
29         for count := 0; count < 3; count++ {
30             for char := 'a'; char < 'a'+26; char++ {
31                 fmt.Printf("%c ", char)
32             }
33         }
34     }()
35
36     // Declare an anonymous function and create a goroutine.
37     go func() {
38         // Schedule the call to Done to tell main we are done.
39         defer wg.Done()
40
41         // Display the alphabet three times.
42         for count := 0; count < 3; count++ {
43             for char := 'A'; char < 'A'+26; char++ {
44                 fmt.Printf("%c ", char)
45             }
46         }
47     }()
48
49     // Wait for the goroutines to finish.
50     fmt.Println("Waiting To Finish")
51     wg.Wait()
52
53     fmt.Println("\nTerminating Program")
54 }

代码清单 6.7 中的示例通过在 14 行调用 GOMAXPROCS 函数创建了两个逻辑处理器。这将允许 goroutines 并行运行。

代码清单 6.8. listing07.go 的输出
Create Goroutines
Waiting To Finish
A B C a D E b F c G d H e I f J g K h L i M j N k O l P m Q n R o S p T
q U r V s W t X u Y v Z w A x B y C z D a E b F c G d H e I f J g K h L
i M j N k O l P m Q n R o S p T q U r V s W t X u Y v Z w A x B y C z D
a E b F c G d H e I f J g K h L i M j N k O l P m Q n R o S p T q U r V
s W t X u Y v Z w x y z
Terminating Program

如果你仔细观察 代码清单 6.8 的输出,你会看到 goroutines 正在并行运行。几乎立即,两个 goroutines 都开始运行,显示器的字母混合在一起。输出是基于在八核机器上运行程序的结果,因此每个 goroutine 都在自己的核心上运行。记住,goroutines 只有在存在多个逻辑处理器并且有物理处理器可以同时运行每个 goroutine 时才能并行运行。

你现在知道了如何创建 goroutines 并理解底层发生了什么。接下来,你需要了解在编写并发程序时可能存在的危险和需要注意的事项。

6.3. 竞态条件

当两个或更多 goroutines 对共享资源进行非同步访问并尝试同时读取和写入该资源时,你就有了一个所谓的 竞态条件。竞态条件是并发编程复杂且具有更大潜在错误可能性的原因。对共享资源的读取和写入操作必须是原子的,换句话说,一次只能由一个 goroutine 完成。

这里有一个包含竞态条件的示例程序。

代码清单 6.9. listing09.go
01 // This sample program demonstrates how to create race
02 // conditions in our programs. We don't want to do this.
03 package main
04
05 import (
06     "fmt"
07     "runtime"
08     "sync"
09 )
10
11 var (
12     // counter is a variable incremented by all goroutines.
13     counter int
14
15     // wg is used to wait for the program to finish.

16     wg sync.WaitGroup
17 )
18
19 // main is the entry point for all Go programs.
20 func main() {
21     // Add a count of two, one for each goroutine.
22     wg.Add(2)
23
24     // Create two goroutines.
25     go incCounter(1)
26     go incCounter(2)
27
28     // Wait for the goroutines to finish.
29     wg.Wait()
30     fmt.Println("Final Counter:", counter)
31 }
32
33 // incCounter increments the package level counter variable.
34 func incCounter(id int) {
35     // Schedule the call to Done to tell main we are done.
36     defer wg.Done()
37
38     for count := 0; count < 2; count++ {
39         // Capture the value of Counter.
40         value := counter
41
42         // Yield the thread and be placed back in queue.
43         runtime.Gosched()
44
45         // Increment our local value of Counter.
46         value++
47
48         // Store the value back into Counter.
49         counter = value
50     }
51 }
代码清单 6.10. listing09.go 的输出
Final Counter: 2

counter 变量被读取和写入四次,每个 goroutine 两次,但程序结束时 counter 变量的值是 2。图 6.5 提供了为什么会出现这种情况的线索。

图 6.5. 竞态条件作用的可视化

每个 goroutine 都会覆盖其他 goroutine 的工作。这发生在 goroutine 交换过程中。每个 goroutine 都会创建counter变量的副本,然后被交换出去以供其他 goroutine 使用。当 goroutine 再次被赋予执行时间时,counter变量的值已经改变,但 goroutine 没有更新其副本。相反,它继续增加其副本,并将值重新设置为counter变量,从而替换了其他 goroutine 执行的工作。

让我们逐步分析代码,以了解它在做什么。从第 25 行和第 26 行可以看到,从incCounter函数创建了两个 goroutine。第 34 行的incCounter函数读取并写入包变量counter,这是我们在这个示例中的共享资源。两个 goroutine 从第 40 行开始读取并将counter变量的副本存储到名为value的局部变量中。然后,在第 46 行,它们将value的副本增加 1,并在第 49 行将新值赋回counter变量。该函数在第 43 行包含对runtime包中的Gosched函数的调用,以释放线程并给其他 goroutine 运行的机会。这是在操作过程中执行的,以强制调度器在两个 goroutine 之间进行交换,以夸大竞争条件的影响。

Go 有一个特殊的工具可以检测代码中的竞争条件。这对于发现这些类型的错误非常有用,尤其是当它们不像我们的示例那样明显时。让我们运行竞争检测器来检测我们的示例代码。

列表 6.11. 使用竞争检测器构建和运行 listing09
go build -race   // Build the code using the race detector flag
./example        // Run the code

==================
WARNING: DATA RACE
Write by goroutine 5:

  main.incCounter()
      /example/main.go:49 +0x96

Previous read by goroutine 6:
  main.incCounter()
      /example/main.go:40 +0x66

Goroutine 5 (running) created at:
  main.main()
      /example/main.go:25 +0x5c

Goroutine 6 (running) created at:
  main.main()
      /example/main.go:26 +0x73
==================
Final Counter: 2
Found 1 data race(s)

列表 6.11 中的竞争检测器已经指出我们示例中的以下四行代码。

列表 6.12. 被竞争检测器标记的代码行
Line 49: counter = value
Line 40: value := counter
Line 25: go incCounter(1)
Line 26: go incCounter(2)

列表 6.12 显示,竞争检测器告诉我们哪个 goroutine 导致了数据竞争,以及哪两行代码存在冲突。指出的是从counter变量读取和写入的代码并不令人惊讶。

我们可以通过使用 Go 对同步 goroutines 的支持,通过锁定共享资源来修复我们的示例并消除竞争条件。

6.4. 锁定共享资源

Go 通过锁定对共享资源的访问来提供传统的 goroutine 同步支持。如果你需要序列化对整数变量或代码块的访问,那么atomicsync包中的函数可能是一个好的解决方案。我们将查看atomic包的一些函数和sync包中的mutex类型。

6.4.1. 原子函数

原子函数提供了对整数和指针访问的低级锁定机制,用于同步。我们可以使用原子函数来修复我们在列表 6.9 中创建的竞争条件。

列表 6.13. listing13.go
01 // This sample program demonstrates how to use the atomic
02 // package to provide safe access to numeric types.
03 package main

04
05 import (
06     "fmt"
07     "runtime"
08     "sync"
09     "sync/atomic"
10 )
11
12 var (
13     // counter is a variable incremented by all goroutines.
14     counter int64
15
16     // wg is used to wait for the program to finish.
17     wg sync.WaitGroup
18 )
19
20 // main is the entry point for all Go programs.
21 func main() {
22     // Add a count of two, one for each goroutine.
23     wg.Add(2)
24
25     // Create two goroutines.
26     go incCounter(1)
27     go incCounter(2)
28
29     // Wait for the goroutines to finish.
30     wg.Wait()
31
32     // Display the final value.
33     fmt.Println("Final Counter:", counter)
34 }
35
36 // incCounter increments the package level counter variable.
37 func incCounter(id int) {
38     // Schedule the call to Done to tell main we are done.
39     defer wg.Done()
40
41     for count := 0; count < 2; count++ {
42         // Safely Add One To Counter.
43         atomic.AddInt64(&counter, 1)
44
45         // Yield the thread and be placed back in queue.
46         runtime.Gosched()
47     }
48 }
列表 6.14. listing13.go 的输出
Final Counter: 4

在第 43 行,程序现在使用atomic包中的AddInt64函数。此函数通过强制一次只有一个 goroutine 可以执行并完成此加法操作来同步整数值的添加。当 goroutine 尝试调用任何原子函数时,它们会自动与引用的变量同步。现在我们得到正确的值4

另外两个有用的原子函数是LoadInt64StoreInt64。这些函数提供了一种安全的方式来读取和写入整数值。以下是一个使用LoadInt64StoreInt64创建同步标志的示例,该标志可以通知多个 goroutine 程序中的特殊条件。

列表 6.15. listing15.go
01 // This sample program demonstrates how to use the atomic
02 // package functions Store and Load to provide safe access
03 // to numeric types.
04 package main
05
06 import (
07     "fmt"
08     "sync"
09     "sync/atomic"
10     "time"
11 )
12
13 var (
14     // shutdown is a flag to alert running goroutines to shutdown.
15     shutdown int64
16
17     // wg is used to wait for the program to finish.
18     wg sync.WaitGroup
19 )
20
21 // main is the entry point for all Go programs.
22 func main() {
23     // Add a count of two, one for each goroutine.
24     wg.Add(2)
25
26     // Create two goroutines.
27     go doWork("A")
28     go doWork("B")
29
30     // Give the goroutines time to run.
31     time.Sleep(1 * time.Second)
32
33     // Safely flag it is time to shutdown.
34     fmt.Println("Shutdown Now")
35     atomic.StoreInt64(&shutdown, 1)
36
37     // Wait for the goroutines to finish.
38     wg.Wait()
39 }
40
41 // doWork simulates a goroutine performing work and
42 // checking the Shutdown flag to terminate early.
43 func doWork(name string) {
44     // Schedule the call to Done to tell main we are done.

45     defer wg.Done()
46
47     for {
48         fmt.Printf("Doing %s Work\n", name)
49         time.Sleep(250 * time.Millisecond)
50
51         // Do we need to shutdown.
52         if atomic.LoadInt64(&shutdown) == 1 {
53             fmt.Printf("Shutting %s Down\n", name)
54             break
55         }
56     }
57 }

在这个例子中,启动了两个 goroutine 并开始执行一些工作。在它们各自的循环的每次迭代之后,goroutine 通过在第 52 行使用LoadInt64函数来检查shutdown变量的值。此函数返回shutdown变量的安全副本。如果值等于1,则 goroutine 会退出循环并终止。

main函数在第 35 行使用StoreInt64函数安全地更改shutdown变量的值。如果任何doWork goroutine 试图在main函数调用StoreInt64的同时调用LoadInt64函数,原子函数将同步调用并保持所有操作安全且无竞争条件。

6.4.2. 互斥锁

另一种同步访问共享资源的方法是使用互斥锁。互斥锁是以互斥排他概念命名的。互斥锁用于在代码周围创建临界区,确保一次只有一个 goroutine 可以执行该代码段。我们还可以使用互斥锁来解决我们在列表 6.9 中创建的竞争条件。

列表 6.16. listing16.go
01 // This sample program demonstrates how to use a mutex
02 // to define critical sections of code that need synchronous
03 // access.
04 package main
05
06 import (
07     "fmt"
08     "runtime"
09     "sync"
10 )
11
12 var (
13     // counter is a variable incremented by all goroutines.
14     counter int
15
16     // wg is used to wait for the program to finish.
17     wg sync.WaitGroup

18
19     // mutex is used to define a critical section of code.
20     mutex sync.Mutex
21 )
22
23 // main is the entry point for all Go programs.
24 func main() {
25     // Add a count of two, one for each goroutine.
26     wg.Add(2)
27
28     // Create two goroutines.
29     go incCounter(1)
30     go incCounter(2)
31
32     // Wait for the goroutines to finish.
33     wg.Wait()
34     fmt.Printf("Final Counter: %d\\n", counter)
35 }
36
37 // incCounter increments the package level Counter variable
38 // using the Mutex to synchronize and provide safe access.
39 func incCounter(id int) {
40     // Schedule the call to Done to tell main we are done.
41     defer wg.Done()
42
43     for count := 0; count < 2; count++ {
44         // Only allow one goroutine through this
45         // critical section at a time.
46         mutex.Lock()
47         {
48             // Capture the value of counter.
49             value := counter
50
51             // Yield the thread and be placed back in queue.
52             runtime.Gosched()
53
54             // Increment our local value of counter.
55             value++
56
57             // Store the value back into counter.
58             counter = value
59         }
60         mutex.Unlock()
61         // Release the lock and allow any
62         // waiting goroutine through.
63     }
64 }

现在对counter变量的操作被包含在由第 46 行和第 60 行的Lock()Unlock()调用定义的临界区中。使用花括号只是为了使临界区更容易看到;它们不是必需的。一次只能有一个 goroutine 进入临界区。只有在调用Unlock()函数之后,另一个 goroutine 才能进入临界区。当线程在第 52 行释放时,调度器将分配相同的 goroutine 继续运行。程序完成后,我们得到正确的值4,竞争条件不再存在。

6.5. 通道

原子函数和互斥锁可以工作,但它们并不使编写并发程序变得更容易、更不容易出错或更有趣。在 Go 中,你不仅只有原子函数和互斥锁来保持共享资源的安全并消除竞争条件。你还有通道,它通过同步 goroutine 在它们发送和接收彼此之间需要共享的资源时来同步 goroutine。

当需要在 goroutine 之间共享资源时,通道作为 goroutine 之间的导管,并提供了一种保证同步交换的机制。在声明通道时,需要指定将要共享的数据类型。内置类型、命名类型、结构体和引用类型的值和指针可以通过通道共享。

在 Go 中创建一个通道需要使用内置函数 make

列表 6.17. 使用 make 创建一个通道
// Unbuffered channel of integers.
unbuffered := make(chan int)

// Buffered channel of strings.
buffered := make(chan string, 10)

在 列表 6.17 中,您可以看到使用内置函数 make 创建无缓冲和缓冲通道的用法。make 的第一个参数需要关键字 chan,然后是通道允许交换的数据类型。如果您正在创建一个缓冲通道,那么您需要指定通道缓冲区的大小作为第二个参数。

将一个值或指针发送到通道中需要使用 <- 操作符。

列表 6.18. 向通道发送值
// Buffered channel of strings.
buffered := make(chan string, 10)

// Send a string through the channel.
buffered <- "Gopher"

在 列表 6.18 中,我们创建了一个包含 10 个值缓冲区的 string 类型的缓冲通道。然后我们通过通道发送了 string “Gopher”。为了使另一个 goroutine 能够从通道接收该字符串,我们使用相同的 <- 操作符,但这次作为一元操作符。

列表 6.19. 从通道接收值
// Receive a string from the channel.
value := <-buffered

当从通道接收一个值或指针时,<- 操作符连接到通道变量的左侧,如 列表 6.19 所示。

无缓冲通道和缓冲通道的行为略有不同。理解这些差异将帮助您确定何时更倾向于使用其中一种,因此让我们分别查看每种类型。

6.5.1. 无缓冲通道

一个 无缓冲通道 是一个在接收之前没有能力存储任何值的通道。这类通道要求发送和接收的 goroutine 在任何发送或接收操作完成之前同时准备好。如果两个 goroutine 没有同时准备好,通道将使执行相应发送或接收操作的 goroutine 首先等待。同步是通道发送和接收交互中的固有特性。一个操作不能在没有另一个操作的情况下发生。

在 图 6.6 中,你可以看到一个示例,其中两个 goroutine 使用无缓冲通道共享一个值。在第 1 步中,两个 goroutine 接近通道,但还没有发出发送或接收操作。在第 2 步中,左侧的 goroutine 将手伸入通道,这模拟了在通道上的发送操作。此时,该 goroutine 被锁定在通道中,直到交换完成。在第 3 步中,右侧的 goroutine 将手伸入通道,这模拟了在通道上的接收操作。现在,该 goroutine 被锁定在通道中,直到交换完成。在第 4 和第 5 步中完成了交换,最后在第 6 步中,两个 goroutine 都可以自由地移开手,这模拟了锁的释放。他们现在都可以继续他们的愉快旅程。

图 6.6. 使用无缓冲通道的 goroutine 同步

为了更清楚地说明,让我们看看两个使用无缓冲通道同步两个 goroutine 之间数据交换的完整示例。

在网球游戏中,两名球员互相击球。球员总是处于两种状态之一:要么等待接收球,要么将球发送回对方球员。你可以使用两个 goroutine 和一个无缓冲通道来模拟球的交换,以模拟网球游戏。

列表 6.20. listing20.go
01 // This sample program demonstrates how to use an unbuffered
02 // channel to simulate a game of tennis between two goroutines.
03 package main
04
05 import (
06     "fmt"
07     "math/rand"
08     "sync"
09     "time"
10 )
11
12 // wg is used to wait for the program to finish.
13 var wg sync.WaitGroup
14
15 func init() {
16     rand.Seed(time.Now().UnixNano())
17 }
18
19 // main is the entry point for all Go programs.
20 func main() {
21     // Create an unbuffered channel.
22     court := make(chan int)
23
24     // Add a count of two, one for each goroutine.
25     wg.Add(2)
26
27     // Launch two players.
28     go player("Nadal", court)
29     go player("Djokovic", court)
30
31     // Start the set.
32     court <- 1
33
34     // Wait for the game to finish.
35     wg.Wait()
36 }
37

38 // player simulates a person playing the game of tennis.
39 func player(name string, court chan int) {
40     // Schedule the call to Done to tell main we are done.
41     defer wg.Done()
42
43     for {
44         // Wait for the ball to be hit back to us.
45         ball, ok := <-court
46         if !ok {
47             // If the channel was closed we won.
48             fmt.Printf("Player %s Won\n", name)
49             return
50         }
51
52         // Pick a random number and see if we miss the ball.
53         n := rand.Intn(100)
54         if n%13 == 0 {
55             fmt.Printf("Player %s Missed\n", name)
56
57             // Close the channel to signal we lost.
58             close(court)
59             return
60         }
61
62         // Display and then increment the hit count by one.
63         fmt.Printf("Player %s Hit %d\n", name, ball)
64         ball++
65
66         // Hit the ball back to the opposing player.
67         court <- ball
68     }
69 }

当你运行程序时,你会得到以下输出。

列表 6.21. listing20.go 的输出
Player Nadal Hit 1
Player Djokovic Hit 2
Player Nadal Hit 3
Player Djokovic Missed
Player Nadal Won

在第 22 行的 main 函数中,创建了一个类型为 int 的无缓冲通道来同步两个 goroutine 打击的球之间的交换。然后在第 28 和 29 行创建了将玩游戏的两个 goroutine。此时,两个 goroutine 都被锁定,等待接收球。在第 32 行将一个球发送到通道中,游戏一直进行,直到其中一个 goroutine 失败。

player 函数内部,你会在第 43 行找到一个无限循环的 for。在循环内部,进行游戏。在第 45 行,goroutine 在通道上执行接收操作,等待接收球。这会锁定 goroutine,直到通道上执行发送操作。一旦通道上的接收操作返回,第 46 行会检查 ok 标志是否为 falsefalse 的值表示通道已关闭,游戏结束。在第 53 到 60 行生成一个随机数,以确定 goroutine 是否击中或错过球。如果击中球,则在第 64 行将球的价值增加一,并在第 67 行将球发送回另一个玩家。此时,两个 goroutine 都被锁定,直到交换完成。最终,一个 goroutine 错过球,在第 58 行关闭通道。然后两个 goroutine 返回,通过 defer 语句调用 Done,程序终止。

另一个使用不同模式通过无缓冲通道同步协程的例子是模拟接力赛。在接力赛中,四名运动员轮流在赛道上跑步。第二、第三和第四名运动员必须在接收到前一名运动员的接力棒后才能开始跑步。接力棒的传递是比赛的关键部分,需要同步以避免错过任何一步。为了实现这种同步,参与交换的两名运动员必须同时准备好。

列表 6.22. listing22.go
01 // This sample program demonstrates how to use an unbuffered
02 // channel to simulate a relay race between four goroutines.
03 package main
04
05 import (
06     "fmt"
07     "sync"
08     "time"
09 )
10
11 // wg is used to wait for the program to finish.
12 var wg sync.WaitGroup
13
14 // main is the entry point for all Go programs.
15 func main() {
16     // Create an unbuffered channel.
17     baton := make(chan int)
18
19     // Add a count of one for the last runner.
20     wg.Add(1)
21
22     // First runner to his mark.
23     go Runner(baton)
24
25     // Start the race.
26     baton <- 1
27
28     // Wait for the race to finish.
29     wg.Wait()
30 }
31
32 // Runner simulates a person running in the relay race.
33 func Runner(baton chan int) {
34     var newRunner int

35
36     // Wait to receive the baton.
37     runner := <-baton
38
39     // Start running around the track.
40     fmt.Printf("Runner %d Running With Baton\n", runner)
41
42     // New runner to the line.
43     if runner != 4 {
44         newRunner = runner + 1
45         fmt.Printf("Runner %d To The Line\n", newRunner)
46         go Runner(baton)
47     }
48
49     // Running around the track.
50     time.Sleep(100 * time.Millisecond)
51
52     // Is the race over.
53     if runner == 4 {
54         fmt.Printf("Runner %d Finished, Race Over\n", runner)
55         wg.Done()
56         return
57     }
58
59     // Exchange the baton for the next runner.
60     fmt.Printf("Runner %d Exchange With Runner %d\n",
61         runner,
62         newRunner)
63
64     baton <- newRunner
65 }

当你运行程序时,你会得到以下输出。

列表 6.23. 列表 22.go 的输出
Runner 1 Running With Baton
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over

main 函数的第 17 行,创建了一个类型为 int 的无缓冲通道来同步接力棒的交换。在第 20 行,我们将 1 添加到 WaitGroup 中,以便 main 函数可以等待最后一名运动员完成。第 23 行,第一名运动员通过创建协程进入赛道,然后在第 26 行将接力棒交给运动员,比赛开始。最后,在第 29 行,main 函数等待 WaitGroup 直到最后一名运动员完成。

Runner 协程内部,你可以看到接力棒是如何从一名运动员传递到另一名运动员的。在第 37 行,协程通过通道上的接收调用等待接收接力棒。一旦接收到接力棒,下一名运动员在第 46 行做好准备,除非协程代表第四名运动员。在第 50 行,运动员在赛道上跑 100 毫秒。在第 55 行,如果第四名运动员刚刚完成跑步,通过 Done 调用减少 WaitGroup 的计数,并且协程返回。如果不是第四名运动员,那么在第 64 行,接力棒传递给已经等待的下一名运动员。此时,两个协程都锁定,直到交换完成。

在这两个例子中,我们使用无缓冲通道来同步协程,模拟网球比赛和接力赛。代码的流程与这些事件和活动在现实世界中的发生方式一致。这使得代码易于阅读且具有自文档性。现在,既然你已经了解了无缓冲通道的工作原理,接下来你可以学习缓冲通道的工作原理。

6.5.2. 缓冲通道

缓冲通道 是一种在接收之前可以持有一个或多个值的通道。这类通道不会强制协程在同一时刻准备好进行发送和接收。发送或接收阻塞的条件也有所不同。接收只有在通道中没有值可以接收时才会阻塞。发送只有在没有可用缓冲区来放置要发送的值时才会阻塞。这导致了无缓冲通道和缓冲通道之间的一大区别:无缓冲通道提供了一种保证,即两个协程之间的交换是在发送和接收发生的瞬间完成的。而缓冲通道没有这样的保证。

在 图 6.7 中,你可以看到一个示例,两个 goroutine 独立地从缓冲通道中添加和移除项目。在第 1 步中,右侧的 goroutine 正在从通道接收一个值。在第 2 步中,相同的 goroutine 能够独立于左侧的 goroutine 向通道发送新值而完成接收。在第 3 步中,左侧的 goroutine 正在向通道发送新值,而右侧的 goroutine 正在接收不同的值。在第 3 步中的这两个操作都没有同步,也没有阻塞。最后,在第 4 步中,所有的发送和接收都已完成,我们有一个包含多个值并有更多空间的通道。

图 6.7. 使用缓冲通道在 goroutine 之间进行同步

让我们来看一个使用缓冲通道来管理一组 goroutine 接收和处理工作的示例。缓冲通道提供了一个干净直观的方式来实现此代码。

列表 6.24. listing24.go
01 // This sample program demonstrates how to use a buffered
02 // channel to work on multiple tasks with a predefined number
03 // of goroutines.
04 package main
05
06 import (
07     "fmt"
08     "math/rand"
09     "sync"
10     "time"
11 )
12
13 const (
14     numberGoroutines = 4  // Number of goroutines to use.
15     taskLoad         = 10 // Amount of work to process.
16 )
17
18 // wg is used to wait for the program to finish.
19 var wg sync.WaitGroup
20
21 // init is called to initialize the package by the
22 // Go runtime prior to any other code being executed.
23 func init() {
24     // Seed the random number generator.
25     rand.Seed(time.Now().Unix())
26 }
27
28 // main is the entry point for all Go programs.
29 func main() {
30     // Create a buffered channel to manage the task load.
31     tasks := make(chan string, taskLoad)
32
33     // Launch goroutines to handle the work.
34     wg.Add(numberGoroutines)
35     for gr := 1; gr <= numberGoroutines; gr++ {
36         go worker(tasks, gr)
37     }
38
39     // Add a bunch of work to get done.
40     for post := 1; post <= taskLoad; post++ {
41         tasks <- fmt.Sprintf("Task : %d", post)
42     }

43
44     // Close the channel so the goroutines will quit
45     // when all the work is done.
46     close(tasks)
47
48     // Wait for all the work to get done.
49     wg.Wait()
50 }
51
52 // worker is launched as a goroutine to process work from
53 // the buffered channel.
54 func worker(tasks chan string, worker int) {
55     // Report that we just returned.
56     defer wg.Done()
57
58     for {
59         // Wait for work to be assigned.
60         task, ok := <-tasks
61         if !ok {
62             // This means the channel is empty and closed.
63             fmt.Printf("Worker: %d : Shutting Down\n", worker)
64             return
65         }
66
67         // Display we are starting the work.
68         fmt.Printf("Worker: %d : Started %s\n", worker, task)
69
70         // Randomly wait to simulate work time.
71         sleep := rand.Int63n(100)
72         time.Sleep(time.Duration(sleep) * time.Millisecond)
73
74         // Display we finished the work.
75         fmt.Printf("Worker: %d : Completed %s\n", worker, task)
76     }
77 }

当你运行程序时,你会得到以下输出。

列表 6.25. listing24.go 的输出
Worker: 1 : Started Task : 1
Worker: 2 : Started Task : 2
Worker: 3 : Started Task : 3
Worker: 4 : Started Task : 4
Worker: 1 : Completed Task : 1
Worker: 1 : Started Task : 5
Worker: 4 : Completed Task : 4
Worker: 4 : Started Task : 6
Worker: 1 : Completed Task : 5

Worker: 1 : Started Task : 7
Worker: 2 : Completed Task : 2
Worker: 2 : Started Task : 8
Worker: 3 : Completed Task : 3
Worker: 3 : Started Task : 9
Worker: 1 : Completed Task : 7
Worker: 1 : Started Task : 10
Worker: 4 : Completed Task : 6
Worker: 4 : Shutting Down
Worker: 3 : Completed Task : 9
Worker: 3 : Shutting Down
Worker: 2 : Completed Task : 8
Worker: 2 : Shutting Down
Worker: 1 : Completed Task : 10
Worker: 1 : Shutting Down

由于程序和 Go 调度器的随机性,每次运行此程序的输出都会不同。但使用所有四个 goroutine 处理缓冲通道中的工作不会改变。你可以从输出中看到每个 goroutine 如何接收从通道分配的工作。

在第 31 行的 main 函数中,创建了一个容量为 10 的 string 类型的缓冲通道。在第 34 行,WaitGroup 被赋予计数 4,代表将要创建的每个 goroutine。然后在第 35 到 37 行,创建了四个 goroutine,并将它们将要接收工作的通道传递给它们。在第 40 到 42 行,向通道发送了 10 个字符串来模拟为 goroutine 的工作。一旦最后一个字符串被发送到通道,通道在第 46 行被关闭,main 函数在第 49 行等待所有工作完成。

在第 46 行关闭通道是一段重要的代码。当一个通道被关闭时,goroutine 仍然可以在通道上执行接收操作,但不能再向通道发送。能够在关闭的通道上接收是很重要的,因为它允许通道通过未来的接收清空所有值,所以通道中永远不会丢失任何东西。在关闭且为空的通道上的接收总是立即返回,并提供通道声明类型的零值。如果你还请求通道接收的可选标志,你可以获取有关通道状态的信息。

worker函数中,你会在第 58 行找到一个无休止的for循环。在循环中,所有接收到的任务都会被处理。每个 goroutine 在第 60 行阻塞,等待从通道接收任务。一旦接收返回,就会检查ok标志以确定通道是否既为空又已关闭。如果ok的值为false,则 goroutine 终止,这会导致第 56 行的defer语句调用Done并返回给main

如果ok标志为true,则接收到的值是有效的。第 71 行和第 72 行模拟了正在处理的工作。一旦工作完成,goroutine 再次在第 60 行的通道接收处阻塞。一旦通道关闭,通道的接收立即返回,并且 goroutine 自行终止。

提供的无缓冲和缓冲通道的示例提供了使用通道可以编写的代码类型的好样本。在下一章中,我们将探讨您可以在自己的项目中使用的真实世界的并发模式。

6.6. 概述

  • 并发是 goroutines 的独立执行。

  • 函数通过使用go关键字创建为 goroutines。

  • Goroutines 在拥有单个操作系统线程和运行队列的逻辑处理器的作用域内执行。

  • 竞态条件是指两个或更多 goroutines 尝试访问同一资源。

  • 原子函数和互斥锁提供了一种防止竞态条件的方法。

  • Channels 提供了一种在两个 goroutines 之间安全共享数据的方法。

  • 无缓冲通道提供数据交换的保证,而缓冲通道则不提供。

第七章. 并发模式

在本章中

  • 控制程序的生命周期

  • 管理可重用资源池

  • 创建一个可以处理工作的 goroutine 池

在第六章(kindle_split_014.html#ch06)中,你学习了并发是什么以及通道是如何工作的,并回顾了展示并发操作的代码。在本章中,你将通过回顾更多代码来扩展这些知识。我们将回顾三个实现不同并发模式的包,你可以在自己的项目中使用这些模式。每个包都提供了关于并发和通道的使用以及它们如何使并发程序更容易编写和推理的实用视角。

7.1. Runner

runner 包的目的是展示如何使用通道来监控程序运行的时间,并在程序运行时间过长时终止程序。当开发计划作为后台任务进程运行的程序时,这种模式很有用。这可能是一个作为 cron 作业运行的程序,或者在基于工作者的云环境(如 Iron.io)中运行。

让我们看看 runner 包中的 runner.go 代码文件。

列表 7.1. runner/runner.go
 01 // Example provided with help from Gabriel Aszalos.
 02 // Package runner manages the running and lifetime of a process.
 03 package runner
 04
 05 import (
 06     "errors"
 07     "os"
 08     "os/signal"
 09     "time"
 10 )
 11
 12 // Runner runs a set of tasks within a given timeout and can be
 13 // shut down on an operating system interrupt.
 14 type Runner struct {
 15     // interrupt channel reports a signal from the
 16     // operating system.
 17     interrupt chan os.Signal
 18
 19     // complete channel reports that processing is done.
 20     complete chan error
 21
 22     // timeout reports that time has run out.
 23     timeout <-chan time.Time
 24
 25     // tasks holds a set of functions that are executed
 26     // synchronously in index order.
 27     tasks []func(int)
 28 }
 29
 30 // ErrTimeout is returned when a value is received on the timeout.
 31 var ErrTimeout = errors.New("received timeout")
 32
 33 // ErrInterrupt is returned when an event from the OS is received.
 34 var ErrInterrupt = errors.New("received interrupt")
 35
 36 // New returns a new ready-to-use Runner.
 37 func New(d time.Duration) *Runner {
 38     return &Runner{
 39         interrupt: make(chan os.Signal, 1),
 40         complete:  make(chan error),
 41         timeout:   time.After(d),
 42     }
 43 }
 44
 45 // Add attaches tasks to the Runner. A task is a function that
 46 // takes an int ID.
 47 func (r *Runner) Add(tasks ...func(int)) {
 48     r.tasks = append(r.tasks, tasks...)
 49 }
 50
 51 // Start runs all tasks and monitors channel events.
 52 func (r *Runner) Start() error {
 53     // We want to receive all interrupt based signals.

 54     signal.Notify(r.interrupt, os.Interrupt)
 55
 56     // Run the different tasks on a different goroutine.
 57     go func() {
 58         r.complete <- r.run()
 59     }()
 60
 61     select {
 62     // Signaled when processing is done.
 63     case err := <-r.complete:
 64         return err
 65
 66     // Signaled when we run out of time.
 67     case <-r.timeout:
 68         return ErrTimeout
 69     }
 70 }
 71
 72 // run executes each registered task.
 73 func (r *Runner) run() error {
 74     for id, task := range r.tasks {
 75         // Check for an interrupt signal from the OS.
 76         if r.gotInterrupt() {
 77             return ErrInterrupt
 78         }
 79
 80         // Execute the registered task.
 81         task(id)
 82     }
 83
 84     return nil
 85 }
 86
 87 // gotInterrupt verifies if the interrupt signal has been issued.
 88 func (r *Runner) gotInterrupt() bool {
 89     select {
 90     // Signaled when an interrupt event is sent.
 91     case <-r.interrupt:
 92         // Stop receiving any further signals.
 93         signal.Stop(r.interrupt)
 95         return true
 96
 97     // Continue running as normal.
 98     default:
 99         return false
100     }
101 }

列表 7.1 中的程序展示了面向任务的程序在计划中无人看管运行时的并发模式。它设计了三个可能的终止点:

  • 程序可以在规定的时间内完成其工作并正常终止。

  • 程序未能按时完成并自行终止。

  • 接收到操作系统中断事件,程序尝试立即干净地关闭。

让我们分析代码,看看每个点是如何实现的。

列表 7.2. runner/runner.go: 行 12–28
12 // Runner runs a set of tasks within a given timeout and can be
13 // shut down on an operating system interrupt.
14 type Runner struct {
15     // interrupt channel reports a signal from the
16     // operating system.
17     interrupt chan os.Signal
18
19     // complete channel reports that processing is done.
20     complete chan error
21
22     // timeout reports that time has run out.
23     timeout <-chan time.Time
24
25     // tasks holds a set of functions that are executed
26     // synchronously in index order.
27     tasks []func(int)
28 }

列表 7.2 从第 14 行开始声明名为 Runner 的结构体。此类型声明了三个用于管理程序生命周期的通道和一个表示要按顺序运行的不同任务的函数切片。

第 17 行的 interrupt 通道发送和接收 os.Signal 类型的接口值,并用于接收来自宿主操作系统的中断事件。

列表 7.3. golang.org/pkg/os/#Signal
// A Signal represents an operating system signal. The usual underlying
// implementation is operating system-dependent: on Unix it is
// syscall.Signal.
type Signal interface {
    String() string
    Signal() // to distinguish from other Stringers
}

os.Signal 接口的声明在 列表 7.3 中展示。此接口抽象了来自不同操作系统的捕获和报告事件的特定实现。

第二个字段名为 complete,是一个发送和接收 error 类型的接口值的通道。

列表 7.4. runner/runner.go: 行 19–20
19     // complete channel reports that processing is done.
20     complete chan error

这个通道被称为 complete,因为它被运行任务的 goroutine 用于表示通道已完成。如果发生错误,它将通过通过通道发送的 error 接口值返回。如果没有发生错误,则发送 nil 作为错误接口值。

第三个字段名为 timeout,接收 time.Time 类型的值。

列表 7.5. runner/runner.go: 行 22–23
22     // timeout reports that time has run out.
23     timeout <-chan time.Time

这个通道用于管理进程完成所有任务所需的时间量。如果这个通道上收到了 time.Time 值,程序将尝试干净地关闭自己。

最后一个字段被命名为 tasks,它是一个函数值的切片。

列表 7.6. runner/runner.go: 行 25–27
25     // tasks holds a set of functions that are executed
26     // synchronously in index order.
27     tasks []func(int)

这些函数值代表了一系列依次运行的函数。这些函数的执行在一个单独但独立的 goroutine 上从 main 进行。

在声明了 Runner 类型之后,接下来我们有两个 error 接口变量。

列表 7.7. runner/runner.go: 行 30–34
30 // ErrTimeout is returned when a value is received on the timeout.
31 var ErrTimeout = errors.New("received timeout")
32
33 // ErrInterrupt is returned when an event from the OS is received.
34 var ErrInterrupt = errors.New("received interrupt")

第一个 error 接口变量被命名为 ErrTimeout。当接收到超时事件时,Start 方法返回这个错误值。第二个 error 接口变量被命名为 ErrInterrupt。当接收到操作系统事件时,Start 方法返回这个错误值。

现在,我们可以看看用户如何创建 Runner 类型的值。

列表 7.8. runner/runner.go: 行 36–43
36 // New returns a new ready-to-use Runner.
37 func New(d time.Duration) *Runner {
38     return &Runner{
39         interrupt: make(chan os.Signal, 1),
40         complete:  make(chan error),

41         timeout:   time.After(d),
42     }
43 }

列表 7.8 展示了一个名为 New 的工厂函数,它接受一个 time.Duration 类型的值,并返回一个 Runner 类型的指针。该函数创建一个 Runner 类型的值,并初始化每个通道字段。tasks 字段没有显式初始化,因为这个字段的零值是一个 nil 切片。每个通道字段都有一个独特的初始化,所以让我们更详细地探索每一个。

interrupt 通道被初始化为一个带有 1 个缓冲的缓冲通道。这保证了至少会从运行时接收到一个 os.Signal 值。运行时将以非阻塞的方式发送这个事件。如果一个 goroutine 没有准备好接收这个值,这个值就会被丢弃。例如,如果用户反复按 Ctrl+C,程序只有在通道中有缓冲并且所有其他事件都被丢弃时才会接收到这个事件。

complete 通道被初始化为一个无缓冲通道。当运行任务的 goroutine 完成时,它会通过这个通道发送一个 error 值或 nil error 值。然后它等待 main 接收它。一旦 main 接收到 error 值,goroutine 就可以安全地终止。

最后一个通道,timeout,使用 time 包中的 After 函数初始化。After 函数返回一个 time.Time 类型的通道。运行时将在指定持续时间过后通过这个通道发送一个 time.Time 值。

现在你已经看到了如何创建和初始化 Runner 值,我们可以看看与 Runner 类型相关的方法。第一个方法,Add,用于捕获要执行的函数任务。

列表 7.9. runner/runner.go: 行 45–49
45 // Add attaches tasks to the Runner. A task is a function that
46 // takes an int ID.
47 func (r *Runner) Add(tasks ...func(int)) {
48     r.tasks = append(r.tasks, tasks...)
49 }

列表 7.9 显示了Add方法,该方法使用一个名为tasks的单个可变参数声明。可变参数可以接受任何数量的传入值。在这种情况下,值必须是一个接受单个整数值并返回空值的函数。一旦进入代码,tasks参数就变成了这些函数值的切片。

现在我们来看看run方法。

列表 7.10. runner/runner.go: 第 72-85 行
72 // run executes each registered task.
73 func (r *Runner) run() error {
74     for id, task := range r.tasks {

75         // Check for an interrupt signal from the OS.
76         if r.gotInterrupt() {
77             return ErrInterrupt
78         }
79
80         // Execute the registered task.
81         task(id)
82     }
83
84     return nil
85 }

在列表 7.10 的第 73 行上的run方法遍历tasks切片,并按顺序执行每个函数。在执行任何函数之前(第 81 行),在第 76 行调用gotInterrupt方法,以查看是否有来自操作系统的任何事件要接收。

列表 7.11. runner/runner.go: 第 87-101 行
 87 // gotInterrupt verifies if the interrupt signal has been issued.
 88 func (r *Runner) gotInterrupt() bool {
 89     select {
 90     // Signaled when an interrupt event is sent.
 91     case <-r.interrupt:
 92         // Stop receiving any further signals.
 93         signal.Stop(r.interrupt)
 95         return true
 96
 97     // Continue running as normal.
 98     default:
 99         return false
100     }
101 }

列表 7.11 中的gotInterrupt方法展示了带有default情况的select语句的经典用法。在第 91 行,代码尝试在interrupt通道上接收。通常如果没有要接收的内容,它会阻塞,但我们有第 98 行的default情况。default情况将尝试在interrupt通道上接收的操作转换为非阻塞调用。如果有中断要接收,则接收并处理它。如果没有要接收的内容,则执行default情况。

当接收到中断事件时,代码通过在第 93 行调用Stop方法请求停止接收任何未来的事件。然后函数返回true。如果没有接收到中断事件,方法在第 99 行返回false。本质上,gotInterrupt方法允许 goroutine 检查中断事件,并在没有发出事件的情况下继续处理工作。

包中的最后一个方法称为Start

列表 7.12. runner/runner.go: 第 51-70 行
51 // Start runs all tasks and monitors channel events.
52 func (r *Runner) Start() error {

53     // We want to receive all interrupt based signals.
54     signal.Notify(r.interrupt, os.Interrupt)
55
56     // Run the different tasks on a different goroutine.
57     go func() {
58         r.complete <- r.run()
59     }()
60
61     select {
62     // Signaled when processing is done.
63     case err := <-r.complete:
64         return err
65
66     // Signaled when we run out of time.
67     case <-r.timeout:
68         return ErrTimeout
69     }
70 }

Start方法实现了程序的主要工作流程。在列表 7.12 的第 52 行,Start设置了gotInterrupt方法接收来自操作系统的中断事件的能力。在第 56 到 59 行,声明并创建了一个匿名函数作为 goroutine。这是执行程序分配的任务集的 goroutine。在第 58 行,在这个 goroutine 内部,调用了run方法,并将返回的error接口值发送到complete通道。一旦接收到error接口值,goroutine 将返回该值给调用者。

创建 goroutine 后,Start进入一个select语句,阻塞等待两个事件之一发生。如果在complete通道上收到error接口值,则 goroutine 要么在分配的时间内完成了工作,要么收到了操作系统中断事件。无论如何,都会返回接收到的error接口值,方法终止。如果在timeout通道上收到time.Time值,则 goroutine 没有在分配的时间内完成工作。在这种情况下,程序返回ErrTimeout变量。

现在你已经看到了runner包的代码,并了解了它是如何工作的,让我们回顾main.go源代码文件中的测试程序。

列表 7.13. runner/main/main.go
01 // This sample program demonstrates how to use a channel to
02 // monitor the amount of time the program is running and terminate
03 // the program if it runs too long.
03 package main
04
05 import (
06     "log"
07     "time"
08
09     "github.com/goinaction/code/chapter7/patterns/runner"
10 )

11
12 // timeout is the number of second the program has to finish.
13 const timeout = 3 * time.Second
14
15 // main is the entry point for the program.
16 func main() {
17     log.Println("Starting work.")
18
19     // Create a new timer value for this run.
20     r := runner.New(timeout)
21
22     // Add the tasks to be run.
23     r.Add(createTask(), createTask(), createTask())
24
25     // Run the tasks and handle the result.
26     if err := r.Start(); err != nil {
27         switch err {
28         case runner.ErrTimeout:
29             log.Println("Terminating due to timeout.")
30             os.Exit(1)
31         case runner.ErrInterrupt:
32             log.Println("Terminating due to interrupt.")
33             os.Exit(2)
34         }
35     }
36
37     log.Println("Process ended.")
38 }
39
40 // createTask returns an example task that sleeps for the specified
41 // number of seconds based on the id.
42 func createTask() func(int) {
43     return func(id int) {
44         log.Printf("Processor - Task #%d.", id)
45         time.Sleep(time.Duration(id) * time.Second)
46     }
47 }

main函数可以在列表 7.13 的第 16 行找到。在第 20 行,将超时值传递给New函数,并返回一个类型为Runner的指针。然后,在第 23 行多次将createTask函数添加到Runner中。在第 42 行声明的createTask函数是一个假装执行指定时间工作的函数。一旦添加了函数,就在第 26 行调用Start方法,main函数等待Start返回。

Start返回时,会检查返回的error接口值。如果发生了错误,代码使用error接口变量来识别方法是否由于超时事件或中断事件而终止。如果没有错误,则任务按时完成。在超时事件中,程序以错误代码 1 终止。在中断事件中,程序以错误代码 2 终止。在其他所有情况下,程序以错误代码 0 正常终止。

7.2. 池化

pool包的目的是展示如何使用缓冲通道来收集一组资源,这些资源可以被任何数量的 goroutine 共享和单独使用。当你有一组静态资源需要共享时,这种模式很有用,例如数据库连接或内存缓冲区。当一个 goroutine 需要从池中获取这些资源之一时,它可以获取资源,使用它,然后将其返回到池中。

让我们看看pool包中的pool.go代码文件。

列表 7.14. pool/pool.go
 01 // Example provided with help from Fatih Arslan and Gabriel Aszalos.
 02 // Package pool manages a user defined set of resources.
 03 package pool
 04
 05 import (
 06     "errors"
 07     "log"
 08     "io"
 09     "sync"
 10 )
 11
 12 // Pool manages a set of resources that can be shared safely by
 13 // multiple goroutines. The resource being managed must implement
 14 // the io.Closer interface.
 15 type Pool struct {
 16     m         sync.Mutex
 17     resources chan io.Closer
 18     factory   func() (io.Closer, error)
 19     closed    bool
 20 }
 21
 22 // ErrPoolClosed is returned when an Acquire returns on a
 23 // closed pool.
 24 var ErrPoolClosed = errors.New("Pool has been closed.")
 25
 26 // New creates a pool that manages resources. A pool requires a
 27 // function that can allocate a new resource and the size of
 28 // the pool.
 29 func New(fn func() (io.Closer, error), size uint) (*Pool, error) {
 30     if size <= 0 {
 31         return nil, errors.New("Size value too small.")
 32     }
 33
 34     return &Pool{
 35         factory:   fn,
 36         resources: make(chan io.Closer, size),
 37     }, nil
 38 }
 39
 40 // Acquire retrieves a resource from the pool.
 41 func (p *Pool) Acquire() (io.Closer, error) {

 42     select {
 43     // Check for a free resource.
 44     case r, ok := <-p.resources:
 45         log.Println("Acquire:", "Shared Resource")
 46         if !ok {
 47             return nil, ErrPoolClosed
 48         }
 49         return r, nil
 50
 51     // Provide a new resource since there are none available.
 52     default:
 53         log.Println("Acquire:", "New Resource")
 54         return p.factory()
 55     }
 56 }
 57
 58 // Release places a new resource onto the pool.
 59 func (p *Pool) Release(r io.Closer) {
 60     // Secure this operation with the Close operation.
 61     p.m.Lock()
 62     defer p.m.Unlock()
 63
 64     // If the pool is closed, discard the resource.
 65     if p.closed {
 66         r.Close()
 67         return
 68     }
 69
 70     select {
 71     // Attempt to place the new resource on the queue.
 72     case p.resources <- r:
 73         log.Println("Release:", "In Queue")
 74
 75     // If the queue is already at capacity we close the resource.
 76     default:
 77         log.Println("Release:", "Closing")
 78         r.Close()
 79     }
 80 }
 81
 82 // Close will shutdown the pool and close all existing resources.
 83 func (p *Pool) Close() {
 84     // Secure this operation with the Release operation.
 85     p.m.Lock()
 86     defer p.m.Unlock()
 87
 88     // If the pool is already closed, don't do anything.
 89     if p.closed {
 90         return
 91     }
 92
 93     // Set the pool as closed.
 94     p.closed = true
 95
 96     // Close the channel before we drain the channel of its

 97     // resources. If we don't do this, we will have a deadlock.
 98     close(p.resources)
 99
100     // Close the resources
101     for r := range p.resources {
102         r.Close()
103     }
104 }

列表 7.14 中的pool包的代码声明了一个名为Pool的结构体,允许调用者创建所需数量的不同池。只要类型实现了io.Closer接口,每个池都可以管理任何类型的资源。让我们看看Pool结构体的声明。

列表 7.15. pool/pool.go: 行 12–20
12 // Pool manages a set of resources that can be shared safely by
13 // multiple goroutines. The resource being managed must implement
14 // the io.Closer interface.
15 type Pool struct {
16     m         sync.Mutex
17     resources chan io.Closer
18     factory   func() (io.Closer, error)
19     closed    bool
20 }

Pool 结构体声明了四个字段,每个字段都帮助以 goroutine 安全的方式管理池。在第 16 行,结构体开始于一个类型为 sync.Mutex 的字段。这个互斥锁用于确保对 Pool 值的所有操作在多 goroutine 访问时是安全的。第二个字段名为 resources,声明为一个接口类型 io.Closer 的通道。这个通道将被创建为一个带缓冲的通道,并将包含共享的资源。由于使用了接口类型,池可以管理任何实现了 io.Closer 接口的资源类型。

factory 字段是一个函数类型。任何不接受参数并返回 io.Closererror 接口值的函数都可以分配给此字段。此函数的目的是在池需要时创建一个新的资源。这种功能是 pool 包范围之外的实施细节,需要由使用此包的用户实现和提供。

第 19 行的最后一个字段是 closed 字段。此字段是一个标志,表示 Pool 正在关闭或已经关闭。现在你已经看到了 Pool 结构体的声明,让我们看看第 24 行声明的 error 接口变量。

列表 7.16. pool/pool.go: 行 22–24
22 // ErrPoolClosed is returned when an Acquire returns on a
23 // closed pool.
24 var ErrPoolClosed = errors.New("Pool has been closed.")

在 Go 中创建 error 接口变量是一种常见做法。这允许调用者从包中的任何函数或方法中识别特定的返回错误值。在 列表 7.16 中的 error 接口变量已被声明,用于报告当用户调用 Acquire 方法且 Pool 已关闭时的情况。由于 Acquire 方法可以返回多个不同的错误,当 Pool 关闭时返回此错误变量允许调用者识别这个特定的错误而不是其他错误。

在声明了 Pool 类型和 error 接口变量之后,我们可以开始查看在 pool 包中声明的函数和方法。让我们从池的工厂函数 New 开始。

列表 7.17. pool/pool.go: 行 26–38
26 // New creates a pool that manages resources. A pool requires a
27 // function that can allocate a new resource and the size of
28 // the pool.
29 func New(fn func() (io.Closer, error), size uint) (*Pool, error) {
30     if size <= 0 {
31         return nil, errors.New("Size value too small.")
32     }
33
34     return &Pool{
35         factory:   fn,
36         resources: make(chan io.Closer, size),
37     }, nil
38 }

在 列表 7.17 中的 New 函数接受两个参数并返回两个值。第一个参数 fn 被声明为一个不接受参数并返回 io.Closererror 接口值的函数类型。函数参数代表一个工厂函数,用于创建池管理的资源值。第二个参数 size 代表创建用于存储资源的带缓冲通道的大小。

在第 30 行,检查size的值以确保它不小于或等于 0。如果是,则代码返回nil作为返回的pool指针值,并即时创建一个错误接口值。由于这是此函数返回的唯一错误,因此不需要为此错误创建和使用错误接口变量。如果大小值有效,则创建并初始化一个新的Pool值。在第 35 行,将函数参数赋值,在第 36 行使用大小值创建缓冲通道。所有创建和初始化都可以在return语句的作用域内完成。因此,创建并返回指向新Pool值的指针和nil的错误接口值作为参数。

在能够创建和初始化Pool值之后,接下来让我们看看Acquire方法。此方法允许调用者从池中获取资源。

列表 7.18. pool/pool.go: 第 40–56 行
40 // Acquire retrieves a resource from the pool.
41 func (p *Pool) Acquire() (io.Closer, error) {
42     select {
43     // Check for a free resource.
44     case r, ok := <-p.resources:
45         log.Println("Acquire:", "Shared Resource")
46         if !ok {
47             return nil, ErrPoolClosed
48         }
49         return r, nil
50
51     // Provide a new resource since there are none available.
52     default:
53         log.Println("Acquire:", "New Resource")
54         return p.factory()
55     }
56 }

列表 7.18 包含Acquire方法的代码。此方法如果池中有可用资源,则返回资源,或者为调用创建一个新的。此实现是通过使用select / case语句来检查缓冲通道中是否有资源来完成的。如果有,则接收并返回给调用者。这可以在第 44 行和第 49 行看到。如果没有资源在缓冲通道中接收,则执行default情况。在这种情况下,在第 54 行执行用户的工厂函数,创建一个新的资源并返回。

在获取资源并且不再需要后,必须将其释放回池中。这就是Release方法发挥作用的地方。但要理解Release方法背后的代码机制,我们首先需要查看Close方法。

列表 7.19. pool/pool.go: 第 82–104 行
 82 // Close will shutdown the pool and close all existing resources.
 83 func (p *Pool) Close() {
 84     // Secure this operation with the Release operation.
 85     p.m.Lock()
 86     defer p.m.Unlock()
 87
 88     // If the pool is already closed, don't do anything.
 89     if p.closed {
 90         return
 91     }
 92
 93     // Set the pool as closed.
 94     p.closed = true
 95
 96     // Close the channel before we drain the channel of its
 97     // resources. If we don't do this, we will have a deadlock.
 98     close(p.resources)
 99

100     // Close the resources
101     for r := range p.resources {
102         r.Close()
103     }
104 }

一旦程序完成对池的使用,它应该调用Close方法。Close方法的代码在列表 7.19 中显示。该方法在第 98 行和第 101 行关闭并清空缓冲通道,关闭任何存在的资源,直到通道为空。此方法中的所有代码必须一次只由一个 goroutine 执行。实际上,当此代码正在执行时,goroutine 还必须防止在Release方法中执行代码。你很快就会明白为什么这很重要。

在第 85 行和第 86 行,互斥锁被锁定,并在函数返回时计划解锁。在第 89 行,检查closed标志以查看池是否已经关闭。如果是,方法立即返回,从而释放锁。如果是第一次调用该方法,则将标志设置为true,并关闭并清空resources通道。

现在我们可以查看Release方法,并了解它是如何与Close方法协调工作的。

列表 7.20. pool/pool.go: 第 58–80 行
58 // Release places a new resource onto the pool.
59 func (p *Pool) Release(r io.Closer) {
60     // Secure this operation with the Close operation.
61     p.m.Lock()
62     defer p.m.Unlock()
63
64     // If the pool is closed, discard the resource.
65     if p.closed {
66         r.Close()
67         return
68     }
69
70     select {
71     // Attempt to place the new resource on the queue.
72     case p.resources <- r:
73         log.Println("Release:", "In Queue")
74
75     // If the queue is already at capacity we close the resource.
76     default:
77         log.Println("Release:", "Closing")
78         r.Close()
79     }
80 }

Release 方法的实现可以在 代码列表 7.20 中找到。该方法从第 61 和 62 行开始锁定和解锁互斥锁。这与 Close 方法中的互斥锁相同。这就是如何通过不同的 goroutine 防止两种方法同时运行。互斥锁的使用有两个目的。首先,它保护第 65 行的 closed 标志的读取不会与 Close 方法中此标志的写入同时发生。其次,我们不希望尝试向已关闭的通道发送数据,因为这会导致恐慌。当 closed 字段为 false 时,我们知道 resources 通道已被关闭。

在第 66 行,当池关闭时,直接调用资源上的 Close 方法。这是因为没有方法可以将资源释放回池中。此时,池已被关闭和刷新。对 closed 标志的读写必须同步,否则 goroutine 可能会被误导,认为池是打开的,并尝试在通道上执行无效操作。

现在您已经看到了池代码并了解了它的工作原理,让我们回顾一下 main.go 源代码文件中的测试程序。

代码列表 7.21. pool/main/main.go
01 // This sample program demonstrates how to use the pool package
02 // to share a simulated set of database connections.
03 package main
04
05 import (
06     "log"
07     "io"
08     "math/rand"
09     "sync"
10     "sync/atomic"
11     "time"
12
13     "github.com/goinaction/code/chapter7/patterns/pool"
14 )
15
16 const (
17     maxGoroutines   = 25 // the number of routines to use.
18     pooledResources = 2  // number of resources in the pool
19 )
20
21 // dbConnection simulates a resource to share.
22 type dbConnection struct {
23     ID int32
24 }
25
26 // Close implements the io.Closer interface so dbConnection
27 // can be managed by the pool. Close performs any resource
28 // release management.
29 func (dbConn *dbConnection) Close() error {
30     log.Println("Close: Connection", dbConn.ID)
31     return nil
32 }
33
34 // idCounter provides support for giving each connection a unique id.
35 var idCounter int32
36
37 // createConnection is a factory method that will be called by
38 // the pool when a new connection is needed.
39 func createConnection() (io.Closer, error) {

40     id := atomic.AddInt32(&idCounter, 1)
41     log.Println("Create: New Connection", id)
42
43     return &dbConnection{id}, nil
44 }
45
46 // main is the entry point for all Go programs.
47 func main() {
48     var wg sync.WaitGroup
49     wg.Add(maxGoroutines)
50
51     // Create the pool to manage our connections.
52     p, err := pool.New(createConnection, pooledResources)
53     if err != nil {
54         log.Println(err)
55     }
56
57     // Perform queries using connections from the pool.
58     for query := 0; query < maxGoroutines; query++ {
59         // Each goroutine needs its own copy of the query
60         // value else they will all be sharing the same query
61         // variable.
62         go func(q int) {
63             performQueries(q, p)
64             wg.Done()
65         }(query)
66     }
67
68     // Wait for the goroutines to finish.
69     wg.Wait()
70
71     // Close the pool.
72     log.Println("Shutdown Program.")
73     p.Close()
74 }
75
76 // performQueries tests the resource pool of connections.
77 func performQueries(query int, p *pool.Pool) {
78     // Acquire a connection from the pool.
79     conn, err := p.Acquire()
80     if err != nil {
81         log.Println(err)
82         return
83     }
84
85     // Release the connection back to the pool.
86     defer p.Release(conn)
87
88     // Wait to simulate a query response.
89     time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
90     log.Printf("QID[%d] CID[%d]\n", query, conn.(*dbConnection).ID)
91 }

在 main.go 中显示的代码,如 代码列表 7.21,使用 pool 包来管理模拟的数据库连接池。代码首先声明了两个常量 maxGoroutinespooledResources,以设置程序将要使用的 goroutine 和资源数量。接着声明了资源和实现了 io.Closer 接口。

代码列表 7.22. pool/main/main.go: 行 21–32
21 // dbConnection simulates a resource to share.
22 type dbConnection struct {
23     ID int32
24 }
25
26 // Close implements the io.Closer interface so dbConnection
27 // can be managed by the pool. Close performs any resource
28 // release management.
29 func (dbConn *dbConnection) Close() error {
30     log.Println("Close: Connection", dbConn.ID)
31     return nil
32 }

代码列表 7.22 展示了 dbConnection 结构体的声明及其实现 io.Closer 接口。dbConnection 类型模拟了一个管理数据库连接的结构体,目前有一个字段 ID,包含每个连接的唯一 ID。Close 方法只是报告连接正在关闭并显示其 ID。

接下来,我们有一个工厂函数,用于创建 dbConnection 的值。

代码列表 7.23. pool/main/main.go: 行 34–44
34 // idCounter provides support for giving each connection a unique id.
35 var idCounter int32
36
37 // createConnection is a factory method that will be called by
38 // the pool when a new connection is needed.
39 func createConnection() (io.Closer, error) {
40     id := atomic.AddInt32(&idCounter, 1)
41     log.Println("Create: New Connection", id)
42
43     return &dbConnection{id}, nil
44 }

代码列表 7.23 展示了 createConnection 函数的实现。该函数为连接生成一个新的唯一 ID,显示正在创建连接,并返回一个具有此唯一 ID 的 dbConnection 类型的指针值。唯一 ID 的生成是通过 atomic.AddInt32 函数完成的。它用于安全地递增包级别变量 idCounter 的值。现在我们有了资源和工厂函数,我们可以使用 pool 包来使用它。

接下来,让我们看看 main 函数内部的代码。

代码列表 7.24. pool/main/main.go: 行 48–55
48     var wg sync.WaitGroup
49     wg.Add(maxGoroutines)
50
51     // Create the pool to manage our connections.
52     p, err := pool.New(createConnection, pooledResources)
53     if err != nil {
54         log.Println(err)
55     }

main 函数开始于第 48 行声明一个 WaitGroup 并将 WaitGroup 的值设置为将要创建的 goroutine 数量。使用 pool 包的 New 函数创建一个新的 Pool。传入工厂函数和管理资源数量。这返回一个指向 Pool 值的指针,并检查任何可能的错误。现在我们有了 Pool,我们可以创建可以共享池管理的资源的 goroutine。

列表 7.25. pool/main/main.go: 行 57–66
57     // Perform queries using connections from the pool.
58     for query := 0; query < maxGoroutines; query++ {
59         // Each goroutine needs its own copy of the query
60         // value else they will all be sharing the same query
61         // variable.
62         go func(q int) {
63             performQueries(q, p)
64             wg.Done()
65         }(query)
66     }

在 列表 7.25 中使用 for 循环创建将使用池的 goroutine。每个 goroutine 调用一次 performQueries 函数然后退出。performQueries 函数提供了一个唯一的 ID 用于日志记录,以及指向 Pool 的指针。一旦所有 goroutine 都创建完成,main 函数等待 goroutine 完成。

列表 7.26. pool/main/main.go: 行 68–73
68     // Wait for the goroutines to finish.
69     wg.Wait()
70
71     // Close the pool.
72     log.Println("Shutdown Program.")
73     p.Close()

在 列表 7.26 中,main 函数等待 WaitGroup。一旦所有 goroutine 报告已完成,关闭 Pool 并终止程序。接下来,让我们看看 performQueries 函数,它使用了池的 AcquireRelease 方法。

列表 7.27. pool/main/main.go: 行 76–91
76 // performQueries tests the resource pool of connections.
77 func performQueries(query int, p *pool.Pool) {
78     // Acquire a connection from the pool.
79     conn, err := p.Acquire()
80     if err != nil {
81         log.Println(err)
82         return
83     }
84
85     // Release the connection back to the pool.
86     defer p.Release(conn)
87
88     // Wait to simulate a query response.
89     time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
90     log.Printf("QID[%d] CID[%d]\n", query, conn.(*dbConnection).ID)
91 }

列表 7.27 中 performQueries 函数的实现展示了池的 AcquireRelease 方法的使用。函数开始时通过调用 Acquire 方法从池中检索一个 dbConnection。检查返回的 error 接口值,然后在第 86 行使用 deferdbConnection 释放回池中。在第 89 和 90 行发生随机睡眠,以使用 dbConnection 模拟工作时间。

7.3. 工作

work 包的目的是展示如何使用无缓冲通道创建一个 goroutine 池,该池将执行并控制并发完成的工作量。这种方法比使用任意静态大小的缓冲通道作为工作队列并将大量 goroutine 扔给它要好。无缓冲通道提供了一种保证,即两个 goroutine 之间已经交换了数据。使用无缓冲通道的方法允许用户知道池何时正在执行工作,当通道忙于处理更多工作而无法接受时,它会推回。永远不会丢失任何工作,也不会在工作队列中卡住,因为无法保证它将被处理。

让我们看看 work 包中的 work.go 代码文件。

列表 7.28. work/work.go
01 // Example provided with help from Jason Waldrip.
02 // Package work manages a pool of goroutines to perform work.
03 package work
04
05 import "sync"
06
07 // Worker must be implemented by types that want to use
08 // the work pool.
09 type Worker interface {
10     Task()

11 }
12
13 // Pool provides a pool of goroutines that can execute any Worker
14 // tasks that are submitted.
15 type Pool struct {
16     work chan Worker
17     wg   sync.WaitGroup
18 }
19
20 // New creates a new work pool.
21 func New(maxGoroutines int) *Pool {
22     p := Pool{
23         tasks: make(chan Worker),
24     }
25
26     p.wg.Add(maxGoroutines)
27     for i := 0; i < maxGoroutines; i++ {
28         go func() {
29             for w := range p.work {
30                 w.Task()
31             }
32             p.wg.Done()
33         }()
34     }
35
36     return &p
37 }
38
39 // Run submits work to the pool.
40 func (p *Pool) Run(w Worker) {
41     p.work <- w
42 }
43
44 // Shutdown waits for all the goroutines to shutdown.
45 func (p *Pool) Shutdown() {
46     close(p.tasks)
47     p.wg.Wait()
48 }

列表 7.28 中的 work 包开始于一个名为 Worker 的接口和一个名为 Pool 的结构的声明。

列表 7.29. work/work.go: 行 07–18
07 // Worker must be implemented by types that want to use
08 // the work pool.
09 type Worker interface {
10     Task()
11 }
12
13 // Pool provides a pool of goroutines that can execute any Worker
14 // tasks that are submitted.
15 type Pool struct {
16     work chan Worker
17     wg   sync.WaitGroup
18 }

在 列表 7.29 的第 09 行上的 Worker 接口声明了一个名为 Task 的单个方法。在第 15 行声明了一个名为 Pool 的结构体,这是实现 goroutines 池的类型,并将具有处理工作的方法。该类型声明了两个字段,一个名为 work,它是一个 Worker 接口类型的通道,另一个名为 wgsync.WaitGroup

接下来让我们看看 work 包的工厂函数。

列表 7.30. work/work.go: 行 20–37
20 // New creates a new work pool.
21 func New(maxGoroutines int) *Pool {
22     p := Pool{
23         work: make(chan Worker),
24     }
25
26     p.wg.Add(maxGoroutines)
27     for i := 0; i < maxGoroutines; i++ {
28         go func() {
29             for w := range p.work {
30                 w.Task()
31             }
32             p.wg.Done()
33         }()
34     }
35
36     return &p
37 }

列表 7.30 展示了用于创建配置了固定数量 goroutines 的工作池的 New 函数。goroutines 的数量作为参数传递给 New 函数。在第 22 行创建了一个类型为 Pool 的值,并将 work 字段初始化为一个无缓冲通道。

然后,在第 26 行初始化了 WaitGroup,在第 27 行至 34 行创建了相同数量的 goroutines。这些 goroutines 只接收类型为 Worker 的接口值,并调用这些值上的 Task 方法。

列表 7.31. work/work.go: 行 28–33
28         go func() {
29             for w := range w.work {
30                 w.Task()
31             }
32             p.wg.Done()
33         }()

for range 循环会阻塞,直到在 work 通道上有 Worker 接口值可以接收。当接收到值时,会调用 Task 方法。一旦 work 通道关闭,for range 循环结束,并调用 WaitGroup 上的 Done 方法。然后 goroutine 终止。

现在我们能够创建一个可以等待并执行工作的 goroutines 池,让我们看看如何将工作提交到池中。

列表 7.32. work/work.go: 行 39–42
39 // Run submits work to the pool.
40 func (p *Pool) Run(w Worker) {
41     w.work <- w
42 }

列表 7.32 展示了 Run 方法。此方法用于将工作提交到池中。它接受一个类型为 Worker 的接口值,并通过 work 通道发送该值。由于 work 通道是一个无缓冲通道,调用者必须等待池中的某个 goroutine 接收它。这正是我们想要的,因为调用者需要保证在 Run 调用返回后,提交的工作正在被处理。

在某个时刻,工作池需要关闭。这就是 Shutdown 方法发挥作用的地方。

列表 7.33. work/work.go: 行 44–48
44 // Shutdown waits for all the goroutines to shutdown.
45 func (p *Pool) Shutdown() {
46     close(p.work)
47     p.wg.Wait()
48 }

在 列表 7.33 中的 Shutdown 方法做了两件事。首先,它关闭了 work 通道,这导致池中的所有 goroutines 关闭并调用 WaitGroup 上的 Done 方法。然后 Shutdown 方法调用 WaitGroup 上的 Wait 方法,这导致 Shutdown 方法等待所有 goroutines 报告它们已经终止。

现在您已经看到了工作包的代码,并了解了它是如何工作的,让我们回顾一下 main.go 源代码文件中的测试程序。

列表 7.34. work/main/main.go
01 // This sample program demonstrates how to use the work package
02 // to use a pool of goroutines to get work done.
03 package main
04
05 import (
06     "log"
07     "sync"
08     "time"
09
10     "github.com/goinaction/code/chapter7/patterns/work"
11 )
12

13 // names provides a set of names to display.
14 var names = []string{
15     "steve",
16     "bob",
17     "mary",
18     "therese",
19     "jason",
20 }
21
22 // namePrinter provides special support for printing names.
23 type namePrinter struct {
24     name string
25 }
26
27 // Task implements the Worker interface.
28 func (m *namePrinter) Task() {
29     log.Println(m.name)
30     time.Sleep(time.Second)
31 }
32
33 // main is the entry point for all Go programs.
34 func main() {
35     // Create a work pool with 2 goroutines.
36     p := work.New(2)
37
38     var wg sync.WaitGroup
39     wg.Add(100 * len(names))
40
41     for i := 0; i < 100; i++ {
42         // Iterate over the slice of names.
43         for _, name := range names {
44             // Create a namePrinter and provide the
45             // specific name.
46             np := namePrinter{
47                 name: name,
48             }
49
50             go func() {
51                 // Submit the task to be worked on. When RunTask
52                 // returns we know it is being handled.
53                 p.Run(&np)
54                 wg.Done()
55             }()
56         }
57     }
58
59     wg.Wait()
60
61     // Shutdown the work pool and wait for all existing work
62     // to be completed.
63     p.Shutdown()
64 }

列表 7.34 显示了使用work包执行名称显示的测试程序。代码从第 14 行开始,声明了一个名为names的包级变量,它被声明为一个字符串切片。该切片还初始化了五个名称。然后声明了一个名为namePrinter的类型。

列表 7.35. work/main/main.go: 第 22–31 行
22 // namePrinter provides special support for printing names.
23 type namePrinter struct {
24     name string
25 }
26
27 // Task implements the Worker interface.
28 func (m *namePrinter) Task() {
29     log.Println(m.name)
30     time.Sleep(time.Second)
31 }

在列表 7.35 的第 23 行,声明了namePrinter类型,并跟随Worker接口的实现。工作的目的是在屏幕上显示名称。该类型包含一个字段name,它将包含要显示的名称。Worker接口的实现使用log.Println函数来显示名称,然后等待一秒后返回。第二次等待只是为了减慢测试程序的速度,以便你可以看到并发操作的效果。

通过实现Worker接口,我们可以查看main函数内部的代码。

列表 7.36. work/main/main.go: 第 33–64 行
33 // main is the entry point for all Go programs.
34 func main() {
35     // Create a work pool with 2 goroutines.
36     p := work.New(2)
37
38     var wg sync.WaitGroup
39     wg.Add(100 * len(names))
40
41     for i := 0; i < 100; i++ {
42         // Iterate over the slice of names.
43         for _, name := range names {
44             // Create a namePrinter and provide the
45             // specific name.
46             np := namePrinter{
47                 name: name,
48             }
49
50             go func() {
51                 // Submit the task to be worked on. When RunTask
52                 // returns we know it is being handled.
53                 p.Run(&np)

54                 wg.Done()
55             }()
56         }
57     }
58
59     wg.Wait()
60
61     // Shutdown the work pool and wait for all existing work
62     // to be completed.
63     p.Shutdown()
64 }

在列表 7.36 的第 36 行,从work包中调用了New函数来创建工作池。调用中传入了数字 2,表示池中只应包含两个 goroutine。在第 38 和 39 行,声明并初始化了一个WaitGroup,用于每个将被创建的 goroutine。在这种情况下,将为names切片中的每个名称创建 100 个 goroutine。这是为了创建大量的 goroutine,它们将竞争向池提交工作。

在第 41 和 43 行声明了内部和外部for循环来创建所有 goroutine。在内循环的每次迭代中,创建一个类型为namePrinter的值,并提供一个要打印的名称。然后,在第 50 行,声明并创建了一个匿名函数,作为一个 goroutine。该 goroutine 调用工作池的Run方法来提交namePrinter值到池中。一旦工作池中的 goroutine 接收到了值,Run的调用就会返回。这反过来导致 goroutine 减少WaitGroup计数并终止。

一旦创建了所有 goroutine,main函数就会在WaitGroup上调用Wait。该函数将等待所有创建的 goroutine 提交它们的工作。一旦Wait返回,通过调用Shutdown方法关闭工作池。此方法不会返回,直到所有工作都完成。在我们的例子中,此时会有两个未完成的工作项。

7.4. 摘要

  • 你可以使用通道来控制程序的生存期。

  • 可以使用带有默认情况的select语句来尝试在通道上进行非阻塞的发送或接收。

  • 缓冲通道可以用来管理一个可重用的资源池。

  • 通道的协调和同步由运行时处理。

  • 使用无缓冲通道创建一个 goroutine 池来执行工作。

  • 任何可以使用未缓冲通道在两个 goroutine 之间交换数据的时候,你都有可以信赖的保证。

第八章. 标准库

本章

  • 编写输出和日志信息

  • 解码和编码 JSON

  • 与 IO 和数据流一起工作

  • 标准库中包之间的互操作性

什么是 Go 标准库 以及为什么它很重要?Go 标准库是一组核心包,它增强并扩展了语言的功能。这些包增加了你可以编写的不同类型程序的数量,而无需构建自己的包或下载他人发布的包。由于这些包与语言绑定,它们提供了一些特殊的保证:

  • 它们将始终存在于语言的每个小版本中。

  • 他们将遵守向后兼容性的承诺。

  • 它们是 Go 的开发、构建和发布过程的一部分。

  • 它们由 Go 贡献者维护和审查。

  • 它们会随着语言每个新版本的发布进行测试和基准测试。

这些保证使标准库变得特殊,并且你希望尽可能多地利用它。通过使用标准库中的包,你可以更容易地管理你的代码,并确保其可靠性。这是因为你不必担心程序在发布周期之间是否会崩溃,也不必管理第三方依赖。

如果标准库不包含所有这些优秀的包,所有这些好处都将毫无意义。社区中的 Go 开发者比其他语言更依赖这些包。这是因为它们设计得很好,并且提供了比传统标准库中通常找到的功能更多。最终,Go 社区依赖于标准库来完成许多其他语言中的开发者不做的事情,例如网络、HTTP、图像处理和密码学。

在本章中,我们将从高层次的角度审视当前属于标准库的包集。然后我们将更详细地探讨三个对许多不同程序都很有用的包:logjsonio。这些包也展示了 Go 提供的一些优秀功能。

8.1. 文档和源代码

标准库中包含的包如此之多,以至于在一章的范围内不可能全部涵盖。目前,有超过 100 个包组织在 38 个类别中。

列表 8.1. 标准库中的顶级文件夹和包集
archive   bufio      bytes     compress   container   crypto    database
debug     encoding   errors    expvar     flag        fmt       go
hash      html       image     index      io          log       math
mime      net        os        path       reflect     regexp    runtime
sort      strconv    strings   sync       syscall     testing   text
time      unicode    unsafe

列表 8.1 中的许多类别本身也是包。要获取详细描述和查看所有可用的包,Go 团队在 Go 网站上维护了文档,网址为 golang.org/pkg/

golang 网站的 pkg 部分提供了每个包的 godoc 文档。图 8.1 展示了 golang 网站上 io 包的包文档示例。

图 8.1. golang.org/pkg/io/#Writer

如果您想要可以交互的文档,Sourcegraph 已经索引了标准库以及包含 Go 代码的许多公共仓库的所有代码。图 8.2 展示了在 Sourcegraph 网站上(https://sourcegraph.com/)的io包的包文档示例。

图 8.2. sourcegraph.com/code.google.com/p/go/.GoPackage/io/.def/Writer

无论您如何安装 Go,标准库的所有源代码都可以在您的开发机器上的\(GOROOT/src/pkg 文件夹中找到。拥有标准库的源代码对于 Go 工具的正常工作非常重要。像`godoc`、`gocode`甚至`go build`这样的工具会读取这些源代码以执行其功能。如果这些源代码不在您的机器上,并且无法通过`\)GOROOT`变量访问,那么在尝试构建您的程序时您将遇到错误。

标准库的源代码作为 Go 发行版包的一部分预先编译。这些预先编译的文件,称为归档文件,可以在您安装的每个目标平台和操作系统的$GOROOT/pkg 文件夹中找到。在图 8.3 中,您可以看到具有.a 扩展名的文件,这些就是归档文件。

图 8.3. pkg 文件夹内归档文件视图

这些文件是特殊的静态 Go 库,Go 构建工具在编译和链接您的最终程序时创建和使用它们。这有助于构建过程更快。但在执行构建时无法指定这些文件,因此您无法共享它们。Go 工具知道何时可以使用现有的.a 文件,何时需要从您机器上的源代码重新构建一个。

在建立这样的背景知识之后,让我们来看看标准库中的几个包,并看看您如何在您的程序中消费它们。

8.2. 记录日志

您的程序有错误,即使它们还没有表现出来。这就是软件开发的本性。记录日志是一种找到这些错误并了解更多关于您的程序如何运行的方法。日志可以是您的眼睛和耳朵,提供代码跟踪、分析和性能分析。考虑到这一点,标准库提供了一个日志包,它可以与一些基本配置一起使用。您还可以创建自定义日志记录器以实现您自己的特定日志需求。

日志记录在 UNIX 中有着悠久的历史,这一点也延续到了log包中。传统的 CLI(命令行界面)程序将它们的输出写入stdout设备。这个设备存在于所有操作系统上,是标准文本输出的默认目的地。默认情况下,终端被配置为显示写入此设备的内容。使用这个单一目的地工作得很好,直到你有一个需要同时写入输出和程序功能细节的程序。当你想要写入日志信息时,你希望将其写入不同的目的地,这样你的输出和日志就不会混合在一起。

为了解决这个问题,UNIX 架构师添加了一个名为stderr的设备。这个设备被创建为日志的默认目的地。它允许开发者将程序的输出与日志分离。为了用户在运行程序时能够看到输出和日志,终端控制台被配置为显示写入stdoutstderr的内容。但是,如果你的程序只写入日志,那么将一般日志信息写入stdout,错误或警告写入stderr是常见的做法。

8.2.1. 日志包

在你学习如何创建自己的自定义日志记录器之前,让我们先看看log包提供的基功能。日志记录的目的是获取程序正在做什么、在哪里发生以及何时发生的跟踪信息。这是你可以通过一些配置写入每条日志行的一些信息。

列表 8.2. 样本跟踪行
TRACE: 2009/11/10 23:00:00.000000 /tmpfs/gosandbox-/prog.go:14: message

在列表 8.2 中,你可以看到一个由log包生成的日志条目。这个日志条目包含一个前缀、一个日期时间戳、写入日志的源代码的完整路径、执行写入操作的代码行,以及最后的信息。让我们看看一个允许你配置log包以写入此类行的程序。

列表 8.3. listing03.go
01 // This sample program demonstrates how to use the base log package.
02 package main
03
04 import (

05     "log"
06 )
07
08 func init() {
09     log.SetPrefix("TRACE: ")
10     log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }
12
13 func main() {
14     // Println writes to the standard logger.
15     log.Println("message")
16
17     // Fatalln is Println() followed by a call to os.Exit(1).
18     log.Fatalln("fatal message")
19
20     // Panicln is Println() followed by a call to panic().
21     log.Panicln("panic message")
22 }

如果你从列表 8.3 运行程序,你应该会得到与列表 8.2 相似的输出。让我们分解代码,看看它是如何工作的。

列表 8.4. listing03.go: 第 08-11 行
08 func init() {
09     log.SetPrefix("TRACE: ")
10     log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }

在第 08 至 11 行,我们有一个名为init()的函数。这个函数作为程序初始化的一部分在main()之前执行。在init()中设置日志配置是常见的做法,这样程序启动时就可以立即使用log包。在我们的程序中,第 09 行设置了用作每行前缀的字符串。这个字符串应该是一个可以让你识别出日志行而不是普通程序输出的字符串。传统上,这个字符串是用大写字母书写的。

log包相关联有几个标志,它们控制可以写入每行日志的其他信息。以下列表显示了当前存在的标志。

列表 8.5. golang.org/src/log/log.go
const (
  // Bits or'ed together to control what's printed. There is no control
  // over the order they appear (the order listed here) or the format
  // they present (as described in the comments).  A colon appears after
  // these items:
  //    2009/01/23 01:23:23.123123 /a/b/c/d.go:23: message

  // the date: 2009/01/23
  Ldate = 1 << iota

  // the time: 01:23:23

  Ltime

  // microsecond resolution: 01:23:23.123123\. assumes Ltime
  Lmicroseconds

  // full file name and line number: /a/b/c/d.go:23
  Llongfile

  // final file name element and line number: d.go:23
  // overrides Llongfile
  Lshortfile

  // initial values for the standard logger
  LstdFlags = Ldate | Ltime
)

列表 8.5 直接来自 log 包的源代码。这些标志被声明为常量,并且在这个块中的第一个常量被称作 Ldate,它使用特殊的语法声明。

列表 8.6. Ldate 常量的声明
// the date: 2009/01/23
  Ldate = 1 << iota

当涉及到声明一组常量时,iota 关键字具有特殊用途。它指示编译器对每个常量重复表达式,直到块结束或找到赋值语句。iota 关键字的另一个功能是,每个先前常量的 iota 值会增加 1,初始值为 0。让我们更仔细地看看这一点。

列表 8.7. 关键字 iota 的使用
const (
  Ldate = 1 << iota  // 1 << 0 = 000000001 = 1
  Ltime              // 1 << 1 = 000000010 = 2
  Lmicroseconds      // 1 << 2 = 000000100 = 4
  Llongfile          // 1 << 3 = 000001000 = 8
  Lshortfile         // 1 << 4 = 000010000 = 16
  ...
)

列表 8.7 展示了常量声明背后的操作。<< 操作符执行操作符左侧值表示的位的左移位操作。在每种情况下,值为 1 的位模式都被移位到 iota 位置。这起到了为每个常量赋予其独特位位置的作用,这在处理标志时是完美的。

LstdFlags 常量显示了为什么每个常量都有其独特的位位置背后的目的。

列表 8.8. LstdFlags 常量的声明
const (
  ...
  LstdFlags = Ldate(1) | Ltime(2) = 00000011 = 3
)

在 列表 8.8 中,你可以看到 LstdFlags 常量由于使用了赋值运算符而中断了 iota 链。LstdFlags 常量被赋予了值 3,这得益于管道操作符 (|) 用于进行位或操作。位或操作相当于将位连接起来,因此每个单独设置的位在最终值中都有所表示。当位 1 和 2 进行或操作时,它们形成值 3

让我们再次看看我们如何设置我们想要应用的日志标志。

列表 8.9. listing03.go: 行 08–11
08 func init() {
09     ...
10     log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }

在这里,我们将 LdateLmicrosecondsLlongfile 标志一起管道化,并将该操作的值传递给 SetFlags 函数。这些标志一起管道化时,代表值 13 和位 4、3 和 1(00001101)。由于每个常量代表一个单独的位,因此可以使用管道操作符将标志连接起来,以创建一个表示我们想要应用的所有日志选项的值。然后,log 包会检查我们传递的整数值,以确定哪些位被设置,从而应用我们请求的正确属性。

在初始化 log 包之后,你可以查看 main() 函数,了解如何写入消息。

列表 8.10. listing03.go: 行 13–22
13 func main() {
14     // Println writes to the standard logger.
15     log.Println("message")
16
17     // Fatalln is Println() followed by a call to os.Exit(1).
18     log.Fatalln("fatal message")
19
20     // Panicln is Println() followed by a call to panic().
21     log.Panicln("panic message")
22 }

列表 8.10 展示了如何使用三个不同的函数:PrintlnFatallnPanicln 来写入日志消息。这些函数也有一个格式版本,以字母 f 结尾而不是 lnFatal 函数族用于写入日志消息并使用 os.Exit(1) 函数调用终止程序。Panic 函数族用于写入日志消息并引发恐慌,除非恢复,否则将导致程序终止并显示堆栈跟踪。Print 函数族是写入日志消息的标准方式。

log 包的一个优点是日志记录器是多 goroutine 安全的。这意味着多个 goroutine 可以同时从同一个日志记录器值调用这些函数,而不会相互冲突。标准日志记录器和您可能创建的任何自定义日志记录器都将具有此属性。

现在您已经知道了如何使用 log 包并对其进行配置,让我们探索如何创建自定义日志记录器,这样您就可以拥有不同日志级别,并将日志写入不同的目的地。

8.2.2. 自定义日志记录器

创建自定义日志记录器需要您创建自己的 Logger 类型值。您创建的每个日志记录器都可以配置为独特的目的地,并设置其自己的前缀和标志。让我们看看一个示例程序,该程序创建不同的 Logger 类型指针变量以支持不同的日志级别。

列表 8.11. listing11.go
01 // This sample program demonstrates how to create customized loggers.
02 package main
03
04 import (
05     "io"
06     "io/ioutil"
07     "log"
08     "os"
09 )
10
11 var (
12     Trace   *log.Logger // Just about anything
13     Info    *log.Logger // Important information
14     Warning *log.Logger // Be concerned
15     Error   *log.Logger // Critical problem
16 )
17
18 func init() {
19     file, err := os.OpenFile("errors.txt",
20         os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
21     if err != nil {
22         log.Fatalln("Failed to open error log file:", err)
23     }
24
25     Trace = log.New(ioutil.Discard,
26         "TRACE: ",
27         log.Ldate|log.Ltime|log.Lshortfile)
28
29     Info = log.New(os.Stdout,
30         "INFO: ",
31         log.Ldate|log.Ltime|log.Lshortfile)
32

33     Warning = log.New(os.Stdout,
34         "WARNING: ",
35         log.Ldate|log.Ltime|log.Lshortfile)
36
37     Error = log.New(io.MultiWriter(file, os.Stderr),
38         "ERROR: ",
39         log.Ldate|log.Ltime|log.Lshortfile)
40 }
41
42 func main() {
43     Trace.Println("I have something standard to say")
44     Info.Println("Special Information")
45     Warning.Println("There is something you need to know about")
46     Error.Println("Something has failed")
47 }

列表 8.11 展示了一个完整的程序,该程序创建了四个不同的 Logger 类型指针变量。它们的名称分别是 TraceInfoWarningError。每个变量都根据其重要性进行了不同的配置。让我们分解代码,以便您可以了解这一切是如何工作的。

在第 11 至 16 行,我们声明了四个 Logger 类型指针变量,用于我们的不同日志级别。

列表 8.12. listing11.go: 行 11–16
11 var (
12     Trace   *log.Logger // Just about anything
13     Info    *log.Logger // Important information
14     Warning *log.Logger // Be concerned
15     Error   *log.Logger // Critical problem
16 )

在 列表 8.12 中,您可以看到 Logger 类型指针变量的声明。我们为每个日志记录器分配了一个简短但描述性的变量名。接下来,让我们看看 init() 函数中的代码,该代码创建并分配每个 Logger 类型值的地址给每个变量。

列表 8.13. listing11.go: 行 25–39
25     Trace = log.New(ioutil.Discard,
26         "TRACE: ",
27         log.Ldate|log.Ltime|log.Lshortfile)
28
29     Info = log.New(os.Stdout,
30         "INFO: ",
31         log.Ldate|log.Ltime|log.Lshortfile)
32
33     Warning = log.New(os.Stdout,
34         "WARNING: ",
35         log.Ldate|log.Ltime|log.Lshortfile)
36
37     Error = log.New(io.MultiWriter(file, os.Stderr),
38         "ERROR: ",
39         log.Ldate|log.Ltime|log.Lshortfile)

要创建每个日志记录器,我们使用来自 log 包的 New 函数,该函数创建一个正确初始化的 Logger 类型值。New 函数返回新创建值的地址。在 New 函数可以创建值之前,我们需要传递一些参数。

列表 8.14. golang.org/src/log/log.go
// New creates a new Logger. The out variable sets the
// destination to which log data will be written.
// The prefix appears at the beginning of each generated log line.
// The flag argument defines the logging properties.
func New(out io.Writer, prefix string, flag int) *Logger {
    return &Logger{out: out, prefix: prefix, flag: flag}
}

列表 8.14 展示了来自 log 包源代码的 New 函数的声明。第一个参数是我们希望日志记录器写入的目的地。这是一个实现了 io.Writer 接口的价值。第二个参数是您之前看到的那个前缀,日志标志是最后一个参数。

在我们的程序中,Trace 记录器使用 ioutil 包中的 Discard 变量作为写入目标。

列表 8.15. listing11.go: 行 25–27
25     Trace = log.New(ioutil.Discard,
26         "TRACE: ",
27         log.Ldate|log.Ltime|log.Lshortfile)

Discard 变量有一些非常有趣的属性。

列表 8.16. golang.org/src/io/ioutil/ioutil.go
// devNull is a named type using int as its base type.
type devNull int

// Discard is an io.Writer on which all Write calls succeed
// without doing anything.
var Discard io.Writer = devNull(0)

// Implementation of the io.Writer interface.
func (devNull) Write(p []byte) (int, error) {
    return len(p), nil
}

列表 8.16 展示了 Discard 变量的声明及其周围的实现。Discard 变量被声明为接口类型 io.Writer,并赋予了一个类型为 devNull0 值。任何写入此变量的内容都将根据 devNull 类型的 Write 方法实现被丢弃。使用 Discard 变量是一种技术,可以在不需要该级别的输出时禁用日志级别。

InfoWarning 记录器都使用 stdout 目标。

列表 8.17. listing11.go: 行 29–35
29     Info = log.New(os.Stdout,
30         "INFO: ",
31         log.Ldate|log.Ltime|log.Lshortfile)
32
33     Warning = log.New(os.Stdout,
34         "WARNING: ",
35         log.Ldate|log.Ltime|log.Lshortfile)

Stdout 变量的声明也很有趣。

列表 8.18. golang.org/src/os/file.go
// Stdin, Stdout, and Stderr are open Files pointing to the standard
// input, standard output, and standard error file descriptors.
var (
    Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
    Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
    Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

os/file_unix.go

// NewFile returns a new File with the given file descriptor and name.
func NewFile(fd uintptr, name string) *File {

在 列表 8.18 中,你可以看到代表所有操作系统上存在的标准目标的三变量声明:StdinStdoutStderr。所有这些变量都被声明为 File 类型的指针,该类型实现了 io.Writer 接口。这使我们来到了最终的记录器,Error

列表 8.19. listing11.go: 行 37–39
37     Error = log.New(io.MultiWriter(file, os.Stderr),
38         "ERROR: ",
39         log.Ldate|log.Ltime|log.Lshortfile)

在 列表 8.19 中,你可以看到 New 函数的第一个参数来自 io 包中的一个特殊函数,称为 MultiWriter

列表 8.20. io 包中 MultiWriter 函数的声明
io.MultiWriter(file, os.Stderr)

列表 8.20 将对 MultiWriter 函数的调用隔离出来,该函数返回一个包含我们打开的文件和 stderr 目标的 io.Writer 接口类型值。MultiWriter 函数是一个可变参数函数,接受任何实现 io.Writer 接口值的数量。该函数返回一个单一的 io.Writer 值,它捆绑了所有传入的 io.Writer 值。这允许像 log.New 这样的函数在单个写入器中接受多个写入器。现在当我们使用 Error 记录器写入日志时,输出将被写入文件和 stderr

现在你已经知道了如何创建自定义记录器,让我们看看如何使用它们来写入消息。

列表 8.21. listing11.go: 行 42–47
42 func main() {
43     Trace.Println("I have something standard to say")
44     Info.Println("Special Information")
45     Warning.Println("There is something you need to know about")
46     Error.Println("Something has failed")
47 }

列表 8.21 展示了 列表 8.11 中的 main() 函数。在第 43 到 46 行,我们为每个创建的记录器写入一条消息。每个记录器变量都包含一组与 log 包实现的功能相同的函数。

列表 8.22. 不同记录方法的声明
func (l *Logger) Fatal(v ...interface{})
func (l *Logger) Fatalf(format string, v ...interface{})
func (l *Logger) Fatalln(v ...interface{})
func (l *Logger) Flags() int
func (l *Logger) Output(calldepth int, s string) error
func (l *Logger) Panic(v ...interface{})
func (l *Logger) Panicf(format string, v ...interface{})
func (l *Logger) Panicln(v ...interface{})
func (l *Logger) Prefix() string
func (l *Logger) Print(v ...interface{})
func (l *Logger) Printf(format string, v ...interface{})
func (l *Logger) Println(v ...interface{})
func (l *Logger) SetFlags(flag int)
func (l *Logger) SetPrefix(prefix string)

列表 8.22 展示了为 Logger 类型实现的所有方法。

8.2.3. 结论

log 包是基于对日志目的的长期历史理解和实际应用而实现的。将输出写入 stdout 和将日志记录到 stderr 已成为许多基于 CLI 的程序的传统。但当你的程序只输出日志时,使用 stdoutstderr 和文件是完全可接受的。

标准库中的 log 包提供了你进行日志记录所需的一切,并且推荐使用。你可以信任其实现,不仅因为它属于标准库,而且还因为它被社区广泛使用。

8.3. 编码/解码

许多应用程序,无论它们是使用数据库、进行网络调用还是在分布式系统中工作,都需要消费和发布数据。如果你的应用程序正在处理 XML 或 JSON,标准库中有名为 xmljson 的包,这些包使得处理这些数据格式变得非常简单。如果你有自己的数据格式需要编码和解码,这些包的实现是实施你自己的包的一个很好的路线图。

现在处理和使用 JSON 比处理 XML 更常见。这主要是因为使用 JSON 比使用 XML 需要更少的标记。这意味着每条消息需要发送的数据更少,这有助于系统的整体性能。此外,JSON 可以转换为 BSON(Binary JavaScript Object Notation),这进一步减少了每条消息的大小。鉴于这一点,我们将探讨如何在 Go 应用程序中消费和发布 JSON。但处理 XML 非常相似。

8.3.1. 解码 JSON

我们将探索的第一个与 JSON 一起工作的方面是使用 json 包中的 NewDecoder 函数和 Decode 方法。如果你正在从网络响应或文件中消费 JSON,这就是你想要使用的函数和方法。让我们看看一个使用 http 包执行针对返回 JSON 结果的 Google 搜索 API 的 Get 请求的示例。下一个列表显示了响应的外观。

列表 8.23. Google 搜索 API 示例 JSON 响应
{
    "responseData": {
        "results": [
            {
                "GsearchResultClass": "GwebSearch",
                "unescapedUrl": "https://www.reddit.com/r/golang",
                "url": "https://www.reddit.com/r/golang",
                "visibleUrl": "www.reddit.com",
                "cacheUrl": "http://www.google.com/search?q=cache:W...",
                "title": "r/\u003cb\u003eGolang\u003c/b\u003e - Reddit",
                "titleNoFormatting": "r/Golang - Reddit",
                "content": "First Open Source \u003cb\u003eGolang\u..."
            },
            {
                "GsearchResultClass": "GwebSearch",
                "unescapedUrl": "http://tour.golang.org/",
                "url": "http://tour.golang.org/",
                "visibleUrl": "tour.golang.org",
                "cacheUrl": "http://www.google.com/search?q=cache:O...",
                "title": "A Tour of Go",
                "titleNoFormatting": "A Tour of Go",
                "content": "Welcome to a tour of the Go programming ..."

            }
        ]
    }
}

下面是一个示例,它检索并解码响应到一个结构体类型。

列表 8.24. listing24.go
01 // This sample program demonstrates how to decode a JSON response
02 // using the json package and NewDecoder function.
03 package main
04
05 import (
06     "encoding/json"
07     "fmt"
08     "log"
09     "net/http"
10 )
11
12 type (
13     // gResult maps to the result document received from the search.
14     gResult struct {
15         GsearchResultClass string `json:"GsearchResultClass"`
16         UnescapedURL       string `json:"unescapedUrl"`
17         URL                string `json:"url"`
18         VisibleURL         string `json:"visibleUrl"`
19         CacheURL           string `json:"cacheUrl"`
20         Title              string `json:"title"`
21         TitleNoFormatting  string `json:"titleNoFormatting"`
22         Content            string `json:"content"`
23     }
24
25     // gResponse contains the top level document.
26     gResponse struct {
27         ResponseData struct {
28             Results []gResult `json:"results"`
29         } `json:"responseData"`
30     }
31 )
32
33 func main() {
34     uri := "http://ajax.googleapis.com/ajax/services/search/web?
                                                  v=1.0&rsz=8&q=golang"
35
36     // Issue the search against Google.
37     resp, err := http.Get(uri)
38     if err != nil {
39        log.Println("ERROR:", err)
40        return
41     }
42     defer resp.Body.Close()
43
44     // Decode the JSON response into our struct type.
45     var gr gResponse
46     err = json.NewDecoder(resp.Body).Decode(&gr)

47     if err != nil {
48         log.Println("ERROR:", err)
49         return
50     }
51
52     fmt.Println(gr)
53 }

在 listing 8.24 中的第 37 行的代码显示了一个程序,该程序执行 HTTP Get 调用以从 Google 获取 JSON 文档。然后,在第 46 行使用 NewDecoder 函数和 Decode 方法,将响应中的 JSON 文档解码到第 26 行声明的结构体类型的变量中。在第 52 行,变量的值被写入 stdout

如果你查看第 26 行和第 14 行的 gResponsegResult 的类型声明,你会注意到每个字段末尾都声明了字符串。这些被称为 标签,它们是提供 JSON 文档和结构体类型之间字段映射元数据的机制。如果没有标签,解码和编码过程将尝试以不区分大小写的方式直接匹配字段名称。当无法进行映射时,结构体值中的字段将包含其零值。

感谢标准库,执行 HTTP Get 调用和将 JSON 解码为结构体类型的所有技术细节都由它处理。让我们看看 NewDecoder 函数和 Decode 方法的声明。

列表 8.25. golang.org/src/encoding/json/stream.go
// NewDecoder returns a new decoder that reads from r.
//
// The decoder introduces its own buffering and may
// read data from r beyond the JSON values requested.
func NewDecoder(r io.Reader) *Decoder

// Decode reads the next JSON-encoded value from its
// input and stores it in the value pointed to by v.
//
// See the documentation for Unmarshal for details about
// the conversion of JSON into a Go value.
func (dec *Decoder) Decode(v interface{}) error

在 列表 8.25 中,你可以看到 NewDecoder 函数接受任何实现了 io.Reader 接口类型的值。在下一节中,你将了解更多关于 io.Readerio.Writer 接口的内容。现在,理解标准库中的许多不同类型实现了这些接口,包括来自 http 包的类型。当类型实现这些特定接口时,你将获得许多免费的支持和功能。

NewDecoder 函数返回一个类型为 Decoder 的指针值。由于 Go 支持复合语句调用,NewDecoder 函数的返回值可以直接用来调用 Decode 方法,而不需要先声明一个变量。在 列表 8.25 中,你可以看到 Decode 方法接受一个类型为 interface{} 的值,并返回一个错误。

如 第五章 所述,空接口是每个类型都实现的接口。这意味着 Decode 方法可以接受任何类型的值。通过使用反射,Decode 方法将检查你传递的值的类型信息。然后,当它读取 JSON 响应时,它将解码响应到该类型的值。这意味着你不需要自己创建值;Decode 可以为你完成这项工作。

列表 8.26. Decode 方法的使用
var gr *gResponse
err = json.NewDecoder(resp.Body).Decode(&gr)

在 列表 8.26 中,我们将类型为 gResponse 的指针变量的地址(值为 nil)传递给 Decode 方法。方法调用后,指针变量的值将被分配一个类型为 gResponse 的值,并根据正在解码的 JSON 文档进行初始化。

有时你正在处理的 JSON 文档以 string 值的形式出现。在这些情况下,你需要将 string 转换为 byte 切片 ([]byte),并使用 json 包中的 Unmarshal 函数。

列表 8.27. listing27.go
01 // This sample program demonstrates how to decode a JSON string.
02 package main
03
04 import (
05     "encoding/json"
06     "fmt"
07     "log"
08 )
09
10 // Contact represents our JSON string.
11 type Contact struct {
12     Name    string `json:"name"`
13     Title   string `json:"title"`
14     Contact struct {
15         Home string `json:"home"`
16         Cell string `json:"cell"`
17     } `json:"contact"`
18 }
19
20 // JSON contains a sample string to unmarshal.
21 var JSON = `{
22     "name": "Gopher",
23     "title": "programmer",
24     "contact": {
25         "home": "415.333.3333",
26         "cell": "415.555.5555"

27     }
28 }`
29
30 func main() {
31     // Unmarshal the JSON string into our variable.
32     var c Contact
33     err := json.Unmarshal([]byte(JSON), &c)
34     if err != nil {
35         log.Println("ERROR:", err)
36         return
37     }
38
39     fmt.Println(c)
40 }

在 列表 8.27 中,我们有一个示例,它将一个 JSON 文档放在一个 string 变量中,并使用 Unmarshal 函数将 JSON 解码为结构体类型的值。如果你运行程序,你会得到以下输出。

列表 8.28. listing27.go 的输出
{Gopher programmer {415.333.3333 415.555.5555}}

有时无法声明一个结构体类型,你需要更多的灵活性来处理 JSON 文档。在这些情况下,你可以将 JSON 文档解码或反序列化为 map 变量。

列表 8.29. listing29.go
01 // This sample program demonstrates how to decode a JSON string.
02 package main
03
04 import (
05     "encoding/json"
06     "fmt"
07     "log"
08 )
09
10 // JSON contains a sample string to unmarshal.
11 var JSON = `{
12     "name": "Gopher",
13     "title": "programmer",
14     "contact": {
15         "home": "415.333.3333",
16         "cell": "415.555.5555"
17     }
18 }`
19
20 func main() {
21     // Unmarshal the JSON string into our map variable.
22     var c map[string]interface{}
23     err := json.Unmarshal([]byte(JSON), &c)
24     if err != nil {

25         log.Println("ERROR:", err)
26         return
27     }
28
29     fmt.Println("Name:", c["name"])
30     fmt.Println("Title:", c["title"])
31     fmt.Println("Contact")
32     fmt.Println("H:", c["contact"].(map[string]interface{})["home"])
33     fmt.Println("C:", c["contact"].(map[string]interface{})["cell"])
34 }

在 列表 8.29 中,我们将程序从 列表 8.27 更改为使用 map 变量而不是我们的结构体类型变量。map 变量被声明为一个具有 string 类型键和 interface{} 类型值的映射。这意味着 map 可以为任何给定的键存储任何类型的值。虽然这在你处理 JSON 文档时提供了很大的灵活性,但它有一个小的缺点。看看访问 contact 子文档中的 home 字段的语法要求。

列表 8.30. 从反序列化映射中访问字段的语法
fmt.Println("\tHome:", c["contact"].(map[string]interface{})["home"])

因为每个键的值都是 interface{} 类型,你需要将值转换为适当的原生类型才能与该值一起工作。列表 8.30 展示了你需要如何将 contact 键的值转换为另一个具有 string 类型键和 interface{} 类型值的映射。这可能会使得使用包含 JSON 文档的映射变得不友好。但是,如果你从未需要深入挖掘你正在处理的 JSON 文档,或者你计划进行很少的修改,使用 map 可以更快,而且不需要声明新的类型。

8.3.2. 编码 JSON

我们将要探索的与 JSON 一起工作的第二个方面是使用 json 包中的 MarshalIndent 函数。当你想从 Go mapstruct 类型值发布格式化的 JSON 文档时,这非常有用。序列化是将数据转换为 JSON 字符串的过程。以下是一个将 map 类型转换为 JSON 字符串的示例。

列表 8.31. listing31.go
01 // This sample program demonstrates how to marshal a JSON string.
02 package main
03
04 import (
05     "encoding/json"
06     "fmt"
07     "log"
08 )
09
10 func main() {

11     // Create a map of key/value pairs.
12     c := make(map[string]interface{})
13     c["name"] = "Gopher"
14     c["title"] = "programmer"
15     c["contact"] = map[string]interface{}{
16         "home": "415.333.3333",
17         "cell": "415.555.5555",
18     }
19
20     // Marshal the map into a JSON string.
21     data, err := json.MarshalIndent(c, "", "    ")
22     if err != nil {
23         log.Println("ERROR:", err)
24         return
25     }
26
27     fmt.Println(string(data))
28 }

列表 8.31 展示了如何使用 json 包中的 MarshalIndent 函数将 map 转换为 JSON 字符串。MarshalIndent 函数返回一个表示 JSON 字符串的字节切片和一个错误值。以下是对 json 包中 MarshalIndent 函数声明的查看。

列表 8.32. golang.org/src/encoding/json/encode.go
// MarshalIndent is like Marshal but applies Indent to format the output
func MarshalIndent(v interface{}, prefix, indent string)
                                                       ([]byte, error) {

你可以在 MarshalIndent 函数的参数中再次看到空接口类型的使用。MarshalIndent 函数使用反射来确定如何将 map 类型转换为 JSON 字符串。

如果你不需要为你的 JSON 编码使用漂亮的打印格式,json 包还提供了一个名为 Marshal 的函数。这个函数非常适合生成可以返回在网络响应中,如 Web API 的 JSON。Marshal 函数的工作方式与 MarshalIndent 函数相同,但没有 prefixindent 参数。

8.3.3. 结论

如果你正在处理 JSON 或甚至 XML,标准库已经为你提供了解码、反序列化和序列化这些格式数据的所有支持。随着 Go 的每个新版本发布,这些包变得越来越快,使得使用 JSON 和 XML 成为一种很好的选择。多亏了反射包和标签支持,声明 struct 类型并将这些字段映射到你需要消费和发布的文档字段变得非常容易。由于 jsonxml 包为 io.Readerio.Writer 接口提供了支持,所以你的 JSON 和 XML 文档来自哪里并不重要。所有支持都已提供,使得处理 JSON 和 XML 变得轻松愉快。

8.4. 输入和输出

UNIX 基础操作系统之所以如此出色,其中一个原因就是程序输出可以作为另一个程序的输入。这种理念创造了一系列只做一件事并且做得非常好的简单程序。然后,通过组合程序,可以创建脚本来完成一些惊人的事情。在这个世界中,stdoutstdin 设备作为在进程之间移动数据的通道。

这个同样的想法也被扩展到了 io 包中,它提供的功能非常出色。无论数据是什么,来自哪里,还是去向何方,该包都支持非常高效地处理数据流。你不再有 stdoutstdin,而是有两个接口,称为 io.Writerio.Reader。实现这些接口的类型值可以用于 io 包提供的所有功能,或者任何其他包中接受这些接口类型值的函数和方法。这就是从接口类型创建功能和 API 的真正美妙之处。开发者可以在现有功能之上进行组合,利用现有功能,并专注于他们试图解决的商业问题。

在这个前提下,让我们首先看看 io.Writerio.Reader 接口的声明,然后检查一些展示 io 包惊人功能的代码示例。

8.4.1. 写入器和读取器接口

io 包的设计是围绕处理实现 io.Writerio.Reader 接口类型的值。组成 io 包的函数和方法对数据的类型以及数据是如何物理上读取和写入的没有任何理解。这得益于 io.Writerio.Reader 接口提供的抽象。让我们首先看看 io.Writer 接口的声明。

列表 8.33. io.Writer 接口的声明
type Writer interface {
        Write(p []byte) (n int, err error)
}

列表 8.33 展示了io.Writer接口的声明。该接口声明了一个名为Write的单个方法,它接受一个byte切片并返回两个值。第一个值是写入的字节数,第二个值是一个error。实现此方法的规则如下。

列表 8.34. io.Writer接口的文档
Write writes len(p) bytes from p to the underlying data stream. It
returns the number of bytes written from p (0 <= n <= len(p)) and any
error encountered that caused the write to stop early. Write must
return a non-nil error if it returns n < len(p). Write must not modify
the slice data, even temporarily.

列表 8.34 中的规则来自标准库。这意味着Write方法的实现应该尝试写入传入的byte切片的整个长度。如果不可能,则该方法必须返回一个错误。报告为写入的字节数可以小于byte切片的长度,但不能更多。最后,byte切片在任何情况下都不能被修改。

让我们看看Reader接口的声明。

列表 8.35. io.Reader接口的声明
type Reader interface {
        Read(p []byte) (n int, err error)
}

列表 8.35 中的io.Reader接口声明了一个名为Read的单个方法,它接受一个byte切片并返回两个值。第一个值是读取的字节数,第二个值是一个error。实现此方法的规则如下。

列表 8.36. io.Reader接口的文档
(1) Read reads up to len(p) bytes into p. It returns the number of bytes
read (0 <= n <= len(p)) and any error encountered. Even if Read returns
n < len(p), it may use all of p as scratch space during the call. If
some data is available but not len(p) bytes, Read conventionally
returns what is available instead of waiting for more.

(2) When Read encounters an error or end-of-file condition after
successfully reading n > 0 bytes, it returns the number of bytes read.
It may return the (non-nil) error from the same call or return the
error (and n == 0) from a subsequent call. An instance of this general
case is that a Reader returning a non-zero number of bytes at the end
of the input stream may return either err == EOF or err == nil. The next
Read should return 0, EOF regardless.

(3) Callers should always process the n > 0 bytes returned before
considering the error err. Doing so correctly handles I/O errors that
happen after reading some bytes and also both of the allowed EOF
behaviors.

(4) Implementations of Read are discouraged from returning a zero byte
count with a nil error, and callers should treat that situation as a
no-op.

标准库中列出了关于实现Read方法的四个规则。第一条规则指出,实现应该尝试读取传入的byte切片的整个长度。读取少于整个长度是可以的,如果调用时没有那么多数据,则不需要等待读取整个长度。

第二条规则提供了关于文件结束(EOF)读取条件的指导。当读取最后一个字节时,有两种选择。Read方法要么返回带有正确计数的最终字节和EOF作为错误值,要么返回带有正确计数的最终字节和nil作为错误值。在后一种情况下,下一次读取必须返回没有字节的计数为 0 和EOF作为错误值。

第三条规则是为那些发起Read调用的人提供的建议。每次Read方法返回字节时,应该首先处理这些字节,然后再检查 EOF 或其他错误值。最后,第四条规则要求Read方法的实现永远不要返回 0 字节读取计数和nil错误值。没有读取的字节应该始终返回一个错误。

现在你已经知道了io.Writerio.Reader接口的样子以及它们应该如何表现,让我们看看一些如何在你的程序中使用这些接口和io包的例子。

8.4.2. 协同工作

这个例子展示了标准库中不同包是如何通过为实现io.Writer接口的类型提供支持而协同工作的。该例子使用了bytesfmtos包来缓冲、连接字符串并将字符串写入stdout

列表 8.37. listing37.go
01 // Sample program to show how different functions from the
02 // standard library use the io.Writer interface.
03 package main
04
05 import (
06     "bytes"
07     "fmt"
08     "os"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     // Create a Buffer value and write a string to the buffer.
14     // Using the Write method that implements io.Writer.
15     var b bytes.Buffer
16     b.Write([]byte("Hello "))
17
18     // Use Fprintf to concatenate a string to the Buffer.
19     // Passing the address of a bytes.Buffer value for io.Writer.
20     fmt.Fprintf(&b, "World!")
21
22     // Write the content of the Buffer to the stdout device.
23     // Passing the address of a os.File value for io.Writer.
24     b.WriteTo(os.Stdout)
25 }

当你在 列表 8.37 中运行程序时,你会得到以下输出。

列表 8.38. listing37.go 的输出
Hello World!

该程序正在使用标准库中的三个包将 “Hello World!” 写入终端窗口。程序从第 15 行开始,声明了一个来自 bytes 包的 Buffer 类型变量,并将其初始化为其零值。在第 16 行创建了一个 byte 切片,并用字符串 "Hello" 初始化。byte 切片被传递到 Write 方法中,成为缓冲区的初始内容。

第 20 行使用 fmt 包中的 Fprintf 函数将字符串 "World!" 追加到缓冲区中。让我们看看 Fprintf 函数的声明。

列表 8.39. golang.org/src/fmt/print.go
// Fprintf formats according to a format specifier and writes to w. It
// returns the number of bytes written and any write error encountered.
func Fprintf(w io.Writer, format string, a ...interface{})
                                                     (n int, err error)

需要注意的重要一点是 Fprintf 函数的第一个参数。它接受实现了 io.Writer 接口类型的值。这意味着 bytes 包中的 Buffer 类型必须实现此接口,因为我们能够通过传递该类型变量的地址。在 bytes 包的源代码中,我们应该找到为 Buffer 类型声明的 Write 方法。

列表 8.40. golang.org/src/bytes/buffer.go
// Write appends the contents of p to the buffer, growing the buffer
// as needed. The return value n is the length of p; err is always
// nil. If the buffer becomes too large, Write will panic with ...
func (b *Buffer) Write(p []byte) (n int, err error) {
    b.lastRead = opInvalid
    m := b.grow(len(p))
    return copy(b.buf[m:], p), nil
}

列表 8.40 展示了实现了 io.Writer 接口的 Buffer 类型的 Write 方法的当前实现。由于此方法的实现,我们可以将 Buffer 类型指针作为 Fprintf 的第一个参数传递。我们在示例中使用 Fprintf 函数通过 Write 方法的实现将字符串 "World!" 追加到 Buffer 类型变量的内部缓冲区中。

让我们回顾 列表 8.37 的最后一行,该行将整个缓冲区写入 stdout

列表 8.41. listing37.go: 行 22–25
22 // Write the content of the Buffer to the stdout device.
23     // Passing the address of a os.File value for io.Writer.
24     b.WriteTo(os.Stdout)
25 }

在 列表 8.37 的第 24 行,Buffer 类型变量的内容使用 WriteTo 方法写入 stdout。此方法接受一个实现了 io.Writer 接口的对象。在我们的程序中,我们传递了来自 os 包的 Stdout 变量的值。

列表 8.42. golang.org/src/os/file.go
var (
    Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
    Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
    Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

这些变量的声明来自 NewFile 函数返回的类型。

列表 8.43. golang.org/src/os/file_unix.go
// NewFile returns a new File with the given file descriptor and name.
func NewFile(fd uintptr, name string) *File {
    fdi := int(fd)
    if fdi < 0 {
        return nil
    }
    f := &File{&file{fd: fdi, name: name}}
    runtime.SetFinalizer(f.file, (*file).close)
    return f
}

正如你在 列表 8.43 中看到的,NewFile 函数返回一个 File 类型的指针。这是 Stdout 变量的类型。由于我们可以将此类型的指针作为参数传递给 WriteTo 方法,因此它必须实现 io.Writer 接口。在 os 包的源代码中,我们应该找到 Write 方法。

列表 8.44. golang.org/src/os/file.go
// Write writes len(b) bytes to the File.
// It returns the number of bytes written and an error, if any.
// Write returns a non-nil error when n != len(b).
func (f *File) Write(b []byte) (n int, err error) {
    if f == nil {
        return 0, ErrInvalid
    }
    n, e := f.write(b)
    if n < 0 {
        n = 0
    }
    if n != len(b) {
        err = io.ErrShortWrite
    }

    epipecheck(f, e)

    if e != nil {

        err = &PathError{"write", f.name, e}
    }
    return n, err
}

确实,列表 8.44 展示了 File 类型指针的 io.Writer 接口实现。再次查看 列表 8.37 中的第 24 行。

列表 8.45. listing37.go: 行 22–25
22     // Write the content of the Buffer to the stdout device.
23     // Using the io.Writer implementation for os.File.
24     b.WriteTo(os.Stdout)
25 }

您可以看到,WriteTo方法能够将缓冲区的内容写入stdout,这导致字符串"Hello World!"在我们的终端窗口中显示。该方法将通过接口值使用File类型的Write方法实现。

这个例子展示了接口的美丽之处以及它们为语言带来的强大功能。多亏了bytes.Bufferos.File类型对接口的实现,我们能够重用标准库中的功能,并使这些类型协同工作以实现解决方案。接下来,让我们看看一个更实用的例子。

8.4.3. 简单的 curl

在 Linux 和 Mac OS X 系统上都可以找到一个名为curl的命令行工具。该工具允许您指定一个 URL,然后执行 HTTP 请求并保存内容。通过使用httpioos包,您只需几行代码就可以编写自己的curl版本。

让我们看看一个实现curl基本版本的例子。

列表 8.46. listing46.go
01 // Sample program to show how to write a simple version of curl using
02 // the io.Reader and io.Writer interface support.
03 package main
04
05 import (
06     "io"
07     "log"
08     "net/http"
09     "os"
10 )
11
12 // main is the entry point for the application.
13 func main() {
14     // r here is a response, and r.Body is an io.Reader.
15     r, err := http.Get(os.Args[1])

16     if err != nil {
17         log.Fatalln(err)
18     }
19
20     // Create a file to persist the response.
21     file, err := os.Create(os.Args[2])
22     if err != nil {
23         log.Fatalln(err)
24     }
25     defer file.Close()
26
27     // Use MultiWriter so we can write to stdout and
28     // a file on the same write operation.
29     dest := io.MultiWriter(os.Stdout, file)
30
31     // Read the response and write to both destinations.
32     io.Copy(dest, r.Body)
33     if err := r.Body.Close(); err != nil {
34         log.Println(err)
35     }
36 }

列表 8.46 展示了curl一个非常基础的实现,可以用来下载、显示和保存任何 HTTP Get请求的内容。该示例将同时将响应写入文件和stdout。为了保持示例的简洁,程序不会检查有效的命令行参数也不提供高级选项的开关。

在第 15 行,程序从命令行获取第一个参数并执行 HTTP Get操作。如果第一个参数是 URL 且没有错误,变量r将包含响应。在第 21 行,我们根据第二个命令行参数打开一个文件。如果我们成功打开文件,那么在第 25 行,我们使用defer语句安排关闭文件。

由于我们希望将请求内容同时写入stdout和我们的文件,所以在第 29 行,我们使用io包中的MultiWriter函数将文件和stdout值组合成一个单一的io.Writer值。在第 33 行,我们使用io包中的Copy函数从响应体中读取内容,并将其写入两个目的地。通过一次调用Copy,多亏了MultiWriter函数提供的值,我们可以使用单个调用将内容写入两个目的地。

由于io包已经提供的支持以及httpos包对io.Writerio.Reader接口的实现,我们不需要编写任何代码来执行这些底层功能。我们可以利用已经存在的一切,只需专注于我们试图解决的问题。如果我们用自己的类型支持这些接口,我们将获得大量的免费功能。

8.4.4. 结论

io包中可以找到大量的功能,并且所有这些功能都可以通过实现io.Writerio.Reader接口的类型值来访问。其他包,如http包,遵循类似的模式,将接口作为包 API 的一部分进行声明,并提供对使用io包的支持。花时间去探索标准库提供了什么以及它是如何实现的,不仅可以帮助你避免重复造轮子,还可以从语言设计者那里学习到为你的包和 API 编写惯用 Go 语言的方法。

8.5. 摘要

  • 标准库提供了特殊的保证,并且被社区广泛使用。

  • 使用标准库中的包可以更容易地管理和信任你的代码。

  • 超过 100 个包被组织在 38 个不同的类别中。

  • 标准库中的log包包含了你进行日志记录所需的一切。

  • 标准库中有两个名为xmljson的包,它们使得处理这些数据格式变得非常简单。

  • io包支持非常高效地处理数据流。

  • 接口允许你的代码与现有功能进行组合。

  • 阅读标准库中的代码是了解惯用 Go 语言的好方法。

第九章. 测试和基准测试

本章内容

  • 编写单元测试以验证你的代码

  • 使用 httptest 模拟基于 HTTP 的请求和响应

  • 使用示例代码记录你的包

  • 使用基准测试检查性能

测试你的代码不是一件你应该等到程序开发完成后才去做的事情。使用 Go 的测试框架,单元测试和基准测试可以在开发过程中进行。就像 go build 命令一样,有一个 go test 命令可以执行你编写的显式测试代码。你只需要遵循一些指南,就可以无缝地将测试集成到你的项目和持续集成系统中。

9.1. 单元测试

单元测试 是一个测试包或程序中特定代码片段或代码集的函数。测试的任务是确定针对给定场景,所讨论的代码是否按预期工作。一个场景可能是一个正向测试,测试确保代码的正常执行不会产生错误。这可能是一个验证代码能否成功将工作记录插入数据库的测试。

其他单元测试可能测试负向路径场景,以确保代码不仅产生错误,而且产生预期的错误。这可能是一个对数据库进行查询但没有找到结果的测试,或者对数据库执行无效更新的测试。在两种情况下,测试都会验证错误被报告,并且提供了正确的错误上下文。最终,你编写的代码必须无论以何种方式调用或执行都是可预测的。

在 Go 中有几种方法可以编写单元测试。基本测试 测试单个参数集和结果的一块特定代码。表格测试 也测试特定的代码,但测试会针对多个参数和结果进行验证。还有方法可以模拟测试代码所需的外部资源,例如数据库或网络服务器。这有助于在测试期间模拟这些资源的存在,而无需它们实际可用。最后,当你构建自己的网络服务时,有方法可以测试传入服务的调用,而无需实际运行服务本身。

9.1.1. 基本单元测试

让我们从单元测试的一个例子开始。

列表 9.1. listing01_test.go
01 // Sample test to show how to write a basic unit test.
02 package listing01
03
04 import (
05     "net/http"
06     "testing"
07 )
08
09 const checkMark = "\u2713"
10 const ballotX = "\u2717"
11
12 // TestDownload validates the http Get function can download content.
13 func TestDownload(t *testing.T) {
14     url := "http://www.goinggo.net/feeds/posts/default?alt=rss"
15     statusCode := 200
16
17     t.Log("Given the need to test downloading content.")
18     {
19         t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
20             url, statusCode)
21         {

22             resp, err := http.Get(url)
23             if err != nil {
24                 t.Fatal("\t\tShould be able to make the Get call.",
25                     ballotX, err)
26             }
27             t.Log("\t\tShould be able to make the Get call.",
28                 checkMark)
29
30             defer resp.Body.Close()
31
32             if resp.StatusCode == statusCode {
33                 t.Logf("\t\tShould receive a \"%d\" status. %v",
34                     statusCode, checkMark)
35             } else {
36                 t.Errorf("\t\tShould receive a \"%d\" status. %v %v",
37                     statusCode, ballotX, resp.StatusCode)
38             }
39         }
40     }
41 }

列表 9.1 展示了一个测试 http 包中 Get 函数的单元测试。它测试 goinggo.net RSS 提要是否可以从网络上正确下载。当我们通过调用 go test -v 运行此测试时(-v 表示 提供详细输出),我们得到如图 9.1 所示的测试结果。图 9.1。

图 9.1. 基本单元测试的输出

在这个例子中,有很多小事情发生,使得这个测试能够正常工作并显示结果。这一切都始于测试文件的名字。如果你查看列表 9.1 的顶部,你会看到测试文件的名字是 listing01_test.go。Go 测试工具只会查看以 _test.go 结尾的文件。如果你忘记遵循这个约定,在包内部运行go test可能会报告没有测试文件。一旦测试工具找到一个测试文件,它就会寻找要运行的测试函数。

让我们更仔细地看看 listing01_test.go 测试文件中的代码。

列表 9.2. listing01_test.go: 第 01-10 行
01 // Sample test to show how to write a basic unit test.
02 package listing01
03

04 import (
05     "net/http"
06     "testing"
07 )
08
09 const checkMark = "\u2713"
10 const ballotX = "\u2717"

在列表 9.2 中,你可以看到第 06 行对testing包的导入。testing包提供了测试框架所需的从测试框架中报告任何测试输出和状态的支持。第 09 行和第 10 行提供了两个常量,这些常量包含在编写测试输出时将使用的勾号和 X 号字符。

接下来,让我们看看测试函数的声明。

列表 9.3. listing01_test.go: 第 12-13 行
12 // TestDownload validates the http Get function can download content.
13 func TestDownload(t *testing.T) {

测试函数的名字是TestDownload,你可以在列表 9.3 的第 13 行看到。一个测试函数必须是一个导出函数,以单词Test开头。不仅函数必须以单词Test开头,它还必须有一个接受testing.T类型指针且不返回任何值的签名。如果我们不遵循这些约定,测试框架将不会识别该函数为测试函数,并且所有工具都不会针对它工作。

testing.T 类型的指针非常重要。它提供了报告每个测试输出和状态机制。对于测试输出的格式化并没有一个统一的标准。我喜欢测试输出读起来清晰,这符合 Go 编写文档的惯例。对我来说,测试输出就是代码的文档。测试输出应该清晰地说明测试存在的原因、正在测试的内容以及测试的结果,使用易于阅读的完整句子。随着我们进一步审查代码,让我们看看我是如何实现这一点的。

列表 9.4. listing01_test.go: 第 14-18 行
14     url := "http://www.goinggo.net/feeds/posts/default?alt=rss"
15     statusCode := 200
16
17     t.Log("Given the need to test downloading content.")
18     {

你可以在列表 9.4 的第 14 行和第 15 行看到,声明并初始化了两个变量。这些变量包含我们想要测试的 URL 和从响应中期望得到的状态。在第 17 行,使用t.Log方法将消息写入测试输出。还有一个这个方法的格式化版本,称为t.Logf。如果在调用go test时没有使用详细选项(-v),除非测试失败,否则我们不会看到任何测试输出。

每个测试函数都应该通过解释测试的给定需求来说明测试存在的原因。对于这个测试,给定的需求是测试下载内容。在声明测试的给定需求之后,测试应该说明被测试的代码将在何时执行以及如何执行。

列表 9.5. listing01_test.go: 第 19-21 行
19         t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
20             url, statusCode)
21         {

你可以在 清单 9.5 的第 19 行看到 when 子句。它具体说明了测试的值。接下来,让我们看看使用这些值进行测试的代码。

清单 9.6. listing01_test.go: 行 22–30
22             resp, err := http.Get(url)
23             if err != nil {
24                 t.Fatal("\t\tShould be able to make the Get call.",
25                     ballotX, err)
26             }
27             t.Log("\t\tShould be able to make the Get call.",
28                 checkMark)
29
30             defer resp.Body.Close()

清单 9.6 中的代码使用了 http 包中的 Get 函数向 goinggo.net 网络服务器发起请求,以获取博客的 RSS 源文件。在 Get 调用返回后,会检查错误值以确定调用是否成功。在两种情况下,我们都声明测试的结果应该是怎样的。如果调用失败,我们会在测试输出中写入一个 X 以及错误信息。如果测试成功,我们会写入一个勾号。

如果 Get 调用失败,第 24 行的 t.Fatal 方法会让测试框架知道这个单元测试失败了。t.Fatal 方法不仅报告单元测试失败,还会将消息写入测试输出,然后停止执行这个特定的测试函数。如果有其他尚未运行的测试函数,它们将会被执行。这个方法的格式化版本被命名为 t.Fatalf

当我们需要报告测试失败但不希望停止特定测试函数的执行时,我们可以使用 t.Error 方法族。

清单 9.7. listing01_test.go: 行 32–41
32             if resp.StatusCode == statusCode {
33                 t.Logf("\t\tShould receive a \"%d\" status. %v",
34                     statusCode, checkMark)
35             } else {
36                 t.Errorf("\t\tShould receive a \"%d\" status. %v %v",
37                     statusCode, ballotX, resp.StatusCode)
38             }
39         }
40     }
41 }

在 清单 9.7 的第 32 行,我们将响应中的状态码与我们期望接收的状态码进行比较。同样,我们声明测试的结果应该是怎样的。如果状态码匹配,我们使用 t.Logf 方法;否则,我们使用 t.Errorf 方法。由于 t.Errorf 方法不会停止测试函数的执行,如果第 38 行之后还有更多测试要进行,单元测试将继续执行。如果一个测试函数没有调用 t.Fatalt.Error 函数,则测试将被视为通过。

如果你再次查看测试输出(见 图 9.2),你可以看到所有这些是如何结合在一起的。

图 9.2. 基本单元测试的输出

在 图 9.2 中,你可以看到测试的完整文档。考虑到需要下载内容,当检查 statusCode 的 URL(在图中被截断)时,我们应该能够发起调用并收到状态码 200。测试输出清晰、描述性强、信息丰富。我们知道运行了哪个单元测试,它通过了,以及它花费了多长时间:435 毫秒。

9.1.2. 表格测试

当测试可以接受一组不同参数并产生不同结果的代码时,应使用表格测试。表格测试类似于基本单元测试,但它维护一个不同值和结果的表格。不同的值会被迭代并运行通过测试代码。在每次迭代中,都会检查结果。这有助于利用单个测试函数来测试一组不同的值和条件。让我们看看一个示例表格测试。

列表 9.8. listing08_test.go
01 // Sample test to show how to write a basic unit table test.
02 package listing08
03
04 import (
05     "net/http"
06     "testing"
07 )
08
09 const checkMark = "\u2713"
10 const ballotX = "\u2717"
11
12 // TestDownload validates the http Get function can download

13 //  content and handles different status conditions properly.
14 func TestDownload(t *testing.T) {
15     var urls = []struct {
16         url        string
17         statusCode int
18     }{
19          {
20              "http://www.goinggo.net/feeds/posts/default?alt=rss",
21              http.StatusOK,
22          },
23          {
24              "http://rss.cnn.com/rss/cnn_topstbadurl.rss",
25              http.StatusNotFound,
26          },
27     }
28
29     t.Log("Given the need to test downloading different content.")
30     {
31         for _, u := range urls {
32             t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
33                 u.url, u.statusCode)
34             {
35                 resp, err := http.Get(u.url)
36                 if err != nil {
37                     t.Fatal("\t\tShould be able to Get the url.",
38                         ballotX, err)
39                 }
40                 t.Log("\t\tShould be able to Get the url",
41                     checkMark)
42
43                 defer resp.Body.Close()
44
45                 if resp.StatusCode == u.statusCode {
46                     t.Logf("\t\tShould have a \"%d\" status. %v",
47                         u.statusCode, checkMark)
48                 } else {
49                     t.Errorf("\t\tShould have a \"%d\" status %v %v",
50                         u.statusCode, ballotX, resp.StatusCode)
51                 }
52             }
53         }
54     }
55 }

在 列表 9.8 中,我们将基本的单元测试转换为表格测试。现在我们可以使用单个测试函数来测试不同的 URL 和状态码与 http.Get 函数。我们不需要为每个要测试的 URL 和状态码创建一个新的测试函数。让我们看看这些更改。

列表 9.9. listing08_test.go: 行 12–27
12 // TestDownload validates the http Get function can download
13 //  content and handles different status conditions properly.
14 func TestDownload(t *testing.T) {

15     var urls = []struct {
16         url        string
17         statusCode int
18     }{
19          {
20              "http://www.goinggo.net/feeds/posts/default?alt=rss",
21              http.StatusOK,
22          },
23          {
24              "http://rss.cnn.com/rss/cnn_topstbadurl.rss",
25              http.StatusNotFound,
26          },
27     }

在 列表 9.9 中,你可以看到相同的测试函数 TestDownload 接受一个 testing.T 类型的指针。但这个版本的 TestDownload 略有不同。在第 15 到 27 行,你可以看到表的实现。表的第一列是一个指向互联网上给定资源的 URL,第二列是我们请求资源时预期的状态。

目前,我们已经用两个值配置了表。第一个值是 goinggo.net URL,状态为 OK,第二个值是另一个 URL,状态为 NotFound。第二个 URL 被拼写错误,导致服务器返回 NotFound 错误。当我们运行这个测试时,我们得到如图 9.3 所示的测试输出。

图 9.3. 表格测试的输出

图 9.3 中的输出显示了如何遍历值表并使用它来进行测试。输出看起来与基本单元测试相同,除了这次我们测试了两个不同的 URL。再次,测试通过。

让我们看看我们为了使表测试工作所做出的更改。

列表 9.10. listing08_test.go: 行 29–34
29     t.Log("Given the need to test downloading different content.")
30     {
31         for _, u := range urls {
32             t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
33                 u.url, u.statusCode)
34             {

列表 9.10 中的第 31 行的 for range 循环允许测试遍历表,并为每个不同的 URL 运行测试代码。除了使用表值之外,原始代码与基本单元测试相同。

列表 9.11. listing08_test.go: 行 35–55
35                 resp, err := http.Get(u.url)
36                 if err != nil {
37                     t.Fatal("\t\tShould be able to Get the url.",
38                         ballotX, err)
39                 }
40                 t.Log("\t\tShould be able to Get the url",
41                     checkMark)
42
43                 defer resp.Body.Close()
44
45                 if resp.StatusCode == u.statusCode {
46                     t.Logf("\t\tShould have a \"%d\" status. %v",
47                         u.statusCode, checkMark)
48                 } else {
49                     t.Errorf("\t\tShould have a \"%d\" status %v %v",
50                         u.statusCode, ballotX, resp.StatusCode)
51                 }
52             }
53         }
54     }
55 }

列表 9.11 展示了在第 35 行,代码使用 u.url 字段作为调用 URL。在第 45 行,使用 u.statusCode 字段来比较响应的实际状态码。在未来,可以添加新的 URL 和状态码到表中,而测试的核心部分不需要改变。

9.1.3. 模拟调用

我们编写的单元测试很棒,但它们确实有几个缺陷。首先,测试要成功运行需要访问互联网。图 9.4 显示了在没有互联网连接的情况下再次运行基本单元测试的情况——测试失败。

图 9.4. 由于没有互联网连接而失败的测试

你不应该总是假设你运行测试的计算机可以访问互联网。此外,让测试依赖于你不在拥有或运营的服务器并不是一个好的做法。这两者都可能对任何自动化持续集成和部署产生重大影响。突然之间,你无法部署新的构建,因为你失去了对外部世界的访问。如果测试失败,你无法部署。

为了解决这个问题,标准库中有一个名为 httptest 的包,它将允许你模拟基于 HTTP 的网络调用。模拟是许多开发者在测试运行时无法访问资源时使用的技巧。httptest 包为你提供了模拟来自互联网上网络资源的请求和响应的能力。通过在我们的单元测试中模拟 http.Get 响应,我们可以解决我们在 图 9.4 中看到的问题。我们的测试将不再因为缺少互联网连接而失败。然而,测试可以验证我们的 http.Get 调用是否正常工作并处理预期的响应。让我们将基本单元测试修改为模拟对 goinggo.net RSS 源的调用。

列表 9.12. listing12_test.go: 第 01-41 行
01 // Sample test to show how to mock an HTTP GET call internally.
02 // Differs slightly from the book to show more.
03 package listing12
04
05 import (
06     "encoding/xml"
07     "fmt"
08     "net/http"
09     "net/http/httptest"
10     "testing"
11 )
12
13 const checkMark = "\u2713"
14 const ballotX = "\u2717"
15
16 // feed is mocking the XML document we except to receive.
17 var feed = `<?xml version="1.0" encoding="UTF-8"?>
18 <rss>
19 <channel>
20     <title>Going Go Programming</title>
21     <description>Golang : https://github.com/goinggo</description>
22     <link>http://www.goinggo.net/</link>
23     <item>
24         <pubDate>Sun, 15 Mar 2015 15:04:00 +0000</pubDate>
25         <title>Object Oriented Programming Mechanics</title>
26         <description>Go is an object oriented language.</description>
27         <link>http://www.goinggo.net/2015/03/object-oriented</link>
28     </item>
29 </channel>
30 </rss>`
31
32 // mockServer returns a pointer to a server to handle the get call.
33 func mockServer() *httptest.Server {

34     f := func(w http.ResponseWriter, r *http.Request) {
35         w.WriteHeader(200)
36         w.Header().Set("Content-Type", "application/xml")
37         fmt.Fprintln(w, feed)
38     }
39
40     return httptest.NewServer(http.HandlerFunc(f))
41 }

列表 9.12 展示了如何模拟对 goinggo.net 网站的调用以模拟下载 RSS 源。在第 17 行,声明了一个包级别的变量 feed,并用一个表示我们将从模拟服务器调用中接收的 RSS XML 文档的文本字符串初始化。这是实际 RSS 源文档的一个小片段,足以进行我们的测试。在第 32 行,我们声明了一个名为 mockServer 的函数,该函数利用 httptest 包内部的支持来模拟对互联网上真实服务器的调用。

列表 9.13. listing12_test.go: 第 32-40 行
32 func mockServer() *httptest.Server {
33     f := func(w http.ResponseWriter, r *http.Request) {
34         w.WriteHeader(200)
35         w.Header().Set("Content-Type", "application/xml")
36         fmt.Fprintln(w, feed)
37     }
38
39     return httptest.NewServer(http.HandlerFunc(f))
40 }

在 列表 9.13 中的 mockServer 函数被声明为返回 httptest.Server 类型的指针。httptest.Server 值是使所有这些工作起来的关键。代码从声明一个与 http.HandlerFunc 函数类型具有相同签名的匿名函数开始。

列表 9.14. golang.org/pkg/net/http/#HandlerFunc
type HandlerFunc func(ResponseWriter, *Request)

The HandlerFunc type is an adapter to allow the use of ordinary
functions as HTTP handlers. If f is a function with the appropriate
signature, HandlerFunc(f) is a Handler object that calls f

这使得匿名函数成为一个处理函数。一旦声明了处理函数,然后在第 39 行,它被用作 httptest.NewServer 函数调用的参数来创建我们的模拟服务器。然后,模拟服务器通过第 39 行的指针返回。

我们将能够使用这个模拟服务器与我们的 http.Get 调用来模拟对 goinggo.net 网服务器的访问。当执行 http.Get 调用,处理函数实际上被执行并用于模拟请求和响应。在第 34 行,处理函数首先设置状态码;然后,在第 35 行,设置内容类型;最后,在第 36 行,返回代表响应的名为 feed 的 XML 字符串作为响应体。

现在,让我们看看如何将模拟服务器集成到基本单元测试中,以及 http.Get 调用如何能够使用它。

列表 9.15. listing12_test.go: 第 43-74 行
43 // TestDownload validates the http Get function can download content
44 // and the content can be unmarshaled and clean.
45 func TestDownload(t *testing.T) {
46     statusCode := http.StatusOK
47
48     server := mockServer()
49     defer server.Close()
50
51     t.Log("Given the need to test downloading content.")
52     {
53         t.Logf("\tWhen checking \"%s\" for status code \"%d\"",
54             server.URL, statusCode)
55         {
56             resp, err := http.Get(server.URL)
57             if err != nil {
58                 t.Fatal("\t\tShould be able to make the Get call.",
59                     ballotX, err)
60             }
61             t.Log("\t\tShould be able to make the Get call.",
62                 checkMark)
63
64             defer resp.Body.Close()
65
66             if resp.StatusCode != statusCode {
67                 t.Fatalf("\t\tShould receive a \"%d\" status. %v %v",
68                     statusCode, ballotX, resp.StatusCode)
69             }
70             t.Logf("\t\tShould receive a \"%d\" status. %v",
71                statusCode, checkMark)
72         }
73     }
74 }

在 列表 9.15 中,你再次看到了 TestDownload 函数,但这次它使用的是模拟服务器。在第 48 和 49 行,调用了 mockServer 函数,并且将 Close 方法的调用推迟到测试函数返回时。之后,测试代码看起来与基本单元测试相同,除了有一点不同。

列表 9.16. listing12_test.go: 第 56 行
56             resp, err := http.Get(server.URL)

这次,调用要使用的 URL 由httptest.Server值提供。当我们使用模拟服务器提供的 URL 时,http.Get调用按预期运行。http.Get调用不知道它没有通过互联网进行调用。调用被发起,我们的处理函数在下面执行,结果返回我们的 RSS XML 文档和一个状态为http.StatusOK的响应。

当我们在没有互联网连接的情况下运行测试时,我们看到测试运行并通过,如图 9.5 所示。这张图显示了测试再次通过的情况。如果你查看用于发起调用的 URL,你可以看到它使用的是 localhost 地址和端口号 52065。每次我们运行测试时,这个端口号都会改变。http包与httptest包以及我们的模拟服务器一起知道将这个 URL 路由到我们的处理函数。现在,我们可以测试对 goinggo.net RSS 源头的调用,而无需实际击中服务器。

图 9.5. 无需互联网连接的成功测试

9.1.4. 测试端点

如果你正在构建一个网络 API,你将希望测试所有端点,而无需启动网络服务。httptest包提供了一种进行此类测试的设施。让我们看看一个实现单个端点的示例网络服务,然后你可以看到如何编写一个模拟实际调用的单元测试。

列表 9.17. listing17.go
01 // This sample code implement a simple web service.
02 package main
03
04 import (
05     "log"
06     "net/http"
07
08     "github.com/goinaction/code/chapter9/listing17/handlers"
09 )
10
11 // main is the entry point for the application.
12 func main() {
13     handlers.Routes()
14
15     log.Println("listener : Started : Listening on :4000")
16     http.ListenAndServe(":4000", nil)
17 }

列表 9.17 显示了网络服务入口点的代码文件。在main函数的第 13 行,代码调用了handlers包内部的Routes函数。该函数设置了网络服务所托管的不同端点的路由。在第 15 行和第 16 行,main函数显示了服务正在监听的端口,并启动了网络服务,等待请求。

现在,让我们看看handlers包的代码。

列表 9.18. handlers/handlers.go
01 // Package handlers provides the endpoints for the web service.
02 package handlers
03
04 import (
05     "encoding/json"
06     "net/http"
07 )
08
09 // Routes sets the routes for the web service.
10 func Routes() {
11     http.HandleFunc("/sendjson", SendJSON)
12 }
13
14 // SendJSON returns a simple JSON document.
15 func SendJSON(rw http.ResponseWriter, r *http.Request) {
16     u := struct {
17         Name  string
18         Email string
19     }{
20         Name:  "Bill",
21         Email: "bill@ardanstudios.com",
22     }
23
24     rw.Header().Set("Content-Type", "application/json")
25     rw.WriteHeader(200)
26     json.NewEncoder(rw).Encode(&u)
27 }

列表 9.18 中handlers包的代码提供了处理函数的实现,并为网络服务设置了路由。在第 10 行,你可以看到Routes函数,它使用http包内部的默认http.ServeMux来配置 URL 和相应处理代码之间的路由。在第 11 行,我们将/sendjson端点绑定到SendJSON函数。

从第 15 行开始,我们有SendJSON函数的实现。该函数与你在列表 9.14 中看到的http.HandlerFunc函数类型具有相同的签名。在第 16 行,声明了一个匿名结构体类型,并创建了一个名为u的变量,并赋予了一些值。在第 24 行和第 25 行设置了响应的内容类型和状态码。最后,在第 26 行,将u值编码成 JSON 文档并发送给客户端。

如果我们构建 Web 服务并启动服务器,我们会看到提供的 JSON 文档,如图 9.6 和 9.7 所示。

图 9.6. 运行 Web 服务

图片

图 9.7. 提供 JSON 文档的 Web 服务

图片

现在我们有一个具有端点的功能齐全的 Web 服务,我们可以编写一个单元测试来测试该端点。

列表 9.19. handlers/handlers_test.go
01 // Sample test to show how to test the execution of an
02 // internal endpoint.
03 package handlers_test
04
05 import (
06     "encoding/json"
07     "net/http"
08     "net/http/httptest"
09     "testing"
10
11     "github.com/goinaction/code/chapter9/listing17/handlers"
12 )
13
14 const checkMark = "\u2713"
15 const ballotX = "\u2717"
16
17 func init() {
18     handlers.Routes()
19 }
20
21 // TestSendJSON testing the sendjson internal endpoint.
22 func TestSendJSON(t *testing.T) {
23     t.Log("Given the need to test the SendJSON endpoint.")
24     {
25         req, err := http.NewRequest("GET", "/sendjson", nil)
26         if err != nil {
27             t.Fatal("\tShould be able to create a request.",
28                 ballotX, err)
29         }
30         t.Log("\tShould be able to create a request.",
31             checkMark)
32
33         rw := httptest.NewRecorder()
34         http.DefaultServeMux.ServeHTTP(rw, req)
35
36         if rw.Code != 200 {
37             t.Fatal("\tShould receive \"200\"", ballotX, rw.Code)
38         }
39         t.Log("\tShould receive \"200\"", checkMark)
40

41         u := struct {
42             Name  string
43             Email string
44         }{}
45
46         if err := json.NewDecoder(rw.Body).Decode(&u); err != nil {
47             t.Fatal("\tShould decode the response.", ballotX)
48         }
49         t.Log("\tShould decode the response.", checkMark)
50
51         if u.Name == "Bill" {
52           t.Log("\tShould have a Name.", checkMark)
53         } else {
54           t.Error("\tShould have a Name.", ballotX, u.Name)
55         }
56
57         if u.Email == "bill@ardanstudios.com" {
58             t.Log("\tShould have an Email.", checkMark)
59         } else {
60             t.Error("\tShould have an Email.", ballotX, u.Email)
61         }
62     }
63 }

列表 9.19 展示了 /sendjson 端点的单元测试。在第 03 行,你可以看到包名与其他测试不同。

列表 9.20. handlers/handlers_test.go: 行 01–03
01 // Sample test to show how to test the execution of an
02 // internal endpoint.
03 package handlers_test

这次,正如你在列表 9.20 中可以看到,包名也以 _test 结尾。当包名以这种方式结束时,测试代码只能访问导出的标识符。即使测试代码文件与被测试的代码在同一文件夹中,这也是正确的。

就像直接运行服务一样,路由需要初始化。

列表 9.21. handlers/handlers_test.go: 行 17–19
17 func init() {
18     handlers.Routes()
19 }

在列表 9.21 的第 17 行,声明了一个 init 函数来初始化路由。如果在运行单元测试之前没有初始化路由,那么测试将因 http.StatusNotFound 错误而失败。现在我们可以查看 /sendjson 端点的单元测试。

列表 9.22. handlers/handlers_test.go: 行 21–34
21 // TestSendJSON testing the sendjson internal endpoint.
22 func TestSendJSON(t *testing.T) {
23     t.Log("Given the need to test the SendJSON endpoint.")
24     {
25         req, err := http.NewRequest("GET", "/sendjson", nil)
26         if err != nil {
27             t.Fatal("\tShould be able to create a request.",
28                 ballotX, err)
29         }
30         t.Log("\tShould be able to create a request.",
31             checkMark)
32
33         rw := httptest.NewRecorder()
34         http.DefaultServeMux.ServeHTTP(rw, req)

列表 9.22 展示了 TestSendJSON 测试函数的声明。测试开始时记录测试的给定需求,然后在第 25 行创建了一个 http.Request 值。请求值被配置为对 /sendjson 端点的 GET 调用。由于这是一个 GET 调用,所以将 nil 作为第三个参数传递给请求数据。

然后,在第 33 行,调用 httptest.NewRecorder 函数创建了一个 http.ResponseRecorder 值。有了 http.Requesthttp.ResponseRecorder 值,在第 34 行对默认服务器多路复用器(mux)的 ServerHTTP 方法进行了调用。调用此方法模拟了一个对 /sendjson 端点的请求,就像它是由外部客户端发起的一样。

一旦 ServeHTTP 方法调用完成,http.ResponseRecorder 值将包含来自我们的 SendJSON 函数处理器的响应。现在我们可以测试响应。

列表 9.23. handlers/handlers_test.go: 行 36–39
36         if rw.Code != 200 {
37             t.Fatal("\tShould receive \"200\"", ballotX, rw.Code)
38         }
39         t.Log("\tShould receive \"200\"", checkMark)

首先,在第 36 行检查响应的状态。对于任何成功的端点调用,期望状态为 200。如果状态是 200,则将 JSON 响应解码到 Go 值中。

列表 9.24. handlers/handlers_test.go: 行 41–49
41         u := struct {
42             Name  string
43             Email string
44         }{}
45
46         if err := json.NewDecoder(rw.Body).Decode(&u); err != nil {
47             t.Fatal("\tShould decode the response.", ballotX)
48         }
49         t.Log("\tShould decode the response.", checkMark)

在列表 9.24 的第 41 行,声明了一个匿名结构体类型,并创建了一个名为 u 的变量,并将其初始化为其零值。在第 46 行,使用 json 包将响应中的 JSON 文档解码到 u 变量中。如果解码失败,单元测试将结束;否则,我们验证解码的值。

列表 9.25. handlers/handlers_test.go: 行 51–63
51         if u.Name == "Bill" {
52           t.Log("\tShould have a Name.", checkMark)
53         } else {
54           t.Error("\tShould have a Name.", ballotX, u.Name)
55         }
56
57         if u.Email == "bill@ardanstudios.com" {
58             t.Log("\tShould have an Email.", checkMark)
59         } else {
60             t.Error("\tShould have an Email.", ballotX, u.Email)
61         }
62     }
63 }

列表 9.25 展示了对我们期望接收的每个值的检查。在第 51 行,我们检查 Name 字段的值为 "Bill",然后在第 57 行检查 Email 字段的值为 "bill@ardanstudios.com"。如果这些值匹配,则单元测试通过;否则,单元测试失败。这两个检查使用 Error 方法来报告失败,因此检查了所有字段。

9.2. 示例

Go 非常注重编写代码的正确文档。godoc 工具就是为了直接从你的代码中生成文档而构建的。在第三章中,我们讨论了使用 godoc 工具生成包文档的使用。godoc 工具的另一个特性是示例代码。示例代码为测试和文档增加了另一个维度。

如果你使用浏览器导航到 Go 的 json 包文档,你会看到类似图 9.8 的内容。

图 9.8. json 包的示例列表

json 包有五个示例,它们显示在包的 Go 文档中。如果你选择第一个示例,你会看到一个示例代码的视图,如图 9.9 所示。

图 9.9. Go 文档中 Decoder 示例的视图

你可以创建自己的示例,并让它们出现在你包的 Go 文档中。让我们看看我们之前示例中的 SendJSON 函数的示例。

列表 9.26. handlers_example_test.go
01 // Sample test to show how to write a basic example.
02 package handlers_test
03
04 import (
05     "encoding/json"
06     "fmt"
07     "log"

08     "net/http"
09     "net/http/httptest"
10 )
11
12 // ExampleSendJSON provides a basic example.
13 func ExampleSendJSON() {
14     r, _ := http.NewRequest("GET", "/sendjson", nil)
15     rw := httptest.NewRecorder()
16     http.DefaultServeMux.ServeHTTP(rw, r)
17
18     var u struct {
19         Name  string
20         Email string
21     }
22
23     if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
24         log.Println("ERROR:", err)
25     }
26
27     // Use fmt to write to stdout to check the output.
28     fmt.Println(u)
29     // Output:
30     // {Bill bill@ardanstudios.com}
31 }

示例基于现有的函数或方法。我们不需要以单词 Test 开头函数,而需要使用单词 Example。在第 13 行的列表 9.26 中,示例的名称是 ExampleSendJSON

在示例中,你需要遵循一条规则。一个示例总是基于一个现有的已导出函数或方法。我们的示例测试是针对 handlers 包中的已导出函数 SendJSON。如果你不使用现有函数或方法的名称,测试将不会显示在包的 Go 文档中。

你为示例编写的代码是为了向某人展示如何使用特定的函数或方法。为了确定测试是否成功或失败,测试将比较函数的最终输出与示例函数底部列出的输出。

列表 9.27. handlers_example_test.go: 行 27–31
27     // Use fmt to write to stdout to check the output.
28     fmt.Println(u)
29     // Output:
30     // {Bill bill@ardanstudios.com}
31 }

在第 28 行的列表 9.27 中,代码使用 fmt.Printlnu 的值写入标准输出。u 的值是在函数中较早调用 /sendjson 端点时初始化的。在第 29 行我们有一个带有单词 Output: 的注释。

Output: 标记用于记录测试函数运行后预期的输出。测试框架知道如何将最终的输出与 stdout 中的输出注释进行比较。如果一切匹配,则测试通过,并且你有一个在包的 Go 文档内部工作的示例。如果输出不匹配,则测试失败。

如果你启动一个本地的 godoc 服务器 (godoc -http=":3000") 并导航到 handlers 包,你可以看到所有这些内容都整合在一起,如 图 9.10 所示。

图 9.10. godoc 视图中的 handlers

你可以在 图 9.10 中看到,handlers 包的文档显示了 SendJSON 函数的示例。如果你选择 SendJSON 链接,文档将显示代码,如 图 9.11 所示。

图 9.11. godoc 中的示例全貌

图 9.11 展示了示例的完整文档,包括代码和预期输出。由于这也是一个测试,你可以使用 go test 工具运行示例函数,如 图 9.12 所示。

图 9.12. 运行示例

运行测试后,你会看到测试通过。这次运行测试时,使用 -run 选项指定了特定的函数 ExampleSendJSON-run 选项接受任何正则表达式来过滤要运行的测试函数。它适用于单元测试和示例函数。当示例失败时,它看起来像 图 9.13。

图 9.13. 运行一个失败的示例

当一个示例失败时,go test 会显示产生的输出和预期的结果。

9.3. 基准测试

基准测试是测试代码性能的一种方法。当你想要测试同一问题的不同解决方案的性能并查看哪个解决方案表现更好时,它很有用。它还可以用来识别可能对应用程序性能至关重要的特定代码片段的 CPU 或内存问题。许多开发者使用基准测试来测试不同的并发模式,或帮助配置工作池,以确保它们配置得当以获得最佳吞吐量。

让我们看看一组基准函数,这些函数揭示了将整数值转换为字符串的最快方式。在标准库中,有三种不同的方法可以将整数值转换为字符串。

列表 9.28. listing28_test.go: 行 01–10
01 // Sample benchmarks to test which function is better for converting
02 // an integer into a string. First using the fmt.Sprintf function,
03 // then the strconv.FormatInt function and then strconv.Itoa.

04 package listing28_test
05
06 import (
07     "fmt"
08     "strconv"
09     "testing"
10 )

列表 9.28 展示了列表 28_test.go 基准测试的初始代码。与单元测试文件一样,文件名必须以 _test.go 结尾。还必须导入 testing 包。接下来,让我们看看其中一个基准函数。

列表 9.29. listing28_test.go: 行 12–22
12 // BenchmarkSprintf provides performance numbers for the
13 // fmt.Sprintf function.
14 func BenchmarkSprintf(b *testing.B) {
15     number := 10
16
17     b.ResetTimer()
18
19     for i := 0; i < b.N; i++ {
20         fmt.Sprintf("%d", number)
21     }
22 }

在 列表 9.29 的第 14 行,你可以看到第一个基准测试,命名为 BenchmarkSprintf。基准测试函数以单词 Benchmark 开头,并且只接受一个类型为 testing.B 的指针作为其唯一参数。为了使基准测试框架能够计算性能,它必须在一个时间段内反复运行代码。这就是 for 循环的作用所在。

列表 9.30. listing28_test.go: 行 19–22
19     for i := 0; i < b.N; i++ {
20         fmt.Sprintf("%d", number)
21     }
22 }

列表 9.30 第 19 行的 for 循环显示了如何使用 b.N 值。在第 20 行,我们有对 fmt 包中 Sprintf 函数的调用。这是我们正在基准测试的函数,用于将整数值转换为字符串。

默认情况下,基准测试框架会反复调用基准测试函数,至少持续一秒。每次框架调用基准测试函数时,它都会增加 b.N 的值。在第一次调用时,b.N 的值将是 1。将所有要基准测试的代码放置在循环内并使用 b.N 值是非常重要的。如果不这样做,结果是不可信的。

如果我们只想运行基准测试函数,我们需要使用 -bench 选项。

列表 9.31. 运行基准测试
go test -v -run="none" -bench="BenchmarkSprintf"

在我们的 go test 调用中,我们指定了 -run 选项,传递字符串 "none" 以确保在运行指定的基准测试函数之前不运行任何单元测试。这两个选项都接受一个正则表达式来过滤要运行的测试。由于没有单元测试函数的名称中包含 nonenone 会消除任何单元测试的运行。当我们发出此命令时,我们得到 图 9.14 中所示的输出。

图 9.14. 运行单个基准测试

输出首先指定没有要运行的测试,然后继续运行 BenchmarkSprintf 基准测试。在 PASS 一词之后,你可以看到运行基准测试函数的结果。第一个数字 5000000 表示循环内代码执行的次数。在这种情况下,那是五百万次。下一个数字表示基于每操作纳秒数的代码性能,因此在这种情况下,使用 Sprintf 函数平均每次调用需要 258 纳秒。

运行基准测试的最终输出显示为 ok,表示基准测试正确完成。然后显示执行代码的文件名,最后显示基准测试运行的总时间。基准测试的默认最小运行时间为 1 秒。你可以看到框架仍然运行了大约一秒半的测试。如果你想使测试运行更长的时间,可以使用另一个选项 -benchtime。让我们再次运行测试,使用基准时间为三秒(见 图 9.15)。

图 9.15. 使用 -benchtime 选项运行单个基准测试

这次,Sprintf 函数运行了 2000 万次,持续了 5.275 秒。函数的性能没有太大变化。这次的性能是每操作 256 纳秒。有时通过增加基准测试时间,你可以得到更准确的性能读数。对于大多数测试,增加基准测试时间超过三秒通常不会为准确读数提供任何差异。但每个基准测试都是不同的。

让我们看看其他两个基准测试函数,然后一起运行所有三个基准测试,看看将整数值转换为字符串最快的方法是什么。

列表 9.32. listing28_test.go: 行 24–46
24 // BenchmarkFormat provides performance numbers for the
25 // strconv.FormatInt function.
26 func BenchmarkFormat(b *testing.B) {
27     number := int64(10)
28
29     b.ResetTimer()
30
31     for i := 0; i < b.N; i++ {
32         strconv.FormatInt(number, 10)
33     }
34 }
35
36 // BenchmarkItoa provides performance numbers for the
37 // strconv.Itoa function.
38 func BenchmarkItoa(b *testing.B) {
39     number := 10
40
41     b.ResetTimer()
42
43     for i := 0; i < b.N; i++ {
44         strconv.Itoa(number)
45     }
46 }

列表 9.32 显示了其他两个基准测试函数。BenchmarkFormat 函数基准测试了 strconv 包中 FormatInt 函数的使用。BenchmarkItoa 函数基准测试了同一 strconv 包中 Itoa 函数的使用。你可以看到这两个其他基准测试函数与 BenchmarkSprintf 函数中相同的模式。调用在 for 循环内部使用 b.N 来控制每个调用的迭代次数。

我们跳过的一件事是 b.ResetTimer 的调用,它在所有三个基准测试函数中使用。当需要在代码开始执行循环之前进行初始化时,此方法很有用。为了获得最准确的基准测试时间,你可以使用此方法。

当我们运行所有基准测试函数至少三秒钟时,我们得到图 9.16 所示的结果。

图 9.16. 运行所有三个基准测试

图 9.16 的替代文本

结果显示,BenchmarkFormat 测试函数以每操作 45.9 纳秒的速度运行得最快。BenchmarkItoa 以每操作 49.4 纳秒的接近速度排在第二位。这两个基准测试都比使用 Sprintf 函数快得多。

在运行基准测试时,你可以使用的另一个优秀选项是 -benchmem 选项。它将提供有关给定测试中分配数量和每个分配的字节数的信息。让我们使用这个选项与基准测试一起使用(见图 9.17)。

图 9.17. 使用 -benchmem 选项运行基准测试

图 9.17 的替代文本

这次,你看到两个新的值:一个是 B/op 的值,另一个是 allocs/op 的值。allocs/op 的值表示每个操作中堆分配的数量。你可以看到 Sprintf 函数在每个操作中分配两个值到堆上,而其他两个函数每个操作只分配一个值。B/op 的值表示每个操作的字节数。你可以看到来自 Sprintf 函数的这两个分配导致每个操作分配了 16 字节的内存。其他两个函数每个操作只分配了 2 字节。

在运行测试和基准测试时,你可以使用许多不同的选项。我建议你探索所有这些选项,并在编写你的包和项目时充分利用这个测试框架。社区期望包作者在发布供社区公开使用的包时提供全面的测试。

9.4. 概述

  • 测试是语言内置的,Go 提供了你需要的所有工具。

  • go test 工具用于运行测试。

  • 测试文件总是以 _test.go 文件名结尾。

  • 表格测试是利用单个测试函数测试多个值的一种很好的方式。

  • 示例既是包的测试也是文档。

  • 基准测试提供了一种机制来揭示代码的性能。

posted @ 2025-11-14 20:40  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报