你在Go中滥用接口 - 架构异味:错误的抽象

64 阅读5分钟

Go中滥用接口是架构异味!避免为实现创建接口,关注抽象需求。利用SOLID原则,考虑是否需要隐藏结构体状态或解耦依赖。单元测试非必要理由,Ports & Adapters架构中,接口由调用者定义,实现依赖反转和可测试性,提升云原生应用质量。

译自:You Are Misusing Interfaces in Go - Architecture Smells: Wrong Abstractions

作者:Emre Savcı

接口在软件架构中扮演着至关重要的角色。使其成为架构的非常强大的原则的是其抽象能力。

软件工程师在面试中经常遇到的一个概念是 SOLID 原则。接口涵盖了 SOLID 的大部分实现部分。当你尝试遵循开闭原则或尝试反转你的依赖关系时,你需要一个接口

作为一条规则,请记住,当你想要从调用者/客户端抽象一些实现细节时,你可能会使用接口。

作为这种逻辑推理的必要推论,我们可以得出结论,如果我们想从另一个包中抽象出一个包,我们需要使用接口。

因此,让我们深入研究 Go 应用程序中接口使用的问题。

在审查 Go 代码时,我通常会遇到一种编码约定,即使用如下例所示的接口。一个接口定义了方法,以及一个紧随其后的实现该接口的单个具体私有结构体。

如你所知,我们强调了接口的重要性和作用:抽象。现在,再次查看该代码,并问自己以下问题:“这里的抽象是什么?”。它是否阻止暴露结构体的功能?否。相反,这才是重点。

可能会有一个论点,比如“从包的外部隐藏结构体本身”。但是,重点是什么?如果无论如何我们都要暴露结构体的功能,为什么要隐藏结构体呢?

通常我们不想隐藏结构体,我们想要“封装”,即隐藏结构体的内部状态(结构体变量)。我们可能不希望任何人对结构体的状态进行不受控制的更改。但在这种情况下,我们只需要使结构体变量私有,并通过结构体方法访问它们。

另一个论点可能是关于单元测试。接口提供了测试结构体功能的方法。但这带来了另一个问题,我们需要测试谁?我们可能想要测试这个结构体的调用者/客户端。因为我们可以在同一个包中测试该结构体的每个功能,而无需定义接口。因此,如果我们想要测试调用者,我们需要一个接口来模拟调用者的外部依赖项。在这里,我们找到了需求:独立于外部依赖项测试调用者。

经过这样的澄清,我们仍然面临同样的问题:“这里的接口/抽象的意义是什么?”。

答案很明显:它大多毫无意义‼️

这是一种过早的优化,这是一种不好的做法。我们没有(而且大多数情况下)不会有同一个接口的具体类型实现(即使我们有,这并不意味着我们需要定义一个接口)。

好的,我们看到了问题,在每个结构体定义前面放置接口是错误的,但是我们需要做什么?使用接口的最佳实践是什么?进行抽象的正确方法是什么?

你需要问自己:

  • 📌 没有接口我能活下去吗?
  • 📌 我需要抽象哪个部分?
  • 📌 我的依赖关系应该朝哪个方向发展?
  • 📌 我想测试什么?

通常,单元测试本身不足以成为创建接口的有力理由(除非我们有外部依赖项)。

但是,在大多数情况下,为了避免深入而困难的模拟过程,我们可以创建一个接口来轻松地模拟一个类型。

除了单元测试之外,先前问题的答案为我们提供了关于抽象需求的巨大洞察力,因此我们可以轻松地决定在哪里放置我们的接口。

现在,采用第一个代码示例,并决定在哪里放置我们的接口/抽象。

假设我们有以下 Consumer 代码及其调用者。假设 Consumer 存在于“infrastructure”包中:

并且 Caller 存在于“application”层中:

Caller 和 Consumer 之间存在很强的耦合,因为 Caller 直接依赖于 Consumer 具体类型。此外,这也带来了层之间的耦合。如果我们想摆脱 Consumer 的细节,我们需要一个抽象:接口

目前,我们无法轻松地对 Caller 进行单元测试,因为 consumer.Consume() 方法将被调用,并且我们无法阻止它发生。因此,我们需要使用接口创建一个抽象

现在让我们创建该抽象来克服这些问题。为了实现这一点,在 Go 中,我们只需要编写一个包含合约的适当接口,即我们使用的方法。

我们将通过定义一个 Consumer 接口来修改 Caller 代码:

通过在 Caller 端创建一个接口,我们将简单地为其依赖项创建一个抽象。此外,我们还切断了层/包之间的直接耦合(这要归功于 Go 的鸭子类型接口实现)。现在,我们可以通过在与 Caller 相同的包中为 Consumer 接口创建一个模拟结构体,轻松地为我们的 Caller 编写单元测试。 我们不需要更改应用程序的任何其他部分,组合根(通常是 main)将保持不变,它仍然创建具体类型,并以相同的方式将它们作为参数传递给构造函数/函数。

通过遵循这种简单的方法,我们能够创建松散耦合、可测试、行为良好的包,而无需不必要的抽象和接口定义。

这种技术主要用于实现 Ports & Adapters 架构。端口由使用的层定义。请记住,主要(驱动)适配器使用/包装端口,而次要(被驱动)适配器实现端口。

我们展示了何时以及如何使用接口进行抽象。感谢您阅读至今,下次再见 🚀