Go-设计模式(全)

Go 设计模式(全)

原文:zh.annas-archive.org/md5/8A110D02C69060149D76F09768570714

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《Go 设计模式》!通过这本书,你将学习使用 Go 语言的基本和高级技术和模式。如果你以前从未编写过 Go 代码,不用担心;本书将逐渐向你介绍 Go 编程中的各种概念。与此同时,专家们会在语言方面提供许多技巧和窍门,所以我鼓励你不要错过任何一章。如果你已经了解经典设计模式,你会发现这本书非常方便,不仅可以作为参考书,还可以作为学习 Go 惯用方法解决常见问题的方式。

本书分为三个部分:

  • Go 语言简介:这是本书的第一部分,你将学习基本语法、二进制发行版附带的工具、基本测试、JSON 解析等。我们将把并发留到后面的章节,以便专注于典型 Go 应用程序中的语法和编译器的工作方式。

  • Go 中的经典设计模式:第二部分介绍了经典设计模式,但正如我们将看到的那样,它们有所不同,部分原因是 Go 中缺乏继承,但也因为我们有不同且更优化的方式来解决相同的问题。对于该语言的新手来说,本节中的示例将非常有用,因为它们可以帮助理解 Go 的根源以及使用 Go 解决问题的惯用方式,就像你在 Java 或 C++等语言中解决问题一样。大多数示例都是使用 TDD 进行演示,其中一些示例甚至展示了 Go 标准库中使用这些模式的示例。

  • 并发模式:本节的重点是学习并发结构和并行执行。你将学习 Go 中编写并发应用程序的大部分基本知识,并且我们将使用一些经典的设计模式来开发并发结构,以最大程度地实现并行性。此外,我们还将学习一些开发 Go 并发应用程序的典型结构。你将了解到,如果我们需要以并发方式工作,一个经典模式可能会变得更加复杂,但是我们的目标是理解 Go 的并发原语,以便读者在完成本书后能够使用所学知识编写自己的并发设计模式。

本书将逐渐提高一些任务的难度。我们在每一章都解释了一些技巧和窍门。

本书内容

第一章 ,准备...开始...Go!,旨在帮助那些对 Go 编程语言有一定背景的新手。它将从在 Linux 机器上安装 Go 环境开始,然后介绍语法、类型和流程控制。

第二章 ,创建模式 - 单例、生成器、工厂、原型和抽象工厂设计模式,介绍了在对象创建或管理特别复杂或昂贵时可能出现的问题,使用了单例、生成器、工厂和抽象工厂设计模式。

第三章 ,结构模式 - 组合、适配器和桥接设计模式,涉及关于对象组合以获得新功能的第一组结构模式。比如创建一个中间对象,并使用各种对象,就好像只有一个对象一样。

第四章《结构模式-代理,外观,装饰器和享元设计模式》不太关注多对象组合,而更注重在现有对象中获取新功能。装饰器模式通常用于遵循开闭原则。外观在 API 中被广泛使用,其中您希望一个单一的来源提供许多信息和操作。享元不太常见,但在内存因大量相似对象而成为问题时,它是一个非常有用的模式。最后,代理模式包装对象以提供相同的功能,同时为代理的功能添加一些东西。

第五章《行为模式-策略,责任链和命令设计模式》处理了第一个行为模式,使对象以预期或有限的方式做出反应。我们将从策略模式开始,这可能是面向对象编程中最重要的设计模式,因为许多设计模式与它有一些共同之处。然后我们将转向责任链,构建可以决定谁必须处理特定情况的对象链。最后,命令模式封装不一定需要立即执行或必须存储的操作。

第六章《行为模式-模板,备忘录和解释器设计模式》继续介绍行为模式,引入了解释器模式,这是一个创建小型语言和其解释器的相当复杂的模式。当问题可以通过为其发明一种小语言来解决时,它可能非常有用。备忘录模式在应用程序中的“撤销”按钮中每天都在我们眼前。模板模式通过定义操作的初始结构来帮助开发人员,以便代码的最终用户可以完成它。

第七章《行为模式-访问者,状态,中介者和观察者设计模式》描述了观察者模式,这是一个在分布式系统和响应式编程中变得非常流行的重要模式。访问者模式处理对象的复杂层次结构,需要根据对象应用特定操作。最后,状态模式通常用于视频游戏和有限状态机,并允许对象根据自身状态改变其行为。

第八章《Go 并发简介》通过一些示例,使用 Goroutines 和通道以及互斥锁和同步来更详细地解释了 Go 中使用的 CSP 并发模型。

第九章《并发模式-屏障,未来和管道设计模式》将通过一些示例和解释介绍 Go 语言中符合惯例的 CSP 并发模式。这些模式虽小,但非常强大,因此我们将提供每种模式的一些用法示例,以及一些图表(如果可能),以便更容易理解每种模式。

第十章,并发模式-工作池和发布或订阅设计模式,讨论了一些具有并发结构的模式。我们将详细解释每一步,以便您可以仔细跟随示例。我们的目标是学习设计并发应用程序的模式,以符合 Go 的习惯用法。我们将大量使用通道和 Goroutines,而不是锁或共享变量。

您需要为本书做些什么?

本书的大部分章节都是按照简单的 TDD 方法编写的,首先编写要求,然后进行一些单元测试,最后编写满足这些要求的代码。我们将只使用 Go 标准库中提供的工具,以更好地理解该语言及其可能性。这个想法是跟随本书并理解 Go 解决问题的方式的关键,特别是在分布式系统和并发应用中。

这本书是为谁写的

这本书适用于 Go 编程语言的初学者和高级开发人员。不需要了解设计模式。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"我们需要一个main函数来使用它,因为库不能直接转换为可执行文件。"

代码块设置如下:

    package main

    func main() {
      ten := 10
      if ten == 20 {
        println("This shouldn't be printed as 10 isn't equal to 20")
      } else {
        println("Ten is not equals to 20")
      }
    }

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

 if "a" == "b" || 10 == 10 || true == false {
      println("10 is equal to 10")
    } else if 11 == 11 && "go" == "go" {
        println("This won't because previous condition was satisfied")
      }
    }

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

$ go run main.go

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,以这种方式出现在文本中:"为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。"

注意

警告或重要说明将显示在这样的框中。

提示

技巧和窍门会以这种方式出现。

第一章:准备...开始...Go!

设计模式已经成为数以千计的软件基础。自从四人帮(Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides)在 1994 年用 C++和 Smalltalk 编写了《设计模式:可复用面向对象软件的元素》一书以来,这二十三种经典模式已经在今天的大多数主要语言中重新实现,并且它们已经在你所知道的几乎每个项目中被使用。

四人帮发现他们的许多项目中存在许多小型架构,他们开始以更抽象的方式重写它们,并发布了这本著名的书。

这本书是对四人帮最常见的设计模式和今天的模式以及一些 Go 中最惯用的并发模式的全面解释和实现。

但 Go 是什么...?

一点历史

在过去的 20 年里,我们在计算机科学领域经历了令人难以置信的增长。存储空间大幅增加,RAM 也有了实质性的增长,而 CPU...嗯...就是更快了。它们的增长是否和存储和 RAM 内存一样多?实际上并不是,CPU 行业已经达到了 CPU 可以提供的速度极限,主要是因为它们变得如此快,以至于在散热时无法获得足够的电力。CPU 制造商现在在每台计算机上都提供了更多的核心。这种情况与许多系统编程语言的背景相冲突,这些语言并不是为多处理器 CPU 或充当唯一机器的大型分布式系统而设计的。在 Google,他们意识到这已经不仅仅是一个问题,而是在努力开发像 Java 或 C++这样没有考虑并发性的语言的分布式应用程序时变得更加困难。

与此同时,我们的程序变得更大、更复杂、更难以维护,而且存在很多不良实践的空间。虽然我们的计算机拥有更多的核心并且更快,但我们在开发代码和分布式应用程序时并没有变得更快。这就是 Go 的目标。

Go 设计始于 2007 年,由三名谷歌员工研究一种可以解决像谷歌这样的大规模分布式系统中常见问题的编程语言。创作者是:

  • Rob Pike:Plan 9 和 Inferno 操作系统。

  • Robert Griesemer:曾在谷歌的 V8 JavaScript 引擎工作,该引擎为 Google Chrome 提供动力。

  • Ken Thompson:曾在贝尔实验室和 Unix 团队工作。他参与设计了 Plan 9 操作系统以及 UTF-8 编码的定义。

2008 年,编译器完成了,团队得到了 Russ Cox 和 Ian Lance Taylor 的帮助。团队于 2009 年开始了开源项目的旅程,并在 2012 年 3 月经过 50 多次发布后达到了 1.0 版本。

安装 Go

任何 Go 安装都需要两个基本的东西:语言的二进制文件在您的磁盘上的某个位置,以及系统中的GOPATH路径,您的项目和您从其他人那里下载的项目将存储在其中。

在接下来的几行中,我们将探讨如何在 Linux、Windows 和 OS X 中安装 Go 二进制文件。有关如何安装最新版本的 Go 的详细说明,您可以参考官方文档golang.org/doc/install

Linux

在 Linux 中安装 Go 有两种选择:

  • 简单选项:使用你的发行包管理器:

  • RHEL/Fedora/Centos 用户使用 YUM/DNF:sudo yum install -y golang

  • Ubuntu/Debian 用户使用 APT:sudo apt-get install -y golang

  • 高级:从golang.org下载最新的发行版。

我建议使用第二种方法并下载一个发行版。Go 的更新保持向后兼容性,通常不需要频繁更新 Go 二进制文件。

Go Linux 高级安装

在 Linux 中高级安装 Go 需要你从golang网页下载二进制文件。进入golang.org,点击Download Go按钮(通常在右侧),每个发行版都有一些Featured Downloads选项。选择Linux发行版下载最新的稳定版本。

注意

golang.org上,您也可以下载语言的 beta 版本。

假设我们已经将tar.gz文件保存在 Downloads 文件夹中,所以让我们解压它并将它移动到另一个路径。按照惯例,Go 二进制文件通常放在/usr/local/go目录中:

tar -zxvf go*.*.*.linux-amd64.tar.gz
sudo mv go /usr/local/go

在提取时,请记住用您下载的版本替换星号(*)。

现在我们的 Go 安装在/usr/local/go路径中,所以现在我们必须将bin子文件夹添加到我们的PATH和 GOPATH 中的bin文件夹。

mkdir -p $HOME/go/bin

使用-p 我们告诉 bash 创建所有必要的目录。现在我们需要将 bin 文件夹路径附加到我们的 PATH,在您的~/.bashrc的末尾添加以下行:

export PATH=$PATH:/usr/local/go/bin

检查我们的go/bin目录是否可用:

$ go version
Go version go1.6.2 linux/amd64

Windows

要在 Windows 中安装 Go,您需要管理员权限。打开您喜欢的浏览器,导航到https://golang.org。一旦到达那里,点击Download Go按钮,然后选择Microsoft Windows发行版。一个*.msi文件将开始下载。

双击执行 MSI 安装程序。一个安装程序将出现,要求您接受最终用户许可协议EULA)并选择安装的目标文件夹。我们将继续使用默认路径,在我的情况下是C:\Go

安装完成后,您需要将位于C:\Go\bin二进制 Go文件夹添加到您的 Path。为此,您必须转到控制面板,并选择系统选项。在系统中,选择高级选项卡,然后点击环境变量按钮。在这里,您会找到当前用户和系统变量的变量窗口。在系统变量中,您会找到Path变量。点击它,然后点击编辑按钮打开一个文本框。您可以通过在当前行的末尾添加;C:\Go/bin来添加您的路径(注意路径开头的分号)。在最近的 Windows 版本(Windows 10)中,您将有一个简单添加变量的管理器。

Mac OS X

在 Mac OS X 中,安装过程与 Linux 非常相似。打开您喜欢的浏览器,导航到golang.org,并点击Download Go。从出现的可能的发行版列表中,选择Apple OS X。这将在您的下载文件夹中下载一个*.pkg文件。

一个窗口将引导您完成安装过程,在这个过程中,您需要输入管理员密码,以便它可以将 Go 二进制文件放在/usr/local/go/bin文件夹中,并赋予适当的权限。现在,打开终端,通过在终端上输入以下内容来测试安装:

$ go version
Go version go1.6.2 darwin/amd64

如果您看到已安装的版本,一切都很好。如果不起作用,请检查您是否正确地按照每个步骤进行了操作,或者参考golang.org上的文档。

设置工作空间 - Linux 和 Apple OS X

Go 将始终在相同的工作空间下工作。这有助于编译器找到您可能正在使用的包和库。这个工作空间通常被称为GOPATH

在开发 Go 软件时,GOPATH 在您的工作环境中起着非常重要的作用。当您在代码中导入一个库时,它将在您的$GOPATH/src中搜索这个库。当您安装一些 Go 应用程序时,二进制文件将存储在$GOPATH/bin中。

同时,所有源代码必须存储在$GOPATH/src文件夹中的有效路径中。例如,我将我的项目存储在 GitHub 中,我的用户名是Sayden,所以,对于一个名为minimal-mesos-go-framework的项目,我的文件夹结构将如下所示:$GOPATH/src/github.com/sayden/minimal-mesos-go-framework,这反映了此存储库在 GitHub 上存储的 URI。

mkdir -p $HOME/go

$HOME/go路径将成为我们的$GOPATH的目的地。我们必须设置一个环境变量,将我们的$GOPATH指向这个文件夹。要设置环境变量,请再次用您喜欢的文本编辑器打开文件$HOME/.bashrc,并在其末尾添加以下行:

export GOPATH=${HOME}/go

保存文件并打开一个新的终端。要检查一切是否正常工作,只需像这样向$GOPATH变量写一个 echo:

echo $GOPATH
/home/mcastro/go

如果前面的命令的输出指向您选择的 Go 路径,则一切正确,您可以继续编写您的第一个程序。

从 Hello World 开始

如果没有一个 Hello World 示例,这本书就不会是一本好书。我们的 Hello World 示例不能更简单,打开您喜欢的文本编辑器,在我们的$GOPATH/src/[your_name]/hello_world中创建一个名为main.go的文件,内容如下:

package main 

func main(){ 
println("Hello World!") 
} 

保存文件。要运行我们的程序,请打开操作系统的终端窗口:

  • 在 Linux 中,转到程序并找到一个名为Terminal的程序。

  • 在 Windows 中,按下 Windows + R,键入cmd(不带引号)在新窗口中按Enter

  • 在 Mac OS X 中,按 Command + Space 打开聚光灯搜索,键入terminal(不带引号)。终端应用程序必须被突出显示,然后按 Enter。

一旦我们在终端中,导航到我们创建main.go文件的文件夹。这应该在您的$GOPATH/src/[your_name]/hello_world下,并执行它:

go run main.go
Hello World!

这就是全部。go run [file]命令将编译并执行我们的应用程序,但不会生成可执行文件。如果只想构建它并获得一个可执行文件,您必须使用以下命令构建应用程序:

go build -o hello_world

什么也没有发生。但是,如果在当前目录中搜索(在 Linux 和 Mac OS X 中使用ls命令,在 Windows 中使用dir命令),您将找到一个名为hello_world的可执行文件。我们在构建时给可执行文件命名为-o hello_world。现在您可以执行此文件:

/hello_world
Hello World!

我们的消息出现了!在 Windows 中,您只需要键入.exe文件的名称即可获得相同的结果。

提示

go run [my_main_file.go]命令将构建并执行应用程序,而不生成中间文件。go build -o [filename]命令将创建一个可执行文件,我可以随身携带,没有依赖关系。

集成开发环境 - IDE

IDE(集成开发环境)基本上是一个用户界面,它通过提供一组工具来加快开发过程中的常见任务,如编译、构建或管理依赖项,来帮助开发人员编写他们的程序。IDE 是强大的工具,需要一些时间来掌握,而本书的目的并不是解释它们(像 Eclipse 这样的 IDE 有自己的书籍)。

在 Go 中,您有许多选择,但只有两个是完全面向 Go 开发的LiteIDEIntellij Gogland。LiteIDE 虽然不是最强大的,但 Intellij 已经付出了很多努力,使 Gogland 成为一个非常好的编辑器,具有完成、调试、重构、测试、可视化覆盖、检查等功能。以下是具有 Go 插件/集成的常见 IDE 或文本编辑器:

  • IntelliJ Idea

  • Sublime Text 2/3

  • Atom

  • Eclipse

但您也可以找到以下 Go 插件:

  • Vim

  • Visual Studio 和 Visual Code

截至撰写本文时,IntelliJ Idea 和 Atom IDE 具有使用名为Delve的插件进行调试的支持。IntelliJ Idea 捆绑了官方的 Go 插件。在 Atom 中,您必须下载一个名为Go-plus的插件和一个调试器,您可以通过搜索单词Delve找到。

类型

类型使用户能够使用助记符名称存储值。所有编程语言都有与数字相关的类型(用于存储整数、负数或浮点数,例如)与字符相关的类型(用于存储单个字符)与字符串相关的类型(用于存储完整的单词)等。Go 语言具有大多数编程语言中常见的类型:

  • bool关键字是布尔类型,表示TrueFalse状态。

  • 许多数字类型是最常见的:

  • int类型在 32 位机器上表示从 0 到 4294967295 的数字,在 64 位机器上表示从 0 到 18446744073709551615 的数字。

  • byte类型表示从 0 到 255 的数字。

  • float32float64类型分别是所有 IEEE-754 64/-位浮点数的集合。

  • 您还有signed int类型,如rune,它是int32类型的别名,表示从-2147483648 到 2147483647 的数字,以及complex64complex128,它们是所有具有float32/float64实部和虚部的复数的集合,例如2.0i

  • string关键字表示字符串类型,表示用引号括起来的字符数组,例如"golang""computer"

  • array是单一类型和固定大小的元素的编号序列(本章后面会详细介绍数组)。一组数字或具有固定大小的单词列表被认为是数组。

  • slice类型是底层数组的一个段(本章后面会详细介绍)。这种类型在开始时有点令人困惑,因为它看起来像一个数组,但实际上,它们更强大。

  • 结构是由其他对象或类型组成的对象。

  • 指针(本章后面会详细介绍)就像程序内存中的方向(是的,就像你不知道里面装了什么的邮箱)。

  • 函数很有趣(本章后面会详细介绍)。您还可以将函数定义为变量,并将它们传递给其他函数(是的,一个使用函数的函数,你喜欢《盗梦空间》电影吗?)。

  • interface对于语言来说非常重要,因为它们提供了许多封装和抽象功能,这些功能我们经常需要。我们将在本书中广泛使用接口,并在后面更详细地介绍它们。

  • map类型是无序的键值结构。因此,对于给定的键,您有一个关联的值。

  • 通道是 Go 语言中用于并发程序的通信原语。我们将在第八章《处理 Go 的 CSP 并发》中更详细地了解通道。

变量和常量

变量是计算机内存中的空间,用于存储在程序执行期间可以修改的值。变量和常量具有与前文描述的类型相同的类型。尽管如此,您不需要显式地编写它们的类型(尽管您可以这样做)。避免显式类型声明的这种属性称为推断类型。例如:

    //Explicitly declaring a "string" variable 
    var explicit string = "Hello, I'm a explicitly declared variable" 

在这里,我们声明了一个名为explicit的字符串类型变量(使用关键字var)。同时,我们将值定义为Hello World!

    //Implicitly declaring a "string". Type inferred 
inferred := ", I'm an inferred variable " 

但在这里,我们做的事情完全相同。我们避免了var关键字和string类型声明。Go 的编译器会内部推断(猜测)变量的类型为字符串类型。这样,您就不必为每个变量定义编写更少的代码。

以下行使用reflect包来收集有关变量的信息。我们使用它来打印代码中的TypeOf变量的类型。

    fmt.Println("Variable 'explicit' is of type:", 
        reflect.TypeOf(explicit)) 
    fmt.Println("Variable 'inferred' is of type:", 
        reflect.TypeOf(inferred)) 

当我们运行程序时,结果如下:

$ go run main.go
Hello, I'm a explicitly declared variable
Hello, I'm an inferred variable
Variable 'explicit' is of type: string
Variable 'inferred' is of type: string

正如我们所预期的,编译器也将隐式变量的类型推断为字符串。两者都已将预期的输出写入控制台。

运算符

运算符用于执行算术运算并在许多事物之间进行比较。以下运算符由 Go 语言保留。

Operators

最常用的运算符是算术运算符和比较运算符。算术运算符如下:

  • 用于求和的+运算符

  • -运算符用于减法

  • *运算符用于乘法

  • /运算符用于除法

  • %运算符用于除法余数

  • ++运算符用于将当前变量加 1

  • --运算符用于从当前变量中减去 1

另一方面,比较器用于检查两个语句之间的差异:

  • ==运算符用于检查两个值是否相等

  • !=运算符用于检查两个值是否不同

  • >运算符用于检查左值是否大于右值

  • <运算符用于检查左值是否小于右值

  • >=运算符用于检查左值是否大于或等于右值

  • <=运算符用于检查左值是否小于或等于右值

  • &&运算符用于检查两个值是否为true

你还有移位器来对值进行左移或右移的二进制移位,以及一个取反操作符来反转一些值。在接下来的章节中,我们将大量使用这些运算符,所以现在不要太担心它们,只要记住你不能在你的代码中像这些运算符那样设置任何变量、字段或函数的名称。

提示

10 的倒数值是多少?10 的负值是多少?-10?不正确。10 在二进制代码中是1010,所以如果我们对每个数字取反,我们将得到0101101,这是数字 5。

流程控制

流程控制是指在条件下决定代码的哪一部分或多少次执行某些代码的能力。在 Go 中,它是使用熟悉的命令式子句如 if、else、switch 和 for 来实现的。语法很容易理解。让我们来回顾一下 Go 中的主要流程控制语句。

if... else 语句

Go 语言,像大多数编程语言一样,有用于流程控制的if…else条件语句。语法与其他语言类似,但你不需要在括号中封装条件:

ten := 10 
if ten == 20 { 
    println("This shouldn't be printed as 10 isn't equal to 20") 
} else { 
    println("Ten is not equals to 20"); 
} 

else...if条件以类似的方式工作,你也不需要括号,它们被声明为程序员所期望的那样:

if "a" == "b" ||  10 == 10 || true == false { 
    println("10 is equal to 10") 
  } else if 11 == 11 &&"go" == "go" { 
  println("This isn't print because previous condition was satisfied"); 
    } else { 
        println("In case no condition is satisfied, print this") 
    } 
} 

注意

Go 没有像condition ? true : false这样的三元条件。

switch 语句

switch语句也与大多数命令式语言类似。你取一个变量并检查可能的值:

number := 3 
switch(number){ 
    case 1: 
        println("Number is 1") 
    case 2: 
        println("Number is 2") 
    case 3: 
        println("Number is 3") 
} 

for…range 语句

_for_循环也与常见的编程语言类似,但你也不使用括号

for i := 0; i<=10; i++ { 
    println(i) 
} 

如果你有计算机科学背景,你可能已经想象到了,我们推断出一个定义为0int变量,并在括号中执行代码,同时条件(i<=10)得到满足。最后,对于每次执行,我们将i的值加 1。这段代码将打印出从 0 到 10 的数字。你还有一个特殊的语法来遍历数组或切片,即range

for index, value := range my_array { 
    fmt.Printf("Index is %d and value is %d", index, value) 
} 

首先,fmt(格式)是一个非常常见的 Go 包,我们将广泛使用它来给控制台中打印的消息赋予形状。

关于 for,你可以使用range关键字来检索集合中的每个项目,比如my_array,并将它们分配给临时变量的值。它还会给你一个index变量来知道你正在检索的值的位置。它相当于写下面的内容:

for index := 0, index < len(my_array); index++ { 
    value := my_array[index] 
    fmt.Printf("Index is %d and value is %d", index, value) 
} 

提示

len方法用于知道集合的长度。

如果你执行这段代码,你会发现结果是一样的。

函数

函数是围绕你想要执行的某些操作的一小部分代码,并返回一个或多个值(或者什么也不返回)。它们是开发人员维护结构、封装和代码可读性的主要工具,但也允许有经验的程序员对他或她的函数进行适当的单元测试。

函数可以非常简单,也可以非常复杂。通常,你会发现简单的函数也更容易维护、测试和调试。在计算机科学世界中也有一个非常好的建议,那就是:一个函数必须只做一件事,但必须做得非常好

函数是什么样子的?

函数是一段代码,有自己的变量和流程,不会影响到开放和关闭括号之外的任何东西,但会影响全局包或程序变量。Go 语言中的函数具有以下组成部分:

func [function_name] (param1 type, param2 type...) (returned type1, returned type2...) { 
    //Function body 
} 

根据前面的定义,我们可以有以下例子:

func hello(message string) error { 
    fmt.Printf("Hello %s\n", message) 
    return nil 
} 

函数可以调用其他函数。例如,在我们之前的hello函数中,我们接收一个类型为字符串的消息参数,并调用一个不同的函数fmt.Printf("Hello %s\n", message)并将我们的参数作为参数。函数也可以在调用其他函数时用作参数,或者被返回。

为你的函数选择一个好的名字非常重要,这样它就非常清楚它是关于什么的,而不需要写太多的注释。这可能看起来有点琐碎,但选择一个好的名字并不容易。一个简短的名字必须显示函数的功能,并让读者想象它正在处理什么错误,或者是否在进行任何类型的日志记录。在你的函数中,你希望做任何特定行为需要的一切,但也要控制预期的错误并适当地包装它们。

因此,编写函数不仅仅是简单地写出几行代码来完成你需要的功能,这就是为什么编写单元测试、使它们小而简洁是很重要的。

什么是匿名函数?

匿名函数是没有名字的函数。当你想要从另一个函数返回一个不需要上下文的函数,或者当你想要将一个函数传递给另一个函数时,这是很有用的。例如,我们将创建一个接受一个数字并返回一个接受第二个数字并将其加到第一个数字的函数。第二个函数没有声明的名称(因为我们将其分配给一个变量),所以它被称为匿名函数:

func main(){ 
    add := func(m int){ 
         return m+1 
} 

    result := add(6) 

    //1 + 6 must print 7 
    println(result) 
} 

add变量指向一个匿名函数,该函数将指定参数加一。正如你所看到的,它只能在其父函数main的作用域中使用,不能从其他任何地方调用。

匿名函数是非常强大的工具,在设计模式中我们会广泛使用它们。

闭包

闭包与匿名函数非常相似,但更加强大。它们之间的关键区别在于匿名函数在自身内部没有上下文,而闭包有。让我们重新编写前面的例子,以便添加任意数量而不是一个:

func main(){ 
    addN := func(m int){ 
        return func(n int){ 
            return m+n 
        }            
    } 

    addFive := addN(5) 
    result := addN(6)  
    //5 + 6 must print 7 

    println(result) 
}

addN变量指向一个返回另一个函数的函数。但是返回的函数在其中具有m参数的上下文。每次调用addN都会创建一个具有固定m值的新函数,因此我们可以有主addN函数,每个函数都添加不同的值。

闭包的这种能力非常有用,可以用来创建库或处理不支持类型的函数。

创建错误,处理错误和返回错误。

错误在 Go 中被广泛使用,可能是因为它的简单性。要创建一个错误,只需调用errors.New(string)并提供你想要在错误中创建的文本。例如:

err := errors.New("Error example") 

正如我们之前所见,我们可以向函数返回错误。在 Go 代码中,你会经常看到以下模式来处理错误:

func main(){ 
    err := doesReturnError() 
    if err != nil { 
        panic(err) 
    } 
} 

func doesReturnError() error { 
    err := errors.New("this function simply returns an error") 
    return err 
} 

具有不确定数量参数的函数

函数可以声明为可变参数。这意味着它的参数数量可以变化。这样做的作用是为函数的作用域提供一个包含函数调用时使用的参数的数组。如果你不想在使用这个函数时强制用户提供一个数组,这是很方便的。例如:

func main() { 
    fmt.Printf("%d\n", sum(1,2,3)) 
    fmt.Printf("%d\n", sum(4,5,6,7,8)) 
} 

func sum(args ...int) (result int) { 
    for _, v := range args { 
        result += v 
    } 
    return 
} 

在这个例子中,我们有一个sum函数,它将返回所有参数的总和,但是仔细看一下我们在main函数中调用sum的地方。现在你可以看到,首先我们用三个参数调用sum,然后用五个参数调用。对于sum函数来说,你传递多少参数都无所谓,因为它将其参数视为一个数组。因此,在我们的sum定义中,我们只是遍历数组,将每个数字加到result整数上。

命名返回类型

你是否意识到我们给返回类型取了一个名字?通常,我们的声明会写成func sum(args int) int,但你也可以给你在函数中用作返回值的变量取一个名字。在返回类型中命名变量也会将其零值化(在这种情况下,一个int会被初始化为零)。最后,你只需要返回函数(无需值),它就会取相应的变量作为返回值。这也更容易跟踪返回变量所经历的变化,以及确保你没有返回一个经过变异的参数。

数组、切片和映射

数组是计算机编程中最常用的类型之一。它们是可以通过它们在列表中的位置访问的其他类型的列表。数组的唯一缺点是它的大小不能被修改。切片允许使用大小可变的数组。maps类型将让我们在 Go 中拥有类似字典的结构。让我们看看每个是如何工作的。

数组

数组是单一类型元素的编号序列。您可以在一个唯一的变量中存储 100 个不同的无符号整数,三个字符串或 400 个布尔值。它们的大小不能被改变。

你必须在创建数组时声明数组的长度以及类型。你也可以在创建时赋值。例如,这里有 100 个值为0int值:

var arr [100]int 

或者一个大小为 3 的strings数组已经被赋值:

arr := [3]string{"go", "is", "awesome"} 

这里有一个我们稍后初始化的包含 2 个布尔值的数组:

var arr [2]bool 
arr[0] = true 
arr[1] = false 

零初始化

在我们之前的例子中,我们初始化了一个大小为 2 的布尔值数组。我们不需要将arr[1]赋值为false,因为语言的零初始化特性。Go 将会将布尔数组中的每个值初始化为false。我们稍后会更深入地了解零初始化。

切片

切片类似于数组,但它们的大小可以在运行时改变。这得益于切片的底层结构是一个数组。因此,就像数组一样,你必须指定切片的类型和大小。因此,使用以下行来创建一个切片:

mySlice := make([]int, 10) 

这个命令创建了一个包含十个元素的基础数组。如果我们需要改变切片的大小,例如添加一个新的数字,我们可以将数字附加到切片上:

mySlice := append(mySlice, 5) 

append 的语法是([要附加项的数组],[要附加的项]),并返回新的切片,它不会修改实际的切片。删除项也是如此。例如,让我们删除数组的第一个项如下:

mySlice := mySlice[1:] 

是的,就像数组一样。但是删除第二个项呢?我们使用相同的语法:

mySlice = append(mySlice[:1], mySlice[2:]...) 

我们取从零索引(包括)到第一个索引(不包括)的所有元素,以及从第二个索引(包括)到数组末尾的每个元素,有效地删除了切片中第二个位置的值(索引 1,因为我们从 0 开始计数)。正如你所看到的,我们使用了未确定参数的语法作为第二个参数。

映射

映射就像字典一样——对于每个单词,我们有一个定义,但我们可以使用任何类型作为单词或定义,它们永远不会按字母顺序排序。我们可以创建指向数字的字符串映射,指向interfaces的字符串映射,指向intintfunctionstructs映射。你不能使用切片、函数和映射作为键。最后,你可以使用关键字make来创建映射,并指定键类型和值类型:

myMap := make(map[string]int) 
myMap["one"] = 1 
myMap["two"] = 2 
fmt.Println(myMap["one"]) 

在解析 JSON 内容时,您还可以使用它们来获取string[interface]映射:

myJsonMap := make(map[string]interface{}) 
jsonData := []byte(`{"hello":"world"}`) 
err := json.Unmarshal(jsonData, &myJsonMap) 
if err != nil { 
panic(err) 
} 
fmt.Printf("%s\n", myJsonMap["hello"]) 

myJsonMap变量是一个将存储 JSON 内容并且我们需要将其指针传递给Unmarshal函数的映射。 jsonData变量声明了一个字节数组,其中包含 JSON 对象的典型内容;我们将其用作模拟对象。然后,我们解析 JSON 的内容,将结果存储在myJsonMap变量的内存位置。在检查转换是否正确以及 JSON 字节数组没有语法错误后,我们可以以类似 JSON 的语法访问映射的内容。

可见性

可见性是函数或变量可见于程序不同部分的属性。因此,变量只能在声明的函数中使用,在整个包中使用或在整个程序中使用。

如何设置变量或函数的可见性?嗯,一开始可能会有点混乱,但实际上并不复杂:

  • 大写定义是公共的(在整个程序中可见)。

  • 小写是私有的(在包级别不可见),函数定义(函数内的变量)只在函数范围内可见。

在这里,您可以看到一个public函数的示例:

package hello 

func Hello_world(){ 
    println("Hello World!") 
} 

在这里,Hello_world是一个全局函数(在整个源代码和代码的第三方用户中可见的函数)。因此,如果我们的包叫做hello,我们可以通过使用“hello.Hello_world()”方法从包外调用这个函数。

package different_package 

import "github.com/sayden/go-design-patters/first_chapter/hello" 

func myLibraryFunc() { 
hello.Hello_world() 
} 

正如您所看到的,我们在different_package包中。我们必须使用关键字 import 导入我们想要使用的包。然后路径就是包含我们要查找的包的路径在您的$GOPATH/src 中。这个路径方便地匹配了 GitHub 账户或任何其他Concurrent Versions SystemCVS)存储库的 URL。

零初始化

零初始化有时会引起混淆。它们是许多类型的默认值,即使您没有为定义提供值,它们也会被赋值。以下是各种类型的零初始化:

  • 对于bool类型使用false初始化。

  • 对于int类型使用0值。

  • 对于float类型使用0.0

  • 对于string类型使用“”(空字符串)。

  • 对于指针、函数、接口、切片、通道和映射使用nil关键字。

  • 对于没有字段的结构使用空struct

  • 对于具有字段的结构,零初始化的struct。结构的零值被定义为其字段也被初始化为零值的结构。

在 Go 中编程时,零初始化是重要的,因为如果您必须返回一个int类型或一个struct,您将无法返回一个nil值。例如,在必须返回一个bool值的函数中,请记住这一点。想象一下,您想知道一个数字是否能被另一个数字整除,但您将0(零)作为除数。

func main() { 
    res := divisibleBy(10,0) 
    fmt.Printf("%v\n", res) 
} 

func divisibleBy(n, divisor int) bool { 
    if divisor == 0 { 
        //You cannot divide by zero 
        return false 
    } 

    return (n % divisor == 0) 
} 

这个程序的输出是false,但这是不正确的。被零除的数字是一个错误,不是 10 不能被零整除,而是根据定义,一个数字不能被零整除。零初始化在这种情况下使事情变得尴尬。那么,我们如何解决这个错误呢?考虑以下代码:

func main() { 
    res, err := divisibleBy(10,0) 
    if err != nil { 
log.Fatal(err) 
    } 

    log.Printf("%v\n", res) 
} 

func divisibleBy(n, divisor int) (bool, error) { 
    if divisor == 0 { 
        //You cannot divide by zero 
        return false, errors.New("A number cannot be divided by zero") 
    } 

    return (n % divisor == 0), nil 
} 

我们再次将10除以0,但现在这个函数的输出是“一个数字不能被零整除”。错误被捕获,程序优雅地结束了。

指针和结构

指针是每个 C 或 C++程序员头痛的主要原因。但它们是非垃圾收集语言中实现高性能代码的主要工具之一。幸运的是,对于我们来说,Go 的指针通过提供具有垃圾收集器功能和易用性的高性能指针,实现了两全其美。

另一方面,对于它的批评者来说,Go 缺乏继承,而更倾向于组合。与其谈论在 Go 中 的对象,不如谈论你的对象 有其他的。所以,你可以有一个包含 car 结构的 vehicle 结构,而不是一个继承类 vehiclecar 结构(汽车是一种车辆)。

指针是什么?为什么它们很好?

指针同时受到憎恨、喜爱和非常有用。理解指针是什么可能很困难,所以让我们试着用一个现实世界的解释。正如我们在本章前面提到的,指针就像一个邮箱。想象一栋建筑里有一堆邮箱;它们都有相同的大小和形状,但每个邮箱都指向建筑物内的不同房子。只是因为所有邮箱的大小都一样,并不意味着每个房子都有相同的大小。我们甚至可以有几栋房子连接在一起,一个曾经存在但现在拥有商业许可的房子,或者一个完全空的房子。所以指针就是邮箱,它们都是相同大小的,指向一个房子。建筑物是我们的内存,房子是我们的指针引用的类型和它们分配的内存。如果你想在你的房子里收到一些东西,只需简单地发送你的房子的地址(发送指针)而不是发送整个房子,这样你的包裹就被放在里面了。但它们也有一些缺点,比如如果你发送了你的地址,然后你的房子(它引用的变量)在发送后消失了,或者它的类型所有者发生了变化——你就会陷入麻烦。

这有什么用?想象一下,你以某种方式有一个 4 GB 的变量,你需要将它传递给另一个函数。没有指针,整个变量都会被克隆到将要使用它的函数的范围内。所以,你将有 8 GB 的内存被使用,通过两次使用这个变量,希望第二个函数不会再在另一个函数中使用它,以进一步增加这个数字。

你可以使用指针将一个非常小的引用传递给第一个函数,这样只需克隆一个小引用,就可以保持内存使用量较低。

虽然这不是最学术的、也不是最准确的解释,但它给出了指针是什么的一个很好的观点,而不解释堆栈或堆在 x86 架构中是如何工作的。

与 C 或 C++ 指针相比,Go 中的指针非常有限。你不能使用指针算术,也不能创建一个指向栈中确切位置的指针。

在 Go 中,指针可以这样声明:

number := 5 

这里的 number := 5 代码表示我们的 4 GB 变量,pointer_to_number 包含对此变量的引用(用 & 表示)。这是指向变量的方向(你放在这个 house/type/variable 的邮箱里)。让我们打印变量 pointer_to_number,这是一个简单的变量:

println(pointer_to_number) 
0x005651FA 

那个数字是什么?嗯,是我们内存中变量的方向。我如何打印房子的实际值?嗯,通过一个星号(*),我们告诉编译器取指针引用的值,也就是我们的 4 GB 变量。

 println(*pointer_to_number) 
5 

结构

结构是 Go 中的一个对象。它与面向对象编程中的类有一些相似之处,因为它们有字段。结构可以实现接口并声明方法。但是,例如,在 Go 中,没有继承。缺乏继承看起来是有限的,但实际上,组合优于继承 是这种语言的要求。

要声明一个结构,你必须用关键字 type 作为前缀,用关键字 struct 作为后缀,然后在括号之间声明任何字段或方法,例如:

type Person struct { 
    Name string 
    Surname string 
    Hobbies []string 
    id string 
} 

在这段代码中,我们声明了一个Person结构,其中有三个公共字段(NameAgeHobbies)和一个私有字段(id,如果你回忆一下本章的可见性部分,Go 中的小写字段指的是私有字段,只在同一个包内可见)。有了这个struct,我们现在可以创建任意多个Person的实例。现在我们将编写一个名为GetFullName的函数,该函数将给出struct所属的名字和姓氏的组合:

func (person *Person) GetFullName() string { 
    return fmt.Sprintf("%s %s", person.Name, person.Surname) 
} 

func main() { 
    p := Person{ 
        Name: "Mario", 
        Surname: "Castro", 
        Hobbies: []string{"cycling", "electronics", "planes"}, 
        id: "sa3-223-asd", 
    } 

    fmt.Printf("%s likes %s, %s and %s\n", p.GetFullName(), p.Hobbies[0], p.Hobbies[1], p.Hobbies[2]) 
} 

方法的定义方式与函数类似,但略有不同。有一个(p *Person),它指的是指向struct创建的实例的指针(回忆一下本章的指针部分)。这就像在 Java 中使用关键字this或在 Python 中引用指向对象时使用self一样。

也许你在想为什么(p *Person)有指针运算符来反映p实际上是一个指针而不是一个值?这是因为你也可以通过删除指针签名来传递 Person 的值。在这种情况下,将传递 Person 值的副本给函数。这有一些影响,例如,如果你通过值传递 p 进行更改,那么在源p中不会反映出这些更改。但是我们的GetFullName()方法呢?

func (person Person) GetFullName() string { 
    return fmt.Sprintf("%s %s", person.Name, person.Surname) 
} 

它的控制台输出在外观上没有影响,但在评估函数之前进行了完全复制。但是,如果我们在这里修改person,源p不会受到影响,新的person值只会在此函数的范围内可用。

main函数中,我们创建了一个名为p的结构的实例。正如你所看到的,我们使用了隐式表示法来创建变量(:=符号)。要设置字段,你必须引用字段的名称,冒号,值和逗号(不要忘记最后的逗号!)。要访问实例化结构的字段,我们只需通过它们的名称引用它们,如p.Namep.Surname。你可以使用相同的语法来访问结构的方法,如p.GetFullName()

该程序的输出是:

$ go run main.go 
Mario Castro likes cycling, electronics and planes

结构也可以包含另一个结构(组合)并实现接口方法,除了它们自己的方法之外,但是,什么是接口方法?

接口

接口在面向对象编程、函数式编程(traits)以及设计模式中至关重要。Go 的源代码中到处都是接口,因为它们提供了通过函数提供所需的抽象来交付无耦合的代码。作为程序员,当你编写库时,也需要这种类型的抽象,但也需要在编写将来需要新功能维护的代码时使用这种抽象。

接口在开始时很难理解,但一旦理解了它们的行为并为常见问题提供了非常优雅的解决方案,就会变得非常容易。我们将在本书中广泛使用它们,因此请特别关注本节。

接口 - 签署合同

接口是一种非常简单但强大的东西。它通常被定义为实现它的对象之间的合同,但对于接口世界的新手来说,这种解释并不够清晰。

水管也是一种合同;你通过它传递的任何东西都必须是液体。任何人都可以使用这根管子,管子将运输你放入其中的任何液体(而不知道其内容)。水管是强制要求用户必须传递液体(而不是其他东西)的接口。

让我们考虑另一个例子:火车。火车的铁轨就像一个接口。火车必须使用指定的值构建(实现)其宽度,以便它可以进入铁路,但铁路永远不知道它究竟装载了什么(乘客或货物)。因此,例如,铁路的接口将具有以下方面:

type RailroadWideChecker interface { 
    CheckRailsWidth() int 
} 

RailroadWideChecker是我们的火车必须实现的类型,以提供有关其宽度的信息。火车将验证火车的宽度是否过宽或过窄以使用其铁路:

type Railroad struct { 
    Width int 
} 

func (r *Railroad) IsCorrectSizeTrain(r RailRoadWideChecker) bool { 
    return r.CheckRailsWidth() != r.Width 
} 

铁路由一个虚构的车站对象实现,该对象包含有关该车站铁路宽度的信息,并具有一个方法来检查火车是否符合铁路的需求,即IsCorrectSizeTrain方法。IsCorrectSizeTrain方法接收一个实现了该接口的火车的指针,并返回火车宽度与铁路之间的验证:

Type Train struct { 
    TrainWidth int 
} 

func (p *Train) CheckRailsWidth() int { 
    return p.TrainWidth 
} 

现在我们已经创建了一列乘客列车。它有一个字段来包含其宽度,并实现了我们的CheckRailsWidth接口方法。这个结构被认为满足RailRoadWideChecker接口的需求(因为它实现了接口要求的方法)。

现在,我们将创建一个宽度为10个单位的铁路和两列火车--一列宽度为10个单位的火车符合铁路尺寸,另一列宽度为15个单位的火车无法使用铁路。

func main(){ 
    railroad := Railroad{Width:10} 

    passengerTrain := Train{TrainWidth: 10} 
    cargoTrain := Train {TrainWidth: 15} 

    canPassengerTrainPass := railroad.IsCorrectSizeTrain(passengerTrain) 
    canCargoTrainPass := railroad.IsCorrectSizeTrain(cargoTrain) 

    fmt.Printf("Can passenger train pass? %b\n", canPassengerTrainPass) 
    fmt.Printf("Can cargo train pass? %b\n", canCargoTrainPass) 
} 

让我们解析这个main函数。首先,我们创建了一个名为railroad的宽度为10个单位的铁路对象。然后,分别为乘客和货物创建了宽度为1015个单位的两列火车。然后,我们将这两个对象传递给接受RailroadWideChecker接口的铁路方法。铁路本身不知道每列火车的宽度(我们将有一个巨大的火车列表),但它有一个火车必须实现的接口,以便它可以询问每个宽度并返回一个值,告诉您火车是否可以或不能使用铁路。最后,对printf函数的调用输出如下:

Can passenger train pass? true
Can cargo train pass? false

正如我之前提到的,接口在本书中被广泛使用,所以即使对读者来说它看起来仍然很困惑,因为在本书中将有大量的例子。

测试和 TDD

当您编写某个库的第几行时,很难引入许多错误。但是一旦源代码变得越来越大,就会变得更容易出错。团队壮大了,现在有很多人在编写相同的源代码,新功能是在您最初编写的代码之上添加的。现在,由于某个函数的某些修改,代码停止工作,现在没有人能够追踪到。

这是企业中测试试图减少的常见情况(它并不能完全解决问题,不是圣杯)。在开发过程中编写单元测试时,您可以检查一些新功能是否破坏了旧功能,或者您当前的新功能是否符合需求中的所有预期。

Go 语言有一个强大的测试包,使您也可以很容易地在 TDD 环境中工作。它还非常方便,可以在不需要编写使用它的整个主应用程序的情况下检查代码的部分。

测试包

在每种编程语言中,测试都非常重要。Go 的创建者知道这一点,并决定在核心包中提供所有测试所需的库和包。您不需要任何第三方库进行测试或代码覆盖。

允许测试 Go 应用程序的包称为testing。我们将创建一个小应用程序,通过命令行提供两个数字并对其进行求和:

func main() { 
    //Atoi converts a string to an int 
    a, _ := strconv.Atoi(os.Args[1]) 
    b, _ := strconv.Atoi(os.Args[2]) 

    result := sum(a,b) 
    fmt.Printf("The sum of %d and %d is %d\n", a, b, result) 
} 

func sum(a, b int) int { 
    return a + b 
} 

让我们在终端中执行我们的程序以得到总和:

$ go run main.go 3 4
The sum of 3 and 4 is 7

顺便说一下,我们正在使用strconv包将字符串转换为其他类型,这种情况下是转换为intAtoi方法接收一个字符串并返回一个int和一个error,为简单起见,我们在这里忽略了(使用下划线)。

提示

如果需要,您可以使用下划线忽略变量返回,但通常不希望忽略错误。

好的,现在让我们编写一个测试,检查求和的正确结果。我们正在创建一个名为main_test.go的新文件。按照惯例,测试文件的命名方式与它们测试的文件相同,加上_test后缀:

func TestSum(t *testing.T) { 
    a := 5 
    b := 6 
    expected := 11 

    res := sum(a, b) 
    if res != expected { 
        t.Errorf("Our sum function doens't work, %d+%d isn't %d\n", a, b, res) 
    } 
} 

在 Go 中进行测试是通过编写以前缀Test开头的方法,一个测试名称和名为ttesting.T指针的注入来完成的。与其他语言相反,在 Go 中没有断言,也没有用于测试的特殊语法。您可以使用 Go 语法来检查错误,并在失败时使用t调用有关错误的信息。如果代码在没有出现错误的情况下到达Test函数的末尾,则该函数已通过测试。

要在 Go 中运行测试,您必须使用go test -v命令(-v是为了从测试中接收详细输出)关键字,如下所示:

$ go test -v
=== RUN   TestSum
--- PASS: TestSum (0.00s)
PASS
ok   github.com/go-design-patterns/introduction/ex_xx_testing 0.001s

我们的测试是正确的。让我们看看如果我们故意破坏事情并将测试的预期值从11改为10会发生什么:

$ go test
--- FAIL: TestSum (0.00s)
 main_test.go:12: Our sum function doens't work, 5+6 isn't 10
FAIL
exit status 1
FAIL  github.com/sayden/go-design-patterns/introduction/ex_xx_testing 0.002s

测试失败了(正如我们预料的那样)。测试包提供了您在测试中设置的信息。让我们再次使其工作,并检查测试覆盖率。将变量expected的值从10更改为11,然后运行命令go test -cover以查看代码覆盖率:

$ go test -cover
PASS
coverage: 20.0% of statements
ok  github.com/sayden/go-design-patterns/introduction/ex_xx_testing 0.001s

-cover选项为我们提供了有关给定包的代码覆盖率的信息。不幸的是,它不提供有关整体应用程序覆盖率的信息。

什么是 TDD?

TDD 是测试驱动开发的缩写。它包括在编写函数之前先编写测试(而不是我们之前所做的,先编写sum函数,然后再编写test函数)。

TDD 改变了编写代码和结构代码的方式,以便可以进行测试(您可以在 GitHub 上找到很多代码,甚至您以前可能编写的代码可能非常难以测试,甚至是不可能的)。

那么,它是如何工作的呢?让我们用一个现实生活的例子来解释一下--想象一下你在夏天,你想要得到一些清凉。你可以建造一个游泳池,把它装满冷水,然后跳进去。但是在 TDD 术语中,步骤将是:

  1. 你跳进一个将要建造游泳池的地方(你写一个你知道会失败的测试)。

  2. 它很疼...而且你也不酷(是的...测试失败了,正如我们预料的那样)。

  3. 你建造了一个游泳池,把它装满冷水(你编写了功能)。

  4. 你跳进游泳池(你再次重复第 1 点的测试)。

  5. 你现在很冷。太棒了!对象完成了(测试通过)。

  6. 去冰箱拿一瓶啤酒到游泳池。喝。双倍的美妙(重构代码)。

所以让我们重复之前的例子,但是用乘法。首先,我们将写下我们要测试的函数的声明:

func multiply(a, b int) int { 
    return 0 
} 

现在让我们编写一个测试,检查之前函数的正确结果:

import "testing" 

func TestMultiply(t *testing.T) { 
    a := 5 
    b := 6 
    expected := 30 

    res := multiply(a, b) 
    if res != expected { 
        t.Errorf("Our multiply function doens't work, %d*%d isn't %d\n", a, b, res) 
    } 
} 

然后我们通过命令行进行测试:

$ go test
--- FAIL: TestMultiply (0.00s)
main_test.go:12: Our multiply function doens't work, 5+6 isn't 0
FAIL
exit status 1
FAIL    github.com/sayden/go-designpatterns/introduction/ex_xx_testing/multiply 
0.002s

很好。就像在我们的游泳池例子中水还没有到位一样,我们的函数返回的值也是不正确的。所以现在我们有了一个函数声明(但还没有定义),以及失败的测试。现在我们必须通过编写函数并执行测试来使测试通过以检查:

func multiply(a, b int) int { 
 return a*b 
} 

然后我们再次执行我们的测试套件。在正确编写我们的代码之后,测试应该通过,这样我们就可以继续进行重构过程:

$ go test
PASS
ok      github.com/sayden/go-design-patterns/introduction/ex_xx_testing/multiply    
0.001s

太棒了!我们已经按照 TDD 开发了multiply函数。现在我们必须重构我们的代码,但我们不能使它更简单或更可读,所以循环可以被认为是封闭的。

在本书中,我们将编写许多测试,定义我们在模式中要实现的功能。TDD 促进了封装和抽象(就像设计模式一样)。

到目前为止,我们大多数的例子都是应用程序。应用程序由其main函数和包定义。但是在 Go 中,您也可以创建纯库。在库中,包的名称不需要被称为 main,也不需要main函数。

由于库不是应用程序,因此无法使用它们构建二进制文件,您需要使用将使用它们的main包。

例如,让我们创建一个算术库,对整数执行常见操作:求和、减法、乘法和除法。我们不会深入讨论实现的细节,而是专注于 Go 库的特殊之处:

package arithmetic 

func Sum(args ...int) (res int) { 
    for _, v := range args { 
        res += v 
    } 
    return 
} 

首先,我们需要为我们的库命名;我们通过为整个包命名来设置这个名称。这意味着此文件夹中的每个文件也必须具有此包名称,所有文件组成的整个库在这种情况下也被称为arithmetic(因为它只包含一个包)。这样,我们就不需要引用此库的文件名,只需提供库名称和路径即可导入和使用它。我们定义了一个Sum函数,它接受您需要的任意数量的参数,并返回一个整数,在函数的范围内,它将被称为res。这使我们能够将我们返回的值初始化为0。我们定义了一个包(不是main包,而是一个库包),并将其命名为arithmetic。由于这是一个库包,我们无法直接从命令行运行它,因此我们必须为其创建main函数或单元测试文件。为了简单起见,我们将创建一个main函数,现在运行一些操作,但让我们先完成库:

func Subtract(args ...int) int { 
    if len(args) < 2 { 
        return 0 
    } 

    res := args[0] 
    for i := 1; i < len(args); i++ { 
        res -= args[i] 
    } 
    return res 
} 

Subtraction代码将在参数数量少于零时返回0,如果有两个或更多参数,则返回所有参数的减法:

func Multiply(args ...int) int { 
    if len(args) < 2 { 
        return 0 
    } 

    res := 1 
    for i := 0; i < len(args); i++ { 
        res *= args[i] 
    } 
    return res 
} 

Multiply函数的工作方式类似。当参数少于两个时,它返回0,当参数为两个或更多时,它返回所有参数的乘积。最后,Division代码有些变化,因为如果要求它除以零,它将返回一个错误:

func Divide(a, b int) (float64, error) { 
    if b == 0 { 
        return 0, errors.New("You cannot divide by zero") 
    }  
    return float64(a) / float64(b), nil 
} 

现在我们的库已经完成,但我们需要一个main函数来使用它,因为库不能直接转换为可执行文件。我们的主函数如下所示:

package main 

import ( 
"fmt" 

"bitbucket.org/mariocastro/go-design-patterns/introduction/libraries/arithmetic" 
) 

func main() { 
    sumRes := arithmetic.Sum(5, 6) 
    subRes := arithmetic.Subtract(10, 5) 
    multiplyRes := arithmetic.Multiply(8, 7) 
    divideRes, _ := arithmetic.Divide(10, 2) 

    fmt.Printf("5+6 is %d. 10-5 is %d, 8*7 is %d and 10/2 is %f\n", sumRes, subRes, multiplyRes, divideRes) 
} 

我们正在对我们定义的每个函数执行操作。仔细看一下import子句。它从与bitbucket.org/中的 URL 匹配的$GOPATH中的文件夹中获取我们编写的库。然后,要使用库中定义的每个函数,您必须在每个方法之前命名库的包名称。

注意

您是否意识到我们使用大写名称来调用我们的函数?根据我们之前看到的可见性规则,包中的导出函数必须具有大写名称,否则它们将在包的范围之外不可见。因此,根据此规则,您无法在包中调用小写函数或变量,并且包调用将始终后跟大写名称。

让我们回顾一下关于库的一些命名约定:

  • 同一文件夹中的每个文件必须包含相同的包名称。文件不需要以任何特殊方式命名。

  • 文件夹代表库中的包名称。文件夹名称将用于导入路径,不需要反映包名称(尽管建议为父包)。

  • 一个库是一个或多个代表树的包,您通过所有包文件夹的父级导入。

  • 您通过包名称调用库中的内容。

Go get 工具

Go get 是一个从 CVS 存储库获取第三方项目的工具。您可以使用 Go get 而不是使用git clone命令来获得一系列的附加好处。让我们以 CoreOS 的 ETCD 项目为例,这是一个著名的分布式键值存储。

CoreOS 的 ETCD 托管在 GitHub 上,网址为github.com/coreos/etcd.git。要使用 Go get 工具下载此项目的源代码,我们必须在终端中输入它在 GOPATH 中将具有的导入路径:

$ go get github.com/coreos/etcd

请注意,我们只是输入了最相关的信息,以便 Go get 找出其余部分。您将获得一些输出,取决于项目的状态,但之后,它将消失。但发生了什么?

  • Go get 已在$GOPATH/src/github.com/coreos中创建了一个文件夹。

  • 它已在该位置克隆了项目,因此现在 ETCD 的源代码可在$GOPATH/src/github.com/coreos/etcd中使用。

  • Go get 已克隆了 ETCD 可能需要的任何存储库。

  • 如果它不是库,它会尝试安装项目。这意味着它已生成了 ETCD 的二进制文件,并将其放在$GOPATH/bin文件夹中。

只需键入go get [project]命令,您就可以从系统中获取项目的所有材料。然后在您的 Go 应用程序中,您可以通过导入源路径来使用任何库。因此,对于 ETCD 项目,它将是:

import "github.com/coreos/etcd" 

非常重要的是,您要熟悉使用 Go get 工具,并在想要从 Git 存储库获取项目时停止使用git clone。这将在尝试导入不包含在 GOPATH 中的项目时为您节省一些麻烦。

管理 JSON 数据

JSON 是JavaScript 对象表示的首字母缩写,正如名称所暗示的那样,它是 JavaScript 的本地格式。它已变得非常流行,并且是今天通信中使用最多的格式。Go 对 JSON 序列化/反序列化有很好的支持,JSON包为您完成了大部分繁重的工作。首先,在处理 JSON 时有两个概念需要学习:

  • Marshal:当您对结构或对象的实例进行编组时,您正在将其转换为其 JSON 对应项。

  • Unmarshal:当您对某些数据进行解组时,以字节数组的形式,您正在尝试将一些预期为 JSON 的数据转换为已知的结构或对象。您还可以以一种快速但不太安全的方式将其解组map[string]interface{},以解释数据,我们将在下面看到。

让我们看一个编组字符串的示例:

import ( 
"encoding/json" 
"fmt" 
) 

func main(){ 
    packt := "packt" 
    jsonPackt, ok := json.Marshal(packt) 
    if !ok { 
        panic("Could not marshal object")  
    }  
    fmt.Println(string(jsonPackt)) 
} 
$ "pack"

首先,我们定义了一个名为packt的变量来保存packt字符串的内容。然后,我们使用json库使用我们的新变量执行Marshal命令。这将返回一个带有 JSON 和提供boolOK操作结果的新bytearray。当我们打印字节数组的内容(先转换为字符串),预期值将出现。请注意,packt实际上出现在引号之间,就像 JSON 表示一样。

编码包

您是否意识到我们已导入了encoding/json包?为什么它以encoding为前缀?如果您查看 Go 的源代码到src/encoding文件夹,您将找到许多有趣的编码/解码包,例如 XML、HEX、二进制,甚至 CSV。

现在有点复杂的东西:

type MyObject struct { 
    Number int 
    `json:"number"` 
    Word string 
} 

func main(){ 
    object := MyObject{5, "Packt"} 
    oJson, _ := json.Marshal(object) 
    fmt.Printf("%s\n", oJson) 
} 
$ {"Number":5,"Word":"Packt"}

方便的是,它也与结构非常配合得很好,但是如果我不想在 JSON 数据中使用大写字母怎么办?您可以在结构声明中定义 JSON 的输出/输入名称:

type MyObject struct { 
    Number int 
    Word string 
} 

func main(){ 
    object := MyObject{5, "Packt"} 
    oJson, _ := json.Marshal(object) 
    fmt.Printf("%s\n", oJson) 
} 
$ {"number":5,"string":"Packt"}

我们不仅将键的名称改为小写,甚至还将Word键的名称更改为字符串。

足够的编组,我们将接收 JSON 数据作为字节数组,但是过程非常相似,有一些变化:

type MyObject struct { 
Number int`json:"number"` 
Word string`json:"string"` 
} 

func main(){ 
    jsonBytes := []byte(`{"number":5, "string":"Packt"}`) 
    var object MyObject 
    err := json.Unmarshal(jsonBytes, &object) 
    if err != nil { 
        panic(err) 
    } 
    fmt.Printf("Number is %d, Word is %s\n", object.Number, object.Word) 
} 

这里的重大区别在于您首先必须为结构分配空间(具有零值),然后将引用传递给Unmarshal方法,以便它尝试填充它。当您使用Unmarshal时,第一个参数是包含 JSON 信息的字节数组,而第二个参数是我们要填充的结构的引用(这就是为什么我们使用了&)。最后,让我们使用通用的map[string]interface{}方法来保存 JSON 的内容:

type MyObject struct { 
    Number int     `json:"number"` 
    Word string    `json:"string"` 
} 

func main(){ 
    jsonBytes := []byte(`{"number":5, "string":"Packt"}`) 
    var dangerousObject map[string]interface{} 
    err := json.Unmarshal(jsonBytes, &dangerousObject) 
    if err != nil { 
        panic(err) 
    } 

    fmt.Printf("Number is %d, ", dangerousObject["number"]) 
    fmt.Printf("Word is %s\n", dangerousObject["string"]) 
    fmt.Printf("Error reference is %v\n",  
dangerousObject["nothing"])
} 
$ Number is %!d(float64=5), Word is Packt 
Error reference is <nil> 

结果发生了什么?这就是为什么我们描述这个对象是危险的。如果在使用这种模式时调用 JSON 中不存在的键,你可能会指向一个nil位置。不仅如此,就像在例子中一样,它也可能将一个值解释为float64,而实际上是一个byte,浪费了大量内存。

所以记住,当你需要快速访问相当简单的 JSON 数据并且你控制了之前描述的类型场景时,只需使用map[string]interface{}

Go 工具

Go 带有一系列有用的工具,以便每天都能简化开发过程。此外,在 GitHub 的 golang 页面上,有一些工具得到了 Go 团队的支持,但它们并不是编译器的一部分。

大多数项目都使用诸如gofmt之类的工具,以便整个代码库看起来相似。Godoc 帮助我们在 Go 的文档中找到有用的信息,而goimport命令则自动导入我们正在使用的包。让我们来看看它们。

golint 工具

一个 linter 分析源代码以检测错误或改进。golint linter 可以在github.com/golang/lint上安装(它不随编译器捆绑)。它非常容易使用,并且集成在一些 IDE 中,可以在保存源代码文件时运行(例如 Atom 或 Sublime Text)。你还记得我们在谈论变量时运行的隐式/显式代码吗?让我们来 lint 它:

//Explicitly declaring a "string" variable 
var explicit string = "Hello, I'm a explicitly declared variable" 

//Implicitly declaring a "string". 
Type inferred inferred := ", I'm an inferred variable " 

$ golint main.go

main.go:10:21:命令应该省略explicitString变量的声明中的字符串类型;它将从右侧推断出来。

它告诉我们,Go 编译器实际上会从代码中推断出这种类型的变量,你不需要声明它的类型。接口部分的Train类型呢?

Type Train struct { 
    TrainWidth int 
} 

$ golint main.go

导出的Train类型应该有一个注释或者保持不导出。

在这种情况下,它告诉我们,像Train这样的公共类型必须被注释,以便用户可以阅读生成的文档了解其行为。

gofmt 工具

gofmt工具已经随编译器捆绑在一起。它的目的是提供一组缩进、格式化、间距和其他规则,以实现漂亮的 Go 代码。例如,让我们拿 Hello World 的代码,并在每个地方插入空格,使它看起来有点奇怪:

package main 

func  main(){ 
    println("Hello World!") 
} 

$ gofmt main.go 
package main 

func main() { 
        println("Hello World!") 
} 

gofmt命令再次正确打印出来。而且,我们可以使用-w标志来覆盖原始文件:

$ gofmt -w main.go

现在我们的文件将被正确纠正。

godoc 工具

Go 文档非常广泛和冗长。你可以找到关于任何你想要实现的主题的详细信息。godoc工具还可以帮助你直接从命令行访问这些文档。例如,我们可以查询encoding/json包:

$godoc cmd/encoding/json
[...]
FUNCTIONS
func Compact(dst *bytes.Buffer, src []byte) error
Compact appends to dst the JSON-encoded src with insignificant space
characters elided.
func HTMLEscape(dst *bytes.Buffer, src []byte)
[...]

你还可以使用grep,这是 Linux 和 Mac 的一个 bash 实用程序,来查找有关某些功能的特定信息。例如,我们将使用 grep 来查找提到解析 JSON 文件的文本:

$ godoc cmd/encoding/json | grep parse

Unmarshal命令解析 JSON 编码的数据,并将结果存储在被解析的对象中。

golint命令警告的一件事是使用与描述函数相同的名称开头的注释。这样,如果你忘记了解析 JSON 的函数名称,你可以使用godocgrep搜索parse,这样行的开头将始终是函数名称,就像在Unmarshal命令之前的例子中一样。

goimport 工具

goimport工具在 Go 中是必不可少的。有时您对自己的包如此了解,以至于不需要搜索太多就能记住它们的 API,但在导入时更难记住它们所属的项目。goimport命令通过搜索您的$GOPATH中的包出现来为您提供项目import行,这对您非常有帮助。如果您配置您的 IDE 在保存时运行goimport,那么源文件中使用的所有包都将自动导入。它也可以反过来运行-如果您从包中删除了您正在使用的函数,并且不再使用该包,它将删除import行。

在 GitHub 上为 Go 开源项目做贡献

关于 Go 打包系统的一个重要事项是,它需要在 GOPATH 中有适当的文件夹结构。这在与 GitHub 项目一起工作时会引入一个小问题。我们习惯于 fork 一个项目,克隆我们的 fork 并开始工作,然后再提交拉取请求到原始项目。错了!

当您 fork 一个项目时,您在 GitHub 上的用户名下创建了一个新的存储库。如果您克隆此存储库并开始使用它,项目中的所有新的导入引用将指向您的存储库,而不是原始存储库!想象一下原始存储库中的以下情况:

package main 
import "github.com/original/a_library" 
[some code] 

然后,您可以 fork 并添加一个名为a_library/my_library的库的子文件夹,您希望从主包中使用。结果将如下所示:

package main 
import ( 
    "github.com/original/a_library" 
    "github.com/myaccount/a_library/my_library" 
) 

现在,如果您提交了这行代码,包含您推送的代码的原始存储库将再次从您的帐户下载此代码,并使用下载的引用!而不是项目中包含的引用!

因此,解决此问题的方法很简单,就是用指向原始库的go get命令替换git clone命令:

$ go get github.com/original/a_library
$ cd $GOPATH/src/github.com/original/a_library
$ git remote add my_origin https://github.com/myaccount/a_libbrary

通过这种修改,您可以在原始代码中正常工作,而不必担心引用会出错。完成后,您只需提交并推送到您的远程存储库。

$ git push my_origin my_brach

这样,您现在可以访问 GitHub 的 Web 用户界面,并在不污染实际原始代码的情况下打开拉取请求。

总结

在这第一章之后,您必须熟悉 Go 的语法和一些与编译器捆绑在一起的命令行工具。我们将并发能力留到后面的章节,因为它们在一开始就很庞大和复杂,所以读者首先要学习语言的语法,熟悉并对其有信心,然后才能开始理解通信顺序进程CSP)并发模式和分布式应用程序。接下来的步骤是从创建设计模式开始。

第二章:创建模式-单例、生成器、工厂、原型和抽象工厂设计模式

我们定义了两种类型的汽车-豪华和家庭。汽车工厂将返回我们将要涵盖的第一组设计模式是创建模式。顾名思义,它将常见的创建对象的实践分组在一起,因此对象的创建更多地封装在需要这些对象的用户之外。主要是,创建模式试图为用户提供可直接使用的对象,而不是要求它们的创建,这在某些情况下可能是复杂的,或者会将您的代码与应该在接口中定义的功能的具体实现耦合在一起。

单例设计模式-在整个程序中具有唯一实例的类型

你有没有为软件工程师做过面试?有趣的是,当你问他们关于设计模式时,超过 80%的人会提到Singleton设计模式。为什么呢?也许是因为它是最常用的设计模式之一,或者是最容易理解的设计模式之一。由于后一种原因,我们将从创建型设计模式开始我们的旅程。

描述

单例模式很容易记住。顾名思义,它将为您提供对象的单一实例,并保证没有重复。

在第一次调用实例时,它被创建,然后在应用程序中需要使用特定行为的所有部分之间重复使用。

您将在许多不同的情况下使用单例模式。例如:

  • 当您想要使用相同的数据库连接来进行每次查询时

  • 当您打开安全外壳SSH)连接到服务器以执行一些任务,并且不想为每个任务重新打开连接时

  • 如果需要限制对某个变量或空间的访问,可以使用 Singleton 作为访问该变量的门(在接下来的章节中,我们将看到在 Go 中使用通道更容易实现这一点)

  • 如果需要限制对某些地方的调用次数,可以创建一个 Singleton 实例来在接受的窗口中进行调用

可能性是无穷无尽的,我们只是提到了其中一些。

目标

作为一般指南,我们考虑在以下规则适用时使用 Singleton 模式:

  • 我们需要一个特定类型的单一共享值。

  • 我们需要将某种类型的对象创建限制为整个程序中的单个单元。

示例-唯一计数器

作为我们必须确保只有一个实例的对象的示例,我们将编写一个计数器,它保存程序执行期间调用的次数。无论我们有多少计数器实例,它们都必须计数相同的值,并且在实例之间必须保持一致。

需求和验收标准

编写所述的单一计数器有一些要求和验收标准。它们如下:

  • 当之前没有创建计数器时,将创建一个新的计数器,其值为 0

  • 如果计数器已经被创建,返回持有实际计数的实例

  • 如果我们调用方法AddOne,计数必须增加 1

我们有三个测试场景要在我们的单元测试中检查。

首先编写单元测试

Go 对这种模式的实现与您在 Java 或 C++等纯面向对象语言中找到的实现略有不同,那里有静态成员。在 Go 中,没有静态成员,但我们有包范围来提供类似的结果。

为了设置我们的项目,我们必须在我们的$GOPATH/src目录中创建一个新文件夹。正如我们在第一章中提到的一般规则,准备...开始...Go!,是创建一个带有 VCS 提供者(如 GitHub)、用户名和项目名称的子文件夹。

例如,在我的情况下,我使用 GitHub 作为我的 VCS,我的用户名是sayden,所以我将创建路径$GOPATH/src/github.com/sayden/go-design-patterns/creational/singleton。路径中的go-design-patterns是项目名称,creational 子文件夹也将是我们的库名称,singleton 是此特定包和子文件夹的名称:

mkdir -p $GOPATH/src/github.com/sayden/go-design-patterns/creational/singleton 
cd $GOPATH/src/github.com/sayden/go-design-
patterns/creational/singleton

在 singleton 文件夹内创建一个名为singleton.go的新文件,以反映包的名称,并编写以下singleton类型的包声明:

package singleton 

type Singleton interface { 
    AddOne() int 
} 

type singleton struct { 
    count int 
} 

var instance *singleton 

func GetInstance() Singleton { 
    return nil 
} 
func (s *singleton) AddOne() int { 
    return 0 
} 

由于我们在编写代码时遵循 TDD 方法,让我们编写使用我们刚刚声明的函数的测试。测试将根据我们之前编写的验收标准来定义。按照测试文件的惯例,我们必须创建一个与要测试的文件同名的文件,后缀为_test.go。两者必须驻留在同一个文件夹中:

package singleton 

import "testing" 

func TestGetInstance(t *testing.T) { 
   counter1 := GetInstance() 

   if counter1 == nil { 
         //Test of acceptance criteria 1 failed 
         t.Error("expected pointer to Singleton after calling GetInstance(), not nil") 
   } 

   expectedCounter := counter1 
} 

第一个测试检查了复杂应用程序中显而易见但同样重要的事情。当我们请求计数器的实例时,我们实际上收到了一些东西。我们必须将其视为一个创建模式——我们将对象的创建委托给一个可能在对象的创建或检索中失败的未知包。我们还将当前计数器存储在expectedCounter变量中,以便稍后进行比较:

currentCount := counter1.AddOne() 
if currentCount != 1 { 
     t.Errorf("After calling for the first time to count, the count must be 1 but it is %d\n", currentCount) 
} 

现在我们利用 Go 的零初始化特性。请记住,Go 中的整数类型不能为 nil,而且我们知道这是对计数器的第一次调用,它是一个整数类型的变量,我们也知道它是零初始化的。因此,在对AddOne()函数的第一次调用之后,计数的值必须为 1。

检查第二个条件的测试证明了expectedConnection变量与我们稍后请求的返回连接没有不同。如果它们不同,消息Singleton instances must be different将导致测试失败:

counter2 := GetInstance() 
if counter2 != expectedCounter { 
    //Test 2 failed 
    t.Error("Expected same instance in counter2 but it got a different instance") 
} 

最后的测试只是再次计数 1,使用第二个实例。之前的结果是 1,所以现在必须给我们 2:

currentCount = counter2.AddOne() 
if currentCount != 2 { 
    t.Errorf("After calling 'AddOne' using the second counter, the current count must be 2 but was %d\n", currentCount) 
} 

完成测试部分的最后一件事是执行测试,以确保它们在实施之前失败。如果其中一个没有失败,那就意味着我们做错了什么,我们必须重新考虑那个特定的测试。我们必须打开终端并导航到 singleton 包的路径以执行:

$ go test -v .
=== RUN   TestGetInstance
--- FAIL: TestGetInstance (0.00s)
 singleton_test.go:9: expected pointer to Singleton after calling GetInstance(), not nil
 singleton_test.go:15: After calling for the first time to count, the count must be 1 but it is 0
 singleton_test.go:27: After calling 'AddOne' using the second counter, the current count must be 2 but was 0
FAIL
exit status 1
FAIL

实现

最后,我们必须实现单例模式。正如我们之前提到的,通常会在诸如 Java 或 C++之类的语言中编写一个static方法和实例来检索单例实例。在 Go 中,我们没有关键字static,但我们可以通过使用包的作用域来实现相同的结果。首先,我们创建一个包含我们希望在程序执行期间保证为单例的对象的struct

package creational 

type singleton struct{ 
    count int 
} 

var instance *singleton 

func GetInstance() *singleton { 
    if instance == nil { 
        instance = new(singleton) 
    }  
    return instance 
} 

func (s *singleton) AddOne() int { 
    s.count++ 
    return s.count 
} 

我们必须密切关注这段代码。在诸如 Java 或 C++之类的语言中,变量实例将在程序开始时初始化为 NULL。在 Go 中,您可以将指向结构的指针初始化为nil,但不能将结构初始化为nil(相当于 NULL)。因此,var instance *singleton行将指针定义为 nil 的 Singleton 类型结构,并命名为instance

我们创建了一个GetInstance方法,检查实例是否已经初始化(instance == nil),并在instance = new(singleton)行中创建一个实例。记住,当我们使用关键字new时,我们正在创建指向括号内类型的实例的指针。

AddOne方法将获取变量实例的计数,将其增加 1,并返回计数器的当前值。

让我们现在再次运行我们的单元测试:

$ go test -v -run=GetInstance
=== RUN   TestGetInstance
--- PASS: TestGetInstance (0.00s)
PASS
ok

关于单例设计模式的一些话

我们已经看到了单例模式的一个非常简单的例子,部分应用于某些情况,即一个简单的计数器。只需记住,单例模式将使您能够在应用程序中拥有某个结构的唯一实例,并且没有任何包可以创建此结构的任何克隆。

使用单例,您还隐藏了创建对象的复杂性,以防它需要一些计算,并且如果它们都是相似的话,每次需要一个实例时都要创建它的陷阱。所有这些代码编写、检查变量是否已经存在和存储都封装在单例中,如果使用全局变量,则无需在每个地方重复。

在这里,我们正在学习单线程上的经典单例实现。当我们到达有关并发的章节时,我们将看到并发单例实现,因为这种实现不是线程安全的!

建造者设计模式 - 重用算法来创建接口的多个实现

谈到创建型设计模式,拥有建造者设计模式似乎非常合理。建造者模式帮助我们构建复杂的对象,而不是直接实例化它们的结构,或编写它们所需的逻辑。想象一个对象可能有数十个字段,这些字段本身是更复杂的结构。现在想象一下,您有许多具有这些特征的对象,而且您可能还有更多。我们不想在只需要使用这些对象的包中编写创建所有这些对象的逻辑。

描述

实例创建可以简单到提供开放和关闭大括号{}并将实例留有零值,也可以复杂到需要进行一些 API 调用、检查状态并为其字段创建对象的对象。您还可以拥有由许多对象组成的对象,这在 Go 中非常惯用,因为它不支持继承。

同时,您可以使用相同的技术来创建许多类型的对象。例如,您将使用几乎相同的技术来构建汽车和构建公共汽车,只是它们的大小和座位数不同,那么为什么不重用构建过程呢?这就是建造者模式发挥作用的地方。

目标

建造者设计模式尝试:

  • 抽象复杂的创建,使对象创建与对象用户分离

  • 通过填充其字段和创建嵌入对象逐步创建对象

  • 在许多对象之间重用对象创建算法

示例 - 车辆制造

建造者设计模式通常被描述为导演、几个建造者和他们构建的产品之间的关系。继续我们的汽车示例,我们将创建一个车辆建造者。创建车辆(产品)的过程(通常描述为算法)对于每种类型的车辆来说基本相同 - 选择车辆类型、组装结构、放置轮子和放置座位。如果您仔细想想,您可以使用此描述构建汽车和摩托车(两个建造者),因此我们正在重用描述来在制造中创建汽车。在我们的示例中,导演由ManufacturingDirector类型表示。

需求和验收标准

就我们所描述的而言,我们必须处理CarMotorbike类型的建造者以及一个名为ManufacturingDirector的唯一导演,以接受建造者并构建产品。因此,Vehicle建造者示例的要求如下:

  • 我必须有一个制造类型,可以构造车辆所需的一切

  • 使用汽车建造者时,必须返回具有四个轮子、五个座位和结构定义为CarVehicleProduct

  • 使用摩托车建造者时,必须返回具有两个轮子、两个座位和结构定义为MotorbikeVehicleProduct

  • 由任何BuildProcess建造者构建的VehicleProduct必须可以进行修改

车辆建造者的单元测试

根据先前的验收标准,我们将创建一个主管变量,即ManufacturingDirector类型,以使用产品建造者变量表示的汽车和摩托车的构建过程。主管负责构建对象,但建造者返回实际的车辆。因此,我们的建造者声明将如下所示:

package creational 

type BuildProcess interface { 
    SetWheels() BuildProcess 
    SetSeats() BuildProcess 
    SetStructure() BuildProcess 
    GetVehicle() VehicleProduct 
} 

上述接口定义了构建车辆所需的步骤。如果要被制造使用,每个建造者都必须实现这个interface。在每个Set步骤上,我们返回相同的构建过程,因此我们可以在同一语句中链接各种步骤,正如我们将在后面看到的。最后,我们需要一个GetVehicle方法来从建造者中检索Vehicle实例:

type ManufacturingDirector struct {} 

func (f *ManufacturingDirector) Construct() { 
    //Implementation goes here 
} 

func (f *ManufacturingDirector) SetBuilder(b BuildProcess) { 
    //Implementation goes here 
} 

ManufacturingDirector主管变量负责接受建造者。它有一个Construct方法,将使用存储在Manufacturing中的建造者,并复制所需的步骤。SetBuilder方法将允许我们更改在Manufacturing主管中使用的建造者:

type VehicleProduct struct { 
    Wheels    int 
    Seats     int 
    Structure string 
} 

产品是我们在使用制造时要检索的最终对象。在这种情况下,车辆由车轮、座位和结构组成:

type CarBuilder struct {} 

func (c *CarBuilder) SetWheels() BuildProcess { 
    return nil 
} 

func (c *CarBuilder) SetSeats() BuildProcess { 
    return nil 
} 

func (c *CarBuilder) SetStructure() BuildProcess { 
    return nil 
} 

func (c *CarBuilder) Build() VehicleProduct { 
    return VehicleProduct{} 
} 

第一个建造者是Car建造者。它必须实现BuildProcess接口中定义的每个方法。这是我们为这个特定建造者设置信息的地方:

type BikeBuilder struct {} 

func (b *BikeBuilder) SetWheels() BuildProcess { 
    return nil 
} 

func (b *BikeBuilder) SetSeats() BuildProcess { 
    return nil 
} 

func (b *BikeBuilder) SetStructure() BuildProcess { 
    return nil 
} 

func (b *BikeBuilder) Build() VehicleProduct { 
    return VehicleProduct{} 
} 

Motorbike结构必须与Car结构相同,因为它们都是建造者实现,但请记住,构建每个的过程可能会有很大的不同。有了这些对象的声明,我们可以创建以下测试:

package creational 

import "testing" 

func TestBuilderPattern(t *testing.T) { 
    manufacturingComplex := ManufacturingDirector{} 

    carBuilder := &CarBuilder{} 
    manufacturingComplex.SetBuilder(carBuilder) 
    manufacturingComplex.Construct() 

    car := carBuilder.Build() 

    //code continues here... 

我们将从Manufacturing主管和Car建造者开始,以满足前两个验收标准。在上述代码中,我们正在创建我们的Manufacturing主管,它将负责在测试期间创建每辆车辆。创建Manufacturing主管后,我们创建了一个CarBuilder,然后通过使用SetBuilder方法将其传递给制造。一旦Manufacturing主管知道现在它必须构建什么,我们就可以调用Construct方法来使用CarBuilder创建VehicleProduct。最后,一旦我们为我们的汽车准备好所有零件,我们就调用CarBuilder上的GetVehicle方法来检索Car实例:

if car.Wheels != 4 { 
    t.Errorf("Wheels on a car must be 4 and they were %d\n", car.Wheels) 
} 

if car.Structure != "Car" { 
    t.Errorf("Structure on a car must be 'Car' and was %s\n", car.Structure) 
} 

if car.Seats != 5 { 
    t.Errorf("Seats on a car must be 5 and they were %d\n", car.Seats) 
} 

我们编写了三个小测试来检查结果是否为汽车。我们检查汽车是否有四个车轮,结构是否具有描述Car,座位数为五。我们有足够的数据来执行测试,并确保它们失败,以便我们可以认为它们是可靠的:

$ go test -v -run=TestBuilder .
=== RUN   TestBuilderPattern
--- FAIL: TestBuilderPattern (0.00s)
 builder_test.go:15: Wheels on a car must be 4 and they were 0
 builder_test.go:19: Structure on a car must be 'Car' and was
 builder_test.go:23: Seats on a car must be 5 and they were 0
FAIL

太好了!现在我们将为Motorbike建造者创建测试,以满足第三和第四个验收标准:

bikeBuilder := &BikeBuilder{} 

manufacturingComplex.SetBuilder(bikeBuilder) 
manufacturingComplex.Construct() 

motorbike := bikeBuilder.GetVehicle() 
motorbike.Seats = 1 

if motorbike.Wheels != 2 { 
    t.Errorf("Wheels on a motorbike must be 2 and they were %d\n", motorbike.Wheels) 
} 

if motorbike.Structure != "Motorbike" { 
    t.Errorf("Structure on a motorbike must be 'Motorbike' and was %s\n", motorbike.Structure) 
} 

上述代码是对汽车测试的延续。正如您所看到的,我们重用了先前创建的制造来通过将Motorbike建造者传递给它来创建摩托车。然后我们再次点击construct按钮来创建必要的零件,并调用建造者的GetVehicle方法来检索摩托车实例。

快速浏览一下,因为我们已经将这辆摩托车的默认座位数更改为 1。我们想要展示的是,即使有了建造者,您也必须能够更改返回实例中的默认信息,以满足某些特定需求。由于我们手动设置了车轮,我们不会测试这个功能。

重新运行测试会触发预期的行为:

$ go test -v -run=Builder .
=== RUN   TestBuilderPattern
--- FAIL: TestBuilderPattern (0.00s)
 builder_test.go:15: Wheels on a car must be 4 and they were 0
 builder_test.go:19: Structure on a car must be 'Car' and was
 builder_test.go:23: Seats on a car must be 5 and they were 0
 builder_test.go:35: Wheels on a motorbike must be 2 and they were 0
 builder_test.go:39: Structure on a motorbike must be 'Motorbike' and was
FAIL

实施

我们将开始实施制造。正如我们之前所说(并且在我们的单元测试中设置),“制造”总监必须接受一个建造者并使用提供的建造者构建车辆。回想一下,BuildProcess接口将定义构建任何车辆所需的常见步骤,“制造”总监必须接受建造者并与他们一起构建车辆:

package creational 

type ManufacturingDirector struct { 
    builder BuildProcess 
} 

func (f *ManufacturingDirector) SetBuilder(b BuildProcess) { 
    f.builder = b 
} 

func (f *ManufacturingDirector) Construct() { 
    f.builder.SetSeats().SetStructure().SetWheels() 
} 

我们的“制造总监”需要一个字段来存储正在使用的建造者;这个字段将被称为“建造者”。 SetBuilder方法将用参数中提供的建造者替换存储的建造者。最后,仔细看一下“构造”方法。它接受已存储的建造者并重现将创建某种未知类型的完整车辆的BuildProcess方法。正如您所看到的,我们已经在同一行中使用了所有设置调用,这要归功于在每个调用上返回BuildProcess接口。这样代码更加紧凑:

提示

您是否意识到建造者模式中的总监实体也是单例模式的明显候选者?在某些情况下,只有总监的一个实例可用可能非常关键,这就是您将为建造者的总监创建单例模式的地方。设计模式组合是一种非常常见且非常强大的技术!

type CarBuilder struct { 
    v VehicleProduct 
} 

func (c *CarBuilder) SetWheels() BuildProcess { 
    c.v.Wheels = 4 
    return c 
} 

func (c *CarBuilder) SetSeats() BuildProcess { 
    c.v.Seats = 5 
    return c 
} 

func (c *CarBuilder) SetStructure() BuildProcess { 
    c.v.Structure = "Car" 
    return c 
} 

func (c *CarBuilder) GetVehicle() VehicleProduct { 
    return c.v 
} 

这是我们的第一个建造者,“汽车”建造者。一个建造者将需要存储一个名为vVehicleProduct对象。然后我们设置了我们业务中汽车的特定需求-四个轮子,五个座位和一个名为“汽车”的结构。在GetVehicle方法中,我们只返回建造者内部存储的VehicleProduct,这个产品必须已经由“制造总监”类型构建。

type BikeBuilder struct { 
    v VehicleProduct 
} 

func (b *BikeBuilder) SetWheels() BuildProcess { 
    b.v.Wheels = 2 
    return b 
} 

func (b *BikeBuilder) SetSeats() BuildProcess { 
    b.v.Seats = 2 
    return b 
} 

func (b *BikeBuilder) SetStructure() BuildProcess { 
    b.v.Structure = "Motorbike" 
    return b 
} 

func (b *BikeBuilder) GetVehicle() VehicleProduct { 
    return b.v 
} 

“摩托车”建造者与“汽车”建造者相同。我们定义了摩托车有两个轮子,两个座位和一个名为“摩托车”的结构。它与“汽车”对象非常相似,但想象一下,您想要区分运动摩托车(只有一个座位)和巡航摩托车(有两个座位)。您可以简单地为运动摩托车创建一个实现构建过程的新结构。

您可以看到这是一个重复的模式,但在BuildProcess接口的每个方法的范围内,您可以封装尽可能多的复杂性,以便用户不需要了解有关对象创建的详细信息。

有了所有对象的定义,让我们再次运行测试:

=== RUN   TestBuilderPattern
--- PASS: TestBuilderPattern (0.00s)
PASS
ok  _/home/mcastro/pers/go-design-patterns/creational 0.001s

干得好!想象一下向“制造总监”添加新车辆有多么容易,只需创建一个封装新车辆数据的新类。例如,让我们添加一个BusBuilder结构:

type BusBuilder struct { 
    v VehicleProduct 
} 

func (b *BusBuilder) SetWheels() BuildProcess { 
    b.v.Wheels = 4*2 
    return b 
} 

func (b *BusBuilder) SetSeats() BuildProcess { 
    b.v.Seats = 30 
    return b 
} 

func (b *BusBuilder) SetStructure() BuildProcess { 
    b.v.Structure = "Bus" 
    return b 
} 

func (b *BusBuilder) GetVehicle() VehicleProduct { 
    return b.v 
} 

就是这样;通过遵循建造者设计模式,您的“制造总监”将准备好使用新产品。

封装建造者设计模式

建造者设计模式通过使用总监使用的通用构建算法来帮助我们维护不可预测数量的产品。构建过程始终从产品的用户中抽象出来。

同时,当我们的源代码新手需要向管道添加新产品时,拥有定义的构建模式会有所帮助。BuildProcess接口指定了他必须遵守以成为可能的建造者的一部分。

但是,当您不完全确定算法是否会更加稳定时,请尽量避免使用建造者模式,因为此接口的任何小更改都将影响到所有建造者,如果您添加一种新方法,有些建造者需要它,而其他建造者则不需要,这可能会很尴尬。

工厂方法-委托创建不同类型的付款

工厂方法模式(或简称工厂)可能是行业中第二为人熟知和使用的设计模式。它的目的是将用户与他需要为特定目的实现的结构体的知识抽象出来,比如从网络服务或数据库中检索一些值。用户只需要一个提供这个值的接口。通过将这个决定委托给工厂,这个工厂可以提供适合用户需求的接口。如果需要,它还可以简化底层类型的实现的降级或升级过程。

描述

使用工厂方法设计模式时,我们获得了一个额外的封装层,以便我们的程序可以在受控环境中增长。通过工厂方法,我们将对象族的创建委托给不同的包或对象,以使我们抽象出我们可以使用的可能对象池的知识。想象一下,您想要使用旅行社组织您的假期。您不需要处理酒店和旅行,只需告诉旅行社您感兴趣的目的地,他们将为您提供一切所需。旅行社代表了旅行的工厂。

目标

在前面的描述之后,工厂方法设计模式的以下目标必须对您清晰:

  • 将新实例的结构的创建委托给程序的不同部分

  • 在接口级别工作,而不是使用具体的实现

  • 将对象族分组以获得一个对象族创建者

示例-商店的支付方法工厂

对于我们的例子,我们将实现一个支付方法工厂,它将为我们提供在商店支付的不同方式。一开始,我们将有两种支付方式--现金和信用卡。我们还将有一个带有Pay方法的接口,每个想要用作支付方法的结构体都必须实现。

验收标准

使用前述描述,验收标准的要求如下:

  • 拥有一个称为Pay的每种支付方法的通用方法

  • 为了能够将支付方法的创建委托给工厂

  • 能够通过将其添加到工厂方法来将更多的支付方法添加到库中

第一个单元测试

工厂方法有一个非常简单的结构;我们只需要确定我们存储了多少个接口的实现,然后提供一个GetPaymentMethod方法,您可以将支付类型作为参数传递:

type PaymentMethod interface { 
    Pay(amount float32) string 
} 

前面的行定义了支付方法的接口。它们定义了在商店支付的方式。工厂方法将返回实现此接口的类型的实例:

const ( 
    Cash      = 1 
    DebitCard = 2 
) 

我们必须将工厂的已识别支付方法定义为常量,以便我们可以从包外部调用和检查可能的支付方法。

func GetPaymentMethod(m int) (PaymentMethod, error) { 
    return nil, errors.New("Not implemented yet") 
} 

前面的代码是将为我们创建对象的函数。它返回一个指针,必须有一个实现PaymentMethod接口的对象,并且如果要求一个未注册的方法,则返回一个错误。

type CashPM struct{} 
type DebitCardPM struct{} 

func (c *CashPM) Pay(amount float32) string { 
    return "" 
} 

func (c *DebitCardPM) Pay(amount float32) string { 
    return "" 
} 

为了完成工厂的声明,我们创建了两种支付方法。正如您所看到的,CashPMDebitCardPM结构体通过声明一个Pay(amount float32) string方法来实现PaymentMethod接口。返回的字符串将包含有关支付的信息。

有了这个声明,我们将从编写第一个验收标准的测试开始:拥有一个通用方法来检索实现PaymentMethod接口的对象:

package creational 

import ( 
    "strings" 
    "testing" 
) 

func TestCreatePaymentMethodCash(t *testing.T) { 
    payment, err := GetPaymentMethod(Cash) 
    if err != nil { 
        t.Fatal("A payment method of type 'Cash' must exist") 
    } 

    msg := payment.Pay(10.30) 
    if !strings.Contains(msg, "paid using cash") { 
        t.Error("The cash payment method message wasn't correct") 
    } 
    t.Log("LOG:", msg) 
} 

现在我们将把测试分成几个测试函数。GetPaymentMethod是一个常用的检索支付方式的方法。我们使用常量Cash,我们已经在实现文件中定义了它(如果我们在包的范围之外使用这个常量,我们将使用包的名称作为前缀来调用它,所以语法将是creational.Cash)。我们还检查在请求支付方式时是否没有收到错误。请注意,如果我们在请求支付方式时收到错误,我们将调用t.Fatal来停止测试的执行;如果我们像之前的测试一样只调用t.Error,那么当我们尝试访问 nil 对象的Pay方法时,我们将在下一行中遇到问题,我们的测试将崩溃执行。我们继续通过将 10.30 作为金额传递给接口的Pay方法来使用接口。返回的消息将包含文本paid using casht.Log(string)方法是测试中的一个特殊方法。这个结构允许我们在运行测试时写一些日志,如果我们传递了-v标志。

func TestGetPaymentMethodDebitCard(t *testing.T) { 
    payment, err = GetPaymentMethod(Debit9Card) 

    if err != nil { 
        t.Error("A payment method of type 'DebitCard' must exist")
    } 

    msg = payment.Pay(22.30) 

    if !strings.Contains(msg, "paid using debit card") { 
        t.Error("The debit card payment method message wasn't correct") 
    } 

    t.Log("LOG:", msg) 
}

我们用相同的方法重复相同的操作。我们请求使用常量DebitCard定义的支付方式,当使用借记卡支付时,返回的消息必须包含paid using debit card字符串。


func TestGetPaymentMethodNonExistent(t *testing.T) { 
    payment, err = GetPaymentMethod(20) 

    if err == nil { 
        t.Error("A payment method with ID 20 must return an error") 
    } 
    t.Log("LOG:", err) 
}

最后,我们将测试请求一个不存在的支付方式的情况(用数字 20 表示,它与工厂中的任何已识别的常量都不匹配)。当请求未知的支付方式时,我们将检查是否返回了错误消息(任何错误消息)。

让我们检查一下所有的测试是否都失败了:

$ go test -v -run=GetPaymentMethod .
=== RUN   TestGetPaymentMethodCash
--- FAIL: TestGetPaymentMethodCash (0.00s)
 factory_test.go:11: A payment method of type 'Cash' must exist
=== RUN   TestGetPaymentMethodDebitCard
--- FAIL: TestGetPaymentMethodDebitCard (0.00s)
 factory_test.go:24: A payment method of type 'DebitCard' must exist
=== RUN   TestGetPaymentMethodNonExistent
--- PASS: TestGetPaymentMethodNonExistent (0.00s)
 factory_test.go:38: LOG: Not implemented yet
FAIL
exit status 1
FAIL

正如您在这个例子中所看到的,我们只能看到返回PaymentMethod接口的测试失败。在这种情况下,我们将不得不实现代码的一部分,然后再次进行测试,然后才能继续。

实施

我们将从GetPaymentMethod方法开始。它必须接收一个与同一文件中定义的常量匹配的整数,以知道应该返回哪种实现。

package creational 

import ( 
    "errors" 
    "fmt" 
) 

type PaymentMethod interface { 
    Pay(amount float32) string 
} 

const ( 
    Cash      = 1 
    DebitCard = 2 
) 

type CashPM struct{} 
type DebitCardPM struct{} 

func GetPaymentMethod(m int) (PaymentMethod, error) { 
    switch m { 
        case Cash: 
        return new(CashPM), nil 
        case DebitCard: 
        return new(DebitCardPM), nil 
        default: 
        return nil, errors.New(fmt.Sprintf("Payment method %d not recognized\n", m)) 
    } 
} 

我们使用一个普通的 switch 来检查参数m(方法)的内容。如果它匹配任何已知的方法--现金或借记卡,它将返回它们的新实例。否则,它将返回一个 nil 和一个指示支付方式未被识别的错误。现在我们可以再次运行我们的测试,以检查单元测试的第二部分:

$go test -v -run=GetPaymentMethod .
=== RUN   TestGetPaymentMethodCash
--- FAIL: TestGetPaymentMethodCash (0.00s)
 factory_test.go:16: The cash payment method message wasn't correct
 factory_test.go:18: LOG:
=== RUN   TestGetPaymentMethodDebitCard
--- FAIL: TestGetPaymentMethodDebitCard (0.00s)
 factory_test.go:28: The debit card payment method message wasn't correct
 factory_test.go:30: LOG:
=== RUN   TestGetPaymentMethodNonExistent
--- PASS: TestGetPaymentMethodNonExistent (0.00s)
 factory_test.go:38: LOG: Payment method 20 not recognized
FAIL
exit status 1
FAIL

现在我们不会再收到找不到支付方式类型的错误,而是在尝试使用它所涵盖的任何方法时,会收到“消息不正确”的错误。当我们请求一个未知的支付方式时,我们也摆脱了“未实现”的消息。现在让我们实现结构体:

type CashPM struct{} 
type DebitCardPM struct{} 

func (c *CashPM) Pay(amount float32) string { 
     return fmt.Sprintf("%0.2f paid using cash\n", amount) 
} 

func (c *DebitCardPM) Pay(amount float32) string { 
     return fmt.Sprintf("%#0.2f paid using debit card\n", amount) 
} 

我们只是得到金额,以一个格式良好的消息打印出来。有了这个实现,现在所有的测试都会通过:

$ go test -v -run=GetPaymentMethod .
=== RUN   TestGetPaymentMethodCash
--- PASS: TestGetPaymentMethodCash (0.00s)
 factory_test.go:18: LOG: 10.30 paid using cash
=== RUN   TestGetPaymentMethodDebitCard
--- PASS: TestGetPaymentMethodDebitCard (0.00s)
 factory_test.go:30: LOG: 22.30 paid using debit card
=== RUN   TestGetPaymentMethodNonExistent
--- PASS: TestGetPaymentMethodNonExistent (0.00s)
 factory_test.go:38: LOG: Payment method 20 not recognized
PASS
ok

您看到了LOG:消息吗?它们不是错误,我们只是打印一些在使用被测试的包时收到的信息。除非您将-v标志传递给测试命令,否则可以省略这些消息:

$ go test -run=GetPaymentMethod .
ok

将借记卡方法升级到新平台

现在想象一下,由于某种原因,您的DebitCard支付方式已经更改,您需要一个新的结构。为了实现这种情况,您只需要创建新的结构,并在用户请求DebitCard支付方式时替换旧的结构。

type CreditCardPM struct {} 
 func (d *CreditCardPM) Pay(amount float32) string { 
   return fmt.Sprintf("%#0.2f paid using new credit card implementation\n", amount) 
} 

这是我们将替换DebitCardPM结构的新类型。CreditCardPM实现了与借记卡相同的PaymentMethod接口。我们没有删除以后可能需要的旧结构。唯一的区别在于返回的消息现在包含了关于新类型的信息。我们还必须修改检索支付方式的方法:

func GetPaymentMethod(m int) (PaymentMethod, error) { 
    switch m { 
        case Cash: 
        return new(CashPM), nil 
        case DebitCard: 
        return new(CreditCardPM), nil 
        default: 
        return nil, errors.New(fmt.Sprintf("Payment method %d not recognized\n", m)) 
   } 
} 

唯一的修改是在创建新的借记卡的那一行,现在指向新创建的结构。让我们运行测试,看看一切是否仍然正确:

$ go test -v -run=GetPaymentMethod .
=== RUN   TestGetPaymentMethodCash
--- PASS: TestGetPaymentMethodCash (0.00s)
 factory_test.go:18: LOG: 10.30 paid using cash
=== RUN   TestGetPaymentMethodDebitCard
--- FAIL: TestGetPaymentMethodDebitCard (0.00s)
 factory_test.go:28: The debit card payment method message wasn't correct
 factory_test.go:30: LOG: 22.30 paid using new debit card implementation
=== RUN   TestGetPaymentMethodNonExistent
--- PASS: TestGetPaymentMethodNonExistent (0.00s)
 factory_test.go:38: LOG: Payment method 20 not recognized
FAIL
exit status 1
FAIL

哦,糟糕!出了些问题。使用信用卡支付时返回的预期消息与返回的消息不匹配。这是否意味着我们的代码不正确?一般来说,是的,你不应该修改你的测试来使你的程序工作。在定义测试时,你还应该注意不要定义得太多,因为这样你可能会在测试中实现一些你的代码中没有的耦合。由于消息限制,我们有一些语法上正确的消息可能性,所以我们将把它改为以下内容:

return fmt.Sprintf("%#0.2f paid using debit card (new)\n", amount) 

现在我们再次运行测试:

$ go test -v -run=GetPaymentMethod .
=== RUN   TestGetPaymentMethodCash
--- PASS: TestGetPaymentMethodCash (0.00s)
 factory_test.go:18: LOG: 10.30 paid using cash
=== RUN   TestGetPaymentMethodDebitCard
--- PASS: TestGetPaymentMethodDebitCard (0.00s)
 factory_test.go:30: LOG: 22.30 paid using debit card (new)
=== RUN   TestGetPaymentMethodNonExistent
--- PASS: TestGetPaymentMethodNonExistent (0.00s)
 factory_test.go:38: LOG: Payment method 20 not recognized
PASS
ok

一切又恢复正常了。这只是一个写好单元测试的小例子。当我们想要检查使用借记卡支付方法返回的消息是否包含“使用借记卡支付”字符串时,我们可能有点过于严格,最好分别检查这些单词,或者定义一个更好的格式来返回消息。

我们从工厂方法中学到了什么

通过工厂方法模式,我们已经学会了如何将对象家族分组,使其实现在我们的范围之外。我们还学会了在需要升级已使用结构的实现时该怎么做。最后,我们已经看到,如果你不想将自己与与测试无关的某些实现绑定在一起,那么测试必须小心编写。

抽象工厂 - 工厂的工厂

在学习了工厂设计模式之后,我们将一个相关对象家族(在我们的例子中是支付方法)进行了分组,人们很快就会想到——如果我将对象家族分组到一个更有结构的家族层次结构中会怎样?

描述

抽象工厂设计模式是一种新的分组层,用于实现一个更大(和更复杂)的复合对象,通过它的接口来使用。将对象分组到家族中并将家族分组的想法是拥有可以互换并且更容易扩展的大工厂。在开发的早期阶段,使用工厂和抽象工厂比等到所有具体实现都完成后再开始编写代码要更容易。此外,除非你知道你的特定领域的对象库将非常庞大并且可以轻松地分组到家族中,否则你不会从一开始就编写抽象工厂。

目标

当你的对象数量增长到需要创建一个唯一的点来获取它们时,将相关的对象组合在一起是非常方便的,这样可以获得运行时对象创建的灵活性。抽象工厂方法的以下目标必须对你清晰明了:

  • 为返回所有工厂的通用接口提供新的封装层

  • 将常见工厂组合成一个超级工厂(也称为工厂的工厂)

车辆工厂的例子,又来了?

对于我们的例子,我们将重用我们在生成器设计模式中创建的工厂。我们想展示使用不同方法解决相同问题的相似之处,以便你可以看到每种方法的优势和劣势。这将向你展示 Go 中隐式接口的强大之处,因为我们几乎不用改动任何东西。最后,我们将创建一个新的工厂来创建装运订单。

验收标准

以下是使用“车辆”对象工厂方法的验收标准:

  • 我们必须使用抽象工厂返回的工厂来检索“车辆”对象。

  • 车辆必须是“摩托车”或“汽车”的具体实现,它实现了两个接口(“车辆”和“汽车”或“车辆”和“摩托车”)。

单元测试

这将是一个很长的例子,所以请注意。我们将有以下实体:

  • 车辆:我们工厂中的所有对象必须实现的接口:

  • 摩托车:一种摩托车的接口,类型为运动(一座)和巡航(两座)。

  • 汽车:用于豪华车(四门)和家庭车(五门)的汽车接口。

  • VehicleFactory:一个接口(抽象工厂),用于检索实现VehicleFactory方法的工厂:

  • 摩托车工厂:一个实现VehicleFactory接口的工厂,返回实现VehicleMotorbike接口的车辆。

  • 汽车工厂:另一个实现VehicleFactory接口的工厂,返回实现VehicleCar接口的车辆。

为了清晰起见,我们将每个实体分开放在不同的文件中。我们将从Vehicle接口开始,它将在vehicle.go文件中:

package abstract_factory 

type Vehicle interface { 
    NumWheels() int 
    NumSeats() int 
} 

CarMotorbike接口将分别放在car.gomotorbike.go文件中:

// Package abstract_factory file: car.go 
package abstract_factory 

type Car interface { 
    NumDoors() int 
} 
// Package abstract_factory file: motorbike.go 
package abstract_factory 

type Motorbike interface { 
    GetMotorbikeType() int 
} 

我们有一个最后的接口,每个工厂都必须实现这个接口。这将在vehicle_factory.go文件中:

package abstract_factory 

type VehicleFactory interface { 
    NewVehicle(v int) (Vehicle, error) 
} 

所以,现在我们要声明汽车工厂。它必须实现之前定义的VehicleFactory接口,以返回Vehicles实例:

const ( 
    LuxuryCarType = 1 
    FamilyCarType = 2 
) 

type CarFactory struct{} 
func (c *CarFactory) NewVehicle(v int) (Vehicle, error) { 
    switch v { 
        case LuxuryCarType: 
        return new(LuxuryCar), nil 
        case FamilyCarType: 
        return new(FamilyCar), nil 
        default: 
        return nil, errors.New(fmt.Sprintf("Vehicle of type %d not recognized\n", v)) 
    } 
} 

我们定义了两种类型的汽车--豪华车和家庭车。car工厂将返回实现CarVehicle接口的汽车,因此我们需要两种具体的实现:

//luxury_car.go 
package abstract_factory 

type LuxuryCar struct{} 

func (*LuxuryCar) NumDoors() int { 
    return 4 
} 
func (*LuxuryCar) NumWheels() int { 
    return 4 
} 
func (*LuxuryCar) NumSeats() int { 
    return 5 
} 

package abstract_factory 

type FamilyCar struct{} 

func (*FamilyCar) NumDoors() int { 
    return 5 
} 
func (*FamilyCar) NumWheels() int { 
    return 4 
} 
func (*FamilyCar) NumSeats() int { 
    return 5 
} 

汽车完成了。现在我们需要摩托车工厂,它必须像汽车工厂一样实现VehicleFactory接口:

const ( 
    SportMotorbikeType = 1 
    CruiseMotorbikeType = 2 
) 

type MotorbikeFactory struct{} 

func (m *MotorbikeFactory) Build(v int) (Vehicle, error) { 
    switch v { 
        case SportMotorbikeType: 
        return new(SportMotorbike), nil 
        case CruiseMotorbikeType: 
        return new(CruiseMotorbike), nil 
        default: 
        return nil, errors.New(fmt.Sprintf("Vehicle of type %d not recognized\n", v)) 
    } 
} 

对于摩托车工厂,我们还使用const关键字定义了两种摩托车类型:SportMotorbikeTypeCruiseMotorbikeType。我们将在Build方法中切换v参数,以知道应返回哪种类型。让我们写两种具体的摩托车:

//sport_motorbike.go 
package abstract_factory 

type SportMotorbike struct{} 

func (s *SportMotorbike) NumWheels() int { 
    return 2 
} 
func (s *SportMotorbike) NumSeats() int { 
    return 1 
} 
func (s *SportMotorbike) GetMotorbikeType() int { 
    return SportMotorbikeType 
} 

//cruise_motorbike.go 
package abstract_factory 

type CruiseMotorbike struct{} 

func (c *CruiseMotorbike) NumWheels() int { 
    return 2 
} 
func (c *CruiseMotorbike) NumSeats() int { 
    return 2 
} 
func (c *CruiseMotorbike) GetMotorbikeType() int { 
    return CruiseMotorbikeType 
} 

最后,我们需要抽象工厂本身,我们将把它放在之前创建的vehicle_factory.go文件中。

package abstract_factory 

import ( 
    "fmt" 
    "errors" 
) 

type VehicleFactory interface { 
    Build(v int) (Vehicle, error) 
} 

const ( 
    CarFactoryType = 1 
    MotorbikeFactoryType = 2 
) 

func BuildFactory(f int) (VehicleFactory, error) { 
    switch f { 
        default: 
        return nil, errors.New(fmt.Sprintf("Factory with id %d not recognized\n", f)) 
    } 
}

我们将编写足够的测试来进行可靠的检查,因为本书的范围并不涵盖 100%的语句。这将是一个很好的练习,让读者完成这些测试。首先是motorbike工厂的测试:

package abstract_factory 

import "testing" 

func TestMotorbikeFactory(t *testing.T) { 
    motorbikeF, err := BuildFactory(MotorbikeFactoryType) 
    if err != nil { 
        t.Fatal(err) 
    } 

    motorbikeVehicle, err := motorbikeF.Build(SportMotorbikeType) 
    if err != nil { 
        t.Fatal(err) 
    } 

    t.Logf("Motorbike vehicle has %d wheels\n", motorbikeVehicle.NumWheels()) 

    sportBike, ok := motorbikeVehicle.(Motorbike) 
    if !ok { 
        t.Fatal("Struct assertion has failed") 
    } 
    t.Logf("Sport motorbike has type %d\n", sportBike.GetMotorbikeType()) 
} 

我们使用包方法BuildFactory来检索摩托车工厂(在参数中传递MotorbikeFactory ID),并检查是否有任何错误。然后,已经有了摩托车工厂,我们要求一个SportMotorbikeType类型的车辆,并再次检查错误。通过返回的车辆,我们可以询问车辆接口的方法(NumWheelsNumSeats)。我们知道它是一辆摩托车,但是我们不能在不使用类型断言的情况下询问摩托车的类型。我们使用类型断言在车辆上检索摩托车,代码行sportBike, found := motorbikeVehicle.(Motorbike),我们必须检查我们收到的类型是否正确。

最后,现在我们有了摩托车实例,我们可以使用GetMotorbikeType方法询问摩托车类型。现在我们要编写一个检查汽车工厂的测试:

func TestCarFactory(t *testing.T) { 
    carF, err := BuildFactory(CarFactoryType) 
    if err != nil { 
        t.Fatal(err) 
    } 

    carVehicle, err := carF.Build(LuxuryCarType) 
    if err != nil { 
        t.Fatal(err) 
    } 

    t.Logf("Car vehicle has %d seats\n", carVehicle.NumWheels()) 

    luxuryCar, ok := carVehicle.(Car) 
    if !ok { 
        t.Fatal("Struct assertion has failed") 
    } 
    t.Logf("Luxury car has %d doors.\n", luxuryCar.NumDoors()) 
} 

同样,我们使用BuildFactory方法通过参数中的CarFactoryType来检索Car工厂。使用这个工厂,我们想要一个Luxury类型的汽车,以便返回一个vehicle实例。我们再次进行类型断言,指向汽车实例,以便我们可以使用NumDoors方法询问门的数量。

让我们运行单元测试:

go test -v -run=Factory .
=== RUN   TestMotorbikeFactory
--- FAIL: TestMotorbikeFactory (0.00s)
 vehicle_factory_test.go:8: Factory with id 2 not recognized
=== RUN   TestCarFactory
--- FAIL: TestCarFactory (0.00s)
 vehicle_factory_test.go:28: Factory with id 1 not recognized
FAIL
exit status 1
FAIL 

完成。它无法识别任何工厂,因为它们的实现还没有完成。

实施

出于简洁起见,每个工厂的实施已经完成。它们与工厂方法非常相似,唯一的区别是在工厂方法中,我们不使用工厂方法的实例,因为我们直接使用包函数。vehicle工厂的实现如下:

func BuildFactory(f int) (VehicleFactory, error) { 
    switch f { 
        case CarFactoryType: 
        return new(CarFactory), nil 
        case MotorbikeFactoryType: 
        return new(MotorbikeFactory), nil 
        default: 
        return nil, errors.New(fmt.Sprintf("Factory with id %d not recognized\n", f)) 
    } 
} 

就像在任何工厂中,我们在工厂可能性之间切换,以返回被要求的那个。由于我们已经实现了所有具体的车辆,测试也必须运行:

go test -v -run=Factory -cover .
=== RUN   TestMotorbikeFactory
--- PASS: TestMotorbikeFactory (0.00s)
 vehicle_factory_test.go:16: Motorbike vehicle has 2 wheels
 vehicle_factory_test.go:22: Sport motorbike has type 1
=== RUN   TestCarFactory
--- PASS: TestCarFactory (0.00s)
 vehicle_factory_test.go:36: Car vehicle has 4 seats
 vehicle_factory_test.go:42: Luxury car has 4 doors.
PASS
coverage: 45.8% of statements
ok

所有测试都通过了。仔细观察并注意,我们在运行测试时使用了-cover标志,以返回包的覆盖率百分比:45.8%。这告诉我们的是,45.8%的代码行被我们编写的测试覆盖,但仍有 54.2%没有被测试覆盖。这是因为我们没有用测试覆盖游轮摩托车和家庭汽车。如果你编写这些测试,结果应该会上升到大约 70.8%。

提示

类型断言在其他语言中也被称为转换。当你有一个接口实例时,它本质上是一个指向结构体的指针,你只能访问接口方法。通过类型断言,你可以告诉编译器指向的结构体的类型,这样你就可以访问整个结构体的字段和方法。

关于抽象工厂方法的几行

我们已经学会了如何编写一个工厂的工厂,它为我们提供了一个非常通用的车辆类型对象。这种模式通常用于许多应用程序和库,比如跨平台 GUI 库。想象一个按钮,一个通用对象,以及一个为 Microsoft Windows 按钮提供工厂的按钮工厂,同时你还有另一个为 Mac OS X 按钮提供工厂。你不想处理每个平台的实现细节,而只想为某些特定行为引发的行为实现操作。

此外,我们已经看到了使用两种不同解决方案(抽象工厂和生成器模式)来解决同一个问题时的差异。如你所见,使用生成器模式时,我们有一个无结构的对象列表(在同一个工厂中有汽车和摩托车)。此外,我们鼓励在生成器模式中重用构建算法。在抽象工厂中,我们有一个非常结构化的车辆列表(摩托车工厂和汽车工厂)。我们也没有混合创建汽车和摩托车,提供了更多的灵活性在创建过程中。抽象工厂和生成器模式都可以解决同样的问题,但你的特定需求将帮助你找到应该采用哪种解决方案的细微差别。

原型设计模式

我们将在本章中看到的最后一个模式是原型模式。像所有创建模式一样,当创建对象时,这也非常方便,而且很常见的是原型模式被更多的模式所包围。

在使用生成器模式时,我们处理重复的构建算法;而在工厂模式中,我们简化了许多类型对象的创建;而在原型模式中,我们将使用某种类型的已创建实例进行克隆,并根据每个上下文的特定需求进行完善。让我们详细看一下。

描述

原型模式的目的是在编译时已经创建了一个对象或一组对象,但你可以在运行时克隆它们任意多次。例如,作为刚刚在您的网页上注册的用户的默认模板,或者某项服务中的默认定价计划。与生成器模式的关键区别在于,对象是为用户克隆的,而不是在运行时构建它们。你还可以构建类似缓存的解决方案,使用原型存储信息。

目标

原型设计模式的主要目标是避免重复的对象创建。想象一个由数十个字段和嵌入类型组成的默认对象。我们不想每次使用对象时都写这个类型所需的一切,尤其是如果我们可以通过创建具有不同基础的实例来搞砸它:

  • 维护一组对象,这些对象将被克隆以创建新实例

  • 提供某种类型的默认值以便开始在其上进行工作

  • 释放复杂对象初始化的 CPU,以占用更多内存资源

例子

我们将构建一个想象中的定制衬衫商店的小组件,其中将有一些默认颜色和价格的衬衫。每件衬衫还将有一个库存保留单位(SKU),用于识别存储在特定位置的物品,需要进行更新。

验收标准

为了实现示例中描述的内容,我们将使用衬衫的原型。每次我们需要一件新的衬衫,我们将取原型,克隆它并使用它。特别是,这些是在此示例中使用原型模式设计方法的验收标准:

  • 为了拥有一个衬衫克隆器对象和接口,以请求不同类型的衬衫(白色、黑色和蓝色分别为 15.00、16.00 和 17.00 美元)

  • 当您要求一件白色衬衫时,必须制作白色衬衫的克隆,并且新实例必须与原始实例不同

  • 创建的对象的 SKU 不应影响新对象的创建

  • 一个信息方法必须给我所有可用的实例字段信息,包括更新后的 SKU

单元测试

首先,我们需要一个ShirtCloner接口和一个实现它的对象。此外,我们需要一个名为GetShirtsCloner的包级函数来检索克隆器的新实例:

type ShirtCloner interface { 
    GetClone(s int) (ItemInfoGetter, error) 
} 

const ( 
    White = 1 
    Black = 2 
    Blue  = 3 
) 

func GetShirtsCloner() ShirtCloner { 
    return nil 
} 

type ShirtsCache struct {} 
func (s *ShirtsCache)GetClone(s int) (ItemInfoGetter, error) { 
    return nil, errors.New("Not implemented yet") 
} 

现在我们需要一个对象结构来克隆,它实现了一个接口来检索其字段的信息。我们将称该对象为ShirtItemInfoGetter接口:

type ItemInfoGetter interface { 
    GetInfo() string 
} 

type ShirtColor byte 

type Shirt struct { 
    Price float32 
    SKU   string 
    Color ShirtColor 
} 
func (s *Shirt) GetInfo()string { 
    return "" 
} 

func GetShirtsCloner() ShirtCloner { 
    return nil 
} 

var whitePrototype *Shirt = &Shirt{ 
    Price: 15.00, 
    SKU:   "empty", 
    Color: White, 
} 

func (i *Shirt) GetPrice() float32 { 
    return i.Price 
} 

提示

您是否意识到我们定义的名为ShirtColor的类型只是一个byte类型?也许您想知道为什么我们没有简单地使用byte类型。我们可以,但这样我们创建了一个易于阅读的结构,如果需要,我们可以在将来升级一些方法。例如,我们可以编写一个String()方法,返回字符串格式的颜色(类型 1 为White,类型 2 为Black,类型 3 为Blue)。

有了这段代码,我们现在可以编写我们的第一个测试:

func TestClone(t *testing.T) { 
    shirtCache := GetShirtsCloner() 
    if shirtCache == nil { 
        t.Fatal("Received cache was nil") 
    } 

    item1, err := shirtCache.GetClone(White) 
    if err != nil { 
        t.Error(err) 
} 

//more code continues here... 

我们将涵盖我们场景的第一种情况,我们需要一个克隆器对象,可以用来请求不同颜色的衬衫。

对于第二种情况,我们将获取原始对象(因为我们可以访问它,所以我们在包的范围内),并将其与我们的shirt1实例进行比较。

if item1 == whitePrototype { 
    t.Error("item1 cannot be equal to the white prototype"); 
} 

现在,对于第三种情况。首先,我们将item1类型断言为衬衫,以便我们可以设置 SKU。我们将创建第二件衬衫,也是白色,我们也将对其进行类型断言,以检查 SKU 是否不同:

shirt1, ok := item1.(*Shirt) 
if !ok { 
    t.Fatal("Type assertion for shirt1 couldn't be done successfully") 
} 
shirt1.SKU = "abbcc" 

item2, err := shirtCache.GetClone(White) 
if err != nil { 
    t.Fatal(err) 
} 

shirt2, ok := item2.(*Shirt) 
if !ok { 
    t.Fatal("Type assertion for shirt1 couldn't be done successfully") 
} 

if shirt1.SKU == shirt2.SKU { 
    t.Error("SKU's of shirt1 and shirt2 must be different") 
} 

if shirt1 == shirt2 { 
    t.Error("Shirt 1 cannot be equal to Shirt 2") 
} 

最后,对于第四种情况,我们记录第一件和第二件衬衫的信息:

t.Logf("LOG: %s", shirt1.GetInfo()) 
t.Logf("LOG: %s", shirt2.GetInfo()) 

我们将打印两件衬衫的内存位置,因此我们在更物理层面上做出这种断言:

t.Logf("LOG: The memory positions of the shirts are different %p != %p \n\n", &shirt1, &shirt2) 

最后,我们运行测试,以便检查它是否失败:

go test -run=TestClone . 
--- FAIL: TestClone (0.00s) 
prototype_test.go:10: Not implemented yet 
FAIL 
FAIL

我们必须在这里停下来,以免测试在尝试使用GetShirtsCloner函数返回的空对象时出现恐慌。

实施

我们将从GetClone方法开始。这个方法应该返回指定类型的物品,我们有三种类型:白色、黑色和蓝色:

var whitePrototype *Shirt = &Shirt{ 
    Price: 15.00, 
    SKU:   "empty", 
    Color: White, 
} 

var blackPrototype *Shirt = &Shirt{ 
    Price: 16.00, 
    SKU:   "empty", 
    Color: Black, 
} 

var bluePrototype *Shirt = &Shirt{ 
    Price: 17.00, 
    SKU:   "empty", 
    Color: Blue, 
} 

现在我们有了三个原型可以操作,我们可以实现GetClone(s int)方法:

type ShirtsCache struct {} 
func (s *ShirtsCache)GetClone(s int) (ItemInfoGetter, error) { 
    switch m { 
        case White: 
            newItem := *whitePrototype 
            return &newItem, nil 
        case Black: 
            newItem := *blackPrototype 
            return &newItem, nil 
        case Blue: 
            newItem := *bluePrototype 
            return &newItem, nil 
        default: 
            return nil, errors.New("Shirt model not recognized") 
    } 
} 

Shirt结构还需要一个GetInfo实现来打印实例的内容。

type ShirtColor byte 

type Shirt struct { 
    Price float32 
    SKU   string 
    Color ShirtColor 
} 

func (s *Shirt) GetInfo() string { 
    return fmt.Sprintf("Shirt with SKU '%s' and Color id %d that costs %f\n", s.SKU, s.Color, s.Price) 
} 

最后,让我们运行测试,看看现在是否一切正常:

go test -run=TestClone -v . 
=== RUN   TestClone 
--- PASS: TestClone (0.00s) 
prototype_test.go:41: LOG: Shirt with SKU 'abbcc' and Color id 1 that costs 15.000000 
prototype_test.go:42: LOG: Shirt with SKU 'empty' and Color id 1 that costs 15.000000 
prototype_test.go:44: LOG: The memory positions of the shirts are different 0xc42002c038 != 0xc42002c040  

PASS 
ok

在日志中(运行测试时记得设置-v标志),您可以检查shirt1shirt2的 SKU 是否不同。此外,我们可以看到两个对象的内存位置。请注意,您的计算机上显示的位置可能会有所不同。

关于原型设计模式的学习

原型模式是构建缓存和默认对象的强大工具。您可能也意识到了一些模式可能有些重叠,但它们之间有一些细微差别,使它们在某些情况下更合适,在其他情况下则不太合适。

总结

我们已经看到了软件行业中常用的五种主要的创建型设计模式。它们的目的是为了将用户从对象的创建中抽象出来,以应对复杂性或可维护性的需求。自上世纪 90 年代以来,它们已经成为成千上万个应用程序和库的基础,而今天我们使用的大多数软件在内部都有许多这些创建型模式。

值得一提的是,这些模式并不是无缝的。在更高级的章节中,我们将会看到如何在 Go 中进行并发编程,以及如何使用并发方法来创建一些更为关键的设计模式。

第三章:结构模式 - 组合,适配器和桥接设计模式

我们将开始我们的结构模式之旅。结构模式,顾名思义,帮助我们用常用的结构和关系来塑造我们的应用程序。

Go 语言本质上鼓励使用组合,几乎完全不使用继承。因此,我们一直在广泛使用组合设计模式,所以让我们从定义组合设计模式开始。

组合设计模式

组合设计模式倾向于组合(通常定义为拥有关系)而不是继承(关系)。自上世纪九十年代以来,组合优于继承的方法一直是工程师之间讨论的话题。我们将学习如何使用拥有方法创建对象结构。总的来说,Go 没有继承,因为它不需要!

描述

在组合设计模式中,您将创建对象的层次结构和树。对象内部有不同的对象,具有它们自己的字段和方法。这种方法非常强大,解决了继承和多重继承的许多问题。例如,典型的继承问题是当您有一个实体从两个完全不同的类继承时,它们之间绝对没有关系。想象一个训练的运动员,和一个游泳的游泳者:

  • Athlete类有一个Train()方法

  • Swimmer类有一个Swim()方法

Swimmer类继承自Athlete类,因此它继承了其Train方法并声明了自己的Swim方法。您还可以有一个自行车手,也是一名运动员,并声明了一个Ride方法。

但现在想象一下,一种会吃东西的动物,比如一只也会叫的狗:

  • Cyclist类有一个Ride()方法

  • Animal类有Eat()Dog()Bark()方法

没有花哨的东西。您也可以有一条鱼是一种动物,是的,会游泳!那么,您如何解决呢?鱼不能是一个还会训练的游泳者。鱼不训练(据我所知!)。您可以创建一个带有Swim方法的Swimmer接口,并使游泳者运动员和鱼实现它。这将是最好的方法,但您仍然必须两次实现swim方法,因此代码的可重用性将受到影响。那么三项全能运动员呢?他们是游泳,跑步和骑车的运动员。通过多重继承,您可以有一种解决方案,但这很快就会变得复杂且难以维护。

目标

正如您可能已经想象的那样,组合的目标是避免这种层次结构混乱,其中应用程序的复杂性可能会增长太多,代码的清晰度受到影响。

游泳者和鱼

我们将以 Go 的方式解决运动员和游泳的鱼的问题。在 Go 中,我们可以使用两种类型的组合--直接组合和嵌入组合。我们将首先通过使用直接组合来解决这个问题,即在结构体内部拥有所需的一切。

需求和验收标准

要求与之前描述的要求相似。我们将有一个运动员和一个游泳者。我们还将有一个动物和一条鱼。SwimmerFish方法必须共享代码。运动员必须训练,动物必须吃:

  • 我们必须有一个带有Train方法的Athlete结构

  • 我们必须有一个带有Swim方法的Swimmer

  • 我们必须有一个带有Eat方法的Animal结构

  • 我们必须有一个带有Swim方法的Fish结构,该方法与Swimmer共享,而不会出现继承或层次结构问题

创建组合

组合设计模式是一种纯粹的结构模式,除了结构本身之外,没有太多需要测试的地方。在这种情况下,我们不会编写单元测试,而只是描述在 Go 中创建这些组合的方法。

首先,我们将从Athlete结构和其Train方法开始:

type Athlete struct{} 

func (a *Athlete) Train() { 
  fmt.Println("Training") 
} 

前面的代码非常简单。它的Train方法打印单词Training和一个换行符。我们将创建一个具有Athlete结构的复合游泳者:

type CompositeSwimmerA struct{ 
  MyAthlete Athlete 
  MySwim func() 
} 

CompositeSwimmerA类型有一个Athlete类型的MyAthlete字段。它还存储一个func()类型。请记住,在 Go 中,函数是一等公民,它们可以像任何变量一样作为参数、字段或参数使用。因此,CompositeSwimmerA有一个MySwim字段,其中存储了一个闭包,它不带参数并且不返回任何内容。我如何将函数分配给它呢?好吧,让我们创建一个与func()签名匹配的函数(无参数,无返回):

func Swim(){ 
  fmt.Println("Swimming!") 
} 

就是这样!Swim()函数不带参数并且不返回任何内容,因此它可以用作CompositeSwimmerA结构中的MySwim字段:

swimmer := CompositeSwimmerA{ 
  MySwim: Swim, 
} 

swimmer.MyAthlete.Train() 
swimmer.MySwim() 

因为我们有一个名为Swim()的函数,我们可以将其分配给MySwim字段。请注意,Swim类型没有括号,这将执行其内容。这样我们就可以将整个函数复制到MySwim方法中。

但等等。我们还没有将运动员传递给MyAthlete字段,我们正在使用它!这将失败!让我们看看执行此片段时会发生什么:

$ go run main.go
Training
Swimming!

这很奇怪,不是吗?实际上并不是,因为 Go 中的零初始化的性质。如果您没有将Athlete结构传递给CompositeSwimmerA类型,编译器将创建一个其值为零初始化的结构,也就是说,一个Athlete结构,其字段的值初始化为零。如果这看起来令人困惑,请查看第一章准备...开始...跑!来回顾零初始化。再次考虑CompositeSwimmerA结构代码:

type CompositeSwimmerA struct{ 
  MyAthlete Athlete 
  MySwim    func() 
} 

现在我们有一个存储在MySwim字段中的函数指针。我们可以以相同的方式分配Swim函数,但需要多一步:

localSwim := Swim 

swimmer := CompositeSwimmerA{ 
  MySwim: localSwim, 
} 

swimmer.MyAthlete.Train() 
swimmer.MySwim () 

首先,我们需要一个包含函数Swim的变量。这是因为函数没有地址,无法将其传递给CompositeSwimmerA类型。然后,为了在结构体内使用这个函数,我们必须进行两步调用。

那么我们的鱼问题呢?有了我们的Swim函数,这不再是问题。首先,我们创建Animal结构:

type Animal struct{} 

func (r *Animal)Eat() { 
  println("Eating") 
} 

然后我们将创建一个嵌入Animal对象的Shark对象:

type Shark struct{ 
  Animal 
  Swim func() 
} 

等一下!Animal类型的字段名在哪里?你有没有意识到我在上一段中使用了embed这个词?这是因为在 Go 中,您还可以将对象嵌入到对象中,使其看起来很像继承。也就是说,我们不必显式调用字段名来访问其字段和方法,因为它们将成为我们的一部分。因此,以下代码将是完全正常的:

fish := Shark{ 
  Swim: Swim, 
} 

fish.Eat() 
fish.Swim() 

现在我们有一个Animal类型,它是零初始化并嵌入的。这就是为什么我可以调用Animal结构的Eat方法而不创建它或使用中间字段名。此片段的输出如下:

$ go run main.go 
Eating 
Swimming!

最后,有第三种使用组合模式的方法。我们可以创建一个带有Swim方法的Swimmer接口和一个SwimmerImpl类型,将其嵌入到运动员游泳者中:

type Swimmer interface { 
  Swim() 
} 
type Trainer interface { 
  Train() 
} 

type SwimmerImpl struct{} 
func (s *SwimmerImpl) Swim(){ 
  println("Swimming!") 
} 

type CompositeSwimmerB struct{ 
  Trainer 
  Swimmer 
} 

使用这种方法,您可以更明确地控制对象的创建。Swimmer字段被嵌入,但不会被零初始化,因为它是一个指向接口的指针。这种方法的正确使用将是以下方式:

swimmer := CompositeSwimmerB{ 
  &Athlete{}, 
  &SwimmerImpl{}, 
} 

swimmer.Train() 
swimmer.Swim() 

CompositeSwimmerB的输出如下,如预期的那样:

$ go run main.go
Training
Swimming!

哪种方法更好?嗯,我有个人偏好,不应被视为金科玉律。在我看来,接口方法是最好的,原因有很多,但主要是因为明确性。首先,您正在使用首选的接口而不是结构。其次,您不会将代码的部分留给编译器的零初始化特性。这是一个非常强大的功能,但必须小心使用,因为它可能导致运行时问题,而在使用接口时,您会在编译时发现这些问题。在不同的情况下,零初始化实际上会在运行时为您节省,事实上!但我尽可能多地使用接口,所以这实际上并不是一个选项。

二叉树组合

另一种非常常见的组合模式是在使用二叉树结构时。在二叉树中,您需要在字段中存储自身的实例:

type Tree struct { 
  LeafValue int 
  Right     *Tree 
  Left      *Tree 
} 

这是一种递归组合,由于递归的性质,我们必须使用指针,以便编译器知道它必须为此结构保留多少内存。我们的Tree结构为每个实例存储了一个LeafValue对象,并在其RightLeft字段中存储了一个新的Tree

有了这个结构,我们可以创建一个对象,就像这样:

root := Tree{ 
  LeafValue: 0, 
  Right:&Tree{ 
    LeafValue: 5, 
    Right: &1Tree{ 6, nil, nil }, 
    Left: nil, 
  }, 
  Left:&Tree{ 4, nil, nil }, 
} 

我们可以这样打印其最深层分支的内容:

fmt.Println(root.Right.Right.LeafValue) 

$ go run main.go 
6

组合模式与继承

在 Go 中使用组合设计模式时,必须非常小心,不要将其与继承混淆。例如,当您在Son结构中嵌入Parent结构时,就像以下示例中一样:

type Parent struct { 
  SomeField int 
} 

type Son struct { 
  Parent 
} 

您不能认为Son结构也是Parent结构。这意味着您不能将Son结构的实例传递给期望Parent结构的函数,就像以下示例中一样:

func GetParentField(p *Parent) int{ 
  fmt.Println(p.SomeField) 
} 

当您尝试将Son实例传递给GetParentField方法时,您将收到以下错误消息:

cannot use son (type Son) as type Parent in argument to GetParentField

事实上,这是有很多道理的。这个问题的解决方案是什么?嗯,您可以简单地将Son结构与父结构组合起来,而不是嵌入,以便稍后可以访问Parent实例:

type Son struct { 
  P Parent 
} 

所以现在你可以使用P字段将其传递给GetParentField方法:

son := Son{} 
GetParentField(son.P) 

关于组合模式的最后几句话

在这一点上,您应该真的很熟悉使用组合设计模式。这是 Go 语言中非常惯用的特性,从纯面向对象的语言切换过来并不是非常痛苦的。组合设计模式使我们的结构可预测,但也允许我们创建大多数设计模式,正如我们将在后面的章节中看到的。

适配器设计模式

最常用的结构模式之一是适配器模式。就像在现实生活中,您有插头适配器和螺栓适配器一样,在 Go 中,适配器将允许我们使用最初未为特定任务构建的东西。

描述

当接口过时且无法轻松或快速替换时,适配器模式非常有用。相反,您可以创建一个新接口来处理应用程序当前需求,该接口在底层使用旧接口的实现。

适配器还帮助我们在应用程序中保持开闭原则,使其更可预测。它们还允许我们编写使用一些无法修改的基础的代码。

注意

开闭原则首次由 Bertrand Meyer 在他的书《面向对象的软件构造》中提出。他指出代码应该对新功能开放,但对修改关闭。这是什么意思?嗯,这意味着一些事情。一方面,我们应该尝试编写可扩展的代码,而不仅仅是可工作的代码。同时,我们应该尽量不修改源代码(你的或其他人的),因为我们并不总是意识到这种修改的影响。只需记住,代码的可扩展性只能通过设计模式和面向接口的编程来实现。

目标

适配器设计模式将帮助您满足最初不兼容的代码部分的需求。这是在决定适配器模式是否适合您的问题时要牢记的关键点——最初不兼容但必须一起工作的两个接口是适配器模式的良好候选对象(但它们也可以使用外观模式,例如)。

使用不兼容的接口与适配器对象

对于我们的示例,我们将有一个旧的Printer接口和一个新的接口。新接口的用户不希望旧接口的签名,并且我们需要一个适配器,以便用户仍然可以在必要时使用旧的实现(例如与一些旧代码一起工作)。

需求和验收标准

有一个名为LegacyPrinter的旧接口和一个名为ModernPrinter的新接口,创建一个结构来实现ModernPrinter接口,并按照以下步骤使用LegacyPrinter接口:

  1. 创建一个实现ModernPrinter接口的适配器对象。

  2. 新的适配器对象必须包含LegacyPrinter接口的实例。

  3. 在使用ModernPrinter时,它必须在后台调用LegacyPrinter接口,并在前面加上文本Adapter

单元测试我们的打印机适配器

我们将首先编写旧代码,但不会测试它,因为我们应该想象它不是我们的代码:

type LegacyPrinter interface { 
  Print(s string) string 
} 
type MyLegacyPrinter struct {} 

func (l *MyLegacyPrinter) Print(s string) (newMsg string) { 
  newMsg = fmt.Sprintf("Legacy Printer: %s\n", s) 
  println(newMsg) 
  return 
} 

名为LegacyPrinter的旧接口有一个接受字符串并返回消息的Print方法。我们的MyLegacyPrinter结构实现了LegacyPrinter接口,并通过在传递的字符串前加上文本Legacy Printer:来修改传递的字符串。在修改文本后,MyLegacyPrinter结构将文本打印到控制台,然后返回它。

现在我们将声明我们需要适配的新接口:

type ModernPrinter interface { 
  PrintStored() string 
} 

在这种情况下,新的PrintStored方法不接受任何字符串作为参数,因为它必须提前存储在实现者中。我们将调用我们的适配器模式的PrinterAdapter接口:

type PrinterAdapter struct{ 
  OldPrinter LegacyPrinter 
  Msg        string 
} 
func(p *PrinterAdapter) PrintStored() (newMsg string) { 
  return 
} 

如前所述,PrinterAdapter适配器必须有一个字段来存储要打印的字符串。它还必须有一个字段来存储LegacyPrinter适配器的实例。因此,让我们编写单元测试:

func TestAdapter(t *testing.T){ 
  msg := "Hello World!" 

我们将使用消息Hello World!作为我们的适配器。当将此消息与MyLegacyPrinter结构的实例一起使用时,它会打印文本Legacy Printer: Hello World!

adapter := PrinterAdapter{OldPrinter: &MyLegacyPrinter{}, Msg: msg} 

我们创建了一个名为adapterPrinterAdapter接口的实例。我们将MyLegacyPrinter结构的实例作为LegacyPrinter字段传递给OldPrinter。此外,我们在Msg字段中设置要打印的消息:

returnedMsg := adapter.PrintStored() 

if returnedMsg != "Legacy Printer: Adapter: Hello World!\n" { 
  t.Errorf("Message didn't match: %s\n", returnedMsg) 
} 

然后我们使用了ModernPrinter接口的PrintStored方法;这个方法不接受任何参数,必须返回修改后的字符串。我们知道MyLegacyPrinter结构返回传递的字符串,并在前面加上文本LegacyPrinter:,适配器将在前面加上文本Adapter:。因此,最终我们必须有文本Legacy Printer: Adapter: Hello World!\n

由于我们正在存储接口的实例,因此我们还必须检查我们处理指针为 nil 的情况。这是通过以下测试完成的:

adapter = PrinterAdapter{OldPrinter: nil, Msg: msg} 
returnedMsg = adapter.PrintStored() 

if returnedMsg != "Hello World!" { 
  t.Errorf("Message didn't match: %s\n", returnedMsg) 
} 

如果我们没有传递LegacyPrinter接口的实例,适配器必须忽略其适配性质,简单地打印并返回原始消息。是时候运行我们的测试了;考虑以下内容:

$ go test -v .
=== RUN   TestAdapter
--- FAIL: TestAdapter (0.00s)
 adapter_test.go:11: Message didn't match: 
 adapter_test.go:17: Message didn't match: 
FAIL
exit status 1
FAIL

实施

为了使我们的单个测试通过,我们必须重用存储在PrinterAdapter结构中的旧MyLegacyPrinter

type PrinterAdapter struct{ 
  OldPrinter LegacyPrinter 
  Msg        string 
} 

func(p *PrinterAdapter) PrintStored() (newMsg string) { 
  if p.OldPrinter != nil { 
    newMsg = fmt.Sprintf("Adapter: %s", p.Msg) 
    newMsg = p.OldPrinter.Print(newMsg) 
  } 
  else { 
    newMsg = p.Msg 
  } 
return 
} 

PrintStored方法中,我们检查是否实际上有一个LegacyPrinter的实例。在这种情况下,我们将存储的消息和Adapter前缀组合成一个新的字符串,以便将其存储在返回变量(称为newMsg)中。然后我们使用指向MyLegacyPrinter结构的指针来使用LegacyPrinter接口打印组合的消息。

如果在OldPrinter字段中没有存储LegacyPrinter实例,我们只需将存储的消息分配给返回变量newMsg并返回该方法。这应该足以通过我们的测试:

$ go test -v .
=== RUN   TestAdapter
Legacy Printer: Adapter: Hello World!
--- PASS: TestAdapter (0.00s)
PASS
ok

完美!现在我们可以通过使用这个Adapter来继续使用旧的LegacyPrinter接口,同时我们可以为将来的实现使用ModernPrinter接口。只要记住,适配器模式理想上只提供使用旧的LegacyPrinter的方法,而不提供其他任何东西。这样,它的范围将更加封装和在将来更易于维护。

Go 源代码中适配器模式的示例

您可以在 Go 语言源代码的许多地方找到适配器实现。著名的http.Handler接口有一个非常有趣的适配器实现。在 Go 中,一个非常简单的Hello World服务器通常是这样做的:

package main 

import ( 
    "fmt" 
    "log" 
    "net/http" 
) 
type MyServer struct{ 
  Msg string 
} 
func (m *MyServer) ServeHTTP(w http.ResponseWriter,r *http.Request){ 
  fmt.Fprintf(w, "Hello, World") 
} 

func main() { 
  server := &MyServer{ 
  Msg:"Hello, World", 
} 

http.Handle("/", server)  
log.Fatal(http.ListenAndServe(":8080", nil)) 
} 

HTTP 包有一个名为Handle的函数(类似于 Java 中的static方法),它接受两个参数--一个表示路由的字符串和一个Handler接口。Handler接口如下:

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

我们需要实现一个ServeHTTP方法,HTTP 连接的服务器端将使用它来执行其上下文。但是还有一个HandlerFunc函数,允许您定义一些端点行为:

func main() { 
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 
    fmt.Fprintf(w, "Hello, World") 
  }) 

  log.Fatal(http.ListenAndServe(":8080", nil)) 
} 

HandleFunc函数实际上是使用函数直接作为ServeHTTP实现的适配器的一部分。再慢慢读一遍最后一句--你能猜出它是如何实现的吗?

type HandlerFunc func(ResponseWriter, *Request) 

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { 
  f(w, r) 
} 

我们可以定义一个与定义结构相同的函数类型。我们使这个函数类型实现ServeHTTP方法。最后,从ServeHTTP函数中,我们调用接收器本身f(w, r)

你必须考虑 Go 的隐式接口实现。当我们定义一个像func(ResponseWriter, *Request)这样的函数时,它会被隐式地识别为HandlerFunc。而且因为HandleFunc函数实现了Handler接口,我们的函数也隐式地实现了Handler接口。这听起来很熟悉吗?如果A = BB = C,那么A = C。隐式实现为 Go 提供了很多灵活性和功能,但你也必须小心,因为你不知道一个方法或函数是否实现了可能引起不良行为的某个接口。

我们可以在 Go 的源代码中找到更多的示例。io包使用管道的示例非常有力。在 Linux 中,管道是一种流机制,它将输入的内容输出为输出的其他内容。io包有两个接口,它们在 Go 的源代码中随处可见--io.Readerio.Writer接口:

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

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

我们到处都使用io.Reader,例如,当您使用os.OpenFile打开文件时,它返回一个文件,实际上实现了io.Reader接口。这有什么用呢?想象一下,您编写了一个Counter结构,从您提供的数字开始计数到零:

type Counter struct {} 
func (f *Counter) Count(n uint64) uint64 { 
  if n == 0 { 
    println(strconv.Itoa(0)) 
    return 0 
  } 

  cur := n 
  println(strconv.FormatUint(cur, 10)) 
  return f.Count(n - 1) 
} 

如果您向这个小片段提供数字 3,它将打印以下内容:

3
2
1

嗯,不是很令人印象深刻!如果我想要写入文件而不是打印呢?我们也可以实现这种方法。如果我想要打印到文件和控制台呢?嗯,我们也可以实现这种方法。我们必须通过使用io.Writer接口将其模块化一些:

type Counter struct { 
  Writer io.Writer 
} 
func (f *Counter) Count(n uint64) uint64 { 
  if n == 0 { 
    f.Writer.Write([]byte(strconv.Itoa(0) + "\n")) 
    return 0 
  } 

  cur := n 
  f.Writer.Write([]byte(strconv.FormatUint(cur, 10) + "\n")) 
  return f.Count(n - 1) 
}

现在我们在Writer字段中提供了一个io.Writer。这样,我们可以像这样创建计数器:c := Counter{os.Stdout},我们将得到一个控制台Writer。但等一下,我们还没有解决我们想要将计数带到许多Writer控制台的问题。但是我们可以编写一个新的Adapter,其中包含一个io.Writer,并使用Pipe()连接读取器和写入器,我们可以在相反的极端进行读取。这样,您可以解决这两个不兼容的接口ReaderWriter可以一起使用的问题。

实际上,我们不需要编写适配器--Go 的io库在io.Pipe()中为我们提供了一个适配器。管道将允许我们将Reader转换为Writer接口。io.Pipe()方法将为我们提供一个Writer(管道的入口)和一个Reader(出口)供我们使用。因此,让我们创建一个管道,并将提供的写入器分配给前面示例的Counter

pipeReader, pipeWriter := io.Pipe() 
defer pw.Close() 
defer pr.Close() 

counter := Counter{ 
  Writer: pipeWriter, 
} 

现在我们有了一个Reader接口,之前我们有了一个Writer。我们在哪里可以使用Readerio.TeeReader函数帮助我们将数据流从Reader接口复制到Writer接口,并返回一个新的Reader,您仍然可以使用它将数据流再次传输到第二个写入器。因此,我们将从相同的读取器流式传输数据到两个写入器--fileStdout

tee := io.TeeReader(pipeReader, file) 

现在我们知道我们正在写入一个文件,我们已经传递给TeeReader函数。我们仍然需要打印到控制台。io.Copy适配器可以像TeeReader一样使用--它接受一个读取器并将其内容写入写入器:

go func(){ 
  io.Copy(os.Stdout, tee) 
}() 

我们必须在不同的 Go 例程中启动Copy函数,以便并发执行写入操作,并且一个读/写不会阻塞另一个读/写。让我们修改counter变量,使其再次计数到 5:

counter.Count(5) 

通过对代码进行这种修改,我们得到了以下输出:

$ go run counter.go
5
4
3
2
1
0

好的,计数已经打印在控制台上。文件呢?

$ cat /tmp/pipe
5
4
3
2
1
0

太棒了!通过使用 Go 原生库中提供的io.Pipe()适配器,我们已经将计数器与其输出解耦,并将Writer接口适配为Reader接口。

Go 源代码告诉我们有关适配器模式的信息

通过适配器设计模式,您已经学会了一种快速实现应用程序中开/闭原则的方法。与其修改旧的源代码(在某些情况下可能不可能),不如创建一种使用新签名的旧功能的方法。

桥梁设计模式

桥梁模式是从原始四人帮书中得到的定义略微神秘的设计。它将抽象与其实现解耦,以便两者可以独立变化。这种神秘的解释只是意味着您甚至可以解耦最基本的功能形式:将对象与其功能解耦。

描述

桥梁模式试图像通常的设计模式一样解耦事物。它将抽象(对象)与其实现(对象执行的操作)解耦。这样,我们可以随心所欲地更改对象的操作。它还允许我们更改抽象对象,同时重用相同的实现。

目标

桥梁模式的目标是为经常更改的结构带来灵活性。通过了解方法的输入和输出,它允许我们在不太了解代码的情况下进行更改,并为双方留下更容易修改的自由。

每个打印机和每种打印方式都有两种。

对于我们的示例,我们将转到控制台打印机抽象以保持简单。我们将有两个实现。第一个将写入控制台。在上一节中了解了io.Writer接口后,我们将使第二个写入io.Writer接口,以提供更多灵活性。我们还将有两个抽象对象使用这些实现——一个Normal对象,它将以直接的方式使用每个实现,以及一个Packt实现,它将在打印消息中附加句子Message from Packt:

在本节的结尾,我们将有两个抽象对象,它们有两种不同的功能实现。因此,实际上,我们将有 2²种可能的对象功能组合。

要求和验收标准

正如我们之前提到的,我们将有两个对象(PacktNormal打印机)和两个实现(PrinterImpl1PrinterImpl2),我们将使用桥接设计模式将它们连接起来。更多或更少,我们将有以下要求和验收标准:

  • 一个接受要打印的消息的PrinterAPI

  • 一个简单地将消息打印到控制台的 API 实现

  • 一个将消息打印到io.Writer接口的 API 实现

  • 一个Printer抽象,具有实现打印类型的Print方法

  • 一个normal打印机对象,它将实现PrinterPrinterAPI接口

  • normal打印机将直接将消息转发到实现

  • 一个Packt打印机,它将实现Printer抽象和PrinterAPI接口

  • Packt打印机将在所有打印中附加消息Message from Packt:

单元测试桥接模式

让我们从验收标准 1开始,即PrinterAPI接口。该接口的实现者必须提供一个PrintMessage(string)方法,该方法将打印作为参数传递的消息:

type PrinterAPI interface { 
  PrintMessage(string) error 
} 

我们将通过前一个 API 的实现转到验收标准 2

type PrinterImpl1 struct{} 

func (p *PrinterImpl1) PrintMessage(msg string) error { 
  return errors.New("Not implemented yet") 
} 

我们的PrinterImpl1是一种通过提供PrintMessage方法的实现来实现PrinterAPI接口的类型。PrintMessage方法尚未实现,并返回错误。这足以编写我们的第一个单元测试来覆盖PrinterImpl1

func TestPrintAPI1(t *testing.T){ 
  api1 := PrinterImpl1{} 

  err := api1.PrintMessage("Hello") 
  if err != nil { 
    t.Errorf("Error trying to use the API1 implementation: Message: %s\n", err.Error()) 
  } 
} 

在我们的测试中,我们创建了一个PrinterImpl1类型的实例来覆盖PrintAPI1。然后我们使用它的PrintMessage方法将消息Hello打印到控制台。由于我们还没有实现,它必须返回错误字符串Not implemented yet

$ go test -v -run=TestPrintAPI1 . 
=== RUN   TestPrintAPI1 
--- FAIL: TestPrintAPI1 (0.00s) 
        bridge_test.go:14: Error trying to use the API1 implementation: Message: Not implemented yet 
FAIL 
exit status 1 
FAIL    _/C_/Users/mario/Desktop/go-design-patterns/structural/bridge/traditional

好的。现在我们必须编写第二个 API 测试,它将使用io.Writer接口:

type PrinterImpl2 struct{ 
  Writer io.Writer 
} 

func (d *PrinterImpl2) PrintMessage(msg string) error { 
  return errors.New("Not implemented yet") 
} 

正如你所看到的,我们的PrinterImpl2结构存储了一个io.Writer实现。此外,我们的PrintMessage方法遵循了PrinterAPI接口。

现在我们熟悉了io.Writer接口,我们将创建一个测试对象来实现这个接口,并将写入它的任何内容存储在一个本地字段中。这将帮助我们检查通过写入器发送的内容:

type TestWriter struct { 
  Msg string 
} 

func (t *TestWriter) Write(p []byte) (n int, err error) { 
  n = len(p) 
  if n > 0 { 
    t.Msg = string(p) 
    return n, nil 
  } 
  err = errors.New("Content received on Writer was empty") 
  return 
} 

在我们的测试对象中,我们在将其写入本地字段之前检查内容是否为空。如果为空,我们返回错误,如果不为空,我们将p的内容写入Msg字段。我们将在以下测试中使用这个小结构来测试第二个 API:

func TestPrintAPI2(t *testing.T){ 
  api2 := PrinterImpl2{} 

  err := api2.PrintMessage("Hello") 
  if err != nil { 
    expectedErrorMessage := "You need to pass an io.Writer to PrinterImpl2" 
    if !strings.Contains(err.Error(), expectedErrorMessage) { 
      t.Errorf("Error message was not correct.\n 
      Actual: %s\nExpected: %s\n", err.Error(), expectedErrorMessage) 
    } 
  } 

让我们在这里停顿一下。我们在前面的代码的第一行创建了一个名为api2PrinterImpl2实例。我们故意没有传递任何io.Writer实例,所以我们首先检查我们是否真的收到了错误。然后我们尝试使用它的PrintMessage方法,但我们必须得到一个错误,因为它在Writer字段中没有存储任何io.Writer实例。错误必须是You need to pass an io.Writer to PrinterImpl2,我们隐式检查错误的内容。让我们继续测试:

  testWriter := TestWriter{} 
  api2 = PrinterImpl2{ 
    Writer: &testWriter, 
  } 

  expectedMessage := "Hello" 
  err = api2.PrintMessage(expectedMessage) 
  if err != nil { 
    t.Errorf("Error trying to use the API2 implementation: %s\n", err.Error()) 
  } 

  if testWriter.Msg !=  expectedMessage { 
    t.Fatalf("API2 did not write correctly on the io.Writer. \n  Actual: %s\nExpected: %s\n", testWriter.Msg, expectedMessage) 
  } 
} 

对于这个单元测试的第二部分,我们使用TestWriter对象的一个实例作为io.Writer接口,testWriter。我们将消息Hello传递给api2,并检查是否收到任何错误。然后,我们检查testWriter.Msg字段的内容--请记住,我们已经编写了一个io.Writer接口,它会将传递给其Write方法的任何字节存储在Msg字段中。如果一切正确,消息应该包含单词Hello

这些就是我们对PrinterImpl2的测试。由于我们还没有任何实现,所以在运行这个测试时应该会得到一些错误。

$ go test -v -run=TestPrintAPI2 .
=== RUN   TestPrintAPI2
--- FAIL: TestPrintAPI2 (0.00s)
bridge_test.go:39: Error message was not correct.
Actual: Not implemented yet
Expected: You need to pass an io.Writer to PrinterImpl2
bridge_test.go:52: Error trying to use the API2 implementation: Not 
implemented yet
bridge_test.go:57: API2 did not write correctly on the io.Writer.
Actual:
Expected: Hello
FAIL
exit status 1
FAIL

至少有一个测试通过了--检查在使用PrintMessage时是否返回了错误消息(任何错误)。其他一切都失败了,这在这个阶段是预期的。

现在我们需要一个打印机抽象,用于可以使用PrinterAPI实现者的对象。我们将定义这个为PrinterAbstraction接口,其中包含一个Print方法。这涵盖了验收标准 4

type PrinterAbstraction interface { 
  Print() error 
} 

对于验收标准 5,我们需要一个普通打印机。Printer抽象将需要一个字段来存储PrinterAPI。因此,我们的NormalPrinter可能如下所示:

type NormalPrinter struct { 
  Msg     string 
  Printer PrinterAPI 
} 

func (c *NormalPrinter) Print() error { 
  return errors.New("Not implemented yet") 
} 

这足以编写Print()方法的单元测试:

func TestNormalPrinter_Print(t *testing.T) { 
  expectedMessage := "Hello io.Writer" 

  normal := NormalPrinter{ 
    Msg:expectedMessage, 
    Printer: &PrinterImpl1{}, 
  } 

  err := normal.Print() 
  if err != nil { 
    t.Errorf(err.Error()) 
  } 
} 

测试的第一部分检查了在使用PrinterImpl1 PrinterAPI接口时,Print()方法尚未实现。我们将在这个测试中使用的消息是Hello io.Writer。使用PrinterImpl1时,我们没有简单的方法来检查消息的内容,因为我们直接打印到控制台。在这种情况下,检查是视觉的,所以我们可以检查验收标准 6

  testWriter := TestWriter{} 
  normal = NormalPrinter{ 
    Msg: expectedMessage, 
    Printer: &PrinterImpl2{ 
      Writer:&testWriter, 
    }, 
  } 

  err = normal.Print() 
  if err != nil { 
    t.Error(err.Error()) 
  } 

  if testWriter.Msg != expectedMessage { 
    t.Errorf("The expected message on the io.Writer doesn't match actual.\n  Actual: %s\nExpected: %s\n", testWriter.Msg, expectedMessage) 
  } 
} 

NormalPrinter测试的第二部分使用PrinterImpl2,这需要一个io.Writer接口的实现者。我们在这里重用我们的TestWriter结构来检查消息的内容。简而言之,我们希望一个接受string类型的MsgPrinterAPI类型的PrinterNormalPrinter结构。在这一点上,如果我使用Print方法,我不应该收到任何错误,并且TestWriter上的Msg字段必须包含我们在初始化NormalPrinter时传递给它的消息。

让我们运行测试:

$ go test -v -run=TestNormalPrinter_Print .
=== RUN   TestNormalPrinter_Print
--- FAIL: TestNormalPrinter_Print (0.00s)
 bridge_test.go:72: Not implemented yet
 bridge_test.go:85: Not implemented yet
 bridge_test.go:89: The expected message on the io.Writer doesn't match actual.
 Actual:
 Expected: Hello io.Writer
FAIL
exit status 1
FAIL

有一个技巧可以快速检查单元测试的有效性--我们调用t.Errort.Errorf的次数必须与控制台上的错误消息数量以及它们产生的行数相匹配。在前面的测试结果中,有三个错误分别在第 72 行第 85 行第 89 行,这恰好与我们编写的检查相匹配。

我们的PacktPrinter结构在这一点上将与NormalPrinter的定义非常相似:

type PacktPrinter struct { 
  Msg     string 
  Printer PrinterAPI 
} 

func (c *PacktPrinter) Print() error { 
  return errors.New("Not implemented yet") 
} 

这涵盖了验收标准 7。我们几乎可以复制并粘贴以前的测试内容,只需做一些更改:

func TestPacktPrinter_Print(t *testing.T) { 
  passedMessage := "Hello io.Writer" 
  expectedMessage := "Message from Packt: Hello io.Writer" 

  packt := PacktPrinter{ 
    Msg:passedMessage, 
    Printer: &PrinterImpl1{}, 
  } 

  err := packt.Print() 
  if err != nil { 
    t.Errorf(err.Error()) 
  } 

  testWriter := TestWriter{} 
  packt = PacktPrinter{ 
    Msg: passedMessage, 
    Printer:&PrinterImpl2{ 
      Writer:&testWriter, 
    }, 
  } 

  err = packt.Print() 
  if err != nil { 
    t.Error(err.Error()) 
  } 

  if testWriter.Msg != expectedMessage { 
    t.Errorf("The expected message on the io.Writer doesn't match actual.\n  Actual: %s\nExpected: %s\n", testWriter.Msg,expectedMessage) 
  } 
} 

我们在这里做了什么改变?现在我们有了passedMessage,它代表了我们传递给PackPrinter的消息。我们还有一个预期的消息,其中包含了来自Packt的带前缀的消息。如果您还记得验收标准 8,这个抽象必须给传递给它的任何消息加上Message from Packt:的前缀,并且同时,它必须能够使用PrinterAPI接口的任何实现。

第二个改变是,我们实际上创建了PacktPrinter结构,而不是NormalPrinter结构;其他一切都是一样的:

$ go test -v -run=TestPacktPrinter_Print .
=== RUN   TestPacktPrinter_Print
--- FAIL: TestPacktPrinter_Print (0.00s)
 bridge_test.go:104: Not implemented yet
 bridge_test.go:117: Not implemented yet
 bridge_test.go:121: The expected message on the io.Writer d
oesn't match actual.
 Actual:
 Expected: Message from Packt: Hello io.Writer
FAIL
exit status 1
FAIL

三个检查,三个错误。所有测试都已覆盖,我们终于可以继续实施了。

实施

我们将按照创建测试的顺序开始实现,首先是PrinterImpl1的定义:

type PrinterImpl1 struct{} 
func (d *PrinterImpl1) PrintMessage(msg string) error { 
  fmt.Printf("%s\n", msg) 
  return nil 
} 

我们的第一个 API 接收消息msg并将其打印到控制台。在空字符串的情况下,将不会打印任何内容。这足以通过第一个测试:

$ go test -v -run=TestPrintAPI1 .
=== RUN   TestPrintAPI1
Hello
--- PASS: TestPrintAPI1 (0.00s)
PASS
ok

您可以在测试输出的第二行中看到Hello消息,就在RUN消息之后。

PrinterImpl2结构也不是很复杂。不同之处在于,我们将在io.Writer接口上写入,而不是打印到控制台,这必须存储在结构中。

type PrinterImpl2 struct { 
  Writer io.Writer 
} 

func (d *PrinterImpl2) PrintMessage(msg string) error { 
  if d.Writer == nil { 
    return errors.New("You need to pass an io.Writer to PrinterImpl2") 
  } 

  fmt.Fprintf(d.Writer, "%s", msg) 
  return nil 
} 

根据我们的测试,我们首先检查了Writer字段的内容,并返回了预期的错误消息**You need to pass an io.Writer to PrinterImpl2**,如果没有存储任何内容。这是我们稍后将在测试中检查的消息。然后,fmt.Fprintf方法将io.Writer接口作为第一个字段,并将格式化的消息作为其余部分,因此我们只需将msg参数的内容转发给提供的io.Writer

$ go test -v -run=TestPrintAPI2 .
=== RUN   TestPrintAPI2
--- PASS: TestPrintAPI2 (0.00s)
PASS
ok

现在我们将继续使用普通打印机。这个打印机必须简单地将消息转发给存储在PrinterAPI接口中的Printer,而不做任何修改。在我们的测试中,我们使用了两种PrinterAPI的实现--一种打印到控制台,一种写入到io.Writer接口:

type NormalPrinter struct { 
  Msg     string 
  Printer PrinterAPI 
} 

func (c *NormalPrinter) Print() error { 
  c.Printer.PrintMessage(c.Msg) 
  return nil 
}

我们返回 nil,因为没有发生错误。这应该足以通过单元测试:

$ go test -v -run=TestNormalPrinter_Print . 
=== RUN   TestNormalPrinter_Print 
Hello io.Writer 
--- PASS: TestNormalPrinter_Print (0.00s) 
PASS 
ok

在前面的输出中,您可以看到PrinterImpl1结构写入stdoutHello io.Writer消息。我们可以认为这个检查已经通过了:

最后,PackPrinter方法类似于NormalPrinter,但只是在每条消息前加上文本Message from Packt:

type PacktPrinter struct { 
  Msg     string 
  Printer PrinterAPI 
} 

func (c *PacktPrinter) Print() error { 
  c.Printer.PrintMessage(fmt.Sprintf("Message from Packt: %s", c.Msg)) 
  return nil 
} 

就像NormalPrinter方法一样,我们接受了Msg字符串和PrinterAPI实现,存储在Printer字段中。然后,我们使用fmt.Sprintf方法来组合一个新的字符串,其中包含文本Message from Packt:和提供的消息。我们取得组合的文本,并将其传递给存储在PacktPrinter结构的Printer字段中的PrinterAPIPrintMessage方法:

$ go test -v -run=TestPacktPrinter_Print .
=== RUN   TestPacktPrinter_Print
Message from Packt: Hello io.Writer
--- PASS: TestPacktPrinter_Print (0.00s)
PASS
ok

同样,您可以看到使用PrinterImpl1写入stdout的结果,文本为Message from Packt: Hello io.Writer。这最后的测试应该覆盖桥接模式中的所有代码。正如您之前所见,您可以使用-cover标志来检查覆盖率:

$ go test -cover .
ok      
2.622s  coverage: 100.0% of statements

哇!100%的覆盖率-看起来不错。然而,这并不意味着代码是完美的。我们还没有检查消息的内容是否为空,也许这是应该避免的,但这不是我们的要求的一部分,这也是一个重要的观点。仅仅因为某个功能不在需求或验收标准中,并不意味着它不应该被覆盖。

使用桥接模式重用一切

通过桥接模式,我们学会了如何将对象及其实现与PrintMessage方法解耦。这样,我们可以重用其抽象以及其实现。我们可以随意交换打印机抽象以及打印机 API,而不影响用户代码。

我们还尽量保持事情尽可能简单,但我相信您已经意识到,所有PrinterAPI接口的实现都可以使用工厂来创建。这将是非常自然的,您可能会发现许多实现都遵循了这种方法。然而,我们不应该陷入过度设计,而应该分析每个问题,以精确地设计其需求,并找到创建可重用、可维护和可读源代码的最佳方式。可读的代码通常被遗忘,但如果没有人能够理解和维护它,那么强大而不耦合的源代码就是无用的。这就像十世纪的书籍一样--它可能是一部宝贵的故事,但如果我们难以理解它的语法,那就会非常令人沮丧。

总结

在本章中,我们已经看到了组合的力量,以及 Go 语言如何利用它的本质。我们已经看到适配器模式可以帮助我们通过在两个不兼容的接口之间使用“适配器”对象来使它们一起工作。同时,我们在 Go 语言的源代码中看到了一些真实的例子,语言的创建者使用了这种设计模式来改进标准库中某个特定部分的可能性。最后,我们已经看到了桥接模式及其可能性,允许我们在对象和它们的实现之间创建可完全重用的交换结构。

此外,在整个章节中,我们一直在使用组合设计模式,不仅仅是在解释它时。我们之前提到过它,但设计模式经常彼此使用。我们使用纯粹的组合而不是嵌入来增加可读性,但是,正如你已经学到的,根据需要可以互换使用两者。在接下来的章节中,我们将继续使用组合模式,因为它是构建 Go 编程语言中关系的基础。

第四章:结构模式 - 代理,外观,装饰器和享元设计模式

通过本章,我们将完成结构模式。我们将最复杂的一些模式留到最后,以便您更加熟悉设计模式的机制和 Go 语言的特性。

在本章中,我们将致力于编写一个用于访问数据库的缓存,一个用于收集天气数据的库,一个带有运行时中间件的服务器,并讨论通过在类型值之间保存可共享状态来节省内存的方法。

代理设计模式

我们将以代理模式开始最终章节。这是一个简单的模式,可以提供有趣的功能和可能性,而且只需很少的努力。

描述

代理模式通常包装一个对象,以隐藏其某些特征。这些特征可能是它是一个远程对象(远程代理),一个非常重的对象,例如非常大的图像或千兆字节数据库的转储(虚拟代理),或者是一个受限制的访问对象(保护代理)。

目标

代理模式的可能性很多,但总的来说,它们都试图提供以下相同的功能:

  • 隐藏对象在代理后面,以便可以隐藏,限制等功能

  • 提供一个易于使用和易于更改的新抽象层

示例

对于我们的示例,我们将创建一个远程代理,它将是在访问数据库之前对象的缓存。假设我们有一个包含许多用户的数据库,但是我们不会每次想要获取有关用户的信息时都访问数据库,而是在代理模式下拥有一个用户的先进先出FIFO)堆栈(FIFO 是一种说法,当缓存需要清空时,它将删除最先进入的对象)。

验收标准

我们将使用代理模式包装一个由切片表示的想象数据库。然后,该模式将必须遵循以下验收标准:

  1. 所有对用户数据库的访问都将通过代理类型完成。

  2. 代理中将保留n个最近用户的堆栈。

  3. 如果用户已经存在于堆栈中,则不会查询数据库,并将返回存储的用户

  4. 如果查询的用户不在堆栈中,则将查询数据库,如果堆栈已满,则删除堆栈中最旧的用户,存储新用户,并返回它。

单元测试

自 Go 的 1.7 版本以来,我们可以通过使用闭包在测试中嵌入测试,以便以更易读的方式对它们进行分组,并减少Test_函数的数量。请参阅第一章,准备...开始...Go!,了解如何安装新版本的 Go,如果您当前的版本早于 1.7 版本。

此模式的类型将是代理用户和用户列表结构以及UserFinder接口,数据库和代理将实现该接口。这很关键,因为代理必须实现与其尝试包装的类型的特性相同的接口:

type UserFinder interface { 
  FindUser(id int32) (User, error) 
} 

UserFinder是数据库和代理实现的接口。User是一种具有名为ID的成员的类型,它是int32类型:

type User struct { 
  ID int32 
} 

最后,UserList是用户切片的一种类型。考虑以下语法:

type UserList []User 

如果您想知道为什么我们不直接使用用户切片,答案是,通过这种方式声明用户序列,我们可以实现UserFinder接口,但是使用切片,我们无法。

最后,代理类型称为UserListProxy,将由UserList切片组成,这将是我们的数据库表示。StackCache成员也将是UserList类型,以简化StackCapacity,以便给我们的堆栈指定大小。

为了本教程的目的,我们将稍微作弊,声明一个名为DidDidLastSearchUsedCache的字段上的布尔状态,该状态将保存上次执行的搜索是否使用了缓存,或者是否访问了数据库。

type UserListProxy struct { 
  SomeDatabase UserList 
  StackCache UserList 
  StackCapacity int 
  DidDidLastSearchUsedCache bool 
} 

func (u *UserListProxy) FindUser(id int32) (User, error) { 
  return User{}, errors.New("Not implemented yet") 
} 

UserListProxy类型将缓存最多StackCapacity个用户,并在达到此限制时旋转缓存。StackCache成员将从SomeDatabase类型的对象中填充。

第一个测试称为TestUserListProxy,并列在下面:

import ( 
   "math/rand" 
   "testing" 
) 

func Test_UserListProxy(t *testing.T) { 
  someDatabase := UserList{} 

  rand.Seed(2342342) 
  for i := 0; i < 1000000; i++ { 
    n := rand.Int31() 
    someDatabase = append(someDatabase, User{ID: n}) 
  } 

前面的测试创建了一个包含随机名称的 100 万用户的用户列表。为此,我们通过调用Seed()函数使用一些常量种子来为随机数生成器提供输入,以便我们的随机化结果也是常量;用户 ID 是从中生成的。它可能有一些重复,但它满足了我们的目的。

接下来,我们需要一个代理,它引用了刚刚创建的someDatabase

proxy := UserListProxy{ 
  SomeDatabase:  &someDatabase, 
  StackCapacity:  2, 
  StackCache: UserList{}, 
} 

此时,我们有一个由 1 百万用户组成的模拟数据库和一个大小为 2 的 FIFO 堆栈实现的缓存的proxy对象。现在我们将从someDatabase中获取三个随机 ID 来使用我们的堆栈:

knownIDs := [3]int32 {someDatabase[3].ID, someDatabase[4].ID,someDatabase[5].ID} 

我们从切片中取出了第四、第五和第六个 ID(请记住,数组和切片从 0 开始,因此索引 3 实际上是切片中的第四个位置)。

这将是我们在启动嵌入式测试之前的起点。要创建嵌入式测试,我们必须调用testing.T指针的Run方法,其中包括描述和具有func(t *testing.T)签名的闭包:

t.Run("FindUser - Empty cache", func(t *testing.T) { 
  user, err := proxy.FindUser(knownIDs[0]) 
  if err != nil { 
    t.Fatal(err) 
  } 

例如,在前面的代码片段中,我们给出了描述FindUser - Empty cache。然后我们定义我们的闭包。首先它尝试查找具有已知 ID 的用户,并检查错误。由于描述暗示,此时缓存为空,用户将不得不从someDatabase数组中检索:

  if user.ID != knownIDs[0] { 
    t.Error("Returned user name doesn't match with expected") 
  } 

  if len(proxy.StackCache) != 1 { 
    t.Error("After one successful search in an empty cache, the size of it must be one") 
  } 

  if proxy.DidLastSearchUsedCache { 
    t.Error("No user can be returned from an empty cache") 
  } 
} 

最后,我们检查返回的用户是否具有与knownIDs切片的索引 0 处的预期用户相同的 ID,并且代理缓存现在的大小为 1。成员DidLastSearchUsedCache的状态代理不能是true,否则我们将无法通过测试。请记住,此成员告诉我们上次搜索是从表示数据库的切片中检索的,还是从缓存中检索的。

代理模式的第二个嵌入式测试是要求与之前相同的用户,现在必须从缓存中返回。这与以前的测试非常相似,但现在我们必须检查用户是否从缓存中返回:

t.Run("FindUser - One user, ask for the same user", func(t *testing.T) { 
  user, err := proxy.FindUser(knownIDs[0]) 
  if err != nil { 
    t.Fatal(err) 
  } 

  if user.ID != knownIDs[0] { 
    t.Error("Returned user name doesn't match with expected") 
  } 

  if len(proxy.StackCache) != 1 { 
    t.Error("Cache must not grow if we asked for an object that is stored on it") 
  } 

  if !proxy.DidLastSearchUsedCache { 
    t.Error("The user should have been returned from the cache") 
  } 
}) 

因此,我们再次要求第一个已知的 ID。代理缓存在此搜索后必须保持大小为 1,并且这次DidLastSearchUsedCache成员必须为 true,否则测试将失败。

最后的测试将使proxy类型的StackCache数组溢出。我们将搜索两个新用户,我们的proxy类型将不得不从数据库中检索这些用户。我们的堆栈大小为 2,因此它将不得不删除第一个用户以为第二个和第三个用户分配空间:

user1, err := proxy.FindUser(knownIDs[0]) 
if err != nil { 
  t.Fatal(err) 
} 

user2, _ := proxy.FindUser(knownIDs[1]) 
if proxy.DidLastSearchUsedCache { 
  t.Error("The user wasn't stored on the proxy cache yet") 
} 

user3, _ := proxy.FindUser(knownIDs[2]) 
if proxy.DidLastSearchUsedCache { 
  t.Error("The user wasn't stored on the proxy cache yet") 
} 

我们已经检索到了前三个用户。我们不检查错误,因为这是以前测试的目的。重要的是要记住,没有必要过度测试您的代码。如果这里有任何错误,它将在以前的测试中出现。此外,我们已经检查了user2user3查询是否未使用缓存;它们不应该被存储在那里。

现在我们将在代理中查找user1查询。它不应该存在,因为堆栈的大小为 2,而user1是第一个进入的,因此也是第一个出去的:

for i := 0; i < len(proxy.StackCache); i++ { 
  if proxy.StackCache[i].ID == user1.ID { 
    t.Error("User that should be gone was found") 
  } 
} 

if len(proxy.StackCache) != 2 { 
  t.Error("After inserting 3 users the cache should not grow" + 
" more than to two") 
} 

无论我们要求一千个用户,我们的缓存都不能大于我们配置的大小。

最后,我们将再次遍历存储在缓存中的用户,并将它们与我们查询的最后两个用户进行比较。这样,我们将检查只有这些用户存储在缓存中。两者都必须在其中找到:

  for _, v := range proxy.StackCache { 
    if v != user2 && v != user3 { 
      t.Error("A non expected user was found on the cache") 
    } 
  } 
} 

现在运行测试应该会出现一些错误,像往常一样。现在让我们运行它们:

$ go test -v .
=== RUN   Test_UserListProxy
=== RUN   Test_UserListProxy/FindUser_-_Empty_cache
=== RUN   Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user
=== RUN   Test_UserListProxy/FindUser_-_overflowing_the_stack
--- FAIL: Test_UserListProxy (0.06s)
 --- FAIL: Test_UserListProxy/FindUser_-_Empty_cache (0.00s)
 proxy_test.go:28: Not implemented yet
 --- FAIL: Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user (0.00s)
 proxy_test.go:47: Not implemented yet
 --- FAIL: Test_UserListProxy/FindUser_-_overflowing_the_stack (0.00s)
 proxy_test.go:66: Not implemented yet
FAIL
exit status 1
FAIL

因此,让我们实现FindUser方法以充当我们的代理。

实施

在我们的代理中,FindUser方法将在缓存列表中搜索指定的 ID。如果找到它,它将返回 ID。如果没有找到,它将在数据库中搜索。最后,如果它不在数据库列表中,它将返回一个错误。

如果您记得,我们的代理模式由两种UserList类型组成(其中一种是指针),它们实际上是User类型的切片。我们还将在User类型中实现一个FindUser方法,该方法与UserFinder接口具有相同的签名:

type UserList []User 

func (t *UserList) FindUser(id int32) (User, error) { 
  for i := 0; i < len(*t); i++ { 
    if (*t)[i].ID == id { 
      return (*t)[i], nil 
    } 
  } 
  return User{}, fmt.Errorf("User %s could not be found\n", id) 
} 

UserList切片中的FindUser方法将遍历列表,尝试找到与id参数相同 ID 的用户,或者如果找不到则返回错误。

您可能想知道为什么指针t在括号之间。这是为了在访问其索引之前取消引用底层数组。如果没有它,您将会遇到编译错误,因为编译器会在取消引用指针之前尝试搜索索引。

因此,代理FindUser方法的第一部分可以编写如下:

func (u *UserListProxy) FindUser(id int32) (User, error) { 
  user, err := u.StackCache.FindUser(id) 
  if err == nil { 
    fmt.Println("Returning user from cache") 
    u.DidLastSearchUsedCache = true 
    return user, nil 
  } 

我们使用上述方法在StackCache成员中搜索用户。如果找到用户,错误将为 nil,因此我们检查这一点,以便在控制台打印一条消息,将DidLastSearchUsedCache的状态更改为true,以便测试可以检查用户是否从缓存中检索,并最终返回用户。

因此,如果错误不是 nil,则意味着它无法在堆栈中找到用户。因此,下一步是在数据库中搜索:

  user, err = u.SomeDatabase.FindUser(id) 
  if err != nil { 
    return User{}, err 
  } 

在这种情况下,我们可以重用我们为UserList数据库编写的FindUser方法,因为在这个例子的目的上,两者具有相同的类型。同样,它在数据库中搜索由UserList切片表示的用户,但在这种情况下,如果找不到用户,则返回UserList中生成的错误。

当找到用户(err为 nil)时,我们必须将用户添加到堆栈中。为此,我们编写了一个专用的私有方法,该方法接收UserListProxy类型的指针:

func (u *UserListProxy) addUserToStack(user User) { 
  if len(u.StackCache) >= u.StackCapacity { 
    u.StackCache = append(u.StackCache[1:], user) 
  } 
  else { 
    u.StackCache.addUser(user) 
  } 
} 

func (t *UserList) addUser(newUser User) { 
  *t = append(*t, newUser) 
} 

addUserToStack方法接受用户参数,并将其放置在堆栈中。如果堆栈已满,则在添加之前删除其中的第一个元素。我们还编写了一个addUser方法来帮助我们在UserList中。因此,现在在FindUser方法中,我们只需添加一行:

u.addUserToStack(user) 

这将新用户添加到堆栈中,必要时删除最后一个。

最后,我们只需返回堆栈的新用户,并在DidLastSearchUsedCache变量上设置适当的值。我们还向控制台写入一条消息,以帮助测试过程:

  fmt.Println("Returning user from database") 
  u.DidLastSearchUsedCache = false 
  return user, nil 
} 

有了这个,我们就有足够的内容来通过我们的测试:

$ go test -v .
=== RUN   Test_UserListProxy
=== RUN   Test_UserListProxy/FindUser_-_Empty_cache
Returning user from database
=== RUN   Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user
Returning user from cache
=== RUN   Test_UserListProxy/FindUser_-_overflowing_the_stack
Returning user from cache
Returning user from database
Returning user from database
--- PASS: Test_UserListProxy (0.09s) 
--- PASS: Test_UserListProxy/FindUser_-_Empty_cache (0.00s)
--- PASS: Test_UserListProxy/FindUser_-_One_user,_ask_for_the_same_user (0.00s)
--- PASS: Test_UserListProxy/FindUser_-_overflowing_the_stack (0.00s)
PASS
ok

您可以在前面的消息中看到,我们的代理已经完美地工作。它已经从数据库中返回了第一次搜索。然后,当我们再次搜索相同的用户时,它使用了缓存。最后,我们进行了一个新的测试,调用了三个不同的用户,通过查看控制台输出,我们可以观察到只有第一个用户是从缓存中返回的,其他两个是从数据库中获取的。

围绕操作进行代理

在需要进行一些中间操作的类型周围包装代理,比如为用户提供授权或提供对数据库的访问,就像我们的示例一样。

我们的示例是将应用程序需求与数据库需求分离的好方法。如果我们的应用程序对数据库的访问过多,解决方案并不在于数据库。请记住,代理使用与其包装的类型相同的接口,对于用户来说,两者之间不应该有任何区别。

装饰器设计模式

我们将继续本章,介绍代理模式的大哥,也许是最强大的设计模式之一。装饰器模式非常简单,但是在处理旧代码时提供了许多好处。

描述

装饰器设计模式允许您在不实际触及它的情况下为已经存在的类型添加更多的功能特性。这是如何可能的呢?嗯,它使用了一种类似于玛特里奥什卡娃娃的方法,您可以将一个小娃娃放在一个相同形状但更大的娃娃中,依此类推。

装饰器类型实现了它装饰的类型的相同接口,并在其成员中存储该类型的实例。这样,您可以通过简单地将旧的装饰器存储在新装饰器的字段中来堆叠尽可能多的装饰器(玩偶)。

目标

当您考虑扩展旧代码而不会破坏任何东西时,您应该首先考虑装饰器模式。这是一种处理这个特定问题的非常强大的方法。

装饰器非常强大的另一个领域可能并不那么明显,尽管当基于用户输入、偏好或类似输入创建具有许多功能的类型时,它会显现出来。就像瑞士军刀一样,您有一个基本类型(刀的框架),然后您展开其功能。

那么,我们什么时候会使用装饰器模式呢?对这个问题的回答:

  • 当您需要向一些无法访问的代码添加功能,或者您不希望修改以避免对代码产生负面影响,并遵循开放/封闭原则(如旧代码)时。

  • 当您希望动态创建或更改对象的功能,并且功能数量未知且可能快速增长时

示例

在我们的示例中,我们将准备一个Pizza类型,其中核心是披萨,配料是装饰类型。我们的披萨上会有一些配料,比如洋葱和肉。

验收标准

装饰器模式的验收标准是具有一个公共接口和一个核心类型,所有层都将在其上构建:

  • 我们必须有所有装饰器都将实现的主要接口。这个接口将被称为IngredientAdd,它将具有AddIngredient() string方法。

  • 我们必须有一个核心PizzaDecorator类型(装饰器),我们将向其添加配料。

  • 我们必须有一个实现相同IngredientAdd接口的配料onion,它将向返回的披萨添加字符串onion

  • 我们必须有一个实现IngredientAdd接口的配料meat,它将向返回的披萨添加字符串meat

  • 在顶层对象上调用AddIngredient方法时,它必须返回一个带有文本Pizza with the following ingredients: meat, onion的完全装饰的pizza

单元测试

要启动我们的单元测试,我们必须首先根据验收标准创建基本结构。首先,所有装饰类型必须实现的接口如下:

type IngredientAdd interface { 
  AddIngredient() (string, error) 
} 

以下代码定义了PizzaDecorator类型,其中必须包含IngredientAdd,并且它也实现了IngredientAdd

type PizzaDecorator struct{ 
  Ingredient IngredientAdd 
} 

func (p *PizzaDecorator) AddIngredient() (string, error) { 
  return "", errors.New("Not implemented yet") 
} 

Meat类型的定义将与PizzaDecorator结构的定义非常相似:

type Meat struct { 
  Ingredient IngredientAdd 
} 

func (m *Meat) AddIngredient() (string, error) { 
  return "", errors.New("Not implemented yet") 
} 

现在我们以类似的方式定义Onion结构体:

type Onion struct { 
  Ingredient IngredientAdd 
} 

func (o *Onion) AddIngredient() (string, error) { 
  return "", errors.New("Not implemented yet") 
}  

这已足以实现第一个单元测试,并允许编译器在没有任何编译错误的情况下运行它们:

func TestPizzaDecorator_AddIngredient(t *testing.T) { 
  pizza := &PizzaDecorator{} 
  pizzaResult, _ := pizza.AddIngredient() 
  expectedText := "Pizza with the following ingredients:" 
  if !strings.Contains(pizzaResult, expectedText) { 
    t.Errorf("When calling the add ingredient of the pizza decorator it must return the text %sthe expected text, not '%s'", pizzaResult, expectedText) 
  } 
} 

现在它必须能够无问题地编译,这样我们就可以检查测试是否失败:

$ go test -v -run=TestPizzaDecorator .
=== RUN   TestPizzaDecorator_AddIngredient
--- FAIL: TestPizzaDecorator_AddIngredient (0.00s)
decorator_test.go:29: Not implemented yet
decorator_test.go:34: When the the AddIngredient method of the pizza decorator object is called, it must return the text
Pizza with the following ingredients:
FAIL
exit status 1
FAIL 

我们的第一个测试已经完成,我们可以看到PizzaDecorator结构体还没有返回任何东西,这就是为什么它失败了。现在我们可以继续进行Onion类型的测试。Onion类型的测试与Pizza装饰器的测试非常相似,但我们还必须确保我们实际上将配料添加到IngredientAdd方法而不是空指针:

func TestOnion_AddIngredient(t *testing.T) { 
  onion := &Onion{} 
  onionResult, err := onion.AddIngredient() 
  if err == nil { 
    t.Errorf("When calling AddIngredient on the onion decorator without" + "an IngredientAdd on its Ingredient field must return an error, not a string with '%s'", onionResult) 
  } 

前面测试的前半部分检查了当没有将IngredientAdd方法传递给Onion结构体初始化程序时返回错误。由于没有可用的披萨来添加配料,必须返回错误:

  onion = &Onion{&PizzaDecorator{}} 
  onionResult, err = onion.AddIngredient() 

  if err != nil { 
    t.Error(err) 
  } 
  if !strings.Contains(onionResult, "onion") { 
    t.Errorf("When calling the add ingredient of the onion decorator it" + "must return a text with the word 'onion', not '%s'", onionResult) 
  } 
} 

Onion类型测试的第二部分实际上将PizzaDecorator结构传递给初始化程序。然后,我们检查是否没有返回错误,以及返回的字符串是否包含单词onion。这样,我们可以确保洋葱已添加到比萨中。

最后对于Onion类型,我们当前实现的测试的控制台输出将如下所示:

$ go test -v -run=TestOnion_AddIngredient .
=== RUN   TestOnion_AddIngredient
--- FAIL: TestOnion_AddIngredient (0.00s)
decorator_test.go:48: Not implemented yet
decorator_test.go:52: When calling the add ingredient of the onion decorator it must return a text with the word 'onion', not ''
FAIL
exit status 1
FAIL

meat成分完全相同,但我们将类型更改为肉而不是洋葱:

func TestMeat_AddIngredient(t *testing.T) { 
  meat := &Meat{} 
  meatResult, err := meat.AddIngredient() 
  if err == nil { 
    t.Errorf("When calling AddIngredient on the meat decorator without" + "an IngredientAdd in its Ingredient field must return an error," + "not a string with '%s'", meatResult) 
  } 

  meat = &Meat{&PizzaDecorator{}} 
  meatResult, err = meat.AddIngredient() 
  if err != nil { 
    t.Error(err) 
  } 

  if !strings.Contains(meatResult, "meat") { 
    t.Errorf("When calling the add ingredient of the meat decorator it" + "must return a text with the word 'meat', not '%s'", meatResult) 
  } 
} 

因此,测试的结果将是类似的:

go test -v -run=TestMeat_AddIngredient .
=== RUN   TestMeat_AddIngredient
--- FAIL: TestMeat_AddIngredient (0.00s)
decorator_test.go:68: Not implemented yet
decorator_test.go:72: When calling the add ingredient of the meat decorator it must return a text with the word 'meat', not ''
FAIL
exit status 1
FAIL

最后,我们必须检查完整的堆栈测试。创建一个带有洋葱和肉的比萨必须返回文本带有以下配料的比萨:肉,洋葱

func TestPizzaDecorator_FullStack(t *testing.T) { 
  pizza := &Onion{&Meat{&PizzaDecorator{}}} 
  pizzaResult, err := pizza.AddIngredient() 
  if err != nil { 
    t.Error(err) 
  } 

  expectedText := "Pizza with the following ingredients: meat, onion" 
  if !strings.Contains(pizzaResult, expectedText){ 
    t.Errorf("When asking for a pizza with onion and meat the returned " + "string must contain the text '%s' but '%s' didn't have it", expectedText,pizzaResult) 
  } 

  t.Log(pizzaResult) 
} 

我们的测试创建了一个名为pizza的变量,就像套娃玩偶一样,嵌入了多个级别的IngredientAdd方法的类型。调用AddIngredient方法执行"洋葱"级别的方法,该方法执行"肉"级别的方法,最后执行PizzaDecorator结构的方法。在检查是否没有返回错误后,我们检查返回的文本是否符合验收标准 5的需求。测试使用以下命令运行:

go test -v -run=TestPizzaDecorator_FullStack .
=== RUN   TestPizzaDecorator_FullStack
--- FAIL: TestPizzaDecorator_FullStack (0.
decorator_test.go:80: Not implemented yet
decorator_test.go:87: When asking for a pizza with onion and meat the returned string must contain the text 'Pizza with the following ingredients: meat, onion' but '' didn't have it
FAIL
exit status 1
FAIL

从前面的输出中,我们可以看到测试现在为我们装饰的类型返回一个空字符串。当然,这是因为尚未进行任何实现。这是最后一个测试,用于检查完全装饰的实现。然后让我们仔细看看实现。

实施

我们将开始实现PizzaDecorator类型。它的作用是提供完整比萨的初始文本:

type PizzaDecorator struct { 
  Ingredient IngredientAdd 
} 

func (p *PizzaDecorator) AddIngredient() (string, error) { 
  return "Pizza with the following ingredients:", nil 
} 

AddIngredient方法的返回上进行了一行更改就足以通过测试:

go test -v -run=TestPizzaDecorator_Add .
=== RUN   TestPizzaDecorator_AddIngredient
--- PASS: TestPizzaDecorator_AddIngredient (0.00s)
PASS
ok

转到Onion结构的实现,我们必须取得我们返回的IngredientAdd字符串的开头,并在其末尾添加单词onion,以便得到一份组合的比萨:

type Onion struct { 
  Ingredient IngredientAdd 
} 

func (o *Onion) AddIngredient() (string, error) { 
  if o.Ingredient == nil { 
    return "", errors.New("An IngredientAdd is needed in the Ingredient field of the Onion") 
  } 
  s, err := o.Ingredient.AddIngredient() 
  if err != nil { 
    return "", err 
  } 
  return fmt.Sprintf("%s %s,", s, "onion"), nil 
} 

首先检查我们是否实际上有一个指向IngredientAdd的指针,我们使用内部IngredientAdd的内容,并检查是否有错误。如果没有错误发生,我们将收到一个由此内容、一个空格和单词onion(没有错误)组成的新字符串。看起来足够好来运行测试:

go test -v -run=TestOnion_AddIngredient .
=== RUN   TestOnion_AddIngredient
--- PASS: TestOnion_AddIngredient (0.00s)
PASS
ok

Meat结构的实现非常相似:

type Meat struct { 
  Ingredient IngredientAdd 
} 

func (m *Meat) AddIngredient() (string, error) { 
  if m.Ingredient == nil { 
    return "", errors.New("An IngredientAdd is needed in the Ingredient field of the Meat") 
  } 
  s, err := m.Ingredient.AddIngredient() 
  if err != nil { 
    return "", err 
  } 
  return fmt.Sprintf("%s %s,", s, "meat"), nil 
} 

他们的测试执行如下:

go test -v -run=TestMeat_AddIngredient .
=== RUN   TestMeat_AddIngredient
--- PASS: TestMeat_AddIngredient (0.00s)
PASS
ok

好的。现在所有的部分都要分别测试。如果一切正常,完全堆叠解决方案的测试必须顺利通过:

go test -v -run=TestPizzaDecorator_FullStack .
=== RUN   TestPizzaDecorator_FullStack
--- PASS: TestPizzaDecorator_FullStack (0.00s)
decorator_test.go:92: Pizza with the following ingredients: meat, onion,
PASS
ok

太棒了!使用装饰器模式,我们可以不断堆叠调用它们内部指针以向PizzaDecorator添加功能的IngredientAdds。我们也不会触及核心类型,也不会修改或实现新的东西。所有新功能都是由外部类型实现的。

一个现实生活的例子-服务器中间件

到目前为止,您应该已经了解了装饰器模式的工作原理。现在我们可以尝试使用我们在适配器模式部分设计的小型 HTTP 服务器的更高级示例。您已经学会了可以使用http包创建 HTTP 服务器,并实现http.Handler接口。该接口只有一个名为ServeHTTP(http.ResponseWriter, http.Request)的方法。我们可以使用装饰器模式为服务器添加更多功能吗?当然可以!

我们将向此服务器添加一些部分。首先,我们将记录对其进行的每个连接到io.Writer接口(为简单起见,我们将使用os.Stdout接口的io.Writer实现,以便将其输出到控制台)。第二部分将在发送到服务器的每个请求上添加基本的 HTTP 身份验证。如果身份验证通过,将出现Hello Decorator!消息。最后,用户将能够选择他/她在服务器中想要的装饰项的数量,并且服务器将在运行时进行结构化和创建。

从常见接口 http.Handler 开始

我们已经有了我们将使用嵌套类型进行装饰的通用接口。我们首先需要创建我们的核心类型,这将是返回句子Hello Decorator!Handler

type MyServer struct{} 

func (m *MyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
  fmt.Fprintln(w, "Hello Decorator!") 
} 

这个处理程序可以归因于http.Handle方法,以定义我们的第一个端点。现在让我们通过创建包的main函数来检查这一点,并向其发送一个GET请求:

func main() { 
  http.Handle("/", &MyServer{}) 

  log.Fatal(http.ListenAndServe(":8080", nil)) 
} 

使用终端执行服务器以执行**go run main.go**命令。然后,打开一个新的终端进行GET请求。我们将使用curl命令进行请求:

$ curl http://localhost:8080
Hello Decorator!

我们已经跨越了我们装饰服务器的第一个里程碑。下一步是用日志功能装饰它。为此,我们必须实现http.Handler接口,以新类型的形式进行如下实现:

type LoggerServer struct { 
  Handler   http.Handler 
  LogWriter io.Writer 
} 

func (s *LoggerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
  fmt.Fprintf(s.LogWriter, "Request URI: %s\n", r.RequestURI) 
  fmt.Fprintf(s.LogWriter, "Host: %s\n", r.Host) 
  fmt.Fprintf(s.LogWriter, "Content Length: %d\n",  
r.ContentLength) 
  fmt.Fprintf(s.LogWriter, "Method: %s\n", r.Method)fmt.Fprintf(s.LogWriter, "--------------------------------\n") 

  s.Handler.ServeHTTP(w, r) 
} 

我们称这种类型为LoggerServer。正如你所看到的,它不仅存储Handler,还存储io.Writer以写入日志的输出。我们的ServeHTTP方法的实现打印请求 URI、主机、内容长度和使用的方法io.Writer。打印完成后,它调用其内部Handler字段的ServeHTTP函数。

我们可以用LoggerMiddleware装饰MyServer

func main() { 
  http.Handle("/", &LoggerServer{ 
    LogWriter:os.Stdout, 
    Handler:&MyServer{}, 
  }) 

  log.Fatal(http.ListenAndServe(":8080", nil)) 
} 

现在运行**curl **命令:

$ curl http://localhost:8080
Hello Decorator!

我们的curl命令返回相同的消息,但是如果你查看运行 Go 应用程序的终端,你可以看到日志:

$ go run server_decorator.go
Request URI: /
Host: localhost:8080
Content Length: 0
Method: GET

我们已经用日志功能装饰了MyServer,而实际上并没有修改它。我们能否用相同的方法进行身份验证?当然可以!在记录请求后,我们将使用HTTP 基本身份验证进行身份验证:

type BasicAuthMiddleware struct { 
  Handler  http.Handler 
  User     string 
  Password string 
} 

BasicAuthMiddleware中间件存储三个字段--一个要装饰的处理程序,就像前面的中间件一样,一个用户和一个密码,这将是访问服务器内容的唯一授权。decorating方法的实现将如下进行:

func (s *BasicAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
  user, pass, ok := r.BasicAuth() 

  if ok { 
    if user == s.User && pass == s.Password { 
      s.Handler.ServeHTTP(w, r) 
    } 
    else { 
      fmt.Fprintf(w, "User or password incorrect\n") 
    } 
  } 
  else { 
    fmt.Fprintln(w, "Error trying to retrieve data from Basic auth") 
  } 
} 

在前面的实现中,我们使用http.RequestBasicAuth方法自动从请求中检索用户和密码,以及解析操作的ok/ko。然后我们检查解析是否正确(如果不正确则向请求者返回消息,并结束请求)。如果在解析过程中没有检测到问题,我们将检查用户名和密码是否与BasicAuthMiddleware中存储的用户名和密码匹配。如果凭据有效,我们将调用装饰类型(我们的服务器),但如果凭据无效,我们将收到用户或密码不正确的消息,并结束请求。

现在,我们需要为用户提供一种选择不同类型服务器的方式。我们将在主函数中检索用户输入数据。我们将有三个选项可供选择:

  • 简单服务器

  • 带有日志的服务器

  • 带有日志和身份验证的服务器

我们必须使用Fscanf函数从用户那里检索输入:

func main() { 
  fmt.Println("Enter the type number of server you want to launch from the  following:") 
  fmt.Println("1.- Plain server") 
  fmt.Println("2.- Server with logging") 
  fmt.Println("3.- Server with logging and authentication") 

  var selection int 
  fmt.Fscanf(os.Stdin, "%d", &selection) 
} 

Fscanf函数需要一个io.Reader实现者作为第一个参数(这将是控制台中的输入),并从中获取用户选择的服务器。我们将传递os.Stdin作为io.Reader接口来检索用户输入。然后,我们将写入它将要解析的数据类型。%d指定符指的是整数。最后,我们将写入存储解析输入的内存地址,即selection变量的内存位置。

一旦用户选择了一个选项,我们就可以在运行时获取基本服务器并进行装饰,切换到所选的选项:

   switch selection { 
   case 1: 
     mySuperServer = new(MyServer) 
   case 2: 
     mySuperServer = &LoggerMiddleware{ 
       Handler:   new(MyServer), 
       LogWriter: os.Stdout, 
     } 
   case 3: 
     var user, password string 

     fmt.Println("Enter user and password separated by a space") 
     fmt.Fscanf(os.Stdin, "%s %s", &user, &password) 

     mySuperServer = &LoggerMiddleware{ 
     Handler: &SimpleAuthMiddleware{ 
       Handler:  new(MyServer), 
       User:     user, 
       Password: password, 
     }, 
     LogWriter: os.Stdout, 
   } 
   default: 
   mySuperServer = new(MyServer) 
 } 

第一个选项将由默认的switch选项处理--一个普通的MyServer。在第二个选项的情况下,我们用日志装饰了一个普通服务器。第三个选项更加复杂--我们再次使用Fscanf要求用户输入用户名和密码。请注意,您可以扫描多个输入,就像我们正在检索用户和密码一样。然后,我们获取基本服务器,用身份验证进行装饰,最后再加上日志。

如果您遵循第三个选项的嵌套类型的缩进,请求将通过记录器,然后通过身份验证中间件,最后,如果一切正常,将通过MyServer参数。请求将遵循相同的路线。

主函数的结尾采用了装饰处理程序,并在8080端口上启动服务器:

http.Handle("/", mySuperServer) 
log.Fatal(http.ListenAndServe(":8080", nil)) 

因此,让我们使用第三个选项启动服务器:

$go run server_decorator.go 
Enter the server type number you want to launch from the following: 
1.- Plain server 
2.- Server with logging 
3.- Server with logging and authentication 

Enter user and password separated by a space 
mario castro

首先,我们将通过选择第一个选项来测试普通服务器。使用命令go run server_decorator.go运行服务器,并选择第一个选项。然后,在另一个终端中,使用 curl 运行基本请求,如下所示:

$ curl http://localhost:8080
Error trying to retrieve data from Basic auth

哦,不!它没有给我们访问权限。我们没有传递任何用户名和密码,因此它告诉我们我们无法继续。让我们尝试一些随机的用户名和密码:

$ curl -u no:correct http://localhost:8080
User or password incorrect

没有访问权限!我们还可以在启动服务器的终端中检查每个请求的记录位置:

Request URI: /
Host: localhost:8080
Content Length: 0
Method: GET

最后,输入正确的用户名和密码:

$ curl -u packt:publishing http://localhost:8080
Hello Decorator!

我们到这里了!我们的请求也已被记录,服务器已经授予我们访问权限。现在我们可以通过编写更多的中间件来改进服务器的功能。

关于 Go 的结构化类型的几句话

Go 有一个大多数人一开始不喜欢的特性 - 结构化类型。这是指您的结构定义了您的类型,而无需明确编写它。例如,当您实现一个接口时,您不必明确地写出您实际上正在实现它,与 Java 等语言相反,在那里您必须写出关键字implements。如果您的方法遵循接口的签名,那么您实际上正在实现接口。这也可能导致意外实现接口,这可能引起无法跟踪的错误,但这种情况非常罕见。

然而,结构化类型也允许您在定义实现者之后定义接口。想象一个MyPrinter结构如下:

type MyPrinter struct{} 
func(m *MyPrinter)Print(){ 
  println("Hello") 
} 

假设我们现在已经使用MyPrinter类型工作了几个月,但它没有实现任何接口,因此不能成为装饰器模式的可能候选,或者可能可以?如果几个月后编写了一个与其Print方法匹配的接口,会发生什么?考虑以下代码片段:

type Printer interface { 
  Print() 
} 

它实际上实现了Printer接口,我们可以使用它来创建一个装饰器解决方案。

结构化类型在编写程序时提供了很大的灵活性。如果您不确定类型是否应该是接口的一部分,可以将其留下,并在完全确定后再添加接口。这样,您可以非常轻松地装饰类型,并且在源代码中进行很少的修改。

总结装饰器设计模式 - 代理与装饰器

您可能会想知道装饰器模式和代理模式之间有什么区别?在装饰器模式中,我们动态地装饰一个类型。这意味着装饰可能存在也可能不存在,或者可能由一个或多个类型组成。如果您记得,代理模式以类似的方式包装类型,但它是在编译时这样做的,更像是一种访问某种类型的方式。

同时,装饰器可能实现其装饰的类型也实现的整个接口或者不实现。因此,您可以拥有一个具有 10 个方法的接口和一个只实现其中一个方法的装饰器,它仍然有效。对装饰器未实现的方法的调用将传递给装饰的类型。这是一个非常强大的功能,但如果您忘记实现任何接口方法,它也很容易出现运行时的不良行为。

在这方面,你可能会认为代理模式不够灵活,确实如此。但装饰器模式更弱,因为你可能会在运行时出现错误,而使用代理模式可以在编译时避免这些错误。只需记住,装饰器通常用于在运行时向对象添加功能,就像我们的 Web 服务器一样。这是你需要的东西和你愿意牺牲以实现它之间的妥协。

外观设计模式

在本章中我们将看到的下一个模式是外观模式。当我们讨论代理模式时,你了解到它是一种包装类型以隐藏某些特性或复杂性的方式。想象一下,我们将许多代理组合在一个单一点,比如一个文件或一个库。这就是外观模式。

描述

在建筑学中,外观是隐藏建筑物房间和走廊的前墙。它保护居民免受寒冷和雨水的侵袭,并为他们提供隐私。它对住宅进行排序和划分。

外观设计模式在我们的代码中做了相同的事情。它保护代码免受未经授权的访问,对一些调用进行排序,并将复杂性范围隐藏在用户视野之外。

目标

当你想要隐藏某些任务的复杂性时,特别是当大多数任务共享实用程序时(例如在 API 中进行身份验证)。库是外观的一种形式,其中某人必须为开发人员提供一些方法,以便以友好的方式执行某些操作。这样,如果开发人员需要使用你的库,他不需要知道检索所需结果的所有内部任务。

因此,在以下情况下使用外观设计模式:

  • 当你想要减少我们代码的某些部分的复杂性时。你通过提供更易于使用的方法将复杂性隐藏在外观后面。

  • 当你想要将相关的操作分组到一个地方时。

  • 当你想要构建一个库,以便其他人可以使用你的产品而不必担心它是如何工作的。

例子

举例来说,我们将迈出编写访问OpenWeatherMaps服务的自己库的第一步。如果你不熟悉OpenWeatherMap服务,它是一个提供实时天气信息以及历史数据的 HTTP 服务。HTTP REST API 非常易于使用,并且将是一个很好的例子,说明如何为隐藏 REST 服务背后的网络连接的复杂性创建外观模式。

接受标准

OpenWeatherMap API 提供了大量信息,因此我们将专注于通过使用其纬度和经度值在某个地理位置获取实时天气数据。以下是此设计模式的要求和接受标准:

  1. 提供一个单一类型来访问数据。从OpenWeatherMap服务检索到的所有信息都将通过它传递。

  2. 创建一种获取某个国家的某个城市的天气数据的方法。

  3. 创建一种获取某个纬度和经度位置的天气数据的方法。

  4. 只有第二和第三点必须在包外可见;其他所有内容都必须隐藏(包括所有连接相关的数据)。

单元测试

为了开始我们的 API 外观,我们需要一个接口,其中包含接受标准 2接受标准 3中要求的方法:

type CurrentWeatherDataRetriever interface { 
  GetByCityAndCountryCode(city, countryCode string) (Weather, error) 
  GetByGeoCoordinates(lat, lon float32) (Weather, error) 
} 

我们将称接受标准 2GetByCityAndCountryCode;我们还需要一个城市名称和一个国家代码,格式为字符串。国家代码是一个两个字符的代码,代表着世界各国的国际标准化组织ISO)名称。它返回一个Weather值,我们稍后会定义,并且如果出现问题会返回一个错误。

验收标准 3将被称为GetByGeoCoordinates,并且需要float32格式的纬度和经度值。它还将返回Weather值和错误。Weather值将根据OpenWeatherMap API 使用的返回 JSON 进行定义。您可以在网页openweathermap.org/current#current_JSON上找到此 JSON 的描述。

如果查看 JSON 定义,它具有以下类型:

type Weather struct { 
  ID   int    `json:"id"` 
  Name string `json:"name"` 
  Cod  int    `json:"cod"` 
  Coord struct { 
    Lon float32 `json:"lon"` 
    Lat float32 `json:"lat"` 
  } `json:"coord"`  

  Weather []struct { 
    Id          int    `json:"id"` 
    Main        string `json:"main"` 
    Description string `json:"description"` 
    Icon        string `json:"icon"` 
  } `json:"weather"` 

  Base string `json:"base"` 
  Main struct { 
    Temp     float32 `json:"temp"` 
    Pressure float32 `json:"pressure"` 
    Humidity float32 `json:"humidity"` 
    TempMin  float32 `json:"temp_min"` 
    TempMax  float32 `json:"temp_max"` 
  } `json:"main"` 

  Wind struct { 
    Speed float32 `json:"speed"` 
    Deg   float32 `json:"deg"` 
  } `json:"wind"` 

  Clouds struct { 
    All int `json:"all"` 
  } `json:"clouds"` 

  Rain struct { 
    ThreeHours float32 `json:"3h"` 
  } `json:"rain"` 

  Dt  uint32 `json:"dt"` 
  Sys struct { 
    Type    int     `json:"type"` 
    ID      int     `json:"id"` 
    Message float32 `json:"message"` 
    Country string  `json:"country"` 
    Sunrise int     `json:"sunrise"` 
    Sunset  int     `json:"sunset"` 
  }`json:"sys"` 
} 

这是一个相当长的结构,但我们拥有响应可能包含的所有内容。该结构称为Weather,因为它由 ID,名称和代码(Cod)以及一些匿名结构组成,即CoordWeatherBaseMainWindCloudsRainDtSys。我们可以通过给它们命名来在Weather结构之外编写这些匿名结构,但是只有在我们必须单独使用它们时才有用。

在我们的Weather结构中的每个成员和结构之后,您可以找到一个`json:`something`行。当区分 JSON 键名和成员名时,这非常方便。如果 JSON 键是something,我们就不必将我们的成员称为something。例如,我们的 ID 成员在 JSON 响应中将被称为id

为什么我们不将 JSON 键的名称给我们的类型?好吧,如果您的类型中的字段是小写的,则encoding/json包将无法正确解析它们。此外,最后的注释为我们提供了一定的灵活性,不仅可以更改成员名称,还可以省略一些键(如果我们不需要),具有以下签名:

`json:"something,omitempty"`

在末尾使用omitempty,如果此键在 JSON 键的字节表示中不存在,则解析不会失败。

好的,我们的验收标准 1 要求对 API 进行单点访问。这将被称为CurrentWeatherData

type CurrentWeatherData struct { 
  APIkey string 
} 

CurrentWeatherData类型具有 API 密钥作为公共成员以工作。这是因为您必须是OpenWeatherMap中的注册用户才能享受其服务。请参阅OpenWeatherMap API 的网页,了解如何获取 API 密钥的文档。在我们的示例中,我们不需要它,因为我们不打算进行集成测试。

我们需要模拟数据,以便我们可以编写mock函数来检索数据。发送 HTTP 请求时,响应以io.Reader的形式包含在名为 body 的成员中。我们已经使用了实现io.Reader接口的类型,因此这对您来说应该很熟悉。我们的mock函数如下所示:

 func getMockData() io.Reader { 
  response := `{
    "coord":{"lon":-3.7,"lat":40.42},"weather : [{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"base":"stations","main":{"temp":303.56,"pressure":1016.46,"humidity":26.8,"temp_min":300.95,"temp_max":305.93},"wind":{"speed":3.17,"deg":151.001},"rain":{"3h":0.0075},"clouds":{"all":68},"dt":1471295823,"sys":{"type":3,"id":1442829648,"message":0.0278,"country":"ES","sunrise":1471238808,"sunset":1471288232},"id":3117735,"name":"Madrid","cod":200}` 

  r := bytes.NewReader([]byte(response)) 
  return r 
} 

通过对OpenWeatherMap使用 API 密钥进行请求生成了前面的模拟数据。response变量是包含 JSON 响应的字符串。仔细看一下重音符(`)用于打开和关闭字符串。这样,你可以毫无问题地使用任意多的引用。

Further on, we use a special function in the bytes package called NewReader, which accepts an slice of bytes (which we create by converting the type from string), and returns an io.Reader implementor with the contents of the slice. This is perfect to mimic the Body member of an HTTP response.

We will write a test to try response parser. Both methods return the same type, so we can use the same JSON parser for both:


func TestOpenWeatherMap_responseParser(t *testing.T) { 
  r := getMockData() 
  openWeatherMap := CurrentWeatherData{APIkey: ""} 
 
  weather, err := openWeatherMap.responseParser(r) 
  if err != nil { 
    t.Fatal(err) 
  } 
 
  if weather.ID != 3117735 { 
    t.Errorf("Madrid id is 3117735, not %d\n", weather.ID) 
  } 
} 

在前面的测试中,我们首先请求了一些模拟数据,我们将其存储在变量r中。稍后,我们创建了一种叫做openWeatherMapCurrentWeatherData类型。最后,我们请求为提供的 io.Reader 接口的天气值,将其存储在变量weather中。在检查错误后,我们确保 ID 与从getMockData方法获取的模拟数据中存储的 ID 相同。

我们必须在运行测试之前声明responseParser方法,否则代码不会编译:


func (p *CurrentWeatherData) responseParser(body io.Reader) (*Weather, error) { 
  return nil, fmt.Errorf("Not implemented yet") 
} 

有了上述所有内容,我们可以运行这个测试:

go test -v -run=responseParser .
=== RUN   TestOpenWeatherMap_responseParser
--- FAIL: TestOpenWeatherMap_responseParser (0.00s)
        facade_test.go:72: Not implemented yet
FAIL
exit status 1
FAIL

好的。我们不会写更多的测试,因为其余的仅仅是集成测试,这超出了结构模式解释的范围,并会强制我们拥有一个 API 密钥以及互联网连接。如果您想看看这个示例的集成测试是什么样的,请参考随书附带的代码。

实现

首先,我们将实现我们的方法将用于解析OpenWeatherMap REST API 的 JSON 响应的解析器:


func (p *CurrentWeatherData) responseParser(body io.Reader) (*Weather, error) { 
  w := new(Weather) 
  err := json.NewDecoder(body).Decode(w) 
  if err != nil { 
    return nil, err 
  } 
 
  return w, nil 
} 

现在,这应该足以通过测试了:


go test -v -run=responseParser . 
=== RUN   TestOpenWeatherMap_responseParser 
--- PASS: TestOpenWeatherMap_responseParser (0.00s) 
PASS 
ok

至少我们对我们的解析器进行了充分测试。让我们将我们的代码结构化得像一个库。首先,我们将创建通过城市名和国家代码以及通过其纬度和经度来检索城市天气的方法,以及使用其纬度和经度的方法:


func (c *CurrentWeatherData) GetByGeoCoordinates(lat, lon float32) (weather *Weather, err error) { 
  return c.doRequest( 
  fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather q=%s,%s&APPID=%s", lat, lon, c.APIkey)) 
} 
 
func (c *CurrentWeatherData) GetByCityAndCountryCode(city, countryCode string) (weather *Weather, err error) { 
  return c.doRequest(   
  fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&APPID=%s", city, countryCode, c.APIkey) ) 
} 

小菜一碟?当然!一切都必须尽可能简单,并且这是一项出色工作的标志。这个外观中的复杂性在于创建与OpenWeatherMap API 的连接,以及控制可能的错误。这个问题在我们的示例中的所有 Facade 方法之间共享,所以我们暂时不需要编写多个 API 调用。

我们所做的是传递 REST API 需要的 URL 以便返回我们想要的信息。这是通过 fmt.Sprintf 函数实现的,该函数在每种情况下格式化字符串。例如,为了使用城市名和国家代码获取数据,我们使用以下字符串:


fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&APPID=%s", city, countryCode, c.APIkey)

这需要预先格式化的字符串openweathermap.org/api,并通过用城市、我们在参数中引入的countryCodeCurrentWeatherData类型的 API 密钥成员来替换每个 %s 指定符来格式化它。

但是,我们还没有设置任何 API 密钥!是的,因为这是一个库,库的用户将必须使用自己的 API 密钥。我们正在隐藏创建 URI 和处理错误的复杂性。

最后,doRequest函数是个大问题,所以我们会逐步详细地查看它:


func (o *CurrentWeatherData) doRequest(uri string) (weather *Weather, err error) { 
  client := &http.Client{} 
  req, err := http.NewRequest("GET", uri, nil) 
  if err != nil { 
    return 
  } 
  req.Header.Set("Content-Type", "application/json") 

首先,签名告诉我们doRequest方法接受一个 URI 字符串,并返回一个指向Weather变量和一个错误的指针。我们首先创建一个http.Client类,它将发送请求。然后,我们创建一个请求对象,该对象将使用GET方法,如OpenWeatherMap网页中所述,并传递我们传递的 URI。如果我们要使用不同的方法,或者多个方法,则必须通过签名中的参数来实现。尽管如此,我们只会使用GET方法,所以我们可以在那里硬编码它。

然后,我们检查请求对象是否已成功创建,并设置一个标题,说明内容类型是 JSON:


resp, err := client.Do(req) 
if err != nil { 
  return 
} 
 
if resp.StatusCode != 200 { 
  byt, errMsg := ioutil.ReadAll(resp.Body) 
  if errMsg == nil { 
    errMsg = fmt.Errorf("%s", string(byt)) 
  } 
  err = fmt.Errorf("Status code was %d, aborting. Error message was:\n%s\n",resp.StatusCode, errMsg) 
 
  return 
} 

然后我们发出请求,并检查错误。因为我们给返回类型命名了,如果发生任何错误,我们只需返回函数,Go 就会返回变量err和变量weather在那一刻的状态。

我们检查响应的状态码,因为我们只接受 200 作为良好的响应。如果没有返回 200,我们将创建一个包含主体内容和返回的状态码的错误消息:


  weather, err = o.responseParser(resp.Body) 
  resp.Body.Close() 
 
  return 
} 

最后,如果一切顺利,我们使用之前编写的responseParser函数来解析 Body 的内容,它是一个io.Reader接口。也许你想知道为什么我们没有控制response parser方法中的err。有趣的是,因为我们实际上是在控制它。responseParserdoRequest具有相同的返回签名。两者都返回一个Weather指针和一个错误(如果有的话),所以我们可以直接返回结果。

使用外观模式创建的库

我们为使用外观模式的OpenWeatherMap API 创建了第一个里程碑。我们在doRequestresponseParser函数中隐藏了访问OpenWeatherMap REST API 的复杂性,而我们库的用户则可以使用易于使用的语法查询 API。例如,要获取西班牙马德里的天气,用户只需在开头输入参数和 API 密钥:


  weatherMap := CurrentWeatherData{*apiKey} 
 
  weather, err := weatherMap.GetByCityAndCountryCode("Madrid", "ES") 
  if err != nil { 
    t.Fatal(err) 
  } 
 
  fmt.Printf("Temperature in Madrid is %f celsius\n", weather.Main.Temp-273.15) 

写作本章时,马德里的天气控制台输出如下:


$ Temperature in Madrid is 30.600006 celsius

一个典型的夏日!

享元模式

我们接下来介绍的是享元设计模式。它在计算机图形和视频游戏行业中非常常见,但在企业应用中并不常见。

描述

享元是一种模式,它允许在某种类型的许多实例之间共享一个重型对象的状态。想象一下,你必须创建和存储太多基本相同的某种重型对象,你会很快耗尽内存。这个问题可以很容易地通过享元模式来解决,还可以额外借助工厂模式的帮助。工厂通常负责封装对象的创建,就像我们之前看到的那样。

目标

由于享元模式(Flyweight pattern)的存在,我们可以在单个共同对象中共享对象的所有可能状态,从而通过使用指向已创建对象的指针来最小化对象的创建。

示例

举个例子,我们将模拟您在赌博网页上找到的一些事情。 想象一下欧洲锦标赛的最后一场比赛,数百万人在整个欧洲观看。 现在想象一下我们拥有一个提供欧洲每支球队历史信息的赌博网页。 这是大量信息,通常存储在一些分布式数据库中,每支球队都有着字面上的兆字节信息,包括球员、比赛、冠军等等。

如果有百万用户访问有关一支球队的信息,并且为每个查询历史数据的用户创建新信息实例,我们将瞬间耗尽内存。 有了我们的代理模式解决方案,我们可以创建一个 n 个最近搜索的缓存以加快查询速度,但如果我们为每支球队返回一个克隆,我们仍然会因内存不足而短缺(但由于缓存,速度会更快)。 有趣,是吧?

相反,我们将仅仅存储每支球队的信息一次,并向用户提供对它们的引用。 因此,如果有百万用户尝试访问有关一场比赛的信息,实际上我们将在内存中只有两支球队,并且有百万个指针指向相同的内存地址。

验收标准

享元模式的验收标准必须始终减少使用的内存量,并且必须主要专注于这个目标:

  1. 我们将创建一个名为Team的结构体,其中包含一些基本信息,比如球队的名称、球员、历史成绩以及展示其队徽的图像。

  2. 我们必须确保正确的团队创建(注意这里的 创建 一词,适合用创建型模式),并且不会出现重复。

  3. 当两次创建相同的球队时,我们必须拥有两个指针指向相同的内存地址。

基本结构体和测试

我们的Team结构体将包含其他结构体,因此将创建总共四个结构体。 Team 结构体的签名如下:


type Team struct { 
  ID             uint64 
  Name           string 
  Shield         []byte 
  Players        []Player 
  HistoricalData []HistoricalData 
} 

每支球队都有一个 ID、一个名称、表示球队队徽的字节片段图像、一组球员和一组历史数据。 这样,我们将有两支球队的 ID:

const ( 
  TEAM_A = iota 
  TEAM_B 
) 

我们通过使用 constiota 关键字声明两个常量。 const 关键字简单地声明接下来的声明为常量。 iota 是一个无类型整数,它会自动递增其值,用于每个括号之间的新常量。 当我们声明TEAM_A时,iota的值开始重置为 0,因此TEAM_A等于 0。 在TEAM_B变量上,iota增加了一个,因此TEAM_B等于 1。 iota 赋值是在声明不需要特定值的常量值时节约输入的一种优雅方式(就像 math 包中的 Pi 常量)。

我们的PlayerHistoricalData如下:

type Player struct { 
  Name    string 
  Surname string 
  PreviousTeam uint64 
  Photo   []byte 
} 
 
type HistoricalData struct { 
  Year          uint8 
  LeagueResults []Match 
} 

如您所见,我们还需要一个存储在 HistoricalData 结构体中的 Match 结构体。在这个上下文中,Match 结构体表示比赛的历史结果:


type Match struct { 
  Date          time.Time 
  VisitorID     uint64 
  LocalID       uint64 
  LocalScore    byte 
  VisitorScore  byte 
  LocalShoots   uint16 
  VisitorShoots uint16 
} 

这足以表示一个团队,并满足 验收标准 1。您可能已经猜到每个团队都有很多信息,因为一些欧洲团队已经存在了 100 多年。

对于 验收标准 2,单词 creation 应该为我们提供一些解决此问题的线索。我们将构建一个工厂来创建和存储我们的团队。我们的工厂将包括一个年份映射,其中包括指向 Teams 的指针作为值,以及一个 GetTeam 函数。使用映射将会加速团队的搜索,如果我们提前知道它们的名称。我们还将提供一个方法来返回已创建对象的数量,称为 GetNumberOfObjects 方法:


type teamFlyweightFactory struct { 
  createdTeams map[string]*Team 
} 
 
func (t *teamFlyweightFactory) GetTeam(name string) *Team { 
  return nil 
} 
 
func (t *teamFlyweightFactory) GetNumberOfObjects() int { 
  return 0 
} 

这足以编写我们的第一个单元测试了:


func TestTeamFlyweightFactory_GetTeam(t *testing.T) { 
  factory := teamFlyweightFactory{} 
 
teamA1 := factory.GetTeam(TEAM_A) 
  if teamA1 == nil { 
    t.Error("The pointer to the TEAM_A was nil") 
  } 
 
  teamA2 := factory.GetTeam(TEAM_A) 
  if teamA2 == nil { 
    t.Error("The pointer to the TEAM_A was nil") 
  } 
 
  if teamA1 != teamA2 { 
    t.Error("TEAM_A pointers weren't the same") 
  } 
 
  if factory.GetNumberOfObjects() != 1 { 
    t.Errorf("The number of objects created was not 1: %d\n", factory.GetNumberOfObjects()) 
  } 
} 

在我们的测试中,我们验证了所有的验收标准。首先我们创建一个工厂,然后请求 TEAM_A 的指针。这个指针不能为 nil,否则测试将失败。

然后我们调用第二个指针指向同一支团队。这个指针也不能为 nil,并且应该指向与前一个指针相同的内存地址,这样我们就知道它没有分配新的内存。

最后,我们应该检查已创建团队的数量是否只有一个,因为我们已经两次请求了相同的团队。我们有两个指针,但只有一个团队实例。让我们运行测试:


$ go test -v -run=GetTeam .
=== RUN   TestTeamFlyweightFactory_GetTeam
--- FAIL: TestTeamFlyweightFactory_GetTeam (0.00s)
flyweight_test.go:11: The pointer to the TEAM_A was nil
flyweight_test.go:21: The pointer to the TEAM_A was nil
flyweight_test.go:31: The number of objects created was not 1: 0
FAIL
exit status 1
FAIL

嗯,失败了。两个指针都是 nil,并且没有创建任何对象。有趣的是,比较这两个指针的函数并没有失败;总之,nil 等于 nil

实现

我们的 GetTeam 方法将需要扫描称为 createdTeams 的映射字段,以确保查询的团队已经创建,并在返回前存储它。如果团队尚未创建,则必须在返回前创建它并将其存储在映射中:


func (t *teamFlyweightFactory) GetTeam(teamID int) *Team { 
  if t.createdTeams[teamID] != nil { 
    return t.createdTeams[teamID] 
  } 
 
  team := getTeamFactory(teamID) 
  t.createdTeams[teamID] = &team 
 
  return t.createdTeams[teamID] 
} 

上述代码非常简单。如果参数名称存在于 createdTeams 映射中,则返回指针。否则,调用团队创建工厂。这足够有趣,让我们停下来分析一下。当您使用享元模式时,很常见有一个享元工厂,它使用其他类型的创建模式来检索它所需的对象。

因此,getTeamFactory 方法将为我们提供所需的团队,我们将其存储在映射中并返回。团队工厂将能够创建两支团队:TEAM_ATEAM_B


func getTeamFactory(team int) Team { 
  switch team { 
    case TEAM_B: 
    return Team{ 
      ID:   2, 
      Name: TEAM_B, 
    } 
    default: 
    return Team{ 
      ID:   1, 
      Name: TEAM_A, 
    } 
  } 
} 

我们简化了对象的内容,以便可以专注于享元模式的实现。好的,我们只需定义检索已创建对象数量的函数,如下所示:


func (t *teamFlyweightFactory) GetNumberOfObjects() int { 
  return len(t.createdTeams) 
} 

这很简单。len 函数返回数组或切片中的元素数量,string 中的字符数量等。看起来一切都完成了,我们可以再次运行测试了:


$ go test -v -run=GetTeam . 
=== RUN   TestTeamFlyweightFactory_GetTeam 
--- FAIL: TestTeamFlyweightFactory_GetTeam (0.00s) 
panic: assignment to entry in nil map [recovered] 
        panic: assignment to entry in nil map 
 
goroutine 5 [running]: 
panic(0x530900, 0xc0820025c0) 
        /home/mcastro/Go/src/runtime/panic.go:481 +0x3f4 
testing.tRunner.func1(0xc082068120) 
        /home/mcastro/Go/src/testing/testing.go:467 +0x199 
panic(0x530900, 0xc0820025c0) 
        /home/mcastro/Go/src/runtime/panic.go:443 +0x4f7 
/home/mcastro/go-design-patterns/structural/flyweight.(*teamFlyweightFactory).GetTeam(0xc08202fec0, 0x0, 0x0) 
        /home/mcastro/Desktop/go-design-patterns/structural/flyweight/flyweight.go:71 +0x159 
/home/mcastro/go-design-patterns/structural/flyweight.TestTeamFlyweightFactory_GetTeam(0xc082068120) 
        /home/mcastro/Desktop/go-design-patterns/structural/flyweight/flyweight_test.go:9 +0x61 
testing.tRunner(0xc082068120, 0x666580) 
        /home/mcastro/Go/src/testing/testing.go:473 +0x9f 
created by testing.RunTests 
        /home/mcastro/Go/src/testing/testing.go:582 +0x899 
exit status 2 
FAIL

惊慌!我们有什么忘了吗?通过阅读 panic 消息中的堆栈跟踪,我们可以看到一些地址、一些文件,似乎GetTeam方法试图在flyweight.go文件的第 71 行给一个空 map 赋值。让我们仔细看看第 71 行(请记住,如果您在按照本教程编写代码,那么错误可能在不同的行,因此请仔细查看您自己的堆栈跟踪):


t.createdTeams[teamName] = &team

好了,这行位于GetTeam方法中,当方法通过这里时,意味着它在 map 中没有找到团队-它已经创建了它(变量团队),并尝试将其分配给 map。但 map 是 nil,因为我们在创建工厂时没有初始化它。这有一个快速解决方案。在我们创建工厂的地方,在测试中初始化 map:


factory := teamFlyweightFactory{
    createdTeams: make(map[int]*Team,0),
}

我相信你已经看到了这里的问题。如果我们无法访问包,我们可以初始化变量。好吧,我们可以将变量设为公共的,就这样。但这会导致每个实现者必须知道他们必须初始化 map,而且它的签名既不方便也不优雅。相反,我们将创建一个简单的工厂构建器来代替。这在 Go 中是一种非常常见的方法:


func NewTeamFactory() teamFlyweightFactory { 
  return teamFlyweightFactory{ 
    createdTeams: make(map[int]*Team), 
  } 
} 

现在,在测试中,我们用对此函数的调用替换了工厂的创建:


func TestTeamFlyweightFactory_GetTeam(t *testing.T) { 
  factory := NewTeamFactory() 
  ... 
} 

然后我们再次运行测试:

$ go test -v -run=GetTeam .
=== RUN   TestTeamFlyweightFactory_GetTeam
--- PASS: TestTeamFlyweightFactory_GetTeam (0.00s)
PASS
ok 

完美!让我们通过添加第二个测试来改进测试,以确保一切都会按预期运行并具有更多的量。我们将创建一百万次对团队创建的调用,代表一百万个用户的调用。然后,我们只需检查创建的团队数量是否只有两个:


func Test_HighVolume(t *testing.T) { 
  factory := NewTeamFactory() 
 
  teams := make([]*Team, 500000*2) 
  for i := 0; i < 500000; i++ { 
  teams[i] = factory.GetTeam(TEAM_A) 
} 
 
for i := 500000; i < 2*500000; i++ { 
  teams[i] = factory.GetTeam(TEAM_B) 
} 
 
if factory.GetNumberOfObjects() != 2 { 
  t.Errorf("The number of objects created was not 2: %d\n",factory.GetNumberOfObjects()) 

在这个测试中,我们分别检索了TEAM_ATEAM_B500,000 次,每个检索达到一百万用户。然后,我们确保只创建了两个对象:


$ go test -v -run=Volume . 
=== RUN   Test_HighVolume 
--- PASS: Test_HighVolume (0.04s) 
PASS 
ok

完美!我们甚至可以检查指针指向的位置以及它们的位置。我们将以前三个为例进行检查。将以下行添加到最后一个测试的末尾,然后再次运行它:

for i:=0; i<3; i++ { 
  fmt.Printf("Pointer %d points to %p and is located in %p\n", i, teams[i], &teams[i]) 
} 

在前面的测试中,我们使用Printf方法打印指针的信息。%p标志会给出指针指向的对象的内存位置。如果通过传递&符号引用指针,它将给出指针本身的方向。

用相同的命令再次运行测试;您将在输出中看到三行新信息,信息类似于以下内容:


Pointer 0 points to 0xc082846000 and is located in 0xc082076000
Pointer 1 points to 0xc082846000 and is located in 0xc082076008
Pointer 2 points to 0xc082846000 and is located in 0xc082076010

它告诉我们的是,地图中的前三个位置指向相同的位置,但实际上我们有三个不同的指针,它们实际上比我们的团队对象轻得多。

那么单例模式和享元模式有什么区别呢?

嗯,差异微妙,但确实存在。使用单例模式,我们确保只创建一次相同的类型。此外,单例模式是一种创建模式。对于享元模式,它是一种结构模式,我们不关心对象是如何创建的,而是关心如何以轻量的方式构造一个类型来包含重的信息。我们谈论的结构是我们的例子中的map[int]*Team结构。在这里,我们真的不关心如何创建对象;我们只是为它编写了一个简单的getTeamFactory方法。我们非常重视拥有一个轻量级的结构来容纳可共享的对象(或对象),在这种情况下是地图。

总结

我们已经看到了几种组织代码结构的模式。结构模式关心如何创建对象,或者它们如何进行业务(我们将在行为模式中看到这一点)。

不要因为混合了几种模式而感到困惑。如果您严格遵循每种模式的目标,您很容易混合六七种模式。只要记住,过度设计和根本不设计一样糟糕。我记得有一天晚上我做了一个负载均衡器的原型,经过两个小时的疯狂过度设计的代码后,我的脑子里一团糟,我宁愿重新开始。

在下一章中,我们将看到行为模式。它们更加复杂,通常使用结构模式和创建模式来实现它们的目标,但我相信读者会觉得它们非常具有挑战性和有趣。

第五章:行为模式-策略,责任链和命令设计模式

我们将看到的最后一组常见模式是行为模式。现在,我们不会定义结构或封装对象创建,而是要处理行为。

行为模式中有什么要处理的?嗯,现在我们将封装行为,例如策略模式中的算法或命令模式中的执行。

正确的行为设计是在了解如何处理对象创建和结构之后的最后一步。正确定义行为是良好软件设计的最后一步,因为总的来说,良好的软件设计让我们能够轻松改进算法并轻松修复错误,而最佳算法实现将无法拯救我们免受糟糕的软件设计。

策略设计模式

策略模式可能是行为模式中最容易理解的。在开发之前的模式时,我们已经使用了几次,但没有停下来谈论它。现在我们将。

描述

策略模式使用不同的算法来实现特定功能。这些算法隐藏在接口后面,并且它们必须是可互换的。所有算法以不同的方式实现相同的功能。例如,我们可以有一个Sort接口和一些排序算法。结果是相同的,某个列表被排序,但我们可以使用快速排序,归并排序等。

你能猜到我们在前几章中何时使用了策略模式吗? 三,二,一...嗯,在我们使用io.Writer接口时,我们大量使用了策略模式。 io.Writer接口定义了写入的策略,功能始终相同-写入某些内容。我们可以将其写入标准输出,写入某个文件或写入用户定义的类型,但最终做的事情是相同的-写入。我们只是改变了写入的策略(在这种情况下,我们改变了写入的位置)。

目标

策略模式的目标非常明确。模式应该做到以下几点:

  • 提供一些算法来实现特定功能

  • 所有类型以不同的方式实现相同的功能,但策略的客户端不受影响

问题在于这个定义涵盖了广泛的可能性。这是因为策略模式实际上用于各种场景,并且许多软件工程解决方案都包含某种策略。因此最好通过一个真实的例子来看它的实际应用。

渲染图像或文本

对于这个例子,我们将做一些不同的事情。我们不仅要在控制台上打印文本,还要在文件上绘制对象。

在这种情况下,我们将有两种策略:控制台和文件。但是库的用户不必处理它们背后的复杂性。

关键特征是“调用者”不知道底层库是如何工作的,他只知道定义的策略上可用的信息。这在下图中很明显:

渲染图像或文本

在这个图表中,我们选择打印到控制台,但我们不会直接处理ConsoleStrategy类型,我们将始终使用代表它的接口。ConsoleStrategy类型将隐藏打印到控制台的实现细节给main函数中的调用者。FileStrategy也隐藏其实现细节以及任何未来的策略。

验收标准

策略必须有一个非常明确的目标,我们将有两种实现它的方式。我们的目标将如下:

  • 提供一种方法向用户显示文本或图像中的对象(正方形)

  • 用户在启动应用程序时必须选择图像或文本

  • 应用程序必须能够添加更多的可视化策略(例如音频)

  • 如果用户选择文本,则必须在控制台中打印单词* Square *

  • 如果用户选择图像,将在文件上打印一个白色正方形在黑色背景上的图像

实施

我们不打算为这个例子编写测试,因为检查图像是否出现在屏幕上将会非常复杂(虽然使用OpenCV,一个令人印象深刻的计算机视觉库,这并非不可能)。我们将直接开始定义每个打印策略必须实现的策略接口(在我们的情况下,文件和控制台类型):

type PrintStrategy interface { 
  Print() error 
} 

就这些了。我们的策略定义了一个简单的Print()方法,返回一个error(在处理文件时,必须返回错误类型)。需要实现PrintStrategy的类型将被称为ConsoleSquareImageSquare类型:

type ConsoleSquare struct {} 

type ImageSquare struct { 
  DestinationFilePath string 
} 

ConsoleSquare结构不需要任何内部字段,因为它将始终将单词Square打印到控制台。ImageSquare结构将存储一个字段,用于打印正方形的图像文件的目的地。我们将从实现ConsoleSquare类型开始,因为它是最简单的:

func(c *ConsoleSquare) Print() error { 
  println("Square")  
  return nil 
} 

非常简单,但图像更复杂。我们不会花太多时间详细解释image包的工作原理,因为代码很容易理解:

func (t *ImageSquare) Print() error { 
  width := 800 
  height := 600 

  origin := image.Point{0, 0} 

  bgImage := image.NewRGBA(image.Rectangle{ 
    Min: origin, 
    Max: image.Point{X: width, Y: height}, 
  }) 

  bgColor := image.Uniform{color.RGBA{R: 70, G: 70, B: 70, A:0}} 
  quality := &jpeg.Options{Quality: 75} 

  draw.Print(bgImage, bgImage.Bounds(), &bgColor, origin, draw.Src) 

然而,这里有一个简短的解释:

  • 我们为图像定义了一个大小(widthheight变量),宽度为 800 像素,高度为 600 像素。这些将是我们图像的大小限制,任何超出该大小的内容都将不可见。

  • origin变量存储一个image.Point,表示任何二维空间中的位置的类型。我们将此点的位置设置为(0, 0),即图像的左上角。

  • 我们需要一个位图来表示我们的背景,这里我们称之为bgImage。在图像包中有一个非常方便的函数,用于创建image.RGBA类型,称为image.NewRGBA。我们需要向此函数传递一个矩形,以便它知道图像的边界。矩形由两个image.Point类型表示--它的左上角点(Min字段)和它的右下角点(Max字段)。我们使用origin作为左上角,一个具有widthheight值的新点作为右下角点。

  • 图像将具有灰色背景颜色(bgColor)。这是通过实例化image.Uniform类型来实现的,该类型表示均匀颜色(因此得名)。image.Uniform类型需要color.Color接口的实例。color.Color类型是实现RGBA() (r, g, b, a uint32)方法以返回红色、绿色、蓝色和 alpha 颜色(RGBA)的uint32值的任何类型(RGBA)。 Alpha 是像素透明度的值。color包方便地提供了一个名为color.RGBA的类型(如果我们不需要实现自己的类型,这就是我们的情况)。

  • 在存储图像的某些格式时,我们必须指定图像的质量。当然,这不仅会影响质量,还会影响文件的大小。在这里,它被定义为 75;100 是我们可以设置的最大质量。正如您所看到的,我们在这里使用jpeg包来设置一个名为Options的类型的值,它只是存储质量的值,没有更多的值可应用。

  • 最后,draw.Print函数将以我们在相同图像边界上定义的特征写入所提供的图像(bgImage)上的像素。draw.Print方法的第一个参数接受目标图像,我们使用了bgImage。第二个参数是要在目标图像中绘制的对象的边界,我们使用了图像的相同边界,但如果需要更小的矩形,我们也可以使用其他边界。第三个参数是要用于给边界上色的颜色。Origin变量用于告诉边界的左上角必须放置在哪里。在这种情况下,边界与图像大小相同,所以我们需要将其设置为原点。指定的最后一个参数是操作类型;只需将其保留在draw.Src参数中。

现在我们必须画正方形。这个操作本质上与绘制背景相同,但在这种情况下,我们是在之前绘制的bgImage上绘制一个正方形:

  squareWidth := 200 
  squareHeight := 200 
  squareColor := image.Uniform{color.RGBA{R: 255, G: 0, B: 0, A: 1}} 
  square := image.Rect(0, 0, squareWidth, squareHeight) 
  square = square.Add(image.Point{ 
    X: (width / 2) - (squareWidth / 2), 
    Y: (height / 2) - (squareHeight / 2), 
  }) 
  squareImg := image.NewRGBA(square) 

  draw.Print(bgImage, squareImg.Bounds(), &squareColor, origin, draw.Src) 

正方形将是 200*200 像素的红色。使用Add方法时,Rect类型的原点被转换为提供的点;这是为了将正方形居中放置在图像上。我们使用正方形Rect创建一个图像,并再次在bgImage图像上调用Print函数来绘制红色正方形:

  w, err := os.Create(t.DestinationFilePath) 
  if err != nil { 
    return fmt.Errorf("Error opening image") 
  } 
  defer w.Close() 

  if err = jpeg.Encode(w, bgImage, quality); err != nil { 
    return fmt.Errorf("Error writing image to disk") 
  } 

  return nil 
} 

最后,我们将创建一个文件来存储图像的内容。文件将存储在ImageSquare结构的DestinationFilePath字段中提供的路径中。要创建文件,我们使用返回*os.Fileos.Create。与每个文件一样,在使用后必须关闭它,因此不要忘记使用defer关键字来确保在方法完成时关闭它。

提示

推迟,还是不推迟?

有些人问为什么要使用defer?不使用defer在函数末尾简单写入会不会一样?实际上并不是。如果在方法执行期间发生任何错误,并且您返回此错误,则如果它位于函数末尾,则Close方法将不会被执行。您可以在返回之前关闭文件,但您必须在每个错误检查中都这样做。使用defer,您不必担心这一点,因为延迟函数始终执行(无论有无错误)。这样,我们确保文件被关闭。

为了解析参数,我们将使用flag包。我们以前使用过它,但让我们回顾一下它的用法。标志是用户在执行我们的应用程序时可以传递的命令。我们可以使用flag.[type]方法在flag包中定义标志。我们想要从控制台读取用户想要使用的输出。这个标志将被称为output。标志可以有一个默认值;在这种情况下,当打印到控制台时,它将具有值console

var output = flag.String("output", "console", "The output to use between 'console' and 'image' file") 

我们的最后一步是编写主函数:

func main(){ 
    flag.Parse() 

记住,在使用标志时,主函数中的第一件事是使用flag.Parse()方法解析它们!很容易忘记这一步:

var activeStrategy PrintStrategy 

switch *output { 
case "console": 
  activeStrategy = &TextSquare{} 
case "image": 
  activeStrategy = &ImageSquare{"/tmp/image.jpg"} 
default: 
  activeStrategy = &TextSquare{} 
} 

我们为用户选择的策略定义了一个变量,称为activeStrategy。但要检查activeStrategy变量是否具有PrintStrategy类型,以便可以用PrintStrategy变量的任何实现来填充它。当用户写入**--output=console**命令时,我们将activeStrategy设置为TextSquare的新实例,并在写入**--output=image**命令时设置为ImageSquare

最后,这是设计模式的执行:

  err := activeStrategy.Print() 
  if err != nil { 
    log.Fatal(err) 
  } 
}

我们的activeStrategy变量是一个实现PrintStrategy的类型,可以是TextSquareImageSquare类。用户将在运行时选择他想要在每种特定情况下使用的策略。此外,我们可以编写一个工厂方法模式来创建策略,这样策略的创建也将与主函数解耦,并抽象成一个不同的独立包。想想看:如果我们将策略创建放在一个不同的包中,这也将允许我们将这个项目作为一个库来使用,而不仅仅是一个独立的应用程序。

现在我们将执行两种策略;TextSquare实例将通过在控制台上打印单词Square来给我们一个正方形:

$ go run main.go --output=console
Square

它已经按预期工作。回想一下标志的工作方式,我们必须使用--(双破折号)和定义的标志,例如我们的情况下的output。然后你有两个选项——使用=(等号)并立即写入标志的值,或者写入<空格>和标志的值。在这种情况下,我们已经将输出的默认值定义为控制台,因此以下三个执行是等效的:

$ go run main.go --output=console
Square
$ go run main.go --output console
Square
$ go run main.go
Square

现在我们要尝试文件策略。如前所述,文件策略将在文件中打印一个红色的正方形作为带有深灰色背景的图像:

$ go run main.go --output image

什么都没发生?但一切都正确地工作了。这实际上是不好的做法。用户在使用您的应用程序或库时必须始终有某种反馈。此外,如果他们正在将您的代码作为库使用,也许他们对输出有特定的格式,因此直接打印到控制台是不好的。我们稍后将解决这个问题。现在,用您喜欢的文件浏览器打开/tmp文件夹,您将看到一个名为image.jpg的文件,其中有我们的红色正方形和深灰色背景。

在我们的库中解决小问题

我们的代码中有一些问题:

  • 它不能作为一个库来使用。我们在main包(策略创建)中编写了关键代码。

解决方案:将命令行应用程序中的策略创建抽象到两个不同的包中。

  • 没有任何策略在文件或控制台上进行记录。我们必须提供一种方法来读取一些日志,外部用户可以将其集成到他们的日志策略或格式中。

解决方案:注入一个io.Writer接口作为日志接收器的依赖。

  • 我们的TextSquare类总是写入控制台(实现了io.Writer接口),而ImageSquare总是写入文件(另一个实现了io.Writer接口)。这种耦合度太高了。

解决方案:注入一个io.Writer接口,以便TextSquareImageSquare可以写入任何可用的io.Writer实现(文件和控制台,还有字节缓冲区、二进制编码器、JSON处理程序等等数十个包)。

因此,为了将其作为一个库来使用并解决第一个问题,我们将遵循 Go 文件结构中应用程序和库的常见方法。首先,我们将把我们的主包和函数放在根包之外;在这种情况下,放在一个名为cli的文件夹中。通常也会将此文件夹称为cmd或者app。然后,我们将把我们的PrintStrategy接口放在根包中,现在将被称为strategy包。最后,我们将在一个同名的文件夹中创建一个shapes包,其中我们将放置文本和图像策略。因此,我们的文件结构将如下所示:

  • 根包:策略

文件:print_strategy.go

  • 子包:形状

文件:image.gotext.gofactory.go

  • 子包:cli

文件:main.go

我们将稍微修改我们的接口以满足我们之前编写的需求:

type PrintStrategy interface { 
  Print() error 
  SetLog(io.Writer) 
  SetWriter(io.Writer) 
} 

我们已经添加了SetLog(io.Writer)方法来向我们的类型添加一个记录器策略;这是为了向用户提供反馈。它还有一个SetWriter方法来设置io.Writer策略。这个接口将位于根包中的print_strategy.go文件中。因此,最终的架构看起来像这样:

在我们的库中解决小问题

TextSquareImageSquare策略都必须满足SetLogSetWriter方法,这些方法只是简单地在它们的字段上存储一些对象,所以我们可以创建一个实现它们的结构,并将这个结构嵌入到策略中,而不是重复实现两次。顺便说一句,这就是我们之前看到的组合模式:

type PrintOutput struct { 
  Writer    io.Writer 
  LogWriter io.Writer 
} 

func(d *PrintOutput) SetLog(w io.Writer) { 
  d.LogWriter = w 
} 

func(d *PrintOutput) SetWriter(w io.Writer) { 
  d.Writer = w 
} 

所以现在每个策略必须嵌入PrintOutput结构,如果我们想修改它们的Writerlogger字段。

我们还需要修改我们的策略实现。TextSquare结构现在需要一个字段来存储输出的io.Writer(它将写入的地方,而不是总是写入控制台)和log写入器。这两个字段可以通过嵌入PrintOutput结构来提供。TextSquare结构也存储在 shapes 包的文件text.go中。因此,该结构现在是这样的:

package shapes 

type TextSquare struct { 
  strategy.PrintOutput 
} 

所以现在Print()方法略有不同,因为我们不再直接使用println函数写入控制台,而是要写入存储在Writer字段中的任何io.Writer

func (t *TextSquare) Print() error { 
  r := bytes.NewReader([]byte("Circle")) 
  io.Copy(t.Writer, r) 
  return nil 
} 

bytes.NewReader是一个非常有用的函数,它接受一个字节数组并将其转换为io.Reader接口。我们需要一个io.Reader接口来使用io.Copy函数。io.Copy函数也非常有用,因为它接受一个io.Reader(作为第二个参数)并将其传输到一个io.Writer(作为第一个参数)。因此,在任何情况下我们都不会返回错误。然而,直接使用t.WriterWrite方法更容易:

func (t *TextSquare) Print() error { 
  t.Writer.Write([]byte("Circle")) 
  return nil 
} 

你可以使用你更喜欢的任何方法。通常,你会使用Write方法,但了解bytes.NewReader函数也很好。

你是否注意到当我们使用t.Writer时,实际上是在访问PrintOutput.WriterTextSquare类型有一个Writer字段,因为PrintOutput结构体有它,并且它被嵌入到TextSquare结构体中。

提示

嵌入不是继承。我们在TextSquare结构体中嵌入了PrintOutput结构体。现在我们可以像访问TextSquare字段一样访问PrintOutput字段。这感觉有点像继承,但这里有一个非常重要的区别:TextSquare不是PrintOutput值,而是在其组合中有一个PrintOutput。这是什么意思?如果你有一个期望PrintOutput的函数,你不能传递TextSquare,只是因为它嵌入了PrintOutput

但是,如果你有一个接受PrintOutput实现的接口的函数,你可以传递TextSquare,如果它嵌入了PrintOutput。这就是我们在示例中所做的。

ImageSquare结构现在与TextSquare类似,都嵌入了PrintOutput

type ImageSquare struct { 
  strategy.PrintOutput 
} 

Print方法也需要修改。现在,我们不再从Print方法中创建文件,因为这违反了单一职责原则。文件实现了io.Writer,所以我们将在ImageSquare结构体之外打开文件,并将其注入Writer字段。因此,我们只需要修改Print()方法的结尾,我们在其中写入文件:

draw.Print(bgImage, squareImg.Bounds(), &squareColor, origin, draw.Src) 

if i.Writer == nil { 
  return fmt.Errorf("No writer stored on ImageSquare") 
} 
if err := jpeg.Encode(i.Writer, bgImage, quality); err != nil { 
  return fmt.Errorf("Error writing image to disk") 
} 

if i.LogWriter != nil { 
  io.Copy(i.LogWriter, "Image written in provided writer\n") 
} 

return nil 

如果你检查我们之前的实现,在使用draw之后,你会发现我们使用了Print方法,我们用os.Create创建了一个文件,并将其传递给jpeg.Encode函数。我们已经删除了关于创建文件的部分,并用一个检查替换了它,查找字段中的Writerif i.Writer != nil)。然后,在jpeg.Encode中,我们可以用之前使用的文件值替换i.Writer字段的内容。最后,如果提供了日志策略,我们再次使用io.Copy来将一些消息记录到LogWriter中。

我们还需要从用户那里抽象出创建PrintStrategy实现者实例所需的知识,为此我们将使用工厂方法:

const ( 
  TEXT_STRATEGY  = "text" 
  IMAGE_STRATEGY = "image" 
) 

func NewPrinter(s string) (strategy.Output, error) { 
  switch s { 
  case TEXT_STRATEGY: 
    return &TextSquare{ 
      PrintOutput: strategy.PrintOutput{ 
        LogWriter: os.Stdout, 
      }, 
    }, nil 
  case IMAGE_STRATEGY: 
    return &ImageSquare{ 
      PrintOutput: strategy.PrintOutput{ 
        LogWriter: os.Stdout, 
      }, 
    }, nil 
  default: 
    return nil, fmt.Errorf("Strategy '%s' not found\n", s) 
  } 
} 

我们有两个常量,分别是我们的两种策略:TEXT_STRATEGYIMAGE_STRATEGY。这些常量必须提供给工厂以检索每个方形抽屉策略。我们的工厂方法接收一个参数s,它是一个包含前述常量之一的字符串。

每个策略都嵌入了一个默认记录器到stdoutPrintOutput类型,但是你可以通过使用SetLog(io.Writer)方法来覆盖它。这种方法可以被认为是原型的工厂。如果它不是一个被识别的策略,将返回一个适当的错误消息。

现在我们有了一个库。我们在strategyshapes包之间拥有了所有需要的功能。现在我们将在一个名为cli的新文件夹中编写main包和函数:

var output = flag.String("output", "text", "The output to use between "+ 
  "'console' and 'image' file") 

func main() { 
  flag.Parse() 

就像之前一样,main函数首先通过解析控制台上的输入参数来开始,以获取所选择的策略。我们现在可以使用变量 output 来创建一个不需要工厂的策略:

activeStrategy, err := shapes.NewPrinter(*output) 
if err != nil { 
  log.Fatal(err) 
} 

有了这段代码,我们有了我们的策略,或者如果发现任何错误(比如一个未被识别的策略),我们会在log.Fatal方法中停止程序执行。

现在我们将使用我们的库来实现业务需求。对于TextStrategy,我们想要写入,例如,到stdout。对于图像,我们将写入到/tmp/image.jpg。就像之前一样。因此,根据前述陈述,我们可以写:

switch *output { 
case shapes.TEXT_STRATEGY: 
  activeStrategy.SetWriter(os.Stdout) 
case shapes.IMAGE_STRATEGY: 
  w, err := os.Create("/tmp/image.jpg") 
  if err != nil { 
    log.Fatal("Error opening image") 
  } 
  defer w.Close() 

  activeStrategy.SetWriter(w) 
} 

TEXT_STRATEGY的情况下,我们使用SetWriterio.Writer设置为os.Stdout。在IMAGE_STRATEGY的情况下,我们在我们的任何一个文件夹中创建一个图像,并将文件变量传递给SetWriter方法。记住,os.File实现了io.Readerio.Writer接口,所以将它作为io.Writer传递给SetWriter方法是完全合法的:

err = activeStrategy.Print() 
if err != nil { 
  log.Fatal(err) 
} 

最后,我们调用用户选择的任何策略的Print方法并检查可能的错误。现在让我们尝试一下程序:

$ go run main.go --output text
Circle

它已经按预期工作。那么图片策略呢?

$ go run main.go --output image
Image written in provided writer

如果我们在/tmp/image.jpg中检查,我们可以在黑色背景上找到我们的红色方块。

关于策略模式的最后一句话

我们已经学会了一种将算法封装在不同的结构体中的强大方式。我们还使用了嵌入而不是继承来在类型之间提供交叉功能,这在我们的应用程序中经常会派上用场。你会发现自己在这里和那里组合策略,就像我们在第二个例子中看到的那样,我们通过使用io.Writer接口有日志和写入的策略,有一个用于字节流操作的策略。

责任链设计模式

我们的下一个模式被称为责任链。顾名思义,它由一条链组成,在我们的情况下,链的每个链接都遵循单一职责原则。

描述

单一职责原则意味着一个类型、函数、方法或任何类似的抽象必须只有一个单一的职责,并且必须做得很好。这样,我们可以将许多实现一个特定功能的函数应用到某个结构体、切片、映射等中。

当我们经常以一种逻辑的方式应用许多这些抽象时,我们可以将它们链接起来按顺序执行,比如,例如一个日志链。

日志链是一组类型,将某个程序的输出记录到多个io.Writer接口。我们可以有一个记录到控制台的类型,一个记录到文件的类型,以及一个记录到远程服务器的类型。每次想要进行一些记录时,你可以进行三次调用,但是只进行一次调用并引发一次链式反应更加优雅。

但是,我们也可以有一系列检查,并且如果其中一个失败,就中断链并返回一些东西。这就是认证和授权中间件的工作方式。

目标

责任链的目标是为开发人员提供一种在运行时链接操作的方法。这些操作相互链接,每个链接将执行某些操作并将请求传递给下一个链接(或不传递)。以下是此模式遵循的目标:

  • 在运行时根据某些输入动态地链接操作

  • 通过一系列处理器传递请求,直到其中一个可以处理它,此时链可能会停止

多记录器链

我们将开发一个多记录器解决方案,可以按我们想要的方式进行链接。我们将使用两个不同的控制台记录器和一个通用记录器:

  1. 我们需要一个简单的记录器,记录带有前缀First logger的请求文本,并将其传递给链中的下一个链接。

  2. 第二个记录器将在控制台上写入文本,如果传入文本包含单词hello,则将请求传递给第三个记录器。但是,如果没有,则链将被中断并立即返回。

  3. 第三个记录器类型是一个名为WriterLogger的通用记录器,它使用io.Writer接口进行记录。

  4. WriterLogger的具体实现写入文件,代表链中的第三个链接。

这些步骤的实现如下图所示:

多日志链

单元测试

链的第一件事,通常是定义接口。责任链接口通常至少有一个Next()方法。Next()方法是执行链中下一个链接的方法:

type ChainLogger interface { 
  Next(string) 
} 

我们示例接口的Next方法接受我们想要记录的消息,并将其传递给链中的下一个链接。如验收标准所述,我们需要三个记录器:

type FirstLogger struct { 
  NextChain ChainLogger 
} 

func (f *FirstLogger) Next(s string) {} 

type SecondLogger struct { 
  NextChain ChainLogger 
} 

func (f *SecondLogger) Next(s string) {} 

type WriterLogger struct { 
  NextChain ChainLogger 
  Writer    io.Writer 
} 
func (w *WriterLogger) Next(s string) {} 

FirstLoggerSecondLogger类型具有完全相同的结构--都实现ChainLogger,并具有指向下一个ChainLoggerNextChain字段。WriterLogger类型等同于FirstLoggerSecondLogger类型,但还具有一个字段用于将其数据写入,因此您可以将任何io.Writer接口传递给它。

与以前一样,我们将实现一个io.Writer结构以在测试中使用。在我们的测试文件中,我们定义以下结构:

type myTestWriter struct { 
  receivedMessage string 
} 

func (m *myTestWriter) Write(p []byte) (int, error) { 
  m.receivedMessage += string(p) 
  return len(p), nil 
} 

func(m *myTestWriter) Next(s string){ 
  m.Write([]byte(s)) 
} 

我们将传递myTestWriter结构的实例给WriterLogger,以便在测试中跟踪被记录的内容。myTestWriter类实现了io.Writer接口的常见Write([]byte) (int, error)方法。请记住,如果它具有Write方法,它可以被用作io.WriterWrite方法简单地将字符串参数存储到receivedMessage字段中,以便我们稍后在测试中检查其值。

这是第一个测试函数的开始:

func TestCreateDefaultChain(t *testing.T) { 
  //Our test ChainLogger 
  myWriter := myTestWriter{} 

  writerLogger := WriterLogger{Writer: &myWriter} 
  second := SecondLogger{NextChain: &writerLogger} 
  chain := FirstLogger{NextChain: &second} 

让我们稍微描述一下这几行,因为它们非常重要。我们创建一个默认的myTestWriter类型的变量,我们将在链的最后一个链接中用作io.Writer接口。然后我们创建链的最后一部分,writerLogger接口。在实现链时,通常从链的最后一部分开始,在我们的例子中是WriterLoggerWriterLogger写入io.Writer,因此我们将myWriter作为io.Writer接口传递。

然后我们创建了一个SecondLogger,即我们链中的中间链接,指向writerLogger。如前所述,SecondLogger只是在包含单词hello的情况下记录并传递消息。在生产应用程序中,它可能是仅错误记录器。

最后,链中的第一个链接具有变量名chain。它指向第二个记录器。因此,总结一下,我们的链如下所示:FirstLogger | SecondLogger | WriterLogger

这将是我们测试的默认设置:

t.Run("3 loggers, 2 of them writes to console, second only if it founds " + 
  "the word 'hello', third writes to some variable if second found 'hello'", 
  func(t *testing.T){ 
    chain.Next("message that breaks the chain\n") 

    if myWriter.receivedMessage != "" { 
      t.Fatal("Last link should not receive any message") 
    } 

    chain.Next("Hello\n") 

    if !strings.Contains(myWriter.receivedMessage, "Hello") { 
      t.Fatal("Last link didn't received expected message") 
    } 
}) 

继续使用 Go 1.7 或更高版本的测试签名,我们定义了一个内部测试,描述如下:三个记录器,其中两个写入控制台,第二个只有在找到单词'hello'时才写入,第三个只有在第二个找到'hello'时才写入一些变量。如果有人需要维护这段代码,这是非常描述性的,也很容易理解。

首先,我们使用Next方法上的消息,它不会到达链中的第三个链接,因为它不包含hello这个词。我们检查receivedMessage变量的内容,它默认为空,看看它是否已经改变,因为它不应该改变。

接下来,我们再次使用链变量,我们链中的第一个链接,并传递消息"Hello\n"。根据测试的描述,它应该使用FirstLogger进行记录,然后在SecondLogger中,最后在WriterLogger中,因为它包含hello这个词,而SecondLogger会让它通过。

测试检查myWriter,链中存储了过去消息的最后一个链接,是否包含我们在链中首次传递的单词:hello。让我们运行它,看看它是否失败:

go test -v .
=== RUN   TestCreateDefaultChain
=== RUN   TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello'
--- FAIL: TestCreateDefaultChain (0.00s)
--- FAIL: TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' (0.00s)
 chain_test.go:33: Last message didn't received expected message
FAIL
exit status 1
FAIL

测试通过了测试的第一个检查,但没有通过第二个检查。嗯...理想情况下,在任何实现之前都不应该通过任何检查。请记住,在测试驱动的开发中,测试必须在第一次启动时失败,因为它们正在测试的代码尚未实现。零初始化误导了我们,使得测试通过了。我们可以用两种方法解决这个问题:

  • ChainLogger的签名更改为返回一个错误:Next(string) error。这样,我们将通过返回错误来打破链。这通常是一个更方便的方法,但现在会引入相当多的样板代码。

  • receivedMessage字段更改为指针。指针的默认值是 nil,而不是空字符串。

现在我们将使用第二个选项,因为它更简单,而且也相当有效。所以让我们将myTestWriter结构的签名更改为以下内容:

type myTestWriter struct { 
  receivedMessage *string 
} 

func (m *myTestWriter) Write(p []byte) (int, error) { 
  if m.receivedMessage == nil { 
         m.receivedMessage = new(string) 
} 
  tempMessage := fmt.Sprintf("%s%s", m.receivedMessage, p) 
  m.receivedMessage = &tempMessage 
  return len(p), nil 
} 

func (m *myTestWriter) Next(s string) { 
  m.Write([]byte(s)) 
} 

检查receivedMessage的类型现在是否有星号(*),以指示它是一个指向字符串的指针。Write函数也需要更改。现在我们必须检查receivedMessage字段的内容,因为像每个指针一样,它被初始化为 nil。然后我们必须首先将消息存储在一个变量中,这样我们就可以在赋值的下一行中取地址(m.receivedMessage = &tempMessage)。

所以现在我们的测试代码也应该有所改变:

t.Run("3 loggers, 2 of them writes to console, second only if it founds "+ 
"the word 'hello', third writes to some variable if second found 'hello'", 
func(t *testing.T) { 
  chain.Next("message that breaks the chain\n") 

  if myWriter.receivedMessage != nil { 
    t.Error("Last link should not receive any message") 
  } 

  chain.Next("Hello\n") 

  if myWriter.receivedMessage == "" || !strings.Contains(*myWriter.receivedMessage, "Hello") { 
    t.Fatal("Last link didn't received expected message") 
  } 
}) 

现在我们正在检查myWriter.receivedMessage是否实际上是nil,因此可以确定变量上确实没有写入任何内容。此外,我们必须更改第二个 if 语句,首先检查成员是否为 nil,然后再检查其内容,否则测试可能会引发恐慌。让我们再次测试一下:

go test -v . 
=== RUN   TestCreateDefaultChain 
=== RUN   TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' 
--- FAIL: TestCreateDefaultChain (0.00s) 
--- FAIL: TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' (0.00s) 
        chain_test.go:40: Last link didn't received expected message 
FAIL 
exit status 1 
FAIL

它再次失败了,而且再次,测试的前半部分在没有实现代码的情况下正确通过了。那么现在我们该怎么办呢?我们必须更改myWriter类型的签名,使得测试在两个检查中都失败,而且只在第二个检查中失败。嗯,在这种情况下,我们可以忽略这个小问题。在编写测试时,我们必须非常小心,不要对它们过于疯狂;单元测试是帮助我们编写和维护代码的工具,但我们的目标是编写功能,而不是测试。这一点很重要,因为你可能会对工程化单元测试感到非常疯狂。

实施

现在我们必须分别实现第一个、第二个和第三个名为FirstLoggerSecondLoggerWriterLogger的记录器。FirstLogger记录器是最简单的,正如第一个验收标准中所描述的那样:我们需要一个简单的记录器,记录请求的文本,并在前缀为 First logger:的情况下将其传递给链中的下一个链接。所以让我们来做吧:

type FirstLogger struct { 
  NextChain ChainLogger 
} 

func (f *FirstLogger) Next(s string) { 
  fmt.Printf("First logger: %s\n", s) 

  if f.NextChain != nil { 
    f.NextChain.Next(s) 
  } 
} 

实现起来非常简单。使用 fmt.Printf 方法对传入的字符串进行格式化和打印,我们附加了文本 First Logger:。然后,我们检查 NextChain 类型是否实际上有一些内容,并通过调用其 Next(string) 方法将控制权传递给它。测试还没有通过,所以我们将继续使用 SecondLogger 记录器:

type SecondLogger struct { 
  NextChain ChainLogger 
} 

func (se *SecondLogger) Next(s string) { 
  if strings.Contains(strings.ToLower(s), "hello") { 
    fmt.Printf("Second logger: %s\n", s) 

    if se.NextChain != nil { 
      se.NextChain.Next(s) 
    } 

    return 
  } 

  fmt.Printf("Finishing in second logging\n\n") 
} 

如第二个验收标准所述,SecondLogger 的描述是:如果传入的文本包含单词 "hello",第二个记录器将在控制台上写入并将请求传递给第三个记录器。首先,它检查传入的文本是否包含文本 hello。如果是,它会将消息打印到控制台,并附加文本 Second logger:,并将消息传递给链中的下一个链接(检查之前的实例是否存在第三个链接)。

但如果它不包含文本 hello,链就会断开,并打印消息 Finishing in second logging

我们将以 WriterLogger 类型结束:

type WriterLogger struct { 
  NextChain ChainLogger 
  Writer    io.Writer 
} 

func (w *WriterLogger) Next(s string) { 
  if w.Writer != nil { 
    w.Writer.Write([]byte("WriterLogger: " + s)) 
  } 

  if w.NextChain != nil { 
    w.NextChain.Next(s) 
  } 
} 

WriterLogger 结构的 Next 方法检查 Writer 成员中是否存储了现有的 io.Writer 接口,并将传入的消息写入其中,附加文本 WriterLogger:。然后,就像之前的链接一样,检查是否有更多的链接来传递消息。

现在测试将成功通过:

go test -v .
=== RUN   TestCreateDefaultChain
=== RUN   TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello'
First logger: message that breaks the chain
Finishing in second logging
First logger: Hello
Second logger: Hello
--- PASS: TestCreateDefaultChain (0.00s)
 --- PASS: TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' (0.00s)
PASS
ok

测试的前半部分打印了两条消息——First logger: 打破了链,这是 FirstLogger 的预期消息。但它在 SecondLogger 中停止,因为在传入的消息中找不到 hello 一词;这就是为什么它打印了 Finishing in second logging 字符串。

测试的后半部分接收到消息 Hello。所以 FirstLogger 打印,SecondLogger 也打印。第三个记录器根本不打印到控制台,而是打印到我们在测试中定义的 myWriter.receivedMessage 行。

那么闭包呢?

有时,为了快速调试,定义链中更灵活的链接可能会很有用。我们可以使用闭包来实现这一点,以便链接功能由调用者定义。闭包链接是什么样子的?类似于 WriterLogger 记录器:

type ClosureChain struct { 
  NextChain ChainLogger 
  Closure   func(string) 
} 

func (c *ClosureChain) Next(s string) { 
  if c.Closure != nil { 
    c.Closure(s) 
  } 

  if c.NextChain != nil { 
    c.Next(s) 
  } 
} 

ClosureChain 类型有一个 NextChain,和一个 Closure 成员。看一下 Closure 的签名:func(string)。这意味着它是一个接受 string 并且不返回任何东西的函数。

ClosureChainNext(string) 方法检查 Closure 成员是否已存储,并使用传入的字符串执行它。和往常一样,该链接检查更多的链接以传递消息,就像链中的每个链接一样。

那么,我们现在如何使用它呢?我们将定义一个新的测试来展示它的功能:

t.Run("2 loggers, second uses the closure implementation", func(t *testing.T) { 
  myWriter = myTestWriter{} 
  closureLogger := ClosureChain{ 
    Closure: func(s string) { 
      fmt.Printf("My closure logger! Message: %s\n", s) 
      myWriter.receivedMessage = &s 
    }, 
  } 

  writerLogger.NextChain = &closureLogger 

  chain.Next("Hello closure logger") 

  if *myWriter.receivedMessage != "Hello closure logger" { 
    t.Fatal("Expected message wasn't received in myWriter") 
  } 
}) 

这个测试的描述很清楚:"2 loggers, second uses the closure implementation"。我们简单地使用两个 ChainLogger 实现,并且在第二个链接中使用 closureLogger。我们创建了一个新的 myTestWriter 来存储消息的内容。在定义 ClosureChain 时,我们在创建 closureLogger 时直接在 Closure 成员上定义了一个匿名函数。它打印 "My closure logger! Message: %s\n",并用传入的消息替换 "%s"。然后,我们将传入的消息存储在 myWriter 上,以便稍后检查。

在定义了这个新链接之后,我们使用了上一个测试的第三个链接,将闭包添加为第四个链接,并传递了消息 Hello closure logger。我们在开头使用单词 Hello,以确保消息将通过 SecondLogger

最后,myWriter.receivedMessage 的内容必须包含传递的文本:Hello closure logger。这是一种非常灵活的方法,但有一个缺点:在定义这样的闭包时,我们无法以非常优雅的方式测试其内容。让我们再次运行测试:

go test -v . 
=== RUN   TestCreateDefaultChain 
=== RUN   TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' 
First logger: message that breaks the chain 
Finishing in second logging 

First logger: Hello 
Second logger: Hello 
=== RUN   TestCreateDefaultChain/2_loggers,_second_uses_the_closure_implementation 
First logger: Hello closure logger 
Second logger: Hello closure logger 
My closure logger! Message: Hello closure logger 
--- PASS: TestCreateDefaultChain (0.00s) 
    --- PASS: TestCreateDefaultChain/3_loggers,_2_of_them_writes_to_console,_second_only_if_it_founds_the_word_'hello',_third_writes_to_some_variable_if_second_found_'hello' (0.00s) 
    --- PASS: TestCreateDefaultChain/2_loggers,_second_uses_the_closure_implementation (0.00s) 
PASS 
ok

看一下第三个 RUN:消息正确地通过第一个、第二个和第三个链接到达闭包,打印了预期的 My closure logger! Message: Hello closure logger 消息。

在某些接口中添加闭包方法实现非常有用,因为它在使用库时提供了相当大的灵活性。你可以在 Go 代码中经常发现这种方法,最著名的是net/http包的方法HandleFunc,我们之前在结构模式中使用它来定义 HTTP 请求的处理程序。

将其放在一起

我们学到了一个强大的工具,可以实现动态处理操作和状态处理。责任链模式被广泛使用,也用于创建有限状态机FSM)。它也可以与装饰者模式互换使用,不同之处在于装饰时改变对象的结构,而链式定义了链中每个链接的行为,也可能会破坏它。

命令设计模式

最后,我们还将看到命令模式--一个小型的设计模式,但仍然经常使用。需要一种方法来连接真正不相关的类型吗?那就为它们设计一个命令。

描述

命令设计模式与策略设计模式非常相似,但也有关键的区别。在策略模式中,我们专注于改变算法,而在命令模式中,我们专注于调用某些东西或者对某种类型进行抽象。

命令模式通常被视为一个容器。你可以将用户界面上的用户交互信息(如点击登录)放入命令中并传递。命令中不需要包含与点击登录操作相关的复杂性,只需要包含操作本身。

有机世界的一个例子是快递公司的包裹。我们可以在上面放任何东西,但作为快递公司,我们更感兴趣的是管理包裹而不是直接管理其内容。

在处理通道时,命令模式将被大量使用。通过通道可以发送任何消息,但是,如果我们需要从通道的接收端获得响应,一种常见的方法是创建一个命令,附加一个第二个响应通道,我们在这里进行监听。

类似地,一个很好的例子是多人游戏,每个用户的每一次操作都可以通过网络发送给其他用户作为命令。

目标

在使用命令设计模式时,我们试图将某种操作或信息封装在一个轻量级的包裹中,这个包裹必须在其他地方进行处理。这类似于策略模式,但实际上,命令可以触发其他地方预先配置的策略,因此它们并不相同。以下是这种设计模式的目标:

  • 把一些信息放进一个盒子里。只有接收者才会打开盒子并知道其中的内容。

  • 将某些操作委托给其他地方。

行为也可以在以下图表中解释:

目标

这里有一个命令接口,带有一个Get() interface{}方法。我们有一个类型A和一个类型B。想法是AB实现命令接口,将自己作为interface{}返回。现在它们实现了命令,它们可以在不太关心底层类型的命令处理程序中自由使用。现在AB可以在处理命令的函数中传递或存储命令。但是B处理程序可以从任何命令处理程序中获取对象来“解包”它,并获取其B内容,以及A命令处理程序和其A内容。

我们把信息放在一个盒子里(命令),并将如何处理它委托给命令的处理程序。

一个简单的队列

我们的第一个例子将会很简单。我们将把一些信息放入一个命令实现者中,并创建一个队列。我们将创建许多实现命令模式的类型的实例,并将它们传递给一个队列,直到队列中有三个命令时,它将对它们进行处理。

验收标准

因此,理解命令的理想验收标准应该在某种程度上反映出可以接受不相关类型并执行命令本身的盒子的创建:

  • 我们需要一个控制台打印命令的构造函数。当使用这个构造函数和一个 string 时,它将返回一个打印它的命令。在这种情况下,处理程序在充当盒子和处理程序的命令内部。

  • 我们需要一个数据结构,它可以将传入的命令存储在队列中,并在队列达到三个长度时打印它们。

实施

这种模式非常简单,我们将编写一些不同的示例,因此我们将直接实现库以保持轻便和简短。经典的命令设计模式通常具有具有 Execute 方法的通用类型结构。我们也将使用这个结构,因为它非常灵活和简单:

type Command interface { 
  Execute() 
} 

这足够通用,可以填充很多不相关的类型!想想看——我们将创建一个类型,当使用 Execute() 方法时会打印到控制台,但它也可以打印数字或发射火箭!关键在于专注于调用,因为处理程序也在命令中。因此,我们需要一些实现这个接口并打印到控制台某种消息的类型:

type ConsoleOutput struct { 
  message string 
} 

func (c *ConsoleOutput) Execute() { 
  fmt.Println(c.message) 
} 

ConsoleOutput 类型实现了 Command 接口,并打印了名为 message 的成员到控制台。

根据第一个验收标准的定义,我们需要一个接受消息字符串并返回 Command 接口的 Command 构造函数。它将具有签名 func CreateCommand(s string) Command

 func CreateCommand(s string) Command { 
   fmt.Println("Creating command") 

   return &ConsoleOutput{ 
         message: s, 
   } 
} 

对于命令 queue,我们将定义一个非常简单的类型 CommandQueue,用于存储实现了 Command 接口的任何类型的队列:

type CommandQueue struct { 
  queue []Command 
} 

func (p *CommandQueue) AddCommand(c Command) { 
  p.queue = append(p.queue, c) 

  if len(p.queue) == 3 { 
    for _, command := range p.queue { 
      command.Execute() 
    } 

    p.queue = make([]Command, 3) 
  } 
} 

CommandQueue 类型存储了一个 Commands 接口的数组。当队列数组达到三个项目时,它会执行队列字段中存储的所有命令。如果还没有达到所需的长度,它只是存储命令。

我们将创建五个命令,足以触发命令队列机制,并将它们添加到队列中。每次创建一个命令时,消息 Creating command 将被打印到控制台。当我们创建第三个命令时,自动命令执行器将被启动,打印前三条消息。我们创建并添加两个命令,但因为我们还没有再次达到第三个命令,它们不会被打印,只会打印 Creating command 消息:

func main() { 
  queue := CommandQueue{} 

  queue.AddCommand(CreateCommand("First message")) 
  queue.AddCommand(CreateCommand("Second message")) 
  queue.AddCommand(CreateCommand("Third message")) 

  queue.AddCommand(CreateCommand("Fourth message")) 
  queue.AddCommand(CreateCommand("Fifth message")) 
} 

让我们运行 main 程序。我们的定义说,命令每三条消息处理一次,我们将创建总共五条消息。前三条消息必须被打印,但第四条和第五条不会被打印,因为我们没有达到第六条消息来触发命令处理:

$go run command.go
Creating command
Creating command
Creating command
First message
Second message
Third message
Creating command
Creating command

正如您所看到的,第四条和第五条消息没有被打印,这是预期的,但我们知道命令已经被创建并存储在数组中。它们只是没有被处理,因为队列正在等待一个命令来触发处理器。

更多例子

前面的例子展示了如何使用一个命令处理程序来执行命令的内容。但使用命令模式的常见方式是将信息委托给不同的对象,而不是执行。

例如,我们将创建一个提取信息的命令,而不是打印到控制台:

type Command interface { 
  Info() string 
} 

在这种情况下,我们的 Command 接口将有一个名为 Info 的方法,它将从其实现者那里检索一些信息。我们将创建两个实现;一个将返回自创建命令到执行之间经过的时间:

type TimePassed struct { 
  start time.Time 
} 

func (t *TimePassed) Info() string { 
  return time.Since(t.start).String() 
} 

time.Since 函数返回自提供参数中存储的时间以来经过的时间。我们通过调用 time.Time 类型的 String() 方法返回传递时间的字符串表示。我们的新 Command 的第二个实现将返回消息 Hello World!

type HelloMessage struct{} 

func (h HelloMessage) Info() string { 
  return "Hello world!" 
} 

我们的main函数将简单地创建每种类型的实例,然后等待一秒,并打印每个Command返回的信息:

func main() { 
  var timeCommand Command 
  timeCommand = &TimePassed{time.Now()} 

  var helloCommand Command 
  helloCommand = &HelloMessage{} 

  time.Sleep(time.Second) 

  fmt.Println(timeCommand.Info()) 
  fmt.Println(helloCommand.Info()) 
} 

time.Sleep函数会暂停当前 goroutine 的执行指定的时间(一秒)。因此,回顾一下,timeCommand变量存储了程序启动时的时间,它的Info()方法返回了自我们给定类型值到调用Info()方法时经过的时间的字符串表示。helloCommand变量在调用其Info()方法时返回消息Hello World!。这里我们没有再次实现Command处理程序,以保持简单,但我们可以将控制台视为处理程序,因为我们只能在控制台上打印 ASCII 字符,就像Info()方法检索到的字符一样。

让我们运行main函数:

go run command.go
1.000216755s
Hello world!

就在这里。在这种情况下,我们使用命令模式来检索一些信息。一个类型存储time信息,而另一个类型不存储任何信息,只是返回相同的简单字符串。每次运行main函数都会返回不同的经过时间,所以不用担心时间与示例中的时间不匹配。

命令的责任链

你还记得责任链设计模式吗?我们之前是在链接之间传递string消息来打印其内容。但我们也可以使用之前的命令来检索信息并记录到控制台上。我们将主要重用我们已经编写的代码。

Command接口将是前面示例中返回string的接口类型:

type Command interface { 
  Info() string 
} 

我们还将使用TimePassed类型的Command实现:

type TimePassed struct { 
  start time.Time 
} 

func (t *TimePassed) Info() string { 
  return time.Since(t.start).String() 
} 

请记住,这种类型返回的是从对象创建到其Info() string方法的经过时间。我们还需要来自本章责任链设计模式部分的ChainLogger接口,但这次它将在其Next方法中传递Command而不是string

type ChainLogger interface { 
  Next(Command) 
} 

为了简单起见,我们将在链中使用相同的类型作为两个链接。这个链接与责任链示例中的FirstLogger类型非常相似,但这次它会附加消息Elapsed time from creation:,并且在打印之前等待 1 秒。我们将其称为Logger而不是FirstLogger

type Logger struct { 
  NextChain ChainLogger 
} 

func (f *Logger) Next(c Command) { 
  time.Sleep(time.Second) 

  fmt.Printf("Elapsed time from creation: %s\n", c.Info()) 

  if f.NextChain != nil { 
    f.NextChain.Next(c) 
  } 
} 

最后,我们需要一个main函数来执行接受Command指针的链:

func main() { 
  second := new(Logger) 
  first := Logger{NextChain: second} 

  command := &TimePassed{start: time.Now()} 

  first.Next(command) 
} 

逐行创建一个名为second的变量,指向Logger的指针;这将成为我们链中的第二个链接。然后创建一个名为first的变量,它将成为链中的第一个链接。第一个链接指向second变量,即链中的第二个链接。

然后,我们创建一个TimePassed的实例来用作Command类型。这个命令的开始时间是执行时间(time.Now()方法返回执行时的时间)。

最后,我们将Command接口传递给链的first.Next(command)语句。该程序的输出如下:

go run chain_command.go
Elapsed time from creation: 1.0003419s
Elapsed time from creation: 2.000682s

结果输出反映在以下图表中:带有时间字段的命令被推送到知道如何执行任何类型命令的第一个链接。然后它将命令传递给第二个链接,它也知道如何执行命令:

这种方法将每个Command执行的复杂性隐藏在每个链接的命令处理程序后面。命令背后隐藏的功能可以是简单的,也可以是非常复杂的,但这里的想法是重用处理程序来管理许多不相关实现的类型。

总结命令模式

命令是一个非常微小的设计模式;它的功能非常容易理解,但由于其简单性而被广泛使用。它看起来非常类似于策略模式,但请记住,策略是关于拥有许多算法来实现特定任务,但所有这些算法都实现了相同的任务。在命令模式中,您有许多任务需要执行,并不是所有的任务都需要相等。

因此,简而言之,命令模式是关于执行封装和委托,以便只有接收者或接收者触发该执行。

总结

我们已经迈出了行为模式的第一步。本章的目标是向读者介绍使用适当的接口和结构来封装算法和执行的概念。通过策略,我们封装了算法,通过责任链处理程序和命令设计模式执行。

现在,通过我们对策略模式的了解,我们可以将应用程序与其算法大大解耦,仅用于测试,这是一个非常有用的功能,可以在几乎不可能测试的不同类型中注入模拟。但也适用于任何可能根据某些上下文需要不同方法的情况(例如对列表进行排序;根据列表的分布,某些算法的性能会更好)。

责任链模式打开了任何类型的中间件和类似插件的库的大门,以改进某些部分的功能。许多开源项目使用责任链来处理 HTTP 请求和响应,以提取信息给最终用户(例如 cookie 信息)或检查身份验证细节(只有在我的数据库中有您的情况下,我才会让您通过到下一个链接)。

最后,命令模式是 UI 处理中最常见的模式,但在许多其他场景中也非常有用,其中我们需要在代码中传递许多不相关类型之间进行某种处理(例如通过通道传递的消息)。

第六章:行为模式 - 模板、备忘录和解释器设计模式

在本章中,我们将看到接下来的三种行为设计模式。难度正在提高,因为现在我们将使用结构和创建模式的组合来更好地解决一些行为模式的目标。

我们将从模板设计模式开始,这是一种看起来非常类似于策略模式但提供更大灵活性的模式。备忘录设计模式在我们每天使用的 99%的应用程序中使用,以实现撤销功能和事务操作。最后,我们将编写一个逆波兰符号解释器来执行简单的数学运算。

让我们从模板设计模式开始。

模板设计模式

模板模式是那些广泛使用的模式之一,特别是在编写库和框架时非常有用。其思想是为用户提供一种在算法中执行代码的方式。

在本节中,我们将看到如何编写成语法 Go 模板模式,并查看一些智能使用的 Go 源代码。我们将编写一个三步算法,其中第二步委托给用户,而第一步和第三步则不是。算法中的第一步和第三步代表模板。

描述

与策略模式封装算法实现在不同的策略中不同,模板模式将尝试实现类似的功能,但只是算法的一部分。

模板设计模式允许用户在算法的一部分中编写代码,而其余部分由抽象执行。这在创建库以简化某些复杂任务或者某些算法的可重用性仅受其一部分影响时很常见。

例如,我们有一个长时间的 HTTP 请求事务。我们必须执行以下步骤:

  1. 验证用户。

  2. 授权他。

  3. 从数据库中检索一些详细信息。

  4. 进行一些修改。

  5. 将详细信息发送回一个新的请求。

不要重复步骤 1 到 5 在用户的代码中,每次他需要修改数据库中的内容。相反,步骤 1、2、3 和 5 将被抽象成相同的算法,该算法接收一个接口,其中包含第五步需要完成事务的内容。它也不需要是一个接口,它可以是一个回调。

目标

模板设计模式关乎可重用性和将责任交给用户。因此,这种模式的目标如下:

  • 将库的算法的一部分延迟给用户。

  • 通过抽象代码的部分来提高可重用性,这些部分在执行之间是不常见的

示例 - 一个包含延迟步骤的简单算法

在我们的第一个示例中,我们将编写一个由三个步骤组成的算法,每个步骤都返回一个消息。第一步和第三步由模板控制,只有第二步延迟到用户。

需求和验收标准

模板模式必须做的事情的简要描述是定义一个算法的模板,其中包括三个步骤,将第二步的实现延迟到用户:

  1. 算法中的每个步骤必须返回一个字符串。

  2. 第一步是一个名为first()的方法,返回字符串hello

  3. 第三步是一个名为third()的方法,返回字符串template

  4. 第二步是用户想要返回的任何字符串,但它由MessageRetriever接口定义,该接口具有Message() string方法。

  5. 算法由一个名为ExecuteAlgorithm的方法顺序执行,并返回每个步骤返回的字符串,这些字符串由一个空格连接在一起。

简单算法的单元测试

我们将专注于仅测试公共方法。这是一个非常常见的方法。总的来说,如果你的私有方法没有从公共方法的某个级别被调用,它们根本就没有被调用。我们需要两个接口,一个用于模板实现者,一个用于算法的抽象步骤:

type MessageRetriever interface { 
  Message()string 
} 

type Template interface { 
   first() string 
   third() string 
   ExecuteAlgorithm(MessageRetriever) string 
} 

模板实现者将接受一个MessageRetriever接口作为其执行算法的一部分。我们需要一个实现这个接口的类型,称为Template,我们将称之为TemplateImpl

type TemplateImpl struct{} 

func (t *TemplateImpl) first() string { 
  return "" 
} 

func (t *TemplateImpl) third() string { 
  return "" 
} 

func (t *TemplateImpl) ExecuteAlgorithm(m MessageRetriever) string { 
  return "" 
} 

因此,我们的第一个测试检查了第四和第五个验收标准。我们将创建一个实现MessageRetriever接口的TestStruct类型,返回字符串world并嵌入模板,以便它可以调用ExecuteAlgorithm方法。它将充当模板和抽象:

type TestStruct struct { 
  Template 
} 

func (m *TestStruct) Message() string { 
  return "world" 
} 

首先,我们将定义TestStruct类型。在这种情况下,我们需要延迟到我们的算法的部分将返回world文本。这是我们稍后在测试中要查找的字符串,进行类型检查“这个字符串中是否存在world这个词?”。

仔细看,TestStruct嵌入了一个名为Template的类型,它代表了我们算法的模板模式。

当我们实现Message()方法时,我们隐式地实现了MessageRetriever接口。所以现在我们可以将TestStruct类型用作MessageRetriever接口的指针:

func TestTemplate_ExecuteAlgorithm(t *testing.T) { 
  t.Run("Using interfaces", func(t *testing.T){ 
    s := &TestStruct{} 
    res := s.ExecuteAlgorithm(s) 
   expected := "world" 

    if !strings.Contains(res, expected) { 
      t.Errorf("Expected string '%s' wasn't found on returned string\n", expected) 
    } 
  }) 
} 

在测试中,我们将使用刚刚创建的类型。当我们调用ExecuteAlgorithm方法时,我们需要传递MessageRetriever接口。由于TestStruct类型也实现了MessageRetriever接口,我们可以将其作为参数传递,但这当然不是强制的。

根据第五个验收标准中定义的ExecuteAlgorithm方法的结果,必须返回一个字符串,其中包含first()方法的返回值,TestStruct的返回值(world字符串)和third()方法的返回值,它们之间用一个空格分隔。我们的实现在第二个位置;这就是为什么我们检查了字符串world前后是否有空格。

因此,当调用ExecuteAlgorithm方法时返回的字符串不包含字符串world时,测试失败。

这已经足够使项目编译并运行应该失败的测试:

go test -v . 
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 template_test.go:47: Expected string ' world ' was not found on returned string
FAIL
exit status 1
FAIL

是时候转向实现这种模式了。

实现模板模式

根据验收标准的定义,我们必须在first()方法中返回字符串hello,在third()方法中返回字符串template。这很容易实现:

type Template struct{} 

func (t *Template) first() string { 
  return "hello" 
} 

func (t *Template) third() string { 
  return "template" 
} 

通过这种实现,我们应该覆盖第二第三个验收标准,并部分覆盖第一个标准(算法中的每个步骤必须返回一个字符串)。

为了满足第五个验收标准,我们定义了一个ExecuteAlgorithm方法,它接受MessageRetriever接口作为参数,并返回完整的算法:通过连接first()Message() stringthird()方法返回的字符串来完成:

func (t *Template) ExecuteAlgorithm(m MessageRetriever) string { 
  return strings.Join([]string{t.first(), m.Message(), t.third()},  " ") 
} 

strings.Join函数具有以下签名:

func Join([]string,string) string 

它接受一个字符串数组并将它们连接起来,将第二个参数放在数组中的每个项目之间。在我们的情况下,我们创建一个字符串数组,然后将其作为第一个参数传递。然后我们传递一个空格作为第二个参数。

通过这种实现,测试必须已经通过了:

go test -v . 
=== RUN   TestTemplate_ExecuteAlgorithm 
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces 
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s) 
    --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s) 
PASS 
ok

测试通过了。测试已经检查了返回结果中是否存在字符串world,这是hello world template消息。hello文本是first()方法返回的字符串,world字符串是我们的MessageRetriever实现返回的,templatethird()方法返回的字符串。空格是由 Go 的strings.Join函数插入的。但是,任何对TemplateImpl.ExecuteAlgorithm类型的使用都将始终在其结果中返回“hello [something] template”。

匿名函数

这不是实现模板设计模式的唯一方法。我们还可以使用匿名函数将我们的实现传递给ExecuteAlgorithm方法。

让我们在之前使用的相同方法中编写一个测试,就在测试之后(用粗体标记):

func TestTemplate_ExecuteAlgorithm(t *testing.T) { 
  t.Run("Using interfaces", func(t *testing.T){ 
    s := &TestStruct{} 
    res := s.ExecuteAlgorithm(s) 

    expectedOrError(res, " world ", t) 
  }) 

 t.Run("Using anonymous functions", func(t *testing.T)
  {
 m := new(AnonymousTemplate)
 res := m.ExecuteAlgorithm(func() string {
 return "world"
 })
 expectedOrError(res, " world ", t)
 }) 
} 

func expectedOrError(res string, expected string, t *testing.T){ 
  if !strings.Contains(res, expected) { 
    t.Errorf("Expected string '%s' was not found on returned string\n", expected) 
  } 
} 

我们的新测试被称为使用匿名函数。我们还将测试中的检查提取到一个外部函数中,以便在这个测试中重用它。我们将这个函数称为expectedOrError,因为如果没有收到预期的值,它将失败并抛出错误。

在我们的测试中,我们将创建一个名为AnonymousTemplate的类型,用它来替换之前的Template类型。这个新类型的ExecuteAlgorithm方法接受func() string类型的方法,我们可以直接在测试中实现它来返回字符串world

AnonymousTemplate类型将具有以下结构:

type AnonymousTemplate struct{} 

func (a *AnonymousTemplate) first() string { 
  return "" 
} 

func (a *AnonymousTemplate) third() string { 
  return "" 
} 

func (a *AnonymousTemplate) ExecuteAlgorithm(f func() string) string { 
  return "" 
} 

Template类型的唯一区别是ExecuteAlgorithm方法接受一个返回字符串的函数,而不是MessageRetriever接口。让我们运行新的测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 template_test.go:47: Expected string ' world ' was not found on returned string
FAIL
exit status 1
FAIL

正如你在测试执行的输出中所读到的,错误是在使用匿名函数测试中抛出的,这正是我们所期望的。现在我们将按照以下方式编写实现:

type AnonymousTemplate struct{} 

func (a *AnonymousTemplate) first() string { 
  return "hello" 
} 

func (a *AnonymousTemplate) third() string { 
  return "template" 
} 

func (a *AnonymousTemplate) ExecuteAlgorithm(f func() string) string { 
  return strings.Join([]string{a.first(), f(), a.third()}, " ") 
} 

实现与Template类型中的实现非常相似。然而,现在我们传递了一个名为f的函数,我们将在Join函数中使用它作为字符串数组中的第二项。由于f只是一个返回字符串的函数,我们需要做的唯一事情就是在正确的位置执行它(数组中的第二个位置)。

再次运行测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
PASS
ok

太棒了!现在我们知道两种实现模板设计模式的方法。

如何避免对接口进行修改

之前方法的问题是现在我们有两个模板需要维护,我们可能会重复代码。在我们使用的接口不能更改的情况下,我们该怎么办?我们的接口是MessageRetriever,但现在我们想要使用一个匿名函数。

好吧,你还记得适配器设计模式吗?我们只需要创建一个Adapter类型,它接受一个func() string类型,返回一个MessageRetriever接口的实现。我们将称这个类型为TemplateAdapter

type TemplateAdapter struct { 
  myFunc func() string 
} 

func (a *TemplateAdapter) Message() string { 
  return "" 
} 

func MessageRetrieverAdapter(f func() string) MessageRetriever { 
  return nil 
} 

正如你所看到的,TemplateAdapter类型有一个名为myFunc的字段,它的类型是func() string。我们还将适配器定义为私有,因为在没有在myFunc字段中定义函数的情况下,它不应该被使用。我们创建了一个名为MessageRetrieverAdapter的公共函数来实现这一点。我们的测试应该看起来是这样的:

t.Run("Using anonymous functions adapted to an interface", func(t *testing.T){ 
  messageRetriever := MessageRetrieverAdapter(func() string { 
    return "world" 
  }) 

  if messageRetriever == nil { 
    t.Fatal("Can not continue with a nil MessageRetriever") 
  } 

  template := Template{} 
  res := template.ExecuteAlgorithm(messageRetriever) 

  expectedOrError(res, " world ", t) 
}) 

看看我们调用MessageRetrieverAdapter方法的语句。我们传递了一个匿名函数作为参数,定义为func() string。然后,我们重用了我们第一个测试中定义的Template类型来传递messageRetriever变量。最后,我们再次使用expectedOrError方法进行检查。看一下MessageRetrieverAdapter方法,它将返回一个具有 nil 值的函数。如果严格遵循测试驱动开发规则,我们必须先进行测试,实现完成之前测试不能通过。这就是为什么我们在MessageRetrieverAdapter函数中返回 nil。

所以,让我们运行测试:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface
--- FAIL: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 --- FAIL: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface (0.00s)
 template_test.go:39: Can not continue with a nil MessageRetriever
FAIL
exit status 1
FAIL

代码的第 39 行测试失败,而且没有继续执行(再次取决于你如何编写你的代码,表示错误的行可能在其他地方)。我们停止测试执行,因为当我们调用ExecuteAlgorithm方法时,我们将需要一个有效的MessageRetriever接口。

对于我们的 Template 模式的适配器实现,我们将从MessageRetrieverAdapter方法开始:

func MessageRetrieverAdapter(f func() string) MessageRetriever { 
  return &adapter{myFunc: f} 
} 

很容易,对吧?你可能会想知道如果我们为f参数传递nil值会发生什么。好吧,我们将通过调用myFunc函数来解决这个问题。

adapter类型通过这个实现完成了:

type adapter struct { 
  myFunc func() string 
} 

func (a *adapter) Message() string { 
  if a.myFunc != nil { 
    return a.myFunc() 
  } 

  return "" 
} 

在调用Message()函数时,我们检查在调用之前myFunc函数中是否实际存储了东西。如果没有存储任何东西,我们返回一个空字符串。

现在,我们使用适配器模式实现的Template类型的第三个实现已经完成:

go test -v .
=== RUN   TestTemplate_ExecuteAlgorithm
=== RUN   TestTemplate_ExecuteAlgorithm/Using_interfaces
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions
=== RUN   TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface
--- PASS: TestTemplate_ExecuteAlgorithm (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_interfaces (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions (0.00s)
 --- PASS: TestTemplate_ExecuteAlgorithm/Using_anonymous_functions_adapted_to_an_interface (0.00s)
PASS
ok

在 Go 的源代码中寻找模板模式

在 Go 的源代码中,Sort包可以被认为是一个排序算法的模板实现。正如包本身所定义的那样,Sort包提供了对切片和用户定义集合进行排序的基本操作。

在这里,我们还可以找到一个很好的例子,说明为什么 Go 的作者不担心实现泛型。在其他语言中,对列表进行排序可能是泛型使用的最佳例子。Go 处理这个问题的方式也非常优雅-它通过接口来处理这个问题:

type Interface interface { 
  Len() int 
  Less(i, j int) bool 
  Swap(i, j int) 
} 

这是需要使用sort包进行排序的列表的接口。用 Go 的作者的话来说:

"一个类型,通常是一个集合,满足 sort.Interface 可以通过这个包中的例程进行排序。这些方法要求集合的元素通过整数索引进行枚举。"

换句话说,编写一个实现这个Interface的类型,以便Sort包可以用来对任何切片进行排序。排序算法是模板,我们必须定义如何检索我们切片中的值。

如果我们查看sort包,我们还可以找到一个使用排序模板的示例,但我们将创建我们自己的示例:

package main 

import ( 
  "sort" 
  "fmt" 
) 

type MyList []int 

func (m MyList) Len() int { 
  return len(m) 
} 

func (m MyList) Swap(i, j int) { 
  m[i], m[j] = m[j], m[i] 
} 

func (m MyList) Less(i, j int) bool { 
  return m[i] < m[j] 
} 

首先,我们做了一个非常简单的存储int列表的类型。这可以是任何类型的列表,通常是某种结构的列表。然后,我们通过定义LenSwapLess方法来实现sort.Interface接口。

最后,main函数创建了一个MyList类型的无序数字列表:

func main() { 
  var myList MyList = []int{6,4,2,8,1} 

  fmt.Println(myList) 
  sort.Sort(myList) 
  fmt.Println(myList) 
} 

我们打印我们创建的列表(无序),然后对其进行排序(sort.Sort方法实际上修改了我们的变量,而不是返回一个新列表,所以要小心!)。最后,我们再次打印结果列表。这个main方法的控制台输出如下:

go run sort_example.go 
[6 4 2 8 1]
[1 2 4 6 8]

sort.Sort函数以透明的方式对我们的列表进行了排序。它有很多代码编写,并将LenSwapLess方法委托给一个接口,就像我们在我们的模板中委托给MessageRetriever接口一样。

总结模板设计模式

我们希望在这个模式上放很多重点,因为在开发库和框架时非常重要,并且允许我们的库的用户有很大的灵活性和控制。

我们还看到了混合模式是非常常见的,以提供灵活性给用户,不仅在行为上,而且在结构上。当我们需要限制对我们代码的某些部分的访问以避免竞争时,这将非常有用。

备忘录设计模式

现在让我们看一个有着花哨名字的模式。如果我们查字典查看memento的含义,我们会找到以下描述:

"作为对某人或事件的提醒而保留的对象。"

在这里,关键词是reminder,因为我们将用这种设计模式来记住动作。

描述

备忘录的含义与它在设计模式中提供的功能非常相似。基本上,我们将有一个带有一些状态的类型,我们希望能够保存其状态的里程碑。保存有限数量的状态后,我们可以在需要时恢复它们,以执行各种任务-撤销操作、历史记录等。

备忘录设计模式通常有三个参与者(通常称为演员):

  • 备忘录:存储我们想要保存的类型的类型。通常,我们不会直接存储业务类型,而是通过这种类型提供额外的抽象层。

  • 发起者:负责创建备忘录并存储当前活动状态的类型。我们说备忘录类型包装了业务类型的状态,我们使用发起者作为备忘录的创建者。

  • Care Taker:一种存储备忘录列表的类型,可以具有将它们存储在数据库中或不存储超过指定数量的逻辑。

目标

备忘录完全是关于随时间的一系列操作,比如撤消一两个操作或为某个应用程序提供某种事务性。

备忘录为许多任务提供了基础,但其主要目标可以定义如下:

  • 捕获对象状态而不修改对象本身

  • 保存有限数量的状态,以便以后可以检索它们。

一个简单的字符串示例

我们将使用字符串作为要保存的状态的简单示例。这样,我们将在使其变得更加复杂之前,专注于常见的备忘录模式实现。

存储在State实例的字段中的字符串将被修改,我们将能够撤消在此状态下进行的操作。

需求和验收标准

我们不断谈论状态;总之,备忘录模式是关于存储和检索状态的。我们的验收标准必须完全关于状态:

  1. 我们需要存储有限数量的字符串类型的状态。

  2. 我们需要一种方法来将当前存储的状态恢复到状态列表中的一个。

有了这两个简单的要求,我们就可以开始为这个示例编写一些测试了。

单元测试

如前所述,备忘录设计模式通常由三个角色组成:状态、备忘录和 originator。因此,我们将需要三种类型来表示这些角色:

type State struct { 
  Description string 
} 

State类型是我们在本例中将使用的核心业务对象。它是我们想要跟踪的任何类型的对象:

type memento struct { 
  state State 
} 

memento类型有一个名为state的字段,表示State类型的单个值。在将它们存储到care taker类型之前,我们的states将被封装在这种类型中。您可能会想知道为什么我们不直接存储State实例。基本上,因为这将使originatorcareTaker与业务对象耦合在一起,而我们希望尽可能少地耦合。这也将更不灵活,正如我们将在第二个例子中看到的:

type originator struct { 
  state State 
} 

func (o *originator) NewMemento() memento { 
  return memento{} 
} 

func (o *originator) ExtractAndStoreState(m memento) { 
  //Does nothing 
} 

originator类型还存储状态。originator结构的对象将从备忘录中获取状态,并使用其存储的状态创建新的备忘录。

提示

原始对象和备忘录模式之间有什么区别?为什么我们不直接使用 Originator 模式的对象?嗯,如果 Memento 包含特定状态,originator类型包含当前加载的状态。此外,保存某物的状态可能像获取某个值那样简单,也可能像维护某个分布式应用程序的状态那样复杂。

Originator 将有两个公共方法--NewMemento()方法和ExtractAndStoreState(m memento)方法。NewMemento方法将返回一个使用originator当前State值构建的新 Memento。ExtractAndStoreState方法将获取 Memento 的状态并将其存储在Originator的状态字段中:

type careTaker struct { 
  mementoList []memento 
} 

func (c *careTaker) Add(m memento) { 
  //Does nothing 
} 

func (c *careTaker) Memento(i int) (memento, error) { 
  return memento{}, fmt.Errorf("Not implemented yet") 
} 

careTaker类型存储了我们需要保存的所有状态的 Memento 列表。它还存储了一个Add方法,用于在列表中插入新的 Memento,以及一个 Memento 检索器,它在 Memento 列表上获取一个索引。

所以让我们从careTaker类型的Add方法开始。Add方法必须获取一个memento对象,并将其添加到careTaker对象的 Mementos 列表中:

func TestCareTaker_Add(t *testing.T) { 
  originator := originator{} 
  originator.state = State{Description:"Idle"} 

  careTaker := careTaker{} 
  mem := originator.NewMemento() 
  if mem.state.Description != "Idle" { 
    t.Error("Expected state was not found") 
  } 

在我们的测试开始时,我们为备忘录创建了两个基本的角色--originatorcareTaker。我们在 originator 上设置了第一个状态,描述为Idle

然后,我们调用NewMemento方法创建第一个 Memento。这应该将当前 originator 的状态封装在memento类型中。我们的第一个检查非常简单--返回的 Memento 的状态描述必须与我们传递给 originator 的状态描述相同,即Idle描述。

检查我们的 Memento 的Add方法是否正确工作的最后一步是看看在添加一个项目后 Memento 列表是否增长:

  currentLen := len(careTaker.mementoList) 
  careTaker.Add(mem) 

  if len(careTaker.mementoList) != currentLen+1 { 
    t.Error("No new elements were added on the list") 
  } 

我们还必须测试Memento(int) memento方法。这应该从careTaker列表中获取一个memento值。它获取你想要从列表中检索的索引,所以像通常一样,我们必须检查它对负数和超出索引值的行为是否正确:

func TestCareTaker_Memento(t *testing.T) { 
  originator := originator{} 
  careTaker := careTaker{} 

  originator.state = State{"Idle"} 
  careTaker.Add(originator.NewMemento()) 

我们必须像在之前的测试中一样开始--创建originatorcareTaker对象,并将第一个 Memento 添加到caretaker中:

  mem, err := careTaker.Memento(0) 
  if err != nil { 
    t.Fatal(err) 
  } 

  if mem.state.Description != "Idle" { 
    t.Error("Unexpected state") 
  } 

一旦我们在careTaker对象上有了第一个对象,我们就可以使用careTaker.Memento(0)来请求它。Memento(int)方法中的索引0检索切片上的第一个项目(记住切片从0开始)。不应该返回错误,因为我们已经向caretaker对象添加了一个值。

然后,在检索第一个 memento 之后,我们检查描述是否与测试开始时传递的描述匹配:

  mem, err = careTaker.Memento(-1) 
  if err == nil { 
    t.Fatal("An error is expected when asking for a negative number but no error was found") 
  } 
} 

这个测试的最后一步涉及使用负数来检索一些值。在这种情况下,必须返回一个错误,显示不能使用负数。当传递负数时,也可能返回第一个索引,但在这里我们将返回一个错误。

要检查的最后一个函数是ExtractAndStoreState方法。这个函数必须获取一个 Memento 并提取它的所有状态信息,然后将其设置在Originator对象中:

func TestOriginator_ExtractAndStoreState(t *testing.T) { 
  originator := originator{state:State{"Idle"}} 
  idleMemento := originator.NewMemento() 

  originator.ExtractAndStoreState(idleMemento) 
  if originator.state.Description != "Idle" { 
    t.Error("Unexpected state found") 
  } 
} 

这个测试很简单。我们用一个Idle状态创建一个默认的originator变量。然后,我们检索一个新的 Memento 对象以便以后使用。我们将originator变量的状态更改为Working状态,以确保新状态将被写入。

最后,我们必须使用idleMemento变量调用ExtractAndStoreState方法。这应该将原始对象的状态恢复到idleMemento状态的值,这是我们在最后的if语句中检查的。

现在是运行测试的时候了:

go test -v . 
=== RUN   TestCareTaker_Add
--- FAIL: TestCareTaker_Add (0.00s)
 memento_test.go:13: Expected state was not found
 memento_test.go:20: No new elements were added on the list
=== RUN   TestCareTaker_Memento
--- FAIL: TestCareTaker_Memento (0.00s)
 memento_test.go:33: Not implemented yet
=== RUN   TestOriginator_ExtractAndStoreState
--- FAIL: TestOriginator_ExtractAndStoreState (0.00s)
 memento_test.go:54: Unexpected state found
FAIL
exit status 1
FAIL

因为三个测试失败,我们可以继续实现。

实现备忘录模式

如果你不要太疯狂,实现备忘录模式通常非常简单。模式中的三个角色(mementooriginatorcare taker)在模式中有非常明确定义的角色,它们的实现非常直接:

type originator struct { 
  state State 
} 

func (o *originator) NewMemento() memento { 
  return memento{state: o.state} 
} 

func (o *originator) ExtractAndStoreState(m memento) { 
  o.state = m.state 
} 

当调用NewMemento方法时,Originator对象需要返回 Memento 类型的新值。它还需要根据ExtractAndStoreState方法的需要将memento对象的值存储在结构的状态字段中。

type careTaker struct { 
  mementoList []memento 
} 

func (c *careTaker) Push(m memento) { 
  c.mementoList = append(c.mementoList, m) 
} 

func (c *careTaker) Memento(i int) (memento, error) { 
  if len(c.mementoList) < i || i < 0 { 
    return memento{}, fmt.Errorf("Index not found\n") 
  } 
  return c.mementoList[i], nil 
} 

careTaker类型也很简单。当我们调用Add方法时,通过调用带有传入参数的append方法来覆盖mementoList字段。这将创建一个包含新值的新列表。

在调用Memento方法时,我们必须先进行一些检查。在这种情况下,我们检查索引是否不超出切片的范围,并且在if语句中检查索引是否不是负数,如果是,则返回错误。如果一切顺利,它只返回指定的memento对象,没有错误。

提示

关于方法和函数命名约定的一点说明。你可能会发现一些人喜欢给方法取名字更具描述性,比如Memento。一个例子是使用一个名为MementoOrError的方法,清楚地显示在调用此函数时返回两个对象,甚至是GetMementoOrError方法。这可能是一种非常明确的命名方法,不一定是坏事,但你在 Go 的源代码中不会经常看到这种情况。

检查测试结果的时间到了:

go test -v .
=== RUN   TestCareTaker_Add
--- PASS: TestCareTaker_Add (0.00s)
=== RUN   TestCareTaker_Memento
--- PASS: TestCareTaker_Memento (0.00s)
=== RUN   TestOriginator_ExtractAndStoreState
--- PASS: TestOriginator_ExtractAndStoreState (0.00s)
PASS
ok

这足以达到 100%的覆盖率。虽然这远非完美的度量标准,但至少我们知道我们正在触及源代码的每一个角落,而且我们没有在测试中作弊来实现它。

另一个使用命令和外观模式的例子

前面的例子足够好而简单,以理解 Memento 模式的功能。然而,它更常用于与命令模式和简单的 Facade 模式结合使用。

这个想法是使用命令模式来封装一组不同类型的状态(实现Command接口的那些状态),并提供一个小的外观来自动将其插入caretaker对象中。

我们将开发一个假想音频混音器的小例子。我们将使用相同的 Memento 模式来保存两种类型的状态:VolumeMuteVolume状态将是一个字节类型,Mute状态将是一个布尔类型。我们将使用两种完全不同的类型来展示这种方法的灵活性(以及它的缺点)。

顺便说一句,我们还可以在接口上为每个Command接口提供自己的序列化方法。这样,我们可以让 caretaker 在不真正知道正在存储什么的情况下将状态存储在某种存储中。

我们的Command接口将有一个方法来返回其实现者的值。这很简单,我们想要撤消的音频混音器中的每个命令都必须实现这个接口:

type Command interface { 
  GetValue() interface{} 
} 

这个接口中有一些有趣的地方。GetValue方法返回一个值的接口。这也意味着这个方法的返回类型是...嗯...无类型的?不是真的,但它返回一个接口,可以是任何类型的表示,我们稍后需要对其进行类型转换,如果我们想使用其特定类型。现在我们必须定义VolumeMute类型并实现Command接口:

type Volume byte 

func (v Volume) GetValue() interface{} { 
  return v 
} 

type Mute bool 

func (m Mute) GetValue() interface{} { 
  return m 
} 

它们都是相当简单的实现。但是,Mute类型将在GetValue()方法上返回一个bool类型,Volume将返回一个byte类型。

与之前的例子一样,我们需要一个将保存CommandMemento类型。换句话说,它将存储指向MuteVolume类型的指针:

type Memento struct { 
  memento Command 
} 

originator类型的工作方式与之前的例子相同,但是使用Command关键字而不是state关键字:

type originator struct { 
  Command Command 
} 

func (o *originator) NewMemento() Memento { 
  return Memento{memento: o.Command} 
} 

func (o *originator) ExtractAndStoreCommand(m Memento) { 
  o.Command = m.memento 
} 

caretaker对象几乎相同,但这次我们将使用堆栈而不是简单列表,并且我们将存储命令而不是状态:

type careTaker struct { 
  mementoList []Memento 
} 

func (c *careTaker) Add(m Memento) { 
  c.mementoList = append(c.mementoList, m) 
} 

func (c *careTaker) Pop() Memento { 
  if len(c.mementoStack) > 0 { 
    tempMemento := c.mementoStack[len(c.mementoStack)-1] 
    c.mementoStack = c.mementoStack[0:len(c.mementoStack)-1] 
    return tempMemento 
  } 

  return Memento{} 
} 

然而,我们的Memento列表被替换为Pop方法。它也返回一个memento对象,但它将它们作为堆栈返回(最后进入,最先出)。因此,我们取堆栈上的最后一个元素并将其存储在tempMemento变量中。然后,我们用不包含下一行上的最后一个元素的新版本替换堆栈。最后,我们返回tempMemento变量。

到目前为止,一切看起来几乎与之前的例子一样。我们还谈到了通过使用 Facade 模式自动化一些任务,所以让我们来做吧。这将被称为MementoFacade类型,并将具有SaveSettingsRestoreSettings方法。SaveSettings方法接受一个Command,将其存储在内部 originator 中,并保存在内部的careTaker字段中。RestoreSettings方法执行相反的流程-恢复careTaker的索引并返回Memento对象中的Command

type MementoFacade struct { 
  originator originator 
  careTaker  careTaker 
} 

func (m *MementoFacade) SaveSettings(s Command) { 
  m.originator.Command = s 
  m.careTaker.Add(m.originator.NewMemento()) 
} 

func (m *MementoFacade) RestoreSettings(i int) Command { 
  m.originator.ExtractAndStoreCommand(m.careTaker.Memento(i)) 
  return m.originator.Command 
} 

我们的 Facade 模式将保存 originator 和 care taker 的内容,并提供这两个易于使用的方法来保存和恢复设置。

那么,我们如何使用这个?

func main(){ 
  m := MementoFacade{} 

  m.SaveSettings(Volume(4)) 
  m.SaveSettings(Mute(false)) 

首先,我们使用 Facade 模式获取一个变量。零值初始化将给我们零值的originatorcaretaker对象。它们没有任何意外的字段,因此一切都将正确初始化(例如,如果它们中的任何一个有一个指针,它将被初始化为nil,如第一章中提到的零初始化部分,准备...开始...跑!)。

我们使用Volume(4)创建了一个Volume值,是的,我们使用了括号。Volume类型没有像结构体那样的内部字段,因此我们不能使用大括号来设置其值。设置它的方法是使用括号(或者创建指向Volume类型的指针,然后设置指向空间的值)。我们还使用外观模式保存了一个Mute类型的值。

我们不知道这里返回了什么类型的Command,因此我们需要进行类型断言。我们将编写一个小函数来帮助我们进行检查类型并打印适当的值:

func assertAndPrint(c Command){ 
  switch cast := c.(type) { 
  case Volume: 
    fmt.Printf("Volume:\t%d\n", cast) 
  case Mute: 
    fmt.Printf("Mute:\t%t\n", cast) 
  } 
} 

assertAndPrint方法接受一个Command类型并将其转换为两种可能的类型-VolumeMute。在每种情况下,它都会向控制台打印一条消息。现在我们可以继续并完成main函数,它将如下所示:

func main() { 
  m := MementoFacade{} 

  m.SaveSettings(Volume(4)) 
  m.SaveSettings(Mute(false)) 

 assertAndPrint(m.RestoreSettings(0))
 assertAndPrint(m.RestoreSettings(1)) 
} 

粗体部分显示了main函数中的新更改。我们从careTaker对象中取出索引 0 并将其传递给新函数,索引1也是一样。运行这个小程序,我们应该在控制台上得到VolumeMute的值:

$ go run memento_command.go
Mute:   false
Volume: 4

太好了!在这个小例子中,我们结合了三种不同的设计模式,以便继续舒适地使用各种模式。请记住,我们也可以将VolumeMute状态的创建抽象为工厂模式,所以这并不是我们停下来的地方。

关于备忘录模式的最后一句话

通过备忘录模式,我们学会了一种强大的方式来创建可撤销的操作,这在编写 UI 应用程序时非常有用,也在开发事务操作时非常有用。无论如何,情况都是一样的:您需要一个Memento,一个Originator和一个caretaker角色。

提示

事务操作是一组必须全部完成或失败的原子操作。换句话说,如果您有一个由五个操作组成的事务,只有其中一个失败,事务就无法完成,其他四个操作所做的每个修改都必须被撤消。

解释器设计模式

现在我们将深入研究一个相当复杂的模式。事实上,解释器模式被广泛用于解决业务案例,其中有一个语言执行常见操作的需求。让我们看看我们所说的语言是什么意思。

描述

我们可以谈论的最著名的解释器可能是 SQL。它被定义为用于管理关系数据库中保存的数据的特定编程语言。SQL 非常复杂和庞大,但总的来说,它是一组单词和操作符,允许我们执行插入、选择或删除等操作。

另一个典型的例子是音乐符号。它本身就是一种语言,解释器是懂得音符与乐器上的表示之间连接的音乐家。

在计算机科学中,为各种原因设计一个小语言可能是有用的:重复的任务,非开发人员的高级语言,或者接口定义语言IDL)如Protocol buffersApache Thrift

目标

设计一个新的语言,无论大小,都可能是一项耗时的任务,因此在投入时间和资源编写其解释器之前,明确目标非常重要:

  • 提供一些范围内非常常见操作的语法(比如播放音符)。

  • 拥有一个中间语言来在两个系统之间转换操作。例如,生成 3D 打印所需的Gcode的应用程序。

  • 简化某些操作的使用,使用更易于使用的语法。

SQL 允许使用关系数据库的非常易于使用的语法(也可能变得非常复杂),但其思想是不需要编写自己的函数来进行插入和搜索。

例如 - 逆波兰表达式计算器

解释器的一个非常典型的例子是创建一个逆波兰表示法计算器。对于那些不知道波兰表示法是什么的人来说,它是一种数学表示法,用于进行操作,其中你首先写下你的操作(求和),然后是值(3 4),因此+ 3 4等同于更常见的3 + 4,其结果将是7。因此,对于逆波兰表示法,你首先放置值,然后是操作,因此3 4 +也将是7

计算器的验收标准

对于我们的计算器,我们应该通过的验收标准如下:

  1. 创建一种允许进行常见算术运算(求和、减法、乘法和除法)的语言。语法是sum表示求和,mul表示乘法,sub表示减法,div表示除法。

  2. 必须使用逆波兰表示法。

  3. 用户必须能够连续写入任意多个操作。

  4. 操作必须从左到右执行。

因此,3 4 sum 2 sub表示的是(3 + 4) - 2,结果将是5

一些操作的单元测试

在这种情况下,我们只有一个名为Calculate的公共方法,它接受一个操作及其值定义为字符串,并将返回一个值或一个错误:

func Calculate(o string) (int, error) { 
  return 0, fmt.Errorf("Not implemented yet") 
} 

因此,我们将发送一个字符串,如"3 4 +"Calculate方法,它应该返回7, nil。另外两个测试将检查正确的实现:

func TestCalculate(t *testing.T) { 
  tempOperation = "3 4 sum 2 sub" 
  res, err = Calculate(tempOperation) 
  if err != nil { 
    t.Error(err) 
  } 

  if res != 5 { 
    t.Errorf("Expected result not found: %d != %d\n", 5, res) 
  } 

首先,我们将进行我们作为示例使用的操作。3 4 sum 2 sub表示是我们语言的一部分,我们在Calculate函数中使用它。如果返回错误,则测试失败。最后,结果必须等于5,我们在最后几行进行检查。下一个测试检查了稍微复杂一些的操作的其余操作符:

  tempOperation := "5 3 sub 8 mul 4 sum 5 div" 
  res, err := Calculate(tempOperation) 
  if err != nil { 
    t.Error(err) 
  } 

  if res != 4 { 
    t.Errorf("Expected result not found: %d != %d\n", 4, res) 
  } 
} 

在这里,我们使用了一个更长的操作重复了前面的过程,即(((5 - 3) * 8) + 4) / 5,它等于4。从左到右,它将如下进行:

(((5 - 3) * 8) + 4) / 5
 ((2 * 8) + 4) / 5
 (16 + 4) / 5
 20 / 5
 4

当然,测试必须失败!

$ go test -v .
 interpreter_test.go:9: Not implemented yet
 interpreter_test.go:13: Expected result not found: 4 != 0
 interpreter_test.go:19: Not implemented yet
 interpreter_test.go:23: Expected result not found: 5 != 0
exit status 1
FAIL

实施

这次实现的时间要比测试长。首先,我们将在常量中定义可能的运算符:

const ( 
  SUM = "sum" 
  SUB = "sub" 
  MUL = "mul" 
  DIV = "div" 
) 

解释器模式通常使用抽象语法树来实现,这通常使用堆栈来实现。我们在本书中之前已经创建了堆栈,所以这对读者来说应该已经很熟悉了:

type polishNotationStack []int 

func (p *polishNotationStack) Push(s int) { 
  *p = append(*p, s) 
} 

func (p *polishNotationStack) Pop() int { 
  length := len(*p) 

  if length > 0 { 
    temp := (*p)[length-1] 
    *p = (*p)[:length-1] 
    return temp 
  } 

  return 0 
} 

我们有两种方法——Push方法用于将元素添加到堆栈顶部,Pop方法用于移除元素并返回它们。如果你认为*p = (*p)[:length-1]这一行有点神秘,我们会解释它。

存储在p方向的值将被覆盖为p方向的实际值(p),但只取数组的开始到倒数第二个元素(:length-1)。

因此,现在我们将逐步进行Calculate函数,根据需要创建更多的函数:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

我们需要做的前两件事是创建堆栈并从传入的操作中获取所有不同的符号(在这种情况下,我们没有检查它是否为空)。我们通过空格拆分传入的字符串操作,以获得一个漂亮的符号(值和运算符)切片。

接下来,我们将使用 range 迭代每个符号,但我们需要一个函数来知道传入的符号是值还是运算符:

func isOperator(o string) bool { 
  if o == SUM || o == SUB || o == MUL || o == DIV { 
    return true 
  } 

  return false 
} 

如果传入的符号是我们常量中定义的任何一个,那么传入的符号就是一个运算符。

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators {
 if isOperator(operatorString) {
 right := stack.Pop()
 left := stack.Pop()
 } 
  else 
  {
 //Is a value
 } 
}

如果它是一个运算符,我们认为我们已经传递了两个值,所以我们要做的就是从堆栈中取出这两个值。取出的第一个值将是最右边的,第二个值将是最左边的(请记住,在减法和除法中,操作数的顺序很重要)。然后,我们需要一些函数来获取我们想要执行的操作:

func getOperationFunc(o string) func(a, b int) int { 
  switch o { 
  case SUM: 
    return func(a, b int) int { 
      return a + b 
    } 
  case SUB: 
    return func(a, b int) int { 
      return a - b 
    } 
  case MUL: 
    return func(a, b int) int { 
      return a * b 
    } 
  case DIV: 
    return func(a, b int) int { 
      return a / b 
    } 
  } 
  return nil 
} 

getOperationFunc函数返回一个返回整数的两参数函数。我们检查传入的运算符,然后返回执行指定操作的匿名函数。因此,现在我们的for range继续如下:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
  if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
 mathFunc := getOperationFunc(operatorString)
 res := mathFunc(left, right)
 stack.Push(res) 
    } else { 
      //Is a value 
    } 
} 

mathFunc变量由该函数返回。我们立即使用它对从堆栈中取出的左值和右值执行操作,并将其结果存储在一个名为res的新变量中。最后,我们需要将这个新值推送到堆栈中,以便稍后继续操作。

现在,当传入的符号是一个值时,这是实现:

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
    if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := getOperationFunc(operatorString) 
      res := mathFunc(left, right) 
      stack.Push(res) 
    } else { 
 val, err := strconv.Atoi(operatorString)
 if err != nil {
 return 0, err
 }
 stack.Push(val) 
    } 
  } 

每次我们得到一个符号时,我们需要将其推送到堆栈中。我们必须将字符串符号解析为可用的int类型。这通常使用strconv包来完成,通过使用其Atoi函数。Atoi函数接受一个字符串并返回一个整数或一个错误。如果一切顺利,该值将被推送到堆栈中。

range语句结束时,只需存储一个值,所以我们只需要返回它,函数就完成了。

func Calculate(o string) (int, error) { 
  stack := polishNotationStack{} 
  operators := strings.Split(o, " ") 

for _, operatorString := range operators { 
    if isOperator(operatorString) { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := getOperationFunc(operatorString) 
      res := mathFunc(left, right) 
      stack.Push(res) 
    } else { 
      val, err := strconv.Atoi(operatorString) 
      if err != nil { 
        return 0, err 
      } 

      stack.Push(val) 
    } 
  } 
 return int(stack.Pop()), nil
}

是时候再次运行测试了:

$ go test -v .
ok

太棒了!我们刚刚以一种非常简单和容易的方式创建了一个逆波兰表示法解释器(我们仍然缺少解析器,但那是另一回事)。

解释器设计模式的复杂性

在这个例子中,我们没有使用任何接口。这并不完全符合更面向对象的语言中定义的解释器设计模式。然而,这个例子是最简单的例子,可以理解语言的目标,下一个级别不可避免地会更复杂,不适合初学者用户。

在更复杂的例子中,我们将不得不定义一个包含更多类型的类型,一个值或无类型。通过解析器,您可以创建这个抽象语法树以后进行解释。

使用接口完成相同的示例,如下所述。

再次使用接口的解释器模式

我们将使用的主要接口称为Interpreter接口。该接口具有一个Read()方法,每个符号(值或运算符)都必须实现:

type Interpreter interface { 
  Read() int 
} 

我们将仅实现运算符的求和和减法,以及称为Value的数字类型:

type value int 

func (v *value) Read() int { 
  return int(*v) 
} 

Value是一个int类型,当实现Read方法时,只返回其值:

type operationSum struct { 
  Left  Interpreter 
  Right Interpreter 
} 

func (a *operationSum) Read() int { 
  return a.Left.Read() + a.Right.Read() 
} 

operationSum结构具有LeftRight字段,其Read方法返回其各自的Read方法的和。operationSubtract结构也是一样,但是减去:

type operationSubtract struct { 
  Left  Interpreter 
  Right Interpreter 
} 

func (s *operationSubtract) Read() int { 
  return s.Left.Read() - s.Right.Read() 
} 

我们还需要一个工厂模式来创建运算符;我们将称之为operatorFactory方法。现在的区别在于它不仅接受符号,还接受从堆栈中取出的LeftRight值:

func operatorFactory(o string, left, right Interpreter) Interpreter { 
  switch o { 
  case SUM: 
    return &operationSum{ 
      Left: left, 
      Right: right, 
    } 
  case SUB: 
    return &operationSubtract{ 
      Left: left, 
      Right: right, 
    } 
  } 

  return nil 
} 

正如我们刚才提到的,我们还需要一个堆栈。我们可以通过更改其类型来重用前面示例中的堆栈:

type polishNotationStack []Interpreter 

func (p *polishNotationStack) Push(s Interpreter) { 
  *p = append(*p, s) 
} 

func (p *polishNotationStack) Pop() Interpreter { 
  length := len(*p) 

  if length > 0 { 
    temp := (*p)[length-1] 
    *p = (*p)[:length-1] 
    return temp 
  } 

  return nil 
} 

现在堆栈使用解释器指针而不是int,但其功能是相同的。最后,我们的main方法看起来也与之前的示例类似:

func main() { 
  stack := polishNotationStack{} 
  operators := strings.Split("3 4 sum 2 sub", " ") 

  for _, operatorString := range operators { 
    if operatorString == SUM || operatorString == SUB { 
      right := stack.Pop() 
      left := stack.Pop() 
      mathFunc := operatorFactory(operatorString, left, right) 
      res := value(mathFunc.Read()) 
      stack.Push(&res) 
    } else { 
      val, err := strconv.Atoi(operatorString) 
      if err != nil { 
        panic(err) 
      } 

      temp := value(val) 
      stack.Push(&temp) 
    } 
  } 

  println(int(stack.Pop().Read())) 
} 

与以前一样,我们首先检查符号是运算符还是值。当它是一个值时,它将其推送到堆栈中。

当符号是一个运算符时,我们还从堆栈中取出右值和左值,我们使用当前运算符和刚刚从堆栈中取出的左值和右值调用工厂模式。一旦我们有了运算符类型,我们只需要调用其Read方法将返回的值也推送到堆栈中。

最后,只剩下一个例子必须留在堆栈上,所以我们打印它:

$ go run interpreter.go
5

解释器模式的力量

这种模式非常强大,但也必须谨慎使用。创建一种语言会在其用户和提供的功能之间产生强大的耦合。人们可能会犯错误,试图创建一种过于灵活、难以使用和维护的语言。另外,人们可能会创建一种相当小而有用的语言,有时解释不正确,这可能会给用户带来痛苦。

在我们的例子中,我们省略了相当多的错误检查,以便专注于解释器的实现。然而,你需要相当多的错误检查和错误时冗长的输出,以帮助用户纠正其语法错误。所以,写你的语言时要开心,但要对用户友好。

总结

本章介绍了三种非常强大的模式,在将它们用于生产代码之前,需要进行大量的练习。通过模拟典型的生产问题来进行一些练习是一个非常好的主意:

  • 创建一个简单的 REST 服务器,重用大部分错误检查和连接功能,提供一个易于使用的接口来练习模板模式

  • 制作一个小型库,可以写入不同的数据库,但前提是所有写入都没有问题,或者删除新创建的写入以练习备忘录,例如

  • 编写你自己的语言,做一些简单的事情,比如回答简单的问题,就像机器人通常做的那样,这样你就可以练习一下解释器模式

这个想法是练习编码并重新阅读任何部分,直到你对每个模式都感到舒适。

第七章:行为模式 - 访问者,状态,中介者和观察者设计模式

这是关于行为模式的最后一章,也是本书关于 Go 语言中常见的、众所周知的设计模式的部分的结束。

在本章中,我们将研究另外三种设计模式。当您想要从一组对象中抽象出一些功能时,访问者模式非常有用。

状态通常用于构建有限状态机FSM),在本节中,我们将开发一个小的猜数字游戏。

最后,观察者模式通常用于事件驱动的架构,并且在微服务世界中再次获得了很多关注。

在本章之后,我们需要在深入并发和它带来的设计模式的优势(和复杂性)之前,对常见的设计模式感到非常舒适。

访问者设计模式

在下一个设计模式中,我们将把对象类型的一些逻辑委托给一个名为访问者的外部类型,该类型将访问我们的对象以对其执行操作。

描述

在访问者设计模式中,我们试图将与特定对象一起工作所需的逻辑与对象本身分离。因此,我们可以有许多不同的访问者对特定类型执行某些操作。

例如,想象一下我们有一个写入控制台的日志记录器。我们可以使记录器“可访问”,以便您可以在每个日志前添加任何文本。我们可以编写一个访问者模式,它将日期、时间和主机名添加到对象中存储的字段。

目标

在行为设计模式中,我们主要处理算法。访问者模式也不例外。我们试图实现的目标如下:

  • 将某种类型的算法与其在其他类型中的实现分离

  • 通过使用一些类型来提高其灵活性,几乎不需要任何逻辑,因此所有新功能都可以添加而不改变对象结构

  • 修复会破坏类型中的开闭原则的结构或行为

您可能会想知道开闭原则是什么。在计算机科学中,开闭原则指出:实体应该对扩展开放,但对修改关闭。这个简单的状态有很多含义,可以构建更易于维护且不太容易出错的软件。访问者模式帮助我们将一些常常变化的算法从我们需要它“稳定”的类型委托给一个经常变化的外部类型,而不会影响我们的原始类型。

日志附加器

我们将开发一个简单的日志附加器作为访问者模式的示例。遵循我们在之前章节中的方法,我们将从一个极其简单的示例开始,以清楚地理解访问者设计模式的工作原理,然后再转向更复杂的示例。我们已经开发了类似的示例,但以稍微不同的方式修改文本。

对于这个特定的例子,我们将创建一个访问者,它会向“访问”的类型附加不同的信息。

验收标准

要有效地使用访问者设计模式,我们必须有两个角色--访问者和可访问者。Visitor是将在Visitable类型内执行的类型。因此,Visitable接口实现将算法分离到Visitor类型:

  1. 我们需要两个消息记录器:MessageAMessageB,它们将在消息之前分别打印带有A:B:的消息。

  2. 我们需要一个访问者能够修改要打印的消息。它将分别将文本“Visited A”或“Visited B”附加到它们。

单元测试

正如我们之前提到的,我们将需要VisitorVisitable接口的角色。它们将是接口。我们还需要MessageAMessageB结构:

package visitor 

import ( 
  "io" 
  "os" 
  "fmt" 
) 

type MessageA struct { 
  Msg string 
  Output io.Writer 
} 

type MessageB struct { 
  Msg string 
  Output io.Writer 
} 

type Visitor interface { 
  VisitA(*MessageA) 
  VisitB(*MessageB) 
} 

type Visitable interface { 
  Accept(Visitor) 
} 

type MessageVisitor struct {} 

MessageAMessageB 结构都有一个 Msg 字段来存储它们将要打印的文本。输出 io.Writer 将默认实现 os.Stdout 接口,或者一个新的 io.Writer 接口,就像我们将用来检查内容是否正确的接口一样。

Visitor 接口有一个 Visit 方法,分别用于 Visitable 接口的 MessageAMessageB 类型。Visitable 接口有一个名为 Accept(Visitor) 的方法,将执行解耦的算法。

与以前的示例一样,我们将创建一个实现 io.Writer 包的类型,以便我们可以在测试中使用它:

package visitor 

import "testing" 

type TestHelper struct { 
  Received string 
} 

func (t *TestHelper) Write(p []byte) (int, error) { 
  t.Received = string(p) 
  return len(p), nil 
} 

TestHelper 结构实现了 io.Writer 接口。它的功能非常简单;它将写入的字节存储在 Received 字段上。稍后我们可以检查 Received 的内容来测试是否符合我们的预期值。

我们将只编写一个测试,检查代码的整体正确性。在这个测试中,我们将编写两个子测试:一个用于 MessageA,一个用于 MessageB 类型:

func Test_Overall(t *testing.T) { 
  testHelper := &TestHelper{} 
  visitor := &MessageVisitor{} 
  ... 
} 

我们将在每个消息类型的每个测试中使用一个 TestHelper 结构和一个 MessageVisitor 结构。首先,我们将测试 MessageA 类型:

func Test_Overall(t *testing.T) { 
  testHelper := &TestHelper{} 
  visitor := &MessageVisitor{} 

  t.Run("MessageA test", func(t *testing.T){ 
    msg := MessageA{ 
      Msg: "Hello World", 
      Output: testHelper, 
    } 

    msg.Accept(visitor) 
    msg.Print() 

    expected := "A: Hello World (Visited A)" 
    if testHelper.Received !=  expected { 
      t.Errorf("Expected result was incorrect. %s != %s", 
      testHelper.Received, expected) 
    } 
  }) 
  ... 
} 

这是完整的第一个测试。我们创建了 MessageA 结构,为 Msg 字段赋予了值 Hello World,并为其传递了在测试开始时创建的 TestHelper 的指针。然后,我们执行它的 Accept 方法。在 MessageA 结构的 Accept(Visitor) 方法中,将执行 VisitA(*MessageA) 方法来改变 Msg 字段的内容(这就是为什么我们传递了 VisitA 方法的指针,没有指针内容将不会被持久化)。

为了测试 Visitor 类型在 Accept 方法中是否完成了其工作,我们必须稍后在 MessageA 类型上调用 Print() 方法。这样,MessageA 结构必须将 Msg 的内容写入提供的 io.Writer 接口(我们的 TestHelper)。

测试的最后一部分是检查。根据验收标准 2的描述,MessageA 类型的输出文本必须以文本 A: 为前缀,存储的消息和文本 "(Visited)" 为结尾。因此,对于 MessageA 类型,期望的文本必须是 "A: Hello World (Visited)",这是我们在 if 部分进行的检查。

MessageB 类型有一个非常相似的实现:

  t.Run("MessageB test", func(t *testing.T){ 
    msg := MessageB { 
      Msg: "Hello World", 
      Output: testHelper, 
    } 

    msg.Accept(visitor) 
    msg.Print() 

    expected := "B: Hello World (Visited B)" 
    if testHelper.Received !=  expected { 
      t.Errorf("Expected result was incorrect. %s != %s", 
        testHelper.Received, expected) 
    } 
  }) 
} 

实际上,我们刚刚将类型从 MessageA 更改为 MessageB,现在期望的文本是 "B: Hello World (Visited B)"Msg 字段也是 "Hello World",我们还使用了 TestHelper 类型。

我们仍然缺少正确的接口实现来编译代码并运行测试。MessageAMessageB 结构必须实现 Accept(Visitor) 方法:

func (m *MessageA) Accept(v Visitor) { 
  //Do nothing 
} 

func (m *MessageB) Accept(v Visitor) { 
  //Do nothing 
} 

我们需要实现在 Visitor 接口上声明的 VisitA(*MessageA)VisitB(*MessageB) 方法。MessageVisitor 接口是必须实现它们的类型:

func (mf *MessageVisitor) VisitA(m *MessageA){ 
  //Do nothing 
} 
func (mf *MessageVisitor) VisitB(m *MessageB){ 
  //Do nothing 
} 

最后,我们将为每种消息类型创建一个 Print() 方法。这是我们将用来测试每种类型的 Msg 字段内容的方法:

func (m *MessageA) Print(){ 
  //Do nothing 
} 

func (m *MessageB) Print(){ 
  //Do nothing 
} 

现在我们可以运行测试,真正检查它们是否已经失败:

go test -v .
=== RUN   Test_Overall
=== RUN   Test_Overall/MessageA_test
=== RUN   Test_Overall/MessageB_test
--- FAIL: Test_Overall (0.00s)
 --- FAIL: Test_Overall/MessageA_test (0.00s)
 visitor_test.go:30: Expected result was incorrect.  != A: Hello World (Visited A)
 --- FAIL: Test_Overall/MessageB_test (0.00s)
 visitor_test.go:46: Expected result was incorrect.  != B: Hello World (Visited B)
FAIL
exit status 1
FAIL

测试的输出很清楚。期望的消息是不正确的,因为内容是空的。现在是创建实现的时候了。

访问者模式的实现

我们将开始完成 VisitA(*MessageA)VisitB(*MessageB) 方法的实现:

func (mf *MessageVisitor) VisitA(m *MessageA){ 
  m.Msg = fmt.Sprintf("%s %s", m.Msg, "(Visited A)") 
} 
func (mf *MessageVisitor) VisitB(m *MessageB){ 
  m.Msg = fmt.Sprintf("%s %s", m.Msg, "(Visited B)") 
} 

它的功能非常简单- fmt.Sprintf 方法返回一个格式化的字符串,其中包含 m.Msg 的实际内容、一个空格和消息 Visited。这个字符串将被存储在 Msg 字段上,覆盖先前的内容。

现在我们将为每种消息类型开发 Accept 方法,该方法必须执行相应的 Visitor:

func (m *MessageA) Accept(v Visitor) { 
  v.VisitA(m) 
} 

func (m *MessageB) Accept(v Visitor) { 
  v.VisitB(m) 
} 

这段小代码有一些含义。在这两种情况下,我们都使用了一个Visitor,在我们的例子中,它与MessageVisitor接口完全相同,但它们可以完全不同。关键是要理解访问者模式在其Visit方法中执行处理Visitable对象的算法。Visitor可能在做什么?在这个例子中,它改变了Visitable对象,但它也可以简单地从中获取信息。例如,我们可以有一个Person类型,有很多字段:姓名、姓氏、年龄、地址、城市、邮政编码等等。我们可以编写一个访问者,仅从一个人中获取姓名和姓氏作为唯一的字符串,一个访问者从应用程序的不同部分获取地址信息,等等。

最后,有一个Print()方法,它将帮助我们测试这些类型。我们之前提到它必须默认打印到Stdout

func (m *MessageA) Print() { 
  if m.Output == nil { 
    m.Output = os.Stdout 
  } 

  fmt.Fprintf(m.Output, "A: %s", m.Msg) 
} 

func (m *MessageB) Print() { 
  if m.Output == nil { 
    m.Output = os.Stdout 
  } 
  fmt.Fprintf(m.Output, "B: %s", m.Msg) 
} 

首先检查Output字段的内容,以便在os.Stdout调用的输出为空时将其赋值。在我们的测试中,我们在那里存储了一个指向我们的TestHelper类型的指针,因此在我们的测试中永远不会执行这行。最后,每个消息类型都会将存储在Msg字段中的完整消息打印到Output字段。这是通过使用Fprintf方法完成的,该方法将io.Writer包作为第一个参数,要格式化的文本作为下一个参数。

我们的实现现在已经完成,我们可以再次运行测试,看看它们是否都通过了:

go test -v .
=== RUN   Test_Overall
=== RUN   Test_Overall/MessageA_test
=== RUN   Test_Overall/MessageB_test
--- PASS: Test_Overall (0.00s)
 --- PASS: Test_Overall/MessageA_test (0.00s)
 --- PASS: Test_Overall/MessageB_test (0.00s)
PASS
ok

一切都很好!访问者模式已经完美地完成了它的工作,调用它们的Visit方法后,消息内容已经被改变。这里非常重要的一点是,我们可以为这两个结构体添加更多功能,MessageAMessageB,而不改变它们的类型。我们只需创建一个新的访问者类型,对Visitable上的所有操作进行处理,例如,我们可以创建一个Visitor来添加一个打印Msg字段内容的方法:

type MsgFieldVisitorPrinter struct {} 

func (mf *MsgFieldVisitorPrinter) VisitA(m *MessageA){ 
  fmt.Printf(m.Msg) 
} 
func (mf *MsgFieldVisitorPrinter) VisitB(m *MessageB){ 
  fmt.Printf(m.Msg) 
} 

我们刚刚为这两种类型添加了一些功能,而没有改变它们的内容!这就是访问者设计模式的威力。

另一个例子

我们将开发第二个例子,这个例子会更加复杂一些。在这种情况下,我们将模拟一个有几种产品的在线商店。产品将具有简单的类型,只有字段,我们将创建一对访问者来处理它们。

首先,我们将开发接口。ProductInfoRetriever 类型有一个方法来获取产品的价格和名称。Visitor 接口,就像之前一样,有一个接受 ProductInfoRetriever 类型的 Visit 方法。最后,Visitable 接口完全相同;它有一个接受 Visitor 类型作为参数的 Accept 方法。

type ProductInfoRetriever interface { 
  GetPrice() float32 
  GetName() string 
} 

type Visitor interface { 
  Visit(ProductInfoRetriever) 
} 

type Visitable interface { 
  Accept(Visitor) 
} 

在线商店的所有产品都必须实现ProductInfoRetriever类型。此外,大多数产品都将具有一些共同的字段,例如名称或价格(在ProductInfoRetriever接口中定义的字段)。我们创建了Product类型,实现了ProductInfoRetrieverVisitable接口,并将其嵌入到每个产品中:

type Product struct { 
  Price float32 
  Name  string 
} 

func (p *Product) GetPrice() float32 { 
  return p.Price 
} 

func (p *Product) Accept(v Visitor) { 
  v.Visit(p) 
} 

func (p *Product) GetName() string { 
  return p.Name 
} 

现在我们有一个非常通用的Product类型,可以存储商店几乎任何产品的信息。例如,我们可以有一个Rice和一个Pasta产品:

type Rice struct { 
  Product 
} 

type Pasta struct { 
  Product 
} 

每个都嵌入了Product类型。现在我们需要创建一对Visitors接口,一个用于计算所有产品的价格总和,一个用于打印每个产品的名称:

type PriceVisitor struct { 
  Sum float32 
} 

func (pv *PriceVisitor) Visit(p ProductInfoRetriever) { 
  pv.Sum += p.GetPrice() 
} 

type NamePrinter struct { 
  ProductList string 
} 

func (n *NamePrinter) Visit(p ProductInfoRetriever) { 
  n.Names = fmt.Sprintf("%s\n%s", p.GetName(), n.ProductList) 
} 

PriceVisitor结构体获取ProductInfoRetriever类型的Price变量的值,作为参数传递,并将其添加到Sum字段。NamePrinter结构体存储ProductInfoRetriever类型的名称,作为参数传递,并将其附加到ProductList字段的新行上。

现在是main函数的时间:

func main() { 
  products := make([]Visitable, 2) 
  products[0] = &Rice{ 
    Product: Product{ 
      Price: 32.0, 
      Name:  "Some rice", 
    }, 
  } 
  products[1] = &Pasta{ 
    Product: Product{ 
      Price: 40.0, 
      Name:  "Some pasta", 
    }, 
  } 

  //Print the sum of prices 
  priceVisitor := &PriceVisitor{} 

  for _, p := range products { 
    p.Accept(priceVisitor) 
  } 

  fmt.Printf("Total: %f\n", priceVisitor.Sum) 

  //Print the products list 
  nameVisitor := &NamePrinter{} 

  for _, p := range products { 
    p.Accept(nameVisitor) 
  } 

  fmt.Printf("\nProduct list:\n-------------\n%s",  nameVisitor.ProductList) 
} 

我们创建了两个Visitable对象的切片:一个Rice和一个Pasta类型,带有一些任意的名称。然后我们使用PriceVisitor实例作为参数对它们进行迭代。在range for之后,我们打印总价格。最后,我们使用NamePrinter重复这个操作,并打印结果的ProductList。这个main函数的输出如下:

go run visitor.go
Total: 72.000000
Product list:
-------------
Some pasta
Some rice

好的,这是访问者模式的一个很好的例子,但是...如果产品有特殊的考虑呢?例如,如果我们需要在冰箱类型的总价格上加 20 呢?好的,让我们编写Fridge结构:

type Fridge struct { 
  Product 
} 

这里的想法是只需重写GetPrice()方法,以返回产品的价格加 20:

type Fridge struct { 
  Product 
} 

func (f *Fridge) GetPrice() float32 { 
  return f.Product.Price + 20 
} 

不幸的是,这对我们的例子来说还不够。Fridge结构不是Visitable类型。Product结构是Visitable类型,而Fridge结构嵌入了一个Product结构,但是正如我们在前几章中提到的,嵌入第二种类型的类型不能被视为后者的类型,即使它具有所有的字段和方法。解决方案是还要实现Accept(Visitor)方法,以便它可以被视为Visitable

type Fridge struct { 
  Product 
} 

func (f *Fridge) GetPrice() float32 { 
  return f.Product.Price + 20 
} 

func (f *Fridge) Accept(v Visitor) { 
  v.Visit(f) 
} 

让我们重写main函数以将这个新的Fridge产品添加到切片中:

func main() { 
  products := make([]Visitable, 3) 
  products[0] = &Rice{ 
    Product: Product{ 
      Price: 32.0, 
      Name:  "Some rice", 
    }, 
  } 
  products[1] = &Pasta{ 
    Product: Product{ 
      Price: 40.0, 
      Name:  "Some pasta", 
    }, 
  } 
  products[2] = &Fridge{ 
    Product: Product{ 
      Price: 50, 
      Name:  "A fridge", 
    }, 
  } 
  ... 
} 

其他一切都保持不变。运行这个新的main函数会产生以下输出:

$ go run visitor.go
Total: 142.000000
Product list:
-------------
A fridge
Some pasta
Some rice

如预期的那样,总价格现在更高了,输出了大米(32)、意大利面(40)和冰箱(50 的产品加上 20 的运输,所以是 70)的总和。我们可以不断地为这些产品添加访问者,但是想法很清楚——我们将一些算法解耦到访问者之外。

访问者来拯救!

我们已经看到了一个强大的抽象,可以向某些类型添加新的算法。然而,由于 Go 语言中缺乏重载,这种模式在某些方面可能有限(我们在第一个示例中已经看到了这一点,在那里我们不得不创建VisitAVisitB的实现)。在第二个示例中,我们没有处理这个限制,因为我们使用了Visitor结构的Visit方法的接口,但我们只使用了一种类型的访问者(ProductInfoRetriever),如果我们为第二种类型实现了Visit方法,我们将会遇到相同的问题,这是原始四人帮设计模式的目标之一。

状态设计模式

状态模式与 FSM 直接相关。FSM,简单来说,是具有一个或多个状态并在它们之间移动以执行某些行为的东西。让我们看看状态模式如何帮助我们定义 FSM。

描述

一个灯开关是 FSM 的一个常见例子。它有两种状态——开和关。一种状态可以转移到另一种状态,反之亦然。状态模式的工作方式类似。我们有一个State接口和我们想要实现的每个状态的实现。通常还有一个上下文,用于在状态之间保存交叉信息。

通过 FSM,我们可以通过将其范围分割为状态来实现非常复杂的行为。这样我们可以基于任何类型的输入来建模执行管道,或者创建对特定事件以指定方式做出响应的事件驱动软件。

目标

状态模式的主要目标是开发 FSM,如下所示:

  • 当一些内部事物发生变化时,拥有一种可以改变自身行为的类型

  • 可以通过添加更多状态并重新路由它们的输出状态轻松升级模型复杂的图形和管道

一个小猜数字游戏

我们将开发一个非常简单的使用 FSM 的游戏。这个游戏是一个猜数字游戏。想法很简单——我们将不得不猜出 0 到 10 之间的某个数字,我们只有几次尝试,否则就会输掉。

我们将让玩家选择难度级别,询问用户在失去之前有多少次尝试。然后,我们将要求玩家输入正确的数字,并在他们猜不中或尝试次数达到零时继续询问。

验收标准

对于这个简单的游戏,我们有五个验收标准,基本上描述了游戏的机制:

  1. 游戏将询问玩家在失去游戏之前有多少次尝试。

  2. 要猜的数字必须在 0 到 10 之间。

  3. 每当玩家输入一个要猜的数字时,重试次数就会减少一个。

  4. 如果重试次数达到零且数字仍然不正确,游戏结束,玩家输了。

  5. 如果玩家猜中数字,玩家获胜。

状态模式的实现

单元测试的想法在状态模式中非常简单,因此我们将花更多时间详细解释如何使用它的机制,这比通常更复杂一些。

首先,我们需要一个接口来表示不同的状态和一个游戏上下文来存储状态之间的信息。对于这个游戏,上下文需要存储重试次数,用户是否已经赢得游戏,要猜的秘密数字和当前状态。状态将有一个executeState方法,该方法接受这些上下文之一,并在游戏结束时返回true,否则返回false

type GameState interface { 
  executeState(*GameContext) bool 
} 

type GameContext struct { 
  SecretNumber int 
  Retries int 
  Won bool 
  Next GameState 
} 

验收标准 1中所述,玩家必须能够输入他们想要的重试次数。这将通过一个名为StartState的状态来实现。此外,StartState结构必须在玩家之前设置上下文的初始值:

type StartState struct{} 
func(s *StartState) executeState(c *GameContext) bool { 
  c.Next = &AskState{} 

  rand.Seed(time.Now().UnixNano()) 
  c.SecretNumber = rand.Intn(10) 

  fmt.Println("Introduce a number a number of retries to set the difficulty:") 
  fmt.Fscanf(os.Stdin, "%d\n", &c.Retries) 

  return true 
} 

首先,StartState结构实现了GameState结构,因为它在其结构上具有executeState(*Context)方法,返回布尔类型。在这个状态的开始,它设置了执行完这个状态后唯一可能的状态--AskState状态。AskState结构尚未声明,但它将是我们询问玩家猜数字的状态。

在接下来的两行中,我们使用 Go 的Rand包生成一个随机数。在第一行中,我们用当前时刻返回的int64类型数字来喂入随机生成器,因此我们确保每次执行都有一个随机的喂入(如果你在这里放一个常数,随机生成器也会生成相同的数字)。rand.Intn(int)方法返回 0 到指定数字之间的整数,因此我们满足了验收标准 2

接下来,我们设置一个消息询问要设置的重试次数,然后使用fmt.Fscanf方法,一个强大的函数,您可以向其传递一个io.Reader(控制台的标准输入)、一个格式(数字)和一个接口来存储读取器的内容,在这种情况下是上下文的Retries字段。

最后,我们返回true告诉引擎游戏必须继续。让我们看看我们在函数开头使用的AskState结构:

type AskState struct {} 
func (a *AskState) executeState(c *GameContext) bool{ 
  fmt.Printf("Introduce a number between 0 and 10, you have %d tries left\n", c.Retries) 

  var n int 
  fmt.Fscanf(os.Stdin, "%d", &n) 
  c.Retries = c.Retries - 1 

  if n == c.SecretNumber { 
    c.Won = true 
    c.Next = &FinishState{} 
  } 

  if c.Retries == 0 { 
    c.Next = &FinishState{} 
  } 

  return true 
} 

AskState结构也实现了GameState状态,你可能已经猜到了。这个状态从一个向玩家的消息开始,要求他们插入一个新的数字。在接下来的三行中,我们创建一个本地变量来存储玩家将要输入的数字的内容。我们再次使用fmt.Fscanf方法,就像我们在StartState结构中做的那样,来捕获玩家的输入并将其存储在变量n中。然后,我们的计数器中的重试次数减少了一个,所以我们必须在上下文的Retries字段中减去一个。

然后,有两个检查:一个检查用户是否输入了正确的数字,如果是,则上下文字段Won设置为true,下一个状态设置为FinishState结构(尚未声明)。

第二个检查是控制重试次数是否已经达到零,如果是,则不会让玩家再次要求输入数字,并直接将玩家发送到FinishState结构。毕竟,我们必须再次告诉游戏引擎游戏必须继续,通过在executeState方法中返回true

最后,我们定义了FinishState结构。它控制游戏的退出状态,检查上下文对象中Won字段的内容:

type FinishState struct{} 
func(f *FinishState) executeState(c *GameContext) bool { 
  if c.Won { 
    println("Congrats, you won") 
  }  
  else { 
    println("You lose") 
  } 
  return false 
} 

TheFinishState结构也通过在其结构中具有executeState方法来实现GameState状态。这里的想法非常简单——如果玩家赢了(这个字段之前在AskState结构中设置),FinishState结构将打印消息恭喜,你赢了。如果玩家没有赢(记住布尔变量的零值是false),FinishState将打印消息你输了

在这种情况下,游戏可以被认为已经结束,所以我们返回false来表示游戏不应该继续。

我们只需要main方法来玩我们的游戏。

func main() { 
  start := StartState{} 
  game := GameContext{ 
    Next:&start, 
  } 
  for game.Next.executeState(&game) {} 
} 

嗯,是的,它不能再简单了。游戏必须从start方法开始,尽管在未来游戏需要更多初始化的情况下,它可以更抽象地放在外面,但在我们的情况下没问题。然后,我们创建一个上下文,将Next状态设置为指向start变量的指针。因此,在游戏中将执行的第一个状态将是StartState状态。

main函数的最后一行有很多东西。我们创建了一个循环,里面没有任何语句。和任何循环一样,在条件不满足后它会继续循环。我们使用的条件是GameStates结构的返回值,在游戏未结束时为true

所以,思路很简单:我们在上下文中执行状态,将上下文的指针传递给它。每个状态都返回true,直到游戏结束,FinishState结构将返回false。所以我们的循环将继续循环,等待FinishState结构发送的false条件来结束应用程序。

让我们再玩一次:

go run state.go
Introduce a number a number of retries to set the difficulty:
5
Introduce a number between 0 and 10, you have 5 tries left
8
Introduce a number between 0 and 10, you have 4 tries left
2
Introduce a number between 0 and 10, you have 3 tries left
1
Introduce a number between 0 and 10, you have 2 tries left
3
Introduce a number between 0 and 10, you have 1 tries left
4
You lose

我们输了!我们把重试次数设为 5。然后我们继续插入数字,试图猜出秘密数字。我们输入了 8、2、1、3 和 4,但都不对。我甚至不知道正确的数字是多少;让我们来修复这个!

去到FinishState结构的定义并且改变那一行写着You lose的地方,用以下内容替换它:

fmt.Printf("You lose. The correct number was: %d\n", c.SecretNumber) 

现在它会显示正确的数字。让我们再玩一次:

go run state.go
Introduce a number a number of retries to set the difficulty:
3
Introduce a number between 0 and 10, you have 3 tries left
6
Introduce a number between 0 and 10, you have 2 tries left
2
Introduce a number between 0 and 10, you have 1 tries left
1
You lose. The correct number was: 9

这次我们把难度加大了,只设置了三次尝试……但我们又输了。我输入了 6、2 和 1,但正确的数字是 9。最后一次尝试:

go run state.go
Introduce a number a number of retries to set the difficulty:
5
Introduce a number between 0 and 10, you have 5 tries left
3
Introduce a number between 0 and 10, you have 4 tries left
4
Introduce a number between 0 and 10, you have 3 tries left
5
Introduce a number between 0 and 10, you have 2 tries left
6
Congrats, you won

太好了!这次我们降低了难度,允许最多五次尝试,我们赢了!我们甚至还有一次尝试剩下,但我们在第四次尝试后猜中了数字,输入了 3、4、5。正确的数字是 6,这是我的第四次尝试。

一个赢的状态和一个输的状态

你是否意识到我们可以有一个赢和一个输的状态,而不是直接在FinishState结构中打印消息?这样我们可以,例如,在赢的部分检查一些假设的得分板,看看我们是否创造了记录。让我们重构我们的游戏。首先我们需要一个WinState和一个LoseState结构:

type WinState struct{} 

func (w *WinState) executeState(c *GameContext) bool { 
  println("Congrats, you won") 

  return false 
} 

type LoseState struct{} 

func (l *LoseState) executeState(c *GameContext) bool { 
  fmt.Printf("You lose. The correct number was: %d\n", c.SecretNumber) 
  return false 
} 

这两个新状态没有什么新东西。它们包含了之前在FinishState状态中的相同消息,顺便说一句,必须修改为使用这些新状态:

func (f *FinishState) executeState(c *GameContext) bool { 
  if c.Won { 
    c.Next = &WinState{} 
  } else { 
    c.Next = &LoseState{} 
  } 
  return true 
} 

现在,结束状态不再打印任何东西,而是将其委托给链中的下一个状态——如果用户赢了,则是WinState结构,如果没有,则是LoseState结构。记住,游戏现在不会在FinishState结构上结束,我们必须返回true而不是false来通知引擎必须继续执行链中的状态。

使用状态模式构建的游戏

你现在可能会想,你可以用新状态无限扩展这个游戏,这是真的。状态模式的威力不仅在于创建复杂的有限状态机的能力,还在于通过添加新状态和修改一些旧状态指向新状态而不影响有限状态机的其余部分来改进它的灵活性。

中介者设计模式

让我们继续使用中介者模式。顾名思义,它是一种将处于两种类型之间以交换信息的模式。但是,为什么我们会想要这种行为呢?让我们仔细看一下。

描述

任何设计模式的关键目标之一是避免对象之间的紧密耦合。这可以通过多种方式实现,正如我们已经看到的。

但是当应用程序增长很多时,特别有效的一种方法是中介者模式。中介者模式是一个很好的例子,它是每个程序员通常在不太考虑的情况下使用的模式。

中介者模式将充当两个对象之间交换通信的类型。这样,通信的对象不需要彼此了解,可以更自由地进行更改。维护对象提供什么信息的模式是中介者。

目标

如前所述,中介者模式的主要目标是松散耦合和封装。目标是:

  • 为了提供两个必须相互通信的对象之间的松散耦合

  • 通过将这些需求传递给中介者模式,减少特定类型的依赖量

一个计算器

对于中介者模式,我们将开发一个非常简单的算术计算器。你可能认为计算器如此简单,不需要任何模式。但我们会看到这并不完全正确。

我们的计算器只会执行两个非常简单的操作:求和和减法。

验收标准

谈论验收标准来定义一个计算器听起来相当有趣,但无论如何我们都要做:

  1. 定义一个名为Sum的操作,它接受一个数字并将其加到另一个数字。

  2. 定义一个名为Subtract的操作,它接受一个数字并将其减去另一个数字。

嗯,我不知道你怎么想,但在这个复杂的标准之后,我真的需要休息。那么为什么我们要这么定义呢?耐心点,你很快就会得到答案。

实现

我们必须直接跳到实现,因为我们无法测试求和是否正确(嗯,我们可以,但那样就是在测试 Go 是否写得正确!)。我们可以测试是否符合验收标准,但对于我们的例子来说有点过度了。

那么让我们从实现必要的类型开始:

package main 

type One struct{} 
type Two struct{} 
type Three struct{} 
type Four struct{} 
type Five struct{} 
type Six struct{} 
type Seven struct{} 
type Eight struct{} 
type Nine struct{} 
type Zero struct{} 

嗯...这看起来相当尴尬。我们在 Go 中已经有数字类型来执行这些操作,我们不需要为每个数字都定义一个类型!

但让我们再继续一下这种疯狂的方法。让我们实现One结构:

type One struct{} 

func (o *One) OnePlus(n interface{}) interface{} { 
  switch n.(type) { 
  case One: 
    return &Two{} 
  case Two: 
    return &Three{} 
  case Three: 
    return &Four{} 
  case Four: 
    return &Five{} 
  case Five: 
    return &Six{} 
  case Six: 
    return &Seven{} 
  case Seven: 
    return &Eight{} 
  case Eight: 
    return &Nine{} 
  case Nine: 
    return [2]interface{}{&One{}, &Zero{}} 
  default: 
    return fmt.Errorf("Number not found") 
  } 
} 

好吧,我就说到这里。这个实现有什么问题?这完全疯狂!为了进行求和而使每种可能的数字操作都变得太过了!特别是当我们有多于一位数时。

嗯,信不信由你,这就是今天许多软件通常设计的方式。一个对象使用两个或三个对象的小应用程序会增长,最终使用数十个对象。仅仅因为它隐藏在某些疯狂的地方,所以要简单地添加或删除应用程序中的类型变得非常困难。

那么在这个计算器中我们能做什么?使用一个中介者类型来解放所有情况:

func Sum(a, b interface{}) interface{}{ 
  switch a := a.(type) { 
    case One: 
    switch b.(type) { 
      case One: 
        return &Two{} 
      case Two: 
        return &Three{} 
      default: 
        return fmt.Errorf("Number not found") 
    } 
    case Two: 
    switch b.(type) { 
      case One: 
        return &Three{} 
      case Two: 
        return &Four{} 
      default: 
      return fmt.Errorf("Number not found") 

    } 
    case int: 
    switch b := b.(type) { 
      case One: 
        return &Three{} 
      case Two: 
        return &Four{} 
      case int: 
        return a + b 
      default: 
      return fmt.Errorf("Number not found") 

    } 
    default: 
    return fmt.Errorf("Number not found") 
  } 
} 

我们只开发了一对数字来简化。Sum函数充当两个数字之间的中介者。首先它检查名为a的第一个数字的类型。然后,对于第一个数字的每种类型,它检查名为b的第二个数字的类型,并返回结果类型。

虽然解决方案现在看起来仍然非常疯狂,但唯一知道计算器中所有可能数字的是Sum函数。但仔细看,你会发现我们为int类型添加了一个类型情况。我们有OneTwoint情况。在int情况下,我们还有另一个int情况用于b数字。我们在这里做什么?如果两种类型都是int情况,我们可以返回它们的和。

你认为这样会有效吗?让我们写一个简单的main函数:

func main(){ 
  fmt.Printf("%#v\n", Sum(One{}, Two{})) 
  fmt.Printf("%d\n", Sum(1,2)) 
} 

我们打印类型One和类型Two的总和。通过使用"%#v"格式,我们要求打印有关类型的信息。函数中的第二行使用int类型,并且我们还打印结果。这在控制台上产生以下输出:

$go run mediator.go
&main.Three{}
7

不是很令人印象深刻,对吧?但是让我们思考一下。通过使用中介者模式,我们已经能够重构最初的计算器,在那里我们必须为每种类型定义每个操作,转换为中介者模式的Sum函数。

好处在于,由于中介者模式的存在,我们已经能够开始将整数作为计算器的值使用。我们刚刚通过添加两个整数定义了最简单的示例,但我们也可以使用整数和type来做同样的事情:

  case One: 
    switch b := b.(type) { 
    case One: 
      return &Two{} 
    case Two: 
      return &Three{} 
    case int: 
      return b+1 
    default: 
      return fmt.Errorf("Number not found") 
    } 

通过这个小修改,我们现在可以使用类型Oneint作为数字b。如果我们继续在中介者模式上工作,我们可以在类型之间实现很大的灵活性,而无需实现它们之间的每种可能操作,从而产生紧密耦合。

我们将在主函数中添加一个新的Sum方法,以查看其运行情况:

func main(){ 
  fmt.Printf("%#v\n", Sum(One{}, Two{})) 
  fmt.Printf("%d\n", Sum(1,2)) 
 fmt.Printf("%d\n", Sum(One{},2)) 
} 
$go run mediator.go&main.Three{}33

很好。中介者模式负责了解可能的类型并返回最适合我们情况的类型,即整数。现在我们可以继续扩展这个Sum函数,直到完全摆脱使用我们定义的数值类型。

使用中介者解耦两种类型

我们进行了一个颠覆性的示例,试图超越传统思维,深入思考中介者模式。应用程序中实体之间的紧密耦合可能在未来变得非常复杂,并且如果需要进行更复杂的重构,则可能更加困难。

只需记住,中介者模式的作用是作为两种不相互了解的类型之间的管理类型,以便您可以获取其中一种类型而不影响另一种类型,并以更轻松和便捷的方式替换类型。

观察者设计模式

我们将用我最喜欢的四人帮设计模式之一结束,即观察者模式,也称为发布/订阅或发布/监听器。通过状态模式,我们定义了我们的第一个事件驱动架构,但是通过观察者模式,我们将真正达到一个新的抽象层次。

描述

观察者模式背后的思想很简单--订阅某个事件,该事件将触发许多订阅类型上的某些行为。为什么这么有趣?因为我们将一个事件与其可能的处理程序解耦。

例如,想象一个登录按钮。我们可以编写代码,当用户点击按钮时,按钮颜色会改变,执行一个操作,并在后台执行表单检查。但是通过观察者模式,更改颜色的类型将订阅按钮点击事件。检查表单的类型和执行操作的类型也将订阅此事件。

目标

观察者模式特别有用,可以在一个事件上触发多个操作。当您事先不知道有多少操作会在事件之后执行,或者有可能操作的数量将来会增加时,它也特别有用。总之,执行以下操作:

  • 提供一个事件驱动的架构,其中一个事件可以触发一个或多个操作

  • 将执行的操作与触发它们的事件解耦

  • 提供触发相同操作的多个事件

通知者

我们将开发最简单的应用程序,以充分理解观察者模式的根源。我们将创建一个Publisher结构,它是触发事件的结构,因此必须接受新的观察者,并在必要时删除它们。当触发Publisher结构时,它必须通知所有观察者有关关联数据的新事件。

验收标准

需求必须告诉我们有一些类型会触发一个或多个操作的某种方法:

  1. 我们必须有一个带有NotifyObservers方法的发布者,该方法接受消息作为参数并触发订阅的每个观察者上的Notify方法。

  2. 我们必须有一个方法向发布者添加新的订阅者。

  3. 我们必须有一个方法从发布者中删除新的订阅者。

单元测试

也许你已经意识到,我们的要求几乎完全定义了Publisher类型。这是因为观察者执行的操作对观察者模式来说是无关紧要的。它应该只执行一个动作,即Notify方法,在这种情况下,一个或多个类型将实现。因此,让我们为此模式定义唯一的接口:

type Observer interface { 
  Notify(string) 
} 

Observer接口有一个Notify方法,它接受一个string类型,其中包含要传播的消息。它不需要返回任何东西,但是当调用Publisher结构的publish方法时,我们可以返回一个错误,以便检查是否已经到达了所有观察者。

为了测试所有的验收标准,我们只需要一个名为Publisher的结构,其中包含三种方法:

type Publisher struct { 
  ObserversList []Observer 
} 

func (s *Publisher) AddObserver(o Observer) {} 

func (s *Publisher) RemoveObserver(o Observer) {} 

func (s *Publisher) NotifyObservers(m string) {} 

Publisher结构将订阅的观察者列表存储在名为ObserversList的切片字段中。然后它具有接受标准的三种方法--AddObserver方法用于向发布者订阅新的观察者,RemoveObserver方法用于取消订阅观察者,以及NotifyObservers方法,其中包含一个作为我们想要在所有观察者之间传播的消息的字符串。

有了这三种方法,我们必须设置一个根测试来配置Publisher和三个子测试来测试每种方法。我们还需要定义一个实现Observer接口的测试类型结构。这个结构将被称为TestObserver

type TestObserver struct { 
  ID      int 
  Message string 
} 
func (p *TestObserver) Notify(m string) { 
  fmt.Printf("Observer %d: message '%s' received \n", p.ID, m) 
  p.Message = m 
} 

TestObserver结构通过在其结构中定义Notify(string)方法来实现观察者模式。在这种情况下,它打印接收到的消息以及自己的观察者 ID。然后,它将消息存储在其Message字段中。这使我们可以稍后检查Message字段的内容是否符合预期。请记住,也可以通过传递testing.T指针和预期消息并在TestObserver结构内部进行检查来完成。

现在我们可以设置Publisher结构来执行这三个测试。我们将创建TestObserver结构的三个实例:

func TestSubject(t *testing.T) { 
  testObserver1 := &TestObserver{1, ""} 
  testObserver2 := &TestObserver{2, ""} 
  testObserver3 := &TestObserver{3, ""} 
  publisher := Publisher{} 

我们为每个观察者分配了不同的 ID,以便稍后可以看到它们每个人都打印了预期的消息。然后,我们通过在Publisher结构上调用AddObserver方法来添加观察者。

让我们编写一个AddObserver测试,它必须将新的观察者添加到Publisher结构的ObserversList字段中:

  t.Run("AddObserver", func(t *testing.T) { 
    publisher.AddObserver(testObserver1) 
    publisher.AddObserver(testObserver2) 
    publisher.AddObserver(testObserver3) 

    if len(publisher.ObserversList) != 3 { 
      t.Fail() 
    } 
  }) 

我们已经向Publisher结构添加了三个观察者,因此切片的长度必须为 3。如果不是 3,测试将失败。

RemoveObserver测试将获取 ID 为 2 的观察者并将其从列表中删除:

  t.Run("RemoveObserver", func(t *testing.T) { 
    publisher.RemoveObserver(testObserver2) 

    if len(publisher.ObserversList) != 2 { 
      t.Errorf("The size of the observer list is not the " + 
        "expected. 3 != %d\n", len(publisher.ObserversList)) 
    } 

    for _, observer := range publisher.ObserversList { 
      testObserver, ok := observer.(TestObserver) 
      if !ok {  
        t.Fail() 
      } 

      if testObserver.ID == 2 { 
        t.Fail() 
      } 
    } 
  }) 

删除第二个观察者后,Publisher结构的长度现在必须为 2。我们还检查剩下的观察者中没有一个的ID为 2,因为它必须被移除。

测试的最后一个方法是Notify方法。使用Notify方法时,所有TestObserver结构的实例都必须将它们的Message字段从空更改为传递的消息(在本例中为Hello World!)。首先,我们将检查在调用NotifyObservers测试之前所有的Message字段是否实际上都是空的:

t.Run("Notify", func(t *testing.T) { 
    for _, observer := range publisher.ObserversList { 
      printObserver, ok := observer.(*TestObserver) 
      if !ok { 
        t.Fail() 
        break 
      } 

      if printObserver.Message != "" { 
        t.Errorf("The observer's Message field weren't " + "  empty: %s\n", printObserver.Message) 
      } 
    } 

使用for语句,我们正在迭代publisher实例中的ObserversList字段。我们需要将指针从观察者转换为TestObserver结构的指针,并检查转换是否已正确完成。然后,我们检查Message字段实际上是否为空。

下一步是创建要发送的消息--在本例中,它将是"Hello World!",然后将此消息传递给NotifyObservers方法,以通知列表上的每个观察者(目前只有观察者 1 和 3):

    ... 
    message := "Hello World!" 
    publisher.NotifyObservers(message) 

    for _, observer := range publisher.ObserversList { 
      printObserver, ok := observer.(*TestObserver) 
      if !ok { 
        t.Fail() 
        break 
      } 

      if printObserver.Message != message { 
        t.Errorf("Expected message on observer %d was " + 
          "not expected: '%s' != '%s'\n", printObserver.ID, 
          printObserver.Message, message) 
      } 
    } 
  }) 
} 

调用NotifyObservers方法后,ObserversList字段中的每个TestObserver测试必须在其Message字段中存储"Hello World!"消息。同样,我们使用for循环来遍历ObserversList字段中的每个观察者,并将每个类型转换为TestObserver测试(请记住,TestObserver结构没有任何字段,因为它是一个接口)。我们可以通过向Observer实例添加一个新的Message()方法并在TestObserver结构中实现它来避免类型转换,以返回Message字段的内容。这两种方法都是有效的。一旦我们将类型转换为TestObserver方法调用printObserver变量作为局部变量,我们检查ObserversList结构中的每个实例是否在其Message字段中存储了字符串"Hello World!"

是时候运行测试了,必须全部失败以检查它们在后续实现中的有效性:

go test -v  
=== RUN   TestSubject 
=== RUN   TestSubject/AddObserver 
=== RUN   TestSubject/RemoveObserver 
=== RUN   TestSubject/Notify 
--- FAIL: TestSubject (0.00s) 
    --- FAIL: TestSubject/AddObserver (0.00s) 
    --- FAIL: TestSubject/RemoveObserver (0.00s) 
        observer_test.go:40: The size of the observer list is not the expected. 3 != 0 
    --- PASS: TestSubject/Notify (0.00s) 
FAIL 
exit status 1 
FAIL

有些地方不如预期。如果我们还没有实现函数,Notify方法是如何通过测试的?再看一下Notify方法的测试。测试遍历ObserversList结构,并且每个Fail调用都在此for循环内。如果列表为空,它将不会进行迭代,因此不会执行任何Fail调用。

让我们通过在Notify测试的开头添加一个小的非空列表检查来解决这个问题:

  if len(publisher.ObserversList) == 0 { 
      t.Errorf("The list is empty. Nothing to test\n") 
  } 

我们将重新运行测试,看看TestSubject/Notify方法是否已经失败:

go test -v
=== RUN   TestSubject
=== RUN   TestSubject/AddObserver
=== RUN   TestSubject/RemoveObserver
=== RUN   TestSubject/Notify
--- FAIL: TestSubject (0.00s)
 --- FAIL: TestSubject/AddObserver (0.00s)
 --- FAIL: TestSubject/RemoveObserver (0.00s)
 observer_test.go:40: The size of the observer list is not the expected. 3 != 0
 --- FAIL: TestSubject/Notify (0.00s)
 observer_test.go:58: The list is empty. Nothing to test
FAIL
exit status 1
FAIL

很好,它们全部失败了,现在我们对测试有了一些保证。我们可以继续实现。

实施

我们的实现只是定义AddObserverRemoveObserverNotifyObservers方法:

func (s *Publisher) AddObserver(o Observer) { 
  s.ObserversList = append(s.ObserversList, o) 
} 

AddObserver方法通过将指针附加到当前指针列表来将Observer实例添加到ObserversList结构中。这很容易。AddObserver测试现在必须通过(但其他测试不通过,否则我们可能做错了什么):

go test -v
=== RUN   TestSubject
=== RUN   TestSubject/AddObserver
=== RUN   TestSubject/RemoveObserver
=== RUN   TestSubject/Notify
--- FAIL: TestSubject (0.00s)
 --- PASS: TestSubject/AddObserver (0.00s)
 --- FAIL: TestSubject/RemoveObserver (0.00s)
 observer_test.go:40: The size of the observer list is not the expected. 3 != 3
 --- FAIL: TestSubject/Notify (0.00s)
 observer_test.go:87: Expected message on observer 1 was not expected: 'default' != 'Hello World!'
 observer_test.go:87: Expected message on observer 2 was not expected: 'default' != 'Hello World!'
 observer_test.go:87: Expected message on observer 3 was not expected: 'default' != 'Hello World!'
FAIL
exit status 1
FAIL

很好。只有AddObserver方法通过了测试,所以我们现在可以继续进行RemoveObserver方法:

func (s *Publisher) RemoveObserver(o Observer) { 
  var indexToRemove int 

  for i, observer := range s.ObserversList { 
    if observer == o { 
      indexToRemove = i 
      break 
    } 
  } 

  s.ObserversList = append(s.ObserversList[:indexToRemove], s.ObserversList[indexToRemove+1:]...) 
} 

RemoveObserver方法将遍历ObserversList结构中的每个元素,将Observer对象的o变量与列表中存储的对象进行比较。如果找到匹配项,它将保存在本地变量indexToRemove中,并停止迭代。在 Go 中删除切片的索引有点棘手:

  1. 首先,我们需要使用切片索引来返回一个新的切片,其中包含从切片开头到我们想要移除的索引(不包括)的每个对象。

  2. 然后,我们从要删除的索引(不包括)到切片中的最后一个对象获取另一个切片

  3. 最后,我们将前两个新切片合并成一个新的切片(使用append函数)

例如,在一个从 1 到 10 的列表中,我们想要移除数字 5,我们必须创建一个新的切片,将从 1 到 4 的切片和从 6 到 10 的切片连接起来。

这个索引移除是使用append函数完成的,因为我们实际上是将两个列表连接在一起。仔细看一下append函数第二个参数末尾的三个点。append函数将一个元素(第二个参数)添加到一个切片(第一个参数),但我们想要添加整个列表。这可以通过使用三个点来实现,它们的作用类似于继续添加元素,直到完成第二个数组

好的,现在让我们运行这个测试:

go test -v           
=== RUN   TestSubject 
=== RUN   TestSubject/AddObserver 
=== RUN   TestSubject/RemoveObserver 
=== RUN   TestSubject/Notify 
--- FAIL: TestSubject (0.00s) 
    --- PASS: TestSubject/AddObserver (0.00s) 
    --- PASS: TestSubject/RemoveObserver (0.00s) 
    --- FAIL: TestSubject/Notify (0.00s) 
        observer_test.go:87: Expected message on observer 1 was not expected: 'default' != 'Hello World!' 
        observer_test.go:87: Expected message on observer 3 was not expected: 'default' != 'Hello World!' 
FAIL 
exit status 1 
FAIL 

我们继续在正确的路径上。RemoveObserver测试已经修复,而没有修复其他任何东西。现在我们必须通过定义NotifyObservers方法来完成我们的实现:

func (s *Publisher) NotifyObservers(m string) { 
  fmt.Printf("Publisher received message '%s' to notify observers\n", m) 
  for _, observer := range s.ObserversList { 
    observer.Notify(m) 
  } 
} 

NotifyObservers方法非常简单,因为它在控制台上打印一条消息,宣布特定消息将传递给“观察者”。之后,我们使用 for 循环遍历ObserversList结构,并通过传递参数m执行每个Notify(string)方法。执行完毕后,所有观察者必须在其Message字段中存储消息Hello World!。让我们通过运行测试来看看这是否成立:

go test -v 
=== RUN   TestSubject 
=== RUN   TestSubject/AddObserver 
=== RUN   TestSubject/RemoveObserver 
=== RUN   TestSubject/Notify 
Publisher received message 'Hello World!' to notify observers 
Observer 1: message 'Hello World!' received  
Observer 3: message 'Hello World!' received  
--- PASS: TestSubject (0.00s) 
    --- PASS: TestSubject/AddObserver (0.00s) 
    --- PASS: TestSubject/RemoveObserver (0.00s) 
    --- PASS: TestSubject/Notify (0.00s) 
PASS 
ok

太棒了!我们还可以在控制台上看到“发布者”和“观察者”类型的输出。 “发布者”结构打印以下消息:

hey! I have received the message  'Hello World!' and I'm going to pass the same message to the observers 

之后,所有观察者按如下方式打印各自的消息:

hey, I'm observer 1 and I have received the message 'Hello World!'

第三个观察者也是如此。

总结

我们已经利用状态模式和观察者模式解锁了事件驱动架构的力量。现在,您可以在应用程序中真正执行异步算法和操作,以响应系统中的事件。

观察者模式通常用于 UI。Android 编程中充满了观察者模式,以便 Android SDK 可以将操作委托给创建应用程序的程序员。

第八章:Gos 并发简介

我们刚刚完成了在面向对象编程语言中广泛使用的四人帮设计模式。在过去的几十年里,它们已经被广泛使用(甚至在它们被明确定义在一本书中之前)。

在本章中,我们将看到 Go 语言中的并发性。我们将学习,通过多个核心和多个进程,应用程序可以帮助我们实现更好的性能和无限的可能性。我们将看看如何以并发安全的方式使用一些已知的模式。

一点历史和理论

当我们谈论 Go 的并发性时,不可能不谈论历史。在过去的几十年里,我们看到 CPU 速度的提高,直到我们达到了当前硬件材料、设计和架构所施加的硬件限制。当我们达到这一点时,我们开始尝试第一台多核计算机,第一台双 CPU 主板,然后是心脏中有多个核心的单 CPU。

不幸的是,我们正在使用的语言仍然是在我们拥有单核 CPU 时创建的语言,比如 Java 或 C++。虽然它们是很棒的系统语言,但它们在设计上缺乏适当的并发支持。你可以通过使用第三方工具或开发自己的工具在项目中的这两种语言中开发并发应用(这并不是一件很容易的任务)。

Go 的并发是在考虑到这些警告的情况下设计的。创作者们希望有垃圾回收和程序化语言,对新手来说很熟悉,但同时又可以轻松编写并发应用,而不影响语言的核心。

我们在早期章节中已经经历过这一点。我们开发了 20 多种设计模式,却没有提到并发。这清楚地表明,Go 语言的并发特性完全与核心语言分离,同时又是其一部分,这是抽象和封装的完美例子。

在计算机科学中有许多并发模型,最著名的是出现在诸如ErlangScala等语言中的 actor 模型。另一方面,Go 使用通信顺序进程CSP),它对并发有不同的方法。

并发与并行

许多人误解了两者之间的区别,甚至认为它们是相同的。Rob Pike,Go 的创始人之一,有一次流行的演讲,并发不等于并行,我非常同意。作为这次演讲的快速总结,我们可以得出以下结论:

  • 并发是同时处理许多事情的能力

  • 并行性是同时做很多事情的能力

通过设计正确的并发工作结构,并发能够实现并行。

例如,我们可以想象一辆自行车的机制。当我们踩踏时,通常是向下踩踏板产生力量(这种推动会使我们的另一条腿上升到相反的踏板)。我们不能同时用两条腿推动,因为曲柄不允许我们这样做。但这种设计允许建造一辆平行自行车,通常称为串联自行车。串联自行车是两个人可以同时骑的自行车;他们都踩踏板并施加力量给自行车。

在自行车的例子中,并发是设计一辆自行车,用两条腿(Goroutines)可以自己产生动力来移动自行车。这种设计是并发和正确的。如果我们使用串联自行车和两个人(两个核心),解决方案是并发的、正确的和并行的。但关键是,通过并发设计,我们不必担心并行性;如果我们的并发设计是正确的,我们可以将其视为额外的功能。事实上,我们可以只用一个人使用串联自行车,但自行车的并发设计仍然是正确的。

并发与并行

在并发方面,左侧有一个由同一 CPU 核心顺序执行的设计和结构。一旦有了这个设计和结构,通过在不同的线程上重复这个结构,就可以实现并行。

这就是 Go 通过简单地不太担心并行执行而更多地关注并发设计和结构来简化关于并发和并行程序的推理。将一个大任务分解成可以并发运行的小任务通常会在单核计算机上提供更好的性能,但如果这种设计也可以并行运行,我们可能会实现更高的吞吐量(或者不会,这取决于设计)。

实际上,我们可以通过将环境变量GOMAXPROCS设置为所需的核心数来设置 Go 应用程序中使用的核心数。这不仅在使用调度程序(如Apache Mesos)时很有用,而且还可以更好地控制 Go 应用程序的工作和性能。

因此,要总结一下,重要的是要记住,并发是关于结构,而并行是关于执行。我们必须考虑以更好的方式使我们的程序并发,通过将它们分解成更小的工作片段,如果可能且允许的话,Go 的调度器将尝试使它们并行化。

CSP 与基于 actor 的并发

最常见且可能直观的思考并发的方式接近 actor 模型的工作方式。

CSP 与基于 actor 的并发

在 actor 模型中,如果Actor 1想要与Actor 2通信,那么Actor 1必须首先了解Actor 2;例如,它必须有它的进程 ID,可能是从创建步骤中获得,并将消息放在其收件箱队列中。放置消息后,Actor 1可以继续其任务,而不会被阻塞,即使Actor 2无法立即处理消息。

另一方面,CSP 引入了一个新的实体-通道。通道是进程之间进行通信的方式,因为它们是完全匿名的(不像 actor,我们需要知道它们的进程 ID)。在 CSP 的情况下,我们没有进程 ID 用于通信。相反,我们必须创建一个通道给进程,以允许传入和传出的通信。在这种情况下,我们知道接收者是它用来接收数据的通道:

CSP 与基于 actor 的并发

在这个图表中,我们可以看到这些进程是匿名的,但我们有一个 ID 为 1 的通道,即通道 1,将它们连接在一起。这种抽象并没有告诉我们每一侧通道上有多少个进程;它只是简单地连接它们,并允许它们通过通道进行通信。

关键在于通道隔离了两个极端,以便进程 A 可以通过一个通道发送数据,这些数据将由潜在的一个或多个对 A 透明的进程处理。它也在相反的情况下起作用;进程 B 可以一次从许多通道接收数据。

Goroutines

在 Go 中,我们通过使用 Goroutines 来实现并发。它们就像在计算机上同时运行应用程序的进程;实际上,Go 的主循环也可以被认为是一个 Goroutine。Goroutines 在我们使用 actor 的地方使用。它们执行一些逻辑然后消失(或者如果有必要,保持循环)。

但是 Goroutines 不是线程。我们可以启动成千上万甚至百万个并发的 Goroutines。它们非常廉价,堆栈增长很小。我们将使用 Goroutines 来执行我们想要并发工作的代码。例如,通过三个 Goroutines 并行设计三个服务的调用来组成一个响应,可能并行进行服务调用,并且第四个 Goroutine 接收它们并组成响应。这里的重点是什么?如果我们有一台有四个核心的计算机,我们可能可以并行运行这个服务调用,但如果我们使用一台单核心的计算机,设计仍然是正确的,调用将在一个核心中并发执行。通过设计并发应用程序,我们不需要担心并行执行。

回到自行车的比喻,我们用两条腿踩踏自行车踏板。这是两个 Goroutines 同时踩踏踏板。当我们使用双人自行车时,我们总共有四个 Goroutines,可能在并行工作。但我们也有两只手来控制前后刹车。这是我们双人自行车上的八个 Goroutines。实际上,我们刹车时不踩踏板,踩踏板时不刹车;这是一个正确的并发设计。我们的神经系统传输关于何时停止踩踏板和何时开始刹车的信息。在 Go 中,我们的神经系统由通道组成;在玩弄 Goroutines 之后,我们将会看到它们。

我们的第一个 Goroutine

现在足够的解释了。让我们动手吧。对于我们的第一个 Goroutine,我们将在一个 Goroutine 中打印消息Hello World!。让我们从我们到目前为止一直在做的事情开始:

package main 

func main() { 
  helloWorld() 
} 

func helloWorld(){ 
  println("Hello World!") 
} 

运行这段小代码片段将在控制台中简单地输出Hello World!

$ go run main.go
Hello World!

一点也不令人印象深刻。要在新的 Goroutine 中运行它,我们只需要在对函数的调用前加上关键字go

package main 

func main() { 
  go helloWorld() 
} 

func helloWorld(){ 
  println("Hello World!") 
} 

通过这个简单的词,我们告诉 Go 启动一个新的 Goroutine 来运行helloWorld函数的内容。

所以,让我们运行它:

$ go run main.go 
$

什么?什么都没打印!为什么?当你开始处理并发应用程序时,事情变得复杂起来。问题在于main函数在helloWorld函数被执行之前就结束了。让我们一步一步地分析一下。main函数开始并安排一个新的 Goroutine 来执行helloWorld函数,但当函数结束时,函数并没有被执行——它仍然在调度过程中。

所以,我们main函数的问题在于main函数必须等待 Goroutine 被执行后才能结束。所以让我们停顿一秒钟,给 Goroutine 一些空间:

package main 
import "time" 

func main() { 
  go helloWorld() 

  time.Sleep(time.Second) 
} 

func helloWorld(){ 
  println("Hello World!") 
} 

time.Sleep函数有效地使主 Goroutine 在继续(并退出)之前休眠一秒钟。如果我们现在运行这个程序,我们必须得到这个消息:

$ go run main.go
Hello World!

我想你现在一定已经注意到了程序在结束之前会有一个小的冻结时间。这是休眠的函数。如果你正在做很多任务,你可能想把等待时间延长到你想要的任何时间。只要记住,在任何应用程序中,main函数不能在其他 Goroutines 之前结束。

匿名函数作为新的 Goroutines 启动

我们已经定义了helloWorld函数,以便可以使用不同的 Goroutine 启动它。这并不是严格必要的,因为你可以直接在函数的作用域中启动代码片段:

package main 
import "time" 

func main() { 
  go func() { 
    println("Hello World") 
  }() 
  time.Sleep(time.Second) 
} 

这也是有效的。我们使用了一个匿名函数,并使用go关键字在一个新的 Goroutine 中启动它。仔细看函数的闭括号——它们后面跟着开括号和闭括号,表示函数的执行。

我们也可以向匿名函数传递数据:

package main 
import "time" 

func main() { 
  go func(msg string) { 
    println(msg) 
  }("Hello World") 
  time.Sleep(time.Second) 
} 

这也是有效的。我们定义了一个接收字符串的匿名函数,然后打印接收到的字符串。当我们在不同的 Goroutine 中调用函数时,我们传递了要打印的消息。在这个意义上,以下示例也是有效的:

package main 
import "time" 

func main() { 
  messagePrinter := func(msg string) { 
    println(msg) 
  } 

  go messagePrinter("Hello World") 
  go messagePrinter("Hello goroutine") 
  time.Sleep(time.Second) 
} 

在这种情况下,我们在main函数的范围内定义了一个函数,并将其存储在名为messagePrinter的变量中。现在我们可以通过使用messagePrinter(string)签名并发打印任意数量的消息:

$ go run main.go
Hello World
Hello goroutine

我们刚刚触及了 Go 中并发编程的表面,但我们已经可以看到它可以非常强大。但我们绝对必须解决这个休眠期的问题。WaitGroups 可以帮助我们解决这个问题。

WaitGroups

WaitGroup 位于同步包(sync包)中,帮助我们同步许多并发的 Goroutines。它非常容易使用-每当我们必须等待一个 Goroutine 完成时,我们向组中添加1,一旦它们全部添加,我们要求组等待。当 Goroutine 完成时,它会说Done,WaitGroup 将从组中取出一个:

package main 

import ( 
  "sync" 
  "fmt" 
) 

func main() { 
  var wait sync.WaitGroup 
  wait.Add(1) 

  go func(){ 
    fmt.Println("Hello World!") 
    wait.Done() 
  }() 
  wait.Wait() 
} 

这是一个最简单的 WaitGroup 示例。首先,我们创建了一个变量来保存它,称为wait变量。接下来,在启动新的 Goroutine 之前,我们告诉 WaitGroup“嘿,你必须等待一件事情完成”,使用wait.Add(1)方法。现在我们可以启动 WaitGroup 必须等待的1,在这种情况下是打印Hello World并在 Goroutine 结束时说Done(使用wait.Done()方法)的先前 Goroutine。最后,我们指示 WaitGroup 等待。我们必须记住,函数wait.Wait()可能在 Goroutine 之前执行。

让我们再次运行代码:

$ go run main.go 
Hello World!

现在它只等待必要的时间,而不是在退出应用程序之前多等待一毫秒。请记住,当我们使用Add(value)方法时,我们向 WaitGroup 添加实体,当我们使用Done()方法时,我们减去一个。

实际上,Add函数接受一个增量值,因此以下代码等同于上一个:

package main 

import ( 
  "sync" 
  "fmt" 
) 

func main() { 
  var wait sync.WaitGroup 
  wait.Add(1) 

  go func(){ 
    fmt.Println("Hello World!") 
    wait.Add(-1) 
  }() 
  wait.Wait() 
} 

在这种情况下,我们在启动 Goroutine 之前添加了1,并在其末尾添加了-1(减去 1)。如果我们预先知道要启动多少个 Goroutines,我们也可以只调用一次Add方法:

package main 
import ( 
  "fmt" 
  "sync" 
) 

func main() { 
  var wait sync.WaitGroup 

  goRoutines := 5 
  wait.Add(goRoutines) 

  for i := 0; i < goRoutines; i++ { 
    go func(goRoutineID int) { 
      fmt.Printf("ID:%d: Hello goroutines!\n", goRoutineID) 
      wait.Done() 
    }(i) 
  } 
  wait.Wait() 
} 

在这个例子中,我们将创建五个 Goroutines(如goroutines变量中所述)。我们事先知道这一点,所以我们只需将它们全部添加到 WaitGroup 中。然后,我们将使用for循环启动相同数量的goroutine变量。每当一个 Goroutine 完成时,它都会调用 WaitGroup 的Done()方法,该方法实际上在主循环的末尾等待。

同样,在这种情况下,代码在启动所有 Goroutines(如果有的话)之前到达main函数的末尾,并且 WaitGroup 使主流程的执行等待,直到所有Done消息被调用。让我们运行这个小程序:

$ go run main.go 

ID:4: Hello goroutines!
ID:0: Hello goroutines!
ID:1: Hello goroutines!
ID:2: Hello goroutines!
ID:3: Hello goroutines!

我们之前没有提到,但我们已将迭代索引作为参数GoroutineID传递给每个 Goroutine,以便用消息Hello goroutines!打印它。您可能还注意到 Goroutines 不按顺序执行。当然!我们正在处理一个不保证 Goroutines 执行顺序的调度程序。这是编写并发应用程序时要牢记的事情。实际上,如果我们再次执行它,我们不一定会得到相同的输出顺序:

$ go run main.go
ID:4: Hello goroutines!
ID:2: Hello goroutines!
ID:1: Hello goroutines!
ID:3: Hello goroutines!
ID:0: Hello goroutines!

回调

现在我们知道如何使用 WaitGroups,我们还可以介绍回调的概念。如果您曾经使用过像 JavaScript 这样广泛使用回调的语言,这一部分对您来说将是熟悉的。回调是将在不同函数的上下文中执行的匿名函数。

例如,我们想要编写一个将字符串转换为大写的函数,同时使其异步化。我们如何编写这个函数以便使用回调?有一个小技巧——我们可以有一个接受一个字符串并返回一个字符串的函数:

func toUpperSync(word string) string { 
  //Code will go here 
} 

因此,将这个函数的返回类型(一个字符串)作为匿名函数的第二个参数,如下所示:

func toUpperSync(word string, f func(string)) { 
  //Code will go here 
} 

现在,toUpperSync函数不返回任何内容,但也接受一个函数,巧合的是,这个函数也接受一个字符串。我们可以用通常返回的结果来执行这个函数。

func toUpperSync(word string, f func(string)) { 
  f(strings.ToUpper(word)) 
} 

我们用提供的单词调用strings.ToUpper方法的结果来执行f函数(它返回大写的parameter)。我们也写main函数:

package main 

import ( 
  "fmt" 
  "strings" 
) 

func main() { 
  toUpperSync("Hello Callbacks!", func(v string) {   
    fmt.Printf("Callback: %s\n", v) }) 
} 

func toUpperSync(word string, f func(string)) { 
  f(strings.ToUpper(word)) 
} 

在我们的主要代码中,我们已经定义了我们的回调。正如你所看到的,我们传递了测试Hello Callbacks!来将其转换为大写。接下来,我们传递回调以执行将我们的字符串转换为大写的结果。在这种情况下,我们只是在控制台上打印文本,并在其前面加上文本Callback。当我们执行这段代码时,我们得到以下结果:

$ go run main.go
Callback: HELLO CALLBACKS!

严格来说,这是一个同步回调。要使它异步,我们必须引入一些并发处理:

package main 
import ( 
  "fmt" 
  "strings" 
  "sync" 
) 

var wait sync.WaitGroup 

func main() { 
  wait.Add(1) 

  toUpperAsync("Hello Callbacks!", func(v string) { 
    fmt.Printf("Callback: %s\n", v) 
    wait.Done() 
  }) 

  println("Waiting async response...") 
  wait.Wait() 
} 

func toUpperAsync(word string, f func(string)) { 
  go func(){ 
    f(strings.ToUpper(word)) 
  }() 
} 

这是异步执行的相同代码。我们使用 WaitGroups 来处理并发(稍后我们将看到通道也可以用于此)。现在,我们的函数toUpperAsync就像其名字所暗示的那样是异步的。我们通过在调用回调时使用关键字go在不同的 Goroutine 中启动了回调。我们写了一条小消息来更准确地显示并发执行的顺序性质。我们等待直到回调信号它已经完成,然后我们可以安全地退出程序。当我们执行这个时,我们得到以下结果:

$ go run main.go 

Waiting async response...
Callback: HELLO CALLBACKS!

正如你所看到的,程序在执行toUpperAsync函数的回调之前就已经到达了main函数的末尾。这种模式带来了许多可能性,但也让我们面临一个被称为回调地狱的大问题。

回调地狱

术语回调地狱通常用来指代当许多回调被堆叠在一起时。当它们增长过多时,这使得它们难以理解和处理。例如,使用与之前相同的代码,我们可以堆叠另一个异步调用与先前打印到控制台的内容:

func main() { 
  wait.Add(1) 

  toUpperAsync("Hello Callbacks!", func(v string) { 
    toUpperAsync(fmt.Sprintf("Callback: %s\n", v), func(v string) { 
      fmt.Printf("Callback within %s", v) 
      wait.Done() 
    }) 
  }) 
  println("Waiting async response...") 
  wait.Wait() 
} 

(我们省略了导入、包名和toUpperAsync函数,因为它们没有改变。)现在我们在toUpperAsync函数中有一个toUpperAsync函数,如果我们愿意,我们可以嵌套更多。在这种情况下,我们再次传递我们先前在控制台上打印的文本,以便在下一个回调中使用。内部回调最终在控制台上打印它,得到以下输出:

$ go run main.go 
Waiting async response...
Callback within CALLBACK: HELLO CALLBACKS!

在这种情况下,我们可以假设外部回调将在内部回调之前执行。这就是为什么我们不需要在 WaitGroup 中再添加一个。

关键在于我们在使用回调时必须小心。在非常复杂的系统中,太多的回调很难理解和处理。但是经过谨慎和理性的处理,它们是强大的工具。

互斥锁

如果你正在处理并发应用程序,你必须处理多个资源可能访问某个内存位置。这通常被称为竞争条件

简单来说,竞争条件类似于两个人同时试图拿到最后一块披萨的时刻——他们的手碰到了一起。用变量替换披萨,用 Goroutines 替换他们的手,我们就有了一个完美的类比。

在这里,有一个人物在晚餐桌上解决这些问题——一个父亲或母亲。他们把披萨放在另一张桌子上,我们必须在拿到我们的披萨之前请求站起来的许可。不管所有的孩子同时问,他们只会允许一个孩子站起来。

好吧,互斥锁就像我们的父母。他们会控制谁可以访问披萨——我的意思是,一个变量——他们不会允许其他人访问它。

要使用互斥锁,我们必须主动锁定它;如果它已经被锁定(另一个 Goroutine 正在使用它),我们必须等待直到它再次解锁。一旦我们获得对互斥锁的访问权,我们可以再次锁定它,进行任何必要的修改,然后再次解锁它。我们将通过一个示例来看看这个过程。

使用互斥锁的示例-并发计数器

互斥锁在并发编程中被广泛使用。在 Go 语言中可能没有那么常见,因为它在并发编程中使用通道的更具惯性的方式,但是值得看看它们在通道不太适用的情况下是如何工作的。

对于我们的示例,我们将开发一个小型并发计数器。这个计数器将在Counter类型中的整数字段中添加一个。这应该以一种并发安全的方式完成。

我们的Counter结构定义如下:

type Counter struct { 
  sync.Mutex 
  value int 
} 

Counter结构有一个int类型的字段,用于存储计数的当前值。它还嵌入了sync包中的Mutex类型。嵌入这个字段将允许我们锁定和解锁整个结构,而无需主动调用特定字段。

我们的main函数启动了 10 个 Goroutines,它们尝试将Counter结构的字段值加一。所有这些都是并发完成的。

package main 

import ( 
  "sync" 
  "time" 
) 

func main() { 
  counter := Counter{} 

  for i := 0; i < 10; i++ { 
    go func(i int) { 
      counter.Lock() 
      counter.value++ 
      defer counter.Unlock() 
    }(i) 
  } 
  time.Sleep(time.Second) 

  counter.Lock() 
  defer counter.Unlock() 

  println(counter.value) 
} 

我们创建了一个名为Counter的类型。使用for循环,我们启动了总共 10 个 Goroutines,就像我们在作为新 Goroutines 启动的匿名函数部分看到的那样。但是在每个 Goroutine 内部,我们都锁定了计数器,以便没有更多的 Goroutines 可以访问它,将一个添加到字段值中,然后再次解锁,以便其他人可以访问它。

最后,我们将打印计数器持有的值。它必须是 10,因为我们启动了 10 个 Goroutines。

但是,我们如何知道这个程序是线程安全的呢?好吧,Go 自带了一个非常方便的内置功能,叫做“竞争检测器”。

介绍竞争检测器

我们已经知道什么是竞争条件。简而言之,当两个进程尝试在同一时间访问同一资源,并且在那一刻涉及一个或多个写操作(两个进程都在写入,或者一个进程在写入而另一个在读取)时,就会使用它。

Go 有一个非常方便的工具来帮助诊断竞争条件,你可以在你的测试或主应用程序中直接运行。所以让我们重用我们刚刚为互斥锁部分编写的示例,并使用竞争检测器运行它。这就像在我们的程序的命令执行中添加-race命令行标志一样简单:

$ go run -race main.go 
10

嗯,这不是很令人印象深刻,是吗?但实际上它告诉我们,在这个程序的代码中没有检测到潜在的竞争条件。让我们通过在修改counter之前不锁定它来使-race标志的检测器警告我们可能存在竞争条件:

for i := 0; i < 10; i++ { 
  go func(i int) { 
    //counter.Lock() 
    counter.value++ 
    //counter.Unlock() 
  }(i) 
} 

for循环内,在将1添加到字段值之前和之后,注释掉LockUnlock调用。这将引入竞争条件。让我们再次运行相同的程序,并激活竞争标志:

$ go run -race main.go 
==================
WARNING: DATA RACE
Read at 0x00c42007a068 by goroutine 6:
 main.main.func1()
 [some_path]/concurrency/locks/main.go:19 +0x44
Previous write at 0x00c42007a068 by goroutine 5:
 main.main.func1()
 [some_path]/concurrency/locks/main.go:19 +0x60
Goroutine 6 (running) created at:
 main.main()
 [some_path]/concurrency/locks/main.go:21 +0xb6
Goroutine 5 (finished) created at:
 main.main()
 [some_path]/concurrency/locks/main.go:21 +0xb6
==================
10
Found 1 data race(s)
exit status 66

我已经减少了一些输出,以便更清楚地看到事情。我们可以看到一个大写的警告消息,上面写着“警告:数据竞争”。但这个输出很容易理解。首先,它告诉我们,我们的main.go文件上的第 19 行代表的某个内存位置正在读取某个变量。但在同一文件的第 19 行上也有一个写操作!

这是因为++操作需要读取当前值并写入一个值。这就是为什么竞争条件在同一行中,因为每次执行它时,它都会读取并写入Counter结构中的字段。

但是让我们记住,竞争检测器是在运行时工作的。它不会静态分析我们的代码!这是什么意思?这意味着我们的设计中可能存在潜在的竞争条件,竞争检测器不会检测到。例如:

package main 

import "sync" 

type Counter struct { 
  sync.Mutex 
  value int 
} 

func main() { 
  counter := Counter{} 

  for i := 0; i < 1; i++ { 
    go func(i int) { 
      counter.value++ 
    }(i) 
  } 
} 

我们将保留前面示例中显示的代码。我们将从代码中删除所有锁定和解锁,并启动一个单个 Goroutine 来更新value字段:

$ go run -race main.go
$

没有警告,所以代码是正确的。好吧,我们知道,按设计,它不是。我们可以将执行的 Goroutines 数量提高到两个,然后看看会发生什么:

for i := 0; i < 2; i++ { 
  go func(i int) { 
    counter.value++ 
  }(i) 
} 

让我们再次执行程序:

$ go run -race main.go
WARNING: DATA RACE
Read at 0x00c42007a008 by goroutine 6:
 main.main.func1()
 [some_path]concurrency/race_detector/main.go:15 +0x44
Previous write at 0x00c42007a008 by goroutine 5:
 main.main.func1()
 [some_path]/concurrency/race_detector/main.go:15 +0x60
Goroutine 6 (running) created at:
 main.main()
 [some_path]/concurrency/race_detector/main.go:16 +0xad
Goroutine 5 (finished) created at:
 main.main()
 [some_path]/concurrency/race_detector/main.go:16 +0xad
==================
Found 1 data race(s)
exit status 66

现在是的,竞争条件被检测到了。但是如果我们将正在使用的处理器数量减少到只有一个,我们也会有竞争条件吗?

$ GOMAXPROCS=1 go run -race main.go
$

似乎没有检测到竞争条件。这是因为调度程序首先执行了一个 Goroutine,然后执行了另一个,所以最终没有发生竞争条件。但是,使用更多的 Goroutines,即使只使用一个核心,它也会警告我们有关竞争条件。

因此,竞争检测器可以帮助我们检测代码中发生的竞争条件,但它不会保护我们免受不立即执行竞争条件的糟糕设计。这是一个非常有用的功能,可以帮我们避免很多麻烦。

通道

通道是语言中允许我们编写并发应用程序的第二个原语。我们在通信顺序进程部分已经谈到了一些关于通道的内容。

通道是我们在进程之间进行通信的方式。我们可以共享一个内存位置,并使用互斥锁来控制进程的访问。但是通道为我们提供了一种更自然的方式来处理并发应用程序,这也在我们的程序中产生了更好的并发设计。

我们的第一个通道

如果我们不能在它们之间创建一些同步,那么使用许多 Goroutines 似乎是相当困难的。只要它们被同步,执行顺序可能就不重要了。通道是在 Go 中编写并发应用程序的第二个关键特性。

现实生活中的电视频道是将一个发射(来自工作室)连接到数百万台电视机(接收器)的东西。Go 中的通道以类似的方式工作。一个或多个 Goroutines 可以作为发射器,一个或多个 Goroutine 可以作为接收器。

还有一件事,通道默认情况下会阻塞 Goroutines 的执行,直到接收到消息。这就好像我们最喜欢的电视节目延迟发射,直到我们打开电视,这样我们就不会错过任何东西。

在 Go 中如何实现这一点?

package main 

import "fmt" 

func main() { 
  channel := make(chan string) 
  go func() { 
    channel <- "Hello World!" 
  }() 

  message := <-channel 
  fmt.Println(message) 
} 

在 Go 中创建通道时,我们使用创建切片时使用的相同语法。使用make关键字创建通道,我们必须传递关键字chan和通道将传输的类型,本例中为字符串。有了这个,我们就有了一个名为channel的阻塞通道。接下来,我们启动一个 Goroutines,向通道发送消息Hello World!。这由直观的箭头表示,显示了流向--Hello World!文本传递给(<-)通道。这就像在变量中进行赋值一样,所以我们只能通过先写通道,然后箭头,最后是要传递的值来传递东西给通道。我们不能写"Hello World!" -> channel

正如我们之前提到的,这个通道会阻塞 Goroutines 的执行,直到接收到消息。在这种情况下,main函数的执行会在启动的 Goroutines 的消息到达通道的另一端的行message := <-channel之前停止。在这种情况下,箭头指向相同的方向,但是放在通道之前,表示数据正在从通道中提取并分配给一个名为message的新变量(使用新的赋值":="运算符)。

在这种情况下,我们不需要使用 WaitGroup 来同步main函数和创建的 Goroutines,因为通道的默认性质是阻塞直到接收到数据。但是反过来呢?如果 Goroutine 发送消息时没有接收器,它会继续吗?让我们编辑这个例子来看看:

package main 

import ( 
  "fmt" 
  "time" 
) 

func main() { 
  channel := make(chan string) 

  var waitGroup sync.WaitGroup 

  waitGroup.Add(1) 
  go func() { 
    channel <- "Hello World!" 
    println("Finishing goroutine") 
    waitGroup.Done() 
  }() 

  time.Sleep(time.Second) 
  message := <-channel 
  fmt.Println(message) 
  waitGroup.Wait() 
} 

我们将再次使用Sleep函数。在这种情况下,我们在 Goroutine 完成时打印一条消息。最大的区别在于main函数。现在,在我们监听通道获取数据之前,我们等待一秒钟:

$ go run main.go

Finishing goroutine
Hello World!

输出可能会有所不同,因为再次强调,执行顺序没有保证,但现在我们可以看到,直到一秒钟过去之前都没有消息被打印出来。在初始延迟之后,我们开始监听通道,接收数据并打印出来。因此,发射器也必须等待来自通道另一侧的提示才能继续执行。

总之,通道是通过一端发送数据,另一端接收数据的方式,在 Goroutines 之间进行通信(就像管道一样)。在它们的默认状态下,发射器 Goroutine 将阻塞其执行,直到接收器 Goroutine 接收数据。接收器 Goroutine 也是一样,它将阻塞,直到某个发射器通过通道发送数据。因此,你可以有被动的监听器(等待数据)或被动的发射器(等待监听器)。

缓冲通道

缓冲通道的工作方式与默认的非缓冲通道类似。你也可以通过使用箭头来传递和获取值,但与非缓冲通道不同的是,发送者不需要等待某个 Goroutine 接收它们发送的数据:

package main 

import ( 
  "fmt" 
  "time" 
) 

func main() { 
  channel := make(chan string, 1) 

  go func() { 
    channel <- "Hello World!" 
    println("Finishing goroutine") 
  }() 

  time.Sleep(time.Second) 

  message := <-channel 
  fmt.Println(message) 
} 

这个例子与我们用于通道的第一个例子类似,但现在我们在make语句中将通道的容量设置为 1。通过这样做,我们告诉编译器,在被阻塞之前,该通道可以容纳一个字符串。因此,第一个字符串不会阻塞发射器,但第二个会。让我们运行这个例子:

$ go run main.go

Finishing goroutine
Hello World!

现在我们可以随意运行这个小程序,输出将始终按照相同的顺序。这一次,我们启动了并发函数并等待了一秒钟。以前,匿名函数在第二秒过去并且有人可以接收到发送的数据之前是不会继续的。在这种情况下,使用缓冲通道,数据被保存在通道中并释放 Goroutine 以继续执行。在这种情况下,Goroutine 总是在等待时间过去之前完成。

这个新通道的大小为 1,因此第二个消息会阻塞 Goroutine 的执行:

package main 

import ( 
  "fmt" 
  "time" 
) 

func main() { 
  channel := make(chan string, 1) 

  go func() { 
    channel <- "Hello World! 1" 
    channel <- "Hello World! 2" 
    println("Finishing goroutine") 
  }() 

  time.Sleep(time.Second) 

  message := <-channel 
  fmt.Println(message) 
} 

在这里,我们添加了第二个Hello world! 2消息,并为其提供了一个索引。在这种情况下,该程序的输出可能如下所示:

$ go run main.go
Hello World! 1

表示我们刚刚从通道缓冲区中取出了一条消息,我们已经打印出来了,并且main函数在启动的 Goroutine 完成之前就结束了。当发送第二条消息时,Goroutine 被阻塞,直到另一端接收了第一条消息。然后它打印出来得如此之快,以至于没有时间打印出消息来显示 Goroutine 的结束。如果你在控制台上不断执行程序,sooner or later 调度器会在主线程之前完成 Goroutine 的执行。

方向性通道

关于 Go 通道的一个很酷的特性是,当我们将它们用作参数时,我们可以限制它们的方向性,使它们只能用于发送或接收。如果通道在受限方向上被使用,编译器会报错。这个特性为 Go 应用程序应用了新的静态类型级别,并使代码更易理解和更易读。

我们将用通道来举一个简单的例子:

package main 

import ( 
  "fmt" 
  "time" 
) 

func main() { 
  channel := make(chan string, 1) 

 go func(ch chan<- string) { 
    ch <- "Hello World!" 
    println("Finishing goroutine") 
  }(channel) 

  time.Sleep(time.Second) 

  message := <-channel 
  fmt.Println(message) 
} 

在我们启动新的 Goroutine go func(ch chan<- string)的那一行,声明了传递给这个函数的通道只能用作输入通道,你不能监听它。

我们也可以传递一个只用作接收器通道的通道:

func receivingCh(ch <-chan string) { 
  msg := <-ch 
  println(msg) 
} 

正如你所看到的,箭头位于chan关键字的相反方向,表示从通道中提取操作。请记住,通道箭头总是指向左边,以指示接收通道,它必须指向左边,以指示插入通道,它必须指向右边。

如果我们试图通过这个只接收通道发送一个值,编译器会抱怨:

func receivingCh(ch <-chan string) { 
  msg := <-ch 
  println(msg) 
  ch <- "hello" 
} 

这个函数有一个只接收通道,我们将尝试通过它发送消息hello。让我们看看编译器说了什么:

$ go run main.go
./main.go:20: invalid operation: ch <- "hello2" (send to receive-only type <-chan string)

它不喜欢它,并要求我们纠正它。现在代码更加可读和安全,我们只是在chan参数的前面或后面放置了一个箭头。

选择语句

select语句也是 Go 中的一个关键特性。它用于在一个 Goroutine 中处理多个通道输入。事实上,它打开了许多可能性,在接下来的章节中我们将广泛使用它。

选择语句

select结构中,我们要求程序在一个或多个通道之间选择接收它们的数据。我们可以将这些数据保存在一个变量中,并在完成选择之前对其进行处理。select结构只执行一次;不管它是否在监听多个通道,它只会执行一次,代码将继续执行。如果我们希望它多次处理相同的通道,我们必须将其放在一个for循环中。

我们将创建一个小应用程序,将消息hello和消息goodbye发送到同一个 Goroutine 中,该 Goroutine 将打印它们,并在五秒内没有收到其他消息时退出。

首先,我们将创建一个通用函数,用于通过通道发送一个字符串:

func sendString(ch chan<- string, s string) { 
  ch <- s 
} 

现在我们可以通过简单调用sendString方法向通道发送一个字符串。现在是接收者的时间了。接收者将从两个通道接收消息--一个发送hello消息的通道,一个发送goodbye消息的通道。你也可以在之前的图表中看到这一点:

func receiver(helloCh, goodbyeCh <-chan string, quitCh chan<- bool) { 
  for { 
    select { 
    case msg := <-helloCh: 
      println(msg) 
    case msg := <-goodbyeCh: 
      println(msg) 
    case <-time.After(time.Second * 2): 
      println("Nothing received in 2 seconds. Exiting") 
      quitCh <- true 
      break 
    } 
  } 
} 

让我们从参数开始。这个函数接受三个通道--两个接收通道和一个用于通过它发送东西的通道。然后,它使用for关键字开始一个无限循环。这样我们就可以永远保持对两个通道的监听。

select块的范围内,我们必须为我们想要处理的每个通道使用一个 case(你是否意识到它与switch语句有多么相似?)。让我们一步一步地看看这三种情况:

  • 第一种情况接收来自helloCh参数的传入数据,并将其保存在一个名为msg的变量中。然后它打印出这个变量的内容。

  • 第二种情况接收来自goodbyeCh参数的传入数据,并将其保存在一个名为msg的变量中。然后它也打印出这个变量的内容。

  • 第三种情况非常有趣。它调用time函数。之后,如果我们检查它的签名,它接受一个时间和持续时间值,并返回一个接收通道。这个接收通道将在指定的持续时间过去后接收一个时间,time的值。在我们的例子中,我们使用它返回的通道作为超时。因为每次处理后select都会重新启动,计时器也会重新启动。这是一个非常简单的方法,可以为等待一个或多个通道的响应的 Goroutine 设置一个计时器。

main函数准备就绪:

package main 
import "time" 

func main() { 
  helloCh := make(chan string, 1) 
  goodbyeCh := make(chan string, 1) 
  quitCh := make(chan bool) 
  go receiver(helloCh, goodbyeCh, quitCh) 

  go sendString(helloCh, "hello!") 

  time.Sleep(time.Second) 

  go sendString(goodbyeCh, "goodbye!") 
  <-quitCh 
} 

再一步一步地,我们创建了这个练习中需要的三个通道。然后,我们在一个不同的 Goroutine 中启动了我们的receiver函数。这个 Goroutine 由 Go 的调度程序处理,我们的程序继续执行。我们启动了一个新的 Goroutine,向helloCh参数发送消息hello。同样,这将在 Go 的调度程序决定时最终发生。

我们的程序再次继续,并等待一秒。在这个间歇中,Go 的调度程序将有时间执行接收者和第一条消息(如果尚未执行),所以hello!消息将在间歇期间出现在控制台上。

一个新的消息通过goodbye通道以goodbye!文本的形式发送到一个新的 Goroutine 中,我们的程序再次继续执行,等待在quitCh参数中接收到一条消息的行。

我们已经启动了三个 Goroutine--接收者仍在运行,第一个消息在消息被select语句处理时已经完成,第二个消息几乎立即被打印并完成了。所以此刻只有接收者在运行,如果在接下来的两秒内没有收到其他消息,它将处理来自time结构的传入消息。在channel类型之后,打印一条消息以表明它正在退出,向quitCh发送一个true,并中断它正在循环的无限循环。

让我们运行这个小应用程序:

$ go run main.go

hello!
goodbye!
Nothing received in 2 seconds. Exiting

结果可能并不令人印象深刻,但概念是清晰的。我们可以使用 select 语句在同一个 Goroutine 中处理许多传入的通道。

也可以对通道进行范围遍历!

我们将看到关于通道的最后一个特性是对通道进行范围遍历。我们谈论的是范围关键字。我们已经广泛使用它来遍历列表,我们也可以用它来遍历通道:

package main 

import "time" 

func main() { 
  ch := make(chan int) 

  go func() { 
    ch <- 1 
    time.Sleep(time.Second) 

    ch <- 2 

    close(ch) 
  }() 
  for v := range ch { 
    println(v) 
  } 
} 

在这种情况下,我们创建了一个非缓冲通道,但它也可以使用缓冲通道。我们在一个新的 Goroutine 中启动一个函数,该函数通过通道发送数字"1",等待一秒,发送数字"2",然后关闭通道。

最后一步是对通道进行范围遍历。语法与列表范围非常相似。我们将从通道中存储传入的数据到变量v,并将这个变量打印到控制台。范围会一直迭代,直到通道关闭,从通道中获取数据。

你能猜出这个小程序的输出吗?

$ go run main.go

1
2

同样,并不令人印象深刻。它打印数字"1",然后等待一秒,打印数字"2",然后退出应用程序。

根据这个并发应用程序的设计,范围会迭代可能从通道中传入的数据

通道

直到并发 Goroutine 关闭这个通道。在那一刻,范围结束,应用程序可以退出。

范围在从通道中获取数据时非常有用,并且通常用于多个不同的 Goroutine 向同一个通道发送数据的扇入模式中。

使用所有这些-并发单例

既然我们知道如何创建 Goroutines 和通道,我们将把所有的知识放在一个单一的包中。回想一下前几章,当我们解释单例模式时--它是一种只能在我们的代码中存在一次的结构或变量。对这个结构的所有访问都应该使用所描述的模式,但实际上,它并不是并发安全的。

现在我们将考虑并发编写。我们将编写一个并发计数器,就像我们在互斥部分中编写的那样,但这次我们将使用通道来解决它。

单元测试

为了限制对singleton实例的并发访问,只有一个 Goroutine 能够访问它。我们将使用通道访问它--第一个通道用于添加一个,第二个通道用于获取当前计数,第三个通道用于停止 Goroutine。

我们将使用从两个不同的singleton实例启动的 10,000 个不同的 Goroutine 添加 10,000 次。然后,我们将引入一个循环来检查singleton的计数,直到达到 5,000,但我们将在开始循环之前写下计数是多少。

一旦计数达到 5,000,循环将退出并退出运行的 Goroutine--测试代码看起来像这样:

package channel_singleton 
import ( 
  "testing" 
  "time" 
  "fmt" 
) 

func TestStartInstance(t *testing.T) { 
  singleton := GetInstance() 
  singleton2 := GetInstance() 

  n := 5000 

  for i := 0; i < n; i++ { 
    go singleton.AddOne() 
    go singleton2.AddOne() 
  } 

  fmt.Printf("Before loop, current count is %d\n", singleton.GetCount()) 

  var val int 
  for val != n*2 { 
    val = singleton.GetCount() 
    time.Sleep(10 * time.Millisecond) 
  } 
  singleton.Stop() 
} 

在这里,我们可以看到我们将使用的完整测试。在创建两个singleton实例之后,我们创建了一个for循环,从每个实例中启动AddOne方法 5,000 次。这还没有发生;它们正在被调度,最终将被执行。我们打印singleton实例的计数,以清楚地看到这种可能性;根据计算机的不同,它将打印出一个大于 0 且小于 10,000 的数字。

在停止持有计数的 Goroutine 之前的最后一步是进入一个循环,检查计数的值,并在值不是预期值(10,000)时等待 10 毫秒。一旦达到这个值,循环将退出,我们可以停止singleton实例。

由于要求非常简单,我们将直接跳转到实施。

实施

首先,我们将创建将保存计数的 Goroutine:

var addCh chan bool = make(chan bool) 
var getCountCh chan chan int = make(chan chan int) 
var quitCh chan bool = make(chan bool) 

func init() { 
  var count int 

  go func(addCh <-chan bool, getCountCh <-chan chan int, quitCh <-chan bool) { 
    for { 
      select { 
      case <-addCh: 
        count++ 
      case ch := <-getCountCh: 
        ch <- count 
      case <-quitCh: 
        return 
      } 
    } 
  }(addCh, getCountCh, quitCh) 
} 

我们创建了三个通道,正如我们之前提到的:

  • addCh通道用于与添加一个计数的动作进行通信,并接收一个bool类型,只是为了发出“添加一个”的信号(虽然我们可以发送数字,但我们不需要)。

  • getCountCh通道将返回一个将接收计数的当前值的通道。花点时间思考一下getCountCh通道-它是一个接收整数类型的通道的通道。听起来有点复杂,但当我们完成示例时,它会更有意义,不用担心。

  • quitCh通道将通知 Goroutine 应该结束其无限循环并结束自身。

现在我们有了执行我们想要的操作所需的通道。接下来,我们启动 Goroutine,将通道作为参数传递。正如你所看到的,我们正在限制通道的方向,以提供更多的类型安全性。在这个 Goroutine 内部,我们创建了一个无限的for循环。这个循环不会停止,直到在其中执行了一个中断。

最后,select语句,如果你还记得,是一种同时从不同通道接收数据的方法。我们有三种情况,因此我们监听了作为参数输入的三个传入通道:

  • addCh情况将计数增加一。请记住,每次迭代只能执行一个情况,以便没有 Goroutine 可以访问当前计数,直到我们完成添加一个。

  • getCountCh通道接收一个接收整数的通道,因此我们捕获了这个新通道,并通过它发送当前值到另一端。

  • quitCh通道中断for循环,因此 Goroutine 结束。

最后一件事。在任何包中,init()函数将在程序执行时执行,因此我们不需要担心从我们的代码中特别执行此函数。

现在,我们将创建测试所期望的类型。我们将看到所有的魔术和逻辑都隐藏在这种类型中,对最终用户来说(正如我们在测试代码中看到的):

type singleton struct {} 

var instance singleton 
func GetInstance() *singleton { 
  return &instance 
} 

singleton类型的工作方式类似于第二章中的工作方式,创建模式-单例,生成器,工厂,原型和抽象工厂,但这次它不会保存计数值。我们为其创建了一个名为instance的本地值,并在调用GetInstance()方法时返回指向此实例的指针。这样做并不是严格必要的,但我们不需要在每次访问计数变量时分配singleton类型的新实例。

首先,AddOne()方法将不得不将当前计数加一。如何?通过向addCh通道发送true。这很简单:

func (s *singleton) AddOne() { 
  addCh <- true 
} 

这个小片段将依次触发我们的 Goroutine 中的addCh情况。addCh情况只是执行count++并完成,让select通道控制流执行init函数中的下一个指令:

func (s *singleton) GetCount() int { 
  resCh := make(chan int) 
  defer close(resCh) 
  getCountCh <- resCh 
  return <-resCh 
} 

GetCount方法每次被调用时都会创建一个通道,并推迟在函数结束时关闭它的操作。这个通道是无缓冲的,正如我们在本章中之前看到的那样。无缓冲通道会阻塞执行,直到它接收到一些数据。因此,我们将这个通道发送到getCountCh,它也是一个通道,并且有效地期望一个chan int类型通过它发送当前计数值。GetCount()方法将不会返回,直到count变量的值到达resCh通道。

你可能会想,为什么我们不在两个方向上使用相同的通道来接收计数的值?这样我们就可以避免分配。如果我们在GetCount()方法中使用相同的通道,我们将在这个通道中有两个监听器--一个在select语句中,在文件的开头的init函数中,一个在那里,所以当发送值时它可以解析到任何一个:

func (s *singleton) Stop() { 
  quitCh <- true 
  close(addCh) 
  close(getCountCh) 
  close(quitCh) 
} 

最后,我们必须在某个时刻停止 Goroutine。Stop方法向singleton类型的 Goroutine 发送值,以触发quitCh情况并打破for循环。下一步是关闭所有通道,以便不再通过它们发送数据。当你知道你不会再使用一些通道时,这非常方便。

执行测试并查看时间:

$ go test -v .
=== RUN   TestStartInstance
Before loop, current count is 4911
--- PASS: TestStartInstance (0.03s)
PASS
ok

输出的代码很少,但一切都按预期工作。在测试中,我们在进入循环之前打印了计数的值,直到达到值 10000。正如我们之前看到的,Go 调度器将尝试使用尽可能多的 OS 线程来运行 Goroutines 的内容,通过使用GOMAXPROCS配置来配置。在我的电脑上,它设置为4,因为我的电脑有四个核心。但关键是我们可以看到在启动 Goroutine(或 10000 个)和下一个执行行之后会发生很多事情。

但互斥锁的使用呢?

type singleton struct { 
  count int 
  sync.RWMutex 
} 

var instance singleton 

func GetInstance() *singleton { 
  return &instance 
} 

func (s *singleton) AddOne() { 
  s.Lock() 
  defer s.Unlock() 
  s.count++ 
} 

func (s *singleton) GetCount()int { 
  s.RLock() 
  defer s.RUnlock() 
  return s.count 
} 

在这种情况下,代码要简洁得多。正如我们之前看到的,我们可以在singleton结构中嵌入互斥锁。计数也保存在count字段中,AddOne()GetCount()方法锁定和解锁值以确保并发安全。

还有一件事。在这个singleton实例中,我们使用的是RWMutex类型,而不是已知的sync.Mutex类型。这里的主要区别在于RWMutex类型有两种锁--读锁和写锁。通过调用RLock方法执行读锁,只有在当前存在写锁时才会等待。同时,它只会阻止写锁,因此可以并行进行许多读操作。这是有道理的;我们不希望因为另一个 Goroutine 也在读取值(它不会改变)而阻塞想要读取值的 Goroutine。sync.RWMutex类型帮助我们在代码中实现这种逻辑。

摘要

我们已经看到了如何使用互斥锁和通道编写并发的 Singleton。虽然通道的例子更复杂,但它也展示了 Go 并发的核心力量,因为你可以通过简单地使用通道实现复杂的事件驱动架构。

请记住,如果你以前没有编写过并发代码,开始以舒适的方式并发思考可能需要一些时间。但这并不是练习不能解决的问题。

我们已经看到了设计并发应用程序以实现程序并行性的重要性。我们已经处理了大部分 Go 的原语,编写了并发应用程序,现在我们可以编写常见的并发设计模式。

第九章:并发模式-屏障、未来和管道设计模式

现在我们熟悉了并发和并行的概念,并且已经了解了如何使用 Go 的并发原语来实现它们,我们可以看到关于并发工作和并行执行的一些模式。在本章中,我们将看到以下模式:

  • 屏障是一种非常常见的模式,特别是当我们必须等待来自不同 Goroutines 的多个响应才能让程序继续时。

  • 未来模式允许我们编写一个算法,该算法最终将由同一个 Goroutine 或不同的 Goroutine 在时间上执行(或不执行)

  • 管道是一种强大的模式,用于构建与某种逻辑相连的 Goroutines 的复杂同步流

快速浏览一下这三种模式的描述。它们都描述了一些逻辑来同步执行时间。非常重要的是要记住,我们现在正在使用前几章中看到的所有工具和模式来开发并发结构。在创建模式中,我们处理创建对象。在结构模式中,我们学习如何构建成惯用的结构,在行为模式中,我们主要处理算法。现在,使用并发模式,我们将主要管理应用程序的定时执行和顺序执行,这些应用程序具有多个

屏障并发模式

我们将从屏障模式开始。它的目的很简单-设置一个屏障,以便在我们获得所有需要的结果之前没有人通过,这在并发应用程序中非常常见。

描述

想象一下这样的情况,我们有一个微服务应用程序,其中一个服务需要通过合并另外三个微服务的响应来组成其响应。这就是屏障模式可以帮助我们的地方。

我们的屏障模式可以是一个服务,它将阻止其响应,直到它已与一个或多个不同的 Goroutines(或服务)返回的结果组合在一起。那么我们有什么样的原语具有阻塞特性呢?嗯,我们可以使用锁,但在 Go 中更惯用的是使用无缓冲通道。

目标

正如其名称所示,屏障模式试图阻止执行,以便在准备好结束之前不要完成。屏障模式的目标如下:

  • 将类型的值与来自一个或多个 Goroutines 的数据组合在一起。

  • 控制任何这些传入数据管道的正确性,以便不返回不一致的数据。我们不希望部分填充的结果,因为其中一个管道返回了错误。

一个 HTTP GET 聚合器

对于我们的示例,我们将在微服务应用程序中编写一个非常典型的情况-一个执行两个 HTTP GET调用并将它们合并成单个响应的应用程序,然后将其打印在控制台上。

我们的小应用程序必须在不同的 Goroutine 中执行每个请求,并在控制台上打印结果,如果两个响应都正确。如果其中任何一个返回错误,那么我们只打印错误。

设计必须是并发的,允许我们利用多核 CPU 并行进行调用:

一个 HTTP GET 聚合器

在上图中,实线表示调用,虚线表示通道。气球是 Goroutines,因此我们有两个 Goroutines 由main函数启动(也可以被认为是 Goroutine)。这两个函数将通过使用它们在makeRequest调用时接收到的公共通道main函数进行通信。

验收标准

我们在这个应用程序中的主要目标是获取两个不同调用的合并响应,因此我们可以这样描述我们的验收标准:

  • 在控制台上打印两次对http://httpbin.org/headershttp://httpbin.org/User-Agent URL 的调用的合并结果。这些是一对公共端点,会响应来自传入连接的数据。它们非常受欢迎,用于测试目的。您需要互联网连接才能完成此练习。

  • 如果任何一个调用失败,它不得打印任何结果-只打印错误消息(或者如果两个调用都失败,则打印错误消息)。

  • 当两个调用都完成时,输出必须作为组合结果打印出来。这意味着我们不能先打印一个调用的结果,然后再打印另一个调用的结果。

单元测试 - 集成

为并发设计编写单元测试或集成测试有时可能会很棘手,但这不会阻止我们编写出色的单元测试。我们将有一个名为barrier的方法,接受一个定义为string类型的端点集。屏障将对每个端点进行GET请求,并在打印结果之前组合结果。在这种情况下,我们将编写三个集成测试,以简化我们的代码,这样我们就不需要生成模拟响应:

package barrier 

import ( 
    "bytes" 
    "io" 
    "os" 
    "strings" 
    "testing" 
) 

func TestBarrier(t *testing.T) { 
  t.Run("Correct endpoints", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers",  "http://httpbin.org/User-Agent"
    } 
  }) 

  t.Run("One endpoint incorrect", func(t *testing.T) { 
    endpoints := []string{"http://malformed-url",  "http://httpbin.org/User-Agent"} 
  }) 

  t.Run("Very short timeout", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers",  "http://httpbin.org/User-Agent"} 
  }) 
} 

我们有一个单一的测试,将执行三个子测试:

  • 第一个测试对正确的端点进行两次调用

  • 第二个测试将有一个错误的端点,因此它必须返回一个错误

  • 最后一个测试将返回最大超时时间,以便我们可以强制超时错误

我们将有一个名为barrier的函数,它将接受形式为字符串的不确定数量的端点。它的签名可能是这样的:

func barrier(endpoints ...string) {} 

正如你所看到的,barrier函数不返回任何值,因为它的结果将打印在控制台上。之前,我们已经编写了一个io.Writer接口的实现,以模拟在操作系统的stdout库上的写入。为了改变一些东西,我们将捕获stdout库而不是模拟一个。一旦你理解了 Go 语言中的并发原语,捕获stdout库的过程并不困难:

func captureBarrierOutput(endpoints ...string) string { 
    reader, writer, _ := os.Pipe() 

    os.Stdout = writer 
    out := make(chan string) 

    go func() { 
      var buf bytes.Buffer 
      io.Copy(&buf, reader) 
      out <- buf.String() 
    }() 

    barrier(endpoints...) 

    writer.Close() 
    temp := <-out 

    return temp 
} 

不要被这段代码吓到;它真的很简单。首先我们创建了一个管道;我们在第三章中已经做过这个操作,结构模式 - 适配器、桥接和组合设计模式,当我们谈论适配器设计模式时。回想一下,管道允许我们将io.Writer接口连接到io.Reader接口,以便读取器的输入是Writer的输出。我们将os.Stdout定义为写入器。然后,为了捕获stdout输出,我们将需要一个不同的 Goroutine 来监听我们写入控制台时。正如你所知,如果我们写入,我们就不会捕获,如果我们捕获,我们就不会写入。这里的关键词是while;这是一个经验法则,如果你在某个定义中找到这个词,你可能需要一个并发结构。因此,我们使用go关键字启动一个不同的 Goroutine,将读取器的输入复制到字节缓冲区,然后通过通道发送缓冲区的内容(我们应该先前创建)。

此时,我们有一个监听的 Goroutine,但我们还没有打印任何东西,所以我们用提供的端点调用我们的(尚未编写的)函数barrier。接下来,我们必须关闭写入器以向 Goroutine 发出不会再有更多输入的信号。我们称为 out 的通道会阻塞执行,直到接收到某个值(由我们启动的 Goroutine 发送的值)。最后一步是返回从控制台捕获的内容。

好的,所以我们有一个名为captureBarrierOutput的函数,它将在stdout中捕获输出并将其作为字符串返回。现在我们可以编写我们的测试了:

t.Run("Correct endpoints", func(t *testing.T) { 
    endpoints := []string{"http://httpbin.org/headers", "http://httpbin.org/User-Agent"
    } 

 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "Accept-Encoding") || strings.Contains (result, "User-Agent") 
  {
 t.Fail()
 }
 t.Log(result) 
}) 

所有这些测试都非常容易实现。总的来说,是captureBarrierOutput函数调用了barrier函数。所以我们传递端点并检查返回的结果。我们发送到httpbin.org的组合响应必须包含每个端点响应中的Accept-EncodingUser-Agent文本。如果我们找不到这些文本,测试将失败。为了调试目的,如果我们想要使用-v标志检查它,我们会记录响应在 go test 中:

t.Run("One endpoint incorrect", func(t *testing.T) { 
  endpoints := []string
  {
    "http://malformed-url", "http://httpbin.org/User-Agent"} 

 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "ERROR") {
 t.Fail()
 }
 t.Log(result) 
}) 

这次我们使用了不正确的端点 URL,所以响应必须返回以ERROR为前缀的错误,这是我们自己在barrier函数中编写的。

最后一个函数将把 HTTP GET客户端的超时减少到最少 1 毫秒,以便强制超时:

t.Run("Very short timeout", func(t *testing.T) { 
  endpoints := []string
  {
    "http://httpbin.org/headers", "http://httpbin.org/User-Agent"} 
 timeoutMilliseconds = 1
 result := captureBarrierOutput(endpoints...)
 if !strings.Contains(result, "Timeout") {
 t.Fail()
 }
 t.Log(result) 
  }) 

timeoutMilliseconds变量将是一个我们在实现过程中需要定义的包变量。

实现

我们需要定义一个名为timeoutMilliseconds的包变量。让我们从那里开始:

package barrier 

import ( 
    "fmt" 
    "io/ioutil" 
    "net/http" 
    "time" 
) 

var timeoutMilliseconds int = 5000 

初始超时延迟为 5 秒(5,000 毫秒),我们将需要这些包在我们的代码中。

好的,所以我们需要一个函数为每个端点 URL 启动一个 Goroutine。你还记得我们是如何在 Goroutines 之间进行通信的吗?没错--通道!所以我们需要一个处理响应的通道和一个处理错误的通道。

但我们可以简化它一些。我们将收到两个正确的响应、两个错误,或一个响应和一个错误;无论如何,总是有两个响应,所以我们可以将错误和响应合并成一个合并类型:

type barrierResp struct { 
    Err  error 
    Resp string 
} 

因此,每个 Goroutine 将发送一个barrierResp类型的值。这个值将有一个Err值或一个Resp字段的值。

该过程很简单:我们创建了一个大小为 2 的通道,用于接收barrierResp类型的响应,我们启动了两个请求并等待两个响应,然后检查是否有任何错误:

func barrier(endpoints ...string) { 
    requestNumber := len(endpoints) 

    in := make(chan barrierResp, requestNumber) 
    defer close(in) 

    responses := make([]barrierResp, requestNumber) 

    for _, endpoint := range endpoints { 
        go makeRequest(in, endpoint) 
    } 

    var hasError bool 
    for i := 0; i < requestNumber; i++ { 
        resp := <-in 
        if resp.Err != nil { 
            fmt.Println("ERROR: ", resp.Err) 
            hasError = true 
        } 
        responses[i] = resp 
    } 

    if !hasError { 
        for _, resp := range responses { 
            fmt.Println(resp.Resp) 
        } 
    } 
} 

根据前面的描述,我们创建了一个名为in的缓冲通道,使其大小与传入的端点一样,并推迟了通道关闭。然后,我们使用每个端点和响应通道启动了一个名为makeRequest的函数。

现在我们将循环两次,每次对应一个端点。在循环中,我们阻塞执行,等待来自in通道的数据。如果我们发现错误,我们会打印带有ERROR前缀的错误,因为这是我们在测试中期望的,并将hasErrorvar设置为 true。在两个响应之后,如果我们没有发现任何错误(hasError== false),我们会打印每个响应,并关闭通道。

我们仍然缺少makeRequest函数:

func makeRequest(out chan<- barrierResp, url string) { 
    res := barrierResp{} 
    client := http.Client{ 
        Timeout: time.Duration(time.Duration(timeoutMilliseconds) * time.Millisecond), 
    } 

    resp, err := client.Get(url) 
    if err != nil { 
        res.Err = err 
        out <- res 
        return 
    } 

    byt, err := ioutil.ReadAll(resp.Body) 
    if err != nil { 
        res.Err = err 
        out <- res 
        return 
    } 

    res.Resp = string(byt) 
    out <- res 
} 

makeRequest函数是一个非常简单的函数,它接受一个通道来输出barrierResp值和一个要请求的 URL。我们创建一个http.Client并将其超时字段设置为timeoutMilliseconds包变量的值。这是我们在in函数测试之前可以改变超时延迟的方法。然后,我们简单地进行GET调用,获取响应,将其解析为字节切片,并通过out通道发送。

我们通过填充barrierResp类型的res变量来完成所有这些。如果我们在执行GET请求或解析结果的主体时发现错误,我们会填充res.Err字段,将其发送到out通道(其对面连接到原始 Goroutine),并退出函数(这样我们就不会错误地通过out通道发送两个值)。

是时候运行测试了。请记住,您需要互联网连接,否则前两个测试将失败。我们将首先尝试具有两个正确端点的测试:

go test -run=TestBarrier/Correct_endpoints -v .
=== RUN   TestBarrier
=== RUN   TestBarrier/Correct_endpoints
--- PASS: TestBarrier (0.54s)
 --- PASS: TestBarrier/Correct_endpoints (0.54s)
 barrier_test.go:20: {
 "headers": {
 "Accept-Encoding": "gzip", 
"Host": "httpbin.org",
"User-Agent": "Go-http-client/1.1"
 }
 }
 {
 "User-Agent": "Go-http-client/1.1"
 } 
 ok

完美。我们得到了一个带有键headers的 JSON 响应,以及另一个带有键User-Agent的 JSON 响应。在我们的集成测试中,我们正在寻找字符串User-AgentAccept-Encoding,这些字符串都存在,所以测试已成功通过。

现在我们将运行一个具有不正确端点的测试:

go test -run=TestBarrier/One_endpoint_incorrect -v .
=== RUN   TestBarrier
=== RUN   TestBarrier/One_endpoint_incorrect
--- PASS: TestBarrier (0.27s)
 --- PASS: TestBarrier/One_endpoint_incorrect (0.27s)
 barrier_test.go:31: ERROR:  Get http://malformed-url: dial tcp: lookup malformed-url: no such host
ok

我们可以看到,当http://malformed-url返回no such host错误时,我们出现了错误。对这个 URL 的请求必须返回一个以ERROR:为前缀的文本,正如我们在验收标准中所述,这就是为什么这个测试是正确的(我们没有假阳性)。

注意

在测试中,理解“假阳性”和“假阴性”测试的概念非常重要。假阳性测试大致描述为当不应该通过条件时通过条件的测试(结果:全部通过),而假阴性则正好相反(结果:测试失败)。例如,我们可能正在测试请求时返回一个字符串,但是返回的字符串可能完全为空!这将导致假阴性,即即使我们正在检查一个有意不正确的行为(对http://malformed-url的请求),测试也不会失败。

最后一个测试将超时时间缩短为 1 毫秒:

go test -run=TestBarrier/Very_short_timeout -v .     
=== RUN   TestBarrier 
=== RUN   TestBarrier/Very_short_timeout 
--- PASS: TestBarrier (0.00s) 
    --- PASS: TestBarrier/Very_short_timeout (0.00s) 
        barrier_test.go:43: ERROR:  Get http://httpbin.org/User-Agent: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) 
        ERROR:  Get http://httpbin.org/headers: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers) 

ok

再次,测试成功通过,我们得到了两个超时错误。URL 是正确的,但我们在一毫秒内没有得到响应,所以客户端返回了超时错误。

使用屏障设计模式等待响应

屏障模式以其可组合的特性打开了微服务编程的大门。它可以被认为是一种结构模式,正如你可以想象的那样。

屏障模式不仅对进行网络请求有用;我们还可以使用它将某些任务分割成多个 Goroutines。例如,一个昂贵的操作可以分割成几个较小的操作,分布在不同的 Goroutines 中,以最大程度地实现并行性并获得更好的性能。

未来设计模式

未来设计模式(也称为Promise)是实现异步编程的并发结构的一种快速简便的方法。我们将利用 Go 中的一级函数来开发Futures

描述

简而言之,我们将在不同的 Goroutines 中定义每个动作的可能行为,然后执行它们。Node.js 使用这种方法,默认提供事件驱动编程。这里的想法是实现一个fire-and-forget,处理动作的所有可能结果。

为了更好地理解,我们可以谈论一个类型,它在执行顺利或失败的情况下嵌入了行为。

描述

在前面的图表中,main函数在一个新的 Goroutine 中启动了一个Future。它不会等待任何东西,也不会接收 Future 的任何进度。它真的只是启动并忘记了它。

这里有趣的是,我们可以在一个 Future 中启动一个新的 Future,并在同一个 Goroutine(或新的 Goroutine)中嵌入尽可能多的 Futures。这个想法是利用一个 Future 的结果来启动下一个。例如:

描述

在这里,我们有相同的 Future。在这种情况下,如果Execute函数返回了正确的结果,那么将执行Success函数,只有在这种情况下,我们才会执行一个新的 Goroutine,里面包含另一个 Future(甚至可以不使用 Goroutine)。

这是一种懒惰的编程,其中一个 Future 可能会无限地调用自身,或者直到满足某些规则为止。这个想法是预先定义行为,让未来解决可能的解决方案。

目标

使用未来模式,我们可以启动许多新的 Goroutines,每个 Goroutine 都有一个动作和自己的处理程序。这使我们能够做到以下几点:

  • 将动作处理程序委托给不同的 Goroutine

  • 在它们之间堆叠许多异步调用(一个异步调用在其结果中调用另一个异步调用)

一个简单的异步请求者

我们将开发一个非常简单的示例来尝试理解 Future 的工作原理。在这个示例中,我们将有一个返回字符串或错误的方法,但我们希望并发执行它。我们已经学会了如何做到这一点。使用通道,我们可以启动一个新的 Goroutine,并处理来自通道的传入结果。

但在这种情况下,我们将不得不处理结果(字符串或错误),而我们不希望这样做。相反,我们将定义成功时要执行的操作,以及出现错误时要执行的操作,并且忘记 Goroutine。

验收标准

对于这个任务,我们没有功能性需求。相反,我们将对其进行技术要求:

  • 将函数执行委托给不同的 Goroutine

  • 函数将返回一个字符串(也许)或一个错误

  • 处理程序必须在执行函数之前已经定义好

  • 设计必须是可重用的

单元测试

所以,正如我们提到的,我们将使用一等函数来实现这种行为,我们将需要三种特定类型的函数:

  • type SuccessFunc func(string): 如果一切顺利,SuccessFunc 函数将被执行。它的字符串参数将是操作的结果,因此我们的 Goroutine 将调用这个函数。

  • type FailFunc func(error): FailFunc 函数处理相反的结果,也就是当出现问题时,正如你所见,它会返回一个错误。

  • type ExecuteStringFunc func() (string, error): 最后,ExecuteStringFunc 函数是一个类型,定义了我们想要执行的操作。也许它会返回一个字符串或一个错误。如果这一切看起来令人困惑,不要担心;稍后会更清楚。

因此,我们创建了 future 对象,定义了成功的行为,定义了失败的行为,并传递了一个要执行的 ExecuteStringFunc 类型。在实现文件中,我们需要一个新类型:

type MaybeString struct {} 

我们还将在 _test.go 文件中创建两个测试:

package future 

import ( 
  "errors" 
  "testing" 
  "sync" 
) 

func TestStringOrError_Execute(t *testing.T) { 
  future := &MaybeString{} 
  t.Run("Success result", func(t *testing.T) { 
    ... 
  }) 
  t.Run("Error result", func(t *testing.T) { 
  ... 
  }) 
} 

我们将通过链接定义函数,就像你通常在 Node.js 中看到的那样。这样的代码紧凑而且不难跟踪:

t.Run("Success result", func(t *testing.T) { 
 future.Success(func(s string) {
 t.Log(s)
 }).Fail(func(e error) {
 t.Fail()
 })
 future.Execute(func() (string, error) {
 return "Hello World!", nil
 }) 
}) 

future.Success 函数必须在 MaybeString 结构中定义,以接受一个 SuccessFunc 函数,如果一切正常则执行,并返回相同指针给 future 对象(这样我们就可以继续链式调用)。Fail 函数也必须在 MaybeString 结构中定义,并且必须接受一个 FailFunc 函数,然后返回指针。我们在两种情况下都返回指针,这样我们就可以定义 FailSuccess 或者反之亦然。

最后,我们使用 Execute 方法传递一个 ExecuteStringFunc 类型(一个接受空参数并返回一个字符串或一个错误的函数)。在这种情况下,我们返回一个字符串和 nil,所以我们期望 SuccessFunc 函数将被执行,并且我们将结果记录到控制台。如果执行了失败函数,那么测试就失败了,因为不应该为返回的 nil 错误执行 FailFunc 函数。

但我们还缺少一些东西。我们说过函数必须在不同的 Goroutine 中异步执行,所以我们必须以某种方式同步这个测试,以防它结束得太快。同样,我们可以使用一个通道或 sync.WaitGroup

t.Run("Success result", func(t *testing.T) { 
 var wg sync.WaitGroup
 wg.Add(1) 
    future.Success(func(s string) { 
      t.Log(s) 

 wg.Done() 
    }).Fail(func(e error) { 
      t.Fail() 

 wg.Done() 
    }) 

    future.Execute(func() (string, error) { 
      return "Hello World!", nil 
    }) 
 wg.Wait() 
  }) 

我们之前在之前的频道中见过 WaitGroups。这个 WaitGroup 被配置为等待一个信号(wg.Add(1))。SuccessFail 方法将触发 WaitGroupDone() 方法,以允许执行继续并完成测试(这就是为什么 Wait() 方法在最后)。记住,每个 Done() 方法都会从 WaitGroup 中减去一个,而我们只添加了一个,所以我们的 Wait() 方法只会阻塞,直到一个 Done() 方法被执行。

利用我们对制作 Success 结果单元测试的了解,通过将错误调用 t.Fail() 方法从错误调用成功调用中交换,很容易制作一个失败的结果单元测试,这样如果调用成功,则测试失败:

t.Run("Failed result", func(t *testing.T) { 
 var wg sync.WaitGroup
 wg.Add(1)
 future.Success(func(s string) {
 t.Fail()
 wg.Done()
 }).Fail(func(e error) {
 t.Log(e.Error())
 wg.Done()
 })
 future.Execute(func() (string, error) {
 return "", errors.New("Error ocurred")
 })
 wg.Wait() 
}) 

如果你像我一样使用 IDE,你的 SuccessFailExecute 方法调用必须是红色的。这是因为我们在实现文件中缺少了方法的声明:

package future 

type SuccessFunc func(string) 
type FailFunc func(error) 
type ExecuteStringFunc func() (string, error) 

type MaybeString struct { 
  ... 
} 

func (s *MaybeString) Success(f SuccessFunc) *MaybeString { 
  return nil 
} 

func (s *MaybeString) Fail(f FailFunc) *MaybeString { 
  return nil 
} 

func (s *MaybeString) Execute(f ExecuteStringFunc) { 
  ... 
} 

我们的测试似乎已经准备好执行了。让我们试一下:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
testing.(*T).Run(0xc4200780c0, 0x5122e9, 0x19, 0x51d750, 0xc420041d30)
 /usr/lib/go/src/testing/testing.go:647 +0x316
testing.RunTests.func1(0xc4200780c0)
 /usr/lib/go/src/testing/testing.go:793 +0x6d
testing.tRunner(0xc4200780c0, 0xc420041e20)
 /usr/lib/go/src/testing/testing.go:610 +0x81
testing.RunTests(0x51d758, 0x5931e0, 0x1, 0x1, 0x50feb4)
 /usr/lib/go/src/testing/testing.go:799 +0x2f5
testing.(*M).Run(0xc420041ee8, 0xc420014550)
 /usr/lib/go/src/testing/testing.go:743 +0x85
main.main()
 go-design-patterns/future/_test/_testmain.go:54 +0xc6
...continue

嗯... 测试失败了,是的... 但不是以可控的方式。为什么呢?因为我们还没有任何实现,所以SuccessFail函数也没有被执行。我们的 WaitGroup 永远在等待调用Done()方法,但这个调用永远不会到来,所以它无法继续并完成测试。这就是所有 Goroutines 都在休眠-死锁的含义。在我们的具体例子中,这意味着没有人会调用 Done(),所以我们已经死了

注意

由于 Go 编译器和运行时执行器,我们可以轻松地检测死锁。想象一下,如果 Go 运行时无法检测死锁,我们将陷入一片空白的屏幕中,不知道出了什么问题。

那么我们该如何解决这个问题呢?嗯,一个简单的方法是使用超时,在等待一段时间后调用Done()方法。对于这段代码来说,等待 1 秒是安全的,因为它不执行长时间运行的操作。

我们将在我们的test文件中声明一个timeout函数,等待一秒,然后打印一条消息,将测试设置为失败,并通过调用其Done()方法让 WaitGroup 继续:

func timeout(t *testing.T, wg *sync.WaitGroup) { 
  time.Sleep(time.Second) 
  t.Log("Timeout!") 

  t.Fail() 
  wg.Done() 
} 

每个子测试的最终外观与我们之前的"Success result"示例类似:

t.Run("Success result", func(t *testing.T) { 
  var wg sync.WaitGroup 
  wg.Add(1) 

  //Timeout! 
  go timeout(t, wg) 
  // ... 
}) 

让我们看看当我们再次执行测试时会发生什么:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
--- FAIL: TestStringOrError_Execute (2.00s)
 --- FAIL: TestStringOrError_Execute/Success_result (1.00s)
 future_test.go:64: Timeout!
 --- FAIL: TestStringOrError_Execute/Failed_result (1.00s)
 future_test.go:64: Timeout!
FAIL
exit status 1
FAIL

我们的测试失败了,但是以一种可控的方式。看一下FAIL行的末尾--注意经过的时间是 1 秒,因为它被超时触发了,正如我们在日志消息中所看到的。

现在是时候转向实现了。

实现

根据我们的测试,实现必须以链式方式在MaybeString类型中接受SuccessFuncFailFuncExecuteStringFunc函数,并异步调用ExecuteStringFunc函数,根据返回的结果调用SuccessFuncFailFunc函数。

链式调用是通过在类型中存储函数并返回类型的指针来实现的。当然,我们谈论的是我们之前声明的类型方法:

type MaybeString struct { 
  successFunc SuccessFunc 
  failFunc    FailFunc 
} 

func (s *MaybeString) Success(f SuccessFunc) *MaybeString { 
  s.successFunc = f 
  return s 
} 

func (s *MaybeString) Fail(f FailFunc) *MaybeString { 
  s.failFunc = f 
  return s 
} 

我们需要两个字段来存储SuccessFuncFailFunc函数,它们分别被命名为successFuncfailFunc字段。这样,对SuccessFail方法的调用只是将它们的传入函数存储到我们的新字段中。它们只是返回特定MaybeString值的指针的设置器。这些类型方法接受MaybeString结构的指针,所以在func声明之后不要忘记在MaybeString上加上"*"。

Execute 方法接受ExecuteStringFunc方法并异步执行它。这似乎很简单,使用 Goroutine 就可以了,对吧?

func (s *MaybeString) Execute(f ExecuteStringFunc) { 
  go func(s *MaybeString) { 
    str, err := f() 
    if err != nil { 
      s.failFunc(err) 
    } else { 
      s.successFunc(str) 
    } 
  }(s) 
} 

看起来很简单,因为它确实很简单!我们启动了执行f方法(一个ExecuteStringFunc)的 Goroutine,并获取它的结果--可能是一个字符串,也可能是一个错误。如果存在错误,我们调用MaybeString结构中的failFunc字段。如果没有错误,我们调用successFunc字段。我们使用 Goroutine 来委托函数执行和错误处理,这样我们的 Goroutine 就不必自己处理它。

现在让我们运行单元测试:

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
--- PASS: TestStringOrError_Execute (0.00s)
 --- PASS: TestStringOrError_Execute/Success_result (0.00s)
 future_test.go:21: Hello World!
 --- PASS: TestStringOrError_Execute/Failed_result (0.00s)
 future_test.go:49: Error ocurred
PASS
ok 

太好了!看看执行时间现在几乎为零,所以我们的超时没有被执行(实际上,它们已经被执行了,但测试已经完成,它们的结果已经被说明)。

而且,现在我们可以使用我们的MaybeString类型来异步执行任何接受空参数并返回字符串或错误的函数。接受空参数的函数似乎有点无用,对吧?但是我们可以使用闭包将上下文引入到这种类型的函数中。

让我们编写一个setContext函数,它接受一个字符串作为参数,并返回一个ExecuteStringFunc方法,该方法返回前面的参数加上后缀Closure!

func setContext(msg string) ExecuteStringFunc { 
  msg = fmt.Sprintf("%d Closure!\n", msg) 

  return func() (string, error){ 
    return msg, nil 
  } 
} 

因此,我们可以编写一个使用这个闭包的新测试:

t.Run("Closure Success result", func(t *testing.T) { 
    var wg sync.WaitGroup 
    wg.Add(1) 
    //Timeout! 
    go timeout(t, &wg) 

    future.Success(func(s string) { 
      t.Log(s) 
      wg.Done() 
    }).Fail(func(e error) { 
      t.Fail() 
      wg.Done() 
    }) 
    future.Execute(setContext("Hello")) 
    wg.Wait() 
  }) 

setContext函数返回一个ExecuteStringFunc方法,它可以直接传递给Execute函数。我们使用一个我们知道会返回的任意文本来调用setContext函数。

让我们再次执行我们的测试。现在一切都要顺利进行!

go test -v .
=== RUN   TestStringOrError_Execute
=== RUN   TestStringOrError_Execute/Success_result
=== RUN   TestStringOrError_Execute/Failed_result
=== RUN   TestStringOrError_Execute/Closure_Success_result
--- PASS: TestStringOrError_Execute (0.00s)
 --- PASS: TestStringOrError_Execute/Success_result (0.00s)
 future_test.go:21: Hello World!
 --- PASS: TestStringOrError_Execute/Failed_result (0.00s)
 future_test.go:49: Error ocurred
 --- PASS: TestStringOrError_Execute/Closure_Success_result (0.00s)
 future_test.go:69: Hello Closure!
PASS
ok

它也给了我们一个 OK。闭包测试显示了我们之前解释的行为。通过取一个消息"Hello"并将其附加到其他内容("Closure!"),我们可以改变我们想要返回的文本的上下文。现在将其扩展到 HTTP GET调用,对数据库的调用,或者您可以想象的任何其他内容。它只需要以返回一个字符串或错误的方式结束。但是请记住,setContext函数中除了我们要返回的匿名函数之外的所有内容都不是并发的,并且在调用 execute 之前将异步执行,因此我们必须尽量将尽可能多的逻辑放在匿名函数中。

将未来放在一起

我们已经看到了通过使用函数类型系统来实现异步编程的好方法。然而,我们也可以通过设置一个具有SuccessFailExecute方法以及满足它们的类型的接口来实现,然后使用模板模式来异步执行它们,正如我们在本章中之前看到的那样。这取决于你!

Pipeline 设计模式

我们将在本章中看到的第三种模式是 Pipeline 模式。您将在并发结构中大量使用此模式,我们也可以认为它是最有用的模式之一。

描述

我们已经知道了管道是什么。每当我们编写任何执行一些逻辑的函数时,我们都在编写一个管道:如果这样,那么那样,否则其他。通过使用一些相互调用的函数,管道模式可以变得更加复杂。它们甚至可以在它们的执行中被循环。

Go 中的 Pipeline 模式以类似的方式工作,但 Pipeline 中的每个步骤都将在不同的 Goroutine 中进行,并且使用通道进行通信和同步。

目标

创建 Pipeline 时,我们主要寻求以下好处:

  • 我们可以创建一个多步算法的并发结构

  • 我们可以通过将算法分解为不同的 Goroutines 来利用多核机器的并行性

然而,仅仅因为我们将算法分解为不同的 Goroutines 并不一定意味着它会执行得最快。我们一直在谈论 CPU,所以理想情况下,算法必须是 CPU 密集型的,以利用并发结构。创建 Goroutines 和通道的开销可能会使算法变得更小。

并发多操作

我们将为我们的示例进行一些数学运算。我们将生成一个从 1 开始,以某个任意数字 N 结束的数字列表。然后我们将取每个数字,将其平方,并将结果数字求和到一个唯一的结果。因此,如果N=3,我们的列表将是[1,2,3]。将它们平方后,我们的列表变为[1,4,9]。如果我们对结果列表求和,得到的值是 14。

验收标准

从功能上讲,我们的 Pipeline 模式需要将每个数字提升到 2 的幂,然后将它们全部求和。它将被分为一个数字生成器和两个操作,因此:

  1. 从 1 到 N 生成一个列表,其中 N 可以是任何整数。

  2. 取出这个生成列表的每个数字并将其提升到 2 的幂。

  3. 将每个结果数字求和到最终结果并返回它。

从测试开始

我们将创建一个管理一切的函数。我们将称这个函数为LaunchPipeline以简化事情。它将以一个整数作为参数,这将是我们的 N 数字,列表中的项目数。在实现文件中的声明如下:

package pipelines 

func LaunchPipeline(amount int) int { 
  return 0 
} 

在我们的测试文件中,我们将使用一个切片的切片创建一个测试表:

package pipelines 

import "testing" 

func TestLaunchPipeline(t *testing.T) { 
  tableTest := [][]int{ 
    {3, 14}, 
    {5, 55}, 
  } 
  // ... 
} 

我们的表是一个整数类型的切片的切片。在每个切片上,第一个整数代表列表大小,第二个位置代表列表中的项。实际上,它是一个矩阵。当传入 3 时,它必须返回 14。当传入 5 时,它必须返回 55。然后,我们必须遍历表,并将每个数组的第一个索引传递给LaunchPipeline函数:

  // ... 

  var res int 
  for _, test := range tableTest { 
    res = LaunchPipeline(test[0]) 
    if res != test[1] { 
      t.Fatal() 
    } 

    t.Logf("%d == %d\n", res, test[1]) 
  } 
} 

使用range,我们得到矩阵中的每一行。每一行都包含在一个临时变量test中。test[0]代表Ntest[1]代表预期结果。我们将预期结果与LaunchPipeline函数的返回值进行比较。如果它们不相同,测试就失败了。

go test -v .
=== RUN   TestLaunchPipeline
--- FAIL: TestLaunchPipeline (0.00s)
 pipeline_test.go:15: 
FAIL
exit status 1
FAIL

实施

我们实现的关键是将每个操作分开放在不同的 Goroutine 中,并用通道连接它们。LaunchPipeline函数是协调它们所有的函数,如下图所示:

Implementation

这个操作包括三个步骤:生成一个数字列表,将它们提升到 2 的幂,并将结果相加。

这个管道模式中的每一步都有以下结构:

func functionName(in <-chan int) (<-chan int){ 
  out := make(chan bool, 100) 

  go func(){ 
    for v := range in { 
      // Do something with v and send it to channel out 
} 

close(out) 
   }() 

  return out 
} 

这个函数代表一个常见的步骤。让我们按照 Go 调度程序可能执行它的顺序来分解它:

  1. functionName函数通常会接收一个通道来获取值(in <-chan int)。我们称之为in函数,就像单词 incoming 一样。在这个函数的范围内,我们无法通过它发送值;这就是为什么箭头指向chan关键字的外部。

  2. functionName函数返回一个通道(<-chan in),函数调用者只能从中获取值(同样,箭头指向chan关键字的外部)。这也意味着通过该通道传递的任何值都必须在函数范围内生成。

  3. 在函数的第一行,我们创建了一个名为out的通道,它将成为函数的返回值(在这个列表中的第 2 个点)。

  4. 然后,我们将启动一个新的 Goroutine。它的范围将在返回此函数后进入,所以让我们继续。

  5. 我们返回先前创建的out通道。

  6. 最终,在执行函数并返回通道out之后,Goroutine 执行。它将从in通道中获取值,直到通道关闭。因此,这个函数的调用者负责关闭这个通道,否则 Goroutine 将永远不会结束!

  7. in通道关闭时,for 循环结束,我们关闭out通道。任何使用这个通道的 Goroutine 都不会再接收到新值,因为最后一个值已经发送了。

唯一不完全符合这种方法的步骤是第一步,它接收一个数字,代表列表上限,而不是一个传入值的通道。因此,如果我们为管道中的每一步编写这个操作,最终的图表看起来更像这样:

Implementation

尽管想法完全相同,但现在我们可以看到,实际上是LaunchPipeline函数将接收通道并将它们发送回管道中的下一步。使用这个图表,我们可以清楚地看到通过跟随箭头的数字来创建管道的流程。实线箭头表示函数调用,虚线箭头表示通道。

让我们更仔细地看一下代码。

列表生成器

操作的第一步是列表生成。列表从1开始,我们将接收一个代表上限的整数。我们必须将列表中的每个数字传递到下一步:

func generator(max int) <-chan int { 
  outChInt := make(chan int, 100) 

  go func() { 
    for i := 1; i <= max; i++ { 
      outChInt <- i 
    } 

    close(outChInt) 
  }() 
  return outChInt 
} 

正如我们之前提到的,这是我们在每个步骤中遵循的模式:创建一个通道,启动通过通道发送数据的 Goroutine,并立即返回通道。这个 Goroutine 将从 1 迭代到 max 参数,这是我们列表的最高阈值,并通过通道发送每个数字。在发送每个数字之后,通道被关闭,以便不能再通过它发送更多数据,但已经缓冲的数据可以被检索。

将数字提升到 2 的幂

第二步将从第一步的通道中接收每个传入的数字(从参数中获取)并将其提升到 2 的幂。每个结果都必须使用新的通道发送到第三步:

func power(in <-chan int) <-chan int { 
  out := make(chan int, 100) 

  go func() { 
    for v := range in { 
      out <- v * v 
    } 
    close(out) 
  }() 
  return out 
} 

我们再次使用相同的模式:创建一个通道并启动 Goroutine,同时返回创建的通道。

注意

for-range循环不断地从通道中获取值,直到通道关闭。

最终的归约操作

第三步也是最后一步,接收来自第二步的每个数字,并将它们添加到本地值,直到连接通道关闭:

func sum(in <-chan int) <-chan int { 
  out := make(chan int, 100) 
  go func() { 
    var sum int 

    for v := range in { 
      sum += v 
    } 

    out <- sum 
    close(out) 
  }()

  return out 
} 

sum 函数还接受一个通道作为参数(从步骤 2返回的通道)。它也遵循创建通道、启动 Goroutine 和返回通道的相同模式。Goroutine 不断将值添加到一个名为sum的变量,直到in通道关闭。当in通道关闭时,sum 的值被发送到out通道,并立即关闭。

启动管道模式

最后,我们可以实现LaunchPipeline函数:

func LaunchPipeline(amount int) int { 
  firstCh := generator(amount) 
  secondCh := power(firstCh) 
  thirdCh := sum(secondCh) 

  result := <-thirdCh 

  return result 
} 

函数generator首先返回传递给 power 函数的通道。power函数返回传递给sum函数的第二个通道。函数sum最终返回将接收唯一值(结果)的第一个通道。现在让我们尝试测试一下:

go test -v .
=== RUN   TestLaunchPipeline
--- PASS: TestLaunchPipeline (0.00s)
 pipeline_test.go:18: 14 == 14
 pipeline_test.go:18: 55 == 55
PASS
ok

太棒了!值得一提的是,LaunchPipeline函数不需要分配每个通道,并且可以重写如下:

func LaunchPipeline(amount int) int { 
  return <-sum(power(generator(amount))) 
} 

generator函数的结果直接传递给power函数,power的结果传递给sum函数。

管道模式的最后一句话

使用管道模式,我们可以以非常简单的方式创建非常复杂的并发工作流程。在我们的案例中,我们创建了一个线性工作流,但它也可以有条件、池和扇入和扇出行为。我们将在接下来的章节中看到其中一些。

总结

并发设计模式是难度上的一大步,并需要一些时间来掌握。作为并发程序员,我们最大的错误是以并行性的方式思考(我怎样才能并行?或者我怎样才能在一个新线程中运行?),而不是以并发结构的方式思考。

纯函数(在其范围之外不会影响任何东西的函数,将始终产生相同的输出(给定相同的输入))对此设计有所帮助。

并发编程需要练习和更多的练习。一旦你理解了基本的原语,Go 就会变得很容易。图表可以帮助你理解数据的可能流动,但理解一切的最好方法就是简单地练习。

在接下来的章节中,我们将看到如何使用管道工作池来执行一些工作,而不是使用唯一的管道。此外,我们将学习如何在并发结构中创建发布/订阅模式,并看到在使用并发构建相同模式时,相同模式可以有多大的不同。

第十章:并发模式 - 工作者池和发布/订阅设计模式

我们已经到达了本书的最后一章,在这里我们将讨论一些具有并发结构的模式。我们将详细解释每一步,以便您可以仔细跟随示例。

这个想法是学习如何在 Go 中设计并发应用程序的模式。我们大量使用通道和 Goroutines,而不是锁或共享变量。

  • 我们将看一种开发工作者池的方法。这对于控制执行中的 Goroutines 数量非常有用。

  • 第二个例子是对观察者模式的重写,我们在第七章中看到了,行为模式 - 访问者、状态、中介者和观察者设计模式,使用并发结构编写。通过这个例子,我们将更深入地了解并发结构,并看看它们与常规方法有何不同。

工作者池

我们可能会遇到的一个问题是,以前的一些并发方法的上下文是无限的。我们不能让一个应用程序创建无限数量的 Goroutines。Goroutines 很轻,但它们执行的工作可能非常繁重。工作者池帮助我们解决了这个问题。

描述

通过一组工作者,我们希望限制可用的 Goroutines 数量,以便更深入地控制资源池。通过为每个工作者创建一个通道,并使工作者处于空闲或繁忙状态,这很容易实现。这项任务可能看起来令人生畏,但实际上并不是。

目标

创建一个工作者池主要是关于资源控制:CPU、RAM、时间、连接等等。工作者池设计模式帮助我们做到以下几点:

  • 使用配额控制对共享资源的访问

  • 为每个应用程序创建有限数量的 Goroutines

  • 为其他并发结构提供更多的并行能力

一组管道

在上一章中,我们看到了如何使用管道。现在我们将启动有限数量的管道,以便 Go 调度器可以尝试并行处理请求。这里的想法是控制 Goroutines 的数量,在应用程序完成时优雅地停止它们,并使用并发结构最大化并行性,而不会出现竞争条件。

我们将使用的管道类似于我们在上一章中使用的管道,那里我们生成数字,将它们提升到 2 的幂,并求和最终结果。在这种情况下,我们将传递字符串,然后附加和前缀数据。

验收标准

从业务角度来看,我们希望有一些东西告诉我们,工作者已经处理了一个请求,有一个预定义的结尾,并且传入的数据被解析为大写:

  1. 当使用一个字符串值(任意)发出请求时,它必须是大写的。

  2. 一旦字符串变成大写,预定义的文本必须附加到它上面。这个文本不应该是大写的。

  3. 使用前面的结果,工作者 ID 必须添加到最终字符串的前缀。

  4. 结果字符串必须传递给预定义的处理程序。

我们还没有讨论如何在技术上实现,只是业务需求。通过整个描述,我们至少会有工作者、请求和处理程序。

实施

最开始是一个请求类型。根据描述,它必须包含将进入管道的字符串以及处理程序函数:

   // workers_pipeline.go file 
    type Request struct { 
          Data    interface{} 
          Handler RequestHandler 
    } 

“字符串”在哪里?我们有一个Data字段,类型为interface{},所以我们可以用它来传递一个字符串。通过使用接口,我们可以重用这种类型来传递一个string,一个int,或一个struct数据类型。接收者必须知道如何处理传入的接口。

“处理程序”字段的类型是“请求”处理程序,我们还没有定义:

type RequestHandler func(interface{}) 

请求处理程序是接受接口作为其第一个参数并返回空的任何函数。再次看到interface{},在这里我们通常会看到一个字符串。这是我们之前提到的接收器之一,我们需要用它来转换传入的结果。

因此,当发送请求时,我们必须在Data字段中填充一些值并实现处理程序;例如:

func NewStringRequest(s string, id int, wg *sync.WaitGroup) Request { 
    myRequest := Request{ 
        Data: "Hello", Handler: func(i interface{})
        { 
            defer wg.Done() 
            s, ok := i.(string) 
                if !ok{ 
                    log.Fatal("Invalid casting to string") 
                 } 
             fmt.Println(s) 
         } 
    } 
} 

处理程序是通过闭包定义的。我们再次检查接口的类型(并在最后推迟调用Done()方法)。如果接口不正确,我们只需打印其内容并返回。如果转换正确,我们也会打印它们,但这是我们通常会对操作结果做一些事情的地方;我们必须使用类型转换来检索interface{}的内容(这是一个字符串)。尽管这会引入一些开销,但这必须在管道的每一步中完成。

现在我们需要一个可以处理Request类型的类型。可能的实现方式几乎是无限的,因此最好先定义一个接口:

   // worker.go file 
    type WorkerLauncher interface { 
        LaunchWorker(in chan Request) 
    } 

WorkerLauncher接口只需实现LaunchWorker(chan Request)方法。任何实现此接口的类型都必须接收一个Request类型的通道来满足它。这种类型的Request通道是管道的唯一入口点。

调度程序

现在,为了并行启动工作程序并处理所有可能的传入通道,我们需要类似于调度程序的东西:

   // dispatcher.go file 
    type Dispatcher interface { 
        LaunchWorker(w WorkerLauncher) 
        MakeRequest(Request) 
        Stop() 
    } 

Dispatcher接口可以在其自己的LaunchWorker方法中启动注入的WorkerLaunchers类型。Dispatcher接口必须使用任何WorkerLauncher类型的LaunchWorker方法来初始化管道。这样我们就可以重用Dispatcher接口来启动许多类型的WorkerLaunchers

在使用MakeRequest(Request)时,Dispatcher接口公开了一个很好的方法,可以将新的Request注入到工作池中。

最后,用户必须在所有 Goroutines 都完成时调用 stop。我们必须在应用程序中处理优雅的关闭,并且我们希望避免 Goroutine 泄漏。

我们有足够的接口,所以让我们从稍微不那么复杂的调度程序开始:

    type dispatcher struct { 
        inCh chan Request 
    } 

我们的dispatcher结构在其字段中存储了一个Request类型的通道。这将是任何管道中请求的唯一入口点。我们说它必须实现三种方法,如下所示:

    func (d *dispatcher) LaunchWorker(id int, w WorkerLauncher) { 
        w.LaunchWorker(d.inCh) 
    } 

    func (d *dispatcher) Stop(){ 
        close(d.inCh) 
    } 

    func (d *dispatcher) MakeRequest(r Request) { 
        d.inCh <- r 
    } 

在这个例子中,Dispatcher接口在启动工作程序之前不需要对自身执行任何特殊操作,因此Dispatcher上的LaunchWorker方法只是执行传入WorkerLauncherLaunchWorker方法,后者也有一个LaunchWorker方法来初始化自身。我们之前已经定义了WorkerLauncher类型至少需要一个 ID 和一个传入请求的通道,所以这就是我们传递的内容。

Dispatcher接口中实现LaunchWorker方法似乎是不必要的。在不同的场景中,保存运行中的工作程序 ID 在调度程序中控制哪些工作程序正在运行或关闭可能是有趣的;这个想法是隐藏启动实现细节。在这种情况下,Dispatcher接口只是作为一个 Facade 设计模式,隐藏了一些实现细节。

第二种方法是Stop。它关闭了传入请求通道,引发了一连串的反应。我们在管道示例中看到,当关闭传入通道时,每个 Goroutines 中的 for-range 循环都会中断,并且 Goroutine 也会结束。在这种情况下,当关闭共享通道时,它会引发相同的反应,但是在每个监听 Goroutine 中,因此所有管道都将停止。酷,对吧?

请求的实现非常简单;我们只需将请求作为参数传递给传入请求的通道。Goroutine 将永远阻塞在那里,直到通道的另一端检索到请求。永远?如果发生了什么事情,这似乎很长。我们可以引入一个超时,如下所示:

    func (d *dispatcher) MakeRequest(r Request) { 
        select { 
        case d.inCh <- r: 
        case <-time.After(time.Second * 5): 
            return 
        } 
    } 

如果你还记得之前的章节,我们可以使用 select 来控制对通道的操作。就像 switch 语句一样,只能执行一个操作。在这种情况下,我们有两种不同的操作:发送和接收。

第一种情况是发送操作——尝试发送这个,它将在那里阻塞,直到有人在通道的另一侧取走值。然后并没有太大的改进。第二种情况是接收操作;如果无法成功发送大写请求,它将在 5 秒后触发,并返回。在这里返回错误会非常方便,但为了简单起见,我们将留空。

最后,在调度程序中,为了方便起见,我们将定义一个 Dispatcher 创建者:

    func NewDispatcher(b int) Dispatcher { 
        return &dispatcher{ 
            inCh:make(chan Request, b), 
        } 
    } 

通过使用这个函数而不是手动创建调度程序,我们可以简单地避免小错误,比如忘记初始化通道字段。正如你所看到的,b 参数指的是通道中的缓冲区大小。

流水线

所以,我们的调度程序已经完成,我们需要开发验收标准中描述的流水线。首先,我们需要一个类型来实现 WorkerLauncher 类型:

   // worker.go file 
    type PreffixSuffixWorker struct { 
        id int 
        prefixS string 
        suffixS string 
    } 

    func (w *PreffixSuffixWorker) LaunchWorker(i int, in chan Request) {} 

PreffixSuffixWorker 变量存储一个 ID,一个要添加的字符串,以及 Request 类型的传入数据的另一个字符串。因此,要添加的值和后缀将在这些字段中是静态的,并且我们将从这里获取它们。

我们将稍后实现 LaunchWorker 方法,并从流水线的每一步开始。根据第一个验收标准,传入的字符串必须是大写。因此,大写方法将是我们流水线中的第一步:

    func (w *PreffixSuffixWorker) uppercase(in <-chan Request) <-chan Request { 
        out := make(chan Request) 

        go func() { 
            for msg := range in { 
                s, ok := msg.Data.(string) 

                if !ok { 
                    msg.handler(nil) 
                    continue 
                } 

                msg.Data = strings.ToUpper(s) 

                out <- msg 
            } 

            close(out) 
        }() 

        return out 
    } 

很好。就像在前一章中一样,流水线中的一步接受传入数据的通道,并返回相同类型的通道。它的方法与我们在前一章中开发的示例非常相似。不过,这一次,我们没有使用包函数,大写是 PreffixSuffixWorker 类型的一部分,传入的数据是一个 struct 而不是一个 int

msg 变量是一个 Request 类型,它将具有一个处理函数和一个接口形式的数据。Data 字段应该是一个字符串,所以我们在使用它之前对其进行类型转换。当对值进行类型转换时,我们将收到请求类型的相同值和一个 truefalse 标志(由 ok 变量表示)。如果 ok 变量为 false,则转换无法完成,我们将不会将该值传递到流水线中。我们通过向处理程序发送 nil 来停止这个 Request(这也会引发类型转换错误)。

一旦我们在 s 变量中有一个好的字符串,我们就可以将其大写并再次存储在 Data 字段中,以便将其发送到流水线的下一步。请注意,该值将再次作为接口发送,因此下一步将需要再次对其进行转换。这是使用这种方法的缺点。

第一步完成后,让我们继续进行第二步。根据第二个验收标准,现在必须添加预定义的文本。这个文本是存储在 suffixS 字段中的文本:

func (w *PreffixSuffixWorker) append(in <-chan Request) <-chan Request { 
    out := make(chan Request) 
    go func() { 
        for msg := range in { 
        uppercaseString, ok := msg.Data.(string) 

        if !ok { 
            msg.handler(nil) 
            continue 
            } 
        msg.Data = fmt.Sprintf("%s%s", uppercaseString, w.suffixS) 
        out <- msg 
        } 
        close(out) 
    }() 
    return out 
} 

append 函数的结构与 uppercase 函数相同。它接收并返回一个传入请求的通道,并启动一个新的 Goroutine,该 Goroutine 遍历传入的通道直到它关闭。我们需要对传入的值进行类型转换,如前所述。

在流水线中的这一步中,传入的字符串是大写的(在进行类型断言后)。要附加任何文本,我们只需要使用 fmt.Sprintf() 函数,就像我们之前做过很多次一样,它使用提供的数据格式化一个新的字符串。在这种情况下,我们将 suffixS 字段的值作为第二个值传递,以将其附加到字符串的末尾。

流水线中只缺少最后一步,即前缀操作:

    func (w *PreffixSuffixWorker) prefix(in <-chan Request) { 
        go func() { 
            for msg := range in { 
                uppercasedStringWithSuffix, ok := msg.Data.(string) 

                if !ok { 
                    msg.handler(nil) 
                    continue 
                } 

                msg.handler(fmt.Sprintf("%s%s", w.prefixS, uppercasedStringWithSuffix)) 
            } 
        }() 
    } 

这个函数中吸引你注意力的是什么?是的,它现在不返回任何通道。我们可以以两种方式完成整个管道。我想你已经意识到我们使用了Future处理函数来执行管道中的最终结果。第二种方法是传递一个通道将数据返回到其原始位置。在某些情况下,Future 就足够了,而在其他情况下,将通道传递可能更方便,以便它可以连接到不同的管道(例如)。

无论如何,管道中的一步的结构对您来说应该已经非常熟悉了。我们对值进行转换,检查转换的结果,并在出现问题时向处理程序发送 nil。但是,如果一切顺利,最后要做的就是再次格式化文本,将prefixS字段放在文本开头,通过调用请求的处理程序将生成的字符串发送回原始位置。

现在,几乎完成了我们的工作程序,我们可以实现LaunchWorker方法:

    func (w *PreffixSuffixWorker) LaunchWorker(in chan Request) { 
        w.prefix(w.append(w.uppercase(in))) 
    } 

工作程序就是这些了!我们只需将返回的通道传递给管道中的下一步,就像我们在上一章中所做的那样。请记住,管道是从调用内部到外部执行的。那么,任何传入管道的数据的执行顺序是什么?

  1. 数据通过uppercase方法中启动的 Goroutine 进入管道。

  2. 然后,它进入了在append中启动的 Goroutine。

  3. 最后,它进入了在prefix方法中启动的 Goroutine,它不返回任何东西,但在给传入的字符串加上更多数据后执行处理程序。

现在我们有了一个完整的管道和一个管道的分发器。分发器将启动我们想要的管道实例,将传入的请求路由到任何可用的工作程序。

如果没有任何工作程序在 5 秒内接受请求,请求将丢失。

让我们在一个小应用程序中使用这个库。

使用工作程序池的应用程序

我们将启动我们定义的管道的三个工作程序。我们使用NewDispatcher函数创建分发器和接收所有请求的通道。该通道具有固定的缓冲区,可以在阻塞之前存储多达 100 条传入消息:

   // workers_pipeline.go 
    func main() { 
        bufferSize := 100 
        var dispatcher Dispatcher = NewDispatcher(bufferSize) 

然后,我们将通过在Dispatcher接口中三次调用LaunchWorker方法,并使用已填充的WorkerLauncher类型来启动工作程序:

    workers := 3 
    for i := 0; i < workers; i++ { 
        var w WorkerLauncher = &PreffixSuffixWorker{ 
            prefixS: fmt.Sprintf("WorkerID: %d -> ", i), 
            suffixS: " World", 
            id:i, 
        } 
        dispatcher.LaunchWorker(w) 
    } 

每个WorkerLauncher类型都是PreffixSuffixWorker的一个实例。前缀将是一个显示工作程序 ID 和后缀文本world的小文本。

此时,我们有三个工作程序,每个都有三个 Goroutines,同时运行并等待消息到达:

    requests := 10 

    var wg sync.WaitGroup 
    wg.Add(requests) 

我们将发出 10 个请求。我们还需要一个 WaitGroup 来正确同步应用程序,以免过早退出。在处理并发应用程序时,您可能会经常使用 WaitGroups。对于 10 个请求,我们需要等待 10 次对Done()方法的调用,因此我们使用delta为 10 调用Add()方法。它被称为 delta,因为您稍后也可以传递-5 以使其在五个请求中保持。在某些情况下,这可能很有用:

    for i := 0; i < requests; i++ { 
        req := NewStringRequest("(Msg_id: %d) -> Hello", i, &wg) 
        dispatcher.MakeRequest(req) 
    } 

    dispatcher.Stop() 

    wg.Wait() 
}

为了发出请求,我们将迭代一个for循环。首先,我们使用在实现部分开头编写的NewStringRequest函数创建一个Request。在这个值中,Data字段将是我们将通过管道传递的文本,它将是附加和后缀操作的“中间”文本。在这种情况下,我们将发送消息编号和单词hello

一旦我们有一个请求,我们就用它调用MakeRequest方法。完成所有请求后,我们停止分发器,如前所述,这将引发一个连锁反应,将停止管道中的所有 Goroutines。

最后,我们等待组,以便接收对Done()方法的所有调用,这表明所有操作都已完成。是时候试一试了:

 go run *
 WorkerID: 1 -> (MSG_ID: 0) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 3) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 4) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 5) -> HELLO World
 WorkerID: 2 -> (MSG_ID: 2) -> HELLO World
 WorkerID: 1 -> (MSG_ID: 1) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 6) -> HELLO World
 WorkerID: 2 -> (MSG_ID: 9) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 7) -> HELLO World
 WorkerID: 0 -> (MSG_ID: 8) -> HELLO World

让我们分析第一条消息:

  1. 这将是零,所以发送的消息是(Msg_id: 0) -> Hello

  2. 然后,文本被转换为大写,所以现在我们有(MSG_ID: 0) -> HELLO

  3. 在将文本world(注意文本开头的空格)转换为大写后进行附加操作。这将给我们文本(MSG_ID: 0) -> HELLO World

  4. 最后,文本WorkerID: 1(在这种情况下,第一个工作者接受了任务,但也可能是任何一个工作者)被附加到步骤 3 的文本中,给我们完整的返回消息,WorkerID: 1 -> (MSG_ID: 0) -> HELLO World

没有测试?

并发应用程序很难测试,特别是如果你正在进行网络操作。测试可能会很困难,代码可能会发生很大变化。无论如何,不进行测试是不可接受的。在这种情况下,测试我们的小应用并不特别困难。创建一个测试,将main函数的内容复制/粘贴到那里:

//workers_pipeline.go file 
package main 

import "testing" 

func Test_Dispatcher(t *testing.T){ 
    //pasted code from main function 
 bufferSize := 100
 var dispatcher Dispatcher = NewDispatcher(bufferSize)
 workers := 3
 for i := 0; i < workers; i++ 
    {
 var w WorkerLauncher = &PreffixSuffixWorker{
 prefixS: fmt.Sprintf("WorkerID: %d -> ", i), 
suffixS: " World", 
id: i,
}
 dispatcher.LaunchWorker(w)
 }
 //Simulate Requests
 requests := 10
 var wg 
    sync.WaitGroup
 wg.Add(requests) 
} 

现在我们必须重写我们的处理程序,以测试返回的内容是否符合我们的期望。转到for循环,修改我们作为每个Request处理程序传递的函数:

for i := 0; i < requests; i++ { 
    req := Request{ 
        Data: fmt.Sprintf("(Msg_id: %d) -> Hello", i), 
        handler: func(i interface{}) 
        { 
            s, ok := i.(string) 
            defer wg.Done() 
 if !ok 
            {
 t.Fail()
 }
 ok, err := regexp.Match(
`WorkerID\: \d* -\> \(MSG_ID: \d*\) -> [A-Z]*\sWorld`,
 []byte(s)) 
 if !ok || err != nil {
 t.Fail()
 } 
        }, 
    } 
    dispatcher.MakeRequest(req) 
} 

我们将使用正则表达式来测试业务。如果你不熟悉正则表达式,它们是一个非常强大的功能,可以帮助你在字符串中匹配内容。如果你还记得我们在练习中使用strings包时。Contains是用来在字符串中查找文本的函数。我们也可以用正则表达式来做。

问题在于正则表达式非常昂贵,消耗大量资源。

我们正在使用regexp包的Match函数来提供一个匹配模板。我们的模板是WorkerID\: \d* -> \(MSG_ID: \d\) -> [A-Z]*\sWorld(不带引号)。具体来说,它描述了以下内容:

  • 一个包含内容WorkerID: \d* -> (MSG_ID: \d*的字符串,这里的\d*表示任意数字写零次或多次,因此它将匹配WorkerID: 10 -> (MSG_ID: 1""WorkerID: 1 -> (MSG_ID: 10

  • "\) -> [A-Z]*\sWorld"(括号必须使用反斜杠进行转义)。"*"表示任意大写字母写零次或多次,所以"\s"是一个空格,它必须以文本World结束,所以) -> HELLO World"会匹配,但) -> Hello World"不会,因为"Hello必须全部大写。

运行这个测试给我们以下输出:

go test -v .
=== RUN   Test_Dispatcher
--- PASS: Test_Dispatcher (0.00s)
PASS
ok

不错,但我们没有测试代码是否在并发执行,所以这更像是业务测试而不是单元测试。并发测试会迫使我们以完全不同的方式编写代码,以检查它是否创建了适当数量的 Goroutines,并且流水线是否遵循了预期的工作流程。这并不是坏事,但它非常复杂,超出了本书的范围。

总结工作池

有了工作池,我们有了第一个可以在真实生产系统中使用的复杂并发应用程序。它还有改进的空间,但它是一个非常好的设计模式,可以构建并发有界应用程序。

非常重要的是,我们始终要控制正在启动的 Goroutines 的数量。虽然很容易启动成千上万个 Goroutines 来实现更多的并行性,但我们必须非常小心,确保它们没有代码会使它们陷入无限循环。

有了工作池,我们现在可以将一个简单操作分解为许多并行任务。想想看;这可以通过一次简单的fmt.Printf调用来实现相同的结果,但我们已经用它做了一个流水线;然后,我们启动了几个这样的流水线实例,最后,在所有这些管道之间分配了工作负载。

并发发布/订阅设计模式

在这一部分,我们将实现我们之前在行为模式中展示的观察者设计模式,但采用并发结构和线程安全。

描述

如果你还记得之前的解释,观察者模式维护了一个想要被通知特定事件的观察者或订阅者列表。在这种情况下,每个订阅者将在不同的 Goroutine 中运行,就像发布者一样。我们将在构建这个结构时遇到新的问题:

  • 现在,订阅者列表的访问必须是串行化的。如果我们用一个 Goroutine 读取列表,我们就不能从中删除一个订阅者,否则就会出现竞争。

  • 当一个订阅者被移除时,订阅者的 Goroutine 也必须被关闭,否则它将永远迭代下去,我们将遇到 Goroutine 泄漏的问题。

  • 当停止发布者时,所有订阅者也必须停止它们的 Goroutines。

目标

这个发布/订阅的目标与我们在观察者模式中写的目标相同。这里的区别在于我们将如何开发它。这个想法是创建一个并发结构来实现相同的功能,即:

  • 提供一个事件驱动的架构,其中一个事件可以触发一个或多个动作

  • 将执行的动作与触发它们的事件解耦

  • 提供多个触发相同动作的源事件

这个想法是解耦发送者和接收者,隐藏发送者处理其事件的接收者的身份,并隐藏接收者可以与之通信的发送者的数量。

特别是,如果我在某个应用程序的按钮上开发了一个点击,它可能会执行一些操作(比如在某处登录)。几周后,我们可能决定也让它显示一个弹出窗口。如果每次我们想要为这个按钮添加一些功能,我们都必须更改处理点击操作的代码,那么这个函数将变得非常庞大,而且对其他项目的可移植性也不是很好。如果我们为每个动作使用一个发布者和一个观察者,点击函数只需要使用一个发布者发布一个事件,每次我们想要改进功能时,我们只需要为这个事件编写订阅者。这在用户界面应用程序中尤为重要,因为在单个 UI 操作中要做的事情很多,可能会减慢界面的响应速度,完全破坏用户体验。

通过使用并发结构来开发观察者模式,如果定义了并发结构并且设备允许我们执行并行任务,UI 就无法感知到后台执行的所有任务。

示例 - 并发通知器

我们将开发一个类似于我们在第七章中开发的notifier行为模式 - 访问者、状态、中介者和观察者设计模式。这是为了专注于结构的并发性质,而不是详细说明已经解释过的太多东西。我们已经开发了一个观察者,所以我们对这个概念很熟悉。

这个特定的通知器将通过传递interface{}值来工作,就像在工作池示例中一样。这样,我们可以通过在接收器上进行转换来将其用于多个类型,引入一些开销。

现在我们将使用两个接口。首先是Subscriber接口:

    type Subscriber interface { 
        Notify(interface{}) error 
        Close() 
    } 

就像在之前的示例中一样,Subscriber接口必顶有一个Notify方法来通知新事件。这是接受interface{}值并返回错误的Notify方法。然而,Close()方法是新的,它必须触发停止订阅者正在监听新事件的 Goroutine 的任何操作。

第二个和最终的接口是Publisher接口:

    type Publisher interface { 
        start() 
        AddSubscriberCh() chan<- Subscriber 
        RemoveSubscriberCh() chan<- Subscriber 
        PublishingCh() chan<- interface{} 
        Stop() 
    } 

Publisher接口具有与发布者相同的操作,但是用于与通道一起工作。AddSubscriberChRemoveSubscriberCh方法接受Subscriber接口(满足Subscriber接口的任何类型)。它必须有一种发布消息的方法和一个Stop方法来停止它们所有(发布者和订阅者 Goroutines)

验收标准

这个例子和第七章中的例子之间的要求,行为模式 - 访问者,状态,中介者和观察者设计模式不得更改。在这两个例子中,目标是相同的,因此要求也必须相同。在这种情况下,我们的要求是技术性的,因此我们实际上需要添加一些更多的验收标准:

  1. 我们必须有一个带有PublishingCh方法的发布者,该方法返回一个通道以发送消息,并在每个订阅的观察者上触发Notify方法。

  2. 我们必须有一种方法来向发布者添加新的订阅者。

  3. 我们必须有一种方法来从发布者中移除新的订阅者。

  4. 我们必须有一种方法来停止订阅者。

  5. 我们必须有一种方法来停止Publisher接口,这也将停止所有订阅者。

  6. 所有 Goroutine 之间的通信必顶是同步的,以便没有 Goroutine 被锁定等待响应。在这种情况下,超时后将返回错误。

嗯,这些标准似乎相当令人生畏。我们忽略了一些要求,这些要求将增加更多的复杂性,例如删除不响应的订阅者或检查以确保发布者 Goroutine 始终处于活动状态。

单元测试

我们之前提到测试并发应用程序可能很困难。通过正确的机制,仍然可以完成,因此让我们看看在没有大麻烦的情况下我们可以测试多少。

测试订阅者

从订阅者开始,它似乎具有更加封装的功能,第一个订阅者必须将来自发布者的传入消息打印到io.Writer接口。我们已经提到订阅者具有一个接口,其中包含两种方法,Notify(interface{}) errorClose()方法:

    // writer_sub.go file 
    package main 

    import "errors" 

    type writerSubscriber struct { 
        id int 
        Writer io.Writer 
    } 

    func (s *writerSubscriber) Notify(msg interface{}) error { 
        return erorrs.NeW("Not implemented yet") 
    } 
    func (s *writerSubscriber) Close() {} 

好的。这将是我们的writer_sub.go文件。创建相应的测试文件,称为writer_sub_test.go文件:

    package main 
    func TestStdoutPrinter(t *testing.T) { 

现在,我们面临的第一个问题是功能打印到stdout,因此没有返回值可供检查。我们可以用三种方式解决它:

  • 捕获stdout方法。

  • 注入一个io.Writer接口进行打印。这是首选解决方案,因为它使代码更易管理。

  • stdout方法重定向到不同的文件。

我们将采用第二种方法。重定向也是一种可能性。os.Stdout是指向os.File类型的指针,因此它涉及用我们控制的文件替换此文件,并从中读取:

    func TestWriter(t *testing.T) { 
        sub := NewWriterSubscriber(0, nil) 

NewWriterSubscriber订阅者尚未定义。它必须帮助创建特定的订阅者,返回一个满足Subscriber接口的类型,因此让我们快速在writer_sub.go文件中声明它:

    func NewWriterSubscriber(id int, out io.Writer) Subscriber { 
        return &writerSubscriber{} 
    } 

理想情况下,它必须接受一个 ID 和一个io.Writer接口作为其写入的目的地。在这种情况下,我们需要一个自定义的io.Writer接口进行测试,因此我们将在writer_sub_test.go文件中为其创建一个mockWriter

    type mockWriter struct { 
        testingFunc func(string) 
    } 

    func (m *mockWriter) Write(p []byte) (n int, err error) { 
        m.testingFunc(string(p)) 
        return len(p), nil 
    } 

mockWriter结构将接受testingFunc作为其字段之一。这个testingFunc字段接受一个代表写入到mockWriter结构的字节的字符串。为了实现io.Writer接口,我们需要定义一个Write([]byte) (int, error)方法。在我们的定义中,我们将p的内容作为字符串传递(请记住,我们总是需要在每个Write方法中返回读取的字节和错误,或者不需要返回)。这种方法将testingFunc的定义委托给测试的范围。

我们将在 Subcriber 接口上调用 Notify 方法,它必须像 mockWriter 结构一样写入 io.Writer 接口。因此,在调用 Notify 方法之前,我们将定义 mockWriter 结构的 testingFunc

    // writer_sub_test.go file 
    func TestPublisher(t *testing.T) { 
        msg := "Hello" 

        var wg sync.WaitGroup 
        wg.Add(1) 

        stdoutPrinter := sub.(*writerSubscriber) 
        stdoutPrinter.Writer = &mockWriter{ 
            testingFunc: func(res string) { 
                if !strings.Contains(res, msg) { 
                    t.Fatal(fmt.Errorf("Incorrect string: %s", res)) 
                } 
                wg.Done() 
            }, 
        } 

我们将发送 Hello 消息。这也意味着无论 Subscriber 接口做什么,它最终都必须在提供的 io.Writer 接口上打印 Hello 消息。

因此,如果最终我们在测试函数中收到一个字符串,我们将需要与 Subscriber 接口同步,以避免测试中的竞争条件。这就是为什么我们使用了这么多的 WaitGroup。这是一种非常方便和易于使用的类型,用于处理这种情况。一个 Notify 方法调用将需要等待一个 Done() 方法的调用,因此我们调用 Add(1) 方法(一个单位)。

理想情况下,NewWriterSubscriber 函数必须返回一个接口,因此我们需要对我们在测试中使用的类型进行类型断言,即 stdoutPrinter 方法。我故意在进行转换时省略了错误检查,只是为了简化事情。一旦我们有了 writerSubscriber 类型,我们就可以访问其 Write 字段,将其替换为 mockWriter 结构。我们本可以直接在 NewWriterSubscriber 函数上传递一个 io.Writer 接口,但这样我们就无法覆盖传递空对象并将 os.Stdout 实例设置为默认值的情况。

因此,测试函数最终将接收一个包含订阅者写入内容的字符串。我们只需要检查接收到的字符串,即 Subscriber 接口将接收到的字符串,是否在某个时刻打印了单词 Hello,而 strings.Contains 函数最适合这种情况。一切都在测试函数的范围内定义,因此我们可以使用 t 对象的值来表示测试失败。

一旦我们完成了检查,我们必须调用 Done() 方法来表示我们已经测试了预期的结果:

err := sub.Notify(msg) 
if err != nil { 
    t.Fatal(err) 
    } 

    wg.Wait() 
    sub.Close() 
} 

我们实际上必须调用 NotifyWait 方法,以便调用 Done 方法来检查一切是否正确。

注意

您是否意识到我们在测试中定义了行为,更多或更少是相反的?这在并发应用程序中非常常见。有时可能会令人困惑,因为如果我们无法线性地跟踪调用,就很难知道函数可能在做什么,但您会很快习惯。与其思考“它这样做,然后这样做,然后那样做”,不如思考“在执行那个时会调用这个”。这也是因为在并发应用程序中,执行顺序在某一点之前是未知的,除非我们使用同步原语(如 WaitGroups 和通道)在某些时刻暂停执行。

现在让我们执行此类型的测试:

go test -cover -v -run=TestWriter .
=== RUN   TestWriter
--- FAIL: TestWriter (0.00s)
 writer_sub_test.go:40: Not implemented yet
FAIL
coverage: 6.7% of statements
exit status 1
FAIL

它退出得很快,但失败了。实际上,Done() 方法的调用尚未执行,因此最好将我们测试的最后部分更改为这样:

err := sub.Notify(msg)
if err != nil {
 wg.Done()
t.Error(err)
 }
 wg.Wait()
sub.Close()
 } 

现在,它不会停止执行,因为我们调用 Error 函数而不是 Fatal 函数,但我们调用 Done() 方法,测试在我们希望的位置结束,即在调用 Wait() 方法之后。您可以尝试再次运行测试,但输出将是相同的。

测试发布者

我们已经看到了 Publisher 接口和将满足其条件的类型,即 publisher 类型。我们唯一确定的是它将需要一些存储订阅者的方式,因此它至少会有一个 Subscribers 切片:

    // publisher.go type 
    type publisher struct { 
        subscribers []Subscriber 
    } 

为了测试 publisher 类型,我们还需要一个 Subscriber 接口的模拟:

    // publisher_test.go 
    type mockSubscriber struct { 
        notifyTestingFunc func(msg interface{}) 
        closeTestingFunc func() 
    } 

    func (m *mockSubscriber) Close() { 
        m.closeTestingFunc() 
    } 

    func (m *mockSubscriber) Notify(msg interface{}) error { 
        m.notifyTestingFunc(msg) 
        return nil 
    } 

mockSubscriber 类型必须实现 Subscriber 接口,因此它必须有 Close()Notify(interface{}) error 方法。我们可以嵌入一个已实现它的现有类型,比如 writerSubscriber,并且只覆盖我们感兴趣的方法,但我们需要定义两者,所以我们不会嵌入任何东西。

因此,在这种情况下,我们需要重写NotifyClose方法,以调用mockSubscriber类型字段上存储的测试函数。

    func TestPublisher(t *testing.T) { 
        msg := "Hello" 

        p := NewPublisher() 

首先,我们将直接通过通道发送消息,这可能会导致潜在的意外死锁,因此首先要定义一个用于这种情况的 panic 处理程序,比如,向关闭的通道发送消息或没有 Goroutines 在通道上监听。我们将发送给订阅者的消息是Hello。因此,通过使用AddSubscriberCh方法返回的通道接收到的每个订阅者都必须接收到这条消息。我们还将使用一个New函数来创建发布者,称为NewPublisher。现在更改publisher.go文件来编写它。

   // publisher.go file 
    func NewPublisher() Publisher { 
        return &publisher{} 
    } 

现在我们将定义mockSubscriber,并将其添加到已知订阅者列表中。回到publisher_test.go文件。

        var wg sync.WaitGroup 

        sub := &mockSubscriber{ 
            notifyTestingFunc: func(msg interface{}) { 
                defer wg.Done() 

                s, ok := msg.(string) 
                if !ok { 
                    t.Fatal(errors.New("Could not assert result")) 
                } 

                if s != msg { 
                    t.Fail() 
                } 
            }, 
            closeTestingFunc: func() { 
                wg.Done() 
            }, 
        } 

像往常一样,我们从一个 WaitGroup 开始。首先,在我们的订阅者中测试函数会在执行结束时延迟调用Done()方法。然后它需要对msg变量进行类型转换,因为它是作为接口传递的。记住,这样一来,我们可以通过引入类型断言的开销,使用Publisher接口与许多类型。这是在第s, ok := msg.(string)行完成的。

一旦我们将msg强制转换为字符串s,我们只需要检查订阅者接收到的值是否与我们发送的值相同,如果不同则测试失败。

        p.AddSubscriberCh() <- sub 
        wg.Add(1) 

        p.PublishingCh() <- msg 
        wg.Wait() 

我们使用AddSubscriberCh方法添加mockSubscriber类型。在准备就绪后,我们通过将WaitGroup加一来发布我们的消息,并在将WaitGroup设置为等待之前发布消息,这样测试就不会继续进行,直到mockSubscriber类型调用Done()方法。

此外,我们需要检查在调用AddSubscriberCh方法后Subscriber接口的数量是否增加,因此我们需要在测试中获取发布者的具体实例。

        pubCon := p.(*publisher) 
        if len(pubCon.subscribers) != 1 { 
            t.Error("Unexpected number of subscribers") 
        } 

类型断言是我们今天的朋友!一旦我们有了具体类型,我们就可以访问Publisher接口的基础订阅者切片。调用AddSubscriberCh方法后,订阅者的数量必须为 1,否则测试将失败。下一步是检查相反的情况--当我们移除一个Subscriber接口时,它必须从这个列表中被移除。

   wg.Add(1) 
   p.RemoveSubscriberCh() <- sub 
   wg.Wait() 

   //Number of subscribers is restored to zero 
   if len(pubCon.subscribers) != 0 { 
         t.Error("Expected no subscribers") 
   } 

   p.Stop() 
}  

我们测试的最后一步是停止发布者,这样就不能再发送消息,所有的 Goroutines 都会停止。

测试已经完成,但在publisher类型实现了所有方法之前我们无法运行测试;这必须是最终结果。

    type publisher struct { 
        subscribers []Subscriber 
        addSubCh    chan Subscriber 
        removeSubCh chan Subscriber 
        in          chan interface{} 
        stop        chan struct{} 
    } 

    func (p *publisher) AddSubscriberCh() chan<- Subscriber { 
        return nil 
    } 

    func (p *publisher) RemoveSubscriberCh() chan<- Subscriber { 
        return nil 
    } 

    func (p *publisher) PublishingCh() chan<- interface{} { 
        return nil 
    } 

    func (p *publisher) Stop(){} 

有了这个空实现,当运行测试时就不会发生什么好事。

go test -cover -v -run=TestPublisher .
atal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
testing.(*T).Run(0xc0420780c0, 0x5244c6, 0xd, 0x5335a0, 0xc042037d20)
 /usr/local/go/src/testing/testing.go:647 +0x31d
testing.RunTests.func1(0xc0420780c0)
 /usr/local/go/src/testing/testing.go:793 +0x74
testing.tRunner(0xc0420780c0, 0xc042037e10)
 /usr/local/go/src/testing/testing.go:610 +0x88
testing.RunTests(0x5335b8, 0x5ada40, 0x2, 0x2, 0x40d7e9)
 /usr/local/go/src/testing/testing.go:799 +0x2fc
testing.(*M).Run(0xc042037ed8, 0xc04200a4f0)
 /usr/local/go/src/testing/testing.go:743 +0x8c
main.main()
 go-design-patterns/concurrency_3/pubsub/_test/_testmain.go:56 +0xcd
goroutine 5 [chan send (nil chan)]:
go-design-patterns/concurrency_3/pubsub.TestPublisher(0xc042078180)
 go-design-patterns/concurrency_3/pubsub/publisher_test.go:55 +0x372
testing.tRunner(0xc042078180, 0x5335a0)
 /usr/local/go/src/testing/testing.go:610 +0x88
created by testing.(*T).Run
 /usr/local/go/src/testing/testing.go:646 +0x2f3
exit status 2
FAIL  go-design-patterns/concurrency_3/pubsub   1.587s

是的,它失败了,但这根本不是一个受控的失败。这是故意做的,以展示在 Go 中需要注意的一些事情。首先,这个测试产生的错误是一个fatal错误,通常指向代码中的一个 bug。这很重要,因为虽然panic错误可以被恢复,但对于致命错误却不能做同样的事情。

在这种情况下,错误告诉我们问题所在:goroutine 5 [chan send (nil chan)],一个nil通道,所以这实际上是我们代码中的一个错误。我们该如何解决这个问题呢?这也很有趣。

我们有一个nil通道的事实是由我们编写的代码导致的,用于编译单元测试,但一旦编写了适当的代码,就不会引发这种特定的错误(因为在这种情况下我们永远不会返回一个nil通道)。我们可以返回一个从未使用的通道,这会导致死锁的致命错误,这也不会有任何进展。

一种解决方法是返回一个通道和一个错误,这样你就可以有一个错误包,其中包含一个实现了Error接口的类型,返回一个特定的错误,比如NoGoroutinesListeningChannelNotCreated。我们已经看到了许多这样的实现,所以我们将把这些留给读者作为练习,然后我们将继续保持对本章并发性质的关注。

没有什么令人惊讶的,所以我们可以进入实施阶段。

实施

回顾一下,writerSubscriber必须接收它将写入的消息,并将其写入满足io.Writer接口的类型。

那么,我们从哪里开始呢?嗯,每个订阅者将运行自己的 Goroutine,而我们已经知道与 Goroutine 通信的最佳方法是使用通道。因此,我们将需要在Subscriber类型中使用一个带有通道的字段。我们可以使用与管道中相同的方法来结束NewWriterSubscriber函数和writerSubscriber类型:

    type writerSubscriber struct { 
        in     chan interface{} 
        id     int 
        Writer io.Writer 
    } 

    func NewWriterSubscriber(id int, out io.Writer) Subscriber { 
        if out == nil { 
            out = os.Stdout 
        } 

        s := &writerSubscriber{ 
            id:     id, 
            in:     make(chan interface{}), 
            Writer: out, 
        } 

        go func(){ 
            for msg := range s.in { 
                fmt.Fprintf(s.Writer, "(W%d): %v\n", s.id, msg) 
            } 
        }() 

        return s 
    } 

在第一步中,如果没有指定写入器(out参数为 nil),默认的io.Writer接口是stdout。然后,我们使用传递给第一个参数的 ID、out 的值(os.Stdout,或者如果不为 nil,则是传入参数中的值),以及一个名为 in 的通道创建了一个新的指向writerSubscriber类型的指针,以保持与之前示例中相同的命名。

然后我们启动一个新的 Goroutine;这是我们提到的启动机制。就像在管道中一样,订阅者将在每次接收到新消息时迭代in通道,并将其内容格式化为一个字符串,其中还包含当前订阅者的 ID。

正如我们之前学到的那样,如果in通道关闭,for range循环将停止,那个特定的 Goroutine 将结束,所以Close方法中唯一需要做的事情就是实际关闭in通道:

    func (s *writerSubscriber) Close() { 
        close(s.in) 
    } 

好的,只剩下Notify方法了;Notify方法是管理特定行为的便捷方法,我们将使用一个在许多调用中常见的模式:

    func (s *writerSubscriber) Notify(msg interface{}) (err error) { 
        defer func(){ 
            if rec := recover(); rec != nil { 
                err = fmt.Errorf("%#v", rec) 
            } 
        }() 

        select { 
        case s.in <- msg: 
        case <-time.After(time.Second): 
            err = fmt.Errorf("Timeout\n") 
        } 

        return 
    } 

在与通道通信时,通常有两种行为需要控制:一种是等待时间,另一种是通道关闭时的行为。延迟函数实际上适用于函数内部可能发生的任何恐慌错误。如果 Goroutine 发生恐慌,它仍将使用recover()方法执行延迟函数。recover()方法返回一个接口,无论错误是什么,所以在我们的情况下,我们将返回变量错误设置为recover返回的格式化值(这是一个接口)。"%#v"参数在将任何类型格式化为字符串时为我们提供了大部分信息。返回的错误会很丑陋,但它将包含我们可以提取的大部分关于错误的信息。例如,对于关闭的通道,它将返回"send on a closed channel"。嗯,这似乎足够清楚了。

第二条规则是关于等待时间。当我们通过通道发送一个值时,我们将被阻塞,直到另一个 Goroutine 从中取出该值(填充的缓冲通道也是如此)。我们不希望永远被阻塞,所以我们通过使用 select 处理程序设置了一秒的超时期。简而言之,使用 select 我们在说:要么你在 1 秒内取出值,要么我将丢弃它并返回一个错误。

我们有CloseNotifyNewWriterSubscriber方法,所以我们可以再次尝试我们的测试:

go test -run=TestWriter -v .
=== RUN   TestWriter
--- PASS: TestWriter (0.00s)
PASS
ok

现在好多了。Writer已经接收了我们在测试中编写的模拟写入器,并向其传递了我们传递给Notify方法的值。同时,由于Notify方法在调用Close方法后返回了一个错误,所以close可能已经有效地关闭了通道。需要提到的一件事是,我们无法在不与其交互的情况下检查通道是否关闭;这就是为什么我们必须推迟执行一个将检查Notify方法中recover()函数的内容的闭包的执行。

实施发布者

好的,发布者还需要一个启动机制,但要处理的主要问题是访问订阅者列表时的竞争条件。我们可以使用sync包中的 Mutex 对象来解决这个问题,但我们已经知道如何使用它,所以我们将改用通道。

在使用通道时,我们将需要为每个可能被视为危险的操作创建一个通道——添加订阅者、移除订阅者、检索订阅者列表以通知Notify方法发送消息,以及一个用于停止所有订阅者的通道。我们还需要一个用于传入消息的通道:

    type publisher struct { 
        subscribers []Subscriber 
        addSubCh    chan Subscriber 
        removeSubCh chan Subscriber 
        in          chan interface{} 
        stop        chan struct{} 
    } 

名称是自描述的,但简而言之,订阅者维护订阅者列表;这是需要多路访问的切片。addSubCh实例是与之通信的通道,当您想要添加新的订阅者时,就会使用它;这就是为什么它是一个订阅者的通道。相同的解释也适用于removeSubCh通道,但这个通道是用来移除订阅者的。in通道将处理必须广播给所有订阅者的传入消息。最后,当我们想要终止所有 Goroutines 时,必须调用 stop 通道。

好的,让我们从AddSubscriberChRemoveSubscriberPublishingCh方法开始,这些方法必须返回通道以添加和移除订阅者以及向所有订阅者发送消息的通道:

    func (p *publisher) AddSubscriber() { 
        return p.addSubCh 
    } 

    func (p *publisher) RemoveSubscriberCh() { 
        return p.removeSubCh 
    } 

    func (p *publisher) PublishMessage(){ 
        return p.in 
    } 

Stop()函数通过关闭stop通道来停止它。这将有效地向每个正在监听的 Goroutine 传播信号:

func (p *publisher) Stop(){ 
  close(p.stop) 
} 

Stop方法,用于停止发布者和订阅者,也会推送到其相应的通道,称为 stop。

也许你会想知道为什么我们不直接保留通道,让用户直接向该通道推送消息,而不使用代理函数。嗯,这样做的想法是,集成库到他们的应用程序中的用户不必处理与库相关的并发结构的复杂性,因此他们可以专注于他们的业务,同时尽可能地提高性能。

处理通道而不产生竞争条件。

到目前为止,我们已经将数据转发到发布者的通道上,但实际上我们还没有处理任何数据。将启动不同的 Goroutine 的机制将处理它们所有。

我们将创建一个launch方法,通过使用go关键字来执行它,而不是将整个函数嵌入NewPublisher函数中:

func (p *publisher) start() { 
  for { 
    select { 
    case msg := <-p.in: 
      for _, ch := range p.subscribers { 
        sub.Notify(msg) 
      } 

Launch是一个私有方法,我们还没有测试过它。请记住,私有方法通常是从公共方法(我们已经测试过的方法)中调用的。通常情况下,如果一个私有方法没有从公共方法中调用,它就不能被调用!

使用这种方法时,我们首先注意到的是一个无限循环,它将在许多通道之间重复执行选择操作,但每次只能执行其中一个。这些操作中的第一个是接收要发布给订阅者的新消息。case msg := <- p.in: 代码处理这个传入操作。

在这种情况下,我们正在迭代所有订阅者并执行它们的Notify方法。也许你会想知道为什么我们不在前面加上go关键字,以便Notify方法作为一个不同的 Goroutine 执行,因此迭代速度更快。嗯,这是因为我们没有对接收消息和关闭消息的操作进行解复用。因此,如果我们在新的 Goroutine 中启动订阅者,并且在Notify方法中处理消息时关闭了它,我们将会出现竞争条件,即消息将尝试在Notify方法中发送到一个关闭的通道。事实上,当我们开发Notify方法时,我们考虑到了这种情况,但是,如果我们每次在新的 Goroutine 中调用Notify方法,我们就无法控制启动的 Goroutines 数量。为简单起见,我们只是调用Notify方法,但是控制在Notify方法执行中等待返回的 Goroutines 数量是一个很好的练习。通过在每个订阅者中缓冲in通道,我们也可以实现一个很好的解决方案:

    case sub := <-p.addSubCh: 
    p.subscribers = append(p.subscribers, sub) 

下一个操作是当一个值到达通道以添加订阅者时该怎么办。在这种情况下很简单:我们更新它,将新值附加到它上。在执行此案例时,不能执行其他调用:

     case sub := <-p.removeSubCh: 
     for i, candidate := range p.subscribers { 
         if candidate == sub { 
             p.subscribers = append(p.subscribers[:i], p.subscribers[i+1:]...) 
             candidate.Close() 
             break 
        } 
    } 

当一个值到达移除通道时,操作会变得更加复杂,因为我们必须在切片中搜索订阅者。我们使用了O(N)的方法,从开头开始迭代直到找到它,但搜索算法可以得到很大的改进。一旦我们找到相应的Subscriber接口,我们就将其从订阅者切片中移除并停止它。需要提到的一件事是,在测试中,我们直接访问订阅者切片的长度,而不进行多路复用操作。这显然是一种竞争条件,但通常在运行竞争检测器时不会反映出来。

解决方案将是开发一种方法,只是为了多路复用调用以获取切片的长度,但它不会属于公共接口。再次,为了简单起见,我们将保持现状,否则这个例子可能会变得太复杂而难以处理:

    case <-p.stop: 
    for _, sub := range p.subscribers { 
        sub.Close() 
            } 

        close(p.addSubCh) 
        close(p.in) 
        close(p.removeSubCh) 

        return 
        } 
    } 
} 

最后一个需要多路复用的操作是stop操作,它必须停止发布者和订阅者中的所有 Goroutines。然后,我们必须遍历存储在订阅者字段中的每个订阅者,执行它们的Close()方法,以便关闭它们的 Goroutines。最后,如果我们返回这个 Goroutine,它也会结束。

好的,是时候执行所有测试,看看情况如何:

go test -race .
ok

还不错。所有测试都成功通过了,我们的观察者模式已经准备就绪。虽然这个例子仍然可以改进,但它是一个很好的例子,展示了我们如何使用 Go 中的通道来处理观察者模式。作为练习,我们鼓励您尝试使用互斥锁而不是通道来控制访问,这样做会更容易,也会让您了解如何使用互斥锁。

关于并发观察者模式的几句话

这个例子演示了如何利用多核 CPU 来构建一个并发消息发布者,通过实现观察者模式。虽然例子很长,但我们试图展示在使用 Go 开发并发应用程序时的常见模式。

总结

我们已经看到了一些开发并发结构的方法,可以并行运行。我们试图展示解决同一个问题的几种方式,一种是没有并发原语,另一种是有并发原语。我们已经看到了使用并发结构编写的发布/订阅示例与经典示例相比有多么不同。

我们还看到了如何使用管道构建并发操作,并通过使用工作池来并行化,这是一种最大化并行性的常见 Go 模式。

这两个例子都足够简单,可以理解,同时尽可能深入了解 Go 语言的本质,而不是问题本身。

posted @ 2024-05-04 22:35  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报