你好,我是孔令飞。今天我们更新一期特别放送作为加餐。

在Go项目开发中,依赖包管理是一个非常重要的内容,依赖包处理不好,就会导致编译失败。而且Go的依赖包管理有一定的复杂度,所以,我们有必要系统学习下Go的依赖包管理工具。

这一讲,我会首先介绍下Go依赖包管理工具的历史,并详细介绍下目前官方推荐的依赖包管理方案Go Modules。Go Modules主要包括了 go mod 命令行工具、模块下载机制,以及两个核心文件go.mod和go.sum。另外,Go Modules也提供了一些环境变量,用来控制Go Modules的行为。这一讲,我会分别介绍下这些内容。

在正式开始讲解这些内容之前,我们先来对Go Modules有个基本的了解。

Go Modules简介

Go Modules是Go官方推出的一个Go包管理方案,基于vgo演进而来,具有下面这几个特性:

在Go1.14版本以及之后的版本,Go官方建议在生产环境中使用Go Modules。因此,以后的Go包管理方案会逐渐统一到Go Modules。与Go Modules相关的概念很多,我在这里把它们总结为“6-2-2-1-1”,这一讲后面还会详细介绍每个概念。

Go包管理的历史

在具体讲解Go Modules之前,我们先看一下Go包管理的历史。从Go推出之后,因为没有一个统一的官方方案,所以出现了很多种Go包管理方案,比较混乱,也没有彻底解决Go包管理的一些问题。Go包管理的历史如下图所示:

这张图展示了Go依赖包管理工具经历的几个发展阶段,接下来我会按时间顺序重点介绍下其中的五个阶段。

Go1.5版本前:GOPATH

在Go1.5版本之前,没有版本控制,所有的依赖包都放在GOPATH下。采用这种方式,无法实现包的多版本管理,并且包的位置只能局限在GOPATH目录下。如果A项目和B项目用到了同一个Go包的不同版本,这时候只能给每个项目设置一个GOPATH,将对应版本的包放在各自的GOPATH目录下,切换项目目录时也需要切换GOPATH。这些都增加了开发和实现的复杂度。

Go1.5版本:Vendoring

Go1.5推出了vendor机制,并在Go1.6中默认启用。在这个机制中,每个项目的根目录都可以有一个vendor目录,里面存放了该项目的Go依赖包。在编译Go源码时,Go优先从项目根目录的vendor目录查找依赖;如果没有找到,再去GOPATH下的vendor目录下找;如果还没有找到,就去GOPATH下找。

这种方式解决了多GOPATH的问题,但是随着项目依赖的增多,vendor目录会越来越大,造成整个项目仓库越来越大。在vendor机制下,一个中型项目的vendor目录有几百M的大小一点也不奇怪。

“百花齐放”:多种Go依赖包管理工具出现

这个阶段,社区也出现了很多Go依赖包管理的工具,这里我介绍三个比较有名的。

Govendor、Glide都是在Go支持vendor之后推出的工具,Godep在Go支持vendor之前也可以使用。Go支持vendor之后,Godep也改用了vendor模式。

Go1.9版本:Dep

对于从0构建项目的新用户来说,Glide功能足够,是个不错的选择。不过,Golang 依赖管理工具混乱的局面最终由官方来终结了:Golang官方接纳了由社区组织合作开发的Dep,作为official experiment。在相当长的一段时间里,Dep作为标准,成为了事实上的官方包管理工具。

因为Dep已经成为了official experiment的过去时,现在我们就不必再去深究了,让我们直接去了解谁才是未来的official experiment吧。

Go1.11版本之后:Go Modules

Go1.11版本推出了Go Modules机制,Go Modules基于vgo演变而来,是Golang官方的包管理工具。在Go1.13版本,Go语言将Go Modules设置为默认的Go管理工具;在Go1.14版本,Go语言官方正式推荐在生产环境使用Go Modules,并且鼓励所有用户从其他的依赖管理工具迁移过来。至此,Go终于有了一个稳定的、官方的Go包管理工具。

到这里,我介绍了Go依赖包管理工具的历史,下面再来介绍下Go Modules的使用方法。

包(package)和模块(module)

Go程序被组织到Go包中,Go包是同一目录中一起编译的Go源文件的集合。在一个源文件中定义的函数、类型、变量和常量,对于同一包中的所有其他源文件可见。

模块是存储在文件树中的Go包的集合,并且文件树根目录有go.mod文件。go.mod文件定义了模块的名称及其依赖包,每个依赖包都需要指定导入路径和语义化版本(Semantic Versioning),通过导入路径和语义化版本准确地描述一个依赖。

