Go 基础体系 · 第 14/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 包、模块、init 与 internal:组织依赖而不是堆目录

本文所有语义和命令均以 Go 1.26.4 为基准。Go 用 package 形成编译、命名与可见性边界,用 module 形成依赖版本与发布边界。一个 module 可以包含许多 package,一个仓库也可以包含多个 module,但目录多并不代表架构清楚。真正需要控制的是:谁能导入谁、初始化何时发生、哪些 API 对外承诺兼容,以及故障能否沿依赖方向定位。

本文讨论源码组织和依赖语义。工具链安装、代理、校验和与可重复构建属于 toolchain-modules;通用项目模板、代码生成和 CI 门禁属于 project-layout,这里不会重复展开。

1. package 是编译与命名边界

一个目录中参与同一次构建的普通 .go 文件必须声明同一个包名。编译器把它们合并为一个包处理,文件顺序不是设计接口;同包代码可访问彼此未导出的标识符。目录名决定导入路径的最后一段,包声明决定调用时使用的限定名,两者通常保持一致。

example.com/shop/price     // 导入路径
package price              // 包名
price.Format(cents)        // 使用方式

包是最小复用单位,不是文件。把一个大包拆成十个文件不会形成十层封装;反过来,为每个结构体创建一个目录会制造导入噪声。优先让同一业务能力的数据、规则和操作内聚,直到依赖方向或维护责任确实要求拆包。

2. module 是版本与依赖图边界

go.mod 第一行声明 module path,例如 module example.com/shop。模块内包的导入路径由 module path 加相对目录组成:internal/order 的完整路径是 example.com/shop/internal/order。module path 是身份,不保证网络上真有同名站点;但发布模块时,它必须与消费者取得源码的路径一致。

go mod init example.com/shop
go list -m
go list -deps ./...
go mod why -m example.com/dependency

应用通常一个 module 足够。只有组件需要独立版本、独立兼容承诺或独立发布节奏时才拆 module。多 module 会增加版本协调、跨模块重构和 CI 成本;它不是隔离业务代码的首选工具,包边界已经能完成多数隔离。

3. 导入、别名与空白导入

导入路径必须唯一,源码引用的是包名。发生同名冲突时可以起局部别名,但别名应表达含义,而不是用 p1p2 隐藏来源。

import (
	"crypto/rand"
	mathrand "math/rand/v2"
)

func sample() byte {
	var b [1]byte
	_, _ = rand.Read(b[:])
	return b[0] ^ byte(mathrand.IntN(256))
}

import _ "example.com/driver" 只触发被导入包的初始化,常见于数据库驱动注册。它建立了隐藏依赖:调用点看不到使用的符号,缺失或重复注册往往到运行时才暴露。业务装配优先显式构造;空白导入仅用于约定明确、注册机制确实需要的边界。

4. 导出规则不是 API 设计的全部

标识符首字母为 Unicode 大写字母才从包中导出。导出的类型、函数、字段和方法可以被其他包引用,但“可引用”不等于“应该依赖”。一旦库对外发布,导出名称、方法集、结构体字面量可填写字段以及错误语义都可能成为兼容承诺。

包名与导出名应在调用处读得自然,例如 http.Clientjson.NewDecoder。避免 article.ArticleService 这类重复。不要为了测试而导出内部细节;测试可通过公共行为验证,确需白盒测试时使用同包 _test.go 文件。

导出结构体字段会允许消费者直接构造和修改。需要维持不变量时,保留未导出字段并提供构造函数和行为方法;但不要机械地给所有字段写 getter/setter,简单数据载体保持结构体即可。

5. 包初始化的确定顺序

程序从 main 包及其导入图开始初始化。每个包只初始化一次;一个包必须等待它直接或间接导入的包初始化完成。包内先按依赖关系初始化包级变量,再按编译器确定的文件顺序执行各文件中的 init 函数,最后才调用 main.main

package config

var Base = loadBase()
var Endpoint = Base + "/v1"

func init() {
	// 此时 Base 和 Endpoint 已完成初始化。
}

