Go 语言目前在云原生和服务端中间件开发领域方兴未艾,相比 Java 的在服务端和大数据应用规模还是相差疏远,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
}

另外用的比较少的为 cgo 编程,使用 //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 就提供用于单元测试的命令,例如 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 的测试用例。

最后为基准测试,基准测试场景针对是一个函数在不同条件下 CPU 和内存使用率,和函数的响应处理时间,在 Go 中这些函数使用 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 命令来生成对应注释文档了。


代码覆盖率

程序代码的覆盖率一般都是指得是代码的功能的测试代码,一个功能函数对应的代码是不是有对应的测试代码覆盖,如果一个项目的代码的测试代码覆盖非常高,那么整个项目的代码质量也比较高,目前已经有很多第三方插件可以实现这些功能有 DeepSourceCodecov ,DeepSource 不能能查看代码测试覆盖率还能通过静态分析的方式查找程序的 Bug 。

默认在 Go 测试子命令 go test 中也通过覆盖率的支持,使用 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)
            }
        })
    }
}

整个测试覆盖率支持生成 html 报表的方式进行展示,需生成对应的 cover 文件,如何通过文件生成对应 html 通过浏览器浏览,通过go test -cover -coverprofile=cover.outcoverprofile 参数用来将覆盖率相关的记录信息输出到一个文件,在使用 go tool cover -html=cover.out ,结果如下图:


代码分析

Go 语言中还提供基于程序 runtime 和内存性能分析工具 pprof ,可以在程序运行的时候分析程序的运行状态信息内存占用情况,对内存进行分析采样数据,最后生成对应数据报表信息供开发者人员查找程序问题,支持的模式有命令行模式,还有 web 界面模式,报告生成模式,用的最多还是命令模式和报告生成模式。

使用 pprof 工具时需要在代码中添加一个 profilling 标记,告诉 Go 运行时会记录程序运行时间的数据。然后你可以使用 go tool pprof 命令来分析这些数据,在代码中使用 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 资源占用过多,如何对症下药就可以解决这些问题了。


其他资料

便宜 VPS vultr
最后修改:2023 年 08 月 30 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !