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

yudotyang · 2021年09月18日 · 159 次阅读

本文是对《100 Go Mistackes:How to Avoid Them》一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解。关注 公众号 “Go 学堂”,获取更多系列文章

所谓接口污染就是用不必要的抽象来淹没我们的代码,使其更难理解和演化

如果研发者按照别的语言的习惯来使用 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 方法:

type Reader interface {
    Read(p []byte) (n int, err error)
}

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

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

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)) 
}

① 实现 Read 方法

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

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

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

type Writer interface {
    Write(p []byte) (n int, err error)
}

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

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

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

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

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

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 传递给该函数。

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 个)
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 个过滤器并返回最后的列表:

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)。我们应该使用接口重构我们的实现:

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 结构体,以实现和数据库的交互方法:

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):

 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 关键字被创建的。对于软件开发者来说基于我们后续的需要来猜测哪些代码需要抽象是经常的事情。这个过程需要被避免,因为我们通过不必要的抽象来评估我们的代码会使我们的代码变得更复杂而难以理解。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
ningxiaofang1o GoCN 每日新闻 (2021-09-19) 中提及了此贴 09月19日 09:23
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册