何处安放我们的 Go 代码

发布: 2019-10-24   上次更新: 2022-03-18   分类: 编程语言   标签: Go

文章目录

用了近半年的 Go,真是有种相见恨晚的感觉。简洁的语法、完善并强大的开发工具链,省去新手不少折腾的时间,可以专注写代码。

这期间也掌握了不少技巧与惯用法(idioms),比如 prosumer,就是一个踩了chan/timer 一些坑后实现生产者/消费者模式框架。但今天不去谈 chan 的使用方式,而是来谈一个最基本的问题,Go 代码应该放在哪里,其实也就是 Go 的包管理机制,有时也称为版本化,其实是一个意思。这看似简单,却让不少 Gopher 吃了不少苦头。本文就来介绍下 Go 的包管理机制,主要会包含以下内容:

https://img.alicdn.com/imgextra/i4/581166664/O1CN01CCdfct1z69wzuInE9_!!581166664.png
Go package manager

GOPATH

在 mod 出现之前,所有的 Go 项目都需要放在同一个文件夹内: $GOPATH/src

1
2
3
4
5
6
7
8
9
$GOPATH/src
    github.com/golang/example/
        .git/                      # Git repository metadata
    outyet/
        main.go                # command source
        main_test.go           # test source
    stringutil/
        reverse.go             # package source
        reverse_test.go        # test source

相比其他语言,这个限制有些无法理解。其实,这和 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 官方博客

https://img.alicdn.com/imgextra/i3/581166664/O1CN01NxbOES1z69wxSzgy2_!!581166664.png_620x10000.jpg
Module Big Picture

模块

模块(Module)是多个 package 的集合,版本管理的基本单元,使用 go.mod 文件记录依赖。go.mod 位于项目的根目录,主要有四类命令:module、require、replace、exclude。一个简单的示例如下:

1
2
3
4
5
6
module github.com/my/repo

require (
    github.com/some/dependency v1.2.3
    github.com/another/dependency/v4 v4.0.0
)

module

module 指令声明当前模块的 module path,一个模块内所有包(package)的引入路径( import path)都以它为前缀。假如一个模块有如下目录结构:

