Go-现实世界项目的设计模式-全-

Go 现实世界项目的设计模式(全)

原文:zh.annas-archive.org/md5/ed73371609da148c0306f0e1d33f99c6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Go 编程语言已经稳固地确立了自己在构建复杂和可扩展的系统应用程序中的首选地位。Go 提供了一种直接且实用的编程方法,让程序员能够使用并发惯用和功能齐全的标准库编写正确且可预测的代码。

本学习路径涵盖内容

模块 1,学习 Go 编程,是一本循序渐进、实用的指南,充满了真实世界的示例,帮助您迅速开始使用 Go。我们首先理解 Go 的基础知识,然后详细描述 Go 数据类型、程序结构和 Maps。在此之后,您将学习如何使用 Go 并发惯用避免陷阱,并创建行为精确的程序。接下来,您将熟悉 Go 中可用于编写和执行测试、基准测试和代码覆盖率的工具和库。

最后,您将能够利用 GO 的一些最重要的功能,例如网络编程和操作系统集成,来构建高效的应用程序。所有概念都解释得清晰简洁,到本模块结束时,您将能够创建高度高效且可部署到云上的程序。

模块 2,Go 设计模式,将为读者提供一个参考点,了解软件设计模式和 CSP 并发设计模式,帮助他们以更地道、健壮和方便的方式在 Go 中构建应用程序。

本模块从对 Go 编程基本知识的简要介绍开始,迅速过渡到解释设计模式背后的理念以及它们如何在 90 年代作为开发者之间解决面向对象编程语言中常见任务的通用“语言”出现。然后,您将学习如何在 Go 中应用 23 种设计模式(GoF),并了解 CSP 并发模式,这是 Go 的“杀手级特性”,它帮助谷歌开发了维护数千台服务器的软件。

通过所有这些,本模块将使您能够以地道的方式理解和应用设计模式,从而产生简洁、可读性和可维护的软件。

模块 3*, Go 编程蓝图 - 第二版,* 将向您展示如何利用所有最新的特性和更多内容。本模块将向您展示如何构建强大的系统,并将您带入现实世界的情况。您将学习开发高质量的命令行工具,这些工具利用了强大的 shell 功能,并使用 Go 内置的并发机制表现出色。规模、性能和高可用性是我们项目的核心,本模块中学到的经验将使您拥有构建世界级解决方案所需的一切。您将了解使用 Docker 和 Google App Engine 进行应用程序部署的感觉。每个项目都可能成为创业的基础,这意味着它们可以直接应用于现代软件市场*.*

您需要为此学习路径准备的内容

模块 1:

要跟随本模块的示例,您需要 Go 版本 1.6 或更高版本。Go 支持以下操作系统上的 AMD64、x386 和 ARM 架构:

  • Windows XP(或更高版本)

  • Mac OSX 10.7(或更高版本)

  • Linux 2.6(或更高版本)

  • FreeBSD 8(或更高版本)

模块 2:

本模块的大部分章节都是按照简单的 TDD 方法编写的,这里首先编写需求,然后是一些单元测试,最后是满足这些需求的代码。我们将使用仅随 Go 标准库提供的工具,以便更好地理解该语言及其可能性。这个想法是遵循模块并理解 Go 解决问题的方法,尤其是在分布式系统和并发应用程序中。

模块 3:

要编译和运行本模块的代码,您需要一个能够运行支持 Go 工具集的操作系统,有关这些操作系统的列表,请参阅golang.org/doc/install#requirements

附录, 稳定 Go 环境的良好实践,提供了一些有用的提示,包括如何安装 Go 和设置开发环境,以及如何使用 GOPATH 环境变量进行工作。

本学习路径面向的对象

对于熟悉其他面向对象编程语言(如 Java、C#或 Python)的 Go 入门者来说,这门课程既有趣又有益。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这门课程的想法——您喜欢什么或不喜欢什么。读者反馈对我们来说非常重要,因为它帮助我们开发出您真正能从中受益的标题。

如要向我们发送一般反馈,请简单地将电子邮件发送至 <feedback@packtpub.com>,并在邮件主题中提及课程的标题。

如果您在某个主题上有所专长,并且您有兴趣撰写或为课程做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors

客户支持

现在你已经是 Packt 课程的自豪拥有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

您可以从您的账户中下载本课程的示例代码文件,网址为 www.packtpub.com。如果您在其他地方购买了此课程,您可以访问 www.packtpub.com/support 并注册,以便将文件直接发送给您。

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

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与错误清单

  4. 搜索框中输入课程的名称。

  5. 选择您想要下载代码文件的课程。

  6. 从下拉菜单中选择您购买此课程的来源。

  7. 点击代码下载

您还可以通过点击 Packt 出版网站课程网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入课程名称来访问此页面。请注意,您需要登录您的 Packt 账户。

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

  • Windows 的 WinRAR / 7-Zip

  • Mac 的 Zipeg / iZip / UnRarX

  • Linux 的 7-Zip / PeaZip

该课程的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Go-Design-Patterns-for-Real-World-Projects。我们还有其他来自我们丰富的图书和视频目录的代码包可供在 github.com/PacktPublishing/ 获取。查看它们吧!

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的课程中发现错误——可能是文本或代码中的错误——如果您能向我们报告这些错误,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进后续版本的课程。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的课程,点击错误清单提交表单链接,并输入您的错误清单详情来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入课程名称。所需信息将出现在勘误部分。

盗版

在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面所提供的帮助。

询问

如果您对这门课程的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一部分 第 1 模块

学习 Go 编程

学习 Go 编程语言的深入指南

第一章. Go 的第一步

在本书的第一章中,您将了解 Go 并游览使该语言成为其采用者喜爱的特性的功能。本章的开始提供了 Go 编程语言的动机。如果您不耐烦,欢迎您跳转到任何其他主题,学习如何编写您的第一个 Go 程序。最后,“Go 概览”部分提供了语言特性的高级总结。

本章涵盖了以下主题:

  • Go 编程语言

  • 玩转 Go

  • 安装 Go

  • 您的第一个 Go 程序

  • Go 概览

Go 编程语言

自从 1970 年代初在贝尔实验室由Dennis Ritchie发明 C 语言以来,计算行业已经产生了许多直接基于(或借鉴了其语法)的流行语言。通常被称为 C 语言家族,它们可以分为两个广泛的进化分支。在一个分支中,如 C++、C#和 Java 等衍生语言已经发展到采用强类型系统、面向对象和使用编译二进制文件。然而,这些语言往往具有缓慢的构建-部署周期,程序员被迫采用复杂的面向对象类型系统以获得运行时安全性和执行速度:

Go 编程语言

在其他进化语言分支中,有诸如 Perl、Python 和 JavaScript 等语言,它们因其缺乏类型安全形式、使用轻量级脚本语法以及代码解释而非编译而被描述为动态语言。动态语言已成为网络和云规模开发的优先工具,在这些领域,速度和部署的便捷性比运行时安全性更重要。然而,动态语言的解释性质意味着它们通常比编译型语言运行得慢。此外,运行时缺乏类型安全性意味着随着应用程序的增长,系统的正确性扩展得不好。

Go 是在 2007 年由Robert GriesemerRob PikeKen Thomson在 Google 创建的系统语言,用于处理应用程序开发的需求。Go 的设计者希望减轻上述语言的问题,同时创建一个简单、安全、一致且可预测的新语言。正如 Rob Pike 所说:

"Go 是尝试将静态类型语言的安全性和性能与动态类型解释语言的表达性和便利性相结合。"

Go 借鉴了之前出现的不同语言的理念,包括:

  • 简洁但易于使用的语法

  • 一种感觉更像动态语言的系统类型

  • 对面向对象编程的支持

  • 静态类型,用于编译和运行时安全性

  • 编译成本地二进制文件,以实现快速的运行时执行

  • 几乎零编译时间,感觉更像解释型语言

  • 一个简单的并发惯用语,以利用多核、多芯片机器

  • 一个用于安全自动内存管理的垃圾回收器

本章的剩余部分将引导你通过一系列入门步骤,让你预览该语言,并开始构建和运行你的第一个 Go 程序。这是本书剩余章节详细讨论的主题的先导。如果你已经对 Go 有基本的了解,欢迎跳转到其他章节。

玩转 Go

在我们一头扎进在本地机器上安装和运行 Go 工具之前,让我们先看看Go 演练场。语言的创造者提供了一种简单的方法,让你在不安装任何工具的情况下熟悉语言。被称为 Go 演练场,这是一个基于网络的工具,可以通过play.golang.org/访问,它使用编辑器隐喻,让开发者可以在网页浏览器窗口中直接编写代码来测试他们的 Go 技能。演练场允许用户在 Google 的远程服务器上编译和运行他们的代码,并立即获得以下截图所示的结果:

玩转 Go

编辑器很简单,因为它旨在用作学习工具和与他人分享代码的方式。演练场包括诸如行号和格式化等实用功能,以确保代码在超过几行长时仍然可读。由于这是一个消耗真实计算资源的免费服务,Google 理所当然地对可以在演练场中完成的事情施加了一些限制:

  • 你对代码消耗的内存量有限制

  • 运行时间长的程序将被终止

  • 文件访问通过内存文件系统模拟。

  • 网络访问仅模拟回环接口

不需要 IDE

除了 Go 演练场,人们应该如何编写 Go 代码呢?编写 Go 代码不需要花哨的集成开发环境IDE)。实际上,你可以使用操作系统捆绑的喜欢的纯文本编辑器开始编写简单的 Go 程序。然而,大多数主要的文本编辑器(以及完整的 IDE)都有 Go 插件,例如 Atom、Vim、Emacs、Microsoft Code、IntelliJ 以及许多其他编辑器。Go 的编辑器和 IDE 插件完整列表可以在github.com/golang/go/wiki/IDEsAndTextEditorPlugins找到。

安装 Go

要在本地机器上使用 Go 进行编程,你需要在计算机上安装Go 工具链。在撰写本文时,Go 已经准备好可以安装在以下主要操作系统平台上:

  • Linux

  • FreeBSD Unix

  • Mac OSX

  • Windows

官方的安装包适用于所有基于 32 位和 64 位 Intel 架构的系统。同时,也有适用于 ARM 架构的官方二进制发布版本。随着 Go 语言的流行,未来肯定会有更多的二进制分发选项可供选择。

让我们跳过详细的安装说明,因为它们在你阅读时肯定会发生变化。相反,我们邀请你访问golang.org/doc/install并遵循针对你特定平台的指示。完成后,务必在继续使用以下命令之前测试你的安装是否正常工作。

$> go version
go version go1.6.1 linux/amd64

之前的命令应该会打印出 Go 及其工具安装的版本号、目标操作系统和机器架构。如果你没有得到与前面命令类似的输出,请确保将 Go 二进制文件的路径添加到你的操作系统的执行PATH环境变量中。

在你开始编写自己的代码之前,请确保你已经正确设置了你的GOPATH。这是一个本地目录,当你使用 Go 工具链时,你的 Go 源文件和编译后的工件都保存在这里。按照golang.org/doc/install#testing中找到的说明来设置你的 GOPATH。

源代码示例

本书中的编程示例可在 GitHub 源代码仓库服务上找到。在那里,你可以找到按章节分组的所有源文件,位于github.com/vladimirvivien/learning-go/的仓库中。为了节省读者一些按键,示例使用了以golang.fyi开头的缩短 URL,它直接指向 GitHub 中的相应文件。

或者,你也可以通过下载并解压(或克隆)本地仓库来跟随。在你的GOPATH中创建一个目录结构,使得源文件的根目录位于$GOPATH/src/github.com/vladimirvivien/learning-go/

你的第一个 Go 程序

在你的本地机器上成功安装 Go 工具后,你现在可以编写并执行你的第一个 Go 程序了。为此,只需打开你喜欢的文本编辑器,并输入以下代码中的简单 Hello World 程序:

package main
import "fmt"
func main() { 
  fmt.Println("Hello, World!")
} 

golang.fyi/ch01/helloworld.go

将源代码保存在名为helloworld.go的文件中,该文件位于你的 GOPATH 中的任何位置。然后使用以下 Go 命令来编译并运行程序:

$> go run helloworld.go 
Hello, World!

如果一切顺利,你应该会在屏幕上看到消息**Hello, World!**输出。恭喜你,你已经编写并执行了你的第一个 Go 程序。现在,让我们从高层次上探讨 Go 语言的特征和属性。

Go 语言概述

设计上,Go 语言拥有简洁的语法。其设计者希望创建一种清晰、简洁且语法一致,且没有太多语法惊喜的语言。在阅读 Go 代码时,请记住这个口诀:所见即所得。Go 语言避免使用巧妙且紧凑的编码风格,而是倾向于编写清晰易读的代码,如下面的程序所示:

// This program prints molecular information for known metalloids 
// including atomic number, mass, and atom count found 
// in 100 grams of each element using the mole unit. 
// See http://en.wikipedia.org/wiki/Mole_(unit) 
package main 

import "fmt" 

const avogadro float64 = 6.0221413e+23 
const grams = 100.0 

type amu float64 

func (mass amu) float() float64 { 
  return float64(mass) 
} 

type metalloid struct { 
  name   string 
  number int32 
  weight amu 
} 

var metalloids = []metalloid{ 
  metalloid{"Boron", 5, 10.81}, 
  metalloid{"Silicon", 14, 28.085}, 
  metalloid{"Germanium", 32, 74.63}, 
  metalloid{"Arsenic", 33, 74.921}, 
  metalloid{"Antimony", 51, 121.760}, 
  metalloid{"Tellerium", 52, 127.60}, 
  metalloid{"Polonium", 84, 209.0}, 
} 

// finds # of moles 
func moles(mass amu) float64 { 
  return grams / float64(mass) 
} 

// returns # of atoms moles 
func atoms(moles float64) float64 { 
  return moles * avogadro 
} 

// return column headers 
func headers() string { 
  return fmt.Sprintf( 
    "%-10s %-10s %-10s Atoms in %.2f Grams\n", 
    "Element", "Number", "AMU", grams, 
  ) 
} 

func main() { 
  fmt.Print(headers()) 

    for _, m := range metalloids { 
      fmt.Printf( 
    "%-10s %-10d %-10.3f %e\n", 
      m.name, m.number, m.weight.float(), atoms(moles(m.weight)), 
      ) 
    } 
}

golang.fyi/ch01/metalloids.go

当代码执行时,它将给出以下输出:

$> go run metalloids.go 
Element    Number     AMU        Atoms in 100.00 Grams 
Boron      5          10.810     6.509935e+22 
Silicon    14         28.085     1.691318e+23 
Germanium  32         74.630     4.494324e+23 
Arsenic    33         74.921     4.511848e+23 
Antimony   51         121.760    7.332559e+23 
Tellerium  52         127.600    7.684252e+23 
Polonium   84         209.000    1.258628e+24

如果您之前从未见过 Go,您可能无法理解前面程序中使用的语法和习惯用法的一些细节。然而,当您阅读代码时,您有很大机会能够跟随逻辑并形成一个程序流程的心理模型。这正是 Go 简单之美以及为什么这么多程序员使用它的原因。如果您完全迷失方向,无需担心,因为后续章节将涵盖语言的各个方面,帮助您入门。

函数

Go 程序由函数组成,这是语言中最小的可调用代码单元。在 Go 中,函数是有类型的实体,可以是命名的(如前例所示),也可以作为值赋给变量:

// a simple Go function 
func moles(mass amu) float64 { 
    return float64(mass) / grams 
} 

关于 Go 函数的另一个有趣特性是它们能够返回多个值作为函数调用的结果。例如,前面的函数可以被重写为除了返回计算出的 float64 类型的值外,还返回一个 error 类型的值:

func moles(mass amu) (float64, error) { 
    if mass < 0 { 
        return 0, error.New("invalid mass") 
    } 
    return (float64(mass) / grams), nil 
}

前面的代码使用了 Go 函数的多返回值功能来返回质量和错误值。你将在本书中遇到这种用法,它被用作向函数的调用者正确地传递错误信息的一种手段。关于多返回值函数的进一步讨论将在第五章,Go 中的函数中展开。

包含 Go 函数的源文件可以进一步组织成称为包的目录结构。包是逻辑模块,用于在 Go 中作为库共享代码。您可以创建自己的本地包或使用 Go 提供的工具自动从源代码仓库拉取并使用远程包。您将在第六章,Go 包和程序中了解更多关于 Go 包的内容。

工作空间

Go 采用简单的代码布局约定,以可靠地组织源代码包并管理它们的依赖关系。您的本地 Go 源代码存储在工作空间中,这是一个包含源代码和运行时文件的目录约定。这使得 Go 工具能够自动查找、构建和安装编译后的二进制文件。此外,Go 工具还依赖于 workspace 设置来从远程仓库(如 Git、Mercurial 和 Subversion)拉取源代码包,并满足它们的依赖关系。

强类型

Go 中的所有值都是静态类型的。然而,该语言提供了一个简单但表达力强的类型系统,它可以给人一种动态语言的感觉。例如,类型可以安全地推断,如下面的代码片段所示:

const grams = 100.0 

如你所预期,常量克会由 Go 类型系统分配一个数值类型,精确地说,是 float64 类型。这不仅适用于常量,任何变量都可以使用如下示例所示的简写形式进行声明和赋值:

package main  
import "fmt"  
func main() { 
  var name = "Metalloids" 
  var triple = [3]int{5,14,84} 
  elements := []string{"Boron","Silicon", "Polonium"} 
  isMetal := false 
  fmt.Println(name, triple, elements, isMetal) 

} 

Chapter 2, *Go Language Essentials* and Chapter 4, *Data Types*, go into more details regarding Go types.

复合类型

除了简单值的类型外,Go 还支持诸如 arrayslicemap 这样的复合类型。这些类型被设计用来存储指定类型的值的索引元素。例如,之前展示的 metalloid 示例就使用了切片,它是一个可变大小的数组。变量 metalloid 被声明为一个 slice,用来存储 metalloid 类型的集合。代码使用字面量语法结合了 metalloid 类型的 slice 的声明和赋值:

var metalloids = []metalloid{ 
    metalloid{"Boron", 5, 10.81}, 
    metalloid{"Silicon", 14, 28.085}, 
    metalloid{"Germanium", 32, 74.63}, 
    metalloid{"Arsenic", 33, 74.921}, 
    metalloid{"Antimony", 51, 121.760}, 
    metalloid{"Tellerium", 52, 127.60}, 
    metalloid{"Polonium", 84, 209.0}, 
} 

Go 还支持一种 struct 类型,它是一个复合类型,存储了称为字段的命名元素,如下面的代码所示:

func main() { 
  planet := struct { 
      name string 
      diameter int  
  }{"earth", 12742} 
} 

之前的示例使用字面量语法声明了 struct{name string; diameter int},其值为 {"earth", 12742}。你可以在第七章 复合类型 中阅读有关复合类型的所有内容。

命名类型

metalloid in the earlier example:
type amu float64 

type metalloid struct { 
  name string 
  number int32 
  weight amu 
} 

amu, which uses type float64 as its underlying type. Type metalloid, on the other hand, uses a struct composite type as its underlying type, allowing it to store values in an indexed data structure. You can read more about declaring new named types in Chapter 4, *Data Types*.

方法和对象

amu receiving a method called float() that returns the mass as a float64 value:
type amu float64 

func (mass amu) float() float64 { 
    return float64(mass) 
} 

这一概念的力量在第八章 方法、接口和对象 中被详细探讨。

接口

Go 支持程序化接口的概念。然而,正如你将在第八章 方法、接口和对象 中看到的,Go 的接口本身就是一个类型,它聚合了一组可以将能力投射到其他类型值的方法。保持其简单性,实现 Go 接口不需要使用关键字显式声明接口。相反,类型系统隐式地使用附加到类型的函数来解析实现的接口。

例如,Go 包含一个名为 Stringer 的内置接口,其定义如下:

type Stringer interface { 
    String() string 
} 

任何附加了 String() 方法的类型,都会自动实现 Stringer 接口。因此,将 metalloid 类型的定义从之前的程序中修改为附加 String() 方法,将自动实现 Stringer 接口:

type metalloid struct { 
    name string 
    number int32 
    weight amu 
} 
func (m metalloid) String() string { 
  return fmt.Sprintf( 
    "%-10s %-10d %-10.3f %e", 
    m.name, m.number, m.weight.float(), atoms(moles(m.weight)), 
  ) 
}  

golang.fyi/ch01/metalloids2.go

String() 方法返回一个预格式化的字符串,表示 metalloid 的值。标准库包 fmt 中的 Print() 函数会自动调用 stringer 参数实现的 String() 方法。因此,我们可以使用这个事实来打印 metalloid 值,如下所示:

func main() { 
  fmt.Print(headers()) 
  for _, m := range metalloids { 
    fmt.Print(m, "\n") 
  } 
} 

再次,请参考第八章,方法、接口和对象,以深入了解接口这一主题。

并发与通道

将 Go 推向当前采用水平的其中一个主要特性是其对简单并发习惯的固有支持。该语言使用一个称为goroutine的并发单元,允许程序员以独立和高度并发的代码结构化程序。

在下面的示例中,您将看到 Go 还依赖于一种称为通道的构造,用于独立运行的goroutines之间的通信和协调。这种方法避免了通过共享内存进行线程通信的传统方法的风险和(有时脆弱)的缺点。相反,Go 通过使用通道进行通信来促进共享。以下示例展示了使用goroutines和通道作为处理和通信原语的方法:

// Calculates sum of all multiple of 3 and 5 less than MAX value. 
// See https://projecteuler.net/problem=1 
package main 

import ( 
  "fmt" 
) 

const MAX = 1000 

func main() { 
  work := make(chan int, MAX) 
  result := make(chan int) 

  // 1\. Create channel of multiples of 3 and 5 
  // concurrently using goroutine 
  go func(){ 
    for i := 1; i < MAX; i++ { 
      if (i % 3) == 0 || (i % 5) == 0 { 
        work <- i // push for work 
      } 
    } 
    close(work)  
  }() 

  // 2\. Concurrently sum up work and put result 
  //    in channel result  
  go func(){ 
    r := 0 
    for i := range work { 
      r = r + i 
    } 
    result <- r 
  }() 

  // 3\. Wait for result, then print 
  fmt.Println("Total:", <- result) 
} 

golang.fyi/ch01/euler1.go

之前示例中的代码将待完成的工作分配给了两个并发运行的goroutines(使用go关键字声明),如代码注释所示。每个goroutine独立运行,并使用 Go 的通道workresult来通信和协调最终结果的计算。再次强调,如果这段代码完全看不懂,请放心,并发内容在第九章,并发中进行了详细阐述。

内存管理与安全性

与其他编译和静态类型语言(如 C 和 C++)类似,Go 允许开发者直接影响内存分配和布局。例如,当开发者创建一个字节的slice(相当于数组)时,这些字节在机器的底层物理内存中有直接的表示。此外,Go 借鉴了指针的概念来表示存储值的内存地址,这使得 Go 程序能够通过值和引用两种方式传递函数参数。

Go 在内存管理方面设定了一个高度意见化的安全屏障,几乎没有可配置的参数。Go 使用运行时垃圾回收器自动处理内存分配和释放的繁琐工作。运行时不允许指针算术;因此,开发者不能通过向或从基本内存地址添加或减去来遍历内存块。

快速编译

Go 的另一个吸引力是它为中等规模项目提供的毫秒级构建时间。这是通过诸如简单语法、无冲突的语法和严格的标识符解析等特性实现的,这些特性禁止使用未使用的声明资源,例如导入的包或变量。此外,构建系统使用依赖树中最接近的源节点中存储的传递性信息来解决包。再次,这减少了代码-编译-运行周期,使其更像动态语言而不是编译语言。

测试和代码覆盖率

虽然其他语言通常依赖于第三方工具进行测试,但 Go 包含内置的 API 和专门为自动化测试、基准测试和代码覆盖率设计的工具。与 Go 中的其他功能类似,测试工具使用简单的约定来自动检查和仪器化代码中找到的测试函数。

以下函数是欧几里得除法算法的简单实现,它返回一个商和一个余数值(作为变量qr)用于正整数:

func DivMod(dvdn, dvsr int) (q, r int) { 
  r = dvdn 
  for r >= dvsr { 
    q += 1 
    r = r - dvsr 
  } 
  return 
} 

golang.fyi/ch01/testexample/divide.go

在单独的源文件中,我们可以编写一个测试函数来验证算法,通过使用 Go 测试 API 检查被测试函数返回的余数值来验证,如下面的代码所示:

package testexample 
import "testing" 
func TestDivide(t *testing.T) { 
  dvnd := 40 
    for dvsor := 1; dvsor < dvnd; dvsor++ { 
      q, r := DivMod(dvnd, dvsor) 
  if (dvnd % dvsor) != r { 
    t.Fatalf("%d/%d q=%d, r=%d, bad remainder.", dvnd, dvsor, q, r) 
    } 
  } 
}  

golang.fyi/ch01/testexample/divide_test.go

要测试源代码,只需像以下示例中那样运行 Go 的测试工具:

$> go test . 
ok   github.com/vladimirvivien/learning-go/ch01/testexample  0.003s

测试工具报告了测试结果的摘要,指示了被测试的包及其通过/失败结果。Go 工具链包含许多更多功能,旨在帮助程序员创建可测试的代码,包括:

  • 自动在测试期间对代码进行仪器化以收集覆盖率统计信息

  • 为覆盖的代码和测试路径生成 HTML 报告

  • 一个基准 API,允许开发者从测试中收集性能指标

  • 基准报告包含用于检测性能问题的有价值指标

你可以在第十二章代码测试中阅读有关测试及其相关工具的所有内容。

文档

文档在 Go 中是一个一等组件。可以说,该语言的部分流行度归因于其广泛的文档(参见golang.org/pkg)。Go 附带 Godoc 工具,这使得从源代码中直接嵌入的注释文本中提取文档变得容易。例如,要记录上一节中的函数,我们只需在DivMod函数上方直接添加注释行,如下面的示例所示:

// DivMod performs a Eucledan division producing a quotient and remainder. 
// This version only works if dividend and divisor > 0\. 
func DivMod(dvdn, dvsr int) (q, r int) { 
... 
}

Go 文档工具可以自动提取并创建 HTML 格式的页面。例如,以下命令将 Godoc 工具作为服务器在localhost 端口 6000上启动:

$> godoc -http=":6001"

http://localhost:6001/pkg/github.com/vladimirvivien/learning-go/ch01/testexample/:

文档

一个广泛的库

由于其短暂的寿命,Go 迅速积累了一系列高质量的 API,作为其标准库的一部分,这些 API 与其他流行和更成熟的语言相当。以下列出的核心 API 并非详尽无遗,但程序员可以开箱即用:

  • 完全支持正则表达式,包括搜索和替换

  • 强大的 IO 原语,用于读取和写入字节

  • 完全支持从套接字、TCP/UDP、IPv4 和 IPv6 进行网络操作

  • 用于编写生产就绪的 HTTP 服务和客户端的 API

  • 支持传统的同步原语(互斥锁、原子操作等)

  • 支持 HTML 的通用模板框架

  • 支持 JSON/XML 序列化

  • 支持多种线格式的 RPC

  • 存档和压缩算法的 API:tarzip/gzipzlib

  • 对大多数主要算法和哈希函数的加密支持

  • 访问操作系统级别的进程、环境信息、信号等更多功能

Go 工具链

在我们结束本章之前,应该强调 Go 的一个最后方面,那就是它的工具集合。虽然其中一些工具已经在之前的章节中提到过,但其他一些工具在此列出,以便您了解:

  • fmt: 重新格式化源代码以符合标准

  • vet: 报告源代码结构的错误使用

  • lint: 另一个源代码工具,报告明显的样式违规

  • goimports: 分析并修复源代码中的包导入引用

  • godoc: 生成和组织源代码文档

  • generate: 从源代码中存储的指令生成 Go 源代码

  • get: 远程检索和安装包及其依赖项

  • build: 编译指定包及其依赖项的代码

  • run: 提供编译和运行 Go 程序的便利性

  • test: 执行单元测试,支持基准和覆盖率报告

  • oracle静态分析工具:查询源代码结构和元素

  • cgo: 为 Go 和 C 之间的互操作性生成源代码

摘要

在其相对较短的存在期间,Go 赢得了许多重视简洁性作为编写精确且能够长期扩展的代码方式的采用者的心。正如您在本章前面的部分所看到的,开始编写第一个 Go 程序很容易。

本章还向读者展示了 Go 最基本特性的高级概述,包括其简化的语法、对并发的强调以及使 Go 成为数据中心计算时代软件工程师首选的系统工具。正如您可能想象的那样,这仅仅是即将到来的一小部分。

在接下来的章节中,本书将继续详细探讨构成 Go 成为优秀学习语言的句法和语言概念。让我们开始吧!

第二章。Go 语言基础

在上一章中,我们确立了使 Go 成为创建现代系统程序的优秀语言的元素特性。在本章中,我们将深入探讨语言的语法,以探索其组件和功能。

我们将涵盖以下主题:

  • Go 源代码文件

  • 标识符

  • 变量

  • 常量

  • 运算符

Go 源代码文件

我们在第一章中,即《Go 语言的第一步》中,看到了一些 Go 程序的示例。在本节中,我们将检查 Go 源代码文件。让我们考虑以下源代码文件(该文件以不同语言打印"Hello World"问候语):

Go 源代码文件

golang.fyi/ch02/helloworld2.go

一个典型的 Go 源代码文件,如前面所列,可以分为三个主要部分,如下所示:

  • 包声明

          //1 Package Clause 
          package main 
    
    
  • 导入声明

          //2 Import Declaration 
          import "fmt" 
          import "math/rand" 
          import "time" 
    
    
  • 源代码体

          //3 Source Body 
          var greetings = [][]string{ 
            {"Hello, World!","English"}, 
            ... 
          } 
    
          func greeting() [] string { 
            ... 
          } 
    
          func main() { 
            ... 
          } 
    
    

子句表示此源文件所属的包的名称(有关包组织的详细讨论,请参阅第六章,Go 包和程序)。导入声明列出源代码希望使用的任何外部包。Go 编译器严格强制执行包声明使用。在源文件中包含未使用的包被认为是错误(编译错误)。源代码的最后部分被认为是源文件的主体。这是你声明变量、常量、类型和函数的地方。

所有 Go 源代码文件都必须以.go后缀结尾。通常,你可以根据需要命名 Go 源代码文件。例如,与 Java 不同,Go 文件名与其内容中声明的类型之间没有直接关联。然而,将文件名命名为能表明其内容的名称被认为是良好的实践。

在我们更详细地探讨 Go 的语法之前,了解一些语言的基本结构元素是很重要的。虽然其中一些元素在语法上被固定在语言中,但其他元素是简单的习语和约定,你应该了解这些,以便使你的 Go 语言入门简单而愉快。

可选的分号

你可能已经注意到 Go 不需要分号作为语句分隔符。这是从其他较轻和解释性语言中借用的一项特性。以下两个程序在功能上是等效的。第一个程序使用惯用的 Go 并省略了分号:

可选分号

程序的第二个版本,如下所示,使用了多余的分号来显式终止其语句。虽然编译器可能会感谢你的帮助,但这在 Go 中不是惯用的用法:

可选分号

虽然在 Go 中分号是可选的,但 Go 的正式语法仍然要求它们作为语句终止符。因此,Go 编译器将在以下行末插入分号:

  • 标识符

  • 字符串、布尔值、数值或复数的字面值

  • 控制流指令,如 break、continue 或 return

  • 一个闭合括号或方括号,例如 )}]

  • 增量 ++ 或递减 -- 运算符

由于这些规则,编译器强制执行严格的语法形式,这对 Go 中的源代码风格有很大影响。例如,所有代码块都必须在其前一个语句的同一行上以开大括号 { 开始。否则,编译器可能会在破坏代码的位置插入分号,如下面的 if 语句所示:

func main() { 
    if "a" == "a" 
    { 
      fmt.Println("Hello, World!") 
    } 
} 

将花括号移动到下一行会导致编译器提前插入分号,这将导致以下语法错误:

$> ... missing condition in if statement ... 

这是因为编译器在 if 语句(if "a"=="a";)之后插入了分号,使用了本节讨论的分号插入规则。你可以通过手动在 if 条件语句后插入分号来验证这一点;你将得到相同的错误。这是一个很好的地方,可以过渡到下一节,讨论代码块中的尾随逗号。

多行

将表达式拆分成多行必须遵循上一节中讨论的分号规则。主要是在多行表达式中,每一行都必须以一个防止提前插入分号的标记结束,如下表所示。应注意,表中带有无效表达式的行将无法编译:

表达式 有效

|

lonStr := "Hello World! " +
"How are you?"

是的,+ 运算符防止了提前插入分号。

|

lonStr := "Hello World! "
+ "How are you?"

不,分号将在第一行之后插入,从语义上破坏了该行。

|

fmt.Printf("[%s] %d %d %v",
str,
num1,
num2,
nameMap)

是的,逗号正确地打破了表达式。

|

fmt.Printf("[%s] %d %d %v",
str,
num1,
num2,
nameMap)

是的,编译器只在最后一行之后插入分号。

|

weekDays := []string{
"Mon", "Tue",
"Wed", "Thr",
"Fri"
}

不,Fri 行导致提前插入分号。

|

weekDays2 := []string{
"Mon", "Tue",
"Wed", "Thr",
"Fri",
}

是的,Fri 行包含尾随逗号,导致编译器在下一行插入分号。
weekDays1 := []string{``"Mon", "Tue",``"Wed", "Thr",``"Fri"} 是的,分号是在闭合括号之后的行中插入的。

你可能会想知道为什么 Go 编译器将责任放在开发者身上,要求他们提供行断点提示以指示语句的结束。当然,Go 设计者可以设计一个复杂的算法来自动解决这个问题。是的,他们可以。然而,通过保持语法简单和可预测,编译器能够快速解析和编译 Go 源代码。

注意

Go 工具链包括 gofmt 工具,它可以用来一致地应用适当的格式化规则到你的源代码中。还有一个 govet 工具,它通过分析你的代码中的结构问题来走得更远,这些问题与代码元素有关。

Go 标识符

Go 标识符用于命名程序元素,包括包、变量、函数和类型。以下总结了 Go 中标识符的一些属性:

  • 标识符支持 Unicode 字符集

  • 标识符的第一个位置必须是字母或下划线

  • Go 的惯用用法偏好混合大小写(驼峰式)命名

  • 包级别标识符必须在给定的包内是唯一的

  • 标识符必须在代码块(函数、控制语句)内是唯一的

空标识符

Go 编译器对声明标识符用于变量或包的使用特别严格。基本规则是:“你声明了它,就必须使用它”。如果你尝试编译包含未使用标识符(如变量或命名包)的代码,编译器将不会高兴,并且会失败编译。

Go 允许您使用空标识符(由下划线字符 _ 表示)来关闭此行为。任何使用空标识符的声明或赋值都不会绑定到任何值,并且在编译时被忽略。空标识符通常用于以下两个上下文中,如下所述的小节。

消除包导入

当包声明前有一个下划线时,编译器允许在不进行任何进一步引用使用的情况下声明包:

import "fmt" 
import "path/filepath" 
import _ "log" 

log will be muted without any further reference in the code. This can be a handy feature during active development of new code, where developers may want to try new ideas without constantly having to comment out or delete the declarations. Although a package with a blank identifier is not bound to any reference, the Go runtime will still initialize it. Chapter 6, *Go Packages and Programs*, discusses the package initialization lifecycle.

抑制不需要的函数结果

当 Go 函数调用返回多个值时,返回列表中的每个值都必须分配给一个变量标识符。然而,在某些情况下,可能希望抑制返回列表中的不需要的结果,同时保留其他结果,如下面的调用所示:

_, execFile := filepath.Split("/opt/data/bigdata.txt")

之前对函数 filepath.Split("/opt/data/bigdata.txt") 的调用接受一个路径并返回两个值:第一个是父路径(/opt/data),第二个是文件名(bigdata.txt)。第一个值分配给空标识符,因此未绑定到任何命名标识符,这导致编译器忽略它。在未来的讨论中,我们将探讨该习语在其他上下文中的其他用途,例如错误处理和 for 循环。

内置标识符

Go 包含了许多内置标识符。它们分为不同的类别,包括类型、值和内置函数。

类型

以下标识符用于 Go 的内置类型:

类别 标识符
数字 byte, int, int8, int16, int32, int64, rune, uint, uint8, uint16, uint32, uint64, float32, float64, complex64, complex128, uintptr
字符串 string
布尔 bool
错误 error

这些标识符具有预分配的值:

类别 标识符
布尔常量 true, false
常量计数器 iota
未初始化值 nil

函数

以下函数作为 Go 的内置预声明标识符的一部分可用:

类别 标识符
初始化 make(), new()
集合 append(), cap(), copy(), delete()
复数 complex(), imag(), real()
错误处理 panic(), recover()

Go 变量

Go 是一种强类型语言,这意味着所有变量都是命名元素,它们绑定到一个值和一个类型。您将看到,其语法简单灵活,这使得在 Go 中声明和初始化变量感觉更像动态类型语言。

变量声明

在 Go 中使用变量之前,必须使用命名标识符声明变量,以便在代码中将来引用。Go 变量声明的长格式如下所示:

*var <identifier list> <type>*

main():
package main 

import "fmt" 

var name, desc string 
var radius int32 
var mass float64 
var active bool 
var satellites []string 

func main() { 
  name = "Sun" 
  desc = "Star" 
  radius = 685800 
  mass = 1.989E+30 
  active = true 
  satellites = []string{ 
    "Mercury", 
    "Venus", 
    "Earth", 
    "Mars", 
    "Jupiter", 
    "Saturn", 
    "Uranus", 
    "Neptune", 
  } 
  fmt.Println(name) 
  fmt.Println(desc) 
  fmt.Println("Radius (km)", radius) 
  fmt.Println("Mass (kg)", mass) 
  fmt.Println("Satellites", satellites) 
} 

golang.fyi/ch02/vardec1.go

零值

之前的源代码展示了几个使用不同类型的变量声明的例子。然后,这些变量在函数 main() 内部被赋予值。乍一看,似乎这些声明的变量在声明时并没有被赋予值。这会与我们的先前的断言相矛盾,即所有 Go 变量都绑定到一个类型和一个值。

我们如何声明一个变量而不将其绑定到一个值上?在变量声明时,如果没有提供值,Go 会自动将默认值(或零值)绑定到变量上,以进行适当的内存初始化(我们稍后会看到如何在单个表达式中同时进行声明和初始化)。

以下表格显示了 Go 类型及其默认零值:

类型 零值
string ""(空字符串)
数值 - 整数:byte, int, int8, int16, int32, int64, rune, uint, uint8, uint16, uint32, uint64, uintptr 0
数值 - 浮点数:float32, float64 0.0
bool false
Array 每个索引位置都有一个与数组元素类型对应的零值。
Struct 每个成员都有其相应零值的空 struct
其他类型:接口、函数、通道、切片、映射和指针 nil

初始化声明

如前所述,Go 还支持使用以下格式将变量声明和初始化组合为一个表达式:

var <标识符列表> <类型> = <值列表或初始化表达式>

这种声明格式具有以下特性:

  • 在等号左侧提供的标识符列表(后跟一个类型)

  • 右侧的匹配的逗号分隔值列表

  • 赋值按照标识符和值的相应顺序进行

  • 初始化表达式必须返回一个匹配的值列表

以下简化的示例展示了声明和初始化组合的使用:

var name, desc string = "Earth", "Planet" 
var radius int32 = 6378 
var mass float64 = 5.972E+24 
var active bool = true 
var satellites = []string{ 
  "Moon", 
} 

golang.fyi/ch02/vardec2.go

省略变量类型

到目前为止,我们已经讨论了所谓的 Go 变量声明和初始化的长格式。为了使语言感觉更接近它的动态类型近亲,可以省略类型指定,如下面的声明格式所示:

var <标识符列表> = <值列表或初始化表达式>

在编译期间,编译器根据赋值值或等号右侧的初始化表达式推断变量的类型,如下面的示例所示。

var name, desc = "Mars", "Planet" 
var radius = 6755 
var mass = 641693000000000.0 
var active = true 
var satellites = []string{ 
  "Phobos", 
  "Deimos", 
} 

golang.fyi/ch02/vardec3.go

如前所述,当一个变量被赋予一个值时,它必须与该值一起接收一个类型。当省略变量的类型时,类型信息从赋值值或表达式的返回值中推断出来。以下表格显示了给定字面值时推断出的类型:

字面值 推断类型
双引号或单引号(原始)文本:"火星"``"所有行星都围绕太阳旋转。" string
整数:-7601244``1840 int
小数:-0.25``4.0``3.1e4``7e-12 float64
复数:-5.0i``3i``(0+4i) complex128
布尔值:true``false bool
数组值:[2]int{-76, 8080} 在字面值中定义的array类型。在这种情况下,它是:[2]int
映射值:map[string]int{ "Sun": 685800, "Earth": 6378, "Mars": 3396,} 在字面值中定义的映射类型。在这种情况下,它是:map[string]int
切片值:[]int{-76, 0, 1244, 1840} 在字面值中定义的slice类型:[]int
结构体值:struct{ name string diameter int}``{ "Mars", 3396,} 在字面值中定义的结构体类型。在这种情况下,类型是:struct{name string; diameter int}
函数值:var sqr = func (v int) int { return v * v } 在函数定义字面值中定义的函数类型。在这种情况下,variablesqr 将具有类型:func (v int) int

短变量声明

Go 语言可以使用短变量声明格式进一步简化变量声明语法。在这个格式中,声明去掉了var关键字和类型指定,并使用赋值运算符:=(冒号等于),如下所示:

<标识符列表> := <值列表或初始化表达式>

这是一个简单且简洁的惯用语,在 Go 语言中声明变量时常用。以下代码示例展示了短变量声明的用法:

func main() { 
    name := "Neptune" 
    desc := "Planet" 
    radius := 24764 
    mass := 1.024e26 
    active := true 
    satellites := []string{ 
         "Naiad", "Thalassa", "Despina", "Galatea", "Larissa", 
     "S/2004 N 1", "Proteus", "Triton", "Nereid", "Halimede", 
         "Sao", "Laomedeia", "Neso", "Psamathe", 
    } 
... 
} 

golang.fyi/ch02/vardec4.go

注意在声明中省略了关键字var和变量类型。短变量声明使用与前面讨论的相同机制来推断变量的类型。

短变量声明的限制

为了方便,短变量声明的简写形式确实附带了一些限制,你应该注意这些限制以避免混淆:

  • 首先,它只能在函数块中使用

  • 赋值运算符:=声明变量并赋值

  • :=不能用来更新之前已声明的变量

  • 变量的更新必须使用等号

虽然这些限制可能有其根植于 Go 语法简单性的合理理由,但它们通常被视为语言新手的混淆来源。例如,不能在包级别变量赋值中使用冒号等于运算符。学习 Go 的开发者可能会发现使用赋值运算符来更新变量很有吸引力,但这样做会导致编译错误。

变量作用域和可见性

Go 使用基于代码块的词法作用域来确定包内变量的可见性。根据变量在源文本中的声明位置,将决定其作用域。一般来说,变量只能从声明它的块内部访问,并且对所有嵌套的子块可见。

下面的截图展示了在源文本中声明的几个变量的作用域。每个变量声明都标有它的作用域(packagefunctionfor 循环和 if...else 块):

变量作用域和可见性

golang.fyi/ch02/makenums.go

如前所述,变量可见性是自顶向下的。具有包作用域的变量,如 mapFilenumbersFile,对包中的所有其他元素都是全局可见的。向下移动作用域层级,函数块变量如 dataerr 对函数及其子块中的所有元素可见。内层 for 循环块中的变量 ib 只在该块内部可见。一旦循环完成,ib 就会超出作用域。

注意

对于 Go 的新手来说,包作用域变量的可见性是一个容易混淆的问题。当一个变量在包级别(在函数或方法块之外)声明时,它对整个包都是全局可见的,而不仅仅是声明变量的源文件。这意味着包作用域的变量标识符只能在组成包的文件组中声明一次,这对于刚开始学习 Go 的开发者来说可能并不明显。有关包组织的详细信息,请参阅第六章,Go 包和程序

变量声明块

Go 的语法允许将顶层变量的声明分组到一起,以提高可读性和代码组织。以下示例展示了使用变量声明块重写之前的一个示例:

var ( 
  name string = "Earth" 
  desc string = "Planet" 
  radius int32 = 6378 
  mass float64 = 5.972E+24 
  active bool = true 
  satellites []string   
) 

golang.fyi/ch02/vardec5.go

Go 常量

在 Go 语言中,常量是一个具有字面表示的值,例如文本字符串、布尔值或数字。常量的值是静态的,在初始赋值后不能更改。虽然它们所代表的概念很简单,但常量却有一些有趣的特性,使它们非常有用,尤其是在处理数值时。

常量字面量

常量是语言中可以用文本字面量表示的值。常量最有趣的特性之一是它们的字面表示可以是带类型的或未指定类型的值。与本质上绑定到类型的变量不同,常量可以存储为内存空间中的未指定类型值。没有这种类型约束,例如,数值常量值可以以极高的精度存储。

以下是在 Go 中可以表示的有效常量字面值示例:

"Mastering Go" 
'G' 
false 
111009 
2.71828 
94314483457513374347558557572455574926671352 1e+500 
5.0i 

带类型的常量

golang.fyi/ch02/const.goNotice in the previous source snippet that each declared constant identifier is explicitly given a type. As you would expect, this implies that the constant identifier can only be used in contexts that are compatible with its types. However, the next section explains how this works differently when the type is omitted in the constant declaration.

未指定类型的常量

当常量未指定类型时,它们甚至更有趣。未指定类型的常量声明如下:

`const <标识符列表> = <值列表或初始化表达式>

与之前一样,使用关键字const来声明一系列标识符作为常量,以及它们各自的边界值。然而,在这个格式中,声明中省略了类型指定。作为一个未指定类型的实体,常量在内存中只是一个没有类型精度限制的字节块。以下是一些未指定类型常量的示例声明:

const i = "G is" + " for Go " 
const j = 'V' 
const k1, k2 = true, !k1 
const l = 111*100000 + 9 
const m1 = math.Pi / 3.141592 
const m2 = 1.414213562373095048801688724209698078569671875376... 
const m3 = m2 * m2 
const m4 = m3 * 1.0e+400 
const n = -5.0i * 3 
const o = time.Millisecond * 5 

m2 is assigned a long decimal value (truncated to fit on the printed page as it goes another 17 digits). Constant m4 is assigned a much larger number of m3 x 1.0e+400. The entire value of the resulting constant is stored in memory without any loss in precision. This can be an extremely useful tool for developers interested in computations where a high level of precision is important.

分配未指定类型的常量

未指定类型的常量值在分配给变量、用作函数参数或作为分配给变量的表达式的部分之前是有限的用途。在像 Go 这样的强类型语言中,这意味着存在一些类型调整的潜在可能性,以确保存储在常量中的值可以正确地分配给目标变量。使用未指定类型常量的一个优点是类型系统放宽了对类型检查的严格应用。未指定类型的常量可以分配给不同但兼容的不同精度的类型,而编译器不会提出任何异议,如下面的示例所示:

const m2 = 1.414213562373095048801688724209698078569671875376... 
var u1 float32 = m2 
var u2 float64 = m2 
u3 := m2 

m2 being assigned to two variables of different floating-point precisions, u1 and u2, and to an untyped variable, u3. This is possible because constant m2 is stored as a raw untyped value and can therefore be assigned to any variable compatible with its representation (a floating point).

虽然类型系统可以适应将m2分配给不同精度的变量,但结果分配会被调整以适应变量类型,如下所示:

u1 = 1.4142135      //float32 
u2 = 1.4142135623730951   //float64 

关于变量u3,它本身是一个未指定类型的变量,这是怎么回事呢?由于u3没有指定类型,它将依赖于从常量值中推断出的类型来接收类型分配。回想一下之前在省略变量类型部分中的讨论,常量字面量根据其文本表示映射到基本的 Go 类型。由于常量m2表示一个十进制值,编译器将推断其默认类型为float64,这将自动分配给变量u3,如下所示:

U3 = 1.4142135623730951  //float64 

如您所见,Go 对未指定类型的原始常量字面量的处理通过自动应用一些简单但有效的类型推断规则,在不牺牲类型安全性的情况下提高了语言的可用性。与其他语言不同,开发者不需要在值字面量中显式指定类型或执行某种类型的转换来使这成为可能。

常量声明块

如您所猜测的,常量声明可以组织为代码块以提高可读性。前面的示例可以重写如下:

const ( 
  a1, a2 string        = "Mastering", "Go" 
  b      rune          = 'G' 
  c      bool          = false 
  d      int32         = 111009 
  e      float32       = 2.71828 
  f      float64       = math.Pi * 2.0e+3 
  g      complex64     = 5.0i 
  h      time.Duration = 4 * time.Second 
... 
) 

golang.fyi/ch02/const2.go

常量枚举

常量的一种有趣用法是创建枚举值。使用声明块格式(如前一小节所示),您可以轻松创建数值递增的枚举整数值。只需将预声明的常量值 iota 赋给声明块中的常量标识符,如下面的代码示例所示:

const ( 
  StarHyperGiant = iota 
  StarSuperGiant 
  StarBrightGiant 
  StarGiant 
  StarSubGiant 
  StarDwarf 
  StarSubDwarf 
  StarWhiteDwarf 
  StarRedDwarf 
  StarBrownDwarf 
) 

golang.fyi/ch02/enum0.go

编译器将自动执行以下操作:

  • 将块中的每个成员声明为无类型的整型常量值

  • 使用零值初始化 iota

  • iota 或零赋给第一个常量成员(StarHyperGiant

  • 每个后续常量都被分配一个增加一的 int

因此,前面的常量列表将被分配从零到九的值序列。每当 const 出现在声明块中时,它都会将计数器重置为零。在以下代码片段中,每组常量分别从零到四进行枚举:

const ( 
  StarHyperGiant = iota 
  StarSuperGiant 
  StarBrightGiant 
  StarGiant 
  StarSubGiant 
) 
const ( 
  StarDwarf = iota 
  StarSubDwarf 
  StarWhiteDwarf 
  StarRedDwarf 
  StarBrownDwarf 
) 

golang.fyi/ch02/enum1.go

覆盖默认枚举类型

默认情况下,枚举常量被声明为无类型的整数值。然而,您可以通过为枚举常量提供显式的数值类型来覆盖枚举值的默认类型,如下面的代码示例所示:

const ( 
  StarDwarf byte = iota 
  StarSubDwarf 
  StarWhiteDwarf 
  StarRedDwarf 
  StarBrownDwarf 
) 

您可以指定任何可以表示整数或浮点值的数值类型。例如,在前面的代码示例中,每个常量将被声明为类型 byte

在表达式中使用 iota

iota 出现在表达式中时,相同的机制按预期工作。编译器将为 iota 的每个后续递增值应用表达式。以下示例将偶数分配给常量声明块中的枚举成员:

const ( 
  StarHyperGiant = 2.0*iota 
  StarSuperGiant 
  StarBrightGiant 
  StarGiant 
  StarSubGiant 
) 

golang.fyi/ch02/enum2.go

如您所预期的那样,前面的示例将偶数值分配给每个枚举常量,从 0 开始,如下面的输出所示:

 StarHyperGiant = 0    [float64]
    StarSuperGiant = 2    [float64]
    StarBrightGiant = 4   [float64]
    StarGiant = 6         [float64]
    StarSubGiant = 8      [float64] 

跳过枚举值

当处理枚举常量时,您可能希望丢弃某些不应包含在枚举中的值。这可以通过在枚举中所需位置将 iota 赋给空标识符来实现。例如,以下跳过了值 0 和 64

_              = iota    // value 0 
StarHyperGiant = 1 << iota 
StarSuperGiant 
StarBrightGiant 
StarGiant 
StarSubGiant 
_          // value 64 
StarDwarf 
StarSubDwarf 
StarWhiteDwarf 
StarRedDwarf 
StarBrownDwarf 

golang.fyi/ch02/enum3.go

由于我们跳过了 iota 位置 0,第一个分配的常量值位于位置 1。这导致表达式 1 << iota 解析为 1 << 1 = 2。同样,在第六个位置,表达式 1 << iota 返回 64。该值将被跳过,不会分配给任何常量,如下面的输出所示:

 StarHyperGiant = 2
    StarSuperGiant = 4
    StarBrightGiant = 8
    StarGiant = 16
    StarSubGiant = 32
    StarDwarf = 128
    StarSubDwarf = 256
    StarWhiteDwarf = 512
    StarRedDwarf = 1024
    StarBrownDwarf = 2048 

Go 运算符

保留其简洁的本质,Go 语言中的运算符确实如您所期望的那样工作,主要允许操作数组合成表达式。Go 语言的运算符没有像 C++或 Scala 中那样的操作符重载,因此没有隐藏的意外行为。这是设计者有意做出的决定,以保持语言的语义简单和可预测。

本节探讨了您开始学习 Go 语言时最常遇到的运算符。其他运算符将在本书的其他章节中介绍。

算术运算符

以下表格总结了 Go 语言支持的算术运算符。

运算符 操作 兼容类型
*, /, - 乘法、除法和减法 整数、浮点数和复数
% 余数 整数
+ 加法 整数、浮点数、复数和字符串(连接)

注意,加法运算符+可以应用于字符串,例如在表达式var i = "G is" + " for Go"中。两个字符串操作数连接起来创建一个新的字符串,并将其赋值给变量i

增量和减量运算符

与其他 C 语言类似的语言一样,Go 语言支持++(增量)和--(减量)运算符。当应用这些运算符时,它们分别将操作数的值增加或减少一。以下是一个使用减量运算符来以相反顺序遍历字符串s中的字母的函数示例:

func reverse(s string) { 
  for i := len(s) - 1; i >= 0; { 
    fmt.Print(string(s[i])) 
    i-- 
  } 
} 

重要提示:增量运算符和减量运算符是语句,而不是表达式,如下面的代码片段所示:

nextChar := i++       // syntax error 
fmt.Println("Current char", i--)   // syntax error 
nextChar++        // OK 

i:
for i := len(s) - 1; i >= 0; { 
  fmt.Print(string(s[i])) 
  --i   //syntax error 
} 

Go 语言赋值运算符

运算符 描述
= 简单赋值按预期工作。它将右操作数的值更新到左操作数。
:= 冒号等于运算符声明一个新变量,左侧运算符,并将其赋值为右侧操作数的值(和类型)。
+=, -=, *=, /=, %= 使用左右运算符执行指定的操作,并将结果存储在左运算符中。例如,a *= 8表示a = a * 8

按位运算符

Go 语言完全支持以最基本的形式操作值。以下总结了 Go 语言支持的按位运算符:

运算符 描述
& 按位与
&#124; 按位或
a ^ b 按位异或
&^ 按位与非
^a 一元按位补码
<< 左移
>> 右移

在位移操作中,右操作数必须是无符号整数或能够转换为无符号值。当左操作数是无类型的常量值时,编译器必须能够从其值推导出有符号整数类型,否则将无法编译。

Go 中的位移操作符也支持算术位移和逻辑位移。如果左操作数是无符号的,Go 会自动应用逻辑位移;如果是符号的,Go 将应用算术位移。

逻辑操作符

下表列出了 Go 语言对布尔值进行的逻辑操作:

操作符 操作
&& 逻辑与
| | 逻辑或
! 逻辑非

比较操作符

所有 Go 类型都可以用于比较,包括基本类型和组合类型。然而,只有字符串、整数和浮点值可以使用排序运算符进行比较,如下表所示:

操作符 操作 支持类型
== 等于 字符串、数值、布尔、接口、指针和结构体类型
!= 不等于 字符串、数值、布尔、接口、指针和结构体类型
<, <=, >, >= 排序操作符 字符串、整数和浮点数

操作符优先级

由于 Go 的操作符比其对应语言(如 C 或 Java)要少,因此其操作符优先级规则要简单得多。以下表格列出了 Go 的操作符优先级等级,从最高开始:

操作符 优先级
乘法 *, /, %, <<, >>, &, &^
加法 +, -, |, ^
比较操作符 ==, !=, <, <=, >, >=
逻辑与 &&
逻辑或 | |

概述

本章涵盖了 Go 语言基本结构的大量内容。它从 Go 源代码文本文件的结构开始,逐步介绍变量标识符、声明和初始化。本章还广泛介绍了 Go 的常量、常量声明和操作符。

在这一点上,你可能觉得关于语言及其语法的这么多基础信息让你有些不知所措。好消息是,你不必知道所有这些细节就能有效地使用这门语言。在接下来的章节中,我们将继续探讨 Go 语言的一些更有趣的方面,包括数据类型、函数和包。

第三章。Go 控制流

Go 从 C 家族语言中借用了其控制流语法的大部分。它支持所有预期的控制结构,包括 if...elseswitchfor 循环,甚至 goto。然而,whiledo...while 语句却明显缺失。本章以下内容将探讨 Go 的控制流元素,其中一些你可能已经熟悉,而其他则带来了一组在其他语言中找不到的新功能:

  • if 语句

  • switch 语句

  • 类型 Switch

  • for 语句

if 语句

Go 中的 if 语句借鉴了其他 C 类似语言的基本结构形式。当跟在 if 关键字后面的布尔表达式评估为 true 时,该语句有条件地执行一个代码块,如下面的简化的程序所示,该程序显示有关世界货币的信息:

import "fmt" 

type Currency struct { 
  Name    string 
  Country string 
  Number  int 
} 

var CAD = Currency{ 
    Name: "Canadian Dollar",  
    Country: "Canada",  
    Number: 124} 

var FJD = Currency{ 
    Name: "Fiji Dollar",  
    Country: "Fiji",  
    Number: 242} 

var JMD = Currency{ 
    Name: "Jamaican Dollar",  
    Country: "Jamaica",  
    Number: 388} 

var USD = Currency{ 
    Name: "US Dollar",  
    Country: "USA",  
    Number: 840} 

func main() { 
  num0 := 242 
  if num0 > 100 || num0 < 900 { 
    fmt.Println("Currency: ", num0) 
    printCurr(num0) 
  } else { 
    fmt.Println("Currency unknown") 
  } 

  if num1 := 388; num1 > 100 || num1 < 900 { 
    fmt.Println("Currency:", num1) 
    printCurr(num1) 
  } 
} 

func printCurr(number int) { 
  if CAD.Number == number { 
    fmt.Printf("Found: %+v\n", CAD) 
  } else if FJD.Number == number { 
    fmt.Printf("Found: %+v\n", FJD) 
  } else if JMD.Number == number { 
    fmt.Printf("Found: %+v\n", JMD) 
  } else if USD.Number == number { 
    fmt.Printf("Found: %+v\n", USD) 
  } else { 
    fmt.Println("No currency found with number", number) 
  } 
} 

golang.fyi/ch03/ifstmt.go

Go 中的 if 语句看起来与其他语言类似。然而,它省略了一些语法规则,同时强制执行新的规则:

  • 测试表达式的括号不是必需的。虽然以下 if 语句可以编译,但它不符合习惯用法:

          if (num0 > 100 || num0 < 900) { 
            fmt.Println("Currency: ", num0) 
            printCurr(num0) 
          } 
    
    
  • 使用以下代替:

          if num0 > 100 || num0 < 900 { 
            fmt.Println("Currency: ", num0) 
            printCurr(num0) 
          } 
    
    
  • 代码块的括号总是必需的。以下片段将无法编译:

          if num0 > 100 || num0 < 900 printCurr(num0) 
    
    
  • 然而,这将编译:

          if num0 > 100 || num0 < 900 {printCurr(num0)} 
    
    
  • 然而,将 if 语句写在一行或多行上(无论语句块多么简单)是习惯用法。这鼓励了良好的风格和清晰度。以下片段将无问题编译:

          if num0 > 100 || num0 < 900 {printCurr(num0)} 
    
    
  • 然而,语句的推荐习惯布局是使用多行,如下所示:

          if num0 > 100 || num0 < 900 { 
            printCurr(num0) 
          }
    
  • if 语句可以包含一个可选的 else 块,当 if 块中的表达式评估为 false 时执行。else 块中的代码必须使用多行括号包裹,如下面的片段所示:

          if num0 > 100 || num0 < 900 { 
            fmt.Println("Currency: ", num0) 
            printCurr(num0) 
          } else { 
            fmt.Println("Currency unknown") 
          } 
    
    
  • else 关键字可以立即跟在另一个 if 语句后面,形成一个 if...else...if 链,正如前面列出的源代码中的 printCurr() 函数所使用的那样:

          if CAD.Number == number { 
            fmt.Printf("Found: %+v\n", CAD) 
          } else if FJD.Number == number { 
            fmt.Printf("Found: %+v\n", FJD) 
          } 
    
    

if...else...if 语句链可以按需增长,并且可以通过一个可选的 else 语句来终止,以表达所有其他未测试的条件。再次强调,这是在 printCurr() 函数中完成的,该函数使用 if...else...if 块测试四个条件。最后,它包括一个 else 语句块来捕获任何其他未测试的条件:

func printCurr(number int) { 
  if CAD.Number == number { 
    fmt.Printf("Found: %+v\n", CAD) 
  } else if FJD.Number == number { 
    fmt.Printf("Found: %+v\n", FJD) 
  } else if JMD.Number == number { 
    fmt.Printf("Found: %+v\n", JMD) 
  } else if USD.Number == number { 
    fmt.Printf("Found: %+v\n", USD) 
  } else { 
    fmt.Println("No currency found with number", number) 
  } 
}

然而,在 Go 中,编写这种深层次的 if...else...if 代码块的习惯用法,是使用无表达式的 switch 语句。这将在后面的 Switch 语句 部分中介绍。

if 语句的初始化

if 语句支持复合语法,其中测试表达式前面有一个初始化语句。在运行时,初始化在评估测试表达式之前执行,如下面的代码片段(来自前面列出的程序)所示:

if num1 := 388; num1 > 100 || num1 < 900 { 
  fmt.Println("Currency:", num1) 
  printCurr(num1) 
}  

初始化语句遵循正常的变量声明和初始化规则。初始化变量的作用域绑定到 if 语句块,超出此范围它们将变得不可达。这是 Go 中常用的一种惯用法,并且在本章中介绍的其他流程控制结构中也得到了支持。

switch 语句

Go 还支持类似于 C 或 Java 等其他语言的 switch 语句。Go 中的 switch 语句通过评估 case 子句中的值或表达式来实现多路分支,如下所示,这是缩写的源代码:

import "fmt" 

type Curr struct { 
  Currency string 
  Name     string 
  Country  string 
  Number   int 
} 

var currencies = []Curr{ 
  Curr{"DZD", "Algerian Dinar", "Algeria", 12}, 
  Curr{"AUD", "Australian Dollar", "Australia", 36}, 
  Curr{"EUR", "Euro", "Belgium", 978}, 
  Curr{"CLP", "Chilean Peso", "Chile", 152}, 
  Curr{"EUR", "Euro", "Greece", 978}, 
  Curr{"HTG", "Gourde", "Haiti", 332}, 
  ... 
} 

func isDollar(curr Curr) bool { 
  var bool result 
  switch curr { 
  default: 
    result = false 
  case Curr{"AUD", "Australian Dollar", "Australia", 36}: 
    result = true 
  case Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344}: 
    result = true 
  case Curr{"USD", "US Dollar", "United States", 840}: 
    result = true 
  } 
  return result 
} 
func isDollar2(curr Curr) bool { 
  dollars := []Curr{currencies[2], currencies[6], currencies[9]} 
  switch curr { 
  default: 
    return false 
  case dollars[0]: 
    fallthrough 
  case dollars[1]: 
    fallthrough 
  case dollars[2]: 
    return true 
  } 
  return false 
} 

func isEuro(curr Curr) bool { 
  switch curr { 
  case currencies[2], currencies[4], currencies[10]: 
    return true 
  default: 
    return false 
  } 
} 

func main() { 
  curr := Curr{"EUR", "Euro", "Italy", 978} 
  if isDollar(curr) { 
    fmt.Printf("%+v is Dollar currency\n", curr) 
  } else if isEuro(curr) { 
    fmt.Printf("%+v is Euro currency\n", curr) 
  } else { 
    fmt.Println("Currency is not Dollar or Euro") 
  } 
  dol := Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344} 
  if isDollar2(dol) { 
    fmt.Println("Dollar currency found:", dol) 
  } 
} 

golang.fyi/ch03/switchstmt.go

Go 中的 switch 语句有一些有趣的特性和规则,使得它易于使用和推理:

  • 从语义上讲,Go 的 switch 语句可以在两个上下文中使用:

    • 表达式 switch 语句

    • 类型 switch 语句

  • 可以使用 break 语句提前退出 switch 代码块。

  • switch 语句可以包含一个默认情况,当没有其他情况表达式评估为匹配时。只能有一个默认情况,并且它可以放置在 switch 块内的任何位置。

使用表达式切换

表达式切换非常灵活,可以在许多需要程序控制流遵循多个路径的上下文中使用。表达式切换支持许多属性,如下列要点所述:

  • 表达式切换可以测试任何类型的值。例如,以下代码片段(来自前面的程序列表)测试了类型为 struct 的变量 Curr

          func isDollar(curr Curr) bool { 
            var bool result 
            switch curr { 
              default: 
              result = false 
              case Curr{"AUD", "Australian Dollar", "Australia", 36}: 
              result = true 
              case Curr{"HKD", "Hong Kong Dollar", "Hong Koong", 344}: 
              result = true 
              case Curr{"USD", "US Dollar", "United States", 840}: 
              result = true 
            } 
            return result 
          } 
    
  • case 子句中的表达式从左到右、从上到下进行评估,直到找到一个与 switch 表达式相等的值(或表达式)。

  • 当遇到第一个与 switch 表达式匹配的情况时,程序将执行 case 块中的语句,然后立即退出 switch 块。与其他语言不同,Go 的 case 语句不需要使用 break 来避免跌入下一个情况(请参阅 Fallthrough cases 部分)。例如,调用 isDollar(Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344}) 将匹配前面函数中的第二个 case 语句。代码将结果设置为 true 并立即退出 switch 代码块。

  • Case 子句可以有多个值(或表达式),它们之间用逗号分隔,并隐含逻辑 OR 操作符。例如,在以下代码片段中,switch 表达式 curr 被测试与 currencies[2]currencies[4]currencies[10] 的值匹配,使用一个情况子句直到找到匹配项:

          func isEuro(curr Curr) bool { 
            switch curr { 
              case currencies[2], currencies[4], currencies[10]: 
              return true 
              default: 
              return false 
            } 
          } 
    
    
  • switch 语句是 Go 中编写复杂条件语句的更简洁、更首选的惯用法。当将前面的代码片段与以下使用 if 语句执行相同比较的代码进行比较时,这一点很明显:

          func isEuro(curr Curr) bool { 
            if curr == currencies[2] || curr == currencies[4],  
            curr == currencies[10]{ 
            return true 
          }else{ 
            return false 
          } 
        } 
    
    

跌入情况

switch statement with a fallthrough in each case block:
func isDollar2(curr Curr) bool { 
  switch curr { 
  case Curr{"AUD", "Australian Dollar", "Australia", 36}: 
    fallthrough 
  case Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344}: 
    fallthrough 
  case Curr{"USD", "US Dollar", "United States", 840}: 
    return true 
  default: 
    return false 
  } 
} 

golang.fyi/ch03/switchstmt.go

当匹配到某个 case 时,fallthrough 语句会级联到后续 case 块的第一个语句。因此,如果 curr = Curr{"AUD", "Australian Dollar", "Australia", 36}",第一个 case 将会被匹配。然后流程级联到第二个 case 块的第一个语句,它也是一个 fallthrough语句。这导致第三个 case 块的第一个语句执行,以返回true`。这在功能上等同于以下代码片段:

switch curr {  
case Curr{"AUD", "Australian Dollar", "Australia", 36},  
     Curr{"HKD", "Hong Kong Dollar", "Hong Kong", 344},  
     Curr{"USD", "US Dollar", "United States", 840}:  
  return true 
default: 
   return false 
}  

无表达式开关

Go 支持一种不指定表达式的 switch 语句形式。在这种格式中,每个 case 表达式必须评估为布尔值 true。以下简化的源代码说明了无表达式的 switch 语句的用法,如函数 find() 中所示。该函数遍历 Curr 值的切片,根据传入的 struct 函数中的字段值搜索匹配项:

import ( 
  "fmt" 
  "strings" 
) 
type Curr struct { 
  Currency string 
  Name     string 
  Country  string 
  Number   int 
} 

var currencies = []Curr{ 
  Curr{"DZD", "Algerian Dinar", "Algeria", 12}, 
  Curr{"AUD", "Australian Dollar", "Australia", 36}, 
  Curr{"EUR", "Euro", "Belgium", 978}, 
  Curr{"CLP", "Chilean Peso", "Chile", 152}, 
  ... 
} 

func find(name string) { 
  for i := 0; i < 10; i++ { 
    c := currencies[i] 
    switch { 
    case strings.Contains(c.Currency, name), 
      strings.Contains(c.Name, name), 
      strings.Contains(c.Country, name): 
      fmt.Println("Found", c) 
    } 
  } 
} 

golang.fyi/ch03/switchstmt2.go

注意在先前的例子中,函数 find() 中的 switch 语句没有包含表达式。每个 case 表达式之间用逗号分隔,并且必须评估为布尔值,每个 case 表达式之间隐含地使用 OR 操作符。先前的 switch 语句等同于以下使用 if 语句实现相同逻辑的用法:

func find(name string) { 
  for I := 0; i < 10; i++ { 
    c := currencies[i] 
    if strings.Contains(c.Currency, name) || 
      strings.Contains(c.Name, name) || 
      strings.Contains(c.Country, name){ 
      fmt.Println""Foun"", c) 
    } 
  } 
} 

开关初始化器

switch 关键字可以立即后跟一个简单的初始化语句,其中可以声明并初始化局部于 switch 代码块中的变量。这种方便的语法在初始化语句和 switch 表达式之间使用分号来声明变量,这些变量可以出现在 switch 代码块的任何位置。以下代码示例展示了如何通过初始化两个变量,namecurr,作为 switch 声明的一部分来实现这一点:

func assertEuro(c Curr) bool {  
  switch name, curr := "Euro", "EUR"; {  
  case c.Name == name:  
    return true  
  case c.Currency == curr:  
    return true 
  }  
  return false  
} 

switch statement with an initializer. Notice the trailing semi-colon to indicate the separation between the initialization statement and the expression area for the switch. In the example, however, the switch expression is empty.

类型开关

给定 Go 强大的类型支持,该语言支持查询类型信息的能力应该不会令人惊讶。type switch 是一个使用 Go 接口类型来比较值的底层类型信息的语句(或表达式)。关于接口类型和类型断言的完整讨论超出了本节的范围。您可以在第八章方法、接口和对象中找到更多关于这个主题的详细信息。

尽管如此,为了完整性,这里提供了一个关于类型开关的简短讨论。目前,您需要知道的是,Go 提供了 interface{} 类型,或空接口,作为类型系统中所有其他类型的超类型。当一个值被赋予 interface{} 类型时,可以使用 type switch 来查询其底层类型信息,如下面的代码片段中的 findAny() 函数所示,以查询其底层类型信息:

func find(name string) { 
  for i := 0; i < 10; i++ { 
    c := currencies[i] 
    switch { 
    case strings.Contains(c.Currency, name), 
      strings.Contains(c.Name, name), 
      strings.Contains(c.Country, name): 
      fmt.Println("Found", c) 
    } 
  } 
}  

func findNumber(num int) { 
  for _, curr := range currencies { 
    if curr.Number == num { 
      fmt.Println("Found", curr) 
    } 
  } 
}  

func findAny(val interface{}) {  
  switch i := val.(type) {  
  case int:  
    findNumber(i)  
  case string:  
    find(i)  
  default:  
    fmt.Printf("Unable to search with type %T\n", val)  
  }  
} 

func main() { 
findAny("Peso") 
  findAny(404) 
  findAny(978) 
  findAny(false) 
} 

golang.fyi/ch03/switchstmt2.go

函数 findAny()interface{} 作为其参数。使用 switch 语句通过类型断言表达式确定变量 val 的底层类型和值:

switch i := val.(type) 

注意前面类型断言表达式中关键字 type 的使用。每个情况子句将针对从 val.(type) 查询的类型信息进行测试。变量 i 将分配底层类型的实际值,并用于调用具有相应值的函数。默认块被调用以防止将任何意外的类型分配给参数 val。然后可以像以下代码片段所示那样使用 findAny 函数调用具有不同类型的值:

findAny("Peso")  
findAny(404)  
findAny(978)  
findAny(false)  

for 语句

作为与 C 家族相关的语言,Go 也支持 for 循环样式控制结构。然而,正如你现在可能已经预料到的,Go 的 for 语句以有趣且简单的方式工作。Go 中的 for 语句支持四种不同的惯用法,如下表所示:

for 语句 用途

| 条件 for | 用于在语义上替换 whiledo...while 循环:

for x < 10 { 
... 
}

|

| 无限循环 | 可以省略条件表达式以创建无限循环:

for {
...
}

|

| 传统 | 这是 C 家族 for 循环的传统形式,具有初始化、测试和更新子句:

for x:=0; x < 10; x++ {
...
}

|

| 范围 for | 用于遍历表示存储在数组、字符串(rune 数组)、切片、映射和通道中的项目集合的表达式:

for i, val := range values {
...
}

|

注意,与 Go 中的所有其他控制语句一样,for 语句在其表达式周围不使用括号。所有循环代码块中的语句都必须在花括号内,否则编译器将产生错误。

条件

for 条件使用与在其他语言中找到的 while 循环语义上等价的结构。它使用关键字 for,后跟一个布尔表达式,只要评估为真,循环就会继续。以下简化的源代码列表显示了这种形式的 for 循环的示例:

type Curr struct {  
  Currency string  
  Name     string  
  Country  string  
  Number   int  
}  
var currencies = []Curr{  
  Curr{"KES", "Kenyan Shilling", "Kenya", 404},  
  Curr{"AUD", "Australian Dollar", "Australia", 36},  
... 
} 

func listCurrs(howlong int) {  
  i := 0  
  for i < len(currencies) {  
    fmt.Println(currencies[i])  
    i++  
  }  
} 

golang.fyi/ch03/forstmt.go

在函数 listCurrs() 中,for 语句在条件表达式 i < len(currencencies) 返回 true 时迭代。必须注意确保每次迭代时更新 i 的值,以避免创建意外的无限循环。

无限循环

当在 for 语句中省略布尔表达式时,循环将无限进行,如下面的示例所示:

for { 
  // statements here 
} 

这等价于在其他语言(如 C 或 Java)中找到的 for(;;)while (true)

传统 for 语句

sortByNumber:
type Curr struct {  
  Currency string  
  Name     string  
  Country  string  
  Number   int  
}  

var currencies = []Curr{  
  Curr{"KES", "Kenyan Shilling", "Kenya", 404},  
  Curr{"AUD", "Australian Dollar", "Australia", 36},  
... 
} 

func sortByNumber() {  
  N := len(currencies)  
  for i := 0; i < N-1; i++ {  
     currMin := i  
     for k := i + 1; k < N; k++ {  
    if currencies[k].Number < currencies[currMin].Number {  
         currMin = k  
    }  
     }  
     // swap  
     if currMin != i {  
        temp := currencies[i]  
    currencies[i] = currencies[currMin]  
    currencies[currMin] = temp  
     } 
  }  
} 

结果表明,传统的 for 语句是之前讨论的其他循环形式的超集,如下表所示:

for 语句 描述

|

k:=initialize()
for ; k < 10; 
++{
...
}
初始化语句被省略。变量 kfor 语句外部初始化。然而,习惯上是用 for 语句初始化变量。

|

for k:=0; k < 10;{
...
}
在这里省略了 update 语句(在最后一个分号之后)。开发者必须在其他地方提供更新逻辑,否则可能会创建一个无限循环。

|

for ; k < 10;{
...
}
这与前面讨论的 for 条件形式(for k < 10 { ... })等价。再次强调,变量 k 应在循环之前声明。必须小心更新 k,否则可能会创建一个无限循环。

|

for k:=0; ;k++{
...
}
这里省略了条件表达式。与之前一样,这会评估条件为 true,如果没有在循环中引入适当的终止逻辑,则会产生无限循环。

|

for ; ;{ ... }
这与形式 for{ ... } 等价,并会产生无限循环。

for 循环中,初始化语句和 update 语句是常规的 Go 语句。因此,它们可以用来初始化和更新多个变量,正如 Go 所支持的。为了说明这一点,下一个示例在语句子句中同时初始化和更新了两个变量 w1w2

import ( 
  "fmt" 
  "math/rand" 
) 

var list1 = []string{ 
"break", "lake", "go",  
"right", "strong",  
"kite", "hello"}  

var list2 = []string{ 
"fix", "river", "stop",  
"left", "weak", "flight",  
"bye"}  

func main() {  
  rand.Seed(31)  
  for w1, w2:= nextPair();  
  w1 != "go" && w2 != "stop";  
  w1, w2 = nextPair() {  

    fmt.Printf("Word Pair -> [%s, %s]\n", w1, w2)  
  }  
}  

func nextPair() (w1, w2 string) {  
  pos := rand.Intn(len(list1))  
  return list1[pos], list2[pos]  
} 

golang.fyi/ch03/forstmt2.go

初始化语句通过调用函数 nextPair() 来初始化变量 w1w2。条件使用一个复合逻辑表达式,只要它评估为真,循环就会继续运行。最后,变量 w1w2 都通过调用 nextPair() 在循环的每次迭代中更新。

for range

最后,for 语句支持一种额外的形式,使用关键字 range 来遍历一个求值为数组、切片、映射、字符串或通道的表达式。for-range 循环具有以下通用形式:

for [<标识符列表>] := range <表达式>

根据 range 表达式产生的类型,每次迭代可以产生多达两个变量,如下表所示:

范围表达式 范围变量

| 遍历数组或切片:

for i, v := range []V{1,2,3} {
...
}
范围生成两个值,其中 i 是循环索引,v 是集合中的值 v[i]。关于数组和切片的进一步讨论请参阅第七章,复合类型

| 遍历字符串值:

for i, v := range "Hello" {
...
}
范围生成两个值,其中 i 是字符串中的字节索引,v 是 UTF-8 编码的字节值,在 v[i] 处返回为 rune。关于字符串类型的进一步讨论请参阅第四章,数据类型

| 遍历映射:

for k, v := range map[K]V {
...
}
range 产生两个值,其中 k 被分配为类型 K 的映射键的值,而 v 被存储在 map[k] 中,类型为 V。关于映射的进一步讨论请参阅第七章,复合类型

在通道值上循环:

var ch chan T
for c := range ch {
...
}
有关通道的充分讨论请参阅第九章,并发。通道是一种双向导线,能够接收和发出值。for...range 语句将每次从通道接收到的值分配给变量 c

你应该知道,每次迭代发出的值是源中存储的原始项的副本。例如,在以下程序中,循环完成后切片中的值不会更新:

import "fmt" 

func main() { 
  vals := []int{4, 2, 6} 
  for _, v := range vals { 
    v-- 
  } 
  fmt.Println(vals) 
} 

要使用 for...range 循环更新原始值,请使用索引表达式访问原始值,如下所示。

func main() { 
  vals := []int{4, 2, 6} 
  for i, v := range vals { 
    vals[i] = v - 1 
  } 
  fmt.Println(vals) 
} 

在前面的示例中,值 i 被用于切片索引表达式 vals[i] 以更新存储在切片中的原始值。如果你只需要访问数组、切片或字符串(或映射的键)的索引值,则可以省略迭代值(赋值中的第二个变量)。例如,在以下示例中,for...range 语句仅在每个迭代中发出当前索引值:

func printCurrencies() { 
  for i := range currencies { 
    fmt.Printf("%d: %v\n", i, currencies[i]) 
  } 
} 

golang.fyi/ch03/for-range-stmt.go

最后,有些情况下你可能对迭代生成的任何值都不感兴趣,而是对迭代机制本身感兴趣。Go 1.4 版本中引入了 for 语句的下一形式,以表达没有变量声明的 for range,如下代码片段所示:

func main() { 
  for range []int{1,1,1,1} { 
    fmt.Println("Looping") 
  } 
}  

上述代码将在标准输出上打印 "Looping" 四次。当范围表达式在通道上时,会使用这种 for...range 循环的形式。它用于简单地通知通道中存在值。

break、continue 和 goto 语句

Go 支持一组专门设计的语句,用于突然退出正在运行的代码块,例如 switch 和 for 语句,并将控制权转移到代码的不同部分。所有三个语句都可以接受一个标签标识符,该标识符指定了控制要转移到的代码中的目标位置。

标签标识符

在深入本节的核心之前,看看这些语句使用的标签是值得的。在 Go 中声明标签需要标识符后跟冒号,如下面的代码片段所示:

DoSearch: 

标签的命名是风格问题。然而,应该遵循上一章中提到的标识符命名指南。标签必须位于函数内部。Go 编译器不允许在代码中悬挂未使用的标签。与变量类似,如果声明了标签,必须在代码中引用它。

break语句

如同其他 C-like 语言一样,Go 的break语句终止并退出最内层封装的switchfor语句代码块,并将控制权转移到运行程序的另一部分。break语句可以接受一个可选的标签标识符,指定程序流程将从中恢复的标签位置。以下是关于break语句标签的一些属性需要记住:

  • 标签必须在包含break语句的同一运行函数内声明

  • 声明的标签必须立即跟随封装的控制语句(一个for循环或switch语句),其中嵌套了break语句

如果break语句后面跟着一个标签,控制权将转移到标签所在的位置,而不是标签后的语句。如果没有提供标签,break语句将突然退出,并将控制权转移到其封装的for语句(或switch语句)块之后的下一个语句。

以下代码是一个过度夸张的线性搜索,用于说明break语句的工作原理。它执行单词搜索,并在找到切片中单词的第一个实例时退出:

import ( 
  "fmt" 
) 

var words = [][]string{  
  {"break", "lake", "go", "right", "strong", "kite", "hello"},  
  {"fix", "river", "stop", "left", "weak", "flight", "bye"},  
  {"fix", "lake", "slow", "middle", "sturdy", "high", "hello"},  
}  

func search(w string) {  
DoSearch:  
  for i := 0; i < len(words); i++ {  
    for k := 0; k < len(words[i]); k++ {  
      if words[i][k] == w {  
        fmt.Println("Found", w)  
        break DoSearch  
      }  
    }  
  }  
}  

break DoSearch statement will essentially exit out of the innermost for loop and cause the execution flow to continue after the outermost labeled for statement, which in this example, will simply end the program.

continue语句

continue语句会导致控制流立即终止封装的for循环的当前迭代,并跳转到下一个迭代。continue语句也可以接受一个可选的标签。标签具有与break语句类似的属性:

  • 标签必须在包含continue语句的同一运行函数内声明

  • 声明的标签必须立即跟随一个封装的for循环语句,其中嵌套了continue语句

continue语句存在于for语句块中时,如果存在,for循环将突然终止,并将控制权转移到最外层的带有标签的for循环块以继续。如果没有指定标签,continue语句将简单地转移到其封装的for循环块的开始,以继续下一个迭代。

为了说明,让我们回顾一下之前的单词搜索示例。这个版本使用了一个continue语句,它会导致在切片中找到搜索单词的多个实例:

func search(w string) {  
DoSearch:  
  for i := 0; i < len(words); i++ {  
    for k := 0; k < len(words[i]); k++ {  
      if words[i][k] == w {  
        fmt.Println("Found", w)  
        continue DoSearch  
      }  
    }  
  }  
} 

golang.fyi/ch03/breakstmt2.go

continue DoSearch语句会导致最内层循环的当前迭代停止,并将控制权转移到带有标签的外层循环,使其继续下一个迭代。

goto语句

goto语句更加灵活,因为它允许将流程控制转移到函数内定义的目标标签的任意位置。goto语句导致控制突然转移到由goto语句引用的标签。以下是一个简单但功能性的示例,展示了 Go 的goto语句的实际应用:

import "fmt" 

func main() {  
  var a string 
Start:  
  for {  
    switch {  
    case a < "aaa":  
      goto A  
    case a >= "aaa" && a < "aaabbb":  
      goto B  
    case a == "aaabbb":  
      break Start  
    }  
  A:  
    a += "a"  
    continue Start  
  B:  
    a += "b"  
    continue Start  
  }  
fmt.Println(a) 
} 

golang.fyi/ch03/gotostmt.go

代码使用goto语句跳转到main()函数的不同部分。注意,goto语句可以针对代码中任何地方定义的标签。代码中多余的Start:标签保留是为了完整性,在此上下文中并非必需(因为不带标签的continue会有相同的效果)。以下是在使用goto语句时的一些指导:

  • 除非实现的逻辑只能通过goto分支来实现,否则请避免使用goto语句。这是因为过度使用goto语句会使代码更难推理和调试。

  • 当可能时,将goto语句及其目标标签放置在相同的封装代码块内。

  • 避免将标签放置在goto语句会导致跳过新变量声明或重新声明变量的地方。

  • Go 允许你从内部跳转到外部的封装代码块。

  • 如果你尝试跳转到同级或封装代码块,将会出现编译错误。

摘要

本章提供了 Go 中控制流机制的概述,包括ifswitchfor语句。虽然 Go 的流程控制结构看起来简单且易于使用,但它们功能强大,实现了现代语言所期望的所有分支原语。读者通过充分的细节和示例了解每个概念,以确保对主题的清晰理解。下一章通过介绍 Go 类型系统,继续深入探讨 Go 的基本概念。

第四章:数据类型

Go 是一种强类型语言,这意味着任何存储值(或产生值的表达式)的语言元素都有一个与之关联的类型。在本章中,读者将了解类型系统的特性,当他们探索语言支持的语言数据类型时,这些类型将在以下内容中概述:

  • Go 类型

  • 数值类型

  • 布尔类型

  • 指针

  • 类型声明

  • 类型转换

Go 类型

为了帮助启动关于类型的对话,让我们看看可用的类型。Go 实现了一个简单的类型系统,它为程序员提供了直接控制内存分配和布局的能力。当程序声明一个变量时,必须发生两件事:

  • 变量必须接收一个类型

  • 变量也将被绑定到一个值(即使没有分配)

这允许类型系统分配存储声明值所需的字节数。声明变量的内存布局直接映射到它们的声明类型。没有类型装箱或自动类型转换发生。你期望分配的空间实际上是在内存中保留的。

为了证明这一点,以下程序使用一个名为 unsafe 的特殊包来绕过类型系统并提取声明变量的内存大小信息。重要的是要注意,这纯粹是说明性的,因为大多数程序并不经常使用 unsafe 包。

package main 
import ( 
   "fmt" 
   "unsafe" 
) 

var ( 
   a uint8   = 72 
   b int32   = 240 
   c uint64  = 1234564321 
   d float32 = 12432345.232 
   e int64   = -1233453443434 
   f float64 = -1.43555622362467 
   g int16   = 32000 
   h [5]rune = [5]rune{'O', 'n', 'T', 'o', 'p'} 
) 

func main() { 
   fmt.Printf("a = %v [%T, %d bits]\n", a, a, unsafe.Sizeof(a)*8) 
   fmt.Printf("b = %v [%T, %d bits]\n", b, b, unsafe.Sizeof(b)*8) 
   fmt.Printf("c = %v [%T, %d bits]\n", c, c, unsafe.Sizeof(c)*8) 
   fmt.Printf("d = %v [%T, %d bits]\n", d, d, unsafe.Sizeof(d)*8) 
   fmt.Printf("e = %v [%T, %d bits]\n", e, e, unsafe.Sizeof(e)*8) 
   fmt.Printf("f = %v [%T, %d bits]\n", f, f, unsafe.Sizeof(f)*8) 
   fmt.Printf("g = %v [%T, %d bits]\n", g, g, unsafe.Sizeof(g)*8) 
   fmt.Printf("h = %v [%T, %d bits]\n", h, h, unsafe.Sizeof(h)*8) 
} 

golang.fyi/ch04/alloc.go

当程序执行时,它将打印出每个声明变量消耗的内存量(以位为单位):

$>go run alloc.go
a = 72 [uint8, 8 bits]
b = 240 [int32, 32 bits]
c = 1234564321 [uint64, 64 bits]
d = 1.2432345e+07 [float32, 32 bits]
e = -1233453443434 [int64, 64 bits]
f = -1.43555622362467 [float64, 64 bits]
g = 32000 [int16, 16 bits]
h = [79 110 84 111 112] [[5]int32, 160 bits]

从前面的输出中,我们可以看到变量 a(类型为 uint8)将使用八个位(或一个字节)存储,变量 b 使用 32 位(或四个字节),依此类推。结合影响内存消耗的能力以及 Go 对指针类型的支持,程序员能够强有力地控制程序中内存的分配和消耗。

本章将介绍以下表格中列出的类型。它们包括基本类型,如数值、布尔值和字符串:

类型 描述
string 用于存储文本值的类型
rune 用于表示字符的整数类型(int32)。
byte, int, int8, int16, int32, int64, rune, uint, uint8, uint16, uint32, uint64, uintptr 用于存储整数值的类型。
float32, float64 用于存储浮点十进制值的类型。
complex64, complex128 可以表示具有实部和虚部的复数的类型。
bool 用于布尔值的类型。
*T, 指向类型 T 的指针 表示存储类型 T 值的内存地址的类型。

Go 支持的其余类型,如以下表格中列出,包括复合类型、接口、函数和通道。它们将在各自的章节中详细说明。

类型 描述
数组 [n]T 一个有序的、大小为 n 的、数值索引的元素序列集合,元素类型为 T
切片 []T 一个由类型 T 的元素组成的、大小未指定的、数值索引的序列集合。
struct{} 结构体是由称为字段的元素组成的复合类型(可以想象为一个对象)。
map[K]T 一个由类型 T 的元素组成的、无序序列,由任意类型的键 K 索引。
interface{} 一个命名函数声明集,定义了一组可以被其他类型实现的操作。
func (T) R 表示具有给定参数类型 T 和返回类型 R 的所有函数的类型。
chan T 一个用于发送或接收类型 T 值的内部通信通道的类型。

数值类型

Go 的数值类型包括对从 8 位到 64 位各种大小的整数和小数值的支持。每种数值类型在内存中都有自己的布局,并且由类型系统视为独特。为了强制执行这一点,并避免在将 Go 移植到不同平台时产生任何混淆,数值类型的名称反映了其大小要求。例如,类型 *int16* 表示一个使用 16 位内部存储的整数类型。这意味着在赋值、表达式和操作跨越类型边界时,数值值必须显式转换。

以下程序并不十分功能化,因为所有值都分配给了空白标识符。然而,它说明了 Go 支持的所有数值数据类型。

package main 
import ( 
   "math" 
   "unsafe" 
) 

var _ int8 = 12 
var _ int16 = -400 
var _ int32 = 12022 
var _ int64 = 1 << 33 
var _ int = 3 + 1415 

var _ uint8 = 18 
var _ uint16 = 44 
var _ uint32 = 133121 
var i uint64 = 23113233 
var _ uint = 7542 
var _ byte = 255 
var _ uintptr = unsafe.Sizeof(i) 

var _ float32 = 0.5772156649 
var _ float64 = math.Pi 

var _ complex64 = 3.5 + 2i 
var _ complex128 = -5.0i 

func main() { 
   fmt.Println("all types declared!") 
} 

golang.fyi/ch04/nums.go

无符号整数类型

以下表格列出了 Go 中可以表示无符号整数及其存储要求的所有类型:

类型 大小 描述
uint8 无符号 8 位 范围 0 - 255
uint16 无符号 16 位 范围 0 - 65535
uint32 无符号 32 位 范围 0 - 4294967295
uint64 无符号 64 位 范围 0 - 18446744073709551615
uint 实现特定 一个预定义的类型,用于表示 32 或 64 位整数。截至 Go 1.x 版本,uint 表示 32 位无符号整数。
byte 无符号 8 位 uint8 类型的别名。
uintptr 无符号 一种无符号整数类型,用于存储底层机器架构的指针(内存地址)。

有符号整数类型

以下表格列出了 Go 中可以表示有符号整数及其存储要求的所有类型:

类型 大小 描述
int8 有符号 8 位 范围 -128 - 127
int16 有符号 16 位 范围 -32768 - 32767
int32 有符号 32 位 范围 -2147483648 - 2147483647
int64 有符号 64 位 范围 -9223372036854775808 - 9223372036854775807
int 实现特定 一个预定义的类型,用于表示 32 或 64 位整数。截至 Go 1.x 版本,int 表示 32 位有符号整数。

浮点数类型

Go 支持以下类型,用于使用 IEEE 标准表示十进制值:

类型 大小 描述
float32 有符号 32 位 IEEE-754 标准表示的单精度浮点值。
float64 有符号 64 位 IEEE-754 标准表示的双精度浮点值。

复数类型

Go 还支持以下表格所示,具有实部和虚部的复数表示:

类型 大小 描述
complex64 float32 表示具有实部和虚部的复数,实部和虚部存储为 float32 值。
complex128 float64 表示具有实部和虚部的复数,实部和虚部存储为 float64 值。

数值文字

Go 支持使用数字序列的自然表示法表示整数值,结合符号和小数点(如前例所示)。可选地,Go 整数文字也可以表示十六进制和八进制数字,如下面的程序所示:

package main 
import "fmt" 

func main() { 
   vals := []int{ 
       1024, 
       0x0FF1CE, 
       0x8BADF00D, 
       0xBEEF, 
       0777, 
   } 
   for _, i := range vals { 
         if i == 0xBEEF { 
               fmt.Printf("Got %d\n", i) 
               break 
         } 
   } 
} 

golang.fyi/ch04/intslit.go

十六进制值以 0x 或 (0X) 前缀开头,而八进制值以数字 0 开头,如前例所示。浮点值可以使用十进制和指数表示法表示,如下例所示:

package main 

import "fmt" 

func main() { 
   p := 3.1415926535 
   e := .5772156649 
   x := 7.2E-5 
   y := 1.616199e-35 
   z := .416833e32 

   fmt.Println(p, e, x, y, z) 
} 

golang.fyi/ch04/floats.go

之前的程序展示了 Go 中浮点文字的几种表示形式。数字可以包含一个可选的指数部分,由数字末尾的 e(或 E)表示。例如,代码中的 1.616199e-35 表示数值 1.616199 x 10^(-35)。最后,Go 支持以下示例所示的表达复数的文字:

package main 
import "fmt" 

func main() { 
   a := -3.5 + 2i 
   fmt.Printf("%v\n", a) 
   fmt.Printf("%+g, %+g\n", real(a), imag(a)) 
} 

golang.fyi/ch04/complex.go

在上一个示例中,变量 a 被分配了一个具有实部和虚部的复数。虚数文字是一个浮点数后跟字母 i。请注意,Go 还提供了两个内置函数,real()imag(),可以将复数分解为其实部和虚部。

布尔类型

在 Go 中,布尔二进制值使用 bool 类型存储。尽管 bool 类型的变量存储为一个字节的值,但它并不是一个数值的别名。Go 提供了两个预定义的文字 truefalse,用于表示布尔值,如下例所示:

package main 
import "fmt" 

func main() { 
   var readyToGo bool = false 
   if !readyToGo { 
       fmt.Println("Come on") 
   } else { 
       fmt.Println("Let's go!") 
   } 
} 

golang.fyi/ch04/bool.go

运行时和字符串类型

为了开始我们关于 runestring 类型的讨论,一些背景信息是必要的。Go 可以将其源代码中的字符和字符串文字常量视为 Unicode。这是一个全球标准,其目标是通过对每个字符分配一个数值(称为代码点)来编目已知书写系统的符号。

默认情况下,Go 内置支持 UTF-8,这是一种高效编码和存储 Unicode 数值的方法。这就是继续本主题所需的全部背景知识。本书范围之外将不再讨论更多细节。

符文

那么,rune类型究竟与 Unicode 有什么关系?runeint32类型的别名。它专门用于存储编码为 UTF-8 的 Unicode 整数值。让我们看看以下程序中的某些rune字面量:

符文

golang.fyi/ch04/rune.go

前一个程序中的每个变量都存储一个作为rune值的 Unicode 字符。在 Go 中,rune可以指定为单引号包围的字符串字面量常量。字面量可以是以下之一:

  • 可打印字符(如变量char1char2char3所示)

  • 使用反斜杠转义的非打印控制值(如制表符、换行符、换行等)的单个字符

  • \u后跟直接 Unicode 值(\u0369

  • \x后跟两个十六进制数字

  • 一个反斜杠后跟三个八进制数字(\045

无论单引号内的rune字面量值如何,编译器都会编译并分配一个整数值,如前一个变量的打印输出所示:

$>go run runes.go
8
9
10
632
2438
35486
873
250
37 

字符串

txt being assigned a string literal containing seven characters including two embedded Chinese characters. As referenced earlier, the Go compiler will automatically interpret string literal values as Unicode characters and encode them using UTF-8\. This means that under the cover, each literal character is stored as a rune and may end up taking more than one byte for storage per visible character. In fact, when the program is executed, it prints the length of txt as 11, instead of the expected seven characters for the string, accounting for the additional bytes used for the Chinese symbols.

解释和原始字符串字面量

txt2 and txt3 respectively. As you can see, these two literals have the exact same content, however, the compiler will treat them differently:
var ( 
   txt2 = "\u6C34\x20brings\x20\x6c\x69\x66\x65." 
   txt3 = ` 
   \u6C34\x20 
   brings\x20 
   \x6c\x69\x66\x65\. 
   ` 
) 

golang.fyi/ch04/string.go

分配给变量txt2的字面量值用双引号括起来。这被称为解释字符串。解释字符串可以包含正常可打印字符以及反斜杠转义值,这些转义值被解析和解释为rune字面量。因此,当txt2被打印时,转义值被翻译为以下字符串:

解释和原始字符串字面量

解释字符串中的每个符号对应于以下表格中总结的转义值或可打印符号:

解释和原始字符串字面量 带来 生命 .
\u6C34 \x20 brings \x20 \x6c\x69\x66\x65 .

另一方面,分配给变量txt3的字面量值被方括号go `` 包围。这创建了 Go 中的原始字符串。原始字符串值是不解释的,其中转义序列被忽略,所有有效字符都按字面量中的方式编码。

当变量txt3被打印时,它会产生以下输出:

\u6C34\x20brings\x20\x6c\x69\x66\x65.

注意,打印的字符串包含原始字符串字面量中出现的所有转义值。未解释的字符串字面量是嵌入源代码体中大量多行文本内容的好方法,而不会破坏其语法。

指针

在 Go 中,当数据存储在内存中时,可以直接访问该数据的值,或者使用指针来引用数据所在的内存地址。与其他 C 系列语言一样,Go 中的指针提供了一种间接级别,允许程序员更有效地处理数据,而无需每次需要时都复制实际的数据值。

与 C 语言不同,然而,Go 运行时在运行时维护对指针管理的控制。程序员不能将任意整数值添加到指针以生成新的指针地址(这种做法称为指针算术)。一旦某个内存区域被指针引用,该区域的数据将保持可访问状态,直到它不再被任何指针变量引用。到那时,未引用的值将符合垃圾回收的条件。

指针类型

与 C/C++类似,Go 使用*运算符来指定一个类型为指针。以下代码片段展示了几个具有不同底层类型的指针:

package main 
import "fmt" 

var valPtr *float32 
var countPtr *int 
var person *struct { 
   name string 
   age  int 
} 
var matrix *[1024]int 
var row []*int64 

func main() { 
   fmt.Println(valPtr, countPtr, person, matrix, row) 
} 

golang.fyi/ch04/pointers.go

给定一个类型为T的变量,Go 使用表达式*T作为其指针类型。类型系统认为T*T是不同的,并且不是可互换的。指针的零值,当它没有指向任何东西时,是地址 0,用字面常量nil表示。

地址运算符

指针值只能分配其声明类型的地址。在 Go 中,你可以使用地址运算符&(和号)来获取变量的地址值,如下例所示:

package main 
import "fmt" 

func main() { 
   var a int = 1024 
   var aptr *int = &a 

   fmt.Printf("a=%v\n", a) 
   fmt.Printf("aptr=%v\n", aptr) 
} 

golang.fyi/ch04/pointers.go

指针类型的变量aptr被初始化并分配了变量a的地址值,使用表达式&a,如下所示:

var a int = 1024 
var aptr *int = &a  

当变量a存储实际值时,我们说aptr指向a。以下展示了程序输出,其中变量a的值及其内存位置被分配给aptr

a=1024 
aptr=0xc208000150

分配的地址值始终相同(始终指向a),无论aptr在代码中的访问位置如何。还值得注意的是,Go 不允许使用地址运算符与数值、字符串和 bool 类型的字面常量一起使用。因此,以下代码将无法编译:

var aptr *int = &1024  
fmt.Printf("a ptr1 = %v\n", aptr)  

然而,在用字面常量初始化复合类型,如 struct 和 array 时,存在一个语法上的例外。以下程序说明了这种情况:

package main 
import "fmt" 

func main() { 
   structPtr := &struct{ x, y int }{44, 55} 
   pairPtr := &[2]string{"A", "B"} 

   fmt.Printf("struct=%#v, type=%T\n", structPtr, structPtr) 
   fmt.Printf("pairPtr=%#v, type=%T\n", pairPtr, pairPtr) 
} 

&struct{ x, y int }{44, 55} and &[2]string{"A", "B"} to return pointer types *struct { x int; y int } and *[2]string respectively. This is a bit of syntactic sugar that eliminates the intermediary step of assigning the values to a variable, then retrieving their assigned addresses.

new()函数

内置函数*new(<type>)*也可以用来初始化指针值。该函数首先为指定类型的零值分配适当的内存。然后函数返回新创建值的地址。以下程序使用new()函数初始化变量intptrp

package main 
import "fmt" 

func main() { 
   intptr := new(int) 
   *intptr = 44 

   p := new(struct{ first, last string }) 
   p.first = "Samuel" 
   p.last = "Pierre" 

   fmt.Printf("Value %d, type %T\n", *intptr, intptr) 
   fmt.Printf("Person %+v\n", p) 
} 

golang.fyi/ch04/newptr.go

变量 intptr 被初始化为 *int,而 p 被初始化为 *struct{first, last string}。一旦初始化,这两个值将在代码的后续部分相应地更新。当初始化时实际值不可用时,您可以使用 new() 函数用零值初始化指针变量。

指针间接引用 - 访问引用的值

如果您只有地址,可以通过将 * 操作符应用于指针值本身(或解引用)来访问它所指向的值。以下程序通过函数 double()cap() 说明了这一概念:

package main 
import ( 
   "fmt" 
   "strings" 
) 

func main() { 
   a := 3 
   double(&a) 
   fmt.Println(a) 
   p := &struct{ first, last string }{"Max", "Planck"} 
   cap(p) 
   fmt.Println(p) 
} 

func double(x *int) { 
   *x = *x * 2 
} 

func cap(p *struct{ first, last string }) { 
   p.first = strings.ToUpper(p.first) 
   p.last = strings.ToUpper(p.last) 
} 

golang.fyi/ch04/derefptr.go

在前面的代码中,函数 double() 中的表达式 *x = *x * 2 可以如下分解,以了解其工作原理:

表达式 步骤

|

*x * 2   

原始表达式,其中 x*int 类型。

|

*(*x) * 2   

通过将 * 应用于地址值来解引用指针。

|

3 * 2 = 6   

*(*x) = 3 的解引用值。

|

*(*x) = 6   

此表达式的右侧解引用了 x 的值。它被更新为结果 6。

在函数 cap() 中,使用类似的方法来访问和更新复合变量 p 的字段,其类型为 struct{first, last string}。然而,当处理复合类型时,惯用法更为宽容。访问指针的字段值不需要写 *p.first。我们可以省略 * 并直接使用 p.first = strings.ToUpper(p.first).

类型声明

在 Go 中,可以将类型绑定到标识符以创建新的命名类型,该类型可以在需要类型的地方进行引用和使用。声明类型的通用格式如下:

type <名称标识符> <基础类型名称>

类型声明以关键字 type 开头,后跟一个 名称标识符 和一个现有 基础类型 的名称。基础类型可以是以下类型之一:数值类型、布尔值或字符串类型,如下面的类型声明片段所示:

type truth bool 
type quart float64 
type gallon float64 
type node string 

注意

类型声明还可以使用复合 类型字面量 作为其基础类型。复合类型包括数组、切片、映射和结构体。本节重点介绍非复合类型。有关复合类型的更多详细信息,请参阅 第七章,复合类型

以下示例说明了命名类型在基本形式下的工作方式。示例中的代码将温度值进行转换。每个温度单位都由一个声明的类型表示,包括 fahrenheitcelsiuskelvin

package main 
import "fmt" 

type fahrenheit float64 
type celsius float64 
type kelvin float64 

func fharToCel(f fahrenheit) celsius { 
   return celsius((f - 32) * 5 / 9) 
} 

func fharToKel(f fahrenheit) celsius { 
   return celsius((f-32)*5/9 + 273.15) 
} 

func celToFahr(c celsius) fahrenheit { 
   return fahrenheit(c*5/9 + 32) 
} 

func celToKel(c celsius) kelvin { 
   return kelvin(c + 273.15) 
} 

func main() { 
   var c celsius = 32.0 
   f := fahrenheit(122) 
   fmt.Printf("%.2f \u00b0C = %.2f \u00b0K\n", c, celToKel(c)) 
   fmt.Printf("%.2f \u00b0F = %.2f \u00b0C\n", f, fharToCel(f)) 
} 

float64. Once the new type has been declared, it can be assigned to variables and participate in expressions just like its underlying type. The newly declared type will have the same zero-value and can be converted to and from its underlying type.

类型转换

通常,Go 将每个类型视为不同。这意味着在正常情况下,不同类型的值在赋值、函数参数和表达式上下文中是不可互换的。这对于内置和声明的类型都适用。例如,以下代码将由于类型不匹配而导致构建错误:

package main 
import "fmt" 

type signal int 

func main() { 
   var count int32 
   var actual int 
   var test int64 = actual + count 

   var sig signal 
   var event int = sig 

   fmt.Println(test) 
   fmt.Println(event) 
} 

golang.fyi/ch04/type_conv.go

表达式 actual + count 会导致编译时错误,因为这两个变量属于不同的类型。尽管变量 actualcount 都是数值类型,且 int32int 具有相同的内存表示,但编译器仍然拒绝这个表达式。

对于声明的命名类型及其底层类型,也是如此。编译器会拒绝赋值 var event int = sig,因为类型 signal 被认为是与类型 int 不同的。即使 signal 使用 int 作为其底层类型,这也是正确的。

要跨越类型边界,Go 支持类型转换表达式,可以将值从一种类型转换为另一种类型。类型转换使用以下格式进行:

<目标类型>(<值或表达式>)

以下代码片段通过将变量转换为正确的类型来修复前面的示例:

type signal int 
func main() { 
   var count int32 
   var actual int 
   var test int32 = int32(actual) + count 

   var sig signal 
   var event int = int(sig) 
} 

var test int32 = int32(actual) + count converts variable actual to the proper type to match the rest of the expression. Similarly, expression var event int = int(sig) converts variable sig to match the target type int in the assignment.

转换表达式通过显式更改封装值的类型来满足赋值。显然,并非所有类型都可以相互转换。以下表格总结了类型转换适当且允许的常见场景:

描述 代码
目标类型和转换后的值都是简单的数值类型。
var i int   
var i2 int32 = int32(i)   
var re float64 = float64(i +   int(i2))   

|

目标类型和转换后的值都是复杂的数值类型。
var cn64 complex64   
var cn128 complex128 =   complex128(cn64)   

|

目标类型和转换后的值具有相同的底层类型。
type signal int   
var sig signal   
var event int = int(sig)   

|

目标类型是字符串,转换后的值是有效的整数类型。
a := string(72)   
b := string(int32(101))   
c := string(rune(108))   

|

目标类型是字符串,转换后的值是字节切片、int32 或 rune。
msg0 := string([]byte{'H','i'})   
msg1 := string([]rune{'Y','o','u','!'})   

|

目标类型是字节切片、int32 或 rune 值的切片,转换后的值是字符串。
data0 := []byte("Hello")   
data0 := []int32("World!")   

|

此外,当目标类型和转换后的值都是指向相同类型的指针时,转换规则也适用。除了上表中的这些场景外,Go 类型不能显式转换。任何尝试这样做都会导致编译错误。

概述

本章向读者介绍了 Go 的类型系统。本章从类型概述开始,深入探讨了基本内置类型,如数值、布尔、字符串和指针类型。讨论继续,向读者介绍了其他重要主题,例如命名类型定义。本章以类型转换机制的内容结束。在接下来的章节中,你将有机会了解更多关于其他类型,如复合类型、函数和接口。

第五章。Go 中的函数

Go 语法中的一项杰作是其对高阶函数的支持,正如在 Python 或 Ruby 等动态语言中所发现的那样。正如我们在本章中将要看到的,函数也是一个具有值的类型实体,这个值可以被分配给一个变量。在本章中,我们将探讨 Go 中的函数,包括以下主题:

  • Go 函数

  • 传递参数值

  • 匿名函数和闭包

  • 高阶函数

  • 错误信号处理

  • 延迟函数调用

  • 函数恐慌和恢复

Go 函数

在 Go 中,函数是一等、有类型的编程元素。声明的函数字面量始终具有类型和值(定义的函数本身),并且可以选择绑定到命名标识符。因为函数可以用作数据,所以它们可以被分配给变量或作为其他函数的参数传递。

函数声明

在 Go 中声明函数采用以下一般形式,如图所示。这种规范形式用于声明命名和匿名函数。

函数声明

在 Go 中,最常见的函数定义形式包括函数字面量中的函数的指定标识符。为了说明这一点,以下表格显示了几个程序的源代码,其中包含具有不同参数和返回类型组合的命名函数的定义。

代码 描述

|

package main import (
  "fmt"
  "math"
)func printPi() {
  fmt.Printf("printPi()
    %v\n", math.Pi)
} func main() {
  printPi() }               ("fmt" "math" ) func
printPi() {
  fmt.Printf("printPi()
    %v\n", math.Pi)
}
func main() { printPi() }

golang.fyi/ch05/func0.go | 一个名为 printPi 的函数。它不接受任何参数,也不返回任何值。注意,当没有返回值时,return 语句是可选的。|

|

package main   
import "fmt"   

func avogadro() float64 {   
   return 6.02214129e23   
}   

func main() {   
   fmt.Printf("avogadro()
   = %e 1/mol\n",   
   avogadro())   
}   

golang.fyi/ch05/func1.go | 一个名为 avogadro 的函数。它不接受任何参数,但返回一个 float64 类型的值。注意,当返回值作为函数签名的一部分声明时,需要 return 语句。|

|

package main   
import "fmt"    
func fib(n int) {   
  fmt.Printf("fib(%d):
    [", n)
  var p0, p1 uint64 = 0,
    1   
  fmt.Printf("%d %d ",
    p0, p1)   
  for i := 2; i <= n; i++
  {   
    p0, p1 = p1, p0+p1
    fmt.Printf("%d ",p1)
  }   
  fmt.Println("]")   
}   
func main() {   
  fib(41)   
}

golang.fyi/ch05/func2.go | 这定义了函数 fib。它接受参数 n,类型为 int,并打印出最多到 n 的斐波那契数列。同样,没有返回值,因此省略了 return 语句。|

|

package main   
import (   
  "fmt"   
  "math"   
)    
func isPrime(n int) bool {   
  lim :=
  int(math.Sqrt
  (float64(n)))
  for p := 2; p <= lim;
  p++ {
    if (n % p) == 0 {   
      return false   
    }  }   
  return true   
}   
func main() {   
  prime := 37
  fmt.Printf
  ("isPrime(%d)  =
  %v\n", prime,
  isPrime(prime))
}

golang.fyi/ch05/func3.go | 最后一个例子定义了 isPrime 函数。它接受一个 int 类型的参数,并返回一个 bool 类型的值。由于函数被声明为返回 bool 类型的值,因此在执行流程中的最后一个逻辑语句必须是一个返回该声明类型的 return 语句。|

注意

函数签名

指定参数类型、结果类型及其声明的顺序的集合被称为函数的签名。这是帮助识别函数的另一个独特特征。两个函数可能有相同数量的参数和结果值;然而,如果这些元素的顺序不同,那么函数的签名就不同。

函数类型

通常,在函数字面量中声明的名称标识符用于通过调用表达式调用函数,其中函数标识符后面跟着参数列表。这是我们到目前为止在书中看到的情况,以下示例展示了调用fib函数:

func main() { 
   fib(41) 
} 

然而,当函数的标识符不带括号出现时,它被视为具有类型和值的普通变量,如下面的程序所示:

package main 
import "fmt" 

func add(op0 int, op1 int) int { 
   return op0 + op1 
} 

func sub(op0, op1 int) int { 
   return op0 - op1 
} 

func main() { 
   var opAdd func(int, int) int = add 
   opSub := sub 
   fmt.Printf("op0(12,44)=%d\n", opAdd(12, 44)) 
   fmt.Printf("sub(99,13)=%d\n", opSub(99, 13)) 
}  

golang.fyi/ch05/functype.go

函数的类型由其签名确定。当函数具有相同数量、相同类型且顺序相同的参数时,它们被认为是同一类型的。在之前的示例中,opAdd变量被声明为具有类型func (int, int) int。这与声明的函数addsub的签名相同。因此,opAdd变量被分配了add函数变量。这允许opAdd以调用add函数的方式被调用。

同样,对于opSub变量也是如此。它被分配了由函数标识符sub和类型func (int, int)表示的值。因此,opSub(99,13)调用了第二个函数,该函数返回减法的结果。

可变参数

函数的最后一个参数可以声明为可变参数可变长度参数),通过在参数类型之前附加省略号()来实现。这表示在调用函数时,可以传递零个或多个该类型的值。

以下示例实现了两个接受可变参数的函数。第一个函数计算传递值的平均值,第二个函数将作为参数传递的数字求和:

package main 
import "fmt" 

func avg(nums ...float64) float64 { 
   n := len(nums) 
   t := 0.0 
   for _, v := range nums { 
         t += v 
   } 
   return t / float64(n) 
} 

func sum(nums ...float64) float64 { 
   var sum float64 
   for _, v := range nums { 
         sum += v 
   } 
   return sum 
} 

func main() { 
   fmt.Printf("avg([1, 2.5, 3.75]) =%.2f\n", avg(1, 2.5, 3.75)) 
   points := []float64{9, 4, 3.7, 7.1, 7.9, 9.2, 10} 
   fmt.Printf("sum(%v) = %.2f\n", points, sum(points...)) 
} 

When no parameters are provided, the function receives an empty slice. The astute reader may be wondering, "Is it possible to pass in an existing slice of values as variadic arguments?" Thankfully, Go provides an easy idiom to handle such a case. Let's examine the call to the  `sum` function in the following code snippet:

points := []float64{9, 4, 3.7, 7.1, 7.9, 9.2, 10}

fmt.Printf("sum(%v) = %f\n", points, sum(points...))


A slice of floating-point values is declared and stored in variable `points`. The slice can be passed as a variadic parameter by adding ellipses to the parameter in the `sum(points...)` function call.

函数结果参数

Go 函数可以被定义为返回一个或多个结果值。到目前为止,在书中,我们遇到的大多数函数都被定义为返回单个结果值。一般来说,一个函数能够返回由逗号分隔的多个结果值,具有不同的类型(参见上一节,函数声明)。

为了说明这个概念,让我们检查以下简单的程序,该程序定义了一个实现欧几里得除法算法(参见en.wikipedia.org/wiki/Division_algorithm)的函数。div函数返回商和余数值作为其结果:

package main 
import "fmt" 

func div(op0, op1 int) (int, int) { 
   r := op0 
   q := 0 
   for r >= op1 { 
         q++ 
         r = r - op1 
   } 
   return q, r 
} 

func main() { 
   q, r := div(71, 5) 
   fmt.Printf("div(71,5) -> q = %d, r = %d\n", q, r) 
} 

golang.fyi/ch05/funcret0.go

**return** 关键字后面跟着与函数签名中声明的结果匹配的结果值数量。在先前的例子中,div 函数的签名指定了两个 int 值作为结果值。在函数内部,该函数定义了 int 变量 pr,在函数完成后作为结果值返回。这些返回值必须与函数签名中定义的类型匹配,否则可能会出现编译错误。

具有多个结果值的函数必须在正确的上下文中调用:

  • 它们必须分别分配给相同类型的标识符列表

  • 它们只能包含在期望相同数量返回值的表达式中

这在以下源代码片段中得到了说明:

q, r := div(71, 5) 
fmt.Printf("div(71,5) -> q = %d, r = %d\n", q, r) 

命名结果参数

通常,可以使用变量标识符及其类型来指定函数签名的结果列表。当使用命名标识符时,它们作为常规声明的变量传递给函数,并且可以根据需要访问和修改。在遇到 return 语句时,最后分配的结果值将被返回。这在下述源代码片段中得到了说明,这是先前程序的改写:

func div(dvdn, dvsr int) (q, r int) { 
   r = dvdn 
   for r >= dvsr { 
         q++ 
         r = r - dvsr 
   } 
   return 
} 

golang.fyi/ch05/funcret1.go

注意到 return 语句是裸露的;它省略了所有标识符。如前所述,qr 中分配的值将被返回给调用者。为了可读性、一致性或风格,您可以选择不使用裸露的 return 语句。将标识符的名称附加到 return 语句(如 return q, r)是完全可以接受的。

传递参数值

在 Go 中,所有传递给函数的参数都是按值传递的。这意味着在调用函数内部创建了一个传递值的局部副本。没有传递参数值按引用传递的固有概念。以下代码通过在 dbl 函数内部修改传递的参数 val 的值来演示这一机制:

package main 
import ( 
   "fmt" 
   "math" 
) 

func dbl(val float64) { 
   val = 2 * val // update param 
   fmt.Printf("dbl()=%.5f\n", val) 
} 

func main() { 
   p := math.Pi 
   fmt.Printf("before dbl() p = %.5f\n", p) 
   dbl(p) 
   fmt.Printf("after dbl() p = %.5f\n", p) 
} 

golang.fyi/ch05/funcpassbyval.go

当程序运行时,它会产生以下输出,记录了在传递给 dbl 函数之前 p 变量的状态。更新是在 dbl 函数内部传递的参数变量本地进行的,最后是调用 dbl 函数后 p 变量的值:

$> go run funcpassbyval.go
before dbl() p = 3.14159
dbl()=6.28319
after dbl() p = 3.14159

前面的输出显示,分配给变量 p 的原始值在传递给似乎在内部更新其值的函数之后保持不变。这是因为 dbl 函数中的 val 参数接收了传递参数的局部副本。

实现按引用传递

虽然按值传递在许多情况下是合适的,但需要注意的是,Go 可以通过使用指针参数值来实现按引用语义。这允许被调用的函数超出其词法作用域,并改变由指针参数引用的位置存储的值,就像以下示例中的half函数所做的那样:

package main 
import "fmt" 

func half(val *float64) { 
   fmt.Printf("call half(%f)\n", *val) 
   *val = *val / 2 
} 

func main() { 
   num := 2.807770 
   fmt.Printf("num=%f\n", num) 
   half(&num) 
   fmt.Printf("half(num)=%f\n", num) 
} 

golang.fyi/ch05/funcpassbyref.go

在先前的示例中,main()中对half(&num)函数的调用就地更新了其num参数引用的原始值。因此,当代码执行时,它显示了num的原始值和调用half函数后的值:

$> go run funcpassbyref.go
num=2.807770
call half(2.807770)
half(num)=1.403885

如前所述,Go 函数参数是按值传递的。即使函数接受指针值作为其参数,这也是正确的。Go 仍然创建并传递指针值的本地副本。在先前的示例中,half函数通过val参数接收到的指针值的副本。代码使用指针运算符(*)进行解引用并就地操作由val引用的值。当half函数退出并超出作用域时,其更改可以通过调用main函数来访问。

匿名函数和闭包

函数可以写成字面量,而不需要命名标识符。这些被称为匿名函数,可以将它们赋给变量以供以后调用,如下面的示例所示:

package main 
import "fmt" 

var ( 
   mul = func(op0, op1 int) int { 
         return op0 * op1 
   } 

   sqr = func(val int) int { 
         return mul(val, val) 
   } 
) 

func main() { 
   fmt.Printf("mul(25,7) = %d\n", mul(25, 7)) 
   fmt.Printf("sqr(13) = %d\n", sqr(13)) 
}  

golang.fyi/ch05/funcs.go

之前的程序显示了两个声明并绑定到mulsqr变量的匿名函数。在两种情况下,函数都接受参数并返回一个值。在main()的后面,这些变量被用来调用绑定到它们的函数代码。

调用匿名函数字面量

值得注意的是,匿名函数不必绑定到标识符。函数字面量可以直接作为返回函数结果的表达式进行评估。这是通过在函数字面量末尾添加一个括号内的参数值列表来完成的,如下面的程序所示:

package main 
import "fmt" 

func main() { 
   fmt.Printf( 
         "94 (°F) = %.2f (°C)\n", 
         func(f float64) float64 { 
               return (f - 32.0) * (5.0 / 9.0) 
         }(94), 
   ) 
} 

fmt.Printf(). The function itself is defined to accept a parameter and returns a value of type float64.
fmt.Printf( 
   "94 (°F) = %.2f (°C)\n", 
   func(f float64) float64 { 
         return (f - 32.0) * (5.0 / 9.0) 
   }(94), 
) 

由于函数字面量以括号内的参数列表结束,因此函数作为表达式被调用。

闭包

Go 函数字面量是闭包。这意味着它们对其封装代码块外部声明的非局部变量具有词法可见性。以下示例说明了这一点:

package main 
import ( 
   "fmt" 
   "math" 
) 

func main() { 
   for i := 0.0; i < 360.0; i += 45.0 { 
         rad := func() float64 { 
               return i * math.Pi / 180 
         }() 
         fmt.Printf("%.2f Deg = %.2f Rad\n", i, rad) 
   } 
} 

github.com/vladimirvivien/learning-go/ch05/funcs.go

在先前的程序中,函数字面量代码块func() float64 {return deg * math.Pi / 180}()被定义为将度数转换为弧度的表达式。在循环的每次迭代中,在封闭的函数字面量和外部非局部变量i之间形成一个闭包。这提供了一种更简单的语法,其中函数自然地访问非局部值,而无需求助于其他手段,如指针。

注意

在 Go 中,词法封闭的值可以在创建封闭的外部函数超出作用域很长时间后仍然与其封闭相关联。垃圾收集器将在这些封闭值变得无界时处理清理工作。

高阶函数

我们已经确定 Go 函数是与类型绑定的值。因此,一个 Go 函数可以接受另一个函数作为参数,也可以返回一个函数作为结果值,这描述了被称为高阶函数的概念,这是一个从数学中采纳的概念。虽然 struct 等类型允许程序员抽象数据,但高阶函数提供了一种封装和抽象行为的方式,这些行为可以组合在一起形成更复杂的行为。

为了使这个概念更清晰,让我们检查以下程序,它使用一个高阶函数 apply 来完成三件事情。它接受一个整数切片和一个函数作为参数。它将指定的函数应用于切片中的每个元素。最后,apply 函数还返回一个函数作为其结果:

package main 
import "fmt" 

func apply(nums []int, f func(int) int) func() { 
   for i, v := range nums { 
         nums[i] = f(v) 
   } 
   return func() { 
         fmt.Println(nums) 
   } 
} 

func main() { 
   nums := []int{4, 32, 11, 77, 556, 3, 19, 88, 422} 
   result := apply(nums, func(i int) int { 
         return i / 2 
   }) 
   result() 
} 

golang.fyi/ch05/funchighorder.go

在程序中,apply 函数通过一个匿名函数调用,将切片中的每个元素减半,如下面的代码片段所示:

nums := []int{4, 32, 11, 77, 556, 3, 19, 88, 422} 
result := apply(nums, func(i int) int { 
   return i / 2 
}) 
result() 

As you explore this book, and the Go language, you will continue to encounter usage of higher-order functions. It is a popular idiom that is used heavily in the standard libraries. You will also find higher-order functions used in some concurrency patterns to distribute workloads (see Chapter 9, *Concurrency*).

错误信号和处理

在这一点上,让我们讨论在函数调用时如何惯用信号和处理错误。如果您使用过 Python、Java 或 C# 等语言,您可能熟悉在出现不希望的状态时通过抛出异常来中断执行流程。

正如我们将在本节中探讨的,Go 对错误信号和错误处理采用了简化的方法,将责任放在程序员身上,要求在调用函数返回后立即处理可能出现的错误。Go 不鼓励通过在执行程序中无差别地短路异常来中断执行,希望它将在调用堆栈的更高处得到适当处理。在 Go 中,传统的错误信号方式是在函数执行过程中出现问题时返回一个类型为 error 的值。因此,让我们更详细地看看这是如何实现的。

信号错误

为了更好地理解前一段落中描述的内容,让我们从一个例子开始。以下源代码实现了 Jon Bentley 流行书籍 Programming Pearls(第二版)中第 2 列描述的字母表程序。该代码读取字典文件(dict.txt)并将所有具有相同字母表的单词分组。如果代码不太清楚,请参阅 golang.fyi/ch05/anagram1.go,以了解程序每个部分的注释说明。

package main 

import ( 
   "bufio" 
   "bytes" 
   "fmt" 
   "os" 
   "errors" 
) 

// sorts letters in a word (i.e. "morning" -> "gimnnor") 
func sortRunes(str string) string { 
   runes := bytes.Runes([]byte(str)) 
   var temp rune 
   for i := 0; i < len(runes); i++ { 
         for j := i + 1; j < len(runes); j++ { 
               if runes[j] < runes[i] { 
                     temp = runes[i] 
                     runes[i], runes[j] = runes[j], temp 
               } 

         } 
   } 
   return string(runes) 
} 

// load loads content of file fname into memory as []string 
func load(fname string) ([]string, error) { 
   if fname == "" { 
         return nil, errors.New( 
               "Dictionary file name cannot be empty.")  
   } 

   file, err := os.Open(fname) 
   if err != nil { 
         return nil, err 
   } 
   defer file.Close() 

   var lines []string 
   scanner := bufio.NewScanner(file) 
   scanner.Split(bufio.ScanLines) 
   for scanner.Scan() { 
         lines = append(lines, scanner.Text()) 
   } 
   return lines, scanner.Err() 
} 

func main() { 
   words, err := load("dict.txt")       
   if err != nil { 
         fmt.Println("Unable to load file:", err) 
         os.Exit(1) 
   } 

      anagrams := make(map[string][]string) 
   for _, word := range words { 
         wordSig := sortRunes(word) 
         anagrams[wordSig] = append(anagrams[wordSig], word) 
   } 

   for k, v := range anagrams { 
         fmt.Println(k, "->", v) 
   } 
} 

load function (extracted from the previous example):
func load(fname string) ([]string, error) { 
   if fname == "" { 
       return nil, errors.New( 
         "Dictionary file name cannot be empty.")  
   } 

   file, err := os.Open(fname) 
   if err != nil { 
         return nil, err 
   } 
   ... 
} 

load function signals an error occurrence to its callers in two possible instances:
  • 当预期的文件名(fname)为空时

  • 当调用 os.Open() 失败时(例如,权限错误,或其他情况)

在第一种情况下,当没有提供文件名时,代码使用errors.New()创建一个error类型的值来退出函数。在第二种情况下,os.Open函数返回一个表示文件的指针和一个错误,分别赋值给fileerr变量。如果err不是nil(表示生成了错误),则load函数的执行会提前终止,并将err的值返回给调用栈上更高层的调用函数处理。

注意

当一个函数有多个结果参数时返回错误,通常习惯于为其他(非错误类型)参数返回零值。在示例中,对于[]string类型的返回值返回了nil值。虽然这不是必需的,但它简化了错误处理,并避免了函数调用者产生任何混淆。

错误处理

load function is handled in the main function:
func main() { 
   words, err := load("dict.txt") 
   if err != nil { 
         fmt.Println("Unable to load file:", err) 
         os.Exit(1) 
   } 
   ... 
} 

由于main函数是调用栈中最顶层的调用者,它通过终止整个程序来处理错误。

这就是 Go 中错误处理机制的全部内容。该语言强制程序员在每次返回error类型值的函数调用时都测试错误状态。if…not…nil error处理方法可能对某些人来说显得过于冗长,尤其是如果你来自具有正式异常机制的语言。然而,这里的优势是程序可以构建一个健壮的执行流程,程序员总是知道错误可能来自哪里,并适当地处理它们。

错误类型

error类型是一个内置接口,因此在使用之前必须实现。幸运的是,Go 标准库提供了现成的实现。我们已经使用了一个来自该包的实现,即errors

errors.New("Dictionary file name cannot be empty.")  

您还可以使用fmt.Errorf函数创建参数化的错误值,如下面的代码片段所示:

func load(fname string) ([]string, error) { 
   if fname == "" { 
         return nil, errors.New( 
             "Dictionary file name cannot be emtpy.") 
   } 

   file, err := os.Open(fname) 
   if err != nil { 
         return nil, fmt.Errorf( 
             "Unable to open file %s: %s", fname, err) 
   } 
   ... 
} 

http://golang.org/src/os/error.go shows the declaration of reusable errors associated with OS file operations:
var ( 
   ErrInvalid    = errors.New("invalid argument") 
   ErrPermission = errors.New("permission denied") 
   ErrExist      = errors.New("file already exists") 
   ErrNotExist   = errors.New("file does not exist") 
) 

golang.org/src/os/error.go

您还可以创建自己的error接口实现来创建自定义错误。这个主题在第七章,方法、接口和对象中再次被提及,书中讨论了扩展类型的概念。

延迟函数调用

Go 支持延迟函数调用的概念。在函数调用前放置关键字defer会产生有趣的效果,它将函数推入一个内部栈,延迟其执行直到包含函数返回之前。为了更好地解释这一点,让我们从一个简单的程序开始,该程序说明了defer的使用:

package main 
import "fmt" 

func do(steps ...string) { 
   defer fmt.Println("All done!") 
   for _, s := range steps { 
         defer fmt.Println(s) 
   } 

   fmt.Println("Starting") 
} 

func main() { 
   do( 
         "Find key", 
         "Aplly break", 
         "Put key in ignition", 
         "Start car", 
   ) 
} 

golang.fyi/ch05/defer1.go

之前的示例定义了 do 函数,该函数接受可变参数 steps。该函数使用 defer fmt.Println("All done!") 延迟语句。接下来,该函数遍历 steps 切片,并使用 defer fmt.Println(s) 延迟输出每个元素。函数 do 中的最后一个语句是对 fmt.Println("Starting") 的非延迟调用。注意程序执行时打印的字符串值的顺序,如下面的输出所示:

$> go run defer1.go
Starting
Start car
Put key in ignition
Aplly break
Find key
All done!

有几个事实可以解释打印输出的反向顺序。首先,回想一下,延迟函数是在其封装函数返回之前执行的。因此,第一个打印的值是由最后一个非延迟方法调用生成的。其次,如前所述,延迟语句被推入一个栈中。因此,延迟调用是按照后进先出的顺序执行的。这就是为什么 "All done!" 是输出中最后一个打印的字符串值。

使用 defer

load function calls file.Close() right before it returns:
func load(fname string) ([]string, error) { 
... 
   file, err := os.Open(fname) 
   if err != nil { 
         return nil, err 
   } 
   defer file.Close() 
... 
} 

golang.fyi/ch05/anagram2.go

在 Go 中,打开-defer-关闭资源的模式被广泛使用。通过在打开或创建资源后立即放置延迟意图,可以使代码读起来更自然,并减少资源泄漏的可能性。

函数 panic 和恢复

在本章的早期,提到 Go 没有其他语言提供的传统异常机制。然而,在 Go 中,有一种突然退出执行函数的方法,称为函数 panic。相反,当程序处于 panic 状态时,Go 提供了一种恢复和重新控制执行流程的方法。

函数 panic

write function to panic when there is a file error:
package main 
... 
func write(fname string, anagrams map[string][]string) { 
   file, err := os.OpenFile( 
         fname,  
         os.O_WRONLY+os.O_CREATE+os.O_EXCL,  
         0644, 
   ) 
   if err != nil { 
         msg := fmt.Sprintf( 
               "Unable to create output file: %v", err, 
         ) 
         panic(msg) 
   } 
   ... 
} 

func main() { 
   words, err := load("dict.txt") 
   if err != nil { 
         fmt.Println("Unable to load file:", err) 
         os.Exit(1) 
   } 
   anagrams := mapWords(words) 
   write("out.txt", anagrams) 
} 

write function calls the panic function if os.OpenFile() method errors out. When the program calls the main function, if there is an output file already in the working directory, the program will panic and crash as shown in the following stack trace, indicating the sequence of calls that caused the crash:
> go run anagram2.go 
panic: Unable to create output file: open out.txt: file exists
goroutine 1 [running]:
main.write(0x4e7b30, 0x7, 0xc2080382a0)
/Go/src/github.com/vladimirvivien/learning-go/ch05/anagram2.go:72 +0x1a3 
main.main()
Go/src/github.com/vladimirvivien/learning-go/ch05/anagram2.go:103 +0x1e9
exit status 2

函数 panic 恢复

当函数 panic 时,如前所述,它可以崩溃整个程序。这可能取决于你的需求。然而,在 panic 序列开始后,可以重新获得控制。为此,Go 提供了一个内置的函数,称为 recover

Recover 与 panic 一起工作。对函数 recover 的调用返回传递给 panic 的值。下面的代码显示了如何从上一个示例中引入的 panic 调用中恢复。在这个版本中,为了清晰起见,将 write 函数移动到 makeAnagram() 内部。当从 makeAnagram() 调用 write 函数并无法打开文件时,它将 panic。然而,现在添加了额外的代码来恢复:

package main 
... 
func write(fname string, anagrams map[string][]string) { 
   file, err := os.OpenFile( 
         fname,  
         os.O_WRONLY+os.O_CREATE+os.O_EXCL,  
         0644, 
   ) 
   if err != nil { 
         msg := fmt.Sprintf( 
               "Unable to create output file: %v", err, 
         ) 
         panic(msg) 
   } 
   ... 
} 

func makeAnagrams(words []string, fname string) { 
   defer func() { 
         if r := recover(); r != nil { 
               fmt.Println("Failed to make anagram:", r) 
         } 
   }() 

   anagrams := mapWords(words) 
   write(fname, anagrams) 
} 
func main() { 
   words, err := load("") 
   if err != nil { 
         fmt.Println("Unable to load file:", err) 
         os.Exit(1) 
   } 
   makeAnagrams(words, "") 
} 

golang.fyi/ch05/anagram3.go

要能够从展开的 panic 序列中恢复,代码必须对 recover 函数进行延迟调用。在前面的代码中,这是通过在 makeAnagrams 函数中将 recover() 包裹在匿名函数字面量中来实现的,如下面的代码片段所示:

defer func() { 
   if r := recover(); r != nil { 
         fmt.Println("Failed to make anagram:", r) 
   } 
}() 

当延迟的 recover 函数执行时,程序有机会重新获得控制并防止 panic 崩溃正在运行的程序。如果 recover() 返回 nil,则表示没有当前 panic 正在沿着调用栈展开,或者 panic 已经在下游被处理。

现在,当程序执行时,它不会因为堆栈跟踪而崩溃,而是恢复并优雅地显示问题,如下面的输出所示:

> go run anagram3.go
Failed to make anagram: Unable to open output file for creation: open out.txt: file exists

注意

你可能想知道为什么我们在调用panic函数时传递了一个字符串,却在测试recover函数返回的值时使用了一个nil。这是因为panicrecover都接受一个空的接口类型。正如你将要学习的,空的接口类型是一个泛型类型,它具有在 Go 的类型系统中表示任何类型的能力。我们将在第七章,方法、接口和对象的讨论中了解更多关于空接口的内容。

摘要

本章向读者展示了 Go 函数的探索。它从命名函数声明的概述开始,然后讨论了函数参数。本章深入讨论了函数类型和函数值。章节的最后部分讨论了错误处理、panic 和恢复的语义。下一章将继续讨论函数;然而,它是在 Go 包的上下文中进行的。它解释了包作为 Go 函数(和其他代码元素)的逻辑分组的作用,以形成可共享和可调用的代码模块。

第六章。Go 包和程序

第五章,Go 中的函数介绍了函数,这是代码组织抽象的基本层次,使得代码可寻址和可重用。本章继续向上攀登抽象的阶梯,以 Go 包为中心进行讨论。正如以下主题所详细介绍的,一个包是一组逻辑上分组存储在源代码文件中的语言元素,可以共享和重用。以下是一些相关主题:

  • Go 包

  • 创建包

  • 构建包

  • 包可见性

  • 导入包

  • 包初始化

  • 创建程序

  • 远程包

Go 包

与其他语言类似,Go 源代码文件被分组为可编译和可共享的单元,称为包。然而,所有 Go 源文件都必须属于一个包(没有默认包的概念)。这种严格的方法允许 Go 通过优先考虑惯例而不是配置来保持其编译规则和包解析规则简单。让我们深入探讨包的基本原理,包括它们的创建、使用和推荐实践。

理解 Go 包

在我们深入包的创建和使用之前,对包的概念有一个高层次的理解至关重要,这有助于引导后续的讨论。Go 包是代码组织的物理和逻辑单元,用于封装可重用的相关概念。按照惯例,存储在相同目录中的源文件组被认为是同一包的一部分。以下是一个简单的目录树示例,其中每个目录代表一个包含一些源代码的包:

 foo
 ├── blat.go
 └── bazz
 ├── quux.go
 └── qux.go 

golang.fyi/ch06-foo

虽然不是必需的,但建议的惯例是在每个源文件中将包的名称设置为与文件所在目录的名称匹配。例如,源文件 blat.go 被声明为 foo 包的一部分,如下面的代码所示,因为它存储在名为 foo 的目录中:

package foo 

import ( 
   "fmt" 
   "foo/bar/bazz" 
) 

func fooIt() { 
   fmt.Println("Foo!") 
   bazz.Qux() 
} 

golang.fyi/ch06-foo/foo/blat.go

文件 quux.goqux.go 都属于包 bazz,因为它们位于名为该名称的目录中,如下面的代码片段所示:

|

package bazz
import "fmt"
func Qux() {
  fmt.Println("bazz.Qux")
}

golang.fyi/ch06-foo/foo/bazz/quux.go |

package bazz
import "fmt"
func Quux() {
  Qux()fmt.Println("gazz.Quux")
}

golang.fyi/ch06-foo/foo/bazz/qux.go |

工作区

在讨论包时,还有一个重要概念需要理解,那就是 Go 工作区。工作区只是一个任意目录,用作命名空间,用于在编译等特定任务中解析包。按照惯例,Go 工具期望在工作区目录中有三个特定命名的子目录:srcpkgbin。这些子目录存储 Go 源文件以及所有构建的包工件。

建立一个静态目录位置,将 Go 包放在一起,具有以下优点:

  • 几乎零配置的简单设置

  • 通过减少代码搜索到已知位置来快速编译

  • 工具可以轻松创建代码和包文件的源图

  • 从源自动推断和解析传递依赖项

  • 可以使项目设置便携且易于分发

以下是我笔记本电脑上 Go 工作空间的局部(简化)树布局,其中突出显示了三个子目录 binpkgsrc

|

/home/vladimir/Go/   
├── bin   
│  ├── circ   
│  ├── golint   
│  ...   
├── pkg   
│  └── linux_amd64    
│    ├── github.com   
│    │  ├── golang   
│    │  │  └── lint.a   
│    │  └── vladimirvivien   
│    │    └── learning-go   
│    │      └── ch06   
│    │        ├── current.a   
│    ...       ...    
└── src   
  ├── github.com   
  │  ├── golang   
  │  │  └── lint   
  │  │    ├── golint   
  │  │    │  ├── golint.go   
  │  ...   ... ...   
  │  └── vladimirvivien   
  │    └── learning-go   
  │      ├── ch01   
  │      ...   
  │      ├── ch06   
  │      │  ├── current   
  │      │  │  ├── doc.go   
  │      │  │  └── lib.go   
  ...     ...      

|

示例工作空间目录

  • bin:这是一个自动生成的目录,用于存储编译后的 Go 可执行文件(也称为程序或命令)。当 Go 工具编译和安装可执行包时,它们将被放置在这个目录中。前一个示例工作空间显示了两个二进制文件 circgolint。建议将此目录添加到操作系统的 PATH 环境变量中,以便在本地使用你的命令。

  • pkg:此目录也是自动生成的,用于存储构建的包文件。当 Go 工具构建和安装非可执行包时,它们将作为具有 .a 后缀的对象文件存储在基于目标操作系统和架构命名的子目录中。在示例工作空间中,对象文件被放置在 linux_amd64 子目录下,这表明该目录中的对象文件是为在 64 位架构上运行的 Linux 操作系统编译的。

  • src:这是一个用户创建的目录,用于存储 Go 源代码文件。src 下的每个子目录都映射到一个包。src 是所有导入路径解析的根目录。Go 工具搜索该目录以解析在编译或其他依赖于源路径的活动期间引用的包。前一个示例工作空间显示了两个包:github.com/golang/lint/golint/github.com/vladimirvivien/learning-go/ch06/current

注意

你可能对工作空间示例中显示的包路径中的 github.com 前缀感到好奇。值得注意的是,对于包目录没有命名要求(参见 命名包 部分)。包可以有任何任意名称。然而,Go 推荐某些约定,这些约定有助于全局命名空间解析和包组织。

创建工作空间

创建工作空间就像设置一个名为 GOPATH 的操作系统环境变量,并将其分配给工作空间目录的根路径。例如,在一个 Linux 机器上,如果工作空间的根目录是 /home/username/Go,则工作空间将被设置为:

$> export GOPATH=/home/username/Go 

在设置 GOPATH 环境变量时,可以指定存储包的多个位置。每个目录由一个操作系统依赖的路径分隔符字符(换句话说,Linux/Unix 为冒号,Windows 为分号)分隔,如下所示:

$> export GOPATH=/home/myaccount/Go;/home/myaccount/poc/Go

当解析包名称时,Go 工具将搜索GOPATH中列出的所有位置。然而,Go 编译器将只将编译后的工件,如对象和二进制文件,存储在分配给GOPATH的第一个目录位置。

注意

通过简单地设置操作系统环境变量来配置工作空间具有巨大的优势。这使开发者能够在编译时动态地设置工作空间以满足某些工作流程要求。例如,开发者可能希望在合并之前测试一个未经验证的代码分支。他或她可能需要设置一个临时工作空间来构建该代码,如下所示(Linux): $> GOPATH=/temporary/go/workspace/path go build

导入路径

在继续介绍设置和使用包的细节之前,还有一个最后的重要概念需要介绍,那就是导入路径的概念。在$GOPATH/src下的每个包的相对路径构成了一个全局标识符,称为包的导入路径。这意味着在给定的工作空间中,没有两个包可以具有相同的导入路径值。

让我们回到之前简化的目录树。例如,如果我们设置工作空间为某个任意的路径值,例如GOPATH=/home/username/Go

/home/username/Go
└── foo
 ├── ablt.go
 └── bazz
 ├── quux.go
 └── qux.go 

从上面所示的示例工作空间中,包的目录路径映射到它们各自的导入路径,如下表所示:

目录路径 导入路径
/home/username/Go/foo
"foo"   

|

/home/username/Go/foo/bar
"foo/bar"   

|

/home/username/Go/foo/bar/bazz
"foo/bar/bazz"   

|

创建包

到目前为止,本章已经介绍了 Go 包的基本概念;现在是时候深入探讨包中 Go 代码的创建。Go 包的主要目的之一是将公共逻辑抽象出来并聚合到可共享的代码单元中。在本章的早期部分提到,目录中的一个 Go 源代码文件组被认为是包。虽然这在技术上是真的,但 Go 包的概念不仅仅是将一堆文件放入目录中。

为了帮助说明第一个包的创建,我们将使用在github.com/vladimirvivien/learning-go/ch06中找到的示例源代码。该目录中的代码定义了一组函数,用于使用欧姆定律计算电值。以下显示了组成示例包的目录布局(假设它们保存在某个工作空间目录$GOPATH/src中):

|

github.com/vladimirvivien/learning-go/ch06   
├── current   
│  ├── curr.go   
│  └── doc.go   
├── power   
│  ├── doc.go   
│  ├── ir   
│  │  └── power.go   
│  ├── powlib.go   
│  └── vr   
│    └── power.go   
├── resistor   
│  ├── doc.go   
│  ├── lib.go   
│  ├── res_equivalence.go   
│  ├── res.go   
│  └── res_power.go   
└── volt   
  ├── doc.go   
  └── volt.go   

|

Ohm 定律示例的包布局

在之前的树状结构中,每个目录都包含一个或多个 Go 源代码文件,这些文件定义并实现了将被安排到包中并使其可重用的函数和其他源代码元素。以下表格总结了从先前工作空间布局中提取的导入路径和包信息:

导入路径
"github.com/vladimirvivien/learning-go/ch06/current" current
"github.com/vladimirvivien/learning-go/ch06/power" power
"github.com/vladimirvivien/learning-go/ch06/power/ir" ir
"github.com/vladimirvivien/learning-go/ch06/power/vr" vr
"github.com/vladimirvivien/learning-go/ch06/resistor" resistor
"github.com/vladimirvivien/learning-go/ch06/volt" volt

虽然没有命名要求,但给包目录命名以反映它们各自的目的还是很有道理的。从上一个表中可以看出,示例中的每个包都被命名为代表一个电学概念,例如电流、功率、电阻和电压。包命名部分将进一步详细介绍包命名约定。

声明包

Go 源文件必须声明自己属于一个包。这是通过使用package子句来完成的,它是 Go 源文件中的第一个合法语句。声明的包由package关键字后跟一个名称标识符组成。以下显示了volt包中的源文件volt.go

package volt 

func V(i, r float64) float64 { 
   return i * r 
} 

func Vser(volts ...float64) (Vtotal float64) { 
   for _, v := range volts { 
         Vtotal = Vtotal + v 
   } 
   return 
} 

func Vpi(p, i float64) float64 { 
   return p / i 
} 

golang.fyi/ch06/volt/volt.go

源文件中的包标识符可以设置为任何任意值。与 Java 不同,包的名称并不反映源文件所在的目录结构。虽然对包名称没有要求,但将包标识符命名为与文件所在的目录相同的名称是一种公认的约定。在我们之前的源代码列表中,包被声明为标识符volt,因为文件存储在 volt 目录中。

多文件包

包的逻辑内容(源代码元素,如类型、函数、变量和常量)可以物理地跨越多个 Go 源文件。包目录可以包含一个或多个 Go 源文件。例如,在以下示例中,包resistor被不必要地分割成几个 Go 源文件,以说明这一点:

|

package resistor   

func recip(val float64) float64 {   
   return 1 / val   
}   

golang.fyi/ch06/resistor/lib.go |

|

  package resistor   

func Rser(resists ...float64) (Rtotal float64) {   
   for _, r := range resists {   
         Rtotal = Rtotal + r   
   }   
   return   
}   

func Rpara(resists ...float64) (Rtotal float64) {   
   for _, r := range resists {   
         Rtotal = Rtotal + recip(r)   
   }   
   return   
}   

golang.fyi/ch06/resistor/res_equivalance.go |

|

package resistor   

func R(v, i float64) float64 {   
   return v / i   
}   

golang.fyi/ch06/resistor/res.go |

|

package resistor   

func Rvp(v, p float64) float64 {   
   return (v * v) / p   
}   

golang.fyi/ch06/resistor/res_power.go |

每个包中的文件都必须有一个与相同名称标识符(在这种情况下为resistor)的包声明。Go 编译器会将所有源文件中的所有元素缝合在一起,形成一个可以在单个作用域内被其他包使用的单一逻辑单元。

需要指出的是,如果给定目录中的所有源文件中的包声明不相同,则编译将失败。这是可以理解的,因为编译器期望目录中的所有文件都属于同一个包。

包命名

如前所述,Go 期望工作区中的每个包都有一个唯一的完全限定导入路径。你的程序可以有任意多的包,你的包结构可以在工作区中尽可能深。然而,Go 的惯用规则规定了包的命名和组织的一些规则,以便于创建和使用包。

使用全局唯一命名空间

首先,在全局范围内完全限定你的包的导入路径是一个好主意,特别是如果你打算与他人共享代码。考虑以一个唯一标识你或你组织的命名空间方案开始你的导入路径名称。例如,公司*Acme, Inc.*可能选择以acme.com/apps开始所有他们的 Go 包名称。因此,一个包的完全限定导入路径将是"acme.com/apps/foo/bar"

注意

在本章的后面部分,我们将看到如何在使用 GitHub 等源代码仓库服务时使用包导入路径。

为路径添加上下文

接下来,当你为你的包制定命名方案时,使用包的路径为你的包名添加上下文。名称中的上下文应从通用开始,从左到右变得越来越具体。例如,让我们参考前面示例中的 power 包的导入路径。功率值的计算被分为三个子包,如下所示:

  • github.com/vladimirvivien/learning-go/ch06/**power**

  • github.com/vladimirvivien/learning-go/ch06/**power/ir**

  • github.com/vladimirvivien/learning-go/ch06/**power/vr**

父路径power包含具有更广泛上下文的包成员。子包irvr包含具有更具体、上下文更窄的成员。这种命名模式在 Go 中广泛使用,包括以下内置包:

  • crypto/md5

  • net/http

  • net/http/httputil

  • reflect

注意一个包深度为一个是一个完全合法的包名(参见reflect),只要它能捕捉到其上下文和所做事情的本质。再次强调,保持简单。避免在命名空间内嵌套包超过三层。如果你是一个习惯于长嵌套包名的 Java 开发者,这种诱惑将特别强烈。

使用短名称

当审查内置 Go 包的名称时,你会注意到与其他语言相比,名称的简短。在 Go 中,一个包被认为是一组实现一组紧密相关功能的代码集合。因此,你的包的导入路径应该是简洁的,并反映它们的功能,而不要过长。我们的示例源代码通过使用如 volt、power、resistance、current 等简短名称来命名包目录来体现这一点。在它们各自的上下文中,每个目录名称都确切地说明了包的功能。

短名称规则在 Go 的内置包中得到了严格的执行。例如,以下是从 Go 的内置包中的一些包名:loghttpxmlzip。每个名称都能清楚地识别包的用途。

注意

简短的包名在大型代码库中具有减少按键次数的优势。然而,拥有简短且通用的包名也有其缺点,即容易发生导入路径冲突。在大型项目(或开源库的开发者)中,他们可能会在代码中使用相同的流行名称(换句话说,logutildb等等)。正如我们在本章后面将要看到的,这可以通过使用named导入路径来处理。

构建包

Go 工具通过应用某些约定和合理的默认值来降低编译代码的复杂性。尽管 Go 的构建工具的全面讨论超出了本节(或本章)的范围,但了解buildinstall工具的目的和使用方法是很有用的。一般来说,构建和安装工具的使用方法如下:

$> go build []

import path可以显式提供或完全省略。build工具接受以完全限定或相对路径表示的import path。给定一个正确设置的工作区,以下都是编译前面示例中的包volt的等效方式:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go
$> go build ./ch06/volt 
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build ./volt 
$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06/volt
$> go build . 
$> cd $GOPATH/src/ 
$> go build github.com/vladimirvivien/learning-go/ch06/current /volt

上述go build命令将编译在目录volt中找到的所有 Go 源文件及其依赖项。此外,还可以使用附加到导入路径的通配符参数构建给定目录中的所有包和子包,如下所示:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build ./...

以下将构建在目录$GOPATH/src/github.com/vladimirvivien/learning-go/ch06中找到的所有包和子包。

安装包

默认情况下,构建命令将结果输出到由工具生成的临时目录中,该目录在构建过程完成后会丢失。要实际生成可用的工件,必须使用install工具来保留编译对象文件的副本。

install工具与构建工具具有完全相同的语义:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go install ./volt

除了编译代码外,它还会将结果保存并输出到工作区位置$GOPATH/pkg,如下所示:

$GOPATH/pkg/linux_amd64/github.com/vladimirvivien/learning-go/
└── ch06
 └── volt.a

生成的对象文件(具有.a扩展名)允许包在工作区中重用并与其他包链接。在本章后面,我们将探讨如何编译可执行程序。

包可见性

无论声明的源文件数量有多少,所有在包级别声明的源代码元素(类型、变量、常量和函数)都共享一个公共作用域。因此,编译器不会允许在包的整个范围内重复声明元素标识符。让我们使用以下代码片段来说明这一点,假设这两个源文件都是同一包$GOPATH/src/foo的一部分:

|

package foo   

var (   
  bar int = 12   
)   

func qux () {   
  bar += bar   
}   

foo/file1.go

package foo   

var bar struct{   
  x, y int   
}   

func quux() {   
  bar = bar * bar   
}   

foo/file2.go

非法变量标识符重新声明

虽然它们位于两个不同的文件中,但在 Go 语言中,具有标识符 bar 的变量声明是非法的。由于这两个文件是同一包的一部分,因此这两个标识符具有相同的范围,因此会发生冲突。

对于函数标识符也是如此。Go 语言不支持在同一作用域内重载函数名。因此,无论函数的签名如何,使用超过一次的函数标识符都是非法的。如果我们假设以下代码出现在同一包内的两个不同的源文件中,以下片段将是非法的:

|

package foo   

var (   
  bar int = 12   
)   

func qux () {   
  bar += bar   
}   

foo/file1.go

package foo   

var (   
  fooVal int = 12   
)   

func qux (inc int) int {   
  return fooVal += inc   
}   

foo/file1.go

非法函数标识符重新声明

在前面的代码片段中,函数名标识符 qux 被使用了两次。即使两个函数有不同的签名,编译器也会失败编译。唯一修复的方法是更改名称。

包成员可见性

包的有用之处在于它能够将其源元素暴露给其他包。控制包元素的可见性很简单,遵循以下规则:大写标识符会自动导出。这意味着任何具有大写标识符的类型、变量、常量或函数都会自动从声明它的包外部可见。

参考前面描述的欧姆定律示例,以下展示了从 resistor 包(位于 github.com/vladimirvivien/learning-go/ch06/resistor)中的这一功能:

代码 描述

|

package resistor   

func R(v, i float64) float64 {   
   return v / i   
}   

函数 R 会自动导出,可以从其他包中访问:resistor.R()

|

package resistor   

func recip(val float64) float64 {   
   return 1 / val   
}   

函数标识符 recip 全部为小写,因此不会被导出。尽管在它自己的作用域内是可访问的,但该函数在其他包中是不可见的。

值得重申的是,同一包内的成员总是对彼此可见。在 Go 语言中,没有像其他语言中那样的复杂可见性结构,如私有、友元、默认等。这使开发者能够专注于正在实现的解决方案,而不是建模可见性层次。

导入包

在这个阶段,你应该已经很好地理解了什么是包,它有什么作用,以及如何创建一个包。现在,让我们看看如何使用包来导入和重用其成员。正如你将在其他几种语言中发现的那样,关键字 import 用于从外部包导入源代码元素。它允许导入的源访问在导入的包中找到的导出元素(参见本章前面提到的 包作用域和可见性 部分)。导入子句的一般格式如下:

import [包名标识符] "<导入路径>"

注意,导入路径必须用双引号括起来。import 语句还支持一个可选的包标识符,可以用来显式命名导入的包(稍后讨论)。import 语句也可以写成导入块,如下所示。这在有两个或更多导入包列表的情况下很有用:

import (

[包名称标识符] "<导入路径>"

)

以下源代码片段显示了之前介绍的欧姆定律示例中的导入声明块:

import ( 
   "flag" 
   "fmt" 
   "os" 

   "github.com/vladimirvivien/learning-go/ch06/current" 
   "github.com/vladimirvivien/learning-go/ch06/power" 
   "github.com/vladimirvivien/learning-go/ch06/power/ir" 
   "github.com/vladimirvivien/learning-go/ch06/power/vr" 
      "github.com/vladimirvivien/learning-go/ch06/volt" 
) 

golang.fyi/ch06/main.go

通常省略导入包的名称标识符,如上所示。Go 将导入路径的最后一个目录的名称作为导入包的名称标识符,如下表所示的一些包所示:

导入路径 包名
flag flag
github.com/vladimirvivien/learning-go/ch06/current current
github.com/vladimirvivien/learning-go/ch06/power/ir ir
github.com/vladimirvivien/learning-go/ch06/volt volt
volt.V() is invoked from imported package "github.com/vladimirvivien/learning-go/ch06/volt":
... 
import "github.com/vladimirvivien/learning-go/ch06/volt" 
func main() { 
   ... 
   switch op { 
   case "V", "v": 
         val := volt.V(i, r) 
  ... 
} 

golang.fyi/ch06/main.go

指定包标识符

import res "github.com/vladimirvivien/learning-go/ch06/resistor"

按照前面描述的格式,名称标识符放在导入路径之前,如前面的片段所示。命名包可以用作缩短或自定义包名称的一种方式。例如,在一个包含大量特定包使用的源文件中,这可以是一个减少按键的好功能。

给包命名也是避免给定源文件中包标识符冲突的一种方法。可以想象导入两个或更多具有不同导入路径但解析到相同包名的包。例如,您可能需要使用来自不同库的两个不同的日志系统来记录信息,如下面的代码片段所示:

package foo 
import ( 
   flog "github.com/woom/bat/logger" 
   hlog "foo/bar/util/logger" 
) 

func main() { 
   flog.Info("Programm started") 
   err := doSomething() 
   if err != nil { 
     hlog.SubmitError("Error - unable to do something") 
   } 
} 

"logger" by default. To resolve this, at least one of the imported packages must be assigned a name identifier to resolve the name clash. In the previous example, both import paths were named with a meaningful name to help with code comprehension.

点标识符

SubmitError from the logger package, the package name is omitted:
package foo 

import ( 
   . "foo/bar/util/logger" 
) 

func main() { 
   err := doSomething() 
   if err != nil { 
     SubmitError("Error - unable to do something") 
   } 
} 

虽然这个特性可以帮助减少重复的按键,但并不鼓励这种做法。通过合并包的作用域,更容易遇到标识符冲突。

空标识符

fmt; however, it never uses it in the subsequent source code:
package foo 
import ( 
   _ "fmt" 
   "foo/bar/util/logger" 
) 

func main() { 
   err := doSomething() 
   if err != nil { 
     logger.Submit("Error - unable to do something") 
   } 
} 

空标识符的一个常见用法是加载包以产生副作用。这依赖于当导入包时的初始化顺序(参见以下 包初始化 部分)。使用空标识符会导致即使无法引用其任何成员,导入的包也会被初始化。这在需要静默运行某些初始化序列的上下文中被使用。

包初始化

foo will be a, y, b, and x:
package foo 
var x = a + b(a) 
var a = 2 
var b = func(i int) int {return y * i} 
var y = 3 

Go 还使用一个名为 init 的特殊函数,它不接受任何参数也不返回任何结果值。它用于封装在导入包时调用的自定义初始化逻辑。例如,以下源代码显示了在 resistor 包中使用 init 函数来初始化函数变量 Rpi

package resistor 

var Rpi func(float64, float64) float64 

func init() { 
   Rpi = func(p, i float64) float64 { 
         return p / (i * i) 
   } 
} 

func Rvp(v, p float64) float64 { 
   return (v * v) / p 
} 

golang.fyi/ch06/resistor/res_power.go

在前面的代码中,init 函数在包级变量初始化之后被调用。因此,init 函数中的代码可以安全地依赖于声明的变量值处于稳定状态。init 函数具有以下特殊之处:

  • 一个包可以定义多个 init 函数

  • 你不能在运行时直接访问声明的 init 函数

  • 它们按照在源文件中出现的词法顺序执行

  • init 函数是将逻辑注入在执行任何其他函数或方法之前执行的包的一种很好的方式。

创建程序

到目前为止,在本书中,你已经学习了如何创建和打包 Go 代码作为可重用的包。然而,一个包不能作为一个独立程序执行。要创建一个程序(也称为命令),你需要将一个包作为一个执行入口点进行定义,如下所示:

  • 声明(至少一个)源文件为名为 main 的特殊包的一部分

  • 声明一个函数名 main() 作为程序的入口点

main 函数不接受任何参数也不返回任何值。以下显示了用于欧姆定律示例的 main 包的缩略源代码(从前面)。它使用来自 Go 标准库的 flag 包来解析格式为 flag 的程序参数:

package main 
import ( 
   "flag" 
   "fmt" 
   "os" 

   "github.com/vladimirvivien/learning-go/ch06/current" 
   "github.com/vladimirvivien/learning-go/ch06/power" 
   "github.com/vladimirvivien/learning-go/ch06/power/ir" 
   "github.com/vladimirvivien/learning-go/ch06/power/vr" 
   res "github.com/vladimirvivien/learning-go/ch06/resistor" 
   "github.com/vladimirvivien/learning-go/ch06/volt" 
) 

var ( 
   op string 
   v float64 
   r float64 
   i float64 
   p float64 

   usage = "Usage: ./circ <command> [arguments]\n" + 
     "Valid command { V | Vpi | R | Rvp | I | Ivp |"+  
    "P | Pir | Pvr }" 
) 

func init() { 
   flag.Float64Var(&v, "v", 0.0, "Voltage value (volt)") 
   flag.Float64Var(&r, "r", 0.0, "Resistance value (ohms)") 
   flag.Float64Var(&i, "i", 0.0, "Current value (amp)") 
   flag.Float64Var(&p, "p", 0.0, "Electrical power (watt)") 
   flag.StringVar(&op, "op", "V", "Command - one of { V | Vpi |"+   
    " R | Rvp | I | Ivp | P | Pir | Pvr }") 
} 

func main() { 
   flag.Parse() 
   // execute operation 
   switch op { 
   case "V", "v": 
    val := volt.V(i, r) 
    fmt.Printf("V = %0.2f * %0.2f = %0.2f volts\n", i, r, val) 
   case "Vpi", "vpi": 
   val := volt.Vpi(p, i) 
    fmt.Printf("Vpi = %0.2f / %0.2f = %0.2f volts\n", p, i, val) 
   case "R", "r": 
   val := res.R(v, i)) 
    fmt.Printf("R = %0.2f / %0.2f = %0.2f Ohms\n", v, i, val) 
   case "I", "i": 
   val := current.I(v, r)) 
    fmt.Printf("I = %0.2f / %0.2f = %0.2f amps\n", v, r, val) 
   ... 
   default: 
         fmt.Println(usage) 
         os.Exit(1) 
   } 
} 

golang.fyi/ch06/main.go

以下列表显示了 main 包的源代码以及当程序运行时执行的 main 函数的实现。欧姆定律程序接受命令行参数,指定要执行哪种电操作(请参阅以下 访问程序参数 部分)。init 函数用于初始化程序标志值的解析。main 函数被设置为一个大的 switch 语句块,根据选定的标志选择要执行的正确操作。

访问程序参数

当程序执行时,Go 运行时通过包变量 os.Args 将所有命令行参数作为一个切片提供。例如,当以下程序执行时,它打印出传递给程序的所有命令行参数:

package main 
import ( 
   "fmt" 
   "os" 
) 

func main() { 
   for _, arg := range os.Args { 
         fmt.Println(arg) 
   } 
} 

golang.fyi/ch06-args/hello.go

以下是在使用所示参数调用程序时的输出:

$> go run hello.go hello world how are you?
/var/folders/.../exe/hello
hello
world
how
are
you?

注意,命令行参数 "hello world how are you?" 放在程序名称之后,被分割成一个空格分隔的字符串。os.Args 切片中的位置 0 保存了程序二进制路径的完全限定名称。切片的其余部分分别存储字符串中的每个项。

Go 标准库中的 flag 包内部使用此机制来提供结构化命令行参数的处理,这些参数被称为标志。在前面列出的欧姆定律示例中,flag 包用于解析以下源代码片段中列出的几个标志(从前面的完整列表中提取):

var ( 
   op string 
   v float64 
   r float64 
   i float64 
   p float64 
) 

func init() { 
   flag.Float64Var(&v, "v", 0.0, "Voltage value (volt)") 
   flag.Float64Var(&r, "r", 0.0, "Resistance value (ohms)") 
   flag.Float64Var(&i, "i", 0.0, "Current value (amp)") 
   flag.Float64Var(&p, "p", 0.0, "Electrical power (watt)") 
   flag.StringVar(&op, "op", "V", "Command - one of { V | Vpi |"+   
    " R | Rvp | I | Ivp | P | Pir | Pvr }") 
} 
func main(){ 
  flag.Parse() 
  ... 
} 

init used to parse and initialize expected flags "v", "i", "p", and "op" (at runtime, each flag is prefixed with a minus sign). The initialization functions in package flag sets up the expected type, the default value, a flag description, and where to store the parsed value for the flag. The flag package also supports the special flag "help", used to provide helpful hints about each flag.

main函数中,flag.Parse()用于启动解析任何作为命令行提供的标志的过程。例如,为了计算一个 12 伏特和 300 欧姆的电路的电流,程序需要三个标志并产生以下输出:

$> go run main.go -op I -v 12 -r 300
I = 12.00 / 300.00 = 0.04 amps

构建和安装程序

构建和安装 Go 程序遵循与构建常规包完全相同的程序(如在前面的构建和安装包部分中讨论的那样)。当你构建可执行 Go 程序源文件时,编译器将通过传递链接main包中声明的所有依赖项来生成一个可执行的二进制文件。默认情况下,构建工具将输出二进制文件命名为与 Go 程序源文件所在的目录相同的名字。

例如,在欧姆定律的示例中,位于github.com/vladimirvivien/learning-go/ch06目录中的main.go文件被声明为main包的一部分。程序可以按照以下方式构建:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build .

main.go源文件被构建时,构建工具将生成一个名为ch06的二进制文件,因为程序的源代码位于一个同名目录中。你可以使用输出标志-o来控制二进制文件的名字。在以下示例中,构建工具创建了一个名为ohms的二进制文件。

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go build -o ohms

最后,使用 Go 的install命令安装 Go 程序的方式与使用 Go 安装常规包的方式完全相同:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch06
$> go install .

当使用 Go 的install命令安装程序时,如果需要,它将被构建,其生成的二进制文件将被保存在$GOPAHT/bin目录中。将工作区bin目录添加到你的操作系统的$PATH环境变量中,将使你的 Go 程序可用于执行。

注意

Go 生成的程序是静态链接的二进制文件。它们运行时不需要满足任何额外的依赖。然而,Go 编译的二进制文件包含了 Go 运行时。这是一组处理垃圾回收、类型信息、反射、goroutine 调度和 panic 管理的操作。虽然一个类似的 C 程序可能要小得多,但 Go 的运行时附带了一些使 Go 变得有趣的工具。

远程包

Go 附带的一个工具允许程序员直接从远程源代码仓库检索包。默认情况下,Go 可以轻松地与以下版本控制系统集成:

注意

为了让 Go 从远程仓库拉取包源代码,您必须在操作系统的执行路径上安装该版本控制系统的客户端作为命令。在底层,Go 启动客户端与源代码仓库服务器进行交互。

get 命令行工具允许程序员使用完全限定的项目路径作为包的导入路径来检索远程包。一旦下载了包,就可以将其导入到本地源文件中使用。例如,如果您想包含前一个片段中 Ohm 定律示例中的一个包,您可以从命令行发出以下命令:

$> go get github.com/vladimirvivien/learning-go/ch06/volt

go get 工具会下载指定的导入路径以及所有引用的依赖项。然后,该工具将在 $GOPATH/pkg 中构建和安装包的工件。如果 import 路径恰好是一个程序,go get 还会在 $GOPATH/bin 中生成二进制文件以及 $GOPATH/pkg 中引用的任何包。

摘要

本章深入探讨了源代码组织和包的概念。读者了解了 Go 工作空间和导入路径。读者还介绍了包的创建以及如何导入包以实现代码重用。本章介绍了诸如导入成员的可见性和包初始化等机制。章节的最后部分讨论了从打包代码创建可执行 Go 程序所需的步骤。

这是一个内容丰富的章节,理应如此,以公正地对待 Go 中包创建和管理这样广泛的主题。下一章将回到 Go 类型讨论,详细介绍了复合类型,如数组、切片、结构体和映射。

第七章。复合类型

在前面的章节中,你可能在一些示例代码中瞥见了复合类型(如数组、切片、映射和结构体)的使用。虽然早期接触这些类型可能让你感到好奇,但请放心,在本章中,你将有机会学习所有关于这些复合类型的内容。本章继续了第四章中开始的内容,数据类型,讨论以下主题:

  • 数组类型

  • 切片类型

  • 映射类型

  • 结构体类型

数组类型

正如你可能在其他语言中找到的那样,Go 数组是用于存储相同类型数值序列的容器,这些数值可以通过数字索引访问。以下代码片段展示了被分配数组类型的变量示例:

var val [100]int 
var days [7]string 
var truth [256]bool 
var histogram [5]map[string]int 

golang.fyi/ch07/arrtypes.go

注意在前面示例中分配给每个变量的类型是使用以下类型格式指定的:

[<长度>]<元素类型>

数组的类型定义由其长度组成,长度用括号括起来,后跟存储元素的类型。例如,days 变量被分配了一个类型 [7]string。这是一个重要的区别,因为 Go 的类型系统认为存储相同类型元素但长度不同的两个数组是不同类型的。以下代码说明了这种情况:

var days [7]string 
var weekdays [5]string 

即使这两个变量都是类型为 string 的数组,类型系统仍然将 daysweekdays 变量视为不同的类型。

注意

在本章的后面部分,你将看到如何使用切片类型而不是数组来减轻这种类型限制。

数组类型可以被定义为多维。这是通过组合和嵌套一维数组类型的定义来实现的,如下面的代码片段所示:

var board [4][2]int
var matrix [2][2][2][2] byte

golang.fyi/ch07/arrtypes.go

Go 没有单独的多维数组类型。具有多个维度的数组是由嵌套在彼此内部的一维数组组成的。下一节将介绍如何初始化单维和多维数组。

数组初始化

当一个数组变量没有显式初始化时,它的所有元素都将被分配给声明类型为零值。数组可以使用以下通用格式的复合字面量值进行初始化:

<数组类型>{<逗号分隔的元素值列表}>

数组的字面值由数组类型定义(在上一节中讨论)后跟一组逗号分隔的值组成,并用大括号括起来,如下面的代码片段所示,该片段展示了几个数组的声明和初始化:

var val [100]int = [100]int{44,72,12,55,64,1,4,90,13,54}
var days [7]string = [7]string{
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
}
var truth = [256]bool{true}
var histogram = [5]map[string]int {
  map[string]int{"A":12,"B":1, "D":15},
  map[string]int{"man":1344,"women":844, "children":577,...},
}

golang.fyi/ch07/arrinit.go

字面值中的元素数量必须小于或等于数组类型中声明的尺寸。如果定义的数组是多维的,可以通过在每个维度内部嵌套另一个维度的括号来使用字面值进行初始化,如下面的示例片段所示:

var board = [4][2]int{ 
   {33, 23}, 
   {62, 2}, 
   {23, 4}, 
   {51, 88}, 
} 
var matrix = [2][2][2][2]byte{ 
   {{{4, 4}, {3, 5}}, {{55, 12}, {22, 4}}}, 
   {{{2, 2}, {7, 9}}, {{43, 0}, {88, 7}}}, 
} 

[5]string to variable weekdays:
var weekdays = [...]string{ 
   "Monday", 
   "Tuesday", 
   "Wednesday", 
   "Thursday", 
   "Friday",    
}  

数组的字面值也可以进行索引。如果你只想初始化某些数组元素,同时允许其他元素使用它们的自然零值进行初始化,这很有用。以下指定了位置 0、2468的元素初始值。其余元素将被分配空字符串:

var msg = [12]rune{0: 'H', 2: 'E', 4: 'L', 6: 'O', 8: '!'} 

声明命名数组类型

matrix, using a multi-dimension array as its underlying type:
type matrix [2][2][2][2]byte 

func main() { 
   var mat1 matrix 
   mat1 = initMat() 
   fmt.Println(mat1) 
} 

func initMat() matrix { 
   return matrix{ 
         {{{4, 4}, {3, 5}}, {{55, 12}, {22, 4}}}, 
         {{{2, 2}, {7, 9}}, {{43, 0}, {88, 7}}}, 
   } 
} 

golang.fyi/ch07/arrtype_dec.go

声明的命名类型matrix可以在所有其底层数组类型被使用的上下文中使用。这允许使用简化的语法,从而促进复杂数组类型的重用。

使用数组

数组是静态实体,一旦声明了指定长度,就不能增长或缩小。当程序需要分配一个预定义大小的连续内存块时,数组是一个很好的选择。当声明数组类型的变量时,它就准备好使用,无需任何进一步的分配语义。

因此,以下image变量的声明将分配一个由 256 个相邻的int值组成的内存块,这些值初始化为零,如图所示:

var image [256]byte

使用数组

与 C 和 Java 类似,Go 使用方括号索引表达式来访问存储在数组变量中的值。这是通过指定变量标识符后跟方括号内元素的索引来完成的,如下面的代码示例所示:

p := [5]int{122,6,23,44,6} 
p[4] = 82 
fmt.Println(p[0]) 

之前的代码更新了数组的第五个元素并打印了第一个元素。

数组长度和容量

seven of type [7]string will return 7 as its length and capacity:
func main() { 
   seven := [7]string{"grumpy", "sleepy", "bashful"} 
   fmt.Println(len(seven), cap(seven)) 
} 

对于数组,cap()函数始终返回与len()相同的值。这是因为数组值的最大容量是其声明的长度。容量函数更适合与切片类型(本章后面讨论)一起使用。

数组遍历

for statement, to initialize an array with random numbers in init(), and the for range statement used to realize the max() function:
const size = 1000 
var nums [size]int 

func init() { 
   rand.Seed(time.Now().UnixNano()) 
   for i := 0; i < size; i++ { 
         nums[i] = rand.Intn(10000) 
   } 
} 

func max(nums [size]int) int { 
   temp := nums[0] 
   for _, val := range nums { 
         if val > temp { 
               temp = val 
         } 
   } 
   return temp 
} 

golang.fyi/ch07/arrmax_iter.go

在传统的for循环语句中,循环的索引变量i用于通过索引表达式num[i]访问数组的值。在for…range语句中,在max函数中,每次循环迭代值存储在val变量中,而索引被忽略(赋给空标识符)。如果你不明白for语句是如何工作的,请参阅第三章,Go 控制流,以详细了解 Go 中循环的机制。

数组作为参数

数组值被视为一个单一单元。数组变量不是指向内存中位置的指针,而是代表包含数组元素的整个内存块。这意味着当数组变量被重新赋值或作为函数参数传递时,会创建数组值的副本。

这可能会对程序的内存消耗产生不良影响。一个解决方案是使用指针类型来引用数组值。在以下示例中,声明了一个名为numbers的命名类型,以表示数组类型[1024 * 1024]int。函数initialize()max()接收类型为*numbers的指针,如下面的源代码片段所示:

type numbers [1024 * 1024]int 
func initialize(nums *numbers) { 
   rand.Seed(time.Now().UnixNano()) 
   for i := 0; i < size; i++ { 
         nums[i] = rand.Intn(10000) 
   } 
} 
func max(nums *numbers) int { 
   temp := nums[0] 
   for _, val := range nums { 
         if val > temp { 
               temp = val 
         } 
   } 
   return temp 
} 
func main() { 
   var nums *numbers = new(numbers) 
   initialize(nums) 
} 

&galaxies{...} returns pointer *galaxies, initialized with the specified element values:
type galaxies [14]string 
func main() { 
   namedGalaxies = &galaxies{ 
         "Andromeda", 
         "Black Eye", 
         "Bode's", 
          ...   
   } 
   printGalaxies(namedGalaxies) 
} 

golang.fyi/ch07/arraddr.go

数组类型是 Go 中的低级存储结构。例如,数组通常用作存储原语的基础,其中对内存分配有严格的限制,以最小化空间消耗。然而,在更常见的案例中,下一节中介绍的切片通常用作处理有序索引集合的更习惯的方式。

切片类型

切片类型在 Go 中通常用作索引数据的习惯构造。切片比数组更灵活,具有许多更有趣的特性。切片本身是一个复合类型,其语义类似于数组。实际上,切片使用数组作为其底层数据存储机制。切片类型的通用形式如下所示:

[ ]<element_type>

切片类型与数组类型之间一个明显的区别是在类型声明中省略了大小,如下面的示例所示:

var ( 
    image []byte      
    ids []string 
    vector []float64 
    months []string 
    q1 []string 
    histogram []map[string]int // slice of map (see map later) 
) 

golang.fyi/ch07/slicetypes.go

切片类型中缺失的大小属性表示以下内容:

  • 与数组不同,切片的大小不是固定的

  • 切片类型代表所有指定元素类型的集合

这意味着切片理论上可以无限制地增长(尽管在实践中并不总是这样,因为切片由一个底层的有界数组支持)。给定元素类型的切片被认为是同一类型,无论其底层大小如何。这消除了数组中存在的限制,即大小决定了类型。

例如,以下变量monthsq1具有相同的[]string类型,并且可以无问题编译:

var ( 
    months []string 
    q1 []string 
) 
func print(strs []string){ ... } 
func main() { 
   print(months) 
   print(q1) 
} 

golang.fyi/ch07/slicetypes.go

与数组类似,切片类型可以嵌套以创建多维切片,如下代码片段所示。每个维度可以独立地有自己的大小,并且必须单独初始化:

var( 
    board [][]int 
    graph [][][][]int 
) 

切片初始化

切片在类型系统中表示为一个值(下一节将探讨切片的内部表示)。然而,与数组类型不同,未初始化的切片具有零值nil,这意味着尝试访问未初始化切片的元素将导致程序崩溃。

初始化切片的最简单方法之一是使用以下格式的复合字面值(类似于数组):

<slice_type>{<逗号分隔的元素值列表}>

切片的字面值由切片类型后跟一系列用逗号分隔的值组成,这些值用花括号括起来,并分配给切片的元素。以下代码片段展示了几个使用复合字面值初始化的切片变量:

var ( 
    ids []string = []string{"fe225", "ac144", "3b12c"} 
    vector = []float64{12.4, 44, 126, 2, 11.5}  
    months = []string { 
         "Jan", "Feb", "Mar", "Apr", 
         "May", "Jun", "Jul", "Aug", 
         "Sep", "Oct", "Nov", "Dec", 
    } 
    // slice of map type (maps are covered later) 
    tables = []map[string][]int { 
         { 
               "age":{53, 13, 5, 55, 45, 62, 34, 7}, 
               "pay":{124, 66, 777, 531, 933, 231}, 
         }, 
    } 
    graph  = [][][][]int{ 
         {{{44}, {3, 5}}, {{55, 12, 3}, {22, 4}}}, 
         {{{22, 12, 9, 19}, {7, 9}}, {{43, 0, 44, 12}, {7}}},     
    } 
) 

golang.fyi/ch07/sliceinit.go

如前所述,切片的复合字面值使用与数组类似的形式表示。然而,字面值中提供的元素数量不受固定大小的限制。这意味着字面值可以按需扩展。然而,在幕后,Go 会创建并管理一个适当大小的数组来存储字面值中表达的价值。

切片表示

之前提到,切片值使用底层数组来存储数据。实际上,“切片”这个名字是指向数组数据段的引用。在内部,切片通过以下三个属性表示为一个复合值:

属性 描述
指针 指针是切片存储在底层数组中的第一个元素的地址。当切片值未初始化时,其指针值为 nil,表示它尚未指向一个数组。Go 使用指针作为切片本身的零值。未初始化的切片将返回 nil 作为其零值。然而,切片值在类型系统中不被视为引用值。这意味着某些函数可以应用于 nil 切片,而其他函数将导致恐慌。一旦创建切片,指针就不会改变。要指向不同的起始点,必须创建一个新的切片。
长度 长度表示从第一个元素开始可以访问的连续元素的数量。它是一个动态值,可以增长到切片的容量(见下文容量)。切片的长度始终小于或等于其容量。尝试访问切片长度之外的元素,而不进行大小调整,将导致恐慌。即使容量大于长度,这也适用。
容量 切片的容量是指从第一个元素开始可能存储在切片中的最大元素数量。切片的容量受底层数组长度的限制。

因此,当以下变量 halfyr 如下初始化时:

halfyr := []string{"Jan","Feb","Mar","Apr","May","Jun"}

它将被存储在一个类型为 [6]string 的数组中,包含指向第一个元素的指针、长度和容量为 6,如图所示:

切片表示

切片操作

创建切片值的另一种方法是通过对现有数组或另一个切片值(或这些值的指针)进行切片。Go 提供了一种索引格式,使得表达切片操作变得容易,如下所示:

<切片或数组值>[<低索引>:<高索引>]

切片表达式使用 [:] 操作符来指定低界和高界索引,由冒号分隔,用于切片段。

  • 值是切片段开始的零基于索引

  • 值是段停止处的第 n 个元素偏移量

下表展示了通过重新切片以下值 halfyr := []string{"Jan","Feb","Mar","Apr","May","Jun"} 的切片表达式示例。

表达式 描述
all := halfyr[:] 在表达式中省略低索引和高索引相当于以下内容:all := halfyr[0 : 6] 这将产生一个新的切片段,与原始切片相等,从索引位置 0 开始,到偏移位置 6 结束:["Jan","Feb","Mar","Apr","May","Jun"]
q1 := halfyr[:3] 这里切片表达式省略了低索引值并指定了切片段长度为 3。它返回一个新的切片,["Jan","Feb","Mar"]
q2 := halfyr[3:] 这通过指定起始索引位置为 3 并省略高界索引值(默认为 6),创建了一个新的切片段,包含最后三个元素。
mapr := halfyr[2:4] 为了消除对切片表达式的任何混淆,此示例展示了如何创建一个新的切片,包含月份 "Mar""Apr"。这返回一个值为 ["Mar","Apr"] 的切片。

切片切片

切片现有的切片或数组值不会创建一个新的底层数组。新的切片创建新的指针位置指向底层数组。例如,以下代码展示了将切片值 halfyr 切片成两个额外的切片:

var ( 
    halfyr = []string{ 
         "Jan", "Feb", "Mar", 
         "Apr", "May", "Jun", 
    } 

    q1 = halfyr[:3] 
    q2 = halfyr[3:] 
) 

golang.fyi/ch07/slice_reslice.go

支持数组的切片可能有多个,以特定视图投影其数据。以下图示说明了前述代码中的切片可能如何直观表示:

切片切片

注意,切片 q1q2 都指向同一底层数组中的不同元素。切片 q1 的初始长度为 3,容量为 6。这意味着 q1 可以调整大小至总共 6 个元素。然而,切片 q2 的大小为 3,容量为 3,不能超过其初始大小(切片调整大小将在后面介绍)。

切片数组

如前所述,数组也可以直接切片。在这种情况下,提供的数组值成为底层数组。切片的容量和长度将使用提供的数组来计算。以下源代码片段展示了现有数组值 months 的切片:

var ( 
    months [12]string = [12]string{ 
         "Jan", "Feb", "Mar", "Apr", "May", "Jun", 
         "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 
    } 

    halfyr = months[:6] 
    q1 = halfyr[:3] 
    q2 = halfyr[3:6] 
    q3 = months[6:9] 
    q4 = months[9:] 
) 

golang.fyi/ch07/slice_reslice_arr.go

带容量的切片表达式

最后,Go 的切片表达式支持更长的形式,其中包含切片的最大容量,如下所示:

<切片或数组值>[<低索引>:<高索引>:max]

max 属性指定用作新切片最大容量的索引值。该值可能小于或等于底层数组的实际容量。以下示例展示了包含最大值的数组切片:

var ( 
    months [12]string = [12]string{ 
         "Jan", "Feb", "Mar", "Apr", "May", "Jun", 
         "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 
    } 
    summer1 = months[6:9:9] 
) 

summer1 with size 3 (starting at index position 6 to 9). The max index is set to position 9, which means the slice has a capacity of 3. If the max was not specified, the maximum capacity would automatically be set to the last position of the underlying array as before.

创建切片

make() function to initialize the slice:
func main() { 
   months := make([]string, 6) 
   ... 
} 

make() does the followings:
  • 创建类型为[6]string的底层数组

  • 使用长度和容量为6创建切片值

  • 返回切片值(不是指针)

使用make()函数初始化后,访问合法的索引位置将返回切片元素的零值,而不是导致程序崩溃。make()函数可以接受一个可选的第三个参数,用于指定切片的最大容量,如下面的示例所示:

func main() { 
   months := make([]string, 6, 12)  
   ... 
} 

months variable with a slice value with an initial length of 6 and a maximum capacity of 12.

使用切片

使用切片值最简单的操作是访问其元素。如前所述,切片使用索引表示法来访问其元素,类似于数组。以下示例访问索引位置为 0 的元素并将其更新为15

func main () { 
   h := []float64{12.5, 18.4, 7.0} 
   h[0] = 15 
   fmt.Println(h[0]) 
   ... 
} 

golang.fyi/ch07/slice_use.go

当程序运行时,它使用索引表达式h[0]打印更新后的值,以检索位置0的项的值。请注意,只有索引数字的切片表达式,例如h[0],返回该位置的项的值。然而,当表达式包含冒号时,例如h[2:]h[:6],该表达式返回一个新的切片。

使用传统的for语句或更惯用的for…range语句进行切片遍历,如下面的代码片段所示:

func scale(factor float64, vector []float64) []float64 { 
   for i := range vector { 
         vector[i] *= factor 
   } 
   return vector 
} 

func contains(val float64, numbers []float64) bool { 
   for _, num := range numbers { 
         if num == val { 
               return true 
         } 
   } 
   return false 
} 

scale uses index variable i to update the values in slice factor directly, while function contains uses the iteration-emitted value stored in num to access the slice element. If you need further detail on the for…range statement, see Chapter 3, *Go Control Flow*.

将切片作为参数

vector parameter will be seen by the caller of function scale:
func scale(factor float64, vector []float64) { 
   for i := range vector { 
         vector[i] *= factor 
   } 
} 

golang.fyi/ch07/slice_loop.go

长度和容量

Go 提供了两个内置函数来查询切片的长度和容量属性。给定一个切片,可以使用lencap函数分别查询其长度和最大容量,如下面的示例所示:

func main() { 
    var vector []float64 
    fmt.Println(len(vector)) // prints 0, no panic 
    h := make([]float64, 4, 10) 
    fmt.Println(len(h), ",", cap(h)) 
} 

回想一下,切片是一个值(不是指针),其零值为 nil。因此,代码能够在运行时查询未初始化切片的长度(和容量)而不会导致崩溃。

向切片中追加

切片类型的一个不可或缺的特性是它们能够动态增长。默认情况下,切片具有静态的长度和容量。任何尝试访问超出该限制的索引的操作都将导致崩溃。Go 提供了内置的变长函数append,可以动态地向指定的切片添加新值,根据需要增长其长度和容量。以下代码片段展示了如何实现这一点:

func main() { 
   months := make([]string, 3, 3) 
   months = append(months, "Jan", "Feb", "March",  
    "Apr", "May", "June") 
   months = append(months, []string{"Jul", "Aug", "Sep"}...) 
   months = append(months, "Oct", "Nov", "Dec") 
   fmt.Println(len(months), cap(months), months) 
} 

3. The append function is used to dynamically add new values to the slice beyond its initial size and capacity. Internally, append will attempt to fit the appended values within the target slice. If the slice has not been initialized or has an inadequate capacity, append will allocate a new underlying array, to store the values of the updated slice.

复制切片

clone() function, which makes a new copy of a slice of numbers:
func clone(v []float64) (result []float64) { 
   result = make([]float64, len(v), cap(v)) 
   copy(result, v) 
   return 
} 

copy function copies the content of v slice into result. Both source and target slices must be the same size and of the same type or the copy operation will fail.

字符串作为切片

在内部,字符串类型是通过使用指向底层 rune 数组复合值的切片实现的。这使得字符串类型获得了与切片相同的惯用处理方式。例如,以下代码片段使用索引表达式从给定的字符串值中提取字符串切片:

func main() { 
   msg := "Bobsayshelloworld!" 
   fmt.Println( 
         msg[:3], msg[3:7], msg[7:12],  
         msg[12:17], msg[len(msg)-1:], 
   ) 
} 

golang.fyi/ch07/slice_string.go

在字符串上的切片表达式将返回一个指向其底层 runes 数组的新的字符串值。字符串值可以转换为字节数组(或 rune 切片),如下面的函数片段所示,该片段对给定字符串的字符进行排序:

func sort(str string) string { 
   bytes := []byte(str) 
   var temp byte 
   for i := range bytes { 
         for j := i + 1; j < len(bytes); j++ { 
               if bytes[j] < bytes[i] { 
                     temp = bytes[i] 
                     bytes[i], bytes[j] = bytes[j], temp 
               } 
         } 
   } 
   return string(bytes) 
} 

golang.fyi/ch07/slice_string.go

之前的代码展示了将字节数组显式转换为字符串值。请注意,每个字符都可以使用索引表达式访问。

映射类型

Go 语言中的映射是一个复合类型,用作存储无序元素的容器,这些元素由任意键值索引。以下代码片段展示了具有各种键类型的多种映射变量声明:

var ( 
    legends map[int]string 
    histogram map[string]int 
    calibration map[float64]bool 
    matrix map[[2][2]int]bool    // map with array key type 
    table map[string][]string    // map of string slices 

   // map (with struct key) of map of string 
   log map[struct{name string}]map[string]string 
) 

*map[<key_type>]<element_type>*

关键字指定了将要用于索引映射存储元素的值的类型。与数组和切片不同,映射的键可以是任何类型,而不仅仅是int。然而,映射的键必须是可比较的类型,包括数值、字符串、布尔值、指针、数组、结构体和接口类型(参见第四章数据类型,关于可比较类型的讨论)。

映射初始化

与切片类似,映射管理一个对用户不可见的底层数据结构来存储其值。未初始化的映射具有 nil 的零值。尝试向未初始化的映射中插入数据将导致程序崩溃。然而,与切片不同,从 nil 映射中访问元素是可能的,这将返回元素的零值。

与其他复合类型一样,映射可以使用以下形式的复合字面量值进行初始化:

<映射类型>{<逗号分隔的键值对列表>}

以下代码片段展示了使用映射复合字面量进行变量初始化:

var ( 
   histogram map[string]int = map[string]int{ 
         "Jan":100, "Feb":445, "Mar":514, "Apr":233, 
         "May":321, "Jun":644, "Jul":113, "Aug":734, 
         "Sep":553, "Oct":344, "Nov":831, "Dec":312,  
   } 

   table = map[string][]int { 
         "Men":[]int{32, 55, 12, 55, 42, 53}, 
         "Women":[]int{44, 42, 23, 41, 65, 44}, 
   } 
) 

golang.fyi/ch07/mapinit.go

使用冒号分隔的键值对指定映射的文本值,如先前的例子所示。每个键值对的类型必须与映射中声明的元素类型相匹配。

创建映射

与切片类似,映射值也可以使用make函数进行初始化。使用 make 函数初始化底层存储,允许数据以如下所示的方式插入映射中:

func main() { 
   hist := make(map[int]string) 
   hist["Jan"] = 100 
   hist["Feb"] = 445 
   hist["Mar"] = 514 
... 
} 

golang.fyi/ch07/maptypes.go

make函数接受映射的类型作为参数,并返回一个初始化后的映射。在先前的例子中,make函数将初始化一个类型为map[int]string的映射。make函数可以可选地接受第二个参数来指定映射的容量。然而,映射会根据需要继续增长,忽略指定的初始容量。

使用映射

"Jan" key being updated with the value 100:
hist := make(map[int]string) 
hist["Jan"] = 100 

使用索引表达式访问给定键的元素,将其放置在赋值表达式的右侧,如下例所示,其中使用"Mar"键索引的值被分配给val变量:

val := hist["Mar"] 

之前提到,访问不存在的键将返回该元素的零值。例如,如果键为 "Mar" 的元素在映射中不存在,则前面的代码将返回 0。正如你所想象的那样,这可能会成为一个问题。你如何知道你得到的是实际值还是零值?幸运的是,Go 提供了一种方法,可以通过返回索引表达式的结果中的可选布尔值来显式测试元素是否存在,如下面的代码片段所示:

func save(store map[string]int, key string, value int) { 
   val, ok := store[key] 
   if !ok { 
         store[key] = value 
   }else{ 
         panic(fmt.Sprintf("Slot %d taken", val)) 
   } 
} 

comma-ok idiom, the Boolean value stored in the ok variable is set to false when the value is not actually found. This allows the code to distinguish between the absence of a key and the zero value of the element.

映射遍历

hist:
for key, val := range hist { 
   adjVal := int(float64(val) * 0.100) 
   fmt.Printf("%s (%d):", key, val) 
   for i := 0; i < adjVal; i++ { 
         fmt.Print(".") 
   } 
   fmt.Println() 
} 

golang.fyi/ch07/map_use.go

每次迭代返回一个键及其关联的元素值。然而,迭代顺序是不确定的。内部映射迭代器可能会在每次程序运行时以不同的顺序遍历映射。为了保持可预测的遍历顺序,请保留(或生成)一个单独的结构中的键的副本,例如切片。在遍历过程中,遍历键的切片以可预测的方式遍历。

注意

你应该知道,在迭代过程中对发出的值进行的更新将会丢失。相反,使用索引表达式,例如 hist[key],在迭代过程中更新元素。有关 for…range 循环的详细信息,请参阅 第三章,Go 控制流,以获得对 Go for 循环的全面解释。

映射函数

除了之前讨论的 make 函数之外,映射类型还支持以下表格中讨论的两个附加函数:

函数 描述

| len(map) | 与其他复合类型一样,内置的 len() 函数返回映射中的条目数。例如,以下将打印 3:

h := map[int]bool{3:true, 7:false, 9:false}   
fmt.Println(len(h))   

len 函数对于未初始化的映射将返回零。|

| delete(map, key) | 内置的 delete 函数用于从给定的映射中删除与提供的键关联的元素。以下代码片段将打印 2:

h := map[int]bool{3:true, 7:false, 9:false}   
delete(h,7)   
fmt.Println(len(h))   

|

映射作为参数

由于映射维护对其后端存储结构的内部指针,因此调用函数中对映射参数的所有更新将在函数返回后由调用者看到。以下示例显示了调用 remove 函数以更改映射内容。传递的变量 hist 将在 remove 函数返回后反映更改:

func main() { 
   hist := make(map[string]int) 
   hist["Jun"] = 644 
   hist["Jul"] = 113 
   remove(hit, "Jun") 
   len(hist) // returns 1 
} 
func remove(store map[string]int, key string) error { 
   _, ok := store[key] 
   if !ok { 
         return fmt.Errorf("Key not found") 
   } 
   delete(store, key) 
   return nil 
} 

golang.fyi/ch07/map_use.go

结构体类型

本章最后讨论的类型是 Go 的 struct。它是一种复合类型,用作其他称为字段的命名类型的容器。以下代码片段显示了几个作为结构体声明的变量:

var( 
   empty struct{} 
   car struct{make, model string} 
   currency struct{name, country string; code int} 
   node struct{ 
         edges []string 
         weight int 
   } 
   person struct{ 
         name string 
         address struct{ 
               street string 
               city, state string 
               postal string 
         } 
   } 
) 

struct also support anonymous fields, covered later).
struct { name string; address struct { street string; city string; state string; postal string }}. Therefore, any variable or expression requiring that type must repeat that long declaration. We will see later how that is mitigated by using named types for struct.

访问结构体字段

fmt.Pritnln(person.name)

选择器可以链式使用,以访问嵌套在结构体内部的字段。以下代码片段将打印嵌套在 person 变量的地址值中的街道和城市:

fmt.Pritnln(person.address.street)
fmt.Pritnln(person.address.city)

结构体初始化

与数组类似,结构体是纯值,没有额外的底层存储结构。未初始化的结构体的字段被分配其各自的零值。这意味着未初始化的结构体不需要进一步分配,即可使用。

尽管如此,可以使用以下形式的复合字面量显式初始化结构体变量:

<struct_type>{}

结构体的复合字面量值可以通过指定一组字段值来初始化,这些值由它们的相应位置指定。使用这种方法,必须提供所有字段值,以匹配它们的声明类型,如下面的片段所示:

var( 
   currency = struct{ 
         name, country string 
         code int 
   }{ 
         "USD", "United States",  
         840, 
   } 
... 
) 

golang.fyi/ch07/structinit.go

在前面的结构体字面量中,提供了 struct 的所有字段值,与它们的声明字段类型相匹配。或者,可以使用字段索引及其关联值来指定 struct 的复合字面量值。与之前一样,索引(字段名称)及其值由冒号分隔,如下面的片段所示:

var( 
   car = struct{make, model string}{make:"Ford", model:"F150"} 
   node = struct{ 
         edges []string 
         weight int 
   }{ 
         edges: []string{"north", "south", "west"}, 
   } 
... 
) 

golang.fyi/ch07/structinit.go

如您所见,当提供索引及其值时,可以选择性指定复合字面量的字段值。例如,在 node 变量的初始化中,edge 字段被初始化,而 weight 被省略。

声明命名结构体类型

尝试重复使用结构体类型可能会很快变得难以管理。例如,每次需要表达结构体类型时,都必须编写 struct { name string; address struct { street string; city string; state string; postal string }},这不会扩展,容易出错,并且会让 Go 开发者感到沮丧。幸运的是,修复这个问题的正确习惯是使用命名类型,如下面的源代码片段所示:

type person struct { 
   name    string 
   address address 
} 

type address struct { 
   street      string 
   city, state string 
   postal      string 
} 

func makePerson() person { 
   addr := address{ 
         city: "Goville", 
         state: "Go", 
         postal: "12345", 
   } 
   return person{ 
         name: "vladimir vivien", 
         address: addr, 
   } 
} 

golang.fyi/ch07/structtype_dec.go

之前的示例将结构体类型定义绑定到标识符 person 和 address。这允许在不同的上下文中重复使用结构体类型,而无需携带类型定义的长形式。您可以参考第四章,数据类型,以了解更多关于命名类型的信息。

匿名字段

diameter and the name, are embedded as anonymous fields in the planet type:
type diameter int 

type name struct { 
   long   string 
   short  string 
   symbol rune 
} 

type planet struct { 
   diameter 
   name 
   desc string 
} 

func main() { 
   earth := planet{ 
         diameter: 7926, 
         name: name{ 
               long:   "Earth", 
               short:  "E", 
               symbol: '\u2641', 
         }, 
         desc: "Third rock from the Sun", 
   } 
   ... 
} 

planet struct. Notice the names of the embedded types become the field identifiers in the composite literal value for the struct.

为了简化字段名称解析,Go 在使用匿名字段时遵循以下规则:

  • 类型名称成为字段名称

  • 匿名字段的名称不能与其他字段名称冲突

  • 仅使用未限定的(省略包)类型名称导入类型

当直接使用选择器表达式访问内嵌结构体的字段时,这些规则同样适用,如下面的代码片段所示。注意,内嵌类型的名称被解析为字段名称:

func main(){ 
   jupiter := planet{} 
   jupiter.diameter = 88846 
   jupiter.name.long = "Jupiter" 
   jupiter.name.short = "J" 
   jupiter.name.symbol = '\u2643' 
   jupiter.desc = "A ball of gas" 
   ... 
} 

golang.fyi/ch07/struct_embed.go

提升字段

内嵌结构体的字段可以被提升到其封装类型。提升字段在选择器表达式中出现时,不需要它们的类型限定名称,如下面的示例所示:

func main() {
...
saturn := planet{}
saturn.diameter = 120536
saturn.long = "Saturn"
saturn.short = "S"
saturn.symbol = '\u2644'
saturn.desc = "Slow mover"
...
}
name by omitting it from the selector expression. The values of the fields long, short, and symbol come from embedded type name. Again, this will only work if the promotion does not cause any identifier clashes. In case of ambiguity, the fully qualified selector expression can be used.

结构体作为参数

回想一下,结构体变量存储实际值。这意味着每当将struct变量重新分配或作为函数参数传递时,就会创建结构体值的新副本。例如,以下在调用updateName()后不会更新 name 的值:

type person struct { 
   name    string 
   title string       
} 
func updateName(p person, name string) { 
   p.name = name 
}  

func main() { 
   p := person{} 
   p.name = "uknown" 
   ... 
   updateName(p, "Vladimir Vivien") 
} 

golang.fyi/ch07/struct_ptr.go

这可以通过传递指向person类型struct值的指针来修复,如下面的代码片段所示:

type person struct { 
   name    string 
   title string 
} 

func updateName(p *person, name string) { 
   p.name = name 
} 

func main() { 
   p := new(person) 
   p.name = "uknown" 
   ... 
   updateName(p, "Vladimir Vivien") 
} 

golang.fyi/ch07/struct_ptr2.go

在这个版本中,p变量被声明为*person,并使用内置的new()函数进行初始化。在updateName()返回后,调用函数可以看到其更改。

字段标签

结构体的最后一个主题与字段标签有关。在定义struct类型时,可以为每个字段声明添加可选的string值。字符串的值是任意的,它可以作为提示,供使用反射来消费标签的工具或其他 API 使用。

以下展示了带有 JSON 注释的 Person 和 Address 结构体的定义,这些注释可以被 Go 的 JSON 编码器和解码器(在标准库中找到)解释:

type Person struct { 
   Name    string `json:"person_name"` 
   Title   string `json:"person_title"` 
   Address `json:"person_address_obj"` 
} 

type Address struct { 
   Street string `json:"person_addr_street"` 
   City   string `json:"person_city"` 
   State  string `json:"person_state"` 
   Postal string `json:"person_postal_code"` 
} 
func main() { 
   p := Person{ 
         Name: "Vladimir Vivien", 
         Title : "Author", 
         ... 
   } 
   ... 
   b, _ := json.Marshal(p) 
   fmt.Println(string(b)) 
} 

golang.fyi/ch07/struct_ptr2.go

注意,标签被表示为原始字符串值(用一对go `` 括起来)。在正常代码执行中,标签会被忽略。然而,它们可以通过 Go 的反射 API 被收集,正如 JSON 库所做的那样。你将在第十章,Go 中的数据输入输出中遇到更多关于这个主题的内容,当本书讨论输入和输出流时。

摘要

本章在遍历 Go 中找到的每个复合类型时覆盖了大量的内容,为它们的特性提供了深入的覆盖。章节从对数组类型的覆盖开始,读者学习了如何声明、初始化和使用数组值。接下来,读者学习了关于切片类型的所有内容,特别是声明、初始化以及使用切片索引表达式创建新切片或重新切片现有切片的实用示例。本章还涵盖了 map 类型,包括有关 map 初始化、访问、更新和遍历的信息。最后,本章提供了有关结构体类型定义、初始化和使用的详细信息。

不言而喻,这可能是本书中最长的章节之一。然而,这里涵盖的信息将在本书继续探索新主题时证明是无价的。下一章将介绍使用 Go 通过方法和接口支持类似对象的习惯用法。

第八章。方法、接口和对象

使用你目前掌握的技能,你可以使用到目前为止所涵盖的基本概念编写一个有效的 Go 程序。正如你将在本章中看到的,Go 的类型系统可以支持超越简单函数的惯用法。虽然 Go 的设计者并没有打算创建一个具有深层类层次结构的面向对象语言,但该语言完全能够支持具有高级功能的类型组合,以表达复杂对象结构的创建,以下将涵盖这些主题:

  • Go 方法

  • Go 中的对象

  • 接口类型

  • 类型断言

Go 方法

一个 Go 函数可以被定义为具有缩小到特定类型的范围。当一个函数的范围缩小到类型时,或者附加到类型上,它被称为方法。方法定义的方式就像任何其他 Go 函数一样。然而,其定义包括一个方法接收器,这是一个放在方法名称之前额外的参数,用于指定方法附加的主类型。

为了更好地说明这个概念,以下图突出了定义方法时涉及的不同部分。它显示了通过g gallon接收器参数附加到type gallon基于接收器的quart方法:

Go 方法

如前所述,方法具有类型的范围。因此,它只能通过使用点符号的声明值(具体或指针)访问附加的类型。以下程序展示了如何使用这种表示法访问声明的方法quart

package main 
import "fmt" 

type gallon float64 

func (g gallon) quart() float64 { 
   return float64(g * 4) 
} 
func main(){ 
    gal := gallon(5) 
    fmt.Println(gal.quart()) 
} 

golang.fyi/ch08/method_basic.go

在前面的例子中,gal变量被初始化为gallon类型。因此,可以通过gal.quart()使用quart方法。

在运行时,接收器参数提供了对方法基本类型分配的值的访问。在示例中,quart方法接收g参数,它传递了声明的类型的值副本。因此,当gal变量初始化为5时,对gal.quart()的调用将接收器参数g设置为5。因此,以下代码将打印出20的值:

func main(){ 
    gal := gallon(5) 
    fmt.Println(gal.quart()) 
} 

重要的是要注意,方法接收器的基本类型不能是指针(也不能是接口)。例如,以下代码将无法编译:

type gallon *float64    
func (g gallon) quart() float64 {
  return float64(g * 4)
}

下面的示例展示了实现一个更通用液体体积转换程序的源代码的较长版本。每个体积类型都接收其相应的方法来暴露该类型所具有的行为:

package main 
import "fmt" 

type ounce float64 
func (o ounce) cup() cup { 
   return cup(o * 0.1250) 
} 

type cup float64 
func (c cup) quart() quart { 
   return quart(c * 0.25) 
} 
func (c cup) ounce() ounce { 
   return ounce(c * 8.0) 
} 

type quart float64 
func (q quart) gallon() gallon { 
   return gallon(q * 0.25) 
} 
func (q quart) cup() cup { 
   return cup(q * 4.0) 
} 

type gallon float64 
func (g gallon) quart() quart { 
   return quart(g * 4) 
} 

func main() { 
    gal := gallon(5) 
    fmt.Printf("%.2f gallons = %.2f quarts\n", gal, gal.quart()) 
    ozs := gal.quart().cup().ounce() 
    fmt.Printf("%.2f gallons = %.2f ounces\n", gal, ozs) 
} 

github.com/vladimirvivien/learning-go/ch08/methods.go

例如,将5加仑转换为盎司可以通过在给定值上调用适当的转换方法来完成,如下所示:

gal := gallon(5) 
ozs := gal.quart().cup().ounce() 

整个实现使用了一种简单但有效的典型结构来表示数据类型和行为。阅读代码时,它清晰地表达了其预期的含义,而不依赖于任何重类的结构。

注意

方法集

通过接收器参数附加到类型的方法数量被称为该类型的方法集。这包括具体的和指针值接收器。方法集的概念在确定类型相等性、接口实现以及支持空接口的空方法集概念中非常重要(本章中均有讨论)。

值和指针接收器

到目前为止,尚未讨论的方法的一个方面是接收器是正常的函数参数。因此,它们遵循 Go 函数的按值传递机制。这意味着被调用的方法会从声明的类型中获取原始值的副本。

接收器参数可以是基类型的值或指针。例如,以下程序显示了两个方法,halfdouble;它们都直接更新各自的方法接收器参数g的值:

package main
import "fmt" 
type gallon float64 
func (g gallon) quart() float64 { 
  return float64(g * 4) 
} 
func (g gallon) half() { 
  g = gallon(g * 0.5) 
} 
func (g *gallon) double() { 
  *g = gallon(*g * 2) 
} 
func main() { 
  var gal gallon = 5 
  gal.half() 
  fmt.Println(gal) 
  gal.double() 
  fmt.Println(gal) 
} 

golang.fyi/ch08/receiver_ptr.go

half方法中,代码通过g = gallon(g * 0.5)更新接收器参数。正如你所预期的那样,这不会更新原始声明的值,而是更新存储在g参数中的副本。因此,当在main中调用gal.half()时,原始值保持不变,以下将打印5

func main() { 
   var gal gallon = 5 
   gal.half() 
   fmt.Println(gal) 
} 

*gallon type, which is updated using *g = gallon(*g * 2). So when the following is invoked in main, it would print a value of 10:
func main() { 
   var gal gallon = 5 
   gal.double() 
   fmt.Println(gal) 
} 

指针接收器参数在 Go 中广泛使用。这是因为它们使得表达既携带状态又具有行为的对象原语成为可能。正如下一节所示,指针接收器,连同其他类型特性,是创建 Go 中对象的基础。

Go 中的对象

上一节中冗长的介绍性材料是为了引出对 Go 中对象的讨论。已经提到,Go 并非设计成作为传统的面向对象语言。Go 中没有定义对象或类关键字。那么,我们为什么还要讨论 Go 中的对象呢?好吧,结果是 Go 完美地支持了面向对象的习惯用法和面向对象编程的实践,而没有其他面向对象语言中发现的经典层次结构和复杂继承结构的沉重负担。

让我们在下表中回顾一下通常归因于面向对象语言的一些原始特性。

对象特性 Go 注释
对象:存储状态并公开行为的类型 在 Go 中,所有类型都可以实现这一点。没有称为类或对象的特殊类型来完成这项工作。任何类型都可以接收一组方法来定义其行为,尽管struct类型在其它语言中通常称为对象最为接近。
组合 使用structinterface(稍后讨论)等类型,可以创建对象并通过组合表达它们的多态关系。
通过接口的子类型 定义了一组行为(方法)的类型,其他类型可以实施这些行为。稍后你将看到它是如何用于实现对象子类化的。
模块化和封装 Go 在核心上支持物理和逻辑模块化,具有诸如包和可扩展的类型系统以及代码元素可见性等概念。
类型继承 Go 不支持通过继承实现多态。新声明的命名类型并不继承其底层类型的所有属性,并且被类型系统以不同的方式处理。因此,很难像在其他语言中找到的那样通过类型谱系实现继承。
Go 中没有作为对象基础的类类型的概念。Go 中的任何数据类型都可以用作对象。

如前表所示,Go 支持通常归因于面向对象编程的大多数概念。本章的剩余部分将涵盖主题和示例,展示如何将 Go 用作面向对象编程语言。

结构体作为对象

几乎所有的 Go 类型都可以通过存储状态并公开能够访问和修改这些状态的方法来充当对象的角色。然而,struct类型提供了其他语言中传统上归因于对象的全部特性,例如:

  • 能够包含方法

  • 能够通过组合进行扩展

  • 能够被子类型化(借助 Go 的interface类型)

本章的剩余部分将基于使用struct类型来讨论对象。

对象组合

fuel, engine, vehicle, truck, and plane:
type fuel int 
const ( 
    GASOLINE fuel = iota 
    BIO 
    ELECTRIC 
    JET 
) 
type vehicle struct { 
    make string 
    model string 
} 

type engine struct { 
   fuel fuel 
   thrust int 
} 
func (e *engine) start() { 
   fmt.Println ("Engine started.") 
} 

type truck struct { 
   vehicle 
   engine 
   axels int 
   wheels int 
   class int 
} 
func (t *truck) drive() { 
   fmt.Printf("Truck %s %s, on the go!\n", t.make, t.model)           
} 

type plane struct { 
   vehicle 
   engine 
   engineCount int 
   fixedWings bool 
   maxAltitude int 
} 
func (p *plane) fly() { 
   fmt.Printf( 
          "Aircraft %s %s clear for takeoff!\n", 
          p.make, p.model, 
       ) 
} 

Go 使用组合优于继承原则,通过struct类型支持的类型嵌入机制来实现多态。在 Go 中,没有通过类型继承实现多态的支持。回想一下,每个类型都是独立的,并且被认为与其他所有类型都不同。实际上,上述模型中的语义略有错误。类型truckplane被显示为由vehicle类型组成(或具有has-a关系),这听起来并不正确。相反,更合适,或者至少更正确的表示方式是通过子类型关系来显示类型truckplanevehicle的子类型。在章节的后面部分,我们将看到如何使用interface类型来实现这一点。

字段和方法提升

t of type truck and p for plane. The former is initialized using a struct literal and the latter is updated using dot notation:
func main() { 
   t := &truck { 
         vehicle:vehicle{"Ford", "F750"}, 
         engine:engine{GASOLINE+BIO,700}, 
         axels:2, 
         wheels:6, 
         class:3,     
   } 
   t.start() 
   t.drive() 

   p := &plane{} 
   p.make = "HondaJet" 
   p.model = "HA-420" 
   p.fuel = JET 
   p.thrust = 2050 
   p.engineCount = 2 
   p.fixedWings = true 
   p.maxAltitude = 43000 
   p.start() 
   p.fly() 

} 

struct type embedding mechanism promotes fields and methods when accessed using dot notation. For instance, the following fields (make, mode, fuel, and thrust), are all declared in types that are embedded inside of the plane type:
p.make = "HondaJet" 
p.model = "HA-420" 
p.fuel = JET 
p.thrust = 2050 

前面的字段是从它们的嵌入类型提升而来的。当它们作为plane类型的成员访问时,实际上它们分别来自vehicleengine类型。为了避免歧义,字段的名称可以像下面这样进行限定:

p.vehicle.make = "HondaJet" 
p.vehicle.model = "HA-420" 
p.engine.fuel = JET 
p.engine.thrust = 2050 

方法也可以以类似的方式提升。例如,在之前的代码中,我们看到了t.start()p.start()方法的调用。然而,truckplane这两种类型都不是名为start()的方法的接收者。正如前面程序所示,start()方法是为engine类型定义的。由于engine类型嵌入在truckplane类型中,start()方法在作用域中被提升到这些封装类型,因此是可访问的。

构造函数

plane and truck types:
type truck struct { 
   vehicle 
   engine 
   axels int 
   wheels int 
   class int 
} 
func newTruck(mk, mdl string) *truck { 
   return &truck {vehicle:vehicle{mk, mdl}} 
} 

type plane struct { 
   vehicle 
   engine 
   engineCount int 
   fixedWings bool 
   maxAltitude int 
}   
func newPlane(mk, mdl string) *plane { 
   p := &plane{} 
   p.make = mk 
   p.model = mdl 
   return p 
} 

golang.fyi/ch08/structobj2.go

虽然不是必需的,但提供一个函数来帮助初始化复合值,如结构体,可以提高代码的可用性。它提供了一个封装可重复初始化逻辑的地方,可以强制执行验证要求。在先前的示例中,构造函数newTrucknewPlane都传入了制造和型号信息以创建和初始化它们各自的价值。

接口类型

当你与已经使用 Go 语言一段时间的人交谈时,他们几乎总是将接口列为他们最喜欢的语言特性之一。Go 语言中接口的概念,与其他语言如 Java 类似,是一组方法,作为描述行为的模板。然而,Go 语言的接口是由interface{}字面量指定的类型,用于列出满足接口的一组方法。以下示例展示了如何将shape变量声明为接口:

var shape interface { 
    area() float64 
    perim() float64 
} 

shape variable is declared and assigned an unnamed type, interface{area()float64; perim()float64}. Declaring variables with unnamed interface literal types is not really practical. Using idiomatic Go, an interface type is almost always declared as a named type. The previous snippet can be rewritten to use a named interface type, as shown in the following example:
type shape interface { 
   area() float64 
   perim() float64 
} 
var s shape 

实现接口

Go 语言中接口的有趣之处在于它们的实现和使用方式。实现 Go 语言接口是隐式进行的。不需要单独的元素或关键字来指示实现的意图。任何定义了interface类型方法集的类型都会自动满足其实现。

以下源代码展示了rect类型作为shape接口类型的实现。rect类型被定义为具有接收器方法areaperim的结构体。这一事实自动使rect成为shape的实现:

type shape interface { 
   area() float64 
   perim() float64 
} 

type rect struct { 
   name string 
   length, height float64 
} 

func (r *rect) area() float64 { 
   return r.length * r.height 
} 

func (r *rect) perim() float64 { 
   return 2*r.length + 2*r.height 
} 

golang.fyi/ch08/interface_impl.go

使用 Go 接口进行子类型化

rect (defined previously) and triangle, are able to be passed to the shapeInfo(shape) function to return a string value containing shape calculations:
type triangle struct { 
   name string 
   a, b, c float64 
} 

func (t *triangle) area() float64 { 
   return 0.5*(t.a * t.b) 
} 

func (t *triangle) perim() float64 { 
   return t.a + t.b + math.Sqrt((t.a*t.a) + (t.b*t.b)) 
} 

func (t *triangle) String() string { 
   return fmt.Sprintf( 
         "%s[sides: a=%.2f b=%.2f c=%.2f]", 
         t.name, t.a, t.b, t.c, 
   ) 
} 
func shapeInfo(s shape) string { 
   return fmt.Sprintf( 
         "Area = %.2f, Perim = %.2f", 
         s.area(), s.perim(), 
   ) 
} 

func main() { 
   r := &      rect{"Square", 4.0, 4.0} 
   fmt.Println(r, "=>", shapeInfo(r)) 

   t := &      triangle{"Right Triangle", 1,2,3} 
   fmt.Println(t, "=>", shapeInfo(t)) 
} 

golang.fyi/ch08/interface_impl.go

实现多个接口

接口的隐式机制允许任何命名类型同时满足多个接口类型。这仅仅是通过确保给定类型的方法集与要实现的每个interface类型的方法相交来实现的。让我们重新实现之前的代码,以展示这是如何完成的。引入了两个新的接口polygoncurved,以更好地捕捉和分类形状的信息和行为,如下面的代码片段所示:

type shape interface { 
   area() float64 
} 

type polygon interface { 
   perim() 
} 

type curved interface { 
   circonf() 
} 
type rect struct {...} 
func (r *rect) area() float64 { 
   return r.length * r.height 
} 
func (r *rect) perim() float64 { 
   return 2*r.length + 2*r.height 
} 

type triangle struct {...} 
func (t *triangle) area() float64 { 
   return 0.5*(t.a * t.b) 
} 
func (t *triangle) perim() float64 { 
   return t.a + t.b + math.Sqrt((t.a*t.a) + (t.b*t.b)) 
} 

type circle struct { ... } 
func (c *circle) area() float64 { 
   return math.Pi * (c.rad*c.rad) 
} 
func (c *circle) circonf() float64 { 
   return 2 * math.Pi * c.rad 
} 

接口嵌入

interface类型的另一个有趣之处在于它支持类型嵌入(类似于struct类型)。这为您提供了以最大化类型重用的方式来结构化类型。继续使用形状示例,以下代码片段通过将形状嵌入到其他两个类型中,重新组织并减少了之前的接口数量,从三个减少到两个:

type shape interface { 
   area() float64 
} 

type polygon interface { 
   shape 
   perim() 
} 

type curved interface { 
   shape 
   circonf() 
} 

golang.fyi/ch08/interface_impl3.go

以下插图展示了接口类型如何组合,以便“是...的”关系仍然满足代码组件之间的关系:

接口嵌入

当嵌入接口类型时,封装类型将继承嵌入类型的函数集。如果嵌入类型导致方法签名冲突,编译器将报错。嵌入成为一个关键特性,尤其是在代码使用类型检查进行类型验证时。它允许类型汇总类型信息,从而减少不必要的断言步骤(类型断言将在后面讨论)。

空接口类型

interface{}类型,或空interface类型,是具有空方法集的interface类型的字面表示。根据我们之前的讨论,可以推断出所有类型都实现了空接口,因为所有类型都可以有一个包含零个或多个成员的方法集。

当一个变量被分配interface{}类型时,编译器会放宽其构建时的类型检查。然而,该变量仍然携带可以在运行时查询的类型信息。以下代码说明了这是如何工作的:

func main() { 
   var anyType interface{} 
   anyType = 77.0 
   anyType = "I am a string now" 
   fmt.Println(anyType) 

   printAnyType("The car is slow") 
   m := map[string] string{"ID":"12345", "name":"Kerry"} 
   printAnyType(m) 
   printAnyType(1253443455) 
} 

func printAnyType(val interface{}) { 
   fmt.Println(val) 
} 

golang.fyi/ch08/interface_empty.go

在前面的代码中,anyType变量被声明为interface{}类型。它能够分配不同类型的值而不会引起编译器的抱怨:

anyType = 77.0 
anyType = "I am a string now" 

printAnyType()函数接受一个interface{}类型的参数。这意味着函数可以传递任何有效类型的值,如下所示:

printAnyType("The car is slow") 
m := map[string] string{"ID":"12345", "name":"Kerry"} 
printAnyType(m) 
printAnyType(1253443455) 

空接口对于习惯性的 Go 语言至关重要。将类型检查延迟到运行时使得语言感觉更加动态,而没有完全牺牲强类型。Go 提供了诸如类型断言(将在下一节介绍)等机制,以在运行时查询接口携带的类型信息。

类型断言

当一个接口(空或非空)被赋值给一个变量时,它携带了可以在运行时查询的类型信息。类型断言是 Go 语言中一种机制,可以习惯性地将一个(interface类型)变量缩小到存储在变量中的具体类型和值。以下示例在eat函数中使用类型断言来选择在eat函数中要选择的food类型:

type food interface { 
   eat() 
} 

type veggie string 
func (v veggie) eat() { 
   fmt.Println("Eating", v) 
} 

type meat string 
func (m meat) eat() { 
   fmt.Println("Eating tasty", m) 
} 

func eat(f food) { 
   veg, ok := f.(veggie) 
   if ok { 
         if veg == "okra" { 
               fmt.Println("Yuk! not eating ", veg) 
         }else{ 
               veg.eat() 
         } 

         return 
   } 

   mt, ok := f.(meat) 
   if ok { 
         if mt == "beef" { 
               fmt.Println("Yuk! not eating ", mt) 
         }else{ 
               mt.eat() 
         } 
         return 
   } 

   fmt.Println("Not eating whatever that is: ", f) 
} 

f parameter to a specific type of food. If the type is asserted to be meat, then the code continues to test the value of the mt variable:
mt, ok := f.(meat) 
if ok { 
   if mt == "beef" { 
         fmt.Println("Yuk! not eating ", mt) 
   }else{ 
         mt.eat() 
   } 
   return 
} 

类型断言表达式也可以只返回值,如下所示:

value := <interface_variable>.(具体类型名称)

这种断言形式是有风险的,因为如果接口变量中存储的值不是断言的类型,运行时将导致程序崩溃。只有在你有其他安全措施来防止或优雅地处理崩溃的情况下,才使用这种形式。

最后,当你的代码需要在运行时测试多个类型时,一个更优雅的断言方法是使用类型switch语句。它使用switch语句的语义,通过 case 子句从接口值中查询静态类型信息。上一个与食物相关的示例中的eat函数可以更新为使用类型switch而不是if语句,如下所示:

func eat(f food) { 
   swtich morsel := f.(type){ 
   case veggie: 
         if morsel == "okra" { 
               fmt.Println("Yuk! not eating ", mosel) 
         }else{ 
               mosel.eat() 
         } 
   case meat: 
         if morsel == "beef" { 
               fmt.Println("Yuk! not eating ", mosel) 
         }else{ 
               mosel.eat() 
         }            
   default: 
         fmt.Println("Not eating whatever that is: ", f) 
   } 
} 

golang.fyi/interface_assert2.go

注意代码的可读性要好得多。它可以支持任意数量的案例,并且通过视觉线索清晰地布局,使得推理变得容易。switch类型还通过简单地指定一个默认情况来处理任何在情况子句中未明确处理的类型,从而消除了 panic 问题。

摘要

本章试图提供一个广泛且相对全面的视角,涵盖 Go 语言中的一些重要主题,包括方法、接口和对象。章节从使用接收器参数将方法附加到类型开始介绍。接下来,读者被引入对象以及如何在 Go 语言中创建惯用的基于对象的编程。最后,本章全面概述了接口类型及其在 Go 语言中支持对象语义的使用。下一章将带领读者了解一个使 Go 语言在开发者中如此受欢迎的最基本概念:并发!

第九章。并发

并发被认为是 Go 语言最吸引人的特性之一。语言的采用者享受其原语表达正确并发实现的简单性,而无需承担通常与这种努力相关的陷阱。本章涵盖了理解和创建并发 Go 程序所必需的主题,包括以下内容:

  • Goroutines

  • 通道

  • 编写并发程序

  • sync 包

  • 检测竞态条件

  • Go 中的并行性

Goroutines

如果你曾在其他语言(如 Java 或 C/C++)中工作过,你可能熟悉并发的概念。这是程序运行两个或更多独立执行路径的能力。这通常是通过直接向程序员暴露线程原语来创建和管理并发来实现的。

Go 语言有其自己的并发原语,称为 goroutine,它允许程序独立于调用函数启动一个函数(例程)执行。Goroutines 是轻量级的执行上下文,在少数几个由操作系统支持的线程之间进行多路复用,并由 Go 的运行时调度器进行调度。这使得它们在创建时无需真正的内核线程的开销。因此,Go 程序可以启动成千上万(甚至数十万)的 goroutine,而对性能和资源退化影响最小。

go 语句

Goroutines 是使用以下格式的 go 语句启动的:

go <函数或表达式>

使用 go 关键字后跟要调度的函数创建一个 goroutine。指定的函数可以是现有的函数、匿名函数或调用函数的表达式。以下代码片段展示了使用 goroutines 的一个示例:

func main() { 
   go count(10, 50, 10) 
   go count(60, 100, 10) 
   go count(110, 200, 20) 
} 
func count(start, stop, delta int) { 
   for i := start; i <= stop; i += delta { 
         fmt.Println(i) 
   } 
} 

golang.fyi/ch09/goroutine0.go

在前面的代码示例中,当在 main 函数中遇到 go count() 语句时,它将在一个独立的执行上下文中启动 count 函数。maincount 函数将并发执行。作为副作用,main 将在 count 函数有机会向控制台打印任何内容之前完成。

在本章的后面部分,我们将看到如何习惯性地在 goroutine 之间处理同步。现在,让我们使用 fmt.Scanln() 来阻塞并等待键盘输入,如下面的示例所示。在这个版本中,在等待键盘输入的同时,并发函数有机会完成:

func main() { 
   go count(10, 30, 10) 
   go count(40, 60, 10) 
   go count(70, 120, 20) 
   fmt.Scanln() // blocks for kb input 
} 

golang.fyi/ch09/goroutine1.go

Goroutines 也可以直接在 go 语句中定义为函数字面量,如下面代码片段中更新的示例所示:

func main() { 
   go count(10, 30, 10) 
   go func() { 
         count(40, 60, 10) 
   }() 
   ... 
}  

golang.fyi/ch09/goroutine2.go

函数字面量提供了一种方便的惯用法,允许程序员在go语句的位置直接组装逻辑。当使用函数字面量与go语句一起使用时,它被视为一个常规闭包,具有对非局部变量的词法访问,如下面的示例所示:

func main() { 
   start := 0 
   stop := 50 
   step := 5 
   go func() { 
         count(start, stop, step) 
   }() 
} 

j from the loop:
func main() { 
   starts := []int{10,40,70,100} 
   for _, j := range starts{ 
         go func() { 
               count(j, j+20, 10) 
         }() 
   } 
} 

golang.fyi/ch09/goroutine4.go

由于j在每次迭代中都会更新,因此无法确定闭包将读取什么值。在大多数情况下,goroutine 闭包将在它们执行时看到j的最后一个更新值。这可以通过在函数字面量中将变量作为参数传递给 goroutine 来轻松修复,如下所示:

func main() { 
   starts := []int{10,40,70,100} 
   for _, j := range starts{ 
         go func(s int) { 
               count(s, s+20, 10) 
         }(j) 
   } 
} 

golang.fyi/ch09/goroutine5.go

每次循环迭代都会调用 goroutine 闭包,通过函数参数接收j变量的一个副本。这创建了一个带有正确值的j值的局部副本,以便在 goroutine 被调度运行时使用。

Goroutine 调度

通常,所有 goroutine 都是相互独立运行的,如下面的插图所示。创建 goroutine 的函数不会等待其返回,除非存在阻塞条件,否则它将继续执行自己的执行流。稍后,本章将介绍同步惯用法来协调 goroutine:

Goroutine 调度

Go 的运行时调度器使用一种协作调度形式来调度 goroutine。默认情况下,调度器将允许正在运行的 goroutine 执行完成。然而,如果发生以下事件之一,调度器将自动将执行权让给另一个 goroutine:

  • 在执行的 goroutine 中遇到go语句

  • 遇到通道操作(通道将在后面介绍)

  • 遇到阻塞的系统调用(例如文件或网络 I/O)

  • 在垃圾回收周期完成后

当在运行的 goroutine 中遇到上述事件之一时,调度器将调度一个准备就绪的 goroutine 以进入执行。重要的是要指出,调度器不对 goroutine 的执行顺序做出保证。例如,当执行以下代码片段时,每次运行的输出顺序将是任意的:

func main() { 
   go count(10, 30, 10) 
   go count(40, 60, 10) 
   go count(70, 120, 20) 
   fmt.Scanln() // blocks for kb input 
} 
func count(start, stop, delta int) { 
   for i := start; i <= stop; i += delta { 
         fmt.Println(i) 
   } 
} 

golang.fyi/ch09/goroutine1.go

下面的示例显示了上一个程序的可能输出:

10
70
90
110
40
50
60
20
30

通道

当谈论并发时,一个自然出现的担忧是数据安全和同步。如果你在 Java 或 C/C++等语言中做过并发编程,你可能会熟悉确保运行线程可以安全访问共享内存值以在线程之间进行通信和同步所需的,有时是脆弱的编排。

这是在 Go 与其 C 系谱有所不同的一个领域。Go 不是通过使用共享内存位置来让并发代码进行通信,而是使用通道作为运行 goroutine 之间通信和数据共享的通道。博客文章Effective Gogolang.org/doc/effective_go.html)将这个概念简化为以下口号:

不要通过共享内存来通信;相反,通过通信来共享内存。

注意

通道的概念源于通信顺序过程CSP),这是著名计算机科学家 C. A. Hoare 的工作,他用通信原语来模拟并发。正如本节将要讨论的,通道提供了同步和在不同运行 goroutine 之间安全通信数据的方法。

本节讨论 Go 通道类型,并提供了对其特性的见解。稍后,你将学习如何使用通道来构建并发程序。

通道类型

chan int, assigned to the variable ch, to communicate integer values:
func main() { 
   var ch chan int 
   ... 
} 

在本章的后面部分,我们将学习如何使用通道在运行程序的不同并发部分之间发送数据。

发送和接收操作

Go 使用<-(箭头)运算符来指示通道内的数据移动。以下表格总结了如何从通道发送或接收数据:

示例 操作 描述
intCh <- 12 发送 当箭头放置在值、变量或表达式的左侧时,它表示向它指向的通道发送操作。在这个例子中,12被发送到intCh通道。
value := <- intCh 接收 <-运算符放置在通道的左侧时,它表示从通道接收操作。value变量被分配从intCh通道接收到的值。

一个未初始化的通道具有nil的零值,必须使用内置的make函数进行初始化。正如以下章节将要讨论的,通道可以初始化为无缓冲或有缓冲,这取决于其指定的容量。每种类型的通道都有不同的特性,这些特性在不同的并发结构中被利用。

无缓冲通道

chan int:
func main() { 
   ch := make(chan int) // unbuffered channel 
   ... 
} 

无缓冲通道的特性在以下图中展示:

无缓冲通道

上述图中的序列(从左到右)显示了无缓冲通道的工作方式:

  • 如果通道为空,接收者会阻塞,直到有数据。

  • 发送者只能向空通道发送,并且会阻塞直到下一个接收操作

  • 当通道有数据时,接收者可以继续接收数据。

如果操作未在 goroutine 中包装,向无缓冲通道发送可能会轻易导致死锁。以下代码在向通道发送12后将会阻塞:

func main() { 
   ch := make(chan int) 
   ch <- 12 // blocks   
   fmt.Println(<-ch) 
} 

golang.fyi/ch09/chan-unbuff0.go

当你运行前面的程序时,你会得到以下结果:

$> go run chan-unbuff0.go
fatal error: all goroutines are asleep - deadlock!

回想一下,发送者一旦向无缓冲通道发送数据就会立即阻塞。这意味着任何后续的语句,例如从通道接收,将变得不可达,导致死锁。以下代码展示了向无缓冲通道发送的正确方式:

func main() { 
   ch := make(chan int) 
   go func() { ch <- 12 }() 
   fmt.Println(<-ch) 
} 

golang.fyi/ch09/chan-unbuff1.go

注意,发送操作被封装在一个匿名函数中,并作为单独的 goroutine 调用。这允许 main 函数在不阻塞的情况下到达接收操作。正如你将看到的,无缓冲通道的这种阻塞特性被广泛用作 goroutine 之间的同步和协调惯用语。

缓冲通道

make 函数使用容量参数时,它返回一个双向 缓冲 通道,如下面的代码片段所示:

func main 
   ch := make(chan int, 3) // buffered channel  
} 

之前的代码将创建一个容量为 3 的缓冲通道。缓冲通道作为一个先进先出(FIFO)的阻塞队列,如下面的图所示:

缓冲通道

前面图中描述的缓冲通道具有以下特性:

  • 当通道为空时,接收者会阻塞,直到至少有一个元素。

  • 只要通道未满,发送者总是成功的。

  • 当通道满时,发送者会阻塞,直到至少有一个元素被接收。

使用缓冲通道,可以在同一个 goroutine 中发送和接收值,而不会导致死锁。以下是一个使用容量为 4 个元素的缓冲通道发送和接收的示例:

func main() { 
   ch := make(chan int, 4) 
   ch <- 2 
   ch <- 4 
   ch <- 6 
   ch <- 8 

   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
   fmt.Println(<-ch) 

} 

golang.fyi/ch09/chan0.go

之前示例中的代码能够将值 2468 发送到 ch 通道,而不会阻塞。四个 fmt.Println(<-ch) 语句用于依次接收通道中缓冲的值。然而,如果在第一次接收之前添加第五次发送操作,代码将如以下片段所示死锁:

func main() { 
   ch := make(chan int, 4) 
   ch <- 2 
   ch <- 4 
   ch <- 6 
   ch <- 8 
   ch <- 10  
   fmt.Println(<-ch) 
   ... 
} 

在本章的后面部分,你将了解到使用通道进行通信的惯用和安全的用法。

单向通道

在声明时,通道类型也可以包括一个单向操作符(再次使用 <- 箭头),以指示通道是单向发送还是单向接收,如下表所示:

声明 操作

| <- chan <元素类型> | 声明一个如后所述的单向接收通道。

var Ch <-chan int

|

| chan <-<元素类型> | 声明一个如后所述的单向发送通道。

var Ch <-chan int

|

makeEvenNums with a send-only channel argument of type chan <- int:
func main() { 
   ch := make(chan int, 10) 
   makeEvenNums(4, ch) 

   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
} 

func makeEvenNums(count int, in chan<- int) { 
   for i := 0; i < count; i++ { 
         in <- 2 * i 
   } 
} 

golang.fyi/ch09/chan1.go

由于通道的方向性在类型中是固定的,访问违规将在编译时被检测到。因此,在之前的示例中,in 通道只能用于接收操作。

可以显式或自动地将双向通道转换为单向通道。例如,当从main()调用makeEvenNums()时,它接收双向通道ch作为参数。编译器自动将通道转换为适当类型。

通道长度和容量

2:
func main() { 
   ch := make(chan int, 4) 
   ch <- 2 
   ch <- 2 
   fmt.Println(len(ch)) 
} 

cap函数返回通道类型的声明容量,与长度不同,在整个通道的生命周期中保持不变。

注意

无缓冲通道的长度和容量为零。

关闭通道

一旦初始化通道,它就准备好进行发送和接收操作。通道将保持打开状态,直到使用内置的close函数强制关闭,如下例所示:

func main() { 
   ch := make(chan int, 4) 
   ch <- 2 
   ch <- 4 
   close(ch) 
   // ch <- 6 // panic, send on closed channel 

   fmt.Println(<-ch) 
   fmt.Println(<-ch) 
   fmt.Println(<-ch) // closed, returns zero value for element 

} 

ch channel is closed after two send operations. As indicated in the comment, a third send operation would cause a panic because the channel is closed. On the receiving side, the code gets the two elements in the channel before it is closed. A third receive operation returns 0, the zero value for the channel's elements.

Go 提供了接收操作的扩展形式,它返回从通道读取的值,后跟一个表示通道关闭状态的布尔值。这可以用来正确处理从关闭通道的零值,如下例所示:

func main() { 
   ch := make(chan int, 4) 
   ch <- 2 
   ch <- 4 
   close(ch) 

   for i := 0; i < 4; i++ { 
         if val, opened := <-ch; opened { 
               fmt.Println(val) 
         } else { 
               fmt.Println("Channel closed!") 
         } 
   } 
} 

golang.fyi/ch09/chan3.go

编写并发程序

到目前为止,关于 goroutines 和通道的讨论有意识地保持分离,以确保每个主题都得到适当的覆盖。然而,通道和 goroutines 的真正力量在于它们结合在一起创建并发程序时,正如本节所述。

同步

通道的主要用途之一是在运行中的 goroutines 之间进行同步。为了说明这种用法,让我们检查以下代码,该代码实现了一个单词直方图。程序从data切片中读取单词,然后在单独的 goroutine 中收集每个单词的出现次数:

func main() { 
   data := []string{ 
         "The yellow fish swims slowly in the water", 
         "The brown dog barks loudly after a drink ...", 
         "The dark bird bird of prey lands on a small ...", 
   } 

   histogram := make(map[string]int) 
   done := make(chan bool) 

   // splits and count words 
   go func() { 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     histogram[word]++ 
               } 
         } 
         done <- true 
   }() 

   if <-done { 
         for k, v := range histogram { 
               fmt.Printf("%s\t(%d)\n", k, v) 
         } 
   } 
} 

golang.fyi/ch09/pattern0.go

之前示例中的代码使用done := make(chan bool)创建用于同步程序中两个运行 goroutines 的通道。main函数启动一个次要 goroutine,执行单词计数,然后它继续执行,直到在<-done表达式处阻塞,导致它等待。

同时,次要 goroutine 运行直到它完成其循环。然后,它向done通道发送一个值,使用done <- true,导致阻塞的main程序变为非阻塞并继续执行。

注意

之前的代码有一个可能导致竞态条件的错误。将在本章后面介绍修正。

在之前的示例中,代码分配并发送了一个用于同步的布尔值。经过进一步检查,很明显通道中的值无关紧要,我们只想发出信号。因此,我们可以进一步提炼同步习语,以以下代码片段中的口语形式呈现:

func main() { 
... 
   histogram := make(map[string]int) 
   done := make(chan struct{}) 

   // splits and count 
   go func() { 
         defer close(done) // closes channel upon fn return 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     histogram[word]++ 
               } 
         } 
   }() 

   <-done // blocks until closed 

   for k, v := range histogram { 
         fmt.Printf("%s\t(%d)\n", k, v) 
   } 
} 

golang.fyi/ch09/pattern1.go

此版本的代码使用以下方式实现 goroutine 同步:

  • 已完成的通道,声明为类型 chan struct{}

  • 主 goroutine 在接收表达式<-done处阻塞

  • done通道关闭时,所有接收者都成功完成而不会阻塞

虽然使用不同的结构来完成信号,但这个版本的代码与第一个版本(pattern0.go)等效。空的struct{}类型不存储任何值,它仅用于信号。这个版本的代码关闭了done通道(而不是发送一个值)。这允许主 goroutine 解除阻塞并继续执行。

流式数据

通道的一个自然用途是从一个 goroutine 流式传输数据到另一个 goroutine。这种模式在 Go 代码中相当常见,为了使其工作,必须执行以下操作:

  • 在通道上持续发送数据

  • 从该通道持续接收传入的数据

  • 通知接收者流的结束,以便它停止

正如你所见,所有这些都可以使用单个通道完成。下面的代码片段是之前示例的重写。它展示了如何使用单个通道从一个 goroutine 流式传输数据到另一个 goroutine。相同的通道也被用作信号设备,以指示流的结束:

func main(){ 
... 
   histogram := make(map[string]int) 
   wordsCh := make(chan string) 

   // splits lines and sends words to channel 
   go func() { 
         defer close(wordsCh) // close channel when done 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     wordsCh <- word 
               } 
         } 
   }() 

   // process word stream and count words 
   // loop until wordsCh is closed 
   for { 
         word, opened := <-wordsCh 
         if !opened { 
               break 
         } 
         histogram[word]++ 
   } 

   for k, v := range histogram { 
         fmt.Printf("%s\t(%d)\n", k, v) 
   } 
} 

golang.fyi/ch09/pattern2.go

这个版本的代码生成的单词直方图与之前相同,但引入了不同的方法。这是通过以下表格中显示的代码的高亮部分实现的:

代码 描述

|

wordsCh := make(chan string)   

用于流式传输数据的通道。

|

wordsCh <- word   

发送 goroutine 逐行遍历文本,每次发送一个单词。然后它会阻塞,直到接收(主)goroutine 接收到该单词。

|

defer close(wordsCh)   

随着单词的持续接收(见后文),发送 goroutine 在完成时关闭通道。这将向接收者发出停止的信号。

|

for {   
  word, opened := <-wordsCh   
  if !opened {   
    break   
  }   
  histogram[word]++   
}   

| 这是接收者代码。由于它事先不知道预期多少数据,所以被放置在一个循环中。在循环的每次迭代中,代码执行以下操作:

  • 从通道中拉取数据

  • 检查通道的打开状态

  • 如果已关闭,则退出循环

  • 否则记录直方图

|

使用 for…range 接收数据

之前的模式在 Go 中非常常见,以至于这种习语以以下for…range语句的形式内置到语言中:

for := range {…}

在每次迭代中,这个for…range语句将阻塞,直到它从指定的通道接收到传入的数据,如下面的代码片段所示:

func main(){                           
... 
   go func() { 
         defer close(wordsCh) 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     wordsCh <- word 
               } 
         } 
   }() 

   for word := range wordsCh { 
         histogram[word]++ 
   } 
... 
} 

golang.fyi/ch09/pattern3.go

之前的代码展示了使用 for-range 语句的代码更新版本,for word := range wordsCh。它依次发出从wordsCh通道接收到的值。当通道关闭(来自 goroutine)时,循环会自动中断。

注意

总是记得关闭通道,以便正确地通知接收者。否则,程序可能会进入死锁,这可能导致 panic。

生成器函数

通道和 goroutines 为使用生成函数实现生产者/生产者模式提供了一种自然的底层支持。在这种方法中,一个 goroutine 被封装在一个函数中,该函数生成通过函数返回的通道发送的值。消费者 goroutine 接收这些值,就像它们被生成一样。

将单词 histogram 更新为使用此模式,如下面的代码片段所示:

func main() { 
   data := []string{"The yellow fish swims...", ...} 
   histogram := make(map[string]int) 

   words := words(data) // returns handle to data channel 
   for word := range words { 
         histogram[word]++ 
   } 
... 
} 

// generator function that produces data 
func words(data []string) <-chan string { 
   out := make(chan string) 
   go func() { 
         defer close(out) // closes channel upon fn return 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     out <- word 
               } 
         } 
   }() 
   return out 
} 

golang.fyi/ch09/pattern4.go

在这个示例中,生成函数,声明为func words(data []string) <-chan string,返回一个只读的字符串元素通道。消费者函数,在这个例子中是main(),接收生成函数发出的数据,该数据通过for…range循环进行处理。

从多个通道中选择

select statement. The generator function words select between two channels, out to send data as before and a new channel stopCh, passed as a parameter, which is used to detect an interruption signal to stop sending data:
func main() { 
... 
   histogram := make(map[string]int) 
   stopCh := make(chan struct{}) // used to signal stop 

   words := words(stopCh, data) // returns handle to channel 
   for word := range words { 
         if histogram["the"] == 3 { 
               close(stopCh) 
         } 
         histogram[word]++ 
   } 
... 
} 

func words(stopCh chan struct{}, data []string) <-chan string { 
   out := make(chan string) 
   go func() { 
         defer close(out) // closes channel upon fn return 
         for _, line := range data { 
               words := strings.Split(line, " ") 
               for _, word := range words { 
                     word = strings.ToLower(word) 
                     select { 
                     case out <- word: 
                     case <-stopCh: // succeeds first when close 
                         return 
                     } 
               } 
         } 
   }() 
   return out 
} 

words generator function will select the first communication operation that succeeds: out <- word or <-stopCh. As long as the consumer code in main() continues to receive from the out channel, the send operation will succeed first. Notice, however, the code in main() closes the stopCh channel when it encounters the third instance of "the". When that happens, it will cause the receive case, in the select statement, to proceed first causing the goroutine to return.

通道超时

在 Go 并发中常见的一个流行惯用法是使用之前介绍过的 select 语句来实现超时。这是通过使用 select 语句等待在给定时间范围内使用time包的 API([golang.org/pkg/time/](https://golang.org/pkg/time/))成功完成通道操作来实现的。

以下代码片段显示了单词直方图示例的一个版本,如果程序在 200 微秒内完成计数和打印单词,则会超时:

func main() { 
   data := []string{...} 
   histogram := make(map[string]int) 
   done := make(chan struct{}) 

   go func() { 
         defer close(done) 
         words := words(data) // returns handle to channel 
         for word := range words { 
               histogram[word]++ 
         } 
         for k, v := range histogram { 
               fmt.Printf("%s\t(%d)\n", k, v) 
         } 
   }() 

   select { 
   case <-done: 
         fmt.Println("Done counting words!!!!") 
   case <-time.After(200 * time.Microsecond): 
         fmt.Println("Sorry, took too long to count.") 
   } 
} 
func words(data []string) <-chan string {...} 

golang.fyi/ch09/pattern6.go

这个版本的直方图示例引入了done通道,用于在处理完成后发出信号。在select语句中,接收操作case <-done:会阻塞,直到 goroutine 关闭done通道。同样,在select语句中,time.After()函数返回一个通道,该通道将在指定的时间后关闭。如果在done关闭之前有 200 微秒的时间流逝,那么time.After()的通道将首先关闭,导致超时情况首先成功。

The sync package

有时候,使用传统方法访问共享值比使用通道更简单、更合适。sync包([golang.org/pkg/sync/](https://golang.org/pkg/sync/))提供了几个同步原语,包括互斥(mutex)锁和同步屏障,用于安全地访问共享值,如本节所述。

使用互斥锁同步

互斥锁允许通过使 goroutines 阻塞并等待锁释放来串行访问共享资源。以下示例展示了使用Service类型的典型代码场景,该类型必须在准备使用之前启动。服务启动后,代码更新一个内部布尔变量started以存储其当前状态:

type Service struct { 
   started bool 
   stpCh   chan struct{} 
   mutex   sync.Mutex 
} 
func (s *Service) Start() { 
   s.stpCh = make(chan struct{}) 
   go func() { 
         s.mutex.Lock() 
         s.started = true 
         s.mutex.Unlock() 
         <-s.stpCh // wait to be closed. 
   }() 
} 
func (s *Service) Stop() { 
   s.mutex.Lock() 
   defer s.mutex.Unlock() 
   if s.started { 
         s.started = false 
         close(s.stpCh) 
   } 
} 
func main() { 
   s := &Service{} 
   s.Start() 
   time.Sleep(time.Second) // do some work 
   s.Stop() 
} 

mutex, of type sync.Mutex, to synchronize access to the shared variable started. For this to work effectively, all contentious areas where the started variable is updated must use the same lock with successive calls to mutex.Lock() and mutex.Unlock(), as shown in the code.
Lock() and Unlock()methods as part of the struct itself:
type Service struct { 
   ... 
   sync.Mutex 
} 

func (s *Service) Start() { 
   s.stpCh = make(chan struct{}) 
   go func() { 
         s.Lock() 
         s.started = true 
         s.Unlock() 
         <-s.stpCh // wait to be closed. 
   }() 
} 

func (s *Service) Stop() { 
   s.Lock() 
   defer s.Unlock() 
   ... 
} 

golang.fyi/ch09/sync3.go

sync包还提供了 RWMutex(读写互斥锁),在只有一个写者更新共享资源,而可能有多个读者的情况下可以使用。写者将使用完整的锁来更新资源,就像之前一样。然而,读者使用RLock()/RUnlock()方法对(分别用于读锁/解锁)来在读取共享资源时应用只读锁。RWMutex 类型将在下一节,同步访问复合值中使用。

同步访问复合值

上一节讨论了在共享简单值时的并发安全性。在共享复合类型值(如 map 和 slice)的访问时,必须采取相同级别的谨慎,因为 Go 不提供这些类型的并发安全版本,如下面的示例所示:

type Service struct { 
   started bool 
   stpCh   chan struct{} 
   mutex   sync.RWMutex 
   cache   map[int]string 
} 

func (s *Service) Start() { 
   ... 
   go func() { 
         s.mutex.Lock() 
         s.started = true 
         s.cache[1] = "Hello World" 
         ... 
         s.mutex.Unlock() 
         <-s.stpCh // wait to be closed. 
   }() 
} 
... 
func (s *Service) Serve(id int) { 
   s.mutex.RLock() 
   msg := s.cache[id] 
   s.mutex.RUnlock() 
   if msg != "" { 
         fmt.Println(msg) 
   } else { 
         fmt.Println("Hello, goodbye!") 
   } 
} 

golang.fyi/ch09/sync4.go

之前的代码使用sync.RWMutex变量(参见前述部分,使用互斥锁进行同步)来管理访问 map 变量cache时的锁。代码将更新cache变量的操作包裹在mutex.Lock()mutex.Unlock()方法调用的一对中。然而,当从cache变量读取值时,使用mutex.RLock()mutex.RUnlock()方法来提供并发安全性。

使用 sync.WaitGroup 的并发屏障

3 and 5 up to MAX. The code uses the WaitGroup variable, wg, to create a concurrency barrier that waits for two goroutines to calculate the partial sums of the numbers, then gathers the result after all goroutines are done:
const MAX = 1000 

func main() { 
   values := make(chan int, MAX) 
   result := make(chan int, 2) 
   var wg sync.WaitGroup 
   wg.Add(2) 
   go func() { // gen multiple of 3 & 5 values 
         for i := 1; i < MAX; i++ { 
               if (i%3) == 0 || (i%5) == 0 { 
                     values <- i // push downstream 
               } 
         } 
         close(values) 
   }() 

   work := func() { // work unit, calc partial result 
         defer wg.Done() 
         r := 0 
         for i := range values { 
               r += i 
         } 
         result <- r 
   } 

   // distribute work to two goroutines 
   go work() 
   go work() 

   wg.Wait()                    // wait for both groutines 
   total := <-result + <-result // gather partial results 
   fmt.Println("Total:", total) 
} 

golang.fyi/ch09/sync5.go

在之前的代码中,方法调用wg.Add(2)配置了WaitGroup变量wg,因为工作被分配给了两个 goroutine。work函数调用defer wg.Done(),每次完成时将 WaitGroup 计数器减一。

最后,wg.Wait()方法调用会阻塞,直到其内部计数器达到零。如前所述,这将在两个 goroutine 的work运行函数成功完成后发生。当发生这种情况时,程序将解除阻塞并收集部分结果。重要的是要记住,如果其内部计数器永远不会达到零,wg.Wait()将无限期地阻塞。

检测竞态条件

使用带有竞态条件的并发代码进行调试可能既耗时又令人沮丧。当发生竞态条件时,它通常是不一致的,并且显示很少或没有可识别的模式。幸运的是,自从版本 1.1 以来,Go 已经将其竞态检测器作为其命令行工具链的一部分包含在内。在构建、测试、安装或运行 Go 源代码时,只需添加-race命令标志即可启用代码的竞态检测器。

例如,当使用-race标志执行源文件golang.fyi/ch09/sync1.go(一个带有竞态条件的代码)时,编译器的输出显示了导致竞态条件的违规 goroutine 位置,如下面的输出所示:

$> go run -race sync1.go 
================== 
WARNING: DATA RACE 
Read by main goroutine: 
  main.main() 
/github.com/vladimirvivien/learning-go/ch09/sync1.go:28 +0x8c 

Previous write by goroutine 5: 
  main.(*Service).Start.func1() 
/github.com/vladimirvivien/learning-go/ch09/sync1.go:13 +0x2e 

Goroutine 5 (running) created at: 
  main.(*Service).Start() 
/github.com/vladimirvivien/learning-go/ch09/sync1.go:15 +0x99 
  main.main() 
/github.com/vladimirvivien/learning-go/ch09/sync1.go:26 +0x6c 
================== 
Found 1 data race(s) 
exit status 66 

竞态检测器列出了存在对共享值并发访问的行号。它列出了随后的读取操作,然后是可能并发发生写入操作的地点。代码中的竞态条件可能不会被注意到,即使在经过良好测试的代码中,直到它随机地表现出来。如果您正在编写并发代码,强烈建议您将竞态检测器集成到您的测试套件中作为一部分。

Go 中的并行性

到目前为止,本章的讨论主要集中在同步并发程序上。正如本章前面提到的,Go 运行时调度器自动将 goroutines 多路复用和调度到可用的操作系统管理的线程中。这意味着可以并行化的并发程序能够利用底层处理器核心,而无需进行很少或没有配置。例如,以下代码干净地分离了其工作单元(计算 3 和 5 的倍数的和),通过启动workers数量的 goroutines 来计算:

const MAX = 1000 
const workers = 2 

func main() { 
   values := make(chan int) 
   result := make(chan int, workers) 
   var wg sync.WaitGroup 

   go func() { // gen multiple of 3 & 5 values 
         for i := 1; i < MAX; i++ { 
               if (i%3) == 0 || (i%5) == 0 { 
                     values <- i // push downstream 
               } 
         } 
         close(values) 
   }() 

   work := func() { // work unit, calc partial result 
         defer wg.Done() 
         r := 0 
         for i := range values { 
               r += i 
         } 
         result <- r 
   } 

   //launch workers 
   wg.Add(workers) 
   for i := 0; i < workers; i++ { 
         go work() 
   } 

   wg.Wait() // wait for all groutines 
   close(result) 
   total := 0 
   // gather partial results 
   for pr := range result { 
         total += pr 
   } 
   fmt.Println("Total:", total) 
} 

golang.fyi/ch09/sync6.go

在多核机器上执行时,前面的代码将自动以并行方式启动每个 goroutine,使用go work()。默认情况下,Go 运行时调度器将为调度创建与 CPU 核心数量相等的操作系统支持的线程。这个数量由运行时值GOMAXPROCS标识。

可以显式更改 GOMAXPROCS 值以影响提供给调度器的线程数量。该值可以使用同名的命令行环境变量进行更改。GOMAXPROCS 还可以通过runtime包中的使用函数GOMAXPROCS()进行更新(golang.org/pkg/runtime)。两种方法都允许程序员微调将参与 goroutine 调度的线程数量。

摘要

并发在任何语言中都可能是一个复杂的话题。本章涵盖了主要主题,以指导读者了解 Go 语言中并发原语的使用。本章的第一部分概述了 goroutines 的关键属性,包括go语句的创建和使用。接下来,本章介绍了 Go 的运行时调度机制以及用于运行中 goroutines 之间通信的通道概念。最后,用户被介绍了几种并发模式,这些模式用于使用 goroutines、通道和来自 sync 包的同步原语创建并发程序。

接下来,您将了解在 Go 中进行数据输入和输出的标准 API。

第十章. Go 中的数据 IO

本书的前几章主要关注基础知识。在本章和未来的章节中,读者将介绍 Go 标准库提供的强大 API 的一些内容。本章详细讨论如何使用标准库及其各自的包的 API 输入、处理、转换和输出数据,以下是一些主题:

  • 使用读取器和写入器进行 IO 操作

  • io.Reader 接口

  • io.Writer 接口

  • 使用 io 包进行工作

  • 与文件一起工作

  • 使用 fmt 进行格式化 IO

  • 缓冲 IO

  • 内存中的 IO

  • 数据的编码和解码

使用读取器和写入器进行 IO 操作

与其他语言,如 Java,Go 将数据输入和输出建模为从源到目标的流。数据资源,如文件、网络连接,甚至一些内存中的对象,都可以建模为字节流,可以从其中读取或写入数据,如下面的图示所示:

使用读取器和写入器进行 IO 操作

数据流表示为可以访问以读取或写入的 字节切片[]byte)。正如我们将在本章中探讨的,*io* 包提供了 io.Reader 接口以实现代码,从源读取并传输数据到字节流。相反,io.Writer 接口允许实现者创建代码,从提供的字节流中读取数据并将其写入目标资源。这两个接口在 Go 中被广泛用作标准习语来表示 IO 操作。这使得可以以可预测的结果交换不同实现和上下文中的读取器和写入器。

io.Reader 接口

如下所示,io.Reader 接口很简单。它由一个方法组成,Read([]byte)(int, error),旨在让程序员实现代码,从任意源读取数据并将其传输到提供的字节切片中。

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

Read 方法返回传输到提供的切片中的总字节数和一个错误值(如果需要)。作为一个指导原则,io.Reader 的实现应该在读取器没有更多数据传输到流 p 时返回错误值 io.EOF。以下显示了 alphaReader 类型,这是一个从其字符串源过滤掉非 alpha 字符的 io.Reader 的简单实现:

type alphaReader string 

func (a alphaReader) Read(p []byte) (int, error) { 
   count := 0 
   for i := 0; i < len(a); i++ { 
         if (a[i] >= 'A' && a[i] <= 'Z') || 
               (a[i] >= 'a' && a[i] <= 'z') { 
               p[i] = a[i] 
         } 
         count++ 
   } 
   return count, io.EOF 
} 

func main() { 
   str := alphaReader("Hello! Where is the sun?") 
   io.Copy(os.Stdout, &str) 
   fmt.Println() 
} 

golang.fyi/ch10/reader0.go

由于 alphaReader 类型的值实现了 io.Reader 接口,因此它们可以在需要读取器的任何地方参与,如 io.Copy(os.Stdout, &str) 调用所示。这会将 alphaReader 变量发出的字节流复制到写入器接口 os.Stdout(稍后介绍)。

连接读取器

alphaReader. This time, it takes an io.Reader as its source as shown in the following code:
type alphaReader struct { 
   src io.Reader 
} 

func NewAlphaReader(source io.Reader) *alphaReader { 
   return &alphaReader{source} 
} 

func (a *alphaReader) Read(p []byte) (int, error) { 
   if len(p) == 0 { 
         return 0, nil 
   } 
   count, err := a.src.Read(p) // p has now source data 
   if err != nil { 
         return count, err 
   } 
   for i := 0; i < len(p); i++ { 
         if (p[i] >= 'A' && p[i] <= 'Z') || 
               (p[i] >= 'a' && p[i] <= 'z') { 
               continue 
         } else { 
               p[i] = 0 
         } 
   } 
   return count, io.EOF 
} 

func main() { 
   str := strings.NewReader("Hello! Where is the sun?") 
   alpha := NewAlphaReader(str) 
   io.Copy(os.Stdout, alpha) 
   fmt.Println() 
} 

alphaReader type can now be combined with an os.File to filter out non-alphabetic characters from a file (the Go source code itself):
... 
func main() { 
   file, _ := os.Open("./reader2.go") 
   alpha := NewAlphaReader(file) 
   io.Copy(os.Stdout, alpha) 
   fmt.Println() 
} 

golang.fyi/ch10/reader2.go

io.Writer 接口

如下所示,io.Writer 接口与其读取器对应者一样简单:

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

channelWriter type, a writer that decomposes and serializes its stream that is sent over a Go channel as consecutive bytes:
type channelWriter struct { 
   Channel chan byte 
} 

func NewChannelWriter() *channelWriter { 
   return &channelWriter{ 
         Channel: make(chan byte, 1024), 
   } 
} 

func (c *channelWriter) Write(p []byte) (int, error) { 
   if len(p) == 0 { 
         return 0, nil 
   } 

   go func() { 
         defer close(c.Channel) // when done 
         for _, b := range p { 
               c.Channel <- b 
         } 
   }() 

   return len(p), nil 
} 

fmt.Fprint function to serialize the "Stream me!" string as a sequence of bytes over a channel using channelWriter:
func main() { 
   cw := NewChannelWriter() 
   go func() { 
         fmt.Fprint(cw, "Stream me!") 
   }() 

   for c := range cw.Channel { 
         fmt.Printf("%c\n", c) 
   } 
} 

for…range statement as they are successively printed. The following snippet shows another example where the content of a file is serialized over a channel using the same channelWriter. In this implementation, an io.File value and io.Copy function are used to source the data instead of the fmt.Fprint function:
func main() { 
   cw := NewChannelWriter() 
   file, err := os.Open("./writer2.go") 
   if err != nil { 
         fmt.Println("Error reading file:", err) 
         os.Exit(1) 
   } 
   _, err = io.Copy(cw, file) 
   if err != nil { 
         fmt.Println("Error copying:", err) 
         os.Exit(1) 
   } 

   // consume channel 
   for c := range cw.Channel { 
         fmt.Printf("%c\n", c) 
   } 
} 

golang.fyi/ch10/writer2.go.

使用 io 包进行工作

在 IO 操作中,最明显的地方是从 io 包开始,嗯,io 包(golang.org/pkg/io)。正如我们已经看到的,io 包定义了输入和输出原语为 io.Readerio.Writer 接口。以下表格总结了在 io 包中可用的其他函数和类型,这些函数和类型有助于简化流式 IO 操作。

函数 描述

| io.Copy() | io.Copy 函数(及其变体 io.CopyBufferio.CopyN)使得从任意 io.Reader 源复制数据到任意 io.Writer 汇入变得容易,如下面的代码片段所示:

data := strings.NewReader("Write   me down.")   
file, _ := os.Create("./iocopy.data")   
io.Copy(file, data)   

golang.fyi/ch10/iocopy.go |

| PipeReader PipeWriter | io 包包括 PipeReaderPipeWriter 类型,它们将 IO 操作建模为内存管道。数据写入管道的 io.Writer,然后可以独立地从管道的 io.Reader 读取。以下简化的代码片段演示了一个简单的管道,它将字符串写入写入器 pw。然后使用 pr 读取器消耗数据,并将其复制到文件中:

file, _ := os.Create("./iopipe.data")   
pr, pw := io.Pipe()    
go func() {   
    fmt.Fprint(pw, "Pipe   streaming")   
    pw.Close()   
}()   

wait := make(chan struct{})   
go func() {   
    io.Copy(file, pr)   
    pr.Close()   
    close(wait)   
}()   
<-wait //wait for pr to finish   

golang.fyi/ch10/iopipe.go 注意,管道写入器将在读取器完全消耗管道内容或遇到错误时阻塞。因此,读取器和写入器都应该被包装在 goroutine 中,以避免死锁。|

| io.TeeReader() | 与 io.Copy 函数类似,io.TeeReader 将内容从读取器传输到写入器。然而,该函数还会通过返回的 io.Reader 发射复制的字节(未更改)。

TeeReader. The resulting reader, data, is then streamed to a gzip writer zip:
fin, _ := os.Open("./ioteerdr.go")   
defer fin.Close()   
fout, _ := os.Create("./teereader.gz")   
defer fout.Close()   

zip := gzip.NewWriter(fout)   
defer zip.Close()   
sha := sha1.New()   
data := io.TeeReader(fin, sha)    
io.Copy(zip, data)   

fmt.Printf("SHA1 hash %x\n",   sha.Sum(nil))   

golang.fyi/ch10/ioteerdr0.go 如果我们想要同时计算 SHA-1 和 MD5,我们可以更新代码,将两个 TeeReader 值嵌套,如下面的代码片段所示:

sha := sha1.New()   
md := md5.New()   
data := io.TeeReader(
  io.TeeReader(fin, md), sha,   
)    
io.Copy(zip, data)   

golang.fyi/ch10/ioteerdr1.go |

| io.WriteString() | io.WriteString 函数将字符串的内容写入指定的写入器。以下示例将字符串的内容写入文件:

fout, err := os.Create("./iowritestr.data")   
if err != nil {   
    fmt.Println(err)   
    os.Exit(1)   
}   
defer fout.Close()   
io.WriteString(fout, "Hello   there!\n")   

golang.fyi/ch10/iowritestr.go |

| io.LimitedReader | 如其名称所示,io.LimitedReader 结构体是一个只从指定的 io.Reader 读取 N 个字节的读取器。以下代码片段将打印字符串的前 19 个字节:

str := strings.NewReader("The   quick brown " +       
    "fox jumps over the lazy   dog")   
limited :=   &io.LimitedReader{R: str, N: 19}   
io.Copy(os.Stdout, limited)   

golang.fyi/ch10/iolimitedrdr.go

$> go run iolimitedrd.go   
The quick brown fox   

|

| io.SectionReader | io.SectionReader 类型通过指定一个索引(基于零)来开始读取,以及一个表示要读取的字节数的偏移量值来实现 seek 和 skip 原语,如下面的代码片段所示:

str := strings.NewReader("The   quick brown"+   
    "fox jumps over the lazy   dog")   
section := io.NewSectionReader(str,   19, 23)   
io.Copy(os.Stdout, section)   

golang.fyi/ch10/iosectionrdr.go 此示例将打印 jumps over the lazy dog。|

io/ioutil io/ioutil 子包实现了一小部分函数,这些函数提供了对 IO 原语(如文件读取、目录列出、临时目录创建和文件写入)的实用快捷方式。

处理文件

os 包 (golang.org/pkg/os/) 提供了 os.File 类型,它表示系统上的文件句柄。os.File 类型实现了多个 IO 原语,包括 io.Readerio.Writer 接口,这使得可以使用标准的流式 IO API 处理文件内容。

创建和打开文件

io.Copy function. One common, and recommended practice to notice is the deferred call to the method Close on the file. This ensures a graceful release of OS resources when the function exits:
func main() { 
   f1, err := os.Open("./file0.go") 
   if err != nil { 
         fmt.Println("Unable to open file:", err) 
         os.Exit(1) 
   } 
   defer f1.Close() 

   f2, err := os.Create("./file0.bkp") 
   if err != nil { 
         fmt.Println("Unable to create file:", err) 
         os.Exit(1) 
   } 
   defer f2.Close() 

   n, err := io.Copy(f2, f1) 
   if err != nil { 
         fmt.Println("Failed to copy:", err) 
         os.Exit(1) 
   } 

   fmt.Printf("Copied %d bytes from %s to %s\n",  
       n, f1.Name(), f2.Name()) 
} 

golang.fyi/ch10/file0.go

函数 os.OpenFile

os.FileOpen function to demonstrate how it works:
func main() { 
   f1, err := os.OpenFile("./file0.go", os.O_RDONLY, 0666) 
   if err != nil {...} 
   defer f1.Close() 

   f2, err := os.OpenFile("./file0.bkp", os.O_WRONLY, 0666) 
   if err != nil {...} 
   defer f2.Close() 

   n, err := io.Copy(f2, f1) 
   if err != nil {...} 

   fmt.Printf("Copied %d bytes from %s to %s\n",  
      n, f1.Name(), f2.Name()) 
} 

golang.fyi/ch10/file1.go

注意

如果你已经有一个指向操作系统文件描述符的引用,你也可以使用 os.NewFile 函数在你的程序中创建一个文件句柄。os.NewFile 函数很少使用,因为文件通常使用之前讨论过的文件函数进行初始化。

文件读写

WriteString method from the os.File variable, fout, to create a text file:
func main() { 
   rows := []string{ 
         "The quick brown fox", 
         "jumps over the lazy dog", 
   } 

   fout, err := os.Create("./filewrite.data") 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
   defer fout.Close() 

   for _, row := range rows { 
         fout.WriteString(row) 
   } 
} 

golang.fyi/ch10/filewrite0.go

然而,如果你的数据源不是文本,你可以直接将原始字节写入文件,如下面的源代码片段所示:

func main() { 
   data := [][]byte{ 
         []byte("The quick brown fox\n"), 
         []byte("jumps over the lazy dog\n"), 
   } 
   fout, err := os.Create("./filewrite.data") 
   if err != nil { ... } 
   defer fout.Close() 

   for _, out := range data { 
         fout.Write(out) 
   } 
} 

../ch0r/dict.txt as raw bytes assigned to slice p up to 1024-byte chunks at a time:
func main() { 
   fin, err := os.Open("../ch05/dict.txt") 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
   defer fin.Close() 
   p := make([]byte, 1024) 
   for { 
         n, err := fin.Read(p) 
         if err == io.EOF { 
               break 
         } 
         fmt.Print(string(p[:n])) 
   } 
} 

golang.fyi/ch10/fileread.go

标准输入、输出和错误

f1 and writes its content to io.Stdout, standard output, using the os.Copy function (standard input is covered later):
func main() { 
   f1, err := os.Open("./file0.go") 
   if err != nil { 
         fmt.Println("Unable to open file:", err) 
         os.Exit(1) 
   } 
   defer f1.Close() 

   n, err := io.Copy(os.Stdout, f1) 
   if err != nil { 
         fmt.Println("Failed to copy:", err) 
         os.Exit(1) 
   } 

   fmt.Printf("Copied %d bytes from %s \n", n, f1.Name()) 
} 

golang.fyi/ch10/osstd.go

使用 fmt 进行格式化 IO

最常用的 IO 包之一是 fmt (golang.org/pkg/fmt)。它包含了一系列用于格式化输入和输出的函数。fmt 包最常用的用途是写入标准输出和读取标准输入。本节还突出了其他使 fmt 成为优秀 IO 工具的函数。

向 io.Writer 接口打印

metalloid data to a specified text file using the fmt.Fprintf function:
type metalloid struct { 
   name   string 
   number int32 
   weight float64 
} 

func main() { 
   var metalloids = []metalloid{ 
         {"Boron", 5, 10.81}, 
         ... 
         {"Polonium", 84, 209.0}, 
   } 
   file, _ := os.Create("./metalloids.txt") 
   defer file.Close() 

   for _, m := range metalloids { 
         fmt.Fprintf( 
               file, 
               "%-10s %-10d %-10.3f\n", 
               m.name, m.number, m.weight, 
         ) 
   } 
} 

golang.fyi/ch10/fmtfprint0.go

在上一个示例中,fmt.Fprintf 函数使用格式说明符将格式化文本写入到 io.File 变量 filefmt.Fprintf 函数支持大量的格式说明符,其正确处理超出了本文的范围。请参阅在线文档以获取这些说明符的完整覆盖。

向标准输出打印

fmt.Printf instead of the fmt.Fprintf function:
type metalloid struct { ... } 
func main() { 
   var metalloids = []metalloid{ 
         {"Boron", 5, 10.81}, 
         ... 
         {"Polonium", 84, 209.0}, 
   } 

   for _, m := range metalloids { 
         fmt.Printf( 
               "%-10s %-10d %-10.3f\n", 
               m.name, m.number, m.weight, 
         ) 
   } 
} 

golang.fyi/ch10/fmtprint0.go

从 io.Reader 读取

fmt.Fscanf for the formatted input of a space-delimited file (planets.txt) containing planetary data:
func main() { 
   var name, hasRing string 
   var diam, moons int 

   // read data 
   data, err := os.Open("./planets.txt") 
   if err != nil { 
         fmt.Println("Unable to open planet data:", err) 
         return 
   } 
   defer data.Close() 

   for { 
         _, err := fmt.Fscanf( 
               data, 
               "%s %d %d %s\n", 
               &name, &diam, &moons, &hasRing, 
         ) 
         if err != nil { 
               if err == io.EOF { 
                     break 
               } else { 
                     fmt.Println("Scan error:", err) 
                     return 
               } 
         } 
         fmt.Printf( 
               "%-10s %-10d %-6d %-6s\n", 
               name, diam, moons, hasRing, 
         ) 
   } 

golang.fyi/ch10/fmtfscan0.go

代码从 io.File 变量 data 读取,直到遇到表示文件结束的 io.EOF 错误。它读取的每一行文本都使用格式说明符 "%s %d %d %s\n" 进行解析,这与存储在文件中的记录的空格分隔布局相匹配。然后,每个解析的标记被分配给相应的变量 namediammoonshasRing,这些变量使用 fm.Printf 函数打印到标准输出。

从标准输入读取

与从任意的 io.Reader 读取不同,fmt.Scanfmt.Scanffmt.Scanln 用于从标准输入文件句柄 os.Stdin 读取数据。以下代码片段展示了从控制台读取文本输入的简单程序:

func main() { 
   var choice int 
   fmt.Println("A square is what?") 
   fmt.Print("Enter 1=quadrilateral 2=rectagonal:") 

   n, err := fmt.Scanf("%d", &choice) 
   if n != 1 || err != nil { 
         fmt.Println("Follow directions!") 
         return 
   } 
   if choice == 1 { 
         fmt.Println("You are correct!") 
   } else { 
         fmt.Println("Wrong, Google it.") 
   } 
} 

golang.fyi/ch10/fmtscan1.go

在上一个程序中,fmt.Scanf 函数使用格式说明符 "%d" 解析输入,从标准输入读取一个整数值。如果读取的值与指定的格式不精确匹配,该函数将抛出一个错误。例如,以下展示了当读取字符 D 而不是整数时会发生什么:

$> go run fmtscan1.go
A square is what?
Enter 1=quadrilateral 2=rectagonal: D
Follow directions!

缓冲输入输出

到目前为止,大多数 IO 操作都是非缓冲的。这意味着每个读写操作都可能受到底层操作系统处理 IO 请求延迟的负面影响。另一方面,缓冲操作通过在 IO 操作期间在内部内存中缓冲数据来减少延迟。bufio 包(golang.org/pkg/bufio/)提供了缓冲读写 IO 操作的函数。

缓冲写入器和读取器

bufio 包提供了几个函数,用于使用 io.Writer 接口进行缓冲的 IO 流写入。以下代码片段创建了一个文本文件,并使用缓冲 IO 向其写入:

func main() { 
   rows := []string{ 
         "The quick brown fox", 
         "jumps over the lazy dog", 
   } 

   fout, err := os.Create("./filewrite.data") 
   writer := bufio.NewWriter(fout) 
   if err != nil { 
         fmt.Println(err) 
         os.Exit(1) 
   } 
   defer fout.Close() 

   for _, row := range rows { 
         writer.WriteString(row) 
   } 
   writer.Flush() 
} 

bufio.Reader variable reader by wrapping the file variable as its underlying source:
func main() { 
   file, err := os.Open("./bufread0.go") 
   if err != nil { 
         fmt.Println("Unable to open file:", err) 
         return 
   } 
   defer file.Close() 

   reader := bufio.NewReader(file) 
   for { 
         line, err := reader.ReadString('\n') 
         if err != nil { 
               if err == io.EOF { 
                     break 
               } else { 
                     fmt.Println("Error reading:, err") 
                     return 
               } 
         } 
         fmt.Print(line) 
   } 
} 

golang.fyi/ch10/bufread0.go

之前的代码使用 reader.ReadString 方法通过 '\n' 字符作为内容分隔符来读取文本文件。要影响内部缓冲区的大小,请使用构造函数 bufio.NewReaderSize(w io.Reader, n int) 来指定内部缓冲区大小。bufio.Reader 类型还提供了 ReadReadByteReadBytes 方法,用于从流中读取原始字节,以及 ReadRune 方法用于读取 Unicode 编码的字符。

扫描缓冲区

bufio.Scanner (instead of the fmt.Fscan function) to scan the content of the text file using the bufio.ScanLines function:
func main() { 
   file, err := os.Open("./planets.txt") 
   if err != nil { 
         fmt.Println("Unable to open file:", err) 
         return 
   } 
   defer file.Close() 

   fmt.Printf( 
         "%-10s %-10s %-6s %-6s\n", 
         "Planet", "Diameter", "Moons", "Ring?", 
   ) 
   scanner := bufio.NewScanner(file) 
   scanner.Split(bufio.ScanLines) 
   for scanner.Scan() { 
         fields := strings.Split(scanner.Text(), " ") 
         fmt.Printf( 
               "%-10s %-10s %-6s %-6s\n", 
               fields[0], fields[1], fields[2], fields[3], 
         ) 
   } 
} 

golang.fyi/ch10/bufscan0.go

使用 bufio.Scanner 的步骤如前一个示例所示,共有四个步骤:

  • 首先,使用 bufio.NewScanner(io.Reader) 创建一个扫描器

  • 调用 scanner.Split 方法来配置内容如何被标记化

  • 使用 scanner.Scan 方法遍历生成的标记

  • 使用 scanner.Text 方法读取标记化数据

代码使用预定义函数 bufio.ScanLines 使用行分隔符解析缓冲内容。bufio 包附带几个预定义的拆分函数,包括 ScanBytes 用于将每个字节作为标记扫描,ScanRunes 用于扫描 UTF-8 编码的标记,以及 ScanWords 用于将每个空格分隔的单词作为标记扫描。

内存中的 IO

byte.Buffer variable, book. Then the buffer is streamed to os.Stdout:
func main() { 
   var books bytes.Buffer 
   books.WriteString("The Great Gatsby") 
   books.WriteString("1984") 
   books.WriteString("A Tale of Two Cities") 
   books.WriteString("Les Miserables") 
   books.WriteString("The Call of the Wild") 

   books.WriteTo(os.Stdout) 
} 

golang.fyi/ch10/bytesbuf0.go

同样的示例可以很容易地更新为将内容流式传输到常规文件,如下面的简略代码片段所示:

func main() { 
   var books bytes.Buffer 
   books.WriteString("The Great Gatsby\n") 
   books.WriteString("1984\n") 
   books.WriteString("A Take of Two Cities\n") 
   books.WriteString("Les Miserables\n") 
   books.WriteString("The Call of the Wild\n") 

   file, err := os.Create("./books.txt") 
   if err != nil { 
         fmt.Println("Unable to create file:", err) 
         return 
   } 
   defer file.Close() 
   books.WriteTo(file) 
} 

golang.fyi/ch10/bytesbuf1.go

数据的编码和解码

Go 中 IO 的另一个常见方面是在流式传输过程中将数据从一种表示形式编码到另一种表示形式。标准库中的编码器和解码器,位于 encoding 包中([golang.org/pkg/encoding/](https://golang.org/pkg/encoding/)),使用 io.Readerio.Writer 接口来利用 IO 原语作为编码和解码过程中流式传输数据的方式。

Go 支持多种编码格式,用于各种目的,包括数据转换、数据压缩和数据加密。本章将专注于使用GobJSON格式进行数据转换的编码和解码。在第十一章《编写网络程序》中,我们将探讨使用编码器将数据转换为客户端和服务器通信,使用远程过程调用RPC)。

使用 gob 进行二进制编码

books, a slice of the Book type with nested values, into the gob format. The encoder writes its generated binary data to an os.Writer instance, in this case the file variable of the *os.File type:
type Name struct { 
   First, Last string 
} 

type Book struct { 
   Title       string 
   PageCount   int 
   ISBN        string 
   Authors     []Name 
   Publisher   string 
   PublishDate time.Time 
} 

func main() { 
   books := []Book{ 
         Book{ 
               Title:       "Leaning Go", 
               PageCount:   375, 
               ISBN:        "9781784395438", 
               Authors:     []Name{{"Vladimir", "Vivien"}}, 
               Publisher:   "Packt", 
               PublishDate: time.Date( 
                     2016, time.July, 
                     0, 0, 0, 0, 0, time.UTC, 
               ), 
         }, 
         Book{ 
               Title:       "The Go Programming Language", 
               PageCount:   380, 
               ISBN:        "9780134190440", 
               Authors:     []Name{ 
                     {"Alan", "Donavan"}, 
                     {"Brian", "Kernighan"}, 
               }, 
               Publisher:   "Addison-Wesley", 
               PublishDate: time.Date( 
                     2015, time.October, 
                     26, 0, 0, 0, 0, time.UTC, 
               ), 
         }, 
         ... 
   } 

   // serialize data structure to file 
   file, err := os.Create("book.dat") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   enc := gob.NewEncoder(file) 
   if err := enc.Encode(books); err != nil { 
         fmt.Println(err) 
   } 
} 

books.data file in the previous example. The decoder reads the data from an io.Reader, in this instance the variable file of the *os.File type:
type Name struct { 
   First, Last string 
} 

type Book struct { 
   Title       string 
   PageCount   int 
   ISBN        string 
   Authors     []Name 
   Publisher   string 
   PublishDate time.Time 
} 

func main() { 
   file, err := os.Open("book.dat") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   var books []Book 
   dec := gob.NewDecoder(file) 
   if err := dec.Decode(&books); err != nil { 
         fmt.Println(err) 
         return 
   } 
} 

golang.fyi/ch10/gob1.go

通过创建一个解码器dec := gob.NewDecoder(file)来解码先前编码的 gob 数据。下一步是声明将存储解码数据的变量。在我们的例子中,books变量,其类型为[]Book,被声明为解码数据的目的地。实际的解码操作是通过调用dec.Decode(&books)来完成的。注意Decode()方法接受其目标变量的地址作为参数。一旦解码完成,books变量将包含从文件中流出的重构数据结构。

注意

到目前为止,gob 编码器和解码器 API 仅适用于 Go 编程语言。这意味着以 gob 编码的数据只能由 Go 程序消费。

将数据编码为 JSON

编码包还附带了一个json编码子包(golang.org/pkg/encoding/json/),以支持 JSON 格式的数据。这大大增加了 Go 程序可以与之交换复杂数据结构的语言数量。JSON 编码与 gob 包中的编码器和解码器类似。区别在于生成数据采用清晰的文本 JSON 编码格式,而不是二进制格式。以下代码更新了先前的例子,以将数据编码为 JSON:

type Name struct { 
   First, Last string 
} 

type Book struct { 
   Title       string 
   PageCount   int 
   ISBN        string 
   Authors     []Name 
   Publisher   string 
   PublishDate time.Time 
} 

func main() { 
   books := []Book{ 
         Book{ 
               Title:       "Leaning Go", 
               PageCount:   375, 
               ISBN:        "9781784395438", 
               Authors:     []Name{{"Vladimir", "Vivien"}}, 
               Publisher:   "Packt", 
               PublishDate: time.Date( 
                     2016, time.July, 
                     0, 0, 0, 0, 0, time.UTC), 
         }, 
         ... 
   } 

   file, err := os.Create("book.dat") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   enc := json.NewEncoder(file) 
   if err := enc.Encode(books); err != nil { 
         fmt.Println(err) 
   } 
} 

golang.fyi/ch10/json0.go

代码与之前完全相同。它使用分配给books变量的相同嵌套结构切片。唯一的区别是使用enc := json.NewEncoder(file)创建编码器,这将创建一个使用file变量作为其io.Writer目的地的 JSON 编码器。当执行enc.Encode(books)时,books变量的内容将被序列化为 JSON 格式并写入本地文件books.dat,如下面的代码所示(格式化以提高可读性):

[ 
 {
 "Title":"Leaning Go",
 "PageCount":375,
 "ISBN":"9781784395438",
 "Authors":[{"First":"Vladimir","Last":"Vivien"}],
 "Publisher":"Packt",
 "PublishDate":"2016-06-30T00:00:00Z"
 },
 {
 "Title":"The Go Programming Language",
 "PageCount":380,
 "ISBN":"9780134190440",
 "Authors":[
 {"First":"Alan","Last":"Donavan"},
                      {"First":"Brian","Last":"Kernighan"}
 ],
 "Publisher":"Addison-Wesley",
 "PublishDate":"2015-10-26T00:00:00Z"
 },
 ...
]

book.dat. Note that the data structure (not shown in the following code) is the same as before:
func main() { 
   file, err := os.Open("book.dat") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   var books []Book 
   dec := json.NewDecoder(file) 
   if err := dec.Decode(&books); err != nil { 
         fmt.Println(err) 
         return 
   } 
} 

golang.fyi/ch10/json1.go

书本.dat 文件中的数据存储为一个 JSON 对象的数组。因此,代码必须声明一个变量,能够存储嵌套结构值的索引集合。在先前的例子中,books变量,其类型为[]Book,被声明为解码数据的目的地。实际的解码操作是通过调用dec.Decode(&books)来完成的。注意Decode()方法接受其目标变量的地址作为参数。一旦解码完成,books变量将包含从文件中流出的重构数据结构。

使用结构体标签控制 JSON 映射

json: tag prefix to specify how object keys are to be encoded and decoded:
type Book struct { 
   Title       string      `json:"book_title"` 
   PageCount   int         `json:"pages,string"` 
   ISBN        string      `json:"-"` 
   Authors     []Name      `json:"auths,omniempty"` 
   Publisher   string      `json:",omniempty"` 
   PublishDate time.Time   `json:"pub_date"` 
} 

golang.fyi/ch10/json2.go

以下表格总结了标签及其含义:

标签 描述
Title string `json:"book_title"` Title 结构体字段映射到 JSON 对象键 "book_title"
PageCount int `json:"pages,string"` PageCount 结构体字段映射到 JSON 对象键 "pages",并将值输出为字符串而不是数字。
ISBN string `json:"-"` 短横线导致 ISBN 字段在编码和解码过程中被跳过。
Authors []Name `json:"auths,omniempty"` Authors 字段映射到 JSON 对象键 "auths"。注释 omniempty 导致字段值为 nil 时被省略。
Publisher string `json:",omniempty"` 将结构体字段名称 Publisher 映射为 JSON 对象键名称。注释 omniempty 导致字段为空时被省略。
PublishDate time.Time `json:"pub_date"` 将字段名称 PublishDate 映射到 JSON 对象键 "pub_date"

当前面的结构体被编码时,在 books.dat 文件中产生以下 JSON 输出(格式化以提高可读性):

... 
{ 
   "book_title":"The Go Programming Language", 
   "pages":"380", 
   "auths":[ 
         {"First":"Alan","Last":"Donavan"}, 
         {"First":"Brian","Last":"Kernighan"} 
   ], 
   "Publisher":"Addison-Wesley", 
   "pub_date":"2015-10-26T00:00:00Z" 
} 
... 

注意 JSON 对象键的命名方式与 struct 标签中指定的一致。键 "pages"(映射到结构体字段 PageCount)被编码为字符串。最后,结构体字段 ISBN 被省略,如 struct 标签中注释所示。

自定义编码和解码

Name is updated to implement json.Marshaller as shown:
type Name struct { 
   First, Last string 
} 
func (n *Name) MarshalJSON() ([]byte, error) { 
   return []byte( 
         fmt.Sprintf(""%s, %s"", n.Last, n.First) 
   ), nil 
} 

type Book struct { 
   Title       string 
   PageCount   int 
   ISBN        string 
   Authors     []Name 
   Publisher   string 
   PublishDate time.Time 
} 
func main(){ 
   books := []Book{ 
         Book{ 
               Title:       "Leaning Go", 
               PageCount:   375, 
               ISBN:        "9781784395438", 
               Authors:     []Name{{"Vladimir", "Vivien"}}, 
               Publisher:   "Packt", 
               PublishDate: time.Date( 
                     2016, time.July, 
                     0, 0, 0, 0, 0, time.UTC), 
         }, 
         ... 
   } 
   ... 
   enc := json.NewEncoder(file) 
   if err := enc.Encode(books); err != nil { 
         fmt.Println(err) 
   } 
} 

golang.fyi/ch10/json3.go

在前面的示例中,Name 类型的值被序列化为 JSON 字符串(而不是之前的对象)。序列化由 Name.MarshallJSON 方法处理,该方法返回一个包含由逗号分隔的姓氏和名字的字节序列数组。前面的代码生成了以下 JSON 输出:

 [
 ...
 {
                "Title":"Leaning Go",
                "PageCount":375,
                "ISBN":"9781784395438",
                "Authors":["Vivien, Vladimir"],
                "Publisher":"Packt",
                "PublishDate":"2016-06-30T00:00:00Z"
          },
          ...
    ] 

json.Unmarshaler to handle the JSON output for the Name type:
type Name struct { 
   First, Last string 
} 

func (n *Name) UnmarshalJSON(data []byte) error { 
   var name string 
   err := json.Unmarshal(data, &name) 
   if err != nil { 
         fmt.Println(err) 
         return err 
   } 
   parts := strings.Split(name, ", ") 
   n.Last, n.First = parts[0], parts[1] 
   return nil 
} 

golang.fyi/ch10/json4.go

Name 类型是 json.Unmarshaler 的实现。当解码器遇到键为 "Authors" 的 JSON 对象时,它使用 Name.Unmarshaler 方法从 JSON 字符串重新构建 Go 结构体 Name 类型。

注意

Go 标准库提供了额外的编码器(此处未涵盖),包括 base32base64binarycsvhexxmlgzip 以及许多加密格式编码器。

摘要

本章提供了 Go 的数据输入和输出惯用语的概述以及实现 IO 原语所涉及的包。本章首先介绍了 Go 中基于流的 IO 的基础知识,包括 io.Readerio.Writer 接口。读者将了解 io.Readerio.Writer 的实现策略和示例。

本章继续介绍支持流式 IO 机制的各种包、类型和函数,包括处理文件、格式化 IO、缓冲 IO 和内存 IO。本章的最后部分涵盖了编码器和解码器,它们在数据流传输过程中将数据进行转换。在下一章中,当讨论转向创建使用网络进行通信的程序时,IO 主题将进一步展开。

第十一章。编写网络服务

Go 作为系统语言广受欢迎的许多原因之一是其创建网络程序的内建支持。标准库公开了从低级套接字原语到高级服务抽象(如 HTTP 和 RPC)的 API。本章探讨了创建连接应用程序的基本主题,包括以下内容:

  • net

  • TCP API 服务器

  • HTTP 包

  • JSON API 服务器

net

Go 中所有网络程序的起点是 net 包 (golang.org/pkg/net)。它提供了一套丰富的 API,用于处理低级网络原语以及应用层协议,如 HTTP。网络中的每个逻辑组件都由一个 Go 类型表示,包括硬件接口、网络、数据包、地址、协议和连接。此外,每个类型都公开了大量的方法,使 Go 拥有支持 IPv4 和 IPv6 的最完整的标准库之一,用于网络编程。

无论创建客户端还是服务器程序,Go 程序员至少需要以下章节中涵盖的网络原语。这些原语作为函数和类型提供,以方便客户端连接到远程服务以及服务器处理传入请求。

地址解析

在进行网络编程时,一个基本原语是 地址net 包的类型和函数使用字符串字面量来表示地址,例如 "127.0.0.1"。地址还可以包括由冒号分隔的服务端口,例如 "74.125.21.113:80"net 包中的函数和方法还支持 IPv6 地址的字符串字面量表示,例如 "::1""[2607:f8b0:4002:c06::65]:80",其中地址的服务端口为 80。

net.Conn 类型

net.Conn 接口表示网络中两个节点之间建立的通用连接。它实现了 io.Readerio.Writer 接口,允许连接的节点使用流式 I/O 原语交换数据。net 包提供了针对 net.Conn 接口的特定网络协议实现,例如 IPConnUDPConnTCPConn。每个实现都公开了其各自网络和协议特有的额外方法。然而,正如我们将在本章中看到的,net.Conn 中定义的默认方法集对于大多数用途来说是足够的。

建立连接

客户端程序使用 net.Dial 函数连接到网络上的主机服务,该函数具有以下签名:

func Dial(network, address string) (Conn, error) 

"tcp" network at the host address, www.gutenberg.org:80, which returns a TCP connection of the *net.TCPConn type. The abbreviated code uses the TCP connection to issue an "HTTP GET" request to retrieve the full text of the literary classic Beowulf from the Project Gutenberg's website (http://gutenberg.org/). The raw and unparsed HTTP response is subsequently written to a local file, beowulf.txt:
func main() { 
   host, port := "www.gutenberg.org", "80" 
   addr := net.JoinHostPort(host, port) 
   httpRequest:="GET  /cache/epub/16328/pg16328.txt HTTP/1.1\n" + 
         "Host: " + host + "\n\n" 

   conn, err := net.Dial("tcp", addr) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer conn.Close() 

   if _, err = conn.Write([]byte(httpRequest)); err != nil { 
         fmt.Println(err) 
         return 
   } 

   file, err := os.Create("beowulf.txt") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer file.Close() 

   io.Copy(file, conn) 
   fmt.Println("Text copied to file", file.Name()) 
}

golang.fyi/ch11/dial0.go

因为 net.Conn 类型实现了 io.Readerio.Writer,它可以用来发送和接收数据,使用流式 I/O 语义。在先前的示例中,conn.Write([]byte(httpRequest)) 将 HTTP 请求发送到服务器。主机返回的响应通过 io.Copy(file, conn)conn 变量复制到 file 变量。

注意

注意,前面的示例展示了如何使用原始 TCP 连接到 HTTP 服务器。Go 标准库提供了一个专门为 HTTP 编程设计的独立包,该包抽象了低级协议细节(将在本章后面介绍)。

net 包还提供了网络特定的拨号函数,如 DialUDPDiapTCPDialIP,每个都返回其相应的连接实现。在大多数情况下,net.Dial 函数和 net.Conn 接口提供了连接和管理远程主机连接的足够功能。

监听传入连接

创建服务程序时,第一步是宣布服务将使用的端口号,以便监听来自网络的传入请求。这是通过调用 net.Listen 函数来完成的,该函数具有以下签名:

func Listen(network, laddr string) (net.Listener, error) 

它接受两个参数,其中第一个参数指定一个协议,有效值为 "tcp""tcp4""tcp6""unix""unixpacket"

第二个参数是服务的本地主机地址。本地地址可以指定为没有 IP 地址,例如 ":4040"。省略主机 IP 地址意味着服务绑定到主机上安装的所有网络接口卡。作为替代,可以通过指定网络上的 IP 地址将服务绑定到主机上的特定网络硬件接口,即 "10.20.130.240:4040"

成功调用 net.Listen 函数返回 net.Listener 类型的值(如果失败,则返回非空错误)。net.Listener 接口公开用于管理传入客户端连接生命周期的方法。根据 network 参数的值("tcp""tcp4""tcp6" 等),net.Listen 将返回 net.TCPListenernet.UnixListener,两者都是 net.Listener 接口的具体实现。

接受客户端连接

net.Listener 接口使用 *Accept* 方法无限期地阻塞,直到从客户端收到新的连接。以下简化的代码片段展示了简单的服务器,它向每个客户端连接返回字符串 "很高兴见到你!" 然后立即断开连接:

func main() { 
   listener, err := net.Listen("tcp", ":4040") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer listener.Close() 

   for { 
         conn, err := listener.Accept() 
         if err != nil { 
               fmt.Println(err) 
               return 
         } 
         conn.Write([]byte("Nice to meet you!")) 
         conn.Close() 
   } 
} 

golang.fyi/ch11/listen0.go

在代码中,listener.Accept 方法返回 net.Conn 类型的值以处理服务器和客户端之间的数据交换(如果失败,则返回非空错误)。conn.Write([]byte("Nice to meet you!")) 方法调用用于向客户端写入响应。当服务器程序运行时,可以使用以下输出中的 telnet 客户端进行测试:

$> go run listen0.go & 
[1] 83884 

$> telnet 127.0.0.1 4040 
Trying 127.0.0.1... 
Connected to localhost. 
Escape character is '^]'. 
Nice to meet you! Connection closed by foreign host.

为了确保服务器程序持续运行并处理后续的客户端连接,Accept方法的调用被包裹在一个无限循环中。一旦连接关闭,循环将重新启动周期以等待下一个客户端连接。同时请注意,在服务器进程关闭时,通过调用Listener.Close()关闭监听器是一个好的实践。

注意

仔细的读者可能会注意到这个简单的服务器无法扩展,因为它不能同时处理多个客户端请求。在下一节中,我们将看到创建可扩展服务器的技术。

TCP API 服务器

到目前为止,本章已经涵盖了创建客户端和服务程序所需的最基本的网络组件。本章的剩余部分将讨论实现货币信息服务的服务器不同版本。该服务在每个请求中返回 ISO 4217 货币信息。目的是展示使用不同的应用层协议创建网络服务和它们的客户端的后果。

之前我们介绍了一个非常简单的服务器来演示设置网络服务所需的必要步骤。本节通过创建一个可以扩展以处理多个并发连接的 TCP 服务器来深入探讨网络编程。本节中展示的服务器代码有以下设计目标:

  • 使用原始 TCP 在客户端和服务器之间进行通信

  • 开发一个基于 TCP 的简单文本协议,用于通信

  • 客户端可以使用文本命令查询服务器的全局货币信息

  • 使用每个连接一个 goroutine 来处理连接并发

  • 维持连接直到客户端断开

以下列出了服务器代码的简略版本。程序使用curr包(位于github.com/vladimirvivien/learning-go/ch11/curr0),此处未讨论,用于从本地 CSV 文件中加载货币数据到切片currencies

在成功连接到客户端后,服务器使用简单的文本协议解析传入的客户端命令,格式为GET ,其中**指定一个字符串值,用于搜索货币信息:

import ( 
   "net" 
   ... 
   curr "https://github.com/vladimirvivien/learning-go/ch11/curr0" 
) 

var currencies = curr.Load("./data.csv") 

func main() { 
   ln, _ := net.Listen("tcp", ":4040") 
   defer ln.Close() 

   // connection loop 
   for { 
         conn, err := ln.Accept() 
         if err != nil { 
               fmt.Println(err) 
               conn.Close() 
               continue 
         }      
         go handleConnection(conn) 
   } 
} 

// handle client connection 
func handleConnection(conn net.Conn) { 
   defer conn.Close() 

   // loop to stay connected with client 
   for { 
         cmdLine := make([]byte, (1024 * 4)) 
         n, err := conn.Read(cmdLine) 
         if n == 0 || err != nil { 
               return 
         } 
         cmd, param := parseCommand(string(cmdLine[0:n])) 
         if cmd == "" { 
               continue 
         } 

         // execute command 
         switch strings.ToUpper(cmd) { 
         case "GET": 
               result := curr.Find(currencies, param) 
               // stream result to client 
               for _, cur := range result { 
                     _, err := fmt.Fprintf( 
                           conn, 
                           "%s %s %s %s\n", 
                           cur.Name, cur.Code, 
                           cur.Number, cur.Country, 
                     ) 
                     if err != nil { 
                           return 
                     } 
                     // reset deadline while writing, 
                     // closes conn if client is gone 
                     conn.SetWriteDeadline( 
                           time.Now().Add(time.Second * 5)) 
               } 
               // reset read deadline for next read 
               conn.SetReadDeadline( 
                     time.Now().Add(time.Second * 300)) 

         default: 
               conn.Write([]byte("Invalid command\n")) 
         } 
   } 
} 

func parseCommand(cmdLine string) (cmd, param string) { 
   parts := strings.Split(cmdLine, " ") 
   if len(parts) != 2 { 
         return "", "" 
   } 
   cmd = strings.TrimSpace(parts[0]) 
   param = strings.TrimSpace(parts[1]) 
   return 
} 

golang.fyi/ch11/tcpserv0.go

与上一节中介绍的简单服务器不同,这个服务器能够同时服务多个客户端连接。在通过ln.Accept()接受新的连接后,它将新客户端连接的处理委托给一个 goroutine,使用go handleConnection(conn)。然后连接循环立即继续并等待下一个客户端连接。

handleConnection 函数管理服务器与连接客户端的通信。它首先使用 cmd, param := parseCommand(string(cmdLine[0:n])) 读取并解析从客户端接收的字节数组到命令字符串。接下来,代码使用 switch 语句测试命令。如果 cmd 等于 "GET",代码通过调用 curr.Find(currencies, param)currencies 切片中搜索与 param 匹配的值。最后,它使用 fmt.Fprintf(conn, "%s %s %s %s\n", cur.Name, cur.Code, cur.Number, cur.Country) 将搜索结果流式传输到客户端的连接。

服务器支持的单个文本协议不包含任何类型的会话控制或控制消息。因此,代码使用 conn.SetWriteDeadline 方法确保与客户端的连接不会不必要地长时间挂起。该方法在循环中调用,循环将响应流式传输到客户端。它设置为 5 秒的截止日期,以确保客户端总是在该时间内准备好接收下一块字节,否则它将超时连接。

使用 telnet 连接到 TCP 服务器

由于前面提出的货币服务器使用简单的基于文本的协议,它可以使用 telnet 客户端进行测试,假设服务器代码已被编译并运行(并监听端口 4040)。以下是一个 telnet 会话的输出,该会话查询服务器以获取货币信息:

$> telnet localhost 4040
Trying ::1...
Connected to localhost.
Escape character is '^]'.
GET Gourde
Gourde HTG 332 HAITI
GET USD
US Dollar USD 840 AMERICAN SAMOA
US Dollar USD 840 BONAIRE, SINT EUSTATIUS AND SABA
US Dollar USD 840 GUAM
US Dollar USD 840 HAITI
US Dollar USD 840 MARSHALL ISLANDS (THE)
US Dollar USD 840 UNITED STATES OF AMERICA (THE)
...
get india
Indian Rupee INR 356 BHUTAN
US Dollar USD 840 BRITISH INDIAN OCEAN TERRITORY (THE)
Indian Rupee INR 356 INDIA

如您所见,您可以通过使用前面解释的 get 命令后跟一个过滤器参数来查询服务器。telnet 客户端将原始文本发送到服务器,服务器解析它并作为响应发送回原始文本。您可以对服务器打开多个 telnet 会话,并且所有请求都在各自的 goroutine 中并发处理。

使用 Go 连接到 TCP 服务器

golang.fyi/ch11/tcpclient0.goThe source code for the Go client follows the same pattern as we have seen in the earlier client example. The first portion of the code dials out to the server using `net.Dial()`. Once a connection is obtained, the code sets up an event loop to capture text commands from the standard input, parses it, and sends it as a request to the server.There is a nested loop that is set up to handle incoming responses from the server (see code comment). It continuously streams incoming bytes into variables `buff` with `conn.Read(buff)`. This continues until the `Read` method encounters an error. The following lists the sample output produced by the client when it is executed:

$> 连接到全球货币服务

curr> get pound

埃及镑 EGP 818 埃及

直布罗陀镑 GIP 292 直布罗陀

苏丹镑 SDG 938 苏丹(THE)

...

叙利亚镑 SYP 760 叙利亚阿拉伯共和国

英镑 GBP 826 大不列颠及北爱尔兰联合王国(THE)

curr>

conbuf 变量,bufio.Buffer 类型,用于使用 conbuf.ReadString 方法读取和分割来自服务器的输入流:


    conbuf := bufio.NewReaderSize(conn, 1024)

    for {

        str, err := conbuf.ReadString('\n')

        if err != nil {

                break

        }

        fmt.Print(str)

        conn.SetReadDeadline(

                time.Now().Add(time.Millisecond * 700))

    }

golang.fyi/ch11/tcpclient1.goAs you can see, writing networked services directly on top of raw TCP has some costs. While raw TCP gives the programmer complete control of the application-level protocol, it also requires the programmer to carefully handle all data processing which can be error-prone. Unless it is absolutely necessary to implement your own custom protocol, a better approach is to leverage an existing and proven protocols to implement your server programs. The remainder of this chapter continues to explore this topic using services that are based on HTTP as an application-level protocol.

HTTP 包

由于其重要性和普遍性,HTTP 是 Go 直接实现的一小部分协议之一。net/http 包 (golang.org/pkg/net/http/) 提供了实现 HTTP 客户端和 HTTP 服务器的代码。本节探讨了使用 net/http 包创建 HTTP 客户端和服务器的基本原理。稍后,我们将把注意力转回到使用 HTTP 构建我们的货币服务版本。

HTTP 客户端类型

http.Client 结构体代表一个 HTTP 客户端,用于创建 HTTP 请求并从服务器检索响应。以下示例展示了如何使用 http.Client 类型的 client 变量从位于 gutenberg.org/cache/epub/16328/pg16328.txt 的 Project Gutenberg 网站检索 Beowulf 的文本内容,并将其内容打印到标准输出:

func main() { 
   client := http.Client{} 
   resp, err := client.Get( 
         " http://gutenberg.org/cache/epub/16328/pg16328.txt") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer resp.Body.Close() 
   io.Copy(os.Stdout, resp.Body) 
} 

golang.fyi/ch11/httpclient1.go

前一个示例使用 client.Get 方法通过 HTTP 协议内部方法 GET 从远程服务器检索内容。GET 方法是客户端类型提供的几个便利方法之一,用于与 HTTP 服务器交互,如下表所示。请注意,所有这些方法都返回 *http.Response 类型的值(稍后讨论),以处理 HTTP 服务器返回的响应。

方法 描述

| Client.Get | 如前所述,Get 是一个便利方法,它向服务器发送一个 HTTP GET 方法,以从服务器检索由 url 参数指定的资源:

Get(url string,   
) (resp *http.Response, err   error)     

|

| Client.Post | Post 方法是一个便利方法,它向服务器发送一个 HTTP POST 方法,将 body 参数指定的内容发送到由 url 参数指定的服务器:

Post(   
  url string,    
  bodyType string,    
  body io.Reader,   
) (resp *http.Response, err error)   

|

| Client.PostForm | PostForm 方法是一个便利方法,它使用 HTTP POST 方法将表单 data(指定为映射的键/值对)发送到服务器:

PostForm(   
  url string,    
  data url.Values,   
) (resp *http.Response, err error)   

|

| Client.Head | Head 方法是一个便利方法,它向由 url 参数指定的远程服务器发送 HTTP 方法 HEAD

Head(url string,   
)(resp *http.Response, err error)   

|

Client.Do 此方法泛化了与远程 HTTP 服务器的请求和响应交互。它被列在此表中的方法内部包装。处理客户端请求和响应节讨论了如何使用此方法与服务器通信。

应注意,HTTP 包使用一个内部 http.Client 变量,该变量旨在作为包函数来镜像前面的方法,以提供进一步的便利。它们包括 http.Get*http.Post*http.PostFormhttp.Head。以下代码片段显示了使用 http.Get 而不是 http.Client 方法的前一个示例:

func main() { 
   resp, err := http.Get( 
       "http://gutenberg.org/cache/epub/16328/pg16328.txt") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer resp.Body.Close() 
   io.Copy(os.Stdout, resp.Body) 
} 

golang.fyi/ch11/httpclient1a.go

配置客户端

Timeout attribute of the Client type:
func main() { 
   client := &http.Client{ 
         Timeout: 21 * time.Second 
   } 
   resp, err := client.Get( 
         "http://tools.ietf.org/rfc/rfc7540.txt") 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer resp.Body.Close() 
   io.Copy(os.Stdout, resp.Body) 
} 

DisableKeepAlive field. The code also uses the Dial function to specify further granular control over the HTTP connection used by the underlying client, setting its timeout value to 30 seconds:
func main() { 
   client := &http.Client{ 
         Transport: &http.Transport{ 
               DisableKeepAlives: true, 
               Dial: (&net.Dialer{ 
                  Timeout:   30 * time.Second, 
               }).Dial, 
         }, 
   } 
... 
} 

处理客户端请求和响应

http.Request type to create a new request which is used to specify the headers sent to the server:
func main() { 
   client := &http.Client{} 
   req, err := http.NewRequest( 
         "GET", "http://tools.ietf.org/rfc/rfc7540.txt", nil, 
   ) 
   req.Header.Add("Accept", "text/plain") 
   req.Header.Add("User-Agent", "SampleClient/1.0") 

   resp, err := client.Do(req) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer resp.Body.Close() 
   io.Copy(os.Stdout, resp.Body) 
} 

golang.fyi/ch11/httpclient3.go

http.NewRequest 函数具有以下签名:

func NewRequest(method, uStr string, body io.Reader) (*http.Request, error) 

Do method of the http.Client type and has the following signature:
Do(req *http.Request) (*http.Response, error) 

该方法接受一个指向 http.Request 值的指针,如前节所述。然后它返回一个指向 http.Response 值的指针或一个错误(如果请求失败)。在前面的源代码中,resp, err := client.Do(req) 用于向服务器发送请求并将响应分配给 resp 变量。

服务器响应被封装在结构体 http.Response 中,该结构体包含多个字段来描述响应,包括 HTTP 响应状态、内容长度、头部信息和响应体。响应体作为 http.Response.Body 字段暴露,实现了 io.Reader 接口,这使得可以使用流式 IO 原语来消费响应内容。

Body 字段还实现了 *io.Closer* 接口,这允许关闭 IO 资源。前面的源代码使用 defer resp.Body.Close() 来关闭与响应体相关的 IO 资源。当预期服务器返回非 nil 的响应体时,这是一个推荐的惯用法。

简单的 HTTP 服务器

HTTP 包提供了两个主要组件来接受 HTTP 请求和提供响应:

  • http.Handler 接口

  • http.Server 类型

http.Server 类型使用 http.Handler 接口类型,该接口在以下列表中定义,用于接收请求和服务器响应:

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

msg type as handler registered to handle incoming client requests:
type msg string 

func (m msg) ServeHTTP( 
   resp http.ResponseWriter, req *http.Request) { 
   resp.Header().Add("Content-Type", "text/html") 
   resp.WriteHeader(http.StatusOK) 
   fmt.Fprint(resp, m) 
} 

func main() { 
   msgHandler := msg("Hello from high above!") 
   server := http.Server{Addr: ":4040", Handler: msgHandler} 
   server.ListenAndServe() 
} 

golang.fyi/ch11/httpserv0.go

在前面的代码中,msg 类型,其底层类型为字符串,实现了 ServeHTTP() 方法,使其成为一个有效的 HTTP 处理程序。其 ServeHTTP 方法使用响应参数 resp 来打印响应头部 "200 OK""Content-Type: text/html"。该方法还使用 fmt.Fprint(resp, m) 将字符串值 m 写入响应变量,并将其发送回客户端。

在代码中,变量 server 被初始化为 http.Server{Addr: ":4040", Handler: msgHandler}。这意味着服务器将在端口 4040 上监听所有网络接口,并使用变量 msgHandler 作为其 http.Handler 实现方式。一旦初始化,服务器通过调用 server.ListenAndServe() 方法启动,该方法用于阻塞并监听传入的请求。

除了 AddrHandler 之外,http.Server 结构体还公开了几个其他字段,可用于控制 HTTP 服务的不同方面,如连接、超时值、头部大小和 TLS 配置。例如,以下片段显示了一个更新的示例,它指定了服务器的读取和写入超时:

type msg string 
func (m msg) ServeHTTP( 
   resp http.ResponseWriter, req *http.Request) { 
   resp.Header().Add("Content-Type", "text/html") 
   resp.WriteHeader(http.StatusOK) 
   fmt.Fprint(resp, m) 
} 
func main() { 
   msgHandler := msg("Hello from high above!") 
   server := http.Server{ 
         Addr:         ":4040", 
         Handler:      msgHandler, 
         ReadTimeout:  time.Second * 5, 
         WriteTimeout: time.Second * 3, 
   } 
   server.ListenAndServe() 
} 

golang.fyi/ch11/httpserv1.go

默认服务器

应当注意,HTTP 包包含一个默认的服务器,在不需要配置服务器的情况下,可以用于更简单的场景。以下简化的代码片段启动了一个简单的服务器,而没有显式创建服务器变量:

type msg string 

func (m msg) ServeHTTP( 
    resp http.ResponseWriter, req *http.Request) { 
   resp.Header().Add("Content-Type", "text/html") 
   resp.WriteHeader(http.StatusOK) 
   fmt.Fprint(resp, m) 
} 

   func main() { 
   msgHandler := msg("Hello from high above!") 
   http.ListenAndServe(":4040", msgHandler) 
} 

golang.fyi/ch11/httpserv2.go

在代码中,使用 http.ListenAndServe(":4040", msgHandler) 函数启动了一个服务器,该服务器在 HTTP 包中声明为一个变量。服务器配置了本地地址 ":4040" 和处理程序 msgHandler(如之前所述)来处理所有传入的请求。

使用 http.ServeMux 路由请求

http.ServeMux variable mux configured to handle two URL paths "/hello" and "/goodbye":
func main() { 
   mux := http.NewServeMux() 
   hello := func(resp http.ResponseWriter, req *http.Request) { 
         resp.Header().Add("Content-Type", "text/html") 
         resp.WriteHeader(http.StatusOK) 
         fmt.Fprint(resp, "Hello from Above!") 
   } 

   goodbye := func(resp http.ResponseWriter, req *http.Request) { 
         resp.Header().Add("Content-Type", "text/html") 
         resp.WriteHeader(http.StatusOK) 
         fmt.Fprint(resp, "Goodbye, it's been real!") 
   } 

   mux.HandleFunc("/hello", hello) 
   mux.HandleFunc("/goodbye", goodbye) 

   http.ListenAndServe(":4040", mux) 
} 

golang.fyi/ch11/httpserv3.go

代码声明了两个函数,分别赋值给变量 hellogoodbye。每个函数分别使用 mux.HandleFunc("/hello", hello)mux.HandleFunc("/goodbye", goodbye) 方法调用映射到路径 "/hello""/goodbye"。当服务器启动时,使用 http.ListenAndServe(":4040", mux),其处理程序将请求 "http://localhost:4040/hello" 路由到 hello 函数,并将路径为 "http://localhost:4040/goodbye" 的请求路由到 goodbye 函数。

默认 ServeMux

值得指出的是,HTTP 包内部提供了一个默认的 ServeMux。当使用时,不需要显式声明一个 ServeMux 变量。相反,代码使用包函数 http.HandleFunc 将路径映射到处理函数,如下面的代码片段所示:

func main() { 
   hello := func(resp http.ResponseWriter, req *http.Request) { 
   ... 
   } 

   goodbye := func(resp http.ResponseWriter, req *http.Request) { 
   ... 
   } 

   http.HandleFunc("/hello", hello) 
   http.HandleFunc("/goodbye", goodbye) 

   http.ListenAndServe(":4040", nil) 
}

golang.fyi/ch11/httpserv4.go

要启动服务器,代码调用 http.ListenAndServe(":4040", nil),其中 ServerMux 参数设置为 nil。这意味着服务器将默认使用每个声明的包实例的 http.ServeMux 来处理传入的请求。

一个 JSON API 服务器

借助上一节的信息,可以使用 HTTP 包在 HTTP 上创建服务。在我们创建用于全球货币货币服务的服务器时,我们讨论了使用原始 TCP 直接创建服务的风险。在本节中,我们将探讨如何使用 HTTP 作为底层协议创建相同服务的 API 服务器。基于 HTTP 的新服务有以下设计目标:

  • 使用 HTTP 作为传输协议

  • 使用 JSON 在客户端和服务器之间进行结构化通信

  • 客户端使用 JSON 格式的请求查询服务器以获取货币信息

  • 服务器使用 JSON 格式的响应进行响应

下面的代码展示了实现新服务所涉及的代码。这次,服务器将使用 curr1 包(参见 github.com/vladimirvivien/learning-go /ch11/curr1)从本地 CSV 文件中加载和查询 ISO 4217 货币数据。

curr1 包中的代码定义了两种类型,CurrencyRequestCurrency,分别用于表示客户端请求和服务器返回的货币数据,如下所示:

type Currency struct { 
   Code    string `json:"currency_code"` 
   Name    string `json:"currency_name"` 
   Number  string `json:"currency_number"` 
   Country string `json:"currency_country"` 
} 

type CurrencyRequest struct { 
   Get   string `json:"get"` 
   Limit int    `json:limit` 
} 

golang.fyi/ch11/curr1/currency.go

注意,前面显示的结构体类型都带有标签,这些标签描述了每个字段的 JSON 属性。这些信息被 JSON 编码器用于编码 JSON 对象的键名(参见第十章,Go 中的数据输入输出,有关编码的详细信息)。以下代码片段中列出的代码定义了设置服务器和接收请求的处理函数:

import ( 
   "encoding/json" 
   "fmt" 
   "net/http" 

   " github.com/vladimirvivien/learning-go/ch11/curr1" 
) 
var currencies = curr1.Load("./data.csv") 

func currs(resp http.ResponseWriter, req *http.Request) { 
   var currRequest curr1.CurrencyRequest 
   dec := json.NewDecoder(req.Body) 
   if err := dec.Decode(&currRequest); err != nil { 
         resp.WriteHeader(http.StatusBadRequest) 
         fmt.Println(err) 
         return 
   } 

   result := curr1.Find(currencies, currRequest.Get) 
   enc := json.NewEncoder(resp) 
   if err := enc.Encode(&result); err != nil { 
         fmt.Println(err) 
         resp.WriteHeader(http.StatusInternalServerError) 
         return 
   } 
} 

func main() { 
   mux := http.NewServeMux() 
   mux.HandleFunc("/currency", get) 

   if err := http.ListenAndServe(":4040", mux); err != nil { 
         fmt.Println(err) 
   } 
} 

golang.fyi/ch11/jsonserv0.go

由于我们正在利用 HTTP 作为服务的传输协议,您可以看到代码现在比之前使用纯 TCP 的实现要小得多。currs 函数实现了处理传入请求的处理程序。它设置了一个解码器,将传入的 JSON 编码请求解码为 curr1.CurrencyRequest 类型的值,如下面的代码片段所示:

var currRequest curr1.CurrencyRequest 
dec := json.NewDecoder(req.Body) 
if err := dec.Decode(&currRequest); err != nil { ... } 

接下来,函数通过调用 curr1.Find(currencies, currRequest.Get) 执行货币搜索,该函数返回分配给 result 变量的 []Currency 切片。然后,代码创建了一个编码器,将 result 编码为 JSON 有效负载,如下面的代码片段所示:

result := curr1.Find(currencies, currRequest.Get) 
enc := json.NewEncoder(resp) 
if err := enc.Encode(&result); err != nil { ... } 

最后,处理程序函数通过调用 mux.HandleFunc("/currency", currs) 将其映射到 main 函数中的 "/currency" 路径。当服务器收到对该路径的请求时,它自动执行 currs 函数。

使用 cURL 测试 API 服务器

由于服务器是在 HTTP 上实现的,它可以很容易地使用任何支持 HTTP 的客户端工具进行测试。例如,以下展示了如何使用 cURL 命令行工具 (curl.haxx.se/)) 连接到 API 端点并检索有关 Euro 的货币信息:

$> curl -X POST -d '{"get":"Euro"}' http://localhost:4040/currency 
[ 
... 
  { 
    "currency_code": "EUR", 
    "currency_name": "Euro", 
    "currency_number": "978", 
    "currency_country": "BELGIUM" 
  }, 
  { 
    "currency_code": "EUR", 
    "currency_name": "Euro", 
    "currency_number": "978", 
    "currency_country": "FINLAND" 
  }, 
  { 
    "currency_code": "EUR", 
    "currency_name": "Euro", 
    "currency_number": "978", 
    "currency_country": "FRANCE" 
  }, 
... 
] 

使用 -X POST -d '{"get":"Euro"}' 参数,cURL 命令将一个 JSON 格式的请求对象发送到服务器。服务器输出的结果(为了便于阅读进行了格式化)是一个包含先前货币项的 JSON 数组。

Go 语言中的 API 服务器客户端

http.Client type to communicate with the server. It also uses the encoding/json sub-package to decode incoming data (note that the client also makes use of the curr1 package, shown earlier, which contains the types needed to communicate with the server):
import ( 
   "bytes" 
   "encoding/json" 
   "fmt" 
   "net/http" 

   " github.com/vladimirvivien/learning-go/ch11/curr1" 
) 

func main() { 
   var param string 
   fmt.Print("Currency> ") 
   _, err := fmt.Scanf("%s", &param) 

   buf := new(bytes.Buffer) 
   currRequest := &curr1.CurrencyRequest{Get: param} 
   err = json.NewEncoder(buf).Encode(currRequest) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   // send request 
   client := &http.Client{} 
   req, err := http.NewRequest( 
         "POST", "http://127.0.0.1:4040/currency", buf) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 

   resp, err := client.Do(req) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   defer resp.Body.Close() 

   // decode response 
   var currencies []curr1.Currency 
   err = json.NewDecoder(resp.Body).Decode(&currencies) 
   if err != nil { 
         fmt.Println(err) 
         return 
   } 
   fmt.Println(currencies) 
} 

golang.fyi/ch11/jsonclient0.go

在前面的代码中,创建了一个 HTTP 客户端来发送 JSON 编码的请求值,currRequest := &curr1.CurrencyRequest{Get: param},其中 param 是要检索的货币字符串。服务器的响应是一个表示 JSON 编码对象数组的有效负载(请参阅 使用 cURL 测试 API 服务器 部分的 JSON 数组)。然后,代码使用 JSON 解码器,json.NewDecoder(resp.Body).Decode(&currencies),将响应体中的有效负载解码到 []curr1.Currency 切片中。

JavaScript API 服务器客户端

到目前为止,我们已经看到了如何使用 cURL 命令行工具和原生 Go 客户端来使用 API 服务。本节展示了使用 HTTP 实现网络服务的多功能性,通过展示一个基于 Web 的 JavaScript 客户端。在这种方法中,客户端是一个基于 Web 的图形用户界面,它使用现代的 HTML、CSS 和 JavaScript 来创建与 API 服务器交互的界面。

首先,服务器代码更新了一个额外的处理程序来服务渲染浏览器上 GUI 的静态 HTML 文件。以下代码展示了这一点:

// serves HTML gui 
func gui(resp http.ResponseWriter, req *http.Request) { 
   file, err := os.Open("./currency.html") 
   if err != nil { 
         resp.WriteHeader(http.StatusInternalServerError) 
         fmt.Println(err) 
         return 
   } 
   io.Copy(resp, file) 
} 

func main() { 
   mux := http.NewServeMux() 
   mux.HandleFunc("/", gui) 
   mux.HandleFunc("/currency", currs) 

   if err := http.ListenAndServe(":4040", mux); err != nil { 
         fmt.Println(err) 
   } 
} 

gui handler function responsible for serving a static HTML file that renders the GUI for the client. The root URL path is then mapped to the function with mux.HandleFunc("/", gui). So, in addition to the "/currency" path, which hosts the API end-point the "/" path will return the web page shown in the following screenshot:

JavaScript API 服务器客户端

下一页 HTML 页面(golang.fyi/ch11/currency.html)负责显示货币搜索的结果。它使用 JavaScript 函数以及jQuery.js库(此处未介绍)将 JSON 编码的请求发送到后端 Go 服务,如下面的简略 HTML 和 JavaScript 片段所示:

<body> 
<div class="container"> 
  <h2>Global Currency Service</h2> 
  <p>Enter currency search string: <input id="in"> 
     <button type="button" class="btn btn-primary" onclick="doRequest()">Search</button> 
  </p>             
  <table id="tbl" class="table table-striped"> 
    <thead> 
      <tr> 
           <th>Code</th> 
           <th>Name</th> 
           <th>Number</th> 
           <th>Country</th> 
      </tr> 
    </thead> 
    <tbody/> 
  </table> 
</div> 

<script> 
 var tbl = document.getElementById("tbl"); 
   function addRow(code, name, number, country) { 
         var rowCount = tbl.rows.length; 
         var row = tbl.insertRow(rowCount); 
         row.insertCell(0).innerHTML = code; 
         row.insertCell(1).innerHTML = name; 
         row.insertCell(2).innerHTML = number; 
         row.insertCell(3).innerHTML = country; 
   } 

    function doRequest() { 
   param = document.getElementById("in").value 
        $.ajax('/currency', { 
            method: 'PUT', 
               contentType: 'application/json', 
               processData: false, 
               data: JSON.stringify({get:param}) 
         }).then( 
         function success(currencies) { 
               currs = JSON.parse(currencies) 
               for (i=0; i < currs.length; i++) { 
                     addRow( 
                           currs[i].currency_code, 
                           currs[i].currency_name, 
                           currs[i].currency_number, 
                           currs[i].currency_country 
                     ); 
               } 

         }); 
   } 
</script> 

golang.fyi/ch11/currency.html

对本例中 HTML 和 JavaScript 代码的逐行分析超出了本书的范围;然而,值得注意的是,JavaScript 的doRequest函数是客户端与服务器交互的地方。它使用 jQuery 的$.ajax函数构建一个使用PUT方法的 HTTP 请求,并指定一个 JSON 编码的货币请求对象JSON.stringify({get:param})发送到服务器。then方法接受回调函数success(currencies),该函数处理来自服务器的响应,并将其解析显示在 HTML 表格中。

当在 GUI 上的文本框中提供搜索值时,页面会动态地在表格中显示其结果,如下面的屏幕截图所示:

一个 JavaScript API 服务器客户端

摘要

本章总结了关于在 Go 中创建网络服务的一些重要概念。它从 Go 的net包的概述开始,包括用于在节点之间创建连接的net.Conn类型,用于连接远程服务的net.Dial函数,以及用于处理来自客户端的传入连接的net.Listen函数。本章继续介绍客户端和服务器程序的多种实现,并展示了在原始 TCP 上直接创建自定义协议与使用现有协议(如 HTTP,JSON 数据格式)之间的差异。

下一章将探讨不同的方向。它探讨了 Go 中可用于简化源代码测试的包、类型、函数和工具。

第十二章。代码测试

测试是现代软件开发实践中的关键仪式。Go 通过提供 API 和命令行工具,将测试直接带入开发周期,以无缝创建和集成自动化测试代码。在这里,我们将介绍 Go 测试套件,包括以下内容:

  • Go 测试工具

  • 编写 Go 测试

  • HTTP 测试

  • 测试覆盖率

  • 代码基准测试

Go 测试工具

在编写任何测试代码之前,让我们先讨论 Go 中自动化测试的工具。与go build命令类似,go test命令旨在编译并执行指定包中的测试源文件,如下所示命令所示:

$> go test .

之前的命令将在当前包中执行所有测试函数。尽管看起来很简单,但之前的命令完成了几个复杂的步骤,包括:

  • 编译当前包中找到的所有测试文件

  • 从测试文件生成一个带有插桩的二进制文件

  • 执行代码中的测试函数

go test命令针对多个包时,测试工具会生成多个测试二进制文件,它们独立执行和测试,如下所示:

$> go test ./... 

测试文件名

测试命令使用导入路径标准(见第六章,Go 包和程序)来指定要测试的包。在指定的包内,测试工具将编译所有具有*_test.go名称模式的文件。例如,假设我们有一个项目,在名为vec.go的文件中有一个简单的数学向量类型的实现,其测试文件的合理名称应该是vec_test.go

测试组织

传统上,测试文件与被测试的代码保存在同一个包(目录)中。这是因为没有必要分离测试文件,因为它们被排除在编译程序的二进制文件之外。以下是一个典型的 Go 包的目录布局,在这个例子中是标准库中的fmt包。它显示了与常规源代码在同一目录下的所有测试文件:

$>tree go/src/fmt/
├── doc.go
├── export_test.go
├── fmt_test.go
├── format.go
├── norace_test.go
├── print.go
├── race_test.go
├── scan.go
├── scan_test.go
└── stringer_test.go

除了拥有更简单的项目结构外,将文件放在一起使得测试函数能够完全看到被测试的包。这有助于访问和验证那些对测试代码来说是透明的包元素。当你的函数被放置在与要测试的代码分开的包中时,它们将失去访问非导出元素的能力。

编写 Go 测试

Add, Sub, and Scale methods (see the full source code listed at https://github.com/vladimirvivien/learning-go/ch12/vector/vec.go). Notice that each method implements a specific behavior as a unit of functionality, which will make it easy to test:
type Vector interface { 
    Add(other Vector) Vector 
    Sub(other Vector) Vector 
    Scale(factor float64) 
    ... 
} 

func New(elems ...float64) SimpleVector { 
    return SimpleVector(elems) 
} 

type SimpleVector []float64 

func (v SimpleVector) Add(other Vector) Vector { 
   v.assertLenMatch(other) 
   otherVec := other.(SimpleVector) 
   result := make([]float64, len(v)) 
   for i, val := range v { 
         result[i] = val + otherVec[i] 
   } 
   return SimpleVector(result) 
} 

func (v SimpleVector) Sub(other Vector) Vector { 
   v.assertLenMatch(other) 
   otherVec := other.(SimpleVector) 
   result := make([]float64, len(v)) 
   for i, val := range v { 
         result[i] = val - otherVec[i] 
   } 
   return SimpleVector(result) 
} 

func (v SimpleVector) Scale(scale float64) { 
   for i := range v { 
         v[i] = v[i] * scale 
   } 
} 
... 

golang.fyi/ch12/vector/vec.go

测试函数

文件vec_test.go中的测试源代码定义了一系列函数,通过独立测试其每个方法来测试类型SimpleVector(见前述章节)的行为:

import "testing" 

func TestVectorAdd(t *testing.T) { 
   v1 := New(8.218, -9.341) 
   v2 := New(-1.129, 2.111) 
   v3 := v1.Add(v2) 
   expect := New( 
       v1[0]+v2[0], 
       v1[1]+v2[1], 
   ) 

   if !v3.Eq(expect) { 
       t.Logf("Addition failed, expecting %s, got %s",  
          expect, v3) 
       t.Fail() 
   } 
   t.Log(v1, "+", v2, v3) 
} 

func TestVectorSub(t *testing.T) { 
   v1 := New(7.119, 8.215) 
   v2 := New(-8.223, 0.878) 
   v3 := v1.Sub(v2) 
   expect := New( 
       v1[0]-v2[0], 
       v1[1]-v2[1], 
   ) 
   if !v3.Eq(expect) { 
       t.Log("Subtraction failed, expecting %s, got %s",  
           expect, v3) 
           t.Fail() 
   } 
   t.Log(v1, "-", v2, "=", v3) 
} 

func TestVectorScale(t *testing.T) { 
   v := New(1.671, -1.012, -0.318) 
   v.Scale(7.41) 
   expect := New( 
       7.41*1.671, 
       7.41*-1.012, 
       7.41*-0.318, 
   ) 
   if !v.Eq(expect) { 
       t.Logf("Scalar mul failed, expecting %s, got %s",  
           expect, v) 
       t.Fail() 
   } 
   t.Log("1.671,-1.012, -0.318 Scale", 7.41, "=", v) 
} 

The source code of a test function usually sets up an expected value, which is pre-determined based on knowledge of the tested code. That value is then compared to the calculated value returned by the code being tested. For instance, when adding two vectors, we can calculate the expected result using the rules of vector additions, as shown in the following snippet:
v1 := New(8.218, -9.341) 
v2 := New(-1.129, 2.111) 
v3 := v1.Add(v2) 
expect := New( 
    v1[0]+v2[0], 
    v1[1]+v2[1], 
) 

v1 and v2, and stored in the variable expect. Variable v3, on the other hand, stores the actual value of the vector, as calculated by the tested code. This allows us to test the actual versus the expected, as shown in the following:
if !v3.Eq(expect) { 
    t.Log("Addition failed, expecting %s, got %s", expect, v3) 
    t.Fail() 
} 

false, then the test has failed. The code uses t.Fail() to signal the failure of the test function. Signaling failure is discussed in more detail in the Reporting failure section.

运行测试

如本章引言部分所述,测试函数使用 go test 命令行工具执行。例如,如果我们从 package vector 内运行以下命令,它将自动运行该包的所有测试函数:

$> cd vector
$> go test .
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.001s

测试也可以通过指定相对于命令发出位置的子包(或使用包通配符 ./... 的所有包)来执行,如下所示:

$> cd $GOPATH/src/github.com/vladimirvivien/learning-go/ch12/
$> go test ./vector
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.005s

执行测试的过滤

在开发大量测试函数的过程中,在调试阶段通常希望专注于一个函数(或一组函数)。Go 测试命令行工具支持 -run 标志,该标志指定一个正则表达式,仅执行名称与指定表达式匹配的函数。以下命令将仅执行测试函数 TestVectorAdd

$> go test -run=VectorAdd -v
=== RUN   TestVectorAdd
--- PASS: TestVectorAdd (0.00s)
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.025s

使用 -v 标志确认只执行了一个测试函数 TestVectorAdd。作为另一个例子,以下命令执行所有以 VectorA.*$ 结尾或匹配函数名 TestVectorMag 的测试函数,同时忽略其他所有内容:

> go test -run="VectorA.*$|TestVectorMag" -v
=== RUN   TestVectorAdd
--- PASS: TestVectorAdd (0.00s)
=== RUN   TestVectorMag
--- PASS: TestVectorMag (0.00s)
=== RUN   TestVectorAngle
--- PASS: TestVectorAngle (0.00s)
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.043s

测试日志

t.Logf("Vector = %v; Unit vector = %v\n", v, expect):
func TestVectorUnit(t *testing.T) { 
   v := New(5.581, -2.136) 
   mag := v.Mag() 
   expect := New((1/mag)*v[0], (1/mag)*v[1]) 
   if !v.Unit().Eq(expect) { 
       t.Logf("Vector Unit failed, expecting %s, got %s",  
           expect, v.Unit()) 
       t.Fail() 
   } 
   t.Logf("Vector = %v; Unit vector = %v\n", v, expect) 
}  

golang.fyi/ch12/vector/vec_test.go

如前所述,Go 测试工具在没有测试失败的情况下运行测试时输出最小化。然而,当提供详细输出标志 *-v* 时,工具将输出测试日志。例如,在 package vector 中运行以下命令将静音所有日志语句:

> go test -run=VectorUnit
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.005s

当提供详细输出标志 -v 时,如以下命令所示,测试运行时打印日志输出:

$> go test -run=VectorUnit -v
=== RUN   TestVectorUnit
--- PASS: TestVectorUnit (0.00s)
vec_test.go:100: Vector = [5.581,-2.136]; Unit vector =
[0.9339352140866403,-0.35744232526233]
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.001s

报告失败

默认情况下,Go 测试运行时认为测试成功,如果测试函数运行并正常返回而没有 panic。例如,以下测试函数是错误的,因为其预期值没有正确计算。然而,测试运行时始终将其报告为通过,因为它没有包含任何报告失败的代码:

func TestVectorDotProd(t *testing.T) { 
    v1 := New(7.887, 4.138).(SimpleVector) 
    v2 := New(-8.802, 6.776).(SimpleVector) 
    actual := v1.DotProd(v2) 
    expect := v1[0]*v2[0] - v1[1]*v2[1] 
    if actual != expect { 
        t.Logf("DotPoduct failed, expecting %d, got %d",  
          expect, actual) 
    } 
} 

golang.fyi/ch12/vec_test.go

这种假阳性条件可能被忽视,尤其是在详细输出标志关闭的情况下,最小化了任何表明它是错误的视觉线索:

$> go test -run=VectorDot
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.001s

修复之前测试的一种方法是通过使用 testing.T 类型的 Fail 方法来指示失败,如下面的代码片段所示:

func TestVectorDotProd(t *testing.T) { 
... 
    if actual != expect { 
        t.Logf("DotPoduct failed, expecting %d, got %d",  
          expect, actual) 
        t.Fail() 
    } 
} 

因此,现在当测试执行时,它正确地报告了它是错误的,如下面的输出所示:

$> go test -run=VectorDot
--- FAIL: TestVectorDotProd (0.00s)
vec_test.go:109: DotPoduct failed, expecting -97.460462, got -41.382286
FAIL
exit status 1
FAIL  github.com/vladimirvivien/learning-go/ch12/vector     0.002s

Errorf method, which is equivalent to calling the Logf and Fail methods:
func TestVectorMag(t *testing.T) { 
    v := New(-0.221, 7.437) 
    expected := math.Sqrt(v[0]*v[0] + v[1]*v[1]) 
    if v.Mag() != expected { 
   t.Errorf("Magnitude failed, execpted %d, got %d",  
        expected, v.Mag()) 
    } 
} 

golang.fyi/ch12/vector/vec.go

类型 testing.T 还提供了 FatalFormatf 方法,作为将消息的日志记录和测试函数的立即终止结合起来的方式。

跳过测试

RUN_ANGLE is set. Otherwise, it will skip the test:
func TestVectorAngle(t *testing.T) { 
   if os.Getenv("RUN_ANGLE") == "" { 
         t.Skipf("Env variable RUN_ANGLE not set, skipping:") 
   } 
   v1 := New(3.183, -7.627) 
   v2 := New(-2.668, 5.319) 
   actual := v1.Angle(v2) 
   expect := math.Acos(v1.DotProd(v2) / (v1.Mag() * v2.Mag())) 
   if actual != expect { 
         t.Logf("Vector angle failed, expecting %d, got %d", 
            expect, actual) 
         t.Fail() 
   } 
   t.Log("Angle between", v1, "and", v2, "=", actual) 
} 

注意代码使用了 Skipf 方法,这是 testing.T 类型的 SkipNowLogf 方法的组合。当在没有环境变量的情况下执行测试时,它输出以下内容:

$> go test -run=Angle -v
=== RUN   TestVectorAngle
--- SKIP: TestVectorAngle (0.00s)
 vec_test.go:128: Env variable RUN_ANGLE not set, skipping:
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.006s 

当提供环境变量时,如以下 Linux/Unix 命令所示,测试将按预期执行(请咨询您的操作系统了解如何设置环境变量):

> RUN_ANGLE=1 go test -run=Angle -v
=== RUN   TestVectorAngle
--- PASS: TestVectorAngle (0.00s)
 vec_test.go:138: Angle between [3.183,-7.627] and [-2.668,5.319] = 3.0720263098372476
PASS
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.005s

表驱动测试

在 Go 中,你经常会遇到的一种技术是使用表格驱动测试。这是指将一组输入和预期输出存储在数据结构中,然后使用它来循环不同的测试场景。例如,在下面的测试函数中,cases 变量,其类型为 []struct{vec SimpleVector; expected float64},用于存储几个向量值及其预期的幅度值,用于测试向量方法 Mag

func TestVectorMag(t *testing.T) { 
   cases := []struct{ 
         vec SimpleVector 
         expected float64 

   }{ 
       {New(1.2, 3.4), math.Sqrt(1.2*1.2 + 3.4*3.4)}, 
       {New(-0.21, 7.47), math.Sqrt(-0.21*-0.21 + 7.47*7.47)}, 
       {New(1.43, -5.40), math.Sqrt(1.43*1.43 + -5.40*-5.40)}, 
       {New(-2.07, -9.0), math.Sqrt(-2.07*-2.07 + -9.0*-9.0)}, 
   } 
   for _, c := range cases { 
       mag := c.vec.Mag() 
       if mag != c.expected { 
         t.Errorf("Magnitude failed, execpted %d, got %d",  
              c.expected, mag) 
       } 
   } 
} 

golang.fyi/ch12/vector/vec.go

在循环的每次迭代中,代码都会将 Mag 方法计算出的值与预期值进行比较。使用这种方法,我们可以测试输入和它们各自的输出的多种组合,就像前面代码中所做的那样。根据需要,这种技术可以扩展以包括更多参数。例如,可以使用名称字段为每个案例命名,这在测试案例数量较多时很有用。或者,为了更加复杂,可以在测试案例结构体中包含一个函数字段,以指定为每个相应案例使用的自定义逻辑。

HTTP 测试

https://github.com/vladimirvivien/learning-go/ch12/service/serv.go):
package main 

import ( 
   "encoding/json" 
   "fmt" 
   "net/http" 

   "github.com/vladimirvivien/learning-go/ch12/vector" 
) 
func add(resp http.ResponseWriter, req *http.Request) { 
   var params []vector.SimpleVector 
   if err := json.NewDecoder(req.Body).Decode(&params);  
       err != nil { 
         resp.WriteHeader(http.StatusBadRequest) 
         fmt.Fprintf(resp, "Unable to parse request: %s\n", err) 
         return 
   } 
   if len(params) != 2 { 
         resp.WriteHeader(http.StatusBadRequest) 
         fmt.Fprintf(resp, "Expected 2 or more vectors") 
         return 
   } 
   result := params[0].Add(params[1]) 
   if err := json.NewEncoder(resp).Encode(&result); err != nil { 
         resp.WriteHeader(http.StatusInternalServerError) 
         fmt.Fprintf(resp, err.Error()) 
         return 
   } 
} 
... 
func main() { 
   mux := http.NewServeMux() 
   mux.HandleFunc("/vec/add", add) 
   mux.HandleFunc("/vec/sub", sub) 
   mux.HandleFunc("/vec/dotprod", dotProd) 
   mux.HandleFunc("/vec/mag", mag) 
   mux.HandleFunc("/vec/unit", unit) 

   if err := http.ListenAndServe(":4040", mux); err != nil { 
         fmt.Println(err) 
   } 
} 

golang.fyi/ch12/service/serv.go

每个函数(addsubdotprodmagunit)实现了 http.Handler 接口。这些函数用于处理来自客户端的 HTTP 请求,并从 vector 包中计算相应的操作。请求和响应都使用 JSON 格式进行格式化,以简化处理。

测试 HTTP 服务器代码

httptest.ResponseRecorder to test the server's add method:
import ( 
   "net/http" 
   "net/http/httptest" 
   "strconv" 
   "strings" 
   "testing" 

   "github.com/vladimirvivien/learning-go/ch12/vector" 
) 

func TestVectorAdd(t *testing.T) { 
   reqBody := "[[1,2],[3,4]]" 
   req, err := http.NewRequest( 
        "POST", "http://0.0.0.0/", strings.NewReader(reqBody)) 
   if err != nil { 
         t.Fatal(err) 
   } 
   actual := vector.New(1, 2).Add(vector.New(3, 4)) 
   w := httptest.NewRecorder() 
   add(w, req) 
   if actual.String() != strings.TrimSpace(w.Body.String()) { 
         t.Fatalf("Expecting actual %s, got %s",  
             actual.String(), w.Body.String(), 
         ) 
   } 
} 

代码使用 reg, err := http.NewRequest("POST", "http://0.0.0.0/", strings.NewReader(reqBody)) 创建一个新的 "POST" 方法的 *http.Request 值,一个假 URL,以及一个请求体变量 reqBody,该请求体被编码为 JSON 数组。在代码的后面部分,w := httptest.NewRecorder() 用于创建一个 httputil.ResponseRecorder 值,该值用于调用 add(w, req) 函数以及创建的请求。在 add 函数执行期间记录在 w 中的值与存储在 atual 中的预期值通过 if actual.String() != strings.TrimSpace(w.Body.String()){...} 进行比较。

测试 HTTP 客户端代码

为 HTTP 客户端编写测试代码更为复杂,因为你实际上需要一个正在运行的服务器来进行适当的测试。幸运的是,httptest 包提供了 httptest.Server 类型,可以用来程序化地创建服务器以测试客户端请求,并向客户端发送模拟响应。

为了说明,让我们考虑以下代码,它部分展示了之前提到的向量服务器 HTTP 客户端的实现(请参阅完整的代码列表github.com/vladimirvivien/learning-go/ch12/client/client.go)。add 方法将 vec0vec2 参数编码为 JSON 对象,这些对象通过 c.client.Do(req) 发送到服务器。响应从 JSON 数组解码到类型为 vector.SimpleVector 的变量 result

type vecClient struct { 
    svcAddr string 
    client *http.Client 
} 
func (c *vecClient) add( 
   vec0, vec1 vector.SimpleVector) (vector.SimpleVector, error) { 
   uri := c.svcAddr + "/vec/add" 

   // encode params 
   var body bytes.Buffer 
    params := []vector.SimpleVector{vec0, vec1} 
   if err := json.NewEncoder(&body).Encode(&params); err != nil { 
         return []float64{}, err 
   } 
   req, err := http.NewRequest("POST", uri, &body) 
   if err != nil { 
        return []float64{}, err 
   } 

   // send request 
   resp, err := c.client.Do(req) 
   if err != nil { 
       return []float64{}, err 
   } 
   defer resp.Body.Close() 

   // handle response 
   var result vector.SimpleVector 
   if err := json.NewDecoder(resp.Body). 
        Decode(&result); err != nil { 
        return []float64{}, err 
    } 
    return result, nil 
} 

golang.fyi/ch12/client/client.go

我们可以使用类型httptest.Server来创建测试客户端发送的请求的代码,并将数据返回给客户端代码以进行进一步检查。函数httptest.NewServer接受一个类型为http.Handler的值,其中封装了服务器的测试逻辑。然后该函数返回一个新的正在运行的 HTTP 服务器,准备在系统选择的端口上提供服务。

下面的测试函数展示了如何使用httptest.Server来测试前面展示的客户端代码中的add方法。请注意,在创建服务器时,代码使用类型http.HandlerFunc,这是一个适配器,它接受一个函数值以生成一个http.Handler。这种便利性允许我们跳过创建一个单独的类型来实现新的http.Handler

import( 
    "net/http" 
    "net/http/httptest" 
    ... 
) 
func TestClientAdd(t *testing.T) { 
   server := httptest.NewServer(http.HandlerFunc( 
         func(resp http.ResponseWriter, req *http.Request) { 
             // test incoming request path 
             if req.URL.Path != "/vec/add" { 
                 t.Errorf("unexpected request path %s",  
                    req.URL.Path) 
                   return 
               } 
               // test incoming params 
               body, _ := ioutil.ReadAll(req.Body) 
               params := strings.TrimSpace(string(body)) 
               if params != "[[1,2],[3,4]]" { 
                     t.Errorf("unexpected params '%v'", params) 
                     return 
               } 
               // send result 
               result := vector.New(1, 2).Add(vector.New(3, 4)) 
               err := json.NewEncoder(resp).Encode(&result) 
               if err != nil { 
                     t.Fatal(err) 
                     return 
               } 
         }, 
   )) 
   defer server.Close() 
   client := newVecClient(server.URL) 
   expected := vector.New(1, 2).Add(vector.New(3, 4)) 
   result, err := client.add(vector.New(1, 2), vector.New(3, 4)) 
   if err != nil { 
         t.Fatal(err) 
   } 
   if !result.Eq(expected) { 
         t.Errorf("Expecting %s, got %s", expected, result) 
   } 
} 

golang.fyi/ch12/client/client_test.go

测试函数首先设置server及其处理函数。在http.HandlerFunc的函数内部,代码首先确保客户端请求正确的路径"/vec/add"。接下来,代码检查客户端的请求体,确保适当的 JSON 格式和加法操作的参数有效。最后,处理函数将预期的结果编码为 JSON,并将其作为响应发送给客户端。

代码使用系统生成的server地址通过newVecClient(server.URL)创建一个新的client。方法调用client.add(vector.New(1, 2), vector.New(3, 4))向测试服务器发送请求,以计算其参数列表中两个值的向量加法。如前所述,测试服务器仅模拟真实服务器的代码,并返回计算出的向量值。resultexpected值进行比较,以确保add方法正常工作。

测试覆盖率

在编写测试时,了解实际代码中有多少被测试(或覆盖)是很重要的。这个数字是测试逻辑对源代码渗透程度的指示。无论你是否同意,在许多软件开发实践中,测试覆盖率是一个关键的指标,因为它衡量了代码被测试的程度。

幸运的是,Go 测试工具自带了一个内置的覆盖率工具。使用带有-cover标志的 Go 测试命令会对原始源代码进行覆盖率逻辑的配置。然后运行生成的测试二进制文件,提供包的整体覆盖率概要,如下所示:

$> go test -cover
PASS
coverage: 87.8% of statements
ok    github.com/vladimirvivien/learning-go/ch12/vector     0.028s

结果显示,代码经过良好测试,覆盖率为87.8%。我们可以使用测试工具提取有关测试代码部分的更多详细信息。为此,我们使用-coverprofile标志将覆盖率指标记录到文件中,如下所示:

$> go test -coverprofile=cover.out

覆盖工具

一旦覆盖率数据被保存,可以使用go tool cover命令以文本表格格式展示。以下显示了之前生成的覆盖率文件中每个测试函数覆盖率分解的部分输出:

$> go tool cover -func=cover.out
...
learning-go/ch12/vector/vec.go:52:  Eq          100.0%
learning-go/ch12/vector/vec.go:57:  Eq2         83.3%
learning-go/ch12/vector/vec.go:74:  Add         100.0%
learning-go/ch12/vector/vec.go:85:  Sub         100.0%
learning-go/ch12/vector/vec.go:96:  Scale       100.0%
...

cover 工具可以将覆盖率指标叠加到实际代码上,提供视觉辅助来显示代码的已覆盖(和未覆盖)部分。使用 -html 标志可以生成一个使用之前收集的覆盖率数据的 HTML 页面:

 $> go tool cover -html=cover.out

命令将打开已安装的默认网页浏览器并显示覆盖率数据,如下面的截图所示:

The cover tool

之前的截图仅显示了生成的 HTML 页面的一部分。它显示了绿色的已覆盖代码和红色的未覆盖代码。其他内容以灰色显示。

代码基准

基准测试的目的是衡量代码的性能。Go 测试命令行工具支持自动生成和测量基准指标。与单元测试类似,测试工具使用基准函数来指定要测量的代码部分。基准函数使用以下函数命名模式和签名:

*func Benchmark(testing.B)

基准函数的预期名称应以 benchmark 开头,并接受类型为 *testing.B 的指针值。以下是一个基准测试 SimpleVector 类型的 Add 方法(之前已介绍)的函数:

import ( 
    "math/rand" 
    "testing" 
    "time" 
) 
... 
func BenchmarkVectorAdd(b *testing.B) { 
   r := rand.New(rand.NewSource(time.Now().UnixNano())) 
   for i := 0; i < b.N; i++ { 
         v1 := New(r.Float64(), r.Float64()) 
         v2 := New(r.Float64(), r.Float64()) 
         v1.Add(v2) 
   } 
} 

golang.fyi/ch12/vector/vec_bench_test.go

Go 的测试运行时通过注入指针 *testing.B 作为参数来调用基准函数。该值定义了与基准框架交互的方法,例如日志记录、失败信号和其他类似于 testing.T 类型的功能。类型 testing.B 还提供了额外的基准特定元素,包括一个整数字段 N。它打算用作基准函数应使用的迭代次数,以进行有效的测量。

被基准测试的代码应放置在由 N 界定的 for 循环内,如前一个示例所示。为了使基准测试有效,循环每次迭代的输入大小不应有差异。例如,在先前的基准测试中,每次迭代始终使用大小为 2 的向量(而向量的实际值是随机化的)。

运行基准测试

基准函数只有在测试命令行工具接收到 -bench 标志时才会执行。以下命令运行当前包中的所有基准函数:

$> go test -bench=.
PASS
BenchmarkVectorAdd-2           2000000           761 ns/op
BenchmarkVectorSub-2           2000000           788 ns/op
BenchmarkVectorScale-2         5000000           269 ns/op
BenchmarkVectorMag-2           5000000           243 ns/op
BenchmarkVectorUnit-2          3000000           507 ns/op
BenchmarkVectorDotProd-2       3000000           549 ns/op
BenchmarkVectorAngle-2         2000000           659 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     14.123s

在分析基准测试结果之前,让我们了解之前发出的命令。go test -bench=. 命令首先执行包中的所有测试函数,然后是所有基准函数(你可以通过在命令中添加详细标志 -v 来验证这一点)。

-run 标志类似,-bench 标志指定了一个正则表达式,用于选择要执行的基准函数。-bench=. 标志匹配所有基准函数的名称,如前例所示。然而,以下仅运行名称中包含模式 "VectorA" 的基准函数。这包括 BenchmarkVectorAngle()BenchmarkVectorAngle() 函数:

$> go test -bench="VectorA"
PASS
BenchmarkVectorAdd-2     2000000           764 ns/op
BenchmarkVectorAngle-2   2000000           665 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     4.396s

跳过测试函数

如前所述,当执行基准测试时,测试工具也会运行所有测试函数。这可能不是所希望的,尤其是如果你在包中有大量测试时。在基准执行期间跳过测试函数的一个简单方法是将 -run 标志设置为与任何测试函数都不匹配的值,如下所示:

> go test -bench=. -run=NONE -v
PASS
BenchmarkVectorAdd-2           2000000           791 ns/op
BenchmarkVectorSub-2           2000000           777 ns/op
...
BenchmarkVectorAngle-2         2000000           653 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     14.069s

之前的命令仅执行基准函数,如部分详尽输出所示。-run 标志的值完全是任意的,可以设置为任何会导致它跳过测试函数执行的值。

基准报告

与测试不同,基准报告总是详尽的,并显示多个列的指标,如下所示:

$> go test -run=NONE -bench="Add|Sub|Scale"
PASS
BenchmarkVectorAdd-2     2000000           800 ns/op
BenchmarkVectorSub-2     2000000           798 ns/op
BenchmarkVectorScale-2   5000000           266 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     6.473s

第一列包含基准函数的名称,每个名称后跟一个数字,该数字反映了 GOMAXPROCS 的值,该值可以在测试时使用 -cpu 标志设置(适用于并行运行基准测试)。

下一个列显示每个基准循环的迭代次数。例如,在上一个报告中,前两个基准函数循环了 200 万次,而最后一个基准函数迭代了 500 万次。报告的最后一列显示了执行测试函数的平均时间。例如,在基准函数 BenchmarkVectorScale 中执行的 Scale 方法的 500 万次调用平均花费了 266 纳秒来完成。

调整 N

默认情况下,测试框架会逐渐调整 N 以确保在 一秒 内获得稳定且有意义的指标。您不能直接更改 N。但是,您可以使用 -benchtime 标志来指定基准运行时间,从而影响基准期间的迭代次数。例如,以下命令运行基准测试了 5 秒:

> go test -run=Bench -bench="Add|Sub|Scale" -benchtime 5s
PASS
BenchmarkVectorAdd-2    10000000           784 ns/op
BenchmarkVectorSub-2    10000000           810 ns/op
BenchmarkVectorScale-2  30000000           265 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     25.877s

注意,尽管每个基准的迭代次数(五倍或更多)有剧烈的跳跃,但每个基准函数的平均性能时间仍然保持合理的一致性。这些信息为您提供了关于代码性能的宝贵见解。这是观察代码或负载变化对性能影响的好方法,如以下章节所述。

比较基准

基准测试代码的另一个有用方面是对比实现类似功能的不同算法的性能。使用性能基准测试算法将表明哪些实现可能更计算和内存高效。

例如,如果两个向量具有相同的幅度和相同的方向(或者它们之间有一个零角度值),则称这两个向量相等。我们可以使用以下源代码片段来实现这个定义:

const zero = 1.0e-7  
... 
func (v SimpleVector) Eq(other Vector) bool { 
   ang := v.Angle(other) 
   if math.IsNaN(ang) { 
         return v.Mag() == other.Mag() 
   } 
   return v.Mag() == other.Mag() && ang <= zero 
} 

golang.fyi/ch12/vector/vec.go

当前面的方法进行基准测试时,它产生了以下结果。它的每 300 万次迭代平均运行时间为半个毫秒:

$> go test -run=Bench -bench=Equal1
PASS
BenchmarkVectorEqual1-2  3000000           454 ns/op
ok    github.com/vladimirvivien/learning-go/ch12/vector     1.849s

基准测试结果并不差,特别是与之前看到的其他基准测试方法相比。然而,如果我们想提高 Eq 方法的性能(可能是因为它是程序的一个关键部分),我们可以使用 -benchmem 标志来获取有关基准测试测试的更多信息:

$> go test -run=bench -bench=Equal1 -benchmem
PASS
BenchmarkVectorEqual1-2  3000000 474 ns/op  48 B/op  2 allocs/op

-benchmem 标志会导致测试工具显示两列额外的信息,这些信息提供了内存分配的指标,如前一个输出所示。我们看到 Eq 方法总共分配了 48 字节,每个操作有两次分配调用。

在我们没有其他东西可以与之比较之前,这并没有告诉我们太多。幸运的是,还有一个我们可以尝试的相等性算法。这个算法基于这样一个事实:如果两个向量具有相同数量的元素,并且每个元素都相等,那么这两个向量也是相等的。这个定义可以通过遍历向量并比较其元素来实现,如下面的代码所示:

func (v SimpleVector) Eq2(other Vector) bool { 
   v.assertLenMatch(other) 
   otherVec := other.(SimpleVector) 
   for i, val := range v { 
         if val != otherVec[i] { 
               return false 
         } 
   } 
   return true 
} 

golang.fyi/ch12/vector/vec.go

现在我们来基准测试 EqEq2 相等性方法,看看哪个更高效,如下所示:

$> go test -run=bench -bench=Equal -benchmem
PASS
BenchmarkVectorEqual1-2   3000000   447 ns/op   48 B/op   2 allocs/op
BenchmarkVectorEqual2-2   5000000   265 ns/op   32 B/op   1 allocs/op

根据基准报告,方法 Eq2 在两个相等性方法中表现更优。它运行时间大约是原始方法的一半,分配的内存也少得多。由于这两个基准测试都使用了类似的数据输入,我们可以有信心地说第二个方法比第一个方法更好。

注意

根据 Go 版本、机器大小和架构的不同,这些基准测试数字会有所变化。然而,结果总是显示 Eq2 方法更高效。

这段讨论只是比较基准测试的表面。例如,之前的基准测试使用相同大小的输入。有时观察输入大小变化时的性能变化是有用的。我们可以比较相等性方法在输入大小从 3、10、20 或 30 个元素变化时的性能曲线。如果算法对大小敏感,使用这样的属性扩展基准测试将揭示任何瓶颈。

摘要

本章提供了对 Go 语言中编写测试实践的广泛介绍。它讨论了几个关键主题,包括使用go test工具来编译和执行自动化测试。读者学习了如何编写测试函数以确保他们的代码得到适当的测试和覆盖。本章还讨论了测试 HTTP 客户端和服务器的话题。最后,本章介绍了基准测试作为使用内置 Go 工具自动化、分析和衡量代码性能的一种方法。

第二部分. 第二模块

Go 设计模式

通过使用 TDD 学习地道的、高效的、简洁且可扩展的 Go 设计模式和并发模式

第一章。准备... 稳定... 开始!

设计模式一直是数十万件软件的基础。自从 1994 年四人帮(Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides)编写了《设计模式:可复用面向对象软件元素》一书,并在 C++和 Smalltalk 中提供了示例以来,二十三个经典模式已经在大多数主要语言中重新实现,并且它们几乎被用于你了解的每一个项目中。

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

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

但 Go 是什么...?

一点历史

在过去的 20 年里,我们在计算机科学领域经历了令人难以置信的增长。存储空间大幅增加,RAM 经历了实质性的增长,CPU... 好吧... 简单来说就是更快。它们的增长是否与存储和 RAM 内存一样大?实际上并非如此,CPU 行业在 CPU 可以提供速度方面已经达到了极限,主要是因为它们变得如此之快,以至于在散发热量的同时无法获得足够的能量来工作。CPU 制造商现在在每个计算机上提供更多的核心。这种情况与许多系统编程语言的设计背景相冲突,这些语言并非为多处理器 CPU 或作为单一机器运作的大规模分布式系统而设计。在谷歌,当他们在 Java 或 C++等非为并发设计的语言中努力开发分布式应用程序时,他们意识到这已经不仅仅是一个问题。

同时,我们的程序更大、更复杂、更难以维护,并且有很多坏习惯的空间。虽然我们的计算机有更多的核心和更快的速度,但我们在编写代码或分布式应用程序时并没有更快。这是 Go 的目标。

Go 的设计始于 2007 年,当时三位谷歌工程师在研究一种可以解决像谷歌那样的大规模分布式系统中常见问题的编程语言。创造者包括:

  • Rob Pike:Plan 9 和 Inferno OS。

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

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

2008 年,编译器完成,团队得到了 Russ Cox 和 Ian Lance Taylor 的帮助。2009 年,团队开始开源项目,并在 2012 年 3 月经过超过五十次发布后达到了 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后,点击下载 Go按钮(通常在右侧),每个发行版都有一些特色下载选项。选择Linux发行版以下载最新稳定版本。

注意

golang.org您还可以下载语言的测试版。

假设我们已经将tar.gz文件保存在下载文件夹中,所以让我们将其解压并移动到不同的路径。按照惯例,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。一旦到达那里,点击下载 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,然后点击下载 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 中,转到程序,找到一个名为终端的程序。

  • 在 Windows 中,按 Windows + R,在新窗口中输入不带引号的cmd,然后按Enter

  • 在 Mac OS X 中,按 Command + Space 打开 spotlight 搜索,输入不带引号的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 成为一个非常好的编辑器,具有自动完成、调试、重构、测试、可视化覆盖、检查等功能。以下是一些常见的 IDE 或文本编辑器,它们具有 Go 插件/集成:

  • 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 是有符号整数类型,因此 int 类型在 32 位机器上表示从 -2147483648 到 2147483647 的数字。

    • 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 语言保留:

运算符

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

  • +运算符用于求和

  • -运算符用于减法

  • *运算符用于乘法

  • /运算符用于除法

  • %运算符用于除法余数

  • ++运算符用于将当前变量的值加 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) 满足的情况下执行括号内的代码。最后,对于每次执行,我们将 1 添加到 i 的值上。这段代码将打印从 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函数,它将返回所有参数的总和,但请更仔细地看看我们调用summain函数。正如你所看到的,我们首先用三个参数调用sum,然后又用五个参数调用。对于sum函数来说,你传递多少个参数并不重要,因为它将其参数视为一个整体数组。因此,在我们的sum定义中,我们只是简单地遍历数组,将每个数字加到result整数上。

返回类型的命名

你有没有意识到我们已经给返回类型起了一个名字?通常,我们的声明会被写成func sum(args int) int,但你也可以给函数内部用作返回值的变量命名。在返回类型中命名变量也会将其初始化为零值(在这种情况下,int将被初始化为零)。最后,你只需要返回函数(不带值),它将从作用域中获取相应的变量作为返回值。这也使得跟踪返回变量所遭受的修改变得更容易,以及确保你没有返回一个被修改的参数。

数组、切片和映射

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

数组

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

你必须在创建数组时声明其长度以及类型。你还可以在创建时分配一些值。例如,这里你有 100 个int值,它们的值都是0

var arr [100]int 

或者一个已分配strings的 3 大小数组:

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

这里有一个我们稍后初始化的 2 个bool值的数组:

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

零初始化

在我们之前的例子中,我们已经初始化了一个大小为2bool值数组。由于语言中零初始化的特性,我们不需要将arr[1]赋值为false。Go 将初始化bool数组中的每个值都为false。我们将在本章后面更深入地探讨零初始化。

切片

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

mySlice := make([]int, 10) 

这个命令创建了一个包含十个元素的底层数组。如果我们需要通过例如添加一个新数字来改变切片的大小,我们将数字追加到切片中:

mySlice := append(mySlice, 5) 

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

mySlice := mySlice[1:] 

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

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

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

映射

映射就像字典一样--对于每个单词,我们都有一个定义,但我们可以使用任何类型作为单词或定义,并且它们永远不会按字母顺序排序。我们可以创建指向数字、指向interfacesstructs指向int以及int指向function的字符串映射。你不能使用切片、函数和映射作为键。最后,你通过使用关键字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 账户或其他任何并发版本系统(CVS)仓库的 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 语言中的对象什么,不如说你的对象其他。因此,你不必有一个继承自vehicle类(汽车是一种车辆)的car结构,而可以有一个包含car结构的vehicle结构。

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

指针既受憎恨,又受喜爱,同时也非常有用。理解指针是什么可能很困难,所以让我们用一个现实世界的解释来尝试。正如我们在本章前面提到的,指针就像一个邮箱。想象一下一栋楼里的一堆邮箱;它们都有相同的大小和形状,但每个都指向楼内的不同房子。仅仅因为所有邮箱大小相同,并不意味着每个房子的大小都相同。我们甚至可以有两个房子相连,一个房子曾经在那里但现在有了商业许可证,或者一个完全空着的房子。所以,指针就像是邮箱,它们大小相同,但都指向一个房子。这栋楼是我们的内存,房子是我们指针指向的类型以及它们分配的内存。如果你想在你家里收到东西,简单地发送你房子的地址(发送指针)要比发送整个房子容易得多,这样你的包裹就可以存放在里面。但它们也有一些缺点,比如如果你发送了你的地址和你的房子(它所指向的变量),发送后你的房子(变量)消失了,或者它的类型所有者改变了——你将陷入麻烦。

这有什么用呢?想象一下,你有一个变量中有 4 GB 的数据,你需要将它传递给另一个函数。如果没有指针,整个变量将被克隆到将要使用它的函数的作用域中。所以,你会占用 8 GB 的内存,使用这个变量两次,希望第二个函数不会再次在另一个函数中使用,以增加这个数字。

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

虽然这不是最学术或最精确的解释,但它给出了一个很好的指针概念,而不必解释栈或堆是什么,或者它们在 x86 架构中是如何工作的。

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

Go 中的指针可以声明如下:

number := 5 

这里 number := 5 代码代表我们的 4 GB 变量,pointer_to_number 包含对这个变量的引用(用 ampersand 表示),这是指向变量(你放在这个“房子/类型/变量”邮箱里的那个)的方向。让我们打印变量 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 
} 

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

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

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

接口

接口在面向对象编程、函数式编程(特性)和,尤其是,在设计模式中是必不可少的。Go 的源代码到处都是接口,因为它们提供了通过函数帮助实现解耦代码所需的抽象。作为程序员,当你编写库或编写将来需要添加新功能的代码时,你也需要这种类型的抽象。

接口在开始时可能难以理解,但一旦你了解了它们的行为,就能提供非常优雅的解决方案来解决常见问题。在这本书中,我们将广泛使用它们,所以请特别关注这一部分。

接口 - 签订合同

接口实际上非常简单但功能强大。它通常被定义为实现它的对象之间的合同,但在我看来,这种解释对于初学者来说还不够清晰。

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

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

type RailroadWideChecker interface { 
    CheckRailsWidth() int 
} 

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

type Railroad struct { 
    Width int 
} 

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

Railroad是通过一个包含有关该站铁路宽度信息的虚拟站对象实现的,并且有一个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个单位。然后创建了两个火车,一个是10个单位宽的客运火车,另一个是15个单位宽的货运火车。然后,我们将这两个对象传递给接受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函数之前所做的那样)。

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 函数来使用它,因为库不能直接转换为可执行文件。我们的 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子句。它正在从其文件夹中获取我们编写的库,该文件夹位于$GOPATH中,与bitbucket.org/中的 URL 相匹配。然后,为了使用库中定义的每个函数,你必须在每个方法之前命名库的包名。

注意

你是否意识到我们使用的是大写命名的函数?由于我们之前看到的可见性规则,包中的导出函数必须具有大写名称,否则它们将不会在包的作用域之外可见。因此,考虑到这个规则,你无法在包内调用小写命名的函数或变量,并且包调用将始终跟随大写名称。

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

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

  • 一个文件夹代表库中的一个包名。文件夹名称将在导入路径中使用,并且不需要反映包名(尽管建议对父包使用包名)。

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

  • 你通过包名来调用库中的内容。

Go get 工具

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

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

$ go get github.com/coreos/etcd

注意我们刚刚输入了最相关的信息,以便 Go get 能够找出其余的信息。根据项目状态,你会得到一些输出,但之后,while,它将消失。但发生了什么?

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

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

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

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

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

import "github.com/coreos/etcd" 

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

管理 JSON 数据

JSON 是JavaScript 对象表示法的缩写,正如其名所示,它是 JavaScript 的本地格式。它已经变得非常流行,并且是今天通信中最常用的格式。Go 语言通过JSON包提供了非常好的 JSON 序列化/反序列化支持,该包为你做了大部分脏活。首先,当与 JSON 一起工作时,有两个概念需要学习:

  • 序列化:当你序列化结构体或对象的实例时,你正在将其转换为它的 JSON 对应物。

  • 反序列化:当你以字节数组的形式反序列化一些数据时,你正在尝试将一些 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命令与我们的新变量。这将返回一个新的bytearray,其中包含 JSON 和一个标志,以提供boolOK操作结果。当我们打印字节数组的内容(之前的字符串转换)时,预期的值就会出现。注意,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

main.go:5:6:导出的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 项目时引入了一个小问题。我们习惯于分叉一个项目,克隆我们的分叉并开始工作,在将拉取请求提交到原始项目之前。错误!

当你分叉一个项目时,你会在 GitHub 中以你的用户名创建一个新的仓库。如果你克隆这个仓库并开始工作,项目中所有新的导入引用都将指向你的仓库,而不是原始仓库!想象一下原始仓库中的以下情况:

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

然后,你创建一个分支,并添加一个名为 a_library/my_library 的子文件夹,其中包含你想要从主包中使用的库。结果将是以下内容:

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

现在如果你提交这一行,包含你已推送代码的原始仓库仍然会从你的账户再次下载这段代码,并使用下载的引用!而不是项目中的那些引用!

因此,这个问题的解决方案很简单,只需将 git clone 命令替换为指向原始库的 go get

$ 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 网页用户界面并打开拉取请求,而不会将你账户的引用污染到实际原始代码中。

摘要

在这一章之后,你必须熟悉 Go 语言的语法以及与编译器捆绑的一些命令行工具。我们将并发能力留到后面的章节,因为它们很大,而且一开始理解起来相当复杂,这样读者可以先学习语言的语法,熟悉并对其有信心,然后他们可以跳到理解通信顺序过程CSP)并发模式和分布式应用。下一步是从创建型设计模式开始。

第二章. 创建型模式 - 单例、建造者、工厂、原型和抽象工厂设计模式

我们定义了两种类型的汽车——豪华型和家庭型。汽车工厂必须返回第一组我们将要介绍的设计模式是创建型模式。正如其名所示,它将创建对象的常见实践分组,因此对象创建对需要这些对象的用户来说更加封装。主要来说,创建型模式试图为用户提供现成的对象,而不是要求他们创建,这在某些情况下可能是复杂的,或者这可能会使你的代码与应该在接口中定义的功能的具体实现耦合。

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

你是否曾经为软件工程师做过面试?有趣的是,当你问他们关于设计模式时,超过 80%的人会提到单例设计模式。这是为什么?也许是因为它是使用最广泛的设计模式之一,或者是最容易理解的一种。我们之所以从创建型设计模式开始,正是因为它易于理解。

描述

单例模式易于记忆。正如其名所示,它将为你提供一个对象的单一实例,并保证没有重复。

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

你会在许多不同的场景中使用单例模式。例如:

  • 当你想使用相同的连接到数据库进行每个查询时

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

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

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

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

目标

作为一般指南,我们认为当以下规则适用时,我们会使用单例模式:

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

  • 我们需要在整个程序中限制某些类型的对象创建到单个单元。

示例 - 一个唯一的计数器

作为必须确保只有一个实例的对象的例子,我们将编写一个在程序执行期间记录被调用次数的计数器。它不应该在乎我们有多少个计数器实例,它们都必须计数相同的值,并且必须在实例之间保持一致。

要求和验收标准

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

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

  • 如果计数器已经创建,则返回包含实际计数的这个实例

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

在我们的单元测试中,我们有一个包含三个测试的场景来检查。

首先编写单元测试

Go 语言实现这种模式的方式与你在像 Java 或 C++这样的纯面向对象语言中找到的略有不同,在这些语言中你有静态成员。在 Go 中,没有类似静态成员的东西,但我们有包作用域来达到类似的效果。

为了设置我们的项目,我们必须在$GOPATH/src目录内创建一个新的文件夹。我们之前提到的通用规则是,创建一个子文件夹,包含 VCS 提供者(如 GitHub)、用户名和项目名称。

例如,在我的情况下,我使用 GitHub 作为我的 VCS,我的用户名是sayden,所以我将创建路径$GOPATH/src/github.com/sayden/go-design-patterns/creational/singleton。路径中的go-design-patterns实例是项目名称,创建型子文件夹也将是我们的库名称,而 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 实例必须不同将导致测试失败:

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) 
} 

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

$ 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这一行定义了一个指向 Singleton 类型结构体的指针,并命名为instance

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

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

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

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

关于单例设计模式的简要介绍

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

使用单例,你还可以隐藏创建对象的复杂性,如果它需要一些计算,以及每次需要该实例时都创建它的陷阱,如果它们都是相似的。所有这些代码编写、检查变量是否已存在以及存储的工作都被封装在单例中,如果你使用全局变量,你就不需要重复它。

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

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

谈到 创建型 设计模式,有一个 Builder 设计模式看起来非常语义化。Builder 模式帮助我们构建复杂对象,而不直接实例化它们的 struct,或者编写它们所需的逻辑。想象一个可能拥有几十个字段的对象,这些字段本身也是更复杂的 struct。现在想象一下,你有许多具有这些特征的对象,并且可能还有更多。我们不想在只需要使用对象的包中编写创建所有这些对象的逻辑。

描述

实例创建可以像提供开闭花括号 {} 并将实例留为零值那样简单,也可以像需要执行一些 API 调用、检查状态并为其实例字段创建对象的对象那样复杂。你也可以有一个由许多对象组成的对象,这在 Go 中非常地道,因为它不支持继承。

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

目标

建造者设计模式试图:

  • 抽象复杂的创建,以便对象创建与对象使用者分离

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

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

示例 - 车辆制造

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

需求和验收标准

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

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

  • 当使用汽车 Builder 时,必须返回具有四个轮子、五个座位以及定义为 Car 的结构的 VehicleProduct

  • 当使用摩托车 Builder 时,必须返回具有两个轮子、两个座位以及定义为 Motorbike 的结构的 VehicleProduct

  • 由任何 BuildProcess Builder 构建的 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

实现

我们将开始实现制造。正如我们之前所说的(以及我们在单元测试中设置的),Manufacturing导演必须接受一个构建者并使用提供的构建者构建一个车辆。为了回忆,BuildProcess接口将定义构建任何车辆所需的共同步骤,并且Manufacturing导演必须接受构建者并与他们一起构建车辆:

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() 
} 

我们的ManufacturingDirector需要一个字段来存储正在使用的构建者;这个字段将被称为builderSetBuilder方法将用提供的参数中的构建者替换存储的构建者。最后,更仔细地看看Construct方法。它接受存储的构建者并重现创建某种未知类型的完整车辆的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 
} 

这里是我们的第一个构建者,car构建者。构建者需要存储一个VehicleProduct对象,在这里我们将其命名为v。然后我们设置汽车在业务中具有的特定需求——四个车轮、五个座位和一个定义为Car的结构。在GetVehicle方法中,我们只需返回构建者中存储的VehicleProduct,它必须已经被ManufacturingDirector类型构建。

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 
} 

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

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

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

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

干得好!想想看,向ManufacturingDirector导演添加新车辆是多么容易,只需创建一个封装新车辆数据的新的类。例如,让我们添加一个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 
} 

就这些;你的ManufacturingDirector将准备好通过遵循 Builder 设计模式使用新产品。

总结 Builder 设计模式

Builder 设计模式通过使用导演使用的通用构建算法,帮助我们维护不可预测数量的产品。构建过程始终从产品的用户那里抽象出来。

同时,当我们源代码的新手需要向流水线添加新产品时,有一个定义好的构建模式很有帮助。BuildProcess接口指定了他必须遵守的规范才能成为可能的构建者之一。

然而,当你不确定算法是否将更加或更少稳定时,尽量避免使用 Builder 模式,因为任何对这个接口的微小更改都将影响所有构建者,如果你添加一个某些构建者需要而其他构建者不需要的新方法,可能会很尴尬。

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

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

描述

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

目标

在之前的描述之后,以下关于工厂方法设计模式的目标应该对你来说已经很清晰了:

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

  • 在接口级别而不是具体实现级别上工作

  • 将对象家族分组以获得家族对象创建器

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

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

验收标准

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

  • 为每个支付方式提供一个名为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方法时,我们会在下一行遇到问题,我们的测试将崩溃执行。我们继续使用接口的Pay方法,通过传递 10.30 作为金额。返回的消息将必须包含文本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

现在,我们没有收到找不到支付方式类型错误的消息。相反,当它尝试使用它覆盖的任何方法时,我们收到一个message not correct错误。我们还去掉了在请求未知支付方式时返回的Not implemented消息。现在让我们实现结构体:

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 中隐式接口的力量,因为我们几乎不需要做任何修改。最后,我们将创建一个新的工厂来创建运输订单。

接受标准

以下是用Vehicle对象的工厂方法使用时的接受标准:

  • 我们必须使用由抽象工厂返回的工厂来检索一个Vehicle对象。

  • 车辆必须是一个具体实现MotorbikeCar的实例,该实例实现了两个接口(VehicleCarVehicleMotorbike)。

单元测试

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

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

    • Motorbike:一个用于运动型(一个座位)和巡航型(两个座位)摩托车的接口。

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

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

    • Motorbike 工厂:一个实现VehicleFactory接口的工厂,用于返回实现VehicleMotorbike接口的车辆。

    • Car 工厂:另一个实现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)) 
    } 
} 

我们已定义了两种类型的汽车——豪华型和家庭型。汽车工厂必须返回实现 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%的语句。对于读者来说,完成这些测试将是一个很好的练习。首先,一个摩托车工厂测试:

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)motorbikeVehicle 表示的摩托车,我们必须检查我们收到的类型是否正确。

最后,现在我们有一个摩托车实例,我们可以通过使用 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") 
} 

现在我们需要一个对象结构来克隆,该结构实现了一个用于检索其字段信息的接口。我们将把这个对象称为Shirt,以及ItemInfoGetter接口:

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函数返回的 nil 对象时崩溃。

实现

我们将从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。我们还可以看到两个对象的内存位置。请注意,你电脑上显示的位置可能会不同。

我们关于原型设计模式学到的内容

原型模式是构建缓存和默认对象的有力工具。你可能也已经意识到,某些模式之间可能存在一些重叠,但它们之间的小差异使得它们在某些情况下更为合适,而在其他情况下则不太合适。

摘要

我们已经看到了在软件行业中常用的五种主要创建型设计模式。它们的目的在于从用户的角度抽象出对象的创建,以简化复杂性或提高可维护性。自 1990 年代以来,它们一直是数千个应用程序和库的基础,我们今天使用的许多软件都包含这些创建型模式。

值得注意的是,这些模式不是线程安全的。在更高级的章节中,我们将看到 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结构

  • 我们必须有一个具有与Swimmer共享的Swim方法的Fish结构,并且没有继承或层次问题

创建组合

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

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

type Athlete struct{} 

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

之前的代码相当简单。它的Train方法打印出单词Training和一行新内容。我们将创建一个包含Athlete结构的复合泳者:

type CompositeSwimmerA struct{ 
  MyAthlete Athlete 
  MySwim func() 
} 

CompositeSwimmerA类型有一个类型为AthleteMyAthlete字段。它还存储一个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类型的字段名在哪里?你意识到我在上一段中使用了嵌入这个词吗?这是因为,在 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 中使用组合设计模式时,你必须非常小心,不要将其与继承混淆。例如,当你将Parent结构体嵌入到Son结构体中时,如下例所示:

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 中,适配器将允许我们使用最初并非为特定任务构建的东西。

描述

当例如接口过时,无法轻松或快速替换时,适配器模式非常有用。相反,你可以创建一个新的接口来处理应用程序的当前需求,而实际上,它使用的是旧接口的实现。

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

注意

开放/封闭原则最初由伯特兰·梅耶在他的书《面向对象软件构造》中提出。他提出,代码应该对新功能开放,但对修改封闭。这意味着什么?嗯,它暗示了几件事情。一方面,我们应该尝试编写可扩展的代码,而不仅仅是能工作的代码。同时,我们应该尽量少修改源代码(无论是你的还是别人的),因为我们并不总是意识到这种修改的后果。只需记住,代码的可扩展性只有通过使用设计模式和面向接口的编程才能实现。

目标

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

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

对于我们的示例,我们将有一个旧的 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

完美!现在我们可以在使用ModernPrinter接口进行未来实现的同时,仍然使用旧的LegacyPrinter接口通过这个Adapter。只需记住,适配器模式理想上只应提供使用旧的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) 
}

现在我们提供一个 io.WriterWriter 字段。这样,我们可以创建计数器,例如:c := Counter{os.Stdout},我们将会得到一个控制台 Writer。但是等等,我们还没有解决我们想要将计数传递到多个 Writer 控制台的问题。但是我们可以写一个新的 Adapter,带有 io.Writer,并使用 Pipe() 连接一个读取器和一个写入器,我们可以在相反的极端读取。这样,你可以解决这两个接口,ReaderWriter,不兼容的问题,使它们可以一起使用。

实际上,我们不需要编写 Adapter——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,你仍然可以使用它再次将数据流到一个第二个写入器。所以我们将数据从同一个读取器流到两个写入器——文件和 Stdout

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 实现

  • 一个具有Print方法的Printer抽象,用于实现打印类型

  • 一个实现PrinterPrinterAPI接口的normal打印机对象

  • normal打印机将直接将消息转发到实现

  • 一个实现Printer抽象和PrinterAPI接口的Packt打印机

  • 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是一个实现PrinterAPI接口的类型,它通过提供PrintMessage方法的实现来实现。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()) 
  } 
} 

在我们的测试中覆盖PrintAPI1时,我们创建了一个PrinterImpl1类型的实例。然后我们使用它的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接口,我们将创建一个实现此接口的测试对象,并将写入的内容存储在本地字段中。这将帮助我们检查通过 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) 
    } 
  } 

让我们在这里暂停一下。在前面的代码的第一行中,我们创建了一个名为PrinterImpl2的实例,称为api2。我们故意没有传递任何io.Writer实例,因此我们也检查了我们是否首先收到一个错误。然后我们尝试使用它的PrintMessage方法,但我们必须得到一个错误,因为它在Writer字段中没有存储任何io.Writer实例。错误必须是您需要向 PrinterImpl2 传递一个 io.Writer,并且我们隐式地检查了错误的正文。让我们继续进行测试:

  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而没有存储io.Writer实例时是否返回错误消息(任何错误)。其他所有测试都未通过,正如预期在这个阶段。

现在我们需要一个可以用于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结构来检查消息的内容。所以,简而言之,我们想要一个接受类型为字符串的Msg和类型为PrinterAPIPrinterNormalPrinter结构。在这个时候,如果我使用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 字段的值,如果没有存储任何内容,则返回预期的错误消息 **您需要向 PrinterImpl2 传递一个 io.Writer**。这是我们将在测试中稍后检查的消息。然后,fmt.Fprintf 方法将 io.Writer 接口作为第一个字段,将格式化的消息作为其余部分,所以我们只需将 msg 参数的内容转发给提供的 io.Writer

$ go test -v -run=TestPrintAPI2 .
=== RUN   TestPrintAPI2
--- PASS: TestPrintAPI2 (0.00s)
PASS
ok

现在,我们将继续使用正常的打印机。这个打印机必须简单地转发消息到存储的 PrinterAPI 接口,没有任何修改。在我们的测试中,我们使用了 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方法中一样,我们在Printer字段中接受了一个Msg字符串和一个PrinterAPI实现。然后我们使用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将文本Message from Packt: Hello io.Writer写入stdout的结果。这个最后的测试应该覆盖我们桥接模式中的所有代码。正如你之前看到的,你可以使用-cover标志来检查覆盖率:

$ go test -cover .
ok      
2.622s  coverage: 100.0% of statements

哇!100%覆盖率——这看起来不错。然而,这并不意味着代码是完美的。我们还没有检查消息的内容是否为空,这可能是一些应该避免的事情,但这不是我们的要求的一部分,这也是一个重要的观点。仅仅因为某个功能不在要求或验收标准中,并不意味着它不应该被覆盖。

使用桥接模式重用一切

通过桥接模式,我们学习了如何解耦对象及其PrintMessage方法的实现。这样,我们可以重用其抽象以及其实现。我们可以像我们想要的那样交换打印抽象以及打印 API,而不会影响用户代码。

我们也试图尽可能保持简单,但我确信你已经意识到,所有PrinterAPI接口的实现都可以使用工厂来创建。这将是非常自然的,你可能会找到许多遵循这种方法的实现。然而,我们不应该过度设计,而应该分析每个问题,精确地设计其需求,并找到创建可重用、可维护和可读源代码的最佳方式。可读的代码常常被忽视,但如果没有人能够理解它来维护它,那么健壮且解耦的源代码就毫无用处。它就像十世纪的书籍——如果它是一个珍贵的故事,但如果我们难以理解它的语法,那么它就会非常令人沮丧。

摘要

我们在本章中看到了组合的力量,以及 Go 如何利用其本质的优势。我们看到了适配器模式可以通过在中间使用Adapter对象来帮助我们使两个不兼容的接口协同工作。同时,我们也看到了 Go 源代码中的真实例子,语言创造者使用这种设计模式来提高标准库中某些特定部分的可行性。最后,我们看到了桥接模式及其可能性,它允许我们创建具有完全可重用性的交换结构,在对象及其实现之间。

此外,我们在整章中使用了组合设计模式,不仅是在解释它的时候。我们之前也提到过,设计模式之间经常相互使用。我们使用纯组合而非内嵌来提高可读性,但正如你所学的,你可以根据需要相互交替使用。在接下来的章节中,我们将继续使用组合模式,因为它是构建 Go 编程语言中关系的基础。

第四章:结构型模式 - 代理、外观、装饰器和享元设计模式

通过本章,我们将完成结构型模式的学习。我们留了一些最复杂的模式到后面,这样你可以更习惯设计模式的机制,以及 Go 语言的特点。

在本章中,我们将编写一个用于访问数据库的缓存、一个收集天气数据的库、一个具有运行时中间件的服务器,并讨论一种通过在类型值之间保存可共享状态来节省内存的方法。

代理设计模式

我们将以代理模式开始结构型模式的最后一章。这是一个简单的模式,只需付出很少的努力就能提供有趣的功能和可能性。

描述

代理模式通常封装一个对象以隐藏其一些特性。这些特性可能包括它是一个远程对象(远程代理)、一个非常重的对象,如非常大的图像或兆字节的数据库转储(虚拟代理),或者一个受限访问的对象(保护代理)。

目标

代理模式的可能性很多,但总的来说,它们都试图提供以下相同的功能:

  • 在代理后面隐藏一个对象,以便可以隐藏、限制等功能。

  • 提供一个易于工作的新抽象层,并且可以轻松更改。

示例

对于我们的示例,我们将创建一个远程代理,它将成为访问数据库前的对象缓存。让我们想象我们有一个包含许多用户的数据库,但每次我们想要获取用户信息时,我们不会直接访问数据库,而是将用户信息存储在一个代理模式中的先进先出FIFO)堆栈中(FIFO 是一种表示当缓存需要清空时,它将删除最先进入的第一个对象的方式)。

接受标准

我们将使用我们的代理模式封装一个虚拟的数据库,用一个切片表示,然后该模式必须遵守以下接受标准:

  1. 所有对用户数据库的访问都将通过代理类型进行。

  2. n个最近用户堆叠保存在代理中。

  3. 如果用户已经在堆叠中存在,它将不会查询数据库,而是返回存储的记录。

  4. 如果查询的用户不在堆栈中,它将查询数据库,如果堆栈已满,则删除最旧的用户,存储新的用户,并返回它。

单元测试

自 Go 1.7 版本以来,我们可以通过使用闭包在测试中嵌入测试,这样我们可以以更易于阅读的方式对它们进行分组,并减少Test_函数的数量。请参阅第一章,*准备... 稳定... 开始!*了解如果当前版本低于 1.7 版本,如何安装 Go 的新版本。

此模式的类型将是代理用户和用户列表结构以及数据库和 Proxy 将实现的 UserFinder 接口。这是关键,因为代理必须实现与它试图包装的类型相同的接口:

type UserFinder interface { 
  FindUser(id int32) (User, error) 
} 

UserFinder 是数据库和 Proxy 实现的接口。User 是一个具有名为 ID 的成员的类型,其类型为 int32

type User struct { 
  ID int32 
} 

最后,UserList 是用户切片的类型。考虑以下语法:

type UserList []User 

如果你问为什么我们不直接使用用户切片,答案是:通过这种方式声明用户序列,我们可以实现 UserFinder 接口,但如果我们使用切片,则不能。

最后,Proxy 类型,称为 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}) 
  } 

前面的测试创建了一个包含一百万个随机名称的用户列表。为此,我们通过调用带有某些常量种子的 Seed() 函数来喂养随机数生成器,这样我们的随机结果也是恒定的;用户 ID 就是根据这个生成的。可能会有一些重复,但这满足了我们的目的。

接下来,我们需要一个具有对 someDatabase 的引用的代理,这是我们刚刚创建的:

proxy := UserListProxy{ 
  SomeDatabase:  &someDatabase, 
  StackCapacity:  2, 
  StackCache: UserList{}, 
} 

到目前为止,我们有一个由一百万个用户组成的模拟数据库和一个大小为 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. Then we define our closure. First it tries to find a user with a known ID, and checks for errors. As the description implies, the cache is empty at this point, and the user will have to be retrieved from the someDatabase array:
  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 参数相同的用户,如果找不到,则返回错误。

你可能想知道为什么指针 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方法来帮助。因此,现在在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 类型(装饰者),我们将向其中添加成分。

  • 我们必须有一个名为 "onion" 的成分实现相同的 IngredientAdd 接口,该接口将字符串 onion 添加到返回的披萨中。

  • 我们必须有一个成分 "meat" 实现了 IngredientAdd 接口,该接口将字符串 meat 添加到返回的披萨中。

  • 当在顶层对象上调用 AddIngredient 方法时,它必须返回一个完全装饰的 pizza,文本为 包含以下成分的披萨:meat, onion

单元测试

为了启动我们的单元测试,我们必须首先创建符合接受标准的基结构。首先,所有装饰类型必须实现的接口如下:

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

最后,我们必须检查完整的堆栈测试。用洋葱和肉制作披萨必须返回文本 Pizza with the following ingredients: meat, onion

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 方法将执行 "onion" 层的方法,然后执行 "meat" 层的方法,最后执行 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

太棒了!使用装饰者模式,我们可以连续堆叠IngredientAdds,这些IngredientAdds会调用它们的内部指针来向PizzaDecorator添加功能。我们也没有触及核心类型,也没有修改或实现新事物。所有的新功能都是由外部类型实现的。

真实生活中的例子 - 服务器中间件

到现在为止,你应该已经理解了装饰者模式的工作原理。现在我们可以尝试一个更高级的例子,使用我们在适配器模式部分设计的 HTTP 小服务器。你了解到可以通过使用http包并实现http.Handler接口来创建 HTTP 服务器。这个接口只有一个方法,叫做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 Basic Authentication 进行身份验证:

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.Request 中的 BasicAuth 方法自动从请求中检索用户和密码,以及解析动作的 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) 
} 

我们将把 验收标准 2 命名为 GetByCityAndCountryCode;我们还需要一个城市名称和一个字符串格式的国家代码。国家代码是两个字符的代码,代表世界国家的 国际标准化组织 (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 
} 

这个模拟数据是通过使用 API 密钥向OpenWeatherMap发出请求生成的。response变量是一个包含 JSON 响应的字符串。仔细观察用来打开和关闭字符串的重音符(`)。这样,你可以使用任意多的引号而不会出现任何问题。

进一步来说,我们在 bytes 包中使用了一个特殊函数NewReader,它接受一个字节数组(我们通过将类型从字符串转换创建),并返回一个包含数组内容的io.Reader实现者。这完美地模仿了 HTTP 响应的Body成员。

我们将编写一个测试来尝试response parser。两种方法返回相同类型,所以我们可以为两者使用相同的JSON parser

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 并通过将每个 %s 指示符替换为城市、我们在参数中引入的 countryCode 以及 CurrentWeatherData 类型的 API key 成员来格式化它。

但是,我们还没有设置任何 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 创建了一个库的第一个里程碑。我们已经将访问OpenWeatherMap REST API 的复杂性隐藏在doRequestresponseParser函数中,我们的库用户有一个易于使用的语法来查询 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

一个典型的夏日!

享元设计模式

我们下一个模式是享元设计模式。它在计算机图形和视频游戏行业中非常常用,但在企业应用中并不那么常见。

描述

享元是一种模式,它允许在许多实例之间共享重型对象的某些类型的状态。想象一下,你必须创建和存储太多本质上相同的一些重型对象。你很快就会耗尽内存。这个问题可以通过享元模式以及工厂模式的额外帮助轻松解决。工厂通常负责封装对象创建,正如我们之前所看到的。

目标

多亏了享元模式,我们可以在单个公共对象中共享所有可能的对象状态,从而通过使用指向已创建对象的指针来最小化对象创建。

示例

为了举例说明,我们将模拟你在投注网页上找到的东西。想象一下欧洲锦标赛的决赛,整个大陆有成千上万的人观看。现在想象一下我们拥有一个投注网页,我们提供关于欧洲每个团队的历史信息。这是大量的信息,通常存储在一些分布式数据库中,每个团队都有关于他们的球员、比赛、锦标赛等等的数兆字节信息。

如果一百万用户访问关于一个团队的信息,并且为每个查询历史数据的用户创建一个新的信息实例,我们将在一瞬间耗尽内存。有了我们的代理解决方案,我们可以缓存最近的 n 次搜索以加快查询速度,但如果我们为每个团队返回一个克隆,我们仍然会内存不足(但得益于我们的缓存会更快)。有趣,对吧?

相反,我们将只存储每个团队的信息一次,并将向用户交付它们的引用。因此,如果我们面临一百万用户试图访问关于比赛的信息,实际上我们只有两个团队在内存中,并且有一百万个指向相同内存方向的指针。

接受标准

Flyweight 模式的接受标准必须始终减少使用的内存量,并且必须主要关注这个目标:

  1. 我们将创建一个包含一些基本信息(如团队名称、球员、历史结果和描述他们盾牌的图像)的 Team 结构体。

  2. 我们必须确保正确创建团队(注意这里的词 creation,是创建模式的候选词),并且没有重复。

  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 
} 

如您所见,我们还需要一个Match结构体,它存储在HistoricalData结构体中。在这个上下文中,Match结构体代表比赛的记录结果:

type Match struct { 
  Date          time.Time 
  VisitorID     uint64 
  LocalID       uint64 
  LocalScore    byte 
  VisitorScore  byte 
  LocalShoots   uint16 
  VisitorShoots uint16 
} 

这足以表示一个团队,并满足验收标准 1。你可能已经猜到,每个团队都有很多信息,因为一些欧洲团队已经存在了 100 多年。

对于验收标准 2,单词创建应该给我们一些关于如何解决这个问题的一些线索。我们将构建一个工厂来创建和存储我们的团队。我们的工厂将包括一个年份的映射,其中包含指向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,否则测试将失败。

然后我们调用指向同一团队的第二个指针。这个指针也不能为空,它应该指向与上一个指针相同的内存地址,这样我们才知道它没有分配新的内存。

最后,我们应该检查创建的团队数量是否只有一个,因为我们请求了同一个团队两次。我们有两个指针,但只有一个团队的实例。让我们运行测试:

$ 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方法需要扫描名为createdTeamsmap字段,以确保查询的团队已经创建,如果已经创建,则返回它。如果团队尚未创建,它必须在返回之前创建它并将其存储在映射中:

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映射中,则返回指针。否则,调用工厂进行团队创建。这足以让我们停下来分析一下。当你使用 Flyweight 模式时,非常常见的是有一个 Flyweight 工厂,它使用其他类型的创建模式来检索它需要的对象。

因此,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, 
    } 
  } 
} 

我们简化了对象的内容,以便我们可以专注于 Flyweight 模式的实现。好吧,所以我们只需要定义一个函数来检索创建的对象数量,如下所示:

func (t *teamFlyweightFactory) GetNumberOfObjects() int { 
  return len(t.createdTeams) 
} 

这相当简单。len函数返回数组或切片中的元素数量,字符串中的字符数量等等。看起来一切都已经完成,我们可以再次启动测试:

$ 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

惊慌!我们忘记什么了吗?通过阅读恐慌信息的堆栈跟踪,我们可以看到一些地址、一些文件,并且看起来GetTeam方法正在尝试将一个条目分配给flyweight.go文件的第71行上的 nil 映射。让我们仔细看看第 71行(记住,如果你在遵循本教程编写代码时遇到错误,错误可能出现在不同的行上,所以请仔细查看你的堆栈跟踪):

t.createdTeams[teamName] = &team 

好吧,这一行是在GetTeam方法中,当方法通过这里时,这意味着它没有在映射中找到团队(它已经创建了变量 team),并试图将其分配给映射。但是映射是 nil,因为我们没有在创建工厂时初始化它。这有一个快速的解决方案。在我们的测试中,在创建工厂的地方初始化映射:

factory := teamFlyweightFactory{ 
  createdTeams: make(map[int]*Team,0), 
} 

我相信你已经在这里看到了问题。如果我们无法访问包,我们可以初始化变量。好吧,我们可以使变量公开,这就足够了。但这将要求每个实现者都知道他们必须初始化映射,并且它的签名既不方便也不优雅。相反,我们将创建一个简单的工厂构建器来为我们完成这项工作。这是 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_B 500,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 类型需要一个实现 RGBA() (r, g, b, a uint32) 方法的 color.Color 接口实例。color.Color 类型是任何实现该方法的类型,该方法返回红色、绿色、蓝色和透明度颜色的 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.Create,它返回*os.File。与每个文件一样,使用后必须关闭,所以不要忘记使用defer关键字来确保在方法结束时关闭它。

小贴士

延迟执行,还是不延迟执行?

有些人问为什么一定要使用defer?直接在函数末尾不使用defer不是一样吗?实际上不是。如果在方法执行过程中发生错误并返回这个错误,如果它在函数的末尾,Close方法将不会执行。你可以在返回之前关闭文件,但你必须在每个错误检查中这样做。使用defer,你不必担心这个问题,因为延迟函数总是执行(无论是否有错误)。这样,我们确保文件被关闭。

为了解析参数,我们将使用flag包。我们之前已经使用过它,但让我们回顾一下它的用法。标志是一个用户在执行我们的应用程序时可以传递的命令。我们可以通过使用flag包中定义的flag.[type]方法来定义一个标志。我们希望从控制台读取用户想要使用的输出。这个标志将被称为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。然后你有两个选项--使用=(等于)并立即为标志写入值,或者写入<space>和标志的值。在这种情况下,我们已将输出默认值定义为控制台,所以以下三个执行是等效的:

$ 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包,我们将把文本和图像策略都放在这个文件夹中。因此,我们的文件结构将如下所示:

  • 根包: strategy

    文件:print_strategy.go

  • 子包: shapes

    文件: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 
} 

因此,现在如果我们要修改它们的 Writerlogger 字段,每个策略都必须内嵌 PrintOutput 结构体。

我们还需要修改我们的策略实现。TextSquare 结构体现在需要一个字段来存储输出 io.Writer(它将要写入的地方,而不是总是写入控制台)和日志写入器。这两个字段可以通过内嵌 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 就传递 TextSquare

但是,如果你有一个接受 PrintOutput 实现的接口的函数,你可以传递 TextSquare 如果它内嵌了 PrintOutput。这正是我们在我们的例子中所做的。

ImageSquare结构现在就像TextSquare一样,内嵌了PrintOutput

type ImageSquare struct { 
  strategy.PrintOutput 
} 

Print方法也需要修改。现在,我们不再从Print方法创建文件,因为这破坏了单一责任原则。文件实现了io.Writer接口,所以我们将文件在外部打开,并将其注入到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,它是一个包含之前常量之一的字符串。

每个策略都有一个PrintOutput类型内嵌,默认将日志输出到stdout,但你可以稍后通过使用SetLog(io.Writer)方法来覆盖它。这种方法可以被认为是一个原型的工厂。如果不是已识别的策略,将返回适当的错误信息。

我们现在有一个库。我们在strategyshapes包之间拥有所需的所有功能。现在我们将编写一个名为main的包和函数,在一个名为cli的新文件夹中:

var output = flag.String("output", "text", "The output to use between "+ 
  "'console' and 'image' file") 

func main() { 
  flag.Parse() 

再次,就像之前一样,main函数首先解析控制台上的输入参数以收集所选策略。现在我们可以使用变量 output 来创建一个策略,而不需要 Factory:

activeStrategy, err := shapes.NewPrinter(*output) 
if err != nil { 
  log.Fatal(err) 
} 

log.Fatal method if any error is found (such as an unrecognized strategy).

现在我们将通过使用我们的库来实现业务需求。对于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的情况下,我们使用SetWriter来设置io.Writeros.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.Writer 使用。Write 方法简单地将字符串参数存储到 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 时记录并传递消息。在生产应用程序中,它可能是一个仅记录错误的记录器。

最后,链中的第一个链接具有变量名链,它指向第二个记录器。所以,总结一下,我们的链看起来是这样的: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,链中的最后一个链接,它在名为receivedMessage的变量中存储了过去的消息,是否包含我们在链中传递的第一个单词: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)错误。这样,我们就会在返回错误时断开链。这在一般情况下是一个更方便的方法,但会引入相当多的模板代码。

  • 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,然后再检查其内容,否则它可能会在测试中抛出 panic。让我们再次测试它:

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 并不返回任何内容的函数。

ClosureChain 类的 Next(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 个记录器,第二个使用闭包实现”。我们简单地使用两个 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)。它也可以与装饰器模式互换使用,区别在于当你装饰时,你改变了对象的结构,而当你使用链时,你为链中的每个链接定义了一个行为,这也可以中断链。

命令设计模式

为了结束这一章,我们还将看到命令模式——这是一个微小的设计模式,但仍然经常使用。你需要一种方法来连接真正无关的类型?那么为它们设计一个命令。

描述

命令设计模式与策略设计模式非常相似,但有一些关键的区别。在策略模式中,我们关注算法的更改,而在命令模式中,我们关注对某物或某些类型的抽象或调用。

命令模式通常被视为一个容器。你可以在 UI 上放置用户交互的信息,比如“点击登录”,并将其作为命令传递。你不需要在命令中包含与“点击登录”操作相关的复杂性,只需操作本身即可。

有机世界的例子可以是快递公司的盒子。我们可以把它放在上面,但作为一个快递公司,我们对其内容的直接管理不如对盒子的管理感兴趣。

在处理通道时,将大量使用命令模式。通过通道,你可以发送任何消息,但如果我们需要从通道的接收端获取响应,一个常见的做法是创建一个带有附加的响应通道的命令,我们在那里监听。

同样,一个很好的例子是多玩家视频游戏,其中每个用户的每个操作都可以作为命令通过网络发送给其他用户。

目标

当使用命令设计模式时,我们试图将某种动作或信息封装在一个轻量级的包装中,该包装必须在其他地方进行处理。它与策略模式类似,但实际上,命令可以在其他地方触发预配置的策略,因此它们并不相同。以下是该设计模式的目标:

  • 将一些信息放入一个盒子中。只有接收者会打开盒子并知道其内容。

  • 将某些操作委托到其他地方。

行为也在以下图中得到解释:

目标

我们有一个命令接口,其中包含一个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()方法返回自我们给该类型赋值以来经过时间的字符串表示。helloCommand变量在调用其Info()方法时返回消息Hello World!。在这里,我们没有再次实现Command处理器以保持简单,但我们可以将控制台视为处理器,因为我们只能打印 ASCII 字符,就像通过Info()方法检索到的那些字符一样。

让我们运行main函数:

go run command.go
1.000216755s
Hello world!

这里我们到了。在这种情况下,我们通过使用命令模式来检索一些信息。一种类型存储时间信息,而另一种则不存储任何信息,它简单地返回相同的简单字符串。每次运行main函数都会返回不同的已过时间,所以如果时间与示例中的时间不匹配,请不要担心。

命令的责任链

你还记得责任链设计模式吗?我们是在链接之间传递一个字符串消息以打印其内容。但我们可以使用之前的命令来检索信息以记录到控制台。我们将主要重用我们已经编写的代码。

Command接口将来自返回字符串的先前示例中的类型接口:

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()字符串方法中返回对象创建的经过时间。我们还需要从本章的责任链设计模式部分获取ChainLogger接口,但这次它将在其Next方法中传递命令而不是字符串:

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()方法返回执行时刻的时间)。

最后,我们在first.Next(command)语句中将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. 第二步是用户想要返回的任何字符串,但它由具有Message() string方法的MessageRetriever接口定义。

  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接口并返回字符串worldTestStruct类型,并嵌入模板,以便它可以调用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()方法返回值、TestStructworld字符串)返回值和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" 
} 

使用这个实现,我们应该覆盖 第二个第三个 验收标准,并部分覆盖 第一个 标准(算法的每个步骤都必须返回一个字符串)。

为了覆盖 第五 验收标准,我们定义了一个接受 MessageRetriever 接口作为参数的 ExecuteAlgorithm 方法,并返回完整的算法:一个由 first()Message() 字符串和 third() 方法返回的字符串连接而成的单个字符串:

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接口。

对于我们的模板模式的适配器实现,我们将从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 的作者的话说:

"通常,类型是一个满足排序的集合。接口可以通过本包中的例程进行排序。这些方法要求集合的元素通过整数索引进行枚举。)

换句话说,编写一个实现此 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 的意思,我们会找到以下描述:

"作为一个人或事件的提醒物。)

这里,关键词是 提醒,因为我们将通过这个设计模式记住动作。

描述

备忘录的意义与它在设计模式中提供的功能非常相似。基本上,我们将有一个包含一些状态的类型,我们希望能够保存其状态的里程碑。保存有限数量的状态,我们可以在必要时恢复它们,用于各种任务——撤销操作、历史记录等。

备忘录设计模式通常有三个参与者(通常称为 actors):

  • Memento:一个存储我们想要保存的类型。通常,我们不会直接存储业务类型,我们通过这个类型提供额外的抽象层。

  • Originator:一个负责创建备忘录并存储当前活动状态的类型。我们说过,Memento 类型封装了业务类型的状态,我们使用 originator 作为备忘录的创建者。

  • Care Taker:一个存储备忘录列表的类型,它可以有将它们存储在数据库中的逻辑,或者不存储超过指定数量的它们。

目标

备忘录完全关于随时间推移的一系列操作,比如撤销一个或两个操作,或者为某些应用程序提供某种事务性。

备忘录为许多任务提供了基础,但其主要目标可以定义为以下内容:

  • 捕获对象状态而不修改对象本身

  • 保存有限数量的状态,以便我们可以在以后检索它们

一个简单的字符串示例

我们将开发一个简单的示例,使用字符串作为我们想要保存的状态。这样,我们将在使用新示例使其更复杂之前,专注于常见的备忘录模式实现。

存储在 State 实例的字段中的字符串将被修改,我们将能够撤销在这个状态下进行的操作。

需求和验收标准

我们一直在谈论状态;总的来说,备忘录模式是关于存储和检索状态。我们的验收标准必须全部关于状态:

  1. 我们需要存储有限数量的字符串类型的状态。

  2. 我们需要一种方法来将当前存储的状态恢复到状态列表中的一个。

在这两个简单的要求下,我们已经开始为这个例子编写一些测试。

单元测试

如前所述,备忘录设计模式通常由三个参与者组成:状态、备忘录和 originator。因此,我们需要三个类型来表示这些参与者:

type State struct { 
  Description string 
} 

State 类型是我们将在本例中使用的核心业务对象。它是我们想要跟踪的任何类型的对象:

type memento struct { 
  state State 
} 

memento 类型有一个名为 state 的字段,表示 State 类型的单个值。我们的 states 将在这个类型中容器化,然后再存储到 care taker 类型中。你可能想知道为什么我们不直接存储 State 实例。基本上,因为这会将 originatorcareTaker 与业务对象耦合起来,而我们希望尽可能减少耦合。它也将变得不那么灵活,正如我们在第二个示例中将会看到的:

type originator struct { 
  state State 
} 

func (o *originator) NewMemento() memento { 
  return memento{} 
} 

func (o *originator) ExtractAndStoreState(m memento) { 
  //Does nothing 
} 

originator类型也存储状态。originator结构体的对象将从 mementos 中获取状态,并使用它们存储的状态创建新的 mementos。

小贴士

原始对象和 Memento 模式之间的区别是什么?为什么我们不直接使用 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") 
  } 

在我们测试的开始,我们为 memento 创建了两个基本演员——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方法。此函数必须接受一个备忘录并提取其所有状态信息,将其设置在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变量。然后,我们检索一个新的备忘录对象以供以后使用。我们将originator变量的状态更改为Working状态,以确保新状态将被写入。

最后,我们必须使用idleMemento变量调用ExtractAndStoreState方法。这应该将originator的状态恢复到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 类型的新值。它还需要根据需要将memento对象存储在结构体的状态字段中,以便用于ExtractAndStoreState方法:

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 模式的功能。然而,它更常与命令模式和简单的外观模式一起使用。

策略是使用命令模式来封装一组不同类型的状态(那些实现Command接口的状态),并提供一个小型的外观来自动化在caretaker对象中的插入。

我们将要开发一个假设的音频混音器的简单示例。我们将使用相同的 Memento 模式来保存两种状态:VolumeMuteVolume状态将是一个字节类型,而Mute状态是一个布尔类型。我们将使用两种完全不同的类型来展示这种方法的灵活性(及其缺点)。

作为旁注,我们还可以在每个Command接口上提供它们自己的序列化方法。这样,我们可以赋予保管者存储状态的能力,而不必真正知道存储的是什么。

我们的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类型。

如同先前的例子,我们需要一个Memento类型来保存一个Command。换句话说,它将存储一个指向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变量。

到目前为止,一切看起来几乎和上一个例子一样。我们也讨论了通过使用门面模式来自动化一些任务,所以让我们来做吧。这将被称为MementoFacade类型,并将具有SaveSettingsRestoreSettings方法。SaveSettings方法接受一个Command,将其存储在一个内部发起者中,并保存在一个内部的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 
} 

我们的门面模式将保存发起者和保管者的内容,并提供这两个易于使用的方法来保存和恢复设置。

那么,我们该如何使用这个方法呢?

func main(){ 
  m := MementoFacade{} 

  m.SaveSettings(Volume(4)) 
  m.SaveSettings(Mute(false)) 

首先,我们使用门面模式获取一个变量。零值初始化将给我们零值的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 应用程序时非常有用,而且在开发事务性操作时也同样有用。无论如何,情况都是一样的:你需要一个备忘录,一个发起者和一个保管者角色。

小贴士

事务操作是一组原子操作,必须全部完成或失败。换句话说,如果你有一个由五个操作组成的事务,只要其中一个操作失败,事务就无法完成,其他四个操作所做的所有修改都必须撤销。

解释器设计模式

现在我们将深入探讨一个相当复杂的模式。解释器模式实际上被广泛用于解决需要有一种语言来执行常见操作的业务案例。让我们看看我们所说的“语言”是什么意思。

描述

我们可以谈论的最著名的解释者可能是 SQL。它被定义为用于管理关系数据库中存储数据的专用编程语言。SQL 非常复杂且庞大,但总的来说,它是一组单词和运算符,允许我们执行插入、选择或删除等操作。

另一个典型的例子是音乐记谱法。它本身是一种语言,解释者是知道音符与其所演奏乐器上表示之间关系的音乐家。

在计算机科学中,出于各种原因,设计一个小语言可能是有用的:重复性任务、为非开发者提供高级语言,或者接口定义语言(IDL)如协议缓冲区Apache 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 服务器,重用大部分错误检查和连接功能,以提供一个易于使用的接口来练习模板模式。

  • 创建一个小型库,可以写入不同的数据库,但只有在所有写入都正常的情况下,或者删除新创建的写入以练习 Memento 为例。

  • 编写你自己的语言,以便练习像机器人通常那样回答简单问题,这样你就可以练习一点解释器模式。

这个想法是练习编码并重新阅读任何部分,直到你对每个模式感到舒适。

第七章。行为模式 - 访问者、状态、中介者和观察者设计模式

这是关于行为模式的最后一章,同时也结束了这本书关于 Go 语言中常见、知名设计模式的部分。

在本章中,我们将探讨三个更多设计模式。访问者模式在你想从一组对象中抽象出某些功能时非常有用。

状态通常用于构建有限状态机FSM),在本节中,我们将开发一个小型的猜数字游戏。

最后,观察者模式在事件驱动架构中很常见,并且在微服务世界中再次获得了大量关注。

在本章之后,我们需要在深入并发及其在设计模式中的优势(以及复杂性)之前,对常见设计模式感到非常熟悉。

访问者设计模式

在下一个设计模式中,我们将将对象类型的某些逻辑委托给一个外部类型,称为访问者,它将访问我们的对象以执行操作。

描述

在访问者设计模式中,我们试图将处理特定对象所需的逻辑与对象本身分离。因此,我们可以有许多不同的访问者对特定类型执行某些操作。

例如,假设我们有一个写入控制台的日志记录器。我们可以使记录器“可访问”,这样你就可以在每个日志前添加任何文本。我们可以编写一个访问者模式,将日期、时间和主机名添加到对象中存储的字段。

目标

在行为设计模式中,我们主要处理算法。访问者模式也不例外。我们试图实现的目标如下:

  • 将某些类型的算法与其在另一个类型中的实现分离

  • 通过使用几乎没有任何逻辑的某些类型来提高它们的灵活性,这样所有新的功能都可以添加,而无需更改对象结构

  • 为了修复一个会破坏类型开放/封闭原则的结构或行为

你可能想知道开放/封闭原则是什么。在计算机科学中,开放/封闭原则指出:实体应该对扩展开放,但对修改封闭。这种简单的状态有很多含义,允许构建更易于维护的软件,并且更不容易出错。访问者模式帮助我们将一些经常变化的算法从需要“稳定”的类型中委托给一个可以经常更改而不影响原始类型的外部类型。

日志追加器

我们将以一个简单的日志追加器为例,来开发一个访问者模式的示例。遵循我们在前几章中采用的方法,我们将从一个极其简单的例子开始,以便清楚地理解访问者设计模式是如何工作的,然后再转向一个更复杂的例子。我们之前也开发过类似的例子,修改文本,但方式略有不同。

对于这个特定的例子,我们将创建一个访问者,它将向它“访问”的类型追加不同的信息。

接受标准

为了有效地使用访问者设计模式,我们必须有两个角色——访问者和可访问者。访问者是将在可访问者类型内执行操作的类型。因此,可访问者接口实现有一个与访问者类型分离的算法:

  1. 我们需要两个消息记录器:MessageAMessageB,它们将在消息前分别打印A:B:

  2. 我们需要一个能够修改要打印的消息的访问者。它将分别向它们追加文本“Visited A”或“Visited B”。

单元测试

正如我们之前提到的,我们需要为访问者可访问者接口提供一个角色。它们将是接口。我们还需要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接口,就像我们将用它来检查内容是否正确的那样。

访问者接口有一个Visit方法,对应于可访问者接口的MessageAMessageB类型。可访问者接口有一个名为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方法的指针,如果没有指针,内容将不会持久化)。

为了测试访问者类型是否在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 方法:

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 接口,就像之前一样,有一个 Visit 方法,它接受 ProductInfoRetriever 类型。最后,Visitable 接口完全相同;它有一个 Accept 方法,该方法接受一个 Visitor 类型作为参数:

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 类型,它可以存储商店几乎任何产品的信息。例如,我们可能有一个 RicePasta 产品:

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实例作为参数对它们中的每一个进行迭代。在 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) 方法返回一个介于零和指定数字之间的整数,因此在这里我们涵盖了接受标准 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。所以我们的 for 循环会一直循环,等待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结构的定义,并更改显示“你输了”的那一行,将其替换为以下内容:

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来通知引擎它必须继续执行链中的状态。

使用状态模式构建的游戏

你现在可能认为你可以通过添加新的状态来无限扩展这个游戏,这是真的。状态模式的强大之处不仅在于能够创建复杂的有限状态机(FSM),而且在于它具有足够的灵活性,可以通过添加新状态和修改一些旧状态来指向新状态,而不会影响整个 FSM 的其余部分。

中介者设计模式

让我们继续讨论中介者模式。正如其名称所暗示的,这是一种位于两种类型之间以交换信息的模式。但是,我们为什么想要这种行为呢?让我们详细看看。

描述

任何设计模式的关键目标之一是避免对象之间的紧密耦合。这可以通过许多方式实现,正如我们之前所看到的。

但当应用程序规模很大时,这是一种特别有效的方法,即中介者模式。中介者模式是程序员经常使用而很少思考的模式的一个完美例子。

中介者模式将充当负责在两个对象之间交换通信的类型。这样,通信对象不需要相互了解,可以更自由地改变。维护哪些对象提供什么信息的模式是中介者。

目标

如前所述,中介者模式的主要目标是关于松散耦合和封装。目标是:

  • 为了在必须相互通信的两个对象之间提供松散耦合

  • 通过将这些需求传递给中介者模式,将特定类型的依赖性减少到最小

计算器

对于中介者模式,我们将开发一个极其简单的算术计算器。你可能认为计算器如此简单,不需要任何模式。但我们会看到这并不完全正确。

我们的计算器只会执行两种非常简单的操作:求和和减法。

接受标准

说到用接受标准来定义计算器,听起来相当有趣,但让我们这样做:

  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语句,我们正在遍历ObserversList字段以在publisher实例中切片。我们需要将观察者指针强制转换为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字段的值来避免强制类型转换。这两种方法都是有效的。一旦我们将类型转换为名为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 方法相当简单,因为它向控制台打印一条消息,宣布要将特定消息传递给 Observers。之后,我们使用一个 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

太棒了!我们还可以在控制台上看到 PublisherObserver 类型的输出。Publisher 结构打印以下消息:

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 就可以将程序员创建应用程序时要执行的操作委托给它们。

第八章。Go 并发简介

我们刚刚完成了在面向对象编程语言中常用到的四人帮设计模式。这些模式在过去几十年里被广泛使用(甚至在它们在书中被明确定义之前)。

在本章中,我们将探讨 Go 语言中的并发。我们将了解到,通过多个核心和多个进程,应用程序可以帮助我们实现更好的性能和无限的可能性。我们将探讨如何以并发安全的方式使用一些已知模式。

一点历史和理论

当我们谈论 Go 的并发时,不可避免地要谈到历史。在过去的几十年里,我们看到了 CPU 速度的提高,直到我们达到了由当前硬件材料、设计和架构强加的硬件限制。当我们达到这一点时,我们开始玩多核计算机,首先是双 CPU 主板,然后是具有一个以上核心的单核 CPU。

不幸的是,我们使用的语言仍然是我们在单核 CPU 时代创建的,如 Java 或 C++。虽然它们是出色的系统语言,但它们在设计上缺乏适当的并发支持。你可以在项目中使用的两种语言中通过使用第三方工具或开发自己的(这不是一件容易的任务)来开发并发应用程序。

Go 的并发设计时考虑到了这些注意事项。创造者希望有一个垃圾回收和过程式语言,这对新手来说很熟悉,但同时又可以轻松地编写并发应用程序,而不会影响语言的内核。

我们在早期章节中已经体验到了这一点。我们开发了 20 多个设计模式,而没有提及并发。这清楚地表明,Go 语言的并发特性完全独立于核心语言,同时又是其一部分,这是抽象和封装的完美例子。

计算机科学中有许多并发模型,其中最著名的是存在于ErlangScala等语言中的 actor 模型。另一方面,Go 使用通信顺序进程CSP),它对并发有不同方法。

并发与并行

许多人误解了两者之间的区别,甚至认为它们是相同的。Go 的创造者之一 Rob Pike 有一句流行的话,“并发不是并行”,我非常赞同。作为对这次谈话的简要总结,我们可以提取以下内容:

  • 并发是关于同时处理很多事情。

  • 并行性是关于同时做很多事情。

并发通过设计正确的并发工作结构来启用并行性。

例如,我们可以思考自行车的机制。当我们踩踏板时,我们通常向下推踏板以产生力量(并且这种推力,也会抬起我们另一只脚对应的踏板)。我们不能同时用两只脚推,因为曲柄不允许我们这样做。但这个设计允许构建一种并行自行车,通常称为双人自行车。双人自行车是一种两个人可以同时骑行的自行车;他们两人都踩踏板并给自行车施加力量。

在自行车示例中,并发是自行车的结构设计,通过两条腿(Goroutines),你可以自己产生力量来推动自行车。这个设计是并发的且是正确的。如果我们使用双人自行车和两个人(两个核心),解决方案是并发的、正确的,并且是并行的。但关键在于,在并发设计中,我们不必担心并行性;如果我们并发设计正确,我们可以将其视为一个额外功能。实际上,我们可以用一个人使用双人自行车,但自行车的腿、踏板、链条、轮子的并发设计仍然是正确的。

并发与并行

在并发方面,在左侧,我们有一个由同一 CPU 核心顺序执行的设计和结构。一旦我们有了这个设计和结构,通过简单地在不同线程上重复这个结构,就可以实现并行性。

这就是 Go 通过简单地不过多担心并行执行,而更多地关注并发设计和结构,来简化对并发和并行程序推理的方法。将大任务分解成可以并行运行的小任务,通常在单核计算机上提供更好的性能,但如果这种设计也可以并行运行,我们就可以实现更高的吞吐量(或者不能,这取决于设计)。

事实上,我们可以在 Go 应用程序中通过设置环境变量 GOMAXPROCS 为我们想要的核数来设置使用的核心数。这不仅在使用调度器,如Apache Mesos时很有用,而且它让我们对 Go 应用程序的工作方式和性能有了更多的控制。

因此,总结一下,我们必须牢记,并发是关于结构,而并行是关于执行。我们必须考虑以更好的方式使我们的程序并发,通过将它们分解成更小的任务块,Go 的调度器将尝试在可能和允许的情况下使它们并行。

CSP 与基于演员的并发

考虑并发的最常见和可能最直观的方式几乎与演员模型的工作方式相似。

CSP 与基于演员的并发

在 actor 模型中,如果Actor 1想要与Actor 2通信,那么Actor 1必须首先知道Actor 2;例如,它必须知道它的进程 ID,可能来自创建步骤,并将消息放在它的收件箱队列中。放置消息后,如果Actor 2不能立即处理消息,Actor 1可以继续其任务而不会阻塞。

在另一方面,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 来接收它们并组合响应。这里的要点是什么?如果我们有一台具有四个核心的计算机,我们可以在理论上并行运行这个服务调用,但如果我们使用单核计算机,设计仍然正确,调用将在单个核心中并发执行。通过设计并发应用程序,我们不需要担心并行执行。

回到自行车的比喻,我们用两条腿推动自行车的踏板。这是两个 Goroutine 同时推动踏板。当我们使用双人自行车时,我们总共有四个 Goroutine,可能并行工作。但我们还有两只手来处理前后刹车。对于我们的双线程自行车,总共有八个 Goroutine。实际上,我们在刹车时不会踩踏板,我们在踩踏板时不会刹车;这是一个正确的并发设计。我们的神经系统传递关于何时停止踩踏板和何时开始刹车的信息。在 Go 中,我们的神经系统由通道组成;我们将在玩了一会儿 Goroutine 之后看到它们。

我们的第一个 Goroutine

现在已经解释得够多了。让我们动手实践一下。对于我们的第一个 Goroutine,我们将在一个 Goroutine 中打印消息 Hello World!。让我们从到目前为止我们所做的一切开始:

package main 

func main() { 
  helloWorld() 
} 

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

Hello World! in the console:
$ 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 函数不能在其余的 Goroutine 执行之前结束。

作为新 Goroutine 启动的匿名函数

我们定义了 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 包),它帮助我们同步多个并发 Goroutine。它工作起来非常简单——每次我们不得不等待一个 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 之前,我们通过使用 wait.Add(1) 方法告诉 WaitGroup “嘿,你将不得不等待某件事情完成”。现在我们可以启动 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)。如果我们事先知道将要启动多少个 Goroutine,我们也可以只调用一次 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() 
} 

在这个例子中,我们将创建五个 Goroutine(如 goroutines 变量所述)。我们事先知道这一点,所以我们只需将它们全部添加到 WaitGroup 中。然后我们将使用 for 循环启动相同数量的 goroutine 变量。每次一个 Goroutine 完成,它都会调用 WaitGroup 的 Done() 方法,该 WaitGroup 实际上是在主循环的末尾等待。

再次强调,在这种情况下,代码在所有 Goroutine(如果有)启动之前就已经到达了 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 不是按顺序执行的。当然!我们正在处理一个不保证 Goroutine 执行顺序的调度器。这是在编写并发应用程序时需要注意的事情。实际上,如果我们再次执行它,我们不一定能得到相同的输出顺序:

$ 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方法的结果(它返回大写单词parameter)来执行f函数。让我们也写一下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,我们就会有一个完美的类比。

桌上有一个角色可以解决这个问题——父亲或母亲。他们将披萨放在另一张桌子上,我们必须在拿到披萨片之前请求许可。无论所有孩子是否同时请求——他们只会允许一个孩子站起来。

好吧,互斥锁就像我们的父母一样。他们会控制谁可以访问披萨——我的意思是,一个变量——并且不允许其他人访问它。

要使用互斥锁(mutex),我们必须主动锁定它;如果它已经被锁定(另一个 Goroutine 正在使用它),我们就必须等待它再次解锁。一旦我们获得对互斥锁的访问权限,我们可以再次锁定它,进行所需的任何修改,然后再次解锁。我们将通过一个示例来查看这一点。

使用互斥锁的示例 - 并发计数器

互斥锁在并发编程中被广泛使用。也许在 Go 中不是那么多,因为 Go 在使用通道进行并发编程时有一个更符合语法的编程方式,但了解它们是如何工作的对于通道不适合的情况是很有价值的。

对于我们的示例,我们将开发一个小型的并发计数器。这个计数器将向 Counter 类型的整数字段加一。这应该以并发安全的方式进行。

我们的 Counter 结构定义如下:

type Counter struct { 
  sync.Mutex 
  value int 
} 

Counter结构有一个int类型的字段,用于存储计数的当前值。它还嵌入了sync包中的Mutex类型。嵌入这个字段将允许我们在不主动调用特定字段的情况下锁定和解锁整个结构。

我们的main函数启动了 10 个 Goroutines,这些 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 可以访问它,将1添加到字段值,然后再解锁,以便其他人可以访问它。

最后,我们将打印计数器持有的值。它必须是 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

我已经减少了输出,以便更清楚地看到事情。我们可以看到一个大的、大写的信息,读取WARNING: DATA RACE。但这个输出很容易理解。首先,它告诉我们,在我们的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 数量也会警告我们存在竞争条件。

因此,竞争检测器可以帮助我们检测代码中正在发生的竞争条件,但它不会保护我们免受那些不会立即执行竞争条件的糟糕设计的影响。这是一个非常有用的功能,可以让我们避免许多头疼的问题。

Channels

Channels 是 Go 语言中的第二个基本元素,它允许我们编写并发应用程序。我们在通信顺序进程部分已经谈到了一些关于 channel 的内容。

Channels 是我们进程间通信的方式。我们可能会共享一个内存位置,并使用互斥锁来控制进程的访问。但 channels 为我们提供了一种更自然的方式来处理并发应用程序,这也会在我们的程序中产生更好的并发设计。

我们的第一个 channel

如果我们无法在它们之间创建一些同步,那么与许多 Goroutines 一起工作似乎相当困难。一旦它们同步,执行顺序可能就无关紧要了。Channels 是 Go 中编写并发应用程序的第二个关键特性。

在现实生活中,电视频道是将发射(来自工作室)连接到数百万台电视(接收器)的东西。Go 中的 channel 以类似的方式工作。一个或多个 Goroutines 可以作为发射器工作,一个或多个 Goroutine 可以作为接收器工作。

还有一点,channels 默认情况下会阻塞 Goroutines 的执行,直到接收到某些东西。这就像我们最喜欢的电视剧延迟播出,直到我们打开电视,这样我们就不会错过任何内容。

在 Go 中是如何做到这一点的?

package main 

import "fmt" 

func main() { 
  channel := make(chan string) 
  go func() { 
    channel <- "Hello World!" 
  }() 

  message := <-channel 
  fmt.Println(message) 
} 

在 Go 中创建 channel 时,我们使用与创建切片相同的语法。make关键字用于创建 channel,我们必须传递关键字chan以及 channel 将要传输的类型,在这种情况下是字符串。有了这个,我们就有一个名为channel的阻塞 channel。接下来,我们启动一个 Goroutine,将消息Hello World!发送到 channel。这通过直观的箭头表示流程——Hello World!文本流向(<-)一个 channel。这就像在变量中赋值一样工作,因此我们只能通过首先写出 channel,然后是箭头,最后是传递的值来向 channel 传递东西。我们不能写出"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

表示我们刚刚从通道缓冲区中取出了一条消息,并打印了它,在启动的 Goroutine 完成之前,main函数已经结束。当发送第二条消息时,Goroutine 被阻塞,直到另一端取走第一条消息。然后它打印得如此之快,以至于没有时间打印消息来显示 Goroutine 的结束。如果你在控制台持续执行程序,迟早调度器会在主线程之前完成 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参数前或后放置了一个箭头。

选择语句

选择语句也是 Go 中的一个关键特性。它用于在 Goroutine 中处理多个通道输入。实际上,它打开了众多可能性,我们将在接下来的章节中广泛使用它。

选择语句

select 结构中,我们要求程序在多个通道中选择一个或多个来接收它们的数据。我们可以在变量中保存这些数据,并在完成 select 之前对其进行处理。select 结构只执行一次;如果它正在监听更多通道,它也只会执行一次,代码将继续执行。如果我们想让它多次处理相同的通道,我们必须将其放入 for 循环中。

我们将创建一个小程序,它将向同一个 Goroutine 发送消息 hello 和消息 goodbye,该 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 语句多么相似?)。让我们一步一步地看看三个 case:

  • 第一个情况从 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! 消息将在中断期间出现在控制台上。

在一个新的 Goroutine 中通过 goodbye 通道发送了一条包含 goodbye! 文本的新消息,我们的程序再次继续到等待 quitCh 参数中传入消息的行。

我们已经启动了三个 Goroutines——接收器仍在运行,第一条消息在select语句处理消息时已经完成,第二条消息几乎立即打印出来并也已完成。所以此刻只有接收器在运行,如果接下来两秒内没有接收到其他消息,它将处理来自time结构的传入消息。在channel类型之后,打印一条消息表示它正在退出,向quitCh发送一个true,并跳出它正在循环的无穷循环。

让我们运行这个小应用程序:

$ go run main.go

hello!
goodbye!
Nothing received in 2 seconds. Exiting

结果可能不太引人注目,但概念是清晰的。我们可以通过使用 select 语句在同一个 Goroutine 中处理多个传入通道。

通道的遍历!

我们将要看到的关于通道的最后一个特性是遍历通道。我们正在讨论 range 关键字。我们已经广泛地使用它来遍历列表,我们也可以用它来遍历通道:

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 中,并将此变量打印到控制台。range 会持续迭代,直到通道关闭,从通道中获取数据。

你能猜出这个小程序的输出吗?

$ go run main.go

1
2

再次,并不十分引人注目。它打印数字 "1",然后等待一秒,打印数字 "2",并退出应用程序。

根据这个并发应用程序的设计,范围遍历可能的传入数据

通道

直到并发 Goroutine 关闭这个通道。在那个时刻,range 完成,应用程序可以退出。

Range 在从通道获取数据时非常有用,它通常用于扇入模式,其中许多不同的 Goroutines 将数据发送到同一个通道。

使用它所有 - 并发单例

现在我们已经知道了如何创建 Goroutines 和通道,我们将所有知识放在一个单独的包中。回想一下前几章,当我们解释单例模式时——它是在我们的代码中只能存在一次的结构或变量。对这个结构的所有访问都应该使用所描述的模式进行,但实际上它并不是并发安全的。

现在,我们将考虑到并发来编写代码。我们将编写一个并发计数器,就像我们在 互斥锁 部分所写的那样,但这次我们将使用通道来解决它。

单元测试

为了限制对 singleton 实例的并发访问,只有一个 Goroutine 能够访问它。我们将使用通道来访问它——第一个用于加一,第二个用于获取当前计数,第三个用于停止 Goroutine。

我们将使用 10,000 个不同的 Goroutine,从两个不同的 singleton 实例启动,来加一 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 循环。这个循环将不会停止,直到其中执行了 break

最后,如果你还记得,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 
} 

addCh case in our Goroutine in turn. The addCh case simply executes count++ and finishes, letting select channel control flow that is executed on init function above to execute the next instruction:
func (s *singleton) GetCount() int { 
  resCh := make(chan int) 
  defer close(resCh) 
  getCountCh <- resCh 
  return <-resCh 
} 

GetCount 方法每次被调用时都会创建一个通道,并在函数结束时延迟关闭它的动作。这个通道是无缓冲的,正如我们在本章前面所看到的。一个无缓冲的通道在接收到数据之前会阻塞执行。因此,我们将这个通道发送到 getCountCh,它也是一个通道,并且实际上期望通过它发送当前的计数值。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

代码输出非常少,但一切如预期般工作。在测试中,我们在进入迭代直到达到 10,000 这个值的循环之前打印了计数器的值。正如我们之前所看到的,Go 调度器会尝试使用你通过GOMAXPROCS配置设置的尽可能多的操作系统线程来运行 Goroutines 的内容。在我的电脑上,它被设置为4,因为我的电脑有四个核心。但关键是,我们可以看到在启动 Goroutine(或 10,000 个)之后和下一执行行之间会发生很多事情。

但它的互斥锁(mutexes)的使用又是如何呢?

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类型帮助我们实现代码中的这种逻辑。

总结

我们已经看到了如何使用互斥锁和通道(channels)编写并发单例(Singleton)。虽然通道的例子更为复杂,但它也展示了 Go 并发机制的核心力量,因为你可以通过简单地使用通道来实现复杂的事件驱动架构。

只需记住,如果你以前没有编写过并发代码,可能需要一些时间才能开始以舒适的方式并发思考。但这是练习可以解决的问题。

我们已经看到了设计并发应用程序以在程序中实现并行性的重要性。我们已经处理了 Go 的大多数原语来编写并发应用程序,现在我们可以编写常见的并发设计模式。

第九章. 并发模式 - 障碍、未来和管道设计模式

现在我们已经熟悉了并发和并行性的概念,并且我们已经理解了如何通过使用 Go 的并发原语来实现它们,我们可以看到一些关于并发工作和并行执行的模式。在本章中,我们将看到以下模式:

  • 障碍是一个非常常见的模式,尤其是在我们需要等待多个 Goroutine 的响应后才能让程序继续之前

  • 未来模式允许我们编写一个算法,该算法最终(或不会)由同一个 Goroutine 或不同的 Goroutine 执行

  • 管道是一个强大的模式,用于构建复杂的同步 Goroutine 流,这些 Goroutine 根据某种逻辑相互连接

快速浏览一下这三种模式的描述。它们都描述了某种逻辑,用于在时间上同步执行。记住,我们现在正在使用前几章中看到的所有工具和模式来开发并发结构。在使用创建型模式时,我们处理的是对象的创建。在使用结构型模式时,我们学习如何构建惯用的结构,而在行为模式中,我们主要使用算法进行管理。现在,在并发模式中,我们将主要管理具有多个 流程 的应用程序的定时执行和顺序执行。

障碍并发模式

我们将从障碍模式开始。其目的是简单的——设置一个障碍,直到我们得到所有需要的结果,这在并发应用程序中相当常见。

描述

想象一下这种情况,我们有一个微服务应用程序,其中一个服务需要通过合并其他三个微服务的响应来组合其响应。这就是障碍模式可以帮助我们的地方。

我们的障碍模式可能是一个服务,它将阻塞其响应,直到它已经与一个或多个不同的 Goroutine(或服务)返回的结果组合。那么我们有什么具有阻塞性质的原始类型呢?嗯,我们可以使用一个锁,但在 Go 中更惯用使用无缓冲通道。

目标

如其名所示,障碍模式试图阻止执行,直到它准备好完成。障碍模式的目标如下:

  • 将类型值与来自一个或多个 Goroutine 的数据组合。

  • 控制任何传入数据管道的正确性,以确保不会返回不一致的数据。我们不希望得到一个部分填充的结果,因为其中一个管道返回了错误。

一个 HTTP GET 聚合器

对于我们的示例,我们将编写一个在微服务应用程序中非常典型的情况——一个执行两个 HTTP GET调用并将它们合并成一个单独的响应,该响应将在控制台上打印的应用程序。

我们的小应用程序必须以不同的 Goroutine 执行每个请求,并在控制台上打印结果,如果两个响应都是正确的。如果任何一个返回错误,那么我们只打印错误。

设计必须是并发的,这样我们就可以利用我们的多核 CPU 并行进行调用:

一个 HTTP GET 聚合器

在前面的图中,实线代表调用,虚线代表通道。气球是 Goroutine,所以我们有两个由main函数(这也可以被认为是一个 Goroutine)启动的 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。如果我们找不到这些文本,测试将失败。为了调试目的,我们记录响应,以便我们可以在 go test 的 -v 标志下检查它:

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通道发送。

我们通过填充一个名为resbarrierResp类型变量来完成所有这些操作。如果在执行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 返回了一个 没有这样的主机 错误。对这个 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 是正确的,但我们没有在不到一毫秒的时间内收到响应,因此客户端返回了一个超时错误。

使用障碍设计模式等待响应

障碍模式以其可组合性打开了微服务编程的大门。它可以被认为是一种结构模式,就像你可以想象的那样。

障碍模式不仅对网络请求有用;我们还可以用它将一些任务拆分成多个 Goroutine。例如,一个昂贵的操作可以被拆分成几个较小的操作,分布在不同的 Goroutine 中,以最大化并行性并实现更好的性能。

Future 设计模式

Future 设计模式(也称为 Promise)是实现异步编程并发结构的一种快速简单的方法。我们将利用 Go 中的第一类函数来开发 Futures

描述

简而言之,我们将在不同的 Goroutine 中执行之前定义每个动作的可能行为。Node.js 使用这种方法,默认提供事件驱动编程。这里的想法是实现一个 发射并忘记,它处理动作中所有可能的结果。

为了更好地理解它,我们可以讨论一个在执行成功或失败的情况下嵌入行为的类型。

描述

在前面的图中,main 函数在一个新的 Goroutine 中启动了一个 Future。它不会等待任何事情,也不会接收 Future 的任何进度。它实际上只是发射并忘记它。

这里有趣的是,我们可以在一个 Future 中启动一个新的 Future,并在同一个 Goroutine(或新的)中嵌入尽可能多的 Future。想法是利用一个 Future 的结果来启动下一个。例如:

描述

这里,我们有相同的 Future。在这种情况下,如果Execute函数返回了正确的结果,则执行Success函数,并且只有在这种情况下,我们才会执行一个新的 Goroutine,其中包含另一个 Future(或者甚至没有 Goroutine)。

这是一种懒惰编程,其中 Future 可以无限期地调用自己,或者直到满足某些规则为止。这个想法是在事先定义行为,并让未来解决可能的解决方案。

目标

使用 Future 模式,我们可以启动许多新的 Goroutine,每个 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

好吧... 测试失败了,是的... 但不是可控的方式。为什么是这样?我们还没有任何实现,所以既没有 Success 也没有 Fail 函数被执行。我们的 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函数以根据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后加上"*"。

执行方法接受ExecuteStringFunc方法并异步执行它。这看起来很简单,对吧?

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函数返回一个可以直接传递给Execute函数的ExecuteStringFunc方法。我们使用一个任意文本调用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 之前异步执行,所以我们必须尽量在匿名函数内放置尽可能多的逻辑。

将 Future 组合起来

我们已经看到通过使用函数类型系统来实现异步编程的一个好方法。然而,我们也可以通过设置一个包含SuccessFailExecute方法及其满足的类型,并使用模板模式来异步执行它们,就像我们在本章之前所看到的那样,而不使用函数来完成它。这取决于你!

流水线设计模式

本章我们将看到的第三个也是最后一个模式是流水线模式。你将在你的并发结构中大量使用这个模式,我们可以将其视为最有用的模式之一。

描述

我们已经知道什么是流水线。每次我们编写执行某些逻辑的任何函数时,我们都在编写一个流水线:如果这个,那么那个,否则其他什么。通过使用几个相互调用的函数,流水线模式可以变得更加复杂。它们甚至可以在它们的输出执行中形成循环。

Go 中的流水线模式以类似的方式工作,但流水线中的每个步骤都将位于不同的 Goroutine 中,并且将通过通道进行通信和同步。

目标

在创建流水线时,我们主要寻找以下好处:

  • 我们可以创建一个多步算法的并发结构

  • 我们可以通过将算法分解为不同的 Goroutines 来利用多核机器的并行性

然而,仅仅因为我们将算法分解为不同的 Goroutines 并不意味着它就会执行得最快。我们一直在谈论 CPU,所以理想情况下,算法必须是 CPU 密集型的,以便利用并发结构。创建 Goroutines 和通道的开销可能会使算法变得更小。

并发多操作

我们将为我们的示例做一些数学运算。我们将生成一个从 1 开始到某个任意数字 N 结束的数字列表。然后我们将每个数字平方,并将结果数字加到一个唯一的结果中。所以,如果N=3,我们的列表将是[1,2,3]。在将它们平方后,我们的列表变为[1,4,9]。如果我们将这些结果相加,结果值是 14。

接受标准

从功能上讲,我们的 Pipeline 模式需要将每个数字平方,然后对所有数字求和。它将被分为一个数字生成器和两个操作,所以:

  1. 从 1 生成一个到 N 的列表,其中 N 可以是任何整数。

  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函数是负责协调所有操作的函数,如下面的图所示:

实现

操作包括三个步骤:生成一个数字列表,将它们平方,然后加和结果数字。

这个 Pipeline 模式中的每个步骤都将具有以下结构:

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 都不会收到最后发送的任何新值。

唯一不完全符合这种方法的步骤是第一个步骤,它接收一个数字,代表列表的上限阈值,而不是一个输入值的通道。因此,如果我们为我们的管道中的每个步骤编码这个操作,最终的图示看起来更像这样:

实现

虽然想法完全相同,但现在我们可以看到,接收通道并发送它们到管道中的下一个步骤的是 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 迭代到最大参数,即我们列表的最高阈值,并将每个数字通过通道发送。发送完每个数字后,通道被关闭,因此无法再通过它发送更多数据,但已缓冲的数据可以被检索。

将数字提升到平方

第二步将接收来自第一步通道(即从参数中获取)的每个输入数字,并将其提升到平方。每个结果都必须通过一个新的通道发送到第三步:

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 的数量,当应用程序完成时优雅地停止它们,并最大限度地提高并行性,同时避免竞争条件。

我们将使用的管道与上一章中使用的类似,当时我们在生成数字,将它们平方,并求和最终结果。在这种情况下,我们将传递字符串,我们将向其中添加和添加前缀数据。

接受标准

在商业术语中,我们希望得到一些信息,表明工作者已经处理了一个请求,一个预定义的结束,以及解析为大写的传入数据:

  1. 当使用字符串值(任何值)进行请求时,它必须是大写的。

  2. 一旦字符串是大写的,必须向其中添加一个预定义的文本。这个文本不应该是大写的。

  3. 根据前面的结果,必须将工作器 ID 前缀添加到最终字符串中。

  4. 生成的字符串必须传递给预定义的处理程序。

我们还没有讨论如何从技术上实现它,只是讨论了业务需求。有了整个描述,我们至少会有工作器、请求和处理程序。

实现

最开始是一个请求类型。根据描述,它必须包含将进入管道的字符串以及处理函数:

   // workers_pipeline.go file 
    type Request struct { 
          Data    interface{} 
          Handler RequestHandler 
    } 

return 在哪里?我们有一个 Data 字段,其类型为 interface{},因此我们可以使用它来传递一个字符串。通过使用接口,我们可以重用此类型来传递 stringintstruct 数据类型。接收者是必须知道如何处理传入接口的那个人。

Handler 字段具有 Request 处理程序的类型,我们还没有定义它:

type RequestHandler func(interface{}) 

请求处理程序是任何接受接口作为其第一个参数且不返回任何内容的函数。再次,我们看到 interface{},我们通常在这里看到一个字符串。这是我们之前提到的一个接收者,我们需要将其转换为传入的结果。

因此,在发送请求时,我们必须在 Data 字段中填充一些值并实现一个处理程序;例如:

func NewStringRequest(s string, id int, wg *sync.WaitGroup) Request { 
    return := 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接口在启动一个 worker 之前不需要对自己做任何特殊的事情,所以Dispatcher上的LaunchWorker方法简单地执行传入的WorkerLauncherLaunchWorker方法,这个WorkerLauncher也有一个LaunchWorker方法来自启动自己。我们之前定义了一个WorkerLauncher类型至少需要一个 ID 和一个用于传入请求的通道,所以这就是我们传递的内容。

Dispatcher接口中实现LaunchWorker方法可能看起来是不必要的。在不同的场景中,保存正在运行的 worker ID 到分发器中,以控制哪些是开启或关闭的,可能是有趣的;这个想法是隐藏启动实现的细节。在这种情况下,Dispatcher接口仅仅充当一个外观设计模式,隐藏了一些实现细节给用户。

第二种方法是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,这个 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()方法的调用,因此我们使用带有增量为 10 的Add()方法。它被称为增量,因为您也可以稍后传递-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

还不错,但我们并没有测试代码是否正在并发执行,所以这更像是业务测试而不是单元测试。并发测试将迫使我们以完全不同的方式编写代码,以检查它是否创建了正确数量的 Goroutine,并且管道是否遵循预期的流程。这并不坏,但相当复杂,超出了本书的上下文。

工作者池的封装

使用工作者池,我们有了第一个复杂的并发应用程序,它可以用于现实世界的生产系统。它也有改进的空间,但这是一个非常好的设计模式来构建并发的有界应用程序。

关键是我们始终要控制正在启动的 Goroutine 的数量。虽然启动数千个 Goroutine 以在应用程序中实现更多并行性很容易,但我们必须非常小心,确保它们没有可能导致无限循环的代码。

使用工作者池,我们现在可以将一个简单操作分解成许多并行任务。想想看;这可以通过一个简单的fmt.Printf调用实现相同的结果,但我们已经通过它建立了一个管道;然后,我们启动了这个管道的几个实例,最后,将工作负载分配给所有这些管道。

并发发布/订阅设计模式

在本节中,我们将实现之前在行为模式中展示的观察者设计模式,但使用并发结构和线程安全。

描述

如果你还记得前面的解释,观察者模式维护一个观察者或订阅者列表,这些观察者或订阅者希望被通知特定事件。在这种情况下,每个订阅者将运行在不同的 Goroutine 中,以及发布者。我们将遇到构建这种结构的新问题:

  • 现在,对订阅者列表的访问必须进行序列化。如果我们用一个 Goroutine 读取列表,我们不能从其中移除订阅者,否则我们将遇到竞争条件。

  • 当订阅者被移除时,订阅者的 Goroutine 也必须关闭,否则它将无限迭代,我们将遇到 Goroutine 泄漏。

  • 当停止发布者时,所有订阅者也必须停止它们的 Goroutine。

目标

发布/订阅的目标与我们在观察者模式中写下的目标相同。这里的区别在于我们将如何开发它。理念是创建一个并发结构以实现相同的功能,具体如下:

  • 提供一个事件驱动的架构,其中一个事件可以触发一个或多个动作

  • 解耦执行的动作与触发它们的动作

  • 提供多个源事件以触发相同的动作

理念是将发送者与接收者解耦,对发送者隐藏将处理其事件的接收者身份,并隐藏接收者从可以与之通信的发送者数量。

特别是,如果我在某个应用程序中的按钮上开发一个点击事件,它可能执行某些操作(例如登录到某个地方)。几周后,我们可能会决定让它显示一个弹出窗口。如果我们每次想要向这个按钮添加一些功能时,都必须更改处理点击动作的代码,那么这个函数将变得很大,并且不太适合其他项目。如果我们使用发布者和每个动作的一个观察者,点击函数只需要使用发布者发布一个单一的事件,每次我们想要改进功能时,我们只需为这个事件编写订阅者即可。这在具有用户界面的应用程序中尤为重要,因为单个 UI 动作中要执行的多项任务可能会降低界面的响应速度,完全破坏用户体验。

通过使用并发结构来开发观察者模式,如果定义了并发结构并且设备允许我们执行并行任务,UI 就无法感觉到正在后台执行的所有任务。

示例 - 并发通知器

我们将开发一个类似于我们在第七章中开发的那个通知器,行为模式 - 访问者、状态、中介者和观察者设计模式。这是为了关注结构的并发性,而不是详细说明已经解释过的太多内容。我们已经开发了一个观察者,因此我们对这个概念很熟悉。

这个特定的通知器将通过传递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方法来停止所有(发布者和订阅者 Goroutine)。

验收标准

与第七章 行为模式 - 访问者、状态、中介者和观察者设计模式 中的示例之间的要求必须保持不变。这两个示例的目标是相同的,因此要求也必须相同。在这种情况下,我们的要求是技术的,因此我们实际上需要添加一些额外的验收标准:

  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接口,但这样我们就无法覆盖传递 nil 对象并将其设置为默认值的场景。

因此,测试函数最终将接收到一个包含订阅者写入内容的字符串。我们只需检查接收到的字符串,即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() 

首先,我们将通过通道直接发送消息,这可能导致潜在的死锁,所以首先需要定义一个用于处理诸如向关闭的通道发送或没有 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() 
}  

我们测试的最终步骤是停止发布者,这样就不会再发送更多消息,并且所有协程都会停止。

测试已经完成,但我们不能运行测试,直到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 中需要注意的一些事情。首先,这个测试中产生的错误是一个致命错误,通常指向代码中的错误。这很重要,因为虽然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。然后,我们创建一个新的指向writerSubscriber类型的指针,该指针包含通过第一个参数传入的 ID,out的值(os.Stdout,或者如果它不为 nil,则传入的参数),以及一个名为in的通道,以保持与之前示例中的相同命名。

然后我们启动一个新的 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()方法返回一个表示错误的接口,所以在这种情况下,我们将返回变量error设置为recover返回的格式化值(它是一个接口)。"%#v"参数在格式化为字符串时提供了关于任何类型的大部分信息。返回的错误可能看起来很糟糕,但它将包含我们可以提取的大部分错误信息。例如,对于已关闭的通道,它将返回“在已关闭的通道上发送”。嗯,这似乎已经很清楚了。

第二条规则是关于等待时间。当我们通过通道发送一个值时,我们将被阻塞,直到另一个 Goroutine 从其中取出值(在填充的缓冲通道中也会发生相同的情况)。我们不希望永远被阻塞,所以通过使用 select 处理程序设置了一个一秒钟的超时期。简而言之,通过 select 我们是在说:要么你在不到 1 秒内取走值,要么我将丢弃它并返回一个错误。

我们有CloseNotifyNewWriterSubscriber方法,因此我们可以再次尝试我们的测试:

go test -run=TestWriter -v .
=== RUN   TestWriter
--- PASS: TestWriter (0.00s)
PASS
ok

现在好多了。Writer已经将我们在测试中编写的模拟写入器写入,并将我们传递给Notify方法的值写入其中。同时,关闭操作可能已经有效地关闭了通道,因为Notify方法在调用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通道将处理必须广播给所有订阅者的传入消息。最后,当我们想要终止所有 Goroutine 时,必须调用停止通道。

好的,让我们从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方法,用于停止发布者和订阅者的函数,也将推送到其各自的通道,称为停止通道。

你可能想知道为什么我们不简单地将通道保持可用,让用户直接向这个通道推送,而不是使用代理功能。好吧,想法是,将库集成到他们的应用程序中的用户不需要处理与库相关的并发结构的复杂性,这样他们就可以专注于他们的业务,尽可能最大化性能。

无竞态条件的通道处理

到目前为止,我们已经将数据转发到发布者的通道上,但我们实际上并没有处理任何这些数据。将要启动不同 Goroutine 的启动机制将处理所有这些数据。

我们将创建一个启动方法,我们将通过使用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方法,我们仍然无法控制启动的 Goroutine 的数量。为了简单起见,我们只是调用Notify方法,但控制Notify方法执行中等待返回的 Goroutine 数量是一个很好的练习。通过在每个订阅者中缓冲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。然后我们必须遍历存储在订阅者字段中的每个Subscriber,以执行它们的Close()方法,这样它们的 Goroutines 也会关闭。最后,如果我们返回这个 Goroutine,它也会结束。

好了,现在是时候执行所有测试,看看进展如何:

go test -race .
ok

还不错。所有测试都成功通过,我们已经准备好了观察者模式。虽然示例还可以改进,但它是一个很好的例子,展示了我们必须如何使用 Go 中的通道来处理观察者模式。作为练习,我们鼓励你尝试使用互斥锁而不是通道来控制访问的相同示例。这会稍微简单一些,也会让你了解如何与互斥锁一起工作。

关于并发观察者模式的一些话

这个示例展示了如何利用多核 CPU 通过实现观察者模式来构建一个并发消息发布者。虽然示例很长,但我们试图展示在 Go 中开发并发应用程序时的一个常见模式。

概述

我们看到了一些开发可以并行运行并发结构的方法。我们尝试展示了解决相同问题的几种方法,一种没有并发原语,另一种有。我们看到了使用并发结构编写的发布/订阅示例与经典示例相比有多么不同。

我们还看到了如何使用管道构建并发操作,并通过使用工作池来并行化它,这是 Go 中非常常见的一种模式,旨在最大化并行性。

两个示例都足够简单,易于理解,但在尽可能深入挖掘 Go 语言本质的同时,并没有深入到问题本身。

第三部分。第 3 模块

Go 编程蓝图,第二版

使用尖端技术和技巧在 Go 中构建真实世界、可生产的解决方案

第一章. 基于 WebSocket 的聊天应用程序

Go 是编写高性能、并发服务器应用程序和工具的绝佳选择,而网络是交付它们的完美媒介。如今,很难找到不是网络化的设备,这使得我们可以构建一个针对几乎所有平台和设备的单一应用程序。

我们的第一个项目将是一个基于网络的聊天应用程序,它允许多个用户在他们的网络浏览器中进行实时对话。惯用的 Go 应用程序通常由许多包组成,这些包通过在不同的文件夹中放置代码来组织,Go 标准库也是如此。我们将从使用 net/http 包构建一个简单的网络服务器开始,该服务器将提供 HTML 文件。然后我们将继续添加对 WebSocket 的支持,我们的消息将通过它流动。

在 C#、Java 或 Node.js 等语言中,需要使用复杂的线程代码和锁的巧妙使用来保持所有客户端的同步。正如我们将看到的,Go 通过其内置的通道和并发范式极大地帮助我们。

在本章中,您将学习如何:

  • 使用 net/http 包来服务 HTTP 请求

  • 向用户浏览器提供模板驱动的内容

  • 满足 Go 接口以构建我们自己的 http.Handler 类型

  • 使用 Go 的 goroutines 允许应用程序并发执行多个任务

  • 使用通道在运行的 goroutines 之间共享信息

  • 将 HTTP 请求升级以使用现代功能,如 WebSocket

  • 向应用程序添加跟踪以更好地理解其内部工作原理

  • 使用测试驱动开发实践编写一个完整的 Go 包

  • 通过导出接口返回未导出类型

注意

本项目的完整源代码可以在 github.com/matryer/goblueprints/tree/master/chapter1/chat 找到。源代码定期提交,因此 GitHub 中的历史记录也遵循本章的流程。

一个简单的网络服务器

我们聊天应用程序需要的第一件事是一个具有两个主要职责的网络服务器:

  • 为在用户浏览器中运行的 HTML 和 JavaScript 聊天客户端提供服务

  • 接受 WebSocket 连接以允许客户端进行通信

注意

GOPATH 环境变量在 附录 中详细说明,稳定 Go 环境的最佳实践。如果您需要帮助设置,请务必先阅读。

在您的 GOPATH 中创建一个名为 chat 的新文件夹内的 main.go 文件,并添加以下代码:

package main 
import ( 
  "log" 
  "net/http" 
) 
func main() { 
  http.HandleFunc("/", func(w http.ResponseWriter, r  *http.Request) { 
    w.Write([]byte(`)) 
      <html> 
        <head> 
          <title>Chat</title> 
        </head> 
        <body> 
          Let's chat! 
        </body> 
      </html> 
    )) 
  }) 
  // start the web server 
  if err := http.ListenAndServe(":8080", nil); err != nil { 
    log.Fatal("ListenAndServe:", err) 
  } 
} 

这是一个完整、尽管简单的 Go 程序,它将:

  • 使用 net/http 包监听根路径

  • 在请求时写入硬编码的 HTML

  • 使用 ListenAndServe 方法在端口 :8080 上启动网络服务器

http.HandleFunc函数将路径模式/映射为我们作为第二个参数传递的函数,因此当用户访问http://localhost:8080/时,该函数将被执行。func(w http.ResponseWriter, r *http.Request)函数签名是 Go 标准库中处理 HTTP 请求的常见方式。

小贴士

我们使用package main是因为我们想要从命令行构建和运行我们的程序。然而,如果我们正在构建一个可重用的聊天包,我们可能会选择使用不同的包名,例如package chat

在终端中,通过导航到您刚刚创建的main.go文件并执行以下命令来运行程序:

go run main.go

小贴士

go run命令是运行简单 Go 程序的便捷快捷方式。它一次构建并执行一个二进制文件。在现实世界中,你通常使用go build自己创建和分发二进制文件。我们将在稍后探讨这一点。

打开浏览器并输入http://localhost:8080以查看**Let's chat!**信息。

将 HTML 代码嵌入我们的 Go 代码中像这样是可行的,但它看起来相当丑陋,并且随着我们项目的增长,情况只会变得更糟。接下来,我们将看看模板如何帮助我们清理这个问题。

使用模板将视图与逻辑分离

模板允许我们将通用文本与特定文本混合,例如,将用户的姓名注入到欢迎信息中。例如,考虑以下模板:

Hello {{name}}, how are you? 

我们能够替换前面模板中的{{name}}文本,用一个人的真实姓名替换。所以如果布鲁斯登录,他可能会看到:

Hello Bruce, how are you? 

Go 标准库有两个主要的模板包:一个叫做text/template用于文本,另一个叫做html/template用于 HTML。html/template包与文本版本的功能相同,但它理解数据将被注入模板的上下文。这很有用,因为它避免了脚本注入攻击,并解决了需要为 URL 编码特殊字符等常见问题。

初始时,我们只想将 HTML 代码从 Go 代码内部移动到自己的文件中,但暂时不会混合任何文本。模板包使得加载外部文件变得非常容易,因此对我们来说是一个不错的选择。

在我们的chat文件夹下创建一个新的文件夹,命名为templates,并在其中创建一个chat.html文件。我们将把 HTML 从main.go移动到这个文件中,但我们会进行一些小的修改以确保我们的更改生效:

<html> 
  <head> 
    <title>Chat</title> 
  </head> 
  <body> 
    Let's chat (from template) 
  </body> 
</html> 

现在,我们的外部 HTML 文件已经准备好了,但我们需要一个方法来编译模板并将其提供给用户的浏览器。

小贴士

编译模板是一个将源模板进行解释并准备与各种数据混合的过程,这必须在模板可以使用之前发生,但只需要发生一次。

我们将编写自己的 struct 类型,该类型负责加载、编译和交付我们的模板。我们将定义一个新的类型,它将接受一个 filename 字符串,一次性编译模板(使用 sync.Once 类型),保留编译后的模板引用,然后响应 HTTP 请求。你需要导入 text/templatepath/filepathsync 包来构建你的代码。

main.go 中,在 func main() 行之前插入以下代码:

// templ represents a single template 
type templateHandler struct { 
  once     sync.Once 
  filename string 
  templ    *template.Template 
} 
// ServeHTTP handles the HTTP request. 
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r  *http.Request) { 
  t.once.Do(func() { 
    t.templ =  template.Must(template.ParseFiles(filepath.Join("templates",
      t.filename))) 
  }) 
  t.templ.Execute(w, nil) 
} 

小贴士

你知道你可以自动化添加和删除导入的包吗?请参阅 附录,稳定 Go 环境的良好实践,了解如何做到这一点。

templateHandler 类型有一个名为 ServeHTTP 的单一方法,其签名看起来与之前传递给 http.HandleFunc 的方法非常相似。此方法将加载源文件,编译模板并执行它,然后将输出写入指定的 http.ResponseWriter 方法。因为 ServeHTTP 方法满足 http.Handler 接口,所以我们可以直接将其传递给 http.Handle

小贴士

快速查看位于 golang.org/pkg/net/http/#Handler 的 Go 标准库源代码,会发现 http.Handler 接口定义指定,只有 ServeHTTP 方法存在,类型才能被 net/http 包用来服务 HTTP 请求。

一次性完成任务

我们只需要编译一次模板,在 Go 中有几种不同的方法来实现这一点。最明显的方法是有一个 NewTemplateHandler 函数,它创建类型并调用一些初始化代码来编译模板。如果我们确定该函数只会被一个 goroutine 调用(可能是 main 函数中的设置阶段),这将是一个完全可接受的方法。另一种方法,我们在前面的部分中已经使用过,是在 ServeHTTP 方法内部一次性编译模板。sync.Once 类型保证我们传递给参数的函数只执行一次,无论有多少个 goroutine 调用 ServeHTTP。这很有帮助,因为 Go 中的 web 服务器是自动并发的,一旦我们的聊天应用风靡全球,我们完全可能期望有多个并发调用 ServeHTTP 方法。

ServeHTTP 方法内部编译模板也确保了我们的代码不会在确定需要之前浪费时间做工作。这种惰性初始化方法在我们当前的案例中节省不了多少时间,但在设置任务耗时和资源密集且功能使用频率较低的情况下,很容易看出这种方法会很有用。

使用自己的处理器

要实现我们的 templateHandler 类型,我们需要更新 main 函数的主体,使其看起来像这样:

func main() { 
  // root 
  http.Handle("/", &templateHandler{filename: "chat.html"}) 
  // start the web server 
  if err := http.ListenAndServe(":8080", nil); err != nil { 
    log.Fatal("ListenAndServe:", err) 
  } 
} 

templateHandler 结构体是一个有效的 http.Handler 类型,因此我们可以直接将其传递给 http.Handle 函数,并要求它处理与指定模式匹配的请求。在之前的代码中,我们创建了一个新的 templateHandler 类型的对象,指定文件名为 chat.html,然后我们使用操作符的地址(&)获取其地址,并将其传递给 http.Handle 函数。我们没有存储新创建的 templateHandler 类型的引用,但这没关系,因为我们不需要再次引用它。

在您的终端中,通过按 Ctrl + C 退出程序,然后重新运行它,然后刷新浏览器,注意(来自模板)文本的添加。现在我们的代码比 HTML 代码简单得多,并且摆脱了其丑陋的块。

正确构建和执行 Go 程序

使用 go run 命令运行 Go 程序在我们代码仅由一个 main.go 文件组成时非常棒。然而,我们可能很快就需要添加其他文件。这要求我们在运行之前正确地将整个包构建成一个可执行的二进制文件。这很简单,从现在开始,这就是您在终端中构建和运行程序的方式:

go build -o {name}
./{name}

go build 命令使用指定文件夹中的所有 .go 文件创建输出二进制文件,-o 标志表示生成的二进制文件名。然后您可以直接通过名称调用程序来运行程序。

例如,在我们的聊天应用案例中,我们可以运行:

go build -o chat
./chat

由于我们是在页面首次提供服务时编译模板,因此每次有任何更改时,我们都需要重新启动您的 Web 服务器程序,以便看到更改生效。

在服务器上模拟聊天室和客户端

我们聊天应用的所有用户(客户端)将自动被放置在一个大型的公共聊天室中,每个人都可以与任何人聊天。room 类型将负责管理客户端连接和消息的进出路由,而 client 类型代表与单个客户端的连接。

提示

Go 将类称为类型,将那些类的实例称为对象。

为了管理我们的 WebSocket,我们将使用 Go 社区开源第三方包中最强大的功能之一。每天都有新的包解决现实世界的问题发布,供您在自己的项目中使用,它们甚至允许您添加功能、报告和修复错误,并获得支持。

提示

除非你有非常好的理由,否则重新发明轮子通常是不明智的。所以在开始构建一个新的包之前,值得搜索任何可能已经解决了你问题的现有项目。如果你找到一个类似的项目,但它并不完全满足你的需求,考虑为该项目做出贡献并添加功能。Go 有一个特别活跃的开源社区(记住 Go 本身是开源的),它总是准备好欢迎新面孔或头像。

我们将使用 Gorilla Project 的websocket包来处理我们的服务器端套接字,而不是自己编写。如果你对它是如何工作的感到好奇,请前往 GitHub 上的项目主页github.com/gorilla/websocket,并浏览开源代码。

客户端建模

chat文件夹中与main.go文件并列创建一个名为client.go的新文件,并添加以下代码:

package main  
import ( 
  "github.com/gorilla/websocket" 
) 
// client represents a single chatting user. 
type client struct { 
  // socket is the web socket for this client. 
  socket *websocket.Conn 
  // send is a channel on which messages are sent. 
  send chan []byte 
  // room is the room this client is chatting in. 
  room *room 
} 

在前面的代码中,socket将持有对网络套接字的引用,这将允许我们与客户端通信,而send字段是一个缓冲通道,通过它接收到的消息被排队,准备转发到用户的浏览器(通过套接字)。room字段将保持对客户端正在聊天的房间的引用,这是必要的,这样我们才能将消息转发给房间中的其他所有人。

如果你尝试构建此代码,你会注意到一些错误。你必须确保你已经调用go get来检索websocket包,这就像打开一个终端并输入以下内容一样简单:

go get github.com/gorilla/websocket

再次构建代码将产生另一个错误:

./client.go:17 undefined: room

问题是我们引用了一个room类型,但没有在任何地方定义它。为了让编译器高兴,创建一个名为room.go的文件,并插入以下占位符代码:

package main 
type room struct { 
  // forward is a channel that holds incoming messages 
  // that should be forwarded to the other clients. 
  forward chan []byte 
} 

我们将在了解我们的房间需要做什么之后,稍后改进这个定义,但现在这将允许我们继续前进。稍后,forward通道是我们将用来将传入的消息发送给所有其他客户端的通道。

注意

你可以将通道想象成一个内存中的线程安全消息队列,发送者通过它传递数据,接收者以非阻塞、线程安全的方式读取数据。

为了让客户端做任何工作,我们必须定义一些方法,这些方法将执行实际的读取和写入操作,从/到网络套接字。将以下代码添加到client.go文件中(在client结构体外部下方)将为client类型添加两个名为readwrite的方法:

func (c *client) read() { 
  defer c.socket.Close() 
  for { 
    _, msg, err := c.socket.ReadMessage() 
    if err != nil { 
      return 
    } 
    c.room.forward <- msg 
  } 
} 
func (c *client) write() { 
  defer c.socket.Close() 
  for msg := range c.send { 
    err := c.socket.WriteMessage(websocket.TextMessage, msg) 
    if err != nil { 
      return 
    } 
  } 
} 

read方法允许我们的客户端通过ReadMessage方法从套接字读取,并将接收到的任何消息持续发送到room类型的forward通道。如果遇到错误(例如'the socket has died'),循环将中断,套接字将被关闭。同样,write方法持续从send通道接收消息,并通过WriteMessage方法将所有内容写入套接字。如果写入套接字失败,for循环将中断,套接字将被关闭。再次构建包以确保一切都能编译。

注意

在前面的代码中,我们介绍了defer关键字,这值得稍微探索一下。我们要求 Go 在函数退出时运行c.socket.Close()。这在需要在一个函数中做一些清理工作(例如关闭文件或,在我们的例子中,关闭套接字)但不确定函数将在哪里退出时非常有用。随着代码的增长,如果这个函数有多个return语句,我们就不需要添加更多关闭套接字的调用,因为这个单独的defer语句会捕获它们所有。

有些人抱怨使用defer关键字时的性能,因为它不如在函数的每个退出点之前键入close语句那样表现得好。你必须权衡运行时性能成本与代码维护成本以及可能引入的潜在错误。一般来说,编写干净、清晰的代码是获胜的关键;毕竟,如果我们足够幸运,我们总是可以回来优化我们认为会减慢我们的产品速度的任何代码片段。

建模房间

我们需要一个方法让客户端能够加入和离开房间,以确保上一节中的c.room.forward <- msg代码实际上将消息转发给所有客户端。为了确保我们不会同时尝试访问相同的数据,一个合理的方法是使用两个通道:一个用于将客户端添加到房间,另一个用于将其移除。让我们更新我们的room.go代码,使其看起来像这样:

package main 
type room struct { 
  // forward is a channel that holds incoming messages 
  // that should be forwarded to the other clients. 
  forward chan []byte 
  // join is a channel for clients wishing to join the room. 
  join chan *client 
  // leave is a channel for clients wishing to leave the room. 
  leave chan *client 
  // clients holds all current clients in this room. 
  clients map[*client]bool 
} 

我们添加了三个字段:两个通道和一个映射。joinleave通道仅仅是为了允许我们安全地向clients映射添加和移除客户端。如果我们直接访问映射,可能两个并发运行的 goroutine 会尝试同时修改映射,从而导致内存损坏或不可预测的状态。

使用惯用的 Go 进行并发编程

现在我们可以使用 Go 并发提供的非常强大的功能——select语句。当我们需要同步或修改共享内存,或者根据我们通道中的各种活动采取不同的操作时,我们可以使用select语句。

room结构体下面,添加以下包含三个select情况的run方法:

func (r *room) run() { 
  for { 
    select { 
    case client := <-r.join: 
      // joining 
      r.clients[client] = true 
    case client := <-r.leave: 
      // leaving 
      delete(r.clients, client) 
      close(client.send) 
    case msg := <-r.forward: 
      // forward message to all clients 
      for client := range r.clients { 
        client.send <- msg 
      } 
    } 
  } 
} 

虽然这看起来可能是一大堆难以消化的代码,但如果我们稍微分解一下,我们会看到它相当简单,尽管非常强大。顶部的for循环表示该方法将永远运行,直到程序终止。这看起来可能像是一个错误,但请记住,如果我们以 goroutine 的形式运行此代码,它将在后台运行,这不会阻塞我们的应用程序的其他部分。前面的代码将不断监视房间内的三个通道:joinleaveforward。如果任何这些通道收到消息,select语句将运行特定情况的代码。

注意

重要的是要记住,它一次只会运行一个代码块。这就是我们能够同步以确保我们的r.clients映射一次只被一件事修改的原因。

如果我们在join通道上收到消息,我们只需更新r.clients映射以保持对已加入房间的客户端的引用。请注意,我们将值设置为true。我们更像是使用切片,但不必担心随着客户端的来去而缩小切片,将值设置为true只是存储引用的一种方便、低内存的方式。

如果我们在leave通道上收到消息,我们只需从映射中删除client类型,并关闭其send通道。如果我们收到forward通道上的消息,我们将遍历所有客户端并将消息添加到每个客户端的send通道。然后,我们的客户端类型的write方法将取回它并通过套接字发送到浏览器。

将房间转换为 HTTP 处理程序

现在我们将我们的room类型转换为http.Handler类型,就像我们之前对模板处理程序所做的那样。如您所回忆的,要做到这一点,我们必须简单地添加一个名为ServeHTTP的方法,并具有适当的签名。

将以下代码添加到room.go文件的底部:

const ( 
  socketBufferSize  = 1024 
  messageBufferSize = 256 
)  
var upgrader = &websocket.Upgrader{ReadBufferSize:  socketBufferSize,
  WriteBufferSize: socketBufferSize}  
func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) { 
  socket, err := upgrader.Upgrade(w, req, nil) 
  if err != nil { 
    log.Fatal("ServeHTTP:", err) 
    return 
  } 
  client := &client{ 
    socket: socket, 
    send:   make(chan []byte, messageBufferSize), 
    room:   r, 
  } 
  r.join <- client 
  defer func() { r.leave <- client }() 
  go client.write() 
  client.read() 
} 

TipIf you accessed the chat endpoint in a web browser, you would likely crash the program and see an error like **ServeHTTPwebsocket: version != 13**. This is because it is intended to be accessed via a web socket rather than a web browser.

为了使用 WebSockets,我们必须使用websocket.Upgrader类型升级 HTTP 连接,它是可重用的,所以我们只需要创建一个。然后,当通过ServeHTTP方法收到请求时,我们通过调用upgrader.Upgrade方法来获取套接字。如果一切顺利,我们然后创建我们的客户端并将其传递到当前房间的join通道。我们还推迟了客户端完成后的离开操作,这将确保在用户离开后一切都会整理干净。

客户端的write方法随后作为 goroutine 调用,如行首的三个字符go(单词go后跟一个空格字符)所示。这告诉 Go 在另一个线程或 goroutine 中运行该方法。

注意

将在其他语言中实现多线程或并发的代码量与 Go 中实现它的三个按键进行比较,您将了解为什么它已成为系统开发人员中的宠儿。

最后,我们在主线程中调用 read 方法,这将阻塞操作(保持连接活跃)直到关闭它。在代码片段顶部添加常量是声明那些在其他地方硬编码的值的良好实践。随着这些值的增加,你可能考虑将它们放在一个单独的文件中,或者至少在各自的文件顶部,以便它们易于阅读和修改。

使用辅助函数来减少复杂性

我们的房间几乎准备好了,尽管为了使其有用,需要创建通道和地图。目前,这可以通过要求开发者使用以下代码来确保完成这项工作:

r := &room{ 
  forward: make(chan []byte), 
  join:    make(chan *client), 
  leave:   make(chan *client), 
  clients: make(map[*client]bool), 
} 

另一个稍微更优雅的解决方案是提供一个 newRoom 函数,为我们完成这项工作。这消除了其他人了解为了使我们的房间有用需要做什么的必要。在 type room struct 定义下方添加此函数:

// newRoom makes a new room. 
func newRoom() *room { 
  return &room{ 
    forward: make(chan []byte), 
    join:    make(chan *client), 
    leave:   make(chan *client), 
    clients: make(map[*client]bool), 
  } 
} 

现在我们的代码用户只需要调用 newRoom 函数,而不是更冗长的六行代码。

创建和使用房间

让我们更新 main.go 中的 main 函数,首先创建一个房间,然后运行一个供所有人连接的房间:

func main() { 
  r := newRoom() 
  http.Handle("/", &templateHandler{filename: "chat.html"}) 
  http.Handle("/room", r) 
  // get the room going 
  go r.run() 
  // start the web server 
  if err := http.ListenAndServe(":8080", nil); err != nil { 
    log.Fatal("ListenAndServe:", err) 
  } 
} 

我们在一个单独的 goroutine 中运行房间(再次注意 go 关键字),这样聊天操作就会在后台进行,允许我们的主 goroutine 运行网络服务器。我们的服务器现在已经完成并成功构建,但没有客户端与之交互就毫无用处。

构建 HTML 和 JavaScript 聊天客户端

为了让我们的聊天应用的用户能够与服务器以及其他用户交互,我们需要编写一些客户端代码,利用现代浏览器中找到的 Web Sockets。当用户点击我们应用的根目录时,我们已经在通过模板传递 HTML 内容,因此我们可以增强这一点。

templates 文件夹中的 chat.html 文件更新以下标记:

<html> 
  <head> 
    <title>Chat</title> 
    <style> 
      input { display: block; } 
      ul    { list-style: none; } 
    </style> 
  </head> 
  <body> 
    <ul id="messages"></ul> 
    <form id="chatbox"> 
      <textarea></textarea> 
      <input type="submit" value="Send" /> 
       </form>  </body> 
</html> 

上述 HTML 将在页面上渲染一个简单的表单,包含一个文本区域和一个 发送 按钮,这是用户向服务器提交消息的方式。上述代码中的 messages 元素将包含聊天消息的文本,以便所有用户都能看到正在说的话。接下来,我们需要添加一些 JavaScript 来为我们的页面添加一些功能。在 form 标签下方,在关闭 </body> 标签上方,插入以下代码:

<script  src="img/jquery.min.js"> </script> 
    <script> 
      $(function(){ 
        var socket = null; 
        var msgBox = $("#chatbox textarea"); 
        var messages = $("#messages"); 
        $("#chatbox").submit(function(){ 
          if (!msgBox.val()) return false; 
          if (!socket) { 
            alert("Error: There is no socket connection."); 
            return false; 
          } 
          socket.send(msgBox.val()); 
          msgBox.val(""); 
          return false; 
        }); 
        if (!window["WebSocket"]) { 
          alert("Error: Your browser does not support web  sockets.") 
        } else { 
          socket = new WebSocket("ws://localhost:8080/room"); 
          socket.onclose = function() { 
            alert("Connection has been closed."); 
          } 
          socket.onmessage = function(e) { 
            messages.append($("<li>").text(e.data)); 
          } 
        } 
      }); 
    </script> 

socket = new WebSocket("ws://localhost:8080/room") 这一行是打开套接字并为两个关键事件 oncloseonmessage 添加事件处理程序的地方。当套接字接收到消息时,我们使用 jQuery 将消息追加到列表元素中,从而将其展示给用户。

提交 HTML 表单会触发对 socket.send 的调用,这是我们向服务器发送消息的方式。

再次构建并运行程序以确保模板重新编译,以便反映这些更改。

在两个不同的浏览器(或同一浏览器的两个标签页)中导航到http://localhost:8080/并玩转这个应用。你会注意到从一个客户端发送的消息会立即出现在其他客户端:

构建 HTML 和 JavaScript 聊天客户端

从模板中获得更多

目前,我们正在使用模板来提供静态 HTML,这很好,因为它为我们提供了一个干净简单的方法来将客户端代码与服务器端代码分离。然而,模板实际上功能更强大,我们将调整我们的应用程序以更现实地使用它们。

目前,我们的应用程序的主机地址(:8080)在两个地方硬编码。第一个实例是在main.go中,我们启动了 Web 服务器:

if err := http.ListenAndServe(":8080", nil); err != nil { 
  log.Fatal("ListenAndServe:", err) 
} 

第二次在打开套接字时硬编码在 JavaScript 中:

socket = new WebSocket("ws://localhost:8080/room"); 

我们的聊天应用如果坚持只在本地的8080端口上运行,就会显得相当固执,因此我们将使用命令行标志来使其可配置,然后利用模板的注入功能确保我们的 JavaScript 知道正确的宿主。

更新main.go中的main函数:

func main() {   
  var addr = flag.String("addr", ":8080", "The addr of the  application.") 
  flag.Parse() // parse the flags 
  r := newRoom() 
  http.Handle("/", &templateHandler{filename: "chat.html"}) 
  http.Handle("/room", r) 
  // get the room going 
  go r.run() 
  // start the web server 
  log.Println("Starting web server on", *addr) 
  if err := http.ListenAndServe(*addr, nil); err != nil { 
    log.Fatal("ListenAndServe:", err) 
  } 
} 

为了使此代码构建,你需要导入flag包。addr变量的定义将我们的标志设置为一个默认值为:8080的字符串(带有对值用途的简短描述)。我们必须调用flag.Parse()来解析参数并提取适当的信息。然后,我们可以通过使用*addr来引用主机标志的值。

注意

flag.String的调用返回一个*string类型,这意味着它返回一个字符串变量的地址,其中存储了标志的值。为了获取值本身(而不是值的地址),我们必须使用指针间接运算符,*

我们还添加了一个log.Println调用,以便在终端输出地址,这样我们就可以确保我们的更改已经生效。

我们将修改我们编写的templateHandler类型,使其将请求的详细信息作为数据传递给模板的Execute方法。在main.go中,更新ServeHTTP函数,将请求r作为data参数传递给Execute方法:

func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r  *http.Request) { 
  t.once.Do(func() { 
    t.templ =  template.Must(template.ParseFiles(filepath.Join("templates",
      t.filename))) 
  }) 
  t.templ.Execute(w, r) 
} 

这告诉模板使用可以从http.Request中提取的数据来渲染自身,这恰好包括我们需要的宿主地址。

要使用http.RequestHost值,我们可以利用特殊的模板语法,允许我们注入数据。更新在chat.html文件中创建套接字的行:

socket = new WebSocket("ws://{{.Host}}/room"); 

双大括号表示注释,以及我们告诉模板源注入数据的方式。{{.Host}}基本上等同于告诉它用request.Host的值替换注释(因为我们作为数据传递了请求r对象)。

提示

我们只是触及了 Go 标准库内置的模板的强大功能的一角。《text/template》包文档是一个学习更多关于你可以实现什么的好地方。你可以在golang.org/pkg/text/template了解更多。

再次重建并运行聊天程序,但这次请注意,无论我们指定哪个主机,聊天操作都不会产生错误:

go build -o chat 
./chat -addr=":3000" 

在浏览器中查看页面的源代码,并注意{{.Host}}已被实际的应用程序主机名替换。有效的宿主不仅限于端口号;您还可以指定环境允许的 IP 地址或其他主机名,例如,-addr="192.168.0.1:3000"

编写跟踪代码以查看内部结构

我们知道我们的应用程序正在工作的唯一方法是通过打开两个或更多浏览器并使用我们的 UI 发送消息。换句话说,我们正在手动测试我们的代码。这对于像我们的聊天应用这样的实验性项目或预期不会增长的小项目来说是可以的,但如果我们的代码要有一个更长的生命周期或由多个人共同工作,这种手动测试就成为一种负担。我们不会在我们的聊天程序中处理测试驱动开发TDD),但我们应该探索另一种有用的调试技术,称为跟踪

跟踪是一种实践,通过它我们在程序的流程中记录或打印关键步骤,以便使底层的操作变得可见。在前一节中,我们添加了一个log.Println调用,以输出聊天程序正在绑定的地址。在本节中,我们将正式化这一过程,并编写我们自己的完整跟踪包。

我们将在编写跟踪代码时探索 TDD 实践,因为 TDD 是我们可能重用、添加、共享,甚至希望开源的包的完美示例。

使用 TDD 编写包

Go 中的包组织到文件夹中,每个文件夹一个包。在同一个文件夹内有不同的包声明是一个构建错误,因为所有兄弟文件都预期为单个包做出贡献。Go 没有子包的概念,这意味着嵌套包(在嵌套文件夹中)仅存在于美学或信息目的,但不从父包继承任何功能或可见性。在我们的聊天应用中,所有文件都贡献给了main包,因为我们想构建一个可执行工具。我们的跟踪包永远不会直接运行,因此它可以,并且应该使用不同的包名。我们还需要考虑我们包的应用程序编程接口API),考虑如何构建一个包,使其尽可能地对用户具有可扩展性和灵活性。这包括应该导出(对用户可见)并为了简单起见保持隐藏的字段、函数、方法和类型。

注意

Go 使用名称的大写来表示哪些项是导出的,这意味着以大写字母开头的名称(例如,Tracer)对包的用户是可见的,而以小写字母开头的名称(例如,templateHandler)是隐藏的或私有的。

chat文件夹旁边创建一个名为trace的新文件夹,这样文件夹结构现在看起来是这样的:

/chat 
  client.go 
  main.go 
  room.go 
/trace 

在我们开始编写代码之前,让我们就我们的包的一些设计目标达成一致,这样我们就可以衡量成功:

  • 该包应该易于使用

  • 单元测试应涵盖功能

  • 用户应该有灵活性,可以用自己的实现替换跟踪器

接口

Go 中的接口是一个非常强大的语言特性,它允许我们定义一个 API,而不必在实现细节上过于严格或具体。 wherever possible,使用接口描述你的包的基本构建块通常会在未来带来回报,这就是我们将从我们的跟踪包开始的地方。

trace文件夹内创建一个名为tracer.go的新文件,并编写以下代码:

package trace 
// Tracer is the interface that describes an object capable of 
// tracing events throughout code. 
type Tracer interface { 
  Trace(...interface{}) 
} 

首先要注意的是,我们已经将我们的包定义为trace

注意

虽然让文件夹名称与包名称匹配是一个好习惯,但 Go 工具并不强制执行这一点,这意味着如果你觉得有意义,你可以自由地给它们不同的名称。记住,当人们导入你的包时,他们会输入文件夹的名称,如果突然导入了一个不同名称的包,可能会造成混淆。

我们的Tracer类型(大写T表示我们打算将其作为一个公开可见的类型)是一个接口,它描述了一个名为Trace的单个方法。...interface{}参数类型表示我们的Trace方法将接受零个或多个任何类型的参数。你可能认为这是一个冗余的规定,因为该方法应该只接受一个字符串(我们只想追踪一些字符字符串,不是吗?)。然而,考虑像fmt.Sprintlog.Fatal这样的函数,它们都遵循 Go 标准库中普遍存在的模式,在尝试一次性传达多个事物时提供了一个有用的快捷方式。 wherever possible,我们应该遵循这样的模式和做法,因为我们希望我们的 API 对 Go 社区来说既熟悉又清晰。

单元测试

我们承诺自己会遵循测试驱动实践,但接口只是定义,不提供任何实现,因此不能直接进行测试。但我们即将编写一个真正的Tracer方法实现,我们确实会先编写测试。

trace文件夹中创建一个名为tracer_test.go的新文件,并插入以下脚手架代码:

package trace 
import ( 
  "testing" 
)  
func TestNew(t *testing.T) { 
  t.Error("We haven't written our test yet") 
} 

测试从一开始就被构建到 Go 工具链中,使得编写可自动化的测试成为一等公民。测试代码与生产代码一起生活在以_test.go结尾的文件中。Go 工具将任何以Test开头(接受单个*testing.T参数)的函数视为单元测试,并且当运行我们的测试时将执行它。要为此包运行它们,在终端中导航到trace文件夹,并执行以下操作:

go test

你会发现我们的测试失败是因为我们在TestNew函数体中调用了t.Error

--- FAIL: TestNew (0.00 seconds)
 tracer_test.go:8: We haven't written our test yet
FAIL
exit status 1
FAIL  trace 0.011s

小贴士

在每次测试运行之前清除终端是一个很好的方法,以确保你不会将之前的运行与最近的运行混淆。在 Windows 上,你可以使用cls命令;在 Unix 机器上,clear命令做同样的事情。

显然,我们没有正确编写我们的测试,并且我们不期望它通过,所以让我们更新TestNew函数:

func TestNew(t *testing.T) { 
  var buf bytes.Buffer 
  tracer := New(&buf) 
  if tracer == nil { 
    t.Error("Return from New should not be nil") 
  } else { 
    tracer.Trace("Hello trace package.") 
    if buf.String() != "Hello trace package.\n" { 
      t.Errorf("Trace should not write '%s'.", buf.String()) 
    } 
  } 

} 

书中的大多数包都来自 Go 标准库,所以你可以添加适当的包的import语句来访问该包。其他的是外部的,这时你需要使用go get来下载它们,然后才能导入。对于这个案例,你需要在文件顶部添加import "bytes"

我们通过成为它的第一个用户来开始设计我们的 API。我们希望能够在bytes.Buffer变量中捕获我们的跟踪器的输出,这样我们就可以确保缓冲区中的字符串与预期值匹配。如果不匹配,调用t.Errorf将使测试失败。在此之前,我们检查确保从虚构的New函数返回的不是nil;如果是,测试将因为调用t.Error而失败。

红绿测试

现在运行go test实际上会产生一个错误;它抱怨没有New函数。我们在这里没有犯错误;我们正在遵循一种称为红绿测试的实践。红绿测试建议我们首先编写一个单元测试,看到它失败(或产生错误),编写尽可能少的代码来使该测试通过,然后重复这个过程。这里的关键点是,我们想要确保我们添加的代码实际上在做一些事情,同时确保我们编写的测试代码在测试有意义的内容。

考虑一下一个无意义的测试一分钟:

if true == true { 
  t.Error("True should be true") 
} 

对于true不可能是true(如果true等于false,那么是时候换一台新电脑了)在逻辑上是不可能的,因此我们的测试是毫无意义的。如果一个测试或声明无法失败,那么在其中找不到任何价值。

用你期望在特定条件下设置为true的变量替换true意味着这样的测试确实可以失败(比如当被测试的代码行为不当时),在这个时候,你有一个有意义的测试,值得贡献给代码库。

你可以将go test的输出视为一个待办事项列表,一次只解决一个问题。目前,关于缺少New函数的抱怨是我们唯一要解决的问题。在trace.go文件中,让我们添加尽可能少的代码来推进事情;在接口类型定义下方添加以下片段:

func New() {} 

现在运行go test显示我们的确取得了一些进展,尽管进展不大。我们现在有两个错误:

./tracer_test.go:11: too many arguments in call to New
./tracer_test.go:11: New(&buf) used as value

第一个错误告诉我们我们在向New函数传递参数,但New函数不接受任何参数。第二个错误说我们将New函数的返回值用作一个值,但New函数实际上并没有返回任何东西。你可能已经预料到了这一点,而且随着你编写测试驱动代码经验的增加,你很可能会跳过这样的琐事。然而,为了正确说明这种方法,我们将会详细说明。让我们通过更新我们的New函数以接受预期的参数来解决第一个错误:

func New(w io.Writer) {} 

我们正在使用满足io.Writer接口的参数,这意味着指定的对象必须有一个合适的Write方法。

注意

使用现有的接口,尤其是 Go 标准库中的接口,是确保你的代码尽可能灵活和优雅的极其强大且通常必要的方法。

接受io.Writer意味着用户可以决定跟踪输出将被写入的位置。这个输出可以是标准输出、一个文件、网络套接字、bytes.Buffer(如我们的测试用例所示),甚至是一些自定义对象,只要它可以像io.Writer接口一样行动。

再次运行go test显示我们已经解决了第一个错误,我们只需要添加一个返回类型就可以解决第二个错误:

func New(w io.Writer) Tracer {} 

我们声明New函数将返回一个Tracer,但我们实际上没有返回任何东西,这让go test很不高兴:

./tracer.go:13: missing return at end of function

修复这个问题很简单;我们只需从New函数返回nil即可:

func New(w io.Writer) Tracer { 
  return nil 
} 

当然,我们的测试代码已经断言返回值不应该为nil,所以go test现在给出了一个失败信息:

tracer_test.go:14: Return from New should not be nil

你可以看到这种对红绿原则的过度严格遵循可能会变得有些繁琐,但我们必须确保不要过于急躁。如果我们一次性写很多实现代码,我们很可能会有一些没有被单元测试覆盖的代码。

总是深思熟虑的核心团队甚至为我们解决了这个问题,通过提供代码覆盖率统计。以下命令提供了代码统计:

go test -cover

假设所有测试都通过,添加-cover标志将告诉我们测试执行期间我们的代码有多少被触及。显然,我们越接近 100%,就越好。

实现接口

为了满足这个测试,我们需要从 New 方法中正确返回一些内容,因为 Tracer 只是一个接口,我们必须返回一些真实的东西。让我们在我们的 tracer.go 文件中添加一个跟踪器的实现:

type tracer struct { 
  out io.Writer 
}  
func (t *tracer) Trace(a ...interface{}) {} 

我们的实现非常简单:tracer 类型有一个名为 outio.Writer 字段,这是我们将会写入跟踪输出的地方。而 Trace 方法正好符合 Tracer 接口所需的方法,尽管目前它还没有做任何事情。

现在我们可以最终修复 New 方法:

func New(w io.Writer) Tracer { 
  return &tracer{out: w} 
} 

再次运行 go test 显示我们的预期没有达到,因为在我们的 Trace 调用期间没有任何内容被写入:

tracer_test.go:18: Trace should not write ''.

让我们更新我们的 Trace 方法,将混合参数写入指定的 io.Writer 字段:

func (t *tracer) Trace(a ...interface{}) { 
  fmt.Fprint(t.out, a...) 
  fmt.Fprintln(t.out) 
} 

当调用 Trace 方法时,我们使用 fmt.Fprint(和 fmt.Fprintln)来格式化和将跟踪细节写入 out 写入器。

我们最终满足我们的测试了吗?

go test -cover
PASS
coverage: 100.0% of statements
ok    trace 0.011s

恭喜!我们已经成功通过了测试,并且测试覆盖率达到了 100%。在我们喝完一杯香槟之后,我们可以花一分钟时间考虑一下我们实现中非常有趣的一点。

返回给用户的未导出类型

我们编写的 tracer 结构体类型是 未导出的,因为它以小写字母 t 开头,那么我们是如何从导出的 New 函数中返回它的呢?毕竟,用户不会收到返回的对象吗?这是完全可接受和有效的 Go 代码;用户将只会看到一个满足 Tracer 接口的对象,并且永远不会知道我们的私有 tracer 类型。由于他们无论如何只与接口交互,所以我们的 tracer 实现公开了其他方法或字段,它们永远不会被看到。这使我们能够保持我们包的公共 API 清洁和简单。

这种隐藏的实现技术在整个 Go 标准库中都有使用;例如,ioutil.NopCloser 方法是一个将正常的 io.Reader 接口转换为 io.ReadCloser 的函数,其中 Close 方法不做任何事情(用于当不需要关闭的 io.Reader 对象被传递到需要 io.ReadCloser 类型的函数中时)。该方法对用户而言返回 io.ReadCloser,但在底层,有一个隐藏的 nopCloser 类型隐藏了实现细节。

注意

要亲自查看,请浏览 Go 标准库源代码在 golang.org/src/pkg/io/ioutil/ioutil.go 并搜索 nopCloser 结构体。

使用我们新的跟踪包

现在我们已经完成了 trace 包的第一个版本,我们可以在我们的聊天应用中使用它,以便更好地理解当用户通过用户界面发送消息时发生了什么。

room.go中,让我们导入我们的新包并调用Trace方法。我们刚刚编写的trace包的路径将取决于你的GOPATH环境变量,因为导入路径是相对于$GOPATH/src文件夹的。所以如果你在$GOPATH/src/mycode/trace中创建你的trace包,那么你需要导入mycode/trace

按照以下方式更新room类型和run()方法:

type room struct { 
  // forward is a channel that holds incoming messages 
  // that should be forwarded to the other clients. 
  forward chan []byte 
  // join is a channel for clients wishing to join the room. 
  join chan *client 
  // leave is a channel for clients wishing to leave the room. 
  leave chan *client 
  // clients holds all current clients in this room. 
  clients map[*client]bool  
  // tracer will receive trace information of activity 
  // in the room. 
  tracer trace.Tracer 
} 
func (r *room) run() { 
  for { 
    select { 
    case client := <-r.join: 
      // joining 
      r.clients[client] = true 
      r.tracer.Trace("New client joined") 
    case client := <-r.leave: 
      // leaving 
      delete(r.clients, client) 
      close(client.send) 
      r.tracer.Trace("Client left") 
    case msg := <-r.forward: 
      r.tracer.Trace("Message received: ", string(msg)) 
      // forward message to all clients 
      for client := range r.clients { 
        client.send <- msg 
        r.tracer.Trace(" -- sent to client") 
      } 
    } 
  } 
}  

我们在我们的room类型中添加了一个trace.Tracer字段,然后在代码中周期性地调用Trace方法。如果我们运行我们的程序并尝试发送消息,你会注意到应用程序崩溃,因为tracer字段是nil。我们可以通过在创建我们的room类型时创建和分配适当的对象来解决这个问题。更新main.go文件以执行此操作:

r := newRoom() 
r.tracer = trace.New(os.Stdout) 

我们使用我们的New方法创建一个对象,该对象将输出发送到os.Stdout标准输出管道(这是一种技术性的说法,意思是我们要将其输出打印到我们的终端)。

重新构建并运行程序,使用两个浏览器来玩这个应用程序,并注意现在终端有一些有趣的跟踪信息供我们查看:

使用我们的新跟踪包

现在我们能够使用调试信息来深入了解应用程序正在做什么,这将有助于我们在开发和支持我们的项目时。

使跟踪可选

一旦应用程序发布,如果我们只是将生成的跟踪信息打印到某个终端,或者更糟糕的是,如果它给我们的系统管理员造成了很多噪音,那么这种跟踪信息将变得相当无用。此外,请记住,当我们没有为我们的room类型设置跟踪器时,我们的代码会崩溃,这不是一个用户友好的情况。为了解决这两个问题,我们打算通过在trace包中添加一个trace.Off()方法来增强我们的trace包,该方法将返回一个满足Tracer接口的对象,但在调用Trace方法时不会做任何事情。

让我们添加一个测试,在调用Trace方法之前调用Off函数来获取一个静默跟踪器,以确保代码不会崩溃。由于跟踪不会发生,我们可以在测试代码中做的就只有这些。将以下测试函数添加到tracer_test.go文件中:

func TestOff(t *testing.T) { 
  var silentTracer Tracer = Off() 
  silentTracer.Trace("something") 
} 

为了使其通过,将以下代码添加到tracer.go文件中:

type nilTracer struct{} 

func (t *nilTracer) Trace(a ...interface{}) {} 

// Off creates a Tracer that will ignore calls to Trace. 
func Off() Tracer { 
  return &nilTracer{} 
} 

我们的nilTracer结构体定义了一个不执行任何操作的Trace方法,调用Off()方法将创建一个新的nilTracer结构体并返回它。请注意,我们的nilTracer结构体与我们的tracer结构体不同,因为它不接收io.Writer接口;它不需要,因为它不会写入任何内容。

现在,让我们通过更新room.go文件中的newRoom方法来解决我们的第二个问题:

func newRoom() *room { 
  return &room{ 
    forward: make(chan []byte), 
    join:    make(chan *client), 
    leave:   make(chan *client), 
    clients: make(map[*client]bool), 
    tracer:  trace.Off(), 
  } 
} 

默认情况下,我们的 room 类型将使用 nilTracer 结构体创建,并且对 Trace 的任何调用都将被忽略。你可以通过从 main.go 文件中移除 r.tracer = trace.New(os.Stdout) 行来尝试这一点:注意当你使用应用程序时,终端上没有任何内容被写入,也没有发生恐慌。

清洁的包 API

快速查看我们的 trace 包的 API(在这个上下文中,暴露的变量、方法和类型)突显出一个简单且明显的设计已经出现:

  • New() 方法 - 创建一个 Tracer 的新实例

  • Off() 方法 - 获取一个不执行任何操作的 Tracer 对象

  • Tracer 接口 - 描述 Tracer 对象将实现的方法

我会非常有信心将这个包提供给一个没有文档或指南的 Go 程序员,而且我相当确信他们会知道如何使用它。

注意

在 Go 中,添加文档就像在每项之前添加注释一样简单。关于这个主题的博客文章值得一读(blog.golang.org/godoc-documenting-go-code),在那里你可以看到 tracer.go 的托管源代码副本,它是 trace 包的一个示例,展示了你如何注释 trace 包。更多信息,请参阅 github.com/matryer/goblueprints/blob/master/chapter1/trace/tracer.go

摘要

在本章中,我们开发了一个完整的并发聊天应用程序,以及我们自己的简单包来跟踪程序流程,以帮助我们更好地理解底层发生了什么。

我们使用了 net/http 包来快速构建了一个最终证明非常强大的并发 HTTP 网络服务器。在某个特定情况下,我们将连接升级以在客户端和服务器之间打开一个 WebSocket。这意味着我们可以轻松快速地向用户的网络浏览器发送消息,而无需编写混乱的轮询代码。我们探讨了模板如何有助于将代码与内容分离,以及如何将数据注入模板源,这使得我们可以使主机地址可配置。命令行标志帮助我们向托管我们应用程序的人提供简单的配置控制,同时也让我们可以指定合理的默认值。

我们的聊天应用程序利用了 Go 强大的并发能力,使我们能够在几行惯用的 Go 代码中编写清晰的 线程 代码。通过通过通道控制客户端的进出,我们能够在代码中设置同步点,防止我们尝试同时修改相同的对象而损坏内存。

我们学习了如何通过接口如 http.Handler 和我们自己的 trace.Tracer 接口,在不触及使用它们的代码的情况下提供不同的实现,在某些情况下,甚至不需要向用户暴露实现名称。我们看到,只需在我们的 room 类型中添加一个 ServeHTTP 方法,我们就能将我们的自定义房间概念转换成一个有效的 HTTP 处理器对象,该对象管理我们的 WebSocket 连接。

实际上,我们离能够正确发布我们的应用程序并不遥远,除了一个主要的疏忽:你无法看到谁发送了每条消息。我们没有用户或用户名的概念,对于一个真正的聊天应用程序来说,这是不可接受的。

在下一章中,我们将添加回复消息的人的名字,以便让他们感觉像是在与其他人类进行真正的对话。

第二章:添加用户账户

在上一章中,我们构建的聊天应用专注于从客户端到服务器以及从服务器返回的高性能消息传输。然而,目前的情况是,我们的用户不知道他们将和谁交谈。解决这个问题的方法之一是构建某种注册和登录功能,并让我们的用户在打开聊天页面之前创建账户并验证身份。

每当我们准备从头开始构建某样东西时,我们必须问自己,别人之前是如何解决这个问题(真正原创的问题极其罕见)的,以及是否已经存在我们可以利用的开放解决方案或标准。授权和认证几乎不能被认为是新问题,尤其是在网络世界中,有许多不同的协议可供选择。那么我们如何决定最佳选择呢?一如既往,我们必须从用户的角度来看待这个问题。

现在,许多网站都允许你使用存在于各种社交媒体或社区网站上的账户进行登录。这节省了用户在决定尝试不同的产品和服务时,反复输入所有账户信息的繁琐工作。这对新网站的转化率也有积极的影响。

在本章中,我们将增强我们的聊天代码库以添加授权功能,这将允许我们的用户使用 Google、Facebook 或 GitHub 进行登录,你将看到添加其他登录端口是多么简单。为了加入聊天,用户必须首先登录。在此之后,我们将使用授权数据来增强用户体验,以便每个人都知道房间里是谁,谁说了什么。

在本章中,你将学习到:

  • 使用装饰器模式将http.Handler类型包装起来,以便为处理器添加额外的功能

  • 使用动态路径提供 HTTP 端点

  • 使用gomniauth开源项目访问认证服务

  • 使用http包获取和设置 cookie

  • 将对象编码为 Base64,然后再将其转换回正常格式

  • 通过 Websocket 发送和接收 JSON 数据

  • 向模板提供不同类型的数据

  • 使用你自己的类型通道

处理到底

对于我们的聊天应用,我们实现了自己的http.Handler类型(房间),以便轻松编译、执行并将 HTML 内容传递给浏览器。由于这是一个非常简单但功能强大的接口,我们在添加 HTTP 处理功能时将尽可能继续使用它。

为了确定用户是否有权继续操作,我们将创建一个授权包装处理器,该处理器将执行检查,并且只有当用户被授权时,才会将执行传递给内部处理器。

我们的外部处理器将满足与内部对象相同的http.Handler接口,允许我们包装任何有效的处理器。实际上,我们即将编写的认证处理器也可以在需要时封装在类似的包装器中。

层层处理

应用到 HTTP 处理器的链式模式

上述图示显示了如何在更复杂的 HTTP 处理器场景中应用此模式。每个对象都实现了http.Handler接口。这意味着一个对象可以被传递给http.Handle方法以直接处理请求,或者它可以被提供给另一个对象,该对象可以添加某种额外功能。Logging处理器可能在调用内部处理器的ServeHTTP方法前后写入日志文件。因为内部处理器只是另一个http.Handler,任何其他处理器都可以被Logging处理器包装(或装饰)。

通常,一个对象会包含决定哪个内部处理器应该被执行的逻辑。例如,我们的认证处理器将传递执行给包装的处理器,或者通过向浏览器发出重定向来处理请求本身。

现在已经有了足够的理论;让我们编写一些代码。在chat文件夹中创建一个名为auth.go的新文件:

package main 
import ("net/http") 
type authHandler struct { 
  next http.Handler 
} 
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r  *http.Request) { 
  _, err := r.Cookie("auth") 
  if err == http.ErrNoCookie { 
    // not authenticated 
    w.Header().Set("Location", "/login") 
    w.WriteHeader(http.StatusTemporaryRedirect) 
    return 
  }  
  if err != nil { 
    // some other error 
   http.Error(w, err.Error(), http.StatusInternalServerError) 
   return 
  }  
  // success - call the next handler 
  h.next.ServeHTTP(w, r) 
} 
func MustAuth(handler http.Handler) http.Handler { 
  return &authHandler{next: handler} 
} 

authHandler类型不仅实现了ServeHTTP方法(满足http.Handler接口),还在next字段中存储(包装)http.Handler。我们的MustAuth辅助函数简单地创建一个包装任何其他http.HandlerauthHandler。这就是允许我们在main.go中轻松添加授权的模式的例子。

让我们调整以下根映射行:

http.Handle("/", &templateHandler{filename: "chat.html"}) 

让我们更改第一个参数,使其明确表示用于聊天的页面。接下来,让我们使用MustAuth函数将templateHandler包装为第二个参数:

http.Handle("/chat", MustAuth(&templateHandler{filename:  "chat.html"})) 

使用MustAuth函数包装templateHandler会导致执行首先通过authHandler;如果请求已认证,它将只运行到templateHandler

authHandler中的ServeHTTP方法会寻找一个名为auth的特殊 cookie,如果 cookie 缺失,它将使用http.ResponseWriter上的HeaderWriteHeader方法将用户重定向到登录页面。请注意,我们使用下划线字符丢弃 cookie 本身,只捕获返回的错误;这是因为在这个阶段我们只关心 cookie 是否存在。

构建并运行聊天应用程序,尝试访问http://localhost:8080/chat

go build -o chat
./chat -host=":8080"

小贴士

您需要删除您的 cookies 以清除之前的认证令牌或任何可能遗留在通过 localhost 提供其他开发项目的其他 cookies。

如果您查看浏览器地址栏,您会注意到您立即被重定向到/login页面。由于我们目前无法处理该路径,您将只会得到404 页面未找到错误。

制作一个漂亮的社交登录页面

到目前为止,我们并没有太多关注使我们的应用程序看起来很漂亮;毕竟,这本书是关于 Go 语言,而不是用户界面开发。然而,没有理由去构建丑陋的应用程序,因此我们将构建一个既美观又实用的社交登录页面。

Bootstrap 是一个用于在网络上开发响应式项目的前端框架。它提供了 CSS 和 JavaScript 代码,以一致且美观的方式解决许多用户界面问题。虽然使用 Bootstrap 构建网站往往看起来很相似(尽管有众多方法可以自定义 UI),但它对于应用程序的早期版本或没有设计师的开发商来说是一个很好的选择。

小贴士

如果你使用 Bootstrap 提出的语义标准构建你的应用程序,那么为你的网站或应用程序创建一个 Bootstrap 主题将变得容易,而且你知道它将完美地嵌入到你的代码中。

我们将使用托管在 CDN 上的 Bootstrap 版本,这样我们就不必担心通过我们的聊天应用程序下载和提供自己的版本。这意味着为了正确渲染我们的页面,即使在开发过程中,我们也需要一个活跃的互联网连接。

如果你更喜欢下载并托管你自己的 Bootstrap 版本,你可以这样做。将文件保存在一个 assets 文件夹中,并在你的 main 函数中添加以下调用(它使用 http.Handle 通过你的应用程序提供资源):

http.Handle("/assets/", http.StripPrefix("/assets",    http.FileServer(http.Dir("/path/to/assets/"))))

注意

注意 http.StripPrefixhttp.FileServer 函数返回的对象满足我们通过 MustAuth 辅助函数实现的装饰器模式中的 http.Handler 接口。

main.go 中,让我们为登录页面添加一个端点:

http.Handle("/chat", MustAuth(&templateHandler{filename:  "chat.html"})) 
http.Handle("/login", &templateHandler{filename: "login.html"}) 
http.Handle("/room", r) 

显然,我们不希望在我们的登录页面中使用 MustAuth 方法,因为它将导致无限重定向循环。

在我们的 templates 文件夹内创建一个名为 login.html 的新文件,并插入以下 HTML 代码:

<html> 
  <head> 
    <title>Login</title> 
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com
     /bootstrap/3.3.6/css/bootstrap.min.css"> 
  </head> 
  <body> 
    <div class="container"> 
      <div class="page-header"> 
        <h1>Sign in</h1> 
      </div> 
      <div class="panel panel-danger"> 
        <div class="panel-heading"> 
          <h3 class="panel-title">In order to chat, you must be signed
          in</h3> 
        </div> 
        <div class="panel-body"> 
          <p>Select the service you would like to sign in with:</p> 
          <ul> 
            <li> 
              <a href="/auth/login/facebook">Facebook</a> 
            </li> 
            <li> 
              <a href="/auth/login/github">GitHub</a> 
            </li> 
            <li> 
              <a href="/auth/login/google">Google</a> 
            </li> 
          </ul> 
        </div> 
      </div> 
    </div> 
  </body> 
</html> 

重新启动网络服务器并导航到 http://localhost:8080/login。你会注意到现在它显示了我们的 登录 页面:

制作一个漂亮的社交登录页面

动态路径的端点

Go 标准库中 http 包的匹配模式并不是最全面和功能最丰富的实现。例如,Ruby on Rails 使得在路径中拥有动态段变得容易得多。你可以像这样映射路由:

"auth/:action/:provider_name" 

Rails 然后提供了一个数据映射(或字典),其中包含它从匹配的路径中自动提取的值。所以如果你访问 auth/login/google,那么 params[:provider_name] 将等于 google,而 params[:action] 将等于 login

http 包默认允许我们指定的最多是路径前缀,我们可以通过在模式末尾留下一个尾随斜杠来利用它:

"auth/" 

然后,我们必须手动解析剩余的段以提取适当的数据。这对于相对简单的情况是可以接受的。由于我们目前只需要处理几个不同的路径,例如以下路径,这符合我们的需求:

  • /auth/login/google

  • /auth/login/facebook

  • /auth/callback/google

  • /auth/callback/facebook

小贴士

如果您需要处理更高级的路由情况,您可能需要考虑使用专用包,例如gowebpatroutesmux。对于像我们这样极其简单的情况,内置功能就足够了。

我们将创建一个新的处理器来驱动我们的登录过程。在auth.go中添加以下loginHandler代码:

// loginHandler handles the third-party login process. 
// format: /auth/{action}/{provider} 
func loginHandler(w http.ResponseWriter, r *http.Request) { 
  segs := strings.Split(r.URL.Path, "/") 
  action := segs[2] 
  provider := segs[3] 
  switch action { 
  case "login": 
    log.Println("TODO handle login for", provider) 
      default: 
        w.WriteHeader(http.StatusNotFound) 
        fmt.Fprintf(w, "Auth action %s not supported", action) 
  } 
} 

在前面的代码中,我们在提取actionprovider的值之前,使用strings.Split将路径分解成段。如果已知动作值,我们将运行特定的代码;否则,我们将输出错误信息并返回一个http.StatusNotFound状态码(在 HTTP 状态码的术语中是404)。

注意

我们现在不会使代码无懈可击。但值得注意的是,如果有人用少量段击中loginHandler,我们的代码会崩溃,因为它会期望segs[2]segs[3]存在。

作为额外加分项,看看您是否能保护您的代码免受此影响,并在有人击中/auth/nonsense时返回一个友好的错误消息而不是让它崩溃。

我们的loginHandler只是一个函数,而不是实现http.Handler接口的对象。这是因为,与其他处理器不同,我们不需要它存储任何状态。Go 标准库支持这一点,因此我们可以使用http.HandleFunc函数以类似于我们之前使用http.Handle的方式将其映射。在main.go中更新处理器:

http.Handle("/chat", MustAuth(&templateHandler{filename:  "chat.html"})) 
http.Handle("/login", &templateHandler{filename: "login.html"}) 
http.HandleFunc("/auth/", loginHandler) 
http.Handle("/room", r) 

重新构建并运行聊天应用:

go build -o chat
./chat -host=":8080"

访问以下 URL 并注意终端中记录的输出:

  • http://localhost:8080/auth/login/google输出TODO handle login for google

  • http://localhost:8080/auth/login/facebook输出TODO handle login for facebook

我们已经成功实现了一个动态路径匹配机制,到目前为止只是打印出TODO消息;我们需要将其与授权服务集成,以便使我们的登录过程生效。

开始使用 OAuth2

OAuth2 是一个开放授权标准,旨在允许资源所有者通过访问令牌交换握手,允许客户端代表访问私有数据(如墙贴或推文)。即使您不想访问私有数据,OAuth2 也是一个很好的选择,允许人们使用现有的凭据登录,而不必将这些凭据暴露给第三方网站。在这种情况下,我们是第三方,我们希望允许我们的用户使用支持 OAuth2 的服务登录。

从用户的角度来看,OAuth2 流程如下:

  1. 用户选择他们希望登录到客户端应用的提供商。

  2. 用户将被重定向到提供者的网站(包含客户端应用 ID 的 URL),在那里他们被要求允许客户端应用。

  3. 用户从 OAuth2 服务提供者登录并接受第三方应用请求的权限。

  4. 用户将被重定向到客户端应用,并带有请求代码。

  5. 在后台,客户端应用将授权代码发送给提供者,提供者发送回一个身份验证令牌。

  6. 客户端应用使用访问令牌向提供者发送授权请求,例如获取用户信息或墙帖子。

为了避免重复造轮子,我们将查看一些已经为我们解决这个问题的一些开源项目。

开源 OAuth2 包

安德鲁·杰拉德自 2010 年 2 月以来一直在核心 Go 团队工作,即在 Go 1.0 正式发布前两年。他的 goauth2 包(见 github.com/golang/oauth2)是 OAuth2 协议的一个优雅实现,完全用 Go 编写。

安德鲁的项目启发了 gomniauth(见 github.com/stretchr/gomniauth)。作为 Ruby 的 omniauth 项目的开源 Go 替代方案,gomniauth 提供了一个统一的解决方案来访问不同的 OAuth2 服务。在未来,当 OAuth3(或下一代授权协议)出现时,理论上 gomniauth 可以承担实现细节的痛苦,而用户代码保持不变。

对于我们的应用,我们将使用 gomniauth 来访问 Google、Facebook 和 GitHub 提供的 OAuth 服务,因此请确保您已通过运行以下命令安装它:

go get github.com/stretchr/gomniauth

小贴士

gomniauth 的一些项目依赖项保存在 Bazaar 存储库中,因此您需要前往 wiki.bazaar.canonical.com 下载它们。

告知授权提供者您的应用信息

在我们请求授权提供者帮助我们的用户登录之前,我们必须告诉他们关于我们的应用信息。大多数提供者都有某种类型的网络工具或控制台,您可以在其中创建应用以启动此过程。以下是一个来自 Google 的示例:

告知授权提供者您的应用信息

为了识别客户端应用,我们需要创建一个客户端 ID 和密钥。尽管 OAuth2 是一个开放标准,但每个提供者都有自己的语言和机制来设置这些内容。因此,您很可能会不得不在每个案例中与用户界面或文档进行交互来找出解决方案。

在撰写本文时,在 Google Cloud Console 中,您需要导航到 API Manager 并点击 Credentials 部分。

在大多数情况下,为了增加安全性,你必须明确指定请求将来自哪个主机 URL。目前,由于我们正在本地托管我们的应用在localhost:8080,你应该使用它。你还将被要求提供一个重定向 URI,这是我们聊天应用中的端点,用户在成功登录后将被重定向到该端点。回调将是loginHandler中的另一个操作,因此 Google 客户端的重定向 URL 将是http://localhost:8080/auth/callback/google

一旦你完成了你想要支持的提供者的授权过程,你将为每个提供者获得一个客户端 ID 和密钥。请记住这些详细信息,因为在我们设置聊天应用中的提供者时我们需要它们。

注意

如果我们将我们的应用程序托管在真实域名上,我们必须创建新的客户端 ID 和密钥,或者更新我们的授权提供者上的适当 URL 字段,以确保它们指向正确的位置。无论如何,为了安全起见,拥有不同的一套开发和生产密钥是良好的实践。

实现外部日志记录

main.go (just underneath the flag.Parse() line toward the top of the main function):
// setup gomniauth 
gomniauth.SetSecurityKey("PUT YOUR AUTH KEY HERE") 
gomniauth.WithProviders( 
  facebook.New("key", "secret", 
    "http://localhost:8080/auth/callback/facebook"), 
  github.New("key", "secret", 
    "http://localhost:8080/auth/callback/github"), 
  google.New("key", "secret", 
    "http://localhost:8080/auth/callback/google"), 
) 

你应该用你之前记录的实际值替换keysecret占位符。第三个参数代表回调 URL,它应该与你在提供者网站上创建客户端时提供的 URL 相匹配。注意第二个路径段是callback;虽然我们还没有实现它,但这是我们处理授权过程响应的地方。

如同往常,你需要确保所有适当的包都已导入:

import ( 
  "github.com/stretchr/gomniauth/providers/facebook" 
  "github.com/stretchr/gomniauth/providers/github" 
  "github.com/stretchr/gomniauth/providers/google" 
) 

注意

Gomniauth 需要SetSecurityKey调用,因为它在客户端和服务器之间发送带有签名校验和的状态数据,这确保了状态值在传输过程中没有被篡改。安全密钥在创建哈希时使用,使得不知道确切的安全密钥几乎不可能重新创建相同的哈希。你应该用你选择的密钥或短语替换some long key

登录

现在我们已经配置了 Gomniauth,当用户到达我们的/auth/login/{provider}路径时,我们需要将用户重定向到提供者的授权页面。我们只需更新auth.go中的loginHandler函数:

func loginHandler(w http.ResponseWriter, r *http.Request) { 
  segs := strings.Split(r.URL.Path, "/") 
  action := segs[2] 
  provider := segs[3] 
  switch action { 
  case "login": 
    provider, err := gomniauth.Provider(provider) 
    if err != nil { 
      http.Error(w, fmt.Sprintf("Error when trying to get provider 
      %s: %s",provider, err), http.StatusBadRequest) 
      return 
    } 
    loginUrl, err := provider.GetBeginAuthURL(nil, nil) 
    if err != nil { 
      http.Error(w, fmt.Sprintf("Error when trying to GetBeginAuthURL            
      for %s:%s", provider, err), http. StatusInternalServerError) 
      return 
    } 
    w.Header.Set("Location", loginUrl) 
    w.WriteHeader(http.StatusTemporaryRedirect) 
    default: 
      w.WriteHeader(http.StatusNotFound) 
      fmt.Fprintf(w, "Auth action %s not supported", action) 
  } 
} 

在这里我们做两件主要的事情。首先,我们使用gomniauth.Provider函数来获取与 URL 中指定的对象(如googlegithub)匹配的提供者对象。然后,我们使用GetBeginAuthURL方法来获取我们必须将用户发送到以启动授权过程的位置。

注意

GetBeginAuthURL(nil, nil)参数分别代表状态和选项,我们不会在我们的聊天应用中使用它们。

第一个参数是一个编码并签名的数据状态映射,它被发送到认证提供者。提供者不对状态做任何处理;它只是将其发送回我们的回调端点。如果,例如,我们希望用户在认证过程介入之前返回他们尝试访问的原始页面,这很有用。对于我们的目的,我们只有/chat端点,所以我们不需要担心发送任何状态。

第二个参数是一个包含额外选项的映射,这些选项将被发送到认证提供者,从而以某种方式修改认证过程的行为。例如,您可以指定自己的scope参数,这允许您请求访问提供者额外信息的权限。有关可用选项的更多信息,请在互联网上搜索 OAuth2,或阅读每个提供者的文档,因为这些值因服务而异。

如果我们的代码在GetBeginAuthURL调用中没有收到错误,我们只需将用户的浏览器重定向到返回的 URL。

如果发生错误,我们使用http.Error函数以非 200状态码输出错误信息。

重新构建并运行聊天应用程序:

go build -o chat
./chat -host=":8080"

提示

我们将在整本书中继续手动停止、重新构建和运行我们的项目,但有一些工具会通过监视更改并自动重新启动 Go 应用程序来为您处理这些操作。如果您对此类工具感兴趣,请查看github.com/pilu/freshgithub.com/codegangsta/gin

通过访问http://localhost:8080/chat打开主聊天页面。由于我们尚未登录,我们被重定向到登录页面。点击Google选项使用您的 Google 账户登录,您会注意到您被展示了一个特定的 Google 登录页面(如果您尚未登录到 Google)。一旦登录,您将看到一个页面,要求您在查看您账户的基本信息之前允许我们的聊天应用程序:

登录

这是我们聊天应用程序用户在登录时将体验到的相同流程。

点击接受后,您会注意到您被重定向到我们的应用程序代码,但显示了一个Auth action callback not supported错误。这是因为我们尚未在loginHandler中实现回调功能。

处理提供者的响应

一旦用户在提供者网站上点击接受(或如果他们点击取消的等效选项),他们将被重定向到我们应用程序的回调端点。

快速查看返回的完整 URL,我们可以看到提供者给我们的授权码:

http://localhost:8080/auth/callback/google?code=4/Q92xJ- BQfoX6PHhzkjhgtyfLc0Ylm.QqV4u9AbA9sYguyfbjFEsNoJKMOjQI 

我们不必担心如何处理这段代码,因为 Gomniauth 会为我们处理;我们只需跳转到实现我们的回调处理程序。然而,了解这一点是值得的,即这段代码将由身份验证提供者交换为允许我们访问私有用户数据的令牌。为了增加安全性,这一额外步骤是在服务器之间而不是在浏览器中幕后发生的。

auth.go 中,我们准备为我们的操作路径段添加另一个 switch case。在默认 case 之前插入以下代码:

case "callback": 
  provider, err := gomniauth.Provider(provider) 
  if err != nil { 
    http.Error(w, fmt.Sprintf("Error when trying to get provider %s: %s",    
    provider, err), http.StatusBadRequest) 
    return 
  } 
  creds, err :=  provider.CompleteAuth(objx.MustFromURLQuery(r.URL.RawQuery)) 
  if err != nil { 
    http.Error(w, fmt.Sprintf("Error when trying to complete auth for 
    %s: %s", provider, err), http.StatusInternalServerError) 
    return 
  } 
  user, err := provider.GetUser(creds) 
  if err != nil { 
    http.Error(w, fmt.Sprintf("Error when trying to get user from %s: %s", 
    provider, err), http.StatusInternalServerError) 
    return 
  } 
  authCookieValue := objx.New(map[string]interface{}{ 
    "name": user.Name(), 
  }).MustBase64() 
  http.SetCookie(w, &http.Cookie{ 
    Name:  "auth", 
    Value: authCookieValue, 
    Path:  "/"}) 
  w.Header().Set("Location", "/chat") 
  w.WriteHeader(http.StatusTemporaryRedirect) 

当身份验证提供者在用户授予权限后重定向用户时,URL 指定这是一个回调操作。我们像之前一样查找身份验证提供者,并调用其 CompleteAuth 方法。我们将请求的 RawQuery 解析到 objx.Map(Gomniauth 使用的多功能映射类型),CompleteAuth 方法使用这些值与提供者完成 OAuth2 提供者握手。如果一切顺利,我们将获得一些授权凭证,我们可以用这些凭证访问用户的基本数据。然后我们使用提供者的 GetUser 方法,Gomniauth 将使用指定的凭证访问有关用户的一些基本信息。

一旦我们获取了用户数据,我们将在 JSON 对象中对 Name 字段进行 Base64 编码,并将其存储为 auth 甜点的值以供以后使用。

小贴士

Base64 编码数据确保它不会包含任何特殊或不可预测的字符,这在将数据传递到 URL 或将其存储在甜点中的情况下很有用。记住,尽管 Base64 编码的数据看起来是加密的,但你仍然可以轻松地将 Base64 编码的数据解码回原始文本,只需一点努力。有一些在线工具可以为你做这件事。

设置甜点后,我们将用户重定向到聊天页面,我们可以安全地假设这是原始目的地。

重新构建并运行代码后,访问 /chat 页面,你会注意到注册流程正常工作,我们最终被允许返回到聊天页面。大多数浏览器都有一个检查器或控制台——这是一个允许你查看服务器发送给你的甜点的工具——你可以用它来查看 auth 甜点是否出现:

go build -o chat
./chat -host=":8080"

在我们的例子中,甜点值是 eyJuYW1lIjoiTWF0IFJ5ZXIifQ==,这是 {"name":"Mat Ryer"} 的 Base64 编码版本。记住,我们在聊天应用程序中从未输入过名字;相反,当我们选择使用 Google 登录时,Gomniauth 向 Google 请求了一个名字。以这种方式存储未签名的甜点对于偶然信息,如用户的名字,是可以的;然而,你应该避免使用未签名的甜点存储任何敏感信息,因为这很容易被他人访问和更改数据。

展示用户数据

将用户数据放在 cookie 中是一个好的开始,但非技术人员永远不会知道它的存在,因此我们必须将数据带到前台。我们将通过增强templateHandler来实现这一点,首先将用户数据传递给模板的Execute方法;这允许我们在 HTML 中使用模板注解来向用户显示用户数据。

更新main.go中的templateHandlerServeHTTP方法:

func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r  *http.Request) { 
  t.once.Do(func() { 
    t.templ =  template.Must(template.ParseFiles(filepath.Join("templates",  
    t.filename))) 
  }) 
  data := map[string]interface{}{ 
    "Host": r.Host, 
  } 
  if authCookie, err := r.Cookie("auth"); err == nil { 
    data["UserData"] = objx.MustFromBase64(authCookie.Value) 
  } 
  t.templ.Execute(w, data) 
} 

我们不是仅仅将整个http.Request对象作为数据传递给我们的模板,而是在一个可能有两个字段的数据对象map[string]interface{}定义中创建一个新的,这两个字段是HostUserData(后者只有在存在authcookie 时才会出现)。通过指定映射类型后跟大括号,我们能够在创建映射的同时添加Host条目,同时完全避免使用make关键字。然后我们将这个新的data对象作为第二个参数传递给模板上的Execute方法。

现在我们将一个 HTML 文件添加到模板源中,以显示名称。更新chat.html中的chatbox表单:

<form id="chatbox"> 
  {{.UserData.name}}:<br/> 
  <textarea></textarea> 
  <input type="submit" value="Send" /> 
</form> 

{{.UserData.name}}注解告诉模板引擎在textarea控件之前插入我们的用户名。

小贴士

由于我们正在使用objx包,别忘了运行go get http://github.com/stretchr/objx并导入它。额外的依赖项会增加项目的复杂性,因此你可能决定从包中复制粘贴适当的函数,甚至编写自己的代码,在 Base64 编码的 cookie 和回之间进行序列化和反序列化。

或者,你可以通过复制整个源代码到你的项目(在名为vendor的根级文件夹中)来供应商依赖项。Go 在构建时,首先会在vendor文件夹中查找任何导入的包,然后再在$GOPATH中查找(由go get放置在那里)。这允许你修复依赖项的确切版本,而不是依赖于源包自你编写代码以来没有发生变化的事实。

有关在 Go 中使用供应商的更多信息,请查看 Daniel Theophanes 关于该主题的帖子,网址为blog.gopheracademy.com/advent-2015/vendor-folder/,或搜索vendoring in Go

重新构建并再次运行聊天应用,你将注意到在聊天框之前添加了你的名字:

go build -o chat
./chat -host=":8080"

通过附加数据增强消息

到目前为止,我们的聊天应用只在客户端和服务器之间以字节切片或[]byte类型传输消息;因此,我们房间的转发通道具有chan []byte类型。为了在消息本身之外发送数据(例如,谁发送了它以及何时发送),我们增强了我们的转发通道,并且也增强了我们在两端与 WebSocket 交互的方式。

定义一个新的类型,通过在chat文件夹中创建一个名为message.go的新文件来替换[]byte切片:

package main 
import ( 
  "time" 
) 
// message represents a single message 
type message struct { 
  Name    string 
  Message string 
  When    time.Time 
} 

message 类型将封装消息字符串本身,但我们还添加了 NameWhen 字段,分别存储用户的姓名和消息发送的时间戳。

由于 client 类型负责与浏览器通信,它需要传输和接收的不仅仅是单个消息字符串。既然我们正在与一个 JavaScript 应用程序(即运行在浏览器中的聊天客户端)通信,而 Go 标准库有一个出色的 JSON 实现,这似乎是将额外信息编码在消息中的完美选择。我们将更改 client.go 中的 readwrite 方法,以使用套接字的 ReadJSONWriteJSON 方法,并将编码和解码我们的新 message 类型:

func (c *client) read() { 
  defer c.socket.Close() 
  for { 
    var msg *message 
    err := c.socket.ReadJSON(&msg) 
    if err != nil { 
      return 
    } 
    msg.When = time.Now() 
    msg.Name = c.userData["name"].(string) 
    c.room.forward <- msg  
} 
}  
func (c *client) write() { 
  defer c.socket.Close() 
  for msg := range c.send { 
    err := c.socket.WriteJSON(msg) 
    if err != nil { 
      break 
    } 
  } 
} 

当我们从浏览器接收消息时,我们预计只会填充 Message 字段,这就是为什么我们在前面的代码中自己设置了 WhenName 字段。

你会注意到,当你尝试构建前面的代码时,它会抱怨一些事情。主要原因是我们试图将 *message 对象发送到我们的 forwardsend chan []byte 通道。在我们更改通道类型之前,这是不允许的。在 room.go 中,将 forward 字段更改为 chan *message 类型,并在 client.go 中对 send chan 类型做同样的更改。

我们必须更新初始化我们通道的代码,因为类型已经更改。或者,你可以等待编译器提出这些问题,并在过程中修复它们。在 room.go 中,你需要进行以下更改:

  • forward: make(chan []byte) 改为 forward: make(chan *message)

  • r.tracer.Trace("Message received: ", string(msg)) 改为 r.tracer.Trace("Message received: ", msg.Message)

  • send: make(chan []byte, messageBufferSize) 改为 send: make(chan *message, messageBufferSize)

编译器还会对客户端缺少用户数据提出抱怨,这是一个合理的观点,因为 client 类型对我们添加到 cookie 中的新用户数据一无所知。更新 client 结构体以包含一个新的通用 map[string]interface{},称为 userData

// client represents a single chatting user. 
type client struct { 
  // socket is the web socket for this client. 
  socket *websocket.Conn 
  // send is a channel on which messages are sent. 
  send chan *message 
  // room is the room this client is chatting in. 
  room *room 
  // userData holds information about the user 
  userData map[string]interface{} 
} 

用户数据来自我们通过 http.Request 对象的 Cookie 方法访问的客户端 cookie。在 room.go 中,更新 ServeHTTP 并进行以下更改:

func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) { 
  socket, err := upgrader.Upgrade(w, req, nil) 
  if err != nil { 
    log.Fatal("ServeHTTP:", err) 
    return 
  } 
  authCookie, err := req.Cookie("auth") 
  if err != nil { 
    log.Fatal("Failed to get auth cookie:", err) 
    return 
  } 
  client := &client{ 
    socket:   socket, 
    send:     make(chan *message, messageBufferSize), 
    room:     r, 
    userData: objx.MustFromBase64(authCookie.Value), 
  } 
  r.join <- client 
  defer func() { r.leave <- client }() 
  go client.write() 
  client.read() 
} 

我们使用 http.Request 类型的 Cookie 方法在传递给客户端之前获取我们的用户数据。我们正在使用 objx.MustFromBase64 方法将我们的编码 cookie 值转换回可用的 map 对象。

现在我们已经将套接字上发送和接收的类型从 []byte 更改为 *message,我们必须通知我们的 JavaScript 客户端我们正在发送 JSON 而不是纯字符串。此外,我们必须要求它在用户提交消息时将 JSON 发送回服务器。在 chat.html 中,首先更新 socket.send 调用:

socket.send(JSON.stringify({"Message": msgBox.val()})); 

我们使用JSON.stringify将指定的 JSON 对象(仅包含Message字段)序列化为字符串,然后将其发送到服务器。我们的 Go 代码将解码(或反序列化)JSON 字符串到一个message对象,将客户端 JSON 对象的字段名与我们的message类型的字段名相匹配。

最后,更新socket.onmessage回调函数以期望 JSON 格式,并添加发送者的名字到页面中:

socket.onmessage = function(e) { 
  var msg = JSON.parse(e.data); 
  messages.append( 
    $("<li>").append( 
      $("<strong>").text(msg.Name + ": "), 
      $("<span>").text(msg.Message) 
    ) 
  ); 
} 

JSON.parse function to turn the JSON string into a JavaScript object and then access the fields to build up the elements needed to properly display them.

构建并运行应用程序,如果可能的话,在两个不同的浏览器中使用两个不同的账户登录(或者邀请一个朋友帮助你测试它):

go build -o chat
./chat -host=":8080"

以下截图显示了聊天应用程序的浏览器聊天界面:

使用附加数据增强消息

摘要

在本章中,我们通过要求用户在使用 OAuth2 服务提供商进行身份验证后才能加入对话,向我们的聊天应用程序添加了一个有用且必要的功能。我们使用了几个开源包,如Gomniauth,这大大减少了我们本应处理的跨服务器复杂度。

当我们将http.Handler类型包装起来时,我们实现了一个模式,使我们能够轻松地指定哪些路径需要用户认证,哪些是可用的,即使没有authcookie。我们的MustAuth辅助函数允许我们以流畅和简单的方式生成包装类型,而不会给我们的代码增加混乱和困惑。

我们看到了如何使用 cookies 和 Base64 编码来安全(尽管不是安全地)地在各自的浏览器中存储特定用户的会话状态,并利用这些数据在常规连接和通过 web sockets 中进行操作。我们增加了对模板可用数据的控制,以便将用户名提供给 UI,并了解了如何在特定条件下仅提供某些数据。

由于我们需要在 web socket 上发送和接收额外的信息,我们了解到将原生类型的通道更改为与我们的类型(如我们的message类型)一起工作的通道是多么容易。我们还学习了如何通过 socket 传输 JSON 对象,而不仅仅是字节的切片。多亏了 Go 的类型安全性和为通道指定类型的能力,编译器帮助我们确保不会通过chan *message发送除message对象之外的内容。尝试这样做会导致编译器错误,立即提醒我们。

从构建聊天应用到现在看到聊天者的名字,在可用性方面是一个巨大的进步。但它非常正式,可能不会吸引习惯于更加视觉体验的现代网络用户。我们缺少聊天者的图片,在下一章中,我们将探讨不同的实现方式。我们可以在用户上传后,允许用户通过从 OAuth2 提供者、Gravatar 网络服务或本地磁盘拉取个人资料图片(头像)来更好地在我们的应用中代表自己。

作为额外作业,看看你是否可以利用我们放入message类型的time.Time字段来告诉用户消息发送的时间。

第三章. 实现个人头像的三种方式

到目前为止,我们的聊天应用程序已经使用了OAuth2协议来允许用户登录我们的应用程序,这样我们就能知道是谁在说什么。在本章中,我们将添加个人头像,使聊天体验更加吸引人。

我们将探讨以下几种在应用程序中的消息旁边添加图片或头像的方法:

  • 使用认证服务提供的头像图片

  • 使用en.gravatar.com/网络服务通过用户的电子邮件地址查找图片

  • 允许用户上传自己的图片并自行托管

前两种选项允许我们将图片的托管委托给第三方,无论是授权服务还是en.gravatar.com/,这很好,因为它减少了我们托管应用程序的成本(在存储成本和带宽方面,因为用户的浏览器实际上会从认证服务的服务器下载图片,而不是我们的服务器)。第三种选项要求我们自己在网络上可访问的位置托管图片。

这些选项不是相互排斥的;你很可能在实际的生产应用程序中结合使用它们。在本章的结尾,你将看到灵活的设计如何使我们能够依次尝试每种实现,直到找到合适的头像。

在本章中,我们将对设计保持敏捷,完成每个里程碑所需的最少工作。这意味着在每个部分的结尾,我们将有可演示的、在浏览器中可工作的实现。这也意味着我们将根据需要重构代码,并在进行决策时讨论其背后的理由。

具体来说,在本章中,你将学习:

  • 即使没有标准,从认证服务中获取额外信息的良好实践是什么

  • 在什么情况下将抽象构建到我们的代码中是合适的

  • Go 的零初始化模式如何节省时间和内存

  • 如何通过重用接口以与现有接口相同的方式处理集合和单个对象

  • 如何使用en.gravatar.com/网络服务

  • 如何在 Go 中进行 MD5 哈希

  • 如何通过 HTTP 上传文件并将它们存储在服务器上

  • 如何通过 Go 网络服务器提供静态文件

  • 如何使用单元测试来指导代码重构

  • 如何以及何时将功能从struct类型抽象到接口中

来自 OAuth2 服务器的头像

结果表明,大多数认证服务器已经为他们的用户提供了图像,并且他们通过我们已使用来获取用户名的受保护用户资源提供这些图像。为了使用这个头像图片,我们需要从提供者那里获取 URL,将其存储在我们的用户 cookie 中,并通过 Websocket 发送,以便每个客户端都可以在相应的消息旁边渲染图片。

获取头像 URL

用户或配置文件资源的模式不是 OAuth2 规范的一部分,这意味着每个提供者都有责任决定如何表示这些数据。确实,提供者做事情的方式不同;例如,GitHub 用户资源中的头像 URL 存储在一个名为avatar_url的字段中,而在 Google 中,相同的字段称为picture。Facebook 甚至更进一步,将头像 URL 值嵌套在一个名为picture的对象的url字段中。幸运的是,Gomniauth 为我们抽象了这一点;它在提供者标准上的GetUser调用标准化了获取常用字段的接口。

为了使用头像 URL 字段,我们需要返回并存储该信息到我们的 cookie 中。在auth.go中,查看callback动作的 switch case 内部,并更新创建authCookieValue对象的代码,如下所示:

authCookieValue := objx.New(map[string]interface{}{ 
  "name":       user.Name(), 
  "avatar_url":  user.AvatarURL(), 
}).MustBase64() 

在前面代码中调用的AvatarURL字段将返回适当的 URL 值,并将其存储在我们的avatar_url字段中,然后我们将其放入 cookie 中。

小贴士

Gomniauth 定义了一个User类型的接口,每个提供者实现自己的版本。从认证服务器返回的通用map[string]interface{}数据存储在每个对象中,方法调用使用正确的字段名访问相应的值。这种描述信息访问方式而不严格关注实现细节的方法——是 Go 中使用接口的绝佳例子。

传输头像 URL

我们需要更新我们的message类型,使其也能携带头像 URL。在message.go中添加AvatarURL字符串字段:

type message struct { 
  Name      string 
  Message   string 
  When      time.Time 
  AvatarURL string 
} 

到目前为止,我们实际上并没有为AvatarURL字段分配值,就像我们对Name字段所做的那样;因此,我们必须更新client.go中的read方法:

func (c *client) read() { 
  defer c.socket.Close() 
  for { 
    var msg *message 
    err := c.socket.ReadJSON(&msg) 
    if err != nil { 
      return 
    } 
    msg.When = time.Now() 
    msg.Name = c.userData["name"].(string) 
    if avatarURL, ok := c.userData["avatar_url"]; ok { 
      msg.AvatarURL = avatarURL.(string) 
    } 
    c.room.forward <- msg 
  } 
} 

我们在这里所做的一切只是从表示我们放入 cookie 中的userData字段中获取值,并将其分配给message中的相应字段,如果该值存在于映射中。我们现在采取额外的步骤来检查值是否存在,因为我们不能保证认证服务会为这个字段提供一个值。而且,由于它可能是nil,如果实际上缺失,将其分配给string类型可能会引起 panic。

将头像添加到用户界面

现在 JavaScript 客户端通过 socket 获取了头像 URL 值,我们可以使用它来在消息旁边显示图像。我们通过更新chat.html中的socket.onmessage代码来完成此操作:

socket.onmessage = function(e) { 
  var msg = JSON.parse(e.data); 
  messages.append( 
    $("<li>").append( 
      $("<img>").css({ 
        width:50, 
        verticalAlign:"middle" 
      }).attr("src", msg.AvatarURL), 
      $("<strong>").text(msg.Name + ": "), 
      $("<span>").text(msg.Message) 
    ) 
  ); 
} 

当我们收到消息时,我们将插入一个 img 标签,其源设置为 AvatarURL 字段。我们将使用 jQuery 的 css 方法强制宽度为 50 像素。这可以保护我们免受大量图片破坏我们的界面,并允许我们将图像与周围的文本对齐。

如果我们使用之前版本登录并构建和运行我们的应用程序,你会发现仍然存在不包含头像 URL 的 auth cookie。我们没有再次被要求进行身份验证(因为我们已经登录),添加 avatar_url 字段的代码也从未有机会运行。我们可以删除我们的 cookie 并刷新页面,但我们在开发过程中每次更改时都必须这样做。让我们通过添加登出功能来正确解决这个问题。

登出

退出用户的最简单方法是删除 auth cookie 并将用户重定向到聊天页面,这将反过来导致重定向到登录页面(因为我们刚刚删除了 cookie)。我们通过在 main.go 中添加一个新的 HandleFunc 调用来实现这一点:

http.HandleFunc("/logout", func(w http.ResponseWriter, r  *http.Request) { 
  http.SetCookie(w, &http.Cookie{ 
    Name:   "auth", 
    Value:  "", 
    Path:   "/", 
    MaxAge: -1, 
  }) 
  w.Header().Set("Location", "/chat") 
  w.WriteHeader(http.StatusTemporaryRedirect) 
}) 

前一个处理函数使用 http.SetCookie 更新 cookie 设置 MaxAge-1,这表示浏览器应立即删除它。并非所有浏览器都会强制删除 cookie,这就是为什么我们还提供了一个新的空字符串 Value 设置,从而删除之前存储的用户数据。

小贴士

作为额外的任务,你可以通过更新 auth.goauthHandler 方法的 ServeHTTP 中的第一行来使你的应用程序更加健壮,使其能够处理空值情况以及缺失 cookie 的情况:

if cookie, err := r.Cookie("auth"); err == http.ErrNoCookie || cookie.Value == ""

我们不再忽略 r.Cookie 的返回值,我们保留返回的 cookie 的引用(如果实际上有一个的话),并添加一个额外的检查来查看 cookie 的 Value 字符串是否为空。

在我们继续之前,让我们添加一个 登出 链接,使其更容易删除 cookie,并允许我们的最终用户登出。在 chat.html 中,更新 chatbox 表单以插入一个简单的 HTML 链接到新的 /logout 处理程序:

<form id="chatbox"> 
  {{.UserData.name}}:<br/> 
  <textarea></textarea> 
  <input type="submit" value="Send" /> 
  or <a href="/logout">sign out</a> 
</form> 

现在构建并运行应用程序,并在浏览器中打开 localhost:8080/chat

go build -o chat
./chat -host=:8080

如果需要,请登出并重新登录。当你点击 发送 时,你会看到你的头像图片出现在你的消息旁边:

登出

让事物更美观

我们的应用程序开始看起来有点丑陋,是时候做些改变了。在上一章中,我们将 Bootstrap 库集成到我们的登录页面中,现在我们将扩展其使用到我们的聊天页面。在 chat.html 中,我们将进行三项更改:包含 Bootstrap 并调整页面的 CSS 样式,更改表单的标记,以及调整我们在页面上渲染消息的方式:

  1. 首先,让我们更新页面顶部的style标签,并在其上方插入一个link标签以包含 Bootstrap:

            <link rel="stylesheet"href="//netdna.bootstrapcdn.com/bootstrap
              /3.3.6/css/bootstrap.min.css"> 
            <style> 
              ul#messages        { list-style: none; } 
              ul#messages li     { margin-bottom: 2px; } 
              ul#messages li img { margin-right: 10px; } 
            </style> 
    
    
  2. 接下来,让我们用以下代码替换body标签顶部的标记(在script标签之前):

            <div class="container"> 
              <div class="panel panel-default"> 
                <div class="panel-body"> 
                  <ul id="messages"></ul> 
                </div> 
              </div> 
              <form id="chatbox" role="form"> 
                <div class="form-group"> 
                  <label for="message">Send a message as {{.UserData.name}}     
                   </label> or <a href="/logout">Sign out</a> 
                  <textarea id="message" class="form-control"></textarea> 
                </div> 
                <input type="submit" value="Send" class="btn btn-default" /> 
              </form> 
            </div>
    

    注意

    此标记遵循 Bootstrap 标准,为各种项目应用适当的类;例如,form-control 类可以整洁地格式化表单内的元素(你可以查看 Bootstrap 文档以获取有关这些类如何工作的更多信息)。

  3. 最后,让我们更新我们的socket.onmessage JavaScript 代码,将发送者的名字作为图像的标题属性。这样,当鼠标悬停时,它会显示图像,而不是在每条消息旁边显示它:

            socket.onmessage = function(e) { 
              var msg = JSON.parse(e.data); 
              messages.append( 
                $("<li>").append( 
                  $("<img>").attr("title", msg.Name).css({ 
                    width:50, 
                    verticalAlign:"middle" 
                  }).attr("src", msg.AvatarURL), 
                  $("<span>").text(msg.Message) 
                ) 
              ); 
            } 
    
    

构建并运行应用程序,刷新浏览器以查看是否出现新的设计:

go build -o chat
./chat -host=:8080

前面的命令显示了以下输出:

使事物更美观

通过对代码进行相对较少的修改,我们显著提高了应用程序的外观和感觉。

实现 Gravatar

Gravatar 是一个允许用户上传单个个人照片并将其与他们的电子邮件地址关联的在线服务,以便在任何网站上都可以访问。像我们这样的开发者可以通过对特定 API 端点执行GET操作来访问这些图像。在本节中,我们将探讨如何实现 Gravatar 而不是使用由认证服务提供的图片。

抽象头像 URL 过程

由于我们在应用程序中有三种获取头像 URL 的不同方式,我们已经达到了一个合理的点,即学习如何抽象功能以干净地实现选项。抽象是指将某物的概念与其特定的实现分离的过程。http.Handler方法是一个很好的例子,说明了如何使用处理程序及其输入输出,而不具体说明每个处理程序采取什么行动。

在 Go 中,我们通过定义一个接口来描述获取头像 URL 的想法。让我们创建一个名为avatar.go的新文件,并插入以下代码:

package main 
import ( 
  "errors" 
) 
// ErrNoAvatar is the error that is returned when the 
// Avatar instance is unable to provide an avatar URL. 
var ErrNoAvatarURL = errors.New("chat: Unable to get an avatar  URL.") 
// Avatar represents types capable of representing 
// user profile pictures. 
type Avatar interface { 
  // GetAvatarURL gets the avatar URL for the specified client, 
  // or returns an error if something goes wrong. 
  // ErrNoAvatarURL is returned if the object is unable to get 
  // a URL for the specified client. 
  GetAvatarURL(c *client) (string, error) 
} 

Avatar接口描述了一个类型必须满足的GetAvatarURL方法,以便能够获取头像 URL。我们以客户端作为参数,以便我们知道要返回 URL 的用户。该方法返回两个参数:一个字符串(如果一切顺利,将是 URL)和一个错误,如果出现问题。

可能出错的事情之一是 Avatar 的某个特定实现无法获取 URL。在这种情况下,GetAvatarURL 将返回 ErrNoAvatarURL 错误作为第二个参数。因此,ErrNoAvatarURL 错误成为接口的一部分;它是方法可能的返回值之一,也是我们代码的用户可能需要明确处理的内容。我们在方法的注释部分提到了这一点,这是在 Go 中传达此类设计决策的唯一方式。

小贴士

由于错误是立即使用 errors.New 初始化并存储在 ErrNoAvatarURL 变量中,因此只会创建这些对象中的一个;传递错误指针作为返回值是低成本的。这与 Java 的检查异常类似,后者具有类似的目的,其中创建了昂贵的异常对象,并将其用作控制流的一部分。

身份验证服务和头像的实现

我们将要编写的 Avatar 的第一个实现将替换我们之前硬编码从身份验证服务获取的头像 URL 的现有功能。让我们采用 测试驱动开发TDD) 方法,以确保我们的代码在没有手动测试的情况下也能正常工作。让我们在 chat 文件夹中创建一个名为 avatar_test.go 的新文件:

package main 
import "testing" 
func TestAuthAvatar(t *testing.T) { 
  var authAvatar AuthAvatar 
  client := new(client) 
  url, err := authAvatar.GetAvatarURL(client) 
  if err != ErrNoAvatarURL { 
    t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL 
    when no value present") 
  } 
  // set a value 
  testUrl := "http://url-to-gravatar/" 
  client.userData = map[string]interface{}{"avatar_url": testUrl} 
  url, err = authAvatar.GetAvatarURL(client) 
  if err != nil { 
    t.Error("AuthAvatar.GetAvatarURL should return no error 
    when value present") 
  } 
  if url != testUrl { 
    t.Error("AuthAvatar.GetAvatarURL should return correct URL") 
  } 
} 

此文件包含对我们目前尚不存在且未定义的 AuthAvatar 类型的 GetAvatarURL 方法的测试。首先,它使用一个没有用户数据的客户端,并确保返回 ErrNoAvatarURL 错误。在设置一个合适的 URL 之后,我们的测试再次调用该方法,这次断言它返回正确的值。然而,构建此代码失败,因为 AuthAvatar 类型不存在,所以我们将声明 authAvatar

在编写实现之前,值得注意的是,我们只声明了 authAvatar 变量为 AuthAvatar 类型,但从未实际分配任何内容,因此其值保持为 nil。这并不是一个错误;我们实际上正在利用 Go 的零初始化(或默认初始化)功能。由于我们的对象不需要状态(我们将 client 作为参数传递),因此没有必要浪费时间和内存来初始化其实例。在 Go 中,在 nil 对象上调用方法是可以接受的,只要该方法不尝试访问字段。当我们实际编写实现时,我们将查看一种确保这种情况的方法。

让我们回到 avatar.go 并使测试通过。在文件底部添加以下代码:

type AuthAvatar struct{} 
var UseAuthAvatar AuthAvatar 
func (AuthAvatar) GetAvatarURL(c *client) (string, error) { 
  if url, ok := c.userData["avatar_url"]; ok { 
    if urlStr, ok := url.(string); ok { 
      return urlStr, nil 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

在这里,我们定义我们的 AuthAvatar 类型为一个空的结构体,并定义了 GetAvatarURL 方法的实现。我们还创建了一个方便的变量 UseAuthAvatar,它具有 AuthAvatar 类型,但其值为 nil。我们可以在以后将 UseAuthAvatar 变量分配给任何查找 Avatar 接口类型的字段。

注意

我们之前编写的 GetAvatarURL 方法没有很好的 视线;快乐的返回值被埋在两个 if 块中。看看你是否可以重构它,使得最后一行是 return urlStr, nil,如果 avatar_url 字段缺失,则方法提前退出。你可以有信心地重构,因为这段代码被单元测试覆盖。

想了解更多关于这种重构背后的原因,请参阅bit.ly/lineofsightgolang上的文章。

通常,方法的接收者(在名称之前括号中定义的类型)将被分配给一个变量,以便可以在方法体中访问它。由于在我们的情况下,我们假设对象可以有 nil 值,我们可以省略变量名来告诉 Go 丢弃引用。这作为对我们自己的额外提醒,我们应该避免使用它。

我们的实现体在其他方面相对简单:我们安全地寻找 avatar_url 的值,并在返回之前确保它是一个字符串。如果任何东西失败,我们返回接口中定义的 ErrNoAvatarURL 错误。

让我们通过打开终端并导航到 chat 文件夹,然后输入以下内容来运行测试:

go test

如果一切顺利,我们的测试将通过,我们将成功创建了我们的第一个 Avatar 实现。

使用实现

当我们使用实现时,我们可以直接引用辅助变量,或者在我们需要功能时创建接口的自己的实例。然而,这将违背抽象的目的。相反,我们使用 Avatar 接口类型来指示我们需要的能力。

对于我们的聊天应用程序,我们将为每个聊天室提供一个获取头像 URL 的唯一方式。因此,让我们更新 room 类型,使其可以持有 Avatar 对象。在 room.go 中,向 room struct 类型添加以下字段定义:

// avatar is how avatar information will be obtained. 
avatar Avatar 

更新 newRoom 函数,以便我们可以传递一个 Avatar 实现用于使用;当我们创建 room 实例时,我们将只把这个实现分配给新的字段:

// newRoom makes a new room that is ready to go. 
func newRoom(avatar Avatar) *room { 
  return &room{ 
    forward: make(chan *message), 
    join:    make(chan *client), 
    leave:   make(chan *client), 
    clients: make(map[*client]bool), 
    tracer:  trace.Off(), 
    avatar:  avatar, 
  } 
} 

现在构建项目将突出显示在 main.go 中的 newRoom 调用是错误的,因为我们没有提供 Avatar 参数;让我们通过传递我们手头的 UseAuthAvatar 变量来更新它,如下所示:

r := newRoom(UseAuthAvatar) 

我们不需要创建 AuthAvatar 的实例,所以没有分配内存。在我们的情况下,这并不导致很大的节省(因为我们整个应用程序只有一个房间),但想象一下如果我们的应用程序有成千上万的房间,潜在节省的大小。我们命名 UseAuthAvatar 变量的方式意味着前面的代码非常易于阅读,它也使我们的意图明显。

小贴士

在设计接口时考虑代码可读性很重要。考虑一个只接受布尔输入的方法,仅传递 true 或 false 会隐藏真实含义,如果您不知道参数名称的话。考虑定义几个辅助常量,如下面的简短示例所示:

func move(animated bool) { /* ... */ } 
const Animate = true const 
DontAnimate = false

考虑以下对move的调用哪个更容易理解:


move(true) 
 move(false) 
 move(Animate) 
 move(DontAnimate) 

现在剩下的只是将client更改为使用我们新的Avatar接口。在client.go中更新read方法,如下所示:

func (c *client) read() { 
  defer c.socket.Close() 
  for { 
    var msg *message 
    if err := c.socket.ReadJSON(&msg); err != nil { 
      return 
    } 
    msg.When = time.Now() 
    msg.Name = c.userData["name"].(string) 
    msg.AvatarURL, _ = c.room.avatar.GetAvatarURL(c) 
    c.room.forward <- msg 
  } 
} 

在这里,我们要求room中的avatar实例为我们获取头像 URL,而不是我们自己从userData中提取它。

当您构建并运行应用程序时,您会注意到(尽管我们进行了一些重构)行为和用户体验完全没有改变。这是因为我们告诉我们的房间使用AuthAvatar实现。

现在让我们为房间添加另一个实现。

Gravatar 实现

Avatar中的 Gravatar 实现将执行与AuthAvatar实现相同的工作,但除了它会为托管在en.gravatar.com/上的个人资料图片生成一个 URL。让我们首先在我们的avatar_test.go文件中添加一个测试:

func TestGravatarAvatar(t *testing.T) { 
  var gravatarAvatar GravatarAvatar 
  client := new(client) 
  client.userData = map[string]interface{}{"email": 
   "MyEmailAddress@example.com"} 
  url, err := gravatarAvatar.GetAvatarURL(client) 
  if err != nil { 
    t.Error("GravatarAvatar.GetAvatarURL should not return an error") 
  } 
  if url != "//www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346" { 
    t.Errorf("GravatarAvatar.GetAvatarURL wrongly returned %s", url) 
  } 
} 

Gravatar 使用电子邮件地址的哈希值来为每个个人资料图片生成一个唯一的 ID,因此我们设置了一个客户端并确保userData包含一个电子邮件地址。接下来,我们调用相同的GetAvatarURL方法,但这次是在具有GravatarAvatar类型的对象上。然后我们断言返回了正确的 URL。因为我们知道这是指定电子邮件地址的正确 URL,因为它在 Gravatar 文档中作为示例列出,这是一个确保我们的代码正在执行其应执行的操作的绝佳策略。

小贴士

记住,本书的所有源代码都可以从出版社下载,并且已在 GitHub 上发布。您可以通过复制粘贴github.com/matryer/goblueprints中的部分内容来节省构建先前核心的时间。通常,硬编码诸如基本 URL 之类的信息不是一个好主意;我们在整本书中硬编码以使代码片段更容易阅读且更明显,但如果您喜欢,欢迎在过程中提取它们。

显然,运行这些测试(使用go test)会导致错误,因为我们还没有定义我们的类型。让我们回到avatar.go并添加以下代码,同时确保导入io包:

type GravatarAvatar struct{} 
var UseGravatar GravatarAvatar 
func(GravatarAvatar) GetAvatarURL(c *client) (string, error) { 
  if email, ok := c.userData["email"]; ok { 
    if emailStr, ok := email.(string); ok { 
      m := md5.New() 
      io.WriteString(m, strings.ToLower(emailStr)) 
      return fmt.Sprintf("//www.gravatar.com/avatar/%x", m.Sum(nil)), nil 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

我们使用了与AuthAvatar相同的模式:我们有一个空的 struct,一个有用的UseGravatar变量,以及GetAvatarURL方法实现本身。在这个方法中,我们遵循 Gravatar 的指南,从电子邮件地址生成 MD5 哈希(在我们确保它是小写之后),然后使用fmt.Sprintf将其附加到硬编码的基本 URL 上。

注意

前面的方法在代码中存在视线不佳的问题。你能忍受它,还是想以某种方式提高可读性?

由于 Go 标准库编写者的辛勤工作,在 Go 中实现哈希非常容易。crypto 包提供了一系列令人印象深刻的加密和哈希功能,使用起来非常简单。在我们的例子中,我们创建了一个新的 md5 哈希器,因为哈希器实现了 io.Writer 接口,我们可以使用 io.WriteString 将字节字符串写入其中。调用 Sum 返回已写入的字节当前哈希值。

小贴士

你可能已经注意到,每次我们需要头像 URL 时,我们都会对电子邮件地址进行哈希处理。这在规模较大时效率很低,但我们应该优先考虑完成工作而不是优化。如果需要,我们总是可以稍后回来并更改这种方式。

现在运行测试显示我们的代码正在工作,但我们还没有在 auth cookie 中包含电子邮件地址。我们通过定位在 auth.go 中分配给 authCookieValue 对象的代码,并将其更新为从 Gomniauth 获取 Email 值来完成此操作:

authCookieValue := objx.New(map[string]interface{}{ 
  "name":       user.Name(), 
  "avatar_url": user.AvatarURL(), 
  "email":       user.Email(), 
}).MustBase64() 

我们必须做的最后一件事是告诉我们的房间使用 Gravatar 实现,而不是 AuthAvatar 实现。我们通过在 main.go 中调用 newRoom 并进行以下更改来完成此操作:

r := newRoom(UseGravatar) 

再次构建并运行聊天程序,然后转到浏览器。记住,由于我们更改了 cookie 中存储的信息,我们必须注销并重新登录才能看到我们的更改生效。

假设你有一个不同的 Gravatar 账户图片,你会注意到系统现在是从 Gravatar 而不是身份验证提供者拉取图片。使用你浏览器的检查器或调试工具将显示 img 标签的 src 属性确实已更改:

Gravatar 实现

如果你没有 Gravatar 账户,你很可能会看到默认占位符图片代替你的个人资料图片。

上传头像图片

在上传图片的第三种和最后一种方法中,我们将探讨如何允许用户从他们的本地硬盘上传图片作为聊天时的个人资料图片。然后,文件将通过 URL 供浏览器服务。我们需要一种方法将文件与特定用户关联起来,以确保我们将正确的图片与相应的消息关联起来。

用户标识

为了唯一标识我们的用户,我们将通过哈希他们的电子邮件地址并使用结果字符串作为标识符来复制 Gravatar 的方法。我们将把用户 ID 与其他用户特定数据一起存储在 cookie 中。这实际上还有额外的优点,即消除了与 GravatarAuth 持续哈希相关的低效性。

auth.go 中,将创建 authCookieValue 对象的代码替换为以下代码:

m := md5.New() 
io.WriteString(m, strings.ToLower(user.Email())) 
userId := fmt.Sprintf("%x", m.Sum(nil)) 
authCookieValue := objx.New(map[string]interface{}{ 
  "userid":      userId, 
  "name":       user.Name(), 
  "avatar_url": user.AvatarURL(), 
  "email":      user.Email(), 
}).MustBase64() 

在这里,我们已经对电子邮件地址进行了哈希处理,并在用户登录时将结果值存储在userid字段中。从现在开始,我们可以在我们的 Gravatar 代码中使用这个值,而不是为每条消息都重新哈希电子邮件地址。为此,首先,我们通过从avatar_test.go中删除以下行来更新测试:

client.userData = map[string]interface{}{"email":  "MyEmailAddress@example.com"} 

然后我们将前面的行替换为这一行:

client.userData = map[string]interface{}{"userid":  "0bc83cb571cd1c50ba6f3e8a78ef1346"} 

我们不再需要设置email字段,因为它没有被使用;相反,我们只需要为新userid字段设置一个适当的值。然而,如果你在终端中运行go test,你会看到这个测试失败。

为了使测试通过,在avatar.go中,更新GravatarAuth类型的GetAvatarURL方法:

func(GravatarAvatar) GetAvatarURL(c *client) (string, error) { 
  if userid, ok := c.userData["userid"]; ok { 
    if useridStr, ok := userid.(string); ok { 
      return "//www.gravatar.com/avatar/" + useridStr, nil 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

这不会改变行为,但它允许我们进行一个意外的优化,这是一个很好的例子,说明为什么你不应该过早地优化代码——你早期发现的低效可能不会持续足够长的时间,以至于值得花费精力去修复它们。

一个上传表单

如果我们的用户要上传文件作为他们的头像,他们需要一种方式来浏览他们的本地硬盘并将文件提交到服务器。我们通过添加一个新的由模板驱动的页面来简化这个过程。在chat/templates文件夹中,创建一个名为upload.html的文件:

<html> 
  <head> 
    <title>Upload</title> 
    <link rel="stylesheet"        
    href="//netdna.bootstrapcdn.com/bootstrap/3.6.6/css/bootstrap.min.css"> 
  </head> 
  <body> 
    <div class="container"> 
      <div class="page-header"> 
        <h1>Upload picture</h1> 
      </div> 
      <form role="form" action="/uploader" enctype="multipart/form-data"  
       method="post"> 
        <input type="hidden" name="userid" value="{{.UserData.userid}}" /> 
        <div class="form-group"> 
          <label for="avatarFile">Select file</label> 
          <input type="file" name="avatarFile" /> 
        </div> 
        <input type="submit" value="Upload" class="btn" /> 
      </form> 
    </div> 
  </body> 
</html> 

我们再次使用了 Bootstrap 来使我们的页面看起来更美观,并且使其与其他页面保持一致。然而,这里需要注意的是那个将提供用户界面以便上传文件的 HTML 表单。表单的动作指向/uploader,而我们还没有实现它的处理程序,并且enctype属性必须是multipart/form-data,这样浏览器才能通过 HTTP 传输二进制数据。然后,有一个类型为fileinput元素,它将包含我们想要上传的文件的引用。此外,请注意,我们已经将UserData映射中的userid值作为一个隐藏的输入包含在内,这将告诉我们哪个用户正在上传文件。确保name属性正确是很重要的,因为这是我们将在服务器上实现处理程序时引用数据的方式。

让我们现在将新的模板映射到main.go中的/upload路径:

http.Handle("/upload", &templateHandler{filename: "upload.html"}) 

处理上传

当用户在选择了文件后点击上传按钮,浏览器将发送文件的资料以及用户 ID 到/uploader,但到目前为止,这些数据实际上并没有去任何地方。我们将实现一个新的HandlerFunc接口,它能够接收文件,读取通过连接流过的字节,并将其作为新文件保存到服务器上。在chat文件夹中,让我们创建一个名为avatars的新文件夹,这是我们将会保存头像图片文件的地方。

接下来,创建一个名为upload.go的新文件,并插入以下代码(确保添加适当的包名和导入,即ioutilsnet/httpiopath):

func uploaderHandler(w http.ResponseWriter, req *http.Request) { 
  userId := req.FormValue("userid") 
  file, header, err := req.FormFile("avatarFile") 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusInternalServerError) 
    return 
  } 
  data, err := ioutil.ReadAll(file) 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusInternalServerError) 
    return 
  } 
  filename := path.Join("avatars", userId+path.Ext(header.Filename)) 
  err = ioutil.WriteFile(filename, data, 0777) 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusInternalServerError) 
    return 
  } 
  io.WriteString(w, "Successful") 
} 

在这里,首先uploaderHandler使用http.Request中的FormValue方法获取我们在 HTML 表单中隐藏输入中放置的用户 ID。然后,它通过调用req.FormFile获取一个能够读取上传字节的io.Reader类型,该函数返回三个参数。第一个参数代表文件本身,具有multipart.File接口类型,它也是io.Reader。第二个是一个包含文件元数据的multipart.FileHeader对象,例如文件名。最后,第三个参数是一个我们希望其值为nil的错误。

当我们说multipart.File接口类型也是io.Reader时,我们是什么意思?好吧,快速浏览一下golang.org/pkg/mime/multipart/#File的文档,可以清楚地看出,该类型实际上只是几个更通用接口的包装器接口。这意味着multipart.File类型可以被传递到需要io.Reader的方法中,因为任何实现了multipart.File的对象都必须实现io.Reader

小贴士

将标准库接口,如包装器,嵌入以描述新概念,是确保你的代码在尽可能多的上下文中都能正常工作的一种好方法。同样,你应该尽量编写使用你能找到的最简单接口类型的代码,理想情况下是从标准库中。例如,如果你编写了一个需要你读取文件内容的方法,你可以要求用户提供一个类型为multipart.File的参数。然而,如果你要求io.Reader,你的代码将变得更加灵活,因为任何具有适当Read方法的类型都可以传递,这包括用户定义的类型。

ioutil.ReadAll方法将不断从指定的io.Reader接口读取,直到接收到所有字节,因此这里是我们实际上从客户端接收字节流的地方。然后我们使用path.Joinpath.Ext通过userid构建一个新的文件名,并从可以从中获取的原始文件名中复制扩展名multipart.FileHeader

我们随后使用ioutil.WriteFile方法在avatars文件夹中创建一个新文件。我们使用userid作为文件名,以便将图像与正确的用户关联起来,这与 Gravatar 的做法非常相似。0777值指定了我们创建的新文件应具有完整的文件权限,如果你不确定应该设置哪些其他权限,这是一个很好的默认设置。

如果在任何阶段发生错误,我们的代码将将其写入响应中,并附带 500 状态码(因为我们指定了http.StatusInternalServerError),这将帮助我们调试它,或者如果一切顺利,它将写入成功

为了将这个新的处理函数映射到/uploader,我们需要回到main.go,并在func main中添加以下行:

http.HandleFunc("/uploader", uploaderHandler) 

现在构建并运行应用程序,并记得注销并重新登录,以便给我们的代码一个上传authcookie 的机会:

go build -o chat
./chat -host=:8080

打开http://localhost:8080/upload,点击选择文件,然后从你的硬盘上选择一个文件并点击上传。导航到你的chat/avatars文件夹,你会注意到文件确实已上传,并重命名为你的userid字段的值。

服务器端图像服务

现在我们有了在服务器上存储用户头像图片的地方,我们需要一种方法让浏览器能够访问它们。我们使用net/http包的内置文件服务器来完成这项工作。在main.go中添加以下代码:

http.Handle("/avatars/", 
  http.StripPrefix("/avatars/", 
    http.FileServer(http.Dir("./avatars")))) 

这实际上是一行代码,为了提高可读性而被拆分。http.Handle调用应该感觉熟悉,因为我们指定了想要将/avatars/路径映射到指定的处理器,这就是事情变得有趣的地方。http.StripPrefixhttp.FileServer都返回http.Handler,并且它们使用了我们在上一章中学到的包装模式。StripPrefix函数接收http.Handler作为输入,通过删除指定的前缀来修改路径,并将功能传递给内部处理器。在我们的例子中,内部处理器是一个http.FileServer处理器,它将简单地提供静态文件,提供索引列表,如果找不到文件,则生成404 Not Found错误。http.Dir函数允许我们指定我们想要公开哪个文件夹。

提示

如果我们没有使用http.StripPrefix从请求中去除/avatars/前缀,文件服务器将在实际的avatars文件夹内寻找另一个名为avatars的文件夹,即/avatars/avatars/filename而不是/avatars/filename

在打开浏览器中的http://localhost:8080/avatars/之前,让我们构建并运行程序。你会注意到文件服务器已经生成了我们avatars文件夹内文件的列表。点击一个文件将下载该文件,或者在图像的情况下,简单地显示它。如果你还没有这样做,请转到http://localhost:8080/upload并上传一张图片,然后回到列表页面并点击它,在浏览器中查看。

本地文件的头像实现

使文件系统头像工作最终一步是编写一个实现我们的Avatar接口的代码,该接口生成指向我们在上一节中创建的文件系统端点的 URL。

让我们在avatar_test.go文件中添加一个测试函数:

func TestFileSystemAvatar(t *testing.T) { 

  filename := filepath.Join("avatars", "abc.jpg") 
  ioutil.WriteFile(filename, []byte{}, 0777) 
  defer os.Remove(filename)  
  var fileSystemAvatar FileSystemAvatar 
  client := new(client) 
  client.userData = map[string]interface{}{"userid": "abc"} 
  url, err := fileSystemAvatar.GetAvatarURL(client) 
  if err != nil { 
    t.Error("FileSystemAvatar.GetAvatarURL should not return an error") 
  } 
  if url != "/avatars/abc.jpg" { 
    t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url) 
  } 
} 

这个测试与GravatarAvatar测试类似,但稍微复杂一些,因为我们还在我们的avatars文件夹中创建了一个测试文件,并在之后删除它。

提示

即使我们的测试代码崩溃,延迟调用的函数仍然会被调用。所以无论发生什么,我们的测试代码都会自行清理。

剩下的测试很简单:我们在client.userData中设置一个userid字段,并调用GetAvatarURL以确保我们得到正确的返回值。当然,运行这个测试将会失败,所以让我们去avatar.go中添加以下代码以便使其通过:

type FileSystemAvatar struct{} 
var UseFileSystemAvatar FileSystemAvatar 
func (FileSystemAvatar) GetAvatarURL(c *client) (string, error) { 
  if userid, ok := c.userData["userid"]; ok { 
    if useridStr, ok := userid.(string); ok { 
      return "/avatars/" + useridStr + ".jpg", nil 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

如您所见,为了生成正确的 URL,我们只是获取userid值,并通过添加适当的段来构建最终的字符串。您可能已经注意到我们硬编码了文件扩展名为.jpg,这意味着我们聊天应用程序的初始版本将只支持 JPEGs。

小贴士

仅支持 JPEGs 可能看起来像是一个半成品解决方案,但遵循敏捷方法,这完全没问题;毕竟,定制的 JPEG 个人资料图片比完全没有定制的个人资料图片要好。

让我们通过更新main.go以使用我们的新Avatar实现来查看我们的新代码的实际效果:

r := newRoom(UseFileSystemAvatar) 

现在像往常一样构建并运行应用程序,然后转到http://localhost:8080/upload并使用网页表单上传一个 JPEG 图像作为您的个人资料图片。为了确保它正常工作,请选择一个独特的图像,而不是您的 Gravatar 图片或认证服务中的图片。一旦点击上传后看到成功消息,转到http://localhost:8080/chat并发布一条消息。您会注意到应用程序确实使用了您上传的个人资料图片。

要更改您的个人资料图片,请返回到/upload页面并上传不同的图片,然后跳回/chat页面并发布更多消息。

本地文件的头像实现

支持不同的文件类型

为了支持不同的文件类型,我们必须使FileSystemAvatar类型的GetAvatarURL方法变得更智能一些。

我们不会盲目地构建字符串,而是会使用非常重要的ioutil.ReadDir方法来获取文件列表。列表还包括目录,因此我们将使用IsDir方法来确定是否应该跳过它。

我们将通过调用path.Match来检查每个文件是否与userid字段匹配(记住我们这样命名文件)。如果文件名与userid字段匹配,那么我们就找到了该用户的文件,并返回该路径。如果发生任何错误或找不到文件,我们将像往常一样返回ErrNoAvatarURL错误。

avatar.go中更新适当的方法,如下所示:

func (FileSystemAvatar) GetAvatarURL(c *client) (string, error) { 
  if userid, ok := c.userData["userid"]; ok { 
    if useridStr, ok := userid.(string); ok { 
      files, err := ioutil.ReadDir("avatars") 
      if err != nil { 
        return "", ErrNoAvatarURL 
      } 
      for _, file := range files { 
        if file.IsDir() { 
          continue 
        } 
        if match, _ := path.Match(useridStr+"*", file.Name());
        match { 
          return "/avatars/" + file.Name(), nil 
        } 
      } 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

删除avatar文件夹中的所有文件以避免混淆并重新构建程序。这次,上传一个不同类型的图片,并注意我们的应用程序没有困难地处理它。

代码重构和优化

当我们回顾我们的 Avatar 类型是如何被使用的时候,你会注意到,每次有人发送消息时,应用程序都会调用 GetAvatarURL。在我们的最新实现中,每次调用该方法时,我们都会遍历 avatars 文件夹中的所有文件。对于一个特别健谈的用户来说,这可能意味着我们每分钟会重复多次迭代。这是明显的资源浪费,并且很快就会成为一个可扩展性问题。

我们不应该为每条消息获取头像 URL,而应该在用户首次登录时只获取一次,并将其缓存到 auth cookie 中。不幸的是,我们的 Avatar 接口类型要求我们将 client 对象传递给 GetAvatarURL 方法,而在我们验证用户身份的点上我们没有这样的对象。

小贴士

那么我们在设计 Avatar 接口时犯了错误吗?虽然这是一个自然的结论,但事实上我们做得是对的。我们根据当时可用的最佳信息设计了解决方案,因此比尝试为所有可能的情况设计要早得多地拥有了一个工作的聊天应用程序。软件会进化,几乎总是会在开发过程中发生变化,并且在代码的整个生命周期中肯定会发生变化。

用接口替换具体类型

我们已经得出结论,我们的 GetAvatarURL 方法依赖于在我们需要它的点上不可用的类型,那么一个好的替代方案是什么?我们可以将每个必需字段作为单独的参数传递,但这会使我们的接口变得脆弱,因为一旦 Avatar 实现需要新的信息,我们就必须更改方法签名。相反,我们将创建一个新的类型,它将封装 Avatar 实现所需的信息,同时在概念上保持与我们特定情况的解耦。

auth.go 文件中,将以下代码添加到页面顶部(当然是在 package 关键字下方):

import gomniauthcommon "github.com/stretchr/gomniauth/common" 
type ChatUser interface { 
  UniqueID() string 
  AvatarURL() string 
} 
type chatUser struct { 
  gomniauthcommon.User 
  uniqueID string 
} 
func (u chatUser) UniqueID() string { 
  return u.uniqueID 
} 

ChatUser, which exposes the information needed in order for our Avatar implementations to generate the correct URLs. Then, we defined an actual implementation called chatUser (notice the lowercase starting letter) that implements the interface. It also makes use of a very interesting feature in Go: type embedding. We actually embedded the gomniauth/common.User interface type, which means that our struct interface implements the interface automatically.

你可能已经注意到,我们实际上只实现了两个必需方法中的一个来满足我们的 ChatUser 接口。我们之所以能够这样做,是因为 Gomniauth 的 User 接口恰好定义了相同的 AvatarURL 方法。在实践中,当我们实例化我们的 chatUser 结构体,并且为隐含的 Gomniauth User 字段设置适当的值时,我们的对象同时实现了 Gomniauth 的 User 接口和我们的 ChatUser 接口。

以测试驱动的方式更改接口

在我们能够使用我们的新类型之前,我们必须更新 Avatar 接口和适当的实现以使用它。由于我们将遵循 TDD 实践,我们将首先在我们的测试文件中做出这些更改,当我们尝试构建代码时,我们会看到编译错误,一旦我们修复了这些错误,我们就会看到失败的测试,最终使测试通过。

打开 avatar_test.go 文件,将 TestAuthAvatar 替换为以下代码:

func TestAuthAvatar(t *testing.T) { 
  var authAvatar AuthAvatar 
  testUser := &gomniauthtest.TestUser{} 
  testUser.On("AvatarURL").Return("", ErrNoAvatarURL) 
  testChatUser := &chatUser{User: testUser} 
  url, err := authAvatar.GetAvatarURL(testChatUser) 
  if err != ErrNoAvatarURL { 
    t.Error("AuthAvatar.GetAvatarURL should return ErrNoAvatarURL 
     when no value present") 
  } 
  testUrl := "http://url-to-gravatar/" 
  testUser = &gomniauthtest.TestUser{} 
  testChatUser.User = testUser 
  testUser.On("AvatarURL").Return(testUrl, nil) 
  url, err = authAvatar.GetAvatarURL(testChatUser) 
  if err != nil { 
    t.Error("AuthAvatar.GetAvatarURL should return no error 
    when value present") 
  } 
  if url != testUrl { 
    t.Error("AuthAvatar.GetAvatarURL should return correct URL") 
  } 
} 

小贴士

你还需要导入gomniauth/test包,并将其命名为gomniauthtest,就像我们在上一节中所做的那样。

在定义之前使用我们的新接口是一个检查我们思考是否合理的有效方法,这也是练习 TDD 的另一个优点。在这个新的测试中,我们创建 Gomniauth 提供的TestUser,并将其嵌入到chatUser类型中。然后我们将新的chatUser类型传递给我们的GetAvatarURL调用,并对输出做出与以往相同的断言。

小贴士

Gomniauth 的TestUser类型很有趣,因为它使用了Testify包的模拟功能。有关更多信息,请参阅github.com/stretchr/testify

OnReturn方法允许我们告诉TestUser在调用特定方法时应该做什么。在第一种情况下,我们告诉AvatarURL方法返回错误,在第二种情况下,我们要求它返回testUrl值,这模拟了我们在测试中覆盖的两个可能的结果。

更新其他两个测试要简单得多,因为它们只依赖于UniqueID方法,我们可以直接控制其值。

avatar_test.go中的其他两个测试替换为以下代码:

func TestGravatarAvatar(t *testing.T) { 
  var gravatarAvatar GravatarAvatar 
  user := &chatUser{uniqueID: "abc"} 
  url, err := gravatarAvatar.GetAvatarURL(user) 
  if err != nil { 
    t.Error("GravatarAvatar.GetAvatarURL should not return an error") 
  } 
  if url != "//www.gravatar.com/avatar/abc" { 
    t.Errorf("GravatarAvatar.GetAvatarURL wrongly returned %s", url) 
  } 
} 
func TestFileSystemAvatar(t *testing.T) { 
  // make a test avatar file 
  filename := path.Join("avatars", "abc.jpg") 
  ioutil.WriteFile(filename, []byte{}, 0777) 
  defer func() { os.Remove(filename) }() 
  var fileSystemAvatar FileSystemAvatar 
  user := &chatUser{uniqueID: "abc"} 
  url, err := fileSystemAvatar.GetAvatarURL(user) 
  if err != nil { 
    t.Error("FileSystemAvatar.GetAvatarURL should not return an error") 
  } 
  if url != "/avatars/abc.jpg" { 
    t.Errorf("FileSystemAvatar.GetAvatarURL wrongly returned %s", url) 
  } 
} 

当然,这段测试代码甚至无法编译,因为我们还没有更新我们的Avatar接口。在avatar.go中,将Avatar接口类型中的GetAvatarURL签名更新为接受ChatUser类型而不是client类型:

GetAvatarURL(ChatUser) (string, error) 

小贴士

注意,我们最终使用的是ChatUser接口(首字母大写),而不是我们内部的chatUser实现结构体。毕竟,我们希望我们的GetAvatarURL方法接受的类型更加灵活。

尝试构建这个项目将揭示我们现在有破损的实现,因为所有的GetAvatarURL方法仍在请求一个client对象。

修复现有实现

改变像我们这样的接口是一个自动找到我们代码中受影响部分的好方法,因为它们将导致编译错误。当然,如果我们正在编写其他人会使用的包,我们就必须对这种接口的改变更加严格,但我们还没有发布我们的 v1 版本,所以这没关系。

现在,我们将更新三个实现签名以满足新的接口,并将方法体更改为使用新类型。将FileSystemAvatar的实现替换为以下内容:

func (FileSystemAvatar) GetAvatarURL(u ChatUser) (string, error) { 
  if files, err := ioutil.ReadDir("avatars"); err == nil { 
    for _, file := range files { 
      if file.IsDir() { 
        continue 
      } 
      if match, _ := path.Match(u.UniqueID()+"*", file.Name()); 
      match { 
        return "/avatars/" + file.Name(), nil 
      } 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

这里关键的改变是我们不再访问客户端的userData字段,而是直接在ChatUser接口上调用UniqueID

接下来,我们使用以下代码更新AuthAvatar实现:

func (AuthAvatar) GetAvatarURL(u ChatUser) (string, error) { 
  url := u.AvatarURL() 
  if len(url) == 0 { 
    return "", ErrNoAvatarURL 
  } 
  return url, nil 
} 

我们的新设计证明要简单得多,如果我们能减少所需的代码量,那总是好事。前面的代码调用以获取AvatarURL值,如果它不为空,我们就返回它;否则,我们返回ErrNoAvatarURL错误。

小贴士

注意代码的预期流程缩进了一级,而错误情况被嵌套在if块中。虽然你不可能 100%地坚持这种做法,但这是一种值得努力的事情。能够快速扫描代码(在阅读时)以查看单列中的正常执行流程,可以使你更快地理解代码。将此与具有大量嵌套的if...else块的代码进行比较,后者需要更多的时间来理解。

最后,更新GravatarAvatar实现:

func (GravatarAvatar) GetAvatarURL(u ChatUser) (string, error) { 
  return "//www.gravatar.com/avatar/" + u.UniqueID(), nil 
} 

全局变量与字段

到目前为止,我们已经将Avatar实现分配给了room类型,这使得我们可以为不同的房间使用不同的头像。然而,这也暴露了一个问题:当我们的用户登录时,没有关于他们要去的房间的概念,因此我们不知道应该使用哪个Avatar实现。因为我们的应用程序只支持一个房间,我们将考虑另一种选择实现的方法:使用全局变量。

全局变量简单地说是一个定义在任何类型定义之外且可以从包的任何部分(以及如果它被导出,则从包外部)访问的变量。对于简单的配置,例如要使用哪种类型的Avatar实现,全局变量是一个简单且直接的解决方案。在main.go中的import语句下面添加以下行:

// set the active Avatar implementation 
var avatars Avatar = UseFileSystemAvatar 

这定义了avatars为一个全局变量,我们可以在需要获取特定用户的头像 URL 时使用它。

实现我们的新设计

我们需要更改调用GetAvatarURL的代码,使其仅访问我们放入userData缓存(通过authcookie)中的值。更改msg.AvatarURL被分配的行,如下所示:

if avatarUrl, ok := c.userData["avatar_url"]; ok { 
  msg.AvatarURL = avatarUrl.(string) 
} 

auth.go中的loginHandler内部找到调用provider.GetUser的代码,并将其替换,直到我们设置authCookieValue对象的位置,替换为以下代码:

user, err := provider.GetUser(creds) 
if err != nil { 
  log.Fatalln("Error when trying to get user from", provider, "-", err) 
} 
chatUser := &chatUser{User: user} 
m := md5.New() 
io.WriteString(m, strings.ToLower(user.Email())) 
chatUser.uniqueID = fmt.Sprintf("%x", m.Sum(nil)) 
avatarURL, err := avatars.GetAvatarURL(chatUser) 
if err != nil { 
  log.Fatalln("Error when trying to GetAvatarURL", "-", err) 
} 

在这里,我们在设置User字段(表示嵌入的接口)为 Gomniauth 返回的User值的同时创建了一个新的chatUser变量。然后我们将userid的 MD5 哈希保存到uniqueID字段中。

调用avatars.GetAvatarURL是我们所有辛勤工作的回报,因为我们现在在处理过程中更早地获得了用户的头像 URL。更新auth.go中的authCookieValue行,以在 cookie 中缓存头像 URL 并删除电子邮件地址,因为它不再需要:

authCookieValue := objx.New(map[string]interface{}{ 
  "userid":     chatUser.uniqueID, 
  "name":       user.Name(), 
  "avatar_url": avatarURL, 
}).MustBase64() 

无论Avatar实现需要执行的工作多么昂贵,例如在文件系统上迭代文件,由于实现仅在用户首次登录时执行,而不是每次他们发送消息时执行,因此这种工作得到了缓解。

整理和测试

最后,我们可以在重构过程中积累的一些冗余代码上进行裁剪。

由于我们不再在 room 中存储 Avatar 实现了,让我们从类型中删除该字段及其所有引用。在 room.go 中,从 room 结构体中删除 avatar Avatar 定义并更新 newRoom 方法:

func newRoom() *room { 
  return &room{ 
    forward: make(chan *message), 
    join:    make(chan *client), 
    leave:   make(chan *client), 
    clients: make(map[*client]bool), 
    tracer:  trace.Off(), 
  } 
} 

小贴士

记得尽可能使用编译器作为你的待办事项列表,并跟随错误找到你影响其他代码的地方。

main.go 中,删除传递给 newRoom 函数调用的参数,因为我们正在使用全局变量而不是这个变量。

在这个练习之后,最终用户体验保持不变。通常在重构代码时,内部结构会被修改,而公共接口保持稳定和不变。在这个过程中,记得重新运行单元测试,以确保你在代码演变过程中没有破坏任何东西。

小贴士

通常,运行像 golintgo vet 这样的工具来检查你的代码是一个好主意,以确保它遵循良好的实践,并且不包含任何 Go 伪错误,例如缺少注释或命名不当的函数。有一些故意留下供你自己修复。

结合所有三个实现

为了以响亮的方式结束这一章节,我们将实现一个机制,其中每个 Avatar 实现轮流尝试获取用户的 URL。如果第一个实现返回 ErrNoAvatarURL 错误,我们将尝试下一个,依此类推,直到找到可用的值。

avatar.go 中,在 Avatar 类型下方添加以下类型定义:

type TryAvatars []Avatar 

TryAvatars 类型只是一个 Avatar 对象的切片,我们可以自由地向其中添加方法。让我们添加以下 GetAvatarURL 方法:

func (a TryAvatars) GetAvatarURL(u ChatUser) (string, error) { 
  for _, avatar := range a { 
    if url, err := avatar.GetAvatarURL(u); err == nil { 
      return url, nil 
    } 
  } 
  return "", ErrNoAvatarURL 
} 

这意味着 TryAvatars 现在是一个有效的 Avatar 实现并且可以替代任何特定的实现。在先前的方法中,我们按顺序遍历 Avatar 对象的切片,为每个对象调用 GetAvatarURL。如果没有返回错误,我们返回 URL;否则,我们继续寻找。最后,如果我们无法找到值,我们根据接口设计返回 ErrNoAvatarURL

main.go 中更新 avatars 全局变量以使用我们的新实现:

var avatars Avatar = TryAvatars{ 
  UseFileSystemAvatar, 
  UseAuthAvatar, 
  UseGravatar} 

在这里,我们创建了一个新的 TryAvatars 切片类型实例,同时将其他 Avatar 实现放入其中。顺序很重要,因为它按切片中对象出现的顺序迭代。因此,首先我们的代码将检查用户是否上传了图片;如果没有,代码将检查认证服务是否有我们可以使用的图片。如果这些方法失败,将生成一个 Gravatar URL,在最坏的情况下(例如,如果用户没有添加 Gravatar 图片),将渲染一个默认占位符图像。

要查看我们的新功能如何工作,请执行以下步骤:

  1. 构建并重新运行应用程序:

    go build -o chat
    ./chat -host=:8080
    
    
  2. 通过访问 http://localhost:8080/logout 来注销。

  3. avatars 文件夹中删除所有图片。

  4. 通过导航到 http://localhost:8080/chat 来重新登录。

  5. 发送一些消息,并注意你的个人资料图片。

  6. 访问 http://localhost:8080/upload 并上传一个新的个人资料图片。

  7. 再次注销并按照之前的方式重新登录。

  8. 发送更多消息,并注意你的个人资料图片已经更新。

摘要

在本章中,我们在我们的聊天应用中添加了三种不同的个人资料图片实现。首先,我们要求身份验证服务为我们提供一个 URL。我们使用 Gomniauth 对用户资源数据的抽象,并将其作为用户界面的一部分包含在内,每次用户发送消息时都会这样做。利用 Go 的零(或默认)初始化,我们能够在不实际创建任何实例的情况下引用我们的 Avatar 接口的不同实现。

我们将数据存储在 cookie 中,以便用户登录时使用。鉴于 cookie 在我们代码的构建之间持续存在,我们添加了一个方便的注销功能来帮助我们验证我们的更改,我们还将其暴露给我们的用户,以便他们也可以注销。对代码的一些小改动以及 Bootstrap 在我们的聊天页面上的应用,极大地改善了我们的应用的外观和感觉。

我们在 Go 中使用 MD5 哈希来实现 en.gravatar.com/ API,通过哈希身份验证服务提供的电子邮件地址。如果电子邮件地址在 Gravatar 中不为人知,他们将为我们提供一个漂亮的默认占位符图片,这意味着我们的用户界面永远不会因为缺少图片而损坏。

然后,我们构建并完成了一个上传表单,并将服务器功能关联到 avatars 文件夹中保存上传的图片。我们看到了如何通过标准库的 http.FileServer 处理器将保存的上传图片暴露给用户。由于这引入了设计中的低效,我们通过单元测试的帮助重构了我们的解决方案。通过将 GetAvatarURL 调用移动到用户登录的点,而不是每次发送消息时,我们使我们的代码具有更高的可扩展性。

我们特殊的 ErrNoAvatarURL 错误类型被用作我们界面设计的一部分,以便在无法获取适当的 URL 时通知调用代码。这在我们创建 Avatars 切片类型时变得特别有用。通过在 Avatar 类型的一个切片上实现 Avatar 接口,我们能够创建一个新的实现,该实现轮流尝试从不同的选项中获取有效的 URL,首先是文件系统,然后是身份验证服务,最后是 Gravatar。我们通过不对用户与界面交互的方式产生任何影响来实现这一点。如果一个实现返回 ErrNoAvatarURL,我们就尝试下一个。

我们的聊天应用已经准备好上线,因此我们可以邀请我们的朋友进行真正的对话。但首先,我们需要选择一个域名来托管它,这个问题我们将在下一章中探讨。

第四章. 命令行工具查找域名

我们迄今为止构建的聊天应用程序准备好席卷全球,但在我们邀请朋友们加入对话之前,我们需要选择一个有效、吸引人且可用的域名,并将其指向运行我们的 Go 代码的服务器。我们不会长时间坐在我们最喜欢的域名提供商面前尝试不同的名称,而是将开发一些命令行工具来帮助我们找到正确的名称。在这个过程中,我们将看到 Go 标准库如何允许我们与终端和其他执行应用程序接口;我们还将探索一些构建命令行程序的模式和惯例。

在本章中,你将学习:

  • 如何使用最少的代码文件构建完整的命令行应用程序

  • 如何确保我们构建的工具可以使用标准流与其他工具组合

  • 如何与简单的第三方 JSON RESTful API 交互

  • 如何在 Go 代码中利用标准输入和输出管道

  • 如何逐行读取流式源

  • 如何构建一个 WHOIS 客户端来查找域名信息

  • 如何在环境变量中存储和使用敏感或部署特定的信息

命令行工具的管道设计

我们将构建一系列使用标准流(stdinstdout)与用户和其他工具通信的命令行工具。每个工具将通过标准输入管道逐行读取输入,以某种方式处理它,然后逐行将输出打印到标准输出管道,以便下一个工具或用户。

默认情况下,标准输入连接到用户的键盘,标准输出从运行命令的终端打印出来;然而,两者都可以使用 重定向元字符 进行重定向。可以将输出重定向到 Windows 上的 NUL 或 Unix 机器上的 /dev/null,或者将其重定向到将输出保存到磁盘的文件。或者,您可以使用 | 管道字符将一个程序的输出管道到另一个程序的输入;我们将利用这个特性将我们的各种工具连接起来。例如,您可以使用以下代码在终端中将一个程序的输出管道到另一个程序的输入:

echo -n "Hello" | md5

echo 命令的输出将是字符串 Hello(不带引号),然后通过 管道 传递给 md5 命令;这个命令将接着计算 Hello 的 MD5 哈希值:

8b1a9953c4611296a827abf8c47804d7

我们的工具将处理字符串的行,其中每一行(由换行符分隔)代表一个字符串。在没有任何管道重定向的情况下运行,我们将能够直接与使用默认输入和输出的程序交互,这在测试和调试我们的代码时将非常有用。

五个简单的程序

在本章中,我们将构建五个小型程序,最后我们将它们组合在一起。这些程序的关键特性如下:

  • Sprinkle:此程序将通过添加一些适合网络的点缀词来增加找到可用域名的机会。

  • Domainify:此程序将通过删除不可接受的字符来确保单词适合作为域名。一旦完成,它将用连字符替换空格,并在末尾添加适当的顶级域名(如.com.net)。

  • Coolify:此程序将通过调整元音来将一个无聊的普通词变成 Web 2.0 风格。

  • 同义词:此程序将使用第三方 API 来查找同义词。

  • 可用性:此语法将使用第三方 API 来查找同义词。可用性:此程序将使用适当的WHOIS服务器检查域名是否可用。

五个程序对于一个章节来说可能看起来很多,但不要忘记在 Go 中整个程序可以有多小。

Sprinkle

我们的第一个程序通过添加一些糖分术语来增强传入的单词,以提高找到可用名称的机会。许多公司使用这种方法来保持核心信息的连贯性,同时能够负担得起.com域名。例如,如果我们传入单词chat,它可能会输出chatapp;或者,如果我们传入talk,我们可能会得到talk time

Go 的math/rand包使我们能够摆脱计算机的预测性。它通过在决策过程中引入随机元素,使我们的程序看起来具有智能。

要使我们的 Sprinkle 程序工作,我们将:

  • 定义一个转换数组,使用特殊常量来指示原始单词将出现的位置

  • 使用bufio包从stdin读取输入,并使用fmt.Println将输出写入stdout

  • 使用math/rand包随机选择一个要应用的转换

小贴士

我们的所有程序都将位于$GOPATH/src目录下。例如,如果你的 GOPATH 是~/Work/projects/go,你将在~/Work/projects/go/src文件夹中创建你的程序文件夹。

$GOPATH/src目录下,创建一个名为sprinkle的新文件夹,并添加一个包含以下代码的main.go文件:

package main 
import ( 
  "bufio" 
  "fmt" 
  "math/rand" 
  "os" 
  "strings" 
  "time" 
) 
const otherWord = "*" 
var transforms = []string{  
otherWord, 
  otherWord + "app", 
  otherWord + "site", 
  otherWord + "time", 
  "get" + otherWord, 
  "go" + otherWord, 
  "lets " + otherWord, 
  otherWord + "hq", 
} 
func main() { 
  rand.Seed(time.Now().UTC().UnixNano()) 
  s := bufio.NewScanner(os.Stdin) 
  for s.Scan() { 
    t := transforms[rand.Intn(len(transforms))] 
    fmt.Println(strings.Replace(t, otherWord, s.Text(), -1)) 
  } 
} 

从现在开始,假设你将自行解决适当的import语句。如果你需要帮助,请参阅提供的提示,见附录,稳定 Go 环境的良好实践

上述代码代表了我们完整的 Sprinkle 程序。它定义了三件事:一个常量、一个变量以及必要的 main 函数,该函数作为 Sprinkle 的入口点。otherWord 常量字符串是一个有用的标记,它允许我们指定原始单词在每个可能的转换中应该出现的位置。它让我们能够编写如 otherWord+"extra" 这样的代码,这清楚地表明在这个特定情况下,我们想在原始单词的末尾添加单词 "extra"。

可能的转换存储在我们声明的字符串切片 transforms 变量中。在上述代码中,我们定义了一些不同的转换,例如在单词末尾添加 app 或在其前面添加 lets。请随意添加更多;越有创意越好。

main 函数中,我们首先使用当前时间作为随机种子。计算机实际上不能生成随机数,但改变随机算法的种子数字可以给人一种它可以的错觉。我们使用纳秒作为当前时间,因为每次程序运行时它都是不同的(前提是在每次运行之前系统时钟没有被重置)。如果我们跳过这一步,math/rand 包生成的数字将是确定的;每次运行程序时它们都是相同的。

然后,我们创建一个 bufio.Scanner 对象(通过调用 bufio.NewScanner),并告诉它从 os.Stdin 读取输入,它代表标准输入流。在我们的五个程序中,这将成为一个常见的模式,因为我们总是要从标准 in 读取并写入标准 out

小贴士

bufio.Scanner 对象实际上接受 io.Reader 作为其输入源,因此我们可以使用多种类型。如果您正在为此代码编写单元测试,您可以指定自己的 io.Reader 以供扫描器读取,从而无需您担心模拟标准输入流。

作为默认情况,扫描器允许我们读取由定义的分隔符分隔的字节块,例如回车和换行符字符。我们可以为扫描器指定自己的分割函数,或者使用标准库中内置的选项。例如,有 bufio.ScanWords,它通过在空白处而不是换行符处断开来扫描单个单词。由于我们的设计指定每行必须包含一个单词(或短语),因此默认的逐行设置是理想的。

Scan 方法的调用告诉扫描器从输入中读取下一个字节数组(下一行),然后它返回一个 bool 值,指示它是否找到了任何内容。这就是我们能够将其用作 for 循环的条件的原因。当有内容要处理时,Scan 返回 true,并执行 for 循环的主体;当 Scan 达到输入的末尾时,它返回 false,循环被中断。所选的字节存储在扫描器的 Bytes 方法中,我们使用的方便的 Text 方法将 []byte 切片转换为字符串。

for 循环内部(因此对于每一行输入),我们使用 rand.Intntransforms 切片中选择一个随机项,并使用 strings.Replace 将原始单词插入到 otherWord 字符串出现的位置。最后,我们使用 fmt.Println 将输出打印到默认标准输出流。

注意

math/rand 包提供的随机数不安全。如果你想要编写用于安全目的的利用随机数的代码,你必须使用 crypto/rand 包。

让我们构建我们的程序并玩玩它:

go build -o sprinkle
./sprinkle

一旦程序开始运行,它将使用默认行为从终端读取用户输入。它使用默认行为是因为我们没有将任何内容管道输入或指定读取来源。输入 chat 并按回车键。我们代码中的扫描器注意到单词末尾的换行符,并运行变换它的代码,输出结果。例如,如果你多次输入 chat,你会看到以下输出:

chat
go chat
chat
lets chat
chat
chat app

Sprinkle 永远不会退出(这意味着 Scan 方法永远不会返回 false 来中断循环),因为终端仍在运行;在正常执行中,输入管道将由生成输入的程序关闭。要停止程序,请按 Ctrl + C

在我们继续之前,让我们尝试运行 Sprinkle,指定不同的输入源。我们将使用 echo 命令生成一些内容,并通过管道字符将其传递到我们的 Sprinkle 程序:

echo "chat" | ./sprinkle

程序将随机变换单词,打印出来,然后退出,因为 echo 命令在终止和关闭管道之前只生成一行输入。

我们已经成功完成了我们的第一个程序,它具有非常简单但有用的功能,正如我们将看到的。

提示

作为额外的作业,而不是像我们之前那样硬编码 transformations 数组,看看你是否可以通过标志或将其存储在文本文件或数据库中来实现外部化。

Domainify

Sprinkle 输出的某些单词包含空格,也许还有在域名中不允许的其他字符。因此,我们将编写一个名为 Domainify 的程序;它将一行文本转换为可接受的域名段,并在末尾添加一个合适的 顶级域名 (TLD)。在 sprinkle 文件夹旁边创建一个新的文件夹,命名为 domainify,并添加一个名为 main.go 的文件,其中包含以下代码:

package main 
var tlds = []string{"com", "net"} 
const allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789_-" 
func main() { 
  rand.Seed(time.Now().UTC().UnixNano()) 
  s := bufio.NewScanner(os.Stdin) 
  for s.Scan() { 
    text := strings.ToLower(s.Text()) 
    var newText []rune 
    for _, r := range text { 
      if unicode.IsSpace(r) { 
        r = '-' 
      } 
      if !strings.ContainsRune(allowedChars, r) { 
        continue 
      } 
      newText = append(newText, r) 
    } 
    fmt.Println(string(newText) + "." +         
                tlds[rand.Intn(len(tlds))]) 
  } 
} 

你会注意到 Domainify 和 Sprinkle 程序之间有一些相似之处:我们使用rand.Seed设置随机种子,生成一个包装os.Stdin读取器的NewScanner方法,并扫描每一行直到没有更多输入。

我们首先将文本转换为小写,并构建一个新的rune类型切片,称为newTextrune类型仅包含出现在allowedChars字符串中的字符,这是strings.ContainsRune函数告诉我们的。如果rune是一个空格,我们通过调用unicode.IsSpace来确定,我们将其替换为一个连字符,这在域名中是一种可接受的实践。

注意

遍历字符串返回每个字符的索引和一个rune类型,这是一个表示字符本身的数值(具体来说,是int32)。有关 runes、字符和字符串的更多信息,请参阅blog.golang.org/strings

最后,我们将newText[]rune切片转换为字符串,并在打印之前添加.com.net,然后使用fmt.Println打印出来。

让我们构建并运行 Domainify:

go build -o domainify
./domainify

输入一些这些选项以查看domainify的反应:

  • Monkey

  • Hello Domainify

  • "What's up?"

  • One (two) three!

你可以看到,例如,One (two) three!可能会产生one-two-three.com

我们现在将组合 Sprinkle 和 Domainify 来观察它们是如何一起工作的。在你的终端中,导航到sprinkledomainify的父文件夹(可能是$GOPATH/src),并运行以下命令:

./sprinkle/sprinkle | ./domainify/domainify

在这里,我们运行了sprinkle程序,并将其输出管道传输到domainify程序。默认情况下,sprinkle使用终端作为输入,而domanify输出到终端。再次尝试输入chat几次,并注意输出与 Sprinkle 之前输出的相似,但现在它们是域名中可接受的。正是这种程序之间的管道连接使我们能够组合命令行工具。

提示

仅支持.com.net顶级域名相当有限。作为额外的作业,看看你是否可以通过命令行标志接受一个 TLDs 列表。

Coolify

通常,像chat这样的常见单词的域名已经被占用,一个常见的解决方案是在单词的元音上玩弄。例如,我们可能会移除a并使其变为cht(这实际上更不可能可用)或者添加a以产生chaat。虽然这显然对酷度没有实际影响,但它已经成为一种流行但略显过时的方法,可以确保听起来像原始单词的域名。

我们的第三个程序 Coolify 将允许我们玩转通过输入进入的单词的元音,并将修改后的版本写入输出。

sprinkledomainify旁边创建一个名为coolify的新文件夹,并创建一个包含以下代码的main.go代码文件:

package main 
const ( 
  duplicateVowel bool   = true 
  removeVowel    bool   = false 
)  
func randBool() bool { 
  return rand.Intn(2) == 0 
} 
func main() { 
  rand.Seed(time.Now().UTC().UnixNano()) 
  s := bufio.NewScanner(os.Stdin) 
  for s.Scan() { 
    word := []byte(s.Text()) 
    if randBool() { 
      var vI int = -1 
      for i, char := range word { 
        switch char { 
        case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U': 
          if randBool() { 
            vI = i 
          } 
        } 
      } 
      if vI >= 0 { 
        switch randBool() { 
        case duplicateVowel: 
          word = append(word[:vI+1], word[vI:]...) 
        case removeVowel: 
          word = append(word[:vI], word[vI+1:]...) 
        } 
      } 
    } 
    fmt.Println(string(word)) 
  } 
} 

虽然前面的 Coolify 代码看起来与 Sprinkle 和 Domainify 的代码非常相似,但它稍微复杂一些。在代码的顶部,我们声明了两个常量,duplicateVowelremoveVowel,这有助于使 Coolify 代码更易于阅读。switch 语句决定是否复制或删除元音。此外,使用这些常量,我们能够非常清楚地表达我们的意图,而不是仅仅使用 truefalse

我们然后定义了一个辅助函数 randBool,它随机返回 truefalse。这是通过请求 rand 包生成一个随机数并确认该数字是否为零来完成的。它将是 01,所以有一半的机会它是 true

Coolify 的 main 函数与 Sprinkle 和 Domainify 的 main 函数以相同的方式开始,设置 rand.Seed 方法并创建标准输入流的扫描器,然后在执行输入行的循环体之前。我们首先调用 randBool 来决定是否甚至要变异一个单词,因此 Coolify 只会影响通过它的单词的一半。

我们然后遍历字符串中的每个 rune 并寻找元音。如果我们的 randBool 方法返回 true,我们保留元音字符在 vI 变量中的索引。如果不,我们继续在字符串中寻找另一个元音,这允许我们从单词中随机选择一个元音,而不是总是修改同一个元音。

一旦我们选择了一个元音,我们再次使用 randBool 来随机决定采取什么行动。

注意

这就是有用的常量发挥作用的地方;考虑以下替代的 switch 语句:

switch randBool() { 
  case true:
    word = append(word[:vI+1], word[vI:]...)
  case false:
    word = append(word[:vI], word[vI+1:]...) }

true and false don't express any context. On the other hand, using duplicateVowel and removeVowel tells anyone reading the code what we mean by the result of randBool.

在切片后面的三个点使得每个项目都能作为单独的参数传递给 append 函数。这是一种将一个切片附加到另一个切片的惯用方法。在 switch 案例中,我们进行一些切片操作,要么复制元音,要么完全删除它。我们再次对 []byte 切片进行切片,并使用 append 函数构建一个新的切片,该切片由原始单词的各个部分组成。以下图表显示了我们在代码中访问的字符串的哪些部分:

Coolify

blueprints 作为示例单词的值,并假设我们的代码已选择第一个 e 字符作为元音(因此 vI3),以下表格将说明单词的每个新切片将代表什么:

代码 描述
word[:vI+1] blue 这描述了从单词开头到所选元音的切片。+1 是必需的,因为冒号后面的值不包括指定的索引;相反,它切片到那个值。
word[vI:] eprints 这描述了从所选元音开始并包括所选元音的切片。
word[:vI] blu 这描述了从单词开头到所选元音之前的切片。
word[vI+1:] 打印 这描述了从所选元音之后的项到切片末尾的切片。

修改单词后,我们使用 fmt.Println 打印它。

让我们构建 Coolify 并尝试使用它看看它能做什么:

go build -o coolify
./coolify

当 Coolify 运行时,尝试输入 blueprints 来查看它提出了什么样的修改:

blueprnts
bleprints
bluepriints
blueprnts
blueprints
bluprints

让我们看看 Coolify 如何通过将它们的名称添加到我们的管道链中来与 Sprinkle 和 Domainify 互动。在终端中,使用 cd 命令返回父文件夹,并运行以下命令:

./coolify/coolify | ./sprinkle/sprinkle | ./domainify/domainify

我们首先通过添加额外的部分来丰富一个单词,并通过调整元音使其更加酷,最后将其转换成一个有效的域名。通过输入几个单词并查看代码的建议来尝试一下。

小贴士

Coolify 只作用于元音;作为一个额外的练习,看看你是否能让代码对遇到的每个字符都进行操作,以看看会发生什么。

Synonyms

到目前为止,我们的程序只修改了单词,但为了真正让我们的解决方案活起来,我们需要能够集成一个提供单词同义词的第三方 API。这允许我们在保留原始意义的同时建议不同的域名。与 Sprinkle 和 Domainify 不同,Synonyms 将为每个给定的单词写出多个响应。我们通过管道连接程序一起的架构意味着这不会成为太大的问题;事实上,我们甚至不必担心它,因为这三个程序中的每一个都能够从输入源中读取多行。

大型同义词词典,bighugelabs.com/,提供了一个非常干净简单的 API,允许我们通过发送单个 HTTP GET请求来查找同义词。

小贴士

在未来,如果我们使用的 API 发生变化或消失(毕竟,我们在处理互联网),你将在 github.com/matryer/goblueprints 找到一些选项。

在你能够使用大型同义词词典之前,你需要一个 API 密钥,你可以通过在 words.bighugelabs.com/ 上注册服务来获取。

使用环境变量进行配置

你的 API 密钥是一段敏感的配置信息,你不希望与他人分享。我们可以在代码中将它存储为 const。然而,这意味着我们无法在不分享密钥的情况下分享我们的代码(特别是如果你喜欢开源项目的话)。此外,也许更重要的是,如果密钥过期或你想使用另一个密钥,你将不得不重新编译整个项目(你不想陷入这种境地)。

一个更好的解决方案是使用环境变量来存储密钥,因为这将允许你轻松地更改它,如果你需要的话。你也可以为不同的部署有不同的密钥;也许你可以有一个用于开发或测试的密钥,另一个用于生产。这样,你可以为特定的代码执行设置一个特定的密钥,这样你就可以轻松地在密钥之间切换,而无需更改系统级别的设置。此外,不同的操作系统以类似的方式处理环境变量,所以如果你正在编写跨平台代码,它们是一个完美的选择。

创建一个名为BHT_APIKEY的新环境变量,并将 API 密钥设置为它的值。

注意

对于运行 bash shell 的机器,你可以修改你的~/.bashrc文件或类似文件,以包含以下export命令:

 `export BHT_APIKEY=abc123def456ghi789jkl` 

在 Windows 机器上,你可以导航到计算机属性,并在高级部分查找环境变量

消费 Web API

在网页浏览器中发出请求,显示了当寻找单词love的同义词时 JSON 响应数据的结构:

{ 
  "noun":{ 
    "syn":[ 
      "passion", 
      "beloved", 
      "dear" 
    ] 
  }, 
  "verb":{ 
    "syn":[ 
      "love", 
      "roll in the hay", 
      "make out" 
    ], 
    "ant":[ 
      "hate" 
    ] 
  } 
} 

一个真实的 API 将返回比这里打印的更多实际单词,但结构才是最重要的。它代表一个对象,其中键描述了单词的类型(动词、名词等)。此外,值是包含以synant(分别代表同义词和反义词)为键的字符串数组的对象;我们感兴趣的是同义词。

为了将这个 JSON 字符串数据转换成我们可以在代码中使用的东西,我们必须使用encoding/json包中的功能将其解码成我们自己的结构。因为我们正在编写可能在我们项目范围之外有用的东西,所以我们将通过可重用的包来消费 API,而不是直接在我们的程序代码中。在你的其他程序文件夹(在$GOPATH/src)旁边创建一个名为thesaurus的新文件夹,并将以下代码插入一个名为bighuge.go的新文件中:

package thesaurus 
import ( 
  "encoding/json" 
  "errors" 
  "net/http" 
) 
type BigHuge struct { 
  APIKey string 
} 
type synonyms struct { 
  Noun *words `json:"noun"` 
  Verb *words `json:"verb"` 
} 
type words struct { 
  Syn []string `json:"syn"` 
} 
func (b *BigHuge) Synonyms(term string) ([]string, error) { 
  var syns []string 
  response, err := http.Get("http://words.bighugelabs.com/api/2/"  + 
   b.APIKey + "/" + term + "/json") 
  if err != nil { 
    return syns, errors.New("bighuge: Failed when looking for  synonyms    
     for "" + term + """ + err.Error()) 
  } 
  var data synonyms 
  defer response.Body.Close() 
  if err := json.NewDecoder(response.Body).Decode(&data); err !=  nil { 
    return syns, err 
  } 
  if data.Noun != nil { 
    syns = append(syns, data.Noun.Syn...) 
  } 
  if data.Verb != nil { 
    syns = append(syns, data.Verb.Syn...) 
  } 
  return syns, nil 
} 

在前面的代码中,我们定义的BigHuge类型包含必要的 API 密钥,并提供了一个Synonyms方法,它将负责访问端点、解析响应并返回结果。这段代码中最有趣的部分是synonymswords结构。它们用 Go 术语描述了 JSON 响应格式,即包含名词和动词对象的对象,这些对象反过来包含一个名为Syn的字符串切片。标签(每个字段定义后面的反引号中的字符串)告诉encoding/json包将哪些字段映射到哪些变量;这是必需的,因为我们已经给了它们不同的名字。

小贴士

通常在 JSON 中,键名是小写的,但我们必须在我们的结构中使用大写名称,这样encoding/json包也会知道字段存在。如果我们不这样做,包将简单地忽略这些字段。然而,类型本身(synonymswords)不需要被导出。

Synonyms方法接受一个term参数,并使用http.Get向包含 API 密钥值以及term值的 API 端点发起网络请求。如果由于某种原因网络请求失败,我们将调用log.Fatalln,这将错误写入标准错误流,并以非零退出代码(实际上是一个退出代码1)退出程序。这表示发生了错误。

如果网络请求成功,我们将响应体(另一个io.Reader)传递给json.NewDecoder方法,并要求它将字节解码到我们的synonyms类型的data变量中。我们延迟关闭响应体,以便在使用 Go 的内置append函数将nounverb同义词连接到我们随后返回的syns切片之前保持内存清洁。

虽然我们已经实现了BigHuge同义词库,但这并不是唯一的选择,我们可以通过向我们的包中添加Thesaurus接口来表达这一点。在thesaurus文件夹中,创建一个名为thesaurus.go的新文件,并将以下接口定义添加到该文件中:

package thesaurus 
type Thesaurus interface { 
  Synonyms(term string) ([]string, error) 
} 

这个简单的接口仅仅描述了一个方法,该方法接受一个term字符串,并返回包含同义词的字符串切片或错误(如果发生错误)。我们的BigHuge结构已经实现了这个接口,但现在,其他用户可以为其他服务添加可互换的实现,例如www.dictionary.com/或 Merriam-Webster 在线服务。

接下来,我们将在程序中使用这个新包。在终端中,将目录向上移动一级到$GOPATH/src,创建一个名为synonyms的新文件夹,并将以下代码插入到该文件夹中一个名为main.go的新文件中:

func main() { 
  apiKey := os.Getenv("BHT_APIKEY") 
  thesaurus := &thesaurus.BigHuge{APIKey: apiKey} 
  s := bufio.NewScanner(os.Stdin) 
  for s.Scan() { 
    word := s.Text() 
    syns, err := thesaurus.Synonyms(word) 
    if err != nil { 
      log.Fatalln("Failed when looking for synonyms for  "+word+", err) 
    } 
    if len(syns) == 0 { 
      log.Fatalln("Couldn't find any synonyms for " + word +  ") 
    } 
    for _, syn := range syns { 
      fmt.Println(syn) 
    } 
  } 
} 

现在你再次管理你的导入时,你将编写一个完整的程序,该程序能够通过集成 Big Huge Thesaurus API 查找单词的同义词。

在前面的代码中,我们的main函数首先通过os.Getenv调用获取BHT_APIKEY环境变量的值。为了保护你的代码,你可能需要再次检查以确保值已正确设置;如果没有,则报告错误。目前,我们将假设一切配置正确。

接下来,前面的代码开始看起来有些熟悉,因为它再次从os.Stdin读取每一行输入,并调用Synonyms方法来获取替换词的列表。

让我们构建一个程序,看看当我们输入单词chat时,API 返回了什么样的同义词:

go build -o synonyms
./synonyms
chat
confab
confabulation
schmooze
New World chat
Old World chat
conversation
thrush
wood warbler
chew the fat
shoot the breeze
chitchat
chatter

你得到的结果很可能与我们列出的不同,因为我们正在调用一个实时 API。然而,重要的是,当我们向程序提供一个单词或术语作为输入时,它返回一个同义词列表作为输出,每行一个。

获取域名建议

通过组合本章中构建的四个程序,我们已经有了一个有用的工具来建议域名。我们现在需要做的就是以适当的方式将程序的输出管道传输到输入。在终端中,导航到父文件夹并运行以下单行命令:

./synonyms/synonyms | ./sprinkle/sprinkle | ./coolify/coolify |  ./domainify/domainify

因为synonyms程序在我们的列表中排在第一位,所以它将接收来自终端的输入(无论用户决定输入什么)。同样,因为domainify在链中排在最后,所以它将打印输出到终端供用户查看。在这个过程中,单词行将通过其他程序进行管道传输,给每个程序一个施展魔法的机会。

输入几个词以查看一些域名建议;例如,当你输入chat并按回车键时,你可能看到以下内容:

getcnfab.com
confabulationtim.com
getschmoozee.net
schmosee.com
neew-world-chatsite.net
oold-world-chatsite.com
conversatin.net
new-world-warblersit.com
gothrush.net
lets-wood-wrbler.com
chw-the-fat.com

你得到的建议数量实际上取决于同义词的数量。这是因为它是唯一一个输出行数多于输入行的程序。

我们还没有解决我们最大的问题:我们不知道建议的域名实际上是否可用。所以我们仍然需要坐下来逐个将它们输入到网站上。在下一节中,我们将解决这个问题。

Available

我们最后的程序,Available,将连接到 WHOIS 服务器以获取传递给它的域的详细信息,当然,如果没有返回详细信息,我们可以安全地假设该域名可供购买。不幸的是,WHOIS 规范(见tools.ietf.org/html/rfc3912)非常小,并且不包含有关当请求域名详细信息时 WHOIS 服务器应该如何回复的信息。这意味着程序化解析响应变成了一项繁琐的工作。为了解决这个问题,我们现在将仅与一个 WHOIS 服务器集成,我们可以确信当它没有该域的记录时,响应中会有“无匹配”字样。

注意

一个更健壮的解决方案是拥有一个 WHOIS 接口,具有定义良好的结构来显示详细信息,也许还有当域名不存在时的错误消息,以及针对不同 WHOIS 服务器的不同实现。正如你可以想象的那样,这是一个相当大的项目;它非常适合开源努力。

在其他文件夹旁边创建一个名为available的新文件夹,并向其中添加一个main.go文件,包含以下函数代码:

func exists(domain string) (bool, error) { 
  const whoisServer string = "com.whois-servers.net" 
  conn, err := net.Dial("tcp", whoisServer+":43") 
  if err != nil { 
    return false, err 
  } 
  defer conn.Close() 
  conn.Write([]byte(domain + "rn")) 
  scanner := bufio.NewScanner(conn) 
  for scanner.Scan() { 
    if strings.Contains(strings.ToLower(scanner.Text()), "no match") { 
      return false, nil 
    } 
  } 
  return true, nil 
} 

exists 函数通过调用 net.Dial 打开到指定 whoisServer 实例的 43 端口的连接来实现 WHOIS 规范中存在的内容。然后我们延迟关闭连接,这意味着无论函数如何退出(成功、错误,甚至恐慌),Close() 都会在 conn 连接上被调用。一旦连接打开,我们只需写入域名后跟 rn(回车和换行字符)。这就是规范告诉我们的全部内容,所以从现在起我们就得自己动手了。

实际上,我们正在寻找响应中提到“没有匹配”的内容,这就是我们将如何决定域名是否存在(在这种情况下,exists 实际上是在询问 WHOIS 服务器是否有我们指定的域名的记录)。我们使用我们最喜欢的 bufio.Scanner 方法来帮助我们遍历响应中的行。将连接传递给 NewScanner 是可行的,因为 net.Conn 实际上也是一个 io.Reader。我们使用 strings.ToLower 以免担心大小写敏感,并使用 strings.Contains 来检查是否有任何一行包含 no match 文本。如果有,我们返回 false(因为域名不存在);否则,我们返回 true

com.whois-servers.net WHOIS 服务支持 .com.net 域名,这就是为什么 Domainify 程序只添加这些类型的域名。如果你使用了一个提供更广泛域名 WHOIS 信息的服务器,你可以添加对其他顶级域(TLD)的支持。

让我们添加一个 main 函数,使用我们的 exists 函数来检查传入的域名是否可用。以下代码中的勾号和叉号符号是可选的,如果你的终端不支持它们,你可以自由地将它们替换为简单的 YesNo 字符串。

将以下代码添加到 main.go 中:

var marks = map[bool]string{true: "✔", false: "✖"}
func main() {
s := bufio.NewScanner(os.Stdin)
for s.Scan() {
domain := s.Text()
fmt.Print(domain, " ")
exist, err := exists(domain)
if err != nil {
log.Fatalln(err)
}
fmt.Println(marks[!exist])
time.Sleep(1 * time.Second)
}
} 

注意

我们可以在代码中愉快地使用勾号和叉号字符,因为所有 Go 代码文件都是 UTF-8 兼容的。实际上获取这些字符的最佳方式是搜索网络并使用复制粘贴选项将它们带入我们的代码。否则,有平台依赖的方式可以获取这样的特殊字符。

在上一个 main 函数的代码中,我们简单地遍历通过 os.Stdin 进来的每一行。这个过程帮助我们使用 fmt.Print 打印出域名(但不是 fmt.Println,因为我们还不想要换行符),调用我们的 exists 函数来检查域名是否存在,并使用 fmt.Println 打印出结果(因为我们确实想要在末尾有一个换行符)。

最后,我们使用 time.Sleep 来告诉进程在一秒钟内什么也不做,以确保我们对 WHOIS 服务器不要太苛刻。

提示

大多数 WHOIS 服务器都会以各种方式受限,以防止你占用过多的资源。因此,放慢速度是一种合理的做法,以确保我们不会让远程服务器生气。

考虑这也对单元测试意味着什么。如果一个单元测试实际上正在向远程 WHOIS 服务器发送真实请求,每次你的测试运行时,你都会对你的 IP 地址进行统计。一个更好的方法是对 WHOIS 服务器进行存根以模拟响应。

顶部的marks映射是一个将bool响应从exists映射到可读文本的好方法,允许我们只需使用fmt.Println(marks[!exist])在单行中打印出响应。我们说不存在,因为我们的程序正在检查域名是否可用(逻辑上,这是否存在于 WHOIS 服务器中的对立面)。

在修复 main.go 文件的导入语句后,我们可以尝试运行 Available,通过输入以下命令来查看域名是否可用:

go build -o available
./available

一旦 Available 开始运行,输入一些域名,并查看结果出现在下一行:

可用

如您所见,对于不可用的域名,我们在它们旁边得到一个小十字标记;然而,当我们使用随机数字创建域名时,我们看到它确实是可用的。

组合所有五个程序

现在我们已经完成了所有五个程序,是时候将它们全部组合起来,这样我们就可以使用我们的工具为我们的聊天应用程序找到一个可用的域名。最简单的方法是使用我们在本章中一直在使用的技术:在终端中使用管道连接输出和输入。

在终端中,导航到五个程序的父文件夹,并运行以下单行代码:

./synonyms/synonyms | ./sprinkle/sprinkle | ./coolify/coolify |  ./domainify/domainify | ./available/available

一旦程序开始运行,输入一个起始词,看看它如何生成建议,然后再检查它们的可用性。

例如,输入chat可能会使程序执行以下操作:

  1. 单词chat进入synonyms,这导致一系列同义词:

    • confab

    • confabulation

    • schmooze

  2. 同义词流入sprinkle;在这里,它们被添加了网络友好的前缀和后缀,如下所示:

    • confabapp

    • goconfabulation

    • schmooze time

  3. 这些新词流入coolify;在这里,元音可能被调整:

    • confabaapp

    • goconfabulatioon

    • schmoooze time

  4. 修改后的单词随后流入domainify;在这里,它们被转换成有效的域名:

    • confabaapp.com

    • goconfabulatioon.net

    • schmooze-time.com

  5. 最后,域名流入available;在这里,它们会与 WHOIS 服务器进行核对,以查看是否有人已经注册了该域名:

    • confabaapp.com 组合所有五个程序

    • goconfabulatioon.net 组合所有五个程序

    • schmooze-time.com 组合所有五个程序

一款程序统治一切

通过管道将程序连接起来运行是一种优雅的架构形式,但它的界面并不优雅。具体来说,每次我们想要运行我们的解决方案时,我们必须输入一个长而混乱的行,其中每个程序都列出来并通过管道字符分隔。在本节中,我们将编写一个 Go 程序,该程序使用 os/exec 包来运行每个子程序,同时将一个程序的输出通过管道传递到下一个程序的输入,正如我们的设计一样。

在其他五个程序旁边创建一个名为 domainfinder 的新文件夹,并在该文件夹内创建另一个名为 lib 的新文件夹。lib 文件夹是我们将保存子程序构建的地方,但我们不想每次更改时都复制粘贴。相反,我们将编写一个脚本来构建子程序并将二进制文件复制到 lib 文件夹中。

在 Unix 机器上创建一个名为 build.sh 的新文件或在 Windows 上创建一个名为 build.bat 的新文件,并将以下代码插入到其中:

#!/bin/bash 
echo Building domainfinder... 
go build -o domainfinder 
echo Building synonyms... 
cd ../synonyms 
go build -o ../domainfinder/lib/synonyms 
echo Building available... 
cd ../available 
go build -o ../domainfinder/lib/available 
cd ../build 
echo Building sprinkle... 
cd ../sprinkle 
go build -o ../domainfinder/lib/sprinkle 
cd ../build 
echo Building coolify... 
cd ../coolify 
go build -o ../domainfinder/lib/coolify 
cd ../build 
echo Building domainify... 
cd ../domainify 
go build -o ../domainfinder/lib/domainify 
cd ../build 
echo Done.

上述脚本简单地构建了我们的所有子程序(包括我们尚未编写的 domainfinder),告诉 go build 将它们放置在我们的 lib 文件夹中。确保通过执行 chmod +x build.sh 或类似操作为新脚本赋予执行权限。从终端运行此脚本,并检查 lib 文件夹以确保它确实放置了我们的子程序的二进制文件。

小贴士

目前不必担心 没有可构建的 Go 源文件 错误;这只是 Go 告诉我们 domainfinder 程序没有 .go 文件可以构建。

domainfinder 内创建一个名为 main.go 的新文件,并将以下代码插入到文件中:

package main 
var cmdChain = []*exec.Cmd{ 
  exec.Command("lib/synonyms"), 
  exec.Command("lib/sprinkle"), 
  exec.Command("lib/coolify"), 
  exec.Command("lib/domainify"), 
  exec.Command("lib/available"), 
} 
func main() { 
  cmdChain[0].Stdin = os.Stdin 
  cmdChain[len(cmdChain)-1].Stdout = os.Stdout 
  for i := 0; i < len(cmdChain)-1; i++ { 
    thisCmd := cmdChain[i] 
    nextCmd := cmdChain[i+1] 
    stdout, err := thisCmd.StdoutPipe() 
    if err != nil { 
      log.Fatalln(err) 
    } 
    nextCmd.Stdin = stdout 
  } 
  for _, cmd := range cmdChain { 
    if err := cmd.Start(); err != nil { 
      log.Fatalln(err) 
    } else { 
      defer cmd.Process.Kill() 
    } 
  } 
  for _, cmd := range cmdChain { 
    if err := cmd.Wait(); err != nil { 
      log.Fatalln(err) 
    } 
  } 
}

os/exec 包为我们提供了在 Go 程序中运行外部程序或命令所需的一切。首先,我们的 cmdChain 切片包含我们想要按顺序连接的 *exec.Cmd 命令。

main 函数的顶部,我们将第一个程序的 Stdin(标准输入流)与该程序的 os.Stdin 流绑定,并将最后一个程序的 Stdout(标准输出流)与该程序的 os.Stdout 流绑定。这意味着,就像之前一样,我们将通过标准输入流获取输入并将输出写入标准输出流。

我们接下来的代码块是通过迭代每个项目并将它的 Stdin 设置为之前程序的 Stdout 流来连接子程序的。

以下表格显示了每个程序及其输入来源和输出去向的描述:

程序 输入(标准输入) 输出(标准输出)
同义词 domainfinder 相同的 Stdin sprinkle
sprinkle 同义词 coolify
coolify sprinkle domainify
domainify coolify available
available domainify domainfinder 相同的 Stdout

我们随后遍历每个命令,调用Start方法,该方法在后台运行程序(与Run方法相反,它将阻塞我们的代码直到子程序存在,这会不好,因为我们将不得不同时运行五个程序)。如果发生任何错误,我们使用log.Fatalln退出;然而,如果程序成功启动,我们将延迟调用以终止进程。这有助于我们确保子程序在我们main函数退出时退出,这将是domainfinder程序结束时。

一旦所有程序开始运行,我们再次遍历每个命令并等待其完成。这是为了确保domainfinder不会提前退出并过早地杀死所有子程序。

再次运行build.shbuild.bat脚本,注意domainfinder程序的行为与我们之前看到的行为相同,但界面更加优雅。

以下截图显示了当我们输入clouds时我们程序的输出;我们找到了相当多的可用域名选项:

一个程序统治一切

摘要

在本章中,我们了解到五个小型命令行程序在组合在一起时可以产生强大的结果,同时保持模块化。我们避免了程序之间的紧密耦合,这样它们仍然可以单独使用。例如,我们可以使用我们的 Available 程序仅检查我们手动输入的域名是否可用,或者我们可以像命令行同义词词典一样使用我们的synonyms程序。

我们了解到如何使用标准流构建这些类型程序的不同流程,以及标准输入和标准输出的重定向如何让我们能够非常容易地玩转不同的流程。

当我们想要从 Big Huge Thesaurus 获取同义词时,我们了解到在 Go 中消费 JSON RESTful API web 服务是多么简单。我们还消费了非 HTTP API,当我们打开到 WHOIS 服务器的连接并使用原始 TCP 写入数据时。

我们看到math/rand包如何通过允许我们在代码中使用伪随机数和决策来带来一点多样性和不可预测性,这意味着每次我们运行程序时,我们都会得到不同的结果。

最后,我们构建了domainfinder超级程序,将所有子程序组合在一起,为我们解决方案提供了一个简单、干净且优雅的界面。

在下一章中,我们将通过探索如何使用消息队列技术连接程序,使它们可以跨多台机器分布式运行,从而将我们迄今为止学到的某些想法进一步发展。

第五章:构建分布式系统和处理灵活数据

在本章中,我们将探讨可转移的技能,这些技能使我们能够使用无模式数据和分布式技术来解决大数据问题。本章中我们将构建的系统将为我们准备一个未来,在这个未来中,所有民主选举都在 Twitter 上在线进行,当然。我们的解决方案将通过查询 Twitter 的流式 API 以获取特定哈希标签的提及来收集和计票,并且每个组件都将能够进行水平扩展以满足需求。我们的用例既有趣又引人入胜,但本章真正关注的是我们将学习到的核心概念和我们将做出的具体技术选择。这里讨论的想法可以直接应用于任何需要真实规模能力的系统。

注意

水平扩展指的是向系统中添加节点,例如物理机器,以提高其可用性、性能和/或容量。像 Google 这样的大数据公司可以通过添加经济实惠且易于获得的硬件(通常称为通用硬件)来扩展,这得益于他们编写软件和构建解决方案的方式。

垂直扩展等同于增加单个节点可用的资源,例如向一个盒子添加额外的 RAM 或具有更多核心的处理器。

在本章中,你将:

  • 了解分布式NoSQL数据存储,特别是如何与 MongoDB 交互

  • 了解分布式消息队列,在我们的案例中,是 Bit.ly 的 NSQ 以及如何使用go-nsq包轻松发布和订阅事件

  • 通过 Twitter 的流式 API 实时传输推文数据并管理长时间运行的网路连接

  • 学习如何正确停止具有许多内部 goroutines 的程序

  • 了解如何使用低内存通道进行信号传递

系统设计

在分布式系统中,许多组件将以不同的方式相互通信,因此绘制一个基本设计通常很有用。我们不希望在这个阶段花费太多时间,因为我们的设计可能会随着我们陷入细节而演变,但我们将查看一个高级概述,以便我们可以讨论组成部分及其如何组合在一起:

系统设计

以下图表显示了我们将要构建的系统的基本概述:

  • Twitter 是我们所有人都知道并喜爱的社交媒体网络。

  • Twitter 的流式 API 允许长时间运行的连接,推文数据可以尽可能快地传输。

  • twittervotes是我们将编写的程序,它通过 Twitter API 提取相关推文数据,决定正在投票的内容(而不是推文正文中提到的选项),然后将投票推送到 NSQ。

  • NSQ 是一个开源的实时分布式消息平台,旨在进行大规模操作,由 Bit.ly 开发和维护。NSQ 在其实例之间传输消息,使其对任何表示对投票数据感兴趣的人可用。

  • counter 是我们将编写的程序,它监听消息队列上的投票,并定期将结果保存到 MongoDB 数据库中。它从 NSQ 接收投票消息,并保持内存中的计分,定期推送更新以持久化数据。

  • MongoDB 是一个开源的文档数据库,旨在进行大规模操作。

  • web 是一个将暴露我们在下一章中编写的实时结果的 Web 服务器程序。

可以争论说,可以编写一个单一的 Go 程序来读取推文、计数投票并将它们推送到用户界面,但这样的解决方案,虽然是一个很好的概念证明,但在规模上会非常有限。在我们的设计中,任何组件都可以随着对该特定功能的需求的增加而进行水平扩展。如果我们有相对较少的投票但很多人查看数据,我们可以保持 twittervotescounter 实例的数量,并添加更多的 web 和 MongoDB 节点,反之亦然。

注意

我们设计的另一个关键优势是冗余;由于我们可以同时运行许多组件实例,如果一个我们的盒子消失了(例如,由于系统崩溃或断电),其他可以填补空缺。现代架构通常将此类系统分布到地理范围,以防止局部自然灾害。如果我们以这种方式构建解决方案,所有这些选项都可以使用。

我们在本章中选择了特定的技术,因为它们与 Go 的联系(例如,NSQ 完全用 Go 编写)以及经过良好测试的驱动程序和包的可用性。然而,从概念上讲,你可以根据需要添加各种替代方案。

数据库设计

我们将称我们的 MongoDB 数据库为 ballots。它将包含一个名为 polls 的单个集合,我们将在这里存储投票细节,例如标题、选项和结果(在一个单独的 JSON 文档中)。投票的代码看起来可能像这样:

{ 
  "_id": "???", 
  "title": "Poll title", 
  "options": ["one", "two", "three"], 
  "results": { 
    "one": 100, 
    "two": 200, 
    "three": 300 
  } 
} 

_id 字段是 MongoDB 自动生成的每个项目的唯一字符串。options 字段包含一个字符串选项数组;这些是我们将在 Twitter 上寻找的哈希标签。results 字段是一个映射,其中键代表选项,值代表每个项目的总投票数。

安装环境

我们在本章中编写的代码具有真实的外部依赖项,我们需要在开始构建我们的系统之前设置这些依赖项。

小贴士

如果你在安装任何依赖项时遇到困难,请务必查看github.com/matryer/goblueprints中的章节注释。

在大多数情况下,必须在运行我们的程序之前启动像 mongodnsqd 这样的服务。由于我们正在编写分布式系统的组件,我们必须同时运行每个程序,这就像打开许多终端窗口一样简单。

介绍 NSQ

NSQ 是一个消息队列,允许一个程序向另一个或多个程序发送消息或事件,这些程序可以是在同一台机器上本地运行的,也可以是通过网络连接的不同节点上的。NSQ 保证至少将每条消息投递一次,这意味着它会将未投递的消息缓存起来,直到所有感兴趣的各方都收到它们。这意味着即使我们停止 counter 程序,也不会错过任何投票。你可以将这种能力与“火光即忘”的消息队列进行对比,其中信息被认为过时,因此如果信息没有及时投递,并且消息的发送者不关心消费者是否收到它们,那么这些信息就会被遗忘。

消息队列抽象允许系统中的不同组件在不同的地方运行,前提是它们能够通过网络连接到队列。你的程序与其他程序解耦;相反,你的设计开始关注专用微服务的输入输出,而不是通过单体程序的数据流。

NSQ 转移原始字节,这意味着如何将这些字节编码成数据取决于我们。例如,根据我们的需求,我们可以将数据编码为 JSON 或二进制格式。在我们的案例中,我们将以字符串的形式发送投票选项,而不进行任何额外的编码,因为我们只共享单个数据字段。

我们首先需要安装并运行 NSQ:

  1. 在浏览器中打开 nsq.io/deployment/installing.html(或搜索 install nsq)并遵循适合你环境的说明。你可以下载预编译的二进制文件,或者从源代码构建自己的版本。如果你已经安装了 homebrew,安装 NSQ 只需输入以下命令:

    brew install nsq
    
    
  2. 安装 NSQ 后,你需要将 bin 文件夹添加到你的 PATH 环境变量中,以便在终端中可以使用这些工具。

  3. 为了验证 NSQ 是否正确安装,打开终端并运行 nsqlookupd;如果程序成功启动,你应该会看到类似以下输出的内容:

    nsqlookupd v0.2.27 (built w/go1.3)
    TCP: listening on [::]:4160
    HTTP: listening on [::]:4161
    
    

    我们将使用默认端口与 NSQ 交互,所以请注意输出中列出的 TCP 和 HTTP 端口,因为我们在代码中会引用它们。

  4. Ctrl + C 停止当前进程;我们稍后会正确启动它们。

我们将从 NSQ 安装中使用的关键工具是 nsqlookupdnsqdnsqlookupd 程序是一个守护进程,它管理分布式 NSQ 环境中的拓扑信息;它跟踪特定主题的所有 nsqd 生产者,并为客户端提供查询此类信息的接口。nsqd 程序是一个守护进程,为 NSQ 执行繁重的工作,例如接收、排队和向感兴趣的一方投递消息。

注意

想要了解更多关于 NSQ 的信息和背景,请访问 nsq.io/

NSQ 驱动程序 for Go

NSQ 工具本身是用 Go 编写的,因此 Bit.ly 团队已经有一个 Go 包,它使得与 NSQ 交互变得非常简单。我们将需要使用它,所以你可以在终端中使用 go get 获取它:

go get github.com/bitly/go-nsq

介绍 MongoDB

MongoDB 是一个文档数据库,它允许你存储和查询 JSON 文档及其中的数据。每个文档都会进入一个集合,可以用来将文档分组在一起,而不会对其中数据强制任何模式。与传统的关系型数据库管理系统(如 Oracle、Microsoft SQL Server 或 MySQL)中的行不同,文档具有不同的形状是完全可接受的。例如,一个 people 集合可以同时包含以下三个 JSON 文档:

{"name":"Mat","lang":"en","points":57} 
{"name":"Laurie","position":"Scrum Master"} 
{"position":"Traditional Manager","exists":false} 

这种灵活性允许具有不同结构的数据共存,而不会影响性能或浪费空间。如果你预计你的软件会随着时间的推移而演变,这将是极其有用的,因为我们确实应该始终这样做。

MongoDB 被设计成在扩展的同时,在单机安装(如我们的开发机)上也非常易于使用。当我们为生产部署应用程序时,我们很可能会安装一个更复杂的、多分片、复制的系统,该系统分布在许多节点和位置,但就目前而言,只需运行 mongod 即可。

访问 www.mongodb.org/downloads 以获取 MongoDB 的最新版本并安装它,确保像往常一样将 bin 文件夹注册到你的 PATH 环境变量中。

为了验证 MongoDB 是否成功安装,运行 mongod 命令,然后按 Ctrl + C 停止它。

MongoDB Go 驱动程序

Gustavo Niemeyer 通过他在 labix.org/mgo 上托管的 mgo(发音为 mango)包,出色地简化了与 MongoDB 的交互,该包可以通过以下命令进行 go get

go get gopkg.in/mgo.v2

启动环境

现在我们已经安装了所有需要的组件,我们需要启动我们的环境。在本节中,我们将:

  • 启动 nsqlookupd 以便我们的 nsqd 实例可被发现

  • 启动 nsqd 并告诉它使用哪个 nsqlookupd

  • 为数据服务启动 mongod

每个这些守护进程都应该在自己的终端窗口中运行,这样我们只需按 Ctrl + C 就可以轻松停止它们。

提示

记住本节的页码,因为你很可能在阅读本章时会多次回到这里。

在终端窗口中,运行以下命令:

nsqlookupd

请注意默认的 TCP 端口是 4160,然后在另一个终端窗口中运行以下命令:

nsqd --lookupd-tcp-address=localhost:4160

确保在 --lookupd-tcp-address 标志中的端口号与 nsqlookupd 实例的 TCP 端口匹配。一旦你启动 nsqd,你将注意到来自 nsqlookupdnsqd 的输出打印到终端;这表明这两个进程正在互相通信。

在另一个窗口或标签页中,通过运行以下命令来启动 MongoDB:

mongod --dbpath ./db

dbpath标志告诉 MongoDB 将数据库的数据文件存储在哪里。你可以选择任何你喜欢的位置,但你需要确保在mongod运行之前文件夹存在。

提示

通过在任何时候删除dbpath文件夹,您可以有效地擦除所有数据并重新开始。这在开发过程中特别有用。

现在我们已经启动了环境,我们准备开始构建我们的组件。

从 Twitter 读取投票

在你的$GOPATH/src文件夹中,与其他项目并列,为这一章创建一个名为socialpoll的新文件夹。这个文件夹本身不会是一个 Go 包或程序,但它将包含我们的三个组件程序。在socialpoll内部创建一个名为twittervotes的新文件夹,并添加必要的main.go模板(这很重要,因为没有main函数的main包无法编译):

package main 
func main(){} 

我们的twittervotes程序将:

  • 使用mgo从 MongoDB 数据库中加载所有投票,并从每个文档的options数组中收集所有选项

  • 打开并维护与 Twitter 流式 API 的连接,寻找任何提及的选项

  • 确定哪个选项被提及,并将该选项推送到匹配过滤器的每个推文的 NSQ

  • 如果 Twitter 的连接断开(这在 Twitter 流式 API 规范中的长时间运行连接中很常见),在短暂的延迟后(这样我们不会向 Twitter 发送过多的连接请求),重新连接并继续

  • 定期重新查询 MongoDB 以获取最新的投票,并刷新与 Twitter 的连接,以确保我们始终在寻找正确的选项

  • 当用户通过按Ctrl + C终止程序时,优雅地停止自身

使用 Twitter 进行授权

为了使用流式 API,我们需要从 Twitter 的应用管理控制台获取认证凭据,这与我们在第三章“三种实现个人头像的方法”中为 Gomniauth 服务提供商所做的方式非常相似。请访问apps.twitter.com并创建一个名为SocialPoll(名称必须是唯一的,所以你可以在这里玩得开心;名称的选择也不会影响代码)的新应用。当你的应用创建完成后,访问API 密钥标签页,找到您的访问令牌部分,在那里你需要创建一个新的访问令牌。稍作延迟后,刷新页面并注意,实际上你拥有两组密钥和密钥:一个 API 密钥和一个密钥,以及一个访问令牌和相应的密钥。遵循良好的编码实践,我们将把这些值设置为环境变量,这样我们的程序就可以访问它们,而无需在我们源文件中硬编码它们。在本章中我们将使用的密钥如下:

  • SP_TWITTER_KEY

  • SP_TWITTER_SECRET

  • SP_TWITTER_ACCESSTOKEN

  • SP_TWITTER_ACCESSSECRET

你可以随意设置环境变量,但由于应用程序依赖于它们才能工作,创建一个名为 setup.sh(用于 bash shell)或 setup.bat(在 Windows 上)的新文件是个好主意,因为你可以将这些文件存入源代码仓库。在 setup.sh 中插入以下代码,通过从 Twitter 应用页面复制适当的值:

#!/bin/bash 
export SP_TWITTER_KEY=yC2EDnaNrEhN5fd33g... 
export SP_TWITTER_SECRET=6n0rToIpskCo1ob... 
export SP_TWITTER_ACCESSTOKEN=2427-13677... 
export SP_TWITTER_ACCESSSECRET=SpnZf336u... 

在 Windows 上,代码看起来可能如下所示:

SET SP_TWITTER_KEY=yC2EDnaNrEhN5fd33g... 
SET SP_TWITTER_SECRET=6n0rToIpskCo1ob... 
SET SP_TWITTER_ACCESSTOKEN=2427-13677... 
SET SP_TWITTER_ACCESSSECRET=SpnZf336u... 

使用源代码或调用命令来设置适当的值,或者将它们添加到你的 .bashrcC:\cmdauto.cmd 文件中,以避免每次打开新终端窗口时都运行它们。

如果你不确定如何操作,只需搜索 在 Linux 上设置环境变量 或类似的内容,互联网会帮助你。

提取连接

Twitter 流式 API 支持长时间保持打开的 HTTP 连接,鉴于我们解决方案的设计,我们需要访问 net.Conn 对象以便从发生请求的 goroutine 外部关闭它。我们可以通过为我们创建的 http.Transport 对象提供一个自己的 dial 方法来实现这一点。

twittervotes 目录内创建一个名为 twitter.go 的新文件(这是所有与 Twitter 相关内容将存放的地方),并插入以下代码:

var conn net.Conn 
func dial(netw, addr string) (net.Conn, error) { 
  if conn != nil { 
    conn.Close() 
    conn = nil 
  } 
  netc, err := net.DialTimeout(netw, addr, 5*time.Second) 
  if err != nil { 
    return nil, err 
  } 
  conn = netc 
  return netc, nil 
} 

我们定制的 dial 函数首先确保 conn 已关闭,然后打开一个新的连接,同时保持 conn 变量更新为当前连接。如果连接中断(Twitter 的 API 会不时这样做)或由我们关闭,我们可以重新连接而不必担心僵尸连接。

我们将定期自己关闭连接并启动一个新的连接,因为我们希望定期从数据库重新加载选项。为此,我们需要一个函数来关闭连接,并关闭 io.ReadCloser,我们将使用它来读取响应体。将以下代码添加到 twitter.go 中:

var reader io.ReadCloser 
func closeConn() { 
  if conn != nil { 
    conn.Close() 
  } 
  if reader != nil { 
    reader.Close() 
  } 
} 

现在,我们可以在任何时候调用 closeConn 来断开与 Twitter 的当前连接并整理事物。在大多数情况下,我们的代码将再次从数据库加载选项并立即打开一个新的连接,但如果我们正在关闭程序(响应于 Ctrl + C 的按键),那么我们可以在退出之前调用 closeConn

读取环境变量

接下来,我们将编写一个函数来读取环境变量,并设置我们需要的 OAuth 对象,以便对请求进行身份验证。将以下代码添加到 twitter.go 文件中:

var ( 
  authClient *oauth.Client 
  creds *oauth.Credentials 
) 
func setupTwitterAuth() { 
  var ts struct { 
    ConsumerKey    string `env:"SP_TWITTER_KEY,required"` 
    ConsumerSecret string `env:"SP_TWITTER_SECRET,required"` 
    AccessToken    string `env:"SP_TWITTER_ACCESSTOKEN,required"` 
    AccessSecret   string `env:"SP_TWITTER_ACCESSSECRET,required"` 
  } 
  if err := envdecode.Decode(&ts); err != nil { 
    log.Fatalln(err) 
  } 
  creds = &oauth.Credentials{ 
    Token:  ts.AccessToken, 
    Secret: ts.AccessSecret, 
  } 
  authClient = &oauth.Client{ 
    Credentials: oauth.Credentials{ 
      Token:  ts.ConsumerKey, 
      Secret: ts.ConsumerSecret, 
    }, 
  } 
} 

在这里,我们定义一个struct类型来存储我们需要用 Twitter 进行认证的环境变量。由于我们不需要在其他地方使用这个类型,我们将其定义在内联,并创建一个名为ts的匿名类型变量(这就是为什么我们有var ts struct...这样的代码)。然后我们使用 Joe Shaw 的envdecode包来帮我们拉取这些环境变量。你需要运行go get github.com/joeshaw/envdecode,并且还需要导入log包。我们的程序将尝试加载所有标记为required的字段的适当值,如果失败则返回错误,这提醒人们程序没有 Twitter 凭证将无法工作。

struct中每个字段旁边的反引号内的字符串被称为标签,并且可以通过反射接口访问,这就是envdecode知道要查找哪些变量的方式。我们向这个包中添加了required参数,这表示如果任何环境变量缺失(或为空)都是错误。

一旦我们有了密钥,我们使用它们从 Gary Burd 的go-oauth包中创建oauth.Credentialsoauth.Client对象,这将允许我们使用 Twitter 授权请求。

现在我们有了控制底层连接和授权请求的能力,我们就可以编写实际构建授权请求并返回响应的代码了。在twitter.go中添加以下代码:

var ( 
  authSetupOnce sync.Once 
  httpClient    *http.Client 
) 
func makeRequest(req *http.Request, params url.Values) (*http.Response, error) { 
  authSetupOnce.Do(func() { 
    setupTwitterAuth() 
    httpClient = &http.Client{ 
      Transport: &http.Transport{ 
        Dial: dial, 
      }, 
    } 
  }) 
  formEnc := params.Encode() 
  req.Header.Set("Content-Type", "application/x-www-form- urlencoded") 
  req.Header.Set("Content-Length", strconv.Itoa(len(formEnc))) 
  req.Header.Set("Authorization",  authClient.AuthorizationHeader(creds, 
  "POST",   
  req.URL, params)) 
  return httpClient.Do(req) 
} 

我们使用sync.Once来确保初始化代码只运行一次,尽管我们可能多次调用makeRequest。在调用setupTwitterAuth方法之后,我们使用一个http.Transport函数创建一个新的http.Client函数,该函数使用我们自定义的dial方法。然后,我们设置适当的头信息,通过编码包含我们查询选项的指定params对象,以实现与 Twitter 的授权。

从 MongoDB 读取

为了加载投票,并因此搜索 Twitter 的选项,我们需要连接到并查询 MongoDB。在main.go中添加两个函数dialdbclosedb

var db *mgo.Session 
func dialdb() error { 
  var err error 
  log.Println("dialing mongodb: localhost") 
  db, err = mgo.Dial("localhost") 
  return err 
} 
func closedb() { 
  db.Close() 
  log.Println("closed database connection") 
} 

这两个函数将使用mgo包连接到并断开本地运行的 MongoDB 实例,并将mgo.Session(数据库连接对象)存储在名为db的全局变量中。

小贴士

作为附加任务,看看你能否找到一个优雅的方法来使 MongoDB 实例的位置可配置,这样你就不需要本地运行它。

假设 MongoDB 正在运行并且我们的代码能够连接,我们需要加载投票对象并从文档中提取所有选项,然后我们将使用这些选项来搜索 Twitter。将以下loadOptions函数添加到main.go中:

type poll struct { 
  Options []string 
} 
func loadOptions() ([]string, error) { 
  var options []string 
  iter := db.DB("ballots").C("polls").Find(nil).Iter() 
  var p poll 
  for iter.Next(&p) { 
    options = append(options, p.Options...) 
  } 
  iter.Close() 
  return options, iter.Err() 
} 

我们的投票文档包含的不仅仅是 Options,但我们的程序对其他任何东西都不关心,因此我们不需要膨胀我们的 poll 结构体。我们使用 db 变量从 ballots 数据库访问 polls 集合,并调用 mgo 包的流畅的 Find 方法,传递 nil(表示没有过滤)。

注意

流畅式接口(最初由 Eric Evans 和 Martin Fowler 提出)是指一种旨在通过允许你链式调用方法来使代码更易于阅读的 API 设计。这是通过每个方法返回上下文对象本身来实现的,以便可以直接调用另一个方法。例如,mgo 允许你编写如下查询:query := col.Find(q).Sort("field").Limit(10).Skip(10)

然后,我们通过调用 Iter 方法来获取一个迭代器,这允许我们逐个访问每个投票。这是一种非常节省内存的读取投票数据的方式,因为它始终只使用一个 poll 对象。如果我们使用 All 方法,我们将使用的内存量将取决于我们数据库中的投票数量,这可能是我们无法控制的。

当我们有一个投票时,我们使用 append 方法来构建 options 切片。当然,随着数据库中数百万个投票的增加,这个切片也会变得很大且难以管理。对于这种规模,我们可能会运行多个 twittervotes 程序,每个程序都专注于投票数据的一部分。一种简单的方法是将投票根据标题开头的字母分组,例如 A-N 组和 O-Z 组。一种稍微复杂的方法是在 poll 文档中添加一个字段,以更受控的方式对其进行分组,可能基于其他组的统计数据,这样我们就能在许多 twittervotes 实例之间平衡负载。

小贴士

内置的 append 函数实际上是一个 可变参数 函数,这意味着你可以传递多个元素给它进行追加。如果你有一个正确类型的切片,你可以在末尾添加 ...,这模拟了将切片的每个项目作为不同的参数传递。

最后,我们在返回选项和迭代过程中发生的任何错误(通过在 mgo.Iter 对象中调用 Err 方法)之前关闭迭代器并清理任何使用的内存。

从 Twitter 读取

现在我们能够加载选项并授权请求 Twitter API。我们准备好编写代码来初始化连接,并持续从流中读取,直到我们调用我们的closeConn方法或 Twitter 因某种原因关闭连接。流中包含的结构是复杂的,包含有关发推文的人、时间以及推文中出现的各种链接或用户提及的信息(更多详情请参考 Twitter 的 API 文档)。然而,我们只对推文文本本身感兴趣;所以,不用担心所有其他噪音,并将以下结构添加到twitter.go中:

type tweet struct { 
  Text string 
} 

小贴士

这可能感觉不完整,但想想它如何使其他可能看到我们代码的程序员对我们的意图更加清晰:推文有一些文本,这就是我们关心的全部。

使用这个新的结构,在twitter.go中添加以下readFromTwitter函数,该函数接受一个只发送通道votes;这是该函数如何通知我们程序它已经注意到 Twitter 上的投票:

func readFromTwitter(votes chan<- string) { 
  options, err := loadOptions() 
  if err != nil { 
    log.Println("failed to load options:", err) 
    return 
  } 
  u, err := url.Parse("https://stream.twitter.com/1.1/statuses
  /filter.json") 
  if err != nil { 
    log.Println("creating filter request failed:", err) 
    return 
  } 
  query := make(url.Values) 
  query.Set("track", strings.Join(options, ",")) 
  req, err := http.NewRequest("POST",u.String(),strings.NewReader
  (query.Encode())) 
  if err != nil { 
    log.Println("creating filter request failed:", err) 
    return 
  } 
  resp, err := makeRequest(req, query) 
  if err != nil { 
    log.Println("making request failed:", err) 
    return 
  } 
  reader := resp.Body 
  decoder := json.NewDecoder(reader) 
  for { 
    var t tweet 
    if err := decoder.Decode(&t); err != nil { 
      break 
    } 
    for _, option := range options { 
      if strings.Contains( 
        strings.ToLower(t.Text), 
        strings.ToLower(option), 
      ) { 
        log.Println("vote:", option) 
        votes <- option 
      } 
    } 
  } 
} 

在前面的代码中,在从所有投票数据中加载选项(通过调用loadOptions函数)之后,我们使用url.Parse创建一个描述 Twitter 上适当端点的url.URL对象。我们构建一个名为queryurl.Values对象,并将选项作为逗号分隔的列表设置。根据 API,我们使用编码后的url.Values对象作为正文,并连同查询对象本身一起传递给makeRequest。如果一切顺利,我们从请求的正文创建一个新的json.Decoder,并通过调用Decode方法在一个无限for循环中持续读取。如果出现错误(可能是由于连接被关闭),我们简单地退出循环并退出函数。如果有推文要读取,它将被解码到t变量中,这将使我们能够访问Text属性(推文本身的 140 个字符)。然后我们遍历所有可能的选项,如果推文提到了它,我们就通过votes通道发送它。这种技术还允许推文同时包含多个投票,你可能或可能不会根据选举规则决定是否更改这一点。

注意

votes通道是只发送的(这意味着我们不能从它接收),因为它属于chan<- string类型。想象一下那个告诉我们消息将如何流动的小箭头:要么进入通道(chan<-),要么从它出来(<-chan)。这是向其他程序员或我们未来的自己表达意图的绝佳方式——很明显,我们永远不会使用我们的readFromTwitter函数来读取投票;相反,我们只会将它们发送到该通道。

Decode返回错误时终止程序不是一个非常健壮的解决方案。这是因为 Twitter API 文档指出,连接有时会断开,客户端在消费服务时应考虑这一点。而且记住,我们也将定期终止连接,因此我们需要考虑一种在连接断开时重新连接的方法。

信号通道

在 Go 语言中,通道的一个重要作用是在不同 goroutine 中运行的代码之间传递事件。当我们编写下一个函数时,我们将看到一个真实世界的例子。

函数的目的是启动一个 goroutine,该 goroutine 会持续调用readFromTwitter函数(使用指定的votes通道接收投票),直到我们发出停止信号。一旦停止,我们希望通过另一个信号通道得到通知。函数的返回值将是一个struct{}类型的通道:一个信号通道。

信号通道有一些有趣的特性值得仔细研究。首先,通道中发送的类型是一个空的struct{},其实例实际上占用零字节,因为它没有字段。所以,struct{}{}是一个很好的内存高效选项,用于信号事件。有些人使用bool类型,这也是可以的,尽管truefalse都占用一个字节的内存。

注意

前往play.golang.org亲自尝试一下。

bool类型的大小为 1:

 `fmt.Println(reflect.TypeOf(true).Size()) = 1` 

另一方面,struct{}{}的大小为零:

fmt.Println(reflect.TypeOf(struct{}{}).Size()) = 0

信号通道也有一个缓冲区大小为 1,这意味着执行不会因为读取通道中的信号而被阻塞。

我们将在代码中使用两个信号通道:一个是我们传递给函数的,告诉我们的 goroutine 应该停止;另一个(由函数提供)在停止完成后发出信号。

twitter.go中添加以下函数:

func startTwitterStream(stopchan <-chan struct{}, votes chan<- string) <-chan struct{} { 
  stoppedchan := make(chan struct{}, 1) 
  go func() { 
    defer func() { 
      stoppedchan <- struct{}{} 
    }() 
    for { 
      select { 
      case <-stopchan: 
        log.Println("stopping Twitter...") 
        return 
      default: 
        log.Println("Querying Twitter...") 
        readFromTwitter(votes) 
        log.Println("  (waiting)") 
        time.Sleep(10 * time.Second) // wait before
         reconnecting 
      } 
    } 
  }() 
  return stoppedchan 
} 

在前面的代码中,第一个参数stopchan是一个类型为<-> struct{}的通道,一个只读信号通道。这个通道在外部代码中会发出信号,这将告诉我们的 goroutine 停止。记住,在这个函数内部它是只读的;实际的通道本身将能够发送。第二个参数是votes通道,投票将通过这个通道发送。我们函数的返回类型也是一个<-> struct{}类型的信号通道:一个只读通道,我们将用它来表示我们已经停止。

这些通道是必要的,因为我们的函数会触发自己的 goroutine 并立即返回;如果没有这些,调用代码将无法知道生成的代码是否仍在运行。

startTwitterStream函数中,我们首先创建我们的stoppedchan参数,并在函数退出时延迟发送struct{}{}以表示我们已经完成。请注意,stoppedchan是一个普通通道,因此尽管它被返回为只接收,我们仍然可以在函数内部发送它。

我们然后启动一个无限for循环,在其中从两个通道中选择一个。第一个是stopchan(第一个参数),这表明是时候停止并返回(从而触发stoppedchan上的延迟信号)。如果没有发生这种情况,我们将调用readFromTwitter(传递votes通道),这将去加载数据库中的选项并打开到 Twitter 的连接。

当 Twitter 连接断开时,我们的代码将返回,我们使用time.Sleep函数暂停 10 秒钟。这是为了让 Twitter API 休息,以防它因为过度使用而关闭了连接。一旦我们休息完毕,我们重新进入循环,再次检查stopchan,看看调用代码是否想要我们停止。

为了使这个流程更清晰,我们正在记录关键语句,这不仅有助于我们调试代码,还可以让我们窥视这个有些复杂的机制的内部工作。

注意

信号通道是解决所有代码都位于单个包内部简单情况的一个很好的解决方案。如果你需要跨越 API 边界,从 Go 1.7 开始推广到标准库的上下文包是处理截止日期、取消和停止的推荐方式。

发布到 NSQ

一旦我们的代码成功注意到 Twitter 上的投票并将它们发送到votes通道,我们需要一种方法将它们发布到 NSQ 主题;毕竟,这是twittervotes程序的目的。

我们将编写一个名为publishVotes的函数,它将接受votes通道,这次类型为<-chan string(一个只接收通道),并发布从它接收到的每个字符串。

注意

在我们之前的函数中,votes通道的类型是chan<- string,但这次它的类型是<-chan string。你可能认为这是一个错误,甚至认为这意味着我们不能为两者使用相同的通道,但你错了。我们稍后创建的通道将使用make(chan string)创建,既不接收也不只发送,可以在这两种情况下使用。在参数中使用<-操作符的原因是为了使通道将要被用于什么目的更加清晰,或者在它是返回类型的情况下,防止用户意外地在用于接收的通道上发送,或者反之亦然。如果他们错误地使用这样的通道,编译器实际上会生成一个错误。

一旦votes通道关闭(这是外部代码告诉我们的函数停止工作的方式),我们将停止发布,并通过返回的停止信号通道发送一个信号。

publishVotes函数添加到main.go中:

func publishVotes(votes <-chan string) <-chan struct{} { 
  stopchan := make(chan struct{}, 1) 
  pub, _ := nsq.NewProducer("localhost:4150",
   nsq.NewConfig()) 
  go func() { 
    for vote := range votes { 
      pub.Publish("votes", []byte(vote)) // publish vote 
    } 
    log.Println("Publisher: Stopping") 
    pub.Stop() 
    log.Println("Publisher: Stopped") 
    stopchan <- struct{}{} 
  }() 
  return stopchan 
} 

再次强调,我们首先做的事情是创建 stopchan,我们稍后返回,这次不是延迟信号,而是通过向 stopchan 发送 struct{}{} 来直接执行。

注意

我们处理 stopchan 的不同之处在于展示替代选项。在一个代码库中,你应该选择你喜欢的一种风格并坚持下去,直到社区中出现标准;在这种情况下,我们都应该采用那个标准。关闭 stopchan 而不是向其发送任何内容也是可能的,这将也会解除等待该通道的代码的阻塞。但一旦通道被关闭,就不能重新打开。

然后,我们通过调用 NewProducer 并使用默认配置连接到 localhost 上的默认 NSQ 端口来创建一个 NSQ 生产者。我们启动一个 goroutine,它使用 Go 语言的一个出色的内置功能,允许我们通过在通道上执行正常的 for...range 操作来持续地从通道中拉取值(在我们的例子中,是 votes 通道)。每当通道没有值时,执行将被阻塞,直到有值到来。如果 votes 通道被关闭,for 循环将退出。

小贴士

要了解更多关于 Go 中通道的力量,强烈建议你寻找约翰·格雷厄姆-卡明(John Graham-Cumming)的博客文章和视频,特别是他在 2014 年 Gophercon 上展示的题为 A Channel Compendium 的文章,其中包含通道的简要历史,包括其起源(有趣的是,约翰也是那位成功请愿英国政府正式道歉,对已故的伟大艾伦·图灵(Alan Turing)进行处理的先生)。

当循环退出(votes 通道被关闭后),发布者被停止,随后发送 stopchan 信号。在 publishVotes 函数中有什么异常之处吗?我们违反了 Go 的一个基本规则,即忽略了一个错误(将其分配给下划线变量;因此忽略它)。作为额外的练习,捕获错误并以适当的方式处理它。

优雅地启动和停止程序

当我们的程序终止时,我们想在真正退出之前做几件事情,即关闭我们与 Twitter 的连接并停止 NSQ 发布者(它实际上注销了对队列的兴趣)。为了实现这一点,我们必须覆盖默认的 Ctrl + C 行为。

小贴士

接下来的代码块都放在 main 函数中;它们被拆分,这样我们可以在继续之前讨论每个部分。

main 函数内部添加以下代码:

var stoplock sync.Mutex // protects stop 
stop := false 
stopChan := make(chan struct{}, 1) 
signalChan := make(chan os.Signal, 1) 
go func() {  
  <-signalChan 
  stoplock.Lock() 
  stop = true 
  stoplock.Unlock() 
  log.Println("Stopping...") 
  stopChan <- struct{}{} 
  closeConn() 
}() 
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) 

在这里,我们创建一个带有相关sync.Mutex函数的stop布尔值,这样我们就可以同时从多个 goroutine 中访问它。然后我们创建另外两个信号通道,stopChansignalChan,并使用signal.Notify请求 Go 在有人尝试停止程序时(无论是使用SIGINT中断还是SIGTERM终止 POSIX 信号)将信号发送到signalChanstopChan函数是我们表示我们希望我们的进程终止的方式,我们将其作为参数传递给后续的startTwitterStream

我们随后运行一个 goroutine,该 goroutine 通过尝试从signalChan读取信号来阻塞等待,这就是在这个情况下<-操作符的作用(它正在尝试从通道中读取)。由于我们不关心信号的类型,所以我们没有麻烦地捕获通道返回的对象。一旦收到信号,我们将stop设置为true并关闭连接。只有当指定的信号之一被发送时,goroutine 的其余代码才会运行,这就是我们能够在退出程序之前执行拆卸代码的原因。

在主函数中添加以下代码片段以打开并延迟关闭数据库连接:

if err := dialdb(); err != nil { 
  log.Fatalln("failed to dial MongoDB:", err) 
} 
defer closedb() 

由于readFromTwitter方法每次都会从数据库重新加载选项,而且我们希望在不重新启动程序的情况下保持程序更新,因此我们将引入最后一个 goroutine。这个 goroutine 将简单地每分钟调用一次closeConn,导致连接死亡并再次调用readFromTwitter。在main函数的底部插入以下代码以启动所有这些进程,然后等待它们优雅地停止:

// start things 
votes := make(chan string) // chan for votes 
publisherStoppedChan := publishVotes(votes) 
twitterStoppedChan := startTwitterStream(stopChan, votes) 
go func() { 
  for { 
    time.Sleep(1 * time.Minute) 
    closeConn() 
    stoplock.Lock() 
    if stop { 
      stoplock.Unlock() 
      return 
    } 
    stoplock.Unlock() 
  } 
}() 
<-twitterStoppedChan 
close(votes) 
<-publisherStoppedChan 

首先,我们创建本节中一直在讨论的votes通道,这是一个简单的字符串通道。请注意,它既不是发送(chan<-)通道,也不是接收(<-chan)通道;实际上,创建这样的通道几乎没有意义。然后我们调用publishVotes,传入votes通道以便从中接收,并捕获返回的停止信号通道作为publisherStoppedChan。同样,我们调用startTwitterStream,传入主函数开头的stopChan函数以及要发送到该通道的votes通道,并捕获结果停止信号通道作为twitterStoppedChan

然后,我们启动我们的刷新 goroutine,该 goroutine 在休眠一分钟并通过调用closeConn关闭连接之前立即进入一个无限for循环。如果stop布尔值已经被设置为true(在之前的 goroutine 中),我们将退出循环;否则,我们将循环并等待另一分钟,然后再关闭连接。stoplock的使用很重要,因为我们有两个 goroutine 可能会同时尝试访问 stop 变量,但我们想避免冲突。

一旦 goroutine 开始运行,我们就通过尝试从中读取来阻塞 twitterStoppedChan。当成功时(这意味着在 stopChan 上发送了信号),我们将关闭 votes 通道,这将导致发布者的 for...range 循环退出,并停止发布者本身,之后将在 publisherStoppedChan 上发送信号,我们在退出之前等待这个信号。

测试

为了确保我们的程序能正常工作,我们需要做两件事:首先,我们需要在数据库中创建一个投票,其次,我们需要查看消息队列内部,以确认消息确实是由 twittervotes 生成的。

在终端中运行 mongo 命令以打开一个数据库外壳,允许我们与 MongoDB 交互。然后,输入以下命令以添加一个测试投票:

> use ballots
switched to db ballots
> db.polls.insert({"title":"Test poll","options":
     ["happy","sad","fail","win"]})

上述命令向 ballots 数据库中的 polls 集合添加了一个新条目。我们使用了一些常见的词汇作为选项,这些词汇可能会被推特上的人提及,这样我们就可以观察真实的推文被翻译成消息。你可能注意到我们的投票对象缺少 results 字段;这是可以的,因为我们处理的是非结构化数据,文档不需要遵循严格的模式。我们将在下一节中编写的 counter 程序将为我们稍后添加并维护 results 数据。

按 *Ctrl *+ C 退出 MongoDB 外壳,并输入以下命令:

nsq_tail --topic="votes" --lookupd-http-
     address=localhost:4161

nsq_tail 工具连接到指定的消息队列主题,并输出它注意到的任何消息。这是我们验证 twittervotes 程序是否发送消息的地方。

在一个独立的终端窗口中,让我们构建并运行 twittervotes 程序:

go build -o twittervotes
./twittervotes

现在切换回运行 nsq_tail 的窗口,并注意确实有消息在响应实时推特活动时生成。

小贴士

如果你看不到太多活动,尝试查找推特上的热门话题标签,并添加另一个包含这些选项的投票。

计数投票

我们将要实现的第二个程序是 counter 工具,它将负责监视 NSQ 中的投票,计数它们,并确保 MongoDB 与最新的数字保持更新。

twittervotes 旁边创建一个名为 counter 的新文件夹,并将以下代码添加到一个新的 main.go 文件中:

package main 
import ( 
  "flag" 
  "fmt" 
  "os" 
) 
var fatalErr error 
func fatal(e error) { 
  fmt.Println(e) 
  flag.PrintDefaults() 
  fatalErr = e 
} 
func main() { 
  defer func() { 
    if fatalErr != nil { 
      os.Exit(1) 
    } 
  }() 
} 

fatal function to record that an error has occurred. Note that only when our main function exits will the deferred function run, which in turn calls os.Exit(1) to exit the program with an exit code of 1. Because the deferred statements are run in LIFO (last in, first out) order, the first function we defer will be the last function to be executed, which is why the first thing we do in the main function is defer the exiting code. This allows us to be sure that other functions we defer will be called *before* the program exits. We'll use this feature to ensure that our database connection gets closed regardless of any errors.

连接到数据库

考虑清理资源,如数据库连接的最佳时间是在成功获取资源后立即进行;Go 的 defer 关键字使这变得简单。在主函数的底部添加以下代码:

log.Println("Connecting to database...") 
db, err := mgo.Dial("localhost") 
if err != nil { 
  fatal(err) 
  return 
} 
defer func() { 
  log.Println("Closing database connection...") 
  db.Close() 
}() 
pollData := db.DB("ballots").C("polls") 

mgo fluent API to keep a reference of the ballots.polls data collection in the pollData variable, which we will use later to make queries.

在 NSQ 中消费消息

为了计数投票,我们需要消费 NSQ 中 votes 主题的消息,并且我们需要一个地方来存储它们。将以下变量添加到 main 函数中:

var counts map[string]int 
var countsLock sync.Mutex 

在 Go 中,一个 map 和一个锁 (sync.Mutex) 是一个常见的组合,因为我们会有多个 goroutine 尝试访问同一个 map,我们需要避免在同时修改或读取时破坏它。

将以下代码添加到main函数中:

log.Println("Connecting to nsq...") 
q, err := nsq.NewConsumer("votes", "counter", nsq.NewConfig()) 
if err != nil { 
  fatal(err) 
  return 
} 

NewConsumer函数允许我们设置一个对象,该对象将监听votes NSQ 主题,因此当twittervotes在该主题上发布投票时,我们可以在本程序中处理它。如果NewConsumer返回错误,我们将使用我们的fatal函数记录它并返回。

接下来,我们将添加处理来自 NSQ 的消息(投票)的代码:

q.AddHandler(nsq.HandlerFunc(func(m *nsq.Message) error { 
  countsLock.Lock() 
  defer countsLock.Unlock() 
  if counts == nil { 
    counts = make(map[string]int) 
  } 
  vote := string(m.Body) 
  counts[vote]++ 
  return nil 
})) 

我们在nsq.Consumer上调用AddHandler方法,并传递一个函数,该函数将在接收到votes主题上的每条消息时被调用。

当收到投票时,我们首先做的事情是锁定countsLock互斥锁。接下来,我们推迟互斥锁的解锁,直到函数退出。这确保了在NewConsumer运行期间,我们是唯一允许修改映射的人;其他人必须等待我们的函数退出后才能使用它。在互斥锁存在的情况下,对Lock方法的调用会阻塞执行,只有当通过调用Unlock释放锁时才会继续。这就是为什么每个Lock调用都必须有一个Unlock对应调用是至关重要的;否则,我们的程序将陷入死锁。

每次我们收到投票时,我们检查counts是否为nil,如果是,就创建一个新的映射,因为一旦数据库已经更新了最新的结果,我们希望重置一切并从零开始。最后,我们将给定键的int值增加一,并返回nil,表示没有错误。

尽管我们已经创建了我们的 NSQ 消费者并添加了我们的处理函数,但我们仍然需要连接到 NSQ 服务,我们将通过添加以下代码来完成:

if err := q.ConnectToNSQLookupd("localhost:4161");
 err !=nil { 
  fatal(err) 
  return 
} 

注意

重要的是要注意,我们实际上是在连接到nsqlookupd实例的 HTTP 端口,而不是 NSQ 实例;这种抽象意味着我们的程序不需要知道消息是从哪里来的,以便消费它们。如果我们无法连接到服务器(例如,如果我们忘记启动它),我们会得到一个错误,我们会在立即返回之前将错误报告给我们的致命函数。

保持数据库更新

我们代码将监听投票并保持结果映射在内存中,但到目前为止,这些信息被限制在我们的程序内部。接下来,我们需要添加将结果定期推送到数据库的代码。添加以下doCount函数:

func doCount(countsLock *sync.Mutex, counts *map[string]int, pollData *mgo.Collection) { 
  countsLock.Lock() 
  defer countsLock.Unlock() 
  if len(*counts) == 0 { 
    log.Println("No new votes, skipping database update") 
    return 
  } 
  log.Println("Updating database...") 
  log.Println(*counts) 
  ok := true 
  for option, count := range *counts { 
    sel := bson.M{"options": bson.M{"$in":
     []string{option}}} 
    up := bson.M{"$inc": bson.M{"results." +
     option:count}} 
    if _, err := pollData.UpdateAll(sel, up); err != nil { 
      log.Println("failed to update:", err) 
      ok = false 
    } 
  } 
  if ok { 
    log.Println("Finished updating database...") 
    *counts = nil // reset counts 
  } 
}  

当我们的doCount函数运行时,我们首先做的事情是锁定countsLock并推迟其解锁。然后我们检查counts映射中是否有任何值。如果没有,我们只是记录我们正在跳过更新,并等待下一次。

我们将所有参数作为指针接收(注意类型名前的 * 字符),因为我们想确保我们正在与底层数据本身交互,而不是它的副本。例如,*counts = nil 这行代码实际上会将底层映射重置为 nil,而不是仅仅使我们的本地副本无效。如果有一些投票,我们将遍历 counts 映射,提取选项和自上次更新以来的投票数,并使用一些 MongoDB 魔法来更新结果。

注意

MongoDB 内部存储 BSON(即 Binary JSON)文档,它们比正常的 JSON 文档更容易遍历,这就是为什么 mgo 包附带 mgo/bson 编码包。当使用 mgo 时,我们经常会使用 bson 类型,如 bson.M 映射,来描述 MongoDB 的概念。

我们首先使用 bson.M 简写类型创建更新操作的选择器,它类似于创建 map[string]interface{} 类型。我们创建的选择器看起来像这样:

{ 
  "options": { 
    "$in": ["happy"] 
  } 
} 

在 MongoDB 中,前面的 BSON 指定我们想要选择 "happy"options 数组中项之一的投票。

接下来,我们使用相同的技巧生成更新操作,看起来像这样:

{ 
  "$inc": { 
    "results.happy": 3 
  } 
} 

在 MongoDB 中,前面的 BSON 指定我们想要增加 results.happy 字段三个值。如果投票中没有 results 映射,将会创建一个,如果没有 happy 键在 results 中,则默认为零。

然后,我们在 pollsData 查询中调用 UpdateAll 方法向数据库发出命令,这将反过来更新所有匹配选择器的投票(与只更新一个的 Update 方法形成对比)。如果出现问题,我们将报告它并将 ok 布尔值设置为 false。如果一切顺利,我们将 counts 映射设置为 nil,因为我们想重置计数器。

我们将 updateDuration 作为常量指定在文件顶部,这将使我们在测试程序时更容易更改。在 main 函数上方添加以下代码:

const updateDuration = 1 * time.Second 

接下来,我们将添加 time.Ticker 并确保我们的 doCount 函数在响应 Ctrl + C 时使用的同一个 select 块中被调用。

响应 Ctrl + C

在我们的程序准备就绪之前要做的最后一件事是设置一个 select 块,该块定期调用 doCount,并确保我们的 main 函数在退出之前等待操作完成,就像我们在 twittervotes 程序中所做的那样。在 main 函数的末尾添加以下代码:

ticker := time.NewTicker(updateDuration)
 termChan := make(chan os.Signal, 1) 
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM,syscall.SIGHUP) 
for { 
  select { 
  case <-ticker.C:
   doCount(&countsLock, &counts,pollData)  case <- termChan:ticker.Stop() 
    q.Stop() 
  case <-q.StopChan: 
    // finished 
    return 
  } 
} 

time.Ticker 函数是一种类型,它为我们提供了一个通道(通过 C 字段),在指定的间隔(在我们的例子中,是 updateDuration)发送当前时间。我们使用这个通道在 select 块中调用 doCount 函数,同时 termChanq.StopChan 处于安静状态。

为了处理终止,我们采用了与之前略有不同的策略。我们捕获终止事件,当我们按下Ctrl + C时,这将导致一个信号传到termChan。接下来,我们启动一个无限循环,在其中我们使用select结构来允许我们在接收到termChan或消费者StopChan上的任何东西时运行代码。

实际上,我们只有在按下Ctrl + C后才会首先收到termChan信号,此时我们停止time.Ticker并要求消费者停止监听投票。然后执行重新进入循环并阻塞,直到消费者通过在其StopChan函数上发出信号来报告它确实已经停止。当这种情况发生时,我们就完成了,然后退出,此时我们的延迟语句运行,如果你还记得,它会清理数据库会话。

运行我们的解决方案

是时候看到我们的代码在行动了。确保你有nsqlookupdnsqdmongod在单独的终端窗口中运行,如下所示:

nsqlookupd
nsqd --lookupd-tcp-address=127.0.0.1:4160
mongod --dbpath ./db

如果你还没有这样做,请确保twittervotes程序正在运行。然后,在counter文件夹中,构建并运行我们的计数程序:

go build -o counter
./counter

你应该会看到周期性的输出,描述counter正在做什么,例如以下内容:

No new votes, skipping database update
Updating database...
map[win:2 happy:2 fail:1]
Finished updating database...
No new votes, skipping database update
Updating database...
map[win:3]
Finished updating database...

小贴士

由于我们实际上是在响应 Twitter 上的真实活动,所以输出当然会变化。

我们可以看到我们的程序正在从 NSQ 接收投票数据并向数据库报告更新结果。我们可以通过打开 MongoDB shell 并查询投票数据来确认这一点,以查看results映射是否正在更新。在另一个终端窗口中,打开 MongoDB shell:

mongo

要求它使用选票数据库:

> use ballots
switched to db ballots

使用不带参数的find方法来获取所有投票(在末尾添加pretty方法以获取格式良好的 JSON):

> db.polls.find().pretty()
{
 "_id" : ObjectId("53e2a3afffbff195c2e09a02"),
 "options" : [
 "happy","sad","fail","win"
 ],
 "results" : {
 "fail" : 159, "win" : 711,
 "happy" : 233, "sad" : 166,
 },
 title" : "Test poll"
}

results映射确实被更新了,在任何时刻,它都包含每个选项的总票数。

摘要

在本章中,我们覆盖了很多内容。我们学习了使用信号通道优雅地关闭程序的不同技术,这在我们的代码在退出前需要做一些工作的情况下尤为重要。我们看到了在程序开始时推迟报告致命错误可以给其他延迟函数一个在进程结束前执行的机会。

我们还发现了使用mgo包与 MongoDB 交互是多么容易,以及如何在描述数据库概念时使用 BSON 类型。bson.M作为map[string]interface{}的替代方案,帮助我们使代码更加简洁,同时在我们处理非结构化或无模式数据时仍然提供所需的所有灵活性。

我们了解了消息队列以及它们如何允许我们将系统的组件分解成隔离和专业的微服务。我们首先运行了nsqlookupd查找守护进程,然后运行单个nsqd实例并将它们通过 TCP 接口连接起来。然后我们能够在twittervotes中发布投票并连接到查找守护进程,为我们的counter程序中发送的每个投票运行一个处理函数。

虽然我们的解决方案实际上执行的是一个相当简单的任务,但我们在本章中构建的架构能够完成一些相当了不起的事情。

我们消除了twittervotes和计数程序需要在同一台机器上运行的必要性——只要它们都能连接到适当的 NSQ,无论它们在哪里运行,它们都将按预期工作。

我们可以将 MongoDB 和 NSQ 节点分布到许多物理机器上,这意味着我们的系统具有巨大的可扩展性——当资源开始不足时,我们可以添加新的盒子来应对需求。

当我们添加其他需要查询和读取投票结果的应用程序时,我们可以确信我们的数据库服务是高度可用且能够交付的。

我们可以将数据库扩展到地理范围,复制数据进行备份,这样在灾难发生时我们不会丢失任何东西。

我们可以构建一个多节点、容错性的 NSQ 环境,这意味着当我们的twittervotes程序发现有趣的推文时,总会有一个地方可以发送数据。

我们可以编写更多从不同来源生成投票的程序;唯一的要求是它们知道如何将消息放入 NSQ。

在下一章中,我们将构建我们自己的 RESTful 数据服务,通过这个服务我们将公开我们的社交投票应用程序的功能。我们还将构建一个网络界面,让用户可以创建他们自己的投票并可视化结果。

第六章。通过 RESTful 数据 Web 服务 API 公开数据和功能

在上一章中,我们构建了一个从 Twitter 读取推文、统计标签投票并将结果存储在 MongoDB 数据库中的服务。我们还使用了 MongoDB shell 来添加投票并查看投票结果。如果我们是唯一使用我们解决方案的人,这种方法是可以的,但如果我们发布了我们的项目并期望用户直接连接到我们的 MongoDB 实例以使用我们构建的服务,那就疯了。

因此,在本章中,我们将构建一个 RESTful 数据服务,通过该服务将公开数据和功能。我们还将构建一个简单的网站来消费新的 API。用户可以使用我们的网站创建和监控投票,或者在他们自己的应用上构建基于我们发布的 Web 服务。

小提示

本章中的代码依赖于第五章中的代码,构建分布式系统和灵活数据处理,因此建议您首先完成该章节,特别是因为它涵盖了本章代码运行的环境设置。

具体来说,您将学习:

  • 如何通过包装http.HandlerFunc类型为我们提供 HTTP 请求的简单但强大的执行管道

  • 如何使用context包安全地在 HTTP 处理器之间共享数据

  • 负责公开数据的处理器的编写最佳实践

  • 在现在允许我们编写最简单的实现的同时,留出空间在以后改进它们而不改变接口的小抽象

  • 通过向我们的项目中添加简单的辅助函数和类型,将防止我们(或至少推迟)对外部包的依赖

RESTful API 设计

要使 API 被认为是 RESTful 的,它必须遵守一些原则,这些原则与 Web 背后的原始概念保持一致,并且大多数开发者都已经知道。这种做法可以确保我们不会在我们的 API 中构建任何奇怪或不寻常的东西,同时也可以让我们的用户在消费它时有一个先发优势,因为他们已经熟悉其概念。

一些重要的 RESTful 设计概念包括:

  • HTTP 方法描述了要采取的操作类型;例如,GET方法将始终读取数据,而POST请求将创建某些内容

  • 数据被表达为资源集合

  • 动作被表达为数据的变化

  • URL 用于引用特定数据

  • HTTP 头用于描述进入和离开服务器的表示类型

下表显示了我们将支持的 HTTP 方法和 URL,包括简短描述和预期如何使用调用的示例用例。

请求 描述 用例
GET /polls 读取所有投票 向用户展示投票列表
GET /polls/{id} 读取投票 显示特定投票的详细信息或结果
POST /polls 创建投票 创建一个新的投票
DELETE /polls/{id} 删除投票 删除特定的投票

{id} 占位符表示在路径中唯一 ID 的位置将放在哪里。

处理程序之间的数据共享

有时,我们需要在中间件和处理程序之间共享一个状态。Go 1.7 将 context 包引入了标准库,它为我们提供了共享基本请求范围数据的方式。

每个 http.Request 方法都附带一个可通过 request.Context() 方法访问的 context.Context 对象,我们可以从中创建新的上下文对象。然后我们可以调用 request.WithContext() 来获取一个(便宜)浅拷贝的 http.Request 方法,它使用我们的新 Context 对象。

要添加一个值,我们可以通过 context.WithValue 方法创建一个新的上下文(基于请求中现有的上下文):

ctx := context.WithValue(r.Context(), "key", "value") 

小贴士

虽然技术上可以使用这种方法存储任何类型的数据,但建议只存储简单的原始类型,如字符串和整数,并且不要用它来注入处理程序可能需要的依赖项或指向其他对象的指针。在本章的后面部分,我们将探讨访问依赖项的模式,例如数据库连接。

在中间件代码中,当我们传递执行到包装处理程序时,我们可以使用我们的新 ctx 对象:

Handler.ServeHTTP(w, r.WithContext(ctx)) 

值得探索上下文包的文档golang.org/pkg/context/,以了解它提供了哪些其他功能。

我们将使用这项技术来允许我们的处理程序访问一个在其他地方提取和验证的 API 密钥。

上下文键

在上下文对象中设置值需要我们使用一个键,虽然值参数是 interface{} 类型可能看起来很明显,这意味着我们可以(但不一定应该)存储我们喜欢的任何东西,但了解键的类型可能会让你感到惊讶:

func WithValue(parent Context, key, val interface{}) Context 

键也是一个 interface{}。这意味着我们不仅限于使用字符串作为键,这对于考虑不同代码可能试图在相同上下文中使用相同名称设置值是有好处的,这可能会引起问题。

相反,一个更稳定的键值模式正在 Go 社区中浮现(并且已经在标准库的一些地方使用)。我们将创建一个简单的(私有)struct来存储我们的键,并添加一个辅助方法来从上下文中获取值。

在新的 api 文件夹内添加必要的最小 main.go 文件:

package main 
func main(){} 

添加一个名为 contextKey 的新类型:

type contextKey struct { 
  name string 
} 

这个结构只包含键的名称,但即使两个键的 name 字段相同,指向它的指针也将保持唯一。接下来,我们将添加一个键来存储我们的 API 密钥值:

var contextKeyAPIKey = &contextKey{"api-key"} 

将相关变量组合在一起并使用共同的前缀是一种良好的实践;在我们的情况下,我们可以从contextKey前缀开始命名所有上下文键类型。在这里,我们创建了一个名为contextKeyAPIKey的键,它是一个指向contextKey类型的指针,名称设置为api-key

接下来,我们将编写一个辅助函数,它将根据上下文提取密钥:

func APIKey(ctx context.Context) (string, bool) {
 key, ok := ctx.Value(contextKeyAPIKey).(string)
 return key, ok
}

函数接收context.Context并返回 API 密钥字符串以及一个ok布尔值,表示密钥是否成功获取并转换为字符串。如果密钥缺失或类型错误,第二个返回参数将为 false,但我们的代码不会崩溃。

注意,contextKeycontextKeyAPIKey是内部的(它们以小写字母开头),但APIKey将被导出。在main包中,这并不重要,但如果你正在编写一个包,了解你存储和从上下文中提取数据的方式的复杂性对用户来说是件好事。

包装处理函数

我们将利用在 Go 中构建服务和网站时学习到的最有价值的模式之一,我们已经在第二章中稍微探索过,即添加用户账户:包装处理函数。我们已经看到如何包装http.Handler类型,在主处理函数执行前后运行代码,现在我们将应用相同的技巧到http.HandlerFunc函数替代品上。

API 密钥

大多数 Web API 要求客户端为其应用程序注册一个 API 密钥,并要求他们在每次请求时发送该密钥。这些密钥有许多用途,从简单地识别请求来自哪个应用程序到解决某些应用程序只能根据用户允许的内容执行有限操作的情况下的授权问题。虽然我们实际上不需要为我们的应用程序实现 API 密钥,但我们要求客户端提供一个,这将允许我们稍后添加实现,同时保持接口不变。

我们将在main.go的底部添加第一个HandlerFunc包装函数,命名为withAPIKey

func withAPIKey(fn http.HandlerFunc) http.HandlerFunc { 
  return func(w http.ResponseWriter, r *http.Request) { 
    key := r.URL.Query().Get("key") 
    if !isValidAPIKey(key) { 
      respondErr(w, r, http.StatusUnauthorized, "invalid
       API key") 
      return 
    } 
    ctx := context.WithValue(r.Context(),
     contextKeyAPIKey, key) 
    fn(w, r.WithContext(ctx)) 
  } 
} 

如您所见,我们的withAPIKey函数既接受一个http.HandlerFunc类型作为参数,也返回一个;这就是我们在这个上下文中所说的包装。withAPIKey函数依赖于我们尚未编写的许多其他函数,但您可以清楚地看到正在发生的事情。我们的函数立即返回一个新的http.HandlerFunc类型,通过调用isValidAPIKey来检查key查询参数。如果密钥被认为无效(通过返回false),我们则响应一个invalid API key错误;否则,我们将密钥放入上下文并调用下一个处理器。要使用此包装器,我们只需将一个http.HandlerFunc类型传递给此函数,以便启用key参数检查。由于它也返回一个http.HandlerFunc类型,因此结果可以传递给其他包装器或直接传递给http.HandleFunc函数,以便将其注册为特定路径模式的处理器。

让我们接下来添加我们的isValidAPIKey函数:

func isValidAPIKey(key string) bool { 
  return key == "abc123" 
} 

目前,我们只是将 API 密钥硬编码为abc123;任何其他内容都将返回false,因此被视为无效。稍后,我们可以修改这个函数,以便通过咨询配置文件或数据库来检查密钥的真实性,而不会影响我们使用isValidAPIKey方法或withAPIKey包装器的方式。

跨源资源共享

同源安全策略规定,在 Web 浏览器中,AJAX 请求仅允许来自同一域的服务,这将使我们的 API 相当有限,因为我们不一定会在所有使用我们的 Web 服务的网站上托管。CORS跨源资源共享)技术绕过了同源策略,使我们能够构建一个能够为托管在其他域上的网站提供服务的服务。为此,我们只需在响应中设置Access-Control-Allow-Origin头为*。既然我们将在创建投票调用中使用Location头,我们也将允许客户端访问此头,这可以通过在Access-Control-Expose-Headers头中列出它来实现。将以下代码添加到main.go

func withCORS(fn http.HandlerFunc) http.HandlerFunc { 
  return func(w http.ResponseWriter, r *http.Request) { 
    w.Header().Set("Access-Control-Allow-Origin", "*") 
    w.Header().Set("Access-Control-Expose-Headers",
     "Location") 
    fn(w, r) 
  } 
} 

这是迄今为止最简单的包装函数;它只是在ResponseWriter类型上设置适当的头,并调用指定的http.HandlerFunc类型。

小贴士

在本章中,我们明确处理 CORS(跨源资源共享),以便我们能够确切地了解正在发生的情况;对于真正的生产代码,你应该考虑使用开源解决方案,例如github.com/fasterness/cors

依赖注入

现在我们可以确信请求有一个有效的 API 密钥并且是 CORS 兼容的,我们必须考虑处理器将如何连接到数据库。一个选项是让每个处理器自己拨号连接,但这不是非常DRY(不要重复自己),并且留下了可能产生错误代码的空间,例如忘记在完成数据库会话后关闭数据库会话的代码。这也意味着,如果我们想改变我们连接数据库的方式(也许我们想使用域名而不是硬编码的 IP 地址),我们可能需要在许多地方修改我们的代码,而不是一个地方。

相反,我们将创建一个新类型,它封装了处理器的所有依赖,并在 main.go 中使用数据库连接来构建它。

创建一个名为 Server 的新类型:

// Server is the API server. 
type Server struct { 
  db *mgo.Session 
} 

我们的处理函数将是这个服务的方法,这样它们就能访问数据库会话。

响应

任何 API 的一个重要部分是使用状态码、数据、错误以及有时是头部来响应请求——net/http 包使得所有这些都非常容易实现。我们有一个选项,对于小型项目或者大型项目的早期阶段来说,仍然是最佳选项,那就是直接在处理器内部构建响应代码。

然而,随着处理器的数量增加,我们最终会重复大量的代码,并在我们的项目中到处散布表示决策。一个更可扩展的方法是将响应代码抽象成辅助函数。

对于我们 API 的第一个版本,我们将只使用 JSON,但我们希望有灵活性,以便在需要时添加其他表示。

创建一个名为 respond.go 的新文件,并添加以下代码:

func decodeBody(r *http.Request, v interface{}) error { 
  defer r.Body.Close() 
  return json.NewDecoder(r.Body).Decode(v) 
} 
func encodeBody(w http.ResponseWriter, r *http.Request, v  interface{}) error { 
  return json.NewEncoder(w).Encode(v) 
} 

这两个函数分别抽象了从 RequestResponseWriter 对象中解码和编码数据。解码器还会关闭请求体,这是推荐的。尽管我们在这里没有添加很多功能,但这意味着我们不需要在我们的代码的其他地方提及 JSON,如果我们决定添加对其他表示的支持或切换到二进制协议,我们只需要修改这两个函数。

接下来,我们将添加一些额外的辅助函数,这将使响应变得更加容易。在 respond.go 中添加以下代码:

func respond(w http.ResponseWriter, r *http.Request, 
 status int, data interface{}) { 
  w.WriteHeader(status) 
  if data != nil { 
    encodeBody(w, r, data) 
  } 
} 

这个函数使得使用我们的 encodeBody 辅助函数将状态码和一些数据写入 ResponseWriter 对象变得非常容易。

处理错误是另一个值得抽象的重要方面。添加以下 respondErr 辅助函数:

func respondErr(w http.ResponseWriter, r *http.Request, 
 status int, args ...interface{}) { 
  respond(w, r, status, map[string]interface{}{ 
    "error": map[string]interface{}{ 
      "message": fmt.Sprint(args...), 
    }, 
  }) 
} 

这个方法提供了一个类似于 respond 函数的接口,但写入的数据将被包裹在一个 error 对象中,以便清楚地表明出了问题。最后,我们可以添加一个专门用于生成正确消息的 HTTP 错误特定辅助函数,该函数使用 Go 标准库中的 http.StatusText 函数:

func respondHTTPErr(w http.ResponseWriter, r *http.Request, status int) { 
  respondErr(w, r, status, http.StatusText(status)) 
} 

注意,这些函数都是“狗粮”,这意味着它们相互使用(就像吃自己的狗粮一样),这对于我们希望在只有一个地方进行实际响应(如果或更可能地,当我们需要做出更改时)来说很重要。

理解请求

http.Request 对象为我们提供了访问底层 HTTP 请求可能需要的每一块信息的途径;因此,浏览一下 net/http 文档以真正了解其强大功能是值得的。以下是一些示例,但不仅限于以下内容:

  • URL、路径和查询字符串

  • HTTP 方法

  • Cookies

  • 文件

  • 表单值

  • 请求者的引用和用户代理

  • 基本认证详情

  • 请求体

  • 标头信息

它没有解决一些问题,我们需要自己解决或寻找外部包来帮助我们。URL 路径解析就是这样一个问题——虽然我们可以通过 http.Request 类型中的 URL.Path 字段以字符串的形式访问路径(如 /people/1/books/2),但没有简单的方法可以提取路径中编码的数据,如 1 的人 ID 或 2 的书 ID。

备注

一些项目很好地解决了这个问题,例如 Goweb 或 Gorillz 的 mux 包。它们允许你映射包含占位符的路径模式,然后从原始字符串中提取这些值并将其提供给你的代码。例如,你可以映射一个模式 /users/{userID}/comments/{commentID},这将映射路径,如 /users/1/comments/2。在你的处理程序代码中,你可以通过大括号内的名称来获取值,而不是自己解析路径。

由于我们的需求很简单,我们将构建一个简单的路径解析实用工具;如果需要,我们总是可以使用不同的包,但这意味着要向我们的项目中添加一个依赖。

创建一个名为 path.go 的新文件,并插入以下代码:

package main 
import ( 
  "strings" 
) 
const PathSeparator = "/" 
type Path struct { 
  Path string 
  ID   string 
} 
func NewPath(p string) *Path { 
  var id string 
  p = strings.Trim(p, PathSeparator) 
  s := strings.Split(p, PathSeparator) 
  if len(s) > 1 { 
    id = s[len(s)-1] 
    p = strings.Join(s[:len(s)-1], PathSeparator) 
  } 
  return &Path{Path: p, ID: id} 
} 
func (p *Path) HasID() bool { 
  return len(p.ID) > 0 
} 

这个简单的解析器提供了一个 NewPath 函数,它解析指定的路径字符串,并返回一个 Path 类型的新的实例。使用 strings.Trim 去除前导和尾随斜杠,并使用 PathSeparator 常量(即一个正斜杠)将剩余的路径分割(使用 strings.Split)。如果有多个段(len(s) > 1),最后一个被认为是 ID。我们使用 s[len(s)-1] 重新切片字符串以选择 ID,并使用 s[:len(s)-1] 选择路径的其余部分。同样,我们使用 PathSeparator 常量重新连接路径段,以形成一个不包含 ID 的单个字符串。

这支持任何 collection/id 对,这正是我们 API 所需要的。以下表格显示了给定原始路径字符串的 Path 类型的状态:

原始路径字符串 路径 ID HasID
/ / nil false
/people/ people nil false
/people/1/ people 1 true

使用一个函数提供我们的 API 服务

一个网络服务不过是一个简单的 Go 程序,它绑定到特定的 HTTP 地址和端口,并处理请求,因此我们可以使用我们所有的命令行工具编写知识和技巧。

小贴士

我们还希望确保我们的main函数尽可能简单和谦逊,这始终是编码的目标,尤其是在 Go 中。

在编写我们的main函数之前,让我们看看我们的 API 程序的一些设计目标:

  • 我们应该能够指定 API 监听的 HTTP 地址和端口以及 MongoDB 实例的地址,而无需重新编译程序(通过命令行标志)

    我们希望程序在终止时能够优雅地关闭,允许正在处理的请求(在发送终止信号给我们的程序时仍在处理的请求)完成

  • 我们希望程序能够正确地记录状态更新并报告错误

main.go文件顶部,将main函数占位符替换为以下代码:

func main() { 
  var ( 
    addr  = flag.String("addr", ":8080", "endpoint
     address") 
    mongo = flag.String("mongo", "localhost", "mongodb
     address") 
  ) 
  log.Println("Dialing mongo", *mongo) 
  db, err := mgo.Dial(*mongo) 
  if err != nil { 
    log.Fatalln("failed to connect to mongo:", err) 
  } 
  defer db.Close() 
  s := &Server{ 
    db: db, 
  } 
  mux := http.NewServeMux() 
  mux.HandleFunc("/polls/",
   withCORS(withAPIKey(s.handlePolls))) 
  log.Println("Starting web server on", *addr) 
  http.ListenAndServe(":8080", mux) 
  log.Println("Stopping...") 
} 

这个函数就是我们的 APImain函数的全部内容。我们首先指定两个命令行标志,addrmongo,并设置一些合理的默认值,然后要求flag包解析它们。然后我们尝试在指定的地址拨号到 MongoDB 数据库。如果我们不成功,我们通过调用log.Fatalln来中止。假设数据库正在运行并且我们能够连接,我们在延迟关闭连接之前将引用存储在db变量中。这确保了我们的程序在结束时能够正确地断开连接并清理。

小贴士

我们创建我们的服务器并指定数据库依赖项。我们调用我们的服务器s,有些人认为这是一种不好的做法,因为它很难阅读只引用单个字母变量的代码并知道它是什么。然而,由于这个变量的作用域很小,我们可以确信它的使用将非常接近其定义,从而消除了混淆的可能性。

然后,我们创建一个新的http.ServeMux对象,这是 Go 标准库提供的请求多路复用器,并为以/polls/路径开始的请求注册一个单一的处理程序。请注意,handlePolls处理程序是我们服务器上的一个方法,这就是它将如何访问数据库。

使用处理程序函数包装器

当我们在ServeMux处理程序上调用HandleFunc时,我们就在使用处理程序函数包装器,如下所示:

withCORS(withAPIKey(handlePolls)) 

由于每个函数都接受一个http.HandlerFunc类型作为参数,并返回一个,因此我们能够通过嵌套函数调用链式执行,就像我们之前所做的那样。因此,当接收到路径前缀为/polls/的请求时,程序将采取以下执行路径:

  1. 调用了withCORS函数,该函数设置了适当的头信息。

  2. 接下来调用 withAPIKey 函数,该函数检查请求中的 API 密钥,如果无效则终止,否则调用下一个处理器函数。

  3. 然后调用 handlePolls 函数,该函数可能使用 respond.go 中的辅助函数向客户端写入响应。

  4. 执行回到 withAPIKey,然后退出。

  5. 执行最终回到 withCORS,然后退出。

处理端点

最后一个拼图是 handlePolls 函数,它将使用辅助函数来理解传入的请求、访问数据库并生成一个有意义的响应,该响应将被发送回客户端。我们还需要对上一章中我们正在处理的投票数据进行建模。

创建一个名为 polls.go 的新文件并添加以下代码:

package main 
import "gopkg.in/mgo.v2/bson" 
type poll struct { 
  ID      bson.ObjectId  `bson:"_id" json:"id"` 
  Title   string         `json:"title"` 
  Options []string       `json:"options"` 
  Results map[string]int `json:"results,omitempty"` 
  APIKey  string         `json:"apikey"` 
} 

在这里,我们定义了一个名为 poll 的结构体,它有五个字段,这些字段反过来描述了我们在上一章中编写的代码创建和维护的投票。我们还添加了 APIKey 字段,您可能不会在现实世界中这样做,但它将允许我们演示如何从上下文中提取 API 密钥。每个字段都有一个标签(ID 的情况有两个),这允许我们提供一些额外的元数据。

使用标签向结构体添加元数据

标签只是跟在结构体类型字段定义同一行的字符串。我们使用黑色引号字符来表示字面字符串,这意味着我们可以在标签字符串内部自由使用双引号。reflect 包允许我们提取与任何键关联的值;在我们的例子中,bsonjson 都是键的例子,它们是每个由空格字符分隔的键/值对。encoding/jsongopkg.in/mgo.v2/bson 包允许您使用标签来指定用于编码和解码的字段名称(以及一些其他属性),而不是从字段名称本身推断值。我们使用 BSON 与 MongoDB 数据库通信,使用 JSON 与客户端通信,因此我们可以实际指定同一 struct 类型的不同视图。例如,考虑 ID 字段:

ID bson.ObjectId `bson:"_id" json:"id"` 

Go 中字段的名称是 ID,JSON 字段是 id,BSON 字段是 _id,这是 MongoDB 中使用的特殊标识符字段。

使用单个处理器执行许多操作

由于我们的简单路径解析解决方案只关心路径,因此当查看客户端正在进行的 RESTful 操作类型时,我们必须做一些额外的工作。具体来说,我们需要考虑 HTTP 方法,以便我们知道如何处理请求。例如,对 /polls/ 路径的 GET 调用应读取投票,而 POST 调用将创建一个新的投票。一些框架通过允许您根据路径以外的更多内容映射处理器来解决此问题,例如 HTTP 方法或请求中存在特定头。由于我们的案例非常简单,我们将使用简单的 switch 语句。在 polls.go 中添加 handlePolls 函数:

func (s *Server) handlePolls(w http.ResponseWriter,
 r *http.Request) { 
  switch r.Method { 
    case "GET": 
    s.handlePollsGet(w, r) 
    return 
    case "POST": 
    s.handlePollsPost(w, r) 
    return 
    case "DELETE": 
    s.handlePollsDelete(w, r) 
    return 
  } 
  // not found 
  respondHTTPErr(w, r, http.StatusNotFound) 
} 

我们根据 HTTP 方法进行分支,并根据它是GETPOST还是DELETE来分支我们的代码。如果 HTTP 方法不是这些,我们只响应一个404 http.StatusNotFound错误。为了使此代码编译,你可以在handlePolls处理程序下方添加以下函数存根:

func (s *Server) handlePollsGet(w http.ResponseWriter,
 r *http.Request) { 
  respondErr(w, r, http.StatusInternalServerError,
   errors.New("not    
  implemented")) 
} 
func (s *Server) handlePollsPost(w http.ResponseWriter,
 r *http.Request) { 
  respondErr(w, r, http.StatusInternalServerError,
   errors.New("not   
   implemented")) 
} 
func (s *Server) handlePollsDelete(w http.ResponseWriter,
  r *http.Request) { 
  respondErr(w, r, http.StatusInternalServerError,
   errors.New("not  
   implemented")) 
} 

小贴士

在本节中,我们学习了如何手动解析请求的元素(HTTP 方法)并在代码中做出决策。这对于简单情况来说很好,但值得看看像 Gorilla 的mux包这样的包,它们提供了一些更强大的解决这些问题的方法。然而,将外部依赖降到最低是编写良好且封装良好的 Go 代码的核心原则。

读取投票

现在是时候实现我们的网络服务功能了。添加以下代码:

func (s *Server) handlePollsGet(w http.ResponseWriter,
 r *http.Request) { 
  session := s.db.Copy() 
  defer session.Close() 
  c := session.DB("ballots").C("polls") 
  var q *mgo.Query 
  p := NewPath(r.URL.Path) 
  if p.HasID() { 
    // get specific poll 
    q = c.FindId(bson.ObjectIdHex(p.ID)) 
  } else { 
    // get all polls 
    q = c.Find(nil) 
  } 
  var result []*poll 
  if err := q.All(&result); err != nil { 
    respondErr(w, r, http.StatusInternalServerError, err) 
    return 
  } 
  respond(w, r, http.StatusOK, &result) 
} 

在我们的每个子处理函数中,我们首先创建数据库会话的副本,这将允许我们与 MongoDB 交互。然后我们使用mgo创建一个指向数据库中polls集合的对象——如果你还记得,这就是我们的投票所在。

我们通过解析路径构建一个mgo.Query对象。如果存在 ID,我们在polls集合上使用FindId方法;否则,我们将nil传递给Find方法,这表示我们想要选择所有投票。我们使用ObjectIdHex方法将 ID 从字符串转换为bson.ObjectId类型,这样我们就可以用它们的数值(十六进制)标识符来引用投票。

由于All方法期望生成一个poll对象的集合,我们定义结果为[]*poll或指向 poll 类型的指针切片。在查询上调用All方法将导致mgo使用其与 MongoDB 的连接来读取所有投票并填充result对象。

注意

对于小规模,例如少量投票,这种方法是可行的,但随着投票的增加,我们需要考虑一种更复杂的方法。我们可以通过在查询上使用Iter方法迭代它们,并使用LimitSkip方法来分页结果,这样我们就不试图将太多数据加载到内存中,或者一次向用户提供太多信息。

现在我们已经添加了一些功能,让我们第一次尝试我们的 API。如果你正在使用我们在上一章中设置的相同 MongoDB 实例,你应该已经在polls集合中有些数据了;为了确保我们的 API 能够正常工作,你应该确保数据库中至少有两个投票。

如果你需要向数据库添加其他投票,在终端中运行mongo命令以打开一个数据库外壳,这将允许你与 MongoDB 交互。然后,输入以下命令以添加一些测试投票:

> use ballots
switched to db ballots
> db.polls.insert({"title":"Test  poll","options":
     ["one","two","three"]})
> db.polls.insert({"title":"Test poll  two","options":
     ["four","five","six"]})

在终端中,导航到你的api文件夹,构建并运行项目:

go build -o api
./api

现在,通过在浏览器中导航到http://localhost:8080/polls/?key=abc123来对该/polls/端点发起一个GET请求;请记住包括末尾的反斜杠。结果将是一个包含 JSON 格式的投票数组的数组。

从投票列表中复制一个 ID,并将其插入到浏览器中的?字符之前,以访问特定投票的数据,例如,http://localhost:8080/polls/5415b060a02cd4adb487c3ae?key=abc123。请注意,它不会返回所有投票,而只返回一个。

小贴士

通过移除或更改密钥参数来测试 API 密钥功能,以查看错误看起来像什么。

你可能也注意到,尽管我们只返回一个投票,但这个投票值仍然嵌套在一个数组中。这是出于两个原因的故意设计决策:第一个也是最重要的原因是嵌套使得 API 用户编写代码来消费数据变得更加容易。如果用户总是期望一个 JSON 数组,他们可以编写描述这种期望的强类型,而不是为单个投票和投票集合使用不同的类型。作为 API 设计者,这是你的决定。第二个原因是,我们让对象嵌套在数组中使得 API 代码更加简单,这允许我们只更改mgo.Query对象,而让其他代码保持不变。

创建投票

客户应该能够向/polls/发送一个POST请求以创建投票。让我们在POST情况下添加以下代码:

func (s *Server) handlePollsPost(w http.ResponseWriter, 
 r *http.Request) { 
  session := s.db.Copy() 
  defer session.Close() 
  c := session.DB("ballots").C("polls") 
  var p poll 
  if err := decodeBody(r, &p); err != nil { 
    respondErr(w, r, http.StatusBadRequest, "failed to
     read poll from request", err) 
    return 
  } 
  apikey, ok := APIKey(r.Context()) 
  if ok { 
    p.APIKey = apikey 
  } 
  p.ID = bson.NewObjectId() 
  if err := c.Insert(p); err != nil { 
    respondErr(w, r, http.StatusInternalServerError,
     "failed to insert 
    poll", err) 
    return 
  } 
  w.Header().Set("Location", "polls/"+p.ID.Hex()) 
  respond(w, r, http.StatusCreated, nil) 
} 

在我们获取到数据库会话的副本之后,我们尝试解码请求的正文,根据 RESTful 原则,该正文应该包含客户端想要创建的投票对象的表示。如果发生错误,我们使用respondErr辅助函数将错误写入用户并立即退出函数。然后我们为投票生成一个新的唯一 ID,并使用mgo包的Insert方法将其发送到数据库。然后我们设置响应的Location头并使用201 http.StatusCreated消息响应,指向可以访问新创建的投票的 URL。一些 API 返回对象而不是提供链接;没有具体的标准,所以这取决于你作为设计者的决定。

删除投票

我们将要包含在我们 API 中的最后一块功能是能够删除投票。通过向投票的 URL(例如/polls/5415b060a02cd4adb487c3ae)发送一个使用DELETE HTTP 方法的请求,我们希望能够从数据库中删除该投票并返回一个200 成功响应:

func (s *Server) handlePollsDelete(w http.ResponseWriter, 
  r *http.Request) { 
  session := s.db.Copy() 
  defer session.Close() 
  c := session.DB("ballots").C("polls") 
  p := NewPath(r.URL.Path) 
  if !p.HasID() { 
    respondErr(w, r, http.StatusMethodNotAllowed,
      "Cannot delete all polls.") 
    return 
  } 
  if err := c.RemoveId(bson.ObjectIdHex(p.ID)); err != nil { 
    respondErr(w, r, http.StatusInternalServerError,
     "failed to delete poll", err) 
    return 
  } 
  respond(w, r, http.StatusOK, nil) // ok 
} 

GET情况类似,我们解析路径,但这次如果路径不包含 ID,我们将返回一个错误。目前我们不希望人们能够通过一个请求删除所有投票,因此我们使用合适的StatusMethodNotAllowed代码。然后,使用我们在前一个案例中使用的相同集合,我们调用RemoveId,将路径中的 ID 转换为bson.ObjectId类型后传递。假设一切顺利,我们将以没有主体的http.StatusOK消息响应。

CORS 支持

为了让我们的DELETE功能能够在 CORS 下工作,我们必须做一些额外的工作来支持 CORS 浏览器处理某些 HTTP 方法(如DELETE)的方式。实际上,CORS 浏览器会发送一个预检请求(带有OPTIONS HTTP 方法),请求允许发送DELETE请求(列在Access-Control-Request-Method请求头中),并且 API 必须适当地响应,以便请求能够工作。在switch语句中添加另一个针对OPTIONS的情况:

case "OPTIONS": 
  w.Header().Add("Access-Control-Allow-Methods", "DELETE") 
  respond(w, r, http.StatusOK, nil) 
  return 

如果浏览器请求发送DELETE请求的权限,API 将通过设置Access-Control-Allow-Methods头为DELETE来响应,从而覆盖我们在withCORS包装处理程序中设置的默认*值。在现实世界中,Access-Control-Allow-Methods头的值将根据请求做出相应改变,但由于我们只支持DELETE这一种情况,我们可以现在将其硬编码。

注意

CORS 的详细信息超出了本书的范围,但如果您打算构建真正可访问的 Web 服务和 API,建议您在网上研究具体细节。前往enable-cors.org/开始。

使用 curl 测试我们的 API

Curl 是一个命令行工具,它允许我们向我们的服务发送 HTTP 请求,这样我们就可以像真正的应用程序或客户端一样访问它,消费该服务。

注意

Windows 用户默认没有 curl 的访问权限,需要寻找替代方案。查看curl.haxx.se/dlwiz/?type=bin或在网上搜索 Windows curl 的替代方案。

在终端中,让我们通过我们的 API 读取数据库中的所有投票。导航到您的api文件夹,构建并运行项目,并确保 MongoDB 正在运行:

go build -o api
./api

我们接下来执行以下步骤:

  1. 输入以下使用-X标志的curl命令,表示我们想要向指定的 URL 发送一个GET请求:

    curl -X GET http://localhost:8080/polls/?
             key=abc123
    
    
  2. 在按下Enter键后,输出将被打印出来:

    [{"id":"541727b08ea48e5e5d5bb189","title":"Best
              Beatle?",
              "options": ["john","paul","george","ringo"]},
            {"id":"541728728ea48e5e5d5bb18a","title":"Favorite
              language?",
              "options": ["go","java","javascript","ruby"]}]
    
    
  3. 虽然看起来不太美观,但您可以看到 API 返回了您的数据库中的投票。发出以下命令来创建一个新的投票:

    curl --data '{"title":"test","options":
             ["one","two","three"]}'
             -X POST http://localhost:8080/polls/?key=abc123
    
    
  4. 再次获取列表以查看包含的新投票:

    curl -X GET http://localhost:8080/polls/?
             key=abc123
    
    
  5. 复制并粘贴其中一个 ID,并调整 URL 以具体指向该投票:

    curl -X GET
              http://localhost:8080/polls/541727b08ea48e5e5d5bb189?
              key=abc123
    [{"id":"541727b08ea48e5e5d5bb189",","title":"Best  Beatle?",
              "options": ["john","paul","george","ringo"]}]
    
    
  6. 现在我们只看到选定的投票。让我们发送一个DELETE请求来删除这个投票:

    curl -X DELETE  
              http://localhost:8080/polls/541727b08ea48e5e5d5bb189? 
              key=abc123
    
    
  7. 现在我们再次获取所有投票时,我们会看到披头士的投票已经消失了:

    curl -X GET http://localhost:8080/polls/?key=abc123
    [{"id":"541728728ea48e5e5d5bb18a","title":"Favorite    
              language?","options":["go","java","javascript","ruby"]}]
    
    

既然我们知道我们的 API 按预期工作,现在是时候构建一个能够正确消费 API 的东西了。

消费该 API 的 Web 客户端

我们将构建一个超简单的 Web 客户端,该客户端消费通过我们的 API 公开的能力和数据,使用户能够与我们在上一章以及本章早期构建的投票系统进行交互。我们的客户端将由三个网页组成:

  • 一个显示所有投票的index.html页面

  • 一个显示特定投票结果的view.html页面

  • 一个允许用户创建新投票的new.html页面

api文件夹旁边创建一个名为web的新文件夹,并将以下内容添加到main.go文件中:

package main 
import ( 
  "flag" 
  "log" 
  "net/http" 
) 
func main() { 
  var addr = flag.String("addr", ":8081", "website address") 
  flag.Parse() 
  mux := http.NewServeMux() 
  mux.Handle("/", http.StripPrefix("/",  
    http.FileServer(http.Dir("public")))) 
  log.Println("Serving website at:", *addr) 
  http.ListenAndServe(*addr, mux) 
} 

这几行 Go 代码真正凸显了语言和 Go 标准库的美丽。它们代表了一个完整、高度可扩展的静态网站托管程序。该程序接受一个addr标志,并使用熟悉的http.ServeMux类型从名为public的文件夹中提供静态文件。

小贴士

在构建 UI 的同时构建接下来的几页,需要编写大量的 HTML 和 JavaScript 代码。由于这不是 Go 代码,如果你不想全部输入,请随意前往这本书的 GitHub 仓库github.com/matryer/goblueprints并复制粘贴。你也可以根据需要包含 Bootstrap 和 jQuery 库的最新版本,但可能与后续版本存在实现差异。

第二十九章:显示投票列表的索引页面

web 文件夹内创建一个名为 public 的文件夹,并在其中添加 index.html 文件,写入以下 HTML 代码:

<!DOCTYPE html> 
<html> 
<head> 
  <title>Polls</title> 
  <link rel="stylesheet"
   href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/     
    bootstrap.min.css"> 
</head> 
<body> 
</body> 
</html> 

我们将再次使用 Bootstrap 来使我们的简单 UI 看起来更美观,但我们需要在 HTML 页面的 body 标签中添加两个额外的部分。首先,添加将显示投票列表的 DOM 元素:

<div class="container"> 
  <div class="col-md-4"></div> 
  <div class="col-md-4"> 
    <h1>Polls</h1> 
    <ul id="polls"></ul> 
    <a href="new.html" class="btn btn-primary">Create new poll</a> 
  </div> 
  <div class="col-md-4"></div> 
</div> 

在这里,我们使用 Bootstrap 的网格系统来居中对齐由投票列表和指向 new.html 的链接组成的内容,用户可以在该页面上创建新的投票。

接下来,添加以下 script 标签和 JavaScript 代码:

<script  src="img/jquery.min.js"></script> 
 <script> 
  $(function(){ 
    var update = function(){ 
      $.get("http://localhost:8080/polls/?key=abc123", null, null,  "json") 
        .done(function(polls){ 
          $("#polls").empty(); 
          for (var p in polls) { 
            var poll = polls[p]; 
            $("#polls").append( 
              $("<li>").append( 
                $("<a>") 
                  .attr("href", "view.html?poll=polls/" + poll.id) 
                  .text(poll.title) 
              ) 
            ) 
          } 
        } 
      ); 
      window.setTimeout(update, 10000); 
    } 
    update(); 
  }); 
</script> 

我们正在使用 jQuery 的 $.get 函数向我们的网络服务发送 AJAX 请求。我们硬编码了 API URL –在实践中,你可能决定不这样做–或者至少使用域名来抽象化它。一旦加载了投票,我们使用 jQuery 构建一个包含指向 view.html 页面的超链接列表,并将投票的 ID 作为查询参数传递。

创建新的投票

为了允许用户创建新的投票,在 public 文件夹内创建一个名为 new.html 的文件,并将以下 HTML 代码添加到该文件中:

<!DOCTYPE html> 
<html> 
<head> 
  <title>Create Poll</title> 
  <link rel="stylesheet"       
    href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/
    bootstrap.min.css"> 
</head> 
<body> 
  <script src="img/jquery.min.js">   </script> 
</body> 
</html> 

我们将添加一个 HTML 表单的元素,用于在创建新的投票时捕获所需的信息,即投票的标题和选项。将以下代码添加到 body 标签内:

<div class="container"> 
  <div class="col-md-4"></div> 
  <form id="poll" role="form" class="col-md-4"> 
    <h2>Create Poll</h2> 
    <div class="form-group"> 
      <label for="title">Title</label> 
      <input type="text" class="form-control" id="title" 
        placeholder="Title"> 
    </div> 
    <div class="form-group"> 
      <label for="options">Options</label> 
      <input type="text" class="form-control" id="options"
        placeholder="Options"> 
      <p class="help-block">Comma separated</p> 
    </div> 
    <button type="submit" class="btn btn-primary">
      Create Poll</button> or <a href="/">cancel</a> 
  </form> 
  <div class="col-md-4"></div> 
</div> 

由于我们的 API 使用 JSON,我们需要做一些工作将 HTML 表单转换为 JSON 编码的字符串,并将逗号分隔的选项字符串拆分为选项数组。添加以下 script 标签:

<script> 
  $(function(){ 
    var form = $("form#poll"); 
    form.submit(function(e){ 
      e.preventDefault(); 
      var title = form.find("input[id='title']").val(); 
      var options = form.find("input[id='options']").val(); 
      options = options.split(","); 
      for (var opt in options) { 
        options[opt] = options[opt].trim(); 
      } 
      $.post("http://localhost:8080/polls/?key=abc123", 
        JSON.stringify({ 
          title: title, options: options 
        }) 
      ).done(function(d, s, r){ 
        location.href = "view.html?poll=" +
        r.getResponseHeader("Location"); 
      }); 
    }); 
  }); 
</script> 

在这里,我们为表单的 submit 事件添加了一个监听器,并使用 jQuery 的 val 方法收集输入值。我们用逗号分隔选项,并在使用 $.post 方法向适当的 API 端点发送 POST 请求之前移除空格。JSON.stringify 允许我们将数据对象转换为 JSON 字符串,我们使用这个字符串作为请求的主体,这是 API 所期望的。成功后,我们提取 Location 标头并将用户重定向到 view.html 页面,传递对新创建的投票的引用作为参数。

显示投票的详细信息

我们应用需要完成的最后一页是 view.html 页面,用户可以查看投票的详细信息和实时结果。在 public 文件夹内创建一个名为 view.html 的新文件,并将以下 HTML 代码添加到其中:

<!DOCTYPE html> 
<html> 
<head> 
  <title>View Poll</title> 
  <link rel="stylesheet" 
   href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css"> 
</head> 
<body> 
  <div class="container"> 
    <div class="col-md-4"></div> 
    <div class="col-md-4"> 
      <h1 data-field="title">...</h1> 
      <ul id="options"></ul> 
      <div id="chart"></div> 
      <div> 
        <button class="btn btn-sm" id="delete">Delete this poll</button> 
      </div> 
    </div> 
    <div class="col-md-4"></div> 
  </div> 
</body> 
</html> 

这个页面与其他页面大部分相似;它包含用于展示投票标题、选项和饼图的元素。我们将把 Google 的可视化 API 与我们的 API 混合使用来展示结果。在 view.html 中的最后一个 div 标签下面(并且在关闭 body 标签之上),添加以下 script 标签:

<script src="img/"></script> 
<script  src="img/jquery.min.js">
</script> 
<script> 
google.load('visualization', '1.0', {'packages':['corechart']}); 
google.setOnLoadCallback(function(){ 
  $(function(){ 
    var chart; 
    var poll = location.href.split("poll=")[1]; 
    var update = function(){ 
      $.get("http://localhost:8080/"+poll+"?key=abc123", null, null,
        "json") 
        .done(function(polls){ 
          var poll = polls[0]; 
          $('[data-field="title"]').text(poll.title); 
          $("#options").empty(); 
          for (var o in poll.results) { 
            $("#options").append( 
              $("<li>").append( 
                $("<small>").addClass("label label 
                default").text(poll.results[o]), 
                " ", o 
              ) 
            ) 
          } 
          if (poll.results) { 
            var data = new google.visualization.DataTable(); 
            data.addColumn("string","Option"); 
            data.addColumn("number","Votes"); 
            for (var o in poll.results) { 
              data.addRow([o, poll.results[o]]) 
            } 
            if (!chart) { 
              chart = new                 google.visualization.PieChart 
                (document.getElementById('chart')); 
            } 
            chart.draw(data, {is3D: true}); 
          } 
        } 
      ); 
      window.setTimeout(update, 1000); 
    }; 
    update(); 
    $("#delete").click(function(){ 
      if (confirm("Sure?")) { 
        $.ajax({ 
          url:"http://localhost:8080/"+poll+"?key=abc123", 
          type:"DELETE" 
        }) 
        .done(function(){ 
          location.href = "/"; 
        }) 
      } 
    }); 
  }); 
}); 
</script> 

我们包括了为我们的页面提供动力的依赖项,jQuery 和 Bootstrap,以及 Google JavaScript API。代码从 Google 加载适当的可视化库,并在 DOM 元素加载之前等待,通过在 poll= 上分割 URL 提取投票 ID。然后我们创建一个名为 update 的变量,它代表一个负责生成页面视图的函数。采取这种做法是为了让我们能够轻松地使用 window.setTimeout 来定期调用更新视图。在 update 函数内部,我们使用 $.get 向我们的 /polls/{id} 端点发送 GET 请求,将 {id} 替换为之前从 URL 中提取的实际 ID。一旦投票加载,我们更新页面上的标题,并遍历选项将它们添加到列表中。如果有结果(记住,在前一章中,只有在开始计票时,results 映射才被添加到数据中),我们创建一个新的 google.visualization.PieChart 对象,并构建一个包含结果的 google.visualization.DataTable 对象。在图表上调用 draw 会使其渲染数据,从而用最新的数字更新图表。然后我们使用 setTimeout 告诉我们的代码在另一秒后再次调用 update

最后,我们将绑定到我们页面中添加的 delete 按钮的 click 事件,并在询问用户是否确定后,向投票 URL 发送 DELETE 请求,然后将其重定向回主页。实际上,这个请求会导致首先发出 OPTIONS 请求,请求权限,这就是为什么我们在之前的 handlePolls 函数中添加了对它的显式支持。

运行解决方案

在前两章中,我们构建了许多组件,现在是时候看看它们一起工作的情况了。本节包含您需要的一切,以便在正确设置环境的情况下运行所有项目,正如前一章开头所述。本节假设您有一个包含四个子文件夹的单一文件夹:apicountertwittervotesweb

假设没有任何程序正在运行,请按照以下步骤操作(每个步骤都在自己的终端窗口中执行):

  1. 在顶级文件夹中,启动 nsqlookupd 守护进程:

    nsqlookupd
    
    
  2. 在同一目录下,启动 nsqd 守护进程:

    nsqd --lookupd-tcp-address=localhost:4160
    
    
  3. 启动 MongoDB 守护进程:

    mongod
    
    
  4. 导航到 counter 文件夹并构建和运行它:

    cd counter
    go build -o counter
    ./counter
    
    
  5. 导航到 twittervotes 文件夹并构建和运行它。确保您已设置适当的环境变量;否则,在运行程序时您将看到错误:

    cd ../twittervotes
    go build -o twittervotes
    ./twittervotes
    
    
  6. 导航到 api 文件夹并构建和运行它:

    cd ../api
    go build -o api
    ./api
    
    
  7. 导航到 web 文件夹并构建和运行它:

    cd ../web
    go build -o web
    ./web
    
    

现在所有程序都在运行,打开浏览器并访问 http://localhost:8081/。使用用户界面,创建一个名为 Moods 的投票,并输入选项为 happy,sad,fail,success。这些词足够常见,我们可能会在推特上看到一些相关的活动。

一旦你创建了你的投票,你将被带到查看页面,在那里你将开始看到结果陆续到来。等待几秒钟,享受你辛勤工作的果实,因为 UI 实时更新,显示实时、实时结果:

运行解决方案

摘要

在本章中,我们通过一个高度可扩展的 RESTful API 公开了我们的社交投票解决方案的数据,并构建了一个简单的网站,该网站消费 API 以提供一种直观的方式供用户与之交互。该网站仅包含静态内容,没有服务器端处理(因为 API 为我们做了繁重的工作)。这使得我们能够在静态托管网站上以非常低廉的成本托管网站,例如bitballoon.com,或者将文件分发到内容分发网络。

在我们的 API 服务中,我们学习了如何在不会破坏或混淆标准库中的处理程序模式的情况下在处理程序之间共享数据。我们还看到了如何编写包装处理程序函数,这允许我们以非常简单直观的方式构建功能管道。

我们编写了一些基本的编码和解码函数,虽然目前它们只是简单地包装了encoding/json包中的对应函数,但以后可以改进以支持一系列不同的数据表示,而不必更改我们代码的内部接口。我们还编写了一些简单的辅助函数,使响应数据请求变得容易,同时提供允许我们以后演进 API 的相同类型的抽象。

我们看到了在简单情况下,切换到 HTTP 方法是如何以一种优雅的方式支持单个端点上的许多功能。我们也看到了通过添加几行额外的代码,我们能够构建对 CORS 的支持,以便允许运行在不同域上的应用程序与我们的服务交互——无需使用诸如 JSONP 之类的黑客手段。

在下一章中,我们将演进我们的 API 和 Web 技能,以构建一个全新的创业应用,名为 Meander。我们还将探索一种在官方不支持枚举器的语言中表示枚举器的方法。

第七章. 随机推荐网络服务

我们在本章中将要构建的项目背后的概念很简单:我们希望用户能够根据我们通过 API 公开的预定义旅程类型生成特定地理位置的随机活动推荐。我们将给我们的项目取名为 Meander。

在现实世界的项目中,你通常不负责整个栈;有人可能构建了网站,另一个人可能编写了 iOS 应用程序,也许外包公司构建了桌面版本。在更成功的 API 项目中,你可能甚至不知道你的 API 的消费者是谁,尤其是如果它是一个公开 API 的话。

在本章中,我们将在实施 API 之前,通过与一个虚构的合作伙伴设计和同意一个最小 API 设计来模拟这种现实。一旦我们完成了我们这一部分的项目,我们将下载我们的队友构建的用户界面,以查看两者如何协同工作以产生最终的应用程序。

在本章中,你将:

  • 学习如何使用简短和简单的敏捷用户故事来表达项目的总体目标

  • 发现你可以在通过 API 设计达成一致的基础上在项目中达成一个会议点,这允许许多人并行工作

  • 看看早期版本可以如何将数据固定写入代码并编译到程序中,这样我们可以在不触及接口的情况下稍后更改实现

  • 学习一种策略,允许 struct(和其他类型)在需要隐藏或转换内部表示的情况下表示自己的公共版本

  • 学习如何使用内嵌 struct 来表示嵌套数据,同时保持我们类型接口的简单性

  • 学习如何使用http.Get来发起外部 API 请求,特别是对 Google Places API 的请求,而不产生代码膨胀

  • 学习如何在 Go 中有效地实现枚举器,即使它们并不是真正的语言特性

  • 经历一个真实的 TDD(测试驱动开发)示例

  • 看看math/rand包如何使从切片中随机选择一个项目变得容易

  • 学习一种简单的方法来从http.Request类型的 URL 参数中抓取数据

项目概述

按照敏捷方法论,让我们编写两个用户故事来描述我们项目的功能。用户故事不应该是不完整的文档,描述应用程序的全部功能集;相反,小卡片非常适合不仅描述用户试图做什么,还为什么这样做。此外,我们应该在不试图一开始就设计整个系统或深入实现细节的情况下完成这项工作。

首先,我们需要一个关于查看用户可以从中选择的不同旅程类型的故事:

作为 旅行者
我想 看看我可以获得推荐的不同旅程类型
为了 我可以决定带我的伴侣去哪种晚上的活动

其次,我们需要一个关于为选定的旅行类型提供随机推荐的故事:

作为一个 旅行者
我想 看到我选择的旅行类型的随机推荐
以便 我知道去哪里以及晚上将有什么活动

这两个故事代表了我们的 API 需要提供的两个核心功能,并且最终代表了两个端点。

为了发现指定地点周围的地方,我们将利用 Google Places API,它允许我们搜索具有给定类型(如barcafemovie_theater)的商家列表。然后我们将使用 Go 的math/rand包从这些地点中随机选择,为我们的用户提供完整的旅程。

小贴士

Google Places API 支持许多商业类型;有关完整列表,请参阅developers.google.com/places/documentation/supported_types

项目设计具体细节

为了将我们的故事变成一个交互式应用程序,我们将提供两个 JSON 端点:一个用于提供用户可以在应用程序中选择的旅行类型,另一个用于为选定的旅行类型生成随机推荐。

GET /journeys 

前面的调用应该返回一个类似于以下列表的结果:

[ 
  { 
    name: "Romantic", 
    journey: "park|bar|movie_theater|restaurant|florist" 
  }, 
  { 
    name: "Shopping", 
    journey: "department_store|clothing_store|jewelry_store" 
  } 
] 

name字段是应用程序生成的推荐类型的可读标签,而journey字段是支持旅行类型的管道分隔列表。我们将作为 URL 参数传递的旅行值传递给我们的另一个端点,该端点生成实际的推荐:

GET /recommendations? 
 lat=1&lng=2&journey=bar|cafe&radius=10&cost=$...$$$$$ 

此端点负责查询 Google Places API 并在返回地点对象数组之前生成推荐。我们将使用 URL 中的参数来控制要进行的查询类型。latlng参数分别表示纬度和经度,告诉我们的 API 我们希望从世界的哪个地方获取推荐,而radius参数表示我们感兴趣的点的周围距离(以米为单位)。

cost值是以人类可读的方式表示 API 返回的地点的价格范围。它由两个值组成:一个下限和一个上限,由三个点分隔。美元字符的数量表示价格水平,其中$是最便宜的,而$$$$$是最贵的。使用这种模式,$...$$表示非常低成本的推荐,而$$$$...$$$$$则表示相当昂贵的体验。

小贴士

一些程序员可能会坚持认为成本范围应该用数值表示,但鉴于我们的 API 将由人们使用,为什么不使事情变得更有趣呢?这取决于作为 API 设计者的你。

对于这个调用,一个可能的负载示例可能看起来像这样:

[ 
  { 
    icon: "http://maps.gstatic.com/mapfiles/place_api/icons/cafe-     
     71.png", 
    lat: 51.519583, lng: -0.146251, 
    vicinity: "63 New Cavendish St, London", 
    name: "Asia House", 
    photos: [{ 
      url: "https://maps.googleapis.com/maps/api/place/photo?        
      maxwidth=400&photoreference=CnRnAAAAyLRN" 
     }] 
  }, ... 
] 

返回的数组包含一个表示旅程中每个段落的随机推荐的地方对象,并按适当的顺序排列。前面的例子是在伦敦的咖啡馆。数据字段相当直观;latlng字段表示地点的位置,namevicinity字段告诉我们业务是什么以及在哪里,photos数组提供了来自 Google 服务器的相关照片列表。vicinityicon字段将帮助我们为用户提供更丰富的体验。

在代码中表示数据

我们首先将展示用户可以选择的旅程;因此,在GOPATH中创建一个名为meander的新文件夹,并添加以下journeys.go代码:

package meander 
type j struct { 
  Name       string 
  PlaceTypes []string 
} 
var Journeys = []interface{}{ 
  j{Name: "Romantic", PlaceTypes: []string{"park", "bar",  
   "movie_theater", "restaurant", "florist", "taxi_stand"}}, 
  j{Name: "Shopping", PlaceTypes: []string{"department_store",  "cafe", 
   "clothing_store", "jewelry_store", "shoe_store"}}, 
  j{Name: "Night Out", PlaceTypes: []string{"bar", "casino", "food", 
   "bar", "night_club", "bar", "bar", "hospital"}}, 
  j{Name: "Culture", PlaceTypes: []string{"museum", "cafe", "cemetery", 
   "library", "art_gallery"}}, 
  j{Name: "Pamper", PlaceTypes: []string{"hair_care",  "beauty_salon", 
   "cafe", "spa"}}, 
} 

在这里,我们在meander包内部定义了一个名为j的内部类型,然后通过在Journeys切片内部创建其实例来描述旅程。这种方法是在不依赖外部数据存储的情况下在代码中表示数据的一种超简单方式。

小贴士

作为额外的作业,为什么不尝试在整个过程中保持golint满意呢?每次添加一些代码,就为包运行golint并满足出现的任何建议。它非常关注没有文档的导出项;因此,在正确的格式中添加简单的注释将使其保持满意。要了解更多关于golint的信息,请参阅github.com/golang/lint

当然,这很可能会演变成那样,也许甚至允许用户创建和分享他们自己的旅程。由于我们通过 API 公开数据,我们可以自由地更改内部实现而不影响接口,因此这种方法非常适合版本 1。

小贴士

我们使用类型[]interface{}的切片,因为我们将在以后实现一种通用的公开数据方式,无论实际类型如何。

一场浪漫的旅程包括首先参观公园,然后是酒吧、电影院,接着是餐厅,在访问花店之后,最后乘坐出租车回家;你大概能理解这个概念。请随意发挥创意,并参考 Google Places API 支持的类型添加其他旅程。

你可能已经注意到,由于我们的代码包含在名为meander的包中(而不是main),我们的代码永远不能像我们迄今为止编写的其他 API 那样作为工具运行。在meander内部创建两个新文件夹,以便你有类似meander/cmd/meander的路径;这将容纳实际通过 HTTP 端点公开meander包功能的命令行工具。

由于我们主要是在构建一个用于我们蜿蜒项目的包(其他工具可以导入并使用),根目录中的代码是meander包,我们将我们的命令(main包)嵌套在cmd文件夹中。我们包括额外的最终meander文件夹,以遵循良好的实践,如果省略它,命令名将与文件夹相同,我们的命令将被称为cmd而不是meander,这可能会造成混淆。

cmd/meander文件夹内,将以下代码添加到main.go文件中:

package main 
func main() { 
  //meander.APIKey = "TODO" 
  http.HandleFunc("/journeys", func(w http.ResponseWriter,
  r *http.Request) { 
    respond(w, r, meander.Journeys) 
  }) 
  http.ListenAndServe(":8080", http.DefaultServeMux) 
} 
func respond(w http.ResponseWriter, r *http.Request, data  []interface{}) error { 
  return json.NewEncoder(w).Encode(data) 
} 

你会认出这是一个简单的 API 端点程序,映射到/journeys端点。

小贴士

你将不得不导入encoding/jsonnet/httpruntime包,以及你之前创建的自己的meander包。

在调用net/http包上的熟悉的HandleFunc函数以绑定我们的端点之前,我们在meander包中设置了APIKey的值(目前被注释掉,因为我们还没有实现它),然后只响应meander.Journeys变量。我们通过提供一个将指定数据编码到http.ResponseWriter类型的respond函数,从上一章借用了抽象响应的概念。

让我们通过在终端中导航到cmd/meander文件夹并使用go run来运行我们的 API 程序。在这个阶段我们不需要将其构建成一个可执行文件,因为它只是一个单独的文件:

go run main.go

访问http://localhost:8080/journeys端点,并注意我们的Journeys数据负载被提供,看起来是这样的:

[{ 
  Name: "Romantic", 
  PlaceTypes: [ 
    "park", 
    "bar", 
    "movie_theater", 
    "restaurant", 
    "florist", 
    "taxi_stand" 
  ] 
}, ...] 

这完全是可以接受的,但有一个主要的缺陷:它暴露了我们实现的一些内部信息。如果我们把PlaceTypes字段名改为Types,我们 API 中做出的承诺就会失效,因此我们避免这种情况是很重要的。

项目会随着时间的推移而发展和变化,尤其是那些成功的项目,作为开发者,我们应该尽我们所能来保护我们的客户免受演变的影响。抽象接口是做这件事的一个很好的方法,同样,负责我们数据对象面向公众的视图也是。

Go 结构体的公共视图

为了控制 Go 中结构体的公共视图,我们需要发明一种方法,允许单个journey类型告诉我们它们希望如何被暴露。在根meander文件夹中,创建一个名为public.go的新文件,并添加以下代码:

package meander 
type Facade interface { 
  Public() interface{} 
} 
func Public(o interface{}) interface{} { 
  if p, ok := o.(Facade); ok { 
    return p.Public() 
  } 
  return o 
} 

Facade接口公开了一个单一的Public方法,它将返回结构体的公共视图。导出的Public函数接受任何对象并检查它是否实现了Facade接口(它是否有Public() interface{}方法?);如果实现了,它将调用该方法并返回结果;如果没有实现,它将原封不动地返回原始对象。这允许我们在将结果写入ResponseWriter对象之前通过Public函数传递任何内容,允许单个结构体控制它们的公共外观。

小贴士

通常,像我们的 Facade 这样的单方法接口会以它们描述的方法命名,例如 ReaderWriter。然而,Publicer这个名字很令人困惑,所以我故意打破了规则。

让我们在 journeys.go 中添加以下代码来实现我们的 j 类型的 Public 方法:

func (j j) Public() interface{} { 
  return map[string]interface{}{ 
    "name":    j.Name, 
    "journey": strings.Join(j.PlaceTypes, "|"), 
  } 
} 

我们 j 类型的公共视图将 PlaceTypes 字段连接成一个由管道字符分隔的单个字符串,符合我们的 API 设计。

返回到 cmd/meander/main.go 并将 respond 方法替换为使用我们新 Public 函数的方法:

func respond(w http.ResponseWriter, r *http.Request, data []interface{}) error { 
  publicData := make([]interface{}, len(data)) 
  for i, d := range data { 
    publicData[i] = meander.Public(d) 
  } 
  return json.NewEncoder(w).Encode(publicData) 
} 

在这里,我们遍历数据切片,对每个项目调用 meander.Public 函数,将结果构建成一个新的同大小切片。在我们的 j 类型中,其 Public 方法将被调用以提供数据的公共视图,而不是默认视图。在终端中,再次导航到 cmd/meander 文件夹,并在运行 go run main.go 之前访问 http://localhost:8080/journeys。请注意,相同的数据现在已更改为新的结构:

[{ 
  journey: "park|bar|movie_theater|restaurant|florist|taxi_stand", 
  name: "Romantic" 
}, ...] 

注意

实现相同结果的一种替代方法是通过使用标签来控制字段名称,就像我们在前面的章节中所做的那样,并实现你自己的 []string 类型,该类型提供了一个 MarshalJSON 方法,告诉编码器如何序列化你的类型。两者都是完全可以接受的,但 Facade 接口和 Public 方法可能更具表达性(如果有人阅读代码,这不是很明显吗?)并且给我们更多的控制。

生成随机推荐

为了从我们的代码随机构建推荐的地方,我们需要查询 Google Places API。在 meander 根目录下,添加以下 query.go 文件:

package meander 
type Place struct { 
  *googleGeometry `json:"geometry"` 
  Name            string         `json:"name"` 
  Icon            string         `json:"icon"` 
  Photos          []*googlePhoto `json:"photos"` 
  Vicinity        string         `json:"vicinity"` 
} 
type googleResponse struct { 
  Results []*Place `json:"results"` 
} 
type googleGeometry struct { 
  *googleLocation `json:"location"` 
} 
type googleLocation struct { 
  Lat float64 `json:"lat"` 
  Lng float64 `json:"lng"` 
} 
type googlePhoto struct { 
  PhotoRef string `json:"photo_reference"` 
  URL      string `json:"url"` 
} 

此代码定义了我们解析 Google Places API 的 JSON 响应到可用对象所需的结构。

小贴士

前往 Google Places API 文档查看我们期望的响应示例。developers.google.com/places/documentation/search

上述代码的大部分内容都很明显,但值得注意的是,Place 类型嵌入了 googleGeometry 类型,这允许我们按照 API 的要求表示嵌套数据,同时在我们的代码中将其扁平化。我们通过在 googleGeometry 中的 googleLocation 来实现这一点,这意味着我们甚至可以直接在 Place 对象上访问 LatLng 值,尽管它们在技术上嵌套在其他结构中。

因为我们想控制 Place 对象的公开显示方式,让我们给这个类型以下 Public 方法:

func (p *Place) Public() interface{} { 
  return map[string]interface{}{ 
    "name":     p.Name, 
    "icon":     p.Icon, 
    "photos":   p.Photos, 
    "vicinity": p.Vicinity, 
    "lat":      p.Lat, 
    "lng":      p.Lng, 
  } 
} 

小贴士

记得运行 golint 来查看哪些注释需要添加到导出项中。

Google Places API 密钥

与大多数 API 一样,我们需要一个 API 密钥才能访问远程服务。前往 Google APIs Console,使用 Google 账户登录,并为 Google Places API 创建一个密钥。有关更详细的说明,请参阅 Google 开发者网站上的文档。

一旦你有了你的密钥,让我们在meander包内部创建一个变量来保存它。在query.go的顶部添加以下定义:

var APIKey string 

现在,回到main.go,从APIKey行中移除双斜杠//,并将TODO值替换为 Google APIs Console 提供的实际密钥。记住,直接在代码中硬编码这样的密钥是不良的做法;相反,将它们分离到环境变量中是值得的,这样可以防止它们出现在源代码库中。

Go 中的枚举器

为了处理我们 API 中的各种成本范围,使用枚举器(或枚举)来表示不同的值并处理字符串表示的转换是有意义的。Go 语言本身并不提供枚举器作为语言特性,但有一个巧妙的方法来实现它们,我们将在本节中探讨。

一个简单的灵活的清单,用于在 Go 中编写枚举器如下:

  • 基于原始整数类型定义一个新的类型

  • 当你需要用户指定适当的值时,使用该类型

  • 使用iota关键字在const块中设置值,忽略第一个零值

  • 实现一个映射,将合理的字符串表示与枚举器的值对应

  • 在类型上实现一个String方法,该方法从映射中返回适当的字符串表示

  • 实现一个ParseType函数,它使用映射将字符串转换为你的类型

现在,我们将编写一个枚举器来表示我们 API 中的成本级别。在根meander文件夹内创建一个名为cost_level.go的新文件,并添加以下代码:

package meander 
type Cost int8 
const ( 
  _ Cost = iota 
  Cost1 
  Cost2 
  Cost3 
  Cost4 
  Cost5 
) 

在这里,我们定义了枚举器的类型,我们称之为Cost,由于我们只需要表示几个值,所以我们基于int8的范围。对于需要更大值的枚举器,你可以自由地使用任何与iota一起工作的整数类型。Cost类型现在是一个真正的类型,我们可以在需要表示支持的值的地方使用它,例如,我们可以将Cost类型作为函数的参数,或者我们可以将其用作结构体字段的类型。

我们然后定义该类型的一组常量,并使用iota关键字来指示我们想要常量的递增值。通过忽略第一个iota值(它总是零),我们表明必须显式使用指定的常量而不是零值。

为了提供枚举的字符串表示,我们只需要在Cost类型中添加一个String方法。即使你不需要在代码中使用这些字符串,这也是一个有用的练习,因为每次你使用 Go 标准库中的打印调用(如fmt.Println)时,默认情况下都会使用数值。通常,这些值没有意义,你需要查找它们,甚至数行来确定每个项目的数值。

注意

关于 Go 中String()方法的更多信息,请参考fmt包中的StringerGoStringer接口,网址为golang.org/pkg/fmt/#Stringer

测试驱动的枚举器

为了确保我们的枚举代码工作正确,我们将编写一些单元测试来对预期行为进行断言。

cost_level.go旁边添加一个名为cost_level_test.go的新文件,并添加以下单元测试:

package meander_test 
import ( 
  "testing" 
  "github.com/cheekybits/is" 
  "path/to/meander" 
) 
func TestCostValues(t *testing.T) { 
  is := is.New(t) 
  is.Equal(int(meander.Cost1), 1) 
  is.Equal(int(meander.Cost2), 2) 
  is.Equal(int(meander.Cost3), 3) 
  is.Equal(int(meander.Cost4), 4) 
  is.Equal(int(meander.Cost5), 5) 
} 

你需要运行go get来获取 CheekyBits 的is包(来自github.com/cheekybits/is)。

小贴士

is包是一个替代的测试辅助包,但这个包非常简单,故意保持基础。当你编写自己的项目或根本不使用它时,你可以选择你喜欢的。

通常,我们不会担心枚举器中常量的实际整数值,但鉴于 Google Places API 使用数值来表示相同的内容,我们需要关注这些值。

注意

你可能已经注意到了这个测试文件的一些奇怪之处,它打破了常规。尽管它位于根meander文件夹中,但它不是meander包的一部分;相反,它在meander_test中。

在 Go 中,这除了测试之外的所有情况下都是错误。因为我们把测试代码放入了自己的包中,这意味着我们不再能够访问meander包的内部。注意我们如何使用包前缀。这看起来可能是一个缺点,但实际上,它允许我们确信我们正在像真实用户一样测试这个包。我们只能调用导出方法,并且只能看到导出类型;就像我们的用户一样。我们无法对内部进行操作来做用户无法做的事情;这是一个真正的用户测试。在测试中,有时你需要调整内部状态,在这种情况下,你的测试需要与代码在同一个包中。

通过在终端中运行go test来运行测试,并注意它通过了。

让我们添加另一个测试来对每个Cost常量的字符串表示进行断言。在cost_level_test.go中添加以下单元测试:

func TestCostString(t *testing.T) { 
  is := is.New(t) 
  is.Equal(meander.Cost1.String(), "$") 
  is.Equal(meander.Cost2.String(), "$$") 
  is.Equal(meander.Cost3.String(), "$$$") 
  is.Equal(meander.Cost4.String(), "$$$$") 
  is.Equal(meander.Cost5.String(), "$$$$$") 
} 

这个测试断言对每个常量调用String方法会得到预期的值。当然,运行这些测试将会失败,因为我们还没有实现String方法。

Cost常量下方添加以下映射和String方法:

var costStrings = map[string]Cost{ 
  "$":     Cost1, 
  "$$":    Cost2, 
  "$$$":   Cost3, 
  "$$$$":  Cost4, 
  "$$$$$": Cost5, 
} 
func (l Cost) String() string { 
  for s, v := range costStrings { 
    if l == v { 
      return s 
    } 
  } 
  return "invalid" 
} 

map[string]Cost变量将成本值映射到字符串表示形式,而String方法遍历映射以返回适当的值。

提示

在我们的情况下,简单的strings.Repeat("$", int(l))返回将同样有效(并且因为代码更简单而获胜);但通常不会这样;因此,本节探讨了通用方法。

如果我们现在打印出Cost3值,我们实际上会看到$$$,这比数值更有用。由于我们想在 API 中使用这些字符串,我们还将添加一个ParseCost方法。

cost_value_test.go中添加以下单元测试:

func TestParseCost(t *testing.T) { 
  is := is.New(t) 
  is.Equal(meander.Cost1, meander.ParseCost("$")) 
  is.Equal(meander.Cost2, meander.ParseCost("$$")) 
  is.Equal(meander.Cost3, meander.ParseCost("$$$")) 
  is.Equal(meander.Cost4, meander.ParseCost("$$$$")) 
  is.Equal(meander.Cost5, meander.ParseCost("$$$$$")) 
} 

在这里,我们断言调用ParseCost将根据输入字符串产生适当的价值。

cost_value.go中添加以下实现代码:

func ParseCost(s string) Cost { 
  return costStrings[s] 
} 

解析Cost字符串非常简单,因为我们的映射就是这样布局的。

由于我们需要表示一系列成本值,让我们想象一个CostRange类型,并编写我们打算如何使用它的测试。将以下测试添加到cost_value_test.go中:

func TestParseCostRange(t *testing.T) { 
  is := is.New(t) 
  var l meander.CostRange 
  var err error 
  l, err = meander.ParseCostRange("$$...$$$") 
  is.NoErr(err) 
  is.Equal(l.From, meander.Cost2) 
  is.Equal(l.To, meander.Cost3) 
  l, err = meander.ParseCostRange("$...$$$$$") 
  is.NoErr(err) 
  is.Equal(l.From, meander.Cost1) 
  is.Equal(l.To, meander.Cost5) 
} 
func TestCostRangeString(t *testing.T) { 
  is := is.New(t) 
  r := meander.CostRange{ 
    From: meander.Cost2, 
    To:   meander.Cost4, 
  } 
  is.Equal("$$...$$$$", r.String()) 
} 

我们指定,传入一个以两个美元符号开头,然后是三个点,最后是三个美元符号的字符串应该创建一个新的meander.CostRange类型,其中From设置为meander.Cost2To设置为meander.Cost3。我们还使用is.NoErr来断言在解析我们的字符串时不会返回错误。第二个测试通过测试CostRange.String方法,该方法返回适当的值。

为了使我们的测试通过,添加以下CostRange类型及其相关的StringParseString函数:

type CostRange struct { 
  From Cost 
  To   Cost 
} 
func (r CostRange) String() string { 
  return r.From.String() + "..." + r.To.String() 
} 
func ParseCostRange(s string) (CostRange, error) { 
  var r CostRange 
  segs := strings.Split(s, "...") 
  if len(segs) != 2 { 
    return r, errors.New("invalid cost range") 
  } 
  r.From = ParseCost(segs[0]) 
  r.To = ParseCost(segs[1]) 
  return r, nil 
} 

这允许我们将类似于$...$$$$$的字符串转换为包含两个Cost值的结构:一个From和一个To,反之亦然。如果有人传入无效的成本范围(我们只是在点分割后的段数上执行简单的检查),则返回错误。如果您想进行额外的检查,例如确保字符串中只包含点和美元符号,您也可以在这里进行。

查询 Google Places API

现在我们能够表示 API 的结果,我们需要一种方式来表示和启动实际的查询。将以下结构添加到query.go中:

type Query struct { 
  Lat          float64 
  Lng          float64 
  Journey      []string 
  Radius       int 
  CostRangeStr string 
} 

此结构包含构建查询所需的所有信息,所有这些信息实际上都来自客户端请求中的 URL 参数。接下来,添加以下find方法,该方法将负责向 Google 服务器发送实际请求:

func (q *Query) find(types string) (*googleResponse, error) { 
  u :=  "https://maps.googleapis.com/maps/api/place/nearbysearch/json" 
  vals := make(url.Values) 
  vals.Set("location", fmt.Sprintf("%g,%g", q.Lat, q.Lng)) 
  vals.Set("radius", fmt.Sprintf("%d", q.Radius)) 
  vals.Set("types", types) 
  vals.Set("key", APIKey) 
  if len(q.CostRangeStr) > 0 { 
    r, err := ParseCostRange(q.CostRangeStr) 
    if err != nil { 
      return nil, err 
    } 
    vals.Set("minprice", fmt.Sprintf("%d", int(r.From)-1)) 
    vals.Set("maxprice", fmt.Sprintf("%d", int(r.To)-1)) 
  } 
  res, err := http.Get(u + "?" + vals.Encode()) 
  if err != nil { 
    return nil, err 
  } 
  defer res.Body.Close() 
  var response googleResponse 
  if err := json.NewDecoder(res.Body).Decode(&response); err != nil { 
    return nil, err 
  } 
  return &response, nil 
} 

首先,我们根据 Google Places API 规范构建请求 URL,通过附加latlngradius和当然,APIKey值的url.Values编码字符串。

注意

url.Values类型实际上是map[string][]string类型,这就是为什么我们使用make而不是new

我们作为参数指定的 types 值表示要查找的业务类型。如果有 CostRangeStr,我们将其解析并设置 minpricemaxprice 值,最后调用 http.Get 实际发出请求。如果请求成功,我们延迟关闭响应体,并使用 json.Decoder 方法将 API 返回的 JSON 解码到我们的 googleResponse 类型中。

构建推荐

接下来,我们需要编写一个方法,允许我们针对旅程中的不同步骤进行多次查找调用。在 find 方法下方,向 Query 结构体添加以下 Run 方法:

// Run runs the query concurrently, and returns the results. 
func (q *Query) Run() []interface{} { 
  rand.Seed(time.Now().UnixNano()) 
  var w sync.WaitGroup 
  var l sync.Mutex 
  places := make([]interface{}, len(q.Journey)) 
  for i, r := range q.Journey { 
    w.Add(1) 
    go func(types string, i int) { 
      defer w.Done() 
      response, err := q.find(types) 
      if err != nil { 
        log.Println("Failed to find places:", err) 
        return 
      } 
      if len(response.Results) == 0 { 
        log.Println("No places found for", types) 
        return 
      } 
      for _, result := range response.Results { 
        for _, photo := range result.Photos { 
          photo.URL =    
            "https://maps.googleapis.com/maps/api/place/photo?" + 
            "maxwidth=1000&photoreference=" + photo.PhotoRef + "&key=" 
             + APIKey 
        } 
      } 
      randI := rand.Intn(len(response.Results)) 
      l.Lock() 
      places[i] = response.Results[randI] 
      l.Unlock() 
    }(r, i) 
  } 
  w.Wait() // wait for everything to finish 
  return places 
} 

我们首先将随机种子设置为自 1970 年 1 月 1 日 UTC 以来纳秒级的当前时间。这确保了每次我们调用 Run 方法并使用 rand 包时,结果都会不同。如果我们不这样做,我们的代码每次都会提出相同的建议,这违背了初衷。

由于我们需要向 Google 发送许多请求,并且我们希望尽可能快地完成,我们将通过并发调用我们的 Query.find 方法同时运行所有查询。因此,接下来,我们创建 sync.WaitGroup 和一个映射来存储选定的地点,以及一个 sync.Mutex 方法,以允许许多 goroutines 安全地并发访问映射。

然后,我们遍历 Journey 切片中的每个项目,该项目可能是 barcafemovie_theater。对于每个项目,我们向 WaitGroup 对象添加 1 并启动一个 goroutine。在例程内部,我们首先调用 defer w.Done,通知 WaitGroup 对象在调用我们的 find 方法进行实际请求之前,此请求已完成。假设没有错误发生并且确实找到了一些地点,我们遍历结果并为可能存在的任何照片构建一个可用的 URL。根据 Google Places API,我们得到了一个 photoreference 键,我们可以使用它来在另一个 API 调用中获取实际图像。为了使我们的客户端不必了解 Google Places API,我们为他们构建完整的 URL。

然后,我们锁定映射锁,通过调用 rand.Intn 随机选择一个选项并将其插入到 places 切片的正确位置,然后解锁 sync.Mutex

最后,我们在返回地点之前,通过调用 w.Wait 等待所有 goroutines 完成。

使用查询参数的处理程序

现在,我们需要连接我们的 /recommendations 调用,所以回到 cmd/meander 文件夹中的 main.go 并在 main 函数中添加以下代码:

http.HandleFunc("/recommendations", cors(func(w 
http.ResponseWriter, r *http.Request) { 
  q := &meander.Query{ 
    Journey: strings.Split(r.URL.Query().Get("journey"), "|"), 
  } 
  var err error 
  q.Lat, err = strconv.ParseFloat(r.URL.Query().Get("lat"), 64) 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusBadRequest) 
    return 
  } 
  q.Lng, err = strconv.ParseFloat(r.URL.Query().Get("lng"), 64) 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusBadRequest) 
    return 
  } 
  q.Radius, err = strconv.Atoi(r.URL.Query().Get("radius")) 
  if err != nil { 
    http.Error(w, err.Error(), http.StatusBadRequest) 
    return 
  } 
  q.CostRangeStr = r.URL.Query().Get("cost") 
  places := q.Run() 
  respond(w, r, places) 
})) 

此处理程序负责准备 meander.Query 对象并在响应结果之前调用其 Run 方法。http.Request 类型的 URL 值公开了提供 Get 方法的 Query 数据,该 Get 方法反过来查找给定键的值。

路径字符串是从 bar|cafe|movie_theater 格式通过管道字符分割转换为一个字符串切片。然后,通过调用 strconv 包中的几个函数,将字符串纬度、经度和半径值转换为数值类型。如果值格式不正确,我们将得到一个错误,然后我们将使用带有 http.StatusBadRequest 状态的 http.Error 辅助函数将错误写入客户端。

CORS

我们 API 第一版中的最后一部分是实现 CORS,就像我们在上一章中所做的那样。在你阅读下一节关于解决方案的说明之前,看看你是否能自己解决这个问题。

小贴士

如果你打算自己解决这个问题,请记住你的目标是设置 Access-Control-Allow-Origin 响应头为 *。同时,考虑我们在上一章中做的 http.HandlerFunc 包装。这段代码的最佳位置可能是在 cmd/meander 程序中,因为它是通过 HTTP 端点公开功能的地方。

main.go 文件中,添加以下 cors 函数:

func cors(f http.HandlerFunc) http.HandlerFunc { 
  return func(w http.ResponseWriter, r *http.Request) { 
    w.Header().Set("Access-Control-Allow-Origin", "*") 
    f(w, r) 
  } 
} 

这种熟悉的模式接受一个 http.HandlerFunc 类型,并在调用传入的函数之前设置适当的头信息,然后返回一个新的函数。现在,我们可以修改我们的代码,确保 cors 函数在我们的两个端点都被调用。更新 main 函数中的相应行:

func main() { 
  meander.APIKey = "YOUR_API_KEY" 
  http.HandleFunc("/journeys", cors(func(w http.ResponseWriter,
  r *http.Request) 
  { 
    respond(w, r, meander.Journeys) 
  })) 
  http.HandleFunc("/recommendations", cors(func(w http.ResponseWriter, 
  r *http.Request) { 
    q := &meander.Query{ 
      Journey: strings.Split(r.URL.Query().Get("journey"), "|"), 
    } 
    var err error 
    q.Lat, err = strconv.ParseFloat(r.URL.Query().Get("lat"), 64) 
    if err != nil { 
      http.Error(w, err.Error(), http.StatusBadRequest) 
      return 
    } 
    q.Lng, err = strconv.ParseFloat(r.URL.Query().Get("lng"), 64) 
    if err != nil { 
      http.Error(w, err.Error(), http.StatusBadRequest) 
      return 
    } 
    q.Radius, err = strconv.Atoi(r.URL.Query().Get("radius")) 
    if err != nil { 
      http.Error(w, err.Error(), http.StatusBadRequest) 
      return 
    } 
    q.CostRangeStr = r.URL.Query().Get("cost") 
    places := q.Run() 
    respond(w, r, places) 
  })) 
  log.Println("serving meander API on :8080") 
  http.ListenAndServe(":8080", http.DefaultServeMux) 
} 

现在,我们的 API 调用将允许来自任何域,而不会发生跨域错误。

小贴士

你能否找到一种方法,通过移除对 r.URL.Query() 的多次调用来使代码更智能?也许你可以这样做一次,并将结果缓存到局部变量中。然后,你可以避免多次解析查询。

测试我们的 API

现在我们准备测试我们的 API,请前往控制台并导航到 cmd/meander 文件夹。因为我们的程序导入了 meander 包,所以构建程序将自动构建我们的 meander 包。

构建并运行程序:

go build -o meanderapi
./meanderapi

为了从我们的 API 中获得有意义的输出,让我们花一分钟时间找到你熟悉的实际纬度和经度。前往 mygeoposition.com/ 并使用网络工具获取你熟悉位置的 x,y 值。

或者,从这些流行的城市中选择:

  • 英国伦敦:51.520707 x 0.153809

  • 美国纽约:40.7127840 x -74.0059410

  • 日本东京:35.6894870 x 139.6917060

  • 美国旧金山:37.7749290 x -122.4194160

现在,打开一个网页浏览器,并使用适当的字段值访问 /recommendations 端点:

http://localhost:8080/recommendations? 
  lat=51.520707&lng=-0.153809&radius=5000& 
  journey=cafe|bar|casino|restaurant& 
  cost=$...$$$ 

以下截图显示了伦敦附近的一个示例推荐可能的样子:

测试我们的 API

随意尝试调整 URL 中的值,通过尝试不同的旅程字符串、调整位置和尝试不同的成本范围值字符串,来查看这个简单的 API 有多强大。

网络应用程序

我们将下载一个完全按照相同 API 规范构建的完整网络应用程序,并将其指向我们的实现,以便在我们眼前看到它变得生动。请访问 github.com/matryer/goblueprints/tree/master/chapter7/meanderweb,并将 meanderweb 项目下载到您的 GOPATH 文件夹中(与您的根 meander 文件夹放在一起即可)。

在终端中导航到 meanderweb 文件夹,构建并运行它:

go build -o meanderweb
./meanderweb

这将启动一个运行在 localhost:8081 的网站,它硬编码为查找运行在 localhost:8080 的 API。因为我们添加了 CORS 支持,所以尽管它们运行在不同的域上,这不会成为问题。

打开浏览器访问 http://localhost:8081/ 并与应用程序进行交互;虽然有人构建了用户界面,但没有我们构建的用于提供动力的 API,它将毫无用处。

摘要

在本章中,我们构建了一个 API,它消费并抽象了 Google Places API,为用户提供了一种有趣且有趣的方式来规划他们的白天和晚上。

我们首先编写了一些简单而简短的用户故事,描述了我们希望在非常高的层面上实现的目标,而没有试图事先设计实现方案。为了并行化项目,我们同意将项目会议点定在 API 设计上,并朝着这个方向构建(正如我们的合作伙伴所做的那样)。

我们直接在代码中嵌入数据,避免了在项目早期阶段调查、设计和实现数据存储的需求。通过关注如何访问这些数据(通过 API 端点),我们允许未来的自己完全改变数据的存储方式和位置,而不会破坏任何使用我们的 API 编写的应用程序。

我们实现了 Facade 接口,它允许我们的结构体和其他类型提供它们的公共表示,而不透露关于我们实现的混乱或敏感细节。

我们对枚举器的探索为我们构建枚举类型提供了一个有用的起点,尽管语言中没有官方支持。我们使用的 iota 关键字使我们能够指定我们自己的数值类型的常量,具有递增的值。我们实现的常见 String 方法向我们展示了如何确保我们的枚举类型不会成为日志中的晦涩数字。同时,我们还看到了一个真实世界的 TDD 和红/绿编程的例子,我们编写了首先失败的单元测试,然后通过编写实现代码使它们通过。

在下一章中,我们将暂时放下网络服务,构建一个代码备份工具,我们将探讨 Go 语言如何使我们轻松地与本地文件系统交互。

第八章. 文件系统备份

有许多提供文件系统备份功能的解决方案。这些包括从像 Dropbox、Box 和 Carbonite 这样的应用程序到像苹果的 Time Machine、Seagate 或网络附加存储产品等硬件解决方案,仅举几例。大多数消费级工具提供一些关键自动功能,以及一个应用程序或网站供您管理您的策略和内容。通常,特别是对于开发者来说,这些工具并不完全符合我们的需求。然而,多亏了 Go 的标准库(包括 ioutilos 等包),我们拥有了构建一个完全符合我们需求的备份解决方案所需的一切。

对于我们的下一个项目,我们将为我们的源代码项目构建一个简单的文件系统备份,它将归档指定的文件夹,并在我们每次进行更改时保存它们的快照。更改可能是当我们调整文件并保存它时,当我们添加新文件和文件夹时,甚至当我们删除一个文件时。我们希望能够回到任何时间点去检索旧文件。

具体来说,在本章中,你将学习以下主题:

  • 如何构建由包和命令行工具组成的项目的结构

  • 在工具执行之间持久化简单数据的一种实用方法

  • os 包如何允许你与文件系统交互

  • 如何在尊重 Ctrl + C 的情况下运行无限定时循环的代码

  • 如何使用 filepath.Walk 遍历文件和文件夹

  • 如何快速确定目录内容是否已更改

  • 如何使用 archive/zip 包来压缩文件

  • 如何构建关注命令行标志和常规参数组合的工具

解决方案设计

我们将首先列出我们解决方案的一些高级验收标准和我们想要采取的方法:

  • 解决方案应在我们更改源代码项目时定期创建我们文件的快照

  • 我们希望控制检查目录更改的间隔

  • 代码项目主要是基于文本的,所以将目录压缩以生成存档将节省大量空间

  • 我们将快速构建这个项目,同时密切关注我们可能希望在以后进行改进的地方

  • 我们做出的任何实现决策都应易于修改,如果我们决定在未来更改我们的实现

  • 我们将构建两个命令行工具:一个执行工作的后端守护程序和一个用户交互实用程序,它将允许我们列出、添加和从备份服务中删除路径

项目结构

在 Go 解决方案中,在单个项目中既有允许其他 Go 程序员使用你的功能的包,又有允许最终用户使用你的程序的命令行工具是很常见的。

正如我们在上一章中看到的,一个用于结构化此类项目的约定正在出现,即我们将包放在主项目文件夹中,将命令行工具放在名为 cmdcmds 的子文件夹中(如果你有多个命令)。由于在 Go 中所有包都是平等的(无论目录树如何),你可以从命令子包中导入包,知道你永远不需要从项目包中导入命令(这是非法的,因为你不能有循环依赖)。这看起来可能像是一个不必要的抽象,但实际上这是一个相当常见的模式,可以在标准 Go 工具链的示例中看到,例如 gofmtgoimports

例如,对于我们的项目,我们将编写一个名为 backup 的包和两个命令行工具:守护程序和用户交互工具。我们将以以下方式组织我们的项目:

/backup - package 
/backup/cmds/backup - user interaction tool 
/backup/cmds/backupd - worker daemon 

小贴士

我们没有直接将代码放在 cmd 文件夹中(即使我们只有一个命令)的原因是,当 go install 构建项目时,它使用文件夹的名称作为命令名称,如果所有的工具都被称为 cmd,那就不会很有用。

备份包

我们首先将编写 backup 包,当我们编写相关的工具时,我们将成为该包的第一个客户。该包将负责决定目录是否已更改并需要备份,以及实际执行备份过程。

首先考虑明显的接口

在开始一个新的 Go 程序时,早期需要考虑的一件事是是否有任何接口显得突出。我们不想过度抽象或浪费太多时间在设计一些我们知道在编码开始时会改变的东西,但这并不意味着我们不应该寻找值得提取的明显概念。如果你不确定,这是完全可以接受的;你应该使用具体类型编写代码,并在实际解决问题后重新审视潜在的抽象。

然而,由于我们的代码将归档文件,Archiver 接口就凸显为一个候选者。

在你的 GOPATH/src 文件夹内创建一个新的文件夹,命名为 backup,并添加以下 archiver.go 代码:

package backup 
type Archiver interface { 
  Archive(src, dest string) error 
} 

Archiver 接口将指定一个名为 Archive 的方法,该方法接受源路径和目标路径,并返回一个错误。该接口的实现将负责归档源文件夹并将其存储在目标路径中。

注意

在一开始就定义一个接口是一个很好的方法,可以将一些概念从我们的脑海中提取出来并放入代码中;只要我们记得简单接口的力量,这个接口就可以随着我们解决方案的演变而改变。此外,记住 io 包中的大多数 I/O 接口只公开一个方法。

从一开始,我们就提出了这样的观点:虽然我们将实现 ZIP 文件作为我们的归档格式,但我们很容易将其稍后与另一种 Archiver 格式交换。

通过实现接口来测试接口

现在我们有了 Archiver 类型的接口,我们将实现一个使用 ZIP 文件格式的类型。

将以下 struct 定义添加到 archiver.go

type zipper struct{} 

我们不会导出这个类型,这可能会让你得出结论,即包外部的用户无法使用它。实际上,我们将为他们提供一个类型的实例,让他们可以使用,以避免他们必须担心创建和管理自己的类型。

添加以下导出实现:

// Zip is an Archiver that zips and unzips files. 
var ZIP Archiver = (*zipper)(nil) 

ZIP of type Archiver, so from outside the package, it's pretty clear that we can use that variable wherever Archiver is needed if you want to zip things. Then, we assign it with nil cast to the type *zipper. We know that nil takes no memory, but since it's cast to a zipper pointer, and given that our zipper struct has no state, it's an appropriate way of solving a problem, which hides the complexity of code (and indeed the actual implementation) from outside users. There is no reason anybody outside of the package needs to know about our zipper type at all, which frees us up to change the internals without touching the externals at any time: the true power of interfaces.

这个技巧的另一个实用的副作用是,编译器现在将检查我们的 zipper 类型是否正确实现了 Archiver 接口,所以如果你尝试构建这段代码,你会得到编译器错误:

./archiver.go:10: cannot use (*zipper)(nil) (type *zipper) as type 
    Archiver in assignment:
 *zipper does not implement Archiver (missing Archive method)

我们看到我们的 zipper 类型没有实现接口中规定的 Archive 方法。

提示

你也可以在测试代码中使用 Archive 方法来确保你的类型实现了它们应该实现的接口。如果你不需要使用这个变量,你可以总是用一个下划线来丢弃它,你仍然会得到编译器的帮助:

var _ Interface = (*Implementation)(nil)

为了让编译器满意,我们将为我们的 zipper 类型添加 Archive 方法的实现。

将以下代码添加到 archiver.go

func (z *zipper) Archive(src, dest string) error { 
  if err := os.MkdirAll(filepath.Dir(dest), 0777); err != nil { 
    return err 
  } 
  out, err := os.Create(dest) 
  if err != nil { 
    return err 
  } 
  defer out.Close() 
  w := zip.NewWriter(out) 
  defer w.Close() 
  return filepath.Walk(src, func(path string, info os.FileInfo, err error) 
  error { 
    if info.IsDir() { 
      return nil // skip 
    } 
    if err != nil { 
      return err 
    } 
    in, err := os.Open(path) 
    if err != nil { 
      return err 
    } 
    defer in.Close() 
    f, err := w.Create(path) 
    if err != nil { 
      return err 
    } 
    _, err = io.Copy(f, in) 
    if err != nil { 
      return err 
    } 
    return nil 
  }) 
} 

你还必须从 Go 标准库中导入 archive/zip 包。在我们的 Archive 方法中,我们采取以下步骤来准备写入 ZIP 文件:

  • 使用 os.MkdirAll 确保目标目录存在。0777 代码表示你可能需要创建任何缺失目录的文件权限

  • 使用 os.Create 创建一个新的文件,该文件由 dest 路径指定

  • 如果文件创建没有错误,使用 defer out.Close() 延迟关闭文件

  • 使用 zip.NewWriter 创建一个新的 zip.Writer 类型,该类型将写入我们刚刚创建的文件,并延迟关闭写入器

一旦我们有了准备好的 zip.Writer 类型,我们使用 filepath.Walk 函数遍历源目录 src

filepath.Walk 函数接受两个参数:根路径和一个回调函数,该函数将在遍历文件系统时对每个遇到的项(文件和文件夹)进行调用。

提示

函数是 Go 中的第一类类型,这意味着你可以将它们用作参数类型,以及全局函数和方法。filepath.Walk 函数指定第二个参数类型为 filepath.WalkFunc,这是一个具有特定签名的函数。只要我们遵守签名(正确的输入和返回参数),我们就可以编写内联函数,而无需担心 filepath.WalkFunc 类型。

快速查看 Go 源代码告诉我们 filepath.WalkFunc 的签名与我们在 func(path string, info os.FileInfo, err error) error 中传递的函数匹配

filepath.Walk函数是递归的,所以它也会深入到子文件夹中。回调函数本身接受三个参数:文件的完整路径、描述文件或文件夹本身的os.FileInfo对象,以及一个错误(如果发生错误,它也会返回一个错误)。如果回调函数的任何调用返回错误(除了特殊的SkipDir错误值),则操作将被终止,filepath.Walk返回该错误。我们只是将这个错误传递给Archive的调用者,让他们来处理,因为我们已经无能为力了。

对于树中的每个项目,我们的代码采取以下步骤:

  • 如果info.IsDir方法告诉我们该项是一个文件夹,我们就返回nil,实际上跳过了它。没有理由将文件夹添加到 ZIP 存档中,因为文件的路径会为我们编码这些信息。

  • 如果传递了错误(通过第三个参数),这意味着在尝试访问文件信息时出现了问题。这种情况很少见,所以我们只是返回错误,该错误最终会被传递给Archive的调用者。作为filepath.Walk的实现者,你在这里不需要强制终止操作;你可以自由地做你自己的情况中合理的事情。

  • 使用os.Open打开源文件进行读取,如果成功,则延迟其关闭。

  • ZipWriter对象上调用Create方法,表示我们想要创建一个新的压缩文件,并给出文件的完整路径,包括它嵌套在内的目录。

  • 使用io.Copy读取源文件的所有字节,并通过ZipWriter对象将它们写入我们之前打开的 ZIP 文件。

  • 返回nil以表示没有错误。

本章不会涵盖单元测试或测试驱动开发TDD)实践,但你可以自由编写测试来确保我们的实现确实做了它应该做的事情。

小贴士

由于我们正在编写一个包,花些时间注释一下到目前为止导出的部分。你可以使用golint来帮助你找到可能遗漏的任何内容。

文件系统是否已更改?

我们备份系统最大的问题之一是决定一个文件夹是否在跨平台、可预测和可靠的方式下发生了变化。毕竟,如果没有与上一次备份不同的内容,创建备份就没有意义。当我们思考这个问题时,会想到以下几点:我们是否只需检查顶级文件夹的最后修改日期?我们是否应该使用系统通知来告知我们关心的文件何时发生变化?这两种方法都存在问题,而且事实证明,这并不是一个简单的问题。

小贴士

查看位于 fsnotify.org(项目源:github.com/fsnotify)的 fsnotify 项目。作者们正在尝试构建一个跨平台的包,用于订阅文件系统事件。在撰写本文时,该项目仍处于起步阶段,并且不是本章的可行选项,但将来,它可能会成为文件系统事件的行业标准解决方案。

我们将生成一个由我们在考虑是否发生变化时关心的所有信息组成的 MD5 哈希值。

查看 os.FileInfo 类型,我们可以看到我们可以了解关于文件或文件夹的大量信息:

type FileInfo interface { 
  Name() string       // base name of the file 
  Size() int64        // length in bytes for regular files;  
                         system-dependent for others 
  Mode() FileMode     // file mode bits 
  ModTime() time.Time // modification time 
  IsDir() bool        // abbreviation for Mode().IsDir() 
  Sys() interface{}   // underlying data source (can return nil) 
} 

为了确保我们了解文件夹中任何文件的多种更改,哈希值将由文件名和路径(如果它们重命名了一个文件,哈希值将不同)组成,大小(如果文件更改大小,它显然不同),最后修改日期,项目是文件还是文件夹,以及文件模式位。即使我们不会存档文件夹,我们仍然关心它们的名称和文件夹的树状结构。

创建一个名为 dirhash.go 的新文件,并添加以下函数:

package backup 
import ( 
  "crypto/md5" 
  "fmt" 
  "io" 
  "os" 
  "path/filepath" 
) 
func DirHash(path string) (string, error) { 
  hash := md5.New() 
  err := filepath.Walk(path, func(path string, info os.FileInfo, err error) 
  error { 
    if err != nil { 
      return err 
    } 
    io.WriteString(hash, path) 
    fmt.Fprintf(hash, "%v", info.IsDir()) 
    fmt.Fprintf(hash, "%v", info.ModTime()) 
    fmt.Fprintf(hash, "%v", info.Mode()) 
    fmt.Fprintf(hash, "%v", info.Name()) 
    fmt.Fprintf(hash, "%v", info.Size()) 
    return nil 
  }) 
  if err != nil { 
    return "", err 
  } 
  return fmt.Sprintf("%x", hash.Sum(nil)), nil 
} 

我们首先创建一个新的 hash.Hash 函数,该函数知道如何计算 MD5 值,然后再使用 filepath.Walk 再次遍历指定路径目录内的所有文件和文件夹。对于每个项目,假设没有错误发生,我们使用 io.WriteString 将差异信息写入哈希生成器,这允许我们将字符串写入 io.Writer,同时使用 fmt.Fprintf 执行相同的操作,但同时也暴露了格式化功能,使我们能够使用 %v 格式说明符为每个项目生成默认值格式。

一旦每个文件都已被处理,并且假设没有错误发生,我们随后使用 fmt.Sprintf 生成结果字符串。hash.Hash 中的 Sum 方法通过附加指定的值来计算最终的哈希值。在我们的情况下,我们不想附加任何内容,因为我们已经添加了我们关心的所有信息,所以我们只传递 nil%x 格式说明符表示我们希望值以十六进制(基数为 16)和小写字母的形式表示。这是表示 MD5 哈希的常用方式。

检查更改并启动备份

现在我们有了对文件夹进行哈希处理和执行备份的能力,我们将这两个功能结合在一个新的类型 Monitor 中。Monitor 类型将包含路径及其相关哈希值的映射,对任何 Archiver 类型(当然,我们现在将使用 backup.ZIP)的引用,以及表示存档位置的字符串。

创建一个名为 monitor.go 的新文件,并添加以下定义:

type Monitor struct { 
  Paths       map[string]string 
  Archiver    Archiver 
  Destination string 
} 

为了触发更改检查,我们将添加以下 Now 方法:

func (m *Monitor) Now() (int, error) { 
  var counter int 
  for path, lastHash := range m.Paths { 
    newHash, err := DirHash(path) 
    if err != nil { 
      return counter, err 
    } 
    if newHash != lastHash { 
      err := m.act(path) 
      if err != nil { 
        return counter, err 
      } 
      m.Paths[path] = newHash // update the hash 
      counter++ 
    } 
  } 
  return counter, nil 
} 

Now方法遍历映射中的每个路径,并生成该文件夹的最新哈希值。如果哈希值与映射中上一次检查生成的哈希值不匹配,则认为它已更改,需要再次备份。我们在调用尚未编写的act方法之前这样做,然后使用这个新哈希值更新映射中的哈希值。

为了给用户一个关于他们调用Now时发生了什么的概述,我们还在维护一个计数器,每次我们备份一个文件夹时都会增加这个计数器。我们将在稍后使用这个计数器来让我们的最终用户了解系统正在做什么,而不会用信息轰炸他们:

m.act undefined (type *Monitor has no field or method act) 

编译器再次帮助我们并提醒我们尚未添加act方法:

func (m *Monitor) act(path string) error { 
  dirname := filepath.Base(path) 
  filename := fmt.Sprintf("%d.zip", time.Now().UnixNano()) 
  return m.Archiver.Archive(path, filepath.Join(m.Destination,  dirname, filename)) 
} 

由于我们在 ZIP Archiver类型中已经完成了繁重的工作,我们在这里只需要生成一个文件名,决定归档将放在哪里,然后调用Archive方法。

小贴士

如果Archive方法返回错误,act方法和Now方法将分别返回它。这种将错误向上传递的机制在 Go 中非常常见,允许你处理可以采取一些有用措施来恢复的情况,或者将问题推迟给其他人。

前面代码中的act方法使用time.Now().UnixNano()生成时间戳文件名,并硬编码了.zip扩展名。

硬编码在短时间内是可以接受的

就像我们现在这样硬编码文件扩展名在开始时是可以接受的,但如果你这么想,我们在某种程度上混合了关注点。如果我们更改Archiver实现以使用 RAR 或我们制作的压缩格式,.zip扩展名就不再合适了。

小贴士

在继续阅读之前,思考一下你可能会采取哪些步骤来避免这种硬编码。文件名扩展的决定在哪里?你需要做出哪些更改才能避免硬编码?

文件名扩展的决定可能应该在Archiver接口中,因为它知道将要进行的归档类型。因此,我们可以添加一个Ext()字符串方法,并从我们的act方法中访问它。但我们可以通过允许Archiver作者指定整个文件名格式而不是仅指定扩展名来添加一些额外的功能,而无需做太多额外的工作。

archiver.go中,更新Archiver接口定义:

type Archiver interface { 
  DestFmt() string 
  Archive(src, dest string) error 
} 

我们的zipper类型现在需要实现以下功能:

func (z *zipper) DestFmt() string { 
  return "%d.zip" 
} 

现在我们可以要求act方法从Archiver接口获取整个格式字符串,更新act方法:

func (m *Monitor) act(path string) error { 
  dirname := filepath.Base(path) 
  filename := fmt.Sprintf(m.Archiver.DestFmt(), time.Now().UnixNano()) 
  return m.Archiver.Archive(path, filepath.Join(m.Destination, dirname, 
  filename)) 
} 

用户命令行工具

我们将构建的两个工具中的第一个允许用户为备份守护程序工具(我们稍后将编写)添加、列出和删除路径。你可以公开一个 Web 界面,甚至使用桌面用户界面集成的绑定包,但我们将保持简单,并构建自己的命令行工具。

backup 文件夹内创建一个名为 cmds 的新文件夹,并在其中创建另一个 backup 文件夹,这样你就有 backup/cmds/backup

在我们的新 backup 文件夹内,将以下代码添加到 main.go 文件中:

func main() { 
  var fatalErr error 
  defer func() { 
    if fatalErr != nil { 
      flag.PrintDefaults() 
      log.Fatalln(fatalErr) 
    } 
  }() 
  var ( 
    dbpath = flag.String("db", "./backupdata", "path to database directory") 
  ) 
  flag.Parse() 
  args := flag.Args() 
  if len(args) < 1 { 
    fatalErr = errors.New("invalid usage; must specify command") 
    return 
  } 
} 

我们首先定义了 fatalErr 变量,并延迟执行检查以确保该值是 nil。如果不是,它将打印错误信息以及标志默认值,并以非零状态码退出。然后我们定义了一个名为 db 的标志,它期望在解析标志和获取剩余参数以及确保至少有一个参数之前,提供 filedb 数据库目录的路径。

持久化小数据

为了跟踪我们生成的路径和哈希值,我们需要一种数据存储机制,理想情况下即使在停止和启动程序时也能正常工作。这里有各种各样的选择:从文本文件到完整的水平可扩展数据库解决方案。Go 的简洁性原则告诉我们,在我们的小型备份程序中内置数据库依赖并不是一个好主意;相反,我们应该考虑以最简单的方式解决这个问题。

github.com/matryer/filedb 包正是针对这类问题的一个实验性解决方案。它允许你以非常简单、无模式的数据库方式与文件系统交互。其设计灵感来源于 mgo 等包,并且适用于数据查询需求非常简单的情况。在 filedb 中,数据库是一个文件夹,而集合是一个文件,其中每一行代表一个不同的记录。当然,随着 filedb 项目的不断发展,这一切都可能发生变化,但希望接口不会改变。

注意

在 Go 项目中添加此类依赖项应非常谨慎,因为随着时间的推移,依赖项可能会过时、超出其初始范围或在某些情况下完全消失。虽然听起来有些反直觉,但你应该考虑将几个文件复制并粘贴到你的项目中是否比依赖外部依赖项更好。或者,考虑通过将整个包复制到命令的 vendor 文件夹中来维护依赖项。这类似于存储依赖项的快照,你知道它对你的工具是有效的。

将以下代码添加到 main 函数的末尾:

db, err := filedb.Dial(*dbpath) 
if err != nil { 
  fatalErr = err 
  return 
} 
defer db.Close() 
col, err := db.C("paths") 
if err != nil { 
  fatalErr = err 
  return 
} 

在这里,我们使用 filedb.Dial 函数连接到 filedb 数据库。实际上,这里并没有发生太多事情,除了指定数据库的位置,因为没有真正的数据库服务器需要连接(尽管这可能在将来发生变化,这就是为什么接口中存在这样的规定)。如果连接成功,我们延迟关闭数据库。关闭数据库实际上会做一些事情,因为可能还有需要清理的打开的文件。

按照与 mgo 的模式,接下来我们使用 C 方法指定一个集合,并将其引用保存在 col 变量中。如果在任何点上发生错误,我们将它分配给 fatalErr 变量并返回。

要存储数据,我们将定义一个名为path的类型,该类型将存储完整路径和最后一个哈希值,并使用 JSON 编码将此存储在我们的filedb数据库中。在main函数上方添加以下struct定义:

type path struct { 
  Path string 
  Hash string 
} 

解析参数

当我们调用flag.Args(而不是os.Args)时,我们接收一个不包括标志的参数切片。这允许我们在同一工具中混合标志参数和非标志参数。

我们希望我们的工具能够以下方式使用:

  • 要添加路径:
backup -db=/path/to/db add {path} [paths...]

  • 要删除路径:
backup -db=/path/to/db remove {path} [paths...]

  • 要列出所有路径:
backup -db=/path/to/db list

要实现这一点,因为我们已经处理了标志,所以我们必须检查第一个(非标志)参数。

main函数中添加以下代码:

switch strings.ToLower(args[0]) { 
case "list": 
case "add": 
case "remove": 
} 

在这里,我们简单地根据设置成小写的第一个参数进行切换(如果用户输入backup LIST,我们仍然希望它能够工作)。

列出路径

要列出数据库中的路径,我们将使用路径的col变量的ForEach方法。在list情况中添加以下代码:

var path path 
col.ForEach(func(i int, data []byte) bool { 
  err := json.Unmarshal(data, &path) 
  if err != nil { 
    fatalErr = err 
    return true 
  } 
  fmt.Printf("= %s\n", path) 
  return false 
}) 

我们向ForEach传递一个回调函数,该函数将为该集合中的每个项目被调用。然后我们将其从 JSON 反序列化为我们的path类型,并使用fmt.Printf打印出来。根据filedb接口,我们返回false,这告诉我们返回true将停止迭代,而我们想确保我们列出所有路径。

为您自己的类型提供字符串表示

如果你以这种方式在 Go 中打印结构体,使用%s格式说明符,你可能会得到一些混乱的结果,这些结果难以阅读。然而,如果类型实现了String()字符串方法,它将被使用,我们可以利用这个方法来控制打印的内容。在路径结构体下方添加以下方法:

func (p path) String() string { 
  return fmt.Sprintf("%s [%s]", p.Path, p.Hash) 
} 

这告诉path类型如何将其自身表示为字符串。

添加路径

要添加路径或多个路径,我们将遍历剩余的参数并调用每个的InsertJSON方法。在add情况中添加以下代码:

if len(args[1:]) == 0 { 
  fatalErr = errors.New("must specify path to add") 
  return 
} 
for _, p := range args[1:] { 
  path := &path{Path: p, Hash: "Not yet archived"} 
  if err := col.InsertJSON(path); err != nil { 
    fatalErr = err 
    return 
  } 
  fmt.Printf("+ %s\n", path) 
} 

如果用户没有指定任何其他参数,例如,如果他们只是调用backup add而没有输入任何路径,我们将返回一个致命错误。否则,我们完成工作并打印出路径字符串(以+符号为前缀),以指示它已成功添加。默认情况下,我们将哈希设置为尚未存档字符串字面量,这是一个无效的哈希,但同时也让用户知道它尚未存档,并通知我们的代码(因为文件夹的哈希永远不会等于该字符串)。

删除路径

要删除路径或多个路径,我们使用路径集合的RemoveEach方法。在remove情况中添加以下代码:

var path path 
col.RemoveEach(func(i int, data []byte) (bool, bool) { 
  err := json.Unmarshal(data, &path) 
  if err != nil { 
    fatalErr = err 
    return false, true 
  } 
  for _, p := range args[1:] { 
    if path.Path == p { 
      fmt.Printf("- %s\n", path) 
      return true, false 
    } 
  } 
  return false, false 
}) 

我们提供给RemoveEach的回调函数期望我们返回两个 bool 类型:第一个指示项目是否应该被删除,第二个指示我们是否应该停止迭代。

使用我们的新工具

我们已经完成了简单的 backup 命令行工具。让我们看看它的实际运行情况。在 backup/cmds/backup 内创建一个名为 backupdata 的文件夹;这将成为 filedb 数据库。

在终端中通过导航到 main.go 文件并运行以下命令来构建工具:

go build -o backup

如果一切顺利,我们现在可以添加一个路径:

./backup -db=./backupdata add ./test ./test2

你应该看到预期的输出:

+ ./test [Not yet archived]
+ ./test2 [Not yet archived]

现在让我们添加另一个路径:

./backup -db=./backupdata add ./test3

你现在应该看到完整的列表:

./backup -db=./backupdata list

我们的程序应该产生以下结果:

= ./test [Not yet archived]
= ./test2 [Not yet archived]
= ./test3 [Not yet archived]

让我们移除 test3 以确保 remove 功能正常工作:

./backup -db=./backupdata remove ./test3
./backup -db=./backupdata list

这将带我们回到这里:

+ ./test [Not yet archived]
+ ./test2 [Not yet archived]

现在,我们能够以一种对我们用例有意义的方式与 filedb 数据库进行交互。接下来,我们构建一个守护程序,它将实际使用我们的 backup 包来完成工作。

守护程序备份工具

我们将称为 backupdbackup 工具将负责定期检查 filedb 数据库中列出的路径,对文件夹进行哈希处理以查看是否有任何变化,并使用 backup 包实际执行需要归档的文件夹的归档工作。

backup/cmds/backup 文件夹旁边创建一个名为 backupd 的新文件夹,然后让我们直接进入处理致命错误和标志:

func main() { 
  var fatalErr error 
  defer func() { 
    if fatalErr != nil { 
      log.Fatalln(fatalErr) 
    } 
  }() 
  var ( 
    interval = flag.Duration("interval", 10 * time.Second, "interval between 
    checks") 
    archive  = flag.String("archive", "archive", "path to archive location") 
    dbpath   = flag.String("db", "./db", "path to filedb database") 
  ) 
  flag.Parse() 
} 

你现在应该非常熟悉这种代码了。在指定三个标志:intervalarchivedb 之前,我们推迟处理致命错误。interval 标志表示检查文件夹是否发生变化之间的秒数,archive 标志是 ZIP 文件将要存放的归档位置的路径,而 db 标志是与 backup 命令交互的相同 filedb 数据库的路径。通常的 flag.Parse 调用设置变量并验证我们是否准备好继续。

为了检查文件夹的哈希值,我们需要一个我们之前编写的 Monitor 实例。将以下代码添加到 main 函数中:

m := &backup.Monitor{ 
  Destination: *archive, 
  Archiver:    backup.ZIP, 
  Paths:       make(map[string]string), 
} 

在这里,我们使用 archive 值作为 Destination 类型创建 backup.Monitor。我们将使用 backup.ZIP 归档器并创建一个映射,以便它能够内部存储路径和哈希值。在守护程序启动时,我们希望从数据库中加载路径,这样它就不会在不必要的情况下进行归档,因为我们停止和启动某些操作。

将以下代码添加到 main 函数中:

db, err := filedb.Dial(*dbpath) 
if err != nil { 
  fatalErr = err 
  return 
} 
defer db.Close() 
col, err := db.C("paths") 
if err != nil { 
  fatalErr = err 
  return 
} 

你之前也见过这段代码;它连接到数据库并创建一个对象,使我们能够与 paths 集合交互。如果任何操作失败,我们设置 fatalErr 并返回。

重复的结构

由于我们将使用与我们在用户命令行工具程序中使用的相同路径结构,因此我们需要为这个程序也包含一个定义。在 main 函数上方插入以下结构:

type path struct { 
  Path string 
  Hash string 
} 

LastChecked field to our backupd program so that we can add rules where each folder only gets archived once an hour at most. Our backup program doesn't care about this and will chug along perfectly happy with its view into what fields constitute a path structure.

缓存数据

现在,我们可以查询所有现有的路径并更新Paths映射,这是一种有用的技术,可以增加程序的速度,尤其是在处理缓慢或断开的数据存储时。通过将数据加载到缓存中(在我们的例子中是Paths映射),我们可以在需要信息时以闪电般的速度访问它,而无需每次都咨询文件。

将以下代码添加到main函数的主体中:

var path path 
col.ForEach(func(_ int, data []byte) bool { 
  if err := json.Unmarshal(data, &path); err != nil { 
    fatalErr = err 
    return true 
  } 
  m.Paths[path.Path] = path.Hash 
  return false // carry on 
}) 
if fatalErr != nil { 
  return 
} 
if len(m.Paths) < 1 { 
  fatalErr = errors.New("no paths - use backup tool to add at least one") 
  return 
} 

再次使用ForEach方法允许我们遍历数据库中的所有路径。我们将 JSON 字节反序列化到我们在其他程序中使用的相同的path结构中,并在Paths映射中设置值。假设一切顺利,我们进行最后的检查以确保至少有一条路径,如果没有,我们返回错误。

注意

我们程序的一个限制是,一旦它开始运行,它将不会动态添加路径。守护进程需要重新启动。如果你觉得这很麻烦,你总是可以构建一个机制,定期更新Paths映射或使用其他类型的配置管理。

无限循环

我们接下来需要做的事情是立即对哈希进行检查,看看在进入无限定时循环之前是否需要存档。在这个无限定时循环中,我们会以规定的、规律的间隔再次执行检查。

无限循环听起来像是一个糟糕的想法;实际上,对某些人来说,它听起来像是一个错误。然而,既然我们在这里讨论的是程序内的无限循环,并且由于无限循环可以通过简单的break命令轻松中断,所以它们并不像听起来那么戏剧化。当我们把无限循环与没有默认情况的select语句混合时,我们能够以可管理的方式运行代码,而不会在等待某事发生时消耗 CPU 周期。执行将被阻塞,直到两个通道之一接收数据。

在 Go 语言中,编写一个无限循环就像运行以下代码一样简单:

for {} 

大括号内的指令会不断地被执行,速度取决于运行代码的机器。再次强调,除非你小心地处理你要求它执行的任务,否则这听起来像是一个糟糕的计划。在我们的例子中,我们立即在两个通道上启动一个select情况,它会安全地阻塞,直到其中一个通道有有趣的东西要说。

添加以下代码:

check(m, col) 
signalChan := make(chan os.Signal, 1) 
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) 
for { 
  select { 
  case <-time.After(*interval): 
    check(m, col) 
  case <-signalChan: 
    // stop 
    fmt.Println() 
    log.Printf("Stopping...") 
    return 
  } 
} 

当然,作为负责任的程序员,我们关心当用户终止我们的程序时会发生什么。因此,在调用check方法(该方法尚未存在)之后,我们创建一个信号通道,并使用signal.Notify请求将终止信号发送到通道,而不是自动处理。在我们的无限for循环中,我们选择两种可能性:要么timer通道发送消息,要么终止信号通道发送消息。如果是timer通道的消息,我们再次调用check;如果是signalChan,我们就开始终止程序;否则,我们将循环回并等待。

time.After函数返回一个通道,该通道将在指定时间过后发送一个信号(实际上,是当前时间)。由于我们使用flag.Duration,我们可以直接将这个(通过*解引用)作为time.Duration参数传递给函数。使用flag.Duration还意味着用户可以用人类可读的方式指定时间长度,例如10s代表 10 秒或1m代表一分钟。

最后,我们从主函数返回,导致延迟语句执行,例如关闭数据库连接。

更新 filedb 记录

剩下的就是我们实现check函数,该函数应该调用Monitor类型的Now方法,并在有新哈希值的情况下更新数据库。

main函数下方,添加以下代码:

func check(m *backup.Monitor, col *filedb.C) { 
  log.Println("Checking...") 
  counter, err := m.Now() 
  if err != nil { 
    log.Fatalln("failed to backup:", err) 
  } 
  if counter > 0 { 
    log.Printf("  Archived %d directories\n", counter) 
    // update hashes 
    var path path 
    col.SelectEach(func(_ int, data []byte) (bool, []byte, bool) { 
      if err := json.Unmarshal(data, &path); err != nil { 
        log.Println("failed to unmarshal data (skipping):", err) 
        return true, data, false 
      } 
      path.Hash, _ = m.Paths[path.Path] 
      newdata, err := json.Marshal(&path) 
      if err != nil { 
        log.Println("failed to marshal data (skipping):", err) 
        return true, data, false 
      } 
      return true, newdata, false 
    }) 
  } else { 
    log.Println("  No changes") 
  } 
} 

check函数首先告诉用户正在执行检查,然后立即调用Now。如果Monitor类型为我们做了任何工作,即询问是否存档了任何文件,我们将它们输出给用户,并继续用新值更新数据库。SelectEach方法允许我们通过返回替换字节来更改集合中的每个记录。因此,我们解包字节以获取路径结构,更新哈希值,并返回打包的字节。这确保了下次我们启动backupd进程时,它将使用正确的哈希值。

测试我们的解决方案

让我们看看我们的两个程序是否能够很好地一起工作。你可能需要为这个打开两个终端窗口,因为我们将会运行两个程序。

我们已经将一些路径添加到数据库中,所以让我们使用backup来看看:

./backup -db="./backupdata" list

你应该看到两个测试文件夹;如果没有,请参考添加路径部分:

= ./test [Not yet archived]
= ./test2 [Not yet archived]

在另一个窗口中,导航到backupd文件夹,并创建我们的两个测试文件夹,分别命名为testtest2

使用通常的方法构建backupd

go build -o backupd

假设一切顺利,我们现在可以开始备份过程,确保将db路径指向与backup程序相同的路径,并指定我们想要使用一个名为archive的新文件夹来存储 ZIP 文件。为了测试目的,让我们指定一个5秒的间隔以节省时间:

./backupd -db="../backup/backupdata/" -archive="./archive" - interval=5s

立即,backupd应该检查文件夹,计算哈希值,并注意它们是不同的(与尚未存档不同),并为两个文件夹启动存档过程。它将打印出以下输出:

Checking...
Archived 2 directories

打开在backup/cmds/backupd中新建的archive文件夹,并注意它创建了两个子文件夹:testtest2。在这些文件夹内是空文件夹的压缩存档版本。你可以随意解压一个看看;到目前为止,没有什么特别激动人心的。

同时,回到终端窗口,backupd正在再次检查文件夹是否有变化:

Checking...
 No changes
Checking...
 No changes

在你最喜欢的文本编辑器中,在test2文件夹内创建一个新的文本文件,包含单词test,并将其保存为one.txt。几秒钟后,你会看到backupd已经注意到了新文件,并在archive/test2文件夹内创建了一个新的快照。

当然,由于时间不同,它有一个不同的文件名,但如果你解压它,你会注意到它确实创建了一个文件夹的压缩存档版本。

通过以下操作尝试解决方案:

  • 修改one.txt文件的内容

  • 将一个文件添加到test文件夹中

  • 删除一个文件

摘要

在本章中,我们成功地为你的代码项目构建了一个非常简单的备份系统。你可以看到扩展或修改这些程序的行为是多么简单。你可以继续解决的问题的范围是无限的。

与我们在上一节中使用的本地存档目标文件夹不同,想象一下挂载一个网络存储设备并使用它。突然之间,你有了这些重要文件的异地(或至少非本地)备份。你可以轻松地将 Dropbox 文件夹设置为存档目标,这意味着你不仅可以访问快照,而且一份副本也存储在云端,甚至可以与其他用户共享。

Archiver接口扩展以支持Restore操作(这将仅使用encoding/zip包解压文件)允许你构建可以查看存档并访问单个文件更改的工具,就像 Mac 上的 Time Machine 允许你做的那样。对文件进行索引可以让你在整个代码历史中完成完整的搜索,就像 GitHub 做的那样。

由于文件名是时间戳,你可以将备份的旧存档转移到不太活跃的存储介质,或者将更改总结成每日存档。

显然,备份软件已经存在,经过良好的测试,并且在全球范围内使用,因此专注于解决尚未解决的问题可能是一个明智的选择。但是,当编写小程序来完成这些事情只需要如此少的努力时,它通常值得去做,因为它给你带来的控制力。当你编写代码时,你可以得到你想要的确切结果,而不需要妥协,这取决于每个人自己做出决定。

具体来说,在本章中,我们探讨了 Go 的标准库如何使与文件系统的交互变得简单:打开文件进行读取、创建新文件和创建目录。os包与io包中的强大类型混合,进一步与encoding/zip和其他功能结合,清楚地展示了如何将 Go 接口组合起来以提供非常强大的结果。

第九章:为 Google App Engine 构建 Q&A 应用程序

Google App Engine 为开发者提供了一种 NoOps(即 No Operations,表示开发者和工程师无需进行任何操作即可使代码运行并可用)的方式来部署他们的应用程序,而 Go 语言已经作为官方支持的语言选项存在了数年。Google 的架构运行着世界上一些最大的应用程序,例如 Google 搜索、Google 地图和 Gmail 等,因此在部署我们自己的代码时,这是一个相当安全的赌注。

Google App Engine 允许你编写一个 Go 应用程序,添加一些特殊的配置文件,并将其部署到 Google 的服务器上,在那里它将被托管并在一个高度可用、可扩展和弹性的环境中提供。实例将自动启动以满足需求,并在不再需要时优雅地关闭,同时保持健康的免费配额和预先批准的预算。

除了运行应用程序实例外,Google App Engine 还提供了一系列有用的服务,例如快速和大规模的数据存储、搜索、memcache 和任务队列。透明的负载均衡意味着你不需要构建和维护额外的软件或硬件来确保服务器不会过载,并且请求能够快速得到满足。

在本章中,我们将构建一个类似于 Stack Overflow 或 Quora 的问答服务的 API 后端,并将其部署到 Google App Engine。在这个过程中,我们将探讨可以应用于所有此类应用程序的技术、模式和最佳实践,并深入了解一些对我们应用程序更有用的服务。

具体来说,在本章中,你将学习:

  • 如何使用 Google App Engine SDK for Go 在将应用程序部署到云端之前在本地构建和测试应用程序

  • 如何使用 app.yaml 配置你的应用程序

  • Google App Engine 中的模块如何让你独立管理构成应用程序的不同组件

  • Google Cloud Datastore 如何让你以规模化的方式持久化和查询数据

  • 在 Google Cloud Datastore 中建模数据和与键交互的合理模式

  • 如何使用 Google App Engine Users API 对具有 Google 账户的人进行身份验证

  • 将非规范化数据嵌入到实体中的模式

  • 如何确保数据完整性和使用事务构建计数器

  • 为什么在代码中保持良好的可读性有助于提高可维护性

  • 如何在不添加第三方包依赖的情况下实现简单的 HTTP 路由

Google App Engine SDK for Go

为了运行和部署 Google App Engine 应用程序,我们必须下载和配置 Go SDK。请访问 cloud.google.com/appengine/downloads 并下载适用于您计算机的最新 Google App Engine SDK for Go。ZIP 文件包含一个名为 go_appengine 的文件夹,您应该将其放置在 GOPATH 外的适当文件夹中,例如,在 /Users/yourname/work/go_appengine

小贴士

这些 SDK 的名称将来可能会更改;如果发生这种情况,请确保您查阅项目主页上的说明,这些说明会在 github.com/matryer/goblueprints 指引您正确的方向。

接下来,您需要将 go_appengine 文件夹添加到您的 $PATH 环境变量中,就像您第一次配置 Go 时对 go 文件夹所做的那样。

要测试您的安装,请打开终端并输入以下内容:

goapp version

您可能会看到以下类似的内容:

go version go1.6.1 (appengine-1.9.37) darwin/amd64

注意

实际的 Go 版本可能有所不同,通常比实际的 Go 发布版落后几个月。这是因为 Google 的云平台团队需要在其端进行工作以支持 Go 的新版本。

goapp 命令是 go 命令的替代品,具有一些额外的子命令;因此,您可以执行类似 goapp testgoapp vet 之类的操作。

创建您的应用程序

为了将应用程序部署到 Google 的服务器,我们必须使用 Google Cloud Platform 控制台来设置它。在浏览器中,转到 console.cloud.google.com 并使用您的 Google 账户登录。寻找 创建项目 菜单项,该菜单项通常会随着控制台的偶尔更改而移动。如果您已经有了一些项目,请点击项目名称以打开子菜单,您将在其中找到它。

小贴士

如果您找不到您想要的内容,只需搜索 创建 App Engine 项目,您就会找到它。

新建项目 对话框打开时,您将被要求为您的应用程序提供一个名称。您可以随意命名(例如,Answers),但请注意为您生成的项目 ID;您稍后配置应用程序时需要引用它。您还可以点击 编辑 并指定您自己的 ID,但请注意,该值必须是全局唯一的,因此您在构思时需要发挥创意。在这本书中,我们将使用 answersapp 作为应用程序 ID,但您无法使用它,因为它已经被占用。

您可能需要等待一分钟或两分钟以创建您的项目;您不需要监视页面,您可以继续并稍后检查。

App Engine 应用程序是 Go 包

现在,Google App Engine SDK for Go 已配置,我们的应用程序也已创建,我们可以开始构建它了。

在 Google App Engine 中,一个应用程序只是一个带有init函数的正常 Go 包,该函数通过http.Handlehttp.HandleFunc函数注册处理程序。它不需要像正常工具那样是main包。

在你的GOPATH文件夹内创建一个名为answersapp/api的新文件夹,并添加以下main.go文件:

package api 
import ( 
  "io" 
  "net/http" 
) 
func init() { 
  http.HandleFunc("/", handleHello) 
} 
func handleHello(w http.ResponseWriter, r *http.Request) { 
  io.WriteString(w, "Hello from App Engine") 
} 

你现在应该熟悉大部分内容,但请注意,没有ListenAndServe调用,处理程序是在init函数中而不是main函数中设置的。我们将使用简单的handleHello函数来处理每个请求,该函数将只写入欢迎字符串。

app.yaml 文件

为了将我们的简单 Go 包转换为 Google App Engine 应用程序,我们必须添加一个特殊的配置文件,称为app.yaml。该文件将位于应用程序或模块的根目录下,因此请在answersapp/api文件夹内创建它,并包含以下内容:

application: YOUR_APPLICATION_ID_HERE 
version: 1 
runtime: go 
api_version: go1 
handlers: 
- url: /.* 
  script: _go_app 

该文件是一个简单的人类(和机器)可读的配置文件,以YAML另一种标记语言)格式。以下表格描述了每个属性:

属性 描述
application 应用程序 ID(在创建项目时复制粘贴的)。
version 你的应用程序版本号。你可以部署多个版本,甚至可以在它们之间分割流量来测试新功能等。我们现在将坚持使用版本 1。
runtime 将执行你的应用程序的运行时名称。由于这是一本 Go 书,而且我们正在构建 Go 应用程序,我们将使用go
api_version go1 api 版本是 Google 支持的运行时版本;你可以想象未来这可能是go2
handlers 配置的 URL 映射的选择。在我们的例子中,所有内容都将映射到特殊_go_app脚本,但你也可以在这里指定静态文件和文件夹。

在本地运行简单应用程序

在我们部署应用程序之前,在本地测试它是有意义的。我们可以使用我们之前下载的 App Engine SDK 来完成这项工作。

导航到你的answersapp/api文件夹,并在终端中运行以下命令:

goapp serve

你应该看到以下输出:

在本地运行简单应用程序

这表示 API 服务器正在本地端口:56443上运行,管理服务器正在:8000上运行,我们的应用程序(默认模块)现在在localhost:8080上提供服务,所以让我们在浏览器中打开它。

在本地运行简单应用程序

如你所见,通过Hello from App Engine响应,我们的应用程序正在本地运行。通过将端口从:8080更改为:8000,导航到管理服务器。

在本地运行简单应用程序

前面的截图显示了我们可以用来查询应用程序内部结构的 Web 门户,包括查看运行实例、检查数据存储、管理任务队列等。

将简单应用程序部署到 Google App Engine

为了真正理解 Google App Engine 无操作(NoOps)承诺的力量,我们将把这个简单应用程序部署到云端。回到终端,通过按 Ctrl+C 停止服务器,并运行以下命令:

goapp deploy

您的应用程序将被打包并上传到 Google 的服务器。一旦完成,您应该看到以下类似的内容:

Completed update of app: theanswersapp, version: 1

真的是这样简单。

您可以通过导航到每个 Google App Engine 应用程序免费提供的端点来证明这一点,记得用您自己的应用程序 ID 替换:https://YOUR_APPLICATION_ID_HERE.appspot.com/

您将看到与之前相同的输出(字体渲染可能会有所不同,因为 Google 的服务器将对内容类型做出假设,而本地开发服务器不会)。

注意

应用程序正在通过 HTTP/2 提供,并且已经能够处理相当大的规模,而我们所做的只是编写一个 config 文件和几行代码。

Google App Engine 中的模块

模块是一个可以版本化、更新和管理独立的 Go 包。一个应用程序可能只有一个模块,或者它可以由许多模块组成,每个模块都是独立的,但又是同一应用程序的一部分,可以访问相同的数据和服务。即使应用程序没有做很多事情,它也必须有一个默认模块。

我们的应用程序将由以下模块组成:

描述 模块名称
必要的默认模块 default
提供 RESTful JSON 的 API 包 api
提供 HTML、CSS 和 JavaScript 的静态网站,该网站向 API 模块发出 AJAX 调用 web

每个模块都将是一个 Go 包,因此将存在于自己的文件夹中。

让我们通过在 api 文件夹旁边创建一个新的文件夹来重新组织我们的项目,命名为 default

我们不会让默认模块做任何其他事情,除了用它来配置,因为我们希望其他模块做所有有意义的工作。但如果我们让这个文件夹为空,Google App Engine SDK 将会抱怨它没有东西可以构建。

default 文件夹内,添加以下占位符 main.go 文件:

package defaultmodule 
func init() {} 

这个文件什么也不做,只是允许我们的 default 模块存在

注意

如果我们的包名能与文件夹匹配,那将很好,但 default 是 Go 中的一个保留关键字,所以我们有很好的理由打破这个规则。

我们应用程序中的另一个模块将被命名为 web,所以创建一个与 apidefault 文件夹并排的新文件夹,命名为 web。在这一章中,我们只将构建我们应用程序的 API,并通过下载网络模块来作弊。

请访问项目主页github.com/matryer/goblueprints,访问第二版的内容,并在README文件的下载部分查找第九章的Web 组件下载链接,即为 Google App Engine 构建问答应用。ZIP 文件包含 Web 组件的源文件,应将其解压缩并放置在web文件夹内。

现在,我们的应用程序结构应该看起来像这样:

/answersapp/api 
/answersapp/default 
/answersapp/web 

指定模块

要指定我们的api包将成为哪个模块,我们必须在我们的api文件夹中的app.yaml中添加一个属性。更新它以包含module属性:

application: YOUR_APPLICATION_ID_HERE 
version: 1 
runtime: go 
module: api 
api_version: go1 
handlers: 
- url: /.* 
  script: _go_app 

由于我们的默认模块也需要部署,因此我们还需要向其中添加一个app.yaml配置文件。在default/app.yaml中复制api/app.yaml文件,将模块更改为default

application: YOUR_APPLICATION_ID_HERE 
version: 1 
runtime: go 
module: default 
api_version: go1 
handlers: 
- url: /.* 
  script: _go_app 

使用 dispatch.yaml 路由到模块

为了将流量适当地路由到我们的模块,我们将创建另一个名为dispatch.yaml的配置文件,它将允许我们将 URL 模式映射到模块。

我们希望所有以/api/路径开始的流量都路由到api模块,其他所有内容都路由到web模块。如前所述,我们不会期望我们的default模块处理任何流量,但它在以后会有更多用途。

answersapp文件夹中(位于我们的模块文件夹之外),创建一个名为dispatch.yaml的新文件,内容如下:

application: YOUR_APPLICATION_ID_HERE 
dispatch: 
  - url: "*/api/*" 
    module: api 
  - url: "*/*" 
    module: web 

同样的application属性告诉 Google App Engine SDK for Go 我们正在引用哪个应用程序,而dispatch部分将 URL 路由到模块。

Google Cloud Datastore

App Engine 开发者可用的服务之一是 Google Cloud Datastore,这是一个为自动扩展和高性能而构建的 NoSQL 文档数据库。其有限的功能集保证了非常高的可扩展性,但了解注意事项和最佳实践对于成功项目至关重要。

反规范化数据

具有关系型数据库(RDBMS)经验的开发者通常会通过规范化数据,将其分散到多个表中,并在合并之前添加引用(外键),以减少数据冗余(试图让每条数据只在其数据库中显示一次),从而构建一个完整的图像。在无模式数据库和 NoSQL 数据库中,我们倾向于做相反的事情。我们反规范化数据,以便每个文档都包含它需要的完整图像,这使得读取时间极快,因为它只需要获取一个单一的事物。

例如,考虑我们如何在 MySQL 或 Postgres 等关系型数据库中建模推文:

反规范化数据

一个推文本身只包含其唯一的 ID,一个指向表示推文作者的 Users 表的键外参照,以及可能在TweetBody中提到的许多 URL。

这个设计的一个优点是,用户可以更改他们的名字或头像 URL,这将反映在他们过去和未来的所有推文中,这在去规范化世界中是免费的。

然而,为了向用户展示推文,我们必须加载推文本身,通过连接查找用户以获取他们的名字和头像 URL,然后从 URL 表加载相关数据以显示链接的预览。在规模上,这变得很困难,因为这三个数据表可能物理上彼此分离,这意味着需要发生很多事情才能构建出这个完整的画面。

考虑一下去规范化设计会是什么样子:

去规范化数据

我们仍然有相同的三类数据,但现在我们的推文包含了渲染给用户所需的所有内容,而无需从其他地方查找数据。现在,那些硬核的关系型数据库设计者已经意识到这意味着什么,这无疑让他们感到不安。

采用这种方法意味着:

  • 数据是重复的 - 用户中的AvatarURL在推文中重复为UserAvatarURL(空间浪费,对吧?)

  • 如果用户更改了AvatarURL,推文中的UserAvatarURL将过时

最终,数据库设计归结于物理层面。我们决定我们的推文将被阅读得比被写入的次数多得多,所以我们宁愿在前期承受痛苦并在存储上做出牺牲。只要理解了哪个集合是主集合,哪个是为了速度而复制的,重复的数据就没有什么问题。

数据的变更本身就是一个有趣的话题,但让我们思考一下我们可能为什么接受这种权衡。

首先,读取推文的速度优势可能值得主数据变更未反映在历史文档中的意外行为;出于这个原因,决定接受这种出现的功能是完全可以接受的。

其次,我们可能会决定保留特定时间点的数据快照是有意义的。例如,想象一下如果有人发推文询问人们是否喜欢他们的个人资料图片。如果图片改变了,推文上下文就会丢失。对于更严重的例子,考虑一下如果你在指向订单交付的地址表中的一行,而地址后来发生了变化,会发生什么。突然之间,订单可能看起来像被运往了不同的地方。

最后,存储变得越来越便宜,因此为了节省空间而进行数据规范化的需求减少了。Twitter 甚至将每个粉丝的整个推文文档都复制过来。在 Twitter 上有 100 个粉丝意味着你的推文至少会被复制 100 次,可能更多以增加冗余。这对关系型数据库爱好者来说听起来像是疯狂,但 Twitter 正在基于其用户体验做出明智的权衡;他们愿意花很多时间写推文并多次存储,以确保当你刷新你的信息流时,你不必等待很长时间才能获取更新。

注意

如果你想了解这个规模的感受,请查看 Twitter API 并查看一条推文文档包含的内容。这是一大批数据。然后,去看看 Lady Gaga 有多少粉丝。这在某些圈子中被称为“Lady Gaga 问题”,并且通过各种不同的技术和方法来解决,这些技术和方法超出了本章的范围。

现在我们已经了解了良好的 NoSQL 设计实践,让我们实现驱动我们 API 数据部分的类型、函数和方法。

实体和数据访问

要在 Google Cloud Datastore 中持久化数据,我们需要一个结构体来表示每个实体。这些实体结构体将在我们通过datastoreAPI 保存和加载数据时进行序列化和反序列化。我们可以添加辅助方法来执行与数据存储的交互,这是一种将此类功能物理上靠近实体的好方法。例如,我们将使用名为Answer的结构体来建模答案,并添加一个Create方法,该方法会调用datastore包中的适当函数。这防止了我们的 HTTP 处理器因为大量的数据访问代码而膨胀,并允许我们保持它们的简洁和简单。

我们应用的基础块之一是问题的概念。一个问题可以被用户提出并由多人回答。它将有一个唯一的 ID,以便它是可寻址的(可以在 URL 中引用),我们还会存储创建时的时间戳。

answersapp内部创建一个名为questions.go的新文件,并添加以下struct函数:

type Question struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  CTime time.Time `json:"created"` 
  Question string `json:"question"` 
  User UserCard `json:"user"` 
  AnswersCount int `json:"answers_count"` 
} 

结构描述了我们应用中的一个问题。其中大部分内容看起来相当明显,因为我们已经在之前的章节中做过类似的事情。UserCard结构体代表一个非规范化的User实体,这两个我们都会稍后添加。

小贴士

你可以使用以下方式在你的 Go 项目中导入datastore包:import "google.golang.org/appengine/datastore"

值得花点时间理解datastore.Key类型。

Google Cloud Datastore 中的键

Datastore 中的每个实体都有一个键,它唯一地标识了它。它们可以由字符串或整数组成,具体取决于你的情况。你可以自己决定键,或者让 Datastore 为你自动分配它们;再次强调,你的用例通常将决定哪种方法最好,我们将在本章中探讨这两种方法。

键是通过datastore.NewKeydatastore.NewIncompleteKey函数创建的,并且通过datastore.Getdatastore.Put函数将数据放入和从 Datastore 中取出。

在 Datastore 中,键和实体体是分开的,与 MongoDB 或 SQL 技术不同,在那里它只是文档或记录中的另一个字段。这就是为什么我们使用datastore:"-"字段标签从Question结构体中排除Key的原因。就像json标签一样,这表示我们希望 Datastore 在获取和存储数据时完全忽略Key字段。

键可以可选地有父键,这是一种将相关数据分组在一起的好方法,Datastore 对这类实体组做出了一些保证,你可以在 Google Cloud Datastore 在线文档中了解更多信息。

将数据放入 Google Cloud Datastore

在我们将数据保存到 Datastore 之前,我们想要确保我们的问题是有效的。在Question结构体定义下面添加以下方法:

func (q Question) OK() error { 
  if len(q.Question) < 10 { 
    return errors.New("question is too short") 
  } 
  return nil 
} 

如果问题有错误,OK函数将返回一个错误,否则将返回nil。在这种情况下,我们只是确保问题至少有 10 个字符。

为了将此数据持久化到数据存储中,我们将在Question结构体本身中添加一个方法。在questions.go的底部添加以下代码:

func (q *Question) Create(ctx context.Context) error { 
  log.Debugf(ctx, "Saving question: %s", q.Question) 
  if q.Key == nil { 
    q.Key = datastore.NewIncompleteKey(ctx, "Question", nil) 
  } 
  user, err := UserFromAEUser(ctx) 
  if err != nil { 
    return err 
  } 
  q.User = user.Card() 
  q.CTime = time.Now() 
  q.Key, err = datastore.Put(ctx, q.Key, q) 
  if err != nil { 
    return err 
  } 
  return nil 
} 

Create方法接受一个指向Question的指针作为接收者,这是很重要的,因为我们想修改字段。

注意

如果接收者没有*而是(q Question),我们会得到问题的副本而不是指向它的指针,并且我们对它所做的任何更改只会影响我们的本地副本,而不会影响原始的Question结构体本身。

我们首先使用log(来自godoc.org/google.golang.org/appengine/log包)来写入一个调试语句,说明我们正在保存问题。当你在一个开发环境中运行你的代码时,你将在终端中看到这个语句;在生产环境中,它将进入由 Google Cloud Platform 提供的专用日志服务中。

如果键是nil(这意味着这是一个新问题),我们将一个不完整的键分配给字段,这会通知 Datastore 我们希望它为我们生成一个键。我们传递的三个参数是context.Context(我们必须传递给所有数据存储函数和方法),一个描述实体类型的字符串,以及父键;在我们的情况下,这是nil

一旦我们知道已经有一个键存在,我们就调用一个方法(我们将在稍后添加)从 App Engine 用户获取或创建 User 并将其设置到问题中,然后设置 CTime 字段(创建时间)为 time.Now,标记问题被提出的时间点。

当我们的 Question 函数处于良好状态时,我们调用 datastore.Put 将其实际放置在数据存储中。像往常一样,第一个参数是 context.Context,然后是问题键和问题实体本身。

由于 Google Cloud Datastore 将键视为与实体分开且不同的,如果我们想在我们的代码中将它们放在一起,我们就必须做一些额外的工作。datastore.Put 方法返回两个参数:完整的键和 error。键参数实际上是有用的,因为我们发送了一个不完整的键并要求数据存储为我们创建一个,它在 put 操作期间这样做。如果成功,它返回一个新的 datastore.Key 对象给我们,代表完整的键,然后我们将其存储在 Question 对象的 Key 字段中。

如果一切顺利,我们返回 nil

添加另一个辅助函数来更新现有问题:

func (q *Question) Update(ctx context.Context) error { 
  if q.Key == nil { 
    q.Key = datastore.NewIncompleteKey(ctx, "Question", nil) 
  } 
  var err error 
  q.Key, err = datastore.Put(ctx, q.Key, q) 
  if err != nil { 
    return err 
  } 
  return nil 
} 

这个方法非常相似,只是它没有设置 CTimeUser 字段,因为它们已经设置好了。

从 Google Cloud Datastore 读取数据

读取数据就像使用 datastore.Get 方法将其放入一样简单,但由于我们希望在实体中维护键(而 datastore 方法并不这样做),因此通常需要添加一个辅助函数,就像我们要添加到 questions.go 中的那样:

func GetQuestion(ctx context.Context, key *datastore.Key) 
(*Question, error) { 
  var q Question 
  err := datastore.Get(ctx, key, &q) 
  if err != nil { 
    return nil, err 
  } 
  q.Key = key 
  return &q, nil 
} 

GetQuestion 函数接受 context.Context 和要获取的问题的 datastore.Key 方法。然后它执行一个简单的任务,调用 datastore.Get 并在返回之前将键分配给实体。当然,错误以通常的方式处理。

这是一个很好的模式,以便你的代码的用户知道他们永远不需要直接与 datastore.Getdatastore.Put 交互,而是使用可以确保实体正确填充键(以及他们可能在保存或加载之前想要做的任何其他调整)的辅助函数。

Google App Engine 用户

我们还将使用另一个服务是 Google App Engine Users API,它提供 Google 帐户(和 Google Apps 帐户)的认证。

创建一个名为 users.go 的新文件,并添加以下代码:

type User struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  UserID string `json:"-"` 
  DisplayName string `json:"display_name"` 
  AvatarURL string `json:"avatar_url"` 
  Score int `json:"score"` 
} 

Question 结构体类似,我们有 Key 和一些组成 User 实体的字段。这个结构体代表一个属于我们应用程序的对象,描述了一个用户;我们将在系统中为每个经过认证的用户有一个,但这不是我们从 Users API 获取的用户对象。

导入 godoc.org/google.golang.org/appengine/user 包并调用 user.Current(context.Context) 函数将返回 nil(如果没有用户经过身份验证)或 user.User 对象。此对象属于 Users API,不适合我们的数据存储,因此我们需要编写一个辅助函数,将 App Engine 用户转换为我们的 User

小贴士

注意 goimports 不会自动导入 os/user;有时最好手动处理导入。

将以下代码添加到 users.go 文件中:

func UserFromAEUser(ctx context.Context) (*User, error) { 
  aeuser := user.Current(ctx) 
  if aeuser == nil { 
    return nil, errors.New("not logged in") 
  } 
  var appUser User 
  appUser.Key = datastore.NewKey(ctx, "User", aeuser.ID, 0, nil) 
  err := datastore.Get(ctx, appUser.Key, &appUser) 
  if err != nil && err != datastore.ErrNoSuchEntity { 
    return nil, err 
  } 
  if err == nil { 
    return &appUser, nil 
  } 
  appUser.UserID = aeuser.ID 
  appUser.DisplayName = aeuser.String() 
  appUser.AvatarURL = gravatarURL(aeuser.Email) 
  log.Infof(ctx, "saving new user: %s", aeuser.String()) 
  appUser.Key, err = datastore.Put(ctx, appUser.Key, &appUser) 
  if err != nil { 
    return nil, err 
  } 
  return &appUser, nil 
} 

我们通过调用 user.Current 来获取当前经过身份验证的用户,如果它是 nil,则返回错误。这意味着用户未登录,操作无法完成。我们的 web 包将为我们检查并确保用户已登录,因此当它们到达 API 端点时,我们期望它们已经经过身份验证。

然后,我们创建一个新的 appUser 变量(它属于我们的 User 类型)并设置 datastore.Key。这次,我们不是创建一个不完整的键;相反,我们使用 datastore.NewKey 并指定一个字符串 ID,与用户 API ID 匹配。这个键的可预测性意味着在我们的应用程序中,每个经过身份验证的用户将只有一个 User 实体,同时它还允许我们无需查询即可加载 User 实体。

小贴士

如果我们将 App Engine 用户 ID 作为字段,那么我们需要进行查询以找到我们感兴趣的记录。与直接 Get 方法相比,查询是一个更昂贵的操作,因此如果可能,这种方法总是首选。

然后,我们调用 datastore.Get 来尝试加载 User 实体。如果这是用户第一次登录,将没有实体,返回的错误将是特殊的 datastore.ErrNoSuchEntity 变量。如果是这种情况,我们设置适当的字段并使用 datastore.Put 来保存它。否则,我们只需返回加载的 User

注意

注意,我们在该函数中检查了早期返回。这是为了确保在不跟随代码的缩进块进进出出的情况下,我们的代码执行流程易于阅读。我称之为代码的视线,并在我的博客上写了一些关于它的内容,博客地址为 medium.com/@matryer

目前,我们将再次使用 Gravatar 来处理头像图片,所以请将以下辅助函数添加到 users.go 文件的底部:

func gravatarURL(email string) string { 
  m := md5.New() 
  io.WriteString(m, strings.ToLower(email)) 
  return fmt.Sprintf("//www.gravatar.com/avatar/%x", m.Sum(nil)) 
} 

嵌入非规范化数据

如果你还记得,我们的 Question 类型不将作者作为 User;相反,类型是 UserCard。当我们将非规范化数据嵌入到其他实体中时,有时我们希望它们看起来与主实体略有不同。在我们的例子中,由于我们没有在 User 实体中存储键(记住 Key 字段有 datastore:"-"),我们需要有一个新的类型来存储键。

users.go 文件的底部添加 UserCard 结构体及其为 User 相关的辅助方法:

type UserCard struct { 
  Key         *datastore.Key `json:"id"` 
  DisplayName string         `json:"display_name"` 
  AvatarURL   string         `json:"avatar_url"` 
} 
func (u User) Card() UserCard { 
  return UserCard{ 
    Key:         u.Key, 
    DisplayName: u.DisplayName, 
    AvatarURL:   u.AvatarURL, 
  } 
} 

注意,UserCard没有指定datastore标签,所以Key字段确实会被持久化在数据存储中。我们的Card()辅助函数只是通过复制每个字段的值来构建和返回UserCard。这看起来很浪费,但提供了很好的控制,特别是如果你想嵌入的数据看起来与原始实体非常不同。

Google Cloud Datastore 中的事务

事务允许您指定一系列对数据存储的更改,并将它们作为一个整体提交。如果任何单个操作失败,整个事务将不会应用。如果您想维护计数器或有多个人物实体依赖于彼此的状态,这将非常有用。在 Google Cloud Datastore 的事务期间,所有读取的实体都会被锁定(其他代码将阻止更改),直到事务完成,这提供了额外的安全性,并防止数据竞争。

注意

如果你在构建一家银行(这似乎很疯狂,但伦敦的 Monzo 公司确实正在使用 Go 构建一家银行),你可能将用户账户表示为一个名为Account的实体。要从账户 A 转账到账户 B,你需要确保从账户 A 扣除资金并作为单一事务存入账户 B。如果其中任何一个操作失败,人们可能不会高兴(公平地说,如果扣除操作失败,账户 A 的持有人可能会很高兴,因为 B 会得到钱而 A 不需要付出任何代价)。

为了了解我们将在哪里使用事务,让我们首先将模型答案添加到问题中。

创建一个名为answers.go的新文件,并添加以下结构和验证方法:

type Answer struct { 
  Key    *datastore.Key `json:"id" datastore:"-"` 
  Answer string         `json:"answer"` 
  CTime  time.Time      `json:"created"` 
  User   UserCard       `json:"user"` 
  Score  int            `json:"score"` 
} 
func (a Answer) OK() error { 
  if len(a.Answer) < 10 { 
    return errors.New("answer is too short") 
  } 
  return nil 
} 

Answer类似于问题,有datastore.Key(将不会被持久化),有CTime来捕获时间戳,并嵌入UserCard(代表回答问题的人)。它还有一个Score整数字段,当用户对答案投票时,该字段会上升或下降。

使用事务来维护计数器

我们的Question结构体有一个名为AnswerCount的字段,我们打算在其中存储一个整数,表示问题所吸引的答案数量。

首先,让我们看看如果我们不使用事务来跟踪问题的答案 4 和 5 的并发活动来保持AnswerCount字段会发生什么:

步骤 答案 4 答案 5 Question.AnswerCount
1 加载问题 加载问题 3
2 AnswerCount=3 AnswerCount=3 3
3 AnswerCount++ AnswerCount++ 3
4 AnswerCount=4 AnswerCount=4 3
5 保存答案和问题 保存答案和问题 4

从表中可以看出,如果没有锁定问题,如果答案同时到达,AnswerCount将最终变为 4 而不是 5。使用事务锁定将看起来像这样:

步骤 答案 4 答案 5 Question.AnswerCount
1 锁定问题 锁定问题 3
2 AnswerCount=3 等待解锁 3
3 AnswerCount++ 等待解锁 3
4 保存答案和问题 等待解锁 4
5 释放锁 等待解锁 4
6 完成 锁定问题 4
7 AnswerCount=4 4
8 AnswerCount++ 4
9 保存答案和问题 5

在这种情况下,首先获得锁的答案将执行其操作,而其他操作将在继续之前等待。这可能会减慢操作速度(因为它必须等待另一个操作完成),但为了得到正确的数字,这是值得付出的代价。

小贴士

最好将事务内的操作量尽可能保持小,因为在事务进行期间,您实际上是在阻止其他人。在事务之外,Google Cloud Datastore 非常快,因为它不提供相同类型的保证。

在代码中,我们使用datastore.RunInTransaction函数。将以下内容添加到answers.go中:

func (a *Answer) Create(ctx context.Context, questionKey *datastore.Key) error { 
  a.Key = datastore.NewIncompleteKey(ctx, "Answer", questionKey) 
  user, err := UserFromAEUser(ctx) 
  if err != nil { 
    return err 
  } 
  a.User = user.Card() 
  a.CTime = time.Now() 
  err = datastore.RunInTransaction(ctx, func(ctx context.Context) error { 
    q, err := GetQuestion(ctx, questionKey) 
    if err != nil { 
      return err 
    } 
    err = a.Put(ctx) 
    if err != nil { 
      return err 
    } 
    q.AnswersCount++ 
    err = q.Update(ctx) 
    if err != nil { 
      return err 
    } 
    return nil 
  }, &datastore.TransactionOptions{XG: true}) 
  if err != nil { 
    return err 
  } 
  return nil 
} 

我们首先创建一个新的不完整键(使用Answer类型),并将父键设置为问题键。这意味着问题将成为所有这些答案的祖先。

小贴士

在 Google Cloud Datastore 中,祖先键是特殊的,建议您在 Google Cloud Platform 网站上阅读有关其细微之处的文档。

使用我们的UserFromAEUser函数,我们获取回答问题的用户,并在设置CTime为当前时间之前,在Answer内部设置UserCard,就像之前做的那样。

然后,我们通过调用datastore.RunInTransaction函数开始我们的事务,该函数接受一个上下文以及一个事务代码将要执行的函数。还有一个第三个参数,即一组datastore.TransactionOptions,我们需要使用它来将XG设置为true,这会通知数据存储我们将执行跨实体组的事务(包括AnswerQuestion类型)。

小贴士

当涉及到编写自己的函数和设计自己的 API 时,强烈建议将任何函数参数放在末尾;否则,如前述代码中的内联函数块会掩盖后面还有另一个参数的事实。很难意识到TransactionOptions对象是传递给RunInTransaction函数的参数,我怀疑谷歌团队中有人对此决定感到后悔。

事务通过为我们提供一个新上下文来工作,这意味着事务函数内的代码看起来与不在事务中时相同。这是一项很好的 API 设计(这也意味着我们可以原谅该函数不是最终参数)。

在事务函数内部,我们使用我们的GetQuestion辅助函数来加载问题。在事务函数内部加载数据是获取其锁的方式。然后我们保存答案,更新AnswerCount整数,并更新问题。如果一切顺利(前提是这些步骤中没有返回错误),答案将被保存,AnswerCount将增加一。

如果我们从事务函数返回错误,其他操作将被取消,并返回错误。如果发生这种情况,我们只需从我们的Answer.Create方法返回该错误,并让用户再次尝试。

接下来,我们将添加我们的GetAnswer辅助函数,它与我们的GetQuestion函数类似:

func GetAnswer(ctx context.Context, answerKey *datastore.Key)  
(*Answer, error) { 
  var answer Answer 
  err := datastore.Get(ctx, answerKey, &answer) 
  if err != nil { 
    return nil, err 
  } 
  answer.Key = answerKey 
  return &answer, nil 
} 

现在我们将在answers.go中添加我们的Put辅助方法:

func (a *Answer) Put(ctx context.Context) error { 
  var err error 
  a.Key, err = datastore.Put(ctx, a.Key, a) 
  if err != nil { 
    return err 
  } 
  return nil 
} 

这两个函数与GetQuestionQuestion.Put方法非常相似,但让我们现在抵制抽象和简化代码的诱惑。

避免过早抽象

复制和粘贴通常被程序员视为坏事,因为这通常可以抽象出一般想法并DRY不要重复自己)代码。然而,值得抵制立即这样做,因为很容易设计出一个糟糕的抽象,然后你将陷入其中,因为你的代码将开始依赖于它。最好是首先在几个地方复制代码,然后稍后回过头来看是否有一个合理的抽象隐藏在那里。

Google Cloud Datastore 的查询

到目前为止,我们只将单个对象放入和取出 Google Cloud Datastore。当我们显示问题的答案列表时,我们希望一次性加载所有这些答案,这可以通过datastore.Query实现。

查询接口是一个流畅的 API,其中每个方法都返回相同的对象或修改后的对象,允许你将调用链在一起。你可以用它来构建一个包含排序、限制、祖先、过滤器等的查询。我们将用它来编写一个函数,该函数将加载给定问题的所有答案,首先显示最受欢迎的(那些具有更高Score值的)。

将以下函数添加到answers.go中:

func GetAnswers(ctx context.Context, questionKey *datastore.Key)  
([]*Answer, error) { 
  var answers []*Answer 
  answerKeys, err := datastore.NewQuery("Answer"). 
    Ancestor(questionKey). 
    Order("-Score"). 
    Order("-CTime"). 
    GetAll(ctx, &answers) 
  for i, answer := range answers { 
    answer.Key = answerKeys[i] 
  } 
  if err != nil { 
    return nil, err 
  } 
  return answers, nil 
} 

我们首先创建一个指向Answer的空切片,并使用datastore.NewQuery开始构建查询。Ancestor方法表示我们只查找属于特定问题的答案,Order方法调用指定我们首先按降序Score排序,然后按最新排序。GetAll方法执行操作,它接受指向我们的切片的指针(结果将放入其中)并返回一个包含所有键的新切片。

小贴士

返回的键的顺序将与切片中实体的顺序相匹配。这就是我们知道哪个键对应于每个项目的方式。

由于我们将键和实体字段放在一起,我们在答案上遍历,并将answer.Key分配给GetAll返回的相应datastore.Key参数。

注意

我们在第一版中为了保持 API 简单,没有实现分页,但理想情况下你确实需要这样做;否则,随着问题和答案数量的增加,你最终会尝试在一个请求中发送所有内容,这可能会让用户和服务器不堪重负。

如果在我们的应用程序中有一个步骤是授权答案(以保护它免受垃圾邮件或不适当内容的影响),我们可能希望添加一个额外的过滤器,使Authorizedtrue,在这种情况下,我们可以这样做:

datastore.NewQuery("Answer"). 
 Filter("Authorized =", true) 

小贴士

关于查询和过滤的更多信息,请参阅在线的 Google Cloud Datastore API 文档。

我们需要查询数据的另一个地方是在我们展示应用主页上的热门问题时。我们热门问题的第一版将只显示那些答案最多的问题;我们认为它们是最有趣的,但你可以将来更改此功能,而不会破坏 API,按分数或甚至按问题查看次数排序。

我们将在Question类型上构建Query,并使用Order方法首先按答案数量排序(数量最多者排在前面),然后按时间排序(也是数量最多/最新者排在前面)。我们还将使用Limit方法确保我们只为这个 API 选择前 25 个问题。稍后,如果我们实现了分页,我们甚至可以使这个排序动态化。

questions.go中添加TopQuestions函数:

func TopQuestions(ctx context.Context) ([]*Question, error) { 
  var questions []*Question 
  questionKeys, err := datastore.NewQuery("Question"). 
    Order("-AnswersCount"). 
    Order("-CTime"). 
    Limit(25). 
    GetAll(ctx, &questions) 
  if err != nil { 
    return nil, err 
  } 
  for i := range questions { 
    questions[i].Key = questionKeys[i] 
  } 
  return questions, nil 
} 

这段代码与加载答案类似,我们最终返回一个Question对象切片或一个错误。

投票

现在我们已经在应用程序中建模了问题和答案,是时候考虑投票可能的工作方式了。

让我们稍作设计:

  • 用户根据自己的观点对答案进行上下投票

  • 答案按其分数排序,因此最好的答案排在前面

  • 每个人对每个答案只能投一次票

  • 如果用户再次投票,他们应该替换他们之前的投票

我们将利用本章学到的几个知识点;事务将帮助我们确保正确计算答案的分数,我们还将再次使用可预测的键来确保每个人对每个答案只能投一次票。

我们首先构建一个结构来表示每个投票,并使用字段标签来更具体地说明我们希望数据存储如何索引我们的数据。

第三十三章:索引

由于广泛使用索引,从 Google Cloud Datastore 读取速度极快。默认情况下,我们结构中的每个字段都是索引的。尝试在未索引的字段上过滤的查询将失败(方法将返回错误);数据存储不会像某些其他技术那样回退到扫描,因为这被认为太慢。如果一个查询过滤了两个或多个字段,必须添加一个额外的索引,该索引由所有字段组成。

当你放置一个包含 10 个字段的结构时,它将执行多个写操作:一个用于实体本身,一个用于需要更新的每个索引。因此,对于你不想查询的字段关闭索引是有意义的。

questions.go 中,向 Question 结构添加 datastore 字段标签:

type Question struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  CTime time.Time `json:"created" datastore:",noindex"` 
  Question string `json:"question" datastore:",noindex"` 
  User UserCard `json:"user"` 
  AnswersCount int `json:"answers_count"` 
} 

添加 datastore:",noindex" 字段标签将告诉数据存储不要索引这些字段。

注意

以逗号开头的 ,noindex 值有点令人困惑。该值本质上是一个逗号分隔的参数列表,第一个是我们希望数据存储在存储每个字段时使用的名称(就像它对 json 标签所做的那样)。由于我们不想说任何关于我们希望数据存储使用的名称的事情,所以我们省略了真实字段名称;因此,第一个参数是空的,第二个参数是 noindex

对于我们不想在 Answer 结构中索引的字段,这样做:

type Answer struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  Answer string `json:"answer" datastore:",noindex"` 
  CTime time.Time `json:"created"` 
  User UserCard `json:"user" datastore:",noindex"` 
  Score int `json:"score"` 
} 

对于 Vote 结构,这样做:

type Vote struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  MTime time.Time `json:"last_modified" datastore:",noindex"` 
  Question QuestionCard `json:"question" datastore:",noindex"` 
  Answer AnswerCard `json:"answer" datastore:",noindex"` 
  User UserCard `json:"user" datastore:",noindex"` 
  Score int `json:"score" datastore:",noindex"` 
} 

你也可以将 noindex 声明添加到我们卡片类型内部的全部字段:AnswerCardUserCardQuestionCard

注意

我们没有添加 noindex 的字段将用于查询,我们需要确保 Google Cloud Datastore 确实在这些字段上维护索引。

嵌入实体的不同视图

现在是时候创建我们的 Vote 结构了,我们将在一个名为 votes.go 的新文件中完成:

type Vote struct { 
  Key *datastore.Key `json:"id" datastore:"-"` 
  MTime time.Time `json:"last_modified" datastore:",noindex"` 
  Question QuestionCard `json:"question" datastore:",noindex"` 
  Answer AnswerCard `json:"answer" datastore:",noindex"` 
  User UserCard `json:"user" datastore:",noindex"` 
  Score int `json:"score" datastore:",noindex"` 
} 

Vote 结构包含许多我们可嵌入的卡片类型,代表 QuestionAnswerUser 投票。它还包含一个 Score 整数,其值为 1-1(取决于他们是否投了赞成票或反对票)。我们还将使用 MTime time.Time 字段跟踪他们投票的时间(或最后更改它)。

注意

如果你喜欢,可以在 Vote 结构中使用 *Card 类型的指针。这会在你将 Vote 对象传入和传出函数时节省额外的副本,但这意味着在这些函数内部所做的任何更改都会影响原始数据,而不仅仅是它们的本地副本。在大多数情况下,使用指针的性能提升不大,可能更简单的是省略它们。这本书故意混合了两种方法,以向您展示它们是如何工作的,但在做出决定之前,您应该了解其影响。

就像我们的UserCard方法一样,我们将为问题和答案添加适当的版本,但这次我们将更仔细地选择哪些字段应该包含,哪些应该排除。

questions.go中添加QuestionCard类型及其相关辅助方法:

type QuestionCard struct { 
  Key *datastore.Key `json:"id" datastore:",noindex"` 
  Question string `json:"question" datastore:",noindex"` 
  User     UserCard `json:"user" datastore:",noindex"` 
} 
func (q Question) Card() QuestionCard { 
  return QuestionCard{ 
    Key:      q.Key, 
    Question: q.Question, 
    User:     q.User, 
  } 
} 

QuestionCard类型捕获Question字符串和谁提出了它(再次是我们的UserCard方法),但我们排除了CTimeAnswersCount字段。

让我们在answers.go中添加AnswerCard

type AnswerCard struct { 
  Key    *datastore.Key `json:"id" datastore:",noindex"` 
  Answer string         `json:"answer" datastore:",noindex"` 
  User   UserCard       `json:"user" datastore:",noindex"` 
} 

func (a Answer) Card() AnswerCard { 
  return AnswerCard{ 
    Key:    a.Key, 
    Answer: a.Answer, 
    User:   a.User, 
  } 
} 

同样,我们只捕获Answer字符串和User,排除CTimeScore

决定要捕获哪些字段以及要排除哪些字段完全取决于你希望提供的用户体验。我们可能会决定,当我们显示投票时,我们想显示Answer在当时的分数,或者我们可能想显示Answer当前的分数,无论投票时它是什么。也许我们想向写答案的用户发送推送通知,比如“Blanca 已经对 Ernesto 的问题的答案进行了点赞,现在得分为 15”,在这种情况下,我们还需要获取Score字段。

投票

在我们的 API 功能完整之前,我们需要添加用户投票的能力。我们将把这个功能分成两个函数,以提高我们代码的可读性。

votes.go内部,添加以下函数:

func CastVote(ctx context.Context, answerKey *datastore.Key, score int) (*Vote, error) { 
  question, err := GetQuestion(ctx, answerKey.Parent()) 
  if err != nil { 
    return nil, err 
  } 
  user, err := UserFromAEUser(ctx) 
  if err != nil { 
    return nil, err 
  } 
  var vote Vote 
  err = datastore.RunInTransaction(ctx, func(ctx context.Context) error { 
    var err error 
    vote, err = castVoteInTransaction(ctx, answerKey, question, user, 
     score) 
    if err != nil { 
      return err 
    } 
    return nil 
  }, &datastore.TransactionOptions{XG: true}) 
  if err != nil { 
    return nil, err 
  } 
  return &vote, nil 
} 

CastVote函数接受(包括必填的Context)要投票的答案的datastore.Key和一个分数整数。它加载问题当前用户,开始数据存储事务,并将执行传递给castVoteInTransaction函数。

通过 datastore.Key 访问父键

我们的CastVote函数可能需要我们知道Questiondatastore.Key以便加载它。但关于祖先键的一个很好的特性是,仅从键本身,你就可以访问父键。这是因为键的层次结构被保存在键本身中,有点像路径。

问题 1 可能有以下键的三个答案:

  • 问题,1/答案,1

  • 问题,1/答案,2

  • 问题,1/答案,3

关键如何在底层工作的实际细节被保留在 datastore 包内部,并且可能会随时更改。因此,只依赖于 API 保证的事情是明智的,比如能够通过Parent方法访问父键。

代码的视线

相比于维护函数的成本,编写函数的成本相对较低,尤其是在成功且长期运行的项目中。因此,花时间确保代码可以被未来的我们和其他人阅读是值得的。

如果代码易于浏览,并且理解语句的通常、预期流程(即“快乐路径”),则可以说代码具有良好的视线。在 Go 中,我们可以通过编写代码时遵循一些简单的规则来实现这一点:

  • 将“快乐路径”对齐到左侧边缘,这样你可以扫描单列并看到预期的执行流程。

  • 不要将快乐路径逻辑隐藏在嵌套缩进的括号中

  • 早期退出你的函数

  • 仅缩进以处理错误或边缘情况

  • 提取函数和方法以保持代码体小且可读

备注

写好视线代码还有一些更多细节,这些细节在 bit.ly/lineofsightincode 中概述并维护。

为了防止我们的 CastVote 函数变得太大且难以跟踪,我们将核心功能拆分到自己的函数中,现在我们将将其添加到 votes.go 中:

func castVoteInTransaction(ctx context.Context, answerKey *datastore.Key, question *Question, user *User, score int) (Vote, error) { 
  var vote Vote 
  answer, err := GetAnswer(ctx, answerKey) 
  if err != nil { 
    return vote, err 
  } 
  voteKeyStr := fmt.Sprintf("%s:%s", answerKey.Encode(), user.Key.Encode()) 
  voteKey := datastore.NewKey(ctx, "Vote", voteKeyStr, 0, nil) 
  var delta int // delta describes the change to answer score 
  err = datastore.Get(ctx, voteKey, &vote) 
  if err != nil && err != datastore.ErrNoSuchEntity { 
    return vote, err 
  } 
  if err == datastore.ErrNoSuchEntity { 
    vote = Vote{ 
      Key:      voteKey, 
      User:     user.Card(), 
      Answer:   answer.Card(), 
      Question: question.Card(), 
      Score:    score, 
    } 
  } else { 
    // they have already voted - so we will be changing 
    // this vote 
    delta = vote.Score * -1 
  } 
  delta += score 
  answer.Score += delta 
  err = answer.Put(ctx) 
  if err != nil { 
    return vote, err 
  } 
  vote.Key = voteKey 
  vote.Score = score 
  vote.MTime = time.Now() 
  err = vote.Put(ctx) 
  if err != nil { 
    return vote, err 
  } 
  return vote, nil 
} 

虽然这个函数很长,但其视线并不太差。快乐路径沿着左侧边缘流动,我们仅缩进以在出现错误或创建新的 Vote 对象的情况下提前返回。这意味着我们可以轻松跟踪它在做什么。

我们接收答案键、相关问题和投票用户以及分数,并返回一个投票对象,或者在出错时返回一个错误。

首先,我们获取答案,由于我们处于事务中,它将锁定答案直到事务完成(或由于错误而停止)。

然后,我们为这次投票构建键,这个键由答案和用户键编码成单个字符串。这意味着对于每个用户/答案对,数据存储中只存在一个 Vote 实体;因此,根据我们的设计,用户对每个答案只能投一次票。

然后,我们使用投票键尝试从数据存储中加载 Vote 实体。当然,当用户第一次对一个问题进行投票时,将不存在实体,我们可以通过检查 datastore.Get 返回的错误是否是特殊的 datastore.ErrNoSuchEntity 值来检查这一点。如果是,我们创建新的 Vote 对象,并设置适当的字段。

我们维护一个名为 delta 的分数整数,它将代表在投票发生后需要添加到答案分数中的数字。当用户第一次对一个问题进行投票时,delta 将是 1-1。如果他们从反对变为支持(-11),delta 将是 2,这将取消之前的投票并添加新的投票。我们通过将 delta 乘以 -1 来撤销之前的投票(如果有的话,即 err != datastore.ErrNoSuchEntity)。这也有一个很好的效果,即如果他们意外地在两个方向上投了相同的票两次,也不会有任何区别(delta 将为 0)。

最后,我们在更新 Vote 对象的最终字段并将它放回数据存储之前,更改答案的分数。然后我们返回,我们的 CastVote 函数退出 datastore.RunInTransaction 函数块,从而释放答案,让其他人也可以对其投票。

通过 HTTP 暴露数据操作

现在我们已经构建了所有实体以及操作它们的数据库访问方法,是时候将它们连接到 HTTP API 上了。这会感觉更熟悉,因为我们已经在书中做过几次类似的事情了。

带类型断言的可选功能

当你在 Go 中使用接口类型时,你可以执行类型断言来查看对象是否实现了其他接口,并且由于你可以内联编写接口,因此可以非常容易地找出对象是否实现了特定函数。

如果vinterface{},我们可以使用以下模式查看它是否有OK方法:

if obj, ok := v.(interface{ OK() error }); ok { 
  // v has OK() method 
} else { 
  // v does not have OK() method 
} 

如果v对象实现了接口中描述的方法,ok将为true,并且obj将是一个可以调用 OK 方法的对象。否则,ok将为false

注意

这种方法的一个问题是它隐藏了代码用户的秘密功能,因此你必须非常详细地记录该函数,以便使其清晰,或者可能将该方法提升为其自己的第一类接口,并坚持要求所有对象实现它。记住,我们总是寻求清晰的代码而不是巧妙的代码。作为辅助练习,看看你是否可以添加接口并在解码签名中使用它。

我们将添加一个函数,帮助我们解码 JSON 请求体,并且可选地验证输入。创建一个名为http.go的新文件,并添加以下代码:

func decode(r *http.Request, v interface{}) error { 
  err := json.NewDecoder(r.Body).Decode(v) 
  if err != nil { 
    return err 
  } 
  if valid, ok := v.(interface { 
    OK() error 
  }); ok { 
    err = valid.OK() 
    if err != nil { 
      return err 
    } 
  } 
  return nil 
} 

解码函数接受http.Request和一个名为v的目标值,其中 JSON 数据将放入。我们检查是否实现了OK方法,如果是,则调用它。我们期望OK在对象看起来不错时返回nil;否则,我们期望它返回一个错误,解释出了什么问题。如果我们得到一个错误,我们将返回它,并让调用代码处理它。

如果一切顺利,我们在函数底部返回nil

响应辅助函数

我们将添加一对辅助函数,这将使响应 API 请求变得容易。将respond函数添加到http.go中:

func respond(ctx context.Context, w http.ResponseWriter,
 r *http.Request, v interface{}, code int) { 
  var buf bytes.Buffer 
  err := json.NewEncoder(&buf).Encode(v) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  w.Header().Set("Content-Type", 
   "application/json; charset=utf-8") 
  w.WriteHeader(code) 
  _, err = buf.WriteTo(w) 
  if err != nil { 
    log.Errorf(ctx, "respond: %s", err) 
  } 
} 

响应方法包含一个contextResponseWriterRequest、要响应的对象和状态码。它在设置适当的头和写入响应之前将v编码到内部缓冲区中。

我们在这里使用缓冲区,因为编码可能会失败。如果它失败了,但已经开始写入响应,那么 200 OK 头将被发送到客户端,这是误导的。相反,将编码到缓冲区让我们能够确保在决定响应的状态码之前,它能够无问题地完成。

现在将respondErr函数添加到http.go文件的底部:

func respondErr(ctx context.Context, w http.ResponseWriter,
 r *http.Request, err error, code int) { 
  errObj := struct { 
    Error string `json:"error"` 
  }{ Error: err.Error() } 
  w.Header().Set("Content-Type", "application/json; charset=utf-8") 
  w.WriteHeader(code) 
  err = json.NewEncoder(w).Encode(errObj) 
  if err != nil { 
    log.Errorf(ctx, "respondErr: %s", err) 
  } 
} 

这个函数将error封装在一个结构体中,该结构体将错误字符串作为名为error的字段嵌入。

解析路径参数

我们的一些 API 端点将需要从路径字符串中提取 ID,但我们不想向我们的项目添加任何依赖(例如外部路由包);相反,我们将编写一个简单的函数来为我们解析路径参数。

让我们先编写一个测试来解释我们希望我们的路径解析如何工作。创建一个名为 http_test.go 的文件,并添加以下单元测试:

func TestPathParams(t *testing.T) { 
  r, err := http.NewRequest("GET", "1/2/3/4/5", nil) 
  if err != nil { 
    t.Errorf("NewRequest: %s", err) 
  } 
  params := pathParams(r, "one/two/three/four") 
  if len(params) != 4 { 
    t.Errorf("expected 4 params but got %d: %v", len(params), params) 
  } 
  for k, v := range map[string]string{ 
    "one":   "1", 
    "two":   "2", 
    "three": "3", 
    "four":  "4", 
  } { 
    if params[k] != v { 
      t.Errorf("%s: %s != %s", k, params[k], v) 
    } 
  } 
  params = pathParams(r, "one/two/three/four/five/six") 
  if len(params) != 5 { 
    t.Errorf("expected 5 params but got %d: %v", len(params), params) 
  } 
  for k, v := range map[string]string{ 
    "one":   "1", 
    "two":   "2", 
    "three": "3", 
    "four":  "4", 
    "five":  "5", 
  } { 
    if params[k] != v { 
      t.Errorf("%s: %s != %s", k, params[k], v) 
    } 
  } 
} 

我们期望能够传递一个模式,并返回一个映射,该映射从 http.Request 中的路径发现值。

运行测试(使用 go test -v),并注意它失败了。

http.go 的底部,添加以下实现以使测试通过:

func pathParams(r *http.Request,pattern string) map[string]string{ 
  params := map[string]string{} 
  pathSegs := strings.Split(strings.Trim(r.URL.Path, "/"), "/") 
  for i, seg := range strings.Split(strings.Trim(pattern, "/"), "/") { 
    if i > len(pathSegs)-1 { 
      return params 
    } 
    params[seg] = pathSegs[i] 
  } 
  return params 
} 

该函数从特定的 http.Request 路径中分解,并构建一个包含从分解模式路径中获取的键的值映射。因此,对于模式 /questions/id 和路径 /questions/123,它将返回以下映射:

questions: questions
id:        123

当然,我们会忽略 questions 键,但 id 将是有用的。

通过 HTTP API 暴露功能

现在我们已经拥有了构建我们的 API 所需的所有工具:用于在 JSON 中编码和解码数据负载的辅助函数、路径解析函数,以及所有实体和数据访问功能,以在 Google Cloud Datastore 中持久化和查询数据。

Go 中的 HTTP 路由

我们将要添加的三个端点,以便处理问题,已在以下表格中概述:

HTTP 请求 描述
POST /questions 提出一个新问题
GET /questions/{id} 获取具有特定 ID 的问题
GET /questions 获取顶级问题

由于我们的 API 设计相对简单,没有必要通过添加额外的依赖来膨胀我们的项目以解决路由问题。相反,我们将使用正常的 Go 代码编写一个非常简单的 adhoc 路由。我们可以使用简单的 switch 语句来检测使用了哪种 HTTP 方法,并使用我们的 pathParams 辅助函数来查看是否指定了 ID,然后再将执行传递到适当的位置。

创建一个名为 handle_questions.go 的新文件,并添加以下 http.HandlerFunc 函数:

func handleQuestions(w http.ResponseWriter, r *http.Request) { 
  switch r.Method { 
  case "POST": 
    handleQuestionCreate(w, r) 
  case "GET": 
    params := pathParams(r, "/api/questions/:id") 
    questionID, ok := params[":id"] 
    if ok { // GET /api/questions/ID 
      handleQuestionGet(w, r, questionID) 
      return 
    } 
    handleTopQuestions(w, r) // GET /api/questions/ 
  default: 
    http.NotFound(w, r) 
  } 
} 

如果 HTTP 方法是 POST,则我们将调用 handleQuestionCreate。如果是 GET,则我们将查看是否可以从路径中提取 ID,如果可以,则调用 handleQuestionGet,否则调用 handleTopQuestions

Google App Engine 中的 Context

如果你还记得,我们调用 App Engine 函数时,所有调用都使用了 context.Context 对象作为第一个参数,但那是什么,我们如何创建一个?

Context实际上是一个接口,它提供取消信号、执行截止时间和在整个函数调用堆栈中跨许多组件和 API 边界请求范围内的数据。Google App Engine SDK for Go 在其 API 中使用它,其细节保留在包内部,这意味着我们(作为 SDK 的用户)不必担心它。当您在自己的包中使用 Context 时,这是一个好的目标;理想情况下,复杂性应该保持在内部并隐藏起来。

注意

您可以通过各种在线资源了解更多关于Context的信息,从blog.golang.org/context上的Go Concurrency Patterns: Context博客文章开始。

要创建适合 App Engine 调用的上下文,您使用appengine.NewContext函数,该函数接受http.Request作为参数,上下文将属于该参数。

在我们刚刚添加的路由代码下面,让我们添加一个负责创建问题的处理器,我们可以看到我们将为每个请求创建一个新的上下文:

func handleQuestionCreate(w http.ResponseWriter, r *http.Request) { 
  ctx := appengine.NewContext(r) 
  var q Question 
  err := decode(r, &q) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  err = q.Create(ctx) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  respond(ctx, w, r, q, http.StatusCreated) 
} 

我们创建Context并将其存储在ctx变量中,这在 Go 社区中已经变成了一种接受的模式。然后我们在调用我们之前编写的Create辅助方法之前解码我们的Question(由于OK方法,它也会为我们验证它)。每一步,我们都传递我们的上下文。

如果有任何问题发生,我们会调用我们的respondErr函数,该函数会在返回并提前退出函数之前向客户端写入响应。

如果一切顺利,我们将以Questionhttp.StatusCreated状态代码(201)进行响应。

解码键字符串

由于我们将datastore.Key对象作为id字段暴露在我们的对象中(通过json字段标签),我们期望我们的 API 用户在引用特定对象时传递回这些相同的 ID 字符串。这意味着我们需要解码这些字符串并将它们转换回datastore.Key对象。幸运的是,datastore包以datastore.DecodeKey函数的形式提供了答案。

handle_questions.go的底部,添加以下处理函数以获取单个问题:

func handleQuestionGet(w http.ResponseWriter, r *http.Request,
 questionID string) { 
  ctx := appengine.NewContext(r) 
  questionKey, err := datastore.DecodeKey(questionID) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  question, err := GetQuestion(ctx, questionKey) 
  if err != nil { 
    if err == datastore.ErrNoSuchEntity { 
      respondErr(ctx, w, r, datastore.ErrNoSuchEntity,
       http.StatusNotFound) 
      return 
    } 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  respond(ctx, w, r, question, http.StatusOK) 
} 

在我们再次创建 Context 之后,我们将解码问题 ID参数,将其字符串转换回datastore.Key对象。问题 ID字符串是从我们添加在文件顶部的路由处理器代码中传递进来的。

假设问题 ID是一个有效的键,并且 SDK 成功将其转换为datastore.Key,我们将调用我们的GetQuestion辅助函数来加载Question。如果我们得到datastore.ErrNoSuchEntity错误,那么我们将以 404(未找到)状态响应;否则,我们将使用http.StatusInternalServerError代码报告错误。

小贴士

在编写 API 时,检查 HTTP 状态码和其他 HTTP 标准,看看你是否可以利用它们。开发者们已经习惯了这些,如果你的 API 使用相同的语言,它将感觉更加自然。

如果我们能够加载问题,我们就调用 respond 并将 JSON 格式的数据发送回客户端。

接下来,我们将通过一个与用于问题的类似 API 来公开与答案相关的功能:

HTTP 请求 描述
POST /answers 提交答案
GET /answers 使用指定的问题 ID 获取答案

创建一个名为 handle_answers.go 的新文件,并添加路由 http.HandlerFunc 函数:

func handleAnswers(w http.ResponseWriter, r *http.Request) { 
  switch r.Method { 
  case "GET": 
    handleAnswersGet(w, r) 
  case "POST": 
    handleAnswerCreate(w, r) 
  default: 
    http.NotFound(w, r) 
  } 
} 

对于 GET 请求,我们调用 handleAnswersGet;对于 POST 请求,我们调用 handleAnswerCreate。默认情况下,我们将响应一个 404 Not Found

使用查询参数

作为解析路径的替代方案,你可以直接从请求的 URL 中获取查询参数,当我们添加读取答案的处理程序时,我们将这样做:

func handleAnswersGet(w http.ResponseWriter, r *http.Request) { 
  ctx := appengine.NewContext(r) 
  q := r.URL.Query() 
  questionIDStr := q.Get("question_id") 
  questionKey, err := datastore.DecodeKey(questionIDStr) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  answers, err := GetAnswers(ctx, questionKey) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  respond(ctx, w, r, answers, http.StatusOK) 
} 

这里,我们使用 r.URL.Query() 来获取包含查询参数的 http.Values,并使用 Get 方法提取 question_id。因此,API 调用将如下所示:

/api/answers?question_id=abc123 

小贴士

在现实世界的 API 中,你应该保持一致性。我们使用了路径参数和查询参数的混合来展示它们之间的区别,但建议你选择一种风格并坚持下去。

请求数据的匿名结构体

回答问题的 API 是通过将包含答案详情以及问题 ID 字符串的正文发送到 /api/answers 来实现的。这个结构与我们的内部 Answer 表示不同,因为问题 ID 字符串需要解码成 datastore.Key。我们可以保留该字段,并通过字段标签指示它应从 JSON 和数据存储中省略,但有一个更干净的方法。

我们可以指定一个内联的匿名结构体来保存新的答案,并且最好的地方是在处理该数据的处理程序函数内部这样做意味着我们不需要在我们的 API 中添加一个新类型,但我们仍然可以表示我们期望的请求数据。

handle_answers.go 的底部添加 handleAnswerCreate 函数:

func handleAnswerCreate(w http.ResponseWriter, r *http.Request) { 
  ctx := appengine.NewContext(r) 
  var newAnswer struct { 
    Answer 
    QuestionID string `json:"question_id"` 
  } 
  err := decode(r, &newAnswer) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  questionKey, err := datastore.DecodeKey(newAnswer.QuestionID) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  err = newAnswer.OK() 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  answer := newAnswer.Answer 
  user, err := UserFromAEUser(ctx) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  answer.User = user.Card() 
  err = answer.Create(ctx, questionKey) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  respond(ctx, w, r, answer, http.StatusCreated) 
} 

看一下有些不寻常的 var newAnswer struct 行。我们声明了一个名为 newAnswer 的新变量,它具有匿名结构体的类型(它没有名字),包含 QuestionID string 并嵌入 Answer。我们可以将请求体解码到这种类型中,并捕获任何特定的 Answer 字段以及 QuestionID。然后,我们将问题 ID 解码成 datastore.Key,就像我们之前做的那样,验证答案,并通过获取当前认证用户并调用 Card 辅助方法来设置 User (UserCard) 字段。

如果一切顺利,我们将调用 Create,这将完成将答案保存到问题的相关工作。

最后,我们需要在我们的 API 中公开投票功能。

编写自相似代码

我们投票 API 只有一个端点,即对/votes的 POST 请求。所以,当然,在这个方法上不需要进行任何路由(我们可以在处理器本身中检查方法),但是编写熟悉且与其他同一包中的代码相似的代码是有一定道理的。在我们的情况下,如果有人查看我们的代码并看到问题路由器后期望有一个路由器,那么省略路由器可能会让人感到有些不适应。

因此,让我们向一个名为handle_votes.go的新文件中添加一个简单的路由处理器:

func handleVotes(w http.ResponseWriter, r *http.Request) { 
  if r.Method != "POST" { 
    http.NotFound(w, r) 
    return 
  } 
  handleVote(w, r) 
} 

我们的路由器仅检查方法,如果它不是POST,则在调用handleVote函数之前就提前退出,我们将在下一部分添加这个函数。

返回错误的验证方法

我们添加到一些对象中的OK方法是一种很好的方式,可以将验证方法添加到我们的代码中。

我们想要确保传入的分数值是有效的(在我们的例子中,是-11),因此我们可以编写一个像这样的函数:

func validScore(score int) bool { 
  return score == -1 || score == 1 
} 

如果我们在几个地方使用这个函数,我们就必须重复解释分数无效的代码。然而,如果函数返回一个错误,你可以在一个地方封装它。

将以下validScore函数添加到votes.go中:

func validScore(score int) error { 
  if score != -1 && score != 1 { 
    return errors.New("invalid score") 
  } 
  return nil 
} 

在这个版本中,如果分数有效,我们返回nil;否则,我们返回一个错误,解释了哪里出了问题。

当我们将handleVote函数添加到handle_votes.go时,我们将使用这个验证函数:

func handleVote(w http.ResponseWriter, r *http.Request) { 
  ctx := appengine.NewContext(r) 
  var newVote struct { 
    AnswerID string `json:"answer_id"` 
    Score    int    `json:"score"` 
  } 
  err := decode(r, &newVote) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  err = validScore(newVote.Score) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusBadRequest) 
    return 
  } 
  answerKey, err := datastore.DecodeKey(newVote.AnswerID) 
  if err != nil { 
    respondErr(ctx, w, r, errors.New("invalid answer_id"), 
    http.StatusBadRequest) 
    return 
  } 
  vote, err := CastVote(ctx, answerKey, newVote.Score) 
  if err != nil { 
    respondErr(ctx, w, r, err, http.StatusInternalServerError) 
    return 
  } 
  respond(ctx, w, r, vote, http.StatusCreated) 
} 

到现在为止,这看起来已经很熟悉了,这突出了为什么我们将所有数据访问逻辑放在与我们的处理器不同的地方;处理器可以专注于 HTTP 任务,例如解码请求和写入响应,并将应用程序的特定细节留给其他对象。

我们还将逻辑分解为不同的文件,以handle_作为 HTTP 处理器代码的前缀模式,这样当我们想要工作在项目的特定部分时,我们可以快速知道在哪里查找。

路由处理器映射

让我们通过将init函数更改为将实际处理器映射到 HTTP 路径来更新我们的main.go文件:

func init() { 
  http.HandleFunc("/api/questions/", handleQuestions) 
  http.HandleFunc("/api/answers/", handleAnswers) 
  http.HandleFunc("/api/votes/", handleVotes) 
} 

你也可以移除现在多余的handleHello处理器函数。

运行具有多个模块的应用程序

对于像我们这样的具有多个模块的应用程序,我们需要列出所有goapp命令的 YAML 文件。

要提供我们的新应用程序,在终端中执行以下命令:

goapp serve dispatch.yaml default/app.yaml api/app.yaml
     web/app.yaml

从调度文件开始,我们列出了所有相关的配置文件。如果你遗漏了任何一个,当你尝试提供你的应用程序服务时,你会看到一个错误。在这里,你会注意到输出现在列出了每个模块正在不同的端口上部署:

运行具有多个模块的应用程序

我们可以直接通过访问每个端口来访问模块,但幸运的是,我们的分发器正在端口:8080上运行,它将根据我们在dispatch.yaml配置文件中指定的规则为我们做这件事。

本地测试

现在我们已经构建了应用程序,转到localhost:8080来查看它的实际运行情况。通过以下步骤使用应用程序的功能:

  1. 使用你的真实电子邮件地址登录(这样,你将看到你的 Gravatar 图片)。

  2. 提出一个问题。

  3. 提交几个答案。

  4. 对答案进行上下投票,并查看分数的变化。

  5. 打开另一个浏览器,以其他身份登录,看看应用程序从他们的角度来看是什么样子。

使用管理控制台

管理控制台与我们的应用程序并行运行,并且可以通过localhost:8000访问:

使用管理控制台

数据存储查看器让你检查应用程序的数据。你可以用它来查看(甚至修改)当你使用应用程序时生成的问答和投票数据。

自动生成的索引

你还可以查看开发服务器为满足你的应用程序查询而自动创建的哪些索引。实际上,如果你查看默认文件夹,你会注意到一个名为index.yaml的新文件神奇地出现了。这个文件描述了你的应用程序需要的相同索引,当你部署你的应用程序时,这个文件会随着它一起上传到云端,以告诉谷歌云数据存储维护这些相同的索引。

部署具有多个模块的应用程序

部署具有多个模块的应用程序稍微复杂一些,因为分发器和索引文件每个都需要一个专门的部署命令。

使用以下命令部署模块:

goapp deploy default/app.yaml api/app.yaml web/app.yaml

一旦操作完成,我们可以使用appcfg.py命令(你必须确保它在你的路径中,你可以在我们本章开始时下载的谷歌应用引擎 SDK for Go 文件夹中找到它)更新分发器:

appcfg.py update_dispatch .

一旦分发器更新后,我们可以将索引推送到云端:

appcfg.py update_indexes -A **YOUR_APPLICATION_ID_HERE** ./default

现在应用程序已经部署,我们可以通过导航到我们的appspot URL 来在野外看到它;https://YOUR_APPLICATION_ID_HERE.appspot.com/

注意

你可能会得到一个错误,说“此查询的索引尚未准备好提供服务”。这是因为谷歌云数据存储需要一点时间来在服务器上准备事情;通常,这不会超过几分钟,所以去喝杯咖啡,稍后再试。

一个有趣的细节是,如果你用 HTTPS 访问 URL,谷歌的服务器将使用 HTTP/2 来提供服务。

一旦你的应用程序功能正常,提出一个有趣的问题,并将链接发送给你的朋友以征求答案。

摘要

在本章中,我们为谷歌应用引擎构建了一个完全功能性的问答应用。

我们学习了如何使用谷歌应用引擎 SDK for Go 在本地构建和测试我们的应用程序,然后再将其部署到云端,以便我们的朋友和家人使用。如果应用程序突然开始获得大量流量,它就可以扩展,我们可以依赖健康的配额来满足早期流量。

我们探讨了如何在 Go 代码中建模数据,跟踪键,以及在 Google Cloud Datastore 中持久化和查询数据。我们还探讨了如何通过去规范化这些数据来提高大规模读取的速度。我们看到了如何通过确保在特定时间点只发生一个操作来保证数据完整性,从而为我们答案的得分构建可靠的计数器。我们使用可预测的数据存储键来确保我们的用户对每个答案只能投一票,当我们希望数据存储为我们生成键时,我们使用不完整的键。

本章探讨的许多技术都适用于任何需要持久化数据并通过 RESTful JSON API 进行交互的应用程序,因此这些技能具有很强的可迁移性。

在下一章中,我们将通过使用 Go Kit 框架构建一个真实的微服务来探索现代软件架构。使用微服务构建解决方案有很多好处,因此它们已经成为大型分布式系统的一个非常流行的选择。许多公司已经在生产中运行这样的架构(主要是用 Go 编写的),我们将看看他们是怎样做到的。

第十章. 使用 Go kit 框架的 Go 微服务

微服务是离散的组件,它们协同工作,为更大的应用程序提供功能性和业务逻辑,通常通过网络协议(如 HTTP/2 或某些其他二进制传输)进行通信,并分布到许多物理机器上。每个组件与其他组件隔离,它们接受定义良好的输入并产生定义良好的输出。同一服务的多个实例可以在多个服务器上运行,并且可以在它们之间进行负载均衡。如果设计得当,单个实例的失败不会导致整个系统崩溃,并且在运行时可以启动新的实例以帮助处理负载峰值。

Go kit(参考gokit.io)是由 Peter Bourgon(Twitter 上的@peterbourgon)创立的,用于构建具有微服务架构的应用程序的分布式编程工具包,目前由一群 Gophers 在开源社区中维护。它旨在解决构建此类系统时许多基础(有时可能有些枯燥)方面的问题,同时鼓励良好的设计模式,让您能够专注于构成您产品或服务的业务逻辑。

Go kit 并不试图从头解决每个问题;相反,它集成了许多流行的相关服务来解决SOA面向服务的架构)问题,例如服务发现、度量、监控、日志记录、负载均衡、断路器以及许多其他正确运行大规模微服务的重要方面。当我们使用 Go kit 手动构建服务时,您会注意到我们将编写大量的模板或框架代码,以便使一切正常工作。

对于小型产品和服务,以及小型开发团队,您可能会决定直接暴露一个简单的 JSON 端点更容易,但 Go kit 在大型团队中表现尤为出色,用于构建具有许多不同服务的大量系统,每个服务在架构中运行数十或数百次。具有一致的日志记录、仪表化、分布式跟踪,并且每个组件都与下一个相似,这意味着运行和维护此类系统变得显著更容易。

“Go kit 的最终目的是在服务内部鼓励良好的设计实践:SOLID 设计、领域驱动设计或六边形架构等。它并不是教条地遵循任何一种,而是试图使良好的设计/软件工程变得可行。” ——Peter Bourgon

在本章中,我们将构建一些解决各种安全挑战的微服务(在一个名为vault的项目中)——在此基础上,我们可以构建更多的功能。业务逻辑将保持非常简单,这样我们就可以专注于学习构建微服务系统的原则。

注意

作为技术选择,有一些 Go kit 的替代方案;它们大多数有类似的方法,但优先级、语法和模式不同。在开始项目之前,请确保您已经考虑了其他选项,但本章中学到的原则将适用于所有情况。

具体来说,在本章中,您将学习:

  • 如何使用 Go kit 手动编码一个微服务

  • gRPC 是什么以及如何使用它来构建服务器和客户端

  • 如何使用 Google 的协议缓冲区和相关工具以高效二进制格式描述服务和进行通信

  • Go kit 中的端点如何允许我们编写单个服务实现,并通过多种传输协议将其公开

  • Go kit 包含的子包如何帮助我们解决许多常见问题

  • 中间件如何让我们在不触及实现本身的情况下包装端点以适应其行为

  • 如何将方法调用描述为请求和响应消息

  • 如何对我们的服务进行速率限制以保护免受流量激增的影响

  • 一些其他的 Go 语言惯用技巧和技巧

本章中的一些代码行跨越了许多行;它们是以溢出的内容在下一行右对齐的方式编写的,如下例所示:

func veryLongFunctionWithLotsOfArguments(one string, two int, three
 http.Handler, four string) (bool, error) { 
  log.Println("first line of the function") 
} 

前面的代码片段中的前三行应该写在一行中。不用担心;Go 编译器会足够友好地指出您是否出错。

介绍 gRPC

当涉及到我们的服务如何相互通信以及客户端如何与服务通信时,有许多选项,Go kit 并不关心(更确切地说,它并不介意——它足够关心,以至于提供了许多流行机制的实现)。实际上,我们能够为我们的用户提供多个选项,并让他们决定他们想要使用哪一个。我们将添加对熟悉的 JSON over HTTP 的支持,但我们也将引入一个新的 API 技术选择。

gRPC,即 Google 的远程过程调用,是一个开源机制,用于通过网络调用远程运行的代码。它使用 HTTP/2 进行传输,并使用协议缓冲区来表示构成服务和消息的数据。

RPC 服务与 RESTful 网络服务不同,因为您不是使用定义良好的 HTTP 标准来更改数据(就像您使用 REST 那样——使用POST创建某物,使用PUT更新某物,使用DELETE删除某物等),而是触发一个远程函数或方法,传递预期的参数,并得到一个或多个数据响应。

为了突出差异,想象一下我们正在创建一个新用户。在一个 RESTful 的世界里,我们可以发出如下请求:

POST /users 
{ 
  "name": "Mat", 
  "twitter": "@matryer" 
} 

我们可能会得到如下响应:

201 Created 
{ 
  "id": 1, 
  "name": "Mat", 
  "twitter": "@matryer" 
} 

RESTful 调用表示对资源状态的查询或更改。在 RPC 世界中,我们会使用生成的代码来代替,以便进行二进制序列化的过程调用,这些调用在 Go 中感觉更像正常的方法或函数。

与 RESTful 服务和 gPRC 服务之间唯一的另一个关键区别是,gPRC 而不是 JSON 或 XML,使用一种称为协议缓冲区的特殊格式。

协议缓冲区

协议缓冲区(在代码中称为protobuf)是一种非常小且编码和解码非常快的二进制序列化格式。您使用声明性迷你语言以抽象方式描述数据结构,并生成源代码(多种语言),以便用户轻松读写数据。

你可以把协议缓冲区看作是 XML 的现代替代品,只不过数据结构的定义与内容分开,而内容是以二进制格式而不是文本格式。

当你查看真实示例时,可以清楚地看到其好处。如果我们想在 XML 中表示一个有名字的人,我们可以这样写:

<person> 
  <name>MAT</name> 
</person> 

这大约占用 30 个字节(不包括空白)。让我们看看它在 JSON 中的样子:

{"name":"MAT"} 

现在我们已经缩减到 14 个字节,但结构仍然内嵌在内容中(名称字段与值一起展开)。

在协议缓冲区中,等效内容只需 5 个字节。以下表格显示了每个字节,以及 XML 和 JSON 表示的前五个字节,以供比较。描述行解释了内容行中字节的含义:

字节 1 2 3 4 5
内容 0a 03 4d 61 72
描述 类型(字符串) 长度(3) M A T
XML < p e r s
JSON { " n a m

结构定义存在于一个特殊的.proto文件中,与数据分开。

仍然有许多情况下,XML 或 JSON 比协议缓冲区更适合,而在决定使用的数据格式时,文件大小并不是唯一的衡量标准,但对于固定模式结构和远程过程调用,或者对于真正大规模运行的应用程序,它是一个因合理原因而流行的选择。

安装协议缓冲区

有一些工具可以编译并生成协议缓冲区的源代码,您可以从项目的 GitHub 主页github.com/google/protobuf/releases获取。一旦下载了文件,解压它并将 bin 文件夹中的protoc文件放置在您的机器上的一个适当文件夹中:一个在您的$PATH环境变量中提到的文件夹。

一旦 protoc 命令就绪,我们需要添加一个插件,这将允许我们使用 Go 代码。在终端中执行以下命令:

go get -u github.com/golang/protobuf/{proto,protoc-gen-go}

这将安装两个我们将要使用的包。

协议缓冲区语言

为了定义我们的数据结构,我们将使用协议缓冲区语言的第三个版本,称为proto3

在你的$GOPATH中创建一个名为vault的新文件夹,并在其中创建一个名为pb的子文件夹。pb包将存放我们的协议缓冲区定义和生成的源代码。

我们将定义一个名为Vault的服务,它有两个方法,HashValidate

方法 描述
Hash 为给定的密码生成一个安全的哈希值。可以存储哈希值而不是存储明文密码。
Validate 给定一个密码和之前生成的哈希值,Validate方法将检查密码是否正确。

每个服务调用都有一个请求和响应对,我们也将定义这些。在pb中,将以下代码插入一个名为vault.proto的新文件中:

syntax = "proto3"; 
package pb; 
service Vault { 
  rpc Hash(HashRequest) returns (HashResponse) {} 
  rpc Validate(ValidateRequest) returns (ValidateResponse) {} 
} 
message HashRequest { 
  string password = 1; 
} 
message HashResponse { 
  string hash = 1; 
  string err = 2; 
} 
message ValidateRequest { 
  string password = 1; 
  string hash = 2; 
} 
message ValidateResponse { 
  bool valid = 1; 
} 

提示

为了节省纸张,已经删除了垂直空白,但如果你认为在各个块之间添加空格可以提高可读性,你可以这样做。

我们在文件中首先指定的是使用proto3语法,以及生成源代码的包名为pb

service块定义了Vault以及在其下方定义的两个方法——HashRequestHashResponseValidateRequestValidateResponse消息。服务块中以rpc开头的行表示我们的服务由两个远程过程调用组成:HashValidate

消息内部字段采用以下格式:

type name = position; 

type是一个描述标量值类型的字符串,例如stringbooldoublefloatint32int64等。name是一个人类可读的字符串,用于描述字段,例如hashpassword。位置是一个整数,表示该字段在数据流中的位置。这很重要,因为内容是字节流,将内容与定义对齐对于能够使用该格式至关重要。此外,如果我们稍后添加(甚至重命名)字段(协议缓冲区的一个关键设计特性),我们可以在不破坏期望以特定顺序包含某些字段的组件的情况下这样做;它们将继续无改动地工作,忽略新数据,并透明地传递它。

提示

有关支持类型的完整列表以及对该语言的深入探讨,请查看developers.google.com/protocol-buffers/docs/proto3上的文档。

注意,每个方法调用都有一个相关的请求和响应对。这些是当远程方法被调用时通过网络发送的消息。

由于哈希方法只接受一个密码字符串参数,因此HashRequest对象包含一个密码字符串字段。像正常的 Go 函数一样,响应可能包含一个错误,这就是为什么HashResponseValidateResponse都有两个字段。在 proto3 中,没有像 Go 中那样的专用error接口,所以我们打算将错误转换为字符串。

生成 Go 代码

Go 无法理解 proto3 代码,但幸运的是,我们之前安装的协议缓冲编译器和 Go 插件可以将它翻译成 Go 可以理解的东西:Go 代码。

在终端中,导航到pb文件夹,并运行以下命令:

protoc vault.proto --go_out=plugins=grpc:.

这将生成一个名为vault.pb.go的新文件。打开该文件并检查其内容。它为我们做了很多工作,包括定义消息,甚至为我们创建了VaultClientVaultServer类型,这将允许我们分别消费和公开服务。

提示

如果你对生成的其余代码(文件描述符看起来特别有趣)感兴趣,你可以自由地解码。现在,我们将相信它工作正常,并使用pb包来构建我们的服务实现。

构建服务

最后,无论我们的架构中正在进行什么其他黑暗魔法,它最终都会归结为调用某个 Go 方法,执行一些工作,并返回一个结果。所以接下来我们要做的是定义和实现 Vault 服务本身。

vault文件夹内,向一个新创建的service.go文件中添加以下代码:

// Service provides password hashing capabilities. 
type Service interface { 
  Hash(ctx context.Context, password string) (string,
    error) 
  Validate(ctx context.Context, password, hash string)
    (bool, error) 
} 

此接口定义了服务。

提示

你可能会认为VaultService比仅仅Service更好,但请记住,由于这是一个 Go 包,它将在外部被视为vault.Service,这听起来很顺耳。

我们定义了两个方法:HashValidate。每个方法都将context.Context作为第一个参数,然后是正常的string参数。响应也是正常的 Go 类型:stringboolerror

提示

一些库可能仍然需要旧的上下文依赖项,即golang.org/x/net/context,而不是 Go 1.7 首次提供的context包。注意错误关于混合使用的问题,并确保你导入的是正确的。

设计微服务的一部分是注意状态存储的位置。即使你将在单个文件中实现服务的各种方法,并且可以访问全局变量,你也绝不应该使用它们来存储每个请求或甚至每个服务的状态。重要的是要记住,每个服务可能会在多个物理机器上多次运行,每个机器都无法访问其他机器的全局变量。

在这个精神下,我们将使用一个空的struct来实现我们的服务,这实际上是一个整洁的 Go 惯用技巧,可以将方法组合在一起,以便在不存储对象本身中的任何状态的情况下实现接口。向service.go添加以下struct

type vaultService struct{} 

提示

如果实现确实需要任何依赖项(例如数据库连接或配置对象),你可以将它们存储在结构体中,并在函数体中使用方法接收器。

从测试开始

在可能的情况下,首先编写测试代码有许多优点,通常最终会提高代码的质量和可维护性。我们将编写一个单元测试,该测试将使用我们新的服务来散列并验证密码。

创建一个名为service_test.go的新文件,并添加以下代码:

package vault 
import ( 
  "testing" 
  "golang.org/x/net/context" 
) 
func TestHasherService(t *testing.T) { 
  srv := NewService() 
  ctx := context.Background() 
  h, err := srv.Hash(ctx, "password") 
  if err != nil { 
    t.Errorf("Hash: %s", err) 
  } 
  ok, err := srv.Validate(ctx, "password", h) 
  if err != nil { 
    t.Errorf("Valid: %s", err) 
  } 
  if !ok { 
    t.Error("expected true from Valid") 
  } 
  ok, err = srv.Validate(ctx, "wrong password", h) 
  if err != nil { 
    t.Errorf("Valid: %s", err) 
  } 
  if ok { 
    t.Error("expected false from Valid") 
  } 
} 

我们将通过NewService方法创建一个新的服务,然后使用它来调用HashValidate方法。我们甚至测试了一个不愉快的案例,即我们输入了错误的密码,并确保Validate返回false——否则,它将非常不安全。

Go 语言中的构造函数

在其他面向对象的语言中,构造函数是一种特殊的函数,用于创建类的实例。它执行任何初始化并接受所需的参数,例如依赖项等。在这些语言中,通常只有一种创建对象的方式,但它往往具有奇怪的语法或依赖于命名约定(例如,函数名称与类名相同)。

Go 语言没有构造函数;它更简单,只有函数,并且由于函数可以返回参数,构造函数将只是一个全局函数,它返回一个可用的结构体实例。Go 语言的简单哲学驱使语言设计者做出这类决策;而不是强迫人们学习关于构建对象的新概念,开发者只需要学习函数的工作方式,他们就可以使用函数构建构造函数。

即使我们在构建一个对象的过程中没有进行任何特殊的工作(例如初始化字段、验证依赖关系等),有时添加一个构建函数也是值得的。在我们的情况下,我们不想通过暴露vaultService类型来膨胀 API,因为我们已经暴露了我们的Service接口类型,并且将其隐藏在构造函数中是一种实现这一点的不错方式。

vaultService结构定义下方,添加NewService函数:

// NewService makes a new Service. 
func NewService() Service { 
  return vaultService{} 
} 

这不仅阻止了我们暴露内部结构,而且如果将来我们需要对vaultService进行更多工作以准备其使用,我们也可以在不更改 API 的情况下完成,因此不需要我们的包的用户在他们的端进行任何更改,这对于 API 设计来说是一个巨大的胜利。

使用 bcrypt 散列和验证密码

我们将在服务中实现的第一个方法是Hash。它将接受一个密码并生成一个散列。然后可以将生成的散列(以及密码)传递给稍后要调用的Validate方法,该方法将确认或否认密码是否正确。

小贴士

要了解更多关于在应用程序中正确存储密码的方法,请查看 Coda Hale 关于该主题的博客文章,链接为codahale.com/how-to-safely-store-a-password/

我们服务的目的是确保密码永远不会需要存储在数据库中,因为如果有人能够未经授权访问数据库,那将是一个安全风险。相反,您可以生成一个单向哈希(无法解码),它可以安全地存储,并且当用户尝试进行身份验证时,您可以执行检查以查看密码是否生成相同的哈希。如果哈希匹配,则密码相同;否则,它们不相同。

bcrypt 包提供了以安全可靠的方式为我们完成这项工作的方法。

service.go 添加 Hash 方法:

func (vaultService) Hash(ctx context.Context, password
 string) (string, error) { 
  hash, err :=
    bcrypt.GenerateFromPassword([]byte(password),
    bcrypt.DefaultCost) 
  if err != nil { 
    return "", err 
  } 
  return string(hash), nil 
} 

确保您导入适当的 bcrypt 包(尝试 golang.org/x/crypto/bcrypt)。我们本质上是在包装 GenerateFromPassword 函数以生成哈希,然后在没有错误发生的情况下返回它。

注意,Hash 方法中的接收器只是 (vaultService);我们没有捕获变量,因为我们无法在空 struct 上存储状态。

接下来,让我们添加 Validate 方法:

func (vaultService) Validate(ctx context.Context,
  password, hash string) (bool, error) { 
  err := bcrypt.CompareHashAndPassword([]byte(hash),
    []byte(password)) 
  if err != nil { 
    return false, nil 
  } 
  return true, nil 
} 

Hash 类似,我们正在调用 bcrypt.CompareHashAndPassword 以安全方式确定密码是否正确。如果返回错误,则表示有问题,我们返回 false 表示。否则,当密码有效时,我们返回 true

使用请求和响应模拟方法调用

由于我们的服务将通过各种传输协议公开,我们需要一种方式来模拟服务内外部的请求和响应。我们将通过为服务将接受或返回的每种消息类型添加一个 struct 来实现这一点。

为了让某人能够调用 Hash 方法并接收哈希密码作为响应,我们需要将以下两个结构添加到 service.go

type hashRequest struct { 
  Password string `json:"password"` 
} 
type hashResponse struct { 
  Hash string `json:"hash"` 
  Err  string `json:"err,omitempty"` 
} 

hashRequest 类型包含一个字段,即密码,而 hashResponse 包含生成的哈希以及一个 Err 字符串字段,以防出现错误。

小贴士

要模拟远程方法调用,您实际上是为传入参数创建一个 struct,并为返回参数创建一个 struct

在继续之前,看看您是否可以为 Validate 方法模拟相同的请求/响应对。查看 Service 接口中的签名,检查它接受的参数,并考虑它需要做出什么样的响应。

我们将添加一个辅助方法(类型为 Go kit 的 http.DecodeRequestFunc),它将能够将 http.Request 的 JSON 主体解码到 service.go

func decodeHashRequest(ctx context.Context, r
 *http.Request) (interface{}, error) { 
  var req hashRequest 
  err := json.NewDecoder(r.Body).Decode(&req) 
  if err != nil { 
    return nil, err 
  } 
  return req, nil 
} 

decodeHashRequest 的签名由 Go kit 决定,因为它将稍后代表我们解码 HTTP 请求。在这个函数中,我们只是使用 json.Decoder 将 JSON 解码到我们的 hashRequest 类型中。

接下来,我们将为 Validate 方法添加请求和响应结构以及一个解码辅助函数:

type validateRequest struct { 
  Password string `json:"password"` 
  Hash     string `json:"hash"` 
} 
type validateResponse struct { 
  Valid bool   `json:"valid"` 
  Err   string `json:"err,omitempty"` 
} 
func decodeValidateRequest(ctx context.Context, 
 r *http.Request) (interface{}, error) { 
  var req validateRequest 
  err := json.NewDecoder(r.Body).Decode(&req) 
  if err != nil { 
    return nil, err 
  } 
  return req, nil 
} 

在这里,validateRequest 结构体同时接受 PasswordHash 字符串,因为签名有两个输入参数,并返回一个包含名为 ValidErrbool 数据类型的响应。

我们需要做的最后一件事是编码响应。在这种情况下,我们可以编写一个单独的方法来编码 hashResponsevalidateResponse 对象。

将以下代码添加到 service.go 中:

func encodeResponse(ctx context.Context, 
  w http.ResponseWriter, response interface{})
error { 
  return json.NewEncoder(w).Encode(response) 
} 

我们的 encodeResponse 方法只是让 json.Encoder 帮我们完成工作。再次注意,签名是通用的,因为 response 类型是 interface{};这是因为它是 Go kit 用于解码到 http.ResponseWriter 的机制。

Go kit 中的端点

端点是 Go kit 中的一个特殊函数类型,它代表单个 RPC 方法。定义在 endpoint 包中:

type Endpoint func(ctx context.Context, request
  interface{})  
(response interface{}, err error) 

端点函数接受 context.Contextrequest,并返回 responseerrorrequestresponse 类型是 interface{},这告诉我们,在构建端点时,处理实际类型的责任在于实现代码。

端点很强大,因为,就像 http.Handler(和 http.HandlerFunc)一样,你可以用通用中间件包装它们,以解决在构建微服务时出现的各种常见问题:日志记录、跟踪、速率限制、错误处理等等。

Go kit 解决了在多种协议上传输的问题,并使用端点作为从它们的代码跳转到我们的代码的通用方式。例如,gRPC 服务器将在端口上监听,并在接收到适当的消息时调用相应的 Endpoint 函数。多亏了 Go kit,这一切对我们来说都是透明的,因为我们只需要用 Go 代码处理我们的 Service 接口。

为服务方法创建端点

为了将我们的服务方法转换为 endpoint.Endpoint 函数,我们将编写一个处理传入的 hashRequest、调用 Hash 服务方法,并根据响应构建和返回适当的 hashResponse 对象的函数。

MakeHashEndpoint 函数添加到 service.go 中:

func MakeHashEndpoint(srv Service) endpoint.Endpoint { 
  return func(ctx context.Context, request interface{})
  (interface{}, error) { 
    req := request.(hashRequest) 
    v, err := srv.Hash(ctx, req.Password) 
    if err != nil { 
      return hashResponse{v, err.Error()}, nil 
    } 
    return hashResponse{v, ""}, nil 
  } 
} 

这个函数接受 Service 作为参数,这意味着我们可以从我们的 Service 接口的任何实现中生成端点。然后我们使用类型断言来指定请求参数实际上应该是 hashRequest 类型。我们调用 Hash 方法,传入上下文和 Password,这些是从 hashRequest 获取的。如果一切顺利,我们使用从 Hash 方法返回的值构建 hashResponse 并返回它。

让我们对 Validate 方法也做同样的事情:

func MakeValidateEndpoint(srv Service) endpoint.Endpoint { 
  return func(ctx context.Context, request interface{})
  (interface{}, error) { 
    req := request.(validateRequest) 
    v, err := srv.Validate(ctx, req.Password, req.Hash) 
    if err != nil { 
      return validateResponse{false, err.Error()}, nil 
    } 
    return validateResponse{v, ""}, nil 
  } 
} 

这里,我们做的是同样的事情:获取请求并使用它来调用方法,然后再构建响应。请注意,我们从不会从 Endpoint 函数返回错误。

不同的错误级别

在 Go kit 中,主要有两种错误类型:传输错误(网络故障、超时、断开连接等)和业务逻辑错误(请求和响应的基础设施执行成功,但逻辑或数据中存在问题)。

如果Hash方法返回错误,我们不会将其作为第二个参数返回;相反,我们将构建hashResponse,其中包含错误字符串(可通过Error方法访问)。这是因为从端点返回的错误旨在指示传输错误,也许 Go kit 将通过某些中间件配置为重试调用几次。如果我们的服务方法返回错误,则被视为业务逻辑错误,并且对于相同的输入可能会始终返回相同的错误,因此不值得重试。这就是为什么我们将错误包装到响应中,并将其返回给客户端,以便他们可以处理它。

将端点包装到服务实现中

在 Go kit 中处理端点时,另一个非常有用的技巧是编写我们vault.Service接口的实现,它只是对底层端点进行必要的调用。

将以下结构体添加到service.go中:

type Endpoints struct { 
  HashEndpoint     endpoint.Endpoint 
  ValidateEndpoint endpoint.Endpoint 
} 

为了实现vault.Service接口,我们将在我们的Endpoints结构体中添加两个方法,这些方法将构建一个请求对象,发送请求,并将生成的响应对象解析为要返回的正常参数。

添加以下Hash方法:

func (e Endpoints) Hash(ctx context.Context, password
  string) (string, error) { 
  req := hashRequest{Password: password} 
  resp, err := e.HashEndpoint(ctx, req) 
  if err != nil { 
    return "", err 
  } 
  hashResp := resp.(hashResponse) 
  if hashResp.Err != "" { 
    return "", errors.New(hashResp.Err) 
  } 
  return hashResp.Hash, nil 
} 

我们使用hashRequest调用HashEndpoint,我们使用密码参数在将一般响应缓存到hashResponse并从中返回哈希值或错误之前创建它。

我们将对Validate方法做同样的事情:

func (e Endpoints) Validate(ctx context.Context, password,
 hash string) (bool, error) { 
  req := validateRequest{Password: password, Hash: hash} 
  resp, err := e.ValidateEndpoint(ctx, req) 
  if err != nil { 
    return false, err 
  } 
  validateResp := resp.(validateResponse) 
  if validateResp.Err != "" { 
    return false, errors.New(validateResp.Err) 
  } 
  return validateResp.Valid, nil 
} 

这两个方法将使我们能够将我们创建的端点视为正常的 Go 方法;这对于我们在本章后面实际消费服务时非常有用。

Go kit 中的 HTTP 服务器

当我们为我们的端点创建一个 HTTP 服务器以进行哈希和验证时,Go kit 的真实价值才显现出来。

创建一个名为server_http.go的新文件,并添加以下代码:

package vault 
import ( 
  "net/http" 
  httptransport "github.com/go-kit/kit/transport/http" 
  "golang.org/x/net/context" 
) 
func NewHTTPServer(ctx context.Context, endpoints
 Endpoints) http.Handler { 
  m := http.NewServeMux() 
  m.Handle("/hash", httptransport.NewServer( 
    ctx, 
    endpoints.HashEndpoint, 
    decodeHashRequest, 
    encodeResponse, 
  )) 
  m.Handle("/validate", httptransport.NewServer( 
    ctx, 
    endpoints.ValidateEndpoint, 
    decodeValidateRequest, 
    encodeResponse, 
  )) 
  return m 
} 

我们正在导入github.com/go-kit/kit/transport/http包,并且(由于我们还在导入net/http包)告诉 Go 我们将显式地引用此包为httptransport

我们正在使用标准库中的 NewServeMux 函数来构建 http.Handler 接口,并进行简单的路由,将 /hash/validate 路径映射。我们获取 Endpoints 对象,因为我们想让我们的 HTTP 服务器提供这些端点,包括我们稍后可能添加的任何中间件。调用 httptransport.NewServer 是让 Go kit 为每个端点提供 HTTP 处理器的方法。像大多数函数一样,我们传入 context.Context 作为第一个参数,这将形成每个请求的基本上下文。我们还传入端点以及我们之前编写的解码和编码函数,以便服务器知道如何反序列化和序列化 JSON 消息。

Go kit 中的 gRPC 服务器

使用 Go kit 添加 gRPC 服务器几乎和添加 JSON/HTTP 服务器一样简单,就像我们在上一节中所做的那样。在我们的生成代码(在 pb 文件夹中),我们得到了以下 pb.VaultServer 类型:

type VaultServer interface { 
  Hash(context.Context, *HashRequest)
    (*HashResponse, error) 
  Validate(context.Context, *ValidateRequest)
    (*ValidateResponse, error) 
} 

这个类型与我们自己的 Service 接口非常相似,只是它接受生成请求和响应类而不是原始参数。

我们将首先定义一个将实现前面接口的类型。将以下代码添加到一个名为 server_grpc.go 的新文件中:

package vault 
import ( 
  "golang.org/x/net/context" 
  grpctransport "github.com/go-kit/kit/transport/grpc" 
) 
type grpcServer struct { 
  hash     grpctransport.Handler 
  validate grpctransport.Handler 
} 
func (s *grpcServer) Hash(ctx context.Context,
 r *pb.HashRequest) (*pb.HashResponse, error) { 
  _, resp, err := s.hash.ServeGRPC(ctx, r) 
  if err != nil { 
    return nil, err 
  } 
  return resp.(*pb.HashResponse), nil 
} 
func (s *grpcServer) Validate(ctx context.Context,
 r *pb.ValidateRequest) (*pb.ValidateResponse, error) { 
  _, resp, err := s.validate.ServeGRPC(ctx, r) 
  if err != nil { 
    return nil, err 
  } 
  return resp.(*pb.ValidateResponse), nil 
} 

注意,你需要导入 github.com/go-kit/kit/transport/grpc 作为 grpctransport,以及生成的 pb 包。

grpcServer 结构体包含每个服务端点的字段,这次是 grpctransport.Handler 类型。然后,我们实现接口的方法,在适当的处理器上调用 ServeGRPC 方法。这个方法实际上会通过首先解码请求,调用适当的端点函数,获取响应,然后编码并发送回请求客户端来处理请求。

从协议缓冲类型转换为我们的类型

你会注意到我们正在使用 pb 包中的请求和响应对象,但请记住,我们自己的端点使用我们在 service.go 中早期添加的结构。我们将需要一个针对每种类型的方法来将其转换为我们的类型。

小贴士

接下来会有很多重复的输入;如果你愿意,可以从 GitHub 仓库 github.com/matryer/goblueprints 复制粘贴以节省你的手指。我们正在手动编码,因为这很重要,要理解构成服务的所有部分。

server_grpc.go 中添加以下函数:

func EncodeGRPCHashRequest(ctx context.Context,
  r interface{}) (interface{}, error) { 
  req := r.(hashRequest) 
  return &pb.HashRequest{Password: req.Password}, nil 
} 

这个函数是 Go kit 定义的 EncodeRequestFunc 函数,它用于将我们的 hashRequest 类型转换为可以与客户端通信的协议缓冲类型。它使用 interface{} 类型,因为它很通用,但在这个案例中,我们可以确信类型,因此我们将传入的请求转换为 hashRequest(我们的类型)然后使用适当的字段构建一个新的 pb.HashRequest 对象。

我们将为此进行编码和解码请求和响应,包括 hash 和 validate 端点的编码和解码。将以下代码添加到server_grpc.go中:

func DecodeGRPCHashRequest(ctx context.Context,
 r interface{}) (interface{}, error) { 
  req := r.(*pb.HashRequest) 
  return hashRequest{Password: req.Password}, nil 
} 
func EncodeGRPCHashResponse(ctx context.Context,
 r interface{}) (interface{}, error) { 
  res := r.(hashResponse) 
  return &pb.HashResponse{Hash: res.Hash, Err: res.Err},
    nil 
} 
func DecodeGRPCHashResponse(ctx context.Context,
 r interface{}) (interface{}, error) { 
  res := r.(*pb.HashResponse) 
  return hashResponse{Hash: res.Hash, Err: res.Err}, nil 
} 
func EncodeGRPCValidateRequest(ctx context.Context,
 r interface{}) (interface{}, error) { 
  req := r.(validateRequest) 
  return &pb.ValidateRequest{Password: req.Password,
    Hash: req.Hash}, nil 
} 
func DecodeGRPCValidateRequest(ctx context.Context,
 r interface{}) (interface{}, error) { 
  req := r.(*pb.ValidateRequest) 
  return validateRequest{Password: req.Password,
    Hash: req.Hash}, nil 
} 
func EncodeGRPCValidateResponse(ctx context.Context,
 r interface{}) (interface{}, error) { 
  res := r.(validateResponse) 
  return &pb.ValidateResponse{Valid: res.Valid}, nil 
} 
func DecodeGRPCValidateResponse(ctx context.Context,
 r interface{}) (interface{}, error) { 
  res := r.(*pb.ValidateResponse) 
  return validateResponse{Valid: res.Valid}, nil 
} 

如您所见,为了使事物正常工作,需要进行大量的模板代码编写。

小贴士

代码生成(此处未涉及)在这里会有很大的应用,因为代码非常可预测且具有自我相似性。

为了使我们的 gRPC 服务器正常工作,最后要做的事情是提供一个辅助函数来创建我们的grpcServer结构体实例。在grpcServer结构体下面,添加以下代码:

func NewGRPCServer(ctx context.Context, endpoints
 Endpoints) pb.VaultServer { 
  return &grpcServer{ 
    hash: grpctransport.NewServer( 
      ctx, 
      endpoints.HashEndpoint, 
      DecodeGRPCHashRequest, 
      EncodeGRPCHashResponse, 
    ), 
    validate: grpctransport.NewServer( 
      ctx, 
      endpoints.ValidateEndpoint, 
      DecodeGRPCValidateRequest, 
      EncodeGRPCValidateResponse, 
    ), 
  } 
} 

就像我们的 HTTP 服务器一样,我们接收一个基本上下文和通过 gRPC 服务器公开的实际Endpoints实现。我们创建并返回一个新的grpcServer类型实例,通过调用grpctransport.NewServer来设置hashvalidate的处理程序。我们使用我们的endpoint.Endpoint函数来处理服务,并告诉服务使用我们哪些编码/解码函数来处理每种情况。

创建服务器命令

到目前为止,我们所有的服务代码都位于vault包内部。我们现在将使用这个包来创建一个新的工具,以暴露服务器功能。

vault中创建一个新的文件夹名为cmd,并在其中创建另一个名为vaultd的文件夹。我们将把命令代码放在vaultd文件夹中,因为尽管代码将在main包中,但工具的名称默认为vaultd。如果我们只是将命令放在cmd文件夹中,工具将被构建成一个名为cmd的二进制文件,这会很令人困惑。

注意

在 Go 项目中,如果包的主要用途是导入到其他程序中(如 Go kit),则根级文件应组成包,并将具有适当的包名(不是main)。如果主要目的是命令行工具,如 Drop 命令(github.com/matryer/drop),则根文件将在main包中。

这种做法的合理性在于可用性;当导入一个包时,您希望用户必须输入的字符串尽可能短。同样,当使用go install时,您希望路径既短又简洁。

我们将要构建的工具(后缀为d,表示它是守护程序或后台任务)将启动我们的 gRPC 和 JSON/HTTP 服务器。每个服务器将在自己的 goroutine 中运行,我们将捕获来自服务器的任何终止信号或错误,这将导致我们的程序终止。

在 Go kit 中,主函数最终会变得相当大,这是有意为之;有一个函数包含您整个微服务的全部内容;从那里,您可以深入了解细节,但它提供了每个组件的直观视图。

我们将在vaultd文件夹中的新main.go文件中逐步构建main函数,首先是一个相当大的导入列表:

import ( 
  "flag" 
  "fmt" 
  "log" 
  "net" 
  "net/http" 
  "os" 
  "os/signal" 
  "syscall" 
  "your/path/to/vault" 
  "your/path/to/vault/pb" 
  "golang.org/x/net/context" 
  "google.golang.org/grpc" 
) 

应将 your/path/to 前缀替换为从 $GOPATH 到你的项目的实际路由。请注意上下文导入;在 Go kit 转移到 Go 1.7 的时候,你可能只需要输入 context 而不是这里列出的导入。最后,Google 的 grpc 包为我们提供了在网络上公开 gRPC 功能所需的一切。

现在,我们将组合我们的 main 函数;记住,从这一部分开始的全部内容都放在 main 函数体内:

func main() { 
  var ( 
    httpAddr = flag.String("http", ":8080",
      "http listen address") 
    gRPCAddr = flag.String("grpc", ":8081",
      "gRPC listen address") 
  ) 
  flag.Parse() 
  ctx := context.Background() 
  srv := vault.NewService() 
  errChan := make(chan error) 

我们使用标志来允许操作团队决定在网络上公开服务时将监听哪些端点,但为 JSON/HTTP 服务器提供合理的默认值 :8080,为 gRPC 服务器提供 :8081

然后,我们使用 context.Background() 函数创建一个新的上下文,该函数返回一个非空、空的上下文,没有指定取消或截止日期,也不包含任何值,非常适合我们所有服务的基上下文。请求和中间件可以自由地从该上下文中创建新的上下文对象,以便添加请求范围的数据或截止日期。

接下来,我们使用 NewService 构造函数为我们创建一个新的 Service 类型,并创建一个零缓冲通道,该通道可以接收错误(如果发生错误)。

我们现在将添加代码来捕获终止信号(如 Ctrl + C)并将错误发送到 errChan

  go func() { 
    c := make(chan os.Signal, 1) 
    signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 
    errChan <- fmt.Errorf("%s", <-c) 
  }() 

在这里,在一个新的 goroutine 中,我们要求 signal.Notify 通知我们何时接收到 SIGINTSIGTERM 信号。当这种情况发生时,信号将通过 c 通道发送,此时我们将它格式化为字符串(调用其 String() 方法),并将其转换为错误,然后将其发送到 errChan,从而导致程序终止。

使用 Go kit 端点

是时候创建一个我们可以传递给服务器的端点实例了。将以下代码添加到主函数体中:

  hashEndpoint := vault.MakeHashEndpoint(srv) 
  validateEndpoint := vault.MakeValidateEndpoint(srv) 
  endpoints := vault.Endpoints{ 
    HashEndpoint:     hashEndpoint, 
    ValidateEndpoint: validateEndpoint, 
  } 

我们将字段分配给端点辅助函数的输出,对于哈希和验证方法都是如此。我们为两者传递相同的服务,因此 endpoints 变量实际上是我们 srv 服务的包装器。

小贴士

你可能会想通过完全删除变量的赋值来整理这段代码,直接将辅助函数的返回值设置到结构体初始化的字段中,但当我们稍后添加中间件时,你会感谢这种方法的。

我们现在可以使用这些端点来启动我们的 JSON/HTTP 和 gRPC 服务器。

运行 HTTP 服务器

现在,我们将添加一个 goroutine 到主函数体中,用于创建和运行 JSON/HTTP 服务器:

  // HTTP transport 
  go func() { 
    log.Println("http:", *httpAddr) 
    handler := vault.NewHTTPServer(ctx, endpoints) 
    errChan <- http.ListenAndServe(*httpAddr, handler) 
  }() 

Go kit 在我们的包代码中已经为我们完成了所有繁重的工作,所以我们只需调用 NewHTTPServer 函数,传递背景上下文和希望公开的服务端点,然后在调用标准库的 http.ListenAndServe 之前,该函数在指定的 httpAddr 中公开处理器功能。如果发生错误,我们将它发送到错误通道。

运行 gRPC 服务器

为了运行 gRPC 服务器,还需要做一些额外的工作,但仍然相当简单。我们必须创建一个低级别的 TCP 网络监听器,并在其上提供 gRPC 服务器。将以下代码添加到主函数主体中:

  go func() { 
    listener, err := net.Listen("tcp", *gRPCAddr) 
    if err != nil { 
      errChan <- err 
      return 
    } 
    log.Println("grpc:", *gRPCAddr) 
    handler := vault.NewGRPCServer(ctx, endpoints) 
    gRPCServer := grpc.NewServer() 
    pb.RegisterVaultServer(gRPCServer, handler) 
    errChan <- gRPCServer.Serve(listener) 
  }() 

我们在指定的 gRPCAddr 端点创建 TCP 监听器,并将任何错误发送到 errChan 错误通道。我们使用 vault.NewGRPCServer 创建处理器,再次传递背景上下文和我们公开的 Endpoints 实例。

提示

注意到 JSON/HTTP 服务器和 gRPC 服务器实际上公开的是相同的服务——字面上是同一个实例。

然后,我们使用 Google 的 grpc 包创建一个新的 gRPC 服务器,并通过 RegisterVaultServer 函数使用我们自己的生成的 pb 包进行注册。

注意

RegisterVaultService 函数只是在我们自己的 grpcServer 上调用 RegisterService,但隐藏了自动生成的服务描述的内部细节。如果你查看 vault.pb.go 并搜索 RegisterVaultServer 函数,你会看到它引用了类似 &_Vault_serviceDesc 的内容,这是服务的描述。你可以随意挖掘生成的代码;元数据特别有趣,但本书不涉及这部分内容。

然后,我们要求服务器自己 Serve,如果发生错误,将错误信息发送到同一个错误通道。

提示

这章不涉及,但建议每个服务都应提供传输层安全性TLS),特别是处理密码的服务。

防止主函数立即终止

如果我们在这里关闭了主函数,它将立即退出并终止所有服务器。这是因为我们正在做的所有防止这种情况发生的事情都在它自己的 goroutine 中。为了防止这种情况,我们需要一种方法在函数末尾阻塞,等待程序收到终止信号。

由于我们使用 errChan 错误通道来处理错误,这是一个完美的候选者。我们可以监听这个通道,在没有任何内容发送下来时,它会阻塞并允许其他 goroutine 执行它们的工作。如果出现问题(或收到终止信号),<-errChan 调用将解除阻塞并退出,所有 goroutine 都将停止。

在主函数的底部,添加最后的语句和结束块:

  log.Fatalln(<-errChan) 
} 

当发生错误时,我们只是记录它并以非零代码退出。

通过 HTTP 消费服务

现在我们已经连接好了一切,我们可以使用 curl 命令或任何允许我们发送 JSON/HTTP 请求的工具来测试 HTTP 服务器。

在终端中,让我们首先运行我们的服务器。转到 vault/cmd/vaultd 文件夹并启动程序:

go run main.go

服务器启动后,你将看到如下内容:

http: :8080
grpc: :8081

现在,打开另一个终端,使用 curl 发出以下 HTTP 请求:

curl -XPOST -d '{"password":"hernandez"}'
    http://localhost:8080/hash

我们正在向散列端点发送一个包含我们想要散列的密码的 JSON 体的 POST 请求。然后,我们得到如下内容:

 {"hash":"$2a$10$IXYT10DuK3Hu.
      NZQsyNafF1tyxe5QkYZKM5by/5Ren"} 

小贴士

在这个例子中的散列值不会与你的匹配——有多个可接受的散列值,而且无法知道你会得到哪一个。确保复制并粘贴你的实际散列值(双引号内的所有内容)。

生成的散列值是我们根据指定的密码存储在数据存储中的值。然后,当用户再次尝试登录时,我们将使用他们输入的密码以及这个散列值向验证端点发送请求:

curl -XPOST -d
     '{"password":"hernandez",
       "hash":"PASTE_YOUR_HASH_HERE"}'
     http://localhost:8080/validate

通过复制和粘贴正确的散列值并输入相同的 hernandez 密码来发送此请求,你将看到以下结果:

{"valid":true}

现在,更改密码(这相当于用户输入错误)并将看到以下内容:

{"valid":false}

你可以看到,我们 vault 服务的 JSON/HTTP 微服务暴露是完整且正在工作的。

接下来,我们将探讨如何消费 gRPC 版本。

构建 gRPC 客户端

与 JSON/HTTP 服务不同,gRPC 服务并不容易供人类交互。它们实际上是作为机器到机器的协议而设计的,因此如果我们想使用它们,我们必须编写一个程序。

为了帮助我们做到这一点,我们首先将在我们的 vault 服务内部添加一个新的包,名为 vault/client/grpc。它将提供一个对象,该对象在从 Google 的 grpc 包获取的 gRPC 客户端连接对象的基础上执行适当的调用、编码和解码,所有这些都在我们自己的 vault.Service 接口背后隐藏。因此,我们将能够将这个对象用作我们接口的另一个实现。

在 vault 中创建新的文件夹,以便你有 vault/client/grpc 的路径。如果你愿意,可以想象添加其他客户端,因此这似乎是一个建立良好模式的合适选择。

将以下代码添加到一个新的 client.go 文件中:

func New(conn *grpc.ClientConn) vault.Service { 
  var hashEndpoint = grpctransport.NewClient( 
    conn, "Vault", "Hash", 
    vault.EncodeGRPCHashRequest, 
    vault.DecodeGRPCHashResponse, 
    pb.HashResponse{}, 
  ).Endpoint() 
  var validateEndpoint = grpctransport.NewClient( 
    conn, "Vault", "Validate", 
    vault.EncodeGRPCValidateRequest, 
    vault.DecodeGRPCValidateResponse, 
    pb.ValidateResponse{}, 
  ).Endpoint() 
  return vault.Endpoints{ 
    HashEndpoint:     hashEndpoint, 
    ValidateEndpoint: validateEndpoint, 
  } 
} 

grpctransport 包引用的是 github.com/go-kit/kit/transport/grpc。现在这可能会让你感到熟悉;我们正在根据指定的连接创建两个新的端点,这次明确指定了 Vault 服务名称和端点名称 HashValidate。我们从前端的 vault 包中传递适当的编码器和解码器以及空响应对象,然后将它们都包装在我们的 vault.Endpoints 结构中,这是我们添加的结构——它实现了 vault.Service 接口,该接口为我们触发了指定的端点。

消费服务的命令行工具

在本节中,我们将编写一个命令行工具(或 CLI-命令行界面),它将允许我们通过 gRPC 协议与我们的服务进行通信。如果我们用 Go 编写另一个服务,我们将以与编写 CLI 工具时相同的方式使用 vault 客户端包。

我们的工具将允许你在命令行中以流畅的方式访问服务,通过用空格分隔命令和参数,这样我们就可以像这样哈希密码:

vaultcli hash MyPassword

我们将能够使用如下哈希来验证密码:

vaultcli hash MyPassword HASH_GOES_HERE

cmd文件夹中,创建一个名为vaultcli的新文件夹。添加一个main.go文件并插入以下主函数:

func main() { 
  var ( 
    grpcAddr = flag.String("addr", ":8081",
     "gRPC address") 
  ) 
  flag.Parse() 
  ctx := context.Background() 
  conn, err := grpc.Dial(*grpcAddr, grpc.WithInsecure(),  
  grpc.WithTimeout(1*time.Second)) 
  if err != nil { 
    log.Fatalln("gRPC dial:", err) 
  } 
  defer conn.Close() 
  vaultService := grpcclient.New(conn) 
  args := flag.Args() 
  var cmd string 
  cmd, args = pop(args) 
  switch cmd { 
  case "hash": 
    var password string 
    password, args = pop(args) 
    hash(ctx, vaultService, password) 
  case "validate": 
    var password, hash string 
    password, args = pop(args) 
    hash, args = pop(args) 
    validate(ctx, vaultService, password, hash) 
  default: 
    log.Fatalln("unknown command", cmd) 
  } 
} 

确保你将vault/client/grpc包导入为grpcclient,将google.golang.org/grpc导入为grpc。你还需要导入vault包。

在调用 gRPC 端点以建立连接之前,我们像往常一样解析标志并获取背景上下文。如果一切顺利,我们将延迟关闭连接并使用该连接创建我们的 vault 服务客户端。记住,这个对象实现了我们的vault.Service接口,因此我们可以像调用正常 Go 方法一样调用这些方法,而无需担心通信是通过网络协议进行的。

然后,我们开始解析命令行参数,以决定采取哪种执行流程。

在 CLI 中解析参数

在命令行工具中解析参数非常常见,在 Go 中有一个整洁的惯用方法来做这件事。所有参数都可通过os.Args切片获得,或者如果你使用标志,则通过flags.Args()方法(该方法获取不带标志的参数)。我们想要从切片(从开始处)移除每个参数并按顺序消费它们,这将帮助我们决定通过程序采取哪种执行流程。我们将添加一个名为pop的辅助函数,它将返回第一个项目,并返回移除了第一个项目的切片。

我们将编写一个快速单元测试来确保我们的pop函数按预期工作。如果你想要尝试自己编写pop函数,那么一旦测试就绪,你应该这样做。记住,你可以通过在终端中导航到相应的文件夹并执行以下命令来运行测试:

go test

vaultcli内部创建一个名为main_test.go的新文件,并添加以下测试函数:

func TestPop(t *testing.T) { 
  args := []string{"one", "two", "three"} 
  var s string 
  s, args = pop(args) 
  if s != "one" { 
    t.Errorf("unexpected "%s"", s) 
  } 
  s, args = pop(args) 
  if s != "two" { 
    t.Errorf("unexpected "%s"", s) 
  } 
  s, args = pop(args) 
  if s != "three" { 
    t.Errorf("unexpected "%s"", s) 
  } 
  s, args = pop(args) 
  if s != "" { 
    t.Errorf("unexpected "%s"", s) 
  } 
} 

我们期望每次调用pop都会返回切片中的下一个项目,并且在切片为空时返回空参数。

main.go的底部添加pop函数:

func pop(s []string) (string, []string) { 
  if len(s) == 0 { 
    return "", s 
  } 
  return s[0], s[1:] 
} 

通过提取 case 体来保持良好的视线

我们唯一剩下要做的事情是实现前面 switch 语句中提到的哈希和验证方法。

我们本可以将此代码嵌入到 switch 语句本身中,但这样会使主函数难以阅读,并且会隐藏不同缩进级别上的 happy path 执行,这是我们应尽量避免的。

相反,将 switch 语句中的情况跳转到专用函数中是一个好习惯,该函数接受它需要的任何参数。在主函数下方,添加以下哈希和验证函数:

func hash(ctx context.Context, service vault.Service, 
  password string) { 
  h, err := service.Hash(ctx, password) 
  if err != nil { 
    log.Fatalln(err.Error()) 
  } 
  fmt.Println(h) 
} 
func validate(ctx context.Context, service vault.Service,  
  password, hash string) { 
  valid, err := service.Validate(ctx, password, hash) 
  if err != nil { 
    log.Fatalln(err.Error()) 
  } 
  if !valid { 
    fmt.Println("invalid") 
    os.Exit(1) 
  } 
  fmt.Println("valid") 
} 

这些函数只是简单地调用服务上的相应方法,并根据结果将结果记录或打印到控制台。如果验证方法返回 false,程序将以退出代码 1 退出,因为非零值表示错误。

从 Go 源代码安装工具

要安装此工具,我们只需在终端中导航到vaultcli文件夹,并输入以下命令:

go install

假设没有错误,该包将被构建并部署到$GOPATH/bin文件夹,该文件夹应该已经列在你的$PATH环境变量中。这意味着工具已经准备好像终端中的正常命令一样使用。

部署的二进制文件名称将与文件夹名称匹配,这就是为什么即使在只构建单个命令的情况下,我们也在cmd文件夹内有一个额外的文件夹。

一旦安装了命令,我们就可以使用它来测试 gRPC 服务器。

前往cmd/vaultd并启动服务器(如果它还没有运行),只需输入以下命令:

go run main.go

在另一个终端中,通过输入以下命令来哈希密码:

vaultcli hash blanca

注意,哈希值被返回。现在让我们验证这个哈希值:

vaultcli validate blanca PASTE_HASH_HERE

小贴士

哈希值可能包含会干扰您的终端的特殊字符,因此如果需要,您应该用引号转义字符串。

在 Mac 上,用$'PASTE_HASH_HERE'格式化参数以正确转义它。

在 Windows 上,尝试用感叹号包围参数:!PASTE_HASH_HERE!

如果您输入了正确的密码,您会注意到您看到了单词valid;否则,您会看到invalid

使用服务中间件进行速率限制

现在我们已经构建了一个完整的服务,我们将看到如何轻松地向我们的端点添加中间件,以扩展服务而不需要触及实际的实现。

在现实世界的服务中,限制它将尝试处理的请求数量是合理的,这样服务就不会过载。这可能发生在进程需要的内存比可用内存多的情况下,或者如果我们注意到性能下降,那么它可能消耗了太多的 CPU。在微服务架构中,解决这些问题的策略是添加另一个节点并分散负载,这意味着我们希望每个单独的实例都受到速率限制。

由于我们提供了客户端,我们应该在那里添加速率限制,这将防止过多的请求进入网络。但是,如果许多客户端同时尝试访问相同的服务,添加到服务器上的速率限制也是合理的。幸运的是,Go kit 中的端点既用于客户端也用于服务器,因此我们可以使用相同的代码在这两个地方添加中间件。

我们将添加一个基于 Token Bucket 的速率限制器,你可以在 en.wikipedia.org/wiki/Token_bucket 上了解更多信息。Juju 团队已经编写了一个 Go 实现供我们使用,通过导入 github.com/juju/ratelimit,Go kit 也为这个实现提供了中间件,这将为我们节省大量时间和精力。

通用思路是我们有一个令牌桶,每个请求都需要一个令牌来完成其工作。如果没有令牌在桶中,我们就达到了限制,请求无法完成。桶在特定的时间间隔内填充。

导入 github.com/juju/ratelimit 并在我们创建 hashEndpoint 之前插入以下代码:

rlbucket := ratelimit.NewBucket(1*time.Second, 5) 

NewBucket 函数创建一个新的速率限制桶,以每秒一个令牌的速度填充,最多五个令牌。这些数字对我们来说相当愚蠢,但我们希望在开发过程中能够手动达到我们的限制。

由于 Go kit 的 ratelimit 包与 Juju 的包同名,我们需要用不同的名称来导入它:

import ratelimitkit "github.com/go-kit/kit/ratelimit"  

Go kit 中的中间件

Go kit 中的端点中间件通过 endpoint.Middleware 函数类型指定:

type Middleware func(Endpoint) Endpoint 

一段中间件仅仅是一个接收 Endpoint 并返回 Endpoint 的函数。记住,Endpoint 也是一个函数:

type Endpoint func(ctx context.Context, request
  interface{}) (response interface{}, err error) 

这有点令人困惑,但它们与为我们 http.HandlerFunc 构建的包装器相同。一个中间件函数返回一个 Endpoint 函数,它在调用被包装的 Endpoint 之前和/或之后执行某些操作。传递给返回 Middleware 的函数的参数被闭包,这意味着它们可以通过闭包(无需在其他地方存储状态)在内部代码中使用。

我们将使用 Go kit 的 ratelimit 包中的 NewTokenBucketLimiter 中间件,如果我们查看代码,我们将看到它如何使用闭包并返回函数来在传递执行给 next 端点之前调用令牌桶的 TakeAvailable 方法:

func NewTokenBucketLimiter(tb *ratelimit.Bucket)
  endpoint.Middleware { 
  return func(next endpoint.Endpoint) endpoint.Endpoint { 
    return func(ctx context.Context, request interface{})
    (interface{}, error) { 
      if tb.TakeAvailable(1) == 0 { 
        return nil, ErrLimited 
      } 
      return next(ctx, request) 
    } 
  } 
} 

在 Go kit 中出现了一种模式,即先获取端点,然后立即在其后放置所有中间件适配器。返回的函数在调用时接收端点,并且相同的变量会被覆盖为结果。

对于一个简单的例子,考虑以下代码:

e := getEndpoint(srv) 
{ 
  e = getSomeMiddleware()(e) 
  e = getLoggingMiddleware(logger)(e) 
  e = getAnotherMiddleware(something)(e) 
} 

现在,我们将为此端点执行此操作;更新主函数内的代码以添加速率限制中间件:

  hashEndpoint := vault.MakeHashEndpoint(srv) 
  { 
    hashEndpoint = ratelimitkit.NewTokenBucketLimiter
     (rlbucket)(hashEndpoint) 
  } 
  validateEndpoint := vault.MakeValidateEndpoint(srv) 
  { 
    validateEndpoint = ratelimitkit.NewTokenBucketLimiter
     (rlbucket)(validateEndpoint) 
  } 
  endpoints := vault.Endpoints{ 
    HashEndpoint:     hashEndpoint, 
    ValidateEndpoint: validateEndpoint, 
  } 

这里没有太多需要更改的;我们只是在将 hashEndpointvalidateEndpoint 变量分配给 vault.Endpoints 结构体之前更新它们。

手动测试速率限制器

为了查看我们的速率限制器是否工作,并且由于我们设置了如此低的阈值,我们可以仅使用我们的命令行工具进行测试。

首先,通过在运行服务器的终端窗口中按Ctrl + C来重启服务器(以便运行新代码)。这个信号将被我们的代码捕获,并将错误发送到errChan,导致程序退出。一旦它已经终止,重新启动它:

go run main.go

现在,在另一个窗口中,让我们来哈希一些密码:

vaultcli hash bourgon

重复这个命令几次——在大多数终端中,你可以按上箭头键并回车。你会注意到前几个请求是成功的,因为它们在限制范围内,但如果你稍微激进一些,在一秒内发出超过五个请求,你会注意到我们得到了错误:

$ vaultcli hash bourgon
$2a$10$q3NTkjG0YFZhTG6gBU2WpenFmNzdN74oX0MDSTryiAqRXJ7RVw9sy
$ vaultcli hash bourgon
$2a$10$CdEEtxSDUyJEIFaykbMMl.EikxvV5921gs/./7If6VOdh2x0Q1oLW
$ vaultcli hash bourgon
$2a$10$1DSqQJJGCmVOptwIx6rrSOZwLlOhjHNC83OPVE8SdQ9q73Li5x2le
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
$2a$10$kriTDXdyT6J4IrqZLwgBde663nLhoG3innhCNuf8H2nHf7kxnmSza

这表明我们的速率限制器正在工作。我们会在令牌桶重新填满之前看到错误,然后我们的请求再次得到满足。

优雅的速率限制

与其返回错误(这通常是一个相当严厉的回应),我们可能更希望服务器只是保留我们的请求,并在能够处理时完成它——这就是所谓的节流。对于这种情况,Go kit 提供了NewTokenBucketThrottler中间件。

更新中间件代码,使用这个中间件函数:

  hashEndpoint := vault.MakeHashEndpoint(srv) 
  { 
    hashEndpoint = ratelimitkit.NewTokenBucketThrottler(rlbucket,
     time.Sleep)(hashEndpoint) 
  } 
  validateEndpoint := vault.MakeValidateEndpoint(srv) 
  { 
    validateEndpoint = ratelimitkit.NewTokenBucketThrottler(rlbucket,
      time.Sleep)(validateEndpoint) 
  } 
  endpoints := vault.Endpoints{ 
    HashEndpoint:     hashEndpoint, 
    ValidateEndpoint: validateEndpoint, 
  } 

NewTokenBucketThrottler的第一个参数与之前的端点相同,但现在我们添加了一个time.Sleep的第二个参数。

注意

Go kit 允许我们通过指定在需要延迟时应该发生什么来定制行为。在我们的例子中,我们传递了time.Sleep,这是一个将请求执行暂停指定时间的函数。如果你想做不同的事情,可以在这里编写自己的函数,但现在这个方法就足够了。

现在重复之前的测试,但这次,请注意我们永远不会得到错误——相反,终端会在请求可以满足之前挂起一秒钟。

摘要

通过构建一个真实的微服务示例,我们在本章中涵盖了大量的内容。没有代码生成,涉及的工作量很大,但对于大型团队和大型微服务架构来说,这些投资是值得的,因为你可以构建出构成系统的自相似、离散组件。

我们学习了 gRPC 和协议缓冲如何为我们提供客户端和服务器之间的高效传输通信。使用proto3语言,我们定义了我们的服务,包括消息,并使用工具生成了一个 Go 包,为我们提供了客户端和服务器代码。

我们探讨了 Go kit 的基本原理以及我们如何使用端点来描述我们的服务方法。当涉及到构建 HTTP 和 gRPC 服务器时,我们让 Go kit 为我们做繁重的工作,通过利用项目中包含的包。我们看到了中间件函数如何让我们轻松地将端点适应,例如,限制服务器需要处理的流量量。

我们还学习了 Go 语言中的构造函数,这是一种解析传入命令行参数的巧妙技巧,以及如何使用bcrypt包来哈希和验证密码,这是一个明智的方法,可以帮助我们避免存储密码。

构建微服务还有很多内容,建议您访问 Go kit 网站gokit.io或加入 gophers.slack.com 上的#go-kit频道进行更多了解。

现在我们已经构建了我们的 Vault 服务,我们需要考虑我们的选项以便将其部署到野外。在下一章中,我们将我们的微服务打包成 Docker 容器,并部署到 Digital Ocean 的云平台。

第十一章。使用 Docker 部署 Go 应用程序

Docker 是一个开源生态系统(技术和一系列相关服务),它允许您将应用程序打包到简单、轻量级且可移植的容器中;它们将在任何环境中以相同的方式运行。考虑到我们的开发环境(可能是一个 Mac)与生产环境(如 Linux 服务器甚至云服务)不同,以及我们可能希望部署相同应用程序的大量不同位置,这非常有用。

大多数云平台已经支持 Docker,这使得它成为将我们的应用程序部署到野外的绝佳选择。

在第九章, 为 Google App Engine 构建问答应用程序中,我们构建了一个适用于 Google App Engine 的应用程序。如果我们决定在另一个平台上运行我们的应用程序,即使忘记了我们对 Google Cloud Datastore 的使用,我们也需要对我们的代码进行重大修改。以在 Docker 容器内部署应用程序为目的构建应用程序,为我们提供了额外的灵活性。

注意

您知道 Docker 本身是用 Go 编写的吗?通过浏览github.com/docker/docker的源代码来亲自看看吧。

在本章中,您将学习:

  • 如何编写简单的 Dockerfile 来描述应用程序

  • 如何使用docker命令构建容器

  • 如何在本地运行 Docker 容器并终止它们

  • 如何将 Docker 容器部署到 Digital Ocean

  • 如何使用 Digital Ocean 中的功能启动已预配置 Docker 的实例

我们将把在第十章 使用 Go kit 框架的 Go 微服务 中创建的 Vault 服务放入 Docker 镜像,并将其部署到云中。

在本地使用 Docker

在我们能够将代码部署到云之前,我们必须使用开发机器上的 Docker 工具构建并推送镜像到 Docker Hub。

安装 Docker 工具

为了构建和运行容器,您需要在您的开发机器上安装 Docker。请访问www.docker.com/products/docker并下载适合您电脑的相应安装程序。

Docker 及其生态系统正在快速发展,因此确保您与最新版本保持同步是个好主意。同样,本章中的一些细节可能会发生变化;如果您遇到困难,请访问项目主页github.com/matryer/goblueprints以获取一些有用的提示。

Dockerfile

Docker 镜像就像一个迷你虚拟机。它包含运行应用程序所需的一切:代码将运行的操作系统,我们代码可能需要的任何依赖项(例如,在我们的 Vault 服务中是 Go kit),以及我们应用程序本身的二进制文件。

一个镜像是通过Dockerfile描述的;一个包含一系列特殊命令的文本文件,这些命令指导 Docker 如何构建镜像。它们通常基于另一个容器,这样可以节省您构建和运行 Go 应用程序所需的一切。

在代码中第十章的vault文件夹内,添加一个名为Dockerfile的文件(注意,此文件名没有扩展名),包含以下代码:

FROM scratch 
MAINTAINER Your Name <your@email.address> 
ADD vaultd vaultd 
EXPOSE 8080 8081 
ENTRYPOINT ["/vaultd"] 

Dockerfile文件中的每一行代表在构建镜像时运行的不同命令。以下表格描述了我们使用的每个命令:

Command 描述
FROM 此镜像将基于的镜像名称。单词,如 scratch,代表托管在 Docker Hub 上的官方 Docker 镜像。有关关于 scratch 镜像的更多信息,请参阅https://hub.docker.com/_/scratch/
ADD 将文件复制到容器中。我们正在复制我们的vaultd二进制文件,并将其命名为vaultd
EXPOSE 公开端口号列表;在我们的案例中,Vault 服务绑定到:8080:8081
ENTRYPOINT 当容器在我们的情况下执行时运行的二进制文件,即vaultd二进制文件,它将由之前的 go install 调用放置在那里。
MAINTAINER 维护 Docker 镜像的人的姓名和电子邮件地址。

注意

要获取支持的命令的完整列表,请查阅在线 Docker 文档:docs.docker.com/engine/reference/builder/#dockerfile-reference

为不同架构构建 Go 二进制文件

Go 支持交叉编译,这是一种机制,我们可以在一台机器(比如我们的 Mac)上为目标操作系统(如 Linux 或 Windows)和架构构建二进制文件。Docker 容器是基于 Linux 的;因此,为了提供一个可以在该环境中运行的二进制文件,我们必须首先构建一个。

在终端中,导航到 vault 文件夹并运行以下命令:

CGO_ENABLED=0 GOOS=linux go build -a ./cmd/vaultd/

我们在这里实际上是在调用 go build,但增加了一些额外的部分来控制构建过程。CGO_ENABLEDGOOS是 go build 会注意到的环境变量,-a是一个标志,./cmd/vaultd/是我们想要构建的命令的位置(在我们的案例中,是我们在上一章中构建的vaultd命令)。

  • CGO_ENABLED=0表示我们不希望启用 cgo。由于我们没有绑定任何 C 依赖项,我们可以通过禁用此功能来减小构建的大小。

  • GOOS是 Go 操作系统的缩写,允许我们指定我们正在针对哪个操作系统,在我们的例子中,是 Linux。要查看完整的选项列表,可以直接访问 Go 源代码,通过访问github.com/golang/go/blob/master/src/go/build/syslist.go

一段时间后,你会注意到出现了一个新的二进制文件,名为vaultd。如果你使用的是非 Linux 机器,你将无法直接执行这个文件,但不用担心;它将在我们的 Docker 容器中正常运行。

构建 Docker 镜像

要构建镜像,在终端中导航到Dockerfile并运行以下命令:

docker build -t vaultd

我们使用docker命令来构建镜像。最后的点表示我们想要从当前目录构建 Dockerfile。-t标志指定我们想要给我们的镜像命名为vaultd。这将允许我们通过名称而不是 Docker 分配给它的哈希值来引用它。

如果你第一次使用 Docker,特别是使用scratch基础镜像,那么根据你的网络连接,从 Docker Hub 下载所需的依赖项将需要一些时间。一旦完成,你将看到类似以下输出的内容:

Step 1 : FROM scratch
 --->
Step 2 : MAINTAINER Your Name <your@email.address>
 ---> Using cache
 ---> a8667f8f0881
Step 3 : ADD vaultd vaultd
 ---> 0561c999c1e3
Removing intermediate container 4b75fde507df
Step 4 : EXPOSE 8080 8081
 ---> Running in 8f169f5b3b44
 ---> 1d7758c20b3a
Removing intermediate container 8f169f5b3b44
Step 5 : ENTRYPOINT /vaultd
 ---> Running in b5d55d6429be
 ---> b7178985dddf
Removing intermediate container b5d55d6429be
Successfully built b7178985dddf

对于每个命令,都会创建一个新的镜像(你可以在过程中看到中间容器被销毁),直到我们得到最终的镜像。

由于我们在本地机器上构建二进制文件并将其复制到容器中(使用ADD命令),我们的 Docker 镜像最终只有大约 7 MB:考虑到它包含了运行服务所需的所有内容,这相当小。

在本地运行 Docker 镜像

现在我们已经构建了镜像,我们可以通过以下命令来测试它:

docker run -p 6060:8080 -p 6061:8081 --name localtest --rm vaultd

docker run命令将启动vaultd镜像的一个实例。

-p标志指定了一对要公开的端口,第一个值是主机端口,第二个值(冒号之后)是镜像内的端口。在我们的例子中,我们表示我们想要将端口8080公开到端口6060,端口8081通过端口6061公开。

我们使用--name标志给运行实例命名为localtest,这将帮助我们识别它,当我们检查和停止它时。--rm标志表示我们希望在停止后删除镜像。

如果成功,你会注意到 Vault 服务确实已经开始,因为它在告诉我们它绑定到的端口:

2016/09/20 15:56:17 grpc: :8081
2016/09/20 15:56:17 http: :8080

小贴士

这些是内部端口;记住,我们已经将这些映射到不同的外部端口。这看起来可能有些混乱,但最终却非常强大,因为负责启动服务实例的人可以决定哪些端口适合他们的环境,而 Vault 服务本身则无需担心这一点。

要查看这个运行状态,打开另一个终端并使用curl命令访问我们的密码散列服务的 JSON 端点:

curl -XPOST -d '{"password":"monkey"}' localhost:6060/hash

你将看到类似运行服务输出的内容:

{"hash":"$2a$0$wk4qc74ougOkbkt/TWuRQHSg03i1ataNupbDADBwpe"}

检查 Docker 进程

要查看正在运行的 Docker 实例,我们可以使用docker ps命令。在终端中,输入以下内容:

docker ps

你将看到一个文本表格,概述以下属性:

CONTAINER ID 0b5e35dca7cc
IMAGE vaultd
COMMAND /bin/sh -c /go/bin/vaultd
CREATED 3 seconds ago
STATUS Up 2 seconds
PORTS 0.0.0.0:6060->8080/tcp, 0.0.0.0:6061->8081/tcp
NAMES localtest

详细信息显示了我们刚刚启动的镜像的高级概述。请注意,PORTS部分显示了外部到内部的映射。

停止 Docker 实例

我们习惯于在运行代码的窗口中按Ctrl + C来停止它,但由于它是在容器中运行的,所以这不会起作用。相反,我们需要使用docker stop命令。

由于我们给我们的实例命名为localtest,我们可以在一个可用的终端窗口中输入以下内容来停止它:

docker stop localtest

几分钟后,你会注意到运行镜像的终端现在已经返回到提示符。

部署 Docker 镜像

现在我们已经将 Vault 服务封装在一个 Docker 容器中,我们将对它做一些有用的事情。

我们将要做的第一件事是将这个镜像推送到 Docker Hub,这样其他人就可以启动自己的实例,甚至基于它构建新的镜像。

部署到 Docker Hub

访问 Docker Hub hub.docker.com,点击右上角的登录链接,然后点击创建账户来创建一个账户。当然,如果你已经有了账户,只需登录即可。

现在,在终端中,你将通过运行 Docker 的login命令来使用此账户进行认证:

docker login -u **USERNAME** -p **PASSWORD** https://index.docker.io/v1/

小贴士

如果你看到类似WARNING: Error loading config, permission denied的错误,那么请尝试使用带有sudo命令前缀的命令再次执行。这一点适用于从现在开始的所有 Docker 命令,因为我们正在使用一个受保护的配置。

确保将USERNAMEPASSWORD替换为你刚刚创建的账户的实际用户名和密码。

如果成功,你将看到“登录成功”。

接下来,回到网页浏览器中,点击创建仓库并创建一个名为vault的新仓库。这个镜像的实际名称将是USERNAME/vault,因此我们需要在本地重新构建镜像以匹配这个名称。

小贴士

注意,为了公开使用,我们称镜像为vault而不是vaultd。这是一个故意的区别,以确保我们处理的是正确的镜像,但这对用户来说也是一个更好的名称。

在终端中,使用正确的名称构建新的存储库:

docker build -t USERNAME/vault

这将再次构建镜像,这次使用适当的名称。要将镜像部署到 Docker Hub,我们使用 Docker 的push命令:

docker push USERNAME/vault

经过一段时间,镜像及其依赖项将被推送到 Docker Hub:

f477b97e9e48: Pushed
384c907d1173: Pushed
80168d020f50: Pushed
0ceba54dae47: Pushed
4d7388e75674: Pushed
f042db76c15c: Pushing [====>               ] 21.08 MB/243.6 MB
d15a527c2ee1: Pushing [=====>              ] 15.77 MB/134 MB
751f5d9ad6db: Pushing [======>             ] 16.49 MB/122.6 MB
17587239b3df: Pushing [===================>] 17.01 MB/44.31 MB
9e63c5bce458: Pushing [==================> ] 65.58 MB/125.1 MB

现在转到 Docker Hub 查看您镜像的详细信息,或者查看hub.docker.com/r/matryer/vault/的示例。

部署到 Digital Ocean

Digital Ocean 是一家提供具有竞争力的价格来托管虚拟机的云服务提供商。它使得部署和提供 Docker 镜像变得非常容易。在本节中,我们将部署一个 droplet(Digital Ocean 对单个机器的术语),在云中运行我们的 docker 化 Vault 服务。

具体来说,以下是将 Docker 镜像部署到 Digital Ocean 的步骤:

  1. 创建 droplet。

  2. 通过基于 Web 的控制台访问它。

  3. 拉取USERNAME/vault容器。

  4. 运行容器。

  5. 通过curl命令远程访问我们的托管 Vault 服务。

Digital Ocean 是一个平台即服务PaaS)架构,因此用户体验可能会不时发生变化,所以这里描述的精确流程在您执行这些任务时可能并不完全准确。通常,通过查看选项,您将能够找出如何进行操作,但已经包括了截图以帮助您。

本节还假设您已启用创建 droplets 可能需要的任何计费。

创建 droplet

通过浏览器访问www.digitalocean.com注册或登录到 Digital Ocean。请确保您使用真实的电子邮件地址,因为这将是他们发送您创建的 droplet 的 root 密码的地方。

如果您没有其他 droplets,您将看到一个空白屏幕。点击创建 Droplet

创建 droplet

一键应用标签页中,查找最新的 Docker 选项;在撰写本文时,它是Docker 1.12.1 on 16.04,这意味着 Docker 版本 1.12.1 正在 Ubuntu 16.04 上运行。

滚动页面选择剩余的选项,包括选择大小(目前最小的尺寸即可)和位置(选择离您最近的地理位置)。现在我们不会添加额外的服务(如卷、网络或备份),只需进行简单的 droplet。

给您的 droplet 起一个有意义的名称可能是个好主意,这样以后就更容易找到,比如vault-service-1或类似名称;现在这并不重要:

创建 droplet

小贴士

你可以选择添加 SSH 密钥以增加额外的安全性,但为了简单起见,我们将继续不添加它。对于生产环境,建议你始终这样做。

在页面底部,点击创建

创建 droplet

访问 droplet 的控制台

一旦你的 droplet 创建完成,从Droplets列表中选择它,并查找控制台选项(它可能被写成Access console)。

几分钟后,你将看到一个基于 Web 的终端。这就是我们将如何控制 droplet,但首先,我们必须登录:

访问 droplet 的控制台

输入登录用户名为root,并检查你的电子邮件以获取 Digital Ocean 发送给你的 root 密码。在撰写本文时,你不能复制粘贴,所以请准备好尽可能准确地输入一个长字符串。

小贴士

密码可能是一个小写十六进制字符串,这将帮助你了解哪些字符可能出现。例如,所有看起来像O的字符可能都是,而1不太可能是IL

第一次登录后,你将被要求更改密码,这需要再次输入生成的长密码!有时安全性会如此不方便。

拉取 Docker 镜像

由于我们选择了 Docker 应用作为我们的 droplet 的起点,Digital Ocean 已经友好地配置了 Docker,使其已经在我们的实例中运行,因此我们可以直接使用docker命令来完成设置。

在基于 Web 的终端中,使用以下命令拉取你的容器,记得将USERNAME替换为你的 Docker Hub 用户名:

docker pull USERNAME/vault

小贴士

如果由于任何原因,这对你不起作用,你可以尝试使用作者放置在那里的 Docker 镜像,通过输入以下命令:docker pull matryer/vault

Docker 将会去拉取它运行我们之前创建的镜像所需的所有内容:

拉取 Docker 镜像

在云中运行 Docker 镜像

一旦镜像及其依赖项成功下载,我们就可以使用docker run命令来运行它,这次使用-d标志来指定我们希望它作为后台守护进程运行。在基于 Web 的终端中,输入以下命令:

docker run -d -p 6060:8080 -p 6061:8081 --name vault USERNAME/vault

这与之前我们运行的命令类似,但这次我们给它命名为 vault,并且省略了--rm标志,因为它与后台守护进程模式不兼容(并且没有意义)。

包含我们的 Vault 服务的 Docker 镜像将开始运行,现在已准备好测试。

访问云中的 Docker 镜像

现在,我们的 Docker 镜像已经在 Digital Ocean 平台上运行的 droplet 中运行,我们可以开始使用它了。

在 Digital Ocean 的 Web 控制面板中,选择Droplets并查找我们刚刚创建的那个。我们需要知道 IP 地址,以便我们可以远程访问服务。一旦你找到了 droplet 的 IP 地址,点击它以复制它。

在你的电脑上打开本地终端(不要使用基于网页的终端)并使用 curl 命令(或等效命令)执行以下请求:

curl -XPOST -d '{"password":"Monkey"}' http://IPADDRESS:6060/hash

记得将 IPADDRESS 替换为你从 Digital Ocean 的网页控制面板中复制的实际 IP 地址。

当你收到以下类似响应时,你会注意到你已经成功管理访问了我们的 Vault 服务的 JSON/HTTP 端点:

{"hash":"$2a$10$eGFGRZ2zMfsXss.6CgK6/N7TsmF.6MAv6i7Km4AHC"}

看看你是否可以修改 curl 命令,使用 /validate 端点验证提供的哈希值。

摘要

在本章中,我们使用 Docker 在 Digital Ocean 的云平台上构建和部署了 Vault Go 应用程序。

在安装 Docker 工具后,我们看到了如何轻松地将我们的 Go 应用程序打包成 Docker 镜像并推送到 Docker Hub。我们使用他们提供的有用的 Docker 应用程序创建了 Digital Ocean 的 Droplet,并通过基于网页的控制台进行控制。一旦进入,我们就能从 Docker Hub 拉取我们的 Docker 镜像并在 Droplet 中运行它。

使用 Droplet 的公网 IP,我们能够远程访问 Vault 服务器的 JSON/HTTP 端点以哈希和验证密码。

附录 附录。稳定 Go 环境的良好实践

编写 Go 代码是一种有趣且愉快的体验,编译时错误不再是痛苦,而是引导你编写健壮、高质量的代码。然而,时不时地,你将遇到一些环境问题,这些问题开始妨碍你的工作流程。虽然你通常可以通过一些搜索和微调来解决这些问题,但正确设置你的开发环境在很大程度上可以减少问题,让你能够专注于构建有用的应用程序。

在本章中,我们将从头开始在新的机器上安装 Go,并讨论我们的一些环境选项及其可能对未来产生的影响。我们还将考虑协作如何影响我们的决策,以及开源我们的包可能产生的影响。

具体来说,我们将:

  • 在你的开发机上安装 Go

  • 了解 GOPATH 环境变量的用途,并讨论其合理的使用方法

  • 了解 Go 工具及其使用方法,以保持我们代码的高质量

  • 学习如何使用工具自动管理我们的导入

  • 考虑到我们的 .go 文件的 保存 操作,以及我们如何将 Go 工具集成到日常开发中

  • 查看一些流行的代码编辑器选项来编写 Go 代码

安装 Go

安装 Go 的最佳方式是使用网络上可用的众多安装程序之一,请访问 golang.org/dl/。访问 Go 网站,点击 下载,然后查找适合你电脑的最新 1.x 版本。页面顶部的 特色下载 部分包含指向最受欢迎版本的链接,所以你的版本可能就在这个列表中。

本书中的代码已经使用 Go 1.7 进行了测试,但任何 1.x 版本都将工作。对于 Go 的未来版本(2.0 及更高版本),你可能需要调整代码,因为主要版本发布可能包含破坏性更改。

配置 Go

Go 现已安装,但为了使用工具,我们必须确保它已正确配置。为了使调用工具更简单,我们需要将我们的 go/bin 路径添加到 PATH 环境变量中。

注意

在 Unix 系统上,你应该将 export PATH=$PATH:/opt/go/bin(确保它是你安装 Go 时选择的路径)添加到你的 .bashrc 文件中。

在 Windows 上,打开 系统属性(尝试右键单击 我的电脑),然后在 高级 选项卡中点击 环境变量 按钮,并使用 UI 确保路径变量包含你的 go/bin 文件夹路径。

在终端中(你可能需要重启终端以使更改生效),你可以通过打印 PATH 变量的值来确保这已经生效:

echo $PATH

确保打印的值包含正确的 go/bin 文件夹路径;例如,在我的机器上它打印如下:

/usr/local/bin:/usr/bin:/bin:/opt/go/bin

注意

路径之间的冒号(在 Windows 上是分号)表示 PATH 变量实际上是一个文件夹列表,而不仅仅是一个文件夹。这表明当你输入终端中的命令时,将搜索每个包含的文件夹。

现在,我们可以确保我们刚刚创建的 Go 构建可以成功运行:

go version

以这种方式执行 go 命令(可以在你的 go/bin 位置找到)将为我们打印出当前版本。例如,对于 Go 1.77.1,你应该看到以下类似的内容:

go version go1.77.1 darwin/amd64

正确设置 GOPATH

GOPATH 是另一个指向文件夹的环境变量(如上一节中的 PATH),用于指定 Go 源代码和编译的二进制包的位置。在你的 Go 程序中使用 import 命令会导致编译器在 GOPATH 位置查找你引用的包。当使用 go get 和其他命令时,项目会被下载到 GOPATH 文件夹中。

虽然 GOPATH 位置可以包含一系列由冒号分隔的文件夹,例如 PATH,并且你可以根据你正在工作的项目为 GOPATH 设置不同的值,但强烈建议你为所有内容使用单个 GOPATH 位置,这是我们假设你在本书的项目中会这样做。

创建一个名为 go 的新文件夹,这次在 Users 文件夹中,可能在 Work 子文件夹中。这将是我们 GOPATH 的目标,所有第三方代码和二进制文件都将在这里结束,我们也将在这里编写我们的 Go 程序和包。使用你在上一节中设置 PATH 环境变量时使用的相同技术,将 GOPATH 变量设置为新的 go 文件夹。让我们打开一个终端并使用新安装的命令之一为我们获取第三方包:

go get github.com/matryer/silk

获取 silk 库实际上会导致创建以下文件夹结构:$GOPATH/src/github.com/matryer/silk。你可以看到路径段在 Go 组织事物的方式中非常重要,这有助于命名空间项目并保持它们的独特性。例如,如果你创建了一个名为 silk 的自己的包,你不会将其保存在 matryer 的 GitHub 仓库中,所以路径就会不同。

当我们在本书中创建项目时,你应该考虑一个合理的 GOPATH 根目录。例如,我使用了 github.com/matryer/goblueprints,如果你去获取它,你实际上会在你的 GOPATH 文件夹中获得这本书所有源代码的完整副本!

Go 工具

Go 核心团队早期做出的一个决定是,所有 Go 代码都应该对说 Go 语的每个人来说都熟悉且明显,而不是每个代码库都需要额外的学习才能让新程序员理解它或对其进行工作。当你考虑到开源项目时,这是一个特别合理的做法,其中一些项目有数百名贡献者来来去去。

有许多工具可以帮助我们达到 Go 核心团队设定的高标准,我们将在本节中查看一些工具的实际应用。

在你的 GOPATH 位置,创建一个名为 tooling 的新文件夹,并创建一个包含以下代码的新 main.go 文件:

package main 
import ( 
  "fmt" 
) 
func main() { 
  return 
  var name string 
  name = "Mat" 
  fmt.Println("Hello ", name) 
} 

紧凑的空间和缺乏缩进是有意为之的,因为我们将要查看 Go 附带的一个非常酷的实用工具。

在终端中,导航到你的新文件夹并运行以下命令:

go fmt -w

注意

在 2014 年科罗拉多州丹佛的 Gophercon 大会上,大多数人了解到,与其将这个小三元组读作 formatf, m, t,实际上它是作为一个单词来发音的。现在试着对自己说:fhumt;看来,计算机程序员们如果不互相说一种外星语就已经够奇怪的了!

你会注意到这个小小的工具实际上调整了我们的代码文件,以确保我们的程序布局(或格式)符合 Go 标准。新版本更容易阅读:

package main  
import ( 
  "fmt" 
)  
func main() { 
  return 
  var name string 
  name = "Mat" 
  fmt.Println("Hello ", name) 
} 

go fmt 命令关注缩进、代码块、不必要的空白、不必要的额外换行符等等。以这种方式格式化你的代码是一种很好的实践,以确保你的 Go 代码看起来像其他所有 Go 代码。

接下来,我们将审查我们的程序,以确保我们没有犯任何错误或可能让用户感到困惑的决定;我们可以使用另一个免费获得的神器来自动完成这项工作:

go vet

我们的小程序输出显示了一个明显且令人瞩目的错误:

main.go:10: unreachable code
exit status 1

我们在函数顶部调用 return,然后尝试做其他事情。go vet 工具注意到了这一点,并指出我们在文件中有不可达的代码。

go vet不仅能捕捉到这种愚蠢的错误,它还会寻找你程序中更微妙的问题,这些问题将指导你编写尽可能好的 Go 代码。要查看 vet 工具将报告的最新列表,请查看golang.org/cmd/vet/上的文档。

我们将要使用的最后一个工具叫做goimports,它是由 Brad Fitzpatrick 编写的,用于自动修复(添加或删除)Go 文件的import语句。在 Go 中,导入一个包而不使用它是错误的,显然,尝试使用未导入的包也不会工作。goimports工具将根据我们的代码文件内容自动重写我们的import语句。首先,让我们使用这个熟悉的命令来安装goimports

go get golang.org/x/tools/cmd/goimports

更新你的程序,导入一些我们不会使用的包,并移除fmt包:

import ( 
  "net/http" 
  "sync" 
) 

当我们通过调用go run main.go来尝试运行我们的程序时,我们会看到一些错误:

./main.go:4: imported and not used: "net/http"
./main.go:5: imported and not used: "sync"
./main.go:13: undefined: fmt

这些错误告诉我们,我们导入了未使用的包,缺少了fmt包,并且为了继续,我们需要进行修正。这就是goimports发挥作用的地方:

goimports -w *.go

我们使用带有-w写入标志的goimports命令,这将节省我们修正所有以.go结尾的文件的麻烦。

现在查看你的main.go文件,注意net/httpsync包已经被移除,而fmt包已经被放回。

你可能会认为切换到终端运行这些命令比手动操作花费的时间更多,在大多数情况下你可能是对的,这就是为什么强烈建议你将 Go 工具与你的文本编辑器集成。

清理、构建和保存时运行测试

由于 Go 核心团队为我们提供了像fmtvettestgoimports这样出色的工具,我们将探讨一种已被证明极其有用的开发实践。每次我们保存.go文件时,我们都希望自动执行以下任务:

  1. 使用goimportsfmt修复我们的导入并格式化代码。

  2. 检查代码中的任何错误,并立即告诉我们。

  3. 尝试构建当前包并输出任何构建错误。

  4. 如果构建成功,运行包的测试并输出任何失败。

由于 Go 代码编译速度非常快(Rob Pike 曾经实际上说过它并不快,但并不像其他所有东西那样慢),我们每次保存文件时都可以舒适地构建整个包。这也适用于运行测试以帮助我们进行 TDD 风格开发的情况,体验非常棒。每次我们对代码进行更改时,我们都可以立即看到是否破坏了某些内容,或者对我们的项目其他部分产生了意外影响。我们将不再看到包导入错误,因为我们的import语句已经为我们修正,而且我们的代码将直接在我们的眼前正确格式化。

一些编辑器可能不支持在特定事件(如保存文件)响应下运行代码,这给您留下了两个选择:您可以选择切换到更好的编辑器,或者您可以编写自己的脚本文件,该文件会在文件系统更改时运行。后者超出了本书的范围;相反,我们将专注于如何在几个流行的编辑器中实现此功能。

集成开发环境

集成开发环境IDEs)本质上是一些具有额外功能,使编写代码和构建软件更简单的文本编辑器。具有特殊意义的文本,如字符串字面量、类型、函数名等,通常通过语法高亮以不同的颜色显示,或者您在键入时可能会获得自动完成选项。一些编辑器甚至会在您执行代码之前指出代码中的错误。

有许多选项可供选择,大多数情况下,这取决于个人喜好,但我们将探讨一些更受欢迎的选择以及如何设置它们以构建 Go 项目。

最受欢迎的编辑器包括以下几种:

  • Sublime Text 3

  • Visual Studio Code

  • Atom

  • Vim(带 vim-go)

您可以在github.com/golang/go/wiki/IDEsAndTextEditorPlugins查看一个完整的精选选项列表。

在本节中,我们将探讨 Sublime Text 3 和 Visual Studio Code。

Sublime Text 3

Sublime Text 3 是一个优秀的编辑器,可以用于编写在 OS X、Linux 和 Windows 上运行的 Go 代码,它拥有极其强大的扩展模型,这使得它易于定制和扩展。您可以从www.sublimetext.com/下载 Sublime Text,并在决定是否购买之前免费试用。

感谢DisposaBoy(请参阅github.com/DisposaBoy),已经有一个针对 Go 的 Sublime 扩展包,实际上为我们提供了许多 Go 程序员实际上错过的丰富功能和力量。我们将安装这个GoSublime包,然后在此基础上添加我们想要的保存功能。

在我们能够安装 GoSublime 之前,我们需要将 Package Control 安装到 Sublime Text 中。访问 sublime.wbond.net/ 并点击 安装 链接,获取安装 Package Control 的说明。在撰写本文时,这只是一个复制单行命令(尽管很长)并将其粘贴到 Sublime 控制台中的简单过程,控制台可以通过从菜单中选择 视图 | 显示控制台 来打开。

完成这些后,按 shift + command + P 并输入 Package Control: Install Package,当你选择了选项后按 return。经过短暂的延迟(Package Control 正在更新其列表),将出现一个框,允许你通过输入、选择并按 return 来搜索和安装 GoSublime。如果一切顺利,GoSublime 将被安装,编写 Go 代码将变得容易得多。

小贴士

现在你已经安装了 GoSublime,你可以通过按 command + ., command + 2(同时按住命令键和点号,然后按住命令键和数字 2)来打开一个包含包详细信息的简短帮助文件。

在保存时需要一些额外帮助的话,请按 command + ., command + 5 打开 GoSublime 设置,并在对象中添加以下条目:

"on_save": [ 
  { 
    "cmd": "gs9o_open",  
    "args": { 
      "run": ["sh", "go build . errors && go test -i && go test && 
       go vet && golint"], 
      "focus_view": false 
    } 
  } 
] 

小贴士

注意,设置文件实际上是一个 JSON 对象,所以确保你在不损坏文件的情况下添加 on_save 属性。例如,如果你在前后都有属性,确保适当的逗号已经放置好。

之前的设置将告诉 Sublime Text 在保存文件时查找代码错误、安装测试依赖项、运行测试和审查代码。保存设置文件(暂时不要关闭它),让我们看看这个功能是如何实际应用的。

从菜单中选择 选择文件 | 打开... 并选择一个文件夹现在打开,让我们打开我们的 tooling 文件夹。Sublime Text 的简单用户界面清楚地表明,我们目前项目中的文件只有一个:main.go。点击文件并添加一些额外的换行符,并添加和删除一些缩进。然后,从菜单中选择 文件 | 保存,或者按 command + S。请注意,代码会立即被清理,并且如果你没有从 main.go 中移除放置不当的返回语句,你会注意到控制台已经出现并报告了问题,这是由于 go vet 的功劳:

main.go:8: unreachable code

按住 command + shift 并在控制台中的不可达代码行上双击,将打开文件并将光标跳转到相关的行。当你继续编写 Go 代码时,你可以看到这个功能将多么有用。

如果你向文件中添加了不需要的导入,你将注意到在使用 on_save 时你会被告知问题,但它并没有自动修复。这是因为我们还需要进行另一个调整。在添加 on_save 属性的相同设置文件中,添加以下属性:

"fmt_cmd": ["goimports"]

这告诉 GoSublime 使用 goimports 命令而不是 go fmt。再次保存此文件,然后返回 main.go。再次将 net/http 添加到导入中,删除 fmt 导入,并保存文件。请注意,已删除未使用的包,并将 fmt 再次放回。

Visual Studio Code

在最佳 Go IDE 竞选中出现的一个惊喜是微软的 Visual Studio Code,可在 code.visualstudio.com 免费获得。

一旦您从网站上下载它,打开一个 Go 文件(任何以 .go 扩展名结尾的文件),请注意 Visual Studio Code 会询问您是否希望安装推荐的插件以使处理 Go 文件更容易:

Visual Studio Code

点击 显示推荐 并点击建议的 Go 插件旁边的 安装

Visual Studio Code

它可能会要求您重新启动 Visual Studio Code 以启用插件,并且它还可能要求您安装一些额外的命令:

Visual Studio Code

点击 安装所有 以安装所有依赖项,确保在启动其他安装过程之前等待之前的安装过程完成。不久后,您会注意到安装了一些工具。

在 Visual Studio Code 中编写一些混乱的代码(或从 github.com/matryer/goblueprints/blob/master/appendixA/messycode/main.go 复制粘贴一些)并保存。您会注意到导入已修复,代码已按照 Go 标准格式化。

您可以利用更多功能,但在这里我们不会进一步探讨。

摘要

在本附录中,我们安装了 Go,现在准备好开始构建真实的项目。我们了解了 GOPATH 环境变量,并发现了一个常见做法,即对所有项目保持一个值。这种方法大大简化了在 Go 项目上的工作,否则您可能会继续遇到棘手的失败。

我们发现了 Go 工具集如何真正帮助我们产生高质量、符合社区标准的代码,任何其他程序员都可以轻松地拾起并在此基础上进行工作,无需额外的学习。更重要的是,我们探讨了自动化这些工具的使用意味着我们可以真正专注于编写应用程序和解决问题,这正是开发者真正想做的事情。

我们查看了一些代码编辑器或 IDE 的选项,并看到了如何轻松添加插件或扩展,以使编写 Go 代码更容易。

参考文献列表

这条学习路径是为您准备的,以帮助您使用最先进的技术和技巧在 Go 中构建生产就绪的解决方案。它包括以下 Packt 产品:

  • 学习 Go 编程,弗拉基米尔·维维安

  • Go 设计模式,马里奥·卡斯特罗·孔特拉斯

  • Go 编程蓝图 - 第二版,马特·瑞尔

posted @ 2025-09-06 13:44  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报