02Go语言进阶 - 工程进阶 | 青训营笔记

108 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第2天

课程内容相关代码链接 github.com/Moonlight-Z…

课程上: juejin.cn/course/byte…

课程下: juejin.cn/course/byte…

讲师: 赵征

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 (并发安全)

pkg.go.dev/sync

 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

pkg.go.dev/sync

 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

go.dev/blog/using-…

(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 语义化版本   MAJOR.{MAJOR}.{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+

开发 |测试| 事故

测试是避免事故的屏障

回归测试
通过终端, 手动回归一些场景
集成测试
对系统功能维度做测试验证, 如对接口进行一些自动化的测试
单元测试
开发阶段, 开发者对单独的函数/模块做功能验证
由上到下, 覆盖率逐层变大, 成本却逐层降低
可以说单元测试在一定程度上决定了代码的质量

单元测试概念和规则

pkg.go.dev/testing#hdr…

输出 -> 测试单元 -> 输出
期望
校对(输出与期望进行校对)
函数/模块/...都可以称为测试单元

 规则:
     所有测试文件以_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摆脱了对本地文件的强依赖
 这样测试可以在任何情况下正常运行

基准测试

pkg.go.dev/testing#hdr…

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的并发安全问题

小结

项目拆解
代码设计
测试运行