1
2
3
4
5
6
repo
|-- bar
|   `-- bar.go
|-- foo
|   `-- foo.go
`-- go.mod

go.mod 为上面的示例代码,那么 bar 包的引入路径则为:

1
import "github.com/my/repo/bar"

可以看到,这里没有声明模块的版本,版本信息是通过 Git 的 tag 来实现的,采用类似 v(major).(minor).(patch)语义化版本,比如:v0.1.0。

https://img.alicdn.com/imgextra/i4/581166664/O1CN01bk1zqT1z69wz301hZ_!!581166664.png_620x10000.jpg
语义化版本

语义化版本要求 v1 及以上的版本保证向后兼容,当有 breaking change 时,需要改变大版本号,变成 v2、v3 等,其他项目在使用时,需要把版本信息体现在 module path 中,比如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# go.mod 声明
module github.com/my/mod/v2

require github.com/my/mod/v2 v2.0.1

# go 代码中使用方式
import "github.com/my/mod/v2/mypkg"

# 安装 go 代码
go get github.com/my/mod/v2@v2.0.1

下面举一个语义化版本解决依赖冲突的例子:

https://img.alicdn.com/imgextra/i3/581166664/O1CN01ZyOLHg1z69wz3Le3i_!!581166664.png_310x310.jpg
dependency hell

从上图可以看的,A 通过直接或间接依赖 D 的三个不同版本,但由于大版本中,会把版本号放在module path 中,因此 Go 可以正确解析使用的版本。

对于没有 tag 的模块,Go 采用了 pseudo_versions,即伪版本,形式如下:

  • v0.0.0-yyyymmddhhmmss-abcdefabcdef

中间的时间采用 UTC 表示,用于对比两个伪版本的新旧;最后的部分为 commit id 前 12 个字符。

require

require 指令声明模块的依赖。下面截取 prometheus 的部分 go.mod 来介绍 require 的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
module github.com/prometheus/prometheus

go 1.13

require (
    # indirect 表示间接依赖自动生成
    cloud.google.com/go v0.44.1 // indirect
    k8s.io/klog v0.4.0
    # 这里采用 pseudo_versions 表示没有采用 go module且没有打版本 tag
    github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4
    # incompatible 表示 go-autorest 没有采用 go module而且版本大于 v2
    github.com/Azure/go-autorest v11.2.8+incompatible
    github.com/aws/aws-sdk-go v1.23.12
)

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 的版本,那么可以这么用:

1
replace github.com/user/lib => /some/path/on/your/disk

代码里面的引入路径不需要变。类似的原理,项目的 module path 也不必加上托管平台前缀了。笔者创建了一个示例项目 strutil,其 go.mod 如下:

1
2
3
module strutil

go 1.12

如果要引用这个项目,只需要这么做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// go.mod
replace strutil => github.com/jiacai2050/strutil v0.0.1

// str_test.go
import strutil

func TestModule(t *testing.T) {
    s := strutil.Reverse("hello")
    assert.Equal(t, "olleh", s)
}

exclude

exclude 指令用于排除当前模块的某项依赖,用的比较少,主要用来排除有重大 bug 时的版本。同 replace 一样,也只作用在主模块上。

https://img.alicdn.com/imgextra/i1/581166664/O1CN015oxndL1z6A7xXSDST_!!581166664.jpg
exclude 工作示意图

在上图中,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 即可,参考示例:

1
2
3
4
5
6
7
.
├── go.mod          // module github.com/jiacai2050/strutil
├── string.go
├── string_test.go
└── v2
    ├── go.mod      // module github.com/jiacai2050/strutil/v2
    └── string.go

很明显,第二种目录方式方便同时维护多个版本。

除此之外,使用模块系统时,一般还需配置如下相关变量:

1
2
3
4
5
6
7
8
9
# 1.13 默认开启
export GO111MODULE=on
# 1.13 之后才支持多个地址,之前版本只支持一个
export GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy,direct
# 1.13 开始支持,配置私有 module,相当于同时设置 GONOPROXY GONOSUMDB ,表示不走代理,不检查 checksum
export GOPRIVATE=*.corp.example.com,rsc.io/private
# 关闭 checksum 校验,一般不需要设置,通过 GOPRIVATE 进行细粒度控制
# go get -insecure .. 是也不会检查 checksum
GOSUMDB=off

关于模块校验的更多内容,可参考:

其它常用命令有:

  • 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 中不在需要的 module
  • go mod vendor 创建 vendor 依赖目录,这时为了与之前做兼容,后面在执行 go test/build 之类的命令时,可以加上 -mod=vendor 这个 build flag 声明使用 vendor 里面的依赖,这样 go mod 就不会再去 $GOPATH/pkg/mod 里面去找。

问题排查

在使用模块系统时,笔者遇到过一个问题,之前很是困扰,后来才发现是没清楚 go 命令参数的含义。这里分享给大家。首先看下项目目录结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
cd ~/code/ceresdb-go-sdk
$ tree
.
├── Makefile
├── ceresdb
│   ├── client.go
│   ├── client_test.go
├── examples
│   ├── README.md
│   └── quickstart.go
├── go.mod
└── go.sum

$ cat go.mod
module github.com/user/ceresdb-go-sdk
require ( ... )

可以看到,项目根目录 ceresdb-go-sdk 内没有 Go 源码,而是放在了 ceresdb 子目录中,这么设计的目的是保证 import path 与目录名一致

1
2
import "github.com/user/ceresdb-go-sdk/ceresdb"
c := ceresdb.NewClient(...)

当然也可以去掉 ceresdb 目录,将源码放在项目根目录下,这样 import 就变为

1
2
import "github.com/user/ceresdb-go-sdk"
c := ceresdb.NewClient(...)

感觉不是很友好,有些 IDE 比较智能,会自动填充 alias

1
2
# 这也说明包名可以不和目录一致但是一般都要保证一致否则会很具备迷惑性
import ceresdb "github.com/user/ceresdb-go-sdk"

但现在还是假设采用子目录的方式,执行 go test ./...

1
2
3
4
5
6
7
time go test -v -x  ./...
can't load package: package github.com/user/ceresdb-go-sdk: unknown import path "github.com/user/ceresdb-go-sdk": cannot find module providing package github.com/user/ceresdb-go-sdk

# 注意花的时间,这期间没有任何输出,即使加了 -x flag
real    1m2.472s
user    0m0.111s
sys 0m0.082s

对于这个错误有些懵,我这个项目不就是 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 成功的一大原因源自其简单易用的特性。

https://img.alicdn.com/imgextra/i1/581166664/O1CN012w8eQ11z69x0Nk2xz_!!581166664.jpg
Simplicity

评论

欢迎读者通过邮件与我交流,也可以在 MastodonTwitter 上关注我。