如何从Go Dep迁移到Go Modules

110 阅读8分钟

你喜欢怪异的构建问题吗?或者你认为我们在这篇博文中做的事情有猫腻,你想修复它?想让我们用bazel来代替?好消息--我们已经为你找到了一个角色

我们正在寻找更多的工程师加入我们的开发基础设施团队随着我们发展到更多的人和更大的代码库,以及新的和令人兴奋的功能和云管理,我们正在寻求扩大我们无序的开发基础设施。如果你想通过授权工程师的工作环境来帮助他们编写令人兴奋的下一代数据库,不要拖延,今天就申请吧

--

在我们历史上的重要部分,CockroachDB使用depvendoring工具来管理包的依赖性。Go模块从Go1.11开始就被广泛使用,随着Go1.14大力推荐迁移以及依赖库向Go模块世界迁移,CockroachDB也是时候跟进了。

然而,对于CockroachDB来说,迁移到go.mod并不是一件简单的事情。所有可能使升级复杂化的东西都出现在我们面前。我们面临的问题有:无法导入供应商的protobuf文件,识别隐性升级和降级,臭名昭著的error writing go.mod 错误等等。在软件工程师最糟糕的噩梦中,我们很难找到并应用StackOverflow的答案、Github的问题以及适用于我们情况的围棋文档。

好奇吗?请关注我们的博文,我们将通过 "迁移到Go模块"页面的指导来探索迁移到Go模块。

从Dep到Go模块的初始迁移

我们的第一步是运行migrate工具。

go mod init github.com/cockroachdb/cockroach

该工具将我们的Gopkg.lock文件迁移到与之对应的go.mod和go.sum。然而,go.mod文件在goimports工具中失败了,因为有这样几行。

replace github.com/grpc-ecosystem/grpc-gateway 52697fc4a24978380c5ad7b80adc795336d4dfd4 => github.com/cockroachdb/grpc-gateway v1.14.6-0.20200519165156-52697fc4a249

这导致Go的linter向我们发出了以下失败的警告。

replace github.com/grpc-ecosystem/grpc-gateway: version "52697fc4a24978380c5ad7b80adc795336d4dfd4" invalid: must be of the form v1.2.3

有趣的是。在我们的Gopkg.toml中,我们已经将grpc-ecosystem/grpc-gateway定义为附加到我们分叉中的一个特定分支。

[[constraint]]
  name = "github.com/grpc-ecosystem/grpc-gateway"
  branch = "v1.14.5-nowarning"
  source = "https://github.com/cockroachdb/grpc-gateway"

但错误信息给了我们线索--52697fc4a24978380c5ad7b80adc795336d4dfd4 组件实际上是不相干的!删除所有像这样的行中的第三个参数就解决了这个问题。

replace github.com/grpc-ecosystem/grpc-gateway => github.com/cockroachdb/grpc-gateway v1.14.6-0.20200519165156-52697fc4a249

go.mod 文件仍然有问题--即模块导入被定义了两次:一次在require go.mod 段落,一次在replace 指令。下面是require 章节中重复定义 "github.com/cockroachdb/grpc-gateway "的一个例子。

require (
   ...
   github.com/cockroachdb/grpc-gateway v1.14.6-0.20200519165156-52697fc4a249
   ...
)

每当我们试图用Go构建任何东西时都会出错。

go: github.com/cockroachdb/grpc-gateway@v1.14.6-0.20200519165156-52697fc4a249: parsing go.mod:
	module declares its path as: github.com/grpc-ecosystem/grpc-gateway
	        but was required as: github.com/cockroachdb/grpc-gateway

删除require 章节中的上述一行(以及我们其他有replace 的包),解决了这个问题。

欢呼吧!现在我们在尝试go build 时得到了一些更有趣的输出。 然而,从这一点上来说,我们已经没有更多的迁移指南步骤可以用来解决我们的问题了......

遇到了写go.mod的错误

之后,我们应该可以使用Go模块构建CockroachDB包。然而,我们遇到了以下错误。

error writing go.mod: open /Users/otan/go/pkg/mod/github.com/lib/pq@v1.3.1-0.20200116171513-9eb3fc897d6f/go.mod298498081.tmp: permission denied

这些似乎是经常遇到的错误(从谷歌快速搜索的许多不同结果来看),而且似乎很诱人,可以盲目地使用chmod 来解决。但要忍住这种诱惑--它在其他人的系统上是行不通的!

