Go常见错误集锦之接口污染

2023-01-31 15:35:49 浏览数 (1)

接口污染就是用不必要的抽象来淹没我们的代码,使其更难理解和演化。如果研发者按照别的语言的习惯来使用Go中的接口的话,那么是非常容易出错的。在深入研究该主题之前,先回顾一下Go中的接口。然后,讨论何时适合使用接口,何时不适合使用。

1 Go接口

接口是为对象定义特定行为的一种方式。接口一般用于创建行为抽象,再由各种对象实现具体的行为。Go的接口与其他语言的接口不同之处在于,接口是被隐式实现的。例如,接口没有像implements这样显示的关键词来标识对象X实现了接口Y。

为了理解是什么使得接口如此强大,我们从标准库中选择了两个常用的接口来深入的了解一下:io.Reader和io.Writer。

io包中提供了对I/O原语的抽象。在这些抽象中,io.Reader是从一个数据源读取数据相关的接口,同时io.Writer是将数据写入到目标中相关的接口。如图1所示:

图-1:io.Reader从数据源读取数据并填充到切片中,同时io.Writer接口负责将切片中的数据写入到目标文件

io.Reader接口仅包含一个Read方法:

代码语言:javascript复制
type Reader interface {
  Read(p []byte) (n int, err error)
}

要实现io.Reader接口,应该接收一个字节切片,然后用读取到的数据填充该字节切片,最后返回读取数据的字节大小或返回错误。

下面是一个对io.Reader接口的实现,让我们看看下面的stringReader结构体是如何实现该接口的:

代码语言:javascript复制
type stringReader struct {
  s string
}

func (r stringReader) Read(p []byte) (n int, err error) { ①
   copy(p, r.s)
   return len(r.s), nil
}
func main() {
   reader := stringReader{s: "foo"}
   p := make([]byte, 3)
   n, _ := reader.Read(p)
   fmt.Println(n, string(p)) ②
}
代码语言:javascript复制
① 实现Read方法

② 打印出3个字节,字符串是 “foo”

stringReader是一个io.Reader类型,因为它包含一个相同签名的Read方法。正如我们所说的,接口是被隐式实现的。

另外,io.Writer接口也只定义了一个方法:Write。

代码语言:javascript复制
type Writer interface {
  Write(p []byte) (n int, err error)
}

io.Writer接口的实现是应该将来自slice的数据写入到目标文件中,然后要么返回写入的字节数,要么返回错误。

因此,两个接口都提供了底层的抽象:

  • io.Reader接口是从数据源读取数据
  • io.Writer接口是将数据写入到目标源

在语言中使用这两个接口的基本原理是什么?创建这些抽象概念有什么意义?

假设我们需要实现一个函数,该函数的功能是拷贝一个文件的内容到另外一个文件。我们可以创建一个具体的函数,将两个*os.File作为输入参数。或者,我们也可以使用io.Reader和io.Writer创建一个更通用的函数:

代码语言:javascript复制
func copySourceToDest(source io.Reader, dest io.Writer) error {
  //...
}

这个函数如果传入os.File参数也是能正常运行的(因为os.File实现了io.Reader和io.Writer接口),同时,由于Go中的其他很多类型也都实现了这两个接口(io.Reader和io.Writer),因此该函数也可以使用标准库中的许多其他类型。同样关于测试,我们不必创建繁琐的临时文件,而是可以将从字符串创建的strings.reader作为reader和bytes.Buffer作为writer传递给该函数。

代码语言:javascript复制
func TestCopySourceToDest(t *testing.T) {
  const input = "foo"
  source := strings.NewReader(input) ①
  dest := bytes.NewBuffer(make([]byte, len(input))) ②
  err := copySourceToDest(source, dest) ③
  if err != nil {
    t.FailNow()
  }
  got := dest.String()
  if got != input {
    t.Errorf("expected: %s, got: %s", input, got)
  }
}

① 创建一个io.Reader

② 创建一个io.Writer

③ 调用copySourceToDest函数,从strings.Reader拷贝到bytes.Buffer中。

因此,一个接收抽象参数而非具体类型参数的通用函数也会简化单元测试的编写。

同时,当设计接口时,需要记住接口的粒度(即接口中包含的方法数量)。Go中有一句与接口大小有关著名谚语是:

接口粒度越大,抽象越弱

事实上,在接口中每增加一个方法,就会降低接口的复用性。io.Reader和io.Writer接口是很好的抽象,因为他们已经再简单不过了。当我们设计接口时,我们应该时刻保持这种原则。

