Go 语言进阶 - 工程进阶 | 青训营笔记

137 阅读7分钟

Go 语言进阶 - 工程进阶 | 青训营笔记

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天,主要记录相关的知识点。

本堂课重点内容

  • 并发编程
  • 依赖管理
  • 单元测试
  • 项目实战

并发编程

GoGo 中,实现高并发是依赖于 GoGo 中存在 goroutinegoroutine ,即协程。

相比于位于内核态的线程,位于用户态的协程更加轻量(栈空间为 KBKB 级别,线程为 MBMB 级别),一个线程可以并发启动多个协程,因此, GoGo 本身可以轻松启动上万的协程。

想要启动一个协程,可以使用关键字 gogo

go func()

涉及到并发,就不得不提 通信

通常,有两种办法实现通信:

  1. 通过通道
  2. 共享内存

GoGo 中,推荐通过使用通道来实现通信,而保留了通过共享内存实现通信的方法。

通道

通道分为有 缓冲通道无缓冲通道

无缓冲通道会导致发送的协程(简称发送方)和接收的协程(简称接收方)会同步化,而要解决这个问题,可以采取有缓冲通道。

使用 通道 或者 可以保证并发的安全,也就是不会因为并发运行导致程序脱离了控制,产生 undefinedundefined 的错误。

下面是通道和锁的示例:

package main

import (
    "fmt"
    "time"
)

func f(from string) {
    for i := 0; i < 3; i++ {
        fmt.Println(from, ":", i)
    }
}

func main() {
    f("test")
    
    go f("goroutine")  // 使用关键字go开启一个协程

    go func(msg string) {
        fmt.Println(msg)
    }("test go") // 可以使用匿名函数

    time.Sleep(time.Second)
    fmt.Println("done")
}
// 输出如下
test : 0
test : 1
test : 2
test go
goroutine : 0
goroutine : 1
goroutine : 2
done

锁:

package main

import (
    "fmt"
    "sync"
)

type Container struct { // 创建一个结构体,含有一个锁的成员
    mu       sync.Mutex // 创建一个锁
    counters map[string]int
}

func (c *Container) inc(name string) { // 对于一个锁,或者含有锁的结构,必须使用指针

    c.mu.Lock() // 加锁
    defer c.mu.Unlock() // 解锁,可以使用defer管理什么时候解锁
    c.counters[name]++
}

func main() {
    c := Container{ // 互斥锁的0值可以被默认初始化,因此这里可以不需要显示构造

        counters: map[string]int{"a": 0, "b": 0},
    }

    var wg sync.WaitGroup // 创建一个WaitGroup

    doIncrement := func(name string, n int) { // 创建一个闭包
        for i := 0; i < n; i++ {
            c.inc(name)
        }
        wg.Done() // 减一
    }

    wg.Add(3) // 表示需要等待三个协程,下面开始执行三个协程
    go doIncrement("a", 10000)
    go doIncrement("a", 10000)
    go doIncrement("b", 10000)

    wg.Wait() // 等待所有协程执行完毕
    fmt.Println(c.counters) // 由于在函数执行时对数据加锁,因此多个协程不会同时读写数据,输出符合预期
}
// 输出如下
map[a:20000 b:10000]

对于多个协程,可以使用 WaitGroupWaitGroup 进行管理。

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d starting\n", id)

    time.Sleep(time.Second) // 模拟执行任务
    fmt.Printf("Worker %d done\n", id)
}

func main() {

    var wg sync.WaitGroup // 别名,注意WaitGroup如果要显示传递到函数中,需要使用指针

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 计数器加一

        i := i // 见27行

        go func() {
            defer wg.Done() // 计数器减一
            worker(i) // 需要注意,go并发编程使用闭包和goroutine时由于会自动获取外部环境,而在gorountine内由于使用了外部变量i,因此直到整个循环结束之前都不会真正开始执行
            // goroutine,因此如果删掉23行,会发现所有的调用的worker函数执行的id都是6
        }()
    }

    wg.Wait() // 等待执行完成

}

依赖管理

在实际的生产环境中,我们应该更关心业务逻辑的实现,而非依赖、框架的组织管理,因此我们就需要有一些工具来辅助管理。

GoGo 的依赖管理经历了三个阶段: GoGo PathPath -> GoGo VendorVendor -> GoGo ModuleModule ,不同的版本使用的可能不同。

Go Path

GoGo PathPath 是默认的一个环境变量,其主要包括:

  • bin 项目编译的二进制文件
  • pkg 项目编译的中间产物,加速编译
  • src 项目源码

我们的项目代码一般都会在 srcsrc 目录下,我们还可以使用 gogetgo get 下载最新版本的包到 srcsrc

但是 GoGo PathPath 存在一些弊端, 比如如果依赖的 packagepackage 进行了升级,比如删除了某些函数,那么使用了被删除函数的项目就会出现编译错误,这就是可能导致的多版本控制问题。

Go Vendor

GoGo VendorVendor 在每个项目下都新增了一个 vendorvendor 文件夹,存储该项目所需要使用的依赖的副本。这样,一个项目如果需要使用某个依赖,就会优先从本项目的 vendorvendor 文件夹下获取。

这样就解决了多个项目需要使用同一个 packagepackage 依赖的问题。

但是 GoGo VendorVendor 仅仅标识了当前的项目所需要使用的依赖,但是,如果出现了如下情况,依旧会产生依赖冲突的问题。

1673848900190.jpg

由于项目的依赖都依赖于一个相同的 packagepackage ,但是存在不同的版本,导致编译错误。其根本原因在于 GoGo VendorVendor 仅仅只提供了依赖的源码,而没有详细标明依赖的版本信息。

Go Module

GoGo ModuleModule 是为了管理项目的依赖关系和定义版本规则而诞生的。通过引入一个新文件 go.modgo.mod 实现依赖管理。我们可以使用 gogo getgetgogo modmod 来管理工具依赖包。

一般来说,一个项目的依赖管理存在三个基本要素:

1673849292027.jpg

go.mod

go.modgo.mod 主要包括三个部分:

  • 依赖管理基本单元
  • 原生库
  • 单元依赖

每个依赖会以 [module[module path]path] [version/pseudoversion][version/pseudo-version] 的格式给出。

对于 versionversion 会以两种方式给出版本标识:

  • 语义化版本, ${MAJOR}.\{MAJOR\}.${MINOR}.\{MINOR\}.${PATCH}\{PATCH\} 的形式给出,一个 MAJORMAJOR 是一个大的版本更新,多个版本之间可能存在隔离(不兼容),一个 MINORMINOR 可以是增加了新的功能等,需要在 MAJORMAJOR 的要求下实现前后兼容,一个 PATCHPATCH 可以是小的 bugbug 的修复。语义化版本的示例如: V1.1.2V1.1.2
  • 基于 commitcommit 的伪版本标识,会包括一个版本前缀(语义化版本)+ 日期 + 12位哈希码

go.modgo.mod 中还存在一些关键字:

  • indirectindirect 标识一些间接依赖。
  • incompatibleincompatible 标识那些没有 go.modgo.mod 文件且主版本在 v2v2 以上的依赖,表示可能存在一些不兼容的代码逻辑。

在实际的依赖管理中, GoGo 总是选择最低的依赖版本来引入依赖。

Proxy

如果要引入依赖,一种方式是从第三方的代码托管平台,比如 GithubGithub 上引入,但是这可能会导致一些问题:

  1. 仓库的管理者可能会对依赖进行修改
  2. 依赖的可用性得不到保证
  3. 增加流量负担
  4. ......

为了解决这个问题, ProxyProxy 诞生了。

ProxyProxy 可以看作是一个服务站,会缓存某些依赖。作为一个稳定,可靠的依赖站点,当我们使用依赖的时候,通过 ProxyProxy 来获取依赖就会更加稳定。

当实际项目设计的时候,ProxyProxy 的设计思想有时候也很有用。

GOPROXYGOPROXY 是一个字符串,包含了多个服务站点的 URLURL 列表,相当于查找依赖的路径,如果在当前站点不存在,就会不断向上回源。

go get/mod

gogo getget 是很常用的管理指令。

go get [URL]/[package]// 基本格式,可以在package后添加几种参数

@update //更新package到最新的版本(默认使用这种方式)
@none //删除当前依赖
@[版本号] //更新到指定的版本
@[commit] //更新到某一个提交
@[branch] //更新到某一个分支的最新提交

gogo modmod 常用的指令如下:

go mod // 基本格式,可以添加参数

init //初始化,新建项目依赖
download //下载模块到本地缓存
tidy //增加需要使用的依赖,删除不需要的依赖

测试

测试主要分成三类:

  • 回归测试:可以认为是模拟最常规的使用
  • 集成测试:对系统的功能、暴露的接口进行校验
  • 单元测试:从开发者角度对实际的代码(比如函数)进行测试

从实际的成本上来说,回归测试>集成测试>单元测试,而从覆盖率上看,单元测试>集成测试>回归测试。由此可见,单元测试是非常重要的。

使用单元测试,需要使用 testing 库,在运行时使用 go test 命令。

一般情况下,被测试的代码如果名字为 initial.go 的话,测试代码的文件被称为 initial_test.go 。

单元测试的代码可以位于任何包下,而被测试的代码和测试代码通常在同一个包下。

一个单元测试的实例:

// 被测试的代码
package main


func IntMin(a, b int) int {
    if a < b {
        return a
    }
    return b
}

// 单元测试代码
package main

import (
    "fmt"
    "testing"
)

func TestIntMinBasic(t *testing.T) { // 一般测试的方法名以Test开头
    ans := IntMin(2, -2)
    if ans != -2 {

        t.Errorf("IntMin(2, -2) = %d; want -2", ans) // 该方法会输出失败信息,并继续测试,Fatal则会直接终止测试
    }
}

func TestIntMinTableDriven(t *testing.T) { // 单元测试可以以表驱动的方式同时进行多个子测试
    var tests = []struct {
        a, b int
        want int
    }{
        {0, 1, 0},
        {1, 0, 0},
        {2, -2, -2},
        {0, -1, -1},
        {-1, 0, -1},
    }

    for _, tt := range tests {

        testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
        t.Run(testname, func(t *testing.T) { // 运行子测试
            ans := IntMin(tt.a, tt.b)
            if ans != tt.want {
                t.Errorf("got %d, want %d", ans, tt.want)
            }
        })
    }
}
// 使用go test -v可以获取详细信息
=== RUN   TestIntMinBasic
--- PASS: TestIntMinBasic (0.00s)
=== RUN   TestIntMinTableDriven
=== RUN   TestIntMinTableDriven/0,1
=== RUN   TestIntMinTableDriven/1,0
=== RUN   TestIntMinTableDriven/2,-2
=== RUN   TestIntMinTableDriven/0,-1
=== RUN   TestIntMinTableDriven/-1,0
--- PASS: TestIntMinTableDriven (0.00s)
    --- PASS: TestIntMinTableDriven/0,1 (0.00s)
    --- PASS: TestIntMinTableDriven/1,0 (0.00s)
    --- PASS: TestIntMinTableDriven/2,-2 (0.00s)
    --- PASS: TestIntMinTableDriven/0,-1 (0.00s)
    --- PASS: TestIntMinTableDriven/-1,0 (0.00s)
PASS
ok      hello/test      0.184s

单元测试的覆盖率可以认为是一个测试的覆盖程度,一般覆盖率有 50%60%50\%-60\% 即可,对于特殊业务需要有 80%80\%

Mock

MockMock 可以让我们在实际测试的时候,减少对于环境的依赖,比如可能原测试文件需要使用某些文件,而我们通过 MockMock 可以用一个新的函数来替代原函数,在执行原函数时会用替代函数代替执行。

基准测试

除了单元测试之外, GoGo 还提供了更加强大的基准测试,可以对代码的执行进行实际分析,比如给出 cpucpu 的运行速度。

//函数名必须以Benchmark开头,参数必须为b *testing.B
// 运行时需要加 -bench=.参数
func BenchmarkIntMin(b *testing.B) {
    
    for i := 0; i < b.N; i++ {
        IntMin(1, 2)
    }
}
// 输出如下
goos: windows
goarch: amd64
pkg: hello/test
cpu: AMD Ryzen 7 4800H with Radeon Graphics
BenchmarkIntMin-16      1000000000               0.2447 ns/op
PASS
ok      hello/test      0.469s

个人总结

本次课程主要学习了:

  • GoGo 的并发编程基础
  • 依赖管理的概念和为什么需要依赖管理
  • 测试