项目布局规范:cmd/internal/handler/service/dao 分层与依赖方向
Go 官方推荐的布局是 cmd/ 放入口、internal/ 放私有代码;Web 项目再按职责细分 handler(HTTP 层)、service(业务层)、dao(数据层)。分层价值不在目录好看,而在依赖方向清晰、可替换、可测试。
1. 目录结构
myapp/
├── cmd/
│ └── server/main.go # 入口:装配配置、路由、启动
├── internal/
│ ├── config/ # 读取环境变量/配置文件
│ ├── dao/ # 数据访问:GORM 查询封装
│ ├── model/ # 数据模型结构体
│ ├── service/ # 业务逻辑:校验、事务编排
│ ├── handler/ # HTTP:绑定参数、调 service、组装响应
│ ├── middleware/ # 鉴权、限流、日志等中间件
│ └── router/ # 路由注册(装配 handler 与 middleware)
├── go.mod
└── go.sum
2. 依赖方向:只允许向下调
一条铁律:handler → service → dao → model,禁止反向或跨层调用:
// dao:只碰 GORM,返回数据或错误,不写 HTTP
type UserDAO struct{ db *gorm.DB }
func (d *UserDAO) ByID(id uint) (*model.User, error) {
var u model.User
err := d.db.First(&u, id).Error
return &u, err
}
// service:编排业务,依赖接口而非具体实现,方便测试注入
type UserSvc struct{ users *UserDAO }
func (s *UserSvc) Get(id uint) (*model.User, error) {
return s.users.ByID(id)
}
// handler:绑定参数 → 调 service → 输出 JSON
func (h *UserHandler) Get(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
u, err := h.svc.Get(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, u)
}
3. 装配与依赖注入
main 里自底向上组装,再交给 router:
db := dao.MustOpenDB(cfg) // 1. 数据层
userDAO := &dao.UserDAO{DB: db}
userSvc := &service.UserSvc{DAO: userDAO}
userH := &handler.UserHandler{Svc: userSvc}
r := router.New(userH, cfg) // 2. 注册路由
r.Run(cfg.Addr)
手工注入足够小项目;工程变大再考虑 wire(google/wire)等生成式注入。
4. 接口放哪、怎么抽象
- service 依赖 dao 时定义「dao 接口」,实现用编译期断言校验(
var _ DaoIface = (*dao.X)(nil))。 - 单测用 fake 实现接口,service 层测试不依赖真实数据库。
- 别为了分层而分层:只暴露调用方真正需要的方法,签名尽量收敛。
注意点
internal/下的包不能被外部项目 import,天然防误用。- handler 不写 SQL、service 不写 HTTP 状态码,各层职责单一。
- 中间件放
middleware/,鉴权逻辑本身属于 service 层能力,中间件只做搬运。 - 错误逐层向上抛,不吞错,由 handler 决定状态码与错误体。
小结
布局规范解决的是「代码放哪、谁能调谁」。按 cmd/internal/... 组织 + 单向依赖 + 接口注入,项目就能保持可读、可测、可替换。实战章节将按此结构实现完整接口。