这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记
1. 语言进阶
从并发编程视角了解Go高性能本质
1.1. 并发 VS 并行 → 协程
-
并发:多线程程序在一个核的CPU上运行
-
并行:多线程程序在多个核的CPU上运行
Go在用户态可以自行设计并发度, 而不是依靠系统调度, 可充分发挥多核优势,高效运行
-
Goroutine
-
协程
- 协程:用户态,轻量级线程,栈KB级别;
- 线程:内核态,线程可以跑多个协程,栈MB级别。
-
快速打印 Hello World
package concurrence import ( "fmt" "sync" ) func hello(i int) { println("hello world : " + fmt.Sprint(i)) } func ManyGo() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) **go** func(j int) { // 通过 go 创建一个协程 defer wg.Done() hello(j) }(i) } wg.Wait() }
-
1.2 CSP ( Communicating Sequential Processes )
-
Goroutine 提倡通过通信共享内存, 而不是通过共享内存而实现通信 (也就是推荐基于通道channel, 当然goroutine也保留共享内存实现通信的机制)
-
通道 Channel
make(chan 元素类型, [缓冲大小])- 无缓冲通道
make(chan int)( 发送和接收Goroutine同步化, 又称同步通道 ) - 有缓冲通道
make(chan int,2)( 解决生产和消费速度不均衡导致的效率问题 )
-
Code
package concurrence func CalSquare() { src := make(chan int) 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(i) } }
- 无缓冲通道
1.3 并发安全 Lock
对变量执行2000次+1操作,5个协程并发执行 → 尽量避免采用共享内存实现并发任务
1.4 WaitGroup
-
Code
// Sample func query(word string, wg *sync.WaitGroup) { // ... **wg.Done()** } func main() { if len(os.Args) != 2 { fmt.Fprintf(os.Stderr, `usage: simpleDict WORD example: simpleDict hello `) os.Exit(1) } word := os.Args[1] **wg := sync.WaitGroup{} wg.Add(1)** go query(word, &wg) **wg.Wait()** }
2. 依赖管理
了解Go依赖管理的演进路线
2.0 背景
工程项目不可能基于标准库由 0→1 编码搭建, 因此需要引入并管理依赖库。
2.1 Go 依赖管理发展
-
2.1.1 GOPATH
-
特点
-
环境变量:
$GOPATH -
项目代码直接依赖src下的代码
-
go get下载最新版本的包到src目录下
-
-
弊端
场景: A 和 B依赖于某一package的不同版本
问题: 无法实现package的多版本控制
-
-
2.1.2 Go Vendor
-
特点
-
项目目录下增加
vendor文件, 所有依赖包副本形式放在$ProjectRoot/vendor -
依赖寻址方式: vendor ⇒ GOPATH
-
通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package依赖的冲突问题。
-
-
弊端
- 无法控制依赖的版本
- 更新项目有可能出现依赖冲突,导致编译出错。
-
-
2.1.3 Go Module
-
特点
- 通过
go.mod文件管理依赖包版本; - 通过
go get/go mod指令工具管理依赖包。
终极目标:定义版本规则和管理项目依赖关系
- 通过
-
2.2 依赖管理三要素
-
2.2.1 配置文件,描述依赖
go.mod-
依赖标识:
[Module Path][Version/Pseudo-version] -
依赖配置
-
version-
语义化版本
${MAJOR}.${MINOR}.${PATCH}, 如V1.3.0,V2.3.0- 不同的
MAJOR版本表示是不兼容的 API,所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块; MINOR版本通常是新增函数或功能,向后兼容;patch版本一般是修复 bug
- 不同的
-
基于
commit伪版本vx.0.0-yyyymmddhhmmss-abcdefgh1234, 如v0.0.0-20220401081311-c38fb59326b7,v1.0.0-20201130134442-10cb98267c6c- 基础版本前缀是和语义化版本一样的;
- 时间戳 (
yyyymmddhhmmss), 也就是提交 Commit 的时间 - 校验码 (
abcdefabcdef), 包含 12 位的哈希前缀;
每次提交commit后, Go 都会默认生成一个伪版本号。
-
-
特殊标识符
-
indirect表示
go.mod对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标示间接依赖-
A → B → C
- A → B 直接依赖
- A → C 间接依赖
-
用法
- 当前项目依赖A, 但是A的
go.mod遗漏了B, 那么就会在当前项目的go.mod中补充B, 加indirect注释 - 当前项目依赖A, 但是A没有
go.mod,同样就会在当前项目的go.mod中补充B, 加indirect注释 - 当前项目依赖A, A又依赖B, 当对A降级的时候, 降级的A不再依赖B, 这个时候B就标记
indirect注释
- 当前项目依赖A, 但是A的
-
-
incompatible-
主版本 2+ 模块会在模块路径增加
/vN后缀这能让
go module按照不同的模块来处理同一个项目不同主版本的依赖。由于go module是1.11实验性引入, 所以这项规则提出之前已经有一些仓库打上了2或者更高版本的tag了,为了兼容这部分仓库,对于没有go.mod文件并且主版本在 2+ 的依赖,会在版本号后加上+incompatible 后缀
-
-
-
依赖图
如果X项依赖了A、B两个项目,且A、B分别依赖了C项目的 v1.3、v1.4两个版本,最终编译时所使用的C项目的版本为 V1.4 ( 选择最低的兼容版本 )
-
-
-
2.2.2 中心仓库管理依赖库
Proxy-
依赖分发 — 回源
对于
go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。-
存在问题
- 首先无法保证构建确定性:软件作者可以直接代码平台 增加/修改/删除 软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本
- 无法保证依赖可用性:依赖软件作者可以直接代码平台删除软件,导致依赖不可用
- 大幅增加第三方代码托管平台压力。
-
-
依赖分发 — Proxy
-
依赖分发 — GOPROXY
Go Modules通过GOPROXY环境变量控制如何使用
Go Proxy;GOPROXY是一个 Go Proxy 站点URL列表,可以使用direct”表示源站。对于示例配置,整体的依赖寻址路径,会优先从
proxy1下载依赖,如果proxy1不存在,会下钻proxy2寻找,如果proxy2中不存在则会回源到源站直接下载依赖,缓存到proxy站点中。
-
-
2.2.3 本地工具
go get/mod-
go get -
go mod尽量提交之前执行下
go mod tidy,减少构建时无效依赖包的拉取
-
3. 测试
单元测试实践提升工程质量意识, 质量就是生命 ⇒ 测试是避免事故的最后一道屏障
3.0 背景
-
3.0.1 事故
- 营销配置错误,导致非预期用户享受权益,资金损失10w+
- 用户提现,幂等失效,短时间可以多次提现,资金损失20w+
- 代码逻辑错误,广告位被占,无法出广告,收入损失500w+
- 代码指针使用错误,导致APP不可用,损失上kw+
-
3.0.2 测试分类
-
从上到下, 覆盖率逐层变大,成本却逐层降低
回归测试一般是QA同学手动通过终端回归一些固定的主流程场景,集成测试是对系统功能维度做测试验证,而单元测试测试开发阶段,开发者对单独的函数、模块做功能验证,层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量。
-
3.1. 单元测试
单元测试主要包括 输入,测试单元,输出,以及校对,单元的概念比较广,包括接口,函数,模块等
用最后的校对来保证代码的功能与我们的预期相符
-
效果
- 保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性
- 提升效率,在代码有bug的情况下,通过编写单元测试,可以在一个较短周期内定位和修复问题。
-
3.1.1 测试规则
-
所有测试文件以
_test.go结尾 -
测试函数以
Test开头funcTestXxx(-testing.T) -
初始化逻辑放到
TestMain中
-
-
3.1.2 测试用例
// 原函数 func HelloTom() string { return "Jerry" } // 测试函数 func TestHelloTom(t *testing.T) { output := HelloTom() expectOutput := "Tom" if output != expectOutput { t.Errorf(format: "Expected %s do not match actual %s", expectOutput, output) } } -
3.1.3 测试运行
go test[flagsl[packages] -
3.1.4 测试assert
// 原函数 HelloTom.go func HelloTom() string { return "Tom" } // 测试函数 HelloTom_test.go import ( "github.com/stretchr/testify/assert" "testing" ) func TestHelloTom(t *testing.T) { output := HelloTom() expectOutput := "Tom" assert.Equal(t, expectOutput, output) } -
3.1.5 测试覆盖率
-
代码覆盖率
- 如何衡量代是否经过了足够的测试?
- 如何评价项目的测试水准?
- 如何评估项目是否达到了高水准测试等级?
-
测试用例
-
一个判断是否及格的函数,超过60分,返回true,否则返回false,右边是对输入为70 的单元测试
// 原函数 JudgePassLine.go func JudgePassLine(score int16) bool { if score >= 60 { return true } return false } // 测试函数 HelloTom_test.go func TestJudgePassLineTrue(t *testing.T){ isPass := JudgePassLine(score:70) assert.Equat(t, expected:true, isPass) } -
执行单元测试,通过指定
cover参数,我们看输出覆盖率为66.7%一共3行,我们的单元测试执行了 2 行,所以为
66.7% -
提升覆盖率,增加一个不及格的测试 case ,最终覆盖率为
100%// 测试函数 HelloTom_test.go func TestJudgePassLineTrue(t *testing.T){ isPass := JudgePassLine(score:70) assert.Equat(t, expected:true, isPass) } func TestJudgePassLineFail(t *testing.T){ isPass := JudgePassLine(score:50) assert.Equat(t, expected:false, isPass) }
-
-
Tips
- 一般覆盖率: 50%~60%, 对于资金型服务,覆盖率可能要求达到较高80%+。
- 测试分支相互独立、全面覆盖。
- 测试单元粒度足够小,函数单一职责。
-
3.2 测试 依赖 与 Mock
-
3.2.1 依赖
外部依赖 ⇒ 稳定 & 幂等 ⇒ Mock机制
- 稳定: 相互隔离,能在任何时间,任何环境,运行测试
- 幂等: 每一次测试运行都应该产生与之前一样的结果
-
3.2.2 文件处理
-
依赖的本地文件
line11 line22 line33 line44 line55 -
测试用例
将文件中的第一行字符串中的11替换成00,执行单测,测试通过
由于单测需要依赖本地的文件,如果文件被修改或者删除测试就会fail
为了保证测试case的稳定性,我们对读取文件函数进行mock,屏蔽对于文件的依赖。
-
-
3.2.3 Mock
Monkey: ‣是一个开源的mock测试库,可以对method,或者实例的方法进行mock
-
快速Mock函数
- 为一个函数打桩
- 为一个方法打桩
Mockey Patch的作用域在Runtime,在运行时通过 Go 的unsafe包,能够将内存中函数的地址替换为运行时函数的地址。将待打桩函数或方法的实现跳转。-
对
ReadFirstLine打桩测试, 不再依赖本地文件
-
3.3 基准测试
基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。
-
3.3.1 基准测试用例
测试函数
- 基准测试以
Benchmark开头,入参是testing.B, 用b中的N值反复递增循环测试(对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么testing.B中的N 值将按1、2、5、10、20、50……递增,并以递增后的值重新进行用例函数测试。) Resettimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;RunParallel是多协程并发测试;
执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
math/rand⇒fastrand(‣)优化思路: 牺牲了一定的数列一致性,在大多数场景是适用的
- 基准测试以
4. 项目实战
通过项目需求、需求拆解、逻辑设计和代码实现体会项目开发过程
4.0 需求背景
-
掘金社区话题入口页面
页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,以此为需求模型,开发一个该页面交涉及的服务端小功能。
-
功能 — 社区话题页面
- 展示话题 (标题, 文字描述) 和回帖列表
- 暂不考虑前瑞页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
-
4.1 需求用例
-
4.1.1 抽取实体
浏览消费用户 ⇒ 抽取
话题和回帖列表两个实体 -
ER 图
4.2 分层结构
- 数据层: 数据 Model, 外部数据的增删改查
- 逻辑层: 业务 Entity, 处理核心业务逻辑输出
- 视图层: 视图 view, 处理和外部的交互逻辑
4.3 组件工具
-
Gin 高性能 go web 框架
-
Go Mod
go mod init go get gopkg.in/gin-gonic/gin.v1@v1.3.0
4.4 Repository
-
4.4.1 Struct 定义
-
Topic
-
Post
-
-
4.4.2 查询实现
-
方法
-
可以用全扫描遍历方式: 但是这虽然能达到我们的目的,但是并非高效的方式
-
使用索引: 索引就像书的目录,可以引导我们快速查找定位我们需要的结果;
-
这里我们用
map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现 O(1) 的时间复杂度查找操作。
-
-
-
操作
-
初始化话题数据索引
打开文件,基于
file初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存mapfunc initTopicIndexMap(filePath string) error { open, err := os.Open(filePath + "topic") if err != nil { return err } scanner := bufio.NewScanner(open) topicTmpMap := make(map[int64]*Topic) for scanner.Scan() { text := scanner.Text() var topic Topic if err := json.Unmarshal([]byte(text), &topic); err != nil { return err } topicTmpMap[topic.Id] = &topic } topicIndexMap = topicTmpMap return nil } -
初始化回帖数据索引
func initPostIndexMap(filePath string) error{ open, err := os.Open(filePath + "post") if err != nil { return err } scanner := bufio.NewScanner(open) postTmpMap := make(map[int64][]*Post) for scanner.Scan() { text := scanner.Text() var post Post if err := json.Unmarshal([]byte(text), &post); err != nil { return err } posts, ok := postTmpMap[post.ParentId] if !ok { postTmpMap[post.ParentId] = []*Post{&post} continue } posts = append(posts, &post) postTmpMap[post.ParentId] = posts } postIndexMap = postTmpMap return nil } -
查询
package repository import ( "sync" ) type Topic struct { Id int64 `json:"id"` Title string `json:"title"` Content string `json:"content"` CreateTime int64 `json:"create_time"` } type TopicDao struct { } var ( topicDao *TopicDao topicOnce sync.Once ) func NewTopicDaoInstance() *TopicDao { topicOnce.Do( func() { topicDao = &TopicDao{} }) return topicDao } func (*TopicDao) QueryTopicById(id int64) *Topic { return topicIndexMap[id] }package repository import ( "sync" ) type Topic struct { Id int64 `json:"id"` Title string `json:"title"` Content string `json:"content"` CreateTime int64 `json:"create_time"` } type TopicDao struct { } var ( topicDao *TopicDao topicOnce sync.Once ) func NewTopicDaoInstance() *TopicDao { topicOnce.Do( func() { topicDao = &TopicDao{} }) return topicDao } func (*TopicDao) QueryTopicById(id int64) *Topic { return topicIndexMap[id] }
-
-
4.5 Service — 逻辑实现
-
4.5.1 实体
type PageInfo struct { Topic *repository.Topic PostList []*repository.Post } -
4.5.2 流程
-
4.5.3 代码流程编排
func (f *QueryPageInfoFlow) Do() (*PageInfo, error) { if err := f.checkParam(); err != nil { return nil, err } if err := f.prepareInfo(); err != nil { return nil, err } if err := f.packPageInfo(); err != nil { return nil, err } return f.pageInfo, nil } func (f *QueryPageInfoFlow) checkParam() error { if f.topicId <= 0 { return errors.New("topic id must be larger than 0") } return nil } func (f *QueryPageInfoFlow) prepareInfo() error { //获取topic信息 var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() topic := repository.NewTopicDaoInstance().QueryTopicById(f.topicId) f.topic = topic }() //获取post列表 go func() { defer wg.Done() posts := repository.NewPostDaoInstance().QueryPostsByParentId(f.topicId) f.posts = posts }() wg.Wait() return nil } func (f *QueryPageInfoFlow) packPageInfo() error { f.pageInfo = &PageInfo{ Topic: f.topic, PostList: f.posts, } return nil }
4.6 Controller
-
4.6.1 实体
type PageData struct { Code int64 `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` } -
操作
- 构建 View 对象
- 业务错误码
func QueryPageInfo(topicIdStr string) *PageData { topicId, err := strconv.ParseInt(topicIdStr, 10, 64) if err != nil { return &PageData{ Code: -1, Msg: err.Error(), } } pageInfo, err := service.QueryPageInfo(topicId) if err != nil { return &PageData{ Code: -1, Msg: err.Error(), } } return &PageData{ Code: 0, Msg: "success", Data: pageInfo, } }
4.7 Router — web服务引擎配置
-
操作
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
func main() { if err := Init("./data/"); err != nil { os.Exit(-1) } r := gin.Default() r.GET("/community/page/get/:id", func(c *gin.Context) { topicId := c.Param("id") data := cotroller.QueryPageInfo(topicId) c.JSON(200, data) }) err := r.Run() if err != nil { return } } func Init(filePath string) error { if err := repository.Init(filePath); err != nil { return err } return nil }
4.8 运行
-
go run server.go
总结
本节课针对go在项目开发过程中, 主要是GO语言的一些高并发特性、依赖管理、单元测试和一些代码层面的实战,包括从并发编程视角了解Go高性能本质、了解Go依赖管理的演进路线和依赖管理三要素、单元测试实践提升工程质量意识、以及 通过项目需求、需求拆解、逻辑设计和代码实现体会项目开发过程实战 等。