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

233 阅读19分钟

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

本堂课重点内容

  • 并发编程与并发安全

    • 协程
    • channel
    • sync
  • Golang 依赖管理与演进

    • GOPATH
    • Go Vendor
    • Go Module
  • 测试

    • 单元测试
    • Mock测试
    • 基准测试
  • 项目实战

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

1.语言进阶-并发编程

从并发编程的视角了解 Go 高性能的的本质。

1.0 并发 VS 并行

image-20230116105147597.png

image-20230116105203360.png Go 可以充分发挥多核优势,高效运行

1.1 goroutine

image-20230116105425130.png

协程:用户态,轻量级线程,栈 MB 级别。 线程:内核态,线程跑多个协程,栈 KB 级别。

image-20230116110905380.png image-20230116110927192.png

快速 打印 hello goroutine:0 ~ hello goroutine:4

1.2 CSP(Communicating Sequential Processes)

image-20230116111115177.png

提倡通过 通信共享内存 而不是通过共享内存而实现通信

1.3 Channel

make(chan 元素类型,[缓冲大小])

  • 无缓冲通道 make(chan int)
  • 有缓冲通道 make(chan int,2)

image-20230116111322203.png

image-20230116112904536.png

1.4 并发安全 LOCK

image-20230116113247221.png 对变量执行 2000 次 +1 操作,5个协程并发执行,如果不加锁,多个 goroutine 同时操作一块内存,会发生数据竞态,输出未知的结果,出现并发安全问题;如果在 +1 之前加锁,保护获取的临界区的资源,计算完再释放,就得到预期的结果,实现并发安全。

1.5 WaitGroup

image-20230116114149810.png 计数器 开启协程 +1;执行结束 -1;主协程阻塞直到计数器为 0

image-20230116114825639.png 让我们回到最初多个协程带打印 hello goroutine 的例子,现在我们用 watigroup 实现协程的同步阻塞。 首先通过 add 方法,对计数器 +5,然后开启协程,每个协程执行完后,通过 done 对计数器减少 1,最后 wait 主协程阻塞,计数器为 0 退出主协程,实现了协程间的同步。

1.6 小结

  • 协程,通过高效的调度模型实现高并发操作
  • 通道 channel,通过通信实现共享内存
  • sync 相关关键字,实现并发安全操作和协程间的同步。

2.依赖管理

在实际工程开发中,一个重要概念就是依赖管理,这一节主要讲解 go 的依赖管理,主要涉及 go 依赖管理的演进路线和 go module 实践,

依赖指各种开发包,我们在开发项目中,需要学会站在巨人的肩膀上,也就是利用已经封装好的、经过验证的开发组件或工具来提升自己的研发效率。

2.0 背景

image-20230116120208252.png 对于 hello world 以及类似的单体函数只需要依赖原生 SDK ,而实际工程会相对复杂,我们不可能基于标准库 0~1 编码搭建,而更多的关注业务逻辑的实现,而其他的涉及框架、日志、driver、以及 collection 等一系列依赖都会通过 sdk 的方式引入,这样对依赖包的管理就显得尤为重要

2.1 Go 依赖管理演进

image-20230116120450607.png Go 的依赖管理主要经历了3个阶段,分别是 gopath,go vendor,go module,到目前被广泛应用的 go module,整个演进路线主要围绕实现两个目标来迭代发展的,分别是

  • 不同环境(项目)依赖的版本不同
  • 控制依赖库的版本

2.11 GOPATH

a.GOPATH 介绍

image-20230116164542826.png GOPATH 是 Go 语言支持的一个环境变量,value是Go项目的工作区。目录有以下结构:

  • src:存放 Go 项目的源码;
  • pkg:存放编译的中间产物,加快编译速度;
  • bin:存放 Go 项目编译生成的二进制文件。

环境变量 $GOPATH ,项目代码直接依赖 src 下的代码,go get 下载最新版本的包到 src 目录下

b.GOPATH 弊端:

image-20230116164609260.png

  • 场景:A 和 B 依赖于某一 package 的不同版本。
  • 问题:无法实现 package 的多版本控制

如图,同一个 pkg,有 2 个版本,A->A(),B->B(),而 src 下只能有一个版本存在,那 A、B 项目无法保证都能编译通过。也就是在 gopath 管理模式下,如果多个项目依赖同一个库,则依赖该库是同一份代码,所以不同项目不能依赖同一个库的不同版本,这很显然不能满足我们的项目依赖需求。为了解决这问题,go vendor 就出现了,

2.12 Go Vendor

