Go:interface 不难,难的是在项目里什么时候该用,什么时候不该用

第一次学 Go 的 interface 时,经常会同时听到几句互相冲突的话:

  • interface 是 Go 最强大的抽象工具
  • interface 要尽量小
  • 没必要为了扩展到处写接口
  • 但写测试时又经常要靠接口替身

如果只是看语法,这几句话很容易被理解成“看心情”。

真正难的地方其实不是 interface 长什么样,而是下面这些判断:

  • 这段代码抽象的到底是能力,还是只是把类型名字藏起来
  • 接口应该由谁来定义,是实现方定义,还是使用方定义
  • 当前只有一个实现,到底该不该提前抽象
  • nil 明明看起来是空,为什么放进接口后又不等于 nil
  • 为什么有些项目越写接口越多,最后反而更难维护

这一篇就围绕一个实际场景来讲:做一个回归测试结果通知服务
这个场景很适合讲 interface,因为它天然会碰到:

  • 不同通知渠道的切换
  • 外部依赖的隔离
  • 测试替身的注入
  • 过度抽象的风险
  • nil 和方法集的高频坑

一、这篇文章要解决什么问题

读完这一篇,应该能独立说清楚这些事:

  • interface 本质上在表达什么
  • Go 的“隐式实现”到底是什么意思
  • 为什么很多场景应该由调用方定义接口
  • 什么时候该用 interface 隔离边界
  • 什么时候根本不该上接口
  • typed nil、指针接收者、方法集这些坑为什么会出现
  • 怎么用一个小接口把测试写稳,而不是把项目抽象空掉

如果这些动作能独立做出来,后面再看 io.Readererrorhttp.Handler、Repository、Client 封装,理解会扎实很多。

二、先把这篇文章里的场景说清楚

假设现在有一个很小的测试平台服务。
每天回归任务跑完后,它要把结果发到不同渠道:

  • 发到飞书群
  • 发到邮件
  • 后面可能还会发到企业微信

通知内容也很简单,大概像这样:

1
2
3
4
计划: nightly-regression
总数: 120
通过: 118
失败: 2

这类需求看起来非常适合上 interface,但真正写起来时会马上碰到几个选择:

  1. 是否现在就抽象一个大而全的 Notifier 接口
  2. 是不是所有 service、repository、formatter 都要先定义接口
  3. 当前只有飞书实现时,直接依赖具体类型是否更简单
  4. 写测试时,怎样抽象才足够,怎样又算抽象过度

后面的内容就围绕这几个问题展开。

三、先看一个最小例子:interface 到底在表达什么

先看最小代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import "fmt"

type Message struct {
Title string
Body string
}

type Notifier interface {
Send(Message) error
}

type FeishuBot struct {
Webhook string
}

func (b FeishuBot) Send(msg Message) error {
fmt.Printf("send to feishu: %s\n", msg.Title)
return nil
}

func Deliver(n Notifier, msg Message) error {
return n.Send(msg)
}

func main() {
bot := FeishuBot{Webhook: "https://example.com/hook"}
_ = Deliver(bot, Message{
Title: "nightly report",
Body: "passed: 118, failed: 2",
})
}

这里最关键的不是“定义了一个接口”,而是:

  • Notifier 只描述一种能力:Send
  • Deliver 不关心你到底是飞书、邮件还是别的渠道
  • 只要某个类型能提供 Send(Message) error,它就能被当成 Notifier

所以 interface 的核心不是“给类型分类”,而是:把调用方真正依赖的行为单独拿出来。

四、Go 里的 interface 本质上是什么,不是什么

如果只记一句话,可以记这个:

interface 是一份行为契约,不是类继承,不是万能抽象层,也不是“以后可能扩展”的心理安慰剂。

它至少包含两层意思:

  1. 调用方现在只需要这些方法
  2. 实现方只要满足这些方法,就能接进来

但它不意味着:

  • 实现方和调用方一定存在稳定层次关系
  • 一旦写了接口,架构就更高级
  • 当前只有一个实现时,也必须先做抽象

在运行时,一个接口值可以粗略理解成一对东西:

  • 动态类型
  • 动态值

这个理解后面讲 nil 时很重要,因为接口变量是否等于 nil,看的是这两部分是不是都为空。

五、隐式实现为什么简单,但也容易被误用

Go 和很多语言不一样的地方在于:实现接口不需要显式声明。

