0%

【Go语言设计与实现】Go Module

依赖管理是每一种编程语言无法避免的一个问题,在一个复杂的项目中如何管理其依赖的软件版本,如何避免 「菱形依赖」 ,如何实现 Reproducible Build,如何实现依赖软件版本的平稳升级,都是在大型软件工程中需要正视和解决的问题。从 Java 的 Maven,到 Rust 的 Cargo,再到 NodeJS 的 npm,各个语言都给出了自己的解决方案。对于 Go 语言,从最早的 GOPATH,到 Go 1.5 版本引入的 vendor 机制,以及基于 vendor 实现的 depglidegovendor 关键管理工具,各种问题依然存在。Go Team 在 Go 1.11 版本推出 Go Module,抛弃了 GOPATH 以来的设计,引入版本化语义、支持锁定版本等特性。本文将介绍 Go 语言在依赖管理问题上的来龙去脉,并介绍 Go Module 的设计原理与使用方法。

依赖管理

为什么需要依赖?永远不要重复造轮子,你也不需要重复造轮子,通过复用已有的工作成果可以大大提高工作效率。然而,将已有的工作成果加入到我们自己的项目中作为依赖存在着太多的不确定性:

  • 这个包的 API 会变化
  • 这个包内部行为会变化
  • 这个包自己的依赖会变化
  • 这个包可能已经已经不存在或无法访问
  • 包与包之间的不同依赖相互冲突等等。

不仅如此,随着软件开发规模的逐步增大,涉及到的外部依赖越来越多,手动管理的所有依赖愈发不可能。所以我们需要依赖管理,我们需要有个工具或者规范来描述和定义包与包之间的依赖关系,并自动化的去处理、解析和满足这些依赖。对于软件依赖管理工具,我们提出了下面两个要求:

  • API 稳定性:我们都希望我们依赖的 API 是稳定的,不会因为我们更新了一个小版本就要大规模的重写我们的代码
  • 可重现构建:相同的源码最后能得到同样的二进制或链接库

GOPATH

最初 Go 语言在代码构建设计方面深受Google内部开发实践的影响,这篇文章介绍了 为什么 Google 要采取这种方式

  • 单一代码库:所有的代码包都放在一个单一代码仓库,也即 Single MonoRepo,使用类似命名空间的包路径来区分包
  • 基于主干开发:所有开发人员基于主干 trunk/mainline 开发,版本发布时所有的bug/fix和增强改进代码都是现在主干代码提交,然后从主干代码 cherry-pick出来一个 release branch

对应于这种方式,Go 语言设置了 GOPATH 机制: 所有的源代码都在 $GOPATH/src 目录下,执行 go build 会分别在 $GOPATH/pkg 目录下生成编译中间文件和在 $GOPATH/bin 目录下生成可执行文件:

1
2
3
4
5
6
.
├── bin # 放编译后的可执行文件,可执行文件名字与源代码文件名字一样
├── pkg # 放编译后的包文件,包文件名字与所在目录一样,注意:名字与 package 无关
└── src # 放源代码文件
└── github.com/foo/bar
└── bar.go

在这种构建方式下,go build 会在$GOROOT/src$GOPATH/src下面按照 import path 去搜索package,由于go get 获取的都是各个package repo的 trunk/mainline的代码,因此,Go 1.5 之前的 Go compiler 都是基于目标Go程序依赖包的trunk/mainline 代码去编译的。这种构建方式至少存在以下问题:

  • 对软件包版本无感知go get 拉取软件包时,每次都是get最新的代码,这会导致不同人获取的软件包不同,从而编译的结果不一致
  • 对第三方软件包没有安全审计:获取最新的代码很容易引入代码新的Bug,后续运行时出了Bug需要解决,也无法版本跟踪管理
  • 依赖的完整性无法校验:基于域名的package名称,域名变化或子路径变化,都会导致无法正常下载依赖
  • 依赖关系无法持久化:需要在 go get 的时候找出所有的依赖包,然后一个一个 go get

这还只是简单的软件依赖的问题,更不用说不同项目依赖同一个项目的不同版本,以及菱形依赖的问题了。

Vendor

