『字节青训营-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): 计数器 +deltaDone(): 计数器 - 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.0V2.3.0
-
基于 commit 的伪版本
定义:
vx.0.0-yyyymmddhhmmss(时间戳)-abcdefg1234(本次的git哈希)例:
v0.0.0-20220401081311-c38fb59326b7v1.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 接口;
- 本地文件存储数据
ER 图
分层结构
