《Go 语言编程入门》5.3 接口设计惯用法

接口设计的好坏决定了代码的可测性与可演进性。本节用 TaskStore 讲清「接受接口、返回结构体」、小接口组合、接口嵌入、编译期断言、标准接口的语义约定、接口隔离与依赖精确化,以及何时不该用接口,并给出 TaskAPI 存储层的接口边界与扩展判据。

5.3 接口设计惯用法

5.1 和 5.2 解决了接口的「机制」问题:怎么定义、怎么满足、怎么断言。但真正决定代码质量的是「设计」问题:接口该多大?该由谁定义?什么时候该用接口、什么时候不该用?本节用 TaskStore 这个具体对象,把这些惯用法讲透。

本节把 TaskAPI 推进到「可测试的存储边界」:重新审视 TaskStore 的方法粒度,拆出可组合的小接口,确定「接受接口、返回结构体」的函数签名,为第 8 章的表驱动测试与 fake store 打基础。

5.3.1 接受接口,返回结构体

Go 社区最常被引用的一条接口惯用法是:Accept interfaces, return structs(参数用接口,返回值用结构体)。

// 推荐:参数是接口,调用方可以传任何实现
func NewService(store TaskStore) *Service {
	return &Service{store: store}
}

// 不推荐:返回接口,调用方被限制在接口方法集内
func NewService(store TaskStore) TaskStore { /* ... */ }

理由有三条:

  • 参数用接口:降低耦合,调用方可以传真实实现,也可以传测试替身。
  • 返回值用结构体:调用方拿到具体类型,能用它的全部方法;将来给结构体加方法,不破坏现有调用方。
  • 返回接口会「锁死」抽象:一旦返回 TaskStore,调用方就只能用那 3 个方法,想加一个 Count() 都得改接口,波及所有实现。

这条规则在 TaskAPI 里的落点是:NewService(store TaskStore) *Service,服务返回 *Service 而非某个 Service 接口。只有当你确实需要多个实现可替换时才返回接口,而这种场景在入门项目里很少。

5.3.2 小接口与接口组合

标准库的 I/O 体系是小接口的典范:io.Reader 一个方法,io.Writer 一个方法,需要「又能读又能写」时用接口嵌入组合:

type Reader interface{ Read() string }
type Writer interface{ Write(s string) }

type ReadWriter interface {
	Reader
	Writer
}

接口嵌入的规则和结构体嵌入类似:ReadWriter 的方法集是 Reader 与 Writer 的并集。任何同时实现 Read 和 Write 的类型自动满足 ReadWriter。

type Buf struct{ data string }

func (b *Buf) Read() string   { return b.data }
func (b *Buf) Write(s string) { b.data += s }

var rw ReadWriter = &Buf{}
rw.Write("hello")
fmt.Println(rw.Read()) // hello

实测输出 hello。这种「用嵌入拼接口」的方式,让接口的粒度可以按需组装,而不是一开始就定义一个大而全的 ReadWriteCloser。

回到 TaskAPI:TaskStore 目前是 3 个方法,将来接数据库可能需要事务能力。正确做法不是把 Begin()、Commit()、Rollback() 塞进 TaskStore,而是定义独立的小接口,让需要事务的调用方依赖组合接口:

type TaskReader interface{ Get(id int64) (Task, bool); List() []Task }
type TaskWriter interface{ Create(title string) (Task, error) }
type TaskStore interface {
	TaskReader
	TaskWriter
}

这样只读的调用方依赖 TaskReader,测试替身也能只实现只读那部分,不必被迫实现写方法。

5.3.3 接口由使用方定义

5.1 提过一次,这里展开成可操作的判据。问自己一个问题:这个接口是谁需要的?

  • 如果答案是「某个具体调用方为了解耦」,接口就该定义在那个调用方附近,方法只包含它用到的。
  • 如果答案是「因为我觉得这个类型应该有个接口」,那就别定义——这是典型的过度抽象。

一个真实的失败案例:项目初期给 MemStore 定义了 TaskStore 接口,但当时只有一处调用、也只有一个实现。这个接口没有带来任何解耦收益,反而多了一层间接。接口应该在第二个实现出现时(比如测试 fake 或数据库实现)再抽取,而不是提前设计。

时机该不该抽接口
只有一个实现,且短期内不会变不必抽
需要一个测试替身抽(这正是第 8 章的场景)
需要替换后端(内存/数据库)抽
只是想「面向接口编程」先别抽,等第二个实现

5.3.4 编译期断言与接口文档

前面反复出现的 var _ I = (*T)(nil) 值得单独说明。它有三个作用:

  1. 编译期校验:类型不再满足接口时立刻报错,而不是等到某处赋值。
  2. 自文档:读代码的人一眼看到「这个类型是给哪个接口用的」。
  3. 零运行时开销:var _ 声明不产生任何代码。
var _ TaskStore = (*MemStore)(nil)

同理,实现 fmt.Stringer、error 等标准接口时,也建议加一行断言,让意图显式化:

var _ fmt.Stringer = Task{}   // Task 实现 Stringer
var _ error = (*ValidationError)(nil) // 第 6 章会用到

5.3.5 标准接口的语义约定

有些接口是「有约定语义」的,实现它们时要遵守约定,不能只看签名。项目里最相关的三个:

接口方法语义约定
fmt.StringerString() string人类可读的调试/展示形式,不保证可解析
errorError() string描述错误,通常是单行,不换行
io.ReaderRead([]byte) (int, error)返回读取字节数与错误,io.EOF 表示结束

Task.String() 实现的是 fmt.Stringer 的语义:给开发者看的可读表示 #1 写稿 [已完成]。注意 String() 用值接收者,这样 Task 和 *Task 都满足 fmt.Stringer,fmt 包在打印时能自动调用它。如果改成指针接收者,那么打印 Task 值时不会触发 String(),会退化成默认的 {1 写稿 true}——这是一个典型的「接收者选择影响接口满足」的连锁反应。

5.3.6 何时不该用接口

接口是工具,不是目标。以下情况不要引入接口:

  • 数据聚合类型:Task 就是数据,给它定义接口毫无意义。接口描述行为,不描述数据。
  • 只有一个实现的「预留扩展」:YAGNI。等真的需要第二个实现再说。
  • 为了「看起来像 Java」:每个类型配一个接口是 Java 的习惯,Go 不这么做。
  • 方法返回接口:见 5.3.1,返回值用具体类型。

反过来,该用接口的信号很明确:出现了两个以上需要互换的实现(真实存储 + 测试 fake),或某个调用方只用到对象的一小部分能力、希望精确表达依赖。

5.3.7 接口隔离:只依赖用到的方法

接口隔离原则(ISP)在 Go 里有一个很直接的体现:调用方只依赖它真正用到的方法。先看反例——所有函数都依赖大接口 TaskStore:

func CountPending(store TaskStore) int     { /* 明明只需要 List */ }
func CreateDefault(store TaskStore) error  { /* 明明只需要 Create */ }

这两个函数被大接口绑死:任何想调用它们的场景,传进来的类型都必须实现 TaskStore 的全部方法,测试替身也一样。正例是按需依赖最小接口:

func CountPending(r TaskReader) int {
	n := 0
	for _, t := range r.List() {
		if !t.Done {
			n++
		}
	}
	return n
}

func CreateDefault(w TaskWriter) (Task, error) {
	return w.Create("默认任务")
}

CountPending 只依赖 TaskReader,CreateDefault 只依赖 TaskWriter。实测把它们接上 MemStore:

m := &MemStore{tasks: make(map[int64]Task)}
CreateDefault(m)
CreateDefault(m)
fmt.Println(CountPending(m)) // 2

输出 2。收益在测试时最明显:给 CountPending 写 fake 只需要实现一个 List 方法,不必实现 Create、Get、Rename。依赖越精确,替身越小,测试越好写——这正是第 8 章「test doubles」要利用的性质。

5.3.8 项目落地:TaskAPI 的接口边界

综合以上,第 5 章结束时 TaskAPI 的接口设计定型为:

// 只读能力
type TaskReader interface {
	Get(id int64) (Task, bool)
	List() []Task
}

// 写入能力
type TaskWriter interface {
	Create(title string) (Task, error)
}

// 完整存储契约:由使用方按需依赖 Reader / Writer / TaskStore
type TaskStore interface {
	TaskReader
	TaskWriter
}

// 内存实现
type MemStore struct {
	nextID int64
	tasks  map[int64]Task
}

var _ TaskStore = (*MemStore)(nil)

服务层函数签名遵循「接受接口、返回结构体」:

type Service struct{ store TaskStore }

func NewService(store TaskStore) *Service {
	return &Service{store: store}
}

只读的报表函数只依赖 TaskReader,写入的创建函数只依赖 TaskWriter——依赖精确到方法级,测试替身也可以按需实现。第 8 章的 fake store 会直接受益于这层设计:它只需实现被测函数真正调用的方法。

5.3.9 小结与检查清单

  • 接受接口,返回结构体
  • 接口尽量小;用接口嵌入组合能力,而非定义大接口
  • 接口由使用方定义,在第二个实现出现时再抽
  • 每个实现加一行 var _ I = (*T)(nil) 编译期断言
  • 遵守 fmt.Stringer / error / io.Reader 的语义约定
  • 数据聚合类型、单实现「预留扩展」不抽接口
  • 注意接收者选择会通过方法集影响接口满足(如 String())
  • 调用方按需依赖最小接口(TaskReader / TaskWriter),别都挂到大接口上

第 5 章把「抽象」这一层建好了。下一章处理 Go 最独特、也最容易被写坏的一块:错误处理。我们会为 TaskStore 定义 ErrNotFound / ErrInvalidTitle 哨兵错误,用 %w 做分层包装,并让调用方能用 errors.Is / errors.As 精确判别错误。

阅读导航:上一节:5.2 类型断言与 type switch · 下一节:6.1 error 接口与哨兵错误 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练