Go模块化开发以及在Tendermint/Cosmos-SDK中的应用

Go模块化开发以及在Tendermint/Cosmos-SDK中的应用

本文首先介绍模块化开发Modular Programming)的一些基本概念,然后围绕Go1.11引入的模块系统介绍如何使用Go语言进行模块化开发,最后简要介绍模Go模块系统在Tendermint/Cosmos-SDK项目中的应用。

  1. 模块化开发介绍

    1.1 包 vs 模块

    1.2 语义版本号

    1.3 Registry

    1.4 清单文件

    1.5 锁定文件

  2. Go模块系统进化过程

    2.1 GOPATH

    2.2 vendor目录

    2.3 vgo

    2.4 Go1.11+

  3. Go模块系统介绍

    3.1 模块

    3.2 Registry

    3.3 语义版本号

    3.4 清单文件

    3.5 锁定文件

  4. Tendermint/Cosmos-SDK应用

  5. 总结

1. 模块化开发介绍

模块化开发是编写复杂软件的有效方式,因此大部分现代编程语言都会内置相应语法特性来支持模块化开发。以Java和Go语言为例,这两种语言都内置了(Package)的概念,可以把代码按包进行组织。对于简单的工程来说,只使用语言内置的标准库就可以了。然而对于复杂的工程,只有标准库肯定远远不够。因此,如何把代码发布出去让别人使用,以及如何使用别人的代码,就显得尤其重要。所以很自然的,在包的基础之上,衍生出了模块(Module)的概念。如果说包是代码组织的单位,那么模块就是代码发布的单位。多个相互关联的包可以构成一个模块,统一发布。以Java语言为例,Java很早就开始支持JAR(其实就是ZIP文件)文件,为后来MavenGradle等工具的流行提供了基础。Java9则更进一步,对模块和模块化开发提供了内置支持。

1.1 包 vs 模块

如前所述,似乎模块的概念已经很清晰了。然而并不是这样!现实是:不同的编程语言对于包和模块的叫法并没有达成一致。比如C++和C#语言就没有包的概念,取而代之的是命名空间(Namespace)。更让人郁闷的是,在一些编程语言里,包和模块的含义和我们前面描述的正好相反。换句话说,在有些编程语言里,包更大一些,由多个模块组成。为了描述清晰起见,在后文中我们统一使用模块这两个术语,二者中模块更大一些。下表列出了在一些主流的编程语言里包和模块的命名以及含义:

Language Package Module
Java (Maven) package jar
Go1.11+ package module
Rust module/mod package/crate
Python module (file) package (directory)
Node.js module package

1.2 语义版本号

凡事不可一蹴而就。同理,任何软件也不可能一下子开发完毕并且不出任何bug,所以按版本一步一步迭代就非常必要。包和模块的概念已经澄清了,接下来要聊一聊模块的版本号。长久以来,各种系统的版本号可谓一片混乱,各式各样五花八门的版本号规则都有。不过随着时间的推移和开发经验的积累,现在大家普遍都认同语义版本号Semantic Versioning,简称SemVer)。简单来说,语义版本号看起来是这样:MAJOR.MINOR.PATCH。其中:

  • 补丁号 如果新版本只是修复前一版本的bug,不引入任何其他变化,那么补丁号+1
  • 小版本号 如果新版本有功能上的改进,但是改进完全向后兼容(Backwards Compatible),那么小版本号+1
  • 大版本号 如果新版本有较为大的改进,并且不能完全向后兼容,那么大版本号+1

基本规则就这么简单,关于语义版本号的更多细节可以参考其当前的2.0规范

1.3 Registry

要想便捷的使用模块系统,一个中央化的模块registry(不知道中文翻译成什么好,干脆不翻译了)必不可少。有了这个registry,任何人都可以把自己开发好的模块发布上去,也可以从上面下载其他人发布的模块。当然了,发布模块的时候,需要指定版本号(一般而言,模块不能覆盖已有版本,只能发布新版本)。下载模块的时候,也需要指定版本号。能够帮助用户解析并且从registry下载依赖的工具叫做模块管理器(Module Manager),或者包管理器(Package Manager),后文统称为模块管理器。下表列出了一些主流编程语言的模块管理器和registry:

Language Module Manager Module Registry
Java Maven、Gradle Maven CentralJCenter
Rust Cargo https://crates.io/
Python pip Python Package Index
Node.js npm https://www.npmjs.com/

1.4 清单文件

如果一个模块需要依赖其他的模块,那么就需要一个清单文件(Manifest)来列出这些模块,并描述这些模块的版本号等信息。大部分的模块管理器都可以自动处理传递性依赖(Transitive Dependency)。比如说模块A依赖模块B、模块B又依赖模块C,那么模块A的清单文件里只要指定模块B即可,不需要指定模块C。清单文件的格式因模块管理器的不同而异,常见的格式有JSON(比如npm的package.json)、XML(比如Maven的pom.xml)、TOML(比如Cargo的Cargo.toml)、YAML等。

1.5 锁定文件

如前所述,版本号使得模块可以渐进的开发和完善。另一个方面,版本号也使得模块可以稳定的构造。假设我们正在开发模块M,它依赖了模块A、B、C。在明确升级这些依赖之前,我们希望每次构建出的M都是一样的,并且在不同的机器上构建出的M也是一样的。这就是可重复构建Reproducible Builds)。如果可以在清单文件中精确指定依赖的版本号,按说自然就可以实现可重复构建(假设模块不会随意覆盖或删除已发布的版本)。不过很多模块管理器都允许在清单文件中指定依赖的版本号范围,这就使得构建不一定可重复。以Rust Cargo为例,当我们在清单文件里指定某依赖的版本号为“1.2.3”时,实际指定的是“^1.2.3”。这样就允许Cargo在[1.2.3, 2.0.0)区间内下载该依赖的最新的版本。关于Cargo清单文件里依赖版本号的更多介绍请看这里

为了解决这个问题,让构建变得可重复,支持版本号区间的模块管理工具一般会使用锁定文件(Lock File)来锁定依赖的版本号。除非明确要求包管理工具升级依赖(进而更新锁定文件),否则每次构建都会使用锁定文件中指定的依赖版本号。比如Cargo使用的锁定文件是Cargo.lock,npm使用的锁定文件是package-lock.json

2. Go模块系统进化过程

以上简单介绍了模块化开发的一些基本概念,下面介绍Go语言模块系统的进化过程。

2.1 GOPATH

在Go 1.5之前,所有的源代码都挤在一个路径下,这个路径就是GOPATH。简单来说,GOPATH是个环境变量,指向一个目录,这个目录下面必须有src/子目录,里面放置Go源代码。这个目录也就是我们常说的工作空间(Workspace),Go的各种命令(比如get、build、test、install等)均以这个目录为基础进行工作。由于所有的源代码(包括本地的和第三方的)都在同一个src/目录下,那么就需要一种机制来划分src/目录,避免不同的项目因根目录名相同而产生冲突。Go采取的解决办法非常简单:对于开源项目,按照源代码托管网站的域名在src/目录下创建子目录;对于本地项目,必须自己保证项目的根目录名不和其他项目重复。一个典型的GOPATH目录结构看起来就像下面这样:

$GOPATH/
  |-bin/
  |-pkg/
  \-src/
    |-github.com/
    | |-cosmos/
    | | |-cosmos-sdk/
    | | \-gaia/
    | \-tendermint/
    |   |-go-amino/
    |   \-tendermint/
    \-myprojs/
      |-proj1/
      \-proj2/

下面从另一个角度给出GOPATH的示意图,也顺便展示了go get等命令的作用:(介绍go get?)

+--------------------------------------------+
| $GOPATH                                    |
|              +-----------------+   go get  |   +--------+
|           +--| src/github.com/ |<--------------| GitHub |
|      go   |  +-----------------+ git clone |   +--------+
|   install |  +-----------------+           |
|           +->| bin/            |           |
|              +-----------------+           |
+--------------------------------------------+

所有的项目都在一个工作空间下虽然简单,不过缺点也很明显。最大的问题就是第三方的项目完全没有版本的概念。以go get命令为例,虽然说该命令也能下载项目和依赖(通过分析Go源文件中的import语句),但要么就是下载项目的最新版本,要么就是使用已经下载的本地版本,很难保证可重复构建。在这种情况下,也出现了一些有趣的解决办法。比如gopkg.in试图给第三方项目添加语义版本支持,并孕育了Semantic Import Versioning和Tagged release等概念。再比如glidegom使用了_vendor/目录维护第三方依赖,类似的godep使用了Godeps/_workspace/目录,这些工具给后来Go 1.5引入的标准的vendor/目录提供了思路。关于这些早期的工具可以看这篇文章这篇文章

2.2 vendor目录

为了解决第三方依赖的问题,Go 1.5引入了vendoring机制。但此时还处于实验阶段,默认是关闭的,需要设置环境变量GO15VENDOREXPERIMENT=1开启。到了Go 1.6,vendoring机制已经稳定,默认开启。简单来说,vendoring机制允许项目的根目录下面存在一个vendor/目录,这个目录下的代码将先于$GOPATH/src/被引用。下面是启用vendering之后的GOPATH示意图:

+-----------------------------------------------+
| $GOPATH                                       |
|              +----------------------+  go get |   +--------+
|           +--| src/github.com/ <------------------| GitHub |
|           |  |   |-my/proj/         |         |   +--------+
|           |  |     |-vendor/        |     download     |
|           |  |       \-github.com/ <-------------------+
|      go   |  +----------------------+         |
|   install |  +----------------------+         |
|           +->| bin/                 |         |
|              +----------------------+         |
+-----------------------------------------------+

有了vendoring机制,项目就可以把第三方依赖的某个版本下载到vendor/目录下,并提交到版本控制系统,这样就保证了可重复构建。vendoring机制虽好,但是依赖版本号的问题仍然是没有解决。于是各种第三方工具开始支持vendoring机制,并在此基础之上提供版本号的支持。比较流行的工具包括前面提到的godep,glide,以及Go官方发布的dep等。要想了解其他的模块管理系统,可以看这里

2.3 vgo

有了dep等工具的开发经验和社区反馈,Russ Cox开始考虑实现一个更好的模块系统,并发布了实验性质的工具vgo。和前面的尝试不同,vgo打算彻底抛弃GOPATH以及vendor/目录,并且从Rust Cargo等成熟的包管理工具中吸收了很多精髓。用户可以执行go get -u golang.org/x/vgo命令下载并试用vgo。由于每一个vgo项目都是一个独立的工作空间,因此可以放在任何地方。vgo项目的根目录下会有一个go.mod文件,描述项目用到的依赖以及版本号等信息。后面会进一步介绍vgo提出的模块系统,下面先给出vgo项目的示意图:

+-----------------------------+        
| myproj1/                    |
|   |- ...          vgo get   |
|   \-go.mod <----------------------+
+-----------------------------+     |    +--------+
                                    |----| GitHub |
+-----------------------------+     |    +--------+
| $GOPATH/pkg/mod/            |     |
|   |-github.com/             |     |
|     |-cosmos/               |     |
|       |[email protected]/ <------+
+-----------------------------+

2.4 Go 1.11+

事实证明vgo非常成功,因此vgo提案最终被合并到了Go里面,随着Go 1.11发布,并且在Go 1.12得到增强。为了使用户能够平滑的过度到新的模块系统,在很长一段时间内老的GOPATH机制仍然需要继续被支持。为此,Go 1.11引入了GO111MODULE环境变量,可以被设置为on、off或者auto,默认值是auto。在Go 1.11和Go 1.12里,如果项目在$GOPATH/src里面,则默认关闭模块系统,需要设置GO111MODULE=on来开启。如果项目在$GOPATH/src外面,则默认开启模块系统。等到Go 1.13(这篇文章写作时还没有发布),即使项目在$GOPATH/src里面,模块系统也会默认启用。下表列出了在不同的Go版本和项目位置($GOPATH/src里面还是外面)的各种组合下,项目默认采用的是传统的GOPATH模式还是新的模块系统:

Go Version Inside $GOPATH/src Outside $GOPATH/src
Go 1.10- GOPATH N/A
Go 1.11 GOPATH MODULE
Go 1.12 GOPATH MODULE
Go 1.13+ MODULE MODULE

3. Go 1.11模块系统介绍

第一小节介绍了模块化开发的基本概念,这一小节介绍这些概念在Go 1.11模块系统中的对应。

