Go 包管理解决之道 —— Modules 初试

有一段时间没有用 Go 了,今天去社区一看,发现了 Go Modules 已经面世了。

Go 的包管理是一直是为人诟病之处,从 Go 1.5 引入的 vendor 机制,到准官方工具 dep,目前为止还没一个简便的解决方案。

不过现在 go modules 随着 golang1.11 的发布而和我们见面了,这是官方提倡的新的包管理,乃至项目管理机制,可以不再需要 GOPATH 的存在。

Go Module

欣喜之余,赶紧上手来试一下吧~

Go modules 的前世今生

说起 Go 的包依赖管理,作为 Google 脑残粉的我也是连连叹气。。。

如果说 Java(Maven) 是坨 shit 的话,那 Go 真是连 Java 都不如。

自 2007 年 “三巨头”(Robert Griesemer, Rob Pike, Ken Thompson)提出设计和实现 Go 语言以来,这门语言已经发展和演化了十余年了。

但是自从 Go 语言诞生以来吧,大佬们就认为 go get 已经挺好了,没必要再额外造一个轮子了——包管理器。

也许是大佬们都不需要团队开发吧,但是大佬毕竟是少数,于是,各种各样的社区解决方案就出现了,可谓是百家争鸣。

当初看到这个列表的时候,我不禁感慨:“这么多的解决方案,可真是挑花了朕的眼睛呀。”

Go 官方说:“莫急,这份官方对比拿好不谢。”

Go 在构建设计方面深受 Google 内部开发实践的影响,比如 go get 的设计就深受 Google 内部单一代码仓库 (single monorepo) 和基于主干 (trunk/mainline based) 的开发模型的影响:只获取 Trunk/mainline 代码和版本无感知。

Google Trunk Based And Release Branch Dev Model

Google 内部基于主干的开发模型:

  • 所有开发人员基于主干 trunk/mainline 开发:提交到 trunk 或从 trunk 获取最新的代码(同步到本地 workspace)
  • 版本发布时,建立 Release branch,release branch 实质上就是某一个时刻主干代码的快照
  • 必须同步到 release branch 上的 bug fix 和增强改进代码也通常是先在 trunk 上提交 (commit),然后再 cherry-pick 到 release branch 上

我们知道 go get 获取的代码会放在 $GOPATH/src 下面,而 go build 会在$GOROOT/src 和 \$GOPATH/src 下面按照 import path 去搜索 package,由于 go get 获取的都是各个 package repo 的 trunk/mainline 的代码,因此,Go 1.5 之前的 Go compiler 都是基于目标 Go 程序依赖包的 trunk/mainline 代码去编译的。这样的机制带来的问题是显而易见的,至少包括:

  • 因依赖包的 trunk 的变化,导致不同人获取和编译你的包/程序时得到的结果实质是不同的,即不能实现 reproduceable build
  • 因依赖包的 trunk 的变化,引入不兼容的实现,导致你的包/程序无法通过编译
  • 因依赖包演进而无法通过编译,导致你的包 / 程序无法通过编译

为了实现 reporduceable build,Go 1.5 引入了 Vendor 机制,Go 编译器会优先在 vendor 下搜索依赖的第三方包,这样如果开发者将特定版本的依赖包存放在 vendor 下面并提交到 code repo,那么所有人理论上都会得到同样的编译结果,从而实现 reporduceable build。

在 Go 1.5 发布后的若干年,gopher 们把注意力都集中在如何利用 vendor 解决包依赖问题,从手工添加依赖到 vendor、手工更新依赖,到一众包依赖管理工具的诞生,比如:govendorglide 以及号称准官方工具的 dep,努力地尝试着按照当今主流思路解决着诸如“钻石型依赖” 等难题。

正当 gopher 认为 dep 将“顺理成章”地升级为 go toolchain 一部分的时候,今年年初,Go 核心 Team 的技术 leader,也是 Go Team 最早期成员之一的 Russ Cox个人博客上连续发表了七篇文章,系统阐述了 Go team 解决”包依赖管理”的技术方案: vgo —— modules 的前身。

vgo 的主要思路包括:Semantic Import VersioningMinimal Version Selection引入 Go module 等。这七篇文章的发布引发了 Go 社区激烈地争论,尤其是 MVS(最小版本选择) 与目前主流的依赖版本选择方法的相悖让很多传统 Go 包管理工具的维护者”不满”,尤其是”准官方工具”:dep。vgo 方案的提出也意味着 dep 项目的生命周期即将进入尾声。

5 月份,Russ Cox 的 Proposal “cmd/go: add package version support to Go toolchain” 被 accepted,这周五早些时候 Russ Cox 将 vgo 的代码 merge 到 Go 主干,并将这套机制正式命名为”go modules”。由于 vgo 项目本身就是一个实验原型,merge 到主干后,vgo 这个术语以及 vgo 项目的使命也就就此结束了。后续 Go modules 机制将直接在 Go 主干上继续演化。

Go modules 是 Go team 在解决包依赖管理方面的一次勇敢尝试,无论如何,对 Go 语言来说都是一个好事

Go modules 上手

是时候开始写点代码了。

这里就用 Gin 框架来实现两个 RESTful API 作示例吧,目录结构如下,两个 handler 分别属于 mainpkg/myapi/data 这两个 package

1
2
3
4
5
6
myproj
├── main.go
└── pkg
└── myapi
└── data
└── api.go

进入 myproj 目录,然后使用 go mod 建立 modules

1
2
$ go mod init github.com/zhaoyibo/myproj
go: creating new go.mod: module github.com/zhaoyibo/myproj

此时会自动产生一个 go.mod 文件,打开看到里边内容只有一行

1
module github.com/zhaoyibo/myproj

现在开始写我们的两个 handler

  1. pkg/myapi/data/api.go

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package data

    import (
    "net/http"
    "github.com/gin-gonic/gin"
    )

    // GetDataAPIHealthHandler GET /health-dataapi to expose heathy check result of data API
    func GetDataAPIHealthHandler(c *gin.Context) {
    // do something to check heathy of data API
    c.JSON(http.StatusOK, gin.H{
    "code": 0,
    "message": "Data API is alive",
    })
    }
  2. main.go

    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
    package main

    import (
    dataapi "github.com/zhaoyibo/myproj/pkg/myapi/data"
    "net/http"

    "github.com/gin-gonic/gin"
    )

    func main() {
    router := gin.Default()

    router.GET("/health", GetHealthHandler)
    router.GET("/health-dataapi", dataapi.GetDataAPIHealthHandler)

    s := &http.Server{
    Addr: ":8000",
    Handler: router,
    }
    s.ListenAndServe()
    }

    // GetHealthHandler - GET /health to expose service health
    func GetHealthHandler(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
    "code": 0,
    "message": "Service is alive!",
    })
    }

然后用我们的老朋友 go build 创建可执行文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ go build -o bin/main main.go
go: finding github.com/gin-gonic/gin v1.3.0
go: downloading github.com/gin-gonic/gin v1.3.0
go: finding github.com/gin-contrib/sse latest
go: finding github.com/ugorji/go/codec latest
go: finding github.com/golang/protobuf/proto latest
go: finding github.com/mattn/go-isatty v0.0.4
go: finding gopkg.in/yaml.v2 v2.2.1
go: finding gopkg.in/go-playground/validator.v8 v8.18.2
go: downloading github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7
go: downloading github.com/mattn/go-isatty v0.0.4
go: downloading github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f
go: finding github.com/golang/protobuf v1.2.0
go: downloading gopkg.in/yaml.v2 v2.2.1
go: downloading gopkg.in/go-playground/validator.v8 v8.18.2
go: downloading github.com/golang/protobuf v1.2.0
go: finding gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405

可以看到 go compiler 主动下载了相关 package。
那么这些 package 被下载到了哪里呢,你打开 $GOPATH/pkg/mod 就可以看到了,另外 modules 是允许同 package 多种版本并存的。