还是上面的例子,FeishuBot 并没有写这种代码:

1
2
3
type FeishuBot struct{}

// 不需要类似 implements Notifier 这样的声明

只要方法集满足接口,它就自动实现了。

这个设计的直接好处很明显:

  • 低耦合
  • 不需要改实现方代码就能接入新接口
  • 很适合在使用端按需抽象

但它也带来一个很高频的误区:
“反正实现接口很容易”这个判断一旦先站住,代码就很容易提前设计出一堆接口,把项目写成:

  • UserService
  • UserServiceImpl
  • UserRepository
  • UserRepositoryImpl
  • UserFormatter
  • UserFormatterImpl

如果这些接口没有承载稳定边界,那它们本质上只是在重复类型名。

六、先看一个编译错误:隐式实现不是“差不多就行”

Go 的隐式实现很灵活,但方法签名只要不完全匹配,就不算实现。

例如:

1
2
3
4
5
6
7
8
9
type Notifier interface {
Send(Message) error
}

type EmailSender struct{}

func (s EmailSender) Send(title string) error {
return nil
}

这时 EmailSender 并没有实现 Notifier,因为:

  • 参数不是 Message
  • 方法签名不一致

再看另一个高频错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Notifier interface {
Send(Message) error
}

type WebhookSender struct{}

func (s *WebhookSender) Send(msg Message) error {
return nil
}

func main() {
var n Notifier
sender := WebhookSender{}
n = sender
}

这段代码也会报错,原因不是方法内容,而是方法集

  • Send 定义在 *WebhookSender
  • WebhookSender 这个值本身的方法集里没有这个方法
  • 所以 WebhookSender 不能赋给 Notifier
  • 只有 *WebhookSender 才可以

这也是为什么你经常会看到这类报错:

1
WebhookSender does not implement Notifier (method Send has pointer receiver)

这个坑不是边角问题,项目里非常常见。

七、调用方定义接口,通常比实现方定义接口更稳

这是 Go 里很重要的一条工程习惯:

接口更适合由使用它的一方定义,而不是由实现它的一方预先定义。

为什么?

因为真正知道“我只需要哪些行为”的,通常是调用方。

先看一个容易写偏的版本。

实现方包里先定义一个很大的接口:

1
2
3
4
5
6
7
8
package notify

type Notifier interface {
Send(Message) error
HealthCheck() error
Close() error
Name() string
}

然后业务代码说“我要依赖这个接口”。

问题在于,业务代码可能其实只需要:

1
Send(Message) error

结果因为实现方提前定义了大接口,所有实现和调用都被迫背上:

  • HealthCheck
  • Close
  • Name

这会直接导致两个问题:

  1. 接口越来越胖
  2. 业务代码的真实依赖被掩盖

更稳的写法通常是由调用方按需定义:

1
2
3
4
5
6
7
8
9
package report

type sender interface {
Send(Message) error
}

type Service struct {
sender sender
}

这时 Service 很明确地告诉你:
我只依赖发送能力,不依赖名字、关闭动作、健康检查。

这就是“consumer-defined interface”的实际价值。

八、什么时候根本不需要 interface

一学 interface,代码就很容易条件反射式地开始抽象。
但在 Go 里,有不少场景直接用具体类型反而更对。

例如:

1
2
3
4
5
6
7
8
type MarkdownFormatter struct{}

func (f MarkdownFormatter) Build(summary Summary) Message {
return Message{
Title: summary.PlanID,
Body: "passed and failed numbers here",
}
}

如果整个项目里只有这一种格式化规则,而且没有单独替换的需求,这时直接依赖 MarkdownFormatter 往往是最简单的。

没必要一上来就写:

1
2
3
type Formatter interface {
Build(Summary) Message
}

因为这层接口并没有隔离任何真实边界。
它既不是外部依赖,也不是稳定扩展点,也没有明显测试价值。

一个很实用的判断标准是:

  • 如果去掉这个接口,代码会不会更直接
  • 如果答案是“会”,那这个接口大概率就不该先存在

九、过度抽象最常见的样子:为了“以后可能扩展”先设计大接口

看一个典型的错误版本:

1
2
3
4
5
6
7
8
type NotificationPlatform interface {
BuildTitle(Summary) string
BuildBody(Summary) string
Send(Message) error
Retry(Message) error
HealthCheck() error
Close() error
}

这个接口表面上很“完整”,实际却同时混了三类事情:

  • 文案构建
  • 通知发送
  • 运行时管理