我们发现这些错误似乎是代码试图修改我们的$GOPATH/pkg/mod目录的结果,所有go.mod模块都被下载到这个目录。这个目录被明显地设置为只读,以防止损坏。这是有道理的--你不希望另一个Go模块修改你系统中的缓存共享模块。

在我们的案例中,我们发现go/loader 已经废弃,应该升级为go/packages - 否则,它将加载并改变模块上的文件。这涉及到改变我们的一些linters,例如我们的returncheck linter

使用protobuf和Go模块中定义的C文件

CockroachDB导入了某些需要protobufs的库,同时也依赖于某些出售的目录内的protobuf定义。我们新的构建错误似乎都指向了这样一个事实:我们无法导入protobuf文件,而这些文件似乎不能从我们之前定义的供应商目录中导入。我们意识到这是因为当前目录或用-I标志指定的目录中的protobufs(protoc)的文件生成器。这在Go模块中更为冗长,因为导入的目录名中有@sha/version的后缀。举个例子,我们需要做-I$GOPATH/pkg/mod/github.com ,在.proto文件中导入prometheus/client_model@v0.2.0/metrics.proto 。在使用dep时,我们使用 -I./vendor/github.com所以我们可以简单地导入prometheus/client_model/metrics.proto ,而不需要版本的概念。需要在protobuf路径中包含Go模块的后缀可能是一个繁琐的变化--特别是在升级过程中,这可能是一个容易被遗忘的更新。

这就是 "Go模块 "的用例之一。 go mod vendor的用例之一,它将模块文件从$GOPATH/pkg/mod复制到当前Go repo中的 "vendor "目录。理论上,这基本上应该使Go模块与dep存储vendored模块的地方非常相似。在 Go 1.13 或之前,如果任何 Go 命令有-mod=vendor 作为标志(例如go build -mod=vendor .go test -mod=vendor . ),Go 会在 vendor 目录中寻找模块。要记住在所有地方都包含这个标志是很痛苦的,但在Go 1.14中解决了这个问题,因为如果检测到供应商目录,标志-mod=vendor 会隐含地添加到任何Go命令中。

然而,另一个问题出现了--如果同一目录下没有Go文件,protobuf文件似乎不会被复制到供应商目录中。特别是在github.com/grpc-ecosys…github.com/grpc-ecosys…github.com/prometheus/…,我们遇到了protobuf文件丢失的问题。这是因为Go并没有检测到这些软件包的任何Go依赖性,因此在运行go mod vendor 时并不试图将其复制过来。因此,我们调整并采用了modvendor工具,将这些额外的目录从$GOPATH/pkg/mod目录复制到vendor目录中。

go mod vendor的另一个用例

