前言
第三节课:Go语言的进阶篇和测试的内容。
课程目录
- Go语言的并发编程
- 依赖管理
- 单元测试怎么做
1. Go语言的并发编程
Go语言可以充分发挥多核优势,高效运行。
1.1 协程的概念,开启协程
- 线程:内核态,线程跑多个协程,栈MB级别
- 协程:用户态,轻量级现场,栈KB级别
例子
package main
import (
"fmt"
"time"
)
func main() {
HelloGoRoutine()
}
func hello(i int) {
println("helo goroutine:" + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
// go 关键字,就是开启协程,例如下面这个将这个方法交给协程去运行
go func(j int) {
hello(j)
}(i)
}
// 会有更优雅的方式,使用WaitGroup
time.Sleep(time.Second)
}
/**
本代码:如何开启一个协程
*/
1.2 Communicating Sequential Process
协程数据的传输推荐的是使用通道。可以参考我这篇文章:
GO语言-10了解Go并发的使用(上) - 掘金 (juejin.cn)
1.3 Lock操作
多核CPU开启并发会有并行的效果,这就会有多个线程操作同个资源的问题了也叫临界区资源操作。属于协程安全问题,可以参考我这篇文章:GO语言-10了解Go并发中锁的概念和使用(下) - 掘金 (juejin.cn)
1.4 WaitGroup
参考我这篇文章:GO语言-10了解Go并发的使用(上) - 掘金 (juejin.cn)
WaitGroup等待goroutine的集合完成。主程序调用Add来设置等待的gor例行程序的数量。然后每个goroutine运行并在完成时调用Done。同时,Wait可用于阻塞直到所有goroutine完成。
通过这段话我们可以捕获三个点
- 在主程序中调用WaitGroup的
Add方法添加字子协程的数量 - 每个子协程运行完成时调用
Done方法 - 主程序通过使用
Wait方法等待子协程运行结束
2. 依赖管理
好家伙这里居然才讲了Go的依赖管理是怎么做的!也就是困扰了我好久的
GOPATH和GoModule
站在巨人的肩膀上编程。
2.1 Go依赖管理演进
graph TD
GOPATH --> GoVendor --> GoModule
2.1.1 GOPATH
GOPATH是以前GO建议我们定义的代码工作区,将源码全放在src中,而第三方包都在pkg中管理,所有的src都依赖pkg。
目录结构:
.
|__ bin 项目编译的二进制文件
|
|__ pkg 项目编译的中间产物,加速编译
|
|__ src 项目源码
弊端
因为src共享一份pkg,所以不能实现package的多版本控制。
2.1.2 GO Vendor
每个新项目的目录下增加vendor文件,vendor中存放当前项目依赖的副本。解决了多个项目需要同一个package依赖的冲突问题。
弊端
无法控制依赖的版本,实际上还是共享一个地方的pkg,如果依赖有冲突的话还是会导致原有问题的发生,不能清楚的定义依赖需要的版本。
2.1.3 GO Module
定义版本规则和管理项目依赖关系。我的理解是主要是go.mod解决了这个问题。我们通过以下几个点来学习这种思想。
2.2 依赖管理的三要素
和Maven的感觉是一样的。
- 配置文件,描述依赖:
go.mod - 中心仓库管理依赖库:
Proxy - 本地工具:
go get/mod
2.2.1 go.mod
- 依赖管理基本单元:标识这个模块,定位这个模块,一个模块对应一个.mod文件
- 原生库:指定这个模块Go的版本号
- 单元依赖:[模块路径][版本号],通过这种方式定位需要依赖的版本
// 依赖管理基本单元
module top.chengyunlai/go-learn
// 原生库
go 1.20
// 单元依赖
require (
github.com/goproxy/goproxy v0.14.0 // indirect
golang.org/x/mod v0.7.0 // indirect
)
2.2.1.1 单元依赖解读
为什么go.mod可以解决以上问题?答案就在这个单元依赖中。
在单元依赖中,github.com/goproxy/goproxy可以定位到该模块,该模块可能存在多个版本,在后面的v...给出,例如v 0.14.0。
我们在浏览器中输入github.com/goproxy/gop…,可以去看它的0.14.0这个版本。
2.2.1.2 版本号
Version有两种形式,上文我们看到的是其中一种,两种格式如下:
- 语义化
- 例如:
v1.3.0- 1 表示大版本,不同数字之间是不兼容的;
- 3 表示功能的增删向后兼容;
- 0 表示bug的修复。
- 例如:
- 基于commit伪版本
- 例如:
v1.0.0-20201130134442-10cb98267c6c- 前面
v1.0.0还是同样的含义,20201130134442表示时间戳;10cb98267c6c表示哈希摘要。
- 前面
- 例如:
2.2.1.3 注释
有
indirect注释和incompatible注释
1. indirect注释
每个单元依赖的后面可能还有// indirect这个注释。这是表示的是一个间接依赖。
例如下图:A用B,B用C
- A与B:直接依赖
- A与C:间接依赖
erDiagram
A }|..|{ B : uses
B }|..|{ C : uses
2. incompatible
这个注释主要是为了那些没有go.mod文件(以前的工程模块),并且这个模块依赖中有主版本是2+的,是为了提醒可能存在冲突。
2.2.2 小测试
问题描述:当前工程依赖了两个子工程:A和B;A和B又依赖了工程C;C有两个版本:1.3和1.4.最终当前工程选择的是1.4,选择最低的兼容版本。
因为:1.4是兼容1.3的,且1.4是兼容中最低的。
erDiagram
Main }|..|{ A : uses
Main }|..|{ B : uses
A }|..|{ C : uses
B }|..|{ C : uses
C ||--|{ v1-3 : contains
C ||--|{ v1-4 : contains
2.2.3 依赖分发
Proxy,它是在远程仓库和本地开发环境中间的一个桥梁。
配置GOPROXY,可以参考七牛云的配置:七牛云 - Goproxy.cn
2.2.4 工具 go get
语法:
go get 模块路径 [参数]
参数:
- @update:默认
- @none:删除依赖
- @v1.1.2:指定版本
- @23dfdd5:特定的commit
- @master:分支的最新commit
2.2.5 工具 go mod
go mod 参数
参数:
- init:初始化,创建
go.mod文件 - download:下载模块到本地缓存
- tidy:增加需要的依赖,删除不要的依赖
3. 测试
良好的测试可以降低程序上线后发生意外错误的概率。
测试大致分为3种类型:
- 回归测试:主要是QA性质,提出一个Q,测试A,即程序能否正常应答Q。
- 集成测试:验证不同组件或模块之间的集成是否正常工作,例如连接数据库是否正常。
- 单元测试:对单独的程序模块进行测试,细粒度很细。
3.1 单元测试
go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。
主要是输入进入测试单元,得到的输出是否符合预期。
在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。
| 类型 | 格式 | 作用 |
|---|---|---|
| 测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
| 基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
| 示例函数 | 函数名前缀为Example | 为文档提供示例文档 |
3.1.2 写单元测试的规则
- 测试模块的名称规则
以_test.go结尾,例如A模块叫UserService.go,那么它的测试文件叫UserService_test.go。
graph TD
UserService.go --> UserService_test.go
- 测试模块中写的测试方法命名
注意要以Test开头,可选的后缀名必须以大写字母开头。
func TestName(t *testing.T){
// ...
}
- 初始化逻辑放到TestMain中
func TestMain(m *testing.M){
// 测试前的数据装载
m.Run()
// 测试后资源释放等收尾工作
}
3.1.3 例子
目录结构:
Main.go如下:
package main
func main() {
}
func searchNameById(id int) string {
return "Chengyunlai"
}
Main_test.go如下:
package main
import "testing"
func TestMain(m *testing.M) {
println("测试之前")
m.Run()
println("测试之后")
}
func TestSearchNameById(t *testing.T) {
output := searchNameById(1)
expectOutPut := "Admin"
if output != expectOutPut {
t.Errorf("预期值是 %s,返回值是 %s", expectOutPut, output)
}
}
输出:
测试之前
=== RUN TestAdd
Main_test.go:15: 预期值是 Admin,返回值是 Chengyunlai
--- FAIL: TestAdd (0.00s)
FAIL
测试之后
3.1.4 工具assert
用来逻辑判断,免得自己写的逻辑判断符号其实在某些处理时有不妥的情况。
在命令行执行go get命令:
PS W:\go-learn> go get github.com/stretchr/testify/assert
go: downloading github.com/stretchr/testify v1.8.2
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/davecgh/go-spew v1.1.1
go: downloading github.com/pmezard/go-difflib v1.0.0
go: added github.com/davecgh/go-spew v1.1.1
go: added github.com/pmezard/go-difflib v1.0.0
go: added github.com/stretchr/testify v1.8.2
go: added gopkg.in/yaml.v3 v3.0.1
修改代码:
func TestAdd(t *testing.T) {
output := searchNameById(1)
expectOutPut := "Admin"
//if output != expectOutPut {
// t.Errorf("预期值是 %s,返回值是 %s", expectOutPut, output)
//}
assert.Equal(t, expectOutPut, output)
}
3.1.5 如何评估代码测试的质量:覆盖率
用覆盖率来评估测试的质量。
在命令行中执行:
go test .\Main_test.go .\Main.go --cover
第一个是测试模块,第二个是被测试模块,会输出如下内容:
command-line-arguments coverage: 100.0% of statements
这表示测试内容全覆盖。
原因:被测试类中的代码只有一行,这行代码被完整验证过了,所以就是100%
return "Chengyunlai"
实际项目中,一般覆盖率在50%~60%,较高的是80%多。
抛出问题,如果对数据库进行测试,那数据库如果因为我的网络不稳定怎么办?
这个问题需要我们考虑单元测试的稳定(单元测试是隔离的)和幂等(重复运行的结果是一样的)。
Mock机制解决这个问题。
3.2 Mock测试
前端的朋友们很熟悉这个词吧~
Mock测试包: 作者已经归档。
可以使用:https://github.com/bouk/monkey
go install github.com/golang/mock/mockgen
但是注意,使用它需要借助接口,不懂的人需要去补一下这方面的基础知识。我们直接改造原文视频的代码:
参考资料:
- 使用Golang的官方mock工具--gomock - Go语言中文网 - Golang中文社区 (studygolang.com)
- Go单测从零到溜系列3—mock接口测试 | 李文周的博客 (liwenzhou.com)
//package file
//
//import (
// "bufio"
// "os"
// "strings"
//)
//
//func ReadFirstLine() string {
// open, err := os.Open("log")
// defer open.Close()
// if err != nil {
// return ""
// }
// scanner := bufio.NewScanner(open)
// for scanner.Scan() { // 逐行扫描文件内容
// return scanner.Text() // 返回第一行文本
// }
// return ""
//}
//
//func ProcessFirstLine() string {
// line := ReadFirstLine() // 第一行文本在文件中是:line11
// destLine := strings.ReplaceAll(line, "11", "00")
// return destLine
//}
package file
import (
"bufio"
"os"
"strings"
)
type FileReader interface {
ReadFirstLine() string
}
type FileReaderImpl struct{}
// 需要打桩
func (f *FileReaderImpl) ReadFirstLine() string {
open, err := os.Open("log")
defer open.Close()
if err != nil {
return ""
}
scanner := bufio.NewScanner(open)
for scanner.Scan() {
return scanner.Text()
}
return ""
}
func ProcessFirstLine(reader FileReader) string {
line := reader.ReadFirstLine()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}
我的目录结构:
执行代码:mockgen -destination mocks/mock_ReadFile.go -package file -source ReadFile.go
- 执行前先创建
mocks文件夹,在ReadFile.go文件下执行上述代码,会在mocks文件夹中生成mock_ReadFile.go文件 -package file是包名,两个要统一。
修改源视频的测试类:
package file
import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"strings"
"testing"
file "top.chengyunlai/go-learn/3-day/5-mock/mocks"
)
func TestMain(m *testing.M) {
println("测试之前")
m.Run()
println("测试之后")
}
// 没有Mock
func TestProcessFirstLine(t *testing.T) {
mockReader := &FileReaderImpl{} // 创建 MockFileReader 对象
line := ProcessFirstLine(mockReader) // 调用原来的方法
expected := "line00" // 预期的返回值
assert.Equal(t, expected, line)
}
// 有Mock
func TestProcessFirstLineWithMock(t *testing.T) {
ctrl := gomock.NewController(t)
// 拆桩
defer ctrl.Finish()
// 注意,主要是这里,file是包名。
mockFileReader := file.NewMockFileReader(ctrl)
// 设置期待值
mockFileReader.EXPECT().ReadFirstLine().Return("line11")
result := ProcessFirstLine(mockFileReader)
expected := "line00"
if !strings.EqualFold(result, expected) {
t.Errorf("ProcessFirstLine返回意外结果. Got: %s, Expected: %s", result, expected)
}
}
3.3 基准测试
用来优化代码
基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:
func BenchmarkName(b *testing.B){
// ...
}
基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。
案例的前提准备:安装字节提供的pkg,里面提供了一个随机数,可以减少原生随机数加锁导致多线程性能下降的问题:
go get -u github.com/bytedance/gopkg@main
示例代码:
这段代码其实就是做一个随机值的生成,并且让数组中的某一个值等于随机值+100,模拟服务器的负载均衡。
package main
import (
"github.com/bytedance/gopkg/lang/fastrand"
"math/rand"
)
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
// 对Select函数做基本测试
func Select() int {
return ServerIndex[rand.Intn(10)]
}
// 使用字节的Rand来选择
func FastSelect() int {
return ServerIndex[fastrand.Intn(10)]
}
现在我们要用基准测试对这个代码的性能进行测试,测试的文件命名和单元测试一样:模块名_test.go,基准测试的方法名上面已经给出。
package main
import "testing"
// 单线程
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Select()
}
}
// 并行测试
func BenchmarkSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Select() // 随机函数有锁,导致性能下降
}
})
}
// 使用字节的随机数组,并行测试
func BenchmarkSelectParallelWithFastRand(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
FastSelect() // 使用字节的FastRand
}
})
}
总结:单元测试的内容,是需要根据平时编程内容做一个额外测试使用的,它应该逐渐变成代码的习惯,Go语言为我们提供了良好的测试模板:单元测试、基准测试,还有一个压力测试,而且可以方便的整合Mock,只要熟练使用,确实是开发的一大利器。只是需要习惯和多复习。