05 标准先行:Go项目的布局标准是什么?

你好,我是Tony Bai。

在前面的讲解中,我们编写的Go程序都是简单程序,一般由一个或几个Go源码文件组成,而且所有源码文件都在同一个目录中。但是生产环境中运行的实用程序可不会这么简单,通常它们都有着复杂的项目结构布局。弄清楚一个实用Go项目的项目布局标准是Go开发者走向编写复杂Go程序的第一步,也是必经的一步。

但Go官方到目前为止也没有给出一个关于Go项目布局标准的正式定义。那在这样的情况下,Go社区是否有我们可以遵循的参考布局,或者事实标准呢?我可以肯定的告诉你:有的。在这一节课里,我就来告诉你Go社区广泛采用的Go项目布局是什么样子的。

要想了解Go项目的结构布局以及演化历史,全世界第一个Go语言项目是一个最好的切入点。所以,我们就先来看一下Go语言“创世项目”的结构布局是什么样的。

Go语言“创世项目”结构是怎样的?

什么是“Go语言的创世项目”呢?其实就是Go语言项目自身,它是全世界第一个Go语言项目。但这么说也不够精确,因为Go语言项目从项目伊始就混杂着多种语言,而且以C和Go代码为主,Go语言的早期版本C代码的比例还不小。

我们先用loccount工具对Go语言发布的第一个Go 1.0版本分析看看:

$loccount .
all          SLOC=460992  (100.00%)	LLOC=193045  in 2746 files
Go           SLOC=256321  (55.60%)	LLOC=109763  in 1983 files
C            SLOC=148001  (32.10%)	LLOC=73458   in 368 files
HTML         SLOC=25080   (5.44%)	LLOC=0       in 57 files
asm          SLOC=10109   (2.19%)	LLOC=0       in 133 files
... ...

你会发现,在1.0版本中,Go代码行数占据一半以上比例,但是C语言代码行数也占据了32.10%的份额。而且在后续Go版本演进过程中,Go语言代码行数占比还在逐步提升,直到Go 1.5版本实现自举后,Go语言代码行数占比将近90%,C语言比例下降为不到1%,这一比例一直延续至今。

虽然C代码比例下降,Go代码比例上升,但Go语言项目的布局结构却整体保留了下来,十多年间虽然也有一些小范围变动,但整体没有本质变化。作为Go语言的“创世项目”,它的结构布局对后续Go社区的项目具有重要的参考价值,尤其是Go项目早期src目录下面的结构。

为了方便查看,我们首先下载Go语言创世项目源码:

$git clone https://github.com/golang/go.git

进入Go语言项目根目录后,我们使用tree命令来查看一下Go语言项目自身的最初源码结构布局,以Go 1.3版本为例,结果是这样的:

$cd go // 进入Go语言项目根目录
$git checkout go1.3 // 切换到go 1.3版本
$tree -LF 1 ./src // 查看src目录下的结构布局
./src
├── all.bash*
├── clean.bash*
├── cmd/
├── make.bash*
├── Make.dist
├── pkg/
├── race.bash*
├── run.bash*
... ...
└── sudo.bash*

从上面的结果来看,src目录下面的结构有这三个特点。

首先,你可以看到,以all.bash为代表的代码构建的脚本源文件放在了src下面的顶层目录下。

第二,src下的二级目录cmd下面存放着Go相关可执行文件的相关目录,我们可以深入查看一下cmd目录下的结构:

$ tree -LF 1 ./cmd
./cmd
... ...
├── 6a/
├── 6c/
├── 6g/
... ...
├── cc/
├── cgo/
├── dist/
├── fix/
├── gc/
├── go/
├── gofmt/
├── ld/
├── nm/
├── objdump/
├── pack/
└── yacc/

我们可以看到,这里的每个子目录都是一个Go工具链命令或子命令对应的可执行文件。其中,6a、6c、6g等是早期Go版本针对特定平台的汇编器、编译器等的特殊命名方式。

第三个特点,你会看到src下的二级目录pkg下面存放着运行时实现、标准库包实现,这些包既可以被上面cmd下各程序所导入,也可以被Go语言项目之外的Go程序依赖并导入。下面是我们通过tree命令查看pkg下面结构的输出结果:

# tree -LF 1 ./pkg
./pkg
... ...
├── flag/
├── fmt/
├── go/
├── hash/
├── html/
├── image/
├── index/
├── io/
... ...
├── net/
├── os/
├── path/
├── reflect/
├── regexp/
├── runtime/
├── sort/
├── strconv/
├── strings/
├── sync/
├── syscall/
├── testing/
├── text/
├── time/
├── unicode/
└── unsafe/

虽然Go语言的创世项目的src目录下的布局结构,离现在已经比较久远了,但是这样的布局特点依然对后续很多Go项目的布局产生了比较大的影响,尤其是那些Go语言早期采纳者建立的Go项目。比如,Go调试器项目Delve、开启云原生时代的Go项目Docker,以及云原生时代的“操作系统”项目Kubernetes等,它们的项目布局,至今都还保持着与Go创世项目早期相同的风格。

当然了,这些早期的布局结构一直在不断地演化,简单来说可以归纳为下面三个比较重要的演进。

演进一:Go 1.4版本删除pkg这一中间层目录并引入internal目录

出于简化源码树层次的原因,Go语言项目的Go 1.4版本对它原来的src目录下的布局做了两处调整。第一处是删除了Go源码树中“src/pkg/xxx”中pkg这一层级目录而直接使用src/xxx。这样一来,Go语言项目的源码树深度减少一层,更便于Go开发者阅读和探索Go项目源码。

另外一处就是Go 1.4引入internal包机制,增加了internal目录。这个internal机制其实是所有Go项目都可以用的,Go语言项目自身也是自Go 1.4版本起,就使用internal机制了。根据internal机制的定义,一个Go项目里的internal目录下的Go包,只可以被本项目内部的包导入。项目外部是无法导入这个internal目录下面的包的。可以说,internal目录的引入,让一个Go项目中Go包的分类与用途变得更加清晰。

演进二:Go1.6版本增加vendor目录

第二次的演进,其实是为了解决Go包依赖版本管理的问题,Go核心团队在Go 1.5版本中做了第一次改进。增加了vendor构建机制,也就是Go源码的编译可以不在GOPATH环境变量下面搜索依赖包的路径,而在vendor目录下查找对应的依赖包。

Go语言项目自身也在Go 1.6版本中增加了vendor目录以支持vendor构建,但vendor目录并没有实质性缓存任何第三方包。直到Go 1.7版本,Go才真正在vendor下缓存了其依赖的外部包。这些依赖包主要是golang.org/x下面的包,这些包同样是由Go核心团队维护的,并且其更新速度不受Go版本发布周期的影响。

vendor机制与目录的引入,让Go项目第一次具有了可重现构建(Reproducible Build)的能力。

演进三:Go 1.13版本引入go.mod和go.sum

第三次演进,还是为了解决Go包依赖版本管理的问题。在Go 1.11版本中,Go核心团队做出了第二次改进尝试:引入了Go Module构建机制,也就是在项目引入go.mod以及在go.mod中明确项目所依赖的第三方包和版本,项目的构建就将摆脱GOPATH的束缚,实现精准的可重现构建。

Go语言项目自身在Go 1.13版本引入go.mod和go.sum以支持Go Module构建机制,下面是Go 1.13版本的go.mod文件内容:

module std

go 1.13

require (
	golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8
	golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7
	golang.org/x/sys v0.0.0-20190529130038-5219a1e1c5f8 // indirect
	golang.org/x/text v0.3.2 // indirect
)

我们看到,Go语言项目自身所依赖的包在go.mod中都有对应的信息,而原本这些依赖包是缓存在vendor目录下的。

总的来说,这三次演进主要体现在简化结构布局,以及优化包依赖管理方面,起到了改善Go开发体验的作用。可以说,Go创世项目的源码布局以及演化对Go社区项目的布局具有重要的启发意义,以至于在多年的Go社区实践后,Go社区逐渐形成了公认的Go项目的典型结构布局。

现在的Go项目的典型结构布局是怎样的?

一个Go项目通常分为可执行程序项目和库项目,现在我们就来分析一下这两类Go项目的典型结构布局分别是怎样的。

首先我们先来看Go可执行程序项目的典型结构布局。

可执行程序项目是以构建可执行程序为目的的项目,Go社区针对这类Go项目所形成的典型结构布局是这样的:

$tree -F exe-layout 
exe-layout
├── cmd/
│   ├── app1/
│   │   └── main.go
│   └── app2/
│       └── main.go
├── go.mod
├── go.sum
├── internal/
│   ├── pkga/
│   │   └── pkg_a.go
│   └── pkgb/
│       └── pkg_b.go
├── pkg1/
│   └── pkg1.go
├── pkg2/
│   └── pkg2.go
└── vendor/

这样的一个Go项目典型布局就是“脱胎”于Go创世项目的最新结构布局,我现在跟你解释一下这里面的几个要点。

我们从上往下按顺序来,先来看cmd目录。cmd目录就是存放项目要编译构建的可执行文件对应的main包的源文件。如果你的项目中有多个可执行文件需要构建,每个可执行文件的main包单独放在一个子目录中,比如图中的app1、app2,cmd目录下的各app的main包将整个项目的依赖连接在一起。

而且通常来说,main包应该很简洁。我们在main包中会做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高级的执行控制对象。另外,也有一些Go项目将cmd这个名字改为app或其他名字,但它的功能其实并没有变。

接着我们来看pkgN目录,这是一个存放项目自身要使用、同样也是可执行文件对应main包所要依赖的库文件,同时这些目录下的包还可以被外部项目引用。

然后是go.modgo.sum,它们是Go语言包依赖管理使用的配置文件。我们前面说过,Go 1.11版本引入了Go Module构建机制,这里我建议你所有新项目都基于Go Module来进行包依赖管理,因为这是目前Go官方推荐的标准构建模式。

对于还没有使用Go Module进行包依赖管理的遗留项目,比如之前采用dep、glide等作为包依赖管理工具的,建议尽快迁移到Go Module模式。Go命令支持直接将dep的Gopkg.toml/Gopkg.lock或glide的glide.yaml/glide.lock转换为go.mod。

最后我们再来看看vendor目录。vendor是Go 1.5版本引入的用于在项目本地缓存特定版本依赖包的机制,在Go Modules机制引入前,基于vendor可以实现可重现构建,保证基于同一源码构建出的可执行程序是等价的。

不过呢,我们这里将vendor目录视为一个可选目录。原因在于,Go Module本身就支持可再现构建,而无需使用vendor。 当然Go Module机制也保留了vendor目录(通过go mod vendor可以生成vendor下的依赖包,通过go build -mod=vendor可以实现基于vendor的构建)。一般我们仅保留项目根目录下的vendor目录,否则会造成不必要的依赖选择的复杂性。

当然了,有些开发者喜欢借助一些第三方的构建工具辅助构建,比如:make、bazel等。你可以将这类外部辅助构建工具涉及的诸多脚本文件(比如Makefile)放置在项目的顶层目录下,就像Go创世项目中的all.bash那样。

另外,这里只要说明一下的是,Go 1.11引入的module是一组同属于一个版本管理单元的包的集合。并且Go支持在一个项目/仓库中存在多个module,但这种管理方式可能要比一定比例的代码重复引入更多的复杂性。 因此,如果项目结构中存在版本管理的“分歧”,比如:app1和app2的发布版本并不总是同步的,那么我建议你将项目拆分为多个项目(仓库),每个项目单独作为一个module进行单独的版本管理和演进。

当然如果你非要在一个代码仓库中存放多个module,那么新版Go命令也提供了很好的支持。比如下面代码仓库multi-modules下面有三个module:mainmodule、module1和module2:

$tree multi-modules
multi-modules
├── go.mod // mainmodule
├── module1
│   └── go.mod // module1
└── module2
    └── go.mod // module2

我们可以通过git tag名字来区分不同module的版本。其中vX.Y.Z形式的tag名字用于代码仓库下的mainmodule;而module1/vX.Y.Z形式的tag名字用于指示module1的版本;同理,module2/vX.Y.Z形式的tag名字用于指示module2版本。

如果Go可执行程序项目有一个且只有一个可执行程序要构建,那就比较好办了,我们可以将上面项目布局进行简化:

$tree -F -L 1 single-exe-layout
single-exe-layout
├── go.mod
├── internal/
├── main.go
├── pkg1/
├── pkg2/
└── vendor/

你可以看到,我们删除了cmd目录,将唯一的可执行程序的main包就放置在项目根目录下,而其他布局元素的功用不变。

好了到这里,我们已经了解了Go可执行程序项目的典型布局,现在我们再来看看Go库项目的典型结构布局是怎样的。