a.Go Vendor介绍
  • 项目目录下增加 vendor 文件,所有依赖包副本形式放在 $ProjectRoot/vendor

  • 依赖寻址方式:vendor => GOPATH

image-20230116164702875.png

Vendor 是当前项目中的一个目录,其中存放了当前项目依赖的副本。在 Vendor 机制下,如果当前项目存在 Vendor 目录,会优先使用该目录下的依赖,如果依赖不存在,会从 GOPATH 中寻找。

通过每个项目引入一份依赖的副本,解决了多个项目需要同一个 package 依赖的冲突问题。

但 vendor 无法很好解决依赖包的版本变动问题和一个项目依赖同一个包的不同版本的问题。

b.Go Vendor 弊端:

问题

  • 无法控制依赖的版本。
  • 更新项目又可能出现依赖冲突,导致编译出错。

image-20230116164743176.png

如图项目 A 依赖 pkg B 和 C,而 B 和 C 依赖了 D 的不同版本,通过 vendor 的管理模式我们不能很好的控制对于 D 的依赖版本,一但更新项目,有可能带来依赖冲突,导致编译出错。

归根到底 vendor 依赖的是项目的源码,不能很清晰的标识依赖的版本概念。那么,go module 就应运而生了。

2.13 Go Module

依赖管理终极目标:定义版本规则和管理项目依赖关系

  • 通过 go.mod 文件管理依赖包版本
  • 通过 go get/go mod 指令工具管理依赖包

Go Module 是 Go 语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题,go module 从 Go 1.11 开始实验性引入,Go1.16 默认开启;

Go Module,我们一般都读为 go mod ,它通过go.mod 文件和 go get/go mod 指令工具,实现了依赖管理的终极目标,

2.2.依赖管理三要素:

  1. 配置文件,描述依赖 ---> 对应 go.mod
  2. 中心仓库管理依赖库 ---> 对应 Proxy
  3. 本地工具 ---> 对应 go get/mod

2.3 依赖配置

2.3.1 go.mod

image-20230116164832087.png

依赖标识:[Module Path][Version/Pseudo-version]

首先模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块,如果是 github 前缀则表示可以从 Github 仓库找到该模块,依赖包的源代码由github 托管,如果项目的子包想被单独引用,则需要通过单独的 init go.mod 文件进行管理。 然后是依赖的原生 sdk 版本,最后是单元依赖,每个依赖单元用模块路径 + 版本来唯一标识。

2.3.2 version

a.语义化版本(git 里面 tag 的概念):

${MAJOR}.${MINOR}.${PATCH}

如:V1.3.0 、V2.3.0

b.基于 commit 伪版本:

vX.0.0-yyyymmddhhmmss-abcdefgh1234

如:v0.0.0-20220401081311-c38fb59326b7 、v1.0.0-20201130134442-10cb98267c6c

gopath 和 go vendor 都是源码副本方式依赖,没有版本规则概念,而go mod,为了放方便管理则定义了版本规则,分为语义化版本和基于 commit 伪版本; 其中语义化版本包括,

  • 不同的 MAJOR 版本表示是不兼容的 API,所以即使是同一个库,MAJOR 版本不同也会被认为是不同的模块;
  • MINOR 版本通常是新增函数或功能,向后兼容;
  • PATCH 版本一般是修复 bug。

而基于commit的伪版本包括,

  • 基础版本前缀是和语义化版本一样的;
  • 时间戳(yyyymmddhhmmss),也就是提交 Commit 的时间,
  • 最后是校验码(abcdefabcdef),包含 12 位的哈希前缀;

每次提交 commit 后 Go 都会默认生成一个伪版本号。

2.3.3 indirect

下面我们再来看下依赖单元中的特殊标识符,首先是 indirect 后缀,表示 go.mod 对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖,标示间接依赖

image-20230116164904136.png 比如 A -> B -> C ,A -> B 直接依赖,A -> C 就是间接依赖(indirect)

2.3.4 incompatible

image-20230116154717742.png

  • 主版本 2+ 模块会在模块路径增加 /vN 后缀。
  • 对于没有 go.mod 文件并且主版本 2+ 的依赖,会 +incompatible

下一个常见是的是 incompatible,主版本 2+ 模块会在模块路径增加 /vN 后缀,这能让 go module 按照不同的模块来处理同一个项目不同主版本的依赖。由于 go module 是 1.11 实验性引入,所以这项规则提出之前已经有一些仓库打上了 v2 或者更高版本的 tag 了,为了兼容这部分仓库,就对于没有 go.mod 文件并且主版本在 v2 或者以上的依赖,会在版本号后加上 +incompatible 后缀