3.1 模块

前面已经提到过,Go 1.11发布的模块系统(后面简称Go模块系统)抛弃了原来的GOPATH模式(以及vendor/目录),每个项目是一个独立的工作空间。Russ Cox在他的文章里写到:“a Go module is a collection of packages versioned as a unit, along with a go.mod file listing other required modules”,这很好的解释了模块是什么。为了成为一个模块,项目的根目录下面需要放置一个go.mod文件,这个文件就是前面提到过的清单文件。清单文件里需要指明模块的路径名,并列出所依赖的其他模块版本号等信息。比如下面是Cosmos-SDK这个项目的清单文件:

module github.com/cosmos/cosmos-sdk

require (
	github.com/bartekn/go-bip39 v0.0.0-20171116152956-a05967ea095d
	github.com/bgentry/speakeasy v0.1.0
	github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d
	github.com/cosmos/go-bip39 v0.0.0-20180618194314-52158e4697b8
	github.com/cosmos/ledger-cosmos-go v0.10.3
	...
)

对于托管在GitHub等网站上的大部分项目而言,一个repo就是一个模块。Go模块系统非常灵活,一个repo也可以包含多个模块,不过这个不在本文讨论范围内,可以从这里获得更多信息。

3.2 Registry

由于大部分的开源项目已经托管在GitHubGitLabBitbucket等网站上了,所以Go模块系统暂时没有提供单独Registry(未来也许会有),而是直接使用这些源代码托管网站。在go.mod清单文件的require部分,依赖路径从github.com这样的域名开始,这样Go模块系统就知道从哪里去下载依赖。相比于之前的vendoring机制,Go模块系统增加了语义版本的支持,这个从前面给出的清单文件也可以看出。项目只要按照语义版本号约定的规则,给每个发布打上类似v1.2.3这样的tag就可以了,Go模块系统会使用这些tag下载相应的版本。下面列出Cosmos-SDK项目打的一些tag:

$ cd cosmos-sdk/
$ git tag --sort=-taggerdate
v0.36.0-rc1
v0.35.0
v0.34.7
v0.34.6
v0.34.5
v0.34.4
v0.34.3
v0.34.2
v0.34.1
v0.34.0
...

3.3 语义版本号

如前所述,Go模块系统要求项目按照语义版本号给每一个发布打上tag。不过为了帮助现有项目更好的过渡到模块系统,Go也支持伪版本号Pseudo-versions)。伪版本号对于遗留项目和正在开发中的项目也很有用。如果某个项目还没有迁移到Go模块系统,或者还没有打tag进行发布,那么别的项目就可以使用类似v0.0.0-yyyymmddhhmmss-commit这样的伪版本号直接根据Git提交哈希值来指定依赖版本。从前面的例子可以看到,Cosmos-SDK通过伪版本号依赖了go-bip39:

github.com/bartekn/go-bip39 v0.0.0-20171116152956-a05967ea095d

3.4 清单文件

前面已经提到了清单文件go.mod,这种清单文件没有使用JSON、YAML、TOML等现有的格式,而是自定义了一种更为简洁的格式,具体可以看这里。下面给出Cosmos-SDK(v0.36.0-rc1)完整的清单文件,仅供参考:

module github.com/cosmos/cosmos-sdk

