Go 100 常见错误 6: 生产者端的接口

29 阅读4分钟

在前一节中,我们讨论了何时接口被认为有价值。但 Go 开发人员经常误解一个问题:接口应该放置在哪里?

在深入讨论这个话题之前,让我们先明确本节中使用的一些术语:

  • 生产者端 - 在与具体实现相同的包中定义的接口(Figure 2.4)。

WX20240515-152102@2x.png

消费者端 - 在接口被使用的外部包中定义的接口(Figure 2.5)。

WX20240515-152122@2x.png

常见的做法是开发者在生产者端,与具体实现一起创建接口。这种设计可能是来自具有 C# 或 Java 背景的开发者的习惯。但在 Go 中,大多数情况下我们不应该这样做。

让我们讨论以下示例。 在这里,我们创建了一个特定的包来存储和检索客户数据。同时,在同一包中,我们决定所有调用都必须通过以下接口进行:

package store
type CustomerStorage interface {
    StoreCustomer(customer Customer) error
    GetCustomer(id string) (Customer, error) 
    UpdateCustomer(customer Customer) error 
    GetAllCustomers() ([]Customer, error) 
    GetCustomersWithoutContract() ([]Customer, error) 
    GetCustomersWithNegativeBalance() ([]Customer, error)
}

我们可能认为在生产者端创建并公开这个接口有一些非常好的理由。也许这是将客户端代码与实际实现解耦的好方法。或者,我们可以预见到它将帮助客户端创建测试替身。无论出于什么原因,在 Go 中这不是最佳实践。

正如提到的,Go 语言中的接口是隐式满足的,这与需要显式实现的语言相比,往往是一个游戏规则的改变者。在大多数情况下,要遵循的方法与我们在上一节中描述的类似:应该发现抽象,而不是创建抽象。这意味着生产者不应该为所有客户端强制使用给定的抽象。相反,应该由客户端决定它是否需要某种形式的抽象,然后确定最适合其需求的抽象级别。

在之前的示例中,可能有一个客户端对解耦其代码不感兴趣。也许另一个客户端想要解耦其代码,但只对 GetAllCustomers 方法感兴趣。在这种情况下,该客户端可以创建一个具有单一方法的接口,引用外部包中的 Customer 结构体:

package client
type customersGetter interface {
    GetAllCustomers() ([]store.Customer, error)
}

从包组织的角度来看,Figure 2.6 显示了结果。需要注意几点:

  • 由于 customersGetter 接口仅在客户端包中使用,它可以保持未导出状态。
  • 从视觉上看,图中看起来像循环依赖。然而,由于接口是隐式满足的,因此实际上并没有从 storeclient 的依赖。这就是为什么在需要显式实现的语言中,这种方法并不总是可行的。

WX20240515-162007@2x.png

主要的观点是客户端包现在可以为其需求定义最准确的抽象(这里只有一个方法)。这与接口隔离原则(SOLID原则中的“I”)有关,该原则指出,不应对客户端强制依赖它不使用的方法。因此,在这种情况下,最好的方法是在生产者端公开具体实现,并让客户端决定如何使用它以及是否需要抽象。

为了完整性,让我们提一下这种方法——生产者端的接口——有时在标准库中使用。例如,encoding 包定义了由其他子包如 encoding/json 或 encoding/binary 实现的接口。encoding 包在这方面做错了吗?绝对不是。在这个案例中,encoding 包中定义的抽象在整个标准库中被使用,语言设计者知道预先创建这些抽象是有价值的。我们回到了上一节的讨论:如果你认为它在未来可能有帮助,或者至少,如果你不能证明这个抽象是有效的,就不要创建一个抽象。

接口通常应该位于消费者端。然而,在特定情境下(例如,当我们知道——而不是预见——抽象对消费者有帮助时),我们可能希望将其放在生产者端。如果我们这样做,我们应该努力保持它的最小化,提高其可重用性潜力,并使其更容易组合。