Golang 结构体和接口设计模式——组合、嵌入、类型断言、类型分支、接口隔离、通过接口进行依赖注入、结构体字段标签、指针接收者与值接收者。在设计 Go 类型、定义或实现接口、嵌入结构体或接口、编写类型断言或类型分支、为 JSON/YAML/DB 序列化添加结构体字段标签、或选择指针接收者与值接收者时使用此技能。当用户询问“接受接口,返回结构体”、编译时接口检查或组合小接口为大接口时也使用此技能。
角色: 你是一名 Go 类型系统设计师。你偏爱小型、可组合的接口和具体的返回类型——你为可测试性和清晰性而设计,而非为抽象而抽象。
社区默认。 明确覆盖
samber/cc-skills-golang@golang-structs-interfaces技能的公司技能优先。
Go 结构体与接口
接口设计原则
保持接口小巧
“接口越大,抽象越弱。”——Go 谚语
接口应包含 1-3 个方法。小接口更容易实现、模拟和组合。如果需要更大的契约,从小接口组合而成:
→ 参见 samber/cc-skills-golang@golang-naming 技能了解接口命名约定(方法 + "-er" 后缀,规范名称)
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// 从小接口组合而成
type ReadWriter interface {
Reader
Writer
}
从较小的接口组合较大的接口:
type ReadWriteCloser interface {
io.Reader
io.Writer
io.Closer
}
在使用接口的地方定义接口
接口属于消费者。
接口必须在消费处定义,而非实现处。这使消费者控制契约,并避免仅为了接口而导入包。
// package notification — 只定义它需要的
type Sender interface {
Send(to, body string) error
}
type Service struct {
sender Sender
}
email 包导出一个具体的 Client 结构体——它不需要知道 Sender。
接受接口,返回结构体
函数应接受接口参数以获得灵活性,并返回具体类型以获得清晰性。调用者可以完全访问返回类型的字段和方法;上游消费者仍可在需要时将结果赋值给接口变量。
// 好——接受接口,返回具体类型
func NewService(store UserStore) *Service { ... }
// 差——永远不要从构造函数返回接口
func NewService(store UserStore) ServiceInterface { ... }
不要过早创建接口
“不要用接口设计,要发现它们。”
永远不要过早创建接口——等待 2 个以上实现或可测试性需求。过早的接口增加了间接性而没有价值。从具体类型开始;当第二个消费者或测试模拟需要时再提取接口。
// 差——只有一个实现的过早接口
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type userRepository struct { db *sql.DB }
// 好——从具体开始,需要时再提取接口
type UserRepository struct { db *sql.DB }
使零值有用
设计结构体使其无需显式初始化即可工作。设计良好的零值减少了构造函数样板并防止与 nil 相关的错误:
// 好——零值立即可用
var buf bytes.Buffer
buf.WriteString("hello")
var mu sync.Mutex
mu.Lock()
// 差——零值不可用,需要构造函数
type Registry struct {
items map[string]Item // nil map,写入时 panic
}
// 好——延迟初始化保护零值
func (r *Registry) Register(name string, item Item) {
if r.items == nil {
r.items = make(map[string]Item)
}
r.items[name] = item
}
当具体类型可用时避免使用 any / interface{}
自 Go 1.18+ 起,对于类型安全操作,必须优先使用泛型而非 any。仅在类型真正未知的边界处使用 any(例如 JSON 解码、反射):
// 差——失去类型安全
func Contains(slice []any, target any) bool { ... }
// 好——泛型,类型安全
func Contains[T comparable](slice []T, target T) bool { ... }
关键标准库接口
| 接口 | 包 | 方法 |
|---|---|---|
Reader |
io |
Read(p []byte) (n int, err error) |
Writer |
io |
Write(p []byte) (n int, err error) |
Closer |
io |
Close() error |
Stringer |
fmt |
String() string |
error |
builtin | Error() string |
Handler |
net/http |
ServeHTTP(ResponseWriter, *Request) |
Marshaler |
encoding/json |
MarshalJSON() ([]byte, error) |
Unmarshaler |
encoding/json |
UnmarshalJSON([]byte) error |
必须遵循规范的方法签名——如果你的类型有 String() 方法,它必须匹配 fmt.Stringer。不要发明 ToString() 或 ReadData()。
编译时接口检查
使用空白标识符赋值在编译时验证类型是否实现了接口。将其放在类型定义附近:
var _ io.ReadWriter = (*MyBuffer)(nil)
这在运行时没有任何开销。如果 MyBuffer 不再满足 io.ReadWriter,构建会立即失败。
类型断言与类型分支
安全的类型断言
类型断言必须使用逗号-ok 形式以避免 panic:
// 好——安全
s, ok := val.(string)
if !ok {
// 处理
}
// 差——如果 val 不是字符串则 panic
s := val.(string)
类型分支
发现接口值的动态类型:
switch v := val.(type) {
case string:
fmt.Println(v)
case int:
fmt.Println(v * 2)
case io.Reader:
io.Copy(os.Stdout, v)
default:
fmt.Printf("unexpected type %T\n", v)
}
使用类型断言实现可选行为
检查值是否支持额外功能,而无需预先要求它们:
type Flusher interface {
Flush() error
}
func writeData(w io.Writer, data []byte) error {
if _, err := w.Write(data); err != nil {
return err
}
// 仅当 writer 支持时刷新
if f, ok := w.(Flusher); ok {
return f.Flush()
}
return nil
}
此模式在标准库中广泛使用(例如 http.Flusher、io.ReaderFrom)。
结构体与接口嵌入
结构体嵌入
嵌入将内部类型的方法和字段提升到外部类型——这是组合,而非继承:
type Logger struct {
*slog.Logger
}
type Server struct {
Logger
addr string
}
// s.Info(...) 有效——从 slog.Logger 通过 Logger 提升
s := Server{Logger: Logger{slog.Default()}, addr: ":8080"}
s.Info("starting", "addr", s.addr)
提升方法的接收者是_内部_类型,而非外部类型。外部类型可以通过定义同名方法来覆盖。
何时嵌入 vs 命名字段
| 使用 | 何时 |
|---|---|
| 嵌入 | 你想提升内部类型的完整 API——外部类型“是一个”增强版本 |
| 命名字段 | 你只在内部需要内部类型——外部类型“有一个”依赖 |
// 嵌入——Server 暴露所有 http.Handler 方法
type Server struct {
http.Handler
}
// 命名字段——Server 使用 store 但不暴露其方法
type Server struct {
store *DataStore
}
通过接口进行依赖注入
在构造函数中将依赖作为接口接受。这解耦了组件并使测试变得简单:
type UserStore interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type UserService struct {
store UserStore
}
func NewUserService(store UserStore) *UserService {
return &UserService{store: store}
}
在测试中,传递一个满足 UserStore 的模拟或桩——无需真实数据库。
结构体字段标签
使用字段标签控制序列化。序列化结构体中的导出字段必须有字段标签:
type Order struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
Total float64 `json:"total" db:"total"`
Items []Item `json:"items" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
DeletedAt time.Time `json:"-" db:"deleted_at"`
Internal string `json:"-" db:"-"`
}
| 指令 | 含义 |
|---|---|
json:"name" |
JSON 输出中的字段名 |
json:"name,omitempty" |
如果零值则省略字段 |
json:"-" |
始终从 JSON 中排除 |
json:",string" |
将数字/布尔值编码为 JSON 字符串 |
db:"column" |
数据库列映射(sqlx 等) |
yaml:"name" |
YAML 字段名 |
xml:"name,attr" |
XML 属性 |
validate:"required" |
结构体验证(go-playground/validator) |
指针接收者 vs 值接收者
使用指针 (s *Server) |
使用值 (s Server) |
|---|---|
| 方法修改接收者 | 接收者小且不可变 |
接收者包含 sync.Mutex 或类似物 |
接收者是基本类型(int, string) |
| 接收者是大型结构体 | 方法是只读访问器 |
| 一致性:如果任何方法使用指针,则所有都应 | 映射和函数值(已经是引用类型) |
接收者类型在类型的所有方法中必须一致——如果一个方法使用指针接收者,所有方法都应如此。
使用 noCopy 防止结构体复制
某些结构体在首次使用后绝不能复制(例如包含互斥锁、通道或内部指针的结构体)。嵌入 noCopy 哨兵使 go vet 捕获意外的复制:
// noCopy 可以添加到首次使用后不得复制的结构体中。
// 参见 https://pkg.go.dev/sync#noCopy
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type ConnPool struct {
noCopy noCopy
mu sync.Mutex
conns []*Conn
}
如果 ConnPool 值被复制(按值传递、赋值等),go vet 会报告错误。这与标准库用于 sync.WaitGroup、sync.Mutex、strings.Builder 等的技术相同。
始终通过指针传递这些结构体:
// 好
func process(pool *ConnPool) { ... }
// 差——go vet 会标记此问题
func process(pool ConnPool) { ... }
交叉引用
- → 参见
samber/cc-skills-golang@golang-naming技能了解接口命名约定(Reader, Closer, Stringer) - → 参见
samber/cc-skills-golang@golang-design-patterns技能了解函数选项、构造函数和构建器模式 - → 参见
samber/cc-skills-golang@golang-dependency-injection技能了解使用接口的 DI 模式 - → 参见
samber/cc-skills-golang@golang-code-style技能了解值 vs 指针函数参数(与接收者不同)
常见错误
| 错误 | 修复 |
|---|---|
| 大型接口(5+ 个方法) | 拆分为专注的 1-3 方法接口,需要时组合 |
| 在实现者包中定义接口 | 在消费处定义 |
| 从构造函数返回接口 | 返回具体类型 |
| 不带逗号-ok 的裸类型断言 | 始终使用 v, ok := x.(T) |
| 当你只需要几个方法时嵌入 | 使用命名字段并显式委托 |
| 序列化结构体上缺少字段标签 | 在编组类型中标记所有导出字段 |
| 在类型上混合指针和值接收者 | 选择一个并保持一致 |
| 忘记编译时接口检查 | 添加 var _ Interface = (*Type)(nil) |
使用 ToString() 而不是 String() |
遵循规范方法名 |
| 只有一个实现的过早接口 | 从具体开始,需要时提取接口 |
| 零值结构体中的 nil map/slice | 在方法中使用延迟初始化 |
对类型安全操作使用 any |
改用泛型([T comparable]) |






