Go 工具链对目录组织没有任何强制约束:编译器只依据 import 路径解析包,除 go.mod 中的一行 module 路径外,源文件放在哪个目录,工具链完全不关心。Java 有 src/main/java 这样的强制布局,Go 没有对应物。因此,"项目该长什么样"完全依赖社区约定,其中流传最广的是 golang-standards/project-layout——名字里带 standards,但其 README 明确说明这不是官方标准。
目录不受约束不等于可以任意安排:入口位置随意、职责不明的包持续膨胀,都会随代码规模推高修改与重构成本。实践中的典型误区有三类:一是照搬全家桶,百行规模的 CLI 工具也创建 pkg/、api/、scripts/ 等十余个空目录;二是沿用 Java 习惯,引入 src/、将 main.go 置于仓库根,再加 utils/、helpers/、common/ 三个职责不明的包;三是 monorepo 中每个 go.mod 都用 replace 指向本地路径,或反之,单 module 项目配置 go.work。布局问题不会立即暴露,但会随代码增长持续累积:main 越来越长,common 越来越大,最终重构成本超出可承受范围。
下文按实施顺序说明,从最粗粒度的决策开始。
先定项目类型,再定目录
布局应跟随项目规模:脚本项目保持扁平,服务项目才值得分层,不应一开始就照搬全家桶。 百行规模的 CLI 工具不需要依赖注入与多层抽象;需要维护多年的服务,则应在项目初期将入口与业务隔开。先确定项目类型,再决定目录:
| 项目类型 | 适用场景 | 关键目录 |
|---|---|---|
| CLI 工具 | 命令行应用 | cmd/{name}/、internal/,pkg/ 可选 |
| 库 | 供他人复用的代码 | 公共包放根级目录,私有实现放 internal/ |
| 服务 | HTTP API、微服务、Web 应用 | cmd/{service}/、internal/、api/、configs/ |
| Monorepo | 多个相关 module | go.work + 每个 module 独立 go.mod |
最简单的工具,一个 cmd/mytool/main.go、一个 internal/、一个 go.mod,必要时再加一个 Makefile,即可满足需要。
目录骨架之外还有一项更粗粒度的决策:整体架构风格——整洁架构、六边形架构、DDD 或扁平结构,它决定 internal/ 内部如何进一步划分。本节的目录骨架对各种风格通用,但风格一经选定即贯穿全项目,中途更换风格的代价远高于调整目录名。
推荐目录树
单 module 应用以 cmd/ + internal/ 为骨架,其余目录按需增设,不应预留空目录。服务与 CLI 通用的参考结构:
myapp/
├── cmd/ # 入口:每个子目录对应一个二进制
│ ├── server/main.go # API 服务
│ ├── worker/main.go # 后台任务
│ └── migrate/main.go # 数据库迁移(一次性管理命令)
├── internal/ # 私有代码,编译器禁止外部 import
│ ├── config/ # 配置加载
│ ├── handler/ # HTTP 处理器
│ ├── service/ # 业务逻辑
│ └── model/ # 领域模型
├── pkg/ # 公共库:确有外部消费者才建
│ └── logger/
├── api/ # API 定义:openapi.yaml、proto 等
├── configs/ # 可提交的默认配置文件
├── scripts/ # 构建、部署脚本
├── docs/ # 设计文档与决策记录
├── test/ # 跨包共享的夹具与测试工具
├── go.mod
├── Makefile
├── .gitignore
└── .golangci.yml
cmd/ 只放 main
所有 main 包应置于 cmd/ 下,一个子目录对应一个二进制。 这样,go build ./cmd/... 一条命令即可构建全部产物;server、worker、migrate 还能共享同一套 internal/ 代码,而不是相互复制。不应将 main.go 放在仓库根——该做法使第二个入口无处安放。
cmd/ 中不应出现业务逻辑,每个 main.go 只做三件事:解析 flag、组装依赖、调用 Run()。 业务逻辑置于 internal/,才具备复用与测试的条件:
// ❌ 业务逻辑全写在 main 里:无法测试,第二个入口只能复制代码
func main() {
db, _ := sql.Open("postgres", dsn)
rows, err := db.Query("SELECT id, amount FROM orders WHERE ...")
// ... 两百行查询、校验、渲染
}
// ✅ cmd/server/main.go:只做组装
func main() {
cfg, err := config.Load() // 来自 internal/config
if err != nil {
log.Fatal(err)
}
app, err := app.New(cfg) // internal/app 负责装配路由与依赖
if err != nil {
log.Fatal(err)
}
log.Fatal(app.Run()) // 真正的逻辑都在 internal/ 里
}
遵循 12-Factor App 的做法,建表、刷数据这类管理任务应实现为一次性命令,落在 cmd/migrate/ 这样的独立入口,而不是向 server 添加隐藏 flag。
internal/:编译期可见性隔离
不应被外部项目 import 的代码,一律置于 internal/——这不是文档层面的约定,而是编译器强制。 规则是:只有位于 internal/ 父目录子树内的代码才能 import 它。对单 module 项目,等价于"仅本 module 可用";外部项目一旦引用,编译直接失败,与 README 如何声明无关:
// 外部项目试图引用本 module 的 internal 包:
import "github.com/acme/myapp/internal/config"
// go build 直接失败:
// use of internal package github.com/acme/myapp/internal/config not allowed
internal/ 还可以嵌在更深的目录下,规则按"internal 的父目录"逐层生效。库作者常在公共包内再放一层 internal/ 隐藏实现细节,只导出门面(facade)API。
pkg/:确有外部消费者再建
pkg/ 与 internal/ 相反,代表对外承诺的公共 API。它不是 Go 工具链的约定,只是社区习惯,争议一直存在。既然 internal/ 已能保护私有代码,pkg/ 的唯一价值是显式声明"这些代码欢迎被 import"——这意味着版本兼容的长期承诺。只有当 logger 这类包确实会被其他仓库复用时,才应放入 pkg/;否则 internal/ 足够。
没有外部消费者,不应建立 pkg/。 库项目略有不同:公共 API 直接放根级目录(如 logger/),私有实现放 internal/;无可执行文件,则不需要 cmd/。
module 和包名
module 路径应与仓库地址一致,全小写,多词用连字符。module 名是他人 go get 该代码的唯一凭据,与仓库地址不一致,协作无从谈起:
// ❌
module myproject // 与仓库无关,别人无法 go get
module github.com/jdoe/MyProject // 含大写
module utils // 没有语义
// ✅
module github.com/jdoe/payment-processor
包名应小写、单数、与目录名一致,按业务域组织。 utils/、helpers/、common/ 这类包是"缺少明确归属"的信号:任何代码都能进入,最终没有任何代码敢被删除,经年累积大量互不相干的函数。
// ❌ 照搬 Java 习惯的项目
myproject/
├── src/ # Go 没有 src/ 的概念
├── main.go # 入口位于仓库根
├── utils/ # 职责不明的泛用包
├── helpers/
└── common/
// ✅ 按业务域组织
myproject/
├── cmd/myapp/main.go
├── internal/
│ ├── order/ # 订单域:模型、逻辑、存储放在一起
│ └── payment/ # 支付域
└── pkg/ # 确有外部消费者才建
起步阶段按 handler/、service/、model/ 这类技术角色分层没有问题;业务变多后,推荐按业务域重组。技术分层会把一个需求的改动摊到三个目录;业务域分层让相关代码彼此相邻,改动的影响范围显著缩小。
配置的来源与存放
配置只能来自环境变量、文件或 flag,严禁硬编码;密码、密钥等敏感值只经环境变量或密钥管理服务,绝不进入 configs/ 提交到仓库。 configs/config.yaml 只放可公开的默认值,真实部署值用环境变量覆盖——这也是 12-Factor App 的基本要求:配置与代码严格分离。Cobra + Viper 的加载顺序与 flag 绑定细节见 CLI 与配置,本节只说明配置结构与文件的对应关系:
// internal/config/config.go:字段与 YAML key 一一对应
type Config struct {
Port int `mapstructure:"port"`
LogLevel string `mapstructure:"log-level"`
Database struct {
DSN string `mapstructure:"dsn"` // 真实值来自环境变量
MaxConn int `mapstructure:"max-conn"`
} `mapstructure:"database"`
}
func Load() (Config, error) {
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return Config{}, fmt.Errorf("unmarshaling config: %w", err)
}
return cfg, nil
}
测试文件的位置
测试文件应与被测代码同目录,夹具放同目录的 testdata/ 下。Go 以文件名后缀做隔离:以 _test.go 结尾的文件(包括 _bench_test.go、_example_test.go 这类约定)不进入正常构建,testdata/ 目录在构建时同样被忽略——不应将 .go 文件放入其中,它们会被直接忽略:
internal/handler/
├── handler.go
├── handler_test.go # 与被测代码同目录、同包
├── handler_bench_test.go # 基准测试,不进正常构建
└── testdata/
└── request_valid.json # 包内夹具:构建时被忽略
单元测试用同包白盒,集成与行为测试用 _test 后缀的黑盒包。黑盒测试只允许依赖导出 API,更接近使用者的视角:
package handler // 白盒:单元测试,可访问未导出标识符
package handler_test // 黑盒:集成测试,只能走导出 API
跨包共享的测试辅助函数应置于 internal/testutil/ 或 test/ 下,不应在业务包之间互相 import 测试代码。库项目还应编写 *_example_test.go 示例函数并附 // Output: 注释——它既是 godoc 文档,又是一条实际执行的测试,输出不符时 go test 即失败。命名、表驱动与 mock 的完整规则见 单元测试;用构建标签(如 //go:build integration)隔离慢速集成测试的场景,也在该篇展开。
多模块工作区:go.work
多个本地 module 相互依赖时,应使用 go.work 构成多模块工作区;单 module 项目、只依赖外部库的简单应用不需要它。 workspace 解决的正是"本地修改 shared 后,下游如何立即生效"的问题:在仓库根执行 go work init,将各 module 逐个 use 进去,本地路径自动生效,各 go.mod 不再需要 replace:
my-monorepo/
├── go.work
├── shared/libs/go.mod
├── services/auth/go.mod
├── services/user/go.mod
└── tools/cli/go.mod
go 1.22
use (
./shared/libs
./services/auth
./services/user
./tools/cli
)
go work init # 仓库根初始化 workspace
go work use ./services/auth # 逐个加入 module
go work sync # 与各 module 保持同步
// ❌ 每个下游 go.mod 都要写,路径还与开发者本机布局耦合
require github.com/you/my-monorepo/shared/libs v0.0.0
replace github.com/you/my-monorepo/shared/libs => ../shared/libs
// ✅ go.work 生效期间,本地目录自动解析,go.mod 保持干净的 require
require github.com/you/my-monorepo/shared/libs v0.0.0
workspace 只影响本地开发时的模块解析;各 go.mod 照常声明 require 并正常发布,CI 与外部用户感知不到它的存在。replace 与多模块版本策略的更多细节,见 依赖管理。
根目录必备文件
每个 Go 项目的根目录至少应包含三项:Makefile、.gitignore、.golangci.yml。Makefile 将 build、test、lint 固化为同一条命令,新成员 clone 后即可运行;.gitignore 至少排除 /vendor/ 和编译产物;.golangci.yml 保证本地与 CI 执行同一套检查,而不依赖口头约定的 linter 清单。新项目的起点只有两行:
go mod init github.com/you/myapp # module 名 = 仓库地址
gofmt -s -w . # 提交前统一格式
速查清单
- 先确定项目类型,再确定目录:脚本项目保持扁平,服务项目分层,不照搬全家桶
- main 包全部置于
cmd/,一个子目录对应一个二进制 main.go只做三件事:解析 flag、组装依赖、调用Run()- 管理任务应实现为
cmd/migrate/这类一次性命令(12-Factor) - 私有代码置于
internal/,编译期可见性隔离由编译器强制,外部 import 直接失败 pkg/仅在确有外部消费者时建立;库项目公共 API 放根级- module 路径与仓库地址一致,全小写,用连字符
- 包名小写、单数、按业务域组织;避免
utils/、helpers/、common/ - 配置只经环境变量、文件或 flag;敏感值不得进入仓库
- 测试与被测代码同目录,夹具置于
testdata/ - 单元测试用白盒,集成测试用
_test后缀黑盒 - 多 module 项目用
go.work构成多模块工作区,单 module 项目不使用 - 根目录应包含
Makefile、.gitignore、.golangci.yml