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

main.go 放哪?别放根目录
很多人一上来就把 main.go 扔项目根目录,结果随着路由、中间件、配置越来越多,根目录迅速变成垃圾场。Go 项目不是脚本,main.go 应该只做一件事:初始化并启动服务。它属于「入口层」,理应放在 cmd/ 下,比如 cmd/myapp/main.go。这样既和业务逻辑隔离,也方便同一仓库下共存多个可执行程序(如 CLI 工具、migration 命令)。
常见错误是把数据库初始化、配置加载、路由注册全塞进 main.go —— 这会导致测试困难、复用性差、启动逻辑无法被单元测试覆盖。
-
cmd/:只放可执行入口,每个子目录对应一个 binary(cmd/api、cmd/migrate) -
internal/:所有不对外暴露的业务代码都放这里(internal/handler、internal/service、internal/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()),而非在handler或repository中隐式传播
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 更可控。










