Go 项目结构与分层
组织 Go 项目时关键的不是背目录模板,而是包边界、依赖方向与变化隔离;cmd/internal 与分层只是实现这些原则的手段。
[!info] 关联笔记
Go 项目结构与分层
这个概念为什么会出现
进入真实服务后,问题不再是“能不能编译”,而是:
- 改 HTTP 协议会不会牵动数据库代码
- 业务规则能不能在 CLI、定时任务、HTTP 间复用
- 新同事能否在 10 分钟内找到“用例从哪进、数据从哪出”
- 测试能否只测业务而不起整站
目录名(pkg/、internal/、domain/)只是可见的结果。真正要设计的是:包边界、依赖方向、变化来源。很多人先抄一套“标准布局”,结果小项目被脚手架压垮,大项目却仍循环依赖。
[!abstract] 一句话理解 先按职责与依赖方向切包,再选目录:接口层依赖业务,业务不依赖具体框架/DB 驱动细节;
cmd组装、internal封装、测试与部署围绕边界展开。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:订单服务骨架——cmd 组装,用例依赖仓储接口
新开一个订单微服务。小服务不必上 DDD 全家桶,但要先定依赖方向:
cmd只组装- HTTP 调用例
- 用例依赖
OrderRepo接口,不绑 Postgres
文件可合并;关键是箭头单向。
myapp/
go.mod
cmd/myapp/main.go # 组装:读配置、建依赖、起服务
internal/
app/order/ # 用例:创建订单、取消订单
service.go
domain/order/ # 领域模型与规则(可选独立)
order.go
httpapi/ # 传输:HTTP 路由与 DTO
handler.go
routes.go
store/postgres/ # 基础设施:实现仓储接口
order_repo.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
)
// OrderRepo:仓储端口——用例不关心 postgres 细节
type OrderRepo interface {
Save(ctx context.Context, id string) error
}
// OrderService:创建订单用例
type OrderService struct {
Repo OrderRepo
}
func (s *OrderService) Create(ctx context.Context, id string) error {
// 领域规则:id 必填(可再扩展库存、幂等等)
if id == "" {
return fmt.Errorf("empty id")
}
return s.Repo.Save(ctx, id)
}
// memRepo:演示用内存实现;生产换成 store/postgres
type memRepo struct{}
func (memRepo) Save(ctx context.Context, id string) error {
// 尊重取消:客户端断开则不必继续写
select {
case <-ctx.Done():
return ctx.Err()
default:
_ = id
return nil
}
}
func main() {
// main = 组装根:接线,不写业务分支
svc := &OrderService{Repo: memRepo{}}
mux := http.NewServeMux()
// 传输层:解码参数 → 调用例 → 映射状态码
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if err := svc.Create(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
建议运行:
go run .
# 终端 2:
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8080/orders?id=o1"
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8080/orders"
期望:
201
400
结合场景再看三个关注点
main只做组装;业务不import具体框架全局。OrderService依赖OrderRepo接口,存储可替换。- 包名表达职责(order、httpapi),不是技术时髦词堆砌。
核心概念与准确模型
1. 包是边界,目录是投影
Go 的编译与可见性以包为单位(见 包与可见性)。好结构首先回答:
| 问题 | 好答案的特征 |
|---|---|
| 这个包对外承诺什么? | 少而稳的导出 API |
| 谁可以依赖谁? | 单向、无环 |
| 变化从哪进来? | 协议、DB、第三方 SDK 被边界挡住 |
| 测试如何站在边界外? | 对接口打桩,不必起全栈 |
目录只是让人眼与 go list 舒服的投影。
2. 常见分层(逻辑层,不是强制文件夹)
flowchart TB
T["传输层 HTTP/RPC/CLI"] --> A["应用/用例层"]
A --> D["领域规则与模型"]
A --> P["端口/接口"]
I["基础设施 DB/MQ/HTTP Client"] --> P
| 层 | 职责 | 典型依赖 |
|---|---|---|
| 传输 | 编解码、状态码、鉴权中间件 | 用例 API、DTO |
| 应用/用例 | 事务边界、编排、权限检查 | 领域、仓储接口 |
| 领域 | 不变量、状态迁移 | 尽量零基础设施 |
| 基础设施 | SQL、SDK、文件系统 | 实现端口接口 |
小项目可把“领域 + 用例”合在一个 internal/app/foo 包;不要为了分层而分层。
3. 社区惯例目录的真实含义
| 路径 | 含义 | 误用 |
|---|---|---|
cmd/<name>/ | 可执行入口,薄 main | 塞满业务 |
internal/ | 仅本 module 可导入(编译器强制) | 把一切塞进一个 internal/pkg 大杂烩 |
pkg/ | 有意对外库式 API(可选) | 假装“更专业”而空转 |
api/ | OpenAPI/proto 等契约源 | 与实现循环引用 |
configs/、deploy/、scripts/ | 运维与工具 | 运行时强依赖未嵌入的路径(见 [[go-embed |
官方并未规定唯一标准树;Organizing a Go module 强调 module 内清晰与 internal 封装。golang-standards/project-layout 是社区模板,不是语言规范。
4. 依赖方向规则
- 稳定方向内指:框架适配器 → 用例 → 领域。
- 具体实现指向抽象:
postgres.OrderRepo实现order.Repository,而不是 serviceimport驱动细节到处飞。 - 禁止反向:
domain不得importhttpapi或某 ORM 包。 - 跨包共享:优先共享接口与 DTO,慎共享“什么都有的 util”。
5. 按业务切片 vs 按技术分层
| 方式 | 优点 | 风险 |
|---|---|---|
技术层 handlers/ services/ repos/ | 上手快 | 大了后跨目录改一个用例 |
业务竖切 order/ billing/ 内含各层 | 用例内聚 | 公共内核要克制 |
混合:internal/order 竖切 + 薄 platform/ | 多数中型服务甜点 | 需约定清晰 |
经验:边界以用例变化频率为准,不是以“目录对称美观”为准。
6. 配置、日志、遥测放哪
- 构造期注入:
Logger、Meter、Config作为依赖传入。 - 避免每个包
init()读全局文件。 main负责:读环境变量/文件 → 构造图 →Run(ctx)。
设计动机
- 变化隔离
HTTP 换 gRPC、Postgres 换别家,不应重写业务规则。 - 可测性
用例对接口测试;传输层用httptest;存储层用集成测。 - 团队导航
新人按“入口 → 用例 → 存储”三条线找代码。 - 与 Go 工具链契合
包级可见性、internal强制封装、module 边界清晰(Modules)。
边界情况与反直觉行为
1. 过早微服务 / 多 module
单仓单 module 服务未稳就拆仓库,会把“函数调用”变成“分布式失败模式”。先包边界,后仓边界。
2. utils / common 黑洞
无领域语义的大包会成为循环依赖与隐性耦合中心。优先:就近私有辅助;真共享再提 internal/x。
3. 接口定义在错误的一侧
若每个实现旁都定义“自己的接口”,调用方无法统一。惯用:接口由消费者定义(小接口),或放在领域端口包。
4. 把 DTO 当领域模型
JSON tag、DB tag 堆在同一结构上,传输变更撕裂持久化。见 JSON 与序列化。
5. pkg/ 对外承诺过重
一旦有外部 module 依赖 pkg,演进成本上升。默认先 internal,真要开源库再提升。
常见误区
[!warning] 常见误区:先抄完整 project-layout 错误:空目录二十个,业务仍糊在
main。
正确:三五个包把依赖方向做对,再随痛点加目录。
[!warning] 常见误区:分层 = 每层一个目录必须对称 错误:10 行逻辑也硬拆 domain/application/infrastructure。
正确:复杂度不够时合并;箭头正确优先。
[!warning] 常见误区:业务包 import 框架全局 错误:service 里直接依赖某 Web 框架 Context 类型。
正确:传输层适配;业务用context.Context与自己的类型。
[!warning] 常见误区:循环依赖用“再抽一个包”无限后移 错误:
a → b → a靠第三个糊墙。
正确:重新划分所有权或引入接口断环。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 包与可见性 | 语言机制;本篇是机制上的结构策略 |
| Modules | 依赖版本与仓边界;layout 是仓内组织 |
| H/S/R | Web 场景的一种具体分层落地 |
| 构建部署 | 产物与交叉编译;入口多在 cmd/ |
| 整洁/六边形架构 | 思想同源;Go 落地要克制仪式感 |
工程实践
cmd保持瘦:组装 + 信号 + 退出码。- 默认
internal:除非明确要对外库。 - 接口小而稳:
Save/Find而非上帝Repository。 - 一个用例一个主路径文件,复杂再拆 private 文件(同包)。
- 测试放同包
_test或foo_test黑盒,按需要选。 - 文档化依赖图:在 README/MOC 画一张“允许 import”表。
- CI:
go test ./...、竞态、lint import 周期(如go vet、额外分析器)。 - 演进:从单体包 → 按痛点拆,而不是按设想一次拆完。
// 允许的依赖方向(约定示例)
// httpapi → app → domain
// store → domain (实现接口)
// cmd → 所有具体实现(唯一组装点)
可验证实验
实验 1:画依赖
对现有小项目跑 go list -f '{{.ImportPath}} {{.Imports}}' ./...,标出是否有“领域 → 传输”。
实验 2:替换存储
为 OrderRepo 增加 fakeRepo,不改 service 跑单测。
实验 3:第二入口
加 cmd/myapp-cli 调用同一 OrderService,验证业务不绑 HTTP。
实验 4:故意反向 import
让 domain import httpapi,观察团队约定/CI 是否拦得住。
本节总结
- 本质:结构 = 包边界 + 依赖方向 + 变化隔离;目录是投影。
- 手段:
cmd组装、internal封装、用例与基础设施分离。 - 克制:小项目简单清晰 > 仪式化分层。
- 下一步:用 net/http 与 优雅关闭 填满
cmd生命周期;用测试钉住端口。
自测题
概念题
- 为什么说“目录模板不是核心”?
internal/提供了什么语言级保证?- 接口应更常由谁定义?
代码推理题
package domain 中 import "myapp/internal/httpapi" 意味着什么架构问题?
工程思考题
10 人团队、单仓、HTTP+异步消费者共用下单逻辑,你如何切包,避免两套业务分叉?
参考答案
展开
- 真正约束可维护性的是依赖方向与职责边界;目录名可替换。
- 其他 module 无法导入该路径,封装被编译器强制。
- 更常由消费者(用例)定义小接口,实现放在基础设施。
代码题:领域依赖传输,依赖反转失败,业务被 HTTP 细节污染。
工程题:把下单用例放internal/app/order,HTTP 与 consumer 都只做适配并调用同一 service;共享领域规则,不共享框架类型。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Organizing a Go module | 官方文档 | module 内组织建议 |
| Package names | 官方博客 | 命名与职责 |
| Go Modules Reference | 规范 | module 边界 |
| Standard Library | 参考 | 标准库自身的包划分风格 |
| Code Review Comments | Wiki | 包与 API 评审习惯 |