为了实现 Reproducible Build,Go 1.5 引入了 Vendor 机制,Vendor 机制仍然是基于 GOPATH 机制,只是开发者将其依赖的包放到其工程子目录 vendor 目录下,有点类似于 node.jsnode_modules

  • vendor 机制支持嵌套 vendor,vendor中的第三方包中也可以包含vendor目录
  • 若一个工程中存在着一个包的两个不同版本,可以放在不同的层次的vendor下

在执行 go buildgo run 命令时,会按照以下顺序去查找包:

  1. 如果当前目录存在vendor目录,在当前vendor目录查找依赖包
  2. 如果当前目录不存在vendor目录,则到上一级目录继续查找
  3. 重复步骤1-2,直到到达$GOPATH/src目录,查找vendor目录中是否存在依赖包
  4. 如何没有查找到依赖包,则继续在$GOROOT目录查找
  5. 如果没有查找到依赖包,则继续在$GOPATH/src目录查找

在此基础上,为了让开发者从手动添加依赖、手动更新依赖的困境中摆脱出来,社区开发了一系列的第三方工具,比如 govendorglide 以及号称准官方工具的dep。这些工具会自动分析工程项目的依赖关系,并且批量下载对应版本的代码库,并复制到项目的 vendor 目录下。这些第三方工具大大提高了方便了开发者管理依赖关系,dep 似乎也即将从实验状态转正。

Go Module

然而,所有基于 vendor 机制实现的第三方依赖管理工具都存在着以下问题:

  • vendor 机制是处于 GOPATH 体系中的,只有项目在 GOPATH/src下,vendor才有意义才起作用
  • 同样版本的相同软件包,即使在不同工程中都使用了,也必须在各自的 vendor 目录下单独复制一份,无法实现复用

基于以上的种种问题的考量,社区最终放弃了 vendor 机制,提出了 Go Module 方案,从此 Go 语言项目再也不需要放在 GOPATH 路径下,工程依赖的软件包被统一放到 $GOPATH/pkg/mod 目录下,实现 import 路径与项目路径的解耦。从 Go 1.13 版本开始,Go Module 已经默认开启,

关键概念

Semantic Import Versioning

这就为我们带来了语义导入版本控制(Semantic Import Versioning)。

首先所有的模块都必须遵循语义化版本规则:

其次,当主版本号大于等于 v2 时,这个 Module 的 import path 必须在尾部加上 /vN

  • 在 go.mod 文件中: module github.com/my/mod/v2
  • 在 require 的时候: require github.com/my/mod/v2 v2.0.0
  • 在 import 的时候: import "github.com/my/mod/v2/mypkg"

最后,当主版本号为 v0 或者 v1 时,尾部的 /v0/v1 可以省略。

为什么在 import 路径可以忽略 v0 和 v1 的主版本号呢?

  • 导入路径中忽略了 v0 版本的原因是:根据语义化版本规范,v0的这些版本完全没有兼容性保证,可以随意的引入破坏性变更,所以不需要显式的写出来
  • 导入路径中忽略 v1 版本的原因是:考虑到许多开发人员创建一旦到达 v1 版本便永不改变的软件包,这是官方所鼓励的,不认为所有这些开发人员在无意发布 v2 版时都应被迫拥有明确的 v1 版本尾缀,这将导致 v1 版本变成“噪音”且无意义。

Q:使用 Go modules 时可以同时依赖同一个模块的不同的两个或者多个小版本(修订版本号不同)吗?

A:不可以的,Go modules 只可以同时依赖一个模块的不同的两个或者多个大版本(主版本号不同)。比如可以同时依赖 example.com/foobar@v1.2.3example.com/foobar/v2@v2.3.4,因为他们的模块路径(module path)不同,Go modules 规定主版本号不是 v0 或者 v1 时,那么主版本号必须显式地出现在模块路径的尾部。但是,同时依赖两个或者多个小版本是不支持的。比如如果模块 A 同时直接依赖了模块 B 和模块 C,且模块 A 直接依赖的是模块 C 的 v1.0.0 版本,然后模块 B 直接依赖的是模块 C 的 v1.0.1 版本,那么最终 Go modules 会为模块 A 选用模块 C 的 v1.0.1 版本而不是模块 A 的 go.mod 文件中指明的 v1.0.0 版本。

