Menu

Go 项目结构:cmd、internal 与包的组织方式

如何组织一个 Go 项目:从扁平结构开始,有理由时再拆分成多个包,多个可执行文件用 cmd/,不允许别人导入的代码放进 internal/,给包起好名字,并把测试放在代码旁边。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

从一个文件开始

一个 Go 程序完全可以只写在一个 main.go 里,小工具也应该如此。下面这个完整的程序在一个包里解析输入、完成工作并打印报告:

当它变大时,把它拆成多个文件,放在同一个目录、同一个包中(parse.go、report.go、main.go)。同一个包中的文件共享所有标识符,所以不需要导出或导入任何东西。对于几千行代码的项目,这往往就是它需要的全部结构。

记住,由多个文件组成的 package main 必须作为一个包来运行:用 go run .,而不是 go run main.go。否则文件会被单独编译,其他文件中定义的名字就会报 undefined 错误。

没有强制的布局

除了“一个目录就是一个包”之外,Go 不要求任何目录结构。官方指南是 go.dev 上的 Organizing a Go module 页面,它介绍的是几种形态,而不是规则。

GitHub 仓库 golang-standards/project-layout 被广泛照搬,也常被误认为是一种标准。它是社区从大型项目中收集来的惯例,时任 Go 技术负责人的 Russ Cox 曾在那里开了一个 issue,说明它并不是 Go 的标准。它的大多数目录(pkg/、api/、build/、deployments/)只对大型代码库有意义。把整套骨架复制到新项目里,只会带来空文件夹和很深的导入路径,毫无益处。

go 工具强制执行的规则很少:

  • 一个目录就是一个包。目录中所有 .go 文件(使用 _test 后缀包名的 _test.go 文件除外)共用一个包名。
  • 导入路径是模块路径加上目录路径。
  • 名为 internal 的目录会限制谁能导入其中的内容。
  • 名为 testdata 的目录,以及以 . 或 _ 开头的目录,会被 go 工具忽略。

常见的形态

单个命令

todo/
├── go.mod        module github.com/you/todo
├── main.go
├── parse.go
├── report.go
└── parse_test.go

扁平结构,所有代码都在 package main 中。go install github.com/you/todo@latest 可以直接使用,生成的可执行文件名为 todo。

一个库

slug/
├── go.mod        module github.com/you/slug
├── slug.go       package slug
├── slug_test.go
├── example_test.go
└── internal/
    └── table/    helpers the public package uses, hidden from users

包位于模块根目录,所以用户导入 github.com/you/slug 并调用 slug.Make(...)。任何你不想作为公开 API 来维护的东西,都放到 internal/ 下。

一个服务或多个可执行文件

shop/
├── go.mod                module github.com/you/shop
├── cmd/
│   ├── shop-api/
│   │   └── main.go       package main: flags, config, wiring
│   └── shop-worker/
│       └── main.go
├── internal/
│   ├── order/            package order: domain types and logic
│   │   ├── order.go
│   │   └── order_test.go
│   ├── store/            package store: database access
│   └── httpapi/          package httpapi: handlers, routing
├── migrations/
└── README.md

cmd/<name>/main.go 为每个可执行文件准备一个目录,目录名就是可执行文件的名字(go build ./cmd/shop-api 生成 shop-api)。每个 main 都保持精简:读取配置、构造依赖、启动服务器。真正的代码放在 internal/ 下的包里,两个可执行文件都能使用,而其他模块都不能。

大多数 Go 服务最终都会收敛到这种布局。当你有了第二个可执行文件,或者确实有理由拆分包时再采用它,不必从第一天就这样做。

internal/ 由 go 命令强制执行

internal/ 下的包只能被以 internal 的父目录为根的目录树中的代码导入。从另一个模块导入,构建就会失败:

package example.com/b
	main.go:6:2: use of internal package example.com/a/internal/secret not allowed

这让 internal/ 成为控制 API 范围的工具。其中的代码可以随意修改,因为你知道所有调用方都在你自己的仓库里。对于应用程序,把几乎所有东西都放进 internal/ 是合理的。对于库,它把你承诺保持稳定的部分和不做承诺的部分分开。

internal 在任何层级都有效:shop/internal/order 对整个 shop/ 可见,而 shop/internal/order/internal/pricing 只在 shop/internal/order/ 内部可见。

