Go Module 存在的意义与解决的问题

作者:William Kennedy | 原文:Modules Part 01: Why And What

最近,我在尝试整理一篇关于 Go 包管理发展历史的文章,希望能加深自己对这一块知识的认识。在搜集资料的时候,发现了这篇文章,顺手翻译了一下。

本文是该系列的第一篇,主要介绍包依赖管理中一些基础知识。文中提出了 Go 开发中的三个痛点,如何解决只能在 GOPATH 指定路径开发,如何实现有效的版本管理,以及如何支持 Go 原生工具集依赖管理。针对它们,Go Module 都提供了相应的解决方案。

从第一篇的内容上看,作者后面的文章应该会对 Go 的模块机制进行详细的剖析,很期待。话说,总感觉这篇文章翻译的有点别扭,检查的时候发现有好几处语义理解错误,尴尬。

翻译正文如下:


介绍

Go Module 是 Go 为包依赖管理提供的一个综合性解决方案。从 Go 初版发布以来,Go 开发者针对包管理这一块提出过三个痛点问题。

如何实现在 GOPATH 工作区之外进行代码开发;

如何实现依赖版本化管理和有效识别出使用依赖的兼容性问题;

如何实现通过 Go 原生工具进行依赖管理;

随着 Go 1.13 的发布,这三个问题都得到了解决。在过去的两年里,Go 团队成员为此付出了巨大的努力。本文中将重点介绍从 GOPATH 到模块机制的变化,还有模块究竟解决了什么问题。我将通过足够易懂的语言向大家说明模块的工作机制。

我觉得,重点要理解为什么模块这样工作。

GOPATH

GOPATH 是用于指定 Go 工作区的物理位置,一直以来都很好地服务着 Go 的开发者们。但它对非 Go 开发者并不友好,想在没有任何配置的情况下,随时随地进行 Go 开发,这是不可能的一件事。

Go 团队要解决的第一个问题就是允许 Go 的源码仓库能被 clone 在磁盘中的任意位置,而不仅仅是 GOPATH 指定的工作区。并且 Go 工具集仍然要能成功定位、编译构建与测试它们。

上图展示了一个 github 仓库,ardanlabs/conf,这个仓库仅有一个包,它用于提供对应用配置处理的支持。

以前,如果想使用这个包,我们需要通过 go get 并指定仓库的规范化名称实现下载一份到你的 GOPATH 下。仓库规范化的名称是由远程仓库的基础 url 和仓库名称两部分组成。

一个例子,在 Go Module 之前,如果你执行 go get github.com/ardanlabs/conf,代码将会被 clone 到 $GOPATH/src/github.com/ardanlabs/conf 目录下。基于 GOPATH 和仓库名,无论我们把工作区设置何处,Go 工具集始终都能正确地找到代码的位置。

导入解析

清单 1

github.com/ardanlabs/conf/blob/master/conf_test.go

package conf_test

import (
    ...
    "github.com/ardanlabs/conf"
    ...
)

清单1 显示了 conf 包中测试文件 conf_test.go 中的导入其他包的代码片段。

当测试包名用 _test 命名,这就意味着测试代码和被测试代码是在不同的包中,测试代码必须导入要被测试的外部代码。从上面的代码片段中,我们可以看出,测试代码是如何将 conf 导入的。基于 GOPATH 机制,可以非常容易地解析出导入包的路径。然后,Go 工具集就可以成功定位、编译和测试代码。

如果 GOPATH 不存在或者目录结构与仓库名称不匹配,将会如何呢?

清单 2

import "github.com/ardanlabs/conf"

// GOPATH mode: Physical location on disk matches the GOPATH
// and Canonical name of the repo.
// GOPATH 模式:磁盘物理位置与 GOPATH 和仓库的规范名称相匹配
$GOPATH/src/github.com/ardanlabs/conf

// Module mode: Physical location on disk doesn’t represent
// the Canonical name of the repo.
// Module 模式:磁盘上的物理位置和仓库全名没有必然的匹配关系。
/users/bill/conf

清单2 展示了如果把仓库 clone 到任意位置将会产生什么问题。当开发者选择将代码下载他们希望的任意位置时,通过 import 包名称解析出源码的实际位置就不行了。

如何解决这个问题?

我们可以指定一个特殊的文件,使用它指定仓库的规范名称。这个文件的位置可理解为是 GOPATH 的一个替代,在它其中定义了仓库的规范名称,Go 工具可以通过这个名称解析源码中导入包的位置,而不必关心仓库被 clone 到了什么地方。

