Go 语言目前在云原生和服务端 Paas 层开发领域方兴未艾,相比 Java 的在大规模企业级服务端、Android 端和大数据应用规模还是相差疏远,Go 目前应用得益于容器技术和容器编排技术的普及,还有服务端开发越来越流行的分布式微服务架构。我个人接触 Go 语言是从 2019 年开始已经有 4 年多了,Go 入门简单编译速度相比 C 和 C++ 、Rust 要快得多,但是要深入掌握 Go 语言和 Go 运行时就要花费些时间自己去研究 Go 运行时的源代码,Go 开发相关的工具链也很完善,例如单元测试 、性能测试 、程序性能分析 pprof 、 交叉构建工具等,这片博文我将会介绍 Go 相关的工具链使用。
编译标签
Go 作为一个静态编译语言生成的二进制为相应平台的架构指令,这就带来一个新的问题如何做交叉编译,并且每个平台对应系统调用 API 接口可能不同或者实现也不同,需要在构建二进制文件的时候过滤掉一些源代码文件,Go 官方提供 build
标签能帮助开发者来实现此功能。
+build ignore
标签:
// +build ignore
package main
import "fmt"
func main() {
num := 3601
fmt.Println(toTime(num))
}
func toTime(num int) string {
return fmt.Sprintf("%d = %dh:%dm:%ds", num, (num / 3600), (num / 3600 / 60), (num % 60))
}
build
标签需要放到源代码文件的包声明语句头部,build
标签也可以使用逻辑运算符 &&、|| 和 ! 来组合多个条件,例如这表示只有在 Linux 的 amd64 架构上才能编译,但不是不能在 Darwin 的 386 和 arm64 架构上编译。
// +build linux,amd64 && !darwin && !386 && !arm64
package main
import "syscall"
func main() {
syscall.Syscall(1, 0, 0, 0) // This is the "exit" syscall on Linux
}
最后使用 GOOS=linux GOARCH=amd64 go build
就可以生成对应的平台架构二进制文件。也可以在特定的代码行上编写注释,使得特定代码行在特定情况进行编译,当使用 Go 的构建标签(build tags
)时,你可以根据不同的条件选择性地包含或排除代码:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("Running on:", runtime.GOOS)
// 根据平台执行不同操作
// +build linux
fmt.Println("Running on Linux")
// +build windows
fmt.Println("Running on Windows")
// +build darwin
fmt.Println("Runing on Darwin")
}
tags
编译项目,并根据平台选择性地包含代码块,例如只编译 Darwin
平台架构的代码:
go build -tags darwin
//go:generate
指令,我们可以在构建时执行一些自定义的命令或脚本,以自动生成一些代码或文件,例如在编译之前使用 go generate
就会生成了一个名字位 version.go
文件输出当前 Go 的 SDK 版本:
package main
import "fmt"
//go:generate sh -c "echo \"package main\n\nconst VERSION = \\\"$(go version | awk '{print $3}')\\\"\" > version.go"
func main() {
fmt.Println("Version:", VERSION)
}
type People struct {
Name string `json:"name"`
Age int `json:"age"`
} // +person
//go:generate some-code-generator --name=ToString --group=toStr
func ToString() string {
// dosomething
}
//go:cgo_import_dynamic
是 Go 中用于指定在 C 代码中导入动态链接库中的函数的名称、库名和符号的标签,例如://go:cgo_import_dynamic funcName library.funcName "/path/mylib.so"
//go:noescape
和 //go:noinline
标签,禁止逃逸分析使得函数局部的变量分配到堆上,和对函数内联禁止使用。禁止逃逸分析可以在明确知道变量不会出栈的作用域时可以使用,也不会导致栈内存溢出使用,例如下面的代码:
package main
import (
"unsafe"
)
//go:noescape
func Copy(dest, src []byte) int {
n := len(src)
if len(dest) < n {
n = len(dest)
}
if n > 0 {
copy(dest[:n], src[:n])
}
return n
}
func main() {
buf1 := make([]byte, 10)
buf2 := make([]byte, 20)
// 使用 Go 语言的 copy 函数复制切片
n := copy(buf1, buf2)
println(n)
// 使用 C 语言的 memcpy 函数复制内存
n = Copy(buf1, buf2)
println(n)
// 使用 unsafe 包将一个字符串转换为一个不可变的 []byte 切片
s := "hello"
b := *(*[]byte)(unsafe.Pointer(&s))
println(string(b))
}
在上面例子中,使用了 go:noescape
指令来确保 Copy 函数中的指针不会被意外地逃逸到堆上。因为 Copy 函数实际上是一个简单的内存复制函数,不需要对切片进行任何操作,所以使用 go:noescape
指令可以避免额外的内存分配和拷贝操作,提高性能和可靠性。
package main
import "fmt"
//go:noinline
func myFunc(x int) int {
return x + 1
}
func main() {
// 调用 myFunc 函数 10 次,并打印返回值
for i := 0; i < 10; i++ {
fmt.Println(myFunc(i))
}
}
上面代码中我们在 fmt.Println
函数中调用了 myFunc
函数,并且循环了 10 次,智能编译器开启了函数内联优化,就会降低函数的调用的时候开销,具体是编译器实现的。如果我们不使用 //go:noinline
指令,并将 myFunc
函数内联到 main
函数中,那么编译器可能会优化掉大部分函数调用开销。但是由于我们禁用了内联优化,编译器将生成一个函数调用指令,每次调用 myFunc
函数时都会执行额外的逻辑,从而使得每个函数调用都有一些开销。
单元测试
单元测试时针对自己编写的代码进行逻辑测试,看一个函数或者一个功能是否能正常的被执行返回预期的结构,一个项目单元测试覆盖率越高说明项目工程的质量越高,其目的是能在软件开发的过程中编写测试提前发现代码中 Bug 提前修复,单元测试的基本思想是将代码分解为小的测试单元,并为每个单元编写测试代码,最常用也就是函数返回结构断言测试。
go test
命令,源代码文件和单元测试文件文件命名有着规定的格式,例如 math.go
文件它对应的测试文件命名应该为 math_test.go
,否则 go test
命令会无法正常进行预期的测试,Go 内在的测试函数列表:
函数名 | 作用 |
---|---|
func TestMain(m *testing.M) | TestMain 是一个特殊的测试函数,用于在执行所有测试之前和之后执行一些操作,例如设置/清除测试环境、打开/关闭数据库连接等。TestMain 接受一个类型为 *testing.M 的参数。在 TestMain 中调用 m.Run() 可以运行所有测试。 |
func TestXxx(t *testing.T) | TestXxx 是一个测试函数,用于测试某个函数或方法的行为。Xxx 应该是被测试函数或方法的名称。TestXxx 接受一个类型为 *testing.T 的参数,用于管理测试状态和记录测试结果。 |
func BenchmarkXxx(b *testing.B) | BenchmarkXxx 是一个基准测试函数,用于测量某个函数或方法的性能。Xxx 应该是被测试函数或方法的名称。BenchmarkXxx 接受一个类型为 *testing.B 的参数,用于管理基准测试状态和记录性能指标。 |
func Example() | Example 是一个示例函数,用于演示某个函数或方法的用法。Example 不接受任何参数。在文档中,示例函数的输出将被包含在代码块中,并使用 Output 标签指定预期输出。 |
*testing.M
测试为例,会在测试开始和测试之后,执行自定义的代码:
package main
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// 执行一些初始化操作,例如设置测试环境
fmt.Println("TestMain setup")
// 调用 m.Run() 运行所有测试
exitCode := m.Run()
// 执行一些清理操作,例如关闭数据库连接
fmt.Println("TestMain teardown")
// 退出测试,并使用 exitCode 作为退出状态码
os.Exit(exitCode)
}
func TestAddition(t *testing.T) {
// 测试加法函数的行为
result := add(2, 3)
if result != 5 {
t.Errorf("Expected 5, but got %d", result)
}
}
func add(a, b int) int {
return a + b
}
t *testing.T
进行测试,没有前置和后置条件,例如下面代码:
# go test -run=^TestAdditio$
func TestAddition(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Addition failed. Expected 5 but got %d", result)
}
}
上面的代码可以通过 go test
命令进行测试,如果有对过函数需要测试不必写多个函数名称,使用 -run
参数后面的字符串是一个正则表达式,^
和 $
表示匹配字符串的开头和结尾,上述命令将只运行名为 TestAddition 的测试用例。
testing.B
类型的参数来管理和记录测试状态和结果,例如下面代码中的 < b.N
程序会自动生成对应的循环次数,以不同场景来测试一段代码性能如何:
package main
import (
"math/rand"
"testing"
)
// 基准测试随机数生成器的性能 go test -bench=.
func BenchmarkRand(b *testing.B) {
for i := 0; i < b.N; i++ {
rand.Int()
}
}
t.Run
来测试多个条件情况下预期结果,来帮助完成代码健壮性测试:
func TestSum(t *testing.T) {
type test struct {
name string
numbers []int
expected int
}
tests := []test{
{name: "Test Sum Positive Numbers", numbers: []int{1, 2, 3, 4, 5}, expected: 15},
{name: "Test Sum Negative Numbers", numbers: []int{-1, -2, -3, -4, -5}, expected: -15},
{name: "Test Sum Mixed Numbers", numbers: []int{-1, 2, 3, -4, 5}, expected: 5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := Sum(tc.numbers...)
if result != tc.expected {
t.Errorf("Sum(%v) = %d; want %d", tc.numbers, result, tc.expected)
}
})
}
}
上述代码中提供了多个参数多个测试条件看是否正常提供单元测试,称之为 Tables Test
,内置的 testing
包功能很多,可以查看观点的 API 文档 testing,另外的网络服务的话可以使用端点测试和调用测试,这些可以使用现有的 Postman 工具进行测试。
代码文档
Go 语言在设计的时候就借鉴过 Java 的设计,Go 作为一个工业工程化的编程语言也提供类似于 Java Doc 工具,但是没有 Java Doc 功能那么强大,可以满足日常开发使用,想要自动生成代码的文档,只需要安装 go-lint 相关工具将可以帮助提示相关注释规范,常用注释格式如下:
// Package mypack provides utility functions for performing various operations.
package mypack
// MaxRetryCount specifies the maximum number of times to retry a failed operation.
var MaxRetryCount = 5
// IsZero returns true if the complex number is zero.
func (c Complex) IsZero() bool {
return c.real == 0 && c.imag == 0
}
go doc [package |symbol]
来查看对应的文档信息,例如 go doc fmt.Println
可以查看这个函数的作用。另外是为代码片段生产对应的文档信息,方便 pkg.go.dev 建立索引信息,要为代码编写 Example
的代码示例,从而让 go doc 来生产对应的 API 文档信息,例如编写一个简单 Add 函数:
package calculator
import (
"fmt"
"os"
)
// Add returns the sum of two integers.
func Add(a, b int) int {
return a + b
}
// ExampleAdd demonstrates how to use Add.
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}
func main() {
doc := `Package calculator provides basic math operations.`
fmt.Fprintln(os.Stdout, doc)
}
ExampleAdd
函数中编写了关于如何使用 Add
函数的示例,并使用 fmt.Println
打印出了 Add(2, 3)
的结果。在 // Output:
注释下面,我们写下了期望的输出值(即 5),这个注释是 Go 的一个特殊注释,它与示例函数一起使用,可以用于验证示例是否正确,就可以使用 go doc --package
命令来生成对应注释文档了。
代码覆盖率
程序代码的覆盖率一般都是指得是代码的功能的测试代码,一个功能函数对应的代码是不是有对应的测试代码覆盖,如果一个项目的代码的测试代码覆盖非常高,那么整个项目的代码质量也比较高,目前已经有很多第三方插件可以实现这些功能有 DeepSource 和 Codecov ,DeepSource 不能能查看代码测试覆盖率还能通过静态分析的方式查找程序的 Bug 。
go test --cover
,通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例,例如有一个 math 包代码:
package math
func init() {
Math := make(map[string]func(n, m int) int, 4)
Math["add"] = Add
Math["sub"] = Sub
Math["multi"] = Multi
Math["div"] = Div
}
func Add(n, m int) int {
return n + m
}
func Sub(n, m int) int {
return n - m
}
func Multi(n, m int) int {
return n * m
}
func Div(n, m int) int {
return n / m
}
对应的测试代码:
package math_test
import "testing"
func TestMath(t *testing.T) {
// 自定义测试结构体
type MathCase struct {
n, m, result int
}
// 自定义子测试map
testGroup := map[string]MathCase{
"add": {1, 2, 3},
"sub": {3, 1, 2},
"multi": {3, 2, 6},
"div": {6, 2, 3},
}
// 测试执行函数
for name, mathCase := range testGroup {
t.Run(name, func(t *testing.T) {
s := -1
switch name {
case "add":
s = Add(mathCase.n, mathCase.m)
case "sub":
s = Sub(mathCase.n, mathCase.m)
case "multi":
s = Multi(mathCase.n, mathCase.m)
case "div":
s = Div(mathCase.n, mathCase.m)
default:
t.Fatalf("No executable testing name :%s", name)
}
if mathCase.result != s {
t.Fatalf(" add computer result error, want %d , got %d", mathCase.result, s)
}
})
}
}
go test -cover -coverprofile=cover.out
的 coverprofile
参数用来将覆盖率相关的记录信息输出到一个文件,在使用 go tool cover -html=cover.out
,结果如下图:
代码分析
Go 语言中还提供基于程序 runtime 和内存性能分析工具 pprof ,可以在程序运行的时候分析程序的运行状态信息内存占用情况,对内存进行分析采样数据,最后生成对应数据报表信息供开发者人员查找程序问题,支持的模式有命令行模式,还有 web 界面模式,报告生成模式,用的最多还是命令模式和报告生成模式。
import _ "net/http/pprof"
添加 profiling 标记。你可以选择添加这个标记到任何 go 文件中:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
// 在主程序的某处开启 pprof 监听,端口号为 6060
log.Println("start pprof server...")
go func() {
err := http.ListenAndServe("localhost:6060", nil)
if err != nil {
log.Fatalln("pprof server start failed: ", err)
}
}()
// 程序的其他逻辑代码
...
}
runtime/pprof
包提供了自定义 profile
的方法:
package main
import (
"fmt"
"os"
"runtime/pprof"
)
func main() {
// ...
// 创建一个 CPU profile 文件
f, err := os.Create("cpu.prof")
if err != nil {
panic(err)
}
defer f.Close()
// 开始 CPU profiling
if err := pprof.StartCPUProfile(f); err != nil {
panic(err)
}
defer pprof.StopCPUProfile()
// 需要分析的代码
for i := 0; i < 100000; i++ {
fmt.Println(i)
}
// ...
}
http://localhost:6060/debug/pprof/
来查看当前的 profiling 数据,这些数据包括 Goroutine、堆内存、堆内存分配、阻塞 goroutine 等等,例如下面有一段简短问题代码,可以使用这个工具进行分析:
package main
import (
"flag"
"fmt"
"os"
"runtime/pprof"
"time"
)
// 一段有问题的代码
func logicCode() {
var c chan int
for {
select {
case v := <-c:
fmt.Printf("recv from chan, value:%v\n", v)
default:
}
}
}
func main() {
var isCPUPprof bool
var isMemPprof bool
flag.BoolVar(&isCPUPprof, "cpu", false, "turn cpu pprof on")
flag.BoolVar(&isMemPprof, "mem", false, "turn mem pprof on")
flag.Parse()
if isCPUPprof {
file, err := os.Create("./cpu.pprof")
if err != nil {
fmt.Printf("create cpu pprof failed, err:%v\n", err)
return
}
pprof.StartCPUProfile(file)
defer pprof.StopCPUProfile()
}
for i := 0; i < 8; i++ {
go logicCode()
}
time.Sleep(20 * time.Second)
if isMemPprof {
file, err := os.Create("./mem.pprof")
if err != nil {
fmt.Printf("create mem pprof failed, err:%v\n", err)
return
}
pprof.WriteHeapProfile(file)
file.Close()
}
}
select
代码块里面的 channel 没有初始化,会一直阻塞着这样一个简单的代码可以通过我们肉眼出来了,但是如果是复杂的我们就需要使用 go tool pprof cpu.pprof
工具来分析具体错误在哪里,分析结果如下:
$: go tool pprof cpu.pprof
Type: cpu
Time: May 16, 2020 at 12:59pm (CST)
Duration: 20.14s, Total samples = 56.90s (282.48%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top3
Showing nodes accounting for 51.91s, 91.23% of 56.90s total
Dropped 7 nodes (cum <= 0.28s)
Showing top 3 nodes out of 4
flat flat% sum% cum cum%
23.04s 40.49% 40.49% 45.18s 79.40% runtime.selectnbrecv
17.26s 30.33% 70.83% 18.93s 33.27% runtime.chanrecv
11.61s 20.40% 91.23% 56.83s 99.88% main.logicCode
(pprof) list logicCode
Total: 56.90s
ROUTINE ======================== main.logicCode in /Users/ding/Documents/GO_CODE_DEV/src/Lets_Go/lets_37_pprof/main.go
11.61s 56.83s (flat, cum) 99.88% of Total
. . 16:// 一段有问题的代码
. . 17:func logicCode() {
. . 18: var c chan int
. . 19: for {
. . 20: select {
11.61s 56.83s 21: case v := <-c:
. . 22: fmt.Printf("recv from chan, value:%v\n", v)
. . 23: default:
. . 24:
. . 25: }
. . 26: }
(pprof)
通过 go 的 pprof 就已经分析出来了包为 main 中的 main.logicCode
函数存在一些问题,cpu 资源占用过多,如何对症下药就可以解决这些问题了。