在写 《配置 1.13+ 的 golang 环境》时,花了大量篇幅解释 module 的概念,还有 module 与 package 之间的联系。眼看字数翻了一番,干脆把这部分另起一篇。
module 与 package
0x0 module 不是 package
是的,他们不是同一个概念!!module(模块)是新引入的概念,一个 module 是 零到多个 package(包)的组合,不要把他们混为一谈。
package | module | |
---|---|---|
本质 | 一个目录下所有 go 源码的集合(不包括子目录,那是另一个 package) | 同一个根目录下所有包的集合(包括子目录) |
共享 | 代码 共享命名空间(包名),包内可以 直接互相调用(包括小写开头的 unexported members) | 同一个 module 下的 package 共享 module path 作为 package path 的前缀,module 内可以 直接互相 import |
单位 | (代码开头)import 的单位 | (go.mod)require 的单位 |
package 具体体现为一个目录下所有 go 源码的集合(不包括子目录,那是另一个 package),它们 共享命名空间(包名),包内可以 直接互相调用(包括小写开头的 unexported members)。package 是 import 的单位 ,import 语句写在每一个 go 源码文件的开头。
包名跟目录名 可以一样也可以不一样。虽然允许不一样,但是大家习惯性认为目录就是包名;为了避免大家还要去查包名, 没什么特别理由建议保持一致。
例如,import path/to/pkg_dir
中的 pkg_dir 是目录名,package pkg
和 pkg.MyFunc()
里的 pkg 是包名。
module 则是同一个根目录下所有包的集合(包括子目录),它们 共享 module path 作为 package path 的前缀,module 内可以 直接互相 import。module 是 require 的单位 ,require 语句在 go.mod 里。
0x1 GOPATH + vendor 时代
这段解释 GOPATH 的机制,是为了对比,加深理解。
如果你不想了解已经被抛弃的 GOPATH ,可以直接跳过看 0x2 部分。
在依赖 GOPATH 的时候,import 的查找范围如下:
$GOROOT/pkg
查找 内置包查找 相对路径 的包- 项目根目录下的 vendor 目录查找 第三方包
$GOPATH/src
查找下载的 第三方包 和 本地包,如果不存在,尝试go get
重点解释 2 和 4。
相对路径 import?别用!
假定有项目 A ,底下有两个包,分别为 A/alpha 和 A/beta。
为了方便,A/alpha 包使用相对路径引入 A/beta:
|
|
如果 A 不在 GOPATH 里开发,换言之 A 不会被别的项目引用,那么是可以正常编译执行的。
可是如果 A 在 GOPATH 里开发,那么编译时会报错:
|
|
这是因为 go 使用全局递归 import,来确保每个用到的包都只 import 一次。(题外话,也因此,go 不允许循环 import,会死循环。)
假定有另一个项目 B,底下的 main 包引入了 A/alpha,那么就会触发以下 import 顺序:
- import “A/alpha”,递归 import “A/alpha” import 的包
- import “../beta”,(到这里会出错,因为 B 项目下找不到 “../beta”)
- 运行 “A/alpha” 的 init(),然后 import 完成
如果你觉得解释太啰嗦,记住 别用相对路径 就完了。
一切靠 GOPATH
既然相对路径会有各种问题,那么本地包的导入,就只剩下第 4 种 - GOPATH 一条路了。
这就导致了包管理高度依赖 GOPATH:
- 为了本地开发的包 能被其它包引用,开发得在 GOPATH 下进行。
- 不仅引用其他项目包要经 GOPATH,连项目内的包互相引用 ,也得经过 GOPATH。(实际上这时不存在 项目 的概念,即使共享一个项目根目录,还是不同的包。)
- 项目目录不能改名,一改,项目内外的引用全得改。(事实上,如果你要把项目托管到源码仓库,或者更换托管地址,项目目录是一定会改的。)
这么打个比方,李明 爸爸叫 李雷,妈妈叫 韩梅梅,他们一家住 广东省广州市黄埔区。但是很奇怪,他们家互相称呼都得叫全名,而且是带地址那种。譬如 妈妈 喊老伴和儿子吃饭,就得喊『广东省 / 广州市 / 黄埔区 / VK花园10-204 / 李雷』和『广东省 / 广州市 / 黄埔区 / VK花园10-204 / 李明』。更诡异的是,如果他们过年回老家了,譬如说 长沙,然后妈妈忘记了改称呼,还按前面叫,明明都在一屋(项目)里,但他们俩都不知道在喊自己了。
根本原因,在于 import 中只有全局,没有本地(项目 / 模块)概念。全局以下就直接是包,包和包之间没有联系,哪怕我们在一个项目里,目录相邻。
如果你写过 Java,对比一下就发现,Java 的 classpath 默认为 当前目录;这个当前目录,是以执行 javac 的位置算的,其实就是项目的根目录。所以同一个项目下的包,用相对根目录的路径 就能 import,不管项目整体放哪、项目目录有没有改名。
0x2 引入 module
module 模式设为 on,背后主要是两个变化:引入 module (和 module path),放弃 GOPATH (和 vendor) 。
这个 module 就是介于 global 和 package 之间的概念,是 一系列的 package 集合。这个概念让在一个 module 里的 package 们产生了联系:整体管理, 互相可见。
module path 和 package path
package path 具体来说,就是 import 后面那串路径;module path 则对应 require。
在使用上,package path 似乎没有任何变化,其实它的组成有了重要的变化:
GOPATH 模式
从 $GOPATH/src
起完整的路径。
例如 $GOPATH/src/github.com/jay/mymod/midware/router
的 package path 是 github.com/jay/mymod/midware/router
,其它包(包括同一个项目github.com/jay/mymod
下的其它包)需要 import 这个路径。
路径上的 任何变化 都要体现在 import 路径里,如果移出 GOPATH 则 直接找不到 。(是的,明明引用的包就在旁边目录都找不到。)
module 模式
module path + module 内的相对路径。(如果 package 在 module 根目录,也就是跟 go.mod 一个目录,当且仅当这种情况 module path 等于 package path。)
例如 module path 是 github.com/jay/mymod
,module 内的 midware/router
的 package path 是 github.com/jay/mymod/midware/router
,其它包(包括同一个module github.com/jay/mymod
下的其它包)需要 import 这个路径。
是不是感觉其实没啥差别,只是把路径截成了两段,把前面那段叫 module path。[苦笑]
差别在于:
- module path 是一个在 go.mod 内的声明,不需要是真实的路径。你的 module 可以放在任何地方开发,不需要放在 GOPATH 地下,路径里也不须包含
github.com/jay/mymod
! - 基于这点,只要 go.mod 声明不改,挪位置,根目录重名,都不影响 module 内 package 互相引用!
module 间引用
等等,这些便利都只是 module 内而已,那 module 之间的引用呢?
再来对比一下:
GOPATH 模式
项目托管地址、本地存放路径、import 路径 (的开头) 三者一致。
仍然以上面的项目为例,三个都是 github.com/jay/mymod
。
具体到 托管地址是 https://github.com/jay/mymod
,
本地存放地址(无论手动新建项目,还是 go get 自动放)是 $GOPATH/src/github.com/jay/mymod
,
import 则是 import "github.com/jay/mymod/midware/router"
(mymod 下面其中一个 package)。
module 模式
在上述三者基础上,加上 go.mod 声明的 module path 一致。
也就是在 module 初始化时,执行 go mod init github.com/jay/mymod
,生成的 go.mod 里第一行就是
|
|
托管地址、import 路径都跟 GOPATH 一样。差别是本地存放路径:$HOME/go/pkg/mod/github.com/jay/mymod
。($HOME/go/pkg/mod
叫 mod cache 目录)
看了这个对比,module 模式多了一个 go.mod 的声明要保持一致,存放路径还变长了,是不是又感觉根本没简化,还变复杂了。[苦笑]x2
关键在于,go.mod 里提供了一个关键字 replace
。
go.mod 里的 replace
我们来设想一下 开发的不同阶段 :
前期,一个人开发原型 。只有 module 内引用,爱放哪放哪。
继续前期,原型 新增了一个 mymod2 ,而且 引用原来的 mymod ,有了 module 间引用,此时你有 两个选择:
继续随便放 ,譬如
~/mymod/
,然后在 mymod2 根目录执行go mod edit -replace=github.com/jay/mymod@v=~/mymod@v
,@v
是可选的。go mod edit -replace=github.com/jay/mymod=~/mymod
就是所有版本都替换。你也可以指定版本如@v1.0.1
。把 mymod 按照 module path 托管到对应地址 ,mymod2 就会从托管服务下载 mymod 自动存放到
$HOME/go/pkg/mod/github.com/jay/mymod@vX.Y.Z
。下载过程是自动的,存放位置自动跟 “托管地址+版本” 映射,并不需要人工干预。需要注意 的是,mymod2 引用的是托管的代码,
~/mymod/
下的最新修改如果没有push 到托管,是访问不到的。如果 mymod2 后续也要发布或者跟其他开发者协作,建议一开始就选择这种方式 提供引用。否则按 2.1 处理,mymod2 在别人的环境无法获取 mymod 的依赖。
中期,其他开发者加入 。为了其他开发者可以正常地访问依赖,需要把所有用到的 module 按 module path 放到托管服务上 。(同 2.2)
托管服务可以是公共的,也可以是私有的。如果是私有的,需要配置 ssh 以达到免密访问。(ssh 配置不展开。)
考虑到迟早需要发布到托管,最好初始化时就考虑 把托管地址作为 module path 。
后期,持续开发和维护。也许是 公共转私有(或者反过来,开源),又或者项目改名,或者某个公共托管撂挑子不干了——总之,有些 module 挪位置了。譬如说
https://github.com/jay/mymod
挪到https://bitbucket.com/jay/mymod
。这时 replace 再次发挥作用,在所有引用这个 module 的 module 的根目录执行
go mod edit -replace=github.com/jay/mymod=bitbucket.com/jay/mymod
,那些 import 语句就不用一个一个修改了。 (原理同 2.1 ,只是映射的是 托管地址,不是本地,所以这个修改写入 go.mod 并提交之后, 对其他开发者也能生效 。)不过 mymod 本身,除非你只挪托管不修改 go.mod 的 module path 声明(意味着 mymod 只作为依赖存在,自身没有 main 包需要编译执行),否则 mymod 内部的 import 语句还是得改为新的 module path。
需要注意 的是,replace 只对当前 module 直接引用的依赖起作用 ,对于间接引用不起作用。如果 mod1 引用 mod2,然后 mod2 引用 mod3;当 mod3 改动地址时,在 mod1 里 replace mod3 的地址,只会对 mod1 直接引用 mod3 起作用; mod2 对 mod3 的引用必须在 mod2 里改。
如果 mod2 是第三方的 module,而它引用的同样是第三方的 mod3 挪了位置之后,mod2 没有及时更新,那么可能你只能 fork 一个 mod2 自行修改了。
这个问题据说可以通过 自建 goproxy 来指定重定向解决。我还没到需要用到的时候,将来踩了自建 goproxy 的坑再回来写。
0x3 semver 语义化版本
要理解 go modules 的运作,还有一个是不得不提的,就是 Semantic Version - 语义化版本,缩写 semver。
关于 semver 是什么,请看 《语义化版本 2.0》。
详细的解释,大家自己看官方文档,这里只强调格式:主版本号.次版本号.修订号
- 主版本号:当你做了 不兼容 的 API 修改 (breaking changes),
- 次版本号:当你做了 向下兼容 的功能性新增 (compatible features),
- 修订号:当你做了 向下兼容的问题修正 (compatible hotfixs)。
譬如说当前版本号是 v1.2.3 ,在此基础上:
- fix 了个 bug,没有影响兼容性,v1.2.4
- 新增 / 改善了功能,依然没有影响兼容性,v1.3.0
- 任何影响兼容性的修改,无论是 fix bug (这 bug 得多严重),还是 API 签名(名字 or 参数)改动,或者干脆的删掉了 deprecated API,反正调用方会出错,必须跟着修改,v2.0.0
一个特例是,主版本号为 0 的版本,被认为是初步开发的 不稳定版本 ,可以不遵循兼容性的原则。
理解了这些,下面的一些做法就比较好理解了。
导入兼容性原则
一个 module 一定是 向下兼容 的。(又叫向后兼容 backwards compatible,指 newer 的版本兼容 older 的版本)反过来说,如果不兼容,会被视作 不同的 module 。
具体操作上,就是 2 以上的主版本号,会加入 module path,使得 module 声明、导入路径(包括 require 和 import)、缓存路径 都发生变化,从而被识别为不同的 module。唯独不变的是 托管地址,靠 tag 就可以区分,没有必要每个主版本新建一个项目。还是以 github.com/jay/mymod
为例:
主版本号 | 0 或 1 | 2 (3 或以上以此类推) |
---|---|---|
module 声明 | module github.com/jay/mymod | module github.com/jay/mymod/v2 |
require 列表 | github.com/jay/mymod v1.0.1 | github.com/jay/mymod/v2 v2.0.2 |
import 语句 | import “github.com/jay/mymod/midware/router” | import “github.com/jay/mymod/v2/midware/router” |
选择最新版本
在同一个主版本下,如果在添加依赖时你没有指定版本(也就是你没有手动 go get github.com/jay/mymod@v1.0.1
,或者只是指定了大版本 go get github.com/jay/mymod@v1
没有指定次版本),那么第一次获取依赖时,go 会自动 获取最新的版本 并将版本信息写入 go.mod。
在这之后,除非你手动更新,否则 go 会一直使用 go.mod 记录的版本,不会自动更新。
最小版本选择
依赖包括 直接依赖 和 间接依赖。mod1 依赖了 mod2,然后 mod2 又依赖了 mod3, mod2 是直接依赖, mod3 是间接依赖。间接依赖在 go.mod 里以 //indirect
结尾。
执行 go mod graph
可以输出所有 module 之间的依赖关系。如果项目稍大,内容会很长,长到超出 bash / cmd 的缓冲那种,建议重定向一个文件再搜索。或者 go mod why <package path>
查询某个 package 被谁依赖。
因为有 直接依赖 和 间接依赖,而且对某个 module 的间接依赖可能不止一处,就有可能出现依赖的版本不一致。这种不一致又分两种情况:
主版本号不同:这个好办,参见上一个小节,主版本号不同直接被认为是不同 module,你依赖你的,我依赖我的,并行不悖。
主版本号相同:选择所有依赖里,最大的版本号。
例如 同时依赖 v1.0.1、v1.0.2、v1.1.3,那么选择 v1.1.3。因为同一个主版本下是向下兼容的,依赖 v1.0.1 和 v1.0.2 的代码,调用 v1.1.3 也是可以的;反过来说,v1.1.3 里可能增加了新功能,依赖它的地方再去调用老版本,很有可能会报错。
伪版本
go module 允许通过 commit-hash 指定版本 (可以通过 hash 前缀指定,有规定最小长度,但我忘了,这是不推荐的做法),但在获取时会自动跟 tag 比对,一旦命中会自动转换成 semver。
如果 module 完全没有打 tag,或者指定的 hash 不命中 tag,go 会生成一个伪版本号来记录,格式是 vX.0.0-yyyymmddhhmmss-12位hash
。
+incompatible
在 go.mod 里可以看见有些依赖后面带着一个 +incompatible
。这个标记的意思是,依赖的版本在 2 以上,但是这个 module 自身没有使用 module 模式(也就是根目录没有 go.mod),所以无法通过在路径添加版本来区分主版本。
更多版本选择的原理,请参考 《Minimal Version Selection》。
延伸:可重现构建
Java 从 ant、Maven 到 gradle,Python distutils、setuptools 到 pip,js 的 npm 和 yarn,go 经历了 vgo、glide、dep 到 内置 modules,再加上一系列 VCS 和 托管服务(目前是 git 和 github 统一了江湖),各种构建物仓库。
大家做了那么多工作,设计这么复杂的机制,本质上都是为了一个目的:构建过程可重复,构建产物可重现 。
在软件个人英雄主义的时代,这不成问题的,代码是大牛一个人开发的,构建所需要的代码和工具,都在大牛的电脑上。稍往后,多几个人加入,也是在一个公司、一个研究机构里,ftp 共享一下就完事了,最多搭建一个内部的 VCS 服务。
但是在软件开发网络大协作的年代,这就变成了一个工程难题。分布在世界各地,素未谋面的一群人一起开发,很多问题就会涌现。特别是开源的年代,即使是小公司的项目,一个学生的作业,也极少会从零开始开发,你不可避免地会引用其他人的工作成果。
哪怕只是和别人合作过一个简单的项目,你都大概率遇到过『你的代码在我这里 编译不过 / 运行报错。』『不可能,我本地一点问题都没有,是测试过才提交的。』这种对话。
上述那么多的 工具 和 机制,是为了保证分散各处的开发者(可能还有测试、运维团队),能够做到共享 一致的环境、一致的配置、一致的代码版本,一致的依赖,一致的构建脚本,重现一致的构建过程,得到一致的构建产物 。
话题很大,不是三言两语能够说清的,就到此为止。提那么一下,是希望帮助理解,为什么把事情搞得那么复杂。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。