Skip to content

Latest commit

 

History

History
1130 lines (804 loc) · 42.5 KB

File metadata and controls

1130 lines (804 loc) · 42.5 KB

二、Go 的实体设计原则

2002 年,Robert“Bob 叔叔”Martin出版了《敏捷软件开发、原则、模式和实践》一书,他在书中定义了可重用程序的五条原则,他称之为坚实原则。虽然在一本关于 10 年后发明的编程语言的书中包含这些原则似乎有些奇怪,但这些原则在今天仍然是相关的。

在本章中,我们将简要分析这些原则中的每一条,它们与依赖注入DI之间的关系,以及这对 Go 意味着什么。SOLID 是五种流行的面向对象软件设计原则的缩写:

  • 单一责任原则
  • 开闭原理
  • 利斯科夫替换原理
  • 界面分离原理
  • 依赖倒置原理

技术要求

本章的唯一要求是对对象和接口的基本理解以及开放的思维

本章所有代码可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch02

您可以在本章末尾的进一步阅读部分找到本章中提到的其他信息和其他参考资料的链接。

单一责任原则(SRP)

“一个班级应该只有一个理由去改变。” -罗伯特·C·马丁

Go 没有类,但如果我们稍微看一眼,用对象(结构、函数、接口或包)替换,那么这个概念仍然适用。

为什么我们希望我们的对象只做一件事?让我们看一看做一件事的两个对象:

这些对象简单易用,用途广泛。

设计对象,使它们都只做一件事,从抽象上讲,听起来不错。但您可能认为,对整个系统这样做会添加更多的代码。是的,会的。然而,它并没有增加复杂性;事实上,这大大减少了它。每段代码都会更小,更容易理解,因此更容易测试。这一事实为我们提供了 SRP 的第一个优势:

SRP 通过将代码分解成更小、更简洁的片段来降低复杂性

有了“单一责任原则”这样的名称,我们可以放心地认为这一切都与责任有关,但到目前为止,我们所谈论的只是变化。为什么会这样?让我们看一个例子:

// Calculator calculates the test coverage for a directory 
// and it's sub-directories
type Calculator struct {
  // coverage data populated by `Calculate()` method
  data map[string]float64
}

// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
  // run `go test -cover ./[path]/...` and store the results
  return nil
}

// Output will print the coverage data to the supplied writer
func (c *Calculator) Output(writer io.Writer) {
  for path, result := range c.data {
    fmt.Fprintf(writer, "%s -> %.1f\n", path, result)
  }
}

代码看起来很合理,一个成员变量和两个方法。然而,它不符合 SRP。假设应用程序成功了,我们决定还需要将结果输出到 CSV。我们可以添加一个方法来实现这一点,如下代码所示:

// Calculator calculates the test coverage for a directory 
// and it's sub-directories
type Calculator struct {
  // coverage data populated by `Calculate()` method
  data map[string]float64
}

// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
  // run `go test -cover ./[path]/...` and store the results
  return nil
}

// Output will print the coverage data to the supplied writer
func (c Calculator) Output(writer io.Writer) {
  for path, result := range c.data {
    fmt.Fprintf(writer, "%s -> %.1f\n", path, result)
  }
}

// OutputCSV will print the coverage data to the supplied writer
func (c Calculator) OutputCSV(writer io.Writer) {
  for path, result := range c.data {
    fmt.Fprintf(writer, "%s,%.1f\n", path, result)
  }
}

我们已经更改了结构并添加了另一个Output()方法。我们为结构增加了更多的责任,同时也增加了复杂性。在这个简单的示例中,我们的更改仅限于一个方法,因此没有破坏前面代码的风险。然而,随着结构变得更大、更复杂,我们的更改不太可能如此干净。

相反,如果我们将职责分解为CalculateOutput,那么添加更多的输出只会定义新的结构。此外,如果我们决定不喜欢默认输出格式,我们可以将其与其他部分分开更改。

让我们尝试另一种实现:

// Calculator calculates the test coverage for a directory 
// and it's sub-directories
type Calculator struct {
  // coverage data populated by `Calculate()` method
  data map[string]float64
}

// Calculate will calculate the coverage
func (c *Calculator) Calculate(path string) error {
  // run `go test -cover ./[path]/...` and store the results
  return nil
}

func (c *Calculator) getData() map[string]float64 {
  // copy and return the map
  return nil
}

type Printer interface {
  Output(data map[string]float64)
}

type DefaultPrinter struct {
  Writer io.Writer
}

// Output implements Printer
func (d *DefaultPrinter) Output(data map[string]float64) {
  for path, result := range data {
    fmt.Fprintf(d.Writer, "%s -> %.1f\n", path, result)
  }
}

type CSVPrinter struct {
  Writer io.Writer
}

// Output implements Printer
func (d *CSVPrinter) Output(data map[string]float64) {
for path, result := range data {
    fmt.Fprintf(d.Writer, "%s,%.1f\n", path, result)
  }
}

