前情提要:

这堂课主要学习企业实际项目开发中所涉及到的一系列知识点

语言进阶 - 协程

并发 VS 并行

  • 并发:多线程程序在一个核的 CPU 上运行

  • 并行:多线程程序在多个核的 CPU 上运行

(Go 可以重复发挥多核优势,高效运行)

Goroutine

  • 线程:内核态,线程跑多个协程,栈 MB 级别
  • 协程:用户态,轻量级线程,栈 KB 级别

一个简单的线程例子,快速打印 hello goroutine 0 ~ 4

1
2
3
4
5
6
7
8
9
10
func hello(i int) {
println("hello goroutine :" + fmt.Sprint(i))
}

func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go hello(i) // 使用 go 关键字开启协程
}
time.Sleep(time.Second * 1) // 保证子协程执行完毕之前,主函数不退出,后面会有更好的方式
}

可以看到不是按照顺序输出的,所以其实是并行输出的

协程间通信:CSP(Communicating Sequential Processes)

Go 提倡使用通道来实现协程间通信(通过通信共享内存)

当然,Go 也保留了通过共享内存实现通信的机制,但是效率低,不推荐

Channel

创建一个通道: make(chan 元素类型,[缓冲大小])

  • 无缓冲的例子: make(chan int)
  • 有缓冲的例子: make(chan int,2)

缓冲就类似于快递站,需要有人取了元素出来才能放入元素,不然就一直阻塞

一个简单的通道例子:

  • A 子协程发送 0~9 的数字
  • B 子协程计算输入数字的平方
  • 主协程输出最后的平方数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src) // 延迟关闭 channel
for i := 0; i < 10; i++ {
src <- i // 把元素放入 src 通道
}
}()
go func() {
defer close(dest) // 延迟关闭 channel
for i := range src { // 从 src 通道取出元素
dest <- i * i // 把平分数放入 dest 通道
}
}()
for i := range dest { // 取出平方数并打印
//复杂操作
println(i)
}
}

可以看见通道是能保证顺序的,也就是并发安全的

为什么使用了带缓冲的 channel ?因为消费者可能需要执行一些复杂操作,耗时可能较长,使用缓冲可以不影响生产者的生产速度

并发安全 Lock

前面讲了协程间还可以通过临界区来进行通信,但是这时一定要注意并发安全,也就是要加锁,可以看下面的这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var (
x int64
lock sync.Mutex // 创建一个锁
)

func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock() //加锁
x += 1
lock.Unlock() // 解锁
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1 //不加锁
}
}

func Add() {
x = 0
for i := 0; i < 5; i++ { // 连开 5 个协程
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:", x)
x = 0
for i := 0; i < 5; i++ { // 连开 5 个协程
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
}

这里分别有两个函数,一个是加锁的,一个是不加锁的,它们分别对一个变量连加 2000 次,而各自又被调用了 5 个协程,所以理论上每个变量又应该加了 10000 次

但是从结果来看,因为不加锁,所以修改变量时产生了混乱,不加锁的加不到 10000 次

所以为了并发安全,多协程修改一个变量时一定要加锁

WaitGroup

前面例子中都是用 Sleep 来进行暴力的阻塞,由于无法精确的知道协程执行的时间,也就无法精确地设定 Sleep 的时间

在 Go 中,可以使用 sync 包中的 WaitGroup 来实现并发的同步,它有几个方法:

  • Add(delta int) : 计数器 + delta
  • Done() : 计数器 - 1
  • Wait() : 阻塞直到计数器为 0 ,等待所有协程执行完
1
2
3
4
5
6
7
8
9
10
11
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5) // 设置计算器为 5
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done() // 执行完函数后,将计算器减 1
hello(j)
}(i)
}
wg.Wait() // 始终阻塞,直到计算器为 0
}

小结

  • Goroutine :理解协程
  • Channel : 使用通道进行协程间通信
  • Sync : 学会使用这个包中的 LockWaitGroup

#依赖管理

背景

在一个项目中,要学会使用他人的组件或工具来提高研发效率

这时就出现了依赖

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 getgo mod 指令管理依赖包

依赖管理三要素

  1. 配置文件,描述依赖: go.mod
  2. 中心仓库管理依赖库: Proxy
  3. 本地工具: 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 依赖管理方案
    1. 配置文件,描述依赖:go.mod
    2. 中心仓库管理依赖库:Proxy
    3. 本地工具:go get/mod

测试

为什么要测试

真实的事故例子:

测试是避免事故的最后一道屏障

测试的分类

  • 回归测试:质量保证人员手动测试项目可用性(刷抖音、看评论)
  • 集成测试:对系统功能的测试(对暴露的接口自动化测试)
  • 单元测试:开发者对单独的函数模块测试

