gitbook/Tony Bai · Go语言第一课/docs/429143.md
2022-09-03 22:05:03 +08:00

341 lines
19 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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工具](https://gitlab.com/esr/loccount)对Go语言发布的第一个[Go 1.0版本](https://github.com/golang/go/releases/tag/go1)分析看看:
```plain
$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语言创世项目源码:
```plain
$git clone https://github.com/golang/go.git
```
进入Go语言项目根目录后,我们使用tree命令来查看一下Go语言项目自身的最初源码结构布局,以Go 1.3版本为例,结果是这样的:
```plain
$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目录下的结构:
```plain
$ 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下面结构的输出结果:
```plain
# 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文件内容:
```plain
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项目所形成的典型结构布局是这样的:
```plain
$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.mod**和**go.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:
```plain
$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可执行程序项目有一个且只有一个可执行程序要构建,那就比较好办了,我们可以将上面项目布局进行简化:
```plain
$tree -F -L 1 single-exe-layout
single-exe-layout
├── go.mod
├── internal/
├── main.go
├── pkg1/
├── pkg2/
└── vendor/
```
你可以看到,我们删除了cmd目录,将唯一的可执行程序的main包就放置在项目根目录下,而其他布局元素的功用不变。
**好了到这里,我们已经了解了Go可执行程序项目的典型布局,现在我们再来看看Go库项目的典型结构布局是怎样的。**
Go库项目仅对外暴露Go包,这类项目的典型布局形式是这样的:
```plain
$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库项目来说,我们也可以将上面的布局做进一步简化,简化的布局如下所示:
```plain
$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版本中的布局那样,它们的典型布局结构是这样的:
```plain
$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 Module相关文件,包括go.mod和go.sum;
* cmd目录:存放项目要编译构建的可执行文件所对应的main包的源码文件;
* 项目包目录:每个项目下的非main包都“平铺”在项目的根目录下,每个目录对应一个Go包;
* internal目录:存放仅项目内部引用的Go包,这些包无法被项目之外引用;
* vendor目录:这是一个可选目录,为了兼容Go 1.5引入的vendor构建模式而存在的。这个目录下的内容均由Go命令自动维护,不需要开发者手工干预。
第二,对于以生产可复用库为目的的Go项目,它的典型结构则要简单许多,我们可以直接理解为在Go可执行程序项目的基础上去掉cmd目录和vendor目录。
最后,早期接纳Go语言的开发者所建立的项目的布局深受Go创世项目1.4版本之前布局的影响,将可导出的公共包放入单独的pkg目录下,我们了解这种情况即可。对于新建Go项目,我依旧建议你采用前面介绍的标准布局形式。
现在,如果你要再面对一个要用于生产环境的Go应用项目的布局问题,是不是胸有成竹了呢?
### 思考题
如果非要你考虑Go项目结构的最小标准布局,那么你觉得这个布局中都应该包含哪些东西呢?欢迎在留言区留下你的答案。
感谢你和我一起学习,也欢迎你把这节课分享给更多对Go项目布局感兴趣的朋友。我是Tony Bai,我们下节课见。