Go Module 的基础概念与原理

知识共享许可协议 4.0

Go Module已经来了,默认Go Module模式将会在1.13版本发布。也就是说半年后,就会全面铺开。鉴于官方提供扫盲文档中的样例过于简单,提供一个更加贴近实际开发过程的例子也许是有必要的。

1. 基础概念篇

官方文档参考:Go Module Wiki

1.1 准备环境

按照官方的说明,Go Module是在 Go 的 1.11版本开始引入,但是默认该选项是关闭的,直到1.13版本将会默认开启。预计1.13将会在2019年8月份发布。所以在这之前,是必须手动开启Go Module支持。

必要条件:

  • Go语言版本 >= 1.11
  • 设置环境变量 GO111MODULE=on

在开启Go Module功能后,官方还提供了环境变量GOPROXY用于设置包镜像服务。此处暂不详细介绍了。

1.2 Go Module带来的改变

1.2.1 GOPATH作用的改变

引入Go Module后,环境变量GOPATH还是存在的。开启Go Module功能开关后,环境变量GOPATH的作用也发生了改变。

When using modules, GOPATH is no longer used for resolving imports. However, it is still used to store downloaded source code (in GOPATH/pkg/mod) and compiled commands (in GOPATH/bin).

翻译出来就是:

  • 环境变量GOPATH不再用于解析imports包路径,即原有的GOPATH/src/下的包,通过import是找不到了。
  • Go Module功能开启后,下载的包将存放与$GOPATH/pkg/mod路径
  • $GOPATH/bin路径的功能依旧保持。

1.2.2 新增go.mod文件配置

开始Go Module开发之前,首先是初始化一个正确的Go Module定义,即go.mod文件。 何为正确的Go Module定义。就是说mod包必须符合。

方法一

  • $GOPATH/src的目录下,创建合理的路径github.com/liujianping/foo路径。
  • 进入$GOPATH/src/github.com/liujianping/foo路径,执行go mod init即可。

或者

方法二

  • 创建foo路径,位置任意
  • 进入foo目录,执行go mod init github.com/liujianping/foo即可。

生成了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.

官方提供了一个简单全面的例子:

module my/thing
go 1.12
require other/thing v1.0.2
require new/thing/v2 v2.3.4
exclude old/thing v1.2.3
replace bad/thing v1.4.5 => good/thing v1.4.5

1.2.3 go get流程改变

引入Go Module之后,go get官方又重新实现了一套。具体实现代码可以参考:

  • 不开启Go Module功能,go get代码实现

$GOROOT/src/cmd/go/internal/get/get.go

  • 开启Go Module功能,go get代码实现

$GOROOT/src/cmd/go/internal/modget/get.go

简单说明一下主要区别,更详细的go get取包原理放到下篇讲解。最直接的区别是:

  • 老的go get取包过程类似:git clone + go install , 开启Go Module功能后go get就只有 git clone 或者 download过程了。
  • 新老实现还有一个不同是,两者存包的位置不同。前者,存放在$GOPATH/src目录下;后者,存放在$GOPATH/pkg/mod目录下。
  • 老的go get取完主包后,会对其repo下的submodule进行循环拉取。新的go get不再支持submodule子模块拉取。

1.3 一个完整的例子

官方的版本由于过于简单,连一个基础的本地第三方包的引入都没有,仅仅通过引入一个公开的第三方开源包,缺少了常规本地开发说明。所以,笔者特意提供一个完整的例子,分别从:

  • 本地仓库
  • 远程仓库
  • 私有仓库

三个维度阐释Go Module的在实际开发中的具体应用。

本例子的目录结构如下:

$GOPATH/src
├── github.com
    └── liujianping
        ├── demo
        │   └── go.mod
        └── foo
            └── go.mod

创建两个mod模块:demofoo, 其中 foo 作为一个依赖包,提供简单的 Greet 函数供 demo 项目调用。

1.3.1 本地仓库

本地仓库的意思,就是例子中的两个包: github.com/liujianping/demogithub.com/liujianping/foo 暂时仅仅存在于本地。无法通过 go get 直接从github.com 上获取。

通过以下命令,简单的创建项目代码:

$: mkdir -p $GOPATH/src/github.com/liujianping/foo
$: cd $GOPATH/src/github.com/liujianping/foo
$: go mod init
$: cat <<EOF > foo.go
package foo

import "fmt"

func Greet(name string) string {
    return fmt.Sprintf("%s, 你好!", name)
}
EOF

$: mkdir -p $GOPATH/src/github.com/liujianping/demo
$: cd $GOPATH/src/github.com/liujianping/demo
$: go mod init
$: cat <<EOF > main.go
package  main

import ( 
    "fmt"
    "github.com/liujianping/foo"
)

func main(){
    fmt.Println(foo.Greet("GoModule"))    
}
EOF

执行完以上命令以后,mod demofoo 的代码部分就完成了。现在来执行以下:

$: cd $GOPATH/src/github.com/liujianping/demo
$: go run main.go
build github.com/liujianping/demo: cannot find module for path github.com/liujianping/foo

从输出可以看出,在demo 中调用 foo的依赖包,在编译过程就失败了。demo无法找到 github.com/liujianping/foo。为什么这样?

按照传统的$GOPATH引入包原则,只要在$GOPATH/src存在相应路径的包,就可以完成编译了。从现在的情形就可以解释$GOPATHGo Module功能开启后,对原有引入包的规则发生的改变。

既然,$GOPATH/src路径不再支持。那么如何解决这个无法找到包依赖的问题呢?方法有二:

  • 本地路径
  • 远程仓库

该小节提供本地路径方法。

$: cat $GOPATH/src/github.com/liujianping/demo/go.mod
module github.com/liujianping/demo

目前demo项目的go.mod仅仅一句话,因为无法找github.com/liujianping/foo,所以在go build过程中也不会修改go.mod,增加对包github.com/liujianping/foo的依赖关系。所以,只能是手动处理了。修改go.mod文件如下:

module github.com/liujianping/demo

require github.com/liujianping/foo v0.0.0
replace github.com/liujianping/foo => ../foo

再次执行demo程序:

$: go run main.go
go: finding github.com/liujianping/foo v0.0.0
GoModule, 你好!

对于项目中直接引用本地依赖包的官方文档中有段注意事项:

Note: for direct dependencies, a require directive is needed even when doing a replace. For example, if foo is a direct dependency, you cannot do replace foo => ../foo without a corresponding require for foo. (If you are not sure what version to use in the require directive, you can often use v0.0.0 such as require foo v0.0.0; see #26241).

意思就是,即使是本地依赖包,明确的require仍然是需要的。至于版本号,其实只要符合SemVer规范就可以。可以是v0.0.0,也可以是v0.1.2

Go Module最主要是引入了依赖包的版本控制。所以,我们不妨就本地版本测试一下。

对本地版本foo进行相应的git本地版本控制,增加几个版本,代码中相应的增加版本信息。

package foo

import "fmt"

func Greet(name string) string {
    return fmt.Sprintf("%s, 你好! Version 1.0.0", name)
}

增加了以下三个版本tag。

$: git tag
v0.1.0
v0.2.0
v1.0.0

demo项目中,设置foo版本, go.mod修改如下:

module github.com/liujianping/demo

require github.com/liujianping/foo v0.1.0
replace github.com/liujianping/foo => ../foo

执行demo程序,输出如下:

go run main.go
go: finding github.com/liujianping/foo v0.1.0
GoModule, 你好! Version 1.0.0

不难得出结论:go get是不会从本地仓库获取版本信息的,查看go get在module模式下工具链实现代码也可得出这个结论。

1.3.2 远程仓库

从上节可以大致了解Go Module的原理。现在我们将foo依赖包上传到github.com上,包括相应的版本tag。首先github.com创建相应的项目foo.再将本地仓库上传到远程仓库中。

$: git remote add origin git@github.com:liujianping/foo.git
$: git push -u origin master

上传版本tag信息:

$: git push origin --tags

现在完成了github.com/liujianping/foo依赖包的远程部署。看看具体实操demo项目,首先去掉本地的直接依赖。demo项目的go.mod如下

$: cat $GOPATH/src/github.com/liujianping/demo/go.mod
module github.com/liujianping/demo

重新执行demo项目

$: cd $GOPATH/src/github.com/liujianping/demo
$: go run main.go
go: finding github.com/liujianping/foo v1.0.0
go: downloading github.com/liujianping/foo v1.0.0
GoModule, 你好! Version 1.0.0

查看变更后的go.mod,如下

$: cat go.mod
module github.com/liujianping/demo

require github.com/liujianping/foo v1.0.0 // indirect

同时demo根目录下,增加了go.sum文件。

cat go.sum
github.com/liujianping/foo v1.0.0 h1:yYoUzvOwC1g+4mXgSEloF187GmEpjKAHEmkApDwvOVQ=
github.com/liujianping/foo v1.0.0/go.mod h1:HKRu+NgbfULQV4mqZOnCXpF9IwlhOOIwmns7gpwjZic=

修改foo版本号到 v0.2.0

$: cat go.mod
module github.com/liujianping/demo

require github.com/liujianping/foo v0.2.0 // indirect

重新执行demo项目

$: cd $GOPATH/src/github.com/liujianping/demo
$: go run main.go
go: finding github.com/liujianping/foo v0.2.0
go: downloading github.com/liujianping/foo v0.2.0
GoModule, 你好! Version 0.2.0

再看看go.sum文件发生的变化:

cat go.sum
github.com/liujianping/foo v0.2.0 h1:2JCV7mfUyneSksnWokX0kZoBbtWPoyL8s8iW80WHl/A=
github.com/liujianping/foo v0.2.0/go.mod h1:HKRu+NgbfULQV4mqZOnCXpF9IwlhOOIwmns7gpwjZic=
github.com/liujianping/foo v1.0.0 h1:yYoUzvOwC1g+4mXgSEloF187GmEpjKAHEmkApDwvOVQ=
github.com/liujianping/foo v1.0.0/go.mod h1:HKRu+NgbfULQV4mqZOnCXpF9IwlhOOIwmns7gpwjZic=

通过以上步骤,粗略可以了解针对Go Module对于远程仓库的版本选择。简单解释版本的选择过程下:

  • 检查远程仓库最新的tag版本,有就取得该版本
  • 远程仓库没有tag版本时,直接获取master分支的HEAD版本
  • 如果在go.mod文件中指定了具体版本,go get直接获取该指定版本
  • go.mod中除了可以指定具体版本号以外,还支持分支名

继续对远程版本foo增加新的版本v1.0.1。提交相应代码并推送版本标签v1.0.1到远端。并重新设置demo项目中的go.mod中的依赖版本为v1.0.0.如下:

$: cat go.mod
module github.com/liujianping/demo

require github.com/liujianping/foo v1.0.0 // indirect

重新执行demo项目

$: cd $GOPATH/src/github.com/liujianping/demo
$: go run main.go
GoModule, 你好! Version 1.0.0

这次执行没有输出go本身的提示信息,而是直接输出了结果。因为github.com/liujianping/foo v1.0.0已经存在于本地的缓存中了,不妨查看一下。

$: ls $GOPATH/pkg/mod/github.com/liujianping/foo@v1.0.0

虽然就demo项目而言,依赖项目foo有两个v1.0.0v1.0.1两个版本可用。按照GoModule版本选择最小版本的算法,demo项目依旧选择v1.0.0版本。

如何更新依赖包版本

更新依赖包的版本,最简单的方式,直接手动编辑go.mod设置依赖包版本即可。

另外一种方式就是通过go get -u的方式进行自动更新。具体操作步骤如下:

查看依赖包版本更新信息

$: go list -u -m all
go: finding github.com/liujianping/foo v1.0.1
github.com/liujianping/demo
github.com/liujianping/foo v1.0.0 [v1.0.1]

更新依赖包版本

$: go get -u 
go: downloading github.com/liujianping/foo v1.0.1

或者,制定更新patch版本

$: go get -u=patch github.com/liujianping/foo 
go: downloading github.com/liujianping/foo v1.0.1

此时,go.mod文件即被更新

$: cat go.mod
module github.com/liujianping/demo

require github.com/liujianping/foo v1.0.1

重新执行程序

$: go run main.go
GoModule, 你好! Version 1.0.1

基于分支

GoModule除了支持基于标签tag的版本控制,可以直接利用远程分支名称进行开发。

所以本节,笔者就模块foo创建一个新的远程分支develop.具体代码,请直接参考github.com/liujianping/foo项目。

修改demo项目的go.mod文件:

module github.com/liujianping/demo

require github.com/liujianping/foo develop

再次执行demo, 结果如下:

$: go run main.go
go: finding github.com/liujianping/foo develop
go: downloading github.com/liujianping/foo v1.0.2-0.20190214080857-9c0018d55446
GoModule, 你好! Branch develop

查看,go.mod文件,发生如下变更:

$: cat go.mod
module github.com/liujianping/demo

require github.com/liujianping/foo v1.0.2-0.20190214080857-9c0018d55446

按官方文档的说明,使用分支名,可以直接拉取该分支的最后一次提交。从实验来看, Go Module一旦发生编译就会针对分支名的依赖进行版本号固定。

1.3.3 私有仓库

对于私有仓库而言,其原理与1.3.2中的远程仓库是类似的。唯一不同之处是,go get取包的过程可能存在种种障碍,导致无法通过go get取到私有仓库包。主要原因可能是:

  • 权限问题
  • 路径问题

导致按照正常的go get过程取包失败。如果了解了go get取包原理,以上问题也就迎刃而解了。

2. go get 取包原理篇

不论是否开启Go Module功能,go get从版本控制系统VCS中取包的基础过程是类似的,除了在新的实现中不再循环拉取submodule子模块以外。

2.1 go get 基础取包流程

假设依赖包github.com/liujianping/foo不在本地,需要通过go get获取。发起以下命令:

$: go get github.com/liujianping/foo

命令发出后:

2.1.1 第一步,正则匹配出依赖包的查询路径

go get可以指定具体包的import路径或者通过其自行分析代码中的import得出需要获取包的路径。但是import路径,并不直接就是该包的查询路径。在go get的源码实现中,包的查询路径是通过一组正则匹配出来的。也就是说,import路径是必须匹配这组正则表达式的,如果不匹配的话,代码是肯定无法编译的。笔者就贴一下这组正则表达式中的github正则与私有仓库的正则:

    // Github
	{
		prefix: "github.com/",
		re:     `^(?P<root>github\.com/[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)(/[\p{L}0-9_.\-]+)*$`,
		vcs:    "git",
		repo:   "https://{root}",
		check:  noVCSSuffix,
	},
    
    //省略其它VCS...
    
    // General syntax for any server. 
	// Must be last.私有仓库将会使用该正则
	{
		re:   `^(?P<root>(?P<repo>([a-z0-9.\-]+\.)+[a-z0-9.\-]+(:[0-9]+)?(/~?[A-Za-z0-9_.\-]+)+?)\.(?P<vcs>bzr|fossil|git|hg|svn))(/~?[A-Za-z0-9_.\-]+)*$`,
		ping: true,
	},

以包路径github.com/liujianping/foo为例,正则匹配后,得出的查询路径就是:

https://github.com/liujianping/foo

再结合go-get参数,向远端VCS系统发起https://github.com/liujianping/foo?go-get=1请求。

2.1.2 第二步,查询得出包的远端仓库地址

包的远端仓库地址,可以通过go get请求的响应中的go-import的meta标签中的content中获取的。

$: curl https://github.com/liujianping/foo?go-get=1 | grep go-import
<meta name="go-import" content="github.com/liujianping/foo git https://github.com/liujianping/foo.git">

例子中的包对应的远端仓库地址就是:https://github.com/liujianping/foo.git.

2.1.3 第三步,根据仓库地址clone到本地

虽然版本控制系统VCS本身就存在各类区别,但是一些基础操作大多类似。在go get中具体clone的过程会根据具体的VCS采用对应的操作。

2.2 go get 代理取包流程

了解了go get取包的基础流程后,说说Go Module功能开启后的完整流程。

开启Go Module后,go get增加了一个新的环境变量GOPROXY。该环境变量一旦开启,go get就完全切换到新的取包流程,即GOPROXY流程,暂时就这么称呼吧。

GOPROXY流程中,官方定义了一组代理接口, 请参考官方接口定义

GET $GOPROXY//@v/list returns a list of all known versions of the given module, one per line.

GET $GOPROXY//@v/.info returns JSON-formatted metadata about that version of the given module.

GET $GOPROXY//@v/.mod returns the go.mod file for that version of the given module.

GET $GOPROXY//@v/.zip returns the zip archive for that version of the given module.

其实这组接口的定义就是$GOPATH/pkg/mod/cache/download中的文件系统。就是说,我们可以直接将此目录下的文件系统作为代理使用,如下命令:export GOPROXY=file:///$GOPATH/pkg/mod/cache/download/

关于GOPROXY代理服务,网上有很多实现,官方也推荐了几个。各有各的问题,只能这样说。因为,对于一些定制话的需求,例如:

  • 私有仓库的权限问题
  • 个别库的镜像国内无法访问等

尚无完美的解决方案。但是即使这样,我们还是可以根据具体的工程化需求构建企业内部的一套标准的GO Module流程来。具体方案,在下一篇工程实践篇中讲解。

2.3 私有仓库取包过程中的常见问题

私有仓库的取包过程中出现的问题大多集中在基础取包过程中。具体的异常又可能发生在2.1.1~2.1.3任一阶段。分别列举常见问题与解决思路。

2.3.1 私有仓库clone阶段的权限问题

通常情况下,私有仓库的访问是基于账号权限的。例如,private.vcs.com/group/foo的包路径,在go get过程中,会正则匹配出https://private.vcs.com/group/foo.git的仓库路径,假设VCS系统是gitlab搭建的。

那么在git clone https://private.vcs.com/group/foo.git的过程中,系统会提醒用户提供用户名与登录密码。每次输入就会很累赘。

解决方案有二:

  • 方法一:

增加 $HOME/.gitconfig 配置:

[url "ssh://git@github.com/MYORGANIZATION/"] insteadOf = https://github.com/MYORGANIZATION/

将原有的https访问方式替换成ssh方式。

  • 方法二:

增加 $HOME/.netrc:

machine github.com login YOU password APIKEY 将其中的 APIKEY 换成自己的登录KEY。

虽然采用的github为例,但适用于gitlab服务。其实,还有一种解决方案,该方案,还能解决2.3.2中的问题,故在下节中讲解。

2.3.2 私有VCS非标路径问题

由于历史原因,笔者公司的gitlab服务地址就是非标准的路径,标准路径应该是: https://private.vcs.com,而笔者公司的gitlab路径则是: https://private.vcs.com:888.

如果按go get流程,import包路径应该采用d:private.vcs.com:888/group/foo,就可以正确匹配出该仓库的合理地址了。但是很不幸,在实际操作中,失败告终。具体原因读者可以自行测试一下。

此时唯一的办法,就是搭建一个中间服务:https://private.vcs.com 能够通过go get的包路径匹配查询正确的仓库地址。