这是因为 Go modules 认为只要主版本号不变,那么剩下的都可以直接升级采用最新的。但是如果采用了最新的结果导致项目 Break 掉了,那么 Go modules 就会 Fallback 到上一个老的版本,比如在前面的例子中就会 Fallback 到 v1.0.0 版本。

Minimal Version Selection

现在我们已经可以定义出一个模块了,但是一个模块具体构建的时候到底选择是哪个版本呢?这就涉及到 Go Module 使用的最小版本选择(Minimal Version Selection)算法。它的工作方式是这样的:我们为每个模块指定的依赖都是可用于构建的最低版本,最后实际选择的版本是所有出现过的最低版本中的最大值

我们现在有这样的一个依赖关系,A 会依赖 B,C,而 B,C 会去依赖 D,D 又会依赖 E。

那么我们从 A 开始把每个模块依赖的版本都找出来,这样我们会首先得到一个粗略的清单。然后相同的模块我们总是取最大的版本,这样就能得到最终的依赖列表。

为什么可以这样呢?

  • 导入兼容性规则 规定了相同的导入路径,新包必须向后兼容旧包,因此只要 D 还是 v1 版本,不管是选择 v1.3 还是 v1.4 都是可以的,不会有破坏性的变更。
  • 语义导入版本控制 规定了不同的大版本需要使用不同的导入路径,因此假设 D 升级到了 v2 版本,那就应当选择 D v1.4D v2.0 这两个包了。

为什么要这样做呢?

为了可重现构建,为了降低复杂度。

大多数包管理工具,包括 depcargopip 等,采用的都是总是选择允许的最新版本(use the newest allowed version)策略。这会带来两个问题:

  • 第一,允许的最新版本可能会随着外部事件而发生变化,比如说在构建的时候,依赖的一个库刚好发布了一个新版本,这会导致可重现构建失效;
  • 第二,开发者为了避免依赖在构建期间发生变化,他必须显式的告诉依赖管理工具我不要哪些版本,比如:>= 0.3, <= 0.4。这会导致依赖管理工具花费大量的时间去计算可用的版本,而最终的结果总是让人感到沮丧,A 依赖需要 Z >= 0.5 而 B 依赖需要 Z <= 0.4,关于这一点 Russ Cox 在 Version SAT 给出了更加规范的论述,感兴趣的同学不妨一观。

与总是选择允许的最新版本相反,Go Module 默认采用的是总是使用允许的最旧的版本。我们在 go.mod 中描述的 vX.Y.Z 实际上是在告诉编译器:“Hey,我最少需要 vX.Y.Z 才能被 Build 出来”,编译器听完了所有模块的话之后按照刚才描述的流程就能选择出允许的最旧的那个版本。

使用方法

初始化项目

你可以在 $GOPATH/src 之外的任何地方创建一个新的目录存放工程代码,在工程目录下初始化一个新的模块

1
2
$ go mod init github.com/SimpCosm/godemo/gomod
go: creating new go.mod: module github.com/SimpCosm/godemo/gomod

成功之后你会发现目录下会生成一个 go.mod 文件。首行为当前的模块名称,接下来是 go 的使用版本。这两行和 npm package.jsonnameengine 字段的功能很类似。

1
2
3
4
$ cat go.mod
module github.com/SimpCosm/godemo/gomod

go 1.14

创建一个文件 main.go 然后加入以下代码,这里直接 import 了 Go 维护者 Russ Cox 写一个简单的库,

1
2
3
4
5
6
7
8
9
10
package main

import (
"fmt"
"rsc.io/quote"
)

func main() {
fmt.Println(quote.Hello())
}

编译并且运行

1
2
3
4
5
6
7
$ go build -o hello
go: finding module for package rsc.io/quote
go: downloading rsc.io/quote v1.5.2
go: found rsc.io/quote in rsc.io/quote v1.5.2
go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
$ ./hello
Hello, world.

go build 之后,会在 go.mod 引入所需要的依赖包。之后再来看看 go.mod 文件的情况,require 就是本项目所需要的所有依赖包 并且在每个依赖包的后面已经表明了版本号。

1
2
3
4
5
module github.com/SimpCosm/repo

go 1.14

require rsc.io/quote v1.5.2

go.mod 文件解析

