精通-Go-第四版-全-

精通 Go 第四版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《精通 Go 语言》的第四版!随着 Go 编程语言的持续发展和流行,我很高兴向大家呈现这本书的更新版。

在技术快速变化的领域中,Go 语言已经成为构建可扩展、性能良好且易于维护的软件的首选语言。无论您是一位经验丰富的 Go 开发者,希望深化您的专业知识,还是一位渴望掌握语言复杂性的新手,本书都是您的全面指南。

如果您拥有《精通 Go 语言》的旧版,除了现在相当古老的初版之外,不要因为《精通 Go 语言,第四版》的推出就将其丢弃。Go 语言并没有发生太大的变化,第二版和第三版仍然有用且相关。然而,《精通 Go 语言,第四版》在很多方面都比之前的版本更好,并包含了关于最新 Go 版本的信息,这些信息您在之前的版本中是找不到的。

在最新版中存在许多令人兴奋的主题,包括编写 RESTful 服务、编写统计应用程序以及使用 eBPF,以及关于模糊测试和可观察性的全新章节。

我试图包含适量的理论和实践内容——但只有您,读者,才能告诉我是否成功。请尝试完成每章末尾的练习,并且不要犹豫与我联系,告诉我如何或有哪些想法可以使本书的后续版本更加完善。

感谢您选择阅读《精通 Go 语言,第四版》。让我们一起深入探索,共同发掘 Go 语言的全部潜力。祝您编码愉快!

本书面向读者

本书面向希望将 Go 语言知识提升到更高层次的业余和中级 Go 程序员,也适用于希望学习 Go 语言的其他编程语言开发者。

如果这是您读的第一本编程书,您可能会在理解上遇到一些问题,可能需要再次阅读才能完全吸收所有展示的知识。

通过实践学习是学习任何编程语言的基本原则。在本书中,您将找到实用的示例和动手练习,这些示例和练习说明了关键概念,并鼓励您将知识应用于实际场景。

无论如何,都要准备好工作、学习、失败,然后再工作、学习、再失败。毕竟,生活与编程并没有太大的区别。

本书涵盖内容

第一章Go 语言快速入门,首先讨论了 Go 语言的历史、Go 语言的重要特性和优势,然后描述了godocgo doc实用工具,并解释了如何编译和执行 Go 程序。接着,本章讨论了控制程序流程、打印输出和获取用户输入、处理命令行参数以及使用日志文件。在第一章的最后部分,我们开发了一个基本的统计应用程序版本,我们将在接下来的章节中对其进行改进。

第二章基本 Go 数据类型,讨论了 Go 语言的基本数据类型,包括数值和非数值类型;允许您对相同类型的数据进行分组的数组切片;Go 指针;常量;以及处理日期和时间。本章的最后部分是关于生成随机数,并用随机数据填充统计应用程序。

第三章复合数据类型,首先向您介绍映射,然后进入结构和struct关键字。此外,它还讨论了正则表达式、模式匹配以及处理 CSV 文件。最后,通过添加数据持久性来改进统计应用程序。

第四章Go 泛型,是关于泛型和如何使用新语法编写泛型函数以及定义泛型数据类型。本章还介绍了cmp包、slices包和maps包,这些都是使用泛型实现的,以便与尽可能多的数据类型一起工作。

第五章反射和接口,是关于反射、接口和类型方法,这些是附加到数据类型上的函数。本章还涵盖了使用sort.Interface接口对切片进行排序、使用空接口、类型断言、类型选择和error数据类型。此外,我们讨论了 Go 语言如何模仿一些面向对象的概念,并在改进统计应用程序之前进行讨论。本章还比较了泛型和接口以及反射。

第六章Go 包和函数,主要关于包、模块和函数,它们是包的主要元素。其中,我们创建了一个用于与 SQLite3 数据库交互的 Go 包,为其编写了文档,并解释了有时棘手的defer关键字的使用。最后,我们讨论了工作区(Workspaces),这是 Go 语言的一个相对较新的特性。

第七章告诉 UNIX 系统做什么,是关于系统编程的,包括处理命令行参数、处理 UNIX 信号、文件输入输出、io.Readerio.Writer接口以及vipercobra包的使用。最后,我们更新了统计应用程序,使其使用 JSON 数据,并在cobra包的帮助下将其转换为合适的命令行实用工具。

第八章Go 并发,讨论了 goroutines、channels 和 pipelines。我们学习了进程、线程和 goroutines 之间的区别,sync包以及 Go 调度器的工作方式。此外,我们还探讨了select关键字的使用,以及 Go channels 的各种类型、共享内存、互斥锁、sync.Mutex类型和sync.RWMutex类型。本章的其余部分讨论了context包、semaphore包、工作池、如何超时 goroutines 以及如何检测竞态条件。

第九章构建 Web 服务,讨论了net/http包、Web 服务器和 Web 服务的开发、创建 Web 客户端以及 HTTP 连接的超时。我们还把统计应用程序转换成一个 Web 服务,并为它创建了一个命令行客户端。

第十章与 TCP/IP 和 WebSocket 协同工作,讲述了net包、TCP/IP、TCP 和 UDP 协议,以及 WebSocket 协议和与 RabbitMQ 协同工作。在本章中,我们开发了大量的实用服务器和客户端。

第十一章与 REST API 协同工作,全部关于与 REST API 和 RESTful 服务协同工作。我们学习了如何定义 REST API,开发强大的并发 RESTful 服务器以及作为 RESTful 服务客户端的命令行实用工具。

第十二章代码测试和性能分析,讨论了代码测试、代码优化和代码性能分析,以及交叉编译、创建示例函数、使用go:generate和查找不可达的 Go 代码。

第十三章模糊测试和可观察性,讲述了模糊测试,这是 Go 编程语言中相对较新的功能,以及可观察性,它指的是根据系统的外部输出或可观察信号来理解、测量和分析系统内部状态和行为的能力。

第十四章效率和性能,讲述了 Go 代码的基准测试、理解 Go 内存模型以及消除内存泄漏。本章还包括开发一个 eBPF 实用工具——eBPF 已成为提高现代 Linux 系统中可观察性、安全性和性能的基础技术。

第十五章近期 Go 版本的变化,讲述了最新 Go 版本中的语言变化、新增功能和改进,这将帮助你了解 Go 语言是如何随时间演变的。

附录Go 垃圾回收器,讲述了 Go 垃圾回收器的操作,并说明了这个 Go 组件如何影响你的代码性能。

为了充分利用这本书

这本书需要一台装有相对较新 Go 版本的现代计算机,包括运行 Mac OS X、macOS 或 Linux 的任何机器,以及熟悉你的操作系统、其文件系统和git(1)。大部分展示的代码在 Microsoft Windows 机器上也能运行,无需任何更改。

当您开始踏上掌握 Go 语言的旅程时,我鼓励您进行实验、提问并积极与材料互动。Go 编程语言提供了简单与强大的清新结合,我确信这本书将为您提供成为熟练 Go 开发人员所需的知识和技能。

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/mactsouk/mGo4th。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为github.com/PacktPublishing/。请查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781805127147

使用的约定

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

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

代码块设置如下:

package main
import "fmt"
func doubleSquare(x int) (int, int) {
    return x * 2, x * x
} 

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

fmt.Println("Double of", n, "is", d)
fmt.Println("Square of", n, "is", s)
**anF :=** **func****(param** **int****)****int** **{**
return param * param
} 

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

$ go run namedReturn.go 1 -2
-2 1
-2 1 

粗体:表示新术语、重要单词或重要信息。例如,菜单或对话框中的单词在文本中显示如下。例如:“wordByWord()函数使用正则表达式来分隔输入文件每一行中找到的单词”。

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

欢迎读者反馈。

一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问authors.packtpub.com

分享您的想法

读完《精通 Go,第四版》后,我们非常乐意听到您的想法!请点击此处直接进入亚马逊评论页面,分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的收件箱。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

二维码描述自动生成

packt.link/free-ebook/9781805127147

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一章:Go 快速入门

尽管本章的名称是“快速入门”,但它不仅仅是 Go 的快速介绍,它还将成为本书其余部分的基础。本章解释了 Go 的基本知识、一些设计决策和 Go 的哲学,以便你在学习 Go 的细节之前能够了解全局。在众多内容中,我们介绍了 Go 的优点和缺点,以便你知道何时使用 Go 以及何时考虑其他替代方案。

在接下来的章节中,我们将介绍一些概念和实用工具,以便在构建which(1)实用工具的简化版本之前,为 Go 建立一个坚实的基础。which(1)是一个 UNIX 实用工具,通过搜索PATH环境变量的目录来定位程序文件。此外,我们还将解释如何将信息写入日志文件,因为这可以帮助你在使用 Go 开发软件时存储错误消息和警告。

在本章末尾,我们开发了一个基本的命令行实用工具,用于计算基本的统计属性。正是这个命令行实用工具,随着我们学习更多高级的 Go 特性,我们将在接下来的本书章节中对其进行改进和扩展。

本章内容如下:

  • 介绍 Go

  • 何时使用 Go

  • Hello World!

  • 运行 Go 代码

  • 你应该了解的 Go

  • 在 Go 中开发which(1)实用工具

  • 记录信息

  • 开发统计应用程序

介绍 Go

Go 是一种开源的系统编程语言,最初作为 2009 年公开的内部 Google 项目而开发。Go 的精神之父是 Robert Griesemer、Ken Thomson 和 Rob Pike。

尽管该语言的官方名称是 Go,但它有时(错误地)被称为Golang。官方的原因是go.org/不可用于注册,因此选择了golang.org——然而,如今,官方的 Go 网站是go.dev/。请记住,当你通过搜索引擎查询与 Go 相关的信息时,单词Go通常被解释为动词;因此,你应该搜索golang。此外,Go 的官方 Twitter 标签是#golang

让我们现在讨论 Go 的历史以及这对想学习 Go 的人意味着什么。

Go 的历史

如前所述,Go 最初是一个内部的 Google 项目,于 2009 年公开。Griesemer、Thomson 和 Pike 设计 Go 作为一种语言,供希望构建可靠、健壮和高效软件的专业程序员使用,这种软件易于管理。他们设计 Go 时考虑到了简洁性,即使这意味着 Go 可能不会成为适合所有人或所有事物的编程语言。

下一个图显示了直接或间接影响 Go 的编程语言。例如,Go 的语法看起来像 C,而包的概念则受到了 Modula-2 的启发。

一组带有黑色文字的白色方块,描述由低置信度自动生成

图 1.1:影响 Go 的编程语言

可交付成果是一个包含工具和标准库的编程语言。除了其语法和工具之外,Go 还提供了丰富的标准库和一个试图让您避免简单错误的类型系统,例如隐式类型转换、未使用的变量和未使用的包。Go 编译器会捕捉到这些简单错误,并在您处理它们之前拒绝编译。此外,Go 编译器还可以找到难以捕捉的错误,例如竞态条件。

如果您是第一次安装 Go,可以从访问 go.dev/dl/ 开始。然而,您的 UNIX 变体很可能已经有一个为 Go 编程语言准备好的安装包,因此您可能希望使用您喜欢的包管理器来获取 Go。

由于 Go 是一种可移植的编程语言,几乎所有展示的代码都可以在未经任何修改的情况下在任何现代的 Microsoft Windows、Linux 或 macOS 机器上正常工作。可能需要一些小或大的调整的 Go 代码是处理操作系统的代码。其中大部分代码在 第七章告诉 UNIX 系统做什么 中进行了介绍。

Go 的优势

Go 为开发者带来了一些重要的优势,首先是因为它是由真正的程序员设计和维护的。Go 也很容易学习,尤其是如果您已经熟悉 C、Python 或 Java 等编程语言。除此之外,由于其简化和优雅的语法,Go 代码看起来很舒服,这在您以编程为生并需要每天查看代码时尤其如此。Go 代码也易于阅读,这意味着您可以轻松地对现有的 Go 代码进行修改,并且它提供了对 Unicode 的原生支持。最后,Go 只保留了 25 个关键字,这使得记住该语言变得容易得多。您能用 C++ 做到这一点吗?

Go 还提供了并发能力,使用一个简单的并发模型,该模型通过 goroutineschannels 实现。Go 会为您管理操作系统线程,并拥有一个强大的运行时,允许您生成轻量级的工作单元(goroutines),它们通过通道相互通信。

虽然 Go 拥有丰富的标准库,但还有一些非常实用的 Go 包,例如 cobraviper,这些包使得 Go 能够开发出复杂的命令行工具,如 dockerhugo。这一点得到了强有力的支持,因为可执行二进制文件是静态链接的,这意味着一旦生成,它们就不依赖于任何共享库,并包含了所有必需的信息。在实践中,这意味着你可以将现有的可执行文件转移到具有相同架构的不同机器上,并确信它将无任何问题地运行。

由于其简单性,Go 代码是可预测的,并且没有奇怪的副作用,尽管 Go 支持指针,但它不支持像 C 语言那样的指针算术,除非你使用 unsafe 包,这可能是许多错误和安全漏洞的根源。虽然 Go 不是一个面向对象编程语言,但 Go 接口非常灵活,允许你模仿一些面向对象语言的能力,如多态性、封装和组合。然而,Go 并不支持类和继承。第五章反射和接口提供了更多关于这个主题的信息。

此外,最新的 Go 版本提供了对 泛型 的支持,这简化了处理多个数据类型时的代码。你可以在 第四章Go 泛型 中了解更多关于 Go 泛型的信息。最后,Go 是一种垃圾回收语言,这意味着不需要手动内存管理。

何时使用 Go

虽然 Go 是一种通用编程语言,但它主要用于编写系统工具、命令行工具、网络服务和在网络上工作的软件。你可以用 Go 来教授编程,由于其简洁性、清晰的思想和原则,它是一个很好的编程语言入门选择。

Go 可以帮助你开发以下类型的应用程序:

  • 专业网络服务

  • 如 Kubernetes 和 Istio 这样的网络工具和服务器

  • 后端系统

  • 坚固的 UNIX 和 Windows 系统工具

  • 与 API 一起工作的服务器和通过交换多种格式的数据(包括 JSON、XML 和 CSV)进行交互的客户端

  • WebSocket 服务器和客户端

  • gRPC(远程过程调用)服务器和客户端

  • 具有多个命令、子命令和命令行参数的复杂命令行工具,如 dockerhugo

  • 以 JSON 格式交换数据的应用程序

  • 从关系型数据库、NoSQL 数据库或其他流行的数据存储系统中处理数据的应用程序

  • 为你自己的编程语言编写的编译器和解释器

  • 如 CockroachDB 这样的数据库系统以及 etcd 这样的键值存储

虽然 Go 是一种非常实用和高效的编程语言,但它并不完美:

  • 这是一种个人偏好,而不是实际的技术缺陷:Go 没有直接和全面的支持面向对象编程,这是一种流行的编程范式。

  • 虽然 goroutines 轻量级,但它们不如操作系统线程强大。根据你试图实现的应用程序,可能存在一些罕见的情况,其中 goroutines 可能不适合这项工作。Apache 网络服务器使用 fork(2) 创建 UNIX 进程来为其客户端提供服务——Go 不支持 fork(2) 的功能。然而,在大多数情况下,考虑使用 goroutines 和 channels 设计你的应用程序将解决你的问题。

  • 尽管垃圾回收在大多数情况下足够快,并且对于几乎所有类型的应用程序来说,有时你需要手动处理内存分配,例如在开发操作系统或处理大量内存时想要避免碎片化——Go 无法做到这一点。在实践中,这意味着 Go 不会允许你手动进行任何内存管理。

  • Go 不提供函数式编程语言的全部功能。

  • Go 不擅长开发具有高可用性保证的系统。在这种情况下,请使用 Erlang 或 Elixir。

Go 在许多方面比其他编程语言做得更好,包括以下方面:

  • Go 编译器可以捕获大量愚蠢的错误,这些错误最终可能成为漏洞。这包括导入的 Go 包和代码中未使用的变量。

  • Go 使用的括号比 C、C++ 或 Java 少,并且没有分号,这使得 Go 源代码更易于阅读且错误更少。

  • Go 搭载了一个丰富且可靠的标准库,并且一直在不断改进。

  • Go 通过 goroutines 和 channels 提供了开箱即用的并发支持。

  • Goroutines 轻量级。你可以在任何现代机器上轻松运行数千个 goroutines,而不会出现任何性能问题。

  • 与 C 语言不同,Go 将函数视为一等公民。

  • Go 代码具有向后兼容性,这意味着较新版本的 Go 编译器可以接受使用语言先前版本创建的程序,而无需任何修改。这种兼容性保证仅限于 Go 的大版本。例如,不能保证 Go 1.x 程序可以用 Go 2.x 编译。

下一个子节将描述我的个人 Go 之旅。

我的个人 Go 之旅

在本节中,我将告诉你我如何最终学习并使用 Go 的个人故事。我是一个 UNIX 人士,这意味着我喜欢 UNIX,并尽可能使用它。我也热爱 C 语言,曾经喜欢 C++;我为我硕士项目用 C++ 编写了一个命令行 FTP 客户端。如今,C++ 已经成为一个庞大且难以学习的编程语言。尽管 C 仍然是一个不错的编程语言,但它需要大量的代码来完成简单的任务,并且由于手动内存管理和不同数据类型之间极其灵活的转换(没有任何警告或错误消息),因此存在难以找到和纠正的错误。

因此,我过去常常使用 Perl 编写简单的命令行实用程序。然而,Perl 并不适合编写严肃的命令行工具和服务,因为它是一种脚本编程语言,并不适用于 Web 开发。

当我第一次听说 Go 语言是由 Google 开发的,并且 Rob Pike 和 Ken Thompson 都参与了其开发时,我立刻对 Go 语言产生了兴趣。

从那时起,我使用 Go 创建了与 RabbitMQ、MySQL 和 PostgreSQL 通信的 Web 服务、服务器和客户端,创建了简单的命令行实用程序,实现了时间序列数据挖掘的算法,创建了生成合成数据的实用程序等。

很快,我们将开始学习 Go 语言,以Hello World!作为第一个示例,但在那之前,我们将介绍go doc命令,该命令允许您查找有关 Go 标准库、其包及其函数的信息,以及godoc实用程序。

如果您尚未安装 Go,现在是安装的时候了。要安装,请访问go.dev/dl/或使用您喜欢的包管理器。

go doc 和 godoc 实用程序

Go 语言发行版附带了许多工具,可以使程序员的编程生活更加轻松。其中两个工具是go doc子命令和godoc实用程序,它们允许您在不使用互联网连接的情况下查看现有 Go 函数和包的文档。然而,如果您更喜欢在线查看 Go 文档,可以访问pkg.go.dev/

go doc命令可以作为正常的命令行应用程序执行,并在终端上显示其输出,它与 UNIX 的man(1)命令类似,但仅针对 Go 函数和包。因此,为了查找有关fmt包的Printf()函数的信息,您应该执行以下命令:

$ go doc fmt.Printf 

类似地,您可以通过运行以下命令来查找有关整个fmt包的信息:

$ go doc fmt 

由于godoc默认未安装,您可能需要通过运行go install golang.org/x/tools/cmd/godoc@latest来安装它。godoc二进制文件将被安装到~/go/bin,除非~/go/bin已包含在您的PATH环境变量中,否则您可以通过~/go/bin/godoc来执行它。

godoc命令行应用程序启动一个本地 Web 服务器。因此,您需要一个 Web 浏览器来查看 Go 文档。

运行godoc需要使用-http参数执行godoc

$ ~/go/bin/godoc -http=:8001 

在前面的命令中,该数值为8001,是 HTTP 服务器将监听的端口号。由于我们省略了 IP 地址,godoc将监听所有网络接口。

如果您有适当的权限,可以选择任何可用的端口号。但是,请注意端口号 01023 是受限的,只能由 root 用户使用,因此最好避免选择这些中的一个,如果它还没有被其他进程使用,可以选择其他端口号。端口号 8001 通常空闲,并且经常用于本地 HTTP 服务器。

您可以在展示的命令中省略等号,并用空格字符代替。因此,以下命令与上一个命令完全等价:

$ ~/go/bin/godoc -http :8001 

之后,您应该将您的网络浏览器指向 http://localhost:8001/ 以获取可用的 Go 包列表并浏览它们的文档。如果没有提供 -http 参数,godoc 将监听端口号 6060

如果您是第一次使用 Go,您会发现 Go 文档对于学习您想要使用的函数的参数和返回值非常有用——随着您在 Go 之旅中的进步,您将使用 Go 文档来学习您想要使用的函数和变量的详细信息。

下一节介绍了本书的第一个 Go 程序,并解释了 Go 的基本概念。

Hello World!

以下是将 Hello World! 程序转换为 Go 版本。请将其键入并保存为 hw.go

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

如果您急于执行 hw.go,请在保存它的同一目录中键入 go run hw.go。该文件也可以在本书 GitHub 仓库的 ch01 目录中找到。

每个 Go 源代码都以包声明开始。在这种情况下,包名是 main,这在 Go 中有特殊含义——自主 Go 程序应使用 main 包。import 关键字允许您包含现有包的功能。在我们的例子中,我们只需要属于标准 Go 库的 fmt 包的一些功能,这些功能通过类似于 C 的 printf()scanf() 的函数实现格式化输入和输出。如果您正在创建可执行应用程序,下一个重要的事情是一个 main() 函数。Go 将其视为应用程序的入口点,并以 main 包的 main() 函数中的代码开始执行应用程序。

hw.go 是一个独立运行的 Go 程序。两个特性使 hw.go 成为一个可以生成可执行二进制文件的源文件:包名应该是 main,以及存在 main() 函数——我们将在下一小节中更详细地讨论 Go 函数,但我们将更深入地了解函数和方法,这些是附加到特定数据类型上的函数,在 第六章Go 包和函数 中。

函数介绍

每个 Go 函数定义都以func关键字开始,后面跟着其名称、签名和实现。除了具有特殊用途的main()函数外,你可以将其他函数命名为任何你想要的名称——有一个全局的 Go 规则也适用于函数和变量名称,并且对所有包(除了 main 包)都有效:以小写字母开头的所有内容都被认为是私有的,并且只能在当前包中访问。我们将在第六章,Go 包和函数中了解更多关于这个规则的内容。这个规则的唯一例外是包名,它可以以小写或大写字母开头。话虽如此,我并不了解以大写字母开头的 Go 包!

你现在可能会问函数是如何组织和交付的。好吧,答案是包——下一个小节将对此进行一些解释。

介绍包

Go 程序是有组织的包——即使是最小的 Go 程序也应该作为一个包来交付。package关键字帮助你定义新包的名称,你可以取任何你想要的名称,只有一个例外:如果你正在创建一个可执行的应用程序,而不仅仅是其他应用程序或包将共享的包,你应该将你的包命名为main。你将在第六章,Go 包和函数中了解更多关于开发 Go 包的内容。

包可以被其他包使用。实际上,重用现有包是一个好的实践,可以节省你编写大量代码或从头实现现有功能的时间。

import关键字用于将其他 Go 包导入到你的 Go 程序中,以使用它们的一些或全部功能。一个 Go 包可以是丰富的标准 Go 库的一部分,也可以来自外部来源。标准 Go 库的包通过名称导入,例如,使用import "os"来使用os包,而像github.com/spf13/cobra这样的外部包则使用它们的完整 URL 导入:import "github.com/spf13/cobra"

运行 Go 代码

你现在需要知道如何执行hw.go或任何其他 Go 应用程序。正如将在接下来的两个小节中解释的那样,有两种方式可以执行 Go 代码:作为编译语言,使用go build,或者模仿脚本语言,使用go run。那么,让我们更深入地了解这两种运行 Go 代码的方式。

编译 Go 代码

要编译 Go 代码并创建一个二进制可执行文件,我们需要使用go build命令。go build为我们创建一个可分发的可执行文件。这意味着当使用go build时,需要额外一步来运行可执行文件。

生成的可执行文件会自动以源代码文件名(不带 .go 扩展名)命名。因此,由于 hw.go 源文件名,可执行文件将被命名为 hw。如果你不希望这样,go build 支持使用 -o 选项来更改生成的可执行文件的文件名和路径。例如,如果你想将可执行文件命名为 helloWorld,你应该执行 go build -o helloWorld hw.go。如果没有提供源文件,go build 会查找当前目录中的 main 包。

之后,你需要在自己的计算机上执行生成的可执行二进制文件。在我们的例子中,这意味着执行 hwhelloWorld。以下输出显示了这一点:

$ go build hw.go
$ ./hw
Hello World! 

现在我们已经知道了如何编译 Go 代码,让我们继续像使用脚本语言一样使用 Go。

将 Go 当作脚本语言使用

go run 命令构建名为 Go 的包,在这个例子中是单个文件中实现的 main 包,创建一个临时可执行文件,执行该文件,并在完成后删除它——对我们来说,这看起来像是使用脚本语言,而 Go 编译器仍然创建二进制可执行文件。在我们的情况下,我们可以做以下操作:

$ go run hw.go
Hello World! 

使用 go run 在测试代码时是一个更好的选择。然而,如果你想创建和分发可执行二进制文件,那么 go build 是正确的选择。

重要的格式化和编码规则

你应该知道,Go 有一套严格的格式化和编码规则,可以帮助开发者避免新手错误和漏洞——一旦你学会了这些规则以及 Go 的特性以及它们对代码的影响,你就可以自由地专注于代码的实际功能。此外,Go 编译器通过其表达性的错误信息和警告来帮助你遵循这些规则。最后,Go 提供了标准工具(gofmt),可以为你格式化代码,所以你永远不必担心它。

以下是在阅读本章时帮助你的一些重要的 Go 规则列表:

  • Go 代码以包的形式提供,你可以自由使用现有包中的功能。有一个 Go 规则说,如果你导入了一个包,你应该以某种方式使用它(调用一个函数或使用数据类型),否则编译器会报错。这个规则有一些例外,主要与初始化数据库和 TCP/IP 服务器连接的包有关,但这对现在来说并不重要。包将在 第六章Go 包和函数 中介绍。

  • 你要么使用变量,要么根本不声明它。这个规则帮助你避免诸如拼写现有变量或函数名错误这样的错误。

  • 在 Go 中格式化花括号只有一种方式。

  • Go 中的代码块使用花括号嵌套,即使它们只包含一个语句或者没有任何语句。

  • Go 函数可以返回多个值。

  • 您不能在不同的数据类型之间自动转换,即使它们属于同一类型。例如,您不能隐式地将整数转换为浮点数。

Go 有更多规则,但前面的规则是最重要的,它们将贯穿本书的大部分内容。您将在本章以及其他章节中看到所有这些规则的实际应用。现在,让我们考虑在 Go 中格式化花括号的唯一方法,因为这个规则适用于所有地方。

看看以下名为 curly.go 的 Go 程序:

package main
import (
    "fmt"
)
func main() 
{
    fmt.Println("Go has strict rules for curly braces!")
} 

虽然看起来没问题,但如果您尝试执行它,您会感到失望,因为代码将无法编译,因此您将得到以下语法错误信息:

$ go run curly.go
# command-line-arguments
./curly.go:7:6: missing function body
./curly.go:8:1: syntax error: unexpected semicolon or newline before { 

对于这个错误信息的官方解释是,Go 语言在许多情况下要求使用分号作为语句终止符,当编译器认为有必要时,会隐式地插入所需的分号。因此,将开括号 ({) 放在其自己的行上会使 Go 编译器在上一行(func main())的末尾插入一个分号,这是错误信息的主要原因。正确编写上一段代码的方法如下:

package main
import (
    "fmt"
)
func main() {
    fmt.Println("Go has strict rules for curly braces!")
} 

在了解这个全局规则之后,让我们继续介绍 Go 语言的一些重要特性。

您应该了解的 Go 语言知识

这个大节讨论了 Go 语言的重要和基本特性,包括变量、控制程序流程、迭代、获取用户输入和 Go 并发。我们首先讨论变量、变量声明和变量使用。

定义和使用变量

想象一下,您想执行基本的数学计算。在这种情况下,您需要定义变量来保存输入、中间计算和结果。

Go 提供了多种声明新变量的方式,使变量声明过程更加自然和方便。您可以使用 var 关键字来声明一个新变量,后跟变量名,然后是所需的数据类型(我们将在 第二章基本 Go 数据类型 中详细介绍数据类型)。如果您愿意,可以在声明后跟 = 和变量的初始值。如果提供了初始值,您可以省略数据类型,编译器会为您推断它。

这带我们来到了一个非常重要的 Go 规则:如果未给变量提供初始值,Go 编译器将自动将该变量初始化为其数据类型的零值。

此外,还有 := 符号,它可以用来代替变量声明。:= 通过推断其后值的类型来定义一个新变量。:= 的官方名称是 短赋值语句,在 Go 语言中非常常用,尤其是在从函数和带有 range 关键字的 for 循环中获取返回值时。

简短的赋值语句可以用作具有隐式类型的var声明的替代。在 Go 语言中,很少看到var的使用;var关键字主要用于声明没有初始值的全局或局部变量。前者之所以如此,是因为存在于函数代码之外的所有语句都必须以关键字开头,例如funcvar

这意味着简短的赋值语句不能在函数环境之外使用,因为那里不允许这样做。最后,当你想明确指定数据类型时,你可能需要使用var。例如,当你想让变量的类型为int8int32而不是默认的int时。

常量

存在一些值,例如数学常数π,是不可变的。在这种情况下,我们可以使用const来声明这样的值。常量的声明方式与变量相同,但一旦声明后就不能更改。

常量支持的数据类型包括字符、字符串、布尔值以及所有数值数据类型。关于 Go 语言数据类型的更多信息,请参阅第二章基本 Go 数据类型

全局变量

全局变量是在函数实现之外定义的变量。全局变量可以在包的任何地方访问,而无需显式地将它们传递给函数,并且除非它们被定义为常量,否则可以使用const关键字来更改它们。

虽然你可以使用var:=来声明局部变量,但只有const(当变量的值不会改变时)和var适用于全局变量。

打印变量

程序倾向于显示信息,这意味着它们需要打印数据或将数据发送到其他软件进行存储或处理。要在屏幕上打印数据,Go 语言使用fmt包的功能。如果你想让 Go 语言处理打印,那么你可能想使用fmt.Println()函数。然而,有时你可能希望完全控制数据的打印方式。在这种情况下,你可能想使用fmt.Printf()

fmt.Printf()类似于 C 语言的printf()函数,需要使用控制序列来指定将要打印的变量的数据类型。此外,fmt.Printf()函数允许你格式化生成的输出,这对于浮点值尤其方便,因为它允许你指定输出中要显示的数字(%.2f显示浮点值小数点后的两位数字)。最后,\n字符用于打印换行符,因此创建新行,因为fmt.Printf()不会自动插入换行符——这与自动插入换行符的fmt.Println()不同,因此其名称末尾有ln

以下程序说明了你可以如何声明新变量,如何使用它们,以及如何打印它们——将以下代码输入一个名为variables.go的纯文本文件中:

package main
import (
    "fmt"
"math"
)
var Global int = 1234
var AnotherGlobal = -5678
func main() {
    var j int
    i := Global + AnotherGlobal
    fmt.Println("Initial j value:", j)
    j = Global
    // math.Abs() requires a float64 parameter
// so we type cast it appropriately
    k := math.Abs(float64(AnotherGlobal))
    fmt.Printf("Global=%d, i=%d, j=%d k=%.2f.\n", Global, i, j, k)
} 

个人来说,我更喜欢通过以下方式使全局变量突出:要么以大写字母开头,要么使用全部大写字母。正如你将在第六章Go 包和函数中学习的,变量名首字符的大小写有特殊含义,在 Go 中会改变其可见性。因此,这仅适用于main包。

上述程序包含以下内容:

  • 一个名为Global的全局int变量。

  • 第二个名为AnotherGlobal的全局变量——Go 自动从其值推断其数据类型,在这种情况下是一个整数。

  • 一个名为j的局部变量,其类型为int,正如你将在下一章学习的,这是一个特殊的数据类型。j没有初始值,这意味着 Go 自动将其数据类型的零值分配给它,在这种情况下是0

  • 另一个名为i的局部变量——Go 从其值推断其数据类型。由于它是两个int值的和,它也是一个int

  • 由于math.Abs()需要一个float64参数,你不能将AnotherGlobal传递给它,因为AnotherGlobal是一个int变量。float64()类型转换将AnotherGlobal的值转换为float64。请注意,AnotherGlobal仍然具有int数据类型。

  • 最后,fmt.Printf()格式化和打印输出。

运行variables.go产生以下输出:

Initial j value: 0
Global=1234, i=-4444, j=1234 k=5678.00. 

这个例子演示了另一个重要的 Go 规则,这个规则之前也提到过:Go 不允许像 C 那样的隐式数据转换。正如在variables.go中展示的,期望(需要)float64值的math.Abs()函数不能与int值一起工作,即使这个特定的转换是直接且无错误的。Go 编译器拒绝编译这样的语句。你应该使用float64()显式地将int值转换为float64,以便事情能够正常工作。

对于不直接(例如,stringint)的转换,存在专门的函数,允许你捕获转换中的问题,以函数返回的错误变量形式。

控制程序流程

到目前为止,我们已经看到了 Go 变量,但我们是如何根据变量的值或其他条件来改变 Go 程序的流程的呢?Go 支持if/elseswitch控制结构。这两种控制结构在大多数现代编程语言中都可以找到,所以如果你已经使用过其他编程语言进行编程,你应该已经熟悉了ifswitch语句。if语句不需要括号来嵌入需要检查的条件,因为 Go 通常不使用括号。正如预期的那样,if支持elseelse if语句。

为了演示 if 的用法,让我们使用 Go 中几乎无处不在的一个非常常见的模式。这个模式表明,如果一个函数返回的错误变量的值为 nil,那么函数执行就没有问题。否则,某个地方存在错误条件,需要特别处理。这个模式通常如下实现:

err := anyFunctionCall()
if err != nil {
    // Do something if there is an error
} 

err 是一个变量,用于存储函数返回的错误值,!= 表示 err 变量的值不等于 nil。你将在 Go 程序中多次看到类似的代码。

// 开头的行是单行注释。如果你在一行的中间放置 //,那么从 // 到行尾的所有内容都被视为注释。如果 // 在字符串值内部,则不适用此规则。

switch 语句有两种不同的形式。在第一种形式中,switch 语句有一个要评估的表达式,而在第二种形式中,switch 语句没有要评估的表达式。在这种情况下,每个 case 语句都会评估表达式,这增加了 switch 的灵活性。你从 switch 获得的主要好处是,当正确使用时,它可以简化复杂且难以阅读的 if-else 块。

以下代码展示了 ifswitch 的用法,该代码旨在处理作为命令行参数给出的用户输入——请将其输入并保存为 control.go。为了学习目的,我们将 control.go 的代码分块呈现,以便更好地解释它:

package main
import (
    "fmt"
"os"
"strconv"
) 

这一部分包含预期的前言,其中包含了导入的包。main() 函数的实现紧接着开始:

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please provide a command line argument")
        return
    }
    argument := os.Args[1] 

这部分程序确保在继续之前,你有一个要处理的单个命令行参数,它可以通过 os.Args[1] 访问。我们将在稍后更详细地介绍这一点,但你也可以参考 图 1.2 了解更多关于 os.Args 切片的信息:

 // With expression after switch
switch argument {
    case "0":
        fmt.Println("Zero!")
    case "1":
        fmt.Println("One!")
    case "2", "3", "4":
        fmt.Println("2 or 3 or 4")
        fallthrough
default:
        fmt.Println("Value:", argument)
    } 

在这里,你看到的是一个包含四个分支的 switch 块。前三个需要精确的字符串匹配,最后一个匹配所有其他内容。case 语句的顺序很重要,因为只有第一个匹配会被执行。fallthrough 关键字告诉 Go,在执行完这个分支后,它将继续执行下一个分支,在这个例子中是默认分支:

 value, err := strconv.Atoi(argument)
    if err != nil {
        fmt.Println("Cannot convert to int:", argument)
        return
    } 

由于命令行参数被初始化为字符串值,我们需要使用单独的调用将用户输入转换为整数值,在这个例子中是调用 strconv.Atoi()。如果 err 变量的值为 nil,则转换成功,我们可以继续。否则,将在屏幕上打印错误消息,程序退出。

以下代码展示了 switch 的第二种形式,其中条件在每一个分支处被评估:

 // No expression after switch
switch {
    case value == 0:
        fmt.Println("Zero!")
    case value > 0:
        fmt.Println("Positive integer")
    case value < 0:
        fmt.Println("Negative integer")
    default:
        fmt.Println("This should not happen:", value)
    }
} 

这为你提供了更多的灵活性,但在阅读代码时需要更多的思考。在这种情况下,默认分支不应该被执行,主要是因为任何有效的整数值都会被其他三个分支捕获。尽管如此,默认分支仍然存在,这是一个好的实践,因为它可以捕获意外的值。

运行 control.go 生成以下输出:

$ go run control.go 10
Value: 10
Positive integer
$ go run control.go 0
Zero!
Zero! 

control.go 中的两个 switch 块各自创建一行输出。

使用 for 循环和 range 迭代

这一部分完全是关于 Go 中的迭代。Go 支持使用 for 循环以及 range 关键字来迭代数组、切片和(如你将在 第三章复合数据类型)中看到的映射的所有元素,而不需要知道数据结构的大小。

Go 简单性的一个例子是,Go 只提供了对 for 关键字的支持,而不是包括对 while 循环的直接支持。然而,根据你如何编写 for 循环,它可以作为 while 循环或无限循环运行。此外,当与 range 关键字结合使用时,for 循环可以实现 JavaScript 的 forEach 函数的功能。

即使 for 循环中只有一个语句或没有语句,你也必须将其括在大括号内。

你也可以使用变量和条件创建 for 循环。for 循环可以使用 break 关键字退出,你可以使用 continue 关键字跳过当前迭代。

以下程序说明了 for 循环及其与 range 关键字结合使用时的用法——将其键入并保存为 forLoops.go 以在之后执行:

package main
import "fmt"
func main() {
    // Traditional for loop
for i := 0; i < 10; i++ {
        fmt.Print(i*i, " ")
    }
    fmt.Println()
} 

之前的代码演示了一个传统的 for 循环,它使用了一个名为 i 的局部变量。这将在屏幕上打印 0123456789 的平方。因为 10 的平方不满足 10 < 10 的条件,所以它没有被计算和打印。

以下代码是典型的 Go 代码,它产生的输出与之前的 for 循环相同:

 i := 0
for ok := true; ok; ok = (i != 10) {
        fmt.Print(i*i, " ")
        i++
    }
    fmt.Println() 

你可能会用到它,但它有时很难阅读,尤其是对于刚接触 Go 的人来说。以下代码展示了如何使用 for 循环来模拟不支持直接使用的 while 循环:

 // For loop used as while loop
    i = 0
for {
        if i == 10 {
            break
        }
        fmt.Print(i*i, " ")
        i++
    }
    fmt.Println() 

if 条件中的 break 关键字提前退出循环并充当循环退出条件。如果没有将在某个时刻满足的退出条件以及 break 关键字,for 循环将永远不会结束。

最后,给定一个切片,你可以将其视为一个可调整大小的数组,命名为 aSlice,你可以通过 range 来迭代其所有元素,range 返回两个有序值:切片中当前元素的索引及其值。如果你想忽略这两个返回值中的任何一个,这里不是这种情况,你可以在想要忽略的值的位置使用 _。如果你只需要索引,你可以完全省略 range 的第二个值而不使用 _

 // This is a slice but range also works with arrays
    aSlice := []int{-1, 2, 1, -1, 2, -2}
    for i, v := range aSlice {
        fmt.Println("index:", i, "value: ", v)
    } 

如果你运行 forLoops.go,你会得到以下输出:

$ go run forLoops.go
0 1 4 9 16 25 36 49 64 81
0 1 4 9 16 25 36 49 64 81
0 1 4 9 16 25 36 49 64 81
index: 0 value:  -1
index: 1 value:  2
index: 2 value:  1
index: 3 value:  -1
index: 4 value:  2
index: 5 value:  -2 

之前的输出说明前三个 for 循环是等效的,因此产生相同的输出。最后六行显示了在 aSlice 中找到的每个元素的索引和值。

现在我们已经了解了 for 循环,让我们看看如何获取用户输入。

获取用户输入

获取用户输入是大多数程序的重要部分。本节介绍了两种获取用户输入的方法,即从标准输入读取和使用程序的命令行参数。

从标准输入读取

fmt.Scanln() 函数可以在程序运行时帮助您读取用户输入并将其存储到字符串变量中,该变量作为指针传递给 fmt.Scanln()fmt 包包含从控制台(os.Stdin)、文件或参数列表中读取用户输入的附加函数。

fmt.Scanln() 函数很少用于获取用户输入。通常,用户输入是从命令行参数或外部文件中读取的。然而,交互式命令行应用程序需要使用 fmt.Scanln()

以下代码演示了从标准输入读取——将其键入并保存为 input.go

package main
import (
    "fmt"
)
func main() {
    // Get User Input
    fmt.Printf("Please give me your name: ")
    var name string
    fmt.Scanln(&name)
    fmt.Println("Your name is", name)
} 

在等待用户输入时,让用户知道他们需要提供的信息类型是很好的,这就是 fmt.Printf() 调用的目的。不使用 fmt.Println() 的原因是因为 fmt.Println() 会自动在输出末尾添加换行符,而这不是我们想要的。

执行 input.go 生成以下类型的输出和用户交互:

$ go run input.go
Please give me your name: Mihalis
Your name is Mihalis 

处理命令行参数

虽然在需要时输入用户输入可能看起来是个好主意,但这通常不是真实软件的工作方式。通常,用户输入是以命令行参数的形式提供给可执行文件的。默认情况下,Go 中的命令行参数存储在 os.Args 切片中。

标准的 Go 库还提供了 flag 包来解析命令行参数,但有一些更好、更强大的替代方案。

下面的图示显示了 Go 中命令行参数的工作方式,这与 C 编程语言相同。重要的是要知道 os.Args 切片是由 Go 正确初始化的,并且在引用时对程序可用。os.Args 切片包含 string 类型的值:

包含文本、截图、字体、黑色描述自动生成

图 1.2:os.Args 切片的工作方式

存储在 os.Args 切片中的第一个命令行参数始终是可执行文件的文件路径。如果您使用 go run,您将获得一个临时名称和路径;否则,它将是用户提供的可执行文件路径。其余的命令行参数是可执行文件名称之后的参数——各种命令行参数自动由空格字符分隔,除非它们包含在双引号或单引号内;这取决于操作系统。

下面的代码展示了os.Args的使用,目的是在忽略无效输入(如字符和字符串)的情况下找到输入的最小和最大数值。将代码输入并保存为cla.go

package main
import (
    "fmt"
"os"
"strconv"
) 

如预期,cla.go以它的前言开始。fmt包用于打印输出,而os包是必需的,因为os.Args是其一部分。最后,strconv包包含将字符串转换为数值的函数。接下来,我们确保我们至少有一个命令行参数:

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Need one or more arguments!")
        return
    } 

记住,os.Args中的第一个元素总是可执行文件的路径,所以os.Args永远不会完全为空。接下来,程序以与之前示例中相同的方式检查错误。你将在第二章基本 Go 数据类型中了解更多关于错误和错误处理的内容。

 var min, max float64
var initialized = 0
for i := 1; i < len(arguments); i++ {
        n, err := strconv.ParseFloat(arguments[i], 64)
        if err != nil {
            continue
        } 

在这个情况下,我们使用strconv.ParseFloat()返回的error变量来确保对strconv.ParseFloat()的调用是成功的,并且有一个有效的数值可以处理。否则,我们应该继续到下一个命令行参数。

使用for循环遍历所有可用的命令行参数(除了第一个,它使用索引值0)。这是处理所有命令行参数的另一种流行技术。

以下代码用于在处理第一个有效命令行参数后正确初始化minmax变量的值:

 if initialized == 0 {
            min = n
            max = n
            initialized = 1
continue
        } 

我们使用initialized == 0来测试这是否是第一个有效的命令行参数。如果是这种情况,我们处理第一个命令行参数并将minmax变量初始化为其值。

下面的代码检查当前值是否是新的最小值或最大值——这是程序逻辑实现的地方:

 if n < min {
            min = n
        }
        if n > max {
            max = n
        }
    }
    fmt.Println("Min:", min)
    fmt.Println("Max:", max)
} 

程序的最后部分是关于打印你的发现,即所有有效命令行参数的最小和最大数值。从cla.go得到的输出取决于其输入:

$ go run cla.go a b 2 -1
Min: -1
Max: 2 

在这种情况下,ab是无效的,唯一有效的输入是-12,分别是最小值和最大值:

$ go run cla.go a 0 b -1.2 10.32
Min: -1.2
Max: 10.32 

在这种情况下,ab是无效输入,因此被忽略:

$ go run cla.go
Need one or more arguments! 

在最后一种情况下,由于cla.go没有要处理的输入,它将打印一条帮助信息。如果你不带有效输入值执行程序,例如,go run cla.go a b c,那么MinMax的值都将为零。

下一个子节展示了使用错误变量区分不同数据类型的技术。

使用错误变量来区分输入类型

现在,让我向您展示一种使用误差变量来区分各种用户输入的技术。为了使这项技术生效,您应该从更具体的案例逐步过渡到更通用的案例。如果我们谈论数值,您应该首先检查一个字符串是否是有效的整数,然后再检查相同的字符串是否是浮点数值,因为每个有效的整数也是有效的浮点数值。

程序的第一部分,保存为 process.go,如下所示:

package main
import (
    "fmt"
"os"
"strconv"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Not enough arguments")
        return
    } 

之前的代码包含了序言部分以及将命令行参数存储在 arguments 变量中的部分。

接下来的部分是我们开始检查输入有效性的地方:

 var total, nInts, nFloats int
    invalid := make([]string, 0)
    for _, k := range arguments[1:] {
        // Is it an integer?
        _, err := strconv.Atoi(k)
        if err == nil {
            total++
            nInts++
            continue
        } 

首先,我们创建三个变量来记录检查的总有效值数、找到的总整数值数和找到的总浮点数值数,分别。invalid 变量,它是一个字符串切片,用于存储所有非数值。

再次强调,我们需要遍历除了第一个参数之外的所有命令行参数,因为第一个参数的索引值为 0,这是可执行文件的路径。我们忽略可执行文件的路径,使用 arguments[1:] 而不是 arguments,选择切片的连续部分将在下一章讨论。

strconv.Atoi() 的调用确定我们是否正在处理一个有效的 int 值。如果是这样,我们将增加 totalnInts 计数器:

 // Is it a float
        _, err = strconv.ParseFloat(k, 64)
        if err == nil {
            total++
            nFloats++
            continue
        } 

同样,如果检查的字符串代表一个有效的浮点数值,strconv.ParseFloat() 的调用将会成功,程序将更新相关的计数器。最后,如果一个值不是数值,它将通过调用 append() 追加到 invalid 切片中:

 // Then it is invalid
        invalid = append(invalid, k)
    } 

程序的最后部分如下:

 fmt.Println("#read:", total, "#ints:", nInts, "#floats:", nFloats)
    if len(invalid) > total {
        fmt.Println("Too much invalid input:", len(invalid))
        for _, s := range invalid {
            fmt.Println(s)
        }
    }
} 

这里展示的额外代码会在您的无效输入多于有效输入时发出警告(len(invalid) > total)。这是在应用程序中保持意外输入的常见做法。

运行 process.go 会产生以下类型的输出:

$ go run process.go 1 2 3
#read: 3 #ints: 3 #floats: 0 

在这种情况下,我们处理了 1、2 和 3,这些都是有效的整数值:

$ go run process.go 1 2.1 a    
#read: 2 #ints: 1 #floats: 1 

在这种情况下,我们有一个有效的整数,1,一个浮点数值,2.1,以及一个无效的值,a:

$ go run process.go a 1 b
#read: 1 #ints: 1 #floats: 0
Too much invalid input: 2
a
b 

如果无效输入多于有效输入,那么 process.go 将打印额外的错误信息。

下一个子节讨论了 Go 的并发模型。

理解 Go 并发模型

本节是 Go 并发模型的快速介绍。Go 并发模型是通过 goroutineschannels 实现的。goroutine 是最小的可执行 Go 实体。要创建一个新的 goroutine,您必须使用 go 关键字后跟一个预定义的函数或匿名函数——这两种方法在 Go 中是等效的。

go 关键字仅与函数或匿名函数一起使用。

Go 中的 channel 是一种机制,它允许 goroutines 通信和交换数据。如果您是业余程序员或第一次听说 goroutines 和 channels,请不要慌张。goroutines 和 channels,以及管道和 goroutines 之间的数据共享,将在 第八章Go 并发 中更详细地解释。

虽然创建 goroutines 很容易,但在处理并发编程时还有其他困难,包括 goroutine 同步和 goroutines 之间的数据共享——这是 Go 在运行 goroutines 时使用全局状态来避免副作用的一种机制。由于 main() 也是一个 goroutine,您不希望 main() 在其他程序 goroutine 完成之前结束,因为一旦 main() 退出,整个程序以及任何尚未完成的 goroutine 都将终止。尽管 goroutines 不能直接相互通信,但它们可以共享内存。好事是,有各种技术可以让 main() 函数等待 goroutines 通过 channels 交换数据,或者在 Go 中较少使用共享内存。

将以下 Go 程序,该程序使用 time.Sleep() 调用来同步 goroutines(这不是同步 goroutines 的正确方法——我们将在 第八章Go 并发 中讨论同步 goroutines 的正确方法),输入您喜欢的编辑器中,并将其保存为 goRoutines.go

package main
import (
    "fmt"
"time"
)
func myPrint(start, finish int) {
    for i := start; i <= finish; i++ {
        fmt.Print(i, " ")
    }
    fmt.Println()
    time.Sleep(100 * time.Microsecond)
}
func main() {
    for i := 0; i < 4; i++ {
        go myPrint(i, 5)
    }
    time.Sleep(time.Second)
} 

之前天真实现的示例创建了四个 goroutines,并使用 myPrint() 函数在屏幕上打印一些值——使用 go 关键字创建 goroutines。运行 goRoutines.go 生成以下输出:

$ go run goRoutines.go
2 3 4 5
0 4 1 2 3 1 2 3 4 4 5
5
3 4 5
5 

然而,如果您多次运行它,您很可能会每次得到不同的输出:

1 2 3 4 5 
4 2 5 3 4 5 
3 0 1 2 3 4 5 
4 5 

这是因为 goroutines 以随机顺序初始化并随机顺序启动。Go 调度器负责 goroutines 的执行,就像操作系统调度器负责操作系统线程的执行一样。第八章Go 并发 详细讨论了 Go 并发,并使用 sync.WaitGroup 变量解决了随机性问题——然而,请记住,Go 并发无处不在,这也是在此处包含此部分的主要原因。因此,由于一些编译器生成的错误消息讨论了 goroutines,您不应认为这些 goroutines 是您创建的。

下一个部分将展示一个实际示例,该示例涉及开发 Go 版本的 which(1) 工具,该工具在当前用户的 PATH 环境值中搜索可执行文件。

在 Go 中开发 which(1) 工具

Go 可以通过一系列包与您的操作系统交互。通过尝试实现传统 UNIX 实用程序简单版本来学习一门新编程语言是一个好方法——一般来说,学习一门编程语言的唯一有效方法是大量使用该语言编写代码。在本节中,您将看到which(1)实用程序的 Go 版本,这将帮助您了解 Go 如何与底层操作系统交互以及如何读取环境变量。

所展示的代码,将实现which(1)的功能,可以分为三个逻辑部分。第一部分是关于读取输入参数,即实用程序将要搜索的可执行文件名称。第二部分是关于读取存储在PATH环境变量中的值,将其分割,并遍历PATH变量的目录。第三部分是在这些目录中寻找所需的二进制文件,并确定是否可以找到它,它是否是一个常规文件,以及它是否是一个可执行文件。如果找到了所需的可执行文件,程序将借助return语句终止。否则,它将在for循环结束后,main()函数退出后终止。

所展示的源文件名为which.go,位于书籍 GitHub 仓库的ch01目录下。现在,让我们看看代码,从通常包括包名、import语句和其他全局定义的逻辑前言开始:

package main
import (
    "fmt"
"os"
"path/filepath"
) 

fmt包用于在屏幕上打印,os包用于与底层操作系统交互,而path/filepath包用于处理读取为长字符串的PATH变量内容。

实用程序的第二个逻辑部分如下:

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide an argument!")
        return
    }
    file := arguments[1]
    path := os.Getenv("PATH")
    pathSplit := filepath.SplitList(path)
    for _, directory := range pathSplit { 

首先,我们读取程序的命令行参数(os.Args),并将第一个命令行参数保存到file变量中。然后,我们获取PATH环境变量的内容,并使用filepath.SplitList()进行分割,它提供了一种分离路径列表的便携方式。最后,我们使用rangefor循环遍历PATH变量的所有目录,因为filepath.SplitList()返回一个切片。

实用程序的其他部分包含以下代码:

 fullPath := filepath.Join(directory, file)
        // Does it exist?
        fileInfo, err := os.Stat(fullPath)
        if err != nil {
            continue
        }
        mode := fileInfo.Mode()
        // Is it a regular file?
if !mode.IsRegular() {
            continue
        }
        // Is it executable?
if mode&0111 != 0 {
            fmt.Println(fullPath)
            return
        }
    }
} 

我们使用filepath.Join()构建要检查的完整路径,它用于使用特定于操作系统的分隔符连接路径的不同部分——这使得filepath.Join()能够在所有支持的操作系统上工作。在这一部分,我们还获取有关文件的一些更底层的信息——请记住,UNIX 将一切视为文件,这意味着我们想要确保我们正在处理的是一个既是常规文件又是可执行文件的文件。

执行which.go会生成以下类型的输出:

$ go run which.go which
/usr/bin/which
$ go run which.go doesNotExist 

最后一条命令找不到 doesNotExist 可执行文件——根据 UNIX 哲学和 UNIX 管道的工作方式,如果实用工具没有要说的,它们就不会在屏幕上生成输出。

虽然在屏幕上打印错误信息很有用,但有时你需要将所有错误信息集中在一起,以便在方便的时候能够搜索它们。在这种情况下,你需要使用一个或多个日志文件。

下一节将讨论 Go 语言的登录操作。

日志信息

所有 UNIX 系统都有自己的日志文件,用于记录运行的服务和程序产生的日志信息。通常,UNIX 系统的大多数系统日志文件都可以在 /var/log 目录下找到。然而,许多流行服务的日志文件,如 Apache 和 Nginx,可能位于其他位置,这取决于它们的配置。

在日志文件中记录和存储日志信息是检查软件中的数据和信息的异步方法,无论是在本地、中央日志服务器,还是使用 Elasticsearch、Beats 和 Grafana Loki 等服务器软件。

通常来说,使用日志文件来记录一些信息被认为比在屏幕上输出相同内容是一种更好的实践,原因有两个。首先,因为输出不会丢失,因为它被存储在文件中;其次,因为你可以使用 UNIX 工具,如 grep(1)awk(1)sed(1) 来搜索和处理日志文件,而这些操作在终端窗口打印消息时是无法完成的。然而,写入日志文件并不总是最佳方法,主要是因为许多服务作为 Docker 镜像运行,当 Docker 镜像停止时,它们自己的日志文件也会丢失。

由于我们通常通过 systemd 运行我们的服务,程序应该将日志记录到 stdout,这样 systemd 就可以将日志数据放入日志中。12factor.net/logs 提供了有关应用程序日志的更多信息。此外,在云原生应用程序中,我们被鼓励简单地记录到 stderr,并让容器系统将 stderr 流重定向到所需的目的地。

UNIX 日志服务支持两个名为 日志级别日志设施 的属性。日志级别是一个指定日志条目严重性的值。有各种日志级别,包括 debuginfonoticewarningerrcritalertemerg,按照严重性递减的顺序。标准 Go 库的 log 包不支持与日志级别一起工作。日志设施类似于用于记录信息的类别。日志设施部分的值可以是 authauthprivcrondaemonkernlprmailmarknewssysloguserUUCPlocal0local1local2local3local4local5local6local7 之一,并且定义在 /etc/syslog.conf/etc/rsyslog.conf 或根据在 UNIX 机器上用于系统日志的服务器进程的其他适当文件中。这意味着如果日志设施没有正确定义,它将不会被处理;因此,您发送给它的日志消息可能会被忽略,从而丢失。

log 包将日志消息发送到标准错误。log 包的一部分是 log/syslog 包,它允许您将日志消息发送到您的机器上的 syslog 服务器。尽管默认情况下日志写入标准错误,但使用 log.SetOutput() 会修改这种行为。用于发送日志数据的函数列表包括 log.Printf()log.Print()log.Println()log.Fatalf()log.Fatalln()log.Panic()log.Panicln()log.Panicf()

日志记录是针对应用程序代码,而不是库代码。如果您正在开发库,请不要在其中添加日志记录。

为了将日志写入系统日志,您需要使用适当的参数调用 syslog.New() 函数。将日志写入主系统日志文件与使用 syslog.LOG_SYSLOG 选项调用 syslog.New() 一样简单。之后,您需要告诉您的 Go 程序所有日志信息都发送到新的日志记录器——这是通过调用 log.SetOutput() 函数实现的。这个过程在以下代码中得到了说明——将其输入您最喜欢的纯文本编辑器并保存为 systemLog.go

package main
import (
    "log"
"log/syslog"
)
func main() {
    sysLog, err := syslog.New(syslog.LOG_SYSLOG, "systemLog.go")
    if err != nil {
        log.Println(err)
        return
    } else {
        log.SetOutput(sysLog)
        log.Print("Everything is fine!")
    }
} 

在调用 log.SetOutput() 之后,所有日志信息都发送到 syslog 日志变量,该变量将其发送到 syslog.LOG_SYSLOG。来自该程序的日志条目的自定义文本作为 syslog.New() 调用的第二个参数指定。

通常,我们希望将日志数据存储在用户定义的文件中,因为它们将相关信息分组,这使得它们更容易处理和检查。

运行 systemLog.go 不会生成任何输出。然而,如果您在 Linux 机器上执行 journalctl -xe,您可以看到如下条目:

Jun 08 20:46:05 thinkpad systemLog.go[4412]: 2023/06/08 20:46:05 Everything is fine!
Jun 08 20:46:51 thinkpad systemLog.go[4822]: 2023/06/08 20:46:51 Everything is fine! 

您自己的操作系统的输出可能略有不同,但基本思想是相同的。

坏事情总是会发生,即使是好人好软件也会遇到。所以下一个小节将介绍 Go 处理不良情况的方式。

log.Fatal() 和 log.Panic()

当发生错误并且你只想在报告了这种糟糕的情况后尽快退出程序时,使用log.Fatal()函数。log.Fatal()调用在打印错误消息后终止 Go 程序在log.Fatal()被调用的位置。在大多数情况下,这个自定义错误消息可以是“参数不足”、“无法访问文件”或类似的内容。此外,它返回一个非零退出代码,在 UNIX 中表示错误。

有时候,一个程序即将永久失败,而你希望尽可能多地了解失败的信息——log.Panic()暗示了一些真正意外且未知的情况已经发生,例如无法找到之前访问过的文件或磁盘空间不足。类似于log.Fatal()函数,log.Panic()打印一条自定义消息并立即终止 Go 程序。

请记住,log.Panic()等同于调用log.Print(),然后调用panic()。这是一个内置函数,它会停止当前函数的执行并开始恐慌。之后,它返回到调用函数。相反,log.Fatal()调用log.Print()然后os.Exit(1),这是一种立即终止当前程序的方式。log.Fatal()log.Panic()都在logs.go文件中进行了说明,该文件包含以下 Go 代码:

package main
import (
    "log"
"os"
)
func main() {
    if len(os.Args) != 1 {
        log.Fatal("Fatal: Hello World!")
    }
    log.Panic("Panic: Hello World!")
} 

如果你没有提供任何命令行参数就调用logs.go,它将调用log.Panic()。否则,它将调用log.Fatal()。这在一个 Arch Linux 系统的以下输出中得到了说明:

$ go run logs.go
2023/06/08 20:48:42 Panic: Hello World!
panic: Panic: Hello World!
goroutine 1 [running]:
log.Panic({0xc000104f60?, 0x0?, 0x0?})
    /usr/lib/go/src/log/log.go:384 +0x65
main.main()
    /home/mtsouk/code/mGo4th/ch01/logs.go:12 +0x85
exit status 2
$ go run logs.go 1
2023/06/08 20:48:59 Fatal: Hello World!
exit status 1 

因此,log.Panic()的输出包括额外的低级信息,希望这些信息能帮助你解决在 Go 代码中出现的困难情况。

请记住,这两个函数都会突然终止程序,这可能不是用户想要的。因此,它们不是结束程序的最佳方式。然而,它们在报告真正糟糕的错误条件或意外情况时可能很有用。两个这样的例子是当程序无法保存其数据或配置文件找不到时。

下一个小节是关于将日志写入自定义日志文件。

将日志写入自定义日志文件

大多数情况下,尤其是在将应用程序和服务部署到生产环境时,你需要将你的日志数据写入你选择的日志文件。这可能出于许多原因,包括在不干扰系统日志文件的情况下写入调试数据,或者将你的日志数据与系统日志分开,以便将其传输或存储在数据库或软件中,如 Elasticsearch。本小节将教你如何写入通常针对特定应用程序的自定义日志文件。

写入文件以及文件输入输出都在第七章告诉 UNIX 系统做什么中进行了介绍——然而,在调试 Go 代码时将信息保存到文件中非常有用,这就是为什么它被包含在第一章中的原因。

使用的日志文件路径(mGo.log)存储在一个名为 LOGFILE 的变量中——这是通过使用 os.TempDir() 函数创建的,该函数返回当前操作系统用于临时文件的默认目录,以防止在出现错误时文件系统变满。

此外,在此阶段,这将让您免于需要以 root 权限执行 customLog.go 并将不必要的文件放入宝贵的系统目录中。

输入以下代码并将其保存为 customLog.go

package main
import (
    "fmt"
"log"
"os"
"path"
)
func main() {
    LOGFILE := path.Join(os.TempDir(), "mGo.log")
    fmt.Println(LOGFILE)
    f, err := os.OpenFile(LOGFILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
// The call to os.OpenFile() creates the log file for writing, 
// if it does not already exist, or opens it for writing 
// by appending new data at the end of it (os.O_APPEND)
if err != nil {
        fmt.Println(err)
        return
    }
    defer f.Close() 

defer 关键字告诉 Go 在当前函数返回之前执行语句。这意味着 f.Close() 将在 main() 返回之前执行。我们将在 第六章Go 包和函数 中更详细地介绍 defer

 iLog := log.New(f, "iLog ", log.LstdFlags)
    iLog.Println("Hello there!")
    iLog.Println("Mastering Go 4th edition!")
} 

最后三个语句基于一个打开的文件(f)创建一个新的日志文件,并使用 Println() 向其写入两条消息。

如果您决定在真实应用中使用 customLog.go 的代码,您应该将存储在 LOGFILE 中的路径更改为更有意义的内容。

在一个 Arch Linux 机器上运行 customLog.go 会打印日志文件的路径:

$ go run customLog.go
/tmp/mGo.log 

根据您的操作系统,您的输出可能会有所不同。然而,重要的是在自定义日志文件中写入的内容:

$ cat /tmp/mGo.log
iLog 2023/11/27 22:15:10 Hello there!
iLog 2023/11/27 22:15:10 Mastering Go 4th edition! 

下一个小节将展示如何在日志条目中打印行号。

在日志条目中打印行号

在本小节中,您将学习如何打印写入日志条目的语句所在的源文件中的文件名以及行号。

所需的功能是通过在 log.New()SetFlags() 的参数中使用 log.Lshortfile 来实现的。log.Lshortfile 标志将文件名以及打印日志条目的 Go 语句的行号添加到日志条目本身中。如果您使用 log.Llongfile 而不是 log.Lshortfile,则获取 Go 源文件的完整路径——通常,这并不是必需的,尤其是当路径非常长时。

输入以下代码并将其保存为 customLogLineNumber.go

package main
import (
    "fmt"
"log"
"os"
"path"
)
func main() {
    LOGFILE := path.Join(os.TempDir(), "mGo.log")
    fmt.Println(LOGFILE)
    f, err := os.OpenFile(LOGFILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer f.Close()
    LstdFlags := log.Ldate | log.Lshortfile
    iLog := log.New(f, "LNum ", LstdFlags)
    iLog.Println("Mastering Go, 4th edition!")
    iLog.SetFlags(log.Lshortfile | log.LstdFlags)
    iLog.Println("Another log entry!")
} 

如果您想知道,您可以在程序执行期间更改日志条目的格式——这意味着当有理由时,您可以在日志条目中打印更多分析信息。这是通过多次调用 iLog.SetFlags() 来实现的。

运行 customLogLineNumber.go 生成以下输出:

$ go run customLogLineNumber.go
/var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/mGo.log 

它还会在由 LOGFILE 全局变量值指定的文件路径中写入以下条目:

$ cat /var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/mGo.log
LNum 2023/06/08 customLogLineNumber.go:25: Mastering Go, 4th edition!
LNum 2023/06/08 20:58:09 customLogLineNumber.go:28: Another log entry! 

第一个错误信息来自源代码的第 25 行,而第二个来自第 28 行。

您在自己的机器上可能会得到不同的输出,这是预期的行为。

向多个日志文件写入

本小节展示了向多个日志文件写入的技术——这通过 multipleLogs.go 来说明,该文件可以在书的 GitHub 仓库的 ch01 目录下找到,并包含以下代码:

package main
import (
    "fmt"
"io"
"log"
"os"
)
func main() {
    flag := os.O_APPEND | os.O_CREATE | os.O_WRONLY
    file, err := os.OpenFile("myLog.log", flag, 0644)
    if err != nil {
        fmt.Println(err)
        os.Exit(0)
    }
    defer file.Close()
    w := io.MultiWriter(file, os.Stderr)
    logger := log.New(w, "myApp: ", log.LstdFlags)
    logger.Printf("BOOK %d", os.Getpid())
} 

io.MultiWriter() 函数使我们能够写入多个目的地,在这种情况下是一个名为 myLog.log 的文件和标准错误。

运行 multipleLogs.go 的结果可以在 myLog.log 文件中看到,该文件将在当前工作目录中创建,并输出到标准错误,通常在屏幕上显示:

$ go run multipleLogs.go
myApp: 2023/06/24 21:02:55 BOOK 71457 

myLog.log 文件的内容与之前相同:

$ at myLog.log
myApp: 2023/06/24 21:02:55 BOOK 71457 

在下一节中,我们将编写统计应用的第一个版本。

开发统计应用

在本节中,我们将开发一个存储在 stats.go 中的基本统计应用。在整个书中,统计应用将得到改进和增强,增加新功能。

stats.go 的第一部分如下:

package main
import (
    "fmt"
"math"
"os"
"strconv"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Need one or more arguments!")
        return
    } 

在应用的第一部分,在 main() 函数之前导入必要的 Go 包,确保我们至少有一个命令行参数可以工作,使用 len(arguments) == 1

stats.go 的第二部分如下:

 var min, max float64
var initialized = 0
    nValues := 0
var sum float64
for i := 1; i < len(arguments); i++ {
        n, err := strconv.ParseFloat(arguments[i], 64)
        if err != nil {
            continue
        }
        nValues = nValues + 1
        sum = sum + n
        if initialized == 0 {
            min = n
            max = n
            initialized = 1
continue
        }
        if n < min {
            min = n
        }
        if n > max {
            max = n
        }
    }
    fmt.Println("Number of values:", nValues)
    fmt.Println("Min:", min)
    fmt.Println("Max:", max) 

在前面的代码片段中,我们处理所有有效的输入来计算有效值的数量,并找出其中的最小值和最大值。

stats.go 文件的最后部分如下:

 // Mean value
if nValues == 0 {
        return
    }
meanValue := sum / float64(nValues)
    fmt.Printf("Mean value: %.5f\n", meanValue)
    // Standard deviation
var squared float64
for i := 1; i < len(arguments); i++ {
        n, err := strconv.ParseFloat(arguments[i], 64)
        if err != nil {
            continue
        }
        squared = squared + math.Pow((n-meanValue), 2)
    }
    standardDeviation := math.Sqrt(squared / float64(nValues))
    fmt.Printf("Standard deviation: %.5f\n", standardDeviation)
} 

在前面的代码片段中,我们找到平均值,因为在不处理所有值之前无法计算。之后,我们处理每个有效值来计算标准差,因为需要平均值来计算标准差。

运行 stats.go 会生成以下类型的输出:

$ go run stats.go 1 2 3
Number of values: 3
Min: 1
Max: 3
Mean value: 2.00000
Standard deviation: 0.81650 

概述

在本章的开头,我们讨论了 Go 的优点、缺点、哲学和历史。然后,介绍了 Go 的基础知识,包括变量、迭代和流程控制,以及如何记录数据。

之后,我们学习了日志记录,实现了 which(1),并创建了一个基本的统计应用。

下一章将介绍基本的 Go 数据类型。

练习

通过尝试完成以下练习来测试你所学的知识:

  • 使用 go doc 读取 fmt 包的文档。

  • 在 UNIX 中,退出码 0 表示成功,而非零退出码通常表示失败。尝试修改 which.go 以使用 os.Exit() 来实现这一点。

  • 当前版本的 which(1) 在找到第一个所需的可执行文件后停止。为了找到所有可能的可执行文件,需要对 which.go 进行必要的代码更改。

其他资源

留下评论!

喜欢这本书吗?通过留下亚马逊评论来帮助像你一样的读者。扫描下面的二维码,获取你选择的免费电子书。

评论二维码

第二章:基礎 Go 數據類型

數據存儲和操作在變數中——所有 Go 語言的變數都應該有一個數據類型,這個數據類型是顯式聲明或隱式確定的。了解 Go 的內置數據類型可以讓你理解如何操作簡單的數據值,並在簡單數據類型不足以或不高效於某項任務時構建更複雜的數據結構。Go 作為一種靜態類型和編譯的編程語言,允許編譯器在程序執行前進行各種優化和檢查。

本章的第一部分全部关于 Go 语言的基礎數據類型,第二部分則合乎邏輯地接續,涵蓋了允許你將同種數據類型的數據分組的數據結構,這些是數組和功能更強大的切片。

但讓我們從一些更實際的內容開始:想像一下你想讀取命令行參數中的數據。你如何確保你讀取的是你期望的內容?你如何處理錯誤情況?那麼,只讀取數字和字符串,而不是從命令行讀取日期和時間呢?你是否需要為處理日期和時間而編寫自己的解析器?

第一章Go 快速入門 中,我們包含了所展示的源文件的完整代碼。然而,從本章開始,這不總是這樣。這有兩個目的:第一個是讓你看到真正重要的代碼,第二個是為了節省書籍空間。

本章將回答所有這些問題以及許多其他問題,例如使用 unsafe 包、切片的內部結構以及切片如何與數組相關聯,以及如何在 Go 中使用指針。此外,它還實現了生成隨機數字和隨機字符串的公用程序,並更新統計應用程序。因此,本章涵蓋了:

  • 錯誤數據類型

  • 數字數據類型

  • 非數字數據類型

  • 常量

  • 將相似數據分組

  • 指針

  • 數據類型和 unsafe

  • 生成随机数

  • 更新統計應用程序

我們以 error 數據類型開始本章,因為錯誤和錯誤處理在 Go 裡扮演著關鍵的角色。

錯誤數據類型

Go 提供了一種特殊的數據類型,稱為 error,用於表示錯誤條件和錯誤信息——實際上,這意味著 Go 將錯誤視為值。要在 Go 中成功編程,你應該了解可能發生在您所使用的函數和方法中的錯誤條件,並相應地處理它們

如你所知,Go 遵循有关错误值的特定约定:如果一个error变量的值为nil,则表示没有错误。作为一个例子,让我们考虑strconv.Atoi(),它用于将string值转换为int值(Atoi代表ASCII to Int)。根据其签名,strconv.Atoi()返回(int, error)。具有nil错误值的表示转换成功,并且如果你想使用,你可以使用int值。具有非nil错误值的表示转换失败,并且字符串输入不是一个有效的int值。

如果你想了解更多关于strconv.Atoi()的信息,你应该在你的终端窗口中执行go doc strconv.Atoi

你可能会想知道,如果你想要创建自己的错误信息会发生什么。这是可能的吗?如果你想返回一个自定义的错误,你可以使用errors.New()函数,它来自errors包。这种情况通常发生在main()函数之外的函数中,因为main()函数不向任何其他函数返回任何内容。此外,定义你自定义错误的好地方是在你创建的 Go 包内部。

你很可能会在你的程序中处理错误,而不需要errors包的功能。此外,除非你正在创建大型应用程序或包,否则你不需要定义自定义错误信息

如果你想要以fmt.Printf()工作的方式格式化错误信息,你可以使用fmt.Errorf()函数,它简化了自定义错误信息的创建——fmt.Errorf()函数返回一个error值,就像errors.New()一样。

现在,我们应该谈谈一些重要的事情:你应该在每个应用程序中有一个全局的错误处理策略,这个策略不应该改变。在实践中,这意味着以下内容:

  • 所有错误信息都应该在同一级别处理,这意味着所有错误要么应该返回给调用函数,要么在它们发生的地方处理。

  • 临界错误的处理应该有明确的文档。这意味着在某些情况下,临界错误应该终止程序,而在其他情况下,临界错误可能只是在屏幕上创建一个警告消息并继续。

  • 被认为是一种好的做法,将所有错误信息发送到机器的日志服务,因为这样可以在以后检查错误信息。然而,这并不总是正确的,因此在设置时请谨慎——例如,云原生应用程序就不是这样工作的。对于云原生应用程序,最好将错误输出发送到标准错误,这样错误信息就不会丢失。

error数据类型被定义为接口——接口将在第五章反射和接口中介绍。

在你最喜欢的文本编辑器中输入以下代码,并将其保存为error.go,放在你放置本章代码的目录中。使用ch02作为目录名是个好主意。

package main
import (
    "errors"
"fmt"
"os"
"strconv"
) 

第一部分是程序的序言部分——error.go 使用了 fmtosstrconverrors 包。

func check(a, b int) error {
    if a == 0 && b == 0 {
        return errors.New("this is a custom error message")
    }
    return nil
} 

前面的代码实现了一个名为 check() 的函数,该函数返回一个 error 值。如果 check() 的两个输入参数都等于 0,则该函数使用 errors.New() 返回一个自定义错误信息;否则它返回 nil

func formattedError(a, b int) error {
    if a == 0 && b == 0 {
        return fmt.Errorf("a %d and b %d. UserID: %d", a, b, os.Getuid())
    }
    return nil
} 

前面的代码实现了 formattedError() 函数,该函数使用 fmt.Errorf() 返回一个格式化的错误信息。除此之外,错误信息通过调用 os.Getuid() 打印出执行程序的用户的用户 ID。当你想要创建一个自定义错误信息时,使用 fmt.Errorf() 可以让你对输出有更多的控制。

func main() {
    err := check(0, 10)
    if err == nil {
        fmt.Println("check() executed normally!")
    } else {
        fmt.Println(err)
    }
    err = check(0, 0)
    if err.Error() == "this is a custom error message" {
        fmt.Println("Custom error detected!")
    }
    err = formattedError(0, 0)
    if err != nil {
        fmt.Println(err)
    }
    i, err := strconv.Atoi("-123")
    if err == nil {
        fmt.Println("Int value is", i)
    }
    i, err = strconv.Atoi("Y123")
    if err != nil {
        fmt.Println(err)
    }
} 

前面的代码是 main() 函数的实现,你可以看到多次使用 if err != nil 语句以及使用 if err == nil,后者用于确保在执行所需代码之前一切正常。

请记住,尽管前面的代码比较了一个错误信息,但这被认为是一种不好的做法。更好的做法是在不是 nil 的情况下打印错误信息。

运行 error.go 产生以下输出:

$ go run error.go
check() ended normally!
Custom error detected!
a 0 and b 0\. UserID: 501
Int value is -123
strconv.Atoi: parsing "Y123": invalid syntax 

现在你已经了解了 error 数据类型、如何创建自定义错误以及如何使用错误值,我们将继续介绍 Go 的基本数据类型,这些数据类型可以逻辑上分为两大类:数值数据类型非数值数据类型。Go 还支持 bool 数据类型,它只能有 truefalse 两个值。

数值数据类型

Go 根据它们消耗的内存空间支持整数、浮点数和复数值,具体版本各异——这有助于节省内存和计算时间。整数数据类型可以是带符号的或无符号的,而浮点数则不是这样。

下表列出了 Go 的数值数据类型。

数据类型 描述
int8 8 位带符号整数
int16 16 位带符号整数
int32 32 位带符号整数
int64 64 位带符号整数
int 32 位或 64 位带符号整数
uint8 8 位无符号整数
uint16 16 位无符号整数
uint32 32 位无符号整数
uint64 64 位无符号整数
uint 32 位或 64 位无符号整数
float32 32 位浮点数
float64 64 位浮点数
complex64 带有 float32 部分的复数
complex128 带有 float64 部分的复数

intuint 数据类型是特殊的,因为它们是在给定平台上带符号和无符号整数最有效的尺寸,每个可以是 32 或 64 位——它们的大小由 Go 根据 CPU 寄存器大小自行定义。int 数据类型是 Go 中最广泛使用的数值数据类型,因为它具有多功能性。

下面的代码展示了数字数据类型的使用——你可以在书籍 GitHub 仓库的ch02目录中的numbers.go文件中找到整个程序:

func main() {
    c1 := 12 + 1i
    c2 := complex(5, 7)
    fmt.Printf("Type of c1: %T\n", c1)
    fmt.Printf("Type of c2: %T\n", c2) 

之前的代码以两种不同的方式创建了两个复数变量——这两种方式都是完全有效且等效的。除非你对数学感兴趣,否则你很可能不会在你的程序中使用复数。然而,对复数的直接支持展示了现代 Go 语言的特点。

 var c3 complex64 = complex64(c1 + c2)
    fmt.Println("c3:", c3)
    fmt.Printf("Type of c3: %T\n", c3)
    cZero := c3 - c3
    fmt.Println("cZero:", cZero) 

之前的代码通过添加和减去两个复数对继续使用复数。尽管cZero等于零,但它仍然是一个复数和一个complex64变量。

 x := 12
    k := 5
    fmt.Println(x)
    fmt.Printf("Type of x: %T\n", x)
    div := x / k
    fmt.Println("div", div) 

在这部分中,我们定义了两个名为xk的整数变量——它们的数据类型由 Go 根据它们的初始值确定。两者都是int类型,这是 Go 首选用于存储整数值的类型。此外,当你除以两个整数值时,即使除法不是完美的,你也会得到一个整数值。因此,如果你不希望这样,你应该采取额外的措施——这将在下一段代码示例中展示:

 var m, n float64
    m = 1.223
    fmt.Println("m, n:", m, n)
    y := 4 / 2.3
    fmt.Println("y:", y)
    divFloat := float64(x) / float64(k)
    fmt.Println("divFloat", divFloat)
    fmt.Printf("Type of divFloat: %T\n", divFloat)
} 

之前的代码使用float64值和变量进行操作。由于n没有初始值,它自动被分配了其数据类型的零值,对于float64数据类型来说,这个值是0。此外,代码展示了一种将整数值除以得到浮点结果的技术,这就是使用float64()divFloat := float64(x) / float64(k)

这是一个类型转换,其中两个整数(xk)被转换为float64值。由于两个float64值之间的除法结果是float64值,所以我们得到了所需的数据类型的结果。

运行numbers.go会生成以下输出:

$ go run numbers.go
Type of c1: complex128
Type of c2: complex128
c3: (17+8i)
Type of c3: complex64
cZero: (0+0i)
12
Type of x: int
div 2
m, n: 1.223 0
y: 1.7391304347826086
divFloat 2.4
Type of divFloat: float64 

输出显示,c1c2都是complex128类型的值,这是在执行代码的机器上首选的复数数据类型。然而,c3是一个complex64类型的值,因为它使用了complex64()创建。n的值是0,因为n变量没有被初始化,这意味着 Go 自动将其数据类型的零值分配给了n

避免溢出

由于每个变量都存储在内存(位)中,因此我们对变量内存空间中可以存储的信息量有一个限制。尽管在这个小节中我们将讨论整数,但类似的规则适用于所有数字数据类型。Go 语言在math包中提供了代表整数数据类型最大和最小值的常量。例如,对于int数据类型,存在math.MaxIntmath.MinInt常量,分别代表int变量的最大和最小允许值。

overflows.go中的重要部分可以在两个for循环中找到。第一个循环是关于确定最大int值:

for {
    if i == math.MaxInt {
        break
    }
    i = i + 1
} 

在之前的代码中,我们不断增加i的值,直到它达到math.MaxInt

下一个for循环是关于找出最小int值:

for {
    if i == math.MinInt {
        break
    }
    i = i - 1
} 

这次,我们不断减小 i 的值,直到它达到 math.MinInt

运行 overflows.go 产生以下输出:

$ go run overflows.go
Max: 9223372036854775807
Max overflow: -9223372036854775808
Min: -9223372036854775808 

因此,当前平台(配备 M1 Max CPU 的 MacBook Pro)上的最大 int 值是 9223372036854775807,最小 int 值是 -9223372036854775808。当我们尝试增加最大 int 值时,我们将会得到最小 int 值!

在了解了数值数据类型之后,现在是时候学习非数值数据类型了,这是下一节的主题。

非数值数据类型

Go 支持字符串、字符、rune、日期和时间。但是,Go 没有专门的 char 数据类型。在 Go 中,日期和时间是同一回事,由相同的数据类型表示。然而,是否一个时间和日期变量包含有效信息取决于你。

我们首先解释与字符串相关的数据类型。

字符串、字符和 rune

Go 支持使用 string 数据类型来表示字符串——字符串被双引号或反引号包围。Go 字符串只是一个 字节的集合,可以作为一个整体或数组来访问。单个字节可以存储任何 ASCII 字符——然而,通常需要多个字节来存储单个 Unicode 字符

现在,支持 Unicode 字符是一个常见的要求——Go 的设计考虑到了 Unicode 支持,这也是为什么有 rune 数据类型的原因。rune 是一个 int32 类型的值,用于表示单个 Unicode 代码点,这是一个用于表示单个 Unicode 字符或,较少情况下,提供格式化信息的整数值。

虽然 rune 是一个 int32 类型的值,但你不能将 runeint32 类型的值进行比较。Go 将这两种数据类型视为完全不同。

你可以使用 []byte("A String") 语句从一个给定的字符串创建一个新的 字节切片。给定一个字节切片变量 b,你可以使用 string(b) 语句将其转换为字符串。当处理包含 Unicode 字符的字节切片时,字节切片中的字节数并不总是与字节切片中的字符数相对应,因为大多数 Unicode 字符需要多个字节来表示。因此,当你尝试使用 fmt.Println()fmt.Print() 打印字节切片的每个单独字节时,输出不是以字符形式呈现的文本,而是整数值。如果你想以文本形式打印字节切片的内容,你应该使用 string(byteSliceVar) 或使用 fmt.Printf() 并带有 %s 来告诉 fmt.Printf() 你想打印一个字符串。你可以使用类似于 []byte("My Initialization String") 的语句来初始化一个新的字节切片。

我们将在 字节切片 部分更详细地介绍字节切片。

你可以使用单引号定义一个 rune:r := '€',并且你可以打印组成它的字节的整数值,作为 fmt.Println(r)——在这种情况下,整数值是 8364。将其打印为单个 Unicode 字符需要使用 fmt.Printf() 中的 %c 控制字符串。

由于字符串可以作为数组访问,你可以使用 for 循环遍历字符串中的 runes,或者如果你知道它在字符串中的位置,可以指向特定的字符。字符串的长度与字符串中找到的字符数相同,这对于字节切片通常不成立,因为 Unicode 字符通常需要多个字节。

以下 Go 代码说明了字符串和 runes 的使用以及如何在你的代码中处理字符串。你可以在本书 GitHub 仓库的 ch02 目录中找到整个程序作为 text.go

程序的第一部分定义了一个包含 Unicode 字符的字符串字面量。然后它像数组一样访问其第一个字节:

func main() {
    aString := "Hello World! €"
    fmt.Println("First byte", string(aString[0])) 

下一个部分是关于处理 runes:

 r := '€'
    fmt.Println("As an int32 value:", r)
    // Convert Runes to text
    fmt.Printf("As a string: %s and as a character: %c\n", r, r)
    // Print an existing string as runes
for _, v := range aString {
        fmt.Printf("%x ", v)
    }
    fmt.Println() 

首先,我们定义一个名为 r 的 rune。使它成为 rune 的是 字符周围的单引号。rune 是一个 int32 值,并且由 fmt.Println() 以这种方式打印出来。fmt.Printf() 中的 %c 控制字符串将 rune 打印为字符。然后我们使用带有 rangefor 循环将 aString 作为切片或数组迭代,并打印 aString 的内容作为 runes。

 // Print an existing string as characters
for _, v := range aString {
        fmt.Printf("%c", v)
    }
    fmt.Println()
} 

最后,我们使用带有 rangefor 循环将 aString 作为切片或数组迭代,并打印 aString 的内容作为字符。

运行 text.go 产生以下输出:

$ go run text.go
First byte H
As an int32 value: 8364
As a string: %!s(int32=8364) and as a character: €
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 20 20ac
Hello World! € 

输出的第一行显示我们可以将字符串作为数组访问,而第二行验证 rune 是一个整数值。第三行显示当你将 rune 作为字符串和字符打印时应该期待什么——正确的方式是将其作为字符打印。第五行显示如何将字符串作为 runes 打印,最后一行显示使用 rangefor 循环将字符串作为字符处理时的输出。

将 int 转换为字符串

你可以将整数值转换为字符串的两种主要方式:使用 string() 和使用 strconv 包中的函数。然而,这两种方法在本质上是有区别的。string() 函数将整数值转换为 Unicode 码点,这是一个单独的字符,而像 strconv.FormatInt()strconv.Itoa() 这样的函数将整数值转换为具有相同表示和相同字符数的字符串值。

这在 intString.go 程序中得到了说明——其最重要的语句如下。你可以在本书的 GitHub 仓库中找到整个程序。

 input := strconv.Itoa(n)
    input = strconv.FormatInt(int64(n), 10)
    input = string(n) 

运行 intString.go 生成以下类型的输出:

$ go run intString.go 100
strconv.Itoa() 100 of type string
strconv.FormatInt() 100 of type string
string() d of type string 

输出数据类型始终是字符串,然而,string()100 转换为 d,因为 d 的 ASCII 表示是 100

现在我们已经了解了如何将整数转换为字符串,现在是时候学习如何处理 Unicode 文本和代码点了。

Unicode 包

unicode标准 Go 包包含各种方便的函数,用于处理 Unicode 代码点。其中之一,称为unicode.IsPrint(),可以帮助你识别字符串中可打印的部分。

以下代码摘录展示了unicode包的功能:

 for i := 0; i < len(sL); i++ {
        if unicode.IsPrint(rune(sL[i])) {
            fmt.Printf("%c\n", sL[i])
        } else {
            fmt.Println("Not printable!")
        }
    } 

for 循环遍历一个定义为 runes 列表的字符串("\x99\x00ab\x50\x00\x23\x50\x29\x9c")的内容,同时unicode.IsPrint()检查字符是否可打印——如果它返回true,则 rune 是可打印的。

你可以在书的 GitHub 仓库中ch02目录下的unicode.go源文件中找到这段代码摘录。运行unicode.go会产生以下输出:

Not printable!
Not printable!
a
b
P
Not printable!
#
P
)
Not printable! 

这个实用工具在过滤输入或过滤数据以便在屏幕上打印、存储在日志文件中、在网络中传输或存储在数据库中时非常方便。

在下一小节中,我们将借助strings包继续处理文本。

Strings 包

strings标准 Go 包允许你在 Go 中操作 UTF-8 字符串,并包含许多强大的函数。其中许多函数在useStrings.go源文件中有展示,该文件位于书的 GitHub 仓库的ch02目录中。

如果你正在处理文本和文本处理,你需要学习strings包的所有细节和函数,所以请确保你尝试所有这些函数,并创建许多示例来帮助你澄清问题。

useStrings.go文件最重要的部分如下:

import (
    "fmt"
    s "strings"
"unicode"
)
var f = fmt.Printf 

由于我们将会多次使用strings包,我们创建了一个方便的别名s——请注意,这被认为是一种不好的做法,我们在这里这样做是为了防止代码行太长。我们对fmt.Printf()函数也做了同样的事情,创建了一个全局别名,使用变量f。这两个快捷方式减少了长代码行的重复。你可以在学习 Go 时使用它们,但在任何类型的生产软件中都不推荐这样做,因为它会使代码的可读性降低。

第一段代码摘录如下:

 f("To Upper: %s\n", s.ToUpper("Hello THERE"))
    f("To Lower: %s\n", s.ToLower("Hello THERE"))
    f("%s\n", s.Title("tHis wiLL be A title!"))
    f("EqualFold: %v\n", s.EqualFold("Mihalis", "MIHAlis"))
    f("EqualFold: %v\n", s.EqualFold("Mihalis", "MIHAli")) 

strings.EqualFold()函数在不考虑字符串大小写的情况下比较两个字符串,当它们相同返回 true,否则返回 false。

 f("Index: %v\n", s.Index("Mihalis", "ha"))
    f("Index: %v\n", s.Index("Mihalis", "Ha"))
    f("Count: %v\n", s.Count("Mihalis", "i"))
    f("Count: %v\n", s.Count("Mihalis", "I"))
    f("Repeat: %s\n", s.Repeat("ab", 5))
    f("TrimSpace: %s\n", s.TrimSpace(" \tThis is a line. \n"))
    f("TrimLeft: %s", s.TrimLeft(" \tThis is a\t line. \n", "\n\t "))
    f("TrimRight: %s\n", s.TrimRight(" \tThis is a\t line. \n", "\n\t ")) 

strings.Index()函数检查第二个参数的字符串是否可以在第一个参数指定的字符串中找到,并返回第一次找到的位置索引。如果搜索失败,则返回-1

 f("Prefix: %v\n", s.HasPrefix("Mihalis", "Mi"))
    f("Prefix: %v\n", s.HasPrefix("Mihalis", "mi"))
    f("Suffix: %v\n", s.HasSuffix("Mihalis", "is"))
    f("Suffix: %v\n", s.HasSuffix("Mihalis", "IS")) 

strings.HasPrefix() 函数检查给定的字符串(第一个参数)是否以第二个参数中给出的字符串开头。在前面的代码中,strings.HasPrefix() 的第一次调用返回 true,而第二次返回 false。同样,strings.HasSuffix() 检查给定的字符串是否以第二个字符串结尾。这两个函数都考虑了输入字符串和第二个参数的大小写。

 t := s.Fields("This is a string!")
    f("Fields: %v\n", len(t))
    t = s.Fields("ThisIs a\tstring!")
    f("Fields: %v\n", len(t)) 

方便的 strings.Fields() 函数根据 unicode.IsSpace() 函数定义的一个或多个空白字符将给定的字符串分割开,并返回在输入字符串中找到的子字符串切片。如果输入字符串只包含空白字符,则返回空切片。

 f("%s\n", s.Split("abcd efg", ""))
    f("%s\n", s.Replace("abcd efg", "", "_", -1))
    f("%s\n", s.Replace("abcd efg", "", "_", 4))
    f("%s\n", s.Replace("abcd efg", "", "_", 2)) 

strings.Split() 函数允许你根据所需的分隔符字符串分割给定的字符串——strings.Split() 函数返回一个字符串切片。使用 "" 作为 strings.Split() 的第二个参数允许你 逐字符处理字符串

strings.Replace() 函数接受四个参数。第一个参数是你想要处理的字符串。第二个参数包含一个字符串,如果找到,将被 strings.Replace() 的第三个参数替换。最后一个参数是允许发生的最大替换次数。如果该参数为负值,则没有替换次数的限制。

 f("SplitAfter: %s\n", s.SplitAfter("123++432++", "++"))
    trimFunction := func(c rune) bool {
        return !unicode.IsLetter(c)
    }
    f("TrimFunc: %s\n", s.TrimFunc("123 abc ABC \t .", trimFunction)) 

strings.SplitAfter() 函数根据作为函数第二个参数提供的分隔符字符串将第一个参数字符串分割成子字符串。分隔符字符串包含在返回的切片中。

最后一行代码定义了一个名为 trimFunction 的 trim 函数,该函数用作 strings.TrimFunc() 的第二个参数,以便根据 trim 函数的返回值过滤给定的输入——在这种情况下,由于调用了 unicode.IsLetter(),trim 函数保留所有字母而忽略其他所有内容。

运行 useStrings.go 产生以下输出:

To Upper: HELLO THERE!
To Lower: hello there
THis WiLL Be A Title!
EqualFold: true
EqualFold: false
Prefix: true
Prefix: false
Suffix: true
Suffix: false
Index: 2
Index: -1
Count: 2
Count: 0
Repeat: ababababab
TrimSpace: This is a line.
TrimLeft: This is a      line. 
TrimRight:      This is a        line.
Compare: 1
Compare: 0
Compare: -1
Fields: 4
Fields: 3
[a b c d   e f g]
_a_b_c_d_ _e_f_g_
_a_b_c_d efg
_a_bcd efg
Join: Line 1+++Line 2+++Line 3
SplitAfter: [123++ 432++ ]
TrimFunc: abc ABC 

访问 pkg.go.dev/strings 上的字符串包文档页面,以获取可用函数的完整列表。你将在本书的其他地方看到 strings 包的功能。

关于字符串和文本的内容就到这里,下一节将介绍在 Go 中处理日期和时间。

时间和日期

通常,我们需要处理日期和时间信息,以存储条目在数据库中最后使用的时间或条目被插入数据库的时间。

在 Go 中处理时间和日期的王者是 time.Time 数据类型,它以纳秒精度表示时间的一个瞬间。每个 time.Time 值都与一个位置(时区)相关联。

如果你是一个 UNIX 用户,你可能已经了解 UNIX 纪元时间,想知道如何在 Go 中获取它。time.Now().Unix()函数返回流行的 UNIX 纪元时间,即自00:00:00 UTC, January 1, 1970以来经过的秒数。如果你想将 UNIX 时间转换为等效的time.Time值,可以使用time.Unix()函数。如果你不是一个 UNIX 用户,那么你可能之前没有听说过 UNIX 纪元时间,但现在你了解了它!

time.Since()函数计算从给定时间以来经过的时间,并返回一个time.Duration变量——Duration数据类型定义为type Duration int64。尽管实际上Duration是一个int64值,但你不能隐式地将持续时间与int64值进行比较或转换,因为 Go 不允许隐式数据类型转换。

关于 Go 语言和日期时间的最重要的话题是 Go 如何解析字符串以将其转换为日期和时间。这之所以重要,是因为通常此类输入是以字符串形式给出的,而不是有效的日期变量。用于解析的函数称为time.Parse(),其完整签名是Parse(layout, value string) (Time, error),其中layout是解析字符串,value是要解析的输入。返回的time.Time值是一个具有纳秒精度的时刻,包含日期和时间信息。

下一个表格显示了用于解析日期和时间的最常用的字符串。

解析值 含义(示例)
03 12 小时制值(12pm, 07am)
15 24 小时制值(23, 07)
04 分钟(55, 15)
05 秒(5, 23)
Mon 星期简称(Tue, Fri)
Monday 星期(Tuesday, Friday)
02 月份中的日期(15, 31)
2006 四位数字的年份(2020, 2004)
06 最后两位数字的年份(20, 04)
Jan 月份简称(Feb, Mar)
January 月份全称(July, August)
MST 时区(EST, UTC)

之前的表格显示,如果你想解析30 January 2023字符串并将其转换为 Go 日期变量,你应该将其与02 January 2006字符串匹配,因为该字符串表示输入的预期格式——在匹配类似30 January 2023的字符串时,不能使用其他任何内容。同样,如果你想解析15 August 2023 10:00字符串,你应该将其与02 January 2006 15:04字符串匹配,因为这指定了输入的预期格式。

time包的文档(pkg.go.dev/time)包含了关于解析日期和时间的更多详细信息——然而,这里提供的应该已经足够用于常规用途。

现在我们已经了解了如何处理日期和时间,是时候学习更多关于处理时区的内容了。

处理不同的时区

所提供的实用程序接受日期和时间,并将它们转换为不同的时区。当您想要预处理来自不同来源且使用不同时区的日志文件时,这特别有用,将这些不同的时区转换为公共时区。再次强调,您需要time.Parse()在执行转换之前将有效输入转换为time.Time值。这次输入字符串包含时区,并由"``02 January 2006 15:04 MST"字符串解析。

为了将解析的日期和时间转换为纽约时间,程序使用以下代码:

 loc, _ = time.LoadLocation("America/New_York")
    fmt.Printf("New York Time: %s\n", now.In(loc)) 

这种技术在convertTimes.go中多次使用。

如果命令行参数包含任何空格字符,您应该将其放在双引号中,以便 UNIX shell 将其视为单个命令行参数。

运行convertTimes.go生成以下输出:

$ go run convertTimes.go "14 December 2023 19:20 EET"
Current Location: 2023-12-14 19:20:00 +0200 EET
New York Time: 2023-12-14 12:20:00 -0500 EST
London Time: 2023-12-14 17:20:00 +0000 GMT
Tokyo Time: 2023-12-15 02:20:00 +0900 JST
$ go run convertTimes.go "14 December 2023 19:20 UTC"
Current Location: 2023-12-14 21:20:00 +0200 EET
New York Time: 2023-12-14 14:20:00 -0500 EST
London Time: 2023-12-14 19:20:00 +0000 GMT
Tokyo Time: 2023-12-15 04:20:00 +0900 JST
$ go run convertTimes.go "14 December 2023 25:00 EET"
parsing time "14 December 2023 25:00 EET": hour out of range 

在程序的最后一次执行中,代码必须将25解析为一天中的小时,这是错误的,并生成hour out of range错误信息。

与时间和日期解析相关的已知 Go 问题,您可以在github.com/golang/go/issues/63345了解更多信息。

下一个子节是关于常量值。

常量

Go 支持常量,它们的行为类似于变量,但无法更改它们的值。Go 中的常量是通过const关键字定义的。常量可以是全局的或局部的。然而,如果您正在定义太多具有局部作用域的常量值,您可能需要重新考虑您的做法。

您在使用程序中的常量时获得的主要好处是保证它们的值在程序执行期间不会改变。严格来说,常量变量的值是在编译时定义的,而不是在运行时——这意味着它包含在二进制可执行文件中。在幕后,Go 使用布尔型、字符串型或数值型作为存储常量值的类型,因为这使 Go 在处理常量时具有更大的灵活性。

常量的可能用途包括定义配置值,如最大连接数或使用的 TCP/IP 端口号,以及定义物理常量,如光速或地球上的重力。

下一个子节讨论了常量生成器 iota,这是一种创建常量序列的便捷方式。

常量生成器 iota

常量生成器 iota用于声明一系列相关的值,这些值使用递增的数字,而无需显式地指定每个值。

const关键字相关的概念,包括常量生成器 iota,在constants.go文件中有说明。

type Digit int
type Power2 int
const PI = 3.1415926
const (
    C1 = "C1C1C1"
    C2 = "C2C2C2"
    C3 = "C3C3C3"
) 

在这部分,我们声明了两个新类型DigitPower2,它们将在稍后使用,以及四个新常量PIC1C2C3

Go 类型是一种定义新命名类型的方式,它使用与现有类型相同的底层类型。这主要用于区分可能使用相同类型数据的不同类型。type 关键字也可以用于定义结构和接口,但在这里不是这种情况。

func main() {
    const s1 = 123
var v1 float32 = s1 * 12
    fmt.Println(v1)
    fmt.Println(PI)
    const (
        Zero Digit = iota
        One
        Two
        Three
        Four
    ) 

上述代码定义了一个名为 s1 的常量。在这里,你还可以看到基于 Digit 的常量生成器 iota 的定义,这相当于以下五个常量的声明:

const (
    Zero = 0
    One = 1
    Two = 2
    Three = 3
    Four = 4
) 

虽然我们是在 main() 内部定义常量,但常量通常可以在 main() 或任何其他函数或方法外部找到。

constants.go 的最后一部分如下:

 fmt.Println(One)
    fmt.Println(Two)
    const (
        p2_0 Power2 = 1 << iota
        _
        p2_2
        _
        p2_4
        _
        p2_6
    )
    fmt.Println("2⁰:", p2_0)
    fmt.Println("2²:", p2_2)
    fmt.Println("2⁴:", p2_4)
    fmt.Println("2⁶:", p2_6)
} 

这里还有一个与之前不同的常量生成器 iota。首先,你可以看到在常量生成器 iota 的 const 块中使用下划线字符,这允许你跳过不想要的值。其次,iota 的值总是递增的,并且可以在表达式中使用,这正是这种情况。

现在我们来看看 const 块内部真正发生了什么。对于 p2_0,iota 的值为 0p2_0 被定义为 1。对于 p2_2,iota 的值为 2p2_2 被定义为表达式 1 << 2 的结果,二进制表示为 0000010000000100 的十进制值是 4,这是结果和 p2_2 的值。类似地,p2_4 的值是 16p2_6 的值是 64

运行 constants.go 产生以下输出:

$ go run constants.go
1476
3.1415926
1
2
2⁰: 1
2²: 4
2⁴: 16
2⁶: 64 

类型化和非类型化常量

常量值可以有数据类型。这可能会很限制性,因为具有数据类型的常量值只能与相同数据类型的值和变量操作,但它可以让你避免错误,因为编译器可以捕获这种情况。

typedConstants.go 的代码片段中将展示类型化常量与非类型化常量之间的区别:

const (
    typedConstant   = int16(100)
    untypedConstant = 100
)
func main() {
    i := int(1)
    fmt.Println("unTyped:", i*untypedConstant)
    fmt.Println("Typed:", **i*typedConstant**)
} 

因此,untypedConstant 没有与之关联的数据类型,而 typedConstant 有。如果你尝试运行 typedConstants.go,编译器将无法编译它,并产生以下错误输出:

$ go run typedConstants.go
# command-line-arguments
./typedConstants.go:13:24: invalid operation: i * typedConstant (mismatched types int and int16) 

错误条件的原因可以在生成的输出中找到:mismatched types int and int16。简单来说,i 是一个 int 变量,而 typedConstant 是一个 int16 值,Go 无法执行它们的乘法,因为变量的数据类型不匹配。另一方面,i*untypedConstant 的乘法没有问题,因为 untypedConstant 没有数据类型。

拥有数据是好的,但当你拥有大量相似数据时会发生什么?你需要有很多变量来存储这些数据,还是有一种更好的方法来做这件事?Go 通过引入数组和切片来回答这些问题。

对相似数据的分组

有时候,你希望将相同数据类型的多个值放在单个变量下,并使用索引号访问它们。在 Go 中,最简单的方法是使用数组或切片。数组是最广泛使用的数结构,由于它们的简单性和访问速度,几乎可以在所有编程语言中找到。Go 提供了一个名为切片的数组替代品。以下的小节将帮助你了解数组和切片之间的区别,以便你知道何时使用哪种数据结构。简而言之,你几乎可以在 Go 的任何地方使用切片代替数组,但我们也在演示数组,因为它们仍然有用,并且因为切片是 Go 使用数组实现的!

数组

我们将开始讨论数组,首先检查它们的核心特性和限制:

  • 当定义一个数组变量时,你必须定义其大小。否则,你应该在数组声明中放置[...],让 Go 编译器为你找出长度。因此,你可以创建一个包含 4 个字符串元素的数组,既可以写成[4]string{"Zero", "One", "Two", "Three"},也可以写成[...]string{"Zero", "One", "Two", "Three"}。如果你在方括号中不放置任何内容,那么将创建一个切片。由于它包含四个元素,该数组的(有效)索引为0123

  • 创建数组后,你不能更改其大小。

  • 当你将一个数组传递给一个函数时,Go 会创建该数组的副本并将其传递给该函数——因此,你在函数内部对数组所做的任何更改,在函数退出时都会丢失。

因此,Go 中的数组并不十分强大,这是 Go 引入名为切片的额外数据结构的主要原因,它类似于数组,但本质上是动态的,如下一小节所述。然而,数组和切片中的数据访问方式是相同的。

切片

Go 中的切片比数组更强大,主要是因为它们是动态的,这意味着它们可以在创建后根据需要增长或缩小。此外,你在函数内部对切片所做的任何更改也会影响原始切片。请注意,这通常是情况,但并不总是如此——如稍后讨论的,所有切片都有一个用于存储数据的底层数组。只有不会导致底层数组分配的更改才会反映回调用函数。然而,处理切片的函数通常不会更改切片的大小。

但这是如何发生的呢?严格来说,Go 中的所有参数都是按值传递的——在 Go 中没有其他传递参数的方式。然而,你可以显式地传递一个变量的指针以按引用传递。切片值是一个包含指向实际存储元素的底层数组的指针、数组的长度以及其容量——切片的容量将在下一小节中解释。请注意,切片值不包括其元素,只包含指向底层数组的指针。因此,当你将切片传递给函数时,Go 会复制该头并传递给函数。这个切片头的副本包括指向底层数组的指针。这个切片头在 reflect 包(pkg.go.dev/reflect#SliceHeader)中定义为以下内容:

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
} 

传递切片头的一个副作用是,将切片传递给函数更快,因为 Go 不需要复制切片及其元素,只需复制切片头。

你可以使用 make() 或不指定大小或使用 [...] 创建切片,就像数组一样。如果你不想初始化切片,那么使用 make() 更好更快。然而,如果你想在创建时初始化它,那么 make() 就帮不上忙了。因此,你可以创建一个包含三个 float64 元素的切片,如 aSlice := []float64{1.2, 3.2, -4.5}。使用 make() 创建具有三个 float64 元素空间的切片与执行 make([]float64, 3) 一样简单。该切片的每个元素都有一个值为 0,这是 float64 数据类型的零值。

切片和数组都可以有多个维度——使用 make() 创建具有两个维度的切片与编写 make([][]int, 2) 一样简单。这返回一个具有两个维度的切片,其中第一个维度是 2(行),第二个维度(列)未指定,在添加数据时应显式指定。

如果你想同时定义和初始化一个具有两个维度的切片,你应该执行类似于 twoD := [][]int{{1, 2, 3}, {4, 5, 6}} 的操作。

你可以使用 len() 函数找到数组或切片的长度。你可以使用 append() 函数向满切片添加新元素。append() 会自动分配所需的内存空间。请注意,你应该将 append() 的返回值赋给所需的变量,因为这并不是原地更改。

下面的示例将阐明许多关于切片的知识点——请随意尝试。输入以下代码并将其保存为 goSlices.go

package main
import "fmt"
func main() {
    // Create an empty slice
    aSlice := []float64{}
    // Both length and capacity are 0 because aSlice is empty
    fmt.Println(aSlice, len(aSlice), cap(aSlice))
    // Add elements to a slice
    aSlice = append(aSlice, 1234.56)
    aSlice = append(aSlice, -34.0)
    fmt.Println(aSlice, "with length", len(aSlice)) 

append() 命令向 aSlice 添加了两个新元素。如前所述,append() 的结果不是原地更改,必须赋给所需的变量。

 // A slice with a length of 4
    t := make([]int, 4)
    t[0] = -1
    t[1] = -2
    t[2] = -3
    t[3] = -4
// Now you will need to use append
    t = append(t, -5)
    fmt.Println(t) 

一旦切片没有更多空间添加元素,你只能使用 append() 向其添加新元素。

 // A 2D slice
    twoD := [][]int{{1, 2, 3}, {4, 5, 6}}
    // Visiting all elements of a 2D slice
// with a double for loop
for _, i := range twoD {
            for _, k := range i {
                fmt.Print(k, " ")
            }
            fmt.Println()
    } 

上述代码展示了如何创建一个名为 twoD 的二维切片变量并初始化它。

 make2D := make([][]int, 2)
    fmt.Println(make2D)
    make2D[0] = []int{1, 2, 3, 4}
    make2D[1] = []int{-1, -2, -3, -4}
    fmt.Println(make2D)
} 

前一部分展示了如何使用 make() 创建一个二维切片。使 make2D 成为二维切片的是在 make() 中使用 [][]int

运行 goSlices.go 产生以下输出:

$ go run goSlices.go 
[] 0 0
[1234.56 -34] with length 2
[-1 -2 -3 -4 -5]
1 2 3 
4 5 6 
[[] []]
[[1 2 3 4] [-1 -2 -3 -4]] 

关于切片长度和容量

数组和切片都支持 len() 函数来找出它们的长度。然而,切片还有一个额外的属性称为 容量,可以使用 cap() 函数找到。当你想要选择切片的一部分或想要使用切片引用数组时,切片的容量很重要。

容量显示了切片在不需要分配更多内存和更改底层数组的情况下可以扩展多少。尽管在切片创建后,切片的容量由 Go 处理,但开发人员可以在创建时使用 make() 函数定义切片的容量——之后,每次切片长度即将超过当前容量时,切片的容量都会加倍。make() 的第一个参数是切片的类型和其维度,第二个是它的初始长度,第三个是可选的,是切片的容量。尽管切片的数据类型在创建后不能改变,但其他两个属性可以改变。

写出 make([]int, 3, 2) 会生成错误信息,因为在任何给定时间,切片的容量(2)不能小于其长度(3)。

下面的图示说明了切片中长度和容量是如何工作的。

计算机屏幕截图,描述由中等置信度自动生成

图 2.1:切片长度和容量之间的关系

对于那些喜欢代码的人来说,这里有一个小的 Go 程序,展示了切片的长度和容量属性。将其输入并保存为 capLen.go

package main
import "fmt"
func main() {
    // Only length is defined. Capacity = length
    a := make([]int, 4) 

在这种情况下,切片 a 的容量与其长度相同,都是 4。

 fmt.Println("L:", len(a), "C:", cap(a))
    // Initialize slice. Capacity = length
    b := []int{0, 1, 2, 3, 4}
    fmt.Println("L:", len(b), "C:", cap(b)) 

再次强调,切片 b 的容量与其长度相同,都是 5,因为这默认行为。

 // Same length and capacity
    aSlice := make([]int, 4, 4)
    fmt.Println(aSlice) 

这次切片 aSlice 的容量与长度相同,不是因为 Go 决定这样做,而是因为我们已经在 make() 调用中指定了它。

 // Add an element
    aSlice = append(aSlice, 5) 

当你向切片 aSlice 添加新元素时,其容量加倍并变为 8

 fmt.Println(aSlice)
    // The capacity is doubled
    fmt.Println("L:", len(aSlice), "C:", cap(aSlice))
    // Now add four elements
    aSlice = append(aSlice, []int{-1, -2, -3, -4}...) 

... 操作符将 []int{-1, -2, -3, -4} 扩展为多个参数,并且 append() 将每个参数逐个追加到 aSlice 中。

 fmt.Println(aSlice)
    // The capacity is doubled
    fmt.Println("L:", len(aSlice), "C:", cap(aSlice))
} 

运行 capLen.go 产生以下输出:

$ go run capLen.go 
L: 4 C: 4
L: 5 C: 5
[0 0 0 0]
[0 0 0 0 5]
L: 5 C: 8
[0 0 0 0 5 -1 -2 -3 -4]
L: 9 C: 16 

如果预先知道切片的正确容量,这将使你的程序运行更快,因为 Go 不必分配新的底层数组并将所有数据复制过来。这在处理非常大的切片时非常重要。

使用切片是好的,但当你想要处理现有切片的连续部分时会发生什么?有没有一种实用的方法来选择切片的一部分?幸运的是,答案是肯定的——下一小节将简要介绍如何选择切片的连续部分。

选择切片的一部分

Go 允许你选择切片的一部分,前提是所有需要的元素都相邻。当你选择一系列元素且不想逐个给出它们的索引时,这会很有用。在 Go 中,你通过指定(直接或间接)两个索引来选择切片的一部分,第一个索引是选择的开始,第二个索引是选择的结束,不包括该索引处的元素,两者之间用 : 分隔。

如果你想要处理一个实用工具的所有命令行参数(除了第一个,即它的文件路径),你可以将其分配给一个新的变量(arguments := os.Args)以方便使用,并使用 arguments[1:] 表示法跳过第一个命令行参数。

然而,有一种变体,你可以添加第三个参数来控制结果切片的容量。所以,使用 aSlice[0:2:4] 选择切片的前 2 个元素(在索引 01),并创建一个最大容量为 4 的新切片。结果的容量定义为 4-0 的减法,其中 4 是最大容量,0 是第一个索引——如果省略第一个索引,它将自动设置为 0。在这种情况下,结果切片的容量将是 4,因为 4-0 等于 4

如果我们使用了 aSlice[2:4:4],我们将创建一个新的切片,包含 aSlice[2]aSlice[3] 元素,并且容量为 4-2。最后,结果的容量不能大于原始切片的容量,因为在这种情况下,你需要一个不同的底层数组。

将以下代码输入你喜欢的编辑器,并保存为 partSlice.go

package main
import "fmt"
func main() {
    aSlice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(aSlice)
    l := len(aSlice)
    // First 5 elements
    fmt.Println(aSlice[0:5])
    // First 5 elements
    fmt.Println(aSlice[:5]) 

在这部分,我们定义了一个名为 aSlice 的新切片,它有 10 个元素。它的容量与其长度相同。0:5:5 两种表示法都选择切片的前 5 个元素,即索引 01234 的元素。

 // Last 2 elements
    fmt.Println(aSlice[l-2 : l])
    // Last 2 elements
    fmt.Println(aSlice[l-2:]) 

给定切片的长度(l),我们可以通过 l-2:ll-2: 来选择切片的最后两个元素。

 // First 5 elements
    t := aSlice[0:5:10]
    fmt.Println(len(t), cap(t))
    // Elements at indexes 2,3,4
// Capacity will be 10-2
    t = aSlice[2:5:10]
    fmt.Println(len(t), cap(t)) 

初始时,t 的容量将是 10-0,即 10。在第二种情况下,t 的容量将是 10-2

 // Elements at indexes 0,1,2,3,4
// New capacity will be 6-0
    t = aSlice[:5:6]
    fmt.Println(len(t), cap(t))
} 

t 的容量现在是 6-0,其长度将是 5,因为我们选择了切片 aSlice 的前五个元素。

partSlice.go 的输出以小部分呈现:

$ go run partSlice.go
[0 1 2 3 4 5 6 7 8 9] 

上一行是 fmt.Println(aSlice) 的输出。

[0 1 2 3 4]
[0 1 2 3 4] 

上两行是由 fmt.Println(aSlice[0:5])fmt.Println(aSlice[:5]) 生成的。

[8 9]
[8 9] 

类似地,前两行是由 fmt.Println(aSlice[l-2 : l])fmt.Println(aSlice[l-2:]) 生成的。

5 10
3 8
5 6 

最后三行打印了aSlice[0:5:10]aSlice[2:5:10]aSlice[:5:6]的长度和容量。

字节切片

字节切片是byte数据类型([]byte)的切片。Go 知道大多数字节切片用于存储字符串,因此使得在byte类型和string类型之间切换变得容易。与其他类型的切片相比,访问字节切片的方式没有特别之处。特别的是,Go 使用字节切片来执行文件 I/O 操作,因为它们允许你精确地确定要读取或写入文件的数据量。这是因为字节在计算机系统之间是一个通用的单位。

由于 Go 没有用于存储单个字符的数据类型,它使用byterune来存储字符值。单个字节只能存储一个 ASCII 字符,而rune可以存储 Unicode 字符。因此,rune可以占用多个字节。

下面的小程序演示了如何将字节切片转换为字符串以及相反的操作,这对于大多数文件 I/O 操作是必需的——将其键入并保存为byteSlices.go

package main
import "fmt"
func main() {
    // Byte slice
    b := make([]byte, 12)
    fmt.Println("Byte slice:", b) 

空字节切片包含零——在这种情况下,12 个零。

 b = []byte("Byte slice €")
    fmt.Println("Byte slice:", b) 

在这种情况下,b的大小是字符串"Byte slice €"的大小,不包括双引号——b现在指向与之前不同的内存位置,即"Byte slice €"存储的位置。这就是将字符串转换为字节切片的方法

由于像这样的 Unicode 字符需要多个字节来表示,字节切片的长度可能与存储的字符串长度不同。

 // Print byte slice contents as text
    fmt.Printf("Byte slice as text: %s\n", b)
    fmt.Println("Byte slice as text:", string(b)) 

上一段代码展示了如何使用两种技术将字节切片的内容作为文本打印出来。第一种是通过使用%s控制字符串,第二种是使用string()

 // Length of b
    fmt.Println("Length of b:", len(b))
} 

上一段代码打印了字节切片的实际长度。

运行byteSlices.go产生以下输出:

$ go run byteSlices.go 
Byte slice: [0 0 0 0 0 0 0 0 0 0 0 0]
Byte slice: [66 121 116 101 32 115 108 105 99 101 32 226 130 172]
Byte slice as text: Byte slice €
Byte slice as text: Byte slice €
Length of b: 14 

输出的最后一行证明了尽管b字节切片包含 12 个字符,但它的大小却是 14。

从切片中删除元素

在不使用如slices之类的包的情况下,从切片中删除元素没有默认函数,这意味着如果你需要从切片中删除元素,你必须编写自己的代码。然而,从 Go 1.21 版本开始,你可以使用slices.Delete()来达到这个目的。因此,本小节在使用较旧的 Go 版本或当你想手动删除元素时是相关的。

从切片中删除元素可能很棘手,因此本小节介绍了两种删除元素的技术。第一种技术将原始切片在需要删除的元素的索引处虚拟地分成两个切片。这两个切片都不包含将要删除的元素。然后,它将这些切片连接起来,创建一个新的切片。

第二个技术将最后一个元素复制到要删除的元素的位置,并通过从原始切片中排除最后一个元素来创建一个新的切片。然而,这个特定的技术改变了切片元素的顺序,这在某些情况下可能很重要。

下一个图显示了从切片中删除元素的两个技术图形表示。

包含文本、截图、字体、设计的图片 描述自动生成

图 2.2:从切片中删除元素

以下程序展示了可以从切片中删除元素的两个技术。通过输入以下代码创建一个文本文件——将其保存为deleteSlice.go

package main
import (
    "fmt"
"os"
"strconv"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Need an integer value.")
        return
    }
    index := arguments[1]
    i, err := strconv.Atoi(index)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Using index", i)
    aSlice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8}
    fmt.Println("Original slice:", aSlice)
    // Delete element at index i
if i > len(aSlice)-1 {
        fmt.Println("Cannot delete element", i)
        return
    }
    // The ... operator auto expands aSlice[i+1:] so that
// its elements can be appended to aSlice[:i] one by one
    aSlice = append(aSlice[:i], aSlice[i+1:]...)
    fmt.Println("After 1st deletion:", aSlice) 

在这里,我们将原始切片逻辑上划分为两个切片。这两个切片在需要删除的元素的索引处被分割。之后,我们通过...的帮助将这两个切片连接起来。

Go 支持使用...操作符,它在将切片或数组展开为多个参数以附加到现有切片之前使用。

接下来,我们看看第二个技术的实际应用:

 // Delete element at index i
if i > len(aSlice)-1 {
        fmt.Println("Cannot delete element", i)
        return
    }
    // Replace element at index i with last element
    aSlice[i] = aSlice[len(aSlice)-1]
    // Remove last element
    aSlice = aSlice[:len(aSlice)-1]
    fmt.Println("After 2nd deletion:", aSlice)
} 

我们使用aSlice[i] = aSlice[len(aSlice)-1]语句将我们要删除的元素替换为最后一个元素,然后使用aSlice = aSlice[:len(aSlice)-1]语句删除最后一个元素。

运行deleteSlice.go会产生以下类型的输出,具体取决于你的输入:

$ go run deleteSlice.go 1
Using index 1
Original slice: [0 1 2 3 4 5 6 7 8]
After 1st deletion: [0 2 3 4 5 6 7 8]
After 2nd deletion: [0 8 3 4 5 6 7] 

由于切片有九个元素,你可以删除索引值为1的元素。

$ go run deleteSlice.go 10
Using index 10
Original slice: [0 1 2 3 4 5 6 7 8]
Cannot delete element 10 

由于切片只有九个元素,你不能从切片中删除索引值为10的元素。

切片如何连接到数组

如前所述,在幕后,切片是通过底层数组实现的。底层数组的长度与切片的容量相同,并且存在指针将切片元素连接到相应的数组元素。

你可以理解,通过将现有数组与切片连接,Go 允许你使用切片引用数组或数组的一部分。这具有一些奇特的功能,包括切片的变化会影响引用的数组!然而,当切片的容量发生变化时,与数组的连接就会终止!这是因为当切片的容量发生变化时,底层数组也会发生变化,切片与原始数组之间的连接就不再存在了。

输入以下代码并将其保存为sliceArrays.go

package main
import (
    "fmt"
)
func change(s []string) {
    s[0] = "Change_function"
} 

这是一个改变其输入切片的第一个元素的函数。

func main() {
    a := [4]string{"Zero", "One", "Two", "Three"}
    fmt.Println("a:", a) 

在这里,我们定义了一个名为a的包含 4 个元素的数组。

 var S0 = a[0:1]
    fmt.Println(S0)
    S0[0] = "S0" 

在这里,我们将S0与数组a的第一个元素连接起来,并打印它。然后我们改变S0[0]的值。

 var S12 = a[1:3]
    fmt.Println(S12)
    S12[0] = "S12_0"
    S12[1] = "S12_1" 

在这部分,我们将 S12a[1]a[2] 关联。因此 S12[0] = a[1]S12[1] = a[2]。然后,我们改变 S12[0]S12[1] 的值。这两个变化也将改变 a 的内容。简单地说,a[1]S12[0] 的新值,而 a[2]S12[1] 的新值。

 fmt.Println("a:", a) 

我们打印变量 a,它以直接的方式完全没有改变。然而,由于 aS0S12 的连接,a 的内容已经改变!

 // Changes to slice -> changes to array
    change(S12)
    fmt.Println("a:", a) 

由于切片和数组是连接的,您对切片所做的任何更改也会影响数组,即使这些更改发生在函数内部。

 // capacity of S0
    fmt.Println("Capacity of S0:", cap(S0), "Length of S0:", len(S0))
    // Adding 4 elements to S0
    S0 = append(S0, "N1")
    S0 = append(S0, "N2")
    S0 = append(S0, "N3")
    a[0] = "-N1" 

随着 S0 容量的变化,它不再连接到相同的底层数组(a)。

 // Changing the capacity of S0
// Not the same underlying array anymore!
    S0 = append(S0, "N4")
    fmt.Println("Capacity of S0:", cap(S0), "Length of S0:", len(S0))
    // This change does not go to S0
    a[0] = "-N1-"
// This change goes to S12
    a[1] = "-N2-" 

然而,数组 a 和切片 S12 仍然连接在一起,因为 S12 的容量没有改变。

 fmt.Println("S0:", S0)
    fmt.Println("a: ", a)
    fmt.Println("S12:", S12)
} 

最后,我们打印 aS0S12 的最终版本。

运行 sliceArrays.go 产生以下输出:

$ go run sliceArrays.go 
a: [Zero One Two Three]
[Zero]
[One Two]
a: [S0 S12_0 S12_1 Three]
a: [S0 Change_function S12_1 Three]
Capacity of S0: 4 Length of S0: 1
Capacity of S0: 8 Length of S0: 5
S0: [-N1 N1 N2 N3 N4]
a:  [-N1- -N2- N2 N3]
S12: [-N2- N2] 

下一个子节将展示一种在切片上尽早捕获越界错误的技术。

捕获越界错误

在本节中,我们介绍了一种捕获越界错误的技术。该技术通过两个函数来展示。第一个函数如下:

func foo(s []int) int {
    return s[0] + s[1] + s[2] + s[3]
} 

foo() 的情况下,对切片 s 不执行边界检查。这意味着我们可以使用任何索引,而不确定我们最初是否会得到那个索引,也不需要编译器执行任何检查。

第二个功能如下:

func bar(slice []int) int {
    a := (*[**3**]int)(slice)
    return a[0] + a[1] + a[2] + a[3]
} 

请记住,编译器不会检查传递给函数的切片。然而,编译器将拒绝编译前面的代码。生成的错误将是 无效参数:索引 3 超出范围 [0:3]。错误被捕获的原因是,尽管我们从 slice 中获取了三个元素并将它们放入 a 中,但我们使用了数组 a 的四个元素,这显然是不允许的。

让我们现在讨论 copy() 函数的使用,在下一个子节中。

copy() 函数

Go 提供了 copy() 函数,用于将现有数组复制到切片或现有切片复制到另一个切片。然而,copy() 的使用可能很棘手,因为如果源切片比目标切片大,则目标切片不会自动扩展。此外,如果目标切片比源切片大,则 copy() 不会清空目标切片中未复制的元素。这将在下面的图中更好地说明。

包含文本、截图、字体、数字的图片,自动生成描述

图 2.3:copy() 函数的使用

以下程序说明了 copy() 函数的使用——将其输入您喜欢的文本编辑器并保存为 copySlice.go

package main
import "fmt"
func main() {
    a1 := []int{1}
    a2 := []int{-1, -2}
    a5 := []int{10, 11, 12, 13, 14}
    fmt.Println("a1", a1)
    fmt.Println("a2", a2)
    fmt.Println("a5", a5)
    // copy(destination, input)
// len(a2) > len(a1)
copy(a1, a2)
    fmt.Println("a1", a1)
    fmt.Println("a2", a2) 

在这里,我们运行copy(a1, a2)命令。在这种情况下,a2切片比a1大。在copy(a1, a2)之后,a2保持不变,这是完全合理的,因为a2是输入切片,而a2的第一个元素被复制到a1的第一个元素,因为a1只有一个元素的空间。

 // len(a5) > len(a1)
copy(a1, a5)
    fmt.Println("a1", a1)
    fmt.Println("a5", a5) 

在这种情况下,a5a1大。再次,在copy(a1, a5)之后,a5保持不变,而a5[0]被复制到a1[0]

 // len(a2) < len(a5) -> OK
copy(a5, a2)
    fmt.Println("a2", a2)
    fmt.Println("a5", a5)
} 

在最后一种情况下,a2的长度短于a5。这意味着由于有足够的空间,整个a2被复制到a5中。由于a2的长度是2,因此只有a5的前两个元素发生变化。

运行copySlice.go产生以下输出:

$ go run copySlice.go 
a1 [1]
a2 [-1 -2]
a5 [10 11 12 13 14]
a1 [-1]
a2 [-1 -2] 

copy(a1, a2)语句不会改变a2切片,只会改变a1。由于a1的大小是1,因此只会从a2复制第一个元素。

a1 [10]
a5 [10 11 12 13 14] 

类似地,copy(a1, a5)只改变了a1。由于a1的大小是1,因此只有a5的第一个元素被复制到a1

a2 [-1 -2]
a5 [-1 -2 12 13 14] 

最后,copy(a5, a2)改变了a5。由于a5的大小是5,因此只有a5的前两个元素被改变,并成为a2的前两个元素,a2的大小为2

排序切片

有时候您想以排序的方式呈现您的信息,并希望 Go 为您完成这项工作。在本节中,我们将看到如何使用sort包提供的功能对各种标准数据类型的切片进行排序。

sort包可以在不编写任何额外代码的情况下对内置数据类型的切片进行排序。此外,Go 提供了sort.Reverse()函数以逆序排序。然而,真正有趣的是sort允许您通过实现sort.Interface接口来编写自己的排序函数以用于自定义数据类型——您将在第五章反射和接口中了解更多关于sort.Interface和接口的一般知识。

在 Go 泛型中,slices包被引入到标准 Go 库中——slices包在第四章Go 泛型中进行了讨论。

因此,您可以通过输入sort.Ints(sInts)来对保存为sInts的整数切片进行排序。当使用sort.Reverse()以逆序对整数切片进行排序时,您需要使用sort.IntSlice(sInts)将所需的切片传递给sort.Reverse(),因为IntSlice类型内部实现了sort.Interface,这允许您以不同于通常的方式排序。同样适用于其他标准 Go 数据类型。

创建一个包含以下代码的文本文件,以说明排序的使用,并将其命名为sortSlice.go

package main
import (
    "fmt"
"sort"
)
func main() {
    sInts := []int{1, 0, 2, -3, 4, -20}
    sFloats := []float64{1.0, 0.2, 0.22, -3, 4.1, -0.1}
    sStrings := []string{"aa", "a", "A", "Aa", "aab", "AAa"}
    fmt.Println("sInts original:", sInts)
    sort.Ints(sInts)
    fmt.Println("sInts:", sInts)
    sort.Sort(sort.Reverse(sort.IntSlice(sInts)))
    fmt.Println("Reverse:", sInts) 

由于sort.Interface知道如何对整数值进行排序,因此以逆序排序它们是微不足道的。

 fmt.Println("sFloats original:", sFloats)
    sort.Float64s(sFloats)
    fmt.Println("sFloats:", sFloats)
    sort.Sort(sort.Reverse(sort.Float64Slice(sFloats)))
    fmt.Println("Reverse:", sFloats)
    fmt.Println("sStrings original:", sStrings)
    sort.Strings(sStrings)
    fmt.Println("sStrings:", sStrings)
    sort.Sort(sort.Reverse(sort.StringSlice(sStrings)))
    fmt.Println("Reverse:", sStrings)
} 

当排序浮点数和字符串时,同样适用这些规则。

运行sortSlice.go产生以下输出:

$ go run sortSlice.go
sInts original: [1 0 2 -3 4 -20]
sInts: [-20 -3 0 1 2 4]
Reverse: [4 2 1 0 -3 -20]
sFloats original: [1 0.2 0.22 -3 4.1 -0.1]
sFloats: [-3 -0.1 0.2 0.22 1 4.1]
Reverse: [4.1 1 0.22 0.2 -0.1 -3]
sStrings original: [aa a A Aa aab AAa]
sStrings: [A AAa Aa a aa aab]
Reverse: [aab aa a Aa AAa A] 

输出说明了原始切片在正常和逆序下的排序情况。

下一个部分将讨论 Go 中的指针。尽管 Go 不支持指针的方式与 C 相同,但 Go 允许你与指针和指针变量一起工作。

指针

Go 支持指针但不支持指针算术,这是 C 等编程语言中许多错误和 bug 的原因。指针是变量的内存地址。你需要取消引用指针以获取其值——取消引用是通过在指针变量前使用*字符来执行的。此外,你可以使用&在变量前获取普通变量的内存地址。

下一个图显示了int指针和int变量的区别。

包含文本、截图、矩形、字体的图片,描述自动生成

图 2.4:一个int变量和一个int指针

如果指针变量指向一个现有的普通变量,那么你使用指针变量对该存储值所做的任何更改都将修改普通变量。

内存地址的格式和值可能在不同的机器、不同的操作系统和不同的架构之间有所不同。

你可能会问,当没有指针算术支持时,使用指针有什么意义?从指针中获得的主要好处是,将变量作为指针(我们可以称之为引用)传递给函数时(当函数返回时),不会丢弃你在该函数内部对该变量值所做的任何更改。有时你希望有这种功能,因为它简化了你的代码,但为此简单性付出的代价是必须格外小心地处理指针变量。

记住,切片在传递给函数时不需要使用指针——是 Go 将切片的底层数组指针传递过去,而且无法改变这种行为。

除了简单性的原因之外,还有三个更多使用指针的原因:

  • 指针允许你在函数之间共享和操作数据,而无需显式地将值返回给调用者。然而,当在函数和 goroutine 之间共享数据时,你应该格外小心处理竞态条件问题。这允许多个函数同时尝试更改相同指针变量的值,这会导致该指针变量的最终状态出现不可预测的行为。

  • 当你想要区分变量的零值和未设置的值(nil)时,指针也非常有用。这对于结构体尤其有用,因为指针(以及因此结构体的指针,这在下一章中会全面介绍)可以有nil值,这意味着你可以比较一个结构体指针与nil值,这对于普通结构体变量是不允许的。

  • 支持指针,特别是结构体指针,使得 Go 能够支持诸如链表和二叉树这样的数据结构,这些在计算机科学中得到了广泛应用。因此,你可以将 Node 结构体的结构字段定义为 Next *Node,这是另一个 Node 结构体的指针。没有指针,这将很难实现,可能也太慢了。

以下代码演示了如何在 Go 中使用指针——创建一个名为 pointers.go 的文本文件并输入以下代码:

package main
import "fmt"
type aStructure struct {
    field1 complex128
    field2 int
} 

这是一个有两个字段名为 field1field2 的结构体。

func processPointer(x *float64) {
    *x = *x * *x
} 

这是一个接收 float64 变量指针作为输入的函数。由于我们使用指针,函数内部对函数参数的所有更改都将持续存在。此外,不需要返回任何内容。

func returnPointer(x float64) *float64 {
    temp := 2 * x
    return &temp
} 

这是一个需要 float64 参数作为输入并返回 float64 指针的函数。要返回常规变量的内存地址,你需要使用 & (&temp)。在这种情况下,Go 足够智能,能够意识到 temp 的指针会逃逸,因此其值将在堆上分配,确保调用者有一个有效的引用来工作,而不是在栈分配中,当函数返回且栈帧被消除时,引用将无效。

func bothPointers(x *float64) *float64 {
    temp := 2 * *x
    return &temp
} 

这是一个需要 float64 指针作为输入并返回 float64 指针作为输出的函数。*x 符号用于获取存储在 x 中内存地址的值,这被称为解引用。

func main() {
    var f float64 = 12.123
    fmt.Println("Memory address of f:", &f) 

要获取名为 f 的常规变量的内存地址,你应该使用 &f 符号。

 // Pointer to f
    fP := &f
    fmt.Println("Memory address of f:", fP)
    fmt.Println("Value of f:", *fP)
    // The value of f changes
    processPointer(fP)
    fmt.Printf("Value of f: %.2f\n", f) 

fP 现在是指向 f 变量内存地址的指针。对存储在 fP 内存地址中的值的任何更改都会影响 f 的值。然而,这只有在 fP 指向 f 变量的内存地址时才成立。

 // The value of f does not change
    x := returnPointer(f)
    fmt.Printf("Value of x: %.2f\n", *x) 

f 的值没有改变,因为函数只使用了它的值。

 // The value of f does not change
    xx := bothPointers(fP)
    fmt.Printf("Value of xx: %.2f\n", *xx) 

在这种情况下,f 的值以及存储在 fP 内存地址中的值都没有改变,因为 bothPointers() 函数没有对存储在 fP 内存地址中的值进行任何更改。

 // Check for empty structure
var k *aStructure 

k 变量是指向 aStructure 结构体的指针。由于 k 指向无地方,Go 使其指向 nil,这是指针的零值。

 // This is nil because currently k points to nowhere
    fmt.Println(k)
    // Therefore you are allowed to do this:
if k == nil {
        k = new(aStructure)
    } 

由于 knil,我们可以将其赋值给一个空的 aStructure 值,使用 new(aStructure) 而不会丢失任何数据——new() 分配所需的内存并将指针设置为该内存。现在,k 已不再是 nil,但 aStructure 的两个字段都拥有它们数据类型的零值。

 fmt.Printf("%+v\n", k)
    if k != nil {
        fmt.Println("k is not nil!")
    }
} 

上述代码只是确保 k 不是 nil。你可能认为这个检查是多余的,但双重检查并不会有害,因为如果你尝试解引用一个 nil 指针,你的程序将会崩溃。

运行 pointers.go 生成以下类型的输出:

Memory address of f: 0x140000180d8
Memory address of f: 0x140000180d8
Value of f: 12.123
Value of f: 146.97
Value of x: 293.93
Value of xx: 293.93
<nil>
&{field1:(0+0i) field2:0}
k is not nil! 

我们将在下一章讨论结构体时重新审视指针。

将切片转换为数组或数组指针

在本小节中,我们将学习如何将切片转换为数组或数组指针。slice2array.go 的第一部分如下:

func main() {
    // Go 1.17 feature
    slice := make([]byte, 3)
    // Slice to array pointer
    arrayPtr := (*[3]byte)(slice)
    fmt.Println("Print array pointer:", arrayPtr)
    fmt.Printf("Data type: %T\n", arrayPtr)
    fmt.Println("arrayPtr[0]:", arrayPtr[0]) 

在前面的代码中,我们将 slice 转换为一个指向具有 3 个元素的数组的数组指针。

slice2array.go 的其余代码如下:

 // Go 1.20 feature
    slice2 := []int{-1, -2, -3}
    // Slice to array
    array := **[****3****]****int****(slice2)**
    fmt.Println("Print array contents:", array)
    fmt.Printf("Data type: %T\n", array)
} 

在前面的代码中,我们将切片转换为一个具有 3 个元素的数组。

运行 slice2array.go 产生以下输出:

$ go run slice2array.go
Print array pointer: &[0 0 0]
Data type: *[3]uint8
arrayPtr[0]: 0
Print array contents: [-1 -2 -3]
Data type: [3]int 

输出的前三行与切片到数组指针的转换有关,而最后两行与切片到数组的转换有关。输出第二行验证我们正在处理一个指向具有三个元素的数组的指针(*[3]uint8),而最后一行验证我们正在处理一个具有三个元素的数组([3]int)。

接下来,我们将讨论数据类型和 unsafe 包。

数据类型和 unsafe

Go 中的 unsafe 包提供了执行破坏 Go 类型安全保证的操作的功能。它是一个强大但可能危险的包,在大多数 Go 代码中不建议使用。因此,unsafe 包旨在用于特定情况,在这些情况下需要低级编程,例如与非 Go 代码接口、处理内存布局或实现某些高级功能。

在本节中,我们将讨论与字符串和切片相关的 unsafe 包的四个函数。你可能不需要经常使用它们,但了解它们是有好处的,因为它们在处理大型字符串或占用大量内存的切片时提供了速度,因为它们直接处理内存地址,如果你不知道自己在做什么,这可能会非常危险。我们将讨论的四个函数是 unsafe.StringData()unsafe.String()unsafe.Slice()unsafe.SliceData()。你可以使用 go doc 了解它们使用的更多详细信息。

请记住,unsafe 包之所以被称为 unsafe,是有原因的,而且在大多数情况下你不应该使用这个包!

typeUnsafe.go 的第一部分包含两个函数:

func byteToString(bStr []byte) string {
    if len(bStr) == 0 {
        return ""
    }
    return **unsafe.String(unsafe.SliceData(bStr),** **len****(bStr))**
}
func stringToByte(str string) []byte {
    if str == "" {
        return nil
    }
    return **unsafe.Slice(unsafe.StringData(str),** **len****(str))**
} 

这两个特定的函数分别使用 unsafe.String()unsafe.Slice() 将字节切片转换为字符串,反之亦然。

unsafe.String() 函数需要一个指针参数和一个长度值,以便知道它将从指针开始走多远以获取数据。unsafe.SliceData() 函数返回函数参数切片的底层数组的指针。

unsafe.Slice() 以类似的方式操作,并返回一个底层数组从给定指针值开始,其长度和容量等于作为其第二个参数传递的整数值的切片——重要的是要理解,当通过指针和unsafe包处理内存地址时,我们需要指定需要走多远。

由于 Go 字符串是不可变的,unsafe.StringData() 返回的字节不应该被修改。

typeUnsafe.go 的第二部分如下:

func main() {
    str := "Go!"
    d := **unsafe.StringData(str)**
    b := **unsafe.Slice(d,** **len****(str))**
// byte is an alias for uint8
    fmt.Printf("Type %T contains %s\n", b, b)
    sData := []int{10, 20, 30, 40}
    // Get the memory address of sData
    fmt.Println("Pointer:", unsafe.SliceData(sData)) 

typeUnsafe.go 的最后一部分如下:

 // String to Byte slice
var hi string = "Mastering Go, 4th edition!"
    myByteSlice := stringToByte(hi)
    fmt.Printf("myByteSlice type: %T\n", myByteSlice)
    // Byte slice to string
    myStr := byteToString(myByteSlice)
    fmt.Printf("myStr type: %T\n", myStr)
} 

typeUnsafe.go 的输出如下:

$ go run typeUnsafe.go
Type []uint8 contains Go!
Pointer: 0x1400001e0c0
myByteSlice type: []uint8
myStr type: string 

记住,使用 unsafe 包最常见的目的是在处理大量数据时提高速度,因为它允许你在不进行类型安全检查的情况下执行指针算术和不同指针类型之间的转换。在处理大量数据时,指针算术可以加快速度。

接下来,我们讨论生成随机数和随机字符串。

生成随机数

随机数生成既是计算机科学中的一个研究领域,也是一种艺术。这是因为计算机是纯粹的逻辑机器,结果发现使用它们来生成随机数极其困难!

Go 可以使用math/rand包的功能来帮助你。每个随机数生成器需要一个种子来开始生成数字。种子用于初始化整个过程,并且非常重要,因为如果你总是从相同的种子开始,你将总是得到相同的伪随机数序列。这意味着每个人都可以重新生成那个序列,而那个特定的序列最终并不是随机的。

然而,这个特性对于测试目的非常有用。在 Go 中,rand.Seed() 函数用于初始化随机数生成器。

如果你真的对随机数生成感兴趣,你应该从阅读唐纳德·E·克努特(Donald E. Knuth)的《计算机编程艺术》(The Art of Computer Programming)的第二卷开始,这本书由 Addison-Wesley Professional 于 2011 年出版。

以下函数是位于书籍 GitHub 仓库ch02中的randomNumbers.go的一部分,它用于生成 [min, max) 范围内的随机数。

func random(min, max int) int {
    return rand.Intn(max-min) + min
} 

random() 函数完成所有工作,通过调用 rand.Intn()minmax-1 范围内生成伪随机数。rand.Intn() 生成从 0 到其单个参数值减一的非负随机整数。

randomNumbers.go 工具接受四个命令行参数,但也可以通过使用默认值使用更少的参数。默认情况下,randomNumbers.go 会生成 100 个从 0 到包括 99 的随机整数。

$ go run randomNumbers.go 
Using default values!
39 75 78 89 39 28 37 96 93 42 60 69 50 9 69 27 22 63 4 68 56 23 54 14 93 61 19 13 83 72 87 29 4 45 75 53 41 76 84 51 62 68 37 11 83 20 63 58 12 50 8 31 14 87 13 97 17 60 51 56 21 68 32 41 79 13 79 59 95 56 24 83 53 62 97 88 67 59 49 65 79 10 51 73 48 58 48 27 30 88 19 16 16 11 35 45 72 51 41 28 

在以下输出中,我们手动定义了每个参数(最小值、最大值、随机值的数量和种子值):

$ go run randomNumbers.go 1 5 10 10
3 1 4 4 1 1 4 4 4 3
$ go run randomNumbers.go 1 5 10 10
3 1 4 4 1 1 4 4 4 3
$ go run randomNumbers.go 1 5 10 11
1 4 2 1 3 2 2 4 1 3 

前两次的种子值是 10,所以我们得到了相同的输出。第三次种子值是 11,这生成了不同的输出。

生成随机字符串

假设你想生成用于难以猜测的密码或测试目的的随机字符串。基于随机数生成,我们创建了一个生成随机字符串的工具。该工具实现为genPass.go,可以在书籍 GitHub 仓库的ch02目录中找到。genPass.go的核心功能在下一个函数中:

func getString(len int64) string {
    temp := ""
    startChar := "!"
var i int64 = 1
for {
        myRand := random(MIN, MAX)
        newChar := string(startChar[0] + byte(myRand))
        temp = temp + newChar
        if i == len {
            break
        }
        i++
    }
    return temp
} 

由于我们只想获取可打印的 ASCII 字符,我们限制了可以生成的伪随机数的范围。ASCII 表中可打印字符的总数是 94 个。这意味着程序可以生成的伪随机数的范围应该是从 0 到 94,不包括 94。因此,MINMAX全局变量的值,这里没有显示,分别是 0 和 94。

startChar 变量保存了工具可以生成的第一个 ASCII 字符,在这种情况下,是感叹号,其十进制 ASCII 值为 33。鉴于程序可以生成高达 94 的伪随机数,可以生成的最大 ASCII 值是 93 + 33,等于 126,这是波浪号的 ASCII 值。所有生成的字符都保存在temp变量中,一旦for循环退出,就返回该变量。string(startChar[0] + byte(myRand))语句将随机整数转换为所需范围内的字符。

genPass.go 工具接受单个参数,即生成的密码长度。如果没有提供参数,genPass.go 将生成一个 8 个字符的密码,这是LENGTH变量的默认值。

运行 genPass.go 会产生以下类型的输出:

$ go run genPass.go
Using default values...
!QrNq@;R
$ go run genPass.go 20
sZL>{F~"hQqY>r_>TX?O 

第一次程序执行使用生成字符串长度的默认值,而第二次程序执行创建了一个包含 20 个字符的随机字符串。

生成安全的随机数

如果你打算使用这些伪随机数进行与安全相关的工作,那么使用 crypto/rand 包非常重要,该包实现了一个密码学安全的伪随机数生成器。使用 crypto/rand 包时,你不需要定义种子。

下面是 cryptoRand.go 源代码的一部分,展示了如何使用 crypto/rand 的功能生成安全的随机数。

func generateBytes(n int64) ([]byte, error) {
    b := make([]byte, n)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }
    return b, nil
} 

rand.Read() 函数随机生成填充整个 b 字节切片的数字。你需要使用 base64.URLEncoding.EncodeToString(b) 对字节切片进行解码,以获取一个没有控制或不可打印字符的有效字符串。这种转换发生在generatePass()函数中,这里没有显示。

运行 cryptoRand.go 会创建以下类型的输出:

$ go run cryptoRand.go   
Using default values!
Ce30g--D
$ go run cryptoRand.go 20
AEIePSYb13KwkDnO5Xk_ 

输出与genPass.go生成的输出没有不同,只是随机数生成得更安全,这意味着它们可以在需要安全性的应用中使用。

现在我们知道了如何生成随机数,我们将重新审视统计应用。

更新统计应用

在本节中,我们将提升统计应用的函数性和操作。当没有有效输入时,我们将使用十个随机值填充统计应用,这在需要将大量数据放入应用进行测试时非常方便——你可以根据需要更改随机值的数量。然而,请注意,这种情况发生在所有用户输入都无效时。

我过去随机生成数据,以便将样本数据放入 Kafka 主题、RabbitMQ 队列和 MySQL 表中。

此外,我们还将对数据进行归一化。官方上,这被称为Z 归一化,有助于更准确地比较值序列。我们将在后续章节中使用归一化。

数据归一化的函数实现如下:

func normalize(data []float64, mean float64, stdDev float64) []float64 {
if stdDev == 0 {
    return data
}
normalized := make([]float64, len(data))
for i, val := range data {
    normalized[i] = **math.Floor((val-mean)/stdDev*****10000****) /** **10000**
}
return normalized
} 

从函数的参数中,你可以看到normalize()在归一化之前需要样本的均值和标准差。除此之外,还有一个使用math.Floor()的小技巧来定义归一化float64值的精度,在这个例子中是四位数字。要获得两位数字的精度,你应该将代码更改为math.Floor((val-mean)/stdDev*100)/100

此外,生成随机浮点数的函数实现如下:

func randomFloat(min, max float64) float64 {
    return min + rand.Float64()*(max-min)
} 

rand.Float64()函数返回从01.0的值,不包括1.0randomFloat()函数返回从minmax的值,不包括max

你可以通过查看stats.go的源代码来了解剩余的实现细节。与前一章的版本相比,主要区别在于我们现在使用一个名为values的切片来存储所有正在处理的有效值。

运行stats.go会产生以下类型的输出:

$ go run stats.go 3 5 5 8 9 12 12 13 15 16 17 19 22 24 25 134
Number of values: 16
Min: 3
Max: 134
Mean value: 21.18750
Standard deviation: 29.84380
Normalized: [-0.6095 -0.5425 -0.5425 -0.4419 -0.4084 -0.3079 -0.3079 -0.2744 -0.2074 -0.1739 -0.1404 -0.0733 0.0272 0.0942 0.1277 3.78] 

虽然随机生成的值可能并不总是完美的,但它们通常足以用于测试目的。

摘要

在本章中,我们学习了 Go 的基本数据类型,包括数值数据类型、字符串和错误。此外,我们学习了如何使用数组来分组相似值,以及如何使用切片。最后,我们学习了数组和切片之间的区别,以及为什么切片比数组更灵活,以及如何生成随机数和字符串以生成随机数据。

你应该从本章记住的一件事是,如果切片的长度等于 0,则切片为空。另一方面,如果切片等于 nil,则切片为 nil——这意味着它指向没有内存地址。var s []string 语句创建了一个不分配任何内存的 nil 切片。nil 切片始终为空——反之则不一定成立。

关于 Go 字符串,请记住双引号定义的是解释过的字符串字面量,而反引号定义的是原始字符串字面量。大多数情况下,你需要使用双引号。

最后,请记住,使用 unsafe 包可能会导致微妙的错误和内存安全问题。Go 语言鼓励类型安全,unsafe 的使用应限制在存在明确风险理解且没有更安全替代方案的情况下。

下一章将讨论 Go 的复合数据类型,即映射和结构体。映射可以使用不同数据类型的键,而结构体可以组合多个数据类型并创建新的类型,你可以将其作为单个实体访问。正如你将在后面的章节中看到的,结构体在 Go 中扮演着关键角色。

练习

尝试以下练习:

  • 修正 typedConstants.go 中的错误。

  • 创建并测试一个将两个数组连接成新切片的函数。

  • 创建一个将两个数组连接成新数组的函数。不要忘记用各种类型的输入测试它。

  • 创建一个将两个切片连接成新数组的函数。

  • 运行 go doc errors Is 以了解 errors.Is(),并尝试创建一个小型 Go 程序使用它。之后,修改 error.go 以使用 errors.Is()

  • 修改 stats.go 以接受随机生成值的数量作为命令行参数。

  • 修改 stats.go 以始终使用随机生成数据。

其他资源

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第三章:复合数据类型

Go 提供了对映射(Maps)和结构体(Structures)的支持,它们是复合数据类型,也是本章的主要内容。我们之所以将它们与数组和切片分开介绍,是因为映射和结构体都比数组和切片更灵活、更强大。每个映射可以使用给定预定义数据类型的键,而结构体可以将多个数据类型组合在一起并创建新的数据类型。

映射和切片用于完全不同的原因。数组和切片用于存储连续数据,并从内存局部性和索引中受益。当您不需要数据的局部性但仍然需要以恒定时间引用它时,映射是有用的。

一般的想法是,如果数组或切片无法完成工作,您可能需要查看映射。如果映射无法以您想要的方式存储数据,那么您应该考虑创建和使用结构体——您还可以使用数组或切片来分组相同类型的结构体。请记住,映射和结构体在用途上是不同的。您可以轻松地有一个结构体的映射,以及结构体的数组或切片。然而,当您需要组合多个逻辑上分组的数据和/或变量时,结构体是有用的。

此外,本章的知识将使我们能够使用结构体读取和保存 CSV 格式的数据。

此外,我们还将改进在 第一章,Go 快速入门 中最初开发的统计应用程序。新版本的实用程序将能够从磁盘加载数据,这意味着您不再需要将数据硬编码或生成随机数。

本章涵盖:

  • 映射(Maps)

  • 结构体

  • 正则表达式和模式匹配

  • 改进统计应用程序

不再赘述,让我们首先介绍映射(Maps)。

映射(Maps)

数组和切片都限制您只能使用正整数作为索引,这些索引从 0 开始,且不能有间隔——这意味着即使您只想在索引 99 的切片元素中放置数据,切片仍然会在内存中占用 100 个元素。映射(Maps)是更强大的数据结构,因为它们允许您使用各种数据类型的索引作为键来查找数据,只要这些键是可比较的。可比较意味着 Go 应该能够判断两个值是否相等,或者哪个值比另一个值更大(或更小)。

尽管布尔变量是可比较的,但将 bool 变量用作映射的键是没有意义的,因为它只允许两个不同的值。此外,尽管浮点值是可比较的,但由这些值的内部表示引起的精度问题可能会产生错误和崩溃,因此您可能想避免将浮点值用作映射的键。

您可能会问,为什么我们需要映射,它们有什么优势?以下列表将帮助澄清这些问题:

  • 映射非常灵活。你甚至可以使用映射创建数据库索引,这允许你根据给定的键或更高级情况下键的组合来搜索和访问元素。

  • 尽管这并不总是如此,但在 Go 中使用映射是快速的,因为你可以以常数时间访问映射的所有元素。在映射中插入和检索元素是常数时间操作。

  • 映射易于理解,这通常会导致清晰的设计。

你可以使用 make() 或映射字面量来创建一个新的映射变量。使用 make() 创建具有 string 键和 int 值的新映射与编写 make(map[string]int) 并将返回值赋给变量一样简单。另一方面,如果你决定使用映射字面量创建映射,你需要写如下内容:

m := map[string]int {
    "key1": -1
"key2": 123
} 

当你希望在创建映射时添加数据时,映射字面量版本更快。之前的映射字面量包含两个键和两个值——总共两个键值对。

你不应该对映射内部元素的顺序做出任何假设。Go 在迭代映射时会随机化键——这是故意为之,并且是语言设计的一部分。

你可以使用 len() 函数找到映射的长度,即映射中的键的数量,这个函数也适用于数组和切片;此外,你可以使用 delete() 函数从映射中删除键值对,该函数接受两个参数:映射的名称和键的名称,顺序如下。

如何判断映射中是否存在键

你可以通过 v, ok := aMap[k] 语句的第二个返回值来判断名为 aMap 的映射中是否存在键 k。如果 ok 被设置为 true,则 k 存在,其值为 v。如果它不存在,v 将被设置为其数据类型的零值,这取决于映射的定义。

现在有一个非常重要的细节:如果你尝试获取映射中不存在的键的值,Go 不会对此提出任何异议,并返回值的数据类型的零值

现在,让我们讨论一个特殊情况,即映射变量具有 nil 值。

将数据存储到 nil 映射中

你可以将映射变量赋值为 nil。在这种情况下,直到你将其赋值给新的映射变量,你将无法使用该变量。简单来说,如果你尝试在 nil 映射上存储数据,你的程序将会崩溃。这在下一段代码中得到了说明,这是可以在本书 GitHub 仓库 ch03 目录中找到的 nilMap.go 源文件的 main() 函数的实现:

func main() {
    aMap := map[string]int{}
    aMap["test"] = 1 

这之所以可行,是因为 aMap 指向一个现有的映射,这是 map[string]int{} 的返回值。

 fmt.Println("aMap:", aMap)
    aMap = nil 

在这个阶段,aMap 指向 nil,在 Go 中 nil 是“无”的同义词。

 fmt.Println("aMap:", aMap)
    if aMap == nil {
        fmt.Println("nil map!")
        aMap = map[string]int{}
    } 

在使用映射之前测试映射是否指向nil是一种良好的实践。在这种情况下,if aMap == nil允许我们确定是否可以将键值对存储到aMap中——我们不能,如果我们尝试这样做,程序将会崩溃。我们通过发出aMap = map[string]int{}语句来纠正这一点。

 aMap["test"] = 1
// This will crash!
    aMap = nil
    aMap["test"] = 1
} 

在程序的最后一部分,我们展示了如果您尝试在nil映射上存储数据,程序会如何崩溃——永远不要在生产环境中使用此类代码!

在实际应用中,如果一个函数接受映射(map)作为参数,那么在处理它之前应该检查该映射是否为nil

运行nilMap.go会产生以下输出:

$ go run nilMap.go
aMap: map[test:1]
aMap: map[]
nil map!
panic: assignment to entry in nil map
goroutine 1 [running]:
main.main()
    /Users/mtsouk/Desktop/mGo4th/code/ch03/nilMap.go:21 +0x17c
exit status 2 

程序崩溃的原因显示在程序输出中:panic: assignment to entry in nil map

遍历映射

forrange结合使用时,它实现了在其他编程语言中找到的foreach 循环的功能,允许您遍历映射的所有元素,而无需知道其大小或其键。在这种情况下,range按顺序返回键值对。

输入以下代码并将其保存为forMaps.go

package main
import "fmt"
func main() {
    aMap := make(map[string]string)
    aMap["123"] = "456"
    aMap["key"] = "A value"
for key, v := range aMap {
        fmt.Println("key:", key, "value:", v)
    } 

在这种情况下,我们使用从range返回的键和值。

 for _, v := range aMap {
        fmt.Print(" # ", v)
    }
    fmt.Println()
} 

在这种情况下,因为我们只对映射返回的值感兴趣,所以我们忽略了键。

正如您已经知道的,您不应该对从for/range循环中返回的映射(map)键值对的顺序做出任何假设。

运行forMaps.go会产生以下输出:

$ go run forMaps.go
key: key value: A value
key: 123 value: 456
 # 456 # A value 

在介绍了映射之后,现在是时候学习 Go 中的结构体了。

结构体

Go 中的结构体(struct)既强大又流行,用于在同一个名称下组织并分组各种类型的数据。结构体是 Go 中更通用的数据类型——它们甚至可以有相关联的函数,这些函数被称为方法。

结构体以及其他用户定义的数据类型通常定义在main()函数或任何其他包函数之外,这样它们就可以具有全局作用域,并可供整个 Go 包使用。因此,除非您想明确指出一个类型仅在当前局部作用域内有用,并且不期望在其他地方使用,否则您应该将新数据类型的定义写在函数外部。

类型关键字

type关键字允许您定义新的数据类型或为现有类型创建别名。因此,您可以说type myInt int并定义一个新的数据类型myInt,它是int的别名。然而,Go 将myIntint视为完全不同的数据类型,您不能直接比较它们,即使它们存储的是相同类型的值。每个结构体定义了一个新的数据类型,因此使用了type

定义新的结构体

当你定义一个新的结构体时,在 Go 文档中称为 struct,你将一组值组合成一个单一的数据类型,这使得你可以将这组值作为一个单一实体传递和接收。结构体有字段,每个字段都有自己的数据类型,这甚至可以是另一个结构体或结构体的切片。此外,由于结构体是一个新的数据类型,它使用 type 关键字定义,后跟结构体的名称,并以 struct 关键字结尾,表示我们正在定义一个新的结构体。

以下代码定义了一个名为 Entry 的新结构体:

type Entry struct {
    Name    string
    Surname string
    Year    int
} 

尽管你可以将结构体定义嵌入到另一个结构体中,但这通常不是一个好主意,应该避免这样做。如果你甚至考虑这样做,你可能需要重新考虑你的设计决策。然而,在结构体内部将现有的结构体作为类型是完全可以接受的。

由于在 第六章Go 包和函数 中将要变得明显的原因,结构体的字段通常以大写字母开头——这取决于你想要对字段做什么,以及它们在当前包之外的可视性可能会如何影响。Entry 结构体有三个字段,分别命名为 NameSurnameYear。前两个字段是 string 数据类型,而最后一个字段包含一个 int 值。

这三个字段可以通过 点表示法 访问,例如 V.NameV.SurnameV.Year,其中 V 是持有 Entry 结构体实例的变量的名称。可以定义一个名为 p1 的结构体字面量,如下所示:p1 := Entry{"Joe", "D.", 2012}

存在两种方式来处理结构变量。第一种是将它们作为常规变量,第二种是将它们作为指针变量,这些指针变量指向结构体的内存地址。这两种方式同样有效,通常会被嵌入到不同的函数中,因为它们允许你正确地初始化结构变量的一些或所有字段,并在使用结构变量之前执行任何其他你想要的任务。

因此,使用函数创建新结构体的主要方式有两种。第一种返回一个常规结构体变量,而第二种返回一个指向结构体的指针。这两种方式各有两种变体。第一种变体返回由 Go 编译器初始化的结构体实例,而第二种变体返回由开发者初始化的结构体实例。

最后,请记住,你在结构类型定义中放置字段的顺序对于定义的结构类型身份是重要的。简单来说,在 Go 中,如果两个结构具有相同的字段但顺序不同,则它们不会被考虑为相同。这主要与服务器和客户端软件之间的数据交换有关,因为不同结构体的变量无法比较,即使它们具有完全相同的字段列表、完全相同的数据类型和完全相同的顺序,因为它们属于不同的数据类型。

使用new关键字

此外,您可以使用new()关键字和如pS := new(Entry)之类的语句创建新的结构实例。new()关键字具有以下特性:

  • 它分配适当的内存空间,这取决于数据类型,然后将其清零。

  • 它总是返回指向已分配内存的指针。

  • 它适用于所有数据类型,除了通道和映射。

所有这些技术都在下面的代码中得到了说明。请在您喜欢的文本编辑器中输入以下代码,并将其保存为structures.go

package main
import "fmt"
type Entry struct {
    Name    string
    Surname string
    Year    int
}
// Initialized by Go
func zeroS() Entry {
    return Entry{}
} 

现在是提醒您一个重要的 Go 规则的好时机:如果未为变量提供初始值,Go 编译器会自动将该变量初始化为其数据类型的零值。对于结构体,这意味着没有初始值的结构体变量将初始化为其每个字段的零值。因此,zeroS()函数返回一个零初始化的Entry结构体。

// Initialized by the user
func initS(N, S string, Y int) Entry {
    if Y < 2000 {
        return Entry{Name: N, Surname: S, Year: 2000}
    }
    return Entry{Name: N, Surname: S, Year: Y}
} 

在这种情况下,用户初始化新的结构体变量。此外,initS()函数检查Year字段的值是否小于2000并采取行动;如果小于2000,则Year字段的值变为2000。这个条件是针对你正在开发的应用程序的需求特定的——这表明初始化结构体的位置也是检查输入的好地方。

// Initialized by Go - returns pointer
func zeroPtoS() *Entry {
    t := &Entry{}
    return t
} 

zeroPtoS()函数返回一个指向零初始化结构的指针。

// Initialized by the user - returns pointer
func initPtoS(N, S string, Y int) *Entry {
    if len(S) == 0 {
        return &Entry{Name: N, Surname: "Unknown", Year: Y}
    }
    return &Entry{Name: N, Surname: S, Year: Y}
} 

initPtoS()函数也返回一个指向结构的指针,但同时也检查用户输入的长度。这种检查是特定于应用的。

func main() {
    s1 := zeroS()
    p1 := zeroPtoS()
    fmt.Println("s1:", s1, "p1:", *p1)
    s2 := initS("Mihalis", "Tsoukalos", 2024)
    p2 := initPtoS("Mihalis", "Tsoukalos", 2024)
    fmt.Println("s2:", s2, "p2:", *p2)
    fmt.Println("Year:", s1.Year, s2.Year, p1.Year, p2.Year)
    pS := new(Entry)
    fmt.Println("pS:", pS)
} 

new(Entry)调用返回一个指向Entry结构的指针。一般来说,当你需要初始化大量的结构变量时,创建一个用于此目的的函数被认为是良好的实践,因为这可以减少出错的可能性。

运行structures.go将生成以下输出:

s1: {  0} p1: {  0}
s2: {Mihalis Tsoukalos 2024} p2: {Mihalis Tsoukalos 2024}
Year: 0 2024 0 2024
pS: &{  0} 

由于字符串的零值是空字符串,因此s1p1pSNameSurname字段中不显示任何数据。

下一个子节将展示如何将相同数据类型的结构分组,并将它们用作切片的元素。

结构体的切片

你可以创建结构切片来将多个结构分组并使用单个变量名处理。然而,访问给定结构的字段需要知道该结构在切片中的确切位置。

请查看以下图表,以更好地理解结构切片的工作原理以及如何访问特定切片元素的字段。

黑色背景上的黑色方块  自动生成的描述

图 3.1:结构切片

因此,每个切片元素都是一个使用切片索引访问的结构。一旦我们选择了我们想要的切片元素,我们就可以选择其任意一个字段。

由于整个过程可能有点令人困惑,下面的代码提供了一些启示并澄清了问题。请输入以下代码并将其保存为 sliceStruct.go。你还可以在书的 GitHub 仓库的 ch03 目录中找到相同名称的文件。

package main
import (
    "fmt"
"strconv"
)
type record struct {
    Field1 int
    Field2 string
}
func main() {
    s := []record{}
    for i := 0; i < 10; i++ {
        text := "text" + strconv.Itoa(i)
        temp := record{Field1: i, Field2: text}
        s = append(s, temp)
    } 

你仍然需要使用 append() 函数向切片中添加一个新的结构。

 // Accessing the fields of the first element
    fmt.Println("Index 0:", s[0].Field1, s[0].Field2)
    fmt.Println("Number of structures:", len(s))
    sum := 0
for _, k := range s {
        sum += k.Field1
    }
    fmt.Println("Sum:", sum)
} 

运行 sliceStruct.go 产生以下输出:

Index 0: 0 text0
Number of structures: 10
Sum: 45 

我们在第五章中回顾了结构,其中我们讨论了反射,以及在第七章 告诉 UNIX 系统做什么 中,我们学习了如何使用结构处理 JSON 数据。现在,让我们讨论正则表达式和模式匹配。

正则表达式和模式匹配

你可能会想知道为什么我们在本章中讨论正则表达式和模式匹配。原因很简单。在不久的将来,你将学习如何从纯文本文件中存储和读取 CSV 数据,你应该能够判断数据是否有效。

模式匹配是一种搜索字符串中某些字符的技术,它基于基于正则表达式和语法的特定搜索模式。

正则表达式是一系列定义搜索模式的字符。每个正则表达式都被编译成一个识别器,通过构建一个称为有限自动机的通用转换图来实现。有限自动机可以是确定性的或非确定性的。非确定性意味着对于相同的输入,从状态中可能有多个转换是可能的。识别器是一个程序,它接受一个字符串 x 作为输入,并可以判断 x 是否是给定语言的句子。

语法是一组用于形式语言中字符串的生产规则——生产规则描述了如何从语言的字母表中创建字符串,这些字符串根据语言的语法是有效的。语法不描述字符串的意义或在任何上下文中可以用它做什么——它只描述其形式。这里重要的是要意识到,语法是正则表达式的核心,因为没有语法,你无法定义和因此使用正则表达式。

关于 regexp.Compile 和 regexp.MustCompile

负责定义正则表达式和执行模式匹配的 Go 包称为 regexp。在该包内部存在 regexp.Compile()regexp.MustCompile(),它们具有相似的功能。

regexp.MustCompile()regexp.Compile() 函数解析给定的正则表达式,并返回一个指向 regexp.Regexp 变量的指针,该变量可用于匹配——regexp.Regexp 是编译后的正则表达式的表示。re.Match() 方法返回 true 如果给定的字节切片与 re 正则表达式匹配,即 regexp.Regexp 变量,否则返回 false

regexp.Compile()regexp.MustCompile() 之间的主要和关键区别在于,前者返回一个 *regexp.Regexp 指针和一个 error 变量,而后者只返回一个 *regexp.Regexp 指针。因此,如果正则表达式的解析过程中出现某种错误,regexp.MustCompile() 将会引发恐慌,从而导致你的程序崩溃!

然而,regexp.MustCompile() 引发恐慌并不一定是坏事,因为如果正则表达式无法解析,你将知道你的表达式在早期过程中是无效的。最终,关于正则表达式解析的整体策略是由开发者决定的。

有时候我们只想找到那些后面或前面跟着另一个给定模式的匹配模式。这类操作分别称为前瞻和后顾。Go 不支持前瞻或后顾,使用时将抛出错误信息。前瞻的一般语法是 X(?=Y),这意味着,只有当 X 后面跟着 Y 时才匹配 Xregexp.Compile()regexp.MustCompile() 之间的区别在 diffRegExp.gomain() 函数中得到了说明,该函数将分两部分展示。

func main() {
    // This is a raw string literal
var re string = `^.*(?=.{7,})(?=.*\d)$` 

前面的正则表达式有什么问题?问题是它使用了前瞻,这在 Go 中不受支持。

第二部分如下:

 exp1, err := regexp.Compile(re)
    if err != nil {
        fmt.Println("Error:", err)
    }
    fmt.Println("RegExp:", exp1)
    exp2 := regexp.MustCompile(re)
    fmt.Println(exp2)
} 

在这个第二个代码段中,展示了 regexp.Compile()regexp.MustCompile() 的使用。

运行 diffRegExp.go 产生以下输出:

$ go run diffRegExp.go
Error: error parsing regexp: invalid or unsupported Perl syntax: `(?=`
RegExp: <nil>
panic: regexp: Compile(`^.*(?=.{7,})(?=.*\d)$`): error parsing regexp: invalid or unsupported Perl syntax: `(?=`
goroutine 1 [running]:
regexp.MustCompile({0x100a0c681, 0x15})
    /opt/homebrew/Cellar/go/1.20.6/libexec/src/regexp/regexp.go:319 +0xac
main.main()
    /Users/mtsouk/Desktop/mGo4th/code/ch03/diffRegExp.go:20 +0xf8
exit status 2 

因此,在第一种情况下,我们知道正则表达式中存在错误,因为 regexp.Compile() 的返回值,而使用带有错误正则表达式的 regexp.MustCompile() 时,程序会引发恐慌并自动终止。

下一个子节将展示如何定义正则表达式。

Go 正则表达式

我们从这个子节开始,展示一些常用的匹配模式,这些模式用于构建正则表达式。

表达式 描述
. 匹配任何字符
* 表示任意次数——不能单独使用
? 零次或一次——不能单独使用
+ 表示一次或多次——不能单独使用
^ 表示行的开头
` 表达式
--- ---
. 匹配任何字符
* 表示任意次数——不能单独使用
? 零次或一次——不能单独使用
+ 表示一次或多次——不能单独使用
^ 表示行的开头
表示行的末尾
[] [] 用于分组字符
[A-Z] 这意味着从大写字母 A 到大写字母 Z 的所有字符
\d 0-9 中的任何数字
\D 非数字
\w 任何单词字符:[0-9A-Za-z_]
\W 任何非单词字符
\s 空白字符
\S 非空白字符

之前表格中展示的字符用于构建和定义正则表达式的语法。

创建单独的函数进行模式匹配可能很有用,因为它允许你在不担心程序上下文的情况下重用这些函数。

请记住,尽管正则表达式和模式匹配一开始看起来很方便,但它们是许多错误的原因。我的建议是使用最简单的正则表达式来解决你的问题。然而,在长期来看,避免使用正则表达式同时完成你的工作会更好!

关于原始字符串和解释字符串字面量

尽管我们在上一章讨论了字符串,但我们第一次在 diffRegExp.go 中定义正则表达式时使用了原始字符串字面量,因此让我们再谈谈原始字符串字面量,它们包含在反引号中而不是双引号中。原始字符串字面量的优点如下:

  • 它们可以在其中包含大量文本,而无需使用控制字符,如 \n 来换行。

  • 它们在定义正则表达式时很有用,因为你不需要使用反引号(\)来转义特殊字符。

  • 它们用于结构标签中,这些标签在第十一章与 REST API 协同工作中有所解释。

因此,总结来说,原始字符串字面量用于存储不进行任何转义处理的字符串,而解释字符串字面量在字符串创建时进行处理

下一个子节将展示用于匹配姓名和姓氏的正则表达式。

匹配姓名和姓氏

所展示的实用工具匹配姓名和姓氏——根据我们的定义,这些是以大写字母开头并继续以小写字母开头的字符串。输入不应包含任何数字或其他字符。

实用工具的源代码可以在 ch03 文件夹中的 nameSurRE.go 文件中找到。支持所需功能的函数名为 matchNameSur(),其实现如下:

func matchNameSur(s string) bool {
    t := []byte(s)
    re := regexp.MustCompile(`^[A-Z][a-z]*$`)
    return re.Match(t)
} 

函数的逻辑在 `^[A-Z][a-z]*$` 正则表达式中,其中 ^ 表示行的开始,$ 表示行的结束。正则表达式所做的匹配是以大写字母开头([A-Z])并继续以任意数量的小写字母([a-z]*)开头的任何内容。这意味着 Z 是一个匹配项,但 ZA 不是一个匹配项,因为第二个字母是大写的。同样,Jo+ 不是一个匹配项,因为它包含 + 字符。

使用各种类型的输入运行 nameSurRE.go 产生以下输出:

$ go run nameSurRE.go Z 
true
$ go run nameSurRE.go ZA
false
$ go run nameSurRE.go Mihalis
True 

这种技术可以帮助你检查用户输入的有效性。下一小节将介绍匹配整数。

匹配整数

该工具可以匹配有符号和无符号整数——这是通过我们定义的正则表达式实现的。如果我们只想匹配无符号整数,那么我们应该将正则表达式中的 [-+]? 替换为 [+]?

与使用正则表达式匹配整数值相比,使用 strconv.Atoi() 会是一个更好的替代方案。作为一个建议,如果你能避免使用正则表达式,请选择替代方法。然而,当你事先不知道期望输入的数据类型或数量时,正则表达式是无价的。一般来说,正则表达式对于分离输入的各个部分非常有价值。请记住,正则表达式始终匹配字符串,你也可以在字符串中找到数字。

工具的源代码位于 intRE.go 文件中,该文件位于 ch03 目录下。支持所需功能的 matchInt() 函数实现如下:

func matchInt(s string) bool {
    t := []byte(s)
    re := regexp.MustCompile(`^[-+]?\d+$`)
    return re.Match(t)
} 

与之前一样,函数的逻辑可以在用于匹配整数的正则表达式中找到,即 `^[-+]?\d+$`。用简单的话来说,我们这里的意思是我们想匹配以 + 开头的东西,这是可选的(?),并以任意数量的数字(\d+)结束——在字符串的末尾之前至少需要一个数字($)。

使用各种类型的输入运行 intRE.go 产生以下输出:

$ go run intRE.go 123
true
$ go run intRE.go /123
false
$ go run intRE.go +123.2
false
$ go run intRE.go +
false
$ go run intRE.go -123.2
false 

在本书的后面部分,你将学习如何通过编写测试函数来测试 Go 代码——现在,我们将大部分测试手动进行。

提升统计应用程序

是时候更新统计应用程序了。统计工具的新版本有以下改进:

  • 它使用函数简化 main() 函数并改进整体设计。

  • 它可以读取包含数值输入的 CSV 文件。

但首先,我们需要学习如何在 Go 中处理 CSV 文件,这是下一小节的主题。

处理 CSV 文件

大多数时候,你不想丢失数据或每次执行应用程序时都必须在没有数据的情况下开始。为此存在许多技术——最简单的一种是将数据本地保存。一个非常易于处理的纯文本文件格式是 CSV,这就是这里所解释的,并在后面的统计应用程序中使用。

好的一点是,Go 提供了一个专门用于处理 CSV 数据的包,名为 encoding/csv (pkg.go.dev/encoding/csv)。对于所提供的工具,输入和输出文件都作为命令行参数给出。

当从磁盘读取或写入 CSV 数据时,所有内容都被视为字符串。因此,如果你在读取阶段希望将数值数据视为此类数据,你可能需要自行将其转换为适当的数据类型。

存在两个非常流行的 Go 接口,分别命名为 io.Readerio.Writer,它们分别与读取和写入文件相关。几乎所有的读取和写入操作在 Go 中都使用这两个接口。所有读取器使用相同接口的做法使得读取器可以共享一些共同特性,但最重要的是,它允许你在 Go 需要任何 io.Reader 读取器的地方创建自己的读取器并使用它们。对于满足 io.Writer 接口的写入器也是如此。你将在 第五章反射与接口 中了解更多关于接口的内容。

需要实现的主要任务如下:

  • 从磁盘加载 CSV 数据并将其放入结构体切片中

  • 使用 CSV 格式将数据保存到磁盘

encoding/csv 包包含可以帮助你读取和写入 CSV 文件的函数。由于我们处理的是小型 CSV 文件,我们使用 csv.NewReader(f).ReadAll() 一次性读取整个输入文件。对于较大的数据文件,或者如果我们想在读取时检查输入或对其进行任何更改,使用 Read() 而不是 ReadAll() 会更好。

Go 假设 CSV 文件使用逗号字符 (,) 来分隔每行的不同字段。如果我们希望改变这种行为,我们应该根据我们想要执行的任务更改 CSV 读取器或写入器的 Comma 变量的值。我们在输出 CSV 文件中改变了这种行为,它使用制表符来分隔字段。

由于兼容性的原因,如果输入和输出 CSV 文件使用相同的字段分隔符会更好。我们只是在输出文件中使用制表符作为字段分隔符来展示 Comma 变量的使用。

由于处理 CSV 文件是一个新主题,本书 GitHub 仓库的 ch03 目录中有一个名为 csvData.go 的单独实用工具,它展示了读取和写入 CSV 文件的技巧。csvData.go 的源代码以块的形式展示。首先,我们展示 csvData.go 的前言部分,其中包含 import 部分、Record 结构体以及 myData 全局变量,它是一个 Record 切片。

package main
import (
    "encoding/csv"
"log"
"os"
)
type Record struct {
    Name       string
    Surname    string
    Number     string
    LastAccess string
}
var myData = []Record{} 

然后,我们介绍 readCSVFile() 函数,该函数读取包含 CSV 数据的纯文本文件。

func readCSVFile(filepath string) ([][]string, error) {
    _, err := os.Stat(filepath)
    if err != nil {
        return nil, err
    }
    f, err := os.Open(filepath)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    // CSV file read all at once
// lines data type is [][]string
    lines, err := csv.NewReader(f).ReadAll()
    if err != nil {
        return [][]string{}, err
    }
    return lines, nil
} 

注意,我们在函数内部检查了给定的文件路径是否存在,并且与一个常规文件相关联。关于在哪里执行这种检查没有正确或错误的选择——你只需要保持一致。readCSVFile() 函数返回一个包含所有读取行的 [][]string 切片。此外,请记住,csv.NewReader() 会分隔每条输入行的字段,这是需要使用二维切片来存储输入的主要原因。

之后,我们借助 saveCSVFile() 函数展示了将内容写入 CSV 文件的技巧。

func saveCSVFile(filepath string) error {
    csvfile, err := os.Create(filepath)
    if err != nil {
        return err
    }
    defer csvfile.Close()
    csvwriter := csv.NewWriter(csvfile)
    // Changing the default field delimiter to tab
    csvwriter.Comma = '\t'
for _, row := range myData {
        temp := []string{row.Name, row.Surname, row.Number, row.LastAccess}
        err = csvwriter.Write(temp)
        if err != nil {
            return err
        }
    }
    csvwriter.Flush()
    return nil
} 

注意 csvwriter.Comma 的默认值发生了变化,以符合我们的需求。

最后,我们可以看到 main() 函数的实现。

func main() {
    if len(os.Args) != 3 {
        log.Println("csvData input output!")
        os.Exit(1)
    }
    input := os.Args[1]
    output := os.Args[2]
    lines, err := readCSVFile(input)
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }
    // CSV data is read in columns - each line is a slice
for _, line := range lines {
        temp := Record{
            Name:       line[0],
            Surname:    line[1],
            Number:     line[2],
            LastAccess: line[3],
        }
        myData = append(myData, temp)
        log.Println(temp)
    }
    err = saveCSVFile(output)
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }
} 

main() 函数将 readCSVFile() 读取的内容放入 myData 切片中——请记住,lines 是一个二维切片,并且 lines 中的每一行已经被分隔成字段。在这种情况下,输入的每一行包含四个字段。因此,我们处理这个 [][]string 切片,并将所需信息放入结构体切片(myData)中。

作为输入使用的 CSV 数据文件的内容如下:

$ cat ~/csv.data
Dimitris,Tsoukalos,2101112223,1600665563
Mihalis,Tsoukalos,2109416471,1600665563
Jane,Doe,0800123456,1608559903 

运行 csvData.go 产生以下类型的输出:

$ go run csvData.go ~/csv.data /tmp/output.data
{Dimitris Tsoukalos 2101112223 1600665563}
{Mihalis Tsoukalos 2109416471 1600665563}
{Jane Doe 0800123456 1608559903} 

输出 CSV 文件的内容如下:

$ cat /tmp/output.data
Dimitris        Tsoukalos       2101112223      1600665563
Mihalis Tsoukalos       2109416471      1600665563
Jane    Doe     0800123456      1608559903 

output.data 文件使用制表符来分隔每条记录的不同字段,因此生成了相应的输出。csvData.go 工具在执行不同类型 CSV 文件之间的转换时可能很有用。

统计应用程序的更新版本

在本小节中,我们将展示统计应用程序的更新代码。normalized() 函数没有变化,因此不再展示。

来自 stats.go 的第一段代码是实现了一个函数,该函数将 CSV 文件作为文本读取,并将其转换为 float64 值的切片。

func readFile(filepath string) ([]float64, error) {
    _, err := os.Stat(filepath)
    if err != nil {
        return nil, err
    }
    f, err := os.Open(filepath)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    lines, err := csv.NewReader(f).ReadAll()
    if err != nil {
        return nil, err
    }
    values := make([]float64, 0)
    for _, line := range lines {
        **tmp, err := strconv.ParseFloat(line[****0****],** **64****)**
if err != nil {
            log.Println("Error reading:", line[0], err)
            continue
        }
        values = append(values, tmp)
    }
    return values, nil
} 

一旦读取了指定的 CSV 文件,其数据就被放入 lines 变量中。请注意,在我们的案例中,CSV 文件中的每一行只有一个字段。尽管如此,lines 有两个维度。

由于我们想要返回一个 float64 值的切片,我们必须将 [][]string 变量转换为 []float64 变量,这正是最后一个 for 循环的目的。for 循环最重要的任务是确保所有字符串都是有效的 float64 值,以便将它们放入 values 切片中——这是 strconv.ParseFloat(line[0], 64) 调用的目的。

接下来,我们有计算标准差的函数实现:

func stdDev(x []float64) (float64, float64) {
    sum := 0.0
for _, val := range x {
        sum = sum + val
    }
    meanValue := sum / float64(len(x))
    **fmt.Printf(****"Mean value: %.5f\n"****, meanValue)**
// Standard deviation
var squared float64
for i := 0; i < len(x); i++ {
        squared = squared + math.Pow((x[i]-meanValue), 2)
    }
    standardDeviation := math.Sqrt(squared / float64(len(x)))
    return meanValue, standardDeviation
} 

首先,stdDev() 函数计算所有给定值的总和,然后计算数据的平均值。最后,计算标准差。当你确定一切按预期工作后,可以移除 stdDev() 函数内的 fmt.Printf() 调用。

最后,这是 main() 函数的实现:

func main() {
    if len(os.Args) == 1 {
        log.Println("Need one argument!")
        return
    }
    file := os.Args[1]
    values, err := readFile(file)
    if err != nil {
        log.Println("Error reading:", file, err)
        os.Exit(0)
    }
    sort.Float64s(values)
    fmt.Println("Number of values:", len(values))
    fmt.Println("Min:", values[0])
    fmt.Println("Max:", values[len(values)-1])
    meanValue, standardDeviation := stdDev(values)
    fmt.Printf("Standard deviation: %.5f\n", standardDeviation)
    normalized := normalize(values, meanValue, standardDeviation)
    fmt.Println("Normalized:", normalized)
} 

尽管更新版本的stats.go的核心功能与上一章开发的版本相同,但使用函数简化了main()的实现。

运行stats.go会产生以下输出:

$ go run stats.go csvData.txt
Error reading: a strconv.ParseFloat: parsing "a": invalid syntax
Number of values: 6
Min: -1.2
Max: 3
Mean value: 0.66667
Standard deviation: 1.54883
Normalized: [-1.2053 -1.0761 -0.4305 0.2797 0.9254 1.5065 

之前的输出显示csvData.txt包含无效行——csvData.txt的内容如下:

$ cat csvData.txt
1.1
2.1
-1.2
-1
0
a
3 

尽管比上一个版本好得多,但新的统计实用工具版本仍然不完美。以下是可以改进的事项列表:

  • 能够处理多个 CSV 数据文件。

  • 能够根据预定义的统计属性(如处理多个 CSV 数据文件时的平均值)对输出进行排序。

  • 能够使用 JSON 记录和 JSON 切片来存储数据,而不是 CSV 文件。

统计应用程序将从第五章开始不断改进,该章介绍了反射和接口,其中实现了对结构体元素的切片排序。

概述

在本章中,我们讨论了 Go 的复合数据类型,即映射(map)和结构体。此外,我们还讨论了处理 CSV 文件以及使用正则表达式和模式匹配。现在我们可以将数据保存在适当的结构体中,使用正则表达式进行验证,并将其存储在 CSV 文件中以实现数据持久性。

总是记住,如果你尝试获取映射中不存在的键的值,Go 不会对此提出异议,并将返回值的零值。

下一章是关于 Go 泛型的内容,这是一个相对较新的 Go 特性。

练习

  • 编写一个 Go 程序,将现有的数组转换为映射(map)。

  • 编写一个 Go 程序,将现有的映射(map)转换为两个切片——第一个切片包含映射的键,而第二个切片包含映射的值。两个切片中索引n的值应该对应于原始映射中可以找到的键值对。

  • nameSurRE.go进行必要的修改,以便能够处理多个命令行参数。

  • 修改intRE.go的代码,以处理多个命令行参数并在最后显示truefalse结果的总计。

  • csvData.go进行修改,根据#字符来分隔记录的字段。

  • 要了解正则表达式可能有多难,请在互联网上搜索一个匹配电子邮件地址的正则表达式。

  • regexp包包括MatchString()方法。尝试理解它与Match方法的主要区别,并创建一个工作示例。

  • 编写一个 Go 实用工具,将os.Args转换为结构体切片,其中包含存储每个命令行参数索引和值的字段——你应该定义将要使用的结构体。

  • csvData.go进行修改,使用单个字符(作为命令行参数给出)来分隔记录的字段。

  • 修改 stats.go 中的 stdDev() 函数,以便将样本的平均值保存到一个全局变量中,并从其中删除 fmt.Printf() 调用。

其他资源

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第四章:Go 泛型

本章将介绍泛型以及如何使用新的语法来编写泛型函数和定义泛型数据类型。泛型编程是一种编程范式,它允许开发者使用一个或多个将在以后提供的数据类型来实现函数。

Go 的泛型支持是在 Go 1.18 中引入的,该版本于 2022 年 2 月正式发布。因此,Go 的泛型现在已经不再是新闻了!尽管如此,Go 社区仍在努力理解并充分利用泛型。事实上,大多数 Go 开发者已经在没有泛型帮助的情况下完成了他们的工作。

如果你觉得在这一学习旅程的这个阶段你对这一章不感兴趣,你可以自由地跳过它,稍后再回来。然而,即使你现在可能不感兴趣,我仍然建议你阅读它。

这引出了以下事实:如果你不想使用 Go 泛型,对泛型的有用性有疑问,或者有其他的想法,你不必使用 Go 泛型。毕竟,你仍然可以在不使用泛型的情况下编写出色、高效、可维护和正确的软件!此外,你可以使用泛型并支持大量数据类型,如果不是所有可用的数据类型,这并不意味着你应该这样做。始终支持所需的数据类型,不多也不少,但不要忘记关注你数据未来的发展以及支持在编写代码时未知的数据类型的可能性。

本章涵盖:

  • 泛型简介

  • 约束

  • 使用泛型定义新数据类型

  • 何时使用泛型

  • cmp

  • slices

  • maps

泛型简介

泛型是一种功能,它允许你不必精确指定一个或多个函数参数的数据类型,主要是因为你希望使你的函数尽可能通用。换句话说,泛型允许函数处理多种数据类型,而无需编写任何特殊代码,就像空接口和一般接口的情况一样。接口在第五章反射与接口中有详细说明。

在 Go 中使用接口时,你必须编写额外的代码来确定你正在处理的接口变量的数据类型,而泛型则不需要这样做。

让我从展示一个小型代码示例开始,该示例实现了一个函数,清楚地展示了泛型可以派上用场并帮助你避免编写大量代码的情况:

func PrintSliceT any {
    for _, v := range s {
        fmt.Println(v)
    }
} 

那么,这里有什么呢?有一个名为PrintSlice()的函数,它接受任何数据类型的切片。这通过函数签名中使用[]T来表示,它指定了该函数接受一个切片,并结合[T any]部分指定所有数据类型都被接受并支持。[T any]部分告诉编译器数据类型T在执行时不会被确定,但它在编译时仍然会根据调用代码提供的类型来确定和强制执行。我们还可以使用多个(泛型)数据类型,使用[T, U, W any]表示法——之后我们应该在函数签名中使用TUW数据类型。

any关键字告诉编译器关于T的数据类型没有任何约束。我们将在稍后讨论约束——现在,我们只学习泛型的语法。

现在,想象一下为整数切片、字符串切片、浮点数切片、复数值切片等实现PrintSlice()功能的功能分别编写单独的函数。因此,我们发现了一个深刻的案例,使用泛型简化了代码和我们的编程工作。然而,并非所有情况都如此明显,我们应该非常小心地避免过度使用any

嗨,泛型!

以下(hw.go)是一段使用泛型的代码,它可以帮助你在深入了解更高级的示例之前更好地理解它们:

package main
import (
    "fmt"
)
func PrintSliceT any {
    for _, v := range s {
        fmt.Print(v, " ")
    }
    fmt.Println()
} 

PrintSlice()与我们在本章前面看到的函数类似。PrintSlice()在同一行中打印每个切片的元素,并在fmt.Println()的帮助下在末尾打印一个新行。

func main() {
    PrintSlice([]int{1, 2, 3})
    PrintSlice([]string{"a", "b", "c"})
    PrintSlice([]float64{1.2, -2.33, 4.55})
} 

在这里,我们使用相同的PrintSlice()函数,但传入三种不同的数据类型:intstringfloat64。Go 编译器不会对此提出异议。相反,它将像我们分别为每种数据类型编写了三个单独的函数一样执行代码。

因此,运行hw.go会产生以下输出:

1 2 3
a b c
1.2 -2.33 4.55 

因此,每个切片都使用单个泛型函数按预期打印。

基于这些信息,让我们首先讨论泛型和约束。

约束

假设你有一个使用泛型乘以两个数值的函数。这个函数应该与所有数据类型一起工作吗?这个函数可以与所有数据类型一起工作吗?你能乘以两个字符串或两个结构体吗?避免这种问题的解决方案是使用约束。类型约束允许你指定你想要与之一起工作的数据类型列表,以避免逻辑错误和错误。

暂时忘记乘法,考虑一些更简单的事情。假设我们想要比较变量以检查它们是否相等——有没有办法告诉 Go 我们只想与可以比较的值一起工作?Go 1.18 带来了预定义的类型约束——其中之一被称为comparable,包括可以比较相等或不等的数据类型。

对于更多预定义的约束,你应该查看 constraints 包(pkg.go.dev/golang.org/x/exp/constraints)。

allowed.go 的代码展示了 comparable 约束的使用。

package main
import (
    "fmt"
)
func SameT comparable bool {
    // Or
// return a == b
if a == b {
        return true
    }
    return false
} 

Same() 函数使用预定义的 comparable 约束而不是 any。实际上,comparable 约束只是一个预定义的接口,它包括所有可以用 ==!= 比较的数据类型。

我们不需要编写任何额外的代码来检查我们的输入,因为函数签名确保我们只会处理可接受和功能性的数据类型。

func main() {
    fmt.Println("4 = 3 is", Same(4,3))
    fmt.Println("aa = aa is", Same("aa","aa"))
    fmt.Println("4.1 = 4.15 is", Same(4.1,4.15))
} 

main() 函数三次调用 Same(),使用不同的数据类型,并打印其结果。

运行 allowed.go 产生以下输出:

4 = 3 is false
aa = aa is true
4.1 = 4.15 is false 

由于只有 Same("aa","aa")true,我们得到相应的输出。

如果你尝试运行一个类似于 Same([]int{1,2},[]int{1,3}) 的语句,该语句尝试比较两个切片,编译器将生成以下错误信息:

# command-line-arguments
./allowed.go:19:10: []int does not satisfy comparable 

这是因为我们无法直接比较两个切片——这种功能应该手动实现。请注意,你可以比较两个数组!

下一小节将展示如何创建你自己的约束。

创建约束

本小节提供了一个示例,其中我们定义了可以使用接口作为泛型函数参数传递的数据类型。numeric.go 的代码如下:

package main
import (
    "fmt"
)
type Numeric interface {
    int | int8 | int16 | int32 | int64 | float64
} 

在这里,我们定义了一个新的接口 Numeric,它指定了支持的数据类型列表。只要你可以使用你将要实现的泛型函数,你就可以使用任何你想要的数据类型。在这种情况下,如果我们想添加 stringuint 到支持的数据类型列表中,这是有意义的。在这种情况下,将 string 添加到 Numeric 接口中没有任何意义。

func AddT Numeric T {
    return a + b
} 

这是定义具有两个泛型参数的泛型函数的定义,这些参数使用 Numeric 约束。

func main() {
    fmt.Println("4 + 3 =", Add(4,3))
    fmt.Println("4.1 + 3.2 =", Add(4.1,3.2))
} 

之前的代码是 main() 函数的实现,其中调用了 Add()

运行 numeric.go 产生以下输出:

4 + 3 = 7
4.1 + 3.2 = 7.3 

尽管如此,Go 的规则仍然适用。因此,如果你尝试调用 Add(4.1,3),你将得到以下错误信息:

# command-line-arguments
./numeric.go:19:15: default type int of 3 does not match inferred type float64 for T 

这种错误的理由是 Add() 函数期望两个相同数据类型的参数。然而,4.1float64 类型,而 3int 类型,所以它们不是同一数据类型。

我们还没有讨论的一个额外问题是约束。正如我们已经知道的,Go 对不同数据类型有不同的处理,即使底层数据类型相同。这意味着如果我们创建一个基于 int 的新数据类型(type aType int),它将不会由 Numeric 约束支持,因为这是未指定的。下一小节将展示如何处理这种情况并克服这一限制。

支持底层数据类型

使用超类型,我们正在添加对底层数据类型(真实的那个)的支持,而不是当前的数据类型,这可能是现有 Go 数据类型的别名。超类型由 ~ 运算符支持。supertypes.go 中的 supertypes.go 部分展示了超类型的使用。supertypes.go 中的代码第一部分如下:

type AnotherInt int
type AllInts interface {
    ~int
} 

在之前的代码中,我们定义了一个名为 AllInts 的约束,它使用了一个超类型(~int)以及一个名为 AnotherInt 的新数据类型,实际上它是 intAllInts 约束的定义允许 AnotherIntAllInts 支持。

supertypes.go 的第二部分如下:

func AddElementsT AllInts T {
    sum := T(0)
    for _, v := range s {
        sum = sum + v
    }
    return sum
} 

在这部分中,我们定义了一个泛型函数。该函数附带了一个约束,因为它只支持 AllInts 的切片。

supertypes.go 的最后一部分如下:

func main() {
    s := []AnotherInt{0, 1, 2}
    fmt.Println(AddElements(s))
} 

在最后一部分,我们使用 AnotherInt 的切片作为参数调用 AddElements()——这种能力是通过在 AllInts 约束中使用超类型提供的。

运行 supertypes.go 产生以下输出:

$ go run supertypes.go
3 

因此,在类型约束中使用超类型允许 Go 处理实际的底层数据类型

支持任何类型的切片

在本小节中,我们将指定函数参数只能为任何数据类型的切片。sliceConstraint.go 中的相关代码如下:

func f1[S interface{ ~[]E }, E interface{}](x S) int {
    return len(x)
}
func f2[S ~[]E, E interface{}](x S) int {
    return len(x)
}
func f3[**S** **~[]****E****,** **E****any**](x S) int {
    return len(x)
} 

所有三个泛型函数是等效的。使用 ~[]E 指定底层数据类型应该是切片,即使它是由不同名称的类型。

f1() 函数是函数签名的长版本。interface{ ~[]E } 指定我们只想与任何数据类型的切片(E interface{})一起工作。f2() 函数将 interface{ ~[]E } 替换为仅 ~[]E,因为 Go 允许你在约束位置省略 interface{}。最后,f3() 函数将常用的 interface{} 替换为其预定义的等效项 any,这是我们之前已经看到过其作用的。我发现 f3() 的实现更简单,更容易理解。

下一节展示了在定义新数据类型时如何使用泛型。

使用泛型定义新数据类型

在本节中,我们将使用泛型创建一个新的数据类型,这在 newDT.go 中展示。newDT.go 的代码如下:

package main
import (
    "fmt"
"errors"
)
type TreeLast[T any] []T 

之前的语句声明了一个名为 TreeLast 的新数据类型,它使用了泛型。

func (t TreeLast[T]) replaceLast(element T) (TreeLast[T], error) {
    if len(t) == 0 {
        return t, errors.New("This is empty!")
    }

    t[len(t) - 1] = element
    return t, nil
} 

replaceLast() 是一个操作 TreeLast 变量的方法。除了函数签名外,没有其他内容显示泛型的使用。

func main() {
    tempStr := TreeLast[string]{"aa", "bb"}
    fmt.Println(tempStr)
    tempStr.replaceLast("cc")
    fmt.Println(tempStr) 

main() 的第一部分中,我们使用 aabb 字符串值创建了一个 TreeLast 变量,然后我们通过调用 replaceLast("cc")bb 值替换为 cc

 tempInt := TreeLast[int]{12, -3}
    fmt.Println(tempInt)
    tempInt.replaceLast(0)
    fmt.Println(tempInt)
} 

main() 的第二部分使用 TreeLast 变量(用 int 值填充)执行与第一部分类似的操作。因此,TreeLast 可以与 stringint 值一起工作而不会出现任何问题。

运行 newDT.go 产生以下输出:

[aa bb]
[aa cc]
[12 -3]
[12 0] 

输出的前两行与 TreeLast[string] 变量相关,而输出的最后两行与 TreeLast[int] 变量相关。

下一个子部分是关于在 Go 结构中使用泛型。

在 Go 结构中使用泛型

在本节中,我们将实现一个使用泛型的链表——这是泛型使用简化事情的一个例子,因为它允许你一次性实现链表,同时能够处理多种数据类型。

structures.go 的代码如下:

package main
import (
    "fmt"
)
type node[T any] struct {
    Data T
    next *node[T]
} 

节点结构使用泛型来支持可以存储所有类型数据的节点。这并不意味着节点的下一个字段可以指向另一个具有不同数据类型 Data 字段的节点。链表包含相同数据类型元素的规则仍然适用——这仅仅意味着,如果你想创建三个链表,一个用于存储 string 值,一个用于存储 int 值,第三个用于存储给定 struct 数据类型的 JSON 记录,你不需要为此编写任何额外的代码。

type list[T any] struct {
    start *node[T]
} 

这是 node 节点构成的链表的根节点定义。listnode 必须共享相同的数据类型 T。然而,正如之前所述,这并不阻止你创建多个不同数据类型的链表。

如果你想要限制允许的数据类型列表,你仍然可以在 nodelist 的定义中将 any 替换为约束。

func (l *list[T]) add(data T) {
    n := node[T]{
        Data: data,
        next: nil,
    } 

add() 函数是泛型的,以便能够与所有类型的节点一起工作。除了 add() 的签名外,其余代码与泛型的使用无关。

 if l.start == nil {
        l.start = &n
        return
    }

    if l.start.next == nil {
        l.start.next = &n
        return
    } 

这两个 if 块与向链表中添加新节点有关。第一个 if 块是当列表为空时的情况,而第二个 if 块是当我们处理当前列表的最后一个节点时的情况。

 temp := l.start
    l.start = l.start.next
    l.add(data)
    l.start = temp
} 

add() 的最后一部分与在列表中添加新节点时定义节点之间的适当关联有关。

func main() {
    var myList list[int] 

首先,我们在 main() 中定义一个整数值的链表,这是我们将要处理的链表。

 fmt.Println(myList) 

myList 的初始值是 nil,因为列表为空且不包含任何节点。

 myList.add(12)
    myList.add(9)
    myList.add(3)
    myList.add(9) 

在本部分,我们向链表中添加了四个元素。

 // Print all elements
    cur := myList.start
    for {
        fmt.Println("*", cur)
        if cur == nil {
            break
        }
        cur= cur.next
    }
} 

main() 的最后一部分是关于通过使用指向列表中下一个节点的 next 字段来遍历列表并打印所有元素。

运行 structures.go 产生以下输出:

{<nil>}
* &{12 0x14000096240}
* &{9 0x14000096260}
* &{3 0x14000096290}
* &{9 <nil>}
* <nil> 

让我们更详细地讨论一下输出。第一行显示空列表的值为nil。列表的第一个节点包含一个值为12和内存地址(0x14000096240),该地址指向第二个节点。这个过程一直持续到我们达到最后一个节点,它包含值为9的值,在这个链表中出现了两次,并指向nil,因为它是最后的节点。因此,泛型使得链表能够与多种数据类型一起工作。

接下来的三个部分介绍了三个使用泛型的包——您可以自由地查看它们的实现细节(见附加资源部分)。

The cmp package

cmp包在 Go 1.21 中成为标准 Go 库的一部分,包含用于比较有序值的类型和函数。之所以在slicesmaps包之前介绍它,是因为它被其他两个包使用。请记住,在其当前版本中,cmp包很简单,但它可能会在未来通过更多功能得到丰富。

在底层,cmpslicesmaps包使用泛型和约束,这是在本章中介绍它们的主要原因。因此,泛型可以用来创建可以与多种数据类型一起工作的包。

cmpPackage.go中的重要代码可以在main()函数中找到。

func main() {
    fmt.Println(cmp.Compare(5, 4))
    fmt.Println(cmp.Compare(4, 5))
    fmt.Println(cmp.Less(4, 5.1))
} 

在这里,cmp.Compare(x, y)比较两个值,当x < y时返回-1,当x=y时返回0,当x > y时返回1cmp.Compare(x, y)返回一个int值。另一方面,cmp.Less(x, y)返回一个bool值,当x < y时设置为true,否则为false

注意,在最后一个语句中,我们正在比较一个整数值和一个浮点值。然而,cmp包足够聪明,可以将int值转换为float64值,并比较这两个值!

运行cmpPackage.go产生以下输出:

$ go run cmpPackage.go
1
-1
true 

cmp.Compare(5, 4)的输出是1cmp.Compare(4, 5)的输出是-1,而cmp.Less(4, 5)的输出是true

The slices package

slices包自 Go 1.21 以来一直是标准 Go 库的一部分,并为任何数据类型的切片提供了函数。在我们继续讨论slices包之前,让我们谈谈浅拷贝深拷贝功能,包括它们之间的区别。

浅拷贝和深拷贝

一个浅拷贝会创建一个新的变量,然后将其赋值为原始变量版本中找到的所有值。如果我们谈论的是映射,那么这个过程会使用普通赋值来分配所有键和值。

深度复制 首先创建一个新的变量,然后插入原始变量中找到的所有值。然而,每个值都必须递归地复制——如果我们谈论的是字符串,这可能不是问题,但如果我们谈论的是结构体、结构体的引用或指针,这可能会成为一个问题。在这个过程中,可能会创建无限循环。关键词在这里是 递归——这意味着我们需要遍历所有值(如果我们谈论的是切片或映射)或字段(如果我们谈论的是结构体),并找出需要递归复制的部分。

因此,浅复制和深度复制之间的主要区别在于,在深度复制中,实际值是递归复制的,而在浅复制中,我们使用普通赋值来分配原始值

我们现在可以继续介绍 slices 包的功能。slicesPackage.gomain() 实现的第一部分如下:

func main() {
    s1 := []int{1, 2, -1, -2}
    s2 := slices.Clone(s1)
    s3 := slices.Clone(s1[2:])
    fmt.Println(s1[2], s2[2], s3[0])
    s1[2] = 0
    s1[3] = 0
    fmt.Println(s1[2], s2[2], s3[0]) 

slices.Clone() 函数返回给定切片的浅复制——元素通过赋值复制。在执行 s2 := slices.Clone(s1) 调用后,s1s2 相等,但它们的元素具有各自的内存空间。

第二部分如下:

 s1 = slices.Compact(s1)
    fmt.Println("s1 (compact):", s1)
    fmt.Println(slices.Contains(s1, 2), slices.Contains(s1, -2))
    s4 := make([]int, 10, 100)
    fmt.Println("Len:", len(s4), "Cap:", cap(s4))
    s4 = slices.Clip(s4)
    fmt.Println("Len:", len(s4), "Cap:", cap(s4)) 

slices.Compact() 函数将连续出现的相等元素替换为单个副本。因此,-1 -1 -1 将变成 -1,而 -1 0 -1 则不会改变。一般来说,slices.Compact() 在排序切片上工作得最好。

slices.Contains() 函数报告给定值是否存在于切片中。

slices.Clip() 函数从切片中移除未使用的容量。简单来说,切片的容量将等于切片的长度。当容量远大于切片长度时,这可以为您节省大量内存。

最后的部分包含以下代码:

 fmt.Println("Min", slices.Min(s1), "Max:", slices.Max(s1))
    // Replace s2[1] and s2[2]
    s2 = slices.Replace(s2, 1, 3, 100, 200)
    fmt.Println("s2 (replaced):", s2)
    slices.Sort(s2)
    fmt.Println("s2 (sorted):", s2)
} 

slices.Min()slices.Max() 函数分别返回切片中的最小值和最大值。

slices.Replace() 函数用提供的值替换给定范围内的元素,在这个例子中是 s2[1:3],这些值在这个例子中是 100200,并返回修改后的切片。最后,slices.Sort() 以升序对任何有序类型的值进行排序。

运行 slicesPackage.go 产生以下输出:

$ go run slicesPackage.go
-1 -1 -1
0 -1 -1
s1 (compact): [1 2 0]
true false
Len: 10 Cap: 100
Len: 10 Cap: 10
Min: 0 Max: 2
s2 (replaced): [1 100 200 -2]
s2 (sorted): [-2 1 100 200] 

您可以在切片的容量中看到 slices.Clip() 的效果,以及在 s2 切片的值中看到 slices.Replace() 的效果。

下一个部分将介绍 maps 包。

maps

maps 包自 Go 1.21 版本以来一直是标准 Go 库的一部分,并为任何类型的映射提供了函数——其用法在 mapsPackage.go 中得到了说明。

mapsPackage.go 程序使用了两个辅助函数,定义如下:

func delete(k string, v int) bool {
    return v%2 != 0
}
func equal(v1 int, v2 float64) bool {
    return float64(v1) == v2
} 

delete() 函数的目的是定义要从映射中删除哪些键值对——这个函数作为参数调用 maps.DeleteFunc()。当前实现对所有奇数值返回 true。这意味着所有奇数值及其键都将被删除。delete() 的第一个参数具有映射键的数据类型,而第二个参数具有映射值的数据类型。

equal() 函数的目的是定义两个映射的值如何定义相等。在这种情况下,我们想要比较 int 值和 float64 值。为了使其合法,我们需要将 int 值转换为 float64 值,这发生在 equal() 内部。

让我们继续实现 main() 函数。在 mapsPackage.go 中找到的 main() 函数的第一部分如下:

func main() {
    m := map[string]int{
        "one": 1, "two": 2,
        "three": 3, "four": 4,
    }
    maps.DeleteFunc(m, delete)
    fmt.Println(m) 

在之前的代码中,我们定义了一个名为 m 的映射,并调用 maps.DeleteFunc() 来删除其中的一些元素。

第二部分如下:

 n := maps.Clone(m)
    if maps.Equal(m, n) {
        fmt.Println("Equal!")
    } else {
        fmt.Println("Not equal!")
    }
    n["three"] = 3
    n["two"] = 22
    fmt.Println("Before n:", n, "m:", m)
    maps.Copy(m, n)
    fmt.Println("After n:", n, "m:", m) 

maps.Clone() 函数返回其参数的浅拷贝。之后,我们调用 maps.Equal() 来确保 maps.Clone() 正如预期那样工作。

maps.Copy(dst, src) 函数将 src 中的所有键值对复制到 dst 中。当 src 中的键已存在于 dst 中时,则 dst 中的值将被 src 中相应键的值覆盖。在我们的程序中,我们将 n 复制到 m 映射中。

最后一部分如下:

 t := map[string]int{
        "one": 1, "two": 2,
        "three": 3, "four": 4,
    }
    mFloat := map[string]float64{
        "one": 1.00, "two": 2.00,
        "three": 3.00, "four": 4.00,
    }
    eq := maps.EqualFunc(t, mFloat, equal)
    fmt.Println("Is t equal to mFloat?", eq)
} 

在最后一部分,我们通过创建两个映射来测试 maps.EqualFunc() 的操作,一个使用 int 值,另一个使用 float64 值,并根据我们之前创建的 equal() 函数进行比较。换句话说,maps.EqualFunc() 的目的是通过比较它们的函数参数来确定两个映射是否包含相同的键值对。

运行 mapsPackage.go 产生以下输出:

$ go run mapsPackage.go
map[four:4 two:2]
Equal!
Before n: map[four:4 three:3 two:22] m: map[four:4 two:2]
After n: map[four:4 three:3 two:22] m: map[four:4 three:3 two:22]
Is t equal to mFloat? true 

maps.DeleteFunc(m, delete) 语句删除所有值是奇数的键值对,使 m 只保留偶数值。此外,对 maps.Equal() 的调用返回 true,并在屏幕上显示 Equal! 消息。maps.Copy(m, n) 语句将 m["two"] 的值更改为 22,并将 three 键添加到 m 中,其值为 3,因为在调用 maps.Copy() 之前 m 中不存在该键。

何时使用泛型

泛型并非万能,不能取代良好的、准确的和理性的程序设计。因此,在考虑使用泛型解决问题时,以下是一些需要记住的原则和个人建议:

  • 当创建需要与多种数据类型一起工作的代码时,可能会使用泛型。

  • 当接口和反射的实现使代码比必要的更复杂、更难以理解时,应该使用泛型。

  • 此外,当预期未来要支持更多数据类型时,可能会使用泛型。

  • 再次强调,在编码时使用任何东西的目标是代码的简洁性和易于维护,而不是炫耀你的编码能力。

  • 最后,当开发者对泛型感到舒适时,可以使用泛型。没有 Go 规则强制使用泛型。

本节总结了本章内容。请注意,为了使用 cmpslicesmaps 包,你需要 Go 版本 1.21 或更高版本。

摘要

本章介绍了泛型,并解释了泛型发明的理由。此外,它还介绍了 Go 泛型的语法以及如果你不小心使用泛型可能会出现的一些问题。

当 Go 社区仍在努力探索如何使用泛型时,有两点是重要的:首先,如果你不想使用泛型或者对它们感到不舒服,你不必使用泛型;其次,当你正确使用泛型时,你将需要为支持多种数据类型编写更少的代码。

虽然泛型函数更灵活,但使用泛型的代码通常比使用预定义静态数据类型的代码运行得更慢。因此,你为灵活性付出的代价是执行速度。同样,使用泛型的 Go 代码的编译时间也比不使用泛型的等效代码长。一旦 Go 社区开始在现实场景中使用泛型,泛型提供最高生产力的案例将变得更加明显。最终,编程是关于理解你决策的成本。只有在这种情况下,你才能称自己为程序员。因此,理解使用泛型而不是接口、反射或其他技术的成本是很重要的。

下一章将介绍类型方法,这些方法是附加到数据类型上的函数、反射和接口。所有这些都将使我们能够进一步改进统计应用程序。此外,下一章将比较泛型与接口和反射,因为它们在使用上有重叠。

练习

尝试解决以下练习:

  • structures.go 中创建一个 PrintMe() 方法,用于打印链表的所有元素。

  • Go 1.21 版本附带了一个名为 clear 的新函数,该函数用于清除映射和切片。对于映射,它会删除所有条目,而对于切片,它会将所有现有值置零。尝试使用它来了解它是如何工作的。

  • 使用泛型实现 delete()search() 功能,针对 structures.go 中找到的链表。

  • 使用 structures.go 中找到的代码,使用泛型实现一个双链表。

其他资源

留下评论!

喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码,获取你选择的免费电子书。

第五章:反射与接口

你可能会想知道,如果你想要根据你自己的标准,如姓氏或数据集的平均值等统计属性,对用户定义的数据结构,如电话记录或数值数据,进行排序,会发生什么。当你想要对具有共同行为的不同数据集进行排序,而无需为每种不同的数据类型从头实现排序功能时,会发生什么?此外,想象一下,如果你想要编写一个对不常见数据进行排序的工具。例如,想象一下,你想要根据体积对包含各种 3D 形状的切片进行排序。这能轻松且合理地完成吗?

所有这些问题的答案和担忧都是使用接口。然而,接口不仅仅是关于数据操作和排序。接口是关于表达抽象、识别和定义可以在不同数据类型之间共享的行为。一旦你为数据类型实现了一个接口,该类型变量和值的全新功能世界就变得可用,这可以节省你的时间并提高你的生产力。接口与类型上的方法类型方法一起工作,这些方法类似于附加到给定数据类型上的函数,在 Go 中通常是结构体。在 Go 中,接口是隐式满足的。这意味着你不需要显式声明一个类型实现了接口。相反,如果一个类型为该接口声明了所有方法的实现,则认为该类型实现了接口。现在,让我们谈谈空接口,它由interface{}表示。空接口指定了零个方法,这意味着任何类型都满足空接口。这可能很强大,但也需要谨慎,因为它本质上意味着“我可以持有任何类型的值。”

另一个实用且高级的 Go 语言特性是反射,它允许你在运行时检查数据类型的内部结构。然而,由于反射是一个高级的 Go 语言特性,你可能不需要经常使用它。

本章涵盖:

  • 反射

  • 类型方法

  • 接口

  • Go 语言中的面向对象编程

  • 接口与泛型

  • 反射与泛型

  • 更新统计应用程序

反射

我们以反思作为本章的开端,这是 Go 语言的一个高级特性,不是因为这是一个容易的主题,而是因为它将帮助你理解 Go 如何与不同的数据类型一起工作,包括接口,以及为什么需要它。

你可能会想知道如何在运行时找出结构体的字段名称。在这种情况下,你需要使用反射。除了使你能够打印出结构和其值之外,反射还允许你探索和操作未知结构,例如从解码 JSON 数据创建的结构。

当我第一次接触反射时,我向自己提出了以下两个主要问题:

  • 为什么反射被包含在 Go 中?

  • 何时应该使用反射?

为了回答第一个问题,反射允许你动态地学习任意对象的类型及其结构信息。Go 提供了 reflect 包来处理反射。fmt.Println() 函数足够聪明,能够理解其参数的数据类型并相应地行动,因为,在幕后,fmt 包使用反射来完成这项工作。

关于第二个问题,反射允许你在编写代码时处理和操作那些当时不存在但未来可能存在的数据类型,这就是我们使用带有新用户定义数据类型的现有包的时候——Go 函数可以使用空接口接受未知的数据类型。此外,当你必须处理那些没有实现通用接口且因此具有不常见或未知行为的数据类型时,反射可能很有用——这并不意味着它们有不良或错误的行为,只是不常见或不寻常的行为,例如用户定义的结构体。

接口将在本章后面进行介绍,所以请继续关注更多内容!

Go 中泛型的引入可能会在某些情况下使反射的使用频率降低,因为,有了泛型,你可以更轻松地处理不同的数据类型,而无需事先知道它们的精确数据类型。然而,对于完全探索变量的结构和数据类型,没有什么能比得上反射。我们将在本章末尾比较反射和泛型。

reflect 包最有用的部分是两个名为 reflect.Valuereflect.Type 的数据类型。reflect.Value 用于存储任何类型的值,而 reflect.Type 用于表示 Go 类型。存在两个名为 reflect.TypeOf()reflect.ValueOf() 的函数,分别返回 reflect.Typereflect.Value 值。请注意,reflect.TypeOf() 返回变量的实际类型——如果我们正在检查一个结构体,它返回结构体的名称。

由于结构体在 Go 中非常重要,reflect 包提供了 reflect.NumField() 方法来列出结构体中的字段数量,以及 Field() 方法来获取结构体特定字段的 reflect.Value 值。

reflect 包还定义了 reflect.Kind 数据类型,它用于表示变量的具体数据类型:intstruct 等。reflect 包的文档列出了 reflect.Kind 数据类型的所有可能值。Kind() 函数返回变量的类型。

最后,Int()String() 方法分别返回 reflect.Value 的整数和字符串值。

反射代码有时看起来不美观且难以阅读。因此,根据 Go 哲学,除非必要,否则你应该很少使用反射,因为尽管它很巧妙,但它不会创建干净的代码。

理解 Go 结构的内部结构

下一个实用工具展示了如何使用反射来发现 Go 结构变量的内部结构和字段。输入它并将其保存为reflection.go

package main
import (
    "fmt"
"reflect"
)
type Secret struct {
    Username string
    Password string
}
type Record struct {
    Field1 string
    Field2 float64
    Field3 Secret
}
func main() {
    A := Record{"String value", -12.123, Secret{"Mihalis", "Tsoukalos"}} 

我们首先定义一个包含另一个结构值(Secret{"Mihalis", "Tsoukalos"})的Record结构变量。

 r := reflect.ValueOf(A)
    fmt.Println("String value:", r.String()) 

这返回了A变量的reflect.Value

 iType := r.Type() 

使用Type()是我们获取变量数据类型的方法——在这个例子中,变量A

 fmt.Printf("i Type: %s\n", iType)
    fmt.Printf("The %d fields of %s are\n", r.NumField(), iType)
    for i := 0; i < r.NumField(); i++ { 

之前的for循环允许访问结构的所有字段并检查它们的特征。

 fmt.Printf("\t%s ", iType.Field(i).Name)
        fmt.Printf("\twith type: %s ", r.Field(i).Type())
        fmt.Printf("\tand value _%v_\n", r.Field(i).Interface()) 

之前的fmt.Printf()语句返回字段的名称、数据类型和值。

 // Check whether there are other structures embedded in Record
        k := reflect.TypeOf(r.Field(i).Interface()).Kind()
        // Need to convert it to string in order to compare it
        if k.String() == "struct" { 

要使用字符串检查变量的数据类型,我们首先需要将数据类型转换为string变量。

 fmt.Println(r.Field(i).Type())
        }
        // Same as before but using the internal value
        if k == reflect.Struct { 

你也可以在检查过程中使用数据类型的内部表示。

 fmt.Println(r.Field(i).Type())
        }
    }
} 

运行reflection.go会产生以下输出:

$ go run reflection.go
String value: <main.Record Value>
i Type: main.Record
The 3 fields of main.Record are
        Field1  with type: string       and value _String value_
        Field2  with type: float64      and value _-12.123_
        Field3  with type: main.Secret  and value _{Mihalis Tsoukalos}_
main.Secret
main.Secret 

main.Record是 Go 定义的结构的全唯一名称——main是包名,Recordstruct名。这样 Go 可以区分不同包的元素。

所展示的代码没有修改结构的任何值。如果你要修改结构字段的值,你会使用Elem()方法并将结构作为指针传递给ValueOf()——记住,指针允许你修改实际变量。存在修改现有值的方法。在我们的例子中,我们将使用SetString()来修改string字段,并使用SetInt()来修改int字段。

这种技术在下一小节中得到了说明。

使用反射修改结构值

了解 Go 结构的内部结构很有用,但更实际的是能够修改 Go 结构中的值,这正是本小节的主题。然而,请注意,这种方法是一个例外,绝大多数 Go 程序不需要实现这一点。

输入以下 Go 代码并将其保存为setValues.go——它也可以在书的 GitHub 仓库中的ch05目录下找到。

package main
import (
    "fmt"
"reflect"
)
type T struct {
    F1 int
    F2 string
    F3 float64
}
func main() {
    A := T{1, "F2", 3.0} 

A是本程序中被检查的变量。

 fmt.Println("A:", A)
    r := reflect.ValueOf(&A).Elem() 

通过使用Elem()和变量A的指针,如果需要的话可以修改变量A

 fmt.Println("String value:", r.String())
    typeOfA := r.Type()
    for i := 0; i < r.NumField(); i++ {
        f := r.Field(i)
        tOfA := typeOfA.Field(i).Name
        fmt.Printf("%d: %s %s = %v\n", i, tOfA, f.Type(), f.Interface())
        k := reflect.TypeOf(r.Field(i).Interface()).Kind()
        if k == reflect.Int {
            r.Field(i).SetInt(-100)
        } else if k == reflect.String {
            r.Field(i).SetString("Changed!")
        }
    } 

我们使用SetInt()来修改整数值(reflect.Int)和SetString()来修改字符串值(reflect.String)。整数值被设置为-100,字符串值被设置为Changed!

 fmt.Println("A:", A)
} 

运行setValues.go会产生以下输出:

$ go run setValues.go
A: {1 F2 3}
String value: <main.T Value>
0: F1 int = 1
1: F2 string = F2
2: F3 float64 = 3
A: {-100 Changed! 3} 

输出的第一行显示了 A 的初始版本,而最后一行显示了经过修改的字段的最终版本 A。这种代码的主要用途是在不知道结构内部结构的情况下,动态地更改结构体字段的值。

反射的三个缺点

毫无疑问,反射是 Go 的一项强大功能。然而,与所有工具一样,反射应谨慎使用,主要有三个原因:

  • 第一个原因是过度使用反射会使你的程序难以阅读和维护。解决这个问题的一个潜在方法是良好的文档,但开发者以没有时间编写适当的文档而闻名。

  • 第二个原因是使用反射的 Go 代码会使你的程序变慢。一般来说,与特定数据类型工作的 Go 代码总是比使用反射动态处理任何 Go 数据类型的 Go 代码要快。此外,这种动态代码使得工具难以重构或分析你的代码。

  • 最后一个原因是反射错误无法在构建时捕获,而是在运行时作为恐慌报告,这意味着反射错误可能会潜在地使你的程序崩溃。这可能会在 Go 程序开发后的几个月甚至几年后发生!解决这个问题的一个方法是在危险函数调用之前进行广泛的测试。然而,这会在你的程序中添加更多的 Go 代码,使它们运行得更慢。

考虑到反射的缺点,重要的是要记住,反射在处理 JSON 和 XML 序列化、动态代码生成以及动态将 Go 结构体映射到数据库表等情况下是必需的。

既然我们已经了解了反射及其能为我们做什么,现在是时候开始讨论类型方法了,这对于理解接口是必要的。

类型方法

类型方法是一个附加到特定数据类型的函数。尽管类型方法(或类型上的方法)是函数,但实际上,它们的定义和使用方式略有不同。

在类型特性方法上,Go 语言获得了一些面向对象的能力,这非常方便,并且在 Go 语言中被广泛使用。此外,接口需要类型方法才能工作。

定义新的类型方法就像创建新的函数一样简单,只要你遵循某些规则,将这些函数与数据类型关联起来。

创建类型方法

所以,想象一下你想用 2x2 矩阵进行计算。实现这一点的非常自然的方式是通过定义一个新的数据类型,并定义类型方法来添加、减去和乘以使用该新数据类型的 2x2 矩阵。为了使其更加有趣和通用,我们将创建一个命令行实用程序,它接受两个 2x2 矩阵的元素作为命令行参数,总共八个整数值,并使用定义的类型方法在这两者之间执行所有三种计算。

通过拥有名为ar2x2的数据类型,你可以为它创建一个名为FunctionName的类型方法,如下所示:

func (a ar2x2) FunctionName(parameters) <return values> {
    ...
} 

(a ar2x2)这一部分使得FunctionName()函数成为一个类型方法,因为它将FunctionName()ar2x2数据类型关联起来。其他数据类型无法使用该函数。然而,你可以自由地为其他数据类型或作为常规函数实现FunctionName()。如果你有一个名为varArar2x2变量,你可以像选择结构变量字段一样调用FunctionName(),即varAr.FunctionName(...)

如果你不想开发类型方法,除非你正在处理接口,否则你没有义务这样做。此外,每个类型方法都可以重写为常规函数。因此,FunctionName()可以重写如下:

func FunctionName(a ar2x2, parameters...) <return values> {
    ...
} 

请记住,在底层,Go 编译器确实会将方法转换为带有自值的常规函数调用。然而,请注意,接口需要使用类型方法才能工作

用于选择结构字段或数据类型的类型方法的表达式,将替换上面变量名后的省略号,被称为选择器

在给定预定义大小的矩阵之间执行计算是使用数组而不是切片更合理的一些罕见情况之一,因为你不需要修改矩阵的大小。有些人可能会争论说,使用切片而不是数组指针是更好的做法——你可以使用对你和当前问题更有意义的方法。一般来说,当需要固定大小的连续内存块或性能是关键问题时,应首选数组。因此,对于大多数动态集合,通常使用切片,而在需要固定大小、性能关键的结构时,使用数组。

大多数情况下,类型方法的输出保存在调用类型方法的变量中——为了为ar2x2数据类型实现这一点,我们传递调用类型方法的数组的指针,例如func (a *ar2x2)

值接收器和指针接收器

如你所知,一个方法可以与一个命名类型关联,方法的接收者可以是值接收器或指针接收器。值接收器是与方法关联的接收器,该方法操作的是值的副本而不是实际的值本身。指针接收器是与方法关联的接收器,该方法直接操作接收器指向的值,而不是值的副本。

在值接收器和指针接收器之间的选择会影响方法的行为,特别是在修改底层值和性能考虑方面。一般来说,当方法不需要修改接收器的状态时,当处理小型、不可变类型时,或者对于逻辑上属于值本身而不是特定实例的方法时,建议使用值接收器。另一方面,当方法需要修改接收器的状态时,当处理大型数据结构以避免任何复制开销,或者对于逻辑上属于类型的特定实例的方法时,你可能更愿意使用指针接收器。

下一个小节将展示类型方法的应用。

使用类型方法

本小节以 ar2x2 数据类型为例,展示了类型方法的使用。Add() 函数和 Add() 方法使用完全相同的算法来添加两个矩阵。它们之间的唯一区别是它们的调用方式以及函数返回一个数组,而方法由于使用了指针,将结果保存到调用变量中。

虽然矩阵的加法和减法是一个简单的过程——你只需将第一个矩阵的每个元素与位于相同位置的第二个矩阵的元素相加或相减——但矩阵乘法是一个更复杂的过程。这是加法和减法都使用 for 循环的主要原因,这意味着代码也可以处理更大的矩阵,而乘法使用的是静态代码,不能在不进行重大更改的情况下应用于更大的矩阵。

如果你正在为结构定义类型方法,你应该确保类型方法的名称不与结构的任何字段名称冲突,因为 Go 编译器将拒绝这种歧义。

输入以下代码并将其保存为 methods.go

package main
import (
    "fmt"
"os"
"strconv"
)
type ar2x2 [2][2]int
// Traditional Add() function
func Add(a, b ar2x2) ar2x2 {
    c := ar2x2{}
    for i := 0; i < 2; i++ {
        for j := 0; j < 2; j++ {
            c[i][j] = a[i][j] + b[i][j]
        }
    }
    return c
} 

在这里,我们有一个传统的函数,它将两个 ar2x2 变量相加并返回它们的和。

// Type method Add()
func (a *ar2x2) Add(b ar2x2) {
    for i := 0; i < 2; i++ {
        for j := 0; j < 2; j++ {
            a[i][j] = a[i][j] + b[i][j]
        }
    }
} 

在这里,我们有一个名为 Add() 的类型方法,它附加到 ar2x2 数据类型上。加法的结果不会返回。发生的情况是调用 Add() 方法的 ar2x2 变量将被修改并保留该结果——这就是为什么在定义类型方法时使用指针的原因。如果你不希望这种行为,你应该修改类型方法的签名和实现以匹配你的需求。

// Type method Subtract()
func (a *ar2x2) Subtract(b ar2x2) {
    for i := 0; i < 2; i++ {
        for j := 0; j < 2; j++ {
            a[i][j] = a[i][j] - b[i][j]
        }
    }
} 

之前的方法从 ar2x2 b 中减去 ar2x2 a,并将结果保存在 a 变量中。

// Type method Multiply()
func (a *ar2x2) Multiply(b ar2x2) {
    a[0][0] = a[0][0]*b[0][0] + a[0][1]*b[1][0]
    a[1][0] = a[1][0]*b[0][0] + a[1][1]*b[1][0]
    a[0][1] = a[0][0]*b[0][1] + a[0][1]*b[1][1]
    a[1][1] = a[1][0]*b[0][1] + a[1][1]*b[1][1]
} 

由于我们正在处理小型数组,所以我们不使用任何 for 循环进行乘法。

func main() {
    if len(os.Args) != 9 {
        fmt.Println("Need 8 integers")
        return
    }
    k := [8]int{}
    for index, i := range os.Args[1:] {
        v, err := strconv.Atoi(i)
        if err != nil {
            fmt.Println(err)
            return
        }
        k[index] = v
    }
    a := ar2x2{{k[0], k[1]}, {k[2], k[3]}}
    b := ar2x2{{k[4], k[5]}, {k[6], k[7]}} 

main() 函数获取输入并创建两个 2x2 矩阵。之后,它使用这两个矩阵执行所需的计算。

 fmt.Println("Traditional a+b:", Add(a, b))
    a.Add(b)
    fmt.Println("a+b:", a)
    a.Subtract(a)
    fmt.Println("a-a:", a)
    a = ar2x2{{k[0], k[1]}, {k[2], k[3]}} 

我们使用两种不同的方式计算 a+b:使用常规函数和使用类型方法。由于 a.Add(b)a.Subtract(a) 都会改变 a 的值,我们必须在使用它之前初始化 a

 a.Multiply(b)
    fmt.Println("a*b:", a)
    a = ar2x2{{k[0], k[1]}, {k[2], k[3]}}
    b.Multiply(a)
    fmt.Println("b*a:", b)
} 

最后,我们计算 a*bb*a 来展示它们是不同的,因为交换律不适用于矩阵乘法。

运行 methods.go 产生以下输出:

$ go run methods.go 1 2 0 0 2 1 1 1
Traditional a+b: [[3 3] [1 1]]
a+b: [[3 3] [1 1]]
a-a: [[0 0] [0 0]]
a*b: [[4 6] [0 0]]
b*a: [[2 4] [1 2]] 

这里的输入是两个 2x2 矩阵,[[1 2] [0 0]][[2 1] [1 1]],输出是它们的计算结果。

现在我们已经了解了类型方法,是时候开始探索接口了,因为接口不能在没有类型方法的情况下实现。

接口

接口是 Go 定义行为的机制,该行为通过一组方法实现。接口在 Go 中起着核心作用,当程序需要处理执行相同任务的多达多个数据类型时,它们可以简化你的程序代码——回想一下 fmt.Println() 几乎适用于所有数据类型。

但请记住,接口不应该过于复杂。如果你决定创建自己的接口,那么你应该从一个你希望多个数据类型使用的共同行为开始。此外,你不应该通过定义接口来设计你的程序。你应该开始设计你的程序,等待共同行为显现出来,然后将这些共同行为转换为接口。最后,如果接口的使用没有使你的代码更简单,考虑移除一些或所有你的接口。

接口定义了零个、一个或多个需要实现类型方法。正如你已经知道的,一旦你实现了接口所需的所有类型方法,该接口就隐式满足。用更简单的话说,一旦你为给定数据类型实现了接口的方法,该接口就自动满足该数据类型。

空接口定义为 interface{}。由于空接口没有方法,这意味着它已经被所有数据类型实现。在 Go 泛型术语中,空接口被称为 any

以更正式的方式来说,Go 接口类型通过指定一组需要实现以支持该行为的方法来定义(或描述)其他类型的行为。为了满足一个接口,数据类型需要实现该接口要求的所有类型方法。因此,接口是抽象类型,它指定了一组需要实现的方法,以便另一个类型可以被考虑为接口的实例。所以,接口是两件事:一组方法和一个数据类型。请记住,小型且定义良好的接口通常是最受欢迎的,因为它们可以在更多的情况下使用。

作为一条经验法则,只有当你想在两个或多个具体数据类型之间共享共同行为时才创建新的接口。这基本上是鸭子类型。

从接口中获得的最大优势是,如果需要,你可以将实现了特定接口的数据类型的变量传递给任何期望该特定接口参数的函数,这让你免于为每种支持的数据类型编写单独的函数。然而,Go 通过最近添加泛型提供了这一点的替代方案。

接口也可以在 Go 中提供一种多态性,这是一种面向对象的概念。多态性提供了一种方式,当不同类型的对象具有共同的行为时,可以以相同统一的方式访问这些对象。

最后,接口还可以用于组合。在实践中,这意味着你可以组合现有的接口,创建新的接口,这些新接口提供了组合接口的行为。下一个图示以图形方式展示了接口组合。

图片

图 5.1:接口组合

简单来说,前一个图示说明了由于定义的原因,要满足接口ABC,需要满足InterfaceAInterfaceBInterfaceC。此外,任何ABC变量都可以用来代替InterfaceA变量、InterfaceB变量或InterfaceC变量,因为它支持这三种行为。最后,只有ABC变量可以在期望ABC变量的地方使用。如果现有接口的组合不能准确描述所需的行为,你可以在ABC接口的定义中包含额外的函数,这并不会受到任何限制。

当组合现有接口时,最好这些接口不包含具有相同名称的方法。

你应该记住的是,接口不需要令人印象深刻,也不需要实现大量的方法。实际上,接口拥有的方法越少,它就越通用、越广泛使用,这增加了它的有用性,因此也增加了它的使用频率。

下一个子节说明了sort.Interface的使用。

sort.Interface接口

sort包包含一个名为sort.Interface的接口,它允许你根据需要和你的数据对切片进行排序,前提是你为存储在你切片中的自定义数据类型实现了sort.Interfacesort包定义sort.Interface如下:

type Interface interface {
    // Len is the number of elements in the collection.
    Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
    Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
    Swap(i, j int)
} 

sort.Interface的定义中我们可以理解,为了实现sort.Interface,我们需要实现以下三个类型方法:

  • Len() int

  • Less(i, j int) bool

  • Swap(i, j int)

Len() 方法返回将要排序的切片的长度,并帮助接口处理所有切片元素,而 Less() 方法,它比较并成对排序元素,定义了元素如何进行比较和排序。Less() 的返回值是 bool,这意味着 Less() 只关心索引 i 的元素是否大于索引 j 的元素,以及这两个元素是如何进行比较的。最后,Swap() 方法用于交换切片中的两个元素,这对于排序算法的正常工作是必需的。

以下代码,可以在 sort.go 中找到,说明了 sort.Interface 的使用。

package main
import (
    "fmt"
"sort"
)
type Size struct {
    F1 int
    F2 string
    F3 int
}
// We want to sort Person records based on the value of Size.F1
// Which is Size.F1 as F3 is an Size structure
type Person struct {
    F1 int
    F2 string
    F3 Size
} 

Person 结构体包含一个名为 F3 的字段,其数据类型为 Size,它也是一个结构体。

type Personslice []Person 

你需要一个切片,因为所有排序操作都是在切片上进行的。这就是你将要实现 sort.Interface 的三个类型方法的切片,它应该是一个新的数据类型,在这种情况下称为 Personslice

// Implementing sort.Interface for Personslice
func (a Personslice) Len() int {
    return len(a)
} 

这里是 Personslice 数据类型的 Len() 的实现。这通常很简单。

// What field to use when comparing
func (a Personslice) Less(i, j int) bool {
    return a[i].F3.F1 < a[j].F3.F1
} 

这里是 Personslice 数据类型的 Less() 的实现。该方法定义了元素排序的方式。在这种情况下,通过使用嵌入的数据结构的一个字段(F3.F1)。

func (a Personslice) Swap(i, j int) {
    a[i], a[j] = a[j], a[i]
} 

这是 Swap() 类型方法的实现,它定义了在排序过程中交换切片元素的方式。这通常很简单。

func main() {
    data := []Person{
        Person{1, "One", Size{1, "Person_1", 10}},
        Person{2, "Two", Size{2, "Person_2", 20}},
        Person{-1, "Two", Size{-1, "Person_3", -20}},
    }

    fmt.Println("Before:", data)
    sort.Sort(Personslice(data))
    fmt.Println("After:", data)
    // Reverse sorting works automatically
    sort.Sort(sort.Reverse(Personslice(data)))
    fmt.Println("Reverse:", data)
} 

一旦实现了 sort.Interface,你就会看到 sort.Reverse(),它是用于反转排序切片的,会自动工作。

运行 sort.go 生成以下输出:

$ go run sort.go
Before: [{1 One {1 Person_1 10}} {2 Two {2 Person_2 20}} {-1 Two {-1 Person_3 -20}}]
After: [{-1 Two {-1 Person_3 -20}} {1 One {1 Person_1 10}} {2 Two {2 Person_2 20}}]
Reverse: [{2 Two {2 Person_2 20}} {1 One {1 Person_1 10}} {-1 Two {-1 Person_3 -20}}] 

第一行显示了切片最初存储的元素。第二行显示了排序后的版本,而最后一行显示了反转排序后的版本。

现在我们来介绍方便的空接口。

空接口

如前所述,空接口被定义为仅 interface{},并且所有数据类型都已经实现了它。因此,任何数据类型的变量都可以放在空接口数据类型参数的位置。

因此,具有 interface{} 参数的函数可以在这个位置接受任何数据类型的变量。然而,如果你打算在函数内部不检查其数据类型的情况下使用 interface{} 函数参数,你应该使用适用于所有数据类型的语句来处理它们;否则,你的代码可能会崩溃或出现异常行为。或者,你可以使用具有适当约束的泛型来避免任何不期望的效果。

下面的程序定义了两个名为 S1S2 的结构体,但只有一个名为 Print() 的函数用于打印它们中的任何一个。这是允许的,因为 Print() 需要一个 interface{} 参数,它可以接受 S1S2 变量。Print() 中的 fmt.Println(s) 语句可以与 S1S2 一起工作。

如果你创建一个接受一个或多个 interface{} 参数的函数,并且运行只能应用于有限数据类型的语句,事情可能不会顺利。作为一个例子,并不是所有的 interface{} 参数都可以乘以 5 或使用 %d 控制字符串在 fmt.Printf() 中使用。

empty.go 的源代码如下:

package main
import "fmt"
type S1 struct {
    F1 int
    F2 string
}
type S2 struct {
    F1 int
    F2 S1
}
func Print(s interface{}) {
    fmt.Println(s)
}
func main() {
    v1 := S1{10, "Hello"}
    v2 := S2{F1: -1, F2: v1}
    Print(v1)
    Print(v2) 

虽然 v1v2 的数据类型不同,但 Print() 可以同时处理它们。

 // Printing an integer
    Print(123)
    // Printing a string
    Print("Go is the best!")
} 

Print() 也可以与整数和字符串一起使用。

运行 empty.go 产生以下输出:

{10 Hello}
{-1 {10 Hello}}
123
Go is the best! 

一旦你意识到你可以在 interface{} 参数的位置传递任何类型的变量,并且你可以将任何数据类型作为 interface{} 返回值返回,使用空接口就变得简单了。然而,权力越大,责任越大——你应该非常小心地处理 interface{} 参数及其返回值,因为,为了使用它们的实际值,你必须确定它们的底层数据类型。我们将在下一节讨论这个问题。

类型断言和类型选择

类型断言 是一种处理接口底层具体值的机制。这主要是因为接口是虚拟数据类型,没有它们自己的值——接口只定义行为,不持有自己的数据。但是,在你尝试类型断言之前不知道数据类型会发生什么?你如何区分支持的数据类型和不支持的数据类型?你如何为每个支持的数据类型选择不同的操作?答案是使用类型选择。类型选择使用 switch 块来处理数据类型,并允许你区分类型断言值,即数据类型,并以你想要的方式处理每个数据类型。此外,要在类型选择中使用空接口,你需要使用类型断言

你可以为所有类型的接口和数据类型使用类型选择。真正重要的是要记住,switch 语句中 case 子句的顺序很重要,因为只有第一个匹配将会被执行。

因此,一旦你进入函数,真正的任务就开始了,因为这是你需要定义支持的数据类型以及每个支持的数据类型所采取的操作的地方。

类型断言使用 x.(T) 语法,其中 x 是接口类型,T 是类型,并帮助你提取隐藏在空接口背后的值。为了使类型断言生效,x 应该不是 nil,并且 x 的动态类型应该与 T 类型相同。

以下代码可以在 typeSwitch.go 中找到。

package main
import "fmt"
type Secret struct {
    SecretValue string
}
type Entry struct {
    F1 int
    F2 string
    F3 Secret
}
func Teststruct(x interface{}) {
    // type switch
switch T := x.(type) {
    case Secret:
        fmt.Println("Secret type")
    case Entry:
        fmt.Println("Entry type")
    default:
        fmt.Printf("Not supported type: %T\n", T)
    }
} 

这是一个只直接支持 SecretEntry 数据类型的类型选择。default 情况处理剩余的数据类型。

func Learn(x interface{}) {
    switch T := x.(type) {
    default:
        fmt.Printf("Data type: %T\n", T)
    }
} 

Learn() 函数打印其输入参数的数据类型。

func main() {
    A := Entry{100, "F2", Secret{"myPassword"}}
    Teststruct(A)
    Teststruct(A.F3)
    Teststruct("A string")
    Learn(12.23)
    Learn('€')
} 

代码的最后部分调用了所需的函数来探索变量 A。运行 typeSwitch.go 产生以下输出:

$ go run typeSwitch.go
Entry type
Secret type
Not supported type: string
Data type: float64
Data type: int32 

如你所见,我们已经成功根据传递给TestStruct()Learn()的变量的数据类型执行了不同的代码。

严格来说,类型断言允许你执行两个主要任务。第一个任务是检查接口值是否保持特定的类型。当这样使用时,类型断言返回两个值:底层值和一个bool值。底层值可能是你想要使用的。然而,是bool变量的值告诉你类型断言是否成功,因此,你是否可以使用底层值。例如,检查名为aVar的变量是否为int类型需要使用aVar.(int)的表示法,它返回两个值。如果成功,它返回aVar的实际int值和true。否则,它返回第二个值为false,这意味着类型断言没有成功,实际值无法提取。第二个任务是使用接口中存储的具体值或将它赋给新变量。这意味着如果接口中有一个float64变量,类型断言允许你获取该值。

reflect包提供的功能帮助 Go 识别接口变量的底层数据类型和实际值。

到目前为止,我们已经看到了第一种情况的变体,其中我们从空接口变量中提取了存储的数据类型。现在,我们将学习如何从空接口变量中提取存储的实际值。正如之前解释的那样,尝试使用类型断言从接口中提取具体值可能有两种结果:

  • 如果你使用正确的具体数据类型,你可以无任何问题地获取底层值。

  • 如果你使用不正确的具体数据类型,你的程序将会崩溃。

所有这些都在assertions.go中得到了展示,其中还包含了下一行代码以及大量的代码注释,解释了整个过程。

package main
import (
    "fmt"
)
func returnNumber() interface{} {
    return 12
}
func main() {
    anInt := returnNumber() 

returnNumber()函数返回一个被空接口包裹的int类型值。

 Number, ok := anInt.(int)
    if ok {
        fmt.Println("Type assertion successful: ", number)
    } else {
        fmt.Println("Type assertion failed!")
    }
    number++
    fmt.Println(number) 

在之前的代码中,我们得到了被空接口变量(anInt)包裹的int值。

 // The next statement would fail because there
// is no type assertion to get the value:
// anInt++
// The next statement fails but the failure is under 
// control because of the ok bool variable that tells
// whether the type assertion is successful or not
    value, ok := anInt.(int64)
    if ok {
        fmt.Println("Type assertion successful: ", value)
    } else {
        fmt.Println("Type assertion failed!")
    }
    // The next statement is successful but 
// dangerous because it does not make sure that
// the type assertion is successful.
// It just happens to be successful
    i := anInt.(int)
    fmt.Println("i:", i)
    // The following will PANIC because anInt is not bool
    _ = anInt.(bool)
} 

最后一条语句会导致程序崩溃,因为anInt变量不包含bool类型的值。运行assertions.go会生成以下输出:

$ go run assertions.go
13
Type assertion failed!
i: 12
panic: interface conversion: interface {} is int, not bool
goroutine 1 [running]:
main.main()
    /Users/mtsouk/mGo4th/code/ch05/assertions.go:39 +0x130
exit status 2 

崩溃的原因会显示在屏幕上:panic: interface conversion: interface {} is int, not bool。Go 编译器还能做些什么来帮助你?

接下来,我们讨论map[string]interface{}映射及其用法。

map[string]interface{}映射

您有一个处理其命令行参数的实用程序;如果一切如预期进行,那么您将得到支持的命令行参数类型,一切都会顺利。但是,当发生意外情况时会发生什么?在这种情况下,map[string]interface{}映射就在这里帮助,本节将展示如何使用!这只是map[string]interface{}映射方便之处的一个例子。

记住,您从使用map[string]interface{}映射或任何存储interface{}值的映射中获得的最大优势是,您仍然拥有您数据在其原始状态和数据类型。如果您使用map[string]string或类似的东西,那么您拥有的任何数据都将被转换为字符串,这意味着您将失去有关原始数据类型和您在映射中存储的数据结构的任何信息。

现在,网络服务通过交换 JSON 记录来工作。如果您以预期的格式收到一个 JSON 记录,那么您可以按预期处理它,一切都会顺利。然而,有时您可能会收到一个错误的记录或一个不支持的 JSON 格式的记录。在这些情况下,使用map[string]interface{}来存储这些未知的 JSON 记录(任意数据)是一个不错的选择,因为map[string]interface{}擅长存储未知类型的 JSON 记录。我们将使用名为mapEmpty.go的实用程序来展示这一点,该实用程序处理作为命令行参数给出的任意 JSON 记录。我们以两种相似但不相同的方式处理输入的 JSON 记录。exploreMap()typeSwitch()函数之间没有真正的区别,除了typeSwitch()生成更丰富的输出之外。mapEmpty.go的代码如下:

package main
import (
    "encoding/json"
"fmt"
"os"
)
var JSONrecord = `{
    "Flag": true,
    "Array": ["a","b","c"],
    "Entity": {
      "a1": "b1",
      "a2": "b2",
      "Value": -456,
      "Null": null
    },
    "Message": "Hello Go!"
  }` 

这个全局变量持有JSONrecord的默认值,以防没有用户输入。

func typeSwitch(m map[string]interface{}) {
    for k, v := range m {
        switch c := v.(type) {
        case string:
            fmt.Println("Is a string!", k, c)
        case float64:
            fmt.Println("Is a float64!", k, c)
        case bool:
            fmt.Println("Is a Boolean!", k, c)
        case map[string]interface{}:
            fmt.Println("Is a map!", k, c)
            typeSwitch(v.(map[string]interface{}))
        default:
            fmt.Printf("...Is %v: %T!\n", k, c)
        }
    }
    return
} 

typeSwitch()函数使用类型选择来区分其输入映射中的值。如果找到一个映射,那么我们将递归地调用typeSwitch()来检查新的映射,以便更深入地检查它。for循环允许您检查map[string]interface{}映射的所有元素。

func exploreMap(m map[string]interface{}) {
    for k, v := range m {
        embMap, ok := v.(map[string]interface{})
        // If it is a map, explore deeper
if ok {
            fmt.Printf("{\"%v\": \n", k)
            exploreMap(embMap)
            fmt.Printf("}\n")
        } else {
            fmt.Printf("%v: %v\n", k, v)
        }
    }
} 

exploreMap()函数检查其输入映射的内容。如果找到一个映射,那么我们将递归地调用exploreMap()来检查新的映射,以便单独检查它。

func main() {
    if len(os.Args) == 1 {
        fmt.Println("*** Using default JSON record.")
    } else {
        JSONrecord = os.Args[1]
    }
    JSONMap := make(map[string]interface{})
    err := json.Unmarshal([]byte(JSONrecord), &JSONMap) 

如您将在第七章中学习的,“告诉 UNIX 系统做什么”json.Unmarshal()处理 JSON 数据并将其转换为 Go 值。尽管这个值通常是 Go 结构体,但在这种情况下,我们使用map[string]interface{}变量指定的映射。严格来说,json.Unmarshal()的第二个参数是空接口数据类型,这意味着它的数据类型可以是任何类型。

 if err != nil {
        fmt.Println(err)
        return
    }
    exploreMap(JSONMap)
    typeSwitch(JSONMap)
} 

当你事先不知道其模式时,map[string]interface{} 非常方便用于存储 JSON 记录。换句话说,map[string]interface{} 擅长存储未知模式的任意 JSON 数据

运行 mapEmpty.go 产生以下输出——请注意,你可能看到不同的输出,因为映射不保证顺序:

$ go run mapEmpty.go 
*** Using default JSON record.
Flag: true
Array: [a b c]
{"Entity":
a2: b2
Value: -456
Null: <nil>
a1: b1
}
Message: Hello Go!
...Is Array: []interface {}!
Is a map! Entity map[Null:<nil> Value:-456 a1:b1 a2:b2]
Is a float64! Value -456
...Is Null: <nil>!
Is a string! a1 b1
Is a string! a2 b2
Is a string! Message Hello Go!
Is a Boolean! Flag true
$ go run mapEmpty.go '{"Array": [3, 4], "Null": null, "String": "Hello Go!"}'
Array: [3 4]
Null: <nil>
String: Hello Go!
...Is Array: []interface {}!
...Is Null: <nil>!
Is a string! String Hello Go!
$ go run mapEmpty.go '{"Array":"Error"' 
unexpected end of JSON input 

第一次运行时没有任何命令行参数,这意味着工具使用了 JSONrecord 的默认值,因此输出了硬编码的数据。其他两次执行使用用户指定的数据。首先,有效数据,然后是表示无效 JSON 记录的数据。第三次执行的错误信息是由 json.Unmarshal() 生成的,因为它无法理解错误 JSON 记录的模式。

错误数据类型

正如承诺的那样,我们正在重新审视 error 数据类型,因为它是一个如下定义的接口:

type error interface {
    Error() string
} 

因此,为了满足 error 接口,你只需要实现 Error() 字符串类型方法。这并不会改变我们使用错误来确定函数或方法执行是否成功的方式,但它显示了接口在 Go 中的重要性,因为它们始终被透明地使用。然而,关键问题是当你应该自己实现 error 接口而不是使用默认接口时。这个问题的答案是当你想要给错误条件提供更多上下文时。

现在,让我们在更实际的情况下讨论 error 接口。当没有更多内容可以读取文件时,Go 返回一个 io.EOF 错误,严格来说,这并不是一个错误条件,而是读取文件逻辑的一部分。如果文件完全为空,当你第一次尝试读取它时,你仍然会得到 io.EOF。然而,这可能在某些情况下引起问题,你可能需要有一种方法来区分一个完全为空的文件和一个已经完全读取且没有更多内容可以读取的文件。处理这个问题的方法之一是借助 error 接口。

这里展示的代码示例与文件 I/O 相关。将其放在这里可能会产生一些关于 Go 中读取文件的问题——然而,我觉得这是放置它的合适位置,因为它与错误和错误处理比与 Go 中的文件读取更相关。

errorInt.go 代码块中,没有包含包和 import 块的内容如下:

type emptyFile struct {
    Ended bool
    Read  int
} 

emptyFile 是程序中使用的新数据类型。

// Implement error interface
func (e emptyFile) Error() string {
    return fmt.Sprintf("Ended with io.EOF (%t) but read (%d) bytes", e.Ended, e.Read)
} 

这是 emptyFileerror 接口实现。

// Check values
func isFileEmpty(e error) bool {
    // Type assertion
    v, ok := e.(emptyFile) 

这是一个类型断言,用于从 error 变量获取 emptyFile 结构。

 if ok {
        if v.Read == 0 && v.Ended == true {
            return true
        }
    }
    return false
} 

这是一个检查文件是否为空的方法。if 条件翻译为:如果你已经读取了零字节(v.Read == 0)并且你已经到达了文件的末尾(v.Ended == true),那么文件是空的。

如果你想要处理多个 error 变量,你应该在类型断言之后向 isFileEmpty() 函数添加一个类型选择。

func readFile(file string) error {
    var err error
    fd, err := os.Open(file)
    if err != nil {
        return err
    }
    defer fd.Close()
    reader := bufio.NewReader(fd)
    n := 0
for {
        line, err := reader.ReadString('\n')
        n += len(line) 

我们逐行读取输入文件——你将在 第七章告诉 UNIX 系统做什么 中了解更多关于文件 I/O 的内容。

 if err == io.EOF {
            // End of File: nothing more to read
if n == 0 {
                return emptyFile{true, n}
            } 

如果我们到达了文件末尾 (io.EOF) 并且我们读取了零个字符,那么我们正在处理一个空文件。这种上下文被添加到 emptyFile 结构中,并作为错误值返回。

 break
        } else if err != nil {
            return err
        }
    }
    return nil
}
func main() {
    flag.Parse()
    if len(flag.Args()) == 0 {
        fmt.Println("usage: errorInt <file1> [<file2> ...]")
        return
    }
    for _, file := range flag.Args() {
        err := readFile(file)
        if isFileEmpty(err) {
            fmt.Println(file, err) 

这是我们检查 readFile() 函数的错误信息的地方。我们检查的顺序很重要,因为只有第一个匹配项会被执行。这意味着我们必须从更具体的案例到更通用的案例

 } else if err != nil {
            fmt.Println(file, err)
        } else {
            fmt.Println(file, "is OK.")
        }
    }
} 

运行 errorInt.go 产生以下输出——你的输出可能会有所不同:

$ go run errorInt.go /etc/hosts /tmp/doesNotExist /tmp/empty /tmp /tmp/Empty.txt
/etc/hosts is OK.
/tmp/doesNotExist open /tmp/doesNotExist: no such file or directory
/tmp/empty open /tmp/empty: permission denied
/tmp read /tmp: is a directory
/tmp/Empty.txt Ended with io.EOF (true) but read (0) bytes 

第一个文件 (/etc/hosts) 读取时没有问题,而第二个文件 (/tmp/doesNotExist) 找不到。第三个文件 (/tmp/empty) 存在,但我们没有读取所需的文件权限,而第四个文件 (/tmp) 实际上是一个目录。最后一个文件 (/tmp/Empty.txt) 存在,但为空,这是我们最初想要捕获的错误情况。

编写你自己的接口

在本节中,我们将学习如何编写我们自己的接口。创建自己的接口很容易。出于简单起见,我们将我们的接口包含在 main 包中。然而,这种情况很少见,因为我们通常希望共享我们的接口,这意味着接口通常包含在 Go 包的 main 之外。

以下代码片段定义了一个新的接口。

type Shape2D interface {
    Perimeter() float64
} 

此接口具有以下属性:

  • 它被称为 Shape2D

  • 它需要实现一个名为 Perimeter() 的单个方法,该方法返回一个 float64 值。

与内置的 Go 接口相比,该接口除了是用户定义之外,没有其他特殊之处——你可以像使用所有其他现有接口一样使用它。因此,为了使一个数据类型满足 Shape2D 接口,它需要实现一个名为 Perimeter() 的类型方法,该方法返回一个 float64 值。

使用 Go 接口

下面的代码展示了使用接口的最简单方式,即直接调用其方法,就像调用函数一样,以获取结果。虽然这是允许的,但这种情况很少见,因为我们通常创建接受接口参数的函数,以便这些函数能够与多种数据类型一起工作。

代码使用了一种方便的技术,可以快速找出给定变量是否为之前在 assertions.go 中展示的给定数据类型。在这种情况下,我们通过使用 interface{}(a).(Shape2D) 语法来检查变量是否为 Shape2D 接口,其中 a 是正在检查的变量,而 Shape2D 是正在检查的变量数据类型。

下一个程序被称为 Shape2D.go——其最有趣的部分如下:

type Shape2D interface {
    Perimeter() float64
} 

这是Shape2D接口的定义,它要求实现Perimeter()类型方法。

type circle struct {
    R float64
}
func (c circle) Perimeter() float64 {
    return 2 * math.Pi * c.R
} 

这是circle类型通过实现Perimeter()类型方法来实现Shape2D接口的地方。

func main() {
    a := circle{R: 1.5}
    fmt.Printf("R %.2f -> Perimeter %.3f \n", a.R, a.Perimeter())
    _, ok := interface{}(a).(Shape2D)
    if ok {
        fmt.Println("a is a Shape2D!")
    }
    i := 12
    _, ok = interface{}(i).(Shape2D)
    if ok {
        fmt.Println("i is a Shape2D!")
    }
} 

如前所述,interface{}(a).(Shape2D)表示法检查a变量是否满足Shape2D接口,而不使用其底层值(circle{R: 1.5})。

运行Shape2D.go会生成以下输出:

R 1.50 -> Perimeter 9.425 
a is a Shape2D! 

Go 语言中的面向对象编程

由于 Go 不支持所有面向对象特性,它不能完全替代面向对象编程语言。然而,它可以模仿一些面向对象的概念。

首先,一个具有其类型方法的 Go 结构体就像一个具有其方法的对象。其次,接口就像定义行为和同一类对象的抽象数据类型,这与多态类似。第三,Go 支持封装,这意味着它通过将数据和方法设置为结构体和当前 Go 包的私有来隐藏数据和方法,从而支持隐藏数据和方法。最后,接口和结构的组合在面向对象术语中类似于组合。

如果你真的想使用面向对象的方法开发应用程序,那么选择 Go 可能不是你的最佳选择。由于我对 Java 并不真正感兴趣,我建议你看看 C++或 Python。这里的通用规则是选择最适合你工作的工具。

你已经在本章前面看到了一些这些点——下一章将讨论如何定义私有字段和函数。接下来的例子,命名为objO.go,展示了组合和多态,以及将匿名结构体嵌入现有结构体以获取所有字段。

package main
import (
    "fmt"
)
type IntA interface {
    foo()
}
type IntB interface {
    bar()
}
type IntC interface {
    IntA
    IntB
} 

IntC接口结合了IntAIntB接口。如果你为数据类型实现了IntAIntB,那么这个数据类型就隐式地满足IntC

type a struct {
    XX int
    YY int
}
type b struct {
    AA string
    XX int
}
type c struct {
    A a
    B b
} 

此结构有两个字段,分别命名为AB,它们的类型分别是ab

func processA(s IntA) {
    fmt.Printf("%T\n", s)
} 

此函数与满足IntA接口的数据类型一起工作。

// Satisfying IntA
func (varC c) foo() {
    fmt.Println("Foo Processing", varC)
} 

结构c通过实现foo()满足IntA

// Satisfying IntB
func (varC c) bar() {
    fmt.Println("Bar Processing", varC)
} 

现在,结构c满足IntB。由于结构c同时满足IntAIntB,它隐式地满足IntC,这是IntAIntB接口的组合。

// Structure compose gets the fields of structure a
type compose struct {
    field1 int
    a
} 

这个新的结构使用了一个匿名结构体(a),这意味着它获取了该匿名结构体的字段。

// Different structures can have methods with the same name
func (A a) A() {
    fmt.Println("Function A() for A")
}
func (B b) A() {
    fmt.Println("Function A() for B")
}
func main() {
    var iC c = c{a{120, 12}, b{"-12", -12}} 

在这里,我们定义了一个由a结构和b结构组成的c变量。

 iC.A.A()
    iC.B.A() 

在这里,我们访问了a结构体的一个方法(A.A())和b结构体的a方法(B.A())。

 // The following will not work
// iComp := compose{field1: 123, a{456, 789}}
// iComp := compose{field1: 123, XX: 456, YY: 789}
    iComp := compose{123, a{456, 789}}
    fmt.Println(iComp.XX, iComp.YY, iComp.field1) 

当在另一个结构体内部使用匿名结构体时,就像我们用a{456, 789}做的那样,你可以直接访问匿名结构体的字段,即a{456, 789}结构体,作为iComp.XXiComp.YY

 iC.bar()
    processA(iC)
} 

虽然processA()IntA变量一起工作,但它也可以与IntC变量一起工作,因为IntC接口满足IntA

与支持抽象类和继承的真正面向对象编程语言的代码相比,objO.go 中的所有代码都很简单。然而,它对于生成具有内部结构的类型和元素以及具有相同方法名称的不同数据类型来说已经足够了。

运行 objO.go 产生以下输出:

$ go run objO.go
Function A() for A
Function A() for B
456 789 123
Bar Processing {{120 12} {-12 -12}}
main.c 

输出的前两行显示,两种不同的结构可以有相同名称的方法。第三行证明了当在一个结构内部使用匿名结构时,可以直接访问匿名结构的字段。第四行是 iC.bar() 调用的输出,其中 iC 是一个 c 变量,它从 IntB 接口访问方法。最后一行是 processA(iC) 的输出,它需要一个 IntA 参数并打印其参数的实际数据类型,在这种情况下是 main.c

显然,尽管 Go 不是面向对象的编程语言,但它可以模仿一些面向对象语言的特征。

下一节讨论了使用接口和泛型支持多种数据类型之间的差异。

接口与泛型

本节展示了一个程序,它使用接口和泛型将数值增加一,以便您可以比较实现细节。

genericsInterfaces.go 的代码展示了这两种技术,并包含了以下代码。

package main
import (
    "fmt"
)
type Numeric interface {
    int | int8 | int16 | int32 | int64 | float64
} 

这是我们定义一个名为 Numeric 的约束,以限制允许的数据类型。

func Print(s interface{}) {
    // type switch
switch s.(type) { 

Print() 函数使用空接口来获取输入,并使用类型选择来处理该输入参数。

简而言之,我们正在使用类型选择来区分受支持的数据类型——在这种情况下,受支持的数据类型只是 intfloat64,这与 类型选择 的实现有关。然而,添加更多数据类型需要代码更改,当需要支持大量数据类型时,这不是最有效率的解决方案。

 case int:
        fmt.Println(s.(int)+1) 

这个分支是我们处理 int 情况的方式。

 case float64:
        fmt.Println(s.(float64)+1) 

这个分支是我们处理 float64 情况的方式。

 default:
        fmt.Println("Unknown data type!")
    }
} 

default 分支是我们处理所有不支持的数据类型的方式。

Print() 函数的最大问题是由于使用了空接口,它接受所有类型的输入。因此,函数签名无法帮助我们限制允许的数据类型。Print() 的第二个问题是我们需要特别处理每个情况——处理更多的情况意味着需要编写更多的代码。

另一方面,编译器和开发者不需要用那种代码猜测很多事情,而泛型则不同,编译器和运行时需要做更多的工作。这种工作引入了执行时间上的延迟。

func PrintGenericsT any {
    fmt.Println(s)
} 

PrintGenerics() 是一个泛型函数,可以简单优雅地处理所有可用的数据类型。

func PrintNumericT Numeric {
    fmt.Println(s+1)
} 

PrintNumeric() 函数通过使用 Numeric 约束支持所有数值数据类型。无需为支持每种不同的数据类型专门添加代码,正如 Print() 函数所做的那样。

func main() {
    Print(12)
    Print(-1.23)
    Print("Hi!") 

main() 函数的第一部分使用 Print() 函数处理各种类型的输入:一个 int 值、一个 float64 值和一个 string 值,分别。

 PrintGenerics(1)
    PrintGenerics("a")
    PrintGenerics(-2.33) 

如前所述,PrintGenerics() 与所有数据类型一起工作,包括 string 值。

 PrintNumeric(1)
    PrintNumeric(-2.33)
} 

main() 函数的最后部分仅使用 PrintNumeric() 和数值一起使用,这是由于使用了 Numeric 约束。

运行 genericsInterfaces.go 产生以下输出:

13
-0.22999999999999998
Unknown data type! 

输出的前三行来自 Print() 函数,该函数使用空接口。

1
a
-2.33 

输出的前三行来自使用泛型的 PrintGenerics() 函数,它支持所有可用的数据类型。因此,它不应该盲目增加其输入的值,因为我们不能确定我们正在处理一个数值。因此,它只是打印给定的输入。

2
-1.33 

最后两行是由两个 PrintNumeric() 调用生成的,它们使用 Numeric 约束操作。

因此,在实践中,当你必须支持多种数据类型时,使用泛型可能比使用接口是一个更好的选择。然而,当我们想要定义和使用特定的行为时,接口比泛型更好,也更具有描述性。这些情况包括使用 Reader 接口读取数据或使用 Writer 接口写入数据。

下一节讨论了使用反射作为绕过泛型使用的方法。

反射与泛型

在本节中,我们开发了一个实用程序,以两种方式打印切片的元素:首先,使用反射;其次,使用泛型。

genericsReflection.go 的代码如下:

package main
import (
    "fmt"
"reflect"
)
func PrintReflection(s interface{}) {
    fmt.Println("** Reflection")
    val := reflect.ValueOf(s)
    if val.Kind() != reflect.Slice {
        return
    }
    for i := 0; i < val.Len(); i++ {
        fmt.Print(val.Index(i).Interface(), " ")
    }
    fmt.Println()
} 

在内部,PrintReflection() 函数仅与切片一起工作。然而,由于我们无法在函数签名中表达这一点,我们需要接受一个空接口参数。简单地说,与其指定所有类型的切片,不如使用空接口更合理。此外,我们必须编写更多的代码来获得所需的输出并防止函数崩溃。

更详细地说,首先,我们需要确保我们正在处理一个切片(reflect.Slice),其次,我们必须使用 for 循环打印切片元素,这看起来相当丑陋。

func PrintSliceT any {
    fmt.Println("** Generics")
    for _, v := range s {
        fmt.Print(v, " ")
    }
    fmt.Println()
} 

再次强调,泛型函数的实现更简单,因此更容易理解。此外,函数签名指定只接受切片作为函数参数——我们不需要为此执行任何额外的检查,因为这是 Go 编译器的工作。最后,我们使用简单的 for 循环和 range 来打印切片元素。

func main() {
    PrintSlice([]int{1, 2, 3})
    PrintSlice([]string{"a", "b", "c"})
    PrintSlice([]float64{1.2, -2.33, 4.55})
    PrintReflection([]int{1, 2, 3})
    PrintReflection([]string{"a", "b", "c"})
    PrintReflection([]float64{1.2, -2.33, 4.55})
} 

main() 函数使用各种类型的输入调用 PrintSlice()PrintReflection() 函数来测试它们的操作。

运行 genericsReflection.go 生成以下输出:

** Generics
1 2 3
** Generics
a b c
** Generics
1.2 -2.33 4.55 

前六行利用泛型打印了int值切片、string值切片和float64值切片的元素。

** Reflection
1 2 3
** Reflection
a b c
** Reflection
1.2 -2.33 4.55 

输出的最后六行产生相同的输出,但这次使用反射。输出没有差异——所有差异都在PrintReflection()PrintSlice()的实现中,这两个函数用于打印输出。正如预期的那样,泛型代码比使用反射的 Go 代码更简单、更短,尤其是在必须支持许多不同数据类型的情况下。虽然 Go 中的泛型提供了一种强大且类型安全的编写可重用代码的方法,但在某些场景下使用反射可能更合适,包括动态类型处理、与不常见类型一起工作、序列化和反序列化,以及实现自定义序列化和反序列化。

接下来,本章的最后部分是关于通过读取多个文件并根据给定的统计属性对输出进行排序来更新统计应用程序。

更新统计应用程序

在本节中,我们将根据数据集的平均值对不同的数据集进行排序。结果,应用程序将能够读取多个文件,这些文件将作为命令行参数提供给实用工具——我们将在第七章告诉 UNIX 系统做什么中了解更多关于文件 I/O 的内容。

我们将创建一个结构来保存每个数据文件的统计属性,使用切片来存储所有这样的结构,并根据每个数据集的平均值对它们进行排序。最后的功能将使用sort.Interface来实现。使用结构来保持重要信息组织有序是一种常见做法。此外,我们将使用slices.Min()slices.Max()函数来分别找到切片中的最小值和最大值,这样可以避免对切片进行排序。

虽然这些实用工具一开始看起来很天真,但它们可以是复杂机器学习系统的基础。一般来说,统计学是机器学习的基础。

DataFile结构的定义如下:

type DataFile struct {
    Filename string
    Len      int
    Minimum  float64
    Maximum  float64
    Mean     float64
    StdDev   float64
} 

我们还需要为DataFile结构的一部分定义一个新的数据类型,如下所示:

type DFslice []DataFile 

我们将为DFslice实现sort.Interface

func (a DFslice) Len() int {
    return len(a)
}
func (a DFslice) Less(i, j int) bool {
    return a[i].Mean < a[j].Mean
}
func (a DFslice) Swap(i, j int) {
    a[i], a[j] = a[j], a[i]
} 

前三个方法满足DFslicesort.Interface

main()函数的实现分为四部分,第一部分如下:

func main() {
    if len(os.Args) == 1 {
        fmt.Println("Need one or more file paths!")
        return
    }
    // Slice of DataFile structures
    files := DFslice{} 

main()函数的这一部分,我们确保至少有一个命令行参数要处理,并为每个文件的DataFile结构定义一个切片变量来保存数据。

main()函数的第二部分如下:

for i := 1; i < len(os.Args); i++ {
        file := os.Args[i]
        **currentFile := DataFile{}**
        currentFile.Filename = file
        values, err := readFile(file)
        if err != nil {
            fmt.Println("Error reading:", file, err)
            os.Exit(0)
        } 

所展示的 for 循环处理所有输入文件,除非出现错误。如果出现错误,实用程序将调用 os.Exit(0) 退出。另一种方法可能是跳过有误的输入文件并继续处理下一个。此外,currentFile 变量将当前文件的数据保存在 DataFile 结构中。

main() 的第三部分包含以下代码:

 currentFile.Len = len(values)
        currentFile.Minimum = slices.Min(values)
        currentFile.Maximum = slices.Max(values)
        meanValue, standardDeviation := stdDev(values)
        currentFile.Mean = meanValue
        currentFile.StdDev = standardDeviation
        files = append(files, currentFile)
    } 

在前面的代码中,我们计算了所有所需的统计属性并将它们保存在 currentFile 变量中。最后,在继续处理下一个文件(如果有的话)之前,将当前版本的 currentFile 变量存储在 files 切片中。

main() 的最后一部分包含以下代码:

 sort.Sort(files)
    for _, val := range files {
        f := val.Filename
        fmt.Println(f,":",val.Len,val.Mean,val.Maximum,val.Minimum)
    }
} 

工具的最后部分对 files 切片进行排序,并打印每个输入文件的信息。您可以从 files 切片中打印任何您想要的数据。

使用 ch05 目录中找到的简单数据文件运行 stats.go 产生以下输出:

$ go run stats.go d1.txt d2.txt d3.txt
Mean value: 3.00000
Mean value: 18.20000
Mean value: 0.75000
d3.txt : 4 0.75 102 -300
d1.txt : 5 3 5 1
d2.txt : 5 18.2 100 -4 

由于 d3.txt 的平均值是 0.75,因此 d3.txt 在具有平均值为 3d1.txt 和具有平均值为 18.2d2.txt 之前显示。

摘要

在本章中,我们学习了接口,它们就像合同一样,还学习了类型方法、类型断言和反射。尽管反射是 Go 的一个非常强大的功能,但它可能会减慢您的 Go 程序,因为它在运行时添加了一层复杂性。此外,如果您不小心使用反射,您的 Go 程序可能会崩溃。然而,除非您想使用 Go 变量执行低级任务,否则通常不需要反射。

请记住,接口指定行为,指定您可以做什么,而不是数据类型是什么。成功使用接口的代码更易于阅读、更易于扩展,并且更容易理解。最后,请记住,一旦实现了所需类型的方法,接口就隐式实现

本章还讨论了编写遵循面向对象编程原则的 Go 代码。如果您只想记住本章中的一件事,那就是 Go 不是一个面向对象编程语言,但它可以模仿一些面向对象编程语言(如 Java、Python 和 C++)提供的一些功能。在最后一节中,我们更新了统计应用程序以支持根据数据集的平均值对多个数据集进行排序。

下一章讨论语义版本控制、Go 包、函数和工作空间。

练习

尝试以下练习:

  • 使用您创建的结构创建一个结构体切片,并使用结构中的一个字段对切片的元素进行排序。

  • 使用空接口和一个允许您区分您创建的两个不同结构的函数。

其他资源

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第六章:Go 包和函数

本章的重点是 Go 包,这是 Go 组织、交付和使用代码的方式。包用于组织代码中的相关功能。作为包的作者,你设计包,包括由导出的常量、变量、类型和函数组成的公共 API。Go 还支持模块,模块包含一个或多个包。模块按照 SemVer 进行版本控制,允许模块作者使用major.minor.patch版本控制方案发布更新,甚至进行破坏性更改。本章还将解释defer的操作,它通常用于清理和释放资源。

关于包元素的可见性,Go 遵循一个简单的规则,即以大写字母开头的函数、变量、数据类型、结构字段等是公共的,而以小写字母开头的函数、变量、类型等是私有的。这就是为什么fmt.Println()被命名为Println()而不是仅仅println()的原因。同样的规则不仅适用于结构变量的名称,也适用于结构变量的字段——在实践中,这意味着你可以有一个既有私有字段又有公共字段的结构变量。然而,这个规则不影响包名,包名可以以大写或小写字母开头。实际上,包括main在内的包名都是小写的。

Go 有一个简单的指导原则:不要过早使用其任何功能。这个原则适用于泛型、接口和包。简单来说,不要仅仅因为可以创建包就创建包。从main包开始开发你的应用程序,并在main包中编写你的函数,直到你发现相同的代码需要被其他 Go 应用程序使用,或者你正在编写太多可以分组到包中的函数。在这种情况下,将相关功能分组到单独的包中是非常有意义的。开发代码在单独包中的其他原因包括封装、测试和暴露功能的安全性。

总结来说,本章涵盖了:

  • Go 包

  • 函数

  • Big O 复杂度

  • 开发自己的包

  • 使用 GitHub 存储 Go 包

  • 模块

  • 创建更好的包

  • 创建文档

  • 工作区

  • 版本控制工具

Go 包

Go 中的所有内容都是以包的形式提供的。Go 包是一个以package关键字开始的 Go 源文件,后面跟着包的名字。

注意,包可以有结构。例如,net包有几个子目录,分别命名为httpmailrpcsmtptextprotourl,分别应该导入为net/httpnet/mailnet/rpcnet/smtpnet/textprotonet/url

除了 Go 标准库的软件包之外,还有使用它们的完整地址导入的外部软件包,在首次使用之前应在本地机器上下载。一个这样的例子是 github.com/spf13/cobra,它存储在 GitHub 上。

软件包主要用于将 相关 的函数、变量和常量分组,以便您可以轻松地传输它们并在自己的 Go 程序中使用它们。请注意,除了 main 软件包之外,Go 软件包不是独立的程序,不能单独编译成可执行文件。因此,如果您尝试像独立程序一样执行 Go 软件包,您将会失望:

$ go run aPackage.go
go run: cannot run non-main package 

相反,为了使用,软件包需要直接或间接地从 main 软件包中调用,正如我们在前面的章节中所展示的那样。

关于 go get 和 go install

在本小节中,您将学习如何使用 github.com/spf13/cobra 作为示例下载外部 Go 软件包。下载 cobra 软件包的 go get 命令如下:

$ go get github.com/spf13/cobra 

然而,正如我们很快将要学习的,在所有最近的 Go 版本中,推荐下载软件包的方式是使用 go install。您可以在 go.dev/doc/go-get-install-deprecation 上了解更多关于这个变化的信息。

注意,您可以在地址中不使用 https:// 的情况下下载该软件包。结果可以在 ~/go 目录中找到——完整路径是 ~/go/src/github.com/spf13/cobra。由于 cobra 软件包附带一个帮助您构建和创建命令行工具的二进制文件,因此您可以在 ~/go/bin 中找到该二进制文件,名为 cobra

以下输出是通过 tree(1) 工具创建的,它显示了我机器上 ~/go 结构的高级视图,包含 3 个级别的细节:

$ tree ~/go -L 3
/Users/mtsouk/go
├── bin
│   ├── benchstat
│   ├── client
│   ├── cobra
│   ├── dlv
│   ├── dlv-dap
│   ├── fillstruct
│   ├── go-outline
│   ├── go-symbols
│   ├── gocode
│   ├── gocode-gomod
│   ├── godef
│   ├── godoc
│   ├── godoctor
│   ├── golint
│   ├── gomodifytags
│   ├── gopkgs
│   ├── goplay
│   ├── gopls
│   ├── gorename
│   ├── goreturns
│   ├── gotests
│   ├── guru
│   ├── impl
│   └── staticcheck
├── pkg
│   ├── darwin_amd64
│   │   └── github.com
│   └── sumdb
│       └── sum.golang.org
└── src
    ├── document
    │   └── document.go
    ├── github.com
    │   ├── agext
    │   ├── apparentlymart
    │   ├── fsnotify
    │   ├── hashicorp
    │   ├── mactsouk
    │   ├── magiconair
    │   ├── mitchellh
    │   ├── pelletier
    │   ├── spf13
    │   ├── subosito
    │   └── zclconf
    ├── golang.org
    │   └── x
    └── gopkg.in
        ├── ini.v1
        └── yaml.v2
26 directories, 25 files 

输出末尾附近显示的 x 路径被 Go 团队用于存储可能成为未来标准 Go 库一部分的实验性软件包。

基本上,~/go 下有三个主要目录,具有以下属性:

  • bin 目录:这是放置二进制工具的地方。

  • pkg 目录:这是放置可重用软件包的地方。仅在 macOS 机器上可以找到的 darwin_amd64 目录包含已安装软件包的编译版本。在 Linux 机器上,您将找到一个 linux_amd64 目录而不是 darwin_amd64

  • src 目录:这是软件包源代码所在的位置。其底层结构基于您要查找的软件包的 URL。因此,github.com/spf13/viper 软件包的 URL 是 ~/go/src/github.com/spf13/viper。如果软件包作为模块下载,则它将位于 ~/go/pkg/mod 下。

从 Go 1.16 版本开始,go install是推荐在模块模式下构建和安装包的方式,你应该只使用这种方式。go get的使用已被弃用,但本章使用go get是因为它在网络上很常见,并且值得了解。然而,本书的大部分章节都使用go mod initgo mod tidy来下载外部依赖项,这是推荐的方式。

如果你想要升级现有的包,你应该使用带有-u选项的go get命令。另外,如果你想查看幕后发生的事情,可以将-v选项添加到go get命令中——在这个例子中,我们使用 Viper 包作为示例,但我们简化了输出:

$ go get -v github.com/spf13/viper
github.com/spf13/viper (download)
...
github.com/spf13/afero (download)
get "golang.org/x/text/transform": found meta tag get.metaImport{Prefix:"golang.org/x/text", VCS:"git", RepoRoot:"https://go.googlesource.com/text"} at //golang.org/x/text/transform?go-get=1
get "golang.org/x/text/transform": verifying non-authoritative meta tag
...
github.com/fsnotify/fsnotify
github.com/spf13/viper 

你在输出中基本上可以看到的是在下载所需包之前下载的初始包的依赖关系——大多数时候,你并不想了解这些。

我们将继续本章,通过查看最重要的包元素:函数。

函数

包的主要元素是函数,这是本节的主题。

类型方法和函数的实现方式相同,有时函数和类型方法的术语可以互换使用。

一条建议:函数应该尽可能地相互独立,并且必须做好一项工作(而且只做一项工作)。所以,如果你发现自己正在编写做多项工作的函数,你可能想要考虑用多个函数来替换它们。

你应该已经知道,所有的函数定义都是以func关键字开始的,后面跟着函数的签名和实现,函数可以接受零个、一个或多个参数,并返回零个、一个或多个值。最流行的 Go 函数是main(),它在每个可执行的 Go 程序中使用——main()函数不接受任何参数也不返回任何内容,但它是每个 Go 程序的起点。此外,当main()函数及其执行的 goroutine 结束时,整个程序也会随之结束。

匿名函数

匿名函数可以内联定义,无需命名,通常用于实现需要少量代码的事情。在 Go 中,一个函数可以返回一个匿名函数,或者将匿名函数作为其参数之一。此外,匿名函数可以附加到 Go 变量上。请注意,在函数式编程术语中,匿名函数被称为lambda。同样,闭包是一种特定的匿名函数,它携带或封闭了与匿名函数定义相同的词法作用域中的变量。

被认为是一个好的实践,匿名函数应该有小的实现和局部焦点。如果一个匿名函数没有局部焦点,那么你可能需要考虑将其改为普通函数。当一个匿名函数适合一项工作时,它极其方便,可以使你的生活更轻松;只是不要在没有充分理由的情况下在你的程序中使用太多的匿名函数。我们将在稍后看看匿名函数的实际应用。

返回多个值的函数

如您从strconv.Atoi()等函数中已经知道,函数可以返回多个不同的值,这使您不必为从函数返回和接收多个值创建一个专门的结构。然而,如果您有一个返回值超过 3 个的函数,您应该重新考虑这个决定,也许重新设计它以使用单个结构或切片来分组并作为单个实体返回所需的值——这使得处理返回值更简单、更容易。函数、匿名函数以及返回多个值的函数都在functions.go中展示,如下面的代码所示:

package main
import "fmt"
func doubleSquare(x int) (int, int) {
    return x * 2, x * x
} 

这个函数返回两个int类型的值,无需使用单独的变量来保存它们——返回值是即时创建的。注意,当一个函数返回多个值时,必须使用括号。

// Sorting from smaller to bigger value
func sortTwo(x, y int) (int, int) {
    if x > y {
        return y, x
    }
    return x, y
} 

前面的函数也返回两个int类型的值。

func main() {
    n := 10
    d, s := doubleSquare(n) 

前一个语句读取了doubleSquare()的两个返回值,并将它们保存在ds中。

 fmt.Println("Double of", n, "is", d)
    fmt.Println("Square of", n, "is", s)
    // An anonymous function
    anF := func(param int) int {
        return param * param
    } 

anF变量持有一个需要单个参数作为输入并返回单个值的匿名函数。匿名函数与普通函数的唯一区别是匿名函数的名字是func(),并且没有func关键字。

 fmt.Println("anF of", n, "is", anF(n))
    fmt.Println(sortTwo(1, -3))
    fmt.Println(sortTwo(-1, 0))
} 

最后两个语句打印了sortTwo()的返回值。运行functions.go将产生以下输出:

Double of 10 is 20
Square of 10 is 100
anF of 10 is 100
-3 1
-1 0 

下一个子节将说明具有命名返回值的函数。

函数的返回值可以命名

与 C 语言不同,Go 语言允许你为 Go 函数的返回值命名。此外,当这样的函数有一个不带任何参数的return语句时,函数会自动返回每个命名返回值的当前值,其顺序与它们在函数签名中声明的顺序相同。

以下函数包含在namedReturn.go中:

func minMax(x, y int) (min, max int) {
    if x > y {
        min = y
        max = x
        return min, max 

这个return语句返回了存储在minmax变量中的值——minmax都在函数签名中定义,而不是在函数体中。

 }
    min = x
    max = y
    return
} 

这个return语句等同于return min, max,这是基于函数签名和命名返回值的使用。

运行namedReturn.go将产生以下输出:

$ go run namedReturn.go 1 -2
-2 1
-2 1 

接受其他函数作为参数的函数

函数可以接受其他函数作为参数。在sort包中可以找到接受另一个函数作为参数的最佳示例。你可以向sort.Slice()函数提供一个函数作为参数,该参数指定了排序的实现方式。sort.Slice()的签名是func Slice(slice interface{}, less func(i, j int) bool)。这意味着以下内容:

  • sort.Slice()函数不返回任何数据。

  • sort.Slice()函数需要两个参数,一个类型为interface{}的切片和另一个函数——切片变量在sort.Slice()内部被修改。

  • sort.Slice()的函数参数名为less,应该具有func(i, j int) bool签名——你不需要为匿名函数命名。less这个名字是必需的,因为所有函数参数都应该有一个名字。

  • lessij参数是切片参数的索引。

类似地,在sort包中还有一个名为sort.SliceIsSorted()的函数,定义为func SliceIsSorted(slice interface{}, less func(i, j int) bool) boolsort.SliceIsSorted()返回一个bool值,并检查切片参数是否根据第二个参数(一个函数)的规则排序。

你在sort.Slice()sort.SliceIsSorted()中并不强制使用匿名函数。你可以定义一个具有所需签名的常规函数并使用它。然而,使用匿名函数更为方便。

下面的 Go 程序展示了sort.Slice()sort.SliceIsSorted()的使用——源文件的名称是sorting.go

package main
import (
    "fmt"
"sort"
)
type Grades struct {
    Name    string
    Surname string
    Grade   int
}
func main() {
    data := []Grades{{"J.", "Lewis", 10}, {"M.", "Tsoukalos", 7},
        {"D.", "Tsoukalos", 8}, {"J.", "Lewis", 9}}
    isSorted := sort.SliceIsSorted(data, func(i, j int) bool {
        return data[i].Grade < data[j].Grade
    }) 

下面的if else块检查sort.SliceIsSorted()bool值以确定切片是否已排序:

 if isSorted {
        fmt.Println("It is sorted!")
    } else {
        fmt.Println("It is NOT sorted!")
    }
    sort.Slice(data,
        func(i, j int) bool { return data[i].Grade < data[j].Grade })
    fmt.Println("By Grade:", data)
} 

sort.Slice()的调用根据作为sort.Slice()第二个参数传递的匿名函数对数据进行排序。

运行sorting.go会产生以下输出:

It is NOT sorted!
By Grade: [{M. Tsoukalos 7} {D. Tsoukalos 8} {J. Lewis 9} {J. Lewis 10}] 

函数可以返回其他函数。

除了接受函数作为参数外,函数还可以返回匿名函数,这在返回的函数不总是相同而是依赖于函数的输入或其他外部参数时非常有用。这可以在returnFunction.go中看到:

package main
import "fmt"
func funRet(i int) func(int) int {
    if i < 0 {
        return func(k int) int {
            k = -k
            return k + k
        }
    }
    return func(k int) int {
        return k * k
    }
} 

funRet()函数签名的声明表明该函数返回一个具有func(int) int签名的另一个函数。函数的实现是未知的,但它将在运行时定义。函数是通过return关键字返回的。开发者应该小心并保存返回的函数。

func main() {
    n := 10
    i := funRet(n)
    j := funRet(-4) 

注意,n-4仅用于确定从funRet()返回的匿名函数。

 fmt.Printf("%T\n", i)
    fmt.Printf("%T %v\n", j, j)
    fmt.Println("j", j, j(-5)) 

第一条语句打印函数的签名,而第二条语句打印函数签名及其内存地址。最后一条语句还返回 j 的内存地址,因为 j 是匿名函数的指针以及 j(-5) 的值。

 // Same input parameter but DIFFERENT
// anonymous functions assigned to i and j
    fmt.Println(i(10))
    fmt.Println(j(10))
} 

虽然 ij 都使用相同的输入(10)调用,但它们将返回不同的值,因为它们存储了不同的匿名函数。

运行 returnFunction.go 生成以下输出:

func(int) int
func(int) int 0x100d446c0
j 0x100d446c0 10
100
-20 

输出的第一行显示了保存 funRet(n) 返回值的 i 变量的数据类型,它是 func(int) int,因为它保存了一个函数。输出的第二行显示了 j 的数据类型,以及存储匿名函数的内存地址。第三行显示了存储在 j 变量中的匿名函数的内存地址,以及 j(-5) 的返回值。最后两行分别是 i(10)j(10) 的返回值。

因此,在本小节中,我们学习了返回函数的函数。这使 Go 语言能够从函数式编程范式中受益,并使 Go 函数成为一等公民。

我们现在将检查可变参数函数,这些函数具有可变数量的参数。

可变参数函数

可变参数函数是可以接受可变数量参数的函数——你已经知道 fmt.Println()append(),它们都是广泛使用的可变参数函数。事实上,fmt 包中的大多数函数都是可变参数函数。

可变参数函数背后的通用思想和规则如下:

  • 可变参数函数使用打包操作符,它由一个 ... 后跟一个数据类型组成。因此,为了使可变参数函数接受可变数量的 int 值,打包操作符应该是 ...int

  • 打包操作符在任何给定的函数中只能使用一次。

  • 保存打包操作的变量是一个切片,因此,在可变参数函数内部作为切片来访问。

  • 与打包操作符相关的变量名总是位于函数参数列表的末尾。

  • 当调用可变参数函数时,你应该在打包操作符变量或解包操作符切片的位置放置一个由逗号分隔的值列表。

打包操作符也可以与空接口一起使用。事实上,fmt 包中的大多数函数都使用 ...interface{} 来接受所有数据类型的可变数量的参数。你可以在 go.dev/src/fmt/ 找到 fmt 的最新实现源代码。

然而,这里有一个需要特别注意的情况——我在学习 Go 语言时犯了这个错误,我在想我得到的错误信息是什么。

如果你尝试将 os.Args(一个字符串切片 []string)作为 ...interface{} 传递给一个可变参数函数,你的代码将无法编译,并生成类似于 cannot use os.Args (type []string) as type []interface {} in argument to <function_name> 的错误信息。这是因为这两种数据类型([]string[]interface{})在内存中的表示不同——这适用于所有数据类型。在实践中,这意味着你不能将 os.Args... 写入以将 os.Args 切片的每个单独值传递给一个可变参数函数。

另一方面,如果你只是使用 os.Args,它将工作,但这样会将整个切片作为一个单一实体传递,而不是其单独的值!这意味着 everything(os.Args, os.Args) 语句可以工作,但并不做你想要的事情。

解决这个问题的方法是将字符串切片(或任何其他切片)转换为 interface{} 切片。实现这一目标的一种方法是通过以下代码:

empty := make([]interface{}, len(os.Args[1:]))
for i, v := range os.Args {
    empty[i] = v
} 

现在,你可以使用 empty... 作为可变参数函数的参数。这是与可变参数函数和解包操作符相关的唯一微妙之处。

这种方法是一个例外,并且只有在用户必须将整个 os.Args 切片作为参数传递给类似 fmt.Println() 的函数时才应使用。主要原因是因为这消除了编译器的一些保证。

由于没有标准库函数为你执行这种转换,你必须编写自己的代码。请注意,转换需要时间,因为代码必须访问所有切片元素。切片中的元素越多,转换所需的时间就越长。这个话题也在 github.com/golang/go/wiki/InterfaceSlice 中进行了讨论。

我们现在可以查看可变参数函数的实际应用。使用你喜欢的文本编辑器输入以下 Go 代码,并将其保存为 variadic.go

package main
import (
    "fmt"
"os"
) 

由于可变参数函数是内置于语言语法中的,因此你不需要任何额外的东西来支持可变参数函数。

func addFloats(message string, s ...float64) float64 { 

这是一个接受一个字符串和未知数量的 float64 值的可变参数函数。它打印字符串变量并计算 float64 值的总和。

 fmt.Println(message)
    sum := float64(0)
    for _, a := range s {
        sum = sum + a
    } 

这个 for 循环将打包操作符作为切片访问,所以这里没有什么特别之处。

 s[0] = -1000
return sum
} 

你也可以访问 s 切片的单个元素。

func everything(input ...interface{}) {
    fmt.Println(input)
} 

这是一个接受未知数量 interface{} 值的可变参数函数。

func main() {
    sum := addFloats("Adding numbers...", 1.1, 2.12, 3.14, 4, 5, -1, 10) 

你可以将可变参数函数的参数内联。

 fmt.Println("Sum:", sum)
    s := []float64{1.1, 2.12, 3.14} 

但你通常使用带有解包操作符的切片变量:

 sum = addFloats("Adding numbers...", s...)
    fmt.Println("Sum:", sum)
    everything(s) 

之前的代码之所以能工作,是因为 s 的内容没有被解包。

 // Cannot directly pass []string as []interface{}
// You have to convert it first!
    empty := make([]interface{}, len(os.Args[1:])) 

你可以将 []string 转换为 []interface{} 以使用解包操作符。

 for i, v := range os.Args[1:] {
        empty[i] = v
    }
    everything(empty...) 

现在,我们可以解包 empty 的内容。

 arguments := os.Args[1:]
    empty = make([]interface{}, len(arguments))
    for i := range arguments {
        empty[i] = arguments[i]
    } 

这是一种将 []string 转换为 []interface{} 的稍微不同方法。

 everything(empty...)
    str := []string{"One", "Two", "Three"}
    everything(str, str, str)
} 

前一个语句之所以有效,是因为你传递了整个 str 变量三次——而不是它的内容。因此,切片包含三个元素——每个元素等于 str 变量的内容。

运行 variadic.go 产生以下输出:

$ go run variadic.go
Adding numbers...
Sum: 24.36
Adding numbers...
Sum: 6.36
[[-1000 2.12 3.14]]
[]
[]
[[One Two Three] [One Two Three] [One Two Three]] 

输出的最后一行显示,我们已经将 str 变量三次传递给 everything() 函数作为三个不同的实体。

可变参数函数在你想要在函数中有一个未知数量的参数时非常有用。下一小节将讨论 defer 的使用,我们已经多次使用过了。

defer 关键字

到目前为止,我们在 ch03/csvData.go 中看到了 defer。但 defer 究竟做了什么?defer 关键字将函数的执行推迟到周围函数返回时。

通常,defer 在文件 I/O 操作中使用,以便将关闭已打开文件的函数调用与打开它的调用保持接近,这样你就不必记得在函数退出前关闭你刚刚打开的文件。

记住这一点非常重要,即延迟执行的函数在周围函数返回后按 后进先出LIFO)顺序执行。简单来说,这意味着如果你在同一个周围函数中首先 defer 函数 f1(),然后 f2(),最后 f3(),那么当周围函数即将返回时,f3() 将首先执行,f2() 将其次执行,而 f1() 将是最后一个被执行的。

在本节中,我们将通过一个简单的程序讨论粗心使用 defer 的危险。defer.go 的代码如下:

package main
import (
    "fmt"
)
func d1() {
    for i := 3; i > 0; i-- {
        defer fmt.Print(i, " ")
    }
} 

d1() 中,defer 在函数体内通过一个 fmt.Print() 调用来执行。记住,这些对 fmt.Print() 的调用是在函数 d1() 返回之前执行的。

func d2() {
    for i := 3; i > 0; i-- {
        defer func() {
            fmt.Print(i, " ")
        }()
    }
    fmt.Println()
} 

d2() 中,defer 被附加到一个不接受任何参数的匿名函数上。在实践中,这意味着匿名函数应该自己获取 i 的值——这是危险的,因为 i 的当前值取决于匿名函数的执行时间。

匿名函数是一个 闭包,这就是为什么它可以访问通常超出作用域的变量。

func d3() {
    for i := 3; i > 0; i-- {
        defer func(n int) {
            fmt.Print(n, " ")
        }(i)
    }
} 

在这种情况下,当前 i 的值作为参数传递给匿名函数,以初始化 n 函数参数。这意味着关于 i 的值没有歧义。

func main() {
    d1()
    d2()
    fmt.Println()
    d3()
    fmt.Println()
} 

main() 的任务是调用 d1()d2()d3()

运行 defer.go 产生以下输出:

$ go run defer.go
1 2 3
0 0 0
1 2 3 

你很可能会发现生成的输出很复杂,难以理解,这证明了如果代码不清晰且不明确,defer 的操作和结果可能会很棘手。让我解释一下结果,以便你更好地了解如果不仔细注意你的代码,defer 可能有多棘手。

让我们从由d1()函数生成的输出(1 2 3)的第一行开始。d1()中的i值按顺序是321。在s中延迟的函数是fmt.Print()语句;因此,当d1()函数即将返回时,你会得到for循环中i变量的三个值的逆序。这是因为延迟函数是按照后进先出(LIFO)顺序执行的

现在,让我解释由d2()函数生成的输出的第二行。我们得到了三个零而不是1 2 3,这真的很奇怪;然而,这有一个原因——请注意,这不是defer的问题,而是闭包的问题。在for循环结束后,i的值是0,因为正是这个i的值使得for循环终止。然而,这里的难点在于延迟的匿名函数是在for循环结束后评估的,因为它没有参数,这意味着它被评估了三次,对于i的值为0,因此生成了输出。这种令人困惑的代码可能会导致你的项目中出现讨厌的 bug,所以尽量避开它。Go 版本 1.22 纠正了这类错误。

最后,我们将讨论由d3()函数生成的输出的第三行。由于匿名函数的参数,每次匿名函数被延迟时,它都会获取并因此使用i的当前值。因此,每次匿名函数的执行都有一个不同的值要处理,没有任何歧义,因此生成了输出。

之后,应该很清楚,使用defer的最佳方法就是第三种,这在d3()函数中得到了体现,因为你在匿名函数中故意以易于阅读的方式传递所需的变量。现在我们已经了解了defer,是时候讨论一些完全不同的事情了:大 O 符号。

大 O 复杂度

算法的计算复杂度通常使用流行的大 O 符号表示。大 O 符号用于表达算法增长顺序的最坏情况。它显示了随着处理的数据规模的增长,算法性能如何变化。

O(1)表示常数时间复杂度,它不依赖于手头的数据量。O(n)表示执行时间与n成正比(线性时间)——你无法处理未访问的数据,因此O(n)被认为是好的。O(n²)(二次时间)表示执行时间与成正比。O(n!)(阶乘时间)表示算法的执行时间与n的阶乘成正比。简单来说,如果你必须处理 100 个某种类型的值,那么O(n)算法将执行大约 100 次操作,O(n²)将执行大约 10,000 次操作,而具有O(n!)复杂度的算法将执行10¹⁵⁸次操作!

现在我们已经学习了 Big O 表示法,是时候讨论开发自己的包了。

开发自己的包

在某个时候,你需要开发自己的包来组织你的代码,并在需要时分发它们。正如本章开头所述,所有以大写字母开头的内容都被认为是公共的,可以从其包外部访问,而所有其他元素都被认为是私有的。Go 规则的唯一例外是包名——使用小写包名是一种最佳实践,尽管允许使用大写包名。

如果包存在于本地机器上,可以手动编译 Go 包,但下载包后也会自动编译,因此无需担心。另外,如果你下载的包中包含任何错误,你将在尝试下载时了解到它们。

然而,如果你想自己编译保存在 sqlite06.go 文件(SQLite 和第六章的组合)中的包,可以使用以下命令:

$ go build -o sqlite06.a sqlite06.go 

因此,之前的命令编译了 sqlite06.go 文件,并将输出保存到 sqlite06.a 文件中:

$ file sqlite06.a
sqlite06.a: current ar archive
The sqlite06.a file is an ar archive. 

在自己编译 Go 包的主要原因是为了检查代码中的语法或其他类型的错误,而不实际使用它们。此外,你可以将 Go 包作为插件 (pkg.go.dev/plugin) 或共享库来构建。关于这些的更多讨论超出了本书的范围。

init() 函数

每个 Go 包可以有一个名为 init() 的私有函数,它在程序执行开始时自动执行——init() 在包初始化时运行。init() 函数具有以下特性:

  • init() 不接受任何参数。

  • init() 不返回任何值。

  • init() 函数是可选的。

  • init() 函数由 Go 隐式调用。

  • 你可以在 main 包中有一个 init() 函数。在这种情况下,init()main() 函数之前执行。实际上,所有 init() 函数总是在 main() 函数之前执行。

  • 源文件可以包含多个 init() 函数——这些函数按声明顺序执行。

  • init() 函数或包中的函数仅执行 一次,即使包被导入多次。

  • Go 包可以包含多个文件。每个源文件可以包含一个或多个 init() 函数。

  • init() 函数被设计为私有函数的事实意味着它不能从包含它的包外部调用。此外,由于包的使用者无法控制 init() 函数,因此在公共包中使用 init() 函数或更改 init() 中的任何全局状态之前,你应该仔细思考。

有一些例外情况,使用 init() 是有意义的:

  • 用于初始化在包函数或方法执行之前可能需要花费时间的网络连接。

  • 用于在执行包函数或方法之前初始化连接到一个或多个服务器。

  • 用于创建所需的文件和目录。

  • 用于检查所需资源是否可用。

由于执行顺序有时可能会令人困惑,在下一小节中,我们将更详细地解释执行顺序。

执行顺序

本小节说明了 Go 代码是如何执行的。例如,如果 main 包导入了包 A,而包 A 依赖于包 B,那么以下情况将会发生:

  1. 该过程从 main 包开始。

  2. main 包导入包 A

  3. A 导入包 B

  4. 如果有,包 B 中的全局变量被初始化。

  5. 如果存在,包 Binit() 函数或函数将会运行。这是第一个被执行的 init() 函数。

  6. 如果有,包 A 中的全局变量被初始化。

  7. 如果有,包 Ainit() 函数或函数将会运行。

  8. main 包中的全局变量被初始化。

  9. init() 函数或 main 包中的函数(如果存在),将会运行。

  10. main 包的 main() 函数开始执行。

注意,如果 main 包单独导入包 B,则不会发生任何事,因为与包 B 相关的所有内容都是由包 A 触发的。这是因为包 A 首先导入了包 B

以下图表展示了关于 Go 代码执行顺序幕后发生的事情:

图 6.1:Go 代码执行顺序

你可以通过阅读 go.dev/ref/spec#Order_of_evaluation 中的 Go 语言规范文档来了解更多关于执行顺序的信息,以及通过阅读 go.dev/ref/spec#Package_initialization 来了解包初始化过程。

使用 GitHub 存储 Go 包

本节将教你如何创建一个 GitHub 仓库,你可以在这里存放你的 Go 包并将其提供给全世界。

首先,你需要自己创建 GitHub 仓库。创建新 GitHub 仓库最简单的方法是访问 GitHub 网站,然后转到 Repositories 标签,在那里你可以看到你的现有仓库并创建新的仓库。点击 New 按钮,并输入创建新 GitHub 仓库所需的信息。如果你将你的仓库设置为公开,每个人都可以看到它——如果它是一个私有仓库,只有你选择的人才能查看。

在你的 GitHub 仓库中有一个清晰的 README.md 文件,解释 Go 包的工作方式,这是一个非常好的实践。

接下来,您需要在您的本地计算机上克隆仓库。我通常使用git(1)实用程序来克隆它。如果仓库的名称是sqlite06,GitHub 用户名是mactsouk,那么git clone命令将如下所示:

$ git clone git@github.com:mactsouk/sqlite06.git 

然后,输入cd sqlite06,您就完成了!在这个时候,您只需编写 Go 包的代码,并记得将更改git commitgit push到 GitHub 仓库。

将 Go 包托管、检查、开发或使用的最佳位置是~/go/src目录。简单来说,~/go/src的目的就是存储您创建或使用的包的源代码。

这种仓库的外观可以在图 6.2中看到——您将在稍后了解更多关于sqlite06仓库的信息:

计算机截图  自动生成的描述

图 6.2:一个包含 Go 包的 GitHub 仓库

使用 GitLab 而不是 GitHub 来托管您的代码,不需要改变您的工作方式。

如果您想使用该包,只需使用其 URL 通过go get获取该包,并将其包含在您的import块中——我们将在实际使用程序时看到这一点。前面的过程是关于开发 Go 包,而不是使用 Go 包

下一节将介绍一个 Go 包,允许您与数据库交互。

用于与 SQLite 一起工作的包

本节将开发一个 Go 包,用于与存储在 SQLite 数据库上的给定数据库模式交互,最终目标是展示如何开发、存储和使用包。当与您的应用程序中的特定模式和表交互时,您通常创建包含所有数据库相关函数的单独包——这也适用于 NoSQL 数据库。

Go 提供了一个通用的包(pkg.go.dev/database/sql),用于与数据库交互。然而,每个数据库都需要一个特定的包,作为驱动程序,允许 Go 连接并使用该特定数据库。

创建所需 Go 包的步骤如下:

  • 下载用于与 SQLite 一起工作的必要外部 Go 包。

  • 创建包文件。

  • 开发所需的功能。

  • 使用 Go 包来开发实用程序并测试其功能。

  • 使用 CI/CD 工具进行自动化(这是可选的)。

您可能想知道为什么我们会创建这样一个用于与数据库工作的包,而不是在需要时在程序中编写实际的命令。原因包括以下几点:

  • Go 包可以被所有与该应用程序一起工作的团队成员共享。

  • Go 包允许人们以文档化的方式使用数据库。

  • 您在 Go 包中放入的专业函数非常适合您的需求。

  • 人们不需要完全访问数据库——他们只需使用包函数和它们提供的功能。

  • 如果你对数据库进行了更改,人们不需要知道这些更改,只要 Go 包的功能保持不变即可。

简而言之,你创建的函数可以与特定的数据库模式、表和数据交互——如果不了解表是如何相互连接的,几乎不可能与未知的数据库模式一起工作。

除了所有这些技术原因之外,创建供多个开发者共享的 Go 包真的很有趣!

让我们继续学习更多关于 SQLite 数据库的知识。

使用 SQLite3 和 Go

你需要下载一个额外的包来处理数据库,例如 Postgres、SQLite、MySQL 或 MongoDB。在这种情况下,我们使用 SQLite,因此需要下载一个允许我们与 SQLite 通信的 Go 包。用于处理 SQLite3 的最流行的 Go 包称为 go-sqlite3,可以在 github.com/mattn/go-sqlite3 找到。

SQLite 数据库是单个文件,本地访问,因此不需要任何 TCP/IP 服务或其他服务器进程运行。

你可以通过运行 go get github.com/mattn/go-sqlite3 来下载该包。然而,这个命令在模块外部不再有效。安装包的新方法是运行 go install github.com/mattn/go-sqlite3@latest

请记住,这个包使用 cgo,这需要在你的计算机上安装 gcc 编译器。有关更多详细信息,请访问 github.com/mattn/go-sqlite3

在成功执行前面的命令后,包的最新版本将被下载。在我的情况下,go-sqlite3 包的源代码可以在 ~/go/pkg/mod/github.com/mattn 找到:

$ ls ~/go/pkg/mod/github.com/mattn
go-sqlite3@v1.14.22 

因此,下载的 go-sqlite3 包的版本是 1.14.22。或者,你可以让 go mod initgo mod tidy 命令为你完成工作。

在使用 SQLite3 进行任何实际工作之前,我们将展示一个简单的实用工具,它只是连接到数据库并打印 SQLite3 的版本。在故障排除时,这些实用工具执行简单但关键的任务,因此非常有用。由于 testSQLite.go 使用外部包,在开发期间应将其放置在 ~/go/src 下,并遵循 go mod initgo mod tidy 过程。在我的情况下,testSQLite.go 放在 ~/go/src/github.com/mactsouk/mGo4th/ch06/testSQLite 目录中。

testSQLite.go 的代码分为两部分。第一部分如下:

package main
import (
    "database/sql"
"fmt"
"os"
    _ "github.com/mattn/go-sqlite3"
)
func main() {
    // Connect or Create an SQLite database
    db, err := sql.Open("sqlite3", "test.db")
    if err != nil {
        fmt.Println("Error connecting:", err)
        return
    }
    defer db.Close() 

由于该包与 SQLite3 进行通信,我们导入 github.com/mattn/go-sqlite3 包,并在包路径前使用 _。这是因为导入的包正在将自己注册为 sql 包的数据库处理器,但在代码中并未直接使用。它仅通过 sql 包来使用。

使用前面的代码,我们使用 sql.Open() 连接到一个名为 test.db 的 SQLite3 数据库。如果 test.db 文件不存在,它将被创建。

第二部分包含以下 Go 代码:

 var version string
    err = db.QueryRow("SELECT SQLITE_VERSION()").Scan(&version)
    if err != nil {
        fmt.Println("Version:", err)
        return
    }
    fmt.Println("SQLite3 version:", version)
os.Remove("test.db")
} 

在第二部分,我们使用 db.QueryRow() 查询 SQLite 数据库以获取其版本号信息。QueryRow() 对于执行预期最多返回一行数据的查询很有用,就像在我们的例子中一样。查询的返回值通过 Scan() 读取,并使用 Scan() 的参数指针保存到变量中。os.Remove() 语句删除 test.db 文件——一般来说,这不是一个好的做法,但在这个特定情况下它是有效的。

在第一次执行 testSQLite.go 之前,不要忘记执行 go mod initgo mod tidy

运行 testSQLite.go 产生以下输出:

$ go run testSQLite.go
SQLite3 version: 3.42.0 

现在,让我们开发一些更高级的内容。connectSQLite3.go 工具可以验证你能否创建一个 SQLite3 数据库和一些表,获取可用表的列表,在表中插入和更新数据,从表中选择数据,删除数据,以及获取记录数。由于该工具使用外部包,它应该放在 ~/go/src 下,并遵循 go mod initgo mod tidy 的过程。在我的情况下,connectSQLite3.go 放在 ~/go/src/github.com/mactsouk/mGo4th/ch06/connectSQLite3,但你可以在任何你想放置的地方,只要它位于 ~/go/src 之下。

connectSQLite3.go 的代码将被分成六个部分,第一部分如下:

package main
import (
    "database/sql"
"fmt"
"os"
"strconv"
"time"
    _ "github.com/mattn/go-sqlite3"
)
var dbname = "ch06.db"
func insertData(db *sql.DB, dsc string) error {
    cT := time.Now().Format(time.RFC1123)
    stmt, err := db.Prepare("INSERT INTO book VALUES(NULL,?,?);")
    if err != nil {
        fmt.Println("Insert data table:", err)
        return err
    }
    _, err = stmt.Exec(cT, dsc)
    if err != nil {
        fmt.Println("Insert data table:", err)
        return err
    }
    return nil
} 

insertData() 函数用于将数据插入到数据库中,并由 main() 函数调用。我们首先使用 db.Prepare() 构建带有所需参数的 INSERT SQL 语句,然后执行 Exec() 实际插入数据。在准备 SQL 语句时使用一个或多个 ? 是一种常见的做法,并在调用 Exec() 时用实际值替换这些问号。

connectSQLite3.go 的第二部分包含以下代码:

func selectData(db *sql.DB, n int) error {
    rows, err := **db.Query(****"SELECT * from book WHERE id > ? "****, n)**
if err != nil {
        fmt.Println("Select:", err)
        return err
    }
    defer rows.Close()
    for **rows.Next()** {
        var id int
var dt string
var description string
        err = rows.Scan(&id, &dt, &description)
        if err != nil {
            fmt.Println("Row:", err)
            return err
        }
        date, err := time.Parse(time.RFC1123, dt)
        if err != nil {
            fmt.Println("Date:", err)
            return err
        }
        fmt.Printf("%d %s %s\n", id, date, description)
    }
    return nil
} 

在本节的第二部分,我们展示了如何查询 SQLite3 数据并读取多行。我们使用 db.Query() 构建一个 SELECT SQL 查询,它返回一个 *sql.Rows 变量。然后我们通过多次调用 Next() 并使用一个 for 循环来读取行,当没有更多数据可读时,循环会自动终止。由于时间在 SQLite 中以文本形式存储,我们需要使用 time.Parse() 将其转换为适当的变量。selectData() 函数会自行打印数据,而不是将其返回给调用函数。

db.Query() 语句不需要 Exec() 来执行。因此,我们在同一语句中将 ? 替换为实际值。

第三部分如下:

func main() {
    // Delete database file
    os.Remove(dbname)
    // Connect and Create the SQLite database
    db, err := sql.Open("sqlite3", dbname)
    if err != nil {
        fmt.Println("Error connecting:", err)
        return
    }
    defer db.Close()
    // Create a table
const create string = `
    CREATE TABLE IF NOT EXISTS book (
      id INTEGER NOT NULL PRIMARY KEY,
      time TEXT NOT NULL,
      description TEXT);`
    _, err = db.Exec(create)
    if err != nil {
        fmt.Println("Create table:", err)
        return
    } 

在本部分中,我们连接到 SQLite3 数据库并创建一个名为book的表。该表有三个字段,分别命名为idtimedescription。使用db.Exec()语句来执行CREATE TABLE SQL 命令。

connectSQLite3.go的第四部分包含以下代码:

 // Insert 10 rows to the book table
for i := 1; i < 11; i = i + 1 {
        dsc := "Description: " + strconv.Itoa(i)
        err = insertData(db, dsc)
        if err != nil {
            fmt.Println("Insert data:", err)
        }
    }
    // Select multiple rows
    err = selectData(db, 5)
    if err != nil {
        fmt.Println("Select:", err)
    } 

之前的代码使用insertData()函数和for循环将十行插入到book表中。之后,调用selectData()函数从book表中选择数据。

connectSQLite3.go的第五部分包含以下 Go 代码:

 time.Sleep(time.Second)
    // Update data
    cT := time.Now().Format(time.RFC1123)
    db.Exec("UPDATE book SET time = ? WHERE id > ?", cT, 7)
    // Select multiple rows
    err = selectData(db, 8)
    if err != nil {
        fmt.Println("Select:", err)
        return
    }
    // Delete data
    stmt, err := db.Prepare("DELETE from book where id = ?")
    _, err = stmt.Exec(8)
    if err != nil {
        fmt.Println("Delete:", err)
        return
    } 

在本部分中,我们展示了基于db.Exec()UPDATE SQL 语句的实现——再次强调,UPDATE SQL 语句的值传递给db.Exec()。之后,我们调用selectData()来查看我们所做的更改。最后,我们使用db.Prepare()构建一个DELETE语句,该语句通过Exec()执行。

connectSQLite3.go的最后部分如下:

 // Select multiple rows
    err = selectData(db, 7)
    if err != nil {
        fmt.Println("Select:", err)
        return
    }
    // Count rows in table
    query, err := db.Query("SELECT count(*) as count from book")
    if err != nil {
        fmt.Println("Select:", err)
        return
    }
    defer query.Close()
    count := -100
for query.Next() {
        _ = query.Scan(&count)
    }
    fmt.Println("count(*):", count)
} 

在工具的最后部分,我们使用db.Query()获取book表中的行数并打印结果。

如预期的那样,在执行connectSQLite3.go之前,你应该首先执行以下命令:

$ go mod init
go: creating new go.mod: module github.com/mactsouk/mGo4th/ch06/connectSQLite3
go: to add module requirements and sums:
    go mod tidy
$ go mod tidy
go: finding module for package github.com/mattn/go-sqlite3
go: found github.com/mattn/go-sqlite3 in github.com/mattn/go-sqlite3 v1.14.17 

输出的最后一行告诉我们,github.com/mattn/go-sqlite3包在我们的 Go 安装中已找到,因此它没有被下载——这是由于我们之前执行的go get github.com/mattn/go-sqlite3命令的结果。

运行connectSQLite3.go会生成以下类型的输出:

$ go run connectSQLite3.go
6 2023-08-16 21:12:00 +0300 EEST Description: 6
7 2023-08-16 21:12:00 +0300 EEST Description: 7
8 2023-08-16 21:12:00 +0300 EEST Description: 8
9 2023-08-16 21:12:00 +0300 EEST Description: 9
10 2023-08-16 21:12:00 +0300 EEST Description: 10
9 2023-08-16 21:12:01 +0300 EEST Description: 9
10 2023-08-16 21:12:01 +0300 EEST Description: 10
9 2023-08-16 21:12:01 +0300 EEST Description: 9
10 2023-08-16 21:12:01 +0300 EEST Description: 10
count(*): 9 

connectSQLite3.go工具的主要优势是它说明了如何在 SQLite 数据库上执行大量任务——大部分展示的代码将在我们即将创建的 Go 包中重用。

现在我们知道了如何使用 Go 访问和查询 SQLite3 数据库,下一个任务应该是实现我们想要开发的 Go 包。

存储 Go 包

如前所述,出于简单起见,我们将使用名为sqilite06的公共 Go 仓库作为 Go 模块,它可以在github.com/mactsouk/sqlite06找到。

要在您的机器上使用该包,您应该首先使用go get获取它,无论是手动还是通过go mod initgo mod tidy的帮助。然而,在开发过程中,您应该从git clone git@github.com:mactsouk/sqlite06.git开始,以获取 GitHub 仓库的内容并对其进行修改,直到其功能最终确定且没有错误。这意味着您已经设置了与 GitHub 的 ssh 连接,这是我通常使用的。我在~/go/src/github.com/mactsouk/sqlite06中开发sqilite06包。

Go 包的设计

图 6.3显示了 Go 包所工作的数据库模式。记住,当与特定的数据库和模式一起工作时,您需要在您的 Go 代码中包含模式信息。简单来说,Go 代码应该知道它所工作的模式:

图 6.3:Go 包工作的两个数据库表

这是一个简单的模式,允许我们保存用户数据并更新它。除了Users表之外,还有一个名为Userdata的表,它包含有关用户的更详细信息。连接两个表的是用户 ID,它应该是唯一的。此外,Users表上的Username字段也应该唯一,因为两个或更多用户不能共享相同的用户名。一旦在Users表中输入了记录,就不能更改它,只能删除它。然而,可以更改的是存储在Userdata表中的数据。

这两个表应该已经在 SQLite 中存在,这意味着 Go 代码假设相关的表在正确的位置。

Go 包应该执行以使我们的生活更轻松的任务如下:

  • 创建一个新用户。

  • 删除现有用户。

  • 更新现有用户。

  • 列出所有用户。

这些任务中的每一个都应该有一个或多个 Go 函数或方法来支持它,这正是我们将在 Go 包中实现的:

  • 一个初始化 SQLite3 连接的函数。用于初始化连接的辅助函数将是私有的。

  • 一个检查给定用户名是否存在的函数——这是一个也将是私有的辅助函数。

  • 一个将新用户插入数据库的函数。

  • 一个从数据库中删除现有用户的函数。

  • 一个更新现有用户的函数。

  • 一个列出所有用户的函数。

既然我们已经了解了 Go 包的整体结构和功能,我们应该开始实现它。

Go 包的实现

在本小节中,我们将实现用于与 SQLite 数据库和给定数据库模式一起工作的 Go 包。我们将分别展示每个函数——如果你将这些函数组合起来,那么你就有了整个包的功能。

在包开发过程中,你应该定期将你的更改提交到 GitHub 或 GitLab 仓库,作为备份策略。

包的所有代码都位于一个名为sqlite06.go的 Go 源文件中。包的前言如下:

package sqlite06
import (
    "database/sql"
"errors"
"fmt"
"strings"
    _ "github.com/mattn/go-sqlite3"
)
var (
    Filename = ""
) 

Filename变量保存数据库文件的名称——这是由使用sqlite06的应用程序设置的。在这本书的第一次,你将看到与main不同的包名,在这种情况下是sqlite06

你在 Go 包中需要的下一个元素是能够保存数据库表数据的结构或多个结构。大多数时候,你需要与数据库表一样多的结构——我们将从这里开始,看看效果如何。因此,我们将定义以下结构:

type User struct {
    ID       int
    Username string
}
type Userdata struct {
    ID          int
    Name        string
    Surname     string
    Description string
} 

如果你思考这个问题,你应该会看到在我们的情况下创建两个单独的 Go 结构体是没有意义的。这是因为User结构体不包含真实数据,而且没有理由将多个结构体传递给处理UsersUserdata SQLite 表的函数。因此,我们可以创建一个单独的 Go 结构体来保存所有已定义的数据,如下所示:

type Userdata struct {
    ID          int
    Username    string
    Name        string
    Surname     string
    Description string
} 

我决定为了简单起见,将结构体命名为数据库表名——然而,在这种情况下,这并不完全准确,因为Userdata结构体比Userdata数据库表有更多的字段。

现在,让我们开始介绍包的功能。openConnection()函数是私有的,并且仅在包的作用域内访问,定义如下:

func openConnection() (*sql.DB, error) {
    db, err := sql.Open("sqlite3", Filename)
    if err != nil {
        return nil, err
    }
    return db, nil
} 

SQLite3 不需要用户名或密码,并且不在 TCP/IP 网络上操作。因此,sql.Open()只需要一个参数,即数据库的文件名。

现在,让我们考虑exists()函数,它也是一个私有函数,因为它是一个辅助函数:

// The function returns the User ID of the username
// -1 if the user does not exist
func exists(username string) int {
    username = strings.ToLower(username)
    db, err := openConnection()
    if err != nil {
        fmt.Println(err)
        return -1
    }
    defer db.Close()
    userID := -1
    statement := fmt.Sprintf(`SELECT ID FROM Users where Username = '%s'`, username)
    rows, err := db.Query(statement)
    defer rows.Close() 

这是我们定义查询以显示提供的用户名是否存在于数据库中的地方。由于我们所有的数据都保存在数据库中,我们需要始终与数据库交互。

这是在函数中返回指示值比返回error值更有意义的一些罕见情况之一,因为它使代码比返回error值更简单。然而,返回error值仍然更健壮,更接近 Go 哲学。

 for rows.Next() {
        var id int
        err = rows.Scan(&id)
        if err != nil {
            fmt.Println("exists() Scan", err)
            return -1
        }
        userID = id
    } 

如果rows.Scan(&id)调用没有错误执行,那么我们知道已经返回了一个结果,这就是期望的用户 ID。

 return userID
} 

exists()函数的最后部分释放资源并返回作为参数传递给exists()的用户的 ID 值。

下面是AddUser()函数的实现:

// AddUser adds a new user to the database
// Returns new User ID
// -1 if there was an error
func AddUser(d Userdata) int {
    d.Username = strings.ToLower(d.Username)
    db, err := openConnection()
    if err != nil {
        fmt.Println(err)
        return -1
    }
    defer db.Close() 

所有用户名都使用strings.ToLower()转换为小写,以避免重复。这是一个设计决策。

 userID := exists(d.Username)
    if userID != -1 {
        fmt.Println("User already exists:", d.Username)
        return -1
    }
    insertStatement := `INSERT INTO Users values (NULL,?)` 

这是我们构建接受参数的INSERT语句的方法。所呈现的语句需要一个值,因为只有一个?

 _, err = db.Exec(insertStatement, d.Username)
    if err != nil {
        fmt.Println(err)
        return -1
    } 

使用db.Exec(),我们将参数的值,即保持为d.Username,传递到insertStatement变量中。

 userID = exists(d.Username)
    if userID == -1 {
return userID
    } 

在将新用户插入Users表后,我们使用exists()函数确保一切顺利,该函数还返回新用户的 ID。这个用户 ID 用于将相关数据插入Userdata表。

 insertStatement = `INSERT INTO Userdata values (?, ?, ?, ?)`
    _, err = db.Exec(insertStatement, userID, d.Name, d.Surname, d.Description)
    if err != nil {
        fmt.Println("db.Exec()", err)
        return -1
    }
    return userID
} 

呈现的查询需要四个值,这由四个?字符表示。由于我们需要向insertStatement传递四个变量,我们将在db.Exec()调用中放入四个值。这是添加新用户到数据库的函数的结束。

DeleteUser()函数的实现如下。

func DeleteUser(id int) error {
    db, err := openConnection()
    if err != nil {
        return err
    }
    defer db.Close()
statement := fmt.Sprintf(`SELECT Username FROM Users WHERE ID = %d`, id)
    rows, err := db.Query(statement)
    defer rows.Close()
    var username string
for rows.Next() {
        **err = rows.Scan(&username)**
if err != nil {
            return err
        }
    }
if **exists(username) != id** {
        return fmt.Errorf("User with ID %d does not exist", id)
    } 

在这里,我们在尝试删除之前,会双重检查给定的用户 ID 是否存在于Users表中。

 // Delete from Userdata
    deleteStatement := `DELETE FROM Userdata WHERE UserID = ?`
    _, err = db.Exec(deleteStatement, id)
    if err != nil {
        return err
    }
    // Delete from Users
    deleteStatement = `DELETE from Users where ID = ?`
    _, err = db.Exec(deleteStatement, id)
    if err != nil {
        return err
    }
    return nil
} 

如果之前返回的用户名存在并且具有与DeleteUser()参数相同的用户 ID,那么我们可以继续删除过程,这包含两个步骤:首先,从Userdata表中删除相关的用户数据,其次,从Users表中删除数据。

在开发过程中,我在包代码中包含了大量的fmt.Println()语句用于调试目的。然而,我在 Go 包的最终版本中去掉了大部分这些语句,并用error值替换了它们。这些error值被传递给使用包功能程序的程序,该程序负责决定如何处理错误消息和错误条件。你也可以使用日志记录来做到这一点——输出可以发送到标准输出,甚至在不需要时发送到/dev/null

现在,让我们来检查ListUsers()函数的实现。

func ListUsers() ([]Userdata, error) {
    Data := []Userdata{}
    db, err := openConnection()
    if err != nil {
        return nil, err
    }
    defer db.Close() 

再次强调,在执行任何数据库查询之前,我们需要打开到数据库的连接。

 rows, err := db.Query(`SELECT ID, Username, Name, Surname, Description FROM Users, Userdata WHERE Users.ID = Userdata.UserID`)
    defer rows.Close()
    if err != nil {
        return Data, err
    } 

这是查询从两个表中读取所有数据的查询。之后,我们使用rows变量来获取查询的结果:

 for rows.Next() {
        var id int
var username string
var name string
var surname string
var desc string
        err = rows.Scan(&id, &username, &name, &surname, &desc)
        temp := Userdata{ID: id, Username: username, Name: name, Surname: surname, Description: desc} 

在这一点上,我们将从SELECT查询中接收到的数据存储在Userdata结构体中。这被添加到将要从ListUsers()函数返回的切片中。这个过程会一直持续到没有更多内容可以读取:

 Data = append(Data, temp)
        if err != nil {
            return nil, err
        }
    }
    return Data, nil
} 

使用append()更新Data切片的内容后,我们结束查询,函数返回存储在Data变量中的可用用户列表。

最后,让我们来检查UpdateUser()函数:

func UpdateUser(d Userdata) error {
    db, err := openConnection()
    if err != nil {
        return err
    }
    defer db.Close()
    userID := exists(d.Username)
    if userID == -1 {
        return errors.New("User does not exist")
    } 

首先,我们需要确保给定的用户名存在于数据库中——更新过程基于用户名。

d.ID = userID
    updateStatement := `UPDATE Userdata set Name = ?, Surname = ?, Description = ? where UserID = ?`
    _, err = db.Exec(updateStatement, d.Name, d.Surname, d.Description, d.ID)
if err != nil {
        return err
    }
    return nil
} 

存储在updateStatement中的更新语句,通过db.Exec()使用所需的参数执行,用于更新用户数据。

在你完成编写代码后,你应该执行以下命令:

$ go mod init
go: creating new go.mod: module github.com/mactsouk/sqlite06
go: to add module requirements and sums:
    go mod tidy 

前一个命令告诉 Go 这是一个具有外部依赖的包。

$ go mod tidy
go: finding module for package github.com/mattn/go-sqlite3
go: found github.com/mattn/go-sqlite3 in github.com/mattn/go-sqlite3 v1.14.17 

go mod tidy命令下载所有必需的依赖项(如果有)。

现在我们已经知道了如何在sqlite06包中实现每个函数的细节,现在是时候开始使用这个包了!

测试 Go 包

为了测试这个包,我们必须开发一个名为sqliteGo.go的命令行工具。由于sqliteGo.go使用外部包,即使我们已经开发了那个包,我们也不应该忘记将其放在~/go/src的某个地方。如果你下载了这本书的 GitHub 仓库,你将在ch06/usePackage中找到它。由于sqliteGo.go仅用于测试目的,我们除了将用户名放入数据库之外,大多数数据都是硬编码的。所有用户名都是随机生成的。

sqliteGo.go的代码分为六个部分。第一部分如下:

package main
import (
    "fmt"
"math/rand"
"strings"
"time"
"github.com/mactsouk/sqlite06"
) 

在这个第一部分,我们导入必要的 Go 包,包括我们自己的sqlite06

sqliteGo.go的第二部分包含以下代码:

var MIN = 0
var MAX = 26
func random(min, max int) int {
    return rand.Intn(max-min) + min
}
func getString(length int64) string {
    startChar := "A"
    temp := ""
var i int64 = 1
for {
        myRand := random(MIN, MAX)
        newChar := string(startChar[0] + byte(myRand))
        temp = temp + newChar
        if i == length {
            break
        }
        i++
    }
    return temp
} 

前面的代码生成指定长度的随机字符串。这些字符串包含大写常规字符。

sqliteGo.go 的第三部分包含以下代码:

func main() {
    sqlite06.Filename = "ch06.db"
    data, err := sqlite06.ListUsers()
    if err != nil {
        fmt.Println("ListUsers():", err)
        return
    }
    if len(data) != 0 {
        for _, v := range data {
            fmt.Println(v)
        }
    } 

这就是 main() 实现的开始。第一条语句是我们定义 SQLite3 数据库名的地方。尽管我们使用外部包,但数据库将会在我们执行 sqliteGo.go 的目录中创建。

之后,我们调用 sqlite06.ListUsers() 来获取可用用户列表。

sqliteGo.go 的第四部分如下:

 SEED := time.Now().Unix()
    rand.Seed(SEED)
    random_username := strings.ToLower(getString(5))
    t := sqlite06.Userdata{
        Username:    random_username,
        Name:        "Mihalis",
        Surname:     "Tsoukalos",
        Description: "This is me!"}
    fmt.Println("Adding username:", random_username)
    id := sqlite06.AddUser(t)
    if id == -1 {
        fmt.Println("There was an error adding user", t.Username)
    } 

前面的代码生成一个随机用户名,创建并填充一个 Userdata 结构,然后调用 sqlite06.AddUser() 来添加新用户。

sqliteGo.go 的第五部分包含以下代码:

 err = sqlite06.DeleteUser(id)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println("User with ID", id, "deleted!")
    }
    // Trying to delete the same user again!
    err = sqlite06.DeleteUser(id)
    if err != nil {
        fmt.Println(err)
    } 

在前面的代码中,我们尝试通过两次调用 sqlite06.DeleteUser() 来删除同一个用户两次。在这种情况下,我们预计第二次尝试会失败。

sqliteGo.go 的最后一部分如下:

 random_username = strings.ToLower(getString(5))
    random_name := getString(7)
    random_surname := getString(10)
    dsc := time.Now().Format(time.RFC1123)
    t = sqlite06.Userdata{
        Username:    random_username,
        Name:        random_name,
        Surname:     random_surname,
        Description: dsc}
    id = sqlite06.AddUser(t)
    if id == -1 {
        fmt.Println("There was an error adding user", t.Username)
    }
    dsc = time.Now().Format(time.RFC1123)
    t.Description = dsc
    err = sqlite06.UpdateUser(t)
    if err != nil {
        fmt.Println(err)
    }
} 

在最后一部分,我们使用随机数据添加另一个用户,然后通过调用 sqlite06.UpdateUser() 更新其描述。

在使用 sqliteGo.go 之前,你应该在 SQLite3 数据库中创建两个表。此外,SQLite3 数据库文件的名称应该是 ch06.db,除非你在 sqliteGo.go 中更改它。准备的最简单方法是使用 sqlite3 工具运行以下命令:

$ sqlite3 ch06.db
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite> .read createTables.sql 

第一个命令创建了 ch06.db 数据库,而第二个命令执行了 createTables.sql 文件中的代码,该文件包含在 usePackage 目录中。createTables.sql 文件的内容如下:

DROP TABLE IF EXISTS Users;
DROP TABLE IF EXISTS Userdata;
CREATE TABLE Users (
    ID INTEGER PRIMARY KEY,
    Username TEXT
);
CREATE TABLE Userdata (
    UserID INTEGER NOT NULL,
    Name TEXT,
    Surname TEXT,
    Description TEXT
); 

执行 createTables.sql 文件后,你可以按照以下方式验证其结果:

$ sqlite3 ch06.db
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite> .tables
Userdata  Users
sqlite> .schema
CREATE TABLE Users (
    ID INTEGER PRIMARY KEY,
    Username TEXT
);
CREATE TABLE Userdata (
    UserID INTEGER NOT NULL,
    Name TEXT,
    Surname TEXT,
    Description TEXT
); 

.tables 命令仅列出可用的表名,而 .schema 命令还会显示有关可用表的信息。

现在我们已经搭建了必要的基础设施并使其运行,我们可以执行 sqliteGo.go。但在那之前,我们需要启用模块并下载任何包依赖项:

$ go mod init
go: creating new go.mod: module github.com/mactsouk/mGo4th/ch06/usePackage
go: to add module requirements and sums:
    go mod tidy
$ go mod tidy
go: finding module for package github.com/mactsouk/sqlite06
go: downloading github.com/mactsouk/sqlite06 v0.0.0-20230817125241-55d77b17637d
go: found github.com/mactsouk/sqlite06 in github.com/mactsouk/sqlite06 v0.0.0-20230817125241-55d77b17637d
go: finding module for package github.com/mattn/go-sqlite3
go: found github.com/mattn/go-sqlite3 in github.com/mattn/go-sqlite3 v1.14.17 

使用 sqliteGo.go 工作会生成以下类型的输出:

$ go run sqliteGo.go
Adding username: yzpon
User with ID 1 deleted!
User with ID 1 does not exist
$ go run sqliteGo.go
{1 vjyps AWJTWCI YIXXXQHSQA Thu, 17 Aug 2023 23:26:40 EEST}
Adding username: cqrxf
User with ID 2 deleted!
User with ID 2 does not exist 

你执行它的次数越多,你将向相关表添加更多的数据。此外,之前的输出确认 sqliteGo.go 正如预期那样工作,因为它可以连接到数据库,添加新用户,更新用户,并删除现有用户。这也意味着 sqlite06 包按预期工作。现在我们知道了如何创建 Go 包,让我们简要讨论一下 Go 模块。

模块

Go 模块类似于带有版本的 Go 包——然而,Go 模块可以由多个包组成。Go 使用语义版本对模块进行版本控制。这意味着版本以字母v开头,后面跟着major.minor.patch版本号。因此,你可以有v1.0.0v1.0.5v2.0.2这样的版本。v1v2v3部分表示 Go 包的主版本,通常不向后兼容。这意味着如果你的 Go 程序与v1兼容,它不一定与v2v3兼容——它可能兼容,但你不能指望它。版本中的第二个数字是关于特性的。通常,v1.1.0v1.0.2v1.0.0有更多的特性,同时与所有旧版本兼容。最后,第三个数字只是关于错误修复,没有添加任何新特性。请注意,语义版本控制也用于 Go 版本。

Go 模块在 Go v1.11 中引入,但在 Go v1.13 中最终确定。

如果你想了解更多关于模块的信息,请访问并阅读go.dev/blog/using-go-modules,它分为五个部分,以及go.dev/doc/modules/developing。只需记住,Go 模块与带有版本的常规 Go 包相似但并不相同,并且一个模块可以由多个包组成。

创建更好的包

本节提供了一些实用的建议,可以帮助你开发更好的 Go 包。以下是一些遵循以创建高质量 Go 包的好规则:

  • 成功包的第一条非正式规则是,其元素必须以某种方式连接在一起。因此,你可以创建一个支持汽车的包,但为汽车、自行车和飞机创建一个单独的包并不是一个好主意。简单来说,将包的功能不必要地拆分成多个包比在单个 Go 包中添加过多的功能要好。

  • 第二个实用的规则是,在将包公开之前,你应该先用一段时间使用自己的包。这有助于你发现错误并确保你的包按预期运行。之后,在将它们公开之前,给一些同行开发者进行额外的测试。此外,你应该始终为任何打算供他人使用的包编写测试。

  • 接下来,确保你的包有一个清晰且有用的 API,以便任何消费者都可以快速地使用它。

  • 尽量限制你包的公共 API 只包含必要的部分。此外,给你的函数起描述性但不要太长的名字。

  • 接口和泛型可以提高函数的有用性,所以当你认为合适的时候,使用接口或泛型数据类型而不是单一类型作为函数的参数或返回类型。

  • 当更新你的一个包时,尽量不要破坏东西并创建与旧版本不兼容的情况,除非这是绝对必要的。

  • 在开发新的 Go 包时,尽量使用多个文件来分组相似的任务或概念。

  • 不要从头开始创建一个已经存在的包。修改现有包,也许可以创建你自己的版本。

  • 没有人想要一个在屏幕上打印日志信息的 Go 包。如果需要,有一个用于开启日志的标志会更专业。你的包中的 Go 代码应该与你的程序中的 Go 代码保持一致。这意味着如果你查看使用你的包的程序,并且你的函数名在代码中以不好的方式突出,那么更改你函数的名称会更好。由于包名几乎在所有地方都会被使用,尽量使用简洁且具有表达力的包名。

  • 如果将新的 Go 类型定义放在它们首次使用的地方附近会更方便,因为没有人,包括你自己,愿意在庞大的源文件中搜索新数据类型的定义。

  • 尽量为你的包创建测试文件,因为包含测试文件的包被认为比没有测试文件的包更专业;细节决定一切,并给人留下你是一个认真开发者的信心!请注意,为你的包编写测试不是可选的,你应该避免使用不包含测试的包。你将在第十二章,代码测试和性能分析中了解更多关于测试的内容。

总要记住,除了包中的实际 Go 代码应该是无错误的之外,一个成功包的下一个最重要的元素是其文档,以及一些澄清其使用并展示包函数特性的代码示例。下一节将讨论在 Go 中创建文档。

生成文档

本节讨论了如何使用 sqlite06 包的代码作为示例来创建 Go 代码的文档。新包已被重命名,现在称为 document——你可以在本书的 GitHub 仓库的 ch06/document 中找到它。

Go 在文档方面遵循一个简单的规则:为了文档化一个函数、方法、变量,甚至整个包,你可以像往常一样写注释,这些注释应该直接位于你想要文档化的元素之前,之间没有空行。你可以使用一个或多个单行注释,这些注释以 // 开头,或者块注释,这些注释以 /* 开头并以 */ 结尾——两者之间的所有内容都被视为注释。

强烈建议你创建的每个 Go 包在包声明之前都有一个块注释,该注释向开发者介绍包,并解释包的功能。

我们不会展示sqlite06包的整个代码,该包已被重命名为document,我们只会展示重要的部分,这意味着这里的函数实现将是空的(实际的文件包含完整版本)。sqlite06.go的新版本称为document.go,并包含以下代码和注释:

/*
The package works on 2 tables on an SQLite database.
The names of the tables are:
    * Users
    * Userdata
The definitions of the tables are:
    CREATE TABLE Users (
        ID INTEGER PRIMARY KEY,
        Username TEXT
    );
    CREATE TABLE Userdata (
        UserID INTEGER NOT NULL,
        Name TEXT,
        Surname TEXT,
        Description TEXT
    );
    This is rendered as code
This is not rendered as code
*/
package document 

这是位于包名称之前的第一块文档。这是记录包功能以及其他重要信息的适当位置。在这种情况下,我们展示了 SQL 的CREATE TABLE命令,这些命令完全描述了我们将要工作的数据库表。另一个重要元素是指定该包交互的数据库服务器。你还可以在包的开始处放置的其他信息包括作者、许可证和包的版本。

如果块注释中的一行以制表符开头,那么在图形输出中会以不同的方式渲染,这对于在文档中区分各种信息是有好处的。

在编写文档时,BUG关键字是特殊的。Go 知道错误是代码的一部分,因此也应该进行文档化。你可以在BUG关键字之后写上任何你想说的话,并且你可以将它们放在任何你想放的地方——最好是靠近它们所描述的错误:

// BUG(1): Function ListUsers() not working as expected
// BUG(2): Function AddUser() is too slow 

接下来,我们展示该包的实现细节。

import (
    "database/sql"
"errors"
"fmt"
"strings"
    _ "github.com/mattn/go-sqlite3"
) 

这是包的import块——这里没有什么特别之处。

以下代码展示了如何对全局变量进行文档化——这也适用于多个变量:

/*
This global variable holds the SQLite3 database filepath
    Filename: In the filepath to the database file
*/
var (
    Filename = ""
) 

这种方法的优点是,你不必在每个全局变量前都放置注释,这样可以使得代码更易于阅读。然而,这种方法的一个缺点是,如果你想要对代码进行任何修改,你应该记得更新注释。然而,一次性对多个变量进行文档化可能不会在基于网页的godoc页面上正确显示。因此,你可能想要独立地对每个字段进行文档化。

下面的摘录展示了如何对 Go 结构进行文档化——这在源文件中有许多结构时特别有用,你想要快速查看它们:

// The Userdata structure is for holding full user data
// from the Userdata table and the Username from the
// Users table
type Userdata struct {
    ID          int
    Username    string
    Name        string
    Surname     string
    Description string
} 

在编写函数文档时,最好在注释的第一行开始就写上函数名。除此之外,你可以在注释中写上你认为重要的任何信息。

// openConnection() is for opening the SQLite3 connection
// in order to be used by the other functions of the package.
func openConnection() (*sql.DB, error) {
} 

接下来,我们解释exists()函数的返回值,因为它们具有特殊含义。

// The function returns the User ID of the username
// -1 if the user does not exist
func exists(username string) int {
} 

你可以在任何你想的地方使用块注释,而不仅仅是包的开始处,如下面的摘录所示:

// AddUser adds a new user to the database
//
// Returns new User ID
// -1 if there was an error
func AddUser(d Userdata) int {
}
/*
DeleteUser deletes an existing user if the user exists.
It requires the User ID of the user to be deleted.
*/
func DeleteUser(id int) error {
} 

当你请求Userdata结构的文档时,Go 会自动展示使用Userdata的函数,就像ListUsers()那样发生。

// ListUsers() lists all users in the database.
//
// Returns a slice of Userdata to the calling function.
func ListUsers() ([]Userdata, error) {
    // Data holds the records returned by the SQL query
    Data := []Userdata{}
} 

与我们之前看到的一样,这是UpdateUser()函数的文档。

/*
UpdateUser() is for updating an existing user
given a Userdata structure.
The user ID of the user to be updated is found
inside the function.
*/
func UpdateUser(d Userdata) error {
} 

我们还没有完成,因为我们需要以某种方式查看文档。查看包文档有两种方法。第一种方法涉及使用 go get,这也意味着创建一个与 sqlite06 相同的 GitHub 仓库。然而,由于这是测试目的,我们将使用第二种简单的方法:由于包已经位于 ~/go/src 下,我们可以从那里访问它——我在 ~/go/src/github.com/mactsouk/mGo4th/ch06/document 进行开发。因此,go doc 命令将与 document 包一起正常工作。

$ go doc document.go
package document // import "command-line-arguments"
The package works on 2 tables on an SQLite database.
The names of the tables are:
  - Users
  - Userdata
The definitions of the tables are:
        CREATE TABLE Users (
            ID INTEGER PRIMARY KEY,
            Username TEXT
        );
        CREATE TABLE Userdata (
            UserID INTEGER NOT NULL,
            Name TEXT,
            Surname TEXT,
            Description TEXT
        );
        This is rendered as code
This is not rendered as code
var Filename = ""
func AddUser(d Userdata) int
func DeleteUser(id int) error
func UpdateUser(d Userdata) error
type Userdata struct{ ... }
    func ListUsers() ([]Userdata, error)
BUG: Function ListUsers() not working as expected
BUG: Function AddUser() is too slow 

请记住,只显示公共元素的文档

如果你想查看特定函数的信息,你应该使用 go doc,如下所示:

$ go doc document.go ListUsers
package document // import "command-line-arguments"
func ListUsers() ([]Userdata, error)
    ListUsers() lists all users in the database.
    Returns a slice of Userdata to the calling function. 

工作空间

工作空间是 Go 的一个相对较新的特性。当你以工作空间模式工作时,你可以同时处理多个模块。Go 工作空间包含 源文件和编译后的二进制文件。通常,如果你不想使用工作空间,你不必强制使用

幸运的是,Go 很灵活,允许开发者做出自己的决定。然而,了解 Go 的特性很重要,即使你并不总是想使用它们。并非所有的 Go 特性都适合每个人。

当使用 Go 工作空间时,你通过一个名为 go.work 的文件来控制所有依赖项,该文件位于工作空间的根目录中。在 go.work 中存在 usereplace 指令,它们覆盖了工作空间目录中 go.mod 文件中找到的信息——这让你免去了手动编辑 go.mod 文件的麻烦。

现在我们来看一个使用工作空间的例子。假设我们想在系统中的稳定版本存在的情况下进一步开发 sqlite06 包。实现这一目标的一种方法是通过工作空间,我们将保留一个本地副本的 sqlite06 包,我们将对其进行修改和测试。出于简单起见,我们只将与一个函数一起工作。更具体地说,我们将使 openConnection() 函数公开,这意味着我们将将其重命名为 OpenConnection()

首先,我们从 ch06 目录执行以下命令:

$ mkdir ws
$ cd ws
$ cp -r ~/go/src/github.com/mactsouk/sqlite06 .
$ cd sqlite06
$ rm go.mod go.sum
$ go mod init
$ go mod tidy
$ cd .. 

之前的命令是为了创建 sqlite06 模块的一个本地副本。你将在 ws 中找到的版本相当简单。

$ mkdir util
$ cp ~/go/src/github.com/mactsouk/mGo4th/ch06/usePackage/sqliteGo.go .
$ cd util
$ go mod init
$ go mod tidy
$ cd .. 

之前的命令是为了复制我们为测试 sqlite06 模块而创建的命令行工具。由于我们只打算使用 sqlite06 中的一个函数,因此我们还将修改 sqliteGo.go 以仅调用该函数。

go work init . 命令创建工作空间。

$ go work use ./util
$ cat go.work
go 1.21.0
use ./util 

之前的命令表示我们想要为 ./util 目录中的模块创建一个工作空间。

$ go work use ./sqlite06
$ cat go.work
go 1.21.0
use (
    ./sqlite06
    ./util
)
$ 

之前的命令表示我们想要使用本地副本。go.work 中缺少另一个命令,如下所示:

replace github.com/mactsouk/sqlite06 => ./sqlite06 

最后一条命令告诉 Go,我们想要替换 github.com/mactsouk/sqlite06 为在./sqlite06目录中找到的模块版本,这是我们实际正在更改的副本。这是整个过程中最重要的命令。

之后,我们就准备好尝试运行修改过的./util/sqliteGo.go版本。

$ go run ./util/sqliteGo.go
Connection string: &{{{} {} 0} {ch06.db 0x14000072020} {{} {} 0} {0 0} [] map[] 0 0 0x1400001c120 false map[] map[] 0 0 0 0 <nil> 0 0 0 0 0x1001512d0} 

输出验证了我们已经执行了本地和修改过的sqlite06模块版本!这意味着我们可以继续开发和修改sqlite06模块,当我们完成时,我们可以用新版本替换原始版本!

如果您想了解go work命令的所有选项,请输入go help work

本章的最后部分是关于版本化实用程序和定义唯一的版本字符串。

版本化实用程序

最困难的任务之一是自动且唯一地版本化命令行工具,尤其是在使用 CI/CD 系统时。本节介绍了一种使用 GitHub 值在本地机器上对命令行工具进行版本化的技术。您可以将相同的技巧应用于 GitLab——只需搜索可用的 GitLab 变量和值,并选择一个适合您需求的值。

这种技术被dockerkubectl等实用程序以及其他工具所使用:

$ docker version
Client:
 Version:           24.0.5
 API version:       1.43
 Go version:        go1.20.6
 Git commit:        ced0996600
 Built:             Wed Jul 26 21:44:58 2023
 OS/Arch:           linux/amd64
 Context:           default
Server:
 Engine:
  Version:          24.0.5
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.6
  Git commit:       a61e2b4c9c
  Built:            Wed Jul 26 21:44:58 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v1.7.2
  GitCommit:        0cae528dd6cb557f7201036e9f43420650207b58.m
 runc:
  Version:          1.1.9
  GitCommit:
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0 

之前的输出显示docker使用 Git 提交值进行版本控制——我们将使用一个比docker使用的更长的值。

使用的实用程序,保存为gitVersion.go,实现如下:

package main
import (
    "fmt"
"os"
)
var VERSION string 

VERSION是将在运行时使用 Go 链接器设置的变量。

func main() {
    if len(os.Args) == 2 {
        if os.Args[1] == "version" {
            fmt.Println("Version:", VERSION)
        }
    }
} 

之前的代码表明,如果存在命令行参数且其值为version,则使用VERSION变量打印版本信息。

我们需要做的是告诉 Go 链接器我们将要定义VERSION变量的值。这通过-ldflags标志来实现,它代表链接器标志——这会将值传递给cmd/link包,允许我们在构建时更改导入包中的值。使用的-X值需要一个键/值对,其中键是变量名,值是我们想要为该键设置的值。在我们的情况下,键具有main.Variable的形式,因为我们更改了主包中变量的值。由于gitVersion.go中变量的名称是VERSION,因此键是main.VERSION

但首先,我们需要决定将用作版本字符串的 GitHub 值。git rev-list HEAD 命令返回当前仓库从最新到最旧的完整提交列表。我们只需要最后一个——最新的,我们可以使用 git rev-list -1 HEADgit rev-list HEAD | head -1 来获取它。因此,我们需要将这个值分配给一个环境变量,并将这个环境变量传递给 Go 编译器。由于这个值每次提交都会改变,并且你总是希望拥有最新的值,你应该在每次执行 go build 时重新评估它——这将在稍后展示。

为了向 gitVersion.go 提供所需环境变量的值,我们应该按照以下方式执行它:

$ export VERSION=$(git rev-list -1 HEAD)
$ go build -ldflags "-X main.VERSION=$VERSION" gitVersion.go 

这在 bashzsh shell 上都有效。如果你使用的是不同的 shell,你应该确保你正在正确地定义环境变量。

如果你想要同时执行这两个命令,你可以这样做:

$ export VERSION=$(git rev-list -1 HEAD) && go build -ldflags "-X main.VERSION=$VERSION" gitVersion.go 

运行生成的可执行文件,称为 gitVersion,会产生以下输出:

$ ./gitVersion version
Version: 4dc3d6b5fd030bf7075ed26f9ab471e8835a8a77 

你的输出将会不同,因为你的 GitHub 仓库将会不同。由于 GitHub 生成随机且唯一的值,你不会两次拥有相同的版本号!

摘要

本章介绍了两个主要主题:函数和包。函数是 Go 的一等公民,这使得它们强大且方便。记住,所有以大写字母开头的东西都是公开的。唯一的例外是包名。私有变量、函数、数据类型名称和结构字段可以在包内部严格使用和调用,而公共的则对每个人可用。此外,我们还了解了更多关于 defer 关键字的内容。此外,记住 Go 包不像 Java 类——Go 包可以大如所需。关于 Go 模块,请记住,Go 模块是多个带有版本的包。

最后,这一章讨论了创建文档,这是开发、工作空间和版本控制命令行工具的重要部分。

下一章将更详细地讨论系统编程以及文件 I/O。

练习

  • 你能编写一个对三个 int 值进行排序的函数吗?尝试编写两个版本的函数:一个带有命名返回值,另一个没有命名返回值。你认为哪一个更好?

  • sqlite06 包不支持按用户名搜索。你能实现这个功能吗?

  • 重新编写 sqlite06 包,使其能够与 MySQL 数据库一起工作。

其他资源

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第七章:告诉 UNIX 系统做什么

本章是关于 Go 语言系统编程的。系统编程涉及与文件和目录、进程控制、信号处理、网络编程、系统文件、配置文件以及文件输入和输出I/O)的工作。如果你还记得第一章Go 语言快速入门,使用 Linux 系统编写系统工具的原因是,通常,Go 软件是在 Docker 环境中执行的——Docker 镜像使用 Linux 操作系统,这意味着你可能需要考虑 Linux 操作系统来开发你的工具。然而,由于 Go 代码的可移植性,大多数系统工具在 Windows 机器上无需任何更改或仅做少量修改即可工作。要记住的关键思想是 Go 使得系统编程更加可移植。此外,在本章中,我们将借助cobra包来改进统计应用程序。

如前所述,从 Go 1.16 版本开始,GO111MODULE环境变量默认设置为on——这影响了不属于 Go 标准库的 Go 包的使用。在实践中,这意味着你必须将你的代码放在~/go/src下。

本章涵盖:

  • stdin, stdout, 和 stderr

  • UNIX 进程

  • 文件 I/O

  • 读取纯文本文件

  • 向文件写入

  • 与 JSON 一起工作

  • viper

  • cobra

  • 重要的 Go 特性

  • 更新统计应用程序

stdin, stdout, 和 stderr

每个 UNIX 操作系统都会为它的进程始终打开三个文件。记住,UNIX 将一切视为文件,即使是打印机或鼠标。UNIX 使用文件描述符,即正整数值,作为访问打开文件的内部表示,这比使用长路径要美观得多。因此,默认情况下,所有 UNIX 系统都支持三个特殊的标准文件名:/dev/stdin/dev/stdout/dev/stderr,它们也可以分别使用文件描述符012来访问。这三个文件描述符也分别被称为标准输入、标准输出和标准错误。此外,文件描述符0在 macOS 机器上可以访问为/dev/fd/0,在 Debian Linux 机器上可以访问为/dev/fd/0/dev/pts/0

Go 使用os.Stdin来访问标准输入,os.Stdout来访问标准输出,以及os.Stderr来访问标准错误。虽然你仍然可以使用/dev/stdin/dev/stdout/dev/stderr或相关的文件描述符值来访问相同的设备,但坚持使用os.Stdinos.Stdoutos.Stderr会更佳、更安全、更可移植。

UNIX 进程

由于 Go 服务器、工具和 Docker 镜像主要在 Linux 上执行,了解 Linux 进程和线程是有好处的。

严格来说,进程是一个包含指令、用户数据和系统数据部分以及其他在运行时获得的资源类型的执行环境。另一方面,程序是一个包含指令和数据二进制文件,这些指令和数据用于初始化进程的指令和数据部分。每个运行的 UNIX 进程都有一个唯一的无符号整数标识符,称为进程 ID。

存在三种进程类别:用户进程、守护进程和内核进程。用户进程在用户空间运行,通常没有特殊访问权限。守护进程是可以在用户空间找到的程序,可以在后台运行而不需要终端。内核进程仅在内核空间执行,可以完全访问所有内核数据结构。

使用 C 语言创建新进程的方式涉及调用 fork(2) 系统调用。fork(2) 的返回值允许程序员区分父进程和子进程。虽然你可以使用 exec 包在 Go 中创建新的进程,但 Go 不允许你控制线程——Go 提供了 goroutines,用户可以在由 Go 运行时创建和处理的线程之上创建 goroutines,这部分由操作系统控制。

现在,我们需要学习如何在 Go 中读取和写入文件。

文件 I/O

本节讨论 Go 中的文件 I/O,包括使用 io.Readerio.Writer 接口、缓冲和非缓冲 I/O,以及 bufio 包。

io/ioutil 包(pkg.go.dev/io/ioutil)自 Go 版本 1.16 起已被弃用。使用 io/ioutil 功能的现有 Go 代码将继续工作,但最好停止使用该包。

io.Reader 和 io.Writer 接口

本小节介绍了流行的 io.Readerio.Writer 接口的定义,因为这两个接口是 Go 中文件 I/O 的基础——前者允许你从文件中读取,而后者允许你向文件写入。io.Reader 接口的定义如下:

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

当我们要使我们的数据类型满足 io.Reader 接口时应该重新审视的此定义,告诉我们以下信息:

  • Reader 接口需要实现一个方法。

  • Read() 方法接收一个字节切片作为输入,该切片将被填充至其长度。

  • Read() 方法返回读取的字节数以及一个 error 变量。

io.Writer 接口的定义如下:

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

之前的定义,当我们要使我们的数据类型满足 io.Writer 接口并将数据写入文件时应该重新审视,揭示了以下信息:

  • 该接口需要实现一个方法。

  • Write() 方法接收一个字节切片,其中包含要写入的数据。

  • Write() 方法返回写入的字节数和一个 error 变量。

使用和误用 io.Reader 和 io.Writer

下面的代码展示了如何使用 io.Readerio.Writer 来处理 自定义数据类型,在这个例子中,是两个名为 S1S2 的 Go 结构体。

对于 S1 结构体,展示的代码实现了两个接口,分别用于从终端读取用户数据以及将数据打印到终端。尽管这有些冗余,因为我们已经有了 fmt.Scanln()fmt.Printf(),但这是一个很好的练习,展示了这两个接口的多样性和灵活性。在不同的情境下,你可以使用 io.Writer 来写入日志服务,保留第二个数据备份,或者满足你需求的任何其他东西。然而,这也是接口允许你做疯狂或,如果你愿意,不寻常的事情的一个例子。开发者需要使用适当的 Go 概念和特性来创建所需的功能!

Read() 方法使用 fmt.Scanln() 从终端获取用户输入,而 Write() 方法使用 fmt.Printf() 将其缓冲区参数的内容打印出结构体 F1 字段值的次数!

对于 S2 结构体,展示的代码仅实现了 io.Reader 接口,以传统方式。Read() 方法读取 S2 结构体的文本字段,它是一个字节切片。当没有更多内容可读取时,Read() 方法返回预期的 io.EOF 错误,实际上这并不是一个错误,而是一个预期的状态。除了 Read() 方法外,还存在两个辅助方法,名为 eof(),它声明没有更多内容可读取,以及 readByte(),它逐字节读取 S2 结构体的文本字段。Read() 方法完成后,用作缓冲区的 S2 结构体的文本字段将被清空。

使用这个实现,S2io.Reader 可以以传统方式读取,在这种情况下,是使用 bufio.NewReader() 和多次 Read() 调用——Read() 调用的次数取决于使用的缓冲区大小,在这个例子中,是一个有两个数据位置的字节切片。

输入以下代码并将其保存为 ioInterface.go

package main
import (
    "bufio"
"fmt"
"io"
) 

之前的部分展示了我们正在使用 iobufio 包来处理文件。

type S1 struct {
    F1 int
    F2 string
}
type S2 struct {
    F1   S1
    text []byte
} 

这是我们将要工作的两个结构体。

// Using pointer to S1 for changes to be persistent
func (s *S1) Read(p []byte) (n int, err error) {
    fmt.Print("Give me your name: ")
    fmt.Scanln(&p)
    s.F2 = string(p)
    return len(p), nil
} 

在前面的代码中,我们实现了 S1io.Reader 接口。

func (s *S1) Write(p []byte) (n int, err error) {
    if s.F1 < 0 {
        return -1, nil
    }
    for i := 0; i < s.F1; i++ {
        fmt.Printf("%s ", p)
    }
    fmt.Println()
    return s.F1, nil
} 

之前的方法实现了 S1io.Writer 接口。

func (s S2) eof() bool {
    return len(s.text) == 0
}
func (s *S2) readByte() byte {
    // this function assumes that eof() check was done before
    temp := s.text[0]
    s.text = s.text[1:]
    return temp
} 

之前的功能是标准库中 bytes.Buffer.ReadByte 的实现。

func (s *S2) Read(p []byte) (n int, err error) {
    if s.eof() {
        err = io.EOF
        return 0, err
    }
    l := len(p)
    if l > 0 {
        for n < l {
            p[n] = s.readByte()
            n++
            if s.eof() {
                s.text = s.text[0:0]
                break
            }
        }
    }
    return n, nil
} 

之前的代码从给定的缓冲区读取,直到它为空。当所有数据都被读取后,相关的结构体字段将被清空。之前的方法实现了 S2io.Reader。然而,Read() 操作是由 eof()readByte() 支持的,这两个也是用户自定义的。

记住,Go 允许你给函数的返回值命名;在这种情况下,没有额外参数的return语句会自动返回函数签名中按顺序出现的每个命名返回变量的当前值。Read()方法可以使用该功能,但通常,裸返回被认为是不良实践。

func main() {
    s1var := S1{4, "Hello"}
    fmt.Println(s1var) 

我们初始化一个名为s1varS1变量。

 buf := make([]byte, 2)
    _, err := s1var.Read(buf) 

上行代码使用两个字节的缓冲区读取s1var变量。但是,该代码块并没有达到预期的效果,因为Read()方法的实现是从终端获取值——在这里我们误用了 Go 接口!

 if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Read:", s1var.F2)
    _, _ = s1var.Write([]byte("Hello There!")) 

在上一行中,我们调用s1varWrite()方法来写入字节数组的内容。

 s2var := S2{F1: s1var, text: []byte("Hello world!!")} 

在之前的代码中,我们初始化了一个名为s2varS2变量。

 // Read s2var.text
    r := bufio.NewReader(&s2var) 

我们现在为s2var创建一个读取器。

 for {
        n, err := r.Read(buf)
        if err == io.EOF {
            break 

我们会一直从s2var读取,直到出现io.EOF条件。

 } else if err != nil {
            fmt.Println("*", err)
            break
        }
        fmt.Println("**", n, string(buf[:n]))
    }
} 

运行ioInterface.go会产生以下输出:

$ go run ioInterface.go
{4 Hello} 

输出的第一行显示了s1var变量的内容。

Give me your name: Mike
Calling the Read() method of the s1var variable.
Read: Mike
Hello There! Hello There! Hello There! Hello There!
The previous line is the output of s1var.Write([]byte("Hello There!")).
** 2 He
** 2 ll
** 2 o 
** 2 wo
** 2 rl
** 2 d!
** 1 ! 

输出的最后部分展示了使用大小为二的缓冲区的读取过程。下一节将讨论带缓冲和不带缓冲的操作。

带缓冲和不带缓冲的文件 I/O

带缓冲的文件 I/O 发生在有缓冲区临时存储数据,在读取数据或写入数据之前。因此,你不会逐字节读取文件,而是可以一次性读取多个字节。你将数据放入缓冲区,并等待有人以期望的方式读取它。

不带缓冲的文件 I/O 发生在没有缓冲区在读取或写入之前临时存储数据的情况——这可能会影响程序的性能。

你可能接下来会问,如何决定何时使用带缓冲的文件 I/O 和何时使用不带缓冲的文件 I/O。当处理关键数据时,不带缓冲的文件 I/O 通常是一个更好的选择,因为带缓冲的读取可能会导致数据过时,而带缓冲的写入可能在计算机电源中断时导致数据丢失。然而,大多数情况下,这个问题没有明确的答案。这意味着你可以使用任何使你的任务更容易实现的方法。然而,请记住,带缓冲的读取器也可以通过减少从文件或套接字读取所需的系统调用来提高性能,因此程序员的选择可能会对性能产生实际影响。

此外,还有bufio包。正如其名所示,bufio是关于带缓冲的 I/O。内部,bufio包实现了io.Readerio.Writer接口,它将它们包装起来以创建bufio.Readerbufio.Writer类型。bufio包在处理纯文本文件时非常流行,你将在下一节中看到它的实际应用。

读取文本文件

在本节中,你将学习如何读取纯文本文件,以及如何使用提供获取随机数方式的/dev/random UNIX 设备。

逐行读取文本文件

逐行读取文件的函数位于byLine.go中,并命名为lineByLine()。逐行读取文本文件的技巧也用于逐字读取纯文本文件,以及逐字符读取纯文本文件,因为通常你按行处理纯文本文件。所提供的实用程序打印它所读取的每一行,这使得它成为cat(1)实用程序的简化版本。

首先,你使用bufio.NewReader()调用为所需的文件创建一个新的读取器。然后,你使用该读取器与bufio.ReadString()一起按行读取输入文件。技巧是通过bufio.ReadString()的参数实现的,该参数告诉bufio.ReadString()在找到该字符之前继续读取。当该参数是换行符(\n)时,不断调用bufio.ReadString()会导致按行读取输入文件。

lineByLine()的实现如下:

func lineByLine(file string) error {
    f, err := os.Open(file)
    if err != nil {
        return err
    }
    defer f.Close()
    r := bufio.NewReader(f) 

在确保你可以使用os.Open()打开给定的文件进行读取后,你使用bufio.NewReader()创建一个新的读取器。

 for {
        line, err := r.ReadString('\n') 

bufio.ReadString()返回两个值:读取的字符串和一个错误变量。

 if err == io.EOF {
            if len(line) != 0 {
                    fmt.Println(line)
            }
            break
        }
        if err != nil {
            fmt.Printf("error reading file %s", err)
            return err
        }
        fmt.Print(line) 

使用fmt.Print()而不是fmt.Println()来打印输入行表明换行符包含在每个输入行中。

 }
    return nil
} 

运行byLine.go会生成以下类型的输出:

$ go run byLine.go ~/csv.data
Dimitris,Tsoukalos,2101112223,1600665563
Mihalis,Tsoukalos,2109416471,1600665563
Jane,Doe,0800123456,1608559903 

之前的输出显示了使用byLine.go逐行显示的~/csv.data(使用你自己的纯文本文件)的内容。下一小节将展示如何逐字读取纯文本文件。

逐字读取文本文件

逐字读取纯文本文件是你在文件上想要执行的最有用的函数之一,因为你通常希望按单词处理文件——这在本小节中通过byWord.go中的代码进行说明。所需的功能在wordByWord()函数中实现。wordByWord()函数使用正则表达式来分隔输入文件每一行中找到的单词。regexp.MustCompile("[^\\s]+")语句中定义的正则表达式表示我们使用空白字符来分隔一个单词与另一个单词。

wordByWord()函数的实现如下:

func wordByWord(file string) error {
    f, err := os.Open(file)
    if err != nil {
        return err
    }
    defer f.Close()
    r := bufio.NewReader(f)
    re := regexp.MustCompile("[^\\s]+")
    for { 

这是我们定义用于将行拆分为单词的正则表达式的地方。

 line, err := r.ReadString('\n')
        if err == io.EOF {
            if len(line) != 0 {
                words := re.FindAllString(line, -1)
                for i := 0; i < len(words); i++ {
                    fmt.Println(words[i])
                }
            }
            break 

这是程序的难点部分。如果我们遇到一个不以换行符结束的行的文件末尾,我们也必须处理它,但之后,我们必须退出for循环,因为从该文件中再也没有什么可读的内容了。之前的代码处理了这一点。

 } else if err != nil {
            fmt.Printf("error reading file %s", err)
            return err
        } 

在这个程序部分,我们处理可能出现的潜在错误条件,这些条件可能会阻止我们读取文件。

 words := re.FindAllString(line, -1) 

这是我们将正则表达式应用于line变量以将其拆分为字段的地方,当读取的行以换行符结束。

 for i := 0; i < len(words); i++ {
            fmt.Println(words[i])
        } 

这个for循环只是打印words切片的字段。如果你想知道输入行中找到的单词数量,你只需找到len(words)调用的值。

 }
    return nil
} 

运行byWord.go会产生以下类型的输出:

$ go run byWord.go ~/csv.data
Dimitris,Tsoukalos,2101112223,1600665563
Mihalis,Tsoukalos,2109416471,1600665563
Jane,Doe,0800123456,1608559903 

由于~/csv.data不包含任何空白字符,每一行都被视为一个单独的单词!

逐字符读取文本文件

在本小节中,你将学习如何逐字符读取文本文件,除非你想开发一个文本编辑器,否则这是一个罕见的需求。你读取每一行,并使用带有范围的for循环将其分割,这会返回两个值。你丢弃第一个值,它是line变量中当前字符的位置,而使用第二个值。然而,这个值是一个 rune,这意味着你必须使用string()将其转换为字符。

charByChar()的实现如下:

func charByChar(file string) error {
  f, err := os.Open(file)
  if err != nil {
    return err
  }
  defer f.Close()
  r := bufio.NewReader(f)
  for {
    line, err := r.ReadString('\n')
    if err == io.EOF {
      if **len****(line) !=** **0** {
        for _, x := range line {
          fmt.Println(string(x))
        } 

再次提醒,我们应该特别注意那些不以换行符结尾的行。捕获此类行的条件是,我们已经到达了正在读取的文件的末尾,并且我们仍然有文本需要处理。

 }
      break
    } else if err != nil {
      fmt.Printf("error reading file %s", err)
      return err
    }
    for _, x := range line {
      fmt.Println(string(x))
    }
  }
  return nil
} 

注意,由于fmt.Println(string(x))语句,每个字符都会打印在一行上,这意味着程序的输出将会很大。如果你想要更紧凑的输出,你应该使用fmt.Print()函数。

运行byCharacter.go并使用head(1)进行过滤,不带任何参数,会产生以下类型的输出:

$ go run byCharacter.go ~/csv.data | head
D
...
,
T 

不带任何参数使用head(1)实用程序将输出限制为仅 10 行。输入man head以了解更多关于head(1)实用程序的信息。

下一个部分是关于从/dev/random读取的内容,它是一个 UNIX 系统文件。

/dev/random读取

在本小节中,你将学习如何从/dev/random系统设备读取。/dev/random系统设备的目的生成随机数据,你可能用它来测试你的程序,或者在这种情况下,作为随机数生成器的种子。从/dev/random获取数据可能有点棘手,这也是我们在这里特别讨论的主要原因。

devRandom.go的代码如下:

package main
import (
    "encoding/binary"
"fmt"
"os"
) 

你需要encoding/binary,因为你从/dev/random读取二进制数据,并将其转换为整数值。

func main() {
    f, err := os.Open("/dev/random")
    defer f.Close()
    if err != nil {
        fmt.Println(err)
        return
    }
    var seed int64
    binary.Read(f, binary.LittleEndian, &seed)
    fmt.Println("Seed:", seed)
} 

有两种表示形式称为小端大端,它们与内部表示的字节序相关。在我们的情况下,我们使用小端。端序与不同计算系统如何排序多个信息字节的方式有关。

一个关于字节序的实际情况的例子是不同语言以不同方式读取文本:欧洲语言通常是从左到右读取,而阿拉伯文本是从右到左读取的。

在大端表示法中,字节是从左到右读取的,而小端表示法是从右到左读取字节。对于需要 4 个字节来存储的 0x01234567 值,大端表示法是 01 | 23 | 45 | 67,而小端表示法是 67 | 45 | 23 | 01

运行 devRandom.go 会产生以下类型的输出:

$ go run devRandom.go
Seed: 422907465220227415 

这意味着 /dev/random 设备是一个获取随机数据的好地方,包括随机数生成器的种子值。

从文件中读取特定数量的数据

本小节教你如何从文件中读取特定数量的数据。当你想查看文件的一小部分时,这个实用程序会很有用。作为命令行参数给出的数值指定了将要用于读取的缓冲区的大小。readSize.go 中的最重要的代码是 readSize() 函数的实现:

func readSize(f *os.File, size int) []byte {
    buffer := make([]byte, size)
    n, err := f.Read(buffer) 

所有的魔法都在 buffer 变量的定义中发生,因为这是我们定义它可以保存的最大数据量的地方。因此,每次我们调用 readSize(),该函数将从 f 中最多读取 size 个字符。

 // io.EOF is a special case and is treated as such
if err == io.EOF {
        return nil
    }
    if err != nil {
        fmt.Println(err)
        return nil
    }
    return buffer[0:n]
} 

剩余的代码是关于错误条件;io.EOF 是一个特殊且预期的条件,应该单独处理,并将读取的字符作为字节切片返回给调用函数。

运行 readSize.go 会产生以下类型的输出。

$ go run readSize.go 12 readSize.go
package main 

在这种情况下,我们由于 12 参数而从 readSize.go 本身读取了 12 个字符。现在我们已经知道如何读取文件,是时候学习如何向文件写入数据了。

向文件写入

到目前为止,我们已经看到了读取文件的方法。本小节展示了如何以四种不同的方式将数据写入文件,以及如何向现有文件追加数据。writeFile.go 的代码如下:

package main
import (
    "bufio"
"fmt"
"io"
"os"
)
func main() {
    buffer := []byte("Data to write\n")
    f1, err := os.Create("/tmp/f1.txt") 

os.Create() 返回一个与文件路径关联的 *os.File 值。请注意,如果文件已经存在,os.Create() 将截断它。

 if err != nil {
        fmt.Println("Cannot create file", err)
        return
    }
    defer f1.Close()
    fmt.Fprintf(f1, string(buffer)) 

fmt.Fprintf() 函数需要一个 string 变量,它可以帮助你使用你想要的格式将数据写入自己的文件。唯一的要求是拥有一个 io.Writer 来写入。在这种情况下,一个有效的 *os.File 变量,它满足 io.Writer 接口,就可以完成这项工作。

 f2, err := os.Create("/tmp/f2.txt")
    if err != nil {
        fmt.Println("Cannot create file", err)
        return
    }
    defer f2.Close()
    n, err := f2.WriteString(string(buffer)) 

WriteString() 将字符串的内容写入一个有效的 *os.File 变量。

 fmt.Printf("wrote %d bytes\n", n)
    f3, err := os.Create("/tmp/f3.txt") 

在这里,我们创建一个临时文件。

Go 还提供了 os.CreateTemp() 来创建临时文件。输入 go doc os.CreateTemp 来了解更多信息。

 if err != nil {
        fmt.Println(err)
        return
    }
    w := bufio.NewWriter(f3) 

此函数返回一个 bufio.Writer,它满足 io.Writer 接口。

 n, err = w.WriteString(string(buffer))
    fmt.Printf("wrote %d bytes\n", n)
    w.Flush()
    f := "/tmp/f4.txt"
    f4, err := os.Create(f)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer f4.Close()
    for i := 0; i < 5; i++ {
        n, err = io.WriteString(f4, string(buffer))
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Printf("wrote %d bytes\n", n)
    }
    // Append to a file
    f4, err = os.OpenFile(f, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 

os.OpenFile() 提供了一种更好的方式来创建或打开文件进行写入。os.O_APPEND 表示如果文件已经存在,你应该向其追加而不是截断它。os.O_CREATE 表示如果文件不存在,应该创建它。最后,os.O_WRONLY 表示程序应该只为写入打开文件。

 if err != nil {
        fmt.Println(err)
        return
    }
    defer f4.Close()
    // Write() needs a byte slice
    n, err = f4.Write([]byte("Put some more data at the end.\n")) 

Write() 方法从字节切片获取输入,这是 Go 的写入方式。所有之前的技术都使用了字符串,这不是最佳方式,尤其是在处理二进制数据时。然而,使用字符串而不是字节切片更为实用,因为操纵 string 值比操纵字节切片的元素更方便,尤其是在处理 Unicode 字符时。另一方面,使用 string 值会增加分配,并可能导致大量的垃圾回收压力。

 if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("wrote %d bytes\n", n)
} 

运行 writeFile.go 会在磁盘上生成一些关于写入字节的输出信息。有趣的是查看在 /tmp 文件夹中创建的文件:

$ ls -l /tmp/f?.txt
-rw-r--r--@ 1 mtsouk  wheel   14 Aug  5 11:30 /tmp/f1.txt
-rw-r--r--@ 1 mtsouk  wheel   14 Aug  5 11:30 /tmp/f2.txt
-rw-r--r--@ 1 mtsouk  wheel   14 Aug  5 11:30 /tmp/f3.txt
-rw-r--r--@ 1 mtsouk  wheel  101 Aug  5 11:30 /tmp/f4.txt 

之前的输出显示,在 f1.txtf2.txtf3.txt 中写入了相同数量的信息(14 字节),这意味着所展示的写入技术是等效的。

下一个部分将展示如何在 Go 中处理 JSON 数据。

处理 JSON

Go 标准库包括 encoding/json,用于处理 JSON 数据。此外,Go 允许你通过标签在 Go 结构中添加对 JSON 字段的支持,这是结构和 JSON 子节点的主题。标签控制 JSON 记录与 Go 结构之间的编码和解码。但首先,我们应该谈谈 JSON 记录的 序列化反序列化

使用 Marshal() 和 Unmarshal()

使用 Go 结构处理 JSON 数据时,序列化和反序列化是重要的步骤。序列化是将 Go 结构转换为 JSON 记录的过程。你通常希望这样做,以便通过计算机网络传输 JSON 数据或将其保存到磁盘上。反序列化是将作为字节切片给出的 JSON 记录转换为 Go 结构的过程。你通常希望在通过计算机网络接收 JSON 数据或从磁盘文件加载 JSON 数据时这样做。

将 JSON 记录转换为 Go 结构,反之亦然时最常见的错误是没有将 Go 结构的必需字段导出,也就是说,它们的首字母大写。当你遇到序列化和反序列化问题时,应从那里开始调试过程。

encodeDecode.go 中的代码展示了使用硬编码数据简化过程的 JSON 记录的序列化和反序列化:

package main
import (
    "encoding/json"
"fmt"
)
type UseAll struct {
    Name    string `json:"username"`
    Surname string `json:"surname"`
    Year    int `json:"created"`
} 

之前元数据告诉我们,UseAll 结构的 Name 字段在 JSON 记录中翻译为 username,反之亦然;Surname 字段翻译为 surname,反之亦然;Year 结构字段在 JSON 记录中翻译为 created,反之亦然。这些信息与 JSON 数据的序列化和反序列化有关。除此之外,你可以像使用常规 Go 结构一样处理和使用 UseAll

func main() {
    useall := UseAll{Name: "Mike", Surname: "Tsoukalos", Year: 2023}
    // Encoding JSON data: Convert Structure to JSON record with fields
    t, err := json.Marshal(&useall) 

json.Marshal() 函数需要一个指向结构变量的指针——其实际数据类型是一个空接口变量——并返回一个包含编码信息的字节切片和一个 error 变量。

 if err != nil {
        fmt.Println(err)
    } else {
        fmt.Printf("Value %s\n", t)
    }
    // Decoding JSON data given as a string
    str := `{"username": "M.", "surname": "Ts", "created":2024}` 

JSON 数据通常以字符串的形式出现。

 // Convert string into a byte slice
    jsonRecord := []byte(str) 

然而,由于json.Unmarshal()需要一个字节切片,你需要在将其传递给json.Unmarshal()之前将那个string转换为字节切片。

 // Create a structure variable to store the result
    temp := UseAll{}
    err = json.Unmarshal(jsonRecord, &temp) 

json.Unmarshal()函数需要一个包含 JSON 记录的字节切片和一个指向将存储 JSON 记录的 Go 结构体变量的指针,返回一个error变量。

 if err != nil {
        fmt.Println(err)
    } else {
        fmt.Printf("Data type: %T with value %v\n", temp, temp)
    }
} 

运行encodeDecode.go会产生以下输出:

$ go run encodeDecode.go
Value {"username":"Mike","surname":"Tsoukalos","created":2023}
Data type: main.UseAll with value {M. Ts 2024} 

下一小节将更详细地说明如何在 Go 结构体中定义 JSON 标签。

结构体和 JSON

假设你有一个 Go 结构体,你想将其转换为 JSON 记录而不包括任何空字段——以下代码展示了如何使用omitempty来执行此任务:

// Ignoring empty fields in JSON
type NoEmpty struct {
    Name    string `json:"username"`
    Surname string `json:"surname"`
    Year    int `json:"creationyear,omitempty"`
} 

现在,假设你有一些敏感数据存储在 Go 结构体的某些字段中,你不想将其包含在 JSON 记录中。你可以通过在所需的json:结构体标签中包含特殊值-来实现这一点。以下代码片段展示了如何做到这一点:

// Removing private fields and ignoring empty fields
type Password struct {
    Name     string `json:"username"`
    Surname  string `json:"surname,omitempty"`
    Year     int `json:"creationyear,omitempty"`
    Pass     string `json:"-"`
} 

因此,在将Password结构体转换为 JSON 记录时使用json.Marshal()函数时,Pass字段将被忽略。

这两种技术已在tagsJSON.go中展示。运行tagsJSON.go会产生以下输出:

$ go run tagsJSON.go
noEmptyVar decoded with value {username":"Mihalis","surname":""}
password decoded with value {"username":"Mihalis"} 

对于输出结果的第一行,我们有以下内容:将noEmpty值转换为名为noEmptyVarNoEmpty结构体变量的值是NoEmpty{Name: "Mihalis"}noEmpty结构体对于SurnameYear字段具有默认值。然而,由于它们没有被特别定义,json.Marshal()忽略了带有omitempty标签的Year字段,但没有忽略具有空string值但没有omitempty标签的Surname字段。

对于输出结果的第二行,password变量的值为Password{Name: "Mihalis", Pass: "myPassword"}。当password变量被转换为 JSON 记录时,Pass字段不包括在输出中。由于omitempty标签,Password结构体的剩余两个字段SurnameYear被省略。所以,剩下的就是username字段及其值。

到目前为止,我们已经看到了如何处理单个 JSON 记录。但是当我们有多个记录需要处理时会发生什么?我们是否必须逐个处理它们?下一小节将回答这些问题以及更多问题!

以流的形式读取和写入 JSON 数据

假设你有一个代表 JSON 记录的 Go 结构体切片,你想处理这些记录。你应该逐个处理记录吗?虽然可以这样做,但这看起来效率如何?好消息是 Go 支持以流的形式处理多个 JSON 记录而不是单个记录。本小节将教授如何使用包含以下两个函数的JSONstreams.go实用程序来完成这项任务:

// DeSerialize decodes a serialized slice with JSON records
func DeSerialize(e *json.Decoder, slice interface{}) error {
    return e.Decode(slice)
} 

DeSerialize() 函数用于读取以 JSON 记录形式输入的数据,对其进行解码,并将其放入切片中。该函数将切片写入,该切片为 interface{} 数据类型,并作为参数给出,并从 *json.Decoder 参数的缓冲区获取输入。*json.Decoder 参数及其缓冲区在 main() 函数中定义,以避免每次都分配它,从而失去使用此类型带来的性能提升和效率。同样适用于以下 *json.Encoder 的使用:

// Serialize serializes a slice with JSON records
func Serialize(e *json.Encoder, slice interface{}) error {
    return e.Encode(slice)
} 

Serialize() 函数接受两个参数,一个 *json.Encoder 和任何数据类型的切片,因此使用了 interface{}。该函数处理切片并将输出写入 json.Encoder 的缓冲区——这个缓冲区在创建编码器时作为参数传递。

由于使用了 interface{}Serialize()DeSerialize() 函数都可以与任何类型的 JSON 记录一起工作。

你可以用 err := json.NewEncoder(buf).Encode(DataRecords)err := json.NewEncoder(buf).Encode(DataRecords) 调用分别替换 Serialize()DeSerialize()。我个人更喜欢使用单独的函数,但你的口味可能不同。

JSONstreams.go 工具生成随机数据。运行 JSONstreams.go 会生成以下输出:

$ go run JSONstreams.go
After Serialize:[{"key":"RESZD","value":63},{"key":"XUEYA","value":13}]
After DeSerialize:
0 {RESZD 63}
1 {XUEYA 13} 

main() 中生成的结构体输入切片被序列化,如输出第一行所示。之后,它被反序列化为原始的结构体切片。

美化打印 JSON 记录

本小节说明了如何美化打印 JSON 记录,这意味着以令人愉快和可读的格式打印 JSON 记录,而无需了解持有 JSON 记录的 Go 结构的内部结构。由于存在两种读取 JSON 记录的方式,分别是个别读取和流式读取,因此存在两种美化打印 JSON 数据的方式:作为单个 JSON 记录和作为流。因此,我们将实现两个单独的函数,分别命名为 prettyPrint()JSONstream()

prettyPrint() 函数的实现如下:

func PrettyPrint(v interface{}) (err error) {
    b, err := json.MarshalIndent(v, "", "\t")
    if err == nil {
        fmt.Println(string(b))
    }
    return err
} 

所有工作都是由 json.MarshalIndent() 完成的,它应用缩进来格式化输出。

虽然 json.MarshalIndent()json.Marshal() 都产生 JSON 文本结果(字节切片),但只有 json.MarshalIndent() 允许应用可定制的缩进,而 json.Marshal() 生成更紧凑的输出。

对于美化打印 JSON 数据流,你应该使用 JSONstream() 函数:

func JSONstream(data interface{}) (string, error) {
  buffer := new(bytes.Buffer)
  encoder := json.NewEncoder(buffer)
  encoder.SetIndent("", "\t") 

json.NewEncoder() 函数返回一个新的编码器,该编码器将写入作为 json.NewEncoder() 参数传递的写入器。编码器将 JSON 值写入输出流。类似于 json.MarshalIndent()SetIndent() 方法允许你将可定制的缩进应用于流。

 err := encoder.Encode(data)
  if err != nil {
    return "", err
  }
  return buffer.String(), nil
} 

在我们完成配置编码器后,我们可以自由地使用 Encode() 处理 JSON 流。

这两个函数在 prettyPrint.go 中得到了说明,它使用随机数据生成 JSON 记录。运行 prettyPrint.go 会产生以下类型的输出:

Last record: {YJOML 63}
{
    "key": "YJOML",
    "value": 63
}
[
    {
        "key": "HXNIG",
        "value": 79
    },
    {
        "key": "YJOML",
        "value": 63
    }
] 

之前的输出显示了单个 JSON 记录的格式化输出,然后是包含两个 JSON 记录的切片的格式化输出——所有 JSON 记录都表示为 Go 结构。

现在,我们将处理一些完全不同的事情,那就是开发一个强大的命令行工具——Go 在这方面真的很擅长。

viper

标志 是特殊格式的字符串,它们被传递到程序中以控制其行为。如果您想支持多个标志和选项,自己处理标志可能会变得非常令人沮丧。Go 提供了 flag 包来处理命令行选项、参数和标志。尽管 flag 可以做很多事情,但它并不像其他外部 Go 包那样强大。因此,如果您正在开发简单的 UNIX 系统命令行工具,您可能会发现 flag 包非常有趣和有用。但您不是在阅读这本书来创建简单的命令行工具!因此,我将跳过 flag 包,向您介绍一个名为 viper 的外部包,它是一个功能强大的 Go 包,支持大量选项。viper 使用 pflag 包而不是 flag,这在代码中也有所说明。

所有 viper 项目都遵循一个模式。首先,您初始化 viper,然后定义您感兴趣的部分。之后,您获取这些元素并按顺序读取它们的值以使用它们。所需的值可以直接获取,这发生在您使用标准 Go 库中的 flag 包时,或者间接使用配置文件。当使用 JSON、YAML、TOML、HCL 或 Java 属性格式的格式化配置文件时,viper 会为您解析所有内容,这可以节省您编写和调试大量 Go 代码的时间。viper 还允许您从 Go 结构中提取和保存值。然而,这要求 Go 结构的字段与配置文件的键匹配。

viper 的主页位于 GitHub 上 (github.com/spf13/viper). 请注意,您并不需要在使用工具时强制使用 viper 的所有功能,只需使用您需要的功能即可。然而,如果您的命令行工具需要太多的命令行参数和标志,那么使用配置文件会更好。

使用命令行标志

第一个示例展示了如何编写一个简单的工具,该工具接受两个作为命令行参数的值,并在屏幕上打印它们以供验证。这意味着我们需要为这两个参数准备两个命令行标志。

相关代码在 ~/go/src/github.com/mactsouk/mGo4th/ch07/useViper 中。你应该将 mGo4th 替换为本书实际的 GitHub 仓库名称,或者将其重命名为 mGo4th。一般来说,短目录名更方便。

之后,你必须转到 ~/go/src/github.com/mactsouk/mGo4th/ch07/useViper 目录并运行以下命令:

$ go mod init
$ go mod tidy 

请记住,在 useViper.go 准备好并包含所有必需的外部包时,应执行前面的两个命令。本书的 GitHub 仓库包含所有程序的最终版本。

useViper.go 的实现如下:

package main
import (
    "fmt"
"github.com/spf13/pflag"
"github.com/spf13/viper"
) 

我们需要导入 pflagviper 包,因为我们将要使用它们的功能。

func aliasNormalizeFunc(f *pflag.FlagSet, n string) pflag.NormalizedName {
    switch n {
    case "pass":
        n = "password"
break
case "ps":
        n = "password"
break
    }
    return pflag.NormalizedName(n)
} 

aliasNormalizeFunc() 函数用于为标志创建额外的别名,在这种情况下,为 --password 标志创建别名。根据现有代码,--password 标志可以通过 --pass–ps 访问。

func main() {
    pflag.StringP("name", "n", "Mike", "Name parameter") 

在前面的代码中,我们创建了一个名为 name 的新标志,也可以通过 -n 访问。它的默认值是 Mike,其描述,在实用程序的用法中显示,是 Name 参数

 pflag.StringP("password", "p", "hardToGuess", "Password")
    pflag.CommandLine.SetNormalizeFunc(aliasNormalizeFunc) 

我们创建了一个名为 password 的另一个标志,也可以通过 -p 访问,默认值为 hardToGuess 并带有描述。此外,我们注册了一个规范化函数来生成 password 标志的别名。

 pflag.Parse()
    viper.BindPFlags(pflag.CommandLine) 

在定义所有命令行标志之后,应使用 pflag.Parse() 调用。它的目的是将命令行标志解析到预定义的标志中。

此外,viper.BindPFlags() 调用使所有标志对 viper 包可用。严格来说,我们说 viper.BindPFlags() 调用将现有的 pflag 标志集(pflag.FlagSet)绑定到 viper

 name := viper.GetString("name")
    password := viper.GetString("password") 

前面的命令显示你可以使用 viper.GetString() 读取两个 string 命令行标志的值。

 fmt.Println(name, password)
    // Reading an Environment variable
    viper.BindEnv("GOMAXPROCS")
    val := viper.Get("GOMAXPROCS")
    if val != nil {
        fmt.Println("GOMAXPROCS:", val)
    } 

viper 包也可以与环境变量一起工作。我们首先需要调用 viper.BindEnv() 来告诉 viper 我们对哪个环境变量感兴趣,然后我们可以通过调用 viper.Get() 来读取它的值。如果 GOMAXPROCS 还未设置,这意味着它的值是 nil,则 fmt.Println() 调用将不会执行。

 // Setting an Environment variable
    viper.Set("GOMAXPROCS", 16)
    val = viper.Get("GOMAXPROCS")
    fmt.Println("GOMAXPROCS:", val)
} 

同样,我们可以使用 viper.Set() 改变环境变量的值。

好事是 viper 自动提供用法信息:

$ go build useViper.go
$ ./useViper.go --help
Usage of ./useViper:
  -n, --name string       Name parameter (default "Mike")
  -p, --password string   Password (default "hardToGuess")
pflag: help requested
exit status 2 

不带任何命令行参数使用 useViper.go 会产生以下类型的输出:

$ go run useViper.go
Mike hardToGuess
GOMAXPROCS: 16 

然而,如果我们为命令行标志提供值,输出将会略有不同。

$ go run useViper.go -n mtsouk -p d1ff1cultPAssw0rd
mtsouk d1ff1cultPAssw0rd
GOMAXPROCS: 16 

在第二种情况下,我们使用了命令行标志的快捷方式,因为这样做更快。

下一个子节讨论了在 viper 中使用 JSON 文件存储配置信息。

读取 JSON 配置文件

viper 包可以读取 JSON 文件以获取其配置,本小节将说明如何操作。使用文本文件来存储配置细节在编写需要大量数据和设置的复杂应用程序时非常有帮助。这可以在 jsonViper.go 中看到。再次强调,我们需要将 jsonViper.go 放在 ~/go/src 目录下,就像之前做的那样:~/go/src/github.com/mactsouk/mGo4th/ch07/jsonViperjsonViper.go 的代码如下:

package main
import (
    "encoding/json"
"fmt"
"os"
"github.com/spf13/viper"
)
type ConfigStructure struct {
    MacPass     string `mapstructure:"macos"`
    LinuxPass   string `mapstructure:"linux"`
    WindowsPass string `mapstructure:"windows"`
    PostHost    string `mapstructure:"postgres"`
    MySQLHost   string `mapstructure:"mysql"`
    MongoHost   string `mapstructure:"mongodb"`
} 

这里有一个重要的观点:尽管我们使用 JSON 文件来存储配置,但 Go 结构使用 mapstructure 而不是 json 作为 JSON 配置文件字段的类型。

var **CONFIG** = ".config.json"
func main() {
    if len(os.Args) == 1 {
        fmt.Println("Using default file", CONFIG)
    } else {
        CONFIG = os.Args[1]
    }
    viper.SetConfigType("json")
    viper.SetConfigFile(CONFIG)
    fmt.Printf("Using config: %s\n", viper.ConfigFileUsed())
    viper.ReadInConfig() 

之前的四个语句声明我们正在使用一个 JSON 文件,让 viper 知道默认配置文件的路径,打印所使用的配置文件,并读取和解析该配置文件。

请记住,viper 并不会检查配置文件实际上是否存在且可读。如果文件找不到或无法读取,viper.ReadInConfig() 就像它正在处理一个空配置文件一样处理。

 if viper.IsSet("macos") {
        fmt.Println("macos:", viper.Get("macos"))
    } else {
        fmt.Println("macos not set!")
    } 

viper.IsSet() 调用检查配置中是否存在名为 macos 的键。如果已设置,它将使用 viper.Get("macos") 读取其值并在屏幕上打印。

 if viper.IsSet("active") {
        value := viper.GetBool("active")
        if value {
            postgres := viper.Get("postgres")
            mysql := viper.Get("mysql")
            mongo := viper.Get("mongodb")
            fmt.Println("P:", postgres, "My:", mysql, "Mo:", mongo)
        }
    } else {
        fmt.Println("active is not set!")
    } 

在上述代码中,我们在读取值之前检查 active 键是否存在。如果其值等于 true,则从另外三个键(名为 postgresmysqlmongodb)中读取值。

由于活动键应该持有布尔值,我们使用 viper.GetBool() 来读取它。

 if !viper.IsSet("DoesNotExist") {
        fmt.Println("DoesNotExist is not set!")
    } 

如预期的那样,尝试读取一个不存在的键会失败。

 var t ConfigStructure
    err := viper.Unmarshal(&t)
    if err != nil {
        fmt.Println(err)
        return
    } 

viper.Unmarshal() 调用允许您将 JSON 配置文件中的信息放入正确定义的 Go 结构中——这是可选的但很方便。

 PrettyPrint(t)
} 

在本章前面已经介绍了 PrettyPrint() 函数的实现。

现在,您需要下载 jsonViper.go 的依赖项:

$ go mod init
$ go mod tidy 

当前目录的内容如下:

$ ls -l
total 120
-rw-r--r--@ 1 mtsouk  staff    745 Aug 21 18:21 go.mod
-rw-r--r--@ 1 mtsouk  staff  48357 Aug 21 18:21 go.sum
-rw-r--r--@ 1 mtsouk  staff   1418 Aug  3 07:51 jsonViper.go
-rw-r--r--@ 1 mtsouk  staff    188 Aug 21 18:20 myConfig.json 

用于测试的 myConfig.json 文件内容如下:

{
"macos": "pass_macos",
"linux": "pass_linux",
"windows": "pass_windows",
"active": true,
"postgres": "machine1",
"mysql": "machine2",
"mongodb": "machine3"
} 

在前面的 JSON 文件上运行 jsonViper.go 产生以下输出:

$ go run jsonViper.go myConfig.json
Using config: myConfig.json
macos: pass_macos
P: machine1 My: machine2 Mo: machine3
DoesNotExist is not set!
{
  "MacPass": "pass_macos",
  "LinuxPass": "pass_linux",
  "WindowsPass": "pass_windows",
  "PostHost": "machine1",
  "MySQLHost": "machine2",
  "MongoHost": "machine3"
} 

之前的输出是由 jsonViper.go 在解析 myConfig.json 并尝试查找所需信息时生成的。

下一节将讨论一个用于创建强大和专业命令行工具的 Go 包,例如 dockerkubectl

cobra 包

cobra 是一个非常实用且流行的 Go 包,它允许您使用命令、子命令和别名来开发命令行实用程序。如果您曾经使用过 hugodockerkubectl,您将立即意识到 cobra 包的作用,因为这些工具都是使用 cobra 开发的。命令可以有一个或多个别名,这在您想取悦业余和经验丰富的用户时非常有用。cobra 还支持持久标志和局部标志,分别是适用于所有命令的标志和仅适用于给定命令的标志。此外,默认情况下,cobra 使用 viper 来解析其命令行参数。

所有 Cobra 项目都遵循相同的发展模式。您使用 Cobra 命令行工具,创建命令,然后对生成的 Go 源代码文件进行所需的更改以实现所需的功能。根据您工具的复杂度,您可能需要对创建的文件进行大量更改。尽管 cobra 节省了您大量时间,但您仍然需要编写实现每个命令所需功能的代码。

为了正确下载 cobra 二进制文件,您需要采取一些额外的步骤:

$ GO111MODULE=on go install github.com/spf13/cobra-cli@latest 

之前的命令下载了 cobra-cli 二进制文件——这是 cobra 可执行二进制文件的新名称。您不需要了解所有支持的环境变量,例如 GO111MODULE,但有时它们可以帮助您解决与 Go 安装相关的复杂问题。因此,如果您想了解您当前的 Go 环境,您可以使用 go env 命令。

由于我更喜欢使用较短的实用程序名称,我将 cobra-cli 重命名为 cobra。如果您不知道如何做到这一点或者您更喜欢使用 cobra-cli,请在所有命令中将 ~/go/bin/cobra 替换为 ~/go/bin/cobra-cli

为了本节的目的,我们需要在 ch07 目录下创建一个单独的目录。正如本书多次提到的,如果您将代码放在 ~/go/src 内的某个地方,一切都会变得容易得多;确切的位置取决于您,但使用类似 ~/go/src/github.com/mactsouk/mGo4th/ch07/go-cobra 这样的结构会更好,其中 mGo4th 是您保存本书源代码文件的目录名称。假设您将使用上述目录,您需要执行以下命令(如果您已经下载了本书的源代码,您不需要做任何事情,因为所有内容都将在那里):

$ cd ~/go/src/github.com/mactsouk/mGo4th/ch07/
$ mkdir go-cobra # only required if the directory is not there
$ cd go-cobra
$ go mod init
go: creating new go.mod: module github.com/mactsouk/mGo4th/ch07/go-cobra
$ ~/go/bin/cobra init
Using config file: /Users/mtsouk/.cobra.yaml
Your Cobra application is ready at
/Users/mtsouk/go/src/github.com/mactsouk/mGo4th/ch07/go-cobra
$ go mod tidy
go: finding module for package github.com/spf13/viper
go: finding module for package github.com/spf13/cobra
go: downloading github.com/spf13/cobra v1.7.0
...
go: downloading github.com/rogpeppe/go-internal v1.9.0
go: downloading github.com/kr/text v0.2.0 

所有以 go: 开头的输出行都与 Go 模块相关,并且只会出现一次。如果您尝试执行当前为空的实用程序,您将得到以下输出:

$ go run main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application. 

最后几行是 cobra 项目的默认消息。我们稍后将对该消息进行修改。现在,您已经准备好开始使用 cobra 工具,并为我们正在开发的命令行实用程序添加命令。

一个包含三个命令的工具

本节说明了 cobra add 命令的使用,该命令用于向现有的 cobra 项目添加新命令。命令的名称是 onetwothree

$ ~/go/bin/cobra add one
Using config file: /Users/mtsouk/.cobra.yaml
one created at /Users/mtsouk/go/src/github.com/mactsouk/go-cobra
$ ~/go/bin/cobra add two 
$ ~/go/bin/cobra add three 

之前的命令在 cmd 文件夹中创建了三个新文件,分别命名为 one.gotwo.gothree.go,它们是三个命令的初始天真实现。

你通常应该做的第一件事是从 root.go 中删除任何不需要的代码,并更改工具和每个命令的消息,如 ShortLong 字段中所述。然而,如果你想,你也可以保持源文件不变。

下一个子节通过添加命令行标志到命令中丰富了工具的功能。

添加命令行标志

我们将创建两个全局命令行标志和一个附加到给定命令(two)但不受其他两个命令支持的命令行标志。全局命令行标志定义在 ./cmd/root.go 文件中。我们将定义两个名为 directory 的全局标志,它是一个字符串,以及名为 depth 的无符号整数。这两个全局标志都在 ./cmd/root.goinit() 函数中定义:

rootCmd.PersistentFlags().StringP("directory", "d", "/tmp", "Path")
rootCmd.PersistentFlags().Uint("depth", 2, "Depth of search")
viper.BindPFlag("directory", rootCmd.PersistentFlags().Lookup("directory"))
viper.BindPFlag("depth", rootCmd.PersistentFlags().Lookup("depth")) 

我们使用 rootCmd.PersistentFlags() 来定义全局标志,然后是标志的数据类型。第一个标志的名称是 directory,其快捷键是 d,而第二个标志的名称是 depth,没有快捷键——如果你想为它添加快捷键,你应该使用 UintP() 方法,因为 depth 参数是一个无符号整数。定义了两个标志后,我们通过调用 viper.BindPFlag() 将它们的控制权传递给 viper。第一个标志是 string 类型,而第二个是 uint 值。由于它们都在 cobra 项目中可用,我们调用 viper.GetString("directory") 来获取 directory 标志的值,并调用 viper.GetUint("depth") 来获取 depth 标志的值。这不是读取标志值并使用它的唯一方法。当我们在更新统计应用程序时,你将看到另一种方法。

最后,我们通过在 ./cmd/two.go 文件的 init() 函数中添加下一行来添加一个仅对 two 命令可用的命令行标志:

twoCmd.Flags().StringP("username", "u", "Mike", "Username") 

标志的名称是 username,其快捷键是 u。由于这是一个仅对 two 命令可用的本地标志,我们只能在 ./cmd/two.go 文件中通过调用 cmd.Flags().GetString("username") 来获取其值。

下一个子节为现有命令创建了命令别名。

创建命令别名

在本节中,我们通过为现有命令创建别名来继续构建前一个子节中的代码。这意味着命令 onetwothree 分别也可以通过 cmd1cmd2cmd3 访问。

为了做到这一点,你需要为每个命令的 cobra.Command 结构添加一个名为 Aliases 的额外字段。Aliases 字段的类型是 字符串切片。因此,对于 one 命令,./cmd/one.gocobra.Command 结构的开始部分将如下所示:

var oneCmd = &cobra.Command{
    Use:     "one",
    Aliases: []string{"cmd1"},
    Short:   "Command one", 

你应该对 ./cmd/two.go./cmd/three.go 进行类似的修改。请记住,one 命令的 内部名称oneCmd,并且继续如此——其他命令有类似的对内部名称。

如果你意外地将 cmd1 别名,或任何其他别名,放入多个命令中,Go 编译器不会抱怨。然而,只有它的第一次出现会被执行。

下一小节通过为 onetwo 命令添加子命令来丰富这个实用程序。

创建子命令

本小节说明了如何为名为 three 的命令创建两个子命令。这两个子命令的名称将是 listdelete。使用 cobra 工具创建它们的方式如下:

$ ~/go/bin/cobra add list -p 'threeCmd'
Using config file: /Users/mtsouk/.cobra.yaml
list created at /Users/mtsouk/go/src/github.com/mactsouk/mGo4th/ch07/go-cobra
$ ~/go/bin/cobra add delete -p 'threeCmd'
Using config file: /Users/mtsouk/.cobra.yaml
delete created at /Users/mtsouk/go/src/github.com/mactsouk/mGo4th/ch07/go-cobra 

之前的命令在 ./cmd 中创建了两个新文件,分别命名为 delete.golist.go-p 标志后面跟着你想关联子命令的命令的内部名称。three 命令的内部名称是 threeCmd。你可以验证这两个命令与 three 命令相关联,如下所示(显示每个命令的默认消息):

$ go run main.go three delete
delete called
$ go run main.go three list
list called 

如果你运行 go run main.go two list,Go 会将 list 视为 two 的命令行参数,并且不会执行 ./cmd/list.go 中的代码。go-cobra 项目的最终版本具有以下结构和包含以下文件,这些文件是由 tree(1) 工具生成的:

$ tree
.
├── LICENSE
├── cmd
│   ├── delete.go
│   ├── list.go
│   ├── one.go
│   ├── root.go
│   ├── three.go
│   └── two.go
├── go.mod
├── go.sum
└── main.go
2 directories, 10 files 

到目前为止,你可能想知道当你想为两个不同的命令创建具有相同名称的两个子命令时会发生什么。在这种情况下,你首先创建第一个子命令,并在创建第二个子命令之前重命名其文件。

在最后一节中也展示了 cobra 包的使用,其中我们彻底更新了统计应用程序。下一节讨论了随着 Go 版本 1.16 带来的一些重要新增功能。

重要的 Go 功能

Go 1.16 带来了一些新功能,包括在 Go 可执行文件中嵌入文件以及引入了 os.ReadDir() 函数、os.DirEntry 类型以及 io/fs 包。

由于这些功能与系统编程相关,它们被包含并在本章中进行了探讨。我们首先介绍将文件嵌入到 Go 可执行文件中的方法。

嵌入文件

本节介绍了一个功能,允许你 将静态资源嵌入到 Go 可执行文件中。允许保留嵌入文件的数据类型有 string[]byteembed.FS。这意味着一个 Go 可执行文件可以包含一个你执行 Go 可执行文件时不需要手动下载的文件!所提供的实用程序嵌入了两份不同的文件,它可以根据给定的命令行参数检索它们。

以下代码,保存为 embedFiles.go,说明了这个新的 Go 功能:

package main
import (
    _ "embed"
"fmt"
"os"
) 

您需要 embed 包来在您的 Go 可执行文件中嵌入任何文件。由于 embed 包不是直接使用的,您需要在它前面放置 _,这样 Go 编译器就不会报错。

//go:embed static/image.png
var f1 []byte 

您需要在一行开头使用 //go:embed,这是一个 Go 注释但以特殊方式处理,后面跟要嵌入的文件路径。在这种情况下,我们嵌入 static/image.png,这是一个二进制文件。下一行应定义将要存储嵌入文件数据的变量,在这种情况下,是一个名为 f1 的字节切片。使用字节切片推荐用于二进制文件,因为我们将直接使用该字节切片来保存该二进制文件。

//go:embed static/textfile
var f2 string 

在这种情况下,我们将 static/textfile 的内容保存到名为 f2 的字符串变量中。

func writeToFile(s []byte, path string) error {
    fd, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer fd.Close()
    n, err := fd.Write(s)
    if err != nil {
        return err
    }
    fmt.Printf("wrote %d bytes\n", n)
    return nil
} 

writeToFile() 函数用于将字节切片存储到文件中,并且是一个可以在其他情况下使用的辅助函数。

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Print select 1|2")
        return
    }
    fmt.Println("f1:", len(f1), "f2:", len(f2)) 

此语句打印 f1f2 变量的长度,以确保它们代表嵌入文件的尺寸。

 switch arguments[1] {
    case "1":
        filename := "/tmp/temporary.png"
        err := writeToFile(f1, filename)
        if err != nil {
            fmt.Println(err)
            return
        }
    case "2":
        fmt.Print(f2)
    default:
        fmt.Println("Not a valid option!")
    }
} 

switch 块负责向用户返回所需的文件——在 static/textfile 的情况下,文件内容被打印到屏幕上。对于二进制文件,我们决定将其存储为 /tmp/temporary.png

这次,我们将编译 embedFiles.go 以使事情更现实,因为它是一个包含嵌入文件的可执行二进制文件。我们使用 go build embedFiles.go 构建二进制文件。运行 embedFiles 产生以下类型的输出:

$ ./embedFiles 2
f1: 75072 f2: 14
Data to write
$ ./embedFiles 1
f1: 75072 f2: 14
wrote 75072 bytes 

以下输出验证了 temporary.png 位于正确的路径(/tmp/temporary.png):

$ ls -l /tmp/temporary.png 
-rw-r--r--  1 mtsouk  wheel  75072 Feb 25 15:20 /tmp/temporary.png 

使用嵌入功能,我们可以创建一个嵌入其自身源代码并在执行时打印到屏幕上的实用程序!这是一种有趣的嵌入文件的方式。printSource.go 的源代码如下:

package main
import (
    _ "embed"
"fmt"
)
//go:embed printSource.go
var src string
func main() {
    fmt.Print(src)
} 

如前所述,被嵌入的文件在 //go:embed 行中定义。运行 printSource.go 将上述代码打印到屏幕上。

ReadDir 和 DirEntry

本节讨论 os.ReadDir()os.DirEntry。然而,它首先讨论了 io/ioutil 包的弃用——io/ioutil 包的功能已转移到其他包。因此,我们有以下内容:

  • os.ReadDir() 是一个新函数,返回 []DirEntry。这意味着它不能直接替换返回 []FileInfoioutil.ReadDir()。尽管 os.ReadDir()os.DirEntry 都没有提供任何新功能,但它们使事情更快更简单,这很重要。

  • os.ReadFile() 函数直接替换了 ioutil.ReadFile()

  • os.WriteFile() 函数可以直接替换 ioutil.WriteFile()

  • 同样,os.MkdirTemp()可以替换ioutil.TempDir()而不做任何更改。然而,由于os.TempDir()的名称已经被占用,新的函数名称不同。

  • os.CreateTemp()函数与ioutil.TempFile()相同。尽管os.TempFile()的名称没有被占用,但 Go 团队决定将其命名为os.CreateTemp(),以便与os.MkdirTemp()保持一致。

  • os.ReadDir()os.DirEntry可以在io/fs包中找到,作为fs.ReadDir()fs.DirEntry,以与io/fs中找到的文件系统接口一起工作。

ReadDirEntry.go实用程序展示了os.ReadDir()的使用。此外,我们将在下一节中看到fs.DirEntryfs.WalkDir()结合使用的情况——io/fs只支持WalkDir(),它默认使用DirEntryfs.WalkDir()filepath.WalkDir()都使用DirEntry而不是FileInfo。这意味着,为了在遍历目录树时看到任何性能提升,您需要将filepath.Walk()调用更改为filepath.WalkDir()调用。

所提供的实用程序使用os.ReadDir()来计算目录树的大小,并借助以下函数:

func GetSize(path string) (int64, error) {
    contents, err := os.ReadDir(path)
    if err != nil {
        return -1, err
    }
    var total int64
for _, entry := range contents {
        // Visit directory entries
if entry.IsDir() { 

如果entry.IsDir()的返回值是true,那么我们处理一个目录,这意味着我们需要继续深入挖掘。

 temp, err := GetSize(filepath.Join(path, entry.Name()))
            if err != nil {
                return -1, err
            }
            total += temp
            // Get size of each non-directory entry
        } else { 

如果我们处理一个文件,我们只需要获取它的大小。这涉及到调用Info()来获取关于文件的一般信息,然后调用Size()来获取它的大小:

 info, err := entry.Info()
            if err != nil {
                return -1, err
            }
            // Returns an int64 value
            total += info.Size()
        }
    }
    return total, nil
} 

运行ReadDirEntry.go会产生以下输出,这表明实用程序按预期工作:

$ go run ReadDirEntry.go /usr/bin
Total Size: 240527817 

最后,请记住,ReadDirDirEntry都是从 Python 编程语言中复制的。

下一节介绍了io/fs包。

io/fs

本节说明了io/fs包的功能,该包也在 Go 1.16 中引入。由于io/fs提供了一种独特类型的函数,因此我们开始本节,解释它能够做什么。简单来说,io/fs提供了一个名为FS的只读文件系统接口。请注意,embed.FS实现了fs.FS接口,这意味着embed.FS可以利用io/fs包提供的一些功能。这意味着您的应用程序可以创建自己的内部文件系统并处理其文件

下面的代码示例,保存为ioFS.go,通过将./static文件夹中的所有文件放入其中来创建一个文件系统。ioFS.go支持以下功能:列出所有文件,搜索文件名,以及使用list()search()extract()分别提取文件。我们首先展示list()的实现:

func list(f embed.FS) error {
    return fs.WalkDir(f, ".", walkFunction)
} 

请记住,fs.WalkDir()与常规文件系统以及embed.FS文件系统一起工作。您可以通过运行go doc fs.WalkDirFunc来了解更多关于walkFunction()签名的信息。

在这里,我们从文件系统的给定目录开始,访问其内容。文件系统存储在 f 中,根目录定义为 "."。之后,所有魔法都在 walkFunction() 函数中发生,该函数的实现如下:

func walkFunction(path string, d fs.DirEntry, err error) error {
    if err != nil {
        return err
    }
    fmt.Printf("Path=%q, isDir=%v\n", path, d.IsDir())
    return nil
} 

walkFunction() 函数以期望的方式处理给定根目录中的每个条目。请注意,walkFunc() 是由 fs.WalkDir() 自动调用的,以访问每个文件或目录。

然后,我们展示 extract() 函数的实现:

func extract(f embed.FS, filepath string) ([]byte, error) {
    s, err := fs.ReadFile(f, filepath)
    if err != nil {
        return nil, err
    }
    return s, nil
} 

使用 ReadFile() 函数从 embed.FS 文件系统检索一个文件,该文件通过其文件路径标识,并以字节切片的形式返回,这是 extract() 函数的返回值。

最后,我们有 search() 函数的实现,该函数基于 walkSearch()

func walkSearch(path string, d fs.DirEntry, err error) error {
    if err != nil {
        return err
    }
    if d.Name() == searchString { 

searchString 是一个全局变量,用于存储搜索字符串。当找到匹配项时,匹配的路径会打印在屏幕上。

 fileInfo, err := fs.Stat(f, path)
        if err != nil {
            return err
        }
        fmt.Println("Found", path, "with size", fileInfo.Size())
        return nil
    } 

在打印匹配项之前,我们调用 fs.Stat() 来获取更多关于它的详细信息:

 return nil
} 

main() 函数特别调用这三个函数。运行 ioFS.go 产生以下类型的输出:

$ go run ioFS.go
Path=".", isDir=true
Path="static", isDir=true
Path="static/file.txt", isDir=false
Path="static/image.png", isDir=false
Path="static/textfile", isDir=false
Found static/file.txt with size 14
wrote 14 bytes 

初始时,实用程序列出文件系统中的所有文件(以 Path 开头的行)。然后,它验证 static/file.txt 是否可以在文件系统中找到。最后,它验证将 14 个字节写入新文件是否成功,因为所有 14 个字节都已写入。

因此,Go 版本 1.16 引入了重要的功能。

在下一节中,我们将改进统计应用程序。

更新统计应用程序

在本节中,我们将更改统计应用程序存储其数据所使用的格式。这次,统计应用程序将使用 JSON 格式。此外,它使用 cobra 包来实现支持的命令。

然而,在继续统计应用程序之前,我们将更多地了解 slog 包。

The slog 包

Go 1.21 中将 log/slog 包添加到标准库中,以改进原始的 log 包。您可以在 pkg.go.dev/log/slog 找到更多关于它的信息。将其包含在本章中的主要原因是它能够以 JSON 格式创建日志条目,这在您想要进一步处理日志条目时非常有用。

将展示 useSLog.go 代码,该代码说明了 log/slog 包的使用,分为三个部分。第一部分如下:

package main
import (
    "fmt"
"log/slog"
"os"
)
func main() {
    slog.Error("This is an ERROR message")
    slog.Debug("This is a DEBUG message")
    slog.Info("This ia an INFO message")
    slog.Warn("This is a WARNING message") 

首先,我们需要导入 log/slog 以使用 Go 标准库中的 slog 包。当使用默认日志记录器时,我们可以使用 Error()Debug()Info()Warn() 发送消息,这是使用该功能的最简单方式。

第二部分包含以下代码:

 logLevel := &slog.LevelVar{}
    fmt.Println("Log level:", logLevel)
    // Text Handler
    opts := &slog.HandlerOptions{
        Level: logLevel,
    }
    handler := slog.NewTextHandler(os.Stdout, opts)
    logger := slog.New(handler)
    logLevel.Set(slog.LevelDebug)
    logger.Debug("This is a DEBUG message") 

在这个程序的部分,我们使用 &slog.LevelVar{} 获取当前的日志级别,并将其更改为 Debug 级别,以便也能获取使用 logger.Debug() 发布的日志条目。这是通过 logLevel.Set(slog.LevelDebug) 语句实现的。

useSLog.go 的最后一部分包含以下代码:

 // JSON Handler
    logJSON := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    logJSON.Error("ERROR message in JSON")
} 

所示的代码创建了一个写入 JSON 记录的日志记录器。

运行 useSLog.go 产生以下输出:

$ go run useSLog.go
2023/08/22 21:49:18 ERROR This is an ERROR message
2023/08/22 21:49:18 INFO This ia an INFO message
2023/08/22 21:49:18 WARN This is a WARNING message 

之前的输出来自 main() 的前四个语句。slog.Debug() 语句没有生成输出,因为默认情况下不会打印 DEBUG 级别。

Log level: LevelVar(INFO)
time=2023-08-22T21:49:18.474+03:00 level=DEBUG msg="This is a DEBUG message" 

之前的输出显示,如果我们提高日志级别,我们可以打印出 DEBUG 消息——默认的日志级别是 INFO

{"time":"2023-08-22T21:49:18.474392+03:00","level":"ERROR","msg":"ERROR message in JSON"} 

输出的最后一行显示了以 JSON 格式的日志信息。如果我们想将日志条目存储在常规数据库或时间序列数据库中以便进一步处理、可视化或数据分析,这将非常有用。

将日志发送到 io.Discard

本小节介绍了一个涉及使用 io.Discard 发送日志条目的技巧——io.Discard 会丢弃所有 Write() 调用而不做任何操作!尽管我们将此技巧应用于日志文件,但它也可以用于涉及写入数据的其他情况。discard.gomain() 函数的实现如下:

func main() {
    if len(os.Args) == 1 {
        log.Println("Enabling logging!")
        log.SetOutput(os.Stderr)
    } else {
        log.SetOutput(os.Stderr)
        log.Println("Disabling logging!")
        **log.SetOutput(io.Discard)**
        log.Println("NOT GOING TO GET THAT!")
    }
} 

启用或禁用写入的条件很简单:当存在单个命令行参数时,启用日志记录;否则,禁用。禁用日志的语句是 log.SetOutput(io.Discard)。然而,在禁用日志之前,我们打印一条日志条目说明这一点。

运行 discard.go 产生以下输出:

$ go run discard.go
2023/08/22 21:35:17 Enabling logging!
$ go run discard.go 1
2023/08/22 21:35:21 Disabling logging! 

在第二次程序执行中,log.Println("NOT GOING TO GET THAT!") 语句没有产生输出,因为它被发送到了 io.Discard

在考虑所有这些信息后,让我们在 cobra 的帮助下继续实现统计应用程序。

使用 cobra

首先,我们需要创建一个地方来托管统计应用程序的 cobra 版本。在这个阶段,你有两个选择:要么创建一个单独的 GitHub 仓库,要么将必要的文件放在 ~/go/src 目录下。本小节将遵循后者。因此,所有相关的代码都将位于 ~/go/src/github.com/mactsouk/mGo4th/ch07/stats

项目已经存在于 stats 目录中。如果你想要自己创建它,这些步骤是有意义的。

首先,我们需要创建并进入相关的目录:

$ cd ~/go/src/github.com/mactsouk/mGo4th/ch07/stats 

之后,我们应该声明我们想要使用 Go 模块:

$ go mod init
go: creating new go.mod: module github.com/mactsouk/mGo4th/ch07/stats 

之后,我们需要运行 cobra init 命令:

$ ~/go/bin/cobra init
Using config file: /Users/mtsouk/.cobra.yaml
Your Cobra application is ready at
/Users/mtsouk/go/src/github.com/mactsouk/mGo4th/ch07/stats 

然后,我们可以执行 go mod tidy

$ go mod tidy 

然后,我们应该使用 cobra(或 cobra-cli)二进制文件创建应用程序的结构。一旦我们有了结构,就很容易知道我们需要实现什么。应用程序的结构基于支持的命令和功能:

$ ~/go/bin/cobra add list
$ ~/go/bin/cobra add delete
$ ~/go/bin/cobra add insert
$ ~/go/bin/cobra add search 

在这一点上,执行 go run main.go 将会下载任何缺失的包并生成默认的 cobra 输出。

我们需要创建一个命令行标志来启用和禁用日志记录。我们将使用 log/slog 包。该标志名为 --log,它将是一个布尔变量。位于 root.go 中的相关语句如下:

rootCmd.PersistentFlags().BoolVarP(&disableLogging, "log", "l", false, "Logging information") 

前面的声明由一个全局变量支持,定义如下:

var disableLogging bool 

这是在使用命令行标志方面与我们之前在 go-cobra 项目中所做的方法不同的一个方法。

因此,disableLogging 全局变量的值保存了 --log 标志的值。尽管 disableLogging 变量是全局的,但你必须在每个命令中定义一个单独的日志变量

下一个子节讨论了 JSON 数据的存储和加载。

存储和加载 JSON 数据

saveJSONFile() 辅助函数的这个功能在 ./cmd/root.go 中实现,使用以下函数:

func saveJSONFile(filepath string) error {
    f, err := os.Create(filepath)
    if err != nil {
        return err
    }
    defer f.Close()
    err = Serialize(&data, f)
    return err
} 

基本上,我们只需要使用 Serialize() 将结构体切片序列化,并将结果保存到文件中。接下来,我们需要能够从该文件中加载 JSON 数据。加载功能也在 ./cmd/root.go 中实现,使用 readJSONFile() 辅助函数:

func readJSONFile(filepath string) error {
    _, err := os.Stat(filepath)
    if err != nil {
        return err
    }
    f, err := os.Open(filepath)
    if err != nil {
        return err
    }
    defer f.Close()
    err = DeSerialize(&data, f)
    if err != nil {
        return err
    }
    return nil
} 

我们所需要做的就是读取包含 JSON 数据的数据文件,并通过反序列化将其数据放入结构体切片中。

现在,我们将讨论 listinsert 命令的实现。其他两个命令(deletesearch)有类似的实现。

实现列表命令

./cmd/list.go 中的重要代码在 list() 函数的实现中:

func list() {
    sort.Sort(DFslice(data))
    text, err := PrettyPrintJSONstream(data)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(text) 

list 的核心功能包含在上面的代码中,它使用 PrettyPrintJSONstream(data)data 切片进行排序并格式化打印 JSON 记录。

 logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
    if disableLogging == false {
        logger = slog.New(slog.NewJSONHandler(io.Discard, nil))
    }
    slog.SetDefault(logger)
    s := fmt.Sprintf("%d records in total.", len(data))
    logger.Info(s)
} 

前面的代码根据 disableLogging 的值处理日志,该值基于 --log 标志。

实现插入命令

insert 命令的实现如下:

var insertCmd = &cobra.Command{
    Use:   "insert",
    Short: "Insert command",
    Long: `The insert command reads a datafile and stores
    its data into the application in JSON format.`,
    Run: func(cmd *cobra.Command, args []string) {
        logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
        // Work with logger
if disableLogging == false {
            logger = slog.New(slog.NewJSONHandler(io.Discard, nil))
        }
        slog.SetDefault(logger) 

首先,我们根据 disableLogging 的值为 insert 命令定义一个单独的日志记录器。

 if file == "" {
            logger.Info("Need a file to read!")
            return
        }
        _, ok := index[file]
        if ok {
            fmt.Println("Found key:", file)
            delete(index, file)
        }
        // Now, delete it from data
if ok {
            for i, k := range data {
                if k.Filename == file {
                    data = slices.Delete(data, i, i+1)
                    break
                }
            }
        } 

然后,前面的代码确保 file 变量(包含数据的文件路径)不为空。另外,如果 fileindex 映射的一个键,这意味着我们之前已经处理过该文件——我们假设没有两个数据集有相同的文件名,因为对我们来说,文件名是唯一标识数据集的东西。在这种情况下,我们从 data 切片和 index 映射中删除它,并再次处理它。这与 update 功能类似,该功能应用程序不支持。

 err := ProcessFile(file)
        if err != nil {
            s := fmt.Sprintf("Error processing: %s", err)
            logger.Warn(s)
        }
        err = saveJSONFile(JSONFILE)
        if err != nil {
            s := fmt.Sprintf("Error saving data: %s", err)
            logger.Info(s)
        }
    },
} 

insert 命令实现的最后部分是处理给定的文件,使用 ProcessFile(),并使用 saveJSONFile() 保存 data 切片的更新版本。

摘要

本章介绍了使用环境变量、命令行参数、读取和写入纯文本文件、遍历文件系统、处理 JSON 数据以及使用cobra创建强大的命令行实用工具。这是本书最重要的章节之一,因为不与操作系统以及文件系统交互,不读取和保存数据,就无法创建任何真正的实用工具。

下一章将介绍 Go 语言中的并发,主要主题包括 goroutines、channels 以及安全的数据共享。我们还将讨论 UNIX 信号处理,因为 Go 使用 channels 和 goroutines 来完成这个目的。

练习

  • 使用byCharacter.gobyLine.gobyWord.go的功能,创建wc(1) UNIX 实用工具的简化版本。

  • 使用viper包处理命令行选项,创建wc(1) UNIX 实用工具的完整版本。

  • 使用cobra包,通过命令而不是命令行选项创建wc(1) UNIX 实用工具的完整版本。

  • Go 提供了bufio.Scanner来逐行读取文件。尝试使用bufio.Scanner重写byLine.go

  • Go 语言中的bufio.Scanner设计为逐行读取输入,将其分割成标记。如果你需要逐字符读取文件,一个常见的方法是结合使用bufio.NewReaderRead()ReadRune()。以这种方式实现byCharacter.go的功能。

  • ioFS.go作为cobra项目。

  • 更新cobra项目中的统计应用程序,以便也将数据集的归一化版本存储在data.json中。

  • byLine.go实用工具使用ReadString('\n')读取输入文件。修改代码以使用Scanner (pkg.go.dev/bufio#Scanner)进行读取。

  • 类似地,byWord.go使用ReadString('\n')读取输入文件。修改代码以使用Scanner代替。

其他资源

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第八章:Go 并发

Go 并发模型的关键组件是 goroutine,它是 Go 中的最小可执行实体。要创建一个新的 goroutine,我们必须使用go关键字后跟一个函数调用或匿名函数——这两种方法等效。对于一个 goroutine 或函数要终止整个 Go 应用程序,它应该调用os.Exit()而不是return。然而,大多数时候,我们使用return退出 goroutine 或函数,因为我们真正想要退出的是特定的 goroutine 或函数,而不是停止整个应用程序。

Go 中的所有内容都以 goroutine 的形式执行,无论是透明地还是有意为之。每个可执行的 Go 程序至少有一个 goroutine,用于运行main包的main()函数。每个 goroutine 根据 Go 调度器的指令在单个 OS 线程上执行,Go 调度器负责 goroutine 的执行——开发者无法控制分配给 goroutine 的内存量。操作系统调度器不会指定 Go 运行时将要创建多少线程,因为 Go 运行时会生成足够的线程以确保有GOMAXPROCS个线程可用于运行 Go 代码。

然而,goroutines 不能直接相互通信。Go 中的数据共享是通过通道、本地套接字或共享内存实现的。通道作为连接多个 goroutines 的粘合剂。另一方面,通道不能处理数据或执行代码,但它们可以向 goroutines 发送数据并从 goroutines 接收数据,具有特殊用途,如作为信号或指定 goroutine 的执行顺序。

当我最初了解到通道时,我认为这是一个很好的主意,比共享内存好得多,我想要在所有地方都使用通道!然而,如今我只在别无选择的情况下使用通道。看看本章末尾的并发统计应用程序的实现,以了解存在不需要使用通道的设计。

虽然在 goroutine 之间使用通道进行通信和同步是非常典型和预期的,但通道可能会引入死锁、开销和复杂性到设计中,以及性能考虑,尤其是在低延迟通信是优先级时。

当你结合多个通道和 goroutines 时,你可以创建数据流,在 Go 术语中,这些被称为管道。因此,你可能有一个 goroutine 从数据库读取数据并发送到通道,另一个 goroutine 从该通道读取数据,处理这些数据,然后将数据发送到另一个通道供另一个 goroutine 读取,在修改数据并将其存储在另一个数据库之前。

本章涵盖:

  • 进程、线程和 goroutines

  • Go 调度器

  • Goroutines

  • 通道

  • 竞态条件是坏事

  • select关键字

  • 超时 goroutine

  • 重新审视 Go 通道

  • 处理 UNIX 信号

  • 共享内存和共享变量

  • 闭包变量和go语句

  • context

  • semaphore

  • 使统计应用程序并发

进程、线程和 goroutine

进程是操作系统对正在运行的程序的表示,而程序是磁盘上的一个二进制文件,其中包含创建操作系统进程所需的所有信息。该二进制文件以特定格式编写,包含 CPU 将要运行的指令以及大量其他所需部分。该程序被加载到内存中,并执行指令,创建一个正在运行的进程。因此,进程携带了额外的资源,如内存、打开的文件描述符和用户数据,以及运行时获取的其他类型资源。

线程是一个比进程更小、更轻的实体。进程由一个或多个具有自己控制流和堆栈的线程组成。区分线程和进程的一个简单方法是将进程视为正在运行的二进制文件,而线程则是进程的一个子集。

Goroutine 是 Go 语言中可以并发执行的最小实体。在这里使用“最小”这个词非常重要,因为 goroutine 不是像 UNIX 进程那样的自主实体——goroutine 生活在操作系统线程中,而操作系统线程又生活在操作系统进程中。好事是 goroutine 比线程轻,而线程又比进程轻——在单台机器上运行数千或数百万个 goroutine 不是问题。goroutine 比线程轻的原因包括它们有一个可以增长的更小的堆栈,它们有更快的启动时间,并且可以通过低延迟的通道相互通信。在实践中,这意味着一个进程可以有多个线程和大量 goroutine,而 goroutine 需要进程的环境才能存在。因此,要创建 goroutine,你需要有一个至少包含一个线程的进程。操作系统负责进程和线程的调度,而 Go 创建必要的线程,开发者创建所需的 goroutine 数量。

现在你已经了解了进程、程序、线程和 goroutine 的基础知识,让我们简单谈谈 Go 调度器。

Go 调度器

操作系统内核调度器负责程序的线程执行。同样,Go 运行时也有自己的调度器,它负责使用称为m:n 调度的技术来执行 goroutine,其中 m 个 goroutine 使用 n 个操作系统线程通过多路复用来执行。Go 调度器是 Go 组件,负责 Go 程序中 goroutine 的执行方式和顺序。这使得 Go 调度器成为 Go 编程语言的重要组成部分。Go 调度器也作为一个 goroutine 来执行。

请注意,由于 Go 调度器只处理单个程序中的 goroutine,其操作比操作系统内核调度器的操作要简单、便宜和快速得多。

Go 使用了分叉-连接并发模型。模型中的 分叉部分,不应与 fork(2) 系统调用混淆,表明可以在程序的任何位置创建子分支。类似地,Go 并发模型中的 连接部分 是子分支结束并与父分支连接的地方。请注意,sync.Wait() 语句和收集 goroutine 结果的通道都是连接点,而每个新的 goroutine 都会创建一个子分支。

公平调度策略将所有负载均匀地分配到所有可用处理器上。起初,这看起来像是一个完美的策略,因为它在保持所有处理器同等忙碌的同时,不需要考虑许多因素。然而,事实证明并非如此,因为大多数分布式任务通常依赖于其他任务。因此,一些处理器未被充分利用,或者说,一些处理器的利用率高于其他处理器。

Goroutine 是一个任务,而 goroutine 调用语句之后的代码则是一个延续。在 Go 调度器使用的偷取工作策略中,一个(逻辑上的)未充分利用的处理器会从其他处理器那里寻找额外的工作。 当它找到这样的工作后,它会从其他处理器或处理器那里窃取这些工作,因此得名。此外,Go 的工作窃取算法还会对队列中的延续进行排队和窃取。正如其名所暗示的,停滞的连接点是指执行线程在连接点停滞并开始寻找其他工作去做的地方。

尽管任务窃取和延续窃取都有停滞的连接点,但延续的发生频率比任务更高;因此,Go 调度算法使用延续而不是任务。延续窃取的主要缺点是它需要编程语言编译器的额外工作。幸运的是,Go 提供了这种额外帮助,因此在其工作窃取算法中使用了延续窃取。延续窃取的一个好处是,使用函数调用而不是 goroutine 或具有多个 goroutine 的单个线程时,你将得到相同的结果。这在两种情况下都只执行一个操作,因此这是完全合理的。

Go 调度器通过三种主要实体工作:操作系统线程(M),它们与使用的操作系统相关联,goroutines(G),以及逻辑处理器(P)。Go 程序可以使用的处理器数量由 GOMAXPROCS 环境变量的值指定——在任何给定时间,最多有 GOMAXPROCS 个处理器。现在,让我们回到 Go 中使用的 m:n 调度算法。严格来说,在任何时候,你都有 m 个正在执行且因此被调度运行的 goroutines,它们在 n 个操作系统线程上运行,最多使用 GOMAXPROCS 个逻辑处理器。你很快就会了解更多关于 GOMAXPROCS 的信息。

每个 goroutine 可以处于以下三个阶段之一:执行中可运行等待中。在执行阶段,goroutine 的指令在一个操作系统线程上执行。在可运行阶段,goroutine 等待被分配到操作系统线程以进行执行。最后,在等待阶段,goroutine 由于某些原因(如等待资源或互斥锁变得可用)而被阻塞,以便进入其他两个阶段之一。

下图显示了每个逻辑处理器都附加了两种不同类型的队列——一个全局运行队列和一个本地运行队列。来自全局队列的 goroutines 被分配到逻辑处理器的队列中,以便在未来某个时刻执行。

图片

图 8.1:Go 调度器的操作

每个逻辑处理器可以有多个线程,偷取发生在可用逻辑处理器的本地队列之间。最后,请记住,Go 调度器在需要时可以创建更多的操作系统线程。操作系统线程在资源方面很昂贵,并且从一个状态转换到另一个状态(上下文切换),这意味着过多地处理操作系统线程可能会减慢你的 Go 应用程序。

接下来,我们讨论 GOMAXPROCS 的含义和使用。

GOMAXPROCS 环境变量

GOMAXPROCS 环境变量允许你设置可以同时执行用户级 Go 代码的操作系统线程数量;这不会限制创建的线程数量,但会限制正在积极运行的线程数量。从 Go 版本 1.5 开始,GOMAXPROCS 的默认值应该是你的机器中可用的逻辑核心数。还有一个 runtime.GOMAXPROCS() 函数,它允许你以编程方式设置和获取 GOMAXPROCS 的值。

如果你决定将 GOMAXPROCS 的值设置为小于你机器中的核心数,可能会影响你程序的性能。然而,使用大于可用核心数的 GOMAXPROCS 值并不一定会使你的 Go 程序运行得更快,因为线程的上下文切换可能会造成影响。

如本节之前所述,您可以程序化地设置和获取 GOMAXPROCS 环境变量的值——这在本节中的 maxprocs.go 中得到了说明,它还将展示运行时包的额外功能。main() 函数的实现如下:

func main() {
    fmt.Print("You are using ", runtime.Compiler, " ")
    fmt.Println("on a", runtime.GOARCH, "machine")
    fmt.Println("Using Go version", runtime.Version()) 

runtime.Compiler 变量保存用于构建运行二进制的编译器工具链。最著名的两个值是 gcgccgoruntime.GOARCH 变量保存当前架构,而 runtime.Version() 返回 Go 编译器的当前版本。这些信息对于使用 runtime.GOMAXPROCS() 不是必需的,但了解您的系统信息是好的。

 fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
} 

runtime.GOMAXPROCS(0) 调用会发生什么?它总是返回可以同时执行的最大 CPU 数量的上一个值。当 runtime.GOMAXPROCS() 的参数等于或大于 1 时,runtime.GOMAXPROCS() 也会更改当前设置。由于我们使用的是 0,我们的调用不会改变当前设置。

运行 maxprocs.go 产生以下输出:

You are using gc on a arm64 machine
Using Go version go1.21.0
GOMAXPROCS: 10 

您可以使用以下技术实时更改 GOMAXPROCS 的值:

$ GOMAXPROCS=100; go run maxprocs.go
You are using gc on a amd64 machine
Using Go version go1.21.0
GOMAXPROCS: 100 

之前的命令临时将 GOMAXPROCS 的值更改为 100 并运行 maxprocs.go

除了使用较少的核心测试代码的性能外,您很可能不需要更改 GOMAXPROCS。下一节将解释并发与并行之间的相似之处和不同之处。

并发与并行

人们普遍认为并发与并行是同一件事——这并不正确!并行性是同时执行多种类型的多个实体,而并发是一种结构化组件的方式,以便在可能的情况下独立执行。

只有在您并发构建软件组件时,才能安全地在您的操作系统和硬件允许的情况下并行执行它们。Erlang 编程语言很久以前就这样做了——在 CPU 具有多个核心和计算机拥有大量 RAM 之前很久。

在一个有效的并发设计中,添加并发实体可以使整个系统运行得更快,因为可以并行执行更多的事情。因此,所需的并行性来自于对问题更好的并发表达和实现。开发者负责在设计阶段考虑并发,并将从系统组件的潜在并行执行中受益。因此,开发者不应该考虑并行性,而应该考虑将问题分解为独立的组件,这些组件在组合时解决初始问题。

即使您无法在您的机器上并行运行函数,有效的并发设计仍然可以改进程序的设计、数据流和可维护性。换句话说,并发比并行更好!现在,在我们探讨通道之前,让我们先谈谈 goroutines,通道是 Go 并发模型的主要组件。

Goroutines

您可以使用go关键字后跟一个命名函数或匿名函数调用来定义、创建和执行一个新的 goroutine。go关键字使得函数调用立即返回,而函数则在后台作为 goroutine 开始运行,其余程序继续执行。您无法控制或对 goroutines 将要执行的顺序做出任何假设,因为这取决于操作系统的调度器、Go 调度器和操作系统负载。

创建 goroutine

在本小节中,您将学习如何创建 goroutines。演示该技术的程序被称为create.gomain()函数的实现如下:

func main() {
    go func(x int) {
        fmt.Printf("%d ", x)
    }(10) 

这就是如何将匿名函数作为 goroutine 运行。最后的(10)是如何向匿名函数传递参数的。前面的匿名函数只是打印一个值。一般来说,显式传递参数比让函数关闭它所使用的变量更易于阅读。

 go printme(15) 

这就是如何将一个函数作为 goroutine 执行。一般来说,您作为 goroutine 执行的函数不应直接返回任何值。与 goroutines 交换数据是通过使用共享内存或通道或其他机制来实现的

 time.Sleep(time.Second)
    fmt.Println("Exiting...")
} 

由于 Go 程序在退出前不会等待其 goroutines 结束,我们需要手动延迟它,这就是time.Sleep()调用的目的。我们将很快纠正这一点,以便在退出前等待所有 goroutines 完成。

运行create.go会产生以下输出:

$ go run create.go 
10 * 15
Exiting... 

输出中的10部分来自匿名函数,而* 15部分来自go printme(15)语句。然而,如果您多次运行create.go,可能会得到不同的输出,因为这两个 goroutines 并不总是按相同的顺序执行,这取决于 Go 调度器:

$ go run create.go
* 15
10 Exiting... 

下一个小节将展示如何运行可变数量的 goroutines。

创建多个 goroutines

在本小节中,您将学习如何创建可变数量的 goroutines。演示该技术的程序被称为multiple.go。goroutines 的数量作为程序的一个命令行参数给出。main()函数实现中的重要代码如下:

fmt.Printf("Going to create %d goroutines.\n", count)
for i := 0; i < count; i++ { 

没有任何禁止您使用for循环来创建多个 goroutines,尤其是在您想创建很多 goroutines 时。

 go func(x int) {
        fmt.Printf("%d ", x)
    }(i)
}
time.Sleep(time.Second)
fmt.Println("\nExiting...") 

再次强调,time.Sleep()防止main()函数立即退出。

运行multiple.go会生成以下类型的输出:

$ go run multiple.go 15
Going to create 15 goroutines.
3 0 8 4 5 6 7 11 9 12 14 13 1 2 10 
Exiting... 

如果你多次运行 multiple.go,你将得到不同的输出。因此,仍有改进的空间。下一小节将展示如何移除对 time.Sleep() 的调用,并使你的程序等待 goroutines 完成。

等待所有 goroutines 完成

仅创建多个 goroutines 是不够的——你还需要在 main() 函数结束前等待它们完成。因此,本小节将展示一个非常流行的技术,它可以改进 multiple.go 的代码——改进后的版本被称为 varGoroutines.go。但首先,我们需要解释它是如何工作的。

同步过程从定义一个 sync.WaitGroup 变量并使用 Add()Done()Wait() 方法开始。如果你查看 sync Go 包的源代码,特别是 waitgroup.go 文件,你会看到 sync.WaitGroup 类型不过是一个包含两个字段的结构的集合:

type WaitGroup struct {
    noCopy noCopy
    state1 [3]uint32
} 

每次调用 sync.Add() 都会增加 state1 字段中的一个计数器,该字段是一个包含三个 uint32 元素的数组。请注意,在 go 语句之前调用 sync.Add() 确实非常重要,以防止任何 竞争条件——我们将在 竞争条件是坏事 部分学习竞争条件。当每个 goroutine 完成其工作后,应该执行 sync.Done() 函数以将相同的计数器减一。

在幕后,sync.Done() 执行一个 Add(-1) 调用。Wait() 方法等待该计数器变为 0 才返回。main() 函数内 Wait() 的返回意味着 main() 将返回,程序结束。

你可以用除了 1 以外的正整数调用 Add(),以避免多次调用 Add(1)。当你事先知道将要创建的 goroutines 的数量时,这很有用。Done() 不支持该功能。

varGoroutines.go 的重要部分如下:

var waitGroup sync.WaitGroup
fmt.Printf("%#v\n", waitGroup) 

这是创建你将要使用的 sync.WaitGroup 变量的地方。fmt.Printf() 调用打印出 sync.WaitGroup 结构的内容——你通常不会这样做,但这对学习 sync.WaitGroup 结构很有帮助。

for i := 0; i < count; i++ {
    waitGroup.Add(1) 

我们在创建 goroutine 之前调用 Add(1) 以避免竞争条件。

 go func(x int) {
        defer waitGroup.Done() 

由于 defer 关键字,Done() 调用将在匿名函数返回之前执行。

 fmt.Printf("%d ", x)
    }(i)
}
fmt.Printf("%#v\n", waitGroup)
waitGroup.Wait() 

Wait() 函数在返回之前等待 waitGroup 变量中的计数器变为 0,这正是我们想要达到的目标。

fmt.Println("\nExiting...") 

Wait() 函数返回时,将执行 fmt.Println() 语句。不再需要调用 time.Sleep() 了!

运行 varGoroutines.go 产生以下输出:

$ go run varGoroutines.go 15
Going to create 10 goroutines.
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0x0}, sema:0x0}
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0xa00000000}, sema:0x0}
14 8 9 10 11 5 0 4 1 2 3 6 13 12 7 
Exiting... 

记住,在程序中使用更多的 goroutines 并不是性能的万能药,因为更多的 goroutines,除了对sync.Add()sync.Wait()sync.Done()的各种调用外,还可能因为 Go 调度器和 Go 垃圾收集器需要做的额外管理工作而减慢你的程序。

如果 Add()和 Done()调用的次数不同怎么办?

sync.Add()调用次数和sync.Done()调用次数相等时,你的程序将一切正常。然而,本小节将告诉你当这两个数字不一致时会发生什么。

如果不给addDone.go提供任何命令行参数,Add()调用的次数将少于Done()调用的次数。如果有至少一个命令行参数,Done()调用的次数将少于Add()调用的次数。你可以自己查看addDone.go的 Go 代码。重要的是它生成的输出。不提供任何命令行参数运行addDone.go会产生以下错误信息:

$ go run addDone.go
Going to create 20 goroutines.
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0x0}, sema:0x0}
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0x1300000000}, sema:0x0}
19 3 4 5 6 7 8 9 10 11 12 13 14 15 16 2 1 17 18 0
Exiting...
panic: sync: negative WaitGroup counter
goroutine 20 [running]:
sync.(*WaitGroup).Add(0x1?, 0x1?)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/waitgroup.go:62 +0x108
sync.(*WaitGroup).Done(0x0?)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/waitgroup.go:87 +0x20
main.main.func1(0x0?)
    ~/go/src/github.com/mactsouk/mGo4th/ch08/addDone.go:26 +0x9c
created by main.main in goroutine 1
    ~/go/src/github.com/mactsouk/mGo4th/ch08/addDone.go:23 +0xec
exit status 2 

错误信息的起因可以在输出中找到:panic: sync: WaitGroup 计数器为负

有时,addDone.go不会产生任何错误信息,并且可以正常终止——这主要发生在系统已经非常繁忙的情况下。这是并发程序的一般问题——它们并不总是崩溃或行为异常,因为执行顺序可能会改变,这可能会改变程序的行为。这使得并发软件的调试变得更加困难。

使用一个命令行参数运行addDone.go会产生以下错误信息:

$ go run addDone.go 1
Going to create 20 goroutines.
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0x0}, sema:0x0}
sync.WaitGroup{noCopy:sync.noCopy{}, state:atomic.Uint64{_:atomic.noCopy{}, _:atomic.align64{}, v:0x1500000000}, sema:0x0}
19 1 2 11 12 13 14 15 16 17 18 6 3 4 5 8 7 9 0 10 fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x0?)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/runtime/sema.go:62 +0x2c
sync.(*WaitGroup).Wait(0x14000128030)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/waitgroup.go:116 +0x78
main.main()
    ~/go/src/github.com/mactsouk/mGo4th/code/ch08/addDone.go:38 +0x230
exit status 2 

再次强调,崩溃的原因会打印在屏幕上:致命错误:所有 goroutines 都处于休眠状态 - 死锁!这意味着程序应该无限期地等待一个 goroutine 完成——也就是说,等待一个永远不会发生的Done()调用。

使用 goroutines 创建多个文件

作为 goroutines 使用的一个实际例子,本小节介绍了一个命令行工具,该工具创建多个包含随机生成数据的文件——这些文件可用于测试文件系统或生成测试数据。randomFiles.go中的关键代码如下:

var waitGroup sync.WaitGroup
for i := start; i <= end; i++ {
    waitGroup.Add(1)

    go func(n int) {
        filepath := filepath.Join(path, fmt.Sprintf("%s%d", filename, n))
        defer waitGroup.Done()
        createFile(filepath)
    }(i)
}
waitGroup.Wait() 

我们首先创建一个sync.WaitGroup变量,以便正确地等待所有 goroutines 完成。每个文件仅由一个 goroutine 创建。这里重要的是每个文件都有一个唯一的文件名——这是通过包含for循环计数器值的filepath变量实现的。多个createFile()函数作为 goroutines 执行来创建文件。这是一种简单但非常高效地创建多个文件的方法。运行randomFiles.go会生成以下输出:

$ go run randomFiles.go 
Usage: randomFiles firstInt lastInt filename directory 

因此,这个实用程序需要四个参数,分别是 for 循环的第一个和最后一个值,以及将要写入文件的文件名和目录。让我们使用正确的参数数量运行这个实用程序:

$ go run randomFiles.go 3 5 masterGo /tmp
/tmp/masterGo3 created!
/tmp/masterGo5 created!
/tmp/masterGo4 created! 

一切看起来都很正常,根据我们的指示已经创建了四个文件!现在我们了解了 goroutines,让我们继续学习通道。

通道

通道是一种通信机制,它允许 goroutines 交换数据。首先,每个通道允许交换特定数据类型,这也被称为通道的元素类型;其次,为了使通道正常工作,需要有人接收通过通道发送的数据。您应该使用 make() 函数和 chan 关键字(make(chan int))来声明一个新的通道,并且可以使用 close() 函数关闭通道。您可以通过编写类似 make(chan int, 1) 的代码来声明通道的大小。这个语句创建了一个带缓冲的通道,它有不同的用途——带缓冲的通道将在本章后面进行解释。

就因为我们可以使用通道,并不意味着我们应该这样做。如果存在一个更简单的解决方案,允许 goroutines 执行并保存生成的信息,我们也应该考虑这一点。每个开发者的目的应该是创建一个简单的设计,而不是使用编程语言的所有功能。

管道是一种虚拟方法,用于连接 goroutines 和通道,使得一个 goroutine 的输出可以通过通道传输成为另一个 goroutine 的输入。使用管道的好处之一是,在您的程序中会有持续的数据流,因为没有 goroutine 或通道需要等待所有操作完成才能开始执行。此外,您使用的变量更少,因此内存空间也更少,因为您不需要将所有内容都保存为变量。最后,管道的使用简化了程序的设计并提高了其可维护性。

向通道写入和从通道读取

将值(val)写入通道(ch)就像写入 ch <- val 一样简单。箭头显示了值的方向,只要 varch 是相同的数据类型,您就不会对这个语句有任何问题。

您可以通过执行 <-c 从名为 c 的通道中读取单个值。在这种情况下,方向是从通道到外部世界。您可以使用 aVar := <-c 将该值保存到一个新变量中。

通道的读写操作在 channels.go 文件中有示例,以下代码展示了这一点:

package main
import (
    "fmt"
"sync"
)
func writeToChannel(c chan int, x int) {
    c <- x
    close(c)
} 

这个函数只是将值写入通道,然后立即关闭它。

func printer(ch chan bool) {
    ch <- true
} 

这个函数只是将 true 值发送到一个 bool 通道。

func main() {
    c := make(chan int, 1) 

此通道具有大小为 1 的缓冲区。这意味着一旦我们填满该缓冲区,我们就可以关闭通道,goroutine 将继续执行并返回。一个无缓冲的通道有不同的行为:当你尝试向该通道发送值时,它将永远阻塞,因为它正在等待有人取走该值。在这种情况下,我们确实需要一个缓冲通道,以避免任何阻塞。

 var waitGroup sync.WaitGroup
    waitGroup.Add(1)
    go func(c chan int) {
        defer waitGroup.Done()
        writeToChannel(c, 10)
        fmt.Println("Exit.")
    }(c)
    fmt.Println("Read:", <-c) 

在这里,我们从通道读取值并打印它,而不将其存储在单独的变量中。

 _, ok := <-c
    if ok {
        fmt.Println("Channel is open!")
    } else {
        fmt.Println("Channel is closed!")
    } 

之前的代码展示了确定通道是否关闭的技术。在这种情况下,我们正在忽略读取值——如果通道是打开的,那么读取值将被丢弃。

 waitGroup.Wait()
    var ch chan bool = make(chan bool)
    for i := 0; i < 5; i++ {
        go printer(ch)
    } 

在这里,我们创建了一个无缓冲的通道,并创建了五个 goroutine,我们没有进行任何同步,因为我们没有使用任何 Add() 调用。

 // Range on channels
// IMPORTANT: As the channel ch is not closed,
// the range loop does not exit on its own.
    n := 0
for i := range ch { 

range 关键字与通道一起工作!然而,在通道上的 range 循环只有在通道关闭或使用 break 关键字时才会退出。

 fmt.Println(i)
        if i == true {
            n++
        }
        if n > 2 {
            fmt.Println("n:", n)
            close(ch)
            break
        }
    } 

当满足某个条件时,我们关闭 ch 通道并使用 break 退出 for 循环。请注意,在接收端关闭通道从来都不是一个好主意——这里展示是为了示例。你很快就会看到这个决定带来的后果。

 for i := 0; i < 5; i++ {
        fmt.Println(<-ch)
    }
} 

当尝试从关闭的通道读取时,我们得到其数据类型的零值,因此这个 for 循环运行得很好,不会引起任何问题。

运行 channels.go 生成以下输出:

Exit.
Read: 10 

使用 writeToChannel(c, 10) 将值 10 写入通道后,我们读取该值。

Channel is closed!
true
true
true 

带有 rangefor 循环在三次迭代后退出——每次迭代在屏幕上打印 true

n: 3
false
false
false
false
false 

这五个 false 值是由程序的最后 for 循环打印的。

虽然看起来 channels.go 没有问题,但它存在一个逻辑问题,我们将在 竞争条件很糟糕 部分进行解释和解决。此外,如果我们多次运行 channels.go,它可能会崩溃。然而,大多数时候它不会,这使得调试变得更加困难。

从关闭的通道接收

从关闭的通道读取返回其数据类型的零值。然而,如果你尝试向关闭的通道写入,你的程序将以糟糕的方式崩溃(panic)。这两种情况在 readCloseCh.go 中进行了探索,特别是在 main() 函数的实现中:

func main() {
    willClose := make(chan complex64, 10) 

如果你将其作为一个无缓冲的通道,程序将会崩溃。

 // Write some data to the channel
    willClose <- -1
    willClose <- 1i 

我们向 willClose 通道写入两个值。

 // Read data and empty channel
    <-willClose
    <-willClose
    close(willClose) 

然后,我们读取并丢弃这两个值,并关闭通道。

 // Read again - this is a closed channel
    read := <-willClose
    fmt.Println(read)
} 

我们从通道读取的最后一个值是 complex64 数据类型的零值。运行 readCloseCh.go 生成以下输出:

(0+0i) 

因此,我们得到了 complex64 数据类型的零值。现在让我们继续讨论如何处理接受通道作为参数的函数。

将通道作为函数参数

当使用通道作为函数参数时,你可以指定其方向——也就是说,它是否仅用于发送或接收数据。在我看来,如果你事先知道通道的目的,你应该使用这个功能,因为它使你的程序更加健壮。你将无法意外地将数据发送到仅应接收数据的通道,或者从仅应发送数据的通道接收数据。

如果你声明一个通道函数参数仅用于读取,而你尝试向其写入,你将得到一个编译错误信息,这很可能会让你在未来避免一些讨厌的 bug。这是这种方法的重大好处!

所有这些都在 channelFunc.go 中得到了说明——接受通道参数的函数的实现如下:

func printer(ch chan<- bool) {
    ch <- true
} 

上面的函数接受一个仅可用于写入的通道参数。

func writeToChannel(c chan<- int, x int) {
    fmt.Println("1", x)
    c <- x
    fmt.Println("2", x)
} 

上面的函数的通道参数仅可用于读取。

func f2(out <-chan int, in chan<- int) {
    x := <-out
    fmt.Println("Read (f2):", x)
    in <- x
    return
} 

最后一个函数接受两个通道参数。然而,out 仅可用于读取,而 in 则用于写入。如果你尝试对一个不允许的操作执行通道参数,Go 编译器将会抱怨。即使函数没有被使用,这种情况也会发生。

下一节的主题是竞争条件——仔细阅读,以避免在处理多个 goroutine 时出现未定义的行为和令人不愉快的情况。

竞争条件是坏事

数据竞争条件 是一种情况,其中两个或多个正在运行的元素,例如线程和 goroutine,试图控制或修改程序的一个共享资源或共享变量。严格来说,数据竞争发生在两个或多个指令访问相同的内存地址时,其中至少有一个执行了写(更改)操作。如果所有操作都是读取操作,则不存在竞争条件。在实践中,这意味着如果你多次运行程序,可能会得到不同的输出,这是不好的。

在运行或构建 Go 源文件时使用 -race 标志将执行 Go 竞争检测器,这使得编译器创建一个典型的可执行文件的修改版本。这个修改版本可以记录所有对共享变量的访问以及所有发生的同步事件,包括对 sync.Mutexsync.WaitGroup 的调用,这些将在本章后面介绍。在分析相关事件后,竞争检测器会打印出一份报告,可以帮助你识别潜在的问题,以便你可以纠正它们。

Go 竞争检测器

你可以使用 go run -race 运行竞争检测器工具。如果我们使用 go run -race 测试 channels.go,我们将得到以下输出:

$ go run -race channels.go 
Exit.
Read: 10
Channel is closed!
true
true
true
n: 3
==================
WARNING: DATA RACE
Write at 0x00c000094010 by main goroutine:
  runtime.recvDirect()
      /opt/homebrew/Cellar/go/1.21.0/libexec/src/runtime/chan.go:348 +0x7c
  main.main()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:54 +0x444
Previous read at 0x00c000094010 by goroutine 10:
  runtime.chansend1()
      /opt/homebrew/Cellar/go/1.21.0/libexec/src/runtime/chan.go:146 +0x2c
  main.printer()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:14 +0x34
  main.main.func3()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:40 +0x34
Goroutine 10 (running) created at:
  main.main()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:40 +0x2b8
==================
false
false
false
false
false
panic: send on closed channel
goroutine 36 [running]:
main.printer(0x0?)
    ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:14 +0x38
created by main.main in goroutine 1
    ~/go/src/github.com/mactsouk/mGo4th/ch08/channels.go:40 +0x2bc
exit status 2 

因此,尽管 channels.go 最初看起来没有问题,但其中存在一个等待发生的竞态条件。现在,让我们根据之前的输出讨论一下 channels.go 中存在的问题。在 channels.go 的第 54 行有一个关闭通道的操作,而在第 14 行对同一个通道进行了写入操作,这看起来是竞态条件情况的根本原因。第 54 行是 close(ch),而第 14 行是 ch <- true。问题是,我们无法确定将要发生什么以及发生的顺序——这就是竞态条件。如果你在没有竞态检测器的情况下执行 channels.go,它可能工作正常,但如果你多次尝试,你可能会得到 panic: send on closed channel 错误信息——这主要与 Go 调度器将要运行的 goroutines 的顺序有关。因此,如果通道的关闭操作先发生,那么对该通道的写入操作将会失败——竞态条件!

修复 channels.go 需要更改代码,更具体地说,是更改 printer() 函数的实现。修正后的 channels.go 版本被命名为 chRace.go,并包含以下代码:

func printer(ch chan<- bool, times int) {
    for i := 0; i < times; i++ {
        ch <- true
    }
    close(ch)
}
func main() {
    // This is an unbuffered channel
var ch chan bool = make(chan bool)
    // Write 5 values to channel with a single goroutine
go printer(ch, 5)
    // IMPORTANT: As the channel ch is closed,
// the range loop is going to exit on its own.
for val := range ch {
        fmt.Print(val, " ")
    }
    fmt.Println()
    for i := 0; i < 15; i++ {
        fmt.Print(<-ch, " ")
    }
    fmt.Println()
} 

首先要注意的是,我们不是使用多个 goroutine 来写入所需的通道,而是使用单个 goroutine。单个 goroutine 写入通道后关闭该通道不会创建任何竞态条件,因为事情是按顺序发生的

运行 go run -race chRace.go 产生以下输出,这意味着不再存在竞态条件:

true true true true true 
false false false false false false false false false false false false false false false 

下一个部分是关于重要且强大的 select 关键字。

select 关键字

select 关键字非常重要,因为它允许你同时监听多个通道。一个 select 块可以有多个情况和一个可选的 default 情况,这类似于 switch 语句。对于 select 块来说,有一个超时选项是很好的。最后,没有任何情况的 select (select{}) 将永远等待。

实际上,这意味着 select 允许 goroutine 等待多个通信操作。因此,select 给你使用单个 select 块监听多个通道的能力。因此,只要你适当地实现了你的 select 块,你就可以在通道上执行非阻塞操作。

一个 select 语句不是按顺序评估的,因为它的所有通道都是同时检查的。如果一个 select 语句中的所有通道都没有准备好,那么 select 语句将阻塞(等待),直到其中一个通道准备好。如果一个 select 语句中的多个通道都准备好了,那么 Go 运行时从这些准备好通道的集合中随机选择

select.go 中的代码展示了 select 在一个具有三个情况的 goroutine 中运行的简单用法。但首先,让我们看看包含 select 的 goroutine 是如何执行的:

 wg.Add(1)
    go func() {
        gen(0, 2*n, createNumber, end)
        wg.Done()
    }() 

之前的代码告诉我们,为了执行wg.Done()gen()应该首先返回。那么,让我们看看gen()的实现:

func gen(min, max int, createNumber chan int, end chan bool) {
    time.Sleep(time.Second)
    for {
        select {
        case createNumber <- rand.Intn(max-min) + min:
        case <-end:
            fmt.Println("Ended!")
            // return 

在这里正确的事情是为gen()函数添加return语句以结束。让我们假设你忘记添加了return语句。这意味着在执行与结束通道参数相关的select分支之后,函数不会结束——createNumber不会结束函数,因为它没有return语句。因此,select块一直在等待更多。解决方案可以在下面的代码中找到:

 case <-time.After(4 * time.Second):
            fmt.Println("time.After()!")
            return
        }
    }
} 

那么,整个select块中的代码实际上在发生什么呢?这个特定的select语句有三个情况。如前所述,select不需要default分支。你可以将select语句的第三个分支视为一个巧妙的default分支。这是因为time.After()等待指定的持续时间(4 * time.Second)过去,然后打印一条消息,并使用return正确地结束gen()。这在这种情况下解除了select语句的阻塞,即所有其他通道由于某种原因而阻塞。尽管省略第二个分支的return是一个错误,但这表明有一个退出策略始终是一件好事。

运行select.go产生以下输出:

$ go run select.go 10
Going to create 10 random numbers.
13 0 2 8 12 4 13 15 14 19 Ended!
time.After()!
Exiting... 

我们将在本章的剩余部分看到select的实际应用,从下一节开始,该节讨论如何超时 goroutines。你应该记住的是,select允许我们从单个点监听多个通道

超时 goroutine

有时候 goroutines 完成所需的时间比预期的要长——在这种情况下,我们希望 goroutines 超时,这样我们就可以解除程序的阻塞。本节介绍了两种这样的技术。

main()函数中超时 goroutine

本小节介绍了一种超时 goroutine 的简单技术。相关代码可以在timeOut1.gomain()函数中找到:

func main() {
    c1 := make(chan string)
    go func() {
        time.Sleep(3 * time.Second)
        c1 <- "c1 OK"
    }() 

time.Sleep()调用用于模拟函数完成其操作所需的时间。在这种情况下,作为 goroutine 执行的匿名函数在向c1通道发送消息之前大约需要三秒钟。

 select {
    case res := <-c1:
        fmt.Println(res)
    case <-time.After(time.Second):
        fmt.Println("timeout c1")
    } 

time.After() 调用的目的是在执行前等待所需的时间——如果执行了另一个分支,等待时间将重置。在这种情况下,我们并不关心 time.After() 返回的实际值,而是关心 time.After() 分支是否已执行,这意味着等待时间已经过去。在这种情况下,由于传递给 time.After() 函数的值小于之前执行 time.Sleep() 调用所使用的值,你很可能会收到超时消息。说“很可能”的原因是 Linux 不是一个实时操作系统,有时操作系统调度器会玩一些奇怪的游戏,尤其是在它必须处理高负载并调度大量任务时——这意味着你不应该对操作系统调度器的操作有任何假设。

 c2 := make(chan string)
    go func() {
        time.Sleep(3 * time.Second)
        c2 <- "c2 OK"
    }()
    select {
    case res := <-c2:
        fmt.Println(res)
    case <-time.After(4 * time.Second):
        fmt.Println("timeout c2")
    }
} 

之前的代码执行了一个需要大约三秒钟来执行 goroutine,因为 time.Sleep() 调用,并在 select 中使用 time.After(4 * time.Second) 定义了四秒的超时期。如果在 select 块的第一个情况中从 c2 通道获取值之后 time.After(4 * time.Second) 调用返回,则不会有超时;否则,你会收到超时。然而,在这种情况下,time.After() 调用的值提供了足够的时间让 time.Sleep() 调用返回,所以你很可能会在这里不会收到超时消息。

让我们现在验证我们的想法。运行 timeOut1.go 产生以下输出:

$ go run timeOut1.go 
timeout c1
c2 OK 

如预期的那样,第一个 goroutine 超时了,而第二个没有。接下来的一小节将介绍另一种超时技术。

main() 外部超时 goroutine

这个小节说明了另一个用于超时 goroutine 的技术。select 语句可以在一个单独的函数中找到。此外,超时期作为命令行参数给出。

timeOut2.go 的有趣之处在于 timeout() 的实现:

func timeout(t time.Duration) {
    temp := make(chan int)
    go func() {
        time.Sleep(5 * time.Second)
        defer close(temp)
    }()
    select {
    case <-temp:
        result <- false
case <-time.After(t):
        result <- true
    }
} 

timeout() 中,time.After() 调用中使用的持续时间是一个函数参数,这意味着它可以变化。再次强调,select 块支持超时逻辑。任何超过 5 秒的超时期都很可能给 goroutine 足够的时间完成。如果 timeout()false 写入 result 通道,则没有超时;如果写入 true,则有超时。运行 timeOut2.go 产生以下输出:

$ go run timeOut2.go 100
Timeout period is 100ms
Time out! 

超时期是 100 毫秒,这意味着 goroutine 没有足够的时间完成,因此收到超时消息。

$ go run timeOut2.go 5500 
Timeout period is 5.5s
OK 

这次,超时时间是 5,500 毫秒,这意味着 goroutine 有足够的时间完成。

下一个部分回顾并介绍了与通道相关的高级概念。

再次回顾 Go 通道

到目前为止,我们已经看到了通道的基本用法——本节介绍了 nil 通道、信号通道和缓冲通道的定义和用法。

尽管通道看起来像是一个有趣的概念,但它们并不是每个并发问题的答案,因为存在一些时候它们可以被互斥锁和共享内存所替代。所以,不要强迫使用通道

记住通道类型的零值是nil,如果你向一个关闭的通道发送消息,程序会崩溃。然而,如果你尝试从关闭的通道读取,你会得到该通道类型的零值。因此,在关闭通道后,你不能再向其写入,但仍然可以从中读取。要能够关闭通道,该通道不能是只读的。

此外,一个nil通道总是阻塞的,这意味着从nil通道读取和写入都会阻塞。当你想通过将nil值分配给通道变量来禁用select语句的一个分支时,这个通道属性非常有用。最后,如果你尝试关闭一个nil通道,你的程序将会崩溃。这最好在closeNil.go程序中说明:

package main
func main() {
    var c chan string 

之前的声明定义了一个名为cnil通道,其类型为string

 close(c)
} 

运行closeNil.go会生成以下输出:

panic: close of nil channel
goroutine 1 [running]:
main.main()
    ~/go/src/github.com/mactsouk/mGo4th/ch08/closeNil.go:5 +0x20
exit status 2 

之前的输出显示了如果你尝试关闭一个nil通道时将收到的消息。现在让我们来讨论带缓冲的通道。

带缓冲的通道

与容量为 0 的无缓冲通道不同,无缓冲通道需要发送方在另一端有一个相应的接收方准备就绪,带缓冲的通道允许在需要接收方之前向通道发送一定数量的值。

这些通道允许我们快速将工作放入队列,以便能够处理更多请求并在稍后处理请求。此外,你可以使用带缓冲的通道作为信号量来限制应用程序的吞吐量。

所展示的技术工作原理如下:所有传入的请求都被转发到一个通道,该通道逐个处理它们。当通道完成处理一个请求后,它会向原始调用者发送一条消息,表示它已准备好处理新的请求。因此,通道缓冲区的容量限制了它可以同时保持的请求数量。请记住,不是通道在处理请求或发送消息。

此外,请记住,带缓冲的通道会一直接受数据,直到由于容量限制而阻塞。然而,在所展示的例子中,实现是通过select语句取消剩余请求,而不是通道本身。实现该技术的源文件名为bufChannel.go,并包含以下代码:

package main
import (
    "fmt"
)
func main() {
    numbers := make(chan int, 5) 

numbers通道可以存储最多五个整数,因为它是一个容量为 5 的缓冲通道。

 counter := 10
for i := 0; i < counter; i++ {
        select {
        // This is where the processing takes place
case numbers <- i * i:
            fmt.Println("About to process", i)
        default:
            fmt.Print("No space for ", i, " ")
        } 

我们开始向numbers中放入数据——然而,当通道满时,它将不会存储更多数据,并将执行default分支。这并不是因为通道的工作方式,而是因为与select的具体实现有关。

 }
    fmt.Println()
    for {
        select {
        case num := <-numbers:
            fmt.Print("*", num, " ")
        default:
            fmt.Println("Nothing left to read!")
            return
        }
    }
} 

同样,我们尝试使用 for 循环从数字中读取数据。当从通道读取所有数据后,default 分支将被执行,并使用其 return 语句终止程序——当 main() 返回时,整个程序将被终止。

运行 bufChannel.go 产生以下输出:

$ go run bufChannel.go 
About to process 0
. . .
About to process 4
No space for 5 No space for 6 No space for 7 No space for 8 No space for 9 
*0 *1 *4 *9 *16 Nothing left to read! 

让我们现在讨论 nil 通道。

空通道

nil 通道总是阻塞!因此,当你故意想要这种行为时,你应该使用它们!下面的代码演示了 nil 通道:

package main
import (
    "fmt"
"math/rand"
"sync"
"time"
)
var wg sync.WaitGroup 

我们将 wg 声明为全局变量,以便在代码的任何地方都可以访问它,并避免将其作为参数传递给每个需要它的函数。这不是 Go 的惯用用法,尽管它的实现更简单,有些人可能不喜欢这种方法。一个替代方案是在 main() 中声明 wg 并将指针传递给每个需要它的函数——你可以将其作为练习来实现。

func add(c chan int) {
    sum := 0
    t := time.NewTimer(time.Second)
    for {
        select {
        case input := <-c:
            sum = sum + input
        case <-t.C:
            c = nil
            fmt.Println(sum)
            wg.Done()
        }
    }
} 

send() 函数持续向通道 c 发送随机数字。不要混淆通道 c,它是一个(通道)函数参数,与定时器 t 的部分 t.C 相区别——你可以更改 c 变量的名称,但不能更改定时器中 C 字段的名称。当定时器 t 的计时结束,定时器会向 t.C 通道发送一个值。

这触发了 select 语句相关分支的执行,将值 nil 赋予通道 c 并打印 sum 变量的值,然后执行 wg.Done(),这将解除 main() 函数中找到的 wg.Wait() 的阻塞。此外,由于 c 变为 nil,它停止/阻塞 send() 向其发送更多数据。

func send(c chan int) {
    for {
        c <- rand.Intn(10)
    }
}
func main() {
    c := make(chan int)
    rand.Seed(time.Now().Unix())
    wg.Add(1)
    go add(c)
    go send(c)
    wg.Wait()
} 

运行 nilChannel.go 产生以下输出:

$ go run nilChannel.go 
11168960 

由于 add() 函数中 select 语句的第一个分支将要执行的次数是不确定的,所以每次执行 nilChannel.go 时,你都会得到不同的结果。

下一个子节讨论工作池。

工作池

工作池是一组处理分配给它们的任务的线程。Apache 网络服务器和 Go 的 net/http 包大致以这种方式工作:主进程接受所有传入的请求,然后将它们转发给工作进程以提供服务。一旦工作进程完成其任务,它就准备好为新客户端提供服务。

由于 Go 没有线程,所提供的实现将使用 goroutine 而不是线程。此外,线程在服务请求后通常不会死亡,因为结束线程和创建新线程的成本太高,而 goroutine 在完成任务后会死亡。Go 中的工作池通过使用带缓冲的通道来实现,因为它们允许你限制同时运行的 goroutine 的数量。

所提供的实用程序实现了一个简单的任务:它使用单个 goroutine 为每个请求提供服务,处理整数并打印它们的平方值。wPools.go 的代码如下:

package main
import (
    "fmt"
"os"
"runtime"
"strconv"
"sync"
"time"
)
type Client struct {
    id      int
    integer int
} 

Client 结构用于跟踪程序将要处理的请求。

type Result struct {
    job    Client
    square int
} 

Result 结构用于保存每个 Client 的数据以及客户端生成的结果。简单来说,Client 结构持有每个请求的输入数据,而 Result 持有请求的结果——如果你想处理更复杂的数据,你应该修改这些结构。

var size = runtime.GOMAXPROCS(0)
var clients = make(chan Client, size)
var data = make(chan Result, size) 

clientsdata 缓冲通道分别用于获取新的客户端请求和写入结果。如果你想使程序运行得更快,可以增加 size 的值。

func worker(wg *sync.WaitGroup) {
    for c := range clients {
        square := c.integer * c.integer
        output := Result{c, square}
        data <- output
        time.Sleep(time.Second)
    }
    wg.Done()
} 

worker() 函数通过读取 clients 通道来处理请求。一旦处理完成,结果将被写入 data 通道。使用 time.Sleep() 引入的延迟不是必需的,但它能更好地让你理解生成的输出是如何打印的。

func create(n int) {
    for i := 0; i < n; i++ {
        c := Client{i, i}
        clients <- c
    }
    close(clients)
} 

create() 函数的目的是正确创建所有请求,然后将它们发送到 clients 缓冲通道进行处理。请注意,clients 通道由 worker() 读取。

func main() {
    if len(os.Args) != 3 {
        fmt.Println("Need #jobs and #workers!")
        return
    }
    nJobs, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    nWorkers, err := strconv.Atoi(os.Args[2])
    if err != nil {
        fmt.Println(err)
        return
    } 

在前面的代码中,你读取了定义作业和工作线程数量的命令行参数。如果作业数量大于工作线程数量,作业将以较小的块提供服务。

 go create(nJobs) 

create() 调用模拟了你将要处理的客户端请求。

 finished := make(chan interface{}) 

finished 通道用于阻塞程序,因此不需要特定的数据类型。

 go func() {
        for d := range data {
            fmt.Printf("Client ID: %d\tint: ", d.job.id)
            fmt.Printf("%d\tsquare: %d\n", d.job.integer, d.square)
        }
        finished <- true 

finished <- true 语句用于在 for range 循环结束时立即解除程序阻塞。for range 循环在 data 通道关闭时结束,这发生在 wg.Wait() 之后,意味着所有工作线程都已完成。

 }()
    var wg sync.WaitGroup
    for i := 0; i < nWorkers; i++ {
        wg.Add(1)
        go worker(&wg)
    }
    wg.Wait()
    close(data) 

上一个 for 循环的目的是生成所需的 worker() 协程数量以处理所有请求。

 fmt.Printf("Finished: %v\n", <-finished)
} 

fmt.Printf() 中的 <-finished 语句会阻塞,直到 finished 通道关闭。

运行 wPools.go 会创建以下类型的输出:

$ go run wPools.go 8 5
Client ID: 0    int: 0    square: 0
Client ID: 1    int: 1    square: 1
Client ID: 2    int: 2    square: 4
Client ID: 3    int: 3    square: 9
Client ID: 4    int: 4    square: 16
Client ID: 5    int: 5    square: 25
Client ID: 6    int: 6    square: 36
Finished: true 

之前的输出显示所有请求都已处理。这种技术允许你服务一定数量的请求,从而避免服务器过载。为此,你需要编写更多的代码。

下一小节将介绍信号通道,并展示一种使用它们为少量协程定义执行顺序的技术。

信号通道

信号通道是仅用于信号的一种通道。简单来说,当你想通知另一个协程某事时,可以使用信号通道。信号通道不应用于数据传输。你将在下一小节中看到信号通道的实际应用,我们将指定协程的执行顺序。

为你的协程指定执行顺序

本小节介绍了一种使用信号通道指定 goroutines 执行顺序的技术。然而,请注意,当处理少量 goroutines 时,这种技术效果最佳。所提供的代码示例有四个 goroutines,我们希望按以下顺序执行它们——首先,函数A()的 goroutine,然后是函数B(),接着是C(),最后是D()

defineOrder.go的代码(不包括package语句和import块)如下:

var wg sync.WaitGroup
func A(a, b chan struct{}) {
    <-a
    fmt.Println("A()!")
    time.Sleep(time.Second)
    close(b)
} 

函数A()将被阻塞,直到作为参数传递的通道a被关闭。在它结束之前,它会关闭作为参数传递的通道b。这将解除下一个 goroutine 的阻塞,该 goroutine 将是函数B()

func B(a, b chan struct{}) {
    <-a
    fmt.Println("B()!")
    time.Sleep(3 * time.Second)
    close(b)
} 

同样,函数B()将被阻塞,直到作为参数传递的通道a被关闭。在B()结束之前,它会关闭作为参数传递的通道b。正如之前一样,这将解除后续函数的阻塞:

func C(a, b chan struct{}) {
    <-a
    fmt.Println("C()!")
    close(b)
} 

与函数A()B()的情况一样,函数C()的执行被通道a阻塞。在它结束之前,它会关闭通道b

func D(a chan struct{}) {
    <-a
    fmt.Println("D()!")
    wg.Done()
} 

这是将要执行的最后一个函数。因此,尽管它被阻塞了,但在退出之前不会关闭任何通道。此外,作为最后一个函数,它可能被执行多次,这与函数A()B()C()不同,因为通道只能关闭一次。

func main() {
    x := make(chan struct{})
    y := make(chan struct{})
    z := make(chan struct{})
    w := make(chan struct{}) 

我们需要拥有与我们要作为 goroutines 执行的函数数量一样多的通道。

 wg.Add(1)
    go func() {
        D(w)
    }() 

这证明了由 Go 代码指定的执行顺序并不重要,因为D()将是最后一个被执行的。

 wg.Add(1)
    go func() {
        D(w)
    }()
    go A(x, y)
    wg.Add(1)
    go func() {
        D(w)
    }()
    go C(z, w)
    go B(y, z) 

尽管我们在B()之前运行C(),但C()将在B()完成后完成。

 wg.Add(1)
    go func() {
        D(w)
    }()
    // This triggers the process
close(x) 

首个通道的关闭触发了 goroutines 的执行,因为这样可以解除A()的阻塞。

 wg.Wait()
} 

运行defineOrder.go会产生以下输出:

$ go run defineOrder.go
A()!
B()!
C()!
D()! D()! D()! D()! 

因此,作为 goroutines 执行的四个函数将按所需顺序执行,并且在最后一个函数的情况下,执行所需的次数。

处理 UNIX 信号

UNIX 信号提供了一种非常方便的方式与异步交互应用程序和服务器进程。在 Go 中处理 UNIX 信号需要使用仅用于此任务的通道。所提供的程序分别处理SIGINT(在 Go 中称为syscall.SIGINT)和SIGINFO,并在switch块中使用default情况来处理剩余的信号。该switch块的实现允许您根据需要区分不同的信号。

存在一个专门的通道,用于接收所有信号,如 signal.Notify() 函数定义。Go 通道可以有容量——这个特定通道的容量是 1,以便能够一次接收并保持 一个信号。这完全合理,因为信号可以终止程序,而且没有必要同时尝试处理另一个信号。通常有一个匿名函数作为 goroutine 执行,并执行信号处理而无需做其他事情。该 goroutine 的主要任务是监听通道中的数据。一旦接收到信号,它就会被发送到该通道,由 goroutine 读取,并存储到一个变量中——此时,通道可以接收更多信号。该变量通过 switch 语句进行处理。

有些信号无法捕获,操作系统也不能忽略它们。因此,SIGKILLSIGSTOP 信号不能被阻塞、捕获或忽略;这是因为它们允许特权用户以及 UNIX 内核终止他们想要的任何进程。

通过输入以下代码创建一个文本文件——一个好的文件名可以是 signals.go

package main
import (
    "fmt"
"os"
"os/signal"
"syscall"
"time"
)
func handleSignal(sig os.Signal) {
    fmt.Println("handleSignal() Caught:", sig)
} 

handleSignal() 是一个用于处理信号的独立函数。然而,你也可以在 switch 语句的分支中直接处理信号。

func main() {
    fmt.Printf("Process ID: %d\n", os.Getpid())
    sigs := make(chan os.Signal, 1) 

我们创建了一个类型为 os.Signal 的通道,因为所有通道都必须有类型。

 signal.Notify(sigs) 

前面的语句意味着处理所有可以处理的信号。

 start := time.Now()
    go func() {
        for {
            sig := <-sigs 

等待从 sigs 通道读取数据(< -)并将其存储在 sig 变量中。

 switch sig { 

根据读取的值,相应地采取行动。这就是如何区分不同的信号。

 case syscall.SIGINT:
                duration := time.Since(start)
                fmt.Println("Execution time:", duration) 

对于处理 syscall.SIGINT,我们计算程序执行开始以来经过的时间,并将其打印在屏幕上。

 case syscall.SIGINFO:
                handleSignal(sig) 

syscall.SIGINFO 情况的代码调用 handleSignal() 函数——开发者需要决定实现的细节。

在 Linux 机器上,你应该将 syscall.SIGINFO 替换为另一个信号,例如 syscall.SIGUSR1syscall.SIGUSR2,因为 Linux 上没有 syscall.SIGINFO (github.com/golang/go/issues/1653)。

 // do not use return here because the goroutine exits
// but the time.Sleep() will continue to work!
                os.Exit(0)
            default:
                fmt.Println("Caught:", sig)
            } 

如果没有匹配项,default 情况将处理剩余的值,并只打印一条消息。

 }
    }()
    for {
        fmt.Print("+")
        time.Sleep(10 * time.Second)
    }
} 

main() 函数末尾的无穷循环是为了模拟真实程序的操作。如果没有无穷的 for 循环,程序几乎会立即退出。

运行 signals.go 并与之交互会产生以下类型的输出:

$ go run signals.go
Process ID: 70153
+^CExecution time: 631.533125ms
+Caught: user defined signal 1
+Caught: urgent I/O condition
+signal: killed 

输出的第二行是在键盘上按下 Ctrl + C 生成的,这在 UNIX 机器上会将 syscall.SIGINT 信号发送到程序。输出的第三行是由在另一个终端上执行 kill -USR1 74252 造成的。输出的最后一行是由 kill -9 74252 命令生成的。由于 KILL 信号(也用数字 9 表示)无法处理,它终止了程序,并打印出 killed 消息。

处理两个信号

如果您想处理有限数量的信号而不是所有信号,您应该将 signal.Notify(sigs) 语句替换为以下类似语句:

signal.Notify(sigs, syscall.SIGINT, syscall.SIGINFO) 

之后,您需要相应地修改负责信号处理的 goroutine 的代码,以便识别和处理 syscall.SIGINTsyscall.SIGINFO——当前版本(signals.go)已经处理了这两个信号。

下一节将讨论共享内存和共享变量,这是一种非常方便的方法,通过使用通道使 goroutines 之间相互通信。

共享内存和共享变量

共享内存和共享变量是并发编程中的巨大主题,也是 UNIX 线程之间相互通信的最常见方式。相同的原理也适用于 Go 和 goroutines,这正是本节要讨论的内容。互斥锁变量,即互斥排他变量的缩写,主要用于线程同步和保护在同时可能发生多个写入或一个写入和一个读取时的共享数据。互斥锁就像一个容量为 1 的缓冲通道,允许最多一个 goroutine 在任何给定时间访问共享变量。这意味着两个或更多 goroutine 无法同时更新该变量。Go 提供了 sync.Mutexsync.RWMutex 数据类型。

并发程序的一个 关键部分 是不能由所有进程、线程或在这种情况下,goroutines 同时执行的代码。这是需要由互斥锁保护的代码。因此,识别代码中的关键部分可以使整个编程过程变得简单得多,您应该特别注意这项任务。当两个关键部分都使用相同的 sync.Mutexsync.RWMutex 变量时,关键部分不能嵌入到另一个关键部分中。然而,几乎在任何情况下都要避免将互斥锁传播到函数中,因为这会使您很难判断是否嵌入了互斥锁

sync.Mutex 类型

sync.Mutex 类型是 Go 对互斥锁的实现。其定义可以在 sync 目录下的 mutex.go 文件中找到(您不需要知道 sync.Mutex 的定义就可以使用它):

type Mutex struct {
    state int32
    sema  uint32
} 

sync.Mutex 的定义并没有什么特别之处。所有有趣的工作都是由 sync.Lock()sync.Unlock() 函数完成的,它们分别可以锁定和解锁一个 sync.Mutex 变量。锁定互斥锁意味着在它被使用 sync.Unlock() 函数释放之前,没有人可以锁定它。所有这些都在 mutex.go 文件中得到了说明,该文件包含以下代码:

package main
import (
    "fmt"
"os"
"strconv"
"sync"
"time"
)
var m sync.Mutex
var v1 int
func change() {
    m.Lock()
    defer m.Unlock() 

这个函数会改变 v1 的值。关键部分从这里开始。

 time.Sleep(time.Second)
    v1 = v1 + 1
if v1 == 10 {
        v1 = 0
        fmt.Print("* ")
    } 

这是关键部分的结束。现在,另一个 goroutine 可以锁定互斥锁。

}
func read() int {
    m.Lock()
    a := v1
    defer m.Unlock()
    return a
} 

这个函数用于读取 v1 的值——因此,它应该使用互斥锁来确保过程是并发安全的。最具体地说,我们想确保在我们读取它的时候没有人会改变 v1 的值。程序的其余部分包含了 main() 函数的实现——你可以自由地查看书籍的 GitHub 仓库中的 mutex.go 的完整代码。

运行 mutex.go 产生以下输出:

$ go run -race mutex.go 10
0 -> 1-> 2-> 3-> 4-> 5-> 6-> 7-> 8-> 9* -> 0-> 0 

之前的输出显示,由于使用了互斥锁,goroutines 无法访问共享数据,因此没有隐藏的竞态条件。

下一个子节展示了如果我们忘记解锁互斥锁可能会发生什么。

如果你忘记解锁互斥锁会发生什么?

忘记解锁 sync.Mutex 互斥锁会创建一个恐慌情况,即使在最简单的程序中也是如此。同样的情况也适用于下一节中介绍的 sync.RWMutex 互斥锁。现在让我们通过一个代码示例来更好地理解这种不愉快的情况——这是 forgetMutex.go 的一部分:

var m sync.Mutex
var w sync.WaitGroup
func function() {
    m.Lock()
    fmt.Println("Locked!")
} 

在这里,我们锁定了一个互斥锁但没有释放它。这意味着如果我们多次以 goroutine 的形式运行 function(),那么第一次运行之后的所有实例都将被阻塞,等待 Lock() 共享互斥锁。在我们的例子中,我们运行了两个 goroutine——你可以自由地查看 forgetMutex.go 的完整代码以获取更多细节。运行 forgetMutex.go 生成以下输出:

Locked!
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x140000021a0?)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/runtime/sema.go:62 +0x2c
sync.(*WaitGroup).Wait(0x100fa1710)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/waitgroup.go:116 +0x74
main.main()
    ~/go/src/github.com/mactsouk/mGo4th/ch08/forgetMutex.go:29 +0x5c
goroutine 34 [sync.Mutex.Lock]:
sync.runtime_SemacquireMutex(0x0?, 0x0?, 0x0?)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/runtime/sema.go:77 +0x28
sync.(*Mutex).lockSlow(0x100fa1520)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/mutex.go:171 +0x174
sync.(*Mutex).Lock(...)
    /opt/homebrew/Cellar/go/1.21.0/libexec/src/sync/mutex.go:90
main.function()
    ~/go/src/github.com/mactsouk/mGo4th/ch08/forgetMutex.go:12 +0x84
main.main.func1()
    ~/go/src/github.com/mactsouk/mGo4th/ch08/forgetMutex.go:20 +0x50
created by main.main in goroutine 1
    ~/go/src/github.com/mactsouk/mGo4th/ch08/forgetMutex.go:18 +0x34
exit status 2 

如预期的那样,程序因为死锁而崩溃。为了避免这种情况,请务必尽快解锁程序中创建的任何互斥锁。

现在我们来讨论 sync.RWMutex,它是 sync.Mutex 的改进版本。

sync.RWMutex 类型

sync.RWMutex 数据类型是 sync.Mutex 的改进版本,并在 Go 标准库的 sync 目录下的 rwmutex.go 文件中定义如下:

type RWMutex struct {
    w           Mutex
    writerSem   uint32
    readerSem   uint32
    readerCount int32
    readerWait  int32
} 

换句话说,sync.RWMutex 是基于 sync.Mutex 并添加了必要的改进。因此,你可能会问,sync.RWMutex 如何改进 sync.Mutex?尽管单个函数可以使用 sync.RWMutex 互斥锁执行写操作,但你可以有多个读者拥有 sync.RWMutex 互斥锁——这意味着使用 sync.RWMutex 的读操作通常更快。然而,有一个重要的细节你应该注意:直到一个 sync.RWMutex 互斥锁的所有读者都解锁它,你才能锁定它进行写操作,这是你为了允许多个读者而获得的性能改进所必须付出的微小代价。

可以帮助你与 sync.RWMutex 一起工作的函数是 RLock()RUnlock(),分别用于锁定和解锁互斥锁以进行读取操作。当你想锁定和解锁 sync.RWMutex 互斥锁进行写操作时,仍然应该使用 sync.Mutex 中使用的 Lock()Unlock() 函数。最后,很明显,你不应该在 RLock()RUnlock() 代码块内部对任何共享变量进行更改。

所有这些都在 rwMutex.go 中得到了说明——重要的代码如下:

var Password *secret
var wg sync.WaitGroup
type secret struct {
    RWM      sync.RWMutex
    password string
} 

这是程序的共享变量——你可以分享任何类型的变量。

func Change(pass string) {
    if Password == nil {
        fmt.Println("Password is nil!")
        return
    }
    fmt.Println("Change() function")
    Password.RWM.Lock() 

这是关键区的开始。

 fmt.Println("Change() Locked")
    time.Sleep(4 * time.Second)
    Password.password = pass
    Password.RWM.Unlock() 

这是关键区的结束。

 fmt.Println("Change() UnLocked")
} 

Change() 函数对共享变量 Password 进行更改,因此需要使用 Lock() 函数,该函数只能被单个写者持有。

func show () {
    defer wg.Done()
    defer Password.RWM.RUnlock()
    Password.RWM.RLock()
    fmt.Println("Show function locked!")
    time.Sleep(2 * time.Second)
    fmt.Println("Pass value:", Password.password)
} 

show() 函数读取共享变量 Password,因此它允许使用 RLock() 函数,该函数可以被多个读者持有。在 main() 函数中,在调用 Change() 函数之前,作为协程执行了三个 show() 函数。这里的关键点是不会有任何竞态条件发生。运行 rwMutex.go 产生以下输出:

$ go run rwMutex.go
Change() function 

Change() 函数被执行,但不能获取互斥锁,因为它已经被一个或多个 show() 协程占用。

Show function locked!
Show function locked! 

之前的输出验证了两个 show() 协程已经成功获取了互斥锁进行读取。

Change() function 

在这里,我们可以看到一个第二个 Change() 函数正在运行并等待获取互斥锁。

Pass value: myPass
Pass value: myPass 

这是两个 show() 协程的输出。

Change() Locked
Change() UnLocked 

我们可以看到一个 Change() 协程完成了它的任务。

Show function locked!
Pass value: 54321 

之后,另一个 show() 协程完成了。

Change() Locked
Change() UnLocked
Current password value: 123456 

最后,第二个 Change() 协程完成了。最后一行输出是为了确保密码值已经改变——请查看 rwMutex.go 的完整代码以获取更多细节。

请记住,你将获得的输出可能会因为调度器的工作方式而有所不同。这是并发编程的本质,这些程序没有任何机制来确保 show() 函数应该首先被调度。

下一个子节讨论了使用 atomic 包来避免竞态条件。

atomic

原子操作是相对于其他线程(在这种情况下,是其他 goroutine)在单个步骤中完成的操作。这意味着原子操作不能在中间被中断。Go 标准库提供了atomic包,在某些简单情况下,可以帮助你避免使用互斥锁。使用atomic包,你可以有多个 goroutine 访问原子计数器,而无需同步问题,也不必担心竞态条件。然而,互斥锁比原子操作更灵活。

如以下代码所示,当使用原子变量时,原子变量的所有读取和写入操作都必须使用atomic包提供的函数来完成,以避免竞态条件。

atomic.go中的代码如下,通过硬编码一些值来减小其大小:

package main
import (
    "fmt"
"sync"
"sync/atomic"
)
type atomCounter struct {
    val int64
} 

这是一个用于存储所需int64原子变量的结构体。

func (c *atomCounter) Value() int64 {
    return atomic.LoadInt64(&c.val)
} 

这是一个辅助函数,它使用atomic.LoadInt64()返回一个int64原子变量的当前值。

func main() {
    X := 100
    Y := 4
var waitGroup sync.WaitGroup
    counter := atomCounter{}
    for i := 0; i < X; i++ { 

我们创建了大量的 goroutine 来改变共享变量——正如之前所述,使用atomic包来处理共享变量提供了一个简单的方法来避免在改变共享变量值时发生竞态条件。

 waitGroup.Add(1)
        go func() {
            defer waitGroup.Done()
            for i := 0; i < Y; i++ {
                atomic.AddInt64(&counter.val, 1)
            } 

atomic.AddInt64()函数以安全的方式更改counter结构变量中val字段的值。

 }()
    }
    waitGroup.Wait()
    fmt.Println(counter.Value())
} 

在检查竞态条件的同时运行atomic.go会产生以下类型的输出:

$ go run -race atomic.go
400 

因此,原子变量由多个 goroutine 修改而不会出现任何问题。

下一个子节将展示如何使用 goroutine 来共享内存。

使用 goroutine 共享内存

本节子节说明了如何使用专用 goroutine 来共享数据。尽管共享内存是线程之间通信的传统方式,但 Go 内置了同步特性,允许单个 goroutine 拥有共享数据。这意味着其他 goroutine 必须向拥有共享数据的单个 goroutine 发送消息,这防止了数据的损坏。这样的 goroutine 被称为监控 goroutine。在 Go 术语中,这是通过通信来共享而不是通过共享来通信

个人而言,我更喜欢使用监控 goroutine 而不是传统的共享内存技术,因为使用监控 goroutine 的实现更安全,更接近 Go 的哲学,并且更容易理解。

程序的逻辑可以在monitor()函数的实现中找到。更具体地说,select语句协调整个程序的操作。当你有一个读取请求时,read()函数尝试从由monitor()函数控制的readValue通道中读取。

这将返回值变量的当前值。另一方面,当你想要更改存储的值时,你调用set()。这会将数据写入writeValue通道,该通道也由相同的select语句处理。因此,没有使用monitor()函数,没有人可以处理共享变量,该函数负责。

monitor.go的代码如下:

package main
import (
    "fmt"
"math/rand"
"os"
"strconv"
"sync"
"time"
)
var readValue = make(chan int)
var writeValue = make(chan int)
func set(newValue int) {
    writeValue <- newValue
} 

此函数将数据发送到writeValue通道。

func read() int {
    return <-readValue
} 

当调用read()函数时,它会从readValue通道读取——这种读取发生在monitor()函数内部。

func monitor() {
    var value int
for {
        select {
        case newValue := <-writeValue:
            value = newValue
            fmt.Printf("%d ", value)
        case readValue <- value:
        }
    }
} 

monitor()函数包含程序的逻辑,包括无限for循环和select语句。第一个情况从writeValue通道接收数据,相应地设置value变量,并打印新值。第二个情况将value变量的值发送到readValue通道。由于所有流量都通过monitor()及其select块,因此不可能出现竞争条件,因为只有一个monitor()实例正在运行。

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Please give an integer!")
        return
    }
    n, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Going to create %d random numbers.\n", n)
    rand.Seed(time.Now().Unix())
    go monitor() 

重要的是,monitor()函数必须首先执行,因为这是协调程序流程的 goroutine。

 var wg sync.WaitGroup
    for r := 0; r < n; r++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            set(rand.Intn(10 * n))
        }()
    } 

for循环结束时,这意味着我们已经创建了所需的随机数数量。

 wg.Wait()
    fmt.Printf("\nLast value: %d\n", read())
} 

最后,我们在打印最后一个随机数之前等待所有set() goroutines 完成。

运行monitor.go产生以下输出:

$ go run monitor.go 10
Going to create 10 random numbers.
98 22 5 84 20 26 45 36 0 16 
Last value: 16 

因此,通过 10 个 goroutines 创建了 10 个随机数,所有这些 goroutines 都将它们的输出发送到monitor()函数,该函数也作为一个 goroutine 执行。除了接收结果外,monitor()函数还会将它们打印到屏幕上,因此所有这些输出都是由monitor()生成的。

下一节将更详细地讨论go语句。

闭包变量和 go 语句

在本节中,我们将讨论闭包变量,即闭包内的变量,以及go语句。请注意,goroutines 中的闭包变量在 goroutine 实际运行时以及执行go语句以创建新 goroutine 时被评估。这意味着闭包变量将在 Go 调度器决定执行相关代码时被其值替换。这可以在goClosure.gomain()函数中看到:

func main() {
    for i := 0; i <= 20; i++ {
        go func() {
            fmt.Print(i, " ")
        }()
    }
    time.Sleep(time.Second)
    fmt.Println()
} 

运行goClosure.go产生以下输出:

$ go run goClosure.go 
3 7 21 21 21 21 21 21 21 21 21 21 21 21 21 21 21 21 21 21 21 

程序主要打印数字 21,这是for循环变量的最后一个值,而不是其他数字。因为i是一个闭包变量,它在执行时被评估。由于 goroutines 开始但等待 Go 调度器允许它们执行,for循环结束,所以使用的i的值是 21。最后,同样的问题也适用于 Go 通道,所以请小心。

使用 Go 竞争检测器运行goClosure.go揭示问题:

$ go run -race goClosure.go
5 4 5 5 ==================
WARNING: DATA RACE
Read at 0x00c00011e028 by goroutine 6:
  main.main.func1()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/goClosure.go:11 +0x34
Previous write at 0x00c00011e028 by main goroutine:
  main.main()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/goClosure.go:9 +0x5c
Goroutine 6 (running) created at:
  main.main()
      ~/go/src/github.com/mactsouk/mGo4th/ch08/goClosure.go:10 +0x44
==================
8 8 6 10 12 11 15 15 15 18 20 20 21 15 21 21 21
Found 1 data race(s)
exit status 66 

现在,让我们纠正goClosure.go并向您展示——新的名称是goClosureCorrect.go,其main()函数如下:

func main() {
    for i := 0; i <= 20; i++ {
        i := i
        go func() {
            fmt.Print(i, " ")
        }()
    } 

这是纠正问题的方法之一。有效的但古怪的 i := i 语句为持有正确值的 goroutine 创建了一个新的变量实例。虽然这是一种有效的方法,但这种类型的变量遮蔽不被认为是良好的实践。

在 Go 中,当在嵌套作用域中声明的变量与外部作用域中的变量具有相同的名称时,会发生变量遮蔽。虽然变量遮蔽在某些情况下可能是故意的并且有用,但它也可能导致混淆并引入微妙的错误。在实践中,建议避免不必要的变量遮蔽,并选择有意义的变量名以最大限度地减少无意中遮蔽的可能性。

 time.Sleep(time.Second)
    fmt.Println()
    for i := 0; i <= 20; i++ {
        go func(x int) {
            fmt.Print(x, " ")
        }(i)
    } 

这是一种纠正竞争条件(race condition)的完全不同的方法:将 i 的当前值作为参数传递给匿名函数,一切就绪。如 第十五章 所述,最近 Go 版本的变化,这个问题在 Go 1.22 中不存在。

 time.Sleep(time.Second)
    fmt.Println()
} 

使用竞争检测器测试 goClosureCorrect.go 生成预期的输出:

$ go run -race goClosureCorrect.go
0 1 2 4 3 5 6 9 8 7 10 11 13 12 14 16 15 17 18 20 19
0 1 2 3 4 5 6 7 8 10 9 12 13 11 14 15 16 17 18 19 20 

下一个部分将介绍 context 包的功能。

context

context 包的主要目的是定义 Context 类型并支持取消。是的,你没听错;有时,出于某种原因,你可能想放弃你正在做的事情。然而,能够包含一些关于你的取消决策的额外信息将非常有帮助。context 包允许你做到这一点。

如果你查看 context 包的源代码,你会意识到它的实现相当简单——甚至 Context 类型的实现也很简单,但 context 包非常重要。

Context 类型是一个具有四个方法的接口:Deadline()Done()Err()Value()。好消息是,你不需要实现 Context 接口的所有这些函数——你只需要使用 context.WithCancel()context.WithDeadline()context.WithTimeout() 等方法修改 Context 变量。

这三种方法都返回一个派生出的 Context(子)和一个 CancelFunc() 函数。调用 CancelFunc() 函数会移除父对子的引用并停止任何相关的计时器。作为副作用,这意味着 Go 垃圾收集器可以自由地回收不再有相关父 goroutine 的子 goroutine。为了正确地进行垃圾收集,父 goroutine 需要保持对每个子 goroutine 的引用。如果一个子 goroutine 在没有父知道的情况下结束,那么就会发生内存泄漏,直到父 goroutine 也被取消。

下面的示例展示了 context 包的使用。该程序包含四个函数,包括 main() 函数。函数 f1()f2()f3() 每个只需要一个参数(即时间延迟),因为它们需要的其他一切都在其函数体内定义。在这个例子中,我们使用 context.Background() 来初始化一个空的 Context。另一个可以创建空 Context 的函数是 context.TODO(),它将在本章后面介绍。

package main
import (
    "context"
"fmt"
"os"
"strconv"
"time"
)
func f1(t int) {
    c1 := context.Background()
    c1, cancel := context.WithCancel(c1)
    defer cancel() 

WithCancel() 方法返回父上下文的副本,并带有新的 Done 通道。请注意,cancel 变量,它是一个函数,是 context.CancelFunc() 的返回值之一。context.WithCancel() 函数使用现有的 Context 并创建一个带有取消的子上下文。context.WithCancel() 函数还返回一个 Done 通道,该通道可以被关闭,无论是当调用 cancel() 函数时,如前述代码所示,还是当父上下文的 Done 通道被关闭时。

 go func() {
        time.Sleep(4 * time.Second)
        cancel()
    }()
    select {
    case <-c1.Done():
        fmt.Println("f1() Done:", c1.Err())
        return
case r := <-time.After(time.Duration(t) * time.Second):
        fmt.Println("f1():", r)
    }
    return
} 

f1() 函数创建并执行一个 goroutine。time.Sleep() 调用模拟了一个真实的 goroutine 完成工作所需的时间。在这种情况下,它是 4 秒,但你可以设置任何你想要的时间段。如果 c1 上下文在 4 秒内调用 Done() 函数,goroutine 将没有足够的时间完成。

func f2(t int) {
    c2 := context.Background()
    c2, cancel := context.WithTimeout(c2, time.Duration(t)*time.Second)
    defer cancel() 

f2() 函数中的取消变量来自 context.WithTimeout(),它需要两个参数:一个 Context 参数和一个 time.Duration 参数。当超时时间到期时,cancel() 函数会自动被调用。

 go func() {
        time.Sleep(4 * time.Second)
        cancel()
    }()
    select {
    case <-c2.Done():
        fmt.Println("f2() Done:", c2.Err())
        return
case r := <-time.After(time.Duration(t) * time.Second):
        fmt.Println("f2():", r)
    }
    return
}
func f3(t int) {
    c3 := context.Background()
    deadline := time.Now().Add(time.Duration(2*t) * time.Second)
    c3, cancel := context.WithDeadline(c3, deadline)
    defer cancel() 

f3() 函数的逻辑与 f1()f2() 相同——select 块协调这个过程。

 go func() {
        time.Sleep(4 * time.Second)
        cancel()
    }()
    select {
    case <-c3.Done():
        fmt.Println("f3() Done:", c3.Err())
        return
case r := <-time.After(time.Duration(t) * time.Second):
        fmt.Println("f3():", r)
    }
    return
} 

f3() 函数中的 cancel 变量来自 context.WithDeadline(),它需要两个参数:一个 Context 变量和表示操作截止日期的未来时间。当截止日期通过时,cancel() 函数会自动被调用。

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Need a delay!")
        return
    }
    delay, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Delay:", delay)
    f1(delay)
    f2(delay)
    f3(delay)
} 

这三个函数由 main() 函数按顺序执行。运行 useContext.go 会产生以下类型的输出:

$ go run useContext.go 3
Delay: 3
f1(): 2023-08-28 16:23:22.300595 +0300 EEST m=+3.001225751
f2(): 2023-08-28 16:23:25.302122 +0300 EEST m=+6.002730959
f3(): 2023-08-28 16:23:28.303326 +0300 EEST m=+9.00391262 

输出的长行是 time.After() 的返回值,显示了 After() 在返回的通道上发送当前时间的时间。所有这些都表示程序的正常操作。

如果你定义一个更大的延迟,那么输出将类似于以下内容:

$ go run useContext.go 13
Delay: 13
f1() Done: context canceled
f2() Done: context canceled
f3() Done: context canceled 

这里的问题是程序在执行过程中出现延迟时会被取消操作。

关于 context.WithCancelCause

context.WithCancelCause() 方法是在 Go 1.21 中引入的。它的主要优点是提供了定制能力,这是 context 包的其他方法所不具备的。除此之外,它的行为类似于 WithCancel()

context.WithCancelCause() 类似,存在 context.WithTimeoutCause()context.WithDeadlineCause()

withCancelCause.go 程序展示了 context.WithCancelCause() 的使用方法。

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithCancelCause(ctx)
    cancel(errors.New("Canceled by timeout"))
    err := takingTooLong(ctx)
    if err != nil {
        fmt.Println(err)
        return
    }
} 

main()的实现包含两个重要元素。首先,我们调用context.WithCancelCause(),它返回一个上下文和一个CancelCauseFunc()函数,该函数的行为类似于CancelFunc(),同时允许我们定义和自定义取消原因,为错误情况提供更清晰的上下文——在这种情况下,取消原因被定义为errors.New("Canceled by timeout")。之后,我们使用我们刚刚定义的上下文调用takingTooLong()。如果takingTooLong()返回一个非nil的错误,我们打印该错误。

func takingTooLong(ctx context.Context) error {
    select {
    case <-time.After(3 * time.Second):
        fmt.Println("Done!")
        return nil
case <-ctx.Done():
        fmt.Println("Canceled!")
        return context.Cause(ctx)
    }
} 

之前的函数返回nilcontext.Cause(ctx)

运行withCancelCause.go会产生以下输出:

$ go run withCancelCause.go
Canceled!
Canceled by timeout 

因此,当select块的第二部分执行时,takingTooLong()打印Canceled!,而main()根据context.WithCancelCause()的初始化打印取消原因。

我们还没有完全完成context,因为下一章将使用它来在连接的客户端超时 HTTP 交互。下一节讨论semaphore包,它不是标准库的一部分。

信号量包

本章的最后部分介绍了由 Go 团队提供的semaphore包。信号量是一种可以限制或控制对共享资源访问的构造。由于我们谈论的是 Go,信号量可以限制 goroutine 对共享资源的访问,但最初,信号量用于限制对线程的访问。信号量可以有权重,限制可以访问资源的线程或 goroutine 的数量。

该过程通过Acquire()Release()方法得到支持,这些方法定义如下:

func (s *Weighted) Acquire(ctx context.Context, n int64) error
func (s *Weighted) Release(n int64) 

Acquire()的第二个参数定义了信号量的权重。由于我们将使用外部包,我们需要将代码放在~/go/src中,以便使用 Go 模块:~/go/src/github.com/mactsouk/mGo4th/ch08/semaphore

现在,让我们展示semaphore.go的代码,它展示了使用信号量的工作池实现:

package main
import (
    "context"
"fmt"
"os"
"strconv"
"time"
"golang.org/x/sync/semaphore"
)
var Workers = 4 

Workers变量指定了此程序可以执行的最多 goroutine 数量。

var sem = semaphore.NewWeighted(int64(Workers)) 

这是我们定义具有与可以并发执行的最多 goroutine 数量相同的权重的信号量的地方。这意味着不能同时超过Workers个 goroutine 获取信号量。

func worker(n int) int {
    square := n * n
    time.Sleep(time.Second)
    return square
} 

worker()函数作为 goroutine 的一部分运行。然而,由于我们使用信号量,没有必要将结果返回到通道。

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Need #jobs!")
        return
    }
    nJobs, err := strconv.Atoi(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    } 

之前的代码读取我们想要运行的作业数量。

 // Where to store the results
var results = make([]int, nJobs)
    // Needed by Acquire()
    ctx := context.TODO()
    for i := range results {
        err = sem.Acquire(ctx, 1)
        if err != nil {
            fmt.Println("Cannot acquire semaphore:", err)
            break
        } 

在这部分,我们尝试根据nJobs定义的作业数量多次获取信号量。如果nJobs大于Workers,那么Acquire()调用将会阻塞并等待Release()调用以解除阻塞。

 go func(i int) {
            defer sem.Release(1)
            temp := worker(i)
            results[i] = temp
        }(i)
    } 

这是我们运行执行工作并将结果写入 results 切片的 goroutines 的地方。由于每个 goroutine 都写入不同的切片元素,因此不存在任何竞争条件。

 err = sem.Acquire(ctx, int64(Workers))
    if err != nil {
        fmt.Println(err)
    } 

这是一个巧妙的技巧:我们获取所有令牌,这样 sem.Acquire() 调用就会阻塞,直到所有工作者/goroutines 完成。这在功能上类似于一个 Wait() 调用。

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

程序的最后部分是关于打印结果。在编写代码后,我们需要运行以下命令来获取所需的 Go 模块:

$ go mod init
$ go mod tidy
$ mod download golang.org/x/sync 

除了第一个命令之外,这些命令都由 go mod init 的输出指示,所以你不需要记住任何东西。

最后,运行 semaphore.go 产生以下输出:

$ go run semaphore.go 3
0 -> 0
1 -> 1
2 -> 4 

输出中的每一行都显示了输入值和输出值,它们由 -> 分隔。使用信号量保持了顺序。

使统计应用程序并发

在本章的这一节中,我们将把统计应用程序转换成一个使用 goroutines 的并发应用程序。然而,我们不会使用通道,而将采用一种不同的方法来防止死锁,同时使程序的整体设计更加简单。此外,还有一个名为 statsNC.gostats.go 版本,它不会创建任何 goroutines,并按顺序处理输入文件。

我们只将展示 stats.gomain() 函数的实现,因为这是实用工具逻辑所在的地方。然而,为了利用 goroutines,还存在一些小的额外更改。stats.go 中最耗时的部分是时间序列的归一化。

令人印象深刻的是,我们通过最小的更改将 stats.go 转换成了一个并发应用程序,这些更改主要与 goroutine 同步有关——这是一个优秀设计的良好迹象。

main() 函数的实现如下:

func main() {
    if len(os.Args) == 1 {
        fmt.Println("Need one or more file paths!")
        return
    }
    var waitGroup sync.WaitGroup
    files = make(DFslice, len(os.Args)) 

到目前为止,我们有一个用于同步 goroutines 的 sync.WaitGroup 变量。此外,我们还有一个名为 files 的切片变量,其元素数量与 os.Args 切片的长度相同——files[0] 将不会被使用。

main() 的剩余代码如下:

 for i := 1; i < len(os.Args); i++ {
        waitGroup.Add(1)
        go func(x int) {
            process(os.Args[x], x)
            defer waitGroup.Done()
        }(i)
    }
    waitGroup.Wait()
} 

我们这里有什么?有一个作为 goroutine 运行的匿名函数。这个匿名函数需要一个参数,即正在处理的命令行参数的索引。这个索引有一个方便的特性:这个索引是唯一的,这意味着我们可以使用这个唯一的索引在将数据放入 files 切片时使用——这个过程发生在 process() 中。这解决了任何潜在的竞争条件,因为每个 goroutine 都使用 files 中的不同位置。记住,files[0] 不会被使用,但我们决定将 files 做得比需要的更大,以便将第一个命令行参数的数据放入 files[1],依此类推。

除了这个之外,我们使用 sync 等待所有 goroutines 完成后再退出程序。

为了比较stats.gostatsNC.go,我们将使用更大的数据集,这些数据集都存储在./ch08/dataset目录中。三个数据文件的大小可以在以下输出中看到:

$ wc dataset/*
 1518653 1518653 4119086 dataset/1.5M
 2531086 2531086 6918628 dataset/2.5M
 4049739 4049739 11037714 dataset/4.0M
 8099478 8099478 22075428 total 

计算程序执行时间的一种快速且简单的方法是使用time(1) UNIX 实用程序。使用该实用程序,我们将比较./ch05/stats.go./ch05/stats.go的执行时间,看看会发生什么:

$ time go run stats.go ./dataset/* ./dataset/* ./dataset/*
real    0m1.240s
user    0m6.259s
sys     0m0.528s
$ time go run statsNC.go ./dataset/* ./dataset/* ./dataset/*
real    0m3.267s
user    0m7.766s
sys     0m0.535s 

在输出中重要的是以real开头的行中的值。当处理九个文件时,并发版本比非并发版本快三倍。想象一下使用更大的数据集,并且需要处理 1,000 个数据集而不是仅仅九个!

摘要

在这个重要的章节中,我们讨论了 Go 并发、goroutines、channels、select关键字、共享内存和互斥锁,以及 goroutines 的超时和context包的使用。请记住,尽管 goroutines 可以处理数据和执行命令,但它们不能直接相互通信,但它们可以通过其他方式通信,包括 channels、本地套接字和共享内存。

记住,操作系统线程由操作系统调度器控制,而在一个或多个操作系统线程中执行的 goroutines 由 Go 运行时控制。当 goroutine 或操作系统线程被执行然后暂停时,正确的术语分别是上下文切换开启和关闭。请记住,Go 调度器不时检查全局队列,以找出是否有 goroutines 等待分配到本地队列。如果全局队列和给定的本地队列都为空,则发生工作窃取

并发的主要优势在于它允许将更大的任务分解成更小的任务,并使每个较小的任务并发执行。此外,并发在将多个 HTTP 请求分配给不同的 goroutines 方面做得很好。最后,并发更好地利用了具有多个核心和虚拟环境的现代 CPU。然而,并发增加了软件设计和代码的复杂性,这影响了可读性和可维护性。因此,你可能需要在代码中最后添加并发,就像我们在统计应用程序中所做的那样。并发的一个其他担忧是消耗所有可用资源,使其他服务不可靠或甚至不可用。最后,并发代码更难进行基准测试——如果你想比较两个并发实现,最好是比较它们的顺序版本,这更多地说明了实际的算法和代码效率。

需要记住的是,合理地使用并发和 goroutines 将允许你编写强大的 Go 应用程序。请随意实验本章的概念和示例,以更好地理解 goroutines、channels 和共享内存。

下一章将全部关于网络服务和在 Go 中使用 HTTP 协议。其中,我们将把统计应用程序转换为网络服务。

练习

  • 尝试实现一个使用带缓冲的通道的 wc(1) 并发版本。

  • 尝试实现一个使用共享内存的 wc(1) 并发版本。

  • 尝试实现一个使用信号量的 wc(1) 并发版本。

  • 尝试实现一个将输出保存到文件的 wc(1) 并发版本。

其他资源

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第九章:构建网络服务

本章的核心主题是使用 net/http 包处理 HTTP——请记住,所有网络服务都需要网络服务器才能运行。此外,在本章中,我们将把统计应用程序转换为接受 HTTP 连接的网络应用程序,并创建一个命令行客户端来与之交互。在章节的最后部分,我们将学习如何超时 HTTP 连接。

更详细地说,本章涵盖了:

  • net/http

  • 创建一个网络服务器

  • 更新统计应用程序

  • 开发网络客户端

  • 为统计服务创建客户端

  • 超时 HTTP 连接

net/http

net/http 包提供了允许你开发网络服务器和客户端的函数。例如,客户端使用 http.Get()http.NewRequest() 来发送 HTTP 请求,而 http.ListenAndServe() 则用于通过指定服务器监听的 IP 地址和 TCP 端口来启动网络服务器。此外,http.HandleFunc() 定义了支持的 URL 以及将要处理这些 URL 的函数。

接下来的三个小节描述了 net/http 包中的三个重要数据结构——在阅读本章时,你可以将这些描述作为参考。

http.Response 类型

http.Response 结构体体现了 HTTP 请求的响应——一旦收到响应头,http.Clienthttp.Transport 都会返回 http.Response 值。其定义可以在 go.dev/src/net/http/response.go 找到:

type Response struct {
    Status     string // e.g. "200 OK"
    StatusCode int // e.g. 200
    Proto      string // e.g. "HTTP/1.0"
    ProtoMajor int // e.g. 1
    ProtoMinor int // e.g. 0
    Header Header
    Body io.ReadCloser 
    ContentLength int64
    TransferEncoding []string
    Close bool
    Uncompressed bool
    Trailer Header 
    Request *Request
    TLS *tls.ConnectionState
} 

你不必使用所有结构字段,但了解它们的存在是好的。然而,其中一些字段,如 StatusStatusCodeBody,比其他字段更重要。Go 源文件以及 go doc http.Response 的输出都包含了关于每个字段目的的更多信息,这同样适用于标准 Go 库中找到的大多数 struct 数据类型。

http.Request 类型

http.Request 结构体代表了一个客户端构建的 HTTP 请求,以便发送或接收 HTTP 服务器。http.Request 的公共字段如下:

type Request struct {
    Method string
    URL *url.URL
    Proto  string
    ProtoMajor int
    ProtoMinor int
    Header Header
    Body io.ReadCloser
    GetBody func() (io.ReadCloser, error)
    ContentLength int64
    TransferEncoding []string
    Close bool
    Host string
    Form url.Values
    PostForm url.Values
    MultipartForm *multipart.Form
    Trailer Header
    RemoteAddr string
    RequestURI string
    TLS *tls.ConnectionState
    Cancel <-chan struct{}
    Response *Response
} 

Body 字段包含请求的主体。在读取请求的主体之后,你可以调用 GetBody(),它返回主体的新副本——这是可选的。

现在让我们介绍 http.Transport 结构体。

http.Transport 类型

http.Transport 的定义,它为你提供了更多对 HTTP 连接的控制,相当长且复杂:

type Transport struct {
    Proxy func(*Request) (*url.URL, error)
    DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
    Dial func(network, addr string) (net.Conn, error)
    DialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error)
    DialTLS func(network, addr string) (net.Conn, error)
    TLSClientConfig *tls.Config
    TLSHandshakeTimeout time.Duration
    DisableKeepAlives bool
    DisableCompression bool
    MaxIdleConns int
    MaxIdleConnsPerHost int
    MaxConnsPerHost int
    IdleConnTimeout time.Duration
    ResponseHeaderTimeout time.Duration
    ExpectContinueTimeout time.Duration
    TLSNextProto map[string]func(authority string, c *tls.Conn) RoundTripper
    ProxyConnectHeader Header
    GetProxyConnectHeader func(ctx context.Context, proxyURL *url.URL, target string) (Header, error)
    MaxResponseHeaderBytes int64
    WriteBufferSize int
    ReadBufferSize int
    ForceAttemptHTTP2 bool
} 

请记住,与http.Client相比,http.Transport是低级别的。后者实现了一个高级 HTTP 客户端——每个http.Client都包含一个Transport字段。你不需要在所有程序中使用http.Transport,也不需要始终处理它的所有字段。要了解更多关于DefaultTransport的信息,请输入go doc http.DefaultTransport

让我们现在学习如何开发一个 Web 服务器。

创建 Web 服务器

本节介绍了一个用 Go 开发的简单 Web 服务器,以更好地理解此类应用程序背后的原理。尽管用 Go 编写的 Web 服务器可以高效且安全地做很多事情,但如果你真正需要的是一个支持模块、多个网站和虚拟主机的强大 Web 服务器,那么你最好使用 Apache、Nginx 或 Caddy 这样的 Web 服务器,这些服务器是用 Go 编写的。这些强大的 Web 服务器通常位于 Go 应用服务器之前。

你可能会问,为什么所展示的 Web 服务器使用 HTTP 而不是安全的 HTTP(HTTPS)。这个问题的答案很简单:大多数 Go Web 服务器都是以 Docker 镜像的形式部署的,并且隐藏在提供安全 HTTP 操作部分的 Web 服务器后面,例如 Caddy 和 Nginx,它们使用适当的认证信息提供安全 HTTP 操作。在不了解如何以及将在哪个域名下部署应用程序的情况下,使用安全 HTTP 协议以及所需的认证信息是没有意义的。

这是在微服务和作为 Docker 镜像部署的常规 Web 应用中的一种常见做法。因此,这是一个在这种情况下常见的做法的设计决策。然而,你的需求可能不同。

net/http包提供了函数和数据类型,允许你开发强大的 Web 服务器和客户端。http.Set()http.Get()方法可以用来发送 HTTP 和 HTTPS 请求,而http.ListenAndServe()用于创建 Web 服务器,给定用户指定的处理函数或处理传入请求的函数。由于大多数 Web 服务需要支持多个端点,你最终需要多个离散的函数来处理传入的请求,这也导致了你服务器的更好设计。

定义受支持的端点以及响应每个客户端请求的处理函数的最简单方法就是使用http.HandleFunc(),它可以被多次调用。

在这个快速且有些理论性的介绍之后,是时候开始讨论更实际的话题了,从实现一个简单的 Web 服务器开始,如wwwServer.go所示:

package main
import (
    "fmt"
"net/http"
"os"
"time"
)
func myHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Serving: %s\n", r.URL.Path)
    fmt.Printf("Served: %s\n", r.Host)
} 

这是一个处理函数,它使用w http.ResponseWriter向客户端发送消息,http.ResponseWriter是一个实现io.Writer接口的接口,用于发送服务器响应。

func timeHandler(w http.ResponseWriter, r *http.Request) {
    t := time.Now().Format(time.RFC1123)
    Body := "The current time is:"
    fmt.Fprintf(w, "<h1 align=\"center\">%s</h1>", Body)
    fmt.Fprintf(w, "<h2 align=\"center\">%s</h2>\n", t)
    fmt.Fprintf(w, "Serving: %s\n", r.URL.Path)
    fmt.Printf("Served time for: %s\n", r.Host)
} 

这是一个名为timeHandler的另一个处理器函数,它以 HTML 格式返回当前时间。所有的fmt.Fprintf()调用都将数据发送回 HTTP 客户端,而fmt.Printf()的输出则打印在 Web 服务器运行的终端上。fmt.Fprintf()的第一个参数是w http.ResponseWriter,它实现了io.Writer接口,因此可以接受用于写入的数据。

func main() {
    PORT := ":8001" 

这是你定义你的 Web 服务器将要监听的端口号的地方。

 arguments := os.Args
    if len(arguments) != 1 {
        PORT = ":" + arguments[1]
    }
    fmt.Println("Using port number: ", PORT) 

如果你使用端口号0,你将得到一个随机选择的可用端口号,这对于测试或当你不想自己指定端口号时非常方便。

如果你不想使用预定义的端口号(8001),那么你应该将你自己的端口号作为命令行参数提供给wwwServer.go

 http.HandleFunc("/time", timeHandler)
    http.HandleFunc("/", myHandler) 

因此,Web 服务器支持/time/这两个 URL。/路径匹配所有其他处理器没有匹配的 URL。我们将myHandler()/关联的事实使得myHandler()成为默认的处理器函数。

 err := http.ListenAndServe(PORT, nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
} 

http.ListenAndServe()调用使用预定义的端口号开始 HTTP 服务器。由于PORT字符串中没有给出主机名,Web 服务器将监听所有可用的网络接口。端口号和主机名应该用冒号(:)分隔,即使没有主机名,也应该有这个冒号——在这种情况下,服务器将监听所有可用的网络接口和所有支持的主机名。这就是为什么PORT的值是:8001而不是仅仅8001

net/http包的一部分是ServeMux结构体(go doc http.ServeMux),它是一个 HTTP 请求多路复用器,它提供了一种与默认方式略有不同的定义处理器函数和端点的方法,默认方式在wwwServer.go中使用。所以如果我们不创建和配置我们自己的ServeMux变量,那么http.HandleFunc()将使用DefaultServeMux,即默认的ServeMux。因此,在这种情况下,我们将使用默认的 Go 路由器来实现 Web 服务——这就是为什么http.ListenAndServe()的第二个参数是nil

运行wwwServer.go并使用curl(1)与之交互会产生以下输出:

$ go run wwwServer.go
Using port number:  :8001
Served: localhost:8001
Served time for: localhost:8001
Served: localhost:8001 

注意,由于wwwServer.go不会自动终止,你需要自己停止它。

curl(1)这一侧,交互看起来如下:

$ curl localhost:8001
Serving: / 

在第一种情况下,我们访问了 Web 服务器的/路径,并由myHandler()提供服务。

$ curl localhost:8001/time
<h1 align="center">The current time is:</h1><h2 align="center">Thu, 31 Aug 2023 22:37:37 EEST</h2>
Serving: /time 

在这种情况下,我们访问了/time,并从timeHandler()得到了 HTML 输出。

$ curl localhost:8001/doesNotExist
Serving: /doesNotExist 

在最后这种情况中,我们访问了不存在的/doesNotExist路径。由于它不能与任何其他路径匹配,因此由默认处理器提供服务,即myHandler()函数。

下一个部分是关于将统计应用程序变成 Web 应用程序!

更新统计应用程序

这次,统计应用程序将作为一个网络服务运行。需要执行的两个主要任务是定义 API 以及端点,并实现 API。还有一个需要确定的任务是关于应用程序服务器与其客户端之间的数据交换。关于服务器与其客户端之间的数据交换存在许多方法。我们将讨论以下四种方法:

  • 使用纯文本

  • 使用 HTML

  • 使用 JSON

  • 使用结合纯文本和 JSON 数据的混合方法

由于在 第十一章使用 REST API 工作中 探讨了 JSON,而 HTML 可能不是服务的最佳选择,因为您需要将数据与 HTML 标签分开并解析数据,我们将使用第一种方法。因此,服务将使用纯文本数据。我们首先定义支持统计应用程序操作的 API。

定义 API

API 支持以下 URL:

  • /list: 这会列出所有可用的条目。

  • /insert/name/d1/d2/d3/.../: 这将插入一个新的数据集。在本章的后面部分,我们将看到如何从包含用户数据和参数的 URL 中提取所需信息。关键点是数据集中元素的数量是可变的,因此 URL 将包含可变数量的值。

  • /delete/name/: 这是在数据集名称的基础上删除条目的操作。

  • /search/name/: 这是在数据集名称的基础上搜索条目的操作。

  • /status: 这是一个额外的 URL,它返回统计应用程序中的条目数量。

端点列表不遵循标准的 REST 规范——所有这些内容都将在 第十一章使用 REST API 工作中 进行介绍。

这次,我们不使用默认的 Go 路由器,这意味着我们定义并配置自己的 http.NewServeMux() 变量。这改变了我们提供处理函数的方式:具有 func(http.ResponseWriter, *http.Request) 签名的处理函数必须转换为 http.HandlerFunc 类型,并由 ServeMux 类型及其 Handle() 方法使用。因此,当使用不同于默认 Go 路由器(DefaultServeMux)的其他 ServeMux 时,我们应该通过调用 http.HandlerFunc() 来显式进行此转换,这使得 http.HandlerFunc 类型充当一个适配器,允许使用具有所需签名的普通函数作为 HTTP 处理器。当使用默认的 Go 路由器时,这不是问题,因为 http.HandleFunc() 函数会自动进行此转换。然而,您也可以使用 ServeMux 类型的 HandleFunc() 方法来进行相同的隐式转换。

为了使事情更清晰,http.HandlerFunc 类型支持一个名为 HandlerFunc() 的方法——类型和方法都在 http 包中定义。同样命名的 http.HandleFunc() 函数(不带 r)与默认的 Go 路由器一起使用。

例如,对于 /time 端点和 timeHandler() 处理函数,你应该调用 mux.Handle()mux.Handle("/time", http.HandlerFunc(timeHandler))。如果你使用 http.HandleFunc() 并且因此使用 DefaultServeMux,那么你应该调用 http.HandleFunc("/time", timeHandler)

下一个子节的主题是 HTTP 端点的实现。

实现处理程序

统计应用的新版本将在 ~/go/src 目录下创建:~/go/src/github.com/mactsouk/mGo4th/ch09/server。正如预期的那样,你还需要执行以下操作:

$ cd ~/go/src/github.com/mactsouk/mGo4th/ch09/server
$ touch handlers.go
$ touch stats.go 

如果你使用本书的 GitHub 仓库,你不需要从头创建服务器,因为 Go 代码已经在那里了。

stats.go 文件包含定义 Web 服务器操作的代码。通常,处理程序被放在一个单独的外部包中,但为了简单起见,我们决定在同一包内创建一个名为 handlers.go 的单独文件来放置处理程序。包含所有与客户端服务相关的功能的 handlers.go 文件内容如下:

package main
import (
    "fmt"
"log"
"net/http"
"strconv"
"strings"
) 

对于 handlers.go 所需的所有包都已导入,即使其中一些已经被 stats.go 导入。请注意,包的名称是 main,这与 stats.go 的情况相同。

const PORT = ":1234" 

这是 HTTP 服务器监听的自定义端口号。

func defaultHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    w.WriteHeader(http.StatusOK)
    body := "Thanks for visiting!\n"
    fmt.Fprintf(w, "%s", body)
} 

这是默认处理程序,它为所有不匹配其他处理程序请求提供服务。接下来是删除条目的处理程序:

func deleteHandler(w http.ResponseWriter, r *http.Request) {
    // Get dataset
    paramStr := strings.Split(r.URL.Path, "/")
    fmt.Println("Path:", paramStr)
    if len(paramStr) < 3 {
        w.WriteHeader(http.StatusNotFound)
        fmt.Fprintln(w, "Not found:", r.URL.Path)
        return
    } 

这是 /delete 路径的处理函数,它首先分割 URL 以读取所需信息。如果我们没有足够的参数,我们应该使用适当的 HTTP 状态码(在这种情况下是 http.StatusNotFound)向客户端发送错误消息。只要它有意义,你可以使用任何你想要的 HTTP 状态码。WriteHeader() 方法在写入响应体之前发送带有提供状态码的头部。

 log.Println("Serving:", r.URL.Path, "from", r.Host) 

这是 HTTP 服务器向日志文件发送数据的地方——这主要发生在调试原因。

 dataset := paramStr[2]
    err := deleteEntry(dataset)
    if err != nil {
        fmt.Println(err)
        Body := err.Error() + "\n"
        w.WriteHeader(http.StatusNotFound)
        fmt.Fprintf(w, "%s", Body)
        return
    } 

由于删除过程基于数据集名称,因此所需的所有内容只是一个有效的数据集名称。这是在分割提供的 URL 后读取参数的地方。如果 deleteEntry() 函数返回错误,那么我们将构建一个合适的响应并发送给客户端。

 body := dataset + " deleted!\n"
    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "%s", body)
} 

到这一点,我们知道删除操作已成功,因此我们也向客户端发送了适当的消息以及 http.StatusOK 状态码。输入 go doc http.StatusOK 查看代码列表。

接下来是 listHandler() 的实现:

func listHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    w.WriteHeader(http.StatusOK)
    body := list()
    fmt.Fprintf(w, "%s", body)
} 

/list 路径中使用的 list() 辅助函数不能失败。因此,在服务 /list 时,总是返回 http.StatusOK。然而,有时 list() 的返回值可以是空字符串。

接下来,我们实现 statusHandler()

func statusHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    w.WriteHeader(http.StatusOK)
    body := fmt.Sprintf("Total entries: %d\n", len(data))
    fmt.Fprintf(w, "%s", body)
} 

之前的代码定义了 /status 的处理函数。它只是返回统计应用中找到的总条目数。它可以用来验证网络服务是否正常工作。

接下来,我们展示 insertHandler() 处理器的实现:

func insertHandler(w http.ResponseWriter, r *http.Request) {
    paramStr := strings.Split(r.URL.Path, "/")
    fmt.Println("Path:", paramStr)
    if len(paramStr) < 4 {
        w.WriteHeader(http.StatusBadRequest)
        fmt.Fprintln(w, "Not enough arguments: "+r.URL.Path)
        return
    } 

和之前一样,我们需要分割给定的 URL 以提取信息。在这种情况下,我们需要至少四个元素,因为我们正在尝试将一个新的数据集插入到统计服务中。

 dataset := paramStr[2]
    // These are string values
    dataStr := paramStr[3:]
    data := make([]float64, 0)
    for _, v := range dataStr {
        val, err := strconv.ParseFloat(v, 64)
        if err == nil {
            data = append(data, val)
        }
    } 

在之前的代码中,我们初始化了 dataset 变量并读取了具有可变长度的数据元素。在这种情况下,我们还需要将数据元素转换为 float64 值,因为它们是以文本形式读取的。

 entry := process(dataset, data)
    err := insert(&entry)
    if err != nil {
        w.WriteHeader(http.StatusNotModified)
        Body := "Failed to add record\n"
        fmt.Fprintf(w, "%s", Body)
    } else {
        Body := "New record added successfully\n"
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, "%s", Body)
    }
    log.Println("Serving:", r.URL.Path, "from", r.Host)
} 

这是 /insert 处理器的结束。insertHandler() 实现的最后一部分处理 insert() 的返回值。如果没有错误,则向客户端发送 http.StatusOK。相反,如果返回 http.StatusNotModified,则表示统计应用中没有变化。检查交互的状态码是客户端的工作,但向客户端发送适当的响应状态码是服务器的工作。

接下来,我们实现 searchHandler()

func searchHandler(w http.ResponseWriter, r *http.Request) {
    // Get Search value from URL
    paramStr := strings.Split(r.URL.Path, "/")
    fmt.Println("Path:", paramStr)
    if len(paramStr) < 3 {
        w.WriteHeader(http.StatusNotFound)
        fmt.Fprintln(w, "Not found: "+r.URL.Path)
        return
    }
    var body string
    dataset := paramStr[2] 

在这一点上,我们从 URL 中提取数据集名称,就像我们在 /delete 中做的那样。

 t := search(dataset)
    if t == nil {
        w.WriteHeader(http.StatusNotFound)
        body = "Could not be found: " + dataset + "\n"
    } else {
        w.WriteHeader(http.StatusOK)
        body = fmt.Sprintf("%s %d %f %f\n", t.Name, t.Len, t.Mean, t.StdDev)
    }
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    fmt.Fprintf(w, "%s", body)
} 

handlers.go 的最后一个函数在这里结束,它关于 /search 端点。search() 辅助函数检查给定的输入是否存在于数据记录中,并相应地执行操作。

此外,main() 函数的实现,可以在 stats.go 中找到,如下所示:

func main() {
    err := readJSONFile(JSONFILE)
    if err != nil && err != io.EOF {
        fmt.Println("Error:", err)
        return
    }
    createIndex() 

main() 的这部分与统计应用的正确初始化有关。内部,数据以 JSON 格式存储。

 mux := http.NewServeMux()
    s := &http.Server{
        Addr:         PORT,
        Handler:      mux,
        IdleTimeout:  10 * time.Second,
        ReadTimeout:  time.Second,
        WriteTimeout: time.Second,
    } 

在这里,我们将 HTTP 服务器的参数存储在 http.Server 结构中,并使用我们自己的 http.NewServeMux() 而不是默认的。

 mux.Handle("/list", http.HandlerFunc(listHandler))
    mux.Handle("/insert/", http.HandlerFunc(insertHandler))
    mux.Handle("/insert", http.HandlerFunc(insertHandler))
    mux.Handle("/search", http.HandlerFunc(searchHandler))
    mux.Handle("/search/", http.HandlerFunc(searchHandler))
    mux.Handle("/delete/", http.HandlerFunc(deleteHandler))
    mux.Handle("/status", http.HandlerFunc(statusHandler))
    mux.Handle("/", http.HandlerFunc(defaultHandler)) 

这是支持的 URL 列表。请注意,尽管 /search 将会失败,因为它没有包含所需的数据,但 /search/search/ 都由同一个处理函数处理。另一方面,/delete/ 的处理方式不同——这将在测试应用时变得明显。由于我们使用 http.NewServeMux() 而不是默认的 Go 路由器,因此在定义处理函数时需要使用 http.HandlerFunc()

 fmt.Println("Ready to serve at", PORT)
    err = s.ListenAndServe()
    if err != nil {
        fmt.Println(err)
        return
    }
} 

如本章前面所述,每个 mux.Handle() 调用都可以替换为等效的 mux.HandleFunc() 调用。因此,mux.Handle("/list", http.HandlerFunc(listHandler)) 将变为 mux.HandleFunc("/list", listHandler)。这同样适用于所有其他的 mux.Handle() 调用。

ListenAndServe()方法使用在http.Server结构中先前定义的参数启动 HTTP 服务器。stats.go的其余部分包含与网络服务操作相关的辅助函数。请注意,尽可能频繁地保存和更新应用程序的内容非常重要,因为这是一个实时应用程序,如果它崩溃,你可能会丢失数据。

下一个命令允许你执行应用程序——你需要在go run中提供两个文件:

$ go run stats.go handlers.go
Ready to serve at :1234
2023/08/31 17:10:10 Serving: /list from localhost:1234
Path: [ delete d1]
2023/08/31 17:10:20 Serving: /delete/d1 from localhost:1234
Path: [ delete d2]
2023/08/31 17:10:22 Serving: /delete/d2 from localhost:1234
Path: [ delete d1]
2023/08/31 17:10:23 Serving: /delete/d1 from localhost:1234
d1 cannot be found!
2023/08/31 17:11:01 Serving: /status from localhost:1234
Path: [ search d3]
2023/08/31 17:11:26 Serving: /search/d3 from localhost:1234
Path: [ search d2]
2023/08/31 17:11:29 Serving: /search/d2 from localhost:1234
Path: [ search d5]
2023/08/31 17:11:30 Serving: /search/d5 from localhost:1234
Path: [ search d4]
2023/08/31 17:11:32 Serving: /search/d4 from localhost:1234
Path: [ insert v1 1.0 2 3 4 5 ]
2023/08/31 17:16:23 Serving: /insert/v1/1.0/2/3/4/5/ from localhost:1234
Path: [ insert v1 1.0 2 3 4 5 ]
2023/08/31 17:16:34 Serving: /insert/v1/1.0/2/3/4/5/ from localhost:1234
Path: [ insert v2 1.0 2 3 4 5 -5 -3 ]
2023/08/31 17:17:21 Serving: /insert/v2/1.0/2/3/4/5/-5/-3/ from localhost:1234 

在客户端,即curl(1),我们有以下交互:

$ curl localhost:1234/list
d6    4    2.325000    1.080220
d4    5    2.860000    1.441666
d1    6    2.216667    1.949715
d2    9    1.000000    0.000000
d0    12    0.333333    0.942809 

这里,我们通过访问/list获取统计应用程序的所有条目:

$ curl localhost:1234/delete/d1
d1 deleted! 

如果d1存在于现有数据集列表中,之前的命令将会工作。如果你的列表为空或d1不存在,你应该在删除它之前将其包含在内。

$ curl localhost:1234/delete/d2
d2 deleted!
$ curl localhost:1234/delete/d1
d1 cannot be found! 

在上一部分,我们尝试删除了d1d2数据集。再次尝试删除d1会失败。

$ curl localhost:1234/status
Total entries: 3 

接下来,我们访问/status并得到预期的输出:

$ curl localhost:1234/search/d3
Could not be found: d3
$ curl localhost:1234/search/d4
d4 5 2.860000 1.44166 

首先,我们搜索不存在的d3,然后搜索存在的d4。在后一种情况下,网络服务返回d4的数据。现在,让我们尝试访问/delete而不是/delete/

$ curl localhost:1234/delete
<a href="/delete/">Moved Permanently</a>. 

所展示的消息是由 Go 路由器生成的,并告诉我们应该尝试/delete/而不是/delete,因为/delete已被永久移动。这是我们没有在路由中明确定义/delete/delete/时可能会得到的消息类型。

现在,让我们插入两个数据集:

$ curl localhost:1234/insert/v1/1.0/2/3/4/5/
New record added successfully
$ curl localhost:1234/insert/v2/1.0/2/3/4/5/-5/-3/
New record added successfully 

如果v1v2都不存在,之前的命令将会工作。如果我们尝试插入一个已存在的数据集,除了304 – Not Modified之外,我们不会收到任何响应。

所有的东西看起来都在正常工作。现在我们可以将统计网络服务上线,并通过多个 HTTP 请求与之交互,因为http包使用多个 goroutine 与客户端交互——在实践中,这意味着统计应用程序是并发运行的!然而,在其当前版本中,没有保护措施来防止数据竞争,如果我们尝试在完全相同的时间插入相同的数据集多次,可能会发生数据竞争。

在本章的后面部分,我们将为统计服务器创建一个命令行客户端。此外,第十二章代码测试和性能分析展示了如何测试你的代码。

下一个部分将展示如何为服务器应用程序构建 Docker 镜像。

创建 Docker 镜像

本节展示了如何将 Go 应用程序转换为 Docker 镜像——我们将使用的是一种与外部世界交互的 HTTP 服务器。在我们的案例中,它将是刚刚开发的统计网络服务。

buildDocker的内容,其中包含创建 Docker 镜像的步骤,如下所示:

FROM golang:alpine AS builder
# Install git.
# Git is required for fetching the dependencies.
RUN apk update && apk add --no-cache git
RUN mkdir $GOPATH/src/server
ADD ./stats.go $GOPATH/src/server
ADD ./handlers.go $GOPATH/src/server
WORKDIR $GOPATH/src/server
RUN go mod init
RUN go mod tidy
RUN mkdir /pro
RUN go build -o /pro/server stats.go handlers.go
FROM alpine:latest
RUN mkdir /pro
COPY --from=builder /pro/server /pro/server
EXPOSE 1234
WORKDIR /pro
CMD ["/pro/server"] 

之后,我们可以使用buildDocker文件来构建一个名为goapp的 Docker 镜像,如下所示:

$ docker build -f buildDocker -t goapp .
. . .
Successfully built 56d0b84b0ab5
Successfully tagged goapp:latest 

docker-compose.yml的内容,它允许我们使用 Docker 镜像,如下所示:

version: "3"
services:
goapp:
**image:****goapp**
**container_name:****goapp**
restart: always
ports:
- 1234:1234
networks:
- services
networks:
services:
driver: bridge 

docker-compose.yml文件中重要的是使用在上一步骤中创建的goapp镜像名称。

拥有docker-compose.yml,我们可以这样使用它:

$ docker-compose up
[+] Running 2/0
  Network server_services  Created  0.0s
  Container goapp          Created  0.0s
Attaching to goapp
goapp  | Ready to serve at :1234
goapp  | 2023/08/31 13:32:54 Serving: /status from think:1234 

之后,我们可以自由地使用curl(1)或其他类似工具与网络服务进行交互。完成后,我们可以使用Ctrl + C来停止 Docker 镜像的运行。

这个特定网络服务的主要缺点是,一旦你禁用 Docker 镜像,所有数据都会丢失——这个问题的解决方案很简单。你可以将数据存储在外部数据库中,或者将内部 Docker 数据文件链接到本地文件系统中的文件。实现这两种解决方案超出了本章的范围。

在了解 HTTP 服务器之后,下一节将展示如何开发 HTTP 客户端。

开发网络客户端

本节展示了如何开发 HTTP 客户端,从简单版本开始,然后继续到更高级的版本。在这个简单版本中,所有工作都是由http.Get()调用完成的,当你不希望处理大量选项和参数时,这非常方便。然而,这种类型的调用给你在过程中的灵活性很小。请注意,http.Get()返回一个http.Response值。所有这些都在simpleClient.go中得到了说明:

package main
import (
    "fmt"
"io"
"net/http"
"os"
"path/filepath"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Printf("Usage: %s URL\n", filepath.Base(os.Args[0]))
        return
    } 

filepath.Base()函数返回路径的最后一个元素。当以os.Args[0]作为其参数时,它返回可执行二进制文件名。

 URL := os.Args[1]
    data, err := http.Get(URL) 

在前两个语句中,我们使用http.Get()获取 URL 及其数据,它返回一个*http.Response和一个error变量。*http.Response值包含所有信息,因此你不需要对http.Get()进行任何额外的调用。

 if err != nil {
        fmt.Println(err)
        return
    }
    _, err = io.Copy(os.Stdout, data.Body) 

io.Copy()函数从data.Body读取器中读取,它包含服务器响应的主体,并将数据写入os.Stdout。由于os.Stdout始终是打开的,因此你不需要为写入而打开它。因此,所有数据都写入标准输出,这通常是终端窗口:

 if err != nil {
        fmt.Println(err)
        return
    }
    data.Body.Close()
} 

最后,我们关闭data.Body读取器,以便使垃圾收集工作更容易。

使用simpleClient.go生成以下类型的输出,在这种情况下是缩略的:

$ go run simpleClient.go https://www.golang.org
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<link rel="preconnect" href="https://www.googletagmanager.com">
...
</body>
</html> 

虽然simpleClient.go负责验证给定的 URL 是否存在且可访问,但它对过程没有控制权。下一小节将介绍一个高级 HTTP 客户端,该客户端处理服务器响应。

使用 http.NewRequest()改进客户端

上一节的网络客户端相对简单,并且没有提供任何灵活性,在本小节中,你将学习如何在不使用http.Get()函数的情况下读取 URL,并且拥有更多选项。然而,额外的灵活性是有代价的,因为你必须编写更多的代码。

wwwClient.go的代码如下:

package main
import (
    "fmt"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Printf("Usage: %s URL\n", filepath.Base(os.Args[0]))
        return
    } 

虽然使用filepath.Base()不是必需的,但它可以使你的输出更加专业。

 URL, err := url.Parse(os.Args[1])
    if err != nil {
        fmt.Println("Error in parsing:", err)
        return
    } 

url.Parse() 函数将字符串解析为 URL 结构。这意味着如果给定的参数不是一个有效的 URL,url.Parse() 会注意到。像往常一样,我们需要检查 error 变量。

 c := &http.Client{
        Timeout: 15 * time.Second,
    }
    request, err := http.NewRequest(http.MethodGet, URL.String(), nil)
    if err != nil {
        fmt.Println("Get:", err)
        return
    } 

http.NewRequest() 函数在提供方法、URL 和可选主体时返回一个 http.Request 对象。http.MethodGet 参数定义了我们想使用 GET HTTP 方法检索数据,而 URL.String() 返回 http.URL 变量的 string 值。

 httpData, err := c.Do(request)
    if err != nil {
        fmt.Println("Error in Do():", err)
        return
    } 

http.Do() 函数使用 http.Client 发送 HTTP 请求(http.Request)并返回一个 http.Response。因此,http.Do() 以更详细的方式完成了 http.Get() 的工作:

 fmt.Println("Status code:", httpData.Status) 

httpData.Status 存储响应的 HTTP 状态码——这很重要,因为它允许你了解请求实际上发生了什么。

 header, _ := httputil.DumpResponse(httpData, false)
    fmt.Print(string(header)) 

httputil.DumpResponse() 函数在此处用于获取服务器的响应,主要用于调试目的。httputil.DumpResponse() 的第二个参数是一个布尔值,用于指定函数是否在输出中包含主体——在我们的例子中,它被设置为 false,这意味着响应主体不会被包含在输出中,只会打印头部。如果你想在服务器端做同样的事情,你应该使用 httputil.DumpRequest()

 contentType := httpData.Header.Get("Content-Type")
    characterSet := strings.SplitAfter(contentType, "charset=")
    if len(characterSet) > 1 {
        fmt.Println("Character Set:", characterSet[1])
    } 

在这里,我们通过搜索 Content-Type 的值来了解响应的字符集:

 if httpData.ContentLength == -1 {
        fmt.Println("ContentLength is unknown!")
    } else {
        fmt.Println("ContentLength:", httpData.ContentLength)
    } 

接下来,我们尝试通过读取 httpData.ContentLength 来获取响应的内容长度。然而,如果该值未设置,我们会打印一条相关的消息:

 length := 0
var buffer [1024]byte
    r := httpData.Body
    for {
        n, err := r.Read(buffer[0:])
        if err != nil {
            fmt.Println(err)
                break
        }
        length = length + n
    }
    fmt.Println("Calculated response data length:", length)
} 

在程序的最后一部分,我们使用一种技术来自行发现服务器 HTTP 响应的大小。如果我们想在屏幕上显示 HTML 输出,我们可以打印 r 缓冲区变量的内容。

使用 wwwClient.go 并访问 www.golang.org 产生以下输出:

$ go run wwwClient.go https://www.golang.org
Status code: 200 OK 

上面的输出是 fmt.Println("Status code:", httpData.Status) 的输出。

接下来,我们看到 fmt.Print(string(header)) 语句的输出,其中包含 HTTP 服务器响应的头部数据:

HTTP/2.0 200 OK
Cache-Control: private
Content-Security-Policy: connect-src 'self' www.google-analytics.com stats.g.doubleclick.net ; default-src 'self' ; font-src 'self' fonts.googleapis.com fonts.gstatic.com data: ; frame-ancestors 'self' ; frame-src 'self' www.google.com feedback.googleusercontent.com www.googletagmanager.com scone-pa.clients6.google.com www.youtube.com player.vimeo.com ; img-src 'self' www.google.com www.google-analytics.com ssl.gstatic.com www.gstatic.com gstatic.com data: * ; object-src 'none' ; script-src 'self' 'sha256-n6OdwTrm52KqKm6aHYgD0TFUdMgww4a0GQlIAVrMzck=' 'sha256-4ryYrf7Y5daLOBv0CpYtyBIcJPZkRD2eBPdfqsN3r1M=' 'sha256-sVKX08+SqOmnWhiySYk3xC7RDUgKyAkmbXV2GWts4fo=' www.google.com apis.google.com www.gstatic.com gstatic.com support.google.com www.googletagmanager.com www.google-analytics.com ssl.google-analytics.com tagmanager.google.com ; style-src 'self' 'unsafe-inline' fonts.googleapis.com feedback.googleusercontent.com www.gstatic.com gstatic.com tagmanager.google.com ;
Content-Type: text/html; charset=utf-8
Date: Fri, 01 Sep 2023 19:12:13 GMT
Server: Google Frontend
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Vary: Accept-Encoding
X-Cloud-Trace-Context: 63a0ba25023e0ff4d5b5ccb87ef286bc 

输出的最后一部分是关于交互的字符集(utf-8)和响应的内容长度(61870),这是通过以下代码计算得出的:

Character Set: utf-8
ContentLength is unknown!
EOF
Calculated response data length: 61870 

现在我们来看一个同时获取多个地址的技术。

使用 errGroup

在本节中,我们将使用 errGroup 包来通过 golang.org/x/sync/errgroup 外部包并发地获取多个 URL,因此 eGroup.go 位于 ~/go/src/github.com/mactsouk/mGo4th/ch09/eGroup

eGroup.go 的代码分为两部分。第一部分如下:

package main
import (
    "fmt"
"net/http"
"os"
"golang.org/x/sync/errgroup"
)
func main() {
    if len(os.Args) == 1 {
        fmt.Println("Not enough arguments!")
        return
    }
    g := new(errgroup.Group) 

我们使用 errgroup.Group 变量,它是一组工作在相同更大任务不同部分的 goroutines。一般来说,errgroup 包为工作在相同任务子任务的 goroutines 提供同步、错误传播和 Context 取消。

eGroup.go 的第二部分包含以下代码:

 for _, url := range os.Args[1:] {
        url := url
        g.Go(func() error {
            resp, err := http.Get(url)
            if err != nil {
                return err
            }
            defer resp.Body.Close()
            fmt.Println(url, "is OK.")
            return nil
        })
    }
    err := g.Wait()
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Everything went fine!")
} 

在这部分,我们使用 g.Go() 以 goroutine 的形式调用所需的函数。此外,我们使用闭包变量为 url 变量,以确保每个 goroutine 正确处理所需的 URL。

如预期的那样,我们需要先运行以下两个命令:

$ go mod init
$ go mod tidy 

在我的 macOS 机器上运行 eGroup.go 生成以下输出:

$ go run eGroup.go https://golang.org https://www.mtsoukalos.eu/
https://www.mtsoukalos.eu/ is OK.
https://golang.org is OK.
Everything went fine! 

在我的 Arch Linux 机器上运行相同的命令会产生不同的输出:

$ go run eGroup.go https://golang.org https://www.mtsoukalos.eu/
https://golang.org is OK.
Error: Get "https://www.mtsoukalos.eu/": tls: failed to verify certificate: x509: certificate signed by unknown authority 

下一个部分将展示如何为我们之前开发的统计网络服务创建一个命令行客户端。

创建统计服务的客户端

在这个子节中,我们创建一个与本章早期开发的统计网络服务交互的命令行实用程序。这个统计客户端版本将使用 cobra 包创建,并且正如预期的那样,它将位于 ~/go/src~/go/src/github.com/mactsouk/mGo4th/ch09/client。上一个目录包含客户端的最终版本。创建客户端的初始步骤如下:

$ cd ~/go/src/github.com/mactsouk/mGo4th/ch09/client
$ go mod init
$ ~/go/bin/cobra init
$ ~/go/bin/cobra add search
$ ~/go/bin/cobra add insert
$ ~/go/bin/cobra add delete
$ ~/go/bin/cobra add status
$ ~/go/bin/cobra add list 

因此,我们有一个包含五个命令的命令行实用程序,分别命名为 searchinsertdeletestatuslist。之后,我们需要实现这些命令并定义它们的本地参数,以便与统计服务器交互。

现在,让我们看看命令的实现,从 root.go 文件的 init() 函数实现开始,因为这是定义全局命令行参数的地方:

func init() {
    rootCmd.PersistentFlags().StringP("server", "S", "localhost", "Server")
    rootCmd.PersistentFlags().StringP("port", "P", "1234", "Port number")
    viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server"))
    viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
} 

因此,我们定义了两个全局参数,分别命名为 serverport,它们分别是服务器的主机名和端口号。这两个参数都有一个别名,并由 viper 处理。

现在,让我们检查 status 命令在 status.go 中的实现:

SERVER := viper.GetString("server")
PORT := viper.GetString("port") 

所有命令都会读取 serverport 命令行参数的值以获取有关服务器的信息,status 命令也不例外:

// Create request
URL := "http://" + SERVER + ":" + PORT + "/status" 

之后,我们构建请求的完整 URL。

data, err := http.Get(URL)
if err != nil {
    fmt.Println(err)
    return
} 

然后,我们使用 http.Get() 向服务器发送一个 GET 请求。

// Check HTTP Status Code
if data.StatusCode != http.StatusOK {
    fmt.Println("Status code:", data.StatusCode)
    return
} 

之后,我们检查请求的 HTTP 状态码以确保一切正常。

// Read data
responseData, err := io.ReadAll(data.Body)
if err != nil {
    fmt.Println(err)
    return
}
fmt.Print(string(responseData)) 

如果一切正常,我们读取服务器响应的全部内容,它是一个字节切片,并将其作为字符串打印到屏幕上。list 的实现几乎与 status 的实现相同。唯一的区别是,实现位于 list.go 中,并且完整的 URL 构建如下:

URL := "http://" + SERVER + ":" + PORT + "/list" 

之后,让我们看看 delete 命令在 delete.go 中的实现:

 SERVER := viper.GetString("server")
        PORT := viper.GetString("port")
        dataset, _ := cmd.Flags().GetString("dataset")
        if dataset == "" {
            fmt.Println("Number is empty!")
            return
        } 

除了读取serverport全局参数的值之外,我们还读取dataset参数的值。如果dataset没有值,则命令将返回此信息。

 URL := "http://" + SERVER + ":" + PORT + "/delete/" + dataset 

再次强调,我们在连接到服务器之前构建了请求的完整 URL。

 data, err := http.Get(URL)
        if err != nil {
            fmt.Println(err)
            return
        } 

之前的代码将客户端请求发送到服务器。

 if data.StatusCode != http.StatusOK {
            fmt.Println("Status code:", data.StatusCode)
            return
        } 

如果服务器响应中存在错误,delete命令将打印 HTTP 错误并终止。

 responseData, err := io.ReadAll(data.Body)
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Print(string(responseData)) 

如果一切正常,服务器响应文本将打印在屏幕上。

delete.go中的init()函数包含了定义本地dataset命令行参数以获取要删除的数据集名称的定义。

接下来,让我们更深入地了解search命令及其在search.go中的实现方式。实现方式与delete相同,除了完整的请求 URL:

URL := "http://" + SERVER + ":" + PORT + "/search/" + dataset 

search命令也支持dataset命令行参数,用于获取要搜索的数据集名称——这是在search.goinit()函数中定义的。

最后一个展示的命令是insert命令,它支持两个在insert.go中的init()函数中定义的本地命令行参数:

 insertCmd.Flags().StringP("dataset", "d", "", "Dataset name")
    insertCmd.Flags().StringP("values", "v", "", "List of values") 

这两个参数是获取所需用户输入所必需的。然而,values参数的值预期是一个以逗号分隔的浮点数值列表——这是我们定义如何获取数据集所有元素的方式。

insert命令是通过以下代码实现的:

 SERVER := viper.GetString("server")
    PORT := viper.GetString("port") 

首先,我们读取serverport全局参数。

 dataset, _ := cmd.Flags().GetString("dataset")
        if dataset == "" {
            fmt.Println("Dataset is empty!")
            return
        }
        values, _ := cmd.Flags().GetString("values")
        if values == "" {
            fmt.Println("No data!")
            return
        } 

然后,我们获取两个本地命令行参数的值。如果其中任何一个参数值为空,则命令将返回而不向服务器发送请求。

 VALS := strings.Split(values, ",")
        vSend := ""
for _, v := range VALS {
            _, err := strconv.ParseFloat(v, 64)
            if err == nil {
                vSend = vSend + "/" + v
            }
        } 

之前的代码非常重要,因为它检查给定数据集元素是否为有效的float64值,然后创建一个形如/value1/value2/.../valueN/的字符串。这个字符串值附加到包含服务器请求的 URL 的末尾。

 URL := "http://" + SERVER + ":" + PORT + "/insert/"
        URL = URL + "/" + dataset + "/" + vSend + "/" 

在这里,我们为了可读性,分两步创建服务器请求。

data, err := http.Get(URL)
if err != nil {
    fmt.Println("**", err)
    return
} 

然后,我们将请求发送到服务器。

if data.StatusCode != http.StatusOK {
    fmt.Println("Status code:", data.StatusCode)
    return
} 

检查 HTTP 状态码总是一个好的做法。因此,如果服务器响应一切正常,我们继续读取数据。否则,我们打印状态码,并退出。

responseData, err := io.ReadAll(data.Body)
if err != nil {
    fmt.Println("*", err)
    return
}
fmt.Print(string(responseData)) 

在读取存储在字节切片中的服务器响应体之后,我们使用string(responseData)将其作为字符串打印在屏幕上。

客户端应用程序生成以下类型的输出:

$ go run main.go list
List of entries:
d6    4    2.325000    1.080220
d4    5    2.860000    1.441666
d2    9    1.000000    0.000000
v1    5    3.000000    1.414214
v2    7    1.000000    3.422614 

这是list命令的输出。

$ go run main.go status
Total entries: 5 

status命令的输出告诉我们应用程序中的条目数量。

$ go run main.go search -d v1
v1 5 3.000000 1.414214 

之前的输出显示了在成功找到数据集时使用search命令的情况。

$ go run main.go search -d notThere
Status code: 404 

之前的输出显示了在找不到数据集时使用search命令的情况。

$ go run main.go delete -d v1
v1 deleted! 

这是delete命令的输出。

$ go run main.go insert -d n1 -v 1,2,3,-4,0,0
New record added successfully 

这是insert命令的操作。如果你尝试多次插入相同的数据集名称,服务器输出将是状态码:304

下一个部分解释了如何超时 HTTP 连接。

超时 HTTP 连接

本节介绍了处理耗时过长的 HTTP 连接超时的技术,这些技术可以在服务器端或客户端工作。

使用 SetDeadline()

SetDeadline()函数由net用于设置网络连接的读写截止时间。由于SetDeadline()的工作方式,你需要在任何读写操作之前调用SetDeadline()。请注意,Go 使用截止时间来实现超时,因此你不需要在每次应用程序接收或发送数据时重置超时。

SetDeadline()的使用在withDeadline.go中得到了说明,特别是在Timeout()函数的实现中:

var timeout = time.Duration(time.Second)
func Timeout(network, host string) (net.Conn, error) {
    conn, err := net.DialTimeout(network, host, timeout)
    if err != nil {
        return nil, err
    }
    conn.SetDeadline(time.Now().Add(timeout))
    return conn, nil
} 

timeout全局变量定义了在SetDeadline()调用中使用的超时时间。前面的函数在main()中的以下代码中使用:

t := http.Transport{
    Dial: Timeout,
}
client := http.Client{
        Transport: &t,
} 

因此,http.TransportDial字段中使用Timeout(),而http.Client使用http.Transport。当你调用client.Get()方法并传入所需的 URL(此处未显示)时,由于http.Transport的定义,Timeout会被自动使用。所以,如果Timeout函数在收到服务器响应之前返回,我们就遇到了超时。

使用withdeadline.go会产生以下类型的输出:

$ go run withDeadline.go http://www.golang.org
Timeout value: 1s
<!DOCTYPE html>
... 

调用成功,并在不到 1 秒内完成,所以没有超时。

$ go run withDeadline.go http://localhost:80
Timeout value: 1s
Get "http://localhost:80": read tcp 127.0.0.1:52492->127.0.0.1:80: i/o timeout 

这次我们遇到了超时,因为服务器响应时间过长。

接下来,我们展示如何使用context包超时一个连接。

在客户端设置超时时间

本节介绍了一种在客户端超时耗时过长的网络连接的技术。因此,如果客户端在期望的时间内没有从服务器收到响应,它将关闭连接。timeoutClient.go源文件展示了这一技术。

package main
import (
    "context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
var delay int = 5 

在前面的代码中,我们定义了一个名为delay的全局变量,用于存储延迟值。

func main() {
    if len(os.Args) == 1 {
        fmt.Println("Need a URL and a delay!")
        os.Exit(1)
    }
    url := os.Args[1]
    if len(os.Args) == 3 {
      t, err := strconv.Atoi(os.Args[2])
      if err != nil {
         fmt.Println(err)
         return
      }
      delay = t
   }
    fmt.Println("Delay:", delay) 

由于 URL 已经是字符串值,因此直接读取,而延迟期则使用strconv.Atoi()转换为数值。

main()函数的其余实现如下:

 ctx, cncl := context.WithTimeout(context.Background(), time.Second * time.Duration(delay))
    defer cncl()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    res, err := http.DefaultClient.Do(req.WithContext(ctx))
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()
    body, err := io.ReadAll(res.Body)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(body))
} 

首先,我们初始化ctx上下文,然后使用http.NewRequestWithContext()将此上下文与 HTTP 请求关联。如果超时时间超过,使用WithTimeout()创建的context.Context将会过期。

timeoutClient.go一起工作并产生超时情况时,会生成以下类型的输出:

$ go run timeoutClient.go http://localhost:1234 5
Delay: 5
Get "http://localhost:1234": context deadline exceeded 

下一个子部分展示了如何在服务器端超时一个 HTTP 请求。

在服务器端设置超时时间

本节介绍了一种超时网络连接的技术,这些连接在服务器端完成时间过长。这比客户端更重要,因为拥有太多打开连接的服务器可能无法处理额外的请求,除非一些已经打开的连接关闭。这通常有两个原因。第一个原因是软件错误,第二个原因是当服务器遭受 拒绝服务DoS)攻击时!

timeoutServer.go 中的 main() 函数展示了这项技术:

func main() {
    PORT := ":8001"
    arguments := os.Args
    if len(arguments) != 1 {
        PORT = ":" + arguments[1]
    }
    fmt.Println("Using port number: ", PORT)
    m := http.NewServeMux()
    srv := &http.Server{
        Addr:         PORT,
        Handler:      m,
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
    } 

这就是定义超时时间的地方。请注意,您可以定义读取和写入过程的超时时间。ReadTimeout 字段的值指定了读取整个客户端请求(包括主体)允许的最大持续时间,而 WriteTimeout 字段的值指定了在超时发送客户端响应之前允许的最大时间。

 m.HandleFunc("/time", timeHandler)
    m.HandleFunc("/", myHandler)
    err := srv.ListenAndServe()
    if err != nil {
        fmt.Println(err)
        return
    }
} 

除了 http.Server 定义中的参数外,其余代码与往常一样:它包含处理函数并调用 ListenAndServe() 来启动 HTTP 服务器。

使用 timeoutServer.go 不会生成任何输出。然而,如果客户端连接到它而没有发送任何请求,客户端连接将在 3 秒后结束。如果客户端接收服务器响应的时间超过 3 秒,也会发生同样的事情。

摘要

在本章中,我们学习了如何使用 HTTP,如何从 Go 代码创建 Docker 镜像,以及如何开发 HTTP 客户端和服务器。我们还把统计应用程序转换成了 Web 应用程序,并为它编写了一个命令行客户端。此外,我们还学习了如何超时 HTTP 连接。

我们现在已准备好开始开发强大且并发的 HTTP 应用程序——然而,我们还没有完成 HTTP 的学习。第十一章与 REST API 一起工作,将连接这些点,并展示如何开发强大的 RESTful 服务器和客户端。

但首先,我们需要了解如何使用 TCP/IP、TCP、UDP 和 WebSocket,这些是下一章的主题。

练习

  • 修改 wwwClient.go 以将 HTML 输出保存到外部文件。

  • 在统计应用程序中使用 sync.Mutex 以避免竞争条件。

  • 使用 goroutines 和 channels 实现 ab(1) 的简单版本。ab(1) 是 Apache HTTP 服务器基准测试工具。

其他资源

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第十章:与 TCP/IP 和 WebSocket 一起工作

TCP/IP 是互联网的基础,因此,在开发网络服务时能够创建 TCP/IP 服务器和客户端是至关重要的。本章教你如何使用 net 包来处理 TCP/IP 的底层协议,即 TCP 和 UDP,这样你就可以开发 TCP/IP 服务器和客户端,并对其功能有更多控制。本章包含的 TCP 和 UDP 工具的 Go 代码使我们能够创建自己的高级 TCP/IP 服务,因为 TCP/IP 的核心原则和逻辑保持不变。

此外,本章还介绍了 WebSocket 协议的服务器和客户端的开发,该协议基于 HTTP,并展示了如何与 RabbitMQ 交互,RabbitMQ 是一个开源的 消息代理

WebSocket 协议在单个 TCP 连接上提供全双工通信通道。另一方面,像 RabbitMQ 和 Apache Kafka 这样的消息代理因其速度而闻名,这也是它们被包含在处理大量数据的工作流程中的主要原因。

更详细地说,本章涵盖了:

  • TCP/IP

  • net

  • 开发 TCP 客户端

  • 开发 TCP 服务器

  • 开发 UDP 客户端

  • 开发 UDP 服务器

  • 开发并发 TCP 服务器

  • 创建 WebSocket 服务器

  • 创建 WebSocket 客户端

  • 与 RabbitMQ 一起工作

TCP/IP

TCP/IP 是一组帮助互联网运行的协议。它的名字来源于其最著名的两个协议:TCP 和 IP。

TCP 代表传输控制协议。TCP 软件使用段(也称为 TCP 数据包)在机器之间传输数据。TCP 的主要特点是它是一个 可靠的协议,这意味着它确保每个数据包都成功交付,而不需要程序员编写任何额外的代码。如果没有数据包交付的证明,TCP 会重新发送该数据包。除此之外,TCP 数据包可以用来建立连接、传输数据、发送确认和关闭连接。

当两台机器之间建立 TCP 连接时,会在这两台机器之间创建一个全双工虚拟电路,类似于电话通话。这两台机器持续通信以确保数据正确发送和接收。如果连接因某种原因失败,两台机器会尝试找出问题并向相关应用程序报告。每个数据包的 TCP 报头包括源端口和目的端口字段。这两个字段,加上源和目的 IP 地址,组合起来可以唯一地标识每个 TCP 连接。所有这些细节都由 TCP/IP 处理,只要你提供所需细节,无需额外努力。

在创建 TCP/IP 服务器进程时,请记住端口号 0-1024 有受限访问权限,只能由 root 用户使用,这意味着您需要管理员权限才能使用该范围内的任何端口。以 root 权限运行进程是一个安全风险,必须避免。

IP 代表互联网协议。IP 的主要特征是它本质上不是一个可靠的协议。IP 封装了在 TCP/IP 网络上传输的数据,因为它负责根据 IP 地址将数据包从源主机传输到目标主机。IP 必须找到一种寻址方法,以便有效地将数据包发送到其目的地。尽管有专门的设备,称为路由器,执行 IP 路由,但每个 TCP/IP 设备都必须执行一些基本路由。

IP 协议的第一个版本现在被称为 IPv4,以区分最新的 IP 协议版本,称为 IPv6。IPv4 的主要问题是它即将耗尽可用的 IP 地址,这是创建 IPv6 协议的主要原因。这是因为 IPv4 地址仅使用 32 位表示,允许有 2³²(4,294,967,296)个不同的 IP 地址。另一方面,IPv6 使用 128 位来定义其地址中的每一个。IPv4 地址的格式是10.20.32.245(由点分隔的四个部分,值从 0 到 255),而 IPv6 地址的格式是3fce:1706:4523:3:150:f8ff:fe21:56cf(由冒号分隔的八个部分)。

UDP(用户数据报协议)基于 IP,这意味着它也是不可靠的。UDP 比 TCP 简单,主要是因为 UDP 设计上不可靠。因此,UDP 消息可能会丢失、重复或顺序错误地到达。此外,数据包可能会比接收者处理它们的速度更快地到达。因此,当速度比可靠性更重要时,使用 UDP。

本章实现了 TCP 和 UDP 软件——TCP 和 UDP 服务是互联网的基础。但首先,让我们谈谈方便的nc(1)实用程序。

nc(1) 命令行实用程序

当您想要测试 TCP/IP 服务器和客户端时,nc(1)实用程序,也称为netcat(1),非常方便:nc(1)是一个涉及 TCP 和 UDP 以及 IPv4 和 IPv6 的实用程序,包括但不限于打开 TCP 连接、发送和接收 UDP 消息以及充当 TCP 服务器。

您可以使用nc(1)作为运行在具有10.10.1.123 IP 地址并监听端口号1234的机器上的 TCP 服务的客户端,如下所示:

$ nc 10.10.1.123 1234 

-l 选项告诉 netcat(1) 作为服务器运行,这意味着当提供 -l 选项时,netcat(1) 将在指定的端口号上监听传入的连接。默认情况下,nc(1) 使用 TCP 协议。但是,如果您使用 -u 标志执行 nc(1),它将使用 UDP 协议,无论是作为客户端还是服务器。最后,-v-vv 选项告诉 netcat(1) 生成详细输出,这在您想要调试网络连接时可能很有用。

net

Go 标准库中的 net 包主要涉及 TCP/IP、UDP、域名解析和 UNIX 域套接字。net.Dial() 函数用于作为客户端连接到网络,而 net.Listen() 函数用于指示 Go 程序接受传入的网络连接,从而充当服务器。

net.Dial()net.Listen() 的返回值都是 net.Conn 数据类型,它实现了 io.Readerio.Writer 接口——这意味着您可以使用与文件 I/O 相关的代码来读取和写入 net.Conn 连接。net.Dial()net.Listen() 的第一个参数是网络类型,但这是它们相似之处结束的地方。

net.Dial() 函数用于连接到远程服务器。net.Dial() 函数的第一个参数定义了将要使用的网络协议,而第二个参数定义了服务器地址,其中必须包括端口号。第一个参数的有效值包括 tcptcp4(仅 IPv4)、tcp6(仅 IPv6)、udpudp4(仅 IPv4)、udp6(仅 IPv6)、ipip4(仅 IPv4)、ip6(仅 IPv6)、unix(UNIX 套接字)、unixgramunixpacket。另一方面,net.Listen() 的有效值包括 tcptcp4tcp6unixunixpacket

执行 go doc net.Listengo doc net.Dial 命令以获取这两个函数的详细信息。

开发 TCP 客户端

本节是关于开发 TCP 客户端。接下来的两个小节展示了两种开发 TCP 客户端等效的方法。

使用 net.Dial() 开发 TCP 客户端

首先,我们将介绍最广泛使用的方法,它在 tcpC.go 中实现:

package main
import (
    "bufio"
"fmt"
"net"
"os"
"strings"
) 

import 块包含 bufiofmt 等也用于文件 I/O 操作的包。

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide host:port.")
        return
    } 

首先,我们读取我们想要连接的 TCP 服务器的详细信息。

 connect := arguments[1]
    c, err := net.Dial("tcp", connect)
    if err != nil {
        fmt.Println(err)
        os.Exit(5)
    } 

在连接详细信息的基础上,我们调用 net.Dial()——它的第一个参数是我们想要使用的协议,在这个例子中是 tcp,而第二个参数包含连接详细信息。成功的 net.Dial() 调用返回一个打开的连接(一个 net.Conn 接口),这是一个通用的面向流的网络连接。

 reader := bufio.NewReader(os.Stdin)
    for {
        fmt.Print(">> ")
        text, _ := reader.ReadString('\n')
        fmt.Fprintf(c, "%s\n", text)
        message, _ := bufio.NewReader(c).ReadString('\n')
        fmt.Print("->: " + message)
        if strings.TrimSpace(string(text)) == "STOP" {
            fmt.Println("TCP client exiting...")
            return
        }
    }
} 

TCP 客户端的最后部分会持续读取用户输入,直到输入STOP为止——在这种情况下,客户端在STOP后等待服务器响应才终止,因为这是for循环构建的方式。这主要是因为服务器可能对我们有一个有用的答案,我们不希望错过。所有用户输入都通过fmt.Fprintf()发送(写入)到打开的 TCP 连接,而bufio.NewReader()用于从 TCP 连接中读取数据,就像处理常规文件一样。

请记住,不检查reader.ReadString('\n')返回的error值的原因是简单性。我们永远不应该忽略错误。

使用tcpC.go连接到 TCP 服务器,在这个例子中,服务器是用nc(1)实现的,命令为nc -l 1234,会产生以下类型的输出:

$ go run tcpC.go localhost:1234
>> Hello!
->: Hi from nc -l 1234
>> STOP
->: Bye!
TCP client exiting... 

>>开头的行表示用户输入,而以->开头的行表示服务器消息。在发送STOP后,我们等待服务器响应,然后客户端结束 TCP 连接。前面的代码演示了如何在 Go 中创建一个带有额外逻辑和功能的适当 TCP 客户端(STOP关键字)。

下一个小节将展示创建 TCP 客户端的另一种方法。

使用net.DialTCP()开发 TCP 客户端

本小节介绍了一种开发 TCP 客户端的替代方法。区别在于用于建立 TCP 连接的 Go 函数,即net.DialTCP()net.ResolveTCPAddr(),而不是客户端的功能。

otherTCPclient.go的代码如下:

package main
import (
    "bufio"
"fmt"
"net"
"os"
"strings"
) 

尽管我们正在使用 TCP/IP 连接,但我们仍需要像bufio这样的包,因为 UNIX 将网络连接视为文件,所以我们基本上是在网络上进行 I/O 操作。

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide a server:port string!")
        return
    } 

我们需要读取我们想要连接的 TCP 服务器的详细信息,包括所需的端口号。在处理 TCP/IP 时,除非我们正在开发一个非常专业的 TCP 客户端,否则实用程序不能使用默认参数操作。

 connect := arguments[1]
    tcpAddr, err := net.ResolveTCPAddr("tcp4", connect)
    if err != nil {
        fmt.Println("ResolveTCPAddr:", err)
        return
    } 

net.ResolveTCPAddr()函数是针对 TCP 连接的,因此得名,它将给定的地址解析为*net.TCPAddr值,这是一个表示 TCP 端点地址的结构——在这种情况下,端点是我们要连接的 TCP 服务器。

 conn, err := net.DialTCP("tcp4", nil, tcpAddr)
    if err != nil {
        fmt.Println("DialTCP:", err)
        return
    } 

在手头有 TCP 端点的情况下,我们调用net.DialTCP()来连接到服务器。除了使用net.ResolveTCPAddr()net.DialTCP()之外,与 TCP 客户端和 TCP 服务器交互相关的其余代码完全相同。

 reader := bufio.NewReader(os.Stdin)
    for {
        fmt.Print(">> ")
        text, _ := reader.ReadString('\n')
        fmt.Fprintf(conn, text+"\n")
        message, _ := bufio.NewReader(conn).ReadString('\n')
        fmt.Print("->: " + message)
        if strings.TrimSpace(string(text)) == "STOP" {
            fmt.Println("TCP client exiting...")
            conn.Close()
            return
        }
    }
} 

最后,使用无限for循环与 TCP 服务器交互。TCP 客户端读取用户数据,并将其发送到服务器。然后,它从 TCP 服务器读取数据。再次使用STOP关键字在客户端侧使用Close()方法终止 TCP 连接。

使用otherTCPclient.go与 TCP 服务器进程交互会产生以下类型的输出:

$ go run otherTCPclient.go localhost:1234
>> Hello!
->: Hi from nc -l 1234
>> STOP
->: Thanks for connecting!
TCP client exiting... 

交互与tcpC.go相同——我们只是学会了开发 TCP 客户端的另一种方式。如果我的意见能被采纳,我更喜欢tcpC.go中的实现,因为它使用了更通用的函数。然而,这仅仅是个人喜好。

下一个部分将展示如何编程 TCP 服务器。

开发 TCP 服务器

本节介绍了两种开发 TCP 服务器的方法,这些服务器可以与 TCP 客户端交互,就像我们与 TCP 客户端交互一样。

使用net.Listen()开发 TCP 服务器

本节中介绍的 TCP 服务器使用net.Listen(),将当前日期和时间作为一个网络数据包发送给客户端。在实践中,这意味着在接收客户端连接后,服务器从操作系统获取时间和日期,并将这些数据发送回客户端。net.Listen()函数监听连接,而net.Accept()方法等待下一个连接,并返回一个包含客户端信息的通用net.Conn变量。tcpS.go的代码如下:

package main
import (
    "bufio"
"fmt"
"net"
"os"
"strings"
"time"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide port number")
        return
    } 

TCP 服务器应该知道它将要使用的端口号——这作为命令行参数给出。

 PORT := ":" + arguments[1]
    l, err := net.Listen("tcp", PORT)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer l.Close() 

net.Listen()函数监听连接,这使得该特定程序成为一个服务器进程。如果net.Listen()的第二个参数包含一个没有 IP 地址或主机名的端口号,net.Listen()将监听本地系统上所有可用的 IP 地址,这里就是这种情况。

这是一个个人偏好;尽管被认为是不良实践,使用如PORTSERVER这样的变量名,但这是我自己表示重要或全局变量的方式。

 c, err := l.Accept()
    if err != nil {
        fmt.Println(err)
        return
    } 

我们调用Accept()并等待客户端连接——Accept()会阻塞,直到新的连接到来。这个特定的 TCP 服务器有一些不寻常的地方:它只能服务即将连接到它的第一个 TCP 客户端,因为Accept()调用在for循环之外,因此只调用一次。每个单独的客户端都应该由不同的Accept()调用服务,但这里并没有这样做。纠正这一点留给读者作为练习。

 for {
        netData, err := bufio.NewReader(c).ReadString('\n')
        if err != nil {
            fmt.Println(err)
            return
        }
        if strings.TrimSpace(string(netData)) == "STOP" {
            fmt.Println("Exiting TCP server!")
            return
        }
        fmt.Print("-> ", string(netData))
        t := time.Now()
        myTime := t.Format(time.RFC3339) + "\n"
        c.Write([]byte(myTime))
    }
} 

这个无休止的for循环会一直与同一个 TCP 客户端交互,直到客户端发送STOP这个词。就像 TCP 客户端一样,bufio.NewReader()用于从网络连接中读取数据,而Write()用于向 TCP 客户端发送数据。

运行tcpS.go并与 TCP 客户端交互会产生以下类型的输出:

$ go run tcpS.go 1234
-> Hello!
-> Have to leave now!
Exiting TCP server! 

服务器连接因客户端连接自动结束,因为当bufio.NewReader(c).ReadString('\n')没有更多内容可读时,for循环结束了。客户端是nc(1),它产生了以下输出:

$ nc localhost 1234
Hello!
2023-10-09T20:02:55+03:00
Have to leave now!
2023-10-09T20:03:01+03:00
STOP 

我们已经使用STOP关键字结束了连接。

因此,我们现在知道如何在 Go 中开发 TCP 服务器。与 TCP 客户端一样,还有另一种开发 TCP 服务器的方法,将在下一小节中介绍。

使用net.ListenTCP()开发 TCP 服务器

这次,这个 TCP 服务器的替代版本实现了回显服务。简单来说,TCP 服务器将接收到的数据发送回客户端。

otherTCPserver.go的代码如下:

package main
import (
    "fmt"
"net"
"os"
"strings"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide a port number!")
        return
    }
    SERVER := "localhost" + ":" + arguments[1]
    s, err := net.ResolveTCPAddr("tcp", SERVER)
    if err != nil {
        fmt.Println(err)
        return
    } 

之前的代码通过命令行参数获取 TCP 端口号值,该值用于net.ResolveTCPAddr()——这是定义 TCP 服务器将要监听的端口号所必需的。

那个函数只与 TCP 一起工作,因此得名。

 l, err := net.ListenTCP("tcp", s)
    if err != nil {
        fmt.Println(err)
        return
    } 

同样,net.ListenTCP()只与 TCP 一起工作,这使得该程序成为一个准备接受传入连接的 TCP 服务器。

 buffer := make([]byte, 1024)
    conn, err := l.Accept()
    if err != nil {
        fmt.Println(err)
        return
    } 

如前所述,由于Accept()被调用的位置,这个特定的实现只能与单个客户端一起工作。这是出于简单性的考虑。在本章后面开发的并发 TCP 服务器将Accept()调用放在了一个无休止的for循环中。

 for {
        n, err := conn.Read(buffer)
        if err != nil {
            fmt.Println(err)
            return
        }
        if strings.TrimSpace(string(buffer[0:n])) == "STOP" {
            fmt.Println("Exiting TCP server!")
            conn.Close()
            return
        } 

您需要使用strings.TrimSpace()来删除输入中的任何空格字符,并将结果与具有特殊意义的STOP关键字进行比较。一旦从客户端接收到STOP关键字,服务器将使用Close()方法关闭连接。

 fmt.Print("> ", string(buffer[0:n-1]), "\n")
        _, err = conn.Write(buffer)
        if err != nil {
            fmt.Println(err)
            return
        }
    }
} 

所有之前的代码都是与 TCP 客户端交互,直到客户端决定关闭连接。运行otherTCPserver.go并与 TCP 客户端交互会产生以下类型的输出:

$ go run otherTCPserver.go 1234
> Hello from the client!
Exiting TCP server! 

>开头的第一行是客户端消息,而第二行是服务器在接收到客户端的STOP消息时的输出。因此,TCP 服务器按照程序处理客户端请求,并在接收到STOP消息时退出,这是期望的行为。

下一个部分是关于开发 UDP 客户端。

开发 UDP 客户端

这一部分演示了如何开发一个可以与 UDP 服务交互的 UDP 客户端。udpC.go的代码如下:

package main
import (
    "bufio"
"fmt"
"net"
"os"
"strings"
)
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide a host:port string")
        return
    }
    CONNECT := arguments[1] 

这是我们从用户获取 UDP 服务器详情的方式。

 s, err := net.ResolveUDPAddr("udp4", CONNECT)
    c, err := net.DialUDP("udp4", nil, s) 

前两行声明我们正在使用 UDP,并且我们想要连接到由net.ResolveUDPAddr()返回值指定的 UDP 服务器。实际的连接是通过net.DialUDP()发起的。

 if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("The UDP server is %s\n", c.RemoteAddr().String())
    defer c.Close() 

这部分程序通过调用RemoteAddr()方法找到 UDP 服务器的详细信息。

 reader := bufio.NewReader(os.Stdin)
    for {

        fmt.Print(">> ")
        text, _ := reader.ReadString('\n')
        data := []byte(text + "\n")
        _, err = c.Write(data) 

使用bufio.NewReader(os.Stdin)从用户读取数据,并使用Write()将数据写入 UDP 服务器。

 if strings.TrimSpace(string(data)) == "STOP" {
            fmt.Println("Exiting UDP client!")
            return
        } 

如果从用户读取的输入是STOP关键字,那么连接将被终止。

 if err != nil {
            fmt.Println(err)
            return
        }
        buffer := make([]byte, 1024)
        n, _, err := c.ReadFromUDP(buffer) 

使用ReadFromUDP()方法从 UDP 连接中读取数据。

 if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Printf("Reply: %s\n", string(buffer[0:n]))
    }
} 

for循环将一直进行,直到接收到作为输入的STOP关键字或以其他方式终止程序。

使用udpC.go就像以下这样——客户端使用nc(1)实现:

$ go run udpC.go localhost:1234
The UDP server is 127.0.0.1:1234 

127.0.0.1:1234c.RemoteAddr().String()的值,它显示了我们所连接的 UDP 服务器的详细信息。

>> Hello!
Reply: Hi from the server. 

我们的客户端向 UDP 服务器发送了Hello!,并收到了Hi from the server.的回复。

>> Have to leave now :)
Reply: OK - bye from nc -l -u 1234 

我们的客户端向 UDP 服务器发送 Have to leave now :) 并收到 OK - bye from nc -l -u 1234 的回复。UDP 服务器开始使用 nc -l -u 1234

>> STOP
Exiting UDP client! 

最后,在向服务器发送 STOP 关键字后,客户端打印 Exiting UDP client! 并终止——该消息在 Go 代码中定义,可以是任何你想要的内容。

下一节是关于编程 UDP 服务器的内容。

开发 UDP 服务器

本节展示了如何开发一个 UDP 服务器,该服务器为客户端生成并返回随机数。UDP 服务器(udpS.go)的代码如下:

package main
import (
    "fmt"
"math/rand"
"net"
"os"
"strconv"
"strings"
"time"
)
func random(min, max int) int {
    return rand.Intn(max-min) + min
}
func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide a port number!")
        return
    }
    PORT := ":" + arguments[1] 

服务器将要监听的 UDP 端口号作为命令行参数提供。

 s, err := net.ResolveUDPAddr("udp4", PORT)
    if err != nil {
        fmt.Println(err)
        return
    } 

net.ResolveUDPAddr() 函数创建一个 UDP 服务器将要监听的 UDP 端点。

 connection, err := net.ListenUDP("udp4", s)
    if err != nil {
        fmt.Println(err)
        return
    } 

net.ListenUDP("udp4", s) 函数调用使此过程成为使用其第二个参数指定的详细信息的 udp4 协议的服务器。

 defer connection.Close()
    buffer := make([]byte, 1024) 

buffer 变量存储一个 1024 字节的字节切片,用于从 UDP 客户端读取数据。

 rand.Seed(time.Now().Unix())
    for {
        n, addr, err := connection.ReadFromUDP(buffer)
        fmt.Print("-> ", string(buffer[0:n-1])) 

ReadFromUDP()WriteToUDP() 方法分别用于从 UDP 连接读取数据并将数据写入 UDP 连接。此外,由于 UDP 的操作方式,UDP 服务器可以服务多个客户端。

 if strings.TrimSpace(string(buffer[0:n])) == "STOP" {
            fmt.Println("Exiting UDP server!")
            return
        } 

当任何一个客户端发送 STOP 消息时,UDP 服务器将终止。除此之外,for 循环将永远运行。

 data := []byte(strconv.Itoa(random(1, 1001)))
        fmt.Printf("data: %s\n", string(data)) 

字节切片存储在 data 变量中,并用于将所需数据写入客户端。

 _, err = connection.WriteToUDP(data, addr)
        if err != nil {
            fmt.Println(err)
            return
        }
    }
} 

udpS.go 一起工作就像以下这样:

$ go run udpS.go 1234
-> Hello from client!
data: 403 

-> 开头的行显示来自客户端的数据。以 data: 开头的行显示 UDP 服务器生成的随机数——在本例中为 403

-> Going to terminate the connection now.
data: 154 

前两行显示了与 UDP 客户端的另一个交互。

-> STOP
Exiting UDP server! 

一旦 UDP 服务器从客户端接收到 STOP 关键字,它将关闭连接并退出。

在使用 udpC.go 的客户端方面,我们有以下交互:

$ go run udpC.go localhost:1234
The UDP server is 127.0.0.1:1234
>> Hello from client!
Reply: 403 

客户端向服务器发送 Hello from client! 消息并收到 403

>> Going to terminate the connection now.
Reply: 154 

客户端向服务器发送 Going to terminate the connection now. 并接收随机数 154

>> STOP
Exiting UDP client! 

当客户端接收到 STOP 作为用户输入时,它将终止 UDP 连接并退出。

下一节展示了如何开发一个使用 goroutines 为其客户端提供服务的并发 TCP 服务器。

开发并发 TCP 服务器

本节教你一个开发并发 TCP 服务器的模式,这些服务器在成功调用 Accept() 后使用单独的 goroutines 为其客户端提供服务。因此,这样的服务器可以同时为多个 TCP 客户端提供服务。这是现实世界生产服务器和服务实现的方式。

concTCP.go 的代码如下:

package main
import (
    "bufio"
"fmt"
"net"
"os"
"strconv"
"strings"
)
var count = 0
func handleConnection(c net.Conn, myCount int) {
    fmt.Print(".") 

前面的语句不是必需的——它只是通知我们一个新的客户端已连接。

 netData, err := bufio.NewReader(c).ReadString('\n')
    if err != nil {
        fmt.Println(err)
        return
    }
    for {

        temp := strings.TrimSpace(string(netData))
        if temp == "STOP" {
            break
        }
        fmt.Println(temp)
        counter := "Client number: " + strconv.Itoa(myCount) + "\n"
        c.Write([]byte(string(counter)))
    } 

for 循环确保 handleConnection() 不会自动退出。再次强调,STOP 关键字停止了当前客户端连接的 goroutine——然而,服务器进程以及所有其他活跃的客户端连接将继续运行。

 defer c.Close()
} 

这是执行为服务客户端的 goroutine 的函数的结束。为了服务一个客户端,你需要一个带有 TCP 客户端详细信息的 net.Conn 参数。在读取客户端数据后,服务器会向当前的 TCP 客户端发送一条消息,指示到目前为止已服务的 TCP 客户端总数。

func main() {
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Please provide a port number!")

        os.Exit(5)
    }
    PORT := ":" + arguments[1]
    l, err := net.Listen("tcp4", PORT)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer l.Close()
    for {
        c, err := l.Accept()
        if err != nil {
            fmt.Println(err)
            return
        }
        go handleConnection(c, count)
        count++
    }
} 

每次有新的客户端连接到服务器时,count 变量会增加。每个 TCP 客户端都由一个执行 handleConnection() 函数的单独的 goroutine 服务。这释放了服务器进程,并允许它接受新的连接。简单来说,当多个 TCP 客户端正在被服务时,TCP 服务器可以自由地与更多的 TCP 客户端交互。和之前一样,新的 TCP 客户端是通过使用 Accept() 函数连接的。

使用 concTCP.go 进行工作会产生以下类型的输出:

$ go run concTCP.go 1234
.Hello
.Hi from  nc localhost 1234 

输出的第一行来自第一个 TCP 客户端,而第二行来自第二个 TCP 客户端。这意味着并发 TCP 服务器按预期工作。因此,当你想在 TCP 服务中服务多个 TCP 客户端时,你可以使用提供的技巧和代码作为开发自己的并发 TCP 服务器的模板。

下面的部分将涉及 WebSocket 协议。

创建 WebSocket 服务器

WebSocket 协议是一种计算机通信协议,它通过单个 TCP 连接提供全双工(同时双向传输数据)通信通道。WebSocket 协议在 RFC 6455 (tools.ietf.org/html/rfc6455) 中定义,并分别使用 ws://wss:// 替代 http://https://。因此,客户端应通过使用以 ws:// 开头的 URL 来开始 WebSocket 连接。

在本节中,我们将使用 gorilla/websocket (github.com/gorilla/websocket) 模块开发一个小型但功能齐全的 WebSocket 服务器。该服务器实现了回声服务,这意味着它会自动将客户端输入返回给客户端。

https://pkg.go.dev/golang.org/x/net/websocket 包提供了另一种开发 WebSocket 客户端和服务器的方法。然而,根据其文档,pkg.go.dev/golang.org/x/net/websocket 缺少一些功能,建议使用 pkg.go.dev/github.com/gorilla/websocket,即这里使用的,或者 pkg.go.dev/nhooyr.io/websocket

你可能会问,为什么使用 WebSocket 协议而不是 HTTP。WebSocket 协议的优点包括以下内容:

  • WebSocket 连接是一个全双工、双向的通信通道。这意味着服务器不需要等待从客户端读取数据才能向客户端发送数据,反之亦然。

  • WebSocket 连接是原始的 TCP 套接字,这意味着它们不需要建立 HTTP 连接所需的开销。

  • WebSocket 连接也可以用来发送 HTTP 数据。然而,普通的 HTTP 连接不能作为 WebSocket 连接工作。

  • WebSocket 连接在它们被终止之前一直存在,因此没有必要总是重新打开它们。

  • WebSocket 连接可以用于实时 Web 应用程序。

  • 数据可以在任何时候从服务器发送到客户端,即使客户端没有请求。

  • WebSocket 是 HTML5 规范的一部分,这意味着它被所有现代网络浏览器支持。

在展示服务器实现之前,了解gorilla/websocket包中的websocket.Upgrader方法将 HTTP 服务器连接升级到 WebSocket 协议,并允许你定义升级参数会很好。之后,你的 HTTP 连接就变成了 WebSocket 连接,这意味着你将不允许执行与 HTTP 协议相关的语句。

下一个小节将展示服务器的实现。

服务器实现

本小节展示了实现 echo 服务的 WebSocket 服务器,这在测试网络连接时非常有用。

代码被放置在~/go/src/github.com/mactsouk/mGo4th/ch10/ws目录中。server目录包含服务器的实现,而client目录包含 WebSocket 客户端的实现。

WebSocket 服务器的实现可以在server.go中找到:

package main
import (
    "fmt"
"log"
"net/http"
"os"
"time"
"github.com/gorilla/websocket"
) 

这是用于处理 WebSocket 协议的外部包。

var PORT = ":1234"
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
} 

这里定义了websocket.Upgrader的参数。它们将很快被使用。

func rootHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome!\n")
    fmt.Fprintf(w, "Please use /ws for WebSocket!")
} 

这是一个常规的 HTTP 处理函数。

func wsHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Connection from:", r.Host)
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("upgrader.Upgrade:", err)
        return
    }
    defer ws.Close() 

WebSocket 服务器应用程序调用Upgrader.Upgrade方法从 HTTP 请求处理器获取 WebSocket 连接。在成功调用Upgrader.Upgrade之后,服务器开始与 WebSocket 连接和 WebSocket 客户端一起工作。

 for {
        mt, message, err := ws.ReadMessage()
        if err != nil {
            log.Println("From", r.Host, "read", err)
            break
        }
        log.Print("Received: ", string(message))
        err = ws.WriteMessage(mt, message)
        if err != nil {
            log.Println("WriteMessage:", err)
            break
        }
    }
} 

wsHandler()中的for循环处理所有针对/ws的传入消息——你可以使用任何你想要的技巧来处理传入请求。此外,在所展示的实现中,除非有网络问题或服务器进程被终止,否则只有客户端被允许关闭现有的 WebSocket 连接。

最后,请记住,在 WebSocket 连接中,您不能使用fmt.Fprintf()语句向 WebSocket 客户端发送数据——如果您使用这些中的任何一个,或者任何其他可以执行相同功能的调用,WebSocket 连接将失败,您将无法发送或接收任何数据。因此,在用gorilla/websocket实现的 WebSocket 连接中发送和接收数据的唯一方法是分别通过WriteMessage()ReadMessage()调用。当然,您总是可以通过处理原始网络数据来实现所需的功能,但这超出了本书的范围。

func main() {
    arguments := os.Args
    if len(arguments) != 1 {
        PORT = ":" + arguments[1]
    } 

如果没有命令行参数,将使用存储在PORT全局变量中的默认端口号。否则,将使用给定的值。

 mux := http.NewServeMux()
    s := &http.Server{
        Addr:         PORT,
        Handler:      mux,
        IdleTimeout:  10 * time.Second,
        ReadTimeout:  time.Second,
        WriteTimeout: time.Second,
    } 

这些是处理 WebSocket 连接的 HTTP 服务器的详细信息。

 mux.Handle("/", http.HandlerFunc(rootHandler))
    mux.Handle("/ws", http.HandlerFunc(wsHandler)) 

用于 WebSocket 的端点可以是您想要的任何内容——在这种情况下,它是/ws。此外,您可以有多个端点,它们使用 WebSocket 协议。

 log.Println("Listening to TCP Port", PORT)
    err := s.ListenAndServe()
    if err != nil {
        log.Println(err)
        return
    }
} 

所展示的代码使用log.Println()而不是fmt.Println()来打印消息——因为这是一个服务器进程,使用log.Println()fmt.Println()更好,因为日志信息被发送到可以在以后检查的文件。然而,在开发过程中,您可能更喜欢fmt.Println()调用,并避免写入日志文件,因为您可以在屏幕上立即看到数据,而无需在其他地方查找。此外,如果您打算以 Docker 镜像运行服务器,使用fmt.Println()更有意义。但是,您应该记住,log包默认也会打印到屏幕上。

服务器实现简短,但功能齐全。代码中最重要的单个调用是Upgrader.Upgrade,因为这会将 HTTP 连接升级为 WebSocket 连接。

从 GitHub 获取并运行代码需要以下步骤——大多数步骤都与模块初始化和下载所需的包有关:

$ go mod init
$ go mod tidy
$ go run server.go 

要测试该服务器,我们需要有一个客户端。由于我们迄今为止还没有开发自己的客户端,我们将使用websocat实用程序来测试 WebSocket 服务器。

使用 websocat

websocat是一个命令行实用程序,可以帮助您测试 WebSocket 连接。然而,由于websocat默认未安装,您需要使用您选择的包管理器在您的机器上安装它。如果目标地址有 WebSocket 服务器,您可以使用以下方式使用它:

$ websocat ws://localhost:1234/ws
Hello from websocat! 

这是我们在服务器上键入并发送的内容。

Hello from websocat! 

这是我们从 WebSocket 服务器返回的内容,该服务器实现了回显服务——不同的 WebSocket 服务器实现不同的功能。

Bye! 

再次强调,上一行是用户输入给websocat的。

Bye! 

最后一条是服务器发送回的数据。连接是通过在websocat客户端上按Ctrl + D来关闭的。

如果你希望从websocat获取详细输出,你可以使用-v标志执行它:

$ websocat -v ws://localhost:1234/ws
[INFO  websocat::lints] Auto-inserting the line mode
[INFO  websocat::stdio_threaded_peer] get_stdio_peer (threaded)
[INFO  websocat::ws_client_peer] get_ws_client_peer
[INFO  websocat::ws_client_peer] Connected to ws
Hello from websocat!
Hello from websocat!
Bye!
Bye!
[INFO  websocat::sessionserve] Forward finished
[INFO  websocat::ws_peer] Received WebSocket close message
[INFO  websocat::sessionserve] Reverse finished
[INFO  websocat::sessionserve] Both directions finished 

在这两种情况下,我们的 WebSocket 服务器的输出应该类似于以下内容:

$ go run server.go
2023/10/09 20:29:16 Listening to TCP Port :1234
2023/10/09 20:29:24 Connection from: localhost:1234
2023/10/09 20:29:31 Received: Hello from websocat!
2023/10/09 20:29:53 Received: Bye!
2023/10/09 20:30:01 From localhost:1234 read websocket: close 1005 (no status) 

下一个子节展示了如何在 Go 中开发 WebSocket 客户端。

创建 WebSocket 客户端

本节展示了如何在 Go 中编程 WebSocket 客户端。客户端读取用户数据,将其发送到服务器,并读取服务器响应。client目录包含 WebSocket 客户端的实现。gorilla/websocket包将帮助我们开发 WebSocket 客户端。

./client/client.go的代码如下:

package main
import (
    "bufio"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/websocket"
)
var (
    SERVER       = ""
    PATH         = ""
    TIMESWAIT    = 0
    TIMESWAITMAX = 5
    in           = bufio.NewReader(os.Stdin)
) 

in变量只是bufio.NewReader(os.Stdin)的一个快捷方式。

func getInput(input chan string) {
    result, err := in.ReadString('\n')
    if err != nil {
        log.Println(err)
        return
    }
    input <- result
} 

getInput()函数作为 goroutine 执行,获取用户输入并将其通过input通道传输到main()函数。每次程序读取一些用户输入时,旧的 goroutine 结束,并开始一个新的getInput() goroutine 以获取新的用户输入。

func main() {
    arguments := os.Args
    if len(arguments) != 3 {
        fmt.Println("Need SERVER + PATH!")
        return
    }
    SERVER = arguments[1]
    PATH = arguments[2]
    fmt.Println("Connecting to:", SERVER, "at", PATH)
    interrupt := make(chan os.Signal, 1)
    signal.Notify(interrupt, os.Interrupt) 

WebSocket 客户端借助interrupt通道处理 UNIX 中断。当捕获到适当的信号(syscall.SIGINT)时,使用websocket.CloseMessage消息关闭与服务器的 WebSocket 连接。这正是专业工具的工作方式!

 input := make(chan string, 1)
    go getInput(input)
    URL := url.URL{Scheme: "ws", Host: SERVER, Path: PATH}
    c, _, err := websocket.DefaultDialer.Dial(URL.String(), nil)
    if err != nil {
        log.Println("Error:", err)
        return
    }
    defer c.Close() 

WebSocket 连接从调用websocket.DefaultDialer.Dial()开始。所有发送到input通道的内容都使用WriteMessage()方法传输到 WebSocket 服务器。

 done := make(chan struct{})
    go func() {
        defer close(done)
        for {
            _, message, err := c.ReadMessage()
            if err != nil {
                log.Println("ReadMessage() error:", err)
                return
            }
            log.Printf("Received: %s", message)
        }
    }() 

另一个 goroutine,这次使用匿名 Go 函数实现,负责使用ReadMessage()方法从 WebSocket 连接中读取数据。

 for {
        select {
        case <-time.After(4 * time.Second):
            log.Println("Please give me input!", TIMESWAIT)
            TIMESWAIT++
            if TIMESWAIT > TIMESWAITMAX {
                syscall.Kill(syscall.Getpid(), syscall.SIGINT)
            } 

syscall.Kill(syscall.Getpid(), syscall.SIGINT)语句使用 Go 代码向程序发送中断信号。根据client.go的逻辑,中断信号使得程序关闭与服务器的 WebSocket 连接并终止其执行。这仅在当前超时周期数大于预定义的全局值时发生,在这个例子中等于5

 case <-done:
            return
case t := <-input:
            err := c.WriteMessage(websocket.TextMessage, []byte(t))
            if err != nil {
                log.Println("Write error:", err)
                return
            }
            TIMESWAIT = 0 

如果你收到用户输入,当前的超时周期数(TIMESWAIT)将被重置,并读取新的输入。

 go getInput(input)
        case <-interrupt:
            log.Println("Caught interrupt signal - quitting!")
            err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) 

在我们关闭客户端连接之前,我们向服务器发送websocket.CloseMessage以正确结束连接。

 if err != nil {
                log.Println("Write close error:", err)
                return
            }
            select {
            case <-done:
            case <-time.After(2 * time.Second):
            }
            return
        }
    }
} 

由于./client/client.go位于单独的目录中,我们需要运行以下命令来收集所需的依赖项并运行它:

$ cd client
$ go mod init
$ go mod tidy 

与 WebSocket 服务器的交互产生以下类型的输出:

$ go run client.go localhost:1234 ws
Connecting to: localhost:1234 at ws
Hello there!
2023/10/09 20:36:25 Received: Hello there! 

前两行显示了用户输入以及服务器响应。

2023/10/09 20:36:29 Please give me input! 0
2023/10/09 20:36:33 Please give me input! 1
2023/10/09 20:36:37 Please give me input! 2
2023/10/09 20:36:41 Please give me input! 3
2023/10/09 20:36:45 Please give me input! 4
2023/10/09 20:36:49 Please give me input! 5
2023/10/09 20:36:49 Caught interrupt signal - quitting!
2023/10/09 20:36:49 ReadMessage() error: websocket: close 1000 (normal) 

输出的最后几行显示了自动超时过程的工作方式。

WebSocket 服务器为之前的交互生成了以下输出:

2023/10/09 20:36:22 Connection from: localhost:1234
2023/10/09 20:36:25 Received: Hello there!
2023/10/09 20:36:49 From localhost:1234 read websocket: close 1000 (normal) 

然而,如果无法在提供的地址找到 WebSocket 服务器,WebSocket 客户端将产生以下输出:

$ go run client.go localhost:1234 ws
Connecting to: localhost:1234 at ws
2023/10/09 08:11:20 Error: dial tcp [::1]:1234: connect: connection refused 

connection refused消息表明没有进程在localhost上监听端口号1234

本章的下一节是关于与 RabbitMQ 消息代理一起工作。

与 RabbitMQ 一起工作

在本章的最后部分,我们将学习如何与 RabbitMQ 一起工作。RabbitMQ 是一个开源的消息代理,当您想要异步交换信息并需要一个安全存储消息的地方时特别有用。RabbitMQ 使您能够交换信息,这也是您不需要直接使用它除非您想执行管理任务的主要原因。

RabbitMQ 使用 AMQP 协议。AMQP 代表高级消息队列协议,是一种面向消息的中间件的开源协议。AMQP 的特点是消息导向、排队、路由、可靠性和安全性。AMQP 与二进制数据一起工作,并以帧的形式传输数据。根据您要执行的任务,存在九种类型的帧(打开连接、关闭连接、传输数据等)。

如果您必须在 RabbitMQ 和 Kafka 之间做出选择,它们都执行类似的工作,您应该首先考虑它们之间的差异。首先,Kafka 比 RabbitMQ 更快。其次,RabbitMQ 使用推送模型,而 Kafka 使用基于拉取的方法。第三,Kafka 支持批处理,而 RabbitMQ 不支持。最后,RabbitMQ 没有对有效负载大小的限制,而 Kafka 对有效负载大小有一些限制。要记住的关键点是,如果速度是您的主要关注点,那么 Kafka 可能是一个更好的选择,而如果您主要关注有效负载大小和简单性,那么 RabbitMQ 是一个更合理的选择。

数据存储在队列中。RabbitMQ 中的队列是一个 FIFO(先进先出)结构,支持两种操作:添加元素和获取元素。队列有名称,这意味着您应该知道您想要交互的队列的名称,无论是作为消息生产者还是消息消费者。

github.com/rabbitmq/amqp091-go Go 模块通过 AMQP 协议连接到 RabbitMQ,前提是您提供了正确的连接细节。

本节的所有文件,包括源文件和用于运行 RabbitMQ 的docker-compose.yml文件,都位于~/go/src/github.com/mactsouk/mGo4th/ch10/MQ

运行 RabbitMQ

在本节的第一小节中,我们将学习如何使用 Docker 来执行 RabbitMQ——这是在您的机器上运行 RabbitMQ 的最干净解决方案,因为它只需要使用单个 Docker 镜像。位于~/go/src/github.com/mactsouk/mGo4th/ch10/MQ/目录下的docker-compose.yml文件的内容如下:

version: "3.6"
services:
rabbitmq:
image: 'rabbitmq:3.12-management'
container_name: rabbit
ports:
- '5672:5672'
- '15672:15672'
environment:
AMQP_URL: 'amqp://rabbitmq?connection_attempts=5&retry_delay=5'
RABBITMQ_DEFAULT_USER: "guest"
RABBITMQ_DEFAULT_PASS: "guest"
networks:
- rabbit
networks:
rabbit:
driver: bridge 

运行docker-compose.yml就像在~/go/src/github.com/mactsouk/mGo4th/ch10/MQ/目录下使用docker-compose up一样简单。如果相关的 Docker 镜像不在活动机器上,它将首先被下载。

现在我们已经启动了 RabbitMQ,让我们学习如何向 RabbitMQ(生产者)发送数据,并从 RabbitMQ 队列(消费者)读取数据。

向 RabbitMQ 写入

RabbitMQ 生产者的代码命名为sendMQ.go,位于producer目录下。sendMQ.go源文件也分为两部分。第一部分包含以下代码:

package main
import (
    "fmt"
    amqp "github.com/rabbitmq/amqp091-go"
)
func main() {
    fmt.Println("RabbitMQ producer")
    conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
    if err != nil {
        fmt.Println("amqp.Dial():", err)
        return
    }
    ch, err := conn.Channel()
    if err != nil {
        fmt.Println(err)
        return
    }
    defer ch.Close() 

amqp.Dial()调用初始化与 RabbitMQ 的连接,而Channel()调用打开该连接的通道,使其准备好使用。

连接字符串(amqp://guest:guest@localhost:5672/)可以分为三个逻辑部分。第一部分(amqp://)是使用的协议,第二部分包含用户凭据,第三部分包含服务器名称和端口号(localhost:5672/)。

第二部分包含以下代码:

 q, err := ch.QueueDeclare("Go", false, false, false, false, nil)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Queue:", q)
    message := "Writing to RabbitMQ!"
    err = ch.PublishWithContext(nil, "", "Go", false, false,
        amqp.Publishing{ContentType: "text/plain", Body: []byte(message)},
    )
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Message published to Queue!")
} 

QueueDeclare()定义了我们将要使用的队列。如果QueueDeclare()指定的队列名称不存在,它将被创建。这意味着队列名称中的错误不会被捕获。在这种情况下,队列被命名为Go

我们想要发送的纯文本数据保存在message变量中。之后,我们指定将要放入amqp.Publishing{}结构体中的数据格式,在这种情况下是纯文本(也支持 JSON 格式)。message变量的内容被转换为字节切片,并放入匿名amqp.Publishing{}结构体的Body字段中。

那个amqp.Publishing{}结构体是ch.PublishWithContext()调用的一个参数,它将结构体发送到 RabbitMQ。

当前版本的sendMQ.go只是使用Publish()将消息写入预定义的队列并退出。这意味着为了向 RabbitMQ 发送多个消息,我们必须多次执行sendMQ.go

运行sendMQ.go生成以下类型的输出:

$ go run sendMQ.go
RabbitMQ producer
Queue: {Go 0 0}
Message published to Queue!
$ go run sendMQ.go
RabbitMQ producer
Queue: {Go 1 0}
Message published to Queue! 

{Go 1 0}输出意味着我们目前在 RabbitMQ 队列中有两条消息。

下一个子节将展示如何从 RabbitMQ 队列中消费消息。

从 RabbitMQ 读取

RabbitMQ 消费者的代码命名为readMQ.go,位于consumer目录下。readMQ.go源文件也分为两部分。第一部分包含以下代码:

package main
import (
    "fmt"
    amqp "github.com/rabbitmq/amqp091-go"
)
func main() {
    fmt.Println("RabbitMQ consumer")
    conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
    if err != nil {
        fmt.Println("Failed Initializing Broker Connection")
        panic(err)
    }
    ch, err := conn.Channel()
    if err != nil {
        fmt.Println(err)
    }
    defer ch.Close() 

sendMQ.go类似,我们定义连接细节,并打开连接。

第二部分如下:

 msgs, err := ch.Consume("Go", "", true, false, false, false, nil)
    if err != nil {
        fmt.Println(err)
    }
    forever := make(chan bool)
    go func() {
        for d := range msgs {
            fmt.Printf("Received: %s\n", d.Body)
        }
    }()
    fmt.Println("Connected to the RabbitMQ server!")
    <-forever
} 

Consume()方法使用 goroutine 从Go队列读取消息。forever通道阻塞程序,防止其退出。接收到的消息的Body字段,它是一个结构体,包含我们想要的数据。

运行readMQ.go,在sendMQ.go向 RabbitMQ 中放入一些消息后,生成以下类型的输出:

$ go run readMQ.go
RabbitMQ consumer
Connected to the RabbitMQ server!
Received: Writing to RabbitMQ!
Received: Writing to RabbitMQ! 

readMQ.go实用程序持续运行并等待新消息,这意味着我们需要自己通过按下Ctrl + C来结束它。

如何删除模块

这与 RabbitMQ 没有直接关系,但它是一个有用的提示。consumer目录中当前版本的go.mod如下所示:

module github.com/mactsouk/mGo4th/ch10/MQ/consumer
go 1.21.1
require github.com/rabbitmq/amqp091-go v1.8.1 

您可以使用以下方式使用nonego.mod中删除模块:

$ go get github.com/rabbitmq/amqp091-go@none
go: removed github.com/rabbitmq/amqp091-go v1.8.1 

之后,go.mod将如下所示:

module github.com/mactsouk/mGo4th/ch10/MQ/consumer
go 1.21.1 

如果您想将go.mod恢复到之前的状态,可以运行go mod tidy

摘要

本章主要介绍了net包、TCP/IP、TCP 和 UDP,它们实现了低级连接,以及 WebSocket 和 RabbitMQ。

WebSocket 为我们提供了创建服务的另一种方式。一般来说,当我们想要交换大量数据,并且希望连接始终保持开启状态,进行全双工数据交换时,WebSocket 是更好的选择。然而,如果我们不确定该选择什么,建议从 TCP/IP 服务开始,看看效果如何,然后再升级到 WebSocket 协议。

最后,当我们要将大量数据存储和检索到外部数据存储时,RabbitMQ 是一个合理的选择。

Go 可以帮助您创建各种并发服务器和客户端。

现在我们已经准备好开始开发自己的服务了!下一章将介绍 REST API、通过 HTTP 交换 JSON 数据以及开发 RESTful 客户端和服务器——Go 在开发 RESTful 客户端和服务器方面得到了广泛应用。

练习

  • 开发一个并发 TCP 服务器,在预定义的范围内生成随机数。

  • 开发一个并发 TCP 服务器,在 TCP 客户端给出的范围内生成随机数。这可以用作从集合中随机选择值的方法。

  • 将 UNIX 信号处理添加到本章开发的并发 TCP 服务器中,以便在接收到指定信号时优雅地停止服务器进程。

  • 编写一个客户端程序,从 RabbitMQ 服务器读取消息并将其发布到 TCP 服务器。

  • 开发一个 WebSocket 服务器,该服务器创建一个可变数量的随机整数并发送给客户端。随机整数的数量由客户端在初始客户端消息中指定。

其他资源

留下您的评价!

喜欢这本书?通过留下亚马逊评价帮助像您这样的读者。扫描下面的二维码以获得您选择的免费电子书。

评价二维码

第十一章:与 REST API 一起工作

本章的主题是使用 Go 编程语言开发 RESTful 服务器和客户端,Go 在许多领域都表现出色。

REST 是表示状态转移的缩写,主要是为设计网络服务提供了一种标准化的高效方式,使客户端能够访问数据并使用提供的服务器功能。

RESTful 服务通常使用 JSON 格式来交换信息,这得到了 Go 的良好支持。REST 与任何操作系统或系统架构无关,也不是一种协议;然而,为了实现 RESTful 服务,我们需要使用 HTTP 或 HTTPS 等协议,因为 REST 是基于 HTTP(S)协议的 API 约定。

虽然我们已经熟悉了所展示的大多数 Go 代码,但 REST 背后的思想和 Go 代码实现它们的方式将会是新的。RESTful 服务器开发的中心是定义适当的 Go 结构和执行必要的序列化和反序列化操作,以支持客户端和服务器之间 JSON 数据的交换。

这确实是一个非常重要且实用的章节,涵盖了:

  • REST 简介

  • 开发 RESTful 服务器和客户端

  • 创建功能性的 RESTful 服务器

  • 创建 RESTful 客户端

REST 简介

大多数现代网络应用程序通过公开它们的 API,并允许客户端使用这些 API 与它们进行交互和通信来工作。尽管 REST 与 HTTP 没有直接关联,但大多数网络服务使用 HTTP 作为其底层协议。此外,尽管 REST 可以与任何数据格式一起工作,但通常 REST 意味着通过 HTTP 的 JSON,因为在大多数情况下,RESTful 服务中的数据交换是以 JSON 格式进行的。也有时候数据是以纯文本格式交换的,通常当交换的数据很简单且没有实际需要 JSON 记录时。由于 RESTful 服务的工作方式,它应该有一个遵循以下原则的架构:

  • 客户端-服务器设计

  • 无状态实现(每次交互不依赖于之前的交互)

  • 可缓存

  • 统一接口

  • 分层系统

根据 HTTP 协议,我们可以在 HTTP 服务器上执行以下操作:

  • POST:这用于创建新资源。

  • GET:这用于读取(获取)现有资源。

  • PUT:这用于更新现有资源。按照惯例,一个PUT请求应该包含现有资源的完整和更新版本。

  • DELETE:这用于删除现有资源。

  • PATCH:这用于更新现有资源。一个PATCH请求只包含对现有资源的修改。

这里重要的是,你做的每一件事,尤其是当你做的是非同寻常的事情时,都必须有很好的文档记录。作为一个参考,请记住,Go 支持的 HTTP 方法被定义为net/http包中的常量:

const (
    MethodGet     = "GET"
    MethodHead    = "HEAD"
    MethodPost    = "POST"
    MethodPut     = "PUT"
    MethodPatch   = "PATCH" // RFC 5789
    MethodDelete  = "DELETE"
    MethodConnect = "CONNECT"
    MethodOptions = "OPTIONS"
    MethodTrace   = "TRACE"
) 

对于每个客户端请求返回的 HTTP 状态码也存在一些约定。以下是最流行的 HTTP 状态码及其含义:

  • 200 表示一切顺利,指定的操作已成功执行。

  • 201 表示所需的资源已创建。

  • 202 表示请求已被接受,目前正在处理中。这通常用于动作需要太长时间才能完成的情况。

  • 301 表示请求的资源已永久移动——新的 URI 应该包含在响应中。这在 RESTful 服务中很少使用,因为 API 版本控制被用来代替。

  • 400 表示请求有误,你应该在再次发送之前更改你的初始请求。

  • 401 表示客户端尝试未经授权访问受保护的请求。

  • 403 表示客户端即使已经正确授权,也没有访问资源的必要权限。在 UNIX 术语中,403 表示当前用户没有执行该操作的必要权限。

  • 404 表示未找到资源。

  • 405 表示客户端使用了资源类型不允许的方法。

  • 500 表示内部服务器错误——这通常表明服务器出现故障。

如果你想了解更多关于 HTTP 协议的信息,你应该访问 RFC 7231,网址为 datatracker.ietf.org/doc/html/rfc7231

现在,让我给你讲一个个人故事。几年前,我正在为一个我正在工作的项目开发一个小型的 RESTful 客户端。客户端连接到指定的服务器以获取用户名列表。对于每个用户名,我必须通过访问另一个端点来获取登录和登出时间。

从我的个人经验中,我可以告诉你,大部分的 Go 代码并不是关于与 RESTful 服务器交互,而是关于处理数据,将其转换为所需的格式,并将其存储在数据库中——我需要执行的两大棘手任务是获取 UNIX 纪元格式的日期和时间,以及从该纪元时间中截断关于分钟和秒的信息,以及确保具有相同数据的记录尚未存储在该数据库中后,将新记录插入数据库表。因此,预期你将要编写的代码的大部分将关于面对服务的逻辑,这不仅适用于 RESTful 服务,也适用于所有服务。

本章的第一部分包含有关编程 RESTful 服务器和客户端的通用但基本的信息。

开发 RESTful 服务器和客户端

本节将使用 Go 标准库的功能开发一个 RESTful 服务器及其客户端,以了解幕后实际是如何工作的。服务器功能在以下端点列表中描述:

  • /add:此端点是用于向服务器添加新条目。

  • /delete:此端点用于删除现有条目。

  • /get:此端点是用于获取已存在条目的信息。

  • /time:此端点返回当前日期和时间,主要用于测试 RESTful 服务器的操作。

  • /:此端点用于处理不匹配任何其他端点的任何请求。

定义端点的另一种替代和更专业的方法如下:

  • /users/ 使用GET方法:获取所有用户的列表。

  • /users/:id 使用GET方法:获取具有给定 ID 值的用户信息。

  • /users/:id 使用DELETE方法:删除具有给定 ID 的用户。

  • /users/ 使用POST方法:创建新用户。

  • /users/:id 使用PATCHPUT方法:更新具有给定 ID 值的用户。

替代方法的实现留给读者作为练习——鉴于处理器的 Go 代码将相同,并且你只需重新定义我们指定端点处理的部分,这应该不难实现。

下一个子节将介绍 RESTful 服务器的实现。

一个 RESTful 服务器

提出的实现目的是为了理解幕后的事情是如何运作的,因为 REST 服务的原则保持不变。

每个处理器函数背后的逻辑很简单。每个函数读取用户输入,并在处理任何数据之前决定给定的输入和 HTTP 方法是否是所需的。

每个客户交互的原则也很简单:服务器应向客户端发送适当的错误消息和 HTTP 状态码,以便每个人都知道真正发生了什么。最后,所有内容都应被记录下来,以便用一种共同的语言进行沟通。

服务器代码,保存为rServer.go,如下所示:

package main
import (
    "encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
type User struct {
    Username string `json:"user"`
    Password string `json:"password"`
} 

这是一个存储用户数据的结构,因此必须使用 JSON 标签,因为 JSON 数据格式与我们使用的 Go 语言中的不同。

var user User 

user全局变量持有当前交互的用户数据——这是/add/get/delete端点及其简单实现的输入。由于此全局变量在整个程序中共享,我们的代码不是并发安全的,这对于用作概念证明的 RESTful 服务器来说是完全可以接受的。

// PORT is where the web server listens to
var PORT = ":1234" 

RESTful 服务器只是一个 HTTP 服务器,因此我们需要定义服务器监听的 TCP 端口号。

// DATA is the map that holds User records
var DATA = make(map[string]string) 

上述代码定义了一个名为DATA的全局变量,它包含服务的数据。

func defaultHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    w.WriteHeader(http.StatusNotFound)
    body := "Thanks for visiting!\n"
    fmt.Fprintf(w, "%s", body)
} 

这是服务的默认处理器。在生产服务器上,默认处理器可能会打印有关服务器操作以及可用端点的说明。

func timeHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host)
    t := time.Now().Format(time.RFC1123)
    body := "The current time is: " + t + "\n"
    fmt.Fprintf(w, "%s", body)
} 

timeHandler() 是另一个简单的处理器,它返回当前的日期和时间——这样的简单处理器通常用于测试服务器的健康状态,并且在生产版本中通常会被移除。一个具有类似目的的非常流行的端点是 /health,它通常存在于现代 REST API 中,其目的是在即使在生产环境中也提供服务器的健康状态。

func addHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host, r.Method)
    if r.Method != http.MethodPost {
        fmt.Fprintf(w, "%s\n", "Method not allowed!")
        http.Error(w, "Error:", http.StatusMethodNotAllowed)
        return
    } 

这是您第一次看到 http.Error() 函数。http.Error() 函数向客户端请求发送回复,包括指定的错误消息,该消息应为纯文本,以及所需的 HTTP 状态码。您仍然需要使用 fmt.Fprintf() 语句编写要发送回客户端的数据。然而,http.Error() 需要最后调用,因为我们不应该在调用 http.Error() 之后对 w 执行任何更多的写入操作。

 d, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Error:", http.StatusBadRequest)
        return
    } 

我们尝试一次性从客户端读取所有数据,使用 io.ReadAll(),并通过检查 io.ReadAll(r.Body) 返回的 d 变量的值来确保我们读取数据时没有错误。

 err = json.Unmarshal(d, &user)
    if err != nil {
        log.Println(err)
        http.Error(w, "Error:", http.StatusBadRequest)
        return
    } 

在从客户端读取数据后,我们将其放入 user 全局变量——尽管使用全局变量不被视为最佳实践,但我个人更喜欢在处理相对较小的 Go 源代码文件时使用全局变量来存储重要的设置或需要共享的数据。数据存储的位置以及如何处理数据由服务器决定。没有关于如何解释数据的规则。因此,客户端应按照服务器的意愿与服务器通信

 if user.Username == "" {
        http.Error(w, "Error:", http.StatusBadRequest)
        return
    }
    DATA[user.Username] = user.Password
    log.Println(DATA)
    w.WriteHeader(http.StatusCreated)
} 

如果给定的 Username 字段不为空,则将新的结构添加到 DATA 映射中。对于这个示例服务器,没有实现数据持久化——每次重新启动 RESTful 服务器时,DATA 映射都会从头开始初始化。

如果 username 字段的值为空,则我们无法将其添加到 DATA 映射中,并且操作会以 http.StatusBadRequest 状态码失败。

func getHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host, r.Method)
    if r.Method != http.MethodGet {
        http.Error(w, "Error:", http.StatusMethodNotAllowed)
        fmt.Fprintf(w, "%s\n", "Method not allowed!")
        return
    } 

对于 /get 端点,我们需要使用 http.MethodGet,因此我们必须确保满足这个条件(if r.Method != http.MethodGet)。

 d, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "ReadAll - Error", http.StatusBadRequest)
        return
    } 

之后,我们仍然需要确保我们可以无问题地从客户端请求中读取数据。

 err = json.Unmarshal(d, &user)
    if err != nil {
        log.Println(err)
        http.Error(w, "Unmarshal - Error", http.StatusBadRequest)
        return
    }
    fmt.Println(user) 

然后,我们使用客户端数据并将其放入 User 结构(user 全局变量)。再次提醒,使用全局变量来存储数据是个人偏好,对于较小的源代码文件来说效果很好,但对于较大的程序应该避免使用。

 _, ok := DATA[user.Username]
    if ok && user.Username != "" {
        log.Println("Found!")
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, "%s\n", d) 

如果找到了所需的用户记录,我们使用存储在 d 变量中的数据将其发送回客户端——记住,dio.ReadAll(r.Body) 调用中初始化,并且已经包含了一个序列化的 JSON 记录。

 } else {
        log.Println("Not found!")
        w.WriteHeader(http.StatusNotFound)
        http.Error(w, "Map - Resource not found!", http.StatusNotFound)
    }
    return
} 

否则,我们通知客户所需的记录未找到,并返回 http.StatusNotFound

func deleteHandler(w http.ResponseWriter, r *http.Request) {
    log.Println("Serving:", r.URL.Path, "from", r.Host, r.Method)
    if r.Method != http.MethodDelete {
        fmt.Fprintf(w, "%s\n", "Method not allowed!")
        http.Error(w, "Error:", http.StatusMethodNotAllowed)
        return
    } 

当删除资源时,DELETE HTTP 方法看起来是一个合理的选择,因此有r.Method != http.MethodDelete的检查。

 d, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "ReadAll - Error", http.StatusBadRequest)
        return
    } 

再次,我们读取客户端输入并将其存储在d变量中。

 err = json.Unmarshal(d, &user)
    if err != nil {
        log.Println(err)
        http.Error(w, "Unmarshal - Error", http.StatusBadRequest)
        return
    }
    log.Println(user) 

在删除资源时保留额外的日志信息被认为是一种良好的做法。

 _, ok := DATA[user.Username]
    if ok && user.Username != "" {
        if user.Password == DATA[user.Username] { 

对于删除过程,我们在删除相关条目之前确保提供的用户名和密码值与DATA映射中存在的值相同。

 delete(DATA, user.Username)
            w.WriteHeader(http.StatusOK)
            fmt.Fprintf(w, "%s\n", d)
            log.Println(DATA)
        }
    } else {
        log.Println("User", user.Username, "Not found!")
        w.WriteHeader(http.StatusNotFound)
        http.Error(w, "Resource not found!", http.StatusNotFound)
    }
    return
}
func main() {
    arguments := os.Args
    if len(arguments) != 1 {
        PORT = ":" + arguments[1]
    } 

之前的代码展示了一种在拥有默认值的同时定义 Web 服务器 TCP 端口号的技术。因此,如果没有命令行参数,将使用默认值。否则,将使用作为命令行参数给出的值。

 mux := http.NewServeMux()
    s := &http.Server{
        Addr:         PORT,
        Handler:      mux,
        IdleTimeout:  10 * time.Second,
        ReadTimeout:  time.Second,
        WriteTimeout: time.Second,
    } 

上述代码块包含了 Web 服务器的详细信息及选项。

 mux.Handle("/time", http.HandlerFunc(timeHandler))
    mux.Handle("/add", http.HandlerFunc(addHandler))
    mux.Handle("/get", http.HandlerFunc(getHandler))
    mux.Handle("/delete", http.HandlerFunc(deleteHandler))
    mux.Handle("/", http.HandlerFunc(defaultHandler)) 

之前的代码定义了 Web 服务器的端点——在这里没有特别之处,因为 RESTful 服务器在幕后实现了一个 HTTP 服务器。

 fmt.Println("Ready to serve at", PORT)
    err := s.ListenAndServe()
    if err != nil {
        fmt.Println(err)
        return
    }
} 

最后一步是使用预定义的选项运行 Web 服务器,这是常见的做法。之后,我们使用curl(1)实用程序测试 RESTful 服务器,这在没有客户端且想要测试 RESTful 服务器操作时非常方便——好处是curl(1)可以发送和接收 JSON 数据。

当与 RESTful 服务器一起工作时,我们需要在curl(1)中添加-H 'Content-Type: application/json'来指定我们将使用 JSON 格式进行操作。-d选项用于向服务器传递数据,相当于--data选项,而-v选项在需要更多详细信息来理解正在发生的事情时会产生更详细的输出。

$ curl localhost:1234/
Thanks for visiting! 

与 RESTful 服务器第一次交互是为了确保服务器按预期工作。接下来的交互是为服务器添加新用户——用户的详细信息在{"user": "mtsouk", "password" : "admin"} JSON 记录中:

$ curl -H 'Content-Type: application/json' -d '{"user": "mtsouk", "password" : "admin"}' http://localhost:1234/add -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 1234 (#0) 

之前的输出显示curl(1)已成功连接到服务器(localhost),并使用所需的 TCP 端口(1234)。

> POST /add HTTP/1.1
> Host: localhost:1234
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 40 

之前的输出显示curl(1)将使用POST方法发送数据,数据长度为 40 字节。

>
< HTTP/1.1 200 OK
< Date: Sat, 28 Oct 2023 19:56:45 GMT
< Content-Length: 0 

之前的输出告诉我们数据已发送,并且服务器响应的主体为0字节。

<
* Connection #0 to host localhost left intact 

输出的最后部分告诉我们,在向服务器发送数据后,连接被关闭。

如果我们尝试添加相同的用户,RESTful 服务器不会抱怨:

$ curl -H 'Content-Type: application/json' -d '{"user": "mtsouk", "password" : "admin"}' http://localhost:1234/add 

虽然这种行为可能并不完美,但如果进行了文档记录,那么它是好的。在生产服务器上这是不允许的,但在实验时是可以接受的。因此,我们在这里偏离了标准做法,你不应该在生产环境中这样做。

$ curl -H 'Content-Type: application/json' -d '{"user": "mihalis", "password" : "admin"}' http://localhost:1234/add 

使用上述命令,我们添加了另一个用户,其详细信息由{"user": "mihalis", "password" : "admin"}指定。

$ curl -H -d '{"user": "admin"}' http://localhost:1234/add
curl: (3) URL using bad/illegal format or missing URL
Error:
Method not allowed! 

前面的输出显示了一个错误的交互,其中 -H 后面没有跟值。尽管请求已发送到服务器,但由于 /add 不使用默认的 HTTP 方法,它被拒绝。

$ curl -H 'Content-Type: application/json' -d '{"user": "admin", "password": "admin"}' http://localhost:1234/get
Error:
Method not allowed! 

这次,curl 命令是正确的,但使用的 HTTP 方法设置不正确。因此,请求没有被服务。

$ curl -X GET -H 'Content-Type: application/json' -d '{"user": "admin", "password" : "admin"}' http://localhost:1234/get
Map - Resource not found!
$ curl -X GET -H 'Content-Type: application/json' -d '{"user": "mtsouk", "password" : "admin"}' http://localhost:1234/get
{"user": "mtsouk", "password" : "admin"} 

前两个交互使用 /get 来获取现有用户的信息。然而,只找到了第二个用户。

$ curl -H 'Content-Type: application/json' -d '{"user": "mtsouk", "password" : "admin"}' http://localhost:1234/delete -X DELETE
{"user": "mtsouk", "password" : "admin"} 

最后一次交互成功删除了由 {"user": "mtsouk", "password" : "admin"} 指定的用户。

服务器进程为所有之前的交互生成的输出将如下所示:

$ go run rServer.go
Ready to serve at :1234
2023/10/28 22:56:36 Serving: / from localhost:1234
2023/10/28 22:56:45 Serving: /add from localhost:1234 POST
2023/10/28 22:56:45 map[mtsouk:admin]
2023/10/28 22:57:44 Serving: /add from localhost:1234 POST
2023/10/28 22:57:44 map[mtsouk:admin]
2023/10/28 22:59:29 Serving: /add from localhost:1234 POST
2023/10/28 22:59:29 map[mihalis:admin mtsouk:admin]
2023/10/28 22:59:47 Serving: /add from localhost:1234 GET
2023/10/28 23:00:08 Serving: /get from localhost:1234 POST
2023/10/28 23:00:17 Serving: /get from localhost:1234 GET
{admin admin}
2023/10/28 23:00:17 Not found!
2023/10/28 23:00:32 Serving: /get from localhost:1234 GET
{mtsouk admin}
2023/10/28 23:00:32 Found!
2023/10/28 23:00:45 Serving: /delete from localhost:1234 DELETE
2023/10/28 23:00:45 {mtsouk admin}
2023/10/28 23:00:45 map[mihalis:admin]
2023/10/28 23:00:45 After: map[mihalis:admin] 

到目前为止,我们已经有一个可以工作的 RESTful 服务器,它已经通过 curl(1) 工具进行了测试。下一节是关于为 RESTful 服务器开发命令行客户端。

一个 RESTful 客户端

本小节说明了之前开发的 RESTful 服务器的客户端开发。然而,在这种情况下,客户端充当测试程序,尝试 RESTful 服务器的功能——在本章的后面,你将学习如何使用 cobra 库编写正确的客户端。因此,可以在 rClient.go 中找到客户端的代码,如下所示:

package main
import (
    "bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type User struct {
    Username string `json:"user"`
    Password string `json:"password"`
} 

这种相同的结构在服务器实现中也可以找到,并用于数据交换。

var u1 = User{"admin", "admin"}
var u2 = User{"tsoukalos", "pass"}
var u3 = User{"", "pass"} 

在这里,我们预先定义了三个将要用于测试的 User 变量。

const addEndPoint = "/add"
const getEndPoint = "/get"
const deleteEndPoint = "/delete"
const timeEndPoint = "/time" 

前面的常量定义了将要使用的端点。

func deleteEndpoint(server string, user User) int {
    userMarshall, err := json.Marshal(user)
    if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusInternalServerError
    }
    u := bytes.NewReader(userMarshall)
    req, err := http.NewRequest(http.MethodDelete, server+deleteEndPoint, u) 

我们准备了一个请求,将使用 DELETE HTTP 方法访问 /delete

 if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusBadRequest
    }
    req.Header.Set("Content-Type", "application/json") 

这是指定在与服务器交互时使用 JSON 数据的正确方式。

 c := &http.Client{
        Timeout: 15 * time.Second,
    }
    resp, err := c.Do(req) 

然后,我们使用 Do() 方法并设置 15 秒的超时时间来发送请求并等待服务器响应。

 if err != nil {
        fmt.Println("Error:", err)
    }
    defer resp.Body.Close()
    if resp == nil {
        return http.StatusBadRequest
    }
    data, err := io.ReadAll(resp.Body)
    fmt.Print("/delete returned: ", string(data)) 

在这里放置 fmt.Print() 的原因是,即使交互中存在错误,我们也想了解服务器的响应。

 if err != nil {
        fmt.Println("Error:", err)
    }
    return resp.StatusCode
} 

resp.StatusCode 的值指定了 /delete 的响应 HTTP 状态码。

func getEndpoint(server string, user User) int {
    userMarshall, err := json.Marshal(user)
    if err != nil {
        fmt.Println("Error in unmarshalling: ", err)
        return http.StatusBadRequest
    }
    u := bytes.NewReader(userMarshall)
    req, err := http.NewRequest(http.MethodGet, server+getEndPoint, u) 

前面的代码将使用 GET HTTP 方法访问 /get

 if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusBadRequest
    }
    req.Header.Set("Content-Type", "application/json") 

我们使用 Header.Set() 指定我们将使用 JSON 格式与服务器交互。

 c := &http.Client{
        Timeout: 15 * time.Second,
    } 

前面的语句为 HTTP 客户端定义了一个超时时间,以防服务器响应过于繁忙。

 resp, err := c.Do(req)
    if err != nil {
        fmt.Println("Error:", err)
    }
    defer resp.Body.Close()
    if resp == nil {
        return resp.StatusCode
    } 

前面的代码使用 c.Do(req) 将客户端请求发送到服务器,并将服务器响应保存到 resp 中,将错误值保存到 err 中。如果 resp 的值为 nil,则表示服务器响应为空,这是一个错误条件。

 data, err := io.ReadAll(resp.Body)
    fmt.Print("/get returned: ", string(data))
    if err != nil {
        fmt.Println("Error:", err)
    }
    return resp.StatusCode
} 

resp.StatusCode 的值,由 RESTful 服务器指定和传递,决定了交互在 HTTP 意义上(逻辑上)是否成功。

func addEndpoint(server string, user User) int {
    userMarshall, err := json.Marshal(user)
    if err != nil {
        fmt.Println("Error in unmarshalling: ", err)
        return http.StatusBadRequest
    }
    u := bytes.NewReader(userMarshall)
    req, err := http.NewRequest("POST", server+addEndPoint, u) 

我们将使用 POST HTTP 方法访问 /add。我们可以使用 http.MethodPost 而不是 POST。如本章前面所述,http 中存在用于剩余 HTTP 方法的相关全局变量(http.MethodGethttp.MethodDeletehttp.MethodPut 等),并且建议我们使用它们以提高可移植性。

 if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusBadRequest
    }
    req.Header.Set("Content-Type", "application/json") 

如前所述,我们指定我们将使用 JSON 格式与服务器交互。

 c := &http.Client{
        Timeout: 15 * time.Second,
    } 

再次强调,我们为客户端定义了一个超时时间,以防服务器响应过于繁忙。

 resp, err := c.Do(req)
     if resp == nil || (resp.StatusCode == http.StatusNotFound) {
        return resp.StatusCode
    }
    defer resp.Body.Close()
    return resp.StatusCode
} 

addEndpoint() 函数用于使用 POST 方法测试 /add 端点。

func timeEndpoint(server string) (int, string) {
    req, err := http.NewRequest(http.MethodPost, server+timeEndPoint, nil) 

我们将使用 POST HTTP 方法访问 /time 端点。

 if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusBadRequest, ""
    }
    c := &http.Client{
        Timeout: 15 * time.Second,
    } 

如前所述,我们为客户端定义了一个超时时间,以防服务器响应过于繁忙。

 resp, err := c.Do(req)
    if resp == nil || (resp.StatusCode == http.StatusNotFound) {
        return resp.StatusCode, ""
    }
    defer resp.Body.Close()
    data, _ := io.ReadAll(resp.Body)
    return resp.StatusCode, string(data)
} 

timeEndpoint() 函数用于测试 /time 端点——请注意,此端点不需要从客户端获取任何数据,因此客户端请求为空。服务器将返回一个包含服务器当前时间和日期的字符串。

func slashEndpoint(server, URL string) (int, string) {
    req, err := http.NewRequest(MethodPost, server+URL, nil) 

我们将使用 POST HTTP 方法访问 /

 if err != nil {
        fmt.Println("Error in req: ", err)
        return http.StatusBadRequest, ""
    }
    c := &http.Client{
        Timeout: 15 * time.Second,
    } 

在服务器响应延迟的情况下,在客户端设置超时时间被认为是一种良好的实践。

 resp, err := c.Do(req)
    if resp == nil {
        return resp.StatusCode, ""
    }
    defer resp.Body.Close()
    data, _ := io.ReadAll(resp.Body)
    return resp.StatusCode, string(data)
} 

slashEndpoint() 函数用于测试服务器中的默认端点——请注意,此端点不需要从客户端获取任何数据。

下一步是实现 main() 函数,它使用所有前面的函数来访问 RESTful 服务器端点:

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Wrong number of arguments!")
        fmt.Println("Need: Server URL")
        return
    }
    server := os.Args[1] 

server 变量包含将要使用的服务器地址和端口号。

 fmt.Println("/add")
    httpCode := addEndpoint(server, u1)
    if HTTPcode != http.StatusOK {
        fmt.Println("u1 Return code:", httpCode)
    } else {
        fmt.Println("u1 Data added:", u1, httpCode)
    }
    httpCode = addEndpoint(server, u2)
    if httpCode != http.StatusOK {
        fmt.Println("u2 Return code:", httpCode)
    } else {
        fmt.Println("u2 Data added:", u2, httpCode)
    }
    httpCode = addEndpoint(server, u3)
    if httpCode != http.StatusOK {
        fmt.Println("u3 Return code:", httpCode)
    } else {
        fmt.Println("u3 Data added:", u3, httpCode)
    } 

所有的前一部分代码都是用于使用各种类型的数据测试 /add 端点。

 fmt.Println("/get")
    httpCode = getEndpoint(server, u1)
    fmt.Println("/get u1 return code:", httpCode)
    httpCode = getEndpoint(server, u2)
    fmt.Println("/get u2 return code:", httpCode)
    httpCode = getEndpoint(server, u3)
    fmt.Println("/get u3 return code:", httpCode) 

所有的前一部分代码都是用于使用各种类型的输入测试 /get 端点。我们只测试返回代码,因为 HTTP 状态码指定了操作的成功或失败。

 fmt.Println("/delete")
    httpCode = deleteEndpoint(server, u1)
    fmt.Println("/delete u1 return code:", httpCode)
    httpCode = deleteEndpoint(server, u1)
    fmt.Println("/delete u1 return code:", httpCode)
    httpCode = deleteEndpoint(server, u2)
    fmt.Println("/delete u2 return code:", httpCode)
    httpCode = deleteEndpoint(server, u3)
    fmt.Println("/delete u3 return code:", httpCode) 

所有的前一部分代码都是用于使用各种类型的输入测试 /delete 端点。再次,我们打印交互的 HTTP 状态码,因为 HTTP 状态码的值指定了客户端请求的成功或失败。

 fmt.Println("/time")
    httpCode, myTime := timeEndpoint(server)
    fmt.Print("/time returned: ", httpCode, " ", myTime)
    time.Sleep(time.Second)
    httpCode, myTime = timeEndpoint(server)
    fmt.Print("/time returned: ", httpCode, " ", myTime) 

前面的代码测试了 /time 端点——它打印了 HTTP 状态码以及服务器响应的其余部分。

 fmt.Println("/")
    URL := "/"
    httpCode, response := slashEndpoint(server, URL)
    fmt.Print("/ returned: ", httpCode, " with response: ", response)
    fmt.Println("/what")
    URL = "/what"
    httpCode, response = slashEndpoint(server, URL)
    fmt.Print(URL, " returned: ", httpCode, " with response: ", response)
} 

程序的最后部分尝试连接到一个不存在的端点,以验证默认处理函数的正确操作。

运行 rClient.go 并与 rServer.go 交互会产生以下类型的输出:

$ go run rClient.go http://localhost:1234
/add
u1 Data added: {admin admin} 200
u2 Data added: {tsoukalos pass} 200
u3 Return code: 400 

前一部分与 /add 端点的测试相关。前两个用户成功添加,而第三个用户(var u3 = User{"", "pass"})没有添加,因为它不包含所有必需的信息。

/get
/get returned: {"user":"admin","password":"admin"}
/get u1 return code: 200
/get returned: {"user":"tsoukalos","password":"pass"}
/get u2 return code: 200
/get returned: Map - Resource not found!
/get u3 return code: 404 

前一部分与 /get 端点的测试相关。用户名为 admintsoukalos 的前两个用户的数据成功返回,而存储在 u3 变量中的用户未找到。

/delete
/delete returned: {"user":"admin","password":"admin"}
/delete u1 return code: 200
/delete returned: Delete - Resource not found!
/delete u1 return code: 404
/delete returned: {"user":"tsoukalos","password":"pass"}
/delete u2 return code: 200
/delete returned: Delete - Resource not found!
/delete u3 return code: 404 

之前的输出与/delete端点的测试相关。admintsoukalos用户已被删除。然而,尝试第二次删除admin失败了。

/time
/time returned: 200 The current time is: Sat, 28 Oct 2023 23:03:39 EEST
/time returned: 200 The current time is: Sat, 28 Oct 2023 23:03:40 EEST 

同样,前一部分与/time端点的测试相关。

/
/ returned: 404 with response: Thanks for visiting!
/what
/what returned: 404 swith response: Thanks for visiting! 

输出的最后一部分与默认处理器的操作相关。

重要的是要意识到rClient.go可以成功与支持相同端点的每个 RESTful 服务器进行交互,而无需了解 RESTful 服务器的实现细节。

到目前为止,RESTful 服务器和客户端可以相互交互。然而,它们都没有执行真正的任务。下一节将展示如何使用gorilla/mux和数据库后端来存储数据开发一个真实的 RESTful 服务器。

创建功能性的 RESTful 服务器

本节说明了在给定 REST API 的情况下如何使用 Go 开发 RESTful 服务器。所提供的 RESTful 服务与第九章中创建的统计应用程序之间最大的区别是,RESTful 服务使用 JSON 消息与其客户端进行交互,而统计应用程序通过纯文本消息进行交互和工作。

如果你打算使用net/http来实现 RESTful 服务器,请在考虑你的服务器需求之前不要这样做!此实现使用gorilla/mux包,这是一个更好的选择,因为它支持子路由——更多关于这一点在使用 gorilla/mux子节中。然而,net/http仍然非常强大,并且对于许多 REST 需求可能很有用。

RESTful 服务器的目的是实现一个登录/认证系统。登录系统的目的是跟踪已登录的用户以及他们的权限。该系统附带一个名为admin的默认管理员用户——默认密码也是admin,你应该更改它。应用程序将其数据存储在 SQLite3 数据库中,这意味着如果你重新启动它,现有用户列表将从该数据库读取,而不会丢失。

REST API

应用程序的 API 有助于你实现你心中的功能。然而,这是一项客户端的工作,而不是服务器的工作。服务器的工作是通过通过适当定义和实现的 REST API 支持简单但完全工作的功能,尽可能多地促进其客户端的工作。在尝试开发和使用 RESTful 服务器之前,请确保你理解这一点。

我们将定义将要使用的端点、将要返回的 HTTP 代码以及允许的方法或方法。基于 REST API 创建用于生产的 RESTful 服务器是一项严肃的工作,不应轻率对待。创建原型来测试和验证你的想法和设计将使你在长期内节省大量时间。始终从原型开始。

支持的端点以及支持的 HTTP 方法和参数如下:

  • /: 这用于捕获和提供所有不匹配的内容。此端点与所有 HTTP 方法兼容。

  • /getall: 这用于获取数据库的全部内容。使用此功能需要具有管理权限的用户。此端点可能返回多个 JSON 记录,并支持GET HTTP 方法。

  • /getid/username: 这用于获取通过用户名识别的用户 ID,该 ID 被传递到端点。此命令应由具有管理权限的用户发出,并支持GET HTTP 方法。

  • /username/ID: 这用于根据所使用的 HTTP 方法删除或获取 ID 等于ID的用户的信息。因此,将要执行的实际操作取决于所使用的 HTTP 方法。DELETE方法删除用户,而GET方法返回用户信息。此端点应由具有管理权限的用户发出。

  • /logged: 这用于获取所有已登录用户的列表。此端点可能返回多个 JSON 记录,并需要使用GET HTTP 方法。

  • /update: 这用于更新用户的用户名、密码或管理员状态——数据库中用户的 ID 保持不变。此端点仅支持PUT HTTP 方法,并且对用户的搜索基于用户名。

  • /login: 这用于根据用户名和密码将用户登录到系统中。此端点支持POST HTTP 方法。

  • /logout: 这用于根据用户名和密码注销用户。此端点支持POST HTTP 方法。

  • /add: 这用于将新用户添加到数据库中。此端点支持POST HTTP 方法,并由具有管理权限的用户发出。

  • /time: 这是一个主要用于测试目的的端点。它是唯一一个不与 JSON 数据兼容、不需要有效账户且与所有 HTTP 方法兼容的端点。

现在,让我们讨论gorilla/mux包的功能和功能。

使用 gorilla/mux

gorilla/mux包(github.com/gorilla/mux)是默认 Go 路由器的流行且强大的替代品,允许您将传入请求与相应的处理程序匹配。尽管默认 Go 路由器(http.ServeMux)和mux.Routergorilla/mux路由器)之间存在许多差异,但主要区别在于mux.Router在匹配路由与处理函数时支持多个条件。这意味着您可以用更少的代码处理一些选项,例如使用的 HTTP 方法。

让我们先通过一些匹配示例开始——默认 Go 路由器不支持此功能:

  • r.HandleFunc("/url", UrlHandlerFunction): 前一个命令会在每次访问 /url 时调用 UrlHandlerFunction 函数。

  • r.HandleFunc("/url", UrlHandlerFunction).Methods(http.MethodPut): 这个例子展示了如何告诉 Gorilla 匹配特定的 HTTP 方法(在这个例子中是 PUT,它通过使用 http.MethodPut 定义),这可以节省你手动编写代码来执行此操作。

  • mux.NotFoundHandler = http.HandlerFunc(handlers.DefaultHandler): 使用 Gorilla,匹配任何其他路径都不匹配的内容的正确方法是通过使用 mux.NotFoundHandler

  • mux.MethodNotAllowedHandler = notAllowed: 如果一个方法对于现有路由不被允许,它将通过 MethodNotAllowedHandler 来处理。这是 gorilla/mux 的特定功能。

  • s.HandleFunc("/users/{id:[0-9]+}"), HandlerFunction): 这个最后的例子表明,你可以使用名称(id)和模式在路径中定义一个变量——Gorilla 会为你进行匹配!如果没有正则表达式,那么匹配将从路径中的第一个斜杠开始到下一个斜杠之间的任何内容。

现在,让我们来谈谈 gorilla/mux 的另一个功能,即子路由器。

子路由器的使用

服务器实现使用了子路由器。一个 子路由器 是一个嵌套路由,只有当父路由与子路由器的参数匹配时,才会检查它是否有潜在的匹配。好处是父路由可以包含所有在子路由器下定义的路径的共同条件,包括主机、路径前缀,以及在我们的案例中,HTTP 请求方法。因此,我们的子路由器是根据后续端点的公共请求方法来划分的。这不仅优化了请求匹配,还使得代码结构更容易理解。

例如,用于 DELETE HTTP 方法的子路由器就像以下这样简单:

deleteMux := mux.Methods(http.MethodDelete).Subrouter()
deleteMux.HandleFunc("/username/{id:[0-9]+}", handlers.DeleteHandler) 

第一条语句用于定义子路由器的公共特性,在这个例子中是 http.MethodDelete HTTP 方法,而剩下的语句,在这个例子中是 deleteMux.HandleFunc(...),用于定义支持的路径。

是的,gorilla/mux 可能比默认的 Go 路由器更难使用,但到目前为止,你应该已经理解了 gorilla/mux 包在处理 HTTP 服务时的好处。

下一个子节简要介绍了 Gin 框架,它是对 gorilla/mux 包的替代方案。

Gin HTTP 框架

Gin 是一个用 Go 编写的开源 Web 框架,可以帮助你编写强大的 HTTP 服务。Gin 的 GitHub 仓库可以在https://github.com/gin-gonic/gin找到。Gin 使用httprouter作为其 HTTP 路由器,因为httprouter针对高性能和低内存使用进行了优化。httprouter是一个 HTTP 路由器,就像net/http包的默认mux或更高级的gorilla/mux。你可以在github.com/julienschmidt/httprouterpkg.go.dev/github.com/julienschmidt/httprouter了解更多关于它的信息。

Gin 与 Gorilla 的比较

Gin 与gorilla/mux之间最大的区别在于gorilla/mux仅仅是一个 HTTP 路由器,没有其他功能,而 Gin 可以做到gorilla/mux所能做到的,包括 JSON 序列化和反序列化、验证、自定义响应写入等。简单来说,Gin 能做的事情比gorilla/mux多得多。在实践中,你可以将 Gin 视为一个比gorilla/mux更高级的框架,具有更多功能。

因此,这里的问题是你应该选择哪一个。从gorilla/mux开始尝试并不坏。如果gorilla/mux不能完成你的工作,那么你肯定需要使用 Gin。简单来说,如果性能至关重要,如果你需要中间件支持,或者如果你想要最小化和快速的路由,那么请使用 Gin。

与数据库交互

在本小节中,我们将向您展示我们如何与支持 RESTful 服务器功能的 SQLite 数据库进行交互。相关文件是./server/restdb.go

RESTful 服务器本身对 SQLite 数据库一无所知。所有相关功能都保存在restdb.go文件中,这意味着如果你更改数据库,处理函数不需要知道这一点。

数据库名称、数据库表和admin用户是通过下一个create_db.sql SQL 文件创建的:

DROP TABLE IF EXISTS users;
CREATE TABLE users (
    UserID INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    password TEXT NOT NULL,
    lastlogin INTEGER,
    admin INTEGER,
    active INTEGER
);
INSERT INTO users (username, password, lastlogin, admin, active) VALUES ('admin', 'admin', 1620922454, 1, 1); 

你可以使用create_db.sql如下:

$ sqlite3 REST.db
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite> .read create_db.sql 

我们可以验证users表已创建并包含所需的条目,如下所示:

$ sqlite3 REST.db
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE users (
    UserID INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    password TEXT NOT NULL,
    lastlogin INTEGER,
    admin INTEGER,
    active INTEGER
);
sqlite> select * from users;
1|admin|admin|1620922454|1|1 

我们将介绍最重要的数据库相关函数,从OpenConnection()开始:

func OpenConnection() *sql.DB {
    db, err := sql.Open("sqlite3", Filename)
    if err != nil {
        fmt.Println("Error connecting:", err)
        return nil
    }
    return db
} 

由于我们需要始终与 SQLite3 进行交互,我们创建了一个辅助函数,该函数返回一个*sql.DB变量,这是一个打开的 SQLite3 连接。Filename是一个全局变量,指定了将要使用的 SQLite3 数据库文件。

接下来,我们介绍DeleteUser()函数:

func DeleteUser(ID int) bool {
    db := OpenConnection()
    if db == nil {
        log.Println("Cannot connect to SQLite3!")
        return false
    }
    defer db.Close() 

上一段代码展示了我们如何使用OpenConnection()来获取数据库连接并进行操作。

 t := FindUserID(ID)
    if t.ID == 0 {
        log.Println("User", ID, "does not exist.")
        return false
    } 

在这里,我们使用FindUserID()辅助函数来确保具有给定用户 ID 的用户存在于数据库中。如果用户不存在,函数将停止并返回false

 stmt, err := db.Prepare("DELETE FROM users WHERE UserID = $1")
    if err != nil {
        log.Println("DeleteUser:", err)
        return false
    } 

这是删除用户的实际 SQL 语句。我们使用 Prepare() 来构建所需的 SQL 语句,然后使用 Exec() 执行。Prepare() 中的 $1 表示将在 Exec() 中给出的参数。如果我们想有更多参数,我们应该将它们命名为 $2$3 等。

 _, err = stmt.Exec(ID)
    if err != nil {
        log.Println("DeleteUser:", err)
        return false
    }
    return true
} 

这就是 DeleteUser() 函数的实现结束的地方。执行 stmt.Exec(ID) 语句会从数据库中删除用户。

下一个展示的 ListAllUsers() 函数返回一个 User 元素切片,它包含在 RESTful 服务器中找到的所有用户:

func ListAllUsers() []User {
    db := OpenConnection()
    if db == nil {
        fmt.Println("Cannot connect to SQLite3!")
        return []User{}
    }
    defer db.Close()
    rows, err := db.Query("SELECT * FROM users \n")
    if err != nil {
        log.Println(err)
        return []User{}
    } 

由于 SELECT 查询不需要参数,我们使用 Query() 来运行它,而不是 Prepare()Exec()。请注意,这很可能是一个返回多条记录的查询。

 all := []User{}
    var c1 int
var c2, c3 string
var c4 int64
var c5, c6 int
for rows.Next() {
        err = rows.Scan(&c1, &c2, &c3, &c4, &c5, &c6)
        if err != nil {
            log.Println(err)
            return []User{}
        } 

这是我们从 SQL 查询返回的单个记录中读取值的方式。首先,我们为每个返回值定义多个变量,然后将它们的指针传递给 Scan()。只要数据库中有新的结果,rows.Next() 方法就会继续返回记录。

 temp := User{c1, c2, c3, c4, c5, c6}
        all = append(all, temp)
    }
    log.Println("All:", all)
    return all
} 

因此,正如之前提到的,ListAllUsers()User 结构体切片中返回。

最后,我们将展示 IsUserValid() 的实现:

func IsUserValid(u User) bool {
    db := OpenConnection()
    if db == nil {
        fmt.Println("Cannot connect to SQLite3!")
        return false
    }
    defer db.Close() 

这是一个常见的模式:我们调用 OpenConnection() 并等待获取一个连接来使用,然后再继续。

 rows, err := db.Query("SELECT * FROM users WHERE username = $1 \n", u.Username)
    if err != nil {
        log.Println(err)
        return false
    } 

在这里,我们直接将参数传递给 Query(),而没有使用 Prepare()Exec()

 temp := User{}
    var c1 int
var c2, c3 string
var c4 int64
var c5, c6 int 

接下来,我们创建所需的参数以保留 SQL 查询的返回值。

 // If there exist multiple users with the same username,
// we will get the FIRST ONE only.
for rows.Next() {
        err = rows.Scan(&c1, &c2, &c3, &c4, &c5, &c6)
        if err != nil {
            log.Println(err)
            return false
        }
        temp = User{c1, c2, c3, c4, c5, c6}
    } 

再次强调,for 循环会一直运行,直到 rows.Next() 返回新的记录。

 if u.Username == temp.Username && u.Password == temp.Password {
        return true
    } 

这是一个重要的观点:不仅给定的用户必须存在,而且给定的密码必须与数据库中存储的给定用户的密码相同,才能使用户有效。

 return false
} 

你可以自己查看 restdb.go 的其余源代码。大多数函数与这里展示的类似。restdb.go 的代码将被用于实现接下来展示的 RESTful 服务器。

实现 RESTful 服务器

现在,我们准备开始解释 RESTful 服务器的实现。服务器代码分为三个属于 main 包的文件。因此,除了 restdb.go 之外,我们还有 main.gohandlers.go

这样做的主要原因是不必处理巨大的源代码文件,并且从逻辑上分离服务器的功能。

main.go 中最重要的部分属于 main() 函数,如下所示:

 rMux.NotFoundHandler = http.HandlerFunc(DefaultHandler) 

因此,我们定义了默认的处理函数。尽管这不是必需的,但拥有这样一个处理函数是一种良好的实践。

 notAllowed := notAllowedHandler{}
    rMux.MethodNotAllowedHandler = notAllowed 

当你尝试使用不支持的 HTTP 方法访问端点时,会执行 MethodNotAllowedHandler 处理器。该处理器的实际实现位于 handlers.go 文件中。

 rMux.HandleFunc("/time", TimeHandler) 

/time 端点支持所有 HTTP 方法,因此它不属于任何子路由器。

 // Define Handler Functions
// Register GET
    getMux := rMux.Methods(http.MethodGet).Subrouter()
    getMux.HandleFunc("/getall", GetAllHandler)
    getMux.HandleFunc("/getid/{username}", GetIDHandler)
    getMux.HandleFunc("/logged", LoggedUsersHandler)
    getMux.HandleFunc("/username/{id:[0-9]+}", GetUserDataHandler) 

首先,我们定义了一个用于 GET HTTP 方法的子路由器,以及支持的端点。记住,gorilla/mux 负责确保只有 GET 请求将通过 getMux 子路由器提供服务。

 // Register PUT
// Update User
    putMux := rMux.Methods(http.MethodPut).Subrouter()
    putMux.HandleFunc("/update", UpdateHandler) 

之后,我们定义了一个用于 PUT 请求的子路由器。

 // Register POST
// Add User + Login + Logout
    postMux := rMux.Methods(http.MethodPost).Subrouter()
    postMux.HandleFunc("/add", AddHandler)
    postMux.HandleFunc("/login", LoginHandler)
    postMux.HandleFunc("/logout", LogoutHandler) 

然后,我们定义了用于 POST 请求的子路由器。

 // Register DELETE
// Delete User
    deleteMux := rMux.Methods(http.MethodDelete).Subrouter()
    deleteMux.HandleFunc("/username/{id:[0-9]+}", DeleteHandler) 

最后一个子路由器用于 DELETE HTTP 方法。gorilla/mux 中的代码负责根据客户端请求的详细信息选择正确的子路由器。

 go func() {
        log.Println("Listening to", PORT)
        err := s.ListenAndServe()
        if err != nil {
            log.Printf("Error starting server: %s\n", err)
            return
        }
    }() 

HTTP 服务器作为 goroutine 执行,因为程序支持信号处理——有关更多详细信息,请参阅 第八章Go 并发

 sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, os.Interrupt)
    sig := <-sigs
    log.Println("Quitting after signal:", sig)
    time.Sleep(5 * time.Second)
    s.Shutdown(nil) 

最后,我们添加了信号处理,以便优雅地终止 HTTP 服务器。sig := <-sigs 语句防止 main() 函数在没有接收到 os.Interrupt 信号的情况下退出。

handlers.go 文件包含了处理函数的实现,也是 main 包的一部分——其最重要的部分如下:

// AddHandler is for adding a new user
func AddHandler(rw http.ResponseWriter, r *http.Request) {
    log.Println("AddHandler Serving:", r.URL.Path, "from", r.Host)
    d, err := io.ReadAll(r.Body)
    if err != nil {
        rw.WriteHeader(http.StatusBadRequest)
        log.Println(err)
        return
    } 

此处理程序用于 /add 端点。服务器使用 io.ReadAll() 读取客户端输入,并确保 io.ReadAll() 调用成功。

 if len(d) == 0 {
        rw.WriteHeader(http.StatusBadRequest)
        log.Println("No input!")
        return
    } 

然后,代码确保客户端请求的正文不为空。

 // We read two structures as an array:
// 1\. The user issuing the command
// 2\. The user to be added
    users := []User{}
    err = json.Unmarshal(d, &users)
    if err != nil {
        log.Println(err)
        rw.WriteHeader(http.StatusBadRequest)
        return
    } 

由于 /add 端点需要两个 User 结构体,之前的代码使用 json.Unmarshal() 将它们放入 []User 变量中——这意味着客户端应该使用数组发送这两个 JSON 记录。

 log.Println(users)
    if !IsUserAdmin(users[0]) {
        log.Println("Issued by non-admin user:", users[0].Username)
        rw.WriteHeader(http.StatusBadRequest)
        return
    } 

如果执行命令的用户没有管理权限,则请求失败。IsUserAdmin()restdb.go 中实现,因为它与数据库中存储的数据有关。

 result := InsertUser(users[1])
    if !result {
        rw.WriteHeader(http.StatusBadRequest)
    }
} 

否则,InsertUser() 将所需用户插入到数据库中。

最后,我们展示了 /getall 端点的处理程序。

// GetAllHandler is for getting all data from the user database
func GetAllHandler(rw http.ResponseWriter, r *http.Request) {
    log.Println("GetAllHandler Serving:", r.URL.Path, "from", r.Host)
    d, err := io.ReadAll(r.Body)
    if err != nil {
        rw.WriteHeader(http.StatusBadRequest)
        log.Println(err)
        return
    } 

再次强调,我们使用 io.ReadAll(r.Body) 从客户端读取数据,并通过检查 err 变量确保整个过程没有错误。

 if len(d) == 0 {
        rw.WriteHeader(http.StatusBadRequest)
        log.Println("No input!")
        return
    }
    user := User{}
    err = json.Unmarshal(d, &user)
    if err != nil {
        log.Println(err)
        rw.WriteHeader(http.StatusBadRequest)
        return
    } 

在这里,我们将客户端数据放入 User 变量中。/getall 端点需要一个单独的 User 记录作为输入。

 if !IsUserAdmin(user) {
        log.Println("User", user, "is not an admin!")
        rw.WriteHeader(http.StatusBadRequest)
        return
    } 

只有管理员用户可以访问 /getall 并获取所有用户的列表,因此使用了 IsUserAdmin()

 err = SliceToJSON(ListAllUsers(), rw)
    if err != nil {
        log.Println(err)
        rw.WriteHeader(http.StatusBadRequest)
        return
    }
} 

代码的最后部分是关于从数据库获取所需数据,并使用 SliceToJSON(ListAllUsers(), rw) 调用将其发送到客户端。

随意将每个处理程序放入单独的 Go 文件中。一般思路是,如果你有很多处理函数,为每个处理函数使用单独的文件是一种良好的实践。除此之外,它还允许多个开发者同时在不打扰彼此的情况下工作多个处理函数。

在开发合适的命令行客户端之前,使用 curl(1) 测试 RESTful 服务器是一个好主意。

测试 RESTful 服务器

本小节展示了如何使用curl(1)实用程序测试 RESTful 服务器。您应该尽可能多地测试 RESTful 服务器,以查找错误或不受欢迎的行为。由于我们使用三个文件来实现服务器,我们需要以go run main.go restdb.go handlers.go的方式运行它。我们首先测试/time处理器,它适用于所有 HTTP 方法:

$ curl localhost:1234/time
The current time is: Mon, 30 Oct 2023 19:38:21 EET 

接下来,我们测试默认处理器:

$ curl localhost:1234/
/ is not supported. Thanks for visiting!
$ curl localhost:1234/doesNotExist
/doesNotExist is not supported. Thanks for visiting! 

最后,我们看看如果我们使用不支持 HTTP 方法与支持端点会发生什么——在这种情况下,仅支持GET/getall端点:

$ curl -s -X PUT -H 'Content-Type: application/json' localhost:1234/getall
Method not allowed! 

虽然/getall端点需要有效的用户才能操作,但我们使用的不受该端点支持的 HTTP 方法具有优先权,因此调用失败,这是正确的失败原因。

在测试过程中,重要的是要查看 RESTful 服务器的输出以及它生成的日志条目。并非所有信息都可以发送回客户端,但服务器进程允许打印任何我们想要的内容。这可以非常有助于调试像我们的 RESTful 服务器这样的服务器进程。

下一小节测试所有支持GET HTTP 方法的处理器。

测试 GET 处理器

首先,我们测试/getall端点——您的输出可能取决于 SQLite 数据库的内容:

$ curl -s -X GET -H 'Content-Type: application/json' -d '{"username": "admin", "password" : "justChanged"}' localhost:1234/getall
[{"id":1,"username":"admin","password":"justChanged","lastlogin":1620922454,"admin":1,"active":1},{"id":2,"username":"","password":"admin","lastlogin":0,"admin":0,"active":0},{"id":3,"username":"mihalis","password":"admin","lastlogin":0,"admin":0,"active":0},{"id":4,"username":"newUser","password":"aPass","lastlogin":0,"admin":0,"active":0}] 

之前的输出是数据库中找到的所有现有用户的列表,以 JSON 格式呈现。您始终可以使用jq(1)实用程序处理生成的输出,以获得更美观的输出。

然后,我们测试/logged端点:

$ curl -X GET -H 'Content-Type: application/json' -d '{"username": "admin", "password" : "justChanged"}' localhost:1234/logged
[{"id":1,"username":"admin","password":"justChanged","lastlogin":1620922454,"admin":1,"active":1}] 

然后,我们测试/username/{id}端点:

$ curl -X GET -H 'Content-Type: application/json' -d '{"username": "admin", "password" : "justChanged"}' localhost:1234/username/3
{"id":3,"username":"mihalis","password":"admin","lastlogin":0,"admin":0,"active":0} 

最后,我们测试/getid/{username}端点:

$ curl -X GET -H 'Content-Type: application/json' -d '{"username": "admin", "password" : "justChanged"}' localhost:1234/getid/mihalis
{"id":3,"username":"mihalis","password":"admin","lastlogin":0,"admin":0,"active":0} 

因此,用户mihalis的用户 ID 为3

到目前为止,我们可以获取现有用户列表和已登录用户列表,并获取特定用户的信息——所有这些端点都使用GET方法。下一小节将测试所有支持POST方法的处理器。

测试 POST 处理器

首先,我们通过添加没有管理员权限的packt用户来测试/add端点:

$ curl -X POST -H 'Content-Type: application/json' -d '[{"username": "admin", "password" : "justChanged", "admin":1}, {"username": "packt", "password" : "admin", "admin":0} ]' localhost:1234/add 

之前的调用向服务器传递了一个包含两个 JSON 记录的数组。第二个记录包含了packt用户的详细信息。该命令由admin用户发出,该用户由第一个 JSON 记录中的数据指定。

如果我们尝试多次添加相同的用户名,过程将会失败——这可以通过在curl(1)命令中使用-v来揭示。相关的错误信息将是HTTP/1.1 400 Bad Request

此外,如果我们尝试使用非管理员用户的凭据添加新用户,服务器将生成由非管理员用户发出的命令:packt消息。

接下来,我们测试/login端点:

$ curl -X POST -H 'Content-Type: application/json' -d '{"username": "packt", "password" : "admin"}' localhost:1234/login 

之前的命令用于登录packt用户。

最后,我们测试/logout端点:

$ curl -X POST -H 'Content-Type: application/json' -d '{"username": "packt", "password" : "admin"}' localhost:1234/logout 

之前的命令用于注销packt用户。您可以使用/logged端点来验证前两个交互的结果。

现在,让我们测试唯一支持PUT HTTP 方法的端点。

测试 PUT 处理器

首先,我们按照以下方式测试/update端点:

$ curl -X PUT -H 'Content-Type: application/json' -d '[{"username": "admin", "password" : "admin", "admin":1}, {"username": "admin", "password" : "justChanged", "admin":1} ]' localhost:1234/update 

上一条命令将admin用户的密码从admin更改为justChanged

然后,我们尝试使用非管理员用户(packt)的凭据更改用户密码:

$ curl -X PUT -H 'Content-Type: application/json' -d '[{"Username":"packt","Password":"admin"}, {"username": "admin", "password" : "justChanged", "admin":1} ]' localhost:1234/update 

生成的日志消息是Command issued by non-admin user: packt

我们可能会考虑这样一个事实,即非管理员用户甚至无法更改自己的密码,这是一个缺陷——可能如此,但这是 RESTful 服务器实现的方式。其理念是非管理员用户不应直接发出危险命令。此外,这个缺陷可以很容易地修复,如下所示:一般来说,普通用户不会以这种方式与服务器交互,而是会提供一个用于此目的的 Web 界面。之后,管理员用户可以将用户请求发送到服务器。因此,这可以以不同的方式实现,更加安全,并且不会给普通用户不必要的权限。

最后,我们将测试DELETE HTTP 方法。

测试 DELETE 处理器

对于DELETE HTTP 方法,我们需要测试/username/{id}端点。由于此端点不返回任何输出,使用curl(1)中的-v选项将揭示返回的 HTTP 状态码:

$ curl -X DELETE -H 'Content-Type: application/json' -d '{"username": "admin", "password" : "justChanged"}' localhost:1234/username/4 -v 

HTTP/1.1 200 OK状态码验证用户已被成功删除。如果我们尝试再次删除同一用户,请求将失败,并返回消息HTTP/1.1 404 Not Found

到目前为止,我们知道 RESTful 服务器按预期工作。然而,curl(1)在日常使用 RESTful 服务器时远非完美。下一节将展示如何为 RESTful 服务器开发命令行客户端。

创建 RESTful 客户端

创建 RESTful 客户端比编写服务器程序更容易,主要是因为你不需要在客户端与数据库交互。客户端需要做的唯一事情是向服务器发送正确数量和类型的数据,并接收并解释服务器的响应。完整的 RESTful 客户端实现可以在 GitHub 仓库的书籍./ch11/client中找到。

支持的第一级 cobra 命令如下:

  • list: 此命令访问/getall端点并返回所有可用用户的列表。

  • time: 此命令用于访问/time端点。

  • update: 此命令用于更新用户记录——用户 ID 不能更改。

  • logged: 此命令列出所有已登录用户。

  • delete: 此命令删除现有用户。

  • login: 此命令用于登录用户。

  • logout: 此命令用于注销用户。

  • add: 此命令用于将新用户添加到系统中。

  • getid: 此命令返回由用户名标识的用户 ID。

  • search: 此命令显示有关给定用户(通过其 ID 识别)的信息。

我们即将展示的客户端比使用 curl(1) 工具工作要好得多,因为它可以处理接收到的信息,更重要的是,它可以解释 HTTP 返回码并在发送到服务器之前预处理数据。你付出的代价是开发调试 RESTful 客户端所需额外的时间。

存在两个命令行标志用于传递执行命令的用户名和密码:usernamepassword。正如你将在它们的实现中看到的那样,它们分别有 -u-p 快捷键。此外,由于包含用户信息的 JSON 记录字段数量较少,所有字段都将使用 data 标志或 -d 快捷键提供——这是在 ./cmd/root.go 中实现的。每个命令将只读取所需的标志和输入 JSON 记录的所需字段——这是在每个命令的源代码文件中实现的。最后,当这有意义时,工具将返回 JSON 记录,或者与访问的端点相关的文本消息。现在,让我们继续客户端的结构和命令的实现。

创建命令行客户端的结构

本小节使用 cobra 工具来创建命令行工具的结构。但首先,我们将使用 Go 模块创建一个合适的 cobra 项目:

$ cd ~/go/src/github.com/mactsouk/mGo4th/ch11
$ mkdir client
$ cd client
$ go mod init
$ ~/go/bin/cobra init
$ go mod tidy
$ go run main.go 

你不需要执行最后一个命令,但它确保到目前为止一切正常。之后,我们准备通过运行以下 cobra 命令来定义工具将支持的命令:

$ ~/go/bin/cobra add add
$ ~/go/bin/cobra add delete
$ ~/go/bin/cobra add list
$ ~/go/bin/cobra add logged
$ ~/go/bin/cobra add login
$ ~/go/bin/cobra add logout
$ ~/go/bin/cobra add search
$ ~/go/bin/cobra add getid
$ ~/go/bin/cobra add time
$ ~/go/bin/cobra add update 

现在我们有了所需的结构,我们可以开始实现命令,也许可以移除由 cobra 插入的一些注释,这是下一个小节的主题。

实现 RESTful 客户端命令

由于展示整个代码没有意义,我们将展示一些命令中最具代表性的代码,从 root.go 开始,这是定义下一个全局变量的地方:

var SERVER string
var PORT string
var data string
var username string
var password string 

这些全局变量持有工具的命令行选项值,并且可以在工具代码的任何地方访问。

type User struct {
    ID        int `json:"id"`
    Username  string `json:"username"`
    Password  string `json:"password"`
    LastLogin int64 `json:"lastlogin"`
    Admin     int `json:"admin"`
    Active    int `json:"active"`
} 

我们定义了 User 结构,用于发送和接收数据。

func init() {
    rootCmd.PersistentFlags().StringVarP(&username, "username", "u", "username", "The username")
    rootCmd.PersistentFlags().StringVarP(&password, "password", "p", "admin", "The password")
    rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "{}", "JSON Record")
    rootCmd.PersistentFlags().StringVarP(&SERVER, "server", "s", "http://localhost", "RESTful server hostname")
    rootCmd.PersistentFlags().StringVarP(&PORT, "port", "P", ":1234", "Port of RESTful Server")
} 

我们展示了 init() 函数的实现,该函数包含了命令行选项的定义。命令行标志的值会自动存储在作为 rootCmd.PersistentFlags().StringVarP() 第一个参数传递的变量中。因此,具有 -u 别名的 username 标志将它的值存储在 username 全局变量中。

下一个部分是实现 list 命令,如 list.go 中所示:

var listCmd = &cobra.Command{
    Use:   "list",
    Short: "List all available users",
    Long:  `The list command lists all available users.`, 

这一部分是关于显示在命令中的帮助信息。尽管它们是可选的,但有一个准确的命令描述是很好的。我们继续实际的实现:

 Run: func(cmd *cobra.Command, args []string) {
        endpoint := "/getall"
        user := User{Username: username, Password: password} 

首先,我们构造一个名为userUser变量,该变量包含发出命令的用户的用户名和密码——user变量将被传递到服务器。

 // bytes.Buffer is both a Reader and a Writer
        buf := new(bytes.Buffer)
        err := user.ToJSON(buf)
        if err != nil {
            fmt.Println("JSON:", err)

            os.Exit(1)
        } 

在将user变量传输到 RESTful 服务器之前,我们需要对其进行编码,这就是ToJSON()方法的目的。ToJSON()方法的实现可以在root.go中找到。

 req, err := http.NewRequest(http.MethodGet,
                               SERVER+PORT+endpoint, buf)
        if err != nil {
            fmt.Println("GetAll – Error in req: ", err)
            return
        }
        req.Header.Set("Content-Type", "application/json") 

在这里,我们使用全局变量SERVERPORT以及端点创建请求,使用所需的 HTTP 方法(http.MethodGet),并声明我们将使用Header.Set()语句发送 JSON 数据。

 c := &http.Client{
            Timeout: 15 * time.Second,
        }
        resp, err := c.Do(req)
        if err != nil {
            fmt.Println("Do:", err)
            return
        } 

之后,我们使用Do()将我们的数据发送到服务器,并获取服务器的响应。

 if resp.StatusCode != http.StatusOK {
            fmt.Println(resp)
            return
        } 

如果响应的状态码不是http.StatusOK,则请求失败。

 users := []User{}
        SliceFromJSON(&users, resp.Body)
        if err != nil {
            fmt.Println(err)
            return
        }
        data, err := PrettyJSON(users)
        if err != nil {
            fmt.Println(err)
            return
        }
        fmt.Print(data)
    },
} 

如果状态码是http.StatusOK,那么我们准备读取一个User变量的切片。由于这些变量持有 JSON 记录,我们需要使用定义在root.go中的SliceFromJSON()对其进行解码。

最后是add命令的代码,如add.go中所示。addlist之间的区别在于add命令需要向 RESTful 服务器发送两个 JSON 记录;第一个记录包含发出命令的用户的数据,第二个记录包含即将被添加到系统中的用户的数据。usernamepassword标志持有第一个记录的UsernamePassword字段的数据,而data命令行标志持有第二个记录的数据。

var addCmd = &cobra.Command{
    Use:   "add",
    Short: "Add a new user",
    Long:  `Add a new user to the system.`,
    Run: func(cmd *cobra.Command, args []string) {
        endpoint := "/add"
        u1 := User{Username: username, Password: password} 

如前所述,我们获取发出命令的用户信息并将其放入一个结构体中。

 // Convert data string to User Structure
var u2 User
        err := json.Unmarshal([]byte(data), &u2)
        if err != nil {
            fmt.Println("Unmarshal:", err)

            os.Exit(1)
        } 

由于data命令行标志持有string值,我们需要将那个字符串值转换为User结构——这就是json.Unmarshal()调用的目的。

 users := []User{}
        users = append(users, u1)
        users = append(users, u2) 

然后,我们创建一个将要发送到服务器的User变量切片。你在这个切片中放置结构的顺序很重要:首先是发出命令的用户,然后是即将创建的用户的数据。这是由 RESTful 服务器决定的。

 buf := new(bytes.Buffer)
        err = SliceToJSON(users, buf)
        if err != nil {
            fmt.Println("JSON:", err)
            return
        } 

然后,我们在发送到 RESTful 服务器通过 HTTP 请求之前对那个切片进行编码。

 req, err := http.NewRequest(http.MethodPost,
                                    SERVER+PORT+endpoint, buf)
        if err != nil {
            fmt.Println("GetAll – Error in req: ", err)
            return
        }
        req.Header.Set("Content-Type", "application/json")
        c := &http.Client{
            Timeout: 15 * time.Second,
        }
        resp, err := c.Do(req)
        if err != nil {
            fmt.Println("Do:", err)
            return
        } 

我们准备请求并将其发送到服务器。服务器负责解码提供的数据并相应地执行,在这种情况下,通过向系统中添加新用户。客户端只需使用适当的 HTTP 方法(http.MethodPost)访问正确的端点,并检查返回的 HTTP 状态码。

 if resp.StatusCode != http.StatusOK {
            fmt.Println("Status code:", resp.Status)
        }
        fmt.Println("User", u2.Username, "added.")
        }
    },
} 

add命令不会向客户端返回任何数据——我们感兴趣的是 HTTP 状态码,因为这决定了命令的成功或失败。

其余的命令有类似的实现,这里没有展示。请随意查看cmd目录中的 Go 源代码文件。

使用 RESTful 客户端

现在,我们将使用命令行工具与 RESTful 服务器交互。这种类型的工具可以用于管理 RESTful 服务器、创建自动化任务以及执行 CI/CD 任务。为了简化,客户端和服务器位于同一台机器上,我们主要使用默认用户(admin)——这使得展示的命令更短。此外,我们执行 go build -o rest-cli 来创建一个二进制可执行文件,以避免始终使用 go run main.go

首先,我们从服务器获取时间:

$ ./rest-cli time
The current time is: Wed, 01 Nov 2023 07:37:49 EET 

接下来,我们列出所有用户。由于输出取决于数据库的内容,我们只打印输出的一部分。请注意,list 命令需要由具有管理员权限的用户执行:

$ ./rest-cli list -u admin -p admin
[
    {
        "id": 3,
        "username": "mihalis",
        "password": "admin",
        "lastlogin": 0,
        "admin": 0,
        "active": 0
    } 

请记住,你应该使用 admin 用户的激活密码来确保之前的命令正确执行。在我的情况下,激活密码也是 admin,但这取决于你当前数据库的状态。

接下来,我们测试使用无效密码发出的 logged 命令:

$ ./rest-cli logged -u admin -p notPass
&{400 Bad Request 400 HTTP/1.1 1 1 map[Content-Length:[0] Date:[Wed, 01 Nov 2023 05:39:38 GMT]] 0x14000204020 0 [] false false map[] 0x14000132c00 <nil>} 

如预期的那样,命令失败了——这个输出用于调试目的。在确保命令按预期工作后,你可能想打印一个更合适的错误信息。

之后,我们测试 add 命令:

$ ./rest-cli add -u admin -p admin --data '{"Username":"newUser", "Password":"aPass"}'
User newUser added. 

再次尝试添加相同的用户将会失败:

$ ./rest-cli add -u admin -p admin --data '{"Username":"newUser", "Password":"aPass"}'
Status code: 400 Bad Request 

接下来,我们将删除 newUser——但首先,我们需要找到 newUser 的用户 ID:

$ ./rest-cli getid -u admin -p admin --data '{"Username":"newUser"}'
User newUser has ID: 4
$ ./rest-cli delete -u admin -p admin --data '{"ID":4}'
User with ID 4 deleted. 

随意继续测试 RESTful 客户端,并告诉我你是否发现了任何错误!

与多个 REST API 版本一起工作

REST API 可以随时间改变和演变。关于如何实现 REST API 版本化的方法有很多,包括以下几种:

  • 使用自定义 HTTP 头(version-used)来定义使用的版本

  • 为每个版本使用不同的子域名(v1.servernamev2.servername

  • 使用 AcceptContent-Type 头的组合——这种方法基于内容协商

  • 为每个版本使用不同的路径(如果 RESTful 服务器支持两个 REST API 版本,则为 /v1/v2

  • 使用查询参数来引用所需的版本(..../endpoint?version=v1..../endpoint?v=1

关于如何实现 REST API 版本化,没有正确答案。使用对你和你的用户来说更自然的方法。重要的是要保持一致并在所有地方使用相同的方法。我个人更喜欢使用 /v1/... 来支持版本 1 的端点,以及 /v2/... 来支持版本 2 的端点,依此类推。

我们 RESTful 服务器和客户端的开发到此结束。通过本章所提供的内容,你可以创建强大的 RESTful 服务!

摘要

Go 被广泛用于开发 RESTful 客户端和服务器,本章展示了如何在 Go 中编写专业的 RESTful 客户端和服务器。虽然你可以使用标准 Go 库开发 RESTful 服务器,但这可能是一项非常繁琐的任务。本章使用的gorilla/mux等外部包和 Gin 可以通过提供高级功能来节省你的时间,这些功能如果使用标准 Go 库实现,则需要大量的代码。

记住,定义一个合适的 REST API 并实现相应的服务器和客户端是一个耗时且需要小幅度调整和修改的过程。

一个高效且富有成效的 RESTful 服务背后是正确定义的 JSON 记录和 HTTP 端点,它们支持所需的操作。考虑到这两项内容,Go 代码应该提供服务器和客户端之间交换 JSON 记录的支持。

下一章将介绍代码测试、性能分析、交叉编译和创建示例函数。我们将编写测试本章开发的 HTTP 处理器的代码。

练习

  • 尝试让rServer.go使用 Gin 而不是net/http。更新后的rServer.go是否仍然与rClient.go兼容?为什么?这是好事吗?

  • server/restdb.go文件修改为支持 PostgreSQL 而不是 SQLite。

  • server/restdb.go文件修改为支持 MySQL 而不是 SQLite。

  • server/handlers.go中的处理函数放入单独的文件中。

其他资源

留下评论!

喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码以获取你选择的免费电子书。

二维码

第十二章:代码测试和分析

编程既是艺术也是科学,因此它需要帮助开发者生成更好的软件并理解为什么他们的代码的一些方面没有按预期工作的工具。本章主要讨论使用 Go 编程语言进行代码测试和代码分析。提供的代码分析工具旨在通过找到并理解瓶颈以及发现错误来提高 Go 程序的性能。

代码优化 是一个过程,其中一个或多个开发者试图使程序中的一些部分运行得更快、更高效或使用更少的资源。简单来说,代码优化就是消除程序在需要时和需要的地方的瓶颈。关于代码优化的讨论将在 第十四章效率和性能 中继续,我们将讨论代码基准测试。

代码测试 是确保你的代码做你想让它做的事情。在本章中,我们体验了 Go 的代码测试方式。编写测试代码的最佳时机是在开发过程中,因为这可以帮助尽早揭示代码中的错误。代码分析 与测量程序的一些方面以获得代码工作方式的详细理解相关。代码分析的结果可以帮助你决定哪些代码部分需要更改。

请记住,在编写代码时,我们应该关注其正确性以及其他期望的特性,如可读性、简单性和可维护性,而不是其性能。一旦我们确信代码是正确的,那么我们可能需要关注其性能。一个提高性能的好技巧是在比生产环境中将要使用的机器慢一点的机器上执行代码。

本章涵盖:

  • 优化代码

  • 为更好的测试重写 main() 函数

  • 代码分析

  • go tool trace 工具

  • 跟踪网络服务器

  • 测试 Go 代码

  • govulncheck 工具

  • 交叉编译

  • 使用 go:generate

  • 创建示例函数

优化代码

代码优化既是艺术也是科学。这意味着没有确定性的方法可以帮助你优化代码,而且如果你想使代码更快,你应该用你的大脑尝试很多事情,算法和技术。然而,关于代码优化的普遍原则是 首先确保它正确,然后让它变得快速。始终记住 Donald Knuth 关于优化的说法:

“真正的问题是程序员在错误的地方和错误的时间花费了太多的时间担心效率;过早的优化是编程中所有邪恶(至少是大部分)的根源。”

此外,记住已故的 Joe Armstrong(Erlang 的一位开发者)关于优化的说法:

“先让它工作,然后让它变得美观,然后如果你真的、真的必须,再让它变得快速。90% 的时间,如果你让它变得美观,它已经足够快了。所以,实际上,只需让它变得美观!”

代码测试帮助你确保程序正确运行,代码剖析揭示了瓶颈。

如果你真的很喜欢代码优化,你可能想阅读 Alfred V. Aho、Monica S. Lam、Ravi Sethi 和 Jeffrey D. Ullman 所著的 Compilers: Principles, Techniques, and Tools(Pearson Education Limited,2014),这本书专注于编译器构造。此外,如果你有时间阅读,Donald Knuth 的 The Art of Computer Programming 系列的所有卷都是编程各个方面的极好资源。

下一节将展示一种重新编写 main() 以便更容易进行测试的技术。

重新编写 main() 函数以更好地进行测试

有一种巧妙的方法可以重新编写每个 main() 函数,以便使测试(和基准测试)变得容易得多。main() 函数有一个限制,即你不能从测试代码中调用它——这个技术通过 main.go 中的代码提供了一个解决方案。为了节省空间,省略了 import 块。

func main() {
    **err := run(os.Args, os.Stdout)**
if err != nil {
        fmt.Printf("%s\n", err)
        return
    }
} 

由于没有 main() 函数就无法有一个可执行程序,我们必须创建一个最小化的 main()main() 做的事情是调用 run(),这是我们自己的定制版本的 main(),向它发送所需的 os.Args,并收集 run() 的返回值:

func run(args []string, stdout io.Writer) error {
    if len(args) == 1 {
        return errors.New("No input!")
    }
    // Continue with the implementation of run()
// as you would have with main()
return nil
} 

如前所述,run() 函数,或者以相同方式由 main() 调用的任何其他函数,提供了一个新的顶级、根类型函数,类似于 main(),并且具有额外的优势,即可以被测试函数调用。简单来说,run() 函数包含了原本位于 main() 中的代码——唯一的区别是 run() 返回一个 error 变量,而 main() 在使用 os.Exit() 时只能返回退出码到操作系统。你可能会说,这因为额外的函数调用而稍微增加了栈的大小,但好处远比增加的内存使用更重要。尽管技术上,run() 的两个参数都可以被移除,因为它们默认情况下是全局可用的,但显式传递这两个参数允许程序员在测试期间传递其他值。

运行 main.go 产生以下输出:

$ go run main.go 
No input!
$ go run main.go some input 

main.go 的操作方式并没有什么特别之处。好事是你可以从任何你想的地方调用 run(),包括你为测试编写的代码,并将所需的参数传递给 run()!记住这个技巧是好的,因为它可能会在你想要为具有特定命令行参数或其他输入的程序编写测试时救你一命。

下一节将介绍 Go 代码的剖析。

剖析代码

分析是一个动态程序分析的过程,它测量与程序执行相关的各种值,以帮助你更好地理解程序的行为。在本节中,我们将学习如何分析 Go 代码以更好地理解它,这可以用来提高其性能。有时,代码分析甚至可以揭示代码中的错误,例如无限循环或永不返回的函数。然而,对于内存泄漏错误和类似的问题,分析会更好。

runtime/pprof 是一个标准的 Go 包,用于分析除 HTTP 服务器之外的各种应用程序。当你想要分析一个 Web 应用程序时,应该使用高级的 net/http/pprof 包。net/http/pprof 所做的是提供用于分析数据的 HTTP 端点,这意味着它也可以用于任何长时间运行的应用程序。你可以通过执行 go tool pprof -help 来查看 pprof 工具的帮助页面。

下一个子节将说明如何分析一个命令行应用程序,接下来的子节将展示 HTTP 服务器的分析。

分析命令行应用程序

应用程序的代码保存为 profileCla.go 并收集 CPU 和内存分析数据。有趣的是 main() 的实现,因为这是分析数据收集发生的地方:

func main() {
    fmt.Println(os.TempDir())
    cpuFilename := path.Join(os.TempDir(), "cpuProfileCla.out")
    cpuFile, err := os.Create(cpuFilename)
    if err != nil {
        fmt.Println(err)
        return
    }
    pprof.StartCPUProfile(cpuFile)
    defer pprof.StopCPUProfile() 

之前的代码是关于收集 CPU 分析数据的。pprof.StartCPUProfile() 开始收集数据,通过 pprof.StopCPUProfile() 调用来停止。所有数据都保存在 os.TempDir() 目录下的一个名为 cpuProfileCla.out 的文件中——os.TempDir() 返回的值取决于所使用的操作系统,这使得代码具有可移植性。使用 defer 的意思是 pprof.StopCPUProfile() 将在 main() 函数退出之前被调用——如果你想在另一个点停止数据收集,你应该将 pprof.StopCPUProfile() 调用放在期望的位置。

 total := 0
for i := 2; i < 100000; i++ {
        n := N1(i)
        if n {
            total = total + 1
        }
    }
    fmt.Println("Total primes:", total)
    total = 0
for i := 2; i < 100000; i++ {
        n := N2(i)
        if n {
            total = total + 1
        }
    }
    fmt.Println("Total primes:", total)
    for i := 1; i < 90; i++ {
        n := fibo1(i)
        fmt.Print(n, " ")
    }
    fmt.Println()
    for i := 1; i < 90; i++ {
        n := fibo2(i)
        fmt.Print(n, " ")
    }
    fmt.Println()
    runtime.GC() 

所有的前序代码都进行了大量的 CPU 密集型计算,以便 CPU 分析器有数据可以收集——这通常是你的实际代码所在的位置。

 // Memory profiling!
    memoryFilename := path.Join(os.TempDir(), "memoryProfileCla.out")
    memory, err := os.Create(memoryFilename)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer memory.Close() 

在 CPU 密集型代码之后,我们将放置使用大量内存的代码。为此,我们创建了一个名为 memoryFilename 的第二个文件,用于收集与内存相关的分析数据。

 for i := 0; i < 10; i++ {
        s := make([]byte, 50000000)
        if s == nil {
            fmt.Println("Operation failed!")
        }
        time.Sleep(50 * time.Millisecond)
    }
    err = pprof.WriteHeapProfile(memory)
    if err != nil {
        fmt.Println(err)
        return
    }
} 

pprof.WriteHeapProfile() 函数将内存数据写入指定的文件。再一次,我们分配了大量的内存,以便内存分析器有数据可以收集。

使用 go run 运行 profileCla.go 将在 os.TempDir() 返回的文件夹中创建两个文件——通常,我们会将它们移动到另一个文件夹中。你可以随意修改 profileCla.go 的代码,并将分析文件放在不同的位置。

在我的情况下,在 macOS Sonoma 机器上运行,临时目录将是 /var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/。因此,我将把 cpuProfileCla.outmemoryProfileCla.out 文件从那里移动到 ch12 目录中——你将找不到它们,因为书籍的 GitHub 仓库的 .gitignore 文件忽略了这两个文件。

那么,我们接下来做什么?我们应该使用 go tool pprof 来处理这两个文件:

$ go tool pprof cpuProfileCla.out
Type: cpu
Time: Dec 13, 2023 at 6:35pm (EET)
Duration: 14.85s, Total samples = 650ms ( 4.38%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 630ms, 96.92% of 650ms total
Showing top 10 nodes out of 47
      flat  flat%   sum%        cum   cum%
     300ms 46.15% 46.15%    330ms 50.77%  main.N2 (inline)
     120ms 18.46% 64.62%    120ms 18.46%  main.N1 (inline)
      40ms  6.15% 70.77%     40ms  6.15%  runtime.kevent
      40ms  6.15% 76.92%     40ms  6.15%  runtime.pthread_cond_signal
      40ms  6.15% 83.08%     40ms  6.15%  runtime.pthread_cond_wait
      30ms  4.62% 87.69%     30ms  4.62%  runtime.asyncPreempt
      30ms  4.62% 92.31%     30ms  4.62%  runtime.madvise
      10ms  1.54% 93.85%     10ms  1.54%  internal/poll.(*pollDesc).prepare
      10ms  1.54% 95.38%    480ms 73.85%  main.main
      10ms  1.54% 96.92%     10ms  1.54%  runtime.memclrNoHeapPointers 

top 命令返回前 10 个条目的摘要。

(pprof) top10 -cum
Showing nodes accounting for 440ms, 67.69% of 650ms total
Showing top 10 nodes out of 47
      flat  flat%   sum%        cum   cum%
      10ms  1.54%  1.54%      480ms 73.85%  main.main
         0     0%  1.54%      480ms 73.85%  runtime.main
     300ms 46.15% 47.69%      330ms 50.77%  main.N2 (inline)
     120ms 18.46% 66.15%      120ms 18.46%  main.N1 (inline)
         0     0% 66.15%      120ms 18.46%  runtime.mcall
      10ms  1.54% 67.69%      120ms 18.46%  runtime.schedule
         0     0% 67.69%      110ms 16.92%  runtime.park_m
         0     0% 67.69%       80ms 12.31%  runtime.findRunnable
         0     0% 67.69%       50ms  7.69%  runtime.notewakeup
         0     0% 67.69%       50ms  7.69%  runtime.semawakeup 

top10 –cum 命令返回每个函数的累积时间。

(pprof) list main.N1
Total: 650ms
ROUTINE ======================== main.N1 in /Users/mtsouk/go/src/github.com/mactsouk/mGo4th/ch12/profileCla.go
     120ms      120ms (flat, cum) 18.46% of Total
         .          .     36:func N1(n int) bool {
         .          .     37:    k := math.Floor(float64(n/2 + 1))
         .          .     38:    for i := 2; i < int(k); i++ {
     120ms      120ms     39:        if (n % i) == 0 {
         .          .     40:            return false
         .          .     41:        }
         .          .     42:    }
         .          .     43:    return true
         .          .     44:} 

最后,list 命令显示有关给定函数的信息。之前的输出显示 if (n % i) == 0 语句是 N1() 运行所需所有时间的责任!

在你自己的代码中尝试这些分析命令,以查看它们的完整输出。访问 go.dev/blog/pprof 以了解有关分析更多的信息。

你还可以使用 Go 分析器的 shell 通过 pdf 命令创建分析数据的 PDF 输出。我个人大多数时候都是从这个命令开始的,因为它给了我一个丰富且清晰的收集数据的概览。

现在,让我们讨论如何分析 HTTP 服务器,这是下一小节的主题。

分析 HTTP 服务器

如前所述,当你要为运行 HTTP 服务器或任何其他需要定期收集分析数据的长时间运行程序收集 Go 应用程序的分析数据时,应该使用 net/http/pprof 包。为此,导入 net/http/pprof/debug/pprof/ 路径下安装了各种处理程序。你很快就会看到更多关于这个的内容。

这种技术在 profileHTTP.go 中得到了展示,其中包含以下代码:

package main
import (
    "fmt"
"net/http"
"net/http/pprof"
"os"
"time"
) 

如前所述,你应该导入 net/http/pprof 包。然而,尽管导入了 net/http/pprof,但它并没有直接使用。导入是为了注册 HTTP 处理程序的副作用,如 pkg.go.dev/net/http/pprof 中所述。

func myHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Serving: %s\n", r.URL.Path)
    fmt.Printf("Served: %s\n", r.Host)
}
func timeHandler(w http.ResponseWriter, r *http.Request) {
    t := time.Now().Format(time.RFC1123)
    Body := "The current time is:"
    fmt.Fprintf(w, "%s %s", Body, t)
    fmt.Fprintf(w, "Serving: %s\n", r.URL.Path)
    fmt.Printf("Served time for: %s\n", r.Host)
} 

前两个函数实现了将要在我们简单的 HTTP 服务器中使用的两个处理程序。myHandler() 是默认的处理程序函数,而 timeHandler() 返回服务器上的当前时间和日期。

func main() {
    PORT := ":8001"
    arguments := os.Args
    if len(arguments) == 1 {
        fmt.Println("Using default port number: ", PORT)
    } else {
        PORT = ":" + arguments[1]
        fmt.Println("Using port number: ", PORT)
    }
    r := http.NewServeMux()
    r.HandleFunc("/time", timeHandler)
    r.HandleFunc("/", myHandler) 

到目前为止,并没有什么特别之处,因为我们只是注册了处理程序函数。

所有的先前声明都为 HTTP 分析器安装了处理程序——你可以使用 Web 服务器的主机名和端口号来访问它们。你不必使用所有处理程序。

 err := http.ListenAndServe(PORT, r)
    if err != nil {
        fmt.Println(err)
        return
    }
} 

最后,你像往常一样启动 HTTP 服务器。

接下来是什么?首先,你运行 HTTP 服务器(go run profileHTTP.go)。然后,你在不同的终端窗口中运行下一个命令来收集与 HTTP 服务器交互时的分析数据:

$ go tool pprof http://localhost:8001/debug/pprof/profile
Fetching profile over HTTP from http://localhost:8001/debug/pprof/profile
Saved profile in /Users/mtsouk/pprof/pprof.samples.cpu.001.pb.gz
Type: cpu
Time: Dec 13, 2023 at 6:44pm (EET)
Duration: 30.02s, Total samples = 30ms (  0.1%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) % 

之前的输出显示了 HTTP 分析器的初始屏幕——可用的命令与分析命令行应用程序时相同。

您可以选择退出 shell,稍后使用go tool pprof分析数据,或者继续输入分析器命令。这是在 Go 中分析 HTTP 服务器的一般思路。

下一个子节讨论了 Go 分析器的 Web 界面。

Go 分析器的 Web 界面

好消息是,从 Go 版本 1.10 开始,go tool pprof自带了一个 Web 界面,您可以通过go tool pprof -http=[host]:[port] aProfile.out启动它——不要忘记将您希望设置的值设置为-http

我已经执行了之前的命令go tool pprof -http=127.0.0.1:1234 cpuProfileCla.out

下一个图中可以看到分析器 Web 界面的一部分,它显示了程序执行时间的分配情况——现在开发者需要找出性能是否存在问题。

计算机屏幕截图 自动生成描述

图 12.1:Go 分析器的 Web 界面

随意浏览 Web 界面,查看提供的各种选项和菜单。不幸的是,关于分析器的更多讨论超出了本章的范围。像往常一样,如果您真的对代码分析感兴趣,尽可能多地实验。

下一个章节是关于代码跟踪的,它提供了关于 Go 内部操作的信息。

Go 工具跟踪实用程序

代码跟踪是一个允许您了解垃圾收集器操作、goroutine 的生命周期、每个逻辑处理器的活动以及操作系统线程使用数量的过程。go tool trace实用程序是一个用于查看存储在跟踪文件中的数据的工具,这些数据可以通过以下三种方式中的任何一种生成:

  • 使用runtime/trace

  • 使用net/http/pprof

  • 使用go test -trace命令

本节通过traceCLA.go的代码示例说明了第一种技术的使用:

package main
import (
    "fmt"
"os"
"path"
"runtime/trace"
"time"
) 

需要使用runtime/trace包来收集所有种类的跟踪数据——没有选择特定跟踪数据的必要,因为所有跟踪数据都是相互关联的。

func main() {
    filename := path.Join(os.TempDir(), "traceCLA.out")
    f, err := os.Create(filename)
    if err != nil {
        panic(err)
    }
    defer f.Close() 

正如我们在分析时做的那样,我们需要创建一个文件来存储跟踪数据。在这种情况下,文件名为traceCLA.out,并存储在您的操作系统的临时目录中。

 err = trace.Start(f)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer trace.Stop() 

这部分完全是关于为go tool trace获取数据,它与程序的目的无关。我们使用trace.Start()启动跟踪过程。完成之后,我们调用trace.Stop()函数。defer调用意味着我们希望在main()函数返回时终止跟踪。

 for i := 0; i < 3; i++ {
        s := make([]byte, 50000000)
        if s == nil {
            fmt.Println("Operation failed!")
        }
    }
    for i := 0; i < 5; i++ {
        s := make([]byte, 100000000)
        if s == nil {
            fmt.Println("Operation failed!")
        }
        time.Sleep(time.Millisecond)
    }
} 

所有的前一段代码都是关于分配内存以触发垃圾回收器的操作并生成更多跟踪数据的——你可以在附录 A 中了解更多关于 Go 垃圾回收器的信息,即Go 垃圾回收器。程序会像往常一样执行。然而,当它完成后,它会将跟踪数据填充到traceCLA.out中。之后,我们应该按照以下方式处理跟踪数据:

$ go tool trace /path/ToTemporary/Directory/traceCLA.out
2023/12/14 18:12:06 Parsing trace...
2023/12/14 18:12:06 Splitting trace...
2023/12/14 18:12:06 Opening browser. Trace viewer is listening on http://127.0.0.1:52829 

最后一条命令会自动启动一个 Web 服务器(http://127.0.0.1:52829)并在你的默认 Web 浏览器上打开跟踪工具的 Web 界面——你可以在自己的电脑上运行它来玩转跟踪工具的 Web 界面。

“查看跟踪”链接显示了关于你的程序 goroutines 和垃圾回收器操作的信息——如果你的代码使用了多个 goroutines,这是理解它们行为最好的地方。

请记住,尽管go tool trace非常方便且强大,但它不能解决所有类型的性能问题。有时go tool pprof更为合适,特别是当我们想要揭示代码大部分时间花在哪里时。

与分析类似,收集 HTTP 服务器的跟踪数据是一个稍微不同的过程,这将在下一小节中解释。

从客户端追踪 Web 服务器

本节展示了如何使用net/http/httptrace来追踪 Web 服务器应用程序。该包允许你追踪客户端 HTTP 请求的各个阶段。与 Web 服务器交互的traceHTTP.go代码如下:

package main
import (
    "fmt"
"net/http"
"net/http/httptrace"
"os"
) 

如预期的那样,我们需要在启用 HTTP 跟踪之前导入net/http/httptrace

func main() {
    if len(os.Args) != 2 {
        fmt.Printf("Usage: URL\n")
        return
    }
    URL := os.Args[1]
    client := http.Client{}
    req, _ := http.NewRequest("GET", URL, nil) 

到目前为止,我们已经像往常一样准备好了发送到 Web 服务器的客户端请求。

 trace := &httptrace.ClientTrace{
        GotFirstResponseByte: func() {
            fmt.Println("First response byte!")
        },
        GotConn: func(connInfo httptrace.GotConnInfo) {
            fmt.Printf("Got Conn: %+v\n", connInfo)
        },
        DNSDone: func(dnsInfo httptrace.DNSDoneInfo) {
            fmt.Printf("DNS Info: %+v\n", dnsInfo)
        },
        ConnectStart: func(network, addr string) {
            fmt.Println("Dial start")
        },
        ConnectDone: func(network, addr string, err error) {
            fmt.Println("Dial done")
        },
        WroteHeaders: func() {
            fmt.Println("Wrote headers")
        },
    } 

上一段代码都是关于追踪 HTTP 请求的。httptrace.ClientTrace结构体定义了我们感兴趣的事件,包括GotFirstResponseByteGotConnDNSDoneConnectStartConnectDoneWroteHeaders。当这些事件发生时,相关的代码会被执行。你可以在net/http/httptrace包的文档中找到更多关于支持的事件及其目的的信息。

 req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
    fmt.Println("Requesting data from server!")
    _, err := http.DefaultTransport.RoundTrip(req)
    if err != nil {
        fmt.Println(err)
        return
    } 

httptrace.WithClientTrace()函数基于给定的父上下文返回一个新的上下文值,而http.DefaultTransport.RoundTrip()则将请求包装在这个上下文值中,以便跟踪请求。

请记住,Go HTTP 跟踪已经被设计来追踪单个http.Transport.RoundTrip的事件。

 _, err = client.Do(req)
    if err != nil {
        fmt.Println(err)
        return
    }
} 

最后的部分将客户端请求发送到服务器以开始跟踪。

运行traceHTTP.go会生成以下输出:

$ go run traceHTTP.go https://go.dev
Requesting data from server!
DNS Info: {Addrs:[{IP:2001:4860:4802:32::15 Zone:} {IP:2001:4860:4802:34::15 Zone:} {IP:2001:4860:4802:36::15 Zone:} {IP:2001:4860:4802:38::15 Zone:} {IP:216.239.32.21 Zone:} {IP:216.239.34.21 Zone:} {IP:216.239.36.21 Zone:} {IP:216.239.38.21 Zone:}] Err:<nil> Coalesced:false}
Dial start
Dial done
Got Conn: {Conn:0x1400018e000 Reused:false WasIdle:false IdleTime:0s}
Wrote headers
First response byte!
Got Conn: {Conn:0x1400018e000 Reused:true WasIdle:false IdleTime:0s}
Wrote headers
First response byte! 

之前的输出可以帮助你更详细地了解连接的进度,在故障排除时很有用。不幸的是,关于跟踪的更多讨论超出了本书的范围。下一小节将展示如何访问 Web 服务器的所有路由以确保它们被正确定义。

访问 Web 服务器的所有路由

gorilla/mux 包提供了一个 Walk() 函数,可以用来访问路由器注册的所有路由——当你想要确保每个路由都已注册且正常工作时,这会非常有用。

walkAll.go 的代码,其中包含许多空处理函数,因为它的目的是不测试处理函数,而是访问它们,如下所示(没有任何东西禁止你在完全实现的 Web 服务器上使用相同的技巧):

package main
import (
    "fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
) 

由于我们使用外部包,walkAll.go 的运行应该在 ~/go/src 下的某个位置进行——在我们的例子中,在 ./ch12/walkAll 下。

func handler(w http.ResponseWriter, r *http.Request) {
    return
} 

这个空处理函数出于简单起见被所有端点共享。

func (h notAllowedHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
    handler(rw, r)
} 

notAllowedHandler 处理器也会调用 handler() 函数。

type notAllowedHandler struct{}
func main() {
    r := mux.NewRouter()
    r.NotFoundHandler = http.HandlerFunc(handler)
    notAllowed := notAllowedHandler{}
    r.MethodNotAllowedHandler = notAllowed
    // Register GET
    getMux := r.Methods(http.MethodGet).Subrouter()
    getMux.HandleFunc("/time", handler)
    getMux.HandleFunc("/getall", handler)
    getMux.HandleFunc("/getid", handler)
    getMux.HandleFunc("/logged", handler)
    getMux.HandleFunc("/username/{id:[0-9]+}", handler)
    // Register PUT
// Update User
    putMux := r.Methods(http.MethodPut).Subrouter()
    putMux.HandleFunc("/update", handler)
    // Register POST
// Add User + Login + Logout
    postMux := r.Methods(http.MethodPost).Subrouter()
    postMux.HandleFunc("/add", handler)
    postMux.HandleFunc("/login", handler)
    postMux.HandleFunc("/logout", handler)
    // Register DELETE
// Delete User
    deleteMux := r.Methods(http.MethodDelete).Subrouter()
    deleteMux.HandleFunc("/username/{id:[0-9]+}", handler) 

前一部分是关于定义我们想要支持的路线和 HTTP 方法。

 err := r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { 

前面的陈述说明了我们如何调用 Walk() 方法。

 pathTemplate, err := route.GetPathTemplate()
        if err == nil {
            fmt.Println("ROUTE:", pathTemplate)
        }
        pathRegexp, err := route.GetPathRegexp()
        if err == nil {
            fmt.Println("Path regexp:", pathRegexp)
        }
        qT, err := route.GetQueriesTemplates()
        if err == nil {
            fmt.Println("Queries templates:", strings.Join(qT, ","))
        }
        qRegexps, err := route.GetQueriesRegexp()
        if err == nil {
            fmt.Println("Queries regexps:", strings.Join(qRegexps, ","))
        }
        methods, err := route.GetMethods()
        if err == nil {
            fmt.Println("Methods:", strings.Join(methods, ","))
        }
        fmt.Println()
        return nil
    }) 

对于每个访问的路由,程序会收集所需的信息。如果你觉得某些 fmt.Println() 调用没有帮助,请随意移除它们。

 if err != nil {
        fmt.Println(err)
    }
    http.Handle("/", r)
} 

因此,walkAll.go 背后的基本思想是,你为服务器中的每个路由分配一个空处理函数,然后调用 mux.Walk() 来访问所有路由。启用 Go 模块并运行 walkAll.go 生成以下输出:

$ go mod init
$ go mod tidy
$ go run walkAll.go
Queries templates: 
Queries regexps: 
Methods: GET
ROUTE: /time
Path regexp: ^/time$
Queries templates: 
Queries regexps: 
Methods: GET 

输出显示了每个路由支持的 HTTP 方法以及路径的格式。因此,/time 端点使用 GET 方法,其路径是 /time,因为 Path 正则表达式的值意味着 /time 在路径的开始 (^) 和结束 ($) 之间。

ROUTE: /getall
Path regexp: ^/getall$
Queries templates: 
Queries regexps: 
Methods: GET
ROUTE: /getid
Path regexp: ^/getid$
Queries templates: 
Queries regexps: 
Methods: GET
ROUTE: /logged
Path regexp: ^/logged$
Queries templates: 
Queries regexps: 
Methods: GET
ROUTE: /username/{id:[0-9]+}
Path regexp: ^/username/(?P<v0>[0-9]+)$
Queries templates: 
Queries regexps: 
Methods: GET 

/username 的情况下,输出包括与用于选择 id 变量值的端点相关的正则表达式。

Queries templates: 
Queries regexps: 
Methods: PUT
ROUTE: /update
Path regexp: ^/update$
Queries templates: 
Queries regexps: 
Methods: PUT
Queries templates: 
Queries regexps: 
Methods: POST
ROUTE: /add
Path regexp: ^/add$
Queries templates: 
Queries regexps: 
Methods: POST
ROUTE: /login
Path regexp: ^/login$
Queries templates: 
Queries regexps: 
Methods: POST
ROUTE: /logout
Path regexp: ^/logout$
Queries templates: 
Queries regexps: 
Methods: POST
Queries templates: 
Queries regexps: 
Methods: DELETE
ROUTE: /username/{id:[0-9]+}
Path regexp: ^/username/(?P<v0>[0-9]+)$
Queries templates: 
Queries regexps: 
Methods: DELETE 

虽然访问 Web 服务器的路由是一种测试方式,但它并不是官方的 Go 测试方式。在输出中,我们主要关注的是端点的缺失、使用错误的 HTTP 方法或端点参数的缺失。

下一个部分将讨论 Go 代码的测试。

测试 Go 代码

本节的主题是通过编写测试函数来测试 Go 代码。软件测试是一个非常大的主题,无法在本书章节的单个部分中涵盖。因此,本节试图尽可能多地提供实用信息。

Go 允许你为你的 Go 代码编写测试以检测错误。然而,软件测试只能显示一个或多个错误的存在,而不能证明没有错误。这意味着你永远不能 100%确信你的代码中没有错误!

严格来说,本节是关于自动化测试,这涉及到编写额外的代码来验证实际代码——即生产代码——是否按预期工作。因此,测试函数的结果要么是 PASS,要么是 FAIL

你很快就会看到它是如何工作的。虽然 Go 的测试方法一开始可能看起来很简单,特别是如果你将其与其他编程语言的测试实践进行比较,但它非常高效和有效,因为它不需要开发者太多的时间。

Go 在测试(以及基准测试)方面遵循某些约定。最重要的约定是测试函数的名称必须以 Test 开头。在 Test 词语之后,我们必须放置一个下划线或大写字母。因此,TestFunctionName()Test_functionName() 都是有效的测试函数,而 Testfunctionname() 则不是。如果你更喜欢 Go 的惯用法,那么请使用 TestFunctionName()。所有这样的函数都放在以 _test.go 结尾的文件中。所有测试函数都必须有一个 t *testing.T 参数,并且不返回任何值。最后,包含测试代码的包应包含 testing 包。

一旦测试代码正确,go test 子命令会为你完成所有脏活,包括扫描所有 *_test.go 文件中的特殊函数,生成适当的临时 main 包,调用这些特殊函数,获取结果,并生成最终输出。

现在,让我们通过回顾 第三章复合数据类型 中的 matchInt() 函数来介绍测试。

为 ./ch03/intRE.go 编写测试

在本小节中,我们为在 第三章复合数据类型 中实现的 matchInt() 函数编写测试。首先,我们创建一个名为 intRE_test.go 的新文件,它将包含所有测试。然后,我们将包名从 main 改为 testRE 并删除 main() 函数——这是一个可选步骤。之后,我们必须决定我们要测试什么以及如何测试。测试的主要步骤包括编写预期输入、意外输入、空输入和边缘情况的测试。所有这些都会在代码中看到。此外,我们还将生成随机整数,将它们转换为字符串,并将它们用作 matchInt() 的输入。一般来说,测试处理数值的函数的好方法是使用随机数或随机值作为输入,并观察你的代码如何表现和处理这些值。

相关代码,包括 intRE.go 的原始版本,可以在 ~/go/src/github.com/mactsouk/mGo4th/ch12/intRE 内找到,并包含两个测试函数。intRE_test.go 的两个测试函数如下:

func TestMatchInt(t *testing.T) {
    if matchInt("") {
        t.Error(`matchInt("") != false`)
    } 

matchInt("") 调用应该返回 false,所以如果它返回 true,则意味着该函数没有按预期工作。

 if matchInt("00") == false {
        t.Error(`matchInt("00") != true`)
    } 

matchInt("00") 调用应该返回 true,因为 00 是一个有效的整数,所以如果它返回 false,则意味着该函数没有按预期工作。

 if matchInt("-00") == false {
        t.Error(`matchInt("-00") != true`)
    }
    if matchInt("+00") == false {
        t.Error(`matchInt("+00") != true`)
    }
} 

这个第一个测试函数使用静态输入来测试 matchInt() 函数的正确性。如前所述,一个测试函数需要一个 *testing.T 参数,并且不返回任何值。

func TestWithRandom(t *testing.T) {
    n := strconv.Itoa(random(-100000, 19999))
    if matchInt(n) == false {
        t.Error("n = ", n)
    }
} 

第二个测试函数使用随机但有效的输入来测试matchInt()——这个随机输入是由random()函数生成的,该函数也实现于intRE_test.go中。因此,给定的输入应该总是通过测试。使用go test运行这两个测试函数会创建以下输出:

$ go test -v *.go
=== RUN   TestMatchInt
--- PASS: TestMatchInt (0.00s)
=== RUN   TestWithRandom
--- PASS: TestWithRandom (0.00s)
PASS
ok    command-line-arguments    0.580s 

因此,所有测试都通过了,这意味着matchInt()一切正常——一般来说,函数的操作越简单,测试它就越容易。-v参数创建详细输出,可以省略。

下一个子节将展示如何测试 UNIX 信号。

测试 UNIX 信号

存在一种测试 UNIX 信号的技术。为什么系统信号需要特殊处理?主要原因在于向正在运行的 UNIX 进程发送 UNIX 信号很困难,这意味着手动测试处理 UNIX 信号的进程也很困难。另一个棘手的原因是,当你收到定义为这样做的信号时,你可能会意外退出正在运行的测试。

相关代码位于ch12/testSignals中,其中包含两个名为signalsTest.gosignalsTest_test.go的文件。

signalsTest.go的结构如下:

package mySignals
func HandleSignal(sig os.Signal) {
    fmt.Println("handleSignal() Caught:", sig)
}
func Listener() {
    // Function implementation
} 

因此,signalsTest.go不包含main()函数,因为它不实现main包。这种情况发生是因为这样的实现使得测试更加容易。在你确保代码按预期工作后,你可以将其包含在main包中,或者将现有文件转换为main包。在这种情况下,你只需要更改package语句,并将Listener()函数重命名为main()

signalsTest_test.go的代码分为两部分。第一部分包含以下代码:

package mySignals
import (
    "fmt"
"syscall"
"testing"
"time"
)
func TestAll(t *testing.T) {
    go Listener()
    time.Sleep(time.Second)
    test_SIGUSR1()
    time.Sleep(time.Second)
    test_SIGUSR2()
    time.Sleep(time.Second)
    test_SIGHUP()
    time.Sleep(time.Second)
} 

TestAll()函数是signalsTest_test.go中发现的唯一测试函数,这意味着它是go test将要执行的唯一函数。因此,它将负责调用signalsTest_test.go中的其他函数以及signalsTest.go中的Listener()函数,后者负责处理 UNIX 信号。如果你忘记调用Listener()函数,所有测试都将失败。此外,Listener()需要作为一个 goroutine 来调用,否则,所有测试都将停滞,因为Listener()永远不会返回。

请记住,如果由于某种原因Listener()协程停止处理信号,而程序继续运行,测试仍然会在实际上未能处理信号的情况下返回PASS。这种情况可能发生的一种方式是Listener()中的匿名函数提前返回。然而,通常情况下,这种情况不应该发生。

time.Sleep()调用为test_SIGUSR1()test_SIGUSR2()test_SIGHUP()函数发送 UNIX 信号以及Listener()函数按顺序处理信号提供了足够的时间。最后一个time.Sleep()调用的目的是在所有测试结束之前给Listener()处理最后一个信号的时间。

signalsTest_test.go的第二部分包含以下代码:

func test_SIGUSR1() {
    fmt.Println("Sending syscall.SIGUSR1")
    syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
}
func test_SIGUSR2() {
    fmt.Println("Sending syscall.SIGUSR2")
    syscall.Kill(syscall.Getpid(), syscall.SIGUSR2)
}
func test_SIGHUP() {
    fmt.Println("Sending syscall.SIGHUP")
    syscall.Kill(syscall.Getpid(), syscall.SIGHUP)
} 

test_SIGUSR1()test_SIGUSR2()test_SIGHUP()中的每一个函数都会向运行的 UNIX 进程发送不同的信号——使用syscall.Getpid()调用发现运行中的 UNIX 进程的进程 ID。

运行测试会产生以下输出:

$ go test -v *.go
=== RUN   TestAll
Process ID: 22100
Sending syscall.SIGUSR1
handleSignal() Caught: user defined signal 1
Execution time: 1.001762208s
Sending syscall.SIGUSR2
handleSignal() Caught: user defined signal 2
Sending syscall.SIGHUP
Caught: hangup
--- PASS: TestAll (4.00s)
PASS
ok    command-line-arguments    4.411s 

所有测试都成功完成,这意味着所有信号都成功处理,没有Listener()函数提前退出。

如果你多次运行前面的测试,你可能会得到一个看起来像以下内容的最后一行:

ok    command-line-arguments    (cached) 

输出中的单词cached告诉我们,Go 使用了现有的测试结果来加快测试运行速度,而没有执行测试函数,这并不总是我们期望的行为。下一小节将展示如何在测试时清除或禁用缓存。

禁用测试缓存

测试和缓存并不总是好的组合,主要是因为你总是得到相同的结果。然而,如果测试输入和测试主题没有变化,这有其好处——这使得它在几种情况下完全有效。存在两种避免使用测试缓存得到结果的方法。第一种需要运行go clean -testcache,这将清除整个测试缓存,而第二种需要使用-count=1运行你的测试,这会阻止 Go 为给定的测试执行保存任何测试缓存。

下一小节将讨论TempDir()方法的使用,这在测试或基准测试期间需要创建一个临时数据存储位置时非常有用。

测试的TempDir()函数

testing.TempDir()方法与测试和基准测试都兼容。它的目的是在测试(或基准测试)期间创建一个临时目录。每次调用testing.TempDir()都会返回一个唯一的目录。Go 会自动删除该临时目录,当测试及其子测试或基准测试即将结束时,通过CleanUp()方法完成——这是 Go 安排的,你不需要自己使用和实现CleanUp()

你不应该混淆testing.TempDir()os.TempDir()。我们已经在本章开头看到了os.TempDir()方法在profileCla.gotraceCLA.go中的使用。os.TempDir()返回用于临时文件的默认目录,而testing.TempDir()返回当前测试使用的临时目录。

临时目录将要创建的确切位置取决于所使用的操作系统。在 macOS 上,它位于 /var/folders 下,而在 Linux 上,它位于 /tmp 下。我们将在下一个子节中说明 testing.TempDir(),同时也会讨论 Cleanup()

Cleanup() 函数

虽然我们在测试场景中展示了 Cleanup() 方法,但 Cleanup() 适用于测试和基准测试。其名称揭示了其目的,即在测试或基准测试包时清理我们创建的一些东西。然而,我们需要告诉 Cleanup() 要做什么——Cleanup() 的参数是一个执行清理的函数。

该函数通常作为匿名函数内联实现,但你也可以在其他地方创建它并通过其名称调用它。

cleanup.go 文件包含一个名为 Foo() 的虚拟函数——因为它不包含任何实际代码,所以没有必要展示它。另一方面,所有重要的代码都可以在 cleanup_test.go 中找到。

func myCleanUp() func() {
    return func() {
        fmt.Println("Cleaning up!")
    }
} 

myCleanUp() 函数将被用作 CleanUp() 的参数,并应具有该特定签名。除了签名之外,你可以在 myCleanUp() 的实现中放置任何类型的代码。

func TestFoo(t *testing.T) {
    t1 := path.Join(os.TempDir(), "test01")
    t2 := path.Join(os.TempDir(), "test02") 

变量 t1t2 存储了我们将要创建的两个目录的路径。

 err := os.Mkdir(t1, 0755)
    if err != nil {
        t.Error("os.Mkdir() failed:", err)
        return
    } 

之前的代码使用 os.Mkdir() 创建了一个目录——我们手动指定了其路径。因此,当它不再需要时,删除该目录是我们的责任。

 defer t.Cleanup(func() {
        err = os.Remove(t1)
        if err != nil {
            t.Error("os.Mkdir() failed:", err)
        }
    }) 

TestFoo() 执行完毕后,t1 被传递给 t.CleanUp() 参数的匿名函数所删除。

 err = os.Mkdir(t2, 0755)
    if err != nil {
        t.Error("os.Mkdir() failed:", err)
        return
    }
} 

我们使用 os.Mkdir() 创建另一个目录——然而,在这种情况下,我们不会删除该目录。因此,在 TestFoo() 执行完毕后,t2 不会被删除。

func TestBar(t *testing.T) {
    t1 := t.TempDir() 

由于使用了 testing.TempDir() 方法,t1 的值(目录路径)由操作系统分配。此外,当测试函数即将结束时,该目录路径将被自动删除

 fmt.Println(t1)
    t.Cleanup(myCleanUp())
} 

在这里,我们使用 myCleanUp() 作为 Cleanup() 的参数。当你想要多次执行相同的清理时,这很方便。运行测试会创建以下输出:

$ go test -v *.go
=== RUN   TestFoo
--- PASS: TestFoo (0.00s)
=== RUN   TestBar
/var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/TestBar1090994662/001 

这是使用 macOS 机器上的 TempDir() 创建的临时目录。

Cleaning up!
--- PASS: TestBar (0.00s)
PASS
ok    command-line-arguments        0.493s 

检查由 TempDir() 创建的目录是否存在表明它们已被成功删除。另一方面,存储在 TestFoo()t2 变量中的目录尚未被删除。再次运行相同的测试(请记住禁用缓存)将会失败,因为 test02 文件已经存在且无法创建:

$ go test -v *.go -count=1
=== RUN   TestFoo
    cleanup_test.go:34: os.Mkdir() failed: mkdir /var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/test02: file exists
--- FAIL: TestFoo (0.00s)
=== RUN   TestBar
/var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/TestBar1703429008/001
Cleaning up!
--- PASS: TestBar (0.00s)
FAIL
FAIL    command-line-arguments    0.310s
FAIL 

错误信息 /var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/test02: 文件存在 揭示了问题的根源。因此,请正确清理您的测试

下一个子节讨论了 testing/quick 包的使用。

测试/快速包

有时候你需要在没有人为干预的情况下创建测试数据。Go 标准库提供了 testing/quick 包,它可以用于 黑盒测试(一种软件测试方法,在没有任何关于其内部工作先验知识的情况下检查应用程序或函数的功能),并且与 Haskell 编程语言中找到的 QuickCheck 包有些相关——这两个包都实现了帮助您进行黑盒测试的实用函数。借助 testing/quick,Go 生成用于测试的内置类型的随机值,这样您就无需手动生成所有这些值。

quickT.go 的代码如下:

package quickt
type Point2D struct {
    X, Y int
}
func Add(x1, x2 Point2D) Point2D {
    temp := Point2D{}
    temp.X = x1.X + x2.X
    temp.Y = x1.Y + x2.Y
    return temp
} 

之前的代码实现了一个单一的功能,该功能将两个 Point2D 变量相加——这是我们将要测试的功能。

quickT_test.go 的代码如下:

package quickt
import (
    "testing"
"testing/quick"
)
var N = 1000000
func TestWithItself(t *testing.T) {
    condition := func(a, b Point2D) bool {
        return Add(a, b) == Add(b, a)
    }
    err := quick.Check(condition, &quick.Config{MaxCount: N})
    if err != nil {
        t.Errorf("Error: %v", err)
    }
} 

quick.Check() 的调用会自动根据其第一个参数的签名生成随机数,该参数是一个之前定义的函数。您无需自己创建这些随机数,这使得代码易于阅读和编写。实际的测试发生在 condition 函数中。简单来说,我们首先定义一个属性函数,它代表我们想要在一系列输入中保持为真的条件。这个函数接受输入值,并返回一个布尔值,表示该属性对于这些值是否成立。

func TestThree(t *testing.T) {
    condition := func(a, b, c Point2D) bool {
        return Add(Add(a, b), c) == Add(a, b)
    } 

这个实现故意是错误的。为了纠正实现,我们应该将 Add(Add(a, b), c) == Add(a, b) 替换为 Add(Add(a, b), c) == Add(c, Add(a, b))。我们这样做是为了看到当测试失败时生成的输出。

 err := quick.Check(condition, &quick.Config{MaxCount: N})
    if err != nil {
        t.Errorf("Error: %v", err)
    }
} 

运行创建的测试生成以下输出:

$ go test -v *.go
=== RUN   TestWithItself
--- PASS: TestWithItself (0.86s) 

如预期的那样,第一个测试是成功的。

=== RUN   TestThree
    quickT_test.go:28: Error: #1: failed on input quickT.Point2D{X:-8079189616506550499, Y:-6176385978113309642}, quickT.Point2D{X:9017849222923794558, Y:-7161977443830767080}, quickT.Point2D{X:-714979330681957566, Y:-4578147860393889265}
--- FAIL: TestThree (0.00s)
FAIL
FAIL    command-line-arguments  0.618s
FAIL 

然而,正如预期的那样,第二个测试生成了一个错误。好事是,导致错误的输入显示在屏幕上,这样你就可以看到导致你的函数失败的输入。

下一小节将告诉我们如何超时那些执行时间太长的测试。

测试超时

如果 go test 工具完成得太慢,或者由于某种原因永远不结束,-timeout 参数可以帮助您。

为了说明这一点,我们使用了前一小节中的代码以及 -timeout-count 命令行标志。前者指定了测试允许的最大时间长度,后者指定了测试将要执行的数量。

运行 go test -v *.go -timeout 1s 告诉 go test 所有测试最多需要一秒钟完成——在我的机器上,测试确实少于一秒就完成了。

然而,运行以下代码会生成不同的输出:

$ go test -v *.go -timeout 1s -count 2
=== RUN   TestWithItself
--- PASS: TestWithItself (0.87s)
=== RUN   TestThree
    quickT_test.go:28: Error: #1: failed on input quickT.Point2D{X:-312047170140227400, Y:-5441930920566042029}, quickT.Point2D{X:7855449254220087092, Y:7437813460700902767}, quickT.Point2D{X:4838605758154930957, Y:-7621852714243790655}
--- FAIL: TestThree (0.00s)
=== RUN   TestWithItself
panic: test timed out after 1s 

实际输出比展示的要长——其余的输出与 goroutines 在完成前被终止有关。这里的关键点是,go test 命令由于使用了 -timeout 1s 而超时了进程。

到目前为止,我们已经看到了在测试失败时使用 Errorf() 的例子。下一个子节将讨论 testing.T.Fatalf()testing.T.Fatal() 的使用。

使用 testing.T.Fatal() 和 testing.T.Fatalf() 进行测试

本节讨论了 testing.T.Fatalf()testing.T.Fatal() 的使用。使用 T.Fatal()T.Fatalf() 而不是 T.Error()T.Errorf() 的核心思想是,当停止测试代码有意义时(因为之前的失败将导致更多失败),应使用 T.Fatal()T.Fatalf()。另一方面,当条件失败不会因为各种依赖而导致更多失败时,应使用适当的 T.Error() 变体。

使用 t.Fatalf() 的两个实际案例是:当测试所需的数据库连接失败时,或者当测试所需的网络连接无法建立时。

相关代码可以在 code.gocode_test.go 中找到,这两个文件都位于 ch12/testFatal 内。

code.go 的内容如下:

package server
var DATA = map[string]string{}
func init() {
    DATA["server"] = "127.0.0.1"
} 

server 包使用 init() 函数初始化 DATA 映射,通过为 "server" 键定义值。

code_test.go 的内容如下:

package server
import (
    "testing"
)
func TestMap(t *testing.T) {
    key := "server"
    server, ok := DATA[key]
    if !ok {
        t.Fatalf("Key %s not found!", key)
    }
    key = "port"
    port, ok := DATA[key]
    if !ok {
        t.Fatalf("Key %s not found!", key)
    }
    t.Log("Connecting to", server, "@port", port)
} 

在这种情况下,执行 t.Log() 调用是没有意义的,因为 "port" 键在 DATA 映射中未定义。在这种情况下,我们使用 t.Fatalf(),它终止测试过程。

运行测试生成以下输出:

$ go test -v *.go
=== RUN   TestMap
    code_test.go:17: Key port not found!
--- FAIL: TestMap (0.00s)
FAIL
FAIL    command-line-arguments    0.412s
FAIL 

因此,正如预期的那样,t.Log() 从未执行,测试失败。

下一个子节将讨论基于表的测试。

基于表的测试

基于表的测试是具有许多输入场景的测试。基于表的测试的主要优点是,开发者可以通过重用现有代码来覆盖大量的测试用例,从而节省时间和精力。为了将各种测试的参数放在同一个地方,我们通常使用结构体切片并遍历其元素来运行测试。

示例中所有相关代码都可以在 ch12/table 目录中找到,该目录包含两个文件。第一个文件名为 table.go,而用于测试的源代码文件名为 table_test.go

table.go 文件包含以下代码:

package division
func intDiv(a, b int) int {
    return a / b
}
func floatDiv(a, b int) float64 {
    return float64(a) / float64(b)
} 

我们将要测试的 Go 包包含两个函数,分别实现整数除法 (intDiv()) 和两个整数之间的浮点除法 (floatDiv())。如您从数学课程中回忆起来,两个整数之间的整数除法与常规除法的结果不同。例如,将 2 除以 4 在整数除法中结果为 0,而在常规除法中为 0.5。这意味着整数除法忽略余数,只产生整数结果,因此 intDiv() 函数的函数签名。

table_test.go 文件将分两部分介绍。第一部分包含以下代码:

package division
import (
    "testing"
)
type myTest struct {
    a        int
    b        int
    resInt   int
    resFloat float64
}
var tests = []myTest{
    {a: 1, b: 2, resInt: 0, resFloat: 0.5},
    {a: 5, b: 10, resInt: 0, resFloat: 0.5},
    {a: 2, b: 2, resInt: 1, resFloat: 1.0},
    {a: 4, b: 2, resInt: 2, resFloat: 2.0},
    {a: 5, b: 2, resInt: 2, resFloat: 2.5},
    {a: 5, b: 4, resInt: 1, resFloat: 1.2},
} 

tests 结构中的条目数量,可以取任何你想要的名称,表示我们将要执行测试的数量。最后一个条目中有一个故意的错误,即 5 除以 4 等于 1.25 而不是 1.2,这意味着相应的测试将会失败。

表格驱动测试的最大优点是添加新的测试与向包含现有测试的结构添加条目一样简单。在另一种情况下,您需要添加一个额外的测试函数。

table_test.go 的第二部分如下:

func TestAll(t *testing.T) {
    t.Parallel()
    for _, test := range tests {
        intResult := intDiv(test.a, test.b)
        if intResult != test.resInt {
            t.Errorf("Expected %d, got %d", test.resInt, intResult)
        }
        floatResult := floatDiv(test.a, test.b)
        if floatResult != test.resFloat {
            t.Errorf("Expected %f, got %f", test.resFloat, floatResult)
        }
    }
} 

Test_all() 测试函数遍历 tests 结构的内容并运行测试。t.Parallel() 语句允许测试并行运行,这使过程更快。在 shell 中执行 go doc testing.T.Parallel 以获取更多关于其使用的信息。然而,在这种情况下,t.Parallel() 没有作用,因为没有其他测试被标记为并行。

通常,像这里展示的浮点数比较通常是不可靠的。对于本书的目的来说,这是可以接受的,但你不应该在测试函数中依赖浮点数比较。更多关于这个话题的信息可以在 medium.com/p/9872fe6de17f 找到。

运行测试产生以下结果:

$ go test -v *.go
=== RUN   TestAll
    table_test.go:33: Expected 1.200000, got 1.250000
--- FAIL: TestAll (0.00s)
FAIL
FAIL    command-line-arguments    0.332s
FAIL 

所有测试都成功了,除了最后一个。下一个小节将展示如何查找关于您软件代码覆盖率的详细信息。

测试代码覆盖率

在本节中,我们将学习如何查找有关我们程序代码覆盖率的信息,以发现测试函数未执行的代码块或单个代码语句。

在其他方面,查看程序的代码覆盖率可以揭示代码中的逻辑问题和错误,所以不要低估它的有用性。然而,代码覆盖率测试补充了单元测试,而没有取代它。唯一要记住的是,你应该确保测试函数尝试覆盖所有情况,因此尝试运行所有可用的代码。如果测试函数没有尝试覆盖所有情况,那么问题可能出在它们身上,而不是正在被测试的代码。

所有相关文件都可以在 ch12/coverage 中找到。coverage.go 的代码如下,其中包含一些故意的问题,以展示如何识别不可达的代码:

package coverage
import "fmt"
func f1() {
    if true {
        fmt.Println("Hello!")
    } else {
        fmt.Println("Hi!")
    }
} 

这个函数的问题在于 if 的第一个分支总是为真,因此 else 分支永远不会被执行。

func f2(n int) int {
    if n >= 0 {
        return 0
    } else if n == 1 {
        return 1
    } else {
        return f2(n-1) + f2(n-2)
    }
} 

f2() 存在两个问题。第一个问题是它不适用于负整数,第二个问题是所有正整数都由第一个 if 分支处理。代码覆盖率只能帮助您解决第二个问题。coverage_test.go 的代码如下——这些是常规测试函数,试图运行所有可用的代码:

package coverage
import "testing"
func Test_f1(t *testing.T) {
    f1()
} 

这个测试函数天真地测试了 f1() 的操作。

func Test_f2(t *testing.T) {
    _ = f2(123)
} 

第二个测试函数通过运行 f2(123) 来检查 f2() 的操作。

首先,我们应该按照以下方式运行 go test——代码覆盖率任务由 s 标志完成:

$ go test -cover *.go
ok    command-line-arguments    0.420s    coverage: 50.0% of statements 

之前的输出显示我们有 50% 的代码覆盖率,这并不是一个好现象!然而,我们还没有完成,因为我们还可以生成一个测试覆盖率报告。下一个命令生成代码覆盖率报告:

$ go test -coverprofile=coverage.out *.go 

coverage.out 的内容如下——你的可能因用户名和使用的文件夹而略有不同:

$ cat coverage.out
mode: set
~/go/src/github.com/mGo4th/ch12/coverage/coverage.go:5.11,6.10 1 1
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:6.10,8.3 1 1
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:8.8,10.3 1 0
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:13.20,14.12 1 1
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:14.12,16.3 1 1
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:16.8,16.19 1 0
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:16.19,18.3 1 0
~/go/src/github.com/mactsouk/mGo4th/ch12/coverage/coverage.go:18.8,20.3 1 0 

覆盖率文件的格式和每行的字段是 name.go:line.column,line.column numberOfStatements count。最后一个字段是一个标志,告诉你指定的 line.column,line.column 语句是否被覆盖。所以,当你看到最后一个字段中的 0 时,这意味着代码没有被覆盖。

最后,通过运行 go tool cover -html=coverage.out,你可以在你喜欢的网页浏览器中看到 HTML 输出。如果你使用了不同于 coverage.out 的文件名,请相应地修改命令。下一张图显示了生成的输出——如果你正在阅读本书的打印版,你可能看不到颜色。红色线条表示未执行的代码,而绿色线条表示由测试执行的代码。

计算机屏幕截图  描述自动生成

图 12.2:代码覆盖率报告

一些代码被标记为未跟踪(灰色),因为这是代码覆盖率工具无法处理的代码。生成的输出清楚地显示了 f1()f2() 的代码问题。你现在只需纠正它们即可!

下一个子节讨论了无法到达的代码以及如何发现它。

寻找无法到达的 Go 代码

有时,一个错误实现的 if 语句或一个放置不当的 return 语句可以创建一些无法到达的代码块,也就是说,这些代码块根本不会被执行。由于这是一个逻辑错误,这意味着它不会被编译器捕获,我们需要找到一种方法来发现它。

幸运的是,go vet 工具,它检查 Go 源代码并报告可疑结构,可以帮助我们完成这项工作——go vet 的使用通过 cannotReach.go 源代码文件来展示,该文件包含以下两个函数:

func S2() {
    return
    fmt.Println("Hello!")
} 

这里存在一个逻辑错误,因为 S2() 在打印期望的消息之前就返回了。

func S1() {
    fmt.Println("In S1()")
    return
    fmt.Println("Leaving S1()")
} 

同样,S1() 返回,没有给 fmt.Println("Leaving S1()") 语句执行的机会。

cannotReach.go 上运行 go vet 生成以下输出:

$ go vet cannotReach.go
# command-line-arguments
./cannotReach.go:9:2: unreachable code
./cannotReach.go:16:2: unreachable code 

第一条信息指向 S2()fmt.Println() 语句,第二条信息指向 S1() 的第二个 fmt.Println() 语句。在这种情况下,go vet 做得很好。然而,go vet 并非特别复杂,无法捕捉到所有可能的逻辑错误。如果您需要更高级的工具,可以看看 staticcheck (staticcheck.io/),它也可以与 Microsoft Visual Studio Code (code.visualstudio.com/)、Neovim (!) 和 Zed (zed.dev/) 集成——下一图显示了 Zed 下划线了不可达的代码。Visual Studio Code 以类似的方式工作。

计算机程序屏幕截图  自动生成的描述

图 12.3:在 Zed 中查看不可达的代码

作为一条经验法则,在您的流程中包含 go vet 并无害处。您可以通过运行 go doc cmd/vet 来找到有关 go vet 功能的更多信息。

下一个子节说明了如何测试具有数据库后端的 HTTP 服务器。

测试具有数据库后端的 HTTP 服务器

HTTP 服务器是一种不同的动物,因为它应该已经运行,以便测试能够执行。幸运的是,net/http/httptest 包可以帮助您——您不需要自己运行 HTTP 服务器,因为 net/http/httptest 包会为您完成这项工作,但您需要确保数据库服务器正在运行。我们将测试我们在 第十一章 中开发的 REST API 服务器,与 REST API 一起工作。所有相关文件都位于 ch12/testHTTP 内。

存储测试 HTTP 服务测试函数的 server_test.go 代码如下:

package main
import (
    "bytes"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
) 

包含 github.com/gorilla/mux 的唯一原因是在之后使用 mux.SetURLVars()

func TestTimeHandler(t *testing.T) {
    req, err := http.NewRequest("GET", "/time", nil)
    if err != nil {
        t.Fatal(err)
    }
    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(TimeHandler)
    handler.ServeHTTP(rr, req)
    status := rr.Code
    if status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }
} 

http.NewRequest() 函数用于定义 HTTP 请求方法、端点,并在需要时向端点发送数据。http.HandlerFunc(TimeHandler) 调用指定了正在测试的处理函数。

func TestMethodNotAllowed(t *testing.T) {
    req, err := http.NewRequest("DELETE", "/time", nil)
    if err != nil {
        t.Fatal(err)
    }
    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(MethodNotAllowedHandler) 

我们在这个测试函数中测试 MethodNotAllowedHandler

 handler.ServeHTTP(rr, req)
    status := rr.Code
    if status != http.StatusNotFound {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }
} 

我们知道这次交互将会失败,因为我们正在测试 MethodNotAllowedHandler。因此,我们期望得到一个 http.StatusNotFound 的响应代码——如果我们得到不同的代码,测试函数将会失败。

func TestLogin(t *testing.T) {
    UserPass := []byte(`{"Username": "admin", "Password": "admin"}`) 

在这里,我们将 User 结构体所需的字段存储在字节切片中。为了测试能够正常工作,admin 用户应该将 admin 作为密码,因为这是代码中使用的——修改 server_test.go 以确保 admin 用户的密码正确,或者任何其他具有管理员权限的用户,以适应您的安装。

通常情况下,尤其是在涉及多个开发者的关键应用程序或项目中,这不是一个好的做法。理想情况下,测试所需的一切都应该包含在测试中。一个可能的解决方案是提供一个单独的数据库或一台仅用于测试目的的单独机器。

 req, err := http.NewRequest("POST", "/login", bytes.NewBuffer(UserPass))
    if err != nil {
        t.Fatal(err)
    }
    req.Header.Set("Content-Type", "application/json") 

之前的代码行构建了所需的请求,这是关于登录到服务的请求。

 rr := httptest.NewRecorder()
    handler := http.HandlerFunc(LoginHandler)
    handler.ServeHTTP(rr, req) 

NewRecorder() 返回一个初始化的 ResponseRecorder,它在 ServeHTTP() 中使用——ServeHTTP() 是执行请求的方法。响应被保存在 rr 变量中。

还有一个用于 /logout 端点的测试函数,这里没有展示,因为它几乎与 TestLogin() 相同。在这种情况下,以随机顺序运行测试可能会在测试中引起问题,因为 TestLogin() 应始终在 TestLogout() 之前执行。

 status := rr.Code
    if status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
        return
    }
} 

如果状态码是 http.StatusOK,这意味着交互按预期工作。

func TestAdd(t *testing.T) {
    now := int(time.Now().Unix())
    username := "test_" + strconv.Itoa(now)
    users := `[{"Username": "admin", "Password": "admin"}, {"Username":"` + username + `", "Password": "myPass"}]` 

对于 Add() 处理器,我们需要传递一个 JSON 记录数组,这里进行了构建。因为我们不希望每次都创建相同的用户名,所以我们将在 _test 字符串中附加当前的时间戳。

 UserPass := []byte(users)
    req, err := http.NewRequest("POST", "/add", bytes.NewBuffer(UserPass))
    if err != nil {
        t.Fatal(err)
    }
    req.Header.Set("Content-Type", "application/json") 

这是我们构建 JSON 记录切片 (UserPass) 并创建请求的地方。

 rr := httptest.NewRecorder()
    handler := http.HandlerFunc(AddHandler)
    handler.ServeHTTP(rr, req)
    // Check the HTTP status code is what we expect.
if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
        return
    }
} 

如果服务器响应是 http.StatusOK,则请求成功且测试通过。

func TestGetUserDataHandler(t *testing.T) {
    UserPass := []byte(`{"Username": "admin", "Password": "admin"}`)
    req, err := http.NewRequest("GET", "/username/1", bytes.NewBuffer(UserPass)) 

虽然我们在请求中使用了 /username/1,但这不会给 Vars 映射添加任何价值。因此,我们需要使用 SetURLVars() 函数来更改 Vars 映射中的值——这将在下面说明:

 if err != nil {
        t.Fatal(err)
    }
    req.Header.Set("Content-Type", "application/json")
    vars := map[string]string{
        "id": "1",
    }
    req = mux.SetURLVars(req, vars) 

gorilla/mux 包提供了 SetURLVars() 函数用于测试目的——这个函数允许你向 Vars 映射中添加元素。在这种情况下,我们需要将 id 键的值设置为 1。你可以添加任意多的键/值对。

 rr := httptest.NewRecorder()
    handler := http.HandlerFunc(GetUserDataHandler)
    handler.ServeHTTP(rr, req)
    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
        return
    }
expected := `{"id":1,"username":"admin","password":"admin",
"lastlogin":1702577035,"admin":1,"active":0}` 

expected 变量包含我们期望从请求中获取的记录。

使用相同的 lastlogin 值没有任何意义。因此,我们可能在这里遇到了一个错误。此外,如果我们无法猜测服务器响应中 lastlogin 的值,我们可能需要在 expectedserverResponse 中将其替换为 0。另一种选择是将结果序列化为结构体,并仅比较测试相关的部分。

 serverResponse = strings.TrimSpace(serverResponse) 

之前的声明从 HTTP 服务器响应中移除了所有空格。

 if serverResponse != expected {
        t.Errorf("handler returned unexpected body: got %v but wanted %v", serverResponse, expected)
    }
} 

代码的最后部分包含了标准 Go 检查我们是否收到了预期答案的方法。

一旦你理解了所提供的示例,为 HTTP 服务创建测试就变得简单了。这主要是因为大多数代码在测试函数之间是重复的。

运行测试生成了以下输出:

$ go test -v server_test.go main.go handlers.go restdb.go 
=== RUN   TestTimeHandler
2023/12/14 22:12:30 TimeHandler Serving: /time from
--- PASS: TestTimeHandler (0.00s)
=== RUN   TestMethodNotAllowed
2023/12/14 22:12:30 Serving: /time from  with method DELETE
--- PASS: TestMethodNotAllowed (0.00s)
=== RUN   TestLogin 

这是使用 DELETE HTTP 方法访问 /time 端点时的输出。其结果是 PASS,因为我们预计这个请求会失败,因为它使用了错误的 HTTP 方法。

2023/12/14 22:12:30 LoginHandler Serving: /login from
2023/12/14 22:12:30 Input user: {0 admin admin 0 0 0}
2023/12/14 22:12:30 Found user: {1 admin admin 1702577035 1 0}
2023/12/14 22:12:30 Logging in: {1 admin admin 1702577035 1 0}
2023/12/14 22:12:30 Updating user: {1 admin admin 1702577825 1 1}
2023/12/14 22:12:30 Affected: 1
2023/12/14 22:12:30 User updated: {1 admin admin 1702577728 1 1}
--- PASS: TestLogin (0.00s) 

这是从 TestLogin() 测试 /login 端点得到的输出。所有以日期和时间开头的行为 REST API 服务器生成,显示了请求的进度。

=== RUN   TestLogout
2023/12/14 22:12:30 LogoutHandler Serving: /logout from
2023/12/14 22:12:30 Found user: {1 admin admin 1702577035 1 1}
2023/12/14 22:12:30 Logging out: admin
2023/12/14 22:12:30 Updating user: {1 admin admin 1702577035 1 0}
2023/12/14 22:12:30 Affected: 1
2023/12/14 22:12:30 User updated: {1 admin admin 1702577035 1 0}
--- PASS: TestLogout (0.00s) 

这是 TestLogout() 测试 /logout 端点并得到 PASS 结果的输出。

=== RUN   TestAdd
2023/12/14 22:12:30 AddHandler Serving: /add from
2023/12/14 22:12:30 [{0 admin admin 0 0 0} {0 test_1702577728 myPass 0 0 0}] 

这是 TestAdd() 测试函数的输出。新创建的用户名为 test_1702577728,并且每次执行测试时都应该不同。

--- PASS: TestAdd (0.00s)
=== RUN   TestGetUserDataHandler
2023/12/14 22:12:30 GetUserDataHandler Serving: /username/1 from
2023/12/14 22:12:30 Found user: {1 admin admin 1702577035 1 0}
--- PASS: TestGetUserDataHandler (0.00s)
PASS
ok    command-line-arguments    0.329s 

最后,这是 TestGetUserDataHandler() 测试函数的输出,该函数也执行无误。

下一节介绍了 govulncheck 工具,该工具用于查找项目依赖项中的漏洞。

govulncheck 工具

govulncheck 工具的目的是在项目依赖项中查找漏洞。这意味着它存在是为了使你的 Go 二进制文件和 Go 模块更加安全。

安装工具

你可以通过运行以下命令来安装 govulncheck

$ go install golang.org/x/vuln/cmd/govulncheck@latest
$ cd ~/go/bin
$ ls -lh govulncheck
-rwxr-xr-x@ 1 mtsouk  staff    11M Dec  9 19:41 govulncheck 

如预期的那样,govulncheck 二进制文件将被安装到 ~/go/bin

相关的 Go 代码可以在 ch12/vulcheck 内找到——源代码文件名为 vul.go,包含以下代码:

package main
import (
    "fmt"
"golang.org/x/text/language"
)
func main() {
    greece := language.Make("el")
    en := language.Make("en")
    fmt.Println(greece.Region())
    fmt.Println(en.Region())
} 

运行 vul.go 需要先执行以下命令:

$ go mod init
$ go mod tidy
go: finding module for package golang.org/x/text/language
go: downloading golang.org/x/text v0.14.0
go: found golang.org/x/text/language in golang.org/x/text v0.14.0 

根据 go mod tidy 的前一个输出,go.mod 的内容如下:

$ cat go.mod
module github.com/mactsouk/mGo4th/ch12/vulcheck
go 1.21.5
require golang.org/x/text v0.14.0 

因此,我们正在使用 golang.org/x/text 包的 v0.14.0 版本。

vul.go 运行 govulncheck 产生以下结果:

$ ~/go/bin/govulncheck ./...
Scanning your code and 47 packages across 1 dependent module for known vulnerabilities...
No vulnerabilities found.
Share feedback at https://go.dev/s/govulncheck-feedback 

现在,让我们故意将 go.mod 的内容更改以包含具有已知漏洞的包版本。这需要执行以下命令:

$ go get golang.org/x/text@v0.3.5
go: downloading golang.org/x/text v0.3.5
go: downgraded golang.org/x/text v0.14.0 => v0.3.5 

上一个命令的目的是下载具有已知漏洞的较旧版本的 golang.org/x/text 包。

go.mod 的内容现在是以下内容:

module github.com/mactsouk/mGo4th/ch12/vulcheck
go 1.21.5
require golang.org/x/text v0.3.5 

因此,我们现在正在使用 golang.org/x/text 包的 v0.3.5 版本。

这次,对 vul.go 运行 govulncheck 产生以下输出:

$ ~/go/bin/govulncheck ./...
Scanning your code and 47 packages across 1 dependent module for known vulnerabilities...
=== Informational ===
Found 2 vulnerabilities in packages that you import, but there are no call
stacks leading to the use of these vulnerabilities. You may not need to
take any action. See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck
for details.
Vulnerability #1: GO-2022-1059
    Denial of service via crafted Accept-Language header in
    golang.org/x/text/language
  More info: https://pkg.go.dev/vuln/GO-2022-1059
  Module: golang.org/x/text
    Found in: golang.org/x/text@v0.3.5
    Fixed in: golang.org/x/text@v0.3.8
Vulnerability #2: GO-2021-0113
    Out-of-bounds read in golang.org/x/text/language
  More info: https://pkg.go.dev/vuln/GO-2021-0113
  Module: golang.org/x/text
    Found in: golang.org/x/text@v0.3.5
    Fixed in: golang.org/x/text@v0.3.7
No vulnerabilities found.
Share feedback at https://go.dev/s/govulncheck-feedback 

这次,我们在我们使用的模块中发现了漏洞。解决方法是运行以下命令升级到 golang.org/x/text 包的最新版本:

$ go get golang.org/x/text@latest
go: upgraded golang.org/x/text v0.3.5 => v0.14.0 

如果你想要以 JSON 格式获取输出,你可以使用 -json 标志运行 govulncheck,如下面的输出所示:

$ ~/go/bin/govulncheck -json ./...
{
  "config": {
    "protocol_version": "v1.0.0",
    "scanner_name": "govulncheck",
    "scanner_version": "v1.0.1",
    "db": "https://vuln.go.dev",
    "db_last_modified": "2023-12-11T21:16:41Z",
    "go_version": "go1.21.5",
    "scan_level": "symbol"
  }
}
{
  "progress": {
    "message": "Scanning your code and 47 packages across 1 dependent module for known vulnerabilities..."
  }
} 

你绝对应该养成在项目中使用 govulncheck 的习惯。

下一节讨论了一个实用的 Go 功能,交叉编译,因为测试完你的代码后,你通常想要分发它!

交叉编译

交叉编译是在没有访问其他机器的情况下,为不同于我们正在工作的架构生成二进制可执行文件的过程。我们从交叉编译中获得的主要好处是我们不需要第二台或第三台机器来创建和分发不同架构的可执行文件。这意味着我们基本上只需要一台机器进行开发。幸运的是,Go 内置了对交叉编译的支持。

要交叉编译 Go 源文件,我们需要将 GOOSGOARCH 环境变量分别设置为目标的操作系统和架构,这并不像听起来那么困难。

你可以在 go.dev/doc/install/source 找到 GOOSGOARCH 环境变量的可用值列表。然而,请注意,并非所有 GOOSGOARCH 组合都是有效的。你可以使用 go tool dist list 命令找到所有有效组合的列表。

crossCompile.go 的代码如下:

package main
import (
    "fmt"
"runtime"
)
func main() {
    fmt.Print("You are using ", runtime.GOOS, " ")
    fmt.Println("on a(n)", runtime.GOARCH, "machine")
    fmt.Println("with Go version", runtime.Version())
} 

在 macOS 机器上运行(Go 版本 1.21.5)会生成以下输出:

$ go run crossCompile.go
You are using darwin on a(n) arm64 machine
with Go version go1.21.5 

在 macOS 机器上运行以下命令,就可以简单地编译 crossCompile.go 以适用于运行在 amd64 处理器上的 Linux OS:

$ env GOOS=linux GOARCH=amd64 go build crossCompile.go
$ file crossCompile
crossCompile: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=28hWIc2cet8-kmHxC-W6/Y7DEXRrm3CrFgqVvflBA/KWqCcnUNgUozkElJHidj/Geqp0vSmfLgkLrZ_-7cX, with debug_info, not stripped 

将该文件传输到一台 Arch Linux 机器并运行它,会生成以下输出:

$ ./crossCompile 
You are using linux on a(n) amd64 machine
with Go version go1.21.5 

这里需要注意的一点是,crossCompile.go 的交叉编译二进制文件会打印出编译它的机器的 Go 版本——这完全合理,因为目标机器甚至可能没有安装 Go!

交叉编译是 Go 的一个很棒的功能,当你想要通过 CI/CD 系统生成多个可执行文件的版本并分发它们时,它非常有用。

下一个子节将讨论 go:generate

使用 go:generate

虽然 go:generate 并不直接与测试或分析相关联,但它是一个方便且高级的 Go 功能,我相信这一章是讨论它的完美地方,因为它还可以帮助你进行测试。go:generate 指令与 go generate 命令相关联,该命令在 Go 1.4 中添加,旨在帮助自动化,并允许你在现有文件中运行由指令描述的命令。

go generate 命令支持 -v-n-x 标志。-v 标志在处理过程中打印包和文件名,而 -n 标志打印将要执行的命令。最后,-x 标志在执行时打印命令——这对于调试 go:generate 命令非常有用。

你可能需要使用 go:generate 的主要原因如下:

  • 你想在执行 Go 代码之前从互联网或其他来源下载动态数据。

  • 你想在运行 Go 代码之前执行一些代码。

  • 你想在代码执行之前生成版本号或其他唯一数据。

  • 你想确保你有样本数据可以工作。例如,你可以使用 go:generate 将数据放入数据库中。

由于使用 go:generate 被认为不是一种好的做法,因为它会隐藏信息给开发者并创建额外的依赖,所以我尽量在可能的情况下避免使用它,而且通常可以做到。另一方面,如果你真的需要它,你会知道的!

go:generate 的使用在 goGenerate.go 中得到了说明,该文件位于 ./ch12/generate,内容如下:

package main
import "fmt"
//go:generate ./echo.sh 

这将执行 echo.sh 脚本,该脚本应位于当前目录中。

//go:generate echo GOFILE: $GOFILE
//go:generate echo GOARCH: $GOARCH
//go:generate echo GOOS: $GOOS
//go:generate echo GOLINE: $GOLINE
//go:generate echo GOPACKAGE: $GOPACKAGE 

$GOFILE$GOARCH$GOOS$GOLINE$GOPACKAGE 是特殊变量,它们在执行时会被翻译。

//go:generate echo DOLLAR: $DOLLAR
//go:generate echo Hello!
//go:generate ls -l
//go:generate ./hello.py 

这将执行 hello.py Python 脚本,该脚本应位于当前目录中。

func main() {
    fmt.Println("Hello there!")
} 

go generate命令不会运行 Go 源文件中找到的fmt.Println()语句或其他任何语句。最后,请记住,go generate不会自动执行,必须显式运行。

./ch12/generate目录内使用goGenerate.go生成下一个输出:

$ go mod init
$ go mod tidy
$ go generate
Hello world!
GOFILE: goGenerate.go
GOARCH: arm64
GOOS: darwin
GOLINE: 10
GOPACKAGE: main 

这是$GOFILE$GOARCH$GOOS$GOLINE$GOPACKAGE变量的输出,它显示了这些变量在运行时的值。

DOLLAR: $ 

此外,还有一个名为$DOLLAR的特殊变量,用于在输出中打印美元字符,因为在操作系统环境中$有特殊含义。

Hello!
total 32
-rwxr-xr-x@ 1 mtsouk  staff   32 Nov 10 22:22 echo.sh
-rw-r--r--  1 mtsouk  staff   59 Dec 14 20:25 go.mod
-rw-r--r--@ 1 mtsouk  staff  383 Nov 10 22:22 goGenerate.go
-rwxr-xr-x@ 1 mtsouk  staff   52 Nov 10 22:22 hello.py 

这是ls -l命令的输出,它显示了代码执行时的当前目录中的文件。这可以用来测试在执行时是否有一些必要的文件存在。

Hello from Python! 

使用-n选项运行go generate会显示将要执行的命令:

$ go generate -n
./echo.sh
echo GOFILE: goGenerate.go
echo GOARCH: arm64
echo GOOS: darwin
echo GOLINE: 10
echo GOPACKAGE: main
echo DOLLAR: $
echo Hello!
ls -l
./hello.py 

因此,go:generate可以在程序执行前与操作系统交互。然而,由于它隐藏了某些内容,其使用应该受到限制。

本章的最后部分讨论示例函数。

创建示例函数

文档过程的一部分是生成示例代码,展示了一个包中某些或所有函数和数据类型的使用。示例函数有许多好处,包括它们是可执行的测试,由go test执行。因此,如果一个示例函数包含// Output:行,go test工具将检查计算出的输出是否与// Output:行之后找到的值匹配。尽管我们应该将示例函数包含在以_test.go结尾的 Go 文件中,但我们不需要为示例函数导入测试 Go 包。此外,每个示例函数的名称必须以Example开头。最后,示例函数不接收任何输入参数,也不返回任何结果。

我们将使用exampleFunctions.goexampleFunctions_test.go的代码来展示示例函数。exampleFunctions.go的内容如下:

package exampleFunctions
func LengthRange(s string) int {
    i := 0
for _, _ = range s {
        i = i + 1
    }
    return i
} 

之前的代码展示了一个包含一个名为LengthRange()的单个函数的常规包。exampleFunctions_test.go的内容,包括示例函数,如下所示:

package exampleFunctions
import "fmt"
func ExampleLengthRange() {
    fmt.Println(LengthRange("Mihalis"))
    fmt.Println(LengthRange("Mastering Go, 4th edition!"))
    // Output:
// 7
// 7
} 

注释行说明预期的输出是77,这显然是错误的。这将在我们运行go test后看到:

$ go test -v exampleFunctions*
=== RUN   ExampleLengthRange
--- FAIL: ExampleLengthRange (0.00s)
got:
7
26
want:
7
7
FAIL
FAIL    command-line-arguments  0.410s
FAIL 

如预期的那样,生成的输出中存在一个错误——第二个生成的值是26,而不是预期的7。如果我们进行必要的修正,输出将如下所示:

$ go test -v exampleFunctions*
=== RUN   ExampleLengthRange
--- PASS: ExampleLengthRange (0.00s)
PASS
ok      command-line-arguments  0.572s 

示例函数可以是学习包功能以及测试函数正确性的一个很好的工具,所以我建议你在你的 Go 包中包含测试代码和示例函数。作为额外的好处,如果你的测试函数出现在包的文档中,那么你可以选择生成包文档。

摘要

本章讨论了 go:generate、代码分析和跟踪以及测试 Go 代码。你可能觉得 Go 的测试方式很无聊,但这正是因为 Go 在总体上既无聊又可预测,而这是一件好事!记住,编写无错误的代码很重要,而编写尽可能快的代码并不总是那么重要。

大多数时候,你需要能够编写足够快的代码。所以,花更多的时间写测试而不是基准测试,除非你的代码运行得真的很慢。你也已经学会了如何找到不可达的代码以及如何交叉编译 Go 代码。

尽管关于 Go 分析器和 go tool trace 的讨论远未完成,但你应该明白,在诸如分析、代码跟踪等主题上,没有什么能取代亲自实验和尝试新技术的经验!

下一章将介绍模糊测试,这是本章中介绍测试技术之外的另一种现代测试 Go 代码的方法。我们还将探讨 Go 中的可观察性。

练习

  • 为计算斐波那契数列的包创建测试函数。不要忘记实现该包。

  • testHTTP/server_test.go 中的代码在 expected 变量中使用相同的 lastlogin 值。这显然是 restdb.go 中的一个错误,因为 lastlogin 的值应该被更新。在修复错误后,修改 testHTTP/server_test.go 以考虑 lastlogin 字段的不同值。

  • 尝试在各个操作系统下找到 os.TempDir() 的值。

  • 输入 go doc os/signal.NotifyContext 以查看你可以在 context.Context 环境中处理信号。尝试创建一个使用 signal.NotifyContext 的示例。

其他资源

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

第十三章:模糊测试与可观察性

本章的主题有两个方面。首先,我们将讨论模糊测试,这是 Go 语言的一个新特性,它改进了测试过程;其次,我们将讨论可观察性,这可以帮助你理解当一切按预期进行但速度不如预期时,系统正在发生什么。

通过模糊测试,我们将意外事件引入测试。主要好处是你可以使用随机和不可预测的值和数据来测试你的代码,这可能导致发现未知漏洞。这也导致了测试覆盖率的提高,测试自动化和效率的提升,更好的持续测试,软件质量的提高,以及成本效益高的安全测试。

可观察性指的是根据系统的外部输出或可观察信号来理解、测量和分析系统内部状态和行为的能力。在计算机系统和软件应用的情况下,可观察性对于监控、故障排除和维护系统的健康和性能至关重要。因此,可观察性帮助我们了解程序未知的方面。

可观察性的第一规则是知道你在寻找什么。这意味着如果你不知道要寻找什么,你可能会收集错误的数据,忽略有用的指标,而专注于无关紧要的指标!

此外,读取指标而不存储和可视化它们是无效的。因此,本章还说明了如何将你的指标暴露给 Prometheus,以及如何使用 Grafana 可视化存储在 Prometheus 中的数据。请记住,Prometheus 并不是存储时间序列数据的唯一软件。Prometheus 的一个替代方案是 Elasticsearch。在这种情况下,你可能需要使用 Kibana 而不是 Grafana。重要的是关键思想保持一致。

在本章中,我们将涵盖以下主题:

  • 模糊测试

  • 可观察性

  • 将指标暴露给 Prometheus

我们从本章的模糊测试开始。

模糊测试

作为软件工程师,我们担心的是意外事件,而不是事情按预期进行时。处理意外事件的一种方法就是模糊测试。模糊测试(或模糊测试)是一种测试技术,它为需要输入的程序生成无效、意外或随机数据。

模糊测试擅长发现代码中的安全和漏洞问题——手动测试并不总是理想的,因为这些测试可能不会考虑到所有潜在的不受信任的输入,特别是可能破坏系统的无效输入。然而,模糊测试不能取代单元测试。这意味着模糊测试不是万能的,不能取代所有其他测试技术。因此,模糊测试更适合测试解析输入的代码,这包括缓冲区溢出和 SQL 注入等案例。

模糊测试的主要优势包括以下内容:

  • 你可以确保代码能够处理无效或随机输入。

  • 通过模糊测试发现的错误可能很严重,并可能表明存在安全风险。

  • 恶意攻击者经常使用模糊测试来定位漏洞,因此做好准备是好的。

与模糊测试一样,我们使用 testing.T 进行测试和 testing.B 进行基准测试——基准测试 Go 代码在 第十四章效率和性能 中有所介绍。此外,模糊测试函数以 Fuzz 开头,就像测试函数以 Test 开头一样。

当模糊测试运行失败时,导致问题的数据将被保存在 testdata 目录下,之后,即使是常规测试也将失败——因为它们将自动使用那些数据——直到我们纠正相关的问题或错误。如果您需要重新运行常规测试,请随意删除该 testdata 目录。

下一个子节提供了一个模糊测试的简单示例。

简单的模糊测试示例

在本小节中,我们将创建一个简单的示例,使用模糊测试来更好地理解它。相关的代码可以在 ch13/fuzz 下找到。

为了这个简单的示例,我们将查看测试 code.go 中简单 Go 函数的代码。code.go 的内容如下:

package main
import (
    "fmt"
)
func AddInt(x, y int) int {
    for i := 0; i < x; i++ {
        y = y + 1
    }
    return y
}
func main() {
    fmt.Println(AddInt(5, 4))
} 

这里有一个问题:AddInt() 没有正确实现,因为当 x 参数为负值时,for 循环将不会工作。

包含测试的 code_test.go 文件将分为两部分。第一部分包含以下代码:

package main
import (
    "testing"
)
func TestAddInt(t *testing.T) {
    testCases := []struct {
        x, y, want int
    }{
        {1, 2, 3},
        {1, 0, 1},
        {100, 10, 110},
    }
    for _, tc := range testCases {
        result := AddInt(tc.x, tc.y)
        if result != tc.want {
            t.Errorf("X: %d, Y: %d, want %d", tc.x, tc.y, tc.want)
        }
    }
} 

这一部分以通常的方式实现了一个测试函数。然而,在这种情况下,我们只使用正整数(自然数)测试 AddInt(),这意味着它将没有任何问题地工作。

code_test.go 的第二部分包含以下代码:

func **FuzzAddInt**(f *testing.F) {
    testCases := []struct {
        x, y int
    }{
        {0, 1},
        {0, 100},
    }
    for _, tc := range testCases {
        f.Add(tc.x, tc.y)
    } 

这一部分实现了一个名为 FuzzAddInt() 的模糊测试函数,可以通过 testing.F 数据类型的使用以及其以 Fuzz 开头的名称来验证。testing.F 数据类型提供了 Add()Fuzz() 方法,分别用于提供(可选的)起始输入和运行实际的模糊测试。

语料库 是指导模糊测试过程的输入集合,由两部分组成。第一部分是 种子语料库,第二部分是 生成语料库种子语料库 可以通过 Add() 函数调用和/或 testdata/fuzz 目录中的数据提供。生成语料库 完全由机器生成。不需要有种子语料库。

Add() 函数将数据添加到 种子语料库,并且可以根据需要多次调用——在这种情况下,我们调用了 Add() 两次。

 f.Fuzz(func(t *testing.T, x, y int) {
        result := AddInt(x, y)
        if result != x+y {
            t.Errorf("X: %d, Y: %d, Result %d, want %d", x, y, result, x+y)
        }
    })
} 

在(可选的)Add() 函数之后,我们需要调用 Fuzz(),这需要一个 *testing.T 变量以及一个模糊参数列表,这些参数应该与 Add() 中使用的参数数量和数据类型相同,通常与被测试函数的参数数量相同。

简而言之,我们在模糊测试函数中嵌入常规测试函数——这些常规测试函数的输入是由模糊测试过程根据生成的语料库提供的。

因此,我们告诉 f.Fuzz() 我们需要两个额外的 int 参数,除了强制性的 *testing.T 之外,这两个参数分别命名为 xy。这两个参数是测试的输入。因此,f.Add() 调用也应该有两个参数。

运行常规测试就像执行以下命令一样简单:

$ go test *.go
ok    command-line-arguments    0.427s 

因此,测试函数没有发现 AddInt() 存在问题。

运行模糊测试需要使用 -fuzz 命令行参数,后跟模糊函数的名称。因此,我们需要执行以下命令:

$ go test -fuzz=FuzzAddInt *.go
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: elapsed: 0s, execs: 6 (410/sec), new interesting: 2 (total: 4)
--- FAIL: FuzzAddInt (0.02s)
    --- FAIL: FuzzAddInt (0.00s)
        code_test.go:40: X: -63, Y: 32, Result 32, want -31
    **Failing input written to testdata/fuzz/FuzzAddInt/b403d5353f8afe03**
    To re-run:
    go test -run=FuzzAddInt/b403d5353f8afe03
FAIL
exit status 1
FAIL    command-line-arguments    0.222s 

因此,模糊测试捕获了 AddInt() 的错误。简单来说,模糊测试过程包括负整数在测试中,并捕获了由 for 循环的使用产生的逻辑错误——我们没有!

testdata/fuzz/FuzzAddInt/b403d5353f8afe03 的内容如下:

$ cat testdata/fuzz/FuzzAddInt/b403d5353f8afe03
go test fuzz v1
int(-63)
int(32) 

修复 AddInt() 作为练习留给您——作为一个提示,考虑在 for 循环中使用的参数为负时使用不同的代码。在我们的例子中,导致错误条件的函数参数是 x

下一个子节将展示一个更实际的模糊测试示例。

高级模糊测试示例

在这个子节中,我们展示了一个更高级的模糊测试示例。相关代码位于 ch13/reversereverse.go 中的代码如下:

package main
**// This version has bugs**
import (
    "fmt"
)
func R1(s string) []byte {
    sAr := []byte(sAr)
    rev := make([]byte, len(s))
    l := len(sAr)
    for i := 0; i < l; i++ {
        rev[i] = sAr[l-1-i]
    }
    return rev
} 

在这个第一部分中,我们实现了一个名为 R1() 的函数,该函数反转一个字符串。内部,该函数将输入的 string 值转换为字节切片并返回一个字节切片。

func R2(s string) string {
    b := []byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
} 

在这个部分中,我们实现了一个名为 R2() 的函数,该函数也反转一个字符串。内部,该函数使用字节切片,但返回一个 string 值。

func main() {
    str := "1234567890"
    fmt.Println(string(R1(str)))
    fmt.Println(R2(str))
} 

main() 函数调用 R1()R2() 两个函数以反转 "1234567890" 字符串——这是一种测试实现功能的基本方法。

运行 reverse.go 生成以下输出:

$ go run reverse.go
0987654321
0987654321 

因此,最初,代码看起来是正确的,并产生了预期的输出。现在,让我们为 reverse.go 中的两个函数编写一些测试。

reverse_test.go 中的代码分为三个部分。第一部分如下:

package main
import (
    "testing"
"unicode/utf8"
)
func TestR1(t *testing.T) {
    testCases := []struct {
        in, want string
    }{
        {" ", " "},
        {"!12345@", "@54321!"},
        {"Mastering Go", "oG gniretsaM"},
    }
    for _, tc := range testCases {
        rev := R1(tc.in)
        if string(rev) != tc.want {
            t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
} 

前面是 R1() 的测试函数。

func TestR2(t *testing.T) {
    testCases := []struct {
        in, want string
    }{
        {" ", " "},
        {"!12345@", "@54321!"},
        {"Mastering Go", "oG gniretsaM"},
    }
    for _, tc := range testCases {
        rev := R2(tc.in)
        if rev != tc.want {
            t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
} 

前面是 R2() 的测试函数。

TestR1()TestR2() 都是常规测试函数,使用用户定义的测试,这些测试存储在 testCases 结构中。第一个字段是原始字符串,而第二个字段是反转后的字符串。

第二部分包含模糊测试函数,如下所示:

func FuzzR1(f *testing.F) {
    testCases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testCases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := R1(orig)
        doubleRev := R1(string(rev))
        if orig != string(doubleRev) {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(string(rev)) {
            t.Errorf("Reverse: invalid UTF-8 string %q", rev)
        }
    })
} 

这是一个用于测试R1()的模糊测试函数。我们向种子语料库中添加了三个字符串,这些字符串保存在testCases切片中。

reverse_test.go文件的最后一部分包含了测试R2()函数的代码:

func FuzzR2(f *testing.F) {
    testCases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testCases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := R2(orig)
        doubleRev := R2(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse: invalid UTF-8 string %q", rev)
        }
    })
} 

这是一个用于测试R2()的模糊测试函数。和之前一样,我们使用保存在testCases切片中的值向种子语料库中添加了三个字符串。

两个模糊测试函数都会将给定的字符串反转两次,并将其与orig本身进行比较,以确保它们相同。这是因为,当你将一个字符串反转两次时,你应该得到原始字符串。

不进行模糊测试运行测试会产生以下结果:

$ go test *.go
ok    command-line-arguments    0.103s 

因此,看起来一切都在按预期工作。但这是否就是情况呢?

让我们运行带有模糊测试的测试。首先,我们将按照以下方式运行FuzzR1()

$ go test -fuzz=FuzzR1 *.go
fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed
fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 10 workers
fuzz: minimizing 30-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzR1 (0.03s)
    --- FAIL: FuzzR1 (0.00s)
        reverse_test.go:55: Reverse: invalid UTF-8 string "\x94\xd4"
    Failing input written to testdata/fuzz/FuzzR1/a256ceb5e3bf582f
    To re-run:
    go test -run=FuzzR1/a256ceb5e3bf582f
FAIL
exit status 1
FAIL    command-line-arguments    0.493s 

因此,在这种情况下,模糊测试发现了代码中的问题。失败的输入被保存在testdata/fuzz/FuzzR1/42f418307d5ef745

testdata/fuzz/FuzzR1/a256ceb5e3bf582f的内容如下:

$ cat testdata/fuzz/FuzzR1/a256ceb5e3bf582f
go test fuzz v1
string("Ԕ") 

简而言之,保存在testdata/fuzz/FuzzR1/a256ceb5e3bf582f中的字符串没有被成功反转。

接下来,我们将运行带有模糊测试的FuzzR2()

$ go test -fuzz=FuzzR2 *.go
--- FAIL: FuzzR1 (0.00s)
    --- FAIL: FuzzR1/a256ceb5e3bf582f (0.00s)
        reverse_test.go:55: Reverse: invalid UTF-8 string "\x94\xd4"
FAIL
exit status 1
FAIL    command-line-arguments    0.253s 

由于FuzzR1()失败,存在一个testdata目录,因此第二次模糊测试由于在testdata目录中找到的数据而失败。在接下来的小节中,我们将修复这个错误。

修复错误

我们现在应该做的是修复错误reverse.go的改进版本被称为correct.go,位于ch13/reverse/correct目录下。其代码如下:

package main
import (
    "errors"
"fmt"
"unicode/utf8"
)
func R1(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("Invalid UTF-8")
    }
    a := []byte(s)
    for i, j := 0, len(s)-1; i < j; i++ {
        a[i], a[j] = a[j], a[i]
        j--
    }
    return string(a), nil
}
func R2(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("Invalid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
} 

注意,在R2()的实现中,我们现在使用runes 而不是 bytes。此外,R1()R2()在开始任何处理之前,都使用utf8.ValidString()的功能来验证我们正在处理一个有效的 UTF-8 字符串。当输入是一个无效的 UTF-8 字符串时,会返回一个错误信息——我们在reverse.go中没有这样做,这就是导致错误的原因。

剩余的代码是关于main()函数的实现:

func main() {
    str := "1234567890"
    R1ret, _ := R1(str)
    fmt.Println(R1ret)
    R2ret, _ := R2(str)
    fmt.Println(R2ret)
} 

如前所述,这个改进版本的主要区别是这两个函数也返回一个error变量,以确保我们正在处理有效的 UTF-8 字符串。然而,这也意味着测试函数以及模糊测试函数应该被修改,以适应函数签名的变化。

运行correct.go会产生以下输出:

$ go run correct.go
0987654321
0987654321 

因此,看起来基本功能仍然被正确实现。

改进的测试版本被称为correct_test.go,也位于ch13/reverse/correct目录下——它将分两部分展示。correct_test.go的第一部分包含以下代码:

package main
import (
    "testing"
"unicode/utf8"
)
func TestR1(t *testing.T) {
    testCases := []struct {
        in, want string
    }{
        {" ", " "},
        {"!12345@", "@54321!"},
        {"Mastering Go", "oG gniretsaM"},
    }
    for _, tc := range testCases {
        rev, err := R1(tc.in)
        if err != nil {
            return
        }
        if rev != tc.want {
            t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
}
func TestR2(t *testing.T) {
    testCases := []struct {
        in, want string
    }{
        {" ", " "},
        {"!12345@", "@54321!"},
        {"Mastering Go", "oG gniretsaM"},
    }
    for _, tc := range testCases {
        rev, err := R2(tc.in)
        if err != nil {
            return
        }
        if rev != tc.want {
            t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
} 

TestR1()TestR2() 的逻辑与之前相同:我们使用存储在结构体切片(testCases)中的值来验证 R1()R2() 的正确性。然而,这次我们考虑了 R1()R2() 返回的 error 值。如果 R1()R2() 出现错误,两个测试函数会立即返回,这使得测试成功。

correct_test.go 的第二部分是模糊测试函数的实现:

func FuzzR1(f *testing.F) {
    testCases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testCases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err := R1(orig)
        if err != nil {
            return
        }
        doubleRev, err := R1(rev)
        if err != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(string(rev)) {
            t.Errorf("Reverse: invalid UTF-8 string %q", rev)
        }
    })
} 

FuzzR1() 使用 R1() 返回的 error 值来确保我们正在处理一个有效的字符串。如果字符串无效,则返回。此外,它将给定的字符串(存储在自动生成的 orig 参数中)反转两次,并与 orig 本身进行比较,以确保它们相同。

func FuzzR2(f *testing.F) {
    testCases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testCases {
        f.Add(tc)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err := R2(orig)
        if err != nil {
            return
        }
        doubleRev, err := R2(**string**(rev))
        if err != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse: invalid UTF-8 string %q", rev)
        }
    })
} 

FuzzR2() 也使用 R2() 返回的 error 值来确保我们正在处理一个有效的字符串。如果字符串无效,它也会返回。剩余的实现遵循 FuzzR1() 的逻辑。

运行常规测试会产生以下输出:

$ go test *.go
ok    command-line-arguments    0.609s 

R1() 进行模糊测试会生成以下输出:

$ go test -fuzz=FuzzR1 *.go -fuzztime 10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/15 completed
fuzz: elapsed: 0s, gathering baseline coverage: 15/15 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1129701 (376437/sec), new interesting: 31 (total: 46)
fuzz: elapsed: 6s, execs: 2527522 (466068/sec), new interesting: 34 (total: 49)
fuzz: elapsed: 9s, execs: 3808349 (426919/sec), new interesting: 37 (total: 52)
fuzz: elapsed: 11s, execs: 4208389 (199790/sec), new interesting: 37 (total: 52)
PASS
ok    command-line-arguments    11.508s 

使用 -fuzztime 10s 确保测试在超过给定时间限制或出现错误后停止。

R2() 进行模糊测试会生成以下输出:

$ go test -fuzz=FuzzR2 *.go -fuzztime 10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1271973 (423844/sec), new interesting: 38 (total: 41)
fuzz: elapsed: 6s, execs: 2638924 (455741/sec), new interesting: 39 (total: 42)
fuzz: elapsed: 9s, execs: 4044955 (468745/sec), new interesting: 39 (total: 42)
fuzz: elapsed: 10s, execs: 4495553 (413586/sec), new interesting: 39 (total: 42)
PASS
ok    command-line-arguments    10.396s 

因此,一切如预期进行,并且错误已被纠正!

下一节是关于可观察性的,这是一种系统地收集与系统效率和内部相关的数据的系统方法。

可观察性

可观察性是衡量系统内部状态与外部操作之间关系的度量。在实践中,可观察性是关于理解在给定软件或应用程序在系统上执行时,计算机系统的资源是如何被有效利用的。

在 Go 术语中,以及与本书内容相关的情况下,可观察性是关于学习 Go 如何使用可用资源以及应用程序在执行过程中的表现,以便理解 Go 应用程序的效率以及 Go 运行时本身的效率。此过程的目的在于提高 Go 应用程序的效率,并可能修改特定 Go 应用程序可用的资源,以改善其整体运行。

如果仍然不清楚,让我用更实际的方式解释一下。假设一个 Go 应用程序运行缓慢。我们需要找出这是为什么。为此,我们需要测量适当的内部 Go 指标以及应用程序特定的指标,并尝试理解它们。

可观察性的关键组件包括:

  • 日志:记录系统生成的事件、活动和消息。日志提供了发生事件的记录,对于调试和审计很有用。

  • 指标:提供对系统性能和行为洞察的定量测量。指标示例包括响应时间、错误率和资源利用率。

  • 跟踪:一系列事件或事务,允许您跟踪请求通过系统中的各个组件的流程。跟踪有助于理解不同组件之间的延迟和依赖关系。

  • 监控:持续跟踪指标和日志以识别模式、异常和潜在问题。当预定义的阈值超过时,监控系统可以生成警报。这是一个复杂任务,尤其是在需要处理大量指标时。

  • 警报:通知机制,告知管理员或操作员关于系统中潜在问题或不规则性的信息。警报有助于快速响应问题并最小化停机时间。

  • 分布式跟踪:跟踪和可视化请求在分布式系统中的各个组件之间传递的过程。这在微服务架构中尤为重要,并且极具挑战性。

本章将处理指标。代码分析和跟踪在 第十二章代码测试和分析 中介绍。此外,日志在 第一章Go 语言快速入门第七章告诉 UNIX 系统做什么 中介绍。处理剩余组件超出了本书的范围。

我们首先解释 runtime/metrics 包的使用,该包提供与 Go 运行时相关的指标。

runtime/metrics

runtime/metrics 包使 Go 运行时导出的指标对开发者可用。每个指标名称由一个路径指定。例如,活着的 goroutines 的数量可以通过 /sched/goroutines:goroutines 访问。然而,如果您想收集所有可用的指标,应使用 metrics.All()——这可以节省您编写大量代码以收集每个单独指标的时间。

指标使用 metrics.Sample 数据类型保存。metrics.Sample 数据结构的定义如下:

type Sample struct {
    Name string
    Value Value
} 

Name 值必须与 metrics.All() 返回的指标描述之一的名字相匹配。如果您已经知道指标描述,则无需使用 metrics.All()

runtime/metrics 包的使用在 metrics.go 中展示。展示的代码获取 /sched/goroutines:goroutines 的值并在屏幕上打印:

package main
import (
    "fmt"
"runtime/metrics"
"sync"
"time"
)
func main() {
    const nGo = "/sched/goroutines:goroutines" 

nGo 变量保存了我们想要收集的指标路径。

 getMetric := make([]metrics.Sample, 1)
    getMetric[0].Name = nGo 

之后,我们创建一个类型为 metrics.Sample 的切片以保存指标值。切片的初始大小为 1,因为我们只为单个指标收集值。我们将 Name 值设置为 /sched/goroutines:goroutines,正如在 nGo 中存储的那样。

 var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(4 * time.Second)
        }() 

在这里,我们手动创建三个 goroutines,以便程序有相关的数据可以收集。

 // Get actual data
        metrics.Read(getMetric)
        if getMetric[0].Value.Kind() == metrics.KindBad {
            fmt.Printf("metric %q no longer supported\n", nGo)
        } 

metrics.Read() 函数根据 getMetric 切片中的数据收集所需的指标。

 mVal := getMetric[0].Value.Uint64()
        fmt.Printf("Number of goroutines: %d\n", mVal)
    } 

在读取所需的度量后,我们将它转换为数值(这里是无符号 int64)以便在我们的程序中使用。

 wg.Wait()
    metrics.Read(getMetric)
    mVal := getMetric[0].Value.Uint64()
    fmt.Printf("Before exiting: %d\n", mVal)
} 

代码的最后几行验证了,在所有 goroutine 完成后,度量值的值将是 1,这是用于运行 main() 函数的 goroutine。

运行 metrics.go 产生以下输出:

$ go run metrics.go
Number of goroutines: 2
Number of goroutines: 3
Number of goroutines: 4
Before exiting: 1 

我们已经创建了三个 goroutine,并且已经有一个用于运行 main() 函数的 goroutine。因此,最大 goroutine 数量确实是四个。

以下小节介绍了一种测量 Go 函数执行时间的技术,这不是 Go 直接支持的功能。

测量函数的执行时间

runtime/metrics 包提供了一组度量,您可以在包的帮助页面上找到这些度量。然而,有时我们想要测量特定操作的时间,这是 runtime/metrics 包不支持的操作。在我们的例子中,我们将做 exactly that 并将信息存储在日志条目中。

在这个例子中,我们将使用标准库中的 log/slog 包。只要它是简单且可读的,您可以使用任何日志包,但最重要的是,只要它是高效的并且不会给系统带来额外的负载。

functionTime.go 的代码分为两部分。第一部分如下:

package main
import (
    "log/slog"
"os"
"time"
)
func myFunction() {
    j := 0
for i := 1; i < 100000000; i++ {
        j = j % i
    }
} 

这是我们定义想要测量的函数的地方——这可以是您想要的任何函数或操作。

functionTime.go 的第二部分包含以下代码:

func main() {
    handler := slog.NewTextHandler(os.Stdout, nil)
    logger := slog.New(handler)
    logger.Debug("This is a DEBUG message")
    for i := 0; i < 5; i++ {
        now := time.Now()
        myFunction()
        elapsed := time.Since(now)
        logger.Info(
            "Observability",
            slog.Int64("time_taken", int64(elapsed)),
        )
    }
} 

使用 time.Now()time.Since() 调用包围 myFunction() 的执行,这是我们测量 myFunction() 执行时间的方法。

记住,time.Duration 数据类型包含纳秒,实际上是一个 int64 值,因此使用了 slog.Int64() 以及 int64(elapsed) 强制类型转换来将 time.Duration 值转换为 int64

运行 functionTime.go 生成以下类型的输出:

$ go run functionTime.go
time=2023-12-30T11:33:36.471+02:00 level=INFO msg=Observability time_taken=51243083
time=2023-12-30T11:33:36.505+02:00 level=INFO msg=Observability time_taken=34088708
time=2023-12-30T11:33:36.536+02:00 level=INFO msg=Observability time_taken=31203083
time=2023-12-30T11:33:36.568+02:00 level=INFO msg=Observability time_taken=31224625
time=2023-12-30T11:33:36.599+02:00 level=INFO msg=Observability time_taken=31206208 

请记住,在繁忙的系统上,由于操作系统调度程序以及 Go 调度程序的操作,time_taken 值不会那么相似。

下一小节是 expvar 包的便捷介绍。

expvar

expvar 包允许您将变量和函数暴露给服务器,包括自定义度量。expvar 包通过 HTTP 在 /debug/vars 以 JSON 格式暴露这些变量。

expvarUse.go 的代码也分为两部分。第一部分如下:

package main
import (
    "expvar"
"fmt"
"net/http"
)
func main() {
    intVar := expvar.NewInt("intVar")
    intVar.Set(1234)
    expvar.Publish("customFunction", expvar.Func(func() interface{} {
        return "Hi from Mastering Go!"
    })) 

之前的代码做了两件事。首先,它使用 expvar.NewInt() 注册一个名为 intVar 的整数变量以供公开,其次,它使用 expvar.Publish() 注册一个名为 customFunction 的函数以供公开。Publish() 函数在其签名中接受两个参数,分别是函数将要公开的变量名称和我们想要公开的函数。在这种情况下,我们使用一个内联实现的匿名函数。

expvarUse.go 的第二部分如下:

 http.Handle("/debug/expvars", expvar.Handler())
    go func() {
        fmt.Println("HTTP server listening on :8080")
        err := http.ListenAndServe(":8080", nil)
        if err != nil {
            fmt.Println("Error starting HTTP server:", err)
        }
    }()
    intVar.Add(10)
    select {}
} 

http.Handle() 函数允许我们在非标准位置安装一个额外的处理程序,在这种情况下,是 /debug/expvars。因此,我们使用 /debug/expvars 路径来访问注册的变量和函数,我们启动监听端口 8080 的 HTTP 服务器,我们使用 intVar.Add() 修改 intVar,并使用 select {} 来防止程序终止,因为它会阻塞程序。

在这种情况下,我们通过手动使用 intVar.Add(10) 更新 intVar 的值来模拟应用程序逻辑,我们应该用我们自己的逻辑来替换它。

运行 expvarUse.go 不会产生任何输出,但它会启动 HTTP 服务器。我们可以通过访问网页浏览器中的 http://localhost:8080/debug/varshttp://localhost:8080/debug/expvars,或者使用 curl(1)wget(1) 等工具发送 HTTP GET 请求来访问公开的变量和函数。

在我们的情况下,我们将使用 curl(1) 并得到以下类型的输出(为了简洁起见,省略了一些输出):

$ curl -X GET http://localhost:8080/debug/expvars
{
"cmdline": ["/var/folders/sk/ltk8cnw50lzdtr2hxcj5sv2m0000gn/T/go-build4228023601/b001/exe/expvarUse"],
"customFunction": "Hi from Mastering Go!",
"intVar": 1244, 

下一个子部分将介绍如何了解机器的 CPU 及其特性。

了解 CPU 特性

有时候我们需要了解 CPU 在运行时的详细信息,并可能将其公开以收集相关指标。在这种情况下,github.com/klauspost/cpuid 包真的非常有用。

我们将把所有相关代码放入 ~/go/src/github.com/mactsouk/mGo4th/ch13/cpuid 目录中,因为我们将要利用一个外部包。

cpuid.go 的 Go 代码如下:

package main
import (
    "fmt"
"strings"
    . "github.com/klauspost/cpuid/v2"
)
func main() {
    // Print basic CPU information:
    fmt.Println("Name:", CPU.BrandName)
    fmt.Println("PhysicalCores:", CPU.PhysicalCores)
    fmt.Println("LogicalCores:", CPU.LogicalCores)
    fmt.Println("ThreadsPerCore:", CPU.ThreadsPerCore)
    fmt.Println("Family", CPU.Family, "Model:", CPU.Model, "Vendor ID:", CPU.VendorID)
    fmt.Println("Features:", strings.Join(CPU.FeatureSet(), ","))
    fmt.Println("Cacheline bytes:", CPU.CacheLine)
    fmt.Println("L1 Data Cache:", CPU.Cache.L1D, "bytes")
    fmt.Println("L1 Instruction Cache:", CPU.Cache.L1I, "bytes")
    fmt.Println("L2 Cache:", CPU.Cache.L2, "bytes")
    fmt.Println("L3 Cache:", CPU.Cache.L3, "bytes")
    fmt.Println("Frequency", CPU.Hz, "hz")
} 

代码是标准的,你不需要对其进行任何修改。

在我的 macOS 机器上运行 cpuid.go 并带有 M1 Max CPU 产生以下输出:

Name: Apple M1 Max
PhysicalCores: 10
LogicalCores: 10
ThreadsPerCore: 1
Family 458787763 Model: 0 Vendor ID: VendorUnknown
Features: AESARM,ASIMD,ASIMDDP,ASIMDHP,ASIMDRDM,ATOMICS,CRC32,DCPOP,FCMA,FP,FPHP,GPA,JSCVT,LRCPC,PMULL,SHA1,SHA2,SHA3,SHA512
Cacheline bytes: 128
L1 Data Cache: 65536 bytes
L1 Instruction Cache: 131072 bytes
L2 Cache: 4194304 bytes
L3 Cache: -1 bytes
Frequency 0 hz 

在带有 Intel i7 处理器的 Linux 机器上运行 cpuid.go 产生以下输出:

Name: Intel(R) Core(TM) i7-10510U CPU @ 1.80GHz
PhysicalCores: 4
LogicalCores: 8
ThreadsPerCore: 2
Family 6 Model: 142 Vendor ID: Intel
Features: ADX,AESNI,AVX,AVX2,BMI1,BMI2,CLMUL,CMOV,CMPXCHG8,CX16,ERMS,F16C,FLUSH_L1D,FMA3,FXSR,FXSROPT,HTT,IA32_ARCH_CAP,IBPB,LAHF,LZCNT,MD_CLEAR,MMX,MOVBE,MPX,NX,OSXSAVE,POPCNT,RDRAND,RDSEED,RDTSCP,SGX,SPEC_CTRL_SSBD,SSE,SSE2,SSE3,SSE4,SSE42,SSSE3,STIBP,SYSCALL,SYSEE,VMX,X87,XGETBV1,XSAVE,XSAVEC,XSAVEOPT,XSAVES
Cacheline bytes: 64
L1 Data Cache: 32768 bytes
L1 Instruction Cache: 32768 bytes
L2 Cache: 262144 bytes
L3 Cache: 8388608 bytes
Frequency 2300000000 hz 

通过比较输出,我们可以看到 cpuid 在带有 Intel CPU 的 Linux 机器上运行得更好,因为它显示了 CPU 的频率,而 MacBook Pro 机器则没有这种情况。

下一个部分将介绍如何将指标暴露给 Prometheus 以及如何在 Grafana 中绘制指标。

将指标暴露给 Prometheus

假设你有一个将文件写入磁盘的应用程序,并且你想要获取该应用程序的度量以更好地理解多个文件写入如何影响整体性能——你需要收集性能数据以了解应用程序的行为。存储此类度量的一种好方法是使用 Prometheus。

Prometheus 支持的度量数据类型列表如下:

  • Counter:这是一个累积值,用于表示递增的计数器——计数器的值可以保持不变,上升,或重置为零,但不能减少。Counter 通常用于表示累积值,例如迄今为止服务的请求数量、错误总数等。

  • Gauge:这是一个允许增加或减少的单个数值。Gauge 通常用于表示可以上升或下降的值,例如请求数量和时间长度。

  • Histogram:Histogram 用于采样观察结果并创建计数和桶。Histogram 通常用于计数请求持续时间、响应时间等。

  • Summary:Summary 类似于直方图,但也可以在滑动窗口中计算时间相关的分位数。

直方图和 Summary 都可用于执行统计计算和属性。通常,一个计数器或 Gauge 就足够用于存储你的系统度量。

下面的子节将说明如何使你收集的任何度量对 Prometheus 可用。

暴露度量

收集度量与向 Prometheus 暴露它们以供收集是完全不同的任务。本小节将展示如何使度量对 Prometheus 的收集可用。为了简化起见,所展示的应用将生成随机值。

samplePro.go的代码如下:

package main
import (
    "fmt"
"net/http"
"math/rand"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
) 

我们需要使用两个外部包来与 Prometheus 通信。

var PORT = ":1234"
var counter = prometheus.NewCounter(
    prometheus.CounterOpts{
        Namespace: "mtsouk",
        Name:      "my_counter",
        Help:      "This is my counter",
    }) 

请记住,我在这里使用全局变量来表示重要的设置,这是一个个人偏好,也是我轻松找到这些设置的方法。同样适用于使用大写字母来命名PORT变量。

这就是我们定义一个新的counter变量并指定所需选项。Namespace字段非常重要,因为它允许你将度量分组。第一个度量的名称是my_counter

var gauge = prometheus.NewGauge(
    prometheus.GaugeOpts{
        Namespace: "mtsouk",
        Name:      "my_gauge",
        Help:      "This is my gauge",
    }) 

这就是我们定义一个新的gauge变量并指定所需选项——度量名称为my_gauge

var histogram = prometheus.NewHistogram(
    prometheus.HistogramOpts{
        Namespace: "mtsouk",
        Name:      "my_histogram",
        Help:      "This is my histogram",
    }) 

这就是我们定义一个新的histogram变量并指定所需选项。

var summary = prometheus.NewSummary(
    prometheus.SummaryOpts{
        Namespace: "mtsouk",
        Name:      "my_summary",
        Help:      "This is my summary",
    }) 

这就是我们定义一个新的summary变量并指定所需选项的方法。然而,正如你将要看到的,定义一个度量变量是不够的。你还需要注册它。

func main() {
    prometheus.MustRegister(counter)
    prometheus.MustRegister(gauge)
    prometheus.MustRegister(histogram)
    prometheus.MustRegister(summary) 

在这四个prometheus.MustRegister()语句中,你注册了四个度量变量。现在,当 Prometheus 连接到服务器和命名空间时,它将了解它们。

 go func() {
        for {
            counter.Add(rand.Float64() * 5)
            gauge.Add(rand.Float64()*15 - 5)
            histogram.Observe(rand.Float64() * 10)
            summary.Observe(rand.Float64() * 10)
            time.Sleep(2 * time.Second)
        }
    }() 

这个 goroutine 在 web 服务器运行期间通过 endless for 循环持续运行。在这个 goroutine 中,由于使用了 time.Sleep(2 * time.Second) 语句,指标每 2 秒更新一次。

 http.Handle("/metrics", promhttp.Handler())
    fmt.Println("Listening to port", PORT)
    fmt.Println(http.ListenAndServe(PORT, nil))
} 

正如你所知,每个 URL 都由一个处理函数处理,你通常自己实现这个处理函数。然而,在这种情况下,我们使用的是 github.com/prometheus/client_golang/prometheus/promhttp 包中提供的 promhttp.Handler() 处理函数——这使我们免于编写自己的代码。然而,在我们启动 web 服务器之前,我们仍然需要使用 http.Handle() 注册 promhttp.Handler() 处理函数。注意,指标位于 /metrics 路径下——Prometheus 知道如何找到它。

samplePro.go 运行时,获取属于 mtsouk 命名空间的指标列表就像运行下一个 curl(1) 命令一样简单:

$ curl localhost:1234/metrics --silent | grep mtsouk
# HELP mtsouk_my_counter This is my counter
# TYPE mtsouk_my_counter counter
mtsouk_my_counter 19.948239343027772 

这是从 counter 变量输出的内容。如果省略了 | grep mtsouk 部分,那么你将得到所有可用指标列表。

# HELP mtsouk_my_gauge This is my gauge
# TYPE mtsouk_my_gauge gauge
mtsouk_my_gauge 29.335329668135287 

这是从 gauge 变量输出的内容。

# HELP mtsouk_my_histogram This is my histogram
# TYPE mtsouk_my_histogram histogram
mtsouk_my_histogram_bucket{le="0.005"} 0
mtsouk_my_histogram_bucket{le="0.01"} 0
mtsouk_my_histogram_bucket{le="0.025"} 0
. . .
mtsouk_my_histogram_bucket{le="5"} 4
mtsouk_my_histogram_bucket{le="10"} 9
mtsouk_my_histogram_bucket{le="+Inf"} 9
mtsouk_my_histogram_sum 44.52262035556937
mtsouk_my_histogram_count 9 

这是从 histogram 变量输出的内容。直方图包含桶,因此有大量的输出行。

# HELP mtsouk_my_summary This is my summary
# TYPE mtsouk_my_summary summary
mtsouk_my_summary_sum 19.407554729772105
mtsouk_my_summary_count 9 

输出的最后几行是针对 summary 数据类型的。

因此,指标已经准备好了,可以由 Prometheus 拉取——在实践中,这意味着每个生产 Go 应用程序都可以导出用于衡量其性能和发现其瓶颈的指标。然而,我们还没有完成,因为我们还需要了解如何为 Go 应用程序构建 Docker 镜像。

创建 Go 服务器的 Docker 镜像

本小节展示了如何为 Go 应用程序创建 Docker 镜像。你从中获得的主要好处是,你可以在 Docker 环境中部署它,而无需担心编译它和所需的资源——所有这些都包含在 Docker 镜像中。

尽管如此,你可能会问,“为什么不使用正常的 Go 二进制文件而不是 Docker 镜像?” 答案很简单:Docker 镜像可以放入 docker-compose.yml 文件中,并可以使用 Kubernetes 进行部署。Go 二进制文件则不行。此外,当需要时,Docker 镜像可以提供一致的共享库。

创建新的 Docker 镜像时,你通常从一个已经包含 Go 的基础 Docker 镜像开始,并在其中创建所需的二进制文件。关键点是 samplePro.go 使用了一个外部包,在构建可执行二进制文件之前,应该在 Docker 镜像中下载这个包。

该过程必须从 go mod initgo mod tidy 开始。名为 dFilev2 的相关 Docker 文件的 内容如下:

FROM golang:alpine AS builder
RUN apk update && apk add --no-cache git 

由于 golang:alpine 使用的是最新的 Go 版本,该版本不包含 git,所以我们手动安装 git

RUN mkdir $GOPATH/src/server
ADD ./samplePro.go $GOPATH/src/server 

如果你想要使用 Go 模块,你应该将你的代码放在 $GOPATH/src 目录下,这是我们在这里所做的事情。

WORKDIR $GOPATH/src/server
RUN go mod init
RUN go mod tidy
RUN go mod download
RUN mkdir /pro
RUN go build -o /pro/server samplePro.go 

我们使用各种 go mod 命令下载所需的依赖。二进制文件的构建与之前相同。

FROM alpine:latest
RUN mkdir /pro
COPY --from=builder /pro/server /pro/server
EXPOSE 1234
WORKDIR /pro
CMD ["/pro/server"] 

在这个第二阶段,我们将二进制文件放入所需的位置 (/pro) 并公开所需的端口,在这个例子中,是端口号 1234。端口号取决于 samplePro.go 中的代码。

前面的过程是一个两步过程,使得最终的 Docker 镜像尺寸更小,因此使用了第二个 FROM 命令,该命令使用不包含 Go 工具的 Docker 镜像,仅用于运行生成的 Go 二进制文件。

使用 dFilev2 构建 Docker 镜像就像运行下一个命令一样简单:

$ docker build -f dFilev2 -t go-app122 . 

尽管之前的 Docker 版本使用 docker build 命令来构建镜像,但 Docker 的最新版本也支持使用 buildx 来执行相同的任务。您可能需要安装 buildkitdocker-buildx 包来启用 buildx 命令。

因此,如果您想走 docker buildx 的路,您应该执行以下命令:

$ docker buildx build -f dFilev2 -t go-app122 . 

docker builddocker buildx 的结果完全相同。

一旦成功创建了 Docker 镜像,您在 docker-compose.yml 文件中使用它的方式就没有区别——docker-compose.yml 文件中的一个相关条目如下所示:

 goapp:
image: go-app122
container_name: goapp-int
restart: always
ports:
- 1234:1234
networks:
- monitoring 

Docker 镜像的名称是 go-app122,而容器的内部名称将是 goapp-int。因此,如果来自 monitoring 网络的不同容器想要访问该容器,它应该使用 goapp-int 主机名。最后,唯一开放的端口是端口号 1234

下一个子节将说明如何将选定的指标公开给 Prometheus。

指定要公开的指标

本节说明了如何将 runtime/metrics 包中的所需指标公开给 Prometheus。在我们的例子中,我们使用 /sched/goroutines:goroutines/memory/classes/total:bytes。您已经了解前者,它是 goroutines 的总数。后者指标是 Go 运行时映射到当前进程的内存量,作为可读写。

由于展示的代码使用了外部包,它应该放在 ~/go/src 中,并使用 go mod init 启用 Go 模块。在我们的例子中,代码可以在 ch13/prom/ 中找到。

prometheus.go 的 Go 代码如下:

package main
import (
    "log"
"math/rand"
"net/http"
"runtime"
"runtime/metrics"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
) 

第一个外部包是 Prometheus 的 Go 客户端库,第二个包是用于使用默认处理函数 (promhttp.Handler())。

var PORT = ":1234"
var nGoroutines = prometheus.NewGauge(
    prometheus.GaugeOpts{
        Namespace: "packt",
        Name:      "n_goroutines",
        Help:      "Number of goroutines"})
var nMemory = prometheus.NewGauge(
    prometheus.GaugeOpts{
        Namespace: "packt",
        Name:      "n_memory",
        Help:      "Memory usage"}) 

在这里,我们定义了两个 Prometheus 指标。

func main() {
    prometheus.MustRegister(nGoroutines)
    prometheus.MustRegister(nMemory)
    const nGo = "/sched/goroutines:goroutines"
const nMem = "/memory/classes/heap/free:bytes" 

这就是你在 Prometheus 中注册指标变量并定义你想要从 runtime/metrics 包中读取的指标的地方。

 getMetric := make([]metrics.Sample, 2)
    getMetric[0].Name = nGo
    getMetric[1].Name = nMem
    http.Handle("/metrics", promhttp.Handler()) 

这是在 /metrics 路径上注册处理函数的地方。我们使用 promhttp.Handler()

 go func() {
        for {
            for i := 1; i < 4; i++ {
                go func() {
                    _ = make([]int, 1000000)
                    time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
                }()
            } 

注意,这样的程序肯定至少应该有两个 goroutine:一个用于运行 HTTP 服务器,另一个用于收集指标。通常,HTTP 服务器位于运行main()函数的 goroutine 上,而指标收集发生在用户定义的 goroutine 中。

外层for循环确保 goroutine 永远运行,而内层for循环创建额外的 goroutine,以便/sched/goroutines:goroutines指标的值始终在变化。

 runtime.GC()
            metrics.Read(getMetric)
            goVal := getMetric[0].Value.Uint64()
            memVal := getMetric[1].Value.Uint64()
            time.Sleep(time.Duration(rand.Intn(15)) * time.Second)
            **nGoroutines.Set**(float64(goVal))
            **nMemory.Set**(float64(memVal))
        }
    }() 

runtime.GC()函数告诉 Go 垃圾收集器运行,并用于更改/memory/classes/heap/free:bytes指标。两次Set()调用更新指标的值。

您可以在附录 AGo 垃圾收集器中了解更多关于 Go 垃圾收集器操作的信息。

 log.Println("Listening to port", PORT)
    log.Println(http.ListenAndServe(PORT, nil))
} 

最后一条语句使用默认的 Go 路由器运行 Web 服务器。从ch13/prom目录运行prometheus.go需要执行以下命令:

$ go mod init
$ go mod tidy
$ go mod download
$ go run prometheus.go
2024/01/01 19:18:11 Listening to port :1234 

虽然prometheus.go除了上一行之外没有生成任何输出,但下一小节将说明如何使用curl(1)从其中读取所需的指标。

获取指标

您可以使用curl(1)prometheus.go获取可用的指标列表,以确保应用程序按预期工作。在尝试使用 Prometheus 获取指标之前,我总是使用curl(1)或其他类似工具(如wget(1))来测试此类应用程序的操作。

$ curl localhost:1234/metrics --silent | grep packt
# HELP packt_n_goroutines Number of goroutines
# TYPE packt_n_goroutines gauge
packt_n_goroutines 6
# HELP packt_n_memory Memory usage
# TYPE packt_n_memory gauge
packt_n_memory 4.8799744e+07 

之前的命令假设curl(1)在运行 HTTP 服务器的同一台机器上执行,并且服务器监听 TCP 端口号1234

接下来,我们必须启用 Prometheus 以拉取指标——从 Docker 镜像运行 Prometheus 要容易得多。要使 Prometheus Docker 镜像能够看到带有指标的 Go 应用程序,最简单的方法是将两者都作为 Docker 镜像执行。我们将使用以下 Dockerfile(与之前使用的dFilev2类似)将prometheus.go转换为 Docker 镜像:

FROM golang:alpine AS builder 

这是用于构建二进制文件的基础 Docker 镜像的名称。golang:alpine只要您定期更新,就始终包含最新的 Go 版本。

RUN apk update && apk add --no-cache git
RUN mkdir $GOPATH/src/server
ADD ./prometheus.go $GOPATH/src/server
WORKDIR $GOPATH/src/server
RUN go mod init
RUN go mod tidy
RUN go mod download 

之前的命令在尝试构建二进制文件之前会下载所需的依赖项。

RUN mkdir /pro
RUN go build -o /pro/server prometheus.go
**FROM** **alpine:latest**
RUN mkdir /pro
COPY --from=builder /pro/server /pro/server
EXPOSE 1234
WORKDIR /pro
CMD ["/pro/server"] 

构建所需的 Docker 镜像,该镜像将被命名为goapp,只需运行以下命令即可:

$ docker build -f Dockerfile -t goapp . 

如果您更喜欢使用docker buildx,则应执行以下命令:

$ docker buildx build -f Dockerfile -t goapp . 

与往常一样,docker images的输出验证了goapp Docker 镜像成功创建——在我的情况下,相关条目如下:

goapp             latest           6f63d9a27185   2 minutes ago   17.8MB 

让我们讨论如何配置 Prometheus 以拉取所需的指标。

将指标放入 Prometheus

要能够拉取指标,Prometheus 需要一个适当的配置文件,该文件指定了指标的来源。将要使用的配置文件如下:

# prometheus.yml
scrape_configs:
- job_name: GoServer
scrape_interval: 5s
static_configs:
- targets: ['goapp:1234'] 

我们告诉 Prometheus 使用端口号1234连接到名为goapp的主机。Prometheus 根据scrape_interval字段的值每五秒拉取一次数据。您应该将prometheus.yml放在prometheus目录下,该目录应与下一个展示的docker-compose.yml文件位于同一根目录下。

Prometheus、Grafana 以及 Go 应用程序都将使用下一个docker-compose.yml文件作为 Docker 容器运行:

version: "3"
services:
goapp:
image: goapp
container_name: goapp
restart: always
ports:
- 1234:1234
networks:
- monitoring 

这是处理收集指标的应用程序的 Go 部分。Docker 镜像名称以及 Docker 容器的内部主机名是goapp。您应该定义将要开放的连接端口号。在这种情况下,内部和外部端口号都是1234。内部端口号映射到外部端口号。此外,您应该将所有 Docker 镜像放在同一个网络下,在这个例子中,该网络被称为monitoring,并且将在稍后定义。

 prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: always
user: "0"
volumes:
- ./prometheus/:/etc/prometheus/ 

这就是您如何将本地机器上的prometheus.yml副本传递给用于 Prometheus 的 Docker 镜像。因此,./prometheus/prometheus.yml可以从 Docker 镜像内部作为/etc/prometheus/prometheus.yml访问。

 - ./prometheus_data/:/prometheus/
command:
- '--config.file=/etc/prometheus/prometheus.yml' 

这就是您告诉 Prometheus 使用哪个配置文件的地方。

 - '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--storage.tsdb.retention.time=200h'
- '--web.enable-lifecycle'
ports:
- 9090:9090
networks:
- monitoring 

这就是场景中 Prometheus 部分的定义结束的地方。使用的 Docker 镜像名为prom/prometheus:latest,其内部名称为prometheus。Prometheus 监听端口号9090

最后,我们展示 Grafana 部分。Grafana 监听端口号3000

 grafana:
image: grafana/grafana
container_name: grafana
depends_on:
- prometheus
restart: always
user: "0"
ports:
- 3000:3000
environment:
- GF_SECURITY_ADMIN_PASSWORD=helloThere 

这是管理员用户(helloThere)的当前密码——您需要它来连接到 Grafana。

 - GF_USERS_ALLOW_SIGN_UP=false
- GF_PANELS_DISABLE_SANITIZE_HTML=true
- GF_SECURITY_ALLOW_EMBEDDING=true
networks:
- monitoring
volumes:
- ./grafana_data/:/var/lib/grafana/
volumes:
grafana_data: {}
    prometheus_data: {} 

前两行与两个volumes字段结合使用,允许 Grafana 和 Prometheus 将数据本地保存,这样每次重启 Docker 镜像时数据都不会丢失。

networks:
monitoring:
driver: bridge 

在内部,所有三个容器都以其container_name字段的值为名。然而,在外部,您可以从本地机器作为http://localhost:port或从另一台机器使用http://hostname:port连接到开放的端口——第二种方式不太安全,应该通过防火墙阻止。最后,您需要运行docker-compose up,这样您就完成了!Go 应用程序开始公开数据,Prometheus 开始收集它。

下一个图显示了 Prometheus UI(http://hostname:9090)显示packt_n_goroutines的简单图表:

一个图表的截图  自动生成的描述

图 13.1:显示 packt_n_goroutines 的 Prometheus UI

这个输出以图形方式显示指标值,对于调试非常有用,但它远非真正专业,因为 Prometheus 不是一个可视化工具。下一个小节将展示如何将 Prometheus 与 Grafana 连接,并使用 Grafana 创建令人印象深刻的图表。

在 Grafana 中可视化 Prometheus 指标

如果不对收集的指标做任何处理,那么收集指标就没有意义了,这里的“处理”指的是可视化。Prometheus 和 Grafana 配合得非常好,因此我们将使用 Grafana 进行可视化部分。在 Grafana 中,您应该执行的最重要任务是将它与您的 Prometheus 实例连接起来。在 Grafana 术语中,您应该创建一个 Grafana 数据源,允许 Grafana 从 Prometheus 获取数据。

使用我们的 Prometheus 安装创建数据源的步骤如下:

  1. 首先,前往 http://localhost:3000 以连接到 Grafana,因为 Grafana 需要了解存储在 Prometheus 中的数据。

  2. 管理员的用户名是 admin,而密码定义在 docker-compose.yml 文件的 GF_SECURITY_ADMIN_PASSWORD 参数值中。

  3. 然后,选择 添加您的第一个数据源。从数据源列表中选择 Prometheus,它通常位于列表顶部。

  4. URL 字段中输入 http://prometheus:9090,然后按下 保存 & 测试 按钮。由于 Docker 镜像之间存在内部网络,Grafana 容器通过 prometheus 主机名知道 Prometheus 容器——这是 container_name 字段的值。正如您已经知道的,您也可以使用 http://localhost:9090 从您的本地机器连接到 Prometheus。

我们完成了!数据源的名字是 Prometheus

在这些步骤之后,从初始的 Grafana 屏幕创建一个新的仪表板,并在上面放置一个新的可视化。如果面板的数据源尚未选择,请选择 Prometheus。然后,转到 指标 下拉菜单并选择所需的指标。点击 保存,您就完成了。创建您想要的任何数量的面板。

下一个图显示了 Grafana 将 prometheus.go 暴露的两个指标进行可视化。

计算机屏幕截图  自动生成描述

图 13.2:在 Grafana 中可视化指标

Grafana 拥有比这里展示的更多功能——如果您正在处理系统指标并想检查 Go 应用的性能,Prometheus 和 Grafana 是好且流行的选择。

在本节中,我们学习了如何从 Go 应用程序向 Prometheus 发送指标,以及如何在 Grafana 中可视化这些指标。然而,如果我们不知道要收集哪些指标,指标的含义是什么,或者如何在不牺牲应用程序整体性能的情况下收集指标,那么这一切都没有意义。

摘要

本章讲述了 Go 测试的一个新功能,即模糊测试。模糊测试可以通过自行生成测试数据来帮助您在代码中找到错误。虽然模糊测试提供了几个好处,但重要的是要注意它并不是万能的。这意味着它应该与其他测试技术和安全实践结合使用,以确保全面的覆盖和强大的安全措施。

你可能会说,在确保你的软件没有错误之后,你可能需要让它运行得更快。在这种情况下,你需要了解你的资源是如何被使用的——可观测性是关于收集与性能相关的信息,这些信息有助于你识别应用程序的行为。

可观测性在现代复杂系统中至关重要,因为传统的监控方法可能无法满足需求。它使工程师和操作员能够深入了解系统的内部运作,诊断问题,并提高整体系统的可靠性和性能。这一概念与 DevOps 和站点可靠性工程(SRE)实践密切相关,强调在现实世界的生产环境中理解和管理系统的重要性。

在下一章中,我们将学习如何避免内存泄漏,如何基准测试 Go 代码,以及 Go 的内存管理。

练习

尝试以下练习:

  • 在你的机器上运行 cpuid.go 并查看你的硬件的功能和特性。

  • 创建一个将所需信息写入日志的 cpuid.go 版本。

  • 修复 fuzz/code.go 中的 AddInt() 函数。

  • 创建一个使用 for 循环实现整数乘法的函数。为它编写测试函数和模糊测试函数。

其他资源

留下评论!

喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码,获取你选择的免费电子书。

评论二维码

第十四章:效率和性能

每个故事都有一个反派。对于开发者来说,这个反派通常是时间。他们必须在给定的时间内编写代码,理想情况下,代码必须尽可能快地运行。大多数错误和缺陷都是与时间限制作斗争的结果,无论是现实的还是想象的!因此,本章旨在帮助你与时间的第二个方面作斗争:效率和性能。对于时间的第一个方面,你需要一个技术技能良好的好管理者。

本章的第一部分是关于使用基准函数对 Go 代码进行基准测试,这些基准函数用于衡量函数或整个程序的性能。认为某个函数的实现比另一个实现更快是不够的。我们需要能够证明这一点。

之后,我们将讨论 Go 如何管理内存,以及粗心的 Go 代码如何引入内存泄漏。在 Go 中,内存泄漏发生在不再需要的内存没有被正确释放时,导致程序内存使用量随时间增长。理解内存模型对于编写高效、正确和并发的 Go 程序至关重要。在实践中,当我们的代码使用大量内存时(这通常不是情况),我们需要格外注意代码,以获得更好的性能。

最后,我们将展示如何使用 Go 语言结合 eBPF。eBPF,即扩展伯克利包过滤器,是一种使 Linux 内核可编程的技术。它起源于对传统伯克利包过滤器(BPF)的扩展,BPF 是为内核内部数据包过滤而设计的。然而,eBPF 是一个更通用和灵活的框架,允许在内核空间中执行用户提供的程序,而无需修改内核本身。

我们将涵盖以下主题:

  • 基准测试代码

  • 缓冲与非缓冲文件 I/O

  • 错误定义的基准函数

  • Go 内存管理

  • 内存泄漏

  • 与 eBPF 一起工作

下一个部分是关于基准测试 Go 代码,这有助于你确定你的代码中什么更快,什么更慢——这使得它成为开始搜索效率的完美起点。

基准测试代码

基准测试衡量函数或程序的性能,允许你比较不同的实现,并了解代码更改对性能的影响。利用这些信息,你可以轻松地揭示需要重写以改进性能的代码部分。不言而喻,除非你有非常充分的理由,否则你不应该在正在用于其他更重要目的的繁忙机器上对 Go 代码进行基准测试!否则,你可能会干扰基准测试过程,得到不准确的结果,更重要的是,你可能会在机器上产生性能问题。

大多数情况下,操作系统的负载在代码性能中起着关键作用。让我在这里讲一个故事:我为一个项目开发的一个 Java 工具在独立运行时执行了大量的计算,耗时 6,242 秒(大约 1.7 小时)。当四个相同的 Java 命令行工具实例在同一台 Linux 机器上运行时,大约需要一天时间!如果你这么想,一个接一个地运行它们会比同时运行它们快!

Go 在基准测试(以及测试)方面遵循某些约定。最重要的约定是基准函数的名称必须以 Benchmark 开头。在 Benchmark 词语之后,我们可以放置一个下划线或大写字母。因此,BenchmarkFunctionName()Benchmark_functionName() 都是有效的基准函数,而 Benchmarkfunctionname() 则不是。按照惯例,这样的函数被放在以 _test.go 结尾的文件中。一旦基准测试正确,go test 子命令就会为你完成所有脏活,包括扫描所有 *_test.go 文件中的特殊函数,生成适当的临时 main 包,调用这些特殊函数,获取结果,并生成最终输出。

基准函数使用 testing.B 变量,而测试函数使用 testing.T 变量。这很容易记住。

从 Go 1.17 开始,我们可以使用 shuffle 参数(go test -shuffle=on)帮助打乱测试和基准的执行顺序。shuffle 参数接受一个值(这是随机数生成器的种子),当你想重新播放执行顺序时很有用。它的默认值是关闭。这个功能背后的逻辑是,有时测试和基准的执行顺序会影响它们的结果

下一个子节展示了一个简单的基准测试场景,我们尝试优化切片初始化。

一个简单的基准测试场景

我们首先展示一个测试两个函数性能的场景,这两个函数执行相同的事情,但实现方式不同。我们希望能够初始化具有连续值的切片,从 0 开始,到预定义的值。所以,给定一个名为 mySlice 的切片,mySlice[0] 将具有 0 的值,mySlice[1] 将具有 1 的值,以此类推。相关代码可以在 ch14/slices 中找到,其中包含两个名为 initialize.goinitialize_test.go 的文件。initialize.go 的 Go 代码分为两部分。第一部分如下:

package main
import (
    "fmt"
)
func InitSliceNew(n int) []int {
    s := make([]int, n)
    for i := 0; i < n; i++ {
    	s[i] = i
    }
    return s
} 

在之前的代码中,我们看到使用 make() 预分配所需内存空间的所需功能实现。

initialize.go 的第二部分如下:

func InitSliceAppend(n int) []int {
    s := make([]int, 0)
    for i := 0; i < n; i++ {
    	s = append(s, i)
    }
    return s
}
func main() {
    fmt.Println(InitSliceNew(10))
    fmt.Println(InitSliceAppend(10))
} 

InitSliceAppend() 中,我们看到一个不同的实现,它从一个空切片开始,并使用多个 append() 调用来填充它。main() 函数的目的是天真地测试 InitSliceNew()InitSliceAppend() 的功能。

基准测试函数的实现位于 initialize_test.go 中,如下所示:

package main
import (
    "testing"
)
var t []int
func BenchmarkNew(b *testing.B) {
    for i := 0; i < b.N; i++ {
    	t = InitSliceNew(i)
    }
}
func BenchmarkAppend(b *testing.B) {
    for i := 0; i < b.N; i++ {
    	t = InitSliceAppend(i)
    }
} 

在这里,我们有两个基准测试函数,分别基准测试 InitSliceNew()InitSliceAppend()。全局参数 t 用于防止 Go 通过忽略 InitSliceNew()InitSliceAppend() 的返回值来优化 for 循环。

请记住,基准测试过程发生在 for 循环内部。这意味着,当需要时,我们可以在 for 循环外部声明新变量、打开网络连接等。

现在有一些关于基准测试的重要信息:默认情况下,每个基准函数至少执行一秒钟——这个持续时间也包括由基准函数调用的函数的执行时间。如果基准函数在不到一秒的时间内返回,b.N 的值会增加,并且该函数将再次以 b.N 的总次数运行。当 b.N 的值为 1 时,它变为 2,然后是 5,然后是 10,然后是 20,然后是 50,以此类推。这是因为函数越快,Go 需要运行它的次数就越多,以获得准确的结果。

在 macOS M1 Max 笔记本上对代码进行基准测试会产生以下类型的输出——你的输出可能会有所不同:

$ go test -bench=. *.go
goos: darwin
goarch: arm64
BenchmarkNew-10             255704         79712 ns/op
BenchmarkAppend-10           86847        143459 ns/op
PASS
ok    command-line-arguments    33.539s 

这里有两个重要的点。首先,-bench 参数的值指定了将要执行的基准测试函数。使用的 . 值是一个正则表达式,它匹配所有有效的基准测试函数。第二个要点是,如果你省略了 -bench 参数,则不会执行任何基准测试函数。

生成的输出显示 InitSliceNew() 比较快,因为 InitSliceNew() 执行了 255704 次,每次耗时 79712 ns,而 InitSliceAppend() 执行了 86847 次,每次耗时 143459 ns。这完全合理,因为 InitSliceAppend() 需要不断分配内存——这意味着切片的长度和容量都会改变,而 InitSliceNew() 只分配一次必要的内存。

理解append()函数的工作原理将有助于你理解结果。如果底层数组有足够的容量,那么结果切片的长度将增加附加元素的数量,但其容量保持不变。这意味着没有新的内存分配。然而,如果底层数组没有足够的容量,将创建一个新的数组,这意味着将分配新的内存空间,具有更大的容量。之后,切片被更新以引用新的数组,并相应地调整其长度和容量。

下一个小节展示了允许我们减少分配数量的基准测试技术。

基准测试内存分配的数量

在这个第二个基准测试场景中,我们将处理与函数操作期间发生的内存分配数量有关的一个性能问题。我们展示了同一程序的两种版本,以说明慢速版本和改进版本之间的差异。所有相关代码都可以在ch14/alloc目录下的两个目录中找到,分别命名为baseimproved

初始版本

被基准测试的函数的目的是将消息写入缓冲区。这个版本没有进行优化。这个第一版本的代码位于ch14/alloc/base,其中包含两个 Go 源代码文件。第一个文件名为allocate.go,包含以下代码:

package allocate
import (
    "bytes"
)
func writeMessage(msg []byte) {
    b := new(bytes.Buffer)
    b.Write(msg)
} 

writeMessage()函数只是将给定的消息写入一个新的缓冲区(bytes.Buffer)。因为我们只关心其性能,所以我们不处理错误处理。

第二个文件,名为allocate_test.go,包含基准测试代码,包含以下代码:

package allocate
import (
    "testing"
)
func BenchmarkWrite(b *testing.B) {
    msg := []byte("Mastering Go!")
    for i := 0; i < b.N; i++ {
        for k := 0; k < 50; k++ {
            writeMessage(msg)
        }
    }
} 

使用带有-benchmem命令行标志的基准测试代码,它还会显示内存分配,产生以下类型的输出:

$ go test -bench=. -benchmem *.go
goos: darwin
goarch: arm64
BenchmarkWrite-10     1148637    1024 ns/op    3200 B/op    50 allocs/op
PASS
ok    command-line-arguments    2.256s 

每次执行基准测试函数都需要 50 次内存分配。这意味着在内存分配的数量上还有改进的空间。我们将在下一个小节中尝试减少它们。

提高内存分配的数量

在本小节中,我们介绍了三个不同的函数,它们都实现了将消息写入缓冲区的功能。然而,这次,缓冲区作为函数参数给出,而不是内部初始化。

改进版本的代码位于ch14/alloc/improved,包含两个 Go 源代码文件。第一个文件名为improve.go,包含以下代码:

package allocate
import (
    "bytes"
"io"
)
func writeMessageBuffer(msg []byte, b bytes.Buffer) {
    b.Write(msg)
}
func writeMessageBufferPointer(msg []byte, b *bytes.Buffer) {
    b.Write(msg)
}
func writeMessageBufferWriter(msg []byte, b io.Writer) {
    b.Write(msg)
} 

我们这里有三个函数,它们都实现了将消息写入缓冲区的功能。然而,writeMessageBuffer()通过值传递缓冲区,而writeMessageBufferPointer()传递缓冲区变量的指针。最后,writeMessageBufferWriter()使用io.Writer接口变量,它也支持bytes.Buffer变量。

第二个文件命名为 improve_test.go,将分三部分进行展示。第一部分包含以下代码:

package allocate
import (
    "bytes"
"testing"
)
func BenchmarkWBuf(b *testing.B) {
    msg := []byte("Mastering Go!")
    buffer := bytes.Buffer{}
    for i := 0; i < b.N; i++ {
        for k := 0; k < 50; k++ {
            writeMessageBuffer(msg, buffer)
        }
    }
} 

这是 writeMessageBuffer() 的基准测试函数。缓冲区只分配一次,并通过传递给相关函数在所有基准测试中使用。

第二部分如下:

func BenchmarkWBufPointerNoReset(b *testing.B) {
    msg := []byte("Mastering Go!")
    buffer := new(bytes.Buffer)
    for i := 0; i < b.N; i++ {
        for k := 0; k < 50; k++ {
            writeMessageBufferPointer(msg, buffer)
        }
    }
} 

这是 writeMessageBufferPointer() 的基准测试函数。再次强调,所使用的缓冲区只分配一次,并在所有基准测试中共享。

improve_test.go 文件的最后一部分包含以下代码:

func BenchmarkWBufPointerReset(b *testing.B) {
    msg := []byte("Mastering Go!")
    buffer := new(bytes.Buffer)
    for i := 0; i < b.N; i++ {
        for k := 0; k < 50; k++ {
            writeMessageBufferPointer(msg, buffer)
            buffer.Reset()
        }
    }
}
func BenchmarkWBufWriterReset(b *testing.B) {
    msg := []byte("Mastering Go!")
    buffer := new(bytes.Buffer)
    for i := 0; i < b.N; i++ {
        for k := 0; k < 50; k++ {
            writeMessageBufferWriter(msg, buffer)
            buffer.Reset()
        }
    }
} 

在这里,我们可以看到在两个基准测试函数中使用了 buffer.Reset()buffer.Reset() 函数的目的是将缓冲区重置为无内容状态。buffer.Reset() 函数的结果与 buffer.Truncate(0) 相同。Truncate(n) 会丢弃缓冲区中除前 n 个未读字节之外的所有字节。我们使用 buffer.Reset() 是因为它可能提高性能。然而,这一点还有待观察。

对改进版本进行基准测试会产生以下类型的输出:

$ go test -bench=. -benchmem *.go
goos: darwin
goarch: arm64
BenchmarkWBuf-10            1128193   1056 ns/op   3200 B/op 50 allocs/op
BenchmarkWBufPointerNoReset-10 4050562   337.1 ns/op  2120 B/op 0 allocs/op
BenchmarkWBufPointerReset-10   7993546   150.7 ns/op   0 B/op      0 allocs/op
BenchmarkWBufWriterReset-10    7851434   151.8 ns/op   0 B/op   0 allocs/op
PASS
ok      command-line-arguments    7.667s 

BenchmarkWBuf() 基准测试函数的结果所示,将缓冲区作为函数的参数使用并不会自动加快过程,即使我们在基准测试期间共享相同的缓冲区。然而,其他基准测试的情况并非如此。

使用指向缓冲区的指针可以避免在传递给函数之前复制缓冲区——这解释了 BenchmarkWBufPointerNoReset() 函数的结果,在该函数中没有额外的内存分配。然而,我们仍然需要每个操作使用 2,120 字节。

使用 -benchmem 命令行参数的输出包括两列额外的信息。第四列显示了在基准测试函数的每次执行中平均分配的内存量。第五列显示了用于分配第四列内存值的分配次数。

最后,发现在使用 writeMessageBufferPointer()writeMessageBufferWriter() 调用 buffer.Reset() 后重置缓冲区可以加快处理速度。一个可能的解释是空缓冲区更容易处理。因此,当使用 buffer.Reset() 时,我们既有 0 次内存分配,也有 0 字节的操作。结果,BenchmarkWBufPointerReset()BenchmarkWBufWriterReset() 分别需要每个操作 150.7 纳秒和 151.8 纳秒,这比 BenchmarkWBuf()BenchmarkWBufPointerNoReset() 分别需要的 1,056 纳秒和 337.1 纳秒快得多。

使用 buffer.Reset() 可能更高效,原因可能包括以下之一:

  • 分配内存的重用:当你调用 buffer.Reset() 时,bytes.Buffer 所使用的底层字节切片不会被释放。相反,它会被重用。缓冲区的长度被设置为零,使得现有的内存可用于写入新数据。

  • 减少分配开销:创建一个新的缓冲区涉及到分配一个新的底层字节切片。这种分配伴随着开销,包括管理内存、更新内存分配器的数据结构,以及可能调用垃圾收集器。

  • 避免垃圾收集:创建和丢弃许多小缓冲区可能导致垃圾收集器压力增加,尤其是在高频缓冲区创建的场景中。通过使用Reset()重用缓冲区,你可以减少短期存在的对象数量,从而可能减少对垃圾收集器的影响。

下一节的主题是基准测试缓冲写入。

缓冲与非缓冲文件 I/O

在本节中,我们将比较读写文件时的缓冲和非缓冲操作。

在本节中,我们将测试缓冲区的大小是否在写操作的性能中扮演关键角色。相关的代码可以在ch14/io中找到。除了相关文件外,该目录还包括一个testdata目录,它首次出现在第十三章,模糊测试和可观察性中,用于存储与测试过程相关的数据。

table.go的代码在此处未展示——请随意查看。table_test.go的代码如下:

package table
import (
    "fmt"
"os"
"path"
"strconv"
"testing"
)
var ERR error
var countChars int
func benchmarkCreate(b *testing.B, buffer, filesize int) {
    filename := path.Join(os.TempDir(), strconv.Itoa(buffer))
    filename = filename + "-" + strconv.Itoa(filesize)
    var err error
for i := 0; i < b.N; i++ {
        err = Create(filename, buffer, filesize)
    }
    ERR = err 

Create()的返回值存储在名为err的变量中,并在之后使用另一个名为ERR的全局变量,这个做法很巧妙。我们希望防止编译器执行任何可能排除我们想要测量的函数执行的优化,因为其结果从未被使用。

 err = os.Remove(filename)
    if err != nil {
        fmt.Println(err)
    }
    ERR = err
} 

benchmarkCreate()的签名或名称都没有使其成为一个基准函数。这是一个辅助函数,允许你调用Create(),该函数在磁盘上创建一个新文件;其实现可以在table.go中找到,带有适当的参数。其实现是有效的,并且可以被基准函数使用。

func BenchmarkBuffer4Create(b *testing.B) {
    benchmarkCreate(b, 4, 1000000)
}
func BenchmarkBuffer8Create(b *testing.B) {
    benchmarkCreate(b, 8, 1000000)
}
func BenchmarkBuffer16Create(b *testing.B) {
    benchmarkCreate(b, 16, 1000000)
} 

这些是三个正确定义的基准函数,它们都调用了benchmarkCreate()。基准函数需要一个*testing.B变量,并且不返回任何值。在这种情况下,函数名末尾的数字表示缓冲区的大小。

func BenchmarkRead(b *testing.B) {
    buffers := []int{1, 16, 96}
    files := []string{"10.txt", "1000.txt", "5k.txt"} 

这是定义将要用于表格测试的数组结构的代码。这使我们免于实现(3x3=)9 个单独的基准函数。

 for _, filename := range files {
        for _, bufSize := range buffers {
            name := fmt.Sprintf("%s-%d", filename, bufSize)
            b.Run(name, func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                    t := CountChars("./testdata/"+filename, bufSize)
                    countChars = t
                }
            })
        }
    }
} 

b.Run()方法允许你在基准函数内运行一个或多个子基准测试,它接受两个参数。首先,子基准测试的名称,它将在屏幕上显示,其次,实现子基准测试的函数。这是使用表格测试运行多个基准测试并了解其参数的有效方式。只需记住为每个子基准测试定义一个合适的名称,因为这将显示在屏幕上。

运行基准测试会生成以下输出:

$ go test -bench=. -benchmem *.go
goos: darwin
goarch: arm64
BenchmarkBuffer4Create-10     382740   2915 ns/op   384 B/op    5 allocs/op
BenchmarkBuffer8Create-10     444297   2400 ns/op   384 B/op    5 allocs/op
BenchmarkBuffer16Create-10    491230   2165 ns/op   384 B/op  5 allocs/op 

前三行分别是 BenchmarkBuffer4Create()BenchmarkBuffer8Create()BenchmarkBuffer16Create() 基准测试函数的结果,并显示了它们的性能。

BenchmarkRead/10.txt-1-10     146298    8180 ns/op   168 B/op   6 allocs/op
BenchmarkRead/10.txt-16-10    197534    6024 ns/op   200 B/op   6 allocs/op
BenchmarkRead/10.txt-96-10    197245    6148 ns/op   440 B/op   6 allocs/op
BenchmarkRead/1000.txt-1-10   4382        268204 ns/op  168 B/op   6 allocs/op
BenchmarkRead/1000.txt-16-10  32732    36684 ns/op   200 B/op   6 allocs/op
BenchmarkRead/1000.txt-96-10  105078   11337 ns/op   440 B/op      6 allocs/op
BenchmarkRead/5k.txt-1-10     912       1308924 ns/op  168 B/op	  6 allocs/op
BenchmarkRead/5k.txt-16-10    7413        159638 ns/op  200 B/op	  6 allocs/op
BenchmarkRead/5k.txt-96-10    36471    32841 ns/op   440 B/op      6 allocs/op 

之前的结果来自表格测试的 9 个子基准测试。

PASS
ok      command-line-arguments    24.518s 

那么,这个输出告诉我们什么呢?首先,每个基准测试函数末尾的 -10 表示用于其执行的 goroutine 数量,这实际上是 GOMAXPROCS 环境变量的值。同样,你还可以看到 GOOSGOARCH 的值,它们显示了在生成的输出中机器的操作系统和架构。输出中的第二列显示了相关函数执行的次数。执行速度较快的函数比执行速度较慢的函数执行次数更多。例如,BenchmarkBuffer4Create() 执行了 382740 次,而 BenchmarkBuffer16Create() 执行了 491230 次,因为它更快!输出中的第三列显示了每次运行的平均时间,并以每基准测试函数执行纳秒(ns/op)为单位进行衡量。第三列的值越高,基准测试函数就越慢。第三列的值较大表明该函数可能需要优化

到目前为止,我们已经学习了如何创建基准测试函数来测试我们自己的函数的性能,以便更好地理解可能需要优化的潜在瓶颈。你可能会问,我们需要多久创建一次基准测试函数?答案是简单的。当某些东西运行得比预期慢,或者当你想要在两种或多种实现之间进行选择时。

下一个子节将展示如何比较基准测试结果。

benchstat 工具

现在想象一下,你有一些基准测试数据,并且想要将其与另一台计算机或不同配置产生的结果进行比较。benchstat 工具在这里可以帮助你。该工具位于 https://pkg.go.dev/golang.org/x/perf/cmd/benchstat 包中,可以使用 go install golang.org/x/perf/cmd/benchstat@latest 命令下载。Go 将所有二进制文件放在 ~/go/bin 目录下,benchstat 也不例外。

benchstat 工具取代了 benchcmp 工具,后者可以在 pkg.go.dev/golang.org/x/tools/cmd/benchcmp 找到。

因此,想象一下,我们有两个针对 table_test.go 的基准测试结果保存在 r1.txtr2.txt 中——你应该删除 go test 输出中所有不包含基准测试结果的行,这样就会留下所有以 Benchmark 开头的行。你可以这样使用 benchstat

$ ~/go/bin/benchstat r1.txt r2.txt
                   │     r1.txt     │                 r2.txt            │
                   │     sec/op     │    sec/op     vs base             │
Buffer4Create-8      10472.0n ± ∞ ¹   830.8n ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Buffer8Create-8       6884.0n ± ∞ ¹   798.9n ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Buffer16Create-8      5010.0n ± ∞ ¹   770.5n ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/10.txt-1-8       14.955µ ± ∞ ¹   3.987µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/10.txt-16-8      12.172µ ± ∞ ¹   2.583µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/10.txt-96-8      11.925µ ± ∞ ¹   2.612µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/1000.txt-1-8      381.3µ ± ∞ ¹   175.8µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/1000.txt-16-8     54.05µ ± ∞ ¹   22.68µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/1000.txt-96-8    19.115µ ± ∞ ¹   6.225µ ± ∞ ¹     ~ (p=1.333 n=1+2) ²
Read/5k.txt-1-8       1812.5µ ± ∞ ¹   895.7µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/5k.txt-16-8       221.8µ ± ∞ ¹   107.7µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
Read/5k.txt-96-8       51.53µ ± ∞ ¹   21.52µ ± ∞ ¹     ~ (p=0.667 n=1+2) ²
geomean                36.91µ         9.717µ        -73.68%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05 

你可以通过将生成的输出重定向到文件来保存基准测试的结果。例如,你可以运行 go test -bench=. > output.txt

如果最后一列的值是 ~,就像这里发生的情况一样,这意味着结果没有发生显著变化。之前的输出显示两个结果之间没有差异。关于 benchstat 的更多讨论超出了本书的范围。输入 benchstat -h 以了解支持的参数。

下一个部分涉及到一个敏感的主题,即错误定义的基准函数。

错误定义的基准函数

在定义基准函数时,你应该非常小心,因为你可能会错误地定义它们。看看以下基准函数的 Go 代码:

func BenchmarkFiboI(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fibo1(i)
    }
} 

BenchmarkFibo() 函数具有有效的名称和正确的签名。坏消息是,这个基准函数在逻辑上是错误的,并且不会产生任何结果。原因是,正如之前描述的那样,随着 b.N 值的增长,基准函数的运行时间也会因为 for 循环而增加。这个事实阻止了 BenchmarkFiboI() 收敛到一个稳定的数字,从而阻止了函数完成,因此没有返回任何结果。出于类似的原因,下一个基准函数也是错误实现的:

func BenchmarkfiboII(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fibo1(b.N)
    }
} 

另一方面,以下两个基准函数的实现并没有什么问题:

func BenchmarkFiboIV(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fibo1(10)
    }
}
func BenchmarkFiboIII(b *testing.B) {
    _ = fibo1(b.N)
} 

正确的基准函数是识别你代码中瓶颈的工具,你应该将其放入自己的项目中,尤其是在处理文件 I/O 或 CPU 密集型操作时——正如我写这篇文章时,我已经等待了三天,等待一个 Python 程序完成其操作以测试一个数学算法的暴力方法的性能。

基准测试就到这里。下一个部分将讨论 Go 处理内存的方式。

Go 内存管理

本节的主题是 Go 内存管理。我们将首先陈述一个你应该已经熟悉的事实:Go 为了简单性和使用 垃圾回收器GC)而牺牲了内存管理的可见性和完全控制。尽管 GC 操作会给程序的速度带来开销,但它使我们免于手动处理内存,这是一个巨大的优势,并使我们免于许多错误。

在程序执行过程中存在两种类型的内存分配:动态分配自动分配。自动分配是指编译器在程序开始执行之前可以推断其生命周期的分配。例如,所有局部变量、函数的返回参数和函数参数都有确定的生存期,这意味着它们可以被编译器自动分配。所有其他分配都是动态进行的,这包括必须在函数作用域之外可用的数据。

我们继续讨论 Go 的内存管理,通过介绍堆和栈来展开,因为大多数的内存分配都发生在这里。

堆和栈

是编程语言存储全局变量的地方——堆是垃圾回收发生的地方。是编程语言存储函数使用的临时变量的地方——每个函数都有自己的栈。由于 goroutines 位于用户空间,Go 运行时负责管理它们的操作规则。此外,每个 goroutine 都有自己的栈,而堆是“共享”的。

动态分配发生在堆上,而自动分配存储在栈上。Go 编译器执行一个称为逃逸分析的过程,以确定是否需要在堆上分配内存或者应该保持在栈上。

在 C++中,当你使用new运算符创建新变量时,你知道这些变量将进入堆。在 Go 和new()make()函数的使用中并非如此。在 Go 中,编译器根据变量的大小和逃逸分析的结果来决定新变量将被放置的位置。这也是为什么我们可以从 Go 函数中返回局部变量的指针。尽管我们在这本书中没有频繁地看到new(),但请记住,new()返回指向初始化内存的指针。

如果你想知道 Go 程序中的变量在哪里分配,你可以使用go run-m gc标志。这在allocate.go中得到了说明——这是一个常规程序,无需修改即可显示额外的输出,因为所有细节都由 Go 处理。

package main
import "fmt"
const VAT = 24
type Item struct {
    Description string
    Value       float64
}
func Value(price float64) float64 {
    total := price + price*VAT/100
return total
}
func main() {
    t := Item{Description: "Keyboard", Value: 100}
    t.Value = Value(t.Value)
    fmt.Println(t)
    tP := &Item{}
    *&tP.Description = "Mouse"
    *&tP.Value = 100
    fmt.Println(tP)
} 

运行allocate.go生成下一个输出——这个输出是使用-gcflags '-m'的结果,它修改了生成的可执行文件。你不应该使用-gcflags标志创建用于生产的可执行二进制文件。

$ go run -gcflags '-m' allocate.go
# command-line-arguments
./allocate.go:12:6: can inline Value
./allocate.go:19:17: inlining call to Value
./allocate.go:20:13: inlining call to fmt.Println
./allocate.go:25:13: inlining call to fmt.Println
./allocate.go:20:13: ... argument does not escape
./allocate.go:20:14: t escapes to heap
./allocate.go:22:8: &Item{} escapes to heap
./allocate.go:25:13: ... argument does not escape
{Keyboard 124}
&{Mouse 100} 

t escapes to heap这条消息的意思是t逃出了函数。简单来说,这意味着t在函数外部被使用,并且没有局部作用域(因为它被传递出了函数)。然而,这并不一定意味着变量已经移动到了堆上。在其他情况下,你可能会看到moved to heap的消息。这条消息表明编译器决定将一个变量移动到堆上,因为它可能在函数外部被使用。does not escape这条消息表示相关的参数没有逃逸到堆上。

理想情况下,我们应该编写算法以使用栈而不是堆,但这是不可能的,因为栈不能分配太大的对象,也不能存储比函数生命周期更长的变量。所以,这取决于 Go 编译器来决定。

输出的最后两行是由两个fmt.Println()语句生成的输出。

如果你想得到更详细的输出,可以使用-m两次:

$ go run -gcflags '-m -m' allocate.go
# command-line-arguments
./allocate.go:12:6: can inline Value with cost 13 as: func(float64) float64 { total := price + price * VAT / 100; return total }
./allocate.go:17:6: cannot inline main: function too complex: cost 199 exceeds budget 80
./allocate.go:19:17: inlining call to Value
./allocate.go:20:13: inlining call to fmt.Println
./allocate.go:25:13: inlining call to fmt.Println
./allocate.go:22:8: &Item{} escapes to heap:
./allocate.go:22:8:   flow: tP = &{storage for &Item{}}:
./allocate.go:22:8:     from &Item{} (spill) at ./allocate.go:22:8
./allocate.go:22:8:     from tP := &Item{} (assign) at ./allocate.go:22:5
./allocate.go:22:8:   flow: {storage for ... argument} = tP:
./allocate.go:22:8:     from tP (interface-converted) at ./allocate.go:25:14
./allocate.go:22:8:     from ... argument (slice-literal-element) at ./allocate.go:25:13
./allocate.go:22:8:   flow: fmt.a = &{storage for ... argument}:
./allocate.go:22:8:     from ... argument (spill) at ./allocate.go:25:13
./allocate.go:22:8:     from fmt.a := ... argument (assign-pair) at ./allocate.go:25:13
./allocate.go:22:8:   flow: {heap} = *fmt.a:
./allocate.go:22:8:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./allocate.go:25:13
./allocate.go:20:14: t escapes to heap:
./allocate.go:20:14:   flow: {storage for ... argument} = &{storage for t}:
./allocate.go:20:14:     from t (spill) at ./allocate.go:20:14
./allocate.go:20:14:     from ... argument (slice-literal-element) at ./allocate.go:20:13
./allocate.go:20:14:   flow: fmt.a = &{storage for ... argument}:
./allocate.go:20:14:     from ... argument (spill) at ./allocate.go:20:13
./allocate.go:20:14:     from fmt.a := ... argument (assign-pair) at ./allocate.go:20:13
./allocate.go:20:14:   flow: {heap} = *fmt.a:
./allocate.go:20:14:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./allocate.go:20:13
./allocate.go:20:13: ... argument does not escape
./allocate.go:20:14: t escapes to heap
./allocate.go:22:8: &Item{} escapes to heap
./allocate.go:25:13: ... argument does not escape
{Keyboard 124}
&{Mouse 100} 

虽然信息更详细,但我发现这个输出太拥挤了。通常,使用-m一次就能揭示程序堆和栈背后的情况。

你应该记住的是,堆通常是存储最大内存量的地方。在实践中,这意味着测量堆大小通常足以理解和计算 Go 进程的内存使用情况。因此,Go 垃圾回收器的大部分时间都用于处理堆,这意味着当我们想要优化程序的内存使用时,堆是首先要分析的部分。

下一个小节将讨论 Go 内存模型的主要元素。

Go 内存模型的主要元素

在本节中,我们将讨论 Go 内存模型的主要元素,以便更好地理解幕后发生的事情。

Go 内存模型与以下主要元素一起工作:

  • 程序代码:当进程即将运行时,操作系统会将程序代码进行内存映射,因此 Go 无法控制这部分。这类数据是只读的。

  • 全局数据:全局数据也被操作系统以只读状态进行内存映射。

  • 未初始化的数据:未初始化的数据由操作系统存储在匿名页面中。我们所说的未初始化数据,指的是包的全局变量等数据。尽管在程序开始之前我们可能不知道它们的值,但我们知道程序开始执行时我们需要为它们分配内存。这种内存空间一旦分配就不再释放。因此,垃圾回收器无法控制这部分内存。

  • :如本章前面所述,这是用于动态分配的堆。

  • :这些是用于自动分配的栈。

你不需要了解 Go 内存模型所有这些组件的详细信息。你需要记住的是,当我们有意或无意地将对象放入堆中而不让垃圾回收器释放它们,从而不释放它们各自的内存空间时,就会产生问题。我们将在稍后看到与切片和映射相关的内存泄漏案例。

还存在一个名为Go 分配器的内部 Go 组件,用于执行内存分配。它可以为 Go 对象动态分配内存块,以使 Go 对象能够正常工作,并且它被优化以防止内存碎片化和锁定。Go 分配器由 Go 团队实现和维护,因此其操作细节可能会发生变化。

下一节将讨论与未正确释放内存空间有关的内存泄漏。

内存泄漏

在接下来的小节中,我们将讨论切片和映射中的内存泄漏。当分配内存后没有完全释放时,就会发生内存泄漏。

我们将从由错误使用切片引起的内存泄漏开始。

切片和内存泄漏

在本小节中,我们将展示使用切片并产生内存泄漏的代码,然后说明避免此类内存泄漏的方法。切片内存泄漏的一个常见场景是在切片不再需要时仍然持有对较大底层数组的引用。这阻止 GC 回收与数组相关的内存。

slicesLeaks.go中的代码如下:

package main
import (
    "fmt"
"time"
)
func createSlice() []int {
    return make([]int, 1000000)
}
func getValue(s []int) []int {
    val := s[:3]
    return val
}
func main() {
    for i := 0; i < 15; i++ {
        message := createSlice()
        val := getValue(message)
        fmt.Print(len(val), " ")
        time.Sleep(10 * time.Millisecond)
    }
} 

createSlice()函数创建了一个具有大型底层数组的切片,这意味着它需要大量的内存。getValue()函数取其输入切片的前五个元素,并将这些元素作为切片返回。然而,它这样做的同时还引用了原始输入切片,这意味着该输入切片不能被 GC 释放。是的,这是一个问题!

使用一些额外的命令行参数运行slicesLeaks.go会产生以下输出:

$ go run -gcflags '-m -l' slicesLeaks.go
# command-line-arguments
./slicesLeaks.go:9:13: make([]int, 1000000) escapes to heap
./slicesLeaks.go:12:15: leaking param: s to result ~r0 level=0
./slicesLeaks.go:21:12: ... argument does not escape
./slicesLeaks.go:21:16: len(val) escapes to heap
./slicesLeaks.go:21:23: " " escapes to heap
3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 

输出表明存在泄漏参数。泄漏参数意味着这个函数在返回后以某种方式保持了其参数的存活状态——这就是内存泄漏发生的地方。然而,这并不意味着它被移动到栈上,因为大多数泄漏参数都是在堆上分配的。

slicesNoLeaks.gogetValue()函数的实现是createSlice()函数的一个改进版本:

func getValue(s []int) []int {
    returnVal := make([]int, 3)
    copy(returnVal, s)
    return returnVal
} 

这次我们创建了要返回的切片部分的副本,这意味着函数不再引用初始切片。因此,GC 将被允许释放其内存。

运行slicesNoLeaks.go会产生以下输出:

$ go run -gcflags '-m -l' slicesNoLeaks.go
# command-line-arguments
./slicesNoLeaks.go:9:13: make([]int, 1000000) escapes to heap
./slicesNoLeaks.go:12:15: s does not escape
./slicesNoLeaks.go:13:19: make([]int, 3) escapes to heap
./slicesNoLeaks.go:22:12: ... argument does not escape
./slicesNoLeaks.go:22:16: len(val) escapes to heap
./slicesNoLeaks.go:22:23: " " escapes to heap
3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 

因此,我们没有收到关于泄漏参数的消息,这意味着问题已经解决。

接下来,我们将讨论内存泄漏和映射。

映射和内存泄漏

本小节讨论由映射(maps)引入的内存泄漏,如mapsLeaks.go所示。mapsLeaks.go中的代码如下:

package main
import (
    "fmt"
"runtime"
)
func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}
func main() {
    n := 2000000
    m := make(map[int][128]byte)
    printAlloc()
    for i := 0; i < n; i++ {
    	m[i] = [128]byte{}
    }
    printAlloc()
    for i := 0; i < n; i++ {
    	delete(m, i)
    }
    runtime.GC()
    printAlloc()
    runtime.KeepAlive(m)
    m = nil
    runtime.GC()
    printAlloc()
} 

printAlloc()是一个用于打印内存信息的辅助函数,而runtime.KeepAlive(m)语句保持对m的引用,这样映射就不会被垃圾回收。

运行mapsLeaks.go会产生以下输出:

$ go run -gcflags '-m -l' mapsLeaks.go
# command-line-arguments
./mapsLeaks.go:11:12: ... argument does not escape
./mapsLeaks.go:11:31: m.Alloc / 1024 escapes to heap
./mapsLeaks.go:16:11: make(map[int][128]byte) does not escape
111 KB
927931 KB
600767 KB
119 KB 

make(map[int][128]byte)语句仅分配了 111 KB 的内存。然而,当我们填充映射时,它分配了 927,931 KB 的内存。之后,我们删除映射的所有元素,并期望使用的内存会缩小。然而,空映射需要 600,767 KB 的内存!这是因为按设计,映射中的桶(buckets)数量不能缩小。因此,当我们删除所有映射元素时,我们并没有减少现有桶的数量;我们只是将桶中的槽位清零。

然而,使用m = nil允许 GC 释放之前由m占用的内存,现在只分配了 119 KB 的内存。因此,将nil值赋予未使用的对象是一种良好的实践。

最后,我们将介绍一种可以减少内存分配的技术。

内存预分配

内存预分配是指在数据结构需要之前为其预留内存空间的行为。尽管预分配内存不是万能的,但在某些情况下,它可以避免频繁的内存分配和释放,从而提高性能并减少内存碎片。

当你对所需的容量或大小有良好的估计,你预计会有大量的插入或追加操作,或者当你想要减少内存重新分配并提高性能时,考虑预分配是很重要的。然而,当处理大量数据时,预分配更有意义。

preallocate.gomain() 函数的实现分为两部分。第一部分包含以下代码:

func main() {
    mySlice := make([]int, 0, 100)
    for i := 0; i < 100; i++ {
    	mySlice = append(mySlice, i)
    }
    fmt.Println(mySlice) 

在这个例子中,使用 make() 函数创建了一个长度为 0、容量为 100 的切片。这为切片预先分配了内存,当元素被追加时,切片可以增长而不需要重复重新分配,这可以加快处理速度。

第二部分如下:

 myMap := make(map[string]int, 10)
    for i := 0; i < 10; i++ {
    	key := fmt.Sprintf("k%d", i)
    	myMap[key] = i
    }
    fmt.Println(myMap)
} 

与之前一样,通过提供初始容量,我们减少了在元素添加时频繁调整映射大小的可能性,从而提高了内存使用效率。

下一节将讨论从 Go 语言中使用 eBPF 的方法——因为eBPF 仅在 Linux 系统上可用,所以提供的代码只能在 Linux 机器上执行。

使用 eBPF

BPF 代表伯克利包过滤器,而 eBPF 代表扩展 BPF。BPF 最初于 1992 年推出,旨在提高数据包捕获工具的性能。2013 年,Alexei Starovoitov 对 BPF 进行了重大重写,该重写于 2014 年被包含在 Linux 内核中,并取代了 BPF。通过这次重写,现在被称为 eBPF 的 BPF 变得更加灵活,可以用于除网络数据包捕获之外的各种任务。

eBPF 软件可以用 BCC、bpftrace 或使用 LLVM 编程。LLVM 编译器可以使用支持的编程语言(如 C 或 LLVM 中间表示)将 BPF 程序编译成 BPF 字节码。由于两种方式都因为使用了低级代码而难以编程,因此使用 BCC 或 bpftrace 可以使开发人员的工作更加简单。

什么是 eBPF?

由于 eBPF 具有众多功能,很难精确描述它能够做什么。相比之下,描述我们如何使用 eBPF 要容易得多。eBPF 可以用于三个主要领域:网络、安全和可观察性。本节重点介绍 eBPF 的可观察性功能(跟踪)。

你可以将 eBPF 视为位于 Linux 内核内部的虚拟机,它可以执行 eBPF 命令,即定制的 BPF 代码。因此,eBPF 使得 Linux 内核可编程,帮助你解决实际问题。请注意,eBPF(以及所有编程语言)本身并不能解决问题。eBPF 只提供了解决问题的工具!eBPF 程序由 Linux 内核的 eBPF 运行时执行。

更详细地说,eBPF 的关键特性和方面包括以下内容:

  • 可编程性:eBPF 允许用户将小型程序写入并加载到内核中,这些程序可以附加到内核代码中的各种钩子或入口点。这些程序在受限的虚拟机环境中运行,确保安全性和安全性。

  • 内核内执行:eBPF 程序以安全的方式在内核中执行,使得直接在内核空间执行高效且开销低的操作成为可能。

  • 动态附加点:eBPF 程序可以附加到内核中的各种钩子或附加点,允许开发者动态地扩展和自定义内核行为。例如,包括网络、跟踪和安全相关的钩子。

  • 可观察性和跟踪:eBPF 因其允许开发者对内核进行仪器化以收集有关系统行为、性能和交互的见解而被广泛用于可观察性和跟踪目的。像 bpftraceperf 这样的工具使用 eBPF 提供高级跟踪功能。

  • 网络:eBPF 在网络中被广泛用于诸如数据包过滤、流量监控和负载均衡等任务。它使得创建高效且可定制的网络解决方案成为可能,而无需修改内核。

  • 性能分析:eBPF 提供了一个强大的性能分析和分析框架。它允许开发者和管理员收集有关系统性能的详细信息,而不会产生显著的开销。

与传统性能工具相比,eBPF 的主要优势在于其高效性、生产安全性以及它是 Linux 内核的一部分。在实践中,这意味着我们可以使用 eBPF 而无需向 Linux 内核添加或加载任何其他组件。

关于可观察性和 eBPF

大多数 Linux 应用程序都在用户空间执行,这是一个没有太多特权的层。尽管使用用户空间更安全、更安全,但它有限制,需要使用系统调用来请求内核访问特权资源。即使是简单的命令在执行时也会使用大量的系统调用。在实践中,这意味着如果我们能够观察我们应用程序的系统调用,我们可以更多地了解它们的行为和操作方式。

当事情按预期运行且我们应用程序的性能良好时,我们通常不太关心性能和执行的系统调用。但当事情出错时,我们迫切需要了解更多关于我们应用程序的操作。在 Linux 内核中放置特殊代码或开发模块以了解我们应用程序的操作是一项困难的任务,可能需要很长时间。这就是可观察性和 eBPF 发挥作用的地方。eBPF、其语言及其工具允许我们动态地看到幕后发生的事情,而无需更改整个 Linux 操作系统。

与 eBPF 通信你所需要的一切是一个支持 libbpf 的编程语言(github.com/libbpf/libbpf)。除了 C 语言之外,Go 还提供了对 libbpf 库的支持(github.com/aquasecurity/libbpfgo)。

下一个子部分将展示如何在 Go 中创建一个 eBPF 工具。

在 Go 中创建 eBPF 工具

由于 gobpf 是一个外部 Go 包,并且默认情况下,所有最新的 Go 版本都使用模块,所有源代码都应该放在 ~/go/src 下的某个位置。所提供的工具通过跟踪 getuid(2) 系统调用来记录每个用户的用户 ID,并为每个用户 ID 记录计数。

uid.go 工具的代码将被分为四个部分进行展示。第一部分包含以下代码:

package main
import (
    "encoding/binary"
"flag"
"fmt"
"os"
"os/signal"
    bpf "github.com/iovisor/gobpf/bcc"
)
import "C"
const source string = `
#include <uapi/linux/ptrace.h>
BPF_HASH(counts);
int count(struct pt_regs *ctx) {
    if (!PT_REGS_PARM1(ctx))
        return 0;
    u64 *pointer;
    u64 times = 0;
    u64 uid;
    uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    pointer = counts.lookup(&uid);
        if (pointer !=0)
            times = *pointer;
    times++;
        counts.update(&uid, &times);
    return 0;
}
` 

如果你熟悉 C 编程语言,你应该能认出 source 变量 包含 C 代码——这是与 Linux 内核通信以获取所需信息的代码。然而,这段代码是从 Go 程序中调用的。

工具的第二部分如下:

func main() {
    pid := flag.Int("pid", -1, "attach to pid, default is all processes")
    flag.Parse()
    m := bpf.NewModule(source, []string{})
    defer m.Close() 

在本节的第二部分,我们定义了一个名为 pid 的命令行参数,并初始化了一个名为 m 的新 eBPF 模块。

工具的第三部分包含以下代码:

 Uprobe, err := m.LoadUprobe("count")
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to load uprobe count: %s\n", err)
        return
    }
    err = m.AttachUprobe("c", "getuid", Uprobe, *pid)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to attach uprobe to getuid: %s\n", err)
        return
    }
    table := bpf.NewTable(m.TableId("counts"), m)
    fmt.Println("Tracing getuid()... Press Ctrl-C to end.") 

m.LoadUprobe("count") 语句加载了 count() 函数。通过 m.AttachUprobe() 调用启动了对探针的处理。AttachUprobe() 方法表示我们想要使用 Uprobe 跟踪 getuid(2) 系统调用。bpf.NewTable() 语句使我们能够访问在 C 代码中定义的 counts 哈希表。记住,eBPF 程序是用 C 代码编写的,这些代码存储在一个 string 变量中。

工具的最后部分包含以下代码:

 sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt)
    <-sig
    fmt.Printf("%s\t%s\n", "User ID", "COUNT")
    for it := table.Iter(); it.Next(); {
        k := binary.LittleEndian.Uint64(it.Key())
        v := binary.LittleEndian.Uint64(it.Leaf())
        fmt.Printf("%d\t\t%d\n", k, v)
    }
} 

之前的代码使用了通道和 UNIX 信号处理来阻塞程序。一旦按下 Ctrl + Csig 通道将解除对程序的阻塞,并借助 table 变量打印所需的信息。由于 table 变量中的数据是二进制格式,我们需要使用两个 binary.LittleEndian.Uint64() 调用来解码它。

为了执行程序,你需要安装 C 编译器和 BPF 库,这取决于你的 Linux 变体。请参考你的 Linux 变体文档了解如何安装 eBPF 的说明。如果你在运行程序时遇到任何问题,请在相关论坛中提问。

运行 uid.go 将生成以下类型的输出:

$ go run uid.go
Tracing getuid()... Press Ctrl-C to end.
User ID    COUNT
979        4
0          3 

你可以将 uid.go 中的代码用作模板,在编写自己的 Go eBPF 工具时使用。

下一个部分将讨论 rand.Seed() 函数以及为什么从 Go 版本 1.20 开始不需要使用它。

摘要

在本书的这一章中,我们介绍了与基准测试、性能和效率相关的各种高级 Go 语言主题。请记住,基准测试结果可能会受到各种因素的影响,例如硬件、编译器优化和工作负载。在考虑基准测试运行的具体条件时,仔细和理性地解释结果是很重要的。

在这一章中,我们了解到 Go 具有自动内存管理,这意味着语言运行时为您处理内存分配和释放。Go 内存管理的主要组件是垃圾回收、自动内存分配和运行时调度器。

本章还介绍了一种非常强大的技术,即 eBPF。如果你在使用 Linux 机器,那么你绝对应该更多地了解 eBPF 以及如何使用 Go 来使用它。由于其多功能性和在 Linux 内核中解决广泛用例的能力,eBPF 框架已经获得了流行。当与 eBPF 一起工作时,你应该首先像系统管理员那样思考,而不是像程序员那样。简单来说,先尝试现有的 eBPF 工具,而不是编写自己的。然而,如果你有一个现有 eBPF 工具无法解决的问题,那么你可能需要开始像开发者一样行事。

下一章将介绍 Go 1.21 和 Go 1.22 以及它们引入的变化。

练习

尝试以下练习:

  • 创建三种不同的函数实现,用于复制二进制文件,并对它们进行基准测试以找到最快的版本。你能解释为什么这个函数更快吗?

  • 编写一个不使用buffer.Reset()BenchmarkWBufWriterReset()版本,并看看它的性能如何。

  • 这是一个非常困难的任务:在 Go 中创建一个机器学习库。请记住,在幕后,机器学习使用统计和矩阵运算。

其他资源

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

加入我们的 Discord 服务器

第十五章:近期 Go 版本的变化

本章是关于最新 Go 版本引入的变化。

首先,我们将看到 Go 的随机数生成能力发生了哪些变化。更具体地说,我们将讨论 rand.Seed()

本章的剩余部分将讨论 Go 1.21 和 Go 1.22,在撰写本文时,它们是最新版本的 Go。我们不应忘记,编程语言也是程序员开发的软件的一部分。因此,编程语言和编译器一直在不断改进,以提供新的功能、更好的代码生成、代码优化和更快的运行速度。我们通过讨论 Go 版本 1.21 和 1.22 中引入的最重要改进来结束本章。

我们将涵盖以下主题:

  • 关于 rand.Seed()

  • Go 1.21 的新特性是什么?

  • Go 1.22 的新特性是什么?

第一部分讨论了 rand.Seed() 函数以及为什么从 Go 版本 1.20 开始不再需要使用它。

关于 rand.Seed()

截至 Go 1.20 版本,使用随机值调用 rand.Seed() 来初始化随机数生成器已经没有理由了。然而,使用 rand.Seed() 并不会破坏现有的代码。为了获得特定的数字序列,建议调用 New(NewSource(seed))

这在 ch15/randSeed.go 中得到了说明——相关的 Go 代码如下:

 src := rand.NewSource(seed)
    r := rand.New(src)
    for i := 0; i < times; i++ {
        fmt.Println(r.Uint64())
    } 

rand.NewSource() 调用基于给定的种子返回一个新的(伪)随机源。因此,如果使用相同的种子调用,它将返回相同的值序列。rand.New() 调用返回一个新的 *rand.Rand 变量,它是生成(伪)随机值的东西。由于调用了 Uint64(),我们正在生成无符号的 int64 值。

运行 randSeed.go 产生以下输出:

$ go run randSeed.go 1
Using seed: 1
5577006791947779410
8674665223082153551
$ go run randSeed.go 1
Using seed: 1
5577006791947779410
8674665223082153551 

下一部分介绍了 Go 1.21 引入的变化。

Go 1.21 的新特性是什么?

在本节中,我们将讨论 Go 1.21 带来的两个新特性:标准库中的 sync.OnceFunc() 函数和内置函数 clear,后者用于删除或清零映射、切片或 类型参数 类型的所有元素。

我们将从 sync.OnceFunc() 函数开始介绍。

sync.OnceFunc() 函数

sync.OnceFunc() 函数是 sync 包的一个辅助函数。它的完整签名是 func OnceFunc(f func()) func(),这意味着它接受一个函数作为参数并返回另一个函数。更详细地说,sync.OnceFunc() 返回一个函数,该函数只调用一次函数 f——这里的重要细节是 只调用一次

现在这可能看起来不太清楚,但保存为 syncOnce.go 的代码将有助于说明 sync.OnceFunc() 的使用。syncOnce.go 的代码分为两部分。第一部分如下:

package main
import (
    "fmt"
"sync"
"time"
)
var x = 0
func initializeValue() {
    x = 5
} 

initializeValue() 函数用于初始化全局变量 x 的值。让我们确保 initializeValue() 只执行一次。

第二部分包含以下代码:

func main() {
    function := sync.OnceFunc(initializeValue)
    for i := 0; i < 10; i++ {
        go function()
    }
    time.Sleep(time.Second)
    for i := 0; i < 10; i++ {
        x = x + 1
    }
    fmt.Printf("x = %d\n", x)
    for i := 0; i < 10; i++ {
        go function()
    }
    time.Sleep(time.Second)
    fmt.Printf("x = %d\n", x)
} 

sync.OnceFunc(initializeValue)调用用于确保initializeValue()只被执行一次,尽管function()多次执行。换句话说,我们确保initializeValue()只由第一个 goroutine 执行。

运行syncOnce.go会产生以下输出:

$ go run syncOnce.go
x = 15
x = 15 

输出显示x变量的值只初始化了一次。这意味着sync.OnceFunc()可以用于初始化变量、连接或文件,并确保初始化过程只执行一次。

现在,是时候学习clear函数了。

清除函数

在本小节中,我们将介绍在处理 map 和数组时使用clear函数的用法。当用于 map 对象时,clear()会清除 map 对象的所有元素。当用于 slice 对象时,clear()会将 slice 的所有元素重置为其数据类型的零值,同时保持相同的 slice 长度和容量——这与 map 对象发生的情况完全不同。

相关程序的名称是clr.go——重要的 Go 代码如下:

func main() {
    m := map[string]int{"One": 1}
    m["Two"] = 2
    fmt.Println("Before clear:", m)
    clear(m)
    fmt.Println("After clear:", m)
    s := make([]int, 0, 10)
    for i := 0; i < 5; i++ {
        s = append(s, i)
    }
    fmt.Println("Before clear:", s, len(s), cap(s))
    clear(s)
    fmt.Println("After clear:", s, len(s), cap(s))
} 

在之前的代码中,我们创建了一个名为m的 map 变量和一个名为s的 slice 变量。在向它们中添加一些数据后,我们调用clear()函数。

运行clr.go会产生以下输出:

$ go run clr.go
Before clear: map[One:1 Two:2]
After clear: map[]
Before clear: [0 1 2 3 4] 5 10
After clear: [0 0 0 0 0] 5 10 

那么,刚才发生了什么?在调用clear()之后,m是一个空的 map,而s是一个与之前相同长度和容量的 slice,其所有元素都重置为其数据类型的零值,即int

下一节将介绍 Go 1.22 中引入的最重要变化。

Go 1.22 的新特性是什么?

在完成本书的写作过程中,Go 1.22 版本正式发布。在本节中,我们将介绍 Go 1.22 版本中最有趣的新特性和改进。

  • 循环变量中不再有共享。

  • 缩小切片大小的函数(Delete()DeleteFunc()Compact()CompactFunc()Replace())现在将新长度和旧长度之间的元素置零。

  • math/rand有一个更新版本,称为math/rand/v2

请记住,在 Go 1.22 中,标准库的 HTTP 路由功能得到了改进。在实践中,这意味着net/http.ServeMux使用的模式已经增强,可以接受方法和通配符。更多关于这个的信息可以在pkg.go.dev/net/http@master#ServeMux找到。

我们将首先介绍slices包中的变化。

切片的变化

除了缩小切片大小的函数的变化之外,还增加了slices.Concat(),它连接多个切片。所有这些都在sliceChanges.go中展示。main()函数的代码分为两部分。

sliceChanges.go 的第一部分代码如下:

func main() {
    s1 := []int{1, 2}
    s2 := []int{-1, -2}
    s3 := []int{10, 20}
    conCat := slices.Concat(s1, s2, s3)
    fmt.Println(conCat) 

在前面的代码中,我们使用 slices.Concat() 连接三个切片。

sliceChanges.go 的其余部分包含以下代码:

 v1 := []int{-1, 1, 2, 3, 4}
    fmt.Println("v1:", v1)
    v2 := slices.Delete(v1, 1, 3)
    fmt.Println("v1:", v1)
    fmt.Println("v2:", v2)
} 

如前所述,slices.Delete() 将其参数指定的切片中删除的元素置零,并返回一个不包含删除切片元素的切片——因此 v1 的长度与之前相同,但 v2 的长度更小。

运行 sliceChanges.go 产生以下输出:

$ go run sliceChanges.go
[1 2 -1 -2 10 20]
v1: [-1 1 2 3 4]
v1: [-1 3 4 0 0]
v2: [-1 3 4] 

第一行显示了连接切片 (conCat) 的内容。第二行包含 v1 的初始版本,而第三行显示了调用 slices.Delete()v1 的内容。最后一行包含存储在 v2 切片中的 slices.Delete() 的返回值。

接下来,我们将查看 for 循环中的变化。

for 循环中的变化

Go 1.22 在 for 循环中引入了一些变化,我们将使用 changesForLoops.go 在本小节中展示这些变化。changesForLoops.gomain() 函数的代码将分为两部分。第一部分如下:

func main() {
    for x := range 5 {
        fmt.Print(" ", x)
    }
    fmt.Println() 

因此,从 Go 1.22 开始,for 循环可以遍历整数。

changesForLoops.go 的最后一部分如下:

 values := []int{1, 2, 3, 4, 5}
    for _, val := range values {
        go func() {
            fmt.Printf("%d ", val)
        }()
    }
    time.Sleep(time.Second)
    fmt.Println()
} 

因此,从 Go 1.22 开始,每次执行 for 循环时,都会分配一个新的变量。这意味着不再共享循环变量,这意味着可以在 goroutine 内使用循环变量而无需担心竞态条件。

这也意味着 ch08/goClosure.go 在执行时不会遇到任何问题。然而,编写清晰的代码始终被视为一种良好的实践。

运行 changesForLoops.go 产生以下输出:

$ go run changesForLoops.go
 0 1 2 3 4
5 3 4 1 2 

输出的第一行显示循环可以遍历整数。第二行输出验证了每个通过 for 循环创建的 goroutine 都使用不同的、独立的循环变量副本。

最后,我们展示了 math/rand 包更新版本的新功能。

math/rand/v2

Go 1.22 引入了对 math/rand 包的更新,名为 math/rand/v2。此包的功能在 randv2.go 中展示,分为三个部分。randv2.go 的第一部分如下:

package main
import (
    "fmt"
"math/rand/v2"
)
func Read(p []byte) (n int, err error) {
    for i := 0; i < len(p); {
        val := rand.Uint64()
        for j := 0; j < 8 && i < len(p); j++ {
            p[i] = byte(val & 0xff)
            val >>= 8
            i++
        }
    }
    return len(p), nil
} 

其中最重要的变化之一是 math/randRead() 方法的弃用。然而,使用 Uint64() 方法实现了一个自定义的 Read() 函数。

第二部分包含以下代码:

func main() {
    str := make([]byte, 3)
    nChar, err := Read(str)
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Printf("Read %d random bytes\n", nChar)
        fmt.Printf("The 3 random bytes are: %v\n", str)
    } 

在这部分中,我们调用之前实现的 Read() 来获取 3 个随机字节。

randv2.go 的最后一部分包含以下代码:

 var max int = 100
    n := rand.N(max)
    fmt.Println("integer n =", n)
    var uMax uint = 100
    uN := rand.N(uMax)
    fmt.Println("unsigned int uN =", uN)
} 

这里引入了适用于任何整数类型的泛型函数。在之前的代码中,我们使用 rand.N() 获取 int 值以及 uint 值。rand.N() 的参数指定了它将要返回的值的类型。

rand.N() 也可以用于时间长度,因为 time.Duration 是基于 int64 的。

使用 Go 1.22 或更高版本运行 randv2.go 会产生以下类型的输出:

$ go run randv2.go
Read 3 random bytes
The 3 random bytes are: [71 215 175]
integer n = 0
unsigned int uN = 2 

本小节总结了本章内容,这也是本书的最后一章!感谢您阅读整本书,感谢您选择我的书来学习 Go!

摘要

在本书的最后一章,我们介绍了 Go 1.21 和 Go 1.22 中引入的有趣且重要的变化,以便更清晰地了解 Go 语言是如何不断改进和演变的。

那么,Go 开发者的未来看起来如何?简而言之,看起来非常美好!你应该已经享受在 Go 中编程,随着语言的演变,你应该继续这样做。如果你想了解 Go 的最新和最伟大之处,你绝对应该访问 Go 团队的官方 GitHub 页面 github.com/golang

Go 帮助你创建出色的软件!所以,去创建出色的软件!记住,当我们享受我们所做的事情时,我们最有效率

练习

尝试以下练习:

  • ./ch02/genPass.go 中的 rand.Seed() 调用改为 rand.New(rand.NewSource(seed)) 以进行必要的更改。

  • 类似地,将 ./ch02/randomNumbers.go 中的 rand.Seed() 调用替换为 rand.New(rand.NewSource(seed)) 以进行必要的更改。

其他资源

作者的话

成为一名优秀的程序员很困难,但这是可以做到的。持续进步,谁知道呢——你可能会成名,甚至有关于你的电影!

感谢您阅读这本书。请随时与我联系,提出建议、问题或可能的其他书籍的想法!

荣耀归主

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

discord.gg/FzuQbc8zd6

附录

Go 垃圾回收器

本附录的主题是 Go 垃圾回收器GC)的操作。需要注意的是,GC 的细节和性能特性可能会随着每个新的 Go 版本而演变。

开发者通常不需要直接与 GC 交互,因为它在其自己的 goroutine 中以自动方式在后台运行。然而,了解其行为对于优化内存使用和避免与内存管理相关的常见陷阱是有益的。对于最新和最详细的信息,建议参考官方 Go 文档和发布说明。

首先,让我们讨论一下垃圾回收的一般情况。之后,我们将深入探讨 Go 垃圾回收的细微差别。

垃圾回收

垃圾回收是释放未使用内存空间的过程。换句话说,GC 看到哪些对象已超出作用域且无法再被引用,并释放它们所消耗的内存空间。这个过程在 Go 程序运行时以并发方式发生,而不是在程序执行之前或之后。Go GC 实现的文档中说明了以下内容:

“垃圾回收器与 mutator 线程并发运行,类型准确(也称为精确),允许多个 GC 线程并行运行。它是一种使用写屏障的并发标记和清除。它既不是代式的也不是压缩式的。分配是通过每个 P 的分配区域按大小分离来进行的,以最小化碎片化并消除常见情况下的锁。”

Go 垃圾回收器的关键特性

Go 垃圾收集器的关键特性如下:

  • 并发和并行:Go 垃圾回收器与 Go 程序的执行并发运行。它与应用程序的线程并发运行,这意味着 GC 可以在不停止正在执行的应用程序的情况下执行其工作。此外,GC 的某些阶段可以并行化,以利用多个 CPU 核心和现代 CPU。

  • 代收集器:Go 的垃圾回收器使用代垃圾回收策略,将对象分为两代:年轻代和老年代。大多数对象分配到年轻代,大部分垃圾回收工作都集中在那里。老年代包含寿命较长的对象,这些对象不太可能被垃圾回收。

  • 三色标记和清除算法:Go GC 使用三色标记和清除算法。该算法在标记阶段使用三种颜色(白色、灰色和黑色)来跟踪对象的状态。白色对象尚未被访问,灰色对象正在被访问过程中,黑色对象已被访问。

  • 写屏障:Go 使用写屏障来跟踪堆中更新的指针,以在垃圾回收期间保持一致性。写屏障确保 GC 了解指针的变化,允许它准确地跟踪对象依赖关系。

  • 垃圾回收触发器:Go GC 基于内存分配和堆大小触发。当分配的内存达到某个阈值或堆大小超过指定的限制时,GC 被触发以回收未使用的内存。

  • 手动控制:虽然 GC 被设计为自动且对开发者透明,但有一些方法可以提供提示并控制垃圾回收过程的某些方面。例如,可以使用runtime.GC()函数请求显式的垃圾回收周期。

我们将在不久的将来重新审视 GC 的大部分特性。

了解 Go GC 的更多内容

Go 标准库提供了允许你研究 GC 操作并了解更多关于 GC 秘密做什么的功能。这些函数在gColl.go实用程序中得到了说明。gColl.go的源代码在此以块的形式展示。

package main
import (
    "fmt"
"runtime"
"time"
) 

我们需要runtime包,因为它允许我们获取有关 Go 运行时系统的信息,其中包括 GC 操作的信息。

func printStats(mem runtime.MemStats) {
    runtime.ReadMemStats(&mem)
    fmt.Println("mem.Alloc:", mem.Alloc)
    fmt.Println("mem.TotalAlloc:", mem.TotalAlloc)
    fmt.Println("mem.HeapAlloc:", mem.HeapAlloc)
    fmt.Println("mem.NumGC:", mem.NumGC, "\n")
} 

printStats()的主要目的是避免多次编写相同的 Go 代码。runtime.ReadMemStats()调用为你获取最新的垃圾回收统计信息。

func main() {
    var mem runtime.MemStats
    printStats(mem)
    for i := 0; i < 10; i++ {
        s := make([]byte, 50000000)
        if s == nil {
            fmt.Println("Operation failed!")
        }
    }
    printStats(mem) 

在这部分,我们有一个 for 循环,创建了 10 个每个 50,000,000 字节的字节块。这样做的原因是,通过分配大量内存,我们可以触发 GC。

 for i := 0; i < 10; i++ {
        s := make([]byte, 100000000)
        if s == nil {
            fmt.Println("Operation failed!")
        }
        time.Sleep(5 * time.Second)
    }
    printStats(mem)
} 

程序的最后部分进行了更大的内存分配——这次,每个字节块有 10 亿字节。

在具有 32 GB RAM 的 macOS Sonoma 机器上运行gColl.go会产生以下类型的输出:

$ go run gColl.go
mem.Alloc: 114960
mem.TotalAlloc: 114960
mem.HeapAlloc: 114960
mem.NumGC: 0
mem.Alloc: 50123152
mem.TotalAlloc: 500163016
mem.HeapAlloc: 50123152
mem.NumGC: 9
mem.Alloc: 121472
mem.TotalAlloc: 1500246248
mem.HeapAlloc: 121472
mem.NumGC: 20 

mem.Alloc的值是已分配堆对象的字节数——所有 GC 尚未释放的对象。mem.TotalAlloc显示为堆对象分配的累积字节数——当对象被释放时,这个数字不会减少,这意味着它会持续增加。因此,它显示了程序执行期间为堆对象分配的总字节数。mem.HeapAllocmem.Alloc相同。最后,mem.NumGC显示了完成的垃圾回收周期总数。这个值越大,你就越需要考虑如何在代码中分配内存,以及是否有优化这一点的途径。

如果你想获取更多关于 GC 操作的详细输出,可以将go run gColl.goGODEBUG=gctrace=1结合使用。除了常规程序输出外,你还会得到一些额外的指标——这将在以下输出中说明:

$ GODEBUG=gctrace=1 go run gColl.go
gc 1 @0.004s 2%: 0.008+0.34+0.042 ms clock, 0.081+0.063/0.51/0.18+0.42 ms cpu, 3->3->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 2 @0.009s 3%: 0.097+0.93+0.049 ms clock, 0.97+0.20/1.0/0.84+0.49 ms cpu, 3->3->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
.
.
.
gc 18 @35.101s 0%: 0.13+0.15+0.009 ms clock, 1.3+0/0.22/0.007+0.095 ms cpu, 95->95->0 MB, 95 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 19 @40.107s 0%: 0.091+0.38+0.011 ms clock, 0.91+0/0.54/0+0.11 ms cpu, 95->95->0 MB, 95 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 20 @45.111s 0%: 0.095+0.26+0.009 ms clock, 0.95+0/0.38/0+0.092 ms cpu, 95->95->0 MB, 95 MB goal, 0 MB stacks, 0 MB globals, 10 P
mem.Alloc: 121200
mem.TotalAlloc: 1500245792
mem.HeapAlloc: 121200
mem.NumGC: 20 

和之前一样,我们有相同数量的完成的垃圾回收周期(20)。然而,我们得到了关于每个周期堆大小的额外信息。因此,对于垃圾回收周期 20(gc 20),我们得到以下信息:

gc 20 @45.111s 0%: 0.095+0.26+0.009 ms clock, 0.95+0/0.38/0+0.092 ms cpu, 95->95->0 MB, 95 MB goal, 0 MB stacks, 0 MB globals, 10 P 

现在我们来解释上一行输出中的 95->95->0 MB 三联组。第一个值(95)是 GC 即将运行时的堆大小。第二个值(95)是 GC 结束操作时的堆大小。最后一个值是活动堆的大小(0)。

三色算法

如前所述,Go GC 的操作基于三色算法。请注意,三色算法并非仅限于 Go,也可以用于其他编程语言。

严格来说,Go 中使用的算法的官方名称是 三色标记-清除算法。它与程序并发工作并使用写屏障。这意味着当 Go 程序运行时,Go 调度器负责调度应用程序以及垃圾回收器(GC),后者也作为一个 goroutine 运行。这就像 Go 调度器必须处理一个具有多个 goroutine 的常规应用程序一样!

该算法背后的核心思想来自 Edsger W. Dijkstra、Leslie Lamport、A. J. Martin、C. S. Scholten 和 E. F. M. Steffens,并在一篇名为 On-the-Fly Garbage Collection: An Exercise in Cooperation 的论文中首次阐述。

三色标记-清除算法背后的主要原则是,根据算法分配的颜色将堆中的对象分为三个不同的集合,颜色可以是黑色、灰色或白色。黑色集合中的对象保证没有指向白色集合中任何对象的指针。另一方面,白色集合中的对象可以指向黑色集合中的对象,因为这不会对 GC 的操作产生影响。灰色集合中的对象可能指向白色集合中的某些对象。最后,白色集合中的对象是垃圾回收的候选对象。

因此,当垃圾回收开始时,所有对象都是白色的,GC 会访问所有根对象并将它们标记为灰色。根对象是可以被应用程序直接访问的对象,包括全局变量和堆栈上的其他东西。这些对象主要依赖于特定程序的 Go 代码。

之后,GC 选择一个灰色对象,将其变为黑色,并开始检查该对象是否指向白色集合中的其他对象。因此,当灰色集合中的对象被扫描以查找指向其他对象的指针时,它会被染成黑色。如果这次扫描发现这个特定的对象有一个或多个指向白色对象的指针,它就会将那个白色对象放入灰色集合。这个过程会一直持续到灰色集合中存在对象为止。之后,白色集合中的对象是不可达的,它们的内存空间可以被重用。因此,在这个时候,白色集合中的元素被认为是垃圾回收的。请注意,没有任何对象可以直接从黑色集合转到白色集合,这允许算法运行并能够清除白色集合中的对象。如前所述,黑色集合中的对象不能直接指向白色集合中的对象。此外,如果在垃圾回收周期中的某个时刻,灰色集合中的对象变得不可达,它将不会在该垃圾回收周期中被回收,而是在下一个周期中回收!尽管这不是最佳情况,但也不是那么糟糕。

在这个过程中,运行中的应用程序被称为突变器。突变器运行一个名为写屏障的小函数,每次堆中的指针被修改时都会执行该函数。如果堆中对象的指针被修改,这意味着该对象现在是可以到达的——写屏障将其染成灰色并将其放入灰色集合。突变器负责确保黑色集合中没有元素指向白色集合中的元素。这是通过写屏障函数实现的。未能实现这一不变性将破坏垃圾回收过程,并且很可能会以非常糟糕和不受欢迎的方式使你的程序崩溃!

因此,总结一下,有三种不同的颜色:黑色、白色和灰色。当算法开始时,所有对象都被染成白色。随着算法的进行,白色对象会被移动到另外两个集合中的一个。留在白色集合中的对象是那些将在某个时刻被清除的对象。下一图显示了包含对象的三个颜色集合。

一组带有字母和数字的圆圈  自动生成的描述

图 A.1:Go 垃圾回收器将程序的堆表示为图

当对象E(在白色集合中)可以访问对象F时,它不能被任何其他对象访问,因为没有任何其他对象指向对象E,这使得它成为垃圾回收的完美候选者!此外,对象ABC是根对象,总是可以到达的;因此,它们不能被垃圾回收。

你能猜到接下来会发生什么吗?好吧,算法将不得不处理灰色集合的剩余元素,这意味着对象AF都将进入黑色集合。对象A进入黑色集合,因为它是一个根元素,而F进入黑色集合,因为它在灰色集合中不指向任何其他对象。

在对象E被垃圾回收后,对象F将变得不可达,并在 GC 的下一个周期中被垃圾回收,因为不可达的对象不能在垃圾回收周期的下一个迭代中神奇地变得可达。

Go 的垃圾回收也可以应用于诸如通道之类的变量。当 GC 发现通道不可达时,即通道变量无法再被访问时,即使通道尚未关闭,它也会释放其资源。

Go 允许你通过在 Go 代码中放置runtime.GC()语句来手动启动垃圾回收周期。然而,请注意,runtime.GC()会阻塞调用者,它可能会阻塞整个程序,尤其是如果你正在运行一个非常繁忙的 Go 程序,并且有很多对象时。这主要是因为当其他一切都在快速变化时,你不能执行垃圾回收,因为这不会给 GC 提供清楚地识别白色、黑色和灰色集合成员的机会。这种垃圾回收状态也被称为垃圾回收安全点。

你可以在github.com/golang/go/blob/master/src/runtime/mgc.go找到 GC 的较长且相对高级的 Go 代码,如果你想要了解更多关于垃圾回收操作的信息,你可以研究这段代码。如果你足够勇敢,甚至可以修改这段代码!

关于 Go GC 的操作更多

本节将更详细地讨论 Go GC,并展示其活动的一些额外信息。Go GC 的主要关注点是低延迟,这基本上意味着在操作中保持短暂的暂停,以便实现实时操作。另一方面,程序一直在创建新的对象,并通过指针操作现有的对象。这个过程可能会导致无法访问的对象,因为没有指针指向这些对象。这些对象随后成为垃圾,等待 GC 清理并释放其内存空间。之后,释放的内存空间就可以再次使用了。

标记-清除算法是使用最简单的算法。该算法为了访问程序堆中所有可访问的对象并对其进行标记,会停止程序执行(停止世界 GC)。之后,它清除不可访问的对象。在算法的标记阶段,每个对象被标记为白色、灰色或黑色。灰色对象的子对象被着色为灰色,而原始的灰色对象随后被着色为黑色。当没有更多灰色对象需要检查时,开始清除阶段。这种技术之所以有效,是因为黑色集合中没有指针指向白色集合,这是算法的一个基本不变量。

尽管标记-清除算法很简单,但在运行时它暂停了程序的执行,这意味着它给实际过程增加了延迟。Go 试图通过将 GC 作为并发进程运行以及使用上一节中描述的三色算法来降低这种延迟。然而,当 GC 并发运行时,其他进程可以移动指针或创建新对象。这个事实可能会给 GC 带来困难。

因此,允许三色算法在保持标记-清除算法的基本不变量的同时并发运行的基本原则是,黑色集合中的任何对象都不能指向白色集合中的对象。

解决这个问题的方法是修复所有可能对算法造成问题的案例。因此,新对象必须进入灰色集合,因为这样,标记-清除算法的基本不变量就不会被改变。此外,当程序中的指针移动时,您将指针指向的对象着色为灰色。灰色集合在白色集合和黑色集合之间充当屏障。最后,每次指针移动时,都会自动执行一些 Go 代码,这就是前面提到的写屏障,它进行一些重新着色。执行写屏障代码引入的延迟是我们为了能够并发运行 GC 而必须付出的代价。

注意,Java 编程语言有许多垃圾收集器,这些收集器可以通过多个参数进行高度配置。其中之一是 G1,它适用于低延迟应用。尽管 Go 没有多个垃圾收集器,但它确实有一些旋钮,您可以使用这些旋钮来调整应用程序的垃圾收集器。

下一个部分将从一个垃圾收集的角度讨论映射和切片,因为有时我们处理变量的方式会影响 GC 的操作。

映射、切片和 Go GC

在本节中,我们讨论 Go GC 与映射和切片的关系操作。本节的目的让您编写使 GC 工作更简单的代码。

使用切片

本节中的示例使用切片来存储大量结构,以展示切片分配与 GC 操作的关系。每个结构存储两个整数值。这通过sliceGC.go实现如下:

package main
import (
    "runtime"
)
type data struct {
    i, j int
}
func main() {
    var N = 80000000
var structure []data
    for i := 0; i < N; i++ {
        value := int(i)
        structure = append(structure, data{value, value})
    }
    runtime.GC()
    _ = structure[0]
} 

最后一条语句(_ = structure[0])用于防止垃圾回收器过早地回收结构变量,因为它在for循环外部没有被引用或使用。接下来的三个 Go 程序也将使用同样的技术。除了这个重要的细节外,for循环用于将所有值放入存储在结构切片变量中的结构体中。另一种实现方式是使用runtime.KeepAlive()。程序不生成任何输出——它只是通过调用runtime.GC()来触发垃圾回收。

使用指针映射

在本小节中,我们使用映射来存储指针。这次,映射使用整数键来引用指针。程序名为mapStar.go,包含以下 Go 代码:

package main
import (
    "runtime"
)
func main() {
    var N = 80000000
    myMap := make(map[int]*int)
    for i := 0; i < N; i++ {
        value := int(i)
        myMap[value] = &value
    }
    runtime.GC()
    _ = myMap[0]
} 

程序的操作与上一节中的sliceGC.go相同。不同的是,它使用映射(make(map[int]*int))来存储int的指针。与之前一样,程序没有生成任何输出。

不使用指针的映射

在本小节中,我们使用一个直接存储整数值的映射,而不是存储整数值的指针。mapNoStar.go中的重要代码如下:

func main() {
    var N = 80000000
    myMap := make(map[int]int)
    for i := 0; i < N; i++ {
        value := int(i)
        myMap[value] = value
    }
    runtime.GC()
    _ = myMap[0]
} 

同样,程序没有生成任何输出。

分割映射

在最后一个程序中,我们使用了一种称为分片的不同技术,其中将一个长映射分割成映射的映射。mapSplit.gomain()函数的实现如下:

func main() {
    var N = 80000000
    split := make([]map[int]int, 2000)
    for i := range split {
        split[i] = make(map[int]int)
    }
    for i := 0; i < N; i++ {
        value := int(i)
        split[i%2000][value] = value
    }
    runtime.GC()
    _ = split[0][0]
} 

代码使用了两个for循环,一个用于创建映射的映射,另一个用于将所需的数据值存储在映射的映射中。

由于所有四个程序都使用大量数据结构,它们消耗了大量的内存。消耗大量内存空间的程序会触发 Go GC 的频率更高。下一小节将介绍对所提出技术的评估。

比较所提出技术的性能

在本小节中,我们使用zsh(1)命令的时间命令(与 UNIX 命令time(1)非常相似)比较这四种实现的性能。比较的目的是了解分配技术和使用的数据结构如何影响程序的性能。

$ time go run sliceGC.go
go run sliceGC.go  0.61s user 0.52s system 92% cpu 1.222 total
$ time go run mapStar.go
go run mapStar.go  23.86s user 1.02s system 176% cpu 14.107 total
$ time go run mapNoStar.go
go run mapNoStar.go  10.01s user 0.53s system 98% cpu 10.701 total
$ time go run mapSplit.go
go run mapSplit.go  11.22s user 0.44s system 100% cpu 11.641 total 

结果表明,所有映射版本都比切片版本慢。不幸的是,对于映射来说,由于哈希函数的执行和数据的不连续性,映射版本将始终比切片版本慢。在映射中,数据存储在由哈希函数输出确定的桶中

此外,第一个地图程序(mapStar.go)可能会触发一些垃圾回收(GC)的减慢,因为获取&value的地址会导致它逃逸到堆上。其他所有程序只是使用栈来存储那些局部变量。当变量逃逸到堆上时,它们会导致更多的垃圾回收压力

访问映射或切片的元素具有O(1)的运行时间,这意味着访问时间不依赖于映射或切片中找到的元素数量。然而,这些结构的工作方式会影响整体速度。

其他资源

留下评论!

喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码,获取你选择的免费电子书。

packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助你规划个人发展和职业进步。更多信息,请访问我们的网站。

第十六章:为什么订阅?

  • 通过来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 的其他书籍也感兴趣:

Go 中的有效并发

Burak Serdar

ISBN: 9781804619070

  • 理解基本的并发概念和问题

  • 了解 Go 并发原语及其工作方式

  • 了解 Go 内存模型及其重要性

  • 了解如何使用常见的并发模式

  • 了解如何在并发程序中处理错误

  • 发现有用的故障排除技术

Go 中的函数式编程

Dylan Meeus

ISBN: 9781801811163

  • 通过实际示例深入了解函数式编程

  • 在核心 FP 概念上建立坚实的基础,并了解它们如何应用于 Go 代码

  • 发现 FP 如何提高你的代码库的可测试性

  • 应用功能设计模式进行问题解决

  • 了解何时选择和何时不选择 FP 概念

  • 了解在处理并发代码时函数式编程的好处

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已经与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解与全球科技社区分享。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《精通 Go 第四版》,我们很乐意听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

posted @ 2025-09-09 11:32  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报