单元测试

单元测试的规则

  • 所有测试文件都以 _test.go 结尾
  • 测试函数写成 func TextXxx(t *testing.T)
  • 初始化逻辑放到 TestMain 中(准备测试的数据->跑测试->释放资源)

单元测试的简单例子

新建一个 test 模块,按照下面的目录创建好文件

1
2
3
└─test
print.go
print_test.go

print.go 中新建一个函数,用于打印 Tom

但是由于疏忽, Tom 变成了 John

1
2
3
4
5
package test

func HelloTom() string {
return "John"
}

现在到 print_test.go 进行测试

1
2
3
4
5
6
7
8
9
10
package test

import "testing"
import "github.com/stretchr/testify/assert"

func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
assert.Equal(t, expectOutput, output)
}

对比输出和预计的输出,这里选择使用一个第三方的包来进行对比

按下左侧的按钮开始测试(我已经测试过了,所以是个叉)

发现与预期不符

现在修改 HelloTom 函数,再进行测试

可以看到修改后通过了测试

单元测试 - 覆盖率

  • 如何衡量代码是否已经经过了足够的测试?

  • 如何评价项目的测试水准?

  • 如何评价项目是否达到了高水准测试等级?

答案就是: 代码覆盖率

来看第二个例子,一个判断学生是否及格的函数

新建 judgment.go

1
2
3
4
5
6
7
8
package test

func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}

然后是 judgment_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package test

import (
"github.com/stretchr/testify/assert"
"testing"
)

func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}

func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t, false, isPass)
}

在测试时使用覆盖率

这样,就可以知道测试时调用了文件中的多少行语句

测试多种输入可以提升覆盖率

可以看见,把另一个输入为 50 的测试完后,judgment.go 的覆盖率达到了 100%

单元测试 Tips:

  • 一般覆盖率:50%~60%,较高覆盖率:80%+
  • 测试分支相互独立、全面覆盖
  • 测试单元粒度足够小,函数单一职责

单元测试 - 依赖

当然,一般在测试时会依赖于一些组件,如数据库、文件之类的

单元测试需要有两个目标:幂等与稳定

  • 幂等:重复运行,结果相同
  • 稳定:任何时间,任何函数,独立运行

但是测试时直接调用数据库等肯定是不稳定的,因为需要依赖网络,这样就会用到 Mock 机制

单元测试 - 文件处理

在讲 Mock 之前,先从文件出发

例如现在有一个处理文本的函数,它将第一行中的 11 都替换为 00

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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()
destLine := strings.ReplaceAll(line, "11", "00")
return destLine
}

准备一个 log 测试样例

1
2
3
4
5
6
7
8
9
line11
line22
line33
line44
line55




然后我们就可以这样写测试函数

1
2
3
4
func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}

但是有一个问题,就是这个测试依赖于 log 文件(实践生产中可能是数据库等资源),一旦 log 无法访问便无法测试,这时就需要 Mock

Mock 测试

Mock 就是打桩,在测试时使用一个函数或方法替换另一个函数或方法(在运行时替换函数的指针),例如在上面使用 ReadFirstLine() 来读取数据,而我们可以用一个函数生成数据,然后替换掉那个函数

常见的用于实现 Mock 的包是 monkey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package test

import (
"bou.ke/monkey"
"github.com/stretchr/testify/assert"
"testing"
)

func TestProcessFirstLine(t *testing.T) {
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}

func TestProcessFirstLineWithMock(t *testing.T) {
monkey.Patch(ReadFirstLine, func() string {
return "line110"
})
defer monkey.Unpatch(ReadFirstLine)
line := ProcessFirstLine()
assert.Equal(t, "line000", line)
}

在这里,使用一个匿名函数替换掉了原来的函数,测试不再依赖于本地文件,可以在任何时间运行

基准测试

自带的代码性能测试工具,它的方法类似于单元测试

基准测试 - 例子

举一个负载均衡服务器的例子,初始化 10 个服务器,然后通过 Select() 函数随机挑选服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package benchmark

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
}
}

func Select() int {
return ServerIndex[rand.Intn(10)]
}

func FastSelect() int {
return ServerIndex[fastrand.Intn(10)]
}

现在来运行基准测试,它与单元测试的不同在于关键词变成了 Benchmark

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package benchmark

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 BenchmarkFastSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
FastSelect()
}
})
}

项目实战

需求设计

  1. 实现一个展示话题(标题,文字描述)和回帖列表的后端 http 接口;
  2. 本地文件存储数据

ER 图

img

分层结构

Repository

索引

查询

Service

Controller

Router

运行