前面讲语义化版本提到,对于同个库的不同的 major 版本,需要建立不同的 pkg 目录,用不同的 go mod 文件管理,如下面仓库为例,V1 版本 go mod 在主目录下,而对于 V2 版本,则单独建立了 V2 目录,用另个 go mod 文件管理依赖路径,来表明不同 major 的不兼容性。

那对于有 V2+tag 版本的依赖包并未遵循这一定义规则,就会打上 +incompatible 标志,增加一个 compatible 的 case

2.3.5 依赖图

image-20230116160241545.png

答案是 B ,会选择最低的兼容版本

2.4 依赖分发

2.4.1 回源

下面讲一下 go module 的依赖分发,也就是从哪里下载,如何下载的问题。

image-20230116160759012.png github 是比较常见给的代码托管系统平台,而 Go Module 系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交或版本,这样的话,对于 go.mod 中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。

但直接使用版本管理仓库下载依赖,存在多个问题,

  • 无法保证构建稳定性:软件作者可以直接在代码平台增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本。
  • 无法保证依赖可用性:依赖软件作者可以直接在代码平台删除软件,导致依赖不可用。
  • 增加第三方压力: 第三方代码托管平台负载问题,大幅增加其压力。

2.4.2 Proxy

image-20230116162300777.png 而 go proxy 就是解决这些问题的方案,Go Proxy 是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供 "immutability" 和 "available" 的依赖分发;使用 Go Proxy 之后,构建时会直接从 Go Proxy 站点拉取依赖。

类比项目中,下游无法满足我们上游的接口,我们就可以使用 proxy 或者适配器的方案,一层 proxy 不行,就多层。

2.4.3 变量、GOPROXY

image-20230116163624823.png go proxy 的使用

Go Module 通过 GOPROXY 环境变量控制如何使用 Go Proxy; GOPROXY 是一个 Go Proxy 站点 URL 列表,可以 使用 “direct” 表示源站。对于示例配置,整体的依赖寻址路径,会优先从 proxy1 下载依赖,如果 proxy1不存在,后下到 proxy2 寻找,如果 proxy2 中不存在则会回源到源站直接下载依赖,缓存到 proxy 站点中。

2.5 工具

2.5.1 go get

image-20230116164029685.png

2.5.2 go mod

image-20230116164214799.png

尽量提交代码之前执行下 go tidy,减少构建时无效依赖包的拉取

3.测试

以单元测试实践出发,提升质量意识。

3.0 测试介绍

3.0.1 质量就是‘生命’

测试关系着系统的质量,质量则决定线上系统的稳定性,一但出现 bug 漏洞,就会造成事故,

3.0.2 事故

image-20230116165321575.png

3.0.3 测试

image-20230116165402812.png 测试是避免事故的最后一道屏障,只要我们做好完备的测试,就可以避免事故的发生。

image-20230116165605909.png 单元测试一般分为这三个:

  • 回归测试 一般是 QA(质量保证)同学手动通过终端回归一些固定的主流程场景,
  • 集成测试 是对系统功能维度做测试验证,
  • 而单元测试是在开发阶段,开发者对单独的函数、模块做功能验证

层级从上至下,测试成本逐渐减低,而测试覆盖率确逐步上升,所以单元测试的覆盖率一定程度上决定这代码的质量。

3.1 单元测试

image-20230116170254622.png 单元测试主要包括,输入,测试单元,输出,以及校对

单元的概念比较广,包括接口,函数,模块等

用最后的校对来保证代码的功能与我们的预期相符

单元测试一方面可以保证质量,在整体覆盖率足够的情况下,一定程度上既保证了新功能本身的正确性,又未破坏原有代码的正确性。另一方面可以提升效率,在代码有 bug 的情况下,通过编写单测,可以在一个较短周期内定位和修复问题。

3.1.1 规则

image-20230116171214189.png

3.1.2 例子

image-20230116171558341.png

3.1.3 运行

image-20230116171659443.png

3.1.4 assert

image-20230116171831294.png

3.1.5 覆盖率

如何衡量代码是否经过了足够的测侧试? 如何评价项目的测试水准? 如何评估项目是否达到了高水准测试等级?

那就是代码覆盖率

例子:

image-20230116172053335.png

这是有一个判断是否及格的函数,超过 60 分,返回 true,否则返回 false,右边是对输入为 70 的单元测试,我们执行右边的单测,通过指定 cover 参数,我们看输出了覆盖率为 66.7%,因为代码一共三行,我们的单测试执行了 2 行