你注意到打印机有什么重要的地方吗?它们与计算毫无关联。它们可以用于相同格式的任何数据。这导致了 SRP 的第二个优势:

SRP 增加了代码的潜在可重用性。

在覆盖率计算器的第一个实现中,为了测试Output()方法,我们将首先调用Calculate()方法。这种方法通过将计算与输出耦合,增加了测试的复杂性。考虑以下情景:

  • 我们如何测试没有结果?
  • 我们如何测试边缘条件,例如 0%或 100%覆盖率?

在去掉这些责任之后,我们应该鼓励自己以较少相互依赖的方式考虑每个部分的输入和输出,从而使测试更容易编写和维护。这导致了 SRP 的第三个优势:

SRP 使测试更易于编写和维护

SRP 也是提高通用代码可读性的一种很好的方法。请看下一个示例:

func loadUserHandler(resp http.ResponseWriter, req *http.Request) {
  err := req.ParseForm()
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }
  userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  row := DB.QueryRow("SELECT * FROM Users WHERE ID = ?", userID)

  person := &Person{}
  err = row.Scan(&person.ID, &person.Name, &person.Phone)
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }

  encoder := json.NewEncoder(resp)
  encoder.Encode(person)
}

我敢打赌这花了五秒钟多的时间才明白。这个代码怎么样?

func loadUserHandler(resp http.ResponseWriter, req *http.Request) {
  userID, err := extractIDFromRequest(req)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  person, err := loadPersonByID(userID)
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }

  outputPerson(resp, person)
}

通过在函数级别应用 SRP,我们减少了函数的膨胀并提高了其可读性。该函数的唯一职责是协调对其他函数的调用。

这与 DI 有什么关系?

当我们将 DI 应用于代码时,毫不奇怪地注入了依赖项,通常是以函数参数的形式。如果您看到一个具有许多注入依赖项的函数,这可能表明该方法做得太多。

此外,应用 SRP 将为我们的对象设计提供信息。因此,这有助于我们确定何时何地使用 DI。

这对围棋意味着什么?

第 1 章中,我们提到了 Go 与 Unix 理念的关系,即我们应该设计只做一件事的代码,但要做到这一点,并与其他代码协同工作。应用 SRP 后,我们的对象将完全符合这一原则。

Go 接口、结构和函数

在接口和结构级别,应用 SRP 会产生许多小型接口。符合 SRP 的函数具有很少的输入,并且非常短(即,它的代码屏幕少于一个)。这两个特性本质上都解决了我们在第 1 章中提到的代码膨胀的问题,永远不会停止追求更好的

通过解决代码膨胀问题,我们发现 SRP 的一个较少宣传的优点是它使代码更容易理解。简单地说,当一段代码做一件事时,它的目的就更清楚了。

在将 SRP 应用于现有代码时,您通常会将代码分成更小的部分。您可能会自然地对此感到厌恶,因为您可能还需要编写更多的测试。在将结构或接口拆分为多个部分的情况下,这可能是正确的。但是,如果您正在重构的代码具有很高的单元测试覆盖率,那么您可能已经有了许多需要的测试。他们只需要移动一下。

另一方面,当将 SRP 应用于函数以减少膨胀时,不需要新的测试;原始功能的测试完全可以接受。让我们看一个loadUserHandler()的测试示例,如前一个示例所示:

func TestLoadUserHandler(t *testing.T) {
   // build request
   req := &http.Request{
      Form: url.Values{},
   }
   req.Form.Add("UserID", "1234")

   // call function under test
   resp := httptest.NewRecorder()
   loadUserHandler(resp, req)

   // validate result
   assert.Equal(t, http.StatusOK, resp.Code)

   expectedBody := `{"ID":1,"Name":"Bob","Phone":"0123456789"}` + "\n"
   assert.Equal(t, expectedBody, resp.Body.String())
}

这个测试可以应用于我们函数的任何一种形式,并将实现相同的功能。在这种情况下,我们是为了可读性而重构的,我们不希望有任何东西阻止我们这样做。此外,从 API(公共方法或其他人调用的函数)进行测试更稳定,因为 API 契约的更改可能性小于内部实现。

围棋包

在包级别应用 SRP 可能更难。系统通常是分层设计的。例如,通常可以看到 HTTP REST 服务的层按以下方式排列:

这些抽象是好的和清楚的;然而,当我们的服务有多个端点时,问题开始出现。我们很快就会得到一个充满完全不相关逻辑的怪物包。另一方面,好的软件包小而简洁,目的明确。

很难找到正确的抽象概念。通常,当我需要灵感时,我会求助于专家并检查标准 Go 库。举个例子,让我们来看看 PosiT0 包:

正如您所看到的,每个不同的类型都整齐地组织在自己的包中,但所有包仍然按照父目录进行逻辑分组。我们的 REST 服务将对其进行分解,如下图所示:

我们最初的抽象是在正确的轨道上,只是从太高的层次。