1
2
3
4
5
6
7
8
9
10
11
~/go/pkg/mod
├── cache
│   ├── download
│   │   ├── github.com
│   │   └── gopkg.in
│   └── vcs
│   ├── 37981a5904034a62f98c21341f5422b08cc21ccf1bea734e2aafc91119af6c9b
│   ├── 37981a5904034a62f98c21341f5422b08cc21ccf1bea734e2aafc91119af6c9b.info
│   ├── 3cef6ea433a84771f272e076cd77b94bd94828b89b4ccd04fa8622bf2d5d3a3f
│   ├── 3cef6ea433a84771f272e076cd77b94bd94828b89b4ccd04fa8622bf2d5d3a3f.info
... ...

我们看看执行 go build 后 go.mod 文件的内容:

1
2
3
4
5
6
7
8
9
10
11
module github.com/zhaoyibo/myproj

require (
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 // indirect
github.com/gin-gonic/gin v1.3.0
github.com/golang/protobuf v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.4 // indirect
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f // indirect
gopkg.in/go-playground/validator.v8 v8.18.2 // indirect
gopkg.in/yaml.v2 v2.2.1 // indirect
)

我们看到 go modules 分析出了 myproj 的依赖,并将其放入 go.mod 的 require 区域。

go modules 拉取 package 的原则是先拉取最新的 release tag,若无 tag 则拉最新 commit 并以 Pseudo-versions 的形式记录。
因为我们的 module 只直接依赖了 gin,其他的都是非直接依赖的,所以它们后边都被以注释形式标记了 indirect,即传递依赖。
go.mod 文件一旦创建后,它的内容将会被 go toolchain 全面掌控。go toolchain 会在各类命令执行时,比如 go get、go build、go mod 等修改和维护 go.mod 文件。

同时发现目录下多了一个文件 go.sum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f h1:y3Vj7GoDdcBkxFa2RUUFKM25TrBbWVDnjRDI0u975zQ=
github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

写过 node 的人应该会发现, go.mod/go.sum 的关系跟 package.json/package-lock.json 类似,前者定义 dependency root,后者将关系展开。

最后执行 bin/main 可以看到 Gin 很贴心的列出了 handler 所属的 package

1
2
3
4
5
6
7
8
9
10
11
$ ./bin/main
[GIN-debug] [WARNING] Now Gin requires Go 1.6 or later and Go 1.7 will be required soon.

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET /health --> main.GetHealthHandler (3 handlers)
[GIN-debug] GET /health-dataapi --> github.com/zhaoyibo/myproj/pkg/myapi/data.GetDataAPIHealthHandler (3 handlers)

到这里,一个 go modules 就完成了。

一些补充

GO111MODULE

Modules 是作为 experiment feature 加入到不久前正式发布的 Go 1.11 中的。
按照 Go 的惯例,在新的 experiment feature 首次加入时,都会有一个特性开关,go modules 也不例外,GO111MODULE 这个临时的环境变量就是 go modules 特性的 experiment 开关。

  • off: go modules experiment feature 关闭,go compiler 会始终使用 GOPATH mode,即无论要构建的源码目录是否在 GOPATH 路径下,go compiler 都会在传统的 GOPATH 和 vendor 目录 (仅支持在 GOPATH 目录下的 package) 下搜索目标程序依赖的 go package;
  • on: 始终使用 module-aware mode,只根据 go.mod 下载 dependency 而完全忽略 GOPATH 以及 vendor 目录
  • auto: Golang 1.11 预设值,使用 GOPATH mode 还是 module-aware mode,取决于要构建的源码目录所在位置以及是否包含 go.mod 文件。满足任一条件时才使用 module-aware mode:
    • 当前目录位于 GOPATH/src 之外并且包含 go.mod 文件
    • 当前目录位于包含 go.mod 文件的目录下

go mod 命令