为了更进一步的讲解,我们摘取了 Prometheus 项目的 go.mod 文件的片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module github.com/prometheus/prometheus

go 1.14

require (
github.com/Azure/azure-sdk-for-go v49.2.0+incompatible
github.com/Azure/go-autorest/autorest/to v0.3.0 // indirect
github.com/docker/docker v20.10.1+incompatible
github.com/prometheus/client_golang v1.9.0
github.com/dgryski/go-sip13 v0.0.0-20200911182023-62edffca9245
// ...
)

exclude (
// Exclude pre-go-mod kubernetes tags, as they are older
// than v0.x releases but are picked when we update the dependencies.
k8s.io/client-go v1.4.0
k8s.io/client-go v1.4.0+incompatible
k8s.io/client-go v10.0.0+incompatible
k8s.io/client-go v11.0.0+incompatible
// ...
)

replace (
k8s.io/klog => github.com/simonpasquier/klog-gokit v0.3.0
k8s.io/klog/v2 => github.com/simonpasquier/klog-gokit/v2 v2.0.1
)
  • module:用于定义当前项目的模块路径
  • go:用于标识当前模块的 Go 语言版本,值为初始化模块时的版本,目前来看还只是个标识作用
  • require:用于设置一个特定的模块版本
  • exclude:用于从使用中排除一个特定的模块版本
  • replace:用于将一个模块版本替换为另外一个模块版本。因为有些私有模块无法通过 go get 获取到,可以通过 replace 指令用本地模块替换。

require 指令中,你会发现 github.com/Azure/go-autorest/autorest/to v0.3.0 的后面会有一个 indirect 标识,indirect 标识表示该模块为间接依赖,也就是在当前应用程序中的 import 语句中,并没有发现这个模块的明确引用,有可能是你先手动 go get 拉取下来的,也有可能是你所依赖的模块所依赖的,情况有好几种。

另外,你也可以看到有一个 +incompatible后缀,这是因为引入的这个 v2+ package 还没有采用 go modules,并且其有一个有效的 semver tag,这时候会添加一个 +incompatible后缀。

Can a module consume a v2+ package that has not opted into modules? What does ‘+incompatible’ mean?

Yes, a module can import a v2+ package that has not opted into modules, and if the imported v2+ package has a valid semver tag, it will be recorded with a +incompatible suffix.

我们还看到,对于 github.com/dgryski/go-sip13 这个项目,其版本号是 v0.0.0 并且后面跟随着一串时间和哈希值。这是因为这个模块还没有发布过 tag,因此它默认取的是主分支最新一次 commit 的 commit 时间和 commithash,也就是 20200911182023-62edffca9245。需要注意的是,所拉取版本的 commit 时间是以UTC时区为准,而并非本地时区。

exclude 和 replace 这两个动词只作用于当前主模块,也就是当前项目,它所依赖的那些其他模块版本中如果出现了你待替换的那个模块版本的话,Go modules 还是会为你依赖的那个模块版本去拉取你的这个待替换的模块版本。

举个例子,比如项目 A 直接依赖了模块 B 和模块 C,然后模块 B 也直接依赖了模块 C,那么你在项目 A 中的 go.mod 文件里的 replace c=>~/some/path/c 是只会影响项目 A 里写的代码中,而模块 B 所用到的还是你 replace 之前的那个 c,并不是你替换成的 ~/some/path/c 这个。

go.sum 文件解析

与此同时,工程目录下多了一个 go.sum文件,有点类似于 npm package-lock.json,其详细罗列了当前项目直接或间接依赖的所有模块版本,并写明了那些模块版本的 SHA-256 哈希值以备 Go 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。

1
2
3
4
5
6
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

可以看到对于每个模块,在 go.sum 中会有两行信息:

  • 第一行信息是对于该 module 的该版本下整个 module 下所有文件得到的 hash
  • 第二行信息,也就是加上了 /go.mod 的 hash,是对该 module 的该版本的 go.mod 文件得到的 hash
  • 每个hash前面有一个 h1 的前缀,这个是以 h<N>:的模式,目前仅仅定义了 h1,使用的是 SHA-256 算法
1
2
3
# format: <module> <version>[/go.mod] <hash>
rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=

Q:在 go.sum 文件中的一个模块版本的 Hash 校验数据什么情况下会成对出现,什么情况下只会存在一行?