这里要注意,"module" != "package",模块和包的关系更像是集合和元素的关系,包属于模块,一个模块是零个或者多个包的集合。下面的代码段,引用了一些包:

import (
    // Go 标准包
    "fmt"

    // 第三方包
    "github.com/spf13/pflag"

    // 匿名包
     _ "github.com/jinzhu/gorm/dialects/mysql"

     // 内部包
    "github.com/marmotedu/iam/internal/apiserver"
)

这里的fmtgithub.com/spf13/pflaggithub.com/marmotedu/iam/internal/apiserver都是Go包。Go中有4种类型的包,下面我来分别介绍下。

下面的目录定义了一个模块:

$ ls hello/
go.mod  go.sum  hello.go  hello_test.go  world

hello目录下有一个go.mod文件,说明了这是一个模块,该模块包含了hello包和一个子包world。该目录中也包含了一个go.sum文件,该文件供Go命令在构建时判断依赖包是否合法。这里你先简单了解下,我会在下面讲go.sum文件的时候详细介绍。

Go Modules 命令

Go Modules的管理命令为go modgo mod有很多子命令,你可以通过go help mod来获取所有的命令。下面我来具体介绍下这些命令。

Go Modules开关

如果要使用Go Modules,在Go1.14中仍然需要确保Go Modules特性处在打开状态。你可以通过环境变量GO111MODULE来打开或者关闭。GO111MODULE有3个值,我来分别介绍下。

所以,如果要打开Go Modules,可以设置环境变量export GO111MODULE=on或者export GO111MODULE=auto,建议直接设置export GO111MODULE=on

Go Modules使用语义化的版本号,我们开发的模块在发布版本打tag的时候,要注意遵循语义化的版本要求,不遵循语义化版本规范的版本号都是无法拉取的。

模块下载

在执行 go get 等命令时,会自动下载模块。接下来,我会介绍下go命令是如何下载模块的。主要有三种下载方式:

通过代理来下载模块

默认情况下,Go命令从VCS(Version Control System,版本控制系统)直接下载模块,例如 GitHub、Bitbucket、Bazaar、Mercurial或者SVN。

在Go 1.13版本,引入了一个新的环境变量GOPROXY,用于设置Go模块代理(Go module proxy)。模块代理可以使Go命令直接从代理服务器下载模块。GOPROXY默认值为https://proxy.golang.org,direct,代理服务器可以指定多个,中间用逗号隔开,例如GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct。当下载模块时,会优先从指定的代理服务器上下载。如果下载失败,比如代理服务器不可访问,或者HTTP返回码为404410,Go命令会尝试从下一个代理服务器下载。

direct是一个特殊指示符,用来指示Go回源到模块的源地址(比如GitHub等)去抓取 ,当值列表中上一个Go module proxy返回404或410,Go会自动尝试列表中的下一个,遇见direct时回源,遇见EOF时终止,并抛出类似invalid version: unknown revision...的错误。 如果GOPROXY=off,则Go命令不会尝试从代理服务器下载模块。

引入Go module proxy会带来很多好处,比如:

在实际开发中,我们的很多模块可能需要从私有仓库拉取,通过代理服务器访问会报错,这时候我们需要将这些模块添加到环境变量GONOPROXY中,这些私有模块的哈希值也不会在checksum database中存在,需要将这些模块添加到GONOSUMDB中。一般来说,我建议直接设置GOPRIVATE环境变量,它的值将作为GONOPROXY和GONOSUMDB的默认值。

GONOPROXY、GONOSUMDB和GOPRIVATE都支持通配符,多个域名用逗号隔开,例如*.example.com,github.com

对于国内的Go开发者来说,目前有3个常用的GOPROXY可供选择,分别是官方、七牛和阿里云。

官方的GOPROXY,国内用户可能访问不到,所以我更推荐使用七牛的goproxy.cngoproxy.cn是七牛云推出的非营利性项目,它的目标是为中国和世界上其他地方的Go开发者提供一个免费、可靠、持续在线,且经过 CDN 加速的模块代理。

指定版本号下载

通常,我们通过go get来下载模块,下载命令格式为go get <package[@version]>,如下表所示:

你可以使用go get -u更新package到latest版本,也可以使用go get -u=patch只更新小版本,例如从v1.2.4v1.2.5

按最小版本下载

一个模块往往会依赖许多其他模块,并且不同的模块也可能会依赖同一个模块的不同版本,如下图所示:

在上述依赖中,模块A依赖了模块B和模块C,模块B依赖了模块D,模块C依赖了模块D和模块F,模块D又依赖了模块E。并且,同模块的不同版本还依赖了对应模块的不同版本。