encoding包的另一个不明显的方面是共享代码在父包中。当开发一个功能时,程序员通常会认为我需要我之前编写的代码,并试图将代码提取到commonsutils包中。请抵制这种诱惑重用代码是绝对正确的,但您应该抵制通用包名称的诱惑。这样的包由于没有明确的目的而本质上违反了 SRP。

另一个常见的诱惑是在现有代码旁边添加新代码。让我们想象一下,我们正在编写前面提到的encoding包,我们制作的第一个编码器是 JSON。接下来,我们添加了 GobEncoder,一切都很顺利。再加上几个编码器,我们突然有了一个包含大量代码和大量导出 API 的实质性包。在某个时候,我们的小encoding包的文档变得太长,用户很难理解。类似地,包中的代码太多,扩展和调试工作会因为很难找到东西而减慢。

SRP 帮助我们确定改变的原因;改变的多重原因表明多重责任。分离这些责任使我们能够开发更好的抽象。

如果你从一开始就有时间或意愿去做,那太棒了。然而,应用 SRP 并从一开始就找到正确的抽象是困难的。您可以通过先打破规则,然后使用后续更改来发现软件想要如何发展来应对这种情况,使用发展的力量作为重构的基础。

开/闭原理(OCP)

“软件实体(类、模块、函数等)应开放进行扩展,但应关闭进行修改。” -伯特兰·迈耶

在讨论软件工程时,openclosed这两个术语不是我经常听到的,因此,也许它们需要一些解释。

开放意味着我们应该能够通过添加新的行为和特性来扩展或调整代码。关闭意味着我们应该避免对现有代码进行更改,这些更改可能导致 bug 或其他类型的回归。

这两个特征看起来可能相互矛盾,但谜题中缺少的是范围。当谈到开放时,我们谈论的是软件的设计或结构。从这个角度来看,开放意味着很容易添加新包、新接口或现有接口的新实现。

当我们谈论关闭时,我们谈论的是现有代码,并最小化我们对它所做的更改,特别是其他人使用的 API。这给我们带来了 OCP 的第一个优势:

OCP 有助于降低增加和扩展的风险

您可以将 OCP 视为一种风险缓解策略。修改现有代码总是有一些风险,尤其是对其他人使用的代码的更改。虽然我们可以也应该通过单元测试来保护自己不受这种风险的影响,但这些测试仅限于我们想要的场景和我们可以想象的误用;他们不会涵盖我们的用户能想到的一切。

以下代码不符合 OCP:

func BuildOutput(response http.ResponseWriter, format string, person Person) {
  var err error

  switch format {
  case "csv":
    err = outputCSV(response, person)

  case "json":
    err = outputJSON(response, person)
  }

  if err != nil {
    // output a server error and quit
    response.WriteHeader(http.StatusInternalServerError)
    return
  }

  response.WriteHeader(http.StatusOK)
}

第一个暗示出问题的是switch语句。不难想象需求发生变化的情况,我们可能需要添加甚至删除输出格式。

如果我们需要添加另一种格式,需要改变多少?见下文:

  • 我们需要为开关添加另一个案例条件:此方法已经有 18 行了;在一个屏幕上看不到所有格式之前,我们还需要添加多少格式?这个switch陈述存在于其他多少地方?它们也需要更新吗?
  • 我们需要编写另一个格式化函数:这是不可避免的三个更改之一
  • 该方法的调用方必须更新才能使用新格式:这是另一个不可避免的更改
  • 我们必须添加另一组测试场景来匹配新的格式:这也是不可避免的;然而,这里的测试可能会比单独测试格式更长

一开始只是一个小而简单的改变,现在开始感到比我们预期的更艰巨和危险。

让我们用一个抽象来替换 format 输入参数和switch语句,如下代码所示:

func BuildOutput(response http.ResponseWriter, formatter PersonFormatter, person Person) {
  err := formatter.Format(response, person)
  if err != nil {
    // output a server error and quit
    response.WriteHeader(http.StatusInternalServerError)
    return
  }

  response.WriteHeader(http.StatusOK)
}

这次有多少变化?让我们看看:

  • 我们需要定义PersonFormatter接口的另一个实现
  • 必须更新方法的调用方才能使用新格式
  • 我们必须为新的PersonFormatter编写测试场景

这要好得多:我们只做了三个不可避免的更改,我们根本没有更改主要功能。这向我们展示了 OCP 的第二个优势:

OCP 可以帮助减少添加或删除功能所需的更改数量。

另外,如果在添加新的格式化程序后,我们的新结构中碰巧出现了一个 bug,那么新代码只能在一个地方出现。这是 OCP 的第三个优点:

OCP 将 bug 的位置缩小到只包含新代码及其用法

让我们看另一个例子,在这个例子中,我们没有应用 DI:

func GetUserHandlerV1(resp http.ResponseWriter, req *http.Request) {
  // validate inputs
  err := req.ParseForm()
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }
  userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  user := loadUser(userID)
  outputUser(resp, user)
}

func DeleteUserHandlerV1(resp http.ResponseWriter, req *http.Request) {
  // validate inputs
  err := req.ParseForm()
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }
  userID, err := strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  deleteUser(userID)
}

如您所见,我们的两个 HTTP 处理程序都从表单中提取数据,然后将其转换为数字。有一天,我们决定加强输入验证,确保数字为正。可能的结果是什么?一些非常讨厌的鸟枪手术。然而,在这种情况下,没有办法。我们把事情搞得一团糟;现在我们需要把它清理干净。希望修复非常明显,将重复的逻辑提取到一个位置,然后在那里添加新的验证,如以下代码所示:

func GetUserHandlerV2(resp http.ResponseWriter, req *http.Request) {
  // validate inputs
  err := req.ParseForm()
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }
  userID, err := extractUserID(req.Form)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  user := loadUser(userID)
  outputUser(resp, user)
}

func DeleteUserHandlerV2(resp http.ResponseWriter, req *http.Request) {
  // validate inputs
  err := req.ParseForm()
  if err != nil {
    resp.WriteHeader(http.StatusInternalServerError)
    return
  }
  userID, err := extractUserID(req.Form)
  if err != nil {
    resp.WriteHeader(http.StatusPreconditionFailed)
    return
  }

  deleteUser(userID)
}

遗憾的是,原始代码没有减少,但它确实更易于阅读。除此之外,我们已经证明了自己不会对UserID字段的验证进行任何进一步的更改。

对于我们的两个例子,满足 OCP 的关键是找到正确的抽象

这与 DI 有什么关系?

第一章中,我们将定义为编码,我们所依赖的资源都是抽象的。通过使用 OCP,我们可以发现更干净、更持久的抽象。

这对围棋意味着什么?

通常,在讨论 OCP 时,示例中充斥着抽象类、继承、虚拟函数以及 Go 没有的各种东西。还是这样?

抽象类到底是什么?它究竟想达到什么目的?

它试图为多个实现之间共享的代码提供一个位置。我们可以在围棋中这样做,它被称为组合。您可以在以下代码中看到它在工作:

type rowConverter struct {
}

// populate the supplied Person from *sql.Row or *sql.Rows object
func (d *rowConverter) populate(in *Person, scan func(dest ...interface{}) error) error {
  return scan(in.Name, in.Email)
}

type LoadPerson struct {
  // compose the row converter into this loader
  rowConverter
}

func (loader *LoadPerson) ByID(id int) (Person, error) {
  row := loader.loadFromDB(id)

  person := Person{}
  // call the composed "abstract class"
  err := loader.populate(&person, row.Scan)

  return person, err
}

type LoadAll struct {
  // compose the row converter into this loader
  rowConverter
}

func (loader *LoadPerson) All() ([]Person, error) {
  rows := loader.loadAllFromDB()
  defer rows.Close()

  output := []Person{}
  for rows.Next() {
    person := Person{}

    // call the composed "abstract class"
    err := loader.populate(&person, rows.Scan)
    if err != nil {
      return nil, err
    }
  }

  return output, nil
}

在前面的例子中,我们已经将一些共享逻辑提取到一个rowConverter结构中。然后,通过将该结构嵌入到其他结构中,我们可以在不做任何更改的情况下使用它。我们已经实现了抽象类和 OCP 的目标。我们的代码是开放的;我们可以嵌入任何我们喜欢但封闭的地方。嵌入类不知道它是嵌入的,也不需要进行任何更改。

之前,我们将关闭定义为保持不变,但将范围仅限于 API 中其他人导出或使用的部分。期望内部实现细节(包括私有成员变量)永远不会改变是不合理的。实现这一点的最佳方法是隐藏这些实现细节。这称为封装

在包级别,封装很简单:我们将其设置为私有。这里有一个很好的经验法则,就是把所有事情都保密,只有在你真正需要的时候才公开。同样,我的理由是避免风险和工作。你出口某物的那一刻,就是某人可以信赖它的那一刻。一旦他们依赖它,它就应该关闭;您必须维护它,任何更改都有较高的损坏风险。通过适当的封装,包中的更改应该对现有用户不可见。

在对象级别,private 并不意味着它在其他语言中所起的作用,因此我们必须学会自己的行为。访问私有成员变量会使对象紧密耦合,这一决定会反过来影响我们

Go 的类型系统的一个我最喜欢的特性是能够将方法附加到任何东西上。假设您正在为运行状况检查编写 HTTP 处理程序。它只返回状态204(无内容)。我们需要满足的接口如下:

type Handler interface {
   ServeHTTP(ResponseWriter, *Request)
}

一个简单的实现可能如以下代码所示:

// a HTTP health check handler in long form
type healthCheck struct {
}

func (h *healthCheck) ServeHTTP(resp http.ResponseWriter, _ *http.Request) {
   resp.WriteHeader(http.StatusNoContent)
}

