golang 1.13 - module VS package

在写 《配置 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 pkgpkg.MyFunc() 里的 pkg 是包名。

module 则是同一个根目录下所有包的集合(包括子目录),它们 共享 module path 作为 package path 的前缀,module 内可以 直接互相 import。module 是 require 的单位 ,require 语句在 go.mod 里。

0x1 GOPATH + vendor 时代

这段解释 GOPATH 的机制,是为了对比,加深理解。

如果你不想了解已经被抛弃的 GOPATH ,可以直接跳过看 0x2 部分。

在依赖 GOPATH 的时候,import 的查找范围如下:

  1. $GOROOT/pkg 查找 内置包
  2. 查找 相对路径 的包
  3. 项目根目录下的 vendor 目录查找 第三方包
  4. $GOPATH/src 查找下载的 第三方包本地包,如果不存在,尝试 go get

重点解释 2 和 4。

相对路径 import?别用!

假定有项目 A ,底下有两个包,分别为 A/alpha 和 A/beta。

为了方便,A/alpha 包使用相对路径引入 A/beta:

1
import "../beta"

如果 A 不在 GOPATH 里开发,换言之 A 不会被别的项目引用,那么是可以正常编译执行的。

可是如果 A 在 GOPATH 里开发,那么编译时会报错:

1
can't load package: local import "../beta" in non-local package

这是因为 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 里第一行就是

1
module github.com/jay/mymod

托管地址、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

我们来设想一下 开发的不同阶段

  1. 前期,一个人开发原型 。只有 module 内引用,爱放哪放哪。

  2. 继续前期,原型 新增了一个 mymod2 ,而且 引用原来的 mymod ,有了 module 间引用,此时你有 两个选择

    1. 继续随便放 ,譬如 ~/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

    2. 把 mymod 按照 module path 托管到对应地址 ,mymod2 就会从托管服务下载 mymod 自动存放到 $HOME/go/pkg/mod/github.com/jay/mymod@vX.Y.Z 。下载过程是自动的,存放位置自动跟 “托管地址+版本” 映射,并不需要人工干预。

      需要注意 的是,mymod2 引用的是托管的代码,~/mymod/ 下的最新修改如果没有push 到托管,是访问不到的。

      如果 mymod2 后续也要发布或者跟其他开发者协作,建议一开始就选择这种方式 提供引用。否则按 2.1 处理,mymod2 在别人的环境无法获取 mymod 的依赖。

  3. 中期,其他开发者加入 。为了其他开发者可以正常地访问依赖,需要把所有用到的 module 按 module path 放到托管服务上 。(同 2.2)

    托管服务可以是公共的,也可以是私有的。如果是私有的,需要配置 ssh 以达到免密访问。(ssh 配置不展开。)

    考虑到迟早需要发布到托管,最好初始化时就考虑 把托管地址作为 module path

  4. 后期,持续开发和维护。也许是 公共转私有(或者反过来,开源),又或者项目改名,或者某个公共托管撂挑子不干了——总之,有些 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》。

详细的解释,大家自己看官方文档,这里只强调格式:主版本号.次版本号.修订号

  1. 主版本号:当你做了 不兼容 的 API 修改 (breaking changes),
  2. 次版本号:当你做了 向下兼容 的功能性新增 (compatible features),
  3. 修订号:当你做了 向下兼容的问题修正 (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)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。