image-20230116172532941.png

下一步就是提升覆盖率,我们可以增加一个不及格的测试 case,重新执行所有单测,最终覆盖率为 100%

3.1.6 Tips

  • 一般覆盖率:50%~60%,较高覆盖率 80%+(比如资金类项目)。
  • 测试分支相互独立、全面覆盖。
  • 测试单元粒度足够小,函数单一职责。

3.2 依赖

image-20230116172831516.png

工程中复杂的项目,一般会依赖 File(本地文件)、DB(数据库)、Cache(Redis), 而我们的单测需要保证稳定性和幂等性

  • 稳定是指相互隔离,能在任何时间,任何环境,运行测试。
  • 幂等是指每一次测试运行都应该产生与之前一样的结果。

而要实现这一目的就要用到 mock 机制。

3.3 文件处理

image-20230116173204515.png

下面举个栗子,将文件中的第一行字符串中的 11 替换成 00,执行单测,测试通过,而我们的单测需要依赖本地的文件,如果文件被修改或者删除,测试就会 fail。为了保证测试 case 的稳定性,我们对读取文件函数进行 mock,屏蔽对于文件的依赖。

3.4 Mock

快速 Mock 函数

  • 为一个函数打桩
  • 为一个方法打桩

image-20230116173628963.png 这里我们用了 monkey(github.com/bouk/monkey) ,monkey 是一个开源的 mock 测试库,可以对 method 或者实例的方法进行 mock、反射、指针赋值 Mockey Patch 的作用域在 Runtime,在运行时通过 Go 的 unsafe 包,能够将内存中函数的地址替换为运行时函数的地址。将待打桩函数或方法的实现跳转 到 Mock。

例子:

image-20230116174101776.png

这是一个 mock 的使用样例,通过 patch 对 ReadfirstLine 进行打桩 mock,默认返回 line110 ,这里通过 defer 卸载 mock,这样整个测试函数就摆脱了本地文件的束缚和依赖。

3.5 基准测试

  • 优化代码需要对当前代码分析
  • 内置的测试框架提供了基准测试的能力

Go 语言还提供了基准测试框架,基准测试是指测试一段程序的运行性能及耗费 CPU 的程度。而我们在实际项目开发中,经常会遇到代码性能瓶颈,为了定位问题经常要对代码做性能分析,这就用到了基准测试,使用方法类似于单元测试。

3.5.1 例子

image-20230116174554255.png 这里举一个服务器负载均衡的例子,首先我们有 10 个服务器列表,每次随机执行 select 函数随机选择一个服务器执行。

3.5.2 运行

image-20230116174705680.png

基准测试以 Benchmark 开头,入参是 *testing.B ,用 b 中的 N 值反复递增循环测试 (对一个测试用例的默认测试时间是 1 秒,当测试用例函数返回时还不到 1 秒,那么 testing.B 中的 N 值将按 1、2、5、10、20、50 递增,并以递增后的值重新进行用例函数测试。)

ReseTimer 重置计时器,我们再 reset 之前做了 init 或其他的准备操作,这些操作不应该作为基准测试的范围,RunParallel 是多协程并发测试,执行 2 个基准测试,发现代码在并发情况下测试性能存在劣化,主要原因是 rand 为了保证全局的随机性和并发安全,持有了一把全局锁。

3.5.3 优化

image-20230116180339626.png

而字节为了解决这一随机性能问题,开源了一个高性能随机数方法 fastrand,开源地址:github.com/bytedance/g…;我们这边再做一下基准测试,性能提升了百倍。主要的思路是牺牲了一定的数列一致性,在大多数场景是适用的,以后遇到随机的场景可以尝试用一下

4.项目实战

通过项目需求、需求拆解、逻辑设计、代码实现感受下真实的项目开发。

4.0 需求背景

image-20230116180835428.png

大家应该都是从掘金的社区话题入口报名的,都看到过这个页面,页面的功能包括话题详情,回帖列表,支持回帖,点赞,和回帖回复,我们今天就以此为需求模型,开发一个该页面交涉及的服务端小功能。

4.1 需求描述

image-20230116181118867.png

4.2 需求用例

image-20230116181226795.png

我们从用例分析一步步拆解实现,主要涉及 2 个功能点,用户浏览消费,涉及页面的展示,包括话题内容和回帖的列表,其实从图中我们应该会抽出 2 个实体的,而实体的属性有哪些,他们之间的联系又如何?

4.3 ER图-Entity Relationship Diagram

image-20230116181540372.png

