Repository navigation
16 encapsulation
omeyang edited this page Sep 16, 2026
·
1 revision
本文档定义 Go 项目中封装与信息隐藏的规范,包括导出决策、结构体设计、包 API 面积控制和构造函数设计。
- 最小暴露 - 必须只导出外部确实需要的标识符
- 隐藏实现细节 - 包的内部实现禁止对外可见
- 通过行为暴露,而非数据 - 应该通过方法暴露能力,而非直接暴露字段
- 构造函数守护不变量 - 有约束的类型必须通过构造函数创建
- 包是封装的基本单元 - Go 的封装边界是包,必须在包级别思考封装
默认不导出,有明确需求时才导出。
| 标识符类型 | 默认 | 导出条件 |
|---|---|---|
| 结构体 | 不导出 | 其他包需要直接使用该类型 |
| 结构体字段 | 不导出 | 需要 JSON 序列化或其他包直接读写 |
| 方法 | 不导出 | 是包 API 的一部分 |
| 函数 | 不导出 | 是包 API 的一部分 |
| 常量 | 不导出 | 其他包需要引用 |
| 接口 | 导出 | 接口通常用于跨包依赖 |
在导出任何标识符前,必须回答以下三个问题:
- 谁需要它? — 如果只有包内使用,不导出
- 为什么不能通过方法间接提供? — 优先通过行为暴露
- 导出后能否自由修改其实现? — 如果不能,说明封装不足
导出的标识符是公开 API,修改它会影响所有调用方。
- 导出的函数签名不应该随意变更
- 导出的结构体字段不应该随意删除或改类型
- 如果不确定是否需要导出,不导出
规则:结构体字段应该默认不导出,通过方法访问。
// 错误:所有字段导出,无法控制修改
type Connection struct {
Host string
Port int
Pool *pgxpool.Pool
MaxRetry int
}
// 正确:字段不导出,通过构造函数和方法控制
type Connection struct {
host string
port int
pool *pgxpool.Pool
maxRetry int
}
func (c *Connection) Host() string { return c.host }
func (c *Connection) IsHealthy() bool { return c.pool != nil }以下场景可以导出字段:
- 纯数据传输对象(DTO):只用于 JSON/BSON 序列化
// DTO:允许导出字段(JSON 序列化需要)
type CreateOrderRequest struct {
UserID string `json:"user_id"`
Items []Item `json:"items"`
}- 简单值对象:无行为、无约束的数据容器
// 简单值对象:允许导出字段
type Point struct {
X float64
Y float64
}- 配置结构体:需要外部赋值的配置
// 配置:允许导出字段
type ServerConfig struct {
Port int `yaml:"port"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
}如果字段有取值约束、一致性要求或不变量(invariant),必须不导出:
// 错误:外部可以设置非法状态
type Order struct {
Status string // 外部可以随意赋值 "anything"
Total float64 // 外部可以设置负数
}
// 正确:通过方法保护不变量
type Order struct {
status OrderStatus
total float64
}
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) Cancel() error {
if o.status != StatusPending {
return fmt.Errorf("cannot cancel order in status %s", o.status)
}
o.status = StatusCancelled
return nil
}有约束的类型必须提供构造函数,禁止依赖外部直接构造。
// 错误:外部可以构造不完整的对象
matcher := &Matcher{} // 缺少 repo 和 cache,后续调用会 panic
// 正确:构造函数确保完整性
func NewMatcher(repo Repository, cache Cache) *Matcher {
return &Matcher{
repo: repo,
cache: cache,
}
}构造函数应该校验参数的合法性:
func NewServer(addr string, opts ...ServerOption) (*Server, error) {
if addr == "" {
return nil, errors.New("addr is required")
}
s := &Server{
addr: addr,
port: 8080,
timeout: 30 * time.Second,
}
for _, opt := range opts {
opt(s)
}
if s.port <= 0 || s.port > 65535 {
return nil, fmt.Errorf("invalid port: %d", s.port)
}
return s, nil
}如果类型可以安全使用零值,可以不提供构造函数:
// 零值可用:sync.Mutex 无需构造函数
var mu sync.Mutex
// 零值可用:bytes.Buffer 无需构造函数
var buf bytes.Buffer判断标准:如果零值不会导致 panic 或错误行为,就是零值可用的。
包导出的标识符越少越好。API 面积小意味着:
- 使用者学习成本低
- 维护者修改自由度高
- 不兼容变更的风险低
| 指标 | 推荐范围 | 说明 |
|---|---|---|
| 导出函数/方法数 | 应该不超过 15 个 | 超过说明包可能职责过宽 |
| 导出类型数 | 应该不超过 5 个 | 超过考虑拆包 |
| 导出接口数 | 应该不超过 3 个 | 接口应小且精 |
| 导出常量/变量数 | 按需 | 尽量少 |
# 统计包的导出符号数量
go doc ./internal/matcher | grep -c "^func\|^type\|^var\|^const"
# 或使用 go vet 的 API 兼容性检查
go vet ./...手段 1:不导出辅助类型
// 错误:辅助类型也导出了
type MatchResult struct { ... } // 只在包内用
type MatchConfig struct { ... } // 只在包内用
// 正确:只导出必要的
type matchResult struct { ... } // 不导出
type matchConfig struct { ... } // 不导出手段 2:使用 internal 包
// 将不应该被外部使用的代码放入 internal
project/
├── matcher/
│ ├── matcher.go // 公开 API
│ └── internal/
│ └── algorithm/ // 内部算法,外部不可导入
│ └── fuzzy.go手段 3:接口收窄
返回接口而非具体类型,隐藏实现细节:
// 错误:暴露了具体类型
func NewCache() *RedisCache { ... }
// 正确:返回接口
func NewCache() Cache { ... }
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
}┌───────────────────────────────────────────┐
│ 包级封装(最外层) │
│ exported/unexported 控制包的 API 面积 │
│ ┌───────────────────────────────────────┐ │
│ │ 类型级封装(中间层) │ │
│ │ 字段可见性 + 方法控制状态变更 │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ 函数级封装(最内层) │ │ │
│ │ │ 局部变量、闭包隐藏实现细节 │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘
包级封装:决定哪些类型和函数可以被其他包使用
- 必须最小化导出标识符
-
必须通过
internal/隐藏不应暴露的包
类型级封装:决定类型的哪些字段和方法对外可见
- 有约束的字段必须不导出
- 状态变更必须通过方法
函数级封装:函数内部的实现细节
- 局部变量禁止泄漏到函数外
- 复杂逻辑应该封装在子函数中
结构体导出所有字段,没有行为方法,逻辑散落在外部。
// 反模式:贫血对象
type Order struct {
Status string
Total float64
Items []Item
}
// 逻辑散落在外部函数中
func CanCancelOrder(o *Order) bool { return o.Status == "pending" }
func CalculateTotal(o *Order) float64 { ... }
// 正确:行为内聚在类型中
type Order struct {
status OrderStatus
total float64
items []item
}
func (o *Order) CanCancel() bool { return o.status == StatusPending }
func (o *Order) Total() float64 { return o.total }接口方法暴露了底层技术细节。
// 反模式:暴露 MongoDB 细节
type Repository interface {
FindByFilter(ctx context.Context, filter bson.M) ([]*Order, error)
Aggregate(ctx context.Context, pipeline mongo.Pipeline) ([]*Result, error)
}
// 正确:技术无关的接口
type Repository interface {
FindByID(ctx context.Context, id string) (*Order, error)
FindByStatus(ctx context.Context, status OrderStatus) ([]*Order, error)
}包级变量导出且可变,任何人随时可改。
// 反模式:全局可变状态
var DefaultTimeout = 30 * time.Second // 任何包都能改
// 正确:不可变或通过函数访问
const DefaultTimeout = 30 * time.Second // 常量不可变
// 或通过函数控制
func SetTimeout(d time.Duration) {
mu.Lock()
defer mu.Unlock()
timeout = d
}- 所有导出的标识符都有明确的外部使用者
- 没有"以防万一"的导出
- 导出前回答了三个问题(谁需要、为什么不能通过方法、能否自由修改实现)
- 有约束的字段不导出
- DTO/配置结构体可以导出字段
- 有约束的类型提供了构造函数
- 构造函数校验了参数合法性
- 导出函数/方法不超过 15 个
- 导出类型不超过 5 个
- 使用
internal/隐藏实现包 - 返回接口而非具体类型(当有多种实现时)
- 没有贫血暴露(行为与数据在一起)
- 接口不泄漏实现细节
- 没有导出的全局可变状态
- 包级变量优先使用常量
- Effective Go — Exported Identifiers:https://go.dev/doc/effective_go#names
- Go Code Review Comments — Package Names:https://go.dev/wiki/CodeReviewComments
- 信息隐藏原则(Parnas, 1972):On the Criteria To Be Used in Decomposing Systems into Modules
- Uber Go Style Guide:https://github.com/uber-go/guide
本页由 Maat 仓库的 scripts/sync-wiki.sh 自动生成,请勿直接编辑。