浪白的博客の小站
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

Go 项目目录结构,我现在的习惯

写作时间:2026-07-22 20:45:00
# Go
# 工程实践
# 架构

关于 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/ 全局目录。

最后

这些都不是"标准答案"。我见过结构完全不同的项目也很好维护。

真正的判断标准是:新人接手时能不能在五分钟内找到"用户注册"的逻辑在哪。

如果找不到,那目录结构就有问题,不管它多符合某个规范。

avatar

浪白

邪恶的浪白

RECOMMENDED

Composer 用了这么多年,几个我认为值得坚持的习惯

2026-04-27 19:15:00

context 传参这件事,我踩过的三个坑

2026-05-06 20:05:00

Go 的 error 处理,用久了反而觉得还行

2026-04-19 22:15:00

Table of Contents