这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记。
《Go语言上手——应用实践》课程由赵征老师讲授,根据赵老师讲解的课程内容,我总结梳理出了如下笔记内容。
见微知著——课程重点一览
步步为营——知识点详细剖析
语言进阶
经过《Go语言上手——基础语言》课程的学习,Go语言的基本使用已经能够被我们掌握。接下来,将从并发编程的视角来了解Go语言高性能的本质。
并发与并行
提到并发编程,就不得不区分两个最基本的概念:一个是并发,一个是并行。
- 并发:是指在一段时间内,多个线程同时执行。而在一个时间点上只有一个线程在执行。
- 并行:是指在多CPU场景下,在一个时间点上,多个线程同时执行。
Go语言之所以性能高、运行快,是它能够充分地发挥多核的优势,使代码能够高效地运行。
Goroutine
Go语言之所以高效,是因为他使用了线程之上更加高效且轻量级的协程。协程可以看作是轻量级的线程,协程比线程更加的灵活。
在Go语言中,线程可以看作是内核态的概念,在线程之上可以运行多个协程,他的栈空间是MB级别的;而协程可以看作是用户态的概念,是更加轻量级的线程,他的栈空间是KB级别的。
在Go语言中,创建协程,只需要一个go关键字即可。
CSP
CSP是Communicating Sequential Processes的缩写。在Go语言中,提倡通过通过通信来实现共享内存;而不是传统语言中的通过共享内存来实现通信。
Channel
channel是Go语言中的一个数据类型,被称为“通道”。我们可以通过使用make语句来创建一个通道。
//Go语言channel创建
make(chan int) // 无缓冲通道
make(chan int, 2) // 有缓冲通道
通道可以分为无缓冲通道和有缓冲通道。通过通道,可以很快地实现一个经典的“生产者-消费者”模型。
Sync
在并发编程中,除了需要考虑上述的通信问题外,还需要考虑多线程/协程的互斥与同步问题。
并发安全Lock
与大多数编程语言一样,Go语言也提供了锁机制来保证多协程条件下的协程互斥。下面给出一段Go语言中利用锁机制保障协程互斥安全的代码段。
//Go语言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
}
}
WaitGroup
Go语言采用WithGroup来实现协程间的同步问题。WithGroup类似于一个计数器,每当开启一个协程,计数器会增加1;每当一个协程执行结束,计数器会减少1。当计数器减少到0时,主协程会结束阻塞状态。
//Go语言WithGroup
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done();
hello(j)
}(i)
}
wg.Wait()
}
WithGroup提供了三个主要的API:
- Add(delta int):计数器 + delta
- Done():计数器 - 1
- Wait():阻塞直到计数器为0
依赖管理
背景
依赖指的就是各种开发包,在项目开发的过程中要学会利用已经封装好的、经过验证的开发组件或工具来提升自己的开发效率。
实际的工程项目并不会像hello wolrd这样的单体函数这样简单,而是一个庞大而复杂的结构。对于单体函数来说,依赖原生的SDK可能就足够了,但是复杂的实际项目是不可能基于标准库0~1编码搭建的。
对于实际项目,我们需要更多地关注业务逻辑的实现、而其他的框架、日志、驱动以及集合等一系列的依赖都应该通过SDK的方式引入。由此可见,对依赖包的管理就显得十分重要。
依赖管理演进
Go语言的依赖管理主要经历了三个阶段:GOPATH、Go Vendor和Go Module。目前为止,应用最广泛的是Go Module。
整个演进路线主要围绕实现两个目标来进行迭代发展:一个是如何保证不同环境依赖的版本不同;另一个是如何控制依赖库的版本。
GOPATH
GOPATH是Go语言支持的一个环境变量,其取值是Go项目的工作区。
Go项目的工作区目录内包含有三个主要结构:
- bin目录:用于存放Go项目编译生成的二进制文件
- pkg目录:用于存放Go项目编译过程中生成的中间产物,用于加快编译速度
- src目录:用于存放Go项目的源码
GOPATH的弊端也是比较明显的,它无法实现package的多版本控制。当两个项目分别依赖于同一个package的两个不同版本时,就无法保证两个项目都能通过编译。这是因为GOPATH管理模式下维护的package是同一份代码,因此无法解决不同项目依赖同一个库的不同版本的问题。
Go Vendor
Go Vendor管理模式的产生主要是为了解决不同项目依赖同一个库的不同版本的问题。它在每个项目的目录中创建一个vendor目录,该目录专门用来存放当前项目所依赖的副本。项目在导入依赖时,会优先在vendor目录下去查找,如果没有再去GOPATH路径下查找。
Vendor机制通过每个项目引入一份依赖的副本的方式,解决了多个项目需要同一个package依赖的冲突问题。
然而Go Vendor管理模式也是存在弊端的。如果说GOPATH管理模式无法解决“项目依赖包”的问题,那么Go Vendor管理模式就无法解决“包依赖包”的问题。
虽然说Go Vendor通过保存副本的方式解决了不同项目导入同一个package的不同版本的问题,但是当一个项目的两个不同的包依赖同一个package的不同版本时,Go Vendor管理模式就无法解决了。这种情况下我们不能很好地控制依赖的版本,一旦更新了项目,就有可能出现依赖冲突,导致编译出错。
Go Module
为了进一步地解决“包依赖包”的问题,Go Module管理模式应运而生。Go Modules是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的无法依赖同一个库的多个版本等问题。
通过Go Module管理模式,我们终于实现了管理模式下的终极目标:定义版本规则和管理项目依赖关系:
- 我们可以通过go.mod文件来管理依赖包的版本
- 我们可以通过go get / go mod指令工具管理依赖包
依赖管理三要素
对于一个完善的依赖管理体系来说,一般包含三个要素:依赖配置、依赖分发和管理工具。下面详细地介绍这三个要素。
配置文件,描述依赖
首先是依赖配置,它指的就是项目中用来描述依赖的配置文件。Go语言中管理依赖的配置文件是go.mod文件,该文件中的一些配置项需要我们了解一下。
-
module:模块路径。模块路径用来标识一个模块,它是依赖管理的基本单元。
-
go 版本号:原生库。原生库描述了项目依赖的原生SDK版本。
-
require:单元依赖。每个单元依赖用“模块路径+版本”来唯一表示。
-
version:版本。依据版本规则进行定义。
-
语义化版本:{MINOR}.${PATCH}
- MAJOR:不兼容的API
- MINOR:新增函数或功能,向后兼容
- PATCH:修复bug
-
基于commit伪版本:vx.0.0-yyyymmddhhmmss-abcdefgh1234
- 前缀:语义化版本
- 时间戳:提交commit的时间
- 校验码:12位哈希前缀
-
-
indirect:间接依赖,表示该依赖没有直接依赖
-
incompatible:主版本2+模块会在模块路径增加/vN后缀,这能让go module按照不同的模块来处理同一个项目不同主版本的依赖。
此外,依赖配置还可以通过绘制依赖图来进行直观的展示。需要注意的是,项目在进行最终编译时,会选定包的最低兼容版本来进行编译。
中心仓库,管理依赖
其次是依赖分发,它描述的是依赖从哪下载,也就是依赖的回源问题。
Github是比较常见的代码托管系统平台,Go Modules系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样,对于go.mod中定义的依赖就直接可以从对应的仓库中下载指定的软件依赖,从而完成依赖的分发。
然而,直接使用版本管理仓库下载依赖存在许多问题:
- 无法保证构建稳定性:软件作者可以在代码平台直接增加、修改软件版本,导致下次构建时使用另外的版本依赖,甚至找不到依赖的版本。
- 无法保证依赖可用性:软件作者可以在代码平台直接删除软件,导致依赖不可用。
- 增加第三方压力:大幅增加第三方代码托管平台压力。
解决该问题的方法就是在软件作者和这些代码托管平台间建立一层代理Proxy。Go Proxy是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会被改变,并且在源站软件删除之后仍然可用从而保证了依赖分发的构建稳定性和依赖可用性。
当使用Go Proxy进行依赖分发之后,项目构建时会直接从Go Proxy站点拉取依赖。如果一层代理无法实现功能,则可以考虑在代理之上再建立一层代理。
为了使用Go Proxy,Go Modules提供了GOPROXY环境变量。GOPROXY是一个Go Proxy站点URL列表,可以使用direct表示源站。依赖会通过GOPROXY环境变量中URL列表的先后顺序依次查找进行导入。
本地工具
最后说一下Go Module提供的本地工具。Go语言提供了两个本地工具,一个是go get工具,一个是go mod工具。
-
go get工具可以借助代码管理工具通过远程拉取或更新代码包及其依赖包,并自动完成编译和安装。
-
go get example.org/pkg :
- @update 默认
- @none 删除依赖
- @v1.1.2 tag版本,语义版本
- @23dfdd5 特定的commit
- @master 分支的最新commit
-
-
go mod工具可以通过过命令进行项目依赖的创建整理,下载生成等操作。
-
go mod :
- init 初始化,创建go.mod文件
- download 下载模块到本地缓存
- tidy 增加需要的依赖,删除不需要的依赖,减少构建时的拉取
-
测试
测试也是实际工程开发中的一个重要概念。测试关系着系统的质量,质量决定着线上系统的稳定性,一旦出现了漏洞,就会造成事故。
事故
事故的发生会带来极大的损失,下面是一些真实的事故例子:
- 营销配置错误,导致非预期用户享受权益,资金损失10w+
- 用户提现,幂等失效,短时间可以多次提现,资金损失20w+
- 代码逻辑错误,广告位被占,无法出广告,收入损失500w+
- 代码指针使用错误,导致APP不可用,损失上kw+
测试
事故的发生是我们不希望看到的,而测试就是我们避免事故发生的最后一道屏障。
测试一般可以分为回归测试、集成测试和单元测试。
- 回归测试:一般是通过手动来实现终端回归一些固定的主流程场景
- 集成测试:一般是对系统功能维度上做测试验证
- 单元测试:一般是在开发阶段,开发者对单独的函数、模块做功能验证
各类测试可用层级关系来进行表示。层级从上到下测试成本逐渐降低,测试覆盖率逐渐上升。因此,单元测试的覆盖率在一定程度上决定了代码的质量。
单元测试
单元测试主要包括输入、测试单元、输出、期望值和校对。测试单元的概念比较广,可以包括接口、函数、模块等。校对操作则是用来保证代码的功能符合预期。
单元测试一方面可以保证代码的质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有bug的情况下,通过编写单元测试,可以在一个较短周期内定位和修复问题。
单元测试规则
- 所有测试文件以_test.go结尾
- 测试函数声明为 func TestXxx(*testing.T)
- 初始化逻辑放在TestMain中
- 在代码中写入assert断言进行是否符合预期的判断
单元测试运行
使用go test [flags] [packages]进行单元测试的运行。
单元测试覆盖率
对单元测试的评价有一个衡量标准,那就是代码的覆盖率:
- 衡量代码是否经过了足够的测试
- 评价项目的测试水准
- 评估项目是否达到了高水准测试等级
单元测试建议
- 一般项目的测试覆盖率要求在50%~60%;对于资金型服务的测试覆盖率要求达到80%以上
- 单元测试的测试分支相互独立、覆盖全面
- 测试单元粒度要足够小,函数设计成单一职责,便于提升代码覆盖率
MOCK测试
工程中的复杂项目一般会有多种外部依赖,而单元测试需要我们保证稳定性和幂等性。
- 稳定性:指单元测试间相互隔离,能在任何时间、任何环境运行测试。
- 幂等性:指每一次测试运行都应该产生与之前一样的结果。
为了实现单元测试的稳定性和幂等性,需要使用mock机制来进行MOCK测试。常用的MOCK测试方式是使用monkey测试库,该测试库可以对方法进行mock、反射和指针赋值。
常用的MOCK测试方法有两种,一种是对函数打桩,另一种是对方法打桩。利用打桩的方式便可使单元测试不再依赖本地文件。
基准测试
基准测试指的是测试一段程序的运行性能以及耗费CPU的程度。在实际项目开发的过程中,经常会遇到代码性能瓶颈,为了定位问题,经常要对代码进行性能分析,此时便需要基准测试来实现。
基准测试的目的是优化代码,通过对当前代码进行分析,从而有针对性地进行代码优化。另外,Go语言提供了内置的基准测试框架来保障语言的基准测试的能力。
基准测试与单元测试在形式上很相似。基准测试函数以Benchmark开头,入参为b *testing.B:
- 利用b中的N值反复递增循环测试。
- 利用b中的ResetTimer重置计时器。
- 利用b中的Runparallel多协程并发测试。
为了解决多协程下测试的随机性问题,公司开源了一个高性能随机数方法fastrand,使得基准测试下性能提升了百倍。其主要的思路是牺牲了一定的数列一致性。
项目实战
这里需要注意一下,老师上课时使用的代码是分享代码仓库中的V0分支代码,在拉取代码之后需要将分支切换到V0分支再进行参考实践。
需求设计
以掘金社区话题页面为背景,设计开发一个服务端的小功能。
需求描述
设计并开发一个简单的社区话题页面。需要实现如下功能:
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地的web服务
- 话题和回帖数据用文件存储
需求用例
根据该需求可以设计出如下用例:
ER图
根据用例可以抽取出话题和回帖两个实体,一个话题下可以有多个回帖,一个回帖属于一个话题。根据这样的关系,可以绘制得到E-R图:
分层结构
项目的整体可以划分为三个层次:数据层、逻辑层和视图层。
- 数据层:关联底层数据模型Model,用于封装外部数据的增删改查。
- 逻辑层:负责处理核心业务逻辑,打包业务实体Entity。
- 视图层:负责处理和外部的逻辑交互,以视图的形式返回给客户端
组件工具
最后介绍一下开发涉及的几个基础组件及工具:
- 首先是gin,它是高性能开源的go web框架,我们基于gin搭建web服务器,主要使用它的路由分发功能。
- 由于引入了web框架,那么对于依赖的管理就必不可少了。这里引入go module依赖管理工具,使用 go mod管理配置文件,使用go get下载gin依赖。
代码开发
Repository
结构体
根据需求阶段绘制的ER图,可以定义出话题和回帖两个结构体:
//话题结构体
type Topic struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
//回帖结构体
type Post struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
Content string `json:"content"`
CreateTime int64 `json:"create_time"`
}
索引
在查询的过程中,我们完全可以使用全扫描遍历的方式来实现,但这并非是高效的方式。因此,在这里提出索引的概念,引导我们快速查找定位我们需要的结果。
//内存索引结构
var (
topicIndexMap map[int64]*Topic
postIndexMap map[int64][]*Post
)
我们可以使用map来实现内存索引,在对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现常数级别时间复杂度的查询操作了。
查询
有了内存索引,查询操作就只需要根据key获得对应的value值即可。为了减少存储的浪费,可以使用Sync.once来实现单例模式,保证高并发场景下内存索引的构建只执行一次。
Service
在逻辑层,首先定义逻辑层中的实体,再根据参数校验、准备数据、组装实体这样的流程实现逻辑层的业务编写。
//逻辑层实体
Type PageInfo struct {
Topic *repository.Topic
PostList []*repository.Post
}
在准备数据阶段,话题和回帖信息的获取都依赖topicId,这样二者就可以并行执行,提高执行效率,节约多核cpu资源。
Controller
在视图层构建一个View对象,通过Code和Msg打包业务状态信息,用Data承接业务实体信息。
Router
最后进行web服务引擎的配置:
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
利用path映射到具体的controller,通过path变量传递话题id。
测试运行
最后执行go run在本地启动web服务,通过curl命令请求服务器暴露的接口。
小试牛刀——课后实践
问题描述
- 支持发布帖子
- 本地 ID 生成需要保证不重复、唯一性
- Append 文件,更新索引,注意 Map 的并发安全问题
实践成果
作业依然采用课堂练习中的结构,在课堂练习的基础之上添加新的发布帖子的功能。项目的整体代码结构如下:
- 首先准备项目环境:
1.在项目所在文件夹中开启命令行,在命令行中输入go mod init github.com/Akatsuki_z/homework 创建go.mod文件。
2.配置goproxy,便于后续项目中可以快速导包,防止因为超时导致导包失败。
3.开启GO111MODULE,强制项目使用go module管理依赖。
4.在项目文件夹下打开命令行,输入 go get github.com/gin-gonic/gin 导入gin框架。此时,go.mod下的go.sum文件内会导入相关的依赖。
- 项目环境准备好后,开始准备Repository层的Model。
1.dbinit.go中增加插入数据到文件的函数。
//dbinit.go
func InsertNewPost(post *Post) error {
fileName := localFilePath + "post"
file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
defer file.Close()
// Post序列化为字符串
text, err := json.Marshal(post)
if err != nil {
return err
}
// 写入文件
write := bufio.NewWriter(file)
_, err = write.WriteString("\n" + string(text))
if err != nil {
return err
}
err = write.Flush()
if err != nil {
return err
}
return nil
}
2.为了实现给帖子赋值ID,这里采用统计当前帖子数目,并将帖子总数 + 1作为新帖子的ID。因此,这里增加了统计当前帖子总个数的函数,实现一个帖子ID自增的效果。
// post.go
func QueryPostAmount() int {
sum := 0
for _, value := range postIndexMap {
sum += len(value)
}
return sum
}
- 在Service层中加入创建新帖子的业务逻辑。
// insert_info.go
var (
lock sync.Mutex
)
func InsertNewPost(postInfo string) (int, error) {
var newPost *repository.Post
// 将信息反序列化为Post
if err := json.Unmarshal([]byte(postInfo), &newPost); err != nil {
return 0, err
}
// 加锁保证多线程并发安全
lock.Lock()
// 为Post赋值id属性
id := repository.QueryPostAmount()
newPost.Id = int64(id) + 1
// 为Post赋值时间戳属性
newPost.CreateTime = time.Now().Unix()
// 插入帖子信息
if err := repository.InsertNewPost(newPost); err != nil {
return 0, err
}
// 更新帖子映射表
if err := repository.Update(); err != nil {
return 0, err
}
lock.Unlock()
return 1, nil
}
- 编写单元测试,测试InsertNewPost方法的正确性。
func TestInsertNewPost(t *testing.T) {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
time.Sleep(2000)
go func() {
defer wg.Done()
postInfo := "{"parent_id":2,"content":"小哥哥快来7"}"
n, err := InsertNewPost(postInfo)
assert.Equal(t, nil, err)
assert.Equal(t, 1, n)
}()
}
wg.Wait()
}
- 测试结果
- 编写Controller层代码,封装信息
// insert_info.go
func InsertNewPost(newPost string) *PageData {
_, err := service.InsertNewPost(newPost)
if err != nil {
return &PageData{
Code: -1,
Msg: err.Error(),
}
}
return &PageData{
Code: 0,
Msg: "success",
}
}
- 在server.go中添加调用接口
r.POST("/community/post/insert", func(c *gin.Context) {
newPost, _ := ioutil.ReadAll(c.Request.Body)
data := controller.InsertNewPost(string(newPost))
c.JSON(200, data)
})
- 运行效果(这里将测试时插入的数据清除掉之后再做的测试)
温故知新——总结与感悟
这门课是第一个引发我学习兴趣的一门课,很喜欢赵征老师的讲课风格。在这门课上我学到了Go语言在并发场景下的开发方式,了解到了Go语言“通过协程间通信实现共享内存”的特性。此外,还学会了如何利用go mod去管理自己的项目依赖。最后,通过“掘金社区话题页面展示”的小项目,学会了如何使用gin框架开发一个MVC架构的Web项目。
在课下,通过完成作业,对go的依赖管理和gin开发有了进一步的熟悉。通过亲自动手实践,学会了go mod相关环境的配置,同时也发现了老师上课使用代码为V0分支代码,而我一直使用main分支进行学习而感到迷茫困惑的“小坑”。
另外,在课后作业官方提供的答案中发现了自己所完成作业的不足。比如,ID的生成应该采用随机生成的方式,而不是每次去统计当前帖子的个数。这一点上引发了我很大的思考:首先,这种采用统计的方式生成ID的效率很低,每次生成都要进行统计操作;其次,这种解决方案是基于没有帖子删除场景存在的,一旦有帖子被删除,便会引发帖子ID的重复,从而引发错误。
以上,便是我对这门课的全部总结与感想。