总之,接口可以创建强大的抽象能力。抽象能力在很多方面能给我们提供帮助。例如,代码解耦,提高函数的可复用性,同时也促进单元测试。然而,就像许多软件工程领域一样,滥用一个概念会导致缺陷

2 何时该使用接口

那么,我们什么时候该使用接口呢?我们深入的研究了两个不同的接口案例,在这两个案例中我们看看通过使用接口都带来了哪些有价值的东西。

普通的行为

使用接口的最常见的场景,就是很多对象都实现了相同的行为。我们通过一个具体的例子来看一下。

有一个函数实现,该函数接收一个customers列表作为参数,然后执行很多的过滤器,最后返回剩余的customers列表。我们首先定义三个过滤器:

  • FilterByAge过滤器,通过age字段过滤
  • FilterByCity过滤器,通过city字段过滤
  • FilterByCount过滤器,最多返回多少个customers(例如,不超过100个)
代码语言:javascript复制
type FilterByAge struct{ minAge int }
func (f FilterByAge) filter(customers []Customer) ([]Customer, error) ①
{
   res := make([]Customer, 0)
   for _, customer := range customers {
       if customer.age < 0 {
           return nil, errors.New("negative age")
      }
       if customer.age >= f.minAge {
           res = append(res, customer)
      }
   }
   return res, nil
}
type FilterByCity struct{ city string }

func (f FilterByCity) filter(customers []Customer) ([]Customer, error) ②
{
   res := make([]Customer, 0)
   for _, customer := range customers {
       if customer.city == f.city {
           res = append(res, customer)
        }
    }
   return res, nil
}
type FilterByCount struct{ max int }
func (f FilterByCount) filter(customers []Customer) ([]Customer, error)③
{
   if len(customers) < f.max {
       return customers, nil
    }
   return customers[:f.max], nil
}

① 根据age字段过滤的实现

② 根据city字段过滤的实现

③ 根据最大个数进行过滤的实现

然后我们实现一个applyFilters方法,该方法会应用上面的3个过滤器并返回最后的列表:

代码语言:javascript复制
type filterApplication struct {
   filterByAge    FilterByAge
   filterByCity   FilterByCity
   filterByCount  FilterByCount
}
// Init filterApplication
func (f filterApplication) applyFilters(customers []Customer) (
  []Customer, error) {
   res, err := f.filterByAge.filter(customers) ①
   if err != nil {
       return nil, err
  }
   res, err = f.filterByCity.filter(customers) ②
   if err != nil {
       return nil, err
   }
   res, err = f.filterByCount.filter(customers) ③
   if err != nil {
       return nil, err
   }
   return res, nil
}

① 应用第一个过滤器

② 应用第二个过滤器

③ 应用第三个过滤器

该实现是可以工作的,但是我们注意到一些公式化的代码,因为所有的过滤器都实现了相同的行为:filter([]Customer)([]Customer, error)。

所以,我们应该使用接口重构我们的实现:

代码语言:javascript复制
type Filter interface {
   filter(customers []Customer) (result []Customer, err error)
}
type filterApplication struct {
   filters []Filter
}
// Init filterApplication

func (f filterApplication) applyFilters(customers []Customer) (
  []Customer, error) {
   for _, filter := range f.filters { ①
       res, err := filter.filter(customers) ②
       if err != nil {
           return nil, err
       }
       customers = res
    }
    return customers, nil
}

① 迭代所有的过滤器

② 应用每一个过滤器

在这个例子汇总,我们使用接口创建了一个Filter抽象类型来帮助我们减少样板代码。我们可以通过增加更多的过滤器到filterApplication结构体中,以扩展我们的代码,applyFilters方法将会依然保持这样。

单元测试

接口另一个重要的使用场景是简化单元测试的书写。简而言之,当我们的代码有一些外部依赖项时,可以方便地将它们包装到接口中。

我们扩展下上面的例子。我们会实现一种方法,对一些客户进行促销。我们将遵循的逻辑如下:

  • 从数据库中获取所有的客户
  • 对这些客户应用上面我们提供的这些过滤器
  • 对剩下的客户进行促销,并更新数据库

我们首先看该方法的第一版本的实现。首先定义一个customerPromotion结构体,该结构体包含一个mysql.Store结构体,以实现和数据库的交互方法:

