这是我参与「第五届青训营 」笔记创作活动的第2天
一、本堂课重点内容
1. 并发编程
从并发编程的视角带大家了解Go高性能的本质
2. 依赖管理
了解Go语言依赖管理的演进路线
3. 单元测试
从单元测试实践出发,提升大家的质量意识
4. 项目实战
通过项目需求、需求拆解、逻辑设计、代码实现带领大家感受下真实的项目开发
二、详细知识点介绍
1.0 并发 VS 并行
并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行
Go 可以充分发挥多核优势,高效运行。开发者不用担心并发的底层逻辑、内存管理,只需要编写好自己的业务逻辑即可。Go语言也提供了十分强大的垃圾回收机制,开发者不用担心创建的量如何销毁。
在其他语言中,编写并发程序往往需要使用其他的并发库才能实现。而在Go语言里,想要编写一个并发程序是非常容易的事情,它不需要额外引用其他的第三方库,只需要使用“go”关键字就可以实现。
1.1 Goroutine
协程:用户态,轻量级线程,栈MB级别
线程:内核态,线程跑多个协程,栈KB级别
package main
import (
"fmt"
"time"
)
func hello(i int) {
println("hello gorountine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
func main() {
HelloGoRoutine()
}
定义:在go里面,每一个并发执行的活动成为goroutine。
详解:goroutine可以认为是轻量级的线程,与创建线程相比,创建成本和开销都很小,每个goroutine的堆栈只有几kb,并且堆栈可根据程序的需要增长和缩小(线程的堆栈需指明和固定),所以go程序从语言层面支持了高并发。
程序执行的背后:当一个程序启动的时候,只有一个goroutine来调用main函数,称它为主goroutine,新的goroutine通过go语句进行创建。
1.2 Channel
make(chan 元素类型,[缓存大小])
- 无缓冲通道 make(chan int) 接收者收到数据 happens before 发送者 goroutine 唤醒
- 有缓存通道 make(chan int,2) 当缓存未满时,向 channel 中发送消息时不会阻塞,当缓存满时可视为变成无缓冲通道,反之亦然。
channel 提供了一种通信机制,通过它可以实现一个 goroutine 向另一个 goroutine 发送消息。channel 本身还需要一个关联类型,也就是 channel 可以发送数据的类型。
ch := make(chan int)
channel 和 map 类似,make 创建了一个底层数据结构的引用,当赋值或参数传递时,只是拷贝了一个 channel 引用,指向相同的 channel 对象。和其他引用类型一样,channel 的空值为 nil。使用 == 可以对类型相同的 channel 进行比较,只有指向相同对象或同为 nil 时,才返回 true。
// channel 的读写操作
ch := make(chan int)
// write to channel
ch <- x
// read from channel
x <- ch
// another way to read
x = <- ch
关闭 channel
ch := make(chan int)
close(ch)
关闭 channel 时,你需要注意以下几点:
-
关闭一个未初始化(nil) 的 channel 会产生 panic
-
重复关闭同一个 channel 会产生 panic
-
向一个已关闭的 channel 中发送消息会产生 panic
-
从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已读出,则会读到类型的零值。从一个已关闭的 channel 中读取消息永远不会阻塞,并且会返回一个为 false 的 ok-idiom,可以用它来判断 channel 是否关闭
-
关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息
package main
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}() // A 子协程发送0~9数字
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}() // B 子协程计算输入数字的平方
for i := range dest {
// 复杂操作
println(i)
} // 主协程输出最后的平方数
}
func main() {
CalSquare()
}
1.3 CSP(Communicating Sequential Processes)
提倡通过通信共享内存而不是通过共享内存而实现通信
Go的CSP并发模型,是通过goroutine和channel来实现的。
- goroutine 是Go语言中并发的执行单位。可以理解为用户空间的线程。
- channel是Go语言中不同goroutine之间的通信机制,即各个goroutine之间通信的”管道“,有点类似于Linux中的管道。
生产者-消费者Sample:
package main
import (
"fmt"
"time"
)
// 生产者
func Producer (queue chan<- int){
for i:= 0; i < 10; i++ {
queue <- i
}
}
// 消费者
func Consumer( queue <-chan int){
for i :=0; i < 10; i++{
v := <- queue
fmt.Println("receive:", v)
}
}
func main(){
queue := make(chan int, 1)
go Producer(queue)
go Consumer(queue)
time.Sleep(1e9) //让Producer与Consumer完成
}
1.4 并发安全 Lock
互斥锁
使用互斥锁能够保证同一时间有且只有一个 Goroutine 进入临界区,其他的 Goroutine 则在等待锁;当互斥锁释放后,等待的 Goroutine 才可以获取锁进入临界区,多个 Goroutine 同时等待一个锁时,唤醒的策略是随机的。
package main
import (
"fmt"
"sync"
)
var global int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
for i := 0; i < 5000; i++ {
lock.Lock() // 加锁
global++
lock.Unlock() // 解锁
}
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(global)
}
读写锁
对于读多写少的场景下使用读写锁性能会比互斥锁好。写锁优先级高,写锁独占,读锁共享。
package main
import (
"fmt"
"sync"
"time"
)
var (
wg sync.WaitGroup
lock sync.Mutex
rwlock sync.RWMutex
)
func write() {
// lock.Lock() // 加互斥锁
rwlock.Lock() // 加写锁
time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
rwlock.Unlock() // 解写锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func read() {
// lock.Lock() // 加互斥锁
rwlock.RLock() // 加读锁
time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
rwlock.RUnlock() // 解读锁
// lock.Unlock() // 解互斥锁
wg.Done()
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
在代码中生硬的使用 time.Sleep 肯定是不合适的,Go 语言中可以使用 sync.WaitGroup 来实现并发任务的同步。
1.5 WaitGroup
WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。
- Add(n) 把计数器设置为n
- Done() 每次把计数器-1
- Wait() 会阻塞代码的运行,直到计数器地值减为0。
package main
import "sync"
func hello(i int) {
println("hello goroutine : ", i)
}
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
func main() {
ManyGoWait()
}
2.0 背景
- 工程项目不可能基于标准库0~1编码搭建
- 管理依赖库
2.1 Go 依赖管理演进
- 不同环境(项目)依赖的版本不同
- 控制依赖库的版本
2.1.1 GOPATH
GOPATH 是一个环境变量,用来表明你写的 Go 项目的存放路径,GOPATH 路径最好只设置一个,所有的项目代码都放到 GOPATH 的 src目录下。
Windows 下环境配置内容:
- 系统变量GOPATH: D:\CODEFile01\GoPro
- path添加路径:go 编译器路径和 GOPATH 对应文件夹路径
在 GOPATH 目录下新建三个文件:
- bin:用来存放编译后生成的可执行文件
- pkg:用来存放编译后生成的归档文件
- src:用来存放源码文件
在进行 Go 语言开发的时候,我们的代码总是会保存在 $GOPATH/src 目录下。在工程经过 go build、go install 或 go get 等指令后,会将下载的第三方包源代码文件放在 $GOPATH/src 目录下,产生的二进制可执行文件放在 $GOPATH/bin 目录下,生成的中间缓存文件会保存在 $GOPATH/pkg 下。
GOPATH - 弊端
- 项目A 和项B 依赖于某一 package 的不同版本 (分别为
Package V1和Package V2) 。而src下只能允许一个版本存在,那项目A 和项B 就无法保证都能编译通过。 - 在 GOPATH 管理模式下,如果多个项目依赖同一个库,则依赖该库是同一份代码,无法做到不同项目依赖同一个库的不同版本。
2.1.2 Go Vendor
- 与 GOPATH 不同之处在于项目目录下增加了 vendor 文件,所有依赖包以副本形式放在
$ProjectRoot/vendor下。 - 在 Vendor 机制下,如果当前项目存在 Vendor 目录,会优先使用该目录下的依赖;如果依赖不存在,则会从 GOPATH 中寻找。这样,通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。
Go Vendor - 弊端
但 Vendor 无法很好解决依赖包版本变动问题和一个项目依赖同一个项目依赖同一个包的不同版本的问题
- 无法控制依赖的版本
- 更新项目又可能出现依赖冲突,导致编译出错
项目A 依赖 Package B 和 Package C,而 Package B 和 Package C 又依赖了 Package D 的不同版本。通过 Vendor 的管理模式不能很好地控制对于 Package D 的依赖版本。一旦更新项目,有可能出现依赖冲突,导致编译出错。归根到底: Vendor 不能很清晰地标识依赖的版本概念。
2.1.3 Go Module
- 通过 go.mod 文件管理依赖包版本
- 通过 go get/go mod 指令工具管理依赖包
终极目标:定义版本规则和管理项目依赖关系
2.2 依赖管理三要素
- 配置文件,描述依赖 go.mod
- 中心仓库管理依赖库 Proxy
- 本地工具 go net/mod
2.3.1 依赖配置 - go.mod
go.mod 文件结构
- module:指定模块的名称(路径)
- go:依赖的原生 Go SDK 版本
- require:项目所依赖的模块
- replace:可以替换依赖的模块
- exclude:可以忽略依赖的模块
go.mod 文件修改方法
要修改 go.mod 文件,我们可以采用下面这三种方法:
- Go 命令在运行时自动修改。
- 手动编辑 go.mod 文件,编辑之后可以执行go mod edit -fmt格式化 go.mod 文件。
- 执行 go mod 子命令修改。
go mod edit -fmt # go.mod 格式化
go mod edit -require=golang.org/x/text@v0.3.3 # 添加一个依赖
go mod edit -droprequire=golang.org/x/text # require的反向操作,移除一个依赖
go mod edit -replace=github.com/gin-gonic/gin=/home/colin/gin # 替换模块版本
go mod edit -dropreplace=github.com/gin-gonic/gin # replace的反向操作
go mod edit -exclude=golang.org/x/text@v0.3.1 # 排除一个特定的模块版本
go mod edit -dropexclude=golang.org/x/text@v0.3.1 # exclude的反向操作
2.3.2 依赖配置 - version
语义化版本
${MAJOR}.${MINOR}.${PATCH}- V1.3.0
- V2.3.0
基于 commit 伪版本
vX.0.0-yyyymmddhhmmss-abcdefgh1234- v0.0.0-20220401081311-c38fb59326b7
- v1.0.0-20201130134442-10cb98267c6c
2.3.3 依赖配置 - indirect
2.3.4 依赖配置 - incompatible
2.3.4 依赖配置 - 依赖图
2.3.5 依赖分发 - 回源
2.3.5 依赖分发 - Proxy
2.3.6 依赖分发 - 变量 GOPROXY
GOPROXY = "proxy1.cn,https://proxy2.cn,…"
服务站点URL列表,“direct”表示源站
graph TD
Proxy1 --> Proxy2 --> Direct
2.3.7 工具 - go get
2.3.8 工具 - go mod
3.0 测试
测试是避免事故的最后一道屏障
回归测试 -> 集成测试 -> 单元测试
从左到右,覆盖率逐层变大,成本却逐层降低
3.1 单元测试
3.1.1 单元测试 - 规则
- 所有测试文件以
_test.go结尾 - func TestXxx(*testing.T)
- 初始化逻辑放到 TestMain 中
3.1.2 单元测试 - assert
3.1.3 单元测试 - 覆盖率
- 如何衡量代码是否经过了足够的测试?
- 如何评价项目的测试水准?
- 如何评估项目是否达到了高水准测试等级?
以上都需要依靠代码覆盖率来评判
3.1.4 单元测试 - Tips
- 一般覆盖率:50% ~ 60%,较高覆盖率80%+
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
3.2 单元测试 - 依赖
外部依赖 >= 稳定&幂等
3.3 单元测试 - 文件处理
3.4 单元测试 - Mock
快速 Mock 函数
- 为一个函数打桩
- 为一个方法打桩
3.5 基准测试
- 优化代码,需要对当前代码分析
- 内置的测试框架提供了基准测试的能力
三、实践练习例子
4.1 需求描述
社区话题页面
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
4.2 需求用例
浏览消费用户
4.3 ER 图 - Entity Relationship Diagram
4.4 分层结构
- 数据层:数据 Model,外部数据的增删改查
- 逻辑层:业务 Entity,处理核心业务逻辑输出
- 视图层:视图 view,处理和外部的交互逻辑
四、课后个人总结
难度上升,学习压力增大,尽可能的多去实践,学到东西。
五、引用参考
Go的CSP并发模型(goroutine + channel)
【GO语言基础】Go依赖管理经历了3个阶段:早期GOPATH、中期Go Vendor、 最新Go Module以及Go Mod九条操作命令:go mod init、go mod tidy