在开发项目中,要学会站在巨人肩膀上,也就是利用已经封装好的、经过验证的开发组件或者工具提升自己的研发效率。
1. 背景
对于hello world以及类似的单体函数只需要依赖原生SDK,而实际工程会相对复杂,我们为不可能基于标准库0-1编码搭建,而更多关注业务逻辑的实现,而其他涉及框架、日志、driver以及collection等一系列依赖都会通过SDK的方式引入,这样对依赖包的管理就显得尤为重要。
2. Go依赖管理演进
Go的依赖管理主要经历了3个阶段:GOPATH、GoVendor、Go Module。到目前被广泛应用的go module,整个演进路线主要围绕不同环境(项目)依赖的版本不同和控制依赖库的版本两个目标迭代发展。
2.1. GOPATH
GOPATH是Go语言支持的一个环境变量,value是Go项目的工作区。
目录有以下结构
- src:存放Go项目的源码
- pkg:存放编译的中间产物,加快编译速度
- bin:存放Go项目编译生成的二进制文件
在进行Go语言开发的时候,我们的代码总是会保存在$GOPATH/src目录下。在工程经过go build、go install或go get等指令后,会将下载的第三方包源代码文件放在$GOPATH/src目录下, 产生的二进制可执行文件放在$GOPATH/bin目录下,生成的中间缓存文件会被保存在$GOPATH/pkg 下。
如果我们使用版本管理工具(Version Control System,VCS。常用如Git)来管理我们的项目代码时,我们只需要添加$GOPATH/src目录的源代码即可。bin 和 pkg 目录的内容无需版本控制。
弊端:
如图,同一个Pkg有两个版本,而src下只能有一个版本存在,那么A、B项目无法保证都能编译通过。也就是在GOPATH管理模式下,如果多个项目依赖同一个库,则依赖该库的是同一份代码,不同项目不能依赖同一个库的 不同版本,显然不能满足项目依赖需求。为了解决这一问题,Go Vender出现了。
2.2. Go Vender
项目目录下增加vendor文件,所有依赖包副本形式放在$ProjectRoot/vendor。在Vendor机制下,如果当前项目存在Vendor目录,会优先使用该目录下的依赖,如果依赖不存在,会从GOPATH中寻找。这样通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。但Vendor无法很好解决依赖包版本变动问题和一个项目依赖同一个包的不同版本的问题。
弊端:
如图,项目A依赖pkg B和C,而B和C依赖了D的不同版本,通过Vendor的管理模式我们不能很好的控制对于D的依赖版本,一旦更新项目,有可能出现依赖冲突,导致编译错误。归根结底Vendor不能很清晰的标识依赖版本概念。于是Go Module应运而生。
2.3. Go Module
Go Module是Go语言官方推出的依赖管理系统,解决了之前依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题,Go Module从Go 1.11开始实验性引入,Go 1.16默认开启。通过go.mod文件管理依赖包版本,go get/go mod指令工具管理依赖包,实现定义管理规则和管理项目依赖关系。
3. Go Module 依赖管理方案
3.1. 依赖管理三要素
- 配置文件,描述依赖
go.mod - 中心仓库管理依赖库
Proxy - 本地工具
go get/mod
3.2. 依赖配置
3.2.1. go.mod
首先模块路径用来标识一个模块,从模块路径可以看出从哪里找到该模块。如果是github前缀则表示可以从github仓库找到该模块,依赖包的源代码由github托管。如果项目的子包想被单独引用,则需要通过单独的init go.mod文件进行管理。
接着是依赖的原生SDK版本。
最下面是单元依赖,每个依赖单元用模块路径+版本来唯一标识。
3.2.2. Version
gopath和govendor都是源码副本方式依赖,没有版本规则概念,而gomod为了方便管理则定义了版本规则,分为语义化版本和基于commit的伪版本。
语义化版本${MAJOR}.${MINOR}.${PATCH}(如:V1.2.0)
包括MAJOR、MINOR、PATCH,不同的MAJOR版本表示是不兼容的API,所以即使是同一个库,MAJOR版本不同也会被认为是不同的模块;MINOR版本通常是新增函数或功能,向后兼容;而PATCH版本一般是修复bug
基于commit的伪版本vX.0.0-yyyymmddhhmmss-abcdefgh1234
(如:v1.0.0-20230808160511-10cb98267c6c)
基础版本前缀是和语义化版本一样的;时间戳(yyyymmddhhmmss)就是提交Commit的时间,最后是校验码(abcdefabcdef),包含12位的哈希前缀;每次提交commit后Go都会默认生成一个伪版本号。
3.2.3. 特殊标识符
indirect 用来标识间接依赖。表示go.mod对应当前模块,没有直接导入该依赖模块的包。
例如:在A->B->C中,A->B是直接依赖,A->C是间接依赖。
incompatible 主版本2+模块会在模块路径增加/vN后缀。对于没有go.mod文件并且主版本2+的依赖,会+incompatible
3.2.4. 依赖图
如下图所示,某个项目依赖项目A和项目B ,且项目A、B分别依赖项目C的v1.3和v1.4版本。最终编译时,Go 所使用的项目C的版本为:v1.4 。
Go选择最低的兼容版本
3.3. 依赖分发
GoModule的依赖分发,就是从哪里下载,如何下载的问题。
GitHub是比较常见的代码托管平台,而GoModules系统中定义的依赖,最终可以对应到多版本代码管理系统中某一项目的特定提交版本。这样对go.mod中定义的依赖,则直接可以从对应仓库中下载指定软件依赖,从而完成依赖分发。
但直接使用版本管理仓库下载依赖,存在着多个问题:
- 无法保证构建完整性:软件作者可以直接在代码平台增加、修改、删除软件版本,导致下次构建使用另外版本的依赖或者找不到依赖版本。
- 无法保证依赖可用性:依赖软件作者可以直接在代码平台删除软件,导致依赖不可用。
- 大幅增加第三方代码托管平台压力。
而go proxy就是解决这些问题的方案。Go Proxy是一个服务站点,它会缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站软件删除之后依然可用,从而实现了供“immutability”和“available”的依赖分发;使用Go Proxy之后,构建时会直接从Go Proxy站点拉取依赖。
Go Module通过GOPROXY环境变量控制如何使用Go Proxy。
示例:GOPROXY = "https://proxy1.cn, https://proxy2.cn, direct"
GOPROXY是一个Go Proxy站点URL列表,可以使用“direct”表示源站。对于示例,整体的依赖寻址路径会优先从proxy1下载链接,如果proxy1不存在,再从proxy2寻找,如果proxy2不存在,则回源到源站直接下载依赖,并缓存到proxy站点中。
3.4. 工具
3.4.1. go get
go get example.org/pkg
| 指令 | 功能 |
|---|---|
| @update | 默认 |
| @none | 删除依赖 |
| @v1.1.2 | 下载指定tag版本,语义版本 |
| @23dfdd5 | 下载特定的commit版本 |
| @master | 下载分支的最新commit |
3.4.2. go mod
| 指令 | 功能 |
|---|---|
| init | 初始化,创建go.mod文件 |
| download | 下载模块到本地缓存 |
| tidy | 增加需要的依赖,删除不需要的依赖 |
尽量提交之前执行下go tidy,减少构建时无效依赖包的拉取。