结果通常会变成:

  • 某些实现根本不需要 Retry
  • 某些实现的 Close 什么也不做
  • 测试替身也被迫补齐一堆空方法

最后代码会充满这种味道:

1
2
func (n MockPlatform) Close() error { return nil }
func (n MockPlatform) Retry(Message) error { return nil }

这不是抽象得好,而是职责没有拆开。

更稳的做法通常是:

  • 文案拼装先用具体类型
  • 发送边界抽成小接口
  • 连接管理如果真的需要,再单独定义更窄的接口

不是“不要抽象”,而是只抽象真实变化的那一层。

十、nil 为什么在 interface 这里最容易坑人

这类坑第一次出现时,通常都在这里。

先看最简单的空接口值:

1
2
var n Notifier
fmt.Println(n == nil)

输出是:

1
true

因为这时接口变量里:

  • 没有动态类型
  • 也没有动态值

但再看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type FeishuBot struct {
Webhook string
}

func (b *FeishuBot) Send(msg Message) error {
return fmt.Errorf("webhook=%s", b.Webhook)
}

func main() {
var bot *FeishuBot = nil
var n Notifier = bot

fmt.Println(bot == nil)
fmt.Println(n == nil)
}

输出会是:

1
2
true
false

为什么?

因为这时接口变量 n 里:

  • 动态类型是 *FeishuBot
  • 动态值是 nil

也就是说,接口本身不是空的。
它装着“一个类型明确但值为空的东西”。

这就是 Go 项目里非常典型的 typed nil 坑。

十一、一个真实风险:你以为判断过 nil 了,其实还是会 panic

继续看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type FeishuBot struct {
Webhook string
}

func (b *FeishuBot) Send(msg Message) error {
if b.Webhook == "" {
return fmt.Errorf("empty webhook")
}
return nil
}

func Deliver(n Notifier, msg Message) error {
if n == nil {
return fmt.Errorf("notifier is nil")
}
return n.Send(msg)
}

func main() {
var bot *FeishuBot = nil
var n Notifier = bot
_ = Deliver(n, Message{Title: "nightly"})
}

这段代码里,Deliver 虽然做了 n == nil 判断,但仍然可能 panic。
因为:

  • n 不是空接口值
  • 调用 n.Send 时,底层接收者其实是一个空指针
  • 方法里一旦访问 b.Webhook,就会触发空指针解引用

应对这个坑,通常有三种思路:

  1. 构造时就保证依赖非空,不让空实现流进来
  2. 尽量避免把 nil 指针塞进接口
  3. 在确实需要的地方,对具体类型做更明确的空判断

最稳的一条通常还是第一条:用构造约束把坏状态挡在入口。

十二、再看一个高频坑:指针接收者、值接收者和方法集

interface 的很多报错,根上都和方法集有关。

先看这段代码:

1
2
3
4
5
6
7
8
9
type Notifier interface {
Send(Message) error
}

type EmailSender struct{}

func (s EmailSender) Send(msg Message) error {
return nil
}

这时:

  • EmailSender 可以赋给 Notifier
  • *EmailSender 也可以赋给 Notifier

因为值接收者方法会同时出现在值类型和指针类型的方法集中。

但如果改成:

1
2
3
func (s *EmailSender) Send(msg Message) error {
return nil
}

这时只有 *EmailSender 才能赋给 Notifier

什么时候该用值接收者,什么时候该用指针接收者,要回到对象语义本身:

  • 小对象、不可变式使用、无共享状态修改,值接收者通常更直接
  • 包含连接、缓存、锁、计数器、配置句柄等状态时,指针接收者通常更合理

不要为了“统一写法”一律上指针,也不要因为图省事忽略方法集差异。

十三、一个更接近项目现场的小案例

现在把上面的零散概念收进一个小服务里。
假设现在要做一个“回归测试通知器”,职责是:

  • 接收测试汇总结果
  • 组装一条通知消息
  • 调用外部发送器发出去

先看一个比较稳的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package report

import "fmt"

type Summary struct {
PlanID string
Total int
Passed int
Failed int
}

type Message struct {
Title string
Body string
}

type sender interface {
Send(Message) error
}

type Formatter struct{}

func (Formatter) Build(summary Summary) Message {
return Message{
Title: fmt.Sprintf("回归结果: %s", summary.PlanID),
Body: fmt.Sprintf(
"总数: %d\n通过: %d\n失败: %d",
summary.Total,
summary.Passed,
summary.Failed,
),
}
}

type Service struct {
sender sender
formatter Formatter
}

func NewService(sender sender) (*Service, error) {
if sender == nil {
return nil, fmt.Errorf("sender can not be nil")
}
return &Service{
sender: sender,
formatter: Formatter{},
}, nil
}

func (s *Service) Notify(summary Summary) error {
if summary.PlanID == "" {
return fmt.Errorf("plan id can not be empty")
}
msg := s.formatter.Build(summary)
return s.sender.Send(msg)
}

这个版本里有几件事值得注意:

  • sender 接口由 Service 所在包定义
  • 接口只有一个方法,刚好覆盖真实依赖
  • Formatter 直接用具体类型,没有无意义抽象
  • NewService 在入口处拦住空依赖,避免后面 typed nil 混进来

这就是比较典型的 Go 风格:
边界上用小接口,内部逻辑优先具体类型。

十四、再看一个写偏的版本,问题会更明显

下面这个版本看起来“更面向对象”,但实际更重:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Formatter interface {
Build(Summary) Message
}

type Sender interface {
Send(Message) error
Name() string
Close() error
}

type Service interface {
Notify(Summary) error
}

type ReportService struct {
formatter Formatter
sender Sender
}

问题主要有三个:

  1. Service 自己也被抽成了接口,但目前并没有第二个实现
  2. Sender 被塞进了 NameClose,而通知流程可能根本不需要
  3. Formatter 也被提前接口化,但项目里只有一种实现

这种代码的典型后果是:

  • 看起来每层都很抽象
  • 实际每层都只有一个实现
  • 测试替身越来越多
  • 阅读成本高于真实收益

这也是为什么 Go 社区里一直强调:不要为了“设计感”制造接口。

十五、什么时候 interface 真正有价值

讲到这里,可以开始给出比较具体的工程判断了。

下面这些场景,interface 通常是有明显价值的:

1. 你在隔离外部依赖

例如:

  • HTTP client
  • 消息队列 producer
  • 第三方 SDK
  • 文件系统
  • 时钟、随机数、ID 生成器

这些东西通常有两个特点:

  • 副作用明显
  • 测试里不适合直接跑真依赖

这时用一个小接口把边界切开,收益很高。

2. 调用方只需要一小部分能力

例如通知服务只需要 Send,那就不要依赖一个带 CloseHealthCheck 的大接口。

3. 你确实会有多种实现,而且这种多实现是现在就存在的

例如:

  • 飞书发送器
  • 邮件发送器
  • 本地日志发送器

注意是“现在就存在”或者“这个边界天然稳定”,不是“以后也许会有”。

4. 你要给测试注入替身

这也是 Go 里接口最常见、最务实的价值之一。

十六、什么时候更应该坚持用具体类型

反过来,下面这些情况通常不要急着写接口:

1. 只是普通内部逻辑

例如:

  • 统计函数
  • 格式化函数
  • 校验函数
  • 纯内存数据转换

这些逻辑没有外部依赖,也没有明确替换边界,直接写具体类型通常更清楚。

2. 当前只有一个实现,而且你看不到稳定的变化方向

不要把“以后可能扩展”当成默认前提。
大多数“以后可能”最后都不会发生。

3. 这个接口只是把具体类型原样包了一层

如果一个接口的方法几乎和具体类型一模一样,而且整个项目只有这一种实现,那通常只是在增加跳转层。

4. 你开始为了接口而接口

出现这些信号时就要警惕:

  • 每个 struct 都有一个同名 interface
  • 每个 interface 只有一个实现
  • mock 数量开始超过真实类型数量
  • 看代码时要不停来回跳定义

这类项目往往不是“抽象做得好”,而是“抽象成本已经超过价值”。

十七、给这个小案例补最小测试,看看接口真正帮了什么

接口最容易体现价值的地方,不是 PPT 式架构图,而是测试。

例如给 Service 写一个最小替身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package report

import "testing"

type stubSender struct {
messages []Message
err error
}

func (s *stubSender) Send(msg Message) error {
if s.err != nil {
return s.err
}
s.messages = append(s.messages, msg)
return nil
}

func TestServiceNotify(t *testing.T) {
sender := &stubSender{}
service, err := NewService(sender)
if err != nil {
t.Fatalf("create service failed: %v", err)
}

summary := Summary{
PlanID: "nightly-regression",
Total: 120,
Passed: 118,
Failed: 2,
}

err = service.Notify(summary)
if err != nil {
t.Fatalf("notify failed: %v", err)
}

if len(sender.messages) != 1 {
t.Fatalf("want 1 message, got %d", len(sender.messages))
}

if sender.messages[0].Title == "" {
t.Fatalf("message title should not be empty")
}
}

这个测试里,接口带来的收益非常直接:

  • 不需要真的发飞书或邮件
  • 不需要网络
  • 不需要第三方配置
  • 只验证业务流程和消息内容

这就是“抽象边界服务于测试”的典型好例子。

十八、怎么验证这篇里的代码理解是不是到位

可以按这个顺序自己做一遍验证:

  1. 先写一个 FeishuBot,让它实现 Send(Message) error
  2. 再写一个 EmailSender,复用同一个 Service
  3. Send 从值接收者改成指针接收者,观察赋值给接口时的编译错误
  4. 故意把 var bot *FeishuBot = nil 塞进接口,观察 n == nil 的结果
  5. Service 写一个 stubSender,跑一组最小测试

如果这五步都能自己做通,interface 的大部分核心理解就已经建立起来了。

理论上可以执行:

1
go test ./...

但这台环境当前没有 go 命令,所以这里我只能给出验证路径,没法实际跑文中的测试代码。

十九、排障时最值得先查的四类问题

项目里如果一碰接口就报错,通常先看这四类:

1. 方法签名不匹配

例如参数、返回值、接收者方法名只要有一个不一致,都不算实现。

2. 指针接收者和值接收者不匹配

看到这类报错时,先别急着改接口,先确认你到底传进去的是值还是指针。

3. typed nil

如果你明明判断了 iface != nil,结果调用时还是 panic,就要高度怀疑是不是把空指针装进了接口。

4. 接口过大,导致实现和测试都很重

如果一个 mock 需要补五六个空方法,往往说明接口已经太胖了。

二十、这篇文章里最重要的工程判断

如果只保留几条最关键的判断,可以先记这几条:

  1. interface 先服务边界,不先服务“设计感”
  2. 接口尽量小,只暴露调用方真的需要的行为
  3. 通常优先让调用方定义接口
  4. 内部纯逻辑优先用具体类型,别为了整齐把所有东西接口化
  5. 先写出具体代码,等边界稳定、替换需求明确后再抽象,往往比一开始就设计一层更稳

这几条看起来不花哨,但在真实项目里非常耐用。

二十一、边界:这篇先不展开哪些内容

这一篇先把 interface 最核心、最容易踩坑的部分讲清楚,但还没展开这些内容:

  • 空接口 any 和 JSON/反序列化场景
  • 类型断言和 type switch 的系统用法
  • error 为什么本身就是一个接口
  • io.Readerio.Writer 这种标准库接口为什么设计得这么小
  • 泛型出现之后,哪些原本依赖 interface{} 的写法会变化

这些内容后面会分别讲,不适合这一篇一次塞完。

二十二、一个实际练习

可以自己做下面这个练习,把这篇的理解再固化一遍:

题目:做一个最小通知中心,支持飞书和邮件两种发送器。

要求:

  • 定义 SummaryMessageService
  • Service 所在包定义一个只包含 Send(Message) error 的小接口
  • Formatter 直接使用具体类型,不要先抽象接口
  • NewService 里拦住空依赖
  • 写两个实现:FeishuBotEmailSender
  • 写一个 stubSender 测试 Notify
  • 再故意制造一个 typed nil 场景,解释为什么 iface != nil

如果这套练习能独立写下来,你对 interface 的理解就已经不只是“知道语法”了。

二十三、结语

Go 里的 interface 确实不难学会写,但很容易学会滥用。

真正稳定的理解不是“看到抽象就上接口”,而是先问三个问题:

  • 这里有没有真实边界
  • 调用方到底只需要什么能力
  • 去掉这层接口,代码会不会更直接

如果答案是:

  • 有真实边界
  • 依赖能力很明确
  • 小接口能明显降低耦合和测试成本

那就用。

如果答案是:

  • 只是内部逻辑
  • 只有一个实现
  • 接口只是把具体类型再包一层

那就先别用。

interface 的价值,从来不在“项目里接口多不多”,而在每一个接口是不是都真的代表了一条清楚的行为边界。