require (
	github.com/bartekn/go-bip39 v0.0.0-20171116152956-a05967ea095d
	github.com/bgentry/speakeasy v0.1.0
	github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d
	github.com/cosmos/go-bip39 v0.0.0-20180618194314-52158e4697b8
	github.com/cosmos/ledger-cosmos-go v0.10.3
	github.com/fortytw2/leaktest v1.3.0 // indirect
	github.com/gogo/protobuf v1.2.1
	github.com/golang/mock v1.3.1-0.20190508161146-9fa652df1129
	github.com/golang/protobuf v1.3.0
	github.com/gorilla/mux v1.7.0
	github.com/gorilla/websocket v1.4.0 // indirect
	github.com/mattn/go-isatty v0.0.6
	github.com/pelletier/go-toml v1.2.0
	github.com/pkg/errors v0.8.1
	github.com/prometheus/client_golang v0.9.2 // indirect
	github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect
	github.com/prometheus/common v0.2.0 // indirect
	github.com/prometheus/procfs v0.0.0-20190227231451-bbced9601137 // indirect
	github.com/rakyll/statik v0.1.4
	github.com/spf13/afero v1.2.1 // indirect
	github.com/spf13/cobra v0.0.5
	github.com/spf13/jwalterweatherman v1.1.0 // indirect
	github.com/spf13/pflag v1.0.3
	github.com/spf13/viper v1.3.2
	github.com/stretchr/testify v1.3.0
	github.com/tendermint/btcd v0.1.1
	github.com/tendermint/crypto v0.0.0-20180820045704-3764759f34a5
	github.com/tendermint/go-amino v0.15.0
	github.com/tendermint/iavl v0.12.3-0.20190712145259-c834d3192b52
	github.com/tendermint/tendermint v0.32.1
	google.golang.org/grpc v1.19.0 // indirect
	gopkg.in/yaml.v2 v2.2.2
)

需要说明的是,清单文件一般情况下并不需要手动编辑。随着Go模块系统一起发布的,还有go mod命令。go mod命令有许多子命令,比如go mod init命令可以给一个项目添加清单文件,go mod edit命令可以修改require指令,等等。此外当模块系统启用时,go getgo listgo build等命令,也全部都进行了增强。对这些命令的介绍超出了本文讨论范围,更多介绍请看这里

3.5 锁定文件

和Cargo等有所不同,Go不支持依赖版本号范围,并且在解析模块依赖时使用了最小版本选择(Minimal Version Selection)算法。因此,go.mod清单文件精确指定了依赖及版本号,默认即支持可重复构建。不过为了避免其他问题(比如tag被修改或删除、源代码托管网站被攻击等),Go还是启用了锁定文件go.sum:

The go command uses the go.sum file to ensure that future downloads of these modules retrieve the same bits as the first download, to ensure the modules our project depends on do not change unexpectedly, whether for malicious, accidental, or other reasons.

下面是Cosmos-SDK项目(v0.36.0-rc1)的go.sum文件,仅供参考:

cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
... 

到此,我们简单介绍了Go 1.11模块系统。概括来说,Registry = GitHub,Module = repo,Version = tag。关于Go模块系统的更多介绍,请看这里

4. Tendermint/Cosmos-SDK

就目前来看,Go模块系统非常成功,众多开源项目也在慢慢的过渡到Go模块系统。Tendermint/Cosmos-SDK最开始使用的是glide,后来切换到了dep,最近又都切换到了Go模块系统。通过扫描相应的清单文件的提交记录可以大概了解到一些信息:

git log --all --full-history --oneline -- glide.yaml
git log --all --full-history --oneline -- Gopkg.toml
git log --all --full-history --oneline -- go.mod

下面是Cosmos-SDK使用的模块管理器变更历史:

Module Manager Manifest File Date Commit Hash Commit Message
Go 1.11 mod go.mod 2019.03.18 6ce4d5efd replace dep with go mod (#3907)
dep Gopkg.toml 2018.02.25 ce689ab4f Switch dependency resolution to dep and update Makefile to use dep
glide glide.yaml 2017.01.13 c1c79d1e3

下面是Tendermint使用的模块管理器变更历史:

Module Manager Manifest File Date Commit Hash Commit Message
Go 1.11 mod go.mod 2019.06.09 8b7ca8fd switch to go mod (#3613)
dep Gopkg.toml 2018.02.26 b6d02905 Switch to dep from glide for dependency resolution
glide glide.yaml 2016.01.06 9d71a040 Add Glide files for project management

5. 总结

本文介绍了编程语言模块系统的一些基本概念,以及这些概念如何在Go 1.11模块系统中的体现。下表对本文中出现的语言和模块管理系统进行了总结:

Language Package Manager SemVer Manifest Lockfile Registry
Java Maven pom.xml Maven Central
Rust Cargo yes Cargo.toml Cargo.lock https://crates.io/
Node.js npm yes package.json package-lock.json https://www.npmjs.com/
Go 1.11+ builtin yes go.mod go.sum GitHub、GitLab、Bitbucket等
Python pip Python Package Index

本文由CoinEx Chain团队Chase写作,转载无需授权。

2 Likes