All Articles

Golang Modules

1. 前言

本文是Go语言系列文章Golang Notes的其中一篇,完整的文章列表请去总章查看。

后续模块相关技术文字内容主要参考自官方wiki说明:Go 1.11 Modules。强烈建议有时间的可以自己通读一遍,任何第三方的讲解都不可能比这篇原文更详细、更细节。这是我见过的最啰嗦的技术说明文章之一。

UPDATE 2019-03-21

官方博客3.19姗姗来迟了一篇模块博文,算是补足了正规渠道的引导:The Go Blog > Using Go Modules。比起之前的wiki,这篇博客算是做了点总结,不像wiki那么啰嗦了。

2. 历史 & 展望

Go语言的包管理可以说是黑历史了,1.5之前基本上没有解决方案,1.5开始出了个vendor文件夹,后面几个版本大部分的工具都围绕着这个文件夹做文章。最后终于到了1.11版本才出了个官方的modules解决方案(vgo),算是尘埃落定。估计一开始做语言设计的时候,大佬觉得包管理根本没必要,怎么随意怎么来。结果后面大厂们都开始用go语言构建大型系统和生态了,才慢慢暴露出问题,文件下载随便一扔的解决方案太过随意,有太多太多的不安定要素。

我之前一直对go语言持观望态度(即便是这两年最火的时候),就是因为这个包管理的原因。于是到了1.11,我终于开始觉得可以入手了。

1.11正式release的module这个功能应该说是稳定了,后面理论上不会再有很大的改动。这从官方给的modules wiki就可以看得出来,洋洋洒洒长篇累牍写了那么长一篇文章。

