Go项目在小型阶段通常单包就能跑通,但功能一多,main包里塞满路由、数据库、业务逻辑,编译期又突然冒出import cycle not allowed,往往意味着包结构需要重新梳理。组织包结构不是建目录那样简单,它更像是在规划依赖的流向和代码的可见边界。本文从依赖方向、internal/cmd布局、包命名三个层面给出可落地的设计原则,并讨论如何把已有混乱结构平滑迁移到清晰形态。

先定依赖方向,让包结构有向无环
Go编译器禁止包之间循环引用。很多结构问题都源于双向依赖:比如service层要调用repository,repository为了记录日志又引用service里的工具函数,最终只能把工具函数下沉到公共包。循环依赖的本质是职责边界模糊。设计包结构的第一步不是创建目录,而是画一张依赖图,确认依赖只从上层流向下层,底层包不感知上层。
典型分层可参考:入口层cmd依赖业务层service,业务层依赖领域/数据层repo/domain,领域层依赖基础工具包。基础工具包不引用任何业务包。用目录表示如下:
project/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── service/
│ ├── repo/
│ └── domain/
└── pkg/
└── logx/
上面结构里cmd只调用internal/service,service调用repo和domain,repo和domain可能引用pkg/logx。依赖方向单向,底层包完全不感知上层。要持续维护这个方向,可以借助 go list -deps 查看实际依赖,或者在CI里配置golangci-lint的depguard规则,禁止反向导入。
用internal和cmd管好暴露面
Go的internal目录是语言级别的访问控制:位于项目内部的internal包,只能被其父目录(含子目录)内的包导入,外部模块无法导入。这非常适合隐藏实现细节。cmd目录则集中存放可执行程序入口,每个子目录一个main包,入口文件只做配置加载、依赖注入、启动监听,不承载业务逻辑。
常见的错误是把初始化逻辑全部写在main包里。这样做会导致后续写集成测试或对外提供库时,这些代码无法复用。更好的方式是在internal/app里提供 New() 函数,返回一个可启动的Server结构,cmd只是调用它。目录可以这样规划:
project/ ├── cmd/ │ ├── server/ │ │ └── main.go │ └── worker/ │ └── main.go ├── internal/ │ ├── app/ │ ├── config/ │ └── store/ └── go.mod
对应的main函数可以保持非常简短:
package main
import (
"log"
"ipipp.com/project/internal/app"
)
func main() {
srv, err := app.New(app.Config{
Addr: ":8080",
})
if err != nil {
log.Fatal(err)
}
if err := srv.Run(); err != nil {
log.Fatal(err)
}
}
pkg目录并不是Go官方语义,但习惯上放可被外部引用的公共库。项目初期不必强行分pkg,可以直接放在internal下,等真正有外部复用需求再暴露。过度使用pkg反而会让内部实现过早暴露,增加维护成本。
包命名:简短、自解释,拒绝util
包名是调用方每次使用时的前缀,例如 logx.Info()、store.GetUser()。如果起名叫util或common,调用时 util.Do() 完全丢失上下文。Go官方建议包名使用简短小写单词,与目录名保持一致,不要用下划线或驼峰。包名应该是一个名词,描述它提供的能力,而不是动作。
以下是不好的命名示例:
project/
├── utils/
│ ├── string_utils.go
│ └── file_utils.go
├── helpers/
│ └── db_helper.go
└── common/
└── constants.go
下面是改进后的命名:
project/
├── strutil/
│ └── clean.go
├── filepathx/
│ └── expand.go
├── dbhelper/
│ └── query.go
└── config/
└── keys.go
注意strutil虽然仍带util,但在特定领域比utils更有信息量。如果包只有一个职责,可以更具体如textclean、pathx。不要害怕包数量多,保持每个包职责单一比把所有东西塞进一个包更重要。包内文件组织上,尽量把类型和它的方法放在同一文件中,包注释放在第一个文件顶部,说明这个包解决什么问题。
从混乱结构平滑迁移的步骤
如果项目已经混乱,直接推倒重来风险很高。可以按依赖关系逐步抽离。第一步用 go mod graph 和 go list -f '{{join .Imports "\n"}}' ./... 找出高耦合包。第二步从叶子节点开始下沉:把不依赖业务逻辑的代码先移到独立包,比如日志、错误码、字符串处理。第三步引入internal限制访问,把本该私有的包放进internal,强制外部不再误用。第四步拆分cmd入口,将main函数瘦身。
迁移过程不要一次性完成,每移动一个包就跑一次 go test ./... 和 go build ./...,确保没有破坏导入路径。对于循环依赖,可以引入接口解耦:service依赖一个接口而不是具体的repo实现,repo实现放在另一个包,通过依赖注入传入。例如:
package service
type UserRepo interface {
FindByID(id int64) (User, error)
}
type UserService struct {
repo UserRepo
}
func NewUserService(repo UserRepo) *UserService {
return &UserService{repo: repo}
}
依赖方向的清晰比目录层级更重要。包结构的优化不是一次性的,而是随着业务演进持续调整。只要守住单向依赖、internal隔离、入口瘦身这几条原则,就能避免大部分包混乱问题。