func healthCheckUsage() {
   http.Handle("/health", &healthCheckLong{})
}

我们可以创建一个新的结构来实现一个接口,但这至少需要五行代码。我们可以将其减少为三个,如下代码所示:

// a HTTP health check handler in short form
func healthCheck(resp http.ResponseWriter, _ *http.Request) {
  resp.WriteHeader(http.StatusNoContent)
}

func healthCheckUsage() {
  http.Handle("/health", http.HandlerFunc(healthCheck))
}

在本例中,秘方隐藏在标准库中。我们正在将函数转换为http.HandlerFunc类型,该类型附带了ServeHTTP方法。这个漂亮的小把戏让我们很容易满足http.Handler接口。正如我们在本章中已经看到的,朝接口的方向发展会使我们获得更易于维护和扩展的耦合更少的代码。

利斯科夫替换原理(LSP)

“如果对于类型 S 的每个对象 o1,有一个类型 T 的对象 o2,使得对于根据 T 定义的所有程序 P,当 o1 代替 o2 时,P 的行为不变,则 S 是 T 的子类型。” -芭芭拉·利斯科夫

在读了三遍之后,我仍然不确定我是否把它弄明白了。谢天谢地,Robert C.Martin 让我们更容易理解,并总结如下:

“子类型必须可以替换其基类型。” -罗伯特 C.马丁

我可以跟着。然而,他不是又在谈论抽象类了吗?可能正如我们在 OCP 一节中看到的,虽然 Go 没有抽象类或继承,但它有一个组合和接口实现。

让我们退一步,看看这个原则的动机。LSP 要求亚型可以相互替代。我们可以使用 Go 接口,这将始终适用。

但是等一下,这个代码呢:

func Go(vehicle actions) {
  if sled, ok := vehicle.(*Sled); ok {
    sled.pushStart()
  } else {
    vehicle.startEngine()
  }

  vehicle.drive()
}

type actions interface {
  drive()
  startEngine()
}

type Vehicle struct {
}

func (v Vehicle) drive() {
  // TODO: implement
}

func (v Vehicle) startEngine() {
  // TODO: implement
}

func (v Vehicle) stopEngine() {
  // TODO: implement
}

type Car struct {
  Vehicle
}

type Sled struct {
  Vehicle
}

func (s Sled) startEngine() {
  // override so that is does nothing
}

func (s Sled) stopEngine() {
  // override so that is does nothing
}

func (s Sled) pushStart() {
  // TODO: implement
}

它使用一个接口,但显然违反了 LSP。我们可以通过添加更多接口来解决此问题,如以下代码所示:

func Go(vehicle actions) {
   switch concrete := vehicle.(type) {
   case poweredActions:
      concrete.startEngine()

   case unpoweredActions:
      concrete.pushStart()
   }

   vehicle.drive()
}

type actions interface {
   drive()
}

type poweredActions interface {
   actions
   startEngine()
   stopEngine()
}

type unpoweredActions interface {
   actions
   pushStart()
}

type Vehicle struct {
}

func (v Vehicle) drive() {
   // TODO: implement
}

type PoweredVehicle struct {
   Vehicle
}

func (v PoweredVehicle) startEngine() {
   // common engine start code
}

type Car struct {
   PoweredVehicle
}

type Buggy struct {
   Vehicle
}

func (b Buggy) pushStart() {
   // do nothing
}

然而,这并不是更好。这段代码仍然有味道,这表明我们可能使用了错误的抽象或组合。让我们再次尝试重构:

func Go(vehicle actions) {
  vehicle.start()
  vehicle.drive()
}

type actions interface {
  start()
  drive()
}

type Car struct {
  poweredVehicle
}

func (c Car) start() {
  c.poweredVehicle.startEngine()
}

func (c Car) drive() {
  // TODO: implement
}

type poweredVehicle struct {
}

func (p poweredVehicle) startEngine() {
  // common engine start code
}

type Buggy struct {
}

func (b Buggy) start() {
  // push start
}

func (b Buggy) drive() {
  // TODO: implement
}

那好多了。Buggy这句话并没有强制执行毫无意义的方法,也没有包含任何不需要的逻辑,而且两种车型的使用都很好且干净。这说明了 LSP 的一个关键点:

LSP 指的是行为而不是实施

对象可以实现它喜欢的任何接口,但这并不意味着它在行为上与同一接口的其他实现一致。请看以下代码:

type Collection interface {
   Add(item interface{})
   Get(index int) interface{}
}

type CollectionImpl struct {
   items []interface{}
}

func (c *CollectionImpl) Add(item interface{}) {
   c.items = append(c.items, item)
}

func (c *CollectionImpl) Get(index int) interface{} {
   return c.items[index]
}

type ReadOnlyCollection struct {
   CollectionImpl
}

func (ro *ReadOnlyCollection) Add(item interface{}) {
   // intentionally does nothing
}

