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) 值得单独说明。它有三个作用:
- 编译期校验:类型不再满足接口时立刻报错,而不是等到某处赋值。
- 自文档:读代码的人一眼看到「这个类型是给哪个接口用的」。
- 零运行时开销:
var _声明不产生任何代码。
var _ TaskStore = (*MemStore)(nil)
同理,实现 fmt.Stringer、error 等标准接口时,也建议加一行断言,让意图显式化:
var _ fmt.Stringer = Task{} // Task 实现 Stringer
var _ error = (*ValidationError)(nil) // 第 6 章会用到
5.3.5 标准接口的语义约定
有些接口是「有约定语义」的,实现它们时要遵守约定,不能只看签名。项目里最相关的三个:
| 接口 | 方法 | 语义约定 |
|---|---|---|
fmt.Stringer | String() string | 人类可读的调试/展示形式,不保证可解析 |
error | Error() string | 描述错误,通常是单行,不换行 |
io.Reader | Read([]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 接口与哨兵错误 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。