Go internal 包
internal 目录如何被工具链强制为包可见性边界:谁能导入、放什么、与大写导出及 monorepo 的关系。
[!info] 关联笔记
Go internal 包
这个概念为什么会出现
仅靠标识符大小写,只能控制包内/包外符号是否可访问,不能表达:
“这个包只给本模块的某棵子树用,不要被其他模块或兄弟产品线直接依赖。”
在 Go 1.4 之前,团队只能靠约定与 Code Review 阻止错误导入;库作者无法在工具链层强制“内部实现包”。Go 1.4 引入 internal 目录约定并由编译器/go 命令强制:导入路径中含 internal 元素的包,只允许位于特定父路径下的代码导入。
这让“实现细节包”真正成为可执行的架构边界,与 modules、semver、最小导出面一起构成封装三件套:
- 小写标识符 — 包内符号
internal— 包路径级导入权- 模块版本 — 跨模块兼容承诺
[!abstract] 一句话理解 路径中带
internal的包只能被该internal段的父目录树之内的代码导入;越界导入在go build/go test时失败。
最小可运行示例结构
先把示例放进业务场景,再看目录与导入:
场景:签发 JWT 的实现细节不对外模块开放
你在做 example.com/app 服务:HTTP 入口在 cmd/server,对外 SDK 在 pkg/client。
token 签名密钥处理、claims 拼装只应给本模块用;若放在普通导出包,别的 module 也能 import 进来耦合内部。
internal/auth 用导入路径规则把可见半径钉在 example.com/app/... 子树——这是编译期强制,不是靠文档约定“请勿使用”。
module example.com/app
app/
go.mod
cmd/server/main.go # 本树内:允许 import internal
internal/auth/token.go # 实现细节:签发/校验
pkg/client/client.go # 对外 API:不要从这里再 export internal 符号
// cmd/server/main.go — 允许(同属 example.com/app 树,且在 internal 父路径内)
package main
import "example.com/app/internal/auth"
func main() {
// 业务:进程内签发或校验 token;外部 module 不应直接依赖此包
_ = auth.Issue
}
// 另一个 module,例如 example.com/other
// import "example.com/app/internal/auth"
// 期望编译错误:use of internal package not allowed
建议验证(在真实多 module 布局中):
# 本模块内构建应成功
go build ./cmd/server
# 从 example.com/other 引用 internal 应失败(错误文案随工具链)
go build ./...
设计背景:Go 1.4 Release Notes — Internal packages
命令与模块文档:cmd/go、Modules reference
结合场景再看关注点
internal控制的是“谁能 import 这个包”,与大小写导出正交。- 放得越深,可见半径越小(
service/internal只有service/...能用)。 - 对外
pkg/client不要顺便 re-export internal 类型,否则边界名存实亡。
核心概念与准确模型
1. 导入规则(核心)
若某包的导入路径包含路径元素 internal,记:
.../parent/internal/...
则只有其目录位置落在 parent 子树内的代码可以导入该包。
更精确的官方表述(概念化):
- 找到导入路径中最后一个
internal路径元素 - 其父目录对应的路径前缀,是允许导入者的根
- 导入者的路径必须等于该前缀,或是该前缀的子路径
示例:
包: example.com/app/internal/auth
父: example.com/app
允许:example.com/app/...
禁止:example.com/other/...
example.com/app2/...
再如更深:
包: example.com/app/service/internal/cache
父: example.com/app/service
允许:example.com/app/service/...
禁止:example.com/app/cmd/... # 不在 service 下
internal 的位置决定可见半径。 放得越深,可见范围越小。
2. 与大小写导出正交
| 机制 | 控制对象 | 强制点 |
|---|---|---|
| 大写/小写标识符 | 包内符号是否跨包可见 | 编译器类型检查 |
internal 目录 | 哪些包可以 import 该包 | go 命令/编译导入规则 |
| 模块边界 | 依赖版本与替换 | modules |
叠加效果:
- internal 包里仍应尽量少导出
- 即使导出
Public符号,外部模块也可能根本 import 不了该包
3. 放什么进 internal
适合:
- 不希望成为公共 API 的实现细节
- 跨多个内部包共享、但不想被外部模块依赖的代码
- 尚未稳定、频繁破坏性变更的实验实现
- 仅服务本应用的 wire 组装、私有中间件、私有模型
不适合:
- 你明确想让外部模块导入的库 API(应放非 internal 路径)
- 真正的可复用开源子库(应独立 module 或公开路径)
- 仅因“暂时随便放”而无边界意识的大杂烩(会变成内部万能包)
4. 常见布局
应用模块
cmd/ # main
internal/ # 全部私有实现
app/
http/
store/
pkg/ # 可选:有意公开的库代码(有争议,见下)
cmd + internal 是许多服务的务实默认。
库模块
module github.com/org/lib
go.mod
lib.go # 公共 API
internal/
re/ # 仅本库使用的实现
外部只能用根公开 API,不能 import github.com/org/lib/internal/re。
Monorepo 多服务
module example.com/monorepo
services/a/internal/...
services/b/internal/...
shared/internal/... # 谨慎:可见半径是 shared 的父路径
若希望仅服务 A 用,把 internal 挂在 services/a/internal,而不是仓库根 internal(根 internal 往往对整个 module 树可见)。
5. 与 pkg/ 目录
社区曾流行 pkg/ 表示“可被外部导入”。注意:
- 工具链不特殊对待
pkg - 只有
internal有强制力 - 现代许多项目去掉
pkg,公共代码直接放模块根或子目录
不要以为放进 pkg 就自动安全;不要以为不放 pkg 就不能导出。
6. 多个 internal 段
路径可以有多个 internal,规则看最后一个 internal 的父路径(以官方文档为准)。实践中保持简单:通常一层 internal 足够。
7. 测试的特殊情况
- 外部测试包
foo_test仍受 internal 规则约束 - 不能因为是测试就导入别人的 internal
- 可在本树内的
internal/testutil放测试辅助
同包测试可访问小写符号;那是另一维度。
8. 与 Modules、replace、workspace
replace指向本地路径时,internal 规则仍按最终导入路径逻辑执行- 多模块 workspace(
go.work)中,模块 B 仍不能导入模块 A 的 internal - 拆 module 时,原 internal 共享代码要重新安置(公开、复制、或新模块)
9. 架构意义:可执行的边界
Code Review 说“别依赖实现包”会被绕过;internal 让 CI 直接红。
适合强制:
- 领域层不直接依赖某驱动细节(把驱动放更深 internal,经接口在组装层注入)
- 公共 SDK 与私有实现分离
不自动解决:
- 包循环
- 分层腐化(全塞进一个 internal 大泥球)
仍需 go-project-layout-and-layering 的纪律。
10. 错误信息长什么样
越界导入时,go build 类似:
use of internal package example.com/app/internal/auth not allowed
这是硬错误,不是 warning。
边界与限制
- internal 不是安全沙箱:同一父树内仍可随意导入;防的是“外树/外模块”,不是恶意同树代码。
- 可见半径取决于路径深度:挂错层级会意外过大或过小。
- 重构移动目录会改可见性:移动
internal是 API 事件。 - 不能替代小写封装:internal 包里乱导出仍增加树内耦合。
- 开源双模块:有时需要把实现公开给主 API 测试之外的集成方——别误 internal。
- 代码生成路径:生成器 import 路径必须落在允许树内。
- 文档与发现性:internal 包默认不应成为用户阅读入口;Godoc 可隐藏或降低暴露。
- 历史 GOPATH 模式:现代以 modules 为准;读旧文注意时代。
常见误区
[!warning] 误区 1:只有小写就够了 小写挡不住“隔壁模块导入你的实现包”。
[!warning] 误区 2:根目录一个巨大 internal 万能库 全模块可见,等于弱化边界,变成 internal-util。
[!warning] 误区 3:pkg 有魔法 无强制;真正强制的是 internal。
[!warning] 误区 4:测试可以随便 import 任何 internal 不可以;规则同样适用。
[!warning] 误区 5:把公共 API 放进 internal 防用户用 用户不能用会导致库无意义;公共 API 应公开,细节 internal。
[!warning] 误区 6:以为 runtime 会检查 internal 这是构建期导入规则,不是运行时权限。
[!warning] 误区 7:monorepo 所有服务共享 root internal 却期望隔离 用更深的 per-service internal。
[!warning] 误区 8:用 internal 代替模块拆分决策 有时真正需要独立 module 与版本;internal 只是同模块封装。
工程实践
决策树
这段代码会被其他 module 导入吗?
是 → 公开路径 + 稳 API
否 → 是否仅子树需要?
是 → 挂到该子树 internal
否 → 模块根 internal 或公开但未导出符号
服务推荐骨架
cmd/api/main.go
internal/
config/
handler/
service/
store/
auth/
go.mod
main 只负责组装;逻辑在 internal。
库推荐骨架
go.mod
doc.go
client.go # 导出 API
option.go
internal/
version/
transport/
Code Review 问题
- 新包是否应 internal?
- internal 挂载深度是否正确?
- 是否出现“internal 被同树到处 import 形成泥球”?
- 是否有本应私有却放在公开路径的实现?
迁移策略
把公开实现收回 internal:
- 先标记 deprecated 公开包装
- 移动代码到 internal
- 公开 API 薄包装转调
- 删除旧导入路径(major 版本或内部模块一步到位)
与 CI
无需特殊插件:错误导入自然失败。可补充 goreleaser/文档生成仅扫描非 internal 路径。
实验
实验 A:制造越界导入
mkdir -p /tmp/modA /tmp/modB
# 在 modA 建 go.mod example.com/a 与 internal/secret
# 在 modB 尝试 import example.com/a/internal/secret(replace 指向 modA)
go build ./...
观察错误。
实验 B:深度可见性
example.com/app/service/internal/x
从 example.com/app/cmd 与 example.com/app/service/api 分别导入,预测谁成功。
实验 C:标识符 vs internal
在 internal 包导出 Func 与未导出 func,同树其他包验证:能 import 包但仍不能访问小写符号。
实验 D:布局重构
把 internal/auth 移到 auth 公开路径,用 go list/go build 看外部 module 是否突然能依赖——体会“移动 = 发布面变化”。
总结
| 问题 | 答案 |
|---|---|
| internal 解决什么? | 包路径级:谁可以导入实现包 |
| 谁强制? | go 工具链构建/测试时 |
| 与导出大小写? | 正交叠加 |
| 挂在哪? | 父路径 = 可见半径 |
| 放什么? | 私有实现、不稳定细节、应用内部代码 |
| 不替代什么? | 分层设计、小导出面、模块版本策略 |
一句话收束:
internal 把“别依赖实现”从口头约定升级为编译失败;挂载深度就是可见半径。
自测题
1. example.com/app/internal/db 能否被 example.com/other 导入?
2. 小写标识符能否阻止同模块其他包导入 internal/db 包本身?
3. 为何 monorepo 根 internal 可能导致边界过粗?
4. pkg/ 目录是否有工具链特权?
5. 测试包能否导入另一模块的 internal?
6. 把实现从 public 挪到 internal 对下游的影响?
答案
- 不能。
- 不能;小写只限符号,不限 import 包(同树仍可 import internal 包再用不导出符号——仅导出符号不可见)。
- 整个 module 树通常都能导入根 internal,服务间隔离弱。
- 无。
- 不能。
- 下游若曾导入该路径会构建失败;这是故意的封装收紧。