Go-设计模式(一)

123 阅读58分钟

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 中进行并发编程,以及如何使用并发方法来创建一些更为关键的设计模式。