前段时间读了微信技术团队的一篇文章(从微信后端仓库发展史谈谈单仓和多仓),讲微信是如何在一步步壮大的过程中,逐步从大仓中剥离公众号、小程序等功能到小仓中,从而解决了各种公共库全都塞在同一个文件夹下导致的耦合、权限等问题。
在这个过程中,微信团队总结了一些经验教训,包括提出了合理规划项目文件结构的重要性:如果一股脑地将所有代码按照功能划分到不同的文件夹中,可能会因为未来的需求,导致文件夹之间产生强耦合,从而影响到代码的可维护性和可扩展性。
看完此文,我想就此谈一谈我对大型项目目录组织的看法。正好,最近 Go 写得比较多,就先从 Go 的项目组织谈起。
不过,谈论 Go 的项目组织前,我不得不搬出 Go 语言作者 Russ Cox 的一则 comment:
There are two problems with this GitHub repo:
- it claims to host Go standards and does not, in the sense that these are in no way official standards
- the project-layout standard it puts forth is far too complex and not a standard
Regarding “why not tell us the standard Go project layout and we’ll update the doc?”, that only addresses point 2. If there really were standards, they would be in the main Go project doc tree. The standard for project layout would also be a lot shorter. I appreciate your trying to provide a useful resource, but calling it ‘golang-standards’ is claiming more than it is.
But for the record, the minimal standard layout for an importable Go repo is really:
- Put a
LICENSE
file in your root - Put a
go.mod
file in your root - Put Go code in your repo, in the root or organized into a directory tree as you see fit
That’s it. That’s the “standard”.
In particular:
- It is not required to put commands in
cmd/
. - It is not required to put packages in
pkg/
. - It is not required to put web stuff in
web/
. - It is not required to put APIs in
api/
. - It is not required to put web stuff in
web/
. - It is not required to put configurations in
configs/
. - It is not required to put systemd scripts in
init/
. - It is not required to put shell scripts in
scripts/
. - It is not required to put Docker files in
build/package/
. - It is not required to put CI configs in
build/ci/
. - It is not required to put deployment configs in
deployments/
. - It is not required to put test support in
test/
. - It is not required to put documentation in
docs/
. - It is not required to put supporting tools in
tools/
. - It is not required to put examples in
examples/
. - It is not required to put third_party code in
third_party/
. - It is not required to put git hooks in
githooks/
. - It is not required to put static assets in
assets/
.
The importable golang.org/x repos break every one of these “rules”.
可以看到,Go 项目并没有强制要求特定的目录结构,开发者可以根据实际情况灵活组织项目————事实上,如果观察流行的 Go 开源项目,同样也会发现它们的目录结构五花八门。
Go 官方文档 给出了一个基础的目录结构,不过看起来只适用于很小的项目;而 golang-standards/project-layout 给出了一个较为通用的项目结构示例,我觉得还不错:
.
├── cmd
│ └── myapp
│ └── main.go
├── internal
│ ├── myapp
│ │ └── app.go
│ └── mylib
│ └── lib.go
├── pkg
│ └── utils
│ └── helper.go
├── api
│ ├── myapp.proto
│ └── myapp.pb.go
├── web
│ ├── static
│ │ ├── css
│ │ ├── js
│ │ └── images
│ └── templates
│ └── index.html
├── build
│ ├── Dockerfile
│ └── Makefile
├── configs
│ ├── config.yaml
│ └── config.json
├── init
│ └── db.sql
├── scripts
│ ├── migrate.sh
│ └── deploy.sh
├── deploy
│ └── k8s
│ ├── deployment.yaml
│ └── service.yaml
├── test
│ ├── integration
│ └── testdata
├── docs
│ └── README.md
├── examples
│ └── simple
│ └── main.go
├── go.mod
├── go.sum
└── LICENSE
下面来详细解释各个目录的作用:
-
cmd
cmd
目录通常用于存放应用程序的入口点。每个子目录对应一个可执行程序,通常包含一个main.go
文件。例如,项目中有一个名为
myapp
的 RPC 应用,cmd/myapp/main.go
可能如下所示:package main import ( "github.com/myorg/myapp/internal/myapp" "github.com/myorg/myapp/internal/mylib" ) func main() { myapp.Start() mylib.Start() }
不过,如果整个项目只有一个
main.go
,那么放到根目录也无妨。 -
internal
internal
目录用于存放不希望被外部引用的代码。Go 语言的包管理机制会阻止外部包引用internal
目录下的内容。例如,对于前文所述项目,
internal/myapp/*.go
存放有该应用的实现,可能如下所示:package myapp import "fmt" func Start() { fmt.Println("myapp started") }
对于多个应用共用的包,也同样可以将其放在
internal
目录下的一个子目录中,例如internal/mylib
,然后在需要使用这些共享代码的应用中引用即可。 -
pkg
pkg
目录用于存放可以被外部引用的代码。与internal
目录不同,pkg
目录下的内容可以被其他项目引用。以
fatedier/frp 为例:
pkg ├── auth ├── config ├── errors ├── featuregate ├── metrics ├── msg └── ...
可以看到,它是把本该属于
internal
的公共库代码提取到了pkg
目录下。通常在两种情况下会使用
pkg
目录:-
如果你的项目希望别人可以向其添加插件——而不用修改内部代码,那么将相关的功能抽象成
pkg
目录下的库是一个不错的选择。 -
别的项目需要使用当前项目中的库。例如,你的一个项目做了企业权限管理系统,这时候你又做了一个新的项目,需要用到这个权限管理系统的某些功能,这时就可以直接引用
pkg
目录下的库,而不需要复制粘贴代码。
目前主流的 Go 项目似乎很少使用
pkg
目录,我看了一圈也只有fatedier/frp 采用了这种方式。
此外,对于上述第二种情况,更好的做法是将共享的功能抽象成一个独立的库,与具体项目进行解耦。
这里,我想引用
Russ Cox 的一则 Issue 来佐证以上内容:
-
For the initial development of module support, we’ve kept everything in internal
directories to make it easier to make changes as our understanding of what the pieces should look like becomes clearer. But now I think the basic pieces have become pretty clear, and we should think about exporting some packages to help other people who want to write tools working directly with module mechanics.
…
-
api
api
目录用于存放对外提供的接口定义,通常会包括:- OpenAPI / Swagger specs
- JSON Schema
- GraphQL Schema
- Protobuf
以
kubernetes/kubernetes 为例:
api ├── api-rules │ └── *.list ├── discovery │ └── *.json └── openapi-spec └── *.json
对于前文所述项目,
api/myapp.proto
存放有该应用的 gRPC 接口定义,可能如下所示:syntax = "proto3"; package myapp; service MyApp { rpc Start(StartRequest) returns (StartResponse); } message StartRequest { string name = 1; } message StartResponse { string message = 1; }
同时,也包括了对应的
api/myapp.pb.go
文件。 -
web
web
目录用于存放与 Web 相关的代码,通常包括:- 静态文件(如 HTML、CSS、JavaScript)
- 模板文件
- Web 服务器相关代码
以
prometheus/prometheus 为例,其
web
目录中就包含了网页服务程序、API 接口、UI 组件等。对于前文所述项目,
web/static
目录可能存放有静态文件,web/templates
目录可能存放有模板文件。这一目录不仅可以用在全栈项目中,对于当前较为流行的服务器端渲染(SSR)框架同样适用。
-
build
build
目录用于存放与项目构建相关的文件,通常包括:- Dockerfile
- CI/CD 配置文件
- 打包脚本
以
kubernetes/kubernetes 为例,其
build
目录中就包含了大量的.sh
脚本和Dockerfile
文件。 -
config
config
目录用于存放项目的配置文件。以
prometheus/prometheus 为例,其
config
目录中就包含了config.go
和reload.go
两个文件及对应的测试文件,用来处理配置文件的加载和重载。 -
init
init
目录用于存放项目的初始化代码,通常包括:- 数据库初始化
- 配置文件加载
- 日志初始化
-
scripts
scripts
目录用于存放项目的脚本文件,通常包括:- 数据库迁移脚本
- 备份脚本
- 部署脚本
这里也会经常放一些供
Makefile
使用的脚本。以
gohugoio/hugo 为例,其
scripts
目录中包含了 Docker 需要使用的entrypoint.sh
脚本。 -
deploy
deploy
目录用于存放与项目部署相关的文件,通常包括:- K8s 配置文件
- Docker Compose 文件
有些地方也会用
deployments
目录。以
gohugoio/hugo 为例,其
deploy
目录中包含了deploy.go
和部属用的配置文件。 -
test
test
目录用于存放项目的测试代码。运行测试时,Go 会自动识别该目录下的测试文件并执行相应的测试用例。对于不需要 Go 运行的文件,可以放入
test/data
或test/testdata
目录中,也可以在文件名开头加上.
或_
。以
syncthing/syncthing 为例,其
test
目录中包含了各类测试文件及测试需要用到的辅助文件。 -
docs
&examples
docs
目录用于存放项目的文档(不是go doc
生成的文档);examples
目录用于存放项目的示例代码。以
gin-gonic/gin 为例,其
docs
目录中包含了文档doc.md
;而examples
目录中则……好吧,它们只留了一个README.md
在里面,告诉你示例代码被移动到了一个单独的仓库中。 -
其它
其它目录可以根据项目需要自行定义,例如:
-
assets
:用于存放静态资源,如图片、字体等 -
third_party
:用于存放第三方依赖的代码和工具 -
vendor
:用于存放项目的依赖包 -
tools
:用于存放项目的开发工具 -
githooks
:用于存放 Git 钩子脚本
-
以上便是一个较好的 Go 项目目录结构示例。
当然,具体的项目结构还需要根据实际情况进行调整和优化。比如,ollama/ollama 作为一个 AI 项目,根目录下就多了诸如
llm
、lm
、model
、openai
这样的子目录。
总之,合理的目录结构是良好架构的重要组成部分。当代码规模扩大时,它能够有效地降低模块之间的耦合度,提高代码的可维护性和可扩展性。
Comments