背景
在 Go 语言中,依赖是指一个项目所依赖的外部代码包或库。依赖是为了重用已经实现的功能,并且降低项目开发的难度和工作量,提高开发效率。对于类似于 Hello World 这样的单体函数1,我们常常只需要依赖原生的 SDK,而不需要其他多余的类、对象或是数据结构,然而实际的工程相对而言会比较的复杂,如果我们仅仅使用原生的 SDK,就要基于标准库完成从 0 到 1 的编码搭建,而这会大大提高开发难度。作为开发者,我们应该更多地关注于业务逻辑的实现,而涉及框架、日志、driver、collection 等的依赖只需通过 SDK 引入即可,这就是所谓“站在巨人的肩膀上”。但是这样一来,就必须想一个行之有效的方法来管理依赖包,这就引出了我们今天要学习的内容。
Go 依赖管理演进
Go 语言的依赖管理从最初的 GOPATH,到后来的 Go Vendor,再到最后的 Go Moudles,依赖管理系统主要围绕着以下两个目标进行迭代发展:
-
复用和版本控制:Go 语言的依赖管理旨在提供一种有效的方式来复用已有的代码,并对依赖包的版本进行精确控制。通过明确指定依赖包的版本,可以确保项目能够以预期的方式工作,并避免由于版本冲突而引起的问题。这意味着开发人员可以选择特定的依赖版本,确保稳定性和一致性。
-
可重复构建:另一个目标是确保构建过程的可重复性。即使在不同的环境中,也能够重现构建相同的版本代码。Go 语言的依赖管理系统应该能够准确地记录项目所使用的每个依赖项的版本信息,并确保在不同的构建环境中能够获取到相同的依赖版本。这样可以降低构建过程中产生不一致结果的风险,并提高项目的可移植性和可维护性。
在这样的核心驱动力下,Go 语言的依赖管理系统逐渐趋于成熟,为开发人员提供了更好的依赖管理解决方案。
GOPATH
首先是 GOPATH,这是比较早期的依赖包管理方法,作为一个环境变量,其值为 Go 的工作区,也就是我们平时写 Go 代码的地方。GOPATH 目录下有三个文件夹:
- bin:用于存放项目编译的可执行的二进制文件;
- pkg:用于存放项目编译的中间产物(可以是库文件,或者是其他平台的二进制文件),加速编译;
- src:用于存放代码包和项目的源码,项目代码直接依赖 src 下的源码,go get 下载的最新版本的包也安装到 src 目录下。
不可否认,在此种管理方式下还是产生了一些弊端:
-
全局共享的环境:GOPATH 设置为全局环境变量,这意味着所有的项目共享同一个 GOPATH。这可能导致不同项目之间的依赖冲突。当一个项目需要特定版本的依赖包时,由于 GOPATH 的全局性,其他项目也会受到影响,并且不容易进行版本控制。
-
包安装目录不可控:在 GOPATH 下使用
go get命令安装依赖包时,它们将被安装在 GOPATH/src 目录中,而不是项目的 vendor 目录或类似的本地路径。这使得项目的依赖管理不够灵活,因为无法直接控制所安装的包的位置。 -
依赖包的版本控制困难:在 GOPATH 中,对依赖包版本的控制相对困难。虽然可以使用
go get命令来获取特定版本的依赖包,但由于缺乏明确的版本信息,很难确保不同项目对同一依赖包的使用和版本之间的一致性,容易引发依赖冲突的问题。 -
难以管理私有仓库:对于私有仓库或内部项目,需要将其放置在 GOPATH 目录中,这可能会导致私密代码被公开访问的安全隐患。
-
缺乏可重复性:由于 GOPATH 是一个全局设置,并且不统一管理依赖版本,所以很难实现构建过程的可重复性。在不同的构建环境或不同的机器上,可能会出现依赖项版本不一致的情况,从而导致构建结果的不一致。
Go Vendor
由于 GOPATH 有这样那样的缺点,新的依赖管理系统,Go Vendor 应运而生了。
该依赖系统是在项目的根目录下增加 vendor 目录,其中存放了当前项目依赖的副本,在 Vendor 机制下,如果当前项目下有 vendor 目录,会优先使用该目录下的依赖,如果依赖不存在,再去 GOPATH 中寻找。为了确保依赖包的版本一致性,开发者通常会将依赖包的具体版本信息一并复制到 vendor 目录中。这样,项目在构建时可以明确使用 vendor 目录中指定的依赖版本,而不会受全局 GOPATH 中其他版本的影响,就解决了多个项目对同一个依赖包的依赖冲突问题。
另外,Go Vendor 还有以下几个特点:
-
依赖包复制:使用 Vendor 方式时,开发者可以通过
go get或其他方式获取需要的依赖包(此时的 GOPATH 并非没有用了,依赖包还是下载到 GOPATH/src 目录下,如果支持 Go Moudles 会下载到模块缓存目录 GOMODCACHE 下),并手动将其复制到项目的 vendor 目录中。这样,项目就有了本地的依赖副本,不再依赖于全局 GOPATH 或其他地方的依赖包。 -
命令支持:Go 1.6 版本及以后的版本,内置了对 Vendor 目录的原生支持。在使用
go build、go run或其他命令构建项目时,Go 工具链会自动识别 vendor 目录,并根据 vendor 目录中的依赖包来解析并构建项目。 -
版本更新:当需要更新项目的依赖包时,开发者需要手动重新获取需要的依赖包,并将其复制到 vendor 目录中。通过手动控制 vendor 目录,可以更加灵活地管理和更新依赖包。
通过以上分析,不难看出 Go Vendor 的优点和缺点:
-
优点:
- 本地化依赖:Go Vendor 的一个优点是将项目的依赖包本地化。通过将依赖包复制到项目的 Vendor 目录中,可以避免依赖于全局 GOPATH 或其他地方的依赖包。这增加了项目的独立性,使得构建环境更可控。
- 版本控制:使用 Go Vendor 可以更好地控制项目的依赖包版本。通过将依赖包的具体版本信息复制到 Vendor 目录中,可以确保项目在不同环境中使用相同版本的依赖包,提高项目的稳定性和一致性。
- 构建可重复性:Go Vendor 使得项目构建过程更具可重复性。由于项目使用的依赖包复制到了 Vendor 目录中,所以构建过程不再受全局 GOPATH 或其他环境变量的影响。这确保了项目可以在不同的构建环境中以相同的方式进行构建。
- 离线支持:当使用 Go Vendor 时,项目的所有依赖包都被复制到本地 Vendor 目录中。这意味着项目可以在离线环境中构建和运行,不需要依赖于网络连接或远程仓库。
-
缺点:
- 手动更新和管理:使用 Go Vendor 需要手动处理依赖包的更新和管理。当需要更新依赖包时,需要手动重新获取并复制到 Vendor 目录中。这增加了依赖管理的工作量,并且容易出现遗漏或错误。
- 冗余存储:由于每个项目都会将依赖包复制到本地的 Vendor 目录中,可能会导致磁盘空间的浪费。如果多个项目使用相同的依赖包,那么就会在磁盘上有相同的副本存在,增加了存储开销。
- 缺少版本解析:Go Vendor 本身并没有提供对依赖包版本解析的功能。它只是将指定版本的依赖包复制到 Vendor 目录中,但不会解决不同依赖包之间的版本冲突问题。这需要开发者自行管理和解决。
Go Moudles
因为 Go Vendor 还是很难满足用户的需求,因此 Go 语言官方又推出了新的依赖管理系统——Go Moudles,该系统使用 go.mod 文件管理依赖包版本,并通过 go get/go mod 指令工具管理依赖包,实现了定义版本规则和管理项目依赖关系两大终极目标,解决了之前的依赖管理系统存在的诸如无法依赖同一个库的多个版本等问题。自 Go 1.16 版本以后,Go Moudles 已经是默认开启了,如果不创建 go.mod,虽然 Go 代码还是能够运行,但已经有了报错提示。
Go Moudles 依赖管理具有三要素:
- 使用配置文件 go.mod 来描述依赖;
- 使用中心仓库 Proxy 来管理依赖库;
- 使用本地工具 go mod/get 来加载依赖。
依赖配置
- go.mod
先说 go.mod 的创建好了,用 go mod init Moudle名 即可创建一个 go.mod,这个“Moudle名”即为依赖管理的基本单元(命名最好能够标识标识了该依赖可以从什么地方找到,比如前缀为 github.com 的依赖包就表示可以从 github 仓库找到该模块,该依赖包的源代码由 github 托管),如果项目的子包想被单独引用则需要通过单独初始化一个 go.mod 文件进行管理。如果没有用 go get 加载任何依赖包,那么这个 go.mod 文件里就只有依赖基本管理单元和原生库了,如果想引用依赖包,则可以使用 require 关键字对单元依赖进行引用(当你在 go.mod 文件中使用 require 命令指定某个依赖包的版本要求时,Go 工具链会自动解析并下载合适的依赖版本到 GOMODCACHE 目录下,go get 也一样)。每个依赖单元的标示格式为:[Moudle Path] [Version/Pseudo-version]。
- version
GOPATH 和 Go Vendor 都是源码副本依赖,没有版本规则的概念,而 Go Moudles 为了方便而定义了版本规则分为语义化版本和基于 commit 的伪版本。
先说语义化版本,其版本号由三部分组成:
- 主版本号(Major):当进行不兼容的 API 修改或重大功能变化时,增加主版本号。
- 次版本号(Minor):当添加新功能,但保持向后兼容2时,增加次版本号。
- 修订号(Patch):当进行向后兼容的错误修复或细小变化时,增加修订号。
格式为 vX.Y.Z。
除了具体的版本号外,Go Modules 还支持使用各种版本范围来定义对依赖包的要求。以下是一些常见的版本范围表示法:
- 精确版本号:例如,
v1.2.3,表示需要使用该具体版本。 - 带有前缀的版本号:例如,
>=v1.2.3,表示需要使用高于等于该版本的最新版本。 - 版本范围:例如,
>=v1.2.3, <v1.3.0,表示需要使用大于等于 v1.2.3 且小于 v1.3.0 的版本。
除此之外,Go Modules 提供了更复杂的版本选择和排除功能,允许通过语法表达更精确的依赖要求,比如逻辑运算符 || 和 && 等。
而基于 commit 的伪版本,其基础版本的前缀与语义化版本无异,其后跟随的是时间戳(提交 commit 的时间)和哈希校验码的 12 位前缀,每次提交 commit 后 Go 都会默认生成一个伪版本号,该种写法常用于版本号不能确定时。
- indirect 注释
表示 go.mod 对应的当前模块没有直接导入该依赖模块的包,即间接依赖3。
- incompatible 后缀
在 Go Modules 中,"+incompatible" 后缀是指示某个依赖包版本与语义化版本规范不兼容的标记。当一个依赖包的版本号无法匹配语义化版本规范时,Go Modules 会自动在其版本号后面添加 "+incompatible" 后缀。举个例子,当引用的某一个依赖模块的主版本为 vN.X.X(N >= 2),那么在这种情况下,模块路径的最后应该加上 "/vN",这才符合语义化的要求,如果没有加,就会在该项依赖模块引用语句的最后加上 "+incompatible"。
依赖分发
Go Moudles 的依赖分发,实质上就是从哪里下载,如何下载的问题。
- 回源
回源的工作流程大致如下:
- 用户发起请求:用户向服务器发送请求,请求特定的资源(如网页、图片等)。
- 缓存服务器检查缓存:在回源的情况下,缓存服务器首先检查是否有缓存的副本可供使用。如果缓存服务器上已经存在该资源的缓存副本且仍然有效,则可以直接将缓存的副本响应给用户。
- 回源请求:如果缓存服务器没有找到可用的缓存副本或缓存副本已过期,它将发起一个回源请求,从源服务器(例如,原始网站服务器)获取最新的资源副本。
- 源服务器响应:源服务器接收到回源请求后,根据请求内容生成响应,并将响应返回给缓存服务器。
- 缓存副本更新:在收到源服务器的响应后,缓存服务器会将最新的资源副本保存起来,并在下次同样的请求中使用缓存副本直接响应用户。
虽然源服务器资源消失了,缓存服务器仍可能提供该资源的缓存副本,但是缓存是有时效的,过期了缓存就失效了,导致资源的彻底无法访问。
因此,如果 Go 的依赖分发使用回源机制,直接从 GitHub、SVN 等版本管理仓库中下载依赖,还是会存在很多问题的:
- 无法保证构建的稳定性:软件作者可以直接在代码平台上增加/修改/删除软件版本,导致下次构建使用另外版本的依赖,或者找不到依赖版本;
- 无法保证依赖可用性:依赖软件的作者可以直接在代码平台上删除软件,导致依赖不可用;
- 大幅增加第三方代码托管平台的压力:当缓存失效或需要更新时,会有大量请求同时发往源服务器,导致源服务器的负载增加。如果源服务器没有足够的处理能力或者未经过适当的扩展配置,这可能导致源服务器的性能下降或甚至宕机。
所以 Go 设置了 Go Proxy 作为服务站点,缓存源站的软件内容且不会改变(感觉本质还是回源,就是有一个永不过期的缓存服务器)。要想使用 Go Proxy,可以设置 GOPROXY 的值为 Go Proxy 站点的 URL 列表,关键字 direct 代表源站,依赖寻址顺序大致如下:优先从第一个代理站点下载依赖,如果没有则去第二个站点,再没有则接着往下找,都不存在就去源站,下载完毕后缓存到代理站点中。
工具
- go get
go get 的最基本用法就是直接在后面加上远程依赖包的导入路径(后面还可以加上@+关键字下载特定的依赖4),并安装到 GOMODCACHE(一般也是GOPATH/pkg/mod) 下,如果该依赖包有其他的依赖关系,它们也会被自动获取和安装;如果依赖包已经存在于你的安装目录下,则会更新它到最新版本。
go get -u 是 go get 命令的一个选项,用于更新已安装的依赖包或工具到它们的最新版本。通过执行 go get -u 命令,Go 工具会检查指定的依赖包或工具是否有新的版本可用,并对其进行更新。如果依赖包或工具已经是最新版本,该命令将不会触发任何更新。
需要注意的是,go get -u 命令只对当前目录下的 go.mod 直接引用的依赖包或工具进行更新(若没有引用 go get -u 更新的依赖包,则 go get -u 就会把其导入路径下的依赖包的直接依赖和间接依赖全部加载到 go.mod 中)。如果你希望更新所有的依赖包(包括间接依赖),应该使用 go get -u -t ./... 命令。其中 -t 标志表示同时更新测试所需的依赖包。
- go mod
go mod 命令有三个基本选项:
go mod init:初始化,用于创建 go.mod 文件。go mod download:下载 go.mod 文件中列出的依赖模块到本地缓存,并处理依赖关系的版本冲突,并确保所有依赖的正确与一致。go mod tidy:增加需要的依赖,删除不需要的依赖。
建议在提交前执行一下 go mod tidy,减少构建时的无效依赖包的拉取。
Footnotes
-
单体函数是一种方法组织代码的方式,指在编程中独立存在的一个函数,它不依赖于任何类、对象或数据结构。 ↩
-
在语义版本控制中,"向后兼容"指的是对于一个软件库或 API 的修改(增添新功能或修复错误),确保它仍然与之前的版本保持兼容,不会破坏现有的功能和接口。 ↩
-
间接依赖是指在项目中使用的依赖包所引入的其他依赖包。这些间接依赖包并不直接被项目直接引用,而是由直接引用的依赖包作为它们的依赖而被间接引入的。 ↩
-
比如 @update 代表默认,@none 代表删除依赖, @v1.1.2 代表更新为该版本,@23dfdd5 代表更新为特定的commit,@master 代表更新为 master 上的最新 commit。 ↩