A:通常情况下,在 go.sum 文件中的一个模块版本的 Hash 校验数据会有两行,前一行是该模块的 ZIP 文件的 Hash 校验数据,后一行是该模块的 go.mod 文件的 Hash 校验数据。但是也有些情况下只会出现一行该模块的 go.mod 文件的 Hash 校验数据,而不包含该模块的 ZIP 文件本身的 Hash 校验数据,这个情况发生在 Go modules 判定为你当前这个项目完全用不到该模块,根本也不会下载该模块的 ZIP 文件,所以就没必要对其作出 Hash 校验保证,只需要对该模块的 go.mod 文件作出 Hash 校验保证即可,因为 go.mod 文件是用得着的,在深入挖取项目依赖的时候要用。

查看全局缓存

gomod 不会在 $GOPATH/src 目录下保存 rsc.io/quote 包的源码,而是包源码和链接库保存在 $GOPATH/pkg/mod 目录下。在 Go 1.15 版本后,增加了环境变量 GOMODCACHE 可以用来配置 module cache 存放的位置,其默认值为 GOPATH[0]/pkg/mod

1
2
3
4
5
6
7
mod
├── cache
├── github.com
├── golang.org
├── google.golang.org
├── gopkg.in
...

需要注意的是同一个模块版本的数据只缓存一份,所有其它模块共享使用。如果你希望清理所有已缓存的模块版本数据,可以执行 go clean -modcache 命令。

除了 go run 命令以外,go buildgo test 等命令也能自动下载相关依赖包。

依赖升级

首先通过 go list -u -m all 命令可以查看所有直接和非直接依赖可用的升级补丁:

1
2
3
4
5
$ go list -u -m all # 查看所有直接和非直接依赖可用的升级补丁
github.com/SimpCosm/godemo/gomod
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c [v0.3.5]
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.0 [v1.99.99]

在 go module 中,go get 的作用是负责为当前正在开发的 module 来解析和添加依赖,然后构建和安装它们。对于每一个依赖包,go get 必须决定拉取哪一个版本。如果执行 go get命令指定了版本,则按照给定的版本拉取依赖包,如下:

命令 作用
go get golang.org/x/text@latest 拉取最新的版本,若存在tag,则优先使用
go get golang.org/x/text@master 拉取 master 分支的最新 commit
go get golang.org/x/text@v0.3.2 拉取 tag 为 v0.3.2 的 commit
go get golang.org/x/text@342b2e 拉取 hash 为 342b231 的 commit,最终会被转换为 v0.3.2

如果执行 go get没有指定版本,则按照下面逻辑确定版本:

  • 默认情况下,go get 选择的是最新的 tagged release version ,比如 v0.4.5 或者 v1.2.3
  • 如果没有 tagged release version ,则选择最新的 tagged pre-release version,比如 v0.0.1-pre1
  • 如果连 tagged pre-release version 也没有,则选择最新的已知 commit。

尽管 go get命令默认拉取最新的模块,但是它并不会使用该模块的依赖的最新版本,相反而是使用该模块请求依赖的版本。举个例子,模块 A 的最新版本 require 模块 B 的 v1.2.3,同时 B 的 v1.2.4 和 v1.3.1 版本同时存在。那么,执行 go get A 将会使用 A 的最新版本,但是使用 B v1.2.3 版本,正如在 A 的 go.mod 中请求的一样。

go get -u 则在默认拉取模块 A 的最新版本情况下,也会使用模块 A 依赖的模块最新版本(同一个 major 版本内的最新版本)。在上面的例子中,go get -u A 将会使用 A 的最新版本,但是使用 B v1.3.1 版本。如果模块 B 依赖于模块 C,但是模块 A 不依赖于模块C,则模块C不会被更新。

go get -u=patch则是默认选择模块 A 的最新 patch版本,也会使用模块 A 依赖的模块最新的 patch版本。在上面的例子中,go get -u=patch A@latest 将会使用 A 的最新版本,但是使用 B v1.2.4 版本。而命令 go get -u=patch A 则使用模块 A 的一个 patch release 版本。

升级次级或补丁版本号:

1
go get -u rsc.io/quote

仅升级补丁版本号:

1
go get -u=patch rscio/quote