不要让正确性依赖文件名排序。规范给出了呈现给编译器的文件顺序建议,但构建系统如何提交文件不应成为业务协议。若两个值存在先后关系,把它们放进同一个明确表达依赖的初始化表达式或显式构造函数。

6. init 的合理用途与危险边界

init 没有参数和返回值,不能被普通代码调用。它适合低风险、确定性的包内表格构造,或者遵循成熟协议的驱动注册。它不适合连接数据库、读取远程配置、启动 goroutine、修改进程级环境或以 panic 报告可恢复的配置错误。

隐藏副作用使测试无法决定初始化时机,也无法轻易注入失败。更稳妥的方式是:main 读取配置,依次调用返回 error 的构造函数,成功后启动服务,关闭时按反向顺序释放资源。这样每一步都有所有者、错误上下文和超时预算。

若测试在进入用例前就 panic,先检查依赖包的包级变量和 init。可用下面的命令观察初始化任务与包依赖,必要时临时在可疑初始化点记录日志,而不是用 recover 掩盖失败。

go list -json ./internal/config
GODEBUG=inittrace=1 go test ./internal/config -run TestName
go test ./... -count=1

7. internal 是工具链执行的访问控制

名为 internal 的目录有特殊导入规则:.../a/internal/b 只能由以 .../a 为根的目录树内代码导入。例如 example.com/shop/internal/order 可被 example.com/shop/cmd/api 导入,却不能被另一个 module example.com/report 导入。

example.com/shop/
├── cmd/api/                 # 可以导入 internal/order
├── internal/order/
└── public/orderapi/         # 对外稳定包

example.com/report/          # 不能导入 shop/internal/order

限制由 go 命令按导入路径检查,不靠代码审查约定。internal 表示“不承诺外部兼容”,不表示内容天然安全,也不阻止同一父树中的任意包访问。敏感信息仍要依赖权限、密钥管理和输入校验。

8. import cycle 为什么被禁止

如果包 order 导入 payment,而 payment 又直接或间接导入 order,编译会报 import cycle not allowed。循环使初始化与编译依赖无法形成有向无环图,Go 直接拒绝它,而不是猜测先后顺序。

常见根因是两个包同时拥有共享领域类型,或底层包为了回调而导入上层实现。修复方式通常有三种:把真正共同且稳定的概念移到更低层的小包;让使用方定义所需的小接口,由上层注入实现;合并本来就属于同一能力的两个包。不要创建装满杂项的 common 只为消除报错,它会成为新的耦合中心。

go list -deps ./...                         # 编译并暴露循环链
go list -f '{{.ImportPath}} {{.Imports}}' ./...
go mod graph                                # 模块级版本图,不等同包导入图

9. 依赖倒置应发生在使用方

包边界稳定的关键不是“所有东西都有接口”,而是高层规则不反向依赖具体基础设施。接口通常由消费者定义,只包含它完成工作所需的方法;数据库包实现它,但不需要导入业务包来声明自己实现了什么。

type PriceStore interface {
	Lookup(sku string) (int64, error)
}

type Service struct{ prices PriceStore }

func NewService(prices PriceStore) *Service {
	return &Service{prices: prices}
}

构造函数接收明确依赖,使测试可以传入小型替身。若接口只被实现方使用、包含其全部方法,往往只是多余的一层。接口的方法集、nil 语义和动态类型属于 interfaces 主题;这里关注它如何切断包的反向导入。

10. 版本选择、主版本与 replace

构建列表为每个 module path 选择一个版本。require 记录最低需要,工具链根据模块图选择满足约束的版本。主版本 v2 及以上必须进入 module path,例如 example.com/lib/v2;它因此可以与 v1 同时存在,也明确表示不兼容 API。

replace 可以把某个模块版本替换成本地目录或另一个版本,适合本地联调和紧急验证。它只影响当前主模块,不会随被依赖库传播给最终消费者。提交指向个人绝对路径的 replace 会让 CI 和同事无法构建,应在发布前清理。

go list -m all
go mod graph
go mod why -m example.com/lib
go mod edit -replace example.com/lib=../lib

go.work 可在本地把多个 module 组成 workspace,避免反复写 replace。是否提交取决于仓库是否把多模块联调作为正式工作流。它不产生新的发布单元,也不会合并各模块的版本承诺。

11. 测试包与构建边界

price_test 这样的外部测试包只能使用 price 的导出 API,能验证消费者真实体验;package price 的内部测试能访问未导出实现,适合复杂算法白盒检查。两者可以并存,但外部测试不能制造导入环:若包测试导入一个又导回被测包的辅助包,仍会触发循环。

测试辅助代码应靠近拥有它的测试。跨包共享 fixture 很容易演变成生产类型的平行模型。公共契约用外部测试锁定,内部不变量用同包测试覆盖,端到端装配放在更高层的集成测试中。

诊断“本机能过、CI 找不到包”时,检查 go env GOWORK GOMOD、构建标签、大小写和 module path。Linux 文件系统通常区分大小写,错误的导入大小写可能只在 CI 暴露。

12. 常见错误模式与工程检查

  • 万能 utilscommonmodel 让所有包互相共享细节,依赖图最终无法分层。
  • 为目录整齐提前拆几十个包,导致微小修改跨越大量接口和构造函数。
  • 包级可变单例和 init 注册让测试顺序相关,并在并发下产生数据竞争。
  • 把应用内部包发布成多个 module,却没有真正独立的维护者和版本策略。
  • 用空白导入或全局注册表隐藏实现选择,使启动配置无法从 main 读出。
  • 修改公共库导出字段或错误类型时只看“能编译”,忽略消费者的源码兼容和行为兼容。

日常审查可从导入图开始:入口只做装配,业务包不依赖传输与存储细节,基础设施包不反向调用入口。出现循环、初始化副作用或巨大公共包时,应重新确认谁拥有该能力,而不是继续添加中间层。

发布库还应在升级前比较导出面和行为测试。编译通过只能证明当前调用点仍满足类型检查,不能证明默认值、错误匹配、并发安全或初始化副作用没有变化。先在真实消费者上试升版本,再根据语义化版本决定发布级别;不要用类型别名长期掩盖已经失控的包迁移。

13. 可运行综合示例:显式装配两个包

下面的小模块由 cmd/shopinternal/cataloginternal/memory 组成。业务包定义自己需要的接口,内存实现包依赖业务数据类型,入口显式装配;没有 init、全局容器或循环导入。完整文件已放入验证目录。

// internal/catalog/catalog.go
package catalog

import "fmt"

type Product struct {
	SKU   string
	Stock int
}

type Store interface {
	Find(sku string) (Product, bool)
}

type Service struct{ store Store }

func New(store Store) *Service { return &Service{store: store} }

func (s *Service) Reserve(sku string, count int) error {
	if count <= 0 {
		return fmt.Errorf("count must be positive")
	}
	p, ok := s.store.Find(sku)
	if !ok {
		return fmt.Errorf("product %q not found", sku)
	}
	if p.Stock < count {
		return fmt.Errorf("product %q: want %d, have %d", sku, count, p.Stock)
	}
	return nil
}
// internal/memory/store.go
package memory

import "example.com/shop/internal/catalog"

type Store map[string]catalog.Product

func (s Store) Find(sku string) (catalog.Product, bool) {
	p, ok := s[sku]
	return p, ok
}
// cmd/shop/main.go
package main

import (
	"fmt"
	"log"

	"example.com/shop/internal/catalog"
	"example.com/shop/internal/memory"
)

func main() {
	store := memory.Store{"book": {SKU: "book", Stock: 3}}
	service := catalog.New(store)
	if err := service.Reserve("book", 2); err != nil {
		log.Fatal(err)
	}
	fmt.Println("reservation accepted")
}

从模块根运行:

gofmt -w .
go test ./...
go run ./cmd/shop

预期输出为 reservation accepted。这个例子最重要的不是目录形状,而是依赖单向:main -> memory -> catalog,同时 main -> catalogcatalog 不知道具体存储。将来替换数据库只新增实现和装配,不需要让核心规则导入基础设施。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。