Go module是从Go 1.11版本才引入的新功能。其目标是取代旧的的基于GOPATH方法来指定在工程中使用哪些源文件或导入包。本文首先分析Go引入module之前管理依赖的优缺点,然后针对这些缺点,看module是如何解决的。
在Go1.11之前,如果想要编写Go代码以及引入第三方包,则需要将源代码写在GOPATH/src目录下。即开发者只能将研发的项目放到GOPATH目录下。同时,将引入的第三方包会下载到GOPATH/pkg目录下。我们先来看下在这种包管理模式下,使用go get是如何安装依赖包的,然后再分析这种包管理的不足。
我们以在项目中引入github.com/go-redis/redis包为例。在项目中使用import导入该包:
import "github.com/go-redis/redis"
然后我们需要使用go get命令将该包下载下来:
go get github.com/go-redis/redis
运行go get命令后,Go会访问 https://github.com/go-redis/redis 并下载该包。一旦下载完成,该包就会被保存到 $GOPATH/pkg/github.com/go-redis/redis 目录下。
那么从执行go get命令到包被保存到对应的目录期间,go get都经历了哪些过程呢?
首先,Go会将包拼接成https协议的URL地址。这里是 https://github.com/go-redis/redis 。Go的第三方包是存储在像GIT或SVN这样的在线版本控制管理系统上的。Go目前支持的在线版本管理类型如下:
Bazaar .bzr
Fossil .fossil
Git .git
Mercurial .hg
Subversion .svn
所以,在示例中,Go首先会解析github.com/go-redis/redis.git (模板格式:github.com/go-redis/redis{.type})。
其次,根据支持的协议依次尝试clone该包。若该在线版本管理系统支持多种协议,那么Go会依次尝试。例如,Git支持 https:// 和 git+ssh:// 协议 , 那么Go会依次使用对应的协议进行解析该包。如果Go成功解析了对应的URL地址,那么该包将会被clone并保存到$GOPATH/pkg目录下。
最后,若版本管理系统不是Go所支持的,则尝试查找META信息。在这种场景下,Go也会试图使用https或http协议拼装成的URL地址去解析。并从返回的HTML代码中查找META信息:
<meta name="go-import" content="import-prefix type repo-root">
根据读取到的meta信息,Go就可以从 https://github.com/go-redis/redis.git 中克隆该项目代码,并将其保存到本地的$GOPATH/src目录下的github.com/go-redis/redis中。
到此,我们已经了解了传统的包管理的工作方式了。下面我们来看看这种管理方式有哪些缺点。
首先,所有的项目都必须在GOPATH/src指向的目录下,或者必须更改GOPATH环境变量所指向的目录。
我们以两个项目A、B来举例说明。假设当前的GOPATH=/usr/local/goworkspace/。如果保持GOPATH不变的话,那么A、B两个项目的源代码都必须要放到GOPATH的目录下,即/usr/local/goworkspace/src目录下。同时,A和B项目引入的第三方包都会在GOPATH/pkg目录下。这样两个项目其实就是混合在一起。
如果不想混合在一起怎么办呢?那就只能更改GOPATH的目录。假设我们现在在研发A项目,并将其工作目录放在/usr/local/goworkspace/a目录下,GOPATH=/usr/local/goworkspace/a。但是在开发B项目时,更改GOPATH的指向,例如我们这里使用/usr/local/goworkspace/b目录下。这样两个项目的源代码以及依赖的第三方包就在各自项目下了。但同时如果想继续修改A项目的代码时,就需要再将GOPATH目录更改到指向A项目的目录中,即GOPATH=/usr/local/goworkspace/src目录。
其次,对于依赖的同一个包只能从master分支上导入最新的提交,且不能导入包的指定的版本。
假设我们有一个第三方包redis,项目A首次引入该包时,使用go get命令从代码库的master分支下载当前最新的代码,并将该包保存在本地的GOPATH/pkg目录下。之后redis包有了新的提交,但同时也引入了一个bug。如果项目A升级或重新安装该包时,使用go get命令并没有指定特定版本的参数,还是从该包的代码库的master分支中下载该包,也就造成了向后不兼容。另外,升级或重新安装的包也会被安装到GOPATH/pkg下的相同目录,因为没有版本的管理,所以会覆盖之前。
好了,以上就是在传统的包管理方式中的两大主要不足之处。那么针对这些不足,我们来看看Go的module是如何解决的。
一个module就是一个包含多个package的目录,即一个package的集合。 其要实现的目标如下:
一个module也是可以像package一样共享的。因此,module也必须是一个git仓库或其他Go可支持的代码控制系统。因此,Go的建议是:
第一,我们在GOPATH之外的任何位置创建一个目录。这里我们使用encodex,该encodex包含一些对字符串的编码功能函数,例如md5,sha1等。如下图:
根据上面所讨论的,一个Go module应该是一个版本控制系统上的代码仓库。所以我们在github上创建一个git的代码仓库,如下图:
第二,在本地的目录下执行go mod init 命令来初始化Go module。
go mod init github.com/goxuetang/encodex
该命令会在encodex的根目录下创建go.mod文件,go.mod文件会包含我们定义的module的导入路径和依赖的包及对应的版本。如下所示:
由上图可知,在生成的go.mod文件中显示了该module可被导入的路径以及Go的版本。因为目前还没有导入任何其他依赖包,所以没有显示导入包的信息。好,现在我们把该目录同时提交到git上。
git init
git remote add origin https://github.com/goxuetang/encodex.git
第三,我们在encodex的hash包中添加如下代码:
好了,到这里我们就可以发布我们的包。但在发布之前我们先来看下语义化的版本。
语义化的版本是一种通用的版本格式。其格式如下:
vMajor.Minor.Patch
该格式以固定的字母 v 开头,Major代表主版本,Minor代表次版本,Patch代表不定版本。只有在版本不兼容之前的版本时,才会改动主版本Major。当做了向下兼容的功能时会改动Minor。当对次版本Minor做了问题修正时会改动Patch。详细的语义化版本可参考语义化版本官方文档进一步阅读。
Go语言指出,当一个module的新老版本不兼容时,新版本应该发布一个新的主版本。同时,Go会认为这是一个独立的module,和之前的老版本没有任何关系。
Git的分支本质上是一个历史提交的记录。对于每一次提交都有一个唯一的标识对应。对于每一个唯一标识,我们还可以给一个语义化的版本别名,也就是我们所说的tag。
最后,我们可以给我们的module打一个tag了。
因为是第一个版本,所以我们使用版本v1.0.0,如下:
git tag v1.0.0
git push --tags
到此,我们的module已经发布了,并由一个v1.0.0的tag版本。接下来,我们看看在项目中如何使用该module
我们在新建的main module中创建了一个main.go文件,在该module下要想使用encodex模块下的包,则需要引入和安装两个步骤。在文件中使用import语句引入包,如下图:
第一步,使用import引入模块下具体的包。因为在encodex的module中,我们设置的引入路径是github.com/goxuetang/encodex, 即go.mod文件的第一行。hash包是encodex模块下的一个包。所以我们引入的完整路径是:
import "github.com/goxuetang/encodex/hash"
第二步,使用go get命令安装引入的包。使用go get命令时,可以指定包的具体版本,如下:
go get github.com/goxuetang/encodex/hash@v1.0.0
也可以不指定版本,这时go get命令会自动的查找最近的版本,如下:
go get github.com/goxuetang/encodex/hash
go get:added github.com/goxuetang/encodex v1.0.0
如图所示:
同时,go get会将引入的包加在go.mod文件中。require中不仅有包名,还有对应的版本号。如下图所示: 好,我们现在来看另外一个问题,下载下来的包存在哪里了。
当go get将包下载下来后,会将其存储到GOPATH/pkg/mod目录下。通过go env可以查看GOPATH环境变量的具体指向目录,我的环境下的GOPATH=/Users/YuYang/go,如下是上节中引入的encodex模块。如下图所示:
我们发现encodex模块的目录是带版本号的,这也是Go module能够支持多版本的原因。
在上面我们有讲到module使用的是vX.X.X格式的语义化版本。那么在日常的研发中又是如何对这三个版本号进行升级的呢。
随着时间的推移,发布的包肯定会有新的提交,比如修复了一个bug,则patch版本号会升级,添加了一个新功能,则小版本号会升级。做了一项大的改动,和前一个版本不兼容了,那么主版本号就会升级。接下来我们看看在已引入的包后,如何升级对应的版本。
如果我们只想升级补丁版本patch,那么可以使用如下命令:
go get -u=patch
如果想更新同一个大版本下的小版本,那么可以使用如下命令:
go get -u
该命令是如果小版本有更新,则升级小版本。如果只有补丁版本有更新,则会升级补丁版本。如果想升级到指定的版本,则使用指定版本的命令:
go get module@version
例如,要将encodex模块升级到v1.1.3版本,则使用如下命令:
go get github.com/goxuetang/encodex@v1.1.3
如果想要升级大版本则需要重新安装大版本,因为在上面我们有提到,在Go中,会将一个大版本视为一个全新的模块。因此,需要使用go get安装该大版本的模块,同时在对应的文件中通过import引入该包。例如encodex模块升级到了v2版本,那么就需要在encodex模块的go.mod中将导入路径更改为v2。如下:
github.com/goxuetang/encodex/v2
然后就可以在工程中引用该v2版本的模块了。如下:
import newHash github.com/goxuetang/encodex/v2/hash
同时使用go get命令下载并安装该模块:
go get github.com/goxuetang/encodex/v2
一个工程所依赖的模块可分为直接依赖和**间接依赖。**直接依赖就是我们的工程文件中使用import语句导入的模块。而间接依赖就是我们直接依赖的模块所依赖的。如下图:
现在我们在main模块中引入github.com/go-redis/redis 模块,然后查看go.mod文件,发现有如下间接的依赖模块,这里的模块正是在github.com/go-redis/redis 中引入的模块,可以查看github.com/go-redis/redis 模块的go.mod文件以确认。
在上图中,我们还发现redis的模块后面的版本是 v6.15.9+incompatible。这个代表什么意思呢?这个代表的是引入的模块的最新版本是v5.15.9,但同时具有不兼容的风险。为什么呢?因为在redis模块中未使用规范的导入名称。例如,规范的模块命名应该是在模块的版本大于1的时候,导入名称就需要增加主版本信息。例如,当该模块是第一个版本时,其对应的go.mod文件如下:
module github.com/go-redis/redis
当主版本升级到2时,则go.mod中的模块导入名称应该为:
module github.com/go-redis/redis/v2
如果不增加v2这个标识,那么当使用go get github.com/go-redis/redis 下载包的时候,go会找到模块名称没有使用主版本标识的最新的版本。我们通过查看该模块在git上的6.15.9的版本源码,发现其源码中并没有go.mod文件。
所以,当模块的go.mod文件中的导入路径没有版本后缀(例如v2)的情况下,默认是v1版本,因此在使用go get获取这样的模块时,默认会获取v1.x.x的最新版本。
我们已经知道了Go可以同时导入主版本不同的module。那么,如果只有小版本或补丁版本不同,那么Go该如何选择呢?假设工程项目直接依赖于两个module:A和B。同时A依赖于MODULE 1 的v1.0.1版本,但B依赖于MODULE 1的v1.0.2版本。如下图所示:
那么,在工程项目模块(PROJECT MODULE)中需要间接依赖MODULE 1的哪个版本呢?如果我们使用v1.0.1,那么MODULE B有可能会产生异常。在语义化版本中,我们知道小版本或补丁版本应该向后兼容,即v1.0.2是兼容v1.0.1的,所以在PROJECZT MODULE中应该选择MODULE 1的v1.0.2版本。
Go module不仅解决了项目代码不再依赖于GOPATH路径,而且还解决了相同module的多版本引入问题。通过本篇文章,相信您对module的创建、发布、版本管理、依赖关系都会有了一个清晰的认识。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8