关于 Go 项目怎么分目录,社区争论一直不少。golang-standards/project-layout 那个仓库星很多,但连 Go 团队的人都说过它不代表官方意见。
写几个我实际用下来觉得顺手的做法,都是取舍,不是标准。
小项目就别分那么细
一个几百行的 CLI 工具,需求是把 CSV 转成 JSON。这时候目录结构应该是:
mytool/
main.go
convert.go
convert_test.go
go.mod
README.md
不用 cmd/、internal/、pkg/。全塞一块,一眼看完。
我见过把这种小工具拆成十几层的,改一个字段要开五个文件。目录结构是为了降低理解成本,不是为了显得规范。
我现在的大致分界线是:代码超过 2000 行,或者有多个二进制入口,才考虑分层。
internal 用得比较多
internal/ 是 Go 编译器强制的访问控制——这个目录下的包只能被它的父目录及子目录导入。这个特性很有用。
myapp/
cmd/
server/main.go
worker/main.go
internal/
user/ # 业务逻辑
order/
storage/ # 数据库
config/
pkg/ # 只有确实要给外部用才建
好处:别人(包括未来的自己)没法从外部 import 你的内部实现,改动内部结构不用担心破坏兼容性。
代价:有些测试想放在外部包(package user_test)就做不到了,因为那个目录在 internal 下面还是能 import 的——实际上这个没问题,同项目的测试文件可以。真正被限制的是别的仓库。
所以 internal/ 我基本是默认加上的,除非确实是要发布的库。
我不再用 pkg
pkg/ 目录在 Go 里没有任何特殊含义,纯粹是社区约定。
它的本意是"可以给外部用的包"。但实际项目里,你很难提前判断哪个包以后会被别的项目用。大部分时候 pkg/ 里的东西永远只有自己在用,白多一层。
我现在的做法:默认全放 internal/,真的需要被别人引用了,再把那个包提到根目录下。这时候目录名就是包名,不用套 pkg/。
比如:
myapp/
cmd/server/
internal/...
mylib/ # 决定要给外部用了,提到这里
按领域分还是按技术分
这是最容易吵起来的问题。两种方式:
按技术分层(传统 MVC 风格):
internal/
handler/
service/
repository/
model/
按业务领域分:
internal/
user/
handler.go
service.go
repo.go
model.go
order/
handler.go
service.go
repo.go
我倾向后者,理由是:
改一个功能的时候,涉及的文件都在一个目录里。按技术分的话,改"用户注册"这个功能要在 handler、service、repository、model 四个目录之间来回跳。
但业务领域分法有个问题:跨领域的调用容易变成循环依赖。比如订单要查用户信息,user 包又要查订单统计。
我的处理方式:跨领域的东西放到专门的协调层。
internal/
user/ # 只管用户自己的事
order/ # 只管订单自己的事
app/ # 协调层,处理跨领域逻辑
app 包可以同时 import user 和 order,反过来不行。这样依赖方向是单向的。
包的粒度
一个包不要太碎。 我见过一个包只有一个 struct 和一个方法的,五个包加起来一百行。这种拆法让 import 列表变长,但没降低任何复杂度。
也不要太肥。 有个项目的 util 包两千行,什么都往里塞。后来没人敢改它,因为不知道谁在用。
现在我的经验值:一个包大概 300~1500 行。超过 1500 行就看看能不能按职责拆;低于 200 行就考虑合到相邻的包。
包名要短,不要 userService、orderManager 这种。Go 的惯例是包名小写单词,调用处是 user.Service 而不是 userService.UserService。
依赖注入:构造函数就好
不用框架。手写:
type UserService struct {
repo UserRepo
hasher PasswordHasher
logger *slog.Logger
}
func NewUserService(repo UserRepo, hasher PasswordHasher, logger *slog.Logger) *UserService {
return &UserService{repo: repo, hasher: hasher, logger: logger}
}
在 main.go 里,或者专门的 wire.go 里组装:
func main() {
db := mustOpenDB(cfg.DB)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
userRepo := user.NewPostgresRepo(db)
hasher := auth.NewBcryptHasher(12)
userSvc := user.NewUserService(userRepo, hasher, logger)
srv := http.NewServer(userSvc, logger)
// ...
}
看 main.go 就能知道整个程序由哪些部件组成,依赖关系是显式的。比用反射的 DI 框架好读,而且编译期就能发现缺依赖。
依赖多了之后 main.go 会变长,我一般拆成几个 func newXxx(deps) *Xxx 放在 internal/app/wire.go。
接口定义在使用方
Go 的一个特殊习惯:接口应该定义在消费它的地方,而不是实现它的地方。
// internal/user/service.go
package user
// 这个接口由 user 包定义,因为它需要这个能力
type UserRepo interface {
GetByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, u *User) error
}
type Service struct {
repo UserRepo
}
而 postgres 的实现包不需要 import user 包的接口,它只要方法签名对得上就行——Go 是隐式实现接口。
好处是:user 包不依赖任何具体的数据库实现,测试的时候传个内存实现就行,不用起 Postgres。
代价是:接口和实现之间没有编译期关联,改接口签名的时候实现方不会报错,要到装配的时候才发现。缓解办法是加一行编译期断言:
// internal/storage/postgres/user_repo.go
var _ user.UserRepo = (*UserRepo)(nil) // 编译期检查
这行让 storage 包 import user 包,依赖方向是 storage → user,符合预期。
配置
internal/config/
config.go
config_test.go
config.go 干三件事:读环境变量、填默认值、校验。
type Config struct {
Addr string
DBDSN string
LogLevel string
ReadTimeout time.Duration
}
func Load() (*Config, error) {
c := &Config{
Addr: env("ADDR", ":8080"),
LogLevel: env("LOG_LEVEL", "info"),
ReadTimeout: envDuration("READ_TIMEOUT", 15*time.Second),
}
c.DBDSN = os.Getenv("DB_DSN")
if c.DBDSN == "" {
return nil, errors.New("DB_DSN 未设置")
}
return c, nil
}
不用配置框架。 环境变量加默认值就够了,配置文件的话 YAML 也行,但没必要上 viper 那种重的东西——它会引入一大堆依赖,而且行为(优先级、类型转换)经常出人意料。
启动时就校验完。 缺关键配置直接退出并打印清楚,不要等到第一次用到才发现。
migrations 放哪
migrations/
0001_create_users.sql
0002_add_order_status.sql
放项目根目录,不放 internal/。因为迁移文件是运维资产,DBA 或者部署脚本要用,不应该藏在内部包里。
用 golang-migrate 或者 goose,都能嵌入到二进制里(embed),部署时不用带一堆文件。
一个完整的例子
中等规模的服务,我现在大概是这样:
myservice/
cmd/
api/main.go # HTTP 入口
worker/main.go # 消费队列的入口
internal/
user/ # 领域:用户
service.go
repo.go # 接口定义
model.go
service_test.go
order/
app/ # 跨领域协调 + 依赖装配
wire.go
storage/
postgres/ # 具体实现
redis/
httpx/ # HTTP 中间件、响应封装
config/
migrations/
api/
openapi.yaml # 接口定义
scripts/
build.sh
Makefile
go.mod
README.md
没有 pkg/,没有 src/,没有 models/ 全局目录。
最后
这些都不是"标准答案"。我见过结构完全不同的项目也很好维护。
真正的判断标准是:新人接手时能不能在五分钟内找到"用户注册"的逻辑在哪。
如果找不到,那目录结构就有问题,不管它多符合某个规范。