当然以目前的现状来说,仍旧远不能称完善,这里可以简单展望一下:

  • 包搜索过于困难:没有一个服务能根据需求功能进行包搜索(类似www.npmjs.org
  • 包有可能不存在:这问题之前npm遇到过,发布者将库删了,结果依赖它的一堆库都挂了。npm的解决方案比较野蛮,所有上了npm的包不允许删除,永远会留在里面,就杜绝了库消失的问题。go在这方面更弱,很多东西都是一个地址,要是github库被作者删了,或者转private了,马上就出问题,而且还没人管得着

这些问题官方应该都是有意识到的,具体可以看这篇官方的博客:Go Modules in 2019

3. 官方文档

除了github那篇wiki,go语言的官网上并没有针对modules的详细文档(当然从内容上来说那篇也够了)。倒是命令行工具的help里有点内容(因内容过长,这里就不贴了,有兴趣的可以直接用命令查看,建议用> ~/Downloads/xxx.txt方式输出查看):

  • go help go.mod
  • go help modules
  • go help module-get

4. 使用 & 常用命令 {#GO_MOD_USE}

正常使用modules的情况下,其实你并不需要特别做什么,一般就:

  • 使用go mod init创建模块定义文件
  • 使用go get命令下载对应的依赖(或是更新对应的依赖)
  • 使用go build / go install命令来进行编译

和modules功能出来之前没有任何不同,所有的模块管理工作在go的一系列命令中已经默认完成了。比如说go buildgo test,等等,在执行这些命令的时候会自动对代码进行分析,找到缺失的依赖下载并将信息写入到对应的go.mod文件中。

GO111MODULE

这个环境变量用来控制是否启用modules功能,还是按之前的套路来处理go的包管理。一般来说都用这个版本了,没什么理由不把这个打开(export GO111MODULE=on)。如果你的代码是放在GOPATH之外的,那么默认就是打开的,放在GOPATH里的,默认是关闭的(我试下来是如此)。

go mod init

初始化代码模块,本质上就是创建一个go.mod文件。关于go.mod,可以看这里

go get (-u)

获取或更新代码,同时会更新对应的go.mod文件。对modules来说,这个命令更多用在更新依赖的版本。

go mod graph

打印模块依赖图,也就是打印出模块之间的依赖关系。

go mod download

将依赖下载到本地cache。这个命令的应用场景一般是一个成熟项目,go.mod文件已经编辑好了,协同工作的成员可以在拉下代码之后直接用这个命令进行下载,然后就可以开始工作了。

go mod tidy

添加缺失的依赖,并移除没有使用的依赖。算是一个清理工具,一般在做版本的时候比较常用。

如果你在使用go mod init命令创建好一个干净空的go.mod文件之后,使用的是go build命令来进行对应的包解析和下载的话(同时go.mod文件的内容也生成了),之后你再使用go mod tidy来进行清理会发现:多出来很多之前go build命令没有放进go.mod文件的包。

这里官方有一个解释:Why does ‘go mod tidy’ record indirect and test dependencies in my ‘go.mod’?

另,其他命令,例如’go build’和’go test’不会从go.mod移除依赖,即便是那些不再被需要的依赖。实质上会在go.mod里进行删除操作的,就只有go mod tidy这个命令。

5. 模块定义 & 文件结构

代码根目录的约定

一般来说,一个go项目就是一个github代码仓库。go语言的最佳实践是一个包一个文件夹,包名和文件夹名重合。所以这里其实就有一个约定,定死的,github仓库的根目录必须是代码的根目录。

github.com/my/repo/package_name/code.go
=>
/Users/xxx/Codes/Golang/repo/package_name/code.go

import “github.com/my/repo/package_name”

如果不是这么放,下载下来的代码文件和代码中的import路径就不一致了,会导致找不到代码。

你不可以自己映射一个文件夹作为代码的根节点,比如说:/Users/xxx/Codes/Golang/repo/src/package_name/code.go。对于有强迫症的人来说,这还蛮恶心的。

几个例子可以看下(gin特别极端,所有源码就散落在根目录下,只有一个包,叫gin):

模块定义官方范例

如果你要在代码仓库 github.com/my/repo 创建一个包含两个包(如下列举)的模块:

  • github.com/my/repo/foo
  • github.com/my/repo/bar

那么你的go.mod文件的第一行将会定义一个模块,命名为:github.com/my/repo,而磁盘上的文件结构如下:

repo/
├── go.mod
├── bar
│   └── bar.go
└── foo
    └── foo.go

6. 版本定义 & 更新

之前我们已经说到,modules的出现并没有天翻地覆改变我们日常工作使用的命令和流程,但我们仍旧需要了解modules的很多细节,才能保证不犯错。这一节关于版本号相关的知识非常细节,也相当重要。

tag的命名规范

对于tag的命名,Go官方有明确的要求,不可以按自己的想法随便写。值得注意的点只有一点就是三位的版本号之前,必须添加一个v,也就是说,所有的版本号都应该是:v1.2.3这样的,而不是 1.2.3这样。

看两个例子,打开链接之后观察下他们的版本号列表:

版本选择

wiki原文可以查看:Version Selection

主要内容是说明在使用一些go命令,比如说’go build’、‘go test’的时候,这些命令会怎么选择依赖的版本号。这里需要关注的是,如果有多个依赖对某个依赖都有版本要求,且版本号并不一致,这种情况下会发生什么:

As an example, if your module depends on module A which has a require D v1.0.0, and your module also depends on module B which has a require D v1.1.1, then minimal version selection would choose v1.1.1 of D to include in the build (given it is the highest listed require version). This selection of v1.1.1 remains consistent even if some time later a v1.2.0 of D becomes available. This is an example of how the modules system provides 100% reproducible builds. When ready, the module author or user might choose to upgrade to the latest available version of D or choose an explicit version for D.

  • 在工具第一次处理同个依赖的多处引入的情况下,会引入该依赖的最高版本。
  • 这个版本会在此后保持不变(go.mod中),即便这个依赖的新版本被release出来(作者更新)。这是为了保证产品100%可重复构建。用户可以自行选择升级与否。

值得注意的是原文wiki中有一句:

require M v1.2.3, which indicates module M is a dependency with allowed version >= v1.2.3 (and < v2, given v2 is considered incompatible with v1)

我没理解这是什么意思,后续需要尝试。猜测:

  • 虽然go.mod文件中对于M的版本定义是v1.2.3,但允许的版本可以>=v1.2.3,并小于v2.x.x大版本
  • 如果git clone一个带go.mod文件的库(clone下来的时候磁盘上无cache文件),使用go mod download,go.mod里对于这个库的定义是v1.2.3,但最新的release是v1.3.9,那么会下载v1.3.9?

版本引入

wiki原文可以查看:Semantic Import Versioning

使用过modules之后,会发现某些依赖的引入的最后带上了.vN这样的字符,这里有点细节可以看下:

major版本是v2或更高的,在require的时候都需要在路径最后带上.vN(官方给的例子里是/vN,但我实际看下来几个包都是.vN,先按实际例子来吧),比如说:

  • require gopkg.in/go-playground/validator.v8 v8.18.2
  • require gopkg.in/yaml.v2 v2.2.2

此外,在使用的时候(import):example&#46;com/my/mod/mypkgexample&#46;com/my/mod.v2/mypkg被认为是两个不同的包,会分开下载安装,且不会影响最后的编译。

如何更新依赖的版本

日常中对依赖进行版本升级或降级,应该使用go get命令:

  • go get dep将依赖升级到最新版本(等同于go get dep@latest
  • go get -u dep将依赖升级到最新的minor或者patch版本
  • go get -u=patch dep将依赖升级到最新的patch版本
  • go get [email protected]将依赖升级(或降级)到指定版本

注意:使用go get -ugo get -u=patch将会一并更新dep的所有直接和间接依赖。最佳实践是先不要带上-u参数运行go get,更新完成没有问题之后,再带上-u=patch,然后-u,逐步更新。更新依赖的时候也最好一个一个更新,便于排查。

此外,同样会对go.mod文件进行更改的一系列命令,比如:‘go build’、‘go test’,或者是’go list’,会自动添加新需要的依赖进入go.mod来满足依赖(更新go.mod文件,并下载对应的新依赖)。

  • 如果要查看所有可以使用的直接或间接依赖升级,请使用命令:go list -u -m all
  • 如果需要查看一个依赖的可用版本,请使用命令:go list -m -versions $depname

这部分,更详细的可以参见:How to Upgrade and Downgrade Dependencies

7. 补充:go.mod & replace指令 {#GO_MOD_FILE}

官方wiki里关于go.mod的说明可以看这里:go.mod

go.mod文件里的指令有:

  • module, to define the module path;
  • go, to set the expected language version;
  • require, to require a particular module at a given version or later;
  • exclude, to exclude a particular module version from use; and
  • replace, to replace a module version with a different module version.

注意:exclude和replace指令只在主模块的go.mod文件中生效,在依赖中则会被忽略。

比如说依赖库里用到了 golang.org/x 里的包,你因为网络关系,在你自己的go.mod将这个库replace掉了,但下载完成之后你会发现在cache里还是会有golang.org/x的代码文件,因为第三方依赖可能用到它们了。

replace指令

在go.mod文件中,用得比较频繁的指令一般就只有require和replace。require不用多说,非常简单,replace还是有点细节的。

一般的使用场景:

  • 使用本地代码:replace example.com/original/import/path => /your/forked/import/path
  • 指定特定版本:replace example.com/some/dependency => example.com/some/dependency v1.2.3
  • 指定特定下载地址(特别适合国内):replace golang.org/x/dependency => github.com/golang/dependency

replace是一个指令,所以它是和require同等级别,相同的使用方法,并不是要和require组合起来使用的,单独使用即可。replace指令引入的包也会直接下载。

可以试验下下面的例子:

require (
	...
)

replace golang.org/x/net => github.com/golang/net v0.0.0-20190301231341-16b79f2e4e95
package main

import (
	"fmt"
	"golang.org/x/net/html"
	"strings"
)

func main() {
	z, errp := html.Parse(strings.NewReader("<html></html>"))
	if errp != nil {
		fmt.Println(errp)
	}
	fmt.Println(z)
}

8. 实际经验

如果定义一个模块名为test-module(一般不会这么干,都是以代码仓库为模块名,e.g github.com/gin-gonic/gin)。

则:

  • go.mod文件第一行为:module test-module
  • 该模块代码文件必须存放在$GOPATH/src/test-module
  • 代码包申明及引用相关:
    • 代码文件的物理文件夹位置即包名
    • 源码文件中的包名申明只需要路径的最后一段在代码中写明,不需要完整路径
      • e.g
      • test-module/lib/dao
      • 代码中的包为package dao
    • 其他代码引用的时候永远以模块名为起始,不能使用相对路径,即便是同模块的其他代码,e.g import "test-module/lib/dao"
    • 正因此,go项目的源码总是散在根目录下面的,不可以自行组织子文件夹
      • $GOPATH/src/test-module/src/lib/dao
      • $GOPATH/src/test-module/dao/lib/dao
      • $GOPATH/src/test-module/lib/dao

这也是为什么我的分布式实践项目代码库不能作为一个go模块来使用,因为我的实践代码库中,源代码相关内容并不是存放在根目录下面的。

dist-system-practice /-
                      | ... # 其他大量资料
                      | golang /- # 自定义的$GOPATH
                                | src /-
                                       | dist-system-practice # 这里才是真正的项目代码

而要作为一个go模块来使用,则文件结构只能是:

dist-system-practice /-
                      | # 这里直接就是源码了
                      | lib 
                      | vendors
                      | web
                      | ...

代码直接放在代码库根目录下。

资料

EOF

Published 2019/3/8

Some tech & personal blog posts