Jason Pan

【Go】透彻理解包、模块

潘忠显 / 2024-07-08


Go 作为一门成熟的语言,有着详尽的文档。但官方的入门文档过于简单,而参考手册又过于丰富,让新人觉得无从着手。

本文由浅入深地解释清楚:在Go语言中,包(package)是代码组织和重用的基本单位,而模块(module)是控制版本和管理依赖的单元

本文主要受众应该是:有一定Go使用经验,但是在使用包和模块时还偶有迷惑的同学。

一、什么是包、模块

Go 语言官方文档 How to Write Go Code模块 有比较清晰的介绍。

A package is a collection of source files in the same directory that are compiled together.

A module is a collection of related Go packages that are released together.

当然,远不止这两句话。我们已经熟悉一些点,我这里先列举一下:


一个简单的、典型的 Go 项目的目录结构和文件可能像下边这样

single-module

接下来,将详细地说明:在Go语言中,包(Package)是代码组织和重用的基本单位,而模块是控制版本和管理依赖的单元

还包括一些可能大家不太清楚的问题解释,比如:子模块、版本编号规则与发布、go.sum的作用、indirect 依赖模块、包的循环引入、模块的版本冲突、仓库地址和模块路径不一致等问题。

二、包 - 组织代码和复用的单元

A package is a source code organization unit: it defines a context for identifier names, determining the visibility of an identifier within the package. Every package is associated with a directory of the same name. All the files in a package’s directory must declare the same package name. – Go doc

包是源代码的组织单位:它为标识符名称定义了一个上下文,决定了一个标识符在包内的可见性。每个包都与一个同名的目录关联。包目录中的所有文件必须声明相同的包名。

这是一种组织和封装代码的方式。这种方式使得代码结构清晰,易于理解和维护。

更直白的翻译一下:Go 程序组织代码的基本单位是包(package),就像说生物体的基本组成单位是细胞一样。

包为什么是代码组织的基本单位?接下来从命名空间、封装以及代码复用等方面聊聊。

2.1 命名空间

命名空间(namespace) 是一个用于管理变量名的区域范围,要保证同一个命名空间内的变量名(更确切的应该是标识符名称)是唯一的。

Go语言中,每个包都会提供一个独立的命名空间。这意味着在不同的包中,你可以使用相同的标识符名称,而不会产生冲突。而在同一个包内,不同文件之间中定义的变量、方法,都可以直接进行访问。

避免命名冲突极大地提高了代码的可维护性。特别是在大型程序中,不同模块或库很可能会使用相同的变量名、函数名。

2.2 封装

封装将数据和操作这些数据的方法组合在一起的机制,其主要目标是隐藏对象的内部状态和实现细节,只暴露必要的接口给外部使用者

封装通常用于面向对象编程(OOP)中,可以通过类和对象来实现。而 Go 语言并不是传统意义上的面向对象编程语言,但其通过使用包也实现了良好的封装

在一个包内部,类型、变量、常量和函数分为耳熟能详的两类:

这种方式提供了良好的封装性,可以隐藏实现细节,只暴露必要的接口给其他包。而通过大小写字母来开头的方式来实现,比一些复杂的类的 publicextern 之类的方式,更简洁明了。

2.3 代码复用

代码复用是软件开发中的一个关键实践,通过代码复用,可以提高开发效率、减少错误、提高代码质量和维护性。

Go语言的包也是代码重用的基本单位。通过 import 语句可以引入其他包。

通过 go get -u 命令+包的路径,可以从指定的远程仓库中获取包,并将其安装到本地的Go环境中。比如:

go-get-package

2.4 其他语言类似的概念

Go语言中的包概念在其他语言中也有类似的概念,主要用于组织和管理代码,但是不完全相同。接下来,以我用过的几类语言来比较一下。

Python

在Python中,有 模块(module)包(package) 的概念。

