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

128 阅读10分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

1 语言进阶

1.1 协程 (goroutine)

Go 语言提供了简单明了的并发编程方式:协程。

线程与协程

  • 线程:属于内核态,其创建、切换、销毁等操作都属于比较重的操作。栈的大小在MB级别。

  • 协程:属于用户态,可以理解为轻量级的线程。栈的大小在KB级别。因此 Go 可以轻松实现大量的并发协程。

协程的使用方式和实例在上一篇 Go语言基础 | 青训营笔记 - 掘金 (juejin.cn) 已经介绍了,这里就不在赘述。

1.2 CSP(communicating sequential processes)和通道 (channel)

在Go语言中,提倡通过通信共享内存和不是通过共享内存实现通信(容易发生数据竞争风险)。

实现协程间通信的方式就是通道机制。通道的详细介绍也已在 Go语言基础 | 青训营笔记 - 掘金 (juejin.cn) ,此处不在赘述。

1.3 并发安全和锁

多个协程访问一个共同的变量时,可能会产生导致出错,可以使用互斥锁来保证正确执行。

互斥锁:lock sync.Mutex,在访问共享变量前加锁,访问完后解锁。

原理详见操作系统课程中的互斥锁部分。

1.4 线程同步

可以使用WaitGroup来阻塞直至goroutine完成执行。

每增加一个协程,调用WaitGroup.Add(1)让计数器增加1

如果已明确知道要开启的协程数量,可以直接用WaitGroup.Add()增加相应数目。

每完成一个协程,调用wg.Done()来让计数器减少1

主goroutine调用wg.Wait()来等待其他goroutine的执行。

以下是一个示例

import (
	"sync"
)

type httpPkg struct{}

func (httpPkg) Get(url string) {}

var http httpPkg

func main() {
	var wg sync.WaitGroup
	var urls = []string{
		"http://www.golang.org/",
		"http://www.google.com/",
		"http://www.example.com/",
	}
	for _, url := range urls {
		// 计数器增加1
		wg.Add(1)
		// 启动一个协程来访问 url
		go func(url string) {
			// 协程完成执行,调用wg.Done()来让计数器减少1
			defer wg.Done()
			// 访问URL
			http.Get(url)
		}(url)
	}
	// 主协程阻塞直至其余协程完成执行
	wg.Wait()
}

2 依赖管理

此部分主要讲解 Go 中依赖管理机制的演变过程:Gopath -> Go Vendor -> Go Module

2.1 GOPATH

GOPATH 是早期的依赖管理机制,使用一个全局的 GOPATH 路径来存放了全局的第三方依赖包。当我们在代码里面 import 某个第三方包时,编译器都会到 GOPATH 路径下面来寻找。

GOPATH 目录可以指定多个位置,不过用户一般很少这样做。如果没有人工指定 GOPATH 环境变量,编译器会默认将 GOPATH 指向的路径设定为 ~/go 目录。可以使用go env GOPATH命令看看自己的 GOPATH 指向哪里。

GOPATH 目录下有 3 个目录:

  • bin:存放第三方包提供的二进制可执行文件
  • pkg:存放编译好的第三方包对象
  • src:存放第三方包的源码

可以使用 go get命令下载最新版本的包到src目录下

当我们导入第三方包时,编译器优先寻找已经编译好的包对象,如果没有包对象,就会去源码目录寻找相应的源码来编译。使用包对象的编译速度会明显快于使用源码。

GOPATH 的缺点是:无法实现 package 的多版本控制,例如两个项目依赖同一 package 的不同版本的情形。

2.2 Go Vendor

在项目目录下增加 vendor 目录,将所有依赖包以副本形式放在 vendor 目录下

依赖的寻址方式为:优先去 vendor 目录里找需要的第三方包,如果没有,再去 GOPATH 全局路径下找。这样就解决了前面提到的 GOPATH 的缺点。

但是 Go Vendor 仍存在缺点:

  • 无法控制依赖的版本
  • 更新项目时又可能出现以来冲突,导致编译出错

2.3 Go Module

为了能实现对依赖的版本控制, Go Module 诞生了。

简单来说,Go Modules 是语义化版本管理的依赖项的包管理工具;它解决了之前的依赖机制存在的缺陷,并且是 Go 官方推出的工具。

有了 Go Module 后,可以:

  • 通过 go.mod 文件管理依赖包的版本
  • 通过 go get/mod命令工具管理依赖包

Go Module 满足了依赖管理三要素:

  • 配置文件,描述依赖:go.mod
  • 中心仓库管理依赖库:Proxy
  • 本地工具:go get/mod

2.3.1 go.mod

go.mod 定义了模块的module path(也是模块根目录的导入路径)以及模块依赖的其他模块的要求,满足了依赖要求模块才能被成功构建起来。

go.mod 文件包含3部分:模块路径、原生库版本和单元依赖,下面是一个例子:

 module example/project/app // 模块路径
 ​
 go 1.16 // 原生库版本
 ​
 // 单元依赖
 require (
     example/lib1 v1.0.2
     example/lib2 v1.0.0 // indirect
     example/lib2 v0.1.0-20190725025543 // indirect
 )
 ​

上面的 //indirect 指的是间接依赖。例如:A->B->C,那么A对B是直接依赖,对C是间接依赖

依赖的版本命名主要有两种方式:

  • 语义化版本:$v{MAJOR}.${MINOR}.${PATCH},例如v1.3.0

    • MAJOR是大版本,不同的大版本之间可能互相不兼容
    • MINOR是小版本,通常是新增了功能、方法
    • PATCH是补丁,通常是修复bug
  • 基于commit的伪版本:vx.0.0-yyyymmddhhmmss-abcdefgh1234

    • 来源于git版本控制中的commit
    • 第一部分是版本号前缀(同语义化版本),第二部分是commit时间,第三部分是commit的哈希码前缀。

2.3.2 依赖图

如果项目X依赖了A、B两个项目,且A、B分别依赖了C项目的v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为?

答案是1.4。

选取原则是:选最低的兼容版本。

2.3.3 Proxy

直接使用版本管理仓库(如 github )下载依赖的方式存在多个问题:

  • 无法保证构建稳定性:软件作者可以直接在代码平台增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本
  • 无法保证依赖可用性:依赖软件的作者可以直接在代码平台删除软件,导致依赖不可用
  • 增加第三方压力:可能会大幅增加第三方代码托管平台的负载

Go Proxy 就是解决这些问题的方案。

Go Proxy 是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,且在源站删除之后仍然可用,从而实现了"immutability"和"available"的以来分发。

使用 Go Proxy 后,构建时会直接从 Go Proxy 站点拉取依赖。

Go Module 通过 GOPROXY 环境变量控制如何使用 Go Proxy。GOPROXY 是一个 Go Proxy 站点的url列表,可以使用direct表示源站,例如:GOPROXY="proxy1.cn, proxy2.cn, direct",优先级从左向右依次降低。

2.3.4 go get

go get example.org/pgk 用于获取某个依赖。后续可接:

  • @update 默认
  • @none 删除依赖
  • @v1.1.2 tag版本,于一版本
  • @23dfdd5,特定的commit
  • @master,分支的最新commit

2.3.5 go mod

go mod 用于管理依赖,后续可接:

  • init 初始化,创建go.mod文件
  • download 下载模块到本地缓存
  • tidy 增加需要的依赖,删除不需要的依赖

3 测试

测试是避免事故的最后一道屏障。

测试一般分为:

  • 回归测试:一般是QA同学(负责质量保证)手动通过终端测试一些固定的主流程场景
  • 集成测试:对系统进行功能维度的测试验证
  • 单元测试:开发者对单独的函数、模块做功能验证

回归测试 -> 集成测试 -> 单元测试,从左到右,测试成本逐渐降低,测试覆盖率逐步上升,因此单元测试一定程度上决定了代码的质量

3.1 单元测试

主要包括:输入、测试单元、输出和校对。

image-20230115215654158

测试单元包括接口、函数、模块等。

校对用来保证代码的功能与我们的预期相符。

单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又不会破坏原有代码的正确性。另一方面还可以提升效率,在代码有bug的情况下,编写单元测试可以在一个较短周期内定位和修复问题。

3.1.1 规则

  • 所有测试文件以 _test.go 结尾
  • 函数命名:func TestXxx(t *tesing.T)。其中参数 t 用于报告测试失败和附加的日志信息
  • 初始化逻辑放到 TestMain 中:包含三部分:数据装载和配置初始化、运行函数、释放资源等收尾工作

示例:

 func HelloTom() string {
     return "Jerry"
 }
 ​
 func TestHelloTom(t *tesing.T) {
     output := HelloTom()
     expectOuput := "Tom"
     if outpu != expectOutput {
         t.Errorf("Expected %s does not match actual %s", expectOutput, output)
     }
 }

运行测试:go test [flags] [packages]

例如:go test judgment_test.go judgment.go

3.1.2 assert

也可以使用断言(assert)机制来提高测试代码的开发效率,类似其他语言中的断言。

可以从第三方库中使用 assert ,例如:github.com/stretchr/testify/assert

3.1.3 覆盖率

go test 添加 --cover 就可以计算覆盖率。

计算方式:成功运行的代码行数 / 总代码行数。

因此,单元测试要保证函数中每一行都经过测试。

tips:

  • 通常覆盖率达到50%到60%即可实现主要功能正常运作。
  • 测试分支应该相互独立、全面覆盖
  • 测试单元粒度应该足够小,函数单一职责

3.2 mock 测试

复杂的项目,一般会依赖很多东西,包括包、文件、数据库、缓存等。

单元测试需要保证稳定性和幂等性:

  • 稳定性:相互隔离,能在任何时间、任何环境运行测试
  • 幂等性:每一次测试运行都应该产生与之前一样的结果。

为了实现这一目的,需要用到mock机制。

monkey是一个常用的mock测试包,它可以对方法或者实例的方法进行mock

补充:什么是 mock

mock是在测试过程中,对于一些不容易构造/获取的对象,创建一个mock对象来模拟对象的行为。比如说你需要调用B服务,可是B服务还没有开发完成,那么你就可以将调用B服务的那部分给Mock掉,并编写你想要的返回结果。

3.3 基准测试

基准测试是测量一个程序在固定工作负载下的性能。

基准测试函数和普通测试函数写法类似,但是以Benchmark为前缀名,并且带有一个*testing.B类型的参数;*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。

常用写法:

func BenchmarkXxxx(b *testing.B) {
    [数据装载和初始化]
    b.ResetTimer()
    for i:=0; i<b.N; i++ {
        Xxxx(args)
    }
}

运行基准测试的命令:go test -bench=[testlist]

测试结果解析:BenchmarkIsPalindrome-8 1000000 1035 ns/op 这个结果表明执行了8线程下运行BenchmarkIsPalindrome 1,000,000次,平均每次用时1035ns。

Go 还提供了并发的Benchmark

func BenchmarkXxxx(b *testing.B) {
    [数据装载和初始化]
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for i:=0; i<b.N; i++ {
        	Xxxx(args)
    	}
    })
}

有些情况下,可能并发化测试反而更慢,例如实例函数中使用了 Go 标准库的 rand 生成随机数,为保证全局的随机性和安全性,持有一个全局锁,这导致了并发执行变慢。

在实际场景中,如果对全局的随机性和安全性不是特别严格,可以考虑使用第三方rand库,例如github.com/bytedance/g… 中的fastrand,它牺牲了一定的数列一致性但是并发性能提升了近百倍,适合多数场景。