这是我参与「第五届青训营 」笔记创作活动的第2天
一、本堂课重点内容:
1、语言进阶——学习使用Go语言的并发编程,以此了解Go高性能的本质。
2、依赖管理——学习Go语言依赖管理的演进过程,并能够熟练使用go module管理依赖。
3、测试——了解测试相关概念,详细讲解单元测试。
4、项目实战——实现社区话题页面的服务端小功能(展示话题和回帖列表),不考虑前端仅实现本地Web服务。
二、详细知识点介绍:
(持续更新中~)
语言进阶——并发编程
由于工程项目需要可用性和可靠性,所以工程实践中会经常用到并发编程。
可以说Go语言就是为高并发和高性能而生的编译型语言,可以充分发挥多核CPU的优势。
Go语言中的并发程序可以用两种手段配合实现,分别是goroutine和channel,goroutine 和 channel 是 Go 语言秉承的 CSP(Communicating Sequential Process,即通信顺序进程)并发模式的重要实现基础。
goroutine
是轻量级线程,也称协程,其是由 Go 语言的运行时调度完成,属于用户态,一般栈为KB级别,在高并发场景下可以创建上万个协程,对比线程,线程是由操作系统调度完成,属于内核态,一般栈为MB级别。
eg:快速打印 hello goroutine
Code:
package main
import (
"fmt"
"time"
)
func hello(i int) {
println("hello goroutine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) { //添加关键字go,为函数创建协程
hello(j)
}(i)
}
time.Sleep(time.Second) //保证上面5个子协程执行结束前,主协程不退出
}
func main() {
HelloGoRoutine()
}
并发乱序输出hello goroutine
PS E:\go_learn> go run E:\go_learn\main.go
hello goroutine : 0
hello goroutine : 4
hello goroutine : 2
hello goroutine : 3
hello goroutine : 1
goroutine之间的通信
如果说 goroutine 是 Go语言程序的并发体的话,那么 channels 就是它们之间的通信机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。
Go语言提倡使用通信的方法代替共享内存,即提倡通过通信来共享内存,而不是通过共享内存来实现通信,当一个资源需要在 goroutine 之间共享时,通道(channel)在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。但另一种通信方式也能在go中实现。
通过通信共享内存
eg:子协程A发送0~9数字,子协程B计算输入数字的平方,主协程输出计算结果。
Code:
package main
func CalSquare() {
src := make(chan int) //无缓冲channel
dest := make(chan int, 3) //指定channel元素类型为int,缓冲大下为3
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest) //使用defer做资源延迟关闭,解决生产消速度差异问题
for i := range src { //生产数字
dest <- i * i
}
}()
for i := range dest {
//复杂操作,消费较慢,所以选择的带缓冲channel
println(i)
}
}
func main() {
CalSquare()
}
可以看到如下输出是可以保证顺序性的,也就是说是并发安全的。
PS D:\go_learn\class02\go-project-example> go run E:\go_learn\main.go
0
1
4
9
16
25
36
49
64
81
通过共享内存实现通信
eg:对变量执行两千次加操作,5个协程并发执行
Code:通过对临界区添加互斥量,保证互斥访问临界资源,进而保证并发安全。
WaitGroup
使用WautGroup实现子协程与主协程同步的问题,其内部原理相当于一个计数器,当添加一个子协程时调用Add(1),结束一个子协程时调用Done(),则主协程中的wait()语句会阻塞到计数器为0。
package main
import (
"fmt"
"sync"
)
func ManyGo() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) //添加一个
go func(j int) {
defer wg.Done() //结束一个
hello(j)
}(i)
}
wg.Wait() //阻塞
}
func main(){
ManyGo()
}
CSP
Go实现了两种并发形式,第一种是大家普遍认知的多线程共享内存,其实就是 Java 或 C++ 等语言中的多线程开发(通过共享内存实现通信);另外一种是Go语言特有的,也是Go语言推荐的 CSP(communicating sequential processes)并发模型(通过通信实现共享内存)。
Go语言就是借用 CSP 并发模型的一些概念为之实现并发的,但是Go语言并没有完全实现了 CSP 并发模型的所有理论,仅仅是实现了 process 和 channel 这两个概念。process 就是Go语言中的 goroutine,每个 goroutine 之间是通过 channel 通讯来实现数据共享。go语言有了CSP后,可以让开发人员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力。
依赖管理
在工程项目中,我们不可能基于标准库从0搭建编码,使用各种造好的轮子可以提高我们的开发效率,但不同项目(环境)依赖的工具版本不同,所以对各种不同版本的依赖管理是非常重要的。
依赖管理的三个阶段:1、GOPATH -> 2、Go Vendor -> 3、GO Module
GOPATH
对于大多数的Go语言用户,只需要配置一个名叫GOPATH的环境变量,用来指定当前工作目录即可。当需要切换到不同工作区的时候,只要更新GOPATH就可以了,例如exportGOPATH=$HOME/gobook。第二个环境变量GOROOT用来指定Go的安装目录,还有它自带的标准库包的位置,一般会自动配置。可用go env 查看环境变量配置情况。
GOPATH对应的工作区目录有三个子目录,其中src子目录用于存储源代码,pkg子目录用于保存编译后的包的目标文件,bin子目录用于保存编译后的可执行程序。
弊端:当两个项目依赖某package的不同版本时,GOPATH无法实现package的多版本控制。
Go Vendor
在GOPATH的基础之上,项目目录下增加vendor文件,所有依赖包副本形式存放在$ProjectRoot/vendor下,依赖寻址时,会先寻找vendor下的副本,当不满足要求时会去GOPATH寻找依赖,以此解决依赖冲突问题(每个项目vendor下存放其需要的package版本)。
弊端: 一个场景:当ProjectA依赖 packageB和packageC,而packageB依赖packageD-V1,packageC依赖packageD-V2时,便会出现依赖冲突问题。
Go Module
通过go.mod文件(在项目目录下)管理依赖包版本,通过go get/mod指令工具管理依赖包,进而可以定义版本规则和管理项目依赖关系,此方式在1.16版本以后默认开启。也可通过 GO111MODULE 可以开启或关闭 go module 工具。
- GO111MODULE=off 禁用 go module,编译时会从 GOPATH 和 vendor 文件夹中查找包。
- GO111MODULE=on 启用 go module,编译时会忽略 GOPATH 和 vendor 文件夹,只根据 go.mod 下载依赖。
- GO111MODULE=auto(默认值),自动模式,当项目在 GOPATH/src 目录之外,并且项目根目录有 go.mod 文件时,开启 go module。
当使用Go Moduel时,依赖管理的三要素为: go.mod、Proxy、go get/mod
go.mod——配置文件、描述依赖
go.mod文件结构:
module github.com/Moonlight-Zhao/go-project-example //指定项目路径,也即是依赖管理的基本单元
go 1.16 //指定go原生库版本号
require ( //单元依赖,指定具体的依赖版本,每个依赖包由 module path+依赖包版本号表示
github.com/gin-contrib/sse v0.1.0 // indirect //indirect表示间接依赖
github.com/gin-gonic/gin v1.3.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gorm.io/driver/mysql v1.3.3 // indirect
gorm.io/gorm v1.23.4 // indirect
)
//没有添加indirect的为直接依赖的包,即明确出现在某个import语句中。
依赖包版本号规则:1、语义化版本,版本号格式为v<major>.<minor>.<patch>(如v1.2.3),其中major为大版本,可以相互不兼容,minor为增添了一些功能函数,patch为做了一些修补工作。2、基于commit伪版本(pseudo-version),格式为vx.y.z-yyyymmddhhmmss-abcdefabcdef,格式 vx.y.z看上去像是一个真实的语义化版本,但通常并不存在该版本,所以称为伪版本。另外yyyymmddhhmmss为提交commit的时间戳,abcdefabcdef表示某个commit ID(哈希码)的前12位。
最小版本选择: Module A 依赖 Module M的v1.0.0版本,但之后 Module A 引入了 Module D,而Module D 依赖 Module M的v1.1.1版本
此时,由于依赖的传递,Module A也会选择v1.1.1版本。
需要注意的是,此时会自动选择最小可用的版本,而不是最新的tag版本(v1.1.1兼容v1.0.0)
与go.mod相关的是go.sum,功能用来记录每个依赖包的版本及哈希值,其目的是为了确保一致性构建,防止下载的依赖包有可能是被黑客恶意篡改的,以及缓存在本地的依赖包被篡改的可能。
依赖存储
GOPATH模式下,获取的依赖包存储在$GOPATH/src,该目录下只保存特定依赖包的一个版本。
而在GOMODULE模式下,获取依赖包存储在$GOPATH/pkg/mod,该目录中可以存储特定依赖包的多个版本(go get 指定下载多个版本)
$GOPATH/pkg/mod目录下有个cache目录,它用来存储依赖包的缓存,简单说,go命令每次下载新的依赖包都会在该cache目录中保存一份
Proxy——中心仓库管理依赖库
当有了go.mod对依赖进行管理后,需要考虑我们的依赖从哪里获取,即如何进行依赖分发。
回源:下图是从源站获取依赖的示意图
这样做有以下缺点:
由于源站基本是第三方代码托管平台,所以作者可以对自己的软件进行修改、删除、增加等操作,所以1、无法保证构建的稳定性,2、无法保证依赖的可用性,3、增加第三方平台负载。(另外对于大陆码农来说,从GitHub获取依赖可能比较慢或者失败)
Proxy:
GOPROXY 是Go语言官方提供的一种通过中间代理商来为用户提供包下载服务的方式,可以达到稳定可靠的效果。目前公开的代理服务器的地址有:
- goproxy.io
- goproxy.cn:(推荐)由国内的七牛云提供。
Go语言在 1.13 版本之后 GOPROXY 默认值为 https://proxy.golang.org,在国内可能会存在下载慢或者无法访问的情况,所以十分建议将 GOPROXY 设置为国内的 goproxy.cn。Windows执行go env -w GOPROXY=https://goproxy.cn,direct命令即可,表示会先从goproxy.cn获取依赖,失败后会再尝试direct源站。
go get/mod 本地工具
go.mod使用实例
实例步骤参考:www.cloudwego.io/zh/docs/her…
测试
测试是避免事故发生的最后一道屏障。测试一般分为:回归测试(终端主流场景测试)、集成测试(功能维度)、单元测试(开发阶段函数、模块的测试),覆盖率逐渐变大,成本逐渐降低。
单元测试:
所有单元测试文件以_test.go结尾,
单元测试函数命名规则:func TestXxx(*testing.T)
初始化逻辑放到TestMain中
单元测试——覆盖率
覆盖率即测试代码调用的函数执行的行数/总行数(一般50%~60%,较高80%+):
单元测试——Mock
实际上我们的测试单元(函数等)会对外部有所依赖,比如一些文件/数据库/Cache等,会导致我们在做测试时难以做到幂等(固定输入则输出不变)和稳定。此时则需要Mock机制来减少外部依赖。Mock中文翻译为假的、模拟的,其功能是为一个函数/方法打桩,在程序执行时用其打桩函数代替其原函数。(换句话说就是为某个函数制定同名的假函数,在假函数中修改有外部依赖的语句)
eg:monkey是一个比较常用的Mock包,https://github.com/bouk/monkey
Patch(A,B) 是用函数B来代替原函数A来执行,在结束后使用Unpatch把桩卸载。通过Mock则对ReadFirsrLine打桩测试,不再依赖本地文件。
基准测试
基准测试是一种测试代码性能的方法。也可以用来识别某段代码的CPU或者内存效率问题,基准测试的文件名也必须以_test.go 结尾,同时也必须导入testing 包,基准测试函数必须以 Benchmark 开头,接受一个指向 testing.B 类型的指针作为唯一参数。
eg:
对Select()函数进行基准测试,之所以并行更慢,是因为rand函数为了并发安全设置了一个全局锁。可以使用fastrand函数进行提升速度,便会快过串行执行。
三、实践练习例子:
指导示例 Github
四、课后个人总结:
本节课是为做工程项目打基础的,所以非常重要,讲解的都是工程项目中基础的并且常用的技术知识,并且也并不是非常难理解,知识需要时间熟悉和消化。