为了支持交叉编译CockroachDB,我们在包含C文件的目录中添加了额外的.zcgo_flags.go 文件,以告诉Go编译器知道在哪里根据正在编译的操作系统来链接某些库。例如,在knz/go-libedit中,当在OSX上编译时,我们在vendor/github.com/knz/go-libedit/unix/zcgo_flags.go中生成以下(.gitignore'd)文件。

// GENERATED FILE DO NOT EDIT

// +build !make

package libedit_unix

// #cgo CPPFLAGS: -I/Users/otan/go/native/x86_64-apple-darwin19.5.0/jemalloc/include
// #cgo LDFLAGS: -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/cryptopp -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/protobuf -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/jemalloc/lib -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/snappy -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/libedit/src/.libs -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/rocksdb -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/libroach -L/Users/otan/go/native/x86_64-apple-darwin19.5.0/proj/lib
import "C"

它告诉 clang 编译器在编译 libedit 时要链接 libedit/src/.libs。由于包含这些模块的$GOPATH/pkg/mod目录是只读的,所以生成的文件必须用go mod vendor 放到供应商目录中。在这样做的时候,我们需要确保用-mod=vendor (适用于Go 1.13或之前)编译cockroach二进制文件,否则就会出现链接错误。

在这一点上,我们已经完成了所有的构建工作!是时候进行go.mod迁移清单的下一步了:运行go mod tidy 。这将清理在go.mod和go.sum中徘徊的任何未使用的导入。

不幸的是,go mod tidy 让一些软件包消失了特别是,消失的软件包似乎与我们运行的工具有关,我们在Gopkg.toml和Gopkg.lock文件中把这些工具固定在一个特定的版本上。我们没有导入这些工具,而是直接从我们的Makefile中安装和运行它们。由于我们没有明确地导入这些工具,go mod tidy 很高兴地将这些条目清理掉。在模块被清理后,任何后续的go install ,都不会被钉在我们之前指定的版本上。

因此,为了使用一个被钉住的、出售的工具,我们必须确保它不会从go.mod中消失。我们创建了一个文件,用空白导入导入所需的工具,以确保这些依赖关系不会被删除。这看起来像下面这样(见源文件)。

// +build tools

package main

import (
	"fmt"

	"github.com/client9/misspell/cmd/misspell"
	"github.com/cockroachdb/crlfmt"
	"github.com/cockroachdb/go-test-teamcity"
	"github.com/cockroachdb/gostdlib/cmd/gofmt"
	"github.com/cockroachdb/gostdlib/x/tools/cmd/goimports"
	"github.com/cockroachdb/stress"
	"github.com/goware/modvendor"
	"github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway"
	"github.com/kevinburke/go-bindata/go-bindata"
	"github.com/kisielk/errcheck"
	"github.com/mattn/goveralls"
	"github.com/mibk/dupl"
	"github.com/mmatczuk/go_generics/cmd/go_generics"
	"github.com/wadey/gocovmerge"
	"golang.org/x/lint/golint"
	"golang.org/x/perf/cmd/benchstat"
	"golang.org/x/tools/cmd/goyacc"
	"golang.org/x/tools/cmd/stringer"
	"golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow"
	"honnef.co/go/tools/cmd/staticcheck"

)

注意那个+build tools 指令,它告诉Go只在Go命令中提供了tools 标签的情况下才构建软件包(例如:go build -tags 'tools' )。在实践中,我们从未设置tools 标签,因此该文件从未被构建或包含在任何软件包中。

隐式升级和降级

当使用go mod init 从dep迁移到Go模块时,我们发现在运行go buildgo mod tidy 之后,有些模块被隐式升级,有些模块被隐式降级。

作为一个例子,以Gopkg.lock中的github.com/linkedin/go…为例,但在Gopkg.toml中没有出现。

[[projects]]
  digest = "1:6ff6c3cb744b42df69cb874c3f19d387ece7ba7998e7d4de811c9bf61a7b1a09"
  name = "github.com/linkedin/goavro"
  packages = ["."]
  pruneopts = "UT"
  revision = "af12b3c46392134a7db8c1a8b6c6a33419fab0ea"
  version = "v2.7.2"

在运行go mod init ,这个导入在我们的迁移后从go.mod中完全消失了。当运行go build ,它检测到github.com/linkedin/goavro 并不存在,而是重新出现了一个较早的版本,巧合的是这个版本正好在 repo 迁移到 Go modules 之前。

require (
   ...
   github.com/linkedin/goavro v2.1.0+incompatible
   ...
)

这是一个不希望出现的库的降级。这似乎是几个Go模块包的一个趋势。

[[projects]]
  digest = "1:0981502f9816113c9c8c4ac301583841855c8cf4da8c72f696b3ebedf6d0e4e5"
  name = "github.com/mattn/go-isatty"
  packages = ["."]
  pruneopts = "UT"
  revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c"
  version = "v0.0.4"

这在最初的go.mod中得到了尊重。

require (
   …
   github.com/mattn/go-isatty v0.0.4
   …
)

但是在运行go buildgo mod tidy 的时候,有些东西将模块的版本改为v0.0.9。

require (
   …
   github.com/mattn/go-isatty v0.0.9
)

这似乎是因为这个导入被几个依赖包使用,它们更喜欢最新的版本。

我们本可以走尝试把所有东西都固定下来的路线,以避免在旧的Gopkg.toml上的供应商变化。然而,我们决定挥手通过所有的升级和降级,因为即使我们钉住了所有的东西,在从dep到Go模块之后,模块的依赖性可能仍然会发生变化,因为一些包仍然需要升级以正确支持Go模块(我们发现一些包在某些版本中会引起Go模块的问题,这时需要进行版本升级)。

为了确保在这些隐含的升级和降级中挥洒自如,我们想确保所有这些升级和降级都被系统地审计。为了做到这一点,我们写了一个脚本,将 vendor/modules.txt 中列出的 SHA 和版本的内容与旧的 Gopkg.lock 进行比较。

我们用vendor/modules.txt来检测软件包的变化,而不是go.sum和go.mod,因为vendor/modules.txt似乎包含了我们对每个依赖关系使用的模块的确切版本。go.sum的数据太多,而go.mod的数据太少。比较旧的vendor目录和新的vendor目录是站不住脚的,因为成千上万行的代码和文件都发生了变化。

为了说明go.mod、go.sum和vendor/modules.txt之间的差异,我们来比较一下。这里是gogo/protobuf的vendor/modules.txt条目。

# github.com/gogo/protobuf v1.3.1 => github.com/cockroachdb/gogoproto v1.2.1-0.20190102194534-ca10b809dba0

将其与go.sum文件进行比较,该文件包含同一软件包的许多条目。这是因为我们的依赖项都有一个对不同版本的protobuf库的依赖。

github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=

在go.mod下,可能不清楚一些隐含的导入可以通过惊喜的方式包含到vendor/modules.txt。举个例子,github.com/russross/bl…的导入被包含在vendor/modules.txt中,尽管在go.mod中没有出现,而且被一个主包明确地使用。值得注意的是,在Go 1.14中,显式导入在vendor/modules.txt中会在导入的后面标注一行## explicit ,以帮助推理隐式与显式导入的定义,即在go.mod中被提及。

# github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563
## explicit
github.com/rcrowley/go-metrics
github.com/rcrowley/go-metrics/exp
# github.com/russross/blackfriday v1.5.2
github.com/russross/blackfriday

从脚本中转出的版本变化被转化为GitHub上的问题清单,由工程师进行审核。通过良好的集成测试和人工审核,我们在隐式升级或降级引起的80多个导入变化中,发现了4个不需要的行为变化,需要采取进一步的行动。我们不确定如何在不做go mod vendor 和窥视vendor/modules.txt文件的情况下重现相同的列表。

其中一些涉及到将导入定义更新为与Go模块等价的版本。特别是,我们必须将 "github.com/cockroachdb/apd "的所有导入改为 "github.com/cockroachdb/apd/v2",将 "github.com/linkedin/goavro "改为 "github.com/linkedin/goavro/v2"。这导致在PR中出现了相当多的文件变化,这些变化大多只是导入变化,导致了更大的和不太集中的代码审查。不幸的是,在软件包导入中指定版本号在Go模块之前是行不通的,因此我们可以作为一个单独的提交/PR来做。

为什么我们在go.sum中仍然看到某些包,尽管从未导入过它们?

尽管将 "github.com/cockroachdb/apd "升级为 "github.com/cockroachdb/apd/v2",我们仍然在go.sum中发现 "github.com/cockroachdb/apd",尽管没有出现在go.mod中。这让我们很困惑。

然而,go mod why 工具告诉了我们原因。

$ go mod why github.com/cockroachdb/apd
...

# github.com/cockroachdb/apd

github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/cdctest
github.com/jackc/pgx
github.com/jackc/pgx.test
github.com/cockroachdb/apd

似乎github.com/jackc/pgx的单元测试依赖于被废弃的包,将其拉入go.sum。然而,这个包并没有出现在vendor/modules.txt或者vendor目录中,因为我们并不要求pgx测试在CockroachDB的任何部分被编译。

可重现的构建和Go模块缓存

对于dep,我们将供应商目录存储在一个名为 "vendored"的 repo中,使用CockroachDB repo上的git子模块来导入该目录。这允许可重复的构建,而不需要将所有的供应商目录内容检查到CockroachDB repo中(作为一个副作用,使那些导入的git贡献统计更有意义;)。更多的原理和维护说明可以在这里找到。

在Go模块的世界里,这是通过Go模块缓存实现的它将模块源文件复制到$GOPATH/pkg/mod。特别是,存储在go.sum中的所有版本会从Go模块缓存中下载任何不同来源的完全相同的版本。然而,我们对此有顾虑,例如。

  • 如果一个Go模块变得不可用或被删除会怎样?它是留在缓存中还是被删除?如何检查缓存历史?
  • 如果一个模块使用相同的版本标签,但改变了SHA,会有什么行为。我们认为这在Github上是不可能的,但在其他地方可以吗?

这些问题对我们来说是个障碍,因为我们有可能在未来的某个时间点无法在任何SHA上得到完全可重复的构建。我们可以通过托管和拥有自己的Go模块缓存(如goproxy/goproxy)来减轻这种担忧,但这也带来了自己的操作复杂性和我们基础设施的额外工作。

因此,我们选择了维护供应商的 repo,使用目前的解决方案,即使用git/Github作为我们的 "Go模块缓存"。这样就能以一种我们觉得舒服的方式保留可重复的构建。这有一个额外的好处,即不必在每次vendored软件包改变时运行go mod vendor ,这与简单地拉入子模块的更新相比可能需要一段时间。

然而,用go mod vendor 维护一个git repo是很棘手的,因为它在重新创建供应商目录之前会擦除它。因此,我们将.git 中的git对象移到一个单独的目录中,然后在go mod vendor 完成后再将其移回。因此,我们的供应商目录重建脚本看起来像下面这样。

#!/usr/bin/env bash

set -Eeoux pipefail

TMP_VENDOR_DIR=.vendor.tmp.$(date +"%Y-%m-%d.%H%M%S")

# restore the vendor directory if any of the below steps fail
trap restore 0
function restore() {
  if [ -d $TMP_VENDOR_DIR ]; then
    rm -rf vendor
    mv $TMP_VENDOR_DIR vendor
  fi
}

mv vendor $TMP_VENDOR_DIR
go mod vendor
modvendor -copy="**/*.c **/*.h **/*.proto"  -include 'github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis/google/api,github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis/google/rpc,github.com/prometheus/client_model'
mv $TMP_VENDOR_DIR/.git vendor/.git
rm -rf $TMP_VENDOR_DIR

这个脚本只需要在模块变更后运行。关于我们在改变Go模块时的工作流程,更详细的说明可在这里找到。

我们从dep到Go模块迁移的步骤总结

为了从dep迁移到Go模块,我们需要执行以下步骤。

  • 运行go mod init github.com/cockroachdb/cockroach
  • 删除所有替换指令的第三个参数。
  • require 章节中删除我们需要替换的库。
  • 将所有使用 go/loader 的地方改为 go/packages。
  • 运行go mod vendor
  • 使用modvendor来包括在vendor目录中没有复制过来的protobuf文件。在这一点上,我们可以开始构建编译二进制文件,用go build -mod=vendor ...
  • 添加一个文件,导入所有我们需要钉在特定版本的工具。
  • 运行go mod tidy
  • 审核所有在运行go mod init 时发生的隐式降级和升级,必要时重新升级和重新降级软件包。
  • 更新那些需要在代码库中修改导入定义的软件包,以使用Go模块的版本。
  • (仅限Go 1.13)更新所有脚本,使用-mod=vendor ,以确保一切从正确的目录导入。

结果

cockroachdb/cockroach#49447开始,我们已经转移到了Go模块,Twitter社区对此给予了高度评价。

使用go mod vendor ,我们在处理出售的软件包方面的一般 "怪异 "现象大部分得到了缓解。它能够处理protobufs,我们能够将额外的文件注入其中,我们能够将供应商目录作为git子模块提交。此外,我们还成功地快速跟进并升级到Go 1.14,以减轻需要记住添加-mod=vendor 标志来编译和测试我们的包(但由于Go 1.14的定时器问题,可能需要降级)。

总的来说,与使用dep ensure 相比,Go模块的使用在速度上要顺畅得多,而且模块的管理感觉更直观。能够同时使用同一模块的不同版本,也使某些软件包迁移到较新的模块更容易。

然而,我们发现对于那些迁移的人来说,Go模块可能会很复杂,让人不知所措--特别是对于大型复杂的代码库来说--这一点从我们比预期更多的迁移过程中可以看出。这里有几个步骤在 "迁移到Go模块"文档页面中并不明显。然而,阅读Go mod 参考页golang.org 上关于 Go 模块的整个博文作为基础步骤是很有用的。如果不深入了解Go模块系统,就很难解决一些构建错误和所需的软件包升级问题,而这些文章提供了这方面的见解。

我们正在为我们的开发者基础设施团队招聘人员

你喜欢怪异的构建问题吗?或者你认为我们在这篇博文中做的事情有猫腻,你想解决它?想让我们用bazel来代替?好消息--我们有一个适合你的角色!

我们正在寻找更多的工程师加入我们的开发基础设施团队随着我们发展到更多的人和更大的代码库,以及新的和令人兴奋的功能和云管理,我们正在寻求扩大我们无序的开发基础设施。如果你想通过授权工程师的工作环境来帮助他们编写令人兴奋的下一代数据库,不要拖延,今天就申请吧