Golang Web项目如何组织目录结构_Web项目结构设计建议

8次阅读

main.go 应放在 cmd/ 目录下,如 cmd/myapp/main.go,仅负责初始化并启动服务;避免根目录混乱、提升可维护性与多二进制支持。

Golang Web项目如何组织目录结构_Web项目结构设计建议

main.go 放哪?别放根目录

很多人一上来就把 main.go 扔项目根目录,结果随着路由中间件、配置越来越多,根目录迅速变成垃圾场。Go 项目不是脚本,main.go 应该只做一件事:初始化并启动服务。它属于「入口层」,理应放在 cmd/ 下,比如 cmd/myapp/main.go。这样既和业务逻辑隔离,也方便同一仓库下共存多个可执行程序(如 CLI 工具、migration 命令)。

常见错误是把数据库初始化、配置加载、路由注册全塞进 main.go —— 这会导致测试困难、复用性差、启动逻辑无法被单元测试覆盖。

  • cmd/:只放可执行入口,每个子目录对应一个 binary(cmd/apicmd/migrate
  • internal/:所有不对外暴露的业务代码都放这里(internal/handlerinternal/serviceinternal/repository
  • pkg/:仅当有明确跨项目复用意图时才放通用工具包(如自定义日志封装http 客户端基类),否则别滥用

handler 层要不要直接调用 database/sql?不要

Go 没有强制分层,但直连 database/sql 或 ORM 实例(如 gorm.DB)到 handler,会带来三个硬伤:难以 mock 测试、事务边界模糊、SQL 泄露到 HTTP 层。正确的做法是让 handler 只负责解析请求、校验参数、调用 service、构造响应。

例如一个用户创建接口handler 解析 json 后,应把干净的结构体传给 service.CreateUser(),而不是自己拼 INSERT 语句或调用 db.Create()

立即学习go语言免费学习笔记(深入)”;

  • handler 接收 *http.Request,返回 http.ResponseWriter,不 import 任何数据库相关包
  • service 层定义接口(如 UserRepository),实现由 repository 提供,便于替换底层存储或加缓存
  • 事务控制应在 service 层显式开启(如 tx := db.Begin()),而非在 handlerrepository 中隐式传播

config 加载时机与热更新支持

配置不应在 init() 函数里读取,也不该在 main() 开头就一次性全部解析完然后全局变量存着。真实项目中,你很可能需要:按环境加载不同文件(config.development.yaml)、支持从环境变量覆盖字段、甚至运行时重载日志级别或 feature flag。

推荐用 github.com/spf13/viper,但要注意三点:

  • cmd/myapp/main.go 中初始化 viper,设置路径、前缀、自动重载(viper.WatchConfig()
  • 配置结构体定义在 internal/config/config.go,用 viper.Unmarshal() 绑定,避免散落各处的 viper.GetString()
  • 数据库连接池参数(如 MaxOpenConns)必须在 sql.Open() 之后立刻设置,不能等第一次查询时才生效

测试目录怎么组织?别建 test/ 文件夹

Go 的测试惯例是「测试文件与被测文件同目录,_test.go 结尾」。新建一个 test/ 目录集中放测试,反而破坏了 Go 工具链对测试的识别(go test ./... 仍能跑,但 ide 跳转、覆盖率统计、go list -f '{{.TestGoFiles}}' ./... 都会出问题)。

正确方式是让每个包自己管自己的测试:

  • internal/handler/user_handler.go → 对应 internal/handler/user_handler_test.go
  • 集成测试(如带真实 DB 的 endpoint 测试)可放 internal/handler/integration_test.go,用 //go:build integration 标签隔离
  • Mock 接口建议用 gomock 生成,但别为每个 repository 都生成——先写测试,发现依赖难 mock 再补 Interface,避免过度设计
package handler  import (     "net/http"     "testing" )  func TestCreateUser(t *testing.T) {     // 构造 fake service,不碰真实 DB     svc := &fakeUserService{}     h := NewUserHandler(svc)      req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"name":"a"}`))     w := httptest.NewRecorder()     h.CreateUser(w, req)      if w.Code != http.StatusCreated {         t.Errorf("expected 201, got %d", w.Code)     } }

真正容易被忽略的是:HTTP handler 的 error 处理粒度。很多人用一个全局 http.Error() 包裹所有错误,导致前端拿不到具体错误码(如 400 vs 409)。应该在 service 层返回带状态码的 error(如自定义 AppError{Code: 409, Msg: "email exists"} ),再由 handler 统一转换。这比后期加中间件拦截 panic 更可控。

text=ZqhQzanResources