golang-structs-interfaces

golang-structs-interfaces

热门

Golang 结构体和接口设计模式——组合、嵌入、类型断言、类型分支、接口隔离、通过接口进行依赖注入、结构体字段标签、指针接收者与值接收者。在设计 Go 类型、定义或实现接口、嵌入结构体或接口、编写类型断言或类型分支、为 JSON/YAML/DB 序列化添加结构体字段标签、或选择指针接收者与值接收者时使用此技能。当用户询问“接受接口,返回结构体”、编译时接口检查或组合小接口为大接口时也使用此技能。

2261Star
150Fork
更新于 2026/6/6
SKILL.md
只读
名称
golang-structs-interfaces
描述

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.Flusherio.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.WaitGroupsync.Mutexstrings.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]