那么Go Modules是如何选择版本的呢?Go Modules 会把每个模块的依赖版本清单都整理出来,最终得到一个构建清单,如下图所示:

上图中,rough list和final list的区别在于重复引用的模块 D(v1.3v1.4),最终清单选用了D的v1.4版本。

这样做的主要原因有两个。第一个是语义化版本的控制。因为模块D的v1.3v1.4版本变更都属于次版本号的变更,而在语义化版本的约束下,v1.4必须要向下兼容v1.3,因此我们要选择高版本的v1.4

第二个是模块导入路径的规范。主版本号不同,模块的导入路径就不一样。所以,如果出现不兼容的情况,主版本号会改变,例如从v1变为v2,模块的导入路径也就改变了,因此不会影响v1版本。

go.mod和go.sum介绍

在Go Modules中,go.mod和go.sum是两个非常重要的文件,下面我就来详细介绍这两个文件。

go.mod文件介绍

go.mod文件是Go Modules的核心文件。下面是一个go.mod文件示例:

module github.com/marmotedu/iam

go 1.14

require (
	github.com/AlekSi/pointer v1.1.0
	github.com/appleboy/gin-jwt/v2 v2.6.3
	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535
	github.com/gin-gonic/gin v1.6.3
	github.com/golangci/golangci-lint v1.30.0 // indirect
	github.com/google/uuid v1.0.0
    github.com/blang/semver v3.5.0+incompatible
    golang.org/x/text v0.3.2
)

replace (
    github.com/gin-gonic/gin => /home/colin/gin
    golang.org/x/text v0.3.2 => github.com/golang/text v0.3.2
)

exclude (
    github.com/google/uuid v1.1.0
)

接下来,我会从go.mod语句、go.mod版本号、go.mod文件修改方法三个方面来介绍go.mod。

  1. go.mod语句

go.mod文件中包含了4个语句,分别是module、require、replace 和 exclude。下面我来介绍下它们的功能。

这里需要注意,虽然我们用$newmodule替换了$module,但是在代码中的导入路径仍然为$module。replace在实际开发中经常用到,下面的场景可能需要用到replace:

有一点要注意,exclude和replace只作用于当前主模块,不影响主模块所依赖的其他模块。

  1. go.mod版本号

go.mod文件中有很多版本号格式,我知道在平时使用中,有很多开发者对此感到困惑。这里,我来详细说明一下。

这里要注意,Go Modules要求模块的版本号格式为v<major>.<minor>.<patch>,如果<major>版本号大于1,它的版本号还要体现在模块名字中,例如模块github.com/blang/semver版本号增长到v3.x.x,则模块名应为github.com/blang/semver/v3

这里再详细介绍下出现// indirect的情况。原则上go.mod中出现的都是直接依赖,但是下面的两种情况只要出现一种,就会在go.mod中添加间接依赖。

  1. go.mod文件修改方法

要修改go.mod文件,我们可以采用下面这三种方法:

在实际使用中,我建议你采用第三种修改方法,和其他两种相比不太容易出错。使用方式如下:

go mod edit -fmt  # go.mod 格式化
go mod edit -require=golang.org/x/text@v0.3.3  # 添加一个依赖
go mod edit -droprequire=golang.org/x/text # require的反向操作,移除一个依赖
go mod edit -replace=github.com/gin-gonic/gin=/home/colin/gin # 替换模块版本
go mod edit -dropreplace=github.com/gin-gonic/gin # replace的反向操作
go mod edit -exclude=golang.org/x/text@v0.3.1 # 排除一个特定的模块版本
go mod edit -dropexclude=golang.org/x/text@v0.3.1 # exclude的反向操作

go.sum文件介绍

Go会根据go.mod文件中记载的依赖包及其版本下载包源码,但是下载的包可能被篡改,缓存在本地的包也可能被篡改。单单一个go.mod文件,不能保证包的一致性。为了解决这个潜在的安全问题,Go Modules引入了go.sum文件。

go.sum文件用来记录每个依赖包的hash值,在构建时,如果本地的依赖包hash值与go.sum文件中记录的不一致,则会拒绝构建。go.sum中记录的依赖包是所有的依赖包,包括间接和直接的依赖包。

这里提示下,为了避免已缓存的模块被更改,$GOPATH/pkg/mod下缓存的包是只读的,不允许修改。

接下来我从go.sum文件内容、go.sum文件生成、校验三个方面来介绍go.sum。

  1. go.sum文件内容

