【笔记】- 《Go语言实战》

Go 基础

Go 语言是谷歌 2009 年首次推出并在 2012 年正式发布的一种全新的编程语言,可以在不损失应用程序性能的情况下降低代码的复杂性。

下面是 Go 的 “Hello world”程序:

package main

import "fmt"

func main(){
	fmt.Println("Hello Go!")
}

题外话

Foo 是一个编程中经常使用的占位符,它没有特定的含义。“foo” 这个词的确有很多不同的起源说法。以下是一些可能的来源:

  • “Foo” 来自于 “FUBAR”,这个说法已经在前面提到过了。FUBAR 是 “Fucked Up Beyond All Recognition” 的缩写,意为 “完全搞砸了”。“Foo” 可能是在这个词的基础上发展而来的。

  • “Foo” 作为占位符的使用可以追溯到 1965 年,当时计算机科学家 Melvin Conway 在一篇论文中使用了 “foo” 和 “bar” 作为变量名。他认为这些词听起来有趣,可以使代码更易于阅读。

  • “Foo” 来自于纽约的一个餐厅,当时计算机科学家们在那里举行了一个会议。他们使用 “foo” 来代表那个餐厅的名字,后来这个词就成为了占位符的代名词。

Go

在 Linux 操作系统中,我们安装 Go 之后可以使用命令管理工具来对 Go 进行管理。

[root@yikuanzz ~]# go -help
Go is a tool for managing Go source code.

Usage:

        go <command> [arguments]

The commands are:

        bug         start a bug report
        build       compile packages and dependencies
        clean       remove object files and cached files
        doc         show documentation for package or symbol
        env         print Go environment information
        fix         update packages to use new APIs
        fmt         gofmt (reformat) package sources
        generate    generate Go files by processing source
        get         add dependencies to current module and install them
        install     compile and install packages and dependencies
        list        list packages or modules
        mod         module maintenance
        work        workspace maintenance
        run         compile and run Go program
        test        test packages
        tool        run specified go tool
        version     print Go version
        vet         report likely mistakes in packages

Use "go help <command>" for more information about a command.

Additional help topics:

        buildconstraint build constraints
        buildmode       build modes
        c               calling between Go and C
        cache           build and test caching
        environment     environment variables
        filetype        file types
        go.mod          the go.mod file
        gopath          GOPATH environment variable
        gopath-get      legacy GOPATH go get
        goproxy         module proxy protocol
        importpath      import path syntax
        modules         modules, module versions, and more
        module-get      module-aware go get
        module-auth     module authentication using go.sum
        packages        package lists and patterns
        private         configuration for downloading non-public code
        testflag        testing flags
        testfunc        testing functions
        vcs             controlling version control with GOVCS

Use "go help <topic>" for more information about that topic.

通过 build 命令,我们可以对 Go 程序进行编译;clean 命令则会删除编译生成的可执行文件;run 命令则是编译并且运行。


除了上面这些常用的命令外,还有一些好用的命令也是值得介绍的:

  • go vet 可以辅助捕获常见的错误;
  • go fmt 会将你的代码布局成和 Go 源码类似的风格;
  • go doc 能阅读包的文档内容,也可以自己去写代码的文档。

所有 Go 语言程序会被组成很多组的文件。每一个组就叫做包,放在同个文件夹内。这样每个包的代码都可以作为很小的复用单元,被其他项目引用。

比如,我们在程序开头声明的就是该程序文件所属的包 package main

创建一个 Go 工程主要步骤如下:

  • 1.新建工程文件目录 goproject

    mkdir goproject
    
  • 2.在工程文件中新建 srcpkgbin 文件目录。

    cd goproject
    mkdir src pkg bin
    
  • 3.在 GOPYAH 中添加工程路径。

    go env GOATH="/home/goproject"
    
  • 4.在 src 文件目录下新建自己的包 example 文件目录。

    cd ./src
    mkdir example
    
  • 5.在 src下编写主程序代码 goproject.go

  • 6.在 example 文件内编写 example.go 和包测试文件 example_test.go

  • 7.编译调试包。

    go build example
    go test example
    go install example
    
  • 8.编译主程序。

    go build goproject.go
    
  • 9.运行主程序。

    ./goproject
    

其中,GOPATH 为工程根目录,bin 用来存放生成的可执行文件,pkg 用来生成 .a 文件,在 golang 中的 import name 实际上是去 GoPATH 中寻找 name.a 文件。


在 Go 语言中,会将命名为 main 的包编译成为 二进制可执行程序。

如果某个包名叫 main ,那么其中一定会有名为 main() 的函数。

比如,我们在 $GOPAHT/src/hello 目录中创建 hello.go 并写入我们的“Hello world”程序,然后通过 go build 命令对该程序进行编译,它会产生一个 hello 的可执行程序。

[root@yikuanzz hello]# ./hello
Hello world!

导包

我们注意到程序中,有使用 import "fmt" 的语句。编译器会使用 Go 环境变量设置的路径,通过引入相对路径来去查找磁盘上的包。插个题外话,如果导入包却不适用包中的代码,则会报错。

但是,我们的 $GOPATH 路径上并没有名为 fmt 的包,显然这是标准库中的包,那么这个查找顺序是怎么样的呢?

举例,如果 Go 安装在 /usr/local/go 路径下,且 GOPATH=/home/myproject:/home/mylibraries的话,那么我们去查找 net/http 包就会按照以下顺序去做:

  • 1、/usr/local/go/src/pkg/net/http
  • 2、/home/myproject/src/net/http
  • 3、/home/mylibraries/src/net/http

如果所有目录上都没找到要导入的包,那么我们对程序进行 runbuild 的时候就会出错。后面,我们会介绍 go get 命令来进行处理。


除了本地导入以外,我们也可以通过远程来导入包,比如 import "github.com/spf13/viper"

当我们的包路径包含 URL 时,Go 工具链就会使用 分布式版本控制系统(Distributed Version Control Systems,DVCS)来获取包,并且将包的源代码保存在 GOPATH 执行的路径里与 URL 匹配的目录里。

这个过程通过 go get 命令完成,go get 将获取任意指定的 URL 的包。


当我们用的包多起来的时候,就可能会有相同的包名,这时我们可以对包导入进行重命名,就像下面的代码这样:

package main

import (
	"fmt"
    myfmt "mylib/fmt"
)

func main(){
    fmt.Println("Standard Library")
    myfmt.Println("mylib/fmt")
}

函数 init

每个包可以包含任意多个 init 函数,这些函数都会在程序执行开始的时候被调用。所有被编译器发现的 init 函数都会安排在 main 函数之前执行。

比如,我们看一下 PostgreSQL 数据库驱动中的一段代码:

package postgres

import "database/sql"

func init(){
    sql.Register("postgres", new(PostgresDriver)) 
}

如果程序导入了这个包,就会调用 init 函数,促使 PostgreSQL 的驱动最终注册到 Go 的 sql 包里,成为一个可用的驱动。

为了要注册这个驱动,我们就要导入这个包,但是呢,我们又不使用包内的代码,只是想要这 init 函数执行。

那么,在导包的时候需要给包重新命名,要用到 _ 符号。这样一来,我们可以将驱动注册到 sql 包里面了:

package main

import (
	"database/sql"
    _ "dbdriver/postgres"
)

func main(){
    sql.Open("postgres", "mydb")
}

依赖管理

上面的内容,我们介绍了 Go 的依赖管理方法,就是通过 GOPATH 的方式来管理包和依赖,这样的方式存在很多弊端,也不利于项目的构建。

因此,社区中出现了很多工具可供我们选择,比如 godepvendergb 等等。

这里我们介绍一下 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 工程会区分开发人员的代码和开发人员需要依赖的代码。开发人员的代码放在 $PROJECT/src/ 中,第三方代码放在 $PROJECT/vendor/src中。

接下来,我们做一个实例,尝试创建一个 gb 工程:

1、首先我们先安装 gb 工具:(要将 $GOPATH/bin 加入到环境变量中 export PATH=$PATH:$GOPATH/bin

[root@yikuanzz ~]# go get github.com/constabulary/gb/...

2、我们创建一个文件夹 demo-project 和目录下的 src/hello 文件夹:

[root@yikuanzz ~]# mkdir /home/demo-project
[root@yikuanzz ~]# mkdir -p /home/demo-project/src/hello
[root@yikuanzz ~]# tree /home/demo-project/
/home/demo-project/
└── src
    └── hello

2 directories, 0 files

3、然后我们将写一个简单的 ”Hello world“ 程序到 hello.go 文件中:

[root@yikuanzz ~]# tree /home/demo-project/
/home/demo-project/
└── src
    └── hello
        └── hello.go

2 directories, 1 file

4、进入到目录下,对项目进行编译和运行:

[root@yikuanzz demo-project]# gb build all
hello
[root@yikuanzz demo-project]# bin/hello
Hello gb
[root@yikuanzz demo-project]# tree
/home/demo-project/
├── bin
│   └── hello
└── src
    └── hello
        └── hello.go

Go 数据结构

数组

数组的最大一个特点就是占用的内存是连续分布的。

  • CPU 能将正在使用的数据缓存更久;
  • 容易计算索引,迭代元素。
// 声明一个包含 5 个 int 元素的数组
var array [5]int

数组初始化时,每个元素都为对应类型的零值

如果想要更加快速地创建数组,可以使用数组字面量

// 声明一个包含 5 个 int 元素的数组 并且 初始化每个元素
array := [5]int {10, 20, 30, 40 ,50}

当然,我们可以让 Go 根据元素的数量来确定数组长度

// 容量初始化值的数量决定
array := [...]int {10, 20, 30, 40, 50}

此外,我们也可以指定对应元素的初始化值

// 指定 索引2 和 索引3 的值
array := [5]int {2: 30, 3:40}

除了通过 [] 运算符来访问数组,我们还可以通过指针访问值

// 数组元素为 整型指针
arrary := [5]*int {0: new(int), 1:new(int)}
// 给 索引0 和 索引1 赋值
*array[0] = 10
*array[1] = 20

在 Go 语言中,数组是一个值。所以,数组是可以用在赋值上的,这样就可以复制数组。

// 声明一个包含 5 个字符串的数组
var array1 [5]string
// 声明另一个包含 5 个字符串的数组 并且 初始化
array2 = [5]string {"Red", "Blue", "Green", "Yellow", "Pink"}
// 把 array2 值复制给 array1
array1 = array2

此外,如果复制的是指针数组的话,则是将指针地址的值复制了一遍,它们都指向同一个内容。


多维度数组也是数组中的一个重要内容,它可以很容易地管理具父子关系的数据或者与坐标相关联的数据。

// 声明一个二维整型数组,维度为 4 和 2
var array [4][2]int
// 数组字面量声明
array := [4][2]int {{10, 11}, {20, 21}, {30, 31}, {40, 41}}

在函数间传递数组是一个很大的开销,因为函数变量的传递都是以值的方式传递的,如果变量是数组的话,就意味着会将一整个完整的数组进行复制,这样显然不太好。

因此,用指针来传递数组会是个好的选择。

package main

import "fmt"


func valueCopy(arr [5]int){
	fmt.Printf("[value]arr: %p \n", &arr)
}

func addrCopy(arr *[5]int){
	fmt.Printf("[address]arr: %p \n", arr)
}


func main(){
	array := [5]int {4, 5, 6, 7, 8}
	fmt.Printf("[base]arr: %p \n", &array)
	valueCopy(array)
	addrCopy(&array)
}

输出结果:

[root@yikuanzz ~]# go run hello
[base]arr: 0xc00001e060 
[value]arr: 0xc00001e090 
[address]arr: 0xc00001e060 

切片

切片是一种动态数组,可以按照需要增长大小和缩小。

// 底层数组指针、切片长度、切片允许增长到的元素个数
 ----- ----- -----
| ptr | len | cap |
 ----- ----- -----

切片的创建和初始化

1、make 和 切片字面量。

// 创建字符串切片,长度和容量都是 5
slice := make([]string, 5)
// 创建整型切片,长度为 3 ,容量为 5
slice := make([]int, 3, 5)

我们还可以通过切片字面量来声明切片,但是要注意与数组的声明方式做区分。如果我们在 [] 运算符中指定了

// 创建字符串切片
slice := []string {"Red", "Blue", "Green", "Yellow", "Pink"}
// 创建字符串切片
slice := []string {99: ""}

2、nil 和 空切片。

nil 切片是很常见的创建切片的方法,可以用于很多标准库和内置函数。

// 创建 nil 整型切片
var slice []int
// 使用 make 创建空切片
slice := make([]int, 0)
// 使用切片字面量创建空的整型切片
slice := []int {}

切片的使用:

1、赋值和切片

// 创建整型切片并赋值
slice := []int{10, 20, 30, 40, 50}

切片之所以叫切片,就是创建一个新的切片就是把底层数组切出一部分。

// 创建一个新切片  前闭后开
newSlice := slice[1:3]

我们仔细观察知道 slice 的切片容量是 5,而 newSlice 的切片容量是 4。为什么呢?

还记得切片的三个元素么,除了长度和容量,还有指针。我们对切片再进行切片时,其实是将指针指向原数组的地址,那么之前作为开头的元素地址我们就不知道了。

2、切片增长

// 创建一个整型切片
slice := []int {10, 20, 30, 40, 50}

// 创建一个新切片
newSlice := slice[1:3]

// 使用原容量分配新元素
newSlice = append(newSlice, 60)

当我们使用 append 函数时,如果底层数组有额外容量,那么就修改对应位置上的数;如果底层数组没有可用容量时,append 函数会创建一个新的底层数组,将被引用的现有的值复制到新数组里,再追加新的值。

函数 append 会智能地处理底层数组的容量增长。当切片容量小于 1000 个元素的时候,总是成倍地增加容量。一旦元素超过 1000,容量的增长因子会设为 1.25。当然,具体的增长算法可能会改变。

3、创建切片时的 3 个索引

// 创建字符串切片
source := []string {"Apple", "Orange", "Plum", "Banana", "Grape"}

// 将第三个元素切片,并限制容量
slice := source[2:3:4]

第三个索引的含义是限制新建切片的最大容量。

有一个比较好的用途是,如果切片最大容量为当前切片长度,那么在使用 append 函数时,就能够让其与原有的底层数组分离。

// 创建字符串切片
source := []string {"Apple", "Orange", "Plum", "Banana", "Grape"}

// 将第三个元素切片,并限制容量
slice := source[2:3:3]

// 向 slice 追加新字符串
slice = append(slice, "Kiwi")

此外,append 函数使用 ... 运算符可以将一个切片的所有元素追加到另一个切片里。

// 创建两个切片,并分别用两个整数进行初始化
s1 := []int {1, 2}
s2 := []int {3, 4}

// 将两个切片追加在一起,并显示结果
fmt.Printf("%v \n", append(s1, s2...))

4、迭代切片

Go 语言中也有 range 可以配合 for 来迭代切片里的元素。

// 创建一个整型切片
slice := []int {10, 20, 30, 40}

// 循环迭代
for index, value := range slice{
    fmt.Printf("Index: %d Value: %d\n", index, value)
}

当迭代切片时,range 会返回两个值,第一个是当前迭代到的索引位置,第二个值是该位置对应元素的一份副本。

当然,我们也可以用传统的 for 循环搭配 len 函数 和 cap 函数来进行迭代。


多维切片

// 创建一个整型切片的切片
slice := [][]int {{10}, {100, 200}}

因为切片的结构简单,我们通过 append 去增加新的元素也不会有特别大的开销。

// 为第一个切片追加值为 20 的元素
slice[0] = append(slice[0], 20)

函数间传递切片

因为切片的尺寸很小,在函数间复制和传递切片的成本也很低。

// 分配 100 万个整型值的切片
slice := make([]int, le6)

// 将 slice 传递给函数
slice = foo(slice)

// 函数 foo 接收一个整型切片,并返回这个切片
func foo(slice []int) []int{
    ...
    return slice
}

32位 和 64位 系统在 Windows 下基本数据类型的大小都是一样的。只有指针的大小不一样!32位指针大小为 4byte,而 64位 的指针大小为 8byte。

这样我们知道,在 64 位计算机中传递切片,实际上的开销只有 24 个字节。并且,函数中的切片和函数外的切片都是指向同一个底层数组,所以如果在函数修改了切片中数据,那么外面的切片也会察觉到变化。

映射

map 是一种无序的基于 key-value 的数据结构,Go 语言中的 map 是引用类型,必须初始化才能使用。

映射功能强大的地方在于,能够基于键快速检索数据。


映射是一个集合,所以它可以使用处理数组和切片刀方式迭代映射中的元素,但映射是无序的集合,意味着没办法预测键值对被返回的顺序。

映射最主要的数据结构有两种:哈希查找表(Hash Table)搜索树(Search Tree)

哈希表用到哈希函数计算 key 的哈希值,然后根据哈希值将 key 分配到不同的桶里面。随着映射存储的增加,哈希值分布就越均匀,访问键值的速度就越快,映射通过合理数量的桶来平衡键值对的分布。

在 Go 语言中,我们给映射添加键值对时,首先会将根据哈希函数的计算值分别放在“同类”的桶里面,一个桶里面放 8 个 key,就是说桶里面共有 8 个位置,然后同个桶内的 key 会根据哈希值地高 8 位来决定在桶内的具体位置。

hmap 就是 hashmap 的缩写,bmap 就是“桶”。我们看到 hmap 中的字段,Bbuckets 数组的长度对数,这里为 5 则 buckets 的长度就是 2 的 5 次方,也就是 32 这么大。

// 桶的数据结构
type bmap struct{
    topbits	[8]uint8
    keys	[8]keytype
    values	[8]valuetype
    pad		uintptr
    overflow	uintptr
}

每个 bucket 设计成最多只能放 8 个 key-value 对,如果有第 9 个 key-value 落入当前的 bucket,那就需要再构建一个 bucket ,通过 overflow 指针连接起来。


创建和初始化

// 创建一个映射,键的类型是 string,值的类型是 int
dict := make(map[string]int)

// 创建一个映射,键和值的类型都是 string
// 用两个键值对初始化映射
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

使用映射

// 创建空映射,用来存储颜色以及对应的十六进制代码
colors := map[string]string{}

// 将 Red 的代码加入到映射
colors["Red"] = "#da1337"

当然,我们可以通过声明一个未初始化的映射来创建一个值为 nil 的映射,要知道的是 nil 映射不能用用于存储键值对,否则会产生错误。

// 通过声明创建一个 nil 映射
var colors map[string]string

如果我们想从映射中取值的话有两种方式:

  • 1、获得值的同时,得到一个标志来确定这个键是否存在。
  • 2、只获得值,然后判断这个值是否为零值确定其是否存在。
// 方式1
value, exists := colors["Blue"]
if exists{
    fmt.Println(value)
}

// 方式2
value := colors["Blue"]
if value !=""{
    fmt.Println(value)
}

同切片一样,映射也能够通过 range 关键字来进行迭代。

// 创建一个映射,存储颜色以及颜色对应的十六进制代码
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 函数。

// 删除键为 Coral 的键值对
delete(colors, "Coral")

// 显示映射里面的所有颜色
for key, value := range colors{
    fmt.Printf("Key: %s Value: %s \n", key, value)
}

函数间传递映射

在函数间传递映射并不会重新再建立一个映射副本,它的特性与切片是一样的。就是说,如果在函数中对映射做了修改的话,所有对这个映射的引用都会察觉到这个修改。

func main() {
	// 创建一个映射,存储颜色以及颜色对应的十六进制代码
	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)
 	} 
	// 调用函数来移除指定的键
	removeColor(colors, "Coral")
	// 显示映射里的所有颜色
	for key, value := range colors {
		fmt.Printf("Key: %s Value: %s\n", key, value)
	} 
}

// removeColor 将指定映射里的键删除
func removeColor(colors map[string]string, key string) {
	delete(colors, key)
} 

Go 语言系统

Go 语言是一种静态类型的编程语言,编译器在编译时得知道程序里每个值的类型。

这样,编译器可以确保程序合理地用值,可以减少潜在的内存异常,并使编译器有机会对代码进行性能优化。

实际上值的类型给编译器提供了两部分信息:

  • 1、需要分配多少内存给这个值。
  • 2、这段内存表示什么。

用户定义类型

Go 语言允许用户定义类型,其实就是用 struct 关键字来组合字段进行类型的声明。

// 定义一个用户类型
type user struct{
    name string
    email string
    ext int
    privileged bool
}