包的命名

包名会出现在每一处调用中,所以它比目录树更重要。

  • 简短、全小写、一个单词: order、store、httpapi。不用下划线,也不用大小写混写。
  • 按它提供的功能命名,而不是按它包含的东西。util、common、helpers、misc 和 models 什么也说明不了,最终会变成杂物堆;Go 团队的风格指南明确不鼓励这样命名。把辅助函数放到使用它的包里,或者放到一个按用途命名的包中(slug、retry)。
  • 避免重复。 调用方写的是 order.Order 和 order.New,所以不要起 order.OrderService 或 order.NewOrder 这样的名字。标准库一贯如此:是 http.Server,而不是 http.HTTPServer。
  • 目录名和包名应该一致,命令目录中的 package main 除外。两者不一致时,读代码的人必须打开文件才能知道这个导入引入的是什么标识符。

按职责拆分包,而不是按种类拆分。models/、controllers/、services/ 这种拆分方式(在其他生态中很常见)会让每个功能都要改动三个包,而且容易产生循环导入,而 Go 禁止循环导入。围绕领域概念组织的包(order、payment、user),各自把自己的类型和逻辑放在一起。

测试放在哪里

测试和代码放在同一个目录中,文件名为 *_test.go。没有 tests/ 目录树。

  • order_test.go 中的 package order:内部测试,可以使用未导出的标识符。
  • 同一目录中的 package order_test:外部测试,像调用方一样只能看到导出的 API。适合编写示例,也能避免测试中出现循环导入。
  • 测试用的数据文件放在测试旁边的 testdata/ 目录中。go 工具会忽略它,而且测试运行时以包目录作为工作目录,所以 testdata/big.json 这样的相对路径可以正常使用。

根目录中的其他文件

文件或目录用途
go.mod、go.sum模块定义和依赖校验和,总是放在根目录
README.md、LICENSE和任何项目一样
Makefile 或 Taskfile.yml可选的构建快捷方式
Dockerfile对于服务,通常放在根目录
migrations/、web/、docs/非 Go 资源,按内容命名
tools.go固定工具依赖的旧方法;Go 1.24 用 go.mod 中的 tool 行(go get -tool)取代了它
go.work在本地同时开发多个模块用的工作区;单模块项目通常不提交它

常见错误

  • 给小项目套用庞大的模板。 从扁平结构开始,当出现第二个可执行文件或真正的边界时再添加目录。
  • 名为 utils 或 common 的包。 按包的功能给它命名。
  • 每个文件或每个类型一个包。 Go 的包本来就比 Java 的类更大。一个包有十个文件很正常。
  • 循环导入。 两个包不能互相导入。这通常说明它们本该放在一起,或者某个共用类型应该移到更底层的包里,或者其中一方应该依赖一个小接口,而不是另一个包。
  • 条件反射式地使用 pkg/。 它给每个导入路径都加了一段,却没有增加任何含义。
  • 把测试放在单独的目录中。 在那里测试看不到未导出的代码,工具链也不这样预期。

常见问题

Go 有官方的项目布局吗?

没有强制性的布局。Go 团队在 go.dev/doc/modules/layout 发布了指南,介绍了几种常见的形态(单个包、单个命令、带 internal/ 的多个命令)。流行的 golang-standards/project-layout 仓库是一个社区项目,并不是 Go 的标准,Go 团队也公开说明过这一点。

Go 中的 internal 目录是什么?

导入路径中包含 internal 一段的包,只能被以该 internal 目录的父目录为根的代码导入。example.com/app/internal/store 可以在 example.com/app/ 下的任何地方导入,而 go 命令会拒绝来自其他模块的导入,并报 use of internal package ... not allowed。

Go 项目中应该使用 pkg 目录吗?

这是可选的,它给路径多加了一段,却没有增加任何含义。一些大型项目用 pkg/ 把公开的库代码和其他代码分开,但 Go 标准库和大多数现代项目都不这么做。可导入的包放在模块根目录或有明确名字的目录中,私有的代码放进 internal/。

Go 项目中的测试文件放在哪里?

和被测代码放在同一个目录,命名为 *_test.go。Go 没有单独的测试目录树。测试用的数据文件放在测试旁边的 testdata 目录中,go 工具在查找包时会忽略它。

Coddy 编程语言插图

和 Coddy 一起学编程

开始