生产者端接口
Go语言中常见100问题-#5 接口污染。在编码的时候,接口应该放在哪里呢?这是Go开发人员经常有误解的一个问题,本文将深入分析该问题。
在深入探讨问题之前,先对提及的术语做一个定义说明,确保我们对其有清晰的理解。
- 生产者端:接口定义与具体实现在同一个包中,称这种为生产者端接口。像下图所示,接口的定义和具体实现都在foo包中,调用客户端代码在bar包中。
- 消费者端:接口定义与具体实现不在相同的包中,而是定义在调用的客户端代码所在的包中,称这种为消费者端接口。如下图所示,接口定义在使用方包bar中。
在生产者端定义接口,与具体实现放在一起,这是具有C#或Java背景语言的人惯用写法。然而,在Go语言中,在大多数情况下,我们不应该采用这种写法。下面通过一个示例进行说明。
示例中,我们创建一个特定的包来存储和查询客户数据。同时在该包中定义一个接口,所有对客户数据的操作都通过接口来实现。对应到前面,这种实现就是生产者端接口。
golang
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)
}
对于上面在生产者端创建接口,并对外暴露这个接口。可能认为有很好的理由这样做,我们可能会说,1.这样可以将客户端代码与实际实现分离,2. 在测试的时候,调用方很方便Mock一个假的接口实现进行测试。但是,这不是Go中的最佳实践。
正如Go语言中常见100问题-#5 接口污染所提到的,与具有显示实现接口的语言相比,Go中通过隐式实现接口,这会带来一些变化,像其它语言惯用的生产者端接口在Go语言中并不是最佳实践。在大多数情况下,应该遵循Go语言中常见100问题-#5 接口污染提到的原则:应该发现抽象而不是创建抽象。这意味着生产者不能为所有客户端强制一个给定的抽象。相反,由客户端决定它是否需要某种形式的抽象。然后确定最适合它需要的抽象级别。
对于上述示例,也许客户端A不会对解耦它的代码感兴趣,也许客户端B想要解耦它的代码,但只对 GetAllCustomers 方法感兴趣。在这种情况下,可以使用单个方法创建一个接口,引用外部包中的 Customer 结构体。
golang
package client
type customersGetter interface {
GetAllCustomers() ([]store.Customer, error)
}
从包组织引用关系来看,客户端和存储两个包关系如下图。需要注意几点:
-
由于 customersGetter 接口仅在客户端包中使用,因此该接口可以保持不导出。
-
咋一看,下图看起来像存在循环依赖。但是,由于隐式满足接口,存储与客户端之间没有循环依赖关系。这也是为什么这种方法在具有显示实现的语言中并不总是可行的原因。
采用在客户端中定义接口的关键点是客户端可以根据需要定义最准确的抽象(像customersGetter只有一个方法),契合接口隔离原则(SOLID中的I),该原则指出不应强迫任何客户端依赖它不使用的方法。因此,在这种情况下,最好的方法是在生产者端公开具体实现(方法可导出),让客户端决定如何使用它以及是否需要抽象。
本文提到了生产者端接口和消费者端接口两个概念,前面分析了消费者端接口,为了完整性,这里对生产者端接口也做点分析。生产者端接口有时候会在标准库中遇到,例如encoding子包中定义了实现的接口,如encoding/json、encoding/binary. 采用这种方式错了吗?前面不是说Go中不推荐生产者端接口吗? 没有错,在这种情况下,encoding包中定义的抽象在标准库中使用,语言设计者知道预先创建这种抽象是有价值的。这满足Go语言中常见100问题-#5 接口污染中的讨论:如果你认为抽象在想象的将来可能有用,但是不能证明这个抽象时有效的,就不要创建抽象。
因此,在大多数情况下,Go语言中的接口应该位于消费者端。但是,在特定情况下,例如,当我们知道(不是预想)抽象对消费者有帮助时,我们可能希望将其放在生产者端。如果要这样做,应该努力让接口尽可能地最小化(接口中的方法仅可能少),像encoding/json中定义的Marshaler接口只包含1个方法,这样增加它的可重用潜力并使其更容易组合。