除非是像“hello world”这样的简单程序,但凡我们编写一些 non-trivial 的实用程序或库,我们都会遇到采用什么样的项目结构(project structure)的问题(通常一个项目对应一个仓库 repository)。在 Go 语言中,项目结构同样十分重要,因为这决定了项目内部包(package)的布局以及包依赖关系是否合理,同时还会影响到外部项目对该项目中包的依赖。
1. Go 项目的项目结构
我们先来看看世界上第一个 Go 项目- Go 语言自身的项目结构是什么样的。
Go 项目的项目结构从发布 1.0 版本以来一直十分稳定,直到现在 Go 项目的顶层结构基本没有大的改变。截至 go 项目 commit 1e3ffb0c(2019.5.14),go 项目结构如下:
# tree -LF 1 ~/go/src/github.com/golang/go
./go
├── api/
├── AUTHORS
├── CONTRIBUTING.md
├── CONTRIBUTORS
├── doc/
├── favicon.ico
├── lib/
├── LICENSE
├── misc/
├── PATENTS
├── README.md
├── robots.txt
├── src/
└── test/
6 directories, 8 files
作为 Go 语言的“创世项目”,其项目结构对后续的其他 Go 语言项目具有重要的参考意义,尤其是 go 项目早期 src 目录下面的结构,以 Go 1.3 版本为例:
# tree -LF 1 ./src
./src
├── all.bash*
├── all.bat
├── all.rc*
├── clean.bash*
├── clean.bat
├── clean.rc*
├── cmd/
├── lib9/
├── libbio/
├── liblink/
├── make.bash*
├── make.bat
├── Make.dist
├── make.rc*
├── nacltest.bash*
├── pkg/
├── race.bash*
├── race.bat
├── run.bash*
├── run.bat
├── run.rc*
└── sudo.bash*
5 directories, 17 files
关于 src 下面的结构,我们总结三个特点:
- 代码构建的脚本源文件放在 src 下面的顶层目录下;
- src 下的二级目录 cmd 下面存放着 go 相关可执行文件的相关目录以及 main 包;
# tree -LF 1 ./cmd
./cmd
... ...
├── 6a/
├── 6c/
├── 6g/
... ...
├── cc/
├── cgo/
├── dist/
├── fix/
├── gc/
├── go/
├── gofmt/
├── ld/
├── nm/
├── objdump/
├── pack/
└── yacc/
26 directories, 0 files
- src 下的二级目录 pkg 下面存放着上面 cmd 下各程序依赖的包、go 运行时以及 go 标准库的源文件
# tree -LF 1 ./pkg
./pkg
... ...
├── flag/
├── fmt/
├── go/
├── hash/
├── html/
├── image/
├── index/
├── io/
├── log/
├── math/
... ...
├── net/
├── os/
├── path/
├── reflect/
├── regexp/
├── runtime/
├── sort/
├── strconv/
├── strings/
├── sync/
├── syscall/
├── testing/
├── text/
├── time/
├── unicode/
└── unsafe/
39 directories, 0 files
虽然 Go 1.4 版本中删除了 Go 源码树中“src/pkg/xxx”中 pkg 这一层级目录而直接使用 src/xxx,但早期 Go 项目 src 目录下的这种结构布局特点依然对后续多数 Go 语言项目的项目结构产生了较大的影响。
2. Go 语言典型项目结构(构建二进制可执行文件类型)
基于上述参考项目结构,Go 社区在多年的 Go 语言实践积累后逐渐形成了一种典型项目结构,如下图所示:
上面就是一个支持构建二进制可执行文件(在 cmd 下)的典型 Go 项目的结构。
- cmd 目录:存放项目要编译构建的可执行文件对应的 main 包的源文件。如果有多个可执行文件需要构建,每个可执行文件的 main 包单独放在一个子目录中,比如图中的 app1、app2;cmd 目录下的各 app 的 main 包将整个项目的依赖连接在一起;并且通常来说,main 包应该很简洁。我们在 main 包中会做一些命令行参数解析、资源初始化、日志设施初始化、数据库连接初始化等工作,之后就会将程序的执行权限交给更高级的执行控制对象;也有一些 go 项目将 cmd 这个名字改为 app,但其功用并没有变;
- pkg 目录:存放项目自身要使用、同样也是可执行文件对应 main 包所要依赖的库文件;同时该目录下的包还可以被外部项目引用;也有些项目将 pkg 这个名字改为 lib,但目录用途不变;
- Makefile:这里的 Makefile 是项目构建工具所用脚本的“代表”。Go 并没有内置如 make、bazel 等级别的项目构建工具,对于一些规模稍大的项目而言,项目构建工具似乎还不可缺少。在 Go 典型项目中,项目构建工具的脚本一般放在项目顶层目录下,比如这里的 Makefile;对于构建脚本较多的项目,也可以建立 build 目录,并将构建脚本的规则属性文件、子构建脚本放入其中;
- go.mod 和 go.sum:Go 语言包依赖管理使用的配置文件。Go 1.11 版本引入 go modules 机制,因此新项目建议基于 go modules 进行包依赖管理;对于没有使用 go modules 进行包管理的项目,这里可以换为 dep 的 Gopkg.toml 和 Gopkg.lock 或者 glide 的 glide.yaml 和 glide.lock 等;
- vendor 目录(可选):vendor 是 Go 1.5 版本引入的用于在项目本地缓存特定版本依赖包的机制,在 go modules 机制引入前,基于 vendor 可以实现可再现构建(reproducible build),保证基于同一源码构建出的可执行程序是等价的,这个机制是对中国大陆地区的 gopher 们尤为实用。go modules 本身就可以实现可再现构建,而无需 vendor,因此这里将 vendor 目录视为一个可选目录。一般我们仅保留项目根目录下的 vendor 目录,否则会造成不必要的依赖选择的复杂性。
Go 1.11 引入的 module 是一组同属于一个版本管理单元的包的集合。如果项目结构中存在版本管理的“分歧”,比如:app1 和 app2 的发布版本并不总是同步的,那么建议将项目拆分为多个项目(仓库),每个项目单独承载一个 module 进行单独的版本管理和演进。
Go 支持在一个项目/仓库中存在多个 module,但这种管理方式可能要比一定比例的代码重复引入更多的复杂性。
3. Go 语言典型项目结构(构建库类型)
Go 1.4 发布时,Go 语言项目自身去掉了 src 下的 pkg 这一层目录,这个结构上的改变对那些只编译为库的 Go 语言库类型项目结构有着一定的影响。我们来看一个典型的 Go 语言库类型项目的结构布局:
我们看到库类型项目相比于构建二进制可执行文件的项目要简单一些:
- 去除了 cmd 和 pkg 两个子目录;
- vendor 也不再是可选目录:对于库类型项目而言,我们不推荐在项目中放置 vendor 目录去缓存库自身的第三方依赖,库项目仅通过 go.mod(或其他包依赖管理工具的 manifest 文件)明确表述出该项目依赖的模块或包以及版本要求即可。
Go 库项目的初衷是为了对外部(开源或组织内部公开)暴露 API,对于仅限项目内部使用的包,在项目结构上可以通过 Go 1.4 版本中引入的 internal 包机制来实现。对库项目而言,最简单的方式就是在顶层加入一个 internal 目录,将不想暴露到外部的包都放在该目录下,比如下面项目结构中的 ilib1、ilib2:
// 带internal的Go库项目结构
GoLibProj
├── LICENSE
├── Makefile
├── README.md
├── go.mod
├── internal/
│ ├── ilib1/
│ └── ilib2/
├── lib.go
├── lib1/
│ └── lib1.go
└── lib2/
└── lib2.go
这样,根据 go internal 机制的作用原理,internal 目录下的 ilib1、ilib2 可以被以 GoLibProj 目录为根目录的其他目录下的代码(比如 lib.go、lib1/lib1.go 等)所导入和使用,但是却不可以为 GoLibProj 目录以外的代码所使用,从而实现选择性的暴露 API 包。当然 internal 也可以放在项目结构中的任一目录层级中,关键是项目结构设计人员明确哪些要暴露到外层代码,哪些仅用于同级目录或子目录中。
4. 小结
以上的两个针对构建二进制可执行文件类型以及库类型的项目参考结构是 Go 社区在多年实践后得到公认且使用较为广泛的项目结构。但它们也不是银弹,在 Go 语言早期,很多项目将所有源文件都放在位于项目根目录下的根包中的作法在一些小规模项目中同样工作得很好,虽然我们现在不推荐这么做了。
对于以构建二进制可执行文件类型为目的的项目来说,受 Go 1.4 项目结构影响,将 pkg 这一层次目录去掉也是很多项目选择的结构布局方式。
上述的参考项目结构与产品设计开发领域的“最小可行产品”(minimum viable product,简称为 mvp)的思路有些异曲同工,开发者可以在这样一个最小的“项目结构核心”的基础上根据实际需要对其进行扩展。