模块是一个包含Python定义和语句的文件,一个文件有独立的命名空间。

是一个包含多个模块的目录,其中有个__init__.py 文件,正是这个文件,使得包在导入时,也被视为一个特殊的模块。

Python的包可以用来组织模块,类似于Go的包。

C++

C++好像没有直接对应Go的包的概念,但是C++有显式的 namespace 支持。

命名空间非常灵活,可以跨越不同的目录和文件,还支持嵌套命名空间来进一步组织代码。换句话说,开发者需要自己来组织命名空间。

此外,C++的头文件和源文件的组织方式也可以看作是一种代码管理的方式,虽然它不像Go的包那样强大。

Java

因为之前开发实时计算任务,也有用到 Java 和 Scala。

Java的代码组织单元是类(Class)。每个 Java 源文件通常只包含一个公共类,该文件名与公共类名相同,并以 .java 结尾。这种文件级别的组织方式使得Java程序易于管理和维护。

Java中,包(Package)用于组织类、接口以及其他子包,提供了命名空间的管理机制,可以避免命名冲突。包名使用点分隔符来分级,例如 java.util,其中 java 是顶级包,util 是它的子包。

看上去跟 Go 的导入是不是还有点像?

import com.google.gson.Gson;

三、模块 - 依赖管理和版本控制的单元

相对于包是组织代码的基本单元,模块则是管理依赖和版本控制的基本单元,两者的目标是不同的。

关于模块的使用,Go 官方一系列文档提供了详细的介绍,包括版本号如何定义、如何发布、如何维护依赖等等。

既然有了包这个概念,为什么还需要模块?

之所以会模块通常包含了多个包,最主要可能是实践中,复用代码的粒度会比较细,而版本管理的没有那么必要那么细。另外,模块通常要实现一系列的功能,很少有一个项目会只有一个包就可以完成的(虽然有些包和模块确实在一个文件夹中,也有的模块确实只有根目录一个包)。

3.1 版本管理功能

Go 版本号发布模块 两个文档简明地介绍如何发布模块版本。

语义化版本

文档里我们会看到 semantic version,这个我们之前有提到过 语义化版本,版本号包括:不兼容的 API 修改的主版本号、新增向下兼容特性的次版本号、新增向下兼容的补丁的修订版本号。最后还可以有预发布版本号,通过横线连接一个字符串,意思是还没有到达前边的标准版本。

semantic-version

语义化版本的好处,是能让我们一眼就能看出哪些版本更新,哪些版本之间是兼容的。

比如以下版本之间,越往右版本越高(越新)。

1.0.0-alpha < 1.0.0 < 2.0.0 < 2.1.0 < 2.1.1

通过 git tag 标记版本

Go 的模块版本,依赖于版本控制仓库,可以使用 Git,也支持 Subversion, Mercurial, Bazaar, Fossil 等其他版本控制的形式。

因为我们主要使用 Git,接下来主要介绍基于 Git 的版本发布。Go 模块路径,也就是我们在调用 go mod init 的时候后边加的路径,通常是一个仓库名。这样做有两个功能:

这里跳过 git init/add 等前序操作,到 git commit 之后使用 git tag,比如我们要发布第一个版本 v0.1.0

git commit -m "Init: v0.1.0"
git tag v0.1.0
git push origin v0.1.0

推送到远端仓库之后,我们可以通过 go list 指令查看模块的可用版本:

✗ go list -m git.woa.com/jasonzxpan/m1
git.woa.com/jasonzxpan/m1 v0.1.0

这里再提一下前面提到的 预发布版本号:如果我们提供的包/模块,不是稳定的版本,但同时也需要打 tag 的话,就可以使用像 v0.1.0-beta.1 这样的预发布版本号。这种版本不必保证版本的稳定性。

如果我们做了变动需要发布,可以再根据前面的【语义化版本】的规则,打上新的 tag,比如 v0.1.1 并推送到仓库。

