🧠 一句真言:你的代码不该被迫依赖它根本用不上的方法 —— 就像你去便利店只买瓶水,店员却非要你背走整面货架。
🧊 起因:一个「无辜」的函数
假设你写了个备份函数,逻辑非常朴实无华:
func Backup(fs FileStorage, data []byte) error {
return fs.Save(data)
}
而 FileStorage 长这样:
type FileStorage struct{}
func (FileStorage) Save(data []byte) error { /* 写硬盘 */ }
func (FileStorage) Load(id string) ([]byte, error) { /* 读硬盘 */ }
乍一看:稳得一批 ✅
但细想一想……Backup 你只调了个 Save,却硬塞给它一个 能读又能写 的全能选手 ——
就像请个米其林主厨来泡面:他确实能泡,但他还会煎牛排、调酱汁、摆盘、写食谱……你只是想喝口汤啊喂!
🎯 问题在哪?
- 耦合过重:
Backup只能接受FileStorage,想换内存/S3/加密存储?重写吧您~ - 测试费劲:测
Backup得搞个假FileStorage,连Load都得 fake 出来(哪怕根本不用)。 - 意图模糊:看函数签名
Backup(fs FileStorage, …),你猜不出它到底要干啥——读?写?删库跑路?
🛠️ 第一步改进:上接口!
先抽象出一个“存储”接口:
type Storage interface {
Save(data []byte) error
Load(id string) ([]byte, error)
}
Backup 改成:
func Backup(s Storage, data []byte) error {
return s.Save(data)
}
✅ 解耦了!✅ 可替换!✅ 可 mock!
(写个 FakeStorage{} 即可愉快单元测试 💨)
但……
🧐 Wait a minute —
Backup只用了Save,却让FakeStorage还得实现Load?
这不就像租单车还得考驾照吗?!
🌟 终极解法:小接口,近消费!
Go 社区有个黄金铁律(来自官方 code review):
接口属于「使用它的地方」,而非「实现它的地方」。
于是我们祭出真正的主角——
type Saver interface {
Save(data []byte) error
}
Backup 大改签名:
func Backup(s Saver, data []byte) error {
return s.Save(data)
}
瞧瞧效果:
| 项目 | 之前 | 之后 |
|---|---|---|
| 接口方法数 | 2 | 1 |
| Fake 大小 | { Save(); Load() } | { Save() } 👉 一行搞定! |
| 意图清晰度 | ❓“这函数可能啥都干” | ✅“它就负责保存,仅此而已” |
| 重构成本 | 每加一个方法,所有 mock 都炸 | 加一百个方法,Backup 笑看风云 |
type FakeSaver struct{}
func (FakeSaver) Save([]byte) error { return nil }
func TestBackup(t *testing.T) {
err := Backup(FakeSaver{}, []byte("hello"))
require.NoError(t, err)
}
优雅,太优雅了!
🧠 背后思想:Interface Segregation Principle(ISP)
Clients should not be forced to depend on methods they do not use.
—— Robert C. Martin
翻译成人话:
别让人签「霸王合同」。只让他们承诺自己真能干的事。
在 Go 中,它天然适配:
- ✅ 隐式实现:只要有个
Save方法,管你是FileStorage、MemStorage还是AlienStorage,统统满足Saver。 - ✅ 消费者定义契约:谁用,谁说了算。不是生产者画个大饼让所有人啃。
🚀 实战案例:别让 AWS SDK 控制你的人生
看这段“经典反面教材”:
type S3Client interface {
PutObject(...) (*s3.PutObjectOutput, error)
GetObject(...) (*s3.GetObjectOutput, error)
ListObjectsV2(...) (*s3.ListObjectsV2Output, error)
// ……还有 30+ 个方法
}
然后你写了个上传函数:
func UploadReport(client S3Client, data []byte) error {
return client.PutObject(...) // 只用 PutObject!
}
结果:
- 每次 mock 都得实现 30+ 个无用方法 😭
- 某天 SDK 加了个
AbortMultipartUploadWithContextAndTracing——你的所有测试全飘红!🔥
正确姿势:
type Uploader interface {
PutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}
func UploadReport(u Uploader, data []byte) error {
_, err := u.PutObject(ctx, &s3.PutObjectInput{...})
return err
}
Mock?一行足矣:
type FakeUploader struct{}
func (FakeUploader) PutObject(...)(...){ return &s3.PutObjectOutput{}, nil }
AWS 官方文档都推荐这么干 👉 Testing with interfaces
✅ 小结:Go 接口设计三原则
| 原则 | 说明 | 反例 |
|---|---|---|
| 小 | 接口只含必要方法(1~2 个很常见) | interface{ A(); B(); C(); D(); … } |
| 近 | 定义在使用方包内(通常是调用者附近) | 所有接口塞进 pkg/storage/interface.go |
| 隐 | 无需显式 implements,Go 自动匹配 | 无(Go 天然支持) |
📌 记住:
大而全的接口 = 未来的债 + 测试的痛 + 重构的噩梦
小而精的接口 = 当下的爽 + 未来的稳 + 团队的爱
🧩 彩蛋:什么时候可以“大一点”?
标准库 io.Reader / io.Writer 是「大接口」却很成功?
✅ 因为它们:
- 足够基础通用
- 极其稳定(几十年没变)
- 方法语义高度正交
👉 应用层代码?还是乖乖写小接口吧~
(别拿 io.Reader 当挡箭牌,你又不是在写标准库 😅)
🎉 结语
下次当你写一个函数时,不妨自问一句:
“我真需要整个‘人’,还是只要他的‘右手’?”
如果答案是右手——
那就定义一个 RightHander 接口,然后愉快地 hand.Shake() ✋
毕竟,在 Go 的世界里:
少即是多,小即是美,用多少,取多少。