Go库项目仅对外暴露Go包,这类项目的典型布局形式是这样的:

$tree -F lib-layout 
lib-layout
├── go.mod
├── internal/
│   ├── pkga/
│   │   └── pkg_a.go
│   └── pkgb/
│       └── pkg_b.go
├── pkg1/
│   └── pkg1.go
└── pkg2/
    └── pkg2.go

我们看到,库类型项目相比于Go可执行程序项目的布局要简单一些。因为这类项目不需要构建可执行程序,所以去除了cmd目录。

而且,在这里,vendor也不再是可选目录了。对于库类型项目而言,我们并不推荐在项目中放置vendor目录去缓存库自身的第三方依赖,库项目仅通过go.mod文件明确表述出该项目依赖的module或包以及版本要求就可以了。

Go库项目的初衷是为了对外部(开源或组织内部公开)暴露API,对于仅限项目内部使用而不想暴露到外部的包,可以放在项目顶层的internal目录下面。当然internal也可以有多个并存在于项目结构中的任一目录层级中,关键是项目结构设计人员要明确各级internal包的应用层次和范围。

对于有一个且仅有一个包的Go库项目来说,我们也可以将上面的布局做进一步简化,简化的布局如下所示:

$tree -L 1 -F single-pkg-lib-layout
single-pkg-lib-layout
├── feature1.go
├── feature2.go
├── go.mod
└── internal/

简化后,我们将这唯一包的所有源文件放置在项目的顶层目录下(比如上面的feature1.go和feature2.go),其他布局元素位置和功用不变。

好了,现在我们已经了解完目前Go项目的典型结构布局了。不过呢,除了这些之外,还要注意一下早期Go可执行程序项目的经典布局,这个又有所不同。

注意早期Go可执行程序项目的典型布局

很多早期接纳Go语言的开发者所建立的Go可执行程序项目,深受Go创世项目1.4版本之前的布局影响,这些项目将所有可暴露到外面的Go包聚合在pkg目录下,就像前面Go 1.3版本中的布局那样,它们的典型布局结构是这样的:

$tree -L 3 -F early-project-layout
early-project-layout
└── exe-layout/
    ├── cmd/
    │   ├── app1/
    │   └── app2/
    ├── go.mod
    ├── internal/
    │   ├── pkga/
    │   └── pkgb/
    ├── pkg/
    │   ├── pkg1/
    │   └── pkg2/
    └── vendor/

我们看到,原本放在项目顶层目录下的pkg1和pkg2公共包被统一聚合到pkg目录下了。而且,这种早期Go可执行程序项目的典型布局在Go社区内部也不乏受众,很多新建的Go项目依然采用这样的项目布局。

所以,当你看到这样的布局也不要奇怪,并且在我的讲解后,你应该就明确在这样的布局下pkg目录所起到的“聚类”的作用了。不过,在这里还是建议你在创建新的Go项目时,优先采用前面的标准项目布局。

小结

到这里,我们今天这门课就结束了。在这一节课里,我们学习了Go创世项目,也就是Go语言项目自身的项目源码布局,以及演进情况。在Go创世项目的启发下,Go社区在多年实践中形成了典型的Go项目结构布局形式。

我们将Go项目分为可执行程序项目和Go库项目两类进行了详细的项目典型布局讲解,这里简单回顾一下。

首先,对于以生产可执行程序为目的的Go项目,它的典型项目结构分为五部分:

第二,对于以生产可复用库为目的的Go项目,它的典型结构则要简单许多,我们可以直接理解为在Go可执行程序项目的基础上去掉cmd目录和vendor目录。

最后,早期接纳Go语言的开发者所建立的项目的布局深受Go创世项目1.4版本之前布局的影响,将可导出的公共包放入单独的pkg目录下,我们了解这种情况即可。对于新建Go项目,我依旧建议你采用前面介绍的标准布局形式。

现在,如果你要再面对一个要用于生产环境的Go应用项目的布局问题,是不是胸有成竹了呢?

思考题

如果非要你考虑Go项目结构的最小标准布局,那么你觉得这个布局中都应该包含哪些东西呢?欢迎在留言区留下你的答案。

感谢你和我一起学习,也欢迎你把这节课分享给更多对Go项目布局感兴趣的朋友。我是Tony Bai,我们下节课见。