这是个 ER 图,用来描述现实世界的概念模型。有了模型实体,属性以及之间的联系,对我们后续做开发就提供了比较清晰的思路。

回到需求,两个个实体主要包括,实体的属性,实体的联系;有了实体模型,下一步就是思考代码结构设计。我们采用典型的分层结构设计,

4.4 分层结构

image-20230116181855172.png

整体分为三层,repository 数据层,service 逻辑层,controoler 视图层,

Repository 数据层关联底层数据模型,也就是这里的 model ,封装外部数据的增删改查,我们的数据存储在本地文件,通过文件操作拉取话题,帖子数据;数据层面向逻辑层,对service层透明,屏蔽下游数据差异,也就是不管下游是文件,还是数据库,还是微服务等,对 service 层的接口模型是不变的。

Servcie 逻辑层处理核心业务逻辑,计算打包业务实体 entity,对应我们的需求,就是话题页面,包括话题和回帖列表,并上送给视图层;

Controller 视图层负责处理和外部的交互逻辑,以 view 视图的形式返回给客户端,对于我们需求,我们封装 json 格式化的请求结果,以 api 形式访问就好,

4.5 组件工具

下面介绍下开发涉及的基础组件和工具,首先是 gin,高性能开源的 go web 框架,我们基于 gin 搭建 web 服务器,在课程手册应该提到了,这里我们只是简单的使用,主要涉及路由分发,不会涉及其他复杂的概念。

因为我们引入了 web 框架,所以就涉及 go module 依赖管理,如前面依赖管理课程内容讲解,我们首先通过 go mod,是初始化 go mod 管理配置文件,然后go get 下载 gin 依赖。

有了框架依赖,我们只需要关注业务本身的实现,从 repository -> service -> controller,我们一步步实现。

4.6 Rexpository

image-20230116183532924.png

首先是 Rexpository,我们可以根据之前的 er 图先定义struct结构体,文件中每行的格式如图所示,那如何实现 QueryTopicById 和 QueryPostsByParentId ?

Rexpository-index :

image-20230116183819164.png

一方面查询我们可以用全扫描遍历的方式,但是这虽然能达到我们的目的,但是并非高效的方式,所以这里引出索引的概念,索引就像书的目录,可以引导我们快速查找定位我们需要的结果,这里我们用 map 实现内存索引,在服务对外暴露前,利用文件元数据初始化全局内存索引,这样就可以实现 O(1) 的时间复杂度查找操作。

image-20230116184100806.png

这是具体的实现,首先是打开文件,基于 file 初始化 scanner,通过迭代器方式遍历数据行,转化为结构体存储至内存map,这就是初始化话题内存索引

Rexpository-查询

image-20230116184607516.png

有了内存索引,下一步就是实现查询操作就比较简单了,直接根据查询 key 获得 map 中的 value 就好了,这里用到了 sync.once ,主要适用高并发的 场景下只执行一次的场景,这里的基于 once 的实现模式就是我们平常说的单例模式,减少存储的浪费。

4.7 Service

image-20230116184702927.png

有了 repository 层以后,下面我们开始实现 service 层,首先我们定义 servcie 层实体

代码流程编排:

image-20230116184918211.png

image-20230116185017940.png

关于 prepareInfo 方法,话题和回帖信息的获取都依 topicid,这样这 2 个就可以并行执行,提高执行效率。在后期做项目开发中,一定要思考流程是否可以并行,通过压榨 CPU,降低接口耗时,不要一味的串行实现,浪费多核 cpu 的资源

4.8 Controller

image-20230116185348846.png

Service 实现完成,下面就是 controller 层。这里我们定义一个 view 对象,通过 code msg 打包业务状态信息,用 data 承载业务实体信息

4.9 Router

image-20230116193002848.png

最后是 web 服务的引擎配置,包括 path 映射到具体的 controller。通过 path 变量传递话题 id

4.10 运行

image-20230116193155190.png

最后执行 go run 本地启动 web 服务,通过 curl 命令请求服务暴露的接口,当然平时写代码不可能这么顺畅,难免有bug,要做好完备的单元测试,快速定位问题,解决问题。

项目实战代码链接:

GitHub: github.com/Moonlight-Z…

个人总结

  • Golang 中使用关键字 go 就能实现并发,非常方便
  • 并发虽好,但也别忘了安全
  • Golang 的 Go Module 实现了依赖管理的终极目标
  • 写项目不是一蹴而就,而应该包含项目需求、需求拆解、逻辑设计、代码实现等步骤,一步一步推进。