1
2
3
4
5
6
7
8
download    download modules to local cache (下载依赖的 modules 到本地 cache)
edit edit go.mod from tools or scripts (编辑 go.mod 文件)
graph print module requirement graph (打印模块依赖图)
init initialize new module in current directory (再当前文件夹下初始化一个新的 module, 创建 go.mod 文件)
tidy add missing and remove unused modules (增加丢失的 modules,去掉未用的 modules)
vendor make vendored copy of dependencies (将依赖复制到 vendor 下)
verify verify dependencies have expected content (校验依赖)
why explain why packages or modules are needed (解释为什么需要依赖)

看这些命令的帮助已经比较容易了解命令的功能。

既有项目

假设你已经有了一个 go 项目, 比如在$GOPATH/github.com/zhaoyibo/myproj下, 你可以使用go mod init github.com/zhaoyibo/myproj在这个文件夹下创建一个空的 go.mod (只有第一行 module github.com/zhaoyibo/myproj)。

然后你可以通过 go get ./...让它查找依赖,并记录在 go.mod 文件中 (你还可以指定 -tags, 这样可以把 tags 的依赖都查找到)。

通过go mod tidy也可以用来为 go.mod 增加丢失的依赖,删除不需要的依赖,但是我不确定它怎么处理tags

执行上面的命令会把 go.mod 的latest版本换成实际的最新的版本,并且会生成一个go.sum记录每个依赖库的版本和哈希值。

replace

在国内访问golang.org/x的各个包都需要梯子,你可以在 go.mod 中使用replace替换成 github 上对应的库。

1
2
3
4
5
replace (
golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac => github.com/golang/crypto v0.0.0-20180820150726-614d502a4dac
golang.org/x/net v0.0.0-20180821023952-922f4815f713 => github.com/golang/net v0.0.0-20180826012351-8a410e7b638d
golang.org/x/text v0.3.0 => github.com/golang/text v0.3.0
)

依赖库中的replace对你的主 go.mod 不起作用,比如github.com/zhaoyibo/myproj的 go.mod 已经增加了replace, 但是你的 go.mod 虽然requirerpcx的库,但是没有设置replace的话, go get还是会访问golang.org/x

所以如果想编译那个项目,就在哪个项目中增加replace

包的版本控制

下面的版本都是合法的:

1
2
3
4
5
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7
gopkg.in/vmihailenco/msgpack.v2 v2.9.1
gopkg.in/yaml.v2 <=v2.2.1
github.com/tatsushid/go-fastping v0.0.0-20160109021039-d7bb493dee3e
latest

版本号遵循如下规律:

1
2
3
4
vX.Y.Z-pre.0.yyyymmddhhmmss-abcdefabcdef
vX.0.0-yyyymmddhhmmss-abcdefabcdef
vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdefabcdef
vX.Y.Z

也就是版本号 + 时间戳 + hash,我们自己指定版本时只需要制定版本号即可,没有版本 tag 的则需要找到对应 commit 的时间和 hash 值。

另外版本号是支持 query 表达式的,其求值算法是“选择最接近于比较目标的版本 (tagged version)”,即上文中的 gopkg.in/yaml.v2 会找不高于 v2.2.1 的最高版本。

对于复杂的包依赖场景,可以参考 Russ Cox 在 “Minimal Version Selection” 一文中给出的形象的算法解释 (注意:这个算法仅是便于人类理解,但是性能低下,真正的实现并非按照这个算法实现)。

go get 升级

  • 运行 go get -u 将会升级到最新的次要版本或者修订版本 (x.y.z,z 是修订版本号, y 是次要版本号)
  • 运行 go get -u=patch 将会升级到最新的修订版本
  • 运行 go get package@version 将会升级到指定的版本号version

go modules 与 vendor

在最初的设计中,Russ Cox 是想彻底废除掉 vendor 的,但在社区的反馈下,vendor 得以保留,这也是为了兼容 Go 1.11 之前的版本。

Go modules 支持通过go mod vendor命令将某个 module 的所有依赖保存一份 copy 到 root module dir 的 vendor 下,然后在构建的使用go build -mod=vendor即可忽略 cache 里的包而只使用 vendor 目录里的版本。

参考