Go-依赖注入实用指南(全)

Go 依赖注入实用指南(全)

原文:zh.annas-archive.org/md5/87633C3DBA89BFAAFD7E5238CC73EA73

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你好!这本书旨在介绍如何在 Go 语言中进行依赖注入。也许你会惊讶地发现,在 Go 语言中有许多不同的方法可以应用依赖注入,在本书中,我们将讨论六种不同的方法,有时它们还可以相互补充。

依赖注入,像许多软件工程概念一样,很容易被误解,因此本文试图解决这个问题。它深入探讨了相关概念,如 SOLID 原则、代码异味和测试诱导的破坏,以便提供更广泛和更实用的视角。

《Go 语言依赖注入实战》的目标不仅是教会你如何应用依赖注入,还有何时、何地以及何时不应该应用。每种方法都有明确定义;我们讨论它的优缺点,以及何时最适合应用该方法。此外,每种方法都会使用重要的示例逐步应用。

尽管我非常喜欢依赖注入,但它并不总是适合所有情况。这本书还将帮助你发现应用依赖注入可能不是最佳选择的情况。

在介绍每种依赖注入方法时,我会请你停下来,退后一步,考虑以下问题。这种技术试图解决什么问题?在你应用这种方法后,你的代码会是什么样子?如果这些问题的答案不会很快出现,不要担心;到本书结束时,它们会出现的。

愉快的编码!

这本书适合谁

这本书适用于希望他们的代码易于阅读、测试和维护的开发人员。它适用于来自面向对象背景的开发人员,他们希望更多地了解 Go,以及相信高质量代码不仅仅是交付一个特定功能的开发人员。

毕竟,编写代码很容易。同样,让单个测试用例通过也很简单。创建代码,使得测试在添加额外功能的几个月或几年后仍然通过,这几乎是不可能的。

为了能够持续地以这个水平交付代码,我们需要很多巧妙的技巧。这本书希望不仅能够装备你这些技巧,还能够给你应用它们的智慧。

为了充分利用这本书

尽管依赖注入和本书中讨论的许多其他编程概念并不简单或直观,但本书在假定很少的知识的情况下介绍它们。

也就是说,我们假设以下内容:

  • 你具有构建和测试 Go 代码的基本经验。

  • 由于之前使用 Go 或面向对象的语言(如 Java 或 Scala)的经验,你对对象/类的概念感到舒适。

此外,至少对构建和使用基于 HTTP 的 REST API 有一定的了解会很有益。在第四章中,《ACME 注册服务简介》,我们将介绍一个示例 REST 服务,它将成为本书许多示例的基础。为了能够运行这个示例服务,你需要在开发环境中安装和配置 MySQL 数据库服务,并能够自定义提供的配置以匹配你的本地环境。本书提供的所有命令都是在 OSX 下开发和测试的,并且应该可以在任何基于 Linux 或 Unix 的系统上无需修改地工作。使用基于 Windows 的开发环境的开发人员需要在运行这些命令之前进行调整。

下载示例代码文件

你可以从www.packt.com的账户中下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。

你可以通过以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/Bookname_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都以以下方式编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"从管理面板中选择系统信息。"

警告或重要提示会以这种方式出现。

提示和技巧会以这种方式出现。

第一章:永远不要停止追求更好

你想要更容易维护的代码吗?更容易测试吗?更容易扩展吗?依赖注入DI)可能正是你需要的工具。

在本章中,我们将以一种有点非典型的方式定义 DI,并探讨可能表明你需要 DI 的代码异味。我们还将简要讨论 Go 以及我希望你如何对待本书中提出的想法。

你准备好和我一起踏上更好的 Go 代码之旅了吗?

我们将涵盖以下主题:

  • DI 为什么重要?

  • 什么是 DI?

  • 何时应用 DI?

  • 我如何作为 Go 程序员改进?

技术要求

希望你已经安装了 Go。它可以从golang.org/ 或你喜欢的软件包管理器下载。

本章中的所有代码都可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch01找到。

DI 为什么重要?

作为专业人士,我们永远不应该停止学习。学习是确保我们保持需求并继续为客户提供价值的唯一真正途径。医生、律师和科学家都是备受尊敬的专业人士,他们都专注于不断学习。为什么程序员应该有所不同呢?

在本书中,我们将开始一段旅程,从一些完成工作的代码开始,然后通过有选择地应用 Go 中可用的各种 DI 方法,我们将把它转变成更容易维护、测试和扩展的东西。

本书中并非所有内容都是传统的,甚至可能不是惯用的,但我希望你在否定之前尝试一下。如果你喜欢,太棒了。如果不喜欢,至少你学到了你不想做什么。

那么,我如何定义 DI?

DI 是以这样的方式编码,使得我们依赖的资源(即函数或结构)是抽象的。因为这些依赖是抽象的,对它们的更改不需要更改我们的代码。这个花哨的词是解耦

这里使用的抽象一词可能有点误导。我不是指像 Java 中那样的抽象类;Go 没有那个。不过,Go 确实有接口和函数文字(也称为闭包)。

考虑以下接口的例子和使用它的SavePerson()函数:

// Saver persists the supplied bytes
type Saver interface {
  Save(data []byte) error
}

// SavePerson will validate and persist the supplied person
func SavePerson(person *Person, saver Saver) error {
  // validate the inputs
  err := person.validate()
  if err != nil {
    return err
  }

  // encode person to bytes
  bytes, err := person.encode()
  if err != nil {
    return err
  }

  // save the person and return the result
  return saver.Save(bytes)
}

// Person data object
type Person struct {
   Name  string
   Phone string
}

// validate the person object
func (p *Person) validate() error {
   if p.Name == "" {
      return errors.New("name missing")
   }

   if p.Phone == "" {
      return errors.New("phone missing")
   }

   return nil
}

// convert the person into bytes
func (p *Person) encode() ([]byte, error) {
   return json.Marshal(p)
}

在前面的例子中,Saver是做什么的?它在某个地方保存一些bytes。它是如何做到的?我们不知道,在编写SavePerson函数时,我们也不关心。

让我们看另一个使用函数文字的例子

// LoadPerson will load the requested person by ID.
// Errors include: invalid ID, missing person and failure to load 
// or decode.
func LoadPerson(ID int, decodePerson func(data []byte) *Person) (*Person, error) {
  // validate the input
  if ID <= 0 {
    return nil, fmt.Errorf("invalid ID '%d' supplied", ID)
  }

  // load from storage
  bytes, err := loadPerson(ID)
  if err != nil {
    return nil, err
  }

  // decode bytes and return
  return decodePerson(bytes), nil
}

decodePerson是做什么的?它将bytes转换为一个人。怎么做?我们现在不需要知道。

这是我要向你强调的 DI 的第一个优点:

DI 通过以抽象或通用的方式表达依赖关系,减少了在处理一段代码时所需的知识

现在,假设前面的代码来自一个将数据存储在网络文件共享NFS)中的系统。我们如何为此编写单元测试?始终访问 NFS 将是一种痛苦。由于完全不相关的问题,例如网络连接问题,任何此类测试也会比应该更频繁地失败。

另一方面,通过依赖于抽象,我们可以用虚假代码替换保存到 NFS 的代码。这样,我们只测试我们的代码与 NFS 隔离的情况,如下面的代码所示:

func TestSavePerson_happyPath(t *testing.T) {
   // input
   in := &Person{
      Name:  "Sophia",
      Phone: "0123456789",
   }

   // mock the NFS
   mockNFS := &mockSaver{}
   mockNFS.On("Save", mock.Anything).Return(nil).Once()

   // Call Save
   resultErr := SavePerson(in, mockNFS)

   // validate result
   assert.NoError(t, resultErr)
   assert.True(t, mockNFS.AssertExpectations(t))
}

不要担心前面的代码看起来陌生;我们将在本书的后面深入研究所有部分。

这带我们来到 DI 的第二个优点:

DI 使我们能够在不依赖于我们的依赖关系的情况下测试我们的代码

考虑前面的例子,我们如何测试我们的错误处理代码?我们可以通过一些外部脚本关闭 NFS,每次运行测试时,但这可能会很慢,肯定会惹恼依赖它的其他人。

另一方面,我们可以快速制作一个总是失败的假Saver,如下所示:

func TestSavePerson_nfsAlwaysFails(t *testing.T) {
   // input
   in := &Person{
      Name:  "Sophia",
      Phone: "0123456789",
   }

   // mock the NFS
   mockNFS := &mockSaver{}
   mockNFS.On("Save", mock.Anything).Return(errors.New("save failed")).Once()

   // Call Save
   resultErr := SavePerson(in, mockNFS)

   // validate result
   assert.Error(t, resultErr)
   assert.True(t, mockNFS.AssertExpectations(t))
}

上面的测试快速、可预测、可靠。这是我们测试中想要的一切!

这给了我们 DI 的第三个优势:

DI 使我们能够快速、可靠地测试其他情况

不要忘记 DI 的传统销售点。如果明天我们决定将保存到 NoSQL 数据库而不是我们的 NFS,我们的SavePerson代码将如何改变?一点也不。我们只需要编写一个新的Saver实现,这给了我们 DI 的第四个优势:

DI 减少了扩展或更改的影响

归根结底,DI 是一个工具——一个方便的工具,但不是魔法子弹。它是一个可以使代码更容易理解、测试、扩展和重用的工具,也可以帮助减少常常困扰新 Go 开发人员的循环依赖问题。

表明您可能需要 DI 的代码气味

俗话说“对于只有一把锤子的人来说,每个问题都像一颗钉子”,这句话虽然古老,但在编程中却从未比现在更真实。作为专业人士,我们应该不断努力获取更多的工具,以便更好地应对工作中遇到的任何问题。DI 虽然是一个非常有用的工具,但只对特定的问题有效。在我们的情况下,这些问题是代码气味。代码气味是代码中潜在更深层问题的指示。

有许多不同类型的代码气味;在本节中,我们将仅讨论那些可以通过 DI 缓解的气味。在后面的章节中,我们将在试图从我们的代码中消除它们时引用这些气味。

代码气味通常可以分为四个不同的类别:

  • 代码膨胀

  • 对变化的抵抗

  • 徒劳的努力

  • 紧耦合

代码膨胀

代码膨胀的气味是指已经添加到结构体或函数中的笨重代码块,使得它们变得难以理解、维护和测试。在旧代码中经常发现,它们往往是逐渐恶化和缺乏维护的结果,而不是有意的选择。

它们可以通过对源代码进行视觉扫描或使用循环复杂度检查器(指示代码复杂性的软件度量标准)来发现,例如 gocyclo(github.com/fzipp/gocyclo)。

这些气味包括以下内容:

  • 长方法:虽然代码是在计算机上运行的,但是它是为人类编写的。任何超过 30 行的方法都应该分成更小的块。虽然对计算机没有影响,但对我们人类来说更容易理解。

  • 长结构体:与长方法类似,结构体越长,就越难理解,因此也更难维护。长结构体通常也表明结构体做得太多。将一个结构体分成几个较小的结构体也是增加代码可重用性潜力的好方法。

  • 长参数列表:长参数列表也表明该方法可能做了太多的事情。在添加新功能时,很容易向现有函数添加新参数,以适应新的用例。这是一个很危险的斜坡。这个新参数要么对现有用例是可选的/不必要的,要么表明方法的复杂性显著增加。

  • 长条件块:Switch 语句很棒。问题在于它们很容易被滥用,而且往往像谚语中的兔子一样繁殖。然而,最重要的问题可能是它们对代码的可读性的影响。长条件块占用大量空间,打断了函数的可读性。考虑以下代码:

func AppendValue(buffer []byte, in interface{}) []byte{
   var value []byte

   // convert input to []byte
   switch concrete := in.(type) {
   case []byte:
      value = concrete

   case string:
      value = []byte(concrete)

   case int64:
      value = []byte(strconv.FormatInt(concrete, 10))

   case bool:
      value = []byte(strconv.FormatBool(concrete))

   case float64:
      value = []byte(strconv.FormatFloat(concrete, 'e', 3, 64))
   }

   buffer = append(buffer, value...)
   return buffer
}

通过将interface{}作为输入,我们几乎被迫使用类似这样的开关。我们最好改为从interface{}改为接口,然后向接口添加必要的操作。这种方法在标准库中的json.Marshallerdriver.Valuer接口中得到了很好的说明。

将 DI 应用于这些问题通常会通过将其分解为更小的、独立的部分来减少代码的复杂性,从而使其更易于理解、维护和测试。

对变化的抵抗

这些情况下很难和/或缓慢地添加新功能。同样,测试通常更难编写,特别是对于失败条件的测试。与代码膨胀类似,这些问题可能是逐渐恶化和缺乏维护的结果,但也可能是由于缺乏前期规划或糟糕的 API 设计引起的。

它们可以通过检查拉取请求日志或提交历史来找到,特别是确定新功能是否需要在代码的不同部分进行许多小的更改。

如果您的团队跟踪功能速度,并且您注意到它在下降,这也可能是一个原因。

这些问题包括以下内容:

  • 散弹手术:这是指对一个结构体进行的小改动需要改变其他结构体。这些变化意味着使用的组织或抽象是不正确的。通常,所有这些更改应该在一个类中。

在下面的例子中,您可以看到向人员数据添加电子邮件字段将导致更改所有三个结构体(PresenterValidatorSaver):

// Renderer will render a person to the supplied writer
type Renderer struct{}

func (r Renderer) render(name, phone string, output io.Writer) {
  // output the person
}

// Validator will validate the supplied person has all the 
// required fields
type Validator struct{}

func (v Validator) validate(name, phone string) error {
  // validate the person
  return nil
}

// Saver will save the supplied person to the DB
type Saver struct{}

func (s *Saver) Save(db *sql.DB, name, phone string) {
  // save the person to db
}
  • 泄漏实现细节:Go 社区中更受欢迎的习语之一是接受接口,返回结构体。这是一个引人注目的短语,但它的简单性掩盖了它的巧妙之处。当一个函数接受一个结构体时,它将用户与特定的实现联系在一起,这种严格的关系使得未来的更改或附加使用变得困难。此外,如果实现细节发生变化,API 也会发生变化,并迫使用户进行更改。

将 DI 应用于这些问题通常是对未来的良好投资。虽然不修复它们不会致命,但代码将逐渐恶化,直到你处理谚语中的大泥球。你知道这种类型——一个没有人理解、没有人信任的包,只有勇敢或愚蠢的人愿意进行更改。DI 使您能够脱离实现选择,从而更容易地重构、测试和维护代码的小块。

浪费的努力

这些问题是代码维护成本高于必要成本的情况。它们通常是由懒惰或缺乏经验引起的。复制/粘贴代码总是比仔细重构代码更容易。问题是,像这样编码就像吃不健康的零食。在当时感觉很棒,但长期后果很糟糕。

它们可以通过对源代码进行批判性审视并问自己我真的需要这段代码吗?或者我能让这更容易理解吗?来找到。

使用诸如 dupl (github.com/mibk/dupl)或 PMD (pmd.github.io/)之类的工具也将帮助您识别需要调查的代码区域。

这些问题包括以下内容:

  • 过多的重复代码:首先,请不要对此变得过分狂热。虽然在大多数情况下,重复的代码是一件坏事,但有时复制代码可以导致一个更容易维护和发展的系统。我们将在第八章中处理这种问题的常见来源,通过配置进行依赖注入

  • 过多的注释:为后来的人留下一条便签,即使只有 6 个月后的自己,也是一件友好和专业的事情。但当这个注释变成一篇文章时,就是重构的时候了。

// Excessive comments
func outputOrderedPeopleA(in []*Person) {
  // This code orders people by name.
  // In cases where the name is the same, it will order by 
  // phone number.
  // The sort algorithm used is a bubble sort
  // WARNING: this sort will change the items of the input array
  for _, p := range in {
    // ... sort code removed ...
  }

  outputPeople(in)
}

// Comments replaced with descriptive names
func outputOrderedPeopleB(in []*Person) {
  sortPeople(in)
  outputPeople(in)
}
  • 过于复杂的代码:代码越难让其他人理解,它就越糟糕。通常,这是某人试图过于花哨或者没有花足够的精力在结构或命名上的结果。从更自私的角度来看,如果只有你一个人能理解一段代码,那么只有你能够处理它。也就是说,你注定要永远维护它。以下代码是做什么的:
for a := float64(0); a < 360; a++ {
   ra := math.Pi * 2 * a / 360
   x := r*math.Sin(ra) + v
   y := r*math.Cos(ra) + v
   i.Set(int(x), int(y), c)
}
  • DRY/WET 代码不要重复自己(DRY)原则旨在通过将责任分组并提供清晰的抽象来减少重复的工作。相比之下,在 WET 代码中,有时也被称为浪费每个人的时间代码,你会发现同样的责任出现在许多地方。这种气味通常出现在格式化或转换代码中。这种代码应该存在于系统边界,也就是说,转换用户输入或格式化输出。

虽然许多这些气味可以在没有依赖注入的情况下修复,但依赖注入提供了一种更容易的方式来将重复的工作转移到一个抽象中,然后可以用来减少重复和提高代码的可读性和可维护性。

紧耦合

对于人来说,紧耦合可能是一件好事。但对于 Go 代码来说,真的不是。耦合是衡量对象之间关系或依赖程度的指标。当存在紧耦合时,这种相互依赖会迫使对象或包一起发展,增加了复杂性和维护成本。

耦合相关的气味可能是最隐匿和顽固的,但处理起来也是最有回报的。它们通常是由于缺乏面向对象设计或接口使用不足造成的。

遗憾的是,我没有一个方便的工具来帮助你找到这些气味,但我相信,在本书结束时,你将毫无困难地发现并处理它们。

经常情况下,我发现先以紧密耦合的形式实现一个功能,然后逐步解耦并彻底单元测试我的代码,然后再提交,这对我来说是特别有帮助的,尤其是在正确的抽象不明显的情况下。

这些气味包括以下内容:

  • 依赖于上帝对象:这些是知道太多做太多的大对象。虽然这是一种普遍的代码气味,应该像瘟疫一样避免,但从依赖注入的角度来看,问题在于太多的代码依赖于这个对象。当它们存在并且我们不小心时,很快 Go 就会因为循环依赖而拒绝编译。有趣的是,Go 认为依赖和导入不是在对象级别,而是在包级别。因此,我们也必须避免上帝包。我们将在第八章中解决一个非常常见的上帝对象问题,通过配置进行依赖注入

  • 循环依赖:这是指包 A 依赖于包 B,包 B 又依赖于包 A。这是一个容易犯的错误,有时很难摆脱。

在下面的例子中,虽然配置可以说是一个上帝对象,因此是一种代码气味,但我很难找到更好的方法来从一个单独的 JSON 文件中导入配置。相反,我会认为需要解决的问题是orders包对config包的使用。一个典型的上帝配置对象如下:

package config

import ...

// Config defines the JSON format of the config file
type Config struct {
   // Address is the host and port to bind to.  
   // Default 0.0.0.0:8080
   Address string

   // DefaultCurrency is the default currency of the system
   DefaultCurrency payment.Currency
}

// Load will load the JSON config from the file supplied
func Load(filename string) (*Config, error) {
   // TODO: load currency from file
   return nil, errors.New("not implemented yet")
}

在对config包的尝试使用中,你可以看到Currency类型属于Package包,因此在config中包含它,如前面的例子所示,会导致循环依赖:

package payment

import ...

// Currency is custom type for currency
type Currency string

// Processor processes payments
type Processor struct {
   Config *config.Config
}

// Pay makes a payment in the default currency
func (p *Processor) Pay(amount float64) error {
   // TODO: implement me
   return errors.New("not implemented yet")
}
  • 对象混乱:当一个对象对另一个对象的内部知识和/或访问过多时,或者换句话说,对象之间的封装不足。因为这些对象紧密耦合,它们经常需要一起发展,增加了理解代码和维护代码的成本。考虑以下代码:
type PageLoader struct {
}

func (o *PageLoader) LoadPage(url string) ([]byte, error) {
   b := newFetcher()

   // check cache
   payload, err := b.cache.Get(url)
   if err == nil {
      // found in cache
      return payload, nil
   }

   // call upstream
   resp, err := b.httpClient.Get(url)
   if err != nil {
      return nil, err
   }
   defer resp.Body.Close()

   // extract data from HTTP response
   payload, err = ioutil.ReadAll(resp.Body)
   if err != nil {
      return nil, err
   }

   // save to cache asynchronously
   go func(key string, value []byte) {
      b.cache.Set(key, value)
   }(url, payload)

   // return
   return payload, nil
}

type Fetcher struct {
   httpClient http.Client
   cache      *Cache
}

在这个例子中,PageLoader重复调用Fetcher的成员变量。以至于,如果Fetcher的实现发生了变化,PageLoader很可能会受到影响。在这种情况下,这两个对象应该合并在一起,因为PageLoader没有额外的功能。

  • Yo-yo problem:这种情况的标准定义是当继承图如此漫长和复杂以至于程序员不得不不断地翻阅代码才能理解它。鉴于 Go 没有继承,你可能会认为我们不会遇到这个问题。然而,如果你努力尝试,通过过度的组合是可能的。为了解决这个问题,最好保持关系尽可能浅和抽象。这样,我们在进行更改时可以集中在一个更小的范围内,并将许多小对象组合成一个更大的系统。

  • Feature envy:当一个函数广泛使用另一个对象时,它就是嫉妒它。通常,这表明该函数应该从它所嫉妒的对象中移开。DI 可能不是解决这个问题的方法,但这种情况表明高耦合,因此是考虑应用 DI 技术的指标:

func doSearchWithEnvy(request searchRequest) ([]searchResults, error) {
   // validate request
   if request.query == "" {
      return nil, errors.New("search term is missing")
   }
   if request.start.IsZero() || request.start.After(time.Now()) {
      return nil, errors.New("start time is missing or invalid")
   }
   if request.end.IsZero() || request.end.Before(request.start) {
      return nil, errors.New("end time is missing or invalid")
   }

   return performSearch(request)
}

func doSearchWithoutEnvy(request searchRequest) ([]searchResults, error) {
   err := request.validate()
   if err != nil {
      return nil, err
   }

   return performSearch(request)
}

当你的代码变得不那么耦合时,你会发现各个部分(包、接口和结构)会变得更加专注。这被称为高内聚。低耦合和高内聚都是可取的,因为它们使代码更容易理解和处理。

健康的怀疑。

当我们阅读本书时,你将看到一些很棒的编码技巧,也会看到一些不太好的。我希望你花一些时间思考哪些是好的,哪些是不好的。持续学习应该与健康的怀疑相结合。对于每种技术,我会列出其利弊,但我希望你能深入思考。问问自己以下问题:

  • 这种技术试图实现什么?

  • 我应用这种技术后,我的代码会是什么样子?

  • 我真的需要它吗?

  • 使用这种方法有什么不利之处吗?

即使你内心的怀疑者否定了这种技术,你至少学会了识别自己不喜欢并且不想使用的东西,而学习总是一种胜利。

关于符合 Go 的惯例的简短说明

我个人尽量避免使用术语符合 Go 的惯例,但是一本 Go 书在某种程度上没有涉及它是不完整的。我避免使用它,因为我经常看到它被用来打击人。基本上,这不是符合惯例的,因此是错误的,并且由此推论,我是符合惯例的,因此比你更好。我相信编程是一门手艺,虽然手艺在应用中应该有一定的一致性,但是,就像所有手艺一样,它应该是灵活的。毕竟,创新通常是通过弯曲或打破规则来实现的。

那么对我来说,符合 Go 的惯例意味着什么?

我会尽量宽泛地定义它:

  • 使用gofmt格式化你的代码:对我们程序员来说,真的少了一件要争论的事情。这是官方的风格,由官方工具支持。让我们找一些更实质性的事情来争论。

  • 阅读,应用,并定期回顾《Effective Go》(golang.org/doc/effective_go.html)和《Code Review Comments》(github.com/golang/go/wiki/CodeReviewComments)中的想法:这些页面中包含了大量的智慧,以至于可能不可能仅通过一次阅读就能全部领会。

  • 积极应用Unix 哲学:它规定我们应该设计代码只做一件事,但要做得很好,并且与其他代码很好地协同工作**。

虽然对我来说,这三件事是最低限度的,但还有一些其他的想法也很有共鸣:

  • 接受接口并返回结构体:虽然接受接口会导致代码解耦,但返回结构体可能会让你感到矛盾。我知道一开始我也是这样认为的。虽然输出接口可能会让你感觉它更松散耦合,但实际上并不是。输出只能是一种东西——无论你编码成什么样。如果需要,返回接口是可以的,但强迫自己这样做最终只会让你写更多的代码。

  • 合理的默认值:自从转向 Go 以来,我发现许多情况下我想要为用户提供配置模块的能力,但这样的配置通常不被使用。在其他语言中,这可能会导致多个构造函数或很少使用的参数,但通过应用这种模式,我们最终得到了一个更清晰的 API 和更少的代码来维护。

把你的包袱留在门口

如果你问我新手 Go 程序员最常犯的错误是什么?我会毫不犹豫地告诉你,那就是将其他语言的模式带入 Go 中。我知道这是我最初的最大错误。我的第一个 Go 服务看起来像是用 Go 编写的 Java 应用程序。结果不仅是次等的,而且相当痛苦,特别是当我试图实现诸如继承之类的东西时。我在使用Node.js中以函数式风格编程 Go 时也有类似的经历。

简而言之,请不要这样做。重新阅读Effective Go和 Go 博客,直到您发现自己使用小接口、毫不犹豫地启动 Go 例程、喜欢通道,并想知道为什么您需要的不仅仅是组合来实现良好的多态性。

总结

在本章中,我们开始了一段旅程——这段旅程将导致更容易维护、扩展和测试的代码。

我们首先定义了 DI,并检查了它可以给我们带来的一些好处。通过一些例子的帮助,我们看到了这在 Go 中可能是什么样子。

之后,我们开始识别需要注意的代码异味,并通过应用 DI 来解决或减轻这些问题。

最后,我们研究了我认为 Go 代码是什么样子的,并向您提出质疑,对本书中提出的技术持怀疑态度。

问题

  1. 什么是 DI?

  2. DI 的四个突出优势是什么?

  3. 它解决了哪些问题?

  4. 为什么持怀疑态度很重要?

  5. 对你来说,惯用的 Go 是什么意思?

进一步阅读

Packt 还有许多其他关于 DI 和 Go 的学习资源。

第二章:Go 的 SOLID 设计原则

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

在本章中,我们将简要讨论这些原则,它们与依赖注入(DI)的关系以及对 Go 意味着什么。SOLID 是五个流行的面向对象软件设计原则的首字母缩写:

  • 单一责任原则

  • 开闭原则

  • Liskov 替换原则

  • 接口隔离原则

  • 依赖反转原则

技术要求

本章的唯一要求是对对象和接口有基本的了解,并持开放的态度。

本章中的所有代码都可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch02找到。

您将在本章结束时的进一步阅读部分中找到本章中提到的其他信息和参考链接。

单一责任原则(SRP)

“一个类应该有一个,且仅有一个,变化的原因。”

–Robert C. Martin

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。

这对 Go 意味着什么?

在第一章中,永远不要停止追求更好,我们提到了 Go 与 Unix 哲学的关系,即我们应该设计代码只做一件事,但要做得很好,并且与其他代码很好地协同工作。应用 SRP 后,我们的对象将完全符合这一原则。

Go 接口、结构和函数

在接口和结构级别应用 SRP 会产生许多小接口。符合 SRP 的函数输入少,代码相当短(即不到一屏的代码)。这两个特点本质上解决了我们在第一章中提到的代码膨胀问题。

通过解决代码膨胀问题,我们发现 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 合同不太可能改变,而内部实现可能会改变。

Go 包

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

这些抽象很好而且清晰;然而,当我们的服务有多个端点时,问题开始出现。我们很快就会得到充满完全无关逻辑的庞大包。另一方面,良好的包应该是小巧、简洁且目的明确的。

找到正确的抽象可能很困难。通常,当我需要灵感时,我会求助于专家,并检查标准的 Go 库。例如,让我们来看看encoding包:

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

我们最初的抽象是正确的,只是从太高的层次开始。

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

另一个常见的诱惑是将新代码添加到现有代码旁边。让我们想象一下,我们正在编写先前提到的encoding包,我们制作的第一个编码器是 JSON 编码器。接下来,我们添加了 GobEncoder,一切都进行得很顺利。再添加几个编码器,突然间我们有了一个大量代码和大量导出 API 的实质性包。在某个时候,我们的encoding包的文档变得如此之长,以至于用户很难跟踪。同样地,我们的包中有如此多的代码,以至于我们的扩展和调试工作变慢,因为很难找到东西。

SRP 帮助我们确定更改的原因;多个更改原因表示多个责任。解耦这些责任使我们能够开发更好的抽象。

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

开闭原则(OCP)

"软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。"

  • Bertrand Meyer

术语开放封闭在讨论软件工程时并不是我经常听到的,所以也许需要做一些解释。

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

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

当我们谈论封闭时,我们指的是现有的代码,以及最小化我们对其进行的更改,特别是被他人使用的 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语句中。很容易想象情况会发生变化,我们可能需要添加或甚至删除输出格式。

如果我们需要添加另一个格式,需要改变多少?请看下面:

  • 我们需要在switch中添加另一个 case 条件:这个方法已经有 18 行长了;在我们无法在一个屏幕上看到所有内容之前,我们需要添加多少个格式?这个switch语句还存在于多少其他地方?它们也需要更新吗?

  • 我们需要编写另一个格式化函数:这是三个不可避免的变化之一

  • 方法的调用者必须更新以使用新格式:这是另一个不可避免的变化

  • 我们需要添加另一组测试场景以匹配新的格式:这也是不可避免的;然而,这里的测试可能会比仅测试独立格式化要长

开始作为一个小而简单的改变,现在开始感觉比我们预期的更艰难和风险。

让我们用一个抽象替换格式输入参数和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 可以帮助减少添加或删除功能所需的更改数量

此外,如果在添加新格式化程序后,新结构中出现了错误,那么错误只会出现在一个地方——新代码中。这是 OCP 的第三个优势:

OCP 将错误的局部性缩小到仅限于新代码及其使用

让我们看另一个例子,我们不会应用 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 有什么关系?

在第一章中,永远不要停止追求更好,我们将 DI 定义为以依赖于抽象的方式编码。通过使用 OCP,我们可以发现更清晰和更持久的抽象。

这对 Go 意味着什么?

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

抽象类到底是什么?它实际上试图实现什么?

它试图提供一个用于多个实现之间共享代码的地方。我们可以在 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 的部分被导出或被他人使用。我们不能期望内部实现细节,包括私有成员变量,永远不会改变。实现这一点的最佳方法是隐藏这些实现细节。这就是封装

在包级别上,封装很简单:我们将其设为私有。在这里的一个很好的经验法则是,将所有东西都设为私有,只有在真正需要时才将其设为公共。再次,我的理由是风险和工作的避免。一旦你导出了某些东西,就意味着有人可能依赖它。一旦他们依赖它,它就应该变成封闭的;你必须维护它,任何更改都有更高的风险会破坏某些东西。通过适当的封装,包内的更改应该对现有用户是不可见的。

在对象级别上,私有并不意味着在其他语言中的意思,所以我们必须学会自律。访问私有成员变量会使对象紧密耦合,这个决定将会给我们带来麻烦。

我最喜欢 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 的子类型。”

-芭芭拉·里斯科夫

读了三遍之后,我仍然不确定我是否理解正确。幸运的是,罗伯特 C.马丁为我们总结了如下:

“子类型必须可以替换其基类型。”

-罗伯特 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 合同,但我们将不需要的方法转换为 NO-OP。通过让我们的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。这些违规行为使我们的代码对实现有太多的了解,从而打破了注入依赖的抽象。

这对 Go 有什么意义?

在使用组合,特别是未命名变量形式来满足接口时,LSP 的应用方式与面向对象语言中的应用方式一样。

在实现接口时,我们可以利用 LSP 对一致的行为的关注,作为检测与不正确的抽象相关的代码异味的一种方式。

接口隔离原则(ISP)

“客户端不应被强迫依赖他们不使用的方法。”

–Robert C. Martin

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

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

臃肿的接口表明更多的责任,正如我们在 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 相关的潜在问题。

在使用接口作为输入并且接口需要臃肿的情况下,这是方法违反 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 中利用这些角色接口,我们的代码与输入的具体实现解耦。

这种解耦不仅允许代码的各个部分分别演化,而且往往更容易识别测试向量。在前面的例子中,逐个扫描输入并考虑它们可能的值和状态更容易。这个过程可能会导致一个类似下面的向量列表:

** value 输入的测试向量包括**:

  • 正常路径:返回一个有效值

  • 错误路径:返回一个空值

** monitor 输入的测试向量包括**:

  • 正常路径:不返回完成信号

  • 错误路径:立即返回完成信号

这对 Go 意味着什么?

在第一章中,我们提到了由Jack Lindamood创造的流行 Go 成语——接受接口,返回结构体。将这个想法与 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。代码甚至似乎满足Robert C. Martin对 DIP 的描述,但遗憾的是,它并没有。中间那个讨厌的词,反转。

在我们的例子中,Shoes包拥有Shoe接口,这是完全合理的。然而,当需求发生变化时就会出现问题。对Shoes包的更改可能会导致Shoe接口发生变化。这将进而要求Person对象发生变化。我们添加到Shoe接口的任何新功能可能不需要或与Person对象无关。因此,Person对象仍然与Shoe包耦合。

为了完全打破这种耦合,我们需要将关系从Person使用 Shoe 更改为Person需要Footwear,就像这样:

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

其次,DIP 鼓励我们将使用要求与实现解耦。在我们的例子中,我们的Brown Shoes对象实现了Footwear,但很容易想象有更多的实现,有些甚至可能不是鞋子。

这与 DI 有什么关系?

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

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

这对 Go 意味着什么?

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

也许你内心的怀疑者正在疯狂地大喊,“但这意味着我到处都要定义接口!”是的,这可能是真的。这甚至可能导致一些重复。然而,你会发现,没有依赖倒置的情况下你定义的接口会更加臃肿和难以控制,这个事实将会在未来给你带来更多的工作成本。

应用 DIP 后,你不太可能遇到任何循环依赖的问题。事实上,你几乎肯定会发现你的代码中导入的数量显著减少,你的依赖图变得相当扁平。事实上,许多包只会被main包导入。

总结

在这个对 SOLID 设计原则的简要介绍中,我们了解到它们不仅适用于 DI,还适用于 Go。在本书第二部分对各种 DI 方法的讨论中,我们将经常引用这些原则。

在下一章中,我们将继续研究应该在你学习和尝试新技术时放在首要位置的编码方面。我还会向你介绍一些方便的工具,让你的编码生活变得更加轻松。

问题

  1. 单一职责原则如何改进 Go 代码?

  2. 开闭原则如何改进 Go 代码?

  3. 里斯科夫替换原则如何改进 Go 代码?

  4. 接口隔离原则如何改进 Go 代码?

  5. 依赖倒置原则如何改进 Go 代码?

  6. 依赖倒置与依赖注入有何不同?

进一步阅读

Packt 还有许多其他关于学习 SOLID 原则的优秀资源:

第三章:为用户体验编码

在本章中,我们将研究编程中经常被忽视但有价值的几个方面,主要是测试、用户体验和依赖图。虽然这些主题可能看起来与依赖注入(DI)没有任何关系,但它们被包含在内是为了给你一个坚实但务实的基础,以便你可以评估本书第二部分的技术。

本章将涵盖以下主题:

  • 为人类进行优化

  • 一个名为单元测试的安全保障。

  • 测试诱发的损害

  • 使用 Godepgraph 可视化您的包依赖关系

发现良好的用户体验

良好的用户体验不需要被推测。它也不需要从一些有经验的大师那里传授。事实上,经验的问题在于,今天对你来说容易、简单和明显的东西与上个月、去年或你刚开始时大不相同。

通过逻辑、坚持和实践可以发现良好的 UX。要找出对于你的用户来说良好的 UX 是什么样的,你可以应用我的 UX 发现调查。

问问自己以下四个问题:

  • 谁是用户?

  • 你的用户有什么能力?

  • 用户为什么想要使用你的代码?

  • 你的用户希望如何使用它?

技术要求

对于本章,你需要对 Go 有基本的了解。

本章中的所有代码都可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch03找到。

为人类进行优化

近年来,我们看到了 UX 这个术语的兴起,它代表用户体验。在其核心,UX 是关于可用性的——理解用户并设计交互和界面,使其对他们更直观或更自然。

UX 通常指的是客户,这是有道理的,毕竟那里有钱。然而,我们程序员错过了一些相当重要的东西。让我问你,你写的代码的用户是谁?不是使用软件本身的客户。代码的用户是你的同事和未来的你。你想让他们的生活更轻松吗?换句话说,你宁愿花时间去弄清楚一段代码的目的,还是扩展系统?那里才有钱。作为程序员,我们得到的报酬是交付功能,而不是美丽的代码,而具有良好 UX 的代码可以更快地交付功能,并且风险更小。

对于 Go 代码,用户体验意味着什么?

对于 Go 代码,UX 意味着什么?简而言之,我们应该编写代码,任何有能力的程序员在第一次阅读后就能理解其一般意图

这听起来有点像挥手吗?是的,可能是挥手。这是解决任何创造性努力中的问题的标准问题;当你看到它时,你知道它,当它不存在时,你会感觉到它。也许定义能力的问题主要是因为团队成员和环境的定义差异很大。同样,很难实现的原因也在于代码本身对作者来说比其他人更容易理解。

但首先,让我们看一些简单的原则,以便朝着正确的方向开始。

从简单开始——只有在必要时才变得复杂

作为程序员,我们应该始终努力保持简单,并在没有其他办法时才求助于复杂。让我们看看这个原则是如何实施的。试着在三秒钟内确定下一个示例的作用:

func NotSoSimple(ID int64, name string, age int, registered bool) string {
  out := &bytes.Buffer{}
  out.WriteString(strconv.FormatInt(ID, 10))
  out.WriteString("-")
  out.WriteString(strings.Replace(name, " ", "_", -1))
  out.WriteString("-")
  out.WriteString(strconv.Itoa(age))
  out.WriteString("-")
  out.WriteString(strconv.FormatBool(registered))
  return out.String()
}

这个怎么样:

func Simpler(ID int64, name string, age int, registered bool) string {
  nameWithNoSpaces := strings.Replace(name, " ", "_", -1)
  return fmt.Sprintf("%d-%s-%d-%t", ID, nameWithNoSpaces, age, registered)
}

将第一个代码中体现的方法应用到整个系统几乎肯定会使其运行更快,但不仅编码可能需要更长时间,而且阅读起来也更困难,因此维护和扩展也更困难。

有时你需要从代码中提取极端的性能,但最好等到无法避免时再增加额外的复杂性。

只应用足够的抽象

过度的抽象会导致过度的心理负担和过度的打字。虽然有人可能会认为任何可以在以后交换或扩展的代码片段都应该有一个抽象,但我会主张更加务实的方法。实现足够的内容以交付我们所负责的业务价值,然后根据需要进行重构。看看以下代码:

type myGetter interface {
  Get(url string) (*http.Response, error)
}

func TooAbstract(getter myGetter, url string) ([]byte, error) {
  resp, err := getter.Get(url)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()

  return ioutil.ReadAll(resp.Body)
}

将上述代码与以下常见概念的使用进行比较:

func CommonConcept(url string) ([]byte, error) {
  resp, err := http.Get(url)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()

  return ioutil.ReadAll(resp.Body)
}

遵循行业、团队和语言约定

当概念、变量和函数名称遵循约定时,它们都很容易理解。问问自己,如果你在一个关于汽车的系统上工作,你会期望一个名为flower的变量是什么?

编码风格可以说是 Go 做对的事情。多年来,我一直参与括号放置制表符与空格之争,但转到 Go 后,一切都改变了。有一个固定的、有文档的、易于重现的风格——运行gofmt,问题解决了。仍然有一些地方你可能会伤害到自己。从一个没有检查异常的语言转过来,你可能会想要使用 Go 的panic()短语;虽然可能,但这是官方代码审查评论维基明确不鼓励的约定之一(github.com/golang/go/wiki/CodeReviewComments)。

团队约定有点难以定义,有时也难以遵循。channel类型的变量应该叫做resultresultCh还是resultChan?我见过,也可能写过,这三种情况都有。

错误日志记录呢?有些团队喜欢在触发错误的地方记录错误,而其他人更喜欢在调用堆栈的顶部这样做。我有自己的偏好,我相信你也有,但我还没有看到一个非常有说服力的论点支持其中任何一种。

只导出必要的内容

当你对你的导出 API 小心谨慎时,会发生很多好事。主要的是,它变得更容易让其他人理解;当一个方法有更少的参数时,它自然更容易理解。看看以下代码:

NewPet("Fido", true)

true是什么意思?不打开函数或文档很难说。但是,如果我们这样做呢:

NewDog("Fido")

在这种情况下,目的是明确的,错误不太可能发生,而且封装性得到了改善。

同样,具有较少方法和对象的接口和结构以及包更容易理解,更有明确的目的。让我们看另一个例子:

type WideFormatter interface {
  ToCSV(pets []Pet) ([]byte, error)
  ToGOB(pets []Pet) ([]byte, error)
  ToJSON(pets []Pet) ([]byte, error)
}

将前面的代码与以下进行比较:

type ThinFormatter interface {
  Format(pets []Pet) ([]byte, error)
}

type CSVFormatter struct {}

func (f CSVFormatter) Format(pets []Pet) ([]byte, error) {
  // convert slice of pets to CSV
}

是的,在这两种情况下,结果都是更多的代码。更直接的代码,但无论如何都更多的代码。为用户提供更好的用户体验通常会带来一些额外的成本,但用户的生产力收益是成倍增加的。考虑到,在许多情况下,你编写的代码的用户之一是未来的你,你可以说现在多做一点额外的工作会为你节省大量的未来工作。

继续关注未来的我,这种方法提供的第二个优势是更容易改变主意。一旦一个函数或类型被导出,它就可以被使用;一旦被使用,就必须被维护,并且更改需要付出更多的努力。这种方法使这些更改变得更容易。

积极应用单一职责原则

正如我们在第二章中看到的,Go 的 SOLID 设计原则,应用单一职责原则SRP)鼓励对象更简洁、更连贯,因此更容易理解。

谁是用户?

大部分时间,答案将是“未来的我”和我的同事。你的“未来的我”将会是一个更好、更聪明、更英俊的版本。另一方面,你的同事则更难预测。如果有帮助的话,我们可以避免考虑那些聪明、了不起的人;希望无论我们做什么,他们都能理解。然而,实习生则更难预测。如果我们的代码能让他们理解,那么对其他人来说也就没问题了。

如果你有机会为公司范围或一般用途编写软件库,那么这个问题就会变得更加困难。一般来说,你希望目标低,只有在没有其他选择时才离开标准和简单的格式。

你的用户有什么能力?

既然我们清楚了用户是谁,我们就可以更好地理解他们的世界观。你和你的用户之间,甚至你和未来的你之间的技能、经验和领域知识可能存在巨大的差异。这就是大多数技术工具和软件库失败的地方。回想一下你刚开始使用 Go 的时候。你的代码是什么样子的?在 Go 中有没有一些语言特性是你还没有使用过的?就我个人而言,我来自 Java 背景,因此我带着一些先入为主的观念进入这个领域:

  • 我以为线程很昂贵(而 goroutine 就是线程)

  • 我以为一切都必须在一个结构体中

  • 习惯于显式接口意味着我对使用接口隔离原则ISP)或依赖反转原则DSP)的热情不如现在

  • 我不理解通道的威力

  • 传递 lambda 让我大开眼界

随着时间的推移,我看到这些事情一次又一次地出现,特别是在代码审查的评论中。回答问题“你的用户有什么能力?”有一种非常有效的方法:写一个例子,然后问你的同事以下问题:

  • 这是做什么的?

  • 你会怎么做?

  • 你期望这个函数做什么?

如果你没有任何可以询问的用户,另一个选择是问自己,“还有什么类似的东西存在?”我并不是建议你跟随别人的错误。基本理论是,如果其他类似的东西存在,而你的用户对它感到舒适,那么如果你的东西类似的话,他们就不必学习如何使用。这在我使用 lambda 时给我留下了深刻的印象。来自函数式背景的同事对此很满意,但来自面向对象背景的同事则觉得有些困惑或者不直观。

用户为什么想要使用你的代码?

回答为什么你的用户想要使用你的代码的问题可能是长而多样的。如果是这样,你可能需要回去重新阅读SRP部分。除了能够将代码分割成更小、更简洁的块之外,我们还需要列出一个清单。我们将这个清单应用到 80/20 法则上。通常,80%的使用来自 20%的用例。让我用一个例子来解释一下。

考虑一个自动取款机ATM)。它的用例列表可能如下所示:

  • 取款

  • 存款

  • 查询余额

  • 更改 PIN 码

  • 转账

  • 存款支票

我估计一个人使用自动取款机的至少 80%的目的是取钱。那么我们可以怎么利用这个信息呢?我们可以优化界面,使最常见的用例尽可能方便。对于自动取款机来说,可能只需要在第一个屏幕的顶部放置取款功能,这样用户就不必搜索了。既然我们了解了用户想要实现什么,我们可以在此基础上继续思考他们期望如何使用它。

他们期望如何使用它?

虽然 ATM 的例子很清楚,但它是一个系统,所以你可能会想知道它如何可能适用于诸如函数之类的低级概念。让我们看一个例子:

// PetFetcher searches the data store for pets whose name matches
// the search string.
// Limit is optional (default is 100). Offset is optional (default 0).
// sortBy is optional (default name). sortAscending is optional
func PetFetcher(search string, limit int, offset int, sortBy string, sortAscending bool) []Pet {
  return []Pet{}
}

这看起来可能还不错,对吧?问题是大多数使用看起来像下面这样:

results := PetFetcher("Fido", 0, 0, "", true)

正如你所看到的,大多数情况下我们并不需要所有这些返回值,而且许多输入都被忽略了。

解决这种情况的第一步是查看代码中未被充分利用的部分,并问自己,我们真的需要它们吗?如果它们只存在于测试中,那么它们就是“测试诱导的破坏”,我们将在本章后面讨论。

如果它们存在于一些不经常使用但引人注目的用例中,那么我们可以用另一种方式来解决。第一种选择是将函数分成多个部分;这将允许用户只采用他们需要的复杂性。第二个选择是将配置合并到一个对象中,允许用户忽略他们不使用的部分。

在这两种方法中,我们提供“合理的默认值”,通过允许用户只关注他们需要的内容来减少函数的心理负担。

何时妥协

拥有出色的用户体验是一个值得追求的目标,但并非必需。总会有一些情况下需要牺牲用户体验。第一个,也许是最常见的情况是团队的发展。

随着团队的发展和对 Go 的经验增加,他们将不可避免地发现一些早期的软件模式不再那么有效。这些可能包括全局变量的使用、panic、从环境变量加载配置,甚至何时使用函数而不是对象。随着团队的发展,他们对良好软件的定义以及标准或直观的定义也在发生变化。

第二个,而且在许多情况下,是对糟糕用户体验的过度使用的借口,是性能。正如我们在本章的早期例子中看到的,通常可以编写更快的代码,但更快的代码通常更难理解。这里的最佳选择是首先为人类优化,然后,只有当系统被证明不够快时,才为速度进行优化。即使在这种情况下,这些优化也应该有选择地应用于系统中那些经过测量证明值得重构和长期成本低于理想用户体验的部分。

最后一种情况是可见性;有时,你就是看不到一个好的用户体验可能是什么。在这些情况下,更有效的选择是实施,然后根据使用和出现的任何不便逐步进行重构。

关于为用户体验编码的最后思考

程序员的时间,你的时间,是昂贵的;你应该节约它以优先考虑 CPU 时间。开发人员的用户体验是具有挑战性的,因为我们天生就有解决问题和交付有用软件的需求。然而,节约程序员的时间是可能的。试着记住以下几点:

  • 使某物更具配置性并不会使其更易用,而是使其更令人困惑

  • 为所有用例设计会使代码对每个人都不方便

  • 用户的能力和期望在你的代码被感知以及被采用方面起着重要作用

也许最重要的是,改变用户体验以适应用户总是更好、更容易,而不是相反。

一个名为单元测试的安全保障。

许多人会告诉你,“你必须为你的代码编写单元测试;它们可以确保你没有错误”。它们实际上根本不这样做。我写单元测试不是因为有人告诉我必须这样做,而是因为它们对我有用。单元测试是有力的。它们实际上减少了我需要做的工作量。也许这些不是你以前听过的理由。让我们更详细地探讨一下。

单元测试给您重构的自由和信心:我喜欢重构,也许有点过分,但这是另一个话题。重构让我可以尝试不同风格的代码、实现和 UX。通过进行单元测试,我可以大胆尝试,并且有信心不会无意中破坏任何东西。它们还可以让您有勇气尝试新技术、库或编码技术。

现有的单元测试使添加新功能变得更容易:正如我们之前提到的,添加新功能确实会带来一些风险——我们可能会破坏某些东西。有了测试,就提供了一个安全网,让我们不那么在意已经存在的东西,更专注于添加新功能。这可能看起来有些反直觉,但单元测试实际上让您更快地前进。随着系统的扩展,有了单元测试的安全保障,您可以自信地继续前进,而不必担心可能会破坏的东西。

单元测试可以防止重复的回归:无论如何,回归都很糟糕。它会让你看起来很糟糕,还会让你额外工作,但它是会发生的。我们最希望的是不要反复修复同一个错误。虽然测试确实可以防止一些回归,但它们无法完全阻止。通过编写一个由于错误而失败的测试,然后修复错误,我们实现了两件事。首先,我们知道错误何时被修复,因为测试通过了。其次,错误不会再次发生。

单元测试记录了您的意图:虽然我并不是在暗示测试可以取代文档,但它们是您编写代码时所期望的明确、可执行的表达。这在团队中工作时是一个非常可取的品质。它允许您在系统的任何部分工作,而不必担心破坏他人编写的代码,甚至可能完全理解它。

单元测试记录了您对依赖项的需求:在本书的第二部分中,我们将通过一些示例来应用 DI 到现有的代码库中。这个过程的一个重要部分将包括将功能分组并提取到抽象中。这些抽象自然成为工作单元。然后分别对每个单元进行测试并隔离。因此,这些测试更加专注,更容易编写和维护。

此外,对使用 DI 的代码进行测试通常会关注该函数如何使用和对依赖项做出反应。这些测试有效地定义了依赖项的需求合同,并有助于防止回归。让我们看一个例子:

type Loader interface {
  Load(ID int) (*Pet, error)
}

func TestLoadAndPrint_happyPath(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&happyPathLoader{}, 1, result)
  assert.Contains(t, result.String(), "Pet named")
}

func TestLoadAndPrint_notFound(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&missingLoader{}, 1, result)
  assert.Contains(t, result.String(), "no such pet")
}

func TestLoadAndPrint_error(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&errorLoader{}, 1, result)
  assert.Contains(t, result.String(), "failed to load")
}

func LoadAndPrint(loader Loader, ID int, dest io.Writer) {
  loadedPet, err := loader.Load(ID)
  if err != nil {
    fmt.Fprintf(dest, "failed to load pet with ID %d. err: %s", ID, err)
    return
  }

  if loadedPet == nil {
    fmt.Fprintf(dest, "no such pet found")
    return
  }

  fmt.Fprintf(dest, "Pet named %s loaded", loadedPet.Name)
}

正如您所看到的,这段代码期望依赖项以某种方式运行。虽然测试不会强制执行依赖项的行为,但它们确实有助于定义代码的需求。

单元测试可以帮助恢复信心并增加理解:您的系统中是否有您不敢更改的代码,因为如果更改,会有东西会出错?您是否有一些代码,您真的不确定它是做什么的?单元测试对这两种情况都非常棒。针对这些代码编写测试是一种不显眼的方式,既可以了解它的功能,又可以验证它是否符合您的预期。这些测试的额外好处是它们还可以用作未来任何更改的回归预防,并且可以教给其他人这段代码的功能。

那么我为什么要写单元测试?

对我来说,写单元测试最具说服力的原因是它让我感觉良好。在一天或一周结束时,知道一切都按预期工作,并且测试正在确保这一点,感觉真好。

这并不是说没有错误,但肯定会更少。一旦修复,错误就不会再次出现,这让我免于尴尬,也节省了时间。也许最重要的是,修复错误意味着晚上和周末的支持电话更少,因为某些东西出了问题。

我应该测试什么?

我希望能给你一个清晰、可量化的度量标准,告诉你应该测试什么,不应该测试什么,但事情并不那么清楚。第一个规则肯定如下:

不要测试太简单的代码。

这包括语言特性,比如以下代码中显示的那些:

func NewPet(name string) *Pet {
   return &Pet{
      Name: name,
   }
}

func TestLanguageFeatures(t *testing.T) {
   petFish := NewPet("Goldie")
   assert.IsType(t, &Pet{}, petFish)
}

这也包括简单的函数,就像以下代码中显示的那样:

func concat(a, b string) string {
   return a + b
}

func TestTooSimple(t *testing.T) {
   a := "Hello "
   b := "World"
   expected := "Hello World"

   assert.Equal(t, expected, concat(a, b))
}

之后,要实事求是。我们得到报酬是为了编写能够工作的代码;测试只是确保它确实如此并持续如此的工具。测试过多是完全可能的。过多的测试不仅会导致大量额外的工作,还会导致测试变得脆弱,并在重构或扩展过程中经常出现故障。

因此,我建议从稍高且更黑盒的层次进行测试。看一下这个例子中的结构:

type PetSaver struct{}

// save the supplied pet and return the ID
func (p PetSaver) Save(pet Pet) (int, error) {
   err := p.validate(pet)
   if err != nil {
      return 0, err
   }

   result, err := p.save(pet)
   if err != nil {
      return 0, err
   }

   return p.extractID(result)
}

// ensure the pet record is complete
func (p PetSaver) validate(pet Pet) (error) {
   return nil
}

// save to the datastore
func (p PetSaver) save(pet Pet) (sql.Result, error) {
   return nil, nil
}

// extract the ID from the result
func (p PetSaver) extractID(result sql.Result) (int, error) {
   return 0, nil
}

如果我们为这个结构的每个方法编写测试,那么我们将被阻止重构这些方法,甚至从Save()中提取它们,因为我们还需要重构相应的测试。然而,如果我们只测试Save()方法,这是其他方法使用的唯一方法,那么我们可以更轻松地重构其余部分。

测试的类型也很重要。通常,我们应该测试以下内容:

  • 快乐路径:这是一切都如预期那样进行时。这些测试也倾向于记录如何使用代码。

  • 输入错误:不正确和意外的输入通常会导致代码以奇怪的方式运行。这些测试确保我们的代码以可预测的方式处理这些问题。

  • 依赖问题:另一个常见的失败原因是依赖项未能按我们需要的方式执行,要么是通过编码错误(如回归),要么是通过环境问题(如丢失文件或对数据库的调用失败)。

希望到现在为止,你已经对单元测试感到满意,并对它们能为你做些什么感到兴奋。测试经常被忽视的另一个方面是它们的质量。我说的不是用例覆盖率或代码覆盖率百分比,而是原始代码质量。遗憾的是,通常会以一种我们不允许自己用于生产代码的方式编写测试。

重复、可读性差和缺乏结构都是常见的错误。幸运的是,这些问题可以很容易地解决。第一步只是注意到这个问题,并且应用与生产代码一样的努力和技能。第二步需要使用一些特定于测试的技术;有很多,但在本章中,我只会介绍三种。它们如下:

  • 表驱动测试

  • 存根

  • 模拟

表驱动测试

通常,在编写测试时,你会发现对同一个方法的多个测试会导致大量的重复。看这个例子:

func TestRound_down(t *testing.T) {
   in := float64(1.1)
   expected := 1

   result := Round(in)
   assert.Equal(t, expected, result)
}

func TestRound_up(t *testing.T) {
   in := float64(3.7)
   expected := 4

   result := Round(in)
   assert.Equal(t, expected, result)
}

func TestRound_noChange(t *testing.T) {
   in := float64(6.0)
   expected := 6

   result := Round(in)
   assert.Equal(t, expected, result)
}

这里没有什么令人惊讶的,也没有什么错误的意图。表驱动测试承认了重复的需要,并将变化提取到一个中。正是这个表驱动了原本需要重复的代码的单个副本。让我们将我们的测试转换成表驱动测试:

func TestRound(t *testing.T) {
   scenarios := []struct {
      desc     string
      in       float64
      expected int
   }{
      {
         desc:     "round down",
         in:       1.1,
         expected: 1,
      },
      {
         desc:     "round up",
         in:       3.7,
         expected: 4,
      },
      {
         desc:     "unchanged",
         in:       6.0,
         expected: 6,
      },
   }

   for _, scenario := range scenarios {
      in := float64(scenario.in)

      result := Round(in)
      assert.Equal(t, scenario.expected, result)
   }
}

现在我们的测试保证在这个方法的所有场景中都是一致的,这反过来使它们更有效。如果我们必须更改函数签名或调用模式,我们只需要在一个地方进行,从而减少维护成本。最后,将输入和输出减少到一个表格中,可以廉价地添加新的测试场景,并通过鼓励我们专注于输入来帮助识别测试场景。

存根

有时被称为测试替身,存根是依赖项(即接口)的虚假实现,它提供可预测的、通常是固定的结果。存根也用于帮助执行代码路径,比如错误,否则可能会非常困难或不可能触发。

让我们看一个接口的例子:

type PersonLoader interface {
   Load(ID int) (*Person, error)
}

假设获取器接口的生产实现实际上调用上游 REST 服务。使用我们之前的测试类型列表,我们想测试以下场景:

  • 正常路径:获取器返回数据

  • 输入错误:获取器未能找到我们请求的“人员”

  • 系统错误:上游服务宕机

我们可以实现更多可能的测试,但这已经足够满足我们的目的了。

让我们想一想如果不使用存根,我们将如何进行测试:

  • 正常路径:上游服务必须正常运行,并且我们必须确保我们随时都有一个有效的 ID 来请求。

  • 输入错误:上游服务必须正常运行,但在这种情况下,我们必须有一个保证无效的 ID;否则,这个测试将是不稳定的。

  • 系统错误:服务必须宕机?如果我们假设上游服务属于另一个团队或者有其他用户,我认为他们不会欣赏我们每次需要测试时都关闭服务。我们可以为服务配置一个不正确的 URL,但那么我们将为不同的测试场景运行不同的配置。

前面的场景存在很多非编程问题。让我们看看一点代码是否可以解决问题:

// Stubbed implementation of PersonLoader
type PersonLoaderStub struct {
   Person *Person
   Error error
}

func (p *PersonLoaderStub) Load(ID int) (*Person, error) {
   return p.Person, p.Error
}

通过前面的存根实现,我们现在可以使用表驱动测试为每个场景创建一个存根实例,如下面的代码所示:

func TestLoadPersonName(t *testing.T) {
   // this value does not matter as the stub ignores it
   fakeID := 1

   scenarios := []struct {
      desc         string
      loaderStub   *PersonLoaderStub
      expectedName string
      expectErr    bool
   }{
      {
         desc: "happy path",
         loaderStub: &PersonLoaderStub{
            Person: &Person{Name: "Sophia"},
         },
         expectedName: "Sophia",
         expectErr:    false,
      },
      {
         desc: "input error",
         loaderStub: &PersonLoaderStub{
            Error: ErrNotFound,
         },
         expectedName: "",
         expectErr:    true,
      },
      {
         desc: "system error path",
         loaderStub: &PersonLoaderStub{
            Error: errors.New("something failed"),
         },
         expectedName: "",
         expectErr:    true,
      },
   }

   for _, scenario := range scenarios {
      result, resultErr := LoadPersonName(scenario.loaderStub, fakeID)

      assert.Equal(t, scenario.expectedName, result, scenario.desc)
      assert.Equal(t, scenario.expectErr, resultErr != nil, scenario.desc)
   }
}

正如你所看到的,我们的测试现在不会因为依赖而失败;它们不再需要项目本身之外的任何东西,而且它们可能运行得更快。如果你觉得编写存根很繁琐,我建议两件事。首先,查看之前的第二章,Go 的 SOLID 设计原则,看看你是否可以将接口分解成更小的部分。其次,查看 Go 社区中的众多优秀工具之一;你肯定会找到一个适合你需求的工具。

过度的测试覆盖

另一个可能出现的问题是过度的测试覆盖。是的,你没看错。写太多的测试是可能的。作为技术思维的程序员,我们喜欢度量。单元测试覆盖率就是这样一种度量。虽然可能实现 100%的测试覆盖率,但实现这个目标是一个巨大的时间浪费,而且结果可能相当糟糕。考虑以下代码:

func WriteAndClose(destination io.WriteCloser, contents string) error {
   defer destination.Close()

   _, err := destination.Write([]byte(contents))
   if err != nil {
      return err
   }

   return nil 
}

要实现 100%的覆盖率,我们需要编写一个测试,其中“destination.Close()”调用失败。我们完全可以做到这一点,但这会实现什么?我们将测试什么?这将给我们另一个需要编写和维护的测试。如果这行代码不起作用,你会注意到吗?比如这个例子:

func PrintAsJSON(destination io.Writer, plant Plant) error {
   bytes, err := json.Marshal(plant)
   if err != nil {
      return err
   }

   destination.Write(bytes)
   return nil
}

type Plant struct {
   Name string
}

同样,我们完全可以测试这一点。但我们真的在测试什么?在这种情况下,我们将测试 Go 标准库中的 JSON 包是否按预期工作。外部 SDK 和包应该有它们自己的测试,这样我们就可以相信它们会按照它们声称的那样工作。如果情况不是这样,我们可以随时为它们编写测试并将它们发送回项目。这样整个社区都会受益。

模拟

模拟非常像存根,但它们有一个根本的区别。模拟有期望。当我们使用存根时,我们的测试对我们对依赖的使用没有任何验证;而使用模拟,它们会有。你使用哪种取决于测试的类型和依赖本身。例如,你可能想为日志依赖使用存根,除非你正在编写一个确保代码在特定情况下记录日志的测试。然而,你通常需要为数据库依赖使用模拟。让我们将之前的测试从存根更改为模拟,以确保我们进行这些调用:

func TestLoadPersonName(t *testing.T) {
   // this value does not matter as the stub ignores it
   fakeID := 1

   scenarios := []struct {
      desc          string
      configureMock func(stub *PersonLoaderMock)
      expectedName  string
      expectErr     bool
   }{
      {
         desc: "happy path",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(&Person{Name: "Sophia"}, nil).
               Once()
         },
         expectedName: "Sophia",
         expectErr:    false,
      },
      {
         desc: "input error",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(nil, ErrNotFound).
               Once()
         },
         expectedName: "",
         expectErr:    true,
      },
      {
         desc: "system error path",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(nil, errors.New("something failed")).
               Once()
         },
         expectedName: "",
         expectErr:    true,
      },
   }

   for _, scenario := range scenarios {
      mockLoader := &PersonLoaderMock{}
      scenario.configureMock(mockLoader)

      result, resultErr := LoadPersonName(mockLoader, fakeID)

      assert.Equal(t, scenario.expectedName, result, scenario.desc)
      assert.Equal(t, scenario.expectErr, resultErr != nil, scenario.desc)
      assert.True(t, mockLoader.AssertExpectations(t), scenario.desc)
   }
}

在上面的示例中,我们正在验证是否进行了适当的调用,并且输入是否符合我们的预期。鉴于基于模拟的测试更加明确,它们通常比基于存根的测试更脆弱和冗长。我可以给你的最好建议是选择最适合你要编写的测试的选项,如果设置量似乎过多,请考虑这对你正在测试的代码意味着什么。您可能会遇到特性嫉妒或低效的抽象。重构以符合 DIP 或 SRP 可能会有所帮助。

与存根一样,社区中有许多用于生成模拟的优秀工具。我个人使用过 Vektra 的 mockery (github.com/vektra/mockery)。

您可以使用以下命令安装 mockery:

$ go get github.com/vektra/mockery/.../

安装后,我们可以使用命令行中的 mockery 为我们的测试接口生成模拟,或者通过在源代码中添加注释来使用 Go SDK 提供的go generate工具,如下面的代码所示:

//go:generate mockery -name PersonLoader -testonly -inpkg -case=underscore
type PersonLoader interface {
   Load(ID int) (*Person, error)
}

安装完成后,我们运行以下命令:

$ go generate ./…

然后生成的模拟可以像前面的示例中那样使用。在本书的第二部分中,我们将大量使用 mockery 和它生成的模拟。如果您希望下载 mockery,您将在本章末尾找到指向他们 GitHub 项目的链接。

测试引起的损害

在 2014 年的一篇博客文章中,David Heinemeier Hansson表示,为了使测试更容易或更快而对系统进行更改会导致测试引起的损害。虽然我同意 David 的意图,但我不确定我们在细节上是否一致。他创造了这个术语,以回应他认为过度应用 DI 和测试驱动开发TDD)。

就个人而言,我对两者都采取务实的态度。它们只是工具。请尝试它们。如果它们对你有用,那太棒了。如果不行,也没关系。我从来没有能够像其他方法那样高效地使用 TDD。通常,我会先编写我的函数,至少是正常路径,然后应用我的测试。然后我进行重构和清理。

测试引起的损害的警告信号

尽管测试可能会对软件设计造成许多损害,但以下是一些更常见的损害类型。

仅因测试而存在的参数、配置选项或输出

虽然单个实例可能并不会产生巨大影响,但成本最终会累积起来。请记住,每个参数、选项和输出都是用户必须理解的内容。同样,每个参数、选项和输出都必须经过测试、记录和其他维护。

导致或由不完全抽象引起的参数

通常会看到数据库连接字符串或 URL 被传递到业务逻辑层,唯一目的是将其传递到数据层(数据库或 HTTP 客户端)。通常的动机是通过层传递配置,以便我们可以将实际配置替换为更友好的测试。这听起来不错,但它破坏了数据层的封装。也许更令人担忧的是,如果我们将数据层实现更改为其他内容,我们可能会有大量的重构工作。这里的实际问题不是测试,而是我们选择如何*替换数据层。使用 DIP,我们可以在业务逻辑层中将我们的需求定义为接口,然后进行模拟或存根。这将完全将业务逻辑层与数据层解耦,并消除了传递测试配置的需要。

在生产代码中发布模拟

模拟和存根是测试工具;因此,它们应该只存在于测试代码中。在 Go 中,这意味着一个_test.go文件。我见过许多好心的人在生产代码中发布接口及其模拟。这样做的第一个问题是,它引入了一个可能性,无论多么微小,这段代码最终会进入生产环境。根据此错误在系统中的位置,结果可能是灾难性的。

第二个问题有点微妙。在发布接口和模拟时,意图是减少重复,这是很棒的。然而,这也增加了依赖性和抵抗变化。一旦这段代码被发布并被其他人采用,修改它将需要改变它的所有用法。

使用 Godepgraph 可视化您的软件包依赖关系

在一本关于 DI 的书中,您可以期待我们花费大量时间讨论依赖关系。在最低级别的依赖关系,函数、结构和接口很容易可视化;我们可以只需阅读代码,或者如果我们想要一个漂亮的图片,我们可以制作一个类图,就像下面的例子一样:

如果我们放大到软件包级别并尝试映射软件包之间的依赖关系,那么生活就会变得更加困难。这就是我们再次依赖开源社区丰富的开源工具的地方。这一次,我们将需要两个名为godepgraphGraphvizwww.graphviz.org/)的工具。Godepgraph 是一个用于生成 Go 软件包依赖关系图的程序,而 Graphviz 是一个源图可视化软件。

安装工具

简单的go get将安装godepgraph,如下面的代码所示:

 $ go get github.com/kisielk/godepgraph

如何安装 Graphviz 取决于您的操作系统。您可以使用 Windows 二进制文件,Linux 软件包,以及 MacPorts 和 HomeBrew 用于 OSX。

生成依赖图

一旦一切都安装好了,使用以下命令:

$ godepgraph github.com/kisielk/godepgraph | dot -Tpng -o godepgraph.png

将为您生成以下漂亮的图片:

正如您所看到的,godepgraph的依赖图很好而且平坦,只依赖于标准库的软件包(绿色圆圈)。

让我们尝试一些更复杂的东西:让我们为我们将在本书第二部分中使用的代码生成依赖图:

$ godepgraph github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v1.png

这给我们一个非常复杂的图表,永远不会适合在页面上。如果您想看看它有多复杂,请查看ch03/04_visualizing_dependencies/acme-graph-v1.png。不要太担心试图弄清楚细节;它现在不是一个非常有用的形式。

我们可以做的第一件事是删除标准库导入(使用-s标志),如下面的代码所示。我们可以假设使用标准库是可以接受的,并且不是我们需要转换为抽象或使用 DI 的东西:

$ godepgraph -s github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v2.png

我们可以使用这个图,但对我来说还是太复杂了。假设我们不会鲁莽地采用外部依赖项,我们可以像标准库一样对待它们,并将它们从图表中隐藏(使用-o标志),如下面的代码所示:

$ godepgraph -s -o github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v3.png

这给我们以下内容:

删除所有外部软件包后,我们可以看到我们的软件包之间的关系和依赖关系。

如果您使用 OSX 或 Linux,我在本章的源代码中包含了一个名为depgraph.sh的 Bash 脚本,我用它来生成这些图表。

解释依赖图

就像编程世界中的许多事物一样,依赖图所表达的意思在很大程度上是开放的。我使用图表来发现我可以在代码中搜索的潜在问题。

那么,完美的图表会是什么样子?如果有一个,它将非常平坦,几乎所有的东西都悬挂在主包下。在这样的系统中,所有的包都将完全解耦,并且除了它们的外部依赖和标准库之外,不会有任何依赖。

这实际上是不可行的。正如您将在本书的第二部分中看到的各种 DI 方法,目标通常是解耦层,以便依赖关系只能单向流动-从上到下。

从抽象的角度来看,这看起来有点像下面这样:

考虑到这一点,我们在图表中看到了哪些潜在问题?

查看任何包时要考虑的第一件事是有多少箭头指向它或指向外部。这是耦合的基本度量。指向包的每个箭头表示该包的用户。因此,每个指向内部的箭头意味着如果我们对当前包进行更改,该包可能必须更改。反之亦然-当前包依赖的包越多,它可能因它们的更改而需要更改。

考虑到 DIP,虽然从另一个包采用接口是快速简便的方法,但定义我们自己的接口允许我们依赖于自己,并减少更改的可能性。

接下来引人注目的是 config 包。几乎每个包都依赖于它。正如我们所见,承担这么多责任,对该包进行更改可能会有些棘手。在棘手程度方面,日志包也不甘落后。也许最令人担忧的是 config 包依赖于日志包。这意味着我们离循环依赖问题只差一个糟糕的导入。这些都是我们需要在后面的章节中利用 DI 来处理的问题。

否则,图表看起来很好;它从主包像金字塔一样流出,几乎所有的依赖关系都是单向的。下次您寻找改进代码库的方法或遇到循环依赖问题时,为什么不启动godepgraph并查看它对您的系统的说法。依赖图不会准确告诉您问题所在或问题所在,但它会给您一些提示从哪里开始查找。

摘要

恭喜!我们已经到达了第一部分的结尾!希望在这一点上,您已经发现了一些新东西,或者可能已经想起了一些您已经忘记的软件设计概念。

编程,就像任何专业努力一样,都需要不断讨论、学习和健康的怀疑态度。

在第二部分,您将找到几种非常不同的 DI 技术,有些您可能会喜欢,有些您可能不会。有了我们迄今为止所检查的一切,您将毫无困难地确定每种技术何时以及如何适合您。

问题

  1. 为什么代码的可用性很重要?

  2. 谁最能从具有良好用户体验的代码中受益?

  3. 如何构建良好的用户体验?

  4. 单元测试对您有什么作用?

  5. 您应该考虑哪些测试场景?

  6. 表驱动测试如何帮助?

  7. 测试如何损害您的软件设计?

第四章:ACME 注册服务简介

在本章中,我们将介绍一个名为ACME 注册服务的小型但虚假的服务。这个服务的代码将成为本书其余大部分示例的基础。我们将研究这个服务所在的商业环境,讨论服务和代码的目标,最后,我们将看一些我们可以通过应用依赖注入DI)来解决的问题的例子。

通过本章结束时,您应该有足够的知识来加入团队,一起完成我们将在接下来的章节中进行的改进。

本章将涵盖以下主题:

  • 我们系统的目标

  • 我们系统的介绍

  • 已知问题

技术要求

由于我们正在了解本书中将要使用的系统,我强烈建议下载源代码并在您喜欢的 IDE 中运行它。

本章中的所有代码都可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch04找到。

有关如何获取代码和配置示例服务的说明,请参阅 README 文件,网址为github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch04/acme文件中找到服务的代码。

我们系统的目标

您有没有尝试过从种子开始种植自己的蔬菜?这是一个漫长、缓慢但令人满意的经历。构建优秀的代码也是一样的。在园艺中,跳过第一步直接从苗圃购买植物作为幼苗可能更常见,编程也是如此。大多数情况下,当我们加入一个项目时,代码已经存在;有时它很健康,但通常它是生病和垂死的。

在这种情况下,我们正在采用一个系统。它有效,但有一些问题——好吧,也许不止一些。通过一些精心的关怀,我们将把这个系统变成健康和蓬勃发展的东西。

那么,我们如何定义一个健康的系统?我们现有的系统有效;它做了业务需要它做的事情。这就足够了,对吧?

绝对不!我们可能明确地被支付一定数量的功能,但我们隐含地被支付以提供可维护和可扩展的代码。除了考虑我们为什么被支付,让我们以更自私的角度来看:您希望明天的工作比今天更容易还是更难?

一个健康的代码库具有以下关键特征:

  • 高可读性

  • 高可测试性

  • 低耦合

我们在第一部分中已经谈到或暗示了所有这些要求,但它们的重要性意味着我们将再次重点介绍它们。

高可读性

简而言之,高可读性意味着能够阅读代码并理解它。不可读的代码会减慢您的速度,并可能导致错误,您可能会假设它做一件事,但实际上它做了另一件事。

让我们看一个示例,如下所示的代码:

type House struct {
   a string
   b int
   t int
   p float64
}

在这个例子中,代码的命名存在问题。短变量名似乎是一个胜利;少打字意味着少工作,对吗?短期内是的,但从长远来看,它们很难理解。您被迫阅读代码以确定变量的含义,然后在该上下文中重新阅读代码,而一个好的名称本来可以省去我们的第一步。这并不意味着超长的名称是正确的;它们也增加了心理负担并浪费了屏幕空间。一个好的变量通常是一个单词,具有常见的含义或目的。

有两种情况下不应遵循上述原则。第一种是方法。也许是因为我使用 C++和 Java 的时间以及 Go 中缺少this运算符,但我发现短方法接收器很有用,可能是因为它们在整个结构中是一致的,只有短变量使它们与其他所有变量有所不同。

第二种情况是我们在处理测试名称时。测试本质上是小故事;在这种情况下,长名称通常是完全合适的。注释也可以起作用,但效果较差,因为测试运行器在失败时输出测试的名称而不是注释。

让我们在考虑这些想法的基础上更新前面的示例,看看它是否更好,如下所示:

type House struct {
   address string
   bedrooms int
   toilets int
   price float64
}

有关可读性的更多信息,请翻回到第三章中的Optimizing for humans部分。

高可测试性

编写自动化测试可能会感觉像是额外的工作,会占用我们编写功能的真正目的的时间。事实上,自动化测试的主要目标是确保代码的执行符合预期,并且尽管我们对代码库作出任何更改或添加,它仍然如此。但自动化测试确实有成本:您必须编写和维护它们。因此,如果我们的代码易于测试,我们就不太可能在测试上吝啬,并匆忙进行下一个令人兴奋的功能。

让我们看一个示例,如下所示:

func longMethod(resp http.ResponseWriter, req *http.Request) {
   err := req.ParseForm()
   if err != nil {
      resp.WriteHeader(http.StatusPreconditionFailed)
      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 userID = ?", userID)

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

   encoder := json.NewEncoder(resp)
   err = encoder.Encode(person)
   if err != nil {
      resp.WriteHeader(http.StatusInternalServerError)
      return
   }
}

所以这个例子有什么问题?最简单的答案是它知道得太多,或者更自私地说,它让我知道得太多。

它包含边界层(HTTP 和数据库)逻辑,也包含业务逻辑。它相当长,意味着我必须在脑海中保留更多的上下文。它基本上违反了单一职责原则SRP)。有很多原因它可能会改变。输入格式可能会改变。数据库格式可能会改变。业务规则可能会改变。任何这样的改变都意味着这段代码的每个测试很可能也需要改变。让我们看看前面代码的测试可能是什么样子,如下所示:

func TestLongMethod_happyPath(t *testing.T) {
   // build request
   request := &http.Request{}
   request.PostForm = url.Values{}
   request.PostForm.Add("UserID", "123")

   // mock the database
   var mockDB sqlmock.Sqlmock
   var err error

   DB, mockDB, err = sqlmock.New()
   require.NoError(t, err)
     mockDB.ExpectQuery("SELECT .* FROM people WHERE ID = ?").
    WithArgs(123).
    WillReturnRows(
      sqlmock.NewRows(
        []string{"ID", "Name", "Phone"}).
        AddRow(123, "May", "0123456789"))

   // build response
   response := httptest.NewRecorder()

   // call method
   longMethod(response, request)

   // validate response
   require.Equal(t, http.StatusOK, response.Code)

   // validate the JSON
   responseBytes, err := ioutil.ReadAll(response.Body)
   require.NoError(t, err)

   expectedJSON := `{"ID":123,"Name":"May","Phone":"0123456789"}` + "\n"
   assert.Equal(t, expectedJSON, string(responseBytes))
}

正如您所看到的,这个测试冗长且笨重。最糟糕的是,对于这个方法的任何其他测试都将涉及复制这个测试并进行微小的更改。这听起来很有效,但有两个问题。这些样板代码中可能很难发现小的差异,而我们测试的功能发生任何更改都需要对所有这些测试进行更改。

虽然有许多方法可以修复我们示例的可测试性,但也许最简单的选择是分离不同的关注点,然后逐个方法进行大部分测试,如下所示:

func shortMethods(resp http.ResponseWriter, req *http.Request) {
   userID, err := extractUserID(req)
   if err != nil {
      resp.WriteHeader(http.StatusInternalServerError)
      return
   }

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

   outputPerson(resp, person)
}

func extractUserID(req *http.Request) (int64, error) {
   err := req.ParseForm()
   if err != nil {
      return 0, err
   }

   return strconv.ParseInt(req.Form.Get("UserID"), 10, 64)
}

func loadPerson(userID int64) (*Person, error) {
   row := DB.QueryRow("SELECT * FROM people WHERE ID = ?", userID)

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

func outputPerson(resp http.ResponseWriter, person *Person) {
   encoder := json.NewEncoder(resp)
   err := encoder.Encode(person)
   if err != nil {
      resp.WriteHeader(http.StatusInternalServerError)
      return
   }
}

有关单元测试对您的作用,可以翻回到第三章中的A security blanket named unit tests部分。

低耦合度

耦合是一个对象或包与其他对象的关系程度的度量。如果对一个对象的更改可能导致其他对象的更改,或者反之亦然,则认为该对象的耦合度高。相反,当一个对象的耦合度低时,它独立于其他对象或包。在 Go 中,低耦合度最好通过隐式接口和稳定且最小化的公开 API 来实现。

低耦合度是可取的,因为它导致代码的更改局部化。在下面的示例中,通过使用隐式接口来定义我们的要求,我们能够使自己免受对依赖项的更改的影响:

正如您从前面的例子中所看到的,我们不再依赖 FileManager Package,这在其他方面也对我们有所帮助。这种缺乏依赖也意味着在阅读代码时我们需要记住的上下文更少,在编写测试时依赖更少。

要了解如何实现低耦合性,请翻回到第二章中涵盖的SOLID Design Principles for Go

关于目标的最终想法

到现在为止,您可能已经看到了一个模式。所有这些目标将导致易于阅读、理解、测试和扩展的代码,也就是说,可维护的代码。虽然这些目标可能看起来是自私或完美主义的,但我认为这对于企业长远来说是必不可少的。在短期内,向用户提供价值,通常以功能的形式,是至关重要的。但是,当这样做得不好时,可以添加功能的速度、添加功能所需的程序员数量以及因更改引入的错误数量都会增加,并且会给企业带来的成本将超过开发良好代码的成本。

现在我们已经定义了我们对服务的目标,让我们来看看它的当前状态。

我们系统的介绍

欢迎加入项目!那么,加入团队需要了解什么呢?与任何项目一样,您首先想要了解它的功能,用户以及部署环境。

我们正在处理的系统是基于 HTTP 的事件注册服务。它旨在被我们的 Web 应用程序或原生移动应用程序调用。以下图表显示了它如何适应我们的网络:

目前有三个端点,列举如下:

  • 注册:这将创建一个新的注册记录

  • 获取:这将返回现有注册记录的全部详细信息

  • 列表:这将返回所有注册的列表

所有请求和响应负载都是 JSON 格式。数据存储在 MySQL 数据库中。

我们还有一个上游货币转换服务——我们在注册时调用它,将 100 欧元的注册价格转换为用户请求的货币。

如果您希望在本地运行服务或测试,请参考ch04/README.md文件中的说明。

软件架构

从概念上讲,我们的代码有三层,如下图所示:

这些层如下:

  • REST:这个包接受 HTTP 请求并将它们转换为业务逻辑中的函数调用。然后将业务逻辑响应转换回 HTTP。

  • 业务逻辑:这就是魔法发生的地方。这一层使用外部服务和数据层来执行业务功能。

  • 外部服务和数据:这一层包括访问数据库和提供货币汇率的上游服务的代码。

我在本节的开头使用了“概念上”的词,因为我们的导入图显示了一个略有不同的故事:

正如您所看到的,我们有一个准第四层,其中包括配置和日志包,更糟糕的是,一切似乎都依赖于它们。这很可能会在某个时候给我们带来问题。

这里显示了一个不太明显的问题。看到 REST 和数据包之间的链接了吗?这表明我们的 HTTP 层依赖于数据层。这是有风险的,因为它们有不同的生命周期和不同的变更原因。我们将在下一节中看到这一点以及其他一些令人不快的惊喜。

已知问题

每个系统都有它的骨架,我们不以之为傲的代码部分。有时,它们是我们本可以做得更好的代码部分,如果我们有更多的时间的话。这个项目也不例外。让我们来看看我们目前知道的问题。

可测试性

尽管是一个小型且工作正常的服务,但我们有相当多的问题,其中最严重的是难以测试。现在,我们不想开始引入测试导致的破坏,但我们确实希望有一个我们有信心的系统。为了实现这一点,我们需要减少测试的复杂性和冗长。看看下面的测试:

func TestGetHandler_ServeHTTP(t *testing.T) {
   // ensure the test always fails by giving it a timeout
   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   defer cancel()

     // Create and start a server
  // With out current implementation, we cannot test this handler 
  // without a full server as we need the mux.
  address, err := startServer(ctx)
  require.NoError(t, err)

   // build inputs
   response, err := http.Get("http://" + address + "/person/1/")

   // validate outputs
   require.NoError(t, err)
   require.Equal(t, http.StatusOK, response.StatusCode)

   expectedPayload := []byte(`{"id":1,"name":"John","phone":"0123456780","currency":"USD","price":100}` + "\n")
   payload, _ := ioutil.ReadAll(response.Body)
   defer response.Body.Close()

   assert.Equal(t, expectedPayload, payload)
}

这个测试是针对我们最简单的端点Get的。问问自己,这个测试可能会以什么方式失败?什么样的技术或业务相关的变化会导致这个测试需要更新?系统的哪些部分必须正常工作才能通过这个测试?

对这些问题的一些潜在答案包括以下:

  • 如果 URL 路径发生变化,这个测试就会失败

  • 如果输出格式发生变化,这个测试就会失败

  • 如果config文件没有正确配置,这个测试就会失败

  • 如果数据库不工作,这个测试就会失败

  • 如果数据库中缺少记录 ID 1,这个测试就会失败

  • 如果业务逻辑层出现错误,这个测试就会失败

  • 如果数据库层出现错误,这个测试就会失败

这个简单端点的测试列表相当恶劣。这个测试可以以这么多种方式失败意味着它是一个脆弱的测试。脆弱的测试令人筋疲力尽,而且通常编写起来也很费力。

工作的重复

让我们来看看业务层中Get端点的测试,如下所示:

func TestGetter_Do(t *testing.T) {
   // inputs
   ID := 1
   name := "John"

   // call method
   getter := &Getter{}
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, name, person.FullName)
}

这个测试几乎与前一节的测试相同。也许这是合理的,因为它是相同的端点。但让我们以自私的角度来看,这个测试除了更好的单元测试覆盖率之外,还给了我们什么?

没有。因为之前的测试实际上是一个集成测试,它测试了整个堆栈。这个测试也是一个集成测试,但是更深一层。因为它测试了之前示例中测试过的代码,我们做了双倍的工作,有双倍数量的测试需要维护,但没有任何收获。

测试中的隔离不足

在我们之前的代码中显示的缺乏隔离是层之间高耦合的症状。在接下来的部分,我们将应用 DI 和依赖反转原则DIP)来解决这个问题。

数据和 REST 包之间的高耦合

我们的REST包使用了data包中定义的Person结构。从表面上看,这是有道理的。更少的代码意味着写和维护更少的工作;然而,这意味着输出格式和数据格式是相互关联的。考虑一下,如果我们开始存储与客户相关的私人信息,比如密码或 IP 地址会发生什么。这些信息可能对某些功能是必要的,但很少需要通过GetList端点发布。

还有另一个考虑我们应该记住。随着存储的数据量或使用量的增长,可能需要更改数据的格式。对这个结构的任何更改都会破坏 API 合同,因此也会破坏我们的用户。

也许这里最大的风险就是人为错误;如果你在data包上工作,你可能不记得REST包如何使用那个结构。假设我们添加了用户登录系统的功能。最简单的实现方式是在数据库中添加一个密码字段。如果我们的Get端点构建其输出如下所示的代码会发生什么?

// output the supplied person as JSON
func (h *GetHandler) writeJSON(writer io.Writer, person *data.Person) error {
   return json.NewEncoder(writer).Encode(person)
}

我们的Get端点负载现在将包括密码。哎呀!

这个问题是 SRP 违规,解决这个问题的方法是确保这两个用例是解耦的,并允许它们分别发展。

与配置包的高耦合

正如我们在依赖图中看到的那样,几乎所有东西都依赖于config包。这主要原因是代码直接引用公共全局变量来配置自身。这带来的第一个问题是它对测试的影响。现在几乎所有的测试都确保在运行之前已经正确初始化了配置全局变量。因为所有的测试都使用同一个全局变量,我们被迫在不改变配置的情况下进行选择,这影响了我们的测试能力,或者按顺序运行测试,这浪费了我们的时间。

让我们来看一个例子,如下面的代码所示:

// bind stop channel to context
ctx := context.Background()

// start REST server
server := rest.New(config.App.Address)
server.Listen(ctx.Done())

在这段代码中,我们正在启动我们的 REST 服务器,并将地址(主机和端口)传递给它以绑定。如果我们决定要启动多个服务器以便隔离测试不同的事物,那么我们将不得不更改存储在config.App.Address中的值。然而,通过在一个测试中这样做,我们可能会意外地影响到另一个测试。

第二个问题并不经常出现,但这种耦合也意味着这段代码不能轻松地被其他项目、包或用例所使用,超出了最初的意图。

最后一个问题可能是最烦人的:由于循环依赖问题,您无法在配置中使用自定义数据类型,这些类型在Config包之外定义。

考虑以下代码:

// Currency is a custom type; used for convenience and code readability
type Currency string

// UnmarshalJSON implements json.Unmarshaler
func (c *Currency) UnmarshalJSON(in []byte) error {
   var s string
   err := json.Unmarshal(in, &s)
   if err != nil {
      return err
   }

   currency, valid := validCurrencies[s]
   if !valid {
      return fmt.Errorf("'%s' is not a valid currency", s)
   }

   *c = currency

   return nil
}

假设您的配置包括以下内容:

type Config struct {
   DefaultCurrency currency.Currency `json:"default_currency"`
}

在这种情况下,任何尝试在与我们的Currency类型相同的包中使用配置包都将被阻止。

下游货币服务

交换包对外部服务进行 HTTP 调用以获取汇率。目前,当运行测试时,它将调用该服务。这意味着我们的测试具有以下特点:

  • 它们需要互联网连接

  • 它们依赖于下游服务可访问和正常工作

  • 它们需要来自下游服务的适当凭据和配额

所有这些因素要么超出我们的控制,要么与我们的服务完全无关。如果我们从测试的可靠性是我们工作质量的衡量标准的角度来看,那么我们的质量现在取决于我们无法控制的事情。这远非理想。

我们可以创建一个虚假的货币服务,并更改我们的配置指向该服务,在测试交换包时,我可能会这样做。但在其他地方这样做是令人讨厌的,并且容易出错。

总结

在本章中,我们介绍了一个状况相当糟糕的小型服务。我们将通过一系列重构来改进这个服务,同时探索许多 DI 技术。在接下来的章节中,我们将通过应用 Go 中可用的不同 DI 技术来解决本章中概述的问题。

对于每种不同的技术,要记住代码异味,SOLID 原则,代码 UX 以及我们在第一部分讨论的所有其他想法。还要记得带上你内心的怀疑者。

始终要问自己,这种技术实现了什么?这种技术如何使代码变得更好/更糟?你如何应用这种技术来改进属于你的其他代码?

问题

  1. 对于我们的服务定义的目标,哪一个对你个人来说最重要?

  2. 概述中列出的问题中哪一个似乎是最紧迫或最重要的?

第五章:使用猴子补丁进行依赖注入

您的代码是否依赖于全局变量?您的代码是否依赖于文件系统?您是否曾经尝试过测试数据库错误处理代码?

在本章中,我们将研究猴子补丁作为一种在测试期间替换依赖项的方法,并以一种其他情况下不可能的方式进行测试。无论这些依赖项是对象还是函数,我们都将应用猴子补丁到我们的示例服务中,以便我们可以将测试与数据库解耦;将不同层解耦,并且所有这些都不需要进行重大重构。

在继续我们务实、怀疑的方法时,我们还将讨论猴子补丁的优缺点。

本章将涵盖以下主题:

  • 猴子魔术——猴子补丁简介

  • 猴子补丁的优点

  • 应用猴子补丁

  • 猴子补丁的缺点

技术要求

熟悉我们在第四章中介绍的服务的代码将是有益的,ACME 注册服务简介。您可能还会发现阅读和运行本章的完整代码版本对您有所帮助,这些代码可在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch05中找到。

获取代码并配置示例服务的说明可在此处的 README 中找到github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch05/acme中找到我们服务的代码,并已应用本章的更改。

猴子魔术!

猴子补丁是在运行时改变程序,通常是通过替换函数或变量来实现的。

虽然这不是传统的依赖注入DI)形式,但它可以在 Go 中用于进行测试。事实上,猴子补丁可以用于以其他方式不可能的方式进行测试。

首先让我们考虑一个现实世界的类比。假设您想测试车祸对人体的影响。您可能不会自愿成为测试期间车内的人。也不允许您对车辆进行更改以便进行测试。但是您可以在测试期间将人类换成碰撞测试假人(猴子补丁)。

在代码中进行猴子补丁的过程与现实情况相同;更改仅在测试期间存在,并且在许多情况下对生产代码的影响很小。

对于熟悉 Ruby、Python 和 JavaScript 等动态语言的人来说,有一个快速说明:可以对单个类方法进行猴子补丁,并在某些情况下对标准库进行补丁。Go 只允许我们对变量进行补丁,这可以是对象或函数,正如我们将在本章中看到的。

猴子补丁的优点

猴子补丁作为一种 DI 形式,在实施和效果上与本书中介绍的其他方法非常不同。因此,在某些情况下,猴子补丁是唯一的选择或唯一简洁的选择。猴子补丁的其他优点将在本节详细介绍。

通过 monkey patching 进行 DI 的实现成本低廉——在这本书中,我们谈论了很多关于解耦的内容,即我们的代码的各个部分应该保持独立,即使它们使用/依赖于彼此。我们引入抽象并将它们注入到彼此中。让我们退后一步,考虑一下为什么我们首先要求代码解耦。这不仅仅是为了更容易测试。它还允许代码单独演变,并为我们提供了小组,可以单独思考代码的不同部分。正是这种解耦或分离,使得 monkey patching 可以应用。

考虑这个函数:

func SaveConfig(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = ioutil.WriteFile(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

我们如何将这个函数与操作系统解耦?换个说法:当文件丢失时,我们如何测试这个函数的行为?

我们可以用*os.Fileio.Writer替换文件名,但这只是把问题推到了别处。我们可以将这个函数重构为一个结构体,将对ioutil.WriteFile的调用改为一个抽象,然后进行模拟。但这听起来像是很多工作。

使用 monkey patching,有一个更便宜的选择:

func SaveConfig(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = writeFile(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

// Custom type that allows us to Monkey Patch
var writeFile = ioutil.WriteFile

一行代码,我们就给自己提供了用模拟替换writeFile()的能力,这样我们就可以轻松测试正常路径和错误场景。

允许我们模拟其他包,而不完全了解其内部情况——在前面的例子中,您可能已经注意到我们在模拟一个标准库函数。您知道如何使ioutil.WriteFile()失败吗?当然,我们可以在标准库中进行搜索;虽然这是提高 Go 技能的好方法,但这不是我们得到报酬的方式。在这种情况下,ioutil.WriteFile()可能会失败并不重要。真正重要的是我们的代码如何对错误做出反应。

Monkey patching,就像其他形式的模拟一样,为我们提供了不必关心依赖的内部情况,但却能让它按我们需要的方式运行的能力。

我建议从外部进行测试,无论如何都是正确的。解耦我们对依赖的思考方式可以确保任何测试对内部情况的了解更少,因此不容易受到实现或环境变化的影响。如果io.WriteFile()的内部实现细节发生任何变化,它们都不会破坏我们的测试。我们的测试只依赖于我们的代码,因此它们的可靠性完全取决于我们自己。

通过 monkey patching 进行 DI 对现有代码的影响很小——在前面的例子中,我们将外部依赖定义如下:

var writeFile = ioutil.WriteFile

让我们稍微改变一下:

type fileWriter func(filename string, data []byte, perm os.FileMode) error

var writeFile fileWriter = ioutil.WriteFile

这让你想起了什么吗?在这个版本中,我们明确地定义了我们的需求,就像我们在第二章中的Go 的 SOLID 设计原则部分所做的那样。虽然这种改变完全是多余的,但它确实引发了一些有趣的问题。

让我们回过头来看看,如果不使用 monkey patching 来测试我们的方法,我们需要做哪些改变。第一个选择是将io.WriteFile注入到函数中,如下面的代码所示:

func SaveConfig(writer fileWriter, filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = writer(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

// This custom type is not strictly needed but it does make the function 
// signature a little cleaner
type fileWriter func(filename string, data []byte, perm os.FileMode) error

这有什么问题吗?就我个人而言,我对此有三个问题。首先,这是一个小而简单的函数,只有一个依赖项;如果我们有更多的依赖项,这个函数将变得非常丑陋。换句话说,代码的用户体验很糟糕。

其次,它会破坏函数实现的封装(信息隐藏)。这可能会让人觉得我在进行狂热的争论,但我并不是这样认为的。想象一下,如果我们重构SaveConfig()的实现,以至于我们需要将io.WriteFile更改为其他内容。在这种情况下,我们将不得不更改我们函数的每次使用,可能会有很多更改,因此也会有很大的风险。

最后,这种改变可以说是测试引起的伤害,正如我们在第三章的测试引起的伤害部分所讨论的,用户体验编码,因为这是一种只用于改进测试而不增强非测试代码的改变。

另一个可能会想到的选择是将我们的函数重构为一个对象,然后使用更传统的 DI 形式,如下面的代码所示:

type ConfigSaver struct {
   FileWriter func(filename string, data []byte, perm os.FileMode) error
}

func (c ConfigSaver) Save(filename string, cfg *Config) error {
   // convert to JSON
   data, err := json.Marshal(cfg)
   if err != nil {
      return err
   }

   // save file
   err = c.FileWriter(filename, data, 0666)
   if err != nil {
      log.Printf("failed to save file '%s' with err: %s", filename, err)
      return err
   }

   return nil
}

遗憾的是,这种重构遭受了与之前相似的问题,其中最重要的是它有可能需要大量的改变。正如你所看到的,monkey patching 需要的改变明显比传统方法少得多。

通过 monkey patching 进行 DI 允许测试全局变量和单例 - 你可能会认为我疯了,Go 语言没有单例。严格来说可能不是,但你有没有读过math/rand标准库包(godoc.org/math/rand)的代码?在其中,你会发现以下内容:

// A Rand is a source of random numbers.
type Rand struct {
   src Source

   // code removed
}

// Int returns a non-negative pseudo-random int.
func (r *Rand) Int() int {
   // code changed for brevity
   value := r.src.Int63()
   return int(value)
}

/*
 * Top-level convenience functions
 */

var globalRand = New(&lockedSource{})

// Int returns a non-negative pseudo-random int from the default Source.
func Int() int { return globalRand.Int() }

// A Source represents a source of uniformly-distributed
// pseudo-random int64 values in the range 0, 1<<63).
type Source interface {
   Int63() int64

   // code removed
}

你如何测试Rand结构?你可以用一个返回可预测的非随机结果的模拟来交换Source,很容易。

现在,你如何测试方便函数Int()?这并不容易。这个方法,根据定义,返回一个随机值。然而,通过 monkey patching,我们可以,如下面的代码所示:

func TestInt(t *testing.T) {
   // monkey patch
   defer func(original *Rand) {
      // restore patch after use
      globalRand = original
   }(globalRand)

   // swap out for a predictable outcome
   globalRand = New(&stubSource{})
   // end monkey patch

   // call the function
   result := Int()
   assert.Equal(t, 234, result)
}

// this is a stubbed implementation of Source that returns a 
// predictable value
type stubSource struct {
}

func (s *stubSource) Int63() int64 {
   return 234
}

通过 monkey patching,我们能够测试单例的使用,而不需要对客户端代码进行任何更改。通过其他方法实现这一点,我们将不得不引入一层间接,这反过来又需要对客户端代码进行更改。

应用 monkey patching

让我们将 monkey patching 应用到我们在[第四章中介绍的 ACME 注册服务上,ACME 注册服务简介。我们希望通过服务改进许多事情之一是测试的可靠性和覆盖范围。在这种情况下,我们将在data包上进行工作。目前,我们只有一个测试,看起来是这样的:

func TestData_happyPath(t *testing.T) {
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // save
   resultID, err := Save(in)
   require.Nil(t, err)
   assert.True(t, resultID > 0)

   // load
   returned, err := Load(resultID)
   require.NoError(t, err)

   in.ID = resultID
   assert.Equal(t, in, returned)

   // load all
   all, err := LoadAll()
   require.NoError(t, err)
   assert.True(t, len(all) > 0)
}

在这个测试中,我们进行了保存,然后使用Load()LoadAll()方法加载新保存的注册。

这段代码至少有三个主要问题。

首先,我们只测试快乐路径;我们根本没有测试错误处理。

其次,测试依赖于数据库。有些人会认为这没问题,我不想加入这场辩论。在这种情况下,使用实时数据库会导致我们对LoadAll()的测试不够具体,这使得我们的测试不如可能的彻底。

最后,我们一起测试所有的函数,而不是孤立地测试。考虑当测试的以下部分失败时会发生什么:

returned, err := Load(resultID)
require.NoError(t, err)

问题在哪里?是Load()出了问题还是Save()出了问题?这是关于孤立测试的论点的基础。

data包中的所有函数都依赖于*sql.DB的全局实例,它代表了一个数据库连接池。因此,我们将对该全局变量进行 monkey patching,并引入一个模拟版本。

介绍 SQLMock

SQLMock 包(github.com/DATA-DOG/go-sqlmock)自述如下:

一个模拟实现 sql/driver 的模拟库。它只有一个目的 - 在测试中模拟任何 sql driver 的行为,而不需要真正的数据库连接

我发现 SQLMock 很有用,但通常比直接使用数据库更费力。作为一个务实的程序员,我很乐意使用任何一种。通常,选择使用哪种取决于我希望测试如何工作。如果我想要非常精确,没有与表的现有内容相关的问题,并且没有由表的并发使用引起的数据竞争的可能性,那么我会花额外的精力使用 SQLMock。

当两个或更多 goroutines 同时访问变量,并且至少有一个 goroutine 正在写入变量时,就会发生数据竞争。

让我们看看如何使用 SQLMock 进行测试。考虑以下函数:

func SavePerson(db *sql.DB, in *Person) (int, error) {
   // perform DB insert
   query := "INSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)"
   result, err := db.Exec(query, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      return 0, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      return 0, err
   }
   return int(id), nil
}

这个函数以*Person*sql.DB作为输入,将人保存到提供的数据库中,然后返回新创建记录的 ID。这个函数使用传统的 DI 形式将数据库连接池传递给函数。这使我们可以轻松地用假的数据库连接替换真实的数据库连接。现在,让我们构建测试。首先,我们使用 SQLMock 创建一个模拟数据库:

testDb, dbMock, err := sqlmock.New()
require.NoError(t, err)

然后,我们将期望的查询定义为正则表达式,并使用它来配置模拟数据库。在这种情况下,我们期望一个单独的db.Exec调用返回2,即新创建记录的 ID,以及1,即受影响的行:

queryRegex := `\QINSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)\E`

dbMock.ExpectExec(queryRegex).WillReturnResult(sqlmock.NewResult(2, 1))

现在我们调用这个函数:

resultID, err := SavePerson(testDb, person)

然后,我们验证结果和模拟的期望:

require.NoError(t, err)
assert.Equal(t, 2, resultID)
assert.NoError(t, dbMock.ExpectationsWereMet())

现在我们已经有了如何利用 SQLMock 来测试我们的数据库交互的想法,让我们将其应用到我们的 ACME 注册代码中。

使用 SQLMock 进行 monkey patching

首先,快速回顾一下:当前的data包不使用 DI,因此我们无法像前面的例子中那样传入*sql.DB。该函数当前的样子如下所示:

// Save will save the supplied person and return the ID of the newly 
// created person or an error.
// Errors returned are caused by the underlying database or our connection
// to it.
func Save(in *Person) (int, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return defaultPersonID, err
   }

   // perform DB insert
   query := "INSERT INTO person (fullname, phone, currency, price) VALUES (?, ?, ?, ?)"
   result, err := db.Exec(query, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      logging.L.Error("failed to save person into DB. err: %s", err)
      return defaultPersonID, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      logging.L.Error("failed to retrieve id of last saved person. err: %s", err)
      return defaultPersonID, err
   }
   return int(id), nil
}

我们可以重构成这样,也许将来我们可能会这样做,但目前我们几乎没有对这段代码进行任何测试,而没有测试进行重构是一个可怕的想法。你可能会想到类似于但如果我们使用 monkey patching 编写测试,然后将来进行不同风格的 DI 重构,那么我们将不得不重构这些测试,你是对的;这个例子有点牵强。也就是说,写测试来为你提供安全保障或高水平的信心,然后以后删除它们是没有错的。这可能会感觉像是在做重复的工作,但这肯定比在一个正在运行且人们依赖的系统中引入回归,以及调试这种回归的工作要少得多。

首先引人注目的是 SQL。我们几乎需要在我们的测试中使用完全相同的字符串。因此,为了更容易地长期维护代码,我们将其转换为常量,并将其移到文件顶部。由于测试将与我们之前的例子非常相似,让我们首先仅检查 monkey patching。从之前的例子中,我们有以下内容:

// define a mock db
testDb, dbMock, err := sqlmock.New()
defer testDb.Close()

require.NoError(t, err)

在这些行中,我们正在创建*sql.DB的测试实例和一个控制它的模拟。在我们可以对*sql.DB的测试实例进行 monkey patching 之前,我们首先需要创建原始实例的备份,以便在测试完成后进行恢复。为此,我们将使用defer关键字。

对于不熟悉的人来说,defer是一个在当前函数退出之前运行的函数,也就是说,在执行return语句和将控制权返回给当前函数的调用者之间。defer的另一个重要特性是参数会立即求值。这两个特性的结合允许我们在defer求值时复制原始的sql.DB,而不用担心当前函数何时或如何退出,从而避免了潜在的大量清理代码的复制和粘贴。这段代码如下所示:

defer func(original sql.DB) {
   // restore original DB (after test)
   db = &original
}(*db)

// replace db for this test
db = testDb

完成后,测试如下所示:

func TestSave_happyPath(t *testing.T) {
   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   defer testDb.Close()
   require.NoError(t, err)

   // configure the mock db
   queryRegex := convertSQLToRegex(sqlInsert)
   dbMock.ExpectExec(queryRegex).WillReturnResult(sqlmock.NewResult(2, 1))

   // monkey patching starts here
   defer func(original sql.DB) {
      // restore original DB (after test)
      db = &original
   }(*db)

   // replace db for this test
   db = testDb
   // end of monkey patch

   // inputs
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // call function
   resultID, err := Save(in)

   // validate result
   require.NoError(t, err)
   assert.Equal(t, 2, resultID)
   assert.NoError(t, dbMock.ExpectationsWereMet())
}

太棒了,我们已经完成了快乐路径测试。不幸的是,我们只测试了函数中的 13 行中的 7 行;也许更重要的是,我们不知道我们的错误处理代码是否正确工作。

测试错误处理

有三种可能的错误需要处理:

  • SQL 插入可能会失败

  • 未能获取数据库

  • 我们可能无法检索到插入记录的 ID

那么,我们如何测试 SQL 插入失败呢?使用 SQLMock 很容易:我们复制上一个测试,而不是返回sql.Result,我们返回一个错误,如下面的代码所示:

// configure the mock db
queryRegex := convertSQLToRegex(sqlInsert)
dbMock.ExpectExec(queryRegex).WillReturnError(errors.New("failed to insert"))

然后我们可以将我们的期望从结果更改为错误,如下面的代码所示:

require.Error(t, err)
assert.Equal(t, defaultPersonID, resultID)
assert.NoError(t, dbMock.ExpectationsWereMet())

接下来是测试无法获取数据库,这时 SQLMock 无法帮助我们,但是可以使用 monkey patching。目前,我们的getDB()函数如下所示:

func getDB() (*sql.DB, error) {
   if db == nil {
      if config.App == nil {
         return nil, errors.New("config is not initialized")
      }

      var err error
      db, err = sql.Open("mysql", config.App.DSN)
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

让我们将函数更改为变量,如下面的代码所示:

var getDB = func() (*sql.DB, error) {
    // code removed for brevity
}

我们并没有改变函数的实现。现在我们可以对该变量进行 monkey patch,得到如下的测试结果:

func TestSave_getDBError(t *testing.T) {
   // monkey patching starts here
   defer func(original func() (*sql.DB, error)) {
      // restore original DB (after test)
      getDB = original
   }(getDB)

   // replace getDB() function for this test
   getDB = func() (*sql.DB, error) {
      return nil, errors.New("getDB() failed")
   }
   // end of monkey patch

   // inputs
   in := &Person{
      FullName: "Jake Blues",
      Phone:    "01234567890",
      Currency: "AUD",
      Price:    123.45,
   }

   // call function
   resultID, err := Save(in)
   require.Error(t, err)
   assert.Equal(t, defaultPersonID, resultID)
}

您可能已经注意到正常路径和错误路径测试之间存在大量重复。这在 Go 语言测试中有些常见,可能是因为我们有意地重复调用一个函数,使用不同的输入或环境,从根本上来说是在为我们测试的对象记录和强制执行行为契约。

鉴于这些基本职责,我们应该努力确保我们的测试既易于阅读又易于维护。为了实现这些目标,我们可以应用 Go 语言中我最喜欢的一个特性,即表驱动测试(github.com/golang/go/wiki/TableDrivenTests)。

使用表驱动测试减少测试膨胀

使用表驱动测试,我们在测试开始时定义一系列场景(通常是函数输入、模拟配置和我们的期望),然后是一个场景运行器,通常是测试的一部分,否则会重复。让我们看一个例子。Load()函数的正常路径测试如下所示:

func TestLoad_happyPath(t *testing.T) {
   expectedResult := &Person{
      ID:       2,
      FullName: "Paul",
      Phone:    "0123456789",
      Currency: "CAD",
      Price:    23.45,
   }

   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   require.NoError(t, err)

   // configure the mock db
   queryRegex := convertSQLToRegex(sqlLoadByID)
   dbMock.ExpectQuery(queryRegex).WillReturnRows(
      sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
         AddRow(2, "Paul", "0123456789", "CAD", 23.45))

   // monkey patching the database
   defer func(original sql.DB) {
      // restore original DB (after test)
      db = &original
   }(*db)

   db = testDb
   // end of monkey patch

   // call function
   result, err := Load(2)

   // validate results
   assert.Equal(t, expectedResult, result)
   assert.NoError(t, err)
   assert.NoError(t, dbMock.ExpectationsWereMet())
}

这个函数大约有 11 行功能代码(去除格式化后),其中大约有 9 行在我们对 SQL 加载失败的测试中几乎是相同的。将其转换为表驱动测试得到如下结果:

func TestLoad_tableDrivenTest(t *testing.T) {
   scenarios := []struct {
      desc            string
      configureMockDB func(sqlmock.Sqlmock)
      expectedResult  *Person
      expectError     bool
   }{
      {
         desc: "happy path",
         configureMockDB: func(dbMock sqlmock.Sqlmock) {
            queryRegex := convertSQLToRegex(sqlLoadAll)
            dbMock.ExpectQuery(queryRegex).WillReturnRows(
               sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
                  AddRow(2, "Paul", "0123456789", "CAD", 23.45))
         },
         expectedResult: &Person{
            ID:       2,
            FullName: "Paul",
            Phone:    "0123456789",
            Currency: "CAD",
            Price:    23.45,
         },
         expectError: false,
      },
      {
         desc: "load error",
         configureMockDB: func(dbMock sqlmock.Sqlmock) {
            queryRegex := convertSQLToRegex(sqlLoadAll)
            dbMock.ExpectQuery(queryRegex).WillReturnError(
                errors.New("something failed"))
         },
         expectedResult: nil,
         expectError:    true,
      },
   }

   for _, scenario := range scenarios {
      // define a mock db
      testDb, dbMock, err := sqlmock.New()
      require.NoError(t, err)

      // configure the mock db
      scenario.configureMockDB(dbMock)

      // monkey db for this test
      original := *db
      db = testDb

      // call function
      result, err := Load(2)

      // validate results
      assert.Equal(t, scenario.expectedResult, result, scenario.desc)
      assert.Equal(t, scenario.expectError, err != nil, scenario.desc)
      assert.NoError(t, dbMock.ExpectationsWereMet())

      // restore original DB (after test)
      db = &original
      testDb.Close()
   }
}

抱歉,这里有很多内容,让我们把它分成几个部分:

scenarios := []struct {
   desc            string
   configureMockDB func(sqlmock.Sqlmock)
   expectedResult  *Person
   expectError     bool
}{

这些行定义了一个切片和一个匿名结构,它将是我们的场景列表。在这种情况下,我们的场景包含以下内容:

  • 描述:这对于添加到测试错误消息中很有用。

  • 模拟配置:由于我们正在测试代码如何对来自数据库的不同响应做出反应,这就是大部分魔法发生的地方。

  • 预期结果:相当标准,考虑到输入和环境(即模拟配置)。这是我们想要得到的。

  • 一个布尔值,表示我们是否期望出现错误:我们可以在这里使用错误值;这样会更精确。但是,我更喜欢使用自定义错误,这意味着输出不是常量。我还发现错误消息可能随时间而改变,因此检查的狭窄性使测试变得脆弱。基本上,我在测试的特定性和耐久性之间进行了权衡。

然后我们有我们的场景,每个测试用例一个:

{
   desc: "happy path",
   configureMockDB: func(dbMock sqlmock.Sqlmock) {
      queryRegex := convertSQLToRegex(sqlLoadAll)
      dbMock.ExpectQuery(queryRegex).WillReturnRows(
         sqlmock.NewRows(strings.Split(sqlAllColumns, ", ")).
            AddRow(2, "Paul", "0123456789", "CAD", 23.45))
   },
   expectedResult: &Person{
      ID:       2,
      FullName: "Paul",
      Phone:    "0123456789",
      Currency: "CAD",
      Price:    23.45,
   },
   expectError: false,
},
{
  desc: "load error",
  configureMockDB: func(dbMock sqlmock.Sqlmock) {
    queryRegex := convertSQLToRegex(sqlLoadAll)
    dbMock.ExpectQuery(queryRegex).WillReturnError(
        errors.New("something failed"))
  },
  expectedResult: nil,
  expectError: true,
},

现在有测试运行器,基本上是对所有场景的循环:

for _, scenario := range scenarios {
   // define a mock db
   testDb, dbMock, err := sqlmock.New()
   require.NoError(t, err)

   // configure the mock db
   scenario.configureMockDB(dbMock)

   // monkey db for this test
   original := *db
   db = testDb

   // call function
   result, err := Load(2)

   // validate results
   assert.Equal(t, scenario.expectedResult, result, scenario.desc)
   assert.Equal(t, scenario.expectError, err != nil, scenario.desc)
   assert.NoError(t, dbMock.ExpectationsWereMet())

   // restore original DB (after test)
   db = &original
   testDb.Close()
}

这个循环的内容与我们原始测试的内容非常相似。通常先编写正常路径测试,然后通过添加其他场景将其转换为表驱动测试更容易。

也许我们的测试运行器和原始函数之间唯一的区别是我们在进行 monkey patch。我们不能在for循环中使用defer,因为defer只有在函数退出时才会运行;因此,我们必须在循环结束时恢复数据库。

在这里使用表驱动测试不仅减少了测试代码中的重复,而且还有其他两个重要的优点。首先,它将测试简化为输入等于输出,使它们非常容易理解,也很容易添加更多的场景。

其次,可能会发生变化的代码,即函数调用本身,只存在一个地方。如果该函数被修改以接受其他输入或返回其他值,我们只需要在一个地方进行修复,而不是每个测试场景一次。

包之间的猴子补丁

到目前为止,我们已经看到了在我们的data包内部进行测试的目的而进行猴子补丁私有全局变量或函数。但是如果我们想测试其他包会发生什么呢?将业务逻辑层与数据库解耦也许是个好主意?这样可以确保我们的业务逻辑层测试不会因为无关的事件(例如优化我们的 SQL 查询)而出错。

再次,我们面临一个困境;我们可以开始大规模的重构,但正如我们之前提到的,这是一项艰巨的工作,而且风险很大,特别是没有测试来避免麻烦。让我们看看我们拥有的最简单的业务逻辑包,即get包:

// Getter will attempt to load a person.
// It can return an error caused by the data layer or 
// when the requested person is not found
type Getter struct {
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := data.Load(ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are encapsulating the 
         // implementation details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

如你所见,这个函数除了从数据库加载人员之外几乎没有做什么。因此,你可以认为它不需要存在;别担心,我们稍后会赋予它更多的责任。

那么,我们如何在没有数据库的情况下进行测试呢?首先想到的可能是像之前一样对数据库池或getDatabase()函数进行猴子补丁。

这样做是可行的,但会很粗糙,并且会污染data包的公共 API,这是测试引起的破坏的明显例子。这也不会使该包与data包的内部实现解耦。事实上,这会使情况变得更糟。data包的实现的任何更改都可能破坏我们对该包的测试。

另一个需要考虑的方面是,我们可以进行任何想要的修改,因为这项服务很小,而且我们拥有所有的代码。这通常并非如此;该包可能由另一个团队拥有,它可能是外部依赖的一部分,甚至是标准库的一部分。因此,最好养成的习惯是保持我们的更改局限于我们正在处理的包。

考虑到这一点,我们可以采用我们在上一节中简要介绍的一个技巧,即猴子补丁的优势。让我们拦截get包对data包的调用,如下面的代码所示:

// Getter will attempt to load a person.
// It can return an error caused by the data layer or 
// when the requested person is not found
type Getter struct {
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := loader(ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the 
         // implementation details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// this function as a variable allows us to Monkey Patch during testing
var loader = data.Load

现在,我们可以通过猴子补丁拦截调用,如下面的代码所示:

func TestGetter_Do_happyPath(t *testing.T) {
   // inputs
   ID := 1234

   // monkey patch calls to the data package
   defer func(original func(ID int) (*data.Person, error)) {
      // restore original
      loader = original
   }(loader)

   // replace method
   loader = func(ID int) (*data.Person, error) {
      result := &data.Person{
         ID:       1234,
         FullName: "Doug",
      }
      var resultErr error

      return result, resultErr
   }
   // end of monkey patch

   // call method
   getter := &Getter{}
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "Doug", person.FullName)
}

现在,我们的测试不依赖于数据库或data包的任何内部实现细节。虽然我们并没有完全解耦这些包,但我们已经大大减少了get包中的测试必须正确执行的事项。这可以说是通过猴子补丁实现 DI 的一个要点,通过减少对外部因素的依赖并增加测试的焦点,减少测试可能出错的方式。

当魔法消失时

在本书的早些时候,我挑战你以批判的眼光审视本书中提出的每种 DI 方法。考虑到这一点,我们应该考虑猴子补丁的潜在成本。

数据竞争——我们在示例中看到,猴子补丁是用执行特定测试所需的方式替换全局变量的过程。这也许是最大的问题。用特定的东西替换全局的,因此是共享的,会在该变量上引发数据竞争。

为了更好地理解这种数据竞争,我们需要了解 Go 如何运行测试。默认情况下,包内的测试是按顺序执行的。我们可以通过在测试中标记t.Parallel()来减少测试执行时间。对于我们当前的data包测试,将测试标记为并行会导致数据竞争出现,从而导致测试结果不可预测。

Go 测试的另一个重要特性是,Go 可以并行执行多个包。像t.Parallel()一样,这对我们的测试执行时间来说可能是很棒的。通过我们当前的代码,我们可以确保安全,因为我们只在与测试相同的包内进行了猴子补丁。如果我们在包边界之间进行了猴子补丁,那么数据竞争就会出现。

如果您的测试不稳定,并且怀疑存在数据竞争,您可以尝试使用 Go 的内置竞争检测器(golang.org/doc/articles/race_detector.html):

$ go test -race ./...

如果这样找不到问题,您可以尝试按顺序运行所有测试:

$ go test -p 1 ./...

如果测试开始一致通过,那么您将需要开始查找数据竞争。

详细测试——正如您在我们的测试中所看到的,猴子补丁和恢复的代码可能会变得相当冗长。通过一点重构,可以减少样板代码。例如,看看这个:

func TestSaveConfig(t *testing.T) {
   // inputs
   filename := "my-config.json"
   cfg := &Config{
      Host: "localhost",
      Port: 1234,
   }

   // monkey patch the file writer
   defer func(original func(filename string, data []byte, perm os.FileMode) error) {
      // restore the original
      writeFile = original
   }(writeFile)

   writeFile = func(filename string, data []byte, perm os.FileMode) error {
      // output error
      return nil
   }

   // call the function
   err := SaveConfig(filename, cfg)

   // validate the result
   assert.NoError(t, err)
}

我们可以将其更改为:

func TestSaveConfig_refactored(t *testing.T) {
   // inputs
   filename := "my-config.json"
   cfg := &Config{
      Host: "localhost",
      Port: 1234,
   }

   // monkey patch the file writer
   defer restoreWriteFile(writeFile)

   writeFile = mockWriteFile(nil)

   // call the function
   err := SaveConfig(filename, cfg)

   // validate the result
   assert.NoError(t, err)
}

func mockWriteFile(result error) func(filename string, data []byte, perm os.FileMode) error {
   return func(filename string, data []byte, perm os.FileMode) error {
      return result
   }
}

// remove the restore function to reduce from 3 lines to 1
func restoreWriteFile(original func(filename string, data []byte, perm os.FileMode) error) {
   // restore the original
   writeFile = original
}

在这次重构之后,我们的测试中重复的部分大大减少,从而减少了维护的工作量,但更重要的是,测试不再被所有与猴子补丁相关的代码所掩盖。

混淆的依赖关系——这不是猴子补丁本身的问题,而是一般依赖管理风格的问题。在传统的 DI 中,依赖关系作为参数传递,使关系显式可见。

从用户的角度来看,这种缺乏参数可以被认为是代码 UX 的改进;毕竟,更少的输入通常会使函数更容易使用。但是,当涉及测试时,事情很快变得混乱。

在我们之前的示例中,“SaveConfig()”函数依赖于“ioutil.WriteFile()”,因此对该依赖进行模拟以测试“SaveConfig()”似乎是合理的。但是,当我们需要测试调用“SaveConfig()”的函数时会发生什么?

SaveConfig()的用户如何知道他们需要模拟ioutil.WriteFile()

由于关系混乱,所需的知识增加了,测试长度也相应增加;不久之后,我们在每个测试的开头就会有半屏幕的函数猴子补丁。

总结

在本章中,我们学习了如何利用猴子补丁来在测试中替换依赖关系。通过猴子补丁,我们已经测试了全局变量,解耦了包,并且消除了对数据库和文件系统等外部资源的依赖。我们通过一些实际示例来改进了我们示例服务的代码,并坦率地讨论了使用猴子补丁的优缺点。

在下一章中,我们将研究第二种,也许是最传统的 DI 技术,即构造函数注入的依赖注入。通过它,我们将进一步改进我们服务的代码。

问题

  1. 猴子补丁是如何工作的?

  2. 猴子补丁的理想用例是什么?

  3. 如何使用猴子补丁来解耦两个包而不更改依赖包?

进一步阅读

Packt 还有许多其他关于猴子补丁的学习资源:

第六章:构造函数注入的依赖注入

在本章中,我们将研究依赖注入DI)最独特的形式之一,即猴子补丁,然后将其推向另一个极端,看看可能是最正常或传统的构造函数注入。

虽然构造函数注入是如此普遍,以至于您甚至可能在不知不觉中使用它,但它有许多微妙之处,特别是关于优缺点的考虑。

与上一章类似,我们将把这种技术应用到我们的示例服务中,从而获得显著的改进。

本章将涵盖以下主题:

  • 构造函数注入

  • 构造函数注入的优点

  • 应用构造函数注入

  • 构造函数注入的缺点

技术要求

熟悉我们在第四章中介绍的服务代码将是有益的,ACME 注册服务简介

您可能还会发现阅读和运行本章的完整代码版本很有用,这些代码可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch06上找到。

获取代码并配置示例服务的说明可在此处的 README 中找到github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch06/acme中找到我们的服务代码,并已应用了本章的更改。

构造函数注入

当对象需要一个依赖项来工作时,确保该依赖项始终可用的最简单方法是要求所有用户将其作为对象构造函数的参数提供。这被称为构造函数注入

让我们通过一个示例来解释,我们将提取一个依赖项,将其概括,并实现构造函数注入。假设我们正在为一个在线社区构建网站。对于这个网站,我们希望在用户注册时向新用户发送电子邮件。这段代码可能是这样的:

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.mailer.Send(to, body)
}

我们将*Mailer设为私有,以确保类的内部封装。我们可以通过将其定义为构造函数的参数来注入*Mailer依赖项,如下面的代码所示:

func NewWelcomeSender(in *Mailer) (*WelcomeSender, error) {
   // guard clause
   if in == nil {
      return nil, errors.New("programmer error: mailer must not provided")
   }

   return &WelcomeSender{
      mailer: in,
   }, nil
}

在前面的示例中,我们包含了一个守卫子句。其目的是确保提供的依赖项不是nil。这并非必需,是否包含取决于个人风格;这样做是完全可以接受的:

func NewWelcomeSenderNoGuard(in *Mailer) *WelcomeSender {
   return &WelcomeSender{
      mailer: in,
   }
}

您可能会认为我们已经完成了。毕竟,我们正在将依赖项Mailer注入WelcomeSender

遗憾的是,我们还没有完全达到目标。事实上,我们错过了 DI 的真正目的。不,这不是测试,尽管我们会做到这一点。DI 的真正目的是解耦。

在这一点上,我们的WelcomeSender没有Mailer实例就无法工作。它们之间耦合度很高。因此,让我们通过应用第二章中的依赖反转原则部分来解耦它们,Go 的 SOLID 设计原则

首先,让我们看一下Mailer结构:

// Mailer sends and receives emails
type Mailer struct{
   Host string
   Port string
   Username string
   Password string
}

func (m *Mailer) Send(to string, body string) error {
   // send email
   return nil
}

func (m *Mailer) Receive(address string) (string, error) {
   // receive email
   return "", nil
}

我们可以通过基于方法签名的接口将其转换为抽象:

// Mailer sends and receives emails
type MailerInterface interface {
   Send(to string, body string) error
   Receive(address string) (string, error)
}

等一下,我们只需要发送电子邮件。让我们应用接口隔离原则,将接口减少到我们使用的方法,并更新我们的构造函数。现在,我们有这样的代码:

type Sender interface {
   Send(to string, body string) error
}

func NewWelcomeSenderV2(in Sender) *WelcomeSenderV2 {
   return &WelcomeSenderV2{
      sender: in,
   }
}

通过这一个小改变,发生了一些方便的事情。首先,我们的代码现在完全自包含。这意味着任何错误、扩展、测试或其他更改只涉及这个包。其次,我们可以使用模拟或存根来测试我们的代码,阻止我们用电子邮件轰炸自己,并要求一个工作的电子邮件服务器来通过我们的测试。最后,我们不再受限于Mailer类。如果我们想要从欢迎电子邮件更改为短信或推特,我们可以将我们的输入参数更改为不同的Sender并完成。

通过将我们的依赖项定义为一个抽象(作为一个本地接口)并将该依赖项传递到我们的构造函数中,我们已经明确地定义了我们的要求,并在测试和扩展中给了我们更大的自由度。

解决房间里的鸭子

在我们深入研究构造函数注入之前,我们应该花一点时间来谈谈鸭子类型。

我们之前提到过 Go 对隐式接口的支持,以及我们如何利用它来执行依赖反转和解耦对象。对于熟悉 Python 或 Ruby 的人来说,这可能感觉像鸭子类型。对于其他人来说,什么是鸭子类型?它被描述如下:

如果它看起来像一只鸭子,它叫起来像一只鸭子,那么它就是一只鸭子

或者,更加技术性地说:

在运行时,仅根据访问的对象部分动态确定对象的适用性

让我们看一个 Go 的例子,看看它是否支持鸭子类型:

type Talker interface {
   Speak() string
   Shout() string
}

type Dog struct{}

func (d Dog) Speak() string {
   return "Woof!"
}

func (d Dog) Shout() string {
   return "WOOF!"
}

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

   fmt.Print(talker.Speak())
}

正如你所看到的,我们的Dog类型并没有声明它实现了Talker接口,正如我们可能从 Java 或 C#中期望的那样,但我们仍然能够将它用作Talker

从我们的例子来看,Go 可能支持鸭子类型,但存在一些问题:

  • 在鸭子类型中,兼容性是在运行时确定的;Go 将在编译时检查我们的Dog类型是否实现了Talker

  • 在鸭子类型中,适用性仅基于访问的对象部分。在前面的例子中,只有Speak()方法被实际使用。然而,如果我们的Dog类型没有实现Shout()方法,那么它将无法编译通过。

那么如果它不是鸭子类型,那它是什么?有点类似的东西叫做结构类型。结构类型是一种静态类型系统,它根据类型的结构在编译时确定适用性。不要让这个不太花哨的名字愚弄你;结构类型是非常强大和极其有用的。Go 提供了编译时检查的安全性,而不需要明确声明实现的接口的强制形式。

构造函数注入的优势

对于许多程序员和编程语言,构造函数注入是它们的默认 DI 方法。因此,它具有许多优势也许并不奇怪。

与依赖项生命周期的分离-构造函数注入,像大多数 DI 方法一样,将依赖项的生命周期管理与被注入的对象分开。通过这样做,对象变得更加简单和易于理解。

易于实现-正如我们在之前的例子中看到的,将这个变得很容易:

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   Mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.Mailer.Send(to, body)
}

并将其更改为:

func NewWelcomeSender(mailer *Mailer) *WelcomeSender {
   return &WelcomeSender{
      mailer: mailer,
   }
}

// WelcomeSender sends a Welcome email to new users
type WelcomeSender struct {
   mailer *Mailer
}

func (w *WelcomeSender) Send(to string) error {
   body := w.buildMessage()

   return w.mailer.Send(to, body)
}

可预测且简洁-通过将依赖项的赋值移动到构造函数,我们不仅明确了我们的要求,而且还确保依赖项被设置并可用于我们的方法。如果在构造函数中包含了一个守卫子句,这一点尤其正确。没有构造函数,每个方法可能都必须包含一个守卫子句(如下例所示),否则可能会出现 nil 指针异常:

type Car struct {
   Engine Engine
}

func (c *Car) Drive() error {
   if c.Engine == nil {
      return errors.New("engine ie missing")
   }

   // use the engine
   c.Engine.Start()
   c.Engine.IncreasePower()

   return nil
}

func (c *Car) Stop() error {
   if c.Engine == nil {

      return errors.New("engine ie missing")
   }

   // use the engine
   c.Engine.DecreasePower()
   c.Engine.Stop()

   return nil
}

而不是更简洁的以下内容:

func NewCar(engine Engine) (*Car, error) {
  if engine == nil {
    return nil, errors.New("invalid engine supplied")
  }

  return &Car{
    engine: engine,
  }, nil
}

type Car struct {
   engine Engine
}

func (c *Car) Drive() error {
   // use the engine
   c.engine.Start()
   c.engine.IncreasePower()

   return nil
}

func (c *Car) Stop() error {
   // use the engine
   c.engine.DecreasePower()
   c.engine.Stop()

   return nil
}

通过扩展,方法还可以假定我们的依赖在访问依赖时处于良好的准备状态,因此无需在构造函数之外的任何地方处理初始化延迟或配置问题。此外,访问依赖时没有与数据竞争相关的问题。它在构造过程中设置,永远不会改变。

封装 - 构造函数注入提供了关于对象如何使用依赖的高度封装。考虑一下,如果我们通过添加FillPetrolTank()方法来扩展我们之前的Car示例,如下面的代码所示:

func (c *Car) FillPetrolTank() error {
   // use the engine
   if c.engine.IsRunning() {
      return errors.New("cannot fill the tank while the engine is running")
   }

   // fill the tank!
   return c.fill()
}

如果我们假设加油Engine无关,并且在调用此方法之前没有填充Engine,那么原来的代码会发生什么?

如果没有构造函数注入来确保我们提供了Engine,这个方法将会崩溃并引发空指针异常。或者,这个方法也可以不使用构造函数注入来编写,如下面的代码所示:

func (c *Car) FillPetrolTank(engine Engine) error {
   // use the engine
   if engine.IsRunning() {
      return errors.New("cannot fill the tank while the engine is running")
   }

   // fill the tank!
   return c.fill()
}

然而,这个版本现在泄漏了方法需要Engine来工作的实现细节。

帮助发现代码异味 - 向现有结构或接口添加只是一个功能是一个容易陷阱。正如我们在单一职责原则的早期讨论中所看到的,我们应该抵制这种冲动,尽可能保持我们的对象和接口尽可能小。发现对象承担太多责任的一个简单方法是计算其依赖关系。通常,对象承担的责任越多,它积累的依赖关系就越多。因此,通过将所有依赖关系清楚地列在一个地方,即构造函数中,很容易就能察觉到可能有些不对劲。

改进测试场景覆盖率

我们要做的第一件事是在测试中消除对上游货币服务的依赖。然后,我们将继续添加测试来覆盖以前无法覆盖的其他场景。我们当前的测试看起来是这样的:

func TestRegisterHandler_ServeHTTP(t *testing.T) {
   // ensure the test always fails by giving it a timeout
   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   defer cancel()

   // Create and start a server
   // With out current implementation, we cannot test this handler without
   // a full server as we need the mux.
   address, err := startServer(ctx)
   require.NoError(t, err)

   // build inputs
   validRequest := buildValidRequest()
   response, err := http.Post("http://"+address+"/person/register", "application/json", validRequest)

   // validate outputs
   require.NoError(t, err)
   require.Equal(t, http.StatusCreated, response.StatusCode)
   defer response.Body.Close()

   // call should output the location to the new person
   headerLocation := response.Header.Get("Location")
   assert.Contains(t, headerLocation, "/person/")
}

我们目前正在启动整个 HTTP 服务器;这似乎有些过分,所以让我们将测试范围缩小到只有RegisterHandler

这种测试范围的缩减还将通过消除其他外围问题来改进测试,比如 HTTP 路由。

由于我们知道我们将有多个类似的场景需要测试,让我们从添加表驱动测试的框架开始:

func TestRegisterHandler_ServeHTTP(t *testing.T) {
   scenarios := []struct {
      desc           string
      inRequest      func() *http.Request
      inModelMock    func() *MockRegisterModel
      expectedStatus int
      expectedHeader string
   }{
      // scenarios go here
   }

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // test goes here
      })
   }
}

从原始测试中,我们可以看到我们的输入是*http.Request*MockRegisterModel。两者都有点复杂,需要创建和配置,所以我们选择用一个函数来构建它们。同样,从原始测试中,我们可以看到测试的输出是 HTTP 响应代码和Location头部。

这四个对象,*http.Request*MockRegistrationModel,HTTP 状态码和Location头部,将构成我们测试场景的配置,如前面的代码所示。

为了完成我们的表驱动测试,我们将原始测试的内容复制到测试循环中,并替换输入和输出,如下面的代码所示:

for _, s := range scenarios {
   scenario := s
   t.Run(scenario.desc, func(t *testing.T) {
      // define model layer mock
      mockRegisterModel := scenario.inModelMock()

      // build handler
      handler := &RegisterHandler{
         registerer: mockRegisterModel,
      }

      // perform request
      response := httptest.NewRecorder()
      handler.ServeHTTP(response, scenario.inRequest())

      // validate outputs
      require.Equal(t, scenario.expectedStatus, response.Code)

      // call should output the location to the new person
      resultHeader := response.Header().Get("Location")
      assert.Equal(t, scenario.expectedHeader, resultHeader)

      // validate the mock was used as we expected
      assert.True(t, mockRegisterModel.AssertExpectations(t))
   })
}

现在我们已经把所有的部分都准备好了,我们开始编写我们的测试场景,从正常情况开始:

{
   desc: "Happy Path",
   inRequest: func() *http.Request {
      validRequest := buildValidRegisterRequest()
      request, err := http.NewRequest("POST", "/person/register", validRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // valid downstream configuration
      resultID := 1234
      var resultErr error

      mockRegisterModel := &MockRegisterModel{}
      mockRegisterModel.On("Do", mock.Anything).Return(resultID, resultErr).Once()

      return mockRegisterModel
   },
   expectedStatus: http.StatusCreated,
   expectedHeader: "/person/1234/",
},

接下来,我们需要测试我们的代码是否能很好地处理错误。那么我们可以期望出现什么样的错误?我们可以检查代码,寻找类似if err != nil的代码。

这可能感觉像一个有用的快捷方式,但请考虑一下。如果我们的测试反映了当前的实现,当实现发生变化时会发生什么?

一个更好的角度是考虑的不是实现,而是功能本身以及其情况或使用。几乎总是有两个答案适用。用户错误,如不正确的输入,以及从依赖项返回的错误

我们的用户错误场景如下所示:

{
   desc: "Bad Input / User Error",
   inRequest: func() *http.Request {
      invalidRequest := bytes.NewBufferString(`this is not valid JSON`)
      request, err := http.NewRequest("POST", "/person/register", invalidRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // Dependency should not be called
      mockRegisterModel := &MockRegisterModel{}
      return mockRegisterModel
   },
   expectedStatus: http.StatusBadRequest,
   expectedHeader: "",
},

我们从依赖项返回的错误如下所示:

{
   desc: "Dependency Failure",
   inRequest: func() *http.Request {
      validRequest := buildValidRegisterRequest()
      request, err := http.NewRequest("POST", "/person/register", validRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // call to the dependency failed
      resultErr := errors.New("something failed")

      mockRegisterModel := &MockRegisterModel{}
      mockRegisterModel.On("Do", mock.Anything).Return(0, resultErr).Once()

      return mockRegisterModel
   },
   expectedStatus: http.StatusInternalServerError,
   expectedHeader: "",
},

有了这三个测试,我们有了合理的测试场景覆盖,但我们遇到了一个问题。我们的从依赖项返回的错误场景导致 HTTP 状态码为400(错误请求),而不是预期的 HTTP500(内部服务器错误)。在查看模型层的实现后,显然400错误是有意的,并且应该表明请求不完整,因此验证失败。

我们的第一反应很可能是希望将验证移到 HTTP 层。但请考虑:如果我们添加另一种服务器类型,例如 gRPC,会发生什么?这种验证仍然需要执行。那么我们如何将用户错误与系统错误分开呢?

另一个选择是从模型返回命名错误以进行验证错误,另一个选择是其他错误。很容易检测和分别处理响应。然而,这将导致我们的代码与model包保持紧密耦合。

另一个选择是将我们对模型包的调用分成两个调用,也许是Validate()Do(),但这会减少我们的model包的用户体验。我将留给您决定这些或其他选项是否适合您。

在对RegisterHandler和此包中的其他处理程序进行这些更改后,我们可以使用 Go 的测试覆盖工具来查看是否错过了任何明显的场景。

对于 Unix/Linux 用户,我在本章的源代码中包含了一个用于生成 HTML 覆盖率的脚本,步骤应该类似于其他平台。该脚本可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/blob/master/ch06/pcov-html找到。

请注意,这里的测试覆盖百分比并不重要。重要的是要查看哪些代码没有被任何测试执行,并决定是否表明可能发生错误,因此我们需要添加的场景。

现在我们的RegisterHandler的形式好多了,我们可以以同样的方式将构造函数注入到REST包中的其他处理程序中。

这些更改的结果可以在本章的源代码中看到github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch06/acme/internal/rest

应用构造函数注入

让我们将构造函数注入到我们的 ACME 注册服务中。这次我们将重构 REST 包,从Register端点开始。您可能还记得Register是我们服务中的三个端点之一,其他端点是GetList

Register端点有三个责任:

  • 验证注册是否完成并有效

  • 调用货币转换服务将注册价格转换为注册时请求的货币

  • 保存注册和转换后的注册价格到数据库中

我们Register端点的代码目前如下所示:

// RegisterHandler is the HTTP handler for the "Register" endpoint
// In this simplified example we are assuming all possible errors 
// are user errors and returning "bad request" HTTP 400.
// There are some programmer errors possible but hopefully these 
// will be caught in testing.
type RegisterHandler struct {
}

// ServeHTTP implements http.Handler
func (h *RegisterHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract payload from request
   requestPayload, err := h.extractPayload(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // register person
   id, err := h.register(requestPayload)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // happy path
   response.Header().Add("Location", fmt.Sprintf("/person/%d/", id))
   response.WriteHeader(http.StatusCreated)
}

// extract payload from request
func (h *RegisterHandler) extractPayload(request *http.Request) (*registerRequest, error) {
   requestPayload := &registerRequest{}

   decoder := json.NewDecoder(request.Body)
   err := decoder.Decode(requestPayload)
   if err != nil {
      return nil, err
   }

   return requestPayload, nil
}

// call the logic layer
func (h *RegisterHandler) register(requestPayload *registerRequest) (int, error) {
   person := &data.Person{
      FullName: requestPayload.FullName,
      Phone:    requestPayload.Phone,
      Currency: requestPayload.Currency,
   }

   registerer := &register.Registerer{}
   return registerer.Do(person)
}

令人失望的是,我们目前只对此函数进行了一个测试,并且它很容易出错。它需要数据库和我们的下游汇率服务都可访问和配置。

虽然我们可以确保我们的本地数据库正在工作,并且对其进行的任何更改不会影响除我们之外的任何人,但下游汇率服务在互联网上并且受到速率限制。我们无法控制它或它何时工作。

这意味着即使我们只有一个测试,该测试也有很高的潜力会因为我们无法控制的原因而变得烦人并且难以维护。

幸运的是,我们不仅可以消除这些依赖,还可以使用模拟来创建我们无法实现的情况。例如,通过模拟,我们可以测试当汇率服务停机或配额用完时的错误处理代码。

与依赖的解耦

第一步是确定我们希望注入的依赖项。对于我们的处理程序来说,这不是数据库或汇率调用。我们希望注入下一个软件层,也就是模型层。

具体来说,我们想要从我们的register方法中注入这一行:

registerer := &register.Registerer{}

按照我们使用更容易的相同过程,我们首先将对象提升为成员变量,如下面的代码所示:

// RegisterHandler is the HTTP handler for the "Register" endpoint
type RegisterHandler struct {
   registerer *register.Registerer
}

由于这对我们的代码与依赖的解耦没有任何作用,我们随后将我们的要求定义为一个本地接口,并更新成员变量,如下面的代码所示:

// RegisterModel will validate and save a registration
type RegisterModel interface {
   Do(in *data.Person) (int, error)
}

// RegisterHandler is the HTTP handler for the "Register" endpoint
type RegisterHandler struct {
   registerer RegisterModel
}

构建构造函数

现在RegisterHandler需要一个抽象依赖项,我们需要确保通过应用构造函数注入来设置依赖项,如下面的代码所示:

// NewRegisterHandler is the constructor for RegisterHandler
func NewRegisterHandler(model RegisterModel) *RegisterHandler {
   return &RegisterHandler{
      registerer: model,
   }
}

应用构造函数注入后,我们的RegisterHandler与模型层和外部资源(数据库和上游服务)的耦合性较小。我们可以利用这种较松散的耦合来改进和扩展我们的RegisterHandler的测试。

使用依赖图验证我们的改进

在我们结束对REST包的工作之前,让我们回顾一下我们的起点和现在的位置。当我们开始时,我们的处理程序与它们匹配的model包紧密耦合,并且测试不足。这两个问题都已得到解决。

让我们看看我们的依赖图是否显示出任何改善的迹象:

遗憾的是,它看起来仍然和以前一样。在深入代码后,我们找到了罪魁祸首:

// New will create and initialize the server
func New(address string) *Server {
   return &Server{
      address:         address,
      handlerGet:      NewGetHandler(&get.Getter{}),
      handlerList:     NewListHandler(&list.Lister{}),
      handlerNotFound: notFoundHandler,
      handlerRegister: NewRegisterHandler(&register.Registerer{}),
   }
}

我们在ServerREST包的一部分)的构造函数中实例化了我们的模型层对象。修复很容易,也很明显。我们将依赖项上推一级,如下面的代码所示:

// New will create and initialize the server
func New(address string,
   getModel GetModel,
   listModel ListModel,
   registerModel RegisterModel) *Server {

   return &Server{
      address:         address,
      handlerGet:      NewGetHandler(getModel),
      handlerList:     NewListHandler(listModel),
      handlerNotFound: notFoundHandler,
      handlerRegister: NewRegisterHandler(registerModel),
   }
}

再次检查我们的依赖图,现在终于显示了一些改进:

正如你所看到的,它更加平坦;REST包不依赖于模块层(listgetregister包)。

dataconfig包的依赖仍然太多,但我们将在后面的章节中处理这个问题。

构造函数注入的缺点

遗憾的是,对于 DI 来说,没有银弹。尽管构造函数注入的效用很大,但并非所有情况都适用。本节介绍了构造函数注入的缺点和限制。

可能导致大量更改-将构造函数注入应用于现有代码时,可能会导致大量更改。如果代码最初是以函数形式编写的,这一点尤其真实。

考虑以下代码:

// Dealer will shuffle a deck of cards and deal them to the players
func DealCards() (player1 []Card, player2 []Card) {
   // create a new deck of cards
   cards := newDeck()

   // shuffle the cards
   shuffler := &myShuffler{}
   shuffler.Shuffle(cards)

   // deal
   player1 = append(player1, cards[0])
   player2 = append(player2, cards[1])

   player1 = append(player1, cards[2])
   player2 = append(player2, cards[3])
   return
}

正如我们在前一节中看到的,要将其转换为使用构造函数注入,我们需要执行以下操作:

  • 从函数转换为结构体

  • 通过定义接口将对*myShuffler的依赖转换为抽象的

  • 创建一个构造函数

  • 更新所有当前使用该函数的地方,使用构造函数注入依赖

在所有的变化中,最令人担忧的是最后一个。在同一包中发生的更改,也就是说,在同一个包中更容易进行,因此风险更小,但对外部包的更改,特别是属于另一个团队的代码,风险显著更大。

除了非常小心外,减轻风险的最佳方法是进行测试。如果重构之前的代码几乎没有测试或没有测试,那么在开始任何重构之前首先创建一些测试是有益的。

使用猴子补丁的 DI 可能是一个吸引人的选择,可以在这些测试中替换任何依赖关系。是的,这些测试在切换到构造函数注入后需要重构或删除,但这并没有什么不对。有了测试,可以确保在重构之前代码是有效的,并且这些测试在重构过程中仍然具有信息性。换句话说,测试将有助于使重构更加安全。

可能引起初始化问题——在讨论构造函数注入的优势时,我们提到了将对象与其依赖的生命周期分离。这段代码和复杂性仍然存在,只是被推到了调用图的更高层。虽然能够分别处理这些问题显然是一个优势,但它也带来了一个次要问题:对象初始化顺序。考虑我们的 ACME 注册服务。它有三层,呈现层、模型层和数据层。

在呈现层能够工作之前,我们需要有一个可用的模型层。

在模型层能够工作之前,我们需要有一个可用的数据层。

在数据层能够正常工作之前,我们必须创建一个数据库连接池。

对于一个简单的服务来说,这已经变得有些复杂了。这种复杂性导致了许多 DI 框架的产生,我们将在第十章《现成的注入》中调查其中一个框架,谷歌的 Wire。

这里可能存在的另一个问题是在应用程序启动时将创建大量对象。虽然这会导致应用程序启动稍微变慢,但一旦支付了这个初始的“成本”,应用程序就不再会因为依赖关系的创建而延迟。

在这里需要考虑的最后一个初始化问题是调试。当依赖关系的创建和使用在代码的同一部分时,更容易理解和调试它们的生命周期和关系。

滥用的危险——鉴于这种技术如此易于理解和使用,滥用也是非常容易的。滥用的最明显迹象是构造函数参数过多。过多的构造函数参数可能表明对象承担了太多的责任,但也可能是提取和抽象了太多的依赖的症状。

在提取依赖之前,考虑封装。这个对象的用户需要了解哪些信息?我们能够隐藏与实现相关的信息越多,我们就越有灵活性进行重构。

另一个需要考虑的方面是:依赖关系是否需要被提取,还是可以留给配置?考虑以下代码:

// FetchRates rates from downstream service
type FetchRates struct{}

func (f *FetchRates) Fetch() ([]Rate, error) {
   // build the URL from which to fetch the rates
   url := downstreamServer + "/rates"

   // build request
   request, err := http.NewRequest("GET", url, nil)
   if err != nil {
      return nil, err
   }

   // fetch rates
   response, err := http.DefaultClient.Do(request)
   if err != nil {
      return nil, err
   }
   defer response.Body.Close()

   // read the content of the response
   data, err := ioutil.ReadAll(response.Body)
   if err != nil {
      return nil, err
   }

   // convert JSON bytes to Go structs
   out := &downstreamResponse{}
   err = json.Unmarshal(data, out)
   if err != nil {
      return nil, err
   }

   return out.Rates, nil
}

虽然可以对 *http.Client 进行抽象和注入,但这真的有必要吗?事实上,唯一需要改变的方面是基本 URI。我们将在第八章《配置注入》中进一步探讨这种方法。

不明显的要求——在 Go 中使用构造函数不是一个必需的模式。在一些团队中,甚至不是一个标准模式。因此,用户可能甚至没有意识到构造函数的存在以及他们必须使用它。鉴于没有注入依赖关系,代码很可能会崩溃,这不太可能导致生产问题,但可能会有些烦人。

一些团队尝试通过将对象设为私有,只导出构造函数和接口来解决这个问题,如下面的代码所示:

// NewClient creates and initialises the client
func NewClient(service DepService) Client {
   return &clientImpl{
      service: service,
   }
}

// Client is the exported API
type Client interface {
   DoSomethingUseful() (bool, error)
}

// implement Client
type clientImpl struct {
   service DepService
}

func (c *clientImpl) DoSomethingUseful() (bool, error) {
   // this function does something useful
   return false, errors.New("not implemented")
}

这种方法确保了构造函数的使用,但也有一些成本。

首先,我们现在必须保持接口和结构同步。这并不难,但这是额外的工作,可能会变得烦人。

其次,一些用户倾向于使用接口而不是在本地定义自己的接口。这会导致用户和导出接口之间的紧密耦合。这种耦合会使得向导出 API 添加内容变得更加困难。

考虑在另一个包中使用前面的示例,如下面的代码所示:

package other

// StubClient is a stub implementation of sdk.Client interface
type StubClient struct{}

// DoSomethingUseful implements sdk.Client
func (s *StubClient) DoSomethingUseful() (bool, error) {
   return true, nil
}

现在,如果我们向Client接口添加另一个方法,上述的代码将会失效。

构造函数不会被继承 - 与我们将在下一章中研究的方法和方法注入不同,构造函数在进行组合时不会被包括;相反,我们需要记住构造函数的存在并使用它们。

在进行组合时需要考虑的另一个因素是,内部结构的构造函数的任何参数都必须添加到外部结构的构造函数中,如下面的代码所示:

type InnerService struct {
   innerDep Dependency
}

func NewInnerService(innerDep Dependency) *InnerService {
   return &InnerService{
      innerDep: innerDep,
   }
}

type OuterService struct {
   // composition
   innerService *InnerService

   outerDep Dependency
}

func NewOuterService(outerDep Dependency, innerDep Dependency) *OuterService {
   return &OuterService{
      innerService: NewInnerService(innerDep),
      outerDep:     outerDep,
   }
}

像前面的关系会严重阻碍我们改变InnerService,因为我们将被迫对OuterService进行匹配的更改。

总结

在本章中,我们已经研究了构造函数注入的 DI。我们已经看到了它是多么容易理解和应用。这就是为什么它是许多程序员和许多情况下的默认选择。

我们已经看到构造函数注入如何为对象和其依赖之间的关系带来了一定程度的可预测性,特别是当我们使用守卫子句时。

通过将构造函数注入应用于我们的REST包,我们得到了一组松散耦合且易于遵循的对象。因此,我们能够轻松扩展我们的测试场景覆盖范围。我们还可以期望,对模型层的任何后续更改现在不太可能会不适当地影响我们的REST包。

在下一章中,我们将介绍 DI 的方法注入,这是处理可选依赖项的一种非常方便的方式。

问题

  1. 我们采用了哪些步骤来采用构造函数注入?

  2. 什么是守卫子句,何时使用它?

  3. 构造函数注入如何影响依赖项的生命周期?

  4. 构造函数注入的理想用例是什么?

第七章:方法注入的依赖注入

在上一章中,我们使用构造函数来注入我们的依赖项。这样做简化了我们的对象和其依赖项的生命周期。但是当我们的依赖项对于每个请求都不同的时候会发生什么?这就是方法注入发挥作用的地方。

本章将涵盖以下主题:

  • 方法注入

  • 方法注入的优势

  • 应用方法注入

  • 方法注入的缺点

技术要求

熟悉我们服务的代码可能会很有益,就像第四章中介绍的那样,ACME 注册服务简介

你可能还会发现阅读并运行本章的完整代码版本很有用,可在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch07找到。

有关如何获取代码和配置示例服务的说明,请参阅 README 文件,位于github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch07/acme中找到我们的服务代码,并已应用了本章的更改。

方法注入

方法注入随处可见。你可能每天都在使用它,甚至都没有意识到。你有没有写过这样的代码?:

fmt.Fprint(os.Stdout, "Hello World")

这样怎么样?:

req, err := http.NewRequest("POST", "/login", body)

这就是方法注入——将依赖项作为参数传递给请求。

让我们更详细地检查之前的例子。Fprint()的函数签名如下:

// Fprint formats using the default formats for its operands and writes 
// to w. It returns the number of bytes written and any write error 
// encountered.
func Fprint(w io.Writer, a ...interface{}) (n int, err error)

正如你所看到的,第一个参数io.Writer是这个函数的一个依赖项。这与任何其他函数调用不同的是,依赖项为函数调用提供了调用上下文或数据。

在第一个例子中,依赖是必需的,因为它被用作输出目的地。然而,在方法注入中使用的依赖项并不总是必需的。有时,依赖是可选的,就像我们在下面的例子中看到的那样:

func NewRequest(method, url string, body io.Reader) (*http.Request, error) {
   // validate method
   m, err := validateMethod(method)
   if err != nil {
      return nil, err
   }

   // validate URL
   u, err := validateURL(url)
   if err != nil {
      return nil, err
   }

   // process body (if exists)
   var b io.ReadCloser
   if body != nil {
      // read body
      b = ioutil.NopCloser(body)
   }

   // build Request and return
   req := &http.Request{
      URL:    u,
      Method: m,
      Body:   b,
   }

   return req, nil
}

这不是标准库中的实际实现;我已经简化了它以突出关键部分。在前面的例子中,io.Reader是可选的,因此受到守卫条款的保护。

在应用方法注入时,依赖项是特定于当前调用的,并且我们经常会发现自己需要守卫条款。为了帮助我们决定是否包含守卫条款,让我们深入研究一下我们的例子。

fmt.Fprint()标准库实现中,对io.Writer没有守卫条款,这意味着提供nil将导致函数发生 panic。这是因为没有io.Writer,输出就无处可去。

然而,在http.NewRequest()的实现中,有一个守卫条款,因为可能发出不包含请求体的 HTTP 请求。

那么,对于我们编写的函数来说意味着什么呢?在大多数情况下,我们应该避免编写可能导致崩溃的代码。让我们实现一个类似于Fprint()的函数,并看看是否可以避免崩溃。这是第一个粗糙的实现(带有 panic):

// TimeStampWriterV1 will output the supplied message to 
//writer preceded with a timestamp
func TimeStampWriterV1(writer io.Writer, message string) {
   timestamp := time.Now().Format(time.RFC3339)
   fmt.Fprintf(writer, "%s -> %s", timestamp, message)
}

避免nil写入器引起的 panic 的第一件事是什么?

我们可以添加一个守卫条款,并在未提供io.Writer时返回错误,如下面的代码所示:

// TimeStampWriterV2 will output the supplied message to 
//writer preceded with a timestamp
func TimeStampWriterV2(writer io.Writer, message string) error {
   if writer == nil {
      return errors.New("writer cannot be nil")
   }

   timestamp := time.Now().Format(time.RFC3339)
   fmt.Fprintf(writer,"%s -> %s", timestamp, message)

   return nil
}

虽然这看起来和感觉起来仍然像是常规的有效的 Go 代码,但我们现在有一个只有在我们程序员犯错时才会发生的错误。一个更好的选择是合理的默认值,如下面的代码所示:

// TimeStampWriterV3 will output the supplied message to 
//writer preceded with a timestamp
func TimeStampWriterV3(writer io.Writer, message string) {
   if writer == nil {
      // default to Standard Out
      writer = os.Stdout
   }

   timestamp := time.Now().Format(time.RFC3339)
   fmt.Fprintf(writer,"%s -> %s", timestamp, message)
}

这种技术称为防御性编码。其核心概念是即使体验降级,也比崩溃更好

尽管这些示例都是函数,但方法注入可以以完全相同的方式与结构体一起使用。有一个警告——不要将注入的依赖保存为成员变量。我们使用方法注入是因为依赖项提供函数调用上下文或数据。将依赖项保存为成员变量会导致它在调用之间共享,从而在请求之间泄漏此上下文。

方法注入的优势

正如我们在前一节中看到的,方法注入在标准库中被广泛使用。当您想要编写自己的共享库或框架时,它也非常有用。它的用途并不止于此。

它在函数中表现出色——每个人都喜欢一个好函数,特别是那些遵循单一责任原则部分的函数,如第二章中所讨论的Go 的 SOLID 设计原则。它们简单、无状态,并且可以被高度重用。将方法注入到函数中将通过将依赖项转换为抽象来增加其可重用性。考虑以下 HTTP 处理程序:

func HandlerV1(response http.ResponseWriter, request *http.Request) {
   garfield := &Animal{
      Type: "Cat",
      Name: "Garfield",
   }

   // encode as JSON and output
   encoder := json.NewEncoder(response)
   err := encoder.Encode(garfield)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   response.WriteHeader(http.StatusOK)
}

简单明了。它构建一个 Go 对象,然后将对象的内容作为 JSON 写入响应。很容易想象,我们接下来编写的下一个 HTTP 处理程序也将具有相同的最终九行。因此,让我们将它们提取到一个函数中,而不是复制和粘贴:

func outputAnimal(response http.ResponseWriter, animal *Animal) {
   encoder := json.NewEncoder(response)
   err := encoder.Encode(animal)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   // Happy Path
   response.WriteHeader(http.StatusOK)
}

现在让我们检查函数的输入;我们如何使这些更通用或抽象?

虽然 JSON 编码器只需要io.Writer而不是完整的http.ResponseWriter,但我们也输出 HTTP 状态码。因此,除了定义我们自己的接口之外,这是我们能做的最好的了。第二个参数是*Animal。在我们的函数中,我们实际上需要的最少是什么?

我们只使用*Animal作为 JSON 编码器的输入,其函数签名为

Encode(v interface{}) error。因此,我们可以减少我们的参数以匹配,得到以下结果:

func outputJSON(response http.ResponseWriter, data interface{}) {
   encoder := json.NewEncoder(response)
   err := encoder.Encode(data)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   // Happy Path
   response.WriteHeader(http.StatusOK)
}

通常,我避免使用interface{},因为它的使用会导致代码中充斥着类型转换和使代码更难阅读的语句。然而,在这种情况下,这是最好(也是唯一)的选择。

与其他章节中基于接口隔离原则的示例类似,最好是在函数或方法旁边定义最小可能的接口;或者如果可能的话,使用标准库中适当的最小接口(如io.Writer)。

依赖项充当数据——因为方法注入要求用户在每次调用时传入依赖项,这对依赖项和使用之间的关系产生了一些有趣的副作用。依赖项成为请求中的数据的一部分,并且可以极大地改变调用的结果。考虑以下代码:

func WriteLog(writer io.Writer, message string) error {
   _, err := writer.Write([]byte(message))
   return err
}

一个非常无害和直接的函数,但是看看当我们提供一些不同的依赖项时会发生什么:

// Write to console
WriteLog(os.Stdout, "Hello World!")

// Write to file
file, _ := os.Create("my-log.log")
WriteLog(file, "Hello World!")

// Write to TCP connection
tcpPipe, _ := net.Dial("tcp", "127.0.0.1:1234")
WriteLog(tcpPipe, "Hello World!")

依赖项是请求范围的——这些依赖项根据定义一直在被创建和销毁。因此,它们不适合构造函数注入甚至猴子补丁。当然,我们可以在每个请求中创建使用依赖项的对象,但这既不高效也不总是必要的。

让我们看一个 HTTP 请求处理程序:

// LoadOrderHandler is a HTTP handler that loads orders based on the current user and supplied user ID
type LoadOrderHandler struct {
   loader OrderLoader
}

// ServeHTTP implements http.Handler
func (l *LoadOrderHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract user from supplied authentication credentials
   currentUser, err := l.authenticateUser(request)
   if err != nil {
      response.WriteHeader(http.StatusUnauthorized)
      return
   }

   // extract order ID from request
   orderID, err := l.extractOrderID(request)
   if err != nil {
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // load order using the current user as a request-scoped dependency
   // (with method injection)
   order, err := l.loader.loadOrder(currentUser, orderID)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   // output order
   encoder := json.NewEncoder(response)
   err = encoder.Encode(order)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   response.WriteHeader(http.StatusOK)
}

作为 HTTP 处理程序,ServeHTTP()方法将针对每个传入的 HTTP 请求调用一次。LoadOrderHandler依赖于OrderLoader,我们将使用构造函数注入我们的实现AuthenticatedLoader

AuthenticatedLoader的实现可以在以下代码中看到:

// AuthenticatedLoader will load orders for based on the supplied owner
type AuthenticatedLoader struct {
   // This pool is expensive to create.  
   // We will want to create it once and then reuse it.
   db *sql.DB
}

// load the order from the database based on owner and order ID
func (a *AuthenticatedLoader) loadByOwner(owner Owner, orderID int) (*Order, error) {
   order, err := a.load(orderID)
   if err != nil {
      return nil, err
   }

   if order.OwnerID != owner.ID() {
      // Return not found so we do not leak information to hackers
      return nil, errNotFound
   }

   // happy path
   return order, nil
}

正如您所看到的,AuthenticatedLoader依赖于数据库连接池;这很昂贵,所以我们不希望在每个请求中重新创建它。

loadByOwner()函数接受使用方法注入的Owner。我们在这里使用方法注入,因为我们期望Owner会随着每个请求而变化。

这个例子使用构造函数注入长期依赖项和方法注入请求范围的依赖项。这样,我们就不会不必要地创建和销毁对象。

协助不可变性、无状态性和并发性—你可能会指责我有点夸大其词,但在编写一些非常并发的 Go 系统之后,我发现无状态和/或不可变的对象不太容易出现与并发相关的问题。方法注入本身并不赋予这些特性,但确实使其更容易实现。通过传递依赖项,所有权和使用范围更加清晰。此外,我们不需要担心对依赖项的并发访问,就像它是成员变量一样。

应用方法注入

在本节中,我们将通过应用方法注入来改进我们的 ACME 注册服务,也许会用到我最喜欢的 Go 标准库中的包,上下文包。该包的核心是Context接口,它自述如下:

上下文在 API 边界跨越期限、取消信号和请求范围值。它的方法可以同时被多个 goroutine 安全使用

那么,为什么我这么喜欢它呢?通过应用方法注入,以上下文作为依赖项,我能够构建我的处理逻辑,以便可以自动取消和清理所有内容。

快速回顾

在我们深入改变之前,让我们更深入地看一下我们示例服务提供的注册函数及其与外部资源的交互。以下图表概述了在调用注册端点时执行的步骤:

这些交互如下:

  1. 用户调用注册端点。

  2. 我们的服务调用汇率服务

  3. 我们的服务将注册信息保存到数据库中。

现在让我们考虑这些交互可能出现的问题。问问自己以下问题:

  • 可能会失败或变慢的是什么?

  • 我希望如何对失败做出反应或恢复?

  • 我的用户会如何对我的失败做出反应?

考虑到我们函数中的交互,立即想到两个问题:

  • 对数据库的调用可能会失败或变慢:我们如何从中恢复?我们可以进行重试,但这一点我们必须非常小心。数据库往往更像是有限资源而不是 web 服务。因此,重试请求实际上可能会进一步降低数据库的性能。

  • 对汇率服务的调用可能会失败或变慢:我们如何从中恢复?我们可以自动重试失败的请求。这将减少我们无法加载汇率的情况。假设业务批准,我们可以设置一些默认汇率来使用,而不是完全失败注册。

我们可以做出的最好的改变来提高系统的稳定性可能会让你感到意外。

我们可以根本不发出请求。如果我们能够改变注册流程,使得在处理的这一部分不需要汇率,那么它就永远不会给我们带来问题。

假设在我们(刻意制造的)例子中,前面提到的解决方案都不可用。我们唯一剩下的选择就是失败。如果加载汇率花费的时间太长,用户放弃并取消他们的请求会发生什么?他们很可能会认为注册失败,希望再次尝试。

考虑到这一点,我们最好的做法是放弃等待汇率,不再进一步处理注册。这个过程被称为提前停止

提前停止

提前停止是基于外部信号中止处理请求的过程(在本应完成之前)。

在我们的情况下,外部信号将是用户 HTTP 请求的取消。在 Go 中,http.Request对象包括一个Context()方法;以下是该方法文档的摘录:

对于传入的服务器请求,当客户端的连接关闭时,请求被取消(使用 HTTP/2),或者当 ServeHTTP 方法返回时,上下文被取消。

当请求被取消时意味着什么?对我们来说最重要的是,这意味着没有人在等待响应。

如果用户放弃等待响应,他们很可能会认为请求失败,并希望再次尝试。

我们应该如何对这种情况做出反应取决于我们正在实现的功能,但在许多情况下,主要是与加载或获取数据相关的功能,最有效的响应是停止处理请求。

对于我们服务的注册端点,这是我们选择的选项。我们将通过方法注入从请求中传递Context到我们代码的所有层。如果用户取消他们的请求,我们将立即停止处理请求。

既然我们清楚我们要达到什么目标,让我们从内部开始将方法注入到我们服务的层中。我们需要从内部开始,以确保我们的代码和测试在重构过程中保持运行。

将方法注入应用到数据包

快速提醒,data包是一个提供对底层 MySQL 数据库的简化和抽象访问的数据访问层DAL)。

以下是Save()函数的当前代码:

// Save will save the supplied person and return the ID of the newly 
// created person or an error.
// Errors returned are caused by the underlying database or our 
// connection to it.
func Save(in *Person) (int, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return defaultPersonID, err
   }

   // perform DB insert
   result, err := db.Exec(sqlInsert, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      logging.L.Error("failed to save person into DB. err: %s", err)
      return defaultPersonID, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      logging.L.Error("failed to retrieve id of last saved person. err: %s", err)
      return defaultPersonID, err
   }

   return int(id), nil
}

通过应用方法注入,我们得到了以下结果:

// Save will save the supplied person and return the ID of the newly 
// created person or an error.
// Errors returned are caused by the underlying database or our 
// connection to it.
func Save(ctx context.Context, in *Person) (int, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return defaultPersonID, err
   }

   // perform DB insert
   result, err := db.ExecContext(ctx, sqlInsert, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      logging.L.Error("failed to save person into DB. err: %s", err)
      return defaultPersonID, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      logging.L.Error("failed to retrieve id of last saved person. err: %s", err)
      return defaultPersonID, err
   }

   return int(id), nil
}

如您所见,我们将Exec()调用替换为ExecContext(),但其他方面没有改变。因为我们已经改变了函数签名,我们还需要更新对该包的使用如下:

// save the registration
func (r *Registerer) save(in *data.Person, price float64) (int, error) {
   person := &data.Person{
      FullName: in.FullName,
      Phone:    in.Phone,
      Currency: in.Currency,
      Price:    price,
   }
   return saver(context.TODO(), person)
}

// this function as a variable allows us to Monkey Patch during testing
var saver = data.Save

您会注意到我们使用了context.TODO();它在这里被用作占位符,直到我们可以将save()方法重构为使用方法注入为止。在更新了我们在重构过程中破坏的测试之后,我们可以继续进行下一个包。

将方法注入应用到 exchange 包

exchange包负责从上游服务加载当前的货币兑换率(例如,马来西亚林吉特兑澳大利亚元),与数据包类似,它提供了对这些数据的简化和抽象访问。

以下是当前代码的相关部分:

// Converter will convert the base price to the currency supplied
type Converter struct{}

// Do will perform the load
func (c *Converter) Do(basePrice float64, currency string) (float64, error) {
   // load rate from the external API
   response, err := c.loadRateFromServer(currency)
   if err != nil {
      return defaultPrice, err
   }

   // extract rate from response
   rate, err := c.extractRate(response, currency)
   if err != nil {
      return defaultPrice, err
   }

   // apply rate and round to 2 decimal places
   return math.Floor((basePrice/rate)*100) / 100, nil
}

// load rate from the external API
func (c *Converter) loadRateFromServer(currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      config.App.ExchangeRateBaseURL,
      config.App.ExchangeRateAPIKey,
      currency)

   // perform request
   response, err := http.Get(url)
   if err != nil {
      logging.L.Warn("[exchange] failed to load. err: %s", err)
      return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
      logging.L.Warn("[exchange] %s", err)
      return nil, err
   }

   return response, nil
}

第一个变化与之前的相同。在Do()loadRateFromServer()方法上进行简单的方法注入,将这些方法签名更改为以下内容:

// Converter will convert the base price to the currency supplied
type Converter struct{}

// Do will perform the load
func (c *Converter) Do(ctx context.Context, basePrice float64, currency string) (float64, error) {

}

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {

}

不幸的是,没有http.GetWithContext()方法,所以我们需要以稍微冗长的方式构建请求并设置上下文,得到以下结果:

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      config.App.ExchangeRateBaseURL,
      config.App.ExchangeRateAPIKey,
      currency)

   // perform request
   req, err := http.NewRequest("GET", url, nil)
   if err != nil {
      logging.L.Warn("[exchange] failed to create request. err: %s", err)
      return nil, err
   }

   // replace the default context with our custom one
   req = req.WithContext(ctx)

   // perform the HTTP request
   response, err := http.DefaultClient.Do(req)
   if err != nil {
      logging.L.Warn("[exchange] failed to load. err: %s", err)
      return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
      logging.L.Warn("[exchange] %s", err)
      return nil, err
   }

   return response, nil
}

与之前一样,我们还需要在调用exchange包的模型层中使用context.TODO(),直到我们有机会将它们改为方法注入。完成了两个底层软件层(dataexchange包)后,我们可以继续进行下一个软件层、业务层或模型层。

将方法注入应用到模型层(Get、List 和 Register 包)

以前,在我们调用dataexchange包的地方,我们使用context.TODO()来确保代码仍然可以编译,并且我们的测试继续发挥作用。现在是时候将方法注入应用到模型层,并用注入的上下文替换context.TODO()的调用。首先,我们将getPrice()save()方法更改为接受上下文:

// get price in the requested currency
func (r *Registerer) getPrice(ctx context.Context, currency string) (float64, error) {
   converter := &exchange.Converter{}
   price, err := converter.Do(ctx, config.App.BasePrice, currency)
   if err != nil {
      logging.L.Warn("failed to convert the price. err: %s", err)
      return defaultPersonID, err
   }

   return price, nil
}

// save the registration
func (r *Registerer) save(ctx context.Context, in *data.Person, price float64) (int, error) {
   person := &data.Person{
      FullName: in.FullName,
      Phone:    in.Phone,
      Currency: in.Currency,
      Price:    price,
   }
   return saver(ctx, person)
}

然后我们可以更新包的公共 API 函数Do()

type Registerer struct {}

func (r *Registerer) Do(ctx context.Context, in *data.Person) (int, error) {
   // validate the request
   err := r.validateInput(in)
   if err != nil {
      logging.L.Warn("input validation failed with err: %s", err)
      return defaultPersonID, err
   }

   // get price in the requested currency
   price, err := r.getPrice(ctx, in.Currency)
   if err != nil {
      return defaultPersonID, err
   }

   // save registration
   id, err := r.save(ctx, in, price)
   if err != nil {
      // no need to log here as we expect the data layer to do so
      return defaultPersonID, err
   }

   return id, nil
}

我们已经将传递给数据和exchange包的Context对象合并为一个单一的注入依赖项;这是一个我们可以从 REST 包中的http.Request中提取的依赖项。

将上下文的方法注入到 REST 包中

最后,现在是关键的更改。首先,我们从请求中提取上下文:

// ServeHTTP implements http.Handler
func (h *RegisterHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract payload from request
   requestPayload, err := h.extractPayload(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // call the business logic using the request data and context
   id, err := h.register(request.Context(), requestPayload)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // happy path
   response.Header().Add("Location", fmt.Sprintf("/person/%d/", id))
   response.WriteHeader(http.StatusCreated)
}

然后我们将其传递给模型:


// call the logic layer
func (h *RegisterHandler) register(ctx context.Context, requestPayload *registerRequest) (int, error) {
   person := &data.Person{
      FullName: requestPayload.FullName,
      Phone:    requestPayload.Phone,
      Currency: requestPayload.Currency,
   }

   return h.registerer.Do(ctx, person)
}

经过了许多太简单的更改之后,我们已经将方法注入应用到了注册端点的所有层。

让我们来看看我们取得了什么成就。我们的处理现在与请求的执行上下文相关联。因此,当请求被取消时,我们将立即停止处理该请求。

但这为什么重要呢?有两个原因;第一个和最重要的是用户期望。如果用户取消了请求,无论是手动还是通过超时,他们将看到一个错误。他们会得出结论,处理已失败。如果我们继续处理请求并设法完成它,这将违背他们的期望。

第二个原因更加务实;当我们停止处理请求时,我们减少了服务器和上游的负载。这种释放的容量随后可以用于处理其他请求。

当涉及满足用户期望时,上下文包实际上可以做更多的事情。我们可以添加延迟预算。

延迟预算

与许多 IT 术语一样,延迟预算可以以多种方式使用。在这种情况下,我们指的是调用允许的最长时间。

将这些转化为我们当前的重构,它涉及两件事:

  • 允许上游(数据库或汇率服务)调用完成的最长时间

  • 我们的注册 API 允许的最长完成时间

你可以看到这两件事情是如何相关的。让我们看看我们的 API 响应时间是如何组成的:

API 响应时间 =(汇率服务调用+数据库调用+我们的代码)

假设我们的代码的性能主要是一致的,那么我们的服务质量直接取决于上游调用的速度。这不是一个非常舒适的位置,那么我们能做什么呢?

在前一节中,我们检查了这些失败和一些选项,并决定暂时要失败请求。我们能为用户提供的最好的失败是什么?一个及时而有信息的失败。

为了实现这一点,我们将使用context.Context接口的另一个特性:

WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

你可能已经猜到了,这种方法在上下文中设置了一个超时。这个超时将作为一个计时器,如果超过了延迟预算(超时),上下文将被取消。然后,因为我们已经设置了停止短路,我们的请求将停止处理并退出。

首先,让我们将其应用到我们的数据库调用中。在下一个示例中,我们将从原始上下文中创建一个子上下文并为其设置一个超时。由于上下文是分层的,我们应用的超时只适用于子上下文和我们从中创建的任何上下文。

在我们的情况下,我们已经决定对数据库的调用的延迟预算为 1 秒,如下所示:

// Save will save the supplied person and return the ID of the newly 
// created person or an error.
// Errors returned are caused by the underlying database or our 
// connection to it.
func Save(ctx context.Context, in *Person) (int, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return defaultPersonID, err
   }

   // set latency budget for the database call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // perform DB insert
   result, err := db.ExecContext(subCtx, sqlInsert, in.FullName, in.Phone, in.Currency, in.Price)
   if err != nil {
      logging.L.Error("failed to save person into DB. err: %s", err)
      return defaultPersonID, err
   }

   // retrieve and return the ID of the person created
   id, err := result.LastInsertId()
   if err != nil {
      logging.L.Error("failed to retrieve id of last saved person. err: %s", err)
      return defaultPersonID, err
   }

   return int(id), nil
}

现在,让我们将延迟预算应用到交换服务调用中。为此,我们将使用http.Request的另一个特性,Context()方法,文档如下:

对于出站客户端请求,上下文控制取消

为了在我们的出站 HTTP 请求上设置延迟预算,我们将创建另一个子上下文,就像我们为数据库做的那样,然后使用WithRequest()方法将该上下文设置到请求中。在这些更改之后,我们的代码看起来像这样:

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      config.App.ExchangeRateBaseURL,
      config.App.ExchangeRateAPIKey,
      currency)

   // perform request
   req, err := http.NewRequest("GET", url, nil)
   if err != nil {
      logging.L.Warn("[exchange] failed to create request. err: %s", err)
      return nil, err
   }

   // set latency budget for the upstream call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // replace the default context with our custom one
   req = req.WithContext(subCtx)

   // perform the HTTP request
   response, err := http.DefaultClient.Do(req)
   if err != nil {
      logging.L.Warn("[exchange] failed to load. err: %s", err)
      return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
      logging.L.Warn("[exchange] %s", err)
      return nil, err
   }

   return response, nil
}

有了这些更改,让我们重新审视我们的 API 响应时间公式,并考虑最坏的情况-两个调用都花了不到 1 秒的时间但成功完成,给我们这个:

API 响应时间 =(~1 秒+ ~1 秒+我们的代码)

这给我们一个大约 2 秒的最大执行时间。但是如果我们决定允许自己的最大响应时间是 1.5 秒呢?

幸运的是,我们也可以轻松做到这一点。早些时候,我提到过上下文是分层的。我们所有的上下文当前都是从请求中的上下文派生出来的。虽然我们无法更改作为请求一部分的上下文,但我们可以从中派生出一个具有我们 API 的延迟预算的上下文,然后将其传递给数据和交换包。处理程序的更新部分如下所示:

// ServeHTTP implements http.Handler
func (h *RegisterHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // set latency budget for this API
   subCtx, cancel := context.WithTimeout(request.Context(), 1500 *time.Millisecond)
   defer cancel()

   // extract payload from request
   requestPayload, err := h.extractPayload(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // register person
   id, err := h.register(subCtx, requestPayload)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // happy path
   response.Header().Add("Location", fmt.Sprintf("/person/%d/", id))
   response.WriteHeader(http.StatusCreated)
}

经过一些简单的更改,我们可以更好地控制我们的 API 的性能,这要归功于上下文包和一点点方法注入。

方法注入的缺点

我没有为您列出很长的缺点;事实上,我只有两个。

添加参数会降低用户体验 - 这是一个相当大的问题。向方法或函数添加参数会降低函数的用户体验。正如我们在第三章中所看到的,为用户体验编码,函数的糟糕用户体验会对其可用性产生负面影响。

考虑以下结构:

// Load people from the database
type PersonLoader struct {
}

func (d *PersonLoader) Load(db *sql.DB, ID int) (*Person, error) {
   return nil, errors.New("not implemented")
}

func (d *PersonLoader) LoadAll(db *sql.DB) ([]*Person, error) {
   return nil, errors.New("not implemented")
}

这段代码有效,完成了任务。但是每次都必须传入数据库很烦人。除此之外,没有保证调用Load()的代码也会维护数据库池。

另一个要考虑的方面是封装。这些函数的用户是否需要知道它们依赖于数据库?请试着站在一会儿Load()函数的用户的角度。你想做什么,你知道什么?

你想加载一个人,你知道那个人的 ID。你不知道(或者不关心)数据来自哪里。如果你为自己设计这个函数,它会是什么样子:

type MyPersonLoader interface {
   Load(ID int) (*Person, error)
}

它简洁易用,没有泄漏任何实现细节。

让我们看另一个例子:

type Generator struct{}

func (g *Generator) Generate(storage Storage, template io.Reader, destination io.Writer, renderer Renderer, formatter Formatter, params ...interface{}) {

}

在这种情况下,我们有很多参数,很难将数据与非请求范围的依赖项分开。如果我们提取这些依赖项,我们会得到以下结果:

func NewGeneratorV2(storage Storage, renderer Renderer, formatter Formatter) *GeneratorV2 {
   return &GeneratorV2{
      storage:   storage,
      renderer:  renderer,
      formatter: formatter,
   }
}

type GeneratorV2 struct {
   storage   Storage
   renderer  Renderer
   formatter Formatter
}

func (g *GeneratorV2) Generate(template io.Reader, destination io.Writer, params ...interface{}) {

}

虽然第二个例子中的用户体验更好,但仍然相当繁琐。代码可以从不同的角度受益,比如组合。

适用性有限 - 正如我们在本章中所看到的,方法注入在函数和请求范围的依赖项中表现出色。虽然这种用例确实经常出现,但方法注入并不适用于非请求范围的依赖项,而这是我们想要使用依赖注入DI)的大部分用例。

总结

在本章中,我们研究了方法注入的 DI,这可能是所有形式的 DI 中最普遍的。

当涉及从现有代码中提取依赖项以进行测试时,可能会首先想到的就是方法。请小心,我们不想引入测试引起的损害

为了测试的唯一目的向导出的 API 函数添加参数无疑会损害 UX 代码。幸运的是,我们有一些技巧可用来避免损害我们的 API。我们可以定义仅存在于测试代码中的成员函数。我们还可以使用即时JIT)依赖注入,我们将在第九章中进行探讨,即时依赖注入

在本章中,我们已经研究了出色而强大的context包。您可能会惊讶地发现,我们可以从这个包中提取更多的价值。我鼓励您查看 Go 博客(blog.golang.org/context)并自行调查这个包。

在下一章中,我们将应用一种特定形式的构造函数注入和方法注入,称为DI by config。通过它,我们最终将config包从我们服务中几乎每个其他包都依赖的状态中解脱出来,使我们的包更加解耦,并显著提高它们的可重用性。

问题

  1. 方法注入的理想用例是什么?

  2. 为什么不保存使用方法注入注入的依赖关系很重要?

  3. 如果我们过度使用方法注入会发生什么?

  4. 为什么“停止短”对整个系统有用?

  5. 延迟预算如何改善用户体验?

第八章:通过配置进行依赖注入

在本章中,我们将通过配置来看依赖注入DI)。配置注入不是一种完全不同的方法,而是构造函数注入和方法注入的扩展。

它旨在解决这些方法可能存在的问题,比如过多或重复注入的依赖项,而不牺牲我们代码的用户体验。

本章将涵盖以下主题:

  • 配置注入

  • 配置注入的优点

  • 应用配置注入

  • 配置注入的缺点

技术要求

熟悉我们在第四章中介绍的服务代码将是有益的,ACME 注册服务简介。本章还假设您已经阅读了第六章,构造函数注入的依赖注入,和第七章,方法注入的依赖注入

您可能还会发现阅读和运行本章的完整代码版本很有用,可在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch08找到。

获取代码并配置示例服务的说明可在此处的 README 中找到:github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch08/acme中找到我们的服务代码,并已应用了本章的更改。

配置注入

配置注入是方法和参数注入的特定实现。通过配置注入,我们将多个依赖项和系统级配置合并到一个config接口中。

考虑以下构造函数:

// NewLongConstructor is the constructor for MyStruct
func NewLongConstructor(logger Logger, stats Instrumentation, limiter RateLimiter, cache Cache, timeout time.Duration, workers int) *MyStruct {
 return &MyStruct{
 // code removed
 }
}

正如你所看到的,我们正在注入多个依赖项,包括记录器、仪器、速率限制器、缓存和一些配置。

可以肯定地假设我们很可能会将记录器和仪器注入到这个项目中的大多数对象中。这导致每个构造函数至少有两个参数。在整个系统中,这将增加大量额外的输入。它还通过使构造函数更难阅读来减少了我们的构造函数的用户体验,并且可能会隐藏重要参数。

考虑一下——超时和工作人数的值可能定义在哪里?它们可能是从某个中央来源定义的,比如一个config文件。

通过应用配置注入,我们的示例变成了以下内容:

// NewByConfigConstructor is the constructor for MyStruct
func NewByConfigConstructor(cfg MyConfig, limiter RateLimiter, cache Cache) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

我们已将常见问题和配置合并到配置定义中,但保留了重要参数。这样,函数参数仍然具有信息性,而无需阅读config接口定义。在某种程度上,我们隐藏或封装了常见问题。

考虑的另一个可用性方面是配置现在是一个接口。我们应该考虑哪种对象会实现这样的接口。这样的对象是否已经存在?它的责任是什么?

通常,配置来自单一来源,其责任是加载配置并提供对其的访问。即使我们引入配置接口以解耦实际的配置管理,利用它是单一来源仍然很方便。

考虑以下代码:

myFetcher := NewFetcher(cfg, cfg.URL(), cfg.Timeout())

这段代码表明所有参数都来自同一位置。这表明它们可以合并。

如果你来自面向对象的背景,你可能熟悉服务定位器的概念。配置注入故意非常相似。然而,与典型的服务定位器用法不同,我们只提取配置和一些共享的依赖项。

配置注入采用这种方法来避免服务定位器的上帝对象和使用与上帝对象之间的耦合。

配置注入的优势

鉴于配置注入是构造函数和方法注入的扩展形式,其他方法的优点在这里也适用。在本节中,我们将仅讨论特定于此方法的附加优点。

它非常适合与配置包解耦-当我们有一个从单一位置加载的config包时,比如一个文件,那么这个包往往会成为系统中许多其他包的依赖项。考虑到第二章中的单一职责原则部分,我们意识到一个包或对象的用户越多,它就越难以改变。

通过配置注入,我们还在本地接口中定义我们的需求,并利用 Go 的隐式接口和依赖反转原则DIP)来保持包的解耦。

这些步骤还使得测试我们的结构体变得更加容易。考虑以下代码:

func TestInjectedConfig(t *testing.T) {
   // load test config
   cfg, err := config.LoadFromFile(testConfigLocation)
   require.NoError(t, err)

   // build and use object
   obj := NewMyObject(cfg)
   result, resultErr := obj.Do()

   // validate
   assert.NotNil(t, result)
   assert.NoError(t, resultErr)
}

现在,看一下使用配置注入的相同代码:

func TestConfigInjection(t *testing.T) {
   // build test config
   cfg := &TestConfig{}

   // build and use object
   obj := NewMyObject(cfg)
   result, resultErr := obj.Do()

   // validate
   assert.NotNil(t, result)
   assert.NoError(t, resultErr)
}

// Simple implementation of the Config interface
type TestConfig struct {
   logger *logging.Logger
   stats  *stats.Collector
}

func (t *TestConfig) Logger() *logging.Logger {
   return t.logger
}

func (t *TestConfig) Stats() *stats.Collector {
   return t.stats
}

是的,代码量更大了。然而,我们不再需要管理测试配置文件,这通常会很麻烦。我们的测试是完全自包含的,不应该出现并发问题,就像全局配置对象可能出现的那样。

减轻注入常见关注的负担-在前面的例子中,我们使用配置注入来注入日志记录和仪表对象。这类常见关注是配置注入的一个很好的用例,因为它们经常需要,但并不涉及函数本身的目的。它们可以被视为环境依赖项。由于它们的共享性质,另一种方法是将它们转换为全局单例,而不是注入它们。个人而言,我更喜欢注入它们,因为这给了我验证它们使用的机会。这本身可能感觉奇怪,但在许多情况下,我们从仪表数据的存在或缺失构建系统监控和警报,从而使仪表成为我们代码的特性或契约的一部分,并且可能希望通过测试来防止它们的退化。

通过减少参数来提高可用性-与前面的优点类似,应用配置注入可以增强方法的可用性,特别是构造函数,同时减少参数的数量。考虑以下构造函数:

func NewLongConstructor(logger Logger, stats Instrumentation, limiter RateLimiter, cache Cache, url string, credentials string) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

现在,看一下使用配置注入的相同构造函数:

func NewByConfigConstructor(cfg MyConfig, url string, credentials string) *MyStruct {
   return &MyStruct{
      // code removed
   }
}

通过从构造函数定义中移除环境依赖项,我们剩下的参数大大减少了。更重要的是,唯一剩下的参数是与目的相关的,因此使得方法更容易理解和使用。

依赖项的创建可以推迟到使用时-你是否曾经尝试注入一个依赖项,却发现它不存在或尚未准备好?你是否曾经有一个非常昂贵的依赖项,你只想在绝对必要的时候才创建它?

通过配置注入,依赖项的创建和访问只需要在使用时解决,而不是在注入时。

应用配置注入

之前,我提到我们的 ACME 注册服务有一些问题,我真的希望我们能解决。在这一部分,我们将使用配置注入来处理其中的两个问题。

第一个是我们的许多包都依赖于configlogging包,除了是一个重大的单一责任原则违反,这种耦合可能会导致循环依赖问题。

第二个问题是我们无法在不实际调用上游服务的情况下测试我们对汇率的调用。到目前为止,我们已经避免在这个包中添加任何测试,因为我们担心我们的测试会受到该服务的影响(在速度和稳定性方面)。

首先,让我们看看我们现在的情况。我们的依赖图目前如下图所示:

正如你所看到的,我们有四个包(dataregisterexchangemain)依赖于config包,还有五个(dataregisterexchangerestconfig)依赖于logging包。也许更糟糕的是这些包如何依赖于configlogging包。目前,它们直接访问公共单例。这意味着当我们想要测试我们的记录器使用或在测试期间替换一些配置时,我们将不得不进行猴子补丁,这将导致测试中的数据竞争不稳定性。

为了解决这个问题,我们将为我们的每个对象定义一个配置。每个配置将包括记录器和任何其他需要的配置。然后,我们将任何直接链接到全局变量的内容替换为对注入配置的引用。

这将导致一些大刀阔斧的手术(许多小的改变),但代码将因此变得更好。

我们只会在这里进行一组更改;如果您希望查看所有更改,请查看本章的源代码。

将配置注入应用到模型层

重新审视我们的register包,我们看到它引用了configlogging

// Registerer validates the supplied person, calculates the price in 
// the requested currency and saves the result.
// It will return an error when:
// -the person object does not include all the fields
// -the currency is invalid
// -the exchange rate cannot be loaded
// -the data layer throws an error.
type Registerer struct {
}

// get price in the requested currency
func (r *Registerer) getPrice(ctx context.Context, currency string) (float64, error) {
  converter := &exchange.Converter{}
  price, err := converter.Do(ctx, config.App.BasePrice, currency)
  if err != nil {
    logging.L.Warn("failed to convert the price. err: %s", err)
    return defaultPersonID, err
  }

  return price, nil
}

我们的第一步是定义一个接口,它将提供我们需要的依赖项:

// Config is the configuration for the Registerer
type Config interface {
   Logger() *logging.LoggerStdOut
   BasePrice() float64
}

你有没有发现什么问题?首先显而易见的是我们的Logger()方法返回一个记录器实现的指针。这样可以工作,但不够具有未来性或可测试性。我们可以在本地定义一个logging接口,并完全与logging包解耦。然而,这意味着我们将不得不在大多数包中定义一个logging接口。从理论上讲,这是最好的选择,但实际上并不太实用。相反,我们可以定义一个logging接口,并让所有的包都依赖于它。虽然这意味着我们仍然与logging包保持耦合,但我们将依赖于一个很少改变的接口,而不是一个更有可能改变的实现。

第二个潜在问题是另一个方法BasePrice()的命名,因为它有点通用,并且可能会在以后造成混淆。它也是Config结构体中的字段名称,但 Go 不允许我们拥有相同名称的成员变量和方法,所以我们需要更改它。

更新我们的config接口后,我们有以下内容:

// Config is the configuration for the Registerer
type Config interface {
  Logger() logging.Logger
  RegistrationBasePrice() float64
}

我们现在可以将配置注入应用到我们的Registerer,得到以下结果:

// NewRegisterer creates and initializes a Registerer
func NewRegisterer(cfg Config) *Registerer {
   return &Registerer{
      cfg: cfg,
   }
}

// Config is the configuration for the Registerer
type Config interface {
   Logger() logging.Logger
   RegistrationBasePrice() float64
}

// Registerer validates the supplied person, calculates the price in 
// the requested currency and saves the result.
// It will return an error when:
// -the person object does not include all the fields
// -the currency is invalid
// -the exchange rate cannot be loaded
// -the data layer throws an error.
type Registerer struct {
   cfg Config
}

// get price in the requested currency
func (r *Registerer) getPrice(ctx context.Context, currency string) (float64, error) {
   converter := &exchange.Converter{}
   price, err := converter.Do(ctx, r.cfg.RegistrationBasePrice(), currency)
   if err != nil {
      r.logger().Warn("failed to convert the price. err: %s", err)
      return defaultPersonID, err
   }

   return price, nil
}

func (r *Registerer) logger() logging.Logger {
   return r.cfg.Logger()
}

我还添加了一个方便的方法logger(),以减少代码从r.cfg.Logger()r.logger()。我们的服务和测试目前已经损坏,所以我们还需要做更多的改变。

为了再次进行测试,我们需要定义一个测试配置并更新我们的测试。对于我们的测试配置,我们可以使用 mockery 并创建一个模拟实现,但我们不感兴趣验证我们的配置使用或在所有测试中添加额外的代码来配置模拟。相反,我们将使用一个返回可预测值的存根实现。这是我们的存根测试配置:

// Stub implementation of Config
type testConfig struct{}

// Logger implement Config
func (t *testConfig) Logger() logging.Logger {
   return &logging.LoggerStdOut{}
}

// RegistrationBasePrice implement Config
func (t *testConfig) RegistrationBasePrice() float64 {
   return 12.34
}

并将这个测试配置添加到我们所有的Registerer测试中,如下面的代码所示:

registerer := &Registerer{
   cfg: &testConfig{},
}

我们的测试又可以运行了,但奇怪的是,虽然我们的服务编译通过了,但如果我们运行它,它会崩溃并出现nil指针异常。我们需要更新我们的Registerer的创建方式,从以下方式:

registerModel := &register.Registerer{}

我们将其更改为:

registerModel := register.NewRegisterer(config.App)

这导致了下一个问题。config.App结构体没有实现我们需要的方法。将这些方法添加到config,我们得到了以下结果:

// Logger returns a reference to the singleton logger
func (c *Config) Logger() logging.Logger {
   if c.logger == nil {
      c.logger = &logging.LoggerStdOut{}
   }

   return c.logger
}

// RegistrationBasePrice returns the base price for registrations
func (c *Config) RegistrationBasePrice() float64 {
   return c.BasePrice
}

通过这些改变,我们已经切断了registration包和config包之间的依赖链接。在我们之前展示的Logger()方法中,你可以看到我们仍然将日志记录器作为单例使用,但它不再是一个全局公共变量,这样就不容易出现数据竞争,而是现在在config对象内部。表面上,这可能看起来没有任何区别;然而,我们主要关心的数据竞争是在测试期间。我们的对象现在依赖于注入版本的日志记录器,并且不需要使用全局公共变量。

在这里,我们检查了我们更新后的依赖图,看看接下来该怎么做:

我们只剩下三个连接到config包的链接,即来自maindataexchange包。来自main包的链接无法移除,因此我们可以忽略它。所以,让我们看看data包。

将配置注入应用到数据包

我们的data包目前是基于函数的,因此与之前的改变相比,这些改变会有所不同。这是data包中的一个典型函数:

// Load will attempt to load and return a person.
// It will return ErrNotFound when the requested person does not exist.
// Any other errors returned are caused by the underlying database 
// or our connection to it.
func Load(ctx context.Context, ID int) (*Person, error) {
   db, err := getDB()
   if err != nil {
      logging.L.Error("failed to get DB connection. err: %s", err)
      return nil, err
   }

   // set latency budget for the database call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // perform DB select
   row := db.QueryRowContext(subCtx, sqlLoadByID, ID)

   // retrieve columns and populate the person object
   out, err := populatePerson(row.Scan)
   if err != nil {
      if err == sql.ErrNoRows {
         logging.L.Warn("failed to load requested person '%d'. err: %s", ID, err)
         return nil, ErrNotFound
      }

      logging.L.Error("failed to convert query result. err: %s", err)
      return nil, err
   }
   return out, nil
}

在这个函数中,我们引用了我们想要移除的日志记录器,以及我们真正需要提取的一个配置。这个配置是前面代码中函数的第一行需要的。这是getDB()函数:

var getDB = func() (*sql.DB, error) {
   if db == nil {
      if config.App == nil {
         return nil, errors.New("config is not initialized")
      }

      var err error
      db, err = sql.Open("mysql", config.App.DSN)
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

我们有一个引用DSN来创建数据库池。那么,你认为我们的第一步应该是什么?

和之前的改变一样,让我们首先定义一个包括我们想要注入的所有依赖和配置的接口:

// Config is the configuration for the data package
type Config interface {
   // Logger returns a reference to the logger
   Logger() logging.Logger

   // DataDSN returns the data source name
   DataDSN() string
}

现在,让我们更新我们的函数以注入config接口:

// Load will attempt to load and return a person.
// It will return ErrNotFound when the requested person does not exist.
// Any other errors returned are caused by the underlying database 
// or our connection to it.
func Load(ctx context.Context, cfg Config, ID int) (*Person, error) {
   db, err := getDB(cfg)
   if err != nil {
      cfg.Logger().Error("failed to get DB connection. err: %s", err)
      return nil, err
   }

   // set latency budget for the database call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // perform DB select
   row := db.QueryRowContext(subCtx, sqlLoadByID, ID)

   // retrieve columns and populate the person object
   out, err := populatePerson(row.Scan)
   if err != nil {
      if err == sql.ErrNoRows {
         cfg.Logger().Warn("failed to load requested person '%d'. err: %s", ID, err)
         return nil, ErrNotFound
      }

      cfg.Logger().Error("failed to convert query result. err: %s", err)
      return nil, err
   }
   return out, nil
}

var getDB = func(cfg Config) (*sql.DB, error) {
   if db == nil {
      var err error
      db, err = sql.Open("mysql", cfg.DataDSN())
      if err != nil {
         // if the DB cannot be accessed we are dead
         panic(err.Error())
      }
   }

   return db, nil
}

不幸的是,这个改变会导致很多问题,因为getDB()data包中所有公共函数调用,而这些函数又被模型层包调用。幸运的是,我们有足够的单元测试来帮助防止在修改过程中出现回归。

我想请你停下来思考一下:我们试图做的是一个微不足道的改变,但它导致了一大堆小改变。此外,我们被迫在这个包的每个公共函数中添加一个参数。这让你对基于函数构建这个包的决定有什么感觉?从函数中重构不是一件小事,但你认为这样做值得吗?

模型层的改变很小,但有趣的是,由于我们已经使用了配置注入,所以这些改变是有意义的。

只需要做两个小改变:

  • 我们将DataDSN()方法添加到我们的 config

  • 我们需要通过loader()调用将配置传递到数据包

这是应用了改变的代码:

// Config is the configuration for Getter
type Config interface {
   Logger() logging.Logger
   DataDSN() string
}

// Getter will attempt to load a person.
// It can return an error caused by the data layer or when the 
// requested person is not found
type Getter struct {
   cfg Config
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := loader(context.TODO(), g.cfg, ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the implementation 
         // details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// this function as a variable allows us to Monkey Patch during testing
var loader = data.Load

遗憾的是,我们需要在所有模型层包中进行这些小改变。完成后,我们的依赖图现在如下图所示:

太棒了。只剩下一个不必要的连接到config包,它来自exchange包。

将配置注入应用到 exchange 包

我们可以像对其他包一样,对exchange包应用配置注入,使用以下步骤:

  1. 定义一个包括我们想要注入的依赖和配置的接口

  2. 定义/更新构造函数以接受config接口

  3. 将注入的配置保存为成员变量

  4. 更改引用(例如指向configlogger)以指向成员变量

  5. 更新其他层的config接口以包含任何新内容

在我们对exchange包应用配置注入后,出现了一种不寻常的情况。我们的依赖图显示,我们已经从exchangeconfig包的链接,如下图所示:

然而,为了使我们的测试继续工作,我们仍然需要引用配置,如下面的代码所示:

type testConfig struct{}

// ExchangeBaseURL implements Config
func (t *testConfig) ExchangeBaseURL() string {
   return config.App.ExchangeRateBaseURL
}

// ExchangeAPIKey implements Config
func (t *testConfig) ExchangeAPIKey() string {
   return config.App.ExchangeRateAPIKey
}

退一步看,我们注意到我们所指的测试并不是针对exchange包的测试,而是针对其用户register包的测试。这是一个很大的警示。我们可以通过在这两个包之间的关系上应用构造函数注入来快速解决这个问题的第一部分。然后我们可以对对外部服务的调用进行模拟或存根。

我们还可以撤消对Config接口的一些早期更改,删除与exchange包相关的方法,并将其还原为以下内容:

// Config is the configuration for the Registerer
type Config interface {
   Logger() logging.Logger
   RegistrationBasePrice() float64
   DataDSN() string
}

这最终使我们能够从我们的register测试到config包的链接,并且更重要的是,使我们能够将我们的测试与外部汇率服务解耦。

当我们开始这一部分时,我们定义了两个目标。首先,从config包和logging包中解耦,并且其次,能够在不调用外部服务的情况下进行测试。到目前为止,我们已经完全解耦了config包。我们已经从除config包以外的所有包中删除了对全局公共记录器的使用,并且我们还删除了对外部汇率服务的依赖。

然而,我们的服务仍然依赖于该外部服务,但我们绝对没有测试来验证我们是否正确调用它,或者证明服务是否按我们期望的方式响应。这些测试被称为边界测试

边界测试

边界测试有两种形式,各自有自己的目标——内部边界和外部边界。

内部边界测试旨在验证两件事:

  • 我们的代码是否按我们期望的方式调用外部服务

  • 我们的代码对来自外部服务的所有响应(包括正常路径和错误)都做出了我们期望的反应

因此,内部边界测试不与外部服务交互,而是与外部服务的模拟或存根实现交互。

外部边界测试则相反。它们与外部服务进行交互,并验证外部服务是否按我们需要的方式执行。请注意,它们不验证外部服务的 API 合同,也不会按照其所有者的期望进行操作。然而,它们只关注我们的需求。外部边界测试通常会比单元测试更慢、更不可靠。因此,我们可能不希望始终运行它们。我们可以使用 Go 的构建标志来实现这一点。

让我们首先向我们的服务添加外部边界测试。我们可以编写一个测试,其中包含按照服务文档建议的格式对外部服务进行 HTTP 调用,然后验证响应。如果我们对这项服务不熟悉,并且尚未构建调用该服务的代码,这也是了解外部服务的绝佳方式。

然而,在我们的情况下,我们已经编写了代码,因此更快的选择是使用live配置调用该代码。这样做会返回一个类似于以下内容的 JSON 负载:

{
   "success":true,
   "historical":true,
   "date":"2010-11-09",
   "timestamp":1289347199,
   "source":"USD",
   "quotes":{
      "USDAUD":0.989981
   }
}

虽然响应的格式是可预测的,但timestampquotes的值会改变。那么,我们可以测试什么?也许更重要的是,我们依赖响应的哪些部分?在仔细检查我们的代码后,我们意识到在响应中的所有字段中,我们唯一使用的是quotes映射。此外,我们从外部服务需要的唯一东西是我们请求的货币存在于该映射中,并且该值是float64类型。因此,通过仅测试这些特定属性,我们的测试将尽可能地对更改具有弹性。

这给我们一个看起来像以下代码的测试:

func TestExternalBoundaryTest(t *testing.T) {
   // define the config
   cfg := &testConfig{
      baseURL: config.App.ExchangeRateBaseURL,
      apiKey:  config.App.ExchangeRateAPIKey,
   }

   // create a converter to test
   converter := NewConverter(cfg)

   // fetch from the server
   response, err := converter.loadRateFromServer(context.Background(), "AUD")
   require.NotNil(t, response)
   require.NoError(t, err)

   // parse the response
   resultRate, err := converter.extractRate(response, "AUD")
   require.NoError(t, err)

   // validate the result
   assert.True(t, resultRate > 0)
}

为了确保这个测试只在我们想要的时候运行,我们在文件顶部放置了以下构建标签:

// +build external

现在,让我们看看内部边界测试。第一步是制作外部服务的模拟实现。我们有先前提到的结果有效负载。为此,我们将使用httptest包创建一个返回我们的测试有效负载的 HTTP 服务器,如下所示:

type happyExchangeRateService struct{}

// ServeHTTP implements http.Handler
func (*happyExchangeRateService) ServeHTTP(response http.ResponseWriter, request *http.Request) {
  payload := []byte(`
{
   "success":true,
   "historical":true,
   "date":"2010-11-09",
   "timestamp":1289347199,
   "source":"USD",
   "quotes":{
      "USDAUD":0.989981
   }
}`)
  response.Write(payload)
}

现在,它返回一个固定的响应,并且不对请求进行验证。我们现在可以构建我们的内部边界测试。与外部边界测试不同,结果现在完全由我们控制,因此是可预测的。因此,我们可以测试确切的结果,如下面的代码所示:

func TestInternalBoundaryTest(t *testing.T) {
   // start our test server
   server := httptest.NewServer(&happyExchangeRateService{})
   defer server.Close()

   // define the config
   cfg := &testConfig{
      baseURL: server.URL,
      apiKey:  "",
   }

   // create a converter to test
   converter := NewConverter(cfg)
   resultRate, resultErr := converter.Exchange(context.Background(), 100.00, "AUD")

   // validate the result
   assert.Equal(t, 101.01, resultRate)
   assert.NoError(t, resultErr)
}

现在我们有了一个基本的内部边界测试。我们能够验证,而不依赖外部服务,外部服务返回我们期望的有效负载,并且我们能够正确提取和使用结果。我们可以进一步扩展我们的测试,包括以下内容:

  • 验证我们的代码,并在外部服务宕机或缓慢时返回合理的错误

  • 证明我们的代码在外部服务返回空或无效响应时返回合理的错误

  • 验证我们的代码执行的 HTTP 请求的测试

在我们的内部边界测试就位后,我们最终对我们的汇率代码进行了测试。我们已经确保我们的代码按预期工作,并且我们的测试是可靠的,并且完全由我们控制。此外,我们还有外部边界测试,我们可以偶尔运行以通知我们外部服务的任何更改将会破坏我们的服务。

配置注入的缺点

正如我们所看到的,配置注入可以与构造函数和函数一起使用,因此可以构建一个只使用配置注入的系统。不幸的是,配置注入也有一些缺点。

传递配置而不是抽象依赖项泄漏实现细节 - 考虑以下代码:

type PeopleFilterConfig interface {
   DSN() string
}

func PeopleFilter(cfg PeopleFilterConfig, filter string) ([]Person, error) {
   // load people
   loader := &PersonLoader{}
   people, err := loader.LoadAll(cfg)
   if err != nil {
      return nil, err
   }

   // filter people
   out := []Person{}
   for _, person := range people {
      if strings.Contains(person.Name, filter) {
         out = append(out, person)
      }
   }

   return out, nil
}

type PersonLoaderConfig interface {
   DSN() string
}

type PersonLoader struct{}

func (p *PersonLoader) LoadAll(cfg PersonLoaderConfig) ([]Person, error) {
   return nil, errors.New("not implemented")
}

在这个例子中,PeopleFilter函数知道PersonLoader是一个数据库。这可能看起来不是什么大不了的事,如果实现策略永远不改变,它就不会产生不利影响。然而,如果我们从数据库转移到外部服务或其他任何地方,我们将不得不同时更改我们的PersonLoader数据库。一个更具未来性的实现如下:

type Loader interface {
   LoadAll() ([]Person, error)
}

func PeopleFilter(loader Loader, filter string) ([]Person, error) {
   // load people
   people, err := loader.LoadAll()
   if err != nil {
      return nil, err
   }

   // filter people
   out := []Person{}
   for _, person := range people {
      if strings.Contains(person.Name, filter) {
         out = append(out, person)
      }
   }

   return out, nil
}

如果我们改变数据加载的位置,这种实现不太可能需要更改。

依赖生命周期不太可预测 - 在优势中,我们说过依赖项的创建可以推迟到使用时。你内心的批评者可能反对这种说法,而且有充分的理由。这是一个优势,但它也使得依赖项的生命周期不太可预测。当使用构造函数注入或方法注入时,依赖项必须在注入之前存在。因此,依赖项的创建或初始化的任何问题都会在此较早的时间出现。当依赖项在某个未知的时间点初始化时,可能会出现一些问题。

首先,如果问题是无法恢复的或导致系统崩溃,这意味着系统最初看起来健康,然后变得不健康或崩溃不可预测。这种不可预测性可能导致极其难以调试的问题。

其次,如果依赖项的初始化包括延迟的可能性,我们必须意识到并考虑任何这样的延迟。考虑以下代码:

func DoJob(pool WorkerPool, job Job) error {
   // wait for pool
   ready := pool.IsReady()

   select {
   case <-ready:
      // happy path

   case <-time.After(1 * time.Second):
      return errors.New("timeout waiting for worker pool")
   }

   worker := pool.GetWorker()
   return worker.Do(job)
}

现在将其与假设池在注入之前已准备就绪的实现进行比较:

func DoJobUpdated(pool WorkerPool, job Job) error {
   worker := pool.GetWorker()
   return worker.Do(job)
}

如果这个函数是端点的一部分,并且具有延迟预算,会发生什么?如果启动延迟大于延迟预算,那么第一个请求将总是失败。

过度使用会降低用户体验 - 虽然我强烈建议您只在配置和环境依赖项(如仪器)中使用这种模式,但也可以在许多其他地方应用这种模式。但是,通过将依赖项推入config接口,它们变得不太明显,并且我们有一个更大的接口要实现。让我们重新审视一个早期的例子:

// NewByConfigConstructor is the constructor for MyStruct
func NewByConfigConstructor(cfg MyConfig, limiter RateLimiter, cache Cache) *MyStruct {
   return &MyStruct{
   // code removed
   }
}

考虑速率限制器依赖。如果我们将其合并到Config接口中会发生什么?这个对象使用和依赖速率限制器的事实就不太明显了。如果每个类似的函数都有速率限制,那么随着使用变得更加环境化,这将不再是一个问题。

另一个不太显而易见的方面是配置。速率限制器的配置可能在所有用法中并不一致。当所有其他依赖项和配置都来自共享对象时,这是一个问题。我们可以组合配置对象并自定义返回的速率限制器,但这感觉像是过度设计。

更改可能会在软件层中传播 - 当配置通过层传递时,这个问题才会出现。考虑以下例子:

func NewLayer1Object(config Layer1Config) *Layer1Object {
   return &Layer1Object{
      MyConfig:     config,
      MyDependency: NewLayer2Object(config),
   }
}

// Configuration for the Layer 1 Object
type Layer1Config interface {
   Logger() Logger
}

// Layer 1 Object
type Layer1Object struct {
   MyConfig     Layer1Config
   MyDependency *Layer2Object
}

// Configuration for the Layer 2 Object
type Layer2Config interface {
   Logger() Logger
}

// Layer 2 Object
type Layer2Object struct {
   MyConfig Layer2Config
}

func NewLayer2Object(config Layer2Config) *Layer2Object {
   return &Layer2Object{
      MyConfig: config,
   }
}

有了这种结构,当我们需要向Layer2Config接口添加新的配置或依赖时,我们也会被迫将其添加到Layer1Config接口中。Layer1Config将违反接口隔离原则,正如第二章中讨论的SOLID 设计原则 for Go,这表明我们可能会有问题。此外,根据代码的分层和重用级别,更改的数量可能会很大。在这种情况下,更好的选择是应用构造函数注入,将Layer2Object注入Layer1Object。这将完全解耦对象并消除对分层更改的需求。

总结

在本章中,我们利用了配置注入,这是构造函数和方法注入的扩展版本,以改善我们的代码的用户体验,主要是通过将环境依赖和配置与上下文相关的依赖分开处理。

在对我们的示例服务应用配置注入时,我们已经将所有可能的包与config包解耦,使其有更多的自由发展。我们还将大部分日志记录器的使用从全局公共变量切换为注入的抽象依赖,从而消除了与日志记录器实例相关的任何数据竞争的可能性,并使我们能够在没有任何混乱的猴子补丁的情况下测试日志记录器的使用。

在下一章中,我们将研究另一种不寻常的依赖注入形式,称为即时JIT依赖注入。通过这种技术,我们将减少与层之间的依赖创建和注入相关的负担,同时不会牺牲使用模拟和存根进行测试的能力。

问题

  1. 配置注入与方法或构造函数注入有何不同?

  2. 我们如何决定将哪些参数移动到配置注入?

  3. 为什么我们不通过配置注入注入所有依赖项?

  4. 为什么我们想要注入环境依赖(如日志记录器),而不是使用全局公共变量?

  5. 边界测试为什么重要?

  6. 配置注入的理想使用案例是什么?

第九章:刚性依赖注入

使用传统 依赖注入DI)方法,父对象或调用对象向子类提供依赖项。然而,有许多情况下,依赖项只有一个实现。在这些情况下,一个务实的方法是问自己,为什么要注入依赖项?在本章中,我们将研究just-in-timeJIT)依赖注入,这是一种策略,它给我们带来了 DI 的许多好处,如解耦和可测试性,而不需要向我们的构造函数或方法添加参数。

本章将涵盖以下主题:

  • JIT 注入

  • JIT 注入的优势

  • 应用 JIT 注入

  • JIT 注入的缺点

技术要求

熟悉我们在第四章中介绍的服务代码可能会有所帮助,ACME 注册服务简介。本章还假定您已经阅读了第六章,构造函数注入的依赖注入,以及在较小程度上,第五章,使用 Monkey Patching 进行依赖注入

您可能还会发现阅读和运行本章的完整代码版本很有用,该代码版本可在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch09上找到。

获取代码并配置示例服务的说明可在此处的 README 部分找到:github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch09/acme中找到我们的服务代码,其中已经应用了本章的更改。

在本章中,我们将使用 mockery(github.com/vektra/mockery)生成我们接口的模拟实现,并介绍一个名为package coveragegithub.com/corsc/go-tools/tree/master/package-coverage))的新工具。

JIT 注入

您是否曾经编写过一个对象,并注入了一个您知道只会有一个实现的依赖项?也许您已经将数据库处理代码注入到业务逻辑层中,如下面的代码所示:

func NewMyLoadPersonLogic(ds DataSource) *MyLoadPersonLogic {
   return &MyLoadPersonLogic{
      dataSource: ds,
   }
}

type MyLoadPersonLogic struct {
   dataSource DataSource
}

// Load person by supplied ID
func (m *MyLoadPersonLogic) Load(ID int) (Person, error) {
   return m.dataSource.Load(ID)
}

您是否曾经为了在测试期间将其模拟而将依赖项添加到构造函数中?这在以下代码中显示:

func NewLoadPersonHandler(logic LoadPersonLogic) *LoadPersonHandler {
   return &LoadPersonHandler{
      businessLogic: logic,
   }
}

type LoadPersonHandler struct {
   businessLogic LoadPersonLogic
}

func (h *LoadPersonHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   requestedID, err := h.extractInputFromRequest(request)

   output, err := h.businessLogic.Load(requestedID)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
      return
   }

   h.writeOutput(response, output)
}

这些事情可能会感觉像是不必要的额外工作,它们确实会降低代码的用户体验。 JIT 注入为我们提供了一个舒适的中间地带。 JIT 注入可能最好通过一些示例来解释。让我们看看我们第一个应用了 JIT 注入的示例:

type MyLoadPersonLogicJIT struct {
   dataSource DataSourceJIT
}

// Load person by supplied ID
func (m *MyLoadPersonLogicJIT) Load(ID int) (Person, error) {
   return m.getDataSource().Load(ID)
}

func (m *MyLoadPersonLogicJIT) getDataSource() DataSourceJIT {
   if m.dataSource == nil {
      m.dataSource = NewMyDataSourceJIT()
   }

   return m.dataSource
}

如您所见,我们已经通过添加一个getter函数getDataSource(),将直接引用从m.dataSource更改为m.getDataSource()。在getDataSource()中,我们执行了一个简单而高效的检查,以查看依赖项是否已经存在,当它不存在时,我们创建它。这就是我们得到just-in-time 注入名称的地方。

因此,如果我们不打算注入依赖项,那么为什么需要注入?简单的答案是测试。

在我们的原始示例中,我们能够在测试期间使用模拟实现替换我们的依赖项,如下面的代码所示:

func TestMyLoadPersonLogic(t *testing.T) {
   // setup the mock db
   mockDB := &mockDB{
      out: Person{Name: "Fred"},
   }

   // call the object we are testing
   testObj := NewMyLoadPersonLogic(mockDB)
   result, resultErr := testObj.Load(123)

   // validate expectations
   assert.Equal(t, Person{Name: "Fred"}, result)
   assert.Nil(t, resultErr)
}

使用 JIT 注入,我们仍然可以提供一个模拟实现,但是不是通过构造函数提供,而是直接将其注入到私有成员变量中,就像这样:

func TestMyLoadPersonLogicJIT(t *testing.T) {
   // setup the mock db
   mockDB := &mockDB{
      out: Person{Name: "Fred"},
   }

   // call the object we are testing
   testObj := MyLoadPersonLogicJIT{
      dataSource: mockDB,
   }
   result, resultErr := testObj.Load(123)

   // validate expectations
   assert.Equal(t, Person{Name: "Fred"}, result)
   assert.Nil(t, resultErr)
}

您可能还注意到,在这个例子中,我们放弃了使用构造函数。这并不是必要的,也不会总是这种情况。应用 JIT 注入通过减少参数的数量来提高对象的可用性。在我们的例子中,没有剩下的参数,所以放弃构造函数似乎也是合适的。

JIT 注入使我们能够打破 DI 的传统规则,使对象能够在需要时创建自己的依赖关系。虽然严格来说这是违反了单一责任原则部分,正如在第二章中讨论的那样,Go 的 SOLID 设计原则,但可用性的改进是显著的。

JIT 注入的优势

这种方法旨在解决传统 DI 的一些痛点。这里列出的优势是特定于这种方法的,与其他形式的依赖注入形成对比。这种方法的特定优势包括以下内容。

更好的用户体验(UX)由于更少的输入 - 我知道我已经提到了这一点很多次,但是更容易理解的代码也更容易维护和扩展。当一个函数的参数更少时,它本质上更容易理解。比较构造函数:

func NewGenerator(storage Storage, renderer Renderer, template io.Reader) *Generator {
   return &Generator{
      storage:  storage,
      renderer: renderer,
      template: template,
   }
}

与这个:

func NewGenerator(template io.Reader) *Generator {
   return &Generator{
      template: template,
   }
}

在这个例子中,我们删除了所有只有一个活动实现的依赖项,并用 JIT 注入替换了它们。现在,这个函数的用户只需要提供一个可能会改变的依赖项。

它非常适合可选依赖项 - 与前面关于 UX 的观点类似,可选依赖项可能会使函数的参数列表膨胀。此外,依赖项是否是可选的并不是立即显而易见的。将依赖项移动到公共成员变量允许用户仅在需要时提供它。然后应用 JIT 注入允许对象实例化默认依赖项的副本。这显著简化了对象内部的代码。

考虑以下不使用 JIT 注入的代码:

func (l *LoaderWithoutJIT) Load(ID int) (*Animal, error) {
   var output *Animal
   var err error

   // attempt to load from cache
   if l.OptionalCache != nil {
      output = l.OptionalCache.Get(ID)
      if output != nil {
         // return cached value
         return output, nil
      }
   }

   // load from data store
   output, err = l.datastore.Load(ID)
   if err != nil {
      return nil, err
   }

   // cache the loaded value
   if l.OptionalCache != nil {
      l.OptionalCache.Put(ID, output)
   }

   // output the result
   return output, nil
}

应用 JIT 注入,这变成了以下形式:

func (l *LoaderWithJIT) Load(ID int) (*Animal, error) {
   // attempt to load from cache
   output := l.cache().Get(ID)
   if output != nil {
      // return cached value
      return output, nil
   }

   // load from data store
   output, err := l.datastore.Load(ID)
   if err != nil {
      return nil, err
   }

   // cache the loaded value
   l.cache().Put(ID, output)

   // output the result
   return output, nil
}

这个函数现在更加简洁,更容易阅读。我们将在下一节中更详细地讨论使用 JIT 注入处理可选依赖项。

更好地封装实现细节 - 对典型 DI(即构造函数或参数注入)的反驳之一是,通过暴露一个对象对另一个对象的依赖,你泄漏了实现细节。考虑以下构造函数:

func NewLoader(ds Datastore, cache Cache) *MyLoader {
   return &MyLoader{
      ds:    ds,
      cache: cache,
   }
}

现在,把自己放在MyLoader的用户的位置上,不知道它的实现。对你来说,MyLoader使用数据库还是缓存重要吗?如果你没有多个实现或配置可供使用,让MyLoader的作者为你处理会更容易吗?

减少测试引起的损害 - 反对 DI 的人经常抱怨的另一个问题是,依赖项被添加到构造函数中,唯一目的是在测试期间替换它们。这个观点是有根据的;你会经常看到这种情况,也是测试引起的损害的更常见形式之一。JIT 注入通过将关系更改为私有成员变量并将其从公共 API 中移除来缓解了这一问题。这仍然允许我们在测试期间替换依赖项,但不会造成公共损害。

如果你在想,选择私有成员变量而不是公共的是有意的,也是有意限制的。私有的话,我们只能在同一个包内的测试期间访问和替换依赖项。包外的测试故意没有访问权限。这样做的第一个原因是封装。我们希望隐藏实现细节,使其他包不与我们的包耦合。任何这样的耦合都会使对我们实现的更改变得更加困难。

第二个原因是 API 污染。如果我们将成员变量设为公共的,那么不仅测试可以访问,而且所有人都可以访问,从而打开了意外、无效或危险使用我们内部的可能性。

这是一个很好的替代方法——正如你可能还记得第五章中所说的,使用猴子补丁进行依赖注入,猴子补丁的最大问题之一是测试期间的并发性。通过调整单个全局变量以适应当前测试,任何使用该变量的其他测试都会受到影响,很可能会出错。可以使用 JIT 注入来避免这个问题。考虑以下代码:

// Global singleton of connections to our data store
var storage UserStorage

type Saver struct {
}

func (s *Saver) Do(in *User) error {
   err := s.validate(in)
   if err != nil {
      return err
   }

   return storage.Save(in)
}

目前,全局变量存储在测试期间需要进行猴子补丁。但是当我们应用 JIT 注入时会发生什么呢?

// Global singleton of connections to our data store
var storage UserStorage

type Saver struct {
   storage UserStorage
}

func (s *Saver) Do(in *User) error {
   err := s.validate(in)
   if err != nil {
      return err
   }

   return s.getStorage().Save(in)
}

// Just-in-time DI
func (s *Saver) getStorage() UserStorage {
   if s.storage == nil {
      s.storage = storage
   }

   return s.storage
}

现在所有对全局变量的访问都通过getStorage()进行,我们能够使用 JIT 注入来替换storage成员变量,而不是对全局(和共享)变量进行猴子补丁,就像这个例子中所示的那样:

func TestSaver_Do(t *testing.T) {
   // input
   carol := &User{
      Name:     "Carol",
      Password: "IamKing",
   }

   // mocks/stubs
   stubStorage := &StubUserStorage{}

   // do call
   saver := &Saver{
      storage: stubStorage,
   }
   resultErr := saver.Do(carol)

   // validate
   assert.NotEqual(t, resultErr, "unexpected error")
}

在上述测试中,全局变量上不再存在数据竞争。

对于分层代码来说非常好——当将依赖注入应用于整个项目时,很常见的是在应用程序执行的早期看到大量对象被创建。例如,我们的最小示例服务已经在main()中创建了四个对象。四个听起来可能不多,但我们还没有将 DI 应用到所有的包,到目前为止我们只有三个端点。

对于我们的服务,我们有三层代码,REST、业务逻辑和数据。层之间的关系很简单。REST 层中的一个对象调用其业务逻辑层的合作对象,然后调用数据层。除了测试之外,我们总是注入相同的依赖项。应用 JIT 注入将允许我们从构造函数中删除这些依赖项,并使代码更易于使用。

实现成本低——正如我们在之前的猴子补丁示例中看到的,应用 JIT 注入非常容易。此外,更改范围很小。

同样,对于原本没有任何形式的 DI 的代码应用 JIT 注入也很便宜。考虑以下代码:

type Car struct {
   engine Engine
}

func (c *Car) Drive() {
   c.engine.Start()
   defer c.engine.Stop()

   c.engine.Drive()
}

如果我们决定将CarEngine解耦,那么我们只需要将抽象交互定义为接口,然后将所有对c.engine的直接访问更改为使用getter函数,如下面的代码所示:

type Car struct {
   engine Engine
}

func (c *Car) Drive() {
   engine := c.getEngine()

   engine.Start()
   defer engine.Stop()

   engine.Drive()
}

func (c *Car) getEngine() Engine {
   if c.engine == nil {
      c.engine = newEngine()
   }

   return c.engine
}

考虑一下应用构造函数注入的过程。我们需要在哪些地方进行更改?

应用 JIT 注入

在之前的章节中,我提到了 JIT 注入可以用于私有和公共依赖项,这是两种非常不同的用例。在本节中,我们将应用这两种选项以实现非常不同的结果。

单元测试覆盖率

在 Go 中,测试覆盖率是通过在调用 go test 时添加-cover标志来计算的。由于这只适用于一个包,我觉得这很不方便。因此,我们将使用一个工具,该工具可以递归计算目录树中所有包的测试覆盖率。这个工具叫做package-coverage,可以从 GitHub (github.com/corsc/go-tools/tree/master/package-coverage) 获取。

使用package-coverage计算覆盖率时,我们使用以下命令:

$ cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch08/

$ export ACME_CONFIG=$GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/config.json

$ package-coverage -a -prefix $(go list)/ ./acme/

注意:我故意使用了第八章中的代码,通过配置进行依赖注入,所以覆盖率数字是在我们在本章可能进行的任何更改之前。

这给我们带来了以下结果:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  65.66 |    265 |   0.00 |      7 | acme/                             |
|  47.83 |     23 |  47.83 |     23 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  73.77 |     61 |  73.77 |     61 | acme/internal/modules/data/       |
|  61.70 |     47 |  61.70 |     47 | acme/internal/modules/exchange/   |
|  85.71 |      7 |  85.71 |      7 | acme/internal/modules/get/        |
|  46.15 |     13 |  46.15 |     13 | acme/internal/modules/list/       |
|  62.07 |     29 |  62.07 |     29 | acme/internal/modules/register/   |
|  79.73 |     74 |  79.73 |     74 | acme/internal/rest/               |
-------------------------------------------------------------------------

所以,我们可以从这些数字中推断出什么呢?

  1. 代码覆盖率是合理的。它可能会更好,但除了logging包上的 0 之外,几乎所有包都有 50%以上。

  2. 语句(stmts)计数很有趣。语句大致相当于代码行,因此这些数字表明哪些包有更多或更少的代码。我们可以看到restdataexchange包是最大的。

  3. 我们可以从包中的代码量推断出,包含的代码越多,责任和复杂性就越大。因此,这个包带来的风险也就越大。

考虑到两个最大的、最具风险的包restdata都有很好的测试覆盖率,我们仍然没有任何迫切需要关注的迹象。但是如果我们将测试覆盖率和依赖图结合起来会发生什么呢?

私有依赖

我们可以通过应用 JIT 注入来改进我们的服务的许多地方。那么我们该如何决定呢?让我们看看我们的依赖图有什么说法:

有很多连接进入日志包。但是我们在第八章中已经相当程度地解耦了它,通过配置进行依赖注入

下一个用户最多的包是data包。我们在第五章中曾经讨论过它,使用 Monkey Patching 进行依赖注入,但也许现在是时候重新审视它,看看我们是否可以进一步改进它。

在我们做出决定之前,我将向你介绍另一种了解代码健康状况和我们最好花费精力的方法:单元测试覆盖率。与依赖图一样,它不能提供明确的指标,只能给你一些暗示。

覆盖率和依赖图

依赖图告诉我们,data包有很多用户。测试覆盖率告诉我们,它也是我们拥有的最大的包之一。因此,我们可以推断,如果我们想要做改进,这可能是开始的合适地方。

你可能还记得之前章节提到的,data包使用了函数和全局单例池,这两者都给我们带来了不便。因此,让我们看看是否可以使用 JIT 注入来摆脱这些痛点。

赶走猴子

以下是get包目前如何使用data包的方式:

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := loader(context.TODO(), g.cfg, ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the implementation 
         // details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// this function as a variable allows us to Monkey Patch during testing
var loader = data.Load

我们的第一个改变将是定义一个接口,用它来替换我们的loader函数:

//go:generate mockery -name=myLoader -case underscore -testonly -inpkg
type myLoader interface {
   Load(ctx context.Context, ID int) (*data.Person, error)
}

你可能已经注意到我们删除了配置参数。等我们完成后,我们将不必在每次调用时传递这个参数。我还添加了一个go generate注释,它将创建一个我们以后会使用的模拟。

接下来,我们将将这个依赖作为私有成员变量添加,并更新我们的Do()方法以使用 JIT 注入:

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := g.getLoader().Load(context.TODO(), ID)
   if err != nil {
      if err == data.ErrNotFound {
         // By converting the error we are hiding the implementation 
         // details from our users.
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

但是我们的 JIT 注入getter方法会是什么样子呢?基本结构将是标准的,如下面的代码所示:

func (g *Getter) getLoader() myLoader {
   if g.data == nil {
      // To be determined
   }

   return g.data
}

因为data包是以函数实现的,我们目前没有任何实现我们的loader接口的东西。我们的代码和单元测试现在都出问题了,所以在我们让它们再次工作之前,我们将不得不盲目行事一段时间。

让我们首先定义一个数据访问对象DAO),这是让我们的代码再次工作的最短路径。这将用一个结构体替换data包中的函数,并给我们一个实现myLoader接口的东西。为了尽量减少更改,我们将让 DAO 方法调用现有的函数,如下面的代码所示:

// NewDAO will initialize the database connection pool (if not already 
// done) and return a data access object which can be used to interact 
// with the database
func NewDAO(cfg Config) *DAO {
   // initialize the db connection pool
   _, _ = getDB(cfg)

   return &DAO{
      cfg: cfg,
   }
}

type DAO struct {
   cfg Config
}

// Load will attempt to load and return a person.
func (d *DAO) Load(ctx context.Context, ID int) (*Person, error) {
   return Load(ctx, d.cfg, ID)
}

即使在我们将 DAO 添加到getLoader()函数中后,我们的测试仍然没有恢复。我们的测试仍然使用了 Monkey Patching,因此我们需要删除该代码并用一个模拟替换它,得到以下结果:

func TestGetter_Do_happyPath(t *testing.T) {
   // inputs
   ID := 1234

   // configure the mock loader
   mockResult := &data.Person{
      ID:       1234,
      FullName: "Doug",
   }
   mockLoader := &mockMyLoader{}
   mockLoader.On("Load", mock.Anything, ID).Return(mockResult, nil).Once()

   // call method
   getter := &Getter{
      data: mockLoader,
   }
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "Doug", person.FullName)
   assert.True(t, mockLoader.AssertExpectations(t))
}

最后,我们的测试又可以工作了。通过这些重构,我们还实现了一些其他的改进:

  • 我们的get包的测试不再使用 Monkey Patching;这意味着我们可以确定没有与 Monkey Patching 相关的并发问题

  • 除了数据结构(data.Person)之外,get包的测试不再使用data

  • 也许最重要的是,get包的测试不再需要配置数据库

完成get包的计划更改后,我们可以转移到data包。

早些时候,我们定义了一个 DAO,其中我们的Load()方法调用了现有的Load()函数。由于Load()函数没有更多的用户,我们可以简单地复制代码并更新相应的测试。

在为data包及其用户重复这个简单的过程之后,我们成功地迁移到了基于对象的包,而不是基于函数的包。

可选的公共依赖项

到目前为止,我们已经将 JIT 依赖注入应用于私有依赖项,目标是减少参数,并使我们的data包更加简单易用。

还有另一种使用 JIT 注入的方式——可选的公共依赖项。这些依赖项是公共的,因为我们希望用户能够更改它们,但我们不将它们作为构造函数的一部分,因为它们是可选的。这样做会影响用户体验,特别是在可选依赖项很少使用的情况下。

假设我们在服务的加载所有注册端点遇到性能问题,并且我们怀疑问题与数据库的响应速度有关。

面对这样的问题,我们决定需要通过添加一些仪器来跟踪这些查询花费了多长时间。为了确保我们能够轻松地打开和关闭这个跟踪器,我们可以将其作为可选依赖项。

我们的第一步将是定义我们的tracker接口:

// QueryTracker is an interface to track query timing
type QueryTracker interface {
   // Track will record/out the time a query took by calculating 
   // time.Now().Sub(start)
   Track(key string, start time.Time)
}

我们需要做出决定。使用QueryTracker是可选的,这意味着用户不能保证已注入依赖项。

为了避免在使用QueryTracker时出现守卫子句,我们将引入一个 NO-OP 实现,当用户没有提供时可以使用。NO-OP 实现,有时被称为空对象,是一个实现接口但所有方法都故意不执行任何操作的对象。

这是QueryTracker的 NO-OP 实现:

// NO-OP implementation of QueryTracker
type noopTracker struct{}

// Track implements QueryTracker
func (_ *noopTracker) Track(_ string, _ time.Time) {
   // intentionally does nothing
}

现在,我们可以将其引入到我们的 DAO 作为一个公共成员变量:

// DAO is a data access object that provides an abstraction over 
// our database interactions.
type DAO struct {
   cfg Config

   // Tracker is an optional query timer
   Tracker QueryTracker
}

我们可以使用 JIT 注入来访问默认为 NO-OP 版本的跟踪器:

func (d *DAO) getTracker() QueryTracker {
   if d.Tracker == nil {
      d.Tracker = &noopTracker{}
   }

   return d.Tracker
}

现在一切就绪,我们可以在想要跟踪的任何方法的开头添加以下行:

// track processing time
defer d.getTracker().Track("LoadAll", time.Now())

这里值得注意的是defer的使用。基本上,defer在这里有两个重要的特性。首先,它将在函数退出时被调用,这样我们可以一次添加跟踪器,而不是在每个返回语句旁边添加。其次,defer的参数是在遇到该行时确定的,而不是在执行时确定的。这意味着time.Now()的值将在我们跟踪的函数开始时调用,而不是在Track()函数返回时调用。

为了使我们的跟踪器有用,我们需要提供除了 NO-OP 之外的实现。我们可以将这些值推送到像 StatsD 或 Graphite 这样的外部系统,但为了简单起见,我们将结果输出到日志。代码如下:

// NewLogTracker returns a Tracker that outputs tracking data to log
func NewLogTracker(logger logging.Logger) *LogTracker {
   return &LogTracker{
      logger: logger,
   }
}

// LogTracker implements QueryTracker and outputs to the supplied logger
type LogTracker struct {
   logger logging.Logger
}

// Track implements QueryTracker
func (l *LogTracker) Track(key string, start time.Time) {
   l.logger.Info("[%s] Timing: %s\n", key, time.Now().Sub(start).String())
}

现在,我们可以暂时将我们的 DAO 使用从这个更新为:

func (l *Lister) getLoader() myLoader {
   if l.data == nil {
      l.data = data.NewDAO(l.cfg)
   }

   return l.data
}

现在更新为:

func (l *Lister) getLoader() myLoader {
   if l.data == nil {
      l.data = data.NewDAO(l.cfg)

      // temporarily add a log tracker
      l.data.(*data.DAO).Tracker = data.NewLogTracker(l.cfg.Logger())
   }

   return l.data
}

是的,这行有点丑,但幸运的是它只是临时的。如果我们决定让我们的 QueryTracker 永久存在,或者发现自己大部分时间都在使用它,那么我们可以很容易地切换到构造函数注入。

JIT 注入的缺点

虽然 JIT 注入可能很方便,但并非在所有情况下都可以使用,而且有一些需要注意的地方。其中包括以下内容:

只能应用于静态依赖项-第一个,也许是最重要的缺点是,这种方法只能应用于在测试期间只发生变化的依赖项。我们不能用它来替代参数注入或配置注入。这是因为依赖项的实例化发生在私有方法内部,只在第一次尝试访问变量时发生。

依赖和用户生命周期没有分开-当使用构造函数注入或参数注入时,通常可以假定被注入的依赖已经完全初始化并准备就绪。任何成本或延迟,比如与创建资源池或预加载数据相关的成本,都已经支付。使用 JIT 注入时,依赖项会在第一次使用之前立即创建。因此,任何初始化成本都必须由第一个请求支付。下图显示了三个对象之间的典型交互(调用者、被调用者和数据存储):

现在,将其与在调用期间创建数据存储对象时的交互进行比较:

您可以看到第二个图中产生的额外时间(成本)。在大多数情况下,这些成本并不会发生,因为在 Go 中创建对象很快。但是,当它们存在时,它们可能会在应用程序启动期间导致一些意外或不便的行为。

在像前面提到的那种情况下,依赖项的状态不确定,导致生成的代码存在另一个缺点。考虑以下代码:

func (l *Sender) Send(ctx context.Context, payload []byte) error {
   pool := l.getConnectionPool()

   // ensure pool is ready
   select {
   case <-pool.IsReady():
      // happy path

   case <-ctx.Done():
      // context timed out or was cancelled
      return errors.New("failed to get connection")
   }

   // get connection from pool and return afterwards
   conn := pool.Get()
   defer l.connectionPool.Release(conn)

   // send and return
   _, err := conn.Write(payload)

   return err
}

将前面的代码与保证依赖项处于就绪状态的相同代码进行比较:

func (l *Sender) Send(payload []byte) error {
   pool := l.getConnectionPool()

   // get connection from pool and return afterwards
   conn := pool.Get()
   defer l.connectionPool.Release(conn)

   // send and return
   _, err := conn.Write(payload)

   return err
}

这只是几行代码,当然,它要简单得多,因此更易于阅读和维护。它也更容易实现和测试。

潜在的数据和初始化竞争-与前一点类似,这一点也围绕着依赖项的初始化。然而,在这种情况下,问题与访问依赖项本身有关。让我们回到前面关于连接池的例子,但改变实例化的方式:

func newConnectionPool() ConnectionPool {
   pool := &myConnectionPool{}

   // initialize the pool
   pool.init()

   // return a "ready to use pool"
   return pool
}

正如您所看到的,连接池的构造函数在池完全初始化之前不会返回。那么,在初始化正在进行时再次调用getConnectionPool()会发生什么?

我们可能会创建两个连接池。这张图显示了这种交互:

那么,另一个连接池会发生什么?它将被遗弃。用于创建它的所有 CPU 都是浪费的,甚至可能无法被垃圾收集器正确清理;因此,任何资源,如内存、文件句柄或网络端口,都可能丢失。

有一种简单的方法可以确保避免这个问题,但它会带来非常小的成本。我们可以使用standard库中的 sync 包。这个包有几个不错的选项,但在这种情况下,我建议使用Once()。通过将Once()添加到我们的getConnectionPool()方法中,我们得到了这个:

func (l *Sender) getConnection() ConnectionPool {
   l.initPoolOnce.Do(func() {
      l.connectionPool = newConnectionPool()
   })

   return l.connectionPool
}

这种方法有两个小成本。第一个是代码的复杂性增加;这很小,但确实存在。

第二个成本是对getConnectionPool()的每次调用,可能有很多次,都会检查Once(),看它是否是第一次调用。这是一个非常小的成本,但根据您的性能要求,可能会不方便。

对象并非完全解耦-在整本书中,我们使用依赖图来识别潜在问题,特别是关于包之间的关系,以及在某些情况下对特定包的过度依赖。虽然我们仍然可以并且应该使用第二章中的依赖反转原则部分,Go 的 SOLID 设计原则,并将我们的依赖定义为本地接口,但通过在我们的代码中包含依赖的创建,依赖图仍将显示我们的包与依赖之间的关系。在某种程度上,我们的对象仍然与我们的依赖有些耦合。

摘要

在本章中,我们使用了 JIT 注入,这是一种不太常见的 DI 方法,以消除前几章中的一些猴子补丁。

我们还使用了不同形式的 JIT 注入来添加可选依赖项,而不会影响我们代码的用户体验。

此外,我们还研究了 JIT 注入如何用于减少测试引起的损害,而不牺牲我们在测试中使用模拟和存根的能力。

在下一章中,我们将研究本书中的最后一个 DI 方法,即现成的注入。我们将讨论采用 DI 框架的一般优缺点,并且在我们的示例中,我们将使用 Google 的 Wire 框架。

问题

  1. JIT 注入与构造函数注入有何不同?

  2. 在处理可选依赖关系时,为什么使用 NO-OP 实现很重要?

  3. JIT 注入的理想用例是什么?

第十章:现成注入

在本节的最后一章中,我们将使用框架来进行依赖注入DI)。选择与您首选风格相匹配的 DI 框架可以显著地简化您的生活。即使您不喜欢使用框架,研究它的实现方式和方法也可能会有所帮助,并帮助您找到改进您首选实现的方法。

虽然有许多可用的框架,包括 Facebook 的 Inject(github.com/facebookgo/inject)和 Uber 的 Dig(godoc.org/go.uber.org/dig),但对于我们的示例服务,我们将使用 Google 的 Go Cloud Wire(github.com/google/go-cloud/tree/master/wire)。

本章将涵盖以下主题:

  • 使用 Wire 进行现成的注入

  • 现成注入的优点

  • 应用现成的注入

  • 现成注入的缺点

技术要求

熟悉我们在第四章中介绍的服务代码将是有益的,ACME 注册服务简介。本章还假设您已经阅读了第六章,构造函数注入的依赖注入

您可能还会发现阅读和运行本章的完整代码版本对您有用,该代码版本可在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch10上找到。

获取代码并配置示例服务的说明在此处的 README 中可用:github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在ch10/acme中找到我们的服务代码,并已应用本章的更改。

使用 Wire 进行现成的注入

Go Cloud 项目是一个旨在使应用程序开发人员能够轻松在任何组合的云提供商上部署云应用程序的倡议。该项目的重要部分是基于代码生成的依赖注入工具Wire

Wire 非常适合我们的示例服务,因为它提倡显式实例化,并且不鼓励使用全局变量;正如我们在之前的章节中尝试实现的那样。此外,Wire 使用代码生成来避免由于运行时反射而导致的性能损失或代码复杂性。

对我们来说,Wire 最有用的方面可能是其简单性。一旦我们理解了一些简单的概念,我们需要编写的代码和生成的代码就会相当简单。

引入提供者

文档将提供者定义如下:

可以生成值的函数。”

对于我们的目的,我们可以换一种方式说,提供者返回一个依赖项的实例。

提供者可以采用的最简单形式是简单的无参数函数,如下面的代码所示:

// Provider
func ProvideFetcher() *Fetcher {
   return &Fetcher{}
}

// Object being "provided"
type Fetcher struct {
}

func (f *Fetcher) GoFetch() (string, error) {
return "", errors.New("not implemented yet")
}

提供者还可以通过具有以下参数的方式指示它们需要注入依赖项:

func ProvideFetcher(cache *Cache) *Fetcher {
   return &Fetcher{
      cache: cache,
   }
}

此提供者的依赖项(参数)必须由其他提供者提供。

提供者还可以通过返回错误来指示可能无法初始化,如下面的代码所示:

func ProvideCache() (*Cache, error) {
   cache := &Cache{}

   err := cache.Start()
   if err != nil {
      return nil, err
   }

   return cache, nil
}

重要的是要注意,当提供者返回错误时,使用提供的依赖项的任何注入器也必须返回错误。

理解注入器

Wire 中的第二个概念是注入器。注入器是魔术发生的地方。它们是我们(开发人员)定义的函数,Wire 将其用作代码生成的基础。

例如,如果我们想要一个函数,可以创建我们服务的 REST 服务器的实例,包括初始化和注入所有必需的依赖关系,我们可以通过以下函数实现:

func initializeServer() (*rest.Server, error) {
 wire.Build(wireSet)
 return nil, nil
}

这可能对于这样一个简单的函数来说感觉很大,尤其是因为它似乎没有做任何事情(即 返回 nil, nil)。但这就是我们需要写的全部;代码生成器将把它转换成以下内容:

func initializeServer() (*rest.Server, error) {
   configConfig, err := config.Load()
   if err != nil {
      return nil, err
   }
   getter := get.NewGetter(configConfig)
   lister := list.NewLister(configConfig)
   converter := exchange.NewConverter(configConfig)
   registerer := register.NewRegisterer(configConfig, converter)
   server := rest.New(configConfig, getter, lister, registerer)
   return server, nil
}

我们将在 应用 部分更详细地讨论这一点,但现在有三个上述函数的特点要记住。首先,生成器不关心函数的实现,除了函数必须包含一个 wire.Build(wireSet) 调用。其次,函数必须返回我们计划使用的具体类型。最后,如果我们依赖于任何返回错误的提供者,那么注入器也必须返回一个错误。

采用提供者集

在使用 Wire 时,我们需要了解的最后一个概念是提供者集。提供者集提供了一种将提供者分组的方法,在编写注入器时可以很有帮助。它们的使用是可选的;例如,之前我们使用了一个名为 wireSet 的提供者集,如下面的代码所示:

func initializeServer() (*rest.Server, error) {
   wire.Build(wireSet)
   return nil, nil
}

然而,我们可以像下面的代码所示,单独传递所有的提供者:

func initializeServer() (*rest.Server, error) {
   wire.Build(
      // *config.Config
      config.Load,

      // *exchange.Converter
      wire.Bind(new(exchange.Config), &config.Config{}),
      exchange.NewConverter,

      // *get.Getter
      wire.Bind(new(get.Config), &config.Config{}),
      get.NewGetter,

      // *list.Lister
      wire.Bind(new(list.Config), &config.Config{}),
      list.NewLister,

      // *register.Registerer
      wire.Bind(new(register.Config), &config.Config{}),
      wire.Bind(new(register.Exchanger), &exchange.Converter{}),
      register.NewRegisterer,

      // *rest.Server
      wire.Bind(new(rest.Config), &config.Config{}),
      wire.Bind(new(rest.GetModel), &get.Getter{}),
      wire.Bind(new(rest.ListModel), &list.Lister{}),
      wire.Bind(new(rest.RegisterModel), &register.Registerer{}),
      rest.New,
   )

   return nil, nil
}

遗憾的是,前面的例子并不是虚构的。它来自我们的小例子服务。

正如你所期望的,Wire 中还有很多更多的功能,但在这一点上,我们已经涵盖了足够让我们开始的内容。

现成注入的优势

虽然到目前为止在本章中我们一直在讨论 Wire,但我想花点时间讨论现成注入的优势。在评估工具或框架时,审视它可能具有的优势、劣势和对代码的影响是至关重要的。

现成注入的一些可能优势包括以下。

减少样板代码—将构造函数注入应用到程序后,main() 函数通常会因对象的实例化而变得臃肿。随着项目的增长,main() 也会增长。虽然这不会影响程序的性能,但维护起来会变得不方便。

许多依赖注入框架的目标要么是删除这些代码,要么是将其移动到其他地方。正如我们将看到的,这是在采用 Google Wire 之前我们示例服务的 main()

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // build the exchanger
   exchanger := exchange.NewConverter(config.App)

   // build model layer
   getModel := get.NewGetter(config.App)
   listModel := list.NewLister(config.App)
   registerModel := register.NewRegisterer(config.App, exchanger)

   // start REST server
   server := rest.New(config.App, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

这是在采用 Google Wire 之后的 main()

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server, err := initializeServer()
   if err != nil {
      os.Exit(-1)
   }

   server.Listen(ctx.Done())
}

所有相关的对象创建都被简化为这样:

func initializeServer() (*rest.Server, error) {
   wire.Build(wireSet)
   return nil, nil
}

因为 Wire 是一个代码生成器,实际上我们最终会得到更多的代码,但其中更少的代码是由我们编写或维护的。同样,如果我们使用另一个名为 Dig 的流行 DI 框架,main() 将变成这样:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // build DIG container
   container := BuildContainer()

   // start REST server
   err := container.Invoke(func(server *rest.Server) {
      server.Listen(ctx.Done())
   })

   if err != nil {
      os.Exit(-1)
   }
}

正如你所看到的,我们在代码上获得了类似的减少。

自动实例化顺序—与前面的观点类似,随着项目的增长,依赖项必须创建的顺序复杂性也会增加。因此,现成注入框架提供的许多 魔法 都集中在消除这种复杂性上。在 Wire 和 Dig 的两种情况下,提供者明确定义它们的直接依赖关系,并忽略它们的依赖项的任何要求。

考虑以下示例。假设我们有一个像这样的 HTTP 处理程序:

func NewGetPersonHandler(model *GetPersonModel) *GetPersonHandler {
   return &GetPersonHandler{
      model: model,
   }
}

type GetPersonHandler struct {
   model *GetPersonModel
}

func (g *GetPersonHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   response.WriteHeader(http.StatusInternalServerError)
   response.Write([]byte(`not implemented yet`))
}

正如你所看到的,处理程序依赖于一个模型,看起来像下面的代码所示:

func NewGetPersonModel(db *sql.DB) *GetPersonModel {
   return &GetPersonModel{
      db: db,
   }
}

type GetPersonModel struct {
   db *sql.DB
}

func (g *GetPersonModel) LoadByID(ID int) (*Person, error) {
   return nil, errors.New("not implemented yet")
}

type Person struct {
   Name string
}

这个模型依赖于 *sql.DB。然而,当我们为我们的处理程序定义提供者时,它只定义了它需要 *GetPersonModel,并不知道 *sql.DB,就像这样:

func ProvideHandler(model *GetPersonModel) *GetPersonHandler {
   return &GetPersonHandler{
      model: model,
   }
}

与创建数据库、将其注入模型,然后将模型注入处理程序的替代方案相比,这样做更简单,无论是在编写还是在维护上。

有人已经为你考虑过了——也许一个好的 DI 框架可以提供的最不明显但最重要的优势是其创建者的知识。创建和维护一个框架的行为绝对不是一个微不足道的练习,它教给了它的作者比大多数程序员需要知道的更多关于 DI 的知识。这种知识通常会导致框架中出现微妙但有用的特性。例如,在 Dig 框架中,默认情况下,所有依赖关系都是单例的。这种设计选择导致了性能和资源使用的改进,以及更可预测的依赖关系生命周期。

应用现成的注入

正如我在前一节中提到的,通过采用 Wire,我们希望在main()中看到代码和复杂性显著减少。我们也希望能够基本上忘记依赖关系的实例化顺序,让框架来为我们处理。

采用 Google Wire

然而,我们需要做的第一件事是整理好我们的房子。大多数,如果不是全部,我们要让 Wire 处理的对象都使用我们的*config.Config对象,目前它存在为全局单例,如下面的代码所示:

// App is the application config
var App *Config

// Load returns the config loaded from environment
func init() {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      logging.L.Error("failed to locate file specified by %s", DefaultEnvVar)
      return
   }

   _ = load(filename)
}

func load(filename string) error {
   App = &Config{}
   bytes, err := ioutil.ReadFile(filename)
   if err != nil {
      logging.L.Error("failed to read config file. err: %s", err)
      return err
   }

   err = json.Unmarshal(bytes, App)
   if err != nil {
      logging.L.Error("failed to parse config file. err : %s", err)
      return err
   }

   return nil
}

为了将其改为 Wire 可以使用的形式,我们需要删除全局实例,并将配置加载更改为一个函数,而不是由init()触发。

快速查看我们的全局单例的用法后,可以看到只有main()config包中的一些测试引用了这个单例。由于我们之前的所有工作,这个改变将会非常简单。重构后的配置加载器如下:

// Load returns the config loaded from environment
func Load() (*Config, error) {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      err := fmt.Errorf("failed to locate file specified by %s", DefaultEnvVar)
      logging.L.Error(err.Error())
      return nil, err
   }

   cfg, err := load(filename)
   if err != nil {
      logging.L.Error("failed to load config with err %s", err)
      return nil, err
   }

   return cfg, nil
}

这是我们更新后的main()

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // load config
   cfg, err := config.Load(config.DefaultEnvVar)
   if err != nil {
      os.Exit(-1)
   }

   // build the exchanger
   exchanger := exchange.NewConverter(cfg)

   // build model layer
   getModel := get.NewGetter(cfg)
   listModel := list.NewLister(cfg)
   registerModel := register.NewRegisterer(cfg, exchanger)

   // start REST server
   server := rest.New(cfg, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

现在我们已经移除了配置全局变量,我们准备开始采用 Google Wire。

我们将首先添加一个新文件;我们将其命名为wire.go。它可以被称为任何东西,但我们需要一个单独的文件,因为我们将使用 Go 构建标签来将我们在这个文件中编写的代码与 Wire 生成的版本分开。

如果你不熟悉构建标签,在 Go 中它们是文件顶部的注释,在package语句之前,形式如下:

//+build myTag

package main

这些标签告诉编译器何时包含或不包含文件在编译期间。例如,前面提到的标签告诉编译器仅在触发构建时包含此文件,就像这样:

$ go build -tags myTag

我们还可以使用构建标签来做相反的事情,使一个文件只在未指定标签时包含,就像这样:

//+build !myTag

package main

回到wire.go,在这个文件中,我们将定义一个用于配置的注入器,它使用我们的配置加载器作为提供者,如下所示:

//+build wireinject

package main

import (
   "github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch10/acme/internal/config"
   "github.com/google/go-cloud/wire"
)

// The build tag makes sure the stub is not built in the final build.

func initializeConfig() (*config.Config, error) {
   wire.Build(config.Load)
   return nil, nil
}

让我们更详细地解释一下注入器。函数签名定义了一个返回*config.Config实例或错误的函数,这与之前的config.Load()是一样的。

函数的第一行调用了wire.Build()并提供了我们的提供者,第二行返回了nil, nil。事实上,它返回什么并不重要,只要它是有效的 Go 代码。Wire 中的代码生成器将读取函数签名和wire.Build()调用。

接下来,我们打开一个终端,并在包含我们的wire.go文件的目录中运行wire。Wire 将为我们创建一个名为wire_gen.go的新文件,其内容如下所示:

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

package main

import (
   "github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch10/acme/internal/config"
)

// Injectors from wire.go:

func initializeConfig() (*config.Config, error) {
   configConfig, err := config.Load()
   if err != nil {
      return nil, err
   }
   return configConfig, nil
}

你会注意到这个文件也有一个构建标签,但它与我们之前写的相反。Wire 已经复制了我们的initializeConfig()方法,并为我们填写了所有的细节

到目前为止,代码非常简单,很可能与我们自己编写的代码非常相似。你可能会觉得到目前为止我们并没有真正获得太多。我同意。当我们将其余的对象转换过来时,Wire 将为我们处理的代码和复杂性将会显著增加。

为了完成这一系列的更改,我们更新main()以使用我们的initializeConfig()函数,如下所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // load config
   cfg, err := initializeConfig()
   if err != nil {
      os.Exit(-1)
   }

   // build the exchanger
   exchanger := exchange.NewConverter(cfg)

   // build model layer
   getModel := get.NewGetter(cfg)
   listModel := list.NewLister(cfg)
   registerModel := register.NewRegisterer(cfg, exchanger)

   // start REST server
   server := rest.New(cfg, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

处理配置后,我们可以继续下一个对象,*exchange.Converter。在先前的示例中,我们没有使用提供程序集,而是直接将我们的提供程序传递给wire.Build()调用。我们即将添加另一个提供程序,所以现在是时候更加有条理了。因此,我们将在main.go中添加一个私有全局变量,并将我们的ConfigConverter提供程序添加到其中,如下所示:

// List of wire enabled objects
var wireSet = wire.NewSet(
   // *config.Config
   config.Load,

   // *exchange.Converter
   wire.Bind(new(exchange.Config), &config.Config{}),
   exchange.NewConverter,
)

正如您所看到的,我还添加了一个wire.Bind()调用。Wire 要求我们定义或映射满足接口的具体类型,以便在注入期间满足它们。*exchange.Converter的构造函数如下所示:

// NewConverter creates and initializes the converter
func NewConverter(cfg Config) *Converter {
   return &Converter{
      cfg: cfg,
   }
}

您可能还记得,这个构造函数使用配置注入和本地定义的Config接口。但是,我们注入的实际配置对象是*config.Config。我们的wire.Bind()调用告诉 Wire,在需要exchange.Config接口时使用*config.Config

有了我们的提供程序集,我们现在可以更新我们的配置注入器,并添加一个Converter的注入器,如下所示:

func initializeConfig() (*config.Config, error) {
   wire.Build(wireSet)
   return nil, nil
}

func initializeExchanger() (*exchange.Converter, error) {
   wire.Build(wireSet)
   return nil, nil
}

重要的是要注意,虽然exchange.NewConverter()不会返回错误,但我们的注入器必须。这是因为我们依赖于返回错误的配置提供程序。这可能听起来很麻烦,但不用担心,Wire 可以帮助我们做到这一点。

继续我们的对象列表,我们需要对我们的模型层做同样的事情。注入器是完全可预测的,几乎与*exchange.Converter完全相同,提供程序集的更改也是如此。

请注意,main()和更改后的提供程序集如下所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // load config
   cfg, err := initializeConfig()
   if err != nil {
      os.Exit(-1)
   }

   // build model layer
   getModel, _ := initializeGetter()
   listModel, _ := initializeLister()
   registerModel, _ := initializeRegisterer()

   // start REST server
   server := rest.New(cfg, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

// List of wire enabled objects
var wireSet = wire.NewSet(
   // *config.Config
   config.Load,

   // *exchange.Converter
   wire.Bind(new(exchange.Config), &config.Config{}),
   exchange.NewConverter,

   // *get.Getter
   wire.Bind(new(get.Config), &config.Config{}),
   get.NewGetter,

   // *list.Lister
   wire.Bind(new(list.Config), &config.Config{}),
   list.NewLister,

   // *register.Registerer
   wire.Bind(new(register.Config), &config.Config{}),
   wire.Bind(new(register.Exchanger), &exchange.Converter{}),
   register.NewRegisterer,
)

有几件重要的事情。首先,我们的提供程序集变得相当长。这可能没关系,因为我们所做的唯一更改是添加更多的提供程序和绑定语句。

其次,我们不再调用initializeExchanger(),我们实际上已经删除了该注入器。我们不再需要这个的原因是 Wire 正在为我们处理对模型层的注入。

最后,为了简洁起见,我忽略了可能从模型层注入器返回的错误。这是一个不好的做法,但不用担心,我们将在下一组更改后很快删除这些行。

快速运行 Wire 和我们的测试以确保一切仍然按预期工作后,我们准备继续进行最后一个对象,即 REST 服务器。

首先,我们对提供程序集进行了以下可能可预测的添加:

// List of wire enabled objects
var wireSet = wire.NewSet(
   // lines omitted

   // *rest.Server
   wire.Bind(new(rest.Config), &config.Config{}),
   wire.Bind(new(rest.GetModel), &get.Getter{}),
   wire.Bind(new(rest.ListModel), &list.Lister{}),
   wire.Bind(new(rest.RegisterModel), &register.Registerer{}),
   rest.New,
)

之后,我们在wire.go中为我们的 REST 服务器定义注入器,如下所示:

func initializeServer() (*rest.Server, error) {
   wire.Build(wireSet)
   return nil, nil
}

现在,我们可以更新main(),只调用 REST 服务器注入器,如下所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server, err := initializeServer()
   if err != nil {
      os.Exit(-1)
   }

   server.Listen(ctx.Done())
}

完成后,我们可以删除除initializeServer()之外的所有注入器,然后运行 Wire,完成!

现在可能是检查 Wire 为我们生成的代码的好时机:

func initializeServer() (*rest.Server, error) {
   configConfig, err := config.Load()
   if err != nil {
      return nil, err
   }
   getter := get.NewGetter(configConfig)
   lister := list.NewLister(configConfig)
   converter := exchange.NewConverter(configConfig)
   registerer := register.NewRegisterer(configConfig, converter)
   server := rest.New(configConfig, getter, lister, registerer)
   return server, nil
}

这看起来熟悉吗?这与我们采用 wire 之前的main()非常相似。

鉴于我们的代码已经在使用构造函数注入,并且我们的服务相当小,很容易感觉我们为了获得最小的收益而做了很多工作。如果我们从一开始就采用 Wire,肯定不会有这种感觉。在我们的特定情况下,好处更多是长期的。现在 Wire 正在处理构造函数注入以及与实例化和实例化顺序相关的所有复杂性,我们的服务的所有扩展将会更加简单,而且更不容易出现人为错误。

API 回归测试

完成 Wire 转换后,我们如何确保我们的服务仍然按我们的期望工作?

我们唯一的即时选择是运行应用程序并尝试。这个选择现在可能还可以,但我不喜欢它作为长期选择,所以让我们看看是否可以添加一些自动化测试。

我们应该问自己的第一个问题是我们在测试什么?我们不应该需要测试 Wire 本身,我们可以相信工具的作者会这样做。其他方面可能出现什么问题?

一个典型的答案可能是我们使用 Wire。如果我们配置错误 Wire,它将无法生成,所以这个问题已经解决了。这让我们只剩下了应用本身。

为了测试应用程序,我们需要运行它,然后进行 HTTP 调用,并验证响应是否符合我们的预期。

我们需要考虑的第一件事是如何启动应用程序,也许更重要的是,如何以一种可以同时运行多个测试的方式来做到这一点。

目前,我们的配置(数据库连接、HTTP 端口等)是硬编码在磁盘上的一个文件中的。我们可以使用它,但它包括一个固定的 HTTP 服务器端口。另一方面,在我们的测试中硬编码数据库凭据要糟糕得多。

让我们采取一个折中的方法。首先,让我们加载标准的config文件:

// load the standard config (from the ENV)
cfg, err := config.Load()
require.NoError(t, err)

现在,让我们找一个空闲的 TCP 端口来绑定我们的服务器。我们可以使用端口0,并允许系统自动分配一个,就像下面的代码所示:

func getFreePort() (string, error) {
   for attempt := 0; attempt <= 10; attempt++ {
      addr := net.JoinHostPort("", "0")
      listener, err := net.Listen("tcp", addr)
      if err != nil {
         continue
      }

      port, err := getPort(listener.Addr())
      if err != nil {
         continue
      }

      // close/free the port
      tcpListener := listener.(*net.TCPListener)
      cErr := tcpListener.Close()
      if cErr == nil {
         file, fErr := tcpListener.File()
         if fErr == nil {
            // ignore any errors cleaning up the file
            _ = file.Close()
         }
         return port, nil
      }
   }

   return "", errors.New("no free ports")
}

我们现在可以使用那个空闲端口,并将config文件中的地址替换为使用空闲端口的地址,就像这样:

// get a free port (so tests can run concurrently)
port, err := getFreePort()
require.NoError(t, err)

// override config port with free one
cfg.Address = net.JoinHostPort("0.0.0.0", port)

现在我们陷入了困境。目前,要创建服务器的实例,代码看起来是这样的:

// start REST server
server, err := initializeServer()
if err != nil {
   os.Exit(-1)
}

server.Listen(ctx.Done())

配置会自动注入,我们没有机会使用我们的自定义配置。幸运的是,Wire 也可以帮助解决这个问题。

为了能够在我们的测试中手动注入配置,但不修改main(),我们需要将我们的提供者集分成两部分。第一部分是除了配置之外的所有依赖项:

var wireSetWithoutConfig = wire.NewSet(
   // *exchange.Converter
   exchange.NewConverter,

   // *get.Getter
   get.NewGetter,

   // *list.Lister
   list.NewLister,

   // *register.Registerer
   wire.Bind(new(register.Exchanger), &exchange.Converter{}),
   register.NewRegisterer,

   // *rest.Server
   wire.Bind(new(rest.GetModel), &get.Getter{}),
   wire.Bind(new(rest.ListModel), &list.Lister{}),
   wire.Bind(new(rest.RegisterModel), &register.Registerer{}),
   rest.New,
)

第二个包括第一个,然后添加配置和所有相关的绑定:

var wireSet = wire.NewSet(
   wireSetWithoutConfig,

   // *config.Config
   config.Load,

   // *exchange.Converter
   wire.Bind(new(exchange.Config), &config.Config{}),

   // *get.Getter
   wire.Bind(new(get.Config), &config.Config{}),

   // *list.Lister
   wire.Bind(new(list.Config), &config.Config{}),

   // *register.Registerer
   wire.Bind(new(register.Config), &config.Config{}),

   // *rest.Server
   wire.Bind(new(rest.Config), &config.Config{}),
)

下一步是创建一个以 config 为参数的注入器。在我们的情况下,这有点奇怪,因为这是由我们的 config 注入引起的,但它看起来是这样的:

func initializeServerCustomConfig(_ exchange.Config, _ get.Config, _ list.Config, _ register.Config, _ rest.Config) *rest.Server {
   wire.Build(wireSetWithoutConfig)
   return nil
}

运行 Wire 后,我们现在可以启动我们的测试服务器,就像下面的代码所示:

// start the test server on a random port
go func() {
   // start REST server
   server := initializeServerCustomConfig(cfg, cfg, cfg, cfg, cfg)
   server.Listen(ctx.Done())
}()

将所有内容放在一起,我们现在有一个函数,它在一个随机端口上创建一个服务器,并返回服务器的地址,这样我们的测试就知道在哪里调用。以下是完成的函数:

func startTestServer(t *testing.T, ctx context.Context) string {
   // load the standard config (from the ENV)
   cfg, err := config.Load()
   require.NoError(t, err)

   // get a free port (so tests can run concurrently)
   port, err := getFreePort()
   require.NoError(t, err)

   // override config port with free one
   cfg.Address = net.JoinHostPort("0.0.0.0", port)

   // start the test server on a random port
   go func() {
      // start REST server
      server := initializeServerCustomConfig(cfg, cfg, cfg, cfg, cfg)
      server.Listen(ctx.Done())
   }()

   // give the server a chance to start
   <-time.After(100 * time.Millisecond)

   // return the address of the test server
   return "http://" + cfg.Address
}

现在,让我们来看一个测试。同样,我们将使用注册端点作为示例。首先,我们的测试需要启动一个测试服务器。在下面的示例中,您还会注意到我们正在定义一个带有超时的上下文。当上下文完成时,通过超时或被取消,测试服务器将关闭;因此,这个超时成为了我们测试的最大执行时间。以下是启动服务器的代码:

// start a context with a max execution time
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// start test server
serverAddress := startTestServer(t, ctx)

接下来,我们需要构建并发送请求。在这种情况下,我们选择了硬编码负载和 URL。这可能看起来有点奇怪,但实际上有点帮助。如果负载或 URL(这两者都构成我们服务的 API)意外更改,这些测试将会失败。另一方面,考虑一下,如果我们使用一个常量来配置服务器的 URL。如果那个常量被更改,API 将会更改,并且会破坏我们的用户。负载也是一样,我们可以使用内部使用的相同 Go 对象,但那里的更改也不会导致测试失败。

是的,这种重复工作更多,确实使测试更加脆弱,这两者都不好,但是我们的测试出问题总比我们的用户出问题要好。

构建和发送请求的代码如下:

    // build and send request
   payload := bytes.NewBufferString(`
{
   "fullName": "Bob",
   "phone": "0123456789",
   "currency": "AUD"
}
`)

   req, err := http.NewRequest("POST", serverAddress+"/person/register", payload)
   require.NoError(t, err)

   resp, err := http.DefaultClient.Do(req)
   require.NoError(t, err)

现在剩下的就是验证结果。将所有内容放在一起后,我们有了这个:

func TestRegister(t *testing.T) {
   // start a context with a max execution time
   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   defer cancel()

   // start test server
   serverAddress := startTestServer(t, ctx)

   // build and send request
   payload := bytes.NewBufferString(`
{
   "fullName": "Bob",
   "phone": "0123456789",
   "currency": "AUD"
}
`)

   req, err := http.NewRequest("POST", serverAddress+"/person/register", payload)
   require.NoError(t, err)

   resp, err := http.DefaultClient.Do(req)
   require.NoError(t, err)

   // validate expectations
   assert.Equal(t, http.StatusCreated, resp.StatusCode)
   assert.NotEmpty(t, resp.Header.Get("Location"))
}

就是这样。我们现在有了一个自动化测试,确保我们的应用程序启动,可以被调用,并且响应如我们所期望的那样。如果您感兴趣,本章的代码中还有另外两个端点的测试。

现成的注入的缺点

尽管框架作者希望他们的工作成为一种万能解决方案,解决所有世界上的 DI 问题,但很遗憾,事实并非如此;采用框架是有一些成本的,也有一些原因可能会选择不使用它。这些包括以下内容。

仅支持构造函数注入-你可能已经注意到在本章中,所有的例子都使用构造函数注入。这并非偶然。与许多框架一样,Wire 只支持构造函数注入。我们不必删除其他 DI 方法的使用,但框架无法帮助我们处理它。

采用可能成本高昂-正如你在前一节中看到的,采用框架的最终结果可能相当不错,但我们的服务规模较小,而且我们已经在使用 DI。如果这两者中有任何一种情况不成立,我们将需要进行大量的重构工作。正如我们之前讨论过的,我们做的改变越多,我们承担的风险就越大。

这些成本和风险可以通过具有框架的先前经验以及在项目早期采用框架来减轻。

意识形态问题-这本身并不是一个缺点,而更多的是你可能不想采用框架的原因。在 Go 社区中,你会遇到一种观点,即框架与 Go 的哲学不符。虽然我没有找到官方声明或文件支持这一观点,但我相信这是基于 Go 的创作者是 Unix 哲学的粉丝和作者,该哲学规定在隔离中做琐事,然后组合起来使事情有用

框架可能被视为违反这种意识形态,特别是如果它们成为整个系统的普遍部分。我们在本章中提到的框架范围相对较小;所以和其他一切一样,我会让你自己做决定。

总结

在本章中,我们讨论了使用 DI 框架来减轻管理和注入依赖关系的负担。我们讨论了 DI 框架中常见的优缺点,并将 Google 的 Wire 框架应用到我们的示例服务中。

这是我们将讨论的最后一个 DI 方法,在下一章中,我们将采取完全不同的策略,看看不使用 DI 的原因。我们还将看看应用 DI 实际上使代码变得更糟的情况。

问题

  1. 在采用 DI 框架时,你可以期待获得什么?

  2. 在评估 DI 框架时,你应该注意哪些问题?

  3. 采用现成的注入的理想用例是什么?

  4. 为什么重要保护服务免受意外 API 更改的影响?

第十一章:控制你的热情

在本章中,我们将研究依赖注入DI)可能出错的一些方式。

作为程序员,我们对新工具或技术的热情有时会让我们失去理智。希望本章能帮助我们保持理智,避免麻烦。

重要的是要记住,DI 是一种工具,因此应该在方便和适合的时候进行选择性应用。

本章将涵盖以下主题:

  • DI 引起的损害

  • 过早的未来保护

  • 模拟 HTTP 请求

  • 不必要的注入?

技术要求

您可能还会发现阅读和运行本章的完整代码版本很有用,这些代码可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch11上找到。

DI 引起的损害

DI 引起的损害是指使用 DI 使代码更难理解、维护或以其他方式使用的情况。

长构造函数参数列表

长构造函数参数列表可能是由 DI 引起的代码损害中最常见和最经常抱怨的。虽然 DI 并非代码损害的根本原因,但它确实没有帮助。

考虑以下示例,它使用构造函数注入:

func NewMyHandler(logger Logger, stats Instrumentation,
   parser Parser, formatter Formatter,
   limiter RateLimiter,
   cache Cache, db Datastore) *MyHandler {

   return &MyHandler{
      // code removed
   }
}

// MyHandler does something fantastic
type MyHandler struct {
   // code removed
}

func (m *MyHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // code removed
}

构造函数参数太多了。这使得使用、测试和维护都变得困难。那么问题的原因是什么呢?实际上有三个不同的问题。

第一个,也许是最常见的,当第一次采用 DI 时,出现错误的抽象。考虑构造函数的最后两个参数是CacheDatastore。假设cache用于datastore的前端,而不是用于缓存MyHandler的输出,那么这些应该合并为不同的抽象。MyHandler代码不需要深入了解数据存储的位置和方式;它只需要对它需要的内容进行规定。我们应该用更通用的抽象替换这两个输入值,如下面的代码所示:

// Loader is responsible for loading the data
type Loader interface {
   Load(ID int) ([]byte, error)
}

顺便说一句,这也是另一个包/层的绝佳位置。

第二个问题与第一个类似,违反了单一责任原则。我们的MyHandler承担了太多责任。它目前正在解码请求,从数据存储和/或缓存加载数据,然后呈现响应。解决这个问题的最佳方法是考虑软件的层次结构。这是顶层,我们的 HTTP 处理程序;它需要理解和使用 HTTP。因此,我们应该寻找方法让它成为其主要(也许是唯一)责任。

第三个问题是横切关注点。我们的参数包括日志记录和仪表盘依赖项,这些依赖项可能会被大多数代码使用,并且很少在少数测试之外进行更改。我们有几种处理这个问题的选择;我们可以应用配置注入,从而将它们合并为一个依赖项,并将它们与我们可能拥有的任何配置合并。或者我们可以使用即时JIT)注入来访问全局单例。

在这种情况下,我们决定使用配置注入。应用后,我们得到以下代码:

func NewMyHandler(config Config,
   parser Parser, formatter Formatter,
   limiter RateLimiter,
   loader Loader) *MyHandler {

   return &MyHandler{
      // code removed
   }
}

我们仍然有五个参数,这比我们开始时要好得多,但仍然相当多。

我们可以通过组合进一步减少这个问题。首先,让我们看看我们之前示例的构造函数,如下面的代码所示:

func NewMyHandler(config Config,
   parser Parser, formatter Formatter,
   limiter RateLimiter,
   loader Loader) *MyHandler {

   return &MyHandler{
      config:    config,
      parser:    parser,
      formatter: formatter,
      limiter:   limiter,
      loader:    loader,
   }
}

MyHandler作为基本处理程序开始,我们可以定义一个包装我们基本处理程序的新处理程序,如下面的代码所示:

type FancyFormatHandler struct {
   *MyHandler
}

现在我们可以按以下方式为我们的FancyFormatHandler定义一个新的构造函数:

func NewFancyFormatHandler(config Config,
   parser Parser,
   limiter RateLimiter,
   loader Loader) *FancyFormatHandler {

   return &FancyFormatHandler{
      &MyHandler{
         config:    config,
         formatter: &FancyFormatter{},
         parser:    parser,
         limiter:   limiter,
         loader:    loader,
      },
   }
}

就像那样,我们少了一个参数。这里真正的魔力在于匿名组合;因为这样,对FancyFormatHandler.ServeHTTP()的任何调用实际上都会调用MyHandler.ServeHTTP()。在这种情况下,我们添加了一点代码,以改进我们用户的处理程序的用户体验。

注入一个对象时,配置就可以了

通常情况下,你的第一反应是注入一个依赖,这样你就可以在隔离环境中测试你的代码。然而,为了这样做,你不得不引入如此多的抽象和间接性,以至于代码量和复杂性呈指数增长。

这种情况的一个普遍发生是使用通用库来访问外部资源,比如网络资源、文件或数据库。例如,让我们使用我们样本服务的data包。如果我们想要抽象出对sql包的使用,我们可能会从定义一个接口开始,如下面的代码所示:

type Connection interface {
   QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
   QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
   ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}

然后我们意识到QueryRowContext()QueryContext()分别返回*sql.Row*sql.Rows。深入研究这些结构,我们发现没有办法从sql包的外部填充它们的内部状态。为了解决这个问题,我们不得不定义我们自己的RowRows接口,如下面的代码所示:

type Row interface {
   Scan(dest ...interface{}) error
}

type Rows interface {
   Scan(dest ...interface{}) error
   Close() error
   Next() bool
}

type Result interface {
   LastInsertId() (int64, error)
   RowsAffected() (int64, error)
}

我们现在完全与sql包解耦,并且能够在我们的测试中模拟它。

但让我们停下来一分钟,考虑一下我们所处的位置:

  • 我们引入了大约 60 行代码,但我们还没有为它们编写任何测试

  • 我们无法在不使用实际数据库的情况下测试新代码,这意味着我们永远无法完全与数据库解耦

  • 我们增加了另一层抽象和一些复杂性

现在,将这与本地安装数据库并确保其处于良好状态进行比较。这里也有复杂性,但可以说是一个微不足道的一次性成本,特别是当分摊到我们所工作的所有项目时。我们还必须创建和维护数据库中的表。这个最简单的选择是一个SQL脚本——一个也可以用来支持实时系统的脚本。

对于我们的样本服务,我们决定维护一个SQL文件和一个本地安装的数据库。由于这个决定,我们不需要模拟对数据库的调用,而只需要将数据库配置传递给我们的本地数据库。

这种情况经常出现,特别是在来自可信来源的低级包中,比如标准库。解决这个问题的关键是要实事求是。问问自己,我真的需要模拟这个吗?有没有一些配置我可以传递进去,从而减少工作量?

最终,我们必须确保我们从额外的工作、代码和复杂性中获得足够的回报来证明这种努力是值得的。

不必要的间接性

DI 被误用的另一种方式是引入有限(或没有)目的的抽象。类似于我们之前讨论的注入配置而不是对象,这种额外的间接性导致了额外的工作、代码和复杂性。

让我们看一个例子,你可以引入一个抽象来帮助测试,但实际上并不需要。

在标准的 HTTP 库中,有一个名为http.ServeMux的结构体。ServeMux用于构建 HTTP 路由器,即 URL 和 HTTP 处理程序之间的映射。一旦ServeMux配置好了,它就会被传递到 HTTP 服务器中,如下面的代码所示:

func TestExample(t *testing.T) {
   router := http.NewServeMux()
   router.HandleFunc("/health", func(resp http.ResponseWriter, req *http.Request) {
      _, _ = resp.Write([]byte(`OK`))
   })

   // start a server
   address := ":8080"
   go func() {
      _ = http.ListenAndServe(address, router)
   }()

   // call the server
   resp, err := http.Get("http://:8080/health")
   require.NoError(t, err)

   // validate the response
   responseBody, err := ioutil.ReadAll(resp.Body)
   assert.Equal(t, []byte(`OK`), responseBody)
}

随着我们的服务扩展,我们需要确保添加更多的端点。为了防止 API 回归,我们决定添加一些测试来确保我们的路由器配置正确。由于我们熟悉 DI,我们可以立即介绍一个ServerMux的抽象,以便我们可以添加一个模拟实现。这在下面的例子中显示:

type MyMux interface {
   Handle(pattern string, handler http.Handler)
   Handler(req *http.Request) (handler http.Handler, pattern string)
   ServeHTTP(resp http.ResponseWriter, req *http.Request)
}

// build HTTP handler routing
func buildRouter(mux MyMux) {
   mux.Handle("/get", &getEndpoint{})
   mux.Handle("/list", &listEndpoint{})
   mux.Handle("/save", &saveEndpoint{})
}

有了我们的抽象,我们可以定义一个模拟实现MyMux,并编写一个测试,如下面的例子所示:

func TestBuildRouter(t *testing.T) {
   // build mock
   mockRouter := &MockMyMux{}
   mockRouter.On("Handle", "/get", &getEndpoint{}).Once()
   mockRouter.On("Handle", "/list", &listEndpoint{}).Once()
   mockRouter.On("Handle", "/save", &saveEndpoint{}).Once()

   // call function
   buildRouter(mockRouter)

   // assert expectations
   assert.True(t, mockRouter.AssertExpectations(t))
}

这一切看起来都很好。然而,问题在于这是不必要的。我们的目标是通过测试端点和 URL 之间的映射来防止意外的 API 回归。

我们的目标可以在不模拟ServeMux的情况下实现。首先,让我们回到我们引入MyMux接口之前的原始函数,就像下面的例子所示:

// build HTTP handler routing
func buildRouter(mux *http.ServeMux) {
   mux.Handle("/get", &getEndpoint{})
   mux.Handle("/list", &listEndpoint{})
   mux.Handle("/save", &saveEndpoint{})
}

深入了解ServeMux,我们可以看到,如果我们调用Handler(req *http.Request)方法,它将返回配置到该 URL 的http.Handler

因为我们知道我们将为每个端点执行一次,所以我们应该定义一个函数来做到这一点,就像下面的例子中所示:

func extractHandler(router *http.ServeMux, path string) http.Handler {
   req, _ := http.NewRequest("GET", path, nil)
   handler, _ := router.Handler(req)
   return handler
}

有了我们的函数,我们现在可以构建一个测试,验证每个 URL 返回预期的处理程序,就像下面的例子中所示:

func TestBuildRouter(t *testing.T) {
   router := http.NewServeMux()

   // call function
   buildRouter(router)

   // assertions
   assert.IsType(t, &getEndpoint{}, extractHandler(router, "/get"))
   assert.IsType(t, &listEndpoint{}, extractHandler(router, "/list"))
   assert.IsType(t, &saveEndpoint{}, extractHandler(router, "/save"))
}

在前面的例子中,您可能还注意到我们的buildRouter()函数和我们的测试非常相似。这让我们对测试的效果产生了疑问。

在这种情况下,更有效的做法是确保我们有 API 回归测试,验证不仅路由器的配置,还有输入和输出格式,就像我们在第十章的结尾所做的那样,现成的注入

服务定位器

首先,定义一下——服务定位器是围绕一个对象的软件设计模式,该对象充当所有依赖项的中央存储库,并能够按名称返回它们。您会发现这种模式在许多语言中使用,并且是一些 DI 框架和容器的核心。

在我们深入探讨为什么这是 DI 引起的损害之前,让我们看一个过于简化的服务定位器的例子:

func NewServiceLocator() *ServiceLocator {
   return &ServiceLocator{
      deps: map[string]interface{}{},
   }
}

type ServiceLocator struct {
   deps map[string]interface{}
}

// Store or map a dependency to a key
func (s *ServiceLocator) Store(key string, dep interface{}) {
   s.deps[key] = dep
}

// Retrieve a dependency by key
func (s *ServiceLocator) Get(key string) interface{} {
   return s.deps[key]
}

为了使用我们的服务定位器,我们首先必须创建它,并将我们的依赖项与它们的名称进行映射,就像下面的例子所示:

// build a service locator
locator := NewServiceLocator()

// load the dependency mappings
locator.Store("logger", &myLogger{})
locator.Store("converter", &myConverter{})

有了我们构建的服务定位器和设置的依赖项,我们现在可以传递它并根据需要提取依赖项,就像下面的代码所示:

func useServiceLocator(locator *ServiceLocator) {
   // use the locators to get the logger
   logger := locator.Get("logger").(Logger)

   // use the logger
   logger.Info("Hello World!")
}

现在,如果我们想在测试期间替换日志记录器,那么我们只需要构建一个带有模拟日志记录器的新服务定位器,并将其传递给我们的函数。

那有什么问题呢?首先,我们的服务定位器现在是一个上帝对象(如第一章中提到的永远不要停止追求更好),我们可能最终会在各个地方传递它。只需要将一个对象传递到每个函数中听起来可能是一件好事,但这会导致第二个问题。

对象和它使用的依赖之间的关系现在完全对外部隐藏了。我们不再能够查看函数或结构定义并立即知道需要哪些依赖。

最后,我们在没有 Go 类型系统和编译器保护的情况下操作。在前面的例子中,下面的这行可能引起了你的注意:

logger := locator.Get("logger").(Logger)

因为服务定位器接受并返回interface{},每次我们需要访问一个依赖项,我们都需要转换为适当的类型。这种转换不仅使代码变得混乱,还可能在值缺失或类型错误时导致运行时崩溃。我们可以通过更多的代码解决这些问题,就像下面的例子所示:

// use the locators to get the logger
loggerRetrieved := locator.Get("logger")
if loggerRetrieved == nil {
   return
}
logger, ok := loggerRetrieved.(Logger)
if !ok {
   return
}

// use the logger
logger.Info("Hello World!")

采用先前的方法,我们的应用程序将不再崩溃,但变得非常混乱。

过早的未来保护

有时,DI 的应用并不是错误的,而只是不必要的。这种常见的表现形式是过早的未来保护。过早的未来保护是指我们根据可能有一天会需要它的假设,向软件添加我们目前不需要的功能。正如你所期望的那样,这会导致不必要的工作和复杂性。

让我们借鉴我们的服务的例子来看一个例子。目前,我们有一个 Get 端点,如下面的代码所示:

// GetHandler is the HTTP handler for the "Get Person" endpoint
type GetHandler struct {
   cfg    GetConfig
   getter GetModel
}

// ServeHTTP implements http.Handler
func (h *GetHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract person id from request
   id, err := h.extractID(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // attempt get
   person, err := h.getter.Do(id)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusNotFound)
      return
   }

   // happy path
   err = h.writeJSON(response, person)
   if err != nil {
      response.WriteHeader(http.StatusInternalServerError)
   }
}

// output the supplied person as JSON
func (h *GetHandler) writeJSON(writer io.Writer, person *get.Person) error {
   output := &getResponseFormat{
      ID:       person.ID,
      FullName: person.FullName,
      Phone:    person.Phone,
      Currency: person.Currency,
      Price:    person.Price,
   }

   return json.NewEncoder(writer).Encode(output)
}

这是一个简单的 REST 端点,返回 JSON。如果我们决定,有一天,我们可能想以不同的格式输出,我们可以将编码移到一个依赖项中,如下面的示例所示:

// GetHandler is the HTTP handler for the "Get Person" endpoint
type GetHandler struct {
   cfg       GetConfig
   getter    GetModel
   formatter Formatter
}

// ServeHTTP implements http.Handler
func (h *GetHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // no changes to this method
}

// output the supplied person
func (h *GetHandler) buildOutput(writer io.Writer, person *Person) error {
   output := &getResponseFormat{
      ID:       person.ID,
      FullName: person.FullName,
      Phone:    person.Phone,
      Currency: person.Currency,
      Price:    person.Price,
   }

   // build output payload
   payload, err := h.formatter.Marshal(output)
   if err != nil {
      return err
   }

   // write payload to response and return
   _, err = writer.Write(payload)
   return err
}

那段代码看起来合理。那么问题出在哪里呢?简单地说,这是我们不需要做的工作。

因此,这是我们不需要编写或维护的代码。在这个简单的例子中,我们的更改只增加了一点额外的复杂性,这是相对常见的。这种少量的额外复杂性在整个系统中的扩散会减慢我们的速度。

如果这真的成为一个实际要求,那么这绝对是交付功能的正确方式,但在那时,它是一个功能,因此是我们必须承担的负担。

模拟 HTTP 请求

在本章的前面,我们谈到了注入并不是所有问题的答案,在某些情况下,传递配置要高效得多,而且代码要少得多。这种情况经常发生在处理外部服务时,特别是在处理 HTTP 服务时,比如我们示例服务中的上游货币转换服务。

虽然可以模拟对外部服务的 HTTP 请求并使用模拟来彻底测试对外部服务的调用,但这并不是必要的。让我们通过使用我们示例服务的代码来比较模拟和配置的差异。

以下是我们示例服务的代码,调用外部货币转换服务:

// Converter will convert the base price to the currency supplied
type Converter struct {
   cfg Config
}

// Exchange will perform the conversion
func (c *Converter) Exchange(ctx context.Context, basePrice float64, currency string) (float64, error) {
   // load rate from the external API
   response, err := c.loadRateFromServer(ctx, currency)
   if err != nil {
      return defaultPrice, err
   }

   // extract rate from response
   rate, err := c.extractRate(response, currency)
   if err != nil {
      return defaultPrice, err
   }

   // apply rate and round to 2 decimal places
   return math.Floor((basePrice/rate)*100) / 100, nil
}

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      c.cfg.ExchangeBaseURL(),
      c.cfg.ExchangeAPIKey(),
      currency)

   // perform request
   req, err := http.NewRequest("GET", url, nil)
   if err != nil {
      c.logger().Warn("[exchange] failed to create request. err: %s", err)
      return nil, err
   }

   // set latency budget for the upstream call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // replace the default context with our custom one
   req = req.WithContext(subCtx)

   // perform the HTTP request
   response, err := http.DefaultClient.Do(req)
   if err != nil {
      c.logger().Warn("[exchange] failed to load. err: %s", err)
      return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
      c.logger().Warn("[exchange] %s", err)
      return nil, err
   }

   return response, nil
}

func (c *Converter) extractRate(response *http.Response, currency string) (float64, error) {
   defer func() {
      _ = response.Body.Close()
   }()

   // extract data from response
   data, err := c.extractResponse(response)
   if err != nil {
      return defaultPrice, err
   }

   // pull rate from response data
   rate, found := data.Quotes["USD"+currency]
   if !found {
      err = fmt.Errorf("response did not include expected currency '%s'", currency)
      c.logger().Error("[exchange] %s", err)
      return defaultPrice, err
   }

   // happy path
   return rate, nil
}

在我们着手撰写测试之前,我们应该首先问自己,我们想要测试什么?以下是典型的测试场景:

  • 正常路径:外部服务器返回数据,我们成功提取数据

  • 失败/慢请求:外部服务器返回错误或在时间上没有响应

  • 错误响应:外部服务器返回无效的 HTTP 响应代码,表示它有问题

  • 无效响应:外部服务器返回我们不期望的格式的有效负载

我们将通过模拟 HTTP 请求来开始我们的比较。

使用 DI 模拟 HTTP 请求

如果我们要使用 DI 和模拟,那么最干净的选项是模拟 HTTP 请求,以便我们可以使其返回我们需要的任何响应。

为了实现这一点,我们需要做的第一件事是抽象构建和发送 HTTP 请求,如下面的代码所示:

// Requester builds and sending HTTP requests
//go:generate mockery -name=Requester -case underscore -testonly -inpkg -note @generated
type Requester interface {
   doRequest(ctx context.Context, url string) (*http.Response, error)
}

您可以看到,我们还包括了一个go generate注释,它将为我们创建模拟实现。

然后我们可以更新我们的Converter以使用Requester抽象,如下面的示例所示:

// NewConverter creates and initializes the converter
func NewConverter(cfg Config, requester Requester) *Converter {
   return &Converter{
      cfg:       cfg,
      requester: requester,
   }
}

// Converter will convert the base price to the currency supplied
type Converter struct {
   cfg       Config
   requester Requester
}

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      c.cfg.ExchangeBaseURL(),
      c.cfg.ExchangeAPIKey(),
      currency)

   // perform request
   response, err := c.requester.doRequest(ctx, url)
   if err != nil {
      c.logger().Warn("[exchange] failed to load. err: %s", err)
      return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
      c.logger().Warn("[exchange] %s", err)
      return nil, err
   }

   return response, nil
}

有了requester抽象,我们可以使用模拟实现进行测试,如下面的代码所示:

func TestExchange_invalidResponse(t *testing.T) {
   // build response
   response := httptest.NewRecorder()
   _, err := response.WriteString(`invalid payload`)
   require.NoError(t, err)

   // configure mock
   mockRequester := &mockRequester{}
   mockRequester.On("doRequest", mock.Anything, mock.Anything).Return(response.Result(), nil).Once()

   // inputs
   ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
   defer cancel()

   basePrice := 12.34
   currency := "AUD"

   // perform call
   converter := &Converter{
      requester: mockRequester,
      cfg:       &testConfig{},
   }
   result, resultErr := converter.Exchange(ctx, basePrice, currency)

   // validate response
   assert.Equal(t, float64(0), result)
   assert.Error(t, resultErr)
   assert.True(t, mockRequester.AssertExpectations(t))
}

在前面的示例中,我们的模拟请求者返回了一个无效的响应,而不是调用外部服务。通过这样做,我们可以确保我们的代码在发生这种情况时表现得恰当。

为了覆盖其他典型的测试场景,我们只需要复制这个测试,并更改模拟的响应和期望。

现在让我们将基于模拟的测试与基于配置的等效测试进行比较。

使用配置模拟 HTTP 请求

我们可以在不进行任何代码更改的情况下测试Converter。第一步是定义一个返回我们需要的响应的 HTTP 服务器。在下面的示例中,服务器返回的与前一节中的模拟相同:

server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
   payload := []byte(`invalid payload`)
   response.Write(payload)
}))

然后我们从测试服务器获取 URL,并将其作为配置传递给Converter,如下面的示例所示:

cfg := &testConfig{
   baseURL: server.URL,
   apiKey:  "",
}

converter := NewConverter(cfg)

现在,下面的示例显示了我们如何执行 HTTP 调用并验证响应,就像我们在模拟版本中所做的那样:

result, resultErr := converter.Exchange(ctx, basePrice, currency)

// validate response
assert.Equal(t, float64(0), result)
assert.Error(t, resultErr)

通过这种方法,我们可以实现与基于模拟的版本相同的测试场景覆盖率,但代码和复杂性要少得多。或许更重要的是,我们不会因为额外的构造函数参数而导致测试引起的损害。

不必要的注入

到目前为止,您可能会想,“有时使用 DI 并不是最佳选择,但我怎么知道呢?”为此,我想再给您提供一个自我调查。

当您不确定如何继续,或者在进行潜在的大规模重构之前,首先快速浏览一下我的 DI 调查:

  • 依赖是否是环境问题(比如日志记录)?

环境依赖是必要的,但往往会污染函数的用户体验,特别是构造函数。注入它们是合适的,但您应该更倾向于使用较不显眼的 DI 方法,比如即时注入或配置注入。

  • 在重构期间是否有测试来保护我们?

在对测试覆盖率较低的现有代码应用 DI 时,添加一些猴子补丁将是您可以进行的最小更改,因此也是风险最小的更改。一旦测试就位,它将受到保护,即使这些更改意味着删除猴子补丁。

  • 依赖的存在是否具有信息性?

依赖的存在告诉用户有关结构体的什么?如果答案不多或没有,那么依赖可以合并到任何配置注入中。同样,如果依赖在这个结构体的范围之外不存在,那么您可以使用即时注入来管理它。

  • 你将有多少个依赖的实现?

如果答案是多于一个,那么注入依赖是正确的选择。如果答案是一个,那么您需要深入一点。依赖是否会发生变化?如果它从未发生过变化,那么注入它就是一种浪费,而且很可能增加了不必要的复杂性。

  • 依赖是否在测试之外发生过变化?

如果它只在测试期间更改,那么这是一个很好的即时注入的候选项,毕竟,我们希望避免测试引起的损害。

  • 依赖是否需要在每次执行时更改?

如果答案是肯定的,那么你应该使用方法注入。在可能的情况下,尽量避免向结构体添加任何决定要使用哪个依赖的逻辑(例如switch语句)。相反,确保您要么注入依赖并使用它,要么注入一个包含决定依赖的逻辑的工厂或定位器对象。这将确保您的结构体不会受到任何与单一职责相关的问题的影响。它还有助于我们避免在添加新的依赖实现时进行大规模的手术式变更。

  • 依赖是否稳定?

稳定的依赖是已经存在的,不太可能改变(或以向后兼容的方式改变),并且不太可能被替换的东西。这方面的很好的例子是标准库和良好管理、很少更改的公共包。如果依赖是稳定的,那么为了解耦而注入它的价值就不那么大,因为代码没有改变,可以信任。

您可能希望注入一个稳定的依赖,以便测试您如何使用它,就像我们之前看到的 SQL 包和 HTTP 客户端的例子一样。然而,为了避免测试引起的损害和不必要的复杂性,我们应该要么采用即时注入,以避免污染用户体验,要么完全避免注入。

  • 这个结构体将有一个还是多个用途?

如果结构体只有一个用途,那么对于代码的灵活性和可扩展性的压力就很低。因此,我们可以更倾向于少注入,更具体地实现;至少在我们的情况发生变化之前是这样。另一方面,在许多地方使用的代码将承受更大的变化压力,并且可以说更希望具有更大的灵活性,以便在更多情况下更有用。在这些情况下,您将希望更倾向于注入,以给用户更多的灵活性。只是要小心,不要注入太多,以至于函数的用户体验变得糟糕。

对于共享代码,您还应该更加努力地将代码与尽可能多的外部(不稳定的)依赖解耦。当用户采用您的代码时,他们可能不想采用您的所有依赖项。

  • 这段代码是否包装了依赖项?

如果我们包装一个包以使其用户体验更方便,以隔离我们免受该包中的更改影响,那么注入该包是不必要的。我们编写的代码与其包装的代码紧密耦合,因此引入抽象并没有取得显著成效。

  • 应用 DI 会让代码变得更好吗?

当然,这是非常主观的,但也可能是最关键的问题。抽象是有用的,但它也增加了间接性和复杂性。

解耦很重要,但并非总是必要的。包和层之间的解耦比包内对象之间的解耦更重要。

通过经验和重复,您会发现许多这些问题会变得自然而然,因为您会在何时应用 DI 以及使用哪种方法方面形成直觉。

与此同时,以下表格可能会有所帮助:

** 方法** ** 理想用于:**
Monkey patching
  • 依赖于单例的代码

  • 当前没有测试或现有依赖注入的代码

  • 解耦包而不对依赖包做任何更改

|

构造函数注入
  • 需要的依赖

  • 必须在调用任何方法之前准备好的依赖项

  • 被对象的大多数或所有方法使用的依赖

  • 在请求之间不会改变的依赖

  • 有多个实现的依赖项

|

方法注入
  • 与函数、框架和共享库一起使用

  • 请求范围的依赖

  • 无状态对象

  • 在请求中提供上下文或数据的依赖,因此预计在调用之间会有所变化

|

配置注入
  • 替换构造函数或方法注入以改善代码的用户体验

|

JIT 注入
  • 替换本来应该注入到构造函数中的依赖项,并且只有一个生产实现。

  • 在对象和全局单例或环境依赖之间提供一层间接或抽象。特别是当我们想在测试期间替换全局单例时

  • 允许用户可选地提供依赖项

|

现成的注入
  • 减少采用构造函数注入的成本

  • 减少创建依赖项顺序的复杂性

|

总结

在本章中,我们研究了不必要或不正确地应用 DI 的影响。我们还讨论了一些情况,在这些情况下,采用 DI 并不是最佳选择。

然后,我们用列出了 10 个问题来帮助您确定 DI 是否适用于您当前的用例。

在下一章中,我们将总结我们对 DI 的研究,回顾我们在整本书中讨论过的所有内容。特别是,我们将对比我们样本服务的当前状态和原始状态。我们还将简要介绍如何使用 DI 启动新服务。

问题

  1. 你最常见到的 DI 引起的损害形式是什么?

  2. 为什么重要的是不要盲目地一直应用 DI?

  3. 采用 Google Wire 等框架是否可以消除 DI 引起的所有损害形式?

第十二章:回顾我们的进展

在我们的最后一章中,我们将回顾并比较应用依赖注入DI)后,我们的示例服务的状态和质量与我们开始时的情况。

我们将回顾我们所做的改进,以及最后一次查看我们的依赖图,并讨论我们在测试覆盖率和服务的可测试性方面的改进。

最后,我们将以简要讨论结束本章,讨论如果我们从头开始使用 DI 而不是将其应用于现有代码,我们本可以做些什么。

本章将涵盖以下主题:

  • 改进概述

  • 依赖图的回顾

  • 测试覆盖率和可测试性的回顾

  • 使用 DI 开始一个新服务

技术要求

熟悉我们服务的代码将是有益的,如第四章中介绍的ACME 注册服务简介。本章还假设您已经阅读了第五章中的使用 Monkey Patching 进行依赖注入,一直到第十章中的现成的注入,介绍了我们在这一过程中所做的各种 DI 方法和其他各种改进。

您可能还会发现阅读和运行本章的完整代码版本很有用,这些代码可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch12找到。

获取代码并配置示例服务的说明可在 README 中找到,该 README 位于github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/

您可以在github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch12/acme找到我们服务的代码,其中已经应用了本章的更改。

改进概述

呼,我们做到了。您认为我们做得如何?您认为这些改进值得努力吗?让我们看看。

为了了解我们已经走了多远,我们首先应该回顾我们的起点。

在第四章中,ACME 注册服务简介,我们有一个小型、简单、可工作的服务。它为我们的用户完成了工作,但对于我们必须维护和扩展它的人来说,它造成了许多不便。

全局单例

最大的痛点之一无疑是使用全局公共单例。乍一看,它们似乎使代码更简洁,但实际上使我们的测试工作变得更加困难。

使用init()函数创建变量意味着我们要么必须使用实时版本(即数据库上的版本),要么必须对全局变量进行 Monkey Patch,这可能导致数据竞争。

我们最初有两个公共全局变量(configlogger)和一个私有全局变量(数据库连接池)。在第五章中,使用 Monkey Patching 进行依赖注入,我们使用了 Monkey Patching 来使我们能够测试依赖于数据库连接池单例的代码。

在第十章中,现成的注入,我们终于成功移除了config全局变量,在我们在第八章中进行的更改中,首先移除了对它的大部分直接访问,通过配置进行依赖注入

通过删除直接访问并定义本地配置接口,我们能够完全将我们的模型和数据层与配置解耦。这意味着我们的代码是可移植的,如果我们将来想在另一个应用程序中使用它。

也许最重要的是,这意味着现在在这段代码上编写测试的工作要少得多,我们的测试可以独立并发地运行。没有与全局实例的链接,我们不必进行猴子补丁。没有依赖链接,我们只剩下一个更小、更专注的config接口,更容易模拟、存根和理解。

全局logger实例设法在我们的许多重构中幸存下来,但它唯一被使用的地方是在config加载代码中。因此,现在让我们将其移除。我们当前的config加载函数看起来像下面的代码所示:

// Load returns the config loaded from environment
func Load() (*Config, error) {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      err := fmt.Errorf("failed to locate file specified by %s", DefaultEnvVar)
      logging.L.Error(err.Error())
      return nil, err
   }

   cfg, err := load(filename)
   if err != nil {
      logging.L.Error("failed to load config with err %s", err)
      return nil, err
   }

   return cfg, nil
}

可以非常肯定地说,如果我们未能加载配置,我们的服务就无法工作。因此,我们可以直接将错误更改为直接写入标准错误。我们更新后的函数如下所示:

// Load returns the config loaded from environment
func Load() (*Config, error) {
   filename, found := os.LookupEnv(DefaultEnvVar)
   if !found {
      err := fmt.Errorf("failed to locate file specified by %s", DefaultEnvVar)
      fmt.Fprintf(os.Stderr, err.Error())
      return nil, err
   }

   cfg, err := load(filename)
   if err != nil {
      fmt.Fprintf(os.Stderr, "failed to load config with err %s", err)
      return nil, err
   }

   return cfg, nil
}

否则,日志记录器是通过配置注入传递的。通过使用配置注入,我们能够忘记常见的关注点(如logger),而不会影响我们构造函数的用户体验。现在我们也能够轻松编写测试来验证日志记录,而不会出现任何数据竞争问题。虽然这样的测试可能会感觉奇怪,但请考虑一下——日志是我们系统的输出,当出现问题需要调试时,我们经常会依赖于它们。

因此,可能有些情况下,确保我们按预期创建日志并继续这样做是有用的。这不是我们经常想要测试的事情,但当我们这样做时,测试本身就像下面这样简单:

func TestLogging(t *testing.T) {
   // build log recorder
   recorder := &LogRecorder{}

   // Call struct that uses a logger
   calculator := &Calculator{
      logger: recorder,
   }
   result := calculator.divide(10, 0)

   // validate expectations, including that the logger was called
   assert.Equal(t, 0, result)
   require.Equal(t, 1, len(recorder.Logs))
   assert.Equal(t, "cannot divide by 0", recorder.Logs[0])
}

type Calculator struct {
   logger Logger
}

func (c *Calculator) divide(dividend int, divisor int) int {
   if divisor == 0 {
      c.logger.Error("cannot divide by 0")
      return 0
   }

   return dividend / divisor
}

// Logger is our standard interface
type Logger interface {
   Error(message string, args ...interface{})
}

// LogRecorder implements Logger interface
type LogRecorder struct {
   Logs []string
}

func (l *LogRecorder) Error(message string, args ...interface{}) {
   // build log message
   logMessage := fmt.Sprintf(message, args...)

   // record log message
   l.Logs = append(l.Logs, logMessage)
}

最后,数据库连接池的全局实例仍然存在;然而,与ConfigLogger不同,它是私有的,因此与之相关的任何风险都有限的范围。事实上,通过使用即时JIT)DI,我们能够完全将我们的模型层测试与数据包完全解耦,而不会影响模型层包的用户体验。

与 config 包的高耦合

当我们在第四章中开始时,ACME 注册服务简介,我们根本没有使用任何接口,因此我们所有的包都彼此紧密耦合。因此,我们的包对变化的抵抗力很强;其中最突出的是config包。这是我们原来的Config结构和全局单例:

// App is the application config
var App *Config

// Config defines the JSON format for the config file
type Config struct {
   // DSN is the data source name (format: https://github.com/go-sql-driver/mysql/#dsn-data-source-name)
   DSN string

   // Address is the IP address and port to bind this rest to
   Address string

   // BasePrice is the price of registration
   BasePrice float64

   // ExchangeRateBaseURL is the server and protocol part of the 
   // URL from which to load the exchange rate
   ExchangeRateBaseURL string

   // ExchangeRateAPIKey is the API for the exchange rate API
   ExchangeRateAPIKey string
}

由于全局单例的组合、缺乏接口,以及几乎每个包都引用了这个包,我们对Config结构所做的任何更改都有可能导致一切都被破坏。同样地,如果我们决定将配置格式从平面 JSON 文件更改为更复杂的结构,我们将面临一些非常恶劣的手术。

让我们比较一下我们原来的Config结构和现在的情况:

// Config defines the JSON format for the config file
type Config struct {
   // DSN is the data source name (format: https://github.com/go-sql-driver/mysql/#dsn-data-source-name)
   DSN string

   // Address is the IP address and port to bind this rest to
   Address string

   // BasePrice is the price of registration
   BasePrice float64

   // ExchangeRateBaseURL is the server and protocol part of the 
   // URL from which to load the exchange rate
   ExchangeRateBaseURL string

   // ExchangeRateAPIKey is the API for the exchange rate API
   ExchangeRateAPIKey string

   // environmental dependencies
   logger logging.Logger
}

// Logger returns a reference to the singleton logger
func (c *Config) Logger() logging.Logger {
   if c.logger == nil {
      c.logger = &logging.LoggerStdOut{}
   }

   return c.logger
}

// RegistrationBasePrice returns the base price for registrations
func (c *Config) RegistrationBasePrice() float64 {
   return c.BasePrice
}

// DataDSN returns the DSN
func (c *Config) DataDSN() string {
   return c.DSN
}

// ExchangeBaseURL returns the Base URL from which we can load 
// exchange rates
func (c *Config) ExchangeBaseURL() string {
   return c.ExchangeRateBaseURL
}

// ExchangeAPIKey returns the DSN
func (c *Config) ExchangeAPIKey() string {
   return c.ExchangeRateAPIKey
}

// BindAddress returns the host and port this service should bind to
func (c *Config) BindAddress() string {
   return c.Address
}

可以看到,我们现在有了更多的代码。然而,额外的代码主要包括实现包的各种配置接口的getter函数。这些getter函数为我们提供了一层间接,使我们能够更改配置的加载和存储方式,而无需影响其他包。

通过在许多包中引入本地Config接口,我们能够将这些包与我们的config包解耦。虽然其他包仍然间接使用config包,但我们获得了两个好处。首先,它们可以分别发展。其次,这些包都在本地记录它们的需求,这使我们在处理包时有了更小的范围。这在测试期间特别有帮助,当我们使用模拟和存根时。

测试覆盖率和可测试性的回顾

当我们引入我们的示例服务时,我们发现了与测试相关的几个问题。其中一个问题是缺乏隔离,其中一个层的测试也间接测试了所有在它下面的层,如下面的代码所示:

func TestGetHandler_ServeHTTP(t *testing.T) {
   // ensure the test always fails by giving it a timeout
   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   defer cancel()

   // Create and start a server
   // With out current implementation, we cannot test this handler without 
   // a full server as we need the mux.
   address, err := startServer(ctx)
   require.NoError(t, err)

   // build inputs
   response, err := http.Get("http://" + address + "/person/1/")

   // validate outputs
   require.NoError(t, err)
   require.Equal(t, http.StatusOK, response.StatusCode)

   expectedPayload := []byte(`{"id":1,"name":"John","phone":"0123456780","currency":"USD","price":100}` + "\n")
   payload, _ := ioutil.ReadAll(response.Body)
   defer response.Body.Close()

   assert.Equal(t, expectedPayload, payload)
}

这是 REST 层的测试,但因为它调用实际的模型,因此也调用了实际的数据层,它实际上测试了一切。这使它成为一个合理的集成测试,因为它确保各层之间适当地协同工作。但它是一个糟糕的单元测试,因为各层没有被隔离。

我们的单元测试现在如下所示:

func TestGetHandler_ServeHTTP(t *testing.T) {
   scenarios := []struct {
      desc            string
      inRequest       func() *http.Request
      inModelMock     func() *MockGetModel
      expectedStatus  int
      expectedPayload string
   }{
      // scenarios removed
   }

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // define model layer mock
         mockGetModel := scenario.inModelMock()

         // build handler
         handler := NewGetHandler(&testConfig{}, mockGetModel)

         // perform request
         response := httptest.NewRecorder()
         handler.ServeHTTP(response, scenario.inRequest())

         // validate outputs
         require.Equal(t, scenario.expectedStatus, response.Code, scenario.desc)

         payload, _ := ioutil.ReadAll(response.Body)
         assert.Equal(t, scenario.expectedPayload, string(payload), scenario.desc)
      })
   }
}

这个测试被认为是隔离的,因为我们不是依赖于其他层,而是依赖于一个抽象——在我们的例子中,是一个名为*MockGetModel的模拟实现。让我们看一个典型的模拟实现:

type MockGetModel struct {
   mock.Mock
}

func (_m *MockGetModel) Do(ID int) (*Person, error) {
   outputs := _m.Called(ID)

   if outputs.Get(0) != nil {
      return outputs.Get(0).(*Person), outputs.Error(1)
   }

   return nil, outputs.Error(1)
}

正如你所看到的,模拟实现非常简单;绝对比这个依赖的实际实现简单。由于这种简单性,我们能够相信它的表现与我们期望的一样,因此,测试中出现的任何问题都将是由实际代码而不是模拟引起的。通过使用代码生成器(如在第三章中介绍的 Mockery,用户体验编码),这种信任可以得到进一步加强,它生成可靠和一致的代码。

模拟还使我们能够轻松测试其他场景。我们现在对以下内容进行了测试:

  • 快乐路径

  • 请求中缺少 ID

  • 请求中的无效 ID

  • 依赖(模型层或更低层)失败

  • 请求的记录不存在

在没有我们所做的更改的情况下,许多这些情况很难进行可靠的测试。

现在我们的测试与其他层隔离,测试本身的范围更小。这意味着我们需要了解的东西更少;我们只需要了解我们正在测试的层的 API 契约。

在我们的例子中,这意味着我们只需要担心 HTTP 相关的问题,比如从请求中提取数据,输出正确的状态代码和呈现响应有效负载。此外,我们正在测试的代码可能失败的方式也减少了。因此,我们得到了更少的测试设置,更短的测试和更多的场景覆盖。

与测试相关的第二个问题是工作重复。由于缺乏隔离,我们原始的测试通常有些多余。例如,Get 端点的模型层测试看起来是这样的:

func TestGetter_Do(t *testing.T) {
   // inputs
   ID := 1

   // call method
   getter := &Getter{}
   person, err := getter.Do(ID)

   // validate expectations
   require.NoError(t, err)
   assert.Equal(t, ID, person.ID)
   assert.Equal(t, "John", person.FullName)
}

这看起来表面上没问题,但当我们考虑到这个测试场景已经被我们的REST包测试覆盖时,我们实际上从这个测试中得不到任何东西。另一方面,让我们看看我们现在有的几个测试中的一个:

func TestGetter_Do_noSuchPerson(t *testing.T) {
   // inputs
   ID := 5678

   // configure the mock loader
   mockLoader := &mockMyLoader{}
   mockLoader.On("Load", mock.Anything, ID).Return(nil, data.ErrNotFound).Once()

   // call method
   getter := &Getter{
      data: mockLoader,
   }
   person, err := getter.Do(ID)

   // validate expectations
   require.Equal(t, errPersonNotFound, err)
   assert.Nil(t, person)
   assert.True(t, mockLoader.AssertExpectations(t))
}

这个测试现在是 100%可预测的,因为它不依赖于数据库的当前状态。它不测试数据库,也不测试我们如何与数据库交互,而是测试我们如何与数据加载器抽象交互。这意味着数据层的实现可以自由地发展或更改,而无需重新审视和更新测试。这个测试还验证了,如果我们从数据层收到错误,我们会如我们的 API 契约所期望的那样适当地转换这个错误。

我们仍然在两个层上进行测试,但现在,这些测试不再毫无价值,而是带来了重大的价值。

第三,我们在测试中遇到的另一个问题是测试冗长。我们所做的许多更改之一是采用表驱动测试。我们注册端点的原始服务测试看起来如下:

func TestRegisterHandler_ServeHTTP(t *testing.T) {
   // ensure the test always fails by giving it a timeout
   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   defer cancel()

   // Create and start a server
   // With out current implementation, we cannot test this handler without 
   // a full server as we need the mux.
   address, err := startServer(ctx)
   require.NoError(t, err)

   // build inputs
   validRequest := buildValidRequest()
   response, err := http.Post("http://"+address+"/person/register", "application/json", validRequest)

   // validate outputs
   require.NoError(t, err)
   require.Equal(t, http.StatusCreated, response.StatusCode)
   defer response.Body.Close()

   // call should output the location to the new person
   headerLocation := response.Header.Get("Location")
   assert.Contains(t, headerLocation, "/person/")
}

现在,考虑它在以下代码块中的样子:

func TestRegisterHandler_ServeHTTP(t *testing.T) {
   scenarios := []struct {
      desc           string
      inRequest      func() *http.Request
      inModelMock    func() *MockRegisterModel
      expectedStatus int
      expectedHeader string
   }{
      // scenarios removed
   }

   for _, s := range scenarios {
      scenario := s
      t.Run(scenario.desc, func(t *testing.T) {
         // define model layer mock
         mockRegisterModel := scenario.inModelMock()

         // build handler
         handler := NewRegisterHandler(mockRegisterModel)

         // perform request
         response := httptest.NewRecorder()
         handler.ServeHTTP(response, scenario.inRequest())

         // validate outputs
         require.Equal(t, scenario.expectedStatus, response.Code)

         // call should output the location to the new person
         resultHeader := response.Header().Get("Location")
         assert.Equal(t, scenario.expectedHeader, resultHeader)

         // validate the mock was used as we expected
         assert.True(t, mockRegisterModel.AssertExpectations(t))
      })
   }
}

我知道你在想什么,测试变得更啰嗦了,而不是更简洁。是的,这个单独的测试确实是。然而,在原始测试中,如果我们要测试另一种情况,第一步将是复制并粘贴几乎整个测试,留下大约 10 行重复的代码和只有几行是该测试场景独有的。

使用我们的表驱动测试风格,我们有八行共享代码,每个场景都会执行,并且清晰可见。每个场景都被整洁地指定为切片中的一个对象,如下所示:

{
   desc: "Happy Path",
   inRequest: func() *http.Request {
      validRequest := buildValidRegisterRequest()
      request, err := http.NewRequest("POST", "/person/register", validRequest)
      require.NoError(t, err)

      return request
   },
   inModelMock: func() *MockRegisterModel {
      // valid downstream configuration
      resultID := 1234
      var resultErr error

      mockRegisterModel := &MockRegisterModel{}
      mockRegisterModel.On("Do", mock.Anything, mock.Anything).Return(resultID, resultErr).Once()

      return mockRegisterModel
   },
   expectedStatus: http.StatusCreated,
   expectedHeader: "/person/1234/",
},

我们只需向切片添加另一个项目,就可以添加另一个场景。这既非常简单,又相当整洁。

最后,如果我们需要对测试进行更改,也许是因为 API 合同发生了变化,现在我们只需要修复一个测试,而不是很多个。

我们遇到的第四个问题是依赖于我们的上游服务。这是我非常讨厌的事情之一。测试应该是可靠和可预测的,测试失败应该是存在问题需要修复的绝对指标。当测试依赖于第三方和互联网连接时,任何事情都可能出错,测试可能因任何原因而失败。幸运的是,在我们在第八章中的更改之后,除了外部边界测试,我们的所有测试现在都依赖于上游服务的抽象和模拟实现。我们的测试不仅可靠,而且现在可以轻松地测试我们的错误处理条件,类似于我们之前讨论的方式。

在以下测试中,我们已经删除并模拟了对converter包的调用,以测试当我们无法加载货币转换时我们的注册会发生什么:

func TestRegisterer_Do_exchangeError(t *testing.T) {
   // configure the mocks
   mockSaver := &mockMySaver{}
   mockExchanger := &MockExchanger{}
   mockExchanger.
      On("Exchange", mock.Anything, mock.Anything, mock.Anything).
      Return(0.0, errors.New("failed to load conversion")).
      Once()

   // define context and therefore test timeout
   ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
   defer cancel()

   // inputs
   in := &Person{
      FullName: "Chang",
      Phone:    "11122233355",
      Currency: "CNY",
   }

   // call method
   registerer := &Registerer{
      cfg:       &testConfig{},
      exchanger: mockExchanger,
      data:      mockSaver,
   }
   ID, err := registerer.Do(ctx, in)

   // validate expectations
   require.Error(t, err)
   assert.Equal(t, 0, ID)
   assert.True(t, mockSaver.AssertExpectations(t))
   assert.True(t, mockExchanger.AssertExpectations(t))
}

您可能还记得我们的 exchange 包中仍然有测试。事实上,我们有两种类型。我们有内部边界测试,它们调用我们创建的一个虚假 HTTP 服务器。这些测试确保当服务器给出特定响应时,我们的代码会如我们所期望的那样做出反应,如下面的代码片段所示:

func TestInternalBoundaryTest(t *testing.T) {
   // start our test server
   server := httptest.NewServer(&happyExchangeRateService{})
   defer server.Close()

   // define the config
   cfg := &testConfig{
      baseURL: server.URL,
      apiKey:  "",
   }

   // create a converter to test
   converter := NewConverter(cfg)
   resultRate, resultErr := converter.Exchange(context.Background(), 100.00, "AUD")

   // validate the result
   assert.Equal(t, 158.79, resultRate)
   assert.NoError(t, resultErr)
}

type happyExchangeRateService struct{}

// ServeHTTP implements http.Handler
func (*happyExchangeRateService) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   payload := []byte(`
{
  "success":true,
  "timestamp":1535250248,
  "base":"EUR",
  "date":"2018-08-26",
  "rates": {
   "AUD":1.587884
  }
}
`)
   response.Write(payload)
}

但我们还有外部边界测试,它们仍然调用上游服务。这些测试帮助我们验证上游服务是否按照我们的需求执行,与我们的代码协同工作。但是,为了确保我们的测试是可预测的,我们不经常运行外部测试。我们通过向该文件添加构建标签来实现这一点,从而可以轻松地决定何时包括这些测试。通常情况下,我只会在出现问题时运行这些测试,或者为了设置构建流水线中仅运行这些测试的特殊步骤。然后,我们可以在这些测试期间的任何失败后决定如何继续。

测试覆盖率

说到原始数字,当我们开始时,我们服务的测试覆盖率如下:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  52.94 |    238 |   0.00 |      3 | acme/                             |
|  73.33 |     15 |  73.33 |     15 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  63.33 |     60 |  63.33 |     60 | acme/internal/modules/data/       |
|   0.00 |     38 |   0.00 |     38 | acme/internal/modules/exchange/   |
|  50.00 |      6 |  50.00 |      6 | acme/internal/modules/get/        |
|  25.00 |     12 |  25.00 |     12 | acme/internal/modules/list/       |
|  64.29 |     28 |  64.29 |     28 | acme/internal/modules/register/   |
|  73.61 |     72 |  73.61 |     72 | acme/internal/rest/               |
-------------------------------------------------------------------------

如您所见,测试覆盖率有些低。由于编写测试的难度以及我们无法模拟或存根我们的依赖关系,这并不奇怪。

在我们的更改之后,我们的测试覆盖率正在提高:

-------------------------------------------------------------------------
|      Branch     |       Dir       |                                   |
|   Cov% |  Stmts |   Cov% |  Stmts | Package                           |
-------------------------------------------------------------------------
|  63.11 |    309 |  30.00 |     20 | acme/                             |
|  28.57 |     28 |  28.57 |     28 | acme/internal/config/             |
|   0.00 |      4 |   0.00 |      4 | acme/internal/logging/            |
|  74.65 |     71 |  74.65 |     71 | acme/internal/modules/data/       |
|  61.70 |     47 |  61.70 |     47 | acme/internal/modules/exchange/   |
|  81.82 |     11 |  81.82 |     11 | acme/internal/modules/get/        |
|  38.10 |     21 |  38.10 |     21 | acme/internal/modules/list/       |
|  75.76 |     33 |  75.76 |     33 | acme/internal/modules/register/   |
|  77.03 |     74 |  77.03 |     74 | acme/internal/rest/               |
-------------------------------------------------------------------------

虽然我们对服务进行的大部分更改使得测试变得更容易,但我们并没有花太多时间添加额外的测试。我们所取得的改进主要来自增加了场景覆盖,主要涉及能够测试非正常路径代码。

如果我们想要提高测试覆盖率,找出需要更多测试的最简单方法是使用标准的 go 工具来计算覆盖率并将其显示为 HTML。为此,我们在终端中运行以下命令:

# Change directory to the code for this chapter
$ cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/

# Set the config location
$ export ACME_CONFIG=cd $GOPATH/src/github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/config.json

# Calculate coverage
$ go test ./acme/ -coverprofile=coverage.out

# Render as HTML
$ go tool cover -html=coverage.out

运行这些命令后,覆盖率将在您的默认浏览器中打开。为了找到潜在的改进位置,我们会浏览文件,寻找红色代码块。红色高亮的代码表示在测试期间未执行的行。

删除所有未经测试的代码并不现实,特别是因为有些错误几乎不可能触发——关键是审查代码,决定是否应该对其进行测试。

考虑以下示例(未覆盖的行用粗体标出)——我们现在将更详细地检查它:

// load rate from the external API
func (c *Converter) loadRateFromServer(ctx context.Context, currency string) (*http.Response, error) {
   // build the request
   url := fmt.Sprintf(urlFormat,
      c.cfg.ExchangeBaseURL(),
      c.cfg.ExchangeAPIKey(),
      currency)

   // perform request
   req, err := http.NewRequest("GET", url, nil)
   if err != nil {
      c.logger().Warn("[exchange] failed to create request. err: %s", err) return nil, err
   }

   // set latency budget for the upstream call
   subCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
   defer cancel()

   // replace the default context with our custom one
   req = req.WithContext(subCtx)

   // perform the HTTP request
   response, err := http.DefaultClient.Do(req)
   if err != nil {
      c.logger().Warn("[exchange] failed to load. err: %s", err)
 return nil, err
   }

   if response.StatusCode != http.StatusOK {
      err = fmt.Errorf("request failed with code %d", response.StatusCode)
 c.logger().Warn("[exchange] %s", err)
 return nil, err
   }

   return response, nil
}

首先,让我们谈谈这些行:

if response.StatusCode != http.StatusOK {
   err = fmt.Errorf("request failed with code %d", response.StatusCode)
   c.logger().Warn("[exchange] %s", err)
   return nil, err
}

这些行处理了上游服务未能返回 HTTP 200(OK)的情况。考虑到互联网和 HTTP 服务的性质,这种情况很有可能发生。因此,我们应该构建一个测试来确保我们的代码处理了这种情况。

现在,看一下这些行:

req, err := http.NewRequest("GET", url, nil)
if err != nil {
   c.logger().Warn("[exchange] failed to create request. err: %s", err)
   return nil, err
}

你知道http.NewRequest()如何失败吗?在标准库中查找后,似乎它会在我们指定有效的 HTTP 方法或 URL 无法解析时失败。这些都是程序员的错误,而且我们不太可能犯这些错误。即使我们犯了,结果也是显而易见的,并且会被现有的测试捕捉到。

此外,为这些情况添加测试将会很困难,并且几乎肯定会对我们的代码的整洁度产生不利影响。

最后,到目前为止,我们的测试缺乏端到端测试。在第十章 现成的注入 结束时,我们添加了少量端到端测试。最初,我们使用这些测试来验证 Google Wire 的表现是否符合我们的预期。从长远来看,它们将用于保护我们的 API 免受意外的回归。对我们服务的公共 API 进行更改,无论是 URL、输入还是输出负载,都很有可能导致我们用户的代码出现问题。有时更改是必要的,在这种情况下,这些测试也将提醒我们需要采取其他措施,比如通知我们的用户或对 API 进行版本控制。

消除对上游服务的依赖

在第六章 构造函数注入的依赖注入 中,我们使用构造函数注入来将我们的模型层与exchange包解耦。你可能还记得exchange包是对我们上游货币转换服务的一个薄抽象。这不仅确保我们的模型层测试不再需要上游服务正常工作才能通过,而且还使我们能够确保我们已充分处理了服务失败的情况。

在第八章 配置的依赖注入 中,我们添加了边界测试,进一步减少了对上游服务的依赖,使我们能够独立测试exchange包,而不依赖上游服务。在我们的频繁运行的单元测试中移除了对上游服务的所有依赖之后,我们添加了一个外部边界来测试外部服务。然而,我们用一个构建标签来保护这个测试,使我们能够有选择地偶尔运行它,从而保护我们免受互联网和上游服务的问题。

提前停止和延迟预算

在第七章 方法注入的依赖注入 中,我们使用方法注入引入了context包和请求范围的依赖。通过将context用作请求范围的依赖,我们随后能够实现延迟预算和提前停止。有了这些,我们能够在异常系统行为期间减少资源使用。例如,如果检索数据(从上游货币转换服务或数据库)花费的时间太长,以至于客户端不再等待响应,我们可以取消请求并停止任何进一步的处理。

简化依赖创建

当我们在第四章 ACME 注册服务简介 中开始时,我们的main()函数看起来相当简单,如下面的代码所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server := rest.New(config.App.Address)
   server.Listen(ctx.Done())
}

在我们的代码中应用了几种 DI 方法之后,到了第九章 即时依赖注入,我们的main()函数变成了以下形式:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // build the exchanger
   exchanger := exchange.NewConverter(config.App)

   // build model layer
   getModel := get.NewGetter(config.App)
   listModel := list.NewLister(config.App)
   registerModel := register.NewRegisterer(config.App, exchanger)

   // start REST server
   server := rest.New(config.App, getModel, listModel, registerModel)
   server.Listen(ctx.Done())
}

如你所见,它变得更长、更复杂了。这是关于 DI 的一个常见抱怨。因此,在第十章中,现成的注入,我们通过让 Wire 为我们完成来减少这种成本。这使我们回到了一个简洁的main()函数,如下所示:

func main() {
   // bind stop channel to context
   ctx := context.Background()

   // start REST server
   server, err := initializeServer()
   if err != nil {
      os.Exit(-1)
   }

   server.Listen(ctx.Done())
}

同样,在第九章中,即时依赖注入,我们意识到数据层只会有一个活动实现,而我们唯一需要注入不同内容的时间是在测试期间。因此,我们决定不将数据层作为构造函数参数,而是使用即时注入,如下面的代码所示:

// Getter will attempt to load a person.
type Getter struct {
   cfg  Config
   data myLoader
}

// Do will perform the get
func (g *Getter) Do(ID int) (*data.Person, error) {
   // load person from the data layer
   person, err := g.getLoader().Load(context.TODO(), ID)
   if err != nil {
      if err == data.ErrNotFound {
         return nil, errPersonNotFound
      }
      return nil, err
   }

   return person, err
}

// Use JIT DI to lessen the constructor parameters
func (g *Getter) getLoader() myLoader {
   if g.data == nil {
      g.data = data.NewDAO(g.cfg)
   }

   return g.data
}

正如所见,这为我们提供了简化的本地依赖创建,而不会减少我们构造函数的用户体验,也不会在测试期间丢失我们模拟数据层的能力。

耦合和可扩展性

在所有的变化之后,也许我们最重要的胜利是解耦我们的包。在可能的情况下,我们的包只定义并依赖于本地接口。由于这个,我们的单元测试完全与其他包隔离,并验证我们对依赖关系的使用——包之间的契约——而不依赖于它们。这意味着在处理我们的包时需要的知识范围是最小的。

或许更重要的是,我们可能想要进行的任何更改或扩展都可能只限于一个或少数几个包。例如,如果我们想在上游货币转换服务前添加一个缓存,所有的更改都将只在exchange包中进行。同样,如果我们想在另一个服务中重用这个包,我们可以复制或提取它并在不进行任何更改的情况下使用它。

依赖图的审查

在整本书中,我们一直将依赖图作为发现潜在问题的一种方式。这是我们开始时的样子:

对于只有三个端点的小服务来说,它有点复杂。从这个图表中,我们还注意到有很多箭头指向dataconfiglogging包。

在假设更多箭头进入或离开一个包意味着更多的风险、复杂性和耦合的前提下,我们开始尝试减少这些关系。

最大的影响是我们采用了配置注入,其中包括本地config接口的定义(如前一节所讨论的)。这移除了所有进入 config 包的箭头,除了来自main()的箭头,这个我们无法移除。

此外,在我们进行配置注入工作期间,我们还移除了对全局日志实例的所有引用,并改为注入日志记录器。然而,这并没有改变图表。这是因为我们决定重用该包中定义的Logger接口。

我们本可以在每个包内定义一个此接口的副本并移除这种耦合,但我们决定不这样做,因为日志记录器的定义可能不会改变。在图中移除箭头之外,复制接口到每个地方只会增加代码而没有任何好处。

在所有重构和解耦工作之后,我们的依赖图看起来像下面的图表:

这样做更好了,但遗憾的是,仍然相当混乱。为了解决这个问题以及我们之前提到的关于日志接口的问题,我还有一个技巧要向你展示。

到目前为止,我们一直使用以下命令生成图表:

$ BASE_PKG=github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/acme
godepgraph -s -o $BASE_PKG $BASE_PKG | dot -Tpng -o depgraph.png

我们可以通过使用 Godepgraph 的排除功能来从图表中移除logging包,将命令改为以下形式:

$ BASE_PKG=github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch12/acme
godepgraph -s -o $BASE_PKG -p $BASE_PKG/internal/logging $BASE_PKG | dot -Tpng -o depgraph.png

最终,这给我们带来了我们一直追求的清晰的金字塔形图表:

你可能想知道我们是否可以通过移除RESTmodel包之间的链接(getlistregister)来进一步扁平化图形。

我们目前正在将模型代码注入到REST包中;然而,两者之间仅剩下的链接是model包的输出格式。现在让我们来看看这个。

我们的列表模型 API 看起来是这样的:

// Lister will attempt to load all people in the database.
// It can return an error caused by the data layer
type Lister struct {
   cfg  Config
   data myLoader
}

// Exchange will load the people from the data layer
func (l *Lister) Do() ([]*data.Person, error) {
   // code removed
}

我们返回的是*data.Person类型的切片,这迫使我们在REST包中定义本地接口如下:

type ListModel interface {
   Do() ([]*data.Person, error)
}

鉴于data.Person是一个数据传输对象DTO),我倾向于务实地保留它。当然,我们可以移除它。要这样做,我们需要改变我们的ListModel定义,以期望一个interface{}切片,然后定义一个接口,我们可以将我们的*data.Person转换成它。

这有两个主要问题。首先,这需要做很多额外的工作,只是为了从依赖图中删除一行,但会使代码变得更混乱。其次,我们实际上是绕过了类型系统,创建了一种让我们的代码在运行时失败的方式,如果我们的模型层的返回类型与REST包的期望不同。

使用 DI 开始一个新的服务

在本书中,我们已经将 DI 应用到了现有的服务中。虽然这是我们最常见的情况,但有时我们会有幸从头开始启动一个新项目。

那么,我们能做些什么不同的吗?

用户体验

我们应该做的第一件事是停下来思考我们要解决的问题。回到 UX 发现调查(第三章,为用户体验编码)。问自己以下问题:

  • 我们的用户是谁?

  • 我们的用户想要实现什么?

  • 我们的用户能做什么?

  • 我们的用户期望如何使用我们即将创建的系统?

想象一下,如果你要开始 ACME 注册服务,你会如何回答这些问题?

答案可能是以下内容:

  • 我们的用户是谁?—这项服务的用户将是负责注册前端的移动应用程序和 Web 开发人员。

  • 我们的用户想要实现什么?—他们希望能够创建、查看和管理注册。

  • 我们的用户能做什么?—他们熟悉调用基于 HTTP 的 REST 服务。他们熟悉传递和消费 JSON 编码的数据。

  • 我们的用户期望如何使用我们即将创建的系统?—鉴于他们对 JSON 和 REST 的熟悉程度,他们希望通过 HTTP 请求来完成所有操作。第一组最明显的用户已经处理完毕,我们可以转向第二重要的用户群:开发团队。

  • 我们代码的用户是谁?—我和开发团队的其他成员。

  • 我们的用户想要实现什么?—我们想要构建一个快速、可靠的系统,易于管理和扩展。

  • 我们的用户能做什么?—我们也熟悉 HTTP、REST 和 JSON。我们也熟悉 MySQL 和 Go。我们也熟悉 DI 的许多形式。

  • 我们的用户期望如何使用我们即将创建的代码?—我们希望使用 DI 来确保我们的代码松耦合,易于测试和维护。

通过考虑我们的用户,你可以看到我们已经开始概述我们的服务。我们已经确定了从用户和开发者对 HTTP、JSON 和 REST 的熟悉程度来看,这是通信的最佳选择。鉴于开发人员对 Go 和 MySQL 的熟悉程度,这些将是关于实现技术的最佳选择。

代码结构

通过了解我们的用户提供的框架,我们已经准备好考虑实现和代码结构。

假设我们正在开发一个独立的服务,我们将需要一个main()函数。之后,我总是在main()下直接添加一个internal文件夹。这样可以在此服务的代码和同一存储库中的任何其他代码之间建立清晰的边界。

当您发布一个供他人使用的包或 SDK 时,这是一种简单的方法,可以确保您的内部实现包不会泄漏到公共 API 中。如果您的团队使用单一存储库或一个存储库中有多个服务,那么这是一种确保您不会与其他团队发生包名称冲突的好方法。

我们原始服务中的层相对正常,因此可以在此处重用它们。这些层如下图所示:

使用这组特定层的主要优势是,每个层代表处理请求时所需的不同方面。REST层仅处理与 HTTP 相关的问题;具体来说,从请求中提取数据和呈现响应。业务逻辑层是业务逻辑所在的地方。它还倾向于包含与调用外部服务和数据层相关的协调逻辑。外部服务和数据将处理与外部服务和系统(如数据库)的交互。

正如您所看到的,每个层都有完全独立的责任和视角。任何系统级的更改,例如更改数据库或从 JSON 更改为其他格式,都可以完全在一个层中处理,并且不应该对其他层造成任何更改。层之间的依赖关系将被定义为接口,这就是我们将利用的不仅是 DI,还有使用模拟和存根进行测试。

随着服务的增长,我们的层可能会由许多小包组成,而不是每个层一个大包。这些小包将导出它们自己的公共 API,以便该层中的其他包可以使用它们。然而,这会破坏层的封装。让我们看一个例子。

假设我们的数据库存在性能问题,想要添加缓存以减少对其的调用次数。代码可能看起来像下面所示:

// DAO is a data access object that provides an abstraction over our 
// database interactions.
type DAO struct {
   cfg Config

   db    *sql.DB
   cache *cache.Cache
}

// Load will attempt to load and return a person.
// It will return ErrNotFound when the requested person does not exist.
// Any other errors returned are caused by the underlying database or 
// our connection to it.
func (d *DAO) Load(ctx context.Context, ID int) (*Person, error) {
   // load from cache
   out := d.loadFromCache(ID)
   if out != nil {
      return out, nil
   }

   // load from database
   row := d.db.QueryRowContext(ctx, sqlLoadByID, ID)

   // retrieve columns and populate the person object
   out, err := populatePerson(row.Scan)
   if err != nil {
      if err == sql.ErrNoRows {
         d.cfg.Logger().Warn("failed to load requested person '%d'. err: %s", ID, err)
         return nil, ErrNotFound
      }

      d.cfg.Logger().Error("failed to convert query result. err: %s", err)
      return nil, err
   }

   // save person into the cache
   d.saveToCache(ID, out)

   return out, nil
}

然而,业务逻辑层无需知道此缓存的存在。我们可以通过在data文件夹下添加另一个internal文件夹来确保数据层的封装不会泄漏cache包。

这种改变可能看起来是不必要的,对于小项目来说,这是一个很好的论点。但随着项目的增长,添加额外的internal文件夹的成本很小,将会得到回报,并确保我们的封装永远不会泄漏。

横切关注点

我们已经看到处理横切关注点(如日志和配置)有许多不同的方法。建议提前决定一种策略,并让团队对此达成一致意见。猴子补丁,构造函数注入,配置注入和 JIT 注入都是传递或访问配置和日志单例的可能方式。选择完全取决于您和您的偏好。

从外部到内部的设计

从项目开始应用 DI 的一个很大的好处是,它使我们能够推迟决策,直到我们更好地了解情况。

例如,在决定实现 HTTP REST 服务后,我们可以继续设计我们的端点。在设计我们的 Get 端点时,我们可以这样描述:

Get 端点以 JSON 格式返回一个人的对象,形式为{"id":1,"name":"John","phone":"0123456789","currency":"USD","price":100}

您可能会注意到,这只描述了用户的需求,并没有指定数据来自何处。然后我们可以实际编写我们的端点来实现这个确切的目标。它甚至可能看起来很像第十章中的现成注入

type GetHandler struct {
   getter GetModel
}

// ServeHTTP implements http.Handler
func (h *GetHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
   // extract person id from request
   id, err := h.extractID(request)
   if err != nil {
      // output error
      response.WriteHeader(http.StatusBadRequest)
      return
   }

   // attempt get
   person, err := h.getter.Do(id)
   if err != nil {
      // not need to log here as we can expect other layers to do so
      response.WriteHeader(http.StatusNotFound)
      return
   }

   // happy path
   err = h.writeJSON(response, person)
   if err != nil {
      // this error should not happen but if it does there is nothing we
      // can do to recover
      response.WriteHeader(http.StatusInternalServerError)
   }
}

由于GetModel是一个本地定义的抽象,它也没有描述数据存储在哪里或如何存储。

同样的过程也可以应用到我们在业务逻辑层中对GetModel的实现。它不需要知道它是如何被调用的或数据存储在哪里,它只需要知道它需要协调这个过程,并将来自数据层的任何响应转换为 REST 层期望的格式。

在每个步骤中,问题的范围都很小。与下层的交互取决于抽象,每个层的实现都很简单。

当一个函数的所有层都实现后,我们可以使用 DI 将它们全部连接起来。

总结

在本章中,我们审查了应用 DI 后我们样本服务的状态和质量,并将其与原始状态进行了对比,从而提醒自己我们为什么做出了这些改变,以及我们从中获得了什么。

我们最后再次查看了我们的依赖图,以直观地了解我们成功地将包解耦的程度。

我们还看到了在进行改变后,我们的样本服务在测试时更容易,而且我们的测试更加专注。

在本章末尾,我们还讨论了如何开始一个新的服务,以及 DI 如何在这方面提供帮助。

通过这样,我们完成了对 Go 语言 DI 的审查。感谢您抽出时间阅读本书——我希望您觉得它既实用又有用。

愉快的编码!

问题

  1. 我们的样本服务中最重要的改进是什么?

  2. 在我们的依赖图中,为什么数据包不在main下面?

  3. 如果您要启动一个新的服务,您会做些什么不同?

第十三章:评估

许多章节末尾的问题都是故意引发思考的,就像编程中的许多事情一样,答案往往取决于程序员的情况或世界观。

因此,接下来的答案可能与你的不同,这没关系。这是我的答案,不一定是你的“正确”答案。

第一章,永远不要停止追求更好

  1. 什么是依赖注入?

在本章中,我将依赖注入定义为以这样的方式编码,即我们依赖的资源(即函数或结构)是抽象的。

我们接着说,因为这些依赖是抽象的,对它们的更改不需要对我们的代码进行更改。这个花哨的词是解耦。

对我来说,解耦实际上是这里的基本属性和目标。当对象解耦时,它们就更容易处理。更容易扩展、重构、重用和测试。虽然这些都非常重要,但我也试图保持务实。最终,如果软件没有解耦并且不使用依赖注入,它仍然会正常工作。但随着时间的推移,它将变得越来越难处理和扩展。

  1. 依赖注入的四个突出优势是什么?
  • 依赖注入通过以抽象或通用的方式表达依赖关系,减少了在处理代码时所需的知识。对我来说,这是关于速度。当我进入一段代码,特别是在一个大项目中,当其依赖关系是抽象的时,更容易理解特定部分(如结构)在做什么。通常,这是因为关系被很好地描述,交互干净(换句话说,没有对象嫉妒)。

  • 依赖注入使我们能够在与依赖项隔离的情况下测试我们的代码。与第一点类似,当依赖关系是抽象的且交互干净时,通过操纵其与依赖项的交互来测试当前代码片段是容易理解的,因此更快。

  • 依赖注入使我们能够快速而可靠地测试那些否则难以或不可能的情况。我知道,我非常注重测试。我并不是一个狂热者;这纯粹是自我保护和我对专业精神的理解。当我为别人编写代码时,我希望它尽可能地好(在资源限制内)。此外,我希望它继续按照我打算的方式工作。测试帮助我在构建过程中和将来澄清和记录我的意图。

  • 依赖注入减少了扩展或更改的影响。当一个方法签名发生变化时,它的用法也会发生变化。当我们依赖于我们自己的代码(如本地接口)时,我们至少可以选择如何应对变化。我们可以切换到其他依赖项;我们可以在中间添加一个适配器。无论我们如何处理,当我们的代码和测试依赖于未更改的部分时,我们可以确信任何出现的问题都在更改的部分或其提供的功能中。

  1. 它解决了什么样的问题?

这个答案本质上就是关于“代码异味”的整个部分,其中包括代码膨胀、难以改变、浪费的努力和紧密耦合。

  1. 为什么怀疑是重要的?

在我们的行业中,解决问题的方法几乎总是不止一种。同样,几乎总有很多人向你推销灵丹妙药来解决你所有的问题。就我个人而言,当被问及一个解决方案是否有效时,我的答案通常是这取决于。这可能会激怒那些寻求简单答案却收到一大堆问题的人,但实际上很少有确定的答案。事实上,这可能是让我不断回头的原因。总有新的东西要学习,新的想法要尝试,旧的概念要重新发现。因此,我恳求你,始终倾听,始终质疑,不要害怕尝试和失败。

5. 对你来说,惯用的 Go意味着什么?

这绝对没有正确的答案。请不要让任何人告诉你相反。如果你在团队中保持一致,那就足够了。如果你不喜欢这种风格,提出并辩论一个更好的风格。虽然很多人都抗拒改变,但更少的人反对更好的代码。

第二章,Go 的 SOLID 设计原则

1. 单一责任原则如何改进 Go 代码?

通过应用单一责任原则,我们的代码的复杂性得到了减少,因为它将代码分解为更小、更简洁的部分。

通过更小、更简洁的部分,我们增加了相同代码的潜在可用性。这些更小的部分更容易组合成更大的系统,因为它们的要求更轻,性质更通用。

单一责任原则还使得编写和维护测试变得更简单,因为当一段代码只有一个目的时,测试所需的范围(因此复杂性)就会大大减少。

2. 开闭原则如何改进 Go 代码?

开闭原则有助于通过鼓励我们不改变现有的代码,特别是公开的 API,来减少添加和扩展的风险。

开闭原则还有助于减少添加或删除功能所需的更改数量。当摆脱某些代码模式(如 switch 语句)时,这一点尤为突出。switch 语句很棒,但它们往往存在于多个地方,当添加新功能时很容易忽略其中一个实例。

此外,一旦出现问题,由于问题要么在新添加的代码中,要么在其与使用之间的交互中,因此更容易找到。

3. Liskov 替换原则如何改进 Go 代码?

通过遵循 Liskov 替换原则,我们的代码无论注入了什么样的依赖,都能保持一致的表现。另一方面,违反 Liskov 替换原则会导致违反开闭原则。这些违规行为会导致我们的代码对实现有过多的了解,从而破坏了注入依赖的抽象性。

在实现接口时,我们可以利用 Liskov 替换原则对一致行为的关注作为一种检测与不正确抽象相关的代码异味的方法。

4. 接口隔离原则如何改进 Go 代码?

接口隔离原则要求我们定义薄接口和明确的输入。这些特性使我们能够将我们的代码与实现我们的依赖的实现解耦。

所有这些都导致了简洁、易于理解和方便使用的依赖定义,特别是在测试过程中使用模拟和存根时。

5. 依赖反转原则如何改进 Go 代码?

依赖反转原则迫使我们关注抽象的所有权,并将其焦点从使用转移到需要

它还进一步将我们的依赖定义与其实现解耦。与接口隔离原则一样,结果是代码更加简单和独立,特别是与其用户分离。

第三章,用户体验编码

1. 代码的可用性为什么重要?

良好的用户体验并不像糟糕的用户体验那么明显。这是因为当用户体验良好时,它只是有效

通常,代码越复杂、难以理解、或者不寻常,就越难以理解。代码越难以跟踪,就越难以维护或扩展,出错的可能性就越大。

2. 谁最能从具有良好用户体验的代码中受益?

作为程序员,我们既是代码的创造者,也是最大的用户;因此,最受益的是我们的同事和我们自己。

3. 如何构建良好的用户体验?

最好的用户体验是直观和自然的。因此,关键是要尽量像你的用户一样思考。你写的代码可能对你来说是有意义的,希望对你来说也是自然的,但是你能说对你的团队其他成员也是这样吗?

在本章中,我们定义了一些需要牢记的方面:

  • 简单开始,只有在必要时才变得复杂。

  • 应用足够的抽象。

  • 遵循行业、团队和语言的惯例。

  • 只导出必要的内容。

  • 积极应用单一职责原则。

我们还介绍了UX 发现调查,作为深入了解你的用户的一种方式。调查包括四个问题:

  • 谁是用户?

  • 你的用户有什么能力?

  • 用户为什么想要使用你的代码?

  • 你的用户期望如何使用它?

4. 单元测试能为你做什么?

总之,很多事情。这因人而异。主要是,我使用测试来给我信心,要么快速前进,要么承担重任,这取决于需要什么。

我还发现测试在记录作者的意图方面做得很好,而且不太可能像注释那样过时。

5. 你应该考虑哪种测试场景?

你总是要考虑至少三种场景:

  • 快乐的路径:你的函数是否做你期望它做的事情?

  • 输入错误:在使用中可预测的错误(特别是输入)

  • 依赖问题:当依赖关系失败时,你的代码是否能正常运行?

6. 表驱动测试(TDTs)如何帮助?

TDT 对减少由同一函数的多个测试场景引起的重复很有帮助。

它们通常比复制/粘贴大量测试更有效。

7. 测试如何损害你的软件设计?

这可能有很多种方式,有些是相当主观/个人的;但在本章中,我们概述了一些常见的原因:

  • 只有因为测试而存在的参数、配置选项或输出

  • 由测试引起或导致的参数泄漏抽象

  • 在生产代码中发布模拟

  • 过度的测试覆盖

第四章,ACME 注册服务简介

1. 对我们的服务定义的目标中,哪个对你个人来说最重要?

这是主观的,因此没有正确答案。就我个人而言,可能是可读性或可测试性。如果代码容易阅读,那么我可以更容易地理解它,可能也能记住更多。另一方面,如果它更容易测试,那么我可以利用这一点来编写更多的测试。有了更多的测试,我就不必记住那么多,可以让测试确保一切都按照我需要的方式执行。

2. 概述的问题中哪个似乎最紧急或最重要?

这也是主观的。你可能会感到惊讶,但我会说测试中缺乏隔离性。随着测试的进行,每个测试都有点类似于端到端测试。这意味着测试设置是冗长的,当出现问题时,找出问题所在将是耗时的。

第五章,使用 Monkey Patching 进行依赖注入

1. Monkey Patching 是如何工作的?

在其最基本的层面上,Go 中的 Monkey Patching 涉及在运行时交换一个变量为另一个变量。这个变量可以是依赖的实例(以结构体的形式)或者是一个包装对依赖的访问的函数。

在更高的层面上,猴子补丁是关于替换或拦截对依赖的访问,以将其替换为另一个实现,通常是存根或模拟,以使测试更简单。

2. 猴子补丁的理想用例是什么?

猴子补丁可以在各种情况下使用,但最显著的情况包括以下情况:

  • 使用依赖于单例的代码

  • 对于当前没有测试、没有依赖注入的代码,并且希望以最少的更改添加测试的情况

  • 在不更改依赖包的情况下解耦两个包

3. 如何使用猴子补丁来解耦两个包而不更改依赖包?

我们可以引入一个调用依赖包的函数类型的变量。然后,我们可以猴子补丁我们的本地变量,而不必更改依赖。在本章中,我们看到这对于与我们无法更改的代码(如标准库)解耦特别有用。

第六章,构造函数注入的依赖注入

1. 我们用来采用构造函数注入的步骤是什么?

  1. 我们确定了我们想要提取并最终注入的依赖关系。

  2. 我们删除了该依赖的创建并将其提升为成员变量。

  3. 然后,我们将依赖的抽象定义为本地接口,并将成员变量更改为使用该接口而不是真实的依赖。

  4. 然后,我们添加了一个构造函数,其中包含依赖的抽象作为参数,以便我们可以确保依赖始终可用。

2. 什么是守卫条款,何时使用它?

我们将守卫条款定义为一段代码,确保提供了依赖(换句话说,不是 nil)。在某些情况下,我们在构造函数中使用它们,以便我们可以百分之百确定依赖已提供。

3. 构造函数注入如何影响依赖的生命周期?

当依赖通过构造函数传入时,我们可以确保它们始终可用于其他方法。因此,与使用依赖相关的 nil 指针崩溃没有风险。

此外,我们不需要在方法中添加守卫条款或其他健全性检查,因为任何此类验证只需要存在于构造函数中。

4. 构造函数注入的理想用例是什么?

构造函数注入对许多情况都很有用,包括以下情况:

  • 所需的依赖

  • 被对象的大多数或所有方法使用的依赖

  • 当一个依赖有多个实现时

  • 依赖在请求之间不会改变的情况

第七章,方法注入的依赖注入

1. 方法注入的理想用例是什么?

方法注入非常适用于以下情况:

  • 函数、框架和共享库

  • 请求作用域依赖,比如上下文或用户凭据

  • 无状态对象

  • 提供请求中的上下文或数据的依赖,因此预计在调用之间会有所变化。

2. 为什么重要的是不保存使用方法注入注入的依赖?

因为依赖是函数或方法的参数,每次调用都会提供一个新的依赖。虽然在调用其他内部方法之前保存依赖可能比将参数传递为依赖更直接,但这样的做法会导致多个并发使用之间的数据竞争。

3. 如果我们过度使用方法注入会发生什么?

这个问题有点主观,取决于你对测试引起的损害和代码 UX 的看法。就我个人而言,我非常关心 UX。因此,通过减少参数使函数更易于使用始终在我脑海中(除了构造函数)。

从测试的角度来看,有一定形式的依赖注入要比没有更灵活。要务实;你会找到适合你的平衡点。

4. 停止短路对整个系统有什么用?

能够在没有人监听响应时停止处理请求是非常有用的。这不仅使系统更接近用户的期望,还减少了整个系统的负载。我们正在处理的许多资源是有限的,特别是数据库,我们可以做的任何事情来更快地完成请求的处理,即使最终以失败告终,也是有利的。

  1. 延迟预算如何改善用户体验?

诚然,延迟预算是一个我很少听到讨论的话题。鉴于我们行业中 API 的普遍存在,也许我们应该更多地讨论它们。它们的重要性是双重的——用于触发停止和为我们的用户设定一些界限或期望。

当我们在 API 文档中发布我们的最大执行时间时,用户将清楚地了解我们的最坏情况性能期望。此外,我们可以利用延迟预算生成的错误返回更具信息性的错误消息,进一步使用户能够做出更明智的决定。

第八章,通过配置进行依赖注入

  1. 配置注入与方法或构造函数注入有何不同?

配置注入是方法和构造函数注入的扩展形式。它旨在通过隐藏常见和环境问题来改善代码的用户体验。减少参数使方法更易于理解、扩展和维护。

  1. 我们如何决定将哪些参数移动到配置注入中?

需要考虑的关键点是参数与方法或构造函数的关系。如果依赖关系微不足道但又是必要的,比如记录器和仪器,那么将其隐藏在配置中会提高函数签名的清晰度,而不是削弱它。同样,来自配置文件的配置通常是必要的但不具信息性。

  1. 为什么我们不通过配置注入来注入所有的依赖关系?

将所有依赖项合并为一个存在两个重要问题。第一个是可读性。方法/函数的用户必须每次都打开配置定义,才能了解可用的参数。其次,作为接口,用户将被迫创建和维护一个可以提供所有参数的接口实现。虽然所有配置可能来自同一位置,但其他依赖关系可能不是。包括环境依赖有点狡猾,但它们的存在几乎是无处不在的,它们在每个构造函数中的重复将会非常恼人。

  1. 为什么我们想要注入环境依赖(如记录器)而不是使用全局公共变量?

作为程序员,我们喜欢不要重复自己DRY)原则。在所有地方注入环境依赖是很多重复的。

  1. 为什么边界测试很重要?

我希望我们都能同意测试很重要。测试的价值部分来自重复运行测试并尽快检测到回归。为了最小化频繁运行测试的成本,我们需要测试速度相当快且绝对可靠。当测试依赖于外部系统,特别是我们不负责的系统时,我们就会把测试的价值置于风险之中。

外部系统可能发生任何事情。所有者可能会破坏它;互联网/网络可能会中断。面向内部的边界测试类似于我们的单元测试。它们保护我们的代码免受回归的影响。面向外部的边界测试是我们自动化记录和确保外部系统执行我们需要它执行的方式。

  1. 配置注入的理想用例是什么?

配置注入可以在与构造函数或方法注入相同的情况下使用。关键的决定因素是依赖项本身是否应该通过配置注入进行组合并在一定程度上隐藏,并且这如何改进或减少代码的用户体验。

第九章,即时依赖注入

1. JIT(即时)依赖注入与构造函数注入有何不同?

这在很大程度上取决于构造函数注入的使用方式;特别是依赖项有多少不同的实现。如果只有一个依赖项的生产实现,那么它们在功能上是等效的。唯一的区别是用户体验(即,是否有一个更少的依赖项注入到构造函数中)。

然而,如果有多个生产实现,那么就不能使用 JIT 依赖注入。

2. 在处理可选依赖项时,为什么使用 NO-OP 实现很重要?

当成员变量没有被构造函数设置时,它实际上是可选的。因此,我们无法确定该值是否已设置且不为 nil。通过添加可选依赖项的 NO-OP 实现并自动将其设置为成员变量,我们可以假定该依赖项始终不为 nil,因此我们可以放弃对守卫子句的需求。

3. JIT 注入的理想用例是什么?

JIT 注入非常适合以下情况:

  • 替换本应注入构造函数的依赖项,且只有一个生产实现

  • 在对象和全局单例之间提供一层间接或抽象,特别是当我们想在测试期间替换全局单例时

  • 允许用户选择性地提供依赖项

第十章,现成的注入

1. 采用依赖注入框架时,可以期待获得什么?

当然,这在不同的框架之间有很大的不同,但通常,你可以期待看到以下内容:

  • 减少样板代码

  • 减少设置和维护依赖项创建顺序的复杂性

2. 在评估依赖注入框架时,应该注意哪些问题?

除了之前提到的收益之外,我的主要标准是它对代码的影响;换句话说,我是否喜欢在采用框架后代码的外观。

我还会考虑框架本身的可配置性。一些配置是可以预期的,但太多可能会导致复杂的用户体验。

最后要考虑的是框架项目的健康状况。它是否在积极维护?报告的错误是否得到了回应?在不同框架之间切换可能不会很便宜;花点时间确保你选择的框架长期来看是合适的是个好主意。

3. 采用现成的注入的理想用例是什么?

通常,框架只支持构造函数注入。因此,已经使用构造函数注入的项目可以使用现成的注入。

4. 为什么重要保护服务免受意外 API 更改的影响?

服务的 API 有时被描述为一个合同。 合同 这个词被精心选择,因为它意在传达 API 与其用户之间的关系是多么重要和有约束力。

当我们发布 API 时,我们无法控制用户如何使用我们的 API,也许更重要的是,我们无法控制他们的软件对我们 API 的更改做出反应。为了履行我们的合同,我们必须尽一切努力确保我们不会通过对 API 的计划外更改来破坏他们的软件。

第十一章,控制你的热情

1. 你最常见到的依赖注入引起的损害形式是什么?

对我来说,这绝对是过多参数。学习了依赖注入并对此感到兴奋后,很容易想要抽象和注入所有东西。这往往会使测试变得更容易,因为每个对象的责任都减少了。缺点是有很多对象和太多的注入。

如果我发现自己有太多的依赖关系,我会尝试退后一步,检查我的对象设计,特别是寻找单一责任原则方面的问题。

2. 为什么不应该一味地应用依赖注入?

仅仅因为某些东西很“酷”或者新颖,并不意味着它就是最适合这项工作的工具。我们应该始终努力解决问题的解决方案,并在可以的时候避免“模仿”编程。

3. 采用 Google Wire 等框架是否消除了依赖注入引起的所有问题?

很遗憾,不是。鉴于它只支持构造函数注入,它甚至不能在所有情况下应用。除此之外,它可以显著减少过多参数的管理痛苦。

虽然这是一件好事,但它减轻了痛苦,这使我们不太可能感到有必要解决潜在的问题。

第十二章,回顾我们的进展

1. 对我们的示例服务进行的最重要的改进是什么?

这是主观的,因此没有正确答案。对我来说,要么是解耦,要么是去除全局变量。当代码解耦时,测试变得更容易,每个部分都变成了一小块,这意味着很容易处理。基本上,我不必费太多心思或记住太多上下文。

就全局变量而言,我过去曾受到过这方面的影响,特别是在测试过程中发生的数据竞争。我无法忍受我的测试不可靠。

2. 在我们的依赖图中,为什么数据包不在主包下?

我们可以重构成这种方式,但目前我们正在模型和数据层之间使用 JIT 注入。这意味着代码的用户体验得到了改善,但依赖图并不像它本应该的那样平坦。数据层还输出 DTOs 而不是基本数据类型,因此任何用户也将使用数据包。

如果我们决定也要移除这个,我们可以为 DTO 制作一个特殊的包,然后将该包从依赖图中排除,但这是额外的工作,目前并没有太多好处。

3. 如果您要启动一个新的服务,您会做些什么不同?

这是主观的,因此没有正确答案。在进行用户体验调查后,我会首先编写足够的代码启动一个 Web 服务器,即使这时还没有使用依赖。然后我会设计所有的端点,并使用硬编码的响应来实现它们。这将使我能够用示例来与用户讨论我的可交付成果。我还可以进行一些端到端的测试,以防止任何 API 回归。

然后我的用户就可以放心地继续,对我的 API 有清晰的认识,我也可以填写细节。

posted @ 2024-05-04 22:36  绝不原创的飞龙  阅读(76)  评论(0编辑  收藏  举报