用结构体类型声明变量,初始化时会对其中的字段进行零值初始化。

// 声明 usr 类型的变量,且初始化为零值
var bill user

// 声明 user 类型,并初始化所有字段
lisa := user{
    name: "Lisa",
    email: "lisa@email.com",
    ext: 123,
    privileged: true,
}

// 声明 user 类型,并初始化所有字段
john := {"John", "john@eamil.com", 123, true}

方法

方法可以给用户定义的类型添加新的行为。

package main

import "fmt"

// user struct
type user struct{
	name string
	email string
}

// notify: print information of user
func (u user) notify(){
	fmt.Printf("Sending User Email To %s<%s> \n", u.name, u.email)
}

// changeEmail: change email of user
func (u *user) changeEmail(email string){
	u.email = email
}

func main(){
	bill := user{"Bill", "bill@email.com"}
	bill.notify()		// value

	lisa := &user{"Lisa", "lisa@email.com"}
	lisa.notify()		// pointer

	bill.changeEmail("bill@newdomain.com")
	bill.notify()

	lisa.changeEmail("lisa@comcast.com")
	lisa.notify()
}

关键字 func 和函数名之间的参数被称为接受者,它将函数与接受者的类型绑定。

[root@yikuanzz ~]# go run test
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> 

这里补充一下,Go 语言里面有两种类型的接受者:值接收者指针接收者

如果用值接收者,那么参数传递的时候会生成一个副本;如果指针接收者,那么参数传递的时候会操作原地址上的数据。

虽然我们在 notify 方法有用指针来去调用,但是它仍是复制了副本,实际上是:(*lisa).notify() 的样子。

类型的本质

1、内置类型。

内置类型就是语言提供的类型:数值类型、字符串类型和布尔类型。

因为是原始的类型,所以我们把这些类型的值传递给方法或函数时,应该传递一个对应值的副本。

// golang.org/src/strings/strings.go:第 620 行到第 625 行
func Trim(s string, cutset string) string{
    if s == "" || cutset == ""{
        return s
    }
    return TrimFunc(s, makeCutsetFunc(cutset))
}

2、引用类型。

Go 语言的引用类型有:切片、映射、通道、接口和函数类型。

当这些类型被声明时,创建的变量叫 标头(header) 值。

每个引用类型创建的标头值是包含一个指向底层数据结构的指针,每个引用类型还有一些特殊的字段来管理底层数据结构。

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

// golang.org/src/net/ip.go:第 329 行到第 337 行
func (ip IP) MarshalText() ([]byte, error){
    if len(ip) == 0{		// 切片直接复制标头值就可以了
        return []byte(""), nil
    }
    
    if len(ip) != IPv4len && len(ip) != IPv6len{
        return nil, errors.New("Invaild IP address")
    }
    return []byte(ip.String()), nil
}

3、结构类型。

结构类型可以用来描述一组数据值,这组值的本质即可以是原始的,也可以是非原始的。

// golang.org/src/time/time.go:第 39 行到第 55 行
// 原始类型的结构体
type Time struct{
    // sec 给出自公元 1 年 1 月 1 日 00:00:00 开始的秒数
    sec int64
    
    // nsec 指定了一秒内的纳秒偏移,这个值必须在[0, 999999999]范围内
    nsec int32
    
    // loc 用于决定时间对应的当地的分、小时、天和年的值
    loc *Location
}

下面的代码,File 类型的实现使用了一个嵌入的指针,指向一个未公开的类型。这层额外的内嵌类型阻止了复制。

// golang.org/src/os/file_unix.go:第 15 行到第 29 行
// 非原始类型的结构体
// File 表示一个打开的文件描述符
type File struct{
    *file
}
// file 是 *File 的实际表示
type file struct{
    fd int
    name string
    dirinfo *dirInfo		// 除了目录结构,此字段为 nil
    nepipe int32		// Write 操作时遇到连续 EPIPE 的次数
}

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

接口

多态是说代码可以根据类型的具体实现采取不同行为的能力。

如果一个类型实现了某个接口,所有使用这个接口的地方,都可以支持这种类型的值。

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

// Simple "curl"

func init(){
	if len(os.Args) != 2 {
		fmt.Println("Usage: ./nnn <url>")
		os.Exit(-1)
	}
}

func main(){
	// Get response from web server
	r, err := http.Get(os.Args[1])
	if err != nil{
		fmt.Println(err)
		return
	}

	// Copy to Stdout from Body
	io.Copy(os.Stdout, r.Body)
	if err := r.Body.Close(); err != nil{
		fmt.Println(err)
	}
}

io.Copy 函数中的一个参数必须是实现了 io.Writer 接口的值,第二个参数是必须实现了 io.Reader 接口的值。 当我们将 BodyStdout 这两个值传给 io.Copy 函数后,这个函数会把服务器的数据分成小段,源源不断地传给终端窗口,直到最后一个片段读取并写入终端,io.Copy 函数才返回。

package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
)

func main() {
    var b bytes.Buffer
    
    // 将字符串写入 Buffer
    b.Write([]byte("Hello"))
    
    // 用 Fprintf 将字符串拼接到 Buffer
    fmt.Fprintf(&b, "World!")
    
    // 把 Buffer 的内容写到 Stdout
    io.Copy(os.Stdout, &b)
}

接口是用来定义行为的类型,这些被定义的行为不由接口直接实现,而是通过方法由用户定义的类型实现。

接口值是一个两字长度的数据结构:

  • 1、包含了一个指向内部表的指针,iTable 包含值类型和方法集。
  • 2、还有一个指向所存储值的指针。

如果赋值给接口的是指针,那么则会获得其地址上的值 *user


这里稍微解释一下 itab (Interface Table),它是用于实现接口类型和具体类型之间的映射,主要作用是将接口类型和实现该接口的具体类型关联起来,提供一个快速查找机制,以便在运行时进行接口方法的调用。

  • 接口类型断言:当进行接口类型断言时,Go 运行时会使用 itab 来检查具体类型是否实现了接口。
  • 接口方法调用:在调用接口方法时,Go 运行时会通过 itab 查找具体类型的方法实现。
type itab struct{
   inter *interfacetype	// 指向接口类型的定义
   _type *_type		// 指向实现该接口的具体类型的定义
   hash uint32		// 类型的哈希值,用于类型切换
   _ [4]byte
   fun [1]uintptr	// 指向具体类型实现的接口方法函数指针数组	fun[0]=0 表示没有实现
}

假设,我们有一个接口 Speaker 和 一个实现该接口的类型 Dog

type Speaker interface {
    Speak() string
}

type Dog struct{}

func (d Dog) Speak() string{
    return "Woof!"
}

在运行的时候,Go 会为 Dog 类型创建一个 itab ,将 Speaker 接口和 Dog 类型关联起来,并存储 Dog 类型实现的 Speak 方法的指针。


方法集定义了接口的接受规则。

我们以方法接受者的视角来看,如果方法接收者是指针,那么给接口的类型就必须是指针类型。

但是,如果方法接收者是,那么给接口的类型可以是指针类型也可以是值类型。

看到下面这段代码执行就会报错!!

package main

import "fmt"

type People interface{
    Speak(string) string
}

type Student struct{}

func (stu *Student) Speak(think string) (talk string) {
    if think == "no" {
        talk = "I don't like you!"
    } else {
        talk = "Hello, how are you going?"
    }
    return
}

func main() {
    var peo People = Student{}
    think := "bitch"
    fmt.Println(peo.Speak(think))
}
[root@yikuanzz ~]# go vet test3
# test3
vet: go/src/test3/pp.go:21:22: cannot use Student{} (value of type Student) as People value in variable declaration: Student does not implement People (method Speak has pointer receiver)

如果要改正的话,就是将 Student{} 改为 &Student{} 就好了。

现在我们明白了这个规则了,但是为什么会这样呢?

事实上,编译器并不是总能自动获得一个值的地址,所以当我们写的方法参数要求传入的是指针的时候,你传入值就会报错。而当写的方案

我们可以看一下这个例子,它用的是

package main

import "fmt"

// duration is based on int
type duration int

// make duration easzier to read
func (d *duration) pretty() string{
	return fmt.Sprintf("Duartion: %d", *d)
}

func main(){
    // 这样就是不能获取地址的形式
    // 如果赋值给变量,然后对变量调用方法就是可行的
    duration(42).pretty()
}


接下来,我们再看一看多态行为的例子。

package main

import (
	"fmt"
)

// Interface: notifier
type notifier interface{
	notify()
}

// Struct: user
type user struct {
	name string
	email string
}

// pointer method
func (u *user) notify(){
	fmt.Printf("Sending user email to %s<%s>\n",
	u.name,
	u.email)
}

// Struct: admin
type admin struct{
	name string
	email string
}

// pointer method
func (a *admin) notify(){
	fmt.Printf("Sending admin email to %s<%s>\n",
	a.name,
	a.email)
}

func main(){
	bill := user{"Bill", "bill@email.com"}
	sendNotification(&bill)

	lisa := admin{"Lisa", "lisa@email.com"}
	sendNotification(&lisa)

}

func sendNotification(n notifier){		// 多态函数
	n.notify()
}
[root@yikuanzz ~]# go run test5
Sending user email to Bill<bill@email.com>
Sending admin email to Lisa<lisa@email.com>

嵌入类型

Go 语言允许用户扩展或修改已有类型的行为,这个功能是通过 嵌入类型(type embedding) 完成的。

嵌入类型是将已有的类型直接声明在新的结构类型里,被嵌入的类型被称为新的外部类型的内部类型。

package main

import "fmt"

type user struct{
	name string
	email string
}

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

type admin struct{
	user 	// embeding type
	level string
}

func main(){
	// initiate a admin
	ad := admin{
		user : user{
			name: "john smith",
			email: "john@yahoo.com",
		},
		level : "super",
	}

	ad.user.notify()
	ad.notify()		// 内部类型的方法被提升到外部类型
}

[root@yikuanzz ~]# go run test6
Sending user eamil to john smith<john@yahoo.com> 
Sending user eamil to john smith<john@yahoo.com> 

我们对上述例子进行修改添加一个接口:

type notifier interface{
	notify()
}

func sendNotification(n notifier){
	n.notify()
}

main 函数中调用 sendNotification(&ad),这样用于实现接口的内部类型的方法被提升到外部类型。

那么这时候问题来了,如果我们要给 admin 也实现一个方法会怎么样?

