Go-编程从新手到专家-全-

Go 编程从新手到专家(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 Go 编程 – 从入门到专业!在这里,你将学习到构建现代软件所需的一切,利用 Go。本课程专为没有编程经验的初学者设计,提供了一种全面的方法来理解和利用 Go 的力量和惯用性。

学习的最佳方式是通过实践。在这本书中,你将确切地这样做。Go 编程 – 从入门到专业将带你进行一场引人入胜的、循序渐进的旅程,从基础知识开始理解 Go。每一章都包含了激动人心的练习和活动,你可以根据自己的节奏进行,或者跳过某些部分。随着你继续阅读,你将逐步深入到更高级的主题,在那里你会发现如何利用 Go 的效率、简洁性和并发性来构建健壮和可扩展的软件解决方案。以你自己的方式和节奏学习,你将逐步建立并加强关键技能,这些技能将随着你作为 Go 开发者的成长而感到有成就感。

本书将突出 Go 1.21 及以后的最新特性,确保你在提升技能的过程中始终跟上这种多才多艺的语言的尖端能力。你将像 Go Gopher 一样构建和迭代你的代码,在学习过程中不断进步。

加入这个激动人心的冒险,随着我们解锁 Go 的全部潜力,我们将赋予你成为一名熟练的 Go 开发者的能力。无论你是构建 Web 应用程序、微服务,还是解决一般的软件挑战并希望利用 Go,这本书都为你提供了成功所需的知识和技能。让我们深入其中,用 Go 提升你的编程之旅。

这本书适合谁

本书专为 Go 的新手设计,无论你是从零开始还是从其他语言过渡过来,本书都赋予开发者构建现实世界项目和发展在 Go 中开启职业生涯所需技能的能力。提供了一种循序渐进的方法,即使是初学者也能在没有先前的编程经验的情况下掌握 Go 的基础知识。随着读者的进步,他们将发现 Go 的惯用最佳实践并探索语言的最新特性。读者将在 Go 中构建现代软件方面获得专业知识,通过实践学习经验,使他们能够成为专业的开发者。

这本书涵盖的内容

第一章变量和运算符,解释了变量如何暂时保存数据。它还展示了你如何使用运算符来更改数据或对数据进行比较。

第二章命令与控制,教你如何通过创建基于变量中数据的规则来使你的代码动态和响应。循环让你在学习过程中重复逻辑,并学会用 Go 掌控你的控制流。

第三章核心类型,向你介绍了数据的基本构建块。你将学习什么是类型以及如何定义核心类型。

第四章, 复杂类型,解释了复杂类型是在核心类型的基础上构建的,以便您可以使用数据分组和从核心类型组合新类型来模拟现实世界的数据。您还将了解在需要时如何克服 Go 的类型系统。

第五章, 函数 – 减少、重用和回收,教授您构建函数的基本知识。然后,我们将深入了解使用函数的更高级功能,例如将函数作为参数传递、返回函数、将函数赋值给变量以及许多您可以用函数做的有趣事情。您将学习代码重用的基础知识。

第六章, 不要恐慌!处理错误,教授您如何处理错误,涵盖诸如声明自己的错误以及以 Go 的方式处理错误等主题。您将了解什么是panic以及如何从中恢复。

第七章, 接口,首先讲解接口的机制,然后演示 Go 中的接口提供了多态性、鸭子类型、能够拥有空接口以及接口的隐式实现。

第八章, 泛型算法超级能力,展示了 Go 提供的泛型参数语法,以创建一个适用于多种类型的通用代码版本。您将理解何时、为什么以及如何利用泛型来减少代码重复。

第九章, 使用 Go 模块定义项目,演示了如何利用 Go 模块来构建和管理 Go 项目,涵盖了基本的 Go 依赖管理文件。

第十章, 包保持项目可管理,演示了如何在我们的程序中利用 Go 包来保持代码可管理并将代码分组到有用的功能子系统。

第十一章, 调试技巧破除错误,教授您在应用程序中查找错误的基本原理。您将使用在代码中打印标记、使用值和类型以及执行日志记录的各种技术。

第十二章, 关于时间,让您提前了解 Go 如何管理时间变量,以及提供了哪些功能来提高您的应用程序,例如测量执行时间和在时区之间导航。

第十三章, 从命令行编程,教授您如何使用 Go 提供的所有功能创建命令行实用程序。您将练习标志解析、处理大量数据、退出代码、终端用户界面,并在过程中学习最佳实践。

第十四章, 文件和系统,展示了 Go 在处理文件和底层操作系统方面有很好的支持。你将处理文件系统,学习如何在操作系统上创建、读取和修改文件。你还将看到 Go 如何读取 CSV 文件,这是管理员常用的文件格式。

第十五章, SQL 和数据库,涵盖了连接数据库和操作表的最重要方面,这些方面在当今是非常常见的任务,你将学习如何使用 Go 高效地与数据库工作。

第十六章, Web 服务器,教你如何使用 Go 标准包创建 HTTP 服务器、构建网站和创建 REST API。你将学习如何接受来自网页表单或来自另一个程序的请求并以人类或机器可读的格式进行响应。

第十七章, 使用 Go HTTP 客户端,指导你如何使用 Go 标准包创建 HTTP 客户端并与 REST API 交互。你将学习如何向服务器发送 GET 请求并处理响应,以及如何向服务器发送表单数据以及如何上传文件到服务器。

第十八章, 并发工作,展示了如何利用 Go 的并发特性使你的软件能够同时执行多个任务,将工作分配到独立的 Goroutines 中,从而减少处理时间。

第十九章, 测试,帮助你理解 Go 支持的各种测试类型,包括 HTTP 测试、模糊测试、基准测试、使用测试套件以及生成测试报告和代码覆盖率。

第二十章, 使用 Go 工具,使你熟悉随 Go 一起提供的工具,并解释如何使用它们来改进你的代码。你将学习如何使用gofmtgoimports自动格式化代码。你还将学习如何使用go vet进行静态分析以及如何使用 Go 的竞态条件检测器检测竞态条件。

第二十一章, 云中的 Go,帮助你理解如何为云部署准备 Go 代码。你将学习如何通过使用 Prometheus、OpenTelemetry 等工具添加监控功能,以及如何将 Go 应用程序容器化以与 Kubernetes 等编排器一起工作。

为了充分利用这本书

每一段伟大的旅程都是从一小步开始的。我们即将开始的 Go 编程之旅也不例外。在我们能够使用 Go 做些酷的事情之前,我们需要准备一个高效的环境。为了这本书能更好地为你服务,你应该安装 Git、Docker 和 1.21 或更高版本的 Go。建议你有 4GB 的 RAM,并在 BIOS 中启用虚拟化(通常默认启用)。这本书最适合 macOS 或 Linux,如果需要,将需要为 Windows 等效命令进行一些调整。建议使用 1.6 GHz 或更快的桌面处理器。

关于额外设置的辅助说明:

安装 Go 编译器

为了将你的 Go 源代码转换成可运行的形式,你需要 Go 编译器。对于 Windows 和 macOS,我们推荐使用安装程序。作为替代,为了获得更多控制权,你可以下载预编译的二进制文件。你可以在packt.live/2PRUGjp找到它们。Windows、macOS 和 Linux 上两种方法的安装说明在packt.live/375DQDA。Go 编译器免费下载和使用。

安装 Git

Go 使用版本控制工具 Git 来安装额外的工具和代码。你可以在packt.live/35ByRug找到 Windows、macOS 和 Linux 的说明。Git 免费安装和使用。

安装 Visual Studio Code(编辑器/IDE)

你需要某种东西来编写你的 Go 源代码。这个工具被称为编辑器或集成开发环境IDE)。如果你已经有了喜欢的编辑器,你可以使用它来学习这门课程,如果你愿意的话。

如果你还没有编辑器,我们推荐你使用免费的编辑器 Visual Studio Code。你可以从packt.live/35KD2Ek下载安装程序:

  1. 一旦安装,打开 Visual Studio Code。

  2. 从顶部菜单栏中选择查看

  3. 从选项列表中选择扩展

  4. 应该在左侧出现一个面板。在顶部是一个搜索输入框。输入Go

  5. 第一个选项应该是一个名为Go by Microsoft的扩展。

  6. 点击该选项上的安装按钮。

  7. 等待一个消息说它已成功安装。

如果你已经安装了 Git,请按照以下步骤操作:

  1. 同时按下Ctrl/Cmd + Shift + P。应在窗口顶部出现一个文本输入框。

  2. 输入go tools

  3. 选择标签类似于转到安装/更新工具的选项。

  4. 你会看到一个选项和复选框的列表。

  5. 在搜索输入框旁边的第一个复选框会选中所有复选框。选择此复选框,然后选择其右侧的转到按钮。

  6. 应该从底部出现一个面板,其中包含一些活动。一旦停止(可能需要几分钟),你就完成了。

完成后,从顶部菜单栏中选择查看,然后选择资源管理器

安装 Docker

Docker 允许我们运行数据库服务器等,而无需安装它们,并将我们的应用程序容器化。Docker 可免费安装和使用。

对于 macOS 用户,请遵循packt.live/34VJLJD中的说明。

对于 Windows 用户,请遵循packt.live/2EKGDG6中的说明。

Linux 用户,您应该能够使用内置的包管理器安装 Docker。常见发行版的说明在packt.live/2Mn8Cjc

书完成后,如果您愿意,可以安全地卸载 Docker。

安装 PostgreSQL

在涵盖数据库交互的章节中使用了 PostgreSQL。要安装 PostgreSQL 驱动程序,请遵循www.postgresql.org/download/中的说明。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 在packt.link/sni2F下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“在前一章中,我们学习了如何在 Go 中使用 ifif-elseelse-ifswitchcasecontinuebreakgoto。”

代码块设置如下:

package main
import "fmt"
func main() {
  fmt.Println(10 > 5)
  fmt.Println(10 == 5)
}

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

go doc -all

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

error, unexpected nil value

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“完成操作后,从顶部菜单栏中选择查看,然后选择资源管理器。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们的读者反馈总是受欢迎的。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件 customercare@packtpub.com 联系我们,并在邮件主题中提及书名。

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

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

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《Go 编程 - 从入门到专业》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但又无法携带您的印刷书籍到处走?

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

别担心,现在,每本 Packt 书籍都免费提供该书的 DRM 免费 PDF 版本,无需额外费用。

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

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的邮箱访问权限。

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

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

packt.link/free-ebook/9781803243054

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分:脚本

编写简单的单文件软件应用程序通常是大多数软件开发之旅的起点。在本节中,您将深入脚本的世界,让您轻松地创建酷炫且实用的工具和助手。

本部分包含以下章节:

  • 第一章**,变量和运算符

  • 第二章**,命令与控制

  • 第三章**,核心类型

  • 第四章**,复杂类型

第一章:变量和运算符

概述

在本章中,你将了解 Go 的各种特性,并基本了解 Go 代码的样子。你还将深入了解变量是如何工作的,并通过练习和活动来获得实践经验,从而开始学习。

到本章结束时,你将能够使用 Go 中的变量、包和函数。你还将了解如何在 Go 中更改变量的值。在本章的后面部分,你将使用数字与运算符一起使用,并使用指针设计函数。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter01

Go 语言简介

Go(或通常称为 Golang)是一种编程语言,它因使用它来开发软件的回报而受到开发者的喜爱。它也受到公司的青睐,因为所有规模的团队能够使用它来提高生产力。Go 还因其持续提供性能极高的软件而赢得了声誉。

Go 有着令人印象深刻的血统,因为它是由一支来自 Google 的团队创造的,这支团队有着悠久的构建优秀的编程语言和操作系统的历史。他们创造了一种感觉上类似于 JavaScript 或 PHP 这样的动态语言,但具有 C++和 Java 这样的强类型语言的性能和效率。他们希望有一种既吸引程序员又适合有数百名开发者的项目的语言。

Go 语言包含了许多有趣且独特的特性,例如符合内存安全性和基于通道的并发。在本章中,我们将探讨这些特性。通过这样做,你会发现它们在 Go 中的独特实现正是使 Go 真正特殊的原因。

Go 语言是用文本文件编写的,然后编译成机器码并打包成一个单一的独立可执行文件。该可执行文件是自包含的,无需先安装任何东西即可运行。拥有单个文件使得部署和分发 Go 软件变得轻松。在编译时,你可以选择多个目标操作系统之一,包括但不限于 Windows、Linux、macOS 和 Android。使用 Go,你只需编写一次代码,就可以在任何地方运行。编译型语言不再受欢迎,因为程序员讨厌等待代码编译的漫长过程。Go 团队深知这一点,并构建了一个闪电般的编译器,即使项目规模扩大,编译速度依然很快。

Go 具有静态类型和类型安全的内存模型,并带有垃圾回收器来自动化内存管理。这种组合保护开发者免受在软件中常见的许多错误和安全漏洞的影响,同时仍然提供出色的性能和效率。动态类型语言,如 Ruby 和 Python,之所以受欢迎,部分原因是程序员认为,如果他们不必担心类型和内存,他们可以更高效地工作。这些语言的缺点是,它们牺牲了性能和内存效率,并且更容易出现类型不匹配的错误。Go 在动态类型语言具有相同的生产力水平的同时,并没有放弃性能和效率。

计算机性能发生了巨大的转变。现在要快速运行,您需要能够尽可能并行或并发地完成更多工作。这种变化是由于现代 CPU 的设计,它强调更多的核心而不是高时钟速度。目前所有流行的编程语言都没有被设计用来利用这一事实,这使得在这些语言中编写并行和并发代码容易出错。Go 被设计用来利用多个 CPU 核心,并消除了所有挫折和充满错误的代码。Go 被设计成允许任何开发者轻松且安全地编写并行和并发代码,使他们能够利用现代的多核 CPU 和云计算 – 无需戏剧性地解锁高性能处理和巨大的可扩展性。

Go 看起来是什么样子?

让我们先看看一些 Go 代码。以下代码会从预定义的消息列表中随机打印一条消息到控制台:

package main
// Import extra functionality from packages
import (
  "errors"
  "fmt"
  "log"
  "math/rand"
  "strconv"
  "time"
)// Taken from: https://en.wiktionary.org/wiki/Hello_World#Translations
var helloList = []string{
  "Hello, world",
  "Καλημέρα κόσμε",
  "こんにちは世界",
  "سلام دنیا‎",
  "Привет, мир",
}

main() 函数定义如下:

func main() {
  // Seed random number generator using the current time
  rand.NewSource(time.Now().UnixNano())
  // Generate a random number in the range of out list
  index := rand.Intn(len(helloList))
  // Call a function and receive multiple return values
  msg, err := hello(index)
  // Handle any errors
  if err != nil {
    log.Fatal(err)
  }
  // Print our message to the console
  fmt.Println(msg)
}

让我们考虑 hello() 函数:

func hello(index int) (string, error) {
  if index < 0 || index > len(helloList)-1 {
    // Create an error, convert the int type to a string
    return "", errors.New("out of range: " + strconv.Itoa(index))
  }
  return helloList[index], nil
}

现在,让我们逐行分析这段代码。

在我们的脚本顶部是以下内容:

package main

这段代码是我们的包声明。所有 Go 文件都必须以其中之一开始。如果您想直接运行代码,您需要将其命名为 main。如果您不命名为 main,那么您可以用它作为库,并将其导入其他 Go 代码中。当创建可导入的包时,您可以给它任何名称。同一目录下的所有 Go 文件都被视为同一包的一部分,这意味着所有文件都必须有相同的包名。

在以下代码中,我们正在从包中导入代码:

// Import extra functionality from packages
import (
  "errors"
  "fmt"
  "log"
  "math/rand"
  "strconv"
  "time"
)

在这个例子中,所有包都来自 Go 的标准库。Go 的标准库质量很高且功能全面。强烈建议您最大限度地利用它。如果您发现一个包看起来像 URL – 例如,github.com/fatih/color,那么这个包就不是来自标准库的。

Go 有一个模块系统,使得使用外部包变得简单。要使用一个新的模块,只需将其添加到您的导入路径。下次您构建代码时,Go 会自动为您下载它。

导入只适用于它们声明的文件,这意味着您必须在同一个包和项目中反复声明相同的导入。但不用担心——您不需要手动这样做。有许多工具和 Go 编辑器可以自动为您添加和删除导入:

// Taken from: https://en.wiktionary.org/wiki/Hello_World#Translations
var helloList = []string{
  "Hello, world",
  "Καλημέρα κόσμε",
  "こんにちは世界",
  "سلام دنیا‎",
  "Привет, мир",
}

在这里,我们声明了一个全局变量,它是一个字符串列表,并用数据初始化它。Go 中的文本或字符串支持多字节 UTF-8 编码,这使得它们对任何语言都是安全的。我们在这里使用的列表类型被称为切片。Go 中有三种列表类型:切片、数组和映射。这三种都是键值对的集合,您使用键从集合中获取值。切片和数组集合使用数字作为键。在切片和数组中,第一个键始终是 0。此外,在切片和数组中,数字是连续的,这意味着数字序列中永远不会出现中断。使用 map 类型时,您可以选择 key 类型。当您想使用其他数据在映射中查找值时,您会使用它。例如,您可以使用一本书的 ISBN 来查找其标题和作者:

func main() {
…
}

在这里,我们声明了一个函数。函数是当被调用时运行的代码。您可以将一个或多个变量作为数据传递给函数,并可选择从它那里接收一个或多个变量。Go 中的 main() 函数是特殊的。main() 函数是 Go 代码的入口点。在 main 包中只能有一个 main() 函数。当您的代码运行时,Go 会自动调用 main 来开始执行:

  // Seed random number generator using the current time
  rand.Seed(time.Now().UnixNano())
  // Generate a random number in the range of out list
  index := rand.Intn(len(helloList))

在前面的代码中,我们正在生成一个随机数。我们首先需要确保它是一个好的随机数;为了做到这一点,我们必须对随机数生成器进行 初始化。我们使用格式化为 Unix 时间戳(包含纳秒)的当前时间来初始化它。为了获取时间,我们调用 time 包中的 Now 函数。Now 函数返回一个结构体类型的变量。结构体是属性和函数的集合,类似于其他语言中的对象。在这种情况下,我们立即在该结构体上调用 UnixNano 函数。UnixNano 函数返回一个 int64 类型的变量,这是一个 64 位整数,或者更简单地说,是一个数字。这个数字被传递到 rand.Seedrand.Seed 函数接受一个 int64 类型的变量作为其输入。请注意,来自 time.UnixNanorand.Seed 的变量的类型必须相同。有了这些,我们就成功地初始化了随机数生成器。

我们想要的是一个可以用来获取随机消息的数字。我们将使用 rand.Intn 来完成这项工作。这个函数给我们一个介于 0 和 1 之间的随机数,减去我们传入的数字。这听起来可能有点奇怪,但它对我们想要做的事情来说非常完美。这是因为我们的列表是一个从 0 开始,每个值递增 1 的切片。这意味着最后一个索引比切片长度少 1。

为了向您展示这意味着什么,这里有一些简单的代码:

package main
import (
  "fmt"
)
func main() {
  helloList := []string{
    "Hello, world",
    "Καλημέρα κόσμε",
    "こんにちは世界",
    "سلام دنیا‎",
    "Привет, мир",
  }
  fmt.Println(len(helloList))
  fmt.Println(helloList[len(helloList)-1])
  fmt.Println(helloList[len(helloList)])
}

这段代码打印列表的长度,然后使用这个长度来打印最后一个元素。为了做到这一点,我们必须减去 1;否则,我们会得到一个错误,这就是最后一行引起的问题:

图 1.1:显示错误的输出

图 1.1:显示错误的输出

一旦我们生成了随机数,我们就将其分配给一个变量。我们使用与 := 符号一起看到的简短变量声明来做这件事,这在 Go 语言中是一个非常流行的快捷方式,在函数内部使用。它告诉编译器继续将那个值分配给变量,并隐式地选择适合那个值的类型。这个快捷方式是使 Go 语言感觉像动态类型语言的多件事之一:

  // Call a function and receive multiple return values
  msg, err := hello(index)

然后,我们使用那个变量来调用一个名为 hello 的函数。我们稍后会看看 hello。重要的是要注意,我们从函数中接收两个值,并且能够使用 := 符号将它们分配给两个新变量,msgerr,其中 err 作为第二个值:

func hello(index int) (string, error) {
…
}

这段代码是 hello 函数的定义;我们现在不展示其主体。函数作为一个逻辑单元,在需要时被调用。当调用函数时,调用它的代码会停止运行,等待函数运行完成。函数是保持代码组织和可理解性的优秀工具。在 hello 的签名中,我们定义了它接受一个 int 类型的值,并返回一个 string 类型的值和一个 error 类型的值。在 Go 语言中,将 error 作为最后一个返回值是非常常见的事情。{} 之间的代码是函数的主体。以下代码是函数被调用时运行的代码:

  if index < 0 || index > len(helloList)-1 {
    // Create an error, convert the int type to a string
    return "", errors.New("out of range: " + strconv.Itoa(index))
  }
  return helloList[index], nil

在这里,我们处于函数内部;主体中的第一行是一个 if 语句。一个 if 语句在其布尔表达式为真时运行其 {} 内的代码。布尔表达式是 if{ 之间的逻辑。在这种情况下,我们正在测试传递的 index 变量是否小于 0 或大于可能的最大切片索引键。

如果布尔表达式为真,那么我们的代码将返回一个空的 string 和一个 error 值。此时,函数将停止运行,调用函数的代码将继续运行。如果布尔表达式不为真,其代码将被跳过,我们的函数将从 helloList 返回一个值,并返回 nil。在 Go 语言中,nil 代表没有值和没有类型的东西:

  // Handle any errors
  if err != nil {
    log.Fatal(err)
  }

在我们运行hello之后,我们需要做的第一件事是检查它是否成功运行。我们可以通过检查存储在err中的error值来完成。如果err不等于nil,那么我们知道我们有一个错误。您将看到对err是否不等于nil的检查,而不是对err是否等于nil的检查,因为这简化了代码库的检查和逻辑。在出现错误的情况下,我们调用log.Fatal,它会写入日志消息并终止我们的应用程序。一旦应用程序被终止,就没有更多的代码运行:

  // Print our message to the console
  fmt.Println(msg)

如果没有错误,那么我们知道hello已成功运行,并且msg的值可以信赖以包含有效值。我们需要做的最后一件事是通过终端将消息打印到屏幕上。

这就是它的样子:

图 1.2:显示有效值的输出

图 1.2:显示有效值的输出

在这个简单的 Go 程序中,我们已经能够涵盖许多关键概念,我们将在接下来的章节中详细探讨。

练习 1.01 – 使用变量、包和函数打印星号

在这个练习中,我们将使用前面示例中学习的一些内容来打印一个介于 1 和 5 之间的随机星号(*)到控制台。这个练习将让您了解使用 Go 的感觉,并练习我们将需要继续使用的 Go 功能。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个main.go文件。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 现在,添加我们将在这个文件中使用的导入:

    import (
      "fmt"
      "math/rand"
      "strings"
      "time"
    )
    
  4. 创建一个main()函数:

    func main() {
    
  5. 初始化随机数生成器:

      rand.Seed(time.Now().UnixNano())
    
  6. 生成一个介于 0 和 1 之间的随机数,然后加 1 以得到一个介于 1 和 5 之间的数字:

      r := rand.Intn(5) + 1
    
  7. 使用字符串重复器创建所需数量的星号字符串:

      stars := strings.Repeat("*", r)
    
  8. 在控制台打印带有星号的字符串,并在末尾添加换行符,然后关闭main()函数:

      fmt.Println(stars)
    }
    
  9. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下为输出结果:

图 1.3:显示星号的输出

图 1.3:显示星号的输出

在这个练习中,我们通过定义包含main()函数的main包来创建了一个可运行的 Go 程序。我们通过添加包导入来使用标准库。这些包帮助我们生成随机数、重复字符串以及写入控制台。

活动练习 1.01 – 定义和打印

在这个活动中,我们将为医生的办公室创建一个医疗表格,以记录病人的姓名、年龄以及他们是否有花生过敏:

  1. 为以下内容创建变量:

    1. 首先以字符串形式输入名字。

    2. 以字符串形式输入姓氏。

    3. 年龄作为一个int类型的值。

    4. 花生过敏作为一个bool类型的值。

  2. 确保它们有一个初始值。

  3. 将值打印到控制台。

以下为预期输出:

图 1.4:分配变量后的预期输出

图 1.4:分配变量后的预期输出

注意

本章所有活动的解决方案都可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter01

接下来,我们将详细讲解到目前为止我们已经覆盖的内容,所以如果你对之前看到的内容感到困惑或有任何疑问,请不要担心。

声明变量

现在,你已经对 Go 有了一定的了解并完成了你的第一个练习,我们将深入探讨。我们旅程的第一个目的地是变量。

变量暂时存储数据,以便你可以使用它。当你声明一个变量时,它需要四个要素:一个声明变量的语句、变量的名称、它可以存储的数据类型以及它的初始值。幸运的是,其中一些部分是可选的,但这也意味着定义变量的方式不止一种。

让我们来看看你可以声明变量的所有方法。

使用 var 声明变量

使用 var 是声明变量的基础方法。我们将要讲解的其他所有方法都是这种方法的变体,通常是通过省略定义的一部分。一个完整的 var 定义,包含所有要素,看起来像这样:

var foo string = "bar"

关键部分是 varfoostring= "bar"

  • var 是我们定义变量的声明

  • foo 是变量的名称

  • string 是变量的类型

  • = "bar" 是其初始值

练习 1.02 – 使用 var 声明变量

在这个练习中,我们将使用完整的 var 语法声明两个变量。然后,我们将它们打印到控制台。你会发现你可以在代码的任何地方使用 var 语法,这不是所有变量声明语法都有的。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 中,将主包名添加到文件顶部:

    package main
    
  3. 添加导入:

    import (
      "fmt"
    )
    
  4. 在包级作用域中声明一个变量。我们将在稍后详细解释作用域的概念:

    var foo string = "bar"
    
  5. 创建 main() 函数:

    func main() {
    
  6. 在我们的函数中使用 var 声明另一个变量:

      var baz string = "qux"
    
  7. 将两个变量都打印到控制台:

      fmt.Println(foo, baz)
    
  8. 关闭 main() 函数:

    }
    
  9. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下是将显示的输出:

bar qux

在这个例子中,foo 在包级别被声明,而 baz 在函数级别被声明。变量声明的位置很重要,因为声明的位置也限制了你可以用来声明变量的语法。

接下来,我们将看看使用 var 语法的另一种方法。

使用 var 一次性声明多个变量

我们可以使用单个 var 声明来定义多个变量,使用 var 块或语句。当声明包级变量时,这种方法很常见。变量不需要是同一类型,它们都可以有自己的初始值。这种语法的样子是这样的:

var (
  <name1> <type1> = <value1>
  <name2> <type2> = <value2>
…
  <nameN> <typeN> = <valueN>
)

你可以有多个这种类型的声明。这是一种将相关变量分组的好方法,从而使你的代码更易于阅读。你可以在函数中使用这种表示法,但很少在那里看到它。

练习 1.03 – 使用 var 同时声明多个变量

在这个练习中,我们将使用单个 var 语句声明多个变量,每个变量具有不同的类型和初始值。然后,我们将打印每个变量的值到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件中,将 main 包名添加到文件顶部:

    package main
    
  3. 添加导入:

    import (
      "fmt"
      "time"
    )
    
  4. 开始 var 声明:

    var (
    
  5. 定义三个变量:

      Debug   bool   = false
      LogLevel  string  = "info"
      startUpTime time.Time = time.Now()
    
  6. 关闭 var 声明:

    )
    
  7. main() 函数中,将每个变量打印到控制台:

    func main() {
      fmt.Println(Debug, LogLevel, startUpTime)
    }
    
  8. 保存文件。然后,在新的文件夹中,运行以下命令:

    go run .
    

    以下为输出:

图 1.5:显示三个变量值的输出

图 1.5:显示三个变量值的输出

在这个练习中,我们使用单个 var 语句声明了三个变量。对于 time.Time 变量,你的输出可能会有所不同,但这是正确的。格式是相同的,但时间本身是不同的。

使用这种 var 表示法是一种保持代码井然有序并节省一些输入的好方法。

接下来,我们将开始移除 var 表示法的一些可选部分。

在声明变量时跳过类型或值

在现实世界的代码中,不常见到使用完整的 var 表示法。有一些情况需要定义具有初始值并严格控制其类型的包级变量。在这些情况下,你需要完整的表示法。当你需要时,这将是明显的,因为你会有某种类型不匹配,所以现在不必过于担心这个问题。其余时间,你可以删除一个可选部分或使用简短的变量声明。

在声明变量时,你不需要同时包含类型和初始值。你可以只使用其中一个;Go 会处理其余部分。如果你在声明中有一个类型但没有初始值,Go 会使用你选择的类型的零值。我们将在本书的后面讨论零值是什么。另一方面,如果你有一个初始值但没有类型,Go 有一个规则集,用于从你使用的字面值中推断所需的类型。

练习 1.04 – 在声明变量时跳过类型或值

在这个练习中,我们将更新我们之前的练习,以便跳过变量声明中的可选初始值或类型声明。然后,我们将像之前一样打印值到控制台,以显示结果相同。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件中,将 main 包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 开始多变量声明:

    var (
    
  5. 第一项练习中的bool值初始值为false。这是bool值的零值,因此我们将从其声明中省略初始值,因为它默认已设置:

      Debug   bool
    
  6. 下两个变量都有非零的类型值,因此我们将省略它们的类型声明:

      LogLevel  = "info"
      startUpTime = time.Now()
    
  7. 关闭var声明:

    )
    
  8. main()函数中,打印出每个变量:

    func main() {
      fmt.Println(Debug, LogLevel, startUpTime)
    }
    
  9. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

    以下为输出:

图 1.6:输出显示变量值,尽管在声明变量时没有提及类型

图 1.6:输出显示变量值,尽管在声明变量时没有提及类型

在这个练习中,我们能够更新之前的代码,使其使用更紧凑的变量声明。声明变量是你必须经常做的事情,不需要使用这种表示法可以让编写代码的体验更好。

接下来,我们将探讨一个你不能跳过任何部分的场景。

类型推断出错

有时候你需要使用声明中的所有部分——例如,当 Go 无法猜出你需要的正确类型时。让我们看看这个例子:

package main
import "math/rand"
func main() {
  var seed = 1234456789
  rand.NewSource(seed)
}

以下为输出:

图 1.7:显示错误的输出

图 1.7:显示错误的输出

这里的问题是rand.NewSource需要一个int64类型的变量。Go 的类型推断规则与整数(如我们用作int值的整数)交互操作。我们将在本书的后面更详细地探讨它们之间的区别。为了解决这个问题,我们将向声明中添加int64类型。以下是它的样子:

package main
import "math/rand"
func main() {
  var seed int64 = 1234456789
  rand.NewSource(seed)
}

接下来,我们将探讨一种更快声明变量的方法。

简短变量声明

当在函数中声明变量时,我们可以使用:=简写。这种简写允许我们使我们的声明更短。它是通过允许我们不必使用var关键字,并且总是从所需的初始值推断类型来实现的。

练习 1.05 – 实现简短变量声明

在这个练习中,我们将更新之前的练习,使其使用简短的变量声明。由于你只能在函数中使用简短变量声明,我们将把变量移出包作用域。之前Debug有类型但没有初始值,我们将将其切换回有初始值,因为使用简短变量声明时需要初始值。最后,我们将将其打印到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个main.go文件。

  2. main.go文件顶部添加main包名:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 创建main()函数:

    func main() {
    
  5. 使用简短变量声明符号声明每个变量:

      Debug := false
      LogLevel := "info"
      startUpTime := time.Now()
    
  6. 将变量打印到控制台:

      fmt.Println(Debug, LogLevel, startUpTime)
    }
    
  7. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下为输出:

图 1.8:显示使用简短变量声明符号后打印的变量值输出

图 1.8:显示使用简短变量声明符号后打印的变量值输出

在这个练习中,我们更新了之前的代码,以便在有一个初始值可以使用时以非常紧凑的方式声明变量。

:=简写法在 Go 开发者中非常受欢迎,并且在现实世界的 Go 代码中定义变量的最常见方式。开发者喜欢它使他们的代码简洁紧凑,同时仍然清楚地表明了正在发生什么。

另一个快捷方式是在同一行上声明多个变量。

使用简短的变量声明声明多个变量

可以使用简短的变量声明同时声明多个变量。它们必须在同一行上,并且每个变量都必须有一个相应的初始值。表示法看起来像<var1>, <var2>, …, <varN> := <val1>, <val2>, …, <valN>。变量名位于:=的左侧,由逗号分隔。初始值再次位于:=的右侧,每个值由逗号分隔。最左侧的变量名获得最左侧的值。必须具有相同数量的名称和值。

以下是一个使用我们之前练习代码的示例:

package main
import (
  "fmt"
  "time"
)
func main() {
  Debug, LogLevel, startUpTime := false, "info", time.Now()
  fmt.Println(Debug, LogLevel, startUpTime)
}

以下是将输出:

图 1.9:显示具有变量声明函数的程序变量值的示例输出

图 1.9:显示具有变量声明函数的程序变量值的示例输出

有时,你确实会看到像这样的现实世界代码。它有点难读,所以在字面值方面并不常见。但这并不意味着这不常见——在调用返回多个值的函数时,这种情况非常常见。我们将在本书稍后讨论函数时详细说明这一点。

练习 1.06 – 从函数中声明多个变量

在这个练习中,我们将调用一个返回多个值的函数,并将每个值分配给一个新的变量。然后,我们将打印这些值到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个main.go文件。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 创建一个返回三个值的函数:

    func getConfig() (bool, string, time.Time) {
    
  5. 在函数中,返回三个字面值,每个值由逗号分隔:

      return false, "info", time.Now()
    
  6. 关闭函数:

    }
    
  7. 创建main()函数:

    func main() {
    
  8. 使用简短的变量声明,捕获函数返回的三个新变量的值:

      Debug, LogLevel, startUpTime := getConfig()
    
  9. 将三个变量打印到控制台:

      fmt.Println(Debug, LogLevel, startUpTime)
    
  10. 关闭main()函数:

    }
    
  11. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

    以下是将输出:

图 1.10:显示具有变量声明函数的程序变量值的输出

图 1.10:显示程序中变量声明函数的变量值输出

在这个练习中,我们能够调用一个返回多个值的函数,并使用一行简短变量声明来捕获它们。如果我们使用var表示法,它将看起来像这样:

var (
  Debug bool
  LogLevel string
  startUpTime time.Time
)
Debug, LogLevel, startUpTime = getConfig()

简短的变量表示法是 Go 语言具有动态语言感觉的一个重要部分。

尽管如此,我们还没有完全结束var的使用——它仍然有一些有用的技巧。

使用var在单行上声明多个变量

虽然使用简短变量声明更为常见,但你也可以使用var在单行上定义多个变量。这种方法的局限性在于,在声明类型时,所有值必须具有相同的类型。如果你使用初始值,那么每个值都会从字面值推断其类型,这样它们就可以不同。以下是一个例子:

package main
import (
  "fmt"
  "time"
)
func getConfig() (bool, string, time.Time) {
  return false, "info", time.Now()
}
func main() {
  // Type only
  var start, middle, end float32
  fmt.Println(start, middle, end)
  // Initial value mixed type
  var name, left, right, top, bottom = "one", 1, 1.5, 2, 2.5
  fmt.Println(name, left, right, top, bottom)
  // works with functions also
  var Debug, LogLevel, startUpTime = getConfig()
  fmt.Println(Debug, LogLevel, startUpTime)
}

以下是输出:

图 1.11:显示变量值的输出

图 1.11:显示变量值的输出

在使用简短变量声明时,这些通常更紧凑。这个事实意味着它们在现实世界的代码中很少出现。例外是相同类型仅有的例子。这种表示法在需要许多相同类型的变量,并且需要仔细控制该类型时非常有用。

非英语变量名

Go 是一种 UTF-8 兼容的语言,这意味着你可以使用除英语使用的拉丁字母以外的字母来定义变量名。关于变量名可以是什么有一些限制。名称的第一个字符必须是字母或_。其余可以是字母、数字和_的混合。让我们看看它是什么样子:

package main
import (
  "fmt"
  "time"
)
func main() {
  デバッグ := false
  日志级别 := "info"
  ይጀምሩ := time.Now()
  _A1_Μείγμα := "" 
"
  fmt.Println(デバッグ, 日志级别, ይጀምሩ, _A1_Μείγμα)
}

以下是输出:

图 1.12:显示变量值的输出

图 1.12:显示变量值的输出

注意

语言与语言:并非所有编程语言都允许你使用 UTF-8 字符作为变量和函数名。这个特性可能是 Go 在亚洲国家,尤其是中国如此受欢迎的原因之一。

改变变量的值

现在我们已经定义了我们的变量,让我们看看我们可以用它们做什么。首先,让我们改变变量的初始值。要做到这一点,我们将使用与设置初始值时相似的表示法。这看起来像 <变量> = <值>

练习 1.07 – 改变变量的值

按照以下步骤操作:

  1. 创建一个新的文件夹并将main.go文件添加到其中。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 导入我们将需要的包:

    import "fmt"
    
  4. 创建main()函数:

    func main() {
    
  5. 声明一个变量:

      offset := 5
    
  6. 将变量打印到控制台:

      fmt.Println(offset)
    
  7. 改变变量的值:

      offset = 10
    
  8. 再次将其打印到控制台并关闭main()函数:

      fmt.Println(offset)
    }
    
  9. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下是在更改变量值之前的输出:

5
10

在这个例子中,我们将偏移量的值从初始值5更改为10。任何你使用原始值的地方,例如我们例子中的510,你都可以使用变量。下面是如何做到这一点:

package main
import "fmt"
var defaultOffset = 10
func main() {
  offset := defaultOffset
  fmt.Println(offset)
  offset = offset + defaultOffset
  fmt.Println(offset)
}

以下是更改变量值后的输出:

10
20

接下来,我们将看看我们如何在单行语句中更改多个变量。

一次性更改多个值

与你可以在一行中声明多个变量一样,你也可以一次性更改多个变量的值。语法也类似;看起来像<var1>, <var2>, …, <varN> = <val1>, <val2>, …, <valN>

练习 1.08 - 一次性更改多个值

在这个练习中,我们将定义一些变量,并使用单行语句更改它们的值。然后,我们将打印它们的新值到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个main.go文件。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import "fmt"
    
  4. 创建main()函数:

    func main() {
    
  5. 使用初始值声明我们的变量:

      query, limit, offset := "bat", 10, 0
    
  6. 使用单行语句更改每个变量的值:

      query, limit, offset = "ball", offset, 20
    
  7. 将值打印到控制台并关闭main()函数:

      fmt.Println(query, limit, offset)
    }
    
  8. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下是通过单行语句显示更改变量值的输出:

ball 0 20

在这个练习中,我们能够在单行中更改多个变量。这种方法在调用函数时同样适用,就像在变量声明中一样。你需要小心使用这种特性,确保首先你的代码易于阅读和理解。如果使用这种单行语句使得难以了解代码的功能,那么最好使用更多行来编写代码。

接下来,我们将看看运算符是什么以及它们如何以有趣的方式更改你的变量。

运算符

当变量存储你的应用程序的数据时,当你开始使用它们来构建软件的逻辑时,它们才变得真正有用。运算符是你用来处理软件数据的工具。使用运算符,你可以比较数据与其他数据——例如,你可以在交易应用程序中检查价格是否过低或过高。你还可以使用运算符来操作数据。例如,你可以使用运算符将购物车中所有商品的成本相加以获取总价。

以下列表提到了运算符的组:

  • 算术运算符:这些用于与数学相关的任务,如加法、减法和乘法。

  • 比较运算符:这些用于比较两个值;例如,它们是否相等,不相等,小于还是大于对方。

  • bool值是 false。

  • 地址运算符:我们将在查看指针时详细介绍这些。这些用于处理它们。

  • 接收运算符:当与 Go 通道一起工作时使用。我们将在本书的后面部分介绍这一点。

练习 1.09 – 使用数字运算符

在这个练习中,我们将模拟餐厅账单。为了构建我们的模拟,我们需要使用数学和比较运算符。我们将首先探索运算符的所有主要用途。

在我们的模拟中,我们将把所有东西加起来,并根据百分比计算小费。然后,我们将使用比较运算符来查看顾客是否获得奖励。让我们开始吧:

注意

我们在这个练习中将美元视为货币。你可以考虑任何你选择的货币;这里的主要重点是操作。

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件中,将 main 包名添加到文件顶部:

    package main
    
  3. 导入你需要的包:

    import "fmt"
    
  4. 创建 main() 函数:

    func main() {
    
  5. 创建一个变量来保存总额。对于账单上的这个项目,顾客购买了两个价值 13 美元的商品。我们必须使用 * 进行乘法。然后,我们必须打印小计:

      // Main course
      var total float64 = 2 * 13
      fmt.Println("Sub :", total)
    
  6. 在这里,他们购买了四件价值 2.25 美元的商品。我们必须使用乘法来计算这些商品的总价,然后使用 + 将其加到之前的总价值上,并将这个值赋回总价值:

      // Drinks
      total = total + (4 * 2.25)
      fmt.Println("Sub :", total)
    
  7. 这位顾客将获得 5 美元的折扣。在这里,我们使用 从总额中减去 5 美元:

      // Discount
      total = total - 5
      fmt.Println("Sub :", total)
    
  8. 然后,我们使用乘法来计算 10%的小费:

      // 10% Tip
      tip := total * 0.1
      fmt.Println("Tip :", tip)
    
  9. 最后,我们将小费加到总额上:

      total = total + tip
      fmt.Println("Total:", total)
    
  10. 账单将分给两个人。使用 / 来将总额分成两部分:

      // Split bill
      split := total / 2
      fmt.Println("Split:", split)
    
  11. 在这里,我们将计算顾客是否获得奖励。首先,我们将设置 visitCount 并给这次访问加 1 美元:

      // Reward every 5th visit
      visitCount := 24
      visitCount = visitCount + 1
    
  12. 然后,我们将使用 % 来获取 visitCount 除以 5 美元后的任何余数:

      remainder := visitCount % 5
    
  13. 顾客在每次第五次访问时获得奖励。如果余数是 0,那么这次访问就是其中之一。使用 == 运算符检查余数是否为 0:

      if remainder == 0 {
    
  14. 如果是,打印一条消息说明他们获得奖励:

        fmt.Println("With this visit, you've earned a reward.")
      }
    }
    
  15. 保存文件。然后在新的文件夹中,运行以下命令:

    go run .
    

以下是输出结果:

图 1.13:使用数字运算符的输出

图 1.13:使用数字运算符的输出

在这个练习中,我们使用了数学和比较运算符与数字。它们使我们能够模拟复杂的情况——计算餐厅账单。有许多运算符,你可以使用的运算符类型因不同类型的值而异。例如,除了有数字的加法运算符外,你还可以使用 + 符号将字符串连接起来。下面是它的实际应用:

package main
import "fmt"
func main() {
  givenName := "John"
  familyName := "Smith"
  fullName := givenName + " " + familyName
  fmt.Println("Hello,", fullName)
}

以下是输出结果:

Hello, John Smith

对于某些情况,我们可以通过运算符来简化一些操作。我们将在下一节中介绍这一点。

位运算符

Go 语言拥有你在编程语言中常见的所有位运算符。如果你知道什么是位运算符,那么这里对你来说不会有惊喜。如果你不知道什么是位运算符,不要担心——它们在现实世界的代码中并不常见。

简写运算符

当你想对现有值及其自身值执行操作时,有几个简写赋值运算符:

  • --: 将数字减 1

  • ++: 将数字加 1

  • +=: 加上并赋值

  • -=: 减去并赋值

练习 1.10 – 实现简写运算符

在这个练习中,我们将使用一些运算符简写的例子来展示它们如何使你的代码更加紧凑且易于编写。我们将创建一些变量,然后使用简写来改变它们,在过程中打印它们。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件中,将 main 包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import "fmt"
    
  4. 创建 main() 函数:

    func main() {
    
  5. 创建一个具有初始值的变量:

      count := 5
    
  6. 我们将向其添加内容,然后将结果赋值回它本身。然后,我们将打印它:

      count += 5
      fmt.Println(count)
    
  7. 将值增加 1 并打印出来:

      count++
      fmt.Println(count)
    
  8. 减少它的值并打印出来:

      count--
      fmt.Println(count)
    
  9. 减去并赋值结果回它本身。打印出新的值:

      count -= 5
      fmt.Println(count)
    
  10. 对于字符串,也存在一个简写运算符。定义一个字符串:

      name := "John"
    
  11. 接下来,我们将另一个字符串追加到其末尾,然后打印出来:

      name += " Smith"
      fmt.Println("Hello,", name)
    
  12. 关闭 main() 函数:

    }
    
  13. 保存文件。然后,在新文件夹中,运行以下命令:

    go run .
    

以下为输出结果:

图 1.14:使用简写运算符的输出

图 1.14:使用简写运算符的输出

在这个练习中,我们使用了一些简写运算符。一组专注于修改然后赋值。这种操作很常见,并且有这些快捷方式可以使编码更加有趣。其他运算符是递增和递减。这些在需要逐个遍历数据时在循环中很有用。这些快捷方式使任何阅读你的代码的人都能清楚地了解你在做什么。

接下来,我们将详细探讨如何比较两个值。

比较值

在应用程序中,逻辑是让你的代码做出决策的问题。这些决策是通过比较变量的值与您定义的规则来实现的。这些规则以比较的形式出现。我们使用另一组运算符来进行这些比较。这些比较的结果总是真或假。你通常还需要进行很多这样的比较才能做出一个决策。为了帮助做到这一点,我们有了逻辑运算符。

这些运算符大部分都作用于两个值,并且总是产生布尔值。你只能用逻辑运算符与布尔值一起使用。让我们更详细地看看比较运算符和逻辑运算符。

比较运算符:

  • ==: 如果两个值相同,则为真

  • !=: 如果两个值不相同,则为真

  • <: 如果左边的值小于右边的值,则为真

  • <=: 如果左边的值小于或等于右边的值,则为真

  • >: 如果左边的值大于右边的值,则为真

  • >=: 如果左边的值大于或等于右边的值,则为真

逻辑运算符:

  • &&: 如果左右两边的值都为真,则为真

  • ||:如果左边的值或右边的值之一为真,则为真

  • !:此运算符仅与单个值一起使用,如果值为假,则结果为真

练习 1.11 – 比较值

在这个练习中,我们将使用比较和逻辑运算符来查看测试不同条件时得到的布尔结果。我们正在测试根据用户访问次数来确定用户会员等级。

我们的会员等级如下:

  • 银级:10 到 20 次访问(包括 10 和 20)

  • 金级:21 到 30 次访问(包括 21 和 30)

  • 白金级:超过 30 次访问

让我们开始吧:

  1. 创建一个新的文件夹并向其中添加一个main.go文件。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import "fmt"
    
  4. 创建main()函数:

    func main() {
    
  5. 定义我们的visits变量并初始化它:

      visits := 15
    
  6. 使用等于运算符查看这是否是他们的第一次访问。然后,将结果打印到控制台:

      fmt.Println("First visit   :", visits == 1)
    
  7. 使用不等号运算符查看他们是否是回头客:

      fmt.Println("Return visit  :", visits != 1)
    
  8. 让我们使用以下代码检查他们是否是银级会员:

      fmt.Println("Silver member :", visits >= 10 && visits < 21)
    
  9. 让我们使用以下代码检查他们是否是金会员:

      fmt.Println("Gold member   :", visits > 20 && visits <= 30)
    
  10. 让我们使用以下代码检查他们是否是白金会员:

      fmt.Println("Platinum member :", visits > 30)
    
  11. 关闭main()函数:

    }
    
  12. 保存文件。然后,在新的文件夹中,运行以下命令:

    go run .
    

以下是输出结果:

图 1.15:显示比较结果的输出

图 1.15:显示比较结果的输出

在这个练习中,我们使用了比较和逻辑运算符来对数据进行决策。你可以以无限多种方式组合这些运算符,以表达你的软件需要的几乎任何类型的逻辑。

接下来,我们将查看当你不给变量一个初始值时会发生什么。

零值

变量的零值是该变量类型的空或默认值。Go 有一套规则说明零值适用于所有核心类型。让我们看看:

图 1.16:变量类型及其零值

图 1.16:变量类型及其零值

还有其他类型,但它们都是从这些核心类型派生出来的,所以相同的规则仍然适用。

在接下来的练习中,我们将查看一些类型的零值。

练习 1.12 – 零值

在这个例子中,我们将定义一些没有初始值的变量。然后,我们将打印出它们的值。我们在这个练习中使用fmt.Printf来帮助我们,因为我们可以得到更多关于值类型的详细信息。fmt.Printf使用模板语言,允许我们转换传递的值。我们使用的替换是%#v。这种转换是显示变量值和类型的有用工具。以下是一些你可以尝试的其他常见替换:

图 1.17:替换表

图 1.17:替换表

当使用 fmt.Printf 时,你需要自己添加换行符号。你可以通过在字符串末尾添加 \n 来做到这一点。让我们开始吧:

  1. 创建一个新的文件夹并将 main.go 文件添加到其中。

  2. main.go 文件顶部添加 main 包名称:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 创建 main() 函数:

    func main() {
    
  5. 声明并打印一个整数:

      var count int
      fmt.Printf("Count  : %#v \n", count)
    
  6. 声明并打印一个 float 值:

      var discount float64
      fmt.Printf("Discount : %#v \n", discount)
    
  7. 声明并打印一个 bool 值:

      var debug bool
      fmt.Printf("Debug  : %#v \n", debug)
    
  8. 声明并打印一个 string 值:

      var message string
      fmt.Printf("Message : %#v \n", message)
    
  9. 声明并打印一组字符串:

      var emails []string
      fmt.Printf("Emails : %#v \n", emails)
    
  10. 声明并打印一个结构体(由其他类型组成的类型;我们将在本书的后面部分介绍):

      var startTime time.Time
      fmt.Printf("Start  : %#v \n", startTime)
    
  11. 关闭 main() 函数:

    }
    
  12. 保存文件。然后,在新的文件夹中,运行以下命令:

    go run .
    

以下为输出结果:

图 1.18:显示初始变量值的输出

图 1.18:显示初始变量值的输出

在这个练习中,我们定义了各种变量类型而没有初始值。然后,我们使用 fmt.Printf 打印它们,以暴露更多关于值的细节。了解零值是什么以及 Go 如何控制它们,可以帮助你避免错误并编写简洁的代码。

接下来,我们将探讨指针是什么以及它们如何帮助你编写高效的软件。

值与指针

当使用 intboolstring 等值时,当你将它们传递给函数时,Go 会复制该值,并在函数中使用这个副本。这种复制意味着在函数中对值的更改不会影响你在调用函数时使用的值。

通过复制传递值往往会导致更少的错误。使用这种传递值的方法,Go 可以使用其简单的内存管理系统,称为栈。缺点是,随着值从函数传递到函数,复制会消耗越来越多的内存。在实际代码中,函数往往很小,值会被传递到很多函数中,所以按值复制有时会使用比所需更多的内存。

有一种比复制更节省内存的替代方法。不是传递一个值,我们创建一个称为指针的东西,然后将它传递给函数。指针本身不是一个值,你不能用指针做任何有用的事情,除了用它来获取值。你可以把指针看作是你想要的值的地址,要获取这个值,你必须到达这个地址。如果你使用指针,Go 不会在传递指针到函数时复制该值。

当创建一个指向值的指针时,Go 无法使用栈来管理该值的内存。这是因为栈依赖于简单的范围逻辑来知道何时可以回收由值使用的内存,而拥有一个变量的指针意味着这些规则不再适用。相反,Go 将值放在堆上。堆允许值存在,直到你的软件中没有部分再指向它为止。Go 通过其所谓的垃圾回收过程回收这些值。这个过程在后台定期发生,你不需要担心它。

指向一个值的指针意味着该值被放在堆上,但这不是唯一的原因。确定一个值是否需要放在堆上的过程称为逃逸分析。有时,没有指针的值也会被放在堆上,而且并不总是清楚为什么。

你无法直接控制值是放在栈上还是堆上。内存管理不是 Go 语言规范的一部分。内存管理被认为是内部实现细节。这意味着它可以在任何时候更改,而我们所说的只是一般性指南,而不是固定规则,可能会在以后更改。

当使用指针而不是传递给许多函数的值时,指针的优点在内存使用方面是明显的,但在 CPU 使用方面并不明显。当一个值被复制时,Go 需要 CPU 周期来获取该内存,然后稍后释放它。使用指针在传递给函数时避免了这种 CPU 使用。另一方面,堆上的值需要被复杂的垃圾回收过程管理。这个过程在某些情况下可能成为 CPU 瓶颈——例如,如果堆上有许多值。当这种情况发生时,垃圾收集器需要进行大量的检查,这会消耗 CPU 周期。这里没有正确答案,最佳方法是经典的性能优化方法。首先,不要过早优化。当你确实有性能问题时,在做出更改之前进行测量,然后在做出更改之后进行测量。

除了性能之外,你可以使用指针来改变你的代码设计。有时,使用指针可以使接口更清晰,并简化你的代码。例如,如果你需要知道一个值是否存在,非指针值总是至少有它的零值,这在你的逻辑中可能是有效的。你可以使用指针来允许未设置状态,同时持有值。这是因为指针,除了持有值的地址外,还可以是nil,这意味着没有值。在 Go 中,nil是一个特殊类型,表示没有值。

指针能够为 nil 的能力还意味着,当它没有与之关联的值时,你可以获取指针的值,这意味着你将得到一个运行时错误。为了防止运行时错误,在尝试获取其值之前,你可以将指针与 nil 进行比较。这看起来像 <pointer> != nil。你可以将指针与相同类型的其他指针进行比较,但只有在比较的是指针本身时,它们才会返回 true。不会比较关联的值。

指针是语言中的强大工具,这得益于它们的效率、能够通过引用(而不是值)传递以允许函数修改原始值,以及它们如何允许使用垃圾回收器进行动态内存分配。然而,任何伟大的工具都伴随着巨大的责任。如果误用,指针可能会很危险,例如,在内存被释放(解除分配)且指针成为“悬垂指针”的情况下,如果访问它可能会导致未定义的行为。还存在内存泄漏、由于直接内存访问而不安全操作以及如果存在共享指针可能会引入数据竞争的并发挑战。总的来说,Go 的指针通常比其他语言(如 C 语言)简单且错误更少。

获取指针

要获取指针,你有几种选择。你可以使用 var 语句声明一个变量为指针类型。你可以通过在大多数类型前添加 * 来这样做。这种表示法看起来像 var <name> *<type>。使用此方法创建的变量的初始值是 nil。你可以使用内置的 new 函数来做这件事。这个函数旨在用于获取某种类型的内存并返回对该地址的指针。表示法看起来像 <name> := new(<type>)new 函数也可以与 var 一起使用。你也可以使用 & 从现有变量获取指针,你可以将其读作 "地址为"。这看起来像 <var1> := &<var2>

练习 1.13 – 获取指针

在这个练习中,我们将使用我们能够使用的所有方法来获取指针变量。然后,我们将使用 fmt.Printf 将它们打印到控制台,以查看它们的类型和值。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 中,将 main 包名添加到文件顶部:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 创建 main() 函数:

    func main() {
    
  5. 使用 var 语句声明指针:

      var count1 *int
    
  6. 使用 new 创建变量:

      count2 := new(int)
    
  7. 你不能取一个字面量的地址。创建一个临时变量来保存一个数字:

      countTemp := 5
    
  8. 使用 & 从现有变量创建指针:

      count3 := &countTemp
    
  9. 可以从某些类型创建指针而不需要临时变量。这里,我们使用我们信任的 time 结构体:

      t := &time.Time{}
    
  10. 使用 fmt.Printf 打印每个变量:

      fmt.Printf("count1: %#v\n", count1)
      fmt.Printf("count2: %#v\n", count2)
      fmt.Printf("count3: %#v\n", count3)
      fmt.Printf("time : %#v\n", t)
    
  11. 关闭 main() 函数:

    }
    
  12. 保存文件。然后在新的文件夹中,运行以下命令:

    go run .
    

以下是输出:

图 1.19:显示指针的输出

图 1.19:显示指针的输出

在这个练习中,我们探讨了创建指针的三种不同方式。每一种都有用,取决于你的代码需要什么。使用 var 语句时,指针的值为 nil,而其他指针已经与它们关联的地址值有关。对于 time 变量,我们可以看到其值,但我们可以判断它是一个指针,因为其输出以 & 开头。

接下来,我们将看到如何从指针中获取值。

从指针获取值

在上一个练习中,当我们将 int 指针的指针变量打印到控制台时,我们要么得到 nil,要么看到内存地址。要获取指针关联的值,你必须使用变量名前的 * 解引用该值。这看起来像 fmt.Println(*<val>)

在 Go 软件中,解引用零或 nil 指针是一个常见的错误,因为编译器无法警告你,并且它发生在应用程序运行时。因此,在解引用之前始终检查指针不是 nil 是最佳实践,除非你确定它不是 nil

你并不总是需要解引用 - 例如,当属性或函数位于结构体上时。不必过于担心何时不应解引用,因为 Go 会明确告诉你何时可以和不可以解引用一个值。

练习 1.14 - 从指针获取值

在这个练习中,我们将更新我们之前的练习,以从指针解引用值。我们还将添加 nil 检查以防止我们得到任何错误。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件顶部添加 main 包名:

    package main
    
  3. 导入我们需要的包:

    import (
      "fmt"
      "time"
    )
    
  4. 创建 main() 函数:

    func main() {
    
  5. 我们声明的指针与之前相同:

      var count1 *int
      count2 := new(int)
      countTemp := 5
      count3 := &countTemp
      t := &time.Time{}
    
  6. 对于计数 1、2 和 3,我们需要添加一个 nil 检查,并在变量名前添加 *

      if count1 != nil {
        fmt.Printf("count1: %#v\n", *count1)
      }
      if count2 != nil {
        fmt.Printf("count2: %#v\n", *count2)
      }
      if count3 != nil {
        fmt.Printf("count3: %#v\n", *count3)
      }
    
  7. 我们还将为我们的 time 变量添加一个 nil 检查:

      if t != nil {
    
  8. 我们将使用 * 解引用变量,就像我们处理 count 变量一样:

        fmt.Printf("time : %#v\n", *t)
    
  9. 这里,我们正在调用 time 变量上的一个函数。这次,我们不需要解引用它:

        fmt.Printf("time : %#v\n", t.String())
    
  10. 关闭 nil 检查:

      }
    
  11. 关闭 main() 函数:

    }
    
  12. 保存文件。然后,在新的文件夹中,运行以下命令:

    go run .
    

以下为输出结果:

图 1.20:显示从指针获取值的输出

图 1.20:显示从指针获取值的输出

在这个练习中,我们使用了解引用来从我们的指针获取值。我们还使用了 nil 检查来防止解引用错误。从这个练习的输出中,我们可以看到 count1 是一个 nil 值,如果我们尝试解引用,我们会得到一个错误。count2 是使用 new 创建的,其值是其类型的零值。count3 也有一个与从指针获取的变量的值相匹配的值。对于我们的 time 变量,我们能够解引用整个结构体,这就是为什么我们的输出不以 & 开头的原因。

接下来,我们将探讨使用指针如何允许我们改变代码的设计:

使用指针进行函数设计

我们将在本书的后面部分更详细地介绍函数,但到目前为止你所了解的已经足够让你看到使用指针如何改变你对函数的使用方式。函数必须被编码为接受指针,这不是你可以选择是否要做的事情。如果你有一个指针变量或者已经将变量的指针传递给了一个函数,那么在函数中对变量值所做的任何更改也会影响函数外部的变量值。

练习 1.15 – 使用指针进行函数设计

在这个练习中,我们将创建两个函数:一个接受一个数字按值传递,将其加 5,然后将其打印到控制台;另一个接受一个数字作为指针,将其加 5,然后将其打印出来。我们还将调用每个函数后打印数字,以评估它对传递给函数的变量的影响。让我们开始吧:

  1. 在其中创建一个名为main.go的新文件:

  2. main.go文件顶部添加main包名:

    package main
    
  3. 导入我们需要的包:

    import "fmt"
    
  4. 创建一个接受int指针作为参数的函数:

    func add5Value(count int) {
    
  5. 给传递的数字加上5

      count += 5
    
  6. 将更新的数字打印到控制台:

      fmt.Println("add5Value   :", count)
    
  7. 关闭函数:

    }
    
  8. 创建另一个接受int指针的函数:

    func add5Point(count *int) {
    
  9. 取消值的引用并给它加上5

      *count += 5
    
  10. 打印count更新的值并取消其引用:

      fmt.Println("add5Point   :", *count)
    
  11. 关闭函数:

    }
    
  12. 创建main()函数:

    func main() {
    
  13. 声明一个int变量:

      var count int
    
  14. 使用变量调用第一个函数:

      add5Value(count)
    
  15. 打印变量的当前值:

      fmt.Println("add5Value post:", count)
    
  16. 调用第二个函数。这次,你需要使用&来传递变量的指针:

      add5Point(&count)
    
  17. 打印变量的当前值:

      fmt.Println("add5Point post:", count)
    
  18. 关闭main()函数:

    }
    
  19. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下为输出:

图 1.21:显示变量当前值的输出

图 1.21:显示变量当前值的输出

在这个练习中,我们向你展示了通过指针传递值如何影响传递给它们的变量值。我们看到了,在按值传递时,你在函数中对值所做的更改不会影响传递给函数的变量的值,而传递值的指针则改变了传递给函数的变量的值。

你可以利用这个事实来克服尴尬的设计问题,有时还可以简化代码的设计。传统上,通过指针传递值被认为更容易出错,所以请谨慎使用这种设计。在函数中使用指针以创建更高效的代码也是常见的,Go 的标准库在这方面做了很多。

活动 1.02 – 指针值交换

在这个活动中,你的任务是完成一位同事开始的一些代码。在这里,我们有一些未完成的代码需要你完成。你的任务是填写缺失的代码,其中注释指示交换ab的值。swap函数只接受指针,不返回任何内容:

package main
import "fmt"
func main() {
  a, b := 5, 10
  // call swap here
  fmt.Println(a == 10, b == 5)
}
func swap(a *int, b *int) {
  // swap the values here
}

按照以下步骤操作:

  1. 调用swap函数,确保你传递了一个指针。

  2. swap函数中,将值赋给另一个指针,确保你解引用了这些值。

下面的输出是预期的:

true true

接下来,我们将探讨如何创建具有固定值的变量。

常量

常量就像变量,但你不能更改它们的初始值。这些在代码运行时常量的值不需要或不应更改的情况下非常有用。你可以认为你可以将这些值硬编码到代码中,它会产生类似的效果。经验告诉我们,虽然这些值在运行时不需要更改,但它们可能以后需要更改。如果发生这种情况,追踪和修复所有硬编码的值可能是一项艰巨且容易出错的任务。使用常量现在只需做一点工作,但可以节省你以后的大量精力。

常量声明与var语句类似。对于常量,需要提供初始值。类型是可选的,如果省略则自动推断。初始值可以是字面量或简单的表达式,可以使用其他常量的值。像var一样,你可以在一个语句中声明多个常量。以下是一些表示法:

constant <name> <type> = <value>
constant (
  <name1> <type1> = <value1>
  <name2> <type2> = <value3>
…
  <nameN> <typeN> = <valueN>
)

练习 1.16 – 常量

在这个练习中,我们遇到了一个性能问题:我们的数据库服务器太慢。我们将创建一个自定义内存缓存。我们将使用 Go 的map集合类型,它将充当缓存。缓存中可以存放的项目数量有一个全局限制。我们将使用一个map来帮助跟踪缓存中的项目数量。我们需要缓存两种类型的数据:书籍和 CD。两者都使用 ID,因此我们需要一种方法在共享缓存中区分这两种类型的项。我们需要一种设置和从缓存中获取项的方法。

我们将设置缓存中项目数量的最大值。我们还将使用常量添加一个前缀来区分书籍和 CD。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个main.go文件。

  2. main.go中,将main包名添加到文件顶部:

    package main
    
  3. 导入我们将需要的包:

    import "fmt"
    
  4. 创建一个代表全局限制大小的常量:

    const GlobalLimit = 100
    
  5. 创建一个MaxCacheSize常量,其值是全球限制大小的 10 倍:

    const MaxCacheSize int = 10 * GlobalLimit
    
  6. 创建我们的缓存前缀:

    const (
      CacheKeyBook = "book_"
      CacheKeyCD = "cd_"
    )
    
  7. 声明一个具有string键和string值的map值作为我们的缓存:

    var cache map[string]string
    
  8. 创建一个从缓存中获取项目的函数:

    func cacheGet(key string) string {
      return cache[key]
    }
    
  9. 创建一个设置缓存中项的函数:

    func cacheSet(key, val string) {
    
  10. 在这个函数中,查看MaxCacheSize常量以防止缓存超过该大小:

      if len(cache)+1 >= MaxCacheSize {
        return
      }
      cache[key] = val
    }
    
  11. 创建一个从缓存中获取书籍的函数:

    func GetBook(isbn string) string {
    
  12. 使用书籍缓存前缀创建一个唯一的键:

      return cacheGet(CacheKeyBook + isbn)
    }
    
  13. 创建一个向缓存中添加书籍的函数:

    func SetBook(isbn string, name string) {
    
  14. 使用书籍缓存前缀来创建一个唯一的键:

      cacheSet(CacheKeyBook+isbn, name)
    }
    
  15. 创建一个从缓存中获取 CD 数据的函数:

    func GetCD(sku string) string {
    
  16. 使用 CD 缓存前缀来创建一个唯一的键:

      return cacheGet(CacheKeyCD + sku)
    }
    
  17. 创建一个向共享缓存中添加 CD 的函数:

    func SetCD(sku string, title string) {
    
  18. 使用 CD 缓存前缀常量来为共享缓存构建一个唯一的键:

      cacheSet(CacheKeyCD+sku, title)
    }
    
  19. 创建 main() 函数:

    func main() {
    
  20. 通过创建一个 map 值来初始化我们的缓存:

      cache = make(map[string]string)
    
  21. 向缓存中添加一本书:

      SetBook("1234-5678", "Get Ready To Go")
    
  22. CD 缓存前缀添加到缓存中:

      SetCD("1234-5678", "Get Ready To Go Audio Book")
    
  23. 从缓存中获取并打印那个 Book

      fmt.Println("Book :", GetBook("1234-5678"))
    
  24. 从缓存中获取并打印那个 CD

      fmt.Println("CD :", GetCD("1234-5678"))
    
  25. 关闭 main() 函数:

    }
    
  26. 保存文件。然后,在新文件夹中运行以下命令:

    go run .
    

以下是输出:

图 1.22:显示书籍和 CD 缓存的输出

图 1.22:显示书籍和 CD 缓存的输出

在这个练习中,我们使用常量来定义在代码运行期间不需要更改的值。我们使用各种表示法声明了它们,一些带有排版,一些没有。我们在一个语句中声明了一个常量,也声明了多个常量。

接下来,我们将查看与值更紧密相关的常数的变体。

枚举

枚举是一种定义一组相关值的固定列表的方法。Go 没有内置的枚举类型,但它提供了诸如 iota 这样的工具,允许你使用常量来定义自己的枚举。我们现在将探讨这一点。

例如,在以下代码中,我们将一周中的日子定义为常量。这段代码是 Go 的 iota 功能的良好候选者:

…
const (
  Sunday  = 0
  Monday  = 1
  Tuesday = 2
  Wednesday = 3
  Thursday = 4
  Friday  = 5
  Saturday = 6
)
…

使用 iota,Go 帮助我们像这样管理列表。使用 iota,以下代码等同于前面的代码:

…
const (
  Sunday = iota
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
)
…

现在,我们有 iota 在为我们分配数字。使用 iota 使得枚举更容易创建和维护,特别是如果你需要在代码的中间添加新值时。在使用 iota 时,顺序很重要,因为它是一个标识符,告诉 Go 编译器从这个例子中的第一个值开始,每次递增 1。使用 iota,你可以使用 _ 跳过值,以不同的偏移量开始,甚至可以使用更复杂的计算。

接下来,我们将详细探讨 Go 的变量作用域规则以及它们如何影响你编写代码的方式。

作用域

Go 中的所有变量都存在于一个作用域中。顶级作用域是包作用域。作用域可以包含其内部的作用域。定义子作用域的方法有几种;最容易想到的是,当你看到 { 时,你开始了一个新的子作用域,并且该子作用域在遇到匹配的 } 时结束。父-子关系是在代码编译时定义的,而不是在代码运行时。当访问一个变量时,Go 会查看代码定义的作用域。如果它找不到具有该名称的变量,它会查看父作用域,然后是祖父作用域,一直到最后到达包作用域。它会在找到匹配名称的变量时停止查找,如果找不到匹配项,则会引发错误。

换句话说,当你的代码使用一个变量时,Go 需要确定该变量是在哪里定义的。它从当前正在运行的代码的作用域开始搜索。如果在那个作用域中有一个使用该名称的变量定义,它就会停止搜索并使用该变量定义来完成工作。如果找不到变量定义,它就会开始沿着作用域栈向上搜索,一旦找到具有该名称的变量就停止。这种搜索完全是基于变量名称进行的。如果在找到具有该名称的变量但类型不正确时,Go 会引发错误。

在这个例子中,我们有四个不同的作用域,但我们只定义了一个 level 变量。这意味着无论你在哪里使用 level,都使用的是同一个变量:

package main
import "fmt"
var level = "pkg"
func main() {
  fmt.Println("Main start :", level)
  if true {
    fmt.Println("Block start :", level)
    funcA()
  }
}
func funcA() {
  fmt.Println("funcA start :", level)
}

以下是在使用 level 时显示变量的输出结果:

Main start : pkg
Block start : pkg
funcA start : pkg

在这个例子中,我们遮蔽了 level 变量。这个新的 level 变量与包作用域中的 level 变量无关。当我们在这个代码块中打印 level 时,Go 运行时会停止寻找名为 level 的变量,一旦找到在 main 中定义的那个。这种逻辑导致一旦新变量遮蔽了包变量,就会打印出不同的值。你还可以看到它是一个不同的变量,因为它是一个不同的类型,而在 Go 中变量类型不能改变:

package main
import "fmt"
var level = "pkg"
func main() {
  fmt.Println("Main start :", level)
  // Create a shadow variable
  level := 42
  if true {
    fmt.Println("Block start :", level)
    funcA()
  }
  fmt.Println("Main end :", level)
}
func funcA() {
  fmt.Println("funcA start :", level)
}

以下输出结果:

Main start : pkg
Block start : 42
funcA start : pkg
Main end : 42

Go 语言的静态作用域解析在调用 funcA 时发挥作用。这就是为什么当 funcA 运行时,它仍然可以看到包作用域的 level 变量。作用域解析并不关注 funcA 是在哪里被调用的。

你无法访问在子作用域中定义的变量:

package main
import "fmt"
func main() {
  {
    level := "Nest 1"
    fmt.Println("Block end :", level)
  }
  // Error: undefined: level
  //fmt.Println("Main end  :", level)
}

以下为输出结果:

图 1.23:显示错误的输出

图 1.23:显示错误的输出

活动 1.03 – 消息错误

以下代码无法工作。编写它的那个人无法修复它,他们已经请求你帮助他们。你能让它工作吗?

package main
import "fmt"
func main() {
  count := 5
  if count > 5 {
    message := "Greater than 5"
  } else {
    message := "Not greater than 5"
  }
  fmt.Println(message)
}

按照以下步骤操作:

  1. 运行代码并查看输出结果。

  2. 问题出在 message 上;对代码进行修改。

  3. 重新运行代码并查看它带来的差异。

  4. 重复此过程,直到看到预期的输出。

    以下为预期的输出结果:

    Not greater than 5
    

在这个活动中,我们看到了变量定义的位置对代码有重大影响。在定义变量时,始终考虑你需要变量在哪个作用域中。

在下一个活动中,我们将探讨一个稍微复杂一点的问题。

活动 1.04 – 错误的计数问题

你的朋友回来了,他们代码中又出现了另一个错误。这段代码应该打印 true,但它打印的是 false。你能帮助他们修复错误吗?

package main
import "fmt"
func main() {
  count := 0
  if count < 5 {
    count := 10
    count++
  }
  fmt.Println(count == 11)
}

按照以下步骤操作:

  1. 运行代码并查看输出结果。

  2. 问题出在 count 上;对代码进行修改。

  3. 重新运行代码并查看它带来的差异。

  4. 重复此过程,直到看到预期的输出。

以下为预期的输出结果:

True

摘要

在本章中,我们深入探讨了变量的细节,包括变量的声明方式,以及你可以用来声明变量的所有不同符号。这种符号的多样性为你提供了 90%工作所需的紧凑符号,同时仍然在你需要时提供非常具体的 10%的能力。我们探讨了在声明变量后如何更改和更新变量的值。同样,Go 为你提供了一些简写,以帮助在最常见的用例中使你的生活更轻松。所有数据最终都以某种形式存储在变量中。数据是使代码动态和响应性的因素。没有数据,你的代码只能永远做一件事;数据释放了软件的真正力量。

现在您的应用程序有了数据,它需要根据这些数据做出选择。这就是变量比较的用武之地。这有助于我们判断某事是真是假,是更大还是更小,等等,同时也帮助我们根据这些比较的结果做出选择。

我们通过查看零值、指针和作用域逻辑来探索 Go 如何实现其变量系统。到目前为止,我们知道这些是交付无错误高效软件和未能做到之间的区别。

我们还探讨了如何通过使用常量来声明不可变变量,以及iota如何帮助管理列表或相关常量以协同工作,例如枚举。

在下一章中,我们将开始通过定义逻辑和遍历变量集合来使用我们的变量。

第二章:命令与控制

概述

在本章中,我们将使用分支逻辑和循环来展示如何控制逻辑并选择性运行。有了这些工具,你将能够根据变量的值控制你想运行或不运行的代码。

到本章结束时,你将能够使用ifelseelse if实现分支逻辑;使用switch语句简化复杂的分支逻辑;使用for循环创建循环逻辑;使用range遍历复杂的数据集合;使用continuebreak控制循环的流程;以及使用goto语句在函数内跳转到标记的语句。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter02

简介

在上一章中,我们探讨了变量和值以及我们如何在变量中临时存储数据并更改这些数据。现在,我们将探讨如何使用这些数据在代码中运行或选择性运行逻辑。“逻辑”指的是控制程序如何操作或处理数据的指令序列。这种逻辑允许你控制数据在软件中的流动方式。你可以根据变量的值做出反应并执行不同的操作。

这种逻辑可以用来验证用户的输入。如果我们正在编写管理银行账户的代码,并且用户要求取款,我们可以检查他们是否请求了有效的金额。我们会检查他们账户中是否有足够的钱。如果验证成功,我们会使用逻辑来更新他们的余额,转账并显示成功消息。如果验证失败,我们会显示一条解释出错原因的消息。

如果你的软件是一个虚拟世界,那么逻辑就是该世界的物理定律。像我们世界的物理定律一样,这些定律必须遵守,不能违反。如果你在法律中存在缺陷,那么你的虚拟世界将无法顺利运行,甚至可能爆炸。

另一种逻辑形式是循环;使用循环可以使你多次执行相同的代码。使用循环的一种常见方式是遍历一组数据。对于我们的虚拟银行软件,我们会使用循环来遍历用户的交易,以便在用户请求时向用户显示。

循环和逻辑使软件能够具有复杂的行为,能够响应变化和动态的数据。

if 语句

if语句是 Go 中最基本的逻辑形式。if语句会根据布尔表达式运行或不运行代码块。其表示法如下:if <布尔表达式> { <代码块> }

布尔表达式可以是简单的代码,结果为布尔值。代码块可以是任何逻辑,你还可以将其放入函数中,并且被限制在该函数的代码块中。当布尔表达式为真时,代码块会运行。你只能在函数的作用域内使用 if 语句。在 Go 中,“函数作用域”的概念指的是函数内变量和语句的可见性和可访问性。

练习 2.01 – 一个简单的 if 语句

在这个练习中,我们将使用 if 语句来控制某些代码是否运行。我们将定义一个硬编码的 int 值,但在实际应用中,这可能是用户输入。然后我们将使用 % 运算符(也称为取模表达式)检查变量的值是奇数还是偶数。取模运算给出了除法后的剩余量。我们将使用取模运算来获取除以 2 后的余数。如果我们得到余数为 0,那么我们知道这个数字是偶数。如果余数为 1,那么我们知道这个数字是奇数。取模运算的结果是一个 int 值,所以我们使用 ==(比较运算符)来获取 truefalse 的布尔值:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个 main 函数:

    func main() {
    
  4. 定义一个具有初始值的 int 变量。我们在这里将其设置为 5,这是一个奇数,但我们也可以将其设置为 6,这是一个偶数:

      input := 5
    
  5. 创建一个使用取模表达式的 if 语句,然后检查结果是否等于 0:

      if input%2 == 0 {
    
  6. 当布尔表达式结果为 true 时,这意味着数字是偶数。然后我们使用 fmt 包将“偶数”打印到控制台:

        fmt.Println(input, "is even")
    
  7. 关闭代码块:

      }
    
  8. 现在为奇数做同样的操作:

      if input%2 == 1 {
        fmt.Println(input, "is odd")
      }
    
  9. 关闭 main

    }
    
  10. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

以下为预期的输出:

5 is odd

在这个练习中,我们使用了逻辑来选择性运行代码。使用逻辑来控制哪些代码运行,让你能够在代码中创建流程。这允许你拥有能够对其数据进行反应的代码。这些流程允许你能够对你的代码如何处理数据进行推理,使其更容易理解和维护。

尝试将输入值更改为 6,看看偶数块是如何执行而不是奇数块的。

在下一个主题中,我们将探讨如何改进这段代码并使其更高效。

if else 语句

在上一个练习中,我们进行了两次评估。一次是检查数字是否为偶数,另一次是查看它是否为奇数。正如我们所知,一个数字只能是奇数或偶数。有了这个知识,我们可以通过演绎来知道,如果一个数字不是偶数,那么它一定是奇数。

使用这种演绎逻辑在编程中很常见,目的是通过避免做不必要的工作来提高程序的效率。

我们可以使用if else语句来表示这种逻辑。表示法如下:if <布尔表达式> { <代码块> } else { <代码块> }if else语句建立在if语句的基础上,并给我们第二个代码块。第二个代码块只有在第一个代码块不运行时才会运行;两个代码块不能同时运行。

练习 2.02 – 使用if else语句

在这个练习中,我们将更新我们之前的练习,使用if else语句:

  1. 创建一个新的文件夹,并添加一个main.go文件。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个main函数:

    func main() {
    
  4. 定义一个具有初始值的int变量,这次我们将给它赋予不同的值:

      input := 4
    
  5. 创建一个使用取模表达式的if语句,然后检查结果是否等于 0:

      if input%2 == 0 {
        fmt.Println(input, "is even")
    
  6. 这次,我们不是关闭代码块,而是开始一个新的else代码块:

      } else {
        fmt.Println(input, "is odd")
      }
    
  7. 关闭main

    }
    
  8. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

下面的预期输出是:

4 is even

在这个练习中,我们能够通过使用一个if else语句简化我们之前的代码。这不仅使代码更高效,还使得代码更容易理解和维护。

在下一个主题中,我们将演示我们如何添加尽可能多的代码块,同时只允许一个执行。

else if语句

if else解决了仅针对一个或两个可能的逻辑结果运行代码的问题。这个问题解决了,那么如果我们的前一个练习的代码原本只打算对非负数有效呢?我们需要某种能够评估多个布尔表达式但只执行一个代码块的东西;即,负数、偶数或奇数的代码块。

在那种情况下,我们不能单独使用if else语句;然而,我们可以通过扩展if语句来覆盖它。在这个扩展中,你可以给else语句自己的布尔表达式。这个表示法看起来是这样的:if <布尔表达式> { <代码块> } else if <布尔表达式> { <代码块> }。你还可以在末尾结合一个最终的else语句,它看起来像这样:if <布尔表达式> { <代码块> } else if <布尔表达式> { <代码块> } else { <代码块> }。在初始的if语句之后,你可以有任意多的else if语句。Go 从语句的顶部评估布尔表达式,并逐个评估每个布尔表达式,直到其中一个结果为true或找到else实例。如果没有else实例,并且没有任何布尔表达式结果为true,则不会执行任何代码块,Go 会继续执行。当 Go 得到布尔true结果时,它只执行该语句的代码块,然后停止评估if语句的任何布尔表达式。

练习 2.03 – 使用else if语句

在这个练习中,我们将更新我们之前的练习。我们将添加对负数的检查。这个检查必须在偶数和奇数检查之前运行,因为只有一个代码块可以运行:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个 main 函数:

    func main() {
    
  4. 定义一个带有初始值的 int 变量,我们将给它一个负值:

      input := -10
    
  5. 我们的第一个布尔表达式是检查负数。如果我们找到一个负数,我们将打印一条消息说明它们是不允许的:

      if input < 0 {
        fmt.Println("input can't be a negative number")
    
  6. 我们需要将我们的偶数检查移动到 else if 语句中:

      } else if input%2 == 0 {
        fmt.Println(input, "is even")
    
  7. else 语句保持不变,然后关闭 main

      } else {
        fmt.Println(input, "is odd")
      }
    }
    
  8. 保存文件,然后在新建的文件夹中运行以下代码片段:

    go run main.go
    

以下是我们期望的输出:

input can't be a negative number

在这个练习中,我们在 if 语句中添加了更复杂的逻辑。我们向其中添加了一个 else if 语句,这允许复杂的评估。这个添加将通常是一个简单的分叉路口,给你很多道路可以选择,但仍然受到只能选择其中一条的限制。

在下一个主题中,我们将使用 if 语句的一个微妙但强大的功能,它让你保持代码整洁。

初始 if 语句

需要调用一个函数但不关心返回值是很常见的。通常,你只想检查它是否正确执行,然后丢弃返回值;例如,发送电子邮件、写入文件或将数据插入数据库:大多数情况下,如果这些类型的操作执行成功,你不需要担心它们返回的变量。不幸的是,变量并没有消失,因为它们仍然在作用域内。

为了停止这些不想要的变量悬挂,我们可以使用我们对作用域规则的了解来消除它们。检查错误的最佳方式是在 if 语句上使用 initial 语句。符号看起来像这样:if <initial statement>; <boolean expression> { <code block> }。初始语句与布尔表达式在同一个部分,用 ; 来分隔它们。

Go 只允许在初始语句部分使用它所称为的简单语句,包括以下内容:

  • 赋值和短变量赋值:

    i := 0
    
  • 如数学或逻辑表达式之类的表达式:

    i = (j * 10) == 40
    
  • 发送用于处理通道的语句,我们将在后面的 第十七章 中介绍,我们将重点介绍并发

  • 增量和减量表达式:

    i++
    

一个常见的错误是尝试使用 var 定义变量。这是不允许的;你可以用简短的赋值来代替。

练习 2.04 – 实现初始的 if 语句

在这个练习中,我们将继续构建我们之前的练习。我们将添加更多关于哪些数字可以检查为奇数或偶数的规则。由于规则众多,将它们全部放入一个布尔表达式中很难理解。我们将把所有的验证逻辑移动到一个返回错误的函数中。这是一个用于错误的内置 Go 类型。如果错误的值为nil,则一切正常。如果不为nil,则表示有错误,你需要处理它。我们将在初始语句中调用该函数,然后检查错误:

  1. 创建一个新文件夹并添加一个main.go文件。

  2. main.go中添加包和导入:

    package main
    import (
      "errors"
      "fmt"
    )
    
  3. 创建一个用于验证的函数。这个函数接受一个整数并返回一个错误:

    func validate(input int) error {
    
  4. 我们定义了一些规则,如果其中任何一个是true,我们就会使用errors包中的New函数返回一个新的错误:

      if input < 0 {
        return errors.New("input can't be a negative number")
      } else if input > 100 {
        return errors.New("input can't be over 100")
      } else if input%7 == 0 {
        return errors.New("input can't be divisible by 7")
    
  5. 如果输入通过了所有的检查,则返回nil

      } else {
        return nil
      }
    }
    
  6. 创建我们的main函数:

    func main() {
    
  7. 定义一个值为21的变量:

      input := 21
    
  8. 使用初始语句调用函数;使用短变量赋值来捕获返回的错误。在布尔表达式中,使用!=检查错误是否不等于nil

      if err := validate(input); err != nil {
        fmt.Println(err)
    }
    
  9. 其余部分与之前相同:

    else if input%2 == 0 {
        fmt.Println(input, "is even")
      } else {
        fmt.Println(input, "is odd")
      }
    }
    
  10. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

以下为预期的输出,它显示了一个错误语句:

input can't be divisible by 7

在这个练习中,我们使用初始语句定义并初始化了一个变量。这个变量可以在布尔表达式和相关的代码块中使用。一旦if语句完成,变量就会超出作用域,并由 Go 的内存管理系统回收。

表达式switch语句

虽然可以在if语句中添加任意多的else if语句,但最终它会变得难以阅读。

当这种情况发生时,你可以使用 Go 的逻辑替代方案:switch。对于需要大if语句的情况,switch可以是一个更紧凑的替代方案。

switch的表示法在以下代码片段中显示:

switch <initial statement>; <expression> {
case <expression>:
  <statements>
case <expression>, <expression>:
  <statements>
default:
  <statements>
}

switch语句中,初始语句与前面的if语句中的工作方式相同。表达式并不相同,因为if是一个布尔表达式。在这个表达式中,你可以有不仅仅是布尔值。cases是检查语句是否被执行的地方。语句就像if语句中的代码块,但这里不需要花括号。

初始语句和表达式都是可选的。如果要只有表达式,它看起来会是这样:switch <expression> {…}。如果要只有初始语句,你会写成switch <initial statement>; {…}。你可以两者都不写,最终你会得到switch {…}。当表达式缺失时,它就像在那里放置了true的值。

使用case表达式有两种主要方式。它们可以像if语句或布尔表达式一样使用,其中你使用逻辑来控制语句是否执行。另一种方式是在那里放置一个字面值。在这种情况下,该值与switch表达式的值进行比较。如果它们匹配,则执行语句。你可以通过逗号分隔来拥有任意数量的case表达式。如果case有多个表达式,则从顶部开始检查,然后从左到右。

case匹配时,只有其语句会被执行,这与许多其他语言不同。为了获得那些语言中找到的 fall-through 行为,必须在想要该行为的每个case的末尾添加一个fallthrough语句。如果在case的末尾之前调用fallthrough,它将在那一刻跳过并继续到下一个case

可选的default可以在switch语句的任何位置添加,但最佳实践是在末尾添加。default与在if语句中使用else语句的效果相同。

这种形式的switch语句称为表达式 switch语句。还有一种形式的switch语句,称为类型 switch语句,我们将在第四章中探讨。

练习 2.05 – 使用switch语句

在这个练习中,我们需要创建一个程序,根据某人出生的日子打印出特定的消息。我们使用time包中的星期几常量集。我们将使用switch语句来创建一个更紧凑的逻辑结构:

  1. 加载main包:

    package main
    
  2. 导入fmttime包:

    import (
      "fmt"
      "time"
    )
    
  3. 定义一个main函数:

    func main() {
    
  4. 定义一个变量,表示某人出生的那一周的某一天。使用time包中的常量来完成。我们将将其设置为星期一,但可以是任何一天:

      dayBorn := time.Monday
    
  5. 创建一个使用变量作为表达式的switch语句:

      switch dayBorn {
    
  6. 每个case表达式都会尝试将其表达式值与switch表达式值进行匹配:

      case time.Monday:
      fmt.Println("Monday's child is fair of face")
      case time.Tuesday:
      fmt.Println("Tuesday's child is full of grace")
      case time.Wednesday:
      fmt.Println("Wednesday's child is full of woe")
      case time.Thursday:
      fmt.Println("Thursday's child has far to go")
      case time.Friday:
      fmt.Println("Friday's child is loving and giving")
      case time.Saturday:
      fmt.Println("Saturday's child works hard for a living")
      case time.Sunday:
      fmt.Println("Sunday's child is bonny and blithe")
    
  7. 我们在这里使用default作为验证的一种形式:

      default:
      fmt.Println("Error, day born not valid")
      }
    
  8. 关闭main函数:

    }
    
  9. 保存文件,然后在新建文件夹中运行以下代码片段:

    go run main.go
    

以下为预期输出:

Monday's child is fair of face

在这个练习中,我们使用switch创建了一个紧凑的逻辑结构,将许多不同的可能值与给用户的特定消息匹配。在这里使用常量作为switch语句是很常见的,就像我们这里使用time包中的星期几常量一样。

接下来,我们将使用case功能,它允许我们匹配多个值。

练习 2.06 – switch语句和多个case

在这个练习中,我们将打印出一个消息,告诉我们某人出生的那天是工作日还是周末。我们只需要两个case,因为每个case可以支持检查多个值:

  1. 加载main包:

    package main
    
  2. 导入fmttime包:

    import (
      "fmt"
      "time"
    )
    
  3. 定义一个main函数:

    func main() {
    
  4. 使用time包的某个常量定义我们的dayBorn变量:

      dayBorn := time.Sunday
    
  5. switch通过使用变量作为表达式以相同的方式开始:

      switch dayBorn {
    
  6. 这次,对于case,我们有星期几的常量。Go 会从左到右逐个检查每个常量与switch表达式的匹配,一次一个。一旦 Go 找到匹配项,它就会停止评估并只运行该case的语句:

      case time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday:
       fmt.Println("Born on a weekday")
    
  7. 然后,它会对周末的日子做同样的处理:

      case time.Saturday, time.Sunday:
       fmt.Println("Born on the weekend")
    
  8. 我们再次使用default进行验证并关闭switch语句:

      default:
       fmt.Println("Error, day born not valid")
      }
    
  9. 关闭main函数:

    }
    
  10. 保存文件,然后在新建的文件夹中运行以下代码片段:

    go run main.go
    

以下是我们预期的输出:

Born on the weekend

在这个练习中,我们使用了具有多个值的case。这允许一个非常紧凑的逻辑结构,可以用几行代码评估一周的 7 天,并进行验证检查。这使得逻辑的意图变得清晰,反过来,也使得它更容易更改和维护。

接下来,我们将看看如何在case表达式中使用更复杂的逻辑。

有时,你会看到在switch语句中没有进行任何评估的代码,但在case表达式中进行了检查。

练习 2.07 – 无表达式的switch语句

并非总是能够使用switch表达式的值来匹配值。有时,你需要根据多个变量进行匹配。其他时候,你可能需要匹配比相等检查更复杂的东西。例如,你可能需要检查一个数字是否在特定的范围内。在这些情况下,switch仍然有助于构建紧凑的逻辑语句,因为case允许与if布尔表达式相同的表达式范围。

在这个练习中,让我们构建一个简单的switch表达式,检查一个日子是否在周末,以展示case可以做什么:

  1. 加载main包:

    package main
    
  2. 导入fmttime包:

    import (
      "fmt"
      "time"
    )
    
  3. 定义一个main函数:

    func main() {
    
  4. 我们的switch表达式使用初始语句来定义我们的变量。表达式被留空,因为我们不会使用它:

    switch dayBorn := time.Sunday; {
    
  5. case使用一些复杂的逻辑来检查这一天是否在周末:

      case dayBorn == time.Sunday || dayBorn == time.Saturday:
       fmt.Println("Born on the weekend")
    
  6. 添加一个default语句并关闭switch表达式:

      default:
       fmt.Println("Born some other day")
      }
    
  7. 关闭main函数:

    }
    
  8. 保存文件,然后在新建的文件夹中运行以下代码片段:

    go run main.go
    

以下是我们预期的输出:

Born on the weekend

在这个练习中,我们学习了当简单的switch语句匹配不足时,你可以在case表达式中使用复杂逻辑。这仍然比你有超过几个情况时管理逻辑语句提供了更紧凑和更简单的方法。

接下来,我们将放下逻辑结构,开始探讨我们可以多次运行相同语句的方法,以使数据处理更容易。

循环

在现实世界的应用中,你经常需要重复运行相同的逻辑。通常需要处理多个输入并给出多个输出。循环是重复你的逻辑的最简单方式。

Go 只有一个循环语句,即for,但它非常灵活。有两种不同的形式:第一种用于大量使用有序集合,如数组和切片,我们将在后面详细介绍。用于有序集合的循环如下所示:

for <initial statement>; <condition>; <post statement> {
  <statements>
}

initial语句与ifswitch语句中的语句类似。initial语句在所有其他语句之前运行,并允许使用之前定义的简单语句。在每次循环结束时,都会检查条件以确定是否应该运行语句或停止循环。与initial语句一样,condition也允许使用简单语句。post语句在执行语句之后运行,并允许运行简单语句。post语句主要用于递增诸如循环计数器之类的项目,这些项目将在下一次循环的condition中评估。这些语句是您想要作为循环一部分运行的任何 Go 代码。

initialconditionpost语句都是可选的,可以编写如下for循环:

for {
  <statements>
}

这种形式会导致一个无限循环,除非使用break语句手动停止循环。除了break之外,还有一个continue语句,可以用来跳过循环的单次运行中的剩余部分,但不会停止整个循环。

for循环的另一种形式是读取返回布尔值的数据源,当有更多数据可读取时。这包括从数据库、文件、命令行输入和网络套接字读取。这种形式如下:

for <condition> {
  <statements>
}

这种形式是用于从有序列表读取的形式的简化版本,但没有控制循环所需的逻辑,因为您使用的数据源旨在轻松地在for循环中工作。

for循环的另一种形式是遍历无序数据集合,如映射。我们将在后面的章节中更详细地介绍映射。遍历这些集合时,你将在循环中使用range语句。对于映射,其形式如下:

for <key>, <value> := range <map> {
  <statements>
}

练习 2.08 – 使用 for i 循环

在这个练习中,我们将使用for循环的三个部分来创建一个变量并在循环中使用该变量。我们将能够通过将变量的值打印到控制台来看到每次循环迭代后变量的变化情况:

  1. 将包定义为main并添加导入:

    package main
    import "fmt"
    
  2. 创建一个main函数:

    func main() {
    
  3. 定义一个for循环,在initial语句部分定义一个初始值为0i变量。在子句中检查i是否小于5。在post语句中,将i增加1

      for i := 0; i < 5; i++ {
    
  4. 在循环体中,打印出i的值:

        fmt.Println(i)
    
  5. 关闭循环:

      }
    
  6. 关闭main

    }
    
  7. 保存文件,然后在新建的文件夹中运行以下代码片段:

    go run main.go
    

以下为预期输出:

0
1
2
3
4

在这个练习中,我们使用了一个仅在for循环中存在的变量。我们设置了变量,检查了它的值,修改了它,并输出了它。在处理有序、数值索引的集合(如数组切片)时,使用这样的循环非常常见。在这个例子中,我们硬编码了停止循环的值;然而,当查看数组和切片时,这个值会根据集合的大小动态确定。

接下来,我们将使用for i循环来处理切片。

练习 2.09 – 遍历数组和切片

在这个练习中,我们将遍历一个字符串集合。我们将使用切片,但循环逻辑也将是相同的一组数组。我们将定义一个集合;然后创建一个循环,使用该集合来控制何时停止循环,并使用一个变量来跟踪我们在集合中的位置。

数组和切片的索引方式意味着数字之间永远不会有空隙,并且第一个数字总是0。内置函数len用于获取任何集合的长度。我们将将其用作条件的一部分,以检查我们是否到达了集合的末尾:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个main函数:

    func main() {
    
  4. 定义一个变量,它是一个strings的切片,并用数据初始化它:

      names := []string{"Jim", "Jane", "Joe", "June"}
    

    我们将在下一章更详细地介绍collectionstring

  5. 循环的initialpost语句与之前相同;不同之处在于condition语句,我们在这里使用len来检查我们是否到达了集合的末尾:

      for i := 0; i < len(names); i++ {
    
  6. 其余部分与之前相同:

        fmt.Println(names[i])
      }
    }
    
  7. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

以下是我们预期的输出:

Jim
Jane
Joe
June

在这个练习中,我们介绍了如何通过索引迭代对象。现在,我们将探讨使用range循环遍历对象的替代方法。

range循环

arrayslice类型始终有一个索引号,并且这个数字总是从0开始。我们之前看到的for i循环是这些类型在现实世界代码中最常见的选项。

另一种集合类型,map,并不能提供相同的保证。这意味着你需要使用range。你将使用range来代替for循环中的condition语句,并且,在每次循环中,range会提供一个集合中元素的键和值,然后移动到下一个元素。

使用range循环时,你不需要定义一个条件来停止循环,因为range会为我们处理这个问题。

调用map顺序

项目的顺序是随机化的,以防止开发者依赖于 map 中元素的顺序,这意味着如果需要,你可以将其用作伪数据随机化的形式。

练习 2.10 – 遍历 map

在这个练习中,我们将创建一个具有字符串键和字符串值的map类型。我们将在后面的章节中更详细地介绍map类型,所以如果你现在还不完全理解map类型,请不要担心。然后我们将使用rangefor循环中遍历map。然后我们将键和值数据写入控制台:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个main函数:

    func main() {
    
  4. 定义一个具有string键和strings变量的string值的map类型,并用以下数据初始化它:

      config := map[string]string{
       "debug":  "1",
       "logLevel": "warn",
       "version": "1.2.1",
      }
    
  5. 使用range获取数组元素的keyvalue变量并将它们分配给变量:

      for key, value := range config {
    
  6. 打印出keyvalue变量:

      fmt.Println(key, "=", value)
    
  7. 关闭循环和main

      }
    }
    
  8. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

以下为预期输出,显示一个具有字符串键和字符串值的map

debug = 1
logLevel = warn
version = 1.2.1

在这个练习中,我们使用rangefor循环中,以便能够从map集合中读取所有数据。map类型不像数组或切片那样提供关于从零开始和没有间隙的保证(如果我们使用整数作为map键)。range还控制何时停止循环。

如果你不需要keyvalue变量,你可以使用_作为变量名来告诉编译器你不需要它。

活动二.01 – 使用range遍历map数据

假设你已经提供了以下表格中的数据。你必须找到计数最多的单词,并使用以下数据打印出单词及其计数:

图 2.1:执行活动的单词和计数数据

图 2.1:执行活动的单词和计数数据

注意

前面的词来自 Rick Astley 演唱的歌曲Never Gonna Give You Up

解决这个活动的步骤如下:

  1. 将单词放入类似这样的map中:

      words := map[string]int{
       "Gonna": 3,
       "You": 3,
       "Give": 2,
       "Never": 1,
       "Up":  4,
      }
    
  2. 创建一个循环并使用range捕获单词和计数。

  3. 使用一个变量跟踪具有最高计数的单词,以及与其关联的单词。

  4. 打印变量。

以下为预期输出,显示最受欢迎的单词及其计数值:

Most popular word: Up
With a count of: 4

注意

这个活动的解决方案可以在本章 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter02/Activity02.01

活动二.02 – 实现 FizzBuzz

当面试编程工作时,你会被要求做一些编码练习。这些问题要求你从头开始编写一些内容,并且会有几个规则要遵循。为了给你一个大概的了解,我们将带你通过一个经典的练习,FizzBuzz

规则是以下这些:

  • 编写一个程序,打印出从 1 到 100 的数字

  • 如果数字是 3 的倍数,则打印“Fizz”

  • 如果数字是 5 的倍数,则打印“Buzz”

  • 如果数字是 3 和 5 的倍数,则打印“FizzBuzz”

这里有一些提示:

  • 你可以使用strconv.Itoa()将数字转换为字符串

  • 需要评估的第一个数字必须是 1,最后一个数字必须是 100

这些步骤将帮助你完成活动:

  1. 创建一个进行 100 次迭代的循环。

  2. 有一个变量用于记录到目前为止的循环次数。

  3. 在循环中,使用该计数并检查它是否能被 3 或 5 整除使用%

  4. 仔细思考你将如何处理“FizzBuzz”的情况。

以下截图显示了预期的输出:

注意

考虑到输出太大无法在此显示,只有图 2.2中的一部分将可见。

图 2.2:FizzBuzz 输出

图 2.2:FizzBuzz 输出

注意

该活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter02/Activity02.02

这个活动可以展示switch语句如何改进并驯服开始变得太大的if else语句。

接下来,我们将看看我们如何可以通过跳过迭代或停止循环来手动控制循环。

break 和 continue

有时会需要跳过单个循环或完全停止循环的运行。可以使用变量和if语句做到这一点,但有一个更简单的方法。

continue关键字停止当前循环的执行,并开始一个新的循环。post循环逻辑运行,循环条件语句被评估。

break关键字也停止当前循环的执行,并停止任何新循环的运行。

当你想跳过集合中的一个项目时使用continue;例如,如果集合中的一个项目无效,但其余项目可以处理,这可能是可以接受的。当数据中存在错误且处理集合的其余部分没有价值时,使用break来停止处理。

这里,我们有一个示例,它生成一个介于08之间的随机数。循环跳过能被 3 整除的数字,并在能被 2 整除的数字上停止。它还打印出每个循环的i变量,以帮助我们看到continuebreak是如何停止循环其余部分的执行。

练习 2.11 – 使用 break 和 continue 控制循环

在这个练习中,我们将使用 continuebreak 在循环中,以向你展示你可以如何控制它。我们将创建一个永远继续的循环。这意味着我们必须手动使用 break 来停止它。我们还将随机使用 continue 跳过循环。我们将通过生成一个随机数来实现这种跳过,如果这个数能被 3 整除,我们将跳过循环的其余部分:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 中添加包和导入:

    package main
    import (
      "fmt"
      "math/rand"
    )
    
  3. 创建一个 main 函数:

    func main() {
    
  4. 创建一个空的 for 循环。如果你不停止它,它将永远循环:

      for {
    
  5. 使用 rand 包中的 Intn 来选择一个介于 0 和 8 之间的随机数:

        r := rand.Intn(8)
    
  6. 如果随机数能被 3 整除,打印 "Skip" 并使用 continue 跳过循环的其余部分:

        if r%3 == 0 {
          fmt.Println("Skip")
          continue
    
  7. 如果随机数能被 2 整除,则打印 "Stop" 并使用 break 停止循环:

        } else if r%2 == 0 {
          fmt.Println("Stop")
          break
        }
    
  8. 如果数字不是上述两种情况之一,则打印该数字:

        fmt.Println(r)
    
  9. 关闭循环和 main

      }
    }
    
  10. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

下面的预期输出显示了随机数、SkipStop

1
7
7
Skip
1
Skip
1
Stop

在这个练习中,我们创建了一个会无限循环的 for 循环,然后我们使用 continuebreak 来覆盖正常的循环行为,以便我们自己控制它。这种能力可以让我们减少所需的嵌套 if 语句和变量数量,以防止逻辑在不应该运行时运行。使用 breakcontinue 有助于清理你的代码并使其更容易工作。

如果你使用一个空的 for 循环像这样,循环将永远继续,你必须使用 break 来防止无限循环。无限循环是代码中永远不会停止的循环。一旦你得到一个无限循环,你需要一种方法来终止你的应用程序;你如何做到这一点将取决于你的操作系统。如果你在终端中运行你的应用程序,正常关闭终端就可以做到这一点。不要慌张 – 这发生在我们所有人身上 – 你的系统可能会变慢,但这对它没有任何伤害。

接下来,我们将进行一些活动来测试你对逻辑和循环的所有新知识。

活动 2.03 – 冒泡排序

在这个活动中,我们将通过交换值来对给定的数字切片进行排序。这种排序技术被称为 冒泡排序 技术。Go 的 sort 包中内置了排序算法,但我们不希望你使用它们;我们希望你使用你刚刚学到的逻辑和循环。

这里是步骤:

  1. 定义一个包含未排序数字的切片。

  2. 将这个切片打印到控制台。

  3. 使用交换排序值。

  4. 完成后,将现在排序好的数字打印到控制台。

这里有一些提示:

  • 你可以这样在 Go 中进行原地交换:

    nums[i], nums[i-1] = nums[i-1], nums[i]
    
  • 你可以使用以下代码创建一个新的切片:

    var nums2 []int
    
  • 你可以这样向切片末尾添加内容:

    nums2 = append(nums2, 1)
    

下面的预期输出是:

Before: [5, 8, 2, 4, 0, 1, 3, 7, 9, 6]
After : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

注意

本活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter02/Activity02.03

goto 语句

有时候你可能想在函数中跳过某些逻辑,并使用 goto 关键字跳转到函数中的某个位置。这可以通过在函数中创建一个标签来实现,如果在函数作用域外尝试使用 goto 标签,将会导致编译错误。

goto 语句是调整 Go 控制流的一种方式;然而,它们需要谨慎使用,因为它们可能导致理解函数控制流困难,如果使用不当,会降低代码的可读性。在某些情况下,goto 语句被用于 Go 标准库中,例如 math 包,以使逻辑更容易阅读并减少对不必要的变量的需求。

练习 2.12 – 使用 goto 语句

在这个练习中,我们将在一个函数中使用 goto 来展示你如何控制它。我们将创建一个无限循环。这意味着我们将在某些自定义条件下手动停止它并退出。与之前的练习一样,我们将生成一个随机数,如果这个数能被 2 整除,我们将终止流程并跳转到我们的标签语句:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 中添加包和导入:

    package main
    import (
      "fmt"
      "math/rand"
    )
    
  3. 创建一个 main 函数:

    func main() {
    
  4. 创建一个空的 for 循环。如果你不停止它,它将无限循环:

      for {
    
  5. 使用 rand 包中的 Intn 来选择一个介于 0 和 8 之间的随机数:

        r := rand.Intn(8)
    
  6. 如果随机数能被 3 整除,打印 "Skip" 并使用 continue 跳过循环的其余部分:

        if r%3 == 0 {
          fmt.Println("Skip")
          continue
    
  7. 如果随机数能被 2 整除,则打印 "Stop" 并使用 goto 关键字和自定义的 STOP 标签停止循环:

        } else if r%2 == 0 {
          fmt.Println("Stop")
          goto STOP
        }
    
  8. 如果数字不是上述两种情况之一,则打印数字:

        fmt.Println(r)
    
  9. 关闭循环:

      }
    

    定义一个名为 STOPgoto 标签,然后打印 "goto label reached"

    STOP:
            fmt.Println("Goto label reached")
            // Close main function
    }
    
  10. 保存文件,并在新文件夹中运行以下代码片段:

    go run main.go
    

下面的预期输出将显示随机数、SkipStop

1
7
7
Skip
1
Skip
1
Stop
Goto label reached

在这个练习中,我们创建了一个会无限循环的 for 循环,然后我们使用 goto 来覆盖正常的循环行为,并根据自己的需求控制它,在特定条件下终止无限循环。这种能力可以让我们根据需要调整控制流。在这个例子中使用 goto 并不难理解函数逻辑的变化到“跳转到”函数逻辑的其他部分;然而,在更复杂的例子中,goto 可能会导致代码可读性的挑战。

接下来,我们将总结本章所学的内容。

摘要

在本章中,我们讨论了逻辑和循环。这些是构建复杂软件的基础构建块。它们允许数据流通过你的代码。它们通过让你对数据的每个元素执行相同的逻辑,让你能够处理数据集合。

能够定义你代码的规则和法律是将在软件中编码现实世界的起点。如果你正在创建银行软件,并且银行有关于你可以和不能做什么钱的规定,那么你也可以在你的代码中定义这些规则。

逻辑和循环是构建所有软件时你将使用的必要工具。

在下一章中,我们将探讨 Go 的类型系统和它所提供的核心类型。

第三章:核心类型

概述

本章旨在向你展示如何使用 Go 的基本核心类型来设计你的软件数据。我们将逐一处理每种类型,展示它们有什么用以及如何在软件中使用它们。理解这些核心类型为你提供了学习如何创建复杂数据设计所需的基础。

到本章结束时,你将能够为 Go 程序创建不同类型的变量,并为不同类型的变量分配值。你将学习如何识别和选择任何编程情况下的合适类型。你还将编写一个程序来测量密码复杂度并实现空值类型。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter03

简介

在上一章中,我们学习了如何在 Go 中使用ifif-elseelse-ifswitchcasecontinuebreakgoto

Go 是一种强类型语言,所有数据都被分配了一个类型。这个类型是固定的,不能更改。你可以对你的数据做什么,不能做什么是由你分配的类型所决定的。准确理解定义 Go 每个核心类型的每一个方面对于在 Go 语言中取得成功至关重要。

在后面的章节中,我们将讨论 Go 的更复杂类型,但这些类型都是基于本章定义的核心类型构建的。

一旦你了解了细节,Go 的核心类型就考虑得很好,也容易理解。需要理解细节意味着 Go 的类型系统并不总是直观的。例如,Go 最常用的数字类型int的大小可能是 32 位或 64 位,这取决于编译代码所使用的计算机。

类型是使数据对人类更容易处理所必需的。计算机只考虑二进制数据。二进制对人类来说很难处理。通过在二进制数据上添加一层抽象并将其标记为数字或某些文本,人类更容易对其进行推理。减少认知负担使得人们能够构建更复杂的软件,因为他们不会被管理二进制数据细节所淹没。

编程语言需要定义什么是数字,或者什么是文本。编程语言定义了你可以称为什么样的数字,以及你可以对数字执行哪些操作。例如,一个整数 10 和一个浮点数 3.14 是否都可以存储为同一类型?虽然看起来很明显你可以乘以数字,但你能否乘以文本?随着我们进入本章,我们将明确定义每种类型的规则以及你可以对它们执行的操作。

数据的存储方式也是定义类型的一个重要部分。为了允许构建高效的软件,Go 对其某些类型的大小施加了限制。例如,Go 核心类型中数字的最大存储量是 64 位内存。这允许存储任何高达 18,446,744,073,709,551,615 的数字。理解这些类型的限制对于构建无错误的代码至关重要。

定义类型的因素如下:

  • 你可以存储的数据类型

  • 你可以使用它的操作

  • 这些操作对其产生的影响

  • 它可以使用的内存量

本章为你提供了使用 Go 类型系统的知识,并增强了你在代码中使用它的信心。

真和假

使用布尔类型bool表示真和假逻辑。当你需要在代码中实现开/关切换时使用此类型。布尔实例的值只能是truefalse。布尔实例的零值是false。“零值”指的是变量在未指定显式初始值时声明的默认值。

当使用比较运算符如==>时,该比较的结果是一个布尔值。

在这个代码示例中,我们在两个数字上使用比较运算符。你会看到结果是布尔值bool

package main
import "fmt"
func main() {
  fmt.Println(10 > 5)
  fmt.Println(10 == 5)
}

运行前面的代码会显示以下输出:

true
false

练习 3.01 – 测量密码复杂度的程序

一个在线门户为用户创建用户账户,并接受长度为 8 到 15 个字符的密码。在这个练习中,我们为门户编写一个程序来显示输入的密码是否满足字符要求。字符要求如下:

  • 包含一个小写字母

  • 包含一个大写字母

  • 包含一个数字

  • 包含一个符号

  • 至少 8 个字符长

为了完成这个练习,我们将使用一些新特性。如果你不完全理解它们的作用,不要担心;我们将在下一章详细讲解。这可以看作是一个预览。我们将边走边解释每一件事,但你的主要焦点应该是布尔逻辑:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中,将主包名添加到文件顶部:

    package main
    
  3. 现在添加我们将在此文件中使用的导入:

    import (
      "fmt"
      "unicode"
    )
    
  4. 创建一个函数,它接受一个字符串参数并返回一个布尔值:

    func passwordChecker(pw string) bool {
    
  5. 将密码字符串转换为rune类型,这对于多字节(UTF-8)字符是安全的:

      pwR := []rune(pw)
    

    我们将在本章后面更详细地讨论rune

  6. 使用len计算多字节字符的数量。此代码产生一个可用于if语句的布尔结果:

      if len(pwR) < 8 {
        return false
      }
    
  7. 定义一些bool变量。我们将在最后检查这些变量:

      hasUpper := false
      hasLower := false
      hasNumber := false
      hasSymbol := false
    
  8. 逐个遍历多字节字符:

      for _, v := range pwR {
    
  9. 使用unicode包检查此字符是否为大写。此函数返回一个布尔值,我们可以直接在if语句中使用:

       if unicode.IsUpper(v) {
    
  10. 如果是,我们将设置hasUpper布尔变量为true

         hasUpper = true
       }
    
  11. 对于小写字母也做同样的事情:

       if unicode.IsLower(v) {
         hasLower = true
       }
    
  12. 也对数字做同样的事情:

       if unicode.IsNumber(v) {
         hasNumber = true
       }
    
  13. 对于符号,我们也会接受标点符号。使用or运算符,它与布尔值一起工作,如果这两个函数中的任何一个返回true,则结果为true

       if unicode.IsPunct(v) || unicode.IsSymbol(v) {
         hasSymbol = true
       }
      }
    
  14. 为了通过所有检查,所有变量都必须是true。在这里,我们通过组合多个and运算符创建一个检查所有四个变量的单行语句:

      return hasUpper && hasLower && hasNumber && hasSymbol
    
  15. 关闭函数:

    }
    
  16. 创建main()函数:

    func main() {
    
  17. 使用无效密码调用passwordChecker()函数。由于这个函数返回一个bool值,可以直接在if语句中使用:

      if passwordChecker("") {
        fmt.Println("password good")
      } else {
        fmt.Println("password bad")
      }
    
  18. 现在,使用有效的密码调用函数:

      if passwordChecker("This!I5A") {
        fmt.Println("password good")
      } else {
        fmt.Println("password bad")
      }
    
  19. 关闭main()函数:

    }
    
  20. 在新文件夹中保存文件,然后运行以下命令:

    go run main.go
    

运行前面的代码会显示以下输出:

password bad
password good

在这个练习中,我们强调了bool值在代码中表现出的各种方式。bool值对于赋予你的代码选择能力、动态性和响应性至关重要。没有bool,你的代码将很难做任何事情。

接下来,我们将探讨数字以及 Go 如何对它们进行分类。

数字

Go 有两种不同的数字类型——整数,也称为整数和浮点数。浮点数类型允许整数和包含整数分数的数。

1、54 和 5,436 是整数示例。1.5、52.25、33.333 和 64,567.00001 都是浮点数示例。

注意

所有数字类型的默认值和空值都是 0。

接下来,我们将从查看整数开始我们的数字之旅。

整数

根据以下条件,整数类型有两种分类方式:

  • 它们是否可以存储负数

  • 它们可以存储的最小和最大数值

可以存储负数的类型称为有符号整数。不能存储负数的类型称为无符号整数。每种类型可以存储的数值大小由它们内部存储的字节数决定。

这里是 Go 语言规范中所有相关整数类型的摘录:

图 3.1:Go 语言规范及相关整数类型

图 3.1:Go 语言规范及相关整数类型

此外,还有以下特殊整数类型:

图 3.2:特殊整数类型

图 3.2:特殊整数类型

uintint类型是 32 位或 64 位,这取决于你是否为 32 位系统或 64 位系统编译你的代码。如今,在 32 位系统上运行应用程序的情况很少,因为大多数系统现在都是 64 位的。

在 64 位系统上的int类型不是int64类型。虽然这两个类型是相同的,但它们不是同一整数类型,你不能将它们一起使用。如果 Go 允许这样做,当相同的代码在 32 位机器上编译时会出现问题,因此将它们分开可以确保代码的可靠性。

这种不兼容性不仅仅是int类型的问题;你不能将任何整数类型一起使用。

在定义变量时选择正确的整数类型很容易——使用 int。在编写应用程序代码时,int 大多数情况下都能完成任务。只有当使用 int 类型导致问题时,才考虑使用其他类型。您在 int 类型上遇到的问题通常与内存使用有关。

例如,假设您有一个内存不足的应用程序。该应用程序使用了大量的整数,但这些整数永远不会是负数,并且不会超过 255。一种可能的解决方案是将 int 类型切换为 uint8 类型。这样做可以将每个数字的内存使用从 64 位(8 字节)减少到 8 位(1 字节)。

我们可以通过创建这两种类型的数据集合,然后询问 Go 语言它使用了多少堆内存来展示这一点。输出结果可能因您的计算机而异,但效果应该是相似的。此代码创建了一个包含 intint8 类型的数字集合。然后它向集合中添加了 1000 万个值。一旦完成,它使用运行时包来读取正在使用的堆内存量。我们可以将这个读取结果转换为 MB,然后打印出来:

package main
import (
  "fmt"
  "runtime"
)
func main() {
  var list []int
  //var list []int8
  for i := 0; i < 10000000; i++ {
    list = append(list, 100)
  }
  var m runtime.MemStats
  runtime.ReadMemStats(&m)
  fmt.Printf("TotalAlloc (Heap) = %v MiB\n", m.TotalAlloc/1024/1024)
}

这是使用 int 类型的输出:

TotalAlloc (Heap) = 403 MiB

这是使用 int8 类型的输出:

TotalAlloc (Heap) = 54 MiB

我们在这里节省了相当多的内存,但我们需要 1000 万个值来使其变得有意义。希望现在您已经相信,从 int 类型开始是可行的,只有在出现问题时才需要关注性能。

接下来,我们将探讨浮点数。

浮点数

Go 语言有两种浮点数类型,float32float64。较大的 float64 类型允许在数字中提供更高的精度。float32 类型有 32 位存储空间,而 float64 类型有 64 位存储空间。浮点数将它们的存储空间分配给整数部分(小数点左侧的所有内容)和小数部分(小数点右侧的所有内容)。用于整数部分或小数部分的存储空间大小根据存储的数字而变化。例如,9,999.9 会为整数部分使用更多的存储空间,而 9.9999 会为小数部分使用更多的存储空间。由于 float64 类型有更大的存储空间,它可以存储比 float32 类型更多的整数和/或小数。

练习 3.02 – 浮点数精度

在这个练习中,我们将比较当我们对不能整除的数字进行除法运算时会发生什么。我们将用 100 除以 3。表示结果的一种方式是 33 ⅓。大多数情况下,计算机无法计算这样的分数。相反,它们使用十进制表示法,即 33.3 重复,小数点后的 3 永远重复。如果我们让计算机这样做,它会耗尽所有内存,这并不很有帮助。

幸运的是,我们不需要担心这种情况发生,因为浮点类型有存储限制。缺点是这会导致一个不反映真实结果的数字;结果有一定的误差。你需要对误差的容忍度和你想要给浮点数的存储空间必须得到平衡:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中,将主包名添加到文件顶部:

    package main
    
  3. 现在添加我们将在文件中使用的导入:

    import "fmt"
    
  4. 创建main()函数:

    func main() {
    
  5. 声明一个int变量并将其初始化为 100:

      var a int = 100
    
  6. 声明一个float32变量并将其初始化为 100:

      var b float32 = 100
    
  7. 声明一个float64变量并将其初始化为 100:

      var c float64 = 100
    
  8. 将每个变量除以 3 并将结果打印到控制台:

      fmt.Println(a / 3)
      fmt.Println(b / 3)
      fmt.Println(c / 3)
    }
    
  9. 保存文件,然后在新的文件夹中运行以下命令:

    go run main.go
    

运行前面的代码显示了以下输出,显示了intfloat32float64类型的等效起始值,除以 3:

33
33.333332
33.333333333333336

在这个练习中,我们可以看到计算机不能给出这种除法的完美答案。你还可以看到,在进行这种整数数学运算时,你不会得到错误。Go 会忽略数字的任何分数部分,这通常不是你想要的。我们还可以看到float64给出的答案比float32更精确。

虽然这个限制看起来可能会导致不准确,但在现实世界的商业工作中,它大多数时候都能很好地完成任务。涉及高度精确计数的使用案例,例如在金融和银行业,你需要特别注意 Go 的数值类型,以确保数学准确性。

让我们看看如果我们尝试通过乘以 3 将我们的数字回到 100 会发生什么:

package main
import "fmt"
func main() {
  var a int = 100
  var b float32 = 100
  var c float64 = 100
  fmt.Println((a / 3) * 3)
  fmt.Println((b / 3) * 3)
  fmt.Println((c / 3) * 3)
}

运行前面的代码显示了以下输出:

99
100
100

在这个例子中,我们看到准确性并没有像你预期的那样受到很大影响。乍一看,浮点数学可能看起来很简单,但它很快就会变得复杂。当你定义浮点变量时,通常float64应该是你的首选,除非你需要更高效的内存使用。

接下来,我们将看看当你超出数字类型的限制时会发生什么。

溢出和回绕

当你尝试用一个超出你所用类型范围的值初始化一个数字时,你会得到一个溢出错误。int8类型中你能有的最大数字是 127。在下面的代码中,我们将尝试用 128 来初始化它,看看会发生什么:

package main
import "fmt"
func main() {
  var a int8 = 128
  fmt.Println(a)
}

运行前面的代码会得到以下输出:

图 3.3:用 128 初始化后的输出

图 3.3:用 128 初始化后的输出

这个错误很容易修复,不会引起任何隐藏的问题。真正的问题在于编译器无法捕捉到它。当这种情况发生时,数字将“回绕”。回绕意味着数字从其可能的最大值变为可能的最小值。在开发代码时,回绕可能很容易被忽略,并可能给用户造成重大问题。

练习 3.03 – 触发数字回绕

在这个练习中,我们将声明两种小的整数类型 – int8uint8。我们将它们初始化在其可能的最大值附近。然后我们将使用循环语句每次循环增加 1,然后打印它们的值到控制台。我们将能够看到它们何时回绕:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中,将主包名添加到文件顶部:

    package main
    
  3. 现在添加我们将在此文件中使用的导入:

    import "fmt"
    
  4. 创建main()函数:

    func main() {
    
  5. 声明一个初始值为 125 的int8变量:

      var a int8 = 125
    
  6. 声明一个初始值为 253 的uint8变量:

      var b uint8 = 253
    
  7. 创建一个运行五次的for i循环:

      for i := 0; i < 5; i++ {
    
  8. 将两个变量各加 1:

        a++
        b++
    
  9. 将变量的值打印到控制台:

        fmt.Println(i, ")", "int8 ", a, "uint8 ", b)
    
  10. 关闭循环:

      }
    
  11. 关闭main()函数:

    }
    
  12. 保存文件,并在新文件夹中运行以下命令:

    go run main.go
    

    运行前面的代码会显示以下输出:

图 3.4:回绕后的输出

图 3.4:回绕后的输出

在这个练习中,我们看到了对于有符号整数,你会得到一个负数,而无符号整数则回绕到 0。你必须始终考虑变量的最大可能值,并确保有适当的数据类型来支持这个数字。

接下来,我们将看看当你需要一个比核心类型能提供的更大的数字时你能做什么。

大数

如果你需要一个比int64uint64能存储的数字更高或更低的数字,你可以使用math/big包。与处理整数类型相比,这个包使用起来感觉有点笨拙,但你可以使用它的 API 做所有你通常可以用整数做的事情。

练习 3.04 – 大数

在这个练习中,我们将创建一个比 Go 的核心数字类型能存储的更大的数字。为了演示这一点,我们将使用加法操作。我们还将对int变量做同样的操作以显示差异。然后,我们将打印结果到控制台:

  1. 创建一个新的文件夹并添加一个main.go文件。

  2. main.go中,将主包名添加到文件顶部:

    package main
    
  3. 现在添加我们将在此文件中使用的导入:

    import (
      "fmt"
      "math"
      "math/big"
    )
    
  4. 创建main()函数:

    func main() {
    
  5. 声明一个int变量,并使用math.MaxInt64初始化它,这是 Go 中int64变量的可能最大值,它被定义为常量:

      intA := math.MaxInt64
    
  6. int加 1:

      intA = intA + 1
    
  7. 现在我们将创建一个big int变量。这是一个自定义类型,它不是基于 Go 的int类型。我们还将使用 Go 的最高可能数值初始化它:

      bigA := big.NewInt(math.MaxInt64)
    
  8. 我们将向我们的big int变量加 1。你可以看到这感觉有点笨拙:

      bigA.Add(bigA, big.NewInt(1))
    
  9. 打印出int的最大大小以及我们的 Go intbig int的值:

      fmt.Println("MaxInt64: ", math.MaxInt64)
      fmt.Println("Int   :", intA)
      fmt.Println("Big Int : ", bigA.String())
    
  10. 关闭main()函数:

    }
    
  11. 保存文件,然后在新的文件夹中运行以下命令:

    go run main.go
    

    运行前面的代码会显示以下输出:

图 3.5:使用 Go 的数字类型显示大数字的输出

图 3.5:使用 Go 的数字类型显示大数字的输出

在这个练习中,我们看到了虽然int已经溢出,但big.Int正确地添加了数字。

如果你有一个数值高于 Go 可以处理的数字的情况,那么你需要使用标准库中的big包。接下来,我们将看看用于表示原始数据的特殊 Go 数字类型。

byte

Go 中的byte类型只是uint8的别名,它是一个有八个存储位的数字。实际上,byte是一个重要的类型,你会在很多地方看到它。位是一个单一的二进制值——一个单一的开关。将位分组为八位是早期计算中的常见标准,并成为编码数据的一种几乎通用的方式。8 位有 256 种“关闭”和“开启”的组合,所以uint8有从 0 到 255 的 256 个可能的整数值。所有开启和关闭的组合都可以用这种类型表示。

你会在读取和写入网络连接以及读取和写入文件数据时看到byte的使用。

通过这种方式,我们完成了数字的讨论。现在,让我们看看 Go 如何存储和管理文本。

文本

Go 使用单个string类型来表示文本。

当你将文本写入一个string变量时,它被称为字符串字面量。在 Go 中,有两种字符串字面量:

  • 原始型 - 通过在文本周围添加一对`

  • 解释型 - 通过在文本周围添加一对"来定义

使用原始字面量,最终出现在你的变量中的正是你在屏幕上看到的文本。使用解释型字面量,Go 会扫描你所写的文本,然后根据它自己的规则集应用转换。

这看起来是这样的:

package main
import "fmt"
func main() {
  comment1 := `This is the BEST
thing ever!`
  comment2 := `This is the BEST\nthing ever!`
  comment3 := "This is the BEST\nthing ever!"
  fmt.Print(comment1, "\n\n")
  fmt.Print(comment2, "\n\n")
  fmt.Print(comment3, "\n")
}

运行前面的代码会得到以下输出:

图 3.6:打印文本的输出

图 3.6:打印文本的输出

在解释型字符串中,\n表示换行符。在我们的原始字符串中,\n不会对我们的格式化产生影响,并且会按照我们输入的方式打印出来。要在原始字符串中得到换行符,我们必须在我们的原始字面量中添加一个实际的新行。解释型字符串必须使用\n来得到换行符,因为不允许在解释型字符串中添加真实的新行。

虽然你可以用解释型字符串字面量做很多事情,但在现实世界的代码中,你最常见的两种是\n表示换行符,偶尔还有\t表示制表符。

解释型字符串字面量在现实世界的代码中是最常见的类型,但原始字面量也有其位置。如果你想复制和粘贴包含大量换行符或"\字符的文本,使用原始字面量会更简单。

在下面的示例中,你可以看到使用原始字面量如何使代码更易读:

package main
import "fmt"
func main() {
  comment1 := `In "Windows" the user directory is "C:\Users\"`
  comment2 := "In \"Windows\" the user directory is \"C:\\Users\\\""
  fmt.Println(comment1)
  fmt.Println(comment2)
}

运行前面的代码会显示以下输出:

图 3.7:更易读代码的输出

图 3.7:更易读代码的输出

在原始字面量中,你不能有 ` 字符。如果你需要一个包含 ` 的字面量,你必须使用解释过的字符串字面量。

字符串字面量只是将文本放入 string 变量的方式。一旦你有了变量的值,就没有区别了。

接下来,我们将探讨如何安全地处理多字节字符串。

运行时

rune 是一种具有足够存储空间以存储单个 UTF-8 多字节字符的类型。字符串字面量使用 UTF-8 编码。UTF-8 是一种非常流行和常见的多字节文本编码标准。string 类型本身并不限于 UTF-8,因为 Go 还需要支持其他文本编码类型。string 不限于 UTF-8 意味着在处理字符串时,通常需要额外的一步来防止错误。

不同的编码使用不同数量的字节来编码文本。旧标准使用一个字节来编码一个字符。UTF-8 使用最多四个字节来编码一个字符。当文本在 string 类型中时,为了允许这种可变性,Go 将所有字符串存储为 byte 集合。为了能够安全地对任何类型的文本执行操作,无论是单字节还是多字节,它应该从 byte 集合转换为 rune 集合。

注意

如果你不知道文本的编码,通常将其转换为 UTF-8 是安全的。此外,UTF-8 与单字节编码的文本向后兼容。

Go 使得访问字符串的各个字节变得容易,如下面的示例所示:

  1. 首先,我们定义包,导入所需的库,并创建 main() 函数:

    package main
    import "fmt"
    func main() {
    
  2. 我们将创建一个包含多字节字符的字符串:

      username := "Sir_King_Über"
    
  3. 我们将使用一个 for i 循环来打印出我们字符串的每个字节:

      for i := 0; i < len(username); i++ {
        fmt.Print(username[i], " ")
      }
    
  4. 然后我们将关闭 main() 函数:

    }
    

运行前面的代码会得到以下输出:

图 3.8:根据输入长度显示字节的输出

图 3.8:根据输入长度显示字节的输出

打印出的数字是字符串的字节值。我们的字符串中只有 13 个字母。然而,它包含了一个多字节字符,所以我们打印出了 14 个字节值。

让我们将我们的字节转换回字符串。这种转换使用类型转换,我们将在后面详细讨论:

package main
import "fmt"
func main() {
  username := "Sir_King_Über"
  for i := 0; i < len(username); i++ {
    fmt.Print(string(username[i]), " ")
  }
}

运行前面的代码会得到以下输出:

图 3.9:显示转换为字符串的字节的输出

图 3.9:显示转换为字符串的字节的输出

输出正如预期,直到我们到达 Ü 字符。这是因为 Ü 使用了多个字节进行编码,而单独的字节本身不再有意义。

为了安全地处理多字节字符串的各个字符,你首先必须将 byte 类型的字符串切片转换为 rune 类型的切片。

考虑以下示例:

package main
import "fmt"
func main() {
  username := "Sir_King_Über"
  runes := []rune(username)
  for i := 0; i < len(runes); i++ {
    fmt.Print(string(runes[i]), " ")
  }
}

运行前面的代码会得到以下输出:

图 3.10:显示字符串的输出

图 3.10:显示字符串的输出

如果我们希望像这样在循环中处理每个字符,那么使用 range 会是一个更好的选择。当使用 range 时,它不是一次移动一个 byte,而是每次移动一个 rune。索引是字节偏移量,值是一个 rune 值。

练习 3.05 – 安全遍历字符串

在这个练习中,我们将声明一个字符串,并用多字节字符串值初始化它。然后我们将使用 range 遍历字符串,一次给出一个字符。然后我们将打印出字节索引和字符到控制台:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 文件中,将主包名添加到文件顶部:

    package main
    
  3. 现在添加我们将在此文件中使用的导入:

    import "fmt"
    
  4. 创建 main() 函数:

    func main() {
    
  5. 声明一个包含多字节字符串值的 string 变量:

      logLevel := "デバッグ"
    
  6. 创建一个 range 循环,遍历字符串,然后在变量中捕获 indexrune

      for index, runeVal := range logLevel {
    
  7. indexrune 打印到控制台,将 rune 转换为字符串:

        fmt.Println(index, string(runeVal))
    
  8. 关闭循环:

      }
    
  9. 关闭 main() 函数:

    }
    
  10. 保存文件,并在新文件夹中运行以下命令:

    go run main.go
    

运行前面的代码会得到以下输出:

图 3.11:安全遍历字符串后的输出

图 3.11:安全遍历字符串后的输出

在这个练习中,我们展示了在语言中直接嵌入了对字符串进行安全、多字节遍历的支持。使用这种方法可以防止你得到无效的字符串数据。

另一种常见的查找错误的方法是检查字符串的字符数,通过直接在它上面使用 len。以下是如何错误处理多字节字符串的一个例子:

package main
import "fmt"
func main() {
  username := "Sir_King_Über"
  // Length of a string
  fmt.Println("Bytes:", len(username))
  fmt.Println("Runes:", len([]rune(username)))
  // Limit to 10 characters
  fmt.Println(string(username[:10]))
  fmt.Println(string([]rune(username)[:10]))
}

运行前面的代码会得到以下输出:

图 3.12:使用  函数后显示错误的输出

图 3.12:使用 len 函数后显示错误的输出

你可以看到,当直接在字符串上使用 len 时,你会得到错误的结果。使用这种方式检查数据输入的长度,使用 len 会得到无效的数据。例如,如果我们需要输入正好是八个字符长,而有人输入了多字节字符,那么直接在输入上使用 len 将允许他们输入少于八个字符。

当处理字符串时,请务必首先检查 strings 包。它充满了可能已经完成你所需要的有用工具。

接下来,让我们仔细看看 Go 的特殊 nil 值。

空值

nil不是一个类型,而是一个特殊值。它表示没有类型的空值。当与指针、映射和接口(我们将在下一章中介绍)一起工作时,你需要确保它们不是nil。如果你尝试与一个nil值交互,你的代码将会崩溃。

如果你不能确定一个值是否是nil,你可以这样检查:

package main
import "fmt"
func main() {
  var message [] string
  if message == nil {
    fmt.Println("error, unexpected nil value")
    return
  }
  fmt.Println(message)
}

运行前面的代码将显示以下输出:

error, unexpected nil value

在前面的示例中,我们声明了message变量为一个字符串切片,但没有用任何值初始化它。因此,message的值是 nil。

活动三.01 – 销售税计算器

在这个活动中,我们创建了一个购物车应用程序,其中必须添加销售税来计算总额:

  1. 创建一个计算单个物品销售税的计算器。

  2. 计算器必须接受物品的成本及其销售税率。

  3. 将销售税相加,打印以下物品所需的总销售税金额:

图 3.13:带有销售税率的物品列表

图 3.13:带有销售税率的物品列表

你的输出应该看起来像这样:

Sales Tax Total: 0.1329

注意

该活动的解决方案可以在本章 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter03/Activity03.01

活动三.02 – 贷款计算器

在这个活动中,我们必须为在线财务顾问平台创建一个贷款计算器。我们的计算器应该有以下规则:

  1. 良好的信用评分是 450 分或以上。

  2. 对于良好的信用评分,你的利率是 15%。

  3. 如果你的评分低于良好,你的利率是 20%。

  4. 对于良好的信用评分,你的月供不能超过你月收入的 20%。

  5. 如果你的信用评分不是至少良好,你的月供不能超过你月收入的 10%。

  6. 如果信用评分、月收入、贷款金额或贷款期限小于 0,则返回错误。

  7. 如果贷款期限不能被 12 个月整除,则返回错误。

  8. 利息支付将是贷款金额 * 利率 * 贷款期限的简单计算。

  9. 在完成这些计算后,向用户显示以下详细信息:

    Applicant X
    -----------
    Credit Score : X
    Income : X
    Loan Amount : X
    Loan Term : X
    Monthly Payment : X
    Rate : X
    Total Cost : X
    Approved : X
    

这是预期的输出:

图 3.14:贷款计算器输出

图 3.14:贷款计算器输出

注意

该活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter03/Activity03.02找到。

摘要

在本章中,我们在使用 Go 的类型系统中迈出了重要的一步。我们花费时间定义了类型是什么以及为什么需要它们。然后我们探讨了 Go 中的每个核心类型。我们从简单的 bool 类型开始,并展示了它在我们的代码中是多么关键。接着我们转向数字类型。Go 提供了大量的数字类型,反映了 Go 在内存使用和精度方面喜欢给予开发者的控制。在数字之后,我们研究了字符串的工作原理以及它们与 rune 类型的紧密关系。随着多字节字符的出现,很容易让你的文本数据变得一团糟。Go 提供了强大的内置功能来帮助你正确处理。最后,我们探讨了 nil 以及如何在 Go 中使用它。

本章中你学到的概念为你提供了应对 Go 中更复杂类型(如集合和结构体)所需的知识。我们将在下一章中探讨这些复杂类型。

第四章:复杂类型

概述

本章介绍了 Go 的更复杂类型。这将建立在我们在上一章中关于 Go 核心类型所学的知识之上。当你构建更复杂的软件时,这些复杂类型是必不可少的,因为它们允许你逻辑地将相关数据分组在一起。这种分组数据的能力使得代码更容易理解、维护和修复。

到本章结束时,你将能够使用数组、切片和映射来分组数据。你将学习根据核心类型创建自定义类型。你还将学习使用结构体来创建由任何其他类型的命名字段组成的结构,并解释 interface{} 的重要性。

技术要求

对于本章,你需要 Go 版本 1.21 或更高。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04

简介

在上一章中,我们介绍了 Go 的核心类型。这些类型对于你在 Go 中所做的所有事情都至关重要,但建模更复杂的数据可能具有挑战性。在现代计算机软件中,我们希望尽可能地将数据和逻辑分组在一起。我们还希望我们的逻辑能够反映我们正在构建的现实世界解决方案。

如果你正在为汽车编写软件,理想情况下你希望有一个体现汽车的定制类型。这个类型应该命名为“car”,并且它应该有属性可以存储有关它是哪种汽车的信息。影响汽车的逻辑,如启动和停止,应该与汽车类型相关联。如果我们需要管理多辆汽车,我们需要能够将所有汽车分组在一起。

在本章中,我们将学习 Go 中允许我们建模此挑战数据部分的特性。然后,在下一章中,我们将解决行为部分。通过使用自定义类型,你可以扩展 Go 的核心类型,使用结构体允许你组合由其他类型组成的类型,并将逻辑与它们关联起来。集合允许你将数据分组在一起,并允许你遍历和操作它们。

随着你任务的复杂性增加,Go 的复杂类型可以帮助你保持代码易于理解和维护。例如数组、切片和映射等集合允许你将相关数据分组在一起。Go 的 struct 类型允许你创建由其他字符串、数字和布尔值组成的单一类型,这让你能够构建复杂现实世界概念的模型。结构体还允许你将逻辑附加到它们上;这允许你将控制模型的逻辑紧密地结合在一起。

当类型变得复杂时,我们需要知道如何使用类型转换和断言来正确地处理类型不匹配。我们还将查看 Go 的 interface{} 类型。这种类型几乎是神奇的,因为它允许你克服 Go 的结构类型系统,但以一种仍然类型安全的方式。

集合类型

如果你只处理一个电子邮件地址,你会定义一个字符串变量来为你保存该值。现在,考虑如果你需要处理 0 到 100 个电子邮件地址,你会如何组织你的代码。你可以为每个电子邮件地址定义一个单独的变量,但 Go 有其他我们可以使用的东西。

当处理大量类似数据时,我们会将其放入集合中。Go 的集合类型包括数组、切片和映射。Go 的集合类型是强类型的,并且易于循环遍历,但它们各自具有独特的特性,意味着它们更适合不同的用例。

数组

Go 最基本的集合类型是数组。当你定义一个数组时,你必须指定它可以包含哪种类型的数据以及数组的大小,以下形式:[<size>]<type>。例如,[10]int 是一个包含整数的长度为 10 的数组,而 [5]string 是一个包含字符串的长度为 5 的数组。使这成为一个数组的关键是指定大小。如果你的定义没有大小,它看起来像可以工作,但它不会是一个数组——它将是一个切片。切片是不同于数组的一种更灵活的集合类型,我们将在数组之后讨论。您可以设置元素值为任何类型,包括指针和数组。

您可以使用以下形式使用数据初始化数组:[<size>]<type>{<value1>,<value2>,…<valueN>}。例如,[5]string{1} 会初始化一个数组,其第一个值为 1,而 [5]string{9,9,9,9,9} 会将每个元素填充为数值九。当使用数据初始化时,您可以让 Go 根据您初始化时使用的元素数量来设置数组的大小。您可以通过将长度数字替换为 ... 来利用这一点。例如,[...]string{9,9,9,9,9} 会创建一个长度为五的数组,因为我们用五个元素初始化了它。就像所有数组一样,长度是在编译时设置的,并且在运行时不可更改。

练习 4.01 – 定义数组

在这个练习中,我们将定义一个包含整数的简单数组,大小为十。然后,我们将打印出其内容。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 中,添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数来定义一个数组,然后返回它:

    func defineArray() [10]int {
      var arr [10]int
      return arr
    }
    
  4. 定义一个 main 函数,调用该函数,并打印结果。我们将使用 fmt.Printf%#v 来获取关于值的额外详细信息,包括其类型:

    func main() {
      fmt.Printf("%#v\n", defineArray())
    }
    
  5. 保存此文件。然后,在新的文件夹中,运行以下命令:

    go run .
    

运行前面的代码会给我们以下输出:

[10]int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}

在这个练习中,我们定义了一个数组,但没有填充任何数据。由于所有数组都有固定的大小,当数组被打印出来时,它包含了 10 个值。这些值是数组接受的任何类型的空值。

比较数组

数组的长度是其类型定义的一部分。如果你有两个接受相同类型的数组但大小不同,它们是不兼容的,并且不能相互比较。要比较数组,它们必须具有相同的长度(大小)和类型。

练习 4.02 – 比较数组

在这个练习中,我们将比较数组。首先,我们将定义几个数组;其中一些是可比较的,而另一些则不是。然后,我们将运行代码并修复出现的任何问题。让我们开始吧:

  1. 创建一个新的文件夹并添加一个 main.go 文件到其中。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数,定义四个数组:

    func compArrays() (bool, bool, bool) {
      var arr1 [5]int
      arr2 := [5]int{0}
      arr3 := [...]int{0, 0, 0, 0, 0}
      arr4 := [9]int{0, 0, 0, 0, 9}
    
  4. 比较这些数组并返回比较的结果。这完成了这个函数:

      return arr1 == arr2, arr1 == arr3, arr1 == arr4
    }
    
  5. 定义一个 main 函数,使其打印出结果:

    func main() {
      comp1, comp2, comp3 := compArrays()
      fmt.Println("[5]int == [5]int{0}       :", comp1)
      fmt.Println("[5]int == [...]int{0, 0, 0, 0, 0}:", comp2)
      fmt.Println("[5]int == [9]int{0, 0, 0, 0, 9} :", comp3)
    }
    
  6. 保存并运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.1:数组类型不匹配错误

图 4.1:数组类型不匹配错误

你应该看到一个错误。这个错误告诉你,arr1,它是一个 [5] int 类型的数组,和 arr4,它是一个 [9] int 类型的数组,它们的长度不同,因此在 Go 中它们不是同一类型的数组,这意味着它们是不可比较的。让我们来修复这个问题。

  1. 这里,我们有以下内容:

      arr4 := [9]int{0, 0, 0, 0, 9}
    

    我们需要将其替换为以下内容:

      arr4 := [5]int{0, 0, 0, 0, 9}
    
  2. 我们还有以下代码:

      fmt.Println("[5]int == [9]int{0, 0, 0, 0, 9} :", comp3)
    

    我们需要将其替换为以下内容:

      fmt.Println("[5]int == [5]int{0, 0, 0, 0, 9} :", comp3)
    
  3. 使用以下命令再次保存并运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.2:无错误输出

图 4.2:无错误输出

在我们的练习中,我们定义了一些数组,它们都是用稍微不同的方式定义的。起初,我们有一个错误,因为我们尝试比较不同长度的数组,在 Go 中这意味着它们是不同类型的。我们修复了这个问题,并再次运行了代码。然后,我们可以看到,尽管前三个数组是用不同的方法定义的,但它们最终是相同的或彼此相等。最后一个数组,现在类型已修复,包含不同的数据,所以它不与其它数组相同或相等。其他集合类型,即 slicemap,以这种方式是不可比较的。对于映射和切片,你必须遍历你正在比较的两个集合的内容,并手动比较它们。这种能力使得数组在比较集合中的数据是代码中的热点路径或频繁操作时具有优势。

使用键初始化数组

到目前为止,当我们用数据初始化数组时,我们让 Go 为我们选择键。键指的是用于初始化数组中特定值的索引或位置。通过使用键在特定索引处设置值,您可以在特定位置初始化具有所需值的数组,同时保留其他元素为默认值。Go 允许您使用 [<size>]<type>{<key1>:<value1>,…<keyN>:<valueN>} 选择您想要的数据键。Go 很灵活,允许您设置带有间隔的键,并且可以按任何顺序设置。如果您定义了一个数组,其中数字键具有特定的含义,并且您想为特定的键设置值,但不需要设置其他任何值,那么使用键来初始化数组可以提供更大的灵活性和对数组中值放置的控制。

练习 4.03 – 使用键初始化数组

在这个练习中,我们将使用一些键初始化几个数组,并设置特定的值。然后,我们将比较它们。之后,我们将打印出一个数组并查看其内容。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个 main.go 文件。

  2. main.go 文件中,添加包和导入语句:

    package main
    import "fmt"
    
  3. 创建一个函数,定义三个数组:

    func compArrays() (bool, bool, [10]int) {
      var arr1 [10]int
      // set key 9 to value 0
      arr2 := [...]int{9: 0}
      // set key 0 to value 1, set key 9 to value 10,
      // and set key 4 to value 5
      arr3 := [10]int{1, 9: 10, 4: 5}
    
  4. 比较数组并返回最后一个,以便我们稍后打印出来:

      return arr1 == arr2, arr1 == arr3, arr3
    }
    
  5. 创建一个 main 函数并调用 compArrays。然后,打印出结果:

    func main() {
      comp1, comp2, arr3 := compArrays()
      fmt.Println("[10]int == [...]{9:0}       :", comp1)
      fmt.Println("[10]int == [10]int{1, 9: 10, 4: 5}}:", comp2)
      fmt.Println("arr3               :", arr3)
    }
    
  6. 保存文件。然后,在新建的文件夹中,运行以下命令:

    go run .
    

运行前面的代码会产生以下输出:

图 4.3:使用键初始化的数组

图 4.3:使用键初始化的数组

在这个练习中,我们使用键初始化了数组的数组数据。对于 arr2,我们结合了 ... 省略符和设置键,使数组长度直接与设置的键相关联。对于 arr3,我们混合了使用键和不使用键来初始化数组,并且在设置键 0 为值 1、键 9 为值 10 和键 4 为值 5 时,我们使用了不按顺序的键。Go 在使用键时的灵活性很强,使得以这种方式使用数组变得愉快。

现在我们已经了解了初始化数组,接下来让我们进一步看看如何读取它们的值。

从数组中读取

到目前为止,我们已经定义了一个数组并用一些数据初始化了它。现在,让我们读取这些数据。可以使用 <array>[<index>] 来访问数组的一个单独元素。例如,这访问了数组的第一个元素,arr[0]。我知道 0 是数组的第一个元素,因为数组总是使用零索引的整数键。零索引意味着数组的第一个索引总是 0,最后一个索引总是数组的长度减 1。

数组中元素的顺序是有保证的稳定的。顺序稳定性意味着放置在索引 0 处的项总是数组中的第一个项。

能够访问数组的特定部分在几种情况下可能很有用。通常需要通过检查第一个和/或最后一个元素来验证数组中的数据。有时,数据在数组中的位置很重要,例如,您可以从第三个索引获取产品名称。这种位置重要性在读取 逗号分隔值CSV)文件或其他类似分隔符分隔的值文件时很常见。CSV 仍然很常见,因为它是从电子表格文档导出数据的一个流行选择。

练习 4.04 – 从数组中读取单个项目

在这个练习中,我们将定义一个数组并用一些单词初始化它。然后,我们将以消息的形式读取单词并打印它。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数,该函数定义一个包含单词的数组。单词的顺序很重要:

    func message() string {
      arr := [...]string{
       "ready",
       "Get",
       "Go",
       "to",
      }
    
  4. 现在,通过以特定顺序连接单词来创建一条消息并返回它。我们在这里使用 fmt.Sprintln 函数,因为它允许我们在打印之前捕获格式化的文本:

      return fmt.Sprintln(arr[1], arr[0], arr[3], arr[2])
    }
    
  5. 创建我们的 main() 函数,调用 message 函数,并将其打印到控制台:

    func main() {
      fmt.Print(message())
    }
    
  6. 保存并运行代码:

    go run .
    

运行前面的代码会产生以下输出:

Get ready to Go

我们现在已经了解了如何使用索引访问数组中的特定元素。接下来,我们将探索如何向数组写入。

向数组写入

一旦定义了一个数组,您就可以使用其索引来更改单个元素,格式为 <array>[<index>] = <value>。这种赋值方式与其他类型的变量赋值方式相同。

在现实世界的代码中,您通常需要在定义集合后根据输入或逻辑修改数据。

练习 4.05 – 向数组写入

在这个练习中,我们将定义一个数组并用一些单词初始化它。然后,我们将对单词进行一些修改。最后,我们将读取单词以形成一条消息并打印它。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数,该函数定义一个包含单词的数组。单词的顺序很重要:

    func message() string {
      arr := [4]string{"ready", "Get", "Go", "to"}
    
  4. 我们将通过使用数组索引分配新值来更改数组中的某些单词。这种操作的顺序并不重要:

      arr[1] = "It’s"
      arr[0] = "time"
    
  5. 现在,通过以特定顺序连接单词来创建一条消息并返回它:

      return fmt.Sprintln(arr[1], arr[0], arr[3], arr[2])
    }
    
  6. 创建我们的 main() 函数,调用 message 函数,并将其打印到控制台:

    func main() {
      fmt.Print(message())
    }
    
  7. 保存并运行代码:

    go run .
    

运行前面的代码会产生以下输出:

It’s time to Go

您现在已经了解了使用索引初始化、读取和向数组写入的基本知识。既然我们知道数组包含多个值,让我们看看如何遍历数组。

遍历数组

你最常使用数组的方式是在循环中使用它们。由于数组索引的工作方式,它们很容易被循环。索引始终从 0 开始,没有间隔,最后一个元素是数组的长度减 1。

由于这个原因,也常常使用循环来创建一个变量来表示索引并手动增加它。这种类型的循环通常被称为for i循环,因为i是分配给索引变量的名字。

注意

你可以使用除i以外的不同字母;然而,在循环数组的情况下,i非常代表单词索引,因此从风格上讲代表了 Go 的惯用用法。

如你从上一章所记得,for循环有三个可能的部分:循环前可以运行的逻辑、每次循环交互时运行的逻辑以检查循环是否应该继续,以及每次循环迭代结束时运行的逻辑。一个for i循环看起来像这样:i := 0; i < len(arr); i++ {。发生的事情是我们将i定义为零,这也意味着i只存在于循环的作用域内。然后,在循环的迭代中检查i以确保它小于数组的长度。我们检查它是否小于数组的长度,因为长度总是比最后一个索引键多 1。最后,我们在每次循环中增加i以让我们逐个遍历数组中的每个元素,直到我们达到数组的长度。

当涉及到数组的长度时,可能会诱使你直接硬编码最后一个索引的值而不是使用len,因为你知道数组的长度始终是相同的。硬编码长度是一个坏主意。硬编码会使你的代码更难维护。你的数据通常会发生变化和演变。如果你需要回来更改数组的尺寸,硬编码的数组长度会引入难以发现的错误,甚至可能导致运行时恐慌。在 Go 中,运行时恐慌基本上是在程序遇到它无法或不应从中恢复的异常情况时发生的事件。当触发恐慌时,程序将立即终止。

使用循环和数组允许你对每个元素重复相同的逻辑——即验证数据、修改数据或输出数据——而无需为多个变量重复相同的代码。

练习 4.06 - 使用“for i”循环遍历数组

在这个练习中,我们将定义一个数组并用一些数字初始化它。我们将遍历这些数字并对每个数字执行一个操作,将结果放入消息中。然后,我们将返回消息并打印它。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为main.go的文件。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数。在循环之前,我们将定义一个包含数据的数组和m变量:

    func message() string {
      m := ""
      arr := [4]int{1,2,3,4}
    
  4. 定义循环的开始。这管理索引和循环:

      for i := 0; i < len(arr); i++ {
    
  5. 然后,编写循环体,对数组的每个元素执行操作并将其添加到消息中:

       arr[i] = arr[i] * arr[i]
       m += fmt.Sprintf("%v: %v\n", i, arr[i])
    
  6. 现在,关闭循环,返回消息,并关闭函数:

      }
      return m
    }
    
  7. 创建我们的main函数,调用message函数,并将其打印到控制台:

    func main() {
      fmt.Print(message())
    }
    
  8. 保存此代码。然后,从新文件夹中运行代码:

    go run .
    

在使用for i循环遍历数组并将值自乘后,运行前面的代码会产生以下输出:

0: 1
1: 4
2: 9
3: 16

for i循环非常常见,所以请密切关注for循环,并确保你理解每个部分的作用。

注意

len。这个特性也适用于其他集合类型;即切片和映射。

在循环中修改数组的内容

除了在循环中从数组中读取内容外,你还可以在循环中更改数组的内容。在数组中的每个元素上工作就像在工作变量上一样。你也会使用相同的for i循环。

就像从数组中读取数据一样,能够更改集合中的数据可以减少你需要编写的代码量,如果每个元素都是一个独立的变量。

练习 4.07 – 在循环中修改数组的内容

在这个练习中,我们将定义一个空数组,用数据填充它,然后修改这些数据。最后,我们将打印填充和修改后的数组到控制台。让我们开始吧:

  1. 创建一个新文件夹并将一个名为main.go的文件添加到其中。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数,用从 1 到 10 的数字填充数组:

    func fillArray(arr [10]int) [10]int {
      for i := 0; i < len(arr); i++ {
       arr[i] = i + 1
      }
      return arr
    }
    
  4. 创建一个函数,将数组中的数字自乘并将结果设置回数组:

    func opArray(arr [10]int) [10]int {
      for i := 0; i < len(arr); i++ {
        arr[i] = arr[i] * arr[i]
      }
      return arr
    }
    
  5. 在我们的main()函数中,我们需要定义我们的空数组,填充它,修改它,然后将内容打印到控制台:

    func main() {
      var arr [10]int
      arr = fillArray(arr)
      arr = opArray(arr)
      fmt.Println(arr)
    }
    
  6. 保存此代码。然后,从新文件夹中运行代码:

    go run .
    

运行前面的代码会产生以下输出:

[1 4 9 16 25 36 49 64 81 100]

一旦你了解了如何在for i循环中使用数组,处理数组中的数据就变得简单了。与处理其他集合相比,处理数组的一个优点是它们的固定长度。使用数组时,不可能意外地更改数组的大小,从而导致无限循环,这是一种无法结束的循环,会导致软件无限期地运行并消耗大量资源。

活动 4.01 – 填充数组

在这个活动中,我们将定义一个数组并使用for i循环填充它。以下是这个活动的步骤:

  1. 创建一个新的 Go 程序。

  2. 定义一个包含 10 个元素的数组。

  3. 使用for i循环将数字 1 到 10 填充到数组中。

  4. 使用fmt.Println将数组打印到控制台。

预期输出如下:

[1 2 3 4 5 6 7 8 9 10]

注意

这个活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.01

切片

数组很棒,但它们在大小上的刚性可能会导致问题。如果你想要创建一个接受数组并对其中的数据进行排序的函数,它只能针对一个数组大小工作。这需要你为每种大小的数组创建一个函数。这种对大小的严格性使得使用数组感觉像是一种麻烦和不吸引人的体验。数组的另一面是,它们是管理排序数据集合的高效方式。如果有一种方法可以同时获得数组的效率和更大的灵活性,那岂不是很好?Go 通过切片的形式为你提供了这一点。

切片是围绕数组的一层薄层,它让你可以拥有一个排序的数值索引集合,而无需担心大小。在薄层下面仍然是 Go 数组,但 Go 管理所有细节,例如使用多大的数组。你使用切片的方式就像使用数组一样;它只持有一种类型的值,你可以使用[]读取和写入每个元素,并且它们很容易使用for i循环进行遍历。

切片还可以通过内置的append函数轻松扩展。这个函数接受你的切片和要添加的值,并返回一个包含所有内容的新的切片。通常,我们会从一个空切片开始,并根据需要扩展它。

由于切片是围绕数组的一层薄层,这意味着它不是一个真正的类型,就像数组一样。你需要了解 Go 如何使用切片背后的隐藏数组。如果你不了解这一点,可能会导致微妙且难以调试的错误。

在现实世界的代码中,你应该将切片作为所有排序集合的首选。你将更加高效,因为你不需要像使用数组那样编写那么多代码。你将在现实世界的项目中看到的大部分代码都使用了大量的切片,而很少使用数组。数组仅在需要确切长度时使用,即使在这种情况下,切片也通常被使用,因为它们可以更容易地在代码中传递。

练习 4.08 – 使用切片

在这个练习中,我们将通过从切片中读取一些数据、将切片传递给函数、遍历切片、从切片中读取值以及将值追加到切片的末尾来展示切片的灵活性。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为main.go的文件。

  2. main.go中,添加包和导入:

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 创建一个函数,该函数接受一个int参数并返回一个string切片:

    func getPassedArgs(minArgs int) []string {
    
  4. 在函数体中,检查是否通过命令行传递了正确的参数数量。如果不是,我们带错误退出程序。当我们从命令行运行程序时,Go 会自动将所有参数放入 os.Args,它是一个字符串切片:

      if len(os.Args) < minArgs {
        fmt.Printf("At least %v arguments are needed\n", minArgs)
        os.Exit(1)
      }
    
  5. os.Args 切片的第一个元素 os.Args[0] 是如何调用代码的,而不是一个参数,所以我们将跳过它,并从索引 1 开始我们的 for i 循环:

      var args []string
      for i := 1; i < len(os.Args); i++ {
        args = append(args, os.Args[i])
      }
    
  6. 然后,我们将返回参数:

      return args
    }
    
  7. 现在,创建一个函数,该函数遍历一个切片并找到最长的字符串。当两个单词长度相同时,返回第一个单词:

    func findLongest(args []string) string {
      var longest string
      for i := 0; i < len(args); i++ {
        if len(args[i]) > len(longest) {
          longest = args[i]
           }
      }
      return longest
    }
    
  8. main() 函数中,我们调用函数并检查错误。如果有错误,我们告诉用户然后终止程序使用 os.Exit(1),这也会将错误代码 1 返回给操作系统:

    func main() {  
     if longest := findLongest(getPassedArgs(3)); len(longest) > 0 {
         fmt.Println("The longest word passed was:", longest)
      } else {
        fmt.Println("There was an error")
        os.Exit(1)
      }
    }
    
  9. 保存文件。然后,在保存文件的文件夹中,使用以下命令运行代码:

    go run . Get ready to Go
    

运行前面的代码会产生以下输出:

The longest word passed was: ready

在这个练习中,我们能够看到切片的灵活性以及它们如何像数组一样工作。这种使用切片的方式是 Go 感觉像是一种动态语言的原因之一。

向切片中添加多个项目

内置的 append 函数可以将多个值添加到切片中。由于最后一个参数是可变参数,因此你可以向 append 添加任意数量的参数。由于它是可变参数,这意味着你也可以使用 ... 符号将切片作为可变参数使用,允许你向 append 传递任意数量的参数。

在实际代码中,经常需要向 append 传递多个参数,并且拥有它可以使 Go 代码更加紧凑,因为它不需要多次调用或循环来添加多个值。

练习 4.09 – 向切片中添加多个项目

在这个练习中,我们将使用 append 的可变参数来将预定义数据的形式添加到切片中。然后,我们将根据用户输入动态添加数据到同一个切片中。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中,添加包和导入:

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 创建一个安全获取用户输入的函数:

    func getPassedArgs() []string {
      var args []string
      for i := 1; i < len(os.Args); i++ {
        args = append(args, os.Args[i])
      }
      return args
    }
    
  4. 创建一个接受字符串切片作为参数并返回字符串切片的函数。然后定义一个 strings 类型的切片变量:

    func getLocales(extraLocales []string) []string {
      var locales []string
    
  5. 使用 append 方法向切片中添加多个字符串:

      locales = append(locales, "en_US", "fr_FR")
    
  6. 从参数中添加更多数据:

      locales = append(locales, extraLocales...)
    
  7. 返回变量并关闭函数定义:

      return locales
    }
    
  8. main() 函数中,获取用户输入,将其传递给我们的函数,然后打印结果:

    func main() {
      locales := getLocales(getPassedArgs())
      fmt.Println("Locales to use:", locales)
    }
    
  9. 保存文件。然后,在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run . fr_CN en_AU
    

运行前面的代码会产生以下输出:

Locales to use: [en_US fr_FR fr_CN en_AU]

在这个练习中,我们使用了两种向切片添加多个值的方法。如果你需要将两个切片合并在一起,你也会使用这种技术。

虽然将这样的切片附加到另一个切片以添加它可能看起来效率不高,但 Go 运行时可以检测到你正在执行 append 操作,并在后台优化调用以确保不浪费资源。

从切片和数组创建切片

通过使用与访问数组或切片中单个元素类似的符号,你可以从数组或切片的内容中创建新的切片。最常用的符号是[<low>:<high>]。这个符号告诉 Go 创建一个新的切片,其值类型与源切片或数组相同,并通过从低索引开始填充新切片,直到但不包括高索引的值。低和高是可选的。如果你省略了低,那么 Go 默认为源的第一个元素。如果你省略了高,那么它将一直到最后一个值。你可以同时省略两者,如果你这样做,那么新切片将包含源中的所有值。

以这种方式创建新切片时,Go 不会复制值。如果源是数组,那么该源数组是新切片的隐藏数组。如果源是切片,那么新切片的隐藏数组与源切片使用的相同隐藏数组。

这是一个重要的概念,因为修改任何切片也会改变其底层数组,而不是它的副本。将切片视为底层数组的一个“视图”。

练习 4.10 – 从切片创建切片

在这个练习中,我们将使用切片范围符号来创建具有各种初始值的切片。在实际代码中,通常只需要处理切片或数组的一小部分。range符号是一种快速直接的方法,可以只获取所需的数据。让我们开始吧:

  1. 创建一个新的文件夹并将一个名为main.go的文件添加到其中。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个函数并定义一个包含九个int值的切片:

    func message() string {
      s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
    
  4. 我们将提取第一个值,首先直接作为int值,然后作为使用低和高索引的切片,最后只使用高索引并跳过低索引。我们将这些值写入消息字符串:

      m := fmt.Sprintln("First :", s[0], s[0:1], s[:1])
    
  5. 现在,我们将获取最后一个元素。为了获取int值,我们将使用长度并从索引中减去 1。我们使用相同的逻辑来设置范围的低。对于高,我们可以使用切片的长度。最后,我们可以看到我们可以跳过高并得到相同的结果:

      m += fmt.Sprintln("Last  :", s[len(s)-1], s[len(s)-1:len(s)], s[len(s)-1:])
    
  6. 现在,让我们获取前五个值并将它们添加到消息中:

      m += fmt.Sprintln("First 5 :", s[:5])
    
  7. 接下来,我们将获取最后四个值并将它们添加到消息中:

      m += fmt.Sprintln("Last 4 :", s[5:])
    
  8. 最后,我们将从切片的中间提取五个值并将它们也添加到消息中:

      m += fmt.Sprintln("Middle 5:", s[2:7])
    
  9. 然后,我们将返回消息并关闭函数:

      return m
    }
    
  10. main中,我们将打印消息:

    func main() {
      fmt.Print(message())
    }
    
  11. 保存文件。然后,在你在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.5:从切片创建切片后的输出

图 4.5:从切片创建切片后的输出

在这个练习中,我们尝试了几种从另一个切片创建切片的方法。你也可以将这些相同的技巧用于数组作为源。我们注意到起始索引和停止索引都是可选的。如果你没有起始索引,它将从源切片或数组的开始处开始。如果你没有停止索引,那么它将在数组的末尾停止。如果你省略了起始索引和停止索引,它将创建切片或数组的副本。这个技巧对于将数组转换为切片很有用,但对于复制切片却没有帮助,因为两个切片共享同一个隐藏数组。

理解切片内部机制

切片很棒,当你需要有序列表时应该首选使用它,但如果你不知道它们在底层是如何工作的,它们可能会导致难以发现的错误。

数组是一种类似于stringint类型的值类型。值类型可以被复制并与其自身进行比较。这些值类型一旦被复制,就不会与它们的源值连接。切片的工作方式不像值类型;它们更像指针,但它们也不是指针。

使用切片保持安全的关键在于理解存在一个隐藏数组用于存储值,并且对切片的更改会改变底层数组。这些更改可能需要也可能不需要用更大的数组替换隐藏数组。隐藏数组的管理在后台发生的事实使得很难很好地推理切片中正在发生的事情。

切片有三个隐藏属性:长度、指向隐藏数组的指针以及隐藏数组中起始点的位置。当你向切片追加内容时,这些属性中的一个或多个会得到更新。哪些属性会更新取决于隐藏数组是否已满。

隐藏数组的大小和切片的大小并不总是相同。切片的大小是其长度,我们可以通过使用len内置函数来找出它。隐藏数组的大小是切片的容量。还有一个内置函数可以告诉你切片的容量;即cap。当你使用append向切片添加新值时,会发生两件事之一:如果切片有额外的容量——也就是说,隐藏数组还没有满——它将值添加到隐藏数组中,然后更新切片的长度属性。如果隐藏数组已满,Go 将创建一个新的更大的数组。然后,Go 将所有值从旧数组复制到新数组中,并添加新值。然后,Go 更新切片,使其从指向旧数组变为指向新数组,并更新切片的长度以及可能的位置。

只有当切片是数组或从非第一个元素开始的切片的值的一个子集时,起始点才会发挥作用,就像在我们的例子中,我们得到了切片的最后五个元素。其余时间,它将是隐藏数组中的第一个元素。

当你定义切片时,可以控制隐藏数组的大小。Go 的内置make函数允许你在创建切片时设置其长度和容量。语法如下:make(<sliceType>, <length>, <capacity>)。当使用make创建切片时,容量是可选的,但长度是必需的。

练习 4.11 – 使用 make 控制切片的容量

在这个练习中,使用make函数,我们将创建几个切片并显示它们的长度和容量。让我们开始吧:

  1. 创建一个新的文件夹并添加一个名为main.go的文件。

  2. main.go中,添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个返回三个int切片的函数:

    func genSlices() ([]int, []int, []int) {
    
  4. 使用var表示法定义一个切片:

      var s1 []int
    
  5. 使用make定义一个切片并只设置长度:

      s2 := make([]int, 10)
    
  6. 定义一个同时使用切片长度和容量的切片:

      s3 := make([]int, 10, 50)
    
  7. 返回三个切片并关闭函数定义:

      return s1, s2, s3
    }
    
  8. main()函数中,调用我们创建的函数并捕获返回的值。对于每个切片,将其长度和容量打印到控制台:

    func main() {
      s1, s2, s3 := genSlices()
      fmt.Printf("s1: len = %v cap = %v\n", len(s1), cap(s1))
      fmt.Printf("s2: len = %v cap = %v\n", len(s2), cap(s2))
      fmt.Printf("s3: len = %v cap = %v\n", len(s3), cap(s3))
    }
    
  9. 保存文件。然后,在你在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.6:显示切片的输出

图 4.6:显示切片的输出

在这个练习中,我们使用了makelencap来在定义切片时控制和显示切片的长度和容量。

如果你已经知道切片所需的最大大小,提前设置容量可以提高性能,因为 Go 不需要花费额外的资源来调整底层数组的大小。

切片的后台行为

由于切片是什么以及它是如何工作的复杂性,你不能直接比较两个切片。如果你尝试这样做,Go 会给你一个错误。你可以将切片与 nil 进行比较,但仅此而已。

切片不是一个值,也不是一个指针,那么它是什么?切片是 Go 语言中的一个特殊构造。切片不会直接存储自己的值。在后台,它使用一个你无法直接访问的数组。切片存储的是指向那个隐藏数组的指针,它在数组中的起始点,切片的长度,以及切片的容量。这些值为切片提供了一个查看隐藏数组的窗口。这个窗口可以是整个隐藏数组,也可以是它的一部分。隐藏数组的指针可以被多个切片共享。这种指针共享可以导致多个切片共享同一个隐藏数组,即使不是所有的切片都包含相同的数据。这意味着其中一个切片可能比其他切片包含更多的数据。

当一个切片需要超出其隐藏数组的大小时,它会创建一个新的更大的数组,将旧数组的内容复制到新数组中,并将切片指向新数组。这种数组交换是为什么我们之前的切片变得不连接。最初,它们都指向同一个隐藏数组,但当我们扩展第一个切片时,它所指向的数组发生了变化。这种变化意味着扩展的切片的更改不再影响其他切片,因为它们仍然指向旧的、较小的数组。

如果你需要复制一个切片并且确保它们不相互连接,你有几种选择。你可以使用 append 将源切片的内容复制到另一个数组中,或者使用内置的 copy 函数。当使用 copy 时,Go 不会改变目标切片的大小,所以请确保它有足够的空间来存放你想要复制的所有元素。

练习 4.12 – 控制切片的内部行为

在这个练习中,我们将探索五种不同的从切片到切片复制数据的方法以及这对切片的内部行为有何影响。让我们开始吧:

  1. 在新文件夹中创建一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个返回三个 int 类型的值的函数:

    func linked() (int, int, int) {
    
  4. 定义一个初始化了某些数据的 int 切片:

      s1 := []int{1, 2, 3, 4, 5}
    
  5. 然后,我们将对该切片进行简单的变量复制:

      s2 := s1
    
  6. 通过切片范围操作将第一个切片的所有值复制到新切片中:

      s3 := s1[:]
    
  7. 在第一个切片中更改一些数据。稍后,我们将看到这如何影响第二个和第三个切片:

      s1[3] = 99
    
  8. 为每个切片返回相同的索引并关闭函数定义:

      return s1[3], s2[3], s3[3]
    }
    
  9. 创建一个将返回两个 int 类型的值的函数:

    func noLink() (int, int) {
    
  10. 定义一个包含一些数据的切片,然后再进行简单的复制:

      s1 := []int{1, 2, 3, 4, 5}
      s2 := s1
    
  11. 这次,我们在做任何事情之前将向第一个切片中追加。这个操作改变了切片的长度和容量:

      s1 = append(s1, 6)
    
  12. 然后,我们将更改第一个切片,从两个切片中返回相同的索引,并关闭函数:

      s1[3] = 99
      return s1[3], s2[3]
    }
    
  13. 在我们的下一个函数中,我们将返回两个 int 类型的值:

    func capLinked() (int, int) {
    
  14. 这次,我们将使用 make 定义第一个切片。当我们这样做时,我们将设置一个比其长度更大的容量:

      s1 := make([]int, 5, 10)
    
  15. 让我们用之前相同的数据填充第一个数组:

      s1[0], s1[1], s1[2], s1[3], s1[4] = 1, 2, 3, 4, 5
    
  16. 现在,我们将通过复制第一个切片来创建一个新的切片,就像我们之前做的那样:

      s2 := s1
    
  17. 我们将在第一个切片中追加一个新值,这将改变其长度但不会改变其容量:

      s1 = append(s1, 6)
    
  18. 然后,我们将更改第一个切片,从两个切片中返回相同的索引,并关闭函数:

      s1[3] = 99
      return s1[3], s2[3]
    }
    
  19. 在这个函数中,我们再次使用 make 来设置容量,但我们将使用 append 来添加将超出该容量的元素:

    func capNoLink() (int, int) {
      s1 := make([]int, 5, 10)
      s1[0], s1[1], s1[2], s1[3], s1[4] = 1, 2, 3, 4, 5
      s2 := s1
      s1 = append(s1, []int{10: 11}...)
      s1[3] = 99
      return s1[3], s2[3]
    }
    
  20. 在下一个函数中,我们将使用 copy 将第一个切片的元素复制到第二个切片中。copy 返回从一个切片复制到另一个切片的元素数量,所以我们也返回这个值:

    func copyNoLink() (int, int, int) {
      s1 := []int{1, 2, 3, 4, 5}
      s2 := make([]int, len(s1))
      copied := copy(s2, s1)
      s1[3] = 99
      return s1[3], s2[3], copied
    }
    
  21. 在最终的函数中,我们将使用append将值复制到第二个切片中。以这种方式使用append会导致值被复制到一个新的隐藏数组中:

    func appendNoLink() (int, int) {
      s1 := []int{1, 2, 3, 4, 5}
      s2 := append([]int{}, s1...)
      s1[3] = 99
      return s1[3], s2[3]
    }
    
  22. main函数中,我们将打印出我们返回的所有数据,并将其打印到控制台:

    func main() {
      l1, l2, l3 := linked()
      fmt.Println("Linked   :", l1, l2, l3)
      nl1, nl2 := noLink()
      fmt.Println("No Link   :", nl1, nl2)
      cl1, cl2 := capLinked()
      fmt.Println("Cap Link  :", cl1, cl2)
      cnl1, cnl2 := capNoLink()
      fmt.Println("Cap No Link :", cnl1, cnl2)
      copy1, copy2, copied := copyNoLink()
      fmt.Print("Copy No Link: ", copy1, copy2)
      fmt.Printf(" (Number of elements copied %v)\n", copied)
      a1, a2 := appendNoLink()
      fmt.Println("Append No Link:", a1, a2)
    }
    
  23. 保存文件。然后,在您在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.7:显示数据的输出

图 4.7:显示数据的输出

在这个练习中,我们逐步通过了五种不同的场景,在这些场景中我们复制了切片数据。在Linked场景中,我们对第一个切片进行了简单的复制,然后对其进行了范围复制。虽然切片本身是不同的,并且不再是相同的切片,但在现实中,这对它们持有的数据没有影响。每个切片都指向相同的隐藏数组,所以当我们对第一个切片进行更改时,它影响了所有切片。

No Link场景中,第一个和第二个切片的设置相同,但在我们对第一个切片进行更改之前,我们向其中追加了一个值。当我们向其中追加这个值时,在后台,Go 需要创建一个新的数组来存储现在的大量值。由于我们是在向第一个切片追加,其指针指向新的更大的切片。第二个切片没有更新其指针。这就是为什么当第一个切片的值发生变化时,第二个切片没有受到影响。第二个切片不再指向相同的隐藏数组,这意味着它们不再链接。

对于Cap Link场景,第一个切片使用make定义,并具有过大的容量。这额外的容量意味着当第一个切片追加值时,隐藏数组中已经有额外的空间。这额外的容量意味着不需要替换隐藏数组。结果是,当我们更新第一个切片的值时,它和第二个切片仍然指向相同的隐藏数组,这意味着更改影响了两者。

Cap No Link场景中,设置与上一个场景相同,但在我们追加值时,追加的值比可用容量多。尽管有额外的容量,但仍然不够,第一个切片中的隐藏数组被替换了。结果是两个切片之间的链接断裂。

Copy No Link中,我们使用了内置的copy函数来为我们复制值。虽然这会将值复制到一个新的隐藏数组中,但copy不会改变切片的长度。这个事实意味着在复制之前,目标切片必须是正确的长度。在现实世界的代码中,你很少看到复制;这可能是因为它很容易被误用。

最后,使用Append No Link,我们使用append执行类似于copy的操作,但无需担心长度。这种方法在现实世界的代码中很常见,当你需要确保获取到与源不链接的值的副本时。由于append经常被使用,这是一个一行解决方案。有一个稍微高效一点的解决方案,可以避免在append的第一个参数中分配空切片的额外内存。你可以通过创建一个 0 容量范围的副本来重用第一个切片。这个替代方案看起来是这样的:

  s1 := []int{1, 2, 3, 4, 5}
  s2 := append(s1[:0:0], s1...)

你在这里看到了什么新东西吗?这里使用了很少使用的切片范围表示法<slice>[<low>:<high>:<capacity>]。在当前的 Go 编译器中,这是复制切片最节省内存的方法。

地图基础

虽然arrayslices相似,有时可以互换使用,但 Go 的另一种集合类型map相当不同,并且不能与arrayslices互换。Go 的map类型服务于不同的目的。

在计算机科学术语中,Go 的映射是一个哈希表。映射与其他集合类型的主要区别在于其键。在数组或切片中,键是一个占位符(索引号),它本身没有意义。它只在那里充当计数器,并且与值没有直接关系。

在映射中,键是数据——与值有真实关系的数据。例如,你可以在映射中有一个用户账户记录的集合。键将是用户的员工 ID。员工 ID 是真实数据,而不仅仅是一个任意的占位符。如果有人给你他们的员工 ID,你将能够查找他们的账户记录,而无需遍历数据来找到它。使用映射,你可以快速设置、获取和删除数据。

你可以使用与切片或数组相同的方式访问映射的各个元素:使用[]。映射可以有直接可比较的任何类型的键,例如intstring类型。你不能比较切片,因此它们不能作为键。映射的值可以是任何类型,包括指针、切片和映射。

你不应该将映射用作有序列表。即使你打算为映射的键使用int类型,映射也不保证始终从索引 0 开始,也不保证键之间没有间隙。即使你想要int键,这个特性也可能是一个优势。如果你在切片或数组中有稀疏填充的数据——即键之间的值有间隙——它将包含大量零数据。在映射中,它只会包含你设置的数据。

要定义映射,你使用以下表示法:map[<key_type>]<value_type>

你可以使用 make 来创建映射,但使用 make 创建映射时的参数是不同的。Go 无法为映射创建键,因此无法像使用切片那样创建任意长度的映射。你可以建议编译器为你的映射使用容量。为映射建议容量是可选的,并且 map 不能与 cap 一起使用来检查其容量。

映射就像切片一样,它们既不是值也不是指针。映射是 Go 中的一个特殊构造。在复制变量或值时,你需要采取相同的谨慎。由于你无法控制或检查映射的容量,当你想要知道添加元素时会发生什么时,它们就更加具有挑战性。

由于 Go 不帮助你使用映射管理键,这意味着你必须指定键来初始化带有数据的映射。它与其他集合类型的表示法相同;也就是说,map[<key_type>]<value_type>{<key1>: <value>, … <keyN>: <valueN>}

一旦定义,你就可以设置值,而无需担心映射的长度,就像你在处理数组和切片时那样。设置值就像其他集合一样;也就是说,<map>[<key>] = <value>。在设置映射的值之前,你需要确保首先已经初始化了它。如果你尝试设置一个未初始化映射的值,会导致运行时恐慌。为了避免这种情况,一个好的做法是避免使用 var 来定义映射。如果你使用数据初始化映射或使用 make 来创建你的映射,你就不会遇到这个问题。

练习 4.13 – 创建、读取和写入映射

在这个练习中,我们将定义一个带有数据的映射,然后向其中添加一个新元素。最后,我们将打印映射到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中,添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个返回 map 类型(具有 string 键和 string 值)的函数:

    func getUsers() map[string]string {
    
  4. 定义一个具有 string 键和 string 值的 map 类型,并使用一些元素初始化它:

      users := map[string]string{
        "305": "Sue",
        "204": "Bob",
        "631": "Jake",
      }
    
  5. 接下来,我们将向 map 类型添加一个新元素:

      users["073"] = "Tracy"
    
  6. 返回 map 类型并关闭函数:

      return users
    }
    
  7. main 函数中,将 map 类型打印到控制台:

    func main() {
      fmt.Println("Users:", getUsers())
    }
    
  8. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

Users: map[073:Tracy 204:Bob 305:Sue 631:Jake]

在这个练习中,我们创建了一个映射,用数据初始化它,然后添加了一个新元素。这个练习表明,与映射一起工作与与数组切片一起工作相似。你应该使用映射取决于你将存储在其中的数据类型,以及你的访问模式是否需要访问单个项目而不是项目列表。

从映射中读取

在需要使用映射获取值之前,你并不总是知道键是否存在于映射中。当你获取一个在映射中不存在的键的值时,Go 会返回映射值类型的零值。使用与零值一起工作的逻辑是 Go 中编程的有效方式,但这并不总是可能的。如果你不能使用零值逻辑,当需要时,映射可以返回一个额外的返回值。这种表示法如下所示:<value>, <exists_value> := <map>[<key>]。在这里,exists_value是一个布尔值,如果键存在于映射中则为true;否则为false。这通常用名为ok的布尔值表示。当遍历映射时,你应该使用range关键字,永远不要依赖于其中项的顺序。Go 不保证映射中项的顺序。为了确保没有人依赖于元素的顺序,Go 在遍历映射时会故意随机化它们的顺序。如果你确实需要按特定顺序遍历映射中的元素,你需要使用数组或切片来帮助你完成这项工作。

练习 4.14 – 从映射中读取

在这个练习中,我们将使用直接访问和循环从映射中读取。我们还将检查键是否存在于映射中。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为main.go的文件。

  2. main.go中添加包和导入:

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 创建一个返回具有字符串键和字符串值的map类型的函数:

    func getUsers() map[string]string {
    
  4. 定义一个map类型并用数据初始化它。然后,返回map类型并关闭函数:

      return map[string]string{
       "305": "Sue",
       "204": "Bob",
       "631": "Jake",
       "073": "Tracy",
      }
    }
    
  5. 在这个函数中,我们将接受一个字符串作为输入。该函数还将返回一个字符串和一个布尔值:

    func getUser(id string) (string, bool) {
    
  6. 从我们之前的功能中获取users映射的副本:

      users := getUsers()
    
  7. 使用传递的 ID 作为键从users映射中获取一个值。捕获值和exists值:

      user, exists := users[id]
    
  8. 返回两个值并关闭函数:

      return user, exists
    }
    
  9. 创建一个main函数:

    func main() {
    
  10. 检查是否至少传递了一个参数。如果没有,则退出:

      if len(os.Args) < 2 {
        fmt.Println("User ID not passed")
        os.Exit(1)
      }
    
  11. 捕获传递的参数并调用getUser函数:

      userID := os.Args[1]
      name, exists := getUser(userID)
    
  12. 如果找不到键,打印一条消息,然后使用range循环打印所有用户。之后,退出:

      if !exists {
        fmt.Printf("Passed user ID (%v) not found.\nUsers:\n", userID)
        for key, value := range getUsers() {
          fmt.Println("  ID:", key, "Name:", value)
        }
        os.Exit(1)
      }
    
  13. 如果一切正常,打印我们找到的名称:

      fmt.Println("Name:", name)
    }
    
  14. 保存文件。然后,在你在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run . 123
    
  15. 然后,运行以下命令:

    go run . 305
    

运行前面的代码会产生以下输出:

图 4.8:显示所有用户和找到的名称的输出

图 4.8:显示所有用户和找到的名称的输出

在这个练习中,我们学习了如何检查键是否存在于映射中。从需要在使用值之前检查键存在性的其他语言来看,这种方式可能看起来有些奇怪。这种方式做事情确实意味着运行时错误的可能性要小得多。如果你的领域逻辑中不可能有零值,那么你可以使用这个事实来检查键是否存在。

我们使用range循环来优雅地打印出我们map中的所有用户。你的输出可能和前面截图中的输出顺序不同,这是因为当你使用range时,Go 会随机化map中元素的顺序。

活动四.02 – 根据用户输入打印用户的名字

现在轮到你来处理map了。我们将定义一个map并创建逻辑来根据传递给应用程序的键打印map中的数据。以下是本活动的步骤:

  1. 创建一个新的 Go 程序。

  2. 定义一个包含以下键值对的map类型:

    : 305, : Sue

    : 204, : Bob

    : 631, : Jake

    : 073, : Tracy

  3. 使用os.Args读取传递进来的键,并打印相应的名字;例如,go run . 073.

  4. 正确处理没有传递参数或传递的参数与map类型中的值不匹配的情况。

  5. 向用户打印包含在值中的名字的消息。

预期的输出如下:

Hi, Tracy

注意

本活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.02.

活动四.03 – 切片星期

在本活动中,我们将创建一个切片,并用一些数据初始化它。然后,我们将使用我们学到的关于子切片的知识来修改这个切片。以下是本活动的步骤:

  1. 创建一个新的 Go 程序。

  2. 创建一个切片,并用周一到周日的所有日子初始化它。

  3. 使用切片范围和append修改切片,使得星期现在从星期日开始,到星期六结束。

  4. 将切片打印到控制台。

预期的输出如下:

[Sunday Monday Tuesday Wednesday Thursday Friday Saturday]

注意

本活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.03.

map中删除元素

如果你需要从一个map中删除一个元素,你需要做不同于数组或切片的事情。在数组中,你不能删除元素,因为长度是固定的;你最好的办法是将值置零。在切片中,你可以将值置零,但也可以使用切片rangeappend的组合来删除一个或多个元素。在map中,你可以将值置零,但元素仍然存在,所以在你的逻辑中检查键是否存在时会导致问题。你也不能在map上使用切片范围来删除元素。

要删除一个元素,我们需要使用内置的 delete 函数。当与映射一起使用时,delete 函数的函数签名是 delete(<map>, <key>)delete 函数不返回任何内容,如果键不存在,则不执行任何操作。

练习 4.15 – 从映射中删除一个元素

在这个练习中,我们将定义一个映射,然后使用用户输入从其中删除一个元素。然后,我们将打印现在可能更小的映射到控制台。让我们开始吧:

  1. 在新文件夹中创建一个名为 main.go 的文件。

  2. main.go 中,添加包和导入:

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 我们将在包作用域中定义我们的 users 映射:

    var users = map[string]string{
      "305": "Sue",
      "204": "Bob",
      "631": "Jake",
      "073": "Tracy",
    }
    
  4. 创建一个函数,使用传入的字符串作为键从 users 映射中删除:

    func deleteUser(id string){
      delete(users, id)
    }
    
  5. main 中,我们将获取传入的 userID 值并将 users 映射打印到控制台:

    func main() {
      if len(os.Args) < 2 {
        fmt.Println("User ID not passed")
        os.Exit(1)
      }
      userID := os.Args[1]
      deleteUser(userID)
      fmt.Println("Users:", users)
    }
    
  6. 保存文件。然后,在您在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run . 305
    

运行前面的代码会产生以下输出:

Users: map[073:Tracy 204:Bob 631:Jake]

在这个练习中,我们使用了内置的 delete 函数来完全从映射中删除一个元素。这个要求对映射是独特的;您不能在数组或切片上使用 delete

活动 4.04 – 从切片中删除一个元素

Go 没有内置的函数来从切片中删除元素,但您可以使用您学到的技术来实现。在这个活动中,我们将设置一个包含一些数据和要删除的一个元素的切片。然后,您需要找出如何做到这一点。有几种方法可以完成这项工作,但您能找出最紧凑的方法吗?

进行此活动的步骤如下:

  1. 创建一个新的 Go 程序。

  2. 按以下顺序创建一个包含以下元素的切片:

    Good
    Good
    Bad
    Good
    Good
    
  3. 编写代码从切片中删除 Bad 元素。

  4. 将结果打印到控制台。

以下为预期的输出:

[Good Good Good Good]

注意

此活动的解决方案可以在 GitHub 仓库的此章节文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.04

简单自定义类型

您可以使用 Go 的简单类型作为起点来创建自定义类型。语法为 type <name> <type>。如果我们基于字符串创建一个 ID 类型,它将看起来像 type id string。自定义类型的行为与基于它的类型相同,包括获得相同的零值和与其他相同类型的值进行比较的能力。自定义类型与其基类型不兼容,但您可以将自定义类型转换回其基于的类型,以便进行交互。

练习 4.16 – 创建一个简单的自定义类型

在这个练习中,我们将定义一个映射,然后使用用户输入和简单的自定义类型从其中删除一个元素。然后,我们将打印现在可能更小的映射到控制台。让我们开始吧:

  1. 在新文件夹中创建一个名为 main.go 的文件。

  2. main.go中添加包和导入:

    package main
    import "fmt"
    
  3. 基于字符串类型定义一个名为id的自定义类型:

    type id string
    
  4. 创建一个返回三个id实例的函数:

    func getIDs() (id, id, id) {
    
  5. 对于id1,我们将初始化它并保留其零值:

      var id1 id
    
  6. 对于id2,我们将使用字符串字面量来初始化它:

      var id2 id = "1234-5678"
    
  7. 最后,对于id3,我们将将其初始化为零,然后单独设置一个值:

      var id3 id
      id3 = "1234-5678"
    
  8. 现在,返回id实例并关闭函数:

      return id1, id2, id3
    }
    
  9. main中调用我们的函数并进行一些比较:

    func main() {
      id1, id2, id3 := getIDs()
      fmt.Println("id1 == id2    :", id1 == id2)
      fmt.Println("id2 == id3    :", id2 == id3)
    
  10. 在此前的比较中,我们将id类型转换回字符串:

      fmt.Println("id2 == \"1234-5678\":", string(id2) == "1234-5678")
    }
    
  11. 保存文件。然后,在你在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.9:比较后的输出

图 4.9:比较后的输出

在这个练习中,我们创建了一个名为id的自定义类型,设置了它的数据,然后将其与相同类型和其基类型string的值进行了比较。

简单的自定义类型是构建现实世界中数据问题的基本部分。拥有旨在反映你需要处理的数据的类型有助于保持你的代码易于理解和维护。

结构体

集合非常适合将相同类型和目的的值分组在一起。在 Go 中,还有另一种方式将数据分组在一起,用于不同的目的。通常,一个简单的字符串、数字或布尔值并不能完全捕捉到你将拥有的数据的本质。

例如,对于我们的用户映射,一个用户通过他们的唯一 ID 和他们的名字来表示。这很少会有足够的细节来处理用户记录。你可以捕捉到关于一个人的数据几乎是无限的,比如他们的给定名、中间名和姓氏。他们偏好的前缀和后缀、他们的出生日期、他们的身高、体重或他们工作的地方也可以被捕捉。将此数据存储在多个具有相同键的映射中是可能的,但这很难处理和维护。

最佳做法是将所有这些不同的数据点收集到一个单一的数据结构中,你可以设计和控制它。这就是 Go 的struct类型:它是一个你可以命名的自定义类型,然后指定字段属性及其类型。

结构体的表示法看起来是这样的:

type <name> struct {
  <fieldName1> <type>
  <fieldName2> <type>
  …
  <fieldNameN> <type>
}

字段名必须在结构体内部是唯一的。你可以为字段使用任何类型,包括指针、集合和其他结构体。

你可以使用以下表示法访问结构体上的字段:<structValue>.<fieldName>。要设置值,你使用以下表示法:<structValue>.<fieldName> = <value>。要读取值,你使用以下表示法:value = <structValue>.<fieldName>

结构体是 Go 中与在其他语言中称为类的东西最接近的东西,但 Go 的设计者故意将其简化。一个关键的区别是结构体没有任何形式的继承。Go 的设计者认为,继承在现实世界的代码中造成的问题比解决的问题要多。

一旦你定义了自定义的 struct 类型,你就可以用它来创建一个值。你有几种方法可以从 struct 类型创建值。现在让我们看看它们。

练习 4.17 – 创建结构体类型和值

在这个练习中,我们将定义一个用户结构体。我们将定义一些不同类型的字段。然后,我们将使用几种不同的方法创建一些结构体值。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中,添加包和导入语句:

    package main
    import "fmt"
    
  3. 我们首先要做的是定义我们的 struct 类型。你通常在包作用域中这样做(在函数体之外)。我们需要给它一个在包级别作用域中唯一的名称:

    type user struct {
    
  4. 我们将添加不同类型的字段,然后关闭结构定义:

      name  string
      age   int
      balance float64
      member bool
    }
    
  5. 我们将创建一个返回我们新定义的 struct 类型切片的函数:

    func getUsers() []user {
    
  6. 我们第一个用户使用这种键值表示法进行初始化。这是初始化结构体最常用的形式:

      u1 := user{
       name:  "Tracy",
       age:   51,
       balance: 98.43,
       member: true,
      }
    
  7. 当使用键值表示法时,字段的顺序不重要,任何你省略的字段都将获得其类型的零值:

      u2 := user{
       age: 19,
       name: "Nick",
      }
    
  8. 使用值初始化结构体是可能的。如果你这样做,所有字段都必须存在,并且它们的顺序必须与你在结构体中定义的顺序相匹配:

      u3 := user{
       "Bob",
       25,
       0,
       false,
      }
    
  9. 这个 var 符号将创建一个所有字段都为零值的结构体:

      var u4 user
    
  10. 现在,我们可以使用 . 和字段名来设置字段的值:

      u4.name = "Sue"
      u4.age = 31
      u4.member = true
      u4.balance = 17.09
    
  11. 现在,我们将返回包含在切片中的值,并关闭函数:

      return []user{u1, u2, u3, u4}
    }
    
  12. main 函数中,我们将获取 users 的切片,遍历它,并将其打印到控制台:

    func main() {
      users := getUsers()
      for i := 0; i < len(users); i++ {
       fmt.Printf("%v: %#v\n", i, users[i])
      }
    }
    
  13. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.10:根据新结构输出的结果

图 4.10:根据新结构输出的结果

在这个练习中,你定义了一个包含多个字段的自定义 struct 类型,每个字段类型不同。然后,我们使用几种不同的方法从该结构体创建了值。这些方法在不同的上下文中都是有效和有用的。

我们在包作用域中定义了结构体,虽然这不是典型做法,但你也可以在函数作用域中定义 struct 类型。如果你在函数中定义了 struct 类型,它只在该函数中有效。在包级别定义类型时,它可以在整个包中使用。

同时定义和初始化结构体也是可能的。如果你这样做,就不能重用该类型,但这仍然是一种有用的技术。表示法如下:

type <name> struct {
  <fieldName1> <type>
  <fieldName2> <type>
  …
  <fieldNameN> <type>
}{
  <value1>,
  <value2>,
  …
  <valueN>,
}

你也可以使用键值表示法进行初始化,但在这个场景下,只使用值进行初始化是最常见的。

比较结构体

如果一个结构体的所有字段都是可比较的类型,那么这个结构体也是可比较的。所以,如果你的结构体由 stringint 类型组成,那么你可以比较整个结构体。如果你的结构体中有一个切片,那么你就不可以。Go 是强类型的,所以你只能比较相同类型的值,但与结构体相比,这里有一些灵活性。如果结构体是匿名定义的并且与命名结构体具有相同的结构,那么 Go 允许比较。

练习 4.18 – 比较结构体

在这个练习中,我们将定义一个可比较的结构体并使用它创建一个值。我们还将定义并创建与我们的命名结构体具有相同结构的匿名结构体值。最后,我们将比较它们并将结果打印到控制台。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件:

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 让我们定义一个简单、可比较的结构体:

    type point struct {
      x int
      y int
    }
    
  4. 现在,我们将创建一个返回两个布尔值的函数:

    func compare() (bool, bool) {
    
  5. 现在,我们将创建我们的第一个匿名结构体:

      point1 := struct {
        x int
        y int
      }{
        10,
        10,
      }
    

注意

这个结构体被认为是匿名的,因为 struct 类型没有名称。point1 是一个包含匿名结构体实例的变量。

  1. 在第二个匿名结构体中,我们将其初始化为零,然后在初始化后更改其值:

      point2 := struct {
        x int
        y int
      }{}
      point2.x = 10
      point2.y = 5
    
  2. 要创建的最后一个结构体使用我们之前创建的命名 struct 类型:

      point3 := point{10, 10}
    
  3. 比较它们。然后返回并关闭函数:

      return point1 == point2, point1 == point3
    }
    
  4. main 中,我们将调用我们的函数并打印结果:

    func main() {
      a, b := compare()
      fmt.Println("point1 == point2:", a)
      fmt.Println("point1 == point3:", b)
    }
    
  5. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.11:比较结构体的输出

图 4.11:比较结构体的输出

在这个练习中,我们看到了我们可以像处理命名 struct 类型一样处理匿名结构体值,包括比较它们。使用命名类型,你只能比较相同类型的结构体。当你比较 Go 中的类型时,Go 会比较所有字段以检查是否有匹配。Go 允许比较这些匿名结构体,因为字段名称和类型匹配。Go 在比较这种结构体时有点灵活。

使用嵌入进行结构体组合

虽然 Go 的结构体不支持继承,但 Go 的设计者确实包含了一个令人兴奋的替代方案。这个替代方案是在 struct 类型中嵌入类型。使用嵌入,你可以从其他结构体向结构体添加字段。这种组合特性使得你可以使用其他结构体作为组件来向结构体添加内容。嵌入与有一个 struct 类型字段的区别。当你嵌入时,嵌入结构体的字段会被提升。一旦提升,字段就像是在目标结构体上定义的一样。

要嵌入一个结构体,你就像添加一个字段一样添加它,但不要指定一个名称。为此,你将 struct 类型名称添加到另一个结构体中,而不给它一个字段名,这看起来像这样:

type <name> struct {
  <Type>
}

虽然不常见,但你可以将任何其他类型嵌入到结构体中。没有需要提升的内容,因此要访问嵌套类型,你需要使用类型的名称;例如,<structValue>.<type>。通过类型名称访问嵌套类型的方式也适用于结构体。这意味着在嵌套类型和根字段名称之间,类型的名称必须是唯一的。在嵌入指针类型时,类型的名称是不带指针表示的类型名称,所以 *<type> 变为 <type>。字段仍然是一个指针,只是名称不同。

在提升方面,如果你与结构体的字段名有重叠,Go 允许你嵌套,但重叠字段的提升不会发生。你仍然可以通过类型名路径访问该字段。

当使用嵌套类型初始化结构体时,不能使用提升。为了初始化数据,你必须使用嵌套类型的名称。

练习 4.19 – 结构体嵌套和初始化

在这个练习中,我们将定义一些结构体和自定义类型。我们将这些类型嵌入到一个结构体中。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import "fmt"
    
  3. 创建一个名为 name 的自定义 string 类型:

    type name string
    
  4. 创建一个名为 location 的结构体,包含两个 int 字段;即 xy

    type location struct {
      x int
      y int
    }
    
  5. 创建一个包含两个 int 字段的结构体 size;即 widthheight

    type size struct {
      width int
      height int
    }
    
  6. 创建一个名为 dot 的结构体。这个结构体将包含前面所有结构体:

    type dot struct {
      name
      location
      size
    }
    
  7. 创建一个返回点切片的函数:

    func getDots() []dot {
    
  8. 我们第一个 dot 实例使用 var 表示法。这将导致所有字段都拥有零值:

      var dot1 dot
    
  9. 对于 dot2,我们也使用零值进行初始化:

      dot2 := dot{}
    
  10. 要设置名称,我们使用类型的名称,就像它是一个字段一样:

      dot2.name = "A"
    
  11. 对于 sizelocation,我们将使用提升的字段来设置它们的值:

      dot2.x = 5
      dot2.y = 6
      dot2.width = 10
      dot2.height = 20
    
  12. 在初始化嵌套类型时,不能使用提升。对于 name,结果是相同的,但对于 locationsize,你需要做更多的工作:

      dot3 := dot{
       name: "B",
       location: location{
         x: 13,
         y: 27,
       },
       size: size{
         width: 5,
         height: 7,
       },
      }
    
  13. 对于 dot4,我们将使用类型名称来设置数据:

      dot4 := dot{}
      dot4.name = "C"
      dot4.location.x = 101
      dot4.location.y = 209
      dot4.size.width = 87
      dot4.size.height = 43
    
  14. 将所有点返回为一个切片,然后关闭函数:

      return []dot{dot1, dot2, dot3, dot4}
    }
    
  15. main 中调用该函数。然后,遍历切片并将其打印到控制台:

    func main() {
      dots := getDots()
      for i := 0; i < len(dots); i++ {
        fmt.Printf("dot%v: %#v\n", i+1, dots[i])
      }
    }
    
  16. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.12:结构体嵌套和初始化后的输出

图 4.12:结构体嵌套和初始化后的输出

在这个练习中,我们能够通过将其他类型嵌入其中来定义一个复杂的结构体。嵌套允许你通过减少重复代码来重用常见的结构体,同时仍然给你的结构体提供一个扁平的 API。

在实际的 Go 代码中,我们可能看不到太多的嵌入。虽然它确实会出现,但复杂性和异常意味着 Go 开发者更喜欢将其他结构体作为命名字段。

活动 4.05 – 创建地区检查器

在这个活动中,我们将创建一个地区验证器。地区是一个国际化本地化概念,它是语言和国家或地区的组合。我们将创建一个表示地区的结构体。之后,我们将定义我们的代码支持的地区列表。然后,我们将从命令行读取一些地区代码,并打印出我们的代码是否接受该地区。

本活动的步骤如下:

  1. 创建一个新的 Go 程序。

  2. 定义一个结构体,其中包含一个用于语言的字段和一个单独的用于国家或地区的字段。

  3. 创建一个集合来存储至少五个地区的本地定义;例如,en_USen_CNfr_CNfr_FRru_RU

  4. 从命令行读取地区;例如,使用 os.Args。确保有错误检查和验证功能。

  5. 将传入的地区字符串加载到一个新的地区结构体中。

  6. 使用该结构体来检查传入的结构体是否受支持。

  7. 向控制台打印一条消息,说明该地区是否受支持。

预期的输出如下:

图 4.13:预期输出

图 4.13:预期输出

注意

本活动的解决方案可以在本章的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.05

类型转换

有时候,你的类型可能不匹配,在 Go 的严格类型系统中,如果类型不同,它们就不能相互交互。在这些情况下,你有两种选择。如果两种类型兼容,你可以进行类型转换——也就是说,你可以通过将一个类型转换为另一个类型来创建一个新的值。进行此类转换的符号是 <value>.(<type>)。当处理字符串时,我们使用此符号将字符串转换为 runes 或 bytes 的切片,然后再转换回来。这是因为字符串是一个特殊类型,它将字符串数据存储为字节的切片。

需要注意的是,并非所有类型转换都能保留原始值。当处理数值类型转换时,数字可能会从其原始值改变。如果您将一个大的int类型(例如,int64)转换为一个较小的int类型(例如,int8),这将导致数字溢出。如果您将一个无符号的int类型(例如,uint64)转换为一个有符号的int类型(例如,int64),这种溢出发生是因为无符号int类型可以存储比有符号int类型更高的数字。当将int类型转换为float类型时,这种溢出也是相同的,因为float类型将其存储空间分割为整数部分和小数部分。当将float类型转换为int类型时,小数部分被截断。

在实际代码中,进行这些类型的损失性转换是完全合理的,并且这些转换经常发生。如果您知道您处理的数据不会跨越这些阈值,那么您无需担心。

Go 会尽力猜测需要转换的类型。这被称为隐式类型转换。例如,math.MaxInt8是一个int类型,如果您尝试将其赋值给一个非int类型的数字,Go 会为您进行隐式类型转换。

练习 4.20 – 数值类型转换

在这个练习中,我们将进行一些数值类型转换,并故意造成一些数据问题。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为main.go的文件。

  2. main.go中,添加包和导入:

    package main
    import (
      "fmt"
      "math"
    )
    
  3. 创建一个返回字符串的函数:

    func convert() string{
    
  4. 定义一些变量来完成我们的工作。Go 将math.MaxInt8int类型隐式转换为int8类型:

      var i8 int8 = math.MaxInt8
      i := 128
      f64 := 3.14
    
  5. 在这里,我们将从较小的int类型转换为较大的int类型。这是一个始终安全的操作:

      m := fmt.Sprintf("int8  = %v > int64  = %v\n", i8, int64(i8))
    
  6. 现在,我们将从比int8最大值大 1 的int类型转换。这将导致溢出到int8的最小值:

      m += fmt.Sprintf("int   = %v > int8   = %v\n", i, int8(i))
    
  7. 接下来,我们将把我们的int8类型转换为float64类型。这不会导致溢出,数据保持不变:

      m += fmt.Sprintf("int8  = %v > float32 = %v\n", i8, float64(i8))
    
  8. 在这里,我们将把float类型转换为int类型。所有的小数数据都会丢失,但整数部分保持不变:

      m += fmt.Sprintf("float64 = %v > int   = %v\n", f64, int(f64))
    
  9. 返回消息然后关闭函数:

      return m
    }
    
  10. main()函数中,调用该函数并将其打印到控制台:

    func main() {
      fmt.Print(convert())
    }
    
  11. 保存文件。然后,在您创建的步骤 1中的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.14:转换后的输出

图 4.14:转换后的输出

类型断言和 interface{}

我们已经大量使用了fmt.Print及其兄弟函数来编写我们的代码,但当一个像fmt.Print这样的函数在 Go 是一种强类型语言时如何接受任何类型的值呢?让我们看看fmt.Print的实际 Go 标准库代码:

// Print formats using the default formats for its operands and writes to standard output.
// Spaces are added between operands when neither is a string.
// It returns the number of bytes written and any write error encountered.
func Print(a ...interface{}) (n int, err error) {
  return Fprint(os.Stdout, a...)
}

希望你能看到,查看 Go 的源代码并不可怕——这是一个很好的方式来了解你应该如何做事,我建议你在好奇他们如何正确使用惯用 Go 语法做事时查看它。

通过查看这段代码,我们可以看到 fmt.Print 有一个 interface{} 类型的可变参数。我们将在后面更详细地介绍接口,但就目前而言,你需要知道的是,Go 中的接口描述了一个类型必须具有哪些函数才能符合该接口。Go 中的接口不描述字段,也不描述类型的核心值,例如字符串或数字。在 Go 中,任何类型都可以有函数,包括字符串和数字。interface{} 描述的是一个没有函数的类型。没有函数、字段和核心值的值有什么用呢?没有用,但它仍然是一个值,并且仍然可以被传递。这个接口不是设置值的类型,而是控制它将允许哪些值用于具有该接口的变量。Go 中哪些类型符合 interface{}?所有类型都符合!Go 的任何类型或你创建的任何自定义类型都符合 interface{},这就是 fmt.Print 可以接受任何类型的原因。你还可以在你的代码中使用 interface{} 来实现相同的结果。

Go 1.18 版本中包含了一个 interface{} 类型的别名,称为 any。由于它们在用法上等效,any 可以与我们在本节开头看到的早期代码示例中的 interface{} 互换。

一旦你有了符合 interface{} 的变量,你能用它做什么呢?即使你的 interface{} 变量的底层值有函数、字段或核心值,你也不能使用它们,因为 Go 正在强制执行接口的合约,这就是为什么这仍然是所有类型安全的。

为了解锁由 interface{}any 隐藏的值的特性,我们需要使用类型断言。类型断言的表示法是 <value>.(<type>)。类型断言会产生一个请求的类型值,以及一个可选的布尔值,表示是否成功。这看起来像 <value> := <value>.(<type>)<value>, <ok> := <value>.(type)。如果你省略布尔值,并且类型断言失败,Go 会引发恐慌。

当你将值放入 interface{} 类型或 any 变量中时,Go 并不会从值中移除任何内容。发生的情况是 Go 编译器阻止你使用它,因为它无法在编译时执行其类型安全检查。使用类型断言是你的指令,告诉 Go 你想要解锁这个值。当你进行类型断言时,Go 执行它在编译时和运行时会执行的类型安全检查,这些检查可能会失败。然后,你必须负责处理类型安全检查失败的情况。类型断言是一个会导致运行时错误和恐慌的特性,这意味着你必须非常小心地处理它们。

练习 4.21 – 类型断言

在这个练习中,我们将执行一些类型断言并确保在执行类型断言时所有安全检查都到位。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import (
      "errors"
      "fmt"
    )
    
  3. 创建一个接受 interface{} 类型并返回字符串和错误的函数:

    func doubler(v interface{}) (string, error) {
    
  4. 首先,我们将检查我们的参数是否是 int 类型,如果是,我们将将其乘以 2 并返回:

      if i, ok := v.(int); ok {
        return fmt.Sprint(i * 2), nil
      }
    
  5. 在这里,我们将检查它是否是一个字符串,如果是,我们将将其与自身连接并返回:

      if s, ok := v.(string); ok {
        return s + s, nil
      }
    
  6. 如果没有匹配项,返回一个错误。然后,关闭函数:

      return "", errors.New("unsupported type passed")
    }
    
  7. main 中,用各种数据调用 doubler 并将结果打印到控制台:

    func main() {
      res, _ := doubler(5)
      fmt.Println("5 :", res)
      res, _ = doubler("yum")
      fmt.Println("yum :", res)
      _, err := doubler(true)
      fmt.Println("true:", err)
    }
    
  8. 保存文件。然后,在您在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.15:显示匹配结果的输出

图 4.15:显示匹配结果的输出

interface{}, any 和类型断言的组合允许您克服 Go 的严格类型控制,从而创建可以处理任何类型变量的函数。挑战在于您失去了 Go 在编译时为类型安全提供的保护。仍然可能做到安全,但现在责任在您身上——如果做错了,您将得到一个讨厌的运行时错误。

类型开关

如果我们想要将 doubler 函数扩展到包括所有 int 类型,我们将会有很多重复的逻辑。Go 有一种处理更复杂类型断言情况的方法,称为类型开关。下面是这个样子:

switch <value> := <value>.(type) {
case <type>:
  <statement>
case <type>, <type>:
  <statement>
default:
  <statement>
}

类型开关仅在匹配您所寻找的类型时运行您的逻辑,并将值设置为该类型。您可以在一个案例中匹配多个类型,但 Go 不能为您更改值的类型,因此您仍然需要进行类型断言。使这成为类型开关而不是表达式开关的一个特点是 <value>.(type) 符号。您只能将其用作类型开关的一部分。类型开关独有的另一个特点是您不能使用 fallthrough 语句。

练习 4.22 – 类型开关

在这个练习中,我们将更新我们的 doubler 函数以使用类型开关并扩展其处理更多类型的能力。让我们开始吧:

  1. 创建一个新的文件夹,并向其中添加一个名为 main.go 的文件。

  2. main.go 中添加包和导入:

    package main
    import (
      "errors"
      "fmt"
    )
    
  3. 创建我们的函数,它接受一个 interface{} 类型的参数并返回一个字符串和一个错误:

    func doubler(v interface{}) (string, error) {
    
  4. 使用我们的参数创建一个类型开关:

      switch t := v.(type) {
    
  5. 对于 stringbool,由于我们只匹配一个类型,我们不需要进行任何额外的安全检查,可以直接处理该值:

      case string:
       return t + t, nil
      case bool:
       if t {
         return "truetrue", nil
       }
       return "falsefalse", nil
    
  6. 对于浮点数,我们在多个类型上进行匹配。这意味着我们需要进行类型断言才能处理该值:

      case float32, float64:
       if f, ok := t.(float64); ok {
         return fmt.Sprint(f * 2), nil
       }
    
  7. 如果这个类型断言失败,我们会崩溃,但我们可以依赖只有 float32 可以直接与类型断言的结果一起工作的逻辑:

      return fmt.Sprint(t.(float32) * 2), nil
    
  8. 匹配所有 intuint 类型。我们通过不需要自己进行类型安全检查,已经能够在这里删除很多代码:

      case int:
       return fmt.Sprint(t * 2), nil
      case int8:
       return fmt.Sprint(t * 2), nil
      case int16:
       return fmt.Sprint(t * 2), nil
      case int32:
       return fmt.Sprint(t * 2), nil
      case int64:
       return fmt.Sprint(t * 2), nil
      case uint:
       return fmt.Sprint(t * 2), nil
      case uint8:
       return fmt.Sprint(t * 2), nil
      case uint16:
       return fmt.Sprint(t * 2), nil
      case uint32:
       return fmt.Sprint(t * 2), nil
      case uint64:
       return fmt.Sprint(t * 2), nil
    
  9. 我们将使用 default 返回一个错误。然后,我们将关闭 switch 语句和函数:

      default:
      return "", errors.New("unsupported type passed")
      }
    }
    
  10. main() 函数中,用更多数据调用我们的函数,并将结果打印到控制台:

    func main() {
      res, _ := doubler(-5)
      fmt.Println("-5 :", res)
      res, _ = doubler(5)
      fmt.Println("5 :", res)
      res, _ = doubler("yum")
      fmt.Println("yum :", res)
      res, _ = doubler(true)
      fmt.Println("true:", res)
      res, _ = doubler(float32(3.14))
      fmt.Println("3.14:", res)
    }
    
  11. 保存文件。然后,在您在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

运行前面的代码会产生以下输出:

图 4.16:调用函数后的输出

图 4.16:调用函数后的输出

在这个练习中,我们使用了类型选择来构建一个复杂类型断言场景。使用类型选择仍然让我们对类型断言有完全的控制,同时也让我们在不需要这种控制级别时简化了类型安全逻辑。

活动 4.06 – 类型检查器

在这个活动中,您将编写一些包含切片或不同类型数据的逻辑。这些数据类型如下:

  • int

  • float

  • string

  • bool

  • struct

创建一个接受任何类型值的函数。该函数返回一个包含类型名称的字符串:

  • 对于 intint32int64,它返回 int

  • 对于所有浮点数,它返回 float

  • 对于字符串,它返回 string

  • 对于布尔值,它返回 bool

  • 对于其他任何内容,它返回 unknown

  • 通过将每个数据传递给您的函数来循环所有数据。

  • 然后,将数据及其类型名称打印到控制台。

预期输出如下:

图 4.17:预期输出

图 4.17:预期输出

注意

本活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter04/Activity04.06。思考根据您使用的 Go 版本,您能以不同的方式实现此解决方案;例如,使用 Go 1.18 的 anyinterface{} 类型别名来解决这个问题。

摘要

在本章中,我们探讨了 Go 中变量和类型的进阶用法。现实世界的代码很快就会变得复杂,因为现实世界本身就是复杂的。能够准确地对数据进行建模并在代码中逻辑上组织这些数据有助于将代码的复杂性降到最低。

您现在知道如何通过使用数组(固定长度有序列表)、切片(动态长度有序列表)或映射(键值哈希)来分组相似的数据。

我们学会了超越 Go 的核心类型,并开始创建基于核心类型或通过创建一个结构体(它是一个包含在单个类型和值中的其他类型的集合)的定制类型。

有时会遇到类型不匹配的情况,因此 Go 允许我们将兼容的类型进行转换,以便它们可以以类型安全的方式交互。

Go 允许我们摆脱其类型安全规则的限制,并赋予我们完全的控制权。通过使用类型断言,我们可以利用 interface{}any 的魔法接受任何类型,然后获取这些类型。

在下一章中,我们将探讨如何将我们的逻辑分组为可重用组件,并将它们附加到我们的自定义类型上,从而使我们的代码更加简单,更容易维护和构建。

第二部分:组件

随着脚本的演变,它们可能会变得难以驾驭和管理。为了保持对代码库的控制,将代码分解成更小、更易于管理的组件至关重要。这不仅增强了代码组织,还促进了代码的重用。

本节深入探讨组件化的领域,使您能够创建模块化和可重用的代码结构。

本部分包含以下章节:

  • 第五章函数 – 减少、重用和回收

  • 第六章不要慌张!处理你的错误

  • 第七章接口

  • 第八章泛型算法超级能力

第五章:函数 – 减少、重用和回收

概述

本章将描述你可以减少、重用和回收代码的各种方法。它将包括对函数的广泛概述,以便你可以包括函数的部分,如定义函数、函数标识符、参数列表、返回类型和函数体。我们还将探讨设计代码时的最佳实践,以便你可以使其可重用和灵活,并使你的功能逻辑小巧且具有目的性。

到本章结束时,你将能够看到 Go 如何使减少、重用和回收代码变得容易。这包括如何描述一个函数以及构成函数的不同部分,以及使用函数评估变量的作用域。你将知道如何创建和调用函数,以及如何利用可变参数函数和匿名函数,并为各种结构创建闭包。你还将了解如何将函数用作参数和返回值,以及如何与函数一起使用defer语句。最后,你将了解如何通过在项目中使用多个文件和目录来将类似的功能分离成逻辑部分。

技术要求

对于本章,你需要安装 Go 编程语言。本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter05

简介

以一种易于维护和迭代的方式编写代码的能力是工程师的一项关键技能。这意味着要精心构建代码,使其可以被重用、易于扩展,并且易于他人理解。Go 语言使得保持代码的整洁和可读性变得容易,并且可以将逻辑块分开。编写易于减少、重用和回收的代码的第一种主要方式是通过使用函数。

函数是许多语言的核心部分,Go 语言也不例外。函数是一段被声明以执行任务的代码。Go 函数可以有零个或多个输入和输出。将 Go 与其他编程语言区分开来的一个特性是它支持多个返回值;大多数编程语言都限制为只有一个返回值。这导致了 Go 的灵活性和开发者持续编写可适应代码的能力。

在接下来的部分中,我们将看到 Go 函数的一些与其他语言不同的特性,例如返回多个类型。我们还将看到 Go 支持一等函数。这意味着 Go 可以将变量分配给函数,将函数作为参数传递,以及将函数作为函数的返回类型。我们将展示如何使用函数将复杂部分分解成更小的部分。

Go 语言中的函数被认为是第一类公民和高级函数。第一类公民是将函数分配给变量的函数。高级函数是可以接受函数作为参数的函数。Go 语言函数的丰富特性使它们能够在以下方式中使用在各个部分:

  • 将函数作为参数传递给另一个函数

  • 将函数作为值从函数返回

  • 将函数用作类型

  • 将函数用作闭包

  • 使用匿名函数

  • 将函数分配给变量

我们将逐一查看这些特性,因为它们在 Go 语言中都是支持的。

函数

函数是 Go 语言的关键部分,我们应该了解它们的位置。让我们考察一下使用函数的一些原因:

  • 分解复杂任务:函数用于执行任务,但如果任务很复杂,应该将其分解成更小的任务。函数可以用于解决更大的问题的小任务。较小的任务更容易管理,使用函数解决特定任务将使整个代码库更容易维护。

  • 减少代码:你应该使用函数的一个好迹象是在你的程序中看到重复的代码。当你有重复的代码时,会增加维护的难度。如果你需要做一次更改,你将需要在多个实例中更改你的代码。

  • 可重用性:一旦你定义了函数,你就可以重复使用它。它也可以被其他程序员使用。这种函数的共享将减少代码行数并节省时间,因为你不需要重新发明轮子。在设计函数时,我们应该遵循以下几条准则:

    • 单一职责:一个函数应该执行一个任务。例如,一个函数不应该同时计算两点之间的距离并估算这两点之间的旅行时间。应该为每个任务创建一个函数。这有助于更好地测试该函数并更容易维护。将函数限制为执行单一任务可能很困难,所以如果你第一次没有做对,不要气馁。即使是经验丰富的程序员在分配函数的职责时也会遇到困难,而且职责可能会随时间而变化。

    • 体积小:函数不应该超过数百行代码。这是代码需要重构的迹象。当我们有大型函数时,违反单一职责原则的可能性更大。一个很好的经验法则是尝试将函数大小限制在大约 25 行代码;然而,这并不是一个硬性规定。保持代码简洁的好处是它减少了调试大型函数的复杂性。它还使得编写具有更好代码覆盖率的单元测试更容易。

函数的组成部分

让我们看看在定义函数时涉及的不同组件。以下是一个函数的典型布局:

图 5.1:函数的不同部分

图 5.1:函数的不同部分

这里描述了函数的不同部分:

  • func:在 Go 中,函数声明以 func 关键字开始。

  • calculateTaxtotalSumfetchId

    标识符应该是描述性的,使代码易于阅读,并使函数的目的易于理解。标识符不是必需的。您可以有一个没有名称的函数;这被称为匿名函数。匿名函数将在本章后面详细讨论。

注意

当函数名的第一个字母是小写时,则该函数不能导出包外。这意味着它是私有的,不能从包外调用。它只能在包内调用。

使用 camelCase 命名约定时请注意这一点。如果您希望函数可导出,函数名的第一个字母必须大写。这意味着如果函数导出并以大写字母开头,其他包可以消费和使用您的函数。

  • name string, age int) 参数是函数的局部变量。

    参数对于函数是可选的。可能没有参数的函数。一个函数可以有零个或多个参数。

    当两个或多个参数具有相同的类型时,可以使用所谓的简写参数表示法。这消除了为每个参数指定相同类型的需要。例如,如果您的参数是 (firstName string, lastName string),它们可以缩短为 (firstName, lastName string)。这减少了参数输入的冗长性,并增加了函数参数列表的可读性。

  • 返回类型:返回类型是一系列数据类型,如布尔值、字符串、映射或另一个可以返回的函数。

    在声明函数的上下文中,我们把这些类型称为返回类型。然而,在调用函数的上下文中,它们被称为返回值。

    返回类型是函数的输出。通常,它们是提供给函数的参数的结果。它们是可选的。大多数编程语言返回单个类型;在 Go 中,您可以返回多个类型。

  • {}

    函数中的语句决定了函数做什么。函数代码是用于执行函数被创建来完成的任务的代码。

    如果定义了返回类型,则函数体中需要有一个 return 语句。return 语句使函数立即停止并返回 return 语句之后列出的值类型。返回类型列表中的类型和 return 语句中的类型必须匹配。

    在函数体中,可以有多个return语句。你经常在出现错误的情况下看到这种情况,你可能需要返回与函数成功处理逻辑不同的值。

  • 函数签名:尽管它没有在前面的代码片段中列出,但函数签名是一个术语,它指的是输入参数与返回类型的组合。这两个单元共同构成了函数签名。

    通常,当你在其他人使用函数时定义函数签名时,你希望努力不要对其进行更改,因为这可能会对你的代码和别人的代码产生不利影响。

随着我们在本章中继续前进,我们将深入探讨函数的各个部分。通过以下讨论,这些函数部分将更容易理解,随着我们通过本章,它们将变得更加清晰。

checkNumbers函数

现在我们已经查看函数的不同部分,让我们看看这些部分如何与各种示例一起工作。让我们从一个简单的checkNumbers函数开始。checkNumbers函数根据数字是偶数还是奇数的某些数学结果打印出各种消息。规则根据给定的数字执行以下操作之一:

  • 如果数字是偶数,打印Even

  • 如果数字是奇数,打印Odd

以下是实现此输出的代码片段:

func checkNumbers() {
    for i := 1; i <= 30; i++ {
        if i%2 == 0 {
            fmt.Println("Even")
        } else {
            fmt.Println("Odd")
        }
    }
}

让我们分部分查看代码:

func checkNumbers() {
  • func,如你可能记得,是声明函数的关键字。这通知 Go,接下来的代码块将是一个函数。

  • checkNumbers是我们函数的名称。在 Go 中,使用驼峰式名称是惯例(标准做法)。

  • (),跟随函数名称的括号是空的:我们当前实现的checkNumbers游戏不需要任何输入参数。如果它确实需要输入参数,它们将包含在括号内。

  • 参数列表()和开括号之间的空格将是返回类型。我们当前的实施不需要返回类型。

  • 关于{,与你可能知道的其它编程语言不同,Go 要求开括号与函数声明在同一行上。如果你尝试运行程序时开括号不在函数签名同一行上,你会得到一个错误:

    for i := 1; i <= 30; i++ {
    

    前面的行是一个for循环,它将i变量从1增加到30

    if i%2 == 0 {
    

注意

%是取模运算符;它给出两个被除整数相除的余数。使用我们的函数,如果i能被2整除,那么它将打印出单词"Even";否则,它将打印“Odd"

随着我们越来越熟悉 Go 的概念和语言语法,代码的解释将排除我们本会多次讨论的项目。

我们现在已经定义了我们的函数。它有一个我们希望它执行的具体任务,但如果我们不执行该函数,那就没有用了。那么,我们如何执行一个函数呢?我们必须调用我们的函数。当我们调用一个函数时,我们是在告诉我们的程序执行该函数。我们将在main()函数内部调用我们的函数。

函数可以调用其他函数。当这种情况发生时,控制权交给被调用的函数。在被调用的函数返回数据或达到结束花括号}后,控制权返回给调用者。让我们通过一个例子来更好地理解这一点:

func main() {
  fmt.Println("Main is in control")
  checkNumbers()
  fmt.Println("Back to main")
}
  • fmt.Println("main 函数控制中"):这个打印语句是为了演示目的。它显示我们处于main()函数中。

  • checkNumbers():我们现在在main()函数内部调用函数。尽管我们的函数没有参数,但括号仍然是必需的,程序的控制权交给checkNumbers()函数。在checkNumbers()函数完成后,控制权随后返回到main()函数。

  • fmt.Println("回到 main"):这个打印语句是为了演示目的,以显示控制权已经返回到main()函数。

输出将如下所示:

图 5.2:的输出

图 5.2:checkNumbers的输出

注意

即使没有输入参数,checkNumbers函数后面的括号仍然是必需的。如果省略了它们,Go 编译器将生成一个错误,指出checkNumbers被评估但没有使用。这是一个常见的错误。

练习 5.01 – 创建一个函数,根据售出的商品数量打印销售人员期望评级

在这个练习中,我们将创建一个没有参数或返回类型的函数。该函数将遍历映射并打印映射上的名称和售出的商品数量。它还将根据销售人员的销售情况打印一条声明。以下步骤将帮助您找到解决方案:

  1. 使用您选择的集成开发环境(IDE)。

  2. 创建一个新文件,并将其保存为main.go

  3. main.go中输入以下代码。main将首先调用的函数是itemsSold();它没有参数,也没有返回值:

    package main
    import (
      "fmt"
    )
    func main() {
      itemsSold()
    }
    
  4. 接下来,我们将定义关于售出商品的逻辑函数:

    func itemsSold() {
    
  5. itemsSold()函数中,初始化一个将包含stringint键值对的映射。该映射将保存个人售出的名称string)和商品数量(int)。名称是该映射的键。我们为售出的商品数量分配各种名称:

      items := make(map[string]int)
      items["John"] = 41
      items["Celina"] = 109
      items["Micah"] = 24
    
  6. 我们遍历items映射,将k分配给key(名称),将v分配给value(商品)

      for k, v := range items{
    
  7. 我们打印出名称和售出的商品数量:

        fmt.Printf("%s sold %d items and ", k, v)
    
  8. 根据变量v商品)的值,我们将确定要打印的声明:

        if v < 40 {
          fmt.Println("is below expectations.")
        } else if v > 40 && v <= 100 {
          fmt.Println("meets expectations.")
        } else if v > 100 {
          fmt.Println("exceeded expectations.")
        }
      }
    }
    
  9. 打开您的终端并导航到代码目录。

  10. 运行go build然后运行可执行文件。

预期的输出如下所示:

John sold 41 items and meets expectations.
Celina sold 109 items and exceeded expectations.
Micah sold 24 items and is below expectations.

在这个练习中,我们看到了函数的一些基本部分。我们展示了如何使用func关键字声明一个函数,然后是如何给我们的函数一个标识符或名称,例如itemsSold()。然后,我们在函数体中添加了代码。在接下来的几节中,我们将扩展函数的核心部分,并学习如何使用参数将数据传递给函数。

注意

最好在 IDE 中输入代码。好处是如果你输入错误,你会看到错误信息,并可以进行一些调试来解决问题。

参数

参数定义了可以传递给我们的函数的参数。函数可以有零个或多个参数。尽管 Go 允许我们定义多个参数,但我们应小心不要有一个庞大的参数列表;这会使代码更难阅读。这也可能表明函数正在执行多个特定任务。如果是这样,我们应该重构函数。以下是一个代码片段的例子:

func calculateSalary(lastName string, firstName string, age int, state string, country string, hoursWorked int, hourlyRate, isEmployee bool) {
// code
}

上述代码是一个参数列表膨胀的函数示例。参数列表应该只与函数的单个职责相关。我们只应该定义解决函数构建的特定问题所需的参数。

参数是我们函数将用于执行其任务的输入类型。函数参数是局部的,意味着它们只对那个函数可用。它们在函数的上下文之外不可用。此外,参数的顺序必须与正确的参数类型顺序相匹配。

正确

func main() {
  greeting("Cayden", 45)
}
func greeting(name string, age int) {
  fmt.Printf("%s is %d", name, age)
}

当正确参数匹配时的输出如下所示:

Cayden is 45

不正确

func main() {
  greeting(45,"Cayden")
}
func greeting(name string, age int) {
  fmt.Printf("%s is %d",name, age)
}

输出如下所示:

图 5.3:不正确参数匹配的输出

图 5.3:不正确参数匹配的输出

在代码的不正确版本中,我们正在使用age参数调用greeting()函数,该参数是integer类型,而参数是string类型。你的参数序列必须与参数输入列表的序列相匹配。

此外,用户可能希望对代码遍历的数据有更多的控制。回到checkNumbers示例,当前的实现只做130。用户可能需要处理不同的数字范围,因此我们需要一种方法来决定循环的结束范围。我们可以修改我们的checkNumbers函数,使其接受一个输入参数。这将满足用户的需求:

func main() {
  checkNumbers(10)
}
func checkNumbers(end int) {
  for i := 1; i <= end; i++ {
    if i%2 == 0 {
      fmt.Println("Even")
    } else {
      fmt.Println("Odd")
    }
  }
}

上述代码片段可以这样解释:

  • main()函数中对于checkNumbers(10),我们将10作为参数传递给我们的checkNumbers函数

  • 对于checkNumbers(end int)end是我们参数的名称,它是int类型

  • 现在,我们的函数将只迭代到我们的结束参数的值;在这个例子中,它将迭代到10

参数和参数之间的区别

现在是讨论参数和参数之间区别的好时机。当你定义你的函数时,使用我们的例子,checkNumbers(end int) 被称为参数。当你调用一个函数,例如 checkNumbers(10)10 被称为参数。此外,参数和参数的名称不需要匹配。

Go 中的函数也可以定义多个参数。我们需要给 checkNumbers 函数添加另一个参数以适应这个增强:

func main() {
  start:= 10
  end:= 20
  checkNumbers(start, end)
}
func checkNumbers(start int, end int) {
  for i := start; i <= end; i++ {
    // code omitted for brevity
  }
}

前面的代码片段可以这样解释:

  • 关于 checkNumbers(start, end),我们现在向 checkNumbers 函数传递两个参数。当有多个参数时,它们必须通过逗号分隔。

  • 关于 func checkNumbers(start int, end int),当在函数中定义多个参数时,它们通过逗号分隔,遵循名称类型、名称类型、名称类型等约定。

我们的 checkNumbers 参数比必要的更详细。当我们有多个相同类型的输入参数时,我们可以通过逗号后跟类型来分隔输入名称。这被称为简写参数符号。请参见以下使用简写参数符号的示例:

func main() {
  start, end := 10,20
  checkNumbers(start, end)
}
func checkNumbers(start, end int) {
  // code…
}

前面的代码片段可以这样解释:

  • 使用简写参数符号时,调用者没有变化。

  • 关于 checkNumbers(start, end int)startendint 类型。为了适应简写参数符号,函数体内的内容不需要做任何改变。

练习 5.02 – 将索引值映射到列标题

我们将要创建的函数将从一个 CSV 文件中获取列标题的切片。它将打印出我们感兴趣的标题的索引值映射:

  1. 打开您选择的 IDE。

  2. 创建一个新文件,并将其保存为 main.go

  3. main.go 中输入以下代码:

    package main
    import (
      "fmt"
      "strings"
    )
    func main() {
      hdr :=[]string{"empid", "employee", "address", "hours worked", "hourly rate", "manager"}
      csvHdrCol(hdr)
      hdr2 :=[]string{"employee", "empid", "hours worked", "address", "manager", "hourly rate"}
      csvHdrCol(hdr2)
    }
    func csvHdrCol(header []string) {
            csvHeadersToColumnIndex:= make(map[int]string)
    

    首先,我们给一个 intstring 的键值对分配一个变量。key(int) 将是我们的 header(string) 列的索引。索引将映射到列标题。

  4. 我们遍历 header 来处理切片中的每个字符串。在下面的 for 循环中,i 将存储索引,v 将被分配给标题中的每个值:

    for i, v := range header {
    
  5. 对于每个字符串,删除字符串前后任何多余的空格。一般来说,我们应该始终假设我们的数据可能包含一些错误字符:

    v = strings.TrimSpace(v)
    
  6. 在我们的 switch 语句中,我们将所有的大小写转换为精确匹配。如您所回忆的,Go 是一个区分大小写的语言。我们需要确保匹配时大小写相同。当我们的代码找到标题时,它将在映射中设置标题的索引值:

                    switch strings.ToLower(v) {
                    case "employee":
                            csvHeadersToColumnIndex[i] = v
                    case "hours worked":
                            csvHeadersToColumnIndex[i] = v
                    case "hourly rate":
                            csvHeadersToColumnIndex[i] = v
          }
      }
    
  7. 通常,我们不会打印出结果。我们应该返回 csvHeadersToColumnIndex,但由于我们还没有讲解如何返回值,我们现在将其打印出来:

           fmt.Println(csvHeadersToColumnIndex)
    }
    
  8. 打开您的终端并导航到代码目录。

  9. 运行 go build 并运行可执行文件。

预期的输出如下:

Map[1:employee 3:hours worked 4: hourly rate]
Map[0:employee 2:hours worked 5: hourly rate]

在这个练习中,我们看到了如何将数据传入函数:通过为我们的函数定义一个参数。我们的函数调用者能够将参数传递给函数。我们将继续发现 Go 中函数可以提供的各种能力。到目前为止,我们已经看到了如何将数据传入我们的函数。在下一节中,我们将看到如何从我们的函数中获取数据。

函数变量作用域

在设计函数时,我们需要考虑变量作用域。变量的作用域决定了变量在应用程序的不同部分中可访问或可见的位置。在函数内部声明的变量被认为是局部变量。这意味着它们只能被函数体内的代码访问。你不能从函数外部访问变量。调用函数无法访问被调用函数内部的变量。输入参数的作用域与函数的局部变量作用域相同。

在调用函数中声明的变量具有该函数的作用域。这意味着变量是函数内的局部变量,并且这些变量在函数外部不可访问。我们的函数无法访问调用函数的变量。要访问这些变量,它们必须作为输入参数传递给我们的函数:

func main() {
  m:= "Uncle Bob"
  greeting()
}
func greeting() {
  fmt.Printf("Greeting %s", m)
}

这里是输出结果:

图 5.4:m 变量未定义时的错误输出

图 5.4:m 变量未定义时的错误输出

之前的代码片段将在 func greeting() 中导致错误,指出 m 未定义。这是因为 m 变量是在 main() 中声明的。greeting() 函数无法访问 m 变量。为了访问它,必须将 m 变量作为输入参数传递给 greeting() 函数:

func main() {
  m:= "Uncle Bob"
  greeting(m)
  fmt.Printf("Hi from main: %s", s)
}
func greeting(name string) {
  fmt.Printf("Greeting %s", name)
  s := "Slacker"
  fmt.Printf("Greeting %s", s)
}

这里是输出结果:

图 5.5:s 变量未定义时的错误输出

图 5.5:s 变量未定义时的错误输出

之前的代码片段将在 func main() 中导致错误,错误将指出 s 未定义。这是因为 s 变量是在 greeting() 函数中声明的。main() 函数无法访问 s 变量。s 变量仅在 greeting() 函数体内部的代码中可见。

这些只是我们在声明和访问变量时需要考虑的一些注意事项。理解函数内部变量与函数外部声明的变量作用域之间的关系非常重要。当你试图访问变量但未处于你试图访问的上下文作用域时,可能会造成一些混淆。本章中的示例应该有助于你理解变量的作用域。

返回值

到目前为止,我们创建的函数都没有任何返回值。函数通常接受输入,对这些输入执行一些操作,然后返回这些操作的结果。某些编程语言的函数只返回一个值。Go 允许你从函数中返回多个值。这是 Go 函数的一个特性,使其与其他编程语言区分开来。

练习 5.03 – 创建带有返回值的checkNumbers函数

在这个练习中,我们将对我们的checkNumbers函数进行一些改进。我们将将其修改为只接受一个整数。我们将把是否进行循环的责任留给调用者,如果他们希望这样做。此外,我们将有两个返回值。第一个将是提供的数字和相应的文本,指示该数字是Even还是Odd。以下步骤将帮助你找到解决方案:

  1. 打开你选择的 IDE。

  2. 在不同的目录中创建一个新的文件,并将其保存为main.go

  3. main()函数中,将变量分配给我们的函数的返回值。ns变量对应于从我们的函数返回的值,分别是intstring

    func main() {
      for i := 0; i <= 15; i++ {
        num, result := checkNumbers(i)
        fmt.Printf("Results:  %d %s\n", num, result)
      }
    }
    
  4. 现在checkNumbers函数返回两个值;第一个是一个int值,后面跟着一个string值:

    func checkNumbers(i int) (int, string) {
      switch {
    
  5. 通过用switch语句替换if{}else{}语句来简化if{}else{}语句。当你编写代码时,你应该寻找简化事物和使代码更易读的方法。case i%2 ==0与我们之前的if i%2 == 0语句等价。我们不再使用之前的fmt.Println()语句,而是用return替换它们。return语句将立即停止函数的执行,并将结果返回给调用者:

        case i%2 == 0:
          return i, "Even"
        default:
          return i, "Odd"
      }
    }
    

    预期的输出如下:

图 5.6:带有返回值的函数的输出

图 5.6:带有返回值的checkNumbers函数的输出

在这个练习中,我们看到了如何从一个函数中返回多个值。我们能够将变量分配给函数的多个返回值。我们还注意到分配给函数的变量与返回值的顺序相匹配。在下一节中,我们将了解到在函数体中,我们可以执行裸返回,在这种情况下,我们不需要在我们的返回语句中指定要返回的变量。

我们还看到了一个用于清理if{}else{}逻辑的switch语句。我们有一个偶数的case,还有一个default“通配”case,其中奇数会落入。default``case正如其名,如果它之前没有case,它将是默认case

活动 5.01 – 计算员工的工作时间

在这个活动中,我们将创建一个函数来计算员工一周的工作小时数。然后,我们将使用这个函数来计算应支付的工资金额。developer结构体有一个名为Individual的字段,其类型为Employeedeveloper结构体跟踪他们收取的HourlyRate值以及他们每天工作的小时数。以下步骤将帮助你找到解决方案:

  1. 创建一个具有以下字段的Employee类型:IdintFirstNamestringLastNamestring

  2. 创建一个developer类型,它有以下字段:IndividualEmployeeHourlyRateintWorkWeek[7]int

  3. 创建一个表示一周七天的enum类型(枚举是只包含有限个固定值的类型)。这将是一个Weekday int类型,并为每周的每一天声明一个常量。

  4. Developer类型创建一个名为LogHours的指针接收器方法,该方法将接受WeekDay类型和int类型作为输入。将当天工作的小时数分配给Developer的工作周切片。

  5. 创建一个名为HoursWorked()的方法,该方法是一个指针接收器。这个方法将返回已经工作的总小时数。

  6. main()函数中,初始化并创建一个Developer类型的变量。

  7. LogHours方法中,调用两天的方法(例如星期一和星期二)。

  8. 打印上一步骤中两天的小时数。

  9. 接下来,打印HoursWorked方法的结果。

以下为预期的输出:

Hours worked on Monday:  8
Hours worked on Tuesday:  10
Hours worked this week:  18

注意

这个活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter05/Activity05.01

这个活动的目的是展示将问题分解为可管理的任务以供函数实现的能力,使得我们的每个函数都有一个单一的责任。LogHours负责分配每天的工作小时数。HoursWorked使用在LogHours中分配的值来显示每天的工作小时数。我们使用函数的返回类型来显示数据。这个练习展示了正确利用函数来解决问题。

裸返回

注意

有返回值的函数必须在函数的最后一个语句处有一个返回语句。如果你省略了返回语句,Go 编译器会给出一个错误,指出“函数末尾缺少返回语句。”

通常情况下,当一个函数返回两种类型时,第二种类型是error。我们还没有讲解错误,所以不会在这些示例中演示它们。了解在 Go 语言中,第二种返回类型通常是error类型是很重要的。

Go 也允许你忽略返回的变量。例如,假设我们对从checkNumbers函数返回的int值不感兴趣。在 Go 中,我们可以使用所谓的空白标识符,这允许我们在赋值时忽略值:

_, err := file.Read(bytes)

例如,在读取文件时,我们可能不会关心读取的字节数。因此,在这种情况下,我们可以使用空白标识符_来忽略返回的值。当从函数返回额外数据,而这些数据对我们的程序没有任何必要信息时,例如读取文件,忽略返回值是一个很好的选择:

注意

你会发现,许多函数将错误作为第二个返回值返回。你不应该忽略错误函数的返回值。忽略函数返回的错误可能会导致意外的行为。错误返回值应该得到适当的处理。

func main() {
  for i := 0; i <= 15; i++ {
    _, result := checkNumbers(i)
    fmt.Printf("Results: %s\n", result)
  }
}

在前面的例子中,我们使用了空白标识符_来忽略返回的int值:

    _, result := checkNumbers(i)

在从函数赋值时,你必须始终为返回的值提供一个占位符。在进行赋值时,占位符的数量必须与函数返回值的数量相匹配。_resultintstring返回值的占位符。

Go 还有一个允许你命名返回值的功能。如果你使用这个功能,它可以使你的代码更易于阅读,以及自我文档化。如果你为返回变量命名,它们将受到与上一节中讨论的局部变量相同的约束。通过命名返回值,你正在在函数中创建局部变量。然后你可以像处理输入参数一样为这些返回变量赋值:

func greeting() (name string, age int) {
  name = "John"
  age = 21
  return name, age
}

在前面的代码中,(name string, age int)是命名返回。现在它们是函数的局部变量。

由于nameage是在函数返回列表中声明的局部变量,你现在可以给它们赋值。它们可以被视为局部变量。在return语句中,指定返回值。如果你没有在返回中指定变量名,它被称为裸返回

func greeting() (name string, age int) {
  name = "John"
  age = 21
  return
}

考虑前面的代码块。这段代码与之前相同,只是返回值没有命名变量。return语句将返回返回列表中命名的变量。

裸返回的一个缺点是它可能在阅读代码时引起混淆。为了避免混淆和其他可能的问题,建议避免使用裸返回功能,因为它可能会使跟踪要返回的变量变得困难。使用裸返回也可能存在阴影问题:

func message() (message string, err error) {
  message = "hi"
  if message == "hi"{
    err := fmt.Errorf("say bye\n")
    return
  }
  return
}

上一段代码将导致以下错误:

图 5.7:裸返回的阴影输出

图 5.7:裸返回的阴影输出

这是因为 err 变量在 return 语句中被命名,并在 if 语句中初始化。回想一下,在花括号内初始化的变量,如 for 循环、if 语句和 switch 语句,其作用域仅限于该上下文,这意味着它们只在前面的花括号内可见和可访问。

练习 5.04 – 将 CSV 索引映射到列标题并返回值

练习 5.02 – 将索引值映射到列标题 中,我们只打印了索引到列标题的结果。在这个练习中,我们将返回映射作为结果。返回的映射是索引到列标题的映射。以下步骤将帮助您找到解决方案:

  1. 打开您选择的 IDE。

  2. 打开之前列标题练习中的文件 main.go

  3. main.go 文件中输入以下代码:

    package main
    import (
      "fmt"
      "strings"
    )
    
  4. 接下来,在 main() 函数中,定义列的标题。首先,我们将一个变量分配给 intstring 的键值对。key(int) 将是我们的 header(string) 列的索引。索引将映射到列标题:

    func main() {
      hdr := []string{"empid", "employee", "address", "hours worked", "hourly rate", "manager"}
      result := csvHdrCol(hdr)
      fmt.Println("Result: ")
      fmt.Println(result)
      fmt.Println()
      hdr2 := []string{"employee", "empid", "hours worked", "address", "manager", "hourly rate"}
      result2 := csvHdrCol(hdr2)
      fmt.Println("Result2: ")
      fmt.Println(result2)
      fmt.Println()
    }
    func csvHdrCol(hdr []string) map[int]string {
      csvIdxToCol := make(map[int]string)
    
  5. 我们使用 range 操作符遍历 header 来处理切片中的每个字符串:

    for i, v := range hdr {
    
  6. 对于每个字符串,我们移除了字符串前后任何多余的空格。一般来说,我们应该始终假设我们的数据可能包含一些错误字符:

    v = strings.TrimSpace(v)
    
  7. 在我们的 switch 语句中,我们将所有匹配项的字母大小写转换为小写。如您所回忆的,Go 是一个区分大小写的语言。我们需要确保匹配时的大小写相同。当我们的代码找到标题时,它将在映射中设置标题的索引值:

    switch strings.ToLower(v) {
        case "employee":
          csvIdxToCol[i] = v
        case "hours worked":
          csvIdxToCol[i] = v
        case "hourly rate":
          csvIdxToCol[i] = v
        }
      }
      return csvIdxToCol
    }
    
  8. 打开终端并导航到代码目录。

  9. 运行 go build 并运行可执行文件。

返回值的预期输出如下:

Result1:
Map[1:employee 3:hours worked 4: hourly rate]
Result2:
Map[0:employee 2:hours worked 5: hourly rate]

在这个练习中,我们看到了一个将 CSV 索引映射到列标题的实际例子。我们使用一个函数来解决这个复杂的问题。我们能够使函数返回一个 map 类型的单一值。在下一节中,我们将看到函数如何接受单个参数内的可变数量参数值。

可变参数函数

可变参数函数是一种接受可变数量参数值的函数。当指定类型的参数数量未知时,使用可变参数函数是很好的选择:

func f(parameterName …Type)

前面的函数是一个可变参数函数的例子。类型前面的三个点 () 被称为 打包操作符。打包操作符使得它成为一个可变参数函数。它告诉 Go 将 Type 类型的所有参数存储在 parameterName 中。可变参数变量可以接受零个或多个变量作为参数:

func main() {
  nums(99, 100)
  nums(200)
  nums()
}
func nums(i ...int) {
  fmt.Println(i)
}

nums 函数是一个接受 int 类型的可变函数。如前所述,你可以传递零个或多个该类型的参数。如果有多个值,你用逗号将它们分开,例如 nums(99, 100)。如果只有一个参数要传递,你只需传递那个参数,例如 nums(200)。如果没有参数要传递,你可以将其留空,例如 nums()

可变函数可以有其他参数。然而,如果你的函数需要多个参数,可变参数必须是函数中的最后一个。此外,每个函数只能有一个可变变量。以下函数是不正确的,并且会在编译时出错,因为可变变量不是函数的最后一个参数。

错误函数

package main
import "fmt"
func main() {
  nums(99, 100, "James")
}
func nums(i ...int, str person) {
  fmt.Println(str)
  fmt.Println(i)
}

预期的输出如下:

图 5.8:可变语法错误输出

图 5.8:可变语法错误输出

正确函数

package main
import "fmt"
func main() {
  nums("James", 99, 100)
}
func nums(str string, i ...int) {
  fmt.Println(str)
  fmt.Println(i)
}

输出将如下所示:

James
[99 100]

到现在为止,你可能已经猜到了函数内部 Type 的实际类型是一个切片。该函数接收传入的参数并将它们转换为指定的新的切片。例如,如果可变类型是 int,那么一旦你进入函数,Go 会将那个可变 int 类型转换为整数的切片:

图 5.9:将可变整型转换为整数切片

图 5.9:将可变整型转换为整数切片

让我们通过让 variadic 函数接受整数值来调整这个示例:

package main
import "fmt"
func main() {
  nums(99, 100)
}
func nums(i ...int) {
  fmt.Println(i)
  fmt.Printf("%T\n", i)
  fmt.Printf("Len: %d\n", len(i))
  fmt.Printf("Cap: %d\n", cap(i))
}

可变函数的输出如下:

[99 100]
[] int
Len: 2
Cap: 2

nums() 函数显示 i 的可变类型是一个整数的切片。一旦进入函数,i 将会是一个整数的切片。可变类型具有长度和容量,这对于切片来说是预期的。在下面的代码片段中,我们将尝试将一个整数的切片传递给一个可变函数 nums()

package main
import "fmt"
func main() {
  i := []int{ 5, 10, 15}
  nums(i)
}
func nums(i ...int) {
  fmt.Println(i)
}

预期的输出如下:

图 5.10:可变函数错误

图 5.10:可变函数错误

为什么这个代码片段不起作用?我们刚刚证明了函数内部的可变变量是 slice 类型。原因是该函数期望一个 int 类型的参数列表被转换成一个切片。可变函数通过将传入的参数转换成指定类型的切片来工作。然而,Go 有一种将切片传递给可变函数的机制。为此,我们需要使用解包操作符;它是三个点()。当你调用一个可变函数,并且你想将一个切片作为参数传递给可变参数时,你需要在变量前放置三个点:

func main() {
  i := []int{ 5, 10, 15}
  nums(i…)
}
func nums(i ...int) {
  fmt.Println(i)
}

这个函数版本与上一个版本的区别在于调用函数的代码,nums 函数。放在 i 变量后面的三个点是整数的切片。这允许将切片传递给可变函数。

练习 5.05 – 求和数字

在这个练习中,我们将对可变数量的参数进行求和。我们将以参数列表和切片的形式传递参数。返回值将是 int 类型——即我们传递给函数的所有值的总和。以下步骤将帮助您找到解决方案:

  1. 打开您选择的集成开发环境(IDE)。

  2. 在新目录中创建一个新文件,并将其保存为 main.go

  3. main.go 文件中输入以下代码:

    package main
    import (
      "fmt"
    )
    func main() {
      i := []int{ 5, 10, 15}
      fmt.Println(sum(5, 4))
      fmt.Println(sum(i...))
    }
    
  4. sum 函数接受一个可变数量的 int 类型参数。由于它被转换成了一个切片,我们可以遍历这些值并返回所有传入值的总和:

    func sum(nums ...int) int {
      total := 0
      for _, num := range nums {
        total += num
      }
      return total
    }
    
  5. 打开终端并导航到代码目录。

  6. 运行 go build 并运行可执行文件。

求和数字的预期输出如下:

9
30

在这个练习中,我们看到了通过使用可变参数,我们可以接受未知数量的参数。我们的函数允许我们求和任意数量的整数。我们可以看到,可变参数可以用于解决特定问题,其中作为参数传递的相同类型值的数量是未知的。在下一节中,我们将探讨如何创建一个没有名称的函数并将函数赋给一个变量。

匿名函数

到目前为止,我们一直在使用命名函数。如您所回忆的,命名函数是有标识符或函数名的函数。匿名函数,也称为函数字面量,是没有函数名的函数,因此得名“匿名函数”。匿名函数的声明方式与命名函数的声明方式类似。唯一的不同之处在于声明中省略了函数名。匿名函数可以执行 Go 中普通函数所能做的任何事情,包括接受参数和返回值。匿名函数也可以在另一个函数内部声明。

在本节中,我们将介绍匿名函数的基本原理及其一些基本用法。稍后,您将看到如何充分利用匿名函数。匿名函数用于(并与)以下:

  • 闭包实现

  • defer 语句

  • 定义一个用于与 goroutine 一起使用的代码块

  • 定义一个一次性使用的函数

  • 将一个函数传递给另一个函数

    以下是一个匿名函数的基本声明:

    func main() {
      func() {
        fmt.Println("Greeting")
      }()
    }
    

让我们更仔细地看看:

  • 注意,我们是在另一个函数内部声明一个函数。与命名函数一样,您必须以 func 关键字开始声明函数。

  • func 关键字之后通常会跟函数名,但匿名函数没有函数名。相反,是空括号。

  • func 关键字之后的空括号是定义函数参数的地方。

  • 接下来是开括号 {,它标志着函数体的开始。

  • 函数体只有一行;它将打印 “Greeting”。

  • 结束的圆括号 } 表示函数的结束。

  • 最后的一组括号称为执行括号。这些括号调用匿名函数。函数将立即执行。稍后,我们将看到如何在函数内的其他位置执行匿名函数。

    您也可以向匿名函数传递参数。要能够向匿名函数传递参数,它们必须在执行括号中提供:

    func main() {
      message := "Greeting"
      func(str string) {
        fmt.Println(str)
      }(message)
    }
    

这里,我们有以下内容:

  • func (str string): 正在声明的匿名函数有一个 string 类型的输入参数。

  • } (message): 正在被传递到执行括号中的参数消息。

我们一直像声明时那样执行匿名函数,但还有其他执行匿名函数的方法。您还可以将匿名函数保存到变量中。这导致了一系列不同的机会,我们将在本章中探讨:

func main() {
  f := func() {
    fmt.Println("Executing an anonymous function using a variable")
  }
  fmt.Println("Line after anonymous function declaration")
  f()
}

让我们更仔细地看看:

  • 我们正在将 f 变量分配给我们的匿名函数。

  • f 现在是 func() 类型。

  • f 现在可以用来调用匿名函数,方式与命名函数类似。在 f 变量后必须包含 () 来执行函数。

练习 5.06 – 创建一个计算数字平方根的匿名函数

匿名函数非常适合执行函数内的小段代码。在这里,我们将创建一个匿名函数,该函数将接受一个参数。然后它将计算平方根。以下步骤将帮助您找到解决方案:

  1. 使用您选择的 IDE。

  2. 创建一个新文件并将其保存为 main.go

  3. main.go 中输入以下代码。我们将我们的 x 变量分配给我们的匿名函数。我们的匿名函数接受一个参数 (i int)。它还返回一个 int 类型的值:

    package main
    import (
      "fmt"
    )
    func main() {
      j := 9
      x := func(i int) int {
        return i * i
      }
    
  4. 注意到最后的大括号没有 () 来执行函数。我们使用 x(j) 调用我们的匿名函数:

      fmt.Printf("The square of %d is %d\n", j, x(j))
    }
    
  5. 打开终端并导航到代码目录。

  6. 运行 go build 并运行可执行文件。

预期输出如下:

The square of 9 is 81

在这个练习中,我们看到了如何将变量分配给函数,然后通过分配给它的变量调用该函数。我们看到,当我们需要一个可能不在程序中可重用的简单函数时,我们可以创建一个匿名函数并将其分配给一个变量。在下一节中,我们将扩展匿名函数的使用到闭包。

闭包

到目前为止,我们已经通过一些基本示例介绍了匿名函数的语法。现在,我们对匿名函数的工作原理有了基本的理解,我们将探讨如何使用这个强大的概念。

闭包是一种匿名函数的形式。常规函数不能引用自身之外的变量;然而,匿名函数可以引用其定义之外的变量。闭包可以使用与匿名函数声明同一级别的变量。这些变量不需要作为参数传递。当匿名函数被调用时,它可以访问这些变量:

func main() {
  i := 0
  incrementor := func() int {
    i +=1
    return i
  }
  fmt.Println(incrementor())
  fmt.Println(incrementor())
  i +=10
  fmt.Println(incrementor())
}

代码摘要:

  1. main()函数中初始化一个名为i的变量并将其设置为0

  2. 我们将incrementor赋值给我们的匿名函数。

  3. 匿名函数增加i的值并返回它。请注意,我们的函数没有任何输入参数。

  4. 然后,我们两次打印incrementor的结果,得到12

  5. 注意,在我们的函数外部,我们通过i增加10。这是一个问题。我们希望i是隔离的,并且它不应该改变,因为这不是我们想要的行为。当我们再次打印incrementor的结果时,它将是12。我们希望它是3。我们将在下一个示例中纠正这个问题。

我们注意到上一个示例中的一个问题是,主函数中的任何代码都可以访问i。正如我们在示例中看到的,i可以在函数外部被访问和更改。这不是我们想要的行为;我们希望递增器是唯一可以更改该值的函数。换句话说,我们希望i被保护,防止其他函数更改它。唯一应该更改的是当我们调用它时我们的匿名函数:

func main() {
  increment := incrementor()
  fmt.Println(increment())
  fmt.Println(increment())
}
func incrementor() func() int {
  i := 0
  return func() int {
    i += 1
    return i
  }
}

代码摘要:

  1. 我们声明了一个名为incrementor()的函数。此函数的返回类型为func() int

  2. 使用i := 0,我们在incrementor()函数级别初始化我们的变量;这与我们在上一个示例中所做类似,只是它是在main()函数级别,任何人都可以访问i。只有incrementor()函数可以访问这个实现中的i变量。

  3. 我们返回我们的匿名函数func() int,该函数增加i变量。

  4. main()函数中,increment := incrementor()将一个变量赋值给返回func() int类型的func()。重要的是要注意,在这里incrementor()只执行一次。在我们的main()函数中,它不再被引用或执行。

  5. increment()func() int类型。每次调用increment()都会运行匿名函数代码。它引用了i变量,即使incrementor()已经执行完毕。

之前的示例演示了我们可以通过将匿名函数包装起来来保护我们的变量,从而仅通过调用匿名函数本身来限制对更新变量的访问。这通过预期的输出显示,我们已将i增加了两次,如下所示:

1
2

练习 5.07 – 创建一个用于递减计数器的闭包函数

在这个练习中,我们将创建一个从给定起始值递减的闭包。我们将结合我们关于将参数传递给匿名函数的知识,并使用这些知识来使用闭包。以下步骤将帮助您找到解决方案:

  1. 打开您选择的 IDE。

  2. 在新目录中创建一个新文件,并将其保存为 main.go

  3. main.go 中输入以下代码:

    func main() {import "fmt"
      counter := 4
    
  4. 我们首先将查看 decrement 函数。它接受一个 int 类型的参数,并有一个返回值为 func()int 的值。在先前的例子中,变量是在函数内部声明的,但在匿名函数之前。在这个练习中,我们将其作为输入参数:

    x:= decrement(counter)
      fmt.Println(x())
      fmt.Println(x())
      fmt.Println(x())
      fmt.Println(x())
    }
    
  5. 我们在匿名函数内部将 i 减去一:

    func decrement(i int) func() int {
    
  6. main() 函数中,我们初始化一个名为 counter 的变量,用作要递减的起始整数:

    return func() int {
    
  7. 这里,我们有 x:= decrement(counter)x 被分配为 func() int。每次调用 x() 都会运行匿名函数:

        I–-
        return i
      }
    }
    
  8. 打开终端并导航到代码目录。

  9. 运行 go build 并运行可执行文件。

decrement 计数器的预期输出如下:

3
2
1
0

在这个练习中,我们看到了闭包可以访问它们外部变量的情况。这使得我们的匿名函数能够对正常函数无法修改的变量进行修改。在下一节中,我们将探讨函数如何作为参数传递给另一个函数。

函数类型

如我们所见,Go 对函数有丰富的功能支持。在 Go 中,函数也是类型,就像 intstringbool 是类型一样。这意味着我们可以将函数作为参数传递给其他函数,函数可以从函数中返回,函数可以被分配给变量。我们甚至可以定义自己的函数类型。函数的类型签名定义了其输入参数和返回值的类型。为了使一个函数成为另一个函数的类型,它必须具有声明类型 func 的确切签名。让我们检查一些函数类型:

type message func()

上述代码片段创建了一个名为 message 的新函数类型。它没有输入参数,也没有任何返回类型。

让我们再检查一个例子:

type calc func(int, int) string

上述代码片段创建了一个名为 calc 的新函数类型。它接受两个 int 类型的参数,其返回值是 string 类型。

现在我们已经对函数类型有了基本理解,我们可以编写一些代码来演示它们的用法:

package main
import (
  "fmt"
)
type calc func(int, int) string
func main() {
  calculator(add, 5, 6)
}
func add(i, j int) string {
  result := i + j
  return fmt.Sprintf("Added %d + %d = %d", i, j, result)
}
func calculator(f calc, i, j int) {
  fmt.Println(f(i, j))
}

让我们逐行查看代码:

type calc func(int, int) string

type calc 声明 calcfunc 类型,确定它接受两个整数作为参数并返回一个字符串:

func add(i, j int) string {
  result := i + j
  return fmt.Sprintf("Added %d + %d = %d", i, j, result)
}

func add(i,j int) stringcalc 类型具有相同的签名。它接受两个整数作为参数,并返回一个字符串,表示“i + j = result”。函数可以像 Go 中的任何其他类型一样传递给其他函数:

func calculator(f calc, i, j int) {
  fmt.Println(f(i, j))
}

func calculator(f calc, i, j int) 接受 calc 作为输入。你可能记得,calc 类型是一个具有 int 输入参数和 string 返回类型的函数类型。任何匹配该签名的都可以传递给该函数。func calculator 函数返回 calc 类型函数的结果。

main 函数中,我们调用 calculator(add, 5, 6)。我们传递给它 add 函数。add 满足 calc func 类型的签名。

图 5.11 总结了前面每个函数及其相互关系。此图显示了 func addfunc calc 类型,这允许它作为参数传递给 func calculator

图 5.11:函数类型和用途

图 5.11:函数类型和用途

我们刚刚看到了如何创建一个函数类型并将其作为参数传递给函数。将函数作为参数传递给另一个函数并不是那么遥远。我们将稍微修改之前的例子,以反映将函数作为参数传递:

func main() {
  calculator(add, 5, 6)
  calculator(subtract, 10, 5)
}
func calculator(f func(int, int) int, i, j int) {
  fmt.Println(f(i, j))
}
func add(i, j int) int {
  return i + j
}
func subtract(i, j int) int {
  return i - j
}

让我们更仔细地看看:

  • 我们修改了 add 函数签名,使其返回 int 类型而不是 string 类型。

  • 我们添加了一个名为 subtract 的第二个函数。请注意,它的函数签名与 add 函数相同。subtract 函数简单地返回两个数字相减的结果:

    func calculator(f func(int, int) int, i, j int) {
      fmt.Println(f(i, j))
    }
    
  • 这里,我们有 calculator(f func(int, int) int, i, j int)。现在 calculator 函数有一个 func 类型的输入参数。输入参数 f 是一个接受两个整数并返回 int 类型的函数。任何满足该签名的函数都可以传递给该函数。

  • main() 函数中,calculator 被调用了两次:一次是用 add 函数和一些整数值传递,另一次是用 subtract 函数作为参数传递,并带有一些整数值。

预期输出如下:

11
5

将函数作为类型传递的能力是一个强大的功能,其中你可以将函数传递给其他函数,如果它们的签名与传递给函数的输入参数匹配。如果是一个整数类型,函数可以传递任何值。对于传递函数也是如此:如果类型正确,函数可以是任何值。

函数也可以从另一个函数中返回。我们在使用匿名函数与闭包结合时看到了这一点。在这里,我们将简要地看一下,因为我们之前已经看到了这个语法:

package main
import "fmt"
func main() {
  v:= square(9)
  fmt.Println(v())
  fmt.Printf("Type of v: %T",v)
}
func square(x int) func() int {
  f := func() int {
    return x * x
  }
  return f
}

返回函数看起来如下:

81
Type of v: func() int
  • 这里,我们有 square(x int) func() intsquare 函数接受一个 int 类型的参数,并返回一个返回 int 类型的函数类型:

    func square(x int) func() int {
      f := func() int {
        return x * x
      }
      return f
    }
    
  • square 函数体中,我们将变量 f 赋值为一个匿名函数,该函数返回输入参数 x 的平方值。

  • square 函数的 return 语句返回一个匿名函数,该函数是 func() int 类型。

  • v 被分配给 square 函数的返回值。如您所回忆的,返回值是 func() int 类型。

  • v 被分配了 func ()int 类型;然而,它尚未被调用。我们将在 print 语句中调用它。

  • 最后,我们有 fmt.Printf("Type of v: %T",v)。这个语句只是打印出 v 的类型,它是 func()int

练习 5.08 – 创建各种函数以计算薪资

在这个练习中,我们将创建几个函数。我们需要能够计算开发人员和经理的薪资。我们希望这个解决方案能够扩展到未来计算其他薪资的可能性。我们将创建计算开发人员和经理薪资的函数。然后,我们将创建另一个函数,它将接受前面提到的函数作为输入参数。以下步骤将帮助您找到解决方案:

  1. 使用您选择的 IDE。

  2. 在新目录中创建一个新文件,并将其保存为 main.go

  3. main.go 中输入以下代码:

    package main
    import "fmt"
    func main() {
      devSalary := salary(50, 2080, developerSalary)
      bossSalary := salary(150000, 25000, managerSalary)
      fmt.Printf("Boss salary: %d\n", bossSalary)
      fmt.Printf("Developer salary: %d\n", devSalary)
    }
    
  4. salary 函数接受一个接受两个整数作为参数并返回 int 类型的函数。因此,任何匹配该签名的函数都可以作为参数传递给 salary 函数:

    func salary(x, y int, f func(int, int) int) int{
    
  5. salary() 函数的主体中,pay 被分配给从 f 函数返回的值。它将 xy 作为参数传递给 f 参数:

      pay := f(x, y)
      return pay
    }
    
  6. 注意到 managerSalarydeveloperSalary 的签名是相同的,并且与 salaryf 函数匹配。这意味着 managerSalarydeveloperSalary 都可以作为 func(int, int) int 传递:

    func managerSalary(baseSalary, bonus int) int {
      return baseSalary + bonus
    }
    
  7. devSalarybossSalary 被分配给 salary 函数的结果。由于 developerSalarymanagerSalary 满足 func(int, int) int 的签名,它们都可以作为参数传递:

    func developerSalary(hourlyRate, hoursWorked int) int {
      return hourlyRate * hoursWorked
    }
    
  8. 打开终端并导航到代码目录。

  9. 运行 go build 并运行可执行文件。

预期的输出如下:

Boss salary: 175000
Developer salary: 104000

在这个练习中,我们看到了函数类型可以作为另一个函数的参数。这允许函数作为另一个函数的参数。这个练习展示了如何通过有一个 salary 函数来简化我们的代码。如果将来我们需要计算测试员的薪资,我们只需要创建一个与 salary 函数类型匹配的函数,并将其作为参数传递。这种灵活性意味着我们不需要更改 salary 函数的实现。在下一节中,我们将看到如何改变函数的执行流程,特别是函数返回之后。

延迟调用

defer语句将函数的执行推迟到周围函数返回。让我们试着更好地解释这一点。在函数内部,你有一个在调用函数之前的defer语句。本质上,该函数将在你当前所在的函数完成之前执行。还是不明白?或许一个例子会使这个概念更清晰一些:

package main
import "fmt"
func main() {
  defer done()
  fmt.Println("Main: Start")
  fmt.Println("Main: End")
}
func done() {
  fmt.Println("Now I am done")
}

defer示例的输出如下:

Main: Start
Main: End
Now I am done

main()函数内部,我们有一个延迟函数,defer done()。请注意,done()函数没有新的或特殊的语法。它只是简单地打印到控制台。

接下来,我们有两个print语句。结果很有趣。main()函数中的两个print语句首先打印。尽管延迟函数在main()中是第一个,但它最后打印。这不是很有趣吗?在main()函数中的顺序并没有决定它的执行顺序。

这些延迟执行的函数通常用于执行“清理”活动。这包括释放资源、关闭文件、关闭数据库连接以及删除程序创建的configuration\temp文件。defer函数也用于从恐慌中恢复;这将在本书的后面讨论。

使用defer语句不仅限于命名函数——你还可以使用匿名函数的defer语句。以我们之前的代码片段为例,让我们将其转换为使用匿名函数的延迟调用:

package main
import "fmt"
func main() {
  defer func() {
    fmt.Println("Now I am done")
  }()
  fmt.Println("Main: Start")
  fmt.Println("Main: End")
}

让我们更仔细地看看:

  • 与之前的代码相比,变化不大。我们将done函数中的代码提取出来,创建了一个延迟的匿名函数。

  • defer语句放置在func()关键字之前。我们的函数没有函数名。如您所回忆的那样,没有名称的函数是匿名函数。

  • 结果与上一个示例相同。在一定程度上,它的可读性比将延迟函数声明为命名函数更容易,就像上一个示例中那样。

在一个函数中同时拥有多个defer语句也是可能且常见的。然而,它们可能不会按照您预期的顺序执行。当在函数前使用defer语句时,执行顺序遵循它们之前放置的defer语句的顺序:

package main
import "fmt"
func main() {
  defer func() {
    fmt.Println("I was declared first.")
  }()
  defer func() {
    fmt.Println("I was declared second.")
  }()
  defer func() {
    fmt.Println("I was declared third.")
  }()
  f1 := func() {
    fmt.Println("Main: Start")
  }
  f2 := func() {
    fmt.Println("Main: End")
  }
  f1()
  f2()
}

多个defer的输出如下:

Main: Start
Main: End
I was declared third.
I was declared second.
I was declared first.

让我们更仔细地看看:

  • 前三个匿名函数的执行被推迟。

  • 我们将f1f2声明为func()类型。这两个函数是匿名的。

  • 如您所见,f1()f2()按预期执行,但多个defer语句的执行顺序与它们在代码中声明的顺序相反。第一个defer语句是最后一个执行的,而最后一个defer语句是第一个执行的。

在使用defer语句时必须谨慎考虑。你应该考虑的情况之一是当你在变量上使用defer语句。当一个变量传递给延迟函数时,该变量在该时刻的值将在延迟函数中使用。如果该变量在延迟函数之后被更改,则更改后的值不会在延迟函数运行时反映出来:

func main() {
  age := 25
  name := "John"
  defer personAge(name, age)
  age *= 2
  fmt.Printf("Age double %d.\n", age)
}
func personAge(name string, i int) {
    fmt.Printf("%s is %d.\n", name, i)
}

输出如下:

Age double 50.
John is 25.

让我们更仔细地看看:

  • age := 25:在defer函数之前,我们将age变量初始化为25

  • name := "John":在defer函数之前,我们将name变量初始化为"John"

  • defer personAge(name, age):我们声明该函数将被延迟调用。

  • age *= 2:在defer函数之后,我们将年龄翻倍。然后,我们打印出翻倍后的当前age值。

  • personAge(name string, i int):这是被延迟调用的函数;它只打印出人员和年龄。

  • 结果显示了在main函数中将age翻倍后的值。

  • 当程序执行到达包含defer personAge(name, age)的行时,age的值为25。在main()函数完成之前,延迟函数运行,age的值仍然是25。在延迟函数中使用的变量是延迟之前的值,无论之后发生什么。

活动第 5.02 节 - 根据工作时间计算员工的应付款项

此活动基于上一个活动。我们将保持相同的功能,但将增加三个额外功能。在本版本的应用程序中,我们希望赋予员工在尚未记录的情况下跟踪他们一天中工作时间的权限。这将使员工在一天结束时记录时间之前能更好地跟踪他们的工作时间。我们还将增强应用程序,以便计算员工的工资。应用程序将计算他们加班工作的工资。应用程序还将打印出每天工作了多少小时的详细信息:

  1. 创建一个名为nonLoggedHours() func(int) int的函数。每次调用此函数时,它将计算员工未记录的工作时间。你将在函数内部使用闭包。

  2. 创建一个名为PayDay()(int,bool)的方法。此方法将计算每周的工资。它需要考虑加班工资。对于超过 40 小时的工作时间,此方法将支付双倍的小时费率。该函数将返回int类型的每周工资和bool类型的加班工资。如果员工工作超过 40 小时,布尔值将为 true;如果他们工作少于 40 小时,则为 false。

  3. 创建一个名为PayDetails()的方法。此方法将打印出员工每天的工作时间和当天的工作时间。它将打印出每周的总工作时间、每周的工资以及工资中是否包含加班工资。

  4. main 函数内部,初始化一个 Developer 类型的变量。将变量分配给 nonLoggedHours。使用 235 的值打印分配给 nonLoggedHours 的变量。

  5. 此外,在 main() 函数中,记录以下几天的工时:周一 8 小时,周二 10 小时,周三 10 小时,周四 10 小时,周五 6 小时,以及周六 8 小时。

  6. 最后,运行 PayDetails() 方法。

以下为预期输出:

图 5.12:可支付金额活动的输出

图 5.12:可支付金额活动的输出

注意

本活动的解决方案可以在本章 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter05/Activity05.02

本活动旨在比 活动 5.01 – 计算员工的工作时间 更进一步,通过使用 Go 函数的一些更高级编程来实现。在这个活动中,我们继续使用函数,就像我们之前做的那样;然而,我们返回了多个值,并从函数中返回了一个函数。我们还展示了如何使用闭包来计算未记录的员工小时数。

分离相似代码

到目前为止,我们已经就函数进行了很多讨论,因为它们是使 Go 成功和灵活作为语言的关键方面之一。为了继续讨论为他人编写灵活的代码,以便他们可以理解、迭代和工作,我们将讨论如何扩展这种心态。

在软件开发的世界里,有效地组织代码对于创建可维护和可扩展的应用程序至关重要。在 Go 编程中,实现代码组织的一种方法是将相关函数分离到不同的目录中,并利用多个包。

到目前为止,我们一直在使用一个文件来理解 Go 的基础知识。然而,除了 main.go 文件之外,还有其他的生活。我们将简要讨论 Go 开发者如何记住他们的代码的可重用性和整洁性,这超出了函数的范围。然而,在介绍 Go 模块时,我们将保持这一高度,以便我们深入探讨细节。

一个良好的目录布局可以增强代码的可读性和可维护性。它允许开发者快速定位和操作特定的功能。在 Go 中,根据其目的、上下文或领域,将相关函数分组到单独的目录中是很常见的。通过将代码组织到基于功能或特定领域的目录中,开发者可以轻松地识别和修改与特定功能相关的代码。这种分离促进了模块化,并使理解应用程序的架构更容易。

随着项目规模和复杂性的增长,将代码拆分为函数和有目的的目录对于管理依赖项和减少认知负荷变得至关重要。大型应用程序通常受益于与项目模块或组件对齐的目录结构。作为开发者,你应该关心将你的 Go 代码分离成逻辑块的原因有很多:

  • 增强代码重用性

  • 提高可读性和可维护性

  • 测试性和隔离性

以下是一个具体的例子:

  1. 使用你选择的 IDE。

  2. 在新目录中创建一个新文件,并将其保存为 main.go

  3. main.go 中输入以下代码:

    package main
    import "fmt"
    func main() {
    calculateSalary()
    playGame()
        getWeather()
    }
    func calculateSalary() {
        // do stuff
    }
    func playGame() {
        // do stuff
    }
    func getWeather() {
        // do stuff
    }
    

calculateSalaryplayGamegetWeather 函数彼此独立,每个都可以包含复杂的逻辑,并且它们可能依赖于不同的、无关的依赖项。

将无关的函数,甚至保留它们的实际逻辑,会使代码文件膨胀;随着你继续迭代代码并添加逻辑,它可能会变得杂乱无章,难以管理。将这三个函数分别放入它们自己的文件中,例如 salary.gogame.goweather.go,可能是合理的。最终,你可以随着项目的进展将它们分离到不同的目录中。

重要的是从小处着手,然后考虑如何将类似的代码分离出来,以继续编写其他人可以轻松理解和迭代的可管理的 Go 代码。再次强调,当我们在介绍 Go 模块时,将更详细地讨论代码分离的概念,因为这是 Go 使代码简单且可重用的关键方式。

摘要

在本章中,我们研究了为什么以及如何函数是 Go 编程语言的一个基本组成部分。我们还讨论了 Go 中函数的各种特性,这些特性使 Go 与其他编程语言区别开来。Go 具有允许我们以小、可迭代和可管理的方式解决许多现实世界问题的特性。Go 中的函数服务于许多目的,包括增强代码的使用和可读性。

接下来,我们学习了如何创建和调用函数。我们研究了 Go 中使用的各种函数类型,并讨论了每种函数类型可以使用的场景。我们还详细阐述了闭包的概念。闭包本质上是一种匿名函数,它可以使用与匿名函数声明级别相同的变量。然后,我们讨论了各种参数和返回类型,并研究了 defer。我们还讨论了如何保持代码的整洁和分离,以便可以将类似的逻辑打包在一起。这种思考如何减少、重用和回收代码的心态将使你成为一个更好的开发者。

在下一章中,我们将探讨错误和错误类型,并学习如何构建自定义错误,从而构建一个恢复机制来处理 Go 中的错误。

第六章:不要恐慌!处理您的错误

概述

在本章中,我们将查看 Go 标准包中的各种代码片段,以了解 Go 语言执行错误处理的惯用方式。我们还将了解如何在 Go 中创建自定义错误类型,并在标准库中查看更多示例。

在本章结束时,您将能够区分不同类型的错误,并比较错误处理和异常处理。您还将能够创建错误值、使用panic(),并在恐慌后正确恢复,并处理您的错误。最后,我们将简要讨论通过错误包装向我们的错误添加上下文。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter06

简介

在上一章中,我们学习了如何通过函数、分离逻辑组件等方式,借助 Go 语言来减少、重用和回收良好的代码实践!我们还发现了更多关于函数的信息,例如函数可以作为参数传递,也可以从函数中返回。在本章中,我们将处理错误,并学习如何在函数中返回这些错误。

开发者并不完美,因此他们生产的代码也不完美。所有软件在某个时间点都会出现错误。在软件开发过程中,错误处理至关重要。这些错误可能会对用户产生不同程度的负面影响。您软件对用户的影响可能比您想象的要深远。

例如,让我们考虑 2003 年的东北电网停电事件。2003 年 8 月 14 日,美国和加拿大大约有 5000 万人遭遇了为期 14 天的停电。这是由于控制室警报系统中的竞态条件错误。从技术上讲,竞态条件错误是两个独立的线程试图对同一内存位置进行写操作。这种竞态条件可能导致程序崩溃。在这个例子中,它导致了超过 250 个发电厂断电。处理竞态条件的一种方法是通过确保各种线程或进程内的执行小单元之间适当的同步,并允许一次只有一个线程对内存位置进行写操作。我们将在本书的后面部分更详细地讨论并发;然而,这个例子说明了我们作为开发者,确保我们正确处理错误的重要性,以便我们尽可能避免此类问题。如果我们没有正确处理错误,这可能会损害我们应用程序的用户及其生活方式,正如这里描述的停电事件所示。是的,这是一个多年前的事件;然而,我们应该抓住这个机会从过去学习,努力正确处理错误,以避免未来再次发生。有关东北电网停电的更多信息可以在网上找到:en.wikipedia.org/wiki/Northeast_blackout_of_2003

在本章中,我们将探讨错误是什么,Go 语言中的错误看起来是什么样子,以及更具体地,如何以 Go 的方式处理错误。让我们开始吧!

什么是错误?

错误是导致你的程序产生非预期结果的东西。这些非预期结果可能包括应用程序崩溃、不正确的数据计算(例如银行交易处理不当),或者没有任何结果。这些非预期结果被称为软件缺陷。任何软件在其生命周期中都会因为程序员没有预料到的众多场景而包含错误。当错误发生时,可能出现以下结果:

  • 错误的代码可能导致程序在没有警告的情况下崩溃

  • 程序的输出不是预期的结果

  • 显示错误信息

你可能会遇到三种类型的错误:

  • 语法错误

  • 运行时错误

  • 语义错误

让我们更详细地探讨每一个。

语法错误

语法错误是由于编程语言使用不当造成的。这通常是由于代码输入错误导致的。大多数现代集成开发环境(IDE)都会有一些视觉方式将语法错误通知程序员。在大多数现代 IDE 中,可以在早期阶段捕获语法错误。当你学习一门新的编程语言时,语法错误可能会更频繁地发生。一些语法错误的出现可能是因为以下原因:

  • 循环语法使用不当

  • 错误地放置或省略花括号、括号或方括号

  • 拼写错误的函数名或包名

  • 向函数传递错误的参数类型

这里是一个语法错误的示例:

package main
import (
  "fmt"
)
func main() {
  fmt.println("Enter your city:")
}

输出如下所示:

fmt.println("Enter your city:")
cannot refer to unexported name fmt.println
undefined: fmt.println

Go 是大小写敏感的,所以 println 应该是 Println

语法错误是你在 IDE 中通过 golint 收到的快速反馈,golint 是由 gopls 语言服务器运行的。gopls 是由 Google Go 团队开发的官方 Go 语言服务器,它提供了各种语言功能,包括代码补全、对语法警告和错误的诊断以及格式化问题。在支持 gopls 的 IDE 中与 Go 代码一起工作以启用这些功能。golint 本身是一个独立的命令行工具,可以提供代码分析,并且可以与 gopls 集成。建议在提交代码之前运行代码检查器。当你在持续集成CI)环境中打开代码供团队成员审查时,这个过程通常会被自动化,以确保基于团队和/或更大规模的项目都有良好的代码质量标准。

运行时错误

这些错误发生在代码被要求执行它无法完成的任务时。与语法错误不同,这些错误通常只在代码执行期间被发现。

以下是一些常见的运行时错误示例:

  • 打开一个不存在的数据库的连接

  • 执行一个大于你正在迭代的切片或数组中元素数量的循环

  • 打开一个不存在的文件

  • 执行一个数学运算,例如除以零

练习 6.01 – 添加数字时的运行时错误

在这个练习中,我们将编写一个简单的程序,该程序将计算一个数字切片的总和。这个程序将演示一个运行时错误的例子,并在执行时崩溃:

  1. Chapter06目录内创建一个名为Exercise06.01的目录。

  2. 步骤 1中创建的目录内创建一个名为main.go的文件。

  3. 此程序将在 package main 中。导入 fmt 包:

    package main
    import "fmt"
    
  4. main函数内部,我们将有一个包含四个元素的整数切片:

    func main() {
      nums := []int{2, 4, 6, 8}
    
  5. 我们将有一个名为total的变量,用于累加切片中的所有整数变量。使用for循环来累加变量:

      total := 0
      for i := 0; i <= 10; i++ {
        total += nums[i]
      }
    
  6. 接下来,我们打印出总和的结果:

      fmt.Println("Total: ", total)
    }
    

    通过这种方式,我们已经向程序中引入了一个运行时错误的例子;因此,我们不会得到以下输出:

    Total: 20
    
  7. 在命令行中,导航到你在步骤 1中创建的目录。

  8. 在命令行中,键入以下内容:

    go build main.go
    

    go build命令将编译你的程序,并创建一个以你在步骤 1中创建的目录命名的可执行文件。

  9. 步骤 8中创建的文件中键入文件名并按Enter键运行可执行文件(添加./main命令)。预期的输出如下:

图 6.1:执行后的输出

图 6.1:执行后的输出

如您所见,程序崩溃了。index out of range panic 是新手和经验丰富的 Go 开发者都常见的错误。

在这个例子中,这个程序中的错误——一个 panic(我们将在本章后面讨论 panic 是什么)——是由于在for循环中迭代次数过多——在我们的例子中,是 10 次——超过了切片中的实际元素数量——在我们的例子中,是 4 次。一个可能的解决方案是使用带有范围的for循环:

package main
import "fmt"
func main() {
  nums := []int{2, 4, 6, 8}
  total := 0
  for i := range nums {
    total += nums[i]
  }
  fmt.Println("Total: ", total)
}

在这个练习中,我们看到了如何通过关注细节来避免运行时错误。

为了在它们成为运行时错误之前更容易地捕获问题,最好做以下事情:

  • 正确测试你的代码

  • 避免对nil指针进行解引用

  • 根据需要使用适当的输入验证

  • 在访问之前执行边界检查以检查数据范围

  • 使用适当的同步机制

  • 避免全局状态

  • 适度使用 panic 和 recover

  • 对队友进行彻底的代码审查

  • 使用代码检查器和分析器

  • 对依赖项进行版本管理

虽然其中一些包括尝试注意适当的编码实践,但本书的后续章节中也将讨论许多这些内容。

语义错误

语法错误是最容易调试的,其次是运行时错误,而逻辑错误是最难调试的。语义错误有时很难发现,因为它们是导致意外行为的逻辑错误的结果。

例如,在 1998 年,当火星气候轨道器发射时,其目的是研究火星的气候,但由于系统中的逻辑错误,价值 2.35 亿美元的火星气候轨道器被摧毁。经过一些分析,发现地面控制器系统上的单位计算是在英制单位下进行的,而轨道器上的软件是在公制单位下进行的。这是一个导致导航系统在太空中错误计算其机动动作的逻辑错误。正如这个语义错误的典型案例所示,这些是代码处理程序元素的方式上的缺陷。这类错误通常在运行时被发现。这是错误代码可能造成的重大后果的另一个例证,因为火星气候轨道器非常昂贵,并包含了大量的工程努力。

这里有一些导致语义错误发生的原因:

  • 逻辑错误,例如计算错误

  • 访问错误的资源(文件、数据库、服务器和变量)

  • 变量取反设置不正确(不等号与等号)

  • 变量上的类型错误

  • 函数、数据结构、指针和并发使用不当

练习 6.02 – 走路距离的语义错误

我们正在编写一个应用程序,该应用程序将确定我们是否应该步行到目的地或开车。如果我们的目的地大于或等于 2 公里,我们将开车。如果它小于 2 公里,那么我们将步行到我们的目的地。我们将通过这个程序演示一个语义错误。

本练习的预期输出如下:

Take the car

按照以下步骤操作:

  1. Chapter06目录中创建一个名为Exercise6.02的目录。

  2. 在上一步创建的目录中保存一个名为main.go的文件。此程序将位于package main中。

  3. 导入fmt包:

    package main
    import "fmt"
    
  4. main函数中,当km大于2时显示一条取车的消息,当km小于2时,发送一条步行消息:

    func main() {
      km := 2
      if km > 2 {
        fmt.Println("Take the car")
      } else {
        fmt.Println("Going to walk today")
      }
    }
    
  5. 在命令行中,导航到您创建的目录。

  6. 在命令行中,键入以下内容:

    go build main.go
    

    go build命令将编译您的程序,并创建一个以您创建的目录命名的可执行文件。

  7. 步骤 6中输入您创建的文件名并按Enter键运行可执行文件(添加./main命令)。预期的输出如下:

您将得到以下输出:

Going to walk today

程序将无错误运行,但显示的消息并非我们所期望的。

如前所述,程序运行没有错误,但结果并非我们所期望的。这是因为我们有一个逻辑错误。我们的if语句没有考虑到km等于2的情况。它只检查距离是否大于2。幸运的是,这是一个简单的修复:将>替换为>=。现在,程序将给出我们期望的结果:

func main() {
  km := 2
  if km >= 2 {
    fmt.Println("Take the car")
  } else {
    fmt.Println("Going to walk today")
  }
}

这个简单的程序使得调试逻辑错误变得容易,但在更大的程序中,这类错误可能并不容易发现。

语义错误涉及理解代码的预期逻辑。最好进行彻底的测试。这包括各种类型的测试,如单元测试、集成测试、端到端测试等。每种类型的测试在捕捉不同方面的错误和防止意外后果方面都起着特定的作用。本书后面将更详细地讨论各种测试类型。此外,采用 Go 的最佳实践和持续学习的思维方式可以帮助!

本章的剩余部分将重点介绍我们已经讨论过的运行时错误。然而,了解作为程序员可能遇到的错误的各种类型是很好的。

使用其他编程语言进行错误处理

对于初学者 Go 并且有其他编程语言背景的程序员来说,他们可能会觉得 Go 处理错误的方法有些奇怪。Go 不按与其他语言(如 Java、Python、C#和 Ruby)相同的方式处理错误。那些语言执行异常处理。

以下是一些其他语言通过执行异常处理来处理错误的代码片段示例:

//java
try {
  // code
}catch (exception e){
  // block of code to handle the error
}
//python
try:
  //code
except:
  //code
else:
  try:
  // code
  except:
  // code
finally:
  //code

通常情况下,如果没有处理异常,应用程序将会崩溃。在大多数情况下,异常处理倾向于是隐式检查,与 Go 函数返回的错误相比是显式检查。在异常处理范式中,任何事物都可能失败,你必须考虑到这一点。每个函数都可能抛出异常,但你不知道那个异常会是什么。

在 Go 使用的错误处理范式中,当程序员没有处理错误时,很明显,因为函数返回错误代码,你可以看到他们没有检查错误。我们将在本章后面讨论检查错误代码的细节。

大多数编程语言遵循与之前代码片段中所示类似的模式。通常是一些try..catch..finally块。与try..catch..finally块的一个争议点是程序的执行流程被中断,可能会遵循不同的路径。这可能导致几个逻辑错误,并使代码的可读性变得困难。以下是如何快速查看 Go 处理错误的方式:

val, err := someFunc() err
if err != nil{
  return err
}
return nil

上述代码片段是处理错误的非常简单的语法。我们将在接下来的章节中更详细地探讨这一点。

错误接口类型

Go 中的错误是什么?Go 中的错误是一个值。以下是从 Go 的关键先驱之一 Rob Pike 引述的一句话:

值可以被编程,由于错误是值,错误也可以被编程。错误不像异常那样特殊,它们没有什么特别之处,而未处理的异常可能会导致 你的程序崩溃 *。”

由于错误是值,它们可以被传递到函数中,从函数中返回,并且像 Go 中的任何其他值一样进行评估。

在 Go 中,任何实现了错误接口的东西都可以被视为错误。接口将在下一章中详细解释,所以在这章中我们将简要介绍接口引用。我们需要查看构成 Go 中错误类型的一些基本方面。要成为 Go 中的错误类型,它必须首先满足type error interface

//https://golang.org/pkg/builtin/#error
type error interface {
  Error() string
}

Go 的奇妙之处在于其关于语言特性的简单设计。这可以通过 Go 标准库使用的错误接口轻松看出。为了满足错误接口,只需要两个条件:

  • 方法名,Error()

  • 返回字符串的Error()方法

理解错误类型是一个接口类型非常重要。任何错误值都可以描述为一个字符串。在 Go 中进行错误处理时,函数将返回错误值。Go 语言在整个标准库中都使用这一点。

以下代码片段是关于错误讨论的起点:

package main
import (
  "fmt"
  "strconv"
)
func main() {
  v := "10"
  if s, err := strconv.Atoi(v); err == nil {
    fmt.Printf("%T, %v\n", s, s)
  }else{
    fmt.Println(err)
  }
  v = "s2"
  s, err := strconv.Atoi(v)
  if err != nil{
    fmt.Println(s, err)
  }
}

我们不会深入探讨函数的每个细节,而是专注于代码的错误部分。在第五章“减少、重用和回收”中,我们了解到函数可以返回多个值。这是一个大多数语言都没有的强大功能。这一点在处理错误值时尤其强大。strconv.Atoi()函数返回一个int类型和一个错误,正如之前提到的示例中所示。这是一个 Go 标准库中的函数。对于返回错误值的函数,错误值应该是最后一个返回值。

在 Go 语言中,对于返回错误值的函数或方法,评估错误值是 Go 语言的规范。不处理从函数返回的错误通常是不良的实践。当返回并被忽略时,错误可能导致大量的调试工作浪费。它也可能导致程序中出现未预见的后果。如果值不是 nil,那么我们遇到了错误,必须决定如何处理它。根据场景,我们可能想要执行以下操作之一:

  • 将错误返回给调用者

  • 记录错误并继续执行

  • 停止程序的执行

  • 忽略它(这强烈不推荐)

  • 抛出异常(仅在非常罕见的情况下;我们将在稍后详细讨论)

如果错误的值为 nil,这意味着没有错误。不需要进一步的操作。

让我们更详细地看看关于错误类型的标准包。我们将从查看 packt.live/2rk6r8Z 文件中的每一行代码开始:

type errorString struct {
    s string
}

errorString 结构体位于 errors 包中。这个结构体用于存储错误的字符串版本。errorString 有一个名为 s 的单个字段,其类型为 stringerrorString 和该字段是不可导出的。这意味着我们无法直接访问 errorString 类型或其字段 s。以下代码展示了尝试访问不可导出的 errorString 类型及其字段 s 的示例:

package main
import (
  "errors"
  "fmt"
)
func main() {
  es := errors.errorString{}
  es.s = "slacker"
  fmt.Println(es)
}

这是输出:

图 6.2:未导出字段的预期输出

图 6.2:未导出字段的预期输出

表面上看,errorString 似乎既不可访问也不实用,但我们应该继续挖掘标准库:

func (e *errorString) Error() string {
    return e.s
}

errorString 类型有一个实现错误接口的方法。它满足要求,提供了一个名为 Error() 的方法,并返回一个字符串。错误接口已被满足。我们现在可以通过 Error() 方法访问 errorString 字段 s。这就是错误在标准库中返回的方式。

你现在应该对 Go 中的错误有基本理解了。接下来,我们将学习如何在 Go 中创建错误类型。

创建错误值

在标准库中,errors 包有一个我们可以用来创建错误的方法:

// https://golang.org/src/errors/errors.go
// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

重要的是要理解 New 函数接受一个字符串作为参数,将其转换为 *errors.errorString,并返回一个错误值。返回的错误类型的底层值是 *errors.errorString 类型。

我们可以通过运行以下代码来证明这一点:

package main
import (
    "errors"
    "fmt"
)
func main() {
     ErrBadData := errors.New("Some bad data")
     fmt.Printf("ErrBadData type: %T", ErrBadData)
}

这里是 Go 标准库 http 中使用 errors 包创建包级变量的一个示例:

var (
    ErrBodyNotAllowed = errors.New("http: request method or response status code does not allow body")
    ErrHijacked = errors.New("http: connection has been hijacked")
    ErrContentLength = errors.New("http: wrote more than the declared Content- Length")
    ErrWriteAfterFlush = errors.New("unused")
)

在 Go 中创建错误时,通常从 Err 变量开始。

练习 6.03 – 创建一个计算每周工资的应用程序

在这个练习中,我们将创建一个函数来计算一周的工资。这个函数将接受两个参数——一周内工作的小时数和时薪。该函数将检查这两个参数是否符合有效性的标准。该函数需要计算正常工资,即一周内工作的小时数少于或等于 40,以及加班工资,即一周内工作的小时数超过 40。

我们将使用errors.New()创建两个错误值。其中一个错误值将在时薪无效时使用。在我们应用中,无效的时薪是指小于 10 或大于 75 的时薪。第二个错误值将在每周工作小时数不在 0 到 80 之间时使用。

使用您选择的 IDE。一个选项是 Visual Studio Code。按照以下步骤操作:

  1. Chapter06目录中创建一个名为Exercise6.03的目录。

  2. 在上一步创建的目录中保存一个名为main.go的文件。main.go文件将在package main中。

  3. 导入两个 Go 标准库,errorsfmt

    package main
    import (
        "errors"
        "fmt"
    )
    
  4. 因此,我们已经使用errors.New()声明了我们的错误变量。现在,我们可以使用 Go 的惯用命名方法,以Err开头并使用驼峰式命名。我们的错误字符串是小写的,不带标点符号:

    var (
         ErrHourlyRate = errors.New("invalid hourly rate")
         ErrHoursWorked = errors.New("invalid hours worked per week")
    )
    
  5. main函数中,我们将调用三次payday()函数。我们将使用errors.New()声明错误变量,并在函数调用后检查err

    func main() {
        pay, err := payDay(81, 50)
        if err != nil {
            fmt.Println(err)
        }
    }
    
  6. 创建一个名为payDay的函数,并使其接受两个参数(hoursWorkedhourlyRate)。该函数将返回一个int类型和一个错误。我们将在之后一步步讨论这个步骤:

        func payDay(hoursWorked, hourlyRate int) (int, error) {
            if hourlyRate < 10 || hourlyRate > 75 {
                return 0, ErrHourlyRate
        }
        if hoursWorked < 0 || hoursWorked > 80 {
            return 0, ErrHoursWorked
        }
        if hoursWorked > 40 {
            hoursOver := hoursWorked - 40
            overTime := hoursOver * 2
            regularPay := hoursWorked * hourlyRate
            return regularPay + overTime, nil
        }
        return hoursWorked * hourlyRate, nil
    }
    
  7. 我们将使用if语句检查时薪是否小于 10 或大于 75。如果hourlyRate满足这些条件,我们将返回0和我们的自定义错误ErrHourlyRate。如果hourlyRate不满足这些条件,则返回值将是return hoursWorked * hourlyRate, nil。我们返回nil作为错误,因为没有错误发生:

    func payDay(hoursWorked, hourlyRate int) (int, error) {
        if hourlyRate < 10 || hourlyRate > 75 {
            return 0, ErrHourlyRate
        }
        return hoursWorked * hourlyRate, nil
    }
    
  8. 第 7 步中,我们验证了hourlyRate。现在,我们需要验证hoursWorked。我们将在payDay()函数中添加另一个if语句,检查hoursWorked是否小于0或大于80。如果hoursWorked符合该条件,我们将返回0和错误,ErrHoursWorked

    func payDay(hoursWorked, hourlyRate int) (int, error) {
        if hourlyRate < 10 || hourlyRate > 75 {
            return 0, ErrHourlyRate
        }
        if hoursWorked < 0 || hoursWorked > 80 {
            return 0, ErrHoursWorked
        }
        return hoursWorked * hourlyRate, nil
    }
    
  9. 在前两个步骤中,我们添加了if语句来验证传递给函数的参数。在这个步骤中,我们将添加另一个if语句来计算加班工资。加班工资是指超过40小时的工作时间。超过40小时的工作时间是时薪的两倍。不超过40小时的工作时间是按时薪计算的:

    func payDay(hoursWorked, hourlyRate int) (int, error) {
        if hourlyRate < 10 || hourlyRate > 75 {
            return 0, ErrHourlyRate
        }
        if hoursWorked < 0 || hoursWorked > 80 {
            return 0, ErrHoursWorked
        }
        if hoursWorked > 40 {
            hoursOver := hoursWorked - 40
            overTime := hoursOver * 2
            regularPay := hoursWorked * hourlyRate
            return regularPay + overTime, nil
        }
        return hoursWorked * hourlyRate, nil
    }
    
  10. main()函数中,我们将用不同的参数三次调用payDay()函数。我们将在每次调用后检查错误,并在适用的情况下打印错误消息。如果没有错误,则打印一周的工资:

    func main() {
        pay, err := payDay(81, 50)
        if err != nil {
            fmt.Println(err)
        }
        pay, err = payDay(80, 5)
        if err != nil {
            fmt.Println(err)
        }
        pay, err = payDay(80, 50)
        if err != nil {
            fmt.Println(err)
        }
        fmt.Println(pay)
    }
    
  11. 在命令行中,导航到您之前创建的目录。

  12. 在命令行中,输入以下内容:

    go build main.go
    

    go build命令将编译你的程序并创建一个以你创建的目录命名的可执行文件。

  13. 输入你创建的文件名并按Enter键运行可执行文件:

    ./main
    

预期输出如下:

Invalid hours worked per week
Invalid hourly rate
4080

在这个练习中,我们展示了如何创建自定义的错误信息,这些信息可以用来轻松地确定数据为何被认为是无效的。我们还展示了如何从函数中返回多个值,以及如何检查函数中的错误。在下一节中,我们将探讨如何在我们的应用程序中使用 panic。

Panic

几种语言使用异常来处理错误。然而,Go 不使用异常——它使用一种称为 panic 的东西。这是一个导致程序崩溃的内置函数。它停止 panic 发生处的当前 goroutine 的正常执行,以及所有其他正在进行的 goroutines,并显示发生情况的堆栈跟踪。

在 Go 中,panic 不是常态,与其他语言中异常是常态不同。panic 信号表示代码中正在发生异常情况。通常,当 panic 由运行时或开发者启动时,是为了保护程序的完整性。

错误和 panic 在目的和 Go 运行时如何处理它们方面有所不同。Go 中的错误表示发生了意外情况,但它不会对程序的完整性产生不利影响。Go 期望开发者正确处理错误。如果你没有处理错误,函数或其他程序通常不会崩溃。然而,panic 在这方面有所不同。当发生 panic 时,除非有处理 panic 的处理程序,否则它最终会崩溃系统。如果没有处理 panic 的处理程序,它将一路向上堆栈并崩溃程序。

在本章后面我们将探讨的一个例子是,由于索引超出范围而发生的 panic。这在尝试访问不存在的集合的索引时很典型。如果 Go 在这种情况下不 panic,可能会损害程序的完整性,例如程序的其他部分尝试存储或检索集合中不存在的数据。

注意

回顾 goroutines 的相关内容,以了解在 Go 中 panic 时会发生什么。从高层次来看,main()函数是一个 Goroutine。当发生 panic 时,你将在错误信息中看到“Goroutine running”的引用。

恐慌可以被开发者发起,也可以在程序执行过程中由运行时错误引起。panic()函数接受一个空接口。目前,只需说,这意味着它可以接受任何作为参数。然而,在大多数情况下,你应该将错误类型传递给panic()函数。对于我们的函数用户来说,了解导致恐慌的详细信息更直观。将错误传递给恐慌函数也是 Go 中的惯例。我们还将看到如何从传递错误类型的恐慌中恢复,这为我们处理恐慌提供了不同的选项。当发生恐慌时,它通常遵循以下步骤:

  1. 执行被停止。

  2. 在恐慌函数中的任何延迟函数都将被调用。

  3. 在恐慌函数的调用栈中的任何延迟函数都将被调用。

  4. 它将继续向上堆栈传播,直到达到main()

  5. 在恐慌函数之后的语句将不会执行。

  6. 程序随后崩溃。

这就是恐慌的工作原理:

图 6.3:恐慌的工作原理

图 6.3:恐慌的工作原理

前面的图示说明了main函数中调用a()函数的代码。然后该函数调用b()函数。在b()内部发生恐慌。panic()函数没有被上游的任何代码(a()main()函数)处理,所以程序将崩溃main()函数。

这是一个在 Go 中发生的恐慌示例。试着确定这个程序为什么崩溃:

package main
import (
    "fmt"
)
func main() {
    nums := []int{1, 2, 3}
    for i := 0; i <= 10; i++ {
        fmt.Println(nums[i])
    }
}

这个恐慌的输出如下所示:

图 6.4:恐慌示例

图 6.4:恐慌示例

恐慌运行时错误是在开发过程中经常会遇到的一个常见错误。它是一个index out of range错误。Go 生成这个恐慌是因为我们试图迭代一个比元素多的切片。Go 认为这是一个恐慌的理由,因为它使程序处于不正常的状态。

这是一个演示使用恐慌的基本代码片段:

package main
import (
    "errors"
    "fmt"
)
func main() {
    msg := "good-bye"
     message(msg)
     fmt.Println("This line will not get printed")
}
func message(msg string) {
    if msg == "good-bye" {
        panic(errors.New("something went wrong"))
    }
}

代码摘要

  • 函数恐慌是因为函数消息的参数是 "good-bye"

  • panic()函数打印错误消息。一个好的错误消息有助于调试过程。

  • 在恐慌中,我们使用了errors.New(),这是我们之前章节中用来创建错误类型的。

  • 如您所见,fmt.Println()main()函数中没有被执行。由于没有defer语句,执行将立即停止。

这个代码片段的预期输出如下:

图 6.5:恐慌示例输出

图 6.5:恐慌示例输出

以下代码片段展示了panicdefer语句如何一起工作:

main.go

func test() {
    n := func() {
    fmt.Println("Defer in test")
    }
    defer n()
    msg := "good-bye"
    message(msg)
}
func message(msg string) {
    f := func() {
    fmt.Println("Defer in message func")
}
    defer f()
    if msg == "good-bye" {
    panic(errors.New("something went wrong"))

这个恐慌示例的输出如下:

图 6.6:恐慌示例输出

图 6.6:恐慌示例输出

让我们分部分理解这段代码:

  1. 我们首先检查message()函数中的代码,因为 panic 就是从这里开始的。当 panic 发生时,它会运行 panic 函数中的defer语句,即message()

  2. 延迟函数func f()message()函数中运行。

  3. 在调用栈中向上,下一个函数是test()函数,它的延迟函数n()将执行。

  4. 最后,我们到达main()函数,执行被 panic 函数停止。main()中的打印语句不会执行。

注意

你可能见过使用os.Exit()来停止程序的执行。os.Exit()会立即停止执行并返回一个状态码。当执行os.Exit()时,不会运行任何延迟语句。在某些情况下,panicos.Exit()更受欢迎,因为 panic 会运行延迟函数。

Exercise 6.04 – 使用 panic 在错误时崩溃程序

在这个练习中,我们将修改Exercise 6.03 – 创建一个计算每周工资的应用程序。考虑以下场景,其中要求已经改变。

我们不再需要从payDay()函数返回错误值。已经决定我们不能信任程序的用户正确地响应错误。有人抱怨工资单不正确。我们相信这是由于调用我们的函数的人忽略了返回的错误。

payDay()函数现在将只返回工资金额,而不返回错误。当提供给函数的参数无效时,而不是返回错误,函数将 panic。这将导致程序立即停止,因此不会处理工资单。

使用你选择的 IDE。一个选项可以是 Visual Studio Code。现在,按照以下步骤操作:

  1. Chapter06目录内创建一个名为Exercise6.04的目录。

  2. 在上一步创建的目录中保存一个名为main.go的文件。这个程序将位于package main中。

  3. main.go中输入以下代码:

    package main
    import (
        "fmt"
        "errors"
    )
    var (
        ErrHourlyRate = errors.New("invalid hourly rate")
        ErrHoursWorked = errors.New("invalid hours worked per week")
    )
    
  4. main函数中,调用payDay()函数,将其赋值给一个变量pay,然后打印它:

    func main() {
        pay := payDay(81, 50)
        fmt.Println(pay)
    }
    
  5. payDay()函数的返回类型更改为只返回int

    func payDay(hoursWorked, hourlyRate int) int {
    
  6. payDay()函数中,将一个变量report赋值给一个匿名函数。这个匿名函数提供了传递给payDay()函数的参数的详细信息。尽管我们不是返回错误,但这将提供一些关于为什么函数 panic 的见解。由于它是一个延迟函数,它将在函数退出之前始终执行:

    func payDay(hoursWorked, hourlyRate int) int {
        report := func() {
            fmt.Printf("HoursWorked: %d\nHourldyRate: %d\n", hoursWorked, hourlyRate)
        }
        defer report()
    }
    

    对于有效的hourlyRatehoursWorked的业务规则与之前的练习相同。而不是返回错误,我们将使用panic函数。当数据无效时,我们 panic 并传递ErrHourlyRateErrHoursWorked的参数。

    传递给panic()函数的参数帮助我们的函数用户理解panic 的原因。

  7. payDay() 函数发生恐慌时,defer 函数 report() 将向调用者提供有关恐慌原因的一些洞察。恐慌会向上冒泡到 main() 函数,并且执行将立即停止。必须在 payDay() 函数中的 defer 函数之后添加以下代码:

        if hourlyRate < 10 || hourlyRate > 75 {
            panic(ErrHourlyRate)
        }
        if hoursWorked < 0 || hoursWorked > 80 {
            panic(ErrHoursWorked )
        }
        if hoursWorked > 40 {
            hoursOver := hoursWorked – 40
            overTime := hoursOver * 2
            regularPay := hoursWorked * hourlyRate
            return regularPay + overTime
        }
        return hoursWorked * hourlyRate
    }
    
  8. 在命令行中,导航到您创建的目录。

  9. 在命令行中,键入以下内容:

    go build main.go
    
  10. go build 命令将编译您的程序并创建一个以您创建的目录命名的可执行文件。

  11. 输入你创建的文件名并按 Enter 键来运行可执行文件。

预期的输出应该是以下内容:

图 6.7:恐慌练习输出

图 6.7:恐慌练习输出

在这个练习中,我们学习了如何执行恐慌并将错误传递给 panic() 函数。这有助于用户更好地理解恐慌的原因。在下一节中,我们将学习如何在发生恐慌后使用 recover() 来恢复程序控制。我们还将讨论 Go 中 panic()recover() 的指南。

恢复

Go 提供了在发生恐慌后恢复控制的能力。recover() 是一个用于恢复恐慌 goroutine 控制的函数。

recover() 函数的签名如下:

func recover() interface{}

recover() 函数不接受任何参数,并返回一个空的 interface{}。目前,一个空的 interface{} 表示可以返回任何类型。recover() 函数将返回发送给 panic() 函数的值。

recover() 函数仅在延迟函数内部有用。如您所回忆的那样,延迟函数会在包含函数终止之前执行。在延迟函数内部执行对 recover() 函数的调用会通过恢复正常执行来停止恐慌。如果 recover() 函数在延迟函数外部被调用,它将不会停止恐慌。

以下图表显示了程序在使用 panic()recover()defer() 函数时采取的步骤:

图 6.8: 函数的流程

图 6.8:recover() 函数的流程

在前面图表中采取的步骤可以这样解释:

  1. main() 函数调用 func a()

  2. func a() 调用 func b()

  3. func b() 内部有一个恐慌。

  4. panic() 函数由使用 recover() 函数的延迟函数处理。

  5. 延迟函数是 func b() 内部最后一个执行的函数。

  6. 延迟函数调用 recover() 函数。

  7. recover() 的调用导致正常流程返回到调用者,即 func a()

  8. 正常流程继续,并且最终通过 main() 函数恢复控制。

以下代码片段模拟了前面图表的行为:

main.go

func main() {
    a()
    fmt.Println("This line will now get printed from main() function")
}
func a() {
    b("good-bye")
    fmt.Println("Back in function a()")
}
func b(msg string) {
    defer func() {
        if r := recover(); r!= nil{
            fmt.Println("error in func b()", r)
    }
}()

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter06/Examples/Example06.02/main.go找到。

代码摘要

  • main() 函数调用 a() 函数。这调用了 b() 函数。

  • b() 接受一个字符串类型并将其分配给 msg 变量。如果 msgif 语句中评估为 true,则将发生 panic。

  • panic 的参数是由 errors.New() 函数创建的新错误:

    if msg == "good-bye" {
        panic(errors.New("something went wrong"))
    }
    

一旦发生 panic,下一个调用将是延迟函数。

延迟函数使用 recover() 函数。从 recover() 返回 panic 的值;在这种情况下,r 的值是一个错误类型。然后,函数打印出一些详细信息:

defer func() {
    if r := recover(); r!= nil {
        fmt.Println("error in func b()", r)
    }
}()
  • 控制流返回到 a()。然后,a() 函数打印出一些详细信息。

  • 接下来,控制权返回到 main() 函数,其中它打印出一些详细信息并终止:

图 6.9:recover() 示例输出

图 6.9:recover() 示例输出

练习 6.05 – 从 panic 中恢复

在这个练习中,我们将增强我们的 payDay() 函数,使其能够从 panic 中恢复。当我们的 payDay() 函数 panic 时,我们将检查该 panic 的错误。然后,根据错误,我们将向用户打印一条信息性消息。让我们开始吧:

  1. Chapter06 目录内创建一个名为 Exercise6.05 的目录。

  2. 在上一步创建的目录中保存一个名为 main.go 的文件。此程序将位于 package main 中。

  3. main.go 文件中输入以下代码:

    package main
    import (
        "errors"
        "fmt"
    )
    var (
        ErrHourlyRate = errors.New("invalid hourly rate")
        ErrHoursWorked = errors.New("invalid hours worked per week")
    )
    
  4. 使用各种参数调用 payDay() 函数,然后打印函数的返回值:

    func main() {
        pay := payDay(100, 25)
        fmt.Println(pay)
        pay = payDay(100, 200)
        fmt.Println(pay)
        pay = payDay(60, 25)
        fmt.Println(pay)
    }
    
  5. 然后,向您的 payDay() 函数添加一个 defer 函数:

    func payDay(hoursWorked, hourlyRate int) int {
        defer func() {
    
  6. 我们可以检查 recover() 函数的返回值,如下所示:

            if r := recover(); r != nil {
               if r == ErrHourlyRate {
    

    如果 r 不是 nil,这意味着发生了 panic,我们应该执行一个操作。

  7. 我们可以评估 r 并查看它是否等于我们的错误值之一 – ErrHourlyRateErrHoursWorked

                    fmt.Printf("hourly rate: %d\nerr: %v\n\n", hourlyRate, r)
                }
                if r == ErrHoursWorked {
                    fmt.Printf("hours worked: %d\nerr: %v\n\n", hoursWorked, r)
                }
            }
    
  8. 如果我们的 if 语句评估为 true,我们将打印有关数据和 recover() 函数的错误值的一些详细信息。然后,我们打印出我们的工资是如何计算的:

            fmt.Printf("Pay was calculated based on:\nhours worked: %d\nhourly Rate: %d\n", hoursWorked, hourlyRate)
        }()
    
  9. payDay() 函数中的其余代码保持不变。要查看其描述,请参阅 练习 6.04 – 使用 panic 在错误时崩溃程序

        if hourlyRate < 10 || hourlyRate > 75 {
            panic(ErrHourlyRate)
        }
        if hoursWorked < 0 || hoursWorked > 80 {
             panic(ErrHoursWorked)
        }
        if hoursWorked > 40 {
            hoursOver := hoursWorked - 40
            overTime := hoursOver * 2
            regularPay := hoursWorked * hourlyRate
            return regularPay + overTime
        }
        return hoursWorked * hourlyRate
    }
    
  10. 在命令行中,导航到您创建的目录。

  11. 在命令行中,键入以下内容:

    go build main.go
    

    go build 命令将编译您的程序并创建一个以您创建的目录命名的可执行文件。

  12. 输入您创建的文件名并按 Enter 运行可执行文件:

    ./main
    

预期的输出如下:

图 6.10:从 panic 中恢复的练习输出

图 6.10:从 panic 中恢复的练习输出

在先前的练习中,我们看到了创建自定义错误并返回该错误的过程。从这一点上,我们能够在需要时使用panic()使程序崩溃。在上一个练习中,我们展示了从panic()中恢复并基于传递给panic()函数的错误类型显示错误消息的能力。在下一节中,我们将讨论在 Go 中进行错误处理时的一些基本指南。

处理错误和panic()时的指南

指南仅作为指导。它们并非一成不变。这意味着大多数时候,你应该遵循指南;然而,可能会有例外。其中一些指南之前已经提到,但我们在这里进行了整合,以便快速参考:

  • 在声明错误类型时,变量需要以Err开头。它还应遵循驼峰命名法:

    var ErrExampleNotAllowd= errors.New("error example text")
    
  • error字符串应以小写字母开头,且不以标点符号结尾。制定此指南的原因之一是错误可以被返回并与其他与错误相关的信息连接。

  • 如果一个函数或方法返回错误,则应该对其进行评估。未评估的错误可能导致程序无法按预期运行。

  • 当使用panic()时,应传递一个错误类型作为参数,而不是空值。

  • 不要评估错误的字符串值来直接从错误的字符串表示中提取信息。相反,应使用类型断言或错误接口方法来检索有关错误的特定细节。

  • 应该谨慎使用panic()函数。

应该在预期情况下使用错误,例如当你遇到代码中的可恢复问题时。当一个函数由于特定条件无法返回预期结果时,返回错误允许调用者优雅地处理这种情况。panic()永远不应该成为你的第一道防线。panic()是为了处理异常或意外情况而设计的,将其用于常规错误处理可能导致难以调试的问题,使代码的可维护性降低。此外,在DEBUG模式下记录错误,这是一种程序为了调试目的提供更多详细信息的状态,当调试错误发生的原因时可能很有用。

遵循这些建议将有助于提高你的 Go 代码的可靠性和可维护性,并帮助你优雅地处理错误。

错误包装

在将错误传播到调用栈时,有方法可以改进错误发生的原因的上下文。这在复杂系统中非常有用,有助于理解错误情况。错误封装有助于保留原始错误信息,同时为错误添加额外的上下文。这可以通过使用fmt.Errorfgithub.com/pkg/errors中的errors.Wrap函数来实现。错误封装提供了更多关于错误发生位置或原因的详细信息,使得理解和处理代码中的错误变得更加容易。

错误封装的一个简单示例可以在以下函数中看到:

func readConfig() error {
    _, err := readFile("file.txt")
    if err != nil {
        return errors.Wrap(err, "failed to read config file")
    }
    return nil
}

或者,封装的错误返回可以表示为以下代码:

return fmt.Errorf("failed to read config file: %w", err)

上述代码显示了如何使用错误封装轻松地链接错误。在之前提供的错误格式字符串上的%w允许错误链接,这提供了关于错误发生原因的额外上下文。这种方法在标准库中得到支持,因此应该被认为是首选和最简单的方法。

然而,还有一个第三方 Go 包可以用来使用github.com/hashicorp/go-multierror一起处理多个错误。这些选项为你提供了理解额外的错误上下文或将多个错误聚合为单个错误的灵活性,这在某些场景中可能很方便。

Go 标准库在 1.13 版本中引入了错误封装,你可以在今天看到专业团队和更复杂的应用程序中使用这种功能。在错误周围提供额外的上下文在调试场景中可能很有用。为了说明这一点,考虑一个在你不熟悉代码中发生错误的情况,并且错误没有提供任何上下文。确定错误的来源将非常具有挑战性。当你缺乏关于错误发生代码特定部分的信息时,调试变得非常困难。然而,你必须注意不要将过多的上下文传播到调用栈中,以免损害你的代码库的安全性。

活动 6.01 – 为银行应用程序创建自定义错误消息

一家银行希望在检查姓氏和有效路由号时添加一些自定义错误。他们发现直接存款程序允许使用无效的名称和路由号。银行希望在发生这些事件时有一个描述性的错误消息。我们的任务是创建两个描述性的自定义错误消息。请记住,为错误变量使用惯用的命名约定,并为错误消息使用适当的结构。

你需要做以下事情:

  1. 首先,你必须为ErrInvalidLastNameErrInvalidRoutingNumber创建两个错误值。

  2. 然后,你必须打印main()函数中的自定义消息,以向银行显示当遇到这些错误时他们将收到的错误消息。

预期的输出如下:

invalid last name
invalid routing number

在完成此活动后,您将熟悉创建自定义错误消息所需的步骤。

注意

此活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter06/Activity06.01找到。

活动六.02 – 验证银行客户的直接存款提交

银行对您在活动 6.01 – 为银行应用程序创建自定义错误消息中创建的自定义错误消息感到满意。他们非常满意,现在希望您实现两个方法。这两个方法用于验证姓氏和路线号:

  1. 您需要创建一个名为 directDeposit 的结构。

  2. directDeposit 结构将包含三个字符串字段:lastNamefirstNamebankName。它还将包含两个名为 routingNumberaccountNumberint 字段。

  3. directDeposit 结构将有一个 validateRoutingNumber 方法。当路线号小于 100 时,该方法将返回 ErrInvalidRoutingNum

  4. directDeposit 结构将有一个 validateLastName 方法。当 lastName 为空字符串时,它将返回 ErrInvalidLastName

  5. directDeposit 结构将有一个名为 report 的方法。它将打印出每个字段的值。

  6. main() 函数中,为 directDeposit 结构的字段分配值,并调用 directDeposit 结构的每个方法。

预期输出如下:

图 6.11:验证银行客户的直接存款提交

图 6.11:验证银行客户的直接存款提交

在完成此活动后,您将学会如何从函数中返回错误,以及如何检查从函数返回的错误。您还将能够检查条件,并根据该条件返回自定义错误。

注意

此活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter06/Activity06.02找到。

活动六.03 – 对无效数据提交引发恐慌

银行现在决定,当提交无效的路线号时,宁愿让程序崩溃。银行认为,错误数据应导致程序停止处理直接存款数据。您需要在无效数据提交实例上引发恐慌。在活动 6.02 – 验证银行客户的直接存款提交的基础上构建此功能。

对于此活动,您只需做一件事 – 修改 validateRoutingNumber 方法,使其不返回 ErrInvalidRoutingNum,而是执行恐慌:

预期输出如下:

图 6.12:无效路由号上的 panic

图 6.12:无效路由号上的 panic

在完成这个活动后,你将能够引发 panic,并看到它如何影响程序的流程。

注意

本活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter06/Activity06.03找到。

活动六.04 – 防止 panic 导致应用崩溃

在进行一些初步的 alpha 测试后,银行不再希望应用崩溃,因此,在本活动中,我们需要从我们在活动 6.03 – 无效数据提交时的 panic中恢复,并打印出导致 panic 的错误:

  1. validateRoutingNumber方法中添加一个defer函数。

  2. recover()函数返回的错误上添加一个if语句。如果有错误,则打印它:

预期输出如下:

图 6.13:从无效路由号上的 panic 中恢复

图 6.13:从无效路由号上的 panic 中恢复

在完成这个活动后,你将引发一个 panic,但你会知道如何防止它导致应用崩溃。你将了解如何使用recover()函数,与defer语句结合使用,以防止应用崩溃。

注意

本活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter06/Activity06.04找到。

摘要

在本章中,我们探讨了编程过程中会遇到的不同类型的错误,例如语法、运行时和语义错误。我们更关注运行时错误,因为它们难以调试。

然后,我们检查了在处理错误时各种语言哲学之间的差异。我们看到了 Go 的错误语法相对于各种语言使用的异常处理来说更容易理解。

在 Go 中,错误是一个值。值可以在函数之间传递。任何错误都可以是一个值,只要它实现了错误接口类型。我们学习了如何轻松地创建错误。我们还学习了我们应该给错误值命名,使它们以Err开头,后面跟着一个描述性的驼峰式名称。

接下来,我们讨论了恐慌(panic)以及恐慌与异常之间的相似性。我们还发现恐慌与异常相当相似;然而,如果恐慌没有被处理,它们将导致程序崩溃。但是,Go 语言有一个机制可以将程序的控制权返回到正常状态:recover() 函数。从恐慌中恢复的要求是在延迟函数中使用 recover() 函数。然后,在探索如何使用错误包装添加额外上下文之前,我们学习了使用错误、panic()recover() 的一般指南。

在下一章中,我们将探讨接口及其用途,以及它们与其他编程语言实现接口的方式有何不同。我们将看到它们如何被用来解决作为程序员可能会遇到的各种问题。

第七章:接口

概述

本章旨在展示 Go 中接口的实现。与其他语言相比,它相当简单,因为在 Go 中它是隐式实现的,而其他语言则需要显式实现接口。

在开始时,您将能够为应用程序定义和声明一个接口,并在您的应用程序中实现接口。本章将向您介绍使用鸭子类型和多态,接受接口,并返回结构体。

到本章结束时,您将学会如何使用类型断言来访问接口的底层具体值,并使用类型选择语句。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter07

简介

在上一章中,我们讨论了 Go 中的错误处理。我们探讨了 Go 中的错误是什么;它是指实现了错误接口的任何东西。当时,我们没有调查接口是什么。在这一章中,我们将探讨接口是什么。

例如,您的经理要求您创建一个可以接受 JSON 数据的 API。数据包含有关各种员工的信息,例如他们的地址和他们在项目上的工作时间。数据需要解析到employee结构体中,这是一个相对简单的任务。然后您创建了一个名为loadEmployee(s string)的函数。该函数将接受一个格式为 JSON 的字符串,然后将该字符串解析为加载employee结构体。

您的经理对工作很满意;然而,他们还有另一个要求。客户端需要能够接受一个包含员工数据的 JSON 格式文件。要执行的功能与之前相同。您创建了一个名为loadEmployeeFromFile(f *os.File)的函数,该函数从文件中读取数据,解析数据,并加载employee结构体。

您的经理又有新的要求,即员工数据现在也应来自一个 HTTP 端点。您需要能够从 HTTP 请求中读取数据,因此您创建了一个名为loadEmployeeFromHTTP(r *Request)的函数。

编写的三个函数都具有共同的行为,即它们正在执行。它们都需要能够读取数据。底层类型可能不同(例如stringos.Filehttp.Request),但在所有情况下,行为或读取数据都是相同的。

func loadEmployee(s string), func loadEmployeeFromFile(f *os.File), 和 func loadEmployeeFromHTTP(r *Request) 这些函数都可以用接口 func loadEmployee (r io.Reader) 来替换。io.Reader 是一个接口,我们将在本章后面更深入地讨论它,但就目前而言,只需知道它可以用来解决给定的问题。

在本章中,我们将看到接口如何解决这样的问题;通过将正在执行的行为定义为接口类型,我们可以接受任何底层具体类型。如果现在这还不清楚,随着我们继续本章的学习,它将开始变得清晰。我们将讨论接口如何让我们执行鸭子类型和多态。我们将看到接受接口和返回结构体会减少耦合并增加函数在我们程序更多区域的使用。我们还将检查空接口,并讨论使用案例以充分利用它,包括类型断言和类型选择语句。

接口

接口是一组描述数据类型行为的函数。接口定义了实现该接口的类型必须满足的行为。行为描述了该类型可以做什么。几乎一切事物都表现出某些行为。例如,猫可以喵喵叫、行走、跳跃和咕噜咕噜。所有这些都是猫的行为。汽车可以启动、停止、转弯和加速。所有这些都是汽车的行为。同样,类型的行为被称为方法。

注意

官方文档提供的定义是:Go 中的接口提供了一种指定对象行为的方式。(go.dev/doc/effective_go#interfaces_and_types

描述接口有几种方式:

  • 方法签名集合是只有方法名称、参数、类型和返回类型的方法。这是 Speaker{} 接口方法签名集合的一个例子:

    type Speaker interface {
        Speak(message string) string
        Greet() string
    }
    
  • 为了满足接口,需要类型的方法蓝图。使用 Speaker{} 接口,蓝图(接口)指出,为了满足 Speaker{} 接口,类型必须有一个接受字符串并返回字符串的 Speak() 方法。它还必须有一个返回字符串的 Greet() 方法。

  • 行为是接口类型必须表现出的。例如,Reader{} 接口有一个 Read 方法。它的行为是读取数据。以下代码来自 Go 标准库的 Reader{} 接口:

    type Reader interface {
        Read(b []byte)(n int, err error)
    }
    
  • 接口可以描述为没有实现细节。当定义一个接口,如 Reader{} 接口时,它只包含方法签名而没有实际的代码实现。提供代码或实现细节的责任在于实现接口的类型,而不是接口本身。

一个类型的行为统称为方法集,它是与该类型相关联的方法集合。方法集包括接口定义的方法名称,以及任何输入参数和返回类型。例如,一个类型可能表现出 Read()Write()Save() 等行为。这些行为共同构成了类型的方法集,为类型可以执行的动作或功能提供了清晰的定义。

重要的是要注意,选择这些行为和类型特性的原因应该被清楚地记录。了解一个类型具有特定行为的原因可以为设计决策提供上下文,并增强整体代码理解。

图 7.1:接口元素的图形表示

图 7.1:接口元素的图形表示

当谈论行为时,请注意,我们没有讨论实现细节。定义接口时省略了实现细节。重要的是要理解,在接口的声明中未指定或强制实施任何实现。我们创建的每个实现接口的类型都可以有自己的实现细节。具有名为 Greeting() 方法的接口可以由各种类型以不同的方式实现。一个 person 结构体类型可以以不同于 animal 结构体类型的方式实现 Greeting()

接口关注类型必须表现的行为。接口的职责不是提供方法实现。那是实现接口的类型的工作。类型,通常是结构体,包含方法集的实现细节。现在我们已经对接口有了基本的了解,在下一个主题中,我们将探讨如何定义接口。

定义一个接口

定义一个接口涉及以下步骤:

图 7.2:定义一个接口

图 7.2:定义一个接口

下面是一个声明接口的例子:

type Speaker interface {
    Speak() string
}

让我们看看这个声明的每个部分:

  • type 关键字开始,然后是名称,然后是 interface 关键字。

  • 我们正在定义一个名为 Speaker{} 的接口类型。在 Go 中,使用 er 后缀命名接口是惯用的。如果是一个单方法接口,通常将接口名称命名为那个单一方法。

  • 接下来,你定义方法集。定义一个接口类型指定属于它的方法。在这个接口中,我们声明了一个具有一个名为 Speak() 的方法的接口类型,它返回一个字符串。

  • Speaker{} 接口的方法集是 Speak().

下面是一个在 Go 中经常使用的接口:

// https://golang.org/pkg/io/#Reader
type Reader interface {
    Read(p []byte) (n int, err error)
}

让我们看看这段代码的各个部分:

  • 接口名称是 Reader{}

  • 方法集是 Read()

  • Read() 方法的签名是 (p []byte)(n int, err error)

接口可以有多个方法作为其方法集。让我们看看 Go 包中使用的接口:

// https://golang.org/pkg/os/#FileInfo
type FileInfo interface {
    Name() string // base name of the file
    Size() int64 // length in bytes for regular files; system-dependent for others
    Mode() FileMode // file mode bits
    ModTime() time.Time // modification time
    IsDir() bool // abbreviation for Mode().IsDir()
    Sys() interface{} // underlying data source (can return nil)
}

如您所见,FileInfo{} 有多个方法。

总结来说,接口是声明方法集的类型。与其他使用接口的语言类似,它们不实现方法集。实现细节不是定义接口的一部分。在下一个主题中,我们将探讨 Go 要求你能够实现接口的条件。

实现接口

其他编程语言中的接口是显式实现接口的。显式实现意味着编程语言直接且明确地声明该对象正在使用此接口。例如,这是在 Java 中:

class Dog implements Pet

代码段明确指出 Dog 类将实现 Pet 接口。

在 Go 中,接口是隐式实现的。这意味着一个类型将通过拥有接口的所有方法和签名来实现接口。以下是一个示例:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
}
func main() {
  c := cat{}
  fmt.Println(c.Speak())
  c.Greeting()
}
func (c cat) Speak() string {
  return "Purr Meow"
}
func (c cat) Greeting() {
  fmt.Println("Meow,Meow!!!!mmmeeeeoooowwww")
}

让我们将这段代码分解成几个部分:

type Speaker interface {
  Speak() string
}

我们正在定义一个 Speaker{} 接口。它有一个描述 Speak() 行为的方法。该方法返回一个字符串。为了实现 Speaker{} 接口,类型必须拥有接口声明中列出的方法。然后,我们创建一个名为 cat 的空结构体类型:

type cat struct {
}
func (c cat) Speak() string {
  return "Purr Meow"
}

cat 类型有一个返回字符串的 Speak() 方法。这满足了 Speaker{} 接口。现在,cat 的实现者有责任提供 cat 类型 Speak() 方法的实现细节。

注意,没有显式声明 cat 实现了 Speaker{} 接口;它只是通过满足接口的要求来实现。

还要注意的是,cat 类型有一个名为 Greeting() 的方法。类型可以拥有不满足 Speaker{} 接口需求的方法。然而,cat 至少必须拥有满足接口所需的方法集。

输出将如下所示:

Purr Meow
Meow,Meow!!!!mmmeeeeoooowwww

隐式实现接口的优势

隐式实现接口有一些优势。我们看到了当你创建一个接口时,你必须去每个类型并明确声明该类型实现了接口。在 Go 中,满足接口的类型被认为是实现了接口。没有像其他语言那样的 implements 关键字;你不需要声明一个类型实现了接口。在 Go 中,如果它有接口的方法集和签名,它就隐式地实现了接口。

当你更改接口的方法集时,在其他语言中,你必须去所有那些不满足接口的类型,并移除类型的显式声明。在 Go 中并非如此,因为它是隐式声明。

另一个优点是你可以使用接口来处理另一个包中的类型。这解耦了接口定义与其实现。我们将在 第十章包保持项目可管理 中讨论包及其作用域。

让我们看看在主包中如何使用来自不同包的接口的例子。Stringer 接口是 Go 语言中的一个接口,它通过 Go 语言被几个包使用。一个例子是 fmt 包,它在打印值时用于格式化:

type Stringer interface {
  String() string
}

Stringer 是一个可以描述自身为字符串的类型接口。接口名称通常遵循方法名称,但添加了 er 后缀:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
  name string
  age int
}
func main() {
  c := cat{name: "Oreo", age:9}
  fmt.Println(c.Speak())
  fmt.Println(c)
}
func (c cat) Speak() string {
  return "Purr Meow"
}
func (c cat) String() string {
  return fmt.Sprintf("%v (%v years old)", c.name, c.age)
}

让我们将这段代码分解成几个部分:

  • 我们为我们的 cat 类型添加了一个 String() 方法。它返回 nameage 字段的数据。

  • 当我们在 main() 中调用 fmt.Println() 方法,并传入 cat 作为参数时,fmt.Println() 会调用 cat 类型的 String() 方法。

  • 我们的 cat 类型现在实现了两个接口:Speaker{} 接口和 Stringer{} 接口。它具有满足这两个接口所需的方法:

图 7.3:类型可以实现多个接口

图 7.3:类型可以实现多个接口

练习 7.01 – 实现接口

在这个练习中,我们将创建一个简单的程序,演示如何隐式实现接口。我们将有一个 person 结构体,它将隐式实现 Speaker{} 接口。person 结构体将包含 nameageisMarried 作为其字段。程序将调用 person 结构体的 Speak() 方法,并显示一个显示 person 结构体 name 的消息。person 结构体还将通过拥有一个 String() 方法来满足 Stringer{} 接口的要求。你可能还记得,在之前的 隐式实现接口的优势 部分,Stringer{} 接口是 Go 语言中的一个接口。它可以在打印值时用于格式化。这就是我们在这个练习中将如何使用它来格式化 person 结构体字段打印的方式:

  1. 创建一个新的文件,并将其保存为 main.go

  2. 我们将创建一个 package main 并在这个程序中使用 fmt 包:

    package main
    import (
      "fmt"
    )
    
  3. 创建一个名为 Speaker{} 的接口,其中包含一个名为 Speak() 的方法,该方法返回一个字符串:

    type Speaker interface {
      Speak() string
    }
    

    我们已经创建了一个 Speaker{} 接口。任何想要实现我们的 Speaker{} 接口类型都必须有一个返回字符串的 Speak() 方法。

  4. 创建我们的 person 结构体,包含 nameageisMarried 作为其字段:

    type person struct {
      name string
      age int
      isMarried bool
    }
    

    我们的 person 类型包含 nameageisMarried 字段。我们将在 main 函数中使用返回字符串的 Speak() 方法来打印这些字段的内容。拥有一个 Speak() 方法将满足 Speaker{} 接口的要求。

  5. main()函数中,我们将初始化一个person类型,打印Speak()方法,并打印person字段的值:

    func main() {
      p := person{name: "Cailyn", age: 44, isMarried: false}
      fmt.Println(p.Speak())
      fmt.Println(p)
    }
    
  6. person创建一个String()方法并返回一个字符串值。这将满足Stringer{}接口,现在它可以通过fmt.Println()方法被调用:

    func (p person) String() string {
      return fmt.Sprintf("%v (%v years old).\nMarried status: %v ", p.name, p.age, p.isMarried)
    }
    
  7. person创建一个返回字符串的Speak()方法。person类型有一个与Speaker{}接口的Speak()方法具有相同签名的Speak()方法。通过拥有返回字符串的Speak()方法,person类型满足Speaker{}接口。为了满足接口,你必须有与接口相同的方法和方法签名:

    func (p person) Speak() string {
      return "Hi my name is: " + p.name
    }
    
  8. 打开终端并导航到代码的目录。

  9. 运行go build main.go

  10. 修正返回的错误,并确保你的代码与这里的代码片段匹配。

  11. 通过在命令行中输入可执行文件名并使用./main命令来运行可执行文件。

你应该得到以下输出:

Hi my name is Cailyn
Cailyn (44 years old).
Married status: false

在这个练习中,我们看到了如何隐式地实现接口是多么简单。在下一个主题中,我们将通过让不同的数据类型,如 structs,实现相同的接口,并将其传递给任何具有该接口类型参数的函数来在此基础上构建。我们将在下一个主题中更详细地介绍这是如何可能的,并看看为什么类型以各种形式出现是一个好处。

鸭子类型

我们基本上一直在做被称为鸭子类型(duck typing)的事情。鸭子类型是计算机编程中的一个测试:如果它看起来像鸭子,游泳像鸭子,发出鸭子的叫声,那么它一定是一只鸭子。 如果一个类型匹配一个接口,那么你可以在使用该接口的任何地方使用那个类型。鸭子类型是根据方法匹配类型,而不是预期的类型:

type Speaker interface {
  Speak() string
}

任何匹配Speak()方法的都可以是Speaker{}接口。当我们实现一个接口时,我们实际上是通过拥有所需的方法集来符合该接口的:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
}
func main() {
  c := cat{}
  fmt.Println(c.Speak())
}
func (c cat) Speak() string {
  return "Purr Meow"
}

catSpeaker{}接口的Speak()方法匹配,所以catSpeaker{}

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
}
func main() {
  c := cat{}
  chatter(c)
}
func (c cat) Speak() string {
  return "Purr Meow"
}
func chatter(s Speaker) {
  fmt.Println(s.Speak())
}

让我们分部分检查这段代码:

  • 在前面的代码中,我们声明了一个cat类型并为cat类型创建了一个名为Speak()的方法。这满足了Speaker{}接口所需的方法集。

  • 我们创建了一个名为chatter的方法,它接受Speaker{}接口作为参数。

  • main()函数中,我们能够将cat类型传递给chatter函数,它可以评估为Speaker{}接口。这满足了接口所需的方法集。

多态

多态是能够以各种形式出现的能力。例如,一个形状可以表现为正方形、圆形、矩形或任何其他形状:

图 7.4:形状的多态示例

图 7.4:形状的多态示例

Go 不像其他面向对象的语言那样进行子类化,因为 Go 没有类。面向对象编程中的子类化是从一个类继承到另一个类。通过子类化,你继承了另一个类的字段和方法。Go 通过嵌入 struct 和使用接口的多态提供了类似的行为。

使用多态的一个优点是它允许重用已经编写并测试过的方法。通过将intfloatbool等具体类型传递,代码被重用。然而,如果你的 API 接受一个接口,那么调用者可以添加所需的方法集以满足该接口,无论底层类型如何。这种可重用性是通过允许你的 API 接受接口来实现的。任何满足接口的类型都可以传递给 API。我们在前面的例子中已经看到了这种行为。现在是时候更仔细地看看Speaker{}接口了。

正如我们在前面的例子中所看到的,每个具体类型都可以实现一个或多个接口。回想一下,我们的Speaker{}接口可以被dogcatperson类型实现:

图 7.5:由多个类型实现的 Speaker 接口

图 7.5:由多个类型实现的 Speaker 接口

当一个函数接受一个接口作为输入参数时,任何实现该接口的具体类型都可以作为参数传递。现在,通过能够将各种具体类型传递给具有接口类型输入参数的方法或函数,你已经实现了多态。

让我们看看一些渐进的例子,这将使我们能够展示如何在 Go 中实现多态:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
}
func main() {
  c := cat{}
  catSpeak(c)
}
func (c cat) Speak() string {
  return "Purr Meow"
}
func catSpeak(c cat) {
  fmt.Println(c.Speak())
}

让我们分部分检查这段代码:

  • cat满足Speaker{}接口。main()函数调用catSpeak()并传递一个cat类型的参数。

  • catSpeak()内部,它打印出其Speak()方法的结果。

我们将实现一些代码,它接受一个具体类型(catdogperson)并满足Speaker{}接口类型。使用之前的编码模式,它将类似于以下代码片段:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
type cat struct {
}
type dog struct {
}
type person struct {
  name string
}
func main() {
  c := cat{}
  d := dog{}
  p := person{name:"Heather"}
  catSpeak(c)
  dogSpeak(d)
  personSpeak(p)
}
func (c cat) Speak() string {
  return "Purr Meow"
}
func (d dog) Speak() string {
  return "Woof Woof"
}
func (p person) Speak() string {
  return "Hi my name is " + p.name +"."
}
func catSpeak(c cat) {
  fmt.Println(c.Speak())
}
func dogSpeak(d dog) {
  fmt.Println(d.Speak())
}
func personSpeak(p person) {
  fmt.Println(p.Speak())
}

让我们分部分看看这段代码:

type cat struct {
}
type dog struct {
}
type person struct {
  name string
}

我们有三个具体类型(catdogperson)。catdog类型是空的 struct,而personstruct 有一个name字段:

func (c cat) Speak() string {
  return "Purr Meow"
}
func (d dog) Speak() string {
  return "Woof Woof"
}
func (p person) Speak() string {
  return "Hi my name is " + p.name +"."
}

我们的每个类型都隐式地实现了Speaker{}接口。每个具体类型都以与其他类型不同的方式实现它:

func main() {
  c := cat{}
  d := dog{}
  p := person{name:"Heather"}
  catSpeak(c)
  dogSpeak(d)
  personSpeak(p)
}

main()函数中,我们调用catSpeak()dogSpeak()personSpeak()来调用它们各自的Speak()方法。前面的代码有很多执行类似操作的重叠函数。我们可以重构这段代码,使其更简单、更容易阅读。我们将使用实现接口时获得的一些特性来提供更简洁的实现:

package main
import (
  "fmt"
)
type Speaker interface {
  Speak() string
}
func saySomething(say ...Speaker) {
  for _, s := range say {
    fmt.Println(s.Speak())
  }
}
type cat struct {}
func (c cat) Speak() string {
  return "Purr Meow"
}
type dog struct {}
func (d dog) Speak() string {
  return "Woof Woof"
}
type person struct {
  name string
}
func (p person) Speak() string {
  return "Hi my name is " + p.name + "."
}
func main() {
  c := cat{}
  d := dog{}
  p := person{name: "Heather"}
  saySomething(c,d,p)
}

让我们分部分看看这段代码:

func saySomething(say ...Speaker)

我们的saySomething()函数使用可变参数。如果你还记得,可变参数可以接受零个或多个该类型的参数。有关可变函数的更多信息,请参阅第五章减少、重用和回收。参数类型是Speaker。接口可以用作输入参数:

func saySomething(say ...Speaker) {
  for _, s := range say {
    fmt.Println(s.Speak())
  }
}

我们遍历Speaker的切片。对于每种Speaker类型,我们调用Speak()方法。在我们的代码中,我们将catdog结构体类型传递给person函数。该函数接受一个Speaker{}接口类型的参数。可以调用该接口的任何方法。对于这些具体类型,都会调用Speak()方法。

图 7.6:实现 Speaker 接口的多种类型

图 7.6:实现 Speaker 接口的多种类型

main()函数中,我们将通过使用接口来演示多态:

func main() {
  c := cat{}
  d := dog{}
  p := person{name: "Heather"}
  saySomething(c,d,p)
}

我们实现了每个具体类型,catdogpersoncatdogperson类型都满足Speaker{}接口。由于它们匹配接口,因此可以在使用该接口的任何地方使用这些类型。正如你所见,这还包括能够将catdogperson类型传递给一个方法。

通过使用接口和多态,此代码比之前的代码片段更简洁。本章开头示例显示了一个满足Speaker{}接口并调用Speak()方法的单个具体类型。然后我们在运行示例中添加了几个更多具体类型(catdogperson),每个都分别调用它们自己的Speak()方法。我们在该示例中发现了冗余代码,并开始寻找更好的实现解决方案的方法。我们发现接口类型可以作为参数输入类型。通过鸭子类型和多态,我们的第三个和最后一个代码片段能够拥有一个函数,该函数会对满足Speaker()接口的每个类型调用Speak()方法。

练习 7.02 – 使用多态计算不同形状的面积

我们将实现一个程序,该程序将计算三角形、矩形和正方形的面积。该程序将使用一个接受Shape接口的单个函数。任何满足Shape接口的类型都可以作为函数的参数传递。该函数应打印出面积和形状的名称:

  1. 使用你选择的 IDE。

  2. 创建一个新文件,并将其保存为main.go

  3. 我们将有一个名为main的包,并且在这个程序中我们将使用fmt包:

    package main
    import (
      "fmt"
    )
    
  4. 创建Shape{}接口,它有两个方法集,Area() float64Name() string

    type Shape interface {
      Area() float64
      Name() string
    }
    
  5. 接下来,我们将创建trianglerectanglesquare结构体类型。这些类型将各自满足Shape{}接口。trianglerectanglesquare具有计算形状面积所需的适当字段:

    type triangle struct {
      base float64
      height float64
    }
    type rectangle struct {
      length float64
      width float64
    }
    type square struct {
      side float64
    }
    
  6. 我们为triangle结构体类型创建Area()Name()方法。三角形的面积是底 * 高/2Name()方法返回形状的名称:

    func (t triangle) Area() float64 {
      return (t.base * t.height) / 2
    }
    func (t triangle) Name() string {
      return "triangle"
    }
    
  7. 我们为rectangle结构体类型创建Area()Name()方法。矩形的面积是长度 * 宽度Name()方法返回形状的名称:

    func (r rectangle) Area() float64 {
      return r.length * r.width
    }
    func (r rectangle) Name() string {
      return "rectangle"
    }
    
  8. 我们为square结构体类型创建Area()Name()方法。正方形的面积是边长 * 边长Name()方法返回形状的名称:

    func (s square) Area() float64 {
      return s.side * s.side
    }
    func (s square) Name() string {
      return "square"
    }
    

    现在,我们的每个形状(trianglerectanglesquare)都满足Shape接口,因为它们各自都有一个具有适当签名的Area()Name()方法:

图 7.7:Shape 类型的正方形、三角形和矩形面积

图 7.7:Shape 类型的正方形、三角形和矩形面积

  1. 我们现在将创建一个函数,该函数接受Shape接口作为可变参数。该函数将遍历Shape类型,并执行其每个Name()Area()方法:

    func printShapeDetails(shapes ...Shape) {
      for _, item := range shapes {
        fmt.Printf("The area of %s is: %.2f\n", item.Name(), item.Area())
      }
    }
    
  2. main()函数内部,设置trianglerectanglesquare的字段。将这三个都传递给printShapeDetail()函数。因为它们各自都满足Shape接口,所以都可以传递:

    func main() {
      t := triangle{base: 15.5, height: 20.1}
      r := rectangle{length: 20, width: 10}
      s := square{side: 10}
      printShapeDetails(t, r, s)
    }
    
  3. 通过在命令行中运行go build来构建程序:

    go build main.go
    
  4. 修正返回的错误,并确保你的代码与这里的代码片段匹配。

  5. 通过在命令行中输入可执行文件名并按Enter键来运行可执行文件:

    ./main
    

你应该看到以下输出:

The area of triangle is: 155.78
The area of rectangle is: 200.00
The area of square is: 100.00

在这个练习中,我们看到了接口为我们程序提供的灵活性和可重用代码。进一步地,我们将讨论通过接受接口和为我们的函数和方法返回结构体来增加代码重用性和降低耦合的方法。当我们使用接口作为 API 的输入参数时,我们是在声明一个类型需要满足该接口。当使用具体类型时,我们要求 API 的参数必须是该类型。例如,如果函数签名是func greeting(msg string),我们知道传递的参数必须是一个字符串。具体类型可以被认为是非抽象类型(如float64intstring等);然而,接口可以被认为是抽象类型,因为你在满足接口类型的方法集。底层接口类型是一个具体类型,但底层类型不是需要传递到 API 中的类型。类型必须满足接口类型定义的方法集要求。

在未来,如果我们需要传递另一种类型,这意味着我们的 API 上游代码需要更改,或者如果我们的 API 的调用者需要更改其数据类型,它可能会要求我们更改我们的 API 以适应它。如果我们使用接口,这不是问题;我们的代码的调用者需要满足接口的方法集。然后,调用者可以更改底层类型,如果它符合接口要求的话。

接受接口并返回结构体

有一个 Go 谚语说,接受接口,返回结构体。这也可以表述为接受接口并返回具体类型。这个谚语是在谈论为你的 API(函数、方法等)接受接口,并返回结构体或具体类型。这个谚语遵循 Postel 法则,该法则指出,对你所做的事情要保守,对你所接受的东西要宽容。我们关注的是 对你所接受的东西要宽容 部分。通过接受接口,你增加了你的函数或方法的 API 的灵活性。通过这样做,你允许 API 的用户满足接口的要求,但不会强迫用户使用具体类型。如果我们的函数或方法只接受具体类型,那么我们就限制了函数的用户只能使用特定的实现。在本章中,我们将探讨前面提到的 Go 谚语,并了解为什么遵循它是一个好的设计模式。我们将看到,当我们查看代码示例时:

图 7.8:接受接口的好处

图 7.8:接受接口的好处

以下示例将说明接受接口与使用具体类型相比的好处。我们将有两个执行相同任务(解码 JSON)的函数,但它们的输入不同。其中一个函数比另一个函数更优越,我们将讨论为什么是这样。

看看下面的例子:

main.go

package main
import (
    "encoding/json"
    "fmt"
    "io"
    "strings"
)
type Person struct {
    Name string `json:"name"`
    Age int `json:"age"`
}

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter07/Example01/main.go找到。

预期输出如下:

{Joe 18}
{Jane 21}

让我们检查这段代码的每一部分。我们将在接下来的章节中讨论代码的一些部分。这段代码将一些数据解码到一个结构体中。为此使用了两个函数,loadPerson2()loadPerson()

func loadPerson2(s string) (Person, error) {
    var p Person
    err := json.NewDecoder(strings.NewReader(s)).Decode(&p)
    return p, err
}

loadPerson2() 函数接受一个具体的字符串参数并返回一个结构体。返回结构体的方式符合“接受接口,返回结构体”的一半原则。然而,它的接受范围非常有限,并不开放。这限制了函数的使用范围,只能用于狭窄的实现。唯一可以传递的是字符串。当然,在某些情况下这可能可以接受,但在其他情况下可能会出现问题。例如,如果你的函数或方法应该只接受特定的数据类型,那么你可能不想接受接口:

func loadPerson(r io.Reader) (Person, error) {
    var p Person
    err := json.NewDecoder(r).Decode(&p)    return p, err
}

在这个函数中,我们接受 io.Reader{} 接口。io.Reader{} (pkg.go.dev/io#Reader) 和 io.Writer{} (pkg.go.dev/io#Writer) 接口是 Go 包中最常用的接口之一。json.NewDecoder 接受任何满足 io.Reader{} 接口的对象。调用者只需确保他们传递的对象满足 io.Reader{} 接口:

p, err := loadPerson(strings.NewReader(s))

strings.NewReader 返回一个具有 Read(b []byte) (n int, err error) 方法的 Reader 类型,该方法满足 io.Reader{} 接口。它可以传递给我们的 loadPerson() 函数。你可能认为每个函数仍然在执行其预期功能。你会是对的,但假设调用者不再传递字符串,或者另一个调用者将传递包含 JSON 数据的文件:

f, err := os.Open("data.json")
if err != nil {
  fmt.Println(err)
}

我们的 loadPerson2() 函数将无法工作;然而,我们的 loadPerson() 数据将工作,因为 os.Open() 的返回类型满足 io.Reader{} 接口。

假设,例如,数据将通过 HTTP 端点传入。我们将从 *http.Request 获取数据。再次强调,loadPerson2() 函数不是一个好的选择。我们将从 request.Body 获取数据,它恰好实现了 io.Reader{} 接口。

你可能想知道接口是否适合输入参数。如果是这样,为什么我们还要返回它们呢?如果你返回一个接口,这会给用户增加不必要的难度。用户将不得不查找接口,然后找到方法集和方法集的签名:

func someFunc() Speaker{} {
  // code
}

你需要查看 Speaker{} 接口的定义,然后花时间查看实现代码,所有这些对于函数的用户来说都是不必要的。如果函数的返回类型需要接口,函数的用户可以为该具体类型创建接口并在他们的代码中使用它。

当你开始遵循这个 Go 谚语时,检查 Go 标准包中是否有接口。这将增加你的函数可以提供的不同实现的数量。我们的函数用户可以通过使用 Go 标准包中的 io.Reader{} 接口,使用 strings.newReaderhttp.Request.Bodyos.File 等各种实现,就像我们的代码示例一样。

空接口

空接口是一个没有方法集和行为的接口。空接口没有指定任何方法:

interface{}

这是一个简单但复杂的概念,需要我们理解。如您所知,接口是隐式实现的;没有implements关键字。由于空接口没有指定任何方法,这意味着 Go 中的每个类型都自动实现了空接口。所有类型都满足空接口。

在下面的代码片段中,我们将演示如何使用空接口。我们还将看到接受空接口的函数如何允许传递任何类型到该函数:

main.go

package main
import "fmt"
type Speaker interface {
    Speak() string
}
type cat struct {
    name string
}

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/01d1c9d340172a55335add4ad7adc285b7a51fe4/Chapter07/Example02/main.go找到。

预期的输出如下:

({oreo}, main.cat)
({oreo}, main.cat)
(99, int)
(false, bool)
(test, string)

让我们分部分评估代码:

func emptyDetails(s interface{}) {
  fmt.Printf("(%v, %T)\n", i, i)
}

函数接受一个空的interface{}。由于所有类型都实现了空接口,因此可以将任何类型传递给该函数。它将打印值和具体类型。%v动词打印值,%T动词打印具体类型:

func main() {
  c := cat{name: "oreo"}
  i := 99
  b := false
  str := "test"
  catDetails(c)
  emptyDetails(c)
  emptyDetails(i)
  emptyDetails(b)
  emptyDetails(str)
}

我们传递了cat类型、integerboolstringemptyDetails()函数将打印它们中的每一个:

图 7.9:猫类型实现了空接口 interface{}和 Speaker 接口

图 7.9:猫类型实现了空接口 interface{}和 Speaker 接口

cat类型隐式实现了空interface{}Speaker{}接口。

现在我们对空接口有了基本的了解,我们将探讨在即将到来的主题中它们的各种用法,包括以下内容:

  • 类型切换

  • 类型断言

  • Go 包的示例

类型断言和 switch

类型断言提供了访问接口具体类型的方法。请记住,interface{}可以是任何值:

package main
import (
  "fmt"
)
func main() {
  var str interface{} = "some string"
  var i interface{} = 42
  var b interface{} = true
  fmt.Println(str)
  fmt.Println(i)
  fmt.Println(b)
}

类型断言的输出将如下所示:

some string
42
true

在每个变量声明的实例中,每个变量都被声明为一个空接口,但str的具体值是一个字符串,i是整数,b是布尔值。

当存在空的 interface{} 类型时,有时了解底层具体类型是有益的。例如,您可能需要根据该类型执行数据操作。如果该类型是字符串,您将执行与整数值不同的数据修改和验证。当您消费未知模式的 JSON 数据时,这也适用。在摄入过程中,该 JSON 中的值可能是已知的。我们需要将数据转换为 map[string]interface{} 并根据其底层类型或结构执行各种数据按摩或转换。本章后面的活动将向我们展示如何执行此类操作。我们可以使用 strconv 包执行类型转换:

package main
import (
  "fmt"
  "strconv"
)
func main() {
  var str interface{} = "some string"
  var i interface{} = 42
  fmt.Println(strconv.Atoi(i))
}

图 7.10:需要类型断言时的错误

图 7.10:需要类型断言时的错误

因此,看起来我们不能使用类型转换,因为类型不兼容类型转换。我们需要使用类型断言:

v := s.(T)

上一条语句表示它断言接口值 s 是类型 T,并将 v 的底层值赋给它:

图 7.11:类型断言流程

图 7.11:类型断言流程

考虑以下代码片段:

package main
import (
  "fmt"
  "strings"
)
func main() {
  var str interface{} = "some string"
  v := str.(string)
  fmt.Println(strings.Title(v))
}

让我们再次检查前面的代码:

  • 上一段代码断言 strstring 类型,并将其赋值给变量 v

  • 由于 vstring 类型,它将以标题大小写打印它

结果如下:

Some String

当断言与预期类型匹配时,这是很好的。那么,如果 s 不是类型 T 会发生什么?让我们看看:

package main
import (
  "fmt"
  "strings"
)
func main() {
  var str interface{} = 49
  v := str.(string)
  fmt.Println(strings.Title(v))
}

让我们检查前面的代码:

  • str{} 是一个空接口,具体类型是 int

  • 类型断言正在检查 str 是否是字符串类型,但在这种情况下,它不是,所以代码将引发恐慌

  • 结果如下:

图 7.12:失败的类型断言

图 7.12:失败的类型断言

不希望抛出恐慌。然而,Go 有一种方法来检查 str 是否是字符串:

package main
import (
  "fmt"
)
func main() {
  var str interface{} = "the book club"
  v, isValid := str.(int)
  fmt.Println(v, isValid)
}

让我们再次检查前面的代码:

  • 类型断言返回两个值,底层值和布尔值。

  • isValid 被赋值为 bool 类型的返回值。如果它返回 true,则表示 strint 类型。这意味着断言是正确的。我们可以使用返回的布尔值来确定对 str 可以采取哪些操作。

  • 当断言失败时,它将返回 false。返回值将是您试图断言的零值。它也不会引发恐慌。

有时会不知道空接口的具体类型。这就是您将使用类型选择的情况。类型选择可以执行多种类型的断言;它类似于常规的 switch 语句。它有 casedefault 子句。区别在于类型选择语句评估的是类型而不是值。

这里是一个基本的语法结构:

switch v := i.(type) {
case S:
  // code to act upon the type S
}

让我们检查前面的代码:

i.(type)

语法类似于类型断言 i.(int),除了在我们的例子中指定的类型 int 被替换为 type 关键字。被断言的类型 i 被分配给 v;然后,它被与每个 case 语句进行比较:

case S:

switch 类型中,语句用于评估类型。在常规切换中,它们用于评估值。在这里,它用于评估 S 的类型。

现在我们已经对类型选择语句有了基本理解,让我们看看一个使用我们刚刚评估的语法的例子:

main.go

func typeExample(i []interface{}) {
    for _, x := range i {
    switch v := x.(type) {
        case int:
            fmt.Printf("%v is int\n", v)
        case string:
            fmt.Printf("%v is a string\n", v)
        case bool:
            fmt.Printf("a bool %v\n", v)
        default:
            fmt.Printf("Unknown type %T\n", v)
        }
    }
}

完整的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter07/Example03/main.go找到。

现在我们将分块探索代码:

func main() {
  c := cat{name: "oreo"}
  i := []interface{}{42, "The book club", true, c}
  typeExample(i)
}

main() 函数中,我们初始化一个变量 i 为接口的切片。在这个切片中,我们有 intstringboolcat 类型:

func typeExample(i []interface{})

该函数接受一个接口的切片:

  for _, x := range i {
    switch v := x.(type) {
      case int:
        fmt.Printf("%v is int\n", v)
      case string:
        fmt.Printf("%v is a string\n", v)
      case bool:
        fmt.Printf("a bool %v\n", v)
      default:
        fmt.Printf("Unknown type %T\n", v)
    }
  }

for 循环遍历接口的切片。切片中的第一个值是 42switch 情况断言切片值 42int 类型。case int 语句将评估为 true 并打印出 42int。当 for 循环遍历到 cat 类型的最后一个值时,switch 语句将不会在其情况评估中找到该类型。由于在 case 语句中没有检查 cat 类型,所以默认将执行其 print 语句。以下是代码执行的结果:

42 is int
The book club is string
a bool true
Unknown type main.cat

练习 7.03 – 分析空接口{}数据

在这个练习中,我们得到了一个映射。映射的键是一个字符串,其值是一个空的 interface{}。映射的值包含存储在映射值部分中的不同类型的数据。我们的任务是确定每个键的值类型。我们将编写一个程序来分析 map[string]interface{} 的数据。理解数据的值可以是任何类型。我们需要编写逻辑来捕获我们不想查找的类型。我们将把信息存储在一个结构体切片中,该切片将包含键名、数据和数据类型:

  1. 创建一个名为 main.go 的新文件。

  2. 在文件内部,我们将有一个 main 包,并需要导入 fmt 包:

    package main
    import (
      "fmt"
    )
    
  3. 我们将创建一个名为 record 的结构体,它将存储来自 map[string]interface{} 的键、值类型和数据。这个结构体用于存储我们对映射进行的分析。key 字段是映射键的名称。valueType 字段存储映射中作为值存储的数据类型。data 字段存储我们正在分析的数据。它是一个空的 interface{},因为映射中可能有各种类型的数据:

    type record struct {
      key string
      valueType string
      data interface{}
    }
    
  4. 我们将创建一个 person 结构体,它将被添加到我们的 map[string]interface{} 中:

    type person struct {
      lastName string
      age int
      isMarried bool
    }
    
  5. 我们将创建一个animal结构体,并将其添加到我们的map[string]interface{}中:

    type animal struct {
      name string
      category string
    }
    
  6. 创建一个newRecord()函数。key参数将是我们的映射键。该函数还接受interface{}作为输入参数。i将是传递给函数的键的映射值。它将返回一个record类型:

    func newRecord(key string, i interface{}) record {
    
  7. newRecord()函数内部,我们初始化record{}并将其赋值给r变量。然后,我们将r.key赋值给键输入参数。

  8. switch语句将i的类型赋值给v变量。v变量的类型将与一系列case语句进行比较。如果一个类型在某个case语句中评估为true,那么valueType记录将被分配给该类型,同时v的值将被分配给r.data,然后返回record类型:

      r := record{}
      r.key = key
      switch v := i.(type) {
      case int:
        r.valueType = "int"
        r.data = v  case bool:
        r.valueType = "bool"
        r.data = v  case string:
        r.valueType = "string"
        r.data = v  case person:
        r.valueType = "person"
    
  9. 需要r.data = vA default语句来配合switch语句。如果v的类型在case语句中没有评估为true,那么将执行defaultrecord.valueType将被标记为unknown

      default:
        r.valueType = "unknown"
        r.data = v  }
        return r
    }
    
  10. main()函数内部,我们将初始化我们的映射。映射被初始化为一个字符串作为键,一个空接口作为值。然后,我们将a赋值给一个animal结构体字面量,将p赋值给一个person结构体字面量。接着,我们开始向映射中添加各种键值对:

    func main() {
      m := make(map[string]interface{})
      a := animal{name: "oreo", category: "cat"}
      p := person{lastName: "Doe", isMarried: false, age: 19}
      m["person"] = p
      m["animal"] = a
      m["age"] = 54
      m["isMarried"] = true
      m["lastName"] = "Smith"
    
  11. 接下来,我们初始化一个record切片。我们遍历映射,并将记录添加到rs中:

      rs := []record{}
      for k, v := range m {
        r := newRecord(k, v)
        rs = append(rs, r)
      }
    
  12. 现在,打印出记录的字段值。我们遍历记录的切片并打印每个记录值:

      for _, v := range rs {
        fmt.Println("Key: ", v.key)
        fmt.Println("Data: ", v.data)
        fmt.Println("Type: ", v.valueType)
        fmt.Println()
      }
    }
    

遍历映射可能会产生不同的输出顺序。预期输出的一个例子如下:

图 7.13:练习的输出

图 7.13:练习的输出

这个练习展示了 Go 语言识别空接口底层类型的能力。正如您从结果中可以看到,我们的类型选择器能够识别每种类型,除了animal键的值。它的类型被标记为unknown。它甚至能够识别person结构体类型,并且数据具有结构体的字段值。

活动 7.01 – 计算薪酬和绩效评估

在这个活动中,我们将计算经理和开发者的年度薪酬。我们将打印出开发者和经理的名字以及他们全年的薪酬。开发者的薪酬将基于时薪。开发者类型还将跟踪他们一年中工作的小时数。开发者类型还将包括他们的评估。评估将需要是一个字符串键的集合。这些字符串是开发者正在被评估的类别,例如工作质量、团队合作和沟通。

这个活动的目的是通过调用一个接受接口的单个函数payDetails()来演示接口的多态性。这个payDetails()函数将打印开发者和经理的薪酬信息。

以下步骤可以帮助你找到解决方案:

  1. 创建一个具有IdFirstNameLastName字段的Employee类型。

  2. 创建一个具有以下字段的Developer类型:Employee类型的IndividualHourlyRateHoursWorkedInYear以及map[string]interface{}类型的Review

  3. 创建一个具有以下字段的Manager类型:Employee类型的IndividualSalaryCommissionRate

  4. 创建一个具有Pay()方法返回字符串和float64Payer接口。

  5. Developer类型应该通过返回Developer名称和根据Developer.HourlyRate * Developer.HoursWorkInYear的计算返回开发者年度工资来实现Payer{}接口。

  6. Manager类型应该通过返回Manager名称和根据Manager.Salary + (Manager.Salary * Manager.CommissionRate)的计算返回Manager年度工资来实现Payer{}接口。

  7. 添加一个名为payDetailsp Payer)的函数,该函数接受一个Payer接口并打印fullName和从Pay()方法返回的工资。

  8. 我们现在需要计算一个开发者的评价等级。Review是通过map[string]interface{}获得的。该映射的键是一个字符串;它是开发者被评价的内容,例如工作质量、团队合作和技能。

  9. 映射中的空interface{}是必需的,因为一些经理将评价作为字符串给出,而另一些则作为数字给出。以下是字符串到整数的映射:

    "Excellent" – 5
    "Good" – 4
    "Fair" – 3
    "Poor" – 2
    "Unsatisfactory" – 1
    
  10. 我们需要将绩效评价值计算为float类型。它是映射interface{}的总和除以映射的长度。考虑到评价可以是字符串或整数,因此你需要能够接受这两种类型并将它们转换为浮点数。

预期的输出如下:

Eric Davis got a review rating of 2.80
Eric Davis got paid 84000.00 for the year
Mr. Boss got paid 160500.00 for the year

注意

该活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter07/Activity7.01

在这个活动中,我们看到了使用空接口的好处,它可以接受任何类型的数据。然后我们使用了类型断言和类型选择语句来根据空接口的底层具体类型执行某些任务。

any

interface{}any关键字。使用any类型定义,Go 已经替换了所有对空接口的引用。然而,需要注意的是,它们是可互换的,是类型别名。

摘要

本章在介绍接口时提出了一些基本和高级主题。我们了解到 Go 对接口的实现与其他语言有一些相似之处;例如,接口不包含它所表示的行为的实现细节,接口是方法的蓝图。实现接口的不同类型可以在它们的实现细节上有所不同。然而,Go 在实现接口方面与其他语言不同。我们了解到实现是隐式完成的,而不是像其他语言那样显式完成。

这意味着 Go 不支持子类化;因此,为了实现多态性,它使用接口。它允许接口类型以不同的形式出现,例如,Shape 接口可以表现为矩形、正方形或圆形。

我们还讨论了接受接口并返回结构体的设计模式。我们演示了这种模式允许其他调用者有更广泛的使用。我们考察了空接口,并看到了在不知道传递的类型或可能传递多个不同类型到您的 API 时如何使用它。尽管在运行时我们不知道类型,但我们向您展示了如何使用类型断言和类型切换来确定类型。我们还看到了有关 any 关键字作为空接口类型别名的更新。对这些各种工具的知识和实践将帮助您构建健壮和流畅的程序。

在下一章中,我们将探讨更多关于 Go 1.18 的泛型更新,以及这如何允许开发者为多种类型的变量使用代码!

第八章:通用算法超级能力

概述

本章将讨论 Go 的类型参数语法为开发者带来的灵活性和表达力。随着我们探索本章内容,我们将揭示创建超越单一变量类型限制的算法的方法。通过利用类型参数的力量,开发者能够创建代码的泛型版本,使其能够无缝地在多种类型上运行。本章将强调减少代码重复的同时,保留 Go 强类型系统内固有的稳健安全性的总体目标。

本章还将带您进入约束的世界,展示 Go 语言如何通过加固通用算法来防止意外错误,并帮助您了解何时使用通用算法而不是接口。通过实际示例和活动,您将掌握设计通用算法的技巧,并了解通用算法的超级能力。到本章结束时,您将具备深刻理解在 Go 语言中何时、为什么以及如何运用通用算法超级能力的知识。我们还将介绍一些最佳实践,并阐明何时使用接口与通用类型。

Google 的 Go 团队始终在思考如何让 Go 开发者的生活更轻松,以及我们未来需要哪些工具、包和支持——始终以完全向后兼容的方式。在本章中,我们将扩展我们迄今为止获得的知识,并讨论 Go 泛型。

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter08找到。

简介

技术要求

泛型在 Go 1.18 版本中正式成为语言的一部分。Go 泛型提供了一种强大的开发代码的方法,可以消除重复,简化可读性,并使开发者能够在函数中使用多种类型。然而,权力越大,责任越大。让我们进一步讨论泛型。

何时使用泛型?

最终决定将泛型引入 Go 编程语言的决定并非易事。由于它们对语言的影响如此之大,因此记住我们作为开发者的根源,并不要让泛型这样的支持改变我们编写代码的方式。换句话说,您应该继续编写正常的 Go 代码,而不是从一开始就过度设计类型。这些都是根植于简单性和可读性的基本 Go 哲学,这些是语言的核心原则。

在将 Go 泛型集成到代码库时需要考虑的见解如下:

  • 编写正常的 Go 代码,不要设计类型:从你典型的具体类型和简单函数开始,利用 Go 强大的静态类型和简洁性。泛型从未打算取代指导语言使用的根本原则,而是一个需要明智应用的工具。

  • 避免样板代码:这是引入泛型到语言中的主要动机之一。当你发现自己为不同的类型编写重复且几乎相同的代码时,那么这是一个信号,表明泛型可以帮助简化你的实现。而不是为各种数据结构和类型重复逻辑,你可以创建与不同类型无缝工作的泛型函数或类型。这消除了代码冗余并提高了开发者对逻辑的维护性。

  • 考虑代码复杂性:如果你的项目涉及复杂的数据结构或算法,泛型可以帮助抽象复杂性,使你的代码更易于理解。然而,要小心不要过度设计;只有在泛型真正简化你的代码时才引入泛型。

  • 增强代码灵活性:泛型允许函数和数据结构与各种类型一起工作。如果你的代码需要适应不同的数据类型,同时不牺牲性能或安全性,那么泛型可以是一个宝贵的补充。

  • 对未来代码进行加固:如果你预计会有变化或扩展,其中引入新类型很可能是必然的,那么尽早采用泛型可以加固你的代码,并减少未来大量重构的需求。

要查看泛型在 Go 中的实际应用,让我们首先看看一个常规函数的实现,使用直观的方法找到一个函数传入的整数值的最大值:

package main
import "fmt"
func findMaxInt(nums []int) int {
    if len(nums) == 0 {
        return -1
    }
    max := nums[0]
    for _, num := range nums {
        if num > max {
            max = num
        }
    }
    return max
}
func main() {
    max := findMaxInt([]int{1, 32, 5, 8, 10, 11})
    fmt.Printf("max integer value: %v\n", max)
}

如果我们想要现在找到不同类型输入的最大值,例如浮点值,那么我们就必须添加一个包含重复逻辑的新函数:

func findMaxFloat(nums []float64) float64 {
    if len(nums) == 0 {
        return -1
    }
    max := nums[0]
    for _, num := range nums {
        if num > max {
            max = num
        }
    }
    return max
}

你可以看到这已经重复了。如果我们想要检查额外类型的最大值,那么到目前为止,我们会有一大堆重复的逻辑。然而,现在我们可以看看拥有一个泛型最大值函数在这里如何有益:

func findMaxGenericNum int | float64 Num {
    if len(nums) == 0 {
        return -1
    }
    max := nums[0]
    for _, num := range nums {
        if num > max {
            max = num
        }
    }
    return max
}

虽然这是一个简单的例子来寻找最大值,但我们通过将扩展代码转换为添加泛型并去除代码重复,使得代码更加简洁。你还可以看到,前面的函数签名使用了不同的符号,利用 Go 中的泛型允许函数接受整数输入或浮点数输入。现在,让我们引入类型参数,这是泛型的一个基本方面,有助于开发者提高代码的清晰度和可维护性,这将在我们前面的泛型函数签名中解释不同的符号。

类型参数

Go 函数的类型参数允许你使用支持泛型输入的类型来参数化一个函数。这是一种向编译器指定调用泛型函数时允许的类型的方式,并在给定函数中代表类型的占位符。类型参数列表看起来像正常的参数列表,但被方括号包围。例如,[T any] 声明了一个可以是任何类型的 T 类型参数。any 关键字在前一章中已经提到。

为了继续理解类型参数,让我们回到先前的示例代码中的最大泛型函数签名:

func findMaxGenericNum int | float64 Num {

这表明我们的函数,名为 findMaxGeneric,包含一个类型参数 Num,它可以由整数或 float64 类型实例化。它将接受一个 Num 的切片,并返回结果的最大整数或 float64 值。

与类型参数相关的一个有趣的概念是类型集。在先前的示例函数签名中,我们讨论了 Num 可以由整数或 float64 类型实例化。这意味着 Num 类型参数的类型集是整数和 float64 类型的并集。因此,我们的 findMaxGeneric 函数可以用这些受约束允许的类型为 Num 调用。类型约束将在本章进一步讨论。

注意

类型参数通常使用大写字母来强调它们确实是类型。

我们可以使用以下代码调用泛型函数并传入我们的输入:

maxGenericInt := findMaxGeneric([]int{1, 32, 5, 8, 10, 11})

我们传递给函数的数字被称为我们的类型参数。向函数提供类型参数称为实例化。在泛型中,当你为类型参数提供类型参数时,实例化很重要。类型参数是使用泛型函数时提供的或推断的实际类型。它是泛型代码实例化或调用时替换类型参数的具体类型。

此外,如果我们为我们的类型参数传递无效的类型参数,Go 编译器会报错。例如,如果我们尝试传递字符串值,我们会看到错误:

string does not satisfy int | float64 (string missing in int | float64)

我们的代码没有正确编译,因为 Go 的类型系统阻止我们为我们的类型参数传递无效的类型参数。

活动 8.01 – 最小值

在这个活动中,我们编写一个简单的函数来计算最小值,其中输入可以是整数或 float64 类型:

  1. 创建一个 findMinGeneric 函数,用于计算输入切片的最小值。

    输入只能为整数或 float64 类型。

  2. 打印出整数和浮点值的结果最小值。

对于使用 []int{1, 32, 5, 8, 10, 11} 的输入,你的输出应该如下所示:

min value: 1

对于使用 []float64{1.1, 32.1, 5.1, 8.1, 10.1, 11.1} 的输入,你的输出应该如下所示:

min value: 1.1

注意

本活动的解决方案可以在本章的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter08/Activity08.01

类型参数在处理操作任何元素类型的切片、映射和通道的函数时很有用。此外,当函数具有这些类型的参数且不基于元素类型做出假设时,它可以被泛化——例如,返回任何映射类型的键。这在与通用数据结构(如链表或二叉树)一起使用时也很有用。用类型参数替换元素类型可以提供更通用的数据结构,这种结构具有更高的可重用性。你不应该过早地使用类型参数。等到你即将编写样板代码时再使用。过早的抽象可能导致不必要的复杂性,并使代码更难被他人理解。建议等到你遇到对泛型解决方案的具体需求时再使用,尤其是在面对重复模式或样板代码时。这种方法与 Go 的简单性和渐进式设计哲学相吻合,确保泛型引入代码库是有目的和合理的。

现在,让我们来探讨类型约束以及它们如何为我们提供一种指定类型参数必须具备的能力或属性的方法,以便与泛型函数一起使用。

类型约束

类型约束是函数类型参数的一种元类型。类型约束规定了任何给定类型参数允许的类型参数。

Go 泛型中的类型约束指的是定义类型集合的接口。这些接口在指定类型参数在处理泛型函数或类型时必须满足的要求或能力方面发挥着强大的作用。为了有效地使用这些接口,它们必须放置在所谓的“约束位置”中,具体是在类型参数列表中声明类型参数的地方。

在此约束位置,当声明泛型函数或类型时,约束是通过使用接口类型来定义类型参数的预期行为来表达的。这确保了提供的类型遵守指定的约束,允许泛型代码安全地操作它们。通过强制执行这些约束,Go 编译器可以在编译时进行彻底的类型检查,增强代码的可靠性和可维护性。

当处理更复杂类型时,通常您会声明约束为接口。约束允许任何实现了该接口的类型与函数一起使用。约束接口可以引用特定的更基本类型。使用约束接口可以帮助将类型约束提取为更易于阅读的形式。

包含对 Go 编程语言中添加的泛型约束的独立逻辑集,一个名为 constraints 的标准库包已被实验性地添加,您可以探索它以获取更多关于在处理类型参数时定义约束的见解。

练习 8.01 – 使用接口计算最大值

让我们看看如何将之前的最大逻辑扩展为更易于阅读的形式,使用接口作为我们的类型约束。现在,我们仍然只允许整数和 float64 值:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 文件中,将主包名添加到文件顶部:

    package main
    
  3. 现在,添加我们将在此文件中使用的导入:

    import (
      "fmt"
    )
    
  4. 创建一个 Number 接口,它将代表我们允许作为输入的类型:

    type Number interface {
        int | float64
    }
    
  5. 创建一个函数,它接受一个数字切片并返回最大值:

    func findMaxGenericNum Number Num {
    
  6. 通过验证是否向函数传递了输入来确保有效输入:

        if len(nums) == 0 {
            return -1
        }
    
  7. 获取第一个值以设置一个占位符最大值,然后再检查剩余的值:

        max := nums[0]
    
  8. 遍历数字:

        for _, num := range nums {
    
  9. 检查当前数字是否大于占位符最大值,并在需要时将最大值重置为当前值:

            if num > max {
                max = num
            }
    
  10. 关闭 for 循环:

        }
    
  11. 返回最大值:

        return max
    
  12. 关闭函数:

    }
    
  13. 定义主函数:

    func main() {
    
  14. 调用我们的函数并打印出整数和 float64 输入的结果:

        maxGenericInt := findMaxGeneric([]int{1, 32, 5, 8, 10, 11})
        fmt.Printf("max generic int: %v\n", maxGenericInt)
        maxGenericFloat := findMaxGeneric([]float64{1.1, 32.1, 5.1, 8.1, 10.1, 11.1})
        fmt.Printf("max generic float: %v\n", maxGenericFloat)
    
  15. 关闭主函数:

    }
    

运行前面的代码将显示以下输出:

max generic int: 32
max generic float: 32.1

我们现在已经看到了如何通过定义一个 Number 接口来查找最大值,使我们的类型约束接口更易于阅读。您可以看到定义一个可以用于整数和 float64 值的函数的好处。

在上一章中,我们学习了大量的接口知识以及它们如何定义一组类型必须实现的方法。当使用泛型时,您可以通过指定它们必须满足某些接口要求来表达对用作类型参数的类型的约束。

对于类型参数,您可能会看到如 comparable 或定义特定方法的自定义接口等约束。让我们看看如何利用一个更复杂的 comparable 示例。

练习 8.02 – 计算农场物品的最大库存

假设有一个仓库,里面存放着不同的库存物品。我们可以使用泛型来计算农场不同物品的最大库存:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. main.go 文件中,将主包名添加到文件顶部:

    package main
    
  3. 现在,添加我们将在此文件中使用的导入:

    import (
      "fmt"
    )
    
  4. 定义一个使用泛型查找最大农场库存的函数:

    func FindLargestRanchStockK comparable, V int | float64 K {
    
  5. 定义变量以保存迄今为止找到的最大库存和库存物品的名称:

        var stock V
        var name K
    
  6. 遍历映射,如果找到的新值大于当时的最大库存,则更新值为最大值并保存物品名称:

        for k, v := range m {
            if v > stock {
                stock = v
                name = k
            }
        }
    
  7. 返回牧场中库存量最大的物品的名称:

        return name
    
  8. 关闭函数:

    }
    
  9. 定义main函数:

    func main() {
    
  10. 定义我们的牧场库存物品:

        animalStock := map[string]int{
            "Chicken": 5,
            "Cattle": 20,
            "Horses": 4,
         }
        miscStock := map[string]float64{
            "Hay": 5.5,
            "Feed": 1.2,
            "Fertilizer": 4.5,
         }
    
  11. 调用我们的函数,并打印出牧场中库存量最大的物品的结果:

        largestStockOnRanchInt := FindLargestRanchStock(animalStock)
        fmt.Printf("The largest stocked item on the ranch is %s\n", largestStockOnRanchInt)
        largestStockOnRanchFloat := FindLargestRanchStock(miscStock)
        fmt.Printf("The largest stocked item on the ranch is %s\n", largestStockOnRanchFloat)
    
  12. 关闭main函数:

    }
    

运行前面的代码显示以下输出:

The largest stocked item on the ranch is Cattle
The largest stocked item on the ranch is Hay

我们现在看到了一个使用comparable的泛型示例。在函数中,我们对K类型参数的约束是comparable。这得益于 Go 使用comparable标准库提供的辅助器启用的常用约束类型。comparable允许任何值可以作为比较运算符(如==!=)的操作数。Go 要求映射的键是可比较的,因此我们的映射键类型上的comparable声明对于在牧场映射中使用K作为键是必要的。如果我们没有声明Kcomparable,那么 Go 编译器将拒绝在函数参数中引用map[K]V

如您所见,Go 允许一种非常强大的接口类型形式来表达约束。我们可以轻松地扩展这个例子,使其适用于更复杂的接口和约束。

值得注意的是,用类型参数替换接口类型可以使数据的底层存储更高效。这也可能意味着代码可以避免类型断言,并在编译时进行全面类型检查。

既然我们已经讨论了类型参数和类型约束,让我们看看类型推断时发生了什么。

类型推断

Go 编译器从函数参数中推断我们想要使用的类型。这被称为类型推断。编译器将从类型参数约束中推断类型参数。

类型推断要么成功,要么失败。当编译器发现问题时,它会抱怨,并提供了需要纠正的类型参数。使用泛型旨在简单;然而,类型推断的底层细节非常复杂。这也是作者们正在迭代以改进的内容。

到目前为止,当我们谈到调用泛型函数时,我们已经介绍了如何通过在方括号中指定类型名称来指定类型参数。这允许编译器知道在您调用的函数中替换类型参数。然而,您可以选择省略类型参数,因为大多数情况下 Go 可以推断它们。但是,通过省略类型参数来简化代码并不总是可能的。当编译器运行您的代码时,它会将每个类型参数替换为具体的类型。

这可以通过我们许多函数签名中的具体例子来看到,我们允许编译器推断我们的类型。例如,在上一个练习中,我们讨论了牧场物品库存。我们的函数签名在代码中如下所示:

largestStockOnRanchInt := FindLargestRanchStock(animalStock)

此调用允许编译器推断 animalStock 的类型。然而,它也等同于以下:

largestStockOnRanchInt := FindLargestRanchStockstring, int

在这里,我们明确声明了传递给我们的键和值的类型。在处理泛型函数时,有一些情况下可能无法或不建议依赖编译器推断类型:

  • PrintType 函数用于打印值的类型。如果你传入一个字符串,它应该识别为字符串;如果你传入一个整数,它应该识别为整数。在调用此函数时,你明确使用 PrintType("Hello")PrintType(42)PrintType(3.14) 等方式声明类型。

  • PrintTwoTypes 函数接受两个可能不同类型的参数。在调用此函数时,你可能出于各种原因明确指定类型。然后,PrintTwoTypes 可以定义为一个整数和一个字符串参数,并按此方式使用 - PrintTwoTypes(42, "Hello")

  • ChainCalls,处理一个值然后调用另一个函数 AnotherFunction。在这里,你可能明确声明链式调用的类型以确保流畅性。

你还可以添加显式类型以在复杂场景中增加清晰度或提高可读性,减少在类型推断时花费的调试时间。

何时使用泛型与接口

在 Go 中何时使用泛型与接口的问题通常取决于你解决问题的本质和代码的具体要求。

Go 中的泛型允许你编写可以在多种类型上操作的功能或数据结构,而不会牺牲类型安全。使用泛型,你可以创建与不同类型一起工作的函数或结构,无需代码重复,同时保持编译时的安全检查。

Go 中的接口定义了一组方法签名。任何实现接口所有方法的类型都被说成是满足接口的。接口提供了一种在 Go 中实现多态的方法,使代码能够与共享一组共同行为的不同类型一起工作。接口在技术上是一种泛型编程的形式,允许开发者捕获不同类型的共同方面并将它们作为方法表达。这不仅提供了一个很好的抽象层,还避免了重复逻辑。

当你的工作需要其他可能实现或你希望捕获的特定行为时,你应该使用接口。泛型在编写类型无关函数和方法时,对于在静态类型语言中保持编译时的类型安全非常有用。对于不同的用例,还可以考虑性能和优化基准测试等其他因素。

有哪些最佳实践?

在处理泛型代码时,以下是一些值得考虑的最佳实践:

  • 优先使用函数而非方法:方法是与类型关联的函数,并通过接收者调用;因此,在泛型的上下文中,函数更灵活,因为它不受特定类型的限制。这允许更容易的重用,以及将不同类型的函数组合在一起的能力。

  • 转换的简便性:将方法转换为函数比向类型中添加函数要容易。函数可以独立于特定类型定义。在泛型的上下文中,你可以使用满足所需约束的任何类型的泛型函数。如果后来有理由将其转换为方法,那么你可以更容易地这样做,而无需修改原始函数。

  • comparable。这为未来在处理类型参数和更广泛类型的函数使用中提供了灵活性。

在 Go 中使用泛型时,也要注意可能涉及的复杂性。它们是我们工具箱中的工具,要明智和正确地使用。通过这样做,你可以利用泛型的力量,而不会损害定义 Go 编程语言的简洁性和可读性。

摘要

在本章中,我们探讨了 Go 泛型的世界,这是语言的一项突破性增强,提供了诸如类型参数、约束和类型推断等关键特性。类型参数,封装在方括号内,作为多才多艺的占位符出现,使得在不了解它们将要与之交互的具体类型的情况下,也能创建函数和数据结构。引入如comparable之类的约束增强了类型安全性和清晰度,确保泛型构造遵循特定的规则或接口。此外,编译器的类型推断开启了一个简洁和精简代码的新时代,开发者可以借助静态类型而不必承担显式类型注解的负担。

虽然 Go 泛型仍在不断完善和添加中,但它是一项强大的补充,旨在赋予开发者以前所未有的轻松程度编写高效、可重用代码的能力。现在我们知道了如何利用泛型编写最优和可重用代码,我们可以在下一章中扩展这一知识。在那里,我们将通过涵盖 Go 模块和了解如何在大规模和协作努力中重用代码来加深我们的理解。

第三部分:模块

模块作为各种应用程序使用的可重用代码的存储库。无论大小,模块都使得代码组织高效,并提高了可重用性。在本节中,你将学习如何有效地创建和管理模块,利用包和外部模块来简化你的开发过程。

本部分包含以下章节:

  • 第九章使用 Go 模块定义项目

  • 第十章, 保持项目可管理

  • 第十一章, 解决虫害的调试技巧,

  • 第十二章, 关于时间

第九章:使用 Go 模块定义项目

概述

本章深入探讨了使用 Go 模块来结构和管理工作 Go 项目。我们将从介绍模块的概念及其在组织代码中的重要性开始。本章还将涵盖创建第一个模块,同时讨论必要的go.modgo.sum文件。

此外,我们将介绍如何将第三方模块作为依赖项使用,并提供有效管理这些依赖项的见解。本章将通过练习和活动提供实践经验,使您能够开发出更加结构化和易于管理的 Go 项目,促进代码重用并简化开发过程。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter09

简介

在上一章中,我们学习了使用 Go 包创建可维护、可重用和模块化软件的重要性。我们学习了包的结构、合适的包命名原则以及可执行包和非可执行包的区别。还讨论了可导出和不可导出代码的概念。

在本章中,我们将在此基础上扩展知识,并探讨使用 Go 模块来定义项目,提高我们的软件开发能力。我们将了解 Go 模块是什么,它们如何有帮助,甚至创建我们自己的模块。我们将了解与 Go 模块一起工作所需的不同文件,以维护项目依赖项的完整性,然后学习如何消费第三方模块并管理它们。最后,我们将探讨如何创建包含多个模块的项目,以及何时这样做是有用的。

什么是模块?

在 Go 编程的世界里,模块是一个基本概念,它是组织、版本控制和管理工作及其依赖项的基石。将其视为一个自包含、封装的单元,它简化了依赖项管理的复杂性,同时促进了代码的重用和维护性。

Go 模块代表了一组离散的 Go 包,所有这些包都整齐地打包在一个共同的、版本化的伞状结构下。这种隔离确保了您的代码库保持一致性和良好的结构,使其更容易共享、协作和维护。模块旨在让您控制项目的外部依赖项,并提供一种结构化的机制来版本控制和管理工作。

使用 Go 模块时的关键组件

与 Go 模块工作相关的一些关键组件。让我们看看一些有助于我们为项目进行 Go 依赖项管理的方面:

  • go.mod 文件:Go 模块的核心是 go.mod 文件。该文件作为您模块的蓝图,包含有关模块路径和版本的基本信息,以及其依赖项的详细列表。这个详细的映射确保所有必需的包都得到了明确定义,并且它们的特定版本被记录下来。

  • go.sum 文件:go.sum 文件与 go.mod 文件协同工作,是 Go 模块管理中的一个重要组成部分。它包含所有在 go.mod 中列出的依赖项的加密校验和,如 SHA-256 哈希。这些校验和作为安全措施,确保下载的依赖项未被篡改或损坏。

  • 版本控制:Go 模块引入了一个强大的版本控制系统,在依赖项管理中发挥着关键作用。每个模块都被分配一个唯一的版本标识符,通常通过版本控制系统的标签或提交哈希实现。这种细致的方法确保您的项目始终使用已知和验证的依赖项集。已发布的模块使用语义版本控制模型发布版本号,您可以在他们的网站上找到更多关于此的信息:semver.org

这三个方面的 Go 模块帮助我们进行项目依赖项管理。使用 Go 模块,您不再需要手动跟踪和管理项目的依赖项。随着您导入包,它们会自动添加到 go.mod 文件中,并带有版本信息,简化了确保代码与为它设计的确切依赖项集兼容的过程。

go.mod 文件

go.mod 文件是 Go 模块的主要配置文件。它包含以下信息:

  • module mymodule 指定了模块路径为 mymodule

  • go.mod 文件列出了模块所需的依赖项,包括它们的模块路径和特定版本或版本范围。

  • 替换指令(可选):这些指令允许您指定某些依赖项的替换,这在测试或解决兼容性问题时可能很有用。

  • 排除指令(可选):这些指令允许您排除可能存在已知问题的特定版本的依赖项。

下面是一个简单的 go.mod 文件示例:

module mymodule
require (
  github.com/some/dependency v1.2.3
  github.com/another/dependency v2.0.0
)
replace (
  github.com/dependency/v3 => github.com/dependency/v4
)
exclude (
  github.com/some/dependency v2.0.0
)

前面的代码展示了如何轻松读取 go.mod 文件,列出项目的依赖项,并在使用 replace 指令进行本地工作时进行调整,或者根据需要排除某些依赖项。

go.sum 文件

go.sum 文件包含项目中使用的特定版本依赖项的校验和列表。这些校验和用于验证下载的包文件的完整性。

go.sum 文件由 Go 工具链自动生成和维护。它确保下载的包未被篡改,并且项目始终使用依赖项的正确版本。

下面是一个简化的 go.sum 文件示例:

github.com/some/dependency v1.2.3 h1:abcdefg...
github.com/some/dependency v1.2.3/go.mod h1:hijklm...
github.com/another/dependency v2.0.0 h1:mnopqr...
github.com/another/dependency v2.0.0/go.mod h1:stuvwx...

在前一个示例中,go.sum 文件的内容展示了如何验证 Go 项目的下载包文件的完整性。这是一个非常简单的例子;然而,在实际中,go.sum 文件可能会变得相当大,这取决于项目可能拥有的依赖项的大小和数量。

模块是如何有帮助的?

Go 模块提供了许多增强 Go 开发体验的好处。让我们更深入地看看 Go 模块是如何有帮助的。

精确且简化的依赖项管理

Go 模块最显著的优点之一是它们能够提供对依赖项的精确控制。当你在 go.mod 文件中指定依赖项时,你可以定义所需的精确版本,这消除了与不那么严格的依赖项管理方法相关的猜测工作和潜在兼容性问题。

Go 模块简化了添加、更新和管理依赖项的过程。在过去,Go 开发者必须依赖于 GOPATHvendor 目录,这可能导致版本冲突,并使依赖项管理变得具有挑战性。Go 模块用更直观和高效的方法取代了这些做法。

版本控制和可重复性

Go 模块引入了一个健壮的版本控制系统。每个模块都带有特定的版本标识符或提交哈希。这种细致的版本控制确保你的项目依赖于一致且已知的依赖项集合。它促进了可重复性,这意味着你和你的合作者可以轻松地重新创建相同的发展环境,减少“在我的机器上它工作”的问题。

改善协作

通过定义良好的模块,在 Go 项目上进行协作变得更加容易。模块为你的代码提供了清晰的边界,确保它保持一致性和自包含。这使得你更容易与他人分享你的工作,其他人也可以在不担心破坏现有功能的情况下为你的项目做出贡献。

依赖项安全性

Go 模块通过 go.sum 文件整合了安全措施。通过在所有项目依赖项中包含前面章节中提到的加密校验和,你可以看到这是如何保护下载包免受潜在篡改或损坏的。

在促进隔离和模块化的同时易于使用

很容易看到 Go 模块如何帮助我们的程序。模块通过易于理解、更新和跟踪项目依赖项,使开发团队更容易维护。随着项目的演变,很容易跟上外部包的变化。

Go 模块促进了隔离和模块化。它们还提供了一个自然机制来隔离你的项目与全局工作空间。这种隔离促进了模块化,让你能够专注于构建自包含、可重用且易于管理和共享的组件。这建立在 Go 的惯用特性之上,并促进了开发团队在 Go 项目中的最佳实践。

Go 模块在 Go 1.11 版本中正式引入,它们提供了一种更复杂、结构化和版本感知的方式来管理项目依赖。鼓励开发者迁移到 Go 模块以进行现代 Go 项目开发。

练习 09.01 – 创建和使用你的第一个模块

在这个练习中,我们将看到如何轻松地创建我们的第一个 Go 模块:

  1. 创建一个名为bookutil的新目录并进入它:

    mkdir bookutil
    cd bookutil
    
  2. 初始化一个名为bookutil的 Go 模块:

    go mod init bookutil
    
  3. 验证go.mod是否在你的项目目录中创建,并且模块路径设置为bookutil

注意

在运行go mod init之后不会创建go.sum文件。它将在你与模块交互并添加其依赖时生成和更新。

  1. 现在,让我们在模块的项目目录内创建一个名为author的目录,以专注于通过创建与书籍章节相关的函数来创建一个 Go 包。

  2. author目录内,创建一个名为author.go的文件来定义包和函数。

    这里是author.go的起始代码:

    package author
    import "fmt"
    // Author represents an author of a book.
    type Author struct {
        Name string
        Contact string
    }
    
  3. 现在,我们可以添加必要的函数来创建我们的作者并定义作者可以执行的操作:

    func NewAuthor(name, contact string) *Author {
        return &Author{Name: name, Contact: contact}
    }
    func (a *Author) WriteChapter(chapterTitle string, content string) {
        fmt.Printf("Author %s is writing a chapter titled   '%s'\n", a.Name, chapterTitle)
        fmt.Println(content)
    }
    func (a *Author) ReviewChapter(chapterTitle string, content string) {
        fmt.Printf("Author %s is reviewing a chapter titled '%s'\n", a.Name, chapterTitle)
        fmt.Println(content)
    }
    func (a *Author) FinalizeChapter(chapterTitle string) {
        fmt.Printf("Author %s has finalized the chapter titled '%s'.\n", a.Name, chapterTitle)
    }
    
  4. 定义了作者包后,我们可以在模块中创建一个 Go 文件来演示如何使用它。让我们将这个文件命名为main.go,放在我们的目录根目录下:

    package main
    import "bookutil/author "
    func main() {
        // Create an author instance.
        authorInstance := author.NewAuthor("Jane Doe",   "jane@example.com")
        // Write and review a chapter.
        chapterTitle := "Introduction to Go Modules"
        chapterContent := "Go modules provide a structured way to manage dependencies and improve code maintainability."
        authorInstance.WriteChapter(chapterTitle, chapterContent)
        authorInstance.ReviewChapter(chapterTitle, "This chapter looks great, but let's add some more examples.")
        authorInstance.FinalizeChapter(chapterTitle)
    }
    
  5. 在文件夹中保存文件并运行以下命令:

    go run main.go
    

运行前面的代码会产生以下输出:

Author John Doe is writing a chapter titled 'Introduction to Go Modules':
Go modules provide a structured way to manage dependencies and improve code maintainability.
Author John Doe is reviewing a chapter titled 'Introduction to Go Modules':
This chapter looks great, but let's add some more examples.
Author John Doe has finalized the chapter titled 'Introduction to Go Modules'.

在这个练习中,我们学习了如何创建 Go 模块并使用它来运行程序。

注意

你的 Go 模块不必与你的 Go 包同名,因为你可以有一个 Go 模块包含多个包,并且一个项目也可以有一个 Go 模块。根据项目的主要目的命名模块是一个好的实践。

在这种情况下,模块的主要目的是管理和处理书籍章节和作者,因此模块的名称反映了更广泛的环境。名称bookutil提供了灵活性,可以包括与书籍相关操作相关的多个包,包括author包。

此外,还有一些关于模块命名的最佳实践,如<prefix>/<descriptive-text>github.com/<project-name/>,你可以在 Go 文档中了解更多信息:go.dev/doc/modules/managing-dependencies#naming_module

现在你已经成功创建了一个名为bookutil的 Go 模块,其中包含一个专注于书籍章节的author包,让我们来探讨使用外部 Go 模块的重要性以及它们如何增强你的项目。

你应该在何时使用外部模块,为什么?

在 Go 开发中,利用外部模块是一种常见的做法,这可以给你的项目带来好处。外部模块,也称为第三方依赖,在合理使用时提供了许多优势。在本节中,我们将探讨何时使用外部模块以及它们被采用背后的有力理由。

你应该使用外部模块来完成以下操作:

  • 提高代码的可重用性和效率

  • 扩展项目功能

  • 转移依赖项管理

  • 通过开源社区促进协作开发

  • 通过开源代码利用经过验证的可靠性、社区支持和文档

然而,始终要谨慎行事,选择与你的项目目标和长期可持续性计划相一致的依赖项和模块。

练习 09.02 – 在我们的模块中使用外部模块

有时,在代码中,你需要为提供给某物的身份提供一个唯一的标识符。这个唯一的标识符通常被称为通用唯一标识符UUID)。Google 提供了一个包来创建这样的 UUID。让我们看看如何使用它:

  1. 创建一个名为 myuuidapp 的新目录并进入它:

    mkdir myuuidapp
    cd myuuidapp
    
  2. 初始化一个名为 myuuidapp 的 Go 模块:

    go mod init myuuidapp
    
  3. 验证 go.mod 文件是否已创建在你的项目目录中,并将模块路径设置为 myuuidapp

  4. 添加一个 main.go 文件。

  5. main.go 中,将主包名添加到文件顶部:

    package main
    
  6. 现在,添加我们将在这个文件中使用的导入:

    import (
        "fmt"
        "github.com/google/uuid"
    )
    
  7. 创建 main() 函数:

    func main() {
    
  8. 使用外部模块包生成一个新的 UUID:

        id := uuid.New()
    
  9. 打印生成的 UUID:

        fmt.Printf("Generated UUID: %s\n", id)
    
  10. 关闭 main() 函数:

    }
    
  11. 保存文件,然后运行以下命令以获取外部依赖项,从而更新你的 go.mod 文件并包含依赖信息:

    go get github.com/google/uuid
    
  12. 验证我们的 go.mod 文件现在已更新为包含新的 require 行的包依赖项。根据它们的发布版本,你的包版本号可能不同:

    require github.com/google/uuid v1.3.1
    
  13. 验证我们的 go.sum 文件现在已更新为新的依赖项。再次提醒,根据它们的发布版本,你的包版本号可能不同:

    github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
    github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
    
  14. 运行代码:

    go run main.go
    

运行前面的代码会产生以下输出,包含一个随机的 UUID:

Generated UUID: 7a533339-58b6-4396-b7f7-d0a50216bf88

通过这样,你已经学会了如何在你的模块中使用外部模块的包,通过使用 Google 的开源代码生成一个唯一的标识符。在这个例子中,我们相信 Google 有经过良好测试的代码,并且它符合我们为代码库设定的标准。如果我们想要升级或降级外部包的版本,那么这将被转移到我们的 Go 模块上。接下来,我们将通过查看在项目中何时使用多个模块来扩展我们对模块的理解。

注意

更多关于 UUID 模块和包的信息可以在 GitHub 上找到:github.com/google/uuid/tree/master

在项目中消耗多个模块

你可以在项目中消费多个 Go 模块。就像你之前看到的 Google 模块示例一样,你可以在项目中使用该模块,同时使用你可能需要的其他 Go 模块。

活动 9.01 – 消费多个模块

在这个活动中,我们将在我们的代码中使用多个 Go 模块:

  1. 创建一个新的 UUID 并打印该 UUID。

  2. 使用 rsc.io/quote 模块获取并打印一个随机引言。

你的输出应该看起来像这样,第二行有不同的 UUID 和不同的随机句子:

Generated UUID: 3c986212-f12d-415e-8eb5-87f61a6cbfee
Random Quote: Do not communicate by sharing memory, share memory by communicating.

注意

该活动的解决方案可以在本章 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter09/Activity09.01.

在项目中定义多个模块

Go 模块系统旨在管理整个模块的依赖项和版本,而不是模块内的子集或子项目。然而,可能存在这样的情况,即你的主项目中有多个不同的组件或子项目,并且这些组件或子项目都有自己的依赖项和版本要求。在这种情况下,你可以以这种方式组织你的项目,即每个组件都是其自己的模块,与主项目模块分开。这些子模块可以作为独立的 Go 模块进行维护,每个子模块都有自己的 go.mod 文件。

例如,如果你有一个包含主组件和其他两个组件的项目,并且每个组件都有独特的依赖项,你可以这样组织你的项目:

myproject/
├── mainmodule/
│   ├── main.go
│   ├── go.mod
│   ├── go.sum
│   ├── ...
├── secondmodule/
│   ├── othermain.go
│   ├── go.mod
│   ├── go.sum
│   ├── ...
├── thirdmodule/
│   ├── othermain.go
│   ├── go.mod
│   ├── go.sum
│   ├── ...

每个子组件/模块(即 secondmodulethirdmodule)被视为一个独立的 Go 模块,具有自己的 go.mod 文件和依赖项。

在以下情况下创建子模块是有意义的:

  • 组件有不同的依赖项:当项目中的不同组件有不同的依赖项集合时,创建子模块可以让你分别管理这些依赖项

  • 有单独的版本要求:如果不同的组件需要同一依赖项的不同版本,使用子模块可以帮助更有效地管理这些版本冲突

  • 有组件可重用性:当你打算在多个项目中重用组件时,将其作为单独的模块可以促进其在各种环境中的重用

  • 有可维护性:子模块可以提高代码组织性和可维护性,因为每个组件都可以单独开发、测试和维护

虽然技术上可以在项目中创建子模块,但这不是常规做法,并且应该在需要为项目中的不同组件进行单独的依赖项管理、版本控制或代码组织时进行。每个子模块都应该有自己的 go.mod 文件,该文件定义了其特定的依赖项和版本要求。

Go 工作空间

在 Go 1.18 中,发布了Go 工作区功能,这改善了在同一项目本地处理多个Go模块的体验。最初,当在同一项目中处理多个 Go 模块时,您需要手动为每个模块编辑 Go 模块文件,使用replace指令来使用您的本地更改。现在,使用 Go 工作区,我们可以定义一个go.work文件,指定使用我们的本地更改,而无需手动管理多个go.mod文件。这对于处理大型项目或跨多个存储库的项目尤其有用。

练习 09.03 – 使用工作区

在这个练习中,我们将回顾在处理需要替换依赖项以使用本地更改的多个 Go 模块的项目时的情况。然后我们将更新示例代码,使其使用 Go 工作区来展示改进:

  1. 创建一个名为printer的新文件夹并添加一个printer.go文件。

  2. printer.go中,将printer包名添加到文件顶部:

    package printer
    
  3. 现在,添加我们将在此文件中使用的导入:

    import (
        "fmt"
        "github.com/google/uuid"
    )
    
  4. 创建导出的PrintNewUUID()函数,返回一个字符串:

    func PrintNewUUID() string {
    
  5. 使用外部模块包生成新的 UUID:

        id := uuid.New()
    
  6. 创建并返回一个字符串以打印生成的 UUID:

        return fmt.Sprintf("Generated UUID: %s\n", id)
    
  7. 关闭PrintNewUUID()函数:

    }
    
  8. 创建一个 Go 模块并安装必要的依赖项:

    go mod init github.com/sicoyle/printer
    go mod tidy
    
  9. 回退一个文件夹并创建一个与printer文件夹并排的新文件夹,命名为othermodule,并添加一个main.go文件。

  10. main.go中,将main包名添加到文件顶部:

    package main
    
  11. 现在,添加我们将在此文件中使用的导入:

    import (
        "fmt"
        "github.com/sicoyle/printer"
    )
    
  12. 创建main()函数:

    func main() {
    
  13. 使用我们在printer模块中定义的PrintNewUUID()函数:

        msg := printer.PrintNewUUID()
    
  14. 打印生成的 UUID 消息字符串:

        fmt.Println(msg)
    
  15. 关闭main()函数:

    }
    
  16. 初始化名为othermodule的 Go 模块:

    go mod init othermodule
    
  17. 添加模块的要求:

    go mod tidy
    
  18. 查看 Go 尝试检索模块依赖项时的错误消息;printer包仅包含未在 GitHub 上公开的本地更改:

    go: finding module for package github.com/sicoyle/printer
    go: othermodule imports
          github.com/sicoyle/printer: cannot find module...
    
  19. 在 Go 工作区之前解决此问题的旧方法包括在othermodule目录内编辑 Go 模块以替换内容:

    go mod edit -replace github.com/sicoyle/printer=../printer
    
  20. 验证othermodule/go.mod文件是否已更新以包含以下内容:

    module othermodule
    go 1.21.0
    replace github.com/sicoyle/printer => ../printer
    
  21. 现在,我们可以成功整理我们的依赖项:

    go mod tidy
    
  22. 运行代码:

    go run main.go
    
  23. 运行前面的代码,显示以下输出,包含一个随机 UUID:

    Generated UUID: 5ff596a2-7c0e-41fe-b0b1-256b28a35b76
    

我们刚刚看到了在引入 Go 工作区之前的工作流程。现在,让我们看看这个新功能带来的变化。

  1. othermodule/go.mod的全部内容替换为以下内容:

    module othermodule
    go 1.21.0
    
  2. 运行整理命令;您将看到错误找到printer模块:

    go mod tidy
    
  3. printer目录中运行以下命令以初始化 Go 工作区:

    go work init
    
  4. 在工作区中使用您的本地更改:

    go work use ./printer
    
  5. 运行以下代码:

    go run othermodule/main.go
    
  6. 运行前面的代码,显示以下输出,包含一个随机 UUID:

    Generated UUID: 5ff596a2-7c0e-41fe-b0b1-256b28a35b76
    

这个练习演示了在引入 Go 工作空间功能之前和之后的流程。这个功能为开发者提供了一种更好地管理大型项目之间以及包含多个可能需要更新的 go.mod 文件的不同存储库中的本地更改的方法。

本章我们覆盖了很多内容。让我们回顾一下我们所学到的所有内容。

摘要

在本章中,我们探讨了 Go 模块的世界,从了解模块是什么以及它们如何提供结构化的项目组织和项目依赖管理开始。我们介绍了两个关键的模块文件——go.modgo.sum——它们负责处理依赖关系。我们还深入探讨了外部模块,强调它们在扩展项目功能以及对其可维护性产生的影响。我们讨论了在单个项目中使用和消费多个模块,以及 Go 工作空间的概念,用于在项目目录内管理多个模块。动手练习和活动加深了我们的理解。

在下一章中,我们将通过介绍包如何帮助团队在迭代、重用和维护项目时使项目更易于管理来增强我们对模块的理解。

第十章:包保持项目可管理

概述

本章旨在展示在 Go 程序中使用包的重要性。我们将讨论如何使用包来帮助我们的代码更易于维护、重用和模块化。在本章中,您将看到它们如何为我们的代码带来结构和组织。这将在我们的练习、活动和 Go 标准库的一些示例中也有所体现。

到本章结束时,您将能够描述一个包及其结构,并声明一个包。您将学习如何评估包中的导出和非导出名称,创建自己的包,并导入您的自定义包。您还将能够区分可执行包和非可执行包,并为包创建别名。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter10

简介

在上一章中,我们探讨了接口。我们看到了如何使用接口来描述类型的行怍。我们还发现,只要类型满足接口的方法集,我们就可以将不同类型的参数传递给接受接口的函数。我们还看到了如何使用接口实现多态。

在本章中,我们将探讨 Go 如何将代码组织成包。我们将看到如何使用包来隐藏或暴露不同的 Go 结构,如结构体、接口、函数等。我们的程序在代码行数和复杂度上一直相对较小。大多数程序都包含在一个代码文件中,通常命名为main.go,并在一个名为main的单个包内。在本章的后面部分,我们将探讨package main的重要性,所以如果您在这个阶段还不理解它,请不要担心。当您在开发团队中工作时,情况并不总是如此。通常,您的代码库可以变得相当庞大,包含多个文件、多个库和多个团队成员。如果我们不能将代码分解成更小的、可管理的部分,这将相当受限。Go 编程语言通过将类似的概念模块化到包中来解决管理大型代码库的复杂性。Go 的创造者使用包来解决他们自己的标准库中的这个问题。在本书中,您已经使用了许多 Go 包,例如fmtstringos等。

让我们看看 Go 标准库中的一个包结构的例子。Go 的strings包封装了操作字符串的字符串函数。通过保持strings包只关注操作字符串的函数,作为 Go 开发者,我们知道这个函数应该包含我们需要的所有字符串操作功能。

Go 的strings包结构如下(pkg.go.dev/strings#section-sourcefiles):

图 10.1:截至 Go 1.21 的 strings 包及其包含的文件

图 10.1:截至 Go 1.21 的 strings 包及其包含的文件

上述图表显示了strings包及其包含的文件。strings包中的每个文件都以其支持的功能命名。代码的逻辑组织从包到文件。我们可以很容易地得出结论,strings包包含用于操作字符串的代码。然后我们可以进一步得出结论,replace.go文件包含用于替换字符串的函数。您已经可以看到,包的概念结构可以将您的代码组织成模块化块。您从一起工作以实现某个目的的代码开始,即字符串操作,并将其存储在名为strings的包中。然后您可以将代码进一步组织到.go文件中,并根据其目的命名它们。下一步是将执行单个目的的函数放入其中,该目的反映了文件名和包名。我们将在本章讨论代码结构时进一步讨论这些概念思想。

开发可维护、可重用和模块化的软件非常重要。让我们简要讨论软件开发的核心组件。

可维护

为了使代码可维护,它必须易于更改,并且任何更改都必须具有低风险,不会对程序产生不利影响。可维护的代码易于修改和扩展,并且易于阅读。随着代码通过软件开发生命周期的不同阶段,代码更改的成本会增加。这些更改可能是由错误、增强或需求变更引起的。当代码不易维护时,成本也会增加。代码需要可维护的另一个原因是需要在行业中保持竞争力。如果你的代码不易维护,可能难以应对竞争对手发布可能用于超越你应用的软件功能的反应。这些只是代码需要可维护的一些原因。

可重用

可重用代码是可以在新软件中使用的代码。例如,我在现有的应用程序中有一个函数,该函数为我的邮件应用程序返回地址;这个函数可能被用于新的软件。返回地址的函数可以用于我新的软件,该软件为顾客返回他们已下订单的地址。

拥有可重用代码的优势如下:

  • 通过使用现有的包来降低未来项目的成本

  • 由于无需重新发明轮子,它减少了交付应用程序所需的时间

  • 通过增加测试和更多使用,程序的质量将得到提高

  • 在开发周期中,可以花更多的时间在其他创新领域

  • 随着你的包的增长,及时为未来项目打下基础变得更加容易

很容易看到为我们的项目创建可重用代码的许多好处。

模块化

模块化和可重用代码在一定程度上是相关的,因为拥有模块化代码使得它更有可能被重用。在开发代码时,代码的组织是一个突出的问题。在一个未组织的大型程序中找到执行特定功能的代码几乎是不可能的,甚至在不知道是否有执行特定任务的代码的情况下,确定这一点也是困难的。模块化有助于解决这个问题。理念是,你的代码执行的每个离散任务都有其自己的代码部分,位于特定的位置。

Go 语言鼓励你通过使用包来开发可维护、可重用和模块化的代码。它旨在鼓励良好的软件开发实践。我们将深入探讨 Go 语言如何利用包来完成这些任务:

图 10.2:代码包的类型可以提供的内容

图 10.2:代码包可以提供的类型

在下一个主题中,我们将讨论什么是包以及构成包的组件。

什么是包?

Go 语言遵循不要重复自己(DRY)原则。这意味着你不应该重复编写相同的代码。将你的代码重构为函数是 DRY 原则的第一步。如果你有数百甚至数千个你经常使用的函数,你将如何跟踪所有这些函数?其中一些函数可能具有共同的特征。你可能有一组执行数学运算、字符串操作、打印或基于文件的操作的函数。你可能正在考虑将它们拆分成单独的文件:

图 10.3:按文件分组函数

图 10.3:按文件分组函数

这可能有助于缓解一些问题。然而,如果你的字符串功能开始进一步增长呢?那么你将有一个大量的字符串函数在一个文件中,甚至多个文件中。你构建的每个程序也必须包含stringmathio的所有代码。你将不得不将代码复制到你所构建的每个应用程序中。一个代码库中的错误必须在多个程序中修复。这种代码结构是不可维护的,也不鼓励代码重用。Go 中的包是组织代码的下一步,以便轻松重用代码组件。以下图表显示了从函数到源文件再到包的代码组织进展:

图 10.4:代码进度组织

图 10.4:代码进度组织

Go 将代码组织成包以提高可重用性,这些包被称为目录。一个包本质上是你工作空间中的一个目录,包含一个或多个 Go 源文件,用于对执行特定任务的代码进行分组。它只暴露必要的部分,以便使用你包的人能够完成任务。包的概念类似于在计算机上使用目录来组织文件。

包结构

对于 Go 语言来说,一个包中包含多少个不同的文件并不重要。你应该根据可读性和逻辑分组将代码分成尽可能多的文件。然而,一个包中的所有文件必须位于同一个目录下。源文件应包含相关的代码,这意味着如果包是用于配置解析,那么其中不应该包含连接到数据库的代码。一个包的基本结构包括一个目录,包含一个或多个 Go 文件和相关代码。以下图表总结了包结构的核心组件:

图 10.5:包结构

图 10.5:包结构

在 Go 中,常用的包之一是strings包。它包含几个 Go 文件,在 Go 文档中被称为包文件。包文件是包的一部分的.go源文件;例如:

  • builder.go

  • compare.go

  • reader.go

  • replace.go

  • search.go

  • strings.go

上述列表中的文件都在标准库中共享与字符串操作相关的相关代码。在我们讨论如何声明一个包之前,我们需要讨论包的正确 Go 命名约定。

包命名

你的包名很重要。它代表你的包包含的内容,并标识其目的。你可以将包名视为自文档化。在命名包时需要仔细考虑。包名应该简短且简洁。它不应该冗长。通常选择简单名词作为包名。以下将是不好的包名:

  • stringconversion

  • synchronizationprimitives

  • measuringtime

更好的选择可能是以下这些:

  • strconv

  • sync

  • time

注意

strconvsynctime是标准库中实际存在的 Go 包。

此外,包的样式也是需要考虑的因素。以下是一些 Go 包名的较差选择:

  • StringConversion

  • synchronization_primitives

  • measuringTime

在 Go 中,包名应该是全部小写,不带下划线。不要使用驼峰式或蛇形风格。存在多个复数命名的包。

鼓励使用缩写,只要它们在编程社区中熟悉或常见。用户应该能够仅从包名中轻松理解该包的用途;例如:

  • strconv(字符串转换)

  • regexp(正则表达式搜索)

  • sync(同步)

  • os(操作系统)

避免使用miscutilcommondata等包名。这些包名会让用户难以理解其用途。在某些情况下,可能会有一些偏离这些指南的情况,但大部分情况下,这是我们应努力追求的:

图 10.6:包命名约定

图 10.6:包命名约定

你可以看到,在挑选包名时,几乎是一门艺术。你希望包名简洁、描述性强,且在使用时清晰易懂。既然我们已经讨论了包名,让我们来看看包声明。

包声明

每个 Go 文件都以包声明开始。包声明是包的名称。包中的每个文件的第一行必须是包声明:

package <packageName>

回想一下,标准库中的strings包包含以下 Go 源文件:

图 10.7:截至 Go 1.21 的字符串包及其包含的文件

图 10.7:截至 Go 1.21 的字符串包及其包含的文件

这些文件中的每一个都以包声明开始,尽管它们都是独立的文件。我们将从 Go 标准库中的一个示例中查看。在 Go 标准库中,有一个名为strings\的包。它由多个文件组成。我们只将查看包中的代码片段:builder.gocompare.goreplace.go。我们已删除注释和一些代码,仅为了展示包文件以包名开头。代码片段将不会有输出。这是 Go 如何将代码组织到多个文件但仍在同一包中的示例(golang.org/src/strings/builder.go):

package strings
import (
    "unicode/utf8"
    "unsafe"
)
type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf []byte
}
// https://golang.org/src/strings/compare.go
package strings
func Compare(a, b string) int {
    if a == b {
        return 0
    }
    if a < b {
        return -1
    }
    return +1
}

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Example10.01/strings.go找到。

在 Go 源文件中定义的所有函数、类型和变量都可以在该包内访问。尽管你的包可能分布在多个文件中,但它仍然是同一个包的一部分。内部,所有代码都可以跨文件访问。简单来说,代码在包内是可见的。注意,并非所有代码在包外都是可见的。前面的代码片段来自官方的 Go 库。有关代码的进一步解释,请访问前面的 Go 代码片段中的链接。

导出和未导出代码

Go 有一个非常简单的方式来确定代码是导出还是未导出。导出意味着变量、类型、函数等在包外部是可见的。未导出意味着它只对包内部可见。如果一个函数、类型、变量等以大写字母开头,它是可导出的;如果以小写字母开头,它是不可导出的。在 Go 中没有需要关心的访问修饰符。如果函数名称是大写的,那么它是导出的,如果是小写的,那么它是未导出的。

注意

只暴露我们希望其他包看到的代码是一种良好的实践。我们应该隐藏所有其他外部包不需要的内容。

让我们看看下面的代码片段:

package main
import (
    "strings"
    "fmt"
)
func main() {
    str := "found me"
    if strings.Contains(str, "found") {
        fmt.Println("value found in str")
    }
}

这个代码片段使用了strings包。我们正在调用一个名为Containsstrings函数。strings.Contains函数会搜索str变量,看它是否包含值"found"。如果"found"str变量中,strings.Contains函数将返回true;如果"found"不在str变量中,strings.Contains函数将返回false

strings.Contains(str, "found")

要调用函数,我们在包名前加上函数名。

这个函数是可导出的,因此对strings包外的其他人来说是可访问的。我们知道它是一个导出函数,因为函数的第一个字母是大写的。

当你导入一个包时,你只能访问导出的名称。

我们可以通过查看strings.go文件来验证函数是否存在于strings包中:

// https://golang.org/src/strings/strings.go
// Contains reports whether substr is within s.
func Contains(s, substr string) bool {
    return Index(s, substr) >= 0
}

下面的代码片段将尝试访问strings包中的一个未导出函数:

package main
import (
    "fmt"
    "strings"
)
func main() {
    str := "found me"
    slc := strings.explode(str, 3)
    fmt.Println(slc)
}

函数未导出,因为它以小写字母开头。只有包内的代码可以访问该函数;它对包外不可见。

代码尝试在strings.go包文件中调用一个未导出的函数:

图 10.8:程序输出

图 10.8:程序输出

以下代码片段来自 Go 标准库的strings包以及该包内部的strings.go文件(packt.live/2RMxXqh)。你可以看到explode()函数是不可导出的,因为函数名以小写字母开头:

main.go

1  // https://golang.org/src/strings/strings.go
2  // explode splits s into a slice of UTF-8 strings,
3  // one string per Unicode character up to a maximum of n (n < 0 means no limit).
4  // Invalid UTF-8 sequences become correct encodings of U+FFFD.
5  func explode(s string, n int) []string {
6      l := utf8.RuneCountInString(s)
7      if n < 0 || n > l {
8          n = l
9      }
10      a := make([]string, n)
11      for i := 0; i < n-1; i++ {
12          ch, size := utf8.DecodeRuneInString(s)
13          a[i] = s[:size]
14          s = s[size:]
15          if ch == utf8.RuneError {
16              a[i] = string(utf8.RuneError)

完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Example10.02/strings.go找到。

包别名

Go 也有别名包名的功能。您可能希望使用别名名的原因有以下几点:

  • 包名可能不容易让人理解其用途。为了清晰起见,可能更好的是为包使用不同的别名。

  • 包名可能太长。在这种情况下,您希望别名更简洁、更简洁。

  • 可能存在包路径唯一但包名相同的场景。这时,您需要使用别名来区分这两个包。

包别名语法非常简单。您在import包路径之前放置别名名:

import "fmt"

这里有一个简单的例子,展示了如何使用包别名:

package main
import (
    f "fmt")
func main() {
    f.Println("Hello, Gophers")

我们将fmt包别名为f

    f.Println("Hello, Gophers")

main()函数中,我们现在可以使用f别名调用Println()函数。

主包

main包是一个特殊的包。Go 中有两种基本的包类型:可执行包和非可执行包。main包是 Go 中的一个可执行包。位于此包中的逻辑可能不会被其他包消费。main包要求在其包中存在一个main()函数。main()函数是 Go 可执行程序的入口点。当您对main包执行go build时,它将编译该包并创建一个二进制文件。二进制文件将创建在main包所在的目录中。二进制文件的名字将是它所在的文件夹名:

图 10.9:为什么 main 包是特殊的

图 10.9:为什么 main 包是特殊的

这里是一个main包代码的简单示例:

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

预期输出如下:

Hello Gophers!

练习 10.01 – 创建一个包来计算各种形状的面积

第七章,“利用接口变得灵活”,我们实现了计算不同形状面积的代码。在这个练习中,我们将所有关于形状的代码移动到一个名为shape的包中。然后,我们将shape包中的代码更新为可导出。然后,我们将更新main包以导入我们新的shape包。然而,我们希望它在main包的main()函数中仍然执行相同的功能。

这里是我们将要转换为包的代码:

github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter07/Exercise07.02/main.go

您应该有一个目录结构,如下面的截图所示:

图 10.10:程序目录结构

图 10.10:程序目录结构

shape.go 文件应包含整个代码:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Exercise10.01/pkg/shape/shape.go

我们将只介绍与将此代码转换为包相关的更改;有关我们已在上一章中介绍过的代码部分,请参阅第七章通过接口 变得灵活

  1. Chapter10 内创建一个名为 Exercise10.01 的目录。

  2. Exercise10.01 目录内创建一个新的 Go 模块:

    go mod init exercise10.01
    
  3. Exercise10.01 目录内创建两个更多目录,分别命名为 cmdpkg/shape 的嵌套目录。

  4. Exercise10.01/cmd 目录内创建一个名为 main.go 的文件。

  5. Exercise10.01/pkg/shape 目录内创建一个名为 shape.go 的文件。

  6. 打开 Exercise10.01/pkg/shape.go 文件。

  7. 添加以下代码:

    package shape
    import "fmt"
    

    此文件中的第一行代码告诉我们这是一个名为 shape 的不可执行包。不可执行包在编译时不会产生二进制或可执行代码。回想一下,main 包是一个可执行包。

  8. 接下来,我们需要使类型可导出。对于每个 struct 类型,我们必须将类型名称及其字段大写化以使其可导出。可导出意味着它可以在包外部可见:

    type Shape interface {
        area() float64
        name() string
    }
    type Triangle struct {
        Base float64
        Height float64
    }
    type Rectangle struct {
        Length float64
        Width float64
    }
    type Square struct {
        Side float64
    }
    
  9. 我们还必须通过将方法名称更改为小写来使方法不可导出。目前没有必要让这些方法在包外部可见。

  10. shape.go 文件内容应包括以下内容:

    func PrintShapeDetails(shapes ...Shape) {
        for _, item := range shapes {
            fmt.Printf("The area of %s is: %.2f\n", item.name(), item.area())
        }
    }
    func (t Triangle) area() float64 {
        return (t.Base * t.Height) / 2
    }
    func (t Triangle) name() string {
        return "Triangle"
    }
    func (r Rectangle) area() float64 {
        return r.Length * r.Width
    }
    func (r Rectangle) name() string {
        return "Rectangle"
    }
    func (s Square) area() float64 {
        return s.Side * s.Side
    }
    func (s Square) name() string {
        return "Square"
    }
    
  11. 此步骤的完整代码可在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Exercise10.01/pkg/shape/shape.go

  12. PrintShapeDetails() 函数也需要大写化:

    func PrintShapeDetails(shapes ...Shape) {
        for _, item := range shapes {
            fmt.Printf("The area of %s is: %.2f\n", item.name(), item.area())
        }
    }
    
  13. 执行构建以确保没有编译错误:

    go build
    
  14. 这里是 main.go 文件的列表。通过将包命名为 main,我们知道这是一个可执行的:

    package main
    
  15. import 声明只有一个导入。它是 shape 包。我们可以看到包名是 shape,因为它是在路径声明中的最后一个目录名。我的包所在路径可能与您的不一样:

    import  "exercise10.01/pkg/shape"
    
  16. main() 函数中,我们正在初始化 shape 包的导出类型:

    func main() {
        t := shape.Triangle{Base: 15.5, Height: 20.1}
        r := shape.Rectangle{Length: 20, Width: 10}
        s := shape.Square{Side: 10}
    
  17. 然后我们调用 shape() 函数和 PrintShapeDetails,以获取每个形状的面积:

        shape.PrintShapeDetails(t, r, s)
    }
    
  18. 在命令行中,进入 Exercise10.01/cmd 目录结构。

  19. 输入以下内容:

    go build
    
  20. go build命令将编译你的程序并创建一个以目录命名的可执行文件,名为cmd

  21. 输入可执行文件名并按Enter键:

    ./cmd
    

预期的输出如下:

The area of Triangle is: 155.78
The area of Rectangle is: 200.00
The area of Square is 100.00

我们现在有了之前在接口章节的shape实现中拥有的功能。现在shape功能被封装在shape包中。我们只暴露或使需要维护之前实现的功能或方法可见。main包更加简洁,并导入本地shape包以提供之前实现中的功能。

init()函数

正如我们之前讨论的,每个 Go 程序(可执行程序)都是从main包开始的,入口点是main()函数。还有一个我们应该注意的特殊函数,称为init()。每个源文件都可以有一个init()函数,但到目前为止,我们将从main包的角度来看init()函数。当你开始编写包时,你可能需要为包提供一些初始化(init()函数)。init()函数用于设置状态或值。init()函数为你的包添加初始化逻辑。以下是一些init()函数使用示例:

  • 设置数据库对象和连接

  • 包变量的初始化

  • 创建文件

  • 加载配置数据

  • 验证或修复程序状态

init()函数需要以下内容:

  • 导入的包首先初始化

  • 包级别变量初始化

  • 调用包的init()函数

  • 执行main()函数

以下图表显示了典型 Go 程序遵循的执行顺序:

图 10.11:执行顺序

图 10.11:执行顺序

这里有一个简单的示例,演示了package main的执行顺序:

package main
import (
    "fmt"
)
var name = "Gopher"
func init() {
    fmt.Println("Hello,", name)
}
func main() {
    fmt.Println("Hello, main function")
}

代码的输出如下:

Hello, Gopher
Hello, main function

让我们分部分理解这段代码:

var name = "Gopher"

根据代码的输出,包级别的变量声明首先执行。我们知道这一点是因为name变量在init()函数中被打印出来:

func init() {
    fmt.Println("Hello,", name)
}

然后init()函数被调用并打印出"Hello, Gopher"

func main() {
    fmt.Println("Hello, main function")
}

最后,执行main()函数:

图 10.12:代码片段的执行流程

图 10.12:代码片段的执行流程

init()函数不能有任何参数或返回值:

package main
import (
    "fmt"
)
var name = "Gopher"
func init(age int) {
    fmt.Println("Hello, ", name)
}
func main() {
    fmt.Println("Hello, main function")
}

运行此代码片段将导致以下错误:

图 10.13:程序输出

图 10.13:程序输出

练习 10.02 – 加载预算类别

编写一个程序,在main()函数运行之前将预算类别加载到全局映射中。然后main()函数应打印映射中的数据:

  1. 创建一个main.go文件。

  2. 代码文件将属于package main,并且需要导入fmt包:

    package main
    import "fmt"
    
  3. 创建一个全局变量,它将包含一个键为int、值为string的预算类别映射:

    var budgetCategories = make(map[int]string)
    
  4. 我们需要在main()函数运行之前使用一个init()函数来加载我们的预算类别:

    func init() {
        fmt.Println("Initializing our budgetCategories")
        budgetCategories[1] = "Car Insurance"
        budgetCategories[2] = "Mortgage"
        budgetCategories[3] = "Electricity"
        budgetCategories[4] = "Retirement"
        budgetCategories[5] = "Vacation"
        budgetCategories[7] = "Groceries"
        budgetCategories[8] = "Car Payment"
    }
    
  5. 由于我们的预算类别已经加载,我们现在可以遍历映射并打印它们:

    func main() {
        for k, v := range budgetCategories {
            fmt.Printf("key: %d, value: %s\n", k, v)
        }
    }
    

我们将得到以下输出:

Initializing our budgetCategories
key: 5, value: Vacation
key: 7, value: Groceries
key: 8, value: Car Payment
key: 1, value: Car Insurance
key: 2, value: Mortgage
key: 3, value: Electricity
key: 4, value: Retirement

注意

输出的顺序可能会有所不同;Go 映射不保证数据的顺序。

这里的目的是展示如何使用init()函数在main()函数执行之前执行数据初始化和加载。通常需要在main()运行之前加载的数据是静态数据,例如下拉列表值或某种配置。如所示,数据通过init()函数加载后,可以被main()函数使用。在下一个主题中,我们将看到多个init()函数是如何执行的。

执行多个init()函数

一个包中可以有多个init()函数。这使您能够模块化初始化,以更好地维护代码。例如,假设您需要设置各种文件和数据库连接以及修复程序执行的环境状态。在一个init()函数中完成所有这些会使维护和调试变得复杂。多个init()函数的执行顺序是函数在代码中放置的顺序:

package main
import (
    "fmt"
)
var name = "Gopher"
func init() {
    fmt.Println("Hello,", name)
}
func init() {
    fmt.Println("Second")
}
func init() {
    fmt.Println("Third")
}
func main() {
    fmt.Println("Hello, main function")
}

让我们将代码分解成部分并评估它:

var name = "Gopher"

Go 首先初始化name变量,在init()函数执行之前:

func init() {
    fmt.Println("Hello,", name)
}

这首先打印出来,因为它是在函数中第一个init

func init() {
    fmt.Println("Second")
}

由于它是函数中的第二个init,所以先前的内容被打印出来:

func init() {
    fmt.Println("Third")
}

由于它是函数中的第三个init,所以先前的内容被打印出来:

func main() {
    fmt.Println("Hello, main function")
}

最后,执行main()函数。

结果将如下所示:

Hello, Gopher
Second
Third
Hello, main function

练习 10.03 – 将收款人分配给预算类别

我们将扩展我们的程序,从练习 10.02加载预算类别,到现在为预算类别分配收款人。这类似于许多尝试将收款人与常用类别匹配的预算应用程序。然后我们将打印收款人与类别之间的映射:

  1. 创建一个main.go文件。

  2. 练习 10.02加载预算类别https://github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Exercise10.02/main.go)中的代码复制到main.go文件中。

  3. budgetCategories之后添加一个payeeToCategory映射:

    var budgetCategories = make(map[int]string)
    var payeeToCategory = make(map[string]int)
    
  4. 添加另一个init()函数。这个init()函数将用于填充我们的新payeeToCategory映射。我们将把收款人分配给类别的键值:

    func init() {
        fmt.Println("Initializing our budgetCategories")
        budgetCategories[1] = "Car Insurance"
        budgetCategories[2] = "Mortgage"
        budgetCategories[3] = "Electricity"
        budgetCategories[4] = "Retirement"
        budgetCategories[5] = "Vacation"
        budgetCategories[7] = "Groceries"
        budgetCategories[8] = "Car Payment"
    }
    
  5. 本步骤的完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter10/Exercise10.03/main.go找到。

  6. main()函数中,我们将打印出收款人到类别。我们遍历payeeToCategory映射,打印键(payee)。我们通过将payeeToCategory映射的值作为键传递给budgetCategories映射来打印类别:

    func main() {
        fmt.Println("In main, printing payee to category")
        for k, v := range payeeToCategory {
            fmt.Printf("Payee: %s, Category: %s\n", k, budgetCategories[v])
        }
    }
    

这里是预期的输出:

图 10.14:将收款人分配到预算类别

图 10.14:将收款人分配到预算类别

您现在创建了一个程序,在main()函数执行之前执行多个init()函数。每个init()函数将数据加载到我们的全局映射变量中。我们确定了init()函数执行的顺序,因为显示的print语句。这表明init()函数按照它们在代码中出现的顺序打印。了解您init()函数的顺序很重要,因为您可能会根据代码执行的顺序得到不可预见的结果。

在即将到来的活动中,我们将使用所有这些概念,包括我们查看的包,看看它们是如何一起工作的。

活动 10.01 – 创建一个计算工资和绩效评估的函数

在这个活动中,我们将从第七章中的活动 7.01计算工资和绩效评估,使用包进行模块化。我们将重构从github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter07/Activity7.01/main.go的代码:

  1. DeveloperEmployeeManager的类型和方法移动到pkg/payroll下的自己的包中。类型、方法和函数必须正确导出或未导出。

  2. 将包命名为payroll

  3. 将类型及其方法逻辑上分离到不同的包文件中。回想一下,良好的代码组织涉及将类似功能分离到单独的文件中。

  4. 创建一个main()函数作为payroll包的别名。

  5. cmd目录下的main包中引入两个init()函数。第一个init()函数应该简单地打印一条问候消息到stdout。第二个init()函数应该初始化/设置键值对。

预期的输出如下:

Welcome to the Employee Pay and Performance Review
++++++++++++++++++++++++++++++++++++++++++++++++++
Initializing variables
Eric Davis got a review rating of 2.80
Eric Davis got paid 84000.00 for the year
Mr. Boss got paid 160500.00 for the year

在这个活动中,我们看到了如何使用包来分离我们的代码,然后将代码逻辑上分离成单个文件。我们可以看到每个文件都构成了一个包。包中的每个文件都可以访问其他文件,无论它们是否在单独的文件中。这个活动演示了如何创建包含多个文件的包,以及如何使用这些单独的文件进一步组织我们的代码。

注意

本活动的解决方案可以在本章的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter10/Activity10.01

摘要

在本章中,我们探讨了开发可维护、可重用和模块化软件的重要性。我们发现了 Go 的包在满足这些软件开发标准方面发挥着重要作用。我们研究了包的整体结构。它由一个目录组成,可以包含一个或多个文件,并包含相关的代码。包本质上是你工作空间中的一个目录,包含一个或多个用于分组执行任务的文件。它仅向使用你的包的人公开必要的部分以完成任务。我们讨论了正确命名包的重要性。我们还学习了如何命名一个包;即简洁、小写、描述性、使用非复数名称,并避免使用通用名称。包可以是可执行的或不可执行的。如果一个包是main包,那么它是一个可执行包。main包必须有一个main()函数,这是我们包的入口点。

我们还讨论了什么是可导出和不可导出的代码。当我们将函数、类型或方法的名称大写时,它对使用我们的包的其他人是可见的。将函数、类型或方法小写化使其对包外部的其他用户不可见。我们了解到init()函数可以执行以下任务:初始化变量、加载配置数据、设置数据库连接或验证我们的程序状态是否已准备好执行。init()函数在执行时和如何利用它们方面有一些规则。本章将帮助你编写高度可管理、可重用和模块化的代码。

在下一章中,我们将开始探索调试技巧,这是软件开发的一个关键方面。这包括学习有效的故障排除,以实现稳健的开发体验。

第十一章:消除 bug 的调试技能

概述

在本章中,我们将探讨基本的调试方法。我们将探讨我们可以采取的一些主动措施来减少我们程序中引入的 bug 数量。一旦我们理解了这些措施,我们将研究我们可以定位 bug 的方法。

你将能够熟悉 Go 中的调试,并实现各种格式化打印的方法。你将评估基本的调试技术,并找到代码中 bug 的一般位置。到本章结束时,你将知道如何使用 Go 代码打印变量类型和值,以及为了调试目的记录应用程序的状态。你还将看到在不同的或受限环境中,你的代码可能部署到的环境中可用的调试措施。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter11

简介

在你开发软件程序的过程中,程序可能会以非预期的方式运行。例如,程序可能会抛出错误并可能崩溃。崩溃是指我们的代码在中间停止运行并突然退出。也许程序给出了我们意料之外的结果。例如,我们请求视频流服务来观看电影 Rocky 1,但反而得到了 Creed 1! 或者你将支票存入银行账户,但银行软件却从你的账户中扣除。这些软件程序以非预期方式运行的情况被称为“bug”。有时,“bug”和“error”可以互换使用。在第六章,“不要慌张!处理你的错误”部分,我们在“什么是错误?”一节中讨论了存在三种不同类型的错误或 bug:语法错误、运行时错误和逻辑错误。我们还考察了示例,并看到了发现每种类型错误位置的难度。

确定意外行为原因的过程称为调试。将发布到生产环境的 bug 有各种原因:

  • 测试作为事后考虑:在开发生命周期中,不进行增量测试是很诱人的。例如,我们正在为应用程序创建多个函数,一旦我们完成所有函数,它们然后被测试。测试我们代码的一个可能更好的方式是在完成每个函数后测试它。这被称为增量测试或以更小的块交付代码。这使我们拥有更好的代码稳定性。这是通过在继续到下一个函数之前测试一个函数以确保它正常工作来实现的。我们刚刚完成的函数可能被其他函数使用。如果我们不测试它就继续,那么使用我们的函数的其他函数可能会使用一个有错误的函数。根据错误和我们对函数的更改,它可能会影响我们函数的其他用户。在本章的后面部分,我们将讨论增量测试的更多好处。

  • 应用增强或需求变更:我们的代码在开发阶段和发布到生产阶段之间通常会发生变化。一旦进入生产阶段,我们会收到用户的反馈;反馈可能是额外的需求或对代码的增强。在一个区域更改生产级代码可能会对另一个区域产生负面影响。如果开发团队使用单元测试,那么这有助于减轻代码库更改中引入的一些错误。通过使用单元测试,我们可以在将代码交付之前运行我们的单元测试,以查看我们的更改是否产生了负面影响。我们将在稍后讨论单元测试是什么。

  • 不切实际的开发时间表:有时,功能请求在非常紧张的时间框架内交付。这可能导致在最佳实践中走捷径,缩短设计阶段,进行较少的测试,以及收到不明确的需求。所有这些都会增加引入错误的机会。

  • 未处理的错误:一些开发者可能选择不处理发生的错误;例如,应用程序加载配置数据所需的文件未找到,未处理无效数学运算(如除以零)的错误返回,或者可能无法建立与服务器的连接。如果你的程序没有正确处理这些和其他类型的错误,这可能会导致错误。

这些只是 bug 的几个原因。bug 对我们的程序有负面影响。一个导致计算错误的 bug 的结果可能是致命的。在医疗行业,有一种机器用于注射一种名为肝素的药物;这种药物是一种抗凝血剂,用于预防血栓。如果确定肝素给药频率和剂量的代码中存在导致其故障的 bug,机器可能会给药过多或过少。这可能会对病人产生不利影响。正如你所看到的,交付尽可能无 bug 的软件至关重要。在本章中,我们将探讨一些减少引入 bug 数量以及隔离 bug 位置的方法。

无 bug 代码的方法

我们将简要地探讨一些方法,这些方法将帮助我们最小化可能被引入代码中的 bug 数量。这些方法还将帮助我们增强对引入 bug 的代码部分的信心:

图 11.1:调试代码的不同方法

图 11.1:调试代码的不同方法

让我们更详细地看看这些方法。

逐步编码和经常测试

让我们考虑逐步开发的方法。这意味着逐步开发程序,并在添加增量代码后经常对其进行测试。这种模式将帮助你轻松跟踪 bug,因为你正在测试每一小段代码,而不是一个大的程序。

编写单元测试

当编写测试并更改代码时,单元测试可以保护代码免受潜在 bug 的引入。典型的单元测试接受一个给定的输入并验证是否产生了一个给定的结果。如果在代码更改之前单元测试通过,但在代码更改后失败,那么我们可以得出结论,我们引入了一些意外的行为。在将代码推送到生产系统之前,单元测试必须通过。换句话说,开发团队在接受代码库的新更改之前,验证测试是否通过,代码是否仍然按预期工作。

处理所有错误

这在第六章中讨论过,“不要慌张!处理你的错误”。忽略错误可能导致程序中出现意外结果。我们需要正确处理错误,以便使调试过程更容易。

执行日志记录

记录日志是另一种我们可以用来确定程序中发生情况的技巧。有各种类型的日志;一些常见的日志类型包括 debug、info、warn、error、fatal 和 trace。我们不会深入到每种类型的细节;相反,我们将专注于执行 debug 类型的日志。这种类型的日志通常用于确定在出现错误之前程序的状态。收集的一些信息包括变量的值、正在执行的代码部分(一个例子是函数名)、传递的参数的值、函数或方法的输出,等等。在本章中,我们将使用 Go 标准库的内置功能执行我们自己的自定义 debug 日志。Go 的内置 log 包可以提供时间戳。这在试图理解各种事件的时机时很有用。当你进行日志记录时,你需要考虑到性能的影响。根据应用程序及其承受的负载(即,在同一时间与系统交互的用户数量),在高峰时段应用程序的日志输出量可能会很大,可能会对应用程序的性能产生负面影响。根据添加到应用程序中的日志数量,与系统交互的用户越多,生成的日志就越多,这可能会对应用程序的性能产生更大的负面影响。在某些情况下,它可能会导致程序无响应。

使用 fmt 进行格式化

fmt 包的一个用途是将数据显示到控制台或文件系统,例如一个包含可能有助于调试代码的信息的文本文件。我们已经多次使用了 fmt.Println() 函数。让我们稍微深入地看看 fmt.Println() 的功能。fmt.Println() 函数在传递给函数的参数之间放置空格,然后在字符串的末尾追加一个换行符。

在 Go 中,每种类型在打印时都有一个默认的格式化方式。例如,字符串按原样打印,整数以十进制格式打印。fmt.Println() 函数打印参数的默认格式。

练习 11.01 – 使用 fmt.Println 操作

在这个练习中,我们将使用 fmt.Println 打印一个 hello 语句:

  1. 导入 fmt 包:

    package main
    import (
        "fmt"
    )
    
  2. main() 函数中声明 fnamelname 变量,并将两个字符串赋给变量:

    func main() {
        fname:= "Edward"
        lname:= "Scissorhands"
    
  3. fmt 包中调用 Println 方法。它将打印 Hello: 然后打印两个变量的值,后面跟着一个空格。然后,它将打印一个 \n(换行符)到标准输出:

        fmt.Println("Hello:", fname, lname)
    
  4. 以下语句将 Next Line 加上 \n 打印到标准输出:

        fmt.Println("Next Line")
    }
    

    输出如下:

    Hello: Edward Scissorhands
    Next Line
    

我们已经演示了打印消息的基本方法。在下一个主题中,我们将探讨如何格式化我们想要打印的数据。

使用 fmt.Printf()进行格式化

fmt包也有多种格式化我们各种print语句输出的方式。接下来,我们将探讨fmt.Printf()函数。

fmt.Printf()根据动词格式化字符串并将其打印到stdout。标准输出(stdout)是一个输出流。默认情况下,标准输出指向终端。该函数使用一种称为格式动词的东西,有时也称为格式说明符。动词告诉fmt函数在哪里插入变量。例如,%s打印一个字符串;它是一个字符串的占位符。这些动词基于 C 语言:

图 11.2:Printf 解释

图 11.2:Printf 解释

考虑以下示例:

package main
import (
    "fmt"
)
func main() {
    fname := "Edward"
    fmt.Printf("Hello %s, good morning", fname)
}

fname变量被赋值为Edward时,当fmt.Printf()函数运行时,%s动词的值将是fname

输出如下:

Hello Edward, good morning

但当我们想要打印多个变量时会发生什么?我们如何在fmt.Printf()函数中打印多个变量?让我们看看:

package main
import (
    "fmt"
)
func main() {
    fname := "Edward"
    lname := "Scissorhands"
    fmt.Printf("Hello Mr. %s %s", fname, lname)
}

如前述代码所示,我们现在将fnamelname赋值给一个字符串。fmt.Printf()函数有两个动词字符串和两个变量。第一个变量fname被分配给第一个%s实例。第二个变量lname被分配给第二个%s实例。变量按照它们在fmt.Printf()函数中的放置顺序替换动词。

输出如下:

Hello Mr. Edward Scissorhands

fmt.Printf()函数不会在其打印的字符串末尾添加新行。如果我们想在输出中返回带有新行的内容,我们必须在字符串中添加换行符:

package main
import (
    "fmt"
)
func main() {
    fname := "Edward"
    lname := "Scissorhands"
    fmt.Printf("Hello my first name is %s\n", fname)
    fmt.Printf("Hello my last name is %s", lname)
}

在 Go 中,你可以使用\来转义字符。如果你想打印\字符,那么你需要输入fmt.Println("\\")来转义该字符。这告诉我们一个字符不应该被打印,因为它有特殊的意义。当你使用\n时,它表示换行。我们可以在字符串的任何地方放置换行符。

输出如下:

Hello my first name is Edward
Hello my last name is Scissorhands

如果我们没有在字符串中放置\n,以下将是结果:

Hello my first name is EdwardHello my last name is Scissorhands

Go 语言有多个打印动词。我们将介绍一些常用的基本动词。当它们与基本调试相关时,我们将介绍其他动词:

图 11.3:表示动词及其含义的表格

图 11.3:表示动词及其含义的表格

注意

使用fmt包可用的完整动词列表可以在pkg.go.dev/fmt#hdr-Printing找到。

让我们看看使用动词打印各种数据类型的示例:

package main
import (
    "fmt"
)
func main() {
    fname := "Joe"
    gpa := 3.75
    hasJob := true
    age := 24
    hourlyWage := 45.53
    fmt.Printf("%s has a gpa of %f.\n", fname, gpa)
    fmt.Printf("He has a job equals %t.\n", hasJob)
    fmt.Printf("He is %d earning %v per hour.\n", age, hourlyWage)
}
  • 我们初始化了各种不同类型的变量,这些变量将在我们的Printf()函数中使用:

    fmt.Printf("%s has a gpa of %f.\n", fname, gpa)
    
  • %s 是字符串的占位符;当 Printf() 函数运行时,fname 变量的值将替换 %s%f 是浮点数的占位符;当 Printf() 语句运行时,gpa 变量中的值将替换 %f

  • 检查这个人是否有工作如下:

    fmt.Printf("He has a job equals %t.\n", hasJob)
    
  • %tbool 类型的占位符。当 Printf() 语句运行时,hasJob 变量中的值将替换 %t

  • 打印人的年龄和他们的时薪:

    fmt.Printf("He is %d earning %v per hour.\n", age, hourlyWage)
    
  • %d 是十进制 int 的占位符。当 Printf 语句运行时,age 变量中的值将替换 %d

  • %v 是默认格式中值的占位符。

以下是我们预期的输出:

Joe has a gpa of 3.750000.
He has a job equals true.
He is 24 earning 45.53 per hour.

接下来,我们将演示如何格式化动词,例如 gpa,使它们四舍五入到特定的位数。

格式化选项的附加选项

动词也可以通过向动词添加额外的选项来格式化。在我们之前的例子中,gpa 变量打印了一些错误的零。在本主题中,我们将演示如何控制某些动词的打印。如果我们想在使用 %f 动词时四舍五入到特定的精度,我们可以通过在 % 符号后放置一个小数和一个数字来实现:%.2f。这将指定两位小数,第二位将被四舍五入。根据以下示例,请注意第 n 个数字是如何四舍五入到由 %.nf 动词中使用的数字 (n) 指定的:

图 11.4:四舍五入小数

图 11.4:四舍五入小数

您也可以指定一个数字的整体宽度。一个数字的宽度指的是您正在格式化的数字的总字符数,包括小数点。您可以通过在数字前放置一个数字来指定您正在格式化的数字的宽度。%10.0f 表示格式将具有总共 10 个字符的宽度;这包括小数点。如果宽度小于格式化的宽度,它将用空格填充,并且它将右对齐。

让我们看看使用宽度和 %.f 动词一起格式化各种数字的例子:

package main
import (
    "fmt"
)
func main() {
    v := 1234.0
    v1 := 1234.6
    v2 := 1234.67
    v3 := 1234.678
    v4 := 1234.6789
    v5 := 1234.67891
    fmt.Printf("%10.0f\n", v)
    fmt.Printf("%10.1f\n", v1)
    fmt.Printf("%10.2f\n", v2)
    fmt.Printf("%10.3f\n", v3)
    fmt.Printf("%10.4f\n", v4)
    fmt.Printf("%10.5f\n", v5)
}

现在,让我们详细理解这段代码:

  • main() 函数中,我们声明了具有不同小数位的变量:

    func main() {
      v := 1234.0
      v1 := 1234.6
      v2 := 1234.67
      v3 := 1234.678
      v4 := 1234.6789
      v5 := 1234.67891
    
  • %10.0f 表示总宽度为十,精度为零:

          fmt.Printf("%10.0f\n", v)
    
  • %10.1f 表示总宽度为十,精度为一位:

          fmt.Printf("%10.1f\n", v1)
    
  • %10.2f 表示总宽度为十,精度为两位:

          fmt.Printf("%10.2f\n", v2)
    
  • %10.3f 表示总宽度为十,精度为三位:

          fmt.Printf("%10.3f\n", v3)
    
  • %10.4f 表示总宽度为十,精度为四位:

          fmt.Printf("%10.4f\n", v4)
    
  • %10.5f 表示总宽度为十,精度为五位:

          fmt.Printf("%10.5f\n", v5)
    }
    

结果如下:

图 11.5:格式化动词后的输出

图 11.5:格式化动词后的输出

  • 要使字段左对齐,您可以在 % 符号后使用 标志,如下所示:

        fmt.Printf("%-10.0f\n", v)
        fmt.Printf("%-10.1f\n", v1)
        fmt.Printf("%-10.2f\n", v2)
        fmt.Printf("%-10.3f\n", v3)
        fmt.Printf("%-10.4f\n", v4)
        fmt.Printf("%-10.5f\n", v5)
    

    使用之前相同的变量,结果如下:

图 11.6:格式化动词后左对齐的输出

图 11.6:格式化动词后左对齐的输出

我们刚刚只是触及了 Go 对动词支持的表面。到现在为止,你应该已经对动词的工作原理有了基本的理解。我们将在接下来的主题中继续探讨使用动词和格式化print的多种方式。这个主题为我们将要使用的基本调试技术奠定了基础。

练习 11.02 – 打印十进制、二进制和十六进制值

在这个练习中,我们将从 1 到 255 打印十进制、二进制和十六进制值。结果应右对齐。十进制宽度应设置为 3,二进制或基 2 宽度设置为 8,十六进制宽度设置为 2。这个练习的目的是通过使用 Go 标准库包来正确格式化我们的数据输出:

  1. Chapter11目录内创建一个名为Exercise11.02的目录。

  2. Chapter11/Exercise11.02/目录内创建一个名为main.go的文件。

  3. 打开main.go文件。

  4. 导入以下包:

    package main
    import (
        "fmt"
    )
    
  5. 添加一个main()函数:

    func main() {
    }
    
  6. main()函数中,使用一个循环,该循环将循环 255 次:

    func main() {
        for i := 1; i <= 255; i++ {
        }
    }
    
  7. 接下来,我们想要以以下格式打印变量:

    以 3 位宽度和右对齐的方式显示i的十进制值。

    以 8 位宽度和右对齐的方式显示i的基 2 值。

    以 2 位宽度和右对齐的方式显示i的十六进制值。

    此代码应放置在for循环内部:

    func main() {
        for i := 1; i <= 255; i++ {
            fmt.Printf("Decimal: %3.d Base Two: %8.b Hex: %2.x\n", i, i, i)
        }
    }
    
  8. 在命令行中,使用以下代码更改目录:

    cd Chapter11/Exercise11.02/
    
  9. 在命令行中,输入以下内容:

    go build main.go
    
  10. 输入由go build命令创建的可执行文件并按Enter键。

这是程序的预期结果:

图 11.7:打印十进制、二进制和十六进制值后的预期输出

图 11.7:打印十进制、二进制和十六进制值后的预期输出

我们已经看到如何使用 Go 标准库fmt包中的Printf()函数来格式化我们的数据。我们将利用这一知识来对我们程序中的打印代码标记进行一些基本的调试。我们将在下一节中了解更多关于这方面的内容。

基本调试

我们一直快乐地编写代码。重大时刻已经到来;现在是时候运行我们的程序了。我们运行程序,发现结果并不像我们预期的那样。事实上,有些地方出现了严重错误。我们的输入和输出不匹配。那么,我们如何找出哪里出了问题呢?嗯,程序中出现错误是我们作为开发者都会面临的问题。然而,我们可以进行一些基本的调试来帮助我们修复这些问题,或者至少收集有关这些错误的信息:

  • print语句帮助我们识别在出现错误时我们在程序中的位置:

    fmt.Println("We are in function calculateGPA")
    
  • 打印变量类型

    在调试过程中,了解我们正在评估的变量类型可能很有用:

    fmt.Printf("fname is of type %T\n", fname)
    
  • 打印变量的值

    除了知道变量的类型外,有时了解变量中存储的值也很有价值:

    fmt.Printf("fname value %#v\n", fname)
    
  • debug 语句输出到文件:可能存在仅在生产环境中发生的错误,或者我们可能想比较代码不同输入下打印到文件中的数据结果。在这种情况下,调整来自标准日志记录器的消息的日志格式化消息可能会有所帮助:

    log.Printf("fname value %#v\n", fname)
    

注意

在本章后面将更详细地讨论如 %T%#v 这样的格式化指令。

这里有一些基本的调试方法:

图 11.8:基本的调试方法

图 11.8:基本的调试方法

调试的第一步之一是确定代码中错误的总体位置。在开始分析任何数据之前,我们需要知道这个错误发生在哪里。我们通过在代码中打印标记来实现这一点。代码中的标记通常只是帮助我们识别错误发生时程序位置的 print 语句。它们也用于缩小错误位置的搜索范围。通常,这个过程涉及放置一个带有显示我们代码位置的信息的 print 语句。如果我们的代码达到那个点,我们就可以根据某些条件确定该区域是否是错误所在。如果我们发现它不是,我们可能会移除那个 print 语句并将其放置在代码的其他位置。

给定以下简单的示例,这里有一个返回一些输出的错误:

Incorrect value
Program exited: status 1.

代码报告了一个错误,但我们不知道错误是从哪里来的。这个代码生成一个随机数,这个随机数被传递给 func afunc b。随机数的值将取决于错误发生在哪个函数中。以下代码演示了正确放置 debug 语句以帮助确定潜在错误所在代码区域的重要性:

main.go

func main() {
    r := random(1, 20)
    err := a(r)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
    err = b(r)
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter11/Example01/main.go找到。

  • 我们正在使用 rand 包来生成一个随机数。

  • rand.Seed() 用于确保每次运行程序时使用 rand.Intn,它降低了返回相同数字的可能性。自 Go 1.20 版本起,rand.Seed() 已被弃用,因为没有理由使用随机值调用 Seed。应该使用 New(NewSource(seed)) 来获取本地随机值,以获取未来版本中调用 Seed 并使用已知值以获取特定结果序列的程序。然而,如果您每次都使用相同的种子,随机数生成器将在第一次运行代码时返回相同的数字。为了最大限度地减少生成相同数字的概率,我们需要每次都向种子函数提供一个唯一的数字。我们使用 time.Now().UTC.UnixNano() 来帮助我们的程序获得更随机的数字。不过,需要注意的是,如果您将其放入循环中,循环的迭代速度可能会使 time.Now().UTC.UnixNano() 生成相同的时间值。然而,对于我们的程序来说,这种情况不太可能;这只是一个在未来的代码中需要考虑的因素。

  • rand.Intn((max-min)+1)+min 开始生成两个其他数字之间的随机数。在我们的程序中,它是 1 和 20:

    func a(i int) error {
        if i < 10 {
            fmt.Println("Error is in func a")
            return errors.New("Incorrect value")
        }
        return nil
    }
    func b(i int) error {
        if i >= 10 {
            fmt.Println("Error is in func b.)
            return errors.New("Incorrect value")
        }
        return nil
    }
    
  • 前面的两个函数评估 i 以查看它是否在给定的范围内。如果该范围内的值返回错误,它也会打印一个 debug 语句,让我们知道错误发生的位置。

通过在代码中战略性地放置 print 语句,我们可以看到错误发生在哪个函数中。

输出应该看起来像这样:

Error is in func a
Incorrect value
Program exited: status 1.

本节介绍了调试基础知识。我们介绍了使用 print 语句进行调试。在下一个主题中,我们将基于打印的知识,探讨如何打印变量类型。

注意

由于 r 的值具有随机性,它可能不同,这将影响程序的结果,可能是 func afunc b

此外,如果您在 Go playground 中运行前面的程序,它将每次都给出相同的结果。这是由于 playground 缓存的原因,所以它不遵循答案的随机性。

打印 Go 变量类型

在调试时了解变量的类型通常很有用。Go 通过使用 %T 动词提供了这种功能。Go 是区分大小写的。大写的 %T 表示变量的类型,而小写的 %t 表示 bool 类型:

package main
import (
    "fmt"
)
type person struct {
    lname string
    age int
    salary float64
}
func main() {
    fname := "Joe"
    grades := []int{100, 87, 67}
    states := map[string]string{"KY": "Kentucky", "WV": "West Virginia", "VA": "Virginia"}
    p := person{lname:"Lincoln", age:210, salary: 25000.00}
    fmt.Printf("fname is of type %T\n", fname)
    fmt.Printf("grades is of type %T\n", grades)
    fmt.Printf("states is of type %T\n", states)
    fmt.Printf("p is of type %T\n", p)
}

以下是前面代码片段的结果:

fname is of type string
grades is of type []int
states is of type map[string]string
p is of type main.person

在每个 print 语句中使用 %T 动词来打印变量的具体类型。在先前的主题中,我们打印了值。我们还可以使用 %#v 打印出类型的 Go 语法表示。能够打印出变量的 Go 表示非常有用。变量的 Go 表示是可以在 Go 代码中复制粘贴的语法:

图 11.9:使用 %T 和 Go 语法表示法的类型语法表示

图 11.9:使用 %T 和 Go 语法表示的类型语法表示

练习 11.03 – 打印 Go 变量的表示

在这个练习中,我们将创建一个简单的程序,演示如何打印出各种变量的 Go 表示。我们将使用各种类型(如字符串、切片、映射和结构体)并打印这些类型的 Go 表示:

  1. Chapter11 目录下创建一个名为 Exercise11.03 的目录。

  2. Chapter11/Exercise11.03/ 目录下创建一个名为 main.go 的文件。

  3. 打开 main.go 文件。

  4. 将以下代码添加到 main.go 中:

    package main
    import (
        "fmt"
    )
    
  5. 接下来,创建一个具有以下字段的 person 结构体:

    type person struct {
        lname string
        age int
        salary float64
    }
    
  6. main 函数内部,将值赋给 fname 变量:

    func main() {
        fname := "Joe"
    
  7. 创建一个 slice 字面量并将其赋值给名为 grades 的变量:

        grades := []int{100, 87, 67}
    
  8. 创建一个键为字符串、值为字符串的 map 字面量并将其赋值给名为 states 的变量。这是一个州缩写及其相应名称的映射:

        states := map[string]string{"KY": "Kentucky", "WV": "West Virginia", "VA": "Virginia"}
    
  9. 创建一个名为 person 的字面量并将其赋值给 p:

        p := person{lname:"Lincoln", age:210, salary: 25000.00}
    
  10. 接下来,我们将使用 %#v 打印出我们每个变量的 Go 表示:

        fmt.Printf("fname value %#v\n", fname)
        fmt.Printf("grades value %#v\n", grades)
        fmt.Printf("states value %#v\n", states)
        fmt.Printf("p value %#v\n", p)
    }
    
  11. 在命令行中,使用以下代码更改目录:

    cd Chapter11/Exercise11.03/
    
  12. 在命令行中,输入以下内容:

    go build main.go
    
  13. 输入由 go build 命令创建的可执行文件并按 Enter 键:

    ./main
    

你将得到以下输出:

图 11.10:Go 类型表示

图 11.10:Go 类型表示

在这个练习中,我们看到了如何打印简单类型(如 fname 字符串)的 Go 表示,以及更复杂的类型,如 person 结构体。这是我们工具箱中的另一个工具,我们可以用它来调试;它允许我们以 Go 的方式查看数据。在下一个主题中,我们将探讨另一个帮助我们调试代码的工具。我们将探讨如何记录信息,这些信息可以进一步帮助我们调试。

记录

记录可以帮助我们调试程序中的错误。操作系统记录各种信息,例如对资源的访问、应用程序正在做什么、系统的整体健康状况等等。这不是因为存在错误;相反,它是为了记录,以便系统管理员更容易确定在各个时间点操作系统发生了什么。当操作系统以异常方式运行或执行某些未预期的任务时,它允许更容易地进行调试。我们在记录应用程序时应该采取同样的态度。我们需要考虑我们收集的信息以及这些信息如何帮助我们调试应用程序,如果某些操作没有按预期执行的话。

我们应该进行日志记录,无论程序是否需要调试。日志记录对于理解发生的事件、应用程序的健康状况、任何潜在问题以及谁访问我们的应用程序或数据都很有用。日志记录是程序的基础设施,当应用程序出现异常时可以加以利用。日志记录帮助我们跟踪我们可能错过的异常。在生产中,我们的代码可能在不同条件下执行,与开发环境相比,例如服务器请求数量的增加。

如果我们没有记录这些信息以及我们的代码性能的能力,我们可能会花费无数小时试图弄清楚为什么我们的代码在生产环境中表现的方式与开发环境不同。另一个例子是我们可能在生产中接收到一些格式不正确的请求数据,我们的代码没有正确处理格式,导致不期望的行为。没有适当的日志记录,可能需要非常多的时间来确定我们收到了我们没有适当处理的数据。

Go 标准库提供了一个名为 log 的包。它包括基本日志记录,可以被我们的程序使用。我们将研究如何使用该包来记录各种信息。

考虑以下示例:

package main
import (
    "log"
)
func main() {
    name := "Thanos"
    log.Println("Demo app")
    log.Printf("%s is here!", name)
    log.Print("Run")
}

Println()Printf()Print() 日志函数与它们的 fmt 对应函数执行相同的功能,但有一个例外。当日志函数执行时,它会提供额外的详细信息,例如执行的时间和日期,如下所示:

2019/11/10 23:00:00 Demo app
2019/11/10 23:00:00 Thanos is here!
2019/11/10 23:00:00 Run

当我们在以后调查和审查日志以及理解事件顺序时,这些信息可能很有用。我们甚至可以让我们的日志记录器记录更多详细信息。Go 的 log 包提供了一个 SetFlags 函数,允许我们更加具体。

这里是 Go 包提供的日志选项列表,我们可以在函数中设置它们(go.dev/src/log/log.go?s=8483:8506#L28):

图 11.11:Go 中的标志列表

图 11.11:Go 中的标志列表

让我们在 图 11.11 中设置一些标志并观察与之前行为的不同。

考虑以下示例:

package main
import (
    "log"
)
func main() {
    log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
    name := "Thanos"
    log.Println("Demo app")
    log.Printf("%s is here!", name)
    log.Print("Run")
}

让我们分解代码以更好地理解它:

log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)

log.Ldate 是本地时区的日期。这是之前记录的信息。

log.Lmicroseconds 将提供格式化日期的微秒数。请注意,我们尚未讨论时间;有关时间的更多详细信息,请参阅第十二章关于时间

log.LlongFile 将提供日志来源的完整文件名和行号。

输出如下:

图 11.12:输出

图 11.12:输出

记录致命错误

使用log包,我们还可以记录致命错误。Fatal()Fatalf()Fatalln()函数与Print()Printf()Println()类似。区别在于记录后,Fatal()函数后面跟着一个os.Exit(1)系统调用。log包还有以下函数:PanicPanicfPaniclnPanic函数和Fatal函数之间的区别在于Panic函数是可恢复的。当使用Panic函数时,你可以使用defer()函数,而当使用Fatal函数时,则不能。如前所述,Fatal函数调用os.Exit();当调用os.Exit()时,defer函数将不会被调用。可能有一些情况下,你可能希望立即终止程序而没有恢复的可能性。例如,应用程序可能已经到达了一个最佳退出状态,以防止数据损坏或产生不期望的行为。或者,你可能开发了一个由他人使用的命令行工具,你需要向你的可执行文件的调用者提供一个退出代码,以表示它已经完成了其任务。

在下面的代码示例中,我们将查看如何使用log.Fataln

package main
import (
    "log"
    "errors"
)
func main() {
    log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
    log.Println("Start of our app")
    err := errors.New("Application Aborted!")
    if err != nil {
        log.Fatalln(err)
    }
    log.Println("End of our app")
}

让我们分解代码以更好地理解它:

log.Println("Start of our app")

该语句将日期、时间和日志消息的行号打印到stdout

err := errors.New("We crashed!")

我们创建一个错误来测试Fatal()错误的记录:

log.Fatalln(err)

我们记录错误,然后程序退出:

log.Println("End of our app")

这行代码没有执行,因为我们记录了错误为fatal,这导致程序退出。

这里是结果。请注意,尽管这是一个错误,但它仍然记录了与打印功能相同的错误详细信息,然后退出:

图 11.13:记录致命错误

图 11.13:记录致命错误

活动 11.01 – 编写一个验证社会保障号码的程序

在这个活动中,我们将验证社会保障号码SSNs)。我们的程序将接受不带连字符的 SSNs。我们希望记录 SSN 的验证过程,以便我们可以追踪整个过程。在生产应用程序中记录真实的 SSNs 不是一个推荐的做法,因为它包含敏感信息,并且会违反安全措施;然而,这对于一个有趣的活动是有用的。我们不希望我们的应用程序在 SSN 无效时停止;我们希望它记录无效的数字并继续到下一个:

  1. 为无效的 SSN 长度创建一个名为ErrInvalidSSNLength的自定义错误。

  2. 为具有非数字数字的 SSN 创建一个名为ErrInvalidSSNNumbers的自定义错误。

  3. 为以三个零为前缀的 SSN 创建一个名为ErrInvalidSSNPrefix的自定义错误。

  4. 如果 SSN 以 9 开头且第四位需要 7 或 9,则创建一个名为ErrInvalidDigitPlace的自定义错误。

  5. 创建一个函数,如果 SSN 长度不是 9,则返回错误。

  6. 创建一个函数,用于检查 SSN 的长度是否为 9。该函数返回一个错误,其中包含无效的 SSN 和自定义错误ErrInvalidSSNLength

  7. 创建一个函数,用于检查 SSN 是否全部由数字组成。该函数返回一个错误,其中包含无效的 SSN 和自定义错误ErrInvalidSSNNumbers

  8. 创建一个函数,用于检查 SSN 是否没有以 000 为前缀。该函数返回一个错误,其中包含无效的 SSN 和自定义错误ErrInvalidSSNPrefix

  9. 创建一个函数,用于检查如果 SSN 以 9 开头,那么第四位需要是 7 或 9。该函数返回一个错误,其中包含无效的 SSN 和自定义错误ErrInvalidDigitPlace

  10. main()函数中,创建一个 SSN 切片,以便你的程序可以验证每个 SSN。

  11. 对于你正在验证的每个 SSN,如果从用于验证的函数返回错误,则记录这些错误并继续处理切片。

  12. 下面是一个用于验证的示例切片:

    validateSSN := []string{"123-45-6789", "012-8-678", "000-12-0962", "999-33- 3333", "087-65-4321","123-45-zzzz"}
    

前面的切片应该有以下输出:

2024/02/12 07:09:14.015902 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:21: Checking data []string{"123-45-6789", "012-8-678", "000-12-0962", "999-33-3333", "087-65-4321", "123-45-zzzz"}
2024/02/12 07:09:14.016070 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "123-45-6789" 1 of 6
2024/02/12 07:09:14.016085 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "012-8-678" 2 of 6
2024/02/12 07:09:14.016089 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:31: the value of 0128678 caused an error: ssn is not nine characters long
2024/02/12 07:09:14.016092 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "000-12-0962" 3 of 6
2024/02/12 07:09:14.016127 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:35: the value of 000120962 caused an error: ssn has three zeros as a prefix
2024/02/12 07:09:14.016132 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "999-33-3333" 4 of 6
2024/02/12 07:09:14.016139 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:39: the value of 999333333 caused an error: ssn starts with a 9 requires 7 or 9 in the fourth place
2024/02/12 07:09:14.016141 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "087-65-4321" 5 of 6
2024/02/12 07:09:14.016201 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:23: Validate data "123-45-zzzz" 6 of 6
2024/02/12 07:09:14.016204 /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter11/Activity11.01/main.go:27: the value of 12345zzzz caused an error: ssn has non-numeric digits

注意

该活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter11/Activity11.01

在这个活动中,我们使用了log包来捕获信息以跟踪验证 SSN 的过程。如果我们需要调试我们的 SSN 验证过程,那么我们可以查看日志消息并跟踪 SSN 的验证失败。我们还演示了如何格式化日志消息以包含调试所需的信息。

在实时或受限环境中进行调试

调试是软件开发中不可或缺的技能,尤其是在处理只在特定环境中出现的难以捉摸的 bug 时。在现实场景中,即时修改代码可能不可行,因此掌握在实时或受限环境中无缝工作的技术至关重要。

在那些环境中进行最佳调试时,你应该考虑以下要点:

  • 理解环境:在深入调试之前,先退一步。了解部署设置、网络配置以及任何安全限制。这些信息有助于预测潜在问题并简化调试过程。

  • 使用适当工具进行远程调试:Delve 是 Go 的一个强大调试器,支持远程调试。通过使用 Delve,你可以连接到正在运行的 Go 进程,检查变量、设置断点和逐步执行代码。这是一个非常有价值的调试工具。

  • pprof 允许你收集运行时统计信息和分析应用程序的性能。通过在代码中公开一个分析端点,你可以在代码部署后不修改代码的情况下从实时系统中收集数据。如果你向应用程序添加指标和额外的可观察性,也是如此。还有可用的工具可以捕获应用程序日志并将它们聚合起来以便将来可搜索。这提供了额外的上下文,可以帮助调试过程。

  • 利用日志级别:利用语言中可用的不同日志级别在不同的环境中很有用。然而,你应该小心不要通过日志过度共享信息——尤其是在处理私人数据时。

  • 设置集成开发环境(IDE)调试器:现代 IDE,如 Visual Studio Code 或 JetBrains GoLand,提供了强大的调试功能。你可以在 IDE 中使用调试器设置断点、监视表达式和逐步执行代码。这在定位问题方面非常高效,但并不是每个部署环境都能做到。

  • 特性标志和金丝雀发布:利用特性标志和/或金丝雀发布可以使你选择性地在生产环境中启用或禁用特定的功能。通过逐步推出更改,你可以观察对用户子集的影响。这使得在广泛发布之前更容易识别和解决问题。

总的来说,需要注意的是调试可能是一门艺术。在某些环境中有效的方法可能在其他环境中不起作用。例如,你无法在生产环境中设置已经运行的代码的 IDE 调试器,但你可以在开发代码时轻松地利用这种方法。你也可能只遇到困扰某些环境的特定问题。这就是“它在我的本地运行正常”的经典故事,但你可能被这个问题困扰,最终可能花费几个小时/几天的时间通过持续集成(CI)环境提交代码来测试代码提交。这种情况可能发生在我们所有人身上,这也是一种合理的调试方法,通过小幅度尝试更改直到问题解决。

最后,知识就是力量,了解最佳调试问题的工具可以大大减少你的调试时间。此外,在不同环境中练习调试,并在计划场景中强制团队处理问题/事件,可以是一种为生产级别事件做准备的好方法,在这些事件中速度是关键。

摘要

在本章中,我们研究了各种简化调试过程的方法,例如逐步编码和频繁测试代码、编写单元测试、处理所有错误以及在代码上执行日志记录。

查看 fmt 包,我们发现各种输出信息的方式,帮助我们找到错误。fmt 包提供了不同的打印格式化、动词以及通过使用各种标志来控制动词输出的方式。

使用 Go 标准库中的日志功能,我们能够看到应用程序执行的详细信息。log 包使我们能够看到日志事件发生的文件路径和行号。log 包附带各种打印函数,这些函数模仿了 fmt 打印函数的一些功能,这为我们提供了关于本章所学动词用法的各种见解。我们能够通过使用 Go 提供的标准库进行基本的调试。我们查看了 log 包,并介绍了 time 类型。我们没有深入探讨 Go 对时间实现的细节。我们还看到了在实时或受限环境中调试代码的各种附加方法。

在下一章中,我们将探讨 Go 中时间的表示方式。我们将讨论与 time.Time 类型一起使用的各种函数。我们还将演示如何将时间转换为各种时间结构(如纳秒、微秒、毫秒、秒、分钟、小时等)。然后,我们将最终了解时间的基本类型。

第十二章:关于时间

概述

本章展示了 Go 如何处理表示时间数据的变量,这是语言的一个重要方面。

到本章结束时,您将能够创建自己的时间格式,比较和管理时间,计算时间序列的持续时间,并根据用户要求格式化时间。

技术要求

对于本章,您需要 Go 版本 1.21 或更高。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter12

简介

上一章向您介绍了 Go 中的基本调试。在 Go 中编写代码越多,您就会变得越好;然而,开发和部署代码可能会遇到需要调试的边缘情况。上一章展示了如何使用 fmt 包,如何将日志写入文件,以及如何使用打印动词进行字符串格式化。

本章致力于向您传授关于处理表示时间数据的变量所需的所有知识。您将学习如何以“Go 方式”来做这件事。首先,我们将从基本的时间创建、时间戳等开始;然后,我们将学习如何比较和操作时间,计算两个日期之间的持续时间,并创建时间戳。最后,我们将学习如何根据我们的需求格式化时间。所以,让我们不要浪费时间,直接开始吧。

创建时间

创建时间意味着声明一个变量,该变量以特定方式格式化时间。时间格式化将在本章末尾介绍;因此,现在我们将使用 Go 提供的默认格式化。在本主题中,我们将在脚本中的 main() 函数中执行一切,因此骨架应该如下所示:

package main
import (
  "fmt"
  "time"
)
func main(){
  // this is where the code goes.
}

让我们先看看我们的骨架,并学习如何创建和操作 time 变量。我们的骨架包含必要的标准 package main 定义。我们使用 fmt 包将输出打印到控制台。由于我们将使用 time 包,因此我们还需要导入它。

每当我们执行 go run <script>.go 时,main() 函数会被调用并执行其中声明的任何内容。

time 包最常见的任务之一是测量脚本的执行持续时间。我们可以通过在开始和结束时捕获当前时间到变量中来实现这一点,以便我们可以计算差异并知道特定操作完成所需的时间。时间差将在本书的后续章节中展示。第一个示例如下:

  start := time.Now()
  fmt.Println("The script has started at: ", start)
  fmt.Println("Saving the world...")
  time.Sleep(2 * time.Second)
  end := time.Now()
  fmt.Println("The script has completed at: ", end)

我们脚本的输出应该看起来像这样:

The script has started at: 2023-09-27 08:19:33.8358274 +0200 CEST m=+0.001998701
Saving the world...
The script has completed at: 2023-09-27 08:19:35.8400169 +0200 CEST m=+2.006161301

如您所见,这看起来并不十分花哨;然而,到本章结束时,您将学会如何使它更加易读。

考虑以下场景:你的雇主给你一个任务,让你开发一个小型的 Go 应用程序,该应用程序基于星期几测试一个网络应用程序。你的雇主每周一凌晨 12:00 CEST 发布新的网络应用程序的主要版本。从凌晨 12:00 CEST 到下午 2:00 CEST 有一个停机窗口,部署大约需要 30 分钟,你有一小时 30 分钟的时间来测试应用程序。这就是 Go 的 time 包能帮助你解决问题的地方。脚本在周的其他天进行 hit-n-run 测试,但在发布日,你需要执行一个完整的 full-blown 功能测试。脚本的第一个版本接受一个参数来决定执行哪种测试,但第二个版本的脚本基于日期和小时来做出决定:

图 12.1:测试策略

图 12.1:测试策略

考虑以下代码:

  day := time.Now().Weekday()
  hour := time.Now().Hour()
  fmt.Println("Day: ", day, "Hour: ", hour)
  if day.String() == "Monday"{
    if hour >= 1{
      fmt.Println("Performing full blown test!")
    } else {
      fmt.Println("Performing hit-n-run test!")
    }
  } else { fmt.Println("Performing hit-n-run test!")}

当前星期几被捕获在名为 day 的变量中。执行的小时也被捕获在名为 hour 的变量中。当这个脚本执行时,有两种类型的输出。

第一个是一个简单的 hit-n-run 输出,如下所示:

Day: Thursday Hour: 14
Performing hit-n-run test!

第二个是 full blown 输出,如下所示:

Day: Thursday Hour: 14
Performing full blown test!

在这个例子中,我们看到了执行日期如何修改应用程序的行为。

注意

实际的测试被有意省略,因为这不是本章主题的一部分。然而,输出清楚地显示了哪个部分负责控制测试。

另一个例子是在 Go 中创建脚本的日志文件名。基本思路是每天收集一个日志,并将时间戳连接到日志文件名中。其结构如下:

Application_Action_Year_Month_Day

在 Go 中,在你的主函数中有一个优雅且简单的方法来做这件事:

package main 
import ( 
  "fmt" 
  "strconv" 
  "time" 
) 
func main() { 
  appName := "HTTPCHECKER" 
  action := "BASIC" 
  date := time.Now() 
  logFileName := appName + "_" + action + "_" + strconv.Itoa(date.Year()) + "_" + date.Month().String() + "_" + strconv.Itoa(date.Day()) + ".log" 
  fmt.Println("The name of the logfile is ", logFileName) 
} 

输出如下所示:

The name of the logfile is: HTTPCHECKER_BASIC_2024_March_16.log

然而,有一个问题。如果你想要将 time 类型的字符串连接起来,这些类型不是隐式可转换的,你需要使用 strconv 包,这需要在你的脚本顶部导入:

import "strconv"

依次,这允许你调用 strconv.Itoa() 函数,该函数将你的 YearDay 值转换为字符串,最终让你将它们连接成一个单一的字符串。

现在我们已经学会了如何创建 time 变量,接下来让我们学习如何比较它们。

练习 12.01 – 创建一个返回时间戳的函数

在这个练习中,我们将创建一个名为 whatstheclock 的函数。这个函数的目标是展示如何创建一个函数,该函数封装了一个格式化的 time.Now() 函数,并以 ANSIC 格式返回日期。ANSIC 格式将在 格式化 时间 部分中进一步详细说明:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. 使用 packageimport 语句初始化脚本:

    package main
    import "time"
    import "fmt"
    
  3. 定义一个名为 whatstheclock() 的函数:

    func whatstheclock() string {
      return time.Now().Format(time.ANSIC)
    }
    
  4. main() 函数中,定义对 whatstheclock() 函数的调用,并将结果打印到控制台:

    func main(){
      fmt.Println(whatstheclock())
    }
    
  5. 保存文件并运行代码:

    go run main.go
    

    你应该看到以下输出:

    Thu Oct 17 13:56:03 2023
    

在这个练习中,我们展示了如何创建一个返回当前时间的 ANSIC 格式的小函数。

注意

你所使用的任何类型的操作系统都会提供两种类型的时钟来测量时间;一种称为“单调时钟”,另一种称为“墙钟”。墙钟是你可以在 Windows 机器的任务栏上看到的时间;它会发生变化,通常根据你的当前位置与公共或企业 网络时间协议 (NTP) 服务器同步。NTP 用于根据原子钟或卫星参考向客户端告知时间。

比较时间

大多数时候,当在较小的脚本上使用 Go 时,了解脚本何时应该运行,或者脚本应该在什么小时和分钟内完成,对于你的统计信息来说非常重要。通过统计,我们指的是知道通过执行特定操作节省了多少时间,与如果我们必须手动执行这些操作所花费的时间相比。这允许我们在进一步开发功能时,测量脚本随时间改进的情况。在这个主题中,我们将查看一些实际例子,展示你如何解决这个问题。

让我们看看第一个脚本的逻辑,该脚本旨在在指定时间之前或之后不运行。这个时间可以通过另一个自动化或当手动放置触发文件来到达;每天,脚本需要在不同时间运行——具体来说,尽可能在指定时间之后运行。

时间格式如下所示:2023-09-27T22:08:41+00:00

  now := time.Now()
  onlAafter, err := time.Parse(time.RFC3339,"2020-11-01T22:08:41+00:00")
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println(now, onlyAfter)
  fmt.Println(now.After(onlyAfter))
  if now.After(onlyAfter){
    fmt.Println("Executing actions!")
  } else {
    fmt.Println("Now is not the time yet!!")
  }

当我们还没有到达截止日期时,脚本的输出如下:

Now is not the time yet!!

当我们满足条件时,输出如下所示:

Executing actions!

让我们来看看这里发生了什么。我们创建了一个 now 变量,这对于执行至关重要。我们根据 RFC3339 解析了 time 字符串。RFC3339 指定了应该用于 datetime 字符串的格式。这个函数返回两个值:一个值是转换成功时的输出,另一个是存在错误时的错误。我们将输出捕获在 onlyAfter 变量中,以及错误 err。我们本可以使用一个标准变量,如 onlyAfterError,但除非我们在稍后使用该变量,否则编译器会抛出一个错误,指出该变量已声明但未使用。我们使用 _ 变量来规避这个问题。基于这个逻辑,我们可以非常简单地实现 onlyBefore 参数或变量。time 包有两个特别有用的函数:一个是名为 After() 的函数,另一个是名为 Before() 的函数。它们允许我们简单地比较两个 time 变量。

包含一个名为 Equal() 的第三个函数。这个函数允许你比较两个 time 变量,并根据它们是否相等返回 truefalse

让我们看看 Equal() 函数的实际应用例子:

  now := time.Now()
  nowToo := now
  time.Sleep(2*time.Second)
  later := time.Now()
  if now.Equal(nowTtoo){
    fmt.Println("The two time variables are equal!")
  } else {
    fmt.Println("The two time variables are different!")
  }
  if now.Equal(later) {
    fmt.Println("The two time variables are equal!")
  }else{
    fmt.Println("The two time variables are different!")
  }

输出看起来是这样的:

The two time variables are equal!
The two time variables are different!

让我们看看这里会发生什么。我们有三个时间变量,分别称为nownow_toolatertime模块的Sleep()函数用于模拟 2 秒的延迟。这个函数接受一个整数参数,等待给定时间过去后继续执行。结果是later变量持有不同的时间值,使我们能够展示Equal()函数的目的,您可以在输出中看到。

现在,是时候检查计算两个时间变量之间的持续时间或差异所提供的设施了。

持续时间计算

在编程的许多方面,计算执行时间的能力都很有用。在我们的日常生活中,我们可以监控我们的基础设施可能面临的不一致性和性能瓶颈。例如,如果您有一个脚本平均只需要 5 秒钟就能完成,而监控执行时间显示您在一天中的某些小时或某些天有巨大的波动,那么进行调查可能很明智。另一个方面与 Web 应用程序有关。测量脚本中请求-响应的持续时间可以让你了解您在应用程序中投入了多少来应对高负载,甚至允许你在一年中的某些日子或周扩展您的容量。例如,如果您有一个在线商店处理产品,根据像黑色星期五或圣诞节这样的模式来调整您的容量可能很明智。

在大多数时间里,你可能可以用较低的能力完成工作,但如果基础设施规模不够,那些假期可能会导致收入损失。向你的脚本添加此类功能所需的编码非常少。现在让我们看看如何实现:

  start := time.Now()
  fmt.Println("The script started at: ", start)
  sum := 0
  for i := 1; i < 10000000000; i++ {
    sum += i
  }
  end := time.Now()
  duration := end.Sub(start)
  fmt.Println("The script completed at: ", end)
  fmt.Println("The task took", duration.Hours(), "hour(s) to complete!")
  fmt.Println("The task took", duration.Minutes(), "minutes(s) to complete!")
  fmt.Println("The task took", duration.Seconds(), "seconds(s) to complete!")
  fmt.Println("The task took", duration.Nanoseconds(), "nanosecond(s) to complete!")

如果您执行此脚本,结果将类似于以下内容,具体取决于 PC 的性能:

图 12.2:测量执行时间

图 12.2:测量执行时间

所需做的只是捕捉脚本开始和结束的时间。然后,我们可以通过减去开始时间和结束时间来计算持续时间。之后,我们可以利用Duration变量的函数来获取完成任务所需时间的小时()分钟()秒()纳秒()值。

您将获得四种分辨率,具体如下:

  • 小时

  • 分钟

  • 纳秒

如果您需要,例如,天、周或月,则可以从提供的分辨率中计算出来。

在过去,我们有一个要求测量事务持续时间的任务,并且我们需要满足一个服务级别协议SLA)。这意味着有一些应用程序需要处理请求,比如 1,000 毫秒或 5 秒,这取决于产品的关键性。接下来的脚本将向你展示这是如何实现的。你有六个不同的分辨率可供选择:

  • 小时

  • 分钟

  • 毫秒

  • 微秒

  • 纳秒

让我们考虑以下示例:

  deadlineSeconds := time.Duration((600 * 10) * time.Millisecond)
  start := time.Now()
  fmt.Println("Deadline for the transaction is", deadlineSeconds)
  fmt.Println("The transaction has started at:", start)
  sum := 0
  for i := 1; i < 25000000000; i++ {
    sum += i
  }
  end := time.Now()
  duration := end.Sub(start)
  transactionTime := time.Duration(duration.Nanoseconds()) * time.Nanosecond
  fmt.Println("The transaction has completed at:", end, duration)
  if transactionTime <= deadlineSeconds{
    fmt.Println("Performance is OK transaction completed in", transactionTime)
  } else{
    fmt.Println("Performance problem, transaction completed in", transactionTime,"second(s)!")
  }

当我们没有满足截止时间时,输出如下:

图 12.3:未满足事务截止时间

图 12.3:未满足事务截止时间

当我们满足截止时间时,看起来是这样的:

图 12.4:事务截止时间满足

图 12.4:事务截止时间满足

让我们剖析我们的示例。首先,我们使用time.Duration()变量定义事务的截止时间。根据我的经验,毫秒的分辨率是最优的;然而,适应计算它需要一些时间。你可以自由选择你喜欢的任何分辨率。我们用start变量标记开始,做一些计算,然后用end变量标记完成。魔法就在这里发生。我们想要计算截止时间和事务持续时间之间的差异,但我们不能直接这样做。我们需要将duration值转换为transaction时间。我们在创建截止时间时也是这样做的。我们简单地使用纳秒分辨率,这是我们应达到的最低分辨率。然而,在这种情况下,你可以使用你想要的任何分辨率。转换后,我们可以轻松地比较并决定事务是否正常。

现在,让我们看看我们如何可以操作时间。

时间管理

Go 编程语言的time包提供了两个函数,允许你操作时间。其中一个被称为Sub(),另一个被称为Add()。在我的经验中,这种情况并不常见。大多数情况下,当计算脚本的执行时间时,使用Sub()函数来告知差异。

让我们看看加法看起来是什么样子:

  timeToManipulate := time.Now()
  toBeAdded := time.Duration(10 * time.Second)
  fmt.Println("The original time:", timeToManipulate)
  fmt.Printf("%v duration later %v", toBeAdded, timeToManipulate.Add(toBeAdded))

执行后,以下输出欢迎我们:

The original time: 2023-10-18 08:49:53.1499273 +0200 CEST m=+0.001994601
10s duration later: 2023-10-18 08:50:03.1499273 +0200 CEST m=+10.001994601

让我们检查这里发生了什么。我们创建了一个变量来保存我们的时间,这需要一些操作。toBeAdded变量代表 10 秒的持续时间,这是我们想要添加的。time包的Add()函数期望一个time.Duration()类型的变量。然后,我们简单地调用我们的日期的Add()函数,结果在控制台上可见。Sub()函数的功能相当繁琐,它并不是真正用来从我们拥有的时间中移除特定持续时间的。这是可以做到的,但你需要更多的代码行来实现这一点。你可以通过使用负值来构建你的持续时间。将第二行替换为以下内容:

toBeAdded := time.Duration(-10 * time.Minute)

它将正常工作并输出以下内容:

The original time: 2023-10-18 08:50:36.5950116 +0200 CEST m=+0.001994401
-10m0s duration later: 2023-10-18 08:40:36.5950116 +0200 CEST m=+599.998005599

这正如我们所期望的;我们已经成功计算出了 10 分钟前的时间。

练习 12.02 – 执行持续时间

在这个练习中,我们将创建一个函数,允许你计算两个 time.Time 变量之间的执行持续时间,并返回一个字符串,告诉你执行完成花费了多长时间。

按以下顺序执行以下步骤:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. 使用以下 packageimport 语句初始化脚本:

    package main
    import (
      "time"
      "fmt"
      "strconv"
    )
    
  3. 现在我们定义我们的 elapsedTime() 函数:

    func elapsedTime(start time.Time, end time.Time) string {
      elapsed := end.Sub(start)
      hours := strconv.Itoa(int(elapsed.Hours()))
      minutes := strconv.Itoa(int(elapsed.Minutes()))
      seconds := strconv.Itoa(int(elapsed.Seconds()))
      return "The total execution time elapsed is: " + hours + " hour(s) and " + minutes + " minute(s) and " + seconds + " second(s)!"
    }
    
  4. 现在,我们已经准备好定义我们的 main() 函数:

    func main(){
      start := time.Now()
      time.Sleep(2 * time.Second)
      end := time.Now()
      fmt.Println(elapsedTime(start, end))
    }
    
  5. 运行以下代码:

    go run main.go
    

以下内容应作为输出显示:

The total execution time elapsed is: 0 hour(s) and 0 minute(s) and 2 second(s)!

在这个练习中,我们创建了一个函数,展示了执行动作需要多少小时、分钟和秒。这很有用,因为你可以将这个函数在其他 Go 应用程序中重用。

现在,让我们把注意力转向时间的格式化。

时间格式化

到目前为止,在本章中,你可能已经注意到日期看起来相当丑陋。我的意思是,看看以下行:

The transaction has started at: 2023-09-27 13:50:58.2715452 +0200 CEST m=+0.002992801

这些是故意留出的,以迫使你思考这是否就是 Go 能做到的全部。有没有一种方法来格式化这些行,使它们更方便、更容易阅读?如果有,那些额外的行是什么?

在这里,我们将回答这些问题。当我们谈论时间格式化时,有两个主要概念是我们所指的。第一个选项是在我们希望 time 变量在打印时输出一个看起来令人满意的字符串时使用,第二个选项是在我们希望将一个字符串解析为特定格式时使用。两者都有自己的用例;我将详细讲解这两个概念,同时教你如何使用它们。

首先,我们将学习关于 Parse() 函数的内容。这个函数本质上有两个参数。第一个是要解析的格式字符串,第二个是需要解析的字符串。这个解析的结束将得到一个 time 变量,它可以利用内置的 Go 函数。Go 使用基于 POSIX 的日期格式,其中 Parse() 在你有应用程序正在处理来自不同时区的时间值,并且你想将它们转换为相同的时区以更好地理解和比较时非常有用:

Mon Jan 2 15:04:05 -0700 MST 2006
0 1 2 3 4 5 6

这种日期格式等同于 POSIX 中的“123456”,可以从前面的示例中解码。语言中提供了常数来帮助你处理解析不同的时间字符串。

我们可以解析三种主要的时间格式:

  • RFC3339

  • UnixDate

  • ANSIC

让我们看看 Parse() 是如何工作的:

  t1, err := time.Parse(time.RFC3339, "2019-09-27T22:18:11+00:00")
  if err != nil {
    fmt.Println(err)
  }
  t2, err := time.Parse(time.UnixDate, "2019-09-27T22:18:11+00:00")
  if err != nil {
    fmt.Println(err)
  }
  t3, err := time.Parse(time.ANSIC, "2019-09-27T22:18:11+00:00")
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println("RFC3339:", t1)
  fmt.Println("UnixDate", t2)
  fmt.Println("ANSIC", t3)

输出如下:

parsing time "2019-09-27T22:18:11+00:00" as "Mon Jan _2 15:04:05 MST 2006": cannot parse "2019-09-27T22:18:11+00:00" as "Mon"
parsing time "2019-09-27T22:18:11+00:00" as "Mon Jan _2 15:04:05 2006": cannot parse "2019-09-27T22:18:11+00:00" as "Mon"
RFC3339: 2019-09-27 22:18:11 +0000 +0000
UnixDate 0001-01-01 00:00:00 +0000 UTC
ANSIC 0001-01-01 00:00:00 +0000 UTC

背后发生的事情如下。我们有 t1t2t3 变量,它们持有时间,这些时间与指定的格式进行解析。err 变量持有转换过程中可能出现的错误结果。t1 变量的输出是唯一有意义的;UnixDateANSIC 是错误的,因为它们解析了错误的字符串。UnixDate 期望一些它们称之为 epoch 的东西。epoch 是一个非常独特的日期;在 Unix 系统中,它标志着时间的开始,始于 1970 年 1 月 1 日。它期望一个巨大的整数,这是从这个日期开始经过的秒数。格式期望类似以下的输入:Mon Sep _27 18:24:05 2019。提供这样的时间允许 Parse() 函数提供正确的输出。

既然我们已经明确了 Parse() 函数,那么是时候看看 Format() 函数了。

Go 允许你自定义 时间 变量。让我们学习如何实现这一点,然后我们将对它们进行格式化:

date := time.Date(2019, 9, 27, 18, 50, 48, 324359102, time.UTC)
fmt.Println(date)

之前的代码演示了如何自己构建时间;然而,我们将看看所有这些数字代表什么。那个骨架语法如下:

func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time

实际上,我们需要指定年、月、日、小时等等。我们希望根据输入变量重新格式化输出;这应该看起来如下:

2019-09-27 18:50:48.324359102 +0000 UTC

在人们开始在大企业环境中工作之前,时区并不重要。当你拥有一个全球性的互联设备群时,能够区分时区就变得很重要了。如果你想要一个 AddDate() 函数,它可以用来将 添加到当前时间,那么这必须允许你动态地添加到你的日期中。让我们看看一个例子。给定我们之前的日期,让我们添加 1 年、2 个月和 3 天:

date := time.Date(2019, 9, 27, 18, 50, 48, 324359102, time.UTC)
next Date := date.AddDate(1, 2, 3)
fmt.Println(next Date)

当你运行这个程序时,你会得到以下输出:

2020-11-30 18:50:48.324359102 +0000 UTC

AddDate() 函数接受三个参数:第一个是 years,第二个是 months,第三个是 days。这给了你调整你脚本的精细机会。为了正确理解格式化是如何工作的,你需要知道内部是如何运作的。

时间格式化中的一个最后重要方面是了解你如何利用 time 包中的 LoadLocation() 函数将你的本地时间转换为另一个时区的本地时间。我们的参考时区将是 洛杉矶 时区。Format() 函数用于告诉 Go 我们希望如何格式化输出。In() 函数是指定我们希望格式化存在的特定时区的引用。

让我们找出洛杉矶的时间:

  current := time.Now()
  losAngeles, err := time.LoadLocation("America/Los_Angeles")
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println("The local current time is:", current.Format(time.ANSIC))
  fmt.Println("The time in Los Angeles is:", current.In(losAngeles).Format(time.ANSIC))

根据你执行的日子,你应该看到以下输出:

The local current time is: Fri Oct 18 08:14:48 2019
The time in Los Angeles is: Thu Oct 17 23:14:48 2019

关键在于我们得到本地时间在一个变量中,然后我们使用 time 包中的 In() 函数,比如说,将那个值转换为特定时区的值。这很简单,但很有用。

练习 12.03 – 您所在时区的时间是什么?

在本练习中,我们将创建一个函数,它将告诉当前时区与指定时区之间的差异。该函数将利用 LoadLocation() 函数根据位置指定变量,该变量将被设置为特定的时间。In() 位置将用于将特定的时间值转换为给定时区的时间值。输出格式应为 ANSIC 标准。

按以下顺序执行以下步骤:

  1. 创建一个新的文件夹并添加一个 main.go 文件。

  2. 使用以下 packageimport 语句初始化脚本:

    package main
    import (
      "time"
      "fmt"
    )
    
  3. 现在是创建我们称为 timeDiff() 的函数的时候了,它还将返回格式化为 ANSICcurrentremoteTime 变量:

    func timeDiff(timezone string) (string, string) {
      current := time.Now()
      remoteZone, err := time.LoadLocation(timezone) 
      if err != nil {
        fmt.Println(err)
    }
      remoteTime := current.In(remoteZone)
      fmt.Println("The current time is:", current.Format(time.ANSIC))
      fmt.Println("The timezone:", timezone,"time is:", remoteTime)
      return current.Format(time.ANSIC), remoteTime.Format(time.ANSIC)
    }
    
  4. 定义一个 main() 函数:

    func main(){
      fmt.Println(timeDiff("America/Los_Angeles"))
    }
    
  5. 运行代码:

    go run main.go
    

输出如下所示:

The current time is: Thu Oct 17 15:37:02 2023
The timezone: America/Los_Angeles time is: 2023-10-17 06:37:02.2440679 -0700 PDT
Thu Oct 17 15:37:02 2023 Thu Oct 17 06:37:02 2023

注意

打印出的时间将根据您运行代码的时间而有所不同。在本练习中,我们看到了在时区之间导航是多么容易。

活动 12.01 – 根据用户要求格式化日期

在此活动中,您需要创建一个小脚本,它接受当前日期并以以下格式输出:02:49:21 31/01/2023。您需要利用您迄今为止学到的有关将整数转换为字符串的知识。这将允许您连接 time 变量的不同部分。请记住,Month() 函数省略了月份名称而不是月份数字。

您必须执行以下步骤以获得所需的输出:

  1. 使用 time.Now() 函数将当前日期捕获到一个变量中。

  2. 将捕获的日期分解为 daymonthyearhourminuteseconds 变量,通过将它们转换为字符串来实现。

  3. 按顺序打印出连接的变量。

    一旦脚本完成,输出应如下所示(请注意,这取决于您何时运行代码):

    15:32:30 2023/10/17
    

    到此活动结束时,您应该已经学会了如何创建自定义的 time 变量,并使用 strconv.Itoa() 将数字转换为字符串并连接结果。

注意

此活动的解决方案可以在本书的 GitHub 仓库中找到:本书本章的 GitHub 仓库文件夹:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter12/Activity12.01

活动 12.02 – 强制执行日期和时间的特定格式

此活动要求您使用本章关于时间的知识。我们希望创建一个小脚本,以以下格式打印日期:02:49:21 31/01/2023

首先,你需要通过使用 time.Date() 函数创建一个 date 变量。然后你需要回忆我们是如何访问变量的 YearMonthDay 属性的,并按照适当的顺序创建一个连接。记住,你不能连接字符串和整数变量。strconv() 函数就在那里帮助你。你还需要记住,当你省略 date.Month() 命令时,它会打印出月份的名称,但它也需要被转换成整数,然后再转换回带有数字的字符串。

你必须执行以下步骤以获得所需的输出:

  1. 使用 time.Now() 函数将当前日期保存在一个变量中。

  2. 使用 strconv.Itoa() 函数将捕获的 date 变量的适当部分保存到以下变量中:daymonthyearhourminutesecond

  3. 最后,使用适当的连接打印这些信息。

预期的输出应该看起来像这样:

2:49:21 2023/1/31

在本活动结束时,你应该已经学会了如何将当前日期格式化为特定的自定义格式。

注意

本活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter12/Activity12.02找到。

活动 12.03 – 测量经过的时间

本活动要求你测量睡眠的持续时间。你应该使用 time.Sleep() 函数休眠 2 秒,一旦休眠完成,你需要计算开始时间和结束时间之间的差异,并展示它花费了多少秒。

首先,你标记执行的开始,休眠 2 秒,然后在一个变量中捕获执行结束的时间。通过使用 time.Sub() 函数,我们可以使用 Seconds() 函数输出结果。输出将有点奇怪,因为它将比预期的稍长。

你必须执行以下步骤以获得所需的输出:

  1. 将开始时间保存在一个变量中。

  2. 创建一个持续 2 秒的 sleep 变量。

  3. 将结束时间保存在一个变量中。

  4. 通过从结束时间减去开始时间来计算长度。

  5. 打印出结果。

根据你电脑的速度,你应该期望以下输出:

The execution took exactly 2.0016895 seconds!

在本活动结束时,你应该已经学会了如何测量特定活动的经过时间。

注意

本活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter12/Activity12.03

活动 12.04 – 计算未来的日期和时间

在这个活动中,我们将计算从Now()开始的 6 小时、6 分钟和 6 秒后的日期。你需要将当前时间存储在一个变量中。然后,在给定的日期上使用Add()函数添加之前提到的长度。为了方便,请使用time.ANSIC格式。但是有一个问题。因为Add()函数期望一个持续时间,所以在添加之前,你需要选择一个分辨率,比如Second,并构建持续时间。

你必须执行以下步骤以获得所需的输出:

  1. 活动十二点零五 - 打印不同时区的本地时间

  2. 注意

  3. 你必须执行以下步骤以获得所需的输出:

  4. 将持续时间添加到当前时间。

  5. ANSIC格式打印出未来的日期。

确保你的输出看起来像这样,使用字符串格式化:

The current time: Thu Oct 17 15:16:48 2023
6 hours, 6 minutes and 6 seconds from now the time will be: Thu Oct 17 21:22:54 2023

在完成这个活动之后,你应该已经学会了如何通过使用time.Duration()time.Add()函数来计算未来的特定日期。

注意

这个活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter12/Activity12.04

摘要

这个活动要求你运用在时间格式化部分学到的知识。你需要加载一个东部城市和一个西部城市。然后,打印出每个城市的当前时间。

这个活动的解决方案可以在本章节的 GitHub 仓库文件夹中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter12/Activity12.05

关键在于LoadLocation()函数,你需要为输出使用ANSIC格式。记住,LoadLocation()函数返回两个值!

  1. 在一个变量中捕获当前时间。

  2. 使用time.LoadLocation()函数创建NyTimeLaTime的参考时区变量。

  3. ANSIC格式打印出相应时区的当前时间。

根据你的执行日,以下可能是你预期的输出:

The local current time is: Thu Oct 17 15:16:13 2023
The time in New York is: Thu Oct 17 09:16:13 2023
The time in Los Angeles is: Thu Oct 17 06:16:13 2023

在完成这个活动之后,你应该已经学会了如何将你的时间变量转换为特定时区。

在一个变量中捕获当前时间。

ANSIC格式打印此值作为参考。

以秒为输入计算持续时间。

本章向您介绍了 Go 语言的time包,它允许您重用其他程序员发明并融入语言的代码。目标是教会您如何创建、操作和格式化time变量,并使您熟悉在time包的帮助下可以做什么。如果您想进一步改进或深入了解该包提供的功能,请查看以下链接:golang.org/pkg/time/

时间戳和时间操作是每个开发人员必备的技能。无论您是将大或小的脚本投入生产,time模块都能帮助您测量操作的执行时间,并提供在执行过程中发生的操作日志。最重要的是,如果使用得当,它可以帮助您轻松地将生产问题追溯到其根源。

第四部分:应用

应用程序的大小和功能各不相同,从单一目的的小工具到具有众多功能的大型系统。无论它们的复杂程度如何,所有应用程序都具备接口,无论是用于人机交互(用户界面/UI)还是与其他应用程序通信(应用程序编程接口/API)。

在本节中,您将探索应用程序的开发,从命令行工具到与文件、数据库等交互的系统。

本部分包含以下章节:

  • 第十三章从命令行进行编程

  • 第十四章文件和系统

  • 第十五章SQL 和数据库

第十三章:命令行编程

概述

在本章中,我们将探讨从命令行进行编程。我们将看到 Go 是创建强大命令行工具和应用程序的绝佳选择,并讨论使用 Go 处理命令行的许多工具。

通过阅读本章,您将熟悉在 Go 中开发强大的命令行工具和应用程序。我们将从读取命令行参数和利用这些标志值来控制应用程序行为的基础知识开始,一瞥在应用程序内外处理大量数据的方法,并在过程中评估退出代码和最佳实践。然后,我们将进一步深入探讨优雅地处理中断、从我们的应用程序中启动外部命令以及使用 go install 的策略。最后,我们将学习如何创建终端用户界面TUIs),这允许我们用 Go 编写强大且用户友好的命令行工具。

技术要求

对于本章,您需要 Go 版本 1.21 或更高。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter13找到。

简介

在上一章中,我们探讨了 Go 在处理时间数据时提供的强大结构。在本章中,我们将稍微转换一下方向,讨论 Go 在为应用程序创建强大界面方面有益的许多方法之一。

用户界面不总是必须是网络应用程序的前端网页。最终用户可以通过引人入胜的命令行界面以及使用命令行****界面CLI)与软件进行交互。

Go 提供了许多允许我们为命令行编程的包。我们将查看其中的一些包,了解 Go 在创建强大命令行工具方面的位置,并了解一些当前在这个领域的努力。

读取参数

命令行参数是构建灵活和交互式命令行应用程序的基本方面。读取参数允许开发者使他们的应用程序更加动态和适应用户输入。命令行参数为用户提供了一种自定义程序行为的方式,而无需修改其源代码。通过从命令行捕获输入参数,开发者可以创建适应不同用例和场景的通用应用程序。

在 Go 语言中,os 包提供了一种直接访问这些参数的简单方法。os.Args 切片提供了一种方便的方式来访问命令行参数。这使得开发者能够检索有关文件路径、配置参数或与应用程序功能相关的任何其他输入信息。读取命令行参数的能力通过使应用程序更加交互性和用户友好来增强用户体验。

此外,命令行参数可以实现自动化和脚本化,使用户能够以编程方式传递输入。这种灵活性在需要以不同参数执行相同程序的场景中尤其有价值,使其成为脚本化和自动化任务的强大工具。

让我们深入了解读取命令行参数的过程,并通过一个简单的示例来展示,该示例使用个性化消息问候用户。

练习 13.01 – 使用作为参数传递的名称说“你好”

在这个练习中,我们将使用从命令行传递的参数来打印一个 hello 语句:

  1. 导入 fmtos 包:

    package main
    import (
      "fmt"
      "os"
    )
    
  2. 利用前面提到的 args 切片来捕获命令行参数:

    func main() {
      args := os.Args
    
  3. 对提供的参数数量进行验证,不包括提供的可执行文件名:

      if len(args) < 2 {
        fmt.Println("Usage: go run main.go <name>")
        return
      }
    
  4. 从提供的参数中提取名称:

      name := args[1]
    
  5. 显示个性化的问候信息:

      greeting := fmt.Sprintf("Hello, %s! Welcome to the command line.", name)
      fmt.Println(greeting)
    }
    
  6. 运行以下命令以执行代码:

    go run main.go Sam
    

输出如下:

Hello, Sam! Welcome to the command line.

通过这些,我们已经展示了使用 Go 语言捕获命令行参数的基本方法,并看到了以简单方式捕获输入数据的一些好处。虽然在这个例子中我们使用了 os 包,但其他包可以帮助实现读取应用程序提供的输入的相同目标,例如使用 flags 包。让我们看看标志在编程中对于命令行有多有用。

使用标志来控制行为

flags 包提供了一种比直接使用 os 包更高级和更结构化的方法来读取参数。标志简化了解析和处理命令行输入的过程,使得开发者更容易创建健壮且用户友好的命令行应用程序。

flags 包允许您定义带有相关类型和默认值的标志,从而清楚地表明用户应提供何种类型的输入。它还自动生成帮助信息,使您的程序更具自文档性。以下是 flags 包如何帮助读取和处理命令行参数的简要概述:

  • 定义标志: 您可以定义标志,包括它们的类型和默认值。这提供了一种清晰且结构化的方式来指定预期的输入。

  • 解析标志: 在定义标志之后,您可以解析命令行参数。这将使用用户提供的值初始化标志变量。

  • 访问标志值:一旦你解析了传递给程序的标志值,你就可以通过变量访问定义的标志,并在整个应用程序中继续使用它们。

标志允许你自定义程序的行为,而无需修改源代码。例如,你可以创建允许你根据标志值切换行为的标志。你还可以使用基于某些标志值设置的基本条件逻辑。让我们完成一个练习,并利用flags包来有条件地说“你好”。

练习 13.02 – 使用标志有条件地说“你好”

在这个练习中,我们将使用flags包打印一个hello语句:

  1. 导入fmtos包:

    package main
    import (
      "flag"
      "fmt"
    )
    
  2. 为实用程序创建标志并设置默认值:

    var (
      nameFlag = flag.String("name", "Sam", "Name of the person to say hello to")
      quietFlag = flag.Bool("quiet", false, "Toggle to be quiet when saying hello")
    )
    Parse the flags and conditionally say hello pending the value of the quiet flag:
    func main(){
      flag.Parse()
      if !*quietFlag {
        greeting := fmt.Sprintf("Hello, %s! Welcome to the command line.", *nameFlag)
        fmt.Println(greeting)
      }
    }
    

如果你运行go run main.go,你会收到以下输出。这是因为quietFlag默认为false,而nameFlag默认为Sam

Hello, Sam! Welcome to the command line.

然而,你可以为标志设置值。为此,你可以使用nameFlag并设置quietFlag的值。运行go run main.go --name=Cassie –-quiet=false的输出如下。这是因为quietFlag被设置为false,而nameFlag被设置为Sam

Hello, Cassie! Welcome to the command line.

或者,如果你使用quietFlag的值为true,则不会输出任何内容。所以,如果你运行go run main.go --quiet=true,那么你将看不到任何输出,因为我们已经使用了标志来控制程序预期的输出行为。

这段代码展示了如何使用标志来控制程序的行为。如果你在使用他人的命令行界面,那么你可以无缝地使用help标志来列出可用的定义标志。Go 语言中的flag包会根据程序中的标志自动生成帮助信息。要查看前面代码的可用帮助信息,你可以运行go run main.go --help。这将提供以下输出:

Usage of /var/folders/qt/5jjdv1bj3h33t2rl40tpt56w0000gn/T/go-build1361710947/b001/exe/main:
-name string
Name of the person to say hello to (default "Sam")
-quiet
Toggle to be quiet when saying hello

通过使用flags包,你可以提高代码的可读性和可维护性,同时提供更友好的用户体验。它简化了处理各种类型输入的过程,并自动生成使用信息,使用户更容易理解和与命令行应用程序交互。现在,让我们看看如何将数据流进和流出应用程序。

在应用程序中流进和流出大量数据

在命令行应用程序中,为了性能和响应性,高效地处理大量数据至关重要。通常,命令行应用程序可能是更大数据处理管道中的一小部分。大多数人都不愿意坐下来逐个输入大量数据,比如数据集。

Go 语言允许你将数据流到你的应用程序中,这样你就可以分块处理信息,而不是一次性处理。这允许你有效地处理大量数据,减少内存开销,并为未来的可扩展性提供更好的支持。

当处理大量数据时,它们通常存储在文件中。这可以是从金融 CSV 文件、分析 Excel 文件到机器学习数据集。使用 Go 流式传输数据的几个主要优点包括:

  • 内存效率:程序可以逐行读取和处理数据,减少内存消耗,因为您不必将整个数据读入内存

  • 实时分析:用户可以观察处理数据结果的真实时分析

  • 交互式界面:您可以通过增强命令行界面使其接受动态信息或在大数据处理时显示额外详细信息

数据机密性可能取决于您可能流式传输到命令行应用程序的数据类型。因此,可能采用不同的编码机制来隐藏文本,而不提供真实的安全性,或者作为保护数据的第一步。

Rot13,或旋转 13 个位置,是一种简单的字母替换密码,用字母表中该字母之后的第 13 个字母替换它。例如,字母 A 将变成 N,B 将变成 C,以此类推。它是一种对称密钥算法,通常用作加密的简单形式,以掩盖文本。此算法不提供显著的安全性,主要用于娱乐,通常不会在生产环境中用于保护数据。它也是完全可逆的,这意味着应用 Rot13 两次将产生相同的数据。这在发送和接收文本的环境中可能很有用,接收端可能知道或不知道数据是否已被编码。

让我们扩展我们新的 Rot13 知识,以便我们可以为一个命令行应用程序工作在有趣的流数据示例。

练习 13.03 – 使用管道、stdin 和 stdout 应用 Rot13 编码到文件

在这个练习中,我们将使用 Rot13 编码处理一些输入数据:

  1. 导入所需的包:

    package main
    import (
      "bufio"
      "fmt"
      "io"
      "os"
    )
    
  2. 定义rot13函数,将 Rot13 编码应用于给定的字符串:

    func rot13(s string) string {
      result := make([]byte, len(s))
      for i := 0; i < len(s); i++ {
        char := s[i]
        switch {
        case char >= 'a' && char <= 'z':
          result[i] = 'a' + (char-'a'+13)%26
        case char >= 'A' && char <= 'Z':
          result[i] = 'A' + (char-'A'+13)%26
        default:
          result[i] = char
        }
      }
      return string(result)
    }
    
  3. 定义一个函数,从stdin读取数据,应用 Rot13 编码,并将输出写入stdout

    func processStdin() {
      reader := bufio.NewReader(os.Stdin)
      for {
        input, err := reader.ReadString('\n')
        if err == io.EOF {
          break
        } else if err != nil {
          fmt.Println("Error reading stdin:", err)
          return
        }
        encoded := rot13(input)
        fmt.Print(encoded)
      }
    }
    
  4. 定义一个处理文件或用户输入的函数,应用 Rot13 编码,并将输出写入stdout

    func processFileOrInput() {
      var inputReader io.Reader
      // Check if a file path is provided
      if len(os.Args) > 1 {
        file, err := os.Open(os.Args[1])
        if err != nil {
          fmt.Println("Error opening file:", err)
          return
        }
        defer file.Close()
        inputReader = file
      } else {
        // No file provided, read user input
        fmt.Print("Enter text: ")
        inputReader = os.Stdin
      }
      // Process input and apply rot13 encoding
      scanner := bufio.NewScanner(inputReader)
      for scanner.Scan() {
        // Apply rot13 encoding to the input line
        encoded := rot13(scanner.Text())
        fmt.Println(encoded)
      }
      if err := scanner.Err(); err != nil {
        fmt.Println("Error reading input:", err)
      }
    }
    
  5. 定义主函数:

    func main() {
      // Check if data is available on stdin
      stat, _ := os.Stdin.Stat()
      if (stat.Mode() & os.ModeCharDevice) == 0 {
        // Data available on stdin, process it
        processStdin()
      } else {
        // No data on stdin, process file or user input
        processFileOrInput()
      }
    }
    

如果您运行go run main.go并输入一些文本,您将收到以下输出:

Enter text: enjoy
rawbl
the
gur
book
obbx

要退出程序,您可以输入 Ctrl + C。此外,程序可以用于管道中,其中一条命令的输出成为命令行应用程序的输入,如果您使用 cat data.txt | go run main.gocat 是一个可以将文件连接在一起的命令(对于 Windows,使用 type 命令进行连接)。如果您单独使用它,那么它提供了一个打印文件内容的简单方法。如果您声明一个 data.txt 文件,并使用以下命令将文件内容传递给命令行应用程序,那么您将看到类似的输出:

cat data.txt | go run main.go

这是生成的输出:

rawbl
gur
obbx

前面的练习演示了一些内容。首先,我们看到了如何使用 bufio.NewReader 逐行处理 stdin 数据,直到遇到文件结束错误。我们还看到了如何处理文件或输入数据并对其进行 Rot13 编码。最后,我们看到了如何使用相同的代码将大量数据通过管道输入程序进行编码。此代码展示了 Go 在命令行应用程序中流式传输大量数据的能力。程序必须通过 Ctrl + C 终止以中断从 stdin 的读取并退出程序。这为我们探索退出代码和最佳实践提供了完美的过渡,在我们探索中断之前,我们将更详细地探讨中断,我们将学习更多关于使用中断(如 Ctrl + C)来终止程序的知识。

退出代码和命令行最佳实践

确保适当的退出代码并遵循最佳实践对于无缝的用户体验至关重要。退出代码为命令行应用程序提供了一种向调用应用程序传达其状态的方式。一个定义良好的退出代码系统使用户和其他脚本能够理解应用程序是否成功执行或在运行时遇到了问题。

在 Go 中,os 包提供了一个使用 os.Exit 函数设置退出代码的直接方法。传统上,退出代码 0 表示成功,而任何非零代码表示错误。

例如,您可以检查上一个练习的状态代码并验证成功状态代码。为此,请在终端中运行 echo $?$? 是一个特殊的 shell 变量,它保存了最后执行命令的退出状态,而 echo 命令将其打印出来。您将看到 0 退出代码的打印输出,表示成功执行状态,没有错误。您可以在程序中手动捕获错误并返回非零代码以表示错误。您甚至可以创建自定义退出代码,如下所示:

const (
  ExitCodeSuccess = 0
  ExitCodeInvalidInput = 1
  ExitCodeFileNotFound = 2
)

这些可以通过 os.Exit 容易地使用,通过在希望退出的成功情况下放置 os.Exit(ExitCodeSuccess),并在特定情况下使用其他错误代码来退出。

在使用适当的退出代码是重要的命令行最佳实践的同时,还有一些其他事项需要考虑:

  • 一致的日志记录:使用有意义的消息来帮助故障排除。

  • 提供清晰的用法信息:提供清晰简洁的用法信息,包括标志和参数。此外,一些包允许您提供示例命令。应该使用这些命令让其他人轻松了解如何使用命令。

  • 处理帮助和版本控制:实现标志以显示帮助和版本信息。这有助于使您的应用程序更加用户友好,并提供一种确保他们使用最新版本的方法,通过检查版本信息。

  • 优雅终止:应考虑退出代码并优雅地终止,确保按需执行适当的清理任务。

您在命令行应用程序最佳实践和退出代码考虑方面已经取得了很好的进展。然而,有时最终用户会提供中断来取消应用程序。让我们学习在这种情况下应该做什么和考虑什么。

通过观察中断来了解何时停止

在构建健壮的应用程序时,优雅地处理中断至关重要,确保软件能够适当地响应表示它应该停止或执行特定操作的信号。在 Go 中,实现这一点的标准方式是通过监控中断信号,允许应用程序以有序的方式关闭或清理资源。

优雅关闭或终止是计算机科学中一个重要的概念。不可预见的事件、服务器维护或外部因素可能需要您的应用程序优雅地停止。这可能包括释放资源、保存状态或通知已连接的客户端。优雅的关闭确保您的应用程序保持可靠和可预测,最大限度地减少数据损坏或丢失的风险。

在没有适当清理的情况下突然终止应用程序可能导致各种问题,如未完成的交易、资源泄露、数据损坏等。优雅的关闭通过提供完成正在进行中的任务和释放已获取资源的机会来减轻这些风险。

操作系统通过信号与运行中的进程进行通信。进程只是计算机上运行的程序。os/signal 包为在 Go 程序中方便地处理这些信号提供了方法。有常见的中断信号,如 stdin 和退出程序。

signal.Notify 函数允许您注册通道以接收指定的信号。这为在接收到中断信号时优雅地关闭您的应用程序奠定了基础。还有一些有效的关闭模式和最佳实践需要牢记,例如确保关闭网络连接、保存状态和向 goroutines 发送信号,以便在退出前完成其任务。此外,使用超时和 context 包增强了应用程序在关闭期间的响应性,防止其无限期地卡住。

优雅地处理中断信号是构建健壮且可靠的 Go 命令行应用程序的基本技能。通过遵循最佳实践和模式以确保优雅的终止,你可以确保你的软件即使在面对意外中断的情况下也能表现出可预测的行为。

你不仅可以用不同的中断优雅地停止程序,还可以从命令行应用程序中启动其他命令。

从你的应用程序启动其他命令

从你的 Go 应用程序中启动外部命令为你打开了与其他程序、进程和系统工具交互的机会。Go 中的os/exec包提供了启动和与外部进程交互的功能。你可以使用这个包运行基本命令,捕获它们的输出,并无缝地处理错误。它为更高级的命令执行场景提供了一个基础。

例如,os/exec包允许你通过配置工作目录、环境变量等属性来自定义命令的执行。你还可以通过来自原始命令行应用程序的标准输入流向子命令提供输入。

通过在命令行应用程序中运行其他命令,你可以将一些进程放在后台运行,允许应用程序继续执行,同时监控或与并行进程交互。你甚至可以与命令建立双向通信,使命令行应用程序和外部进程之间实现实时交互和数据交换。

当启动其他应用程序时,必须考虑到跨平台的问题。不同操作系统之间在 shell 行为和命令路径上存在差异。因此,当从命令行应用程序中执行子命令时,重要的是要考虑到无论最终用户使用哪种计算设备,都要保持一致和可靠的命令执行。幸运的是,os/exec包为 Go 中执行外部命令提供了一个跨平台解决方案,这使得在不同操作系统上编写代码变得更加容易。

既然我们已经讨论了从 Go 命令行应用程序中执行其他命令的有用性,让我们看看一个实际操作的例子。

练习 13.04 – 创建有时间限制的秒表

在这个练习中,我们将创建一个有时间限制的秒表,并从应用程序中启动另一个命令:

  1. 导入必要的包:

    package main
    import (
      "fmt"
      "os"
      "os/exec"
      "time"
    )
    
  2. 在主函数中,设置秒表的计时限制,并允许用户输入来启动时钟:

    func main() {
      timeLimit := 5 * time.Second
      fmt.Println("Press Enter to start the stopwatch...")
      _, err := fmt.Scanln() // Wait for user to press Enter
      if err != nil {
        fmt.Println("Error reading from stdin:", err)
        return
      }
      fmt.Println("Stopwatch started. Waiting for", timeLimit)
    
  3. 等待时间限制,从命令行应用程序中执行其他命令,并关闭主函数:

      time.Sleep(timeLimit)
      fmt.Println("Time's up! Executing the other command.")
      cmd := exec.Command("echo", "Hello")
      cmd.Stdout = os.Stdout
      cmd.Stderr = os.Stderr
      err = cmd.Run()
      if err != nil {
        fmt.Println("Error executing command:", err)
      }
    }
    

如果你运行go run main.go并在提示时按下 Enter 键来启动计时器,你将收到以下输出:

Press Enter to start the stopwatch...
Stopwatch started. Waiting for 5s
Time's up! Executing the other command.
Hello

执行外部命令是一种强大的功能,允许你的 Go 应用程序与更广泛的环境交互。虽然这是一个简单的 echo 命令,但它展示了如果你扩展此代码以启动其他应用程序、并行或后台运行命令等,这一功能有多强大。

终端用户界面

Go 命令行编程的最新更新中包括了一些终端用户界面,简称TUI。在 Go 中创建一个 TUI 打开了构建交互式命令行应用的新世界。构建终端用户界面的过程中涉及一些基本概念:

  • 组件:TUI 由各种组件组成,如按钮、输入字段和/或列表

  • 布局:在结构化的布局中排列组件对于整洁直观的设计至关重要

  • 用户输入处理:处理键盘事件中的用户输入是交互式界面的基本要素

一些 TUI 包提供了对事件处理的支持,例如鼠标事件和按键,基于输入数据的动态更新,或自定义 UI 组件的外观。有几个流行的 TUI 包可用。在接下来的练习中,我们将查看其中一个,我们将在此基础上构建本章之前的练习。

练习 13.05 – 为我们的 Rot13 管道创建包装器

在这个练习中,我们将为我们在 练习 13.03 中创建的 Rot13 管道创建一个 TUI 包装器。

  1. 导入必要的包:

    package main
    import (
      "bufio"
      "fmt"
      "io"
      "os"
      "strings"
      tea "github.com/charmbracelet/bubbletea"
    )
    
  2. 表示 TUI 选择和模型具体信息:

    var choices = []string{"File input", "Type in input"}
    type model struct {
      cursor int
      choice string
    }
    func (m model) Init() tea.Cmd {
      return nil
    }
    
  3. 定义处理模型更新的函数:

    func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
      switch msg := msg.(type) {
      case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q", "esc":
          return m, tea.Quit
        case "enter":
          m.choice = choices[m.cursor]
          return m, tea.Quit
        case "down", "j":
          m.cursor++
          if m.cursor >= len(choices) {
            m.cursor = 0
          }
        case "up", "k":
          m.cursor--
          if m.cursor < 0 {
            m.cursor = len(choices) - 1
          }
        }
      }
      return m, nil
    }
    
  4. 定义 TUI 视图:

    func (m model) View() string {
      s := strings.Builder{}
      s.WriteString("Select if you would like to work with file input or type in input:\n\n")
      for i := 0; i < len(choices); i++ {
        if m.cursor == i {
          s.WriteString("(•) ")
        } else {
          s.WriteString("( ) ")
        }
        s.WriteString(choices[i])
        s.WriteString("\n")
      }
      s.WriteString("\n(press q to quit)\n")
      return s.String()
    }
    
  5. 定义与之前相同的 Rot13 编码函数:

    func rot13(s string) string {
      result := make([]byte, len(s))
      for i := 0; i < len(s); i++ {
        char := s[i]
        switch {
        case char >= 'a' && char <= 'z':
          result[i] = 'a' + (char-'a'+13)%26
        case char >= 'A' && char <= 'Z':
          result[i] = 'A' + (char-'A'+13)%26
        default:
          result[i] = char
        }
      }
      return string(result)
    }
    
  6. 定义从 stdin 读取数据以应用 Rot13 编码的函数:

    func processStdin() {
      reader := bufio.NewReader(os.Stdin)
      for {
        input, err := reader.ReadString('\n')
        if err == io.EOF {
          break
        } else if err != nil {
          fmt.Println("Error reading stdin:", err)
          return
        }
        encoded := rot13(input)
        fmt.Print(encoded)
      }
    }
    
  7. 定义处理文件输入的修改后的函数:

    func processFile(filename string) {
      var inputReader io.Reader
      file, err := os.Open(filename)
      if err != nil {
        fmt.Println("Error opening file:", err)
        return
      }
      defer file.Close()
      inputReader = file
      // Process input and apply rot13 encoding
      scanner := bufio.NewScanner(inputReader)
      for scanner.Scan() {
        encoded := rot13(scanner.Text())
        fmt.Println(encoded)
      }
      if err := scanner.Err(); err != nil {
        fmt.Println("Error reading input:", err)
      }
    }
    
  8. 定义启动 TUI 的主函数:

    func main() {
      p := tea.NewProgram(model{})
      m, err := p.Run()
      if err != nil {
        fmt.Println("Error running program:", err)
        os.Exit(1)
      }
      if m, ok := m.(model); ok && m.choice != "" {
        fmt.Printf("\n---\nYou chose %s!\n", m.choice)
      }
      if m, ok := m.(model); ok && m.choice != "" && m.choice == "File input" {
        processFile("data.txt")
      }
      if m, ok := m.(model); ok && m.choice != "" && m.choice == "Type in input" {
        processStdin()
      }
    }
    

如果你运行 go run main.go 并选择“文件输入”,你会收到以下输出:

Select if you would like to work with file input or type in input:
(•) File input
( ) Type in input
(press q to quit)
---
You chose File input!
rawbl
gur
obbx

如果你运行 go run main.go 并选择“类型”输入,你会收到以下输出:

Select if you would like to work with file input or type in input:
( ) File input
(•) Type in input
(press q to quit)
---
You chose Type in input!
enjoy
rawbl
the
gur
book
obbx

以下示例是在我们之前的练习基础上进行的扩展。在这里,我们提供了一个非常简单的包装器,用于 Rot13 编码练习的入口,提供了一个很好的用户界面,如果你打算使用默认的数据文件作为输入或提供自己的输入。这个 TUI 故意设计得简单,以展示在定义模型接口以便它与终端用户界面一起工作时,涉及的内容相当多。

现在,让我们看看使用 go install 消费其他人的命令行应用的样子。

go install

你可以使用 go install 命令安装 Go 命令行应用。这个命令是 Go 工具链提供的一个强大工具,它可以在你的工作空间的 bin 目录中编译和安装 Go 应用程序。这允许你从任何终端窗口全局运行你的应用程序。要安装一个 Go 应用程序,你只需导航到项目的根目录并运行 go install

此命令通过提供 GOOS 标志来考虑跨平台编译,您可以使用该标志指定要针对哪个操作系统,以及 GOARCH 标志,您可以使用该标志指定要针对的底层架构。

一个常见的 Go 语言包示例,您可以使用它来生成 Go 语言的命令行界面,是 cobra 包。这也是一个工具,如果您想进一步深入开发您的编程技能,可以使用它来快速开发基于 Cobra 的应用程序。此包提供了一个使用 go install 命令的简单示例:

go install github.com/spf13/cobra-cli@latest

前面的命令安装了所有使用 Cobra CLI 所需的依赖项。因此,我的机器知道了这个工具,您现在可以轻松地使用您刚刚安装的命令行程序,如下所示:

cobra-cli –help

Cobra 是一个用于 Go 语言的 CLI 库,它赋予了应用程序强大的功能。

它可以生成必要的文件,以快速创建 Cobra 应用程序:

Usage:
 cobra-cli [command]
Available Commands:
 add Add a command to a Cobra Application
completion Generate the autocompletion script for the specified shell
help Help about any command
init Initialize a Cobra Application
Flags:
-a, --author string author name for copyright attribution (default "YOUR NAME")
--config string config file (default is $HOME/.cobra.yaml)
-h, --help help for cobra-cli
-l, --license string name of license for the project
--viper use Viper for configuration
Use "cobra-cli [command] --help" for more information about a command.

有了这些,您就知道了如何安装他人的命令行应用程序。现在,让我们总结一下本章所学的内容。

摘要

在本章中,我们研究了通过命令行进行编程的各种方法。我们揭示了 Go 语言如何成为创建命令行应用程序的绝佳选择,以及如何使用原生 Go 工具链。

osflag 包开始,我们探讨了如何从命令行读取应用程序的参数。然后,我们研究了用于控制程序行为的标志,并探讨了如何通过在应用程序中流式传输大量数据来阐明 Go 语言中的命令行应用程序如何成为更大程序管道的一部分。

我们还探讨了如何优雅地处理 CLI 关闭过程,包括讨论退出码和中断,以及在我们的命令行应用程序中调用其他命令。我们通过查看终端 UI,将 CLI 提升到下一个层次,并使用原生 Go 工具链安装其他 CLI 来结束这一章。

在下一章中,我们将使用 Go 语言查看文件和系统。虽然我们在本章中已经涉及了这一点,以便为我们的 CLI 应用程序读取输入数据,但我们将在下一章深入探讨读取和写入文件。

第十四章:文件和系统

概述

在本章中,我们将看到如何与文件系统交互,这意味着我们将读取文件、操作它们、为以后使用存储它们,并获取有关它们的信息。我们还将介绍如何读取文件夹,以便我们可以搜索所需的文件,并检查一些特定的文件格式,例如 CSV,它通常用于以表格形式共享信息。

本章你还将学习如何以标志的形式将一些信息发送到你的应用程序中。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter14

简介

在上一章中,我们探讨了如何编写简单的命令行应用程序。在这里,我们将继续这一主题,介绍向应用程序传递参数的方法,以便它根据我们发送的值以不同的方式表现。

之后,我们将与文件系统交互。我们将要处理的文件系统级别是文件、目录和权限级别。我们将解决开发人员在处理文件系统时面临的日常问题。

我们将学习如何创建一个命令行应用程序,该程序可以读取和写入文件。除了讨论从操作系统接收到信号中断时会发生什么之外,我们还将演示在应用程序停止运行之前执行清理操作的方法。我们还将处理应用程序接收到中断的情况,并处理应用程序退出的方式。有时,当你的应用程序正在运行时,操作系统会发送一个信号来关闭应用程序。

在这种情况下,我们可能希望在关闭时记录信息以进行调试;这将帮助我们了解应用程序为何关闭。在本章中,我们将探讨如何做到这一点。然而,在我们开始处理这些问题之前,让我们先对文件系统有一个基本的了解。

文件系统

文件系统控制数据在硬盘、USB、DVD 或其他介质上的命名、存储、访问和检索方式。没有统一的文件系统,其行为在很大程度上取决于你使用的操作系统。你一定听说过FATFAT32NFTS等等,这些都是不同的文件系统,通常在 Windows 中使用。Linux 可以读写这些文件系统,但通常使用以ext开头的不同文件系统家族,ext代表扩展。你不需要对文件系统有深入的了解,但作为一个软件工程师,至少对这一主题有一个基本了解是好的。

然而,在本章中,我们感兴趣的是每个文件系统都有自己的文件命名约定,例如文件名的长度、可以使用的特定字符、后缀或文件扩展名的长度等。每个文件都有信息或元数据,这些信息或元数据是嵌入在文件中或与文件关联的,用于描述或提供有关文件的信息。关于文件的这个元数据可以包含诸如文件大小、位置、访问权限、创建日期、修改日期等信息。这是我们应用程序可以访问的所有信息。

文件通常被放置在某种层次结构中。这种结构通常由多个目录和子目录组成。文件在目录中的放置是一种组织数据并获得对文件或目录访问的方式:

图 14.1 – Linux 文件系统

图 14.1 – Linux 文件系统

图 14.1 所示,目录可以嵌套。在正常的 Linux 文件系统中,我们将看到有一个根目录,它由名称 / 定义,而其他所有内容都是它的子目录。目录通常包含系统每个用户的文件,在上面的示例中,matt 是一个包含 docsmp3 目录的目录,它们是 matt 的子目录,但 matt 本身是 home 的子目录。

在下一个主题中,我们将探讨文件权限。

文件权限

在处理文件创建和修改时,权限是一个重要的方面,你需要理解。

我们需要查看可以分配给文件的各个权限类型。我们还需要考虑这些权限类型在符号和八进制表示法中的表示方式。

Go 使用 Unix 命名法来表示权限类型。它们以符号表示法或八进制表示法表示。三种权限类型是 读取写入执行

图 14.2 – 文件权限

图 14.2 – 文件权限

每个文件的权限都分配给了三个不同的实体,这些实体可以是个人或组。这意味着一个用户可以是某个有权访问某些文件的组的成员,因此用户继承了这些文件的访问权限。无法将文件权限分配给特定用户;相反,我们将用户添加到组中,然后为该组分配权限。话虽如此,将文件权限分配给以下内容是可能的:

  • 所有者:这是一个个人,如约翰·史密斯这样的单个个人,或者是文件的所有者 root 用户。一般来说,这是创建文件的个人。

  • :组通常由多个个人或其他组组成。

  • 其他用户:那些不在组中或不是所有者的用户。

现在我们来看看,如何通过符号表示法来表示权限。以下图表是一个文件及其在 Unix 机器上权限的示例:

图 14.3 – 权限表示

图 14.3 – 权限表示

上图中的第一个破折号(-)表示该实体是一个文件。如果它是一个目录,它将是字符d

指定权限的另一种方式是八进制表示法,它用一个数字表示多种权限类型。例如,如果你想使用符号表示法来指示读和写权限,它将是rw-。如果要用八进制数表示,它将是6,因为4表示读权限,2表示写权限。完全权限将是7,意味着4+2+1read+write+executerwx)。

以下是对权限及其解释的总结:

图 14.4 – 组和权限示例

图 14.4 – 组和权限示例

如您所见,每个权限都可以用一个<=7的数字表示,这是一个一位数。所有者、组和其他人的权限可以用三位八进制数表示,如下所示:

图 14.5 – 权限表示示例

图 14.5 – 权限表示示例

你可能会注意到,在八进制表示法中,所有数字都以一个0开头。当你通过命令行与文件系统交互时,你可以省略前导零。然而,在许多情况下,当你编程时,你需要传递它,以便编译器理解你正在使用八进制表示法。你可能会争辩说0777777是相同的数字,但前导零只是一个约定,告诉编译器你正在使用八进制表示法,数字是八进制而不是十进制。换句话说,777被解释为十进制数777,而0777被解释为八进制数0777,这是十进制数511

标志和参数

Go 提供了创建命令行界面工具的支持。通常,当我们编写可执行的 Go 程序时,它们需要接受各种输入。这些输入可能包括文件位置、以调试状态运行程序的价值、获取运行程序的帮助等等。所有这些都可以通过 Go 标准库中的一个名为flag的包来实现。它用于允许将参数传递给程序。标志是传递给 Go 程序的参数。使用flag包传递给 Go 程序的标志顺序对 Go 来说并不重要。

要定义你的flag,你必须知道你将接受的flag类型。flag包提供了许多用于定义标志的函数。以下是一个示例列表:

func Bool(name string, value bool, usage string) *bool
func Duration(name string, value time.Duration, usage string) *time.Duration
func Float64(name string, value float64, usage string) *float64
func Int(name string, value int, usage string) *int
func Int64(name string, value int64, usage string) *int64

这些是一些允许你创建标志并接受参数的函数,Go 中的每个默认类型都有一个。

上述函数的参数可以这样解释:

  • 名称:此参数是标志的名称;它是一个字符串类型。例如,如果您传递 file 作为参数,您将使用以下方式从命令行访问该标志:

    ./app -file
    
  • :此参数是标志设置的默认值。

  • 用法:此参数用于描述标志的用途。当您错误地设置值时,它通常会出现在命令行上。传递错误的标志类型将停止程序并导致错误;将打印用法。

  • 返回值:这是存储标志值的变量的地址。

让我们看看一个简单的例子:

package main
import (
    "flag"
    "fmt"
)
func main() {
    v := flag.Int("value", -1, "Needs a value for the flag.")
    flag.Parse()
    fmt.Println(*v)
}

让我们回顾一下前面的代码块并分析它:

  1. 首先,我们定义 main 包。

  2. 然后我们导入 flagfmt 包。

  3. v 变量将引用 -value--value 的值。

  4. 在调用 flag.Parse() 之前,*v 的初始值是 -1 的默认值。

  5. 在定义标志之后,您必须调用 flag.Parse() 将定义的标志解析到命令行中。

  6. 调用 flag.Parse()-value 参数的值放入 *v

  7. 一旦您调用了 flag.Parse() 函数,标志将可用。

  8. 在命令行上,执行以下命令,您将在同一目录中获取可执行文件:

    go build -o flagapp main.go
    

要在 Windows 上获取可执行文件,请运行:

go build -o flagapp.exe main.go

然而,还有另一种定义这些标志的方法。可以使用以下函数来完成:

func BoolVar(p *bool, name string, value bool, usage string)
func DurationVar(p *time.Duration, name string, value time.Duration, usage string)
func Float64Var(p *float64, name string, value float64, usage string)
func Int64Var(p *int64, name string, value int64, usage string)
func IntVar(p *int, name string, value int, usage string)

如您所见,对于每种类型,都有一个类似于我们之前看到的函数,其名称以 Var 结尾。它们都接受一个指向标志类型的指针作为第一个参数,并且可以像以下代码片段中那样使用:

package main
import (
    "flag"
    "fmt"
)
func main() {
    var v int
    flag.IntVar(&v, "value", -1, "Needs a value for the flag.")
    flag.Parse()
    fmt.Println(v)
}

此代码与前面的代码片段做的是相同的事情,但是这里有一个简短的分解:

  • 首先,我们定义一个整数变量 v

  • 将其引用作为 IntVar 函数的第一个参数

  • 解析标志

  • 打印 v 变量,现在不需要解引用,因为它不是标志而是一个实际的整数

如果我们使用任何前面的代码片段将我们的应用程序编译为名为 flagapp 的可执行文件,并在可执行文件相同的目录中使用以下调用,我们会看到它会打印数字 5

flagapp -value=5

如果我们在可执行文件相同的目录中使用以下调用而不带参数调用它,我们会看到它只会打印 -1

flagapp

这是因为 -1 是默认值。

信号

信号是操作系统发送到我们的程序或进程的中断。当信号被发送到我们的程序时,程序将停止正在执行的操作;要么处理信号,要么如果可能的话忽略它。

以下是最常用于 Go 程序的前三个中断信号列表:

  • SIGINT(中断):

    • 情况:此信号通常在用户在终端中按下 Ctrl + C 以中断程序的执行时使用。

    • 定义:SIGINT 是中断信号。它用于优雅地终止程序并在退出之前执行清理操作。

  • SIGTERM(终止):

    • 情况:这个信号通常用于以受控方式请求程序终止。它是一个用于终止进程的通用信号。

    • 定义:SIGTERM 是终止信号。它允许程序在退出之前执行清理操作,类似于 SIGINT,但它可以被捕获并不同方式处理。

  • SIGKILL(终止):

    • 情况:这个信号用于强制终止程序。它不允许程序执行任何清理操作。

    • 定义:SIGKILL 是终止信号。它立即终止进程,不给它清理资源的机会。与 SIGTERM 相比,这是一种更加强力的结束程序的方式。

我们已经看到了其他改变程序流程的 Go 命令;你可能想知道应该使用哪一个。

我们在我们的应用程序中使用defer语句来执行各种清理活动,如下所示:

  • 释放资源

  • 关闭文件

  • 关闭数据库连接

  • 执行移除配置或临时文件的操作

在某些用例中,完成这些活动非常重要。使用defer函数将在返回调用者之前执行它。然而,这并不能保证它总是会运行。在某些场景中,defer函数不会执行;例如,操作系统对程序的干扰:

  • os.Exit(1)

  • Ctrl + C

  • 来自操作系统的其他指令

前面的场景表明了可能需要使用信号的情况。信号可以帮助我们控制程序的退出。根据信号,它可能会终止我们的程序。例如,应用程序正在运行,并在执行employee.CalculateSalary()后遇到操作系统中断信号。在这种情况下,defer函数将不会运行,因此,employee.DepositCheck()不会执行,员工没有得到工资。信号可以改变程序的流程。以下图表概述了我们之前讨论的场景:

图 14.6 – 带有信号的示例程序

图 14.6 – 带有信号的示例程序

处理信号的支持内置在 Go 标准库中;它在os/signal包中。这个包将使我们能够使我们的程序更具弹性。我们希望在接收到某些信号时优雅地关闭。在 Go 中处理信号的第一件事是捕获或拦截你感兴趣的信号。这是通过使用以下函数来完成的:

func Notify(c chan<- os.Signal, sig ...os.Signal)

这个函数接受一个os.Signal数据类型在通道c上,sig参数是一个os.Signal的可变变量;我们指定零个或多个我们感兴趣的os.Signal数据类型。让我们看看一个代码片段,展示我们如何使用这个函数来停止应用程序的执行:

package main
import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)
func main() {
    sigs := make(chan os.Signal, 1)
    done := make(chan struct{})
    signal.Notify(sigs,syscall.SIGINT)
    go func() {
    for {
        s := <-sigs
        switch s {
            case syscall.SIGINT:
                fmt.Println()
                fmt.Println("My process has been interrupted. Someone might of pressed CTRL-C")
                fmt.Println("Some clean up is occuring")
                done <-struct{}{}
            }
        }
    }()
    fmt.Println("Program is blocked until a signal is caught")
    done <- struct{}{}
    fmt.Println("Out of here")
}

在定义包和导入包之后,我们进行以下操作:

  • 定义一个用于发送信号的通道。

  • 定义一个我们可以用作停止执行的标志的通道。

  • 使用 Notify 发送 SIGINT 信号。

  • 创建一个无限监听信号的协程,如果信号是 SIGINT,则进行一些打印输出,并向 done 通道发送带有 true 值的消息。

  • 打印一条消息,说明我们正在等待接收 done 消息。

  • 等待 done 消息。

  • 打印最终的消息。

当我们运行应用程序时,实际上我们会看到应用程序很快就会终止,因为我们手动发送了 SIGINT 信号。在现实世界的场景中,应用程序会等待 SIGKILL 信号,我们可以通过手动发送 Ctrl + X 来发送这个信号。

现在我们来看看如何模拟清理操作。

练习 14.01 – 模拟清理操作。

在这个练习中,我们将捕获两个信号:SIGINTSIGTSTP。一旦捕获到这些信号,我们将模拟文件清理。我们还没有讲解如何删除文件,所以在这个例子中,我们将简单地创建一个延迟来演示我们如何在捕获到信号后运行一个函数。这是这个练习期望的输出:

  1. 创建一个名为 main.go 的文件。

  2. 向这个文件添加 main 包和以下 import 语句:

    package main
    import (
        "fmt"
        "os"
        "os/signal"
        "syscall"
        "time"
    )
    
  3. main() 函数中,创建一个 os.Signal 类型的通道。sigs 通道用于接收来自 Notify 方法的这些通知:

    func main() {
        sigs := make(chan os.Signal, 1)
    
  4. 接下来,添加一个 done 通道。done 通道用于通知我们程序何时可以退出:

        done := make(chan struct{})
    
  5. 然后我们将添加一个 signal.Notify 方法。Notify 方法通过向通道发送 os.Signal 类型的值来工作。

  6. 回想一下,signal.Notify 方法的最后一个参数是 os.Signal 类型的可变参数。

  7. signal.Notify 方法将在 sigs 通道上接收 syscall.SIGINTsyscall.SIGTSTP 类型的通知。

  8. 通常情况下,syscall.SIGINT 类型会在你按下 Ctrl + C 时发生。

  9. 通常情况下,syscall.SIGTSTP 类型会在你按下 Ctrl + Z 时发生:

        signal.Notify(sigs, syscall.SIGINT, syscall.SIGTSTP)
    
  10. 创建一个匿名函数作为协程:

        go func() {
    
  11. 在协程内部,创建一个无限循环。在无限循环内部,我们将从 sigs 通道接收一个值并将其存储在 s 变量中,s := <-sigs

        for {
          s := <-sigs
    
  12. 创建一个 switch 语句来评估从通道接收到的内容。

  13. 我们将有两个情况语句来检查 syscall.SIGINTsyscall.SIGTSP 类型。

    每个情况语句都会打印一条消息。

  14. 我们还将调用我们的 cleanup() 函数。

  15. 情况语句中的最后一个语句是向 done 通道发送 true 以停止阻塞:

          switch s {
          case syscall.SIGINT:
            fmt.Println()
            fmt.Println("My process has been interrupted. Someone might have pressed CTRL-C")
            fmt.Println("Some clean up is occuring")
            cleanUp()
            done <- struct{}{}
          case syscall.SIGTSTP:
            fmt.Println()
            fmt.Println("Someone pressed CTRL-Z")
            fmt.Println("Some clean up is occuring")
            cleanUp()
            done <- struct{}{}
          }
        }
      }()
      fmt.Println("Program is blocked until a signal is caught(ctrl-z, ctrl-c)")
      done <- struct{}{}
      fmt.Println("Out of here")
    }
    
  16. 创建一个简单的函数来模拟执行清理操作的过程:

    func cleanUp() {
      fmt.Println("Simulating clean up")
      for i := 0; i <= 10; i++ {
        fmt.Println("Deleting Files.. Not really.", i)
        time.Sleep(1 * time.Second)
      }
    }
    
  17. 你可以尝试运行这个程序,然后按下 Ctrl + ZCtrl + C 来检查程序的不同结果。这仅在 Linux 和 macOS 上有效:

  18. 现在运行代码:

    go run main.go
    
  19. 以下是输出结果:

图 14.7 – 示例输出

图 14.7 – 示例输出

在这个练习中,我们展示了拦截中断并在应用程序关闭前执行任务的 ability。我们有控制退出 ability。这是一个强大的功能,允许我们执行清理操作,包括删除文件、进行最后的日志记录、释放内存等。在下一个主题中,我们将创建并写入文件。我们将使用来自 Go 标准包的os函数。

创建并写入文件

Go 语言以各种方式提供支持来创建和写入新文件。我们将检查一些最常见的方法,这些方法是如何执行的。

os包提供了一种简单的方式来创建文件。对于那些熟悉 Unix 世界中的touch命令的人来说,它与此类似。以下是该函数的签名:

func Create(name string(*File, error)

该函数将创建一个空文件,就像touch命令一样。重要的是要注意,如果文件已经存在,它将截断文件。

os包的Create函数有一个输入参数,即要创建的文件名及其位置。如果成功,它将返回一个File类型。值得注意的是,File类型满足io.Writeio.Read接口。这对于本章后面的内容很重要:

package main
import (
    "os"
)
func main() {
    f, err := os.Create("test.txt")
    if err != nil {
        panic(err)
    }
    defer f.Close()
}

上述代码只是定义了导入,然后在main函数中尝试创建一个名为test.txt的文件。如果因此出现错误,它将引发恐慌。在括号关闭前的最后一行确保,无论应用程序是成功终止还是恐慌,文件都将被关闭。我们想确保我们永远不会保持文件处于打开状态。

创建一个空文件很简单,但让我们继续使用os.Create并写入我们刚刚创建的文件。回想一下,os.Create返回一个*os.File类型。有两个有趣的方法可以用来写入文件:

  • Write

  • WriteString

让我们看看一些如何使用它们的例子:

package main
import (
    "os"
)
func main() {
    f, err := os.Create("test.txt")
    if err != nil {
        panic(err)
    }
    defer f.Close()
    f.Write([]byte("Using Write function.\n"))
    f.WriteString("Using Writestring function.\n")
}

这段代码与之前的代码非常相似。我们只是添加了两行,将两句话写入文件。

第一次函数调用如下:

f.Write([]byte("Using Write function.\n"))

在这里,我们可以看到该函数需要发送字节,因此我们使用以下方式将字符串转换为字节数组:

[]byte("Using Write function.\n")

第二个函数仅接受一个字符串,并且使用起来很简单。

然而,我们可以使用该包直接写入文件,而无需先打开它。我们可以使用os.WriteFile函数来完成这个操作:

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

该方法将数据写入由filename参数指定的文件,并使用给定的权限。如果存在错误,它将返回一个错误。让我们看看它是如何工作的:

package main
import (
    "fmt"
    "os
)
func main() {
    message := []byte("Look!")
    err := os.WriteFile("test.txt", message, 0644)
    if err != nil {
        fmt.Println(err)
    }
}

如我们所见,我们可以在一行中创建一个文件,发送一个转换为字节切片的字符串,并为其分配权限。同时,我们也需要发送权限级别,并注意我们需要使用带前导零的八进制表示法(这是因为如果没有前导零,权限将不会按预期工作)。

我们之前还没有看到的一个重要问题是,如何检查文件是否存在。这很重要,因为如果文件确实存在,我们可能不想截断它并用新内容覆盖它。让我们看看我们如何做到这一点:

package main
import (
    "fmt"
    "s"
    "flag"
)
func main() {
    var name tring
    flag.StringVar(&name, "name", "", "File name")
    flag.Parse()
    file, err := os.Stat(name)
    if err != nil {
        if os.IsNotExist(err) {
            fmt.Printf("%s: File does not exist!\n", name)
            fmt.Println(file)
            return
        }
        fmt.Println(err)
        return
      }
    fmt.Printf("file name: %s\nIsDir: %t\nModTime: %v\nMode: %v\nSize: %d\n", file.Name(),
    file.IsDir(), file.ModTime(), file.Mode(), file.Size())
}

让我们回顾一下前面的代码做了什么:

  1. 首先,我们导入所有需要的包。

  2. 然后我们定义一个表示文件名的字符串标志:

    flag.StringVar(&name, "name", "", "File name")
    
  3. 接下来,我们解析标志;在这种情况下,只有一个是我们创建的。

  4. 然后,我们获取文件的状态信息:

    file, err := os.Stat(name)
    
  5. 如果出现错误,我们检查这是否是因为文件不存在:

    if os.IsNotExist(err) {
    
  6. 如果文件不存在,我们打印一条消息,然后终止应用程序。

  7. 如果错误与 IsNotExist 不同,我们就打印错误信息。

  8. 如果最终文件存在,我们就打印与它相关的一系列信息。该文件实现了 FileInfo 接口,其中包含修改时间、大小、八进制权限(mode)、名称以及它是否是目录。

你可以尝试运行这个应用程序并传递任何文件的名称。如果它存在于你运行应用程序的目录中,你将看到所有这些信息被打印出来。

现在我们来看看如何读取整个文件。

一次性读取整个文件

在这个主题中,我们将探讨两种读取文件所有内容的方法。这两个函数在文件大小较小时使用起来很好。虽然这两个方法方便且易于使用,但它们有一个主要的缺点。那就是,如果文件太大,可能会耗尽系统上的内存。这一点很重要,我们需要记住,并理解我们将在这个主题中讨论的两个方法的限制。尽管这些方法是一些最快和最简单加载数据的方法,但重要的是要理解它们应该仅限于小文件,而不是大文件。该方法的签名如下:

func ReadFile(filename string) ([]byte, error)

ReadFile 函数读取文件内容,并以字节切片的形式返回,同时报告任何错误。当使用 ReadFile 方法时,我们将查看错误返回值:

  • 成功调用返回 err == nil

  • 在文件的其他一些读取方法中,文件结束EOF)被视为错误。对于将整个文件读入内存的函数来说,情况并非如此。

让我们看看一个代码片段,解释如何使用这个函数:

package main
import (
    "fmt"
    "os"
)
func main() {
    content, err := os.ReadFile("test.txt")
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println("File contents: ")
    fmt.Println(string(content))
}

如我们所见,我们在代码中执行的操作如下:

  • 我们执行导入操作

  • 我们读取整个 test.txt 文件的内容

  • 如果发生错误,我们打印错误信息

  • 否则,我们打印文件的内容:

      fmt.Println("File contents: ")
      fmt.Println(string(content))
    

由于内容是以字节数组的形式检索的,我们需要将其转换为字符串以可视化它。让我们看看如何在下一个片段中逐字符读取文件:

package main
import (
    "fmt"
    "io"
    "log"
    "os"
)
func main() {
    f, err := os.Open("test.txt")
    if err != nil {
        log.Fatalf("unable to read file: %v", err)
    }
    buf := make([]byte, 1)
    for {
        n, err := f.Read(buf)
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println(err)
            continue
        }
    if n > 0 {
            fmt.Print(string(buf[:n]))
    }
    }
}

让我们更详细地分析这个片段,因为它有点复杂。在这种情况下,在导入所需的包之后,我们执行以下操作:

  1. 使用Open函数打开文件:

    f, err := os.Open("test.txt")
    
  2. 我们检查错误是否为nil,如果不是,我们打印错误并退出:

    if err != nil {
        log.Fatalf("unable to read file: %v", err)
    }
    
  3. 然后我们创建一个大小为1的字节数组切片:

      buf := make([]byte, 1)
    
  4. 然后我们创建一个无限循环,并在其中读取文件到缓冲区:

    n, err := f.Read(buf)
    
  5. 然后我们检查是否有错误,这也意味着我们到达了文件末尾,在这种情况下我们停止循环:

    if err == io.EOF {
        break
    }
    
  6. 如果错误不是nil但也不是文件结束,我们将继续循环,忽略错误。

  7. 如果没有错误并且已经读取了内容,那么我们显示内容:

    if n > 0 {
      fmt.Print(string(buf[:n]))
    }
    

注意我们一次读取一个字符,因为我们创建了一个大小为 1 的缓冲区(字节数组)。这可能会很耗费资源,所以你可能需要根据你特定的案例和需求更改这个值。

练习 14.02 – 备份文件

通常,当我们处理文件时,在对其做出更改之前需要备份文件。这是在可能犯错或需要原始文件进行审计目的的情况下。在这个练习中,我们将取一个名为note.txt的现有文件,并将其备份到backupFile.txt。然后我们将打开note.txt并在文件的末尾添加一些额外的注释。我们的目录将包含以下文件:

图 14.8 – 将文件备份到目录

图 14.8 – 将文件备份到目录

  1. 我们必须首先在可执行文件相同的目录下创建note.txt文件。此文件可以是空的,也可以包含一些示例数据,如下所示:

图 14.9 – notes.txt 文件内容的示例

图 14.9 – notes.txt 文件内容的示例

  1. 创建一个名为main.go的 Go 文件。

  2. 这个程序将是main包的一部分。

  3. 包含如以下代码所示的导入:

    package main
    import (
        "errors"
        "fmt"
        "io"
        "os"
        "strconv"
    )
    
  4. 创建一个自定义错误,当工作文件(note.txt)未找到时使用:

    var (
        ErrWorkingFileNotFound = errors.New("The working file is not found.")
    )
    
  5. 创建一个执行备份的函数。这个函数负责将工作文件的内容存储在backup文件中。这个函数接受两个参数。working参数是你当前正在工作的文件的文件路径:

    func createBackup(working, backup string) error {
    }
    
  6. 在这个函数内部,我们需要检查工作文件是否存在。在我们能够读取其内容并将其存储在我们的备份文件之前,它必须首先存在。

    我们能够通过使用os.IsNotExist(err)来检查错误是否是文件不存在的情况。

    如果文件不存在,我们将返回我们的自定义错误,ErrWorkingFileNotFound

        // check to see if our working file exists,
        // before backing it up
        _, err := os.Stat(working)
        if err != nil {
        if os.IsNotExist(err) {
            return ErrWorkingFileNotFound
        }
        return err
      }
    
  7. 接下来,我们需要打开工作文件并将函数返回的os.File存储到workFile变量中:

        workFile, err := os.Open(working)
        if err != nil {
            return err
        }
    
  8. 我们需要读取workFile的内容。我们将使用io.ReadAll方法来获取workFile的所有内容。workFileos.File类型,它满足io.Reader接口;这允许我们将其传递给ioutil.ReadFile

  9. 检查是否有错误:

      content, err := io.ReadAll(workFile)
      if err != nil {
        return err
      }
    
  10. content变量包含以字节切片形式表示的workFile数据。这些数据需要写入备份文件。我们将实现将content变量的数据写入备份文件的代码。

  11. content变量存储从函数返回的[]byte数据。这是存储在变量中的整个文件内容。

  12. 我们可以使用os.Writefile方法。如果备份文件不存在,它将创建文件。如果备份文件已存在,它将使用内容变量data覆盖文件:

      err = os.WriteFile(backup, content, 0644)
      if err != nil {
          fmt.Println(err)
      }
    
  13. 我们需要返回nil,表示在这个阶段,我们没有遇到任何错误:

      return nil
    }
    
  14. 创建一个函数,将数据附加到我们的工作文件。

  15. 命名函数addNotes;这个函数将接受我们工作文件的地址和一个将被附加到工作文件的字符串参数。该函数需要返回一个错误:

    func addNotes(workingFile, notes string) error {
    //…
      return nil
    }
    
  16. addNotes函数内部,添加一行代码,将新行附加到每个笔记的字符串上。这将使每个笔记单独占一行:

    func addNotes(workingFile, notes string) error {
      notes += "\n"
      //…
      return nil
    }
    
  17. 接下来,我们将打开工作文件,并允许向文件追加内容。os.OpenFile()函数将在文件不存在时创建文件。检查是否有任何错误:

    func addNotes(workingFile, notes string) error {
      notes += "\n"
      f, err := os.OpenFile(
        workingFile,
        os.O_APPEND|os.O_CREATE|os.O_WRONLY,
        0644,
      )
      if err != nil {
        return err
      }
      // …
      return nil
    }
    
  18. 在打开文件并检查错误后,我们应该确保使用defer函数f.Close()在函数退出时关闭文件:

    func addNotes(workingFile, notes string) error {
      notes += "\n"
      f, err := os.OpenFile(
        workingFile,
        os.O_APPEND|os.O_CREATE|os.O_WRONLY,
        0644,
      )
      if err != nil {
         return err
      }
      defer f.Close()
    //…
      return nil
    }
    
  19. 函数的最终步骤是将笔记的内容写入workingFile变量。我们可以使用Write方法来完成这个任务:

    func addNotes(workingFile, notes string) error {
      notes += "\n"
      f, err := os.OpenFile(workingFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
      if err != nil {
          return err
      }
      defer f.Close()
      if _, err := f.Write([]byte(notes)); err != nil {
        return err
      }
      return nil
    }
    
  20. main()函数中,我们将初始化三个变量;backupFile变量包含备份我们的workingFile变量的文件名,而data变量是我们将要写入workingFile变量的内容:

    func main() {
        backupFile := "backupFile.txt"
        workingFile := "note.txt"
        data := "note"
    
  21. 调用我们的createBackup()函数来备份我们的workingFile。在调用函数后检查错误:

        err := createBackup(workingFile, backupFile)
        if err != nil {
            fmt.Println(err)
        os.Exit(1)
        }
    
  22. 创建一个循环,该循环将迭代10次。

    在每次迭代中,我们将我们的note变量设置为data变量加上循环的i变量。

    由于我们的note变量是字符串,而i变量是int类型,我们需要使用strconv.Itoa(i)方法将i转换为字符串。

    调用我们的addNotes()函数,并传递workingFile和我们的note变量。

    检查函数返回的任何错误:

        for i := 1; i <= 10; i++ {
        note := data + " " + strconv.Itoa(i)
        err := addNotes(workingFile, note)
        if err != nil {
            fmt.Println(err)
            os.Exit(1)
            }
        }
    }
    
  23. 运行程序:

    go run main.go
    
  24. 在运行程序后评估文件的变化。

以下是运行程序后的结果:

图 14.10 – 备份结果

图 14.10 – 备份结果

让我们看看如何使用 Go 处理 CSV 文件。

CSV

文件结构中最常见的方式之一是逗号分隔值。这是一个包含数据的纯文本文件,基本上以行和列的形式表示。这些文件通常用于交换数据。CSV 文件具有简单的结构。每条数据由逗号分隔,然后是新的一行以表示另一个记录。以下是一个 CSV 文件的示例:

firstName, lastName, age
Celina, Jones, 18
Cailyn, Henderson, 13
Cayden, Smith, 42

在你的一生中,你可能会遇到 CSV 文件,因为它们非常常见。Go 编程语言有一个用于处理 CSV 文件的标准库:encoding/csv

package main
import (
    "encoding/csv"
    "fmt"
    "io"
    "log"
    "strings"
)
func main() {
    in := `firstName, lastName, age
Celina, Jones, 18
Cailyn, Henderson, 13
Cayden, Smith, 42
`
    r := csv.NewReader(strings.NewReader(in))
    for {
        record, err := r.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println(record)
    }
}

在这里,我们定义了一个包含我们 CSV 文件内容的字符串:

func main() {
    in := `firstName, lastName, age
Celina, Jones, 18
Cailyn, Henderson, 13
Cayden, Smith, 42`

然后我们使用以下行来读取整个 CSV 的内容:

  r := csv.NewReader(strings.NewReader(in))

以下代码行创建了一个字符串读取器,它可以由csv.NewReader函数使用。实际上,我们不能直接将字符串传递给 CSV 读取器,因为它需要一个io.Reader实例,在这种情况下由strings.NewReader提供:

strings.NewReader(in)

然后,我们创建一个无限循环,当达到 CSV 的末尾时终止:

if err == io.EOF {
   break
}

正如我们在本章前面所做的那样,我们接着检查另一个错误,如果找到错误则退出;否则,我们打印记录,该记录是通过 CSV 读取器的Read()方法检索到的。

在前面的例子中,我们看到了如何一次性获取整个记录,即我们的 CSV 中的一行。然而,有一种方法可以访问返回行中的每一列,即每一行的单个元素。

如果你回顾一下之前的代码片段,你会看到行是以以下方式返回的:

 record, err := r.Read()

然后,我们只是打印了内容,但这实际上是一个字符串切片,因此我们可以通过索引获取每个项目。假设我们只对可视化 CSV 中的人名感兴趣。为此,我们可以修改fmt.Println(record)行如下:

  fmt.Println(record[0])

使用这个库,我们只会看到名字列表。

嵌入

通常,你需要向用户展示一些复杂的文本,可能是一个 HTML 页面,将整个文件定义为字符串可能不太实际。你可能像在本章中学到的那样读取文件,然后将其用作模板。你可能还想显示一张图片,同样是通过打开和读取包含图片的文件。Go 的一个伟大特性是,即使你可以将你的应用程序构建为一个单一的二进制文件,你也将有需要与你的二进制文件一起分发的外部依赖。另一个问题是,从文件中读取可能很慢,所以如果我们可以将文件嵌入到我们的 Go 应用程序中,那就太好了。这将允许我们只分发一个包含所有资源的二进制文件。在过去,这需要外部库,但现在 Go 包含一个名为embed的包,它允许你轻松地将任何文件嵌入到你的二进制文件中,这样你就不需要共享其他依赖。让我们看看如何做到这一点。

在下一个片段中,我们将创建一个非常简单的模板文件,并读取和解析它。然后我们将使用它来显示一些问候语。让我们从模板开始。我们需要一个如下的文件夹结构:embedding_example/main.gotemplates/template.txt

template.txt文件的内容是Hello {{.Name}},这很简单。这仅仅意味着当我们使用这个模板并传递一个名为Name的变量时,引擎将用我们传递的任何值来替换这个变量。在这个阶段,你不需要对模板系统有更多的了解。

现在我们来看看如何利用这个在外部文件编写的模板,而无需每次运行应用程序时都读取它:

package main
import (
    "embed"
    "os"
    "text/template"
)
type Person struct {
    Name string
}
var (
    //go:embed templates
    f embed.FS
)
func main() {
    p := Person{"John"}
    tmpl, err := template.ParseFS(f, "templates/template.txt")
    if err != nil {
        panic(err)
    }
    err = tmpl.Execute(os.Stdout, p)
    if err != nil {
        panic(err)
    }
}
  1. 我们开始导入所有必要的包。之后,我们定义一个名为Person的结构体,它将保存要问候的人的名字。接下来的部分是重要的部分:

    var (
        //go:embed templates
        f embed.FS
    )
    

这定义了一个类型为embed.FSf变量,代表嵌入文件系统,将为我们作为一个虚拟文件系统工作。声明顶部的指令需要正好位于我们定义的变量之上,否则编译器会提示我们错误。这个指令告诉 Go 编译器它需要读取并嵌入templates文件夹中的内容,并使其可用。如果你添加一个包含太多大文件的文件夹,请注意,你的最终二进制文件的大小将会增加。

  1. main函数内部,我们随后实例化了一个类型为Person的结构体,其中Name属性具有值John

  2. 之后,我们使用template包的ParseFS函数,我们用它从嵌入的文件系统(变量f表示)中读取templates文件夹内的template.txt文件。

  3. 接下来,我们只需执行模板引擎,传递之前创建的结构体。如果你运行应用程序,你会看到以下消息打印出来:

    Hello John
    
  4. 现在,这似乎并不多,但尝试运行以下命令:

    go build -o embtest main.go
    
  5. 然后,将你的可执行文件复制到另一个位置,那里没有template文件夹。如果你现在从这个新文件夹运行,你仍然会看到完全相同的信息:

    ./embtest
    

这里的重要收获是,该指令从指定的点开始获取整个文件系统,在本例中是templates文件夹,并创建一个虚拟文件系统。从这个虚拟文件系统中,你可以读取所有文件,但实际上整个文件夹的内容将实际存储在你的应用程序的最终二进制文件中。这个功能非常强大,但应该明智地使用,因为最终的二进制文件可能会轻易变得非常大。

摘要

在本章中,我们了解了 Go 如何看待和使用文件权限。我们了解到文件权限可以用符号和八进制表示。我们发现 Go 标准库内置了对打开、读取、写入、创建、删除和向文件追加数据的功能的支持。我们研究了flag包以及它如何提供创建命令行应用程序以接受参数的功能。

使用flag包,我们还可以打印出与我们的命令行应用程序相关的usage语句。

然后,我们演示了操作系统信号如何影响我们的 Go 程序;然而,通过使用 Go 标准库,我们可以捕获操作系统信号,并在适用的情况下控制我们希望如何退出我们的程序。

我们还了解到 Go 有一个用于处理 CSV 文件的标准库。在我们之前的工作中,我们看到了我们还可以处理结构化为 CSV 文件的文件。Go CSV 包提供了遍历文件内容的能力。CSV 文件可以看作是类似于数据库表的行和列。

最后,我们了解了如何在应用程序的最终二进制文件中嵌入文件,以及如何使用此功能来加快应用程序的速度并避免将外部依赖项与二进制文件一起分发。在下一章中,我们将探讨如何连接到数据库并执行针对数据库的 SQL 语句。这将展示 Go 在需要后端存储数据的应用程序中的使用能力。

第十五章:SQL 和数据库

概述

本章将介绍数据库——特别是关系数据库——以及如何通过 Go 编程语言访问它们。

本章将指导你如何连接到 SQL 数据库引擎,如何创建数据库,如何在数据库中创建表,以及如何在表中插入和检索数据。到本章结束时,你将能够更新和删除特定表中的数据,以及截断和删除表。

技术要求

对于本章,你需要 Go 版本 1.21 或更高。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter15找到。

简介

在上一章中,你学习了如何与你的 Go 应用程序运行的文件系统交互。你学习了退出代码的重要性以及如何自定义脚本以接受参数,从而增加应用程序的灵活性。你还学习了如何处理应用程序接收到的不同信号。

在本章中,你将通过学习如何在 Go 中使用 SQL 和数据库来进一步提高你的 Go 技能。作为一名开发者,没有对持久数据存储和数据库的适当理解是无法立足的。我们的应用程序处理输入并产生输出,但大多数情况下,如果不是所有情况,数据库都会涉及这个过程。这个数据库可以是内存中的(存储在计算机的 RAM 中),也可以是基于文件的(目录中的一个文件),它可以存在于本地或远程存储上。数据库引擎可以本地安装,就像我们将在本章后面做的那样,但也可以使用云提供商,这些提供商允许你将数据库作为一项服务使用;提供多种数据库引擎选项的云提供商包括 Azure、AWS 和 Google Cloud。

我们在本章中旨在让你能够流利地与这些数据库进行交流,并理解数据库的基本概念。最后,随着你通过本章的学习,你将扩展你的技能集,使你成为一个更好的 Go 开发者。

假设你的老板想要你创建一个可以与数据库通信的 Go 应用程序。这里的“通信”意味着任何 INSERTUPDATEDELETECREATE 事务都可以并由应用程序处理。本章将展示如何做到这一点。

理解数据库

我们通常以不同的方式使用“数据库”这个词,但让我们在这里更加正式一些:

数据库是我们存储数据的地方,是我们持久化数据的地方(如果我们想的话),我们还可以在其中运行一些查询来插入新数据,检索或修改现有数据。

您可能会认为文件系统符合这个描述,但实际上并非如此;真正的数据库允许我们根据非常具体的条件执行非常复杂和精确的查询来收集数据。为此,我们将有一个用于执行这些查询或其他操作的语言。在我们的案例中,我们将专注于一种称为 SQL 的语言。

我们已经说明了什么是数据库,但这仍然相当抽象。为了创建数据库并填充数据,我们需要一个引擎——本质上是一个应用程序——它将允许我们执行所有这些操作。在本节中,我们将学习如何使用名为 PostgreSQL 的数据库引擎。正如其名称所暗示的,这个引擎将允许我们使用 SQL 语言执行操作。

安装和配置 PostgreSQL

作为第一步,您需要安装 PostgreSQL 并为您自己配置它,以便您可以尝试以下示例。

首先,您需要从 www.postgresql.org/download/ 下载安装程序。选择适合您系统的版本;我们将在这里介绍 Windows 安装程序,但其他系统的情况也相当相似。安装程序非常易于使用,我建议您接受默认设置:

  1. 运行安装程序:

图 15.1:选择安装目录

图 15.1:选择安装目录

  1. 保持默认组件不变:

图 15.2:选择要安装的组件

图 15.2:选择要安装的组件

  1. 保持默认的数据目录:

图 15.3:选择数据目录

图 15.3:选择数据目录

注意

您将被要求输入密码。您需要记住这个密码,因为这是您数据库的主密码。

Start!123 是本例的密码。数据库运行在本地的端口 5432 上。pgAdmin 图形界面工具也将被安装,一旦安装程序完成,您就可以启动 pgAdmin 来连接到数据库。

在您的浏览器中,访问 packt.live/2PKWc5w 以访问管理员界面:

图 15.4:管理员界面

图 15.4:管理员界面

安装完成后,您可以通过 pgAdmin 创建新的数据库,并命名为您想要的任何名称,但在接下来的几个步骤中,请确保您有一个名为 postgres 的数据库,我们将通过 Go 连接到它。我们现在可以继续到下一部分,并通过 Go 连接到数据库。

数据库 API 和驱动程序

数据库是一个存储数据的地方;我们通常使用数据库引擎,这些是软件应用程序,允许我们创建和与数据库交互。存在许多不同的数据库引擎,它们为我们提供了不同的数据结构方式。如今,存在许多不同类型的数据库,但最常用且稳固的是被称为 SQL 数据库 的那些。SQL 是一个代表 结构化查询语言 的标准。这是一种标准化的语言,它指定了数据库引擎应该如何响应用户的特定命令。正如其名所示,这是一种允许我们在数据库引擎上执行查询的语言——也就是说,要求它执行那些操作。

要与数据库一起工作,有一种称为 纯 Go 的方法,这意味着 Go 有一个 API 允许你使用不同的驱动程序连接到数据库。该 API 来自 database/sql 包,驱动程序有两种类型。对许多驱动程序有原生支持,所有这些都可以在官方 GitHub 页面找到(packt.live/2LMzcC4),还有第三方驱动程序需要额外的包才能运行,例如 SQLlite3 包,它要求你安装 GCC,因为它是一个纯 C 实现。

注意

GCC 是由 GNU 项目生产的编译器系统。它将你的源代码转换成机器码,以便你的电脑能够运行应用程序。

这里有一些驱动程序的列表:

API 和驱动程序方法背后的理念是,Go 提供了一个统一的接口,允许开发者与不同类型的数据库进行通信。你所需要做的就是导入 API 和必要的驱动程序,然后你就可以与数据库进行通信了。你不需要学习特定驱动程序的实现或驱动程序是如何工作的,因为 API 的唯一目的是创建一个抽象层,以加速开发。

让我们考虑一个例子。假设我们想要一个查询数据库的脚本。这个数据库是 MySQL。一种方法是从驱动程序开始学习如何用其语言编码,然后你就可以开始了。过了一段时间,你编写了许多小脚本,它们都能正确地完成工作。现在,是时候做出一个让你不高兴的管理决策了。他们决定 MySQL 不够好,他们打算用基于云的数据库 AWS Athena 来替换数据库。

现在,既然你为特定的驱动程序编写了脚本,你将忙于重写脚本以使其正常工作。这里的保障是使用统一的 API 和驱动程序组合。这意味着编写针对 API 而不是驱动程序的脚本。API 将翻译你对特定驱动程序的需求。这样,你唯一需要做的就是更换驱动程序,脚本就能保证工作。你刚刚节省了许多小时编写脚本和重写代码的时间,即使底层数据库已经被完全替换。

当我们在 Go 中使用数据库时,我们可以区分以下类型的数据库:

  • 关系型数据库

  • NoSQL 数据库

  • 搜索和分析数据库

在我们的案例中,我们将专注于关系型数据库,这些数据库主要使用 SQL 语言。

连接到数据库

连接到数据库到目前为止是最容易的事情;然而,我们需要记住一些事情。要连接到任何数据库,我们需要至少以下四个条件满足:

  • 我们需要一个主机来连接

  • 我们需要一个在端口上运行的数据库来连接

  • 我们需要一个用户名

  • 我们需要一个密码

用户需要具有适当的权限,因为我们不仅想要连接,我们还希望执行特定的操作,例如查询、插入或删除数据,创建或删除数据库,以及管理用户和视图。让我们想象一下,连接到数据库就像作为一个特定的人拿着特定的钥匙走到门前。门是否打开取决于钥匙,但我们越过门槛后能做什么将取决于这个人(由他们的权限定义)。

在大多数情况下,数据库服务器支持多个数据库,数据库包含一个或多个表:

图 15.5 – 服务器中的数据库

图 15.5 – 服务器中的数据库

想象一下,数据库是相互关联的逻辑容器。

创建一个新的项目

首先,让我们创建一个新的项目。为此,创建一个名为database1的文件夹,并使用终端进入该文件夹。在文件夹内,写下以下内容:

go mod init

让我们看看如何在 Go 中连接到数据库。要连接,我们需要从 GitHub 获取适当的模块,这需要互联网连接。我们需要执行以下命令来获取与 Postgres 实例交互所需的包:

go get github.com/lib/pq

注意

本章使用pq包来连接到数据库。然而,这里还有其他可用的替代包。

记得在项目文件夹内运行它。一旦完成,你就可以开始编写脚本了。首先,我们将初始化我们的脚本:

package main
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"
// import _ <package name> is a special import statement that tells Go to import a package solely for its side effects.

注意

如果你需要更多信息,请访问packt.live/2PByusw

现在我们已经初始化了脚本,我们可以连接到我们的数据库:

db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")

这个主题很特殊,因为 API 提供了一个Open()函数,它接受多种参数。虽然有一些简写方式可以做到这一点,但我希望你知道所有参与建立连接的组件,所以我将使用较长的方法。稍后,你可以决定使用哪一种。

Open函数调用中作为第一个参数使用的postgres字符串,告诉函数使用Postgres驱动程序来建立连接。第二个参数是一个所谓的连接字符串,它包含userpasswordhostportdbnamesslmode参数;这些将用于初始化连接。在这个例子中,我们正在连接到标记为127.0.0.1的本地主机上的默认端口5432,并且我们不使用ssl。对于生产系统,人们倾向于更改默认端口并通过ssl对数据库服务器强制加密流量;你应该始终遵循你正在处理的数据库类型的最佳实践。

如你所见,Open()函数返回两个值。一个是数据库连接,另一个是错误,如果在初始化期间发生了错误。我们如何检查初始化是否成功?嗯,我们可以通过编写以下代码来检查是否有任何错误:

if err != nil {
  panic(err)
}else{
  fmt.Println("The connection to the DB was successfully initialized!")
}

Go 中的panic()函数用于指示某些事情意外出错,我们无法优雅地处理它,因此停止执行。如果连接成功,我们打印出一条消息,声明The connection to the DB was successfully initialized!。当你有一个长期运行的应用程序时,值得加入一种检查数据库是否仍然可访问的方法,因为由于间歇性的网络错误,你可能会丢失连接并无法执行你想要执行的操作。这可以通过以下小代码片段进行检查:

connectivity := db.Ping()
if connectivity != nil{
  panic(err)
}else{
  fmt.Println("Good to go!")
}

你可以每隔几秒在不同的 Go 协程上运行这个检查。它将检查数据库是否开启,同时也有助于保持连接打开;否则,它将进入空闲状态。这是一个主动解决方案,因为你正在检查数据库连接的状态。

在这种情况下,我们使用了panic()函数来指示连接已丢失。最后,一旦我们的工作完成,我们需要终止与数据库的连接以删除用户会话并释放资源。这可以发生在你构建一个作为作业运行的脚本的情况下,因此它会运行并完成,或者如果你正在构建一个长期运行的服务。在前一种情况下,你可以在脚本末尾使用以下命令:

db.Close()

这确保了在终止脚本之前,连接将被断开。如果你正在构建一个长期运行的服务,你代码中并没有一个特定的点可以知道脚本将终止,但它可能随时发生。你可以使用以下代码来确保连接被断开:

defer db.Close()

差别在于范围。db.Close()将在执行到达特定行时终止与数据库的连接,而defer db.Close()表示数据库连接应该在调用它的函数超出作用域时执行。这样做的一种习惯用法是defer db.Close()

在下一节中,我们将开始使用连接进行更有目的的操作,并且我们将从创建表开始。

注意

Go 语言的官方Postgres库可以在packt.live/35jKEwL找到。

创建表

创建表的行为旨在创建逻辑容器,以持久地保存属于一起的数据。你将需要出于许多原因创建表——例如,跟踪员工出勤、收入跟踪和统计数据。共同的目标是为理解这些的应用程序提供服务。这些数据库引擎如何控制谁可以访问什么数据?有两种方法:

  • 第一个涉及的是 CREATEUPDATEDELETE

  • 第二种方法涉及继承和角色。这更健壮,更适合大型企业。

Postgres使用第二种方法,在本节中,我们将学习如何创建一个 SQL 表,以及如何在Postgres中特别创建一个表。

创建表的通用语法看起来像这样:

CREATE TABLE table_name (
  column1 datatype constrain,
  column2 datatype constrain,
  column3 datatype constrain,
  ....
);

当我们通过 SQL 与Postgresmysqlmssql服务器通信时,它们对CREATE TABLEINSERT命令的响应方式相同,因为它们都遵循 SQL 规范。标准的理念不是指定引擎内部的工作方式,而是指定与它的交互方式。这些数据库引擎在功能、速度和存储方法方面通常有所不同;这就是多样性的来源。然而,这并不是一个完整的 SQL 或数据库引擎教程,所以我们只是简要介绍了 SQL 是什么,而没有深入细节。

让我们看看 SQL 语言的一些一般性声明,稍后我们将对其进行一些实验。创建表的语句是CREATE TABLE。这个命令在你连接的数据库上下文中被理解。一个服务器可以托管多个数据库,连接到错误的数据库在执行修改结构的命令时可能会引起麻烦。该命令通常需要一个列名,在我们的例子中是column1,以及我们列中的数据类型,即datatype。最后,我们可以对我们的列设置约束,这将赋予它们特殊属性。我们列支持的数据类型取决于数据库引擎。

这里有一些常见的数据类型:

  • INT

  • DOUBLE

  • FLOAT

  • VARCHAR,这是一个具有特定长度的字符串

约束也取决于数据库引擎,但以下是一些例子:

  • NOT NULL

  • PRIMARY KEY

  • 命名函数

命名函数在每次插入新记录或更新旧记录时都会执行,并且根据事务的评估结果,要么允许要么拒绝。

我们不仅能够创建一个表格,还可以清空表格——也就是说,移除其所有内容,或者从数据库中移除该表格本身。要清空一个表格,我们可以使用以下命令:

TRUNCATE TABLE table_name

要删除表格,我们可以使用以下命令:

DROP TABLE table_name

现在,创建一个新的表格。在Postgres中,你可以使用一个默认的数据库;我们不会为本章的示例创建一个单独的数据库。

我们希望初始化我们的脚本,该脚本位于示例文件夹中,名为DBInit.go

package main
import (
  "fmt"
  "database/sql"
_ "github.com/lib/pq"
)

现在,我们准备定义我们的main()函数:

DBInit.go

func main(){
  db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
  if err != nil {
    panic(err)
  }else{
    fmt.Println("The connection to the DB was successfully initialized!")
  }
  DBCreate := `
  CREATE TABLE public.test (
    id integer,
    name character varying COLLATE pg_catalog."default"
  )
  WITH (
    OIDS = FALSE
  )
`
  _, err = db.Exec(DBCreate),
  if err != nil {
    panic(err)
  } else{
    fmt.Println("The table was successfully created!")
  }
  db.Close()

完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter15/Examples/DBInit.go找到。

让我们分析一下这里发生了什么。我们初始化了数据库连接,没有使用之前提到的默认用户名和密码,现在我们有了db变量来与数据库交互。除非执行过程中出现错误,否则以下输出将在我们的控制台中可见:

图 15.6 – 在控制台可见的期望输出

图 15.6 – 在控制台可见的期望输出

如果我们重新运行脚本,将会出现以下错误:

图 15.7 – 连续执行失败后的输出

图 15.7 – 连续执行失败后的输出

这表示表格已经存在。我们创建了一个名为DBCreate的多行字符串,其中包含所有表格创建信息。在这个字符串中,有一个名为test的表格,它有一个名为id的整型列和一个名为name的字符串列。其余的是 Postgres 特定的配置。表空间定义了我们的表格存储的位置。带有db.Exec()_, err行负责执行查询。

我们正在创建的表格将为每一行分配一个 ID(唯一标识符),这将是一个整型,还有一个名称列,这将是一个字符型。名称有一些特性;例如,COLLATE定义了数据将如何排序,或者说在请求数据时,按照升序或降序排列时,什么会排在第一位或之后。我们正在使用postgres的默认排序规则,该规则由当前数据库中的本地化定义。

正如我们刚才说的,我们将创建一个带有 ID 的表格,并使用它来识别行。Postgres为每一行自动提供一个唯一标识符,称为oid对象标识符),但我们需要手动处理它,所以我们不需要这个。请注意,并非所有其他数据库引擎都提供 oid。

由于我们的目标现在是创建表,我们只关心是否有任何错误;如果没有错误,我们可以使用一个临时变量来捕获输出。如果err不是nil,那么就像我们之前看到的那样,有错误。否则,我们假设表已按预期创建。最后,关闭与数据库的连接。

现在我们已经可以连接到数据库并且我们有一个表,我们可以插入一些数据。

插入数据

很久以前,当支持 SQL 数据库的 Web 应用程序时代开始繁荣起来时,一些勇敢的人发明了 SQL 注入攻击。在这里,通过 SQL 查询对数据库进行一种身份验证,例如,将密码通过数学魔法转换为散列函数后,Web 应用程序执行带有来自表单输入的用户名和密码的查询。许多服务器执行了类似以下的内容:

"SELECT password FROM Auth WHERE username=<input from user>"

然后,密码会被重新散列;如果两个散列匹配,密码对用户来说是有效的。

这个问题出在<用户输入>部分,因为如果攻击者足够聪明,他们可以重新构造查询并运行额外的命令。以下是一个例子:

"SELECT password FROM Auth WHERE username=<input from user> OR '1'='1'"

这个查询的问题在于OR '1' = '1'总是评估为true,并且用户名是什么并不重要;用户的密码散列会被返回。这可以进一步用于制定额外的攻击。为了防止这种情况,Go 使用了一个名为Prepare()的语句,它可以防止这些攻击。

Go 有两种类型的替换:

  • 在查询的情况下,我们使用WHERE col = $1

  • 在插入或更新的情况下,我们使用VALUES($1,$2)

让我们在我们的表中添加一些值。我们将以通常的方式初始化我们的脚本。这个脚本可以在示例文件夹下找到,名为DBInsert.go

package main
................
  insert, err := db.Prepare("INSERT INTO test VALUES ($1, $2)")
  if err != nil {
    panic(err)
  }
  _, err = insert.Exec(2, "second")
  if err != nil {
    panic(err)
  }
  fmt.Println("The value was successfully inserted!")
  defer db.Close()
}

执行成功后,我们将得到以下输出:

The connection to the DB was successfully initialized!
The value was successfully inserted!

让我们看看插入部分发生了什么。db.Prepare()接受一个 SQL 语句,并赋予它防止 SQL 注入攻击的保护。它是通过限制变量替换的值来工作的。在我们的例子中,我们有两个列,所以为了使替换工作,我们使用$1$2。你可以使用任意数量的替换;你只需要确保它们在评估时产生一个有效的 SQL 语句。当insert变量初始化时没有错误,它将负责执行 SQL 语句。它找出预定义语句期望的参数数量,它的唯一目的是调用语句并执行操作。insert.Exec(2,"second")插入一个新元素,其id=2name='second'。如果我们检查我们的数据库中有什么,我们会看到结果。

现在我们已经在我们的表中有了数据,我们可以查询它。

练习 15.01 – 创建一个存储一系列数字的表

在这个练习中,我们将编写一个脚本,该脚本将创建一个名为Numbers的表,我们将存储数字。这些数字将在稍后插入。

创建两个列,NumberPropertyNumber 列将存储数字,而 Property 列在创建时将是 OddEven

使用默认的 Postgres 数据库进行连接。数字应该从 0 到 99。

执行以下步骤来完成这个练习:

  1. 创建一个名为 main.go 的文件。

  2. 使用以下行初始化包:

    package main
    import "fmt"
    import "database/sql"
    import _ "github.com/lib/pq"
    func main(){
    
  3. 创建一个用于后续使用的 property string 变量:

      var property string
    
  4. 初始化数据库连接:

      db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
      if err != nil {
        panic(err)
      }else{
        fmt.Println("The connection to the DB was successfully initialized!")
      }
    
  5. 创建一个多行字符串来创建表:

      TableCreate := `
    CREATE TABLE Number
    (
      Number integer NOT NULL,
      Property text COLLATE pg_catalog."default" NOT NULL
    )
    WITH (
      OIDS = FALSE
    )
    TABLESPACE pg_default;
    ALTER TABLE Number
      OWNER to postgres;
    `
    
  6. 创建表:

      _, err = db.Exec(TableCreate)
      if err != nil {
        panic(err)
      } else{
        fmt.Println("The table called Numbers was successfully created!")
      }
    
  7. 插入数字:

      insert, insertErr := db.Prepare("INSERT INTO Number VALUES($1,$2)")
      if insertErr != nil{
        panic(insertErr)
      }
      for i := 0; i < 100; i++ {
        if i % 2 == 0{
          prop = "Even"
        }else{
          prop = "Odd"
        }
        _, err = insert.Exec(i,prop)
        if err != nil{
          panic(err)
        }else{
          fmt.Println("The number:",i,"is:",prop)
        }
      }
      insert.Close()
      fmt.Println("The numbers are ready.")
    
  8. 关闭数据库连接和函数:

      db.Close()
    }
    

    当你执行脚本时,你应该看到以下输出:

图 15.8 – 成功属性更新的输出

图 15.8 – 成功属性更新的输出

注意

由于长度原因,图 15.8 的一部分输出已被省略。

在这个练习中,我们看到了如何使用 for 循环和 Prepare() 语句在我们的数据库中创建新表以及如何插入新记录。

检索数据

SQL 注入不仅关注要插入的数据。它还关注数据库中任何被操作的数据。检索数据,尤其是安全地检索数据,也是我们必须优先考虑和处理的事项。当我们查询数据时,我们的结果取决于我们连接的数据库和我们想要查询的表。然而,我们也必须提到,数据库引擎实现的安全机制可能也会阻止查询成功,除非用户具有适当的权限。

我们可以区分两种类型的查询:

  • 一些查询不需要参数,例如 SELECT * FROM table

  • 一些查询需要你指定过滤条件

Go 提供了两个允许你查询数据的函数。一个叫做 Query(),另一个叫做 QueryRow()。一般来说,你应该记住 Query() 用于返回任意数量的结果,而 QueryRow 用于你预期最多检索一行的情况。你也可以用 Prepare() 语句来包装它们,尽管我们在这里不会介绍,因为之前已经演示过了。相反,我们想看看这些函数是如何工作的。

让我们为 Query() 创建一个脚本。像往常一样,我们将初始化脚本。它可以在示例中找到,并命名为 DBQuery.go

package main
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"

我们的 main() 函数将略有不同,因为我们想介绍 Scan() 函数:

func main(){
  var id int
  var name string
  db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
  if err != nil {
    panic(err)
  }else{
    fmt.Println("The connection to the DB was successfully initialized!")
  }
  rows, err := db.Query("SELECT * FROM test")
  if err != nil {
    panic(err)
  }
  for rows.Next() {
    err := rows.Scan(&id, &name)
    if err != nil {
      panic(err)
    }
    fmt.Printf("Retrieved data from db: %d %s\n", id, name)
  }
  err = rows.Err()
  if err != nil {
    panic(err)
  }
  err = rows.Close()
  if err != nil {
    panic(err)
  }
  db.Close()
}

输出应该看起来像这样:

The connection to the DB was successfully initialized!
Retrieved data from db: 2 second

注意

在专业环境中,由于性能和安全问题,不太可能看到 SELECT * 查询字符串。你通常会有更具体的查询字符串来针对特定的数据。

如前所述,我们将这些数据插入到我们的数据库中,您可以在此基础上添加更多数据。我们已经定义了idname变量,这将有助于我们的Scan()函数。我们连接到数据库并创建我们的db变量。之后,我们用Query()函数的结果填充我们的rows变量,这将包含表中的所有元素。

接下来是棘手的部分:我们使用for rows.Next()来遍历结果行。但这还不够;我们希望将查询的结果分配给相应的变量,该变量由rows.Scan(&id, &name)返回。这允许我们引用当前行的 ID 和NAME,这使得我们可以更容易地处理这些值。最后,优雅地关闭行和数据库连接。

让我们使用Prepare()查询一行。初始化看起来和之前一样:

DBPrepare.go

package main
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"

完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter15/Examples/DBPrepare.go找到。

主要区别在于main()函数的开始部分:

func main(){
  var name string
  var id int
  id = 2
  db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
  if err != nil {
    panic(err)
  }else{
    fmt.Println("The connection to the DB was successfully initialized!")
  }
  qryrow, err := db.Prepare("SELECT name FROM test WHERE id=$1")
  if err != nil{
    panic(err)
  }
  err = qryrow.QueryRow(id).Scan(&name)
  if err != nil {
    panic(err)
  }
  fmt.Printf("The name with id %d is %s", id, name)
  err = qryrow.Close()
  if err != nil {
    panic(err)
  }
  db.Close()
}

如果您一切操作正确,输出应该看起来像这样:

The connection to the DB was successfully initialized!
The name with id 2 is second

让我们仔细检查我们的main函数。我们定义了两个变量:name变量,当我们处理查询结果时将使用它,以及id变量,它作为我们执行的查询的灵活输入。通常的数据库连接初始化和之前一样发生。

然后是SQL 注入证明部分。我们准备了一个查询,它在某种程度上是动态的,因为它接受一个参数,这个参数将是我们要查找的 ID。然后,使用qryrow执行QueryRow()函数,该函数反过来又接受我们之前指定的id变量,并将结果返回到name变量中。然后,我们输出带有解释的字符串,说明列的值基于指定的id变量。最后,关闭qryrowdb资源。

现在我们知道了如何从数据库中检索数据,我们需要看看如何更新数据库中的现有数据。

更新现有数据

当您使用 Go 更新一行或多行时,您会遇到麻烦。sql包没有提供名为Update()的任何函数;然而,有一个Exec()函数,它作为您查询的通用执行器。您可以使用此函数执行SELECTUPDATEDELETE或您需要执行的任何操作。本节将向您展示如何安全地执行这些操作。

我们希望以通常的方式开始我们的脚本。它可以在示例文件夹中找到,并命名为DBUpdate.go

package main
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"

然后就是魔法时刻。想法是更新特定id变量对应的name列的值,我们将这个变量作为参数传递。所以,main()函数看起来是这样的:

func main(){
  db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
  if err != nil {
    panic(err)
  }else{
    fmt.Println("The connection to the DB was successfully initialized!")
  }
  UpdateStatement :=`
  UPDATE test
  SET name = $1
  WHERE id = $2
  `
  updateResult, updateResultErr := db.Exec(updateStatement,"well",2)
  if updateResultErr != nil {
    panic(updateResultErr)
  }
  updatedRecords, updatedRecordsErr := updateResult.RowsAffected()
  if updatedRecordsErr != nil {
    panic(UpdatedRecordsErr)
  }
  fmt.Println("Number of records updated: ",UpdatedRecords)
  db.Close()
}

如果一切顺利,我们将看到以下输出:

The connection to the DB was successfully initialized!
Number of records updated: 1

注意,你可以也应该尝试不同的输入,并观察脚本如何对不同的问题/错误做出反应。

让我们分析一下这里发生了什么。我们像之前一样初始化数据库连接。我们创建了一个名为UpdateStatement的变量,它是一个多行字符串,并且被设计成可以传递给Exec()函数,该函数接受参数。我们想要更新具有指定 ID 的列的名称。这个函数要么自己运行指定的语句,要么可以用来传递将被替换到适当位置的参数。这完全没问题,可以完成我们的工作,但我们需要确保UPDATE命令至少更新了一条记录。

为了这个目的,我们可以使用RowsAffected()。它将返回更新的行数以及过程中遇到的任何错误。最后,我们将更新的行数打印到控制台并关闭连接。

删除数据库中的数据的时候到了。

删除数据

数据可能因多种原因被删除:我们不再需要这些数据,我们正在迁移到另一个数据库,或者我们正在替换当前解决方案。我们很幸运,因为当前的 Go 功能提供了一种非常优雅的方式来完成这项工作。这与我们记录的UPDATE语句的类比相同。我们制定一个DELETE语句并执行它;技术上,我们可以修改我们的UPDATE脚本来从数据库中删除它。

为了简化,我们只修改相关的行。我们的DELETE语句将替换UPDATE语句,如下所示:

DBDelete.go

12  DeleteStatement :=`
13  DELETE FROM test
14  WHERE id = $1
15  `

完整的代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter15/Examples/DBDelete.go找到。我们将更新包含Exec()语句的行:

  deleteResult, deleteResultErr := db.Exec(deleteStatement,2)
  if deleteResultErr != nil {
    panic(deleteResultErr)
  }

此外,我们还必须更新计算更新记录的行:

  deletedRecords, deletedRecordsErr := deleteResult.RowsAffected()
  if deletedRecordsErr != nil {
    panic(deletedRecordsErr)
  }
  fmt.Println("Number of records deleted: ",deletedRecords)

我们的结果应该看起来像这样:

The connection to the DB was successfully initialized!
Number of records deleted: 1

就这样。经过一点修改,我们得到了一个可以更新或删除记录并带有验证的脚本。

现在,让我们看看如何创建一个存储素数的表。

练习 15.02 – 在数据库中存储素数

在这个练习中,我们将基于 练习 15.01 – 创建一个包含一系列数字的表格 来构建。我们希望创建一个脚本,告诉我们表格中有多少素数,并按出现顺序提供给我们。我们希望在输出中看到素数的总和。然后,我们希望从表格中删除所有偶数,并查看删除了多少个。我们希望将素数的总和添加到剩余的奇数中,并使用记录更新表格,必要时更改属性。使用 math/big 包进行素性测试。

按照以下步骤操作:

  1. 创建一个名为 main.go 的脚本。

  2. 初始化我们的脚本以执行特定操作:

    package main
    import "fmt"
    import "database/sql"
    import _ "github.com/lib/pq"
    import "math/big"
    func main(){
    
  3. 定义四个变量以供以后使用:

      var number int64
      var prop string
      var primeSum int64
      var newNumber int64
    
  4. 初始化数据库连接:

      db, err := sql.Open("postgres", "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
      if err != nil {
        panic(err)
      }else{
        fmt.Println("The connection to the DB was successfully initialized!")
      }
    
  5. 获取所有素数的列表:

      allTheNumbers := "SELECT * FROM Number"
      numbers, err := db.Prepare(allTheNumbers)
      if err != nil {
        panic(err)
      }
      primeSum = 0
      result, err := numbers.Query()
      fmt.Println("The list of prime numbers:")
      for result.Next(){
        err = result.Scan(&number, &prop)
        if err != nil{
          panic(err)
        }
        if big.NewInt(number).ProbablyPrime(0) {
          primeSum += number
          fmt.Print(" ",number)
        }
      }
      err := numbers.Close()
      if err != nil{
        panic(err)
      }
    
  6. 打印素数的总和:

      fmt.Println("\nThe total sum of prime numbers in this range is:", primeSum)
    
  7. 删除偶数:

      remove := "DELETE FROM Number WHERE Property=$1"
      removeResult, err := db.Exec(remove,"Even")
      if err != nil {
        panic(err)
      }
      modifiedRecords, err := removeResult.RowsAffected()
      fmt.Println("The number of rows removed:",ModifiedRecords)
      fmt.Println("Updating numbers...")
    
  8. 使用 primeSum 更新剩余记录并打印结束语:

      update := "UPDATE Number SET Number=$1 WHERE Number=$2 AND Property=$3"
      allTheNumbers = "SELECT * FROM Number"
      numbers, err = db.Prepare(allTheNumbers)
      if err != nil {
        panic(err)
      }
      result, err = numbers.Query()
    for result.Next(){
        err = result.Scan(&number, &prop)
        if err != nil{
          panic(err)
        }
        newNumber = number + primeSum
        _, err = db.Exec(update,newNumber,number,prop)
        if err != nil {
          panic(err)
        }
      }
      numbers.Close()
      if err != nil{
        panic(err)
      }
      fmt.Println("The execution is now complete...")
    
  9. 关闭数据库连接:

      db.Close() 
      }
    

    一旦脚本执行完毕,以下输出应该可见:

图 15.9 – 计算输出

图 15.9 – 计算输出

在这个练习中,我们看到了如何利用内置的 Go 函数来查找素数。我们还通过删除数字来操作表格,然后执行更新操作。

注意

关闭数据库很重要,因为一旦我们的工作完成,我们确实想释放未使用的资源。

截断和删除表格

在本节中,我们想要清空一个表格并将其删除。为了清空表格,我们可以简单地制定匹配我们表格中每个记录的 DELETE 语句,从而从我们的表格中删除每个记录。然而,有一种更优雅的方法来做这件事:我们可以使用 TRUNCATE TABLE SQL 语句。这个语句的结果是一个空表。我们可以使用我们的 sql 包的 Exec() 函数来做这件事。你已经知道如何通过导入初始化包。你也知道如何连接到数据库。这次,我们只关注语句。

以下语句将实现完整的 TRUNCATE

emptyTable, emptyTableErr := db.Exec("TRUNCATE TABLE test")
if emptyTableErr != nil {
  panic(emptyTableErr)
}

这的结果是一个名为 test 的空表。要完全删除表格,我们可以修改我们的语句如下:

dropTable, dropTableErr := db.Exec("DROP TABLE test")
if dropTableErr != nil {
  panic(dropTableErr)
}

如果你需要一个表格但不需要更多的旧数据,你可能想要截断它,并继续向现有表格中添加新数据。如果你因为更改了模式而不再需要表格,你可能想使用 DROP 命令将其删除。

如果我们检查我们的数据库引擎,我们不会找到 test 表的任何痕迹。这彻底从数据库的表面消除了整个表。

这一节主要介绍了如何通过 Go 编程语言与数据库进行交互。现在,你对如何开始有了相当的了解。

注意

对于更多信息及额外细节,你应该查看 SQL API 的官方文档:packt.live/2Pi5oj5

活动十五点零一 - 在表中存储用户数据

在此活动中,我们将创建一个将存储用户信息(如IDNameEmail)的表。我们将基于你在创建表插入数据部分获得的知识来构建。

按照以下步骤完成此活动:

  1. 创建一个小的脚本,该脚本将创建一个名为Users的表。此表必须有三列:IDNameEmail

  2. 将两位用户及其详细信息添加到表中。他们应该有独特的名字、ID 和电子邮件地址。

  3. 然后,你需要将第一个用户的电子邮件更新为user@packt.com并删除第二个用户。确保所有字段都不是NULL。由于 ID 是主键,它需要是唯一的。

  4. 当你在表中插入、更新和删除数据时,请使用Prepare()函数来防止 SQL 注入攻击。

  5. 你应该使用结构体来存储你想要插入的用户信息,并在插入时使用for循环遍历结构体。

  6. 一旦完成insertupdatedelete调用,确保在适当的时候使用Close()并关闭与数据库的连接。

    成功完成后,你应该看到以下输出:

图十五点一零 - 可能的输出

图十五点一零 - 可能的输出

注意

此活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter15/Activity15.01/main.go找到。

在此活动结束时,你应该学会如何创建一个名为users的新表,以及如何向其中插入数据。

活动十五点零二 - 查找特定用户的消息

在此活动中,我们将基于活动十五点零一 - 在表中存储用户数据继续。

我们需要创建一个名为Messages的新表。此表将有两个列,这两个列都应该有 280 个字符的限制:一个是UserID,另一个是Message

当你的表准备就绪时,你应该添加一些带有用户 ID 的消息。确保添加UserID,它不在users表中。

一旦添加了数据,编写一个查询,返回指定用户发送的所有消息。使用Prepare()函数来防止 SQL 注入。

如果找不到指定的用户,请打印查询未返回任何内容,没有这样的用户:<username>。你应该从键盘输入用户名。

执行以下步骤以完成此活动:

  1. 定义一个结构体,用于存储UserID及其消息。

  2. 应该使用一个遍历先前定义结构的for循环来插入消息。

  3. 当接收到用户输入时,请确保使用Prepare()语句来构建你的查询。

    如果一切顺利,您应该会得到以下输出,具体取决于您如何用用户名和消息填充您的数据库:

图 15.11:预期输出

图 15.11:预期输出

注意

本活动的解决方案可以在 github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter15/Activity15.02/main.go 找到。

如果您愿意,您可以调整脚本,以便在连续运行时不重新创建数据库。

在本活动结束时,您应该学会如何创建一个名为 Messages 的新表,然后从用户那里获取输入,并根据输入搜索相关的用户和消息。

使用 GORM 添加用户

到目前为止,我们通过与直接编写一些 SQL 查询来与数据库交互。我们所做的是创建并运行 Go 代码,然后使用这些代码来运行 SQL 代码。这完全没问题,但还有一种方法可以直接运行 Go 代码来与 SQL 数据库交互。在此基础上,我们存储在数据库中的数据将被解包到 Go 变量中,一行内容可能定义了一个 Go 结构体实例的值。为了改进和简化整个过程,我们可以进一步抽象数据库并使用 对象关系映射器ORM)。这是一个将表及其关系与 Go 结构体匹配的库,这样您就可以以实例化和删除任何 Go 结构体实例相同的方式插入和检索数据。ORM 通常不是语言的一部分,Go 本身也不提供。然而,存在一系列第三方库,其中之一是 Go 的既定 ORM,即 GORM。您可以在 gorm.io/ 找到该包的所有详细信息,但我们将简要学习如何使用它来添加和搜索数据库中的数据。

要使用 GORM,我们必须导入它。以下是导入方法:

import (
  "gorm.io/gorm"
  "gorm.io/driver/postgres"
)

如您所见,我们不仅仅只有一行代码,而是有两行。第一行加载了 GORM 库,而第二行指定了要使用的驱动程序。GORM 可以用来与许多不同的数据库引擎交互,包括 MySQL、Postgres 和 SQLite。虽然库本身可以从 gorm.io/gorm 获取,但与引擎交互的具体方式由驱动程序处理——在本例中,是 Postgres 驱动程序。

下一步将是定义一个模式——即一个代表表内内容的 Go 结构体。让我们定义一个代表用户的结构体:

type User struct {
  gorm.Model
  FirstName  string
  LastName   string
  Email      string
}

这非常直接——我们定义一个名为 User 的 struct,并添加一些字段来存储用户的姓名和电子邮件地址。然而,第一件重要的事情是,我们将 gorm.Model struct 嵌入到我们的 struct 中,使其成为一个 GORM 模型。这个 struct 将添加一些字段,例如 ID,并将其设置为主键,以及一些其他字段,例如创建和更新日期,并且还会添加一些库将用于与数据库交互的方法。

现在我们已经定义了一个用户结构的 struct,让我们看看我们如何将一个用户插入到数据库中。为了与数据库交互,我们必须连接到它。之前,我们看到了如何连接到 PostgreSQL;这里我们将做类似的事情:

connection_string = "user=postgres password=Start!123 host=127.0.0.1 port=5432 dbname=postgres sslmode=disable"
db, err := gorm.Open(postgres.Open(connection_string), &gorm.Config{})
if err != nil {
   panic("failed to connect database")
}

如您所见,我们可以使用之前相同的连接字符串,但我们将这样做是在 gorm.Open 调用内部,这允许 GORM 与底层数据库引擎交互。

到目前为止,我们还没有为用户创建一个表格,我们也看到了如何使用 SQL 创建一个表格并通过 Go 调用它。使用 GORM,我们不需要这样做。在定义将放入存储用户的表格中的类型之后,我们可以让 GORM 为我们创建该表格,如果它尚未存在的话。我们可以用以下代码来完成:

db.AutoMigrate(&User{})

这个调用确保有一个包含所有必需列的表格,并且默认情况下会将其命名为 users。有方法可以更改表格的名称,但通常,遵循约定会更好。因此,存储用户数据的表格将被称为 users,而存储用户详细信息的 struct 将被称为 User

现在剩下的只是添加一个实际的用户——我们将称他为 John Smith,并使用 john.smith@gmail.com 作为他的电子邮件地址。这就是我们如何使用他的详细信息实例化 struct 的方法:

u := &User{FirstName: "John", LastName: "Smith", Email: "john.smith@gmail.com"}

最后,我们可以将其插入到数据库中:

db.Create(u)

如您所见,这非常直接,它允许我们只编写 Go 代码并将我们的数据建模为 Go structs。

GORM 有很多功能;在本节中,我们学习了如何创建 structs 并使用它们来匹配数据库中的模式,以及如何向特定表格添加数据。现在,让我们学习如何使用 GORM 查找用户。

使用 GORM 查找用户

一旦我们添加了用户,我们希望检索它们。让我们使用上一节中学到的知识添加几个其他用户:

db.Create(&User{FirstName: "John", LastName: "Doe", Email: "john.doe@gmail.com"
db.Create(&User{FirstName: "James", LastName: "Smith", Email: "james.smith@gmail.com"})

假设我们已为 John Smith 插入了记录。因此,从一个干净的数据库和干净的表格开始,我们应该有 ID 为 1、2 和 3 的用户。

现在,我们想要检索我们插入的第一个用户的详细信息。我们可以用以下命令来完成:

var user User
db.First(&user, 1)

这将返回第一个用户,其 ID 等于 1。返回的记录被反序列化到 user 变量中,它是 User 结构体的一个实例。我们可以通过他们的 ID 搜索每个其他用户,并将数字 1 替换为 2 或 3。然而,这并不很有趣,因为我们可能不知道用户的 ID,只知道他们的名字或姓氏。让我们看看如何通过姓氏检索 John Doe:

db.First(&user, "last_name = ?", "Doe")

注意,我们没有使用 “LastName” 而是使用 last_name,因为 GORM 自动将结构体的每个驼峰式命名的属性转换为蛇形命名;这是数据库列名称的常用约定。另一个需要注意的重要事情是我们使用了两个参数:

"last_name = ?" and "Doe"

第一个表示我们想要搜索的列,等号后面有一个问号。问号是一个占位符,将被下一个参数替换,即 Doe。由于有两个姓氏为 Smith 的人,我们刚才使用的函数将检索到第一个同姓的人,但这并不一定是我们要找的人。我们可以使用 Last 函数,它返回与查询匹配的最后结果,但我们可能有更多同姓的用户。这个解决方案如下:

db.First(&user, "last_name = ? AND first_name= ?", "Smith", "James")

在这里,我们创建了一个包含更多条件的查询——前几个参数表示条件,而后续的参数使用占位符填充值。

我们可能面临的问题是,我们可能会混淆结构体属性的名称和实际的列名称。如果我们需要执行一个简单的匹配查询,我们可以用以下代码替换之前的代码:

db.First(&user, &User{FirstName: "James", LastName: "Smith"})

这里,我们只传递一个设置了几个属性的 User 结构体实例,其他属性使用默认值。

这些示例允许我们搜索特定的记录,但通常,我们需要一个对象的列表。当然,FirstLast 函数只返回一个项目,但 GORM 也提供了一个函数来返回所有符合我们标准的记录。如果标准是一个简单的 ID,或者如果我们搜索的字段是唯一的,我们最好坚持使用 First,但如果我们的标准不是唯一的,我们应该使用以下函数:

var users []User
db.Find(&users, &User{LastName: "Smith"})

Find 函数返回所有匹配的记录,但我们不能直接将其反序列化为单个用户实例。因此,我们必须定义一个 users 变量,它是一个 User 实例的切片,而不是使用之前看到的 user,它是一个 User 结构体的实例。

这让我们有了一个如何使用 GORM 插入和检索数据的概念,但我们忘记了一件事:错误。这些函数正在联系数据库,但查询可能会因为几个原因而出错,我们需要控制这一点。之前看到的函数不返回错误,而是一个指向数据库结构的指针,我们可以用它来获取错误:

tx := db.Find(&users, &User{LastName: "Smith"})
if tx.Error != nil {
  fmt.Println(tx.Error)
}

在这里,tx 变量代表 事务,并返回一个包含潜在错误的值集。我们可以通过比较 tx.Error 的值与 nil 来检查是否存在错误。当我们使用事务时,我们对数据库所做的任何操作都不是最终的;它不会影响任何其他客户端访问的数据库状态,因此任何更改都是临时的。为了使任何更改生效,我们需要提交事务。在这种情况下,我们只是返回结果,并没有修改数据库,因此我们不需要提交。我们使用事务是因为 GORM 从 Find 调用返回一个事务。

这为我们使用 GORM 来建模和使用数据,同时将其存储在数据库中提供了一个起点。

概述

本章使你在与 SQL 数据库交互方面变得高效。你学习了如何创建、删除和操作数据库表。你还了解了 Go 语言适合与之交互的所有不同类型的数据库。由于本章是以 PostgreSQL 引擎为背景编写的,你应该熟悉其 Go 模块。

借助这些知识,你现在可以进入使用 Go 语言进行数据库编程的领域,并在某种程度上实现自给自足,即你知道在哪里寻找解决问题的方案和额外知识。这种知识的最常见用例是当你必须构建自动报告应用程序,从数据库中提取数据并以电子邮件的形式报告时。另一种用例是当你有一个自动应用程序用于将数据推送到数据库服务器,该服务器处理 CSV 文件或 XML 文件时。这取决于你所处的具体情况。

本章还向你介绍了 ORM 的概念,并为你介绍了 Go 语言中最著名的 ORM:GORM。

在下一章中,你将学习如何通过 HTTP 客户端与 Web 接口交互,这是 Go 语言中最有趣的话题之一。

第五部分:为 Web 构建应用

现代世界深受互联网和万维网的影响。Go 语言,诞生于互联网时代,被精心打造以适应这个数字环境。

本节深入探讨了使用 Go 语言进行 Web 开发的领域,使你能够创建强大且高效的 Web 应用程序。

本节包括以下章节:

  • 第十六章Web 服务器

  • 第十七章使用 Go HTTP 客户端

第十六章:网络服务器

概述

本章介绍了创建 HTTP 服务器以接受来自互联网的请求的不同方法。你将能够理解一个网站是如何被访问的,以及它如何响应一个表单。你还将学习如何响应来自另一个软件程序的需求。

到本章结束时,你将能够创建一个渲染简单信息的 HTTP 服务器。你还将知道如何创建一个渲染复杂数据结构并服务于本地静态文件的 HTTP 服务器。此外,你将了解如何创建一个渲染动态页面并使用不同路由方式的 HTTP 服务器。最后,你将知道如何创建一个 REST 服务,通过表单接收数据,以及接收 JSON 数据。

技术要求

要完成并运行本章中的示例,你需要你的首选集成开发环境(IDE)和 Go 编译器的最新版本。在撰写本文时,这是 1.21 版本。所有示例都将使用标准 Go 库。你可以参考本书的 GitHub 仓库以获取本章的代码:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter16

简介

在本章中,我们将深入探讨如何创建一个远程服务器,因此如果你已经知道如何请求信息,你将看到如何对这些请求进行响应。

一个网络服务器是一个使用 HTTP 协议的程序——因此称为 HTTP 服务器——用于接受来自任何 HTTP 客户端(网页浏览器、另一个程序等)的请求,并以适当的消息响应它们。当我们用浏览器浏览互联网时,它将是一个 HTTP 服务器,将发送一个 HTML 页面到我们的浏览器,我们就能看到它。在某些其他情况下,服务器不会返回一个 HTML 页面,而是返回一个适合客户端的不同消息。

一些 HTTP 服务器提供了一个可以被另一个程序消费的 API。想想当你想要注册到一个网站时,你被问及是否想要通过 Facebook 或 Google 注册。这意味着你想要注册的网站将消费一个 Google 或 Facebook API 来获取你的详细信息。这些 API 通常以结构化文本的形式响应,这是一段代表复杂数据结构的文本。这些服务器期望请求的方式可能不同。一些期望返回相同类型的结构化消息,而一些提供所谓的 REST API,它对使用的 HTTP 方法非常严格,并期望以 URL 参数或值的形式输入,类似于网页表单。

如何构建一个基本服务器

我们能创建的最简单的 HTTP 服务器是一个Hello World服务器。这是一个返回简单消息,声明Hello World并且不会做其他任何事情的服务器。它并不非常实用,但它是观察默认 Go 包能给我们什么的一个起点,也是任何其他更复杂服务器的基石。目标是有一个在机器的本地主机上特定端口运行的服务器,并接受其下的任何路径。接受任何路径意味着当你用浏览器测试服务器时,它总是会返回Hello World消息和状态码200。当然,我们可以返回任何其他消息,但出于历史原因,当你学习编程时学习的最简单的项目总是某种返回声明Hello World的消息的软件。在这种情况下,我们将看到如何做到这一点,并在正常浏览器中可视化,然后可能将其放在互联网上并与数十亿用户分享,尽管在实践中,用户可能更喜欢一个更有用的服务器。让我们说这是你可以创建的最基本的 HTTP 服务器。

HTTP 处理器

为了响应 HTTP 请求,我们需要编写一些通常所说的处理请求的内容;因此,我们称这个内容为处理器。在 Go 中,我们有几种方法可以做到这一点,其中一种方法是实现http包的处理器接口。此接口有一个相当直观的方法,如下所示:

ServeHTTP(w http.ResponseWriter, r *http.Request)

因此,每当我们需要为 HTTP 请求创建一个处理器时,我们可以创建一个包含此方法的 struct,并可以使用它来处理 HTTP 请求。以下是一个示例:

type MyHandler struct {}
func(h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}

这是一个有效的 HTTP 处理器,您可以使用它如下:

http.ListenAndServe(":8080", MyHandler{})

在这里,ListenAndServe()是一个将使用我们的处理器来处理请求的函数;任何实现了处理器接口的 struct 都是可以的。然而,我们需要让我们的服务器做些事情。

如您所见,ServeHTTP方法接受ResponseWriter和一个Request对象。您可以使用它们来捕获请求中的参数并将消息写入响应。例如,最简单的事情就是让我们的服务器返回一个消息:

func(h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  _, err := w.Write([]byte("HI"))
  if err != nil {
    log.Printf("an error occurred: %v\n", err)
    w.WriteHeader(http.StatusInternalServerError)
  }
}

ListenAndServe方法可能会返回一个错误。如果发生这种情况,我们希望程序执行停止。一种常见的做法是将此函数调用用致命日志包装起来:

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

这将停止执行并打印出ListenAndServe函数返回的错误信息。

练习 16.01 – 创建一个 Hello World 服务器

让我们从根据上一节学到的内容构建一个简单的Hello WorldHTTP 服务器开始。

您需要做的第一件事是创建一个名为hello-world-server的文件夹。您可以通过命令行或使用您最喜欢的编辑器来完成此操作。在文件夹内,创建一个名为main.go的文件。在这里,我们不会使用任何外部库:

  1. 添加包名,如下所示:

    package main
    

    这告诉编译器,此文件是程序入口点,该程序可以被执行。

  2. 导入必要的包:

    import (
      "log"
      "net/http"
    )
    
  3. 现在,创建handler,这个结构体将用于处理请求:

    type hello struct{}
    func(h hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      msg := "<h1>Hello World</h1>"
      w.Write([]byte(msg))
    }
    
  4. 现在我们有了我们的处理器,创建main()函数。这将启动服务器并生成一个包含我们消息的网页:

    func main() {
      log.Fatal(http.ListenAndServe(":8080", hello{}))
    }
    

    整个文件应该看起来像这样:

    package main
    import (
      "log"
      "net/http"
    )
    type hello struct{}
    func(h hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      msg := "<h1>Hello World</h1>"
      w.Write([]byte(msg))
    }
    func main() {
      log.Fatal(http.ListenAndServe(":8080", hello{}))
    }
    
  5. 现在,打开你的终端,进入你的hello-world-server文件夹,并输入以下命令:

    go run .
    

    你不应该看到任何东西;程序已经启动。

  6. 现在,打开你的浏览器到以下地址:

    http://localhost:8080
    

    你应该看到一个带有大消息的页面:

图 16.1:Hello World 服务器

图 16.1:Hello World 服务器

现在,如果你尝试更改路径并访问/page1,你会看到以下消息:

图 16.2:Hello World 服务器子页面

图 16.2:Hello World 服务器子页面

恭喜!这是你的第一个 HTTP 服务器。

在这个练习中,我们创建了一个基本的 Hello World 服务器,它对任何子地址上的任何请求都返回一条消息,声明Hello World

简单路由

在上一个练习中我们构建的服务器并没有做什么——它只是响应一条消息;我们无法询问其他内容。在我们使服务器更加动态之前,让我们想象我们想要创建一个在线书籍,并且我们想要能够通过更改 URL 来选择章节。目前,如果我们浏览以下页面,我们总是会看到相同的信息:

http://localhost:8080
http://localhost:8080/hello
http://localhost:8080/chapter1

现在,我们想要将不同的消息与服务器上的不同路径关联起来。我们将通过向服务器引入一些简单的路由来实现这一点。

路径是在 URL 中8080之后看到的,其中8080是我们选择在服务器上运行的端口号。这个路径可以是一个数字,一个单词,一组数字,或者由/分隔的字符组。为此,我们将使用net/http包的另一个函数:

HandleFunc(pattern string, handler func(ResponseWriter, *Request))

在这里,模式是我们想要由handler函数服务的路径。注意handler函数签名与你在上一个练习中添加到hello结构体的ServeHTTP方法具有相同的参数。

作为例子,我们在练习 16.01中构建的服务器并不非常实用,但我们可以通过添加除Hello World之外的页面来将其转变为更有用的东西。为此,我们需要做一些基本的路由。这里的目的是写一本书,这本书必须有一个包含标题和第一章的欢迎页面。书名是Hello World,所以我们可以保留之前所做的工作。第一章将有一个标题,声明第一章。这本书还在进行中,所以内容仍然很糟糕并不重要;我们需要的是能够选择章节;我们将在稍后添加内容。

练习 16.02 – 路由我们的服务器

我们将修改练习 16.01中的代码,使其支持不同的路径。如果你还没有完成前面的练习,请现在完成,以便你有一个这个练习的基本框架:

  1. 创建一个新的文件夹和一个 main.go 文件,并将之前练习中的代码添加到 main 函数的定义中:

    package main
    import (
      "log"
      "net/http"
    )
    type hello struct{}
      func(h hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        msg := "<h1>Hello World</h1"
        w.Write([]byte(msg))
    }
    
  2. 创建 main() 函数:

    func main() {
    
  3. 然后,使用 handle/chapter1 路由通过 handlefunc() 函数:

      http.HandleFunc("/chapter1", func(w http.ResponseWriter, r *http.Request) {
        msg := "Chapter 1"
        w.Write([]byte(msg))
    })
    

    这意味着我们将路径 /chapter1 与一个返回特定信息的函数关联起来。

  4. 最后,设置服务器以便它监听一个端口;然后,运行以下命令:

        log.Fatal(http.ListenAndServe(":8080", hello{}))
    }
    
  5. 现在,保存你的文件,并使用以下命令再次运行服务器:

    go run .
    
  6. 然后,打开你的浏览器并加载以下 URL:

    • http://localhost:8080

    • http://localhost:8080/chapter1

      • 主页的输出显示在下图中:

图 16.3:多页服务器 – 主页

图 16.3:多页服务器 – 主页

“第 1 页”的输出显示在下图中:

图 16.4:多页服务器 – 第 1 页

图 16.4:多页服务器 – 第 1 页

注意,它们仍然显示相同的信息。这是因为我们将 hello 设置为我们的服务器的处理器,这覆盖了我们的特定路径。我们可以修改我们的代码,使其看起来像这样:

func main() {
  http.HandleFunc("/chapter1", func(w http.ResponseWriter, r *http.Request) {
    msg := "<h1>Chapter 1</h1>"
    w.Write([]byte(msg))
})
    http.Handle("/", hello{})
    log.Fatal(http.ListenAndServe(":8080", nil))
}

在这里,我们移除了 hello 处理器,使其不再是我们的服务器的主处理器,并将此处理器与主要的 / 路径关联起来:

http.Handle("/", hello{})

然后,我们将一个 handler 函数与特定的 /chapter1 路径关联起来:

  http.HandleFunc("/chapter1", func(w http.ResponseWriter, r *http.Request) {
    msg := "Chapter 1"
    w.Write([]byte(msg))
})

现在,如果我们停止并再次运行我们的服务器,我们会看到 /chapter1 路径现在返回了新的信息:

图 16.5:多页服务器重复 – 第一章

图 16.5:多页服务器重复 – 第一章

同时,所有其他路径都返回旧的 Hello World 信息:

图 16.6:多页服务器 – 基础页面

图 16.6:多页服务器 – 基础页面

服务器的默认页面也显示在另一个路由上:

图 16.7:未设置的页面返回默认设置

图 16.7:未设置的页面返回默认设置

通过这样,我们创建了一个基本的 Hello World 网络服务器,为不同的页面设置了特定的路由。在这个过程中,我们使用了 go http 包中的几个函数,其中一些用于实现相同的结果。我们将很快看到为什么有多个方法可以完成同一件事,以及为什么我们需要所有这些方法。

处理器与处理器函数

如你所见,我们之前使用了两个不同的函数,http.Handlehttp.HandleFunc,它们都以路径作为第一个参数,但在第二个参数方面有所不同。这两个函数都确保特定的路径由一个函数处理。然而,http.Handle 期望 http.Handler 来处理路径,而 http.HandleFunc 期望一个函数来做同样的事情。

如我们之前所见,http.Handler 是任何具有此签名的结构体:

ServeHTTP(w http.ResponseWriter, r *http.Request)

因此,在这两种情况下,都存在一个带有 http.ResponseWriter*http.Request 作为参数的函数来处理路径。在许多情况下,选择哪一个可能只是个人偏好的问题,但在创建复杂项目时,例如,选择正确的方法可能很重要。这样做将确保项目的结构是最优的。不同的路由如果由属于不同包的处理器处理,或者可能需要执行非常少的操作,就像我们之前的例子一样,可能会组织得更好;而一个简单的函数可能证明是理想的选择。

通常,对于只有几个简单页面的简单项目,你可以选择使用 HandleFunc。例如,假设你想要有静态页面,并且每个页面上没有复杂的行为。在这种情况下,仅为了返回静态文本而使用一个空的 struct 就显得过于冗余。当需要设置一些参数或想要跟踪某些内容时,处理器更为合适。作为一个一般规则,如果我们有一个计数器,Handler 是最佳选择,因为你可以用一个计数为 0 的结构体初始化,然后增加它,但我们将这在 活动 16.01 中看到。

活动 16.01 – 向 HTML 页面添加页面计数器

假设你拥有一个网站,比如有三个页面,你在那里写你的书。你通过网站收到的访问量来赚钱。为了了解你的网站有多受欢迎,以及你赚了多少钱,你需要跟踪访问量。

在这个活动中,你将构建一个包含三个页面且每个页面都有一些内容的 HTTP 服务器,并在每个页面上显示该页面迄今为止的访问次数。你将使用 http.Handler 方法,在这种情况下,这将帮助你泛化计数器。

要显示动态值,你可以使用 fmt 包中的 fmt.Sprintf 函数,该函数将打印并格式化一条消息到字符串中。使用此函数,你可以构建包含字符和数字的字符串。你可以在 Go 文档中在线找到有关此方法的更多信息。

你将使用迄今为止所学的一切,包括如何实例化结构体,如何设置结构体的属性,指针,如何增加一个整数,以及当然,迄今为止你所学的关于 HTTP 服务器的所有内容。

观察以下步骤将提供一个优雅且有效的解决方案:

  1. 创建一个名为 page-counter 的文件夹。

  2. 创建一个名为 main.go 的文件。

  3. httpfmt 包添加必要的导入。

  4. 定义一个名为 PageWithCounter 的结构体,其中包含一个整型属性 counter,一个文本属性 content 和一个文本属性 heading

  5. 向结构体添加一个能够显示内容、标题和总浏览次数的消息的 ServeHTTP 方法。

  6. 创建你的 main 函数,并在其中实现以下内容:

    • 实例化三个PageWithCounter类型的处理器,具有Hello WorldChapter 1Chapter 2标题和一些内容。

    • 将三个处理器添加到//chapter1/chapter2路由。

  7. 在端口8080上运行服务器。

当你运行服务器时,你应该看到以下内容:

图 16.8:首次运行服务器时浏览器中的输出

图 16.8:首次运行服务器时浏览器中的输出

如果你刷新页面,你应该看到以下内容:

图 16.9:第二次运行服务器时浏览器中的输出

图 16.9:第二次运行服务器时浏览器中的输出

接下来,通过在地址栏中输入localhost:8080/chapter1来导航到chapter1。你应该能够看到以下类似的内容:

图 16.10:首次访问 chapter1 页面时浏览器中的输出

图 16.10:首次访问 chapter1 页面时浏览器中的输出

类似地,导航到chapter2;你应该能够看到以下关于查看次数的增加:

图 16.11:首次访问 chapter2 页面时浏览器中的输出

图 16.11:首次访问 chapter2 页面时浏览器中的输出

当你再次访问chapter1时,你应该看到查看次数的增加,如下所示:

图 16.12:第二次访问 chapter1 页面时浏览器中的输出

图 16.12:第二次访问 chapter1 页面时浏览器中的输出

注意

本活动的解决方案可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter16/Activity16.01/main.go

在这个活动中,你学习了如何创建一个服务器,该服务器可以针对不同页面上的不同请求返回特定的静态文本,并在每个页面上都有一个计数器,每个计数器与其他计数器独立。

添加中间件

有时,你需要创建很多函数来处理 HTTP 请求,可能是在 URL 的不同路径上提供不同的服务,所有这些都在执行不同的操作。你可能需要创建一个函数来处理返回用户列表的服务器,一个用于项目列表,一个用于更新某些细节的路由,所有这些函数都在做不同的事情。然而,尽管这些函数执行不同的操作,它们也可能有一些共同点。一个常见的例子是,当这些函数需要在安全环境中执行时,这意味着只有登录的用户才能执行。让我们看一个非常简单的例子,并考虑以下两个函数:

http.HandleFunc(
  "/hello1",
  func(w http.ResponseWriter,
  r *http.Request,
){
  msg := "Hello there, this is function 1"
  w.Write([]byte(msg))
})
http.HandleFunc(
  "/hello2",
  func(w http.ResponseWriter,
  r *http.Request,
){
  msg := "Hello there, and now we are in function 2"
  w.Write([]byte(msg))
})

两个函数都将显示以“你好,”开头的句子。让我们找到一种方法来提取这些函数行为的一部分,并创建一个第三函数,该函数将用于执行写入初始欢呼信息的操作:

func Hello(next http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    msg := "Hello there,"
    w.Write([]byte(msg))
    next.ServeHTTP(w, r)
  }
}

此函数具有以下签名:

func Hello(next http.HandlerFunc) http.HandlerFunc

这意味着它被命名为Hello,接受http.HandlerFunc作为参数,并返回对http.HandlerFunc的结果。此参数被称为next,因为它是我们想要运行的下一个函数。让我们看看函数的主体:

  return func(w http.ResponseWriter, r *http.Request) {
    msg := "Hello there,"
    w.Write([]byte(msg))
    next.ServeHTTP(w, r)
  }

正如您所看到的,它返回一个实现http.HandlerFunc类型并具有正确参数和返回类型的函数。这个函数将向响应写入器w写入一条消息,指出“你好,”,然后调用next函数,使用相同的响应写入器和请求调用没有名称的函数。

现在,让我们重构我们的代码,使其更容易阅读。我们将为要执行的操作创建两个函数:

func Function1(w http.ResponseWriter,
  r *http.Request,
) {
  msg := " this is function 1"
  w.Write([]byte(msg))
}
func Function2(w http.ResponseWriter,
  r *http.Request,
) {
  msg := " and now we are in function 2"
  w.Write([]byte(msg))
}

让我们看看到目前为止我们的文件看起来像什么:

package main
import (
  "log"
  "net/http"
)
func Hello(next http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    msg := "Hello there,"
    w.Write([]byte(msg))
    next.ServeHTTP(w, r)
  }
}
func Function1(w http.ResponseWriter,
  r *http.Request,
) {
  msg := " this is function 1"
  w.Write([]byte(msg))
}
func Function2(w http.ResponseWriter,
  r *http.Request,
) {
  msg := " and now we are in function 2"
  w.Write([]byte(msg))
}

正如您所看到的,我们有Hello函数和两个函数,它们向响应写入器返回两个不同的句子。最后一步是将这些函数与一个路径关联起来,如下所示:

func main() {
  http.HandleFunc(
    "/hello1", Function1)
  http.HandleFunc(
    "/hello2", Function2)
  log.Fatal(http.ListenAndServe(":8085", nil))
}

正如您所看到的,我们将函数 1 和函数 2 传递给每个路由。如果您在自己的机器上运行此代码并访问http://localhost:8085/hello1,您将看到一个显示“这是函数 1”的消息。尽管如此,我们还没有使用Hello函数。让我们重写最后一块代码并使用它:

func main() {
  http.HandleFunc(
    "/hello1", Hello(Function1))
  http.HandleFunc(
    "/hello2", Hello(Function2))
  log.Fatal(http.ListenAndServe(":8085", nil))
}

如果您再次运行此程序,您将看到消息已更改为“你好,这是函数 1”。Hello函数实际上在Function1函数之前运行,并在完成自己的工作后,调用Function,以便该函数也可以完成其工作。我们将Hello函数称为Middleware,因为它充当中间人——它捕获请求,做一些工作,然后调用下一个函数。通过这样做,我们可以通过执行如下操作来链式连接许多中间件:

Hello(Middleware2(Middleware3((Function2)))

您可以使用这种模式在需要与 URL 路径关联的实际函数之前或之后执行许多常见操作。

动态内容

仅提供静态内容的服务器很有用,但还有更多的事情可以做。HTTP 服务器可以根据更细粒度的请求交付内容,这是通过向服务器传递一些参数来完成的。有多种方法可以做到这一点,但一种简单的方法是将参数传递给querystring。如果服务器的 URL 如下所示:

http://localhost:8080

然后,我们可以添加如下内容:

http://localhost:8080?name=john

这里,?name=john 被称为 querystring 字符串,因为它是一个表示查询的字符串。在这种情况下,querystring 设置了一个名为 name 的变量,其值为 john。这种方式通常用于 GET 请求,而 POST 请求通常使用请求体来发送参数。我们将首先看看如何接受 GET 请求的参数,因为这种请求是通过简单地在我们的浏览器上打开一个特定地址来进行的。我们将在稍后通过表单查看如何处理 POST 请求。

在下一个练习中,您将学习如何根据用户在地址栏中 querystring 字符串中输入的值返回不同的文本作为 HTTP 请求的响应。

练习 16.03 – 个性化欢迎

在这个练习中,我们将创建一个可以为我们欢呼的 HTTP 服务器,但与一般的 hello world 消息不同,我们将提供一个基于我们名字的消息。想法是,通过在服务器的 URL 上打开浏览器并添加一个名为 name 的参数,服务器将用一条消息欢迎我们,该消息以 hello 开头,后跟 name 参数的值。服务器非常简单,没有子页面,但它包含一个动态元素,这构成了更复杂情况的一个起点:

  1. 创建一个名为 personalised-welcome 的新文件夹,并在文件夹内创建一个名为 main.go 的文件。在文件内添加包名:

    package main
    
  2. 然后,添加所需的导入:

    import (
      "fmt"
      "log"
      "net/http"
      "strings"
    )
    
  3. 这些是我们之前练习和活动中使用的相同导入,所以没有新的内容。在这个练习中,我们不会使用处理器,因为它很小,但我们将利用 http.handleFunc 函数。

  4. 现在,在导入之后添加以下代码:

    func Hello(w http.ResponseWriter, r *http.Request) {
    
  5. 这是定义一个可以作为 HTTP 路径处理函数使用的函数的定义。

  6. 现在,使用请求的 URL 对象上的 Query 方法将查询保存到变量中:

      vl := r.URL.Query()
    
  7. 请求的 URL 对象上的 Query 方法返回一个 map[string][]string 字符串,其中包含通过 URL 中的 querystring 发送的所有参数。然后我们将这个映射分配给一个变量,vl

  8. 在这一点上,我们需要获取一个名为 name 的特定参数的值,所以我们从 name 参数获取值:

      name, ok := vl["name"]
    
  9. 如您所见,我们有两个变量的赋值,但只有一个值来自 vl["name"]。第二个变量 ok 是一个布尔值,它告诉我们 name 键是否存在。

  10. 如果没有传递 name 参数并且我们想要显示错误消息,我们必须在变量未找到时添加它——换句话说,如果 ok 变量为假:

      if !ok {
        w.WriteHeader(400)
        w.Write([]byte("Missing name"))
        return
      }
    
  11. 如果切片中不存在键,则调用条件代码,并将 400 状态码(错误请求)写入标题,以及一条消息到响应写入器,说明没有发送 name 参数。我们使用 return 语句停止执行,以防止进一步的操作。

  12. 在这一点上,向响应写入器写入一个有效的消息:

      w.Write([]byte(fmt.Sprintf("Hello %s", strings.Join(name, ","))))
    }
    
  13. 此代码格式化一个字符串并将名称注入其中。使用fmt.Sprintf函数进行格式化,而使用strings.Joinname切片转换为字符串。请注意,name变量被设置为vl["name"]的值,但vl是一个map[string][]string字符串,这意味着它是一个具有字符串键的映射,其值是字符串切片;因此,vl["name"]是一个字符串切片,需要将其转换为单个字符串。strings.Join函数接受切片的所有元素,并使用","作为分隔符构建一个单独的字符串。也可以使用其他字符作为分隔符。

  14. 你需要编写的文件的最后一部分如下:

    func main() {
      http.HandleFunc("/", Hello)
      log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  15. 如往常一样,创建了一个main()函数,然后将Hello函数与"/"路径关联,并启动了服务器。以下是三个不同 URL 的输出——两个有效的和一个缺少参数的:

图 16.13:请求名为 John 的页面时服务器的输出

图 16.13:请求名为 John 的页面时服务器的输出

前面的图显示了我们将 URL 中的查询参数设置为 John 时的输出。如果我们更改 URL 中查询参数的名称,我们将看到新的值:

图 16.14:请求名为 Will 的页面时服务器的输出

图 16.14:请求名为 Will 的页面时服务器的输出

如果我们不设置查询参数,我们将收到一个错误消息,如下所示:

图 16.15:请求不带名称的页面时服务器输出的错误信息

图 16.15:请求不带名称的页面时服务器输出的错误信息

接下来,我们将探讨模板的概念。

模板化

虽然在需要跨软件程序共享复杂数据结构时,JSON 可能是最佳选择,但在一般情况下,当 HTTP 服务器预期由人类使用时,情况并非如此。在之前的练习和活动中,选择格式化文本的方式是使用fmt.Sprintf函数,这对于格式化文本是好的,但当需要更多动态和复杂的文本时,它就远远不够了。正如你在之前的练习中注意到的,当将名称作为参数传递给观察到的 URL 时,返回的消息遵循一个特定的模式,这就是新概念出现的地方——模板。模板是一个复杂的实体可以从中发展出来的骨架。本质上,模板就像带有一些空白的文本。模板引擎将取一些值并填充这些空白,如下面的图所示:

图 16.16:模板示例

图 16.16:模板示例

如你所见,{{name}}是一个占位符,当通过引擎传递值时,占位符会使用该值进行修改。

我们到处都能看到模板。我们有 Word 文档的模板,我们只需填写缺失的部分,就可以生成新的文档,这些文档彼此都不同。一位老师可能有一些用于他们课程的模板,并从相同的模板中开发不同的课程。Go 提供了两个不同的模板包——一个用于文本,一个用于 HTML。由于我们正在处理 HTTP 服务器,并且我们想要生成一个网页,我们将使用 HTML 模板包,但文本模板库的接口是相同的。尽管模板包对于任何实际应用都足够好,但还可以使用几个其他外部包来提高性能。其中之一是hero模板引擎,它比标准的 Go 模板包快得多。

Go 模板包提供了一个占位符语言,我们可以使用以下内容:

{{name}}

这是一个简单的代码块,它将使模板引擎将name变量替换为提供的值,但更复杂的情况可以通过条件语句来处理:

{{if age}} Hello {{else}} bye {{end}}

在这里,如果age参数不为空,模板将包含Hello;否则,它将包含bye。每个条件都需要一个{{end}}占位符来确定其结束。

模板中的变量不需要是简单的数字或字符串;它们可以是对象。在这种情况下,如果我们有一个名为ID的字段的结构体,我们可以在模板中这样引用这个字段:

{{.ID}}

这非常方便,因为我们可以将一个结构体传递给模板,而不是许多单个参数。

在下一个练习中,你将学习如何使用 Go 的基本模板功能来创建带有自定义消息的页面,就像你之前做的那样,但方式更加优雅。

练习 16.04 – 模板化我们的页面

本练习旨在帮助你构建一个结构更清晰的网页,使用模板,并用 URL 的querystring中的参数填充它。在这种情况下,我们希望显示客户的基本信息,并在数据缺失时隐藏一些信息。客户有idnamesurnameage值,如果这些值中的任何一个缺失,则不会显示。除非数据是id值,就像在这个例子中,将会显示错误信息:

  1. 首先,创建一个包含main.go文件的server-template文件夹。然后,添加通常的包和一些导入:

    package main
    import (
      "html/template"
      "log"
      "net/http"
      "strconv"
      "strings"
    )
    
  2. 在这里,我们使用了两个新的导入:html/template用于模板,strconv用于将字符串转换为数字(这个包也可以反过来工作,但还有更好的文本格式化解决方案)。

  3. 现在,写下以下内容:

    var tplStr = `
    <html>
      <h1>Customer {{.ID}}</h1>
      {{if .ID }}
       <p>Details:</p>
       <ul>
       {{if .Name}}<li>Name: {{.Name}}</li>{{end}}
       {{if .Surname}}<li>Surname: {{.Surname}}</li>{{end}}
       {{if .Age}}<li>Age: {{.Age}}</li>{{end}}
       </ul>
      {{else}}
      <p>Data not available</p>
      {{end}}
    </html>
    `
    
  4. 这是一个包含一些 HTML 和模板代码的原始字符串,这些代码被{{}}包裹。我们现在将分析这个字符串。

  5. {{.ID}}基本上是一个占位符,告诉模板引擎,无论此代码出现在何处,它将被一个名为ID的 struct 属性所替代。Go 模板引擎与 struct 一起工作,所以基本上,一个 struct 将被传递到引擎,其属性值将用于填充占位符。{{if .ID}}是一个条件,告诉模板接下来发生的事情将取决于ID的值。在这种情况下,如果ID不是空字符串,模板将显示客户的详细信息;否则,它将显示<p>Data not available</p>,这是在{{else}}{{end}}占位符之间包装的。如您所见,第一个条件内部嵌套了更多的条件。在每个列表项中,都有一个<li>标签,例如,被{{if .Name}}包装,并以{{end}}结束。

  6. 既然我们已经有了字符串模板,让我们创建一个具有正确属性的 struct。为了填充模板,写下以下内容:

    type Customer struct {
      ID int
      Name string
      Surname string
      Age int
    }
    

    这个 struct 是自我解释的。它包含模板所需的所有属性。

  7. 定义handler函数并将一个变量设置为querystring中的值映射:

    func Hello(w http.ResponseWriter, r *http.Request) {
      vl := r.URL.Query()
    
  8. 实例化一个cust变量,类型为Customer

      cust := Customer{}
    
  9. 变量现在所有属性都已设置为默认值,我们需要从 URL 中获取传递的值。为此,写下以下内容:

      id, ok := vl["id"]
      if ok {
        cust.ID, _ = strconv.Atoi(strings.Join(id, ","))
      }
      name, ok := vl["name"]
      if ok {
        cust.Name = strings.Join(name, ",")
      }
      surname, ok := vl["surname"]
      if ok {
        cust.Surname = strings.Join(surname, ",")
      }
      age, ok := vl["age"]
      if ok {
        cust.Age, _ = strconv.Atoi(strings.Join(age, ""))
      }
    
  10. 如您所见,参数直接从值映射中获取,如果存在,则用于设置相关cust属性的值。为了检查这些参数是否存在,我们再次使用了ok变量,该变量在映射包含请求的键时设置为具有值true的布尔值。最后一个属性Age的处理略有不同:

        cust.Age, _ = strconv.Atoi(strings.Join(age, ""))
    
  11. 这是因为strconv.Atoi在传递的参数不是数字时返回错误。通常,我们应该处理错误,但在这个例子中,我们将忽略它,并且如果提供的年龄不是数字,则不会显示任何与年龄相关的信息。

  12. 接下来,写下以下内容:

      tmpl, _ := template.New("test").Parse(tplStr)
    
  13. 这创建了一个名为test的模板对象,其中包含您最初创建的字符串内容。再次忽略错误,因为我们确信我们编写的模板是有效的。然而,在生产环境中,应该处理所有错误。

  14. 你现在可以完成函数的编写:

      tmpl.Execute(w, cust)
    }
    
  15. 在这里,模板使用cust struct 执行;其内容直接发送到w ResponseWriter,无需手动调用Write方法。

  16. 现在缺少的是main方法,这相当简单。写下以下内容:

    func main() {
      http.HandleFunc("/", Hello)
      log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  17. 简单来说,主路径与Hello函数相关联,然后服务器启动。

  18. 这段代码的性能并不很高,因为我们为每个请求创建了一个模板。这个模板可以在main中创建,然后传递给一个处理程序,这个处理程序可以有一个类似于你刚刚编写的Hello函数的ServeHTTP方法。这里的代码被保持简单,以便专注于模板化。

  19. 现在,如果你启动服务器并访问以下页面,你应该会看到一些类似于以下内容的输出:

图 16.17:带有空白参数的模板响应

图 16.17:带有空白参数的模板响应

现在,你可以在 URL 中添加一个名为id的查询参数,并通过访问localhost:8080/?id=1使其等于1

图 16.18:仅指定 ID 的模板响应

图 16.18:仅指定 ID 的模板响应

然后,你可以通过访问localhost:8080/?id=1&name=John为名称参数添加一个值:

图 16.19:指定 ID 和名称的模板响应

图 16.19:指定 ID 和名称的模板响应

最后,你也可以通过访问localhost:8080/?id=1&name=John&age=40添加年龄:

图 16.20:指定 ID、名称和年龄的模板响应

图 16.20:指定 ID、名称和年龄的模板响应

在这里,如果querystring中的每个参数有效,它都会在 Web 应用程序中显示出来。

静态资源

在这本书中,直到之前的练习为止,你所学到的知识已经足够构建 Web 应用程序和动态网站;你只需要把所有这些部件组合起来。

在本章中你所做的是返回不同的消息,但这些消息都是作为字符串硬编码的。即使是动态消息,也基于在练习和活动源文件中硬编码的模板。现在,让我们考虑一下。对于第一个hello world服务器,消息从未改变。如果我们想修改消息并返回一个Hello galaxy的消息,我们就必须更改代码中的文本,然后重新编译和/或再次运行服务器。如果你想要出售你的简单“hello”服务器并给每个人指定自定义消息的选项呢?当然,你应该把源代码给每个人,这样他们就可以重新编译和运行服务器。

虽然你可能想拥抱开源代码,但这可能不是分发应用程序的理想方式,我们需要找到一种更好的方法来将消息与服务器分离。一个解决方案是提供静态文件,这些文件是由你的程序作为外部资源加载的。这些文件不会改变,不会编译,并且由你的程序加载和处理。一个这样的例子可能是模板,如之前所见,因为它们只是文本,你可以使用模板文件而不是将模板作为文本添加到你的代码中。另一个静态资源的简单例子是,如果你想在你的网页中包含样式文件,如 CSS。你将在接下来的练习和活动中看到如何做到这一点。你将学习如何提供特定的文件或特定的文件夹,然后你将学习如何使用静态模板提供动态文件。

练习 16.05 – 使用静态文件创建 Hello World 服务器

在这个练习中,你将创建你的 Hello World 服务器,但使用静态 HTML 文件。我们想要的是一个简单的服务器,它有一个查找特定文件(具有特定名称)的处理函数,该文件将作为每个路径的输出。在这种情况下,你需要在你的项目中创建多个文件:

  1. 创建一个名为 static-file 的文件夹,并在其中创建一个名为 index.html 的文件。然后,插入以下代码,创建一个相当简单的 HTML 文件,其中包含标题和声明欢迎信息的 h1 标签:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Welcome</title>
    </head>
    <body>
      <h1>Hello World</h1>
    </body>
    </html>
    
  2. 现在,创建一个名为 main.go 的文件,并开始编写必要的导入:

    package main
    import (
      "log"
      "net/http"
    )
    
  3. 现在,编写 main 函数:

    func main() {
    
  4. 接下来,编写 handler 函数:

      http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "./index.html")
      })
    
  5. 这就是魔法发生的地方。这里一个正常的 http.HandleFunc 被调用,其第一个参数是 "/" 路径,之后传递一个处理函数,该函数包含一条指令:

        http.ServeFile(w, r, "./index.html")
    
  6. 这会将 index.html 文件的内容发送到 ResponseWriter

  7. 现在,编写最后一部分:

        log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  8. 如同往常一样,这会启动服务器,在出错时记录,并退出程序。

  9. 现在,保存文件,并使用以下命令运行程序:

    go run main.go
    

    如果你打开浏览器访问 localhost:8080 页面,你应该看到以下内容:

图 16.21:带有静态模板文件的 Hello World

图 16.21:带有静态模板文件的 Hello World

  1. 接下来,在不停止服务器的情况下,只需更改 HTML 文件,即 index.html,并修改第 8 行,在那里你会看到以下内容:

        <h1>Hello World</h1>
    
  2. 更改 <h1> 标签中的文本,如下所示:

        <h1>Hello Galaxy</h1>
    
  3. 保存 index.html 文件,并且不要触摸终端,也不要重新启动服务器,只需在同一页面上刷新浏览器。你现在应该看到以下内容:

图 16.22:带有静态模板文件的 Hello World 服务器

图 16.22:修改后的静态模板文件的 Hello World 服务器

  1. 因此,即使服务器正在运行,它也会获取文件的新版本。

在这个练习中,你学习了如何使用静态 HTML 文件来服务一个网页,以及如何将静态资源从你的应用程序中分离出来,这样你就可以在不重新启动应用程序的情况下更改你提供的服务页面。

获取一些样式

到目前为止,你已经看到了如何服务一个静态页面,你可能考虑使用相同的方法来服务几个页面,也许创建一个具有要服务的文件名称作为属性的处理器结构。对于大量页面来说,这可能不太实用,尽管在某些情况下这是必要的。然而,一个网页不仅仅包含 HTML 代码——它还可能包含图片和样式,以及一些前端代码。

这本书的范围并不包括教你如何构建 HTML 页面,甚至更少教你如何编写 JavaScript 代码或 CSS 样式表,但你需要知道如何作为我们使用一个小 CSS 文件构建示例来服务这些文档。

服务静态文件并将模板放在不同的文件中,或者通常使用外部资源,是在我们的项目中分离关注点的好方法,可以使我们的项目更易于管理和维护,因此你应该尝试在所有项目中遵循这种方法。

要将样式表添加到你的 HTML 页面中,你需要添加一个像这样的标签:

<link rel="stylesheet" href="file.css">

这将 CSS 文件注入页面作为“样式表”,但这在这里只是作为一个例子,以防你对学习如何编写 HTML 感兴趣。

你还看到我们服务了文件,逐个从文件系统中读取它们,但 Go 为我们提供了一个简单的函数来完成这项工作:

http.FileServer(http.Dir("./public"))

实际上,http.FileServer创建了一个它名字所描述的服务外部文件的服务器。它从http.Dir中定义的目录中获取。无论我们在./public目录中放置什么文件,都会在地址栏中自动可访问:

http://localhost:8080/public/myfile.css

这看起来足够好了。然而,在现实世界的场景中,你不想暴露你的文件夹名称,而是为你的静态资源指定一个不同的名称。这可以通过以下方式实现:

http.StripPrefix(
  "/statics/",
  http.FileServer(http.Dir("./public")),
)

你可能已经注意到http.FileServer函数被http.StripPrefix函数包装,我们使用它来将请求的路径与文件系统上的正确文件关联。本质上,我们希望/statics路径可用,并将其绑定到public文件夹的内容。StripPrefix函数将移除请求中的"/statics/"前缀,并将其传递给文件服务器,文件服务器将只获取要服务的文件名,并在public文件夹中搜索它。

如果你不想更改路径和文件夹的名称,则不需要使用这些包装器,但这个解决方案是通用的,并且可以在任何地方使用,因此你可以在其他项目中使用它而不用担心。

练习 16.06 – 一个时尚的欢迎

本练习旨在帮助你显示一个欢迎页面,同时使用一些外部静态资源。我们将采用与练习 16.05相同的方法,但我们将添加一些额外的文件和代码。我们将把一些样式表放在一个statics文件夹中,并且我们将提供它们,以便它们可以被同一服务器提供的其他页面使用:

  1. 作为第一步,创建一个名为stylish-welcome的文件夹,并在该文件夹内添加一个名为index.html的文件。然后,加入以下内容:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Welcome</title>
      <link rel="stylesheet" href="/statics/body.css">
      <link rel="stylesheet" href="/statics/header.css">
      <link rel="stylesheet" href="/statics/text.css">
    </head>
    <body>
      <h1>Hello World</h1>
      <p>May I give you a warm welcome</p>
    </body>
    </html>
    
  2. 如你所见,与之前的 HTML 相比,差异很少;我们有一个包含更多文本的段落,由<p>标签包裹,并且在<head>标签内,我们包含了指向外部资源的三个链接。

  3. 现在,在你的stylish-welcome文件夹内创建一个名为public的文件夹,并在其中创建三个文件,名称和内容如下:

    header.css
    h1 {
      color: brown;
    }
    body.css
    body {
      background-color: beige;
    }
    text.css
    p {
      color: coral;
    }
    
  4. 现在,回到你的主项目文件夹stylish-welcome,并创建一个名为main.go的文件。文件开头的内容与之前的某个练习中的内容完全一致:

    package main
    import (
      "log"
      "net/http"
    )
    func main() {
      http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "./index.html")
      })
    
  5. 现在,添加以下代码来处理静态文件:

      http.Handle(
        "/statics/",
        http.StripPrefix(
        "/statics/",
        http.FileServer(http.Dir("./public")),
      ),
    )
    
  6. 此代码为/statics/路径添加了一个处理器,通过http.FileServer函数实现,该函数返回一个静态文件处理器。

  7. 此功能需要一个用于抓取的目录,我们将一个参数传递给它:

      http.Dir("./statics")
    
  8. 这将读取你之前创建的本地public文件夹。

  9. 现在,将以下最后部分添加到文件中:

      log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  10. 这里,服务器再次创建,main()函数关闭。现在,再次运行服务器:

    go run main.go
    
  11. 你将看到以下输出:

图 16.23:样式化的主页

图 16.23:样式化的主页

某种程度上,HTML 文件现在正在使用你最初创建的样式表中的样式。

  1. 现在,让我们看看文件是如何注入的。如果你回顾index.html文件,你会看到这些行:

    <link rel="stylesheet" href="/statics/body.css">
    <link rel="stylesheet" href="/statics/header.css">
    <link rel="stylesheet" href="/statics/text.css">
    
  2. 因此,本质上,我们正在寻找"/statics/"路径下的文件。第一个地址将显示页面主体的 CSS 内容:

图 16.24:主体 CSS 文件

图 16.24:主体 CSS 文件

第二个显示了页面标题的 CSS:

图 16.25:标题 CSS 文件

图 16.25:标题 CSS 文件

最后,我们有页面上的文本 CSS。因此,所有的样式表都已提供:

图 16.26:文本 CSS 文件

图 16.26:文本 CSS 文件

  1. 此外,你甚至可以访问这里:

图 16.27:在浏览器中可见的静态文件夹内容

图 16.27:在浏览器中可见的静态文件夹内容

  1. 你会看到public文件夹内的所有文件都在/statics/路径下提供。如果你在寻找一个简单的静态文件服务器,Go 通过几行代码允许你创建一个,并且通过更多的代码,你可以使其适用于生产环境。

  2. 如果您使用 Chrome,可以通过右键单击使用鼠标进行检查,尽管如果您有开发者工具,任何浏览器都可以做到这一点。您将看到以下类似的内容:

图 16.28:开发者工具显示加载的脚本

图 16.28:开发者工具显示加载的脚本

如您所见,文件已加载,样式显示为从右侧的样式表中计算得出。

变得动态

静态资源通常按原样提供服务,但当你想创建一个动态页面时,你可能想使用外部模板,这样你就可以即时使用它,这样你就可以在不重新启动服务器的情况下更改模板,或者你可以在启动时加载,这意味着您在更改后必须重新启动服务器(这并不完全正确,但我们需要一些并发编程的概念来实现这一点)。在启动时加载文件只是为了性能原因。文件系统操作总是最慢的,即使 Go 是一种相当快的语言,当您想要提供页面时,您可能仍然需要考虑性能,尤其是如果您有来自多个客户端的大量请求。

如您所回忆的,我们使用了标准的 Go 模板来创建动态页面。现在,我们可以将模板作为外部资源使用,将模板代码放入 HTML 文件中,并加载它。模板引擎可以解析它,然后使用传递的参数填充空白。为此,我们可以使用html/template函数:

func ParseFiles(filenames ...string) (*Template, error)

例如,可以使用以下代码调用:

template.ParseFiles("mytemplate.html")

此外,模板被加载到内存中,并准备好使用。

到目前为止,您一直是您 HTTP 服务器的唯一用户,但在实际场景中,情况不会是这样。在接下来的示例中,我们将查看性能,并使用启动时加载的资源。

活动十六.02 – 外部模板

在此活动中,您将创建一个欢迎服务器,类似于您之前创建的,您将不得不使用模板包,就像您之前做的那样。然而,在此活动中,我们不想让您从硬编码的字符串创建模板,而是从 HTML 文件创建,该文件将包含所有模板占位符。

您应该能够利用本章和上一章所学的内容完成此活动。

此活动从文件名列表返回template指针和一个错误。如果任何文件不存在或模板格式错误,则返回错误。在任何情况下,都不要担心添加多个文件的可能性。坚持使用一个。

完成此活动的步骤如下:

  1. 为您的项目创建一个文件夹。

  2. 创建一个名为index.html的模板,并用标准的 HTML 代码填充,包括欢迎信息和名称的占位符。确保如果名称为空,消息在名称应出现的位置插入单词visitor

  3. 创建你的 main.go 文件,并向其中添加正确的包和导入。

  4. main.go 文件中,创建一个包含可以传递给模板的名称的结构体。

  5. 使用你的 index.html 文件从文件创建一个模板。

  6. 创建能够处理 HTTP 请求的东西,并使用 querystring 接收参数,通过你之前创建的模板显示数据。

  7. 将所有路径设置到服务器上,以便你可以使用你在上一步中创建的函数或处理器;然后,创建服务器。

  8. 运行服务器并检查结果。输出将如下所示:

图 16.29:匿名访客页面

图 16.29:匿名访客页面

访客页面,包括显示的名称,看起来可能如下所示:

图 16.30:名为“Will”的访客页面

图 16.30:名为“Will”的访客页面

注意

该活动的解决方案可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter16/Activity16.02

在这个活动中,你学习了如何创建一个模板化的 HTTP 处理器作为结构体,它可以初始化为任何外部模板。你现在可以创建多个页面,使用不同的模板实例化相同的结构体。

外部文件嵌入

在前面的章节中,你了解了一种非常有趣的技术,但读取外部文件在部署到生产环境时可能会出现问题,尤其是在使用 Go 语言时,Go 语言的一个强大特性是构建单个可执行文件。幸运的是,Go 语言中有一个名为 embed 的包,它允许我们将外部文件添加到我们的最终二进制文件中,这样我们在开发时不需要原始文件,但也不需要与他人共享此文件,因为它将被编译并添加到我们的最终二进制文件中。让我们看看它是如何工作的。

让我们假设你有一个简单的模板文件,并想在你的 web 服务器上使用它:

mytemplate.html
<h1>{{.Text}}</h1>

让我们看看一个小程序,它正好做了这件事,使用了你在上一章中学到的知识:

package main
import (
  "html/template"
  "log"
  "net/http"
)
func main() {
  t, _ := template.ParseFiles("mytemplate.html")
  http.HandleFunc(
    "/hello1", func(w http.ResponseWriter,
      r *http.Request,
    ) {
      data := struct {
        text string
      }{
        text: "Hello there",
      }
      t.Execute(w, data)
    })
  log.Fatal(http.ListenAndServe(":8085", nil))
}

如果你运行此代码,程序将解析你的文件夹中的文件,并将其用作模板在 /hello1 路径上显示 Hello there。然而,如果你构建你的应用程序并将可执行文件移动到不同的文件夹,你将收到一个错误。让我们修改这个软件,使其使用 embed 包:

package main
import (
  _ "embed"
  "html/template"
  "log"
  "net/http"
)
//go:embed mytemplate.html
var s string
func main() {
  t, _ := template.New("mytemplate").Parse(s)
  http.HandleFunc(
    "/hello1", func(w http.ResponseWriter,
      r *http.Request,
    ) {
      data := struct {
        text string
      }{
        text: "Hello there",
      }
      t.Execute(w, data)
    })
  log.Fatal(http.ListenAndServe(":8085", nil))
}

差异在于我们刚刚创建了一个全局变量 s,它包含 mytemplate.html 文件的内容,并在你使用 //go:embed 构建标签指令编译代码时将其存储在二进制文件中:

_ "embed"
//go:embed mytemplate.html
var s string
t, _ := template.New("mytemplate").Parse(s)

最后,我们使用 New 方法创建一个模板,然后解析字符串。如果你编译代码并从不同的文件夹运行你的应用程序,你将不会遇到任何错误。

摘要

在本章中,你被引入了 Web 编程的服务器端。你学习了如何接受来自 HTTP 客户端的请求并做出相应的响应。你还学习了如何通过路径和子路径将可能的请求分离到 HTTP 服务器的不同区域。为此,你使用了一个简单的路由机制,即标准的Go HTTP包。

然后,你学习了如何根据不同的消费者返回响应:为合成客户端返回 JSON 响应,为人类访问返回 HTML 页面。

接下来,你学习了如何使用模板来格式化你的纯文本和 HTML 消息,使用的是标准的模板包。你学习了如何提供和使用静态资源,可以直接通过默认文件服务器或模板对象来提供服务。

之后,你学习了如何创建中间件以及如何将外部文件嵌入到你的二进制文件中以实现更好的可移植性。在这个阶段,你已经掌握了构建生产级别 HTTP 服务器的基础知识,尽管你可能希望使用一些外部库来简化你的 Hello World 示例,例如使用 gorilla mux 或通常的gorilla包,这是一个在http包之上的低级抽象。你可以使用hero作为模板引擎来加快页面渲染速度。

有一点需要提及的是,你可以利用本章所学的内容创建几乎无状态的服务,但目前你还不能创建一个生产级别的有状态服务器,因为你不知道如何处理并发请求。这意味着视图计数器还不适合用于生产服务器,但这将是下一章的主题。

在下一章中,你将转换方向,学习如何使用 Go HTTP 客户端通过互联网与其他系统通信。

第十七章:使用 Go HTTP 客户端

概述

本章将使您能够使用 Go HTTP 客户端通过互联网与其他系统进行通信。

您将首先学习如何使用 HTTP 客户端从 Web 服务器获取数据并向 Web 服务器发送数据。到本章结束时,您将能够将文件上传到 Web 服务器,并使用自定义的 Go HTTP 客户端与 Web 服务器进行交互。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter17

简介

在上一章中,您学习了使用 REST 的 Web API。您了解了 REST 是什么,如何考虑 API 设计、REST 中的资源以及错误,以及 REST 的问题和它的某些替代方案。

在本章中,您将了解 Go HTTP 客户端及其使用方法。HTTP 客户端是用来从 Web 服务器获取数据或向 Web 服务器发送数据的东西。最著名的 HTTP 客户端例子可能是网络浏览器(如 Firefox、Chrome 和 Microsoft Edge)。当您在浏览器中输入网址时,它将内置一个 HTTP 客户端,向服务器发送请求以获取数据。服务器将收集数据并发送回 HTTP 客户端,然后 HTTP 客户端将在浏览器中显示网页。同样,当您在浏览器中填写表单时,例如登录网站时,浏览器将使用其 HTTP 客户端将表单数据发送到服务器,并根据响应采取适当的行动。

本章将探讨您如何使用 Go HTTP 客户端从 Web 服务器请求数据并向服务器发送数据。您将检查您可以使用 HTTP 客户端与 Web 服务器交互的不同方式以及这些交互的各种用例。Web 浏览器示例将有助于解释不同的交互。作为本章的一部分,您将创建自己的 Go 程序,利用 Go HTTP 客户端从 Web 服务器发送和接收数据。

Go HTTP 客户端及其用途

Go HTTP 客户端是 Go 标准库的一部分,具体是 net/http 包。使用它主要有两种方式。第一种是使用 net/http 包中包含的默认 HTTP 客户端。它使用简单,让您能够快速启动并运行。第二种方式是基于默认 HTTP 客户端创建自己的 HTTP 客户端。这允许您自定义请求和各种其他事情。配置起来需要更长的时间,但它为您提供了对发送的请求的更多自由和控制。

当使用 HTTP 客户端时,您可以发送不同类型的请求。虽然有许多类型的请求,但我们将讨论两种主要的请求:GET 请求和 POST 请求。例如,如果您想从服务器检索数据,您将发送一个 GET 请求。当您在网页浏览器中输入网址时,它将向该地址的服务器发送 GET 请求,然后显示它返回的数据。如果您想向服务器发送数据,您将发送一个 POST 请求。如果您想登录到网站,您将把您的登录详情 POST 到服务器。

在本章中,有一些练习旨在向您介绍 Go HTTP 客户端。它们将教会您如何使用 GET 请求从服务器以各种格式请求数据。它们还将教会您如何将表单数据 POST 到 Web 服务器,类似于当您登录到网站时,网络浏览器如何发送 POST 请求。这些练习还将向您展示如何将文件上传到 Web 服务器,以及如何使用自定义的 HTTP 客户端来对您发送的请求有更多的控制。

向服务器发送请求

当您想从 Web 服务器检索数据时,您会向服务器发送 GET 请求。在发送请求时,URL 将包含您想要数据的资源信息。URL 可以分解为几个关键部分。这包括协议、主机名、URI 和查询参数。它的格式看起来像这样:

图 17.1:URL 格式分解

图 17.1:URL 格式分解

在这个例子中,我们可以看到以下内容:

  • Protocol告诉客户端如何连接到服务器。最常用的两种协议是 HTTP 和 HTTPS。在这个例子中,我们使用了https

  • Hostname是我们想要连接的服务器的地址。在这个例子中,它是example.com

  • URI/downloads

  • Query Parameters告诉服务器它需要任何额外的信息。在这个例子中,我们有两个查询参数。这些是filter=latestos=windows。您会注意到它们与 URI 由?分隔。这样,服务器就可以从请求中解析它们。我们将任何额外的参数与 URI 的末尾通过&符号连接,就像os参数中看到的那样。

练习 17.01 – 使用 Go HTTP 客户端向 Web 服务器发送 GET 请求

在这个练习中,您将从 Web 服务器获取数据并打印出这些数据。您将向www.google.com发送 GET 请求并显示 Web 服务器返回的数据:

  1. 创建一个新的目录,Exercise17.01。在该目录中,创建一个新的 Go 文件,名为main.go

  2. 由于这是一个新的程序,您将想要将文件的包设置为main()函数。导入net/http包、log包和io包。输入以下代码:

    package main
    import (
      "io"
      "log"
      "net/http"
    )
    

    现在您已经设置了所需的包和导入,您可以从创建一个从网络服务器获取数据的函数开始。您将要创建的函数将从网络服务器请求数据。

  3. 创建一个返回字符串的函数:

    func getDataAndReturnResponse() string {
    
  4. 在那个函数中,您可以使用默认的 Go HTTP 客户端从服务器请求数据。在这个练习中,您将请求 www.google.com 的数据。要从网络服务器请求数据,您使用 http 包中的 Get 函数,它看起来如下:

      r, err := http.Get("https://www.google.com")
      if err != nil {
        log.Fatal(err)
      }
    
  5. 服务器发送回的数据包含在 r.Body 中,所以您只需读取该数据。要读取 r.Body 中的数据,您可以使用 io 包中的 ReadAll 函数。这两个函数放在一起将如下所示:

      defer r.Body.Close()
      data, err := io.ReadAll(r.Body)
      if err != nil {
        log.Fatal(err)
      }
    
  6. 在您从服务器收到响应并读取数据后,您只需将数据作为字符串返回,如下所示:

      return string(data)
    }
    

    您现在创建的函数将看起来如下:

    func getDataAndReturnResponse() string {
      // send the GET request
      r, err := http.Get("https://www.google.com")
      if err != nil {
        log.Fatal(err)
      }
      // get data from the response body
      defer r.Body.Close()
      data, err := io.ReadAll(r.Body)
      if err != nil {
        log.Fatal(err)
      }
      // return the response data
      return string(data)
    }
    
  7. 创建一个 main 函数。在 main 函数中,调用 getDataAndReturnResponse 函数并记录它返回的字符串:

    func main() {
      data := getDataAndReturnResponse()
      log.Println(data)
    }
    
  8. 要运行程序,请打开您的终端,并导航到您创建 main.go 文件的目录。

  9. 运行 go run main.go 来编译和执行文件:

    go run main.go
    

    程序将向 www.google.com 发送 GET 请求,并在您的终端中记录响应。

    虽然它看起来像是乱码,但如果您将那些数据保存到名为 response.html 的文件中,并在您的网页浏览器中打开它,它将类似于谷歌首页。这就是当您打开网页时,您的网页浏览器在底层会做什么。它会向服务器发送一个 GET 请求,然后显示它返回的数据。如果我们手动这样做,它将如下所示:

图 17.2:在 Chrome 中查看请求的 HTML 响应

图 17.2:在 Chrome 中查看请求的 HTML 响应

在这个练习中,我们看到了如何向网络服务器发送 GET 请求并获取数据。您创建了一个 Go 程序,向 www.google.com 发送请求,并获取了谷歌首页的 HTML 数据。

结构化数据

一旦您从服务器请求数据,返回的数据可以以各种格式出现。例如,如果您向 packtpub.com 发送请求,它将返回 Packt 网站的 HTML 数据。虽然 HTML 数据对于显示网站很有用,但它并不适合发送机器可读数据。在 Web API 中常用的数据类型是 JSON。JSON 为机器可读和人类可读的数据提供了良好的结构。稍后,您将学习如何使用 Go 解析 JSON 并利用它。

练习 17.02 – 使用结构化数据与 HTTP 客户端

在这个练习中,您将使用 Go 解析结构化 JSON 数据。服务器将返回 JSON 数据,您将使用 json.Unmarshal 函数来解析数据并将它们放入结构体中:

  1. 创建一个新的目录,Exercise17.02。在该目录内,再创建两个子目录,serverclient。然后,在server目录内,创建一个名为server.go的文件,并写入以下代码:

    package main
    import (
      "log"
      "net/http"
    )
    type server struct{}
    func (srv server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      msg := "{\"message\": \"hello world\"}"
      w.Write([]byte(msg))
    }
    func main() {
      log.Fatal(http.ListenAndServe(":8080", server{}))
    }
    

    这创建了一个非常基本的网络服务器,它会发送回 JSON 数据。目前,我们只是将其用作示例。

  2. 一旦你创建了服务器,导航到客户端目录并创建一个名为client.go的文件。添加package main,并导入文件所需的包:

    package main
    import (
      "encoding/json"
      "fmt"
      "io"
      "log"
      "net/http"
    )
    
  3. 然后,创建一个带有字符串参数的结构体,它可以接受来自服务器的响应。你必须使用导出字段来编码或解码 JSON。字段必须以大写字母开头才能被导出。然后,添加一个结构体标签来自定义编码的 JSON 键名。如果 JSON 字段的名称与 Go 结构体中的字段名称不同,这允许我们将该 JSON 字段链接到结构体中的字段。在我们的例子中,名称是相同的。我们可以省略结构体标签,但最好显式使用 JSON 标签以使其更清晰:

    type messageData struct {
      Message string `json:"message"`
    }
    
  4. 接下来,创建一个你可以调用的函数来获取和解析来自服务器的数据。使用你刚刚创建的结构体作为返回值:

    func getDataAndReturnResponse() messageData {
    

    当你运行网络服务器时,它将监听http://localhost:8080。因此,你需要向该 URL 发送一个 GET 请求,然后读取响应体:

      r, err := http.Get("http://localhost:8080")
      if err != nil {
        log.Fatal(err)
      }
      defer r.Body.Close()
      data, err := io.ReadAll(r.Body)
      if err != nil {
        log.Fatal(err)
      }
    
  5. 然而,这次你将解析响应而不是简单地返回它。为此,你创建了一个你创建的结构体实例,然后将它连同响应数据一起传递给json.Unmarshal

      message := messageData{}
      err = json.Unmarshal(data, &message)
      if err != nil {
        log.Fatal(err)
      }
    

    这将使用来自服务器的数据填充message变量。

  6. 然后你需要返回结构体以完成函数:

      return message
    }
    
  7. 最后,从main()函数中调用你刚刚创建的函数并记录来自服务器的消息:

    func main() {
      data := getDataAndReturnResponse()
      fmt.Println(data.Message)
    }
    
  8. 要运行此代码,你需要进行两个步骤。第一步是在你的终端中导航到server目录并运行以下命令。这将启动网络服务器:

    go run server.go
    
  9. 在第二个终端窗口中,导航到client目录并运行go run main.go。这将启动客户端并连接到服务器。它应该输出以下来自服务器的消息:

图 17.3:预期输出

图 17.3:预期输出

在这个练习中,你向服务器发送了一个 GET 请求,并收到了以 JSON 格式结构化的数据。然后你解析了这些 JSON 数据以获取其中的消息。

活动 17.01 – 从网络服务器请求数据并处理响应

想象你正在与一个 Web API 交互。你发送一个 GET 请求以获取数据,并返回一个包含名称的数组。你需要计数这些名称以找出你有多少个每种名称。在这个活动中,你将做这件事。你将向服务器发送一个 GET 请求,获取结构化的 JSON 数据,解析数据,并计算在响应中返回了多少个每种名称:

  1. 创建一个名为Activity17.01的目录。

  2. 创建两个子目录,一个名为client,另一个名为server

  3. server 目录中创建一个名为 server.go 的文件。

  4. server.go 中添加服务器代码。

  5. 在服务器目录中通过调用 go run server.go 来启动服务器。

  6. client 目录中创建一个名为 client.go 的文件。

  7. client.go 中添加必要的导入。

  8. 创建用于解析响应数据的结构体。

  9. 创建一个名为 getDataAndParseResponse 的函数,它返回两个整数。

  10. 向服务器发送一个 GET 请求。

  11. 将响应解析到结构体中。以下是一个数据将呈现的示例:

    {"names":["Electric","Electric","Electric","Boogaloo","Boogaloo","Boogaloo","Boogaloo"]}
    
  12. 遍历结构体并计算 ElectricBoogaloo 名称的出现次数。

  13. 返回计数。

  14. 打印计数。

预期的输出如下:

图 17.4:可能的输出

图 17.4:可能的输出

重要提示

该活动的解决方案可以在 GitHub 仓库中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter17/Activity17.01

在这个活动中,我们从一个 Web 服务器请求数据,并使用 Go HTTP 客户端处理它返回的数据。

向服务器发送数据

除了从服务器请求数据外,你还会想向服务器发送数据。最常见的方法是通过 POST 请求。POST 请求有两个主要部分:URL 和主体。POST 请求的主体是你放置要发送到服务器的数据的地方。一个常见的例子是登录表单。当我们发送登录请求时,我们将主体 POST 到 URL。Web 服务器将检查主体中的登录详情是否正确,并更新我们的登录状态。它通过告诉客户端是否成功来响应请求。在本节中,你将学习如何使用 POST 请求向服务器发送数据。

练习 17.03 – 使用 Go HTTP 客户端向 Web 服务器发送 POST 请求

在这个练习中,你将向一个包含消息的 Web 服务器发送一个 POST 请求。Web 服务器将回送相同的消息,这样你就可以确认它已经接收到了:

  1. 创建一个新的目录,Exercise17.03。在该目录中,创建两个额外的目录,serverclient。然后在 server 目录中创建一个名为 server.go 的文件,并编写以下代码。我们将忽略错误处理以保持练习代码的简洁性,但在实际应用中,请确保处理错误:

    package main
    import (
      "encoding/json"
      "log"
      "net/http"
    )
    type server struct{}
    type messageData struct {
      Message string `json:"message"`
    }
    func (srv server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      jsonDecoder := json.NewDecoder(r.Body)
      messageData := messageData{}
      err := jsonDecoder.Decode(&messageData)
      if err != nil {
        log.Fatal(err)
      }
      jsonBytes, _ := json.Marshal(messageData)
      log.Println(string(jsonBytes))
      w.Write(jsonBytes)
    }
    func main() {
      log.Fatal(http.ListenAndServe(":8080", server{}))
    }
    

    这创建了一个非常基础的 Web 服务器,它接收一个 JSON POST 请求并将发送给它的消息返回给客户端。

  2. 一旦创建了服务器。导航到客户端目录并创建一个名为 client.go 的文件。添加 package main 和文件所需的导入:

    package main
    import (
      "bytes"
      "encoding/json"
      "fmt"
      "io"
      "log"
      "net/http"
    )
    
  3. 接下来,你需要创建一个结构体来发送和接收我们想要的数据。这将与服务器用于解析请求的结构体相同:

    type messageData struct {
      Message string `json:"message"`
    }
    
  4. 然后,你需要创建一个函数来将数据 POST 到服务器。它应该接受一个messageData结构体参数,并返回一个messageData结构体:

    func postDataAndReturnResponse(msg messageData) messageData {
    
  5. 要将数据发送到服务器,你需要将结构体序列化为客户端可以发送给服务器的字节。为此,你可以使用json.Marshal函数。注意,我们在这里故意省略了错误处理,但通常你将处理错误:

      jsonBytes, _ := json.Marshal(msg)
    
  6. 现在你有了字节,你可以使用http.Post函数发送 POST 请求。在请求中,你只需要告诉函数要发送到哪个 URL,你发送的数据类型,以及你想要发送的数据。在这种情况下,URL 是http://localhost:8080。你发送的内容是application/json,数据是刚刚创建的jsonBytes变量。组合起来,看起来是这样的:

      r, err := http.Post("http://localhost:8080", "application/json", bytes.NewBuffer(jsonBytes))
      if err != nil {
        log.Fatal(err)
      }
    
  7. 之后,函数的其余部分与上一个练习相同。你读取响应,解析数据,然后返回数据,看起来是这样的:

      defer r.Body.Close()
      data, err := io.ReadAll(r.Body)
      if err != nil {
        log.Fatal(err)
      }
      message := messageData{}
      err = json.Unmarshal(data, &message)
      if err != nil {
        log.Fatal(err)
      }
      return message
    }
    
  8. 然后,你需要从你的main函数中调用postDataAndReturnResponse函数。然而,这次你需要将你想要发送的消息传递给函数。你只需要创建一个messageData结构体的实例,并在调用函数时将其传递,如下所示:

    func main() {
      msg := messageData{Message: "Hi Server!"}
      data := postDataAndReturnResponse(msg)
      fmt.Println(data.Message)
    }
    
  9. 要运行这个练习,你需要执行两个步骤。第一步是在你的终端中导航到server目录并运行go run server.go。这将启动 Web 服务器。在第二个终端窗口中,导航到client目录并运行go run main.go。这将启动客户端并连接到服务器。它应该从服务器输出以下消息:

图 17.5:预期输出

图 17.5:预期输出

在这个练习中,你向服务器发送了一个 POST 请求。服务器解析了请求,并将相同的信息发送回你。如果你更改发送给服务器的消息,你应该看到服务器发送回的新消息的响应。

在 POST 请求中上传文件

你可能想要发送到 Web 服务器的数据的另一个常见例子是从你的本地计算机上的文件。这就是网站允许用户上传他们的照片等的方式。正如你可以想象的那样,这比发送简单的表单数据要复杂得多。为了实现这一点,首先需要读取文件,然后将其包装成服务器可以理解的形式。然后,它可以在所谓的多部分表单中作为 POST 请求发送到服务器。你将学习如何使用 Go 读取文件并将其上传到服务器。

练习 17.04 – 通过 POST 请求将文件上传到 Web 服务器

在这个练习中,你将读取本地文件并将其上传到 Web 服务器。然后你可以检查 Web 服务器是否保存了你上传的文件:

创建一个新的目录,Exercise17.04。在该目录内,创建两个额外的目录,serverclient。然后,在server目录内,创建一个名为server.go的文件并写入以下代码:

func (srv server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  uploadedFile, uploadedFileHeader, err := r.FormFile("myFile")
  if err != nil {
    log.Fatal(err)
  }
  defer uploadedFile.Close()
  fileContent, err := io.ReadAll(uploadedFile)
  if err != nil {
    log.Fatal(err)
  }
  1. 此步骤的完整代码可在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter17

    这创建了一个非常基本的 Web 服务器,它可以接收多部分表单 POST 请求并在表单中保存文件。多部分表单是一种用于在 HTTP 请求中编码文件或二进制数据以及文本键值对的 MIME 类型。我们使用这种编码将文件作为表单数据的一部分,因为这是处理 HTML 表单中文件上传的标准方式。

  2. 一旦您创建了服务器,导航到客户端目录并创建一个名为client.go的文件。添加package main和文件所需的导入:

    package main
    import (
      "bytes"
      "fmt"
      "io"
      "io"
      "log"
      "mime/multipart"
      "net/http"
      "os"
    )
    
  3. 然后您需要创建一个函数来调用,并将文件名提供给该函数。该函数将读取文件,将其上传到服务器,并返回服务器的响应:

    func postFileAndReturnResponse(filename string) string {
    
  4. 您需要创建一个缓冲区,可以将文件字节写入其中,然后创建一个写入器,以便允许字节写入:

      fileDataBuffer := bytes.Buffer{}
      multipartWriter := multipart.NewWriter(&fileDataBuffer)
    
  5. 使用以下命令从您的本地计算机打开文件:

      file, err := os.Open(filename)
      if err != nil {
        log.Fatal(err)
      }
    
  6. 一旦您打开了本地文件,您需要创建formFile。这会将文件数据包装在正确的格式中,以便上传到服务器:

      formFile, err := multipartWriter.CreateFormFile("myFile", file.Name())
      if err != nil {
        log.Fatal(err)
      }
    
  7. 将本地文件的字节复制到表单文件中,然后关闭表单文件写入器,以便它知道不会再添加更多数据:

      _, err = io.Copy(formFile, file)
      if err != nil {
        log.Fatal(err)
      }
      multipartWriter.Close()
    
  8. 接下来,您需要创建要发送到服务器的 POST 请求。在前面的练习中,我们使用了如http.Post之类的快捷函数。然而,在这个练习中,我们需要对发送的数据有更多的控制。这意味着我们需要创建http.Request。在这种情况下,您正在创建一个将发送到http://localhost:8080的 POST 请求。由于我们正在上传文件,字节缓冲区也需要包含在请求中;它看起来如下所示:

      req, err := http.NewRequest("POST", "http://localhost:8080", &fileDataBuffer)
      if err != nil {
        log.Fatal(err)
      }
    
  9. 然后您需要设置Content-Type请求头。这会告诉服务器关于文件内容的信息,以便它知道如何处理上传:

      req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
    
  10. 按照以下方式发送请求:

      response, err := http.DefaultClient.Do(req)
      if err != nil {
        log.Fatal(err)
      }
    
  11. 在您发送请求后,我们可以读取响应并返回其中的数据:

      defer response.Body.Close()
      data, err := io.ReadAll(response.Body)
      if err != nil {
        log.Fatal(err)
      }
      return string(data)
    }
    
  12. 最后,您只需调用postFileAndReturnResponse函数并告诉它要上传哪个文件:

    func main() {
      data := postFileAndReturnResponse("./test.txt")
      fmt.Println(data)
    }
    
  13. 要运行此操作,您需要执行两个步骤。第一步是在您的终端中导航到server目录并运行go run server.go。这将启动 Web 服务器:

    go run server.go
    
  14. 接下来,在client目录中创建一个名为test.txt的文件,并在其中放入几行文本。

  15. 在第二个终端窗口中,导航到client目录并运行go run client.go。这将启动客户端并连接到服务器:

    go run client.go
    
  16. 然后,客户端将读取test.txt并将其上传到服务器。客户端应给出以下输出:

图 17.6:期望的客户端输出

图 17.6:期望的客户端输出

然后,如果你导航到server目录,你应该会看到test.txt文件已经出现:

图 17.7:期望的文本文件存在

图 17.7:期望的文本文件存在

在这个练习中,你使用 Go HTTP 客户端向 Web 服务器发送了一个文件。这种方法对小文件效果很好,但在处理大文件时可能会导致内存溢出。因此,如果你使用大文件,可能需要修改代码以使用替代方法,例如os.Pipe()。在这个例子中,对于小文件,你从磁盘读取文件,将其格式化为 POST 请求,并将数据发送到服务器。你看到了多部分表单文件上传的使用。我们还看到了 Go http.DefaultClient的使用。DefaultClient效果很好;然而,它永远不应该在生产环境中使用,因为它没有为 HTTP 请求设置默认超时。在发起 HTTP 请求时,设置超时至关重要,以确保在出现网络问题或服务器无响应的情况下,你的应用程序不会无限期地挂起。

自定义请求头

请求有时不仅仅是请求或发送数据。这些信息存储在请求头中。一个非常常见的例子是授权头。当你登录到服务器时,它将响应一个授权令牌。在所有未来发送到服务器的请求中,你都会在请求头中包含这个令牌,以便服务器知道是你发起的请求。你将在稍后学习如何将授权令牌添加到请求中。

练习 17.05 – 使用 Go HTTP 客户端的自定义头和选项

在这个练习中,你将创建自己的 HTTP 客户端并为其设置自定义选项。你还需要在请求头中设置一个授权令牌,以便服务器知道是你请求的数据:

  1. 创建一个新的目录,Exercise17.05。在该目录中,创建另外两个目录,serverclient。然后,在server目录中,创建一个名为server.go的文件,并编写以下代码:

    package main
    import (
      "log"
      "net/http"
      "time"
    )
    type server struct{}
    func (srv server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
      auth := r.Header.Get("Authorization")
      if auth != "superSecretToken" {
        w.WriteHeader(http.StatusUnauthorized)
        w.Write([]byte("Authorization token not recognized"))
        return
      }
      time.Sleep(10 * time.Second)
      msg := "hello client!"
      w.Write([]byte(msg))
    }
    func main() {
      log.Fatal(http.ListenAndServe(":8080", server{}))
    }
    

    这创建了一个非常基本的 Web 服务器,它接收请求,检查授权头是否正确,等待 10 秒,然后发送回数据。

  2. 一旦创建了服务器,导航到客户端目录并创建一个名为client.go的文件。添加package main和文件所需的导入:

    package main
    import (
      "fmt"
      "io"
      "log"
      "net/http"
      "time"
    )
    
  3. 然后,你需要创建一个函数,该函数将创建 HTTP 客户端,设置超时限制,并设置授权头:

    func getDataWithCustomOptionsAndReturnResponse() string {
    
  4. 你需要创建自己的 HTTP 客户端并将超时设置为 11 秒:

      client := http.Client{Timeout: 11 * time.Second}
    
  5. 你还需要创建一个请求以将其发送到服务器。你应该创建一个带有 URL http://localhost:8080 的 GET 请求。在这个请求中不会发送任何数据,因此数据可以设置为 nil。你可以使用http.NewRequest函数来完成此操作:

      req, err := http.NewRequest("POST", "http://localhost:8080", nil)
      if err != nil {
        log.Fatal(err)
      }
    
  6. 如果你再次查看服务器代码,你会注意到它检查Authorization请求头,并期望其值为superSecretToken。因此,你需要在你的请求中设置Authorization头:

      req.Header.Set("Authorization", "superSecretToken")
    
  7. 然后你让创建的客户端执行请求:

      resp, err := client.Do(req)
      if err != nil {
        log.Fatal(err)
      }
    
  8. 然后,你需要读取来自服务器的响应并返回数据:

      defer resp.Body.Close()
      data, err := io.ReadAll(resp.Body)
      if err != nil {
        log.Fatal(err)
      }
      return string(data)
    }
    
  9. 最后,你需要从main函数中调用你刚刚创建的函数并记录它返回的数据:

    func main() {
      data := getDataWithCustomOptionsAndReturnResponse()
      fmt.Println(data)
    }
    
  10. 要运行此练习,你需要执行两个步骤。第一步是在你的终端中导航到server目录并运行go run server.go。这将启动 Web 服务器。

  11. 在第二个终端窗口中,导航到你创建的client目录。

  12. 要执行客户端,请运行以下命令:

    go run client.go
    

这将启动客户端并连接到服务器。客户端将向服务器发送请求,并在 10 秒后输出以下内容:

图 17.8:预期输出

图 17.8:预期输出

重要提示

将客户端的超时设置改为 10 秒以下,看看会发生什么。你还可以更改或删除请求上的授权头,看看会发生什么。

在这个练习中,你学习了如何向请求中添加自定义头。你了解了添加授权头的一个常见例子,这在你想与许多 API 交互时是必需的。

活动 17.02 – 使用 POST 和 GET 向 Web 服务器发送数据并检查数据是否接收

想象你正在与一个 Web API 交互,并且你希望向 Web 服务器发送数据。然后你想检查数据是否已添加。在这个活动中,你将做这件事。你将向服务器发送一个 POST 请求,然后使用 GET 请求请求数据,解析数据,并将其打印出来。

按照以下步骤获取所需的结果:

  1. 创建一个名为Activity17.02的目录。

  2. 创建两个子目录,一个名为client,另一个名为server

  3. server目录下,创建一个名为server.go的文件。

  4. 将服务器代码添加到server.go文件中。

  5. 通过在服务器目录中调用go run server.go来启动服务器。

  6. client目录中,创建一个名为client.go的文件。

  7. client.go中添加必要的导入。

  8. 创建结构体来存储请求数据,如下所示:{"name":"Electric"}

  9. 创建结构体来解析响应数据,如下所示:{"ok":true}.

  10. 创建一个addNameAndParseResponse函数来向服务器发送一个名称。

  11. 创建一个getDataAndParseResponse函数来解析服务器响应。

  12. 向服务器发送 POST 请求以添加名称。

  13. 向服务器发送 GET 请求。

  14. 将响应解析到结构体中。

  15. 遍历结构体并打印名称。

这是预期的输出:

图 17.9:可能的输出

图 17.9:可能的输出

重要提示

本活动的解决方案可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter17/Activity17.02找到。

在这个活动中,您看到了如何使用 POST 请求向 Web 服务器发送数据,然后如何使用 GET 请求从服务器请求数据以确保它已更新。在专业编程中,以这种方式与服务器交互是非常常见的。

摘要

HTTP 客户端用于与 Web 服务器交互。它们用于向服务器发送不同类型的请求(例如,GET 或 POST 请求),然后对服务器返回的响应做出反应。Web 浏览器是一种 HTTP 客户端,它将向 Web 服务器发送 GET 请求并显示它返回的 HTML 数据。在 Go 中,您创建了您自己的 HTTP 客户端并执行了相同的事情,向www.google.com发送 GET 请求,然后记录服务器返回的响应。您还了解了 URL 的组成部分,以及您可以通过更改 URL 来控制您从服务器请求的内容。

Web 服务器不仅仅是请求 HTML 数据。您了解到它们可以以 JSON 形式返回结构化数据,这些数据可以被解析并在您的代码中使用。数据也可以通过 POST 请求发送到服务器,允许您将表单数据发送到服务器。然而,发送到服务器的数据并不仅限于表单数据:您还可以使用 POST 请求上传文件到服务器。

还有方法可以自定义您发送的请求。您了解到常见的授权示例,其中您将令牌添加到 HTTP 请求的头部,以便服务器可以知道是谁发起的那个请求。

第六部分:专业

在熟练使用 Go 处理实际任务后,现在是时候为您提供 Go 专业编程所必需的工具和技术了。

本节涵盖了旨在提升您编程技能至专业水平的高级主题和最佳实践。

本节包括以下章节:

  • 第十八章并发工作

  • 第十九章测试

  • 第二十章使用 Go 工具

  • 第二十一章云中的 Go

第十八章:并发工作

概述

本章介绍了 Go 的功能,这些功能将允许你执行并发工作,换句话说,实现并发。你将学习的第一个功能被称为 Goroutine。你将了解 Goroutine 是什么,以及如何使用它来实现并发。然后,你将学习如何利用 WaitGroup 来同步多个 Goroutine 的执行。你还将学习如何使用原子操作来实现跨不同 Goroutine 共享变量的同步和线程安全更改。为了同步更复杂的变化,你将使用互斥锁。

在本章的后面部分,你将实验通道的功能,并使用消息跟踪来跟踪任务的完成情况。我们还将讨论并发的重要性、并发模式等内容。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在以下位置找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter18

简介

有些软件是为单个用户使用的,而本书中你学到的大部分内容都允许你开发这样的应用程序。然而,还有其他软件是为同时供多个用户使用的。一个例子是网络服务器。你在第十六章“网络服务器”中创建了网络服务器。它们被设计用来服务网站或网络应用程序,这些网站或应用程序通常由成千上万的用户同时使用。

当多个用户访问网络服务器时,有时它需要执行一系列完全独立且结果对最终输出唯一重要的操作。所有这些情况都需要一种类型的编程,其中不同的任务可以同时独立执行。一些语言允许并行计算,其中任务是同时计算的。

在并发编程中,当一项任务开始时,所有其他任务也会同时开始,但机器不是依次完成它们,而是同时执行每个任务的一部分。虽然 Go 允许并发编程,但当机器有多个核心时,任务也可以并行执行。然而,从程序员的视角来看,这种区别并不那么重要,因为创建任务时,我们假设它们将以并行的方式执行,并且机器将以任何方式执行它们。让我们在本章中了解更多信息。

Goroutines

想象有几个人有一些钉子要钉到墙上。每个人都有不同数量的钉子和不同的墙面区域,但只有一个锤子。每个人用锤子钉一个钉子,然后传给下一个人,以此类推。钉子最少的人会先完成,但他们都会使用同一个锤子;这就是 Goroutines 的工作方式。

使用 Goroutines,Go 允许多个任务同时运行(它们也被称为协程)。这些是可以在同一进程中并发运行的例程(即任务),但它们是完全并发的。Goroutines 不共享内存,这就是它们与线程不同的原因。然而,我们将看到如何在代码中轻松地在它们之间传递变量,以及这可能会引起一些意外的行为。

编写 Goroutine 并没有什么特别之处;它们只是普通的函数。每个函数都可以轻松地成为一个 Goroutine;我们只需要在调用函数之前写上单词go

让我们考虑一个名为hello()的函数:

func hello() {
  fmt.Println("hello world")
}

要将我们的函数作为 Goroutine 调用,我们执行以下操作:

go hello()

函数将以 Goroutine 的形式运行。这意味着什么可以通过以下代码更好地理解:

func main() {
  fmt.Println("Start")
  go hello()
  fmt.Println("End")

代码首先打印Start,然后调用hello()函数。然后,执行直接跳转到打印End,而不等待hello()函数完成。无论hello()函数运行多长时间,main()函数都不会关心hello()函数,因为这些函数将独立运行。为了更好地理解这是如何工作的,让我们做一些练习。

注意

需要记住的重要一点是,Go 不是一种并行语言,而是一种并发语言,这意味着 Goroutines 不是以独立的方式工作,而是每个 Goroutine 被分割成更小的部分,每个 Goroutine 一次运行其一个子部分。

练习 18.01 – 使用并发 Goroutines

让我们想象一下,我们想要进行两个计算。首先,我们将从110的所有数字相加,然后从1100的数字相加。为了节省时间,我们希望这两个计算独立进行,并且同时看到两个结果:

  1. 在你的文件系统中创建一个新的文件夹,并在其中创建一个main.go文件,然后编写以下内容:

    package main
    import "fmt"
    
  2. 创建一个用于求和两个数字的函数:

    func sum(from, to int) int {
      res := 0
      for i := from; i<=to; i++ {
        res += i
      }
      return res
    }
    

    这个函数接受两个整数作为极值(区间的最小值和最大值),并返回这两个极值之间所有数字的总和。

  3. 创建一个main()函数,它将数字1100相加,然后打印结果:

    func main() {
      s1 := sum(1, 100)
      fmt.Println(s1)
    }
    
  4. 运行程序:

    go run main.go
    

    你将看到以下输出:

    5050
    
  5. 现在,让我们引入一些并发性。修改main()函数,使其看起来像这样:

    func main() {
      var s1 int
      go func() {
        s1 = sum(1, 100)
      }()
      fmt.Println(s1)
    }
    

    这里,我们正在运行一个匿名函数,它将值 s1 赋给总和,就像之前一样,但如果我们运行代码,结果将是 0。如果你尝试在 func() 部分之前移除 go 项,你会看到结果是 5050。在这种情况下,匿名函数将运行并开始求和数字,但随后有一个调用 fmt.Println,它打印 s1 的值。在这里,程序在打印 s1 的值之前等待 sum() 函数结束,因此返回正确的结果。

    如果我们调用函数并在前面加上 go 这个词,程序会在函数仍在计算总和时(总和仍然是 0)打印 s1 的当前值,然后终止。

    让我们两次调用 sum() 函数,使用两个不同的范围。修改 main() 函数:

    func main() {
      var s1, s2 int
      go func() {
        s1 = sum(1, 100)
      }()
      s2 = sum(1, 10)
      fmt.Println(s1, s2)
    }
    

    如果你运行这个程序,它将打印数字 055。这是因为并发函数 go func() 没有时间返回结果。main() 函数更快,因为它必须数到 55 而不是 5050,所以程序在并发函数完成之前就终止了。

    为了解决这个问题,我们想要找到一个等待并发函数完成的方法。有一些正确的方法可以做到这一点,但现在,让我们做一件相当粗糙但有效的事情,那就是等待固定的时间。要做到这一点,只需在 fmt.Println 命令之前添加这一行:

    time.Sleep(time.Second)
    
  6. 修改 import 部分,位于 package main 指令下方,使其看起来如下:

    import (
      "log"
      "time"
    )
    

    如果你现在运行你的程序,你应该在屏幕上看到打印出 5050 55

  7. main() 函数中,编写代码以打印日志:

    log.Println(s1, s2)
    
  8. 如果你现在运行你的程序,你将再次看到相同的输出,5050 55,但前面会加上表示你运行代码时的时间戳:

    2024/01/25 19:23:00 5050 55
    

如你所见,计算是并发发生的,我们同时收到了两个输出。

注意

这个练习的完整代码可以在 github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter18 找到。

WaitGroup

在之前的练习中,我们使用了一种不太优雅的方法来确保通过让主 Goroutine 等待一秒钟来结束 Goroutine。重要的是要理解,即使程序没有通过 go 调用显式使用 Goroutines,它仍然使用了一个 Goroutine,那就是主程序。当我们运行程序并创建一个新的 Goroutine 时,我们正在运行两个 Goroutines:一个是主程序,另一个是我们刚刚创建的。为了同步这两个 Goroutines,Go 给我们提供了一个名为 WaitGroup 的函数。你可以使用以下代码定义一个 WaitGroup

wg := sync.WaitGroup{}

WaitGroup 需要导入 sync 包。典型的使用 WaitGroup 的代码可能如下所示:

package main
import "sync"
func main() {
  wg := &sync.WaitGroup{}
  wg.Add(1)
  …………………..
  wg.Wait()
  ………….
  ………….
}

在这里,我们创建了一个指向新 WaitGroup 的指针,然后提到我们正在添加一个异步操作,该操作使用 wg.Add(1)1 添加到组中。这本质上是一个计数器,持有所有正在运行并发 Goroutine 的数量。稍后,我们将添加将运行并发调用的代码。最后,我们告诉 WaitGroup 使用 wg.Wait() 等待 Goroutine 完成。

WaitGroup 如何知道这些例程已经完成?嗯,我们需要在 Goroutine 中显式地告诉 WaitGroup 如下:

wg.Done()

这必须位于被调用 Goroutine 的末尾。我们将在下一个练习中看到这一点。

练习 18.02 – 使用 WaitGroup 进行实验

假设我们再次在 练习 18.01使用并发 Goroutine 中计算加法,这次使用与主进程并发运行的 Goroutine。然而,这次我们想要使用 WaitGroup 来同步结果。我们需要做一些更改。本质上,sum() 函数需要接受一个新的 WaitGroup 参数,并且不需要使用 time 包。许多刚开始学习并发的人会在 Goroutine 完成之前添加 time.Sleep 来等待。这种故意的延迟是自相矛盾的,并且在实际应用中没有意义,因为 Goroutine 的目的是加快整体执行速度:

  1. 在新文件夹内创建一个 main.go 文件。你的文件包和导入部分如下:

    package main
    import (
      "log"
      "sync"
    )
    

    在这里,我们将包定义为 main 包,然后导入 logsync 包。log 将再次用于打印消息,而 sync 将用于 WaitGroup

  2. 接下来,编写一个 sum() 函数:

    func sum(from,to int, wg *sync.WaitGroup, res *int) {
    

    现在,我们添加一个名为 wg 的参数,它是一个指向 sync.WaitGroup 的指针,以及结果参数。在前一个练习中,我们用匿名函数包装了 sum() 函数,该函数作为一个 Goroutine 运行。这里,我们想要避免这样做,但我们需要以某种方式获取 sum() 函数的结果。因此,我们传递一个额外的参数作为指针,它将返回正确的值。

  3. 创建一个循环来增加 sum() 函数:

      *res = 0
      for i := from; i <=to ; i++ {
        *res += i
      }
    

    在这里,我们将 res 指针所持有的值设置为 0,然后我们使用之前看到的相同循环,但再次将 sum() 函数与 res 参数所指向的值关联起来。

  4. 我们现在可以完成这个函数:

      wg.Done()}
    

    在这里,我们告诉 WaitGroup 这个 Goroutine 已经完成,然后返回。

  5. 现在,让我们编写一个 main() 函数,它将设置变量并运行计算总和的 Goroutine。然后我们将等待 Goroutine 完成,并显示结果:

    func main() {
      s1 := 0
      wg := &sync.WaitGroup{}
    

    在这里,定义了 main() 函数,并设置了一个名为 s1 的变量为 0。同时,创建了一个指向 WaitGroup 的指针。

  6. WaitGroup 的计数加一,然后运行 Goroutine:

      wg.Add(1)
      go sum(1,100, wg, &s1)
    

    这段代码通知 WaitGroup 有一个 Goroutine 正在运行,然后创建一个新的 Goroutine 来计算总和。sum() 函数将调用 wg.Done() 方法通知 WaitGroup 其完成。

  7. 我们需要等待 Goroutine 完成。为此,编写以下代码:

      wg.Wait()
      log.Println(s1)
    }
    

    这也将结果记录到标准输出。

  8. 运行程序:

    go run main.go
    

你将看到使用 WaitGroup 的函数的日志输出,如下所示,带有时间戳:

2024/01/25 19:24:51 5050

通过这个练习,我们已经通过在我们的代码中同步 Goroutines 探索了 WaitGroup 的功能。

竞态条件

需要考虑的一个重要问题是,无论何时我们并发运行多个函数,我们都没有保证每个函数中的每个指令将按什么顺序执行。在许多架构中,这并不是一个问题。一些函数根本不与其他函数连接,并且一个函数在其 Goroutine 中所做的操作不会影响其他 Goroutines 中执行的操作。然而,这并不总是正确的。我们可以想到的第一种情况是,当一些函数需要共享相同的参数时。一些函数会从这个参数中读取,而其他函数会写入这个参数。由于我们不知道哪个操作会先运行,所以一个函数可能会覆盖另一个函数更新的值。让我们看看一个解释这种情况的例子:

func next(v *int) {
  c := *v
  *v = c + 1
}

这个函数接受一个指向整数的指针作为参数。它是一个指针,因为我们想使用 next() 函数运行多个 Goroutines 并更新 v。如果我们运行以下代码,我们期望 a 将持有值 3:

a := 0
next(&a)
next(&a)
next(&a)

这完全没问题。然而,如果我们运行以下代码:

a := 0
go next(&a)
go next(&a)
go next(&a)

在这种情况下,我们可能会看到 a 持有 3、2 或 1。为什么会这样呢?因为当一个函数执行以下语句时,v 的值可能对所有在独立 Goroutines 中运行的函数都是 0:

c := *v

如果发生这种情况,那么每个函数都会将 v 设置为 c + 1,这意味着没有任何 Goroutine 意识到其他 Goroutine 在做什么,并覆盖了另一个 Goroutine 所做的任何更改。这个问题被称为 竞态条件,并且每次我们在没有采取预防措施的情况下处理共享资源时都会发生。幸运的是,我们有几种方法可以防止这种情况,并确保相同的更改只进行一次。我们将在下一节中查看这些解决方案,并更详细地探讨我们刚才描述的情况,包括适当的解决方案和竞态检测。

原子操作

让我们想象我们再次想要运行独立的函数。然而,在这种情况下,我们想要修改变量的值。我们仍然想要从 1 到 100 求和,但我们将工作分成两个并发 Goroutines。我们可以在一个进程中求和 1 到 50 的数字,在另一个进程中求和 51 到 100 的数字。最后,我们仍然需要收到 5050 的值,但两个不同的进程可以同时向同一个变量添加一个数字。让我们看看一个只有四个数字的例子,我们想要求和 1、2、3 和 4,结果是 10。

想象有一个名为s := 0的变量,然后进行一个循环,其中s的值变为以下:

s = 0
s = 1
s = 3 // (1 + 2)
s = 6
s = 10

然而,我们也可以有以下的循环。在这种情况下,求和的数字顺序是不同的:

s = 0
s = 1
s = 4 // 3 + 1, the previous value of 1
s = 6 // 2 + 4 the previous value of 4
s = 10

实质上,这只是求和的交换律,但这给我们一个提示,我们可以将求和分成两个或更多的并发调用。这里出现的问题是,所有函数都需要操作同一个变量s,这可能导致竞态条件和最终值不正确。竞态条件发生在两个进程更改同一个变量时,一个进程在不考虑先前更改的情况下覆盖另一个进程所做的更改。幸运的是,我们有一个名为atomic的包,允许我们在 Goroutines 之间安全地修改变量。

我们将很快查看这个包是如何工作的,但现在,你需要知道的是,这个包有一些函数可以用于在变量上执行简单的并发安全操作。让我们看看一个例子:

func AddInt32(addr *int32, delta int32) (new int32)

这段代码接受一个指向int32的指针,并通过将其指向的值加到delta的值上来修改它。如果addr持有值为 2 且delta为 4,在调用此函数后,addr将持有 6。

练习 18.03 – 原子变更

在这个练习中,我们想要计算 1 到 100 之间所有数字的总和,但使用更多的并发 Goroutines——比如说 4 个。所以,我们有一个函数在 1-25 的范围内求和,一个在 26-50 的范围内,然后是 51-75,最后是 76-100。我们将使用我们关于原子操作和WaitGroups的知识:

  1. 创建一个新的文件夹和一个main.go文件。在里面,写下以下代码:

    package main
    import (
      "log"
      "sync"
      "sync/atomic"
    )
    

    这将导入之前练习中使用的相同包,以及sync/atomic包。

  2. 下一步是将练习 19.02使用 WaitGroup 进行实验中的sum()函数重构,以使用atomic包:

    func sum(from, to int, wg *sync.WaitGroup, res *int32) {
    

    在这里,我们只是将resint改为*int32。这样做的原因是,专门针对算术操作可用的原子操作仅适用于int32/64和相关的uint32/64

  3. 在这一点上,写一个循环将每个数字加到总数中:

      for i := from; i <= to; i++ {
        atomic.AddInt32(res, int32(i))
      }
      wg.Done()
      return
    }
    

    如您所见,我们不是将res的值赋为0,而是现在将i添加到res持有的总值中。其余的代码保持不变。

  4. 下一步是编写一个main()函数,以四个不同的 Goroutines 计算总和:

    func main() {
      s1 := int32(0)
      wg := &sync.WaitGroup{}
    

    在这里,我们将s1设置为int32类型而不是int,这样我们就可以将其作为参数发送给sum()函数。然后,我们创建一个指向WaitGroup的指针。

  5. 现在,告诉WaitGroup我们将有四个 Goroutines 正在运行:

      wg.Add(4)
    
  6. 现在,运行四个 Goroutines,分别对四个范围进行求和:1-25,26-50,51-75,和 76-100:

      go sum(1, 25, wg, &s1)
      go sum(26, 50, wg, &s1)
      go sum(51, 75, wg, &s1)
      go sum(76, 100, wg, &s1)
    
  7. 现在,添加等待例程完成并打印结果的代码:

      wg.Wait()
      log.Println(s1)
    }
    
  8. 现在,使用以下内容运行代码:

    go run main.go
    2024/01/25 19:26:04 5050
    

    实际日期将不同,因为它取决于你何时运行此代码。

  9. 现在,让我们测试代码。我们将使用它来向您展示什么是竞态条件,为什么我们使用这个atomic包,以及什么是并发安全性。以下是测试代码:

    package main
    import (
      "bytes"
      "log"
      "testing"
    )
    func Test_Main(t *testing.T) {
      for i:=0; i < 10000; i++ {
        var s bytes.Buffer
        log.SetOutput(&s)
        log.SetFlags(0)
        main()
        if s.String() != "5050\n" {
          t.Error(s.String())
        }
      }
    }
    

    我们将运行相同的测试 10,000 次。

  10. 运行你的测试:

    go test
    

    原子更改测试的结果如下:

    PASS
    ok parallelwork 0.048s
    
  11. 现在,添加-race标志:

    go test -race
    

    使用-race标志运行这些测试的输出如下:

    PASS
    ok parallelwork 3.417s
    

    再次,到目前为止一切正常。

  12. 现在,让我们移除sync/atomic导入,并修改包含此行的sum()函数:

    atomic.AddInt32(res, int32(i))
    
  13. 改成这样:

    *res = *res + int32(i)
    
  14. 现在,运行你的程序:

    go run main.go
    
  15. 使用指针时,非原子更改的日志输出保持不变:

    2024/01/25 19:30:47 5050
    
  16. 但如果你尝试多次运行测试,你可能会看到一些不同的结果,尽管在这种情况下,这种情况相当不可能。然而,此时尝试使用-race标志运行测试:

    go test -race
    

你将看到以下输出:

图 18.1:在此处使用指针时会出现竞态条件

图 18.1:在此处使用指针时会出现竞态条件

注意

必须安装 GCC 才能运行此代码。有关安装说明的信息,请参阅go.dev/doc/install/gccgo

  1. 现在,让我们不带-race标志运行代码:

图 18.2:带有竞态条件的堆栈跟踪

图 18.2:带有竞态条件的堆栈跟踪

注意

...图 18.2 中表示我删除的一些输出行,以使视觉更易于理解。

通过多次运行代码,你可以看到不同的结果,因为每个例程可以在任何时间以任何顺序更改s1的值,这是我们无法提前知道的。

在这个练习中,你学习了如何使用atomic包安全地修改多个 Goroutines 共享的变量。你学习了从不同的 Goroutines 直接访问相同的变量可能是危险的,以及如何使用atomic包来避免这种情况。我们还看到了如何在 Go 中处理测试。这个主题将在下一章中更详细地介绍。

注意

此练习的完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter18/Exercise18.03找到。

不可见的并发

在之前的练习中,我们已经看到了通过竞态条件产生的并发效果,但我们想在实际中看到这些效果。很容易理解,并发问题很难可视化,因为它们在每次运行程序时并不总是以相同的方式表现出来。这就是为什么我们专注于寻找同步并发工作的方法。然而,有一个简单的方法可以可视化它,但在测试中使用却很困难,那就是打印出每个并发例程,并查看这些例程被调用的顺序。例如,在之前的练习中,我们可以在for循环的每次迭代中发送另一个带有名称的参数,并打印出函数的名称。

如果我们想看到并发效果并仍然能够测试它,我们可以再次使用atomic包,这次使用字符串,这样我们就可以构建一个包含每个 Goroutine 消息的字符串。对于这种情况,我们再次使用sync包,但我们将不使用原子操作。相反,我们将使用一个新的结构体Mutex。互斥锁,简称为互斥排他,在 Go 中作为同步原语,允许多个 Goroutine 协调对共享资源的访问。当一个 Goroutine 获取互斥锁时,它会锁定它,确保对代码关键部分的独占访问。这阻止了其他 Goroutine 在互斥锁解锁之前访问相同的资源。一旦关键部分执行完成,互斥锁被解锁,允许其他 Goroutine 获取它并继续并发执行。让我们看看我们如何使用它。首先,需要导入sync包。然后,我们可以创建一个互斥锁,如下所示:

mtx := sync.Mutex{}

但大多数时候,我们希望将互斥锁传递给几个函数,所以我们最好创建一个指向互斥锁的指针:

mtx := &sync.Mutex{}

这确保我们在任何地方都使用相同的互斥锁。使用相同的互斥锁很重要,但为什么互斥锁必须是唯一的,在分析Mutex结构体中的方法之后将会变得清楚。如果所有 Goroutine 在修改代码关键部分(如以下情况)中的值之前都执行了mtx.Lock(),那么由于锁定,每次只能有一个 Goroutine 修改变量:

mtx.Lock()
s = s + 5

上述代码片段将锁定所有例程的执行,除了将改变变量的那个例程。在这个时候,我们将向s的当前值添加 5。之后,我们使用以下命令释放锁,以便任何其他 Goroutine 都可以修改s的值:

mtx.Unlock()

从现在开始,任何后续的代码都将并发运行。我们稍后会看到一些更好的方法来确保在修改变量时的安全性,但就目前而言,不要担心在锁定/解锁部分之间添加太多代码。这些结构之间有更多的代码,你的代码并发性就会降低。因此,你应该锁定程序的执行,只添加确保安全性的逻辑,然后解锁,继续执行剩余的代码,这些代码不会触及共享变量。

有一点很重要需要注意,即异步执行代码的顺序可能会改变。这是因为 Goroutines 是独立运行的,你无法知道哪个先运行。此外,互斥锁保护的代码一次只能由一个 Goroutine 运行,因此你不应该依赖 Goroutines 来正确排序;如果你需要一个特定的顺序,你可能需要在之后对结果进行排序。

通道

我们已经看到了如何通过 Goroutines 创建并发代码,如何使用WaitGroup进行同步,如何执行原子操作,以及如何暂时停止并发以同步对共享变量的访问。现在,我们将介绍一个不同的概念——通道,这是 Go 的典型特征。通道就是名字所暗示的——它是可以管道传输消息的地方,任何 Goroutine 都可以通过通道发送或接收消息。与切片类似,通道是以以下方式创建的:

var ch chan int
ch = make(chan int)

当然,可以直接使用以下方式实例化通道:

ch := make(chan int)

就像处理切片一样,我们也可以做以下操作:

ch := make(chan int, 10)

在这里,我们创建了一个包含 10 个项目的缓冲区通道。

通道可以是任何类型,例如整数、布尔值、浮点数,以及任何可以定义的结构体,甚至是切片和指针,尽管后两者使用得较少。

通道可以作为参数传递给函数,这就是不同的 Goroutines 如何共享内容。让我们看看如何向通道发送消息:

ch <- 2

在这种情况下,我们将 2 的值发送到前面的ch通道,这是一个整数通道。当然,尝试向整数通道发送非整数值将导致错误。

在发送消息后,我们需要能够从通道接收消息。为此,我们可以这样做:

<- ch

这样做确保了消息被接收;然而,消息并没有被存储。丢失消息似乎没有用,但我们会看到这可能是有意义的。尽管如此,我们可能想要保留从通道接收到的值,我们可以通过将值存储在一个新变量中来实现这一点:

i := <- ch

让我们看看一个简单的程序,它展示了我们如何使用到目前为止所学的知识:

package main
import "log"
func main() {
  ch := make(chan int, 1)
  ch <- 1
  i := <- ch
  log.Println(i)
}

此程序创建了一个新的通道,将整数 1 管道输入,然后读取它,最后打印出i的值,该值应该是 1。这段代码在实践中并不那么有用,但通过一个小改动,我们可以看到一些有趣的东西。让我们通过将通道定义更改为以下内容来使通道无缓冲:

ch := make(chan int)

如果你运行代码,你将得到以下输出:

fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
    /Users/ samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Exercise19.04/main.go:8 +0x59Process finished with exit code 2

消息可能取决于你使用的 Go 版本。此外,一些错误,如这些,在新版本中已经被引入。然而,在旧版本中,编译器更为宽容。在这个特定的情况下,问题很简单:如果我们不知道通道的大小,Goroutines 将无限期地等待,这被称为死锁。你可以将无缓冲通道视为容量为零。如果我们尝试将其放入其中,它将不会保留项目 – 相反,它将阻塞,直到我们可以将项目通过通道传递给一个变量,例如。我们将在稍后看到如何处理它们,因为它们需要多个线程运行。只有一个 Goroutine 时,在我们发送消息后,我们将阻塞执行,没有其他 Goroutine 能够接收消息;因此,我们遇到了死锁。

在我们进一步探讨之前,让我们看看通道的另一个特性,即它们可以被关闭。当通道被创建的任务完成时,需要关闭通道。要关闭通道,请输入以下内容:

close(ch)

或者,你可以像以下代码片段所示那样延迟关闭:

...
defer close(ch)
for i := 0; i < 100; i++ {
  ch <- i
}
return

在这种情况下,在return语句之后,通道被关闭,因为关闭操作被延迟到return语句之后执行。

练习 18.04 – 通过通道交换问候消息

在这个练习中,我们将使用一个 Goroutine 发送一个问候消息,然后我们在主进程中接收问候。这个练习非常简单,不需要并发,但它理解消息传递工作原理的起点:

  1. 创建一个文件夹。在其中,创建一个名为main.go的文件,并使用main包:

    package main
    import (
      "log"
    )
    
  2. 然后,创建一个greeter()函数:

    func greet(ch chan string) {
      ch <- "Hello"
    }
    

    这个函数只是向通道发送一个Hello消息并结束。

  3. 现在,创建一个main()函数,在其中实例化一个通道并将其传递给greeter()函数:

    func main() {
      ch := make(chan string)
      go greet(ch)
    

    在这里,只创建了一个字符串通道并将其作为参数传递给名为greet的新例程的调用。

  4. 现在,打印结果并完成函数:

      log.Println(<-ch)
    }
    

    在这里,我们正在打印通道中出现的任何内容。代码的以下部分返回一个值,该值直接传递给Println函数:

    <- ch
    
  5. 使用以下命令运行程序:

    go run main.go
    

你将看到以下输出:

2024/01/25 19:44:11 Hello

现在,我们可以看到消息已经通过通道传递到了main()函数中。

在这个练习中,你看到了如何使用通道使不同的 Goroutines 相互通信并同步它们的计算。

练习 18.05 – 使用通道进行双向消息交换

我们现在想要从主 Goroutine 向第二个 Goroutine 发送消息,然后得到一个响应消息。我们将基于之前的代码并扩展它。主 Goroutine 将发送一个"Hello John"消息,而第二个 Goroutine 将针对收到的消息返回"Thanks",并完整地表达出来,然后添加一个"Hello David"消息:

  1. 创建一个文件夹。在其中创建一个 main.go 文件,包含 main 包:

    package main
    import (
      "fmt"
      "log"
    )
    

    在必要的导入之后,我们将使用 fmt 包来操作字符串。

  2. 编写一个 greet() 函数以返回预期的消息:

    func greet(ch chan string) {
      msg := <- ch
      ch <- fmt.Sprintf("Thanks for %s", msg)
      ch <- "Hello David"
    }
    

    greet() 函数的签名没有改变。然而,现在,在发送消息之前,它将首先等待一个消息,然后回复。在收到消息后,这个函数会发送一条消息表示感谢问候,然后发送自己的问候。

  3. 现在,创建一个 main() 函数并将 greet() 函数作为 Goroutine 调用:

    func main() {
      ch := make(chan string)
      go greet(ch)
    

    在这里,main() 函数被创建,并实例化了一个字符串通道。然后,启动第二个 Goroutine。接下来,我们需要从主 Goroutine 向第二个正在等待的 Goroutine 发送第一条消息。

  4. 现在,要向通道发送 "Hello John" 消息,请编写以下代码:

      ch <- "Hello John"
    
  5. 最后,添加在打印之前等待消息返回的代码:

      log.Println(<-ch)
      log.Println(<-ch)
    }
    

你可以看到你需要记录两次,因为你期望返回两条消息。在许多情况下,你将使用循环来检索所有消息,我们将在下一个练习中看到。现在,尝试运行你的代码,你将看到如下内容:

2024/01/25 19:44:49 Thanks for Hello John
2024/01/25 19:44:49 Hello David

从输出中,你可以看到两条消息都通过通道接收到了。

在这个练习中,你学习了 Goroutine 如何通过同一个通道发送和接收消息,以及两个 Goroutine 如何通过同一个通道在两个方向上交换消息。

练习 18.06 – 从各个地方汇总数字

假设你想要添加一些数字,但这些数字来自多个来源。它们可能来自一个源或数据库;我们只知道我们要添加哪些数字以及它们来自哪里。然而,我们需要将它们全部添加到同一个地方。在这个练习中,我们将有四个 Goroutine 在特定的范围内发送数字,以及主 Goroutine,它将计算它们的总和:

  1. 让我们从创建一个新的文件夹和主文件开始。完成这些后,编写包和导入:

    package main
    import (
      "log"
      "time"
    )
    

    在这里,我们还包含了 time 包,我们将使用它来做一个小技巧,这将帮助我们更好地可视化并发的效果。

  2. 现在,编写一个 push() 函数:

    func push(from, to int, out chan int) {
      for i := from; i <= to; i++ {
        out <- i
        time.Sleep(time.Microsecond)
      }
    }
    

    这将把 from, to 范围内的所有数字发送到通道。在每条消息发送后,Goroutine 将休眠一微秒,以便另一个 Goroutine 可以接手工作。

  3. 现在,编写一个 main() 函数:

    func main() {
      s1 := 0
      ch := make(chan int, 100)
    

    这段代码创建了一个用于最终总和的变量 s1 和一个用于通道 ch 的变量,该通道有一个 100 的缓冲区。

  4. 现在,创建四个 go 线程:

      go push(1, 25, ch)
      go push(26, 50, ch)
      go push(51, 75, ch)
      go push(76, 100, ch)
    
  5. 在这一点上,我们需要收集所有要加的数字,因此我们创建了一个 100 次循环:

      for c := 0; c < 100; c++ {
    
  6. 然后,从通道中读取数字:

        i := <- ch
    
  7. 我们还想看到哪个数字来自哪个 Goroutine:

        log.Println(i)
    
  8. 最后,我们计算总和并显示结果:

        s1 += i
      }
      log.Println(s1)
    }
    

在这里,我们展示了程序运行后的截断输出:

2024/01/25 21:42:09 76
2024/01/25 21:42:09 26
2024/01/25 21:42:09 51
2024/01/25 21:42:09 77
2024/01/25 21:42:09 52
……………………………………………………………
2024/01/25 21:42:09 48
2024/01/25 21:42:09 75
2024/01/25 21:42:09 100
2024/01/25 21:42:09 23
2024/01/25 21:42:09 49
2024/01/25 21:42:09 24
2024/01/25 21:42:09 50
2024/01/25 21:42:09 25
2024/01/25 21:42:09 5050

根据结果,我们可以轻松地猜测哪个数字来自哪个 routine。最后一行显示了所有数字的总和。如果你多次运行程序,你还会看到数字的顺序也会改变。

在这个练习中,我们看到了如何将一些计算工作分配给几个并发的 Goroutines,然后在单个 Goroutine 中收集所有计算结果。每个 Goroutine 执行一个任务。在这种情况下,一个发送数字,而另一个接收数字并执行求和操作。

练习 18.07 – 向 Goroutines 发送请求

在这个练习中,我们将解决与 练习 19.06 中提到的相同问题,即 从各个地方求和数字,但以不同的方式。不是接收 Goroutines 发送的数字,而是让主 Goroutine 从其他 Goroutines 请求数字。我们将玩转通道操作,并实验它们的阻塞特性:

  1. 创建一个文件夹和一个名为 main.go 的文件,并使用 main 包。然后,添加以下导入:

    package main
    import (
      "log"
    )
    
  2. 然后,编写 push() 函数的签名:

    func push(from, to int, in chan bool, out chan int) {
    

    这里有两个通道 - 一个布尔类型的通道称为 in,它代表传入的请求,另一个是 out,它将用于发送回消息。

  3. 现在,编写一个循环,在接收到请求时发送数字:

      for i := from; i <= to; i++ {
        <- in
        out <- i
      }
    }
    

    如你所见,循环仍然是针对固定数量的项目。在发送任何内容之前,它等待从 in 通道接收请求。当它收到请求时,它会发送一个数字。

  4. 现在,创建一个 main() 函数,在其中调用四个不同的 Goroutines 中的 push() 函数,每个 Goroutine 发送 1 到 100 的数字的一个子集:

    func main() {
      s1 := 0
      out := make(chan int, 100)
      in := make(chan bool, 100)
      go push(1, 25, in, out)
      go push(26, 50, in, out)
      go push(51, 75, in, out)
      go push(76, 100, in, out)
    

    这与上一个练习非常相似,但它创建了一个额外的通道,in

  5. 现在,创建一个循环来请求一个数字,打印它,并将其添加到总数中:

      for c := 0; c < 100; c++ {
        in <- true
        i := <- out
        log.Println(i)
        s1 += i
      }
      log.Println(s1)
    }
    

    在这种情况下,循环首先请求一个数字,然后等待接收另一个数字。在这里,我们不需要等待一微秒,因为在我们收到一个数字后,下一个请求将发送到任何活动的 Goroutine。如果你运行程序,你将再次看到与上一个练习类似的结果。这里,我们有截断的输出:

2024/01/25 22:18:00 76
2024/01/25 22:18:00 1
2024/01/25 22:18:00 77
2024/01/25 22:18:00 26
2024/01/25 22:18:00 51
2024/01/25 22:18:00 2
2024/01/25 22:18:00 78
…………………………………………………………
2024/01/25 22:18:00 74
2024/01/25 22:18:00 25
2024/01/25 22:18:00 50
2024/01/25 22:18:00 75
2024/01/25 22:18:00 5050

你可以看到每个数字都是按照接收到的顺序打印的。然后,所有数字的总和会打印在屏幕上。

在这个练习中,你学习了如何使用通道请求其他 Goroutines 执行某些操作。通道可以用来发送一些触发消息,而不仅仅是交换内容和值。

并发的重要性

到目前为止,我们已经看到了如何使用并发将工作分割到多个 Goroutines 中,但在所有这些练习中,并发实际上并不是必需的。事实上,你做我们做的事情并不会节省多少时间,也没有其他优势。并发在你需要执行几个逻辑上相互独立的不同任务时很重要,最容易理解的情况是网络服务器。你在 第十六章网络服务器 中看到,多个客户端很可能会连接到同一个服务器,所有这些连接都将导致服务器执行一些操作。此外,这些操作都是独立的;这就是并发之所以重要的地方,因为你不希望你的用户在他们的请求得到处理之前必须等待所有其他 HTTP 请求完成。并发的另一个情况是当你有不同数据源来收集数据时,你可以在不同的 Goroutines 中收集这些数据,并在最后合并结果。我们现在将看到更复杂的并发应用,并学习如何将其用于 HTTP 服务器。

练习 18.08 – 在 Goroutines 之间平均分配工作

在这个练习中,我们将看到如何以预定义的 Goroutines 数量执行数字的求和,以便它们在最后收集结果。本质上,我们想要创建一个加法函数,该函数从通道接收数字。当函数不再接收更多数字时,我们将通过通道将总和发送到 main() 函数。

这里需要注意的一点是,执行求和的函数事先不知道它将接收多少数字,这意味着我们不能有一个固定的 from, to 范围。因此,我们必须找到另一种解决方案。我们需要能够将工作分割成任意数量的 Goroutines,并且不受 from, to 范围的限制。此外,我们不想在 main() 函数中执行加法。相反,我们想要创建一个函数,该函数将在多个 Goroutines 之间分配工作:

  1. 创建一个文件夹和一个 main.go 文件,包含 main 包,并编写以下内容:

    package main
    import (
      "log"
    )
    
  2. 现在,让我们编写一个执行部分加法的函数。我们将称之为 worker(),因为我们将有一个固定集的 Goroutines 运行这个相同的函数,等待数字的到来:

    func worker(in chan int, out chan int) {
      sum := 0
      for i := range in {
        sum += i
      }
      out <- sum
    }
    

    如你所见,我们有一个整数输入通道 in 和输出通道 out。然后,我们实例化 sum 变量,它将存储发送到这个工作者的所有数字的总和。

  3. 在这个阶段,我们有一个遍历通道的循环。这很有趣,因为我们没有直接使用 in,如下所示:

    <- in
    

    我们,相反,只依赖于范围来获取数字。在循环中,我们只是将 i 添加到总数中,并在结束时将部分总和发送回去。即使我们不知道将要发送到通道中的项目数量,我们仍然可以无问题地遍历范围。我们依赖于这样一个事实:当没有更多项目发送时,in 通道将被关闭。

  4. 创建一个 sum() 函数:

    func sum(workers, from, to int) int {
    

    这是实际的 sum() 函数,它具有工作者的数量和要相加的数字的常规范围。

  5. 现在,写一个循环来运行请求的工作者数量:

      out := make(chan int, workers)
      in := make(chan int, 4)
      for i := 0; i <  workers; i++ {
        go worker(in, out)
      }
    

    这创建了两个 in/out 通道,并运行由 workers 参数设置的工作者数量。

  6. 然后,创建一个循环将所有数字发送到 in 通道:

      for i := from; i <= to; i++ {
        in <- i
      }
    

    这会将所有要相加的数字发送到通道,该通道将数字分配到所有 Goroutines 上。如果你要打印出带有工作者索引接收到的数字,你可以看到数字是如何在 Goroutines 上均匀分布的,这并不意味着精确分割,但至少是公平的。

  7. 由于我们发送了所有数字,我们现在需要接收部分和,但在那之前,我们需要通知函数要相加的数字已经完成,所以添加以下代码行。关闭通道意味着不能再发送任何内容,但仍然可以从通道接收数据:

      close(in)
    
  8. 然后,对部分和进行求和:

      sum := 0
      for i := 0; i < workers; i++ {
        sum += <-out
      }
    
  9. 然后,最后,关闭 out 通道并返回结果:

      close(out)
      return sum
    }
    
  10. 到目前为止,我们需要以某种方式执行这个函数。所以,让我们写一个简单的 main() 函数来做这件事:

    func main() {
      res := sum(100, 1, 100)
      log.Println(res)
    }
    

    这只是从使用并发并打印结果的函数中输出一个和。

如果你运行你的程序,你应该看到分割到不同程序中的数字求和的日志输出如下:

2024/01/25 19:49:13 5050

如你所见,在将计算分割到多个 Goroutines 之后,结果被同步到一个单一的结果中。

在这个练习中,你学习了如何利用并发将你的计算分割到几个并发的 Goroutines 上,然后将所有这些计算组合成一个单一的结果。

并发模式

我们组织并发工作的方式在每一个应用程序中几乎都是相同的。我们将查看一个称为 pipeline 的常见模式,其中有一个源,然后消息从一个 Goroutine 发送到另一个 Goroutine,直到线路的尽头,直到管道中的所有 Goroutines 都被利用。另一个模式是 fan out/ fan in 模式,其中,就像在之前的练习中一样,工作被发送到几个从同一通道读取的 Goroutines。然而,所有这些模式通常由一个 source 阶段组成,这是管道的第一个阶段,它收集或源生数据,然后是一些内部步骤,最后是一个 sink,这是最终阶段,其他所有程序的结果都合并在这里。它被称为 sink,因为所有数据都流入其中。

缓冲区

在之前的练习中,你已经看到了有定义长度的通道和未定义长度的通道:

ch1 := make(chan int)
ch2 := make(chan int, 10)

让我们看看我们如何利用这一点。

缓冲区就像一个需要填充一些内容的容器,所以当你期望接收该内容时,你准备它。我们说过,通道上的操作是阻塞操作,这意味着当你尝试从通道读取消息时,Goroutine 的执行将停止并等待。让我们通过一个示例来尝试理解这在实践中意味着什么。假设我们在一个 Goroutine 中有以下代码:

i := <- ch

我们知道,在我们继续执行代码之前,我们需要接收一个消息。然而,关于这种阻塞行为还有一些其他的事情。如果通道没有缓冲区,Goroutine 也会被阻塞。无法向通道写入或从通道接收。我们可以通过一个示例来更好地理解这一点,并展示如何使用无缓冲通道以实现相同的结果,这样你将更好地理解你在之前的练习中看到的内容。

让我们来看看这段代码:

ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println(<-ch)
fmt.Println(<-ch)

如果你将此代码放入一个函数中,你会发现它工作得非常完美,并将显示如下内容:

1
2

但如果你添加一个额外的读取操作呢?让我们看看:

ch := make(chan int, 2)
ch <- 1
ch <- 2
ch <- 3
fmt.Println(<-ch)
fmt.Println(<-ch)

在这种情况下,你会看到一个错误:

fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
    /tmp/sandbox223984687/prog.go:9 +0xa0

这是因为运行此代码的例程在缓冲区被来自读取操作(通常称为读取)的 2 个数据大小填满后(大小为 2),缓冲区被数据填满,在这种情况下,有 2 个数据,缓冲区的大小为 2。我们可以增加缓冲区的大小:

ch := make(chan int, 3)

它将再次工作;我们只是没有显示第三个数字。

现在,让我们看看如果我们移除缓冲区会发生什么。尝试一下,你将再次看到之前的错误。这是因为缓冲区总是满的,并且例程被阻塞了。无缓冲通道相当于以下内容:

ch := make(chan int, 0)

我们已经无问题地使用了无缓冲通道。让我们看看如何使用它们的示例:

package main
import "fmt"
func readThem(ch chan int) {
  for {
    fmt.Println(<- ch)
  }
}
func main() {
  ch := make(chan int)
  go readThem(ch)
  ch <- 1
  ch <- 2
  ch <- 3
}

如果你运行这个程序,你应该看到如下内容:

1
2
3

但你可能会看到更少的数字。如果你在 Go Playground 上运行这个程序,你应该看到这个结果,但如果你在自己的机器上运行它,你可能看到更少的数字。尝试发送更多的数字:

ch <- 4
ch <- 5

在每次加法操作时运行你的程序;你可能看不到所有的数字。基本上,有两个 Goroutine:一个是读取无缓冲通道的消息,主 Goroutine 通过相同的通道发送这些消息。因此,没有死锁。这表明我们可以通过使用两个 Goroutine 完美地利用无缓冲通道进行读写操作。然而,我们仍然有一个问题,就是不是所有的数字都会显示出来,我们可以在以下方式中修复这个问题:

package main
import "fmt"
import "sync"
func readThem(ch chan int, wg *sync.WaitGroup) {
  for i := range ch {
    fmt.Println(i)
  }
  wg.Done()
}
func main() {
  wg := &sync.WaitGroup{}
  wg.Add(1)
  ch := make(chan int)
  go readThem(ch, wg)
  ch <- 1
  ch <- 2
  ch <- 3
  ch <- 4
  ch <- 5
  close(ch)
  wg.Wait()
}

在这里,我们在 Goroutine 内部迭代通道,一旦通道关闭就停止迭代。这是因为当通道关闭时,range 停止迭代。在所有消息发送完毕后,主 Goroutine 会关闭通道。我们在这里使用 WaitGroup 来知道一切是否完成。如果我们不在 main() 函数中关闭通道,我们就会在主 Goroutine 中,这会在第二个 Goroutine 打印所有数字之前终止。然而,还有另一种等待第二个 Goroutine 执行完成的方法,那就是通过显式通知,我们将在下一个练习中看到。需要注意的是,即使我们关闭了通道,消息仍然会到达接收例程。这是因为你可以从关闭的通道接收消息;你只是不能发送更多的消息。

练习 18.09 – 通知计算何时完成

在这个练习中,我们想要有一个 Goroutine 发送消息,另一个 Goroutine 打印消息。此外,我们还想知道发送者何时完成发送消息。代码将与前面的例子类似,但有一些修改:

  1. 创建一个新文件并导入必要的包:

    package main
    import "log"
    
  2. 然后,定义一个函数,该函数首先接收字符串并在稍后打印它们:

    func readThem(in, out chan string) {
    
  3. 然后,创建一个循环,直到通道关闭:

      for i := range in {
        log.Println(i)
      }
    
  4. 最后,发送一个通知,说明处理已完成:

      out <- "done"
    }
    
  5. 现在,让我们构建 main() 函数:

    func main() {
      log.SetFlags(0)
    

    在这里,我们还设置了 log 标志为 0,这样我们就不会看到除了我们发送的字符串之外的其他内容。

  6. 现在,创建必要的通道并使用它们启动 Goroutine:

      in, out := make(chan string), make(chan string)
      go readThem(in, out)
    
  7. 接下来,创建一组字符串并遍历它们,将每个字符串发送到通道:

      strs := []string{"a","b", "c", "d", "e", "f"}
      for _, s := range strs {
        in <- s
      }
    
  8. 之后,关闭用于发送消息的通道并等待 done 信号:

      close(in)
      <-out
    }
    

如果你运行你的程序,你会看到使用 done 通道的代码的日志输出:

a
b
c
d
e
f

我们看到 main() 函数接收了来自 Goroutine 的所有消息并将它们打印出来。只有当它被通知所有传入的消息都已发送时,main() 函数才会终止。

在这个练习中,你已经学会了如何通过通过通道传递消息而不需要 WaitGroup 来使一个 Goroutine 通知另一个 Goroutine 工作已完成。

一些常见的做法

在所有这些例子中,我们创建了通道并通过它们传递,但函数也可以返回通道并启动新的 Goroutine。以下是一个例子:

func doSomething() chan int {
  ch := make(chan int)
  go func() {
    for i := range ch {
      log.Println(i)
    }
  }()
  return ch
}

在这种情况下,我们实际上可以在 main() 函数中有以下内容:

ch := doSomething()
ch <- 1
ch <- 4

我们不需要将 doSomething 函数作为 Goroutine 调用,因为它会自己启动一个新的。

一些函数也可以返回或接受,例如这个:

<- chan int

这里还有一个例子:

chan <- int

这清楚地说明了函数如何使用通道。实际上,你可以尝试在我们已经做过的所有练习中指定方向,看看如果你指定了一个错误的方向会发生什么。

HTTP 服务器

你已经看到了如何在第十六章,“Web 服务器”中构建 HTTP 服务器,但你可能还记得,在 HTTP 服务器中处理某些事情是有些困难的,那就是应用程序的状态。本质上,HTTP 服务器作为一个单独的程序运行,并在主 Goroutine 中监听请求。然而,当客户端之一发起一个新的 HTTP 请求时,会创建一个新的 Goroutine 来处理该特定请求。你没有手动这样做,也没有管理服务器的通道,但这是它内部的工作方式。实际上,你不需要在不同的 Goroutines 之间发送任何东西,因为每个 Goroutine 和每个请求都是独立的,因为它们是由不同的人发起的。

然而,你必须考虑的是,当你想要保持状态时,如何避免创建竞态条件。大多数 HTTP 服务器是无状态的,尤其是如果你正在构建一个微服务环境。然而,你可能想用一个计数器来跟踪事物,或者你可能实际上在与 TCP 服务器、游戏服务器或聊天应用一起工作,在这些应用中你需要保持状态并从所有对等方收集信息。本章中你学到的技术允许你这样做。你可以使用互斥锁来确保计数器是线程安全的,或者更好的是,在所有请求中是例程安全的。我建议你回到你的 HTTP 服务器代码,并使用互斥锁来确保安全性。

方法作为 Goroutines

到目前为止,你只看到了用作 Goroutines 的函数,但方法只是带有接收者的简单函数;因此,它们也可以异步使用。如果你想要共享结构体的某些属性,比如在 HTTP 服务器中的计数器,这可能会很有用。

使用这种技术,你可以在不需要将这些通道传递到每个地方的情况下,封装属于同一结构体实例的多个 Goroutines 所使用的通道。

这里有一个简单的示例,说明如何做到这一点:

type MyStruct struct {}
func (m MyStruct) doIt()
. . . . . .
ms := MyStruct{}
go ms.doIt()

但让我们看看如何在练习中应用这一点。

练习 18.10 – 结构化工作

在这个练习中,我们将使用多个工作器来计算总和。工作器本质上是一个函数,我们将把这些工作器组织到一个单独的结构体中:

  1. 创建你的文件夹和main文件。在其中,添加所需的导入并定义一个带有两个通道inoutWorker结构体。确保你添加了一个互斥锁:

    package main
    import (
      "fmt"
      "sync"
    )
    type Worker struct {
      in, out chan int
      sbw int // sbw: subworker
      mtx *sync.Mutex
    }
    
  2. 要创建其方法,请编写以下内容:

    func (w *Worker) readThem() {
      w.sbw++
      go func() {
    

    在这里,我们创建了一种方法并增加了subworker实例的数量。子工作器基本上是相同的 Goroutines,它们将需要完成的工作分割开来。请注意,该函数旨在直接使用,而不是作为一个 Goroutine,因为它本身会创建一个新的 Goroutine。

  3. 现在,构建派生 Goroutine 的内容:

        partial := 0
        for i := range w.in {
          partial += i
        }
        w.out <- partial
    
  4. 这与你之前所做的是非常相似的;现在来谈谈难点部分:

        w.mtx.Lock()
        w.sbw--
        if w.sbw == 0 {
          close(w.out)
        }
        w.mtx.Unlock()
      }()
    }
    

    在这里,我们已经锁定常规操作,安全地减少了子工作者的计数,然后,以防所有工作者都已终止,我们关闭了输出通道。接着,我们解锁执行,允许程序继续运行。

  5. 在这一点上,我们需要创建一个能够返回总和的函数:

    func (w *Worker) gatherResult() int {
      total := 0
      wg := &sync.WaitGroup{}
      wg.Add(1)
      go func() {
    
  6. 这里,我们创建一个总数,然后一个 WaitGroup,我们向它添加 1,因为我们只将生成一个 Goroutine,其内容如下:

        for i:= range w.out{
          total += i
        }
        wg.Done()
      }()
    

    如你所见,我们循环直到子工作者之一关闭了 out 通道。

  7. 在这一点上,我们可以等待 Goroutine 完成,并返回结果:

      wg.Wait()
      return total
    }
    
  8. 主代码只是为工作者及其子工作者设置变量:

    func main() {
      mtx := &sync.Mutex{}
      in := make(chan int, 100)
      wrNum := 10
      out := make(chan int)
      wrk := Worker{in: in, out: out, mtx: mtx}
    
  9. 现在,创建一个循环,在其中调用 readThem() 方法 wrNum 次。这将创建一些子工作者:

      for i := 1; i <= wrNum; i++ {
        wrk.readThem()
      }
    
  10. 现在,将需要求和的数字发送到通道:

      for i := 1;i <= 100; i++ {
        in <- i
      }
    
  11. 关闭通道以通知所有数字已发送:

      close(in)
    
  12. 然后,等待结果并打印出来:

      res := wrk.gatherResult()
      fmt.Println(res)
    }
    
  13. 如果你运行程序,你将看到使用结构体组织我们的工作所做的总和的日志输出:

    5050
    

在这个练习中,你学习了如何使用结构体的方法创建一个新的 Goroutine。这个方法可以像任何函数一样调用,但结果将创建一个新的匿名 Goroutine。

Go 上下文包

我们已经看到了如何运行并发代码,并运行它直到完成,通过 WaitGroup 或通道读取等待某些处理完成。你可能在一些 Go 代码中看到过,特别是与 HTTP 调用相关的代码,一些来自 context 包的参数,你可能想知道它是什么以及为什么使用它。

我们所写的所有代码都在我们的机器上运行,并且不通过互联网,所以我们几乎没有由于延迟造成的延迟;然而,在涉及 HTTP 调用的场合,我们可能会遇到不响应的服务器并卡住。在这种情况下,如果服务器在一段时间后没有响应,我们如何停止我们的调用?当发生事件时,我们如何停止独立运行的例程的执行?嗯,我们有几种方法,但一种标准的方法是使用上下文,我们现在将看到它们是如何工作的。上下文是一个变量,它通过一系列调用传递,可能包含一些值或可能为空。它是一个容器,但它不是用来在函数之间发送值的;你可以使用正常的整数、字符串等来达到这个目的。上下文被传递以获取对正在发生的事情的控制:

func doIt(ctx context.Context, a int, b string) {
  fmt.Println(b)
  doThat(ctx, a*2)
}
func doThat(ctx context.Context, a int) {
  fmt.Println(a)
  doMore(ctx)
}

如你所见,有几个调用,并且 ctx 被传递,但我们没有对它做任何事情。然而,它可以包含数据,并且它包含我们可以用来停止当前 Goroutine 执行的函数。我们将在下一个练习中看到它是如何工作的。

练习 18.11 – 使用上下文管理 Goroutines

在这个练习中,我们将启动一个 Goroutine,它将启动一个无限循环,从零开始计数,直到我们决定停止它。我们将使用上下文来通知例程停止,并使用睡眠函数来确保我们知道我们执行了多少次迭代:

  1. 创建你的文件夹和一个 main.go 文件,然后编写以下内容:

    package main
    import (
      "context"
      "log"
      "time"
    )
    

    对于常规导入,我们有 logstime,我们已经见过,还有 context 包。

  2. 让我们编写一个每 100 毫秒从 0 开始计数的函数:

    func countNumbers(ctx context.Context, r chan int) {
      v := 0
      for {
    

    这里,v 是我们从零开始计数的值。ctx 变量是上下文,而 r 变量是返回结果的通道。然后,我们开始定义一个循环。

  3. 现在,我们启动一个无限循环,但在循环内部,我们将使用 select

        select {
          case <-ctx.Done():
          r <- v
          return
    

    在这个 select 组中,我们有一个检查上下文是否 done 的情况,如果是,我们就退出循环并返回到目前为止所计的值。

  4. 如果上下文不是 done,我们需要继续计数:

          default:
          time.Sleep(time.Millisecond * 100)
          v++
        }
      }
    }
    

    在这里,我们暂停 100 毫秒,然后数值增加 1。

  5. 下一步是编写一个 main() 函数来使用这个计数器:

    func main() {
      r := make(chan int)
      ctx := context.TODO()
    

    我们创建一个整数通道来传递给计数器和上下文。

  6. 我们需要能够取消上下文,因此我们扩展了这个简单的上下文。为了清晰起见,cl 是可取消上下文的变量名,而 stop 是我们选择的取消它的函数名:

    cl, stop := context.WithCancel(ctx)
    go countNumbers(cl, r)
    

    这里,我们也最终调用了计数 Goroutine。

  7. 在这一点上,我们需要一种方法来退出循环,所以我们将使用 context.WithCancel 返回的 stop() 函数,但我们将在另一个 Goroutine 中这样做。这将使上下文在 300 毫秒后停止:

      go func() {
        time.Sleep(time.Millisecond*100*3)
        stop()
      }()
    
  8. 现在,我们只需要等待接收带有计数的消息并将其记录下来:

      v := <- r
      log.Println(v)
    }
    

经过 300 毫秒后,计数器将返回 3,因为由于上下文操作,例程在第三次迭代时停止:

2024/01/25 20:00:58 3

这里,我们可以看到,尽管循环是无限的,但在三次迭代后执行停止。

在这个练习中,你学习了如何使用上下文来停止 Goroutine 的执行。这在许多情况下都很有用,例如在执行长时间任务时,你希望在最大时间或发生某些事件后停止。

有一点需要说明的是,在这个练习中,我们做了一些在某些情况下可能导致问题的操作。我们创建了一个在 Goroutine 中创建的通道,但在另一个 Goroutine 中关闭它。这并不错误;在某些情况下,这可能是有用的,但尽量避免它,因为它可能导致在某人查看代码或几个月后查看代码时出现问题,因为很难跟踪在多个函数中关闭通道的位置。

与 sync.Cond 的并发工作

在不同的 Goroutines 之间进行有效的协调对于确保平稳执行和资源管理至关重要。Go 标准库提供的另一个强大的同步原语是 sync.Cond(条件)。Cond 类型与 sync.Mutex 关联,并为 Goroutines 提供了一种等待或通知特定条件发生或共享数据变化的方法。

让我们通过创建一个简单的工作进度WIP)限制队列的例子来探索如何使用 sync.Cond

练习 18.12 – 创建一个工作进度限制队列

假设你有一个场景,其中多个 Goroutines 产生和消费项目,但你希望限制当前进行中的项目数量。sync.Cond 可以帮助实现这种同步。以下是使用它的方法:

  1. 创建你的文件夹和一个 main.go 文件,然后编写以下内容:

    package main
    import (
      "fmt"
      "sync"
      "time"
    )
    

    我们导入 fmtsynctime,这些我们已经见过。

  2. 让我们定义一个 WorkInProgressQueue 和一个函数来创建一个新的 WorkInProgressQueue 对象:

    type WorkQueue struct {
      cond *sync.Cond
      maxSize int
      workItems []string
    }
    func NewWorkQueue(maxSize int) *WorkQueue {
      return &WorkQueue{
        cond: sync.NewCond(&sync.Mutex{}),
        maxSize: maxSize,
        workItems: make([]string, 0),
      }
    }
    
  3. 现在,我们定义一个 enqueue() 函数来添加工作项,同时尊重工作队列上的最大大小约束:

    func (wq *WorkQueue) enqueue(item string) {
      wq.cond.L.Lock()
      defer wq.cond.L.Unlock()
      for len(wq.workItems) == wq.maxSize {
        wq.cond.Wait()
      }
      wq.workItems = append(wq.workItems, item)
      wq.cond.Signal()
    }
    
  4. 然后,定义一个 dequeue() 函数,其中我们消费工作项:

    func (wq *WorkQueue) dequeue() string {
      wq.cond.L.Lock()
      defer wq.cond.L.Unlock()
      for len(wq.workItems) == 0 {
        wq.cond.Wait()
      }
      item := wq.workItems[0]
      wq.workItems = wq.workItems[1:]
      wq.cond.Signal()
      return item
    }
    
  5. 现在我们定义一个 main() 函数和我们的工作队列的最大容量为三项:

    func main() {
      var wg sync.WaitGroup
      workQueue := NewWorkQueue(3)
    
  6. 接下来,我们定义第一个 Goroutine。这个 Goroutine 负责产生工作项:

      wg.Add(1)
      go func() {
        defer wg.Done()
        for i := 1; i <= 5; i++ {
          workItem := fmt.Sprintf("WorkItem %d", i)
          workQueue.enqueue(workItem)
          fmt.Printf("Enqueued: %s\n", workItem)
          time.Sleep(time.Second)
        }
      }()
    
  7. 然后我们定义第二个 Goroutine。这个 Goroutine 负责消费工作项:

      wg.Add(1)
      go func() {
        defer wg.Done()
        for i := 1; i <= 5; i++ {
          workItem := workQueue.dequeue()
          fmt.Printf("Dequeued: %s\n", workItem)
          time.Sleep(2 * time.Second)
        }
      }()
    
  8. 最后,我们等待所有 Goroutines 完成,并关闭我们的 main() 函数:

      wg.Wait()
    }
    
  9. 运行程序:

    go run main.go
    

你将看到以下输出,其中项目可能以不同的顺序入队和出队:

Enqueued: WorkItem 1
Dequeued: WorkItem 1
Enqueued: WorkItem 2
Dequeued: WorkItem 2
Enqueued: WorkItem 3
Enqueued: WorkItem 4
Enqueued: WorkItem 5
Dequeued: WorkItem 3
Dequeued: WorkItem 4
Dequeued: WorkItem 5

这个练习演示了一个简单的工作队列,其中 Goroutine 将项目入队到队列的最大大小。如果队列已满,则 Goroutine 将等待直到队列中有更多空间。一旦项目入队,它将通知可能正在等待条件变量的其他 Goroutines。还有一个第二个 Goroutine,或者消费者 Goroutine,它从队列中出队项目。消费者在队列空到五项时等待。出队一个项目后,它向可能正在等待条件变量的其他 Goroutines 发出信号。正如你所看到的,sync.Cond 变量用于信号和等待 Goroutines。

线程安全的映射

在并发编程中,安全地管理对共享数据结构的访问对于避免竞态条件和确保一致性至关重要。Go 的标准库提供了一个用于并发映射访问的强大工具 – sync.Map 类型。与常规 Map 类型不同,sync.Map 是专门设计用来在不需要外部同步的情况下并发使用的。

sync.Map 类型是 sync 包的一部分,它内部提供细粒度锁定,允许多个读取者和单个写入者并发访问映射而不会阻塞操作。这使得它在有多个 Goroutines 需要并发读取或修改映射的场景中非常适用。

让我们看看一个展示 sync.Map 作用的练习。

练习 18.13 – 使用 sync.Map 计算随机数在 0 到 9 之间的次数

假设我们想在并发环境中计算随机数落在零到九之间的次数。sync.Map 类型将帮助我们安全地完成这项任务:

  1. 创建你的文件夹和一个 main.go 文件,然后编写以下内容:

    package main
    import (
      "crypto/rand"
      "fmt"
      "math/big"
      "sync"
    )
    
  2. 让我们编写一个函数来生成一个在 [0, max) 范围内的随机数:

    func generateRandomNumber(max int) (int, error) {
      n, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
      if err != nil {
        return 0, err
      }
      return int(n.Int64()), nil
    }
    
  3. 让我们编写一个辅助函数来使用加载和存储方法更新计数,以安全地访问和更新计数映射:

    func updateCount(countMap *sync.Map, key int) {
      count, _ := countMap.LoadOrStore(key, 0)
      countMap.Store(key, count.(int)+1)
    }
    
  4. 现在,编写一个函数来打印 sync.Map 内容中的计数:

    func printCounts(countMap *sync.Map) {
      countMap.Range(func(key, value interface{}) bool {
        fmt.Printf("Number %d: Count %d\n", key, value)
        return true
      })
    }
    
  5. 最后,我们可以定义一个 main() 函数,该函数将定义我们的 sync.Map 类型和一个生成随机数并更新 sync.Map 类型中计数的 Goroutine:

    func main() {
      var countMap sync.Map
      numGoroutines := 5
      var wg sync.WaitGroup
      generateAndCount := func() {
        defer wg.Done()
        // Generate 1000 random numbers per Goroutine
        for i := 0; i < 1000; i++ {
          // Generate random number between 0 and 9
          randomNumber, err := generateRandomNumber(10)
          if err != nil {
            fmt.Println("Error generating random number:", err)
            return
          }
          updateCount(&countMap, randomNumber)
        }
      }
    
  6. 然后,我们将通过启动所有我们的 Goroutines 并等待它们完成,在打印并发安全映射中的计数之前结束我们的 main() 函数:

      for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go generateAndCount()
      }
      wg.Wait()
      printCounts(&countMap)
    }
    
  7. 运行程序:

    go run main.go
    

你将看到以下输出,其中项目可能以不同的顺序和不同的计数出现:

Number 7: Count 480
Number 0: Count 488
Number 5: Count 506
Number 4: Count 489
Number 1: Count 472
Number 9: Count 499
Number 2: Count 499
Number 6: Count 515
Number 3: Count 481
Number 8: Count 533

在这个练习中,我们使用了 sync.Map 类型来安全地维护每个由多个 Goroutines 生成的随机数的准确计数。我们有一个 updateCount 函数,负责使用 LoadOrStoreStore 方法以线程安全的方式更新计数。我们刚刚看到了如何在不使用额外的同步机制的情况下使用这个线程安全的映射。使用 sync.Map 简化了并发映射访问,并消除了显式锁的需求,使得代码在需要并发访问映射的场景中更加简洁和高效。

摘要

在本章中,你学习了如何创建生产就绪的并发代码,如何处理竞态条件,以及如何确保你的代码是并发安全的。你学习了如何使用通道使你的 Goroutines 互相通信,以及如何使用上下文停止它们的执行。

你已经掌握了多种处理并发计算的技术,并学习了sync.Condsync.Map作为你在并发编程工具箱中的强大工具。在许多实际场景中,你可能只是使用为你处理并发的函数和方法,尤其是如果你在进行 Web 编程,但也有一些情况你必须自己处理来自不同来源的工作。你需要通过不同的渠道匹配请求和响应。你可能需要从不同的来源收集不同的数据到一个单一的 Goroutine 中。通过在这里学到的知识,你将能够做到所有这些。你将能够确保通过等待所有 Goroutine 完成来避免数据丢失。你将能够从不同的 Goroutine 中修改相同的变量,确保如果你不希望覆盖值时不会覆盖。你还学习了如何避免死锁以及如何使用通道来共享信息。Go 的一个座右铭是通过通信共享,不要通过共享来通信。这意味着共享值的首选方式是通过通道发送,如果不是绝对必要,则不依赖于互斥锁。你现在知道如何做到所有这些。

在下一章中,你将学习如何让你的代码更加专业。本质上,你将学习作为一个专业人士在真实工作环境中应该做什么,那就是测试和检查你的代码——基本上确保你的代码能够工作并且是有效的。

第十九章:测试

概述

测试是软件开发的一个关键方面,它确保了代码的可靠性和正确性。在 Go 中,全面的测试方法涵盖了各种类型的测试,每种测试都服务于独特的目的。本章探讨了 Go 中可用的不同测试技术和工具,以赋予开发者构建健壮和可维护应用程序的能力。

在本章结束时,你将了解 Go 开发者所实施的各类测试。我们将讨论三大测试类型:单元测试、集成测试和端到端测试E2E)。然后,我们将介绍其他几种测试类型,例如 HTTP 测试和模糊测试。我们将涵盖测试套件、基准测试和代码覆盖率,甚至为项目利益相关者创建最终的测试报告,或者只是为了分享你的代码真正测试得有多好。你还将看到定期自动测试你的代码的好处,同时持续迭代你的代码库。这些技能对于开发生产就绪和行业级应用程序至关重要。测试也是软件开发生命周期(SDLC)的重要组成部分,我们作为开发者,在项目过程中会经历这一部分。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter19找到。

简介

测试是软件开发的一个基本方面,它确保了应用程序的可靠性、正确性和稳定性。在 Go 中,测试在维护软件系统的健壮性方面发挥着关键作用。

如我之前所述,测试是软件开发生命周期(SDLC)的一个关键部分,它涵盖了从需求收集到部署的各个阶段。质量保证从实施有效的测试策略开始。健壮的测试不仅能够识别和纠正代码中的错误和缺陷,还能促进可维护性和可扩展性。考虑一个场景,一个关键的金融应用程序缺乏全面的测试。一个无害的代码更改可能会无意中引入一个未被注意到的错误,直到应用程序在灾难性失败时才被发现。因此,测试充当了一个安全网,在开发生命周期的早期阶段捕捉潜在的问题。如果一个团队受到一个在他们的不同环境中推出的错误的冲击,开发者应该积极创建测试来覆盖该场景,以便未来使用——无论他们使用什么语言编码。

Go 提供了一个简单而强大的内置测试框架。利用测试包,开发者可以创建单元测试、基准测试,甚至是基于示例的文档测试。测试包旨在具有表现力,这使得编写、阅读和维护测试变得容易。通过测试文件后缀(_test.go)和以Test开头的前缀清晰的测试函数签名等约定,Go 鼓励一种标准化的测试方法,这有助于在所有项目中保持一致性。值得注意的是,在 Go 中进行测试时没有main()函数来控制程序流程。每个测试函数都是独立且连续执行的。

在本章中,我们将深入探讨 Go 测试的细节,包括编写有效的单元测试、基准测试、表驱动测试等内容。有了对 Go 测试原则的扎实理解,开发者将能够构建可靠且可维护的软件应用程序。

单元测试

测试你的应用程序的一个基本方面是从单元测试开始的。单元测试专注于单个组件,验证每个函数或方法是否按预期工作。在 Go 中,单元测试使用内置的测试包编写,这使得定义测试函数和断言变得容易。

你通常会定义正例和反例单元测试。例如,如果你有一个连接几个字符串的函数,那么你会有一些正例测试用例,例如"hi " + "sam" = "hi sam""bye," + " sam" = "bye, sam"。你还会添加一些反例测试用例,以验证输入是否发生了错误,例如"hi" + "there" expecting the result of "hi sam"。这并不等价,也不是我们期望的输出,因此我们的反例测试用例期望出现错误。

你还可以考虑边缘情况,例如连接包含标点符号的字符串,并确保它们包含在连接中,并且强制执行正确的语法语法和大小写。这为测试用例提供了覆盖,你期望它们能正常工作,测试用例你期望会产生错误,以及测试覆盖你函数或方法的边缘或角落情况。

由于其惯用性,对于 Go 程序员来说,编写符合习惯和易于阅读的代码应该是自然而然的。因此,Go 采用表驱动测试结构进行所有测试,包括单元测试,这并不令人惊讶。表驱动测试保持代码可读性、灵活性和适应未来变化的能力。它们包括定义一个匿名using结构体,定义为TestsTestCases,其中你包括测试用例的名称、输入或参数和预期输出。让我们看看这个例子是如何实施的。

练习 19.01 – 表驱动测试

让我们看看创建惯用表驱动单元测试的例子:

  1. 在你的文件系统中创建一个新的文件夹,并在其中创建一个main_test.go文件,并编写以下代码。我们包括gotest.tools模块中的assert包,因为它为 Go 中的测试提供了实用工具和增强。具体来说,assert包以表达性和可读性的方式提供断言,所以它是一个很好的包:

    package main
    import (
      "testing"
      "gotest.tools/assert"
    )
    
  2. 创建一个函数,用于计算两个数字的和:

    func add(x, y int) int {
      return x + y
    }
    
  3. 定义一个表格驱动的测试函数来检查数值加法是否正确,并添加一些测试用例进行检查。我们将添加一个预期值错误的测试用例来查看其表现:

    func TestAdd(t *testing.T) {
      tests := []struct {
        name string
        inputs []int
        want int
      }{
        {
          name: "Test Case 1",
          inputs: []int{5, 6},
          want: 11,
        },
        {
          name: "Test Case 2",
          inputs: []int{11, 7},
          want: 18,
        },
        {
          name: "Test Case 3",
          inputs: []int{1, 8},
          want: 9,
        },
        {
          name: "Test Case 4 (intentional failure)",
          inputs: []int{2, 3},
          want: 0, // This should be 5, intentionally incorrect to demonstrate failure
        },
      }
    
  4. 遍历每个测试用例,断言接收到的值是预期的,然后关闭函数。我们同样可以使用if条件语句而不是assert包;然而,它可以使代码更加紧凑和整洁,所以你通常会看到这个包被用来断言测试值是否正确:

        for _, test := range tests {
            got := add(test.inputs[0], test.inputs[1])
            assert.Equal(t, test.want, got)
        }
      }
    
  5. 运行程序:

    go test main_test.go
    

    你将看到以下输出:

        main_test.go:45: assertion failed: 8 (test.want int) != 5 (got int)
    FAIL
    FAIL    command-line-arguments  0.168s
    FAIL
    

    如你所见,assert包使得在测试函数中看到失败变得容易。然而,这个输出使得知道特定的哪个测试用例失败变得有些困难。

  6. 现在,如果我们修复故意错误的测试用例,我们将看到以下输出:

    ok      command-line-arguments  0.153s
    

    你现在已经看到了 Go 中单元测试的表格驱动测试的样子。然而,我们可以对这个代码进行一些改进,使其更加易读——例如,我们可以调整代码,使其利用子测试。Go 中的子测试提供了几个好处:

    • 如果适用,隔离设置和清理逻辑

    • 清晰的测试输出

    • 能够添加并行执行

    • 结构化测试组织

    • 条件测试执行

    • 改进测试可读性

    现在我们已经看到了子测试的一些好处,让我们看看当它被添加到之前的单元测试函数中时的样子。为此,我们可以简单地更新 for 循环逻辑:

    for _, test := range tests {
      test := test
      t.Run(test.name, func(t *testing.T) {
        got := add(test.inputs[0], test.inputs[1])
        assert.Equal(t, test.want, got)
      })
    }
    
  7. 更新后再次运行程序:

    go test main_test.go
    

你将看到以下输出:

--- FAIL: TestAdd (0.00s)
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestAdd$ github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Exercise19.01
--- FAIL: TestAdd (0.00s)
--- FAIL: TestAdd/Test_Case_4_(intentional_failure) (0.00s)
/Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Exercise19.01/main_test.go:45: assertion failed: 0 (test.want int) != 5 (got int)
FAIL
FAIL github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Exercise19.01 0.164s
FAIL

从这里,你可以看到失败的测试输出如何通过测试用例中的name字段帮助我们确定哪个测试用例失败。此外,测试函数的 for 循环现在包括test := test。这是由于在函数字面量中使用变量在范围作用域内。如果你不包含这一行,那么 lint 器会因为函数字面量(闭包)中test循环变量的使用问题而抱怨。

当你在函数字面量中使用循环变量时,它会通过引用捕获循环变量。这可能导致意外的行为,因为循环变量在 for 循环的所有迭代中是共享的。为了纠正这个问题,你可以在循环内创建循环变量的局部副本,以避免通过引用捕获它,使用我们添加的行。

注意

本练习的完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/blob/main/Chapter19/Example01/main_test.go找到。

我们已经看到了如何编写单元测试以确保单个组件的正确性,并在 Go 中使用表格驱动测试。你也看到了 Go 中所有测试函数都以Test开头,并涉及传递testing.T参数。你现在知道了命名测试用例、定义正负测试用例以覆盖边缘情况,以及在单元测试中注意良好实践和测试覆盖率的好处。单元测试是添加起来最容易的测试之一,因此它们应该很多,并且随着代码的增长而容易适应。

集成测试

集成测试验证了应用程序中不同组件或服务之间的交互。这可能包括一个服务与另一个服务交互的测试,或者一个服务与多个服务交互的测试。总的来说,这些测试确保集成系统作为一个整体正确运行。它们通常比单元测试需要更多的设置,并且实现起来需要更多的时间。这些测试旨在验证应用程序各个部分之间的协作和通信。

集成测试在确保系统的不同组件无缝协作中起着至关重要的作用。它们有助于揭示与数据流、应用程序编程接口API)集成、数据库交互和其他协作方面相关的问题,这些问题在单元测试期间可能不明显。这类测试对于检测组件在现实场景中交互时可能出现的问题非常重要。

在设置集成测试时,你需要考虑以下几点:

  • 测试环境:集成测试通常需要一个与生产环境相似或可能需要专用测试数据库和/或模拟服务的测试环境。

  • 数据设置:为集成测试准备模拟真实场景的数据是常见的。这可能涉及用特定数据填充数据库或配置外部服务以使用测试用例数据。

  • gomocktestify/mock可以帮助创建用于测试的模拟。

练习 19.02 – 带数据库的集成测试

使用内存数据库对于集成测试来说是一个不错的选择,因为它们不会影响实时数据库。让我们看看一个模拟数据库、期望数据库发生某些事件并使用assert包检查我们值的练习:

  1. 在你的文件系统中创建一个新的文件夹,并在其中创建一个main_test.go文件,并编写以下内容:

    package main
    import (
      "context"
      "database/sql"
      "testing"
      "github.com/DATA-DOG/go-sqlmock"
      "github.com/stretchr/testify/assert"
      "github.com/stretchr/testify/require"
    )
    
  2. 定义一个Record数据对象,你可以用它来检查数据库操作:

    type Record struct {
      ID int
      Name string
      Value string
    }
    
  3. 创建数据库结构和创建新数据库的函数:

    type Database struct {
      conn *sql.DB
    }
    func NewDatabase(conn *sql.DB) *Database {
      return &Database{conn: conn}
    }
    
  4. 创建数据库的插入函数:

    func (d *Database) InsertRecord(ctx context.Context, record Record) error {
      _, err := d.conn.ExecContext(ctx, "INSERT INTO records (id, name, value) VALUES ($1, $2, $3)", record.ID, record.Name, record.Value)
      return err
    }
    
  5. 创建一个从数据库检索插入对象的函数:

    func (d *Database) GetRecordByID(ctx context.Context, id int) (Record, error) {
      var record Record
      row := d.conn.QueryRowContext(ctx, "SELECT id, name, value FROM records WHERE id = $1", id)
      err := row.Scan(&record.ID, &record.Name, &record.Value)
      return record, err
    }
    
  6. 创建一个测试函数来检查与内存数据库的集成,并通过创建内存 SQL 模拟以及与数据库交互的测试记录来执行设置:

    func TestDatabaseIntegration(t *testing.T) {
      db, mock, err := sqlmock.New()
      require.NoError(t, err)
      defer db.Close()
      testRecord := Record{
        ID: 1,
        Name: "TestRecord",
        Value: "TestValue",
      }
    
  7. 设置 SQL 模拟数据库的期望:

      mock.ExpectExec("INSERT INTO records").WithArgs(testRecord.ID, testRecord.Name, testRecord.Value).WillReturnResult(sqlmock.NewResult(1, 1))
      rows := sqlmock.NewRows([]string{"id", "name", "value"}).AddRow(testRecord.ID, testRecord.Name, testRecord.Value)
      mock.ExpectQuery("SELECT id, name, value FROM records").WillReturnRows(rows)
    Create the database and insert a record into it:
      dbInstance := NewDatabase(db)
      err = dbInstance.InsertRecord(context.Background(), testRecord)
      assert.NoError(t, err, "Error inserting record into the database")
    
  8. 验证您可以从数据库中检索插入的记录,确保数据库上所有模拟的期望都得到满足,并关闭测试函数:

      retrievedRecord, err := dbInstance.GetRecordByID(context.Background(), 1)
      assert.NoError(t, err, "Error retrieving record from the database")
      assert.Equal(t, testRecord, retrievedRecord, "Retrieved record does not match the inserted record")
      assert.NoError(t, mock.ExpectationsWereMet())
    }
    
  9. 运行程序:

    go test main_test.go
    

您将看到以下输出:

  ok      command-line-arguments  0.252s

我们现在已经看到了如何使用模拟资源进行集成测试以及执行数据库交互的样子。这个测试检查了在内存数据库中记录的插入和检索。您可以轻松扩展此代码以检查项目可能交互的不同数据库,或者用它作为灵感来检查额外的项目交互。

端到端测试

在 Go 语言中,端到端测试对于评估整个系统至关重要。与关注独立代码单元的单元测试不同,或者与可能检查某些组件是否按预期合作的集成测试不同,端到端测试对整个系统进行测试,模拟真实用户场景。这些测试有助于捕捉可能由各种组件集成引起的问题,确保应用程序的整体功能。

端到端测试的目的是验证整个应用程序,包括其用户界面、API 和底层服务,是否按预期运行。这些测试模拟用户与系统交互的动作,覆盖多个层次和组件。通过测试应用程序的完整流程,端到端测试有助于识别系统中的集成问题、配置问题或可能在现实世界环境中出现的意外行为。

端到端测试有几个显著特点:

  • 现实场景:端到端测试模拟用户流程或业务流程,确保应用程序从最终用户的角度来看表现如预期。这种现实感有助于捕捉在更隔离的测试中可能不明显的问题。

  • 多组件交互:在典型应用中,各种组件,如数据库、API 和用户界面,共同工作以提供功能。端到端测试同时对这些组件进行测试,验证它们的交互和兼容性。

  • 与生产环境相似的环境:端到端测试通常在接近生产设置的环境中运行。这确保了测试能够准确地反映应用程序在实际条件下的行为。

在 Go 中实现 E2E 测试时,可以使用 testingtesting/httptest 等工具包,这些工具包有助于有效地构建和执行 E2E 测试。这些测试通常涉及设置应用程序,以编程方式与之交互,并断言预期的结果与实际结果相匹配。

下面是一些 E2E 测试的最佳实践:

  • 隔离:E2E 测试应在隔离环境中运行,以防止与其他测试或生产级系统发生干扰。这确保了测试结果的可靠性和一致性。

  • 自动化:由于这些测试的复杂性,自动化至关重要。自动化的 E2E 测试可以集成到 持续集成CI)管道中,允许定期验证应用程序的 E2E 功能。

  • 清晰的测试场景:定义清晰且具有代表性的测试场景,涵盖关键的用户旅程。这些场景应包括用户在应用程序中采取的最常见路径。

  • 数据管理:有效地设置和管理测试数据。这包括创建数据固定或使用数据库迁移,以确保每次测试运行的一致状态,确保一个测试不会干扰另一个测试。

E2E 测试是设置项目时最复杂的测试之一,并且完成它们需要花费最多的时间。然而,它们有助于增强对系统整体功能的信心,并且可以在开发早期阶段捕捉到集成问题。

HTTP 测试

在 Go 中,HTTP 测试有助于验证网络服务和应用程序的行为和功能。这些测试确保 HTTP 端点对各种请求做出正确的响应,适当地处理错误,并与底层逻辑无缝交互。测试 HTTP 层对于构建健壮和可靠的应用程序至关重要,因为它允许开发者验证其 API 实现的正确性,并在开发早期阶段捕捉到问题。

HTTP 测试的重要性可以通过以下方面总结:

  • 功能验证:HTTP 测试验证了 API 端点的功能方面,确保它们在不同场景下产生预期的响应。这包括检查状态码、响应体和头信息。

  • 集成验证:HTTP 测试有助于验证应用程序不同组件之间的集成。它们有助于确认各种服务是否通过 HTTP 正确通信,同时遵守定义的契约。

  • 错误处理:测试 HTTP 错误场景对于确保您的 API 对错误请求做出适当的响应至关重要。这包括测试适当的错误代码、错误消息和错误处理行为。

  • 安全保证:HTTP 测试是安全测试的一个组成部分。它允许开发者验证认证和授权机制是否按预期工作,以及敏感信息是否得到安全处理。

Go 提供了一个强大的测试框架,使得编写 HTTP 测试变得简单直接。net/http/httptest包提供了一个测试服务器,它允许创建用于测试的隔离 HTTP 环境。这对于测试你的应用程序如何与外部服务或 API 交互特别有用。

练习 19.03 – 与测试服务器的认证集成

考虑这样一个场景,我们有一个通过 HTTP 与认证服务通信的应用程序。我们希望有一个模拟一些认证逻辑并测试主应用程序用户认证功能的认证服务测试服务器。让我们开始吧:

  1. 在你的文件系统中创建一个新的文件夹,并在其中创建一个main_test.go文件,并编写以下内容:

    package main
    import (
      "net/http"
      "net/http/httptest
      "testing"
      "github.com/stretchr/testify/assert"
    )
    
  2. 定义一个User结构体和一个Application结构体,以及一个新的应用程序函数:

    type User struct {
      UserID string
      Username string
    }
    type Application struct {
      AuthServiceURL string
    }
    func NewApplication(authServiceURL string) *Application {
      return &Application{
        AuthServiceURL: authServiceURL,
      }
    }
    
  3. 创建一个模拟用户认证过程的函数:

    func (app *Application) AuthenticateUser(token string) (*User, error) {
      return &User{
        UserID: "123",
        Username: "testuser",
      }, nil
    }
    
  4. 定义测试函数并设置用于认证服务的测试服务器:

    func TestAuthenticationIntegration(t *testing.T) {
      authService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") == "Bearer valid_token" {
          w.WriteHeader(http.StatusOK)
          w.Write([]byte(`{"user_id": "123", "username": "testuser"}`))
        } else {
          w.WriteHeader(http.StatusUnauthorized)
        }
      }))
      defer authService.Close()
    
  5. 创建应用程序,测试认证过程,然后关闭测试功能:

      app := NewApplication(authService.URL)
      token := "valid_token"
      gotUser, err := app.AuthenticateUser(token)
      assert.NoError(t, err)
      assert.Equal(t, "123", gotUser.UserID)
      assert.Equal(t, "testuser", gotUser.Username)
    }
    
  6. 运行程序:

    go test main_test.go
    

你将看到以下输出:

  ok      command-line-arguments  0.298s

在这个练习中,我们使用了httptest.NewServer函数来创建认证服务的测试服务器,使我们能够在测试期间控制其行为。然后测试设置了主应用程序,触发了认证过程,并断言了预期的结果。这有助于突出如何将 HTTP 测试集成到构建可靠的 Web 应用程序中。

模糊测试

模糊测试,或称模糊化,涉及向函数提供随机或格式不正确的输入以发现漏洞。换句话说,它是一种测试技术,涉及向程序提供无效、意外或随机数据作为输入,目的是发现漏洞和错误。Go 标准库中的测试包包括对模糊测试的支持,使开发者能够发现代码中的意外行为。

与传统的测试不同,传统的测试依赖于预定义的测试用例,模糊测试则广泛地探索输入空间。你可以将其视为模糊测试经常使程序承受大量随机或格式不正确的输入数据,以观察会发生什么。由于模糊测试与传统测试用例有很大不同,它们可以帮助识别边缘情况和可能触发错误或漏洞的意外输入场景。如果以自动化的方式建立,它们还可以提供持续的安全性和稳定性验证。

对于我们之前提到的add函数的一个简单模糊测试示例如下:

func add(x, y int) int {
  return x + y
}
func FuzzAdd(f *testing.F) {
  f.Fuzz(func(t *testing.T, i int, j int) {
    got := add(i, j)
    assert.Equal(t, i + j, got)
  })
}

与我们之前看到的测试函数相比,这个函数有一些不同之处。例如,模糊测试函数以 Fuzz 开头,并传递 f *testing.F 而不是 t *testing.T。您还可以看到,由于我们不必担心生成自己的输入,与本章早期所有测试用例相比,该函数非常简单且干净。此外,我们不再使用 t.Run 执行,而是使用 f.Fuzz,它接受一个模糊目标函数以及我们常用的 t *testing.T 和模糊输入类型。

最后,需要注意的是,要查看模糊测试结果,必须运行以下命令:

go test –fuzz .

这将运行测试文件中存在的模糊测试。需要注意的是,您的模糊测试可以与测试文件中的其他测试共存。您还将看到类似于以下的结果:

fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 331780 (110538/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 6s, execs: 709743 (126040/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 9s, execs: 1123414 (137875/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 12s, execs: 1417293 (97927/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 15s, execs: 1713062 (98627/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 18s, execs: 2076324 (121080/sec), new interesting: 0 (total: 1)
^Cfuzz: elapsed: 20s, execs: 2237555 (107504/sec), new interesting: 0 (total: 1)
PASS
ok      github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Example    19.697s

前面的输出中新的有趣之处在于添加到语料库中的输入数量,这些输入提供了独特的结果。Execs 是运行的单个测试的数量。如您所见,这比我们之前在单元测试用例中手动测试的数量大得多。模糊测试可以帮助提供大量的输入。如果您有一个失败的模糊测试,那么失败的种子语料库条目将被写入文件并放置在包目录中。然后您可以使用该信息来纠正失败。

基准测试

基准测试通过测量特定函数的执行时间来评估代码的性能。testing 包提供了对基准测试的支持,允许开发者识别性能瓶颈,确定开发者是否实现了项目的服务级别指标SLIs)或服务级别目标SLOs),并深入了解他们的应用程序。

与模糊测试有略微不同的语法,但遵循我们通常的 Go 测试设置期望一样,基准测试看起来也不同,但与我们习惯的非常相似。例如,基准测试以单词 Benchmark 开头,并接受 b *testing.B。测试运行器会执行每个基准函数多次,每次运行都会增加 b.N 的值。

这里是一个针对我们的加法函数的简单基准函数示例:

func BenchmarkAdd(b *testing.B) {
  for i := 0; i < b.N; i++ {
    add(1, 2)
  }
}

要运行基准测试,您必须为 Go 测试框架添加基准标志:

go test –bench .

这将给出类似于以下的结果:

goos: darwin
goarch: arm64
pkg: github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Example
BenchmarkAdd-10      1000000000      0.3444 ns/op
PASS
ok      github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Example  0.538s

1000000000 的数值表示基准测试执行的迭代次数或操作数;在这种情况下,是 10 亿次操作。0.3444 ns/op 是每次操作的平均时间,以纳秒为单位。这表明,平均而言,每次调用 add 函数大约花费了 0.3444 纳秒。

您可以编写更复杂的基准函数,以便从代码的性能中获得更多意义,从而包括每个操作的堆分配和每个操作的字节分配。

在基准测试中,使用真实世界的输入来获取代码的最准确性能指标非常重要。你还应该将基准测试分解成多个函数,以获得更好的粒度,这将有助于你评估代码的性能。

测试套件

测试套件将多个相关的测试组织成一个统一的单元。Go 中的testing包支持使用TestMain函数和testing.M类型的测试套件。TestMain是一个特殊函数,可以用于执行整个测试套件的设置和清理逻辑。当你需要设置跨多个测试套件的资源或配置时,这特别有用。

练习 19.04 – 使用 TestMain 执行多个测试函数

考虑一个场景,你有多个测试函数想要分组到一个测试套件中。让我们看看如何使用本机 Go 测试框架来实现这一点:

  1. 在你的文件系统中创建一个新的文件夹,并在其中创建一个main_test.go文件,并编写以下内容:

    package main
    import (
      "log"
      "testing"
    )
    
  2. 定义设置和清理函数:

    func setup() {
      log.Println("setup() running")
    }
    func teardown() {
      log.Println("teardown() running")
    }
    
  3. 创建TestMain函数:

    func TestMain(m *testing.M) {
      setup()
      defer teardown()
      m.Run()
    }
    
  4. 定义一些测试用例以运行TestMain

    func TestA(t *testing.T) {
      log.Println("TestA running")
    }
    func TestB(t *testing.T) {
      log.Println("TestB running")
    }
    func TestC(t *testing.T) {
      log.Println("TestC running")
    }
    
  5. 以详细模式运行程序以查看TestMain执行我们的测试函数:

    go test –v main_test.go
    

你将看到以下输出:

2024/02/05 23:34:29 setup() running
=== RUN   TestA
2024/02/05 23:34:29 TestA running
--- PASS: TestA (0.00s)
=== RUN   TestB
2024/02/05 23:34:29 TestB running
--- PASS: TestB (0.00s)
=== RUN   TestC
2024/02/05 23:34:29 TestC running
--- PASS: TestC (0.00s)
PASS
2024/02/05 23:34:29 teardown() running
ok      github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Exercise20.04    0.395s

在这里,你可以看到TestMain如何执行设置,然后执行我们在包中定义的测试函数,最后通过defer函数执行清理。这允许你编写可能相关的多个测试函数,并将它们分组为套件一起运行。通过使用TestMain,你可以更好地控制整个测试套件的设置和清理过程,从而有效地处理测试套件的共享资源和配置。

测试报告

编写测试对于维护应用程序的正确性至关重要;然而,了解测试结果同样重要。生成测试报告可以清晰地概述测试结果。这有助于识别问题并随着时间的推移跟踪代码库的改进。Go 的test命令支持各种标志来定制测试输出。

Go 提供了json标志,可用于生成可机器读取的 JSON 输出。然后,可以使用各种工具处理或分析此输出。要生成 JSON 格式的测试报告,请运行以下命令:

go test . -v -json > test-report.json

此命令运行测试,并将 JSON 格式的输出重定向到名为test-report.json的文件,使用点号表示可用的测试文件,尽管你可以指定某些测试文件而不是使用点号。生成的文件包含有关每个测试的信息,包括其名称、状态、持续时间以及任何失败消息。

然后,您可以使用各种工具使用此报告来分析和更好地可视化测试结果。然而,您必须注意,某些包可能会以未预料到的方式(或有用的方式)更改您的测试结果输出。例如,stretchr/testify包可以与我们在一些练习中看到的断言函数一起使用,以在测试报告中提供更有用的输出。我们关于从前一章节的练习中添加值的简单断言可以修改为在失败事件中提供清晰的输出。

假设我们有以下代码:

assert.Equal(t, i+j, got)

这可以更新为以下内容:

assert.Equal(t, i+j, got, "the values i and j should be summed together properly")

此外,您还可以进一步更新它,以提供更多关于ij的哪些值未能通过测试等信息的洞察。

代码覆盖率

理解代码覆盖范围确保您的测试充分测试了代码库。Go 提供了一个名为cover的内置工具,它根据您已实施的测试用例生成测试覆盖率报告,并且可以识别。这对于单元测试来说很容易添加,这是 Go 1.20 为集成测试代码覆盖率添加的新功能。

应用程序开发的行业标准是项目力争达到 80%的代码覆盖率平均。您通常会在 CI 工具中看到覆盖率工具的使用,这些工具针对 main/master 分支运行拉取请求,以检查是否存在已测试的代码覆盖率的大幅下降。

您可以将cover标志添加到测试函数中,以计算测试覆盖率。还有一个标志,您可以在其中指定一个输出文件,以便从运行并收集其结果的代码覆盖率工具生成。当您的项目需要利益相关者和领导层验证项目开发中包含适当的测试代码覆盖率时,这是一个有用的报告。

让我们看看一个例子,看看这对于我们在本章中使用的add.go文件中的包可能是什么样子。如果您有一个包含相应TestAdd()函数的add_test.go文件,您可以使用以下命令检查代码覆盖率:

go test . -cover

您还可以更新周期,使其指向您想要代码覆盖的具体包或目录。您应该看到典型的测试输出,然后是覆盖率数量:

ok      github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter19/Example02        0.357s  coverage: 100.0% of statements

如您所见,由于我们的add函数有一个测试函数,所以我们有 100%的覆盖率。如果我们意外地添加了一个针对不同类型输入的加法函数,例如 float64 值,那么我们的覆盖率将下降到 50%,直到我们添加与我们的新函数相对应的测试函数。

注意

本练习的完整代码可在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter19/Example02找到。

一个开发团队可以使用代码覆盖率工具的结果来确定他们是否需要增加他们项目的测试用例数量,或者在项目的某些区域,因为输出文件可以展示代码覆盖率不足的区域。这是 Go 语言为开发者提供编写经过适当测试的代码的许多强大方式之一。

摘要

在本章中,我们探讨了 Go 语言测试的多样化领域,从单元测试到集成和端到端测试,以及一些其他类型的测试,包括 HTTP 测试和模糊测试。我们还了解了在项目代码覆盖率方面的一些测试套件和行业最佳实践。我们以我们可以对测试做的事情结束本章,包括创建测试报告以共享测试覆盖率,以及突出显示代码库性能的基准测试。

有了这些工具和技术,开发者可以确保他们编写的 Go 代码的可靠性和稳定性。在 Go 语言测试方面,我们覆盖了很多内容,但仅仅触及了 Go 工具链为我们开发者提供的功能的一角。在下一章中,我们将更深入地探讨 Go 工具及其为我们提供的功能。

第二十章:使用 Go 工具

概述

本章将教你如何利用 Go 工具来提高和构建你的代码。它还将帮助你使用 Go 工具构建和改进代码,并使用 go build 创建二进制文件。此外,你还将学习如何使用 goimports 清理库导入,使用 go vet 检测可疑结构,以及使用 Go 竞态检测器识别代码中的竞态条件。

到本章结束时,你将能够使用 go run 运行代码,使用 gofmt 格式化代码,使用 go doc 自动生成文档,以及使用 go get 下载第三方包。

技术要求

对于本章,你需要 Go 版本 1.21 或更高版本。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter20找到。

简介

在前面的章节中,你学习了如何生成并发和经过良好测试的代码。虽然与其它语言相比,Go 使得创建并发和经过良好测试的代码任务变得更加容易,但这些任务本质上可能很复杂。这就是学习使用工具来编写更好的代码,从而简化复杂性的时候。

在本章中,你将了解 Go 工具。Go 提供了几个工具来帮助你编写更好的代码。例如,在前面的章节中,你遇到了 go build,你用它来将代码构建成可执行文件。你还遇到了 go test,你用它来测试你的代码。还有一些其他工具以不同的方式提供帮助。例如,goimports 工具将检查你是否有了使代码正常工作所需的所有导入语句,如果没有,它将添加它们。它还可以检查是否有任何导入语句不再需要,并删除它们。虽然这看起来很简单,但它意味着你不再需要担心导入,而可以专注于你正在编写的代码。或者,你可以使用 Go 竞态检测器来查找代码中隐藏的竞态条件。当你开始编写并发代码时,这是一个极其宝贵的工具。

Go 语言提供的工具是它受欢迎的原因之一。它们提供了一种标准方式来检查代码的格式问题、错误和竞态条件,这在专业环境中开发软件时非常有用。本章的练习提供了如何使用这些工具来改进代码的实际示例。

go build 工具

go build工具将 Go 源代码编译成可执行格式。在创建软件时,你用人类可读的编程语言编写代码。然后,代码需要被转换成机器可读的格式以便执行。这是通过编译器完成的,它将机器指令从源代码编译出来。要使用 Go 代码完成此操作,你可以使用go build

练习 20.01 – 使用go build工具

在这个练习中,你将了解go build工具。这个工具会将你的 Go 源代码编译成一个二进制文件。要使用它,请在命令行上运行go build工具,并使用–o标志来指定输出文件或可执行文件名:

go build -o name_of_the_binary_to_create source_file.go

如果省略了–o标志,输出文件将使用包含源文件的包或文件夹命名。

让我们开始吧:

  1. 创建一个名为Exercise20.01的新目录。在该目录内,创建一个名为main.go的新文件。

  2. 运行以下两个命令来创建练习的 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个简单的Hello World程序:

    package main
    import "fmt"
    func main() {
      fmt.Println("Hello World")
    }
    
  4. 要运行程序,你需要打开你的终端并导航到创建main.go文件的目录。然后,通过编写以下内容来运行go build工具:

    go build -o hello_world main.go
    
  5. 这将创建一个名为hello_world的可执行文件,你可以通过在命令行上运行它来执行这个二进制文件:

    ./hello_world
    

输出将如下所示:

Hello World

在这个练习中,你使用了go build工具将你的代码编译成二进制文件并执行它。

go run工具

go run工具与go build类似,因为它也会编译 Go 代码。然而,细微的区别在于go build会输出一个二进制文件,你可以执行它,而go run工具不会创建一个需要执行的二进制文件。它将代码编译并运行,最终没有二进制文件输出。如果你想要快速检查代码是否按预期工作,而不需要创建和运行二进制文件,这很有用。这在测试代码时常用,这样你可以快速运行代码,而无需创建执行二进制文件。

练习 20.02 – 使用go run工具

在这个练习中,你将了解go run工具。这个工具可以用作编译和运行代码的单步快捷方式,如果你想要快速检查代码是否工作,这很有用。要使用它,请在命令行上按照以下格式运行go run工具:

go run source_file.go

执行以下步骤:

  1. 创建一个名为Exercise20.02的新目录。在该目录内,创建一个名为main.go的新文件。

  2. 运行以下两个命令来创建这个练习的 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个简单的Hello Packt程序:

    package main
    import "fmt"
    func main() {
      fmt.Println("Hello Packt")
    }
    
  4. 现在,你可以使用go run工具来运行程序:

    go run main.go
    

这将执行代码并在一步中运行它,给出以下输出:

Hello Packt

在这个练习中,你使用了go run工具一次性编译和运行一个简单的 Go 程序。这有助于快速检查你的代码是否按预期工作。

gofmt 工具

gofmt工具用于保持你的代码整洁和一致的风格。在大型软件项目中,一个重要但经常被忽视的因素是代码风格。在整个项目中保持一致的代码风格对于可读性很重要。当你必须阅读他人的代码,或者甚至几个月后阅读自己写的代码时,保持一致的样式可以使你无需太多努力就能专注于逻辑。在阅读代码时解析不同的样式只是又一件需要担心的事情,并可能导致错误。为了克服这个问题,Go 自带了一个工具,可以自动以一致的方式格式化你的代码,称为gofmt。这意味着在你的项目以及使用gofmt工具的其他 Go 项目中,代码将保持一致。因此,它将通过纠正间距和缩进来修复代码的格式,并尝试对齐你的代码的各个部分。

练习 20.03 – 使用 gofmt 工具

在这个练习中,你将学习如何使用gofmt工具格式化你的代码。当你运行gofmt工具时,它将显示它认为文件应该如何看起来,具有正确的格式,但它不会更改文件。如果你想让gofmt自动将文件更改为正确的格式,你可以使用带有-w选项的gofmt,这将更新文件并保存更改。让我们开始吧:

  1. 创建一个名为Exercise20.03的新目录。在该目录中,创建一个名为main.go的新 Go 文件。

  2. 运行以下两个命令以创建此练习的 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个格式错误的 Hello Packt 程序:

    package main
    import "fmt"
    func main(){
      firstVar := 1
      secondVar := 2
      fmt.Println(firstVar)
      fmt.Println(secondVar)
      fmt. Println("Hello Packt")
    }
    
  4. 然后,在你的终端中运行gofmt以查看文件将看起来如何:

    gofmt main.go
    

    这将显示文件应该如何格式化才能使其正确。以下是预期的输出:

图 20.1:gofmt 的预期输出

图 20.1:gofmt 的预期输出

然而,这仅显示它将做出的更改;它不会更改文件。这样做是为了你可以确认你对这些更改感到满意。

  1. 要更改文件并保存这些更改,你需要添加-w选项:

    gofmt -w main.go
    

    这将更新文件并保存更改。然后,当你查看文件时,它应该看起来像这样:

    package main
    import "fmt"
    func main() {
      firstVar := 1
      secondVar := 2
      fmt.Println(firstVar)
      fmt.Println(secondVar)
      fmt.Println("Hello Packt")
    }
    

你可能会观察到,在gofmt工具使用后,格式错误的代码已经被重新对齐。间距和缩进已经被修复,funcmain()之间的新行已经被删除。

注意

在保存时对代码进行多次gofmt。值得研究如何使用你选择的 IDE 来做这件事,以便gofmt工具可以自动运行并修复代码中的任何间距或缩进错误。

在这个练习中,你使用了 gofmt 工具将格式错误的文件重新格式化为整洁的状态。当你刚开始编码时,这可能会显得毫无意义且令人烦恼。然而,随着你的技能提高,并开始处理更大的项目时,你将开始欣赏整洁和一致代码风格的重要性。

goimports 工具

Go 附带的另一个有用工具是 goimports,它可以自动添加文件中需要的导入。软件工程的一个关键部分不是重新发明轮子,而是重用他人的代码。在 Go 中,你通过在文件的 import 部分开始导入库来实现这一点。然而,每次需要使用这些导入时,添加它们可能会变得繁琐。你还可以不小心留下未使用的导入,这可能会带来安全风险。更好的方法是使用 goimports 自动为你添加导入。它还会删除未使用的导入,并将剩余的导入按字母顺序重新排序,以提高可读性。

练习 20.04 – 使用 goimports 工具

在这个练习中,你将学习如何使用 goimports 来管理简单 Go 程序中的导入。当你运行 goimports 工具时,它将输出它认为文件应该如何看起来,导入已修复。或者,你可以使用 -w 选项运行 goimports,这将自动更新文件中的导入并保存更改。让我们开始吧:

  1. 创建一个名为 Exercise20.04 的新目录。在该目录中,创建一个名为 main.go 的新文件。

  2. 运行以下两个命令来为这个练习创建 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个具有错误导入的简单 Hello Packt 程序:

    package main
    import (
      "net/http"
      "fmt"
    )
    func main() {
      fmt.Println("Hello")
      log.Println("Packt")
    }
    

    你会注意到 log 库没有被导入,并且 net/http 导入未被使用。

  4. 在你的终端中,运行 goimports 工具来查看导入如何变化:

    goimports main.go
    

    这将显示它将如何更改文件以进行修复。以下是预期的输出:

图 20.2:goimports 的预期输出

图 20.2:goimports 的预期输出

这不会更改文件,但显示了文件将如何更改。如你所见,net/http 导入已被删除,而 log 导入已被添加。

  1. 要将这些更改写入文件,请添加 -w 选项:

    goimports -w main.go
    
  2. 这将更新文件,使其看起来如下:

    package main
    import (
      "fmt"
      "log"
    )
    func main() {
      fmt.Println("Hello")
      log.Println("Packt")
    }
    

在这个练习中,你学习了如何使用 goimports 工具。你可以使用这个工具来检测不正确和未使用的导入语句,并自动修复它们。许多 IDE 都内置了开启 goimports 的方式,这样当保存文件时,它会自动为你修复导入。

go vet 工具

go vet工具用于对 Go 代码进行静态分析。虽然 Go 编译器可以找到并通知你你可能犯的错误,但它会错过某些事情。因此,创建了go vet工具。这听起来可能微不足道,但其中一些问题可能在代码部署后很长时间内才会被发现,其中最常见的是在使用Printf函数时传递错误的参数数量。它还会检查无用的赋值,例如,如果你设置了一个变量然后从未使用过这个变量。它还会检测当非指针接口传递给unmarshal函数时的情况。编译器不会注意到这一点,因为它是有效的;然而,unmarshal函数将无法将数据写入接口。这可能会在调试时造成麻烦,但使用go vet工具可以在问题成为问题之前及早捕捉并修复它。

练习 20.05 – 使用 go vet 工具

在这个练习中,你将使用go vet工具来查找在使用Printf函数时常见的错误。你将用它来检测传递给Printf函数的参数数量是否正确。让我们开始吧:

  1. 在名为Exercise20.05的新目录中,创建一个名为main.go的新 go 文件:

  2. 运行以下两个命令来为这个练习创建 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个简单的Hello Packt程序:

    package main
    import "fmt"
    func main() {
      helloString := "Hello"
      packtString := "Packt"
      jointString := fmt.Sprintf("%s", helloString, packtString)
      fmt.Println(jointString)
    }
    

    如你所见,jointString变量使用了fmt.Sprintf将两个字符串合并为一个。然而,%s格式字符串是不正确的,并且只格式化了一个输入字符串。当你构建这段代码时,它将编译成一个二进制文件,没有任何错误。然而,当你运行程序时,输出将不会如预期。幸运的是,go vet工具正是为此而创建的。

  4. 对你创建的文件运行go vet工具:

    go vet main.go
    
  5. 这将显示它在代码中发现的任何问题:

图 20.3:go vet 的预期输出

图 20.3:go vet 的预期输出

如你所见,go vet在文件的第 9 行识别到了一个问题。Sprintf调用需要1个参数,但我们给了它2个。

  1. 更新Sprintf调用,使其可以处理我们想要发送的两个参数:

    package main
    import "fmt"
    func main() {
      helloString := "Hello"
      packtString := "Packt"
      jointString := fmt.Sprintf("%s %s", helloString, packtString)
      fmt.Println(jointString)
    }
    
  2. 现在,你可以再次运行go vet并检查是否还有问题:

    go vet main.go
    

    它应该返回空结果,让你知道文件没有更多的问题。

  3. 现在,运行程序:

    go run main.go
    

在对字符串进行修正后的输出如下:

Hello Packt

在这个练习中,你学习了如何使用go vet工具来检测编译器可能遗漏的问题。虽然这是一个非常基础的例子,但go vet可以检测到像将非指针传递给unmarshal函数或检测到不可达代码这样的错误。你被鼓励将go vet作为构建过程的一部分来运行,以便在这些问题进入你的程序之前捕捉到它们。

Go 竞争检测器

Go 的竞态条件检测器被添加到 Go 中,以便开发者可以检测竞态条件。正如我们在第十八章中提到的,并发工作,你可以使用 goroutines 来并发运行代码的一部分。然而,即使是经验丰富的程序员也可能犯下错误,允许不同的 goroutines 同时访问相同的资源。这被称为竞态条件。竞态条件的问题在于一个 goroutine 可以在另一个 goroutine 读取资源的过程中修改资源,这意味着资源可能会被损坏。虽然 Go 已经将并发作为语言的第一等公民,但并发代码的机制并不能防止竞态条件。此外,由于并发的固有性质,竞态条件可能在你代码部署很久之后才会显现出来。这也意味着它们往往是瞬时的,这使得它们难以调试和修复。这就是为什么 Go 的竞态条件检测器被创建出来的原因。

这个工具通过使用一个检测异步内存访问的算法来工作,但它的缺点是只能在代码执行时这样做。因此,你需要运行代码才能检测到竞态条件。幸运的是,它已经被集成到 Go 工具链中,因此我们可以用它来自动完成这项工作。

练习 20.06 – 使用 Go 竞态条件检测器

在这个练习中,你将创建一个包含竞态条件的基本程序。你将使用 Go 竞态条件检测器来查找竞态条件。你将学习如何识别问题所在,然后学习减轻竞态条件的方法。让我们开始吧:

  1. Exercise20.06目录中创建一个新的目录。在该目录内,创建一个名为main.go的新文件。

  2. 运行以下两个命令来创建这个练习的 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个具有竞态条件的简单程序:

    package main
    import "fmt"
    func main() {
      finished := make(chan bool)
      names := []string{"Packt"}
      go func() {
        names = append(names, "Electric")
        names = append(names, "Boogaloo")
        finished <- true
      }()
      for _, name := range names {
        fmt.Println(name)
      }
      <-finished
    }
    

    如你所见,有一个名为names的数组,里面有一个项目。然后一个 goroutine 开始向其中添加更多名字。同时,主 goroutine 正试图打印出数组中的所有项目。因此,这两个 goroutines 同时访问了相同的资源,这就是竞态条件。

  4. 使用race标志激活运行前面的代码:

    go run --race main.go
    

运行此命令将给出以下输出:

Packt
==================
WARNING: DATA RACE
Write at 0x00c0000aa000 by goroutine 6:
  main.main.func1()
      /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter20/Exercise20.06/main.go:10 +0xe0
Previous read at 0x00c0000aa000 by main goroutine:
  main.main()
      /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter20/Exercise20.06/main.go:14 +0x170
Goroutine 6 (running) created at:
  main.main()
      /Users/samcoyle/go/src/github.com/packt-book/Go-Programming---From-Beginner-to-Professional-Second-Edition-/Chapter20/Exercise20.06/main.go:9 +0x168
==================
Found 1 data race(s)
exit status 66

在输出中,你可以看到一个警告,告诉你有关竞态条件的信息。它告诉你代码在main.go:10main.go:15行中读取并写入相同的资源,如下所示:

  names = append(names, "Electric")
  for _, name := range names {

如你所见,在两种情况下,都是访问names数组,所以问题就出在这里。这种情况发生的原因是程序在等待finished通道之前就开始打印names

  1. 一种解决方案是在打印项目之前等待finished通道:

      <-finished
      for _, name := range names {
        fmt.Println(name)
      }
    
  2. 这意味着在开始打印之前,所有项目都将被添加到数组中。你可以通过再次运行程序并激活--race标志来确认这个解决方案:

    go run --race main.go
    
  3. 这应该会正常运行程序,不会显示竞争条件警告。修正后的预期输出如下:

    Packt
    Electric
    Boogaloo
    

    最终修复了竞争条件的程序将如下所示:

    package main
    import "fmt"
    func main() {
      finished := make(chan bool)
      names := []string{"Packt"}
      go func() {
        names = append(names, "Electric")
        names = append(names, "Boogaloo")
        finished <- true
      }()
      <-finished
      for _, name := range names {
        fmt.Println(name)
      }
    }
    

虽然这个练习中的程序相当简单,解决方案也是如此,但鼓励你回到 第十八章并发工作,并使用那里的 --race 标志。这将提供一个更好的示例,说明 Go 竞争检测器如何帮助你。

注意

Go 竞争检测器通常被专业软件开发者用来确认他们的解决方案中不包含任何隐藏的竞争条件。

go doc 工具

go doc 工具用于为 Go 中的包和函数生成文档。许多软件项目的文档部分常常被忽视。这是因为编写文档可能很繁琐,而且保持其更新状态可能更加繁琐。因此,Go 提供了一个工具来自动生成代码中包声明和函数的文档。你只需在函数和包的开始处添加注释。然后,这些注释将被提取并与函数头结合。

这样就可以与他人分享,帮助他们了解如何使用你的代码。要为包及其函数生成文档,你可以使用 go doc 工具。这种类型的文档在处理大型项目时非常有用,其他人们需要使用你的代码。通常,在专业环境中,不同的团队会负责程序的不同部分;每个团队都需要与其他团队沟通,了解包中可用的函数以及如何调用它们。为此,他们可以使用 go doc 为他们编写的代码生成文档,并与其他团队共享。

练习 20.07 – 实现 go doc 工具

在这个练习中,你将了解 go doc 工具以及如何用它来生成代码的文档。让我们开始吧:

  1. 创建一个名为 Exercise20.07 的新目录。在该目录内,创建一个名为 main.go 的新文件。

  2. 运行以下两个命令来为这个练习创建 Go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到你创建的 main.go 文件中:

    package main
    import "fmt"
    // Add returns the total of two integers added together
    func Add(a, b int) int {
      return a + b
    }
    // Multiply returns the total of one integer multiplied by the other
    func Multiply(a, b int) int {
      return a * b
    }
    func main() {
      fmt.Println(Add(1, 1))
      fmt.Println(Multiply(2, 2))
    }
    

    这创建了一个简单的程序,其中包含两个函数:一个名为 Add 的函数,用于添加两个数字,另一个名为 Multiply 的函数,用于乘以两个数字。

  4. 运行以下命令来编译和执行文件:

    go run main.go
    
  5. 输出将如下所示:

    2
    4
    
  6. 你会注意到,两个函数上方都有注释,注释以函数名称开头。这是 Go 的一个约定,让你知道这些注释可以用作文档。这意味着你可以使用 go doc 工具为代码创建文档。在 main.go 文件所在的同一目录下运行以下命令:

    go doc -all
    

这将为代码生成文档并输出如下:

图 20.4:go doc 的预期输出

图 20.4:go doc 的预期输出

在这个练习中,你学习了如何使用go doc工具为你创建的 Go 包及其函数生成文档。你可以使用它为其他你创建的包生成文档,并与其他人共享,如果他们想使用你的代码。如果你想捕获这些文档,可以使用godoc package/path > output.txt

go get 工具

go get工具允许你下载和使用不同的库。虽然 Go 默认自带了一大批包,但与可用的第三方包数量相比,就显得微不足道了。这些包提供了额外的功能,你可以在代码中使用它们来增强代码。然而,为了让你的代码使用这些包,你需要在你的电脑上安装它们,以便编译器在编译你的代码时包含它们。要下载这些包,你可以使用go get工具。

练习 20.08 – 实现 go get 工具

在这个练习中,你将学习如何使用go get下载第三方包。让我们开始吧:

  1. 在名为Exercise20.08的新目录中创建一个新文件,命名为main.go

  2. 运行以下两个命令来为这个练习创建 go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到你创建的main.go文件中:

    package main
    import (
      "fmt"
      "log"
      "net/http"
      "github.com/gorilla/mux"
    )
    func exampleHandler(w http.ResponseWriter, r *http.Request) {
      w.WriteHeader(http.StatusOK)
      fmt.Fprintf(w, "Hello Packt")
    }
    func main() {
      r := mux.NewRouter()
      r.HandleFunc("/", exampleHandler)
      log.Fatal(http.ListenAndServe(":8888", r))
    }
    
  4. 这是一个简单的 Web 服务器,你可以通过运行以下命令来启动它:

    go run main.go
    
  5. 然而,Web 服务器使用了一个名为mux的第三方包。在导入部分,你会看到它是从github.com/gorilla/mux导入的。但由于我们没有在本地存储这个包,当我们尝试运行程序时会出现错误:

    main.go:8:2: no required module provides package github.com/gorilla/mux; to add it:
       go get github.com/gorilla/mux
    
  6. 要获取第三方包,你可以使用go get。这将本地下载它,以便我们的 Go 代码可以使用它:

    go get github.com/gorilla/mux
    
  7. 现在你已经下载了包,你可以再次运行 Web 服务器:

    go run main.go
    

    这次,它应该可以无错误地运行:

图 20.5:运行 Web 服务器时的预期输出

图 20.5:运行 Web 服务器时的预期输出

  1. 当网络服务器正在运行时,你可以在你的网络浏览器中打开http://localhost:8888并检查它是否工作:

图 20.6:在 Firefox 中查看时的 Web 服务器输出

图 20.6:在 Firefox 中查看时的 Web 服务器输出

在这个练习中,你学习了如何使用go get工具下载第三方包。这允许使用 Go 标准包之外的工具和包。

活动 20.01 – 使用 gofmt、goimport、go vet 和 go get 来纠正文件

想象一下,你正在对一个编写糟糕的项目进行工作。该文件包含格式错误的文件、缺少导入和位置不当的日志消息。你希望使用本章中学习的 Go 工具来修正文件并找出其中的任何问题。在这个活动中,你将使用 gofmtgoimportgo vetgo get 来修复文件并找出其中的任何问题。这个活动的步骤如下:

  1. 创建一个名为 Activity20.01 的目录。

  2. 创建一个名为 main.go 的文件。

  3. 为你的活动代码添加 Go 模块。

  4. Activity20.01/example 目录中的代码添加到 main.go 中,以便你可以正确格式化并安装其依赖项。

  5. 修复任何格式问题。

  6. 修复 main.go 中缺失的导入。

  7. 使用 go vet 检查编译器可能遗漏的任何问题。

  8. 确保第三方 gorilla/mux 包已下载到你的本地计算机。

    这是预期的输出:

图 20.7:运行代码时的预期输出

图 20.7:运行代码时的预期输出

你可以通过在浏览器中访问 http://localhost:8888 来检查这是否成功:

图 20.8:通过 Firefox 访问 Web 服务器时的预期输出

图 20.8:通过 Firefox 访问 Web 服务器时的预期输出

注意

本活动的解决方案可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter20/Activity20.01

下面是示例代码的修正:

package main
import (
  "log"
  "fmt"
  "github.com/gorilla/mux"
)
// ExampleHandler handles the http requests sent to this webserver
Func ExampleHandler(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(http.StatusOK)
  fmt.Fprintf(w, "Hello Packt")
  return
  log.Println("completed")
}
func main() {
  r := mux.NewRouter()
  r.HandleFunc("/", ExampleHandler)
  log.Fatal(http.ListenAndServe(":8888", r))
}

你现在已经在一次编码练习中看到了几个 Go 工具的实际应用。

摘要

当程序员编写代码时,Go 工具是无价的。在本章中,你学习了 go build 以及如何将你的代码编译成可执行文件。然后,你学习了在项目开发中保持整洁代码的一致性为何很重要,以及你可以如何使用 gofmt 自动美化代码。这可以通过 goimports 进一步改进,它可以删除不必要的导入以提高安全性,并自动添加你可能忘记添加的导入。

之后,你了解了 go vet 以及它如何帮助你找到编译器可能遗漏的任何错误。你还学习了如何使用 Go 竞态检测器来找到代码中隐藏的竞态条件。然后,你学习了如何为你的代码生成文档,这有助于在处理大型项目时进行协作。最后,你了解了如何使用 go get 工具下载第三方包,这允许你使用在线上可用的许多 Go 包来增强你的代码。

在下一章中,你将学习如何在云中运行你的 Go 代码,以及开发者在这种情况下的考虑因素。

第二十一章:云端 Go

概述

本章将向您展示如何将您的 Go 应用程序提升到部署准备就绪的下一个级别。它将涵盖您必须考虑的因素,以确保您的 Go 应用程序在部署到您的服务器或云基础设施后能够可靠地运行,通过演示如何通过一个名为Prometheus的开源监控和警报工具包向系统中添加监控能力来展示这一点。本章还将讨论如何使用编排器运行您的应用程序,以及您从中获得的所有即开即用的好处。最后,本章将涵盖OpenTelemetry允许的见解,以及容器化您的 Go 应用程序代码的最佳实践。

在本章结束时,您将能够可靠地部署您的 Go 应用程序,并凭借对系统的宝贵见解来确保其成功。

技术要求

对于本章,您需要 Go 版本 1.21 或更高版本。本章的代码可以在github.com/PacktPublishing/Go-Programming-From-Beginner-to-Professional-Second-Edition-/tree/main/Chapter21找到。

简介

在上一章中,您学习了各种 Go 工具,这些工具使开发者能够编写更好的代码并提高生产力。我们介绍了使用go buildgo run命令编译和运行 Go 代码的 Go 工具。然后我们探讨了如何使用gofmt格式化 Go 代码。我们还看到了通过goimportsgo get命令行工具与 Go 生态系统依赖项一起工作的力量。在代码中有了功能依赖项之后,我们可以使用Go vet工具和Go race检测器看到可能存在的问题。最后,任何好的代码都伴随着通过Go doc工具进行适当的文档,从而形成一个全面的项目。上一章使您在 Go 生态系统中拥有了触手可及的工具。

在本章中,我们关注的是在项目的某个阶段,您的应用程序开发之旅将引导您进入最终领域:部署应用程序。但是,在您点击部署按钮或运行最终命令之前,有一些基本考虑因素是确保您的应用程序在其目标环境中可靠且高效运行的关键。

您的 Go 应用程序将部署在哪里取决于许多因素。这可能是由利益相关者和领导层主导的决定,基于现有基础设施,或者甚至基于您项目或客户群的规格。无论目的地如何,您的 Go 代码都将能够打包并运送到那里。然而,确保您的项目为部署做好准备的责任在于您自己。

本章将向您介绍在云中成功运行应用程序的方法,以及您在将其部署到云中或您选择的任何地方之前可能需要考虑的一些事项。我们将涵盖监控、编排、跟踪和容器化等主题,为您提供知识和工具,以有效地导航云基础设施的复杂性。

首先,我们将讨论在云原生环境中监控应用程序性能和健康的重要性。我们将探讨如何将 Prometheus 等监控系统集成到您的 Go 应用程序中,使您能够收集关键指标并对其持续行为获得洞察。

接下来,我们将深入探讨使用 OpenTracing 的分布式跟踪和日志领域。通过将跟踪和日志集成到您的 Go 应用程序中,您将能够了解请求和响应在微服务之间的流动。这将为您提供额外的洞察,使调试问题变得轻而易举——希望如此——并为您提供洞察,以便未来可能进行性能优化。

最后,我们将介绍 Go 应用程序的基本容器化实践,包括镜像优化、依赖关系管理和安全考虑。您将学习如何构建健壮的容器镜像,以便在任何环境中部署。这将使我们能够无缝过渡到解决使用 Kubernetes 等平台编排应用程序的挑战。编排器允许您以更大的规模对应用程序进行可伸缩性、弹性和易于管理的操作。

到本章结束时,您将准备好自信地将 Go 应用程序部署到云中,并拥有确保其可靠性、可伸缩性、性能以及在生产环境中的可见性的知识和工具。让我们开始吧!

使您的应用程序可通过 Prometheus 等系统进行监控

监控是维护任何应用程序健康和性能的关键方面,无论使用何种语言。在资源动态且分布式的云原生环境中,监控尤为重要。在软件工程中,监控和可观察性之间有一些细微的差别。

监控方面更多地依赖于通过预定义的指标和阈值收集数据来检测和警报问题,以定义系统的整体健康状况,而可观察性则更加注重调查,并深入理解系统行为和性能,以便在复杂环境中进行有效的调试和故障排除。为了专注于启用监控能力和对我们应用程序健康状况的洞察,我们将在本书的这一章节中专注于监控而不是可观察性。

当涉及到在应用程序上启用监控功能时,Prometheus 是一个强大的工具。它基于拉取模型运行,定期从配置了指标的应用程序中抓取指标。这些指标随后存储在时间序列数据库中,允许开发者在实时中查询、可视化和发出警报。作为一名 Go 开发者,将 Prometheus 集成到应用程序中可以使您获得有关其性能和行为的宝贵见解。

要使您的 Go 应用程序可由 Prometheus 监控,您需要使用捕获其内部状态和性能相关信息的指标对其进行配置。这涉及到向您的应用程序代码库中添加配置代码,以暴露 Prometheus 可以抓取的指标端点。

Prometheus Go 客户端库提供了一种方便的方法,可以将指标配置到您的 Go 应用程序中。它提供了一系列指标类型,允许您捕获应用程序行为的各个方面:

  • 计数器:用于随时间跟踪事件发生次数的单调递增值。当应用程序重新启动时,它们重置为零,并且对于测量事件频率(如请求数或错误数)非常有用。

  • 仪表:在特定时间点对特定值的瞬时测量。它们可以增加或减少,并代表系统的当前状态,例如 CPU 使用率、内存消耗或活动连接数。

  • 直方图:一种跟踪值随时间分布的方法,使您能够了解数据的变异性和分布范围。它们将观察结果收集到可配置的桶中,并提供诸如百分位数、中位数和平均值等指标,这些指标对于理解响应时间、延迟和请求持续时间非常有用。

  • 摘要:与直方图类似,摘要提供了对数据分布的更准确表示,特别是对于高基数数据集。它们动态计算分位数和百分位数,允许您以精确和细粒度分析数据分布,这使得它们适合于测量延迟、持续时间和响应时间分布。

一旦您使用上述适合您所需指标和用例的指标类型对应用程序进行了配置,接下来您需要暴露指标端点供 Prometheus 抓取。这些端点通常以与 Prometheus 展示格式兼容的格式提供指标,如/metrics

Prometheus 使用名为抓取配置的配置文件来定义它应该抓取指标的目标。您需要配置 Prometheus 以抓取您的应用程序指标端点,并指定抓取间隔以定期收集数据。

使用 Prometheus 从你的 Go 应用程序收集指标后,你现在可以使用像 Grafana 这样的工具来可视化它们,并基于预定义的阈值或条件设置警报。这允许你主动监控应用程序的健康状况和性能,并在必要时采取纠正措施。

练习 21.01 – 创建具有 /healthz 端点的应用程序

我们刚刚概述了监控的工作原理,这是一个强大的工具,你可以用它来捕获指标,以及你如何可视化这些指标并用于改进你的项目。现在,我们将看看这在代码中是什么样的:

  1. 在名为 Exercise21.01 的目录下创建一个新的目录。在该目录内,创建一个名为 main.go 的新文件。

  2. 运行以下两个命令以创建用于练习的 go 模块:

    go mod init
    go mod tidy
    
  3. 将以下代码添加到文件中,以创建一个我们可以监控的简单应用程序:

    package main
    import (
      "fmt"
      "net/http"
      "time"
      "github.com/prometheus/client_golang/prometheus"
      "github.com/prometheus/client_golang/prometheus/promhttp"
    )
    
  4. 添加我们将用于监控端点调用次数的计数器指标:

    var (
      healthzCounter = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "healthz_calls_total",
        Help: "Total number of calls to the healthz endpoint.",
      })
    )
    
  5. 将指标注册到 Prometheus:

    func init() {
      prometheus.MustRegister(healthzCounter)
    }
    
  6. 定义 /healthz 端点的处理器:

    func main() {
      http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        healthzCounter.Inc()
        w.WriteHeader(http.StatusOK)
        fmt.Println("Monitoring endpoint invoked! Counter was incremented!")
      })
    
  7. 定义一个用于查看指标的处理器:

      http.Handle("/metrics", promhttp.Handler())
    
  8. 定义并启动服务器,然后关闭函数:

      server := &http.Server{
        Addr: ":8080",
        ReadTimeout: 10 * time.Second,
        WriteTimeout: 10 * time.Second,
      }
      fmt.Println("Server listening on port 8080...")
      if err := server.ListenAndServe(); err != nil {
        fmt.Printf("Error starting server: %s\n", err)
      }
    }
    
  9. 要运行程序,你需要打开你的终端,导航到包含 main.go 文件的目录。然后,通过编写以下内容来运行 go build 工具:

    go build -o monitored_app main.go
    
  10. 这将创建一个名为 monitored_app 的可执行文件,你可以在命令行上运行它来执行二进制文件:

    ./monitored_app
    

输出将如下所示:

Server listening on port 8080...

服务器现在正在监听请求。你现在可以通过在网页浏览器中导航到 /healthz 端点,或者通过 curl 命令执行 HTTP 请求来访问该端点。导航到网页浏览器并刷新页面几次:http://localhost:8080/healthz

如果你回到你的终端,你会看到计数器随着每个请求增加,换句话说,每次你刷新网页:

Monitoring endpoint invoked! Counter was incremented!
Monitoring endpoint invoked! Counter was incremented!
Monitoring endpoint invoked! Counter was incremented!

你将看到与你对该端点的 web 服务器请求次数相同数量的输出行。现在我们已经向服务器发送了一些请求,我们在监控的应用程序上生成了一些数据。我们可以在 http://localhost:8080/metrics 查看可用的 Prometheus 指标。

如果你访问网页浏览器的 /metrics 端点,你将看到我们创建的指标,以及许多其他用三个点缩写的指标,因为太多而无法在页面上很好地列出:

...
# HELP healthz_calls_total Total number of calls to the healthz endpoint.
# TYPE healthz_calls_total counter
healthz_calls_total 3
# HELP promhttp_metric_handler_requests_in_flight Current number of scrapes being served.
# TYPE promhttp_metric_handler_requests_in_flight gauge
promhttp_metric_handler_requests_in_flight 1
# HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code.
# TYPE promhttp_metric_handler_requests_total counter
promhttp_metric_handler_requests_total{code="200"} 0
promhttp_metric_handler_requests_total{code="500"} 0
promhttp_metric_handler_requests_total{code="503"} 0

你可以看到我们的自定义指标:

healthz_calls_total 3

我们调用了我们的端点三次;因此,我们看到计数器值为 3

你看到的额外指标是由 Prometheus 客户端库本身提供的,与 Go 运行时指标相关,包括内存分配、垃圾回收、goroutines 以及其他运行时统计信息。这些指标在你在 Go 应用程序中导入和使用 Prometheus 客户端库时自动暴露。

在这个练习中,您使用 Prometheus 为 HTTP 服务器上的一个端点定义了一个计数器指标。通过使用 Prometheus 对您的 Go 应用程序进行仪表化并遵循监控的最佳实践,您可以在云原生环境中获得对其行为和性能的宝贵见解。通过 Prometheus,我们看到了如何使用它来定义应用程序的监控能力。

通过添加额外的自定义指标来扩展示例,可以使团队在早期发现问题,有效地调试他们的应用程序,并确保 Go 应用程序在生产级环境中的可靠性和可伸缩性。Prometheus 还能够在满足某些标准时对指标进行警报,证明它在深入了解您的应用程序时是一个相当强大的工具。

通过 OpenTelemetry 实现深入洞察

在当今复杂的分布式系统环境中,了解我们的应用程序的行为和性能对于维护可靠性和性能至关重要。现在,我们将探讨另一个现成的有用监控工具。OpenTelemetry是深入了解分布式系统功能和性能的关键工具。OpenTelemetry,通常被称为 OTel,提供了一种标准化的方法来收集和关联系统各个组件的数据。

通过将 OpenTelemetry 集成到您的 Go 应用程序中,您可以无缝地捕获遥测数据,包括跟踪、指标和日志,从而全面了解您系统的工作情况。让我们看看 OpenTelemetry 包含的三个主要支柱:

  • 跟踪使我们能够跟踪请求在穿越不同的服务和组件时的流动,提供了关于延迟、依赖关系和错误传播的宝贵见解。为了跟踪,我们在服务边界之间创建和传播跟踪上下文,以实现端到端请求流的可见性。这使得我们能够可视化请求在系统中的流动,识别性能瓶颈,诊断错误,并优化资源利用。

  • 指标为我们系统的健康和性能提供了一个量化的视角,使我们能够监控关键指标并识别潜在的瓶颈或异常。OpenTelemetry 提供了一种收集指标的方法,类似于提供对我们应用程序健康和性能的洞察。

  • 日志提供了我们应用程序中事件和动作的叙述,有助于故障排除和调试工作。这也允许我们在分布式系统中跟踪信息流,并在应用程序内部事件发生时捕获日志。

要在应用程序中利用 OpenTelemetry 的力量,您必须首先使用必要的仪器库和软件开发工具包SDK来对应用程序进行仪器化。这与我们在本章前一部分中看到您必须对应用程序进行 Prometheus 仪器化的过程类似。对于 OpenTelemetry 来说,这是一个将 OpenTelemetry SDK 集成到代码库并配置跟踪、指标和日志仪器化的类似过程。

让我们看看这些在实际中是什么样子。

练习 21.02 – 使用 OpenTelemetry 进行可查询日志和跟踪

我们现在理解了 OpenTelemetry 允许开发者使用的监控能力。我们将现在看看它是如何帮助创建结构化日志的,这些日志使开发者能够更容易地在以后查询他们的日志,以及了解 OpenTelemetry 中的跟踪是什么样的。

在名为 Exercise21.02 的新目录中创建一个名为 main.go 的新 Go 文件,然后执行以下操作:

  1. 运行以下两个命令来为练习创建一个 go 模块:

    go mod init
    go mod tidy
    
  2. 将以下代码添加到文件中,包括我们 OpenTelemetry 监控所需的所有导入:

    package main
    import (
      "context"
      "fmt"
      "log"
      "net/http"
      "time"
      "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
      "go.opentelemetry.io/otel"
      "go.opentelemetry.io/otel/exporters/otlp/otlptrace"
      "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
      "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
      "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
      sdktrace "go.opentelemetry.io/otel/sdk/trace"
      "go.opentelemetry.io/otel/trace"
      "go.uber.org/zap"
    )
    
  3. 创建一个初始化跟踪导出器的函数:

    func initTraceExporter(ctx context.Context) *otlptrace.Exporter {
      traceExporter, err := otlptracegrpc.New(
        ctx,
        otlptracegrpc.WithEndpoint("http://localhost:4317),
      )
      if err != nil {
        log.Fatalf("failed to create trace exporter: %v", err)
      }
      return traceExporter
    }
    
  4. 创建一个初始化日志导出器的函数:

    func initLogExporter(ctx context.Context) *otlptrace.Exporter {
      logExporter, err := otlptracehttp.New(
        ctx,otlptracehttp.WithEndpoint("http://localhost:4318/v1/logs"),
      )
      if err != nil {
        log.Fatalf("failed to create log exporter: %v", err)
      }
      return logExporter
    }
    
  5. 创建一个初始化结构化日志器的函数:

    func initLogger() *zap.Logger {
      logger, err := zap.NewProduction()
      if err != nil {
        log.Fatalf("failed to create logger: %v", err)
      }
      return logger
    }
    
  6. 创建一个初始化跟踪提供者的函数:

    func initTracerProvider(traceExporter *otlptrace.Exporter) *sdktrace.TracerProvider {
      exp, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
      if err != nil {
        log.Println("failed to initialize stdouttrace exporter:", err)
      }
      bsp := sdktrace.NewBatchSpanProcessor(exp)
      tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(traceExporter),
        sdktrace.WithSpanProcessor(bsp),
      )
      return tp
    }
    

    然后,定义一个 HTTP 处理器,该处理器将处理传入的监控请求并捕获日志信息,以及为传入的请求启动跨度:

    func handler(w http.ResponseWriter, r *http.Request){
      ctx := r.Context()
      span := trace.SpanFromContext(ctx)
      defer span.End()
      logger := zap.NewExample().Sugar()
      logger.Infow("Received request",
        "service", "exercise22.02",
        "httpMethod", r.Method,
        "httpURL", r.URL.String(),
        "remoteAddr", r.RemoteAddr,
      )
      w.WriteHeader(http.StatusOK)
      fmt.Fprintf(w, "Monitoring endpoint invoked!")
    }
    
  7. 最后,定义一个 main() 函数,在其中调用我们刚刚定义的所有初始化辅助函数:

    func main() {
      ctx := context.Background()
      traceExporter := initTraceExporter(ctx)
      defer traceExporter.Shutdown(context.Background())
      logExporter := initLogExporter(ctx)
      defer logExporter.Shutdown(context.Background())
      tp := initTracerProvider(traceExporter)
      otel.SetTracerProvider(tp)
      logger := initLogger()
      defer logger.Sync()
    
  8. 使用 OpenTelemetry 仪器包装 HTTP 处理器,启动 HTTP 服务器,并关闭 main() 函数:

      httpHandler := otelhttp.NewHandler(http.HandlerFunc(handler), "HTTPServer")
      http.Handle("/", httpHandler)
      server := &http.Server{
        Addr: ":8080",
        ReadTimeout: 10 * time.Second,
        WriteTimeout: 10 * time.Second,
      }
      fmt.Println("Server listening on port 8080...")
      if err := server.ListenAndServe(); err != nil {
        fmt.Printf("Error starting server: %s\n", err)
      }
    }
    
  9. 要运行程序,您需要打开您的终端并导航到创建 main.go 文件的目录。然后,通过编写以下内容来运行 go build 工具:

    go build -o monitored_app main.go
    
  10. 这将创建一个名为 monitored_app 的可执行文件,您可以通过在命令行上运行它来执行该二进制文件:

    ./monitored_app
    

输出将如下所示:

Server listening on port 8080...

服务器现在正在监听请求。您现在可以通过在网页浏览器中导航到 /healthz 端点或通过 curl 命令执行 HTTP 请求来导航到该端点。在网页浏览器中导航到端点,然后重新加载页面几次:http://localhost:8080/healthz

网页现在将显示以下内容:

Monitoring endpoint invoked!

如果您回到您的终端,您将看到我们定义的结构化日志:

{"level":"info","msg":"Received request","service":"exercise22.02","httpMethod":"GET","httpURL":"/healthz","remoteAddr":"[::1]:51082"}

您还将看到跟踪信息导出到标准输出的结果,这样我们就可以在终端中看到跟踪。在这里,您可以看到部分输出,缩短以方便查看:

图 21.1:OpenTelemetry 跟踪输出 – 这张图片旨在显示输出和文本;可读性不是关键

图 21.1:OpenTelemetry 跟踪输出 – 这张图片旨在显示输出和文本;可读性不是关键

在这个练习中,您使用了 OpenTelemetry 来获取有关应用程序的有价值监控洞察,包括请求的结构化日志和跟踪信息。日志有助于提供有关发生情况的信息,我们看到了如何根据您的用例和项目结构化日志以包含相关信息。从那里,您可以使用日志的不同方面进行查询。例如,我们的日志包括服务名称、使用的 HTTP 方法和调用的端点。我们可以轻松地基于所有服务请求或特定端点的所有请求创建查询。这可以为团队提供有价值的洞察,以便在他们的项目中实践。我们还看到了使用 OpenTelemetry 的跟踪信息。如果有子请求,这些信息对于时间洞察和执行流程非常有用。我们还可以使用不同的导出器或 UI 工具来可视化这些结果,以便在更复杂的用例中更容易地看到我们的请求流程中正在发生什么。

将您的 Go 应用程序放入容器的最佳实践

近年来,容器化技术彻底改变了软件工程师部署和管理软件应用的方式。通过将应用程序及其依赖项封装进轻量级、可移植的容器中,容器化技术为我们的应用程序提供了众多好处,包括一致性、可扩展性和便携性。这种方法在各个行业中得到了广泛应用,并被认为是最现代软件开发和部署工作流程的标准实践。

容器化对于当今的软件至关重要,因为它通过将应用程序及其依赖项打包成一个单一单元来确保一致性,消除了臭名昭著且令人讨厌的“在我的机器上它工作”问题。这种一致性扩展到不同的环境,包括生产环境,降低了配置漂移的风险。它还允许按需进行可扩展性,因为当应用程序轻量级且在容器中启动快速时,添加或删除应用程序实例是高效的。最后,容器可以在本地、云服务提供商CSPs)或混合环境中运行。因此,了解如何将您的 Go 应用程序依赖项打包到容器中,以运行您的 Go 代码,确保其一致性、可扩展性和便携性是至关重要的。

Docker 是容器化生态系统中的大玩家,作为最广泛使用的容器化平台之一。Docker 提供了一个容器化引擎、镜像管理、容器编排以及广泛集成的生态系统。它提供了创建、部署和管理容器的工具、工作流程和基础设施。

在容器化您的 Go 应用程序时,有一些最佳实践需要牢记:

  • 利用 Go 模块进行依赖管理:Go 模块为管理 Go 应用程序的依赖提供了一个便捷的方式。在容器化您的 Go 应用程序时,请确保您正在使用 Go 模块来有效地管理依赖。本书在第九章使用 Go 模块定义 项目中早期就介绍了 Go 模块。

  • 保持容器轻量:容器化的一个基本原理是保持容器轻量。这意味着最小化容器镜像的大小,以减少部署时间和资源使用。在为 Go 应用程序构建容器镜像时,使用多阶段构建来编译您的应用程序二进制文件,并将必要的文件复制到最终的镜像中。此外,利用基于 Alpine 或 scratch 的镜像作为基础镜像,进一步减小镜像大小。

  • 优化 Dockerfile 指令:在为您的 Go 应用程序编写 Dockerfile 时,优化 Dockerfile 指令以提高构建性能并减小镜像大小。使用多阶段构建将构建环境与最终生产镜像分开,最小化最终镜像的大小。此外,通过按从最少变化到最多变化的顺序排列您的 Dockerfile 指令,利用 Docker 的层缓存机制,确保在重建镜像时只执行必要的步骤。

  • 保护您的容器环境:在容器化您的 Go 应用程序时,安全性应该是首要考虑的。遵循安全最佳实践,例如使用最小和可信的基础镜像,使用像 Trivy 这样的工具扫描容器镜像以查找漏洞,并在可能的情况下通过运行非 root 用户来应用最小权限原则。此外,确保敏感信息,如凭证或 API 密钥,不会硬编码到您的容器镜像中,而是在运行时作为环境变量提供或挂载为机密信息。最后,考虑利用 Chainguard 镜像增强 Dockerfile 的安全性,通过依赖其增强的安全措施来提高容器镜像的安全性。

  • 实现健康检查和日志记录:在您的 Go 应用程序中实现健康检查和日志记录,以提高容器化环境中的可观察性和可靠性。定义健康检查端点,以便容器编排平台如 Kubernetes 能够监控应用程序的健康状况,并自动重启不健康的容器。此外,使用结构化日志提供有关应用程序行为的宝贵见解,使解决问题和在生产环境中调试问题变得更加容易。

  • 使用容器编排平台:我们将在本章的下一节讨论为什么这很重要。

现在我们已经了解到了解如何容器化我们的 Go 应用程序的重要性,让我们看看在实践中这看起来是什么样子。

练习 21.03 – 为 Go 应用程序创建 Dockerfile

要容器化您的 Go 应用程序,您需要创建一个 Dockerfile,这是一个包含 Docker 如何构建应用程序镜像的指令的文本文件。让我们通过创建一个简单 Go 应用程序的 Dockerfile 的过程,然后看看如何构建和运行容器。我们将使用本章前面的 Exercise21.01 目录中的代码:

  1. Exercise21.03 目录下创建一个名为 main.go 的新文件:

  2. Exercise21.03/main.goExercise21.03/go.modExercise21.03/go.sum 的内容复制到新目录中。

  3. 创建一个包含指令的新文件 Dockerfile

  4. 以官方的 Go 镜像作为基础镜像:

    FROM golang:latest AS builder
    
  5. 确保 Go 编译器构建一个静态链接的二进制文件,包括二进制文件内的所有必要库:

    ENV CGO_ENABLED=0
    
  6. 设置容器内的工作目录:

    WORKDIR /app
    
  7. 复制 Go 模块文件和监控应用程序的代码:

    COPY go.mod go.sum ./
    COPY main.go ./
    RUN go mod download
    
  8. 构建 Go 二进制文件:

    RUN go build -o monitored_app .
    
  9. 开始一个新的阶段以创建一个最小的最终镜像:

    FROM scratch
    
  10. 将二进制文件复制到我们的最终阶段:

    COPY --from=builder /app/monitored_app /.
    
  11. 暴露我们将用于与应用程序交互的端口:

    EXPOSE 8080
    
  12. 运行我们的监控应用程序:

    CMD ["./monitored_app"]
    
  13. 现在我们已经填充了 Dockerfile 文件的内容,我们可以在终端中运行以下命令来构建我们的 Docker 镜像:

    docker build -t monitored-app .
    
  14. 我们可以使用以下命令运行我们的 Docker 容器,该命令将基于我们监控的应用程序镜像启动一个容器,并将主机机器上的端口 8080 映射到容器的端口 8080

    docker run -p 8080:8080 monitored-app
    
  15. 我们现在可以通过我们一直在访问的相同 URL 访问我们的应用程序:http://localhost:8080/healthz

我们仍然看到了与之前相同的应用程序输出:

Monitoring endpoint invoked! Counter was incremented!

我们现在已经看到了如何将我们的 Go 应用程序转换为轻量级、短暂的容器,并使用 Docker 命令运行它。Docker 是一个平台,它使我们能够通过将我们的 Go 应用程序依赖项打包到一个可移植的容器中,从而在 Docker 容器中构建、传输和运行我们的应用程序,该容器可以在不同的环境中部署。

让我们现在扩展这个可移植性和容器编排的想法。

使您的应用程序准备好与 Kubernetes 等编排器一起工作

Kubernetes,通常缩写为 K8s,已成为容器编排和管理的既定标准。它提供了自动化部署、扩展和管理容器化应用程序的能力。在其核心,Kubernetes 抽象了管理单个容器的复杂性,并为跨机器集群编排容器化工作负载提供了一个统一的 API 和控制平面。当您想要简化现代、云原生应用程序的部署和管理时,编排器如 Kubernetes 就是您所依赖的。

在当今动态且快速发展的软件领域中,微服务架构和容器化已成为主流,Kubernetes 提供了一个可扩展且具有弹性的平台,用于部署和运行这些分布式应用程序。然而,它并非没有其复杂性和学习曲线。

为了使你的应用程序能够与 Kubernetes 等编排器协同工作,你需要做几件事情:

  • 容器化你的应用程序:将你的 Go 应用程序及其依赖项打包到 Docker 容器中,正如我们在上一节中看到的。

  • 部署你的容器化应用程序:一旦你构建了容器镜像,你需要将其部署到你的 Kubernetes 集群中。这通常涉及将容器镜像推送到容器注册库(如 Docker Hub、Google Container RegistryGCR)或Amazon Elastic Container RegistryAmazon ECR)),然后使用 Kubernetes 部署清单将其部署到你的 Kubernetes 集群中。

  • 定义 Kubernetes 资源:在 Kubernetes 中,你使用如 Deployments、Services、ConfigMaps 和 Secrets 等 Kubernetes 资源来定义应用程序的期望状态。你需要创建描述这些资源的 Kubernetes 清单(YAML 文件),并指定 Kubernetes 应该如何管理你的 Go 应用程序。

  • 处理应用程序生命周期:Kubernetes 管理应用程序的生命周期,包括扩展、滚动更新和监控。确保你的应用程序通过实现健康检查、就绪性探测、优雅关闭以及日志/指标工具来实现与 Kubernetes 的良好协同工作。

  • 服务发现和负载均衡:使用 Kubernetes 服务在集群内部和外部客户端中公开你的应用程序。这允许应用程序的其他部分发现并与你的 Go 应用程序通信,并使 Kubernetes 能够对应用程序的多个实例进行负载均衡。

  • 监控和日志记录:使用 Prometheus、Grafana、Fluentd、OpenTelemetry 等工具对你的 Go 应用程序进行监控和日志记录。以结构化格式发出指标、日志和跟踪信息,以便 Kubernetes 可以收集和分析它们。这让你能够了解在 Kubernetes 中运行的应用程序的健康状况和性能。

通过遵循这些步骤,你可以成功地将你的 Go 应用程序部署并运行在如 Kubernetes 环境这样的编排器中。熟悉 Kubernetes 概念和最佳实践对于确保你的应用程序在生产环境中平稳高效地运行至关重要。你还应该认识到,为了跟上与 Kubernetes 协同工作的步伐,你需要面对一个更加复杂的环境和学习曲线。

摘要

本章内容非常精彩,它扩展了我们对于运行我们编写的 Go 应用程序的理解。我们学习了如何在云端运行我们的 Go 代码,并且这些代码被完美地打包,为我们提供了确保服务成功的监控洞察。

我们从理解为什么以及如何使用 Prometheus 来监控我们的 Go 应用程序代码开始。这为使用 OpenTelemetry 获取更丰富的应用程序洞察提供了一个很好的过渡。然后,我们展示了如何使用 Docker 容器化我们的应用程序,并探讨了如何在如 Kubernetes 这样的编排环境中运行这个容器化应用程序。在这一章以及整本书中,我们覆盖了大量的内容。

在本书的整个过程中,我们学习了 Go 语言的基础,包括变量和各种类型声明。我们使用 Go 语言进入了控制流和数据规则的学习,还涵盖了使用泛型和接口等一些处理复杂数据类型的新特性。我们通过代码重用、错误处理以及如何通过 Go 模块和包来处理大规模项目,介绍了适用于 Go 语言的良好软件工程实践。我们还简要提到了时间和文件以及系统。

本书将我们的技能提升到了专业水平,展示了使用 Go 进行调试的最佳实践,构建最先进的 CLI 应用程序,以及如何通过连接数据库、与 Web 服务器和客户端协作来进行应用程序开发。我们通过涵盖 Go 的并发原语、强大的测试实践,甚至突出 Go 生态系统提供的最佳工具,将所有内容完美地串联起来。最后,我们看到了如何在云端运行我们的 Go 代码,并深入了解我们的应用程序性能。这本书应该为你提供将 Go 知识转化为专业 Go 开发者的工具和知识!

posted @ 2025-09-05 09:30  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报