在前面的例子中,我们通过实现所有的方法满足了 API 合同(如交付),但我们将不需要的方法变成了不可操作的方法。通过让我们的ReadOnlyCollection实现Add()方法,它满足了接口,但引入了潜在的混淆。当您有一个接受Collection的函数时会发生什么?当您致电Add()时,您希望发生什么?

在这种情况下,修复方法可能会让你大吃一惊。我们可以将关系翻转过来,而不是将MutableCollection变成ImmutableCollection,如下代码所示:

type ImmutableCollection interface {
   Get(index int) interface{}
}

type MutableCollection interface {
   ImmutableCollection
   Add(item interface{})
}

type ReadOnlyCollectionV2 struct {
   items []interface{}
}

func (ro *ReadOnlyCollectionV2) Get(index int) interface{} {
   return ro.items[index]
}

type CollectionImplV2 struct {
   ReadOnlyCollectionV2
}

func (c *CollectionImplV2) Add(item interface{}) {
   c.items = append(c.items, item)
}

这种新结构的一个好处是,我们现在可以让编译器确保在需要MutableCollection的地方不使用ImmutableCollection

这与 DI 有什么关系?

通过遵循 LSP,无论我们注入的依赖项是什么,我们的代码都会一致地执行。另一方面,违反 LSP 会导致我们违反 OCP。这些冲突导致我们的代码对实现有太多的了解,这反过来破坏了注入依赖项的抽象。

这对围棋意味着什么?

当使用组合(尤其是未命名变量形式)来满足接口时,LSP 的应用与面向对象语言中的应用一样。

在实现接口时,我们可以使用 LSP 关注的一致行为作为一种检测与错误抽象相关的代码气味的方法。

接口隔离原则(ISP)

“不应强迫客户依赖他们不使用的方法。” -罗伯特·C·马丁

就我个人而言,我更喜欢一个更直接的定义——接口应该减少到尽可能小的尺寸

让我们首先讨论为什么胖接口可能是件坏事。Fat 接口有更多的方法,因此可能更难理解。它们还需要更多的工作来使用,无论是通过实现、模拟还是存根。

Fat 接口表示更多的责任,正如我们在 SRP 中看到的,一个对象的责任越大,它就越想改变。如果界面发生变化,它会对所有用户产生连锁反应,违反 OCP,并导致大量的鸟枪手术。这是 ISP 的第一个优势:

ISP 要求我们定义瘦接口

对于许多程序员来说,他们的自然趋势是添加到现有的接口,而不是定义一个新的接口,从而创建一个胖接口。这导致了这样一种情况:有时是单一的实现与接口的用户紧密耦合。这种耦合使得界面、它们的实现和用户都更难以改变。考虑下面的例子:

type FatDbInterface interface {
   BatchGetItem(IDs ...int) ([]Item, error)
   BatchGetItemWithContext(ctx context.Context, IDs ...int) ([]Item, error)

   BatchPutItem(items ...Item) error
   BatchPutItemWithContext(ctx context.Context, items ...Item) error

   DeleteItem(ID int) error
   DeleteItemWithContext(ctx context.Context, item Item) error

   GetItem(ID int) (Item, error)
   GetItemWithContext(ctx context.Context, ID int) (Item, error)

   PutItem(item Item) error
   PutItemWithContext(ctx context.Context, item Item) error

   Query(query string, args ...interface{}) ([]Item, error)
   QueryWithContext(ctx context.Context, query string, args ...interface{}) ([]Item, error)

   UpdateItem(item Item) error
   UpdateItemWithContext(ctx context.Context, item Item) error
}

type Cache struct {
   db FatDbInterface
}

func (c *Cache) Get(key string) interface{} {
   // code removed

   // load from DB
   _, _ = c.db.GetItem(42)

   // code removed
   return nil
}

func (c *Cache) Set(key string, value interface{}) {
   // code removed

   // save to DB
   _ = c.db.PutItem(Item{})

   // code removed
}

不难想象所有这些方法都属于一个结构。像GetItem()GetItemWithContext()这样的方法对很可能共享很多(如果不是几乎全部的话)相同的代码。另一方面,GetItem()的用户不太可能也使用GetItemWithContext()。对于这个特定用例,更合适的接口如下:

type myDB interface {
   GetItem(ID int) (Item, error)
   PutItem(item Item) error
}

type CacheV2 struct {
   db myDB
}

func (c *CacheV2) Get(key string) interface{} {
   // code removed

   // load from DB
   _, _ = c.db.GetItem(42)

   // code removed
   return nil
}

func (c *CacheV2) Set(key string, value interface{}) {
   // code removed

   // save from DB
   _ = c.db.PutItem(Item{})

   // code removed
}

利用这个新的瘦接口,函数签名更加明确和灵活。这让我们看到了 ISP 的第二个优势:

ISP 导致显式输入

瘦接口也更直接,更全面地实现,使我们远离 LSP 的任何潜在问题。