代码语言:javascript复制
type customerPromotion struct {
  filter filterApplication ①
  storer mysql.Store ②
}

func (c customerPromotion) setPromotion() error {
    customers, err := c.storer.GetAllCustomers() ③
    if err != nil {
      return err
    }
    filteredCustomers, err := c.filter.applyFilters(customers) ④
    if err != nil {
      return err 
    }
    customerIDs := getCustomerIDs(filteredCustomers)
    return c.storer.SetPromotionToCustomers(customerIDs) ⑤
}

① 过滤器结构体

② 和数据库交互的结构体

③ 获取所有的客户

④ 应用过滤器

⑤ 更新数据库

现在有一个问题:我们应该如何测试该函数呢?第一种方式是创建一个测试,将MySQL实例作为先决条件。然而,这种测试不是一个单元测试。单元测试应该被视为在单个进程中快速且确定地运行的测试。所以,这可能不是最好的选择。

那么,该如何给这个方法实现单元测试呢?应该使用接口的方式来强制替换依赖(这里是集成的MySQL):

代码语言:javascript复制
type Storer interface { ①    GetAllCustomers() (customers []Customer, err error)    SetPromotionToCustomers(customerIDs []string) error}type customerPromotion struct {    filter filterApplication    storer Storer ②}func (c customerPromotion) setPromotion() error {    customers, err := c.storer.GetAllCustomers() ③    if err != nil {      return err     }    filteredCustomers, err := c.filter.applyFilters(customers)    if err != nil {      return err     }    customerIDs := getCustomerIDs(filteredCustomers)    return c.storer.SetPromotionToCustomers(customerIDs) ④}

① 创建一个接口,该接口包含在setPromotion函数中使用的两个必要方法② 使用接口引用而非具体的实现③ 使用Storer接口④ 使用Storer接口

通过创建一个接口,就能将代码从具体的实现解耦了。然后可以使用Storer接口的测试替身 来编写单元测试了。测试替身(test double)是Martin Fowler推广的概念。主要有三种测试替身(test doubles):

  • Stub:一个具有预先定义数据的对象,并用该对象来回应在测试期间的调用。
  • Mock:stub对象的扩展,在这里我们还希望注册对象接收的调用以执行进一步的断言。
  • Fake:工作实现,但不同于生产实现(例如hashmap)。

使用测试替身(test double),可以给setPromotion编写不依赖于外部数据库的单元测试。这是由于我们把外部依赖包装成了接口,所以才使此变为可能。

我们看到了创建接口的两种主要案例:

  • 给一个共享行为创建抽象
  • 将外部依赖替换成接口以简化单元测试的编写。

3 何时不该使用接口

在Go项目中,接口被过度使用是很常见的。也许你具有C 或Java的背景,你会发现在具体类型之前创建接口是很自然的。然而,这不是Go中的工作方式。

像在本文开始提到的,接口允许我们创建抽象,同时抽象是隐藏的,不是被创建的。换言之,如果没有具体的原因,我们不应该从在代码中创建抽象开始。我们应该努力在我们需要的时候才创建接口,而不是在我们预见到我们将需要它们的时候就过早的创建接口。

如果过度使用接口会有什么问题呢?首先,应该注意到通过接口作为参数调用方法会影响性能。它需要从一个哈希表中查找接口所指向的具体类型。然而,这也不是主要问题,因为被禁止的没有多少上下文内容,但是这依然是值得被提到的点。主要的问题是接口使代码的流程变得更复杂。增加一个无用的间接调用层级不会带来任何好处;这就是通过创建无用的抽象会使代码变的更复杂的原因。

在很多案例中,如果我们只定义了一个接口,该接口只有一个具体的实现(这里我们不将测试替身算在内)并且该实现又没有包含任何外部依赖,我们可以问问自己这个接口是否是有用的。如果只是为了简化单元测试,那我们为什么不直接调用具体的实现类型呢?那我们应该如何理性对待呢?例如,如果由于一些状态使结构体变得配置起来非常复杂导致可能会出现异常时,那么我们更倾向于抽象它。然而,在一些特殊场景下,使用接口不是必须的。

总之,当在代码中创建抽象时必须非常小心。再次强调,抽象是隐藏的,不是通过像implement关键字被创建的。对于软件开发者来说基于我们后续的需要来猜测哪些代码需要抽象是经常的事情。这个过程需要被避免,因为我们通过不必要的抽象来评估我们的代码会使我们的代码变得更复杂而难以理解。

0 人点赞