Go 语言进阶 - 工程进阶 | 青训营笔记
这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天,主要记录相关的知识点。
本堂课重点内容
- 并发编程
- 依赖管理
- 单元测试
- 项目实战
并发编程
在 中,实现高并发是依赖于 中存在 ,即协程。
相比于位于内核态的线程,位于用户态的协程更加轻量(栈空间为 级别,线程为 级别),一个线程可以并发启动多个协程,因此, 本身可以轻松启动上万的协程。
想要启动一个协程,可以使用关键字
go func()
涉及到并发,就不得不提 通信 。
通常,有两种办法实现通信:
- 通过通道
- 共享内存
在 中,推荐通过使用通道来实现通信,而保留了通过共享内存实现通信的方法。
通道
通道分为有 缓冲通道 和 无缓冲通道 。
无缓冲通道会导致发送的协程(简称发送方)和接收的协程(简称接收方)会同步化,而要解决这个问题,可以采取有缓冲通道。
使用 通道 或者 锁 可以保证并发的安全,也就是不会因为并发运行导致程序脱离了控制,产生 的错误。
下面是通道和锁的示例:
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]
对于多个协程,可以使用 进行管理。
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() // 等待执行完成
}
依赖管理
在实际的生产环境中,我们应该更关心业务逻辑的实现,而非依赖、框架的组织管理,因此我们就需要有一些工具来辅助管理。
的依赖管理经历了三个阶段: -> -> ,不同的版本使用的可能不同。
Go Path
是默认的一个环境变量,其主要包括:
- bin 项目编译的二进制文件
- pkg 项目编译的中间产物,加速编译
- src 项目源码
我们的项目代码一般都会在 目录下,我们还可以使用 下载最新版本的包到 。
但是 存在一些弊端, 比如如果依赖的 进行了升级,比如删除了某些函数,那么使用了被删除函数的项目就会出现编译错误,这就是可能导致的多版本控制问题。
Go Vendor
在每个项目下都新增了一个 文件夹,存储该项目所需要使用的依赖的副本。这样,一个项目如果需要使用某个依赖,就会优先从本项目的 文件夹下获取。
这样就解决了多个项目需要使用同一个 依赖的问题。
但是 仅仅标识了当前的项目所需要使用的依赖,但是,如果出现了如下情况,依旧会产生依赖冲突的问题。
由于项目的依赖都依赖于一个相同的 ,但是存在不同的版本,导致编译错误。其根本原因在于 仅仅只提供了依赖的源码,而没有详细标明依赖的版本信息。
Go Module
是为了管理项目的依赖关系和定义版本规则而诞生的。通过引入一个新文件 实现依赖管理。我们可以使用 或 来管理工具依赖包。
一般来说,一个项目的依赖管理存在三个基本要素:
go.mod
主要包括三个部分:
- 依赖管理基本单元
- 原生库
- 单元依赖
每个依赖会以 的格式给出。
对于 会以两种方式给出版本标识:
- 语义化版本, $$$ 的形式给出,一个 是一个大的版本更新,多个版本之间可能存在隔离(不兼容),一个 可以是增加了新的功能等,需要在 的要求下实现前后兼容,一个 可以是小的 的修复。语义化版本的示例如:
- 基于 的伪版本标识,会包括一个版本前缀(语义化版本)+ 日期 + 12位哈希码
在 中还存在一些关键字:
- 标识一些间接依赖。
- 标识那些没有 文件且主版本在 以上的依赖,表示可能存在一些不兼容的代码逻辑。
在实际的依赖管理中, 总是选择最低的依赖版本来引入依赖。
Proxy
如果要引入依赖,一种方式是从第三方的代码托管平台,比如 上引入,但是这可能会导致一些问题:
- 仓库的管理者可能会对依赖进行修改
- 依赖的可用性得不到保证
- 增加流量负担
- ......
为了解决这个问题, 诞生了。
可以看作是一个服务站,会缓存某些依赖。作为一个稳定,可靠的依赖站点,当我们使用依赖的时候,通过 来获取依赖就会更加稳定。
当实际项目设计的时候, 的设计思想有时候也很有用。
是一个字符串,包含了多个服务站点的 列表,相当于查找依赖的路径,如果在当前站点不存在,就会不断向上回源。
go get/mod
是很常用的管理指令。
go get [URL]/[package]// 基本格式,可以在package后添加几种参数
@update //更新package到最新的版本(默认使用这种方式)
@none //删除当前依赖
@[版本号] //更新到指定的版本
@[commit] //更新到某一个提交
@[branch] //更新到某一个分支的最新提交
常用的指令如下:
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
单元测试的覆盖率可以认为是一个测试的覆盖程度,一般覆盖率有 即可,对于特殊业务需要有 。
Mock
可以让我们在实际测试的时候,减少对于环境的依赖,比如可能原测试文件需要使用某些文件,而我们通过 可以用一个新的函数来替代原函数,在执行原函数时会用替代函数代替执行。
基准测试
除了单元测试之外, 还提供了更加强大的基准测试,可以对代码的执行进行实际分析,比如给出 的运行速度。
//函数名必须以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
个人总结
本次课程主要学习了:
- 的并发编程基础
- 依赖管理的概念和为什么需要依赖管理
- 测试