1 编译工具
Go语言的工具链非常丰富,从获取源码、编译、文档、测试、性能分析,到源码格式化、源码提示、重构工具等应有尽有。 在Go语言中可以使用测试框架编写单元测试,使用统一的命令行即可测试及输出测试报告的工作。基准测试提供可自定义的计时器和一整套基准测试算法,能方便快速地分析一段代码可能存在的CPU耗用和内存分配问题。性能分析工具可以将程序的CPU耗用、内存分配、竞态问题以图形化方式展现出来。
<备注> 示例代码运行使用的Go发布版本:go version go1.15.4 linux/amd64。
1.1 编译(go build)
Go语言中使用 go build 命令将.go源码编译为可执行文件。go build 有很多种编译方法,如无参数编译、文件列表编译、指定包编译等,使用这些方法都可以输出可执行文件。
1.1.1 go build 无参数编译
1、进入到go源码目录下。
2、直接执行 go build 命令。示例如下:
lib.go
package main
import "fmt"
func pkgFunc() {
fmt.Println("call pkgFunc")
}
main.go
package main
import (
"fmt"
)
func main() {
//同包的函数
pkgFunc()
fmt.Println("Hello world")
}
(1)使用 tree -L 4 命令查看目录树关系如下:
(2)进入到存放go源码的 gobuild 目录下,执行命令:go build
(3)使用 ls 命令查看该目录下的文件。
gobuild lib.go main.go
go build 在编译开始时,会搜索当前目录下的go源文件。在该例中,go build 会找到 lib.go 和 main.go 这两个源文件。编译这两个文件后,生成当前目录名的可执行文件 gobuild,并放置于当前目录下。
(4)运行当前目录下的可执行文件:./gobuild
call pkgFunc
Hello world
1.1.2 go build + 文件列表
编译同目录下的多个源码文件时,可以在 go build 的后面提供多个文件名,go build 会编译这些源码,输出可执行文件,go build + 文件列表的格式如下:
go build file1.go file2.go ......
还是以上面的代码为例,使用 go build + 文件列表的方式编译:
// 方式1
$ go build main.go lib.go #选中需要编译的Go源码
$ ls
lib.go main main.go #这次的可执行文件变成了 main
$ ./main #执行 main 文件,得到期望的运行结果
call pkgFunc
Hello world
// 方式2
$ go build lib.go main.go #把 lib.go 放在文件列表的首位
$ ls
lib lib.go main main.go #这次编译得到的可执行文件变成了 lib
$ ./lib #执行 lib 文件,输出结果一样
call pkgFunc
Hello world
// 方式3
$ go build *.go #用通配符也是可以的
$ ls
lib lib.go main.go #得到的可执行文件 lib
<提示> 使用 “go build + 文件列表” 方式编译时,可执行文件默认选择文件列表中第一个Go源码文件作为可执行文件名。
如果需要指定输出可执行文件名,可以使用 “- o” 参数,参见下面的示例:
$ go build -o myexec main.go lib.go
$ ls
lib.go main.go myexec
$ ./myexec
call pkgFunc
Hello world
<注意> 使用 “go build + 文件列表” 编译方式时,文件列表中每个文件必须是同一个包的Go源码文件。也就是说,不能像C++语言那样,将所有工程的Go源码使用文件变量方式进行编译。编译复杂工程时,需要用“指定包编译”的方式。“go build + 文件列表” 方式更适合使用Go语言编写只有少量源码文件的项目。
1.1.3 go build + 包
“go build + 包” 在设置环境变量 GOPATH 后,可以直接根据包名进行编译,即使包内文件被增删也不影响编译指令。示例如下:
1. 代码位置及源码
相对于 GOPATH 的目录树关系如下:
$ tree -L 5 src
src
└── chapter11
└── goinstall
├── main.go
└── mypkg
└── mypkg.go3 directories, 2 files
main.go 代码如下:
package main
import (
"fmt"
"goinstall/mypkg" //在同一个项目下导入本地包
)
func main() {
mypkg.CustomPkgFunc()
fmt.Println("Hello world")
}
mypkg.go 代码如下:
package mypkg
import "fmt"
func CustomPkgFunc() {
fmt.Println("call CustomPkgFunc")
}
2. 按包编译命令
执行一下命令将按包方式编译 goinstall 代码:
$ export GOPATH=~/go_work/
$ echo $GOPATH
/home/kobe/go_work/
$ cd ~/go_work/src/chapter11/goinstall #进入项目路径下
$ go mod init goinstall #会在当前目录下生成go.mod文件,文件内容如下:
$ more go.mod
module goinstall
go 1.15
$ ls
go.mod main.go mypkg
$ go build -o main goinstall #编译包goinstall,成功后生成可执行文件main,goinstall是编译的
#包名,由于是在goinstall项目目录下,也可以省略包名:
#go build -o main
$ ls
go.mod main main.go mypkg
$ ./main
call CustomPkgFunc
Hello world
<说明> 从Go1.13版本开始,Go语言默认使用go module工具来管理依赖包。
1.1.4 go build 编译时的附加参数
go build 还有一些附加参数,可以显示更多的编译信息和更多的操作,详见下表所示:
附件参数 | 备注 |
-v | 编译时显示包名 |
-p n | 开启并发编译,默认情况下该值为CPU的逻辑核心数 |
-a | 强制重新构建 |
-n | 打印编译时会用到的所有命令,但不真正执行 |
-x | 打印编译时会用到的所有命令 |
-race | 开启竞态检测 |
2. 编译后运行(go run)
Python、Lua 语言可以在不输出二进制文件的情况下,将代码使用虚拟机直接执行。Go语言虽然不使用虚拟机,但可使用go run 命令达到同样的效果。
go run 命令会编译源码,并且直接执行源码的main函数,不会在当前目录下生成可执行文件。
下面我们准备一个 main.go 的源文件,观察 go run 的运行结果,代码如下:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("args:", os.Args)
}
这段代码的功能是将输入的参数打印出来。使用 go run 运行这个源码文件,命令如下:
$ go run main.go --filename xxx.go
args: [/tmp/go-build746571631/b001/exe/main --filename xxx.go]
go run 不会在运行目录下生成可执行文件,可执行文件被放在临时文件中被执行,工作目录被设置为当前目录。在 go run 的后部可以添加参数,这部分参数会作为代码可以接受的命令行输入提供给程序。
go run 不能使用 “go run + 包” 的方式进行编译,如需快速编译运行报,需要使用如下步骤:
(1)使用 go build 生成可执行文件。
(2)运行可执行文件。
3 编译并安装(go install)
go install 的功能和 go build 类似,附加参数绝大多数都可以与 go build 通用。go install 只是将编译的中间文件放在 GOPATH 的 pkg 目录下,以及固定地将编译结果放在 GOPATH 的 bin 目录下。
使用 go install 来指向 chapter11/goinstall 项目代码,执行命令如下:
$ cd ~/go_work/src/chapter11/goinstall/
$ go mod init goinstall
go: creating new go.mod: module goinstall
$ ls
go.mod main.go mypkg
$ more go.mod
module goinstall
go 1.15
$ go install goinstall
$ ls
go.mod main.go mypkg
$ cd ~/go_work/;ls
bin pkg src
$ echo $GOPATH
/home/kobe/go_work/
$ tree -L 4
.
├── bin
│ └── goinstall
├── pkg
│ └── mod
│ └── cache
│ └── lock
└── src
└── chapter11
├── goinstall
│ ├── go.mod
│ ├── main.go
│ └── mypkg
└── main.go
8 directories, 5 files
$ cd bin/;ls
goinstall
$ ./goinstall
call CustomPkgFunc
Hello world
go install 的编译过程有如下规律:
- go install 是建立在环境变量 GOPATH 上的。
- GOPATH 下的bin目录放置的是使用go install 生成的可执行文件名称来自于编译时的包名。
- go install 输出目录始终为 GOPATH 下的 bin目录,无法使用 -o 附件参数进行自定义。
- GOPATH 下的 pkg 目录放置的是编译期间的中间文件。
4 一键获取代码、编译并安装(go get)
go get 可以借助代码管理工具通过远程拉取或更新代码包及其依赖包,并自动完成编译、安装。整个过程就像安装一个App一样简单。
使用 go get 之前,需要安装与远程匹配的代码管理工具,如Git、SVN、HG等,参数中需要提供一个包名。
4.1 远程包的路径格式
Go 语言的代码托管于 GitHub.com 网站,该网站是基于 Git 代码管理工具的,很多有名的项目都在该网站托管代码。其他类似的托管网站还有:code.google.com、bitbucket.org等。
这些网站的项目包路径都有一个共同的标准,参见下图所示:
上图中的远程包路径是Go语言的源码,这个路径由3个 部分组成。
- 网站域名:表示代码托管的网站,类似于电子邮件 @ 后面的服务器组成。
- 作者或机构:表明这个项目的归属,一般为网站的用户名,如果需要找到这个作者下的所有项目,可以直接在网站上通过搜索 “域名/网站” 进行查看。这部分类似于电子邮件 @ 前面的部分。
- 项目名:每个网站下的作者或机构可能会同时拥有很多的项目,上图标识的项目名称为“go”。
4.2 go get + 远程包
默认情况下,go get 可以直接使用。例如,想获取go的源码并编译,使用下面的命令即可:
# 需要先安装Git工具,切换成root用户
# yum install -y git
# 然后切换成普通用户
# su - kobe
$ go get github.com/davyxu/cellnet
# 下载成功后,查看目录树结构如下
$ tree -L 4
.
├── bin
├── pkg
│ └── linux_amd64
│ └── github.com
│ └── davyxu
└── src
├── chapter11
│ ├── goinstall
│ │ ├── go.mod
│ │ ├── main.go
│ │ └── mypkg
│ └── main.go
└── github.com
└── davyxu
└── cellnet
12 directories, 3 files
获取前,要确保GOPATH环境变量已经设置。Go 1.8版本之后,GOPATH 默认在用户目录的go目录下。例如,GOPATH="/home/kobe/go"。可以使用 go env 命令查看go的环境变量。
4.3 go get 使用时的附加参数
使用 go get 时可以配合附加参数显示更多的信息及实现特殊的下载和安装操作,如下表所示:
附加参数 | 备注 |
-v | 显示操作流程的日志及信息,方便检查错误 |
-u | 下载丢失的包,但不会更新已经存在的包 |
-d | 只下载,不安装 |
-insecure | 允许使用不安全的HTTP方式进行下载操作 |
5 测试(go test)
Go语言拥有一套单元测试和性能测试系统,仅需要添加很少的代码就可以快速测试一段需求代码。
性能测试系统可以给出代码的性能数据,帮助测试者分析性能问题。
<提示> 单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java中单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小测试功能模块。单元测试是在软件开发过程中进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
5.1 单元测试 —— 测试和验证代码的框架
要开始一个单元测试,需要准备一个go源码文件,在命名文件时需要让文件必须以 _test 结尾。
单元测试源码文件可以由多个测试用例组成,每个测试用例函数需要以 Test 为前缀,例如:func Testxxxx(t *testing.T)
- 测试用例文件不会参与正常源码编译,不会被包含到可执行文件中。
- 测试用例文件使用 go test 命令来执行,没有也不需要 main() 函数作为函数入口。所有在以 _test 结尾的源码文件内以 Test 开头的函数会自动被执行。
- 测试用例可以不传入*testing.T 参数。
示例:helloworld 的测试代码,测试源码文件名:helloworld_test.go。
package gotest
import "testing"
//待测试用例必须以 Test 开头
func TestHelloWorld(t *testing.T){
t.Log("hello world")
}
1. 单元测试命令行
$ go test helloworld_test.go
ok command-line-arguments 0.003s
$ go test -v helloworld_test.go
=== RUN TestHelloWorld
helloworld_test.go:18: hello world
--- PASS: TestHelloWorld (0.00s)
PASS
ok command-line-arguments 0.004s
《代码说明》
- 在 go test 后跟 helloworld_test.go 源码文件,表示测试这个文件里的所有测试用例。
- 测试结果为 ok,表示测试通过,command-line-arguments 是测试用例需要用到的一个包名,0.003s 表示测试花费的时间。
- -v 附加参数,可以让测试时显示详细的流程。
- === RUN TestHelloWorld:表示开始运行名叫 TestHelloWorld 的测试用例。
- --- PASS: TestHelloWorld (0.00s):表示已经运行完 TestHelloWorld 的测试用例,PASS 表示测试成功。
- ok command-line-arguments 0.004s:表示测试通过,耗时0.004s。
2. 运行指定单元测试用例
go test 指定文件时默认执行文件内所有的测试用例。可以使用 -run 参数选择需要的测试用例单独进行测试。
示例:一个文件包含了多个测试用例。源码文件名:select_test.go
package gotest
import "testing"
func TestA(t *testing.T){
t.Log("A")
}
func TestAK(t *testing.T){
t.Log("AK")
}
func TestB(t *testing.T){
t.Log("B")
}
func TestC(t *testing.T){
t.Log("C")
}
这里我们指定测试TestA用例:
$ go test -v -run TestA select_test.go
=== RUN TestA
select_test.go:26: A
--- PASS: TestA (0.00s)
=== RUN TestAK
select_test.go:30: AK
--- PASS: TestAK (0.00s)
PASS
ok command-line-arguments 0.011s
TestA 和 TestAK 的测试用例都被执行,原因是 -run 跟随的测试用例的名称支持正则表达式,使用 -run TestA$ 即可执行TestA 测试用例。
$ go test -v -run TestA$ select_test.go
=== RUN TestA
select_test.go:26: A
--- PASS: TestA (0.00s)
PASS
ok command-line-arguments 0.004s
3. 标记单元测试结果
当需要终止当前测试用例时,可以使用 FailNow() 方法。还有一种只标记错误但不终止测试的方法Fail()。
示例:测试标记结果。源码文件名:fail_test.go。
package gotest
import (
"fmt"
"testing"
)
func TestFailNow(t *testing.T){
fmt.Println("before failnow")
t.FailNow()
fmt.Println("after failnow")
}
func TestFail(t *testing.T){
fmt.Println("before fail")
t.Fail()
fmt.Println("after fail")
}
测试结果如下:
$ go test -v -run TestFailNow fail_test.go
=== RUN TestFailNow
before failnow
--- FAIL: TestFailNow (0.00s)
FAIL
FAIL command-line-arguments 0.005s
FAIL
可以看到,TestFailNow测试用例,当运行到t.FailNow()时,就终止了测试用例,测试结果标记为失败。
$ go test -v -run TestFail$ fail_test.go
=== RUN TestFail
before fail
after fail
--- FAIL: TestFail (0.00s)
FAIL
FAIL command-line-arguments 0.004s
FAIL
可以看到,TestFail测试用例,当运行t.Fail()后,测试结果标记为失败,但是测试用例并立即没有终止运行,仍然打印出了"after fail"。
4. 单元测试日志
每个测试用例可能并发执行,使用 testing.T 提供的日志输出可以保证日志跟随这个测试用例上下文一起打印输出。testing.T 提供了几种日志输出方法,详见下表所示。
方法 | 备注 |
Log | 打印日志,同时结束测试 |
Logf | 格式化打印日志,同时结束测试 |
Error | 打印错误日志,同时结束测试 |
Errorf | 格式化打印错误日志,同时结束测试 |
Fatal | 打印致命日志,同时结束测试 |
Fatalf | 格式化打印致命日志,同时结束测试 |
开发者可以根据实际需要选择合适的日志打印方法。
5.2 基准测试 —— 获得代码内存占用和运行效率的性能数据
基准测试可以测试一段程序的运行性能及耗费CPU的程度。Go语言中提供了基准测试框架,使用方法类似于单元测试,使用者无须准备高精度的计时器和各种分析工具,基准测试本身可以打印出非常标准的测试报告。
1. 基准测试基本使用
下面通过一个例子来了解基准测试的基本使用方法。
示例:基准测试。源码文件名:benchmark_test.go
package gotest
import (
"testing"
)
func Benchmark_Add(b *testing.B){
var n int
for i:=0; i<b.N; i++ {
n++
}
}
这段代码使用基准测试框架测试加法性能。代码中的 b.N 由基准测试框架提供。测试代码需要保证函数可重入性及无状态,也就是说,测试代码不能使用全局变量等带有记忆性质的数据结构。避免多次运行同一段代码时的环境不一致,不能假设N值范围。
使用如下命令行开启基准测试:
$ go test -v -bench=. benchmark_test.go
goos: linux
goarch: amd64
Benchmark_Add
Benchmark_Add 1000000000 0.748 ns/op
PASS
ok command-line-arguments 0.838s
《代码说明》
- -bench=. 表示运行 benchmark_test.go 文件里的所有基准测试,和单元测试中的 -run 类似。
- Benchmark_Add 表示基准测试名称,1000000000 表示测试的次数,也就是 testing.B 结构中提供给程序使用的N值。0.748 ns/op 表示每一个操作耗费多少时间(纳秒)。
<提示> Windows系统下使用 go get 命令行时,-bench=. 应写成 -bench="."。
2. 基准测试原理
基准测试框架对一个基准测试用例的默认测试时间是1秒。开始测试时,当以Benchmark开头的基准测试用例函数返回时还不到1秒,那么 testing.B 中的N值j将按 1、2、5、10、20、50......递增,同时以递增后的值重新调用基准测试用例函数。
3. 自定义测试时间
通过 -benchtime 参数可以自定义测试时间,例如:
$ go test -v -bench=. -benchtime=5s benchmark_test.go
goos: linux
goarch: amd64
Benchmark_Add
Benchmark_Add 1000000000 0.380 ns/op
PASS
ok command-line-arguments 0.421s
4. 测试内存
基准测试可以对一段代码可能存在的内存分配进行统计,下面是一段使用字符串格式化的函数,内部会进行一些内存分配操作。
package gotest
import (
"fmt"
"testing"
)
func Benchmark_Add(b *testing.B){
var n int
for i:=0; i<b.N; i++ {
n++
}
}
func Benchmark_Alloc(b *testing.B){
for i:=0; i<b.N; i++ {
fmt.Sprintf("%d", i)
}
}
运行基准测试用例的命令如下:
$ go test -v -bench=Alloc -benchmem benchmark_test.go
goos: linux
goarch: amd64
Benchmark_Alloc
Benchmark_Alloc 4995866 231 ns/op 15 B/op 1 allocs/op
PASS
ok command-line-arguments 1.404s
- -bench=Alloc 表示测试指定的基准测试用例 Benchmark_Alloc() 函数。
- Benchmark_Alloc 表示基准测试用例名称;4995866 表示的是b.N的值;231 ns/op 表示每一次操作耗时(单位:纳秒);15 B/op 表示每一次调用需要分配15个字节;1 allocs/op 表示每一次分配有1次调用。
开发者根据这些信息可以迅速找到可能的分配点,进行代码的优化和调整。
5. 控制计时器
有些测试需要一定的启动和初始化时间,如果从 Benchmark() 函数开始计时会很大程度上影响测试结果的精准性。testing.B 提供了一些列的方法可以方便地控制计时器,从而让计时器只在需要的区间就那些测试。
示例:基准测试中的计时器控制例子。对应下面代码中的 Benchmark_Add_TimerControl() 函数。
package gotest
import (
"fmt"
"testing"
)
func Benchmark_Add(b *testing.B){
var n int
for i:=0; i<b.N; i++ {
n++
}
}
func Benchmark_Alloc(b *testing.B){
for i:=0; i<b.N; i++ {
fmt.Sprintf("%d", i)
}
}
func Benchmark_Add_TimerControl(b *testing.B){
//重置计时器
b.ResetTimer()
//开始计时器
b.StartTimer()
var n int = 0
for i:=0; i<b.N; i++ {
n++
}
//停止计时器
b.StopTimer()
}
从 Benchmark() 函数开始,Timer 就开始计数了。ResetTimer() 方法用于重置计数器的数据,然后通过 StartTimer() 重新开始计时,而 StopTimer() 可以停止这个计数过程。
计数器内部不仅包含耗时数据,还包括内存分配数据。
参考
《Go语言从入门到进阶实战(视频教学版)》