这是我参与「第五届青训营 」伴学笔记创作活动的第2天
课程内容相关代码链接 github.com/Moonlight-Z…
讲师: 赵征
1 并发编程
从并发编程的视角了解Go高性能的本质
属于编程进阶内容,考虑到工程项目的可用性和可靠性,工程实践中经常会用到。
协程Goroutine
并发 vs 并行
并发: 多线程程序在一个核的CPU上运行
并行: 多线程程序在多个核的CPU上运行
Go可以充分发挥多核优势, 高效运行
线程 vs 协程
协程(属用户态): 用户态, 轻量级线程, 栈KB级别
线程(属内核态): 内核态, 线程跑多个协程, 栈MB级别
体验协程
func hello(i int) {
println("hello gorountine: " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
// 是为了保证子协程完成前, 主线程不退出
// 之后会用更优雅的方式: 用WaitGroup
time.Sleep(time.Second)
}
通道Channel
CSP (Communicating Sequential Processes)
通信顺序进程
⭐理念: 不要以共享内存的方式来通信, 要用通信来共享内存
Channel 使用
make(chan 元素类型, [缓冲大小])
无缓冲通道(也被称为同步通道) make(chan int)
有缓冲通道 make(chan int, 2)
// 经典模型⭐
func CalSquare() {
// src实现协程A和B的通信
src := make(chan int)
// dest实现协程B和主协程的通信
// 这里用有缓冲通道, 是考虑到主协程复杂操作速度较慢,借缓冲协调速度不均衡
dest := make(chan int, 3)
go func() { // 协程A: 生产, 子协程发送0-9数字
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() { // 协程B: 消费, 计算输入数字的平方
defer close(dest)
for i := range src {
dest <- i * i
}
}()
// 主协程输入最后的平方数
for i := range dest {
// 借println表示复杂操作
println(i)
}
}
锁Lock (并发安全)
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 main() {
x = 0
for i := 0; i < 50; i++ {
// 这是未定义行为, 应避免
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:", x)
x = 0
for i := 0; i < 50; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
}
// WithoutLock: 75984
// WithLock: 100000
实际项目中, 并发安全问题是有一定概率引起错误结果的, 较难定位
⭐ 应避免对共享内存, 进行非并发安全的读写操作
线程同步WaitGroup
WaitGroup
Add(delta int) // 计数器+delta
Done() // 计数器-1
Wait() // 阻塞直到计数器为0
使用步骤:
开启协程+1
执行结束-1
主协程阻塞直到计数器为0
func HelloGoRoutine() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
// 原不优雅的方法
// time.Sleep(time.Second)
wg.Wait()
}
小结
Goroutine
Channel 通过通信实现共享内存
Sync
lock sync.Mutex // 并发安全
var wg sync.WaitGroup // 协程同步
2 依赖管理
了解Go依赖管理演进的历程
通过课程学习以及课后实践能能够熟练使用go module 管理依赖。
Gopath
bin 项目编译的二进制文件
pkg 项目编译的中间产物, 加速编译
src 项目源码
GOPATH方案
项目直接依赖src下的代码
go get下载最新版本的包到src目录下
弊端
无法实现package的多版本控制
Go Vendor
项目目录下增加vendor文件夹, 所以依赖包副本形式放在$ProjectRoot/vendor
依赖寻址方式: vendor => GOPATH
弊端:
无法控制依赖的版本
更新项目又可能出现依赖冲突, 导致编译出错
Go Module
(1.11实验性引入, 1.16默认开启)
通过go.mod文件管理依赖包版本
通过go get/go mod指令工具管理依赖包
终极目标: 定义版本规则和管理项目依赖关系
依赖管理三要素
1 配置文件, 描述依赖
go.mod
2 中心仓库管理依赖库
Proxy
3 本地工具
go get/mod
(类似Java的Maven, JS应该也类似)
依赖配置
go.mod
// 模块路径, 标识在哪找到模块
module example/project/app // 依赖管理基本单元
// 原生库版本号
go 1.16
// 单元依赖
require(
// 依赖标识
// [Module Path][Version/Pseudo-version]
example/lib1 v1.0.2
example/lib2 v1.0.0
)
version
1 语义化版本
{MINOR}.${PATCH}
MAJOR: 大版本, 不同MAJOR可以不兼容, 同MAJOR兼容
MINOR: 新增功能
PATCH: bug修复
如: V1.3.0, V2.3.0 (来源于git tag的概念, 后续将到git会深入, 应该是git tag, 听不太清晰)
2 基于commit的伪版本
版本前缀(同语义化版本)-时间戳(提交commit的时间戳)-12位hash(提交commit的12位hash前缀)
如: v1.0.0-20220401081311-c38fb59326b7
indirect
A->B->C
A->B是直接依赖
A->C是间接依赖
go.mod中, 对于间接依赖的模块, 会标识为indirect
如 example/lib2 v1.0.0 // indirect
incompatible
主版本2+模块会在模块路径增加/vN后缀
如: example/lib5/v3 v3.0.2
对于没有go.mod文件并且主版本2+的依赖, 会带上+incompatible后缀
如: example/lib6 v3.2.0+incompatible
(因为go.mod是1.11才试验性引入的, 之前的一些仓库已经打上了v2或更高版本的tag了, 且没有go.mod文件, 为了兼容这些仓库, 会在版本号后加上+incompatible后缀, 即可能有不兼容的代码逻辑, incompatible: 不兼容的)
最低兼容版本
Main依赖了A 1.2和B 1.2
A 1.2依赖了C 1.3, B 1.2依赖了C 1.4
最终编译时所使用的C项目的版本为
A v1.3
B v1.4
C A用到C时用v1.3编译, B用到C时用v1.4编译
答案: B
因为B依赖C 1.4所以最低也要是C 1.4, 而且即使C有如1.5或其它更高版本也不会去选择, 只会选满足兼容中的最低版本
依赖分发
Github
SVN
...
直接使用代码管理仓库下载依赖的弊端:
无法保证构建稳定性 (作者可以增加/修改/删除软件版本)
无法保证依赖可用性 (原因同上)
增加第三方压力 (代码托管平台负载问题)
解决: Proxy
保证依赖稳定可靠
GOPROXY="proxy1.cn, proxy2.cn, direct"
服务站点URL列表, direct表示源站(即依赖所在的第三方代码平台)
Proxy1 -> Proxy-> Direct
前面几个Proxy都失效的话, 就会去源站找
(和缓存的设计很相通)
go get
go get
@update 最新(默认)
@none 删除依赖
@v1.1.2 tag版本, 语义版本
@23dfdd5 特定的commit
@master 分支的最新commit
go mod
// 初始化, 创建go.mod文件
// 是每个项目开始时必须的步骤
go mod init
// 下载模块到本地缓存
go mod download
// 增加需要的依赖, 删除不需要的依赖
// 常用, 可以在每次提交代码前执行go mod tidy, 把非必要的依赖删除掉
go mod tidy
小结
Go依赖管理演进
Go Module依赖管理方案
依赖管理三要素
1 配置文件, 描述依赖
go.mod
2 中心仓库管理依赖库
Proxy
3 本地工具
go get/mod
3 单元测试
go get "github.com/stretchr/testify/assert"
go get "bou.ke/monkey"
从单元测试实践出发, 提升大家的质量意识
质量就是生命
事故:
1 营销配置错误, 导致非预期用户享受权益, 资金损失10w+
2 用户提现, 幂等失效, 短时间可以多次提现, 资金损失20w+
3 代码逻辑错误, 广告位被占, 无法出广告, 收入损失500w+
4 代码指针使用错误, 导致APP不可用, 损失上kw+
开发 |测试| 事故
测试是避免事故的屏障
回归测试
通过终端, 手动回归一些场景
集成测试
对系统功能维度做测试验证, 如对接口进行一些自动化的测试
单元测试
开发阶段, 开发者对单独的函数/模块做功能验证
由上到下, 覆盖率逐层变大, 成本却逐层降低
可以说单元测试在一定程度上决定了代码的质量
单元测试概念和规则
输出 -> 测试单元 -> 输出
期望
校对(输出与期望进行校对)
函数/模块/...都可以称为测试单元
规则:
所有测试文件以_test.go结尾
func TestXxxx(t *testing.T)
初始化等逻辑放在func TestMain(m *testing.M)中
func TestMain(m *testing.M) {
// 测试前: 数据装载, 配置初始化等前置工作
// 运行所有单测
code := m.Run()
// 测试后: 释放资源等收尾工作
// TODO
// 退出
os.Exit(code)
}
简单例子
// hello.go
package unitTest
func HelloTom() string {
return "Jerry"
}
// hello_test.go
package unitTest
import "testing"
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}
}
// cmd执行
go test hello.go hello_test.go
改进版
// cmd 执行
go mod init unitTest
go get "github.com/stretchr/testify/assert"
// 将
if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}
// 改为使用第三方库的形式
assert.Equal(t, expectOutput, output)
// cmd执行, 因为go mod init过了, 所以可以直接go test不带参数, 即对整个包进行测试, 也可以用IDE的按钮
go test
覆盖率
如何衡量代码是否经过了足够的测试?
如何评价项目的测试水准?
如何评估项目是否达到了高水准测试等级?
代码覆盖率
go test加上--cover可以给出代码测试的覆盖率
如
func JudgePassLine(score int16) bool {
if score >= 60 {
return true
}
return false
}
func TestJudgePassLineTrue(t *testing.T) {
isPass := JudgePassLine(70)
assert.Equal(t, true, isPass)
}
go test judgement_test.go judgement.go --cover
// ok command-line-arguments 0.547s coverage: 66.7% of statements
解释: 覆盖率=待测函数实际被执行的行数/总行数=66.7%
func TestJudgePassLineFail(t *testing.T) {
isPass := JudgePassLine(50)
assert.Equal(t, false, isPass)
}
再补充这个单测, 覆盖率达到100%
实际项目中100%是一个目标, 不一定能达到
一般覆盖率50%~60%, 较高覆盖率80%+
// 50%~60%主流程应该没问题, 一些分支没测到
// 资金类: 80%+, 60%提升到80%比较需要成本
测试分支相互独立, 全面覆盖
测试单元粒度足够小, 函数单一职责
Mock测试
github.com/bouk/monkey开源Mock测试包
go get "bou.ke/monkey"
依赖
外部依赖(File DB Cache) => 稳定&幂等
依赖的变动可能导致测试出错或不可运行, (如案例中log文件内容变动, 或log文件被删)
解决: 使用Mock
monkey原理
在运行时通过go的unsafe包将内存中函数的地址替换为运行时函数的地址, 最终测试时调用的其实是打桩函数, 实现了Mock (似懂非懂)
//func TestProcessFirstLine(t *testing.T) {
// firstLine := ProcessFirstLine()
// assert.Equal(t, "line00", firstLine)
//}
func TestProcessFirstLine(t *testing.T) {
// 当调用ReadFirstLine改为调用打桩函数
monkey.Patch(ReadFirstLine, func() string {
return "line11"
})
defer monkey.Unpatch(ReadFirstLine)
firstLine := ProcessFirstLine()
assert.Equal(t, "line00", firstLine)
}
通过Mock摆脱了对本地文件的强依赖
这样测试可以在任何情况下正常运行
基准测试
go get github.com/NebulousLabs/fastrand
优化代码, 需要对当前代码分析
内置的测试框架提供了基准测试的能力
案例: 随机选择执行服务器
// 串行的基准测试
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
// 因为Init不属于要测试的内容, 所以这里重置下计时器, 以刨去Init的时间
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()
}
})
}
小结
单元测试
Mock测试
基准测试
4 项目实战
通过项目需求, 需求拆解, 逻辑设计, 代码实现感受真实的项目开发
需求描述
社区话题页面
展示话题(标题, 文字描述)和回帖列表
暂不考虑前端页面实现, 仅仅实现一个本地web服务
话题和回帖数据用文件存储
分析
User
TopicPage
Topic (话题)
PostList (帖子)
ER图
Entity Relationsip Diagram
感觉老师PPT上的是UML, 不是ER, 我也不大懂ER, 可能是有很多种, 有空了去学学
这里先拿UML顶一下
classDiagram
class Topic {
id
title
content
create_time
}
class Post {
id
topic_id
content
create_time
}
分层结构
File -> Repository(Model) -> Service(Entity) -> Controller(View) -> Client
数据层: 数据Model, 外部数据的增删改查
逻辑层: 业务Entity, 处理核心业务逻辑输出
视图层: 视图View, 处理和外部的交互逻辑
组件工具
Gin 高性能go web框架
github.com/gin-gonic/gin#installation
Go Mod
go mod init
go get gopkg.in/gin-gonic/gin.v1@v1.3.0
Repository
Service
Controller
github.com/Moonlight-Z… 更多见源码
课后实践
支持发布帖子
本地id生产需要保证不重复, 唯一性
Append文件, 更新索引, 注意Map的并发安全问题
小结
项目拆解
代码设计
测试运行