这是我参与「第五届青训营 」伴学笔记创作活动的第 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 单元测试
主要包括:输入、测试单元、输出和校对。
测试单元包括接口、函数、模块等。
校对用来保证代码的功能与我们的预期相符。
单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又不会破坏原有代码的正确性。另一方面还可以提升效率,在代码有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,它牺牲了一定的数列一致性但是并发性能提升了近百倍,适合多数场景。