这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记
5.1. 包简介
任何包系统设计的目的都是为了简化大型程序的设计和维护工作,通过将一组相关的特性放进一个独立的单元以便于理解和更新,在每个单元更新的同时保持和程序中其它单元的相对独立性。这种模块化的特性允许每个包可以被其它的不同项目共享和重用,在项目范围内、甚至全球范围统一的分发和复用。
每个包一般都定义了一个不同的名字空间用于它内部的每个标识符的访问。每个名字空间关联到一个特定的包,让我们给类型、函数等选择简短明了的名字,这样可以在使用它们的时候减少和其它部分名字的冲突。
每个包还通过控制包内名字的可见性和是否导出来实现封装特性。通过限制包成员的可见性并隐藏包API的具体实现,将允许包的维护者在不影响外部包用户的前提下调整包的内部实现。通过限制包内变量的可见性,还可以强制用户通过某些特定函数来访问和更新内部变量,这样可以保证内部变量的一致性和并发时的互斥约束。
当修改了一个源文件,我们必须重新编译该源文件对应的包和所有依赖该包的其他包。即使是从头构建,Go语言编译器的编译速度也明显快于其它编译语言。Go语言的闪电般的编译速度主要得益于三个语言特性。第一点,所有导入的包必须在每个文件的开头显式声明,这样的话编译器就没有必要读取和分析整个源文件来判断包的依赖关系。第二点,禁止包的环状依赖,因为没有循环依赖,包的依赖关系形成一个有向无环图,每个包可以被独立编译,而且很可能是被并发编译。第三点,编译后包的目标文件不仅仅记录包本身的导出信息,目标文件同时还记录了包的依赖关系。因此,在编译一个包的时候,编译器只需要读取每个直接导入包的目标文件,而不需要遍历所有依赖的的文件(译注:很多都是重复的间接依赖)。
5.2. 导入路径
每个包是由一个全局唯一的字符串所标识的导入路径定位。出现在import语句中的导入路径也是字符串。
import (
"fmt"
"math/rand"
"encoding/json"
"golang.org/x/net/html"
"github.com/go-sql-driver/mysql"
)
Go语言的规范并没有指明包的导入路径字符串的具体含义,导入路径的具体含义是由构建工具来解释的。在本章,我们将深入讨论Go语言工具箱的功能,包括大家经常使用的构建测试等功能。当然,也有第三方扩展的工具箱存在。例如,Google公司内部的Go语言码农,他们就使用内部的多语言构建系统(译注:Google公司使用的是类似Bazel的构建系统,支持多种编程语言,目前该构件系统还不能完整支持Windows环境),用不同的规则来处理包名字和定位包,用不同的规则来处理单元测试等等,因为这样可以更紧密适配他们内部环境。
如果你计划分享或发布包,那么导入路径最好是全球唯一的。为了避免冲突,所有非标准库包的导入路径建议以所在组织的互联网域名为前缀;而且这样也有利于包的检索。例如,上面的import语句导入了Go团队维护的HTML解析器和一个流行的第三方维护的MySQL驱动。
5.3. 包声明
在每个Go语言源文件的开头都必须有包声明语句。包声明语句的主要目的是确定当前包被其它包导入时默认的标识符(也称为包名)。
例如,math/rand包的每个源文件的开头都包含package rand包声明语句,所以当你导入这个包,你就可以用rand.Int、rand.Float64类似的方式访问包的成员。
package main
import (
"fmt"
"math/rand"
)
func main() {
fmt.Println(rand.Int())
}
通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。稍后我们将看到如何同时导入两个有相同包名的包。
关于默认包名一般采用导入路径名的最后一段的约定也有三种例外情况。第一个例外,包对应一个可执行程序,也就是main包,这时候main包本身的导入路径是无关紧要的。名字为main的包是给go build(§10.7.3)构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。
第二个例外,包所在的目录中可能有一些文件名是以_test.go为后缀的Go源文件(译注:前面必须有其它的字符,因为以_或.开头的源文件会被构建工具忽略),并且这些源文件声明的包名也是以_test为后缀名的。这种目录可以包含两种包:一种是普通包,另一种则是测试的外部扩展包。所有以_test为后缀包名的测试外部扩展包都由go test命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖,具体细节我们将在11.2.4节中介绍。
第三个例外,一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如“gopkg.in/yaml.v2”。这种情况下包的名字并不包含版本号后缀,而是yaml。
5.4. 导入声明
可以在一个Go语言源文件包声明语句之后,其它非导入声明语句之前,包含零到多个导入包声明语句。每个导入声明可以单独指定一个导入路径,也可以通过圆括号同时导入多个导入路径。下面两个导入形式是等价的,但是第二种形式更为常见。
import "fmt"
import "os"
import (
"fmt"
"os"
)
导入的包之间可以通过添加空行来分组;通常将来自不同组织的包独自分组。包的导入顺序无关紧要,但是在每个分组中一般会根据字符串顺序排列。(gofmt和goimports工具都可以将不同分组导入的包独立排序。)
import (
"fmt"
"html/template"
"os"
"golang.org/x/net/html"
"golang.org/x/net/ipv4"
)
如果我们想同时导入两个有着名字相同的包,例如math/rand包和crypto/rand包,那么导入声明必须至少为一个同名包指定一个新的包名以避免冲突。这叫做导入包的重命名。
import (
"crypto/rand"
mrand "math/rand" // alternative name mrand avoids conflict
)
导入包的重命名只影响当前的源文件。其它的源文件如果导入了相同的包,可以用导入包原本默认的名字或重命名为另一个完全不同的名字。
导入包重命名是一个有用的特性,它不仅仅只是为了解决名字冲突。如果导入的一个包名很笨重,特别是在一些自动生成的代码中,这时候用一个简短名称会更方便。选择用简短名称重命名导入包时候最好统一,以避免包名混乱。选择另一个包名称还可以帮助避免和本地普通变量名产生冲突。例如,如果文件中已经有了一个名为path的变量,那么我们可以将“path”标准包重命名为pathpkg。
每个导入声明语句都明确指定了当前包和被导入包之间的依赖关系。如果遇到包循环导入的情况,Go语言的构建工具将报告错误。
5.5. go test
go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以_test.go为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test测试的一部分。
在*_test.go文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准测试函数以计算一个平均的执行时间。示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。我们将在11.2节讨论测试函数的所有细节,并在11.4节讨论基准测试函数的细节,然后在11.6节讨论示例函数的细节。
go test命令会遍历所有的*_test.go文件中符合上述命名规则的函数,生成一个临时的main包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。
5.6. 测试函数
每个测试函数必须导入testing包。测试函数有如下的签名:
func TestName(t *testing.T) {
// ...
}
测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:
func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }
其中t参数用于报告测试失败和附加的日志信息。让我们定义一个实例包gopl.io/ch11/word1,其中只有一个函数IsPalindrome用于检查一个字符串是否从前向后和从后向前读都是一样的。(下面这个实现对于一个字符串是否是回文字符串前后重复测试了两次;我们稍后会再讨论这个问题。)
gopl.io/ch11/word1
// Package word provides utilities for word games.
package word
// IsPalindrome reports whether s reads the same forward and backward.
// (Our first attempt.)
func IsPalindrome(s string) bool {
for i := range s {
if s[i] != s[len(s)-1-i] {
return false
}
}
return true
}
在相同的目录下,word_test.go测试文件中包含了TestPalindrome和TestNonPalindrome两个测试函数。每一个都是测试IsPalindrome是否给出正确的结果,并使用t.Error报告失败信息:
package word
import "testing"
func TestPalindrome(t *testing.T) {
if !IsPalindrome("detartrated") {
t.Error(`IsPalindrome("detartrated") = false`)
}
if !IsPalindrome("kayak") {
t.Error(`IsPalindrome("kayak") = false`)
}
}
func TestNonPalindrome(t *testing.T) {
if IsPalindrome("palindrome") {
t.Error(`IsPalindrome("palindrome") = true`)
}
}
go test命令如果没有参数指定包那么将默认采用当前目录对应的包(和go build命令一样)。我们可以用下面的命令构建和运行测试。
$ cd $GOPATH/src/gopl.io/ch11/word1
$ go test
ok gopl.io/ch11/word1 0.008s
结果还比较满意,我们运行了这个程序, 不过没有提前退出是因为还没有遇到BUG报告。不过一个法国名为“Noelle Eve Elleon”的用户会抱怨IsPalindrome函数不能识别“été”。另外一个来自美国中部用户的抱怨则是不能识别“A man, a plan, a canal: Panama.”。执行特殊和小的BUG报告为我们提供了新的更自然的测试用例。
func TestFrenchPalindrome(t *testing.T) {
if !IsPalindrome("été") {
t.Error(`IsPalindrome("été") = false`)
}
}
func TestCanalPalindrome(t *testing.T) {
input := "A man, a plan, a canal: Panama"
if !IsPalindrome(input) {
t.Errorf(`IsPalindrome(%q) = false`, input)
}
}
为了避免两次输入较长的字符串,我们使用了提供了有类似Printf格式化功能的 Errorf函数来汇报错误结果。
当添加了这两个测试用例之后,go test返回了测试失败的信息。
$ go test
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
FAIL gopl.io/ch11/word1 0.014s
先编写测试用例并观察到测试用例触发了和用户报告的错误相同的描述是一个好的测试习惯。只有这样,我们才能定位我们要真正解决的问题。
先写测试用例的另外的好处是,运行测试通常会比手工描述报告的处理更快,这让我们可以进行快速地迭代。如果测试集有很多运行缓慢的测试,我们可以通过只选择运行某些特定的测试来加快测试速度。
参数-v可用于打印每个测试函数的名字和运行时间:
$ go test -v
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.017s
参数-run对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行:
$ go test -v -run="French|Canal"
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.014s
当然,一旦我们已经修复了失败的测试用例,在我们提交代码更新之前,我们应该以不带参数的go test命令运行全部的测试用例,以确保修复失败测试的同时没有引入新的问题。
我们现在的任务就是修复这些错误。简要分析后发现第一个BUG的原因是我们采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正确处理。第二个BUG是因为没有忽略空格和字母的大小写导致的。
针对上述两个BUG,我们仔细重写了函数:
gopl.io/ch11/word2
// Package word provides utilities for word games.
package word
import "unicode"
// IsPalindrome reports whether s reads the same forward and backward.
// Letter case is ignored, as are non-letters.
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
同时我们也将之前的所有测试数据合并到了一个测试中的表格中。
func TestIsPalindrome(t *testing.T) {
var tests = []struct {
input string
want bool
}{
{"", true},
{"a", true},
{"aa", true},
{"ab", false},
{"kayak", true},
{"detartrated", true},
{"A man, a plan, a canal: Panama", true},
{"Evil I did dwell; lewd did I live.", true},
{"Able was I ere I saw Elba", true},
{"été", true},
{"Et se resservir, ivresse reste.", true},
{"palindrome", false}, // non-palindrome
{"desserts", false}, // semi-palindrome
}
for _, test := range tests {
if got := IsPalindrome(test.input); got != test.want {
t.Errorf("IsPalindrome(%q) = %v", test.input, got)
}
}
}
现在我们的新测试都通过了:
$ go test gopl.io/ch11/word2
ok gopl.io/ch11/word2 0.015s
这种表格驱动的测试在Go语言中很常见。我们可以很容易地向表格添加新的测试数据,并且后面的测试逻辑也没有冗余,这样我们可以有更多的精力去完善错误信息。
失败测试的输出并不包括调用t.Errorf时刻的堆栈调用信息。和其他编程语言或测试框架的assert断言不同,t.Errorf调用也没有引起panic异常或停止测试的执行。即使表格中前面的数据导致了测试的失败,表格后面的测试数据依然会运行测试,因此在一个测试中我们可能了解多个失败的信息。
如果我们真的需要停止测试,或许是因为初始化失败或可能是早先的错误导致了后续错误等原因,我们可以使用t.Fatal或t.Fatalf停止当前测试函数。它们必须在和测试函数同一个goroutine内调用。
测试失败的信息一般的形式是“f(x) = y, want z”,其中f(x)解释了失败的操作和对应的输入,y是实际的运行结果,z是期望的正确的结果。就像前面检查回文字符串的例子,实际的函数用于f(x)部分。显示x是表格驱动型测试中比较重要的部分,因为同一个断言可能对应不同的表格项执行多次。要避免无用和冗余的信息。在测试类似IsPalindrome返回布尔类型的函数时,可以忽略并没有额外信息的z部分。如果x、y或z是y的长度,输出一个相关部分的简明总结即可。测试的作者应该要努力帮助程序员诊断测试失败的原因。