cmd/ 下应每个子目录仅含一个 main 包,共用逻辑须提取至 internal/,main 函数仅解析 flag、调 bootstrap、执行 run,避免重复初始化与循环引用。

cmd/ 下放多个 main 包会破坏单一入口假设
Go 语言构建工具(go build、go run)默认把每个 main 包视为一个独立可执行程序。如果你在 cmd/ 下放了 cmd/api、cmd/cli、cmd/worker,它们各自是合法的 main 包——但问题在于:它们无法共享初始化逻辑或配置加载,除非你手动重复写一遍。
常见错误现象:flag.Parse() 在每个 main.go 里都调一次,结果配置解析逻辑散落、环境变量处理不一致、日志初始化时机错乱。
- 所有
cmd/*子目录必须有且仅有一个func main() - 禁止在
cmd/内部 import 其他cmd/*下的包(Go 不允许 import main 包) - 如果多个命令需要共用 flag 定义,应提取到
internal/config或pkg/flag,而不是复制粘贴
shared logic 应该放在 internal/ 而不是 cmd/ 或 pkg/
cmd/ 是入口层,pkg/ 是可复用的公共能力,而真正“只供本项目内部使用、不对外暴露”的共享逻辑(比如多命令共用的数据库连接池初始化、统一的信号监听、健康检查 handler 注册),必须放进 internal/。
使用场景:当你发现 cmd/api/main.go 和 cmd/worker/main.go 都在写几乎一样的 setupDB()、setupLogger()、trapSignal() —— 这就是 internal/bootstrap 的职责。
立即学习“go语言免费学习笔记(深入)”;
-
internal/下的包不能被本项目以外的模块 import(Go 编译器强制限制) - 不要把业务逻辑塞进
internal/bootstrap;它只做启动时依赖组装和生命周期钩子 - 避免在
internal/中引入cmd/下的类型(否则会形成循环引用)
main 函数里只留三件事:解析 flag、调用 bootstrap、执行 Run
一个干净的 cmd/api/main.go 不该超过 20 行。它的唯一任务是把控制权交出去,而不是自己处理业务。
容易踩的坑:main() 里直接 new struct、调 http.ListenAndServe、手写 goroutine 管理——这些都会让测试变难、启动流程不可控、信号响应不一致。
- flag 解析用
flagset := flag.NewFlagSet(...),别用全局flag包(否则多个命令间 flag 冲突) - bootstrap 返回一个带
Run() error方法的对象(比如*App),main 只负责调它 - 所有 defer、log.Fatal、os.Exit 必须集中在 main 函数末尾,不要分散在 bootstrap 里
func main() {
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
port := fs.String("port", "8080", "server port")
if err := fs.Parse(os.Args[1:]); err != nil {
log.Fatal(err)
}
app := bootstrap.NewAPIApp(*port)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
测试 cmd 层的关键是替换 os.Args 和捕获 log 输出
你没法直接测试 func main(),但可以测试它调用的 bootstrap 构造函数和 Run() 方法。难点在于模拟命令行参数和验证错误路径是否触发了预期日志。
性能影响不大,但兼容性要注意:不同 Go 版本对 flag.SetOutput 的行为略有差异,尤其在并发测试中。
- 测试前用
os.Args = []string{"cmd", "--port=9000"}模拟参数 - 用
bytes.Buffer替换log.SetOutput,然后断言错误信息是否包含"listen tcp :9000: bind: address already in use" - 不要 mock
os.Exit;改用返回 error 的接口设计,让 exit 行为由 main 函数自己决定
cmd/ 当成“随便放点代码的地方”,结果半年后新加一个 cmd/migrate,发现要抄三份数据库初始化逻辑,还漏掉了 TLS 配置的环境变量 fallback。










