Go internal 包

internal 目录如何被工具链强制为包可见性边界:谁能导入、放什么、与大写导出及 monorepo 的关系。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go internal 包

这个概念为什么会出现

仅靠标识符大小写,只能控制包内/包外符号是否可访问,不能表达:

“这个包只给本模块的某棵子树用,不要被其他模块或兄弟产品线直接依赖。”

在 Go 1.4 之前,团队只能靠约定与 Code Review 阻止错误导入;库作者无法在工具链层强制“内部实现包”。Go 1.4 引入 internal 目录约定并由编译器/go 命令强制:导入路径中含 internal 元素的包,只允许位于特定父路径下的代码导入。

这让“实现细节包”真正成为可执行的架构边界,与 modules、semver、最小导出面一起构成封装三件套:

  1. 小写标识符 — 包内符号
  2. internal — 包路径级导入权
  3. 模块版本 — 跨模块兼容承诺

[!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/goModules reference

结合场景再看关注点

  1. internal 控制的是“谁能 import 这个包”,与大小写导出正交。
  2. 放得越深,可见半径越小service/internal 只有 service/... 能用)。
  3. 对外 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 不了该包

go-packages-and-visibility

3. 放什么进 internal

适合:

  1. 不希望成为公共 API 的实现细节
  2. 跨多个内部包共享、但不想被外部模块依赖的代码
  3. 尚未稳定、频繁破坏性变更的实验实现
  4. 仅服务本应用的 wire 组装、私有中间件、私有模型

不适合:

  1. 你明确想让外部模块导入的库 API(应放非 internal 路径)
  2. 真正的可复用开源子库(应独立 module 或公开路径)
  3. 仅因“暂时随便放”而无边界意识的大杂烩(会变成内部万能包)

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 共享代码要重新安置(公开、复制、或新模块)

go-modulesWorkspaces

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。

边界与限制

  1. internal 不是安全沙箱:同一父树内仍可随意导入;防的是“外树/外模块”,不是恶意同树代码。
  2. 可见半径取决于路径深度:挂错层级会意外过大或过小。
  3. 重构移动目录会改可见性:移动 internal 是 API 事件。
  4. 不能替代小写封装:internal 包里乱导出仍增加树内耦合。
  5. 开源双模块:有时需要把实现公开给主 API 测试之外的集成方——别误 internal。
  6. 代码生成路径:生成器 import 路径必须落在允许树内。
  7. 文档与发现性:internal 包默认不应成为用户阅读入口;Godoc 可隐藏或降低暴露。
  8. 历史 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 问题

  1. 新包是否应 internal?
  2. internal 挂载深度是否正确?
  3. 是否出现“internal 被同树到处 import 形成泥球”?
  4. 是否有本应私有却放在公开路径的实现?

迁移策略

把公开实现收回 internal:

  1. 先标记 deprecated 公开包装
  2. 移动代码到 internal
  3. 公开 API 薄包装转调
  4. 删除旧导入路径(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/cmdexample.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 对下游的影响?

答案
  1. 不能。
  2. 不能;小写只限符号,不限 import 包(同树仍可 import internal 包再用不导出符号——仅导出符号不可见)。
  3. 整个 module 树通常都能导入根 internal,服务间隔离弱。
  4. 无。
  5. 不能。
  6. 下游若曾导入该路径会构建失败;这是故意的封装收紧。

依据与延伸

创建于 2026/7/14 更新于 2026/7/15