Go 工程进阶
本文来源于第五届字节跳动青训营活动,已收录到golang工程进阶 | 青训营笔记 - 掘金 (juejin.cn) ,主要记录了对golang工程开发的学习
语言进阶
1.并发 VS 并行
- 并发:多线程程序在一个核的CPU上运行
- 并行:多线程程序在多个核的CPU上运行
go语言可以充分发挥多核优势,高效运行
1.1 Goroutine
- 线程:用户态,轻量级线程,栈MB级别
- 协程:内核态,线程跑多个协程,栈KB级别
关于用户态和内核态的区别
用户态和内核态是操作系统的两种运行级别。
当程序运行在3级特权级上时,就可以称之为运行在用户态,这是最低特权级,是普通用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态。
当程序运行在0级特权级上时,就可以称之为运行在内核态。
运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。用户态下的程序在其需要操作系统帮助完成某些它没有权限和能力完成的工作时就会切换到内核态,比如操作硬件。
这两种状态的主要差别是
- 处于用户态执行时,进程所能访问的内存空间和对象受到限制,其处于占用的处理器是可被抢占的
- 处于内核态执行时,能够访问使用的内存空间和对象,且所占有的处理器是不允许被抢占的
快速打印
1 | func hello(i int) { |
1.2 CSP
CSP(Communicating Sequential Processes)是一种并发模型。在go语言中提倡通过通信共享内存而不是通过共享内存而实现通信(像Java、C++、Python等都是通过共享内存来实现通信),go语言通过goroutine和channel实现通过通信共享内存,这是go语言的独特优势。
1.3 Channel
通道channel是用来传递数据的一个数据结构。通道可用于两个goroutine之间通过传递一个指定类型的值来同步运行和通讯。操作符<-用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。
创建通道,通道分为无缓冲通道和有缓冲通道,在创建时指定参数即可。
1 | ch := make(chan int) //无缓冲通道 |
通道的基本使用
1 | ch <- v //把v发送到通道ch |
一个示例
一个子协程发送0~9数字
另一个子协程计算输入数字的平方
主协程输出结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19func CalSqrt() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
println(i)
}
}
1.4 并发安全 Lock
对变量执行大量重复操作,使用多个协程并发执行,利用锁来保证最终结果的正确性
1 | var ( |
1.5 WaitGroup
waitgroup是go语言应用开发过程中经常使用的并发控制技术。其内部通过一个计数器来统计协程,这个计数器的值需要在启动协程之前用Add方法初始化,在结束一个协程的时候使用Done方法将计数器-1
当开启一个协程时,计数器+1;当一个协程执行完时,计数器-1;主线程会一直阻塞直到计数器为0。
改造前面的协程快速打印的示例,使用waitgroup实现协程的同步阻塞。
1 | func GoWait() { |
依赖管理
go语言依赖管理的演进路线和go module实践。
依赖指各种开发包或者库,利用已经封装好的、经过验证的开发组件或工具,能够大大提升开发效率。
对于简单的程序来说,只需要依赖原生的SDK即可。对于实际工程来说,更多的是关注业务逻辑的实现,涉及框架、日志、驱动driver以及collection等一系列依赖都会通过sdk的方式引入,因此就需要对依赖包进行管理。
go依赖管理的演进主要经历了3个阶段,从GOPATH到Go Vendor再到目前被广泛应用的Go Module。主要围绕实现两个目标来迭代发展
- 不同环境(项目)依赖的版本不同
- 控制依赖库的版本
GOPATH
GOPATH是go语言支持的一个环境变量,是go项目的工作区。根目录有以下结构
- src:存放go项目的源码
- pkg:存放编译的中间产物,加快编译速度
- bin:存在go项目编译生成的二进制文件
GOPATH的弊端
GOPATH无法实现package的多版本控制。同一个pkg,存在两个版本,有两个项目依赖不同的版本,但是src下只能存放一个版本,那么就无法保证两个版本的项目都能通过编译。就是说在GOPATH管理模式下,如果多个项目依赖同一个库,则该库只能是同一份代码,无法实现多个项目依赖同一个库的不同版本。于是Go Vendor应运而生。
Go Vendor
Go Vendor在GOPATH的基础上增加了vendor目录,用于存放当前项目依赖的副本。在Vendor机制下,如果当前目录存在vendor目录,则会优先使用该目录下的依赖,如果依赖不存在,再从GOPATH中寻找。这样,通过每个项目引入一份依赖副本,解决了多个项目需要同一个package依赖的冲突问题。但是vendor无法很好解决依赖包版本变动问题和一个项目依赖同一个包的不同版本的问题。实质上,vendor并不能很清晰地标识依赖的版本概念,无法控制依赖的版本,更新项目又可能导致依赖冲突、编译出错。于是又诞生了Go Module。
Go Module
Go Module是go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸多问题。Go Module在go语言1.11版本开始引入,在go语言1.16版本默认开启。习惯上将Go Module称为go mod。
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod指令工具管理依赖包
go mod最终目标是定义版本规则和管理项目依赖关系。(相当于Java中的Maven)
依赖管理三要素
要素 | 对应工具 |
---|---|
配置文件,描述依赖 | go.mod |
中心仓库管理依赖库 | Proxy |
本地工具 | go get / go mod |
依赖配置——go mod
使用模块路径来标识一个模块,从模块路径找到该模块,如果是github前缀则表示可以从github仓库中找到该模块,依赖包的源代码由github托管,如果项目的子包需要被单独引用,则通过单独的init go.mod文件进行管理。
1 | module example/project/app //依赖管理基本单元 |
依赖配置——version
GOPATH和Go Vendor都是源码副本形式的依赖,没有版本规则的概念,而go mod为了方便管理定义了版本规则,分为语义化版本和基于commit的伪版本。
语义化版本有三个部分
${MAJOR}.${MINOR}.${PATCH}
,如v1.3.0,v2.3.0不同的MAJOR版本表示是不兼容的API,所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块。
MINOR版本通常是新增函数或功能,向后兼容。
PATCH版本一般是修复bug。
基于commit的伪版本也有三个部分
vX.0.0-yyyymmddhhmmss-abcdefgh1234
基础版本前缀和语义化版本是一样的。
时间戳,也就是提交commit的时间。
最后是校验码,包含12位的哈希前缀。每次commit后go都会默认生成一个伪版本号。
依赖配置——indirect
依赖单元中的特殊标识符,indirect后缀,表示go.mod对应的当前模块没有直接导入该依赖模块的包,也就是间接依赖。
依赖配置——incompatible
在主版本为v2及以上的模块后面会有+incompatible后缀,这让go mod能够按照不同的模块来处理同一个项目不同主版本的依赖。由于go mod是在go1.11版本才开始引入,在这个更新之前已经有一些仓库打上了v2或者更高的版本tag。为了兼容这部分仓库,对于没有go.mod文件且MAJOR主版本在v2及以上的依赖,会在版本号后面加上+incompatible
的后缀,表示可能存在不兼容的源代码。
依赖配置——依赖图
如果一个主项目依赖A、B两个项目,A、B分别依赖C项目v1.3、v1.4的两个版本,则最终编译时所使用的C项目版本为最低的兼容版本,即v1.4。
依赖分发——回源
依赖分发,也就是指go mod从哪里下载,如何下载。
Go Modules系统中定义的依赖最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。
但直接使用版本管理仓库下载依赖,存在诸多问题
- 无法保证构建确定性,软件作者可以直接在代码平台 增加/修改/删除 软件版本,导致下次构建时使用另外版本的依赖或者找不到依赖版本。
- 无法保证依赖可用性,依赖软件作者可以直接在代码平台删除软件,导致依赖不可用。
- 增加第三方压力,每次从第三方代码托管平台下载依赖时都会增加第三方代码托管平台的压力。
依赖分发——Proxy
Go Proxy就是解决上述的依赖分发问题。Go Proxy是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然能够下载,从而实现了immutability(不变性)和available(可用的)的依赖分发。
使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖。
依赖分发——变量 GOPROXY
Go Modules通过GOPROXY环境变量使用Go Proxy服务。GOPROXY是一个Go Proxy站点URL列表,可以使用"direct"表示源站。
示例配置
1 | GOPROXY="https://proxy1.cn, https://proxy2.cn, direct" |
对于以上配置,整体的依赖寻址路径会先从proxy1下载,如果proxy1不存在,再从proxy2寻找,如果proxy2不存在,则回源到源站直接下载依赖,并缓存到proxy站点中。
工具——go get
go get是Go Module的两个重要工具之一。
go get使用命令
1 | go get example.org/pkg... |
以上命令后面接不同的指令,这些指令具有不同的作用
指令 | 作用 |
---|---|
@update | 默认 |
@none | 删除依赖 |
@v1.1.2 | 下载指定tag版本,语义版本 |
@23dfdd5 | 特定的commit |
@master | 分支的最新commit |
工具——go mod
go mod也是Go Module中的重要工具。
go mod 使用命令
初始化,创建go.mod文件
1 | go mod init |
下载模块到本地缓存
1 | go mod download |
增加需要的依赖,删除不需要的依赖
1 | go mod tidy |
在实际开发中,建议在向仓库提交代码之前执行go mod tidy
,可以减少构建时无效依赖包的拉取。
使用go mod前的一些注意事项
- 设置GO111MODULE=on,表示只使用Go Module而不会使用GOPATH。具体配置参考七牛云 - Goproxy.cn
- 清空IDE中的所有的GOPATH。go mod和GOPATH不能并存,开启go mod之后需要在IDE中把项目从GOPATH移除,否则可能会出错。
- 在项目中创建go.mod文件,如果已经存在则不需要重新创建。创建完go.mod文件后先执行
go mod tidy
来增加项目启动所需的最小依赖。
测试
在实际工程开发中,除了依赖管理,还有另一个重要概念就是单元测试,包括单元测试规范、Mock测试、基准测试。测试关系着系统的质量,质量决定线上系统的稳定性,一旦出现问题就会引起事故。测试就是为了避免这些事故。
测试一般分为回归测试,集成测试和单元测试。回归测试一般是手动通过终端回归一些固定的主流场景,集成测试是对系统功能维度做测试验证,而单元测试阶段,开发者对单独的函数、模块做功能验证,层级至上而下,测试成本逐渐降低,测试覆盖率逐步上升。因此单元测试的覆盖率一定程度上决定了代码的质量。
单元测试
单元测试主要包括输入、测试单元、输出,以及校对。单元的概念比较广泛,包括接口、函数、模块等。最后的校对用于保证代码的功能符合预期。单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上即保证了新功能本身的正确性,又保护了原有代码的完整性;另一方面可以提升效率,在代码存在bug的情况下,通过单元测试,可以在较短周期内定位和修复问题。
规则
单元测试的一些基本规范
- 所有测试文件名称以_test.go结尾
- 所有用于测试的函数声明为
func TestXxx(t *testing.T)
- 初始化逻辑放到TestMain函数中实现
这样就很好地区分了源代码和测试代码。
示例
源代码
1 | func HelloTom() string { |
测试代码
1 | func TestHelloTom(t *testing.T) { |
运行
运行查看结果
assert
assert包提供了对代码测试的支持,能够快速方便地进行代码测试
获取assert包,在当前项目中使用以下命令
1 | go get github.com/stretchr/testify/assert |
改造原来的测试代码
1 | func TestHelloTom(t *testing.T) { |
运行查看结果,使用assert测试能够输出更加详细的信息。
覆盖率
代码覆盖率用于衡量代码是否经过了足够的测试,评价项目的测试水准,评估项目是否达到了高水准测试等级。
示例代码
judgment.go
1 | func JudgePassLine(score int16) bool { |
judgment_test.go
1 | func TestJudgePassLineTrue(t *testing.T) { |
使用以下命令进行测试
1 | go test judgment_test.go judgment.go --cover |
通过指定cover参数,可以看到覆盖率,表示执行到的代码行数占实际代码总行数。
提升覆盖率
可以在测试代码中增加其他的情况,重新执行所有单元测试,最终使覆盖率达到100%
新增测试函数
1 | func TestJudgePassLineFail(t *testing.T) { |
重新执行测试命令,指定cover参数,查看测试结果。
Tips
- 实际项目中,一般要求覆盖率是50%~60%,对于资金型服务,覆盖率要求达到80%以上。
- 在进行单元测试时,要求测试分支相互独立、全面覆盖。
- 测试单元粒度足够小,函数职责单一,即要求函数体足够小,这样能比较简单地提升覆盖率,也符合函数设计的单一职责。
Mock测试
工程中复杂的项目一般会依赖File、DB、Cache等外部依赖,而单元测试需要保证稳定性和幂等性。稳定性指相互隔离,能在任何时间、任何环境下运行测试;幂等是指每次测试运行都应该产生与之前一样的结果。而实现这些目的就需要Mock机制。
文件处理
将测试文件进行删除,进行单元测试,测试通过,但单元测试需要依赖本地的文件,如果文件被修改或者删除测试就会失败,也就是说删除文件的测试普通情况下只能测试一次。为了保证测试case的稳定性,需要对删除文件函数进行mock,屏蔽对文件的依赖。
快速Mock函数
这里提供了一个开源的mock测试库Monkey patching in Go ,可以对函数或者实例的方法进行mock测试,原理是反射和指针赋值。这里引入了一个打桩的概念,桩或者桩代码就是指用来代替关联代码或者未实现代码的代码,目的主要是隔离、补齐、控制。
快速Mock函数能为一个函数打桩或为一个方法打桩。Monkey Patch的作用域在Runtime,在运行时通过Go的unsafe包,能够将内存中函数的地址替换为运行时函数的地址,将待打桩函数或方法的实现跳转到运行时。
示例
通过patch对ReadFirstLine进行打桩mock,通过defer卸载mock,使整个测试函数脱离了对本地文件的依赖。
源代码
1 | func ReadFirstLine() string { |
测试代码
1 | func TestProcessFirstLine(t *testing.T) { |
测试文件内容
1 | line11 |
monkey的部分源代码实现,主要通过这些函数实现打桩
1 | func Patch(target, replacement interface{}) *PatchGuard { |
改造测试代码增加函数实现打桩,通过patch对ReadFirstLine进行打桩mock,使其默认返回line110
1 | func TestReadFirstLineWithMock(t *testing.T) { |
这样即使没有本地文件,也能够模拟读取、修改或删除文件的测试。
基准测试
go语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费CPU的程度。在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试。
示例
服务器负载均衡问题。
源代码,假设有10台服务器,每次随机选择其中1台执行。
1 | func InitServerIndex() { |
测试代码,基准测试以Benchmark开头,参数类型是testing.B,用b中的N值反复递增循环测试。基准测试对于一个测试用例的默认测试时间是1秒,当测试用例函数返回时还不到1秒,那么testing.B中的N值将按1、2、5、10、20、50…递增,并以递增后的值重新进行用例函数测试。
ResetTimer重置计时器,在重置之前的初始化或其他准备操作,不属于基准测试的范围,通过选择重置的时间点来跳过不必要的测试。
RunParallel是多协程并发测试。执行两个基准测试,可以发现代码在并发情况下存在劣化,主要原因是rand包为了保证全局的随机性和并发安全,使用了全局锁。
1 | func BenchmarkSelect(b *testing.B) { |
运行
在IDE中使用gobench benchmark
选项进行测试,不要直接运行。
优化
为了解决上述的随机性能瓶颈问题,有一个开源的高性能随机数方法fastrand,仓库地址bytedance/gopkg 。主要思路是牺牲了一定的数列一致性来换取性能,在大多数场景下适用,相比原始方法的性能有大幅提升。
引入fastrand
1 | go get github.com/bytedance/gopkg |
将源代码中的rand替换为fastrand即可。
项目实战
在并发编程,依赖管理以及单元测试的基础上,通过项目实践来理解项目开发的思路和流程,主要包括需求设计,代码开发和测试运行。
需求设计
需求背景
开发一个类似掘金社区的服务端小功能。
需求描述
社区话题页面
- 展示话题(标题、文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
需求用例
主要涉及用户浏览消费,页面的展示,包括话题内容和回帖的列表。可以先抽象出两个实体,话题内容和回帖列表,分析它们所具有的属性以及联系,定义出结构体。
ER 图——Entity Relationship Diagram
ER图用于描述现实世界的概念模型。有了模型实体、属性以及联系,就能进入下一步,思考代码结构设计。
参考ER图设计:Topic话题有id、titile、content、create_time四个属性。Post帖子有id、topic_id、content、create_time四个属性,其中id和topic_id和Topic相联系。
这里采用典型的分层结构设计。
代码开发
分层结构
分层结构整体分为三层,repository数据层,service逻辑层,controller视图层。
- 数据层关联底层数据模型,也就是model,封装外部数据的增删改查。这里的数据存储在本地文件,通过文件操作拉取话题,帖子数据。
- 数据层面向逻辑层,也就是对service层透明,屏蔽下游数据差异,即逻辑层不需要考虑数据的来源是本地文件、数据库还是微服务等。逻辑层只处理核心业务逻辑,接口模型保持不变,计算打包业务实体Entity,对应需求并上传给视图层。
- 视图层controller负责处理与外部交互的逻辑,以view视图的形式返回给客户端。这里只考虑封装为json格式化的请求结果,通过API形式访问即可。
组件工具
开发涉及的基础组件工具。Gin:开源的高性能go web框架,源地址gin-gonic 。这里基于gin搭建web服务器,本项目主要涉及路由分发的概念,不涉及其他复杂概念。
使用web框架,需要用Go Module依赖管理。先用go mod init初始化go.mod管理配置文件。
1 | go mod init main |
下载gin依赖
1 | go get -u github.com/gin-gonic/gin |
在框架依赖的基础上,只需要关注业务本身的实现,从repository到service再到controller逐步实现。
Repository
struct
根据之前的ER图定义结构体
index
查询数据可以使用全扫描遍历的方式,但是效率不高,所以这里引入索引的概念。
索引就像书的目录,可以快速查找定位到需要的结果。这里利用map实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,实现O(1)时间复杂度的查找操作。
1 | var ( |
具体实现,打开文件,基于file初始化scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map,完成初始化话题内存索引。同理实现帖子的内存索引初始化
1 | func initTopicIndexMap(filePath string) error { |
查询
实现查询操作,直接查询key获取map中的value。这里使用了sync.Once,主要适用于高并发的情况下只执行一次的场景。基于Once的实现模式就是单例模式,减少存储的浪费。
topic查询实现,这里的topic结构体自行设计。同理实现post的查询
1 | type TopicDao struct { |
Service
实现了repository层之后就是service层。
定义service层实体
1 | type PageInfo struct { |
实现流程是参数校验、准备数据、组装实体。
代码流程编排,通过err控制流程退出,正常的话会返回页面信息。
1 | func (f *QueryPageInfoFlow) Do() (*PageInfo, error) { |
prepareInfo方法实现,由于话题和回帖信息的获取都需要topicId,这就可以考虑并行执行,提高效率。并行可以充分利用多核CPU的资源,降低接口耗时。
1 | func (f *QueryPageInfoFlow) prepareInfo() error { |
Controller
service层之后是controller层。定义一个view对象,通过code msg打包业务状态信息,用data承载业务实体信息。
参考代码
1 | type PageData struct { |
Router
最后是web服务的引擎配置,包括
- 初始化数据索引
- 初始化引擎配置
- 构建路由
- 启动服务
path映射到具体的controller,通过path变量传递话题id。
参考写法
1 | func main() { |
测试运行
通过go run命令运行本地web服务,main.go是这里main方法所在的go文件,文件名称可以自定义
1 | go run main.go |
通过curl命令请求服务暴露的接口,查看结果。
1 | curl --location --request GET "http://0.0.0.0:8080/community/get/1" | json |
这里的json命令是通过nodejs的npm安装的,用于格式化curl输出的json信息。
1 | npm install -g json |
参考资料
用户态和内核态的区别 - Gizing - 博客园 (cnblogs.com)
Go goroutine理解 - golang开发笔记 - SegmentFault 思否
- 标题: Go 工程进阶
- 作者: Entropy Tree
- 创建于 : 2023-01-26 18:01:52
- 更新于 : 2023-04-01 07:55:52
- 链接: https://www.entropy-tree.top/2023/01/26/golang-day2/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。