如果我们使用一个接口作为输入,并且该接口需要是 fat,这就有力地表明该方法违反了 SRP。考虑下面的代码:

func Encrypt(ctx context.Context, data []byte) ([]byte, error) {
   // As this operation make take too long, we need to be able to kill it
   stop := ctx.Done()
   result := make(chan []byte, 1)

   go func() {
      defer close(result)

      // pull the encryption key from context
      keyRaw := ctx.Value("encryption-key")
      if keyRaw == nil {
         panic("encryption key not found in context")
      }
      key := keyRaw.([]byte)

      // perform encryption
      ciperText := performEncryption(key, data)

      // signal complete by sending the result
      result <- ciperText
   }()

   select {
   case ciperText := <-result:
      // happy path
      return ciperText, nil

   case <-stop:
      // cancelled
      return nil, errors.New("operation cancelled")
   }
}

你看到问题了吗?我们使用的是context接口,这是非常棒的,强烈推荐,但我们违反了 ISP。作为实用主义程序员,我们可以说这个接口被广泛使用和理解,定义我们自己的接口以将其简化为我们需要的两种方法的价值是不必要的。在大多数情况下,我会同意,但在这一特殊情况下,我们应该重新考虑。我们使用context接口有两个完全不同的目的。第一个是控制通道,允许我们在短时间内停止或超时任务,第二个是提供一个值。事实上,我们在这里使用context违反了 SRP,因此有可能造成混乱,导致更大的变革阻力。

如果我们决定不在请求级别而是在应用程序级别使用停止通道模式,会发生什么?如果键值不在context中,而是来自其他来源,会发生什么情况?通过应用 ISP,我们可以将关注点分为两个接口,如下代码所示:

type Value interface {
   Value(key interface{}) interface{}
}

type Monitor interface {
   Done() <-chan struct{}
}

func EncryptV2(keyValue Value, monitor Monitor, data []byte) ([]byte, error) {
   // As this operation make take too long, we need to be able to kill it
   stop := monitor.Done()
   result := make(chan []byte, 1)

   go func() {
      defer close(result)

      // pull the encryption key from Value
      keyRaw := keyValue.Value("encryption-key")
      if keyRaw == nil {
         panic("encryption key not found in context")
      }
      key := keyRaw.([]byte)

      // perform encryption
      ciperText := performEncryption(key, data)

      // signal complete by sending the result
      result <- ciperText
   }()

   select {
   case ciperText := <-result:
      // happy path
      return ciperText, nil

   case <-stop:
      // cancelled
      return nil, errors.New("operation cancelled")
   }
}

我们的功能现在符合 ISP 的要求,两种输入都可以自由地单独发展。但是这个函数的用户会怎么样呢?他们必须停止使用context吗?绝对不是。可以调用该方法,如以下代码所示:

// create a context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// store the key
ctx = context.WithValue(ctx, "encryption-key", "-secret-")

// call the function
_, _ = EncryptV2(ctx, ctx, []byte("my data"))

反复使用context作为参数可能感觉有点奇怪,但正如你所看到的,这是为了一个好的理由。这使我们获得了 ISP 的最终优势:

ISP 帮助将输入与具体实现分离,使它们能够独立发展

这与 DI 有什么关系?

正如我们所看到的,ISP 帮助我们将接口分解为逻辑上独立的部分,每个部分都提供一个特定的功能,这一概念有时被称为角色接口。通过在 DI 中利用这些角色接口,我们的代码与输入的具体实现分离。

这种解耦不仅允许代码的各个部分单独演化,而且还使识别测试向量变得更容易。在前一个例子中,更容易逐一扫描输入,并考虑它们可能的值和状态。此过程可能会产生如下向量列表:

输入的测试向量包括

  • 快乐路径:返回有效值
  • 错误路径:返回空值

监视器输入的测试向量包括

  • 快乐路径:不返回完成信号
  • 错误路径:立即返回一个已完成的信号

这对围棋意味着什么?

第一章中,我们提到了Jack Lindamood创造的流行围棋成语—接受接口,返回结构。将这一想法与 ISP 结合起来,事情就开始起步了。结果函数非常简洁地描述了它们的需求,同时,它们的输出也非常明确。在其他语言中,我们可能必须以抽象的形式定义输出,或者创建适配器类来将我们的功能与用户完全解耦。然而,考虑到 Go 对隐式接口的支持,没有必要这样做。

隐式接口是一种语言特性,实现者(即结构)不需要定义它实现的接口,而只需要定义适当的方法来满足接口,如以下代码所示:

type Talker interface {
   SayHello() string
}

type Dog struct{}

// The method implicitly implements the Talker interface
func (d Dog) SayHello() string {
   return "Woof!"
}

func Speak() {
   var talker Talker
   talker = Dog{}

   fmt.Print(talker.SayHello())
}

这似乎是一个减少打字的巧妙方法,事实确实如此。然而,这并不是使用它的唯一原因。当使用显式接口时,实现对象在某种程度上与其依赖项耦合,因为它们之间存在相当明确的链接。然而,也许最重要的原因是简单。让我们来看看 Go 中最流行的界面之一,您可能从未听说过:

// Stringer is implemented by any value that has a String method, which 
// defines the “native” format for that value. The String method is used 
// to print values passed as an operand to any format that accepts a 
// string or to an unformatted printer such as Print.
type Stringer interface {
    String() string
}

此接口可能看起来不太令人印象深刻,但fmt包支持此接口的事实允许您执行以下操作:

func main() {
  kitty := Cat{}

  fmt.Printf("Kitty %s", kitty)
}

type Cat struct{}

// Implicitly implement the fmt.Stringer interface
func (c Cat) String() string {
  return "Meow!"
}

如果我们有显式接口,想象一下我们需要声明多少次来实现Stringer。在 Go 中,隐式接口给我们带来的最大优势可能是当它们与 ISP 和 DI 结合时。这三者的结合允许我们定义瘦的、特定于特定用例的、与其他一切分离的输入接口,正如我们在Stringer接口中看到的那样。

此外,在使用接口的包中定义接口会缩小处理一段代码所需的知识范围,从而使其更易于理解和测试。

依赖倒置原理(DIP)

“高级模块不应该依赖于低级模块。两者都应该依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象” –罗伯特·C·马丁

你有没有发现自己站在一家鞋店里,不知道该买棕色的还是黑色的,到家后却后悔自己的选择?可悲的是,一旦你买了,它们就是你的了。针对具体实现进行编程也是一样的:一旦你选择了,你就会被卡住,尽管有退款和重构。但为什么在你不需要的时候选择呢?查看下图所示的关系:

不是很灵活,是吗?让我们将关系转换为抽象:

那好多了。所有东西都只依赖于清晰的抽象,满足 LSP 和 ISP 的要求。这些包简洁明了,令人满意地满足了 SRP。代码甚至似乎满足罗伯特 C马丁**s描述了这次下陷,但遗憾的是,事实并非如此。正是这个讨厌的词在中间,倒转。

在我们的示例中,Shoes包拥有Shoe接口,这完全符合逻辑。但是,当需求发生变化时会出现问题。对Shoes包的更改可能会导致Shoe接口需要更改。这将反过来要求Person对象进行更改。我们添加到Shoe接口的任何新功能可能都不需要,也可能与Person对象无关。因此,Person对象仍然耦合到Shoe包。

为了完全打破这种耦合,我们需要将关系从使用鞋子改为需要,如下:

这里有两个关键点。首先,DIP 迫使我们关注抽象的所有权。在我们的示例中,这意味着将接口移动到使用它的包中,并将关系从使用更改为需要;这是一个微妙的区别,但却是一个重要的区别。

其次,DIP 鼓励我们将使用需求与实现分离。在我们的示例中,我们的Brown Shoes对象实现了Footwear,但不难想象还有更多的实现,有些甚至可能不是鞋。

这与 DI 有什么关系?

依赖项反转很容易被误认为是依赖项注入,很多人,包括我,长期以来都认为它们是等价的。但是正如我们所看到的,依赖项反转关注于依赖项抽象定义的所有权,而 DI 关注于使用这些抽象。

通过将 DIP 应用于 DI,我们最终得到了非常好的解耦包,这些包非常容易理解、易于扩展和易于测试。

这对围棋意味着什么?

我们之前讨论过 Go 对隐式接口的支持,以及如何利用它将依赖项定义为同一包中的接口,而不是从另一个包导入接口。这种方法很简单。

也许你内心的怀疑论者会发疯,大声喊道,*,但这意味着我必须在任何地方定义接口!*是的,可能是这样。它甚至可能导致少量的重复。但是,您会发现,如果没有依赖项反转,您将定义的接口将更胖、更笨拙,这一事实将使您在未来的工作中付出更多的代价。

应用 DIP 后,不太可能出现任何循环依赖性问题。事实上,您几乎肯定会发现代码中的导入数量显著减少,依赖关系图变得相当平坦。事实上,许多包装只会通过main包装进口。

总结

在这篇固体设计原则的简要介绍中,我们了解了它们如何不仅适用于 DI,而且适用于 Go。在本书第二部分中我们对各种 DI 方法的研究中,我们将经常参考这些原则。

在下一章中,我们将继续研究编码的各个方面,这些方面在学习和试验新技术时应该放在您的思想的最前沿。我还将向您介绍一些方便的工具,这些工具将使您的编码生活变得更加轻松。

问题

  1. 单一责任原则如何改进 Go 代码?
  2. 打开/关闭原则如何改进 Go 代码?
  3. liskov 替换原理如何改进 Go 代码?
  4. 接口隔离原则如何改进 Go 代码?
  5. 依赖倒置原则如何改进 Go 代码?
  6. 依赖倒置与依赖注入有何不同?

进一步阅读

Packt 还有许多其他优秀资源可用于学习坚实的原则: