用了近半年的 Go,真是有种相见恨晚的感觉。简洁的语法、完善并强大的开发工具链,省去新手不少折腾的时间,可以专注写代码。
这期间也掌握了不少技巧与惯用法(idioms),比如 prosumer,就是一个踩了chan/timer 一些坑后实现生产者/消费者模式框架。但今天不去谈 chan 的使用方式,而是来谈一个最基本的问题,Go 代码应该放在哪里,其实也就是 Go 的包管理机制,有时也称为版本化,其实是一个意思。这看似简单,却让不少 Gopher 吃了不少苦头。本文就来介绍下 Go 的包管理机制,主要会包含以下内容:
- 包管理的历史
- 新的包管理方式 module(简称 mod、模块)
- 问题排查,彻底解决如何放置 Go 代码的问题
GOPATH
在 mod 出现之前,所有的 Go 项目都需要放在同一个文件夹内: $GOPATH/src
|
|
相比其他语言,这个限制有些无法理解。其实,这和 Go 的一设计理念紧密相关:
包管理应该是去中心化的
所以 Go 里面没有 maven/npm 之类的包管理工具,只有一个 =go get=,支持从公共的代码托管平台(Bitbucket/GitHub..)下载依赖,当然也支持自己托管,具体可参考官方文档:Remote import paths。
由于没有中央仓库,所以 Go 项目位置决定了其 import path,同时为了与 go
get 保持一致,所以一般来说我们的项目名称都是 github.com/user/repo
的形式。
当然也可以不是这种形式,只是不方便别人引用而已,后面会讲到如何在 go mod
中实现这种效果。
百花齐放
使用 go get
下载依赖的方式简单暴力,伴随了 Go 七年之久,直到 1.6(2016/02/17)才正式支持了 vendor,可以把所有依赖下载到当前项目中,解决可重复构建(reproducible
builds)的问题,但是无法管理依赖版本。社区出现了各式各样的包管理工具,来方便开发者固化依赖版本,由于不同管理工具采用不同的元信息格式(比如:godep 的 Godeps.json、Glide 的 glide.yaml),不利于社区发展,所以 Go 官方推出了 dep。
dep 的定位是实验、探索如何管理版本,并不会直接集成到 Go 工具链,Go 核心团队会吸取 dep 使用经验与社区反馈,开发下一代包管理工具 module,并于 2019/09/03 发布的 1.13 正式支持,并随之发布 Module Mirror, Index,Checksum,用于解决软件分发、中间人攻击等问题。下图截取自 Go 官方博客
模块
模块(Module)是多个 package 的集合,版本管理的基本单元,使用 go.mod 文件记录依赖。go.mod 位于项目的根目录,主要有四类命令:module、require、replace、exclude。一个简单的示例如下:
|
|
module
module 指令声明当前模块的 module path,一个模块内所有包(package)的引入路径( import path)都以它为前缀。假如一个模块有如下目录结构:
|
|
go.mod 为上面的示例代码,那么 bar 包的引入路径则为:
|
|
可以看到,这里没有声明模块的版本,版本信息是通过 Git 的 tag 来实现的,采用类似 v(major).(minor).(patch)
的语义化版本,比如:v0.1.0。
语义化版本要求 v1 及以上的版本保证向后兼容,当有 breaking change 时,需要改变大版本号,变成 v2、v3 等,其他项目在使用时,需要把版本信息体现在 module path 中,比如
|
|
下面举一个语义化版本解决依赖冲突的例子:
从上图可以看的,A 通过直接或间接依赖 D 的三个不同版本,但由于大版本中,会把版本号放在module path 中,因此 Go 可以正确解析使用的版本。
对于没有 tag 的模块,Go 采用了 pseudo_versions,即伪版本,形式如下:
v0.0.0-yyyymmddhhmmss-abcdefabcdef
中间的时间采用 UTC 表示,用于对比两个伪版本的新旧;最后的部分为 commit id 前 12 个字符。
require
require 指令声明模块的依赖。下面截取 prometheus 的部分 go.mod 来介绍 require 的用法:
|
|
replace
replace 主要用来替换依赖的 module path,该指令仅对主模块生效,当模块作为依赖被使用时,会被忽略掉。
replace directives only apply in the main module’s go.mod file and are ignored in other modules.
说明:主模块是指调用 go 命令时所在的模块。
replace 对于引用本地依赖非常有帮助。比如有一个库 github.com/user/lib
有 bug,需要 fork 到本地去修复,这时需要在项目中引用本地 fork
的版本,那么可以这么用:
|
|
代码里面的引入路径不需要变。类似的原理,项目的 module path 也不必加上托管平台前缀了。笔者创建了一个示例项目 strutil,其 go.mod 如下:
|
|
如果要引用这个项目,只需要这么做:
|
|
exclude
exclude 指令用于排除当前模块的某项依赖,用的比较少,主要用来排除有重大 bug 时的版本。同 replace 一样,也只作用在主模块上。
在上图中,C 1.3 版本被 exclude 了,正好 C 有 1.4 版本,因此 Go 就选择了这个版本。
Go 在编译时选择模块版本的算法是:Minimal version selection (MVS),细节可参考 research!rsc: Minimal Version Selection (Go & Versioning, Part 4),这里不再赘述。
常用命令
对于使用模块开发的项目,使用 go mod init {moduleName}
初始化后,直接在源文件中 import 所需包名,go test/build
之类的命令会自动分析,将其加到 go.mod 中的 require
里面,不需要自己去修改,当然也可以采用 go get xxx@version
的方式安装指定版本的依赖。
开发测试完成后,如果想做为 lib 分发,最好打上语义化的 tag,项目的版本号一般从 v0.1.0 开始,表示开始第一个 feature,当有 bugfix 时,变更第三个版本号,如 v0.1.1;当有新 feature 时,变更中间版本号,如 v0.2.0;有 breaking changes 时,变更第一个版本号,比如 v2.0.0。
对于 v2 及以上版本,一般有两种目录组织方式,一是直接在项目根目录的 go.mod 中的 module path 中增加 v2
后缀,例如: github.com/my/module/v2
;二是建一个子目录
v2
,把相关代码拷贝过来,在这个目录下创建 go.mod,module path
与第一种相同。最后再打上 v2.x.x
的 tag 即可,参考示例:
|
|
很明显,第二种目录方式方便同时维护多个版本。
除此之外,使用模块系统时,一般还需配置如下相关变量:
|
|
关于模块校验的更多内容,可参考:
其它常用命令有:
go mod download -json
查看模块详细信息
type Module struct { Path string // module path Version string // module version Error string // error loading module Info string // absolute path to cached .info file GoMod string // absolute path to cached .mod file Zip string // absolute path to cached .zip file Dir string // absolute path to cached source root directory Sum string // checksum for path, version (as in go.sum) GoModSum string // checksum for go.mod (as in go.sum) }
go list -m all
查看模块最终依赖的版本go list -u -m all
查看依赖的新版本go get -u ./...
更新所有依赖到最新版go get -u=patch ./...
更新所有依赖到最新的 patch 版本go mod tidy
清理 go.mod/go.sum 中不在需要的 modulego mod vendor
创建 vendor 依赖目录,这时为了与之前做兼容,后面在执行 go test/build 之类的命令时,可以加上-mod=vendor
这个 build flag 声明使用 vendor 里面的依赖,这样 go mod 就不会再去$GOPATH/pkg/mod
里面去找。
问题排查
在使用模块系统时,笔者遇到过一个问题,之前很是困扰,后来才发现是没清楚 go 命令参数的含义。这里分享给大家。首先看下项目目录结构:
|
|
可以看到,项目根目录 ceresdb-go-sdk 内没有 Go 源码,而是放在了 ceresdb 子目录中,这么设计的目的是保证 import path 与目录名一致
|
|
当然也可以去掉 ceresdb 目录,将源码放在项目根目录下,这样 import 就变为
|
|
感觉不是很友好,有些 IDE 比较智能,会自动填充 alias
|
|
但现在还是假设采用子目录的方式,执行 go test ./...
|
|
对于这个错误有些懵,我这个项目不就是 github.com/user/ceresdb-go-sdk 嘛,怎么会报找不到呢?为了解释原因,需要了解下 go 命令的一般形式:
go command [command_args] [build flags] [packages]
- command 是指 test/build/mod/list 之类命令
- command_args 是指特定命令相关的参数
build flags 是大部分命令都支持的参数,比较常用的有:
-race
开启 race 检测-v
输出正在编译的包名-x
输出详细执行的命令,在 go get 卡住时可以打开定位问题-mod
指定 module 依赖下载模式,目前只有两个值:readonly 或者 vendor-tags
逗号分隔编译 tags,主要用于区别 build 环境,比如集成测试
- packages 指定当前命令作用的包
上面的错误就出错在最后一个参数上,我们知道包的 import path 需要加上 module path,上面的错误也证明了这一点。 特殊的:
./xxx
会去找对应目录下面的包./...
递归地找当前目录下的所有包,且包括当前目录
问题就在于项目根目录下没有任何 Go 源文件,所以就找不到当前目录下的包了!解决方法也很简单:
go test ./ceresdb # go test github.com/user/ceresdb-go-sdk/ceresdb 当然也可以 ... PASS ok github.com/user/ceresdb-go-sdk/ceresdb 0.017s
或者,在根目录下加一个 Go 文件
# 包名可以和 import path 不一致,所以这里 abc 也是可以的 $ echo 'package abc' > abc.go $ go test . ? github.com/user/ceresdb-go-sdk [no test files]
这样也不会报错了。
总结
通过本文的介绍,希望让大家更清楚了解 Go 模块系统的设计初衷以及使用方式,自己在设计系统时,遵循语义化版本规则,大版本向后兼容。如果项目比较复杂,可参考下面的项目结构:
Clojure 作者 Rich Hickey 有个有名的演讲 Simple Made Easy,主要讲述了可以通过简单的工具来降低软件开发的复杂度,Go 作者 Rob Pike 在 2015 年的一个 talk Simplicity is Complicated 中也指出,Go 成功的一大原因源自其简单易用的特性。