本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
检测代码覆盖率
代码覆盖率是一个非常有用的工具,可以知道是否漏掉了某些明显的状况。但达到100%的测试覆盖率并不能保证在某些输入下代码中没有错误。首先,我们会学习如何使用go test
展示代码覆盖率,然后我们会了解仅依赖代码覆盖率的局限性。
在go test
命令中添加-cover
标记可以计算覆盖率信息,并在测试输出中添加摘要。如果再加上一个-coverprofile
的参数,可将覆盖率信息保存到一个文件中。我们再回到第15章的GitHub代码库的sample_code/table目录中,收集代码覆盖率信息:
$ go test -v -cover -coverprofile=c.out
如果检测表格测试的代码覆盖率,测试输出会显示一行信息,代码覆盖率为87.5%。虽然这是有用的信息,但我们更希望看到漏掉了哪些测试。Go 附带的cover
工具会生成包含了这些信息的 HTML 表示:
$ go tool cover -html=c.out
运行该命令,应该会打开浏览器并能看到如图12-1的页面:
图12-1:初始测试代码覆盖率
每个测试过的文件都会出现在左上角的组合框中。源代码有三种颜色。灰色表不可测试的代码行,绿色表已被测试覆盖的代码,红色表未经测试的代码。通过观察颜色,可以看出我们没有对default分支编写测试,即对函数传递错误的运算符时。下面将这种情况添加到测试列表中:
{"bad_op", 2, 2, "?", 0, `unknown operator ?`},
重新运行go test -v -cover -coverprofile=c.out
和go tool cover
-html=c.out
,可在图12-2中看到测试代码覆盖率为100%。
图12-2:最终测试代码覆盖率
代码覆盖率非常棒,但也有不足。虽然有100%的覆盖率,但代码中却有一个bug。不知读者有没有注意到?如果没有,可以添加另一个测试用例然后运行测试:
{"another_mult", 2, 3, "*", 6, ""},
可以看到如下错误:
table_test.go:57: Expected 6, got 5
在乘法用例中有一处笔误。对乘法使用了加号。(复制、粘贴代码时要格外小心!)修改代码,再次运行go test -v -cover -coverprofile=c.out
和go tool cover -html=c.out
,测试会正常通过。
警告:代码覆盖率很有必要,但并不足够。覆盖率为100%的代码仍可能存在bug。
基准测试
确定代码是快或慢非常复杂。我们不用自己计算,应使用Go测试框架内置的基准测试。下面来看第15章的GitHub代码库sample_code/bench目录下的函数:
func FileLen(f string, bufsize int) (int, error) {
file, err := os.Open(f)
if err != nil {
return 0, err
}
defer file.Close()
count := 0
for {
buf := make([]byte, bufsize)
num, err := file.Read(buf)
count += num
if err != nil {
break
}
}
return count, nil
}
这个函数计算文件中的字数。它接收两个参数,文件名和用于读取文件的缓冲大小(稍后会讲到第二个参数的作用)。
在测试其速度前,应当测试代码运行是否正常。以下是简单的测试:
func TestFileLen(t *testing.T) {
result, err := FileLen("testdata/data.txt", 1)
if err != nil {
t.Fatal(err)
}
if result != 65204 {
t.Error("Expected 65204, got", result)
}
}
下面来看运行该函数需要多长时间。我们的目标是找出该使用多大的缓冲区读取文件。
注:在花时间坠入优化的深渊之前,请明确程序需要进行优化。如果程序已经足够快,满足了响应要求,并且使用的内存量在接受范围之内,那么将时间花在新增功能和修复bug上会更好。业务的需求决定了何为"足够快"和"接受范围之内"。
在 Go 中,基准测试是测试文件中以单词Benchmark
开头的函数,它们接受一个类型为*testing.B
的参数。这种类型包含了*testing.T
的所有功能,以及用于基准测试的额外支持。首先看一个使用 1 字节缓冲区的基准测试:
var blackhole int
func BenchmarkFileLen1(b *testing.B) {
for i := 0; i < b.N; i++ {
result, err := FileLen("testdata/data.txt", 1)
if err != nil {
b.Fatal(err)
}
blackhole = result
}
}
blackhole
包级变量是有作用的。我们将 FileLen
的结果写入这个包级变量,以确保编译器不会自负到优化掉对 FileLen
的调用,而对基准测试产生破坏。
每个 Go 基准测试都必须有一个循环,从 0 迭代到 b.N
。测试框架会一遍又一遍地调用我们的基准测试函数,每次传递更大的 N
值,直到确保时间结果准确为止。马上会在输出中看到这一点。
我们通过向go test
传递-bench
标记来运行基准测试。该标记接收一个正则表达式来描述要运行的基准测试名称。使用-bench=.
来运行所有基准测试。第二个标记-benchmem
在基准测试输出中包含内存分配信息。所有测试在基准测试之前运行,因此只有在测试通过时才能对代码进行基准测试。
以下是运行基准测试我电脑上的输出:
BenchmarkFileLen1-12 25 47201025 ns/op 65342 B/op 65208 allocs/op
运行含内存分配信息的基准测试输出有5列。分别如下:
-
BenchmarkFileLen1-12
基准测试的名称,中间杠,加用于测试的GOMAXPROCS的值。
-
25
产生稳定输出运行测试的次数。
-
47201025 ns/op
该基准测试运行单次通过的时间,单位是纳秒(1秒为1,000,000,000纳秒)。
-
65342 B/op
基准测试单次通过所分配的字节数。
-
65208 allocs/op
基准测试单次通过堆上分配字节的次数。其值小于等于字节的分配数。
我们已经得到1字节缓冲的结果,下面来看使用其它大小缓冲所得到的结果:
func BenchmarkFileLen(b *testing.B) {
for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {
b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {
for i := 0; i < b.N; i++ {
result, err := FileLen("testdata/data.txt", v)
if err != nil {
b.Fatal(err)
}
blackhole = result
}
})
}
}
和使用t.Run
启动表格测试类似,我们使用b.Run
启动不同输入的基准测试。作者电脑上的结果如下:
BenchmarkFileLen/FileLen-1-12 25 47828842 ns/op 65342 B/op 65208 allocs/op
BenchmarkFileLen/FileLen-10-12 230 5136839 ns/op 104488 B/op 6525 allocs/op
BenchmarkFileLen/FileLen-100-12 2246 509619 ns/op 73384 B/op 657 allocs/op
BenchmarkFileLen/FileLen-1000-12 16491 71281 ns/op 68744 B/op 70 allocs/op
BenchmarkFileLen/FileLen-10000-12 42468 26600 ns/op 82056 B/op 11 allocs/op
BenchmarkFileLen/FileLen-100000-12 36700 30473 ns/op 213128 B/op 5 allocs/op
结果符合预期;随着缓冲区大小的增加,分配次数减少,代码运行速度更快,直至缓冲区大于文件的大小。当缓冲区大于文件大小时,会有额外的分配导致输出减慢。如果我们预期文件大致是这个大小,那么10,000 字节的缓冲区效果最佳。
但是有一个改动可以进一步提高性能。现在每次从文件获取下一组字节时都重新分配缓冲区。这是没必要的。如果我们在循环之前进行字节切片分配,然后重新运行基准测试,会看到提升:
BenchmarkFileLen/FileLen-1-12 25 46167597 ns/op 137 B/op 4 allocs/op
BenchmarkFileLen/FileLen-10-12 261 4592019 ns/op 152 B/op 4 allocs/op
BenchmarkFileLen/FileLen-100-12 2518 478838 ns/op 248 B/op 4 allocs/op
BenchmarkFileLen/FileLen-1000-12 20059 60150 ns/op 1160 B/op 4 allocs/op
BenchmarkFileLen/FileLen-10000-12 62992 19000 ns/op 10376 B/op 4 allocs/op
BenchmarkFileLen/FileLen-100000-12 51928 21275 ns/op 106632 B/op 4 allocs/op
现在分配的次数相同且较小,每个缓冲区大小仅需四次分配。有意思的是,我们现在可以作出权衡。如果内存紧张,可以使用较小的缓冲区大小,在牺牲性能的情况下节约内存。
Go代码性能调优
如果基准测试显示存在性能或内存问题,下一步是确定问题的具体原因。Go 包含了分析工具,可从正在运行的程序中收集 CPU 和内存使用数据,还有用于可视化和解释生成的数据的工具。甚至可以暴露一个 Web 服务端点,远程从运行的 Go 服务中收集分析信息。
讨论性能调优工具不在我们的范畴。线上有许多很好的资源提供相关信息。一个不错的起点是 Julia Evans 的博文使用 pprof 对 Go 程序做性能分析。