func (a *admin) notify(){
	fmt.Printf("Sending admin eamil to %s<%s> \n",
	a.name,
	a.email)
}

我们在 main 函数中执行这些语句:sendNotification(&ad)ad.user.notify()ad.notify()

[root@yikuanzz ~]# go run test6
Sending admin eamil to john smith<john@yahoo.com> 
Sending user eamil to john smith<john@yahoo.com> 
Sending admin eamil to john smith<john@yahoo.com> 

我们发现,只有执行 ad.user.notify() 才会调用内部类型的方法。

所以,我们可以得出的结论是:

  • 当我们只实现了内部类方法的时候,不论何种方式调用的就是内部类方法。
  • 如果我们两个方法都实现了的时候,那么只有调用内部类再调用其实现的方法才会真的调用。

公开或未公开的标识符

如果想设计好的 API,是需要用某种规则来控制声明后的标识符的可见性。

Go 语言支持从包里公开或隐藏标识符,通过这个功能,让用户能按照自己的规划控制标识符的可见性。

其实,如果我们知道私有变量和公有变量的概念就很好理解了,因为就是一个道理。

标识符的名字小写字母开头的,那么这个标识符就是未公开的;如果标识符的名字大写字母开头的,那么这个标识符就是公开的。

我们一般用工厂函数来对未公开的标识符进行操作,其实就是用函数来创建对象,为它添加数性和方法,然后返回这个对象。

// entities
package entities

type user struct{
    Name string
    Email string
}

type Admin struct{
    user		// 嵌入类型未公开
    Rights int
}
// main
package main

import (
	"fmt"
    "entities"
)

func main(){
    a := entities.Admin{
        Right: 10,
    }
    
    // 可设置内部类型公开字段的值
    a.Name = "Bill"
    a.Email = "bill@email.com"
    
    fmt.Printf("User: %v \n", a)
}

因为 user 不是公开的,所以不能直接通过字面量构建;但是内部类型公开的标识符是可以通过外部类型来访问,并进行初始化的。

Go 并发

并发与并行

在开始 Go 语言并发编程之前,我们先简单地了解一些概念:

  • 1、进程和线程:

    • 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
    • 线程是进程的一个执行实例,是 CPU 调度和分派的基本单位。
    • 一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。
  • 2、协程和线程:

    • 协程,独立的栈空间,共享堆空间,调度由用户自己控制。
    • 线程,在一个线程上可以跑多个协程,就是说协程是轻量级的线程。
  • 3、并发和并行:

    • 多线程程序在一个核心的 CPU 上运行,就是并发,并发主要由切换时间片来实现"同时"运行。

    • 多线程程序在多个核心的 CPU 上运行,就是并行,并行是直接利用多核实现多线程的运行。

Go 语言中的并发其实就是能让某个函数独立于其他函数运行的能力。当一个函数创建为 goroutine 时,Go 会将其视为一个独立的工作单元,这个单元会被调度到可用的逻辑处理器上执行。

Go 语言通过运行时的调度器来管理被创建的所有 goroutine 并为其分配执行时间。这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行 goroutine

有时,正在运行的 goroutine 需要执行阻塞的系统调用,比如打开一个文件,这时线程和 goroutine 会从逻辑处理器上分离,并且这个线程会继续阻塞直到系统调用返回。

此时,逻辑处理器就失去了线程,于是调度器会新建一个线程,并将其绑定到逻辑处理器上,然后在从本地运行队列里选择另一个 goroutine 来运行。

一旦被阻塞的系统调用执行完成并返回,对应的 goroutine 会回到本地运行队列,而之前的线程会被维护,以便之后可以继续使用。


如果一个 goroutine 要做一个网络 I/O 调用的话,goroutine 就不用逻辑处理器,而是移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或写操作已就绪,对应的 goroutine 就会重新分配到逻辑处理器上来完成操作。(调度器对可以创建的逻辑处理器的数量没有限制,默认每个程序最多创建 10 000 个线程。)

所以,当 goroutine 并行的时候,就会有多个逻辑处理器,这时调度器会将 goroutine 平等分配到每个逻辑处理器上,这样 goroutine 就会在不同的线程上同时运行。

goroutine

我们看一看 goroutine 的例子,它创建两个协程,以并发的形式分别显示大写和小写的英文字母。

package main

import (
	"fmt"
    "runtime"
    "sync"
)

func main(){
    // 分配一个逻辑处理器给调度器
    runtime.GOMAXPROCS(1)
    
    // wg 等待程序完成
    var wg sync.WaitGroup
    // 等待 2 个 goroutine 完成
    wg.Add(2)
    
    fmt.Println("Start Goroutines")
    
    // 匿名函数 小写
    go func(){
        defer wg.Done()		// 完成计数减1
        
        for count := 0; count < 3; count++{
            for char := 'a' char < 'a'+26; char++{
                fmt.Printf("%c ", char)
            }
        }
    }()
    
    // 匿名函数 大写
    go func(){
        defer wg.Done()		// 完成计数减1
        
        for count := 0; count < 3; count++{
            for char := 'A' char < 'A'+26; char++{
                fmt.Printf("%c ", char)
            }
        }
    }()
    
    fmt.Println("Waiting To Finish")
    wg.Wait()
    
    fmt.Println("Terminating Program")
}

此外,我们知道的是,一个正在运行的 goroutine 在工作结束前,是可以被停止并重新调度的。因为,如果一个 goroutine 在一定时间内没有完成它的任务就会被挂起,然后切换到另一个 goroutine 去执行它的任务。

我们也可以通过给每个核心分配一个逻辑处理器去处理任务。

import "runtime"

// 给每个可用的核心分配一个逻辑处理器
runtime.GOMAXPROCS(runtime.NumCPU())

竞争状态

如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作 竞争状态(race candition)

对于一个共享资源的读写操作必须是原子化的,也就是说同一时刻下,只能有一个 goroutine 对共享资源进行读和写操作。

package main

import (
    "fmt"
    "runtime"
    "sync"
)

var (
    counter int	
    wg sync.WaitGroup
)

func main(){
    wg.Add(2)
    
    go incCounter(1)
    go incCounter(2)
    
    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

func incCounter(id int){
    defer wg.Done()
    
    for count := 0; count < 2; count++{
        value := counter	// 获取 counter 值
        runtime.Gosched()	// 把 goroutine 退出并放回队列
    	value++		// 增加本地 value 值
        counter = value		// 将值保存到 counter
    }	
}

上述代码中,变量 counter 会进行 4 次读和写操作,每个 goroutine 执行两次操作,但是程序终止时,counter 变量的值会变为 2,竞争状态下的程序行为如下。

我们可以用 go build -race 来检测程序中的竞争,当然,为了消除竞争状态,我们可以通过 Go 提供的锁机制来锁住共享资源,来保证 goroutine 的同步状态。

锁住共享资源

如果需要顺序访问一个整型变量或一段代码,atomicsync 包的函数提供了很好的解决方案。

原子函数能以很底层的加锁机制来同步访问整型变量和指针。

package main

import (
	"fmt"
    "runtime"
    "sync"
    "sync/atomic"
)

var (
	counter int64
    wg sync.WaitGroup
)

func main(){
    wg.Add(2)
    
    go incCounter(1)
    go incCounter(2)
    
    wg.Wait()
    
    fmt.Println("Final Counter:" , counter)
}

func incCounter(id int) {
    defer wg.Done()
    
    for count:=0; count < 2; count++{
        // 安全地对 counter 加 1
        atomic.AddInt64(&counter, 1)
        
        runtime.GOsched()
    }
}

我们使用的 AddInt64 函数会同步整型的加法,强制同时只有一个 goroutine 运行并完成这个加法操作,另外的原子函数是 LoadInt64StoreInt64 ,这两个函数提供了一种安全地读和写一个整型值的方式。

package main

import (
	"fmt"
    "sync"
    "sync/atomic"
    "time"
)

var (
	shuntdown int64
    wg sync.WaitGroup
)

func main(){
    wg.Add(2)
    
    go doWork("A")
    go doWork("B")
    
    time.Sleep(1 * time.Second)
    
    fmt.Println("Shutdown Now")
    atomic.StoreInt64(&shutdown, 1)
    
    wg.Wait()
}

func doWork(name string){
    defer wg.Done()
    
    for {
        fmt.Printf("Doing %s Work \n", name)
        time.Sleep(250 * time.Millisecond)
        
        if atmoic.LoadInt64(&shutdown) == 1{
            fmt.Printf("Shutting %s Down \n", name)
            break
        }
    }
}

另一种同步访问共享资源的方式是使用 互斥锁(mutex)。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界区代码。

package main

import (
	"fmt"
    "runtime"
    "sync"
)

var (
	counter int
    wg sync.WaitGroup
    mutex sync.Mutex
)

func main(){
    wg.Add(2)
    
    go incCounter(1)
    go incCounter(2)
    
    wg.Wait()
    fmt.Printf("Final Counter: %d\\n", counter)
}

func incCounter(id int){
    defer wg.done()
    
    for count := 0; count < 2; count++{
        mutex.Lock(){
            value := counter
            runtime.Gosched()
            value++
            counter = value
        }
        
        mutex.Unlock()
    }
}

通道

在 Go 中,除了使用原子函数和互斥锁来保证对共享资源的安全访问以及消除竞争状态,还可以使用通道,通过发送和接收需要共享的资源,在 goroutine 之间做同步。

声明通道时,需要指定将要被共享的数据的类型。

// 创建一个无缓冲的整型通道
unbuffered := make(chan int)

// 创建一个有缓冲的通道
buffered := make(chan string 10)

// 通过通道发送一个字符串
buffered <- "Gopher"

// 从通道接收一个字符串
value := <- buffered

无缓冲通道(unbuffered channel) 是指在接收前没有能力保存任何值的通道,它要求发送和接收的 goroutine 必须是准备好的,因此它们这中交互的行为就是同步的,如果没准备好,那么另一方就会阻塞等待。

我们用两个 goroutine 来模拟网球比赛,并用无缓冲的通道来模拟球的来回。

package main  
  
import (  
    "fmt"  
    "math/rand"    
    "sync"
)  
  
var wg sync.WaitGroup  
  
func main() {  
    court := make(chan int)  
  
    wg.Add(2)  
  
    go player("Nadal", court)  
    go player("Djokovic", court)  
  
    // 发球  
    court <- 1  
    wg.Wait()  
}  
  
func player(name string, court chan int) {  
    defer wg.Done()  
  
    for {  
       ball, ok := <-court  
       if !ok {  
          fmt.Printf("Player %s Won\n", name)  
          return  
       }  
  
       // 选择随机数判断是否没接到球  
       n := rand.Intn(100)  
       if n%13 == 0 {  
          fmt.Printf("Player %s Missed\n", name)  
          // 关闭管道表示认输  
          close(court)  
          return  
       }  
       // 显示击球数  
       fmt.Printf("Player %s Hit %d \n", name, ball)  
       ball++  
  
       // 将球打向对手  
       court <- ball  
    }  
}

下面是另一个例子,在 goroutine 之间同步数据,来模拟接力比赛。在接力比赛中,4 个跑步者围绕赛道轮流跑步,第二个、第三个和第四个跑步者要接到前一位跑步者的接力棒后才能起跑。比赛中最重要的部分是要传递接力棒,要求同步传递。

package main  
  
import (  
    "fmt"  
    "sync"    "time")  
  
var wg sync.WaitGroup  
  
func main() {  
    baton := make(chan int)  
  
    // 为在跑的跑步者计数  
    wg.Add(1)  
    // 跑步者持有接力棒  
    go Runner(baton)  
    // 开始比赛  
    baton <- 1  
  
    wg.Wait()  
}  
  
func Runner(baton chan int) {  
    var newRunner int  
  
    runner := <-baton  
  
    fmt.Printf("Runner %d Running With Baton\n", runner)  
    if runner != 4 {  
       newRunner = runner + 1  
       fmt.Printf("Runner %d To The Line\n", runner)  
       go Runner(baton)  
    }  
  
    time.Sleep(500 * time.Millisecond)  
  
    if runner == 4 {  
       fmt.Printf("Runner %d Finished, Race Over\n", runner)  
       wg.Done()  
       return  
    }  
  
    fmt.Printf("Runner %d Exchange With Runner %d \n", runner, newRunner)  
    baton <- newRunner  
}

有缓冲通道(buffered channel) 是一种在被接受前能存储一个或者多个值的通道。

我们看下用有缓冲通道来管理一组 goroutine 的例子。

package main  
  
import (  
    "fmt"  
    "math/rand"    
    "sync"    
    "time"
)  
  
const (  
    numberGoroutines = 4  
    taskLoad         = 10  
)  
  
var wg sync.WaitGroup  
  
func main() {  
    tasks := make(chan string, taskLoad)  
  
    wg.Add(numberGoroutines)  
  
    // 创建线程等待接受管道发送的信息  
    for g := 1; g <= numberGoroutines; g++ {  
       go worker(tasks, g)  
    }  
  
    // 发派任务给到携程执行  
    for post := 1; post <= taskLoad; post++ {  
       tasks <- fmt.Sprintf("Task: %d", post)  
    }  
  
    close(tasks)  
    wg.Wait()  
}  
  
func worker(tasks chan string, worker int) {  
    defer wg.Done()  
  
    for {  
       task, ok := <-tasks  
       if !ok {  
          fmt.Printf("Worker: %d : Shutting Down\n", worker)  
          return  
       }  
  
       fmt.Printf("Worker: %d : Started %s\n", worker, task)  
  
       sleep := rand.Int63n(100)  
       time.Sleep(time.Duration(sleep) * time.Millisecond)  
       fmt.Printf("Worker: %d : Completed %s\n", worker, task)  
    }  
}

Go 并发模式

runner

runner 包用于展示如何使用管道来监视程序的执行时间,如果程序运行时间太长,可以通过 runner 包来终止程序,通常使用在需要调度后台进行任务处理的程序。

简单看一下 runner 包中的 runner.go 代码。

package runner

import (
	"errors"
	"os"
	"os/signal"
	"time"
)

/*
	Runner 在给定超时时间内执行一组任务
	并在操作系统发送中断信号时结束任务
*/
type Runner struct{
	// 中断信号
	interrupt chan os.Signal

	// 完成信号
	complete chan error

	// 超时信号
	timeout <- chan time.Time

	// 持有任务
	tasks []func(int)
}

var ErrTimeout = erros.New("received timeout")

var ErrInterrupt = errors.New("received interupt")

// 返回一个新的准备使用的 Runner
func New(d time.Duration) *Runner{
	return &Runner{
		interrupt: make(chan os.Signal, 1),
		complete: make(chan error),
		timeout: time.After(d),
	}
}

// 可将多个任务附加到 Runner 上
func (r *Runner) Add(tasks ...func(int)){
	r.tasks = append(r.tasks, tasks...)
}

// 执行所有任务并监视通道事件
func (r *Runner) Start() error{
	// 接收所有中断信号
	signal.Notify(r.interrupt, os.Interrupt)

	// 启动协程去执行任务
	go func(){
		r.complete <- r.run()
	}()	

	select{
	case err := <- r.complete:
		return err
	case <- r.timeout
		return ErrTimeout
	}
}

// 执行已注册任务
func (r *Runner) run() error{
	for id, task := range r.tasks{
		if r.gotInterrupt(){
			return ErrInterrupt
		}
		task(id)
	}
}

// 验证是否收到了中断信号
func (r *Runner) gotInterrupt() bool{
	select{
	case <- r.interrupt:
		// 停止接收后续的任何信号
		signal.Stop(r.interrupt)
		return true
	default:
		return false
	}
}

我们可以看到设计上的一些亮点:

  • 程序可以在分配的时间内完成工作,正常终止;
  • 程序没有及时完成工作,“自杀”;
  • 接收到操作系统发送的中断事件,程序立刻试图清理状态并停止工作。

现在,我们再看一下 main.go 代码文件中的测试程序。

package main

import (
	"log"
	"time"
	"github.com/goinaction/code/chapter7/patterns/runner"
)

const timeout = 3 * time.Second

func main(){
	log.Println("Starting work.")

	// 给任务分配超时时间
	r := runner.New(timeout)

	// 加入要执行的任务
	r.Add(createTask(), createTask(), createTask())

	// 执行任务并处理结果
	if err := r.Start(); err != nil{
		switch err{
		case runner.ErrTimeout:
			log.Println("Terminating due to timeout.")
			os.Exit(1)
		case runner.ErrInteruot:
			log.Println("Terminating due to interrupt.")
			os.Exit(2)
		}
	}
	log.Println("Process ended.")
}

func createTask() func(int){
	return func(id int){
		log.Printf("Processor - Task #%d.", id)
		time.Sleep(time.Duration(id) * time.Second)
	}
}

pool

这个包会展示如何用有缓冲的通道实现资源池,来管理可以在任意数量的 goroutine 之间共享及独立使用的资源。如果 goroutine 需要从池里得到这些资源中的一个,它可以中池里申请,使用完归还到资源池里。当然,这里推荐去使用 sync.Pool

package pool

import (
	"errors"
	"log"
	"io"
	"sync"
)

type Pool struct{
	m  sync.Mutex
	resources  chan io.Closer
	factory  func() (io.Closer, error)
	closed  bool
}

// 表示请求了一个已关闭的池
var ErrPoolClosed = errors.New("Pool has been closed.")

// 创建一个用来管理资源的池,需要用来分配资源的函数
func New(fn func() (io.Closer, error), size uint) (*Pool, error){
	if size <= 0{
		return nil, error.New("Size value too small.")
	}

	return &Pool{
		factory: fn,
		resources: make(chan io.Closer, size),
	}, nil
}

// 从池中获取一个资源
func (p *Pool) Acquire() (io.Closer, error){
	select{
	// 检查是否有空闲资源
	case r, ok := <- p.resources:
		log.Println("Acquire:", "Shared Resource")
		if !ok{
			return nil, ErrPoolClosed
		}
		return r, nil
	
	// 因为没有空闲资,即提供新的资源
	default:
		log.Println("Acquire:", "New Resource")
		return p.factory()
	}
}

// 将一个使用后的资源放回池里
func (p *Pool) Release(r io.Closer){
	p.m.Lock()
	defer p.m.Unlock()
	// 如果池关闭了,就销毁这个资源
	if p.closed{
		r.Close()
		return
	}

	select{
	// 尝试将资源放入队列
	case p.resources <- r:
		log.Println("Release:", "In Queue")

	// 队列满了就关闭这个资源
	default:
		log.Println("Release:", "Closing")
		r.Close()
	}
}

// 让资源池停止工作,并关闭所有现有的资源
func (p *Pool) Close(){
	p.m.Lock()
	defer p.m.Unlock()

	if p.closed{
		return
	}

	p.closed = true

	// 清空管道资源前先关闭管道
	close(p.resources)

	// 关闭资源
	for r := range p.resources{
		r.Close()
	}
}

现在,我们再看一下 main.go 代码文件中的测试程序。

package main

import(
	"log"
	"io"
	"math/rand"
	"sync"
	"sync/atomic"
	"time"
	"github.com/goinaction/code/chapter7/patterns/pool"
)

const(
	maxGoroutines = 25   // 要用的协程数
	pooledResources = 2    // 池中资源数量
)

type dbConnection struct{ // 模拟要共享的资源
	ID int32
}

func (dbConn *dbConnection) Close() error{
	log.Println("Close: Connection", dbConn.ID)
	return nil
}

var idCounter int32   // 给每个连接分配 ID

func createConnection() (io.Closer, error){
	id := atomic.AddInt32(&idCounter, 1)
	log.Println("Create: New Connection", id)
	return &dbConnection{id}, nil
}

func main(){
	var wg sync.WaitGroup
	wg.Add(maxGroutines)

	// 创建管理连接的池
	p, err := pool.New(createConnection, pooledResources)
	if err != nil{
		log.Println(err)
	}

	// 使用池中的连接完成查询
	for query := 0; query < maxGoroutines; query++{
		go func(q int){
			performQueries(q, p)
			wg.Done()
		}(query)
	}
	wg.Wait()
}

// 测试连接的资源池
func performQueries(query int, p *pool.Pool){
	// 从池中请求一个连接
	conn, err := p.Acquire()
	if err != nil{
		log.Println(err)
		return
	}

	// 将连接释放回池里
	defer p.Release(conn)

	// 用等待来模拟查询响应
	time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
	log.Printf("QID[%d] CID[%d]\n", query, conn.(*dbConnection).ID)
}

work

这个包展示的是用无缓冲通道来创建 goroutine 池,这些 goroutine 执行并控制一组工作,让其并发执行。只需要用无缓冲通道来保证两个 goroutine 之间的数据交换就可以了。

package work

import "sync"

type Worker interface{
	Task()
}

// 提供 goroutine 池用于完成任何已提交的 Worker 任务
type Pool struct{
	work chan Worker
	wg sync.WaitGroup
}

// 创建一个新工作池
func New(maxGoroutines int) *Pool{
	p := Pool{
		work: make(chan Worker),
	}

	p.Wg.Add(maxGoroutines)
	for i := 0; i < maxGoroutines; i++{
		go func(){
			for w := range p.work{
				w.Task()
			}
			p.wg.Done()
		}()
	}
	return &p
}

// 提交工作到工作池
func (p *Pool) Run(w Worker){
	p.work <- w
}

// 等待所有协程停止工作
func (p *Pool) Shutdown(){
	close(p.work)
	p.wg.Wait()
}

现在,我们再看一下 main.go 代码文件中的测试程序。

package main

import (
	"log"
	"sync"
	"time"
	"github.com/goinaction/code/chapter7/pattern/work"
)

var names = []string{
	"steve",
	"bob",
	"mary",
	"therese",
	"jason",
}

type namePrinter struct{
	name string
}

// 实现 Worker 接口
func (m *namePrinter) Task(){
	log.Println(m.name)
	time.Sleep(time.Second)
}

func main(){
	p := work.New(2)

	var wg sync.WaitGroup
	wg.Add(100 * len(names))

	for i := 0; i < 100; i++{
		for _, name := range names{
			np := namePrinter{
				name: name,
			}
			go func(){
				p.Run(&np)
				wg.Done()
			}
		}
	}
	wg.Wait()
	p.Shutdown()
}

Go 标准库

标准库作为 Go 发布包的一部分,它们的源代码是经过预编译的,这些预编译后的文件称作归档文件(archive file),拓展名是 .a

这些文件是特殊的 Go 静态库文件,由 Go 的构建工具创建,并在编译和链接最终程序时被使用。归档文件可以让构建的速度更快,但是没办法指定文件。

日志记录

记录日志的目的是跟踪程序什么时候在什么位置做了什么,这就需要通过某些配置在每个日志项上要写的一些信息。

一个日志项可能会包含前缀、日期时间戳、记录该项的源文件、源文件日志所在行和日志信息。

package main  
  
import (  
    "log"  
)  
  
func init() {  
    log.SetPrefix("TRACE: ")  
    log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile)  
}  
  
func main() {  
    // Println 写入到标准日志记录器  
    log.Println("Hello World")  
  
    // Fatalln() = Println() + os.Exit(1)  
    log.Fatalln("Fatal Message")  
  
    // Panicln() = Println() + panic()  
    log.Panicln("Panic Message")  
}

log.SetFlags 函数中我们设置了一些标志,用来控制写到每个日志项的其他信息,其中用 iota 让每个标志代表二进制数中的某位,然后通过检查位数来进行日志项记录的设置的。

// log.go
const(
	// 日期: 2009/01/23
	Ldate = 1 << iota

	// 时间:01:23:23
	Ltime

	// 毫秒级时间: 01:23:23.123123
	Limicroseconds

	// 完整路径的文件名和行号:d.go:23
	Lshortfile

	// 标准日志记录器初始值
	LstdFlags = Ldate | Ltime
)

此外 log 包的优点是它允许多个 goroutine 同时调用来自同一个日志记录器的函数,不会有写冲突。

如果我们想定制一个日志及录器,就要创建一个 Logger 类型值,给日志记录器配置单独的目的地,并独立设置前缀和标志。

package main

import (
	"io"
	"log"
	"os"
)

var (
	Trace   *log.Logger // 记录所有日志
	Info    *log.Logger // 重要信息
	Warning *log.Logger // 需要注意的信息
	Error   *log.Logger // 非常严重的问题
)

func init() {
	file, err := os.OpenFile("./static/error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
	if err != nil {
		log.Fatalln("Failed to open error log file:", err)
	}

	Trace = log.New(io.Discard, "TRACE: ", log.Ldate|log.Ltime|log.Lshortfile)
	Info = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
	Warning = log.New(os.Stdout, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile)
	Error = log.New(io.MultiWriter(file, os.Stderr), "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)

}

func main() {
	Trace.Println("I have something standard to say")
	Info.Println("Special Information")
	Warning.Println("There is something you need to know about")
	Error.Println("Something has failed")
}

这里,我们看到 New 函数,它负责创建一个新的 Logger。

func New(out io.Writer, prefix string, flag int) *Logger{
	return &Logger{out: out, prefix: prefix, flag: flag}
}

编码/解码

在今天,JSON 远比 XML 流行。这主要是因为与 XML 相比,使用 JSON 需要处理的标签更少。而这就意味着网络传输时每个消息的数据更少,从而提升整个系统的性能。而且,JSON 可以转换为 BSON(Binary JavaScript Object Notation,二进制 JavaScript 对象标记),进一步缩小每个消息的数据长度。

处理 JSON 就先要了解 json 包中的 NewDecoder 函数以及 Decode 方法。

[
  {
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
  },
  {
    "userId": 1,
    "id": 2,
    "title": "quis ut nam facilis et officia qui",
    "completed": false
  },
  {
    "userId": 1,
    "id": 3,
    "title": "fugiat veniam minus",
    "completed": false
  }]

假设我们需要对上述 JSON 数据进行解析,那么我们需要有一个结构体来存储 JSON 数据的映射。

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
)

// 定义一个结构体来表示 JSON 数据
type ResponseData struct {
	UserID    int    `json:"userId"`
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func main() {
	// 发送 HTTP GET 请求
	resp, err := http.Get("https://jsonplaceholder.typicode.com/todos")
	if err != nil {
		log.Fatalf("Failed to send request: %v", err)
	}
	defer resp.Body.Close()

	// 将 JSON 响应解码到结构类型
	var data []ResponseData
	err = json.NewDecoder(resp.Body).Decode(&data)
	if err != nil {
		log.Fatalf("Failed to decode response: %v", err)
		return
	}

	// 输出解析后的数据
	fmt.Printf("Parsed Response: %+v\n", data)
}

我们在每个结构体后用单引号声明了一个字符串,这些字符串被称作标签(tag),是提供每个字段的元信息的一种机制。

func NewDecoder(r io.Reader) *Decoder

func (dec *Decoder) Decode(v interface{}) error

函数 NewDecoder 接受一个实现了 io.Reader 接口类型的值作为参数,并返回一个指向 Decoder 类型的指针类型。由于 Go 支持复合语句调用,可以直接调用从 NewDecoder 函数返回的值的 Decode 方法。

其中,Decode 方法通过反射获取传入值的类型,自动将 JSON 解码为对应类型的值。我们传入的是 指向 ResponseData 的地址,该地址为 nil,在方法调用后这个指针会被赋值一个 ResponseData 类型的值,并根据解码后的 JSON 文档做初始化。

有时,JSON 文档以字符串存在的时候,就需要将 string 转为 byte 切片,并用 json 包的 Unmarshal 函数进行反序列的处理。

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

// Contact 结构代表我们的 JSON 字符串
type Contact struct {
	Name    string `json:"name"`
	Title   string `json:"title"`
	Contact struct {
		Home string `json:"home"`
		Cell string `json:"cell"`
	} `json:"contact"`
}

// JSON 包含用于反序列化的演示字符串
var JSON = `{
	 "name": "Gopher",
	 "title": "programmer",
	 "contact": {
		 "home": "415.333.3333",
		 "cell": "415.555.5555"
	 }
 }`

func main() {
	// 将 JSON 字符串反序列化到变量
	var c Contact
	err := json.Unmarshal([]byte(JSON), &c)
	if err != nil {
		log.Println("ERROR:", err)
		return
	}

	fmt.Println(c)
}

有时,无法为 JSON 的格式声明一个结构体类型,可以将 JSON 文档解码到一个 map 变量中。

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

// JSON 包含要反序列化的样例字符串
var JSON = `{
 	"name": "Gopher",
 	"title": "programmer",
 	"contact": { 
 		"home": "415.333.3333",
 		"cell": "415.555.5555"
 } 
}`

func main() {
	// 将 JSON 字符串反序列化到 map 变量
	var c map[string]interface{}
	err := json.Unmarshal([]byte(JSON), &c)
	if err != nil {
		log.Println("ERROR:", err)
		return
	}

	fmt.Println("Name:", c["name"])
	fmt.Println("Title:", c["title"])
	fmt.Println("Contact")
	fmt.Println("H:", c["contact"].(map[string]interface{})["home"])
	fmt.Println("C:", c["contact"].(map[string]interface{})["cell"])
}

我们接下来要用的就是 json 包的 MarshalIndent 函数进行编码,这个函数可以方便地将 GO 语言的 map 类型的值或结构类型的值转换为易读格式的 JSON 文档。

序列化(marshal) 是指将数据转换为 JSON 字符串的过程。

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

func main() {
	// 创建一个保存键值对的映射
	c := make(map[string]interface{})
	c["name"] = "Gopher"
	c["title"] = "programmer"
	c["contact"] = map[string]interface{}{
		"home": "415.333.3333",
		"cell": "415.555.5555",
	}

	// 将这个映射序列化到 JSON 字符串
	data, err := json.MarshalIndent(c, "", " ")
	if err != nil {
		log.Println("ERROR:", err)
		return
	}

	fmt.Println(string(data))
}

输入和输出

类 UNIX 的操作系统如此伟大的一个原因是,一个程序的输出可以是另一个程序的输入这一理念。依照这个哲学,这类操作系统创建了一系列的简单程序,每个程序只做一件事,并把这件事做得非常好。之后,将这些程序组合在一起,可以创建一些脚本做一些很惊艳的事情。这些程序使用 stdin 和 stdout 设备作为通道,在进程之间传递数据。

与 stdout 和 stdin 对应,这个包含有 io.Writer 和 io.Reader 两个接口。所有实现了这两个接口的类型的值,都可以使用 io 包提供的所有功能,也可以用于其他包里接受这两个接口的函数以及方法。这是用接口类型来构造函数和 API 最美妙的地方。

我们先看一下 io.Writer 接口的声明,它会返回写入的字节数。

type Writer interface{
	Wrtie(p []byte) (n int, err error)
}

再看一下 io.Reader 接口的声明,它会返回读入的字节数,如果没有读到值,它总是返回一个错误。

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

import (
	"bytes"
	"fmt"
	"os"
)

func main() {
	// 使用 io.Writer 实现的 write 方法写入数据
	var b bytes.Buffer
	b.Write([]byte("Hello "))

	// 将字符串拼接到 Buffer 中
	fmt.Fprintf(&b, "Yikuanzz!")

	// 将 os.File 值的地址作为 io.Writer 类型值传入
	b.WriteTo(os.Stdout)
}

我们看到 Buffer 的类型声明,它实现了 Wrtite 方法。

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
}

我们可以看一下打开的三种标准文件。

var ( 
	Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
	Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
	Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
) 

其中,NewFile 返回一个具有给定的文件描述符和名字的新 File。

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
}

在 Linux 和 MacOS 系统里可以找到一个名为 curl 的命令行工具,这个工具可以对指定 URL 发起 HTTP 请求,并保存返回的内容。通过 http、io 和 os 包,我们可以实现一个自己的 curl 工具。

package main

import (
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	r, err := http.Get(os.Args[1])
	if err != nil {
		log.Fatal(err)
	}

	file, err := os.Create(os.Args[2])
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	dest := io.MultiWriter(file, os.Stdout)
	io.Copy(dest, r.Body)
	if err := r.Body.Close(); err != nil {
		log.Fatal(err)
	}
}

Go 测试和性能

作为一名合格的开发者,不应该在程序开发完之后才开始写测试代码。使用 Go 语言的测试框架,可以在开发的过程中就进行单元测试和基准测试。和 go build 命令类似,go test 命令可以用来执行写好的测试代码,需要做的就是遵守一些规则来写测试。而且,可以将测试无缝地集成到代码工程和持续集成系统里。

单元测试

单元测试是用来测试包或者程序的一部分代码或者一组代码的函数。测试的目的是确认目标代码在给定的场景下,有没有按照期望工作。

一个场景是正向路经测试,就是在正常执行的情况下,保证代码不产生错误的测试。这种测试可以用来确认代码可以成功地向数据库中插入一条工作记录。

另外一些单元测试可能会测试负向路径的场景,保证代码不仅会产生错误,而且是预期的错误。这种场景下的测试可能是对数据库进行查询时没有找到任何结果,或者对数据库做了无效的更新。

在 Go 语言里有几种方法写单元测试:

  • 基础测试(basic test) 只使用一组参数和结果来测试一段代码。
  • 表组测试(table test) 也会测试一段代码,但是会使用多组参数和结果进行测试。
  • 模仿(mock) 测试代码需要使用到的外部资源,如数据库或者网络服务器。这有助于让测试在没有所需的外部资源可用的时候,模拟这些资源的行为使测试正常进行。

最后,在构建自己的网络服务时,有几种方法可以在不运行服务的情况下,调用服务的功能进行测试。

我们先看一看单元测试的例子。

package mytest

import (
	"net/http"
	"testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// Test Get()
func TestDownload(t *testing.T) {
	url := "https://filesampleshub.com/download/document/txt/sample3.txt"
	statusCode := 200

	t.Log("Given the need to test the download function.")
	{
		t.Logf("\tWhen checking \"%s\" for status code \"%d\"", url, statusCode)
		{
			resp, err := http.Get(url)
			if err != nil {
				t.Fatal("\t\tShould be able to make the Get call.", ballotX, err)
			}
			t.Log("\t\tShould be able to make the Get call.", checkMark)

			defer resp.Body.Close()

			if resp.StatusCode == statusCode {
				t.Logf("\t\tShould receive a \"%d\" status. %v", statusCode, checkMark)
			} else {
				t.Errorf("\t\tShould receive a \"%d\" status. %v %v", statusCode, ballotX, resp.StatusCode)
			}
		}
	}
}

Go 语言的测试工具只会认为以 _test.go 结尾的文件是测试文件,如果测试工具找到了测试文件,就会查找里面的测试函数并执行。

其中,checkMar="\u2713"ballotX="\u2717" 是测试输出时会用到的对号(√)和叉号(×)。

func TestDownload(t *testing.T)

测试函数名字是 TestDownload,一个测试函数必须是公开的函数,并且以 Test 单词开头,而且函数签名必须接收一个指向 testing.T 类型的指针,并且不返回任何值。

该指针提供的机制可以报告每个测试的输出和状态,测试的输出需要使用完整易读的语句,来记录为什么需要这个测试,具体测试了什么,以及测试的结果是什么。

在测试代码中,声明了两个变量分别是 urlstatusCode 也就是要测试的 URL 地址和期望从响应重返回的状态,用 t.Log 来输出测试信息。

	url := "https://filesampleshub.com/download/document/txt/sample3.txt"
	statusCode := 200

	t.Log("Given the need to test the download function.")

每个测试函数都应该通过解释这个测试的给定要求(given need),来说明为什么应该存在这个测试。对这个例子来说,给定要求是测试能否成功下载数据。声明了测试的给定要求后,测试应该说明被测试的代码应该在什么情况下被执行,以及如何执行。

t.Logf("\tWhen checking \"%s\" for status code \"%d\"", url, statusCode)

上述代码是执行条件的说明,它特别说明了要测试的值。

我们会通过 http.Get 方法对测试的 URL 发送请求,在响应返回时,会检查错误值,判断调用是否成功。如果调用失败,除了结果还会输出叉号以及得到的错误值,如果测试成功就输出对号。

resp, err := http.Get(url)
			if err != nil {
				t.Fatal("\t\tShould be able to make the Get call.", ballotX, err)
			}
			t.Log("\t\tShould be able to make the Get call.", checkMark)

			defer resp.Body.Close()

其中,在错误的那行,我们使用了 t.Fatal 方法,它不单报告这个单元测试失败,而且会向测试输出写一些信息,而后立刻停止这个测试函数的执行。

如果除了这个测试函数外,还有其他没有执行的测试函数,就会执行其他测试函数。

			if resp.StatusCode == statusCode {
				t.Logf("\t\tShould receive a \"%d\" status. %v", statusCode, checkMark)
			} else {
				t.Errorf("\t\tShould receive a \"%d\" status. %v %v", statusCode, ballotX, resp.StatusCode)
			}

此外,测试状态码时的错误,我们用的是 t.Errorf 犯法,它不会停止当前测试函数的执行。所以,如果测试函数执行时,没有调用过 t.Fatalt.Error 方法,就会认为测试通过了。

表组测试,如果测试可以接受一组不同的输入并产生不同的输出的代码,那么应该使用表组测试的方法进行测试。

表组测试除了会有一组不同的输入值和期望结果外,其余部分都很像基础单元测试,测试会依次迭代不同的值,来运行要测试的代码。

package mytest

import (
	"net/http"
	"testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

func TestDownload(t *testing.T) {
	var urls = []struct {
		url        string
		statusCode int
	}{
		{
			"https://filesampleshub.com/download/document/txt/sample1.txt",
			http.StatusOK,
		},
		{
			"https://filesampleshub.com/download/document/txt/sample2.txt",
			http.StatusOK,
		},
		{
			"https://filesampleshub.com/download/document/txt/sample.txt",
			http.StatusNotFound,
		},
	}

	t.Log("Given the need to test downloading different content.")
	{
		for _, url := range urls {
			t.Logf("\tWhen checking %q for status code %d", url.url, url.statusCode)
			{
				resp, err := http.Get(url.url)
				if err != nil {
					t.Fatal("\t\tShould be able to Get the url.", ballotX, err)
				}
				t.Log("\t\tShould be able to Get the URL.", checkMark)
				defer resp.Body.Close()

				if resp.StatusCode == url.statusCode {
					t.Logf("\t\tShoudld have a \"%d\" status. %v", url.statusCode, checkMark)
				} else {
					t.Errorf("\t\tShould have a \"%d\" status. %v %v", url.statusCode, ballotX, resp.StatusCode)
				}
			}

		}
	}
}

其实我们的测试单元还存在一些瑕疵,就是这些测试都需要访问互联网,才能保证测试运行成功,如果没有互联网连接,运行基础单元测试会测试失败。

为了修正这个问题,标准库包含名为 httptest 的包,它让开发人员可以模仿基于 HTTP 的网络调用。模仿(mocking) 是一个很常用的技术手段,用来在运行测试时模拟访问不可用的资源。

package mocktest

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// feed 模仿了我们期望接收的 XML 文档
var feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<title>Going Go Programming</title>
<description>Golang : https://github.com/goinggo</description>
<link>http://www.goinggo.net/</link>
<item>
<pubDate>Sun, 15 Mar 2015 15:04:00 +0000</pubDate>
<title>Object Oriented Programming Mechanics</title>
<description>Go is an object oriented language.</description>
<link>http://www.goinggo.net/2015/03/object-oriented</link>
</item>
</channel>
</rss>`

// mockServer 返回用来处理请求的服务器指针
func mockServer() *httptest.Server {
	f := func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		w.Header().Set("Content-Type", "application/rss+xml")
		fmt.Fprintf(w, feed)
	}
	return httptest.NewServer(http.HandlerFunc(f))
}

mockServer 函数,返回一个指向 httptest.Server 类型的指针,代码中声明了一个匿名函数其签名符合 http.HandlerFunc 函数类型。

type HandlerFunc func(ResponseWriter, *Request)

/*
	HandlerFunc 类型是一个适配器,运行常规函数作为 HTTP 的处理函数函数使用。如果函数 f 具有合适的签名,HandlerFunc(f) 就是一个处理 HTTP 请求的 Handler 对象,内部通过调用 f 处理请求。
*/

现在,我们看一下模仿服务器与基础单元测试是怎么整合在一起的,以及如何将 http.Get 请求发送到模仿服务器。

package mocktest

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
)

const checkMark = "\u2713"
const ballotX = "\u2717"

// feed 模仿了我们期望接收的 XML 文档
var feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
<title>Going Go Programming</title>
<description>Golang : https://github.com/goinggo</description>
<link>http://www.goinggo.net/</link>
<item>
<pubDate>Sun, 15 Mar 2015 15:04:00 +0000</pubDate>
<title>Object Oriented Programming Mechanics</title>
<description>Go is an object oriented language.</description>
<link>http://www.goinggo.net/2015/03/object-oriented</link>
</item>
</channel>
</rss>`

// mockServer 返回用来处理请求的服务器指针
func mockServer() *httptest.Server {
	f := func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		w.Header().Set("Content-Type", "application/rss+xml")
		fmt.Fprintf(w, feed)
	}
	return httptest.NewServer(http.HandlerFunc(f))
}

func TestDownload(t *testing.T) {
	statusCode := http.StatusOK
	server := mockServer()
	defer server.Close()

	t.Log("Given the need to test downloading content.")
	{
		t.Logf("\tWhen checking \"%s\" for status code \"%d\"", server.URL, statusCode)
		{
			resp, err := http.Get(server.URL)
			if err != nil {
				t.Fatal("\t\tShould be able to make the Get call.", ballotX, err)
			}
			t.Log("\t\tShould be able to make the Get call.", checkMark)

			defer resp.Body.Close()

			if resp.StatusCode != statusCode {
				t.Fatalf("\t\tShould receive a \"%d\" status. %v %v", statusCode, ballotX, resp.StatusCode)
			}
			t.Logf("\t\tShould receive a \"%d\" status. %v", statusCode, checkMark)
		}
	}
}

服务端点(endpoint) 是指与服务宿主信息无关,用来分辨某个服务的地址,一般是不包含宿主的一个路径。

如果在构造网络 API,你会希望直接测试自己的服务的所有服务端点,而不用启动整个网络服务,包 httptest 提供了可以做到这一点的机制。

假设我们实现了如下的一个简单的网络服务。

package main

import (
	"log"
	"net/http"

	"github.com/goinaction/code/chapter9/listing17/handlers"
)

// main 是应用程序的入口
func main() {
	handlers.Routes()

	log.Println("listener : Started : Listening on :4000")
	http.ListenAndServe(":4000", nil)
}

代码调用了内部 handlers 包的 Routes 函数,它为托管的网络服务设置了一个服务端点。

// handlers 包提供了用于网络服务的服务端点
package handlers

import (
	"encoding/json"
	"net/http"
)

// Routes 为网络服务设置路由
func Routes() {
	http.HandleFunc("/sendjson", SendJSON)
}

// SendJSON 返回一个简单的 JSON 文档
func SendJSON(rw http.ResponseWriter, r *http.Request) {
	u := struct {
		Name  string
		Email string
	}{
		Name:  "Bill",
		Email: "bill@ardanstudios.com",
	}

	rw.Header().Set("Content-Type", "application/json")
	rw.WriteHeader(200)
	json.NewEncoder(rw).Encode(&u)
}

现在有了包含一个服务端点的可用的网络服务,我们可以写单元测试来测试这个服务端点。

// 这个示例程序展示如何测试内部服务端点的执行效果  
package handlers_test  
  
import (  
    "encoding/json"  
    "net/http"    
    "net/http/httptest"    
    "testing"
    "github.com/goinaction/code/chapter9/listing17/handlers")  
  
const checkMark = "\u2713"  
const ballotX = "\u2717"  
  
func init() {  
    handlers.Routes()  
}  
  
// TestSendJSON 测试/sendjson 内部服务端点  
func TestSendJSON(t *testing.T) {  
    t.Log("Given the need to test the SendJSON endpoint.")  
    {  
       req, err := http.NewRequest("GET", "/sendjson", nil)  
       if err != nil {  
          t.Fatal("\tShould be able to create a request.",  
             ballotX, err)  
       }  
       t.Log("\tShould be able to create a request.",  
          checkMark)  
  
       rw := httptest.NewRecorder()  
       http.DefaultServeMux.ServeHTTP(rw, req)  
  
       if rw.Code != 200 {  
          t.Fatal("\tShould receive \"200\"", ballotX, rw.Code)  
       }  
       t.Log("\tShould receive \"200\"", checkMark)  
  
       u := struct {  
          Name  string  
          Email string  
       }{}  
  
       if err := json.NewDecoder(rw.Body).Decode(&u); err != nil {  
          t.Fatal("\tShould decode the response.", ballotX)  
       }  
       t.Log("\tShould decode the response.", checkMark)  
  
       if u.Name == "Bill" {  
          t.Log("\tShould have a Name.", checkMark)  
       } else {  
          t.Error("\tShould have a Name.", ballotX, u.Name)  
       }  
  
       if u.Email == "bill@ardanstudios.com" {  
          t.Log("\tShould have an Email.", checkMark)  
       } else {  
          t.Error("\tShould have an Email.", ballotX, u.Email)  
       }  
    }  
}

就像直接运行服务时一样,需要为服务端点初始化路由。

rw := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(rw, req)

调用 httptest.NewRecoder 函数来创建一个 http.ResponseRecorder 值,接着就可以通过调用服务默认的多路由选择器的 ServeHttp 方法,模仿外部客户端对服务端点的请求。

ServeHTTP 方法调用完成,http.ResponseRecorder 值就包含了 SendJSON 处理函数的响应。然后解码对字段值就行匹配,如果这些值都匹配,单元测试通过;否则,单元测试失败。

示例

Go 语言很重视给代码编写合适的文档。专门内置了 godoc 工具来从代码直接生成文档。

当然,开发人员可以编写自己的示例,示例是基于已经存在的函数或者方法,用 Example 代替 Test 作为函数名的开始。

package handlers_test

import(
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
)

func ExampleSendJSON(){
	r, _ := http.NewRequest("GET", "/sendjson", nil)
	rw := httptest.NewRecorder()

	http.DefaultServeMux.ServeHTTP(rw, r)
	var u struct{
		Name string
		Email string
	}

	if err := json.NewDecoder(rw.Body).Decode(&u); err != nil{
		log.Println("ERROR:", err)
	}
	// 将结果写到 stdout 检测输出
	fmt.Println(u)
	// Output:
	// {Bill bill@ardanstudios.com}
}

写示例代码的目的是展示某个函数或者方法的特定使用方法。为了判断测试是成功还是失败,需要将程序最终的输出和示例函数底部列出的输出做比较。

代码中的 Output: 标记用来在文档中标记处示例函数运行后期望的输出。Go 的测试框架知道如何比较注释里的期望输出和标准输出的最终输出。如果两者匹配,这个示例作为测试就会通过,并加入到包的 Go 文档里,如果输出不匹配,这个示例作为测试就会失败。

如果启动一个本地的 godoc 服务器(godoc -http=“:3000”),并找到 handlers 包,就能看到包含示例的文档。

基准测试

基准测试是一种测试代码性能的方法,想要测试解决同一问题的不同方案的性能,以及查看哪种解决方案的性能更好的时候,基准测试就会很有用。

基准测试也可以用来识别某段代码的 CPU 或内存系效率问题,而这段代码的效率可能会影响整个应用程序的新能。

许多开发人员会用基准测试来测试不同的并发模式,或用基准测试来辅助配置工作池的数量,以保证最大化系统的吞吐量。

package hello

import (
	"fmt"
	"testing"
)

// BenchmarkSprintf 对 fmt.Sprintf 函数进行基准测试
func BenchmarkSprintf(b *testing.B) {
	number := 10

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		fmt.Sprintf("%d", number)
	}
}

