《Go in Action》读书笔记

GO IN ACTION

第 1 章:关于 Go 语言的介绍

本章主要内容

  • 用 Go 语言解决现代计算难题
  • 使用 Go 语言工具

本章小结

  • Go 语言是现代的、快速的,带有一个强大的标准库
  • Go 语言内置对并发的支持。
  • Go 语言使用接口作为代码复用的基础模块。

第 2 章:快速开始一个 Go 程序

本章主要内容

  • 学习如何写一个复杂的 Go 程序
  • 声明类型、变量、函数和方法
  • 启动并同步操作 goroutine
  • 使用接口写通用的代码
  • 处理程序逻辑和错误
  • 如果需要声明初始值为零值的变量,应该使用 var 关键字声明变量;如果提供确切的非零值初始化变量或者使用函数返回值创建变量,应该使用简化变量声明运算符。
  • 当代码导入了一个包时,程序可以直接访问这个包中任意一个公开的标识符。这些标识符以大写字母开头。以小写字母开头的标识符是不公开的,不能被其他包中的代码直接访问。
  • 在 Go 语言中,所有变量都被初始化为其零值。对于数值类型,零值是 0;对于字符串类型,零值是空字符串;对于布尔类型,零值是 false;对于指针,零值是 nil。对于引用类型来说,所引用的底层数据结构会被初始化为对应的零值。
  • 不仅仅是 Go 语言,很多语言都允许一个函数返回多个值。一般会像 RetrieveFeeds 函数这样声明一个函数返回一个值和一个错误值。如果发生了错误,永远不要使用该函数返回的另一个值。 这时必须忽略另一个值,否则程序会产生更多的错误,甚至崩溃。
  • 使用 sync 包的 WaitGroup 跟踪所有启动的 goroutine。非常推荐使用 WaitGroup 来跟踪 goroutine 的工作是否完成。WaitGroup 是一个计数信号量,我们可以利用它来统计所有的 goroutine 是不是都完成了工作。
  • Go 语言中,所有的变量都以值的方式传递。因为指针变量的值是所指向的内存地址,在函数间传递指针变量,是在传递这个地址值,所以依旧被看作以值的方式在传递。
  • 因为有了闭包,函数可以直接访问到那些没有作为参数传入的变量。 匿名函数并没有拿到这些变量的副本,而是直接访问外层函数作用域中声明的这些变量本身。因为 matcher 和 feed 变量每次调用时值不相同,所以并没有使用闭包的方式访问这两个变量。
  • goroutine 里面调用了 WaitGroup 的 Wait 方法。这个方法会导致 goroutine 阻塞,直到 WaitGroup 内部的计数到达 0。
  • 因为 Go 编译器可以根据赋值运算符右边的值来推导类型,声明常量的时候不需要指定类型。
  • 常量的名称使用小写字母开头,表示它只能在当前包内的代码里直接访问,而不暴露到包外面。
  • 每个字段的声明最后 ` 引号里的部分被称作标记(tag),每个标记将结构类型里字段对应到 JSON 文档里指定名字的字段。
  • 使用关键字 defer 来安排调用 Close 方法,可以保证这个函数一定会被调用。哪怕函数意外崩溃终止,也能保证关键字 defer 安排调用的函数会被执行。关键字 defer 可以缩短打开文件和关闭文件之间间隔的代码行数,有助提高代码可读性,减少错误。
  • Decode 方法接受一个类型为 interface{}的值作为参数。这个类型在 Go 语言里很特殊,一般会配合 reflect 包里提供的反射功能一起使用。
  • 如果接口类型只包含一个方法,那么这个类型的名字以 er 结尾。如果接口类型内部声明了多个方法,其名字需要与其行为关联。
  • 如果要让一个用户定义的类型实现一个接口,这个用户定义的类型要实现接口类型里声明的所有方法。
  • 空结构在创建实例时,不会分配任何内存。这种结构很适合创建没有任何状态的类型。
  • 如果声明函数的时候带有接收者,则意味着声明了一个方法。这个方法会和指定的接收者的类型绑在一起。Search 方法与 defaultMatcher 类型的值绑在一起。这意味着我们可以使用 defaultMatcher 类型的值或者指向这个类型值的指针来调用 Search 方法。
  • 因为大部分方法在被调用后都需要维护接收者的值的状态,所以,一个最佳实践是,将方法的接收者声明为指针。
  • 使用指针作为接收者声明的方法,只能在接口类型的值是一个指针的时候被调用。使用值作为接收者声明的方法,在接口类型的值为值或者指针时,都可以被调用。
  • 程序里所有的 init 方法都会在 main 函数启动前被调用。
  • 我们使用下划线标识符作为别名导入 matchers 包,完成了这个调用。这种方法可以让编译器在导入未被引用的包时不报错,而且依旧会定位到包内的 init 函数。
  • append 这个内置函数会根据切片需要,决定是否要增加切片的长度和容量。这个函数的第一个参数是希望追加到的切片,第二个参数是要追加的值。
  • 使用指针可以在函数间或者 goroutine 间共享数据。

本章小结

  • 每个代码文件都属于一个包,而包名应该与代码文件所在的文件夹同名。
  • Go 语言提供了多种声明和初始化变量的方式。如果变量的值没有显式初始化,编译器会将变量初始化为零值。
  • 使用指针可以在函数间或者 goroutine 间共享数据。
  • 通过启动 goroutine 和使用通道完成并发和同步。
  • Go 语言提供了内置函数来支持 Go 语言内部的数据结构。
  • 标准库包含很多包,能做很多很有用的事情。
  • 使用 Go 接口可以编写通用的代码和框架。

第 3 章:打包和工具链

本章主要内容

  • 如何组织 Go 代码
  • 使用 Go 语言自带的相关命令
  • 使用其他开发者提供的工具
  • 与其他开发者合作
  • 所有 Go 语言的程序都会组织成若干组文件,每组文件被称为一个包。这样每个包的代码都可以作为很小的复用单元,被其他项目引用。
  • 所有的 .go 文件,除了空行和注释,都应该在第一行声明自己所属的包。每个包都在一个单独的目录里。
  • 同一个目录下的所有 .go 文件必须声明同一个包名。
  • 给包命名的惯例是使用包所在目录的名字。
  • 所有用 Go 语言编译的可执行程序都必须有一个名叫 main 的包。
  • 当编译器发现某个包的名字为 main 时,它一定也会发现名为 main() 的函数,否则不会创建可执行文件。
  • 程序编译时,会使用声明 main 包的代码所在的目录的目录名作为二进制可执行文件的文件名。
  • 记住,在 Go 语言里,命令是指任何可执行程序。包更常用来指语义上可导入的功能单元。
  • go doc fmt
  • go doc strings
  • GOPATH 指定的这些目录就是开发者的个人工作空间。
  • 编译器会首先查找 Go 的安装目录,然后才会按顺序查找 GOPATH 变量里列出的目录。

远程导入

  • 目前的大势所趋是,使用分布式版本控制系统(Distributed Version Control Systems DVCS)来分享代码,如 GitHub、Launchpad 还有 Bitbucket。Go 语言的工具链本身就支持从这些网站及类似网站获取源代码。
  • go get 将获取任意指定的 URL 的包,或者一个已经导入的包所依赖的其他包。由于 go get 的这种递归特性,这个命令会扫描某个包的源码树,获取能找到的所有依赖包。

命名导入

  • 命名导入是指,在 import 语句给出的包路径的左侧定义一个名字,将导入的包命名为新名字。
  • 当你导入了一个不在代码里使用的包时,Go 编译器会编译失败,并输出一个错误。Go 开发团队认为,这个特性可以防止导入了未被使用的包,避免代码变得臃肿。
  • 有时,用户可能需要导入一个包,但是不需要引用这个包的标识符。在这种情况,可以使用空白标识符_来重命名这个导入。
  • 每个包可以包含任意多个 init 函数,这些函数都会在程序执行开始的时候被调用。

Go 常用命令

  • build 和 clean 命令会执行编译和清理的工作。
  • 在不包含文件名时,go 工具会默认使用当前目录来编译。
  • 因为构建包是很常用的动作,所以也可以直接指定包。go build github.com/goinaction/code/chapter3/wordcount
  • 也可以在指定包的时候使用通配符。...表示匹配所有的字符串。
  • 除了指定包,大部分 Go 命令使用短路径作为参数。
  • go run 命令会先构建 wordcount.go 里包含的程序,然后执行构建后的程序。
  • vet 命令会帮开发人员检测代码的常见错误。
  • fmt 命令会自动格式化开发人员指定的源代码文件并保存。
  • 如果开发人员使用命令行提示符工作,可以在终端上直接使用 go doc 命令来打印文档。go doc tar
  • 如果开发人员认为一个浏览器界面会更有效率,可以使用 godoc 程序来启动一个 Web 服务器,通过点击的方式来查看 Go 语言的包的文档。godoc -http=:6060
  • 如果想给包写一段文字量比较大的文档,可以在工程里包含一个叫作 doc.go 的文件,使用同样的包名,并把包的介绍使用注释加在包名声明之前。

以分享为目的创建代码库

  • 包应该在代码库的根目录中。在创建想要分享的代码库的时候,包名应该就是代码库的名字,而且包的源代码应该位于代码库目录结构的根目录。
  • 包可以非常小。
  • 对代码执行 go fmt。
  • 给代码写文档。

现在最流行的依赖管理工具

  • godep
  • vender
  • gopkg.in

第三方依赖

  • 像 godep 和 vender 这种社区工具已经使用第三方(verdoring)导入路径重写这种特性解决了依赖问题。其思想是把所有的依赖包复制到工程代码库中的目录里,然后使用工程内部的依赖包所在目录来重写所有的导入路径。
  • gb 基于工程将 Go 工具链工作空间的元信息做替换。这种依赖管理的方法不需要重写工程内代码的导入路径。而且导入路径依旧通过 go get 和 GOPATH 工作空间来管理。
  • gb 工程会区分开发人员写的代码和开发人员需要依赖的代码。开发人员的代码所依赖的代码被称作第三方代码(vendored code)。
  • gb 一个最好的特点是,不需要重写导入路径。
  • gb 工程与 Go 官方工具链(包括 go get)并不兼容。因为 gb 不需要设置 GOPATH,而 Go 工具链无法理解 gb 工程的目录结构,所以无法用 Go 工具链构建、测试或者获取代码。构建(如代码清单 3-16 所示)和测试 gb 工程需要先进入$PROJECT 目录,并使用 gb 工具。

本章小结

  • 在 Go 语言中包是组织代码的基本单位。
  • 环境变量 GOPATH 决定了 Go 源代码在磁盘上被保存、编译和安装的位置。
  • 可以为每个工程设置不同的 GOPATH,以保持源代码和依赖的隔离。
  • go 工具是在命令行上工作的最好工具。
  • 开发人员可以使用 go get 来获取别人的包并将其安装到自己的 GOPATH 指定的目录。
  • 想要为别人创建包很简单,只要把源代码放到公用代码库,并遵守一些简单规则就可以了。
  • Go 语言在设计时将分享代码作为语言的核心特性和驱动力。
  • 推荐使用依赖管理工具来管理依赖。
  • 有很多社区开发的依赖管理工具,如 godep、vender 和 gb。

第 4 章:数组、切片和映射

本章主要内容

  • 数组的内部实现和基础功能
  • 使用切片管理数据集合
  • 使用映射管理键值对
  • Go 语言有 3 种数据结构可以让用户管理集合数据:数组、切片和映射。

数组

  • 在 Go 语言里,数组是一个长度固定的数据类型,用于存储一段具有相同的类型的元素的连续块。数组存储的类型可以是内置类型,如整型或者字符串,也可以是某种结构类型。
  • 声明数组时需要指定内部存储的数据的类型,以及需要存储的元素的数量,这个数量也称为数组的长度。var array [5]int
  • 一旦声明,数组里存储的数据类型和数组长度就都不能改变了。如果需要存储更多的元素,就需要先创建一个更长的数组,再把原来数组里的值复制到新数组里。
  • 一种快速创建数组并初始化的方式是使用数组字面量。array := [5]int{10, 20, 30, 40, 50}
  • 如果使用...替代数组的长度,Go 语言会根据初始化时数组元素的数量来确定该数组的长度。array := [...]int{10, 20, 30, 40, 50}
  • 声明数组并指定特定元素的值。array := [5]int{1: 10, 2: 20}
  • 声明一个所有元素都是指针的数组。使用*运算符就可以访问元素指针所指向的值。
  • 数组变量的类型包括数组长度和每个元素的类型。只有这两部分都相同的数组,才是类型相同的数组,才能互相赋值。
  • 声明二维数组
    • 声明一个二维整型数组,两个维度分别存储 4 个元素和 2 个元素:var array [4][2]int
    • 使用数组字面量来声明并初始化一个二维整型数组:array := [4][2]int{{10, 11}, {20, 21}, {30, 31}, {40, 41}}
    • 声明并初始化外层数组中索引为 1 个和 3 的元素:array := [4][2]int{1: {20, 21}, 3: {40, 41}}
    • 声明并初始化外层数组和内层数组的单个元素:array := [4][2]int{1: {0: 20}, 3: {1: 41}}
  • 在函数之间传递变量时,总是以值的方式传递的。如果这个变量是一个数组,意味着整个数组,不管有多长,都会完整复制,并传递给函数。有一种更好且更有效的方法来处理这个操作。可以只传入指向数组的指针,这样只需要复制 8 字节的数据。

切片

  • 切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append 来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。
  • 切片是有 3 个字段的数据结构,分别是指向底层数组的指针、切片访问的元素的个数(即长度)和切片允许增长到的元素个数(即容量)。
  • 是否能提前知道切片需要的容量通常会决定要如何创建切片,频繁的复制移动底层数组会产生不必要的性能消耗。
  • 一种创建切片的方法是使用内置的 make 函数。当使用 make 时,需要传入一个参数,指定切片的长度。slice := make([]string, 5)
  • 如果只指定长度,那么切片的容量和长度相等。也可以分别指定长度和容量。slice := make([]int, 3, 5)
  • 不允许创建容量小于长度的切片。
  • 另一种常用的创建切片的方法是使用切片字面量。初始的长度和容量会基于初始化时提供的元素的个数确定。slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}
  • 当使用切片字面量时,可以设置初始长度和容量。要做的就是在初始化时给出所需的长度和容量作为索引。slice := []string{99: ""}
  • 记住,如果在[]运算符里指定了一个值,包括...,那么创建的就是数组而不是切片。
  • 创建 nil 切片var slice []int
  • 利用初始化,通过声明一个切片可以创建一个空切片slice := make([]int, 0) 或 slice := []int{}
  • 对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使用[]操作符就可以改变某个元素的值。
  • 切片之所以被称为切片,是因为创建一个新的切片就是把底层数组切出一部分。
1
2
3
4
5
6
// 创建一个整型切片
// 其长度和容量都是 5 个元素
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片
// 其长度为 2 个元素,容量为 4 个元素
newSlice := slice[1:3]

  • newSlice 无法访问到它所指向的底层数组的第一个元素之前的部分。
  • 需要记住的是,现在两个切片共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到。
  • 切片只能访问到其长度内的元素。试图访问超出其长度的元素将会导致语言运行时异常。
  • 相对于数组而言,使用切片的一个好处是,可以按需增加切片的容量。
  • 函数 append 总是会增加新切片的长度,而容量有可能会改变,也可能不会改变,这取决于被操作的切片的可用容量。
  • 如果切片的底层数组没有足够的可用容量,append 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值。
  • 函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1000 个元素时,总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长因子会设为 1.25,也就是会每次增加 25%的容量。 随着语言的演化,这种增长算法可能会有所改变。
  • 第三个索引可以用来控制新切片的容量。其目的并不是要增加容量,而是要限制容量。
1
2
3
4
5
6
7
8
9
// 创建字符串切片
// 其长度和容量都是 5 个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 将第三个元素切片,并限制容量
// 其长度为 1 个元素,容量为 2 个元素
slice := source[2:3:4]
// 对于 slice[i:j:k] 或 [2:3:4]
// 长度: j – i 或 3 * 2 = 1
// 容量: k – i 或 4 * 2 = 2

  • 如果试图设置的容量比可用的容量还大,就会得到一个语言运行时错误。
  • 如果在创建切片时设置切片的容量和长度一样,就可以强制让新切片的第一个 append 操作创建新的底层数组,与原有的底层数组分离。新切片与原有的底层数组分离后,可以安全地进行后续修改。
1
2
3
4
5
6
7
8
// 创建字符串切片
// 其长度和容量都是 5 个元素
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
// 对第三个元素做切片,并限制容量
// 其长度和容量都是 1 个元素
slice := source[2:3:3]
// 向 slice 追加新字符串
slice = append(slice, "Kiwi")
  • 内置函数 append 也是一个可变参数的函数。这意味着可以在一次调用传递多个追加的值。如果使用...运算符,可以将一个切片的所有元素追加到另一个切片里。
1
2
3
4
5
6
7
// 创建两个切片,并分别用两个整数进行初始化
s1 := []int{1, 2}
s2 := []int{3, 4}
// 将两个切片追加在一起,并显示结果
fmt.Printf("%v\n", append(s1, s2...))
Output:
[1 2 3 4]
  • 当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本。range 创建了每个元素的副本,而不是直接返回对该元素的引用。
  • for range 迭代切片
1
2
3
4
5
6
7
// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 迭代每一个元素,并显示其值
for index, value := range slice {
fmt.Printf("Index: %d Value: %d\n", index, value)
}
  • 传统 for 循环迭代切片
1
2
3
4
5
6
7
// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 从第三个元素开始迭代每个元素
for index := 2; index < len(slice); index++ {
fmt.Printf("Index: %d Value: %d\n", index, slice[index])
}
  • 有两个特殊的内置函数 len 和 cap,可以用于处理数组、切片和通道。对于切片,函数 len 返回切片的长度,函数 cap 返回切片的容量。
  • 多维切片
1
2
// 创建一个整型切片的切片
slice := [][]int{{10}, {100, 200}}

  • 在函数间传递切片就是要在函数间以值的方式传递切片。由于切片的尺寸很小,在函数间复制和传递切片成本也很低。
1
2
3
4
5
6
7
8
9
// 分配包含 100 万个整型值的切片
slice := make([]int, 1e6)
// 将 slice 传递到函数 foo
slice = foo(slice)
// 函数 foo 接收一个整型切片,并返回这个切片
func foo(slice []int) []int {
...
return slice
}
  • 在 64 位架构的机器上,一个切片需要 24 字节的内存:指针字段需要 8 字节,长度和容量字段分别需要 8 字节。由于与切片关联的数据包含在底层数组里,不属于切片本身,所以将切片复制到任意函数的时候,对底层数组大小都不会有影响。复制时只会复制切片本身,不会涉及底层数组。
  • 在函数间传递 24 字节的数据会非常快速、简单。这也是切片效率高的地方。不需要传递指针和处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本。

映射

  • 映射里基于键来存储值。映射功能强大的地方是,能够基于键快速检索数据。键就像索引一样,指向与该键关联的值。
  • 映射是无序的集合。无序的原因是映射的实现使用了散列表。
  • 使用 make 声明映射
1
2
3
4
5
// 创建一个映射,键的类型是 string,值的类型是 int
dict := make(map[string]int)
// 创建一个映射,键和值的类型都是 string
// 使用两个键值对初始化映射
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}
  • 映射的键可以是任何值。这个值的类型可以是内置的类型,也可以是结构类型,只要这个值可以使用==运算符做比较。切片、函数以及包含切片的结构类型这些类型由于具有引用语义,不能作为映射的键,使用这些类型会造成编译错误
  • 声明一个存储字符串切片的映射。dict := map[int][]string{}
  • 空映射 & 映射赋值
1
2
3
4
// 创建一个空映射,用来存储颜色以及颜色对应的十六进制代码
colors := map[string]string{}
// 将 Red 的代码加入到映射
colors["Red"] = "#da1337"
  • nil 映射
1
2
3
4
5
6
// 通过声明映射创建一个 nil 映射
var colors map[string]string
// 将 Red 的代码加入到映射
colors["Red"] = "#da1337"
Runtime Error:
panic: runtime error: assignment to entry in nil map
  • 从映射获取值并判断键是否存在
1
2
3
4
5
6
// 获取键 Blue 对应的值
value, exists := colors["Blue"]
// 这个键存在吗?
if exists {
fmt.Println(value)
}
  • 从映射获取值,并通过该值是否为对应类型的空值,从而判断键是否存在
1
2
3
4
5
6
// 获取键 Blue 对应的值
value := colors["Blue"]
// 这个键存在吗?
if value != "" {
fmt.Println(value)
}
  • 使用 range 迭代映射
1
2
3
4
5
6
7
8
9
10
11
// 创建一个映射,存储颜色以及颜色对应的十六进制代码
colors := map[string]string{
"AliceBlue": "#f0f8ff",
"Coral": "#ff7F50",
"DarkGray": "#a9a9a9",
"ForestGreen": "#228b22",
}
// 显示映射里的所有颜色
for key, value := range colors {
fmt.Printf("Key: %s Value: %s\n", key, value)
}
  • 如果想把一个键值对从映射里删除,就使用内置的 delete 函数。delete(colors, "Coral")
  • 在函数间传递映射并不会制造出该映射的一个副本。实际上,当传递映射给一个函数,并对这个映射做了修改时,所有对这个映射的引用都会察觉到这个修改。

本章小结

  • 数组是构造切片和映射的基石。
  • Go 语言里切片经常用来处理数据的集合,映射用来处理具有键值对结构的数据。
  • 内置函数 make 可以创建切片和映射,并指定原始的长度和容量。也可以直接使用切片和映射字面量,或者使用字面量作为变量的初始值。
  • 切片有容量限制,不过可以使用内置的 append 函数扩展容量。
  • 映射的增长没有容量或者任何限制。
  • 内置函数 len 可以用来获取切片或者映射的长度。
  • 内置函数 cap 只能用于切片。
  • 通过组合,可以创建多维数组和多维切片。也可以使用切片或者其他映射作为映射的值。但是切片不能用作映射的键。
  • 将切片或者映射传递给函数成本很小,并且不会复制底层的数据结构。

第 5 章:Go 语言的类型系统

本章主要内容

  • 声明新的用户定义的类型

  • 使用方法,为类型增加新的行为

  • 了解何时使用指针,何时使用值

  • 通过接口实现多态

  • 通过组合来扩展或改变类型

  • 公开或者未公开的标识符

  • Go 语言是一种静态类型的编程语言。这意味着,编译器需要在编译时知晓程序里每个值的类型。

  • 一个 int 值的大小可能是 8 字节(64 位),也可能是 4 字节(32 位)。

  • 当用户声明一个新类型时,这个声明就给编译器提供了一个框架,告知必要的内存大小和表示信息。

  • 结构里每个字段都会用一个已知类型声明。这个已知类型可以是内置类型,也可以是其他用户定义的类型。

  • 任何时候,创建一个变量并初始化为其零值,习惯是使用关键字 var。

  • 一个短变量声明操作符在一次操作中完成两件事情:声明一个变量,并初始化。


  • 基于一个已有的类型,将其作为新类型的类型说明。type Duration int64(虽然 int64 是基础类型,Go 并不认为 Duration 和 int64 是同一种类型。)

  • 关键字 func 和函数名之间的参数被称作接收者,将函数与接收者的类型绑在一起。如果一个函数有接收者,这个函数就被称为方法。

  • Go 语言里有两种类型的接收者:值接收者和指针接收者。

  • 值接受者

1
2
3
4
5
6
7
func (u user) notify() {
// 使用值来调用
bill := user{"Bill", "bill@email.com"}
bill.notify()
// 使用值的指针来调用,这时候操作的仍然是值的副本
lisa := &user{"Lisa", "lisa@email.com"}
lisa.notify() // Go背后执行的操作实际是:(*lisa).notify()
  • 指针接收者
1
func (u *user) changeEmail(email string) {
  • 当调用使用指针接收者声明的方法时,这个方法会共享调用方法时接收者所指向的值
  • 值接收者使用值的副本来调用方法,而指针接受者使用实际值来调用方法。
  • 也可以使用一个值来调用使用指针接收者声明的方法

  • 如果给这个类型增加或者删除某个值,是要创建一个新的值,还是要更改当前的值?如果是要创建一个新值,该类型的方法就使用值接收者。如果是要修改当前值,就使用指针接收者。
  • 内置类型是由语言提供的一组类型。我们已经见过这些类型,分别是数值类型、字符串类型和布尔类型。
  • Go 语言里的引用类型有如下几个:切片、映射、通道、接口和函数类型。
  • 是使用值接收者还是指针接收者,不应该由该方法是否修改了接收到的值来决定。这个决策应该基于该类型的本质。
  • 这条规则的一个例外是,需要让类型值符合某个接口的时候,即便类型的本质是非原始本质的,也可以选择使用值接收者声明方法。这样做完全符合接口值调用方法的机制。
  • 接口
  • 多态是指代码可以根据类型的具体实现采取不同行为的能力。如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。
  • 接口值是一个两个字长度的数据结构,第一个字包含一个指向内部表的指针。这个内部表叫作 iTable,包含了所存储的值的类型信息。iTable 包含了已存储的值的类型信息以及与这个值相关联的一组方法。第二个字是一个指向所存储值的指针。 将类型信息和指针组合在一起,就将这两个值组成了一种特殊的关系。

  • 方法集定义了一组关联到给定类型的值或者指针的方法。定义方法时使用的接收者的类型决定了这个方法是关联到值,还是关联到指针,还是两个都关联。
  • 如果使用指针接收者来实现一个接口,那么只有指向那个类型的指针才能够实现对应的接口。如果使用值接收者来实现一个接口,那么那个类型的值和指针都能够实现对应的接口。
  • 为什么会有这种限制?事实上,编译器并不是总能自动获得一个值的地址。

多态:嵌入类型

  • Go 语言允许用户扩展或者修改已有类型的行为。这个功能对代码复用很重要,在修改已有类型以符合新类型的时候也很重要。这个功能是通过嵌入类型(type embedding)完成的。嵌入类型是将已有的类型直接声明在新的结构类型里。被嵌入的类型被称为新的外部类型的内部类型。
  • 通过嵌入类型,与内部类型相关的标识符会提升到外部类型上。这些被提升的标识符就像直接声明在外部类型里的标识符一样,也是外部类型的一部分。这样外部类型就组合了内部类型包含的所有属性,并且可以添加新的字段和方法。外部类型也可以通过声明与内部类型标识符同名的标识符来覆盖内部标识符的字段或者方法。这就是扩展或者修改已有类型的方法。
  • 要嵌入一个类型,只需要声明这个类型的名字就可以了。
  • 一旦我们将 user 类型嵌入 admin,我们就可以说 user 是外部类型 admin 的内部类型。有了内部类型和外部类型这两个概念,就能更容易地理解这两种类型之间的关系。
  • 虽然没有指定内部类型对应的字段名,还是可以使用内部类型的类型名,来访问到内部类型的值。ad.user.notify()
  • 由于内部类型的标识符提升到了外部类型,我们可以直接通过外部类型的值来访问内部类型的标识符。
  • 由于内部类型的提升,内部类型实现的接口会自动提升到外部类型。这意味着由于内部类型的实现,外部类型也同样实现了这个接口。ad.notify()
  • 如果外部类型实现了 notify 方法,内部类型的实现就不会被提升。不过内部类型的值一直存在,因此还可以通过直接访问内部类型的值,来调用没有被提升的内部类型实现的方法。

公开和未公开标识符

  • 要想设计出好的 API,需要使用某种规则来控制声明后的标识符的可见性。
  • 当一个标识符的名字以小写字母开头时,这个标识符就是未公开的,即包外的代码不可见。如果一个标识符以大写字母开头,这个标识符就是公开的,即被包外的代码可见。
  • 将工厂函数命名为 New 是 Go 语言的一个习惯。
  • New 函数创建了一个未公开的类型的值,并将这个值返回给调用者。
  • 第一,公开或者未公开的标识符,不是一个值。第二,短变量声明操作符,有能力捕获引用的类型,并创建一个未公开的类型的变量。
  • 永远不能显式创建一个未公开的类型的变量,不过短变量声明操作符可以这么做。
  • 即便内部类型是未公开的,内部类型里声明的字段依旧是公开的。既然内部类型的标识符提升到了外部类型,这些公开的字段也可以通过外部类型的字段的值来访问。

本章小结

  • 使用关键字 struct 或者通过指定已经存在的类型,可以声明用户定义的类型。
  • 方法提供了一种给用户定义的类型增加行为的方式。
  • 设计类型时需要确认类型的本质是原始的,还是非原始的。
  • 接口是声明了一组行为并支持多态的类型。
  • 嵌入类型提供了扩展类型的能力,而无需使用继承。
  • 标识符要么是从包里公开的,要么是在包里未公开的。

第 6 章:并发

本章主要内容

  • 使用 goroutine 运行程序
  • 检测并修正竞争状态
  • 利用通道共享数据
  • Go 语言里的并发指的是能让某个函数独立于其他函数运行的能力。
  • Go 语言的并发同步模型来自一个叫作通信顺序进程(Communicating Sequential Processes,CSP)的范型(paradigm)。
  • CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。
  • 用于在 goroutine 之间同步和传递数据的关键数据类型叫作通道(channel)。
  • 使用通道可以使编写并发程序更容易,也能够让并发程序出错更少。
  • 什么是操作系统的线程(thread)和进程(process)?
    • 进程可以看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。 这些资源包括但不限于内存地址空间、文件和设备的句柄以及线程。
    • 一个线程是一个执行空间,这个空间会被操作系统调度来运行函数中所写的代码。
    • 每个进程至少包含一个线程,每个进程的初始线程被称作主线程。 因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。
    • 操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度 goroutine 来运行。
  • 1.5 版本上,Go 语言的运行时默认会为每个可用的物理处理器分配一个逻辑处理器。
  • 如果创建一个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的 goroutine 会一直等待直到自己被分配的逻辑处理器执行。
  • 如果一个 goroutine 需要做一个网络 I/O 调用,流程上会有些不一样。在这种情况下,goroutine 会和逻辑处理器分离,并移到集成了网络轮询器的运行池。一旦该轮询器指示某个网络读或者写操作已经就绪,对应的 goroutine 就会重新分配到逻辑处理器上来完成操作。
  • 并发(concurrency)不是并行(parallelism)。
  • 并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。 在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。
  • 如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。
  • 不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go 语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

goroutines

  • 调用了 runtime 包的 GOMAXPROCS 函数。这个函数允许程序更改调度器可以使用的逻辑处理器的数量。如果不想在代码里做这个调用,也可以通过修改和这个函数名字一样的环境变量的值来更改逻辑处理器的数量。
  • WaitGroup 是一个计数信号量,可以用来记录并维护运行的 goroutine。
  • 如果 WaitGroup 的值大于 0,Wait 方法就会阻塞。
  • 关键字 defer 会修改函数调用时机,在正在执行的函数返回时才真正调用 defer 声明的函数。
  • 当 goroutine 占用时间过长时,调度器会停止当前正运行的 goroutine,并给其他可运行的 goroutine 运行的机会。
  • 给每个可用的核心分配一个逻辑处理器 runtime.GOMAXPROCS(runtime.NumCPU())
  • 需要强调的是,使用多个逻辑处理器并不意味着性能更好。在修改任何语言运行时配置参数的时候,都需要配合基准测试来评估程序的运行效果。
  • 记住,只有在有多个逻辑处理器且可以同时让每个 goroutine 运行在一个可用的物理处理器上的时候,goroutine 才会并行运行。

竞争状态

  • 如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)。
  • 对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine 对共享资源进行读和写操作。
  • 当前 goroutine 从线程退出,并放回到队列:runtime.Gosched()
  • go build -race可以在代码里检测竞争状态。在查找这类错误的时候,这个工具非常好用,尤其是在竞争状态并不像这个例子里这么明显的时候。
  • 一种修正代码、消除竞争状态的办法是,使用 Go 语言提供的锁机制,来锁住共享资源,从而保证 goroutine 的同步状态。

锁住共享资源

  • 如果需要顺序访问一个整型变量或者一段代码,atomic 和 sync 包里的函数提供了很好的解决方案。
  • 原子函数能够以很底层的加锁机制来同步访问整型变量和指针。
    • atmoic 包的 AddInt64 函数会同步整型值的加法,方法是强制同一时刻只能有一个 goroutine 运行并完成这个加法操作。 当 goroutine 试图去调用任何原子函数时,这些 goroutine 都会自动根据所引用的变量做同步处理。
    • 另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。
    • 原子函数会将这些读写调用互相同步,保证这些操作都是安全的,不会进入竞争状态。
  • 另一种同步访问共享资源的方式是使用互斥锁(mutex)。
    • 互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界区代码。
    • 同一时刻只有一个 goroutine 可以进入临界区。之后,直到调用 Unlock()函数之后,其他 goroutine 才能进入临界区。

通道

  • 原子函数和互斥锁都能工作,但是依靠它们都不会让编写并发程序变得更简单,更不容易出错,或者更有趣。
  • 当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。
  • 声明通道时,需要指定将要被共享的数据的类型。
  • 可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。
  • 使用 make 创建通道。make 的第一个参数需要是关键字 chan,之后跟着允许通道交换的数据的类型。
  • 向通道发送值或者指针需要用到<-操作符

  • 无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。 如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。
  • 有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。 这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。
  • 有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。
  • 通道关闭后,goroutine 依旧可以从通道接收数据,但是不能再向通道里发送数据。 能够从已经关闭的通道接收数据这一点非常重要,因为这允许通道关闭后依旧能取出其中缓冲的全部值,而不会有数据丢失。从一个已经关闭且没有数据的通道里获取数据,总会立刻返回,并返回一个通道类型的零值。

本章小结

  • 并发是指 goroutine 运行的时候是相互独立的。
  • 使用关键字 go 创建 goroutine 来运行函数。
  • goroutine 在逻辑处理器上执行,而逻辑处理器具有独立的系统线程和运行队列。
  • 竞争状态是指两个或者多个 goroutine 试图访问同一个资源。
  • 原子函数和互斥锁提供了一种防止出现竞争状态的办法。
  • 通道提供了一种在两个 goroutine 之间共享数据的简单方法。
  • 无缓冲的通道保证同时交换数据,而有缓冲的通道不做这种保证。

第 7 章:并发模式

本章主要内容

  • 控制程序的生命周期
  • 管理可复用的资源池
  • 创建可以处理任务的 goroutine 池

runner

  • runner 包用于展示如何使用通道来监视程序的执行时间,如果程序运行时间太长,也可以用 runner 包来终止程序。 当开发需要调度后台处理任务的程序的时候,这种模式会很有用。这个程序可能会作为 cron 作业执行,或者在基于定时任务的云环境(如 iron.io)里执行。
  • 在设计上,可支持以下终止点:
    _ 程序可以在分配的时间内完成工作,正常终止;
    _ 程序没有及时完成工作,“自杀”; * 接收到操作系统发送的中断事件,程序立刻试图清理状态并停止工作。
  • 通道 interrupt 被初始化为缓冲区容量为 1 的通道。这可以保证通道至少能接收一个来自语言运行时的 os.Signal 值,确保语言运行时发送这个事件的时候不会被阻塞。如果 goroutine 没有准备好接收这个值,这个值就会被丢弃。
  • 通道 complete 被初始化为无缓冲的通道。当执行任务的 goroutine 完成时,会向这个通道发送一个 error 类型的值或者 nil 值。之后就会等待 main 函数接收这个值。一旦 main 接收了这个 error 值,goroutine 就可以安全地终止了。
  • 最后一个通道 timeout 是用 time 包的 After 函数初始化的。After 函数返回一个 time.Time 类型的通道。语言运行时会在指定的 duration 时间到期之后,向这个通道发送一个 time.Time 的值。
  • task 字段的零值是 nil,已经满足初始化的要求,所以没有被明确初始化。
  • 可变参数可以接受任意数量的值作为传入参数。
  • 方法 gotInterrupt 展示了带 default 分支的 select 语句的经典用法。
  • 代码试图从 interrupt 通道去接收信号。一般来说,select 语句在没有任何要接收的数据时会阻塞,不过有了第 98 行的 default 分支就不会阻塞了。default 分支会将接收 interrupt 通道的阻塞调用转变为非阻塞的。如果 interrupt 通道有中断信号需要接收,就会接收并处理这个中断。如果没有需要接收的信号,就会执行 default 分支。
  • 方法 Start 实现了程序的主流程。

pool

  • 本章会介绍 pool 包。在 Go 1.6 及之后的版本中,标准库里自带了资源池的实现(sync.Pool)。推荐使用。
  • 这个包用于展示如何使用有缓冲的通道实现资源池,来管理可以在任意数量的 goroutine 之间共享及独立使用的资源。这种模式在需要共享一组静态资源的情况(如共享数据库连接或者内存缓冲区)下非常有用。如果 goroutine 需要从池里得到这些资源中的一个,它可以从池里申请,使用完后归还到资源池里。
  • Pool 的结构允许调用者根据所需数量创建不同的资源池。只要某类资源实现了 io.Closer 接口,就可以用这个资源池来管理。
  • sync.Mutex 类型的互斥锁用来保证在多个 goroutine 访问资源池时,池内的值是安全的。
  • resources 字段被声明为 io.Closer 接口类型的通道。这个通道是作为一个有缓冲的通道创建的,用来保存共享的资源。由于通道的类型是一个接口,所以池可以管理任意实现了 io.Closer 接口的资源类型。
  • factory 字段是一个函数类型。任何一个没有输入参数且返回一个 io.Closer 和一个 error 接口值的函数,都可以赋值给这个字段。这个函数的目的是,当池需要一个新资源时,可以用这个函数创建。这个函数的实现细节超出了 pool 包的范围,并且需要由包的使用者实现并提供。
  • closed 字段是一个标志,表示 Pool 是否已经被关闭。
  • Go 语言里会经常创建 error 接口变量。这可以让调用者来判断某个包里的函数或者方法返回的具体的错误值(如果可能存在多种错误值的情况下。)。
  • Acquire 方法在还有可用资源时会从资源池里返回一个资源,否则会为该调用创建并返回一个新的资源。
  • 一旦程序不再使用资源池,需要调用这个资源池的 Close 方法。在同一时刻只能有一个 goroutine 执行这段代码。事实上,当这段代码被执行时,必须保证其他 goroutine 中没有同时执行 Release 方法。
  • Release 方法的任务是如果不再需要已经获得的资源,必须将这个资源释放回资源池里。和 Close 方法中的互斥量是同一个互斥量。这样可以阻止这两个方法在不同 goroutine 里同时运行。
  • 唯一标识是通过 atomic.AddInt32 函数生成的。这个函数可以安全地增加包级变量 idCounter 的值。

work

  • work 包的目的是展示如何使用无缓冲的通道来创建一个 goroutine 池,这些 goroutine 执行并控制一组工作,让其并发执行。
  • 在这种情况下,使用无缓冲的通道要比随意指定一个缓冲区大小的有缓冲的通道好,因为这个情况下既不需要一个工作队列,也不需要一组 goroutine 配合执行。
  • 无缓冲的通道保证两个 goroutine 之间的数据交换。
  • 这种使用无缓冲的通道的方法允许使用者知道什么时候 goroutine 池正在执行工作,而且如果池里的所有 goroutine 都忙,无法接受新的工作的时候,也能及时通过通道来通知调用者。
  • 使用无缓冲的通道不会有工作在队列里丢失或者卡住,所有工作都会被处理。
  • work 包的工厂函数
  • 代码展示了 New 函数,这个函数使用固定数量的 goroutine 来创建一个工作池。goroutine 的数量作为参数传给 New 函数。
  • 在第 22 行,创建了一个 Pool 类型的值,并使用无缓冲的通道来初始化 work 字段。
  • 之后,在第 26 行,初始化 WaitGroup 需要等待的数量,并在第 27 行到第 34 行,创建了同样数量的 goroutine。
  • 这些 goroutine 只接收 Worker 类型的接口值,并调用这个值的 Task 方法。
  • 代码清单 7-31 里的 for range 循环会一直阻塞,直到从 work 通道收到一个 Worker 接口值。
  • 如果收到一个值,就会执行这个值的 Task 方法。一旦 work 通道被关闭(所有任务结束后,在 shutdown 中关闭),for range 循环就会结束,并调用 WaitGroup 的 Done 方法。然后 goroutine 终止。
  • Shutdown 方法做了两件事,首先,它关闭了 work 通道,这会导致所有池里的 goroutine 停止工作,并调用 WaitGroup 的 Done 方法;然后,Shutdown 方法调用 WaitGroup 的 Wait 方法,这会让 Shutdown 方法等待所有 goroutine 终止。

本章小结

  • 可以使用通道来控制程序的生命周期。
  • 带 default 分支的 select 语句可以用来尝试向通道发送或者接收数据,而不会阻塞。
  • 有缓冲的通道可以用来管理一组可复用的资源。
  • 语言运行时会处理好通道的协作和同步。
  • 使用无缓冲的通道来创建完成工作的 goroutine 池。
  • 任何时间都可以用无缓冲的通道来让两个 goroutine 交换数据,在通道操作完成时一定保证对方接收到了数据。

第 8 章:标准库

本章主要内容

  • 输出数据以及记录日志
  • 对 JSON 进行编码和解码
  • 处理输入/输出,并以流的方式处理数据
  • 让标准库里多个包协同工作
  • Go 标准库是一组核心包,用来扩展和增强语言的能力。这些包为语言增加了大量不同的类型。开发人员可以直接使用这些类型,而不用再写自己的包或者去下载其他人发布的第三方包。
  • 标准库本身是经过良好设计的,并且比其他语言的标准库提供了更多的功能。

文档与源代码

  • 不管用什么方式安装 Go,标准库的源代码都会安装在$GOROOT/src/pkg 文件夹中。
  • 作为 Go 发布包的一部分,标准库的源代码是经过预编译的。这些预编译后的文件,称作归档文件(archive file)。可以 在$GOROOT/pkg 文件夹中找到已经安装的各目标平台和操作系统的归档文件。
  • 归档文件是特殊的 Go 静态库文件,由 Go 的构建工具创建,并在编译和链接最终程序时被使用。归档文件可以让构建的速度更快。

记录日志

  • 日志是开发人员的眼睛和耳朵,可以用来跟踪、调试和分析代码。
  • 标准库提供了 log 包,可以对日志做一些最基本的配置。根据特殊需要,开发人员还可以自己定制日志记录器。
  • 传统的 CLI(命令行界面)程序直接将输出写到名为 stdout 的设备上。
  • stderr 设备被创建为日志的默认目的地。
  • 如果用户的程序只记录日志,没有程序输出,更常用的方式是将一般的日志信息写到 stdout,将错误或者警告信息写到 stderr。
  • 通常程序会在 init()函数里配置日志参数,这样程序一开始就能使用 log 包进行正确的输出。
  • 有几个和 log 包相关联的标志,这些标志用来控制可以写到每个日志项的其他信息。
  • 关键字 iota 在常量声明区里有特殊的作用。这个关键字让编译器为每个常量复制相同的表达式,直到声明区结束,或者遇到一个新的赋值语句。关键字 iota 的另一个功能是,iota 的初始值为 0,之后 iota 的值在每次处理为常量后,都会自增 1。
  • Fatal 系列函数用来写日志消息,然后使用 os.Exit(1)终止程序。
  • Panic 系列函数用来写日志消息,然后触发一个 panic。除非程序执行 recover 函数,否则会导致程序打印调用栈后终止。
  • Print 系列函数是写日志消息的标准方法。
  • log 包有一个很方便的地方就是,这些日志记录器是多 goroutine 安全的。这意味着在多个 goroutine 可以同时调用来自同一个日志记录器的这些函数,而不 会有彼此间的写冲突。

定制的日志记录器

  • 要想创建一个定制的日志记录器,需要创建一个 Logger 类型值。可以给每个日志记录器配置一个单独的目的地,并独立设置其前缀和标志。
  • 为了创建每个日志记录器,我们使用了 log 包的 New 函数,它创建并正确初始化一个 Logger 类型的值。
  • 当某个等级的日志不重要时,使用 Discard 变量可以禁用这个等级的日志。
  • MultiWriter 函数是一个变参函数,可以接受任意个实现了 io.Writer 接口的值。这个函数会返回一个 io.Writer 值,这个值会把所有传入的 io.Writer 的值绑在一起。当对这个返回值进行写入时,会向所有绑在一起的 io.Writer 值做写入。
  • Logger 类型实现的所有方法

编码/解码

  • 如果程序需要处理 XML 或者 JSON,可以使用标准库里名为 xml 和 json 的包,它们可以处理这些格式的数据。
  • 在今天,JSON 远比 XML 流行。这主要是因为与 XML 相比,使用 JSON 需要处理的标签更少。而这就意味着网络传输时每个消息的数据更少,从而提升整个系统的性能。而且,JSON 可以转换为 BSON(Binary JavaScript Object Notation,二进制 JavaScript 对象标记),进一步缩小每个消息的数据长度。
  • 每个字段最后使用单引号声明了一个字符串。这些字符串被称作标签(tag),是提供每个字段的元信息的一种机制,将 JSON 文档和结构类型里的字段一一映射起来。
  • 如果不存在标签,编码和解码过程会试图以大小写无关的方式,直接使用字段的名字进行匹配。如果无法匹配,对应的结构类型里的字段就包含其零值。
  • Decode 方法接受一个 interface{} 类型的值做参数,并返回一个 error 值。任何类型都实现了一个空接口 interface{}。这意味着 Decode 方法可以接受任意类型的值。使用反射,Decode 方法会拿到传入值的类型信息。然后,在读取 JSON 响应的过程中,Decode 方法会将对应的响应解码为这个类型的值。
  • 有时,需要处理的 JSON 文档会以 string 的形式存在。在这种情况下,需要将 string 转换为 byte 切片([]byte),并使用 json 包的 Unmarshal 函数进行反序列化的处理。
  • 有时,无法为 JSON 的格式声明一个结构类型,而是需要更加灵活的方式来处理 JSON 文档。在这种情况下,可以将 JSON 文档解码到一个 map 变量中。
  • 变量 c 声明为一个 map 类型,其键是 string 类型,其值是 interface{} 类型。这意味着这个 map 类型可以使用任意类型的值作为给定键的值。var c map[string]interface{}
  • 展示了如何将 contact 键的值转换为另一个键是 string 类型,值是 interface{} 类型的 map 类型。
  • 使用 json 包的 MarshalIndent 函数进行编码。这个函数可以很方便地将 Go 语言的 map 类型的值或者结构类型的值转换为易读格式的 JSON 文档。
  • 序列化(marshal)是指将数据转换为 JSON 字符串的过程。

输入和输出

  • 类 UNIX 的操作系统如此伟大的一个原因是,一个程序的输出可以是另一个程序的输入这一理念。 依照这个哲学,这类操作系统创建了一系列的简单程序,每个程序只做一件事,并把这件事做得非常好。之后,将这些程序组合在一起,可以创建一些脚本做一些很惊艳的事情。这些程序使用 stdin 和 stdout 设备作为通道,在进程之间传递数据。
  • 与 stdout 和 stdin 对应,这个包含有 io.Writer 和 io.Reader 两个接口。
  • 所有实现了这两个接口的类型的值,都可以使用 io 包提供的所有功能,也可以用于其他包里接受这两个接口的函数以及方法。这是用接口类型来构造函数和 API 最美妙的地方。
  • 由于 io.Writer 和 io.Reader 提供了足够的抽象,这些 io 包里的函数和方法并不知道数据的类型,也不知道这些数据在物理上是如何读和写的。



  • 在 bytes 包的源代码里,为 Buffer 类型声明的 Write 方法
  • os 包的源代码里,为 File 类型声明的 Write 方法
  • 应该花时间看一下标准库中提供了些什么,以及它是如何实现的——不仅要防止重新造轮子,还要理解 Go 语言的设计者的习惯,并将这些习惯应用到自己的包和 API 的设计上。

本章小结

  • 标准库有特殊的保证,并且被社区广泛应用。
  • 使用标准库的包会让你的代码更易于管理,别人也会更信任你的代码。
  • 100 余个包被合理组织,分布在 38 个类别里。
  • 标准库里的 log 包拥有记录日志所需的一切功能。
  • 标准库里的 xml 和 json 包让处理这两种数据格式变得很简单。
  • io 包支持以流的方式高效处理数据。
  • 接口允许你的代码组合已有的功能。
  • 阅读标准库的代码是熟悉 Go 语言习惯的好方法。

第 9 章:测试和性能

本章主要内容

  • 编写单元测试来验证代码的正确性
  • 使用 httptest 来模拟基于 HTTP 的请求和响应
  • 使用示例代码来给包写文档
  • 通过基准测试来检查性能
  • 作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。
  • 使用 Go 语言的测试框架,可以在开发的过程中就进行单元测试和基准测试。
  • 和 go build 命令类似,go test 命令可以用来执行写好的测试代码,需要做的就是遵守一些规则来写测试。而且,可以将测试无缝地集成到代码工程和持续集成系统里。

单元测试

  • 单元测试是用来测试包或者程序的一部分代码或者一组代码的函数。
  • 一个场景是正向路经测试,就是在正常执行的情况下,保证代码不产生错误的测试。
  • 一些单元测试可能会测试负向路径的场景,保证代码不仅会产生错误,而且是预期的错误。
  • 基础测试(basic test)只使用一组参数和结果来测试一段代码。
  • 表组测试(table test)也会测试一段代码,但是会使用多组参数和结果进行测试。
  • 使用一些方法来模仿(mock)测试代码需要使用到的外部资源,如数据库或者网络服务器。

基础测试

  • 调用 go test -v 来运行这个测试(-v 表示提供冗余输出)。
  • Go 语言的测试工具只会认为_test.go 结尾的文件是测试文件
  • testing 包提供了从测试框架到报告测试的输出和状态的各种测试功能的支持。
  • 第 09 行和第 10 行声明了两个常量,这两个常量包含写测试输出时会用到的对号(√)和叉号(×)
  • 一个测试函数必须是公开的函数,并且以 Test 单词开头。不但函数名字要以 Test 开头,而且函数的签名必须接收一个指向 testing.T 类型的指针,并且不返回任何值
  • 测试的输出需使用完整易读的语句,来记录为什么需要这个测试,具体测试了什么,以及测试的结果是什么。
  • 使用方法 t.Log 来输出测试的消息。这个方法还有一个名为 t.Logf 的版本,可以格式化消息。
  • 如果执行 go test 的时候没有加入冗余选项(-v),除非测试失败,否则我们是看不到任何测试输出的。
  • 每个测试函数都应该通过解释这个测试的给定要求(given need),来说明为什么应该存在这个测试。
  • 特别说明了要测试的值。
  • 在每种情况下,我们都会说明测试应有的结果。如果调用失败,除了结果,还会输出叉号以及得到的错误值。如果测试成功,会输出对号。
  • t.Fatal 方法不但报告这个单元测试已经失败,而且会向测试输出写一些消息,而后立刻停止这个测试函数的执行。如果除了这个函数外还有其他没有执行的测试函数,会继续执行其他测试函数。这个方法对应的格式化版本名为 t.Fatalf。
  • 如果需要报告测试失败,但是并不想停止当前测试函数的执行,可以使用 t.Error 系列方法

表组测试

  • 表组测试除了会有一组不同的输入值和期望结果之外,其余部分都很像基础单元测试。

模仿调用

  • 依赖不属于你的或者你无法操作的服务来进行测试,也不是一个好习惯。这两点会严重影响测试持续集成和部署的自动化。
  • httptest 的包让开发人员可以模仿基于 HTTP 的网络调用。
  • 模仿(mocking)是一个很常用的技术手段,用来在运行测试时模拟访问不可用的资源。包 httptest 可以让你能够模仿互联网资源的请求和响应。
  • httptest.Server 的值是整个模仿服务的关键。
  • HandlerFunc 类型是一个适配器,允许常规函数作为 HTTP 的处理函数使用。
  • 当我们使用由模仿服务器提供的 URL 时,http.Get 调用依旧会按我们预期的方式运行。http.Get 方法调用时并不知道我们的调用是否经过互联网。
  • 如果仔细看用于调用的 URL,会发现这个 URL 使用了 localhost 作为地址,端口是 52065。这个端口号每次运行测试时都会改变。

测试服务端点

  • 服务端点(endpoint)是指与服务宿主信息无关,用来分辨某个服务的地址,一般是不包含宿主的一个路径。
  • 如果在构造网络 API,你会希望直接测试自己的服务的所有服务端点,而不用启动整个网络服务。
  • handlers_test 包的名字也使用_test结尾。如果包使用这种方式命名,测试代码只能访问包里公开的标识符。 即便测试代码文件和被测试的代码放在同一个文件夹中,也只能访问公开的标识符。

示例

  • Go 语言很重视给代码编写合适的文档。专门内置了 godoc 工具来从代码直接生成文档。这个工具的另一个特性是示例代码。
  • 开发人员可以创建自己的示例,并且在包的 Go 文档里展示。
  • 示例基于已经存在的函数或者方法。我们需要使用 Example 作为函数名的开始。
  • 示例代码的函数名字必须基于已经存在的公开的函数或者方法。
  • 写示例代码的目的是展示某个函数或者方法的特定使用方法。
  • 这个Output:标记用来在文档中标记出示例函数运行后期望的输出。
  • Go 的测试框架知道如何比较注释里的期望输出和标准输出的最终输出。如果两者匹配,这个示例作为测试就会通过,并加入到包的 Go 文档里。如果输出不匹配,这个示例作为测试就会失败。
  • 示例的一组完整文档,包括代码和期望的输出。
  • 由于这个示例也是测试的一部分,可以使用 go test 工具来运行这个示例函数。
  • 运行测试时,使用-run选项指定了特定的函数。-run 选项接受任意的正则表达式,来指定要运行的测试函数。这个选项既支持单元测试,也支持示例函数。

基准测试

  • 基准测试是一种测试代码性能的方法。
  • 想要测试解决同一问题的不同方案的性能,以及查看哪种解决方案的性能更好时,基准测试就会很有用。
  • 基准测试也可以用来识别某段代码的 CPU 或者内存效率问题,而这段代码的效率可能会严重影响整个应用程序的性能。
  • 许多开发人员会用基准测试来测试不同的并发模式,或者用基准测试来辅助配置工作池的数量,以保证能最大化系统的吞吐量。
  • 基准测试的文件名也必须以_test.go结尾。同时也必须导入 testing 包。
  • 基准测试函数必须以 Benchmark 开头,接受一个指向 testing.B 类型的指针作为唯一参数。
  • 基准测试框架默认会在持续 1 * 秒的时间内,反复调用需要测试的函数。
  • 测试框架每次调用测试函数时,都会增加 b.N 的值。
  • 第一次调用时,b.N 的值为 1。需要注意,一定要将所有要进行基准测试的代码都放到循环里,并且循环要使用 b.N 的值。否则,测试的结果是不可靠的。
  • 如果我们只希望运行基准测试函数,需要加入-bench 选项。go test -v -run="none" -bench="BenchmarkSprintf"
  • -run选项传递了字符串”none”,来保证在运行制订的基准测试函数之前没有单元测试会被运行。
  • 默认情况下,基准测试的最小运行时间是 1 秒。如果想让运行时间更长,可以使用另一个名为-benchtime的选项来更改测试执行的最短时间。
  • 对大多数测试来说,超过 3 秒的基准测试并不会改变测试的精确度。
  • 在代码开始执行循环之前需要进行初始化时,b.ResetTimer 方法用来重置计时器,保证测试代码执行前的初始化代码,不会干扰计时器的结果。
  • 运行基准测试时,另一个很有用的选项是-benchmem 选项。这个选项可以提供每次操作分配内存的次数,以及总共分配内存的字节数。
  • 单位为 allocs/op 的值表示每次操作从堆上分配内存的次数。
  • 单位为 B/op 的值表示每次操作分配的字节数。

本章小结

  • 测试功能被内置到 Go 语言中,Go 语言提供了必要的测试工具。
  • go test 工具用来运行测试。
  • 测试文件总是以_test.go 作为文件名的结尾。
  • 表组测试是利用一个测试函数测试多组值的好办法。
  • 包中的示例代码,既能用于测试,也能用于文档。
  • 基准测试提供了探查代码性能的机制。