学完这篇你能拿到一套可以直接照抄的 Go 项目目录骨架,以及每条目录该放什么、不该放什么、为什么这么分的判断标准。
第一步:初始化模块,先定 module path
进入项目根目录执行:
go mod init github.com/yourname/yourproject
go 1.22 # 或你实际使用的版本,写进 go.mod
module path 建议直接用仓库地址,后面所有 import 路径都以它为前缀。这一步先做,是因为后面 internal 的可见性判断、包引用路径都依赖它。
第二步:建出目录骨架
一条命令铺开:
mkdir -p cmd/api internal/{handler,service,repository,model,config} \
pkg/logger api configs migrations scripts
各目录职责:
| 目录 | 放什么 | 不放什么 |
|---|
cmd/api | main.go,只做参数解析与依赖装配 | 业务逻辑 |
internal/ | 本项目私有代码,外部无法 import | — |
pkg/ | 可被其他项目复用的通用包 | 业务代码 |
api/ | OpenAPI/Proto 定义文件 | 实现代码 |
configs/ | config.yaml 示例、默认配置 | 真实密钥 |
migrations/ | SQL 迁移脚本,按序号命名 | — |
注意:pkg/ 不要一上来就建。只有当某个包确定要被外部项目引用时才移进去,否则全塞 internal/ 更安全。常见的错误是把所有代码都写进 pkg/,等于放弃了 Go 的编译期私有边界。
第三步:理解 internal 的强制边界
internal 是 Go 编译器强制的可见性规则:internal/ 下的包,只有以 internal 的父目录为根的子树可以 import。也就是说 myproject/internal/service 只能被 myproject/... 引用,外部项目写了就编译报错。
这条规则比任何团队约定都硬,所以业务代码默认全部进 internal。验证方式:
go list ./...
如果外部项目误引用,go build 会直接抛出 use of internal package not allowed。
第四步:三层分层与依赖方向
推荐的最小分层:
handler → service → repository → DB
handler:解析 HTTP 请求、参数校验、组装响应。只调 service,不碰 DB。
service:业务规则、事务编排、跨仓库协调。不 import net/http。
repository:数据读写,返回领域模型。不写业务判断。
依赖方向永远单向向内,service 不能反向 import handler。用一个包级检查就能发现越界:
go list -f '{{.ImportPath}} -> {{join .Imports " "}}' ./internal/... | grep handler
看到 service -> handler 就是分层破了。
注意:接口定义在消费方,不是实现方。service 需要读数据时,接口写在 service 包里,repository 去实现它。这样测试时可以直接塞 mock,不必启动数据库。
第五步:main.go 只做装配
cmd/api/main.go 的标准形态:
func main() {
cfg := config.MustLoad("configs/config.yaml")
db := repository.MustOpen(cfg.DB)
repo := repository.NewUserRepo(db)
svc := service.NewUserService(repo)
h := handler.NewUserHandler(svc)
log.Fatal(http.ListenAndServe(cfg.Addr, h.Router()))
}
装配逻辑集中在一处,依赖关系一眼看全。项目变大后可引入 wire 或手写 container.go,但别在 init() 里偷偷建全局 DB 连接——这会让测试无法隔离。
第六步:配置与迁移的落位
- 配置用
configs/config.example.yaml 入库,真实 config.yaml 写进 .gitignore。
- 迁移脚本命名
0001_init.up.sql / 0001_init.down.sql,按序执行,不回头改已上线的脚本。
- 本地起服务:
go run ./cmd/api -config configs/config.yaml
注意:不要把 configs/ 和 internal/config 搞混——前者放文件,后者放读取文件的代码。
小结
go mod init 先定 module path,后续路径都依赖它。
- 业务代码默认进
internal/,pkg/ 只在确定对外复用时才用。
- 分层方向单向:handler → service → repository,接口定义在消费方。
main.go 只做装配,不写业务;全局 init() 建连接是坑。
- 配置样例入库、真实配置忽略;迁移脚本按序号、不回头改。