基准测试函数必须以 Benchmark 开头,接受一个指向 testing.B 类型的指针作为唯一参数。

基准测试框架默认会在持续 1 秒的时间内,反复调用需要测试的函数,测试框架每次调用测试函数时,都会增加 b.N 的值。第一次调用时,b.N 的值为 1。需要注意,一定要将所有要进行基准测试的代码都放到循环里,并且循环要使用 b.N 的值。否则,测试的结果是不可靠的。

go test -v -run="none" -bench="BenchmarkSprintf"

这次 go test 调用中,我们给 -run 选项传递字符串“none”,来保证在运行制订的基准测试函数之前没有单元测试会被运行。这两个选项都可以接受正则表达式,来决定需要运行哪些测试。由于例子里没有单元测试函数的名字中有 none,所以使用 none 可以排除所有的单元测试。

PS D:\Project\GOSTUDY\hello> go test -v -run="none" -bench="BenchmarkSprintf"
goos: windows
goarch: amd64
pkg: GOSTUDY/hello
cpu: AMD Ryzen 7 6800H with Radeon Graphics
BenchmarkSprintf
BenchmarkSprintf-16     23954868                50.16 ns/op
PASS
ok      GOSTUDY/hello   1.470s

其中,23954868 表示在循环中的代码被执行的次数;后面的数字是代码的性能,单位为每次操作消耗的纳秒(ns)数。这个数字展示了这次测试,使用 Sprintf 函数平均每次花费了 50.16 纳秒。

如果想让运行时间更长,可以使用另一个名为 -benchtime 的选项来更改测试执行的最短时间。有时,增加基准测试时间会得到更加精确的性能结果。对于大多数测试来说,超过 3 秒的基准测试并不会改变测试的精准度,只是每次基准测试的结果会有不同。

那么,让我们看另外两个基准测试函数,并一起运行这 3 个基准测试,看看哪种将整数值转换为字符串的方法最快。

// BenchmarkSprintf 对 fmt.Sprintf 函数进行基准测试  
func BenchmarkSprintf(b *testing.B) {  
    number := 10  
  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       fmt.Sprintf("%d", number)  
    }  
}  
  
// BenchmarkFormat 对 strconv.FormatInt 函数进行基准测试  
func BenchmarkFormat(b *testing.B) {  
    number := int64(10)  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       strconv.FormatInt(number, 10)  
    }  
}  
  
// BenchmarkItoa 对 strconv.Itoa 函数进行基准测试  
func BenchmarkItoa(b *testing.B) {  
    number := 10  
    b.ResetTimer()  
    for i := 0; i < b.N; i++ {  
       strconv.Itoa(number)  
    }  
}
goos: windows
goarch: amd64
pkg: GOSTUDY/hello
cpu: AMD Ryzen 7 6800H with Radeon Graphics         
BenchmarkSprintf
BenchmarkSprintf-16    	22955391	        50.20 ns/op
BenchmarkFormat
BenchmarkFormat-16     	586592259	         2.149 ns/op
BenchmarkItoa
BenchmarkItoa-16       	550634260	         2.103 ns/op
PASS

运行基准测试时,另一个很有用的选项是 -benchmem 选项,这个选项可以提供每次操作分配内存的次数以及总分配内存的字节数。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值