我们把这个特殊的文件命名为 go.mod,将在这个文件中定义的由规范名称表示的新实体称为 Module。

清单 3

github.com/ardanlabs/conf/blob/v1.1.0/go.mod

module github.com/ardanlabs/conf

清单3 中显示了 conf 仓库中的 go.mod 文件的第一行 。

这一行定义了模块的名称,它同时也代表了仓库全名,开发者期待使用它来引用库中任意部分的代码。现在,库被下载到什么位置已经不再那么重要了,Go 工具集会根据 module 文件所在位置和模块名定位和解析内部包的导入,比如前面的示例中,在测试文件中的导入 conf 包。

现在,模块机制允许我们将代码下载到任意位置。那下一个要解决的问题就是如何将代码捆绑到一起进行版本控制。

捆绑和版本控制

多数的版本管理系统都支持了在任意提交点打标签。这些标签通常是被用来发布新特性(v1.0.0、v2.3.8,等等),而且一般都是不可变的。

图中显示,conf 已经被打了三个不同的版本标签。这三个标签遵循着语义化版本的格式。

利用版本管理工具,我们可以通过指定 tag 实现 clone 任意版本的 conf 包的目的。但这有两个问题亟待解决。

  • 我应该使用哪个版本的包;
  • 我如何才能知道哪个版本的包兼容我所写的或使用的代码;

一旦回答完这两个问题,又会产生第三个问题:

  • 从哪里下载依赖的代码,Go 工具要能查找和访问到它;

接着,情况变得更差。

为了要使用特定版本的 conf 包,你必须要下载 conf 的所有依赖。对于所有存在依赖传递的项目,这是一个共性的问题。

在 GOPATH 模式下,可以使用 go get 识别和下载所有的依赖包,然后放到 GOPATH 指定的工作区下。但这不是一个完美的方案,因为 go get 仅仅只能从 master 分支下载和更新最新的代码。当初期写代码时,从 master 下载代码没什么问题。但几个月后,有些依赖可能已经升级了,master 分支的最新代码可能已经不再兼容你的项目。这是因为你的项目没有遵守明确的版本管理,任何的升级都可能带来一个不兼容的改变。

在 Module 模式下,通过 go get 下载所有的依赖到一个单一的工作区不再是首选方式。你需要一种方式实现为整个项目中的每个依赖指定一个兼容版本。同时,还要支持针对同一个依赖不同主版本的引入,以防止出现一个项目中依赖同一个包的不同主版本。

针对上面的这些问题,社区已经开发了一些解决方案,如 dep, godep, glide 等。但 Go 需要一个集成的解决方案。这个方案通过重用 go.mod 文件实现按版本维护这些直接和间接依赖。然后,将任何一个版本的依赖当成一个不可变的代码包。这个特定版本不可变的代码包被称为一个 Module。

集成解决方案

上图显示了仓库和模块的关系。它显示了如何引用到一个特定版本模块中的包。在这种情况下,在 conf-1.1.0 的代码从版本为 0.3.1go-cmp 导入了 cmp 包。既然,依赖信息已经在 conf 模块中(保存在模块文件中),Go 就可以通过内置的工具集获取指定版本的模块进行编译构建。

一旦有了模块,许多便利的工程体验就体现了出来:

  • 可以向全世界的 Go 开发者提供支持,如 build、retain、authenticate, validate, fetch, cache 等;
  • 在不同的版本管理系统前构建一个代理服务器,从而实现前面提到的那些支持;
  • 可以验证一个模块是否被修改过,而不用关心它被构建了多少次,从何处何人手里获取,

在这方面是非常值得庆幸地,因为在 Go 1.13 中,Go 团队已经提供了许多这方面的支持。

总结

这篇文章尝试为后面讨论 Go 模块是什么以及 Go 团队如何设计了这个方案打下了基础。接下来还有一些问题需要讨论,比如:

  • 一个特定版本的模块是如何被选择?
  • 模块文件是什么样的组织结构以及它提供了哪些选项帮助你控制模块的选择?
  • 模块是如何编译、获取和缓存到本地的磁盘帮助实现导入包的解析?
  • 如何通过语义版本进行模块验证?
  • 如何在你的项目中使用模块以及有什么最佳实践?

在接下来的文章中,我计划将针对这些问题提供一个更深度的理解。现在,你要确保自己已经明白了仓库、包和模块之间的关系。