下面是一个go.sum文件的内容:

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> <version>[/go.mod] <algorithm>:<hash>。目前,从Go1.11到Go1.14版本,只有一个算法SHA-256,用h1表示。

正常情况下,每个依赖包会包含两条记录,分别是依赖包所有文件的哈希值和该依赖包go.mod的哈希值,例如:

rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=

但是,如果一个依赖包没有go.mod文件,就只记录依赖包所有文件的哈希值,也就是只有第一条记录。额外记录go.mod的哈希值,主要是为了在计算依赖树时不必下载完整的依赖包版本,只根据go.mod即可计算依赖树。

  1. go.sum文件生成

在Go Modules开启时,如果我们的项目需要引入一个新的包,通常会执行go get命令,例如:

$ go get rsc.io/quote

当执行go get rsc.io/quote命令后,go get命令会先将依赖包下载到$GOPATH/pkg/mod/cache/download,下载的依赖包文件名格式为$version.zip,例如v1.5.2.zip

下载完成之后,go get会对该zip包做哈希运算,并将结果存在$version.ziphash文件中,例如v1.5.2.ziphash。如果在项目根目录下执行go get命令,则go get会同时更新go.mod和go.sum文件。例如,go.mod新增一行require rsc.io/quote v1.5.2,go.sum新增两行:

rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=
  1. 校验

在我们执行构建时,go命令会从本地缓存中查找所有的依赖包,并计算这些依赖包的哈希值,然后与go.sum中记录的哈希值进行对比。如果哈希值不一致,则校验失败,停止构建。

校验失败可能是因为本地指定版本的依赖包被修改过,也可能是go.sum中记录的哈希值是错误的。但是Go命令倾向于相信依赖包被修改过,因为当我们在go get依赖包时,包的哈希值会经过校验和数据库(checksum database)进行校验,校验通过才会被加入到go.sum文件中。也就是说,go.sum文件中记录的哈希值是可信的。

校验和数据库可以通过环境变量GOSUMDB指定,GOSUMDB的值是一个web服务器,默认值是sum.golang.org。该服务可以用来查询依赖包指定版本的哈希值,保证拉取到的模块版本数据没有经过篡改。

如果设置GOSUMDBoff,或者使用go get的时候启用了-insecure参数,Go就不会去对下载的依赖包做安全校验,这存在一定的安全隐患,所以我建议你开启校验和数据库。如果对安全性要求很高,同时又访问不了sum.golang.org,你也可以搭建自己的校验和数据库。

值得注意的是,Go checksum database可以被Go module proxy代理,所以当我们设置了GOPROXY后,通常情况下不用再设置GOSUMDB。还要注意的是,go.sum文件也应该提交到你的 Git 仓库中去。

模块下载流程

上面,我介绍了模块下载的整体流程,还介绍了go.mod和go.sum这两个文件。因为内容比较多,这里用一张图片来做个总结:

最后还想介绍下Go modules的全局缓存。Go modules中,相同版本的模块只会缓存一份,其他所有模块公用。目前,所有模块版本数据都缓存在 $GOPATH/pkg/mod$GOPATH/pkg/sum 下,未来有可能移到 $GOCACHE/mod$GOCACHE/sum 下,我认为这可能发生在 GOPATH 被淘汰后。你可以使用 go clean -modcache 清除所有的缓存。

总结

Go依赖包管理是Go语言中一个重点的功能。在Go1.11版本之前,并没有官方的依赖包管理工具,业界虽然存在多个Go依赖包管理方案,但效果都不理想。直到Go1.11版本,Go才推出了官方的依赖包管理工具,Go Modules。这也是我建议你在进行Go项目开发时选择的依赖包管理工具。

Go Modules提供了 go mod 命令,来管理Go的依赖包。 go mod 有很多子命令,这些子命令可以完成不同的功能。例如,初始化当前目录为一个新模块,添加丢失的模块,移除无用的模块,等等。

在Go Modules中,有两个非常重要的文件:go.mod和go.sum。go.mod文件是Go Modules的核心文件,Go会根据go.mod文件中记载的依赖包及其版本下载包源码。go.sum文件用来记录每个依赖包的hash值,在构建时,如果本地的依赖包hash值与go.sum文件中记录的不一致,就会拒绝构建。

Go在下载依赖包时,可以通过代理来下载,也可以指定版本号下载。如果不指定版本号,Go Modules会根据自定义的规则,选择最小版本来下载。

课后练习

  1. 思考下,如果不提交go.sum,会有什么风险?
  2. 找一个没有使用Go Modules管理依赖包的Go项目,把它的依赖包管理方式切换为Go Modules。

欢迎你在留言区与我交流讨论,我们下一讲见。