3.2 使用第三方包和模块

Go 语言的标准库是随着 Go 版本发布的,因此在编写Go代码时,引入标准库的包(例如 import "fmt")不需要指定具体的版本号。

你可以通过 IDE 点到源代码查看中,在Mac上,默认安装的 Go 语言通常位于 /usr/local/opt/go/libexec 目录下。这里边有 VERSION 文件,标识当前安装的 Go 语言版本及其标准库的版本信息。

指定版本的依赖

本节中,第三方包和模块是指除了模块自身中的包标准库之外的包和模块。

我们上边创建的模块 git.woa.com/jasonzxpan/m1,假设其中有一个包 p1,其中有个导出函数 M1P1F1()

我们再创建一个模块 go mod init git.woa.com/jasonzxpan/m2,要使用这个 m1/p1 的包,可以直接在文件中引入。

package main

import (
    m1p1 "git.woa.com/jasonzxpan/m1/p1"
)

func main() {
    m1p1.M1P1F1()
}

这时候可以直接通过 go mod tidy 让其自己分析依赖,并且获得最新版本的包。

➜  m2 git:(master) ✗ go mod tidy
go: finding module for package git.woa.com/jasonzxpan/m1/p1
go: downloading git.woa.com/jasonzxpan/m1 v0.1.1
go: found git.woa.com/jasonzxpan/m1/p1 in git.woa.com/jasonzxpan/m1 v0.1.1

go get 可以让我们获得指定版本的模块:

go get git.woa.com/jasonzxpan/m1/p1@v0.1.0

如果要更新某个模块,也可以用 go get -u 来更新:

替换模块依赖 - repalce

有些场景,我们需要替换掉依赖远端的第三方库:

无论是哪种场景,大概的操作流程都类似:

仍以上边 m2 依赖 m1 模块为例,我们可以直接通过 replace,在开发过程中使用本地的 m1

go mod edit -replace=git.woa.com/jasonzxpan/m1@v0.1.1=../m1

类似地,可以替换成远端的仓库:

go mod edit -replace=git.code.oa.com/trpc-go/trpc-go=git.woa.com/trpc-go/trpc-go@v0.15.0

其实,还有另外一种常见场景,就是后边会提到的多个模块的,可能会有直接 replace xxx => ../.. 的操作,比如,grpc-go 子模块 grpc-go/examples 中的 go.mod

go-mod-example

为什么需要间接依赖

如果我们项目复杂点,打开 go.mod 文件,会看到一些信息。 除了 module_path、Go 版本以及直接依赖的模块和版本信息之外,还有另外一个间接依赖。与直接依赖类似,也是模块路径和版本号,区别在于最后有个 // indirect 的注释,其实上节图 grpc-go/examples 中就有。

为了简明,我们再以被广泛使用的 uber 日志库 zap 的 go.mod 为例,可以看到有三个间接依赖的模块:

go-mod-indirect-deps

我们可以理解间接依赖是指:如果我们直接使用 moduleA 中的包,而 moduleA 依赖 moduleB,那么 moduleB 就是我们模块的间接依赖

是不是 go.mod 会将所有间接依赖都列出来?答案是否定的。以上边的依赖关系来说,只有 moduleA 的 go.mod 文件中,没有指定 moduleB 的版本,才会在我们的自己的模块 go.mod 文件中指定间接依赖的版本。如果 moduleA 已经指定了 moduleB 的版本,就不需要指定了。

运行 go mod tidy 命令来更新你的go.mod文件,这个命令会添加缺失的模块,删除无用的模块,以及更新间接依赖的版本


那究竟为什么需要指定间接依赖的版本呢?

记录间接依赖的版本信息是为了确保项目的构建稳定性和可重现性。因为间接依赖的版本可能会影响直接依赖模块的行为和版本兼容性。只有记录清楚所有依赖的版本,才能够重现完全相同的依赖版本树,从而避免由于间接依赖的不一致而导致的问题。

3.3 单仓库多模块

Go 的官方文档中,有子包 sub-package 的表述,但是没有 sub-module 的表述。

但我们能看到部分项目中,可能存在多个 go.mod 的情况:模块中还有模块的,其实外层目录是一个模块和子目录是另外一个模块,并非真正意义上的子模块

How to Write Go Code 的文档中,有这么一段话,也描述包、模块、仓库的关系:

A Go repository typically contains only one module, located at the root of the repository. A file named go.mod there declares the module path: the import path prefix for all packages within the module.

The module contains the packages in the directory containing its go.mod file as well as subdirectories of that directory, up to the next subdirectory containing another go.mod file (if any).

但是,上边提到了模块包含当前目录、以及所有子目录的中的包。但是如果一个子目录包含另外一个 go.mod 文件,则这个目录中及其子目录的包,不属于当前模块。

多模块的项目

我们确实见到过类似的项目,比如 trpc-go和 grpc-go 的仓库中都有多个 go.mod 文件,这就意味着这些仓库中有多个模块。

这些子模块大多是依赖顶层模块,或者项目的一些工具。我们以 grpc-go 仓库为例:

grpc-go-tree

为什么要多模块

多个模块有关联性,但是又不那么强烈的耦合的场景,就适合拆分成多个模块。

比如 grpc-go/examples 模块,只是 grpc-go 的示例,单独放个仓库过于简单,没有必要。而如果直接放在 grpc-go 模块中作为一个包,不加 go.mod 单独一个模块,又会对 grpc-go 包的纯洁性有影响——用户不会依赖 examples 包。

另外,这样划分,客观上还带来了一些好处:

我们可以通过 go list -m -versions 来看两个模块的版本信息,可以看到这个工具只有到6个版本,而根模块有到 v1.66.0-dev 等几十个版本:

grpc-module-versions

如何对不同的模块不同的版本?在官方文档 Managing module source 中有介绍。根目录直接打 v1.2.1,子目录打标签时候带上目录即可。比如我们看到 protoc-gen-go-grpc 对应的版本在仓库中有打对应的 tag 如 cmd/protoc-gen-go-grpc/v1.1.0

grpc-module-tags

可以上下层次,也可以两个目录并行,比如官方介绍的:

go-multiple-module

四、其他疑惑

4.1 go.sum 里边有什么

在官方文档 管理依赖 一文中,有这样一段描述:

When you add dependencies, Go tools also create a go.sum file that contains checksums of modules you depend on. Go uses this to verify the integrity of downloaded module files, especially for other developers working on your project.

Include the go.mod and go.sum files in your repository with your code.

当添加依赖(创建 go.mod)的同时,也会创建 go.sum 文件。go.sum 记录了项目中使用的每个依赖模块的模块路径(module path)以及对应的校验和,用于验证依赖模块在下载时的完整性。go getgo mod tidy 的时候,在更新 go.mod 的同时,也会更新 go.sum。

go.mod 文件定义了项目的模块依赖关系和版本约束,而 go.sum 文件则提供了依赖模块的安全性保证,确保每次构建时都能使用正确的依赖版本,可以防止恶意或不良网络条件下的中间人攻击

go.sum 和 go.mod 一样,都要加入到版本控制中。

4.2 依赖下载到本地,存放在哪里

Go Modules 会将依赖下载到本地的模块缓存目录,默认情况下,这个目录是 $GOPATH/pkg/mod。如果没有设置 GOPATH 环境变量,默认的 GOPATH 是用户主目录下的 go 目录:

go-mod-path-before-clean

如果缓存的模块太多导致磁盘占用太大,或者缓存的模块出现问题导致构建失败,这些情形下,我们可以调用 go clean -modcache清理模块缓存,这会删除 $GOPATH/pkg/mod 目录中的所有内容。

go-mod-path-after-clean

对比之下还有 go clean 指令,他只是用来清理项目自身构建的二进制文件、测试缓存文件、临时文件等。

4.3 如果仓库地址和模块路径不一样怎么办?

虽然仓库地址是 github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc,但是他模块路径自己写的是 google.golang.org/grpc/cmd/protoc-gen-go-grpc,这两者是不一致的,到时候会报错:

✗ go get github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc
go: downloading github.com/grpc/grpc-go v1.65.0
go: downloading github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc v1.4.0
go: github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc@upgrade (v1.4.0) requires github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc@v1.4.0: parsing go.mod:
	module declares its path as: google.golang.org/grpc/cmd/protoc-gen-go-grpc
	        but was required as: github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc

你需要在你的go.mod文件中添加一个 replace 指令,将 github.com 模块名替换为google.golang.org 开头的模块名。例如:

replace github.com/grpc/grpc-go/cmd/protoc-gen-go-grpc => google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.4.0

比如我们之前的仓库地址是 git.code.oa.com, 之后迁移到了 git.woa.com,那很多之前很多代码仓库的 module path 都是以 git.code.oa.com 开头的。这时候如果直接 go get git.woa.com/trpc/trpc-go,可能会遇到跟上边一样的错误:

✗ go get git.woa.com/trpc-go/trpc-go
go: git.woa.com/trpc-go/trpc-go@upgrade (v0.18.1) requires git.woa.com/trpc-go/trpc-go@v0.18.1: parsing go.mod:
	module declares its path as: git.code.oa.com/trpc-go/trpc-go
	        but was required as: git.woa.com/trpc-go/trpc-go

而如果直接使用老仓库地址,go get git.code.oa.com/trpc-go/trpc-go 可能老的服务已经停止了,无法获得对应的包。除了使用上边replace 之外,还可以使用公司内提供的 GOPROXY:

问:为什么 goproxy.woa.com 能去 git 下载到 import path 为 git.code.oa.com 的库?

答:是因为 goproxy.woa.com 服务 git clone 时会自动将 git.code.oa.com 替换为 git.woa.com

4.4 导入的多个包依赖同一模块,其版本冲突如何处理

对于版本冲突,Go Modules 使用了一种称为“最小版本选择”(Minimal Version Selection, MVS)的机制。MVS 机制确保在构建过程中使用的每个模块的版本是所有依赖项中要求的最低版本

考虑下图中的示例。主模块需要版本 1.2 或更高版本的模块 A,以及版本 1.2 或更高版本的模块 B。A 1.2 和 B 1.2 分别需要 C 1.3 和 C 1.4。

C 1.3 和 C 1.4 都需要 D 1.2。所以根据 MVS 的机制,加粗黑框中的模块将会被选中。

go-build-mvs-1

这里你可能有疑问,C 的两个冲突版本 1.3 和 1.4 中最小的不是1.3吗?为什么选择1.4。MVS 不是要找依赖的最低版本,而是找满足所有模块依赖的最低版本,比如 B 1.2 如果使用到了 C 1.4 中的新增接口,那么 C 1.3 是不能满足 B 1.2 的需求的。所以,所有依赖项对 C 模块依赖的最低版本是 1.4。

B 和 D 的更高版本可用,但 MVS 不会选择它们,因为没有任何内容需要它们


如果发生依赖的冲突无法解决,可能会涉及到升级依赖、降级依赖、替代、排除等方法来解决

go-build-mvs-upgradego-build-mvs-downgrade

go-build-mvs-with-replacego-build-mvs-with-exclude

五、参考资料

Go 作为一门成熟的语言,有着详尽的文档。但官方的入门文档过于简单,而手册又过于丰富,让新人觉得无从着手。

我这篇文章主要是针对有一定Go使用经验,但是还没有透彻理解Go包、模块的同学。

如果你遇到任何问题,可以像翻字典一样,查一查 Go 的官方文档: