Golang 少用 Interface 的哲学:奥卡姆剃刀下的工程实践,删掉不必要的Interface

Golang 少用 Interface 的哲学:奥卡姆剃刀下的工程实践,删掉不必要的Interface

其实个人很反对Golang所谓的“面向接口编程”的这种话术推广理念,这对新手来说是一种严重的误导性,编程语言里有很多类似的Slogan也有各种类似的营销话术上的问题,例如Java的那句“一切皆对象”,这话虽然对但强化传达理念的同时又让新手开发者陷入一种潜意识迷茫,往往容易削足适履。

“如无必要,勿增实体。” —— 奥卡姆剃刀定律

Interface 应该定义在消费方,而不是生产方。

Go Interface本身并不是用来“组织代码层级”的,这是interface被“滥用”的根源。

Go 的 interface 被滥用的根源,不是开发者不会写 interface,而是把 interface 当成了组织代码层级、分层结构、模块边界的工具。

实际上,反直觉的是:代码层级本身并不需要 interface 来证明。

一个项目可以分层,可以按模块组织,可以按职责划分,但这些都不必然要求每一层都必须先有一个 interface。
层级是组织方式,interface 是能力抽象。

如果把两者混在一起,就会产生错觉:

  • 【错觉1】:没有 interface,就不算分层。
  • 【错觉2】:没有 interface,就不够解耦。
  • 【错觉3】:没有 interface,就不够工程化。

这个错觉就是滥用的起点,理解这种【错觉】产生的原因非常关键!。

设计者 Rob Pike 的原话非常明确:
Don’t design with interfaces, discover them.(不要设计接口,而是发现接口。)
The bigger the interface, the weaker the abstraction.(接口越大,抽象越弱。)

实际上,Go 标准库中所有接口的平均方法数只有 2 个io.Reader 一个方法,io.Writer 一个方法,error 一个方法。这就是设计者心目中接口的样子。实际上Go 的 interface 在底层并不是一个零成本抽象!

《Effective Go》也明确说了:

Go 官方文档《Effective Go》里写得很清楚:

Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.
关键词是 behavior——行为,不是层级,不是模块,不是架构分层。

官方还强调:

如果你定义了一个叫 Thing 的接口,问问自己为什么这个 Thing 不是一个结构体。
这几乎就是在直接警告:不要用名词命名接口,不要用接口来代表”某一层”或”某个模块”。

Go Code Review Comments 也说了

Go 官方的代码审查指南里有一条明确规则:
Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.
也就是说,接口应该定义在消费方,而不是生产方

在 Go 社区里,有一种几乎被默认接受的写法:先定义 interface,再写实现。很多团队甚至把“面向接口编程”当作工程规范,仿佛一个 Go 项目如果没有足够多的 interface,就不够规范、不够解耦、不够“企业级”,这大错特错是非常严重的误读!

但如果仔细观察 Go 标准库本身,会发现一个有趣的事实:Go 标准库中大量代码并不依赖 interface,而是直接使用具体类型、结构体和函数。net/http 的请求处理、encoding/json 的编解码器、os 包中的文件操作,很多核心逻辑都建立在具体实现之上,而不是层层抽象之上。

这不是偶然。Go 的设计哲学本身就倾向于简单、具体、可预测。它鼓励开发者从真实需求出发,而不是从抽象设计出发。

注意不是“interface 是否应该被消灭”,而是另一个更务实的问题:

在 Go 项目中,为什么我们应该尽量少用 interface?什么时候才真正需要它?

其实道理也并不复杂:如果更简单的实现已经足够满足当前需求,就不要提前引入更复杂的抽象。还有一点,现实当中,以及微服务的发展,很多业务接口单元已经拆分得颗粒度足够细,基本上不需要额外的过度封装,也就不需要为了接口而接口

结构体是具体、静态、可内联、可预测的。
interface 是间接、动态、灵活、有运行时成本的。

所以更准确的工程原则是:
结构体能搞定的,(尽量)不要用 interface。
Interface 更适合在真正需要多态、替换、能力抽象或测试隔离时才出现。


一、再来看为什么这个话题值得写?

Go 里真正该被强调的其实不是“面向接口编程”,而是鸭子类型 + 消费端按需声明小接口。这两件事和“先定义接口、再写实现、再让服务依赖接口”是两回事。

但很多团队、教程、代码审查习惯,会把它简化成一句口号:

Go 要面向接口编程。

这句话的问题在于,它把一种局部工具包装成了全局规范

新手听到之后,很容易形成一种潜意识

  • 写 struct 不够“高级”;
  • 直接依赖具体类型不够“解耦”;
  • 每个 service 都应该配一个同名 interface;
  • 不写 interface 就是耦合;
  • 写了 interface 就是工程化。

1. 仔细体会这句话:Go 的接口本来不是用来“组织代码层级”的!(这是绝大部分新手和老手最容易产生的潜意识误区)

Go 接口的真正价值,通常体现在这类场景:

1
func Copy(dst io.Writer, src io.Reader) (int64, error)

io.Readerio.Writer 并不关心你是文件、网络、内存、压缩流还是加密流。它们只关心一个能力:

  • 能不能读;
  • 能不能写。

这是一种能力抽象,不是模块分层抽象

但很多项目里看到的是:

1
2
3
4
5
6
type UserService interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error)
}

type UserServiceImpl struct{}

这种接口通常没有真正抽象“能力”,它只是把 UserServiceImpl 的名字换了一层皮。

它带来的不是多态,而是:

  • 多一个文件;
  • 多一次跳转;
  • 多一层命名;
  • 多一处同步修改;
  • 多一种“实现到底是谁”的不确定性。

所以问题不是 interface 本身造成的层级冗余,而是它被用成了组织目录结构的工具


2. “面向接口编程”最容易让新手削足适履

新手最容易被误导的地方,是把“可替换性”当成默认目标。

于是代码会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
type OrderRepository interface {
FindByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, order *Order) error
}

type OrderService interface {
PlaceOrder(ctx context.Context, req *PlaceOrderRequest) error
}

type OrderHandler interface {
HandlePlaceOrder(w http.ResponseWriter, r *http.Request)
}

每个层都先有接口,再有实现。

但现实里:

  • OrderRepository 只有一个 Postgres 实现;
  • OrderService 只有一个默认实现;
  • OrderHandler 只有一个 HTTP handler;
  • 未来一年也不会换。

这时候接口并没有降低复杂度,它只是把原本可以直接看懂的代码,变成了需要跳转才能看懂的代码。

这就是典型的削足适履:

为了让代码符合“面向接口编程”的口号,强行制造抽象。

但抽象不是免费的。

每一层抽象都会带来:

成本表现
阅读成本不知道实际实现是谁
维护成本改方法签名要改多处
测试成本mock 越来越多,测试越来越绕
性能成本动态分发、类型判断、内联受限
认知成本新人以为项目复杂是因为业务复杂,其实是被抽象复杂化了

3. Java 的“一切皆对象”也有类似问题

你说的 Java “一切皆对象”很典型。

这句话在语言设计层面有它的表达意图:强调统一抽象、对象模型、多态、封装。

但到了工程实践里,它经常变成一种潜意识压力:

  • 数据要包成对象;
  • 行为要放进对象;
  • 函数要放进类;
  • 常量要放进类;
  • 工具方法要放进 Utility 类;
  • 一个简单操作也要搞出 XxxFactoryXxxStrategyXxxManagerXxxHandler

最后代码看起来很“面向对象”,但未必更清楚。

比如一个本来可以写成函数的东西:

1
PriceCalculator.calculate(order)

可能会被包装成:

1
2
3
4
priceCalculatorFactory
.create(orderContext)
.getStrategy()
.calculate(order)

它满足了“一切皆对象”的理念,但代价是:

  • 调用链变长;
  • 命名膨胀;
  • 真实逻辑被埋进框架结构里;
  • 新人必须理解一整套设计模式才能看懂一个价格计算。

这就是口号的危险之处。

口号会让人以为:

只要符合这个理念,代码就是好的。

但工程判断不是这样的。

真正该问的是:

  • 这个抽象有没有减少重复?
  • 这个抽象有没有隔离真实变化?
  • 这个抽象有没有让调用方更简单?
  • 这个抽象有没有让测试更稳定?
  • 这个抽象有没有让未来修改更容易?

如果答案都是没有,那它就不是工程抽象,而是仪式化抽象。


4. Go 里最该反对的不是 interface,而是“单实现接口崇拜”

更准确地说,应该反对的是这种写法:

1
2
3
4
5
6
7
8
9
type FooService interface {
DoSomething(ctx context.Context) error
}

type fooServiceImpl struct{}

func (s *fooServiceImpl) DoSomething(ctx context.Context) error {
return nil
}

这种代码的问题不是用了 interface,而是:

  • 接口和实现几乎一一对应;
  • 接口名只是实现名的泛化;
  • 接口没有表达通用能力;
  • 接口没有让消费方更灵活;
  • 接口只是把 fooServiceImpl 藏起来。

它看起来很“规范”,但其实只是把简单问题复杂化。

Go 更自然的写法通常是:

1
2
3
4
5
type FooService struct{}

func (s *FooService) DoSomething(ctx context.Context) error {
return nil
}

如果以后真的出现第二种实现,再抽接口也不迟。

这不是“不重视抽象”,而是:

抽象应该从真实变化中长出来,而不是提前规定出来。


5. 更健康的说法应该是:按需抽象,而不是面向接口编程

我会把 Go 里的接口原则改成三句话:

接口应该描述能力,而不是模块

不好的:

1
type UserService interface { ... }

更好的:

1
2
3
type UserReader interface {
GetUser(ctx context.Context, id string) (*User, error)
}

UserService 往往是一个业务模块名,不是一个能力。

UserReader 则表达了一个明确能力:能读用户。


接口应该在消费端按需声明

不是实现方提前定义:

1
2
3
4
5
6
7
8
type Storage interface {
Get(...)
Set(...)
Delete(...)
List(...)
Scan(...)
// ...
}

而是消费方只声明自己需要的:

1
2
3
type ConfigReader interface {
Get(key string) ([]byte, error)
}

这样接口才不会变成“大而全的类型标签”。


没有多个实现、没有测试隔离需求时,不要提前抽象

判断标准很简单:

  • 现在只有一个实现;
  • 未来也没有明确的多实现需求;
  • 它不是外部系统边界;
  • 它不是 IO、存储、通知、支付、缓存这类可替换边界;
  • 它只是为了“看起来解耦”。

那就不要用 interface。

直接写具体类型。


6. 这类口号最大的问题是把“风格”伪装成“正确性”

“面向接口编程”“一切皆对象”“依赖倒置”“高内聚低耦合”,这些话本身不是错的。

问题在于,它们太容易被当成绝对真理。

一旦变成绝对真理,开发者就会停止判断:

  • 这里真的需要多态吗?
  • 这里真的会变化吗?
  • 这里真的需要替换吗?
  • 这里真的需要隔离吗?
  • 这里是不是只是我在模仿别人项目里的目录结构?

于是代码开始服务于口号,而不是服务于业务。

这就是你说的“潜意识迷茫”。

新手不是不会写 interface,而是不知道什么时候不写 interface。

而工程能力很多时候恰恰体现在:

知道什么时候不需要抽象。


7. 可以这样修正 Go 社区里的常见说法

原来的说法:

Go 要面向接口编程。

更准确的说法应该是:

Go 鼓励使用小接口,在消费端按需声明能力,但不要给每个具体类型都配一个同名接口。

原来的说法:

依赖接口,不要依赖实现。

更准确的说法应该是:

依赖稳定能力,不要依赖不必要的实现细节。

原来的说法:

先定义接口,再写实现。

更准确的说法应该是:

当真实存在多种实现、测试隔离需求或外部边界时,再提取接口。

原来的说法:

接口让代码更解耦。

更准确的说法应该是:

接口只有在隔离真实变化时才解耦,否则只是增加间接层。


8. 对新手来说,真正该教的不是“多写接口”

新手教程如果一直强调“面向接口编程”,很容易培养出一批只会写:

1
2
type XxxService interface { ... }
type XxxServiceImpl struct { ... }

的开发者。

但更该教的是:

  • 什么时候具体类型就够了;
  • 什么时候接口是多余的;
  • 什么时候接口真正隔离了变化;
  • 为什么小接口通常比大接口好;
  • 为什么消费端定义接口通常比实现端定义接口好;
  • 为什么单实现接口经常是一种代码异味;
  • 为什么过度抽象会让项目更难维护。

否则新手学到的不是 Go 的接口设计哲学,而是把接口当成一种“工程正确感”的装饰品。


所以我很同意你的判断。

Go 的接口不是不好,而是被口号化之后变味了。

它本来是一种很轻的能力抽象工具,结果在很多项目里被用成了分层模板、命名仪式和伪解耦工具。

真正该强调的不是:

要面向接口编程。

而是:

如无必要,勿增接口。

9.很多 Go 项目在发展过程中,都会逐渐出现一种现象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type UserRepository interface {
FindByID(ctx context.Context, id uint64) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id uint64) error
}

type UserService interface {
GetUser(ctx context.Context, id uint64) (*User, error)
CreateUser(ctx context.Context, req *CreateUserRequest) error
}

type UserHandler interface {
HandleGetUser(w http.ResponseWriter, r *http.Request)
}

表面上看,这种代码很规范:分层清晰、职责明确、方便替换实现。

但真正进入项目维护阶段后,开发者往往会发现几个问题。

第一,很多 interface 只有一个实现。UserRepository 只有一个 PostgresUserRepositoryUserService 只有一个 DefaultUserService。接口并没有带来多态收益,只是多了一层间接。

第二,阅读代码时经常需要跳转。看到 UserService 时,你不知道它背后是哪个实现;想理解业务逻辑,必须继续找实现类。

第三,修改一个方法签名,可能要改多个文件。接口定义要改,实现要改,mock 也要改。原本只是改一个函数,最后变成了改三个地方。

第四,测试代码可能比生产代码还复杂。为了 mock 接口,项目里出现了大量 MockUserRepositoryMockUserService,测试关注点从“业务行为是否正确”变成了“mock 是否配置正确”。

这些问题并不是 interface 本身的错,而是抽象过早、抽象过度带来的成本。

所以,讨论“少用 interface”,本质上是在讨论如何控制项目复杂度。


二、Go 的 interface 和传统面向对象语言并不完全一样

很多开发者从 Java、C# 等语言进入 Go 后,会自然地把“面向接口编程”带过来。这在原来的语言生态里并不奇怪,但在 Go 里需要重新理解。

Go 的 interface 是隐式实现的。一个类型只要实现了接口要求的方法,就自动满足这个接口,不需要显式声明:

1
2
3
4
5
6
7
8
9
10
type Writer interface {
Write(p []byte) (n int, err error)
}

type File struct{}

func (f *File) Write(p []byte) (int, error) {
// 写入文件
return len(p), nil
}

这里 File 并没有写:

1
2
// Go 不需要这样声明
// type File implements Writer

这种设计带来一个重要差异:Go 更适合在使用方定义自己需要的接口,而不是让实现方提前定义庞大接口。

也就是说,Go 的 interface 更适合做行为契约,而不是类型标签。

所以,如果一个项目里每个 struct 都有一个同名接口,例如:

1
2
type OrderService interface { ... }
type orderServiceImpl struct { ... }

这通常不是 Go 风格,而更像是把其他语言的接口模式直接搬了过来。

Go 真正推崇的是小接口、消费端接口、按需抽象。它并不鼓励每个模块都先画出一套完整的接口体系,再往里面填充实现。


三、Interface 的真实成本

在讨论“少用”之前,先看看 interface 到底带来了什么成本。


3.1 认知成本

1
2
3
4
5
6
7
8
type UserService interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, user *User) error
}

func HandleRequest(svc UserService) {
// svc 到底是什么?运行时才知道
}

看到这段代码时,读者并不能直接知道 svc 的真实行为。它可能是某个数据库实现,也可能是某个缓存实现,还可能是测试里的 mock。

每多一层 interface,读者就需要多一次跳转才能理解实际逻辑。

在小型项目里,这种成本不明显。但在一个中型或大型项目中,当几十个 interface 互相依赖时,认知开销会累积。新人接手项目时,最先感受到的往往不是“架构很清晰”,而是“不知道代码到底从哪里开始执行”。

相比之下,具体类型通常更直接:

1
2
3
4
5
6
7
type UserService struct {
repo *UserRepo
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.FindByID(ctx, id)
}

这里没有隐藏实现。UserService 依赖 UserRepo,逻辑一目了然。


3.2 间接调用的性能成本

Interface 调用在 Go 中并不是完全免费的。它通常通过 interface table 进行动态分发。虽然 Go 编译器会对一些简单场景做优化,但在热路径上,这仍然是一个不可忽视的开销。

1
2
3
4
5
// 直接调用:编译器更容易优化
func process(u *User) { ... }

// interface 调用:需要动态分发
func process(u UserHandler) { ... }

对于大多数业务系统来说,这种性能差异可能不会成为瓶颈。但在日志处理、序列化、高并发数据处理、协议解析等热路径上,interface 调用带来的间接寻址和无法内联问题,可能会影响性能。

所以,如果某个函数被频繁调用,并且实现是确定的,使用具体类型通常更合适。


3.3 维护成本

修改一个具体类型的方法签名,通常只需要关注调用方。

修改一个 interface 的方法签名,则可能需要同时关注:

  • 接口定义;
  • 所有实现;
  • 所有调用方;
  • 测试中的 mock;
  • 可能存在的生成代码。

例如:

1
2
3
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}

如果后来需要增加超时控制:

1
2
3
type EmailSender interface {
Send(ctx context.Context, to, subject, body string, opts ...SendOption) error
}

那么所有实现和 mock 都要同步修改。

如果一开始只是具体类型:

1
2
3
4
5
6
7
8
type EmailSender struct {
client *smtp.Client
}

func (s *EmailSender) Send(ctx context.Context, to, subject, body string) error {
// 发送邮件
return nil
}

修改成本通常更低,影响范围也更清晰。


3.4 测试的“伪便利”

很多人使用 interface 的理由是方便 mock 测试。这个理由并不错,但需要谨慎。

mock 确实可以让测试更快、更隔离,但它也可能带来新的问题。

例如,测试可能只验证了方法是否被调用,却没有验证真实状态是否发生变化:

1
mockRepo.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil)

这行测试只能说明 Save 被调用了,并不能说明数据真的被正确保存了。

更麻烦的是,当生产代码重构后,mock 测试可能仍然通过,因为它只关心调用关系,不关心真实行为。

所以,interface 在测试中的价值不是“让所有东西都能被 mock”,而是:

当某个外部依赖难以替换、难以控制、难以稳定复现时,用 interface 把它隔离出来。


四、什么时候确实需要 Interface

奥卡姆剃刀不是“永远不用 interface”,而是“不必要时不用”。以下场景,interface 通常是合理的。


4.1 标准库风格的通用抽象

Go 标准库中有很多优秀的小接口:

1
2
3
4
5
io.Reader
io.Writer
io.Closer
fmt.Stringer
http.Handler

它们的共同特点是:方法少、语义清晰、被大量实现和使用。

例如:

1
func Copy(dst io.Writer, src io.Reader) (written int64, err error)

io.Copy 不关心数据来自文件、网络、内存缓冲区还是压缩流。它只关心两件事:

  • 能不能读字节;
  • 能不能写字节。

这种抽象的价值很高,因为它让大量 IO 操作可以复用同一套逻辑。

如果你的项目中也有类似需求,例如多种数据源、多种输出目标、多种编解码方式,那么定义类似的小接口是合理的。


4.2 消费端定义的小接口

Go 的 interface 最强大的特性之一是隐式实现。最佳实践是在消费端定义小而精的 interface。

不好的做法是在定义端定义一个大接口:

1
2
3
4
5
6
7
type Storage interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
Delete(key string) error
List(prefix string) ([]string, error)
// ... 20 个方法
}

这种做法的问题在于,消费方往往并不需要全部方法。它可能只需要读取能力,却被强制依赖整个存储抽象。

更好的做法是在使用方定义自己真正需要的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type KeyReader interface {
Get(key string) ([]byte, error)
}

func LoadConfig(r KeyReader) (*Config, error) {
data, err := r.Get("app:config")
if err != nil {
return nil, err
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}

return &cfg, nil
}

这里 KeyReader 不是由存储层定义的,而是由 LoadConfig 定义的。任何类型,只要实现了 Get 方法,就可以作为配置来源。

这种写法有几个好处:

  • 接口更小;
  • 实现方不需要知道接口存在;
  • 测试时更容易替换;
  • 消费方只依赖自己真正需要的能力。

这也是 Go 社区常说的“接受接口,返回结构体”的一部分实践。


4.3 确实存在多个实现的外部边界

如果一个模块确实需要支持多种实现,interface 就有存在价值。

例如支付系统:

1
2
3
4
type PaymentProvider interface {
Charge(ctx context.Context, req *ChargeRequest) (*ChargeResult, error)
Refund(ctx context.Context, req *RefundRequest) (*RefundResult, error)
}

因为现实中可能确实需要对接多个支付渠道:

1
2
3
type AlipayProvider struct { ... }
type WechatPayProvider struct { ... }
type StripeProvider struct { ... }

这里 interface 不是为了“看起来规范”,而是为了真正隔离不同供应商的差异。

类似场景还包括:

  • 多种消息通知渠道:邮件、短信、钉钉、企业微信;
  • 多种对象存储:S3、OSS、MinIO;
  • 多种日志输出目标:标准输出、文件、远程日志服务;
  • 多种缓存实现:本地缓存、Redis、Memcached。

但判断标准仍然是:现在是否真的需要多态?未来是否很可能出现多个实现?

如果只是猜测,通常不值得提前抽象。


4.4 需要隔离不可控外部依赖

有些外部依赖不适合直接耦合到业务逻辑里,例如:

  • 时间;
  • 随机数;
  • 第三方 API;
  • 消息队列;
  • 邮件服务;
  • 短信服务;
  • 计费接口。

这些边界适合用 interface 隔离,因为它们通常难以直接控制,也容易影响测试。

例如,如果业务逻辑依赖当前时间:

1
2
3
type Clock interface {
Now() time.Time
}

生产环境可以使用真实时间:

1
2
3
4
5
type systemClock struct{}

func (systemClock) Now() time.Time {
return time.Now()
}

测试环境可以使用固定时间:

1
2
3
4
5
6
7
type fixedClock struct {
t time.Time
}

func (c fixedClock) Now() time.Time {
return c.t
}

这种情况下,Clock 接口不是为了未来扩展,而是为了把不可控因素从业务逻辑中剥离出来。


五、什么时候不该用 Interface

理解了 interface 的价值之后,更重要的是知道什么时候不该用它。


5.1 只有一个实现时,通常不需要提前抽象

如果一个接口只有一个实现,并且短期内没有第二个实现的可能,那么它很可能只是增加了一层间接。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}

type smtpEmailSender struct {
host string
port int
}

func (s *smtpEmailSender) Send(ctx context.Context, to, subject, body string) error {
// 发送 SMTP 邮件
return nil
}

如果项目里只有 SMTP 一种发送方式,也没有计划支持企业微信、钉钉、短信通知等替代实现,那么直接写具体类型通常就足够了:

1
2
3
4
5
6
7
8
9
type EmailSender struct {
host string
port int
}

func (s *EmailSender) Send(ctx context.Context, to, subject, body string) error {
// 发送 SMTP 邮件
return nil
}

有人会说:“万一以后要支持别的实现呢?”

这就是典型的预防性抽象。问题在于,未来如果真的需要支持多种实现,那时再提取 interface 并不困难;而现在为了一个不确定的未来增加抽象,会让当前代码变得更复杂。

YAGNI 原则在这里同样适用:

You Aren’t Gonna Need It.
你不会真的需要它。


5.2 为了避免“直接依赖”而抽象,往往不值得

有些团队不喜欢具体类型依赖,认为:

1
2
3
type OrderService struct {
repo *OrderRepo
}

不够解耦。

但实际上,这种依赖并没有想象中那么危险。

只要 OrderServiceOrderRepo 属于同一个业务模块,它们本来就是一起变化的。强行抽象成:

1
2
3
type OrderService struct {
repo OrderRepository
}

并不会让系统更灵活,只会让调用链更绕。

真正需要解耦的地方,通常是模块边界、外部系统边界、可替换策略边界,而不是同一个业务模块内部的普通依赖。


5.3 为了 mock 而 mock,会让测试变得脆弱

mock 本身不是问题,问题是为了 mock 而设计代码。

例如,为了让测试方便,把一个服务拆成十几个接口:

1
2
3
4
5
type UserRepo interface { ... }
type UserCache interface { ... }
type UserValidator interface { ... }
type UserLogger interface { ... }
type UserEventPublisher interface { ... }

测试里看起来很容易构造:

1
2
3
4
5
6
7
svc := NewUserService(
mockRepo,
mockCache,
mockValidator,
mockLogger,
mockPublisher,
)

但这种测试可能只验证了 mock 之间的调用关系,而不是真实行为。

更危险的是,当生产代码重构时,mock 测试可能仍然通过,因为它只关心方法是否被调用、参数是否匹配,而不关心最终状态是否正确。

所以,测试设计应该优先考虑:

  1. 能否使用真实依赖?
  2. 能否使用轻量级测试替身?
  3. 能否只隔离真正不可控的外部依赖?
  4. 是否真的需要 mock?

interface 应该服务于测试稳定性,而不是让测试看起来更容易写。


5.4 方法过多的“胖接口”通常说明抽象失败

Go 社区有一个常见经验:接口越小越好。

很多标准库接口都只有一个方法:

1
2
3
4
io.Reader
io.Writer
io.Closer
fmt.Stringer

小接口的优势在于:

  • 更容易实现;
  • 更容易组合;
  • 更容易被消费方接受;
  • 更容易在测试中替换。

如果一个接口有十几个方法,通常说明它承担了太多职责:

1
2
3
4
5
6
7
8
9
type UserManager interface {
FindByID(ctx context.Context, id uint64) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id uint64) error
Validate(user *User) error
SendWelcomeEmail(ctx context.Context, user *User) error
PublishCreatedEvent(ctx context.Context, user *User) error
}

这种接口往往不是抽象,而是把多个职责打包在一起。

更好的做法通常是拆分:

1
2
3
4
5
6
7
type UserReader interface {
FindByID(ctx context.Context, id uint64) (*User, error)
}

type UserWriter interface {
Save(ctx context.Context, user *User) error
}

或者直接使用具体类型,等真正出现多态需求时再抽象。


六、一个判断标准:不要问“能不能替换”,要问“是否需要替换”

很多开发者在决定是否使用 interface 时,会问:

“这个实现以后能不能替换?”

这个问题太宽泛了。几乎任何东西理论上都能替换:数据库可以换,缓存可以换,日志库可以换,甚至 HTTP handler 也能换。

但“能替换”不等于“需要替换”。

更合适的判断方式是问四个问题:

  1. 现在是否有多个实现?
    如果没有,interface 的价值需要谨慎评估。

  2. 未来是否很可能出现多个实现?
    如果只是猜测,不应提前抽象。

  3. 调用方是否真的不关心具体实现?
    如果调用方始终依赖某个具体行为,抽象意义有限。

  4. 这个抽象是否降低了复杂度?
    如果引入 interface 后代码更难理解,那它就不是好的抽象。

可以用一个简单表格辅助判断:

场景建议
标准库风格的通用行为,如 io.Reader适合使用 interface
支付、通知、存储等确实有多种实现适合使用 interface
只有一个实现,且未来不确定是否扩展先用具体类型
为了 mock 测试而抽象数据库访问谨慎使用,优先考虑真实依赖
预防性抽象,“以后可能换实现”通常不需要
单方法回调或策略可以考虑函数类型

七、实战案例:一个用户注册服务如何从“接口过多”变简洁

假设有一个用户注册服务,最初设计如下。


7.1 重构前:接口层层嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type UserRepository interface {
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, user *User) error
}

type UserValidator interface {
ValidateEmail(email string) error
ValidatePassword(password string) error
}

type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}

type UserEventPublisher interface {
PublishUserCreated(ctx context.Context, userID uint64) error
}

type UserService interface {
Register(ctx context.Context, req *RegisterRequest) error
}

实现层:

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
type userService struct {
repo UserRepository
validator UserValidator
sender EmailSender
publisher UserEventPublisher
}

func (s *userService) Register(ctx context.Context, req *RegisterRequest) error {
if err := s.validator.ValidateEmail(req.Email); err != nil {
return err
}

if err := s.validator.ValidatePassword(req.Password); err != nil {
return err
}

_, err := s.repo.FindByEmail(ctx, req.Email)
if err == nil {
return errors.New("email already exists")
}

user := &User{
Email: req.Email,
}

if err := s.repo.Save(ctx, user); err != nil {
return err
}

if err := s.sender.Send(ctx, user.Email, "Welcome", "Welcome to our platform"); err != nil {
return err
}

return s.publisher.PublishUserCreated(ctx, user.ID)
}

这个代码看起来“很工程化”,但问题也很明显:

  • UserService 只有一个实现;
  • UserRepository 只有一个数据库实现;
  • UserValidator 只是普通校验逻辑,没有必要抽象;
  • EmailSender 当前只有 SMTP;
  • UserEventPublisher 当前只有 Kafka;
  • 测试时需要 mock 四个依赖。

这种结构不是错,但对于一个中小规模模块来说,抽象层级偏高。


7.2 重构后:保留必要边界,减少多余接口

重构后的代码可以变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
type UserRepo struct {
db *sql.DB
}

func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*User, error) {
// 查询数据库
return nil, nil
}

func (r *UserRepo) Save(ctx context.Context, user *User) error {
// 保存用户
return nil
}

校验逻辑不需要接口,因为它通常属于业务规则,而不是可替换策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
func validateEmail(email string) error {
if email == "" {
return errors.New("email is required")
}
return nil
}

func validatePassword(password string) error {
if len(password) < 8 {
return errors.New("password too short")
}
return nil
}

邮件发送如果当前只有 SMTP,也可以先用具体类型:

```go
type EmailSender struct {
client *smtp.Client
}

func (s *EmailSender) Send(ctx context.Context, to, subject, body string) error {
// 发送邮件

最后再强调一点,抛开编程语言本身,从纯编程语言设计原则上来说:不能为了接口而接口,否则会过度设计,没有意义,大道至简才是最经济的选择。

虽然现在是AI编程时代Vibe Coding,但作为开发者也要清醒认识到:即使不写代码,也要必须读懂和理解代码!

Golang 少用 Interface 的哲学:奥卡姆剃刀下的工程实践,删掉不必要的Interface

https://www.wdft.com/d1dfbed0.html

Author

Jaco Liu

Posted on

2026-09-06

Updated on

2026-09-07

Licensed under