← 返回文章列表

Go 项目里的 internal 目录应该怎么组织?

Go
  • # go
  • # project-layout
  • # architecture

讨论 Go 项目目录结构时,internal 很容易被理解成一个“内部代码垃圾桶”:只要不想给外部项目 import,就统统塞进去。于是目录很快变成这样:

internal/
├── app/
└── pkg/

看起来很工整。app 放应用代码,pkg 放内部公共代码。但问题在于:如果项目无论多大,internal 下面永远只有 apppkg 两个入口,那么 internal 本身就没有随着项目规模扩展。真正扩展的地方被推迟到了下一层,最后 internal/appinternal/pkg 会变成新的“大杂烩”。

这篇文章想说明一个更具体的判断:internal 目录应该按访问边界组织,而不是机械地按 app/pkg 二分。

internal 真正解决的是什么

internal 不是普通约定,它有 Go 工具链层面的限制。

Go 官方文档里对 internal package 的规则是:如果 import path 中包含 internal,那么只有 internal 父目录所在目录树里的代码可以 import 它。比如:

example.com/project/
├── cmd/
│   └── api/
│       └── main.go
└── internal/
    └── user/
        └── service.go

cmd/api/main.go 可以 import:

import "example.com/project/internal/user"

但另一个项目不能 import:

import "example.com/project/internal/user" // use of internal package not allowed

所以 internal 的核心不是“里面一定要再分 app 和 pkg”,而是:这部分代码属于当前父目录树,不承诺成为外部 API。

这也是它和顶层 pkg 的关键差别。pkg 只是社区约定,表示“这里的代码可以被外部项目使用”;internal 则会被 Go 命令真正检查。

可以参考 Go 官方文档里的说明:Internal Directories,以及 Go 1.4 引入 internal 包机制的发布说明:Go 1.4 Release Notes

internal/app 和 internal/pkg 的问题

internal/appinternal/pkg 这套分法来自一种常见项目布局。比如 golang-standards/project-layout 里提到,可以把实际应用代码放到 /internal/app,把多个应用共享的内部库放到 /internal/pkg

这种写法不是错。小项目、模板项目、只有一个服务的项目,用它没有太大问题。

真正的问题出现在项目继续变大之后。

假设一个项目开始只有一个 API 服务:

internal/
├── app/
│   └── api/
└── pkg/
    ├── config/
    └── logger/

后来又加了 worker、admin、scheduler、billing、notification。目录可能变成:

internal/
├── app/
│   ├── api/
│   ├── admin/
│   ├── worker/
│   └── scheduler/
└── pkg/
    ├── config/
    ├── logger/
    ├── database/
    ├── cache/
    ├── auth/
    ├── billing/
    └── notification/

这时 internal 顶层仍然只有两个目录,但它没有表达项目里真正的组件。你必须继续进入 apppkg,才能知道这个系统里到底有哪些东西。

更麻烦的是,pkg 这个名字太宽了。一个包只要被两个地方用到,就容易被扔进 internal/pkg。久而久之,internal/pkg 会变成“内部共享代码”的集合,但“共享”并不是一个稳定的领域边界。今天共享的是 logger,明天共享的是 user,后天共享的是 billing 规则。目录名无法告诉你这些包为什么存在,也无法阻止依赖关系变乱。

这就是 internal/appinternal/pkg 的主要风险:它们按代码使用方式分类,而不是按系统边界分类。

更可扩展的方式:让 internal 直接承载组件

如果一个项目由一个或多个组件组成,可以让 internal 下面直接出现这些组件或领域包:

cmd/
├── api/
│   └── main.go
├── worker/
│   └── main.go
└── scheduler/
    └── main.go

internal/
├── api/
│   ├── handler/
│   └── server/
├── worker/
│   └── consumer/
├── scheduler/
│   └── job/
├── user/
│   ├── service.go
│   └── repository.go
├── billing/
│   ├── invoice.go
│   └── payment.go
└── platform/
    ├── config/
    ├── database/
    └── log/

这里有几个变化:

  1. cmd 仍然只放入口,main.go 尽量薄,只负责组装配置、依赖和启动流程。
  2. internal/apiinternal/workerinternal/scheduler 表示不同运行组件的内部实现。
  3. internal/userinternal/billing 表示业务领域或模块。
  4. internal/platform 放跨组件的基础设施能力,比如配置、日志、数据库连接。

这样做的好处是:项目增加一个组件时,internal 可以直接增加一个对应目录。目录本身会随着系统增长而增长。

比如新增一个 notification worker,可以自然变成:

cmd/
└── notification-worker/
    └── main.go

internal/
├── notification/
│   ├── sender.go
│   └── template.go
└── notificationworker/
    └── consumer.go

这个结构比“全部塞到 internal/appinternal/pkg”更容易看出项目正在变成什么。

app 和 pkg 不是不能用,而是不要让它们成为默认答案

有些场景下,app 这个名字仍然有意义。

比如一个项目就是一个服务,内部结构很简单:

cmd/
└── server/
    └── main.go

internal/
└── app/
    ├── app.go
    ├── handler.go
    └── service.go

这时 app 表达的是“整个应用的组装对象”,不是一个长期承载所有业务代码的二级根目录。它只是一个小项目里的简单命名。

pkg 也不是绝对不能出现。问题是要先问清楚:这个包真的要作为外部 API 提供给其他项目 import 吗?

如果答案是否定的,优先放在 internal 里。如果只是多个内部组件复用,不一定要叫 pkg,可以叫更具体的名字:

internal/
├── platform/
│   ├── config/
│   ├── database/
│   └── log/
└── user/
    └── service.go

platformpkg 多表达了一层意思:这里放的是支撑业务运行的基础设施能力,而不是“任何共享代码”。

看几个开源项目

Go 官方源码本身就是一个很好的例子。go 命令的内部实现放在 cmd/go/internal 下,里面直接按能力拆出 loadmodloadworkruncachevcs 等包,而不是统一塞进 apppkg

这说明 internal 可以出现在更深的位置。cmd/go/internal 的可见范围比仓库根目录下的 internal 更窄,它表达的是“这些包主要服务于 go 这个命令”。

GitHub CLI 也是类似思路。它有 cmdpkg,也有 internal。在 internal 下面可以看到 configbrowserghrepopromptersafepaths 等面向具体能力的包。它没有强迫所有内部实现都先进入 internal/appinternal/pkg

GoReleaserinternal 更明显:里面有 archiveannounceuploadpipelinemiddlewaretmplyaml 等包。它按发布流水线里的能力来拆分,而不是先问“这是 app 还是 pkg”。

这些例子不是说所有项目都应该照抄它们,而是说明一点:成熟 Go 项目里的 internal 往往是按实际边界生长出来的。internal/appinternal/pkg 只是其中一种模板,不是规则本身。

一个简单判断标准

设计 Go 项目目录时,可以先问三个问题:

  1. 这段代码是否允许外部项目 import?
  2. 这段代码服务于哪个运行组件、业务领域或基础设施能力?
  3. 新增一个组件时,目录结构能不能自然增加,而不是继续往某个大目录里堆?

如果代码不允许外部项目 import,就放进 internal

如果它属于某个组件,就让组件成为目录:

internal/api
internal/worker
internal/scheduler

如果它属于某个业务领域,就让领域成为目录:

internal/user
internal/order
internal/billing

如果它是基础设施能力,就给它一个具体的基础设施边界:

internal/platform/config
internal/platform/database
internal/platform/log

最后再考虑是否需要 apppkg。不要反过来,一开始就把所有代码塞进 apppkg,再让真实的系统边界躲在第二层、第三层。

Go 的目录结构没有唯一标准。真正重要的是:目录能不能表达代码的访问边界、业务边界和增长方式。internal 的价值也在这里。它不是为了让项目看起来“标准”,而是为了把不该暴露的代码关在合适的边界里。