『字节青训营-3rd』L2:Go 语言上手 - 工程实践
前情提要:
- 没错,这是昨天上午的课,但是昨天太忙了就一直拖到现在来写了
(其实今天也很忙) - 配套实例代码:https://github.com/Moonlight-Zhao/go-project-example/tree/V0
这堂课主要学习企业实际项目开发中所涉及到的一系列知识点
语言进阶 - 协程
并发 VS 并行
-
并发:多线程程序在一个核的 CPU 上运行
-
并行:多线程程序在多个核的 CPU 上运行
(Go 可以重复发挥多核优势,高效运行)
Goroutine
- 线程:内核态,线程跑多个协程,栈 MB 级别
- 协程:用户态,轻量级线程,栈 KB 级别
一个简单的线程例子,快速打印 hello goroutine 0 ~ 4
:
1 | func hello(i int) { |
可以看到不是按照顺序输出的,所以其实是并行输出的
协程间通信:CSP(Communicating Sequential Processes)
Go 提倡使用通道来实现协程间通信(通过通信共享内存)
当然,Go 也保留了通过共享内存实现通信的机制,但是效率低,不推荐
Channel
创建一个通道: make(chan 元素类型,[缓冲大小])
- 无缓冲的例子:
make(chan int)
- 有缓冲的例子:
make(chan int,2)
缓冲就类似于快递站,需要有人取了元素出来才能放入元素,不然就一直阻塞
一个简单的通道例子:
- A 子协程发送 0~9 的数字
- B 子协程计算输入数字的平方
- 主协程输出最后的平方数
1 | func CalSquare() { |
可以看见通道是能保证顺序的,也就是并发安全的
为什么使用了带缓冲的 channel ?因为消费者可能需要执行一些复杂操作,耗时可能较长,使用缓冲可以不影响生产者的生产速度
并发安全 Lock
前面讲了协程间还可以通过临界区来进行通信,但是这时一定要注意并发安全,也就是要加锁,可以看下面的这个例子
1 | var ( |
这里分别有两个函数,一个是加锁的,一个是不加锁的,它们分别对一个变量连加 2000 次,而各自又被调用了 5 个协程,所以理论上每个变量又应该加了 10000 次
但是从结果来看,因为不加锁,所以修改变量时产生了混乱,不加锁的加不到 10000 次
所以为了并发安全,多协程修改一个变量时一定要加锁
WaitGroup
前面例子中都是用 Sleep
来进行暴力的阻塞,由于无法精确的知道协程执行的时间,也就无法精确地设定 Sleep
的时间
在 Go 中,可以使用 sync
包中的 WaitGroup
来实现并发的同步,它有几个方法:
Add(delta int)
: 计数器 +delta
Done()
: 计数器 - 1Wait()
: 阻塞直到计数器为 0 ,等待所有协程执行完
1 | func ManyGoWait() { |
小结
- Goroutine :理解协程
- Channel : 使用通道进行协程间通信
- Sync : 学会使用这个包中的
Lock
和WaitGroup
#依赖管理
背景
在一个项目中,要学会使用他人的组件或工具来提高研发效率
这时就出现了依赖
Go 依赖管理演进
三个阶段:GOPATH
-> GO Vendor
-> Go Module
目标:
- 实现不同环境(项目)依赖的版本不同
- 控制依赖库的版本
GOPATH
GOPATH 下有三个文件夹:
bin
: 项目编译的二进制文件pkg
: 项目编辑的中间产物,加速编译src
: 项目源码
所有项目和依赖源码都在 src
GOPATH - 弊端
A 和 B 依赖于一个包的不同版本
无法实现包的多版本控制
Go Vender
在项目下增加 vender 文件夹,所有依赖放在 $ProjectRoot/vendor
,找不到再去 GOPATH
解决了多个项目需要同一个包的冲突问题
Go Vender 弊端
无法控制依赖的版本
更新项目可能出现依赖冲突
Go Module
从 1.1 引入, 1.6 默认开启
- 通过
go.mod
文件管理依赖包版本 - 通过
go get
与go mod
指令管理依赖包
依赖管理三要素
- 配置文件,描述依赖:
go.mod
- 中心仓库管理依赖库:
Proxy
- 本地工具:
go get/mod
依赖配置 - go.mod
由三部分组成:模块路径
、原始库版本
、单元依赖
依赖标识:[Module Path][Version/Pseudo-version]
依赖配置 - version
-
语义化版本
定义:
${MAJOR}.${MINOR}.${PATCH}
- MAJOR:大版本,各版本直接可以不相互兼容
- MINOR:新增函数或功能,在一个大版本下应当相互兼容
- PATCH:修 bug
例:
V1.3.0
V2.3.0
-
基于 commit 的伪版本
定义:
vx.0.0-yyyymmddhhmmss(时间戳)-abcdefg1234(本次的git哈希)
例:
v0.0.0-20220401081311-c38fb59326b7
v1.0.0-20201130134442-10cb98267c6c
依赖配置 - indirect
关键字之 indirect
,标识是否为间接依赖(依赖的包所依赖的包)
A -> B -> C
- A -> B 直接依赖
- A -> C 间接依赖
依赖配置 - incompatible
关键字之 incompatible
按照 Go Module 的标准,如果大版本大于 1 的话,要在路径中也加入 vN
后缀,但是 Go Module 推出之前已经有很多库的版本到了 2 或更高了,这时就需要加上这个关键字来兼容这部分仓库
依赖配置 - 依赖图
你可能会选 C ,但其实是 B ,Go Module 会选择最近的兼容版本(1.3 和 1.4 按理来说是兼容的)
依赖分发 - 回源
关于依赖去哪里下载的问题,主要是 Github 等第三方代码仓库,但是这会带来一系列弊端:
- 无法保证构建稳定性(增删改)
- 无法保证依赖可用性(仓库被删了)
- 增加第三方压力(代码托管平台负载问题)
依赖分发 - Proxy
为了解决这个问题,就出现了 Go Proxy
这东西会缓存依赖的内容,保证依赖的稳定与可靠
依赖分发 - 变量 GOPROXY
1 | GOPROXY="https://proxy1.cn,https://proxy2.cn,direct" |
direct
表示源站点,Go 会按照顺序的优先级找依赖
工具 - go get
1 | go get example.org/pkg [参数] |
关于参数:
- 不加参数:拉取主版本的最新提交
@upadte
:跟不加一样@none
:在本地删除这个依赖@v1.1.2
:拉取对应的语义版本@23dfdd5
:拉取特定的 commit@master
:拉取某分支的最新提交
工具 - go mod
1 | go mod 参数 |
关于参数:
init
:初始化,创建 go.mod 文件download
:下载模块到本地缓存tidy
:增加需要的依赖,删除不需要的依赖
小结
- Go 依赖管理演进
- Go Module 依赖管理方案
- 配置文件,描述依赖:go.mod
- 中心仓库管理依赖库:Proxy
- 本地工具:go get/mod
测试
为什么要测试
真实的事故例子:
测试是避免事故的最后一道屏障
测试的分类
- 回归测试:质量保证人员手动测试项目可用性(刷抖音、看评论)
- 集成测试:对系统功能的测试(对暴露的接口自动化测试)
- 单元测试:开发者对单独的函数模块测试
单元测试
单元测试的规则
- 所有测试文件都以
_test.go
结尾 - 测试函数写成
func TextXxx(t *testing.T)
- 初始化逻辑放到
TestMain
中(准备测试的数据->跑测试->释放资源)
单元测试的简单例子
新建一个 test 模块,按照下面的目录创建好文件
1 | └─test |
在 print.go
中新建一个函数,用于打印 Tom
但是由于疏忽, Tom 变成了 John
1 | package test |
现在到 print_test.go
进行测试
1 | package test |
对比输出和预计的输出,这里选择使用一个第三方的包来进行对比
按下左侧的按钮开始测试(我已经测试过了,所以是个叉)
发现与预期不符
现在修改 HelloTom
函数,再进行测试
可以看到修改后通过了测试
单元测试 - 覆盖率
-
如何衡量代码是否已经经过了足够的测试?
-
如何评价项目的测试水准?
-
如何评价项目是否达到了高水准测试等级?
答案就是:
来看第二个例子,一个判断学生是否及格的函数
新建 judgment.go
1 | package test |
然后是 judgment_test.go
1 | package test |
在测试时使用覆盖率
这样,就可以知道测试时调用了文件中的多少行语句
测试多种输入可以提升覆盖率
可以看见,把另一个输入为 50 的测试完后,judgment.go
的覆盖率达到了 100%
单元测试 Tips:
- 一般覆盖率:50%~60%,较高覆盖率:80%+
- 测试分支相互独立、全面覆盖
- 测试单元粒度足够小,函数单一职责
单元测试 - 依赖
当然,一般在测试时会依赖于一些组件,如数据库、文件之类的
单元测试需要有两个目标:幂等与稳定
- 幂等:重复运行,结果相同
- 稳定:任何时间,任何函数,独立运行
但是测试时直接调用数据库等肯定是不稳定的,因为需要依赖网络,这样就会用到 Mock 机制
单元测试 - 文件处理
在讲 Mock 之前,先从文件出发
例如现在有一个处理文本的函数,它将第一行中的 11
都替换为 00
1 | func ReadFirstLine() string { |
准备一个 log 测试样例
1 | line11 |
然后我们就可以这样写测试函数
1 | func TestProcessFirstLine(t *testing.T) { |
但是有一个问题,就是这个测试依赖于 log 文件(实践生产中可能是数据库等资源),一旦 log 无法访问便无法测试,这时就需要 Mock
Mock 测试
Mock 就是打桩,在测试时使用一个函数或方法替换另一个函数或方法(在运行时替换函数的指针),例如在上面使用 ReadFirstLine()
来读取数据,而我们可以用一个函数生成数据,然后替换掉那个函数
常见的用于实现 Mock 的包是 monkey
1 | package test |
在这里,使用一个匿名函数替换掉了原来的函数,测试不再依赖于本地文件,可以在任何时间运行
基准测试
自带的代码性能测试工具,它的方法类似于单元测试
基准测试 - 例子
举一个负载均衡服务器的例子,初始化 10 个服务器,然后通过 Select()
函数随机挑选服务器
1 | package benchmark |
现在来运行基准测试,它与单元测试的不同在于关键词变成了 Benchmark
1 | package benchmark |
项目实战
需求设计
- 实现一个展示话题(标题,文字描述)和回帖列表的后端 http 接口;
- 本地文件存储数据