升降级版本号,可以使用比较运算符控制:

1
go get foo@'<v1.6.2'

总结一下:

命令 作用
go get 拉取依赖,会进行指定性拉取,并不会更新所依赖的其它模块。
go get -u 只会更新主要模块,忽略了单元测试
go get -u -t 只会更新主要模块,考虑了单元测试
go get -u ./… 递归更新所有子目录的所有模块,没考虑了单元测试
go get -u -t ./… 递归更新所有子目录的所有模块,但是考虑了单元测试
go get -u all 更新所有模块,推荐使用

移除依赖

当前代码中不需要了某些包,删除相关代码片段后并没有在 go.mod 文件中自动移出。

运行下面命令可以移出所有代码中不需要的包:

1
go mod tidy

如果仅仅修改 go.mod 配置文件的内容,那么可以运行 go mod edit --droprequire=path,比如要移出 golang.org/x/crypto

1
go mod edit --droprequire=golang.org/x/crypto

查看依赖包

可以直接查看 go.mod 文件,或者使用命令行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ go list -m all
github.com/SimpCosm/godemo/gomod
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.0
$ go list -m -json all # json 格式输出
{
"Path": "github.com/SimpCosm/godemo/gomod",
"Main": true,
"Dir": "/Users/houmin/project/cosmos/godemo/gomod",
"GoMod": "/Users/houmin/project/cosmos/godemo/gomod/go.mod",
"GoVersion": "1.14"
}
{
"Path": "golang.org/x/text",
"Version": "v0.0.0-20170915032832-14c0d48ead0c",
"Time": "2017-09-15T03:28:32Z",
"Indirect": true,
"Dir": "/Users/houmin/go/pkg/mod/golang.org/x/text@v0.0.0-20170915032832-14c0d48ead0c",
"GoMod": "/Users/houmin/go/pkg/mod/cache/download/golang.org/x/text/@v/v0.0.0-20170915032832-14c0d48ead0c.mod"
}
{
"Path": "rsc.io/quote",
"Version": "v1.5.2",
"Time": "2018-02-14T15:44:20Z",
"Dir": "/Users/houmin/go/pkg/mod/rsc.io/quote@v1.5.2",
"GoMod": "/Users/houmin/go/pkg/mod/cache/download/rsc.io/quote/@v/v1.5.2.mod"
}
{
"Path": "rsc.io/sampler",
"Version": "v1.3.0",
"Time": "2018-02-13T19:05:03Z",
"Indirect": true,
"Dir": "/Users/houmin/go/pkg/mod/rsc.io/sampler@v1.3.0",
"GoMod": "/Users/houmin/go/pkg/mod/cache/download/rsc.io/sampler/@v/v1.3.0.mod"
}

模块配置文本格式化

由于可手动修改 go.mod 文件,所以可能此文件并没有被格式化,使用下面命令进行文本格式化。

1
go mod edit -fmt

从老项目迁移

从很多第三方的包管理工具迁移到 gomod 特别简单,直接运行 go mod init 即可。如果没有使用任何第三方包管理工具,除了运行 go mod init 初始化以外,还要使用 go get ./... 下载安装所有依赖包,并更新 go.modgo.sum 文件。

默认情况下,go get 命令使用 @latest 版本控制符对所有依赖进行下载,如果想要更改某一个包的版本,可以使用 go mod edit --require 命令,比如要更新 rsc.io/quote 到 v3.1.0 版本。

1
go mod edit --require=rsc.io/quote@v3.1.0

依赖拉取

Go Module 方案通过 Semantic Import VersioningMinimal Version Selection,解决了Go语言项目中的依赖管理问题。但是,即使我们确定了我们依赖的版本,我们如何保证这个版本能够被可靠的拉取到,并且不会被篡改呢?这就引入 Go Proxy 和 Go Checksum Database,Go module proxy life of a query 非常形象地介绍了 Go Proxy 和 Go Checksum Database 出现的原因和他们是如何解决问题的。这里总结两个主要的原因:

  • 如果你依赖的项目作者不再维护自己的代码了,比如说他将原来代码放在 Github 上,有一天突然删除了自己的repo,那么这时你再也不能获得你的依赖代码,你也就无法实现 Reproducible Build
  • 如果你获取到的代码实际上已经被恶意篡改过,那么你构建的程序也就发生了变化。如何能够确认你下载的代码就是你想要的代码呢?

Go Module Proxy

Go Proxy Module 有以下作用:

  • 集中式模块版本管理
  • 加快模块版本的拉取速度
  • 加快项目的构建速度
  • 使 Go 脱离对于 VCS 的依赖
  • 防止项目由于依赖某个模块而被其作者删除而 break 掉
  • 淡化 Vendor 概念

Go module proxy 是一个能够响应 GET 请求的 Web 服务器,通过 go help goproxy 可以获取更多信息,其请求包括

  • GET $GOPROXY/<module>/@v/list :返回给定 module 的已知 version 的 list,每行一个 version
  • GET $GOPROXY/<module>/@v/<version>.info:返回一个 JSON 格式的描述该 module 的元数据
  • GET $GOPROXY/<module>/@v/<version>.mod:返回某个 module 指定 version 的 go.mod 文件
  • GET $GOPROXY/<module>/@v/<version>.zip:返回某个 module 指定 version 的 源代码压缩包
  • GET $GOPROXY/<module>/@latest:返回该 module 最新版本的JSON 格式的描述该 module 的元数据

在当前,module 的元数据格式为,以后可能会被扩展:

1
2
3
4
type Info struct {
Version string // version string
Time time.Time // commit time
}

Go 提供了 GOPROXY 环境变量用于配置 Go Module Proxy 地址,其默认值是:https://proxy.golang.org,direct。在国内,proxy.golang.org 无法访问,需要设置国内的 Go 模块代理,执行如下命令:

1
$ go env -w GOPROXY=https://goproxy.cn,direct
  • GOPROXY的值是一个以英文逗号 , 分割的 Go 模块代理列表,允许设置多个模块代理,假设你不想使用,也可以将其设置为 off ,这将会禁止 Go 在后续操作中使用任何 Go 模块代理
  • direct 是一个特殊指示符,用于指示 Go 回源到模块版本的源地址去抓取(比如 GitHub 等),场景如下:当值列表中上一个 Go 模块代理返回 404 或 410 错误时,Go 自动尝试列表中的下一个,遇见 direct 时回源,也就是回到源地址去抓取

Go Checksum Database

Go Checksum Database 用于在拉取模块版本时,保证拉取到的模块版本数据未经过篡改,若发现不一致,也就是可能存在篡改,将会立即中止。

Go 提供了 GOSUMDB 环境变量用于配置 Go Checksum Database 的地址,其默认值为:sum.golang.org。在国内这个地址也是无法访问的,但是 GOSUMDB 可以被 Go 模块代理所代理。先前我们所设置的模块代理 goproxy.cn 就能支持代理 sum.golang.org,所以在设置 GOPROXY 后,你可以不需要过度关心这个问题。

若对 GOSUMDB 的值有自定义需求,其支持如下格式:

  • 格式 1:<SUMDB_NAME>+<PUBLIC_KEY>
  • 格式 2:<SUMDB_NAME>+<PUBLIC_KEY> <SUMDB_URL>

也可以将其设置为 off,也就是禁止 Go 在后续操作中校验模块版本。

拉取私有模块

常见的公共 Go Module Proxy,比如 proxy.golang.orggoproxy.cn 都是无权访问任何人的私有模块的。即使不使用任何 Go Module Proxy,也就是将 GOPROXY 设置为 direct,默认情况下 Go 也是无法抓取私有模块的,参见 这里

解决的方法是,想办法修改 VCS 的拉取行为,比如使用 git 的时候,在 $HOME/.gitconfig 文件中追加:

1
2
[url "ssh://git@github.com/"]
insteadOf = https://github.com/

给 git 添加了这个配置之后,git 将使用 ssh 协议拉取代码,而不是 HTTPS 协议拉取代码。

与此同时,需要在 GOPROXY 设置一个 fallback 选项,也就是说访问前面的 proxy 地址失败后,使用 direct 去代码原地址拉取

1
go env -w GOPROXY=https://goproxy.cn,direct

除此之外,还可以通过 GONOPROXY 或者 GOPRIVATE 环境变量来告诉 Go 在拉取哪些模块时忽略 Go Module Proxy 。

参考资料