Go 里的“最小依赖”哲学:血的教训!

24 阅读4分钟

🧠 一句真言:你的代码不该被迫依赖它根本用不上的方法 —— 就像你去便利店只买瓶水,店员却非要你背走整面货架。


🧊 起因:一个「无辜」的函数

假设你写了个备份函数,逻辑非常朴实无华:

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,却硬塞给它一个 能读又能写 的全能选手 ——
就像请个米其林主厨来泡面:他确实能泡,但他还会煎牛排、调酱汁、摆盘、写食谱……你只是想喝口汤啊喂!


🎯 问题在哪?

  1. 耦合过重Backup 只能接受 FileStorage,想换内存/S3/加密存储?重写吧您~
  2. 测试费劲:测 Backup 得搞个假 FileStorage,连 Load 都得 fake 出来(哪怕根本不用)。
  3. 意图模糊:看函数签名 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)
}

瞧瞧效果:

项目之前之后
接口方法数21
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 方法,管你是 FileStorageMemStorage 还是 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 的世界里:

少即是多,小即是美,用多少,取多少。