Go-研讨会-全-

Go 研讨会(全)

原文:zh.annas-archive.org/md5/46a579fbb870db28ffbcc431715a2fb4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了本书的覆盖范围,你需要掌握的技术技能以开始学习,以及完成所有包含的活动和练习所需的软件要求。

关于本书

你已经知道你想要学习 Go,而学习任何东西的最佳方式是通过实践学习。《Go 工作坊》专注于提升你的实践技能,以便你可以开发高性能的并发应用程序,甚至创建 Go 脚本来自动化重复的日常任务。你将从真实案例学习,这些案例可以带来真实的结果。

在《Go 工作坊》中,你将采取引人入胜的逐步方法来理解 Go。你不必忍受任何不必要的理论。如果你时间紧迫,你可以每天跳入一个单独的练习,或者你可以花一个周末的时间学习如何测试和确保你的 Go 应用程序。由你选择。按照你的方式学习,你将以一种感觉有成就感的方式建立和加强关键技能。

每一本《Go 工作坊》的实体印刷版都能解锁访问互动版。通过详细说明所有练习和活动,你将始终有一个指导性的解决方案。你也可以通过评估来衡量自己,跟踪你的进度,并接收内容更新。你甚至可以在完成时获得可以在线共享和验证的安全凭证。这是一项包含在印刷版中的高级学习体验。要兑换它,请遵循你 Go 书籍开头的说明。

快速直接,《Go 工作坊》是 Go 初学者的理想伴侣。你将像软件开发者一样构建和迭代你的代码,在学习过程中不断进步。这个过程意味着你会发现你的新技能会持续,并作为最佳实践嵌入其中。为未来的几年打下坚实的基础。

关于各章节

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

第二章逻辑与循环,教你如何通过创建基于变量中数据的规则来使你的代码动态和响应。循环让你可以反复执行逻辑。

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

第四章复杂数据类型,解释了复杂数据类型是如何建立在核心类型之上,允许你通过数据分组和从核心类型中组合新类型来模拟现实世界的数据。你还将了解在需要时如何克服 Go 的类型系统。

第五章函数,教你构建函数的基础知识。然后,我们将深入探讨使用函数的更高级功能,例如将函数作为参数传递、返回函数、将函数赋值给变量,以及许多你可以用函数做的有趣事情。

第六章错误处理,教您如何处理错误,包括如何声明自己的错误以及以 Go 语言的方式处理错误。您将了解什么是 panic 以及如何从中恢复。

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

第八章,展示了 Go 标准库如何组织其代码,以及您如何为自己的代码做同样的事情。

第九章基本调试,教您如何找到应用程序中的错误。您将使用在代码中打印标记、使用值和类型以及执行日志记录的各种技术。

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

第十一章编码和解码(JSON),为您讲解 JSON 文档的基础知识,这种文档在当今软件的各个部分被广泛使用,同时 Go 语言对读取和创建 JSON 文档提供了强大的支持。

第十二章文件和系统,展示了 Go 语言在处理文件和底层操作系统方面具有强大的支持。您将学习如何与文件系统交互,学习如何在操作系统上创建、读取和修改文件。您还将了解 Go 语言如何读取 CSV 文件,这是管理员常用的文件格式。

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

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

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

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

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

第十八章安全,帮助您了解如何识别和修复如 SQL 注入和跨站脚本等安全攻击。您将学习如何使用 Go 标准包实现对称和非对称加密,以及如何使用哈希库和 Go 中的 TLS 包来保护静态数据和传输中的数据。

第十九章特殊功能,让您探索 Go 语言中的一些隐藏宝藏,这将使开发更加容易。您将学习如何使用构建约束来控制应用程序的构建行为。您还将学习如何使用 Go 语言的通配符模式,以及如何使用 reflect 包在 Go 中使用反射。本章还将帮助您理解如何使用 unsafe 包访问应用程序的运行时内存。

习惯用法

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:

"一个panic()函数接受一个空接口。"

您在屏幕上看到的单词,例如在菜单或对话框中,也以相同的格式出现。

代码块设置如下:

type error interface { 
 Error()string 
}

新术语和重要单词如下所示:"这些行为统称为方法集。"

长代码片段将被截断,并在截断代码的顶部放置 GitHub 上相应代码文件的名称。整个代码的永久链接放置在代码片段下方。它应该看起来如下所示:

main.go
6  func main() {
7    a()
8    fmt.Println("This line will now get printed from main() function")
9  }
10 func a() {
11   b("good-bye")
12   fmt.Println("Back in function a()")
13 }
The full code for this step is available at: https://packt.live/2E6j6ig

在开始之前

每一段伟大的旅程都始于一个谦卑的步伐。我们即将开始的 Go 编程冒险也不例外。在我们能够使用 Go 做一些酷的事情之前,我们需要准备一个高效的环境。在这篇简短的笔记中,我们将看到如何做到这一点。

Windows with Docker 的硬件和软件推荐

要能够运行课程中使用的所有推荐工具,建议您拥有:

  • 1.6 GHz 或更快的桌面(amd64, 386)处理器。

  • 4 GB 的 RAM。

  • Windows 10 64 位:专业版、企业版或教育版(1607 周年更新,构建 14393 或更高版本)。

  • 您必须在 BIOS 中启用虚拟化,这通常默认启用。虚拟化与启用 Hyper-V 不同。

  • CPU SLAT 功能。

Windows without Docker 的硬件和软件推荐

如果您使用的系统低于使用 Docker 的推荐要求,您仍然可以完成课程。为此,您必须完成一个额外的步骤。

要能够运行所有工具(不包括 Docker),您需要:

  • 1.6 GHz 或更快的桌面(amd64, 386)处理器

  • 1 GB 的 RAM

  • Windows 7(带.NET Framework 4.5.2)、8.0、8.1 或 10(32 位和 64 位)

跳过解释如何安装 Docker 的步骤。您需要安装 MySQL 服务器。您可以从packt.live/2EQkiHe下载安装程序。如果您不确定选择哪个选项,默认选项是安全的。MySQL 免费安装和使用。

一旦课程完成,你可以安全地卸载 MySQL。

macOS 与 Docker 的硬件和软件推荐

要运行课程中使用的所有推荐工具,建议你拥有:

  • 1.6 GHz 或更快的桌面(amd64, 386)处理器

  • 4 GB 的 RAM

  • macOS X 或更新的版本,配备英特尔硬件内存管理单元MMU

  • macOS Sierra 10.12 或更新的版本

macOS 无 Docker 的硬件和软件推荐

  • 如果你使用的系统低于使用 Docker 推荐的系统要求,你仍然可以完成这门课程。你需要完成一个额外的步骤才能做到这一点。

  • 要运行所有工具(不包括 Docker),你需要:

  • 1.6 GHz 或更快的桌面(amd64, 386)处理器

  • 1 GB 的 RAM

  • macOS Yosemite 10.10 或更新的版本

跳过解释如何安装 Docker 的步骤。你需要安装 MySQL 服务器。你可以从packt.live/2EQkiHe下载安装程序。如果你不确定选择哪个选项,默认选项是安全的。MySQL 安装和使用都是免费的。

一旦课程完成,你可以安全地卸载 MySQL。

Linux 硬件和软件推荐

要运行课程中使用的所有推荐工具,建议你拥有:

  • 1.6 GHz 或更快的桌面(amd64, 386)处理器

  • 1 GB 的 RAM

  • Linux(Debian):Ubuntu 桌面 14.04,Debian 7

  • Linux(Red Hat):Red Hat Enterprise Linux 7,CentOS 7,Fedora 23

安装 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(编辑器/集成开发环境)

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

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

  1. 安装完成后,打开 Visual Studio Code。

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

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

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

  5. 输入Go

  6. 第一个选项应该是一个名为Go的由Microsoft提供的扩展。

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

  8. 等待显示成功安装的消息。

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

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

  2. 输入go tools

  3. 选择标有类似Go: Install/Update Tools的选项。

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

  5. 搜索输入框旁边的第一个复选框会检查所有的复选框。选择这个复选框,然后选择它右侧的Go按钮。

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

完成后,从顶部菜单栏选择视图,然后选择资源管理器

你现在需要一个地方来存放你的 Go 项目。我建议在你的主目录中某个位置。避免将其放在 Go 路径中,即 Go 编译器安装的文件夹。如果你在课程后面的modules部分遇到问题,这可能是原因之一。一旦你知道你想在哪里存储项目,就为它们创建一个文件夹。确保你能找到回到这个文件夹的路。

在 Visual Studio Code 中,选择打开文件夹按钮。从打开的对话框中,选择你刚刚创建的文件夹。

创建一个测试应用程序

  1. 在你的编辑器中,创建一个名为test的新文件夹。

  2. 在文件夹中创建一个名为main.go的文件。

  3. 将以下代码复制并粘贴到您刚刚创建的文件中:

    package main
    import (
        "fmt"
    )
    func main() {
        fmt.Println("This is a test")
    }
    
  4. 保存文件。

  5. 打开一个终端并进入test文件夹。

  6. 如果你正在使用 Visual Studio Code:

    • 从顶部菜单栏选择终端

    • 从选项中选择New Terminal

    • 输入cd test

  7. 在终端中输入go build

  8. 这应该会快速运行并完成,而不显示任何消息。

  9. 你现在应该会在同一个文件夹中看到一个新文件。在 Linux 和 macOS 上,你会有一个名为test的文件。在 Windows 上,你会有一个名为test.exe的文件。这个文件是你的二进制文件。

  10. 现在,让我们通过执行我们的二进制文件来运行我们的应用程序。输入./test

  11. 你应该会看到消息This is a test。如果你看到这个消息,你就已经成功设置了你的 Go 开发环境。

安装 Docker

如果你的电脑可以运行它(参见硬件和软件要求部分),你应该安装 Docker。Docker 允许我们运行诸如数据库服务器之类的程序,而无需安装它们。Docker 的安装和使用是免费的。

我们只使用 Docker 来运行课程数据库部分的 MySQL。如果你已经安装了 MySQL,那么你可以跳过这部分。

对于 macOS 用户,请按照packt.live/34VJLJD中的说明操作。

对于 Windows 用户,请按照packt.live/2EKGDG6中的说明操作。

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

课程完成后,如果你想,可以安全地卸载 Docker。

安装代码包

从 GitHub 下载代码文件至packt.live/2ZmmZJL,并将它们放置在一个名为C:\Code的新文件夹中。请参考这些代码文件以获取完整的代码包。

第一章:1. 变量和运算符

概述

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

到本章结束时,你将能够使用 Go 中的变量、包和函数。你将学习如何在 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.Seed(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 代码的入口点。当您的代码运行时,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.Seed中。rand.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.01:显示错误的输出

图 1.01:显示错误的输出

一旦我们生成了我们的随机数,我们就将它分配给一个变量。我们使用 := 符号来做这件事,这是 Go 中一个非常受欢迎的快捷方式。它告诉编译器继续将那个值分配给我的变量,并为那个值选择适当的数据类型。这个快捷方式是许多使 Go 感觉像动态类型语言的事情之一:

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

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

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,那么我们知道我们有一个错误。然后,我们调用 log.Fatal,它将记录消息并杀死我们的应用程序。一旦应用程序被杀死,就没有更多的代码运行:

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

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

这就是它的样子:

![图 1.02:显示有效值的输出]

![图 B14177_01_02.jpg]

![图 1.02:显示有效值的输出]

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

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

使用字符串重复器创建一个包含所需星星数量的字符串:

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

  2. 声明变量

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

    import (
      "fmt"
      "math/rand"
      "strings"
      "time"
    )
    
  4. ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_01_03.jpg)

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

      rand.Seed(time.Now().UnixNano())
    
  6. 在这个练习中,我们将使用在前面的例子中学到的一些知识来打印一个介于 1 到 5 之间的随机数(*)到控制台。这个练习将让你感受到使用 Go 的感觉,并练习我们将要使用的一些 Go 功能。让我们开始吧:

      r := rand.Intn(5) + 1
    
  7. 活动一.01 定义和打印

      stars := strings.Repeat("*", r)
    
  8. 图 1.04:分配变量后的预期输出

      fmt.Println(stars)
    }
    
  9. 为以下内容创建一个变量:

    go run .
    

生成一个介于 0 和 1 之间的随机数,然后加 1 以得到一个介于 1 到 5 之间的数字:

![图 1.03:显示星星的输出以下为输出:名字作为一个字符串在这个练习中,我们通过在main包中定义一个main()函数来创建一个可运行的 Go 程序。我们通过添加到包的导入来使用标准库。这些包帮助我们生成随机数、重复字符串以及写入控制台。## 接下来,我们将开始详细讲解到目前为止我们已经讨论的内容,所以如果你感到困惑或对之前看到的内容有疑问,请不要担心。在控制台打印带有换行符的星星字符串,并关闭main()函数:1. 在main.go中,将main包名称添加到文件顶部: + 注意 + 姓氏作为字符串 + 年龄作为一个int + 在这个活动中,我们将创建一个用于医生办公室的医疗表格,以记录患者的姓名、年龄以及他们是否有花生过敏:1. 确保它们有一个初始值。1. 将值打印到控制台。以下为预期的输出:图 1.04:分配变量后的预期输出

创建一个main()函数:

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_01_03.jpg)

本活动的解决方案可在第 684 页找到

我们现在将介绍所有可以声明变量的方式。

使用var声明变量

花生过敏作为一个bool

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

关键部分是varfoostring= "bar"

图 1.03:显示星星的输出

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

var foo string = "bar"

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

  • 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 (
  <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.05:显示三个变量值的输出

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

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

以这种方式使用 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. 关闭变量声明:

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

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

    go run .
    

    以下为输出结果:

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

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

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

接下来,我们将看看一个你不能省略任何部分的情况。

类型推断错误

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

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

以下为输出结果:

图 1.07:输出显示错误

图 1.07:输出显示错误

这里的问题是rand.Seed需要一个int64类型的变量。Go 的类型推断规则将整数(如我们用作int的整数)视为一个整体。我们将在后面的章节中更详细地探讨它们之间的区别。为了解决这个问题,我们将向声明中添加int64。以下是这样做的外观:

package main
import "math/rand"
func main() {
  var seed int64 = 1234456789
  rand.Seed(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.08:使用简短变量声明符号后打印的变量值使用简短的变量声明符号

图 1.08:使用简短变量声明符号后打印的变量值

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

:=简写是 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.09:显示程序变量值的示例输出使用变量声明函数

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

有时,你确实会看到这样的真实世界代码。它读起来有点困难,所以在直接值方面并不常见。但这并不意味着它不常见,因为在调用返回多个值的函数时,这种情况非常普遍。我们将在稍后的章节中详细讨论这一点。

练习 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:显示变量值的输出图 B14177_01_12.jpg

图 1.12:显示变量值的输出

注意

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

改变变量的值

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

练习 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

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

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

运算符

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

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

  • 算术运算符

    用于数学相关的任务,如加法、减法和乘法。

  • 比较运算符

    用于比较两个值;例如,它们是否相等,不相等,小于还是大于对方。

  • 逻辑运算符

    与布尔值一起使用,以查看它们是否都为真,只有一个为真,或者布尔值是否为假。

  • 地址运算符

    我们将在查看指针时详细讲解这些。这些用于与它们一起使用。

  • 接收运算符

    当与 Go 通道一起使用时使用,我们将在后面的章节中介绍。

练习 1.09 使用运算符进行数字运算

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

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

注意

我们已经将美元作为本练习的货币。你可以考虑任何你喜欢的货币;这里的主要重点是操作。

  1. 在新文件夹中创建一个main.go文件:

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

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

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

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

      // Main course
      var total float64 = 2 * 13
      fmt.Println("Sub  :", total)
    
  6. 在这里,他们购买了 4 件价值 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. 减去 1 然后打印出来:

      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 次访问

  • 金级:包含 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:变量类型及其零值]

![img/B14177_01_16.jpg]

图 1.16:变量类型及其零值

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

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

练习 1.12 零值

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

![图 1.17:替换表]

![img/B14177_01_17.jpg]

图 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. 声明并打印一个布尔值:

      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:显示零值的输出]

![img/B14177_01_18.jpg]

图 1.18:显示零值的输出

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

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

值与指针

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

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

有一种使用更少内存的复制替代方案。我们不是传递一个值,而是创建一个称为指针的东西,然后将它传递给函数。指针本身不是一个值,你不能用指针做任何有用的事情,除了用它来获取一个值。你可以把指针想象成指向你想要的价值的方向,要到达这个值,你必须遵循这些方向。如果你使用指针,Go 在将指针传递给函数时不会复制该值。

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

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

你无法直接控制一个值是放在栈上还是堆上。内存管理不是 Go 语言规范的一部分。内存管理被视为内部实现细节。这意味着它可以在任何时候更改,而我们之前讨论的只是一些一般性指南,而不是固定规则,并且可能在以后发生变化。

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

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

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

作为该语言的初学者,我建议在它们成为必要之前避免使用指针,无论是由于你有性能问题,还是因为使用指针可以使你的代码更清晰。

获取指针

要获取指针,你有几种选择。你可以使用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:创建指针后的输出

图 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:使用指针获取的值的输出]

![图片 B14177_01_20.jpg]

图 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:显示变量当前值的输出]

![图片 B14177_01_21.jpg]

图 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
    

    注意

    这个活动的解决方案可以在第 685 页找到

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

常量

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

常量声明与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使得枚举更容易创建和维护,尤其是当你需要在代码的中间添加新值时。

接下来,我们将详细探讨 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)
}

以下为使用级别显示变量的输出:

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

当我们调用 funcA 时,Go 的静态作用域解析开始发挥作用。这就是为什么当 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:显示错误的输出img/B14177_01_23.jpg

图 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
    

    注意

    本活动的解决方案可在第 685 页找到

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

在下一个活动中,我们将研究一个类似但稍微复杂一点的问题。

活动一.04:错误计数问题

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

package main
import "fmt"
func main() {
  count := 0
  if count < 5 {
    count := 10
    count++
  }
  fmt.Println(count == 11)
}
  1. 运行代码并查看输出结果。

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

  3. 重新运行代码,看看它有什么不同。

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

    以下为预期的输出:

    True
    

    注意

    本活动的解决方案可在第 686 页找到

摘要

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

现在你的应用程序有了数据,它需要根据这些数据做出选择。这就是变量比较发挥作用的地方。这有助于我们判断某事是真是假,是更大还是更小,并基于这些比较的结果做出选择。

通过查看零值、指针和作用域逻辑,我们研究了 Go 如何实现其变量系统。现在,我们知道这些细节是交付无 bug 高效软件和未能做到之间的区别。

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

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

第二章:2. 逻辑和循环

概述

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

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

简介

在上一章中,我们探讨了变量和值,以及我们如何可以在变量中临时存储数据并对该数据进行更改。现在,我们将探讨如何使用这些数据来选择性运行逻辑。这种逻辑允许你控制数据在你的软件中的流动。你可以根据变量中的值来响应并执行不同的操作。

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

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

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

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

if 语句

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

布尔表达式可以是一个简单的代码,其结果为布尔值。代码块可以是任何你也能放入函数中的逻辑。当布尔表达式为真时,代码块会运行。你只能在函数作用域中使用if语句。

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

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

  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.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中添加packageimport

    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 中添加 packageimport

    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语句上的“初始”语句。表示法如下:if <initial statement>; <boolean expression> { <code block> }。初始语句与布尔表达式位于同一部分,用;分隔它们。

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

  • 赋值和短变量赋值:

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

    E.g.: i = (j * 10) == 40
    
  • 发送用于处理通道的语句,我们将在后面介绍。

  • 增量和减量表达式:

    E.g.: i++
    

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

练习 2.04:实现初始if语句

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

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

  2. main.go中添加packageimport

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

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

      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 的内存管理系统回收。

活动二.01:实现 FizzBuzz

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

规则是这样的:

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

  • 如果数字是 3 的倍数,打印 "Fizz."

  • 如果数字是 5 的倍数,打印 "Buzz."

  • 如果数字是 3 和 5 的倍数,打印 "FizzBuzz."

这里有一些提示:

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

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

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

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

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

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

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

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

    注意

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

    图 2.01:FizzBuzz 输出

图 2.01:FizzBuzz 输出

注意

本活动的解决方案可以在第 686 页找到。

在下一个主题中,我们将看到如何驯服开始变得太大的if else语句。

表达式 switch 语句

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

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

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

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

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

初始语句和表达式都是可选的。要只有表达式,它看起来像这样:switch <expression> {…}。要只有初始语句,您将写switch <initial statement>; {…}。您可以同时省略它们,最终结果是switch {…}。当表达式缺失时,它就像在那里放置了true的值。

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

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

可以在switch语句的任何地方添加一个可选的default情况,但最佳实践是在末尾添加。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创建了一个紧凑的逻辑结构,将许多不同的可能值匹配给我们的用户特定的消息。在像这里一样使用常量时,使用time包中的星期几常量,看到switch语句的使用相当常见。

接下来,我们将使用允许我们匹配多个值的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表达式中使用复杂的逻辑。如果你有超过几个case,这仍然比if提供了更紧凑、更易于管理逻辑语句的方法。

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

循环

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

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. package定义为main并添加导入:

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

    func main() {
    
  3. 定义一个for循环,在initial语句部分将i变量定义为初始值为0。在子句中检查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中添加packageimport

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

    func main() {
    
  4. 定义一个变量,它是一个“字符串”切片,并用数据初始化它:

      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
    

循环范围

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

另一种集合类型map不提供相同的保证。这意味着您需要使用range。您将使用range而不是for循环的条件,并且,在每次循环中,range都会提供一个集合中元素的键和值,然后移动到下一个元素。

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

注意

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

练习 2.10:遍历 Map

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

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

  2. main.go中添加packageimport

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

    func main() {
    
  4. 定义一个具有string键和字符串值strings变量的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变量,你可以使用_作为变量名来告诉编译器你不需要它。

活动 2.02:使用 range 遍历 Map 数据

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

![图 2.02:执行活动的单词和计数数据图 2.02:执行活动的单词和计数数据

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

注意

前面的单词来自由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
    

    注意

    这个活动的解决方案可以在第 688 页找到。

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

breakcontinue

有时会需要跳过单个循环或完全停止循环的运行。可以使用变量和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 中添加 packageimport

    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 来防止无限循环。无限循环是代码中永远不会停止的循环。一旦你遇到无限循环,你需要一种方法来终止你的应用程序;你如何做将取决于你的操作系统。如果你在终端中运行你的应用程序,正常关闭终端就可以做到这一点。不要慌张——这发生在我们所有人身上——你的系统可能会变慢,但不会造成任何伤害。

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

活动二.03:冒泡排序

在这个活动中,我们将通过交换值来对给定的数字切片进行排序。这种排序技术被称为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]
    

    注意

    这个活动的解决方案可以在第 690 页找到。

摘要

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

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

逻辑和循环是你构建所有软件的基本工具。

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

第三章:3. 核心类型

概述

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

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

简介

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

Go 是一种强类型语言,所有数据都被分配了一个类型。这个类型是固定的,不能更改。你可以和不能对你的数据做的事情受到你分配的类型所限制。准确理解定义 Go 每个核心类型的因素对于成功使用 Go 语言至关重要。

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

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

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

编程语言需要定义数字或文本是什么。编程语言定义了你可以称为数字的内容,并定义了你可以在数字上使用的操作。例如,整数 10 和浮点数 3.14 是否都可以存储为同一类型?虽然显然你可以乘以数字,但你能否乘以文本?随着我们进入本章,我们将明确定义每种类型的规则以及你可以与它们一起使用的操作。

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

定义类型的因素包括:

  • 你可以在其中存储的数据类型

  • 你可以使用哪些操作

  • 这些操作对其有何影响

  • 它能使用多少内存

本章将为你提供使用 Go 类型系统正确编写代码的知识和信心。

真和假

使用布尔类型bool表示真和假逻辑。当你在代码中需要一个开/关切换时使用此类型。bool的值只能是truefalsebool的零值是false

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

在此代码示例中,我们在两个数字上使用比较运算符。你会看到结果是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. 创建一个接受字符串参数并返回bool的函数:

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

      pwR := []rune(pw)
    

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

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

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

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

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

      if unicode.IsUpper(v) {
    
  10. 如果是,我们将设置hasUpper bool变量为true

        hasUpper = true
      }
    
  11. 对于小写字母也做同样的事情:

      if unicode.IsLower(v) {
        hasLower = true
      }
    
  12. 同样也应用于数字:

      if unicode.IsNumber(v) {
        hasNumber = true
      }
    
  13. 对于符号,我们还将接受标点符号。使用or运算符,它与Booleans一起工作,如果这些函数中的任何一个返回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.01:Go 语言规范及相关整数类型

图 3.01:Go 语言规范及相关整数类型

以下是一些特殊的整数类型:

图 3.02:特殊整数类型

图 3.02:特殊整数类型

uintint的位数取决于你是否为 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 的值:

    33
    33.333332
    33.333333333333336
    

在这个练习中,我们可以看到计算机不能给出这种类型除法的完美答案。你还可以看到,在进行这种整数数学运算时,你不会得到错误。Go 会忽略数字的任何分数部分,这通常不是你想要的。我们还可以看到float64float32给出了更精确的答案。

虽然这个限制看起来可能会导致精度问题,但在现实世界的商业工作中,它大多数时候都能很好地完成任务。

让我们看看如果我们尝试通过乘以 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.03:初始化为 128 后的输出

图 3.03:初始化为 128 后的输出

这个错误很容易修复,不会引起任何隐藏的问题。真正的问题是当编译器无法捕获它时。当这种情况发生时,数字将".环绕"。环绕意味着数字从它的可能最大值变为可能的最小值。在开发你的代码时,环绕可能很容易被忽略,并可能对你的用户造成重大问题。

练习 3.03:触发数字环绕

在这个练习中,我们将声明两个小的整数类型:int8uint8。我们将它们初始化在它们可能的最大值附近。然后我们将使用循环语句每次循环增加 1,然后将它们的值打印到控制台。我们将能够看到它们何时环绕。

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

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

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

    import "fmt"
    
  4. 创建主函数:

    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.04:环绕后的输出

图 3.04:环绕后的输出

在这个练习中,我们看到,对于有符号整数,你最终会得到一个负数,而对于无符号整数,它会回绕到 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. 将 1 加到int上:

      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 语言数字类型。

字节

Go 语言中的byte类型只是uint8的别名,它是一个有 8 位存储空间的数值。实际上,byte是一个重要的类型,你会在很多地方看到它。位是一个单一的二进制值,一个单一的开关。将位分组为 8 位是早期计算中的常见标准,并成为几乎通用的数据编码方式。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

rune 是一种类型,它有足够的存储空间来存储单个 UTF-8 多字节字符。字符串字面量使用 UTF-8 编码。UTF-8 是一种非常流行和常见的多字节文本编码标准。string 类型本身并不限于 UTF-8,因为 Go 需要支持除了 UTF-8 以外的其他文本编码类型。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:根据输入长度显示字节的输出]

![img/B14177_03_08.jpg]

![图 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:根据字符串显示转换后的字节]

![img/B14177_03_09.jpg]

![图 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:根据字符串显示的输出]

![img/B14177_03_10.jpg]

![图 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. 声明一个具有多字节字符串值的字符串:

      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:安全遍历字符串后的输出]

图片 B14177_03_11.jpg

![图 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:使用 len 函数后显示错误的输出]

图片 B14177_03_12.jpg

图 3.12:使用 len 函数后显示错误的输出

你可以看到,当直接在字符串上使用 len 时,你会得到错误的结果。以这种方式使用 len 检查数据输入的长度会导致无效数据。例如,如果我们需要输入正好是 8 个字符长,而有人输入了多字节字符,直接在输入上使用 len 将允许他们输入少于 8 个字符。

当处理字符串时,请务必首先检查 strings 包。它充满了可能已经完成你所需要的有用工具。

接下来,让我们仔细看看 Go 语言的特殊 nil 值。

空值

nil 在 Go 语言中不是一个类型,而是一个特殊值。它表示没有类型的空值。当与指针、映射和接口(我们将在下一章中介绍这些)一起工作时,你需要确保它们不是 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

活动 3.01:销售税计算器

在这个活动中,我们创建了一个购物车应用程序,其中必须添加销售税来计算总额:

  1. 创建一个计算单个商品销售税的计算器。

  2. 计算器必须接受商品的成本及其销售税率。

  3. 将销售税相加并打印出以下商品的所需销售税总额:

![图 3.13:包含销售税率的商品列表]

图片 B14177_03_13.jpg

图 3.13:包含销售税率的商品列表

你的输出应该看起来像这样:

Sales Tax Total:  0.1329

注意

这个活动的解决方案可以在第 691 页找到。

活动 3.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:贷款计算器输出

注意

本活动的解决方案可在第 692 页找到。

摘要

在本章中,我们在使用 Go 语言类型系统方面迈出了重要一步。我们花时间定义了类型是什么以及为什么需要它们。然后我们探讨了 Go 语言中的每个核心类型。我们从简单的bool类型开始,并展示了它在我们的代码中是多么关键。然后我们转向数字类型。Go 语言提供了许多数字类型,反映了 Go 语言在内存使用和精度方面喜欢给予开发者的控制。在数字之后,我们研究了字符串的工作原理以及它们与rune类型的紧密关系。随着多字节字符的出现,很容易搞乱文本数据。Go 语言提供了强大的内置功能来帮助你正确处理。最后,我们探讨了nil及其在 Go 语言中的使用。

你在本章中学到的概念为你提供了应对 Go 语言更复杂类型(如集合和结构体)所需的知识。我们将在下一章探讨这些复杂类型。

第四章:4. 复杂类型

概述

本章介绍了 Go 语言中更复杂的类型。这将基于我们在上一章中学到的关于 Go 核心类型的知识。当构建更复杂的软件时,这些复杂类型是必不可少的,因为它们允许你逻辑上分组相关数据。这种分组数据的能力使得你的代码更容易理解、维护和修复。

到本章结束时,你将能够使用数组、切片和映射来分组数据。你将学习根据核心类型创建自定义类型。你还将学习使用结构体来创建由任何其他类型的命名字段组成的结构,并解释interface{}的重要性。

简介

在上一章中,我们介绍了 Go 的核心类型。这些类型对于你在 Go 中做的所有事情都是至关重要的,但建模更复杂的数据可能具有挑战性。在现代计算机软件中,我们希望能够尽可能地将数据和逻辑分组在一起。我们还希望我们的逻辑能够反映我们正在构建的现实世界解决方案。

如果你正在为汽车编写软件,你理想情况下想要一个体现汽车的定制类型。这个类型应该命名为"car",并且它应该有可以存储关于汽车类型信息的属性。影响汽车的逻辑,如启动和停止,应该与汽车类型相关联。如果我们需要管理多辆汽车,我们需要能够将所有汽车分组在一起。

在本章中,我们将学习 Go 语言中允许我们建模此挑战数据部分的特性。然后,在下一章中,我们将解决行为部分。通过使用自定义类型,你可以扩展 Go 的核心类型,而使用结构体则允许你组合由其他类型组成的类型,并将逻辑与它们关联。集合让你可以将数据分组在一起,并允许你遍历并对其执行操作。

随着任务复杂性的增加,Go 的复杂类型帮助你保持代码易于理解和维护。如arraysslicesmaps之类的集合允许你将相关数据分组在一起。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} 会将每个元素填充为值 9。当使用数据初始化时,你可以让 Go 根据你初始化时使用的元素数量来设置数组的大小。你可以通过将长度数字替换为 ... 来利用这一点。例如,[...]string{9,9,9,9,9} 会创建一个长度为 5 的数组,因为我们用 5 个元素初始化了它。就像所有数组一样,长度在编译时设置,在运行时不可更改。

练习 4.01:定义数组

在这个练习中,我们将定义一个大小为 10 的简单整数数组,然后打印其内容。让我们开始吧:

  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:数组类型不匹配错误

    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_04_02.jpg)

    图 4.1:数组类型不匹配错误

    你应该看到一个错误。这个错误告诉你,arr1,它是一个 [5] int 类型的数组,和 arr4,它是一个 [9] int 类型的数组,不是同一类型,也不兼容。让我们来修复这个问题。

  7. 这里,我们有以下内容:

      arr4 := [9]int{0, 0, 0, 0, 9}
    

    我们需要将其替换为以下内容:

      arr4 := [9]int{0, 0, 0, 0, 9}
    
  8. 我们还有以下代码:

      fmt.Println("[5]int == [9]int{0, 0, 0, 0, 9}  :", comp3)
    

    我们需要将其替换为以下内容:

      fmt.Println("[5]int == [5]int{0, 0, 0, 0, 9}  :", comp3)
    
  9. 保存并再次使用以下命令运行代码:

    go run .
    

    运行前面的代码产生以下输出:

图 4.2:无错误输出

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_04_02.jpg)

图 4.2:无错误输出

在我们的练习中,我们定义了一些数组,并且它们都是用稍微不同的方式定义的。起初,我们有一个错误,因为我们试图比较不同长度的数组,在 Go 中这意味着它们是不同类型的。我们修复了这个问题,并再次运行了代码。然后,我们可以看到,尽管前三个数组是用不同的方法定义的,但它们最终是相同的或相等的。最后一个数组,现在类型已修复,包含不同的数据,所以它不与其它数组相同或相等。其他集合类型,即切片和映射,不能以这种方式比较。在使用映射和切片时,你必须遍历你要比较的两个集合的内容,并手动进行比较。这种能力给数组带来了优势,如果你的代码中比较集合数据是一个热点路径。

使用键初始化数组

到目前为止,当我们用数据初始化数组时,我们让 Go 为我们选择键。如果你想要使用 [<大小>]<类型>{<键 1>:<值 1>,…<键 N>:<值 N>} 来选择你想要的数据键,Go 允许你这样做。Go 是灵活的,允许你设置带有间隔的键,并且可以按任何顺序设置。如果你定义了一个数组,其中数字键具有特定的含义,并且你想为特定的键设置值,但不需要设置其他任何值,这种用键设置值的能力是有帮助的。

练习 4.03:使用键初始化数组

在这个练习中,我们将使用一些键初始化几个数组,并将它们相互比较。然后,我们将打印出一个数组并查看其内容。让我们开始吧:

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

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

    package main
    import "fmt"
    
  3. 创建一个函数,该函数定义了三个数组:

    func compArrays() (bool, bool, [10]int) {
      var arr1 [10]int
      arr2 := [...]int{9: 0}
      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,我们混合了使用键和不使用键的情况,并且我们还使用了顺序不正确的键。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是分配给索引变量的名称。

如你从上一章所记得,for循环有三个可能的部分:循环前可以运行的逻辑、每次循环交互时运行的逻辑以检查循环是否应该继续,以及每次循环迭代结束时运行的逻辑。一个for i循环看起来像i := 0; i < len(arr); i++ {。发生的事情是我们将i定义为零,这也意味着i只存在于循环的作用域内。然后,在循环的每次迭代中检查i以确保它小于数组的长度。我们检查它是否小于数组的长度,因为长度总是比最后一个索引键多 1。最后,我们在每次循环中递增i,这样我们就可以逐个遍历数组中的每个元素。

当涉及到数组的长度时,可能会诱使你直接硬编码最后一个索引的值,而不是使用len,因为你知道数组的长度始终相同。硬编码长度是一个糟糕的想法。硬编码会使你的代码更难维护。数据的变化和演变是很常见的。如果你需要回来更改数组的尺寸,硬编码的数组长度会引入难以发现的错误,甚至可能导致运行时崩溃。

使用循环与数组一起使用,允许你对每个元素重复相同的逻辑,即验证数据、修改数据或输出数据,而无需为多个变量重复相同的代码。

练习 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循环非常常见,所以请特别注意第 4 步,即我们定义循环的地方,并确保理解三个部分各自的作用。

注意

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循环中使用数组,处理数组中的数据就变得简单了。与处理其他集合相比,数组的一个优点是它们的长度是固定的。使用数组时,不可能意外地改变数组的大小,从而导致无限循环,这是一种无法结束的循环,会导致软件无限期地运行并消耗大量资源。

活动四.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]
    

    注意

    本活动的解决方案可在第 696 页找到

切片

数组很棒,但它们在大小上的僵化可能会引起问题。如果你想要创建一个接受数组并对其中的数据进行排序的函数,它只能适用于一种数组大小。这需要你为每种数组大小创建一个函数。这种对大小的严格性使得使用数组感觉像是一种麻烦和不吸引人的体验。数组的另一面是,它们是管理排序数据集合的高效方式。如果有一种方法可以同时获得数组的效率和更多的灵活性,那岂不是很好?Go 通过切片的形式为你提供了这种功能。

切片是围绕数组的一层薄薄的外壳,让你拥有一个排序的数值索引集合,而无需担心大小。在薄薄的外壳下面仍然是 Go 数组,但 Go 为你管理所有细节,例如使用多大的数组。你使用切片的方式就像使用数组一样;它只包含一个类型的值,你可以使用[]读取和写入每个元素,并且它们使用for i循环进行循环时很容易。

切片还能做的另一件事是使用内置的append函数轻松扩展。这个函数接受你的切片和你要添加的值,并返回一个包含所有内容的新切片。通常,我们会从一个空切片开始,并根据需要扩展它。

由于切片是围绕数组的一层薄薄的外壳,这意味着它不是一个真正的类型,就像数组一样。你需要了解 Go 如何使用切片后面的隐藏数组。如果你不了解这一点,可能会导致微妙且难以调试的错误。

在现实世界的代码中,你应该使用切片作为所有排序集合的首选。这将使你更加高效,因为你不需要像使用数组那样编写那么多代码。在现实世界的项目中,你将看到的代码大多数都使用了大量的切片,而很少使用数组。数组仅在需要确切长度时使用,即使在这种情况下,切片也通常被使用,因为它们可以更容易地在代码中传递。

练习 4.08:处理切片

在这个练习中,我们将通过从切片中读取一些数据、将切片传递给函数、遍历切片、从切片中读取值以及向切片末尾添加值来展示切片的灵活性。让我们开始吧:

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

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

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 创建一个函数,它接受一个int类型的参数并返回一个字符串切片:

    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. 切片的第一个元素是代码的调用方式,而不是一个参数,所以我们将移除它:

      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()函数中,我们调用函数并检查错误:

    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 感觉像是一种动态语言的原因之一。

活动 4.02:根据用户输入打印用户姓名

现在轮到你了,开始使用地图。我们将定义一个地图,并创建逻辑来根据传递给应用程序的键打印地图中的数据。以下是这个活动的步骤:

  1. 创建一个新的 Go 应用程序。

  2. 定义一个包含以下键值对的map

    键:305,值:Sue

    键:204,值:Bob

    键:631,值:Jake

    键:073,值:Tracy

  3. 使用os.Args读取传递进来的键并打印相应的名字;例如,go run . 073

  4. 正确处理没有传递参数或传递的参数不匹配map中的值的情况。

  5. 向用户打印包含在值中的名字的消息。

    预期输出如下:

    Hi, Tracy
    

    注意

    这个活动的解决方案可以在第 697 页找到

向切片中添加多个项目

内置的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. 创建一个接受字符串切片作为参数并返回字符串切片的函数。然后,定义一个字符串切片变量:

    func getLocals(extraLocals []string) []string {
      var locales []string
    
  5. 使用 append 方法向切片中添加多个字符串:

      locales = append(locales, "en_US", "fr_FR")
    
  6. 从参数中添加更多数据:

      locales = append(locales, extraLocals...)
    
  7. 返回变量并关闭函数定义:

      return locales
    }
    
  8. main() 函数中,获取用户输入,将其传递给我们的函数,然后打印结果:

    func main() {
      locales := getLocals(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 操作,并在后台优化调用以确保不浪费资源。

活动 4.03:创建区域检查器

在这个活动中,我们将创建一个区域验证器。区域是一个国际化本地化概念,它是语言和国家的组合。我们将创建一个表示区域的结构。之后,我们将定义代码支持的区域列表。然后,我们将从命令行读取一些区域代码,并打印出我们的代码是否接受该区域。

下面是这个活动的步骤:

  1. 创建一个新的 Go 应用程序。

  2. 定义一个具有语言字段和单独的国家或地区字段的结构。

  3. 创建一个集合来存储至少五个区域的本地定义,例如,"en_US"、"en_CN"、"fr_CN"、"fr_FR" 和 "ru_RU"。

  4. 使用 os.Args 等方法从命令行读取本地,确保有错误检查和验证。

  5. 将传递的区域字符串加载到一个新的区域结构中。

  6. 使用该结构来检查传递的结构是否受支持。

  7. 向控制台打印一条消息,说明是否支持该区域。

    预期的输出如下:

图 4.4:预期输出

img/B14177_04_04.jpg

图 4.4:预期输出

注意

本活动的解决方案可以在第 698 页找到。

从切片和数组创建切片

通过使用与访问数组或切片中单个元素类似的符号,你可以创建从数组或切片内容派生的新切片。最常用的符号是 [<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:从切片创建切片后的输出

在这个练习中,我们尝试了几种从另一个切片创建切片的方法。你也可以将这些相同的技巧用于数组作为源。我们注意到起始索引和停止索引都是可选的。如果你没有起始索引,它将从源切片或数组的开始处开始。如果你没有停止索引,那么它将在数组的末尾停止。如果你跳过了起始索引和停止索引,它将复制切片或数组。这个技巧对于将数组转换为切片很有用,但对于复制切片没有帮助,因为两个切片共享同一个隐藏数组。

理解切片内部机制

切片很棒,当你需要有序列表时应该首选使用,但如果你不知道它们在底层是如何工作的,它们会导致难以发现的错误。

数组是一种类似于字符串或int的值类型。值类型可以被复制并与其自身进行比较。这些值类型一旦被复制,就不再与它们的源值连接。切片不像值类型那样工作;它们更像指针,但它们也不是指针。

使用切片保持安全的关键在于理解存在一个隐藏数组来存储值,并且对切片的更改可能需要或不要求用更大的一个替换隐藏数组。隐藏数组的管理在后台发生的事实使得很难很好地推理切片中正在发生的事情。

切片有三个隐藏属性:长度、指向隐藏数组的指针以及它在隐藏数组中的起始位置。当你向切片添加内容时,这些属性之一或全部会更新。哪些属性会更新取决于隐藏数组是否已满。

隐藏数组的尺寸和切片的尺寸并不总是相同的。切片的尺寸是其长度,我们可以通过使用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)
    

    返回三个切片并关闭函数定义:

      return s1, s2, s3
    }
    
  7. 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))
    }
    
  8. 保存文件。然后,在您创建的 步骤 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. 创建一个返回三个整数的函数:

    func linked() (int, int, int) {
    
  4. 定义一个初始化了一些数据的整数切片:

      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. 创建一个将返回两个整数的函数:

    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. 在我们的下一个函数中,我们将返回两个整数:

    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:显示数据的输出

图 4.7:显示数据的输出

在这个练习中,我们逐步分析了五种不同的场景,在这些场景中我们复制了切片数据。在链接场景中,我们对第一个切片进行了简单的复制,然后对其进行了范围复制。虽然切片本身是不同的,不再是同一个切片,但在现实中,它们所持有的数据并没有区别。每个切片都指向同一个隐藏数组,所以当我们对第一个切片进行更改时,它会影响所有切片。

无链接场景中,第一和第二个切片的设置相同,但在我们对第一个切片进行更改之前,我们向其中添加了一个值。当我们向其中添加这个值时,在后台,Go 需要创建一个新的数组来存储现在的大量值。由于我们是在向第一个切片追加,其指针指向了新的、更大的切片。第二个切片没有更新其指针。这就是为什么当第一个切片的值发生变化时,第二个切片没有受到影响。第二个切片不再指向同一个隐藏数组,这意味着它们不再链接。

对于带链接场景,第一个切片是使用make和过大的容量定义的。这个额外的容量意味着当第一个切片被追加一个值时,隐藏数组中已经有了额外的空间。这个额外的容量意味着不需要替换隐藏数组。结果是,当我们更新第一个切片上的值时,它和第二个切片仍然指向同一个隐藏数组,这意味着更改会影响两者。

无链接场景中,设置与上一个场景相同,但在我们追加值时,我们追加的值超过了可用的容量。尽管有额外的容量,但仍然不够,第一个切片中的隐藏数组被替换。结果是,两个切片之间的链接断裂。

无链接中,我们使用了内置的copy函数来为我们复制值。虽然这会将值复制到一个新的隐藏数组中,但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 编译器中,这是复制切片最节省内存的方法。

Map 基础知识

虽然 数组和切片 相似,有时可以互换使用,但 Go 的其他集合类型 map 则相当不同,不能与数组和切片互换。Go 的 map 类型服务于不同的目的。

Go 的 map 在计算机科学术语中是一个哈希表。map 与其他集合类型的区别主要在于其键。在数组或切片中,键是一个占位符,它本身没有意义。它仅仅作为计数器存在,与值没有直接关系。

在 map 中,键是数据——与值有真实关系的数据。例如,你可以在 map 中有一个用户账户记录的集合。键将是用户的员工 ID。员工 ID 是真实数据,而不仅仅是一个任意的占位符。如果有人给你他们的员工 ID,你就能查找到他们的账户记录,而无需遍历数据来找到它。使用 map,你可以快速地设置、获取和删除数据。

你可以像访问切片或数组中的单个元素一样访问 map 的单个元素:使用 []。map 的键可以是任何可以直接比较的类型,如 int 或 string。你不能比较切片,因此它们不能作为键。map 的值可以是任何类型,包括指针、切片和 map。

你不应该将 map 用作有序列表。即使你打算使用 int 作为 map 的键,map 也不保证始终从索引 0 开始,也不保证键之间没有空隙。即使你想要 int 键,这个特性也可能是一个优势。如果你有稀疏填充的数据,即在切片或数组中键之间有间隙的值,它将包含大量零数据。在 map 中,它只会包含你设置的数据。

要定义一个地图,你使用以下表示法:map[<key_type>]<value_type>。你可以使用 make 来创建地图,但使用 make 创建地图时的参数是不同的。Go 不能为地图创建键,因此你不能像使用切片那样创建任意长度的地图。你可以建议一个容量,编译器可以使用这个容量来创建你的地图。为地图建议容量是可选的,并且不能使用 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 的函数:

    func getUsers() map[string]string {
    
  4. 定义一个具有字符串键和字符串值的 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 是一个布尔值,如果键存在于映射中则为真,否则为假。当遍历映射时,你应该使用 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. 捕获传入的参数并调用获取用户函数:

      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:显示所有用户及其找到的名称的输出img/B14177_04_08.jpg

图 4.8:显示所有用户及其找到的名称的输出

在这个练习中,我们学习了如何检查一个键是否存在于映射中。从需要先检查键的存在性才能获取值的语言中来看,这可能会显得有些奇怪,不是在获取值之后。这种做事方式确实意味着运行时错误的可能性要小得多。如果你的领域逻辑中零值是不可能的,那么你可以使用这个事实来检查键是否存在。

我们使用 range 循环来优雅地打印出我们映射中的所有用户。你的输出可能与前面截图中的输出顺序不同,这是因为当你使用 range 时,Go 会随机化映射中元素的顺序。

活动四.04:切片一周

在本活动中,我们将创建一个切片,并用一些数据初始化它。然后,我们将使用我们学到的关于子切片的知识来修改这个切片。以下是本活动的步骤:

  1. 创建一个新的 Go 应用。

  2. 创建一个切片,并用一周中的所有天初始化它,从周一开始到周日结束。

  3. 使用切片范围更改切片,使其从周日开始,周六结束。

  4. 将切片打印到控制台。

    预期的输出如下:

    [Sunday Monday Tuesday Wednesday Thursday Friday Saturday]
    

    注意

    本活动的解决方案可以在第 700 页找到

从映射中删除元素

如果你需要从映射中删除一个元素,你需要做不同于数组或切片的事情。在数组中,你不能删除元素,因为长度是固定的;你最好的办法是将值置零。在切片中,你可以将值置零,但也可以使用切片范围和追加的组合来删除一个或多个元素。在映射中,你可以将值置零,但元素仍然存在,所以在你的逻辑中检查键是否存在时会导致问题。你也不能在映射上使用切片范围来删除元素。

要移除一个元素,我们需要使用内置的 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

活动四.05:从切片中移除元素

Go 没有内置的从切片中移除元素的功能,但你可以使用你学到的技术来实现。在本活动中,我们将设置一个包含一些数据和要移除的一个元素的切片。然后,你需要想出如何做到这一点。有许多方法可以完成这项工作,但你能否找到最紧凑的方法?

这里是这个活动的步骤:

  1. 创建一个新的 Go 应用程序。

  2. 创建一个包含以下元素的切片:

    Good

    Good

    Bad

    Good

    Good

  3. 编写代码从切片中删除“Bad”元素。

  4. 将结果打印到控制台。

    以下是我们预期的输出:

    [Good Good Good Good]
    

    注意

    该活动的解决方案可以在第 701 页找到

简单自定义类型

你可以使用 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. 现在,返回 ids 并关闭函数:

      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:比较后的输出

在这个练习中,我们创建了一个自定义类型,为其设置数据,然后将其与相同类型和基类型的数据进行比较。

简单的自定义类型是构建现实世界中遇到的数据问题的基础部分。拥有旨在反映你需要处理的数据的类型可以帮助保持你的代码易于理解和维护。

结构体

集合非常适合将相同类型和目的的值分组在一起。在 Go 中,还有另一种方式将数据分组在一起,用于不同的目的。通常,一个简单的字符串、数字或布尔值并不能完全捕捉到你将拥有的数据的本质。

例如,对于我们的用户映射,一个用户通过他们的唯一 ID 和他们的名字来表示。这很少会有足够的信息来处理用户记录。你可以关于一个人捕获的数据几乎是无限的,比如他们的给定名、中间名和姓氏。他们偏好的前缀和后缀,他们的出生日期,他们的身高、体重或他们工作的地方也可以被捕获。将数据存储在多个具有相同键的映射中是可能的,但这很难处理和维护。

理想的做法是将所有这些不同的数据收集到一个单一的数据结构中,你可以设计和控制它。这就是 Go 的结构体类型:它是一个可以命名并指定字段属性及其类型的自定义类型。

结构体的表示法如下:

type <name> struct {
  <fieldName1> <type>
  <fieldName2> <type>
  …
  <fieldNameN> <type>
}

字段名必须在结构体内部是唯一的。你可以为字段使用任何类型,包括指针、集合和其他结构体。

你可以使用以下表示法访问结构体上的字段:<structValue>.<fieldName>。要设置值,你使用以下表示法:<structValue>.<fieldName> = <value>。要读取值,你使用以下表示法:value = <structValue>.<fieldName>

结构体是 Go 语言中与其它语言中所谓的类最接近的东西,但 Go 的设计者故意将结构体简化。一个关键的区别是结构体没有任何形式的继承。Go 的设计者认为,继承在现实世界的代码中带来的问题比解决的问题更多。

一旦你定义了自定义的结构体类型,你就可以用它来创建一个值。你有几种从结构体类型创建值的方法。现在让我们看看它们:

练习 4.17:创建结构体类型和值

在这个练习中,我们将定义一个用户结构体。我们将定义不同类型的字段。然后,我们将使用几种不同的方法创建一些结构体值。让我们开始吧:

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

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

    package main
    import "fmt"
    
  3. 我们首先要做的是定义我们的结构体类型。你通常在包作用域内这样做。我们需要给它一个在包级作用域中唯一的名字:

    type user struct {
    
  4. 我们将添加不同类型的字段,然后关闭结构体定义:

      name  string
      age   int
      balance float64
      member  bool
    }
    
  5. 我们将创建一个返回我们新定义的结构体类型的切片的函数:

    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:根据新结构体的输出

在这个练习中,您定义了一个包含多个字段的自定义结构体类型,每个字段类型不同。然后,我们使用几种不同的方法从该结构体创建值。这些方法都是有效的,在不同的上下文中都有用。

我们在包作用域中定义了结构体,虽然这不是典型做法,但您也可以在函数作用域中定义结构体类型。如果您在函数中定义了结构体类型,它将只在该函数中有效。当在包级别定义类型时,它可以在整个包中使用。

也可以同时定义和初始化一个结构体。如果您这样做,就不能重用该类型,但这仍然是一种有用的技术。记法如下:

type <name> struct {
  <fieldName1> <type>
  <fieldName2> <type>
  …
  <fieldNameN> <type>
}{
  <value1>,
  <value2>,
  …
  <valueN>,
}

您也可以使用键值记法进行初始化,但仅使用值进行初始化在这种情况下是最常见的。

比较结构体

如果一个结构体的所有字段都是可比较的类型,那么整个结构体也是可比较的。所以,如果你的结构体由字符串和整数组成,那么你可以比较整个结构体。如果你的结构体中有一个切片,那么就不能。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,
      }
    
  6. 使用第二个匿名结构体,我们将其初始化为零,然后在初始化后更改其值:

      point2 := struct {
        x int
        y int
      }{}
      point2.x = 10
      point2.y = 5
    
  7. 最后要创建的结构体使用了我们之前创建的命名结构体类型:

      point3 := point{10, 10}
    
  8. 比较它们。然后,返回并关闭函数:

      return point1 == point2, point1 == point3
    }
    
  9. main中,我们将调用我们的函数并打印结果:

    func main() {
      a, b := compare()
      fmt.Println("point1 == point2:", a)
      fmt.Println("point1 == point3:", b)
    }
    
  10. 保存文件。然后,在您在步骤 1中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    运行前面的代码会产生以下输出:

图 4.11:比较结构体的输出

图 4.11:比较结构体的输出

在这个练习中,我们看到了我们可以像处理命名结构体类型一样处理匿名结构体值,包括比较它们。对于命名类型,你只能比较相同类型的结构体。当你比较 Go 中的类型时,Go 会比较所有字段以检查匹配。Go 允许比较这些匿名结构体,因为字段名称和类型匹配。Go 在比较这种结构体时有点灵活。

使用内嵌的结构体组合

虽然 Go 结构体不支持继承,但 Go 的设计者确实包含了一个令人兴奋的替代方案。这个替代方案是将类型嵌入到结构体类型中。使用内嵌,你可以从其他结构体向结构体添加字段。这种组合功能的效果是让你可以使用其他结构体作为组件来向结构体添加内容。内嵌与有一个结构体类型的字段不同。当你内嵌时,内嵌结构体的字段会被提升。一旦提升,字段就像是在目标结构体上定义的一样。

要嵌入一个结构体,你就像添加一个字段一样添加它,但不要指定名称。为此,你将结构体类型名称添加到另一个结构体中,而不给它一个字段名称,这看起来是这样的:

type <name> struct {
  <Type>
}

虽然不常见,但你可以在结构体中内嵌任何其他类型。没有需要推广的内容,因此要访问内嵌类型,你需要使用类型的名称来访问它,例如,<structValue>.<type>。通过类型名称访问内嵌类型的方式也适用于结构体。这意味着与内嵌类型和根字段名称相比,类型的名称必须是唯一的。在嵌入指针类型时,类型的名称是不带指针表示的类型名称,因此*<type>名称变为<type>。字段仍然是指针,只是名称不同。

当涉及到推广时,如果你与结构体的字段名称有任何重叠,Go 允许你内嵌,但重叠字段的推广不会发生。你仍然可以通过类型名称路径来访问该字段。

在使用内嵌类型初始化结构体时,不能使用推广。为了初始化数据,你必须使用内嵌类型的名称。

练习 4.19:结构体内嵌和初始化

在这个练习中,我们将定义一些结构体和自定义类型。我们将将这些类型嵌入到结构体中。让我们开始吧:

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

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

    package main
    import "fmt"
    
  3. 创建一个名为 name 的自定义字符串类型:

    type name string
    
  4. 创建一个名为 location 的结构体,包含两个 int 字段,即xy

    type location struct {
      x int
      y int
    }
    
  5. 创建一个包含两个 int 字段的大小结构体,即widthheight

    type size struct {
      width  int
      height int
    }
    
  6. 创建一个名为dot的结构体。它将前面提到的每个结构体嵌入其中:

    type dot struct {
      name
      location
      size
    }
    
  7. 创建一个返回 dots 切片的函数:

    func getDots() []dot {
    
  8. 我们第一个dot使用var表示法。这将导致所有字段都具有零值:

      var dot1 dot
    
  9. dot2中,我们也在使用零值进行初始化:

      dot2 := dot{}
    
  10. 要设置名称,我们使用类型的名称,就像它是一个字段一样:

      dot2.name = "A"
    
  11. 对于大小和位置,我们将使用提升的字段来设置它们的值:

      dot2.x = 5
      dot2.y = 6
      dot2.width = 10
      dot2.height = 20
    
  12. 在初始化内嵌类型时,不能使用提升。对于名称,结果是相同的,但对于位置和大小,你需要在这方面多下功夫:

      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:结构体内嵌和初始化后的输出图 4.12:结构体内嵌和初始化后的输出在这个练习中,我们能够通过将其他类型嵌入其中来定义一个复杂的结构体。内嵌允许你通过减少重复代码来重用常见的结构,同时仍然给你的结构体提供一个扁平的 API。在现实世界的 Go 代码中,我们不会看到很多内嵌。它确实会出现,但由于复杂性和异常,Go 开发者更喜欢将其他结构体作为命名字段。## 类型转换有时候你的类型可能不匹配,在 Go 的严格类型系统中,如果类型不相同,它们就不能相互交互。在这些情况下,你有两个选择。如果两种类型是兼容的,你可以进行类型转换——也就是说,你可以通过将一种类型转换为另一种类型来创建一个新的值。进行这种转换的表示法是<value>.(<type>)。当处理字符串时,我们使用这个表示法将字符串转换为 runes 或 bytes 的切片,然后再转换回来。这是因为字符串是一个特殊类型,它将字符串的数据存储为一个字节的切片。字符串类型转换是有损的,但并非所有类型转换都是这样。当处理数值类型转换时,数值可能会从原始值改变。例如,如果你将一个大的int类型,比如int64,转换为一个较小的int类型,比如int8,这会导致数值溢出。如果你要将无符号整数,比如uint64,转换为一个有符号整数,比如int64,这种溢出会发生,因为无符号整数可以存储比有符号的int更高的数值。当将int转换为float时,这种溢出也会发生,因为float将其存储空间分割为整数部分和小数部分。当从float转换为int时,小数部分会被截断。进行这类有损转换仍然非常合理,这些转换在现实世界的代码中经常发生。如果你知道你处理的数据不会跨越这些阈值,那么就没有必要担心。Go 会尽力猜测需要转换的类型。这被称为隐式类型转换。例如,math.MaxInt8是一个int,如果您尝试将其赋值给一个非int类型的数字,Go 会为您进行隐式类型转换。## 练习 4.20:数值类型转换在这个练习中,我们将进行一些数值类型转换,并故意造成一些数据问题。让我们开始吧:1. 创建一个新的文件夹,并在其中添加一个名为main.go的文件。1. 在main.go中添加包和导入: go package main import (   "fmt"   "math" ) 1. 创建一个返回字符串的函数: go func convert() string{ 1. 定义一些变量来完成我们的工作。Go 会将int类型的math.MaxInt8隐式转换为int8go   var i8 int8 = math.MaxInt8   i := 128   f64 := 3.14 1. 在这里,我们将从较小的int类型转换为较大的int类型。这始终是一个安全的操作: go   m := fmt.Sprintf("int8  = %v > in64  = %v\n", i8, int64(i8)) 1. 现在,我们将从比int8最大值大 1 的int类型进行转换。这将导致溢出到int8的最小值: go   m += fmt.Sprintf("int   = %v > in8   = %v\n", i, int8(i)) 1. 接下来,我们将int8转换为float64。这不会导致溢出,数据保持不变: go   m += fmt.Sprintf("int8  = %v > float32 = %v\n", i8, float64(i8)) 1. 在这里,我们将一个浮点数转换为int。所有的小数数据都会丢失,但整数部分会保持不变: go   m += fmt.Sprintf("float64 = %v > int   = %v\n", f64, int(f64)) 1. 返回消息然后关闭函数: go   return m } 1. 在main()函数中,调用函数并将输出打印到控制台: go func main() {   fmt.Print(convert()) } 1. 保存文件。然后,在您创建的文件夹中,使用以下命令运行代码: go go run . 运行前面的代码会产生以下输出:图 4.13:转换后的输出

图 4.13:转换后的输出

类型断言和 interface{}

我们已经大量使用了fmt.Print及其兄弟函数来编写我们的代码,但 Go 是强类型语言,那么像fmt.Print这样的函数是如何接受任何类型的值呢?让我们看看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 的源代码并不可怕——这是一个了解您应该如何做事的好方法,我建议您在好奇他们是如何做某事的时候查看它。

通过查看这段代码,我们可以看到 fmt.Print 有一个 interface{} 类型的可变参数。我们将在稍后更详细地介绍接口,但就现在而言,你需要知道的是,Go 中的接口描述了一个类型必须具有哪些函数才能符合该接口。Go 中的接口不描述字段,也不描述类型的核心值,例如字符串或数字。在 Go 中,任何类型都可以有函数,包括字符串和数字。interface{} 描述的是一个没有函数的类型。没有函数、字段和核心值的值有什么用?没有用,但它仍然是一个值,并且仍然可以被传递。这个接口不是设置值的类型,而是控制它将允许哪些值作为具有该接口的变量。Go 中哪些类型符合 interface{}?所有类型都符合!Go 的任何类型或你创建的任何自定义类型都符合 interface{},这就是 fmt.Print 可以接受任何类型的原因。你还可以在你的代码中使用 interface{} 来实现相同的结果。

一旦你有了符合 interface{} 的变量,你能用它做什么?即使你的 interface{} 变量的底层值有函数、字段或核心值,你也不能使用它们,因为 Go 正在强制执行接口的合约,这就是为什么这仍然是所有类型安全的。

为了解锁被 interface{} 遮盖的值的特性,我们需要使用类型断言。类型断言的表示法是 <value>.(<type>)。类型断言会产生一个请求的类型值,以及一个可选的 bool 值,表示是否成功。这看起来像 <value> := <value>.(<type>)<value>, <ok> := <value>.(type)。如果你省略布尔值,并且类型断言失败,Go 会引发恐慌。

当你将值放入 interface{} 变量中时,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.14:显示匹配的输出

图 4.14:显示匹配的输出

interface{}和类型断言的组合允许你克服 Go 的严格类型控制,从而允许你创建可以处理任何类型变量的函数。挑战在于,你失去了 Go 在编译时为你提供的类型安全保护。仍然可能做到安全,但现在责任在你身上——如果你做错了,你将得到一个讨厌的运行时错误。

类型切换

如果我们想将doubler函数扩展到包括所有int类型,我们最终会有很多重复的逻辑。Go 有一种处理更复杂类型断言情况的方法,称为类型切换。下面是这个样子:

switch <value> := <value>.(type) {
case <type>:
  <statement>
case <type>, <type>:
  <statement>
default:
  <statement>
}

类型切换仅在匹配你寻找的类型时运行你的逻辑,并将值设置为该类型。你可以在 case 中匹配多个类型,但 Go 不能为你更改值的类型,所以你仍然需要进行类型断言。使这成为类型切换而不是表达式切换的其中一个特点是<value>.(type)记法。你只能将其用作类型切换的一部分。类型切换独有的另一个特点是,你不能使用fallthrough语句。

练习 4.22:类型切换

在这个练习中,我们将更新我们的doubler函数,使其使用类型切换并扩展其功能以处理更多类型。让我们开始吧:

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

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

    package main
    import (
      "errors"
      "fmt"
    )
    
  3. 创建我们的函数,它接受一个inferface{}参数,并返回一个字符串和一个错误:

    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. 我们将使用默认值返回一个错误。然后,我们将关闭 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.15:调用函数后的输出

图 4.15:调用函数后的输出

在这个练习中,我们使用类型选择(type switch)构建了一个复杂类型断言场景。使用类型选择仍然让我们对类型断言有完全的控制,同时也让我们在不需要这种控制级别时简化了类型安全逻辑。

活动四.06:类型检查器

在这个活动中,你将编写一些包含切片或不同类型数据的逻辑。这些数据类型如下:

  • 一个整数

  • 一个浮点数

  • 一个字符串

  • 一个布尔值

  • 一个结构体

创建一个接受任何类型值的函数。该函数返回一个包含类型名称的字符串:

  • 对于 int、int32 和 int64,它返回int

  • 对于所有浮点数,它返回float

  • 对于字符串,它返回string

  • 对于布尔值,它返回bool

  • 对于其他任何东西,它返回unknown

  • 通过将每个数据传递给函数来遍历所有数据。

  • 然后,将数据和其类型名称打印到控制台。

    预期输出如下:

图 4.16:预期输出

图 4.16:预期输出

注意

本活动的解决方案可以在第 702 页找到

摘要

在本章中,我们探讨了 Go 中变量和类型的进阶用法。现实世界的代码很快就会变得复杂,因为现实世界本身就是复杂的。能够准确地对数据进行建模并在代码中逻辑上组织这些数据有助于将代码的复杂性降低到最小。

你现在知道如何将类似的数据分组,无论是使用数组在固定长度有序列表中,还是在动态长度有序列表中使用切片,或者在使用映射的键值哈希中。

我们学习了如何超越 Go 的核心类型,并开始创建基于核心类型或通过创建一个结构体(struct)的自定义类型,结构体是一个包含在单个类型和值中的其他类型的集合。

有时会遇到类型不匹配的情况,因此 Go 赋予我们转换兼容类型的能力,以便它们可以以类型安全的方式交互。

Go 还让我们摆脱了其类型安全规则,并赋予我们完全的控制权。通过使用类型断言,我们可以使用interface{}的魔力接受任何类型,然后获取这些类型。

在下一章中,我们将探讨如何将我们的逻辑分组为可重用组件,并将它们附加到我们的自定义类型上,从而使我们的代码更加直接且易于维护。

第五章:5. 函数

概述

本章将详细描述函数的各个部分,例如定义函数、函数标识符、参数列表、返回类型和函数体。我们还将探讨设计函数时的最佳实践,例如函数执行单一任务、如何减少代码、使函数小型化以及确保函数可重用。

到本章结束时,你将能够描述函数以及构成函数的不同部分,并评估函数的变量作用域。你将学会创建和调用函数;利用可变参数和匿名函数,并为各种结构创建闭包。你还将学会将函数用作参数和返回值;以及与函数一起使用defer语句。

简介

函数是许多语言的核心部分,Go 语言也不例外。函数是一段被声明以执行任务的代码。Go 函数可以有零个或多个输入和输出。将 Go 与其他编程语言区分开来的一个特性是它支持多个返回值;大多数编程语言都限制为只有一个返回值。

在下一节中,我们将看到 Go 函数的一些与其他语言不同的特性,例如返回多个类型。我们还将看到 Go 支持一等函数。这意味着 Go 有将变量分配给函数、将函数作为参数传递以及将函数作为函数的返回类型的能力。我们将展示如何使用函数将复杂部分分解成更小的部分。

Go 语言中的函数被视为一等公民和高级函数。一等公民是将函数分配给变量的函数。高级函数是可以接受函数作为参数的函数。Go 函数的丰富特性使它们能够在以下方式中用于各种段:

  • 将函数作为参数传递给另一个函数

  • 从函数中返回一个函数值

  • 函数作为一种类型

  • 闭包

  • 匿名函数

  • 将函数分配给变量

我们将查看 Go 支持的所有这些功能。

函数

函数是 Go 语言的关键部分,我们应该了解它们的位置。让我们考察一下使用函数的一些原因:

  • 分解复杂任务:函数用于执行任务,但如果任务很复杂,那么应该将其分解成更小的任务。函数可以用于解决更大的问题的小任务。小任务更容易管理,使用函数解决特定任务会使整个代码库更容易维护。

  • 减少代码:你应该使用函数的一个好迹象是在你的程序中看到相似的代码重复出现。当你有重复的代码时,它增加了维护的难度。如果你需要做一次更改,你将有多处代码需要更改。

  • 可重用性:一旦你定义了你的函数,你可以重复使用它。它也可以被其他程序员使用。这种函数的共享将减少代码行数并节省时间,因为你不需要重新发明轮子。在设计函数时,我们应该遵循以下一些准则:

  • 单一职责:一个函数应该执行一个任务。例如,一个函数不应该计算两点之间的距离并估计在这两点之间旅行的所需时间。应该为每个任务有一个函数。这允许更好地测试该函数并更容易维护。将函数缩小到执行单一任务是有难度的,所以如果你第一次没有做对,不要气馁。即使是经验丰富的程序员在为函数分配单一职责时也会遇到困难。

  • 体积小:函数不应该超过数百行代码。这是代码需要重构的迹象。当我们有大型函数时,更有可能违反单一职责原则。一个好的经验法则是尝试将函数大小限制在大约 25 行代码;然而,这不是一个硬性规则。保持代码简洁的好处是它减少了调试大型函数的复杂性。它还使得编写具有更好代码覆盖率的单元测试更容易。

函数的部分

我们现在将探讨定义函数所涉及的不同组件。以下是一个函数的典型布局:

![图 5.1:函数的不同部分图

图 5.1:函数的不同部分

函数的不同部分在此处描述:

  • func 关键字。

  • calculateTaxtotalSumfetchId

    标识符应该是描述性的,使得代码易于阅读,并使函数的目的易于理解。标识符不是必需的。你可以有一个没有名称的函数;这被称为匿名函数。匿名函数将在本章的后续部分详细讨论。

    注意

    当函数名的第一个字母是小写时,那么该函数在包外部不可导出。这意味着它们是私有的,不能从包外部调用。它们只能在包内部调用。

    使用驼峰命名法时请记住这一点。如果你想使你的函数可导出,函数名的第一个字母必须大写。

  • name string, age int)。参数是函数的局部变量。

    参数对于函数是可选的。一个函数可能没有任何参数。一个函数可以有零个或多个参数。

    当两个或多个参数具有相同的类型时,你可以使用所谓的简写参数表示法。这消除了为每个参数指定相同类型的需要。例如,如果你的参数是(firstName string, lastName string),它们可以缩短为(firstName, lastName string)。这减少了参数输入的冗长性,并增加了函数参数列表的可读性。

  • 返回类型:返回类型是一系列数据类型,如布尔值、字符串、映射或可以返回的另一个函数。

    在声明函数的上下文中,我们把这些类型称为返回类型。然而,在调用函数的上下文中,它们被称为返回值。

    返回类型是函数的输出。通常,它们是提供给函数的参数的结果。它们是可选的。大多数编程语言返回单个类型;在 Go 语言中,你可以返回多个类型。

  • {}

    函数中的语句决定了函数做什么。函数代码是执行函数被创建来完成的任务的代码。

    如果定义了返回类型,那么函数体中需要有一个return语句。return语句使函数立即停止并返回return语句之后列出的值类型。返回类型列表和return语句中的类型必须匹配。

    在函数体中,可以有多个return语句。

  • 函数签名:尽管在先前的代码片段中没有列出,但函数签名是一个术语,它指的是输入参数与返回类型的组合。这两个单元共同构成了函数签名。

    通常,当其他人使用函数时定义函数签名时,你想要努力不对其进行更改,因为这可能会对你的代码和别人的代码产生不利影响。

随着我们通过本章的进展,我们将深入探讨函数的各个部分。通过以下讨论,这些函数部分将变得更容易理解,所以如果你现在还没有完全理解所有部分,请不要担心。随着我们继续阅读本章,一切将变得清晰。

fizzBuzz

现在我们已经研究了函数的不同部分,让我们看看这些部分如何通过各种示例来工作。让我们从一个经典的编程游戏fizzBuzz开始。fizzBuzz的规则很简单。fizzBuzz函数根据某些数学结果打印出各种消息。规则根据给定的数字执行以下操作之一:

  • 如果数字能被3整除,则打印Fizz

  • 如果数字能被5整除,则打印Buzz

  • 如果数字能被15整除,则打印FizzBuzz

  • 否则,打印数字。

以下是实现此输出的代码片段:

func fizzBuzz() {
    for i := 1; i <= 30; i++ {
        if i%15 == 0 {
            fmt.Println("FizzBuzz")
        } else if i%3 == 0 {
            fmt.Println("Fizz")
        } else if i%5 == 0 {
            fmt.Println("Buzz")
        } else {
            fmt.Println(i)
        }
    } 
}

现在我们来分部分查看代码:

func fizzBuzz() {
  • func,如您所记得,是声明函数的关键字。这通知 Go,以下代码块将是一个函数。

  • fizzBuzz是我们函数的名称。在 Go 语言中,使用驼峰式命名法是惯例。

  • (),函数名称后面的括号是空的:我们当前实现的FizzBuzz游戏不需要任何输入参数。

  • 参数列表()和开括号之间的空格将是返回类型。我们当前的实施并不需要返回类型。

  • 关于{,与您可能了解的其他编程语言不同,Go 要求开括号与函数声明在同一行上。如果您尝试运行程序时开括号不在函数签名同一行上,您将得到一个错误。

    for i := 1; i <= 30; i++ {
    

    前一行是一个for循环,它将i变量从1增加到30

    if i%15 == 0 {
    
  • %是取模运算符;它给出两个整数相除的余数。使用我们的函数,如果i15,那么15%15将返回零。我们使用取模运算符来确定i是否能被3515整除。

    注意

    随着我们越来越熟悉 Go 的概念和语言语法,代码的解释将排除我们本会多次提到的项目。

我们现在已经定义了我们的函数。它有一个特定的任务我们希望它执行,但如果我们不执行该函数,那就没有好处。那么,我们如何执行一个函数呢?我们必须调用我们的函数。当我们调用一个函数时,我们是在告诉我们的程序执行该函数。我们将在main()函数内部调用我们的函数。

函数可以调用其他函数。当这种情况发生时,控制权交给了被调用的函数。在被调用的函数返回数据或达到结束括号}后,控制权交还给调用者。让我们通过一个例子来更好地理解这一点:

func main() {
  fmt.Println("Main is in control")
  fizzBuzz()
  fmt.Println("Back to main")
}
  • }fmt.Println("Main is in control"): 这条打印语句用于演示目的。它显示我们处于main()函数中。

  • fizzBuzz(): 我们现在在main()函数内部调用该函数。尽管我们的函数没有参数,但括号仍然是必需的,程序的控制权交给了fizzBuzz()函数。在fizzBuzz()函数完成后,控制权随后交还给main()函数。

  • fmt.Println("Back to main"): 这条打印语句用于演示目的,以显示控制权已交还给main()函数。

    输出将如下所示:

图 5.2:fizzBuzz 的输出

图 5.2:fizzBuzz 的输出

注意

即使没有输入参数,fizzBuzz函数后面的括号也是必需的。如果省略了它们,Go 编译器将生成一个错误,指出fizzBuzz已评估但未使用。这是一个常见的错误。

输出将如下所示:

![图 5.3:没有括号的 fizzBuzz 输出图 5.3:没有括号的 fizzBuzz 输出

图 5.3:没有括号的 fizzBuzz 输出

练习 5.01:创建一个函数以打印销售人员的期望评分

在这个练习中,我们将创建一个没有参数或返回类型的函数。该函数将遍历一个映射并打印映射中销售的商品名称和数量。它还将根据销售人员的销售情况打印一条声明。以下步骤将帮助您找到解决方案:

  1. 使用您选择的 IDE。

  2. 创建一个新文件,并将其保存为main.go

  3. main.go中输入以下代码。main将首先调用printAge()函数;它没有参数,也没有返回值:

    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.4:不正确的参数匹配输出]

图 5.4:不正确的参数匹配输出

图 5.4:不正确的参数匹配输出

在代码的不正确版本中,我们用 age 参数调用 greeting() 函数,而该参数的类型为 integer,而参数的类型为 string。你的参数序列必须与参数输入列表的序列匹配。

此外,用户可能希望对代码迭代的 数据有更多的控制。回到 fizzBuzz 例子,当前的实现只做 1100。用户可能需要处理不同的数字范围,因此我们需要一种方法来决定循环的结束范围。我们可以将 fizzBuzz 函数更改为接受输入参数。这将满足用户的需求:

func main() {
  fizzBuzz(10)
}
func fizzBuzz(end int) {
  for i := 1; i <= end; i++ {
    if i%15 == 0 {
      fmt.Println("FizzBuzz")
    } else if i%3 == 0 {
      fmt.Println("Fizz")
    } else if i%5 == 0 {
      fmt.Println("Buzz")
    } else {
      fmt.Println(i)
    }
  }
}

上述代码片段可以这样解释:

  • main() 函数中,对于 fizzBuzz(10),我们将 10 作为参数传递给我们的 fizzBuzz 函数。

  • 对于 fizzBuzz(end int)topEnd 是我们的参数名,它属于 int 类型。

  • 我们现在的函数将只迭代到我们的结束参数的值;在这个例子中,它将迭代到 10

参数与参数的区别

这是个讨论参数与参数区别的好时机。当你定义你的函数时,以我们的例子 fizzBuzz(end int) 为例,它被称为参数。当你调用一个函数,如 fizzBuzz(10),10 被称为参数。此外,参数和参数的名称不需要匹配。

Go 中的函数也可以定义多个参数。我们需要向我们的 fizzBuzz 函数添加另一个参数以适应这个增强:

func main() {
  s:= 10
  e:= 20
fizzBuzz(s,e)
}
func fizzBuzz(start int, end int) {
  for i := start; i <= end; i++ {
  // code omitted for brevity
  }
}

上述代码片段可以这样解释:

  • 关于 fizzBuzz(s,e),我们现在向 fizzBuzz 函数传递了两个参数。当有多个参数时,它们必须通过逗号分隔。

  • 关于 func fizzBuzz(start int, end int),当在函数中定义多个参数时,它们通过逗号分隔,遵循名称类型、名称类型、名称类型等顺序。

我们的 fizzBuzz 参数比必要的更冗长。当我们有多个相同类型的输入参数时,可以通过逗号后跟类型来分隔输入名称。这被称为简写参数表示法。请看以下使用简写参数表示法的示例:

func main() {
  s,e := 10,20
  fizzBuzz(s,e)
}
func fizzBuzz(start,end int) {
  // code…
}

上述代码片段可以这样解释:

  • 使用简写参数表示法时,调用者没有变化。

  • 关于 fizzBuzzstart, 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 的键值对。keyint)将是我们的 headerstring)列的索引。索引将映射到列标题。

  4. 我们遍历header以处理切片中的每个字符串:

    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.5:m 变量未定义的错误输出]

图 5.5:m 变量未定义的错误输出

![图 5.5: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.6:s 变量未定义时的错误输出

图 5.6:s 变量未定义时的错误输出

之前的代码片段将在func main()中导致错误。错误将指出s未定义。这是因为s变量是在greeting()函数中声明的。main()函数无法访问s变量。s变量仅在greeting()函数体内部的代码中可见。

这些只是我们在声明和访问变量时需要考虑的一些因素。了解函数内部变量与函数外部声明的变量的作用域关系非常重要。当您尝试访问变量但未处于您尝试访问的上下文的作用域时,可能会造成一些混淆。本章中的示例应有助于您理解变量的作用域。

返回值

到目前为止,我们创建的函数没有任何返回值。函数通常接受输入,对这些输入执行一些操作,然后返回这些输入的结果。大多数编程语言只返回一个值。Go 允许您从函数中返回多个值。这是 Go 函数的一个特性,使其与其他编程语言区分开来。

练习 5.03:创建具有返回值的 fizzBuzz 函数

我们将对我们的fizzBuzz函数进行一些增强。我们将将其修改为只接受一个整数。如果调用者希望这样做,我们将把执行循环的责任留给调用者。此外,我们将有两个返回值。第一个将是提供的数字和相应的文本,空字符串、fizzbuzzfizzbuzz。以下步骤将帮助您找到解决方案。

  1. 打开您选择的 IDE。

  2. 创建一个新文件,并将其保存到$GOPATH\functions\fizzBuzzreturn\main.go

  3. main()函数中,将变量分配给我们的函数的返回值。n, s变量分别对应从我们的函数返回的值,int, string:

    func main() {
      for i := 1; i <= 15; i++ {
        n, s := fizzBuzz(i)
        fmt.Printf("Results:  %d %s\n", n, s)
      }
    }
    
  4. fizzBuzz函数现在返回两个值;第一个是int,后面跟着一个字符串。

    func fizzBuzz(i int) (int, string) {
      switch {
    
  5. 通过将 if{}else{} 语句替换为 switch 语句来简化 if{}else{} 语句。在编写代码时,您应该寻找简化事物和使代码更易读的方法。case i%15 ==0 等同于我们之前的 if i%15 == 0 语句。用我们之前的 fmtPrintln() 语句替换它们,用 return 语句替换。return 语句将立即停止函数的执行并将结果返回给调用者:

      case i%15 == 0:
        return i, "FizzBuzz"
      case i%3 == 0:
        return i, "Fizz"
      case i%5 == 0:
        return i, "Buzz"
      }
      return i, ""
    }
    

    预期输出如下:

图 5.7:fizzBuzz 函数的返回值输出

图 5.7:fizzBuzz 函数的返回值输出

在这个练习中,我们看到了如何从函数中返回多个值。我们能够将变量分配给函数的多个返回值。我们还注意到分配给函数的变量与返回值的顺序相匹配。在下一节中,我们将学习在函数体中,我们可以执行裸返回,其中我们不需要在我们的返回语句中指定要返回的变量。

活动五.01:计算员工的工时

在这个活动中,我们将创建一个函数来计算员工一周的工作时间,这将用于计算应支付的工资金额。developer 结构体有一个名为 Individual 的字段,其类型为 Employeedeveloper 结构体跟踪他们收取的小时费率和每天工作的小时数。以下步骤将帮助您找到解决方案:

  1. 创建一个 Employee 类型,包含以下字段:IdintFirstNamestring,和 LastNamestring

  2. 创建一个 developer 类型,包含以下字段:Individual EmployeeHourlyRate int,和 WorkWeek [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
    

    注意

    本活动的解决方案可以在第 704 页找到。

这个活动的目的是展示将问题分解为可管理的任务以供函数实现的能力,以便我们的每个函数都有一个单一的责任。LogHours负责分配每天的工作时间。HoursWorked使用在LogHours中分配的值来显示每天的工作时间。我们使用了函数的返回类型来显示数据。这个练习展示了正确使用函数来解决问题。

裸返回

注意

返回值的函数必须将return语句作为函数中的最后一个语句。如果你省略了return语句,Go 编译器会给你以下错误:“函数末尾缺少返回。”

通常,当一个函数返回两种类型时,第二种类型是error类型。我们还没有讨论错误,所以在这些例子中,我们没有演示它们。了解在 Go 中,第二个返回类型通常是error类型是很好的。

Go 还允许忽略返回的变量。例如,假设我们对从我们的fizzBuzz函数返回的int值不感兴趣。在 Go 中,我们可以使用所谓的空标识符;它提供了一种在赋值中忽略值的方法:

_, err := file.Read(bytes)

例如,当读取文件时,我们可能不关心读取的字节数。在这种情况下,我们可以使用空标识符_来忽略返回的值。当函数返回了不需要我们程序中的额外数据时,比如读取文件,它是一个很好的忽略返回值的候选。

注意

你会发现,许多函数将错误作为第二个返回值返回。你不应该忽略函数返回的错误。忽略函数返回的错误可能会导致意外的行为。错误返回值应该得到适当的处理。

func main() {
  for i := 1; i <= 15; i++ {
    _, s := fizzBuzz(i)
    fmt.Printf("Results: %s\n",s)
  }
}

在前面的例子中,我们使用空标识符_来忽略返回的int值:

    _, s := fizzBuzz(i)

当从函数中赋值时,你必须为返回的值提供一个占位符。在进行赋值时,占位符必须与函数的返回值数量相匹配。_sintstring返回值的占位符。

Go 还有一个功能允许你为返回值命名。如果你使用这个功能,它可以使得你的代码更易于阅读以及自文档化。如果你为返回变量命名,它们将受到与之前主题中讨论的局部变量相同的约束。通过命名返回值,你正在在函数中创建局部变量。然后你可以将这些返回变量的值赋值,就像你处理输入参数一样:

func greeting() (name string, age int){
  name = "John"
  age = 21
  return name, age
}

在前面的代码中,(name stringage 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.8:使用裸返回值的阴影输出图 5.8:使用裸返回值的阴影输出

图 5.8:使用裸返回值的阴影输出

这是因为 err 变量在 return 中命名并在 if 语句中初始化。回想一下,在花括号内初始化的变量,例如 for 循环、if 语句和 switch 语句,其作用域仅限于该上下文,意味着它们只能在那些花括号内可见和访问。

练习 5.04:使用返回值将 CSV 索引映射到列标题

练习 5.02将索引值映射到列标题 中,我们只打印了索引到列标题的结果。在这个练习中,我们将返回映射作为结果。返回的映射是索引到列标题的映射。以下步骤将帮助您找到解决方案:

  1. 打开您选择的 IDE。

  2. 打开上一个练习中的文件:$GOPATH\functions\indxToColHdr\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. 我们遍历 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)

前面的函数是一个可变参数函数的例子。在类型前面的三个点()被称为 TypeparameterName。可变变量可以接受零个或多个变量作为参数:

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.9:可变参数语法错误输出图片 B14177_05_09.jpg

图 5.9:可变参数语法错误输出

正确函数:

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.10:将可变参数 int 转换为整数切片图片 B14177_05_10.jpg

图 5.10:将可变参数 int 转换为整数切片

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.11:可变参数函数错误图片 B14177_05_11.jpg

图 5.11:可变参数函数错误

为什么这个代码片段不起作用?我们刚刚证明了函数内的可变变量是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. 创建一个新文件并将其保存到$GOPATH\functions\variadic\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): 被传递到执行括号中的参数message

我们目前是在声明匿名函数时立即执行它们,但还有其他执行匿名函数的方法。您还可以将匿名函数保存到变量中。这导致了一系列不同的机会,我们将在本章中探讨:

func main() {
  f := func() {
    fmt.Println("Executing an anonymous function using a variable")
  }
  fmt.Println("Line after anonymous function")
  f()
}
  • 我们将f变量赋值给我们的匿名函数。

  • f现在为func()类型。

  • f现在可以用来调用匿名函数,方式与命名函数类似。您必须在f变量之后包含()来执行函数。

练习 5.06:创建一个用于计算数字平方根的匿名函数

匿名函数非常适合您想在函数内部执行的小段代码。在这里,我们将创建一个匿名函数,该函数将接受一个参数。然后它将计算平方根。以下步骤将帮助您找到解决方案:

  1. 使用您选择的 IDE。

  2. 创建一个新文件,并将其保存到$GOPATH\functions\anonymousfnc\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 的函数。需要注意的是,incrementor() 在这里只执行一次。在我们的 main() 函数中,它不再被引用或执行。

  5. Increment()func() int 类型。每次调用 increment() 都会运行匿名函数代码。即使在 incrementor() 执行之后,它仍然引用 i 变量。

练习 5.07:创建一个用于递减计数器的闭包函数

在这个练习中,我们将创建一个从给定起始值递减的闭包。我们将结合向匿名函数传递参数和利用闭包的知识。以下步骤将帮助您找到解决方案:

  1. 打开您选择的 IDE。

  2. $GOPATH\closureFnc\variadic\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 一样是类型。这意味着我们可以将函数作为参数传递给其他函数,函数可以从函数中返回,函数可以被赋值给变量。我们甚至可以定义自己的函数类型。函数的类型签名定义了其输入参数和返回值的类型。为了使一个函数成为另一个函数的类型,它必须具有声明的类型函数的确切签名。让我们考察一些函数类型:

type message func()

上述代码片段创建了一个名为 message 的新函数类型。它没有输入参数,也没有任何返回类型。

让我们再看看另一个例子:

type calc func(int, int) string

上述代码片段创建了一个名为 calc 的新函数类型。它接受两个整型参数,其返回值类型为字符串。

现在我们已经对函数类型有了基本理解,我们可以编写一些代码来演示它们的用法:

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) string 与类型 calc 具有相同的签名。它接受两个整数作为参数,并返回一个字符串,说明 "Adding 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.12 总结了前面每个函数以及它们之间的关系。该图显示了 func addfunc calc 类型,这允许它作为参数传递给 func calculator

![图 5.12:函数类型和用法]

![图片 B14177_05_12.jpg]

图 5.12:函数类型和用法

我们刚刚看到了如何创建一个函数类型并将其作为参数传递给函数。将函数作为参数传递给另一个函数并不是那么遥远。我们将稍微修改之前的示例以反映传递函数作为参数:

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 而不是字符串。

  • 我们添加了一个名为 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 函数作为参数传递,并带有一些整数值。

将函数作为类型传递是一个非常强大的功能,只要它们的签名与传递给函数的输入参数匹配,就可以将不同的函数传递给其他函数。当你这么想的时候,这相当简单。一个函数的整数类型可以是任何整数值。同样,对于传递函数来说:只要它是正确的类型,函数可以是任何值。

函数也可以从另一个函数返回。我们在使用匿名函数和闭包时看到了这一点。在这里,我们将简要地看一下,因为我们已经在之前的章节中看到了这种语法:

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() int: square 函数接受一个 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. 创建一个新文件,并将其保存到 $GOPATH\function\funcAsParam\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 函数接受一个接受两个整数作为参数并返回整数的函数。因此,任何匹配该签名的函数都可以作为 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 的签名相同,并且与 salary 函数的 f 相匹配。这意味着 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()函数没有新的或特殊的语法。它只是简单地打印到stdout

接下来,我们有两个打印语句。结果很有趣。main()函数中的两个print语句首先打印。尽管延迟函数在main()中是第一个,但它最后打印。这不是很有趣吗?它在main()函数中的顺序并没有决定它的执行顺序。

延迟执行的函数通常用于执行“清理”活动。这包括释放资源、关闭文件、关闭数据库连接以及删除程序创建的配置\临时文件。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.
  • 前三个匿名函数正在执行延迟。

  • 我们声明f1f2func()类型。这两个函数是匿名函数。

  • 如您所见,我们的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: 在延迟函数之后,我们将年龄加倍。然后我们打印加倍后的当前age值。

  • personAge(name string, i int): 这是一个被延迟调用的函数;它只打印出人和年龄。

  • 结果显示了在main函数中将年龄加倍后的age值(25)。

  • 当程序执行到达包含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。打印分配给 nonLoggedHours 的变量,其值为 235

  5. 此外,在 main() 函数中,记录以下几天的时数:周一 8 小时,周二 10 小时,周三 10 小时,周四 10 小时,周五 6 小时,以及周六 8 小时。

  6. 然后运行 PayDetails() 方法。

    以下是我们预期的输出:

![图 5.13:可支付金额活动的输出]

img/B14177_05_13.jpg

图 5.13:可支付金额活动的输出

注意

这个活动的解决方案可以在第 706 页找到。

这个活动的目的是在比 活动 5.01计算员工的工作时间 更进一步,通过使用一些更高级的 Go 函数编程来实现。在这个活动中,我们将继续使用函数,就像我们之前做的那样;然而,我们将返回多个值,并从函数中返回一个函数。我们还演示了使用闭包来计算员工未记录的小时数。

摘要

我们已经研究了为什么函数是 Go 编程语言的一个基本组成部分,以及函数在 Go 中的各种特性,这些特性使 Go 与其他编程语言区别开来。Go 具有允许我们解决许多现实世界问题的特性。Go 中的函数服务于许多目的,包括增强代码的使用和可读性。我们学习了如何创建和调用函数。我们还研究了 Go 中使用的各种函数类型,并讨论了每种函数类型可以使用的场景。我们还阐述了闭包的概念。闭包本质上是一种匿名函数,它可以使用与匿名函数声明级别相同的变量。我们还讨论了各种参数和返回类型,并研究了 defer

在下一章中,我们将探讨错误和错误类型,并学习如何构建自定义错误,从而构建一个恢复机制来处理 Go 中的错误。

第六章:6. 错误

概述

在本章中,我们将通过查看 Go 标准包中的各种代码片段来了解 Go 进行错误处理的惯用方式。我们还将了解如何在 Go 中创建自定义错误类型,并查看标准库中的示例。

到本章结束时,你将能够区分不同类型的错误,并比较错误处理和异常处理。你还将能够创建错误值,并使用panic()来处理错误,并在恐慌后恢复。

简介

在上一章中,我们学习了创建函数。我们还发现函数可以作为参数传递,并从函数中返回。在本章中,我们将处理错误,并学习如何从函数中返回这些错误。

开发者并不完美,因此他们生产的代码也不完美。所有软件在某个时间点都会出现错误。在开发软件时处理错误至关重要。这些错误可能会对用户产生不同程度的负面影响。你的软件对用户的影响可能比你想象的要深远得多。

例如,让我们考虑 2003 年的东北停电事件。2003 年 8 月 14 日,美国和加拿大约有 5000 万人遭遇了为期 14 天的停电。这是由于一个控制室警报系统中的竞态条件错误。从技术上讲,竞态条件错误是指两个独立的线程试图对同一内存位置进行写操作。这种竞态条件可能导致程序崩溃。在这个例子中,它导致了超过 250 个发电厂关闭。处理竞态条件的一种方法是通过确保各种线程之间的适当同步,并允许一次只有一个线程访问内存位置进行写操作。作为开发者,确保正确处理错误非常重要。如果我们没有正确处理错误,这可能会对我们的应用程序用户及其生活方式产生负面影响,正如我们所描述的停电事件所示。

在本章中,我们将探讨错误是什么,Go 中的错误看起来像什么,以及更具体地,如何以 Go 的方式处理错误。让我们开始吧!

什么是错误?

错误是导致你的程序产生非预期结果的东西。这些非预期结果可能包括应用程序崩溃、数据计算错误(例如银行交易未正确处理)或没有任何结果。这些非预期结果被称为软件缺陷。由于程序员没有预料到的众多场景,任何软件在其生命周期中都会包含错误。当发生错误时,以下是一些可能的后果:

  • 错误的代码可能导致程序在没有警告的情况下崩溃。

  • 程序的输出不是预期的结果。

  • 显示错误信息。

你可能会遇到三种类型的错误:

  • 语法错误

  • 运行时错误

  • 语义错误

语法错误

语法错误是由于对编程语言的不当使用而产生的。这通常是由于代码输入错误造成的。大多数现代 IDE 都会有一些视觉方式将语法错误通知程序员;例如,参考 图 6.1。在大多数现代 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

运行时错误

当代码被要求执行它无法完成的任务时,这些错误会发生。与语法错误不同,这些错误通常只在代码执行期间被发现。

以下是一些常见的运行时错误示例:

  • 打开一个不存在的数据库连接

  • 执行一个比你要迭代的切片或数组中的元素数量更大的循环

  • 打开一个不存在的文件

  • 执行数学运算,例如将一个数除以零

练习 6.01:添加数字时的运行时错误

在这个练习中,我们将编写一个简单的程序,该程序将计算数字切片的总和。这个程序将演示一个运行时错误的例子,并且在执行时会崩溃。

  1. $GOPATH 中,创建一个名为 Exercise6.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
    

    go build 命令将编译你的程序,并创建一个以你在 步骤 1 中创建的目录命名的可执行文件。

  9. 输入在 步骤 8 中创建的文件名,然后按 Enter 键运行可执行文件:

![图 6.1:执行后的输出img/B14177_06_01.jpg

图 6.1:执行后的输出

如您所见,程序崩溃了。index out of range 的恐慌是新手和经验丰富的 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)
}

在这个练习中,我们看到了如何通过关注细节来避免运行时错误。

语义错误

语法错误是最容易调试的,其次是运行时错误,而逻辑错误是最难调试的。语义错误有时一开始很难发现。例如,1998 年,当火星气候轨道器发射时,其目的是研究火星气候,但由于系统中的逻辑错误,价值 2.35 亿美元的火星气候轨道器被摧毁。经过一些分析,发现地面控制器系统中的单位计算使用了英制单位,而轨道器上的软件使用了公制单位。这是一个导致导航系统在太空中错误计算其机动动作的逻辑错误。正如你所看到的,这些都是代码处理程序元素的方式中的缺陷。语义错误发生的原因可能包括:

  • 计算错误

  • 访问不正确的资源(文件、数据库、服务器、变量等)

  • 变量取反设置不正确(不等号与等号)

练习 6.02:步行距离逻辑错误

我们正在编写一个应用程序,该程序将确定我们是应该步行去目的地还是开车。如果我们的目的地距离大于或等于 2 公里,我们将开车去。如果小于 2 公里,我们将步行到目的地。我们将通过这个程序演示一个语义错误。

本练习的预期输出如下:

Take the car
  1. 在你的 $GOPATH 中创建一个名为 Exercise6.02 的目录。

  2. 步骤 1 中创建的目录内保存一个名为 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. 在命令行中,导航到 步骤 1 中创建的目录。

  6. 在命令行中,输入以下内容:

    go build
    

    go build 命令将编译你的程序并创建一个以你在 步骤 1 中创建的目录命名的可执行文件。

  7. 输入 步骤 6 中创建的文件名并按 Enter 键运行可执行文件。你将得到以下输出:

    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 处理错误的方式与其他语言,如 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 中的错误是一个值。以下是 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 标准库中的函数(packt.live/2YvL1BV)。对于返回错误值的函数,它应该是最后一个返回值。

在 Go 中,评估返回错误值的函数或方法是一种习惯。通常,不处理从函数返回的错误是一种坏习惯。返回并忽略错误可能导致大量的调试工作浪费。它也可能导致程序中出现未预见的后果。如果值不是 nil,那么我们有一个错误,必须决定我们想要如何处理它。根据场景,我们可能想要:

  • 将错误返回给调用者

  • 记录错误并继续执行

  • 停止程序的执行

  • 忽略它(这强烈不推荐)

  • Panic(仅在非常罕见的情况下,我们将在稍后进一步讨论)

如果错误的值为 nil,这意味着没有错误。不需要进一步的操作。

让我们进一步探讨标准包中关于错误类型的内容。我们将从查看packt.live/2rk6r8Z文件中的每一行代码开始。

type errorString struct {
s string
}

struct errorString位于errors包中。该结构用于存储错误的字符串版本。errorString有一个名为s的单个字段,其类型为stringerrorString及其字段s是不可导出的。这意味着我们无法直接访问errorString类型或其字段s。以下代码给出了尝试访问不可导出的errorString类型及其字段s的示例:

package main
import (
  "errors"
  "fmt"
)
func main() {
  es := errors.errorString{}
  es.s = "slacker"
  fmt.Println(es)
}

![Figure 6.2: 未导出字段的预期输出]

![img/B14177_06_02.jpg]

Figure 6.2: 未导出字段的预期输出

表面上看,errorString 似乎既不可访问也不实用,但我们应该继续挖掘。我们仍然在标准库中:

func (e *errorString) Error() string {
    return e.s
}

errorString 类型有一个实现错误接口的方法。它满足了一个名为 Error() 的方法,并返回一个字符串。错误接口已被满足。我们现在可以通过 Error() 方法访问 errorString 字段,s。这是在标准库中返回错误的方式。

您现在应该对 Go 中的错误有一个基本的理解。现在,我们应该看看如何在 Go 中创建我们自己的错误类型。

创建错误值

在标准库中,error 包有一个我们可以用来创建自定义错误的方法:

// 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.errorSting 类型。

我们可以通过运行以下代码来证明这一点:

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. 在您的 $GOPATH 内创建一个名为 Exercise6.03 的目录。

  2. 步骤 1 创建的目录中保存一个名为 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() 声明了我们的错误变量:

      pay, err := payDay(81, 50)
      if err != nil {
        fmt.Println(err)
      }
    
  6. main() 函数中,检查函数后的每个 err。如果 err 不是 nil,这意味着存在一个错误,我们将打印出该错误。

    创建一个接受两个参数(hoursWorkedhourlyRate)的 payDay 函数。该函数将返回一个 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. 在命令行中,导航到在步骤 1中创建的目录。

  12. 在命令行中,输入以下内容:

    go build
    

    go build 命令会编译你的程序并创建一个以你在步骤 1中创建的目录命名的可执行文件。

  13. 输入在步骤 12中创建的文件名,然后按 Enter 运行可执行文件。

    预期的输出如下:

    Invalid hours worked per week
    Invalid hourly rate
    4080
    

在这个练习中,我们展示了如何创建自定义错误消息,以便可以轻松确定数据被认为无效的原因。我们还展示了如何从函数中返回多个值以及如何检查函数的错误。在下一个主题中,我们将探讨如何在我们的应用程序中使用 panic。

Panic

几种语言使用异常来处理错误。然而,Go 不使用异常,它使用一种称为 panic 的机制。Panic 是一个内置函数,会导致程序崩溃。它停止 Goroutine 的正常执行。

在 Go 中,panic 不是常态,与在其他语言中异常是常态不同。panic 信号表示代码内部正在发生异常情况。通常,当 panic 由运行时或开发者启动时,是为了保护程序的完整性。

错误和恐慌在目的和 Go 运行时如何处理它们方面有所不同。Go 中的错误表示发生了意外情况,但它不会对程序的完整性产生不利影响。Go 期望开发者正确处理错误。如果你没有处理错误,函数或其它程序通常不会崩溃。然而,恐慌在这方面有所不同。当发生恐慌时,除非有处理程序,否则它最终会崩溃系统。如果没有处理程序,它将一直向上传递到栈顶并崩溃程序。

我们将在本章后面讨论的一个例子是,由于索引超出范围而发生的恐慌。这在尝试访问不存在的集合的索引时很典型。如果 Go 在这种情况下不发生恐慌,可能会对程序的完整性产生不利影响,例如,程序的其它部分尝试存储或检索集合中不存在的数据。

注意

回顾 Goroutines 的相关内容。main()函数是一个 Goroutine。当发生恐慌时,你将在错误信息中看到“正在运行的 Goroutine”的引用。

恐慌可以被开发者引发,也可以在程序执行过程中由运行时错误引起。panic()函数接受一个空接口。目前,只需知道这意味着它可以接受任何类型的参数。然而,在大多数情况下,你应该将错误类型传递给panic()函数。对于我们的函数用户来说,了解导致恐慌的详细信息会更加直观。在 Go 中,将错误传递给 panic 函数也是一种惯例。我们还将看到如何从传递了错误类型的 panic 中恢复,这为我们处理 panic 提供了不同的选项。当发生恐慌时,它通常遵循以下步骤:

  • 执行被停止

  • 在恐慌函数中的任何延迟函数都将被调用

  • 在恐慌函数的调用栈中的任何延迟函数都将被调用

  • 它会一直向上传递到main()函数

  • 在恐慌函数之后的语句将不会执行

  • 程序随后崩溃

下面是恐慌的工作原理:

![图 6.3:恐慌的工作原理图片

图 6.3:恐慌的工作原理

以下图展示了main函数中调用函数a()的代码。函数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:恐慌示例

潜在运行时错误是在开发过程中常见的一种错误。这是一个 索引越界 错误。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"))
  }
}

代码摘要:

  • 函数因为传递给 message 函数的参数是 "good-bye" 而发生恐慌。

  • panic() 函数将打印错误信息。拥有良好的错误信息有助于调试过程。

  • 在恐慌中,我们使用了 errors.New(),这是我们之前在创建错误类型时使用的。

  • 正如你所见,fmt.Println()main() 函数中不会执行。由于没有 defer 语句,执行会立即停止。

    此代码片段的预期输出是:

图 6.5:恐慌示例输出

图 6.5:恐慌示例输出

在以下代码片段中,我们将看到如何使用 panicdefer 语句一起工作。

main.go
10 func test() {
11   n := func() {
12     fmt.Println("Defer in test")
13   }
14   defer n()
15   msg := "good-bye"
16   message(msg)
17 }
18 func message(msg string) {
19   f := func() {
20     fmt.Println("Defer in message func")
21   }
22   defer f()
23   if msg == "good-bye" {
24     panic(errors.New("something went wrong"))
The full code is available at: https://packt.live/2qyujFg

潜规则示例的输出如下:

图 6.6:恐慌示例输出

图 6.6:恐慌示例输出

让我们分部分理解代码:

  • 我们将从 message() 函数中的代码开始检查,因为恐慌就是从这里开始的。当发生恐慌时,它会运行恐慌函数 message() 内的 defer 语句。

  • 延迟函数 func f()message() 函数中运行。

  • 在调用栈向上移动,下一个函数是 test() 函数,它的延迟函数 n() 将执行。

  • 最后,我们到达 main() 函数,执行被恐慌函数停止。main() 中的打印语句不会执行。

    注意

    你可能见过使用 os.Exit() 来停止程序的执行。os.Exit() 会立即停止执行并返回一个状态码。当执行 os.Exit() 时,不会运行任何延迟语句。在某些情况下,panicos.Exit() 更受欢迎。panic 会运行延迟函数。

练习 6.04:使用 panic 在错误时崩溃程序

我们将修改 练习 6.03创建一个计算周薪的应用程序。考虑以下场景,其中要求已经改变。我们不再需要从我们的 payDay() 函数返回错误值。我们已经决定不能信任程序的用户正确地响应错误。有人抱怨工资单不正确。我们认为这是由于我们的函数调用者忽略了返回的错误。

payDay() 函数现在将只返回工资金额,而不返回错误。当提供给函数的参数无效时,而不是返回错误,函数将恐慌。这将导致程序立即停止,因此不会处理工资单。

使用你选择的 IDE。一个选项可以是 Visual Studio Code。

  1. 创建一个新文件,并将其保存在$GOPATH\err\panicEx\main.go

  2. main.go中输入以下代码:

    package main
    import (
      "fmt"
      "errors"
    )
    var (
      ErrHourlyRate  = errors.New("invalid hourly rate")
      ErrHoursWorked = errors.New("invalid hours worked per week")
    )
    
  3. main函数内部,调用payDay()函数,将其赋值给一个变量pay,然后打印它:

    func main() {
      pay := payDay(81, 50)
      fmt.Println(pay)
    }
    
  4. payDay()函数的返回类型更改为仅返回int

    func payDay(hoursWorked, hourlyRate int) int {
    
  5. payDay()函数内部,将一个变量report赋值给匿名函数。这个匿名函数提供了传递给payDay()函数的参数的详细信息。尽管我们没有返回错误,但这将提供一些关于为什么函数会引发恐慌的洞察。由于它是一个 deferred 函数,它将在函数退出之前始终执行:

    func payDay(hoursWorked, hourlyRate int) int {
      report := func() {
        fmt.Printf("HoursWorked: %d\nHourldyRate: %d\n", hoursWorked, hourlyRate)
      }
      defer report()
    }
    

    对于有效的hourlyRatehoursWorked的业务规则与之前的练习相同。我们将使用panic函数而不是返回错误。当数据无效时,我们将引发恐慌并传递ErrHourlyRateErrHoursWorked作为参数。

    传递给panic()函数的参数有助于我们的函数用户理解恐慌的原因。

  6. payDay()函数发生恐慌时,defer函数report()将向调用者提供一些关于恐慌发生原因的洞察。恐慌将沿着堆栈向上冒泡到main()函数,并且执行将立即停止:

      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
    
  7. 在命令行中,导航到在步骤 1中创建的目录。

  8. 在命令行中,键入以下内容:

    go build
    

    go build命令将编译您的程序,并创建一个以您在步骤 1中创建的目录命名的可执行文件。

  9. 输入在步骤 8中创建的文件名,然后按Enter键运行可执行文件。

    预期的输出应如下所示:

图 6.7:恐慌练习输出

图 6.7:恐慌练习输出

在这个练习中,我们学习了如何执行panic并将错误传递给panic()函数。这有助于用户更好地理解恐慌的原因。在下一个主题中,我们将看到如何使用Recover在恐慌发生后重新控制程序。

恢复

Go 为我们提供了在panic发生后重新控制程序的能力。Recover 是一个用于重新控制恐慌的 Goroutine 的函数。

recover()函数的签名如下:

func recover() interface{}

recover()函数不接受任何参数,并返回一个空的interface{}。目前,一个空的interface{}表示可以返回任何类型。recover()函数将返回发送给panic()函数的值。

recover()函数仅在 deferred 函数内部有用。如您所回忆的,deferred 函数在包含函数终止之前执行。在 deferred 函数内部调用recover()函数将停止恐慌,通过恢复正常执行。如果recover()函数在 deferred 函数外部调用,它将不会停止恐慌。

以下图表显示了程序在使用panic()recover()defer()函数时采取的步骤:

![图 6.8:恢复函数流程]

图片

![图 6.8:恢复函数流程]

图表中遵循的步骤可以这样解释:

  • main()函数调用func a()

  • func a()调用func b()

  • func b()内部有一个 panic。

  • panic()函数由使用recover()函数的延迟函数处理。

  • 延迟函数是func b()内部最后一个执行的函数。

  • 延迟函数调用了recover()函数。

  • recover()的调用导致正常流程返回到调用者func a()

  • 正常流程继续,最终控制权回到main()函数。

以下代码片段模拟了前面的图表行为:

main.go
6  func main() {
7    a()
8    fmt.Println("This line will now get printed from main() function")
9  }
10 func a() {
11   b("good-bye")
12   fmt.Println("Back in function a()")
13 }
14 func b(msg string) {
15   defer func() {
16     if r:= recover(); r!= nil{
17       fmt.Println("error in func b()",r)
18     }
19   }()
The full code is available at: https://packt.live/2E6j6ig

代码摘要

  • main()函数调用函数a()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:恢复示例输出]

图片

![图 6.9:恢复示例输出]

练习 6.05:从 panic 中恢复

在这个练习中,我们将增强我们的payDay()函数以从 panic 中恢复。当我们的payDay()函数 panic 时,我们将检查该 panic 的错误。然后,根据错误,我们将向用户打印一条信息性消息。

使用您选择的 IDE,一个选项是 Visual Studio Code。

  1. 创建一个新文件,并将其保存在$GOPATH\err\panicEx\main.go

  2. main.go中输入以下代码:

    package main
    import (
      "errors"
      "fmt"
    )
    var (
      ErrHourlyRate  = errors.New("invalid hourly rate")
      ErrHoursWorked = errors.New("invalid hours worked per week")
    )
    
  3. 使用各种参数调用payDay()函数,然后打印函数的返回值:

    func main() {
      pay := payDay(100, 25)
      fmt.Println(pay)
      pay = payDay(100, 200)
      fmt.Println(pay)
      pay = payDay(60, 25)
      fmt.Println(pay)
    }
    
  4. 然后,在您的payDay()函数中添加一个defer函数:

    func payDay(hoursWorked, hourlyRate int) int {
      defer func() {
    
  5. 我们可以检查recover()函数的返回值,如下所示:

    if r := recover(); r != nil {
          if r == ErrHourlyRate {
    

    如果r不是nil,这意味着发生了 panic,我们应该执行某些操作。

  6. 我们可以评估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)
          }
        }
    
  7. 如果我们的if语句评估为true,我们将打印有关数据和recover()函数中的错误值的一些详细信息。然后,我们打印出我们的工资是如何计算的:

        fmt.Printf("Pay was calculated based on:\nhours worked: %d\nhourly Rate: %d\n", hoursWorked, hourlyRate)
      }()
    
  8. 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
    }
    
  9. 在命令行中,导航到在步骤 1中创建的目录。

  10. 在命令行中,输入以下内容:

    go build
    

    go build命令将编译你的程序,并创建一个以你在步骤 1中创建的目录命名的可执行文件。

  11. 输入在步骤 10中创建的文件名,然后按Enter键运行可执行文件。

    预期的输出如下:

![图 6.10:从恐慌练习输出中恢复

![图片 B14177_06_10.jpg]

图 6.10:从恐慌练习输出中恢复

在前面的练习中,我们看到了创建自定义错误并返回该错误的进展。由此,我们能够在需要时使用panic来崩溃程序。在前面的练习中,我们展示了从恐慌中恢复并基于传递给panic()函数的错误类型显示错误消息的能力。在接下来的主题中,我们将讨论在 Go 中执行错误处理时的一些基本指南。

处理错误和恐慌时的指南

指南仅用于指导。它们不是固定不变的。这意味着,大多数时候你应该遵循指南;然而,可能会有例外。其中一些指南已经提到过,但我们在这里进行了整合,以便快速参考:

  • 当我们声明自己的错误类型时,变量需要以Err开头。它还应遵循驼峰命名约定。

    var ErrExampleNotAllowd= errors.New("error example text")
    
  • error字符串应以小写字母开头,不以标点符号结尾。遵循此指南的原因之一是错误可以被返回并与其他与错误相关的信息连接。

  • 如果一个函数或方法返回错误,应该对其进行评估。未评估的错误可能导致程序无法按预期运行。

  • 当使用panic()时,将错误类型作为参数传递,而不是空值。

  • 不要评估错误的字符串值。

  • 适度使用panic()函数。

活动六.01:为银行应用程序创建自定义错误消息

一家银行希望在检查姓氏和有效路由号时添加一些自定义错误。他们发现直接存款程序允许使用无效的名称和路由号。银行希望在发生这些事件时有一个描述性的错误消息。我们的任务是创建两个描述性的自定义错误消息。请记住,为错误变量使用惯用的命名约定,并为错误消息使用适当的结构。

你需要做以下事情:

  1. InvalidLastNameInvalidRoutingNumber创建两个错误值。

  2. 然后,在main()函数中打印自定义消息,以向银行显示当遇到这些错误时他们将收到的错误消息。

    预期的输出如下:

invalid last name
invalid routing number

在完成这个活动之后,你将熟悉创建自定义错误消息所需的步骤。

注意

本活动的解决方案可以在第 709 页找到。

活动六.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:验证银行客户的直接存款提交

在本活动的结束时,您将学会如何从函数中返回错误,以及如何检查从函数返回的错误。您还将能够检查条件,并根据该条件返回您自己的自定义错误。

注意

本活动的解决方案可以在第 710 页找到。

活动 6.03:无效数据提交引发的恐慌

银行现在决定,当提交无效路由号时,他们宁愿让程序崩溃。银行认为错误的数据验证导致程序停止处理直接存款数据。您需要在无效数据提交实例上引发恐慌。在活动 6.02验证银行客户的直接存款提交的基础上构建:

  1. validateRoutingNumber方法更改为不返回ErrInvalidRoutingNum,而是执行一个恐慌:

    预期的输出如下:

图 6.12:无效路由号引发的恐慌

图 6.12:无效路由号引发的恐慌

在本活动的结束时,您将能够引发panic并看到这对程序流程的影响。

注意

本活动的解决方案可以在第 713 页找到。

活动 6.04:防止因无效路由号导致应用程序崩溃

经过一些初步的 alpha 测试后,银行不再希望应用程序崩溃,相反,在本活动中,我们需要从活动 6.03无效数据提交引发的恐慌中添加的恐慌中恢复,并打印出导致恐慌的错误:

  1. validateRoutingNumber方法中添加一个defer函数。

  2. 添加一个if语句来检查recover()函数返回的错误。如果有错误,则打印错误:

    预期的输出如下:

图 6.13:从无效路由号恢复恐慌

]

图 6.13:从无效路由号恢复恐慌

在本活动的结束时,你将引发一个恐慌,但你将能够防止它使应用程序崩溃。你将了解如何使用 recover() 函数,结合 defer 语句,来防止应用程序崩溃。

注意

本活动的解决方案可以在第 614 页找到。

摘要

在本章中,我们探讨了编程时可能会遇到的不同类型的错误,例如语法错误、运行时错误和语义错误。我们更关注运行时错误。这些错误更难调试。

我们探讨了在处理错误时不同语言哲学之间的差异。我们看到了与各种语言使用的异常处理相比,Go 的错误语法更容易理解。

Go 中的错误是一个值。值可以被传递给函数。只要实现了错误接口类型,任何错误都可以作为一个值。我们学习了如何轻松地创建错误。我们还了解到,我们应该以 Err 开头给错误值命名,后面跟着一个描述性的驼峰式命名。

接下来,我们讨论了恐慌以及恐慌与异常之间的相似性。我们还发现,恐慌与异常非常相似;然而,如果恐慌未被处理,它们将导致程序崩溃。但是,Go 有一种机制可以将程序的控制权返回到正常状态。我们通过使用 recover() 函数来实现这一点。从恐慌中恢复的要求需要在延迟函数中使用 recover() 函数。我们还学习了使用 errorspanicrecover 的一般指南。

在下一章中,我们将探讨接口及其用途,以及它们与其他编程语言实现接口的方式有何不同。我们将看到它们如何被用来解决作为程序员面临的各类问题。

第七章:7. 接口

概述

本章旨在展示在 Go 语言中实现接口的过程。与其他语言相比,这相当简单,因为在 Go 中它是隐式实现的,而其他语言则需要显式地实现接口。

在开始时,你将能够为应用程序定义和声明一个接口,并在你的应用程序中实现接口。本章将向你介绍使用鸭子类型和多态,接受接口并返回结构体。

到本章结束时,你将学会使用类型断言来访问接口的底层具体值,并使用类型选择语句。

简介

在上一章中,我们讨论了 Go 语言中的错误处理。我们探讨了 Go 中的错误是什么。我们发现,在 Go 中,任何实现了错误接口的东西都可以被视为错误。当时,我们没有深入研究接口是什么。在本章中,我们将探讨接口是什么。

例如,你的经理要求你创建一个可以接受 JSON 数据的 API。数据包含有关各种员工的信息,例如他们的地址和他们在项目上工作的小时数。数据需要被解析到 employee 结构体中,这是一个相对简单的任务。然后你创建了一个名为 loadEmployee(s string) 的函数。该函数将接受一个格式为 JSON 的字符串,然后将该字符串解析以加载 employee 结构体。

你的经理对你的工作感到满意;然而,他还有另一个要求。客户需要能够接受一个包含员工数据的 JSON 格式文件。要执行的功能与之前相同。你创建了一个名为 loadEmployeeFromFile(f *os.File) 的另一个函数,该函数从文件中读取数据,解析数据并加载员工结构体。

你的经理还有另一个要求,即员工数据现在也应来自 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 是一个接口,我们将在本章后面更深入地讨论它,但就目前而言,只需说它可以用来解决给定的问题。

在本章中,我们将看到接口如何解决此类问题;通过将正在执行的行为定义为接口类型,我们可以接受任何底层具体类型。如果现在这还不清楚,请不要担心;随着本章的深入,它将开始变得清晰。我们将讨论接口如何使我们能够执行鸭子类型和多态。我们将看到接受接口和返回结构体如何减少耦合并增加函数在我们程序更多区域的使用。我们还将检查空接口,并讨论使用案例以充分利用它,包括类型断言和类型选择语句。

接口

接口是一组描述数据类型行为的函数。接口定义了实现该接口必须满足的行为。行为描述了该类型可以做什么。几乎一切事物都表现出某些行为。例如,猫可以喵喵叫、行走、跳跃和咕噜咕噜。所有这些都是猫的行为。汽车可以启动、停止、转弯和加速。所有这些都是汽车的行为。同样,类型的行为被称为方法。

注意

packt.live/2qOtKrd提供的定义是:“Go 中的接口提供了一种指定对象行为的方式。”

描述接口有几种方式:

  • 方法签名集合是一组只有方法名称、参数、类型和返回类型的方法。这是Speaker{}接口方法签名集合的一个例子:

    type Speaker interface{
    Speak(message string) string
    Greet() string
    }
    
  • 该类型的实现方法蓝图是满足接口所需的。使用Speaker{}接口,蓝图(接口)声明,为了满足Speaker{}接口,该类型必须有一个接受string并返回stringSpeak()方法。它还必须有一个返回stringGreet()方法。

  • 行为是接口类型必须表现出的。例如,Reader{}接口有一个Read方法。它的行为是读取数据,以及 Go 标准库的Reader{}接口:

    type Reader interface{
    Read(b []byte)(n int, err error)
    }
    
  • 接口可以描述为没有实现细节。Reader{}接口只包含方法的签名,但不包含方法的代码。接口的实现者有责任提供代码或实现细节,而不是接口本身。

    类型的行为可以是以下几种:

  • Read()

  • Write()

  • Save()

这些行为统称为方法集。行为由一组方法定义。方法集是一组方法。这些方法集包括方法名称、任何输入参数和任何返回类型。

![图 7.1:接口元素的图形表示]

图 7.1:接口元素的图形表示

图 7.1:接口元素的图形表示

当我们谈论行为时,请注意我们没有讨论实现细节。定义接口时省略了实现细节。重要的是要理解,接口声明中未指定或强制实施任何实现。我们创建的每个实现接口的类型都可以有自己的实现细节。一个名为 Greeting() 的方法可以通过不同的方式由各种类型实现。人的结构体类型可以以不同于动物的结构体类型的方式实现 Greeting()。接口关注的是类型必须展示的行为。接口的职责不是提供方法实现。这是实现接口的类型的工作。类型,通常是结构体,包含方法集的实现细节。现在我们已对接口有了基本了解,在下一个主题中,我们将探讨如何定义接口。

定义接口

定义接口包括以下步骤:

![图 7.2:定义接口img/B14177_07_02.jpg

图 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 接口实现。代码段明确指出 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类型的实现者有责任提供Speak()方法的实现细节。

注意,没有显式声明cat实现了Speaker{}接口;它只是通过满足接口的要求来实现。

还很重要的一点是,cat类型有一个名为Greeting()的方法。类型可以拥有不需要满足Speaker{}接口的方法。然而,猫必须至少有满足接口所需的方法集。

输出将如下所示:

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. 创建一个名为Speak()的方法的Speaker{}接口,该方法返回一个字符串:

    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()方法。person类型通过拥有一个返回字符串的Speak()方法来满足Speaker{}接口。为了满足接口,你必须有与接口相同的方法和方法签名:

    func (p person) Speak() string {
      return "Hi my name is: " + p.name
    }
    
  8. 打开终端并导航到代码目录。

  9. 运行go build

  10. 修正返回的错误,并确保你的代码与这里的代码片段匹配。

  11. 通过在命令行中输入可执行文件名来运行可执行文件。

    你应该得到以下输出:

    Hi my name is Cailyn
    Cailyn (44 years old).
    Married status: false
    

在这个练习中,我们看到了隐式实现接口是多么简单。在下一个主题中,我们将通过让不同的数据类型,如结构体,实现相同的接口,并将这些接口传递给任何具有该接口类型的参数的函数来进一步探讨。我们将在下一个主题中更详细地探讨这是如何可能的,并了解为什么类型以各种形式出现是一个好处。

鸭子类型

我们一直在做的是所谓的鸭子类型。鸭子类型是计算机编程中的一个测试:“如果它看起来像鸭子,游泳像鸭子,嘎嘎叫像鸭子,那么它一定是一只鸭子。”如果一个类型匹配一个接口,那么你可以在使用该接口的任何地方使用该类型。鸭子类型是基于方法匹配类型,而不是预期的类型:

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"
}

cat匹配Speaker{}接口的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 通过嵌入结构和通过接口使用多态提供了类似的行为。

使用多态的一个优点是它允许重用已经编写并测试过的方法。通过接受一个接口的 API 来重用代码;如果我们的类型满足该接口,则可以将其传递给该 API。不需要为每个类型编写额外的代码;我们只需要确保我们满足接口方法的要求集。通过使用接口获得的多态性将提高代码的可重用性。如果你的 API 只接受intfloatbool等具体类型,则只能传递该具体类型。然而,如果你的 API 接受一个接口,那么调用者可以添加所需的方法集以满足该接口,无论底层类型如何。这种可重用性是通过允许你的 API 接受接口来实现的。任何满足接口的类型都可以传递给 API。我们已经在之前的例子中看到了这种行为。现在是时候更仔细地看看Speaker{}接口了。

如前所述的例子中看到的,每个具体类型都可以实现一个或多个接口。回想一下,我们的Speaker{}接口可以被dogcatfish类型实现:

图 7.5:由多个类型实现的 Speaker 接口

Figure 7.5: The Speaker interface implemented by multiple types

图 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类型是空结构体,而person结构体有一个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
}
type cat struct {
}
type dog struct {
}
type person struct {
  name string
}
func main() {
  c := cat{}
  d := dog{}
  p := person{name: "Heather"}
  saySomething(c,d,p)
}
func saySomething(say ...Speaker) {
  for _, s := range say {
    fmt.Println(s.Speak())
  }
}
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 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 接口的多种类型

图 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 类型的正方形、三角形、矩形面积

  9. 现在我们将创建一个函数,该函数接受 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())
      }
    }
    
  10. 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)
    }
    }
    
  11. 通过在命令行运行 go build 来构建程序:

    go build
    
  12. 修正返回的错误,并确保你的代码与这里的代码片段匹配。

  13. 通过输入可执行文件名并按 Enter 键来运行可执行文件。

    你应该看到以下输出:

    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:接受接口的好处图片 B14177_07_08.jpg

图 7.8:接受接口的好处

以下示例将说明接受接口与使用具体类型相比的好处。我们将有两个函数执行相同的解码 JSON 任务,但它们的输入不同。其中一个函数比另一个函数更优越,我们将讨论为什么是这样的情况。

看以下示例:

main.go
1  package main
2  import (
3    "encoding/json"
4    "fmt"
5    "io"
6    "strings"
7  )
8  type Person struct {
9    Name string `json:"name"`
10   Age  int    `json:"age"`
11 }
The full code is available at: https://packt.live/38teYHn

预期的输出如下:

{Joe 18}
{Jane 21}

让我们检查这段代码的每一部分。我们将在接下来的章节中讨论这段代码的一些部分。这段代码将一些数据解码到一个结构体中。为此使用了两个函数,loadPerson2()loadPerson()

func loadPerson2(s string) (Person, error) {
  var p Person
  err := json.NewDecoder(strings.NewReader(s)).Decode(&p)
  if err != nil {
  return p, err
  }
  return p, nil
}

loadPerson2() 函数接受一个具体的 string 类型的参数,并返回一个 struct。返回 struct 符合 "接受接口,返回结构体" 的半部分。然而,它的接受范围非常有限,并不开放。这限制了函数的使用范围,使其只能应用于狭窄的实现。唯一可以传递的是字符串。当然,在某些情况下这可能是可以接受的,但在其他情况下可能会出现问题。例如,如果你的函数或方法应该只接受特定的数据类型,那么你可能不想接受接口:

func loadPerson(r io.Reader) (Person, error) {
  var p Person
  err := json.NewDecoder(r).Decode(&p)
  if err != nil {
    return p, err
  }
  return p, err
}

在这个函数中,我们接受 io.Reader{} 接口。io.Reader{} (packt.live/2LRG3Kv) 和 io.Writer{} (packt.live/2YIAJhP) 接口是 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 标准包中是否有接口。这将增加你的函数可以提供不同实现的数量。我们的函数用户可以使用 strings.newReaderhttp.Request.Bodyos.File 以及许多其他实现,就像我们的代码示例一样,通过使用 Go 标准包中的 io.Reader{} 接口。

空接口 interface{}

空接口是一个没有方法集和行为的接口。空接口没有指定任何方法:

interface{}

这是一个简单但复杂的概念,需要你理解。正如你可能记得的,接口是隐式实现的;没有 implements 关键字。由于空接口没有指定任何方法,这意味着 Go 中的每个类型都自动实现了空接口。所有类型都满足空接口。

在以下代码片段中,我们将演示如何使用空接口。我们还将看到接受空接口的函数如何允许传递任何类型到该函数:

main.go
1  package main
2  import (
3    "fmt"
4  )
5  type Speaker interface {
6    Speak() string
7  }
8  type cat struct {
9    name string
10 }
The full code is available at: https://packt.live/34dVEdB

预期的输出如下:

({oreo}, main.cat)
({oreo}, main.cat)
(99, int)
(false, bool)
(test, string)

让我们分部分评估代码:

func emptyDetails(s interface{}) {
  fmt.Printf("(%v, %T)\n", i, i)
}

该函数接受一个空的interface{}。由于所有类型都实现了空的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:类型 cat 实现了空接口和 Speaker 接口

图 7.9:类型 cat 实现了空接口和 Speaker 接口

cat类型隐式实现了空接口和Speaker{}接口。

现在我们对空接口有了基本的了解,我们将在接下来的主题中查看它们的各种用例,包括以下内容:

  • 类型切换

  • 类型断言

  • Go 包的示例

类型断言和切换

类型断言提供了访问接口的具体类型。请记住,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 中的值可能是已知的。我们将在本章后面的活动中展示如何执行此类操作。我们可以使用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
13 func typeExample(i []interface{}) {
14   for _, x := range i {
15     switch v := x.(type) {
16     case int:
17       fmt.Printf("%v is int\n", v)
18     case string:
19       fmt.Printf("%v is a string\n",v)
20     case bool:
21       fmt.Printf("a bool %v\n", v)
22     default:
23       fmt.Printf("Unknown type %T\n", v)
24     }
25   }
26 }
The full code is available at: https://packt.live/38xWEwH

让我们现在分块探索代码:

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循环遍历接口切片。切片中的第一个值是 42。switch情况断言切片值 42 是int类型。case int语句将评估为true,并打印42 is int。当for循环遍历cat类型的最后一个值时,switch语句将不会在其case评估中找到该类型。由于在case语句中没有检查cat类型,所以默认将执行其打印语句。以下是代码执行的结果:

42 is int
The book club is string
a bool true
Unknown type main.cat

练习 7.03:分析空的 interface{} 数据

在这个练习中,我们得到了一个映射。映射的键是一个字符串,其值是一个空的 interface{}。映射的值包含存储在映射值部分的不同类型的数据。我们的任务是确定每个键的值类型。我们将编写一个程序来分析 map[string]interface{} 的数据。理解数据的值可以是任何类型。我们需要编写逻辑来捕获我们不想查找的类型。我们将把信息存储在一个包含键名、数据和数据类型的结构体切片中:

  1. 创建一个名为 main.go 的新文件。

  2. 在文件中,我们将有一个 main 包,并需要导入 fmt 包:

    package main
    import (
      "fmt"
    )
    
  3. 我们将创建一个名为 record 的结构体,用于存储来自 map[string]interface{} 的键、值类型和数据。此结构体用于存储我们对映射进行的分析。key 字段是映射键的名称。valueType 字段存储映射中作为值存储的数据类型。数据字段存储我们正在分析的数据。它是一个空的 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
        return r
      case bool:
        r.valueType = "bool"
        r.data = v
        return r
      case string:
        r.valueType = "string"
        r.data = v
        return r
      case person:
        r.valueType = "person"
        r.data = v
        return r
    
  9. 对于 switch 语句需要一个 default 语句。如果 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结构体类型,并且数据具有结构体的字段值。

活动七点零一:计算工资和绩效评价

在这个活动中,我们将计算经理和开发者的年薪。我们将打印出开发者和经理的名字以及全年的工资。开发者的工资将基于时薪。开发者类型还将跟踪他们在一年中工作的小时数。开发者类型还将包括他们的评价。评价将需要是一个字符串键的集合。这些字符串是开发者被评价的类别,例如工作质量、团队合作、沟通等等。

这个活动的目的是通过调用一个接受接口的单个函数payDetails()来演示 Go 的多态性。这个payDetails()函数将打印出开发者类型和经理类型的工资信息。

以下步骤将有助于您找到解决方案:

  1. 创建一个具有IdFirstNameLastName字段的Employee类型。

  2. 创建一个具有以下字段的Developer类型:Employee类型的IndividualHourlyRateHoursWorkedInYearmap[string]interface{}类型的Review

  3. 创建一个具有以下字段的Manager类型:Employee类型的IndividualSalaryCommissionRate

  4. 创建一个具有Pay()方法并返回stringfloat64Payer接口。

  5. Developer类型应通过返回Developer名称和基于Developer.HourlyRate * Developer.HoursWorkInYear计算的年工资来实现Payer{}接口。

  6. Manager类型应通过返回Manager名称和基于Manager.Salary加上(Manager.Salary * Manager.CommissionRate)计算的Manager年工资来实现Payer{}接口。

  7. 添加一个名为payDetails的函数,它接受一个Payer接口并打印fullName和从Pay()方法返回的工资。

  8. 我们现在需要计算一个开发者的评价等级。Review是通过map[string]interface{}获得的。该映射的键是一个字符串;这是开发者被评价的内容,例如工作质量、团队合作、技能等等。

  9. 映射中的空interface{}是必需的,因为一些经理将评价作为字符串给出,而另一些则作为数字给出。以下是stringinteger的映射:

    "优秀" – 5

    "良好" – 4

    "一般" – 3

    "差" – 2

    "不满意" – 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
    

    注意

    这个活动的解决方案可以在第 715 页找到

在这个活动中,我们看到了使用允许我们接受任何类型数据的空接口的好处。然后我们使用了类型断言和类型切换语句来根据空接口的底层具体类型执行某些任务。

摘要

本章介绍了使用接口时的基本和高级主题。我们了解到 Go 的接口实现与其他语言有一些相似之处;例如,接口不包含它所表示的行为的实现细节,接口是方法的蓝图。实现接口的不同类型可以在实现细节上有所不同。然而,Go 在实现接口的方式上与其他语言不同。我们了解到实现是隐式进行的,而不是像其他语言那样显式进行。

这表明 Go 不进行子类化,因此,为了实现多态性,它使用接口。它允许接口类型以不同的形式出现,例如,Shape接口可以表现为矩形、正方形或圆形。

我们还讨论了一个接受接口和返回结构体的设计模式。我们演示了这种模式允许其他调用者有更广泛的使用。我们检查了空接口,并看到了在不知道传递的类型或当有多个不同类型传递给 API 时如何使用它。尽管我们在运行时不知道类型,但我们展示了如何使用类型断言和类型切换来确定类型。掌握和实践这些各种工具将有助于你构建健壮和流畅的程序。

在下一章中,我们将探讨 Go 如何使用包,以及我们如何使用它们来进一步帮助构建有组织和专注的代码段。

第八章:8. 包

概述

本章旨在展示在 Go 程序中使用包的重要性。我们将讨论如何使用包来帮助我们的代码更易于维护、重用和模块化。在本章中,您将看到它们如何为我们的代码带来结构和组织。这将在我们的练习、活动和 Go 标准库的一些示例中体现出来。

到本章结束时,您将能够描述一个包及其结构,并声明一个包。您将学习如何在一个包中评估导出和非导出名称,创建自己的包并导入自定义包。您还将能够区分可执行包和非可执行包,并创建一个包的别名。

简介

在上一章中,我们探讨了接口。我们看到了如何使用接口来描述类型的行怍。我们还发现,只要类型满足接口的方法集,我们就可以将不同类型传递给接受接口的函数。我们还看到了如何使用接口实现多态。

在本章中,我们将探讨 Go 如何将代码组织成包。我们将看到如何使用包来隐藏或暴露不同的 Go 结构,如结构体、接口、函数等。我们的程序在代码行数和复杂度上相对较小,大多数程序都包含在一个名为main.go的单个代码文件中,并在名为main的单个包内。在本章的后面部分,我们将探讨package main的重要性,所以如果您目前不理解它,请不要担心。在您作为开发团队成员工作时,情况并不总是如此。通常,您的代码库可以变得相当庞大,包含多个文件、多个库和多个团队成员。如果我们不能将代码分解成更小的、可管理的部分,这将相当受限。Go 编程语言通过将类似的概念模块化到包中来解决管理大型代码库的复杂性。Go 的创造者使用包来解决他们自己的标准库中的这个问题。在本书中,您已经使用了许多 Go 包,例如fmtstringosioutil等。

让我们看看 Go 标准库中的一个包结构的例子。Go 的strings包封装了操作字符串的函数。通过使strings包只关注操作字符串的函数,我们作为 Go 开发者知道,这个函数应该包含我们进行字符串操作所需的所有内容。

Go 的字符串包结构如下(packt.live/35jueEu):

图 8.1:strings 包及其包含的文件

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_08_01.jpg)

图 8.1:strings 包及其包含的文件](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_08_01.jpg)

上述图表显示了strings包及其中的文件。strings包中的每个文件都是以它所支持的功能命名的。代码的逻辑组织从包到文件。我们可以很容易地得出结论,strings包包含用于操作字符串的代码。然后我们可以进一步得出结论,replace.go文件包含用于替换字符串的函数。您已经可以看到,包的概念结构可以将您的代码组织成模块化的块。您从一起工作以实现某个目的的代码开始,即字符串操作,并将其存储在名为string的包中。然后您可以将代码进一步组织到.go文件中,并根据其目的命名它们。下一步是将执行单一目的的函数放入其中,该目的反映了文件名和包名。我们将在本章讨论结构化代码时进一步讨论这些概念思想。

开发可维护、可重用和模块化的软件非常重要。让我们简要讨论软件开发的核心组件中的每个这些。

可维护性

为了使代码可维护,它必须易于更改,并且任何更改都必须具有低风险,不会对程序产生不利影响。可维护的代码易于修改和扩展,并且易于阅读。随着代码在软件开发生命周期的不同阶段中进展,对代码的更改成本会增加。这些更改可能是由错误、增强或需求变更引起的。当代码不易维护时,成本也会增加。代码需要可维护的另一个原因是需要在行业中保持竞争力。如果你的代码不易维护,可能很难对竞争对手做出反应,竞争对手正在发布一个软件功能,该功能可能被用来超出你的应用程序销售。这只是代码需要可维护的一些原因。

可重用性

可重用代码是指可以在新软件中使用的代码。例如,我在现有的应用程序中有一段代码,该代码有一个函数可以返回我的邮件应用程序的地址;这个函数可能被用于新的软件中。这个返回地址的函数可以用于我新的软件中,该软件可以返回客户已下单的订单的地址。

拥有可重用代码的优势如下:

  • 通过使用现有包来降低未来项目的成本。

  • 由于无需重新发明轮子,它减少了交付应用程序所需的时间。

  • 通过增加测试和更多使用,程序的质量将得到提高。

  • 在开发周期中可以花更多的时间在其他创新领域。

  • 随着您的包增长,及时为未来项目打下基础变得更加容易。

模块化

模块化和可重用代码在一定程度上是相关的,从某种意义上说,拥有模块化代码使得它更有可能被重用。在开发代码时,代码的组织是一个突出的问题。在一个未组织的大型程序中找到执行特定功能的代码几乎是不可能的,甚至在不知道是否有执行特定任务的代码的情况下,确定这一点也是困难的,除非有一些代码组织。模块化有助于这个领域。想法是,你的代码执行的每个离散任务都有其自己的代码部分,位于特定的位置。

Go 语言鼓励你通过使用包来开发可维护、可重用和模块化的代码。它旨在鼓励良好的软件开发实践。我们将深入了解 Go 如何利用包来完成这些任务:

图 8.2:代码包可以提供的数据类型

图 8.2:代码包可以提供的数据类型

在下一个主题中,我们将讨论什么是包以及构成包的组件。

什么是包?

Go 语言遵循不要重复自己DRY)原则。这意味着你不应该重复编写相同的代码。将你的代码重构为函数是 DRY 原则的第一步。假设你拥有数百甚至数千个你经常使用的函数,你将如何跟踪所有这些函数呢?其中一些函数可能具有共同的特征。你可能有一组执行数学运算、字符串操作、打印或基于文件的操作的函数。你可能正在考虑将它们拆分成单独的文件:

图 8.3:按文件分组函数

图 8.3:按文件分组函数

这可能有助于缓解一些问题。然而,如果你的字符串功能开始进一步增长呢?那么你将在一个文件或多个文件中拥有大量的字符串函数。你构建的每个程序也将不得不包含stringmathio的所有代码。你将不得不将代码复制到你所构建的每个应用程序中。一个代码库中的错误将需要在多个程序中修复。这种代码结构既不可维护,也不鼓励代码重用。Go 语言中的包是组织你的代码的下一步,以便于重用代码的组件。以下图表显示了从函数到源文件再到包的代码组织进展:

图 8.4:代码进度组织

图 8.4:代码进度组织

Go 将代码组织到称为包的目录中以实现可重用性。包本质上是你工作区内的一个目录,包含一个或多个 Go 源文件,用于对执行任务的代码进行分组。它仅暴露必要的部分,以便使用你的包的人能够完成任务。包的概念类似于在计算机上使用目录来组织文件。

包结构

对于 Go 来说,一个包中有多少不同的文件并不重要。你应该根据可读性和逻辑分组将代码分成尽可能多的文件。然而,包中的所有文件必须位于同一目录下。源文件应包含相关的代码,这意味着如果包是用于配置解析,那么其中不应该包含连接到数据库的代码。包的基本结构由一个目录组成,包含一个或多个 Go 文件和相关代码。以下图表总结了包结构的核心组件:

图 8.5:包结构

图 8.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等包名。这些包名使得用户难以理解你的包的目的。在某些情况下,可能会有一些偏离这些指南的情况,但大部分情况下,这是我们应努力追求的:

图 8.6:包命名约定

图 8.6:包命名约定

包声明

每个 Go 文件都以包声明开始。包声明是包的名称。可执行代码的第一行必须是包声明:

package <packageName>

回想一下,标准库中的strings包包含以下 Go 源文件:

这些文件中的每一个都是以包声明开始的,尽管它们都是独立的文件。我们将从 Go 标准库中查看一个示例。在 Go 标准库中,有一个名为strings的包(packt.live/35jueEu)。它由多个文件组成。我们只将查看包中的代码片段:builder.gocompare.goreplace.go。我们已删除注释和一些代码,仅为了展示包文件是以包名开始的。代码片段将不会有输出。这是一个 Go 如何将代码组织到多个文件但仍在同一包中的示例:

main.go
// https://golang.org/src/strings/builder.go
1  package strings
2  import (
3    "unicode/utf8"
4    "unsafe"
5  )
6  type Builder struct {
7    addr *Builder // of receiver, to detect copies by value
8    buf  []byte
9  }
10 // https://golang.org/src/strings/compare.go
11 package strings
12 func Compare(a, b string) int {
13   if a == b {
14     return 0
15   }
The full code is available at: https://packt.live/35sihwF

在 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"。如果foundstr变量中,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包文件中的未导出函数:

![图 8.7:程序输出图片

图 8.7:程序输出

以下代码片段来自 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.
func explode(s string, n int) []string {
5    l := utf8.RuneCountInString(s)
6    if n < 0 || n > l {
7      n = l
8    }
9    a := make([]string, n)
10   for i := 0; i < n-1; i++ {
11     ch, size := utf8.DecodeRuneInString(s)
12     a[i] = s[:size]
13     s = s[size:]
14     if ch == utf8.RuneError {
15       a[i] = string(utf8.RuneError)
The full code is available at https://packt.live/2teXDBN.

GOROOT 和 GOPATH

我们已经探讨了包是什么以及它的用途。我们有一个基本理解,多个文件可以是一个包结构的一部分。我们已经讨论了 Go 语言中命名包的惯用方法。我们已经看到所有这些基本概念在 Go 标准库中的应用。在我们开始创建自己的包之前,还有一个概念需要了解。理解 Go 编译器如何查找我们应用程序中使用的包的位置是很重要的。

Go 编译器需要一种方法来知道如何找到我们的源文件(包),以便编译器可以构建和安装它们。编译器利用两个环境变量来完成这项工作。$GOROOT$GOPATH告诉 Go 编译器在哪里搜索由import语句列出的 Go 包的位置。

$GOROOT用于告诉 Go 编译器 Go 标准库包的位置。$GOROOT是针对 Go 标准库的。这是 Go 用来确定其标准库包和工具位置的方法。

$GOPATH是我们创建的包以及我们可能导入的第三方包的位置。在命令行中,输入以下代码:

ECHO $GOPATH

$GOPATH 文件结构内部,有三个目录:binpkgsrcbin 目录是最容易理解的。这是 Go 在运行 go install 命令时放置二进制文件或可执行文件的地方。pkg 目录的主要用途是编译器用来存储 Go 编译器构建的包的对象文件,这是为了帮助加快程序的编译速度。src 目录是我们最感兴趣的,因为我们把我们的包放在这个目录中。这是放置具有 .go 扩展名的文件的目录。

例如,如果我们有一个位于 $GOPATH/src/person/address/ 的包,并且我们想使用地址包,我们需要以下 import 语句:

import "person/address"

另一个例子是,如果我们有一个位于 $GOPATH/src/company/employee 的包。如果我们对使用 employee 包感兴趣,则 import 语句如下:

import "company/employee"

位于源代码仓库中的包将遵循类似的模式。如果我们想从 packt.live/2EKp357 导入源代码,则在文件系统中的位置将是 $GOPATH/src/github.com/PacktWorkshops/The-Go-Workshop/Chapter08/Exercise8.01

导入方式如下:

import "github.com/PacktWorkshops/Get-Ready-To-Go/Chapter08/Exercise8.01"

下图显示了 $GOROOT$GOPATH 之间的差异:

图 8.8:GOROOT 和 GOPATH 比较

图 8.8:GOROOT 和 GOPATH 比较

我们将创建一个名为 msg 的简单包。此文件的位于 $GOPATH $GOPATH/msg/msg.go:

package msg
import "fmt"
//Greeting greets the input parameter
func Greeting(str string) {
    fmt.Printf("Greeting %s\n", str)
}

包的名称是 msg

它有一个导出的函数。该函数接收一个字符串并将 "Greeting" 打印到传递给函数的参数上。

要使用 Go 包和我们的自定义包,我们必须导入它们。import 声明包含路径位置和包的名称。包的名称是包含包文件的最后一个目录。例如,如果我们有位于 $GOPATH 位置的目录结构,packt/chpkg/test/mpeg,则包名将是 mpeg

下面的代码片段是 main 包文件。它位于 $GOPATH 内的以下目录结构中:

$GOPATH/demoimport/demoimport.go:

package main
import (
  "fmt"
  "msg"
)
func main() {
  fmt.Println("Demo Import App")
  msg.Greeting("George")
}

输出将如下所示:

Greeting George

这个基本程序导入了 msg 包。由于我们已经导入了 msg 包,因此我们可以通过使用 "msg.<functionName>" 优先级来调用包中的任何可导出函数。我们知道我们的 msg 包有一个名为 Greeting 的可导出函数。我们从 msg 包中调用可导出的 Greeting 函数,并在前面的图中获得输出。

当创建一个包时,它可以在同一目录下包含多个文件。我们需要确保该目录中的每个文件都属于同一个包。如果你有一个名为 shape 的包,在该目录下你有两个文件,但每个文件都有不同的包声明,Go 编译器将返回一个错误:

shape.go

package shape

junk.go

package notright

如果你尝试进行构建,你会得到以下错误:

![图 8.9:程序输出图片

图 8.9:程序输出

包别名

Go 也有能力别名包名。你可能想使用别名名的原因有几个:

  • 包名可能不容易让人理解其目的。为了清晰起见,可能最好为包别名为不同的名称。

  • 包名可能太长。在这种情况下,你希望别名更加简洁,不那么冗长。

  • 可能存在包路径唯一但包名相同的情况。这时,你需要使用别名来区分这两个包。

包别名的语法非常简单。你将别名名放在 import 包路径之前:

import  f "fmt"

这里是一个简单示例,展示了如何使用包别名:

package main
import (
  f "fmt"
  //"fmt"
)
func main() {
  f.Println("Hello, Gophers")
}
import (
  f "fmt"

我们正在将 fmt 包别名为 f

  f.Println("Hello, Gophers")

main() 函数中,我们现在能够使用 f 别名调用 Println() 函数。

主包

主包是一个特殊的包。Go 中有两种基本的包类型:可执行包和非可执行包。主包是 Go 中的可执行包。主包需要在其包中有一个 main() 函数。main() 函数是 Go 可执行程序的入口点。当你对主包执行 go build 时,它将编译包并创建一个二进制文件。二进制文件将创建在主包所在的目录中。二进制文件的名字将是它所在的文件夹名:

![图 8.10:主包功能图片

图 8.10:主包功能

这里是一个主包代码的简单示例:

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

预期输出如下:

Hello Gophers !

练习 8.01:创建一个包来计算各种形状的面积

第七章接口 中,我们实现了计算不同形状面积的代码。在这个练习中,我们将所有关于形状的代码移动到一个名为 shape 的包中。然后,我们将更新 shape 包中的代码以使其可导出。然后,我们将更新 main 以导入我们新的 shape 包。然而,我们希望它在主包的 main() 函数中仍然执行相同的功能。

这里是我们将要转换为包的代码:

packt.live/36zt6gv.

你应该在 $GOPATH 内有一个目录结构,并在相应的目录中有文件,如下面的截图所示:

![图 8.11:程序目录结构图片

图 8.11:程序目录结构

shape.go文件应包含整个代码:

packt.live/2PFsWNx

我们将只介绍与将此代码作为包进行构建相关的更改,有关我们已在上一章中介绍过的代码部分的详细信息,请参阅第七章接口

  1. Chapter08目录下创建一个名为Exercise8.01的目录。

  2. Exercise8.01目录下再创建两个名为areashape的目录。

  3. Exercise8.01/area目录下创建一个名为main.go的文件。

  4. Exercise8.01/shape目录下创建一个名为shape.go的文件。

  5. 打开Exercise8.01/shape.go文件。

  6. 添加以下代码:

    package shape
    import "fmt"
    

    此文件的第一行代码告诉我们这是一个名为shape的非可执行包。非可执行包在编译时不会产生二进制或可执行代码。回想一下,main包是一个可执行的包。

  7. 接下来,我们需要使类型可导出。对于每个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
    }
    
  8. 我们还必须通过将方法名改为小写来使方法不可导出。目前没有必要使那些方法在包外部可见:

    Exercise8.01
    18 func PrintShapeDetails(shapes ...Shape) {
    19   for _, item := range shapes {
    20     fmt.Printf("The area of %s is: %.2f\n", item.name(), item.area())
    21   }
    22 }
    23 func (t Triangle) area() float64 {
    24   return (t.Base * t.Height) / 2
    25 }
    26 func (t Triangle) name() string {
    27   return "Triangle"
    28 }
    29 func (r Rectangle) area() float64 {
    30   return r.Length * r.Width
    31 }
    32 func (r Rectangle) name() string {
    The full code for this step is available at: https://packt.live/2rngdHf.
    
  9. PrintShapeDetails函数也需要大写首字母:

    func PrintShapeDetails(shapes ...Shape) {
      for _, item := range shapes {
        fmt.Printf("The area of %s is: %.2f\n", item.name(), item.area())
      }
    }
    
  10. 执行构建以确保没有编译错误:

    go build
    
  11. 这里是main.go文件的列表。通过将包作为main,我们知道这是一个可执行的:

    package main
    
  12. import声明只有一个导入。它是shape包。路径位置是$GOPATH加上import路径声明。我们可以看到包名为shape,因为它是路径声明中的最后一个目录名。这里提到的$GOPATH可能与你的不同:

    import (
      import "github.com/PacktWorkshops/The-Go-Workshop/Chapter08/Exercise8.01/shape"
    )
    
  13. 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}
    
  14. 然后我们调用shape()函数,PrintShapeDetails,以获取每个形状的面积:

      shape.PrintShapeDetails(t, r, s)
    }
    
  15. 在命令行中,进入\Exercise8.01\area目录结构。

  16. 在命令行中,输入以下内容:

    go build
    
  17. go build命令将编译你的程序并创建一个以dir区域命名的可执行文件。

  18. 输入可执行文件名并按Enter键:

    ./area
    

    预期输出如下:

    The area of Triangle is: 155.78
    The area of Rectangle is: 200.00
    The area of Square is 100.00
    

我们现在有了在接口章节实现中之前拥有的功能。我们现在将shape功能封装在shape包中。我们只公开或使需要维护先前实现的函数或方法可见。main包更加简洁,并导入shape包以提供先前实现中的功能。

init()函数

正如我们所讨论的,每个 Go 程序(可执行文件)都是从main包开始的,入口点是main函数。我们还应该注意另一个特殊函数,称为init()。每个源文件都可以有一个init()函数,但到目前为止,我们将从main包的角度来看init函数。当你开始编写包时,你可能需要为包提供一些初始化(init()函数)。init()函数用于设置状态或值。init()函数为你的包添加初始化逻辑。以下是一些init()函数的用法示例:

  • 设置数据库对象和连接

  • 包变量的初始化

  • 创建文件

  • 加载配置数据

  • 验证或修复程序状态

init()函数需要以下模式来调用:

  • 导入的包首先被初始化。

  • 包级别的变量被初始化。

  • 调用包的init()函数。

  • main被执行。

以下图表显示了典型 Go 程序遵循的执行顺序:

![图 8.12:执行顺序img/B14177_08_12.jpg

图 8.12:执行顺序

这里有一个简单的示例,演示了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()函数:

![图 8.13:代码片段的执行流程img/B14177_08_13.jpg

图 8.13:代码片段的执行流程

init()函数不能有任何参数或返回值:

package main
import (
  "fmt"
)
var name = "Gopher"
func init(age int) {
  fmt.Println("Hello, ",name)
}
func main() {
  fmt.Println("Hello, main function")
}

运行此代码片段将导致以下错误:

![图 8.14:程序输出img/B14177_08_14.jpg

图 8.14:程序输出

练习 8.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
    

此处的目的是演示如何使用 init() 函数在 main 函数执行之前执行数据初始化和加载。通常需要在 main 运行之前加载的数据是静态数据,例如下拉列表值或某种配置。如所示,数据通过 init 函数加载后,可以被 main 函数使用。在下一个主题中,我们将看到多个 init 函数是如何执行的。

注意

输出顺序可能不同;Go maps 不保证数据的顺序。

执行多个 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 在执行 init 函数之前首先初始化 name 变量:

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

练习 8.03:将收款人分配到预算类别

我们将扩展我们的程序,从 练习 8.02加载预算类别,现在将收款人分配到预算类别。这类似于许多预算应用程序,试图将收款人与常用类别匹配。然后我们将打印收款人与类别的映射:

  1. 创建 main.go 文件。

  2. 练习 8.02加载预算类别github.com/PacktWorkshops/The-Go-Workshop/blob/master/Chapter08/Exercise8.02/main.go 中的代码复制到 main.go 文件中。

  3. budgetCategories 后添加一个 payeeToCategory 映射:

    var budgetCategories = make(map[int]string)
    var payeeToCategory = make(map[string]int)
    
  4. 添加另一个 init() 函数。这个 init() 函数将用于填充我们的新 payeeToCategory 映射。我们将收款人分配到类别的键值:

    main.go
    5  func init() {
    6      fmt.Println("Initializing our budgetCategories")
    7      budgetCategories[1] = "Car Insurance"
    8      budgetCategories[2] = "Mortgage"
    9      budgetCategories[3] = "Electricity"
    10     budgetCategories[4] = "Retirement"
    11     budgetCategories[5] = "Vacation"
    12     budgetCategories[7] = "Groceries"
    13     budgetCategories[8] = "Car Payment"
    14 }
    The full code for this step is available at: https://packt.live/2Qdss1E.
    
  5. main() 函数中,我们将打印出收款人到类别。我们遍历 payeeToCategory 映射,打印键(收款人)。我们通过将 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])
        }
    }
    

这是预期的输出:

图 8.15:将收款人分配到预算类别

图 8.15:将收款人分配到预算类别

你现在创建了一个程序,在执行main函数之前,先执行多个init()函数。每个init()函数都将数据加载到我们的全局映射变量中。我们确定了init函数执行的顺序,因为显示的print语句。这表明init()函数按照它们在代码中出现的顺序打印。了解你的init函数的顺序很重要,因为你可能会根据代码执行的顺序得到不可预见的结果。

在即将到来的活动中,我们将使用我们查看的所有关于包的概念,并看看它们是如何一起工作的。

活动八.01:创建计算薪酬和绩效评估的函数

在这个活动中,我们将采取活动 7.01计算薪酬和绩效评估,并使用包进行模块化。我们将重构来自packt.live/2YNnfS6的代码:

  1. DeveloperEmployeeManager的类型和方法移动到它们自己的包中。类型、方法和函数必须正确导出或未导出。

  2. 将包命名为payroll

  3. 将类型及其方法逻辑上分离到不同的包文件中。回想一下,良好的代码组织涉及将类似功能分离到单独的文件中。

  4. 创建main()函数作为payroll包的别名。

  5. 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
    

在这个活动中,我们看到了如何使用包来分离我们的代码,然后将代码逻辑上分离成单独的文件。我们可以看到,每个文件都构成了一个包。包中的每个文件都可以访问其他文件,无论它们是否在单独的文件中。这个活动演示了如何创建包含多个文件的包,以及如何使用这些单独的文件进一步组织我们的代码。

注意

这个活动的解决方案可以在第 720 页找到。

摘要

我们探讨了开发可维护、可重用和模块化软件的重要性。我们发现 Go 的包在满足这些软件开发标准方面发挥着重要作用。我们研究了包的整体结构。它由一个目录组成,可以包含一个或多个文件,并且包含相关的代码。一个包本质上是你工作空间中的一个目录,其中包含一个或多个用于分组执行任务的代码文件。它只向使用你的包的人暴露必要的部分以完成任务。我们讨论了正确命名包的重要性。我们还学习了如何命名一个包,即简洁、小写、描述性、使用非复数名称,并避免使用通用名称。包可以是可执行的或不可执行的。如果一个包是主包,那么它就是一个可执行包。主包必须有一个主函数,这是我们的包的入口点。

我们还讨论了可导出和不可导出代码的概念。当我们将函数、类型或方法的名称大写时,它对使用我们的包的其他人是可见的。将函数、类型或方法小写化使其对包外部的其他用户不可见。在创建包时,我们意识到GOROOTGOPATH是重要的,因为它们决定了 Go 在哪里查找包。我们了解到init函数可以执行以下任务:初始化变量、加载配置数据、设置数据库连接或验证我们的程序状态是否已准备好执行。init()函数在执行时有一定的规则,以及如何利用它。本章将帮助你编写高度可管理、可重用和模块化的代码。

在下一章中,我们将研究基本的调试技术。我们将探讨各种帮助我们定位程序中错误的技术。我们还将讨论减少定位错误难度的方法以及如何在修改代码库后增加定位错误的机会。

第九章:9. 基本调试

概述

在本章中,我们将探讨基本的调试方法。我们将探讨我们可以采取的一些主动措施来减少我们引入程序中的错误数量。一旦我们理解了这些措施,我们将研究我们可以定位错误的方法。

你将能够熟悉 Go 语言中的调试,并实现各种格式化打印的方法。你将评估基本的调试技术,并找到代码中错误的通用位置。到本章结束时,你将知道如何使用 Go 代码打印变量类型和值,以及为了调试目的记录应用程序的状态。

简介

当你开发软件程序时,你的程序有时会以未预期的方式表现。例如,程序可能会抛出错误并可能崩溃。崩溃是指我们的代码在中间停止功能并突然退出。也许,程序给出了意外的结果。例如,我们请求视频流服务观看电影 Rocky 1,但反而得到了 Creed 1! 或者,你将支票存入银行账户,但银行软件并没有给你账户记入,反而从你的账户中扣除。这些软件程序以未预期方式表现的情况被称为错误。有时,“错误”和“错误”可以互换使用。在 第六章什么是错误? 部分,我们讨论了有三种不同类型的错误或错误:语法错误、运行时错误和逻辑错误。我们还检查了示例,并看到了发现每种类型错误位置的困难。

确定意外行为原因的过程称为调试。有各种原因的 bug 被发布到生产环境中:

  • 测试是在开发结束时进行的:在开发生命周期中,可能会诱使我们不进行增量测试。例如,我们正在为应用程序创建多个功能,一旦我们完成所有功能,它们然后被测试。测试我们代码的更好方法可能是,在我们完成每个功能后立即对其进行测试。这被称为增量测试或以更小的块交付代码。这使我们拥有更好的代码稳定性。这是通过在继续到下一个功能之前测试一个功能来确保它正常工作来实现的。我们刚刚完成的功能可能被其他功能使用。如果我们继续之前不对其进行测试,使用我们的功能的其他功能可能会使用一个有缺陷的功能。根据错误和我们对功能所做的更改,这可能会影响我们功能的其他用户。在本章的后面部分,我们将讨论一些编码和测试增量的一些更多好处。

  • 应用程序增强或需求变更:我们的代码在开发阶段和发布到生产阶段之间经常发生变化。一旦进入生产阶段,我们会收到用户的反馈;反馈可能是额外的需求或对代码的增强。在一个区域更改生产级代码可能会对另一个区域产生负面影响。如果开发团队使用单元测试,那么这将有助于减轻代码库变更中引入的一些 bug。通过使用单元测试,我们可以在交付代码之前运行我们的单元测试,以查看我们的变更是否产生了负面影响。我们将在后面讨论单元测试是什么。

  • 不切实际的开发时间表:有时功能需要在非常紧张的时间框架内交付。这可能导致在最佳实践中走捷径,缩短设计阶段,进行较少的测试,以及收到不明确的需求。所有这些都会增加引入 bug 的机会。

  • 错误处理:一些开发者可能选择不处理发生的错误。例如,应用程序加载配置数据所需的文件找不到,未处理无效数学运算(如除以零)的错误返回,或者可能无法建立与服务器的连接。如果你的程序没有正确处理这些和其他类型的错误,这可能会导致 bug。

这些只是 bug 的一些原因。bug 对我们的程序有负面影响。导致计算错误的 bug 的结果可能是致命的。在医疗行业,有一种机器用于注射一种名为肝素的药物;这种药物是血液稀释剂,用于预防血栓。如果确定肝素给药频率和剂量的代码中存在导致其故障的 bug,机器可能会过量或不足地给药。这可能会对病人产生不利影响。正如你所看到的,交付尽可能无 bug 的软件至关重要。在本章中,我们将探讨一些减少引入 bug 数量的方法以及隔离 bug 位置的方法。

无 bug 代码的方法

我们将简要探讨一些方法,这些方法将帮助我们最小化可能被引入代码中的 bug 数量。这些方法还将帮助我们增强对引入 bug 的代码部分的信心:

![图 9.1:调试代码的不同方法图片

图 9.1:调试代码的不同方法

逐步编码并频繁测试

让我们考虑逐步开发的方法。这意味着逐步开发程序,并在添加增量代码后经常对其进行测试。这种模式将帮助你轻松跟踪 bug,因为你正在测试每一小段代码,而不是一个大的程序。

编写单元测试

当编写测试并发生代码更改时,单元测试可以保护代码免受潜在错误的引入。一个典型的单元测试会接受一个给定的输入并验证是否产生了预期的结果。如果代码更改前单元测试通过,但代码更改后失败,那么我们可以得出结论,我们引入了一些意外的行为。在将代码推送到生产系统之前,单元测试必须通过。

处理所有错误

这在 第六章错误 中讨论过。忽略错误可能导致我们的程序出现潜在的不期望的结果。我们需要正确处理错误,以便使调试过程更容易。

执行日志记录

记录是我们可以用来确定程序中发生情况的另一种技术。有各种类型的日志;一些常见的日志类型包括 debug、info、warn、error、fatal 和 trace。我们不会深入到每种类型的细节;相反,我们将专注于执行 debug 类型的日志。这种类型的日志通常用于确定错误发生前的程序状态。收集的一些信息包括变量的值、正在执行的代码部分(例如函数名)、传递的参数的值、函数或方法的输出,等等。在本章中,我们将使用 Go 标准库的内置功能执行我们自己的自定义 debug 日志。Go 的内置日志包可以提供时间戳。这在尝试理解各种事件的时机时很有用。当你进行日志记录时,你需要考虑到性能的影响。根据应用程序及其负载,在高峰时段日志可能会非常详细,可能会对应用程序的性能产生负面影响。在某些情况下,它可能会导致应用程序无响应。

使用 fmt 格式化

fmt 包的一个用途是在控制台或文件系统中显示数据,例如一个包含可能有助于调试代码的信息的文本文件。我们已经多次使用了 Println() 函数。让我们稍微深入地看看 fmt.Println() 的功能。fmt.Println() 函数在变量之间放置空格,然后在字符串的末尾追加一个新行。fmt.Println() 函数打印变量的默认格式。

练习 9.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 包也有多种方式来格式化我们各种打印语句的输出。我们接下来将看看 fmt.Printf()

fmt.Printf() 根据动词格式化字符串,并将其打印到 stdout。标准输出 (stdout) 是一个输出流。默认情况下,标准输出指向终端。该函数使用称为格式动词或有时称为格式说明符的东西。动词告诉 fmt 函数在哪里插入变量。例如,%s 打印一个字符串;它是一个字符串的占位符。这些动词基于 C 语言:

图 9.2:Printf 的解释

图 9.2:Printf 的解释

图 9.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 中,你可以使用 \ 来转义字符。这告诉我们一个字符不应该被打印,因为它有特殊含义。当你使用 \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 语言有几种打印动词。我们将介绍一些常用的一些基本动词。我们将介绍其他动词,当它们与执行基本调试相关时:

![图 9.3:表示动词及其含义的表格图 9.3:表示动词及其含义的表格

图 9.3:表示动词及其含义的表格

让我们看看使用动词来打印各种数据类型的一个例子:

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)
}
    fname := "Joe"
    gpa := 3.75
    hasJob := true
    age := 24
    hourlyWage := 45.53
  • 我们初始化了各种不同类型的变量,这些变量将在我们的 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。这将指定两位小数,第二位进行四舍五入。根据以下示例,注意 nth 数字是如何根据 %.nf 动词中使用的 n(数字)来四舍五入的:

![图 9.4:舍入小数]

![img/B14177_09_04.jpg]

图 9.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 表示总宽度为十,精度为零,使用 v,动词的总宽度为 4:

        fmt.Printf("%10.0f\n", v)
    
  • %10.1f 表示总宽度为十,精度为一位,使用 v1,动词的总宽度为 6:

        fmt.Printf("%10.1f\n", v1)
    
  • %10.2f 表示总宽度为十,精度为两位,使用 v2,动词的总宽度为 7:

        fmt.Printf("%10.2f\n", v2)
    
  • %10.3f 表示总宽度为十,精度为三,使用 v3,动词的总宽度为 8:

        fmt.Printf("%10.3f\n", v3)
    
  • %10.4f 表示总宽度为十,精度为四位,使用 v4,动词的总宽度为 9:

        fmt.Printf("%10.4f\n", v4)
    
  • %10.5f 表示总宽度为十,精度为五,使用 v5,动词的总宽度为 10:

        fmt.Printf("%10.5f\n", v5)
    }
    

    结果如下:

![图 9.5:格式化动词后的输出]

![img/B14177_09_05.jpg]

图 9.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)
    

    在结果之前使用相同的变量将是以下内容:

![图 9.6:格式化动词左对齐后的输出图片

图 9.6:格式化动词左对齐后的输出

我们刚刚只是对 Go 支持动词的使用进行了初步了解。到现在为止,你应该已经对动词的工作原理有了基本的理解。我们将在接下来的主题中继续探讨使用动词以及格式化 print 的各种方法。这个主题为我们将要使用的基本调试技术奠定了基础。

练习 9.02:打印十进制、二进制和十六进制值

在这个练习中,我们将从 1 到 255 打印十进制、二进制和十六进制值。结果应该右对齐。十进制宽度应设置为三位,二进制或基 2 宽度设置为 8,十六进制宽度设置为 2。这个练习的目的是通过使用 Go 标准库包来正确格式化我们的数据输出。

所有创建的目录和文件都应该位于你的 $GOPATH 内:

  1. Chapter09 目录下创建一个名为 Exercise9.02 的目录。

  2. Chapter09/Exercise9.02/ 目录下创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开 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 位宽度和右对齐的方式显示 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 Chapter09/Exercise9.02/
    
  9. 在命令行中,输入以下内容:

    go build
    
  10. 使用 go build 命令创建的可执行文件,然后按 Enter 键。

程序的预期结果如下:

![图 9.7:打印十进制、二进制和十六进制值后的预期输出图片

图 9.7:打印十进制、二进制和十六进制值后的预期输出

我们已经看到了如何使用 Go 标准库 fmt 包中的 Printf() 来格式化我们的数据。我们将利用这些知识来执行一些基本的打印代码标记的调试。我们将在下一节中了解更多关于这个内容。

基本调试

我们一直快乐地编写代码。关键时刻已经到来;现在是时候运行我们的程序了。我们运行程序,发现结果并不像我们预期的那样。事实上,有些地方出了大问题。我们的输入和输出不匹配。那么,我们如何找出问题所在呢?嗯,程序中出现错误是我们作为开发者都会面临的问题。然而,我们可以进行一些基本的调试来帮助我们修复这些问题,或者至少通过以下方式收集有关这些错误的信息:

  • 在代码中打印代码标记

    我们代码中的标记是打印语句,帮助我们识别在出现错误时程序的位置:

    fmt.Println("We are in function calculateGPA")
    
  • 打印变量的类型

    在调试过程中,了解我们正在评估的变量类型可能是有用的:

    fmt.Printf("fname is of type %T\n", fname)
    
  • 打印变量的值

    除了知道变量的类型外,有时了解变量中存储的值也是有价值的:

    fmt.Printf("fname value %#v\n", fname)
    
  • 执行调试日志

    有时,可能需要将调试语句打印到文件中:可能是在生产环境中才会出现的错误。或者,我们可能希望比较不同输入到我们的代码中打印到文件中的数据结果:

    log. Printf("fname value %#v\n", fname)
    

这里有一些基本的调试方法:

图 9.8:基本的调试方法

图片

图 9.8:基本的调试方法

调试的第一步之一是确定 bug 在代码中的大致位置。在你开始分析任何数据之前,我们需要知道这个 bug 发生在哪里。我们通过在代码中打印标记来实现这一点。代码中的标记通常只是打印语句,帮助我们识别在出现 bug 时程序的位置。它们也用于缩小 bug 可能存在的范围。通常,这个过程涉及放置一个带有消息的打印语句,显示我们在代码中的位置。如果我们的代码到达那个点,我们就可以根据某些条件判断该区域是否是 bug 所在的地方。如果我们发现它不是,我们可能需要移除那个打印语句,并将其放置在代码的其他位置。

给定以下简单的示例,这里有一个返回错误的 bug:

Incorrect value
Program exited: status 1.

代码报告了一个错误,但我们不知道错误来自哪里。此代码生成一个随机数,并将该随机数传递给 func afunc b。根据随机数的值,它将决定哪个函数中发生错误。以下代码演示了正确放置 debug 语句以帮助确定潜在 bug 所在代码区域的重要性:

main.go
9  func main() {
10     r := random(1, 20)
11     err := a(r)
12     if err != nil {
13         fmt.Println(err)
14         os.Exit(1)
15     }
16     err = b(r)
17     if err != nil {
18         fmt.Println(err)
19         os.Exit(1)
20     }
21 }
The full code is available at: https://packt.live/35TQpl0
  • 我们正在使用 rand 包生成随机数。

  • rand.Seed() 用于确保每次运行程序时使用 rand.Intn 时,降低返回相同数字的可能性。然而,如果你每次都使用相同的种子,随机数生成器在第一次运行代码时将返回相同的数字。为了最小化生成相同数字的概率,我们需要每次向种子函数提供一个唯一的数字。我们使用 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 语句,让我们知道错误发生的位置。

通过在代码中战略性地放置打印语句,我们可以看到错误在哪个函数中。

输出应该看起来像以下这样:

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

%T 在每个 print 语句中使用,以打印变量的具体类型。在先前的主题中,我们打印了值。我们还可以使用 %#v 打印类型的 Go 语法表示。能够打印出变量的 Go 表示很有用。变量的 Go 表示是可以在 Go 代码中复制粘贴的语法:

图 9.9:使用 %T 和 Go 语法表示的类型语法表示

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_09_09.jpg)

图 9.9:使用 %T 和 Go 语法表示的类型语法表示

练习 9.03 打印变量的 Go 表示

在这个练习中,我们将创建一个简单的程序,演示如何打印出各种变量的 Go 表示。我们将使用各种类型(如字符串、切片、映射和结构体)并打印这些类型的 Go 表示:

  1. Chapter09 目录内创建一个名为 Exercise9.03 的目录。

  2. Chapter09/Exercise9.03/ 目录内创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开 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. 创建一个 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 Chapter09/Exercise9.03/
    
  12. 在命令行中,输入以下内容:

    go build
    
  13. 输入由go build命令创建的可执行文件并按Enter键。

    你将得到以下输出:

图 9.10:Go 类型表示

图 9.10:Go 类型表示

在这个练习中,我们看到了如何将简单类型(如fname字符串)的 Go 表示打印到更复杂的类型,如person结构体。这是我们工具箱中的另一个工具,我们可以用它来调试;它允许我们以 Go 的方式查看数据。在下一个主题中,我们将探讨另一个帮助我们调试代码的工具。我们将探讨如何记录可用于进一步帮助调试的信息。

日志记录

日志记录可以帮助调试我们程序中的错误。操作系统记录各种信息,例如对资源的访问、应用程序正在做什么、系统的整体健康状况等等。这不是因为存在错误,而是为了记录,以便系统管理员更容易确定在各个时间点操作系统的情况。当操作系统执行或执行某些未预期的任务时,它允许更容易地进行调试。当我们记录应用程序时,我们应该采取同样的态度。我们需要考虑我们收集的信息以及这些信息如何帮助我们调试应用程序,如果某些操作没有按预期执行的话。

无论程序是否需要调试,我们都应该进行日志记录。日志记录有助于理解发生的事件、应用程序的健康状况、任何潜在问题以及谁正在访问我们的应用程序或数据。日志记录是程序的基础设施,当应用程序出现异常时可以加以利用。日志记录帮助我们跟踪我们可能错过的异常。在生产环境中,我们的代码可能在不同条件下执行,与开发环境相比,例如服务器请求数量的增加。

如果我们没有能力记录这些信息以及我们的代码性能,我们可能会花费无数小时试图弄清楚为什么我们的代码在生产环境中表现与开发环境不同。另一个例子是我们可能在生产中接收到一些格式不正确的数据作为请求,我们的代码没有正确处理该格式并导致不期望的行为。没有适当的日志记录,可能需要额外的时间来确定我们收到了我们没有适当处理的数据。

Go 标准库提供了一个名为log的包。它包括基本日志记录,可以被我们的程序使用。我们将探讨如何使用这个包来记录各种信息。

考虑以下示例:

package main
import (
    "fmt"
    "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 日志包有一个名为SetFlags的函数,允许我们更加具体。

备注

这里是 Go 包提供的日志选项列表,我们可以在函数中设置这些选项(golang.org/src/log/log.go?s=8483:8506#L267):

图 9.11:Go 中的标志列表

图 9.11:Go 中的标志列表

让我们在图 9.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将给出日志消息来源的完整文件名和行号。

输出如下:

图 9.12:输出

图 9.12:输出

记录致命错误

使用日志包,我们还可以记录致命错误。Fatal()Fatalf()Fatalln()函数与Print()Printf()Println()类似。区别在于日志Fatal()函数之后跟随一个os.Exit(1)系统调用。日志包还有以下函数: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")

这行代码没有执行,因为我们已将错误记录为致命,这会导致程序退出。

这里是结果。请注意,尽管这是一个错误,但它仍然记录了与打印功能相同的错误详细信息,然后退出:

图 9.13:记录致命错误

图 9.13:记录致命错误

活动九.01:构建验证社会保障号码的程序

在这个活动中,我们将验证社会保障号码SSNs)。我们的程序将接受不带连字符的 SSNs。我们希望记录 SSN 的验证过程,以便我们可以追踪整个过程。我们不希望我们的应用程序在 SSN 无效时停止;我们希望它记录无效号码并继续下一个:

  1. 为无效的 SSN 长度创建一个名为ErrInvalidSSNLength的自定义错误。

  2. 为具有非数字数字的 SSN 创建一个名为ErrInvalidSSNNumbers的自定义错误。

  3. 为前缀为三个零的 SSN 创建一个名为ErrInvalidSSNPrefix的自定义错误。

  4. 为以 9 开头的 SSN 且第四位需要 7 或 9 的 SSN 创建一个名为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 切片,以便你的程序可以验证它们中的每一个。

  11. 对于你正在验证的每个 SSN,如果从用于验证的函数返回错误,则记录这些错误并继续处理切片。

  12. 用于验证的示例切片如下:

    validateSSN := []string{"123-45-6789", "012-8-678", "000-12-0962", "999-33-  3333", "087-65-4321","123-45-zzzz"}
    

    前面的切片应具有以下输出:

图 9.14:验证 SSN 输出

图 9.14:验证 SSN 输出

注意

这个活动的解决方案可以在第 725 页找到。

在这个活动中,我们使用了日志包来捕获信息以追踪验证 SSN 的过程。如果我们需要调试我们的 SSN 验证过程,我们可以查看日志消息并追踪 SSN 的验证失败。我们还演示了如何格式化日志消息以包含调试所需的信息。

摘要

在本章中,我们研究了各种简化调试过程的方法,例如逐步编码和频繁测试代码、编写单元测试、处理所有错误以及在代码上执行日志记录。

查看fmt包,我们发现了很多输出信息的方法,帮助我们找到 bug。fmt包提供了不同的打印格式、动词以及通过使用各种标志来控制动词输出的方式。

通过使用 Go 标准库中的日志功能,我们能够看到应用程序执行的详细信息。日志包允许我们看到日志事件发生的文件路径和行号。日志包附带了一些打印函数,它们模仿了fmt包的一些打印函数,这为我们提供了关于本章所学的动词使用的各种见解。我们还能够将日志信息保存到文件中。每次我们从日志包调用打印函数时,它都会将结果放入文件中。

我们能够通过使用 Go 提供的标准库进行基本的调试。我们查看了日志包,并了解了time类型。我们没有深入探讨 Go 对时间实现的细节。

在下一章中,我们将探讨 Go 中时间是如何表示的。我们将讨论与time类型一起使用的各种函数。我们还将演示如何将时间转换为各种时间结构(例如纳秒、微秒、毫秒、秒、分钟、小时等)。然后,我们将最终了解时间的基本类型。

第十章:10. 关于时间

概述

本章演示了 Go 如何处理表示时间数据的变量,这是语言非常重要的一个方面。

到本章结束时,你将能够创建自己的时间格式,比较和管理时间,计算时间序列的持续时间,并根据用户要求格式化时间。

简介

前一章向您介绍了 Go 中的基本调试。在 Go 中编写代码越多,你的技能就越好;然而,开发和部署代码可能会遇到需要调试的边缘情况。前一章展示了如何使用 fmt 包,如何将日志记录到文件中,以及如何使用 f 函数格式。

本章致力于教你所有关于处理表示时间数据的变量的知识。你将学习如何以“Go 方式”完成它。首先,我们将从基本的时间创建、时间戳等开始;然后,我们将学习如何比较和操作时间,计算两个日期之间的持续时间,并创建时间戳。最后,我们将学习如何根据我们的需求格式化时间。所以,我们不要浪费时间,直接进入正题。

创建时间

创建时间意味着声明一个变量,该变量以特定方式格式化时间。格式化时间将在本章末尾介绍;因此,现在我们将使用 Go 提供的默认格式化。在这个主题中,我们将在脚本的 main() 函数中执行所有操作,所以骨架应该如下所示:

package main
import "fmt"
import "time"
func main(){
  //this is where the code goes.
}

让我们先看看我们的骨架,并学习如何创建和操作时间变量。我们的骨架有必要的标准 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:  2019-09-27 08:19:33.8358274 +0200 CEST m=+0.001998701
Saving the world...
The script has completed at:  2019-09-27 08:19:35.8400169 +0200 CEST m=+2.006161301

如你所见,这看起来并不很花哨;然而,到本章结束时,你将学会如何让它更易于阅读。

考虑以下场景;你的雇主给你一个任务,开发一个小型的 Go 应用程序,根据星期几测试一个网络应用程序。你的雇主每周一凌晨 12:00 CEST 发布新网络应用程序的主要版本。从凌晨 12:00 CEST 到下午 2:00 CEST 有一个停机窗口,部署大约需要 30 分钟,你有一小时 30 分钟的时间来测试应用程序。这就是 Go 的时间模块如何帮助你。脚本在周的其他天进行“击中并逃跑”测试,但在发布日,你需要执行一个“全面”的功能测试。脚本的第一版通过参数来决定执行哪种测试,但第二版版本基于日期和小时来做出决定:

![图 10.1:测试策略图 10.1:测试策略

图 10.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 的变量中。当这个脚本执行时,有两种类型的输出。

第一个是简单的“击中并逃跑”输出,如下所示:

Day: Thursday Hour: 14
Performing hit-n-run test!

第二个是“全面”输出,如下所示:

Day: Thursday Hour: 14
Performing full blown test!

在这个例子中,我们看到了执行日期如何修改应用程序的行为。

注意

实际的测试被有意省略,因为这不是本章主题的一部分。然而,输出清楚地显示了哪个部分负责控制测试。

另一个例子是为 Go 中的脚本创建日志文件名。基本思路是每天收集一个日志,并将时间戳连接到日志文件名上。其结构如下:

Application_Action_Year_Month_Day

在 Go 中,有一个优雅且简单的方法来做这件事:

import "strconv"
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_2019_September_27.log

然而,有一个问题。如果你想要连接 time 类型的字符串,这些类型不是隐式可转换的,请使用需要导入到脚本顶部的 strconv 包:

import "strconv"

依次,这允许你调用 strconv.Itoa() 函数,该函数将你的 YearDay 值转换为字符串,最终让你将它们连接成一个单一的字符串。

现在我们已经学会了如何创建时间变量,让我们学习如何比较它们。

练习 10.1:创建一个返回时间戳的函数

在这个练习中,我们将创建一个名为 whatstheclock 的函数。这个函数的目标是展示如何创建一个封装了格式化的 time.Now() 函数的函数,并返回 ANSIC 格式的日期。ANSIC 格式将在 格式化时间 部分中进一步详细解释:

  1. 创建一个名为 Chapter_10_Exercise_1.go 的文件。

  2. 使用包和导入语句初始化脚本:

    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 Chapter_10_Exercise_1.go
    

    你应该看到以下输出:

    Thu Oct 17 13:56:03 2019
    

在这个练习中,我们展示了如何创建一个小的函数,该函数可以返回当前时间的 ANSIC 格式。

注意

你所使用的任何类型的操作系统都会提供两种类型的时钟来测量时间;一种称为“单调时钟”,另一种称为“墙钟”。墙钟是你可以在 Windows 机器的任务栏上看到的时间;它会发生变化,通常根据你的当前位置与公共或企业 NTP 服务器同步。NTP 服务器代表网络时间协议,它用于根据原子时钟或卫星参考向客户端告知时间。

比较时间

在处理较小的 Go 脚本时,了解脚本何时应该运行,或者脚本应该在什么小时和分钟内完成,这对你的统计信息来说非常重要。通过统计,我们指的是知道通过执行特定操作节省了多少时间,与手动执行这些操作所需的时间成本相比。这允许我们在进一步开发功能时,测量脚本随时间改进的情况。在这个主题中,我们将查看一些实际例子,展示你如何解决这个问题。

让我们看看第一个脚本的逻辑,该脚本旨在在指定时间之前或之后不运行。这个时间可以通过另一个自动化程序到达,或者当手动放置触发文件时;每天,脚本需要在不同的时间运行,具体来说,尽可能在指定时间之后运行。

时间格式为以下 2019-09-27T22:08:41+00:00

  now := time.Now()
  only_after, _ := time.Parse(time.RFC3339,"2020-11-01T22:08:41+00:00")
  fmt.Println(now, only_after)
  fmt.Println(now.After(only_after))
  if now.After(only_after){
    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 字符串的格式。此函数返回两个值:一个值是转换成功时的输出,另一个值是存在错误时的错误。我们将输出捕获在 only_after 变量中,并使用一个丢弃变量来捕获任何输出;这是下划线符号 _。我们可以使用一个标准变量,如 only_after_error,但除非我们在稍后使用该变量,否则编译器会抛出一个错误,表明该变量已声明但未使用。这是通过使用 _ 变量来规避的。基于这个逻辑,我们可以非常简单地实现 only_before 参数或变量。time 包中有两个非常有用的函数:一个称为 After(),另一个称为 Before()。它们允许我们简单地比较两个 time 变量。

包中还有一个名为 Equal() 的第三个函数。此函数允许你比较两个 time 变量,并根据它们是否相等返回 truefalse

让我们看看Equal()函数的一个实际例子:

now := time.Now()
  now_too := now
  time.Sleep(2*time.Second)
  later := time.Now()
  if now.Equal(now_too){
    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!

让我们看看这里会发生什么。我们有三个time变量,分别称为nownow_toolatertime模块的Sleep()函数用于模拟 2 秒的延迟。这个函数接受一个整数参数,等待给定的时间过去然后继续执行。结果是later变量持有不同的时间值,使我们能够展示Equal()函数的目的,这在输出中可以看到。

现在,是时候检查提供了哪些设施来计算两个time变量之间的持续时间或差异了。

持续时间计算

计算执行持续时间的功能在编程的许多方面都很有用。在我们的日常生活中,我们可以监控我们的基础设施可能面临的不一致和性能瓶颈。例如,如果你有一个脚本平均只需要 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!")

如果你运行这个脚本,结果可能如下,这取决于电脑的性能:

![图 10.2:测量执行时间图片

图 10.2:测量执行时间

所需要做的只是捕捉脚本开始和结束的时间。然后,我们可以通过减去开始时间和结束时间来计算持续时间。之后,我们可以利用Duration变量的函数来获取完成任务所需时间的小时()分钟()秒()纳秒()值。

你将获得四个分辨率,分别是:

  • 小时

  • 分钟

  • 纳秒

如果你需要,例如,天、周或月,那么你可以从提供的分辨率中计算出来。

在过去,我们有一个要求测量事务持续时间的任务,并且我们需要满足一个服务级别协议(SLA)。这意味着有一些应用程序需要根据产品的关键性在 1,000 毫秒或 5 秒内处理请求。接下来的脚本将向您展示这是如何实现的。您有 6 种不同的分辨率可供选择:

  • 小时

  • 分钟

  • 第二

  • 毫秒

  • 微秒

  • 纳秒

让我们考虑以下示例:

  deadline_seconds := time.Duration((600 * 10) * time.Millisecond)
  Start := time.Now()
  fmt.Println("Deadline for the transaction is  ",deadline_seconds)
  fmt.Println("The transaction has started at: ", Start)
  sum := 0
  for i := 1; i < 25000000000; i++ {
      sum += i
  }
  End := time.Now()
  //Duration := time.Duration((End.Sub(Start)).Seconds() * time.Second)
  Duration := End.Sub(Start)
  TransactionTime := time.Duration(Duration.Nanoseconds()) * time.Nanosecond
  fmt.Println("The transaction has completed at: ", End, Duration)
  if TransactionTime <= deadline_seconds{
    fmt.Println("Performance is OK transaction completed in",TransactionTime)
  }else{
    fmt.Println("Performance problem, transaction completed in",TransactionTime,"second(s)!")
  }  

当我们没有满足截止时间时,输出如下:

图 10.3:未满足事务截止时间

图 10.3:未满足事务截止时间

当我们遇到截止时间时,它看起来是这样的:

图 10.4:事务截止时间满足

图 10.4:事务截止时间满足

让我们剖析我们的例子。首先,我们使用time.Duration()变量为事务定义一个截止时间。根据我的经验,Millisecond的分辨率是最优的;然而,适应计算它确实需要一些时间。请随意使用您喜欢的任何分辨率。我们用Start变量标记开始,进行一些计算,并用End变量标记完成。魔法就在此之后发生。我们希望计算截止时间和事务持续时间之间的差异,但我们不能直接这样做。我们需要将Duration值转换为Transaction时间。这与我们创建截止时间时的方法相同。我们简单地使用Nanosecond分辨率,这是我们应达到的最低分辨率。然而,在这种情况下,您可以使用您想要的任何分辨率。转换后,我们可以轻松比较并决定事务是否正常。

现在,让我们看看我们如何操作时间。

管理时间

Go 编程语言的time包提供了两个函数,允许您操作时间。其中一个叫做Sub(),另一个叫做Add()。在我的经验中,这种情况并不常见。大多数情况下,当计算脚本的执行时间时,使用Sub()函数来告知差异。

让我们看看添加的样子:

  TimeToManipulate := time.Now()
  ToBeAdded := time.Duration(10 * time.Second)
  fmt.Println("The original time:",TimeToManipulate)
  fmt.Println(ToBeAdded," duration later:",TimeToManipulate.Add(ToBeAdded))

执行后,以下输出欢迎我们:

The original time: 2019-10-18 08:49:53.1499273 +0200 CEST m=+0.001994601
10s duration later: 2019-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: 2019-10-18 08:50:36.5950116 +0200 CEST m=+0.001994401
-10m0s duration later: 2019-10-18 08:40:36.5950116 +0200 CEST m=+599.998005599

这正如我们所期望的;我们已经成功计算出了 10 分钟前的时间。

练习 10.2:执行持续时间

在这个练习中,我们将创建一个函数,允许您计算两个time.Time变量之间的执行持续时间,并返回一个字符串,告诉您执行完成花费了多长时间:

按以下顺序执行以下步骤:

  1. 创建一个名为Chapter_10_Exercise_2.go的文件。

  2. 使用以下packageimport语句初始化脚本:

    package main 
    import "time"
    import "fmt"
    import "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 Chapter_10_Exercise_2.go
    

    以下应该是输出结果:

    The total execution time elapsed is: 0 hour(s) and 0 minute(s) and 2   second(s)!
    

在这个练习中,我们创建了一个函数,展示了执行动作花费了多少小时、分钟和秒。这很有用,因为您可以在其他 Go 应用程序中重用这个函数。

现在,让我们转向时间的格式化。

时间格式化

到目前为止,在本章中,您可能已经注意到日期看起来相当丑陋。我的意思是,看看以下行:

The transaction has started at:  2019-09-27 13:50:58.2715452 +0200 CEST   m=+0.002992801

这些被故意留下以迫使您思考这真的是 Go 能做的所有事情。有没有一种方法可以将这些行格式化,使它们更方便、更容易阅读?如果有,那些额外的行是什么?

在这里,我们将回答这些问题。当我们谈论时间格式化时,有两个主要概念是我们所指的。第一个选项是在我们希望我们的时间变量在打印时输出一个期望的字符串时,第二个选项是在我们希望将一个字符串解析为特定格式时。两者都有自己的用例;我将详细向您介绍如何使用这两个选项。

首先,我们将学习关于Parse()函数的内容。这个函数本质上有两个参数。第一个是要解析的标准,第二个是需要解析的字符串。解析的结束将产生一个可以利用内置 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, _ := time.Parse(time.RFC3339,"2019-09-27T22:18:11+00:00")
  t2, _ := time.Parse(time.UnixDate,"2019-09-27T22:18:11+00:00")
  t3, _ := time.Parse(time.ANSIC,"2019-09-27T22:18:11+00:00")
  fmt.Println("RFC3339:",t1)
  fmt.Println("UnixDate",t2)
  fmt.Println("ANSIC",t3)

输出如下:

RFC3339: 2019-19-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 变量,它们持有时间,这些时间与指定的格式进行解析。如果转换过程中有任何错误,_ 变量将持有错误结果。t1 变量的输出是唯一有意义的;UnixDate 和 ANSIC 是错误的,因为它们解析了错误的字符串与标准格式。UnixDate 期望的是它们称之为 epoch 的东西。epoch 是一个非常独特的日期;在 UNIX 系统中,它标志着时间的开始,始于 1970 年 1 月 1 日。它期望一个巨大的整数,这是从这个日期开始经过的秒数。格式期望的输入如下:Mon Sep _27 18:24:05 2019。提供这样的时间允许 Parse() 函数提供正确的输出。

既然我们已经阐明了 Parse() 函数,现在是时候看看 Format() 函数了。

Go 允许你创建自己的 time 变量。让我们学习如何做到这一点,然后我们将对其进行格式化:

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() 函数,它可以用来将 YearMonthDay 添加到你的当前时间,那么这必须允许你动态地添加到你的日期。让我们看看一个例子。给定我们之前的日期,让我们添加 1 年、2 个月和 3 天:

date := time.Date(2019, 9, 27, 18, 50, 48, 324359102, time.UTC)
next_date := date.AddDate(1, 2, 3)
fmt.Prinln(next_date)

执行此程序后,你会得到以下输出:

2020-11-30 18:50:48.324359102 +0000 UTC

AddDate() 函数接受三个参数:第一个是 Year,第二个是 Month,第三个是 Day。这给了你调整你拥有的脚本的精细度。为了正确理解格式化是如何工作的,你需要知道其背后的原理。

时间格式化的最后一个重要方面是了解你如何利用 time 包的 LoadLocation() 函数将你的本地时间转换为另一个时区的本地时间。我们的参考时区将是 洛杉矶 时区。Format() 函数用于告诉 Go 我们希望如何格式化输出。In() 函数是指我们希望格式化存在的特定时区。

让我们找出柏林的时间:

Current := time.Now()
  Berlin, _ := time.LoadLocation("America/Los_Angeles")
  fmt.Println("The local current time is:",Current.Format(time.ANSIC))
  fmt.Println("The time in Berlin is: ",Current.In(Berlin).Format(time.ANSIC))

根据你的执行日,你应该看到以下输出:

The local current time is: Fri Oct 18 08:14:48 2019
The time in Berlin is: Thu Oct 17 23:14:48 2019

关键在于我们得到本地时间的一个变量,然后我们使用 time 包的 In() 函数,比如说,将这个值转换为一个特定时区的值。这很简单,但很有用。

练习 10.03:你的时区是什么时间?

在这个练习中,我们将创建一个函数,该函数可以告诉当前时区与指定时区之间的差异。该函数将使用LoadLocation()函数根据位置指定位置,变量将被设置为特定的时间。In()位置将用于将特定的时间值转换为给定的时间区值。输出格式应为 ANSIC 标准。

按以下顺序执行以下步骤:

  1. 创建一个名为Chapter_10_Exercise_3.go的文件。

  2. 使用以下packageimport语句初始化脚本:

    package main
    import "time"
    import "fmt"
    
  3. 现在是时候创建我们的函数timeDiff()了,它还将返回格式化为 ANSIC 的CurrentRemoteTime变量:

    func timeDiff(timezone string) (string, string)  {
      Current := time.Now()
      RemoteZone, _ := time.LoadLocation(timezone)
      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 Chapter_10_Exercise_3.go
    

    输出如下所示:

    The current time is: Thu Oct 17 15:37:02 2019
    The timezone: America/Los_Angeles time is: 2019-10-17 06:37:02.2440679 -0700   PDT
    Thu Oct 17 15:37:02 2019 Thu Oct 17 06:37:02 2019
    

在这个练习中,我们看到了在各个时区之间导航是多么容易。

活动 10.01:根据用户要求格式化日期

在这个活动中,你需要创建一个小脚本,它接受当前日期并以以下格式输出:“02:49:21 31/01/2019。”你需要利用你迄今为止学到的有关将整数转换为字符串的知识。这将允许你连接time变量的不同部分。记住,你不能连接字符串和整数变量。strconv()函数就在那里帮助你。你还需要记住,当你省略date.Month()命令时,它打印出月份的名称,但它也需要被转换成整数,然后再转换回带有数字的字符串。

你必须执行以下步骤以获得所需的输出:

  1. 使用time.Now()函数捕获当前日期到一个变量中。

  2. 通过将它们转换为字符串,将捕获的日期分解为daymonthyearhourminuteseconds变量。

  3. 按顺序打印出连接的变量。

    一旦脚本完成,输出应该如下所示(请注意,这取决于你何时运行代码):

    15:32:30 2019/10/17
    

    到这个活动的结束时,你应该已经学会了如何创建你自己的自定义time变量,并使用strconv.Itoa()将数字转换为字符串并将结果连接起来。

    注意

    该活动的解决方案可以在第 729 页找到。

活动 10.02:强制日期和时间的特定格式

这个活动需要你使用本章关于时间的知识。我们希望创建一个小脚本,打印出以下格式的日期:“02:49:21 31/01/2019。”

首先,你需要通过使用time.Date()函数创建一个date变量。然后,回想一下我们是如何访问变量的YearMonthDay属性的,并按适当的顺序创建一个连接。记住,你不能连接字符串和整数变量。strconv()函数就在那里帮助你。你还需要记住,当你省略date.Month()命令时,它打印出月份的名称,但它也需要被转换成整数,然后再转换回带有数字的字符串。

你必须执行以下步骤以获得所需的输出:

  1. 使用 time.Now() 函数将当前日期存储在一个变量中。

  2. 使用 strconv.Itoa() 函数将捕获的 date 变量的适当部分保存到以下变量中:daymonthyearhourminutesecond

  3. 最后,使用适当的连接打印这些信息。

    预期的输出应该看起来像这样:

    2:49:21 2019/1/31
    

    在完成此活动后,你应该已经学会了如何将当前日期格式化为特定的自定义格式。

    注意

    此活动的解决方案可以在第 730 页找到。

活动 10.03:测量经过时间

此活动要求你测量睡眠的持续时间。你应该使用 time.Sleep() 函数睡眠 2 秒,一旦睡眠完成,你需要计算开始和结束时间之间的差异,并显示花费了多少秒。

首先,标记执行的开始,睡眠 2 秒,然后捕获执行的结束时间在变量中。通过利用 time.Sub() 函数,我们可以使用 Seconds() 函数来输出结果。输出将略长于预期。

你必须执行以下步骤以获得所需的输出:

  1. 将开始时间存储在一个变量中。

  2. 创建一个持续 2 秒的睡眠变量。

  3. 将结束时间存储在一个变量中。

  4. 通过从结束时间减去开始时间来计算长度。

  5. 打印出结果。

    根据你电脑的速度,你应该期望以下输出:

    The execution took exactly 2.0016895 seconds!
    

    在完成此活动后,你应该已经学会了如何测量特定活动的经过时间。

    注意

    此活动的解决方案可以在第 730 页找到。

活动 10.04:计算未来的日期和时间

在此活动中,我们将计算从 Now() 开始的 6 小时、6 分钟和 6 秒的日期。你需要将当前时间存储在一个变量中。然后,在给定的日期上使用 Add() 函数添加之前提到的长度。为了方便,请使用 time.ANSIC 格式。然而,有一个陷阱。因为 Add() 函数期望一个持续时间,你需要选择一个分辨率,例如 Second,在添加之前创建持续时间。

你必须执行以下步骤以获得所需的输出:

  1. 将当前时间存储在一个变量中。

  2. 以 ANSIC 格式打印此值作为参考。

  3. 使用秒作为输入计算持续时间。

  4. 将持续时间添加到当前时间。

  5. 以 ANSIC 格式打印出未来的日期。

    确保你的输出看起来像这样,使用字符串格式化:

    The current time: Thu Oct 17 15:16:48 2019
    6 hours, 6 minutes and 6 seconds from now the time will be:  Thu Oct 17   21:22:54 2019
    

    在完成此活动后,你应该已经学会了如何通过利用 time.Duration()time.Add() 函数来计算未来的特定日期。

    注意

    此活动的解决方案可以在第 731 页找到。

活动 10.05:在不同时区打印本地时间

这个活动需要您利用在格式化时间部分学到的知识。您需要加载一个东海岸城市和一个西海岸城市。然后,打印出每个城市的当前时间。

这里的关键是LoadLocation()函数,您需要使用ANSIC格式输出。记住,LoadLocation()函数返回两个值!

您必须执行以下步骤以获得所需的输出:

  1. 将当前时间存储在一个变量中。

  2. 使用time.LoadLocation()函数为NYtimeLA创建参考时区变量。

  3. 以 ANSIC 格式打印出相应时区的当前时间。

    根据您的执行日,以下可能是您的预期输出:

    The local current time is: Thu Oct 17 15:16:13 2019
    The time in New York is: Thu Oct 17 09:16:13 2019
    The time in Los Angeles is: Thu Oct 17 06:16:13 2019
    

    在这个活动结束时,您应该已经学会了如何将您的时间变量转换为特定的时区。

    注意

    这个活动的解决方案可以在第 732 页找到。

摘要

本章向您介绍了go语言的time包,它允许您重用其他程序员发明并融入语言的代码。目标是教会您如何创建、操作和格式化时间变量,并使您熟悉在time包的帮助下可以做什么。如果您想进一步改进或深入了解该包提供的功能,请查看以下链接:golang.org/pkg/time/.

时间戳和时间操作是每个开发者必备的技能。无论您是将大或小的脚本投入生产,时间模块都能帮助您测量操作的经过时间,并在执行过程中提供操作的日志记录。最重要的是,如果使用得当,它可以帮助您轻松地将生产问题追溯到其根源。

下一章将向您介绍编码和解码 JSON,即 JavaScript 对象表示法。

第十一章:11. 编码和解码(JSON)

概述

本章旨在使您熟悉 JavaScript 对象表示法(JSON)的基础知识。您将学习如何使用 Go 解析 JSON,然后获得将 JSON 转换为结构体并将其转换回 JSON 的能力。

在这里,您将学习如何描述 JSON 并将 JSON 反序列化为结构体。您还将学习如何将结构体序列化为 JSON 并将 JSON 键名设置为与结构体字段名不同的名称。到本章结束时,您将能够使用各种 JSON 标签属性来控制要转换为 JSON 的内容,反序列化未知的 JSON 结构,并使用编码进行数据传输。

引言

在上一章中,我们探讨了 Go 中的错误,并发现 Go 中的错误是值,这使得我们可以将错误作为函数和方法的参数传递。我们还看到 Go 函数可以返回多个值,其中一个通常是错误。我们了解到检查函数返回的错误值是一个好的实践。通过不忽略错误,它可以防止程序中出现意外的行为。在 Go 中,我们看到了可以创建自己的自定义错误类型。最后,我们探讨了恐慌并学习了如何从中恢复。

在本章中,我们将仅使用 Go 的标准库来处理 JSON。在我们开始查看如何在 Go 代码中使用 JSON 之前,让我们简要介绍一下 JSON。

JSON

JSON代表JavaScript 对象表示法。它在许多编程语言中广泛用于传输和存储数据。通常,这是通过将数据从网络服务器传输到客户端来完成的。JSON 在 Web 应用程序中传输,甚至用于将数据存储在文件中以供后续处理。在本章中,我们将探讨这种操作的各种示例。JSON 是最简的;它不像 XML 那样冗长。它是自我描述的;这增加了其可读性和编写它的便捷性。JSON 是一种语言无关的文本格式:

![图 11.1:描述 JSON

![图片 B14177_11_01.jpg]

图 11.1:描述 JSON

JSON 广泛用作在 Web 应用程序之间交换数据以及各种服务器到服务器通信的数据格式。在应用程序中使用的常见 API 是 REST API。JSON 经常用于使用 REST API 的应用程序中。JSON 在 REST API 中使用而不是 XML 的原因之一是它比 XML 更简洁、更轻量级且更易于阅读。查看以下 JSON 和 XML,我们可以看到 JSON 更简洁、更易于阅读且更轻量级:

{
"firstname":"Captain",
"lastname":"Marvel"
}
<avenger>
<firstname>Captain</firstname>
<lastname>"Marvel"</lastname>
</avenger>

大多数现代数据库现在也将 JSON 作为字段中的数据类型存储。静态 Web 应用程序有时使用 JSON 来渲染其网页。

JSON 格式非常结构化。构成 JSON 格式的核心部分是一系列键值对,如下图所示:

![图 11.2:JSON 键值对

![图片 B14177_11_02.jpg]

图 11.2:JSON 键值对

键始终是一个用引号括起来的字符串,而值可以包含多种数据类型。JSON 中的键值对是一个key名称后跟一个冒号,然后是一个value。如果有更多的键值对,它们将用逗号分隔。

图 11.2中,有两个键值对。firstname键及其值为Captain的一个。另一组是lastnameMarvel

JSON 可以包含数组。值位于一组括号内。在图 11.3中,第3行和第4行是phonenumbers键的值:

![图 11.3:JSON 数组图片 B14177_11_03.jpg

图 11.3:JSON 数组

现在,我们已经看到了键值对,让我们看看 JSON 数据类型。JSON 对象支持许多不同的数据类型;以下图表显示了这些数据类型:

![图 11.4:JSON 数据类型图片 B14177_11_04.jpg

图 11.4:JSON 数据类型

这里有一些示例:

  • 字符串:

    Example: {"firstname": "Captain"}
    
  • 数字:这可以是浮点数或整数:

    Example: {"age": 32}
    
  • 数组:

    Example: {"hobbies": ["Go", "Saving Earth", "Shield"]}
    
  • 布尔值:只能是truefalse

    Example: {"ismarried": false}
    
  • 空值:

    Example: {"middlename": null}
    
  • 对象:

    JSON 对象类似于 Go 中的结构体。以下示例展示了 Go 结构体和 JSON 对象:

    type person struct {
      firstname string
      middlename string
      lastname string
      age int
      ismarried bool
      hobbies []string
    }
    {
      "person": {
        "firstname": "Captain",
        "middlename": null,
        "lastname": "Marvel",
        "age": 32,
        "ismarried": false,
        "hobbies": ["Go", "Saving Earth", "Shield"]
      }
    }
    

在本节中,我们简要介绍了 JSON。在接下来的章节中,我们将探讨 Go 如何解码和编码 JSON。

解析 JSON

当我们谈论解析 JSON 时,我们所说的就是我们将一个 JSON 数据结构转换为一个 Go 数据结构。将 JSON 转换为 Go 数据结构的好处是能够以原生方式处理数据。例如,如果 JSON 数据有一个字段是 Go 中的数组,那么它会被解码为一个切片。然后我们就可以像处理任何其他切片一样处理这个切片,这意味着我们可以使用range子句遍历切片,我们可以获取切片的长度,向切片中追加元素,等等。

如果我们事先知道我们的 JSON 的样子,我们可以在解析 JSON 时使用结构体。使用 Go 术语,我们需要能够unmarshal JSON 编码的数据并将结果存储在结构体中。为了能够做到这一点,我们需要导入encoding/json包。我们将使用 JSON 的Unmarshal函数。反序列化是将 JSON 解析到数据结构的过程。通常,你会听到反序列化和解码被互换使用:

func Unmarshal(data []byte, v interface{}) error

在前面的代码中,变量data被定义为字节数组。变量v是一个结构体的指针。Unmarshal函数接受 JSON 数据的字节数组并将结果存储在v指向的值中。

v的参数必须是一个指针,并且不能为nil。如果这两个要求中的任何一个没有得到满足,那么将返回以下错误:

![图 11.5:非指针作为参数传递时的 Unmarshal 错误图片 B14177_11_05.jpg

图 11.5:非指针作为参数传递时的 Unmarshal 错误

让我们看看以下代码作为反序列化数据的简单示例。我们将详细描述代码的每一部分,以便更好地理解程序:

package main
import (
  "encoding/json"
  "fmt"
)
type greeting struct {
  Message string 
}
func main() {
  data := []byte(`
  {
  "message": "Greetings fellow gopher!"
  }
`)
  var v greeting
  err := json.Unmarshal(data, &v)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println(v.Message)
}

让我们分解代码以更好地理解:

type greeting struct {
  Message string 
}

问候结构体有一个名为 Message 的可导出字段,其类型为 string

func main() {
  data := []byte(`
  {
  "message": "Greetings fellow gopher!"
  }
`)

注意

` 符号是反引号,而不是单引号。它用于字符串字面量。

json.Unmarshal 结构体要求 JSON 编码的数据必须是字节切片:

var g greeting

我们声明 g 为问候类型:

  err := json.Unmarshal(data, &v)
  if err != nil {
    fmt.Println(err)
  }

Unmarshal() 函数接收 JSON 数据的字节切片,并将结果存储在由 v 指向的值中。

v 变量指向我们的问候结构体。

它将 JSON 解析到问候实例中,如下所示:

图 11.6:将 JSON 解析到 Go 结构体

图 11.6:将 JSON 解析到 Go 结构体

现在,让我们看看解析后的输出:

fmt.Println(v.Message)

它应该看起来像这样:

Greetings fellow gopher!

在我们之前的示例中,JSON 序列化器将我们的字段名 Message 与 JSON 键 message 匹配。

注意

要能够将数据解析到结构体中,结构体字段必须是可导出的。结构体的字段名必须大写。只有可导出的字段才能在外部可见,包括 JSON 解析器。只有导出字段才会出现在 JSON 输出中;其他字段将被忽略。

结构体标签

我们可以使用结构体标签来提供有关结构体字段如何解析或序列化的转换信息。标签遵循 `key: "value"` 的格式。标签以反引号(`)开始和结束。

考虑以下示例:

type person struct {
  LastName string `json:"lname"`
}

使用标签给我们更多的控制。现在我们可以将结构体字段命名为任何内容,只要它是可导出的。

在此示例中将被反序列化的 json 字段是 lname

一旦你为 JSON 解析和序列化使用了标签,如果结构体字段不可导出,则无法编译。Go 编译器足够智能,能够意识到由于与结构体字段关联了 JSON 标签,因此它必须是可导出的才能用于 JSON 序列化和反序列化过程。以下是一个示例,当 lastname 小写时,你会得到以下错误:

type person struct {
  lastName string `json:"lname"`
}

这是未导出 JSON 结构体字段的错误信息:

图 11.7:未导出 JSON 结构体字段的错误

图 11.7:未导出 JSON 结构体字段的错误

我们已经看到过这段代码,并且我们知道如何解析 JSON。然而,我们将进行一个小小的更改,那就是在我们的代码中添加一个 struct 标签:

package main
import (
  "encoding/json"
  "fmt"
)
type greeting struct {
  SomeMessage string `json:"message"`
}
func main() {
  data := []byte(`
  {
  "message": "Greetings fellow gopher!"
  }
`)
  var g greeting
  err := json.Unmarshal(data, &g)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println(g.SomeMessage)
}

让我们分解代码以更好地理解:

type greeting struct {
  SomeMessage string `json:"message"`
}

我们将 greeting 结构体更改为使用与 JSON 中不同的可导出字段名。

`json:"message"` 标签表示这个可导出字段对应于 JSON 数据中的 message 键:

err := json.Unmarshal(data, &g)

当数据被解析时,JSON 消息值将被放置在 SomeMessage 结构体字段中。

我们将得到以下输出:

Greetings fellow gopher!

Go JSON unmarshaller 在解码时遵循确定哪个结构体字段映射 JSON 数据的过程:

  • 带有标签的导出字段。

  • 一个导出字段名与 JSON 键名大小写匹配。

  • 一个导出字段名与 JSON 键名大小写不敏感匹配。

  • 我们还可以验证我们即将反序列化的 JSON 是否有效。

以下是将反序列化执行的代码:

package main
import (
  "encoding/json"
  "fmt"
  "os"
)
type greeting struct {
  SomeMessage string `json:"message"`
}
func main() {
  data := []byte(`
  {
  message": "Greetings fellow gopher!"
  }
`)
  if !json.Valid(data) {
    fmt.Printf("JSON is not valid: %s", data)
    os.Exit(1)
  }
  //Code to perform the unmarshal
}

Valid() 函数接受一个字节数组切片作为参数,并将返回一个布尔值,指示 JSON 是否有效。对于有效的 JSON,它将显示 True,对于无效的 JSON,它将显示 False

这在我们尝试将 JSON 反序列化到结构体之前检查我们的 JSON 可能很有用。

你认为你需要哪些结构体来处理以下 JSON?让我们看看。

{
"lname": "Smith",
  "fname": "John",
  "address": {
    "street": "Sulphur Springs Rd",
      "city": "Park City",
      "state": "VA",
      "zipcode": 12345
    }
}

前面的 JSON 有一个嵌套的对象称为 address。如您从本章的介绍中回忆的那样,对象是 JSON 支持的类型之一。JSON 中对象类型的 Go 表示是结构体。我们的 parent 结构体需要有一个嵌套的结构体称为 address

以下代码片段是反序列化多个 JSON 对象到 Go 结构体的示例:

package main
import (
  "encoding/json"
  "fmt"
)
type person struct {
  Lastname  string  `json:"lname"`
  Firstname string  `json:"fname"`
  Address   address `json:"address"`
}
type address struct {
  Street  string `json:"street"`
  City    string `json:"city"`
  State   string `json:"state"`
  ZipCode int    `json:"zipcode"`
}
func main() {
  data := []byte(`
      {
      "lname": "Smith",
      "fname": "John",
      "address": {
        "street": "Sulphur Springs Rd",
        "city": "Park City",
        "state": "VA",
        "zipcode": 12345
      }
    }
  `)
  var p person
  err := json.Unmarshal(data, &p)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Printf("%+v",p)
}

让我们分解代码以更好地理解:

type person struct {
  Lastname  string  `json:"lname"`
  Firstname string  `json:"fname"`
  Address   address `json:"address"`
}

person 结构体有一个嵌套的结构体,称为 Address。它在 JSON 中表示为名为 address 的对象。address 结构体中的字段将具有反序列化到它们的 JSON 值:

  data := []byte(`
      {
      "lname": "Smith",
      "fname": "John",
      "address": {
        "street": "Sulphur Springs Rd",
        "city": "Park City",
        "state": "VA",
        "zipcode": 12345
      }
    }
  `)

JSON 中的 address 是一个对象,它将被反序列化到我们的 person 结构体的 address 字段:

![图 11.8:未展开的 JSON 地址到 person.address]

img/B14177_11_08.jpg

图 11.8:反序列化的 JSON 地址到 person.address

Unmarshal() 函数将 JSON 编码的 data 解码到指针 p

var p person
  err := json.Unmarshal(data, &p)

结果如下:

![图 11.9:解码 JSON 后的 person 结构体]

img/B14177_11_09.jpg

图 11.9:解码 JSON 后的 person 结构体

我们将在下一个练习中使用我们迄今为止学到的这些概念。

练习 11.01:反序列化学生课程

在这个练习中,我们将编写一个程序,该程序从网络请求中获取大学课程注册的 JSON。我们的程序需要将 JSON 数据反序列化到 Go 结构体中。JSON 将包含有关学生和他们所修课程的数据。在我们反序列化 JSON 之后,我们将打印结构体以进行验证。输出应如下所示:

![图 11.10:打印学生课程结构]

img/B14177_11_10.jpg

图 11.10:打印学生课程结构

所有创建的目录和文件都需要在 $GOPATH 内创建:

  1. 在名为 Chapter11 的目录中创建一个名为 Exercise11.01 的目录。

  2. Chapter11/Exercise11.01 内创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开新创建的 main.go 文件。

  4. 添加以下包名和导入语句:

    package main
    import (
      "encoding/json"
      "fmt"
    )
    
  5. 我们需要创建一个 student 结构体。student 结构体的所有字段都需要是导出的,这样我们才能将 JSON 数据反序列化到它们。每个结构体字段都需要一个 JSON 标签,该标签将是 JSON 数据字段的名称:

    type student struct {
      StudentId     int      `json:"id"`
      LastName      string   `json:"lname"`
      MiddleInitial string   `json:"minitial"`
      FirstName     string   `json:"fname"`
      IsEnrolled    bool     `json:"enrolled"`
      Courses       []course `json:"classes"`
    }
    
  6. 我们需要创建一个 course 结构体。course 结构体需要所有字段都是可导出的,这样我们才能将 JSON 数据解析到它们中。每个结构体字段都需要一个 JSON 标签,该标签将是 JSON 数据字段的名称:

    type course struct {
      Name   string `json:"coursename"`
      Number int    `json:"coursenum"`
      Hours  int    `json:"coursehours"`
    }
    
  7. 添加一个 main() 函数:

    func main() {
      }
    
  8. main() 函数中,添加我们将要解析到我们的结构体(studentcourse)中的 JSON 数据:

      data := []byte(`
        {
          "id": 123,
          "lname": "Smith",
          "minitial": null,
          "fname": "John",
          "enrolled": true,
          "classes": [{
            "coursename": "Intro to Golang",
            "coursenum": 101,
            "coursehours": 4
          },
        {
            "coursename": "English Lit",
            "coursenum": 101,
            "coursehours": 3
          },
        {
            "coursename": "World History",
            "coursenum": 101,
            "coursehours": 3
          }
      ]
        }
      `)
    
  9. 声明一个 student 类型的变量:

      var s student
    
  10. 接下来,我们将 JSON 解析到我们的 student 结构体中。我们还将处理 json.Unmarshal() 方法返回的任何错误:

      err := json.Unmarshal(data, &s)
      if err != nil {
        fmt.Println(err)
      }
    
  11. 我们将打印 student 结构体,以便我们可以看到 JSON 中的所有数据:

      fmt.Println(s)
    }
    
  12. 通过在命令行中运行 go build 来构建程序:

    go build
    

    修正返回的错误,并确保你的代码与这里的代码片段匹配。

  13. 通过输入可执行文件名并按 Enter 键来运行可执行文件。

    输出如下:

![图 11.11:打印学生课程结构体]

![图片 B14177_11_10.jpg]

图 11.11:打印学生课程结构体

这个练习展示了如何成功地将 JSON 数据解析到 Go 结构体中。

JSON 编码

我们已经研究了如何将 JSON 解析到结构体中。现在我们将做相反的操作:将结构体序列化为 JSON。当我们谈论编码 JSON 时,我们的意思是将一个 Go 结构体转换为 JSON 数据结构。这种操作通常发生在你有一个服务正在响应来自客户端的 HTTP 请求时。客户端希望数据以某种格式呈现,这通常是 JSON。另一种情况是数据存储在 NoSQL 数据库中,它需要 JSON 格式,或者甚至是一个具有 JSON 数据类型的列的传统数据库。

我们需要能够将 Go 结构体 Marshal 到一个 JSON 编码的结构体中。为了做到这一点,我们需要导入 encoding/json 包。我们将使用 json.Marshal 函数:

func Marshal(v interface{}) ([]byte, error)

v 被编码为 JSON。通常,v 是一个 structMarshal() 函数返回一个字节切片和错误。在编码 v 的过程中检查是否有错误总是一个好的做法。让我们通过一个简单的例子来进一步解释 Go 结构体到 JSON 的序列化:

package main
import (
  "encoding/json"
  "fmt"
)
type greeting struct {
  SomeMessage string
}
func main() {
  var v greeting
  v.SomeMessage = "Marshal me!"
  json, err := json.Marshal(v)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Printf("%s",json)
}

让我们分解代码以更好地理解:

type greeting struct {
  SomeMessage string
}

我们有一个只有一个可导出字段的 struct。注意没有 JSON 标签。你应该能够猜出该字段在 JSON 数据中是什么:

json, err := json.Marshal(v)

以下图示显示了如何使用 json.Marshal 方法将 greeting 结构体序列化为 JSON。marshal 方法中的 v 接口参数是 greeting 结构体。marshal 方法将 greeting 字段 SomeMessage 编码为 JSON。以下图示显示了该过程:

![图 11.12:将 Go 结构体序列化为 JSON]

![图片 B14177_11_12.jpg]

图 11.12:将 Go 结构体序列化为 JSON

当我们调用Marshal函数时,我们传递给它一个结构体。该函数将返回一个错误和g的 JSON 编码。

打印语句的结果如下:

{"SomeMessage":"Marshal me!"}

由于我们没有为结构体greeting提供 JSON 标签,Go Marshal将编码可导出字段及其值。Go Marshal使用字段名SomeMessage作为 JSON 数据中key字段的名称。

以下代码产生了一个不理想的结果。检查以下代码并注意未设置的结构体字段的结果。请特别注意在main()函数中未设置的字段。

考虑以下示例:

package main
import (
  "encoding/json"
  "fmt"
)
type book struct {
  ISBN          string `json:"isbn"`
  Title         string `json:"title"`
  YearPublished int    `json:"yearpub"`
  Author        string `json:"author"`
  CoAuthor      string `json:"coauthor"`
}
func main() {
  var b book
  b.ISBN = "9933HIST"
  b.Title = "Greatest of all Books"
  b.Author = "John Adams"
  json, err := json.Marshal(b)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Printf("%s", json)
} 

当字段值未设置时,序列化结构体数据将给出以下输出:

{"isbn":"9933HIST","title":"Greatest of all Books","yearpub":0,"author":"John   Adams","coauthor":""}

有时候我们可能不希望当字段未设置时将结构体字段序列化为 JSON。我们的CoAuthor字段和YearPublished未设置,因此 JSON 值分别为空字符串和零。有一个我们可以利用的 JSON 标签属性,称为omitempty。如果它为空,它将省略结构体字段:

package main
import (
  "encoding/json"
  "fmt"
)
type book struct {
  ISBN          string `json:"isbn"`
  Title         string `json:"title"`
  YearPublished int    `json:"yearpub,omitempty"`
  Author        string `json:"author"`
  CoAuthor      string `json:"coauthor,omitempty"`
}
func main() {
  var b book
  b.ISBN = "9933HIST"
  b.Title = "Greatest of all Books"
  b.Author = "John Adams"
  json, err := json.Marshal(b)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Printf("%s", json)
}

让我们分解代码以更好地理解:

  YearPublished int    `json:"yearpub,omitempty"`
  CoAuthor      string `json:"coauthor,omitempty"`

两个book字段的 JSON 标签使用omitempty属性。如果这些字段未设置,它们将不会出现在 JSON 中。结果如下:

{"isbn":"9933HIST","title":"Greatest of all Books","author":"John Adams"}

当使用 JSON 标签时,你需要小心不要在值中有任何空格。使用我们之前的示例,让我们将我们的YearPublished JSON 标签更改为以下内容:

YearPublished int    `json:"yearpub, omitempty"`

注意逗号和omitempty之间的空格。如果你使用go vet,这将导致以下错误:

![图 11.13:Go vet 错误图 B14177_11_13.jpg

图 11.13:Go vet 错误

另一个需要注意的事情是,如果你没有正确处理错误,你将得到一些错误的结果:

{"isbn":"9933HIST","title":"Greatest of all Books","yearpub":0,"author":"John 
  Adams"}

即使json.Marshal(b)函数出错,它仍然将结构体转换为 JSON。yearpub值被设置为零。这就是为什么处理我们的错误很重要的原因之一。

在以下示例中,我们将简要查看其他一些 JSON 标签:

package main
import (
  "encoding/json"
  "fmt"
)
type book struct {
  ISBN          string `json:"isbn"`
  Title         string `json:"title"`
  YearPublished int    `json:",omitempty"`
  Author        string `json:",omitempty"`
  CoAuthor      string `json:"-"`
}
func main() {
  var b book
  b.ISBN = "9933HIST"
  b.Title = "Greatest of all Books"
  b.Author = "John Adams"
  b.CoAuthor ="Can't see me"
  json, err := json.Marshal(b)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Printf("%s", json)
}

让我们分解代码以更好地理解:

  YearPublished int    `json:",omitempty"`
  Author        string `json:",omitempty"`
  • 在上面的代码中,`json:",omitempty"`没有字段的值。注意 JSON 标签值以逗号开头。

  • `json:",omitempty"`如果键有值,则该字段将出现在 JSON 中。如果Author设置了值,它将作为"Author" :"somevalue"键出现在 JSON 中:

    CoAuthor      string `json:"-"`
    
  • 破折号用于忽略字段。该字段将不会被序列化为 JSON。

结果如下:

{"isbn":"9933HIST","title":"Greatest of all Books","Author":"John Adams"}

以下图表总结了我们在将结构体序列化为 JSON 时与我们的结构体一起使用的不同 JSON 标签属性:

![图 11.14:JSON 标签字段描述图 B14177_11_14.jpg

图 11.14:JSON 标签字段描述

将 JSON 输出作为一行并不是很易读,尤其是当你开始处理更大的 JSON 结构时。Go JSON 包提供了一个格式化 JSON 输出的方法。MarshalIndent()函数提供了与Marshal函数相同的功能。除了编码 JSON 之外,MarshalIndent()函数还可以格式化 JSON,使其易于阅读。这通常被称为“美化打印”。以下代码展示了MarshalIndent()函数的示例代码:

func MarshalIndent(v interface{}, prefix, indent string) ([]byte, error)

在我们的示例中,我们不会使用前缀。它只是在我们的缩进字符串之前应用一个字符串。每个元素都将开始在新的一行上:

package main
import (
  "encoding/json"
  "fmt" 
  "os"
)
type person struct {
  LastName  string  `json:"lname"`
  FirstName string  `json:"fname"`
  Address   address `json:"address"`
}
type address struct {
  Street  string `json:"street"`
  City    string `json:"city"`
  State   string `json:"state"`
  ZipCode int    `json:"zipcode"`
}
func main() {
  p := person{LastName: "Vader", FirstName: "Darth"} 
  p.Address.Street = "Galaxy Far Away" 
  p.Address.City= "Dark Side"
  p.Address.State= "Tatooine"
  p.Address.ZipCode =12345
  noPrettyPrint, err := json.Marshal(p)
  if err != nil {
    fmt.Println(err)
    os.Exit(1)
  }
  prettyPrint, err := json.MarshalIndent(p, "", "    ")
  if err != nil {
    fmt.Println(err)
    os.Exit(1)
  }
  fmt.Println(string(noPrettyPrint))
  fmt.Println()
  fmt.Println(string(prettyPrint))
}

让我们分解代码以更好地理解:

type person struct {
  LastName  string  `json:"lname"`
  FirstName string  `json:"fname"`
  Address   address `json:"address"`
}
type address struct {
  Street  string `json:"street"`
  City    string `json:"city"`
  State   string `json:"state"`
  ZipCode int    `json:"zipcode"`
}

我们有两个结构体:一个person结构体和一个address结构体。address结构体嵌入在person结构体中。两个结构体都在 JSON 标签中定义了 JSON 键名。address结构体将在 JSON 中作为一个单独的对象:

  p := person{LastName: "Vader", FirstName: "Darth"} 
  p.Address.Street = "Galaxy Far Away" 
  p.Address.City= "Dark Side"
  p.Address.State= "Tatooine"
  p.Address.ZipCode =12345

我们初始化person结构体并设置person.Address字段的值。每个字段都设置了值,因此在我们的 JSON 中不会有空字符串或零值:

  noPrettyPrint, err := json.Marshal(p)
  if err != nil {
    fmt.Println(err)
    os.Exit(1)
  }

noPrettyPrint变量是p的 JSON 编码。

我们当然会检查json.Marshal()函数返回的任何错误:

  prettyPrint, err := json.MarshalIndent(p, "", "    ")
  if err != nil {
    fmt.Println(err)
    os.Exit(1)
  }

prettyPrint变量是使用json.MarshalIndent()p进行 JSON 编码的结果。我们将前缀参数设置为空字符串,将缩进参数设置为四个空格。

json.Marshal()函数一样,我们也检查json.MarshalIndent()函数返回的任何错误。我们可以使用以下图中的json.MarshalIndent()方法来查看这些各种步骤:

图 11.15:json.MarshalIndent()方法

图 11.15:json.MarshalIndent()方法

然后我们使用json.Marshal()函数打印 JSON 编码的结果:

fmt.Println(string(noPrettyPrint))

如您所见,JSON 的可读性略有挑战。

没有使用MarshalIndent的 JSON 序列化如下所示:

{"lname":"Vader","fname":"Darth","address":{"street":"Galaxy Far   Away","city":"Dark Side","state":"Tatooine","zipcode":12345}}

我们还使用json.MarshalIndent()函数打印了 JSON 编码的结果:

fmt.Println(string(prettyPrint))

使用json.MarshalIndent()函数的结果更容易阅读。你可以比之前打印的结果更容易地阅读输出:

图 11.16:使用 MarshalIndent JSON 结果

图 11.16:使用 MarshalIndent JSON 结果

练习 11.02:序列化学生课程

在这个练习中,我们将做与练习 11.01反序列化学生课程相反的事情。我们将从结构体到 JSON 进行序列化。这是之前的结构体:

 type student struct {
  StudentId     int      `json:"id"`
  LastName      string   `json:"lname"`
  MiddleInitial string   `json:"minitial"`
  FirstName     string   `json:"fname"`
  IsEnrolled    bool     `json:"enrolled"`
  Courses       []course `json:"classes"`
}

我们将对 JSON 标签进行一些修改。

所有创建的目录和文件都需要在您的$GOPATH内创建:

  1. 创建一个名为main.go的文件。

  2. 添加以下包名和导入语句:

    package main
    import (
      "encoding/json"
      "fmt"
      "os"
    )
    
  3. 创建一个student结构体。所有字段都将可导出。以下字段的 JSON 标签在它们被序列化时需要以下功能:

    如果未设置值,则应省略 MiddleInitialIsMarried 不应出现在 JSON 中;IsEnrolled 应为字段名,如果未设置则省略:

    type student struct {
      StudentId     int      `json:"id"`
      LastName      string   `json:"lname"`
      MiddleInitial string   `json:"mname,omitempty"`
      FirstName     string   `json:"fname"`
      IsMarried     bool   `json:"-"`
      IsEnrolled    bool     `json:"enrolled,omitempty "`
      Courses       []course `json:"classes"`
    }
    
  4. 创建一个 course 结构体:

    type course struct {
      Name   string `json:"coursename"`
      Number int    `json:"coursenum"`
      Hours  int    `json:"coursehours"`
    }
    
  5. 创建一个名为 newStudent() 的函数。此函数将返回一个 student 结构体:

    func newStudent(studentID int, lastName, middleInitial, firstName string,
      isMarried, isEnrolled bool) student {
      s := student{StudentId: studentID,
        LastName:      lastName,
        MiddleInitial: middleInitial,
        FirstName:     firstName,
        IsMarried:     isMarried,
        IsEnrolled:    isEnrolled,
      }
      return s
    }
    
  6. 添加 main() 函数:

    func main() {
    }
    
  7. main() 函数中,使用 newStudent() 函数创建一个 student 结构体,并将函数的结果赋值给变量 s

      s := newStudent(1, "Williams", "s", "Felicia", false, false)
    
  8. 接下来,将 s 序列化为 JSON。我们希望 JSON 的缩进为每个字段四个空格,以便于阅读:

      student1, err := json.MarshalIndent(s, "", "")
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  9. 打印 student1

      fmt.Println(string(student1))
      fmt.Println()
    
  10. 使用 newStudent() 函数创建另一个 student

      s2 := newStudent(2, "Washington", "", "Bill", true, true)
    
  11. 我们现在将为 s2 添加各种课程:

      c := course{Name: "World Lit", Number: 101, Hours: 3}
      s2.Courses = append(s2.Courses, c)
      c = course{Name: "Biology", Number: 201, Hours: 4}
      s2.Courses = append(s2.Courses, c)
      c = course{Name: "Intro to Go", Number: 101, Hours: 4}
      s2.Courses = append(s2.Courses, c)
    
  12. 接下来,将 s2 序列化为 JSON。我们希望 JSON 的缩进为每个字段四个空格,以便于阅读:

      student2, err := json.MarshalIndent(s2, "", "")
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  13. 打印 student2

      fmt.Println(string(student2))
    }
    

    student1 打印语句的结果如下:

    {
        "id": 1,
        "lname": "Williams",
        "mname": "S",
        "fname": "Felicia",
        "classes": null
    }
    

    student2 打印语句的结果如下:

    {
        "id": 2,
        "lname": "Washington",
        "Fname": "Bill",
        "IsEnrolled": true,
        "classes": [
            {
                "coursename": "World Lit",
                "coursenum": 101,
                "coursehours": 3
            },
            {
                "coursename": "Biology",
                "coursenum": 201,
                "coursehours": 4
            },
            {
                "coursename": "Intro to Go",
                "coursenum": 101,
                "coursehours": 4
            }
        ]
    }
    

本练习的目的是演示如何编码 JSON。我们从一个结构体开始,将其编码为 JSON。我们能够通过缩进字段来改变编码,使其更容易阅读。我们还看到了如何改变字段编码到 JSON 的行为。我们看到,如果结构体字段没有数据,我们可以省略字段以避免将其编码到 JSON 中。我们展示了我们可以使用 JSON 标签以与结构体中字段名不同的名称命名 JSON 数据中的字段。我们还看到了我们甚至可以忽略结构体中的字段,这样在序列化时它们就不会出现在 JSON 中。

到目前为止,我们已经处理了在事先知道 JSON 结构且该结构不会改变的情况。在下一节中,我们将讨论如何处理当你得到一个 JSON 结构,但该结构可能会改变且不稳定的情况。

未知 JSON 结构

当我们事先知道 JSON 结构时,这允许我们灵活地设计我们的结构体以匹配预期的 JSON。正如我们所见,我们可以将我们的 JSON 值反序列化到目标结构体类型中。Go 提供了对结构体类型进行编码(序列化)和解码(反序列化)的支持。

有时候你可能不知道 JSON 的结构。例如,你可能在与一个为流媒体服务发布指标的第三方工具交互。这个指标是以 JSON 格式存在的;然而,它非常动态,服务于各种客户。他们经常为他们的各种客户添加新的指标。你想要订阅这个服务并报告这些不同的指标。问题是这些指标的提供者经常更改 JSON 数据。他们更改得如此频繁,以至于他们不提供更改,也没有任何规定的日程。你需要能够对新指标和旧指标进行分析,而且你不能因为要将 JSON 中的新字段添加到你的结构中而中断你的服务。你需要有能力以最小的服务中断连续报告他们的指标。

如果你的 JSON 是动态的,将其解码到结构体中就不会起作用。那么,当你不知道 JSON 结构或者它频繁变化时,你该怎么办?

在这些情况下,我们可以使用map[string]interface{}。JSON 数据的键将是映射的字符串键。empty interface{}将是那些 JSON 键的值。每个类型都实现了空接口:

图 11.17:JSON 到映射数据类型的映射

图 11.17:JSON 到映射数据类型的映射

json.Unmarshal函数会将未知的 JSON 结构解码成键为字符串、值为空接口的映射。这很好,因为 JSON 键必须是字符串。

考虑以下示例:

package main
import (
  "encoding/json"
  "fmt"
)
func main() {
  jsonData := []byte(`{"checkNum":123,"amount":200,"category":["gift","clothing"]}`)
  var v interface{}
  json.Unmarshal(jsonData, &v)
  fmt.Println(v)
}

让我们分解代码以便更好地理解:

jsonData := []byte(`{"checkNum":123,"amount":200,"category":["gift","clothing"]}`)

jsonData代表我们给出的 JSON,但我们不知道其结构:

  var v interface{}
  json.Unmarshal(jsonData, &v)

尽管我们不知道 JSON 结构,但我们仍然可以将其反序列化到接口中。

jsonData被反序列化到空接口v,它将是一个映射。

映射键是字符串,值是空接口。打印出v的结果如下:

map[amount:200 category:[gift clothing] checkNum: 123]

map[string]interface{}的打印顺序与数据存储的顺序不匹配。这是因为映射是无序的,所以它们的顺序不能保证。

v的 Go 表示如下:

v = map[string]interface{}{
  "amount": 200,
  "category": []interface{}{
    "gift",
    "clothing",
  },
  "checkNum":  123,
}

记住键是字符串,值是接口。即使 JSON 中有切片,值也会变成interface{}的切片,表示为[]interface{}

我们在第七章接口中了解到,我们有访问具体类型的能力。我们可以进行类型断言来访问map[string]interface{}的底层具体类型。让我们看看另一个例子,其中我们有许多数据类型可以处理。

练习 11.03:分析大学课程 JSON

在这个练习中,我们将分析来自大学管理办公室的数据,看看我们是否可以替换当前的大学课程成绩提交应用程序。问题是旧系统的 JSON 数据没有很好地记录。JSON 中的数据类型和结构都不清楚。在某些情况下,JSON 结构是不同的。我们需要编写一个程序,可以分析未知的 JSON 结构,并且对于结构中的每个字段,打印出数据类型和 JSON 键值对。

所有创建的目录和文件都需要在 $GOPATH 内创建:

  1. 在名为 Chapter11 的目录下创建一个名为 Exercise11.03 的目录。

  2. Chapter11/Exercise11.03 内创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开新创建的 main.go 文件。

  4. 添加以下 package 名称和 import 语句:

    package main
    import (
      "encoding/json"
      "fmt"
      "os"
    )
    
  5. 创建一个 main() 函数,然后将 jsonData 赋值给一个 []byte,该 []byte 将代表来自大学成绩提交程序的 JSON

    func main() {
      jsonData := []byte(`
    {
      "id": 2,
      "lname": "Washington",
      "fname": "Bill",
      "IsEnrolled": true,
      "grades":[100,76,93,50],
      "class": 
        {
          "coursename": "World Lit",
          "coursenum": 101,
          "coursehours": 3
        }
    }
    `)
    
  6. 检查 jsonData 是否是有效的 JSON。如果不是,打印错误消息并退出应用程序:

      if !json.Valid(jsonData) {
        fmt.Printf("JSON is not valid: %s", jsonData)
        os.Exit(1)
      }
    
  7. 声明一个空的 interface 变量:

      var v interface{}
    
  8. jsonData 解码到空的 interface 中。检查是否有任何错误。如果有错误,打印错误并退出应用程序:

      err := json.Unmarshal(jsonData, &v)
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  9. 对映射中的每个值执行类型切换。为 stringfloat64bool[]interfacedefault 准备一个情况语句来捕获值的未知类型。每个 case 语句应打印数据类型、键和值。我们的类型断言流程应如图所示:![图 11.18:类型断言流程 图片

      data := v.(map[string]interface{})
      for k, v := range data {
        switch value := v.(type) {
        case string:
          fmt.Println("(string):", k, value)
        case float64:
          fmt.Println("(float64):", k, value)
        case bool:
          fmt.Println("(bool):", k, value)
        case []interface{}:
          fmt.Println("(slice):", k)
          for i, j := range value {
            fmt.Println("    ", i, j)
          }
        default:
          fmt.Println( "(unknown):",k, value)
          }
      }
    }
    
  10. 通过在命令行上运行 go build 来构建程序:

    go build
    
  11. 修正返回的错误,并确保你的代码与代码片段 packt.live/2Qr4dNx 相匹配。

  12. 通过键入可执行文件名然后按 Enter 键来运行可执行文件。

    类型 switch 语句的输出应如下所示:

![图 11.19:大学班级 JSON 的输出图片

图 11.19:大学班级 JSON 的输出

注意

映射的输出可能与前面的示例不同,因为使用范围循环迭代映射并不是每次迭代都确定的。

在这个练习中,我们看到了即使不知道其内容,如何解析 JSON 结构。我们了解到通过将 JSON 解码到空的 interface 中,我们得到 map[string]interface{} 的结构。映射的键是 JSON 的字段,映射的 interface{} 是 JSON 的值。然后我们能够遍历映射并执行类型断言语句来获取映射值的类型和数据,以及键名。

GOB:Go 的自有编码

Go 语言有其特有的数据编码协议,称为 gob。只有在编码和解码操作发生在 Go 语言环境中时,你才能使用 gob。如果需要与其他语言编写的软件进行通信,仅限于 Go 语言可能成为一项无法逾越的限制。对于组织内部使用的软件,编码和解码软件通常都使用相同的语言编写,因此这种情况并不常见。

如果可以使用 gob,它将为你提供异常高的性能和效率。例如,JSON 是一种基于字符串的协议,它需要在任何编程语言中都是可用的。这限制了 JSON 和类似协议所能实现的功能。另一方面,Gob 是一种基于二进制的协议,并且 gob 只需要为 Go 用户工作。这使得 gob 成为一个空间和处理器效率高的编码协议,同时仍然易于使用。

Gob 不需要任何配置或设置即可使用。此外,gob 不要求发送者和接收者的数据模型完全匹配。因此,它不仅高效快捷,而且易于使用。

虽然 Go 在类型方面非常严格,但 gob 则不然。gob 将所有数字视为相同,无论是 int 还是 float。你可以使用指针与 gob 一起使用,并且在编码时,gob 将为你从指针中提取值。gob 还会愉快地将值设置为指针或值类型,无论值是从指针还是值编码而来。

Gob 可以编码复杂类型,如结构体。Gob 的灵活性继续存在,因为它不要求结构体上的属性匹配。如果结构体上存在要解码的匹配属性,它将使用它;如果没有,则它将丢弃该值。这一事实还带来了额外的好处,即你可以添加新的属性,而无需担心它会破坏你的旧服务。

当在 Go 网络服务之间使用 gob 进行通信时,通常的做法是使用 Go 的 rpc 包来处理服务之间的网络通信方面。rpc 包提供了一种简单的方式来调用其他 Go 网络服务,并且默认情况下,rpc 包使用 gob 来处理编码任务。这意味着你将获得使用 gob 的所有好处,而无需进行任何额外的工作。

使用 gob 进行服务间通信的 rpc 服务将导致通信延迟降低。低延迟通信是现代软件架构设计,如微服务,得以实现的关键。

要在 Go 中直接使用 gob 协议进行数据编码,你使用 Go 的 gob 包。该包是 Go 对 gob 协议的实现。当使用此包进行编码时,它将返回一个 byte 切片。这些字节切片在处理文件和网络时在代码中很常见。这意味着已经有许多辅助函数供你利用。

Gob 不仅限于在网络解决方案中使用。你还可以使用 gob 将数据存储在文件中。将 Go 数据写入文件的一个常见用例是使数据对服务器重启具有容错性。在现代云服务器部署中,如果服务器开始出现问题,它会被杀死,你的应用程序会在新的服务器上重新启动。如果你有任何仅在内存中的重要数据,它将会丢失。通过将数据写入附加到服务器的挂载文件系统来防止这种损失。当替换服务器启动时,它会连接到相同的文件系统,在启动时,你的应用程序将从文件系统恢复数据。

使用文件进行数据容错的一个例子是在基于事务的工作负载中。在基于事务的工作负载中,丢失单个事务可能是一个大问题。为了防止这种情况发生,在应用程序处理事务的同时,将事务的备份写入磁盘。如果发生重启,应用程序会检查这些备份以确保一切正常。使用 gob 来编码这些数据将确保它们尽快写入文件系统,从而最大限度地减少数据丢失的可能性。

另一个用例是冷启动缓存预填充。当出于性能原因使用缓存时,你需要将其存储在内存中。这个缓存的规模增长到几 GB 大小并不罕见。服务器重启意味着这个缓存丢失,需要从数据库重新加载。如果很多服务器同时重启,会导致缓存踩踏,这可能会使数据库崩溃。避免这种过载情况的一种方法是将缓存的一个副本写入挂载文件系统。然后,当你的应用程序启动时,它会从文件而不是数据库中预填充其缓存。使用 gob 来编码这些数据将允许更有效地使用磁盘空间,从而反过来允许更快的读取和更有效的解码。这也意味着你的服务器可以更快地恢复在线状态。

练习 11.04:使用 gob 编码数据

在这个练习中,我们将使用 gob 编码和传输一个事务,然后解码。我们将使用一个虚拟网络将一个银行事务从客户端发送到服务器。这个事务是一个结构体,它还包含一个嵌入的用户结构体。这表明复杂的数据可以很容易地编码。

为了展示gob协议的灵活性,客户端和服务器结构体在几个方面不匹配。例如,客户端的用户是一个指针,而服务器的用户不是。金额是不同的浮点类型,客户端是float64,而服务器是*float32。一些字段在服务器类型中缺失,而在客户端类型中存在。

我们将使用bytes包来存储我们的编码数据。这表明一旦编码,你可以使用标准库来处理 gob 二进制数据。

步骤:

  1. 定义client结构体。

  2. 定义具有多种不同方式的server结构体。

  3. 创建一个字节数组缓冲区作为虚拟网络。

  4. 创建一个包含一些模拟数据的客户端值。

  5. 编码客户端值。

  6. 将编码后的数据写入模拟网络。

  7. 创建一个充当服务器的函数。

  8. 从模拟网络中读取数据。

  9. 解码数据。

  10. 将解码后的数据打印到控制台。

让我们开始这个练习:

  1. 在名为Chapter11的目录中创建一个名为Exercise11.04的目录。

  2. Chapter11/Exercise11.04中创建一个名为main.go的文件。

  3. 使用 Visual Studio Code 打开新创建的main.go文件。

  4. 添加以下包名和导入语句:

    package main
    import (
      "bytes"
      "encoding/gob"
      "fmt"
      "io"
      "log"
    )
    
  5. 创建一个表示客户端用户模型的struct

    type UserClient struct {
      ID   string
      Name string
    }
    
  6. 创建一个表示客户端事务的structTx是事务的常用缩写:

    type TxClient struct {
      ID          string
      User        *UserClient
      AccountFrom string
      AccountTo   string
      Amount      float64
    }
    
  7. 创建一个表示服务器端用户模型的struct。这个模型与客户端模型不匹配,因为它没有Name属性:

    type UserServer struct {
      ID string
    }
    
  8. 创建一个表示服务器端事务的struct。在这里,用户不是一个指针。金额是一个指针,但这个指针是指向float32的,而不是float64

    type TxServer struct {
      ID          string
      User        UserServer
      AccountFrom string
      AccountTo   string
      Amount      *float32
    }
    
  9. 创建main()函数:

    func main() {
    
  10. 创建一个模拟网络,它是一个来自bytes包的缓冲区:

      var net bytes.Buffer
    
  11. 使用客户端struct创建模拟数据:

      clientTx := &TxClient{
        ID: "123456789",
        User: &UserClient{
          ID:   "ABCDEF",
          Name: "James",
        },
        AccountFrom: "Bob",
        AccountTo:   "Jane",
        Amount:      9.99,
      }
    
  12. 编码数据。编码数据的目标是我们的模拟网络:

      enc := gob.NewEncoder(&net)
    
  13. 检查错误,如果发现任何错误则退出:

      if err := enc.Encode(clientTx); err != nil {
        log.Fatal("error encoding: ", err)
      }
    
  14. 将数据发送到服务器:

      serverTx, err := sendToServer(&net)
    
  15. 检查错误,如果发现任何错误则退出:

      if err != nil {
        log.Fatal("server error: ", err)
      }
    
  16. 将解码后的数据打印到控制台:

      fmt.Printf("%#v\n", serverTx)
    
  17. 关闭main()函数:

    }
    
  18. 创建我们的sendToServer函数。这个函数接受一个单一的io.Reader接口,并返回一个服务器端事务和一个error

    func sendToServer(net io.Reader) (*TxServer, error) {
    
  19. 创建一个变量作为解码的目标:

      tx := &TxServer{}
    
  20. 使用网络作为源创建一个解码器:

      dec := gob.NewDecoder(net)
    
  21. 解码并捕获任何错误:

      err := dec.Decode(tx)
    
  22. 返回解码后的数据和捕获到的任何错误:

      return tx, err
    
  23. 关闭函数:

    }
    
  24. 通过在命令行中运行go build来构建程序:

    go build
    
  25. 通过输入可执行文件名并按Enter键来运行可执行文件。

    类型切换语句的输出应该如下所示:

![图 11.20:Gob 输出]

img/B14177_11_20.jpg

图 11.20:Gob 输出

在这个练习中,我们使用客户端类型编码了数据,将其sent到服务器,并输出了服务器解码的内容。从服务器返回的内容中,我们可以看到它使用了不同的类型,用户有一个 ID 但没有名字,并且Amount是一个 32 位的浮点指针类型。

我们可以看到 gob 多么容易和灵活,可以用来工作。当需要在服务器之间进行通信时,gob 也是一个很好的性能选择,但两个服务器都需要用 Go 编写才能利用这些功能。

在下一个活动中,我们将使用 JSON 测试我们迄今为止学到的内容。

活动内容 11.01:使用 JSON 模拟客户订单

在这个活动中,我们将模拟客户订单。一个在线电子商务门户需要通过其 Web 应用程序接受客户订单。当客户浏览网站时,客户将向订单添加商品。这个 Web 应用程序需要能够将 JSON 添加到 JSON 中。

步骤:

  1. 创建一个包含所有可导出字段的address结构体(Street字符串、City字符串、State字符串和Zipcode整数)。

  2. 创建一个包含所有可导出字段的item结构体(Name字符串、Description字符串、Quantity整数和Price整数)。如果描述字段没有数据,则不应在 JSON 中显示。

  3. 创建一个包含所有可导出字段的order结构体(TotalPrice整数、IsPaid布尔值、Fragile布尔值和OrderDetail []item)。如果Fragile字段没有数据,则不应在 JSON 中显示。

  4. 创建一个包含所有字段的customer结构体(UserName字符串、Password字符串、Token字符串、ShipTo地址和PurchaseOrder订单)。PasswordToken字段永远不会出现在 JSON 中。

  5. 应用程序应检查jsonData是否是有效的 JSON。以下代码片段是我们应用程序客户订单的一些示例 JSON:

      jsonData := []byte(`
      {
        "username" :"blackhat",
        "shipto":  
          {
              "street": "Sulphur Springs Rd",
              "city": "Park City",
              "state": "VA",
              "zipcode": 12345
          },
        "order":
          {
            "paid":false,
            "orderdetail" : 
              [{
                "itemname":"A Guide to the World of zeros and ones",
                "desc": "book",
                "qty": 3,
                "price": 50
              }]
          }
      }
      `)
    
  6. 应用程序应将jsonData解码到客户结构体中。

  7. 在订单中添加两个额外的项目,包括订单中所有项目的TotalPrice,订单是否有易碎物品,以及所有物品是否已全额支付。

  8. 打印客户订单,使其易于阅读。

    应用程序预期的输出如下:

![图 11.21:客户订单打印输出图片

图 11.21:客户订单打印输出

我们已经看到如何将复杂数据类型如切片编码和解码到 JSON 中。我们已经检查了 JSON 是否是有效的 JSON。我们还看到了如何控制结构体中显示的字段,以及是否能够从 JSON 中省略没有数据的字段。当我们打印 JSON 时,我们能够以易于阅读的格式打印它。

注意

这个活动的解决方案可以在第 732 页找到。

概述

在这一章中,我们研究了 JSON 是什么以及我们如何使用 Go 将 JSON 存储在我们的结构体中。

JSON 被许多编程语言包括 Go 使用。JSON 由键值对组成。这些键值对可以是以下任何一种类型:字符串、数字、对象、数组、布尔值或 null。

Go 的标准库提供了许多使处理 JSON 变得容易的功能。这包括将 JSON 数据解码到结构体的能力。它还具有将结构体编码到 JSON 的能力。

我们已经看到,通过使用 JSON 标签,我们在 JSON 的编码和解码方面有了更大的灵活性和控制权。这些标签使我们能够命名 JSON 键名,忽略字段并且不将其编码到 JSON 中,以及当字段为空时省略字段。

Go 标准库通过使用json.MarshalIndent()函数,为我们提供了以易于阅读的格式打印的能力。我们同时也看到了在事先不知道 JSON 格式的情况下如何解码 JSON 结构。所有这些特性和许多其他特性都展示了 Go 标准库强大的功能。

在下一章中,我们将探讨文件和系统。这一章将介绍如何与文件系统交互,包括创建和修改文件。你还将了解文件权限以及创建使用各种标志和参数的命令行应用程序。我们还将探讨另一种存储数据格式的 CSV。下一章将包含所有这些内容以及更多。

第十二章:12. 文件和系统

概述

本章旨在帮助你了解如何与文件系统交互。这包括创建和修改文件。你还将学习如何检查文件是否存在。我们将向文件写入并保存到磁盘上。然后我们将创建一个接受各种标志和参数的命令行应用程序。我们还将能够捕获信号,并在退出程序之前确定如何处理它们。

在本章中,你将创建接受参数并显示帮助内容的命令行应用程序。到本章结束时,你将能够处理操作系统(OS)发送给应用程序的信号,并在操作系统发送信号立即停止应用程序时控制应用程序的退出。

简介

在上一章中,我们探讨了如何序列化和反序列化 JSON。我们能够将我们的结构体设置为 JSON 键值,并将结构体值放入 JSON 中。Go 编程语言对 JSON 的支持非常好,就像它对文件系统类型操作的支持一样好(例如,opencreatemodify 文件)。

在本章中,我们将与文件系统进行交互。我们将处理的文件系统级别包括文件、目录和权限级别。我们将解决开发者在处理文件系统时面临的日常问题,包括如何编写需要从命令行接受参数的命令行应用程序。我们将学习如何创建一个能够读取和写入文件的命令行应用程序。除了讨论从操作系统接收到信号中断时会发生什么,我们还将演示在应用程序停止运行之前如何执行清理操作。我们还将处理应用程序接收到中断的场景,并处理应用程序退出的方式。有时,当应用程序正在运行时,操作系统会发送一个信号来关闭应用程序。在这种情况下,我们可能希望记录关闭时的信息以供调试;这将帮助我们了解应用程序为何关闭。我们将在本章中探讨如何做到这一点。然而,在我们开始解决这些问题之前,让我们先对文件系统有一个基本的了解。

文件系统

文件系统控制数据在硬盘、USB、DVD 或其他介质等设备上的命名、存储、访问和检索方式。每个特定操作系统的文件系统都会指定其命名文件的习惯,例如文件名长度、可以使用特定字符、后缀或文件扩展名的长度等。大多数文件系统都包含一些关于文件的描述符或元数据,例如文件大小、位置、访问权限、创建日期、修改日期等:

图 12.1:文件的文件系统元数据

图 12.1:文件的文件系统元数据

文件通常被放置在某种层次结构中。这种结构通常由多个目录和子目录组成。文件在目录中的放置是一种组织数据并获得访问文件或目录的方式:

图 12.2:文件系统目录结构

图 12.2:文件系统目录结构

图 12.2所示,顶级目录是Chapter12。它包含子目录ex1ex2activity1。在这个例子中,这些子目录根据每个练习和活动组织文件。文件系统还负责谁或什么可以访问目录和文件。在下一个主题中,我们将探讨文件权限。

文件权限

权限是在处理文件创建和修改时需要理解的重要方面。

我们需要查看可以分配给文件的多种权限类型。我们还需要查看这些权限类型如何以符号和八进制表示法表示。

Go 使用 Unix 命名法来表示权限类型。它们以符号表示法或八进制表示法表示。三种权限类型是读取写入执行

每个权限都有符号和八进制表示法。下表解释了权限类型及其表示方式:

图 12.3:权限

图 12.3:权限

对于每个文件,都有三组个人或组指定了它们的权限:

所有者

  • 对于个人来说,这是一个单个人,比如 John Smith 或 root 用户。

  • 一个组通常由多个个人或其他组组成。

其他人

  • 那些不属于组或所有者的。

  • 下面的示例展示了 Unix 机器上的一个文件及其权限:图 12.4:权限集

图 12.4:权限集

  • 第一个短横线表示这是一个文件;如果它是d,则表示这是一个目录。

  • 八进制表示法可以用一个数字来显示多个权限类型。例如,如果你想使用符号表示法显示读取写入权限,它将是rw-。如果这要表示为八进制数字,它将是6

图 12.5:权限类型

图 12.5:权限类型

下表展示了不同权限类型的数字和符号:

图 12.6:权限类型、八进制和符号

图 12.6:权限类型、八进制和符号

下表是所有者其他人的文件权限示例:

图 12.7:基于所有者、组和其他人的权限

图 12.7:基于所有者、组和其他人的权限

标志和参数

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
func String(name string, value string, usage string) *string
func Uint(name string, value uint, usage string) *uint
func Uint64(name string, value uint64, usage string) *uint64

前述函数的参数可以这样解释:

名称

  • 此参数是标志的名称;它是一个字符串类型。例如,如果你传递file作为参数,你将可以通过以下方式从命令行访问该标志:

    app.exe -file
    

  • 此参数是标志默认设置的值。

用法

  • 此参数用于描述标志的目的。当您错误地设置值时,它通常会出现在命令行上。

  • 为标志传递错误的类型将停止程序并导致错误;将打印用法。

返回值

  • 这是存储标志值的变量的地址。

让我们看看一个简单的例子:

package main
import (
  "flag"
  "fmt"
)
func main() {
  v := flag.Int("value", -1, "Needs a value for the flag.")
  flag.Parse()
  fmt.Println(*v)
}

以下图表描述了使用标志包时的前一个示例。

图 12.8:flag.Int 参数

图 12.8:flag.Int 参数

我们将回顾图中的代码和前述代码片段。

  • 变量v将引用-value或–value的值。

  • 在调用flag.Parse()之前,*v的初始值是-1的默认值:

    flag.Parse()
    
  • 在定义标志之后,你必须调用flag.Parse()来将命令行解析到定义的标志中。

  • 调用flag.Parse()-value的参数放入*v中。

  • 一旦你调用了flag.Parse()函数,标志将可用。

  • 在命令行上执行以下go build -o exFlag命令,你将在名为exFlag的目录中获得可执行文件:

图 12.9:应用程序标志和参数

图 12.9:应用程序标志和参数

让我们看看以下代码片段中各种类型标志的使用:

package main
import (
  "flag"
  "fmt"
)
func main() {
  i := flag.Int("age", -1, "your age")
  n := flag.String("name", "", "your first name")
  b := flag.Bool("married", false, "are you married?")
  flag.Parse()
  fmt.Println("Name: ", *n)
  fmt.Println("Age: ", *i)
  fmt.Println("Married: ", *b)
}

让我们分析前述代码:

  • 我们定义了三个IntStringBool类型的标志。

  • 我们随后调用flag.Parse()函数,将这些标志的参数放入相应的引用变量中。

  • 然后,我们简单地打印这些值。

  • 不带参数运行可执行文件:./exFlag

    Name:  
    Age:  -1
    Married:  false
    
  • 不提供参数运行;引用指针的值是我们定义标志类型时分配的默认值:./exFlag -h

    Usage of ./exFlag:
      -age int
        your age (default -1)
      -married
        are you married?
      -name string
        your first name
    
  • 使用 -h 标志运行我们的应用程序将打印出我们在定义标志时设置的用法说明:

    ./exFlag -name=John –age 42 -married true results

    Name:  John
    Age:  42
    Married:  false
    

有时候我们可能需要使 flag 对命令行应用程序成为必需。当标志是必需的时,仔细选择默认值很重要。你可以检查标志的值是否为默认值,以及它是否退出程序:

package main
import (
  "flag"
  "fmt"
  "os"
)
func main() {
  i := flag.Int("age", -1, "your age")
  n := flag.String("name", "", "your first name")
  b := flag.Bool("married", false, "are you married?")
  flag.Parse()
  if *n == "" {
  fmt.Println("Name is required.")
  flag.PrintDefaults()
  os.Exit(1)
  }
  fmt.Println("Name: ", *n)
  fmt.Println("Age: ", *i)
  fmt.Println("Married: ", *b)
  if *n == "" {
  fmt.Println("Name is required.")
  flag.PrintDefaults()
  os.Exit(1)
  }
}

让我们详细回顾一下代码:

  • 名称标志的默认值为空字符串。

  • 我们检查 *n 的值是否为该值。如果是,我们打印一条消息通知用户 Name 是必需的。

  • 然后我们调用 flag.PrintDefaults();这会将使用说明打印给用户。

  • 调用应用程序的结果是 /exFlag --age 42 -married true

Name is required.
  -age int
    your age (default -1)
  -married
    are you married?
  -name string
    your first name

信号

  • 什么是信号?在我们的上下文中,信号是由操作系统发送到我们的程序或进程的中断。当信号被发送到我们的程序时,程序将停止正在做的事情;要么处理信号,要么如果可能的话,忽略它。我们已经看到了其他改变程序流程的 Go 命令;你可能想知道该使用哪一个。

我们在我们的应用程序中使用 defer 语句执行各种清理活动,如下所示:

  • 资源的释放

  • 文件的关闭

  • 数据库连接的关闭

  • 执行配置文件或临时文件的删除

在某些用例中,完成这些活动是强制性的。使用 defer 函数将在返回给调用者之前执行它。然而,这并不保证它总是会运行。在某些场景中,defer 函数不会执行;例如,操作系统对程序的干扰:

  • os.Exit(1)

  • Ctrl + C

  • 来自操作系统的其他指令

  • 前面的场景表明了可能需要使用信号的情况。信号可以帮助我们控制程序的退出。根据信号的不同,它可能会终止我们的程序。例如,应用程序正在运行,并在执行 employee.CalculateSalary() 后遇到操作系统的中断信号。在这种情况下,defer 函数将不会运行,因此,employee.DepositCheck() 不会执行,员工将不会收到工资。信号可以改变程序的流程。以下图表概述了我们之前讨论的场景:图 12.10:信号改变程序的流程

图 12.10:信号改变程序的流程

  • Go 标准库内置了对信号处理的支撑;它位于 os/signal 包中。这个包将使我们能够使我们的程序更加健壮。当我们收到某些信号时,我们希望优雅地关闭程序。在 Go 中处理信号的第一件事是拦截或捕获你感兴趣的信号。这是通过以下方式完成的:
func Notify(c chan<- os.Signal, sig ...os.Signal)
  • Notify()函数接受一个通道上的os.Signal数据类型,csig参数是一个可变参数的os.Signal;我们指定零个或多个我们感兴趣的os.Signal数据类型。

  • 以下是一个处理syscall.SIGINT中断的示例,类似于CTRL-C

package main
import (
  "fmt"
  "os"
  "os/signal"
  "syscall"
)
func main() {
  sigs := make(chan os.Signal, 1)
  done := make(chan bool)
  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 <- true    
    }
  }
  }()
  fmt.Println("Program is blocked until a signal is caught")
  <-done
  fmt.Println("Out of here")
}
  • 让我们详细看看前面的代码片段:

    sigs := make(chan os.Signal, 1)
    
  • 我们创建了一个os.Signal类型的通道。Notify方法通过向通道发送os.Signal类型的值来工作。sigs通道用于接收Notify方法的通知:

    done := make(chan bool)
    
  • done通道用于让我们知道程序何时可以退出:

    signal.Notify(sigs,syscall.SIGINT)
    
  • signal.Notify方法将在syscall.SIGINT类型的sigs通道上接收通知:

      go func() {
      for {
        s := <-sigs
        switch s {
        case syscall.SIGINT:
        fmt.Println("My process has been interrupted.  Someone might of pressed   CTRL-C")
        fmt.Println("Some clean up is occurring")
        done <- true    
        }
      }
    
  • 我们创建一个匿名函数,该函数是一个 goroutine。这个函数目前只有一个 case 语句,它会阻塞,直到接收到syscall.SIGINT类型。

  • 它将打印出各种消息。

  • 我们向done通道发送true以指示我们已接收到信号。这将停止通道阻塞:

      fmt.Println("Program is blocked until a signal is caught")
      <-done
      fmt.Println("Out of here")
    
  • <-done通道将阻塞,直到我们的程序接收到信号。

  • 这里是结果:

    Program is blocked until a signal is caught
    ^C
    My process has been interrupted.  Someone might of pressed CTRL-C
    Some clean up is occurring
    Out of here
    

练习 12.01:模拟清理

在这个练习中,我们将捕获两个信号:SIGINTSIGTSTP。一旦捕获到这些信号,我们将模拟清理文件。我们还没有讲解如何删除文件,所以在这个例子中,我们将简单地创建一个延迟来演示我们如何在捕获到信号后运行一个函数。这是这个练习期望的输出:

  1. 创建一个名为main.go的文件。

  2. 在文件中添加包main和以下导入语句:

    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 bool)
    
  5. 然后我们将添加一个signal.Notify方法。Notify方法通过向通道发送os.Signal类型的值来工作。

  6. 回想一下,signal.Notify方法的最后一个参数是可变参数的os.Signal类型。

  7. signal.Notify方法将在通道sigs上接收syscall.SIGINTsyscall.SIGTSTP类型的通知。

  8. 通常来说,当您按下Ctrl + C时,可能会发生syscall.SIGINT类型。

  9. 通常来说,当您按下Ctrl + Z时,可能会发生syscall.SIGTSTP类型:

      signal.Notify(sigs, syscall.SIGINT, syscall.SIGTSTP)
    
  10. 创建一个匿名函数作为 goroutine:

      go func() {
    
  11. 在 goroutine 内部,创建一个无限循环。

  12. 在无限循环内部,我们将从sigs通道接收一个值并将其存储在s变量中,s := <-sigs

        for {
          s := <-sigs
    
  13. 创建一个switch语句来评估从通道接收到的内容。

  14. 我们将有两个 case 语句来检查syscall.SIGINTsyscall.SIGTSP类型。

  15. 每个 case 语句都会打印一条消息。

  16. 我们还将调用我们的cleanup()函数。

  17. 在 case 语句中的最后一个语句是将true发送到done通道以停止阻塞:

          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")
            cleanUp()
            done <- true
          case syscall.SIGTSTP:
            fmt.Println()
            fmt.Println("Someone pressed CTRL-Z")
            fmt.Println("Some clean up is occuring")
            cleanUp()
            done <- true
          }
        }
      }()
      fmt.Println("Program is blocked until a signal is caught(ctrl-z, ctrl-c)")
      <-done
      fmt.Println("Out of here")
    }
    
  18. 创建一个简单的函数来模拟执行清理过程的进程:

    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)
      }
    }
    
  19. 你可以尝试运行这个程序,然后按Ctrl + ZCtrl + C来检查程序的不同结果。这仅在 Linux 和 macOS 上有效:

  20. 现在运行代码:

    go run main.go
    

    以下是对应的输出:

图 12.11:模拟清理输出

图 12.11:模拟清理输出

在这个练习中,我们展示了拦截中断并在应用程序关闭前执行任务的能力。我们有能力控制我们的退出。这是一个强大的功能,允许我们执行清理操作,包括删除文件、进行最后的日志记录、释放内存等。在下一个主题中,我们将创建并写入文件。我们将使用来自 Go 标准包os的函数。

创建和写入文件

Go 语言以各种方式提供支持来创建和写入新文件。我们将检查一些最常见的方法。

os包提供了一个简单的方式来创建文件。对于那些熟悉 Unix 世界中的touch命令的人来说,它与此类似。以下是该函数的签名:

func Create(name string(*File, error)

该函数将创建一个空文件,就像touch命令一样。重要的是要注意,如果它已经存在,那么它将截断该文件。

os包中的Create函数的输入参数是你要创建的文件名和位置。如果成功,它将返回一个File类型。值得注意的是,File类型满足io.Writeio.Read接口。这一点在章节的后面很重要:

package main
import (
  "fmt"
  "os"
)
func main() {
  f, err := os.Create("test.txt")
  if err != nil {
  panic(err)
  }
  defer f.Close()
}
  • 上述代码只是创建了一个空文件:

    f, err := os.Create("test.txt")
    
  • 它创建了一个名为 test.txt 的文件。

  • 如果已经存在同名文件,则它将截断该文件。

  • 由于我们没有提供文件的位置,它将在我们的可执行文件所在的目录中创建文件:

      if err != nil {
      fmt.Println(err)
      }
    
  • 我们随后检查os.Create函数的错误。立即检查错误是一个好习惯,因为如果发生了错误而我们没有检查错误,这将使我们在程序中稍后调试变得困难。

  • 如果出现错误,我们会感到恐慌。最好是恐慌后退出,因为如果你在一个带有 defer 函数的函数中执行 os.Exit(1),defer 函数将不会运行。

  • 如果确实发生了错误,那么它将是*PathError 类型。例如,假设我们给os.Create函数提供了一个错误的路径,比如/lol/test.txt。我们会得到以下错误:

    open /lol/test.txt: no such file or directory
    

创建一个空文件很简单,但让我们继续使用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")
    }
    

    让我们更详细地看看前面的代码:

    func (f *File) Write(b []byte) (n int, err error)
    
  • Write 方法接受一个字节数组切片,并在有错误时返回写入的字节数和错误。此方法还允许 os.File 类型满足 io.Write 接口:

    f.Write([]byte("Using Write function.\n"))
    
  • 我们将字符串 "Using Write function.\n" 转换为字节数组切片。

  • 然后,我们将它写入我们的 test.txt 文件。Write 方法接受 []byte 类型:

    f.WriteString("Using Writestring function.\n")
    
  • WriteString 方法的行为与 Write 方法相同,不同之处在于它接受一个字符串作为输入参数,而不是 []byte 数据类型。

    Go 提供了在单个命令中创建和写入文件的能力。我们将利用 Go 的 io/ioutil 包来完成此任务。ioutil.WriteFile 方法是一个非常方便的方法,它提供了这种能力:

    func WriteFile(filename string, data []byte, perm os.FileMode) error
    

    该方法将数据写入由文件名参数指定的文件,并使用给定的权限。如果存在错误,它将返回错误。让我们看看它是如何工作的:

    package main
    import (
      "fmt"
      "io/ioutil"
    )
    func main() {
      message := []byte("Look!")
      err := ioutil.WriteFile("test.txt", message, 0644)
      if err != nil {
        fmt.Println(err)
      }
    }
    

    让我们逐部分理解这段代码:

    err := ioutil.WriteFile("test.txt", message, 0644)
    
  • WriteFile 方法会将 []byte 类型的变量消息写入 test.txt 文件。

  • 如果 test.txt 文件不存在,它将以 0644 权限创建 test.txt 文件。所有者将具有读写权限,组和其他用户将具有读权限。

  • 如果文件存在,它将截断文件。

os.Createioutil.WriteFile 都会在文件存在时截断文件。这可能不是我们总是希望的行为。有时我们希望在创建文件或尝试读取文件之前检查文件是否存在。幸运的是,Go 提供了一个简单的机制来检查文件是否存在:

注意

junk.txt file to not exist. It also requires the test.txt file to exist in the same directory as the program's executable.
package main
import (
  "fmt"
  "os"
)
func main() {
  file, err := os.Stat("junk.txt")
  if err != nil {
  if os.IsNotExist((err)) {
    fmt.Println("junk.txt:  File does not exist!")
    fmt.Println(file)
  }
  }
  fmt.Println()
  file, err = os.Stat("test.txt")
  if err != nil {
  if os.IsNotExist((err)) {
    fmt.Println("test.txt:  File does not exist!")
  }
  }
  fmt.Printf("file name: %s\nIsDir: %t\nModTime: %v\nMode: %v\nSize: %d\n", file.Name(), file.IsDir(), file.ModTime(), file.Mode(), file.Size())
}

让我们更详细地看看前面的代码片段:

file, err := os.Stat("junk.txt")
  • 我们正在对 junk.txt 文件调用 os.Stat() 来检查它是否存在。如果文件存在,os.Stat() 方法将返回 FileInfo 类型。如果不存在,FileInfo 将是 nil,并且将返回错误:

      if err != nil {
      if os.IsNotExist((err)) {
        fmt.Println("junk.txt:  File does not exist!")
        fmt.Prinln(file)
      }
      }
    
  • os.Stat() 方法可以返回多个错误。我们必须检查错误以确定错误是否是由于文件不存在。标准库提供了 os.IsNotExist(error),可以用来检查错误是否是由于文件不存在。以下是结果:

    IsNotExist returns a boolean indicating whether the error is known to report that a file or a directory does not exist. It is satisfied by ErrNotExist as well as some syscall errors.
    func os.IsNotExist(err error) bool
    
  • 在这种情况下,打印 file(FileInfo) 将会是 nil,因为 junk.txt 文件不存在:

    file, err = os.Stat("test.txt")
    
  • 在这种情况下,test.txt 文件确实存在,所以 err 将是 nil,并且文件将包含 FileInfo 类型:

      fmt.Printf("file name: %s\nIsDir: %t\nModTime: %v\nMode: %v\nSize: %d\n",     file.Name(), file.IsDir(), file.ModTime(), file.Mode(), file.Size())
    }
    
  • FileInfo 类型包含各种信息,了解这些信息可能很有用。

  • FileInfo 接口中可以找到以下详细信息,请参阅 golang.org/src/os/types.go?s=479:840#L11

    // A FileInfo describes a file and is returned by Stat and Lstat.
      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)
      }
    
  • 这里是执行代码的结果:

图 12.12: os.Stat

图 12.12: os.Stat

一次性读取整个文件

在这个主题中,我们将探讨两种读取文件所有内容的方法。当文件大小较小时,这两个函数是很好的选择。虽然这两个方法方便且易于使用,但它们有一个主要的缺点。那就是,如果文件大小太大,可能会耗尽系统内存。这一点很重要,需要记住,并理解我们将在这个主题中讨论的两个方法的限制。尽管这些方法是最快和最简单加载数据的方式之一,但重要的是要理解它们应该仅限于小文件,而不是大文件。

我们将要检查的第一个读取文件的方法如下:

func ReadFile(filename string) ([]byte, error)

ReadFile 函数读取文件内容,并以字节切片的形式返回它,同时报告任何错误。我们将查看当使用 ReadFile 方法时的错误返回值:

  • 一个成功的调用返回 err == nil

  • 在一些其他文件读取方法中,EOF 被视为错误。对于将整个文件读入内存的函数来说,情况并非如此:

    package main
    import (
      "fmt"
      "io/ioutil"
    )
    func main() {
      content, err := ioutil.ReadFile("test.txt")
      if err != nil {
      fmt.Println(err)
      }
      fmt.Println("File contents: ")
      fmt.Println(string(content))
    }
    
  • 对于这个代码片段,我有一个 test.txt 文件,它位于我的可执行文件相同的目录中。它包含以下内容:

![图 12.13:示例文本文件]

![图片 B14177_12_13.jpg]

![图 12.13:示例文本文件]

content, err := ioutil.ReadFile("test.txt")
  • text.txt 的内容被分配为字节切片到变量 content 中。如果有任何错误,它们将被存储在 err 变量中:

      fmt.Println("File contents: ")
      fmt.Println(string(content))
    
  • 由于这是一个字节切片,必须将其转换为字符串格式以便于阅读。以下是打印语句的结果:

![图 12.14:示例输出]

![图片 B14177_12_14.jpg]

![图 12.14:示例输出]

我们将要查看的下一个函数是读取整个内容到内存的以下函数:

func ReadAll(r io.Reader) ([]byte, error)

ReadFile 方法不同,ReadAll 方法接受 io.Reader 作为参数。这是 ReadFileReadAll 行为之间唯一的真正区别:

package main
import (
  "fmt"
  "io/ioutil"
  "os"
  "strings"
)
func main() {
  f, err := os.Open("test.txt")
  if err != nil {
  fmt.Println(err)
  os.Exit(1)
  }
  content, err := ioutil.ReadAll(f)
  if err != nil {
  fmt.Println(err)
  os.Exit(1)
  }
  fmt.Println("File contents: ")
  fmt.Println(string(content))
  r := strings.NewReader("No file here.")
  c, err := ioutil.ReadAll(r)
  if err != nil {
  fmt.Println(err)
  os.Exit(1)
  }
  fmt.Println()
  fmt.Println("Contents of strings.NewReader: ")
  fmt.Println(string(c))
}

让我们逐部分理解代码:

f, err := os.Open("test.txt")
  • ioutil.ReadAll 方法需要一个 io.Reader 作为参数。os.Open 方法返回一个 *os.File 类型,它满足 io.Reader 接口:

      content, err := ioutil.ReadAll(f)
      if err != nil {
      fmt.Println(err)
      os.Exit(1)
      }
    
  • 内容存储了从 ioutil.ReadAll(f) 方法结果中获取的 []byte 数据。如果有任何错误,它们将被存储在 err 变量中:

      fmt.Println("File contents: ")
      fmt.Println(string(content))
    
  • 由于这是一个字节切片,必须将其转换为字符串格式以便于阅读。打印语句的结果如下:

![图 12.15:示例输出]

![图片 B14177_12_15.jpg]

![图 12.15:示例输出]

r := strings.NewReader("No file here.")
  • 由于 ioutil.ReadAll 方法接受接口,这为我们提供了更多的灵活性。如果您还记得 第七章,接口,当使用接口时,它允许更多的灵活性和使用:

  • 我们使用 strings.NewReader,它接受一个字符串并返回一个实现 io.Reader 接口的 Reader 类型。这允许我们在没有文件的情况下使用 ioutil.ReadAll() 方法。通过这样做,我们可以在尚未提供文件时对数据进行各种测试:

    c, err := ioutil.ReadAll(r)
    
  • 我们可以使用 ioutil.Readall 方法以与 os.Open() 相同的方式使用 strings.Reader() 的结果:

      fmt.Println()
      fmt.Println("Contents of strings.NewReader: ")
      fmt.Println(string(c))
    
  • 以下是打印语句的结果:

图 12.16:strings.NewReader 内容

图 12.16:strings.NewReader 内容

我们已经看到了各种写入文件、创建文件和从文件中读取的方法。然而,我们还没有看到如何向文件中追加数据。有时你可能想要向文件追加额外的信息。os.OpenFile() 方法提供了这种能力。大多数时候,你将使用 CreateOpen 来进行打开或创建过程;然而,当你想要向文件追加数据时,你需要使用 OpenFile。该方法的签名如下:

func OpenFile(name string, flag int, perm FileMode) (*File, error)

唯一一个独特的参数是 flag 参数。这个参数用于确定打开文件时允许执行的操作;它不要与 FileMode 类型混淆,后者是权限类型可以分配给文件本身的。

这里是可用于打开文件的一些标志列表(golang.org/src/pkg/os/file.go):

// Flags to OpenFile wrapping those of the underlying system. Not all
// flags may be implemented on a given system.
const (
// Exactly one of O_RDONLY, O_WRONLY, or O_RDWR must be specified.
  O_RDONLY int = syscall.O_RDONLY // open the file read-only.
  O_WRONLY int = syscall.O_WRONLY // open the file write-only.
  O_RDWR   int = syscall.O_RDWR   // open the file read-write.
  // The remaining values may be or'ed in to control behavior.
  O_APPEND int = syscall.O_APPEND // append data to the file when writing.
  O_CREATE int = syscall.O_CREAT  // create a new file if none exists.
  O_EXCL   int = syscall.O_EXCL   // used with O_CREATE, file must not exist.
  O_SYNC   int = syscall.O_SYNC   // open for synchronous I/O.
  O_TRUNC  int = syscall.O_TRUNC  // truncate regular writable file when opened.
 )

这些标志可以在打开文件时以各种组合使用。让我们看看一些使用标志的例子:

package main
import (
  "os"
)
func main() {
  f, err := os.OpenFile("junk101.txt", os.O_CREATE, 0644)
  if err != nil {
  panic(err)
  }
  defer f.Close()
}

让我们看看前一个例子中的 os.OpenFile

f, err := os.OpenFile("junk101.txt", os.O_CREATE, 0644)
  • 使用带有 os.O_CREATE 文件模式的 os.OpenFile 将在文件不存在时创建 junk101.txt 文件并打开它。

让我们看看使用 os.OpenFile 的不同文件模式的一个例子:

package main
import (
  "os"
)
func main() {
  f, err := os.OpenFile("junk101.txt", os.O_CREATE|os.O_WRONLY, 0644)
  if err != nil {
  panic(err)
  }
  defer f.Close()
  if _, err := f.Write([]byte("adding stuff\n")); err != nil {
  panic(err)
  }
}

让我们更详细地看看前面的代码。

f, err := os.OpenFile("junk101.txt", os.O_CREATE| os.O_WRONLY, 0644)
  • 使用带有 os.O_CREATE 标志的 os.OpenFile 将在文件不存在时创建 junk101.txt 文件并打开它。如果它已经存在,它将只打开文件。由于 os.O_WRONLY 标志,它还将允许在文件打开时进行读写操作:

      if _, err := f.Write([]byte("adding stuff\n")); err != nil {
      panic(err)
      }
    
  • 由于我们使用了 os.O_WRONLY 标志,我们可以在文件打开时写入它。

让我们看看如何向文件追加数据的一个例子:

package main
import (
  "os"
)
func main() {
  f, err := os.OpenFile("junk.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
  if err != nil {
    panic(err)
  }
  defer f.Close()
  if _, err := f.Write([]byte("adding stuff\n")); err != nil {
    panic(err)
  }}
f, err := os.OpenFile("junk101.txt", os.O_APPEND | os.O_CREATE| os.O_WRONLY,   0644)
  • 使用带有 os.O_CREATE 标志的 os.OpenFile 将在文件不存在时创建 junk101.txt 文件并打开它。如果它已经存在,它将只打开文件:

  • 由于 os.O_WRONLY 标志,它还将允许在文件打开时进行读写操作。

  • os.O_APPEND 将允许你将数据追加到文件底部:

      if _, err := f.Write([]byte("adding stuff\n")); err != nil {
      panic(err)
      }
    
  • 由于我们使用了 os.O_WRONLY 标志,我们可以在文件打开时写入它。

由于我们包含了 os.O_APPEND 标志,数据将被追加到文件底部,而不是覆盖现有数据。以下是一些常见的权限标志组合,可用于 os.OpenFile

os.O_CREATE

  • 如果文件不存在,它将在尝试打开时创建文件。

os.O_CREATE | os.O_WRONLY

  • 当打开文件时,你现在可以写入它。

  • 文件中的任何数据都将被覆盖。

os.O_CREATE | os.O_WRONLY | os.O_APPEND

  • 当写入文件时,它不会覆盖数据,而是将数据追加到文件末尾。

练习 12.02:备份文件

在处理文件时,我们通常需要在对其进行更改之前备份文件。这是在可能犯错或需要原始文件进行审计目的的情况下。在本练习中,我们将取一个名为note.txt的现有文件,并将其备份到backupFile.txt。然后我们将打开note.txt并在文件末尾添加一些额外的笔记。我们的目录将包含以下文件:

![图 12.17:将文件备份到目录图片

图 12.17:将文件备份到目录

  1. 我们必须首先在可执行文件相同的目录中创建note.txt文件。此文件可以是空的,也可以包含一些示例数据,如下所示:![图 12.18:notes.txt 文件内容示例 图片

    图 12.18:notes.txt 文件内容示例

  2. 创建一个名为main.go的 Go 文件。

  3. 此程序将是main包的一部分。

  4. 包含以下代码中的导入:

    package main
    import (
      "errors"
      "fmt"
      "io/ioutil"
      "os"
      "strconv"
    )
    
  5. 创建一个用于当工作文件(note.txt)未找到时使用的自定义错误:

    var (
      ErrWorkingFileNotFound = errors.New("The working file is not found.")
    )
    
  6. 创建一个执行备份操作的函数。此函数负责将工作文件的内容存储到backup文件中。此函数接受两个参数。working参数是你当前正在工作的文件的文件路径:

    func createBackup(working, backup string) error {
    }
    
  7. 在此函数内部,我们需要检查工作文件是否存在。在我们可以读取其内容并将其存储在我们的备份文件中之前,它必须首先存在。

  8. 我们可以使用os.IsNotExist(err)来检查错误是否是文件不存在的情况。

  9. 如果文件不存在,我们将返回我们的自定义错误: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
      }
    
  10. 接下来,我们需要打开工作文件并将函数返回的os.File存储到workFile变量中:

    workFile, err := os.Open(working)
      if err != nil {
      return err
      }
    
  11. 我们需要读取workFile的内容。我们将使用ioutil.ReadAll方法获取workFile的所有内容。workFileos.File类型,它满足io.Reader接口;这允许我们将其传递给ioutil.ReadFile

  12. 检查是否有错误:

      content, err := ioutil.ReadAll(workFile)
      if err != nil {
      return err
      }
    
  13. content变量包含workFile的数据,表示为字节数组。这些数据需要写入备份文件。我们将实现将content变量的数据写入备份文件的代码。

  14. 内容存储从函数返回的[]byte数据。这是存储在变量中的整个文件内容。

  15. 我们可以使用ioutil.Writefile方法。如果备份文件不存在,它将创建文件。如果备份文件已存在,它将使用content变量数据覆盖文件:

      err = ioutil.WriteFile(backup, content, 0644)
      if err != nil {
      fmt.Println(err)
      }
    
  16. 我们需要返回nil,表示在此阶段,我们没有遇到任何错误:

      return nil
    }
    
  17. 创建一个函数,用于将数据追加到我们的工作文件中。

  18. 将函数命名为 addNotes;这将接受我们的工作文件位置和一个将被追加到工作文件的字符串参数。该函数需要返回一个错误:

    func addNotes(workingFile, notes string) error {
    //…
      return nil
    }
    
  19. addNotes 函数内部,添加一行代码,将新行追加到每个笔记的字符串中。这将使每个笔记单独占一行:

    func addNotes(workingFile, notes string) error {
      notes += "\n"
      //…
      return nil
    }
    
  20. 接下来,我们将打开工作文件并允许向文件追加内容。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
    }
    
  21. 在打开文件并检查错误后,我们应该确保使用 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
    }
    
  22. 函数的最终步骤是将笔记的内容写入 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
    }
    
  23. main() 函数中,我们将初始化三个变量;backupFile 变量包含备份我们的 workingFile 变量的文件名,而 data 变量是我们将要写入 workingFile 变量的内容:

    func main() {
      backupFile := "backupFile.txt"
      workingFile := "note.txt"
      data := "note"
    
  24. 调用我们的 createBackup() 函数来备份我们的 workingFile。在调用函数后检查错误:

      err := createBackup(workingFile, backupFile)
      if err != nil {
      fmt.Println(err)
      os.Exit(1)
      }
    
  25. 创建一个循环,该循环将迭代 10 次。

  26. 在每次迭代中,我们将我们的 note 变量设置为 data 变量加上我们的循环的 i 变量。

  27. 由于我们的 note 变量是字符串,而我们的 i 变量是 int 类型,因此我们需要使用 strconv.Itoa(i) 方法将 i 转换为字符串。

  28. 调用我们的 addNotes() 函数,并传递 workingFile 和我们的 note 变量。

  29. 检查函数返回的任何错误:

      for i := 1; i <= 10; i++ {
      note := data + " " + strconv.Itoa(i)
      err := addNotes(workingFile, note)
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
      }
    }
    
  30. 运行程序:

    go run main.go
    
  31. 评估程序运行后文件的变化。

    以下是程序运行后的结果:

    ![图 12.19:备份文件的结果 图片

图 12.19:备份文件的结果

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)
  }
}

以下创建了一个 reader 类型并返回它:

r := csv.NewReader(strings.NewReader(in))

NewReader 方法接受一个 io.Reader 参数,并返回一个用于读取 CSV 数据的 Reader 类型:

for {
  record, err := r.Read()
  if err == io.EOF {
    break
  }

在这里,我们正在无限循环中逐个读取每个记录。在读取每个记录后,我们首先检查它是否是文件的末尾(io.EOF);如果是,则退出循环。r.Read() 函数读取一个记录;它是 r 变量的字符串切片。它返回一个 []string 类型的记录。

这里是打印记录的结果:

图 12.20:CSV 示例输出

图 12.20: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))
  header := true
  for {
  record, err := r.Read()
  if err == io.EOF {
    break
  }
  if err != nil {
    log.Fatal(err)
  }
  if !header {
    for idx, value := range record {
    switch idx {
    case 0:
      fmt.Println("First Name: ", value)
    case 1:
      fmt.Println("Last Name: ", value)
    case 2:
      fmt.Println("Age: ", value)
    }
    }
  }
  header = false
  }
}

我们将在本例中讨论代码的新部分:

header := true

我们将使用header变量作为标志。它将帮助我们解析 CSV 数据的标题:

  for {

无限循环将在到达文件末尾时停止:

  record, err := r.Read()
  if err == io.EOF {

r.Read()函数读取单个记录并返回一个包含该记录字段的字符串切片:

    break
  }
  // Code omitted for brevity  

如果是文件末尾,则跳出无限循环。

  if !header {

接下来,检查这是否是循环的第一个迭代。如果是循环的第一个迭代,那么第一行将是字段的标题;我们不希望解析标题:

    for idx, value := range record {

遍历记录中的字段:

    switch idx {  
    }

使用switch语句执行每个字段的特定解析:

    }
  }
  header = false
  }

初始设置为true,在第一次通过循环后,可以将其设置为false。标题通常是文件的第一行。

输出如下:

图 12.21:解析 CSV 字段的输出

图 12.21:解析 CSV 字段的输出

活动十二.01:解析银行交易文件

在这个活动中,我们将从银行获取交易文件。该文件是一个 CSV 文件。我们的银行还包括文件中交易的预算类别。文件如下:

id,payee,spent,category
1, sheetz, 32.45, fuel
2, martins,225.52,food
3, wells fargo, 1100, mortgage
4, joe the plumber, 275, repairs
5, comcast, 110, tv
6, bp, 40, fuel
7, aldi, 120, food
8, nationwide, 150, car insurance
9, nationwide, 100, life insurance
10, jim electric, 140, utilities
11, propane, 200, utilities
12, county water, 100, utilities
13, county sewer, 105, utilities
14, 401k, 500, retirement

本活动的目的是创建一个命令行程序,该程序将接受两个标志:CSV 银行交易文件的位置和日志文件的位置。在应用程序开始解析 CSV 文件之前,我们将检查日志和银行文件位置是否有效。程序将解析 CSV 文件,并将遇到的任何错误记录到日志中。每次程序重启时,它还将删除之前的日志文件。

按照以下步骤完成活动:

  1. 我们需要为fuelfoodmortgagerepairsinsuranceutilitiesretirement创建预算类别类型。

  2. 创建一个自定义错误,用于当找不到预算类别时。

  3. 创建一个具有IDpayeespentcategory字段的结构体类型transaction(这是我们在第一步中创建的类型)。

  4. 创建一个将接受来自银行交易文件的类别的函数。这个函数将交易类别映射到我们的类别。映射包括fuelgas映射到autoFuelfood映射到foodmortgage映射到mortgagerepairs映射到repairscar insurancelife insurance映射到insuranceutilities映射到utilities,其他所有内容将返回我们在上一步中创建的自定义错误。该函数将返回我们的budgetCategory类型和一个错误。

  5. 创建一个writeErrorToLog(msg string, err error, data string, logfile string) error.函数。这个函数将接受msgerrdata字符串并将它们写入日志文件。

  6. 创建一个具有以下签名的函数:parseBankFile(bankTransactions io.Reader, logFile string) []transaction。这个函数将遍历bankTransaction文件。在它循环时,使用switch语句并检查记录的索引。

    每个case语句将索引的值分配给transaction结构体中的相应值。当case语句的索引与 CSV 文件的类别匹配时,我们需要调用我们的convertToBudgetCategory()。这将把银行交易映射到我们的预算类别。

  7. main()函数中,我们需要两个c标志用于交易文件,以及一个l标志用于日志文件的位置。

  8. 银行交易文件和日志文件是必需的,因此你必须确保它们在继续之前存在。

  9. 然后调用parseBankFile()函数并打印从函数返回的[]transactions

    以下为输出:

图 12.22:交易文件格式

](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/go-ws/img/B14177_12_22.jpg)

图 12.22:交易文件格式

注意

本活动的解决方案可在第 737 页找到。

在这个活动中,我们创建了一个接受标志的命令行应用程序。我们还配置了我们的命令行应用程序以要求这些标志。在这个命令行应用程序中,我们创建和修改了文件。我们还解析了系统编程中常用的逗号分隔值(CSV)文件格式。我们能够从文件中读取并将数据存储在我们的各种结构类型中。当我们遇到错误时,我们能够继续处理 CSV 文件。当我们遇到错误时,我们将错误写入日志文件以供后续调试。这个命令行应用程序展示了在编程命令行应用程序中通常进行的实际活动(例如接受标志、要求标志、解析如 CSV 的文件、修改和创建文件以及记录)。

摘要

在本章中,我们了解了 Go 如何查看和使用文件权限。我们学习了文件权限可以用符号和八进制表示。我们发现 Go 标准库内置了对打开、读取、写入、创建、删除和向文件追加数据的功能的支持。我们探讨了flag包以及它是如何提供创建命令行应用程序以接受参数的功能的。

使用flag包,我们还可以打印出与我们的命令行应用程序相关的usage语句。

然后,我们演示了 OS 信号如何影响我们的 Go 程序;然而,通过使用 Go 标准库,我们可以捕获 OS 信号,并在适用的情况下控制我们希望如何退出我们的程序。

我们还了解到 Go 有一个用于处理 CSV 文件的标准库。在之前处理文件时,我们看到了我们还可以处理结构化为 CSV 文件的文件。Go CSV 包提供了遍历文件内容的能力。CSV 文件可以看作是类似于数据库表的行和列。在下一章中,我们将探讨如何连接到数据库并执行针对数据库的 SQL 语句。这将展示 Go 在需要后端存储数据的应用程序中的使用能力。

第十三章:13. SQL 和数据库

概述

本章的目标是帮助你使用 Go 编程语言连接到 SQL 数据库。

你将开始学习如何连接到数据库,在数据库中创建表,并将数据插入到表中以及从表中检索数据。到本章结束时,你将能够更新和删除特定表中的数据,还可以截断和删除表。

简介

在上一章中,你学习了如何与你的 Go 应用程序运行的系统进行交互。你了解了退出代码的重要性以及如何自定义脚本以接受参数,从而增加应用程序的灵活性。你还学习了如何掌握处理应用程序接收到的不同信号。

在本章中,你将通过学习如何在 Go 中使用 SQL 和数据库来进一步提高你的 Go 技能。作为一个开发者,如果没有对持久数据存储和数据库的适当理解,你将无法胜任。我们的应用程序处理输入并产生输出,但大多数情况下,如果不是所有情况,数据库都会参与到这个过程中。这个数据库可以是内存中的(存储在计算机的 RAM 中)或基于文件的(目录中的一个文件),它可以存在于本地或远程存储上。云可以为你提供数据库服务;Azure 和 AWS 都可以帮助你实现这一点。

我们在本章中旨在让你能够流利地与这些数据库进行交流,并理解数据库的基本概念。最后,随着你通过本章的学习,你应该扩展你的技能集,以使你成为一个更好的 Go 开发者。

假设你的老板要求你创建一个可以与数据库通信的 Go 应用程序。这里的“通信”意味着任何INSERTUPDATEDELETECREATE事务都应该由应用程序处理。本章将向你展示如何做到这一点。

数据库

为了使本章更具吸引力,让我们看看如何在你的系统上安装一个名为Postgres的数据库解决方案,并为你进行配置,以便你可以尝试以下示例。

首先,我们需要从packt.live/2RMFPYV获取安装程序。选择合适的版本。安装程序非常易于使用,我建议您接受默认设置:

  1. 运行安装程序:图 13.1:选择安装目录

    图 13.1:选择安装目录

  2. 保留默认组件:图 13.2:选择要安装的组件

    图 13.2:选择要安装的组件

  3. 保留默认数据目录:

图 13.3:选择数据目录

图 13.3:选择数据目录

它将要求输入密码,您需要记住这个密码,因为这是您数据库的主密码。Start!123 是本例的密码。数据库运行在本地的 5432 端口上。pgAdmin 图形界面工具也将被安装,一旦安装程序完成,您就可以启动 pgAdmin 来连接到数据库。

在浏览器中,可以使用以下链接访问管理界面:packt.live/2PKWc5w:

图 13.4:管理界面

图 13.4:管理界面

一旦安装完成,我们就可以继续进行下一部分,并通过 Go 连接到数据库。

数据库 API 和驱动程序

为了与数据库一起工作,有一种称为“纯”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 数据库

  • 搜索和分析数据库

连接到数据库

连接到数据库是最容易的事情;然而,我们需要记住一些事情。为了连接到任何数据库,我们至少需要四个条件。我们需要一个要连接的主机,我们需要连接到运行在端口的数据库,我们还需要用户名和密码。用户需要具有适当的权限,因为我们不仅想要连接,我们还希望执行特定的操作,例如查询、插入或删除数据,创建或删除数据库,以及管理用户和视图。让我们想象一下,连接到数据库就像作为一个特定的人拿着特定的钥匙走到门前。门是否打开取决于钥匙,但我们越过门槛后能做什么将取决于这个人(由他们的权限定义)。

在大多数情况下,数据库服务器支持多个数据库,并且数据库包含一个或多个表。想象一下,数据库是相互关联的逻辑容器。

让我们看看如何在 Go 中连接到数据库。为了连接,我们需要从 GitHub 获取适当的模块,这需要互联网连接。我们需要发出以下命令来获取与 Postgres 实例交互所需的包:

go get github.com/lib/pq

一旦完成,你就可以开始编写脚本了。首先,我们将初始化我们的脚本:

package main
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"

import _ <package name> 是一个特殊的 import 语句,它告诉 Go 仅为了其副作用而导入一个包。

注意

如果你需要更多信息,请访问 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() 函数,它接受各种参数。有简写的方式来完成这个任务,但我希望您了解所有参与建立连接的组件,所以我将使用较长的方法。稍后,您可以决定使用哪一个。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!")
}

在这种情况下,我们使用了 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。SQL 是一个标准,代表Postgresmysqlmssql服务器,它们都对CREATE TABLEINSERT命令以相同的方式响应,因为它们都符合 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"
import "database/sql"
import _ "github.com/lib/pq"

现在,我们准备定义我们的main()函数:

DBInit.go
5  func main(){
6    db, err := sql.Open("postgres", "user=postgres password=Start!123        host=127.0.0.1 port=5432 dbname=postgres sslmode=disable")
7    if err != nil {
8      panic(err)
9    }else{
10     fmt.Println("The connection to the DB was successfully          initialized!")
11   }
12 DBCreate := `
13   CREATE TABLE public.test
14   (
15     id integer,
16     name character varying COLLATE pg_catalog."default"
17   )
18   WITH (
19     OIDS = FALSE
20   )
The full code is available at: https://packt.live/34Ovy15

让我们分析这里发生了什么。我们初始化数据库连接,没有使用之前提到的默认用户名和密码,现在我们有了db变量来与数据库交互。除非执行过程中出现错误,否则以下输出将在我们的控制台上可见:

The connection to the DB was successfully initialized!
The table was successfully created!

如果我们重新运行脚本,将出现以下错误:

图 13.5:连续执行后的失败输出

图 13.5:连续执行后的失败输出

这表示表已经存在。我们创建了一个名为DBCreate的多行字符串,其中包含所有表创建信息。在这里,我们有一个名为test的表,它有一个名为id的整数列和一个名为name的字符串列。其余的是Postgres特定的配置。表空间定义了我们的表所在的位置。_, err行中的db.Exec()负责执行查询。

由于我们的目标是创建表,我们只关心是否有任何错误;否则,我们使用一个临时变量来捕获输出。如果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
import "fmt"
import "database/sql"
import _ "github.com/lib/pq"

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!")
}
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)
} else{
  fmt.Println("The value was successfully inserted!")
}
db.Close()
}

执行成功后,输出如下:

The connection to the DB was successfully initialized!
The vale was successfully inserted!

让我们看看插入部分发生了什么。db.Prepare()接受一个 SQL 语句,并赋予它防止 SQL 注入攻击的保护。它是通过限制变量替换的值来工作的。在我们的例子中,我们有两个列,所以为了使替换工作,我们使用$1 和$2。你可以使用任意数量的替换;你只需要确保它们在评估时产生一个有效的 SQL 语句。当insert变量初始化且没有错误时,它将负责执行 SQL 语句。它找出预定义语句期望多少个参数,它的唯一目的是调用语句并执行操作。insert.Exec(2,"second")插入一个新元素,id=2name='second'。如果我们检查我们的数据库,我们会看到结果。

现在我们表中已经有了一些数据,我们可以查询它。

练习 13.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. 创建一个用于后续使用的prop string变量:

    var prop 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 Messages 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()
    

    当你执行脚本时,你应该看到以下输出:

图 13.6:成功更新属性的输出

图 13.6:成功更新属性的输出

注意

由于长度原因,图 13.6中省略了部分输出。

在这个练习中,我们看到了如何在数据库中创建一个新的表,以及如何使用for循环和Prepare()语句插入新记录。

获取数据

SQL 注入不仅关注要插入的数据。它还关注在数据库中操纵的任何数据。检索数据,更重要的是,安全地检索数据,也是我们必须优先考虑和处理的事项。当我们查询数据时,我们的结果取决于我们连接的数据库和我们想要查询的表。但我们也必须提到,数据库引擎实施的安全机制也可能阻止成功查询,除非用户具有适当的权限。我们区分两种类型的查询。有一种查询不需要参数,例如 SELECT * FROM table,还有一种查询需要你指定过滤条件。Go 提供了两个允许你查询数据的函数。一个叫做 Query() 函数,另一个叫做 QueryRow() 函数。这些函数的可用性取决于你交互的数据库。作为一个经验法则,你应该记住 Query() 函数最有可能工作。你还可以用 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.Println(id, name)
}
err = rows.Err()
if err != nil {
  panic(err)
}
rows.Close()
db.Close()
}

输出应该看起来像这样:

The connection to the DB was successfully initialized!
2 second

由于我们之前已经将此数据插入到我们的数据库中,你可以根据之前的示例添加更多数据。我们定义了 idname 变量,这将有助于我们的 Scan() 函数。我们连接到数据库并创建我们的 db 变量。之后,我们将 rows 变量填充为 Query() 函数的结果,它基本上将包含表中的所有元素。接下来是难点。我们使用 for rows.Next() 来遍历结果行。但这还不够;我们希望将查询结果分配给相应的变量,该变量由 rows.Scan(&id, &name) 返回。这允许我们引用当前行的 ID 和 NAME,这使得我们可以更容易地处理值。最后,我们优雅地关闭 rows 和数据库连接。

让我们用 Prepare() 查询一行。

初始化看起来和之前一样。

DBPrepare.go
1  package main
2  import "fmt"
3  import "database/sql"
4  import _ "github.com/lib/pq"
The full code is available at: https://packt.live/376LxJo

主要区别在于 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.Println("The name column value is",name,"of the row with id=",id)
qryrow.Close()
db.Close()
}

如果你一切都做对了,输出应该看起来大致像这样:

The connection to the DB was successfully initialized!
The name column value is second of the row with id= 2

让我们仔细检查一下我们的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 `
The full code is available at: https://packt.live/371GoCy

我们更新了带有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

基本上,就是这样。经过一点修改,我们有一个可以更新或删除记录并验证的脚本。

现在,让我们看看我们如何创建一个包含质数的表。

练习 13.02:在数据库中存储质数

在这个练习中,我们基于练习 13.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)
        }
      }
    Numbers.Close()
    
  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()
    fmt.Println("The execution is now complete...")
    
  9. 关闭数据库连接:

    db.Close()
    

    一旦脚本执行,以下输出应该是可见的:

![图 13.7:计算输出]

图 13.7:计算输出

图 13.7:计算输出

在这个练习中,我们看到了如何利用内置的 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)
}

如果我们检查我们的数据库引擎,我们将找不到名为 test 的表的任何痕迹。这从数据库的表面彻底消除了整个表。

那个主题完全是关于通过 Go 编程语言与数据库交互。现在你对如何入门有了相当的了解。

注意

对于更多信息及额外细节,你应该查看 SQL API 的官方文档,packt.live/2Pi5oj5

活动十三.01:在表中存储用户数据

在这个活动中,我们将创建一个表,该表将存储用户信息,如 IDNameEmail。我们基于你在 创建表插入数据 部分获得的知识。

按照以下步骤完成此活动:

  1. 创建一个小脚本,用于创建一个名为 Users 的表。这个表必须有三个列:IDNameEmail

  2. 将两位用户及其数据详情添加到表中。他们应该有独特的名字、ID 和电子邮件地址。

  3. 然后你需要更新第一个用户的电子邮件地址为 user@packt.com 并删除第二个用户。确保所有字段都不是 NULL,ID 是主键,因此需要是唯一的。

  4. 当你在表中插入、更新和删除数据时,请使用 Prepare() 函数来防止 SQL 注入攻击。

  5. 你应该使用一个结构体来存储你想要插入的用户信息,并且在插入时,使用 for 循环遍历结构体。

  6. 一旦 insertupdatedelete 调用完成,确保在适当的时候使用 Close(),并最终关闭数据库连接。

    成功完成后,你应该会看到以下输出:

![图 13.8:可能的输出![图 13.8:可能的输出图 13.8:可能的输出注意本活动的解决方案可以在第 745 页找到。在本活动的结束时,你应该已经学会了如何创建一个名为 users 的新表以及如何向该表中插入数据。## 活动十三.02:查找特定用户的消息在这个活动中,我们将基于 活动 13.01在表中存储用户数据 进行扩展。我们需要创建一个名为 Messages 的新表。这个表将有两个列,两个列都应该有 280 个字符的限制:一个是 UserID,另一个是 Message。当你的表格准备就绪时,你应该添加一些带有用户 ID 的消息。确保你添加了 UserID,这个字段在 users 表中不存在。一旦你添加了数据,写一个查询,返回指定用户发送的所有消息。使用 Prepare() 函数来防止 SQL 注入。如果找不到指定的用户,则打印 查询未返回任何内容,没有这样的用户:<username>。你应该从键盘输入用户名。按以下步骤完成活动:1. 定义一个 struct,它包含 userID 和消息。1. 应该使用一个 for 循环来插入消息,该循环遍历之前定义的 struct。1. 当接收到用户输入时,确保你使用 Prepare() 语句来构建你的查询。 如果一切顺利,这将是你填入数据库的用户名和消息后的输出,具体取决于你如何填充数据库:![图 13.9:预期输出img/B14177_13_09.jpg

图 13.9:预期输出

注意

这个活动的解决方案可以在第 748 页找到。

如果你愿意,你可以调整脚本,以便在连续运行时不要尝试重新创建数据库。

在完成这个活动后,你应该学会如何创建一个名为 Messages 的新表,然后从用户那里获取输入,并根据输入搜索相关的用户和消息。

摘要

本章使你在与 SQL 数据库交互方面变得高效。你学习了如何创建、删除和操作数据库表。你还意识到了 Go 适合与之交互的所有不同类型的数据库。由于本章是以 PostgreSQL 引擎为背景编写的,你应该熟悉它的 Go 模块。有了这些知识,你现在将能够使用 Go 语言在数据库编程领域迈出自己的步伐,并且能够自给自足,因为你知道在哪里寻找问题的解决方案和额外的知识。这种知识最常见的用例是当你必须构建自动报告应用程序,从数据库中提取数据并以电子邮件的形式报告时。另一个用例是你有一个自动应用程序,用于将数据推送到数据库服务器,该服务器处理 CSV 文件或 XML 文件。这完全取决于你所处的具体情况。

在下一章中,你将学习如何通过 HTTP 客户端与 Web 界面交互,这是 Go 中最有趣的主题之一。

第十四章:14. 使用 Go HTTP 客户端

概述

本章将使你能够使用 Go HTTP 客户端通过互联网与其他系统进行通信。

你将首先学习如何使用 HTTP 客户端从 Web 服务器获取数据并向 Web 服务器发送数据。到本章结束时,你将能够将文件上传到 Web 服务器,并尝试使用自定义 Go HTTP 客户端与 Web 服务器交互。

简介

在上一章中,你学习了 SQL 和数据库。你学习了如何执行查询,如何创建表,如何向表中插入数据并获取数据,如何更新数据,以及如何在表中删除数据。

在本章中,你将学习 Go HTTP 客户端及其使用方法。HTTP 客户端是一种用于从或向 Web 服务器获取或发送数据的工具。最著名的 HTTP 客户端例子是网页浏览器(如 Firefox)。当你在一个网页浏览器中输入一个网址时,它会内置一个 HTTP 客户端,该客户端会向服务器发送数据请求。服务器会收集数据并将其发送回 HTTP 客户端,然后客户端会在浏览器中显示网页。同样,当你在一个网页浏览器中填写表单时,例如登录一个网站,浏览器会使用其 HTTP 客户端将表单数据发送到服务器,并根据响应采取适当的行动。

本章将探讨如何使用 Go HTTP 客户端从 Web 服务器请求数据并向服务器发送数据。你将检查可以使用 HTTP 客户端与 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 客户端来对发送的请求有更多的控制。

向服务器发送请求

当你想从网络服务器检索数据时,你需要向服务器发送一个 GET 请求。在发送请求时,URL 将包含你想要获取数据的资源信息。URL 可以分解为几个关键部分。这包括协议、主机名、URI 和查询参数。其格式看起来像这样:

![图 14.1:URL 格式分解]

图片

图 14.1:URL 格式分解

在这个例子中:

  • 协议告诉客户端如何连接到服务器。最常用的两种协议是 HTTP 和 HTTPS。在这个例子中,我们使用了https

  • 主机名是我们想要连接的服务器的地址。在这个例子中,它是example.com

  • URI/downloads

  • 查询参数告诉服务器它需要任何额外的信息。在这个例子中,我们有两个参数。这些是filter=latestos=windows。你会注意到它们与 URI 由?分隔。这样服务器就可以从请求中解析它们。我们将任何额外的参数与 URI 的末尾通过&符号连接,就像os参数中看到的那样。

练习 14.01:使用 Go HTTP 客户端向 Web 服务器发送 GET 请求

在这个练习中,你将从一个网络服务器获取数据并打印出这些数据。你将向www.google.com发送一个 GET 请求并显示服务器返回的数据:

注意

对于这个主题,你需要在你的系统上安装 Go 并设置 GOPATH。你还需要一个 IDE,你可以用它来编辑.go文件。

  1. 打开你的 IDE,在你的 GOPATH 中创建一个新的目录Exercise14.01。在该目录内,创建一个新的 Go 文件,名为main.go

  2. 由于这是一个新的程序,您可能希望将文件的包设置为 main() 函数。导入 net/http 库、log 库和 io/ioutil 库。输入以下代码:

    package main
    import (
        "io/ioutil"
        "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/ioutil 库中的 ReadAll 函数。这两个函数组合起来将看起来像这样:

        defer r.Body.Close()
        data, err := ioutil.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 := ioutil.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 server.go
    

    程序将向 www.google.com 发送 GET 请求,并在您的终端中记录响应。

    虽然看起来像是乱码,但如果将那些数据保存到名为 response.html 的文件中,并在您的网页浏览器中打开它,它将类似于谷歌首页。这就是当您打开网页时,您的网页浏览器在底层所做的事情。它会向服务器发送一个 GET 请求,然后显示它返回的数据。如果我们手动做这件事,它看起来会像这样:

![图 14.2:在 Firefox 中查看时请求 HTML 响应

![图片 B14177_14_02.jpg]

图 14.2:在 Firefox 中查看时请求 HTML 响应

在这个练习中,我们看到了如何向网络服务器发送 GET 请求并获取数据。您创建了一个 Go 程序,向 www.google.com 发送请求,并获取了谷歌首页的 HTML 数据。

结构化数据

一旦从服务器请求数据,返回的数据可以以各种格式出现。例如,如果您向 packtpub.com 发送请求,它将返回 Packt 网站的 HTML 数据。虽然 HTML 数据对于显示网站很有用,但它不是发送机器可读数据的理想选择。在 Web API 中常用的数据类型是 JSON。JSON 为机器可读和人类可读的数据提供了良好的结构。稍后,您将学习如何使用 Go 解析 JSON 并利用它。

练习 14.02:使用结构化数据与 HTTP 客户端

在这个练习中,你将使用 Go 解析结构化的 JSON 数据。服务器将返回 JSON 数据,你将使用json.Unmarshal函数来解析数据并将其放入结构体中:

  1. 在你的 GOPATH 中创建一个新的目录,命名为Exercise14.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{}))
    }
    

    这创建了一个非常基础的 Web 服务器,它发送回 JSON 数据。我们将在下一章中更详细地解释它是如何工作的。现在,我们只是将其作为一个例子。

  2. 一旦创建了服务器,导航到客户端目录并创建一个名为main.go的文件。添加package main并导入文件所需的包:

    package main
    import (
        "encoding/json"
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
    )
    
  3. 然后,创建一个带有字符串参数的结构体,它可以接受来自服务器的响应。然后,向其中添加 JSON 元数据,以便它可以用于反序列化 JSON message参数:

    type messageData struct {
        Message string `json:"message"`
    }
    
  4. 接下来,创建一个你可以调用的函数,用于从服务器获取并解析数据。使用你刚刚创建的结构体作为返回值:

    func getDataAndReturnResponse() messageData {
    

    当你运行 Web 服务器时,它将在http://localhost:8080上监听。因此,你需要向该 URL 发送 GET 请求,然后读取响应体:

        r, err := http.Get("http://localhost:8080")
        if err != nil {
            log.Fatal(err)
        }
        defer r.Body.Close()
        data, err := ioutil.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目录并运行以下命令。这将启动 Web 服务器:

    go run server.go
    
  9. 在第二个终端窗口中,导航到client目录并运行go run main.go。这将启动客户端并连接到服务器。它应该输出服务器的消息:

![图 14.3:预期输出]

![图片 B14177_14_03.jpg]

图 14.3:预期输出

在这个练习中,你向服务器发送了一个 GET 请求,并从它那里获取了结构化的 JSON 格式的数据。然后你解析了这些 JSON 数据以获取其中的消息。

活动十四点零一:从 Web 服务器请求数据并处理响应

假设你正在与一个 Web API 交互。你发送一个 GET 请求以获取数据,并返回一个包含名称的数组。你需要计数这些名称以找出你有多少个每种名称。在这个活动中,你将做这件事。你将向服务器发送 GET 请求,获取结构化的 JSON 数据,解析数据,并计算在响应中返回了多少个每种名称:

  1. 创建一个名为Activity14.01的目录。

  2. 创建两个子目录,一个命名为client,另一个命名为server

  3. server目录中,创建一个名为server.go的文件。

  4. server.go中添加服务器代码。

  5. 通过在服务器目录中调用go run server.go来启动服务器。

  6. client目录中创建一个名为main.go的文件。

  7. main.go中添加必要的导入。

  8. 创建结构体以解析响应数据。

  9. 创建一个名为getDataAndParseResponse的函数,它返回两个整数。

  10. 向服务器发送一个GET请求。

  11. 将响应解析到结构体中。

  12. 遍历结构体并计算ElectricBoogaloo这两个名称的出现次数。

  13. 返回计数。

  14. 打印计数。

    预期的输出如下:

图 14.4:可能的输出

图 14.4:可能的输出

注意

本活动的解决方案可以在第 752 页找到。

在这个活动中,我们已经从 Web 服务器请求数据,并使用 Go HTTP 客户端处理它返回的数据。

向服务器发送数据

除了从服务器请求数据外,你还会想向服务器发送数据。最常见的方法是通过 POST 请求。POST 请求由两个主要部分组成:URL 和主体。POST 请求的主体是你放置要发送到服务器的数据的地方。一个常见的例子是登录表单。当我们发送登录请求时,我们将主体 POST 到 URL。Web 服务器将检查主体中的登录详情是否正确,并更新我们的登录状态。它通过告诉客户端是否成功来响应请求。在本章中,你将学习如何使用 POST 请求向服务器发送数据。

练习 14.03:使用 Go HTTP 客户端向 Web 服务器发送 POST 请求

在这个练习中,你将向一个包含消息的 Web 服务器发送一个 POST 请求。Web 服务器将随后以相同的消息响应,以便你可以确认它已接收:

  1. 在你的 GOPATH 上创建一个新的目录,Exercise14.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. 一旦创建了服务器,导航到客户端目录并创建一个名为main.go的文件。添加package main和文件所需的导入:

    package main
    import (
        "bytes"
        "encoding/json"
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
    )
    
  3. 接下来,你需要创建一个结构体来发送和接收我们想要的数据。这将与服务器用于解析请求的结构体相同:

    type messageData struct {
        Message string `json:"message"`
    }
    
  4. 然后你需要创建一个函数来将数据 POST 到服务器。它应该接受一个messageData结构体参数,并返回一个messageData结构体:

    func postDataAndReturnResponse(msg messageData) messageData {
    
  5. 要将数据 POST 到服务器,你需要将结构体序列化为客户端可以发送到服务器的字节。为此,你可以使用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 := ioutil.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。这将启动网络服务器。在第二个终端窗口中,导航到client目录并运行go run main.go。这将启动客户端并连接到服务器。它应该输出来自服务器的消息:

图 14.5:预期输出

图 14.5:预期输出

在这个练习中,你向服务器发送了一个 POST 请求。服务器解析了请求,并将相同的信息发送回你。如果你更改发送给服务器的消息,你应该看到服务器发送回的新消息的响应。

在 POST 请求中上传文件

你可能想要 POST 到网络服务器的另一个常见数据示例是来自你本地计算机的文件。这就是网站允许用户上传他们的照片等的方式。正如你可以想象的那样,这比发送简单的表单数据要复杂一些。为了实现这一点,首先需要读取文件,然后将其包装成服务器可以理解的形式。然后,它可以作为一个多部分表单发送到服务器。你将学习如何使用 Go 读取文件并将其上传到服务器。

练习 14.04:通过 POST 请求将文件上传到网络服务器

在这个练习中,你将读取一个本地文件,然后将其上传到网络服务器。然后你可以检查网络服务器是否保存了你上传的文件:

  1. 在你的 GOPATH 上创建一个新的目录,Exercise14.04。在该目录内,创建另外两个目录,serverclient。然后,在server目录内,创建一个名为server.go的文件,并编写以下代码:

    server.go
    9  func (srv server) ServeHTTP(w http.ResponseWriter, r      *http.Request) {
    10     uploadedFile, uploadedFileHeader, err :=          r.FormFile("myFile")
    11     if err != nil {
    12         log.Fatal(err)
    13     }
    14     defer uploadedFile.Close()
    15     fileContent, err := ioutil.ReadAll(uploadedFile)
    16     if err != nil {
    17         log.Fatal(err)
    18     }
    The full code for this step is available at: https://packt.live/2SkeZHW
    

    这创建了一个非常基本的网络服务器,它可以接收多部分表单 POST 请求并在表单内保存文件。

  2. 一旦你创建了服务器,导航到客户端目录并创建一个名为main.go的文件。添加package main和文件所需的导入:

    package main
    import (
        "bytes"
        "fmt"
        "io"
        "io/ioutil"
        "log"
        "mime/multipart"
        "net/http"
        "os"
    )
    
  3. 然后你需要创建一个函数来调用,并将文件名提供给该函数。该函数将读取文件,将其上传到服务器,并返回服务器的响应:

    func postFileAndReturnResponse(filename string) string {
    
  4. 你需要创建一个缓冲区,可以将文件字节写入其中,然后创建一个写入器,以便字节可以写入其中:

        fileDataBuffer := bytes.Buffer{}
        multipartWritter := multipart.NewWriter(&fileDataBuffer)
    
  5. 使用以下命令从你的本地计算机打开文件:

        file, err := os.Open(filename)
        if err != nil {
            log.Fatal(err)
        }
    
  6. 一旦你打开了本地文件,你需要创建一个formFile。这会将文件数据包装在正确的格式中,以便上传到服务器:

        formFile, err := multipartWritter.CreateFormFile("myFile",       file.Name())
        if err != nil {
            log.Fatal(err)
        }
    
  7. 将本地文件的字节复制到表单文件中,然后关闭表单文件写入器,以便它知道不会再添加更多数据:

        _, err = io.Copy(formFile, file)
        if err != nil {
            log.Fatal(err)
        }
        multipartWritter.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",       multipartWritter.FormDataContentType())
    
  10. 按照以下方式发送请求:

        response, err := http.DefaultClient.Do(req)
        if err != nil {
            log.Fatal(err)
        }
    
  11. 在你发送请求后,我们可以读取响应并返回其中的数据:

        defer response.Body.Close()
        data, err := ioutil.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 main.go。这将启动客户端并连接到服务器:

    go run server.go
    
  16. 客户端将读取test.txt并将其上传到服务器。客户端应该给出以下输出:

图 14.6:预期的客户端输出

图 14.6:预期的客户端输出

然后,如果你导航到server目录,你应该会看到test.txt文件已经出现:

图 14.7:预期的客户端输出

图 14.7:预期的客户端输出

在这个练习中,你使用 Go HTTP 客户端向 Web 服务器发送了一个文件。你从磁盘读取文件,将其格式化为 POST 请求,并将数据发送到服务器。

自定义请求头

有时候,请求不仅仅是请求或发送数据。这些信息存储在请求头中。一个非常常见的例子是授权头。当你登录到服务器时,它将响应一个授权令牌。在所有未来发送到服务器的请求中,你都会在请求头中包含此令牌,以便服务器知道是你正在发起请求。你将在稍后学习如何将授权令牌添加到请求中。

练习 14.05:使用 Go HTTP 客户端的自定义头和选项

在这个练习中,您将创建自己的 HTTP 客户端并设置自定义选项。您还将设置一个授权令牌在请求头中,以便服务器知道是您请求的数据:

  1. 在您的 GOPATH 中创建一个新的目录,名为Exercise14.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. 一旦创建了服务器,导航到客户端目录并创建一个名为main.go的文件。添加package main和文件所需的导入:

    package main
    import (
        "fmt"
        "io/ioutil"
        "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 := ioutil.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 main.go
    

    这将启动客户端并连接到服务器。客户端将向服务器发送请求,并在 10 秒后输出以下内容:

    图 14.8:预期输出

图 14.8:预期输出

注意

将客户端的超时设置改为低于 10 秒,并查看会发生什么。您还可以更改或删除请求上的授权头,并查看会发生什么。

在这个练习中,您学习了如何向请求添加自定义头。您学习了添加授权头的常见示例,这在您想要与许多 API 交互时是必需的。

活动 14.02:使用 POST 和 GET 向 Web 服务器发送数据并检查数据是否被接收

想象你正在与一个网络 API 交互,并且你希望向网络服务器发送数据。然后你想要检查数据是否已添加。在这个活动中,你将做这件事。你将向服务器发送一个 POST 请求,然后使用 GET 请求请求数据,解析数据,并将其打印出来。

按照以下步骤获取期望的结果:

  1. 创建一个名为Activity14.02的目录。

  2. 创建两个子目录,一个名为client,另一个名为server

  3. server目录下,创建一个名为server.go的文件。

  4. 将服务器代码添加到server.go文件中。

  5. 通过在服务器目录中调用go run server.go来启动服务器。

  6. client目录下,创建一个名为main.go的文件。

  7. main.go中添加必要的导入。

  8. 创建结构体以存储请求数据。

  9. 创建结构体以解析响应数据。

  10. 创建一个addNameAndParseResponse函数,将名字发送到服务器。

  11. 创建一个getDataAndParseResponse函数,用于解析服务器响应。

  12. 向服务器发送 POST 请求,以添加名字。

  13. 向服务器发送 GET 请求。

  14. 将响应解析到结构体中。

  15. 遍历结构体并打印名字。

    这是预期的输出:

![图 14.9:可能的输出图片

图 14.9:可能的输出

注意

这个活动的解决方案可以在第 754 页找到。

在这个活动中,你看到了如何使用 POST 请求向网络服务器发送数据,然后如何使用 GET 请求请求数据以确保它已更新。在专业编程中,以这种方式与服务器交互是非常常见的。

摘要

HTTP 客户端用于与网络服务器交互。它们用于向服务器发送不同类型的请求(例如,GET 或 POST 请求),然后对服务器返回的响应做出反应。网络浏览器是一种 HTTP 客户端,它将向网络服务器发送 GET 请求并显示它返回的 HTML 数据。在 Go 中,你创建了自定义的 HTTP 客户端并做了同样的事情,发送一个 GET 请求到www.google.com,然后记录服务器返回的响应。你还了解到 URL 的组成部分,以及你可以通过更改 URL 来控制从服务器请求的内容。

网络服务器不仅仅是请求 HTML 数据。你了解到它们可以以 JSON 的形式返回结构化数据,这些数据可以被解析并在你的代码中使用。数据也可以通过 POST 请求发送到服务器,允许你将表单数据发送到服务器。然而,发送到服务器的数据并不仅限于表单数据:你还可以使用 POST 请求上传文件到服务器。

也有方法自定义你发送的请求。你了解到常见的例子是授权,你会在 HTTP 请求的头部添加一个令牌,这样服务器就可以知道是谁在发起这个请求。

在本章中,你在练习中使用了某些基本的 Web 服务器。然而,你并没有学习到它们具体做了什么。在下一章中,你将更详细地了解 Web 服务器。

第十五章:15. HTTP 服务器

概述

本章向您介绍创建 HTTP 服务器以接受来自互联网的请求的不同方法。您将能够理解网站是如何被访问的,以及它如何响应一个表单。您还将学习如何响应来自另一个软件程序的请求。

您将能够创建一个渲染简单消息的 HTTP 服务器。您将学习如何创建一个渲染复杂数据结构并服务于本地静态文件的 HTTP 服务器。进一步,您将创建一个渲染动态页面并处理不同路由方式的 HTTP 服务器。到本章结束时,您还将学习如何创建 REST 服务,通过表单接收数据,以及接收 JSON 数据。

简介

在上一章中,我们看到了如何联系远程服务器以获取一些信息,但现在我们将深入了解远程服务器是如何创建的,所以如果你已经知道如何请求信息,现在你将看到如何回复这些请求。

网络服务器是一个使用 HTTP 协议的程序,因此,HTTP 服务器用于接受来自任何 HTTP 客户端(网页浏览器、另一个程序等)的请求,并以适当的消息响应它们。当我们用浏览器浏览互联网时,它将是一个 HTTP 服务器,它会向我们的浏览器发送一个 HTML 页面,我们就能看到它。在某些其他情况下,服务器不会返回一个 HTML 页面,而是返回一个适合客户端的不同消息。

一些 HTTP 服务器提供了一个可以被另一个程序使用的 API。想想当你想要注册一个网站时,你会被问是否想要通过 Facebook 或 Google 注册。这意味着你想要注册的网站将消耗一个 Google 或 Facebook API 来获取你的详细信息。这些 API 通常会以结构化文本的形式响应,这是一种表示复杂数据结构的文本。这些服务器期望请求的方式可能不同。有些期望返回相同类型的结构化消息,而有些提供所谓的 REST API,它对使用的 HTTP 方法非常严格,并期望以 URL 参数或值的形式输入,就像网页表单中的那些。

如何构建基本服务器

我们可以创建的最简单的 HTTP 服务器就是一个 Hello World 服务器。这是一个将返回简单消息“Hello World”且不会做其他任何事情的服务器。它并不非常实用,但它是了解 Go 默认包提供的内容的起点,也是任何更复杂服务器的基石。目标是创建一个在您的机器的 localhost 上的特定端口运行的服务器,并接受其下的任何路径。接受任何路径意味着当您用浏览器测试服务器时,它总是会返回“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()是一个函数,它将使用我们的处理器来处理请求;任何实现了处理器接口的结构体都是可以的。然而,我们需要让我们的服务器做些事情。

如您所见,ServeHTTP方法接受一个ResponseWriter和一个Request对象。您实际上可以使用它们来从请求中捕获参数并将消息写入响应。例如,最简单的事情就是让我们的服务器返回一条消息:

func(h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  w.Write([]byte("HI"))
}

ListenAndServe方法可能会返回一个错误。如果发生这种情况,我们很可能希望程序执行停止,因此一个常见的做法是将这个函数调用用致命日志包装起来:

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

这将使执行停止并打印出ListenAndServe函数返回的错误信息。

练习 15.01:创建一个 Hello World 服务器

让我们从之前的学习内容出发,开始构建一个简单的 Hello World HTTP 服务器。

首先要做的事情是创建一个名为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. 现在我们有了我们的handler,创建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文件夹内,并输入以下命令:

    hello-world-server go run .
    

    你应该什么也看不到;程序已经开始运行。

  6. 如果你现在在以下地址打开你的浏览器:

    http://localhost:8080
    

    你应该看到一个带有大消息的页面:

图 15.01:Hello world 服务器

图 15.01:Hello world 服务器

如果你现在尝试更改路径并访问/page1,你将再次看到以下信息:

图 15.02:Hello world 服务器子页面

图 15.02:Hello world 服务器子页面

恭喜!这是你的第一个 HTTP 服务器。

在这个练习中,我们创建了一个基本的 hello world 服务器,它对任何子地址上的任何请求都返回“Hello World”消息。

简单路由

在上一个练习中刚刚构建的服务器并不做什么。它只是响应一条消息,我们无法询问其他任何内容。在我们能够使我们的服务器更加动态之前,让我们想象我们想要创建一个在线书籍,并且我们想要能够通过更改 URL 来选择章节。目前,如果我们浏览以下页面:

http://localhost:8080
http://localhost:8080/hello
http://localhost:8080/chapter1

我们总是看到相同的信息,但现在我们想要将不同的消息与服务器上的不同路径关联起来。我们将通过向服务器引入一些简单的路由来实现这一点。

路径是你看到 URL 中的8080之后的内容;它可以是单个数字、一个单词、一组由斜杠分隔的数字或字符组。为了做到这一点,我们将使用 net/http 包的另一个函数,它是:

HandleFunc(pattern string, handler func(ResponseWriter, *Request))

这里,模式是我们想要由handler函数服务的路径。注意handler函数签名与你在上一个练习中添加到hello结构中的ServeHTTP方法具有完全相同的参数。

例如,练习 15.01中构建的服务器并不很有用,但我们可以通过添加除了hello world之外的其他页面来将其转变为更有用的东西,为了做到这一点,我们需要做一些基本的路由。这里的目的是写一本书,这本书必须有一个带有标题的欢迎页面,以及第一章。书名是hello world,所以我们可以保留之前所做的工作。第一章将有一个标题声明第一章。这本书还在进行中,所以内容仍然很贫乏并不重要;我们需要的是能够选择章节的能力,然后我们将在稍后添加内容。

练习 15.02:路由我们的服务器

我们现在将修改练习 15.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. 现在,保存您的文件并再次使用以下命令运行服务器:

    hello-world-server go run main.go
    
  6. 然后,转到您的浏览器并加载以下 URL:

    http://localhost:8080

    http://localhost:8080/chapter1

    以下截图显示了主页的输出:

图 15.03:多页服务器 – 主页

图 15.03:多页服务器 – 主页

页面 1 的输出如下截图所示:

图 15.04:多页服务器 – 第一章

图 15.04:多页服务器 – 第一章

注意,它们仍然显示相同的信息。这是因为我们将我们的 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 路径现在返回新的信息:

图 15.05:多页服务器重复 – 第一章

图 15.05:多页服务器重复 – 第一章

同时,所有其他路径都返回旧的 Hello World 信息。

图 15.06:多页服务器 – 基础页面

图 15.06:多页服务器 – 基础页面

图 15.07:未设置的页面返回默认设置

图 15.07:未设置的页面返回默认设置

处理器与处理器函数

如您可能已注意到的,我们之前使用了两个不同的函数,http.Handlehttp.HandleFunc,这两个函数都以路径作为它们的第一个参数,但在第二个参数方面有所不同。这两个函数都确保特定的路径由一个函数处理。然而,http.Handle 期望 http.Handler 处理路径,而 http.HandleFunc 期望一个函数来做同样的事情。

如我们之前所见,http.Handler 是任何具有此签名的结构体:

ServeHTTP(w http.ResponseWriter, r *http.Request)

因此,在两种情况下,都始终会有一个以 http.ResponseWriter*http.Request 为参数的函数来处理路径。至于何时选择其中一个,在很多情况下可能只是个人偏好的问题,但在创建复杂项目时,例如,选择正确的方法可能很重要。这样做将确保项目的结构是最优的。不同的路由如果由属于不同包的处理程序处理,可能会显得更有组织,或者可能需要执行非常少的操作,就像我们之前的例子一样;而一个简单的函数可能就是理想的选择。

通常,对于只有几个简单页面的简单项目,你可以选择 HandleFunc。例如,假设你想要有静态页面,并且每个页面没有复杂的行为。在这种情况下,仅仅为了返回静态文本而使用一个空的空结构体将是过度设计。当需要设置一些参数或想要跟踪某些内容时,处理程序更为合适。作为一个一般规则,如果我们说,如果你有一个计数器,Handler 是最佳选择,因为你可以用一个计数为 0 的 struct 来初始化,然后增加它,但我们将这在 活动 15.01 中看到。

活动 15.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 函数,并在其中实现以下内容:

  7. 实例化三个PageWithCounter类型的处理器,分别带有Hello WorldChapter 1Chapter 2的标题和一些内容。

  8. 将三个处理器添加到路由//chapter1/chapter2

  9. 在端口8080上运行服务器。

当你运行服务器时,你应该看到以下内容:

![图 15.08:第一次运行服务器时的浏览器输出图片

图 15.08:第一次运行服务器时的浏览器输出

如果你刷新页面,你应该看到以下内容:

![图 15.09:第二次运行服务器时的浏览器输出图片

图 15.09:第二次运行服务器时的浏览器输出

接下来,在地址栏中输入localhost:8080/chapter1以导航到第一章。你应该能看到以下类似的内容:

![图 15.10:第一次访问 chapter1 页面时的浏览器输出图片

图 15.10:第一次访问 chapter1 页面时的浏览器输出

类似地,导航到第二章,你应该能看到以下查看次数的增加:

![图 15.11:第一次访问 chapter2 页面时的浏览器输出图片

图 15.11:第一次访问 chapter2 页面时的浏览器输出

当你再次访问第一章时,你应该看到如下所示的查看次数增加:

![图 15.12:第二次访问 chapter1 页面时的浏览器输出图片

图 15.12:第二次访问 chapter1 页面时的浏览器输出

注意

本活动的解决方案可以在第 757 页找到

在这个活动中,你学习了如何创建一个服务器,该服务器能够对不同页面上的不同请求做出特定静态文本的响应,并在每个页面上都有一个计数器,每个计数器与其他计数器独立。

返回复杂数据结构

到目前为止我们所看到的内容在构建网站时是有用的,尽管,为了这个目的,我们仍然需要了解如何更好地渲染 HTML 页面。你可能想使用像 revelgin 这样的框架来完成这个任务,尽管纯 Go 加上几个库对于生产级别的网站来说已经足够了。然而,你会发现 HTTP 服务器不仅用于构建网站,还用于构建网络服务,尤其是现在,微服务。尽管如何构建基于网络服务的项目超出了本章和本书的范围,但了解如何让你的 HTTP 服务器为不会通过浏览器由人类消费,而是由另一个程序消费的内容提供服务是很重要的。你可能已经知道什么是网络服务,即使你不知道,你也可能需要在需要修改网络服务的现有项目上工作。有几种方式可以向另一个程序(称为客户端)传递消息,但通常,它们都将涉及某种形式的结构化文本,这些文本可以很容易地被解析。格式可以是 XML 字符串,但现在最常见且轻量级的格式是 JSON。在下一个练习中,我们将看到如何构建一个数据结构并将其以 JSON 字符串的形式发送给客户端(无论是浏览器还是另一个程序)。

活动 15.02:使用 JSON 有效负载处理请求

在这个活动中,你将创建一个数据结构,并通过 HTTP 服务器提供服务。你将利用你已经学到的关于 JSON 和结构体的编码/解码的知识,并将其与关于 HTTP 服务器的知识结合起来。你可能已经猜到了,但在这个练习中,你已经拥有了完成它所需的所有知识,你应该能够独立完成它。现在让我们再建一本书。标题和章节都是相同的,但这次我们希望让它能够被一个程序访问,该程序将作为 JSON 文档消费服务器上的页面。该文档还将包括每个章节的查看次数,以便代码可以利用在 活动 15.01 中生成的代码。步骤如下:

  1. 创建一个新的文件夹名为 book-api

  2. 在该文件夹中创建一个名为 main.go 的文件。

  3. 添加所需的导入。

  4. 创建一个名为 PageWithCounter 的结构体,表示一个具有标题、内容和计数器的书籍,如果需要,可以添加适当的 JSON 标签。

  5. 向结构体添加一个 ServeHTTP 方法,能够以 JSON 文档的形式显示内容、标题以及包含总查看次数的消息。

  6. 创建 main() 函数。

  7. 实例化三个 PageWithCounter 类型的处理器,标题分别为 Hello WorldChapter 1Chapter 2,并包含一些内容。

  8. 将三个处理器添加到路由 //chapter1/chapter2

  9. 在端口 8080 上运行服务器。

运行你的服务器,你应该能看到分配的以下路由:

图 15.13:当处理器为/时的预期输出

]

图 15.13:当处理器为 / 时的预期输出

图 15.14:当处理器为 /chapter1 时的预期输出

图 15.14:当处理器为 /chapter1 时的预期输出

图 15.15:当处理器为 /chapter2 时的预期输出

图 15.15:当处理器为 /chapter2 时的预期输出

在这个活动中,你学习了如何通过 HTTP 服务器返回复杂结构。任何类型的复杂数据结构都可以通过这种方式提供,使用标准的格式,如 JSON。

注意

本活动的解决方案可以在第 761 页找到

动态内容

仅提供静态内容的服务器是有用的,但可以做更多的事情。HTTP 服务器可以根据更细粒度的请求提供内容,这是通过向服务器传递一些参数来完成的。有很多种方法可以做到这一点,但一种简单的方法是将参数传递给 querystring。如果服务器的 URL 是:

http://localhost:8080

然后,我们可以添加如下内容:

http://localhost:8080?name=john

在这里,?name=john 这部分被称为 querystring,因为它是一个表示查询的字符串。在这种情况下,这个 querystring 设置了一个名为 name 的变量,其值为 john。这种方式通常用于 GET 请求中传递参数,而 POST 请求通常使用请求体来发送参数。这并不意味着 GET 请求没有请求体,但不是向 GET 请求传递参数的标准方式。我们将首先查看如何接受 GET 请求的参数,因为这种请求只需在特定地址上打开浏览器即可完成。我们将在稍后看到如何通过表单处理 POST 请求。

在下一个练习中,你将能够返回不同的文本作为对 HTTP 请求的响应,其中文本取决于用户在地址栏中的 querystring 中输入的值。

练习 15.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. 现在,使用请求的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代码(错误请求)写入头部,同时向响应写入器发送一条消息,说明名称尚未作为参数发送。我们使用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 的输出,其中两个是有效的,一个是缺少参数的:

![图 15.16:请求带有名称“john”的页面时的服务器输出]

![图片 B14177_15_16.jpg]

图 15.16:请求带有名称“john”的页面时的服务器输出

![图 15.17:请求带有名称的页面时的服务器输出]

![图片 B14177_15_17.jpg]

图 15.17:请求带有名称的页面时的服务器输出

![图 15.18:请求不带名称的页面时服务器输出的错误消息]

![图片 B14177_15_18.jpg]

图 15.18:请求不带名称的页面时服务器输出的错误消息

模板化

虽然当需要在软件程序之间共享复杂数据结构时,JSON 可能是最佳选择,但在一般情况下,当 HTTP 服务器打算由人类消费时,情况并非如此。在之前的练习和活动中,选择格式化文本的方式是 fmt.Sprintf 函数,这对于格式化文本是好的,但在需要更动态和复杂的文本时,它就远远不够了。正如您在之前的练习中已经注意到的,当将名称作为参数传递给观察到的 URL 时,返回的消息遵循特定的模式,这就是新概念出现的地方——模板。本质上,模板就像是一个带有一些空白的文本,模板引擎将取一些值并填充这些空白,正如您在以下图中可以看到的那样:

图 15.19:模板示例

图片 B14177_15_19.jpg

图 15.19:模板示例

如您所见,{{name}} 是一个占位符,当值传递给引擎时,占位符会与该值一起修改。

我们无处不在都能看到模板。我们有用于 Word 文档的模板,我们只需填写缺失的内容,就可以生成彼此不同的新文档。一位教师可能有一些用于他们课程的模板,并会从这个相同的模板中开发出不同的课程。Go 提供了两个不同的模板包,一个用于文本,一个用于 HTML。由于我们正在处理 HTTP 服务器,并且我们想要生成网页,我们将使用 HTML 模板包,但接口与文本模板库相同。尽管模板包对于任何实际应用都足够好,但还有几个其他外部包可以用来提高性能。其中之一是 hero 模板引擎,它比标准的 Go 模板包要快得多。

Go 模板包提供了一种占位符语言,我们可以使用如下内容:

{{name}}

这是一个简单的搜索和替换块,但更复杂的情况可以通过条件语句来处理:

{{if age}} Hello {{else}} bye {{end}}

在这里,如果 age 参数不为空,模板将显示 Hello;否则显示 bye。每个条件都需要一个 {{end}} 占位符来确定其结束。

然而,模板中的变量不需要是简单的数字或字符串;它们可以是对象。在这种情况下,如果我们有一个名为 ID 的字段的结构体,我们可以在模板中以这种方式引用该字段:

{{.ID}}

这非常方便,意味着我们可以将结构体传递给模板,而不是传递许多单个参数。

在下一个练习中,您将看到如何使用 Go 的基本模板功能来创建带有自定义消息的页面,就像您之前所做的那样,但方式更加优雅。

练习 15.04:模板化我们的页面

这个练习的目的是构建一个更结构化的网页,使用模板,并用 URL 的querystring中的参数填充它。在这种情况下,我们想显示客户的基本信息,并在数据缺失时隐藏一些信息。一个客户有一个idnamesurnameage,如果这些数据项中的任何一个缺失,则不会显示。除非数据是id,就像这个例子一样,将显示错误消息:

  1. 首先创建一个server-template文件夹,并添加一个main.go文件,就像通常一样,然后添加通常的包和一些导入:

    package main
    import (
       "html/template"
       "log"
       "net/http"
       "strconv"
       "strings"
    )
    
  2. 在这里,我们使用了两个新的导入,"html/template"用于我们的模板,以及"strconv"将字符串转换为数字(这个包也可以反过来工作,但还有更好的解决方案来格式化文本)。

  3. 现在,编写以下内容:

    var tplStr = `
    <html>
      <h1>Customer {{.ID}}
      {{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
    }
    

    这个结构体是自我解释的。它包含模板所需的所有属性。

  7. 定义处理函数并设置一个变量到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结构体来执行的,内容直接发送到w ResponseWriter,而不需要手动调用Write方法。

  16. 现在缺少的是main方法,它相当简单。编写以下内容:

    func main() {
       http.HandleFunc("/", Hello)
       log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  17. 简单来说,主路径与Hello函数相关联,然后启动服务器。

  18. 这段代码的性能并不高,因为我们每次请求都会创建一个模板。模板可以在main中创建,然后传递给一个处理程序,该处理程序可以有一个类似于你刚刚编写的Hello函数的ServeHTTP方法。这里代码被保持简单,以便专注于模板。

  19. 如果你现在启动服务器并访问以下页面,你应该会看到以下类似的输出:

图 15.20:带有空参数的模板响应

图 15.20:带有空参数的模板响应

现在,你可以在 URL 中添加一个名为id的查询参数,并将其设置为1localhost:8080/?id=1

图 15.21:仅指定 ID 的模板响应

图 15.21:仅指定 ID 的模板响应

然后,你还可以通过访问地址localhost:8080/?id=1&name=John为名称参数添加一个值:

图 15.22:指定 ID 和名称的模板响应

图 15.22:指定 ID 和名称的模板响应

最后,你还可以通过访问地址localhost:8080/?id=1&name=John&age=40添加一个年龄:

图 15.23:指定 ID、名称和年龄的模板响应

图 15.23:指定 ID、名称和年龄的模板响应

在这里,如果有效,querystring中的每个参数都会在 Web 应用程序中显示。

静态资源

在这本书中,你到目前为止所学的所有内容,直到最后一个练习,都足以构建网络应用程序和动态网站;你只需要将所有这些部分组合在一起。在本章中,你所做的是返回不同性质的消息,但这些消息都是作为字符串硬编码的。即使是动态消息,也基于在练习和活动源文件中硬编码的模板。现在让我们考虑一下。在第一个"hello world"服务器的例子中,消息从未改变。如果我们想修改消息并返回一个"Hello galaxy"的消息,我们就必须更改代码中的文本,然后重新编译和/或再次运行服务器。如果你想要出售你的简单"hello"服务器并给每个人指定一个自定义消息的选项呢?当然,你应该把源代码给每个人,这样他们就可以重新编译和运行服务器。虽然你可能想要拥抱开源代码,但这可能不是分发应用程序的理想方式,我们需要找到一种更好的方法来将消息与服务器分离。一个解决方案是提供静态文件,这些文件是由你的程序作为外部资源加载的。这些文件不会改变,也不会被编译,但会被你的程序加载和处理。一个这样的例子可能是模板,就像之前看到的,因为它们只是文本,你可以使用模板文件而不是将模板作为文本添加到你的代码中。另一个静态资源的简单例子是你想要包含在网页中的图像,或者 CSS 样式文件。你将在接下来的练习和活动中看到如何做到这一点。你将能够提供特定的文件或特定的文件夹,然后你会看到如何使用静态模板提供动态文件。

练习 15.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页面上,你应该看到以下内容:

    ![图 15.24:带有静态模板文件的 Hello world]

    图片

    ![图 15.24:带有静态模板文件的 Hello world]

  10. 但现在,不需要停止你的服务器,只需更改 HTML 文件,index.html,并修改第8行,你看到:

      <h1>Hello World</h1>
    
  11. 修改<h1>标签中的文本:

      <h1>Hello Galaxy</h1>
    
  12. 保存index.html文件,并且不要触摸终端,也不要重新启动你的服务器,只需在同一个页面上刷新你的浏览器,你现在应该看到以下内容:![图 15.25:修改后的静态模板文件的 Hello world 服务器]

    图片

    图 15.25:修改后的静态模板文件的 Hello world 服务器

  13. 因此,即使服务器正在运行,它也会选择文件的新版本。

    在这个练习中,你看到了如何使用静态 HTML 文件来服务一个网页,以及如何将静态资源从你的应用程序中分离出来,使你能够在不重新启动应用程序的情况下更改你提供的服务页面。

获取一些样式

到目前为止,你已经看到了如何服务一个静态页面,你可能考虑使用相同的方法服务几个页面,也许创建一个具有要服务文件名称的 handler struct 作为属性。对于大量页面来说,这可能不太实用,尽管在某些情况下这是必要的。然而,一个网页不仅仅包括 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 文件夹的内容。StripePrefix 函数将从请求中移除 "/statics/" 前缀,并将其传递给文件服务器,文件服务器将只获取要服务的文件名,并在 public 文件夹中搜索它。如果你不想更改路径和文件夹的名称,不需要使用这些包装器,但这个解决方案是通用的,并且适用于任何地方,所以你可以在其他项目中使用它而不用担心。

练习 15.06:风格化欢迎

本练习的目的是显示一个欢迎页面,利用一些外部静态资源。我们将采用与 练习 15.05 相同的方法,但我们将添加一些额外的文件和代码。我们将一些样式表放在一个 static 文件夹中,并且我们将提供它们,以便它们可以被同一服务器提供的其他页面使用:

  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("./public")
    
  8. 这读取你之前创建的本地 "public" 文件夹。

  9. 现在,将以下内容添加到文件中:

    log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  10. 这里,服务器再次创建,main() 函数被关闭。如果你现在再次运行你的服务器,使用:

      go run main.go
    
  11. 你现在会看到以下内容:![图 15.26:样式化主页

    ![图片 B14177_15_26.jpg]

    图 15.26:样式化主页

    因此,HTML 文件现在正在从你最初创建的样式表中获取样式。

  12. 现在我们来检查文件是如何注入的。如果你回顾一下 index.html 文件,你会看到这些行:

    <link rel="stylesheet" href="/statics/body.css">
    <link rel="stylesheet" href="/statics/header.css">
    <link rel="stylesheet" href="/statics/text.css">
    
  13. 因此,本质上,我们正在寻找路径 "/statics/" 下的文件。因此,你可以访问这些地址,你会看到:![图 15.27:body CSS 文件

    ![图片 B14177_15_27.jpg]

    图 15.27:body CSS 文件

    ![图 15.28:header CSS 文件]

    ![图片 B14177_15_28.jpg]

    图 15.28:header CSS 文件

    图 15.29:text CSS 文件

    图 15.29:text CSS 文件

  14. 因此,所有样式表都已被提供。此外,你甚至可以在这里查看:图 15.30:在浏览器中可见的静态文件夹内容

    图 15.30:在浏览器中可见的静态文件夹内容

  15. 并且查看public文件夹中的所有文件,这些文件在/statics/路径下提供服务。你可以看到,如果你需要一个简单的静态文件服务器,Go 允许你通过几行代码创建一个,并且通过更多的代码,你可以使其适用于生产环境。

  16. 如果你使用 Chrome 浏览器,你可以通过右键点击来检查,或者如果你有开发者工具,也可以在任何浏览器中检查,你将看到类似以下内容:

图 15.31:开发者工具显示加载的脚本

图 15.31:开发者工具显示加载的脚本

你可以看到文件已经被加载,并且样式显示为从右侧的样式表中计算得出的。

动态获取

静态资源通常以原样提供服务,但当你想要创建一个动态页面时,你可能想使用外部模板,这样你就可以即时使用它,这样你就可以在不重新启动服务器的情况下更改模板,或者你可以在启动时加载,这意味着你将不得不在更改后重新启动服务器(这并不完全正确,但我们需要一些并发编程的概念来实现这一点)。在启动时加载文件是为了性能原因。文件系统操作总是最慢的,即使 Go 是一种相当快的语言,你可能在想要提供页面时考虑性能,尤其是如果你有来自多个客户端的大量请求。

如你所回忆的,从前一个主题中,我们使用了标准的 Go 模板来制作动态页面。现在,我们可以将模板作为一个外部资源,并将我们的模板代码放入 HTML 文件中并加载它。模板引擎可以解析它,然后用传递的参数填充空白。为此,我们可以使用html/template函数:

func ParseFiles(filenames ...string) (*Template, error)

这可以通过以下方式调用:

template.ParseFiles("mytemplate.html")

此外,模板被加载到内存中,并准备好使用。

到目前为止,你一直是你的 HTTP 服务器的唯一用户,但在实际场景中,情况肯定不是这样。在接下来的示例中,我们将查看性能,并使用启动时加载的资源。

活动十五.03:外部模板

在这个活动中,你将创建一个欢迎服务器,就像你之前创建的那样,并且你必须使用模板包,就像你之前做的那样。然而,在这个活动中,我们不想让你从硬编码的字符串中创建模板,而是从一个包含所有模板占位符的 HTML 文件中创建模板。

你应该能够完成这个活动,利用你在本章和上一章中学到的知识。

此活动返回一个指向template的指针和一个错误列表。如果任何文件不存在或模板格式错误,则返回错误。在任何情况下,不要担心添加多个文件的可能性。坚持使用一个。

完成此活动的步骤如下:

  1. 为你的项目创建一个文件夹。

  2. 创建一个名为index.html等名称的模板,并用标准的 HTML 代码填充,包括欢迎信息和名称占位符。确保如果名称为空,信息将把“访客”这个词插入到名称应该出现的位置。

  3. 创建你的main.go文件,并向其中添加正确的包和导入。

  4. main.go文件中,创建一个包含名称的struct,该名称可以传递给一个模板。

  5. 使用你的index.html文件创建一个模板。

  6. 创建一个能够处理 HTTP 请求并使用querystring接收参数,并通过之前创建的模板显示数据的程序。

  7. 将所有路径设置为服务器使用之前步骤中创建的函数或处理程序,然后创建服务器。

  8. 运行服务器并检查结果。

    输出将如下所示:

![图 15.32:匿名访客页面img/B14177_15_32.jpg

图 15.32:匿名访客页面

包含名称的访客页面看起来可能如下截图所示:

![图 15.33:名为“Will”的访客页面img/B14177_15_33.jpg

图 15.33:名为“Will”的访客页面

注意

此活动的解决方案可以在第 763 页找到

在这个活动中,你学习了如何创建一个模板化的 HTTP 处理程序作为结构体,它可以初始化为任何外部模板。你现在可以创建多个页面,使用不同的模板实例化相同的结构体。

HTTP 方法

到目前为止,你通过网页浏览器检查了你的练习和活动的结果,只需访问一个地址,即你的 localhost,并得到一些以网页形式返回的结果。这种方式消费 HTTP 服务器使用的是所谓的GET方法。你在使用 HTTP 客户端时已经看到了这些方法,它们是除了GETPOST之外唯一可以使用的途径。然而,通过你的网页浏览器,你也可以使用POST方法,这种方法通常用于发送表单数据。虽然可以通过GET方法发送表单数据,但这种方法会将参数污染 URL,并且在可以发送的数据大小方面有一些限制。

还有其他常用的方法,这些是 PUTDELETE,但您需要特定的客户端来使用它们。这就是为什么使用这四种方法来构建所谓的 REST API。还有其他方法,但本书的范围不涉及所有 HTTP 方法,而是专注于最常用的方法。REST API 实质上是一组 路径 和方法,它们响应特定的请求。公开 REST API 的 HTTP 服务器称为 REST 服务器。为了理解为什么有不同方法可用,您需要了解它们是如何使用的。如果您需要请求一些数据,您正在尝试获取这些数据,因此,GET 方法是最合适的。如果您相反,想要修改您已经熟悉的一个资源,您想在已知位置放置一些特定的值,您将使用 PUT 方法,这本质上会在已知位置更改服务器的状态。如果您需要以某种方式修改服务器的状态,您需要查找要修改的资源。例如,如果您不知道它们的 ID,您将使用 POST 方法。这就是为什么您经常在网上看到,关于何时使用 POSTPUT 的最常见解释是,前者用于添加资源,而后者用于更新资源。尽管这通常是真的,但并不总是如此,因为您也可能使用 POST 方法执行更新。

在下一个练习中,您将看到如何使用不同的方法,GETPOST,以相同的函数完成不同的事情。请注意,通常,您可能会使用更复杂的第三方库来编写更优雅的代码,但在这里,我们关注的是如何做基础,并展示标准 Go 库已经为我们提供了很多帮助来完成我们的工作。

练习 15.07:完成问卷

在这个练习中,您将构建一个表单,并将数据发送到另一个页面。表单将包含诸如您的姓名、姓氏和年龄等问题,这些数据将被发送到另一个页面,该页面将显示它们。您将利用您已经学到的知识,同时您还将了解如何从您的 HTTP 请求中获取 posted 参数。

  1. 首先,创建一个名为 questionnaire 的文件夹,并在该文件夹内包含一个名为 index.html 的文件,其内容如下:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Welcome</title>
    </head>
    <body>
      <h1>Details</h1>
      <ul>
      <li>Name: {{.Name}}</li>
      <li>Surname: {{.Surname}}</li>
      <li>Age: {{.Age}}</li>
      </ul>
    </body>
    </html>
    
  2. 这是一个显示个人信息项的正常模板。如果任何数据缺失,我们简单地将其显示为空字符串,而不是隐藏它们。

  3. 现在,创建一个名为 form.html 的文件,并添加以下内容:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Form</title>
    </head>
    <body>
      <form method="post" action="/">
      <ul>
       <li>Name: <input type="text" name="name"></li>
      <li>Surname: <input type="text" name="surname"></li>
      <li>Age: <input type="text" name="age"></li>
      <li><input type="submit" name="send" value="send"></li>
      </ul>
      </form>
    </body>
    </html>
    
  4. 这是在表单内部的一个另一页,包含三个文本输入和一个按钮。输入字段代表我们想要发送的详细信息。请注意,表单的动作设置为"/",这意味着当点击按钮时,页面会被重定向到主路径,但会携带表单中的数据集。方法属性设置为post,这是我们之前讨论过的 HTTP 方法。

  5. 现在,你必须创建实际的 Go 服务器。创建一个main.go文件并添加以下内容:

    package main
    import (
       "html/template"
       "log"
       "net/http"
    )
    
  6. 然后,为模板创建结构体:

    type Visitor struct {
       Name string
       Surname string
       Age string
    }
    

    这包含了模板所需的全部属性。

  7. 然后,执行以下命令:

    type Hello struct {
       tpl *template.Template
    }
    

    这包含了之前看到的模板。

  8. 在这一点上,你需要为处理程序创建handler函数,所以添加以下内容:

    func (h Hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
       vst := Visitor{}
    

    这里,创建了一个新的空访客。

  9. 检查请求是否为Post请求,因此你需要添加:

    if r.Method == http.MethodPost {
    

    这检查方法与 Go http包提供的常量是否匹配。

  10. 解析表单:

       err := r.ParseForm()
       if err != nil {
      w.WriteHeader(400)
      return
       }
    
  11. 如果解析表单时发生错误,我们返回400代码,这是一个错误请求。

  12. 如果表单解析正确,我们可以继续,所以添加以下内容:

       vst.Name =  r.Form.Get("name")
       vst.Surname = r.Form.Get("surname")
       vst.Age = r.Form.Get("age")
    }
    

    这里,表单中的所有参数都被分配给访客的属性。然后我们关闭if语句,转到处理程序函数的公共部分。

  13. 由于我们有访客,无论表单是否已提交以及提交了什么值,我们最终可以返回页面,所以编写:

       h.tpl.Execute(w, vst)
    }
    
  14. 我们需要有一种方法来创建处理程序,所以,就像你之前做的那样,添加以下函数:

    func NewHello(tplPath string) (*Hello, error){
       tmpl, err := template.ParseFiles(tplPath)
       if err != nil {
      return nil, err
       }
       return &Hello{tmpl}, nil
    }
    
  15. 在这一点上,你可以编写main()函数,它创建处理程序,将其分配给主路径,然后将静态form.html文件分配给/form路径:

    func main() {
       hello, err := NewHello("./index.html")
       if err != nil {
      log.Fatal(err)
       }
       http.Handle("/", hello)
       http.HandleFunc("/form", func(writer http.ResponseWriter, request *http.Request) {
      http.ServeFile(writer, request, "./form.html")
       })
       log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  16. 运行你的服务器,你将看到以下内容,当你访问主页时:图 15.34:空详情页面

    图 15.34:空详情页面

  17. 如果你访问/form路径,你会看到:图 15.35:空表单页面

    图 15.35:空表单页面

  18. 如果你填写了数据:图 15.36:已填写表单页面

    图 15.36:已填写表单页面

  19. 然后按下send按钮,你将被重定向到这个页面:

图 15.37:添加了详情的页面

图 15.37:添加了详情的页面

这,同样,是设置了通过表单中输入的参数的详情的主页面。

JSON 加载

并非所有 HTTP 服务器都是为了被浏览器和人类用户使用。很多时候,我们有不同的软件程序在相互通信。这些程序需要通过一个共同接受的格式相互发送消息,其中之一就是 JSON。这代表 JavaScript 对象表示法,本质上意味着它模仿了在 JavaScript(另一种编程语言)中直接创建对象的方式。它是一个简单的格式,不是特别冗长,并且易于软件解析和人类阅读。然而,作为用户,你可以使用许多工具中的任何一个来发送和接收 JSON 有效负载,其中最常见的是InsomniaPostman,你可以在网上轻松找到它们,网址分别是packt.live/2RY13Dtpackt.live/2RY13Dt

它们都是免费的,并且适用于不同的平台。你也可以使用curl作为命令行工具,但这会变得复杂一些。

练习 15.08:构建接受 JSON 请求的服务器

在这个练习中,你将构建一个接受 JSON 消息的服务器,并以另一个 JSON 消息作为响应。你将无法使用浏览器来测试它,但你可以使用像InsomniaPostman这样的客户端来测试。示例截图将使用Insomnia提供,所以最好你也使用相同的工具。你将构建的服务器接受包含名字和姓氏的消息,并返回包含一些个性化问候的消息:

  1. 创建一个名为json-server的文件夹,并在其中添加一个名为main.go的文件。开始向文件中添加包和导入:

    package main
    import (
       "encoding/json"
       "fmt"
       "log"
       "net/http"
    )
    

    这里,导入的包是 HTTP 编程、日志记录、字符串格式化和当然,JSON 编码的常规包。

  2. 在此之后,你需要为传入和传出的消息创建模型,所以编写以下内容:

    type Request struct {
       Name string
       Surname string
    }
    type Response struct {
       Greeting string
    }
    

    这些结构体相当简单,只包含我们所需要的部分。

  3. 现在,添加main函数:

    func main() {
    
  4. 现在将函数设置为处理 JSON 消息:

       http.HandleFunc("/", func(wr http.ResponseWriter, req *http.Request) {
      decoder := json.NewDecoder(req.Body)
    

    如你所见,函数内部的第一件事是创建一个 JSON 解码器,它将解码请求的主体。

  5. 作为下一步,编写以下内容:

    var data Request
    err := decoder.Decode(&data)
    if err != nil {
       wr.WriteHeader(400)
       return
    }
    
  6. 在这里,我们定义了一个Request类型的数据变量,并将 HTTP 请求的主体解码到它里面。如果发生任何错误,我们返回一个400错误代码表示请求无效。

  7. 一旦数据被正确解码,你现在可以使用这些数据来创建响应:

    rsp := Response{Greeting: fmt.Sprintf("Hello %s %s", data.Name, data.Surname)}
    
  8. 在这里,请求中的名字和姓氏被合并成一个个性化的问候信息。

  9. 现在剩下的就是将信息发送回请求者:

       bts, err := json.Marshal(rsp)
       if err != nil {
      wr.WriteHeader(400)
      return
       }
       wr.Write(bts)
    })
    
  10. 在这里,响应被编码成一个 JSON 字符串并发送,将其作为字节数组写入响应写入器。现在你可以运行服务器并打开Insomnia

  11. 现在,创建main()函数来提供页面服务:

    func main() {
      http.HandleFunc("/", Hello)
      log.Fatal(http.ListenAndServe(":8080", nil))
    }
    

    运行前面的代码会产生以下输出:

![图 15.38:Insomnia 响应]

![img/B14177_15_38.jpg]

图 15.38:Insomnia 响应

如您所见,您可以使用 Insomnia 发送 post 请求并将 JSON 字符串发送到您的服务器。在右侧,您将看到作为 JSON 文档的响应。

摘要

在本章中,您已经了解了网络编程的服务器端。您学习了如何接受来自 HTTP 客户端的请求并以适当的方式响应。您学习了如何通过路径和子路径将可能的请求分离到 HTTP 服务器的不同区域。为此,您使用了一个简单的路由机制和标准的 Go HTTP 包。您看到了如何返回响应以适应不同的消费者:为合成客户端返回 JSON 响应,为人类访问返回 HTML 页面。您还看到了如何使用模板来格式化您的纯文本和 HTML 消息,使用标准的模板包。您学习了如何提供和使用静态资源,直接通过默认文件服务器或通过模板对象提供。您还学习了什么是 REST 服务,尽管我们还没有一起构建一个,但您已经拥有了创建一个所需的所有知识,只要您遵循您所得到的描述。在这个阶段,您已经了解了构建生产级 HTTP 服务器的所有基础知识,尽管您可能想要使用一些外部库来简化您的 hello world 示例,通过使用像 gorilla mux 或通常的整个 gorilla 包来更好地实现路由,这是一个在 http 包之上的低级抽象。您可以使用 hero 作为模板引擎来加快页面渲染速度。有一点需要提及的是,您可以使用本章中学到的知识创建几乎无状态的服务,但您目前无法创建一个生产级的有状态服务器,因为您不知道如何处理并发请求。这意味着我们的 视图计数器 还不适合生产服务器,但这将是下一章的主题。

在下一章中,您将看到 Go 如何利用 Goroutines 系统同时处理多个任务。这个特性非常重要,您可以将它应用到 HTTP 服务器和其他类型的项目中,在这些项目中您有很多并发用户或您想要同时做很多事情。

第十六章:16. 并发工作

概述

本章介绍了 Go 的特性,这些特性将允许你执行并发工作,换句话说,实现并发。你将学习的第一个特性被称为 Goroutine。你将了解 Goroutine 是什么以及如何使用它来实现并发。然后,你将学习如何利用 WaitGroups 来同步多个 Goroutines 的执行。你还将学习如何使用原子更改来实现跨不同 Goroutines 共享变量的同步和线程安全更改。为了同步更复杂的变化,你将使用互斥锁。

在本章的后面部分,你将实验通道的功能,并使用消息跟踪来跟踪任务的完成情况。

简介

有一些软件是为单个用户使用的,而你在这本书中学到的许多内容都允许你开发这样的应用程序。然而,还有一些软件是为同时供多个用户使用的。一个例子是网络服务器。你在第十五章,HTTP 服务器中创建了网络服务器。它们被设计用来服务于网站或通常由数千个用户同时使用的网络应用程序。

当多个用户访问一个网络服务器时,有时它需要执行一系列完全独立且其结果对最终输出唯一重要的事情。所有这些情况都需要一种编程方式,在这种方式中,不同的任务可以同时执行,彼此独立。一些语言允许并行计算,其中任务可以同时计算。然而,在某些语言中,例如 Go,任务是通过机器逐个完成的;也就是说,每个任务或进程被分成小块,程序将一次执行一小块任务,直到所有任务都完成。这被称为并发编程。

在并发编程中,当一个任务开始时,所有其他任务也会开始,但机器不是逐个完成它们,而是同时执行每个任务的一小部分。虽然 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 一次运行其子部分之一。

练习 16.01:使用并发线程

让我们假设我们想要进行两个计算。首先,我们将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 55
    
  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. 如果你的 IDE 没有为你做这件事,修改import部分,就在package main指令下面,使其看起来如下:

    import (
        "log"
        "time"
    )
    

    如果你现在运行你的程序,你应该在屏幕上看到打印出5050 55

  7. main()函数中,编写要打印的日志代码:

    log.Println(s1, s2)
    
  8. 如果你现在运行你的程序,你将再次看到相同的输出,5050 55,但前面会加上一个时间戳,表示你运行代码的时间:

    2019/10/28 19:23:00 5050 55
    

如你所见,计算是并发发生的,我们同时收到了两个输出。

注意

这个练习的完整代码可以在以下链接找到:packt.live/2Qek69K

WaitGroup

在上一个练习中,我们使用了一种不太优雅的方法来确保 Goroutine 通过让主例程等待一秒来结束。重要的是要理解,即使程序没有通过go调用显式使用 Goroutines,它仍然使用了一个 Goroutine,那就是主例程。当我们运行程序并创建一个新的 Goroutine 时,我们实际上运行了两个 Goroutines:一个是主 Goroutine,另一个是我们刚刚创建的。为了同步这两个 Goroutine,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 添加到组中。这本质上是一个计数器,用于记录所有正在运行的并发例程的数量。稍后,我们添加实际运行并发调用的代码。最后,我们告诉 WaitGroup 使用wg.Wait()等待 Goroutines 结束。

WaitGroup 是如何知道例程已经完成的呢?嗯,我们需要在 Goroutine 内部显式地告诉 WaitGroup,如下所示:

wg.Done()

这必须在主 Goroutine 函数内部,这意味着它需要一个对 WaitGroup 的引用。我们将在下一个练习中看到这一点。

练习 16.02:使用 WaitGroup 进行实验

假设我们再次计算 练习 16.01使用并发线程 中的加法,这次使用与主进程并发运行的 Goroutine。然而,这次我们想要使用 WaitGroup 来同步结果。我们需要做一些更改。本质上,sum() 函数需要接受一个新的 WaitGroup 参数,并且不需要使用 time 包。

  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,然后我们只是使用之前看到的相同循环,但再次将 sumres 参数所指向的值关联起来。

  4. 我们现在可以完成这个函数:

    wg.Done()
      return
    }
    

    在这里,我们告诉 WaitGroup 这个 Goroutine 已完成,然后返回。

  5. 现在,让我们编写 main() 函数,该函数将设置变量并运行计算 sum 的 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 来计算 sumsum() 函数将调用 .Done() 方法来通知 WaitGroup 其完成。

  7. 我们需要等待 Goroutine 完成。为此,编写以下代码:

        wg.Wait()
        log.Println(s1)
    }
    

    这也将结果记录到标准输出。

  8. 运行程序:

    go run main.go
    

    你将看到使用 WaitGroups 的函数的日志输出,如下所示,带有时间戳:

    2019/10/28 19:24:51 5050
    

通过这个练习,我们通过在我们的代码中同步 Goroutines 探索了 WaitGroup 的功能。

竞态条件

需要考虑的一个重要问题是,无论何时我们并发运行多个函数,我们都没有保证每个函数中的每个指令将按什么顺序执行。在许多架构中,这并不是一个问题。有些函数与其他函数没有任何联系,并且函数在其例程中执行的操作不会影响其他例程中执行的操作。然而,这并不总是正确的。我们可以想到的第一个情况是,当一些函数需要共享相同的参数时。一些函数将只从这个参数中读取,而其他函数将写入这个参数。由于我们不知道哪个操作将首先运行,一个函数覆盖另一个函数更新的值的可能性很高。让我们看看一个解释这种情况的例子:

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。为什么会这样呢?因为当一个函数执行以下语句时,所有在独立 Goroutines 中运行的函数的v值可能都是 0:

c := *v

如果这种情况发生,那么每个函数都会将v设置为c+1,这意味着没有任何一个例程知道其他例程正在做什么,并覆盖了其他例程所做的任何更改。这个问题被称为竞态条件,每次我们在没有采取预防措施的情况下处理共享资源时都会发生。幸运的是,我们有几种方法可以防止这种情况,并确保相同的更改只发生一次。我们将在下一节中查看这些解决方案,并更详细地探讨我们刚才描述的情况,包括适当的解决方案和竞态检测。

原子操作

让我们假设我们再次想要运行独立的函数。然而,在这种情况下,我们想要修改变量的值。我们仍然想要从 1 加到 100,但我们将工作分成两个并发的 Goroutines。我们可以在一个例程中计算从 1 到 50 的数字之和,在另一个例程中计算从 51 到 100 的数字之和。最后,我们仍然需要得到 5050 这个值,但两个不同的例程可以同时向同一个变量添加一个数字。让我们看看一个只有 4 个数字的例子,我们想要计算 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。

练习 16.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. 下一步是将练习 16.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 中计算sum

    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
    

    你会看到类似这样的内容:

    2019/10/28 19:26:04 5050
    

    实际的日期将不同,因为它取决于你何时运行此代码。

  9. 现在,让我们测试一下代码。我们将使用它来向您展示什么是竞态条件,为什么我们使用这个原子包,以及什么是并发安全性。以下是测试代码:

    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/ex3    0.048s
    
  11. 现在添加race标志:

    go test -race
    

    使用race标志运行这些测试的输出如下:

    PASS
    Ok    parallelwork/ex3    3.417s
    

    再次,到目前为止一切正常。

  12. 现在让我们移除sync/atomic导入,并修改sum函数中看到此行的地方:

    atomic.AddInt32(res, int32(i))
    
  13. 将其改为:

    *res = *res + int32(i)
    
  14. 现在运行你的程序:

    go run main.go
    
  15. 使用指针时,非原子更改的日志输出保持不变:

    2019/10/28 19:30:47 5050
    
  16. 但如果你尝试多次运行测试,你可能会看到一些不同的结果,尽管在这种情况下,这种情况相当不可能。然而,此时尝试使用-race标志运行测试:

    go test -race main.go
    

    你将看到以下输出:

    图 16.1:在此使用指针时会出现竞态条件

    图 16.1:在此使用指针时会出现竞态条件

    注意

    'GCC'必须安装才能运行此代码。

  17. 现在,让我们在没有race标志的情况下运行代码:

图 16.2:带有竞态条件的堆栈跟踪

图 16.2:带有竞态条件的堆栈跟踪

通过多次运行代码,你可以看到不同的结果,因为每个例程可以在任何时间以任何顺序更改s1的值,而我们无法提前知道。

在这个练习中,你学习了如何使用原子包安全地修改多个 Goroutine 共享的变量。你已经了解到从不同的 Goroutine 直接访问相同的变量可能会很危险,以及如何使用原子包来避免这种情况。

注意

本练习的完整代码可在packt.live/35UXbqD找到。

不可见并发

在前面的练习中,我们已经看到了通过竞态条件产生的并发影响,但我们想在实际中看到它们。很容易理解并发问题很难可视化,因为它们在每次运行程序时并不以相同的方式表现出来。这就是为什么我们专注于寻找同步并发工作的方法。然而,有一种简单的方法可以可视化它,但在测试中很难使用,那就是打印出每个并发例程,并查看这些例程被调用的顺序。例如,在前面的练习中,我们可以在for循环的每次迭代中发送另一个带有名称的参数,并打印出函数的名称。

如果我们想看到并发的影响并仍然能够测试它,我们可以再次使用原子包,这次使用字符串,这样我们就可以构建一个包含每个 Goroutine 消息的字符串。对于这种情况,我们将再次使用sync包,但我们将不使用原子操作。相反,我们将使用一个新的结构体,称为mutex。互斥锁(Mutex)是“互斥”的缩写,它本质上是一种停止所有例程、运行代码中的一个,然后继续并发代码的方式。让我们看看我们如何使用它。首先,它需要导入sync包。然后,我们创建一个互斥锁,如下所示:

mtx := sync.Mutex{}

但大多数时候我们希望将互斥锁传递给几个函数,所以我们最好创建一个指向互斥锁的指针:

mtx := &sync.Mutex{}

这确保了我们可以在任何地方使用相同的互斥锁。使用相同的互斥锁很重要,但为什么互斥锁必须只有一个的原因将在分析 Mutex 结构中的方法后变得清晰;考虑以下代码:

mtx.Lock()
s = s + 5

上述代码片段将锁定所有例程的执行,除了将更改变量的那个例程。在此阶段,我们将向s的当前值添加 5。之后,我们将使用以下命令释放锁,以便任何其他例程都可以修改s的值。

mtx.Unlock()

从现在开始,任何后续的代码都将并发运行。我们稍后会看到一些更好的方法来确保在修改变量时的安全性,但,目前请不要担心在锁定/解锁部分之间添加过多的代码。在这些结构之间有越多的代码,你的代码并发性就越低。因此,你应该锁定程序的执行,只添加确保安全性的逻辑,然后解锁并继续执行剩余的代码,这些代码不会触及共享变量。

一个需要注意的重要事情是异步执行代码的顺序可能会改变。这是因为 Goroutines 是独立运行的,你无法知道哪个先运行。然而,每个例程在让另一个例程运行之前都会完全运行。因此,你不应该依赖 Goroutines 来正确排序事物;如果你需要一个特定的顺序,你可能需要在之后对结果进行排序。

活动十六.01:列出数字

在这个活动中,你需要构建一个包含从 1 到 100 的所有数字的字符串。然而,你不需要使用单个循环,你需要将工作分配给四个循环,就像在练习 16.03中一样。此外,每个循环将在其自己的循环中添加数字。以下是步骤:

  1. 创建一个文件夹和一个main.go文件。

  2. 创建一个函数,该函数接受一个范围作为参数,并将一个字符串作为参数,你将在其中添加该范围内的所有数字(也作为字符串)。

  3. 将每个数字用字符"|"包裹起来,例如,|4|,这样列表将具有|4||10|的形式。

  4. 创建一个main()函数,在其中创建四个 Goroutines,每个 Goroutine 有一个 25 个数字的范围。

  5. 确保所有例程安全地修改相同的字符串。

  6. 确保main()函数等待例程的完成。

  7. 打印最终的字符串并运行程序。

你应该能够使用本章迄今为止学到的所有内容来完成这个活动。

当你运行你的程序时,你应该看到类似以下的内容:

![图 16.3:列出数字时的第一次输出图片

图 16.3:列出数字时的第一次输出

然而,如果你多次运行它,你很可能会看到不同的结果:

图片

图 16.4:列出数字的第二次尝试返回不同的顺序

注意

这个活动的解决方案可以在第 766 页找到。

通道

我们已经看到了如何通过 Goroutines 创建并发代码,如何使用 WaitGroup 进行同步,如何执行原子操作,以及如何暂时停止并发以同步对共享变量的访问。现在,我们将介绍一个不同的概念,即通道,这是 Go 语言的特点。通道就是名字所暗示的——它是一个可以传递消息的地方,任何协程都可以通过通道发送或接收消息。与切片类似,通道的创建方式如下:

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/deliodanna/goprojects/parallelwork/exercise 4/main.go:8 +0x59
Process finished with exit code 2

消息可能因你使用的 Go 版本而异。此外,一些错误如这些在新版本中已经被引入。然而,在旧版本中,编译器更为宽容。在这个特定的情况下,问题很简单:如果我们不知道通道的大小,协程将无限期地等待,这被称为死锁。这并不意味着我们不能处理无缓冲通道。我们将在后面看到如何处理它们,因为它们需要多个协程运行。只有一个协程时,在我们发送消息后,我们将阻塞执行,没有其他协程能够接收消息;因此,我们遇到了死锁。

在我们进一步之前,让我们看看通道的一个更多特性,即它们可以被关闭。当通道被创建的任务完成后,需要关闭通道。为了关闭通道,请输入以下内容:

close(ch)

或者,你可以延迟关闭,如下面的代码片段所示:

...
defer close(ch)
for i:=0; i< 100; i++ {
    ch <- i
}
return

在这种情况下,在 return 语句之后,通道被关闭,因为关闭被延迟到 return 语句之后执行。

练习 16.04:通过 Channels 交换问候消息

在这个练习中,我们将使用 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
    

    你将看到以下输出:

    2019/10/28 19:44:11 Hello
    

    现在我们可以看到消息已经通过通道传递到了 main 函数。

在这个练习中,你看到了如何使用通道使不同的 Goroutine 互相通信并同步它们的计算。

练习 16.05:使用 Channels 进行双向消息交换

现在我们想要从主程序向第二个程序发送消息,然后得到一个响应消息。我们将基于之前的代码并扩展它。主程序将发送一个 "Hello John" 消息,而第二个程序将返回 "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() 函数并作为 Goroutine 调用 greet() 函数:

    func main() {
        ch := make(chan string)
        go greet(ch)
    

    在这里,创建了主函数并实例化了一个字符串通道。然后,启动了第二个 Goroutine。接下来,我们需要从主程序向第二个等待的程序发送第一条消息。

  4. 现在,要将消息 "Hello John" 发送到通道,请编写以下代码:

    ch <- "Hello John"
    
  5. 最后,添加在打印消息之前等待消息返回的代码:

        log.Println(<-ch)
        log.Println(<-ch)
    }
    

    你可以看到,你需要记录两次,因为你预计会收到两条消息。在许多情况下,你会使用循环来检索所有消息,我们将在下一个练习中看到。现在,尝试运行你的代码,你将看到如下内容:

    2019/10/28 19:44:49 Thanks for Hello John
    2019/10/28 19:44:49 Hello David
    

从输出中,你可以看到两条消息都已经通过通道接收到了。

在这个练习中,你学习了 goroutine 如何通过同一个通道发送和接收消息,以及两个 goroutine 如何通过同一个通道在两个方向上交换消息。

练习 16.06:从各个地方求和数字

假设你想要加几个数字,但这些数字来自多个来源。它们可能来自一个源或数据库;我们并不知道我们将要加的哪些数字以及它们来自哪里。然而,我们需要将它们全部加在一个地方。在这个练习中,我们将有四个 Goroutines 在特定的范围内发送数字,以及主程序,它将计算它们的总和。

  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范围内的所有数字发送到通道。在每条消息发送后,程序将暂停一微秒,以便另一个程序可以接手工作。

  3. 现在编写main()函数:

    func main() {
        s1 := 0
        ch := make(chan int, 100)
    

    这段代码创建了一个用于最终总和的变量sum1和一个具有 100 个缓冲区的通道ch

  4. 现在创建四个go routines:

        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)
    }
    

    在这里,一旦运行程序,我们将得到以下截断的输出:

    2019/07/08 21:42:09 76
    2019/07/08 21:42:09 26
    2019/07/08 21:42:09 51
    2019/07/08 21:42:09 77
    2019/07/08 21:42:09 52
    ……………………………………………………………
    2019/07/08 21:42:09 48
    2019/07/08 21:42:09 75
    2019/07/08 21:42:09 100
    2019/07/08 21:42:09 23
    2019/07/08 21:42:09 49
    2019/07/08 21:42:09 24
    2019/07/08 21:42:09 50
    2019/07/08 21:42:09 25
    2019/07/08 21:42:09 5050
    

    根据结果,我们可以轻松地猜测哪个数字来自哪个程序。最后一行显示了所有数字的总和。如果你多次运行程序,你还会看到数字的顺序也会改变。

在这个练习中,我们看到了如何将一些计算工作分配给几个并发程序,然后在单个程序中汇总所有计算。每个程序执行一个任务。在这种情况下,一个发送数字,而另一个接收数字并执行求和。

练习 16.07:请求 Goroutines

在这个练习中,我们将解决与练习 16.06 相同的相同问题,即从各个地方求和数字,但以不同的方式。我们不会像程序发送时那样接收数字,而是让主程序从其他程序请求数字。我们将玩转通道操作,并实验它们的阻塞特性。

  1. 创建一个文件夹和一个名为main.gomain包文件。然后,添加以下导入:

    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()函数,在其中调用四个不同的 goroutine 中的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。如果你运行程序,你将再次看到与上一个练习类似的内容。这里,我们有截断的输出:

    2019/07/08 22:18:00 76
    2019/07/08 22:18:00 1
    2019/07/08 22:18:00 77
    2019/07/08 22:18:00 26
    2019/07/08 22:18:00 51
    2019/07/08 22:18:00 2
    2019/07/08 22:18:00 78
    …………………………………………………………
    2019/07/08 22:18:00 74
    2019/07/08 22:18:00 25
    2019/07/08 22:18:00 50
    2019/07/08 22:18:00 75
    2019/07/08 22:18:00 5050
    

你可以看到每个数字都是按照接收的顺序打印的。然后,所有数字的总和打印在屏幕上。

在这个练习中,你学习了如何使用通道请求其他 goroutine 执行某些操作。通道可以用来发送一些触发消息,而不仅仅是交换内容和值。

并发的重要性

到目前为止,我们看到了如何使用并发将工作分配给多个 Goroutine,但在所有这些练习中,并发并不是真正必要的。事实上,你做我们做的事情并没有节省多少时间,也没有其他优势。当你需要执行几个逻辑上相互独立的不同任务时,并发就很重要了。最容易理解的情况是 Web 服务器。你在第十五章HTTP 服务器中看到,几个客户端很可能会连接到同一个服务器,所有这些连接都会导致服务器执行一些操作。此外,这些操作都是独立的;这就是并发之所以重要的地方,因为你不希望你的用户在他们的请求得到处理之前必须等待所有其他 HTTP 请求完成。并发的另一个情况是,当你有不同数据源来收集数据,你实际上可以在不同的 goroutine 中收集这些数据,并在最后将结果合并。我们现在将看到一些更复杂的并发应用,并学习如何将其用于 HTTP 服务器。

练习 16.08:在 goroutine 之间平均分配工作

在这个练习中,我们将看到我们如何在预定义的例程中执行数字的求和,以便它们在最后收集结果。本质上,我们想要创建一个函数,该函数可以添加数字并从通道接收数字。当函数不再接收更多数字时,我们将通过通道将总和发送到主函数。

这里需要注意的一点是,执行求和的函数事先不知道它将接收多少数字,这意味着我们不能有一个固定的 from, to 范围。因此,我们必须找到另一种解决方案。我们需要能够将工作分割成任意数量的 Goroutines,而不仅仅受 from, to 范围的限制。此外,我们不想在主函数中进行加法。相反,我们想要创建一个函数,该函数将在多个例程之间分割工作。

  1. 创建一个文件夹和一个名为 main.go 的文件,包含主包,并编写以下内容:

    package main
    import (
        "log"
    )
    
  2. 现在让我们编写一个执行部分加法的函数。我们将称之为 worker(),因为我们将会有一组固定的例程运行这个相同的函数,等待数字的到来:

    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
        }
    

    这将所有要加的数字发送到通道,该通道将数字分配给所有例程。如果你要打印出带有工人索引接收到的数字,你可以看到数字是如何均匀地分配给例程的,这并不意味着一个精确的分割,但至少是公平的。

  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)
    }
    

    这只是从一个使用并发性的函数中输出一个总和,然后打印出结果。

  11. 如果运行你的程序,你应该看到数字总和的日志输出被分成不同的例程,如下所示:

    2019/10/28 19:49:13 5050
    

如你所见,在将计算分配到多个 goroutine 之后,结果被同步到一个单一的结果中。

在这个练习中,你学习了如何利用并发将你的计算分配到几个并发 goroutine,然后将所有这些计算组合成一个单一的结果。

并发模式

我们在每一个应用中组织并发工作的方式几乎都是一样的。我们将查看一个称为管道的常见模式,其中有一个源,然后消息从一个例程发送到另一个例程,直到线路的尽头,直到管道中的所有例程都被利用。另一个模式是扇出/扇入模式,其中,就像之前的练习一样,工作被发送到几个从同一通道读取的例程。然而,所有这些模式通常都由一个阶段组成,这是管道的第一个阶段,它收集或提供数据,然后是一些内部步骤,最后是一个,这是所有其他例程处理结果的最终合并阶段。它被称为汇,因为所有数据都流入其中。

活动 16.02:源文件

在这个活动中,你将创建一个程序,该程序将同时读取包含一些数字的两个文件。你需要将这些数字通过管道传输到一个函数,该函数将根据它们的值将它们分成偶数和奇数。然后,它将奇数发送到一个例程,偶数发送到另一个例程。然后,它将所有偶数和奇数的总和写入另一个文件。

你将需要两个包含数字的文件,这些数字将作为源文件使用。然后,你将生成一个文件,其中包含一行中所有奇数的总和,然后是下一行中所有偶数的总和。这个活动的概要步骤如下:

  1. 创建两个输入文件。你可以使用更多,但建议的代码将使用两个。

  2. 在你的输入文件中添加一些数字,每行一个数字,不要添加其他内容。你需要在每个文件的末尾添加一个空行。

  3. 创建你的主程序并从导入开始。

  4. 创建一个函数来读取文件并将每一行通过管道传输到通道。不过,请注意;你可能需要添加一个 WaitGroup 或其他东西来避免任何死锁。

  5. 创建一个函数来接收数字并将奇数通过一个通道传输,偶数通过另一个通道传输。

  6. 创建一个函数来计算数字并将结果通过管道传输到新的通道。

  7. 创建一个合并函数,从奇数和偶数通道读取并将结果写入名为result.txt的文件。该文件中的每一行应包含根据值确定的单词“Odd”或“Even”,后跟总和。

  8. 创建主函数来运行所有的 Goroutines,并在需要时处理 WaitGroups。

如果你运行你的程序,你应该在控制台看到什么都没有,但应该创建一个名为result.txt的文件。根据你的输入文件中的数字,你会发现输出文件的内容类似于以下内容:

Odd 9
Even 12

注意

这个活动的解决方案可以在第 768 页找到。

缓冲区

你在之前的练习中看到,有固定长度的通道和不确定长度的通道:

ch1 := make(chan int)
ch2 := make(chan int, 10)

让我们看看我们如何利用这一点。

缓冲区就像一个需要填充一些内容的容器,所以当你期望接收那个内容时,你就准备它。我们说过,通道上的操作是阻塞操作,这意味着每次你尝试从通道读取消息时,程序的执行都会停止并等待。让我们通过一个例子来尝试理解这在实践中意味着什么。假设我们在一个 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

在每次添加时运行你的程序;你可能看不到所有数字。基本上,有两个常规:一个是读取来自无缓冲通道的消息,而主常规是通过相同的通道发送这些消息。因此,没有死锁。这表明我们可以通过使用两个常规,完美地利用无缓冲通道进行读写操作。然而,我们仍然有一个问题,即不是所有数字都显示出来,我们可以在以下方式中修复它:

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 停止迭代。通道在主常规中关闭,在所有消息发送完毕后。我们在这里使用 WaitGroup 来知道一切已完成。如果我们不在主函数中关闭通道,我们就会在主常规中,这将在第二个常规打印所有数字之前终止。然而,还有另一种等待第二个常规执行完成的方法,那就是通过显式通知,我们将在下一个练习中看到。需要注意的是,即使我们关闭了通道,消息仍然会到达接收常规。这是因为通道仅在所有消息被接收者接收后才会关闭。

练习 16.09:在计算完成后通知

在这个练习中,我们希望有一个常规用于发送消息,另一个用于打印它们。此外,我们还想知道发送者何时完成消息的发送。代码将与之前的示例类似,但有一些修改。

  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. 之后,关闭用于发送消息的通道并等待完成信号:

        close(in)
        <-out
    }
    

    如果你运行程序,你会看到使用 done 通道的代码的日志输出:

    a
    b
    c
    d
    e
    f
    

    我们看到主函数已经从 Goroutine 接收了所有消息并打印了它们。主函数仅在通知所有传入消息都已发送后才终止。

在这个练习中,你学习了如何通过通过通道传递消息而不需要 WaitGroup 来让一个 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 服务器

你已经看到了如何在第十五章,HTTP 服务器中构建 HTTP 服务器,但你可能还记得,在 HTTP 服务器中有些东西很难处理,那就是应用程序的状态。本质上,HTTP 服务器作为一个单独的程序运行,并在主例程中监听请求。然而,当客户端发起一个新的 HTTP 请求时,会创建一个新的例程来处理该特定请求。你没有手动做这件事,也没有管理服务器的通道,但这是它内部的工作方式。你实际上不需要在不同的 Goroutines 之间发送任何东西,因为每个例程和每个请求都是独立的,因为它们是由不同的人发起的。

然而,你必须考虑的是,当你想要保持状态时,如何不创建竞态条件。大多数 HTTP 服务器是无状态的,特别是如果你正在构建一个微服务环境。然而,你可能想用计数器跟踪一些事情,或者你可能实际上在与 TCP 服务器、游戏服务器或聊天应用一起工作,在这些应用中你需要保持状态并从所有对等方收集信息。你在这章中学到的技术允许你这样做。你可以使用互斥锁来确保计数器在所有请求中是线程安全的,或者更好的是,是例程安全的。我建议你回到你的 HTTP 服务器代码,并使用互斥锁来确保安全性。

作为例程的方法

到目前为止,你只看到函数被用作 Goroutines,但方法实际上是带有接收者的简单函数;因此,它们也可以异步使用。如果你想要共享你结构体的一些属性,比如在 HTTP 服务器中的计数器,这可能会很有用。

使用这种技术,你可以封装你用于同一结构体实例所属的多个例程之间的通道,而无需将这些通道传递到每个地方。

这里有一个简单的例子说明如何做到这一点:

type MyStruct struct {}
func (m MyStruct) doIt()
. . . . . . 
ms := MyStruct{}
go ms.doIt()

但让我们看看如何在练习中应用这一点。

练习 16.10:结构化工作

在这个练习中,我们将使用几个工作线程来计算总和。工作线程本质上是一个函数,我们将把这些工作线程组织到一个单独的结构体中。

  1. 创建你的文件夹和main文件。在其中,添加所需的导入并定义一个带有两个通道inoutWorker结构体。确保你添加了一个互斥锁:

    package main
    import (
        "fmt"
        "sync"
    )
    type Worker struct {
        in, out chan int
        sbw int
        mtx *sync.Mutex
    }
    
  2. 要创建其方法,请编写以下内容:

    func (w *Worker) readThem() {
        w.sbw++
        go func() {
    

    在这里,我们创建一个方法并增加subworkers的数量。子工作基本上是相同的程序,它们将需要完成的工作分割开来。请注意,这个函数旨在直接使用,而不是作为一个 Goroutine,因为它本身会创建一个新的 Goroutine。

  3. 现在,构建产生程序的内容:

            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,因为我们只将产生一个内容如下所示的程序:

            for  i:= range w.out{
                total += i
            }
            wg.Done()
        }()
    

    正如你所见,我们已经循环直到子工作中的一个关闭了输出通道。

  7. 在这一点上,我们可以等待程序完成并返回结果:

        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 Context Package

我们已经看到了如何运行并发代码,并运行它直到完成,通过 WaitGroup 或通道读取等待某些处理完成。你可能在一些 Go 代码中看到过,特别是与 HTTP 调用相关的代码,一些来自context包的参数,你可能想知道它是什么以及为什么使用它。我们在这里编写的所有代码都在我们的机器上运行,并不通过互联网,所以我们几乎没有因为延迟造成的延迟;然而,在涉及 HTTP 调用的场合,我们可能会遇到不响应的服务器并卡住。在这种情况下,如果服务器在一段时间后没有响应,我们如何停止我们的调用?当发生事件时,我们如何停止独立运行的程序的执行?嗯,我们有一些方法,但一个标准的方法是使用上下文,我们现在将看到它们是如何工作的。上下文是一个变量,它通过一系列调用传递,可能包含一些值或者可能是空的。它是一个容器,但它不是用来在函数之间发送值的;你可以使用正常的整数、字符串等来达到这个目的。上下文被传递是为了获取对正在发生的事情的控制:

func doIt(c context.Context , a int, b string) {
  fmt.Println(b)
  doThat(c, a*2)
}
func doThat(c context.Context , a int) {
  fmt.Println(a)
  doMore(c)
}

正如你所见,有几个调用,c被传递了,但我们并没有对它做任何事情。然而,它可以包含数据,并且它包含了我们可以用来停止当前例程执行的函数。我们将在下一个练习中看到它是如何工作的。

练习 16.11:使用上下文管理例程

在这个练习中,我们将启动一个无限循环的 Goroutine,从零开始计数,直到我们决定停止它。我们将利用上下文来通知例程停止,并使用睡眠函数来确保我们知道我们进行了多少次迭代。

  1. 创建你的文件夹和一个main.go文件,然后编写以下内容:

    package main
    import (
        "context"
        "log"
        "time"
    )
    

    对于常规导入,我们有logstime,我们已经见过,加上context包。

  2. 让我们编写一个每 100 毫秒从 0 开始计数的函数:

    func countNumbers(c context.Context, r chan int) {
        v := 0
        for {
    

    在这里,v是我们从零开始计数的值。c变量是上下文,而r变量是返回结果的通道。然后,我们开始定义循环。

  3. 现在,我们开始一个无限循环,但在这个循环内部我们将有select

             select {
            case <-c.Done():
                r <- v
                break
    

    在这个select组中,我们有一个检查上下文是否完成的情况,如果是,我们就跳出循环并返回到目前为止所计的值。

  4. 如果上下文尚未完成,我们需要继续计数:

                default:
                time.Sleep(time.Millisecond * 100)
                v++
            }
        }
    }
    

    在这里,我们睡眠 100 毫秒,然后增加值 1。

  5. 下一步是编写一个main()函数,使其使用这个计数器:

    func main() {
        r := make(chan int)
        c := context.TODO()
    

    我们创建了一个整数通道来传递给计数器和一个上下文

  6. 我们需要能够取消上下文,所以我们将这个简单的上下文扩展为可取消的上下文:

         cl, stop := context.WithCancel(c)
        go countNumbers(cl, r)
    

    在这里,我们也最终调用了计数例程。

  7. 在这一点上,我们需要一种方法来跳出循环,所以我们将使用context.WithCancel返回的stop()函数,但我们将在另一个 Goroutine 中这样做。这将使上下文在 300 毫秒后停止:

         go func() {
            time.Sleep(time.Millisecond*100*3)
            stop()
        }()
    
  8. 在这一点上,我们只需要等待收到带有计数的消息并将其记录下来:

         v := <- r
        log.Println(v)
    }
    

    经过 300 毫秒后,计数器将返回 3,因为由于上下文操作,例程在第三次迭代时停止:

    2019/10/28 20:00:58 3
    

    在这里,我们可以看到,尽管循环是无限的,但执行在三次迭代后停止。

在这个练习中,你学习了如何使用上下文来停止例程的执行。这在许多情况下非常有用,例如在执行长时间任务且希望在其后最多时间内停止任务时。

有一个需要提到的是,在这个练习中,我们做了一些在某些情况下可能导致问题的操作。我们所做的是在一个 Goroutine 中创建了一个通道,但在另一个 Goroutine 中关闭了它。这并不错;在某些情况下,它可能是有用的,但尽量避免它,因为它可能导致在有人查看代码或几个月后查看代码时出现问题,因为很难跟踪在多个函数中关闭通道的位置。

摘要

在本章中,你已经学会了如何创建生产就绪的并发代码,如何处理竞态条件,以及如何确保你的代码是并发安全的。你学习了如何使用通道使 goroutines 之间相互通信,以及如何使用上下文来停止它们的执行。

你已经掌握了处理并发计算的一些技术。在许多实际场景中,你可能只需要使用为你处理并发的函数和方法,尤其是如果你在进行 Web 编程的话,但也有一些情况,你必须自己处理来自不同来源的工作。你需要通过不同的通道匹配请求和响应。你可能需要从不同的来源将不同的数据汇集到单个例程中。通过在这里学到的知识,你将能够完成所有这些。你将能够确保在等待所有 Goroutines 完成时不会丢失数据。你将能够从不同的例程中修改相同的变量,确保如果你不希望覆盖值时不会覆盖。你还学习了如何避免死锁,以及如何使用通道来共享信息。Go 的一个座右铭是“通过通信来共享,而不是通过共享来通信。”这意味着共享值的首选方式是通过通道发送,如果不是严格必要的话,不要依赖互斥锁。你现在知道如何做到所有这些。

在下一章中,你将学习如何让你的代码更加专业。本质上,你将学习作为一个专业人士在真实工作环境中应该做什么,那就是测试和检查你的代码,确保你的代码能够工作并且是有效的。

第十七章:17. 使用 Go 工具

概述

本章将教你如何利用 Go 工具库来改进和构建你的代码。它还将帮助你使用 Go 工具构建和改进你的代码,并使用go build创建二进制文件。它将向你展示如何使用goimports清理库导入,使用go vet检测可疑结构,以及使用 Go 竞态检测器识别代码中的竞态条件。

到本章结束时,你将能够使用go run运行代码,使用gofmt格式化代码,使用go doc自动生成文档,以及使用go get下载第三方包。

简介

在上一章中,你已经学会了如何生成并发代码。虽然与其它语言相比,Go 使创建并发代码的任务变得容易得多,但并发代码本质上很复杂。这就是学习使用工具来编写更好的代码,从而简化复杂性的时候。

在本章中,你将了解 Go 工具。Go 附带了一些工具来帮助你编写更好的代码。例如,在前面的章节中,你遇到了go build,你用它将代码构建成可执行文件。你也遇到了go test,你用它来测试代码。还有一些其他工具以不同的方式帮助。例如,goimports工具将检查你是否有了使你的代码正常工作所需的全部导入语句,如果没有,它会添加它们。它还可以检查是否有任何导入语句不再需要,并删除它们。虽然这看起来很简单,但它意味着你不再需要担心导入,而可以专注于你正在编写的代码。或者,你可以使用 Go 竞态检测器来查找代码中隐藏的竞态条件。当你开始编写并发代码时,这是一个极其宝贵的工具。

Go 语言提供的工具是它受欢迎的原因之一。它们提供了一种标准的方式来检查代码的格式问题、错误和竞态条件,这在你在专业环境中开发软件时非常有用。本章中的练习提供了如何使用这些工具来改进你的代码的实用示例。

go build 工具

go build工具将 Go 源代码编译成可执行文件。在创建软件时,你用人类可读的编程语言编写代码。然后,代码需要被翻译成机器可读的格式才能执行。这是通过编译器完成的,它从源代码编译出机器指令。要使用 Go 代码做这件事,你会使用go build

练习 17.01:使用 go build 工具

在这个练习中,你将了解go build工具。它将你的 Go 源代码编译成二进制文件。要使用它,请在命令行上运行go build工具,如下所示:

go build -o name_of_the_binary_to_create source_file.go

让我们开始吧:

  1. 在你的 GOPATH 上创建一个名为Exercise17.01的新目录。在该目录内,创建一个名为main.go的新文件:

  2. 将以下代码添加到文件中,以创建一个简单的 Hello World 程序:

    package main
    import "fmt"
    func main() {
      fmt.Println("Hello World")
    }
    
  3. 要运行程序,您需要打开您的终端并导航到您创建 main.go 文件所在的目录。然后,通过编写以下命令来运行 go build 工具:

    go build -o hello_world main.go
    
  4. 这将创建一个名为 hello_world 的可执行文件,您可以通过在命令行中运行它来执行该二进制文件:

    > ./hello_world
    

    输出将如下所示:

    Hello World
    

在这个练习中,您使用了 go build 工具将代码编译成二进制文件并执行它。

go run 工具

go run 工具与 go build 类似,因为它会编译您的 Go 代码。然而,细微的区别在于 go build 会输出一个可以执行的二进制文件,而 go run 工具不会创建需要执行的二进制文件。它将代码编译并运行在一个步骤中,最终没有二进制文件输出。如果您想快速检查代码是否按预期工作,而不需要创建和运行二进制文件,这很有用。这通常在测试代码时使用,以便您可以快速运行代码,而无需创建执行的二进制文件。

练习 17.02:使用 go run 工具

在这个练习中,您将了解 go run 工具。这是一个用于在单个步骤中编译和运行您的代码的快捷方式,如果您想快速检查代码是否工作,这很有用。要使用它,请在命令行中按照以下格式运行 go run 工具:

go run source_file.go

执行以下步骤:

  1. 在您的 GOPATH 中创建一个名为 Exercise17.02 的新目录。在该目录内,创建一个名为 main.go 的新文件。

  2. 将以下代码添加到文件中,以创建一个简单的 Hello Packt 程序:

    package main
    import "fmt"
    func main() {
      fmt.Println("Hello Packt")
    }
    
  3. 现在,您可以使用 go run 工具运行程序:

    go run main.go
    

    这将执行代码并在一个步骤中运行它,给出以下输出:

    Hello Packt
    

在这个练习中,您使用了 go run 工具来单步编译和运行一个简单的 Go 程序。这有助于快速检查代码是否按预期工作。

gofmt 工具

gofmt 工具用于保持您的代码整洁并保持一致的样式。在处理大型软件项目时,代码风格是一个重要但常常被忽视的因素。在整个项目中保持一致的代码风格对于可读性非常重要。当您需要阅读他人的代码,或者几个月后再次阅读自己的代码时,保持一致的样式可以让您在无需太多努力的情况下专注于逻辑。在阅读代码时解析不同的样式只是又一件需要担心的事情,并可能导致错误。为了克服这个问题,Go 提供了一个名为 gofmt 的工具,可以自动以一致的方式格式化您的代码。这意味着,在您的项目中和使用 gofmt 工具的其他 Go 项目中,代码将保持一致。因此,它将通过纠正间距和缩进来修复代码的格式,并尝试对齐代码的各个部分。

练习 17.03:使用 gofmt 工具

在这个练习中,你将学习如何使用gofmt工具来格式化你的代码。当你运行gofmt工具时,它会显示它认为文件应该如何看起来,具有正确的格式,但它不会更改文件。如果你想让gofmt自动将文件更改为正确的格式,你可以使用带有-w选项的gofmt,这将更新文件并保存更改。让我们开始吧:

  1. 在你的 GOPATH 中创建一个名为Exercise17.03的新目录。在该目录内,创建一个名为main.go的新 Go 文件。

  2. 将以下代码添加到文件中,以创建一个格式错误的Hello Packt程序:

    package main
        import "fmt"
    func
    main(){
      firstVar := 1
           secondVar :=    2
      fmt.Println(firstVar)
                      fmt.Println(secondVar)
      fmt.    Println("Hello Packt")
                        }
    
  3. 然后,在你的终端中运行gofmt以查看文件将看起来如何:

    gofmt main.go
    

    这将显示文件应该如何格式化以使其正确。以下是预期的输出:

    ![图 17.1:gofmt 的预期输出]

    ![img/B14177_17_01.jpg]

    图 17.1:gofmt 的预期输出

    然而,这仅显示它将做出的更改;它不会更改文件。这样做是为了你可以确认你对这些更改感到满意。

  4. 要实际更改文件并保存这些更改,你需要添加-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来自动为你添加导入。它还会删除未使用的导入,并将剩余的导入按字母顺序重新排序,以提高可读性。

练习 17.04:使用 goimports 工具

在这个练习中,您将学习如何使用goimports来管理简单 Go 程序中的导入。当您运行goimports工具时,它将输出它认为文件应该如何看起来,导入已修复。或者,您可以使用带有-w选项的goimports,它会自动更新文件中的导入并保存更改。让我们开始吧:

  1. 在您的 GOPATH 上创建一个名为Exercise17.04的新目录。在该目录内,创建一个名为main.go的新文件。

  2. 将以下代码添加到文件中,以创建一个带有错误导入的简单Hello Packt程序:

    package main
    import (
      "net/http"
      "fmt"
    )
    func main() {
      fmt.Println("Hello")
      log.Println("Packt")
    }
    

    您会注意到log库尚未导入,而net/http导入未使用。

  3. 在您的终端中运行goimports工具来查看导入如何更改:

    goimports main.go
    

    这将显示它将如何修改文件以纠正错误。以下为预期输出:

    图 17.2:goimports 的预期输出

    图 17.2:goimports 的预期输出

    这不会更改文件,但显示了文件将被更改成什么样子。如您所见,net/http导入已被删除,而log导入已被添加。

  4. 要将这些更改写入文件,请添加-w选项:

    goimports -w main.go
    
  5. 这将更新文件并使其看起来如下:

    package main
    import (
      "fmt"
      "log"
    )
    func main() {
      fmt.Println("Hello")
      log.Println("Packt")
    }
    

    许多 IDE 都内置了开启goimports的方式,这样当您保存文件时,它会自动为您纠正导入。

在这个练习中,您学习了如何使用goimports工具。您可以使用此工具来检测不正确和未使用的导入语句,并自动纠正它们。

go vet 工具

go vet工具用于对 Go 代码进行静态分析。虽然 Go 编译器可以找到并通知您可能犯的错误,但它会错过某些事情。因此,创建了go vet工具。这听起来可能微不足道,但其中一些问题可能在代码部署后很长时间内才会被发现,其中最常见的是在调用Prinf函数时传递了错误的参数数量。它还会检查无用的赋值,例如,如果您设置了一个变量然后从未使用过该变量。它还会检测当将非指针接口传递给“unmarshal”函数时的情况。编译器不会注意到这一点,因为它有效;然而,unmarshal 函数将无法将数据写入接口。这可能会在调试时造成麻烦,但使用go vet工具可以在问题成为问题之前及早捕捉并修复它。

练习 17.05:使用 go vet 工具

在这个练习中,您将使用go vet工具来查找在使用Printf函数时常见的错误。您将使用它来检测是否向Printf函数传递了错误的参数数量。让我们开始吧:

  1. 在您的 GOPATH 上创建一个名为Exercise17.05的新目录。在该目录内,创建一个名为main.go的新 Go 文件:

  2. 将以下代码添加到文件中,以创建一个简单的 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 工具正是为此原因而创建的。

  3. 对您创建的文件运行 go vet 工具:

    go vet main.go
    
  4. 这将显示它在代码中发现的任何问题:图 17.3:go vet 预期输出

    图 17.3:go vet 预期输出

    如您所见,go vet 已经在文件的第 9 行识别到一个问题。Sprintf 调用需要 1 个参数,但我们提供了 2 个。

  5. 更新 Sprintf 调用,使其能够处理我们想要发送的两个参数:

    package main
    import "fmt"
    func main() {
      helloString := "Hello"
      packtString := "Packt"
      jointString := fmt.Sprintf("%s &s", helloString, packtString)
      fmt.Println(jointString)
    }
    
  6. 现在,您可以再次运行 go vet 并检查是否还有更多问题:

    go vet
    

    它应该返回空值,让您知道文件没有更多问题。

  7. 现在,运行程序:

    go run main.go
    

    修正后的输出是我们想要的字符串,如下所示:

    Hello Packt
    

在这个练习中,您学习了如何使用 go vet 工具来检测编译器可能遗漏的问题。虽然这是一个非常基础的例子,但 go vet 可以检测诸如向 unmarshal 函数传递非指针或检测不可达代码等错误。鼓励将 go vet 作为构建过程的一部分运行,以在这些问题进入您的程序之前捕获它们。

Go 竞赛检测器

Go 竞赛检测器被添加到 Go 中,以便能够检测竞争条件。正如我们在第十六章并发工作中提到的,您可以使用 goroutines 来并发运行代码的一部分。然而,即使是经验丰富的程序员也可能犯下错误,允许不同的 goroutines 同时访问相同的资源。这被称为竞争条件。竞争条件是问题性的,因为一个 goroutine 可能会在另一个正在读取资源的 goroutine 中间编辑资源,这意味着资源可能会被损坏。虽然 Go 已经将并发作为语言中的第一公民,但并发代码的机制并不能防止竞争条件。此外,由于并发的固有性质,竞争条件可能直到您的代码部署很长时间后才被发现。这也意味着它们往往是瞬时的,这使得它们难以调试和修复。这就是为什么创建 Go 竞赛检测器的原因。

此工具通过使用一种检测异步内存访问的算法来工作,但它的缺点是只能在代码执行时进行。因此,您需要运行代码才能检测到竞争条件。幸运的是,它已经集成到 Go 工具链中,因此我们可以用它来自动完成这项工作。

练习 17.06:使用 Go 竞赛检测器

在这个练习中,你将创建一个包含竞态条件的基本程序。你将使用 Go 竞态检测器来查找程序中的竞态条件。你将学习如何确定问题所在,然后学习减轻竞态条件的方法。让我们开始吧:

  1. 在你的 GOPATH 中创建一个名为Exercise17.06的新目录。在该目录内,创建一个名为main.go的新文件。

  2. 将以下代码添加到文件中,以创建一个具有竞态条件的简单程序:

    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 正在尝试打印出数组中的所有项目。因此,两个 goroutine 同时访问相同的资源,这就是竞态条件。

  3. 激活race标志运行代码:

    go run --race main.go
    

    运行此命令将给出以下输出:

    图 17.4:使用 Go 竞态检测器时的预期输出

    图 17.4:使用 Go 竞态检测器时的预期输出

  4. 在前面的屏幕截图中,你可以看到一个警告,告诉你有关竞态条件的信息。它告诉你代码中在main.go:10main.go:15行中读取和写入相同的资源,如下所示:

      names = append(names, "Electric")
    

      for _, name := range names {
    

    如你所见,在这两种情况下,都是访问names数组,所以问题就出在这里。这种情况发生的原因是程序在等待finished通道之前开始打印names

  5. 一种解决方案是在打印项目之前等待finished通道:

      <-finished
      for _, name := range names {
        fmt.Println(name)
      }
    
  6. 这意味着在开始打印之前,所有项目都将被添加到数组中。你可以通过再次运行程序并激活竞态标志来确认这个解决方案:

    go run --race main.go
    
  7. 这应该正常运行程序,并且不会显示竞态条件警告。在进行了修正之后,预期的输出如下所示:

    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 为他们编写的代码生成文档,并与其他团队共享。

练习 17.07:实现 go doc 工具]

在这个练习中,你将了解 go doc 工具以及如何用它来生成代码的文档。让我们开始吧:

  1. 在你的 GOPATH 中创建一个名为 Exercise17.07 的新目录。在该目录内,创建一个名为 main.go 的新文件。

  2. 将以下代码添加到你创建的 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 integers multiplied 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 的函数,用于乘以两个数字。

  3. 运行以下命令来编译和执行文件:

    go run main.go
    
  4. 输出将如下所示:

    2
    4
    
  5. 你会注意到这两个函数上方都有注释,注释以函数名开头。这是 Go 的一个约定,表示这些注释可以用作文档。这意味着你可以使用 go doc 工具为代码创建文档。在你的 main.go 文件所在的目录中,运行以下命令:

    go doc -all
    
  6. 这将生成代码的文档并将其输出,如下所示:

![图 17.5:go doc 的预期输出

![img/B14177_17_05.jpg]

图 17.5:go doc 的预期输出

在这个练习中,你学习了如何使用 go doc 工具为创建的 Go 包及其函数生成文档。你可以使用它为其他包生成文档,并与他人共享,如果他们想使用你的代码的话。

go get 工具

go get 工具允许你下载和使用不同的库。虽然 Go 默认附带了一系列的包,但与可用的第三方包数量相比,就显得微不足道了。这些包提供了额外的功能,你可以在自己的代码中使用它们来增强代码。然而,为了使你的代码能够使用这些包,你需要在电脑上安装它们,以便编译器在编译你的代码时包含它们。要下载这些包,你可以使用 go get 工具。

练习 17.08:实现 go get 工具

在这个练习中,你将学习如何使用 go get 下载第三方包。让我们开始吧:

  1. 在你的 GOPATH 中创建一个名为 Exercise17.08 的新目录。在该目录内,创建一个名为 main.go 的新文件。

  2. 将以下代码添加到你创建的 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))
    }
    
  3. 这是一个简单的 Web 服务器,你可以通过运行以下命令来启动它:

    go run main.go
    
  4. 然而,web 服务器使用了一个名为 "mux" 的第三方包。在导入部分,你会看到它已经被从 "github.com/gorilla/mux" 导入。然而,由于我们没有在本地存储这个包,当我们尝试运行程序时会出现错误:![图 17.6:预期的错误信息 img/B14177_17_06.jpg

    图 17.6:预期的错误信息

  5. 要获取第三方包,你可以使用 go get。这将将其下载到本地,以便我们的 Go 代码可以使用它:

    go get github.com/gorilla/mux
    
  6. 现在你已经下载了包,你可以再次运行 web 服务器:

    go run main.go
    

    这次,它应该可以无错误地运行:

    ![图 17.7:运行 web 服务器时的预期输出 img/B14177_17_07.jpg

    图 17.7:运行 web 服务器时的预期输出

  7. 当 web 服务器运行时,你可以在你的网络浏览器中打开 http://localhost:8888 并检查它是否工作:

![图 17.8:在 Firefox 中查看 web 服务器输出img/B14177_17_08.jpg

图 17.8:在 Firefox 中查看 web 服务器输出

在这个练习中,你学习了如何使用 go get 工具下载第三方包。这允许使用 Go 标准包之外的工具和包。

活动 17.01:使用 gofmt、goimport、go vet 和 go get 修正文件

想象你正在对一个代码编写不佳的项目进行工作。该文件包含格式错误的文件,缺失的导入,以及放置错误的位置的日志信息。你希望使用本章中学习到的 Go 工具来修正文件并查找其中的任何问题。在这个活动中,你将使用 gofmtgoimportgo vetgo get 来修正文件并查找其中的任何问题。这个活动的步骤如下:

  1. 创建一个名为 Activity 17.01 的目录。

  2. 创建一个名为 main.go 的文件。

  3. 将示例代码添加到 main.go 中。

  4. 修复任何格式问题。

  5. 修复 main.go 中任何缺失的导入。

  6. 使用 go vet 检查编译器可能遗漏的任何问题。

  7. 确保第三方包 "gorilla/mux" 已经下载到你的本地计算机。

    以下是你预期的输出:

![图 17.9:运行代码时的预期输出img/B14177_17_09.jpg

图 17.9:运行代码时的预期输出

你可以通过在浏览器中访问 http://localhost:8888 来检查这是否成功:

![图 17.10:通过 Firefox 访问 web 服务器时的预期输出img/B14177_17_10.jpg

图 17.10:通过 Firefox 访问 web 服务器时的预期输出

注意

这个活动的解决方案可以在第 775 页找到。

以下是需要修正的示例代码:

package main
import (
  "log"
  "fmt"
  "github.com/gorilla/mux"
)
// ExampleHandler handles the http requests send 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 build 以及如何将你的代码编译成可执行文件。然后,你了解到在项目开发中,保持代码的一致性和整洁是多么重要,以及你可以如何使用 gofmt 自动美化代码。这可以通过 goimports 进一步改进,它可以移除不必要的导入以增强安全性,并自动添加你可能忘记添加的导入。

之后,你了解了 go vet 以及它如何帮助你找到编译器可能遗漏的任何错误。你还学习了如何使用 Go 竞态检测器来查找代码中隐藏的竞态条件。然后,你学习了如何为你的代码生成文档,这有助于在处理大型项目时进行更轻松的协作。最后,你了解了如何使用 go get 工具下载第三方包,这允许你利用在线可用的众多 Go 包来增强你自己的代码。

在下一章中,你将学习关于安全的内容。你将了解如何防止你的代码被利用,以及如何保护它免受常见的攻击向量。

第十八章:18. 安全

概述

本章旨在为你提供保护代码免受攻击和漏洞的基本技能。你将能够评估主要攻击向量的工作原理,实现用于数据加密和解密的加密库,并通过使用 TLS 证书实现通信安全。

到本章结束时,你将能够识别可能导致安全漏洞的常见代码问题,并重构代码以提高其安全性。

简介

在上一章中,我们学习了 Go 工具,如fmtvetrace,这些工具旨在帮助你进行代码开发。现在,让我们看看如何通过查看常见漏洞的示例来保护你的代码。我们还将查看标准库中的包,这些包可以帮助你安全地存储数据。

安全性不能是事后考虑的事情。它应该是你代码练习的一部分,是你每天都要练习的东西。应用程序中的大多数漏洞都源于开发者不了解潜在的安全攻击,以及没有在应用程序部署前对其进行安全审查。

如果你查看任何处理敏感数据的网站,例如,银行网站,它们将具备基本的安全措施,例如使用已签名的 SSL 证书。在设计应用程序时始终考虑安全性,而不是事后添加安全层,这样可以避免重构或重新设计应用程序,这总是更好的。在本章中,我们将介绍一些主要的攻击向量以及最佳实践,这些将指导你保护你的应用程序。以下代码中的基本合理性检查将确保你默认受到大多数漏洞和攻击的保护。

应用程序安全

在你的应用程序开发过程中,你将无法预测它可能被攻击的所有可能方式。然而,你始终可以通过遵循安全的编码实践来尝试保护应用程序,例如在传输和静止状态下加密数据。众所周知,如果我们保护应用程序免受 SQL 注入等已知攻击向量的攻击,我们就能抵御大多数攻击。我们将讨论使用数字证书和散列敏感数据以保护其免受攻击者侵害的话题。

软件应用程序的主要攻击向量之一是命令或 SQL 注入,恶意用户输入可以改变命令或查询的行为。这可能在 SQL 查询、HTTP URL 或操作系统命令中的构建不良查询时发生。

让我们详细看看 SQL 注入和命令注入。

SQL 注入

如果你正在开发需要存储数据的应用程序,你很可能会使用数据库。

SQL 注入是一种将恶意代码注入到你的数据库查询中的方法。尽管这是无意的,但这可能对你的应用程序产生重大影响,例如数据丢失或敏感信息泄露。

让我们看看一些示例,以了解 SQL 注入的确切内容和它是如何工作的。

以下函数接受一个userID参数,并使用它来查询数据库以返回属于用户的卡号:

func GetCardNumber(db *sql.DB, userID string) (resp string, err error) {
  query := `SELECT CARD_NUMBER FROM USER_DETAILS WHERE USER_ID = ` + userID
  row := db.QueryRow(query)
  switch err = row.Scan(&resp); err {
  case sql.ErrNoRows:
    return resp, fmt.Errorf("no rows returned")
  case nil:
    return resp, err
  default:
    return resp, err
  }
  return
}

如果用户输入是795001,查询字符串将解析为:

query := `SELECT CARD_NUMBER FROM USER_DETAILS WHERE USER_ID = 795001`

然而,恶意用户可以构造一个输入字符串,导致函数检索不属于用户的信息。例如,他们可以向函数传递以下输入:

"" OR '1' == '1'

这个用户输入将生成一个查询,该查询将返回所有用户的CARD_NUMBER

`SELECT CARD_NUMBER FROM USER_DETAILS WHERE USER_ID = "" OR '1' == '1'

如您所见,在定义数据库查询时很容易出错。

除了获取未经授权的数据访问外,SQL 注入还可以用于破坏或甚至删除数据。

因此,定义查询的惯用方法是什么?我们绝对不应该通过将用户输入连接成查询字符串来构造查询。相反,使用预处理语句来定义查询,其中使用占位符传递用户参数,如下例所示:

func GetCardNumberSecure(db *sql.DB, userID string) (resp string, err error){
  stmt, err := db.Prepare(`SELECT CARD_NUMBER FROM USER_DETAILS WHERE USER_ID =     ?`)
  if err != nil {
    return resp, err
  }
  defer stmt.Close()
  row := stmt.QueryRow(userID)
  switch err = row.Scan(&resp); err {
  case sql.ErrNoRows:
    return resp, fmt.Errorf("no rows returned")
  case nil:
    return resp, err
  default:
    return resp, err}
  }
  return
}

通过使用用户输入的占位符,我们已经减轻了潜在的 SQL 注入攻击。

命令注入

命令注入是另一种应警惕的注入攻击向量。注入的目的是在应用程序服务器上执行 OS 命令,这可能允许攻击者获取敏感数据、删除文件,甚至在服务器上执行恶意脚本。这种攻击可能发生在用户输入未经过滤的情况下。

我们将通过以下示例了解它是如何工作的。考虑这个函数,它接受一个字符串作为输入并使用它来列出文件:

func listFiles(path string) (string, error) {
  cmd := exec.Command("bash", "-c", "ls"+path)
  var out bytes.Buffer
  cmd.Stdout = &out
  err := cmd.Run()
  if err != nil {
    return "", err
  }
  return out.String(), nil
}

这里有几个问题:

  • 用户输入未经过滤。

  • 用户可以传递任何字符串作为路径。

  • 除了path string之外,用户还可以添加其他可以在服务器上运行的命令。

让我们通过对这个函数运行单元测试来测试这一点。以下测试运行应该证明之前列出的所有问题:

package command_injection
import "testing"
func TestListFiles(t *testing.T) {
  out, err := listFiles(" .; cat /etc/hosts")
  if err != nil {
    t.Error(err)
  } else {
    t.Log(out)
  }
}

当您使用前面的命令运行测试时,应该得到以下输出:

go test -v ./...

![图 19.1:预期输出图 18.1:预期输出

图 18.1:预期输出

如您所见,用户没有传递有效的文件名,而是传递了一个使函数返回目录中的文件以及读取服务器上的/etc/hosts文件的字符串。

练习 18.01:处理 SQL 注入

在这个练习中,我们将启用一个功能来防止 SQL 注入攻击。

注意

在这个练习中,我们将使用一个轻量级的数据库Sqlite,它可以在您的本地机器上内存中运行。要使用数据库,我们需要导入一个使用cgo的第三方 Go 库。

packt.live/38Bjl3a

如果您使用的是 Windows 机器,您需要安装 GCC 并将其包含在您的路径中。您可以使用此网站上的说明为 Windows 安装 GCC:https://packt.live/38Bjl3a。

以下步骤将帮助你解决问题:

  1. 创建injection.go并导入以下包:

    package exercise1
    import (
      "database/sql"
      "fmt"
      "strings"
    )
    
  2. 定义一个名为UpdatePhone()的函数,该函数接受一个sql.DB对象和一些用户信息,如 ID 和电话号码(作为字符串):

    func UpdatePhone(db *sql.DB, Id string, phone string) error {
      var builder strings.Builder
      builder.WriteString("UPDATE USER_DETAILS SET PHONE=")
      builder.WriteString(phone)
      builder.WriteString(" WHERE USER_ID=")
      builder.WriteString(Id)
      fmt.Printf("Running query: %s\n", builder.String())
      _, err := db.Exec(builder.String())
      if err != nil {
        return err
      }
      return nil
    }
    

    UpdatePhone()函数通过拼接输入参数中的数据将用户 ID 和电话号码插入到表中。

    UpdatePhone()函数中的查询字符串容易受到 SQL 注入的影响。例如,如果传递以下值的输入:

    ID: "19853011 OR USER_ID=1007007"
    

    这将不仅更新用户 ID 为"19853011"的记录,还会更新"1007007"的记录。这是一个简单的例子。然而,可能会发生更糟糕的事情,比如在数据库中删除表。

  3. 创建另一个名为UpdatePhoneSecure()的函数,该函数将安全地更新用户详细信息。而不是将输入拼接成查询,使用占位符传递参数到查询中:

    injection.go
    20 func UpdatePhoneSecure(db *sql.DB, Id string, phone string) error {
    21   stmt, err := db.Prepare(`UPDATE USER_DETAILS SET PHONE=? WHERE        USER_ID=?`)
    22   if err != nil {
    23     return err
    24   }
    25   defer stmt.Close()
    26   result, err := stmt.Exec(phone, Id)
    27   if err != nil {
    28     return err
    29   }
    The full code for this step is available at: https://packt.live/34QWP31
    
  4. 定义一个名为initializeDB()的辅助函数,用于设置数据库并加载一些测试数据:

    func initializeDB(db *sql.DB) error {
      _, err := db.Exec(`CREATE TABLE IF NOT EXISTS USER_DETAILS (USER_ID TEXT,     PHONE TEXT, ADDRESS TEXT)`)
      if err != nil {
        return err
      }
      stmt, err := db.Prepare(`INSERT INTO USER_DETAILS (USER_ID, PHONE,     ADDRESS) VALUES (?, ?, ?)`)
      if err != nil {
        return err
      }
      for _, user := range testData {
        _, err := stmt.Exec(user.Id, user.CardNumber, user.Address)
        if err != nil {
          return err
        }
      }
      return nil
    }
    

    注意

    在每次测试后进行清理是一个好习惯。

  5. 定义一个名为tearDownDB()的函数,帮助你清理数据库:

    func tearDownDB(db *sql.DB) error {
      _, err := db.Exec("DROP TABLE USER_DETAILS")
      if err != nil {
        return err
      }
      return nil
    }
    
  6. 我们还需要一个函数来帮助设置数据库连接。定义一个名为getConnection()的函数,它返回一个*sql.DB对象:

    func getConnection() (*sql.DB, error) {
      conn, err := sql.Open("sqlite3", "test.DB")
      if err != nil {
        return nil, fmt.Errorf("could not open db connection %v", err)
      }
      return conn, nil
    }
    
  7. 定义一个TestMain()函数,该函数执行测试数据的设置,然后运行测试。此函数还需要调用tearDownDB()函数来清理测试数据:

    func TestMain(m *testing.M) {
      var err error
      db, err = getConnection()
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
      err = initializeDB(db)
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
      defer tearDownDB(db)
      if m.Run() != 0 {
        fmt.Println("error running tests")
        os.Exit(1)
      }
    }
    
  8. 最后,定义TestUpdatePhoneSecure()函数,帮助你运行针对UpdatePhoneSecure()函数的测试:

    injection_test.go
    77 func TestUpdatePhoneSecure(t *testing.T) {
    78   var tests = []struct {
    79     ID    string
    80     Phone string
    81     err   string
    82   }{
    83     {
    84       ID:    "1",
    85       Phone: "1234",
    86       err:   "",
    87     },
    The full code for this step is available at: https://packt.live/34MEJze
    
  9. 使用以下命令运行测试:

    go test -v ./...
    

    你应该得到以下输出:

图 19.2:预期输出

图 18.2:预期输出

当用户输入未正确清理时,可能会发生 SQL 和命令注入。通常,我们应该避免直接将用户输入传递到 SQL 或操作系统命令中。

在这个练习中,我们学习了如何安全地编写 SQL 代码以保护应用程序免受 SQL 注入的攻击。

跨站脚本攻击

跨站脚本攻击(XSS)是另一种常见的攻击类型,经常被列入 OWASP(开放网络应用安全项目)的前十种应用程序漏洞。与 SQL 注入类似,这种漏洞也是由未清理的用户输入引起的,但在这个情况下,它不是修改数据库的行为,而是将脚本注入到网页中。

网页使用 HTML 标签构建。每个 HTML 页面都包含一些由 HTML 标签括起来的内容,如下所示:

<html>
  Hello World!
</html>

其中一个 HTML 标签是<script>标签,用于嵌入可执行内容——通常是 JavaScript 代码。此标签用于在浏览器上执行客户端代码执行,例如,生成动态内容或操作数据和图像。

<script> 标签内的代码在网页上不可见,因此通常不会被注意到。这个 <script> 标签的特性可以被攻击者利用来运行恶意脚本,窃取敏感数据,监控活动或执行其他未经授权的操作。那么,恶意脚本最初是如何注入的呢?如果通过浏览器输入的用户数据没有经过清理,攻击者可以向 Web 服务器输入/注入恶意脚本,然后它可以存储在数据库中。

当受害者访问页面时,脚本会被加载到他们的浏览器中。

注意

OWASP 是一个提供有用信息以保护应用程序的组织。他们为常见的应用程序安全漏洞提供排名,如 OWASP 前 10 名:

packt.live/36t6RbU

你可以在 OWASP 这里找到更多信息:

packt.live/34ioCsZ

练习 18.02:处理 XSS 攻击

在这个练习中,我们将看到如何在网页上执行 XSS 攻击,然后我们将用代码修复这个问题,使其免受此类攻击:

  1. 创建一个 main.go 文件并导入以下包:

    package main
    import (
      "fmt"
      "net/http"
      "text/template"
    )
    
  2. 定义一个可以用来加载网页的示例 HTML 模板。对于多行文本赋值给变量,可以使用反引号(`)包围的字符串:

    var content = `<html>
    <head>
    <title>My Blog</title>
    </head>
    <body>
      <h1>My Blog Site</h1>
      <h2> Comments </h2>
      {{.Comment}}
      <formaction="/" method="post">
        Add Comment:<input type="text"name="input">
        <input type="submit" value="Submit">
      </form>
    </body>
    </html>`
    
  3. 创建一个名为 inputstruct,其中包含一个名为 Commentstring 类型的字段。这个 struct 将被用来包装用户评论:

    type input struct {
      Comment string
    }
    
  4. 创建一个 handler() 函数以返回 HTTP 请求的响应:

    func handler(w http.ResponseWriter, r *http.Request) {
      var userInput = &input{
        Comment: r.FormValue("input"),
      }
      t := template.Must(template.New("test").Parse(content))
      err := t.Execute(w, userInput)
      if err != nil {
        fmt.Println(err)
      }
    }
    
  5. 定义 main() 函数以运行 HTTP 服务器:

    funcmain() {
      http.HandleFunc("/", handler)
      http.ListenAndServe(":8080", nil)
    }
    
  6. 运行代码:

    go run main.go
    
  7. 在浏览器中打开 http://localhost:8080。你应该能看到以下页面:图 19.3:HTTP 服务器登录页面

    <script>alert("Hello")</script>
    

    这是你将看到的内容:

    图 19.4:XSS 执行

    图 18.4:XSS 执行

  8. 让我们修复我们的 Web 应用程序,使其免受 XXS 攻击。在这种情况下,解决方案就像从 text/template 更新到使用 html/template 包一样简单:

    package main
    import (
      "fmt"
      "net/http"
      "html/template"
    )
    

    如果你再次运行服务器并提交相同的输入,你的输出将被 html/template 库转义,因此不会被当作脚本处理:

图 19.5:XSS 转义输出

图 18.5:XSS 转义输出

在这个练习中,我们学习了在代码中正确使用模板化以保护应用程序免受跨站脚本攻击的方法。

密码学

Go 的标准库中包含了一个非常全面的加密库,涵盖了哈希算法、PKI 证书以及对称和非对称加密算法。

虽然拥有不同加密和哈希库的集合供我们使用很方便,但我们还需要意识到这些算法中的漏洞,以便我们可以为我们的用例选择最合适的算法。

例如,MD5 和 SHA-1 哈希算法被认为不安全用于加密数据,因为它们很容易被暴力破解。然而,它们通常被文件服务器用于提供文件校验和以进行错误检查。

哈希库

哈希是将明文数据转换为加密格式的过程,通过实现一个生成加密文本的算法。这个过程的结果应该是唯一的,并且哈希碰撞的概率,即两个不同的输入产生相同的输出,极不可能。哈希函数在数据库和消息的安全传输中经常被使用。

我们可以使用校验和函数来生成单向哈希。例如,要生成 MD5 校验和,我们可以使用Sum()函数,它接受一个byte数组并返回一个byte数组:

Sum(in []byte) []byte

对于 SHA256,校验和函数的定义非常相似:

Sum256(data []byte) [Size]byte

除了 MD5 之外,Go 的标准库包含 SHA1、SHA256 和 SHA512 的实现。我们将在接下来的练习中看到如何使用它们。

练习 18.03:使用不同的哈希库

在这个练习中,我们将学习如何在 Go 中使用不同的哈希库:

  1. 创建一个main.go文件并导入以下加密哈希库:

    package main
    import (
      "crypto/md5"
      "crypto/sha256"
      "crypto/sha512"
      "fmt"
      "golang.org/x/crypto/blake2b"
      "golang.org/x/crypto/blake2s"
      "golang.org/x/crypto/sha3"
    )
    
  2. 定义一个名为getHash()的实用函数,它接受要哈希的输入字符串和要使用的哈希库类型。定义一个使用hashType输入字符串来决定使用哪种哈希库的switch语句:

    func getHash(input string, hashType string) string {
    
  3. switch语句中,添加使用 MD5、SHA256、SHA512 和 SHA3_512 的情况。switch情况应该使用相应的哈希库返回输入字符串的哈希值:

      switch hashType {
      case "MD5":
        return fmt.Sprintf("%x", md5.Sum([]byte(input)))
      case "SHA256":
        return fmt.Sprintf("%x", sha256.Sum256([]byte(input)))
      case "SHA512":
        return fmt.Sprintf("%x", sha512.Sum512([]byte(input)))
      case "SHA3_512":
        return fmt.Sprintf("%x", sha3.Sum512([]byte(input)))
      default:
        return fmt.Sprintf("%x", sha256.Sum256([]byte(input)))
      }
    }
    
  4. 添加一些标准库中没有的哈希库:

      // from "golang.org/x/crypto/blake2s"
      case "BLAKE2s_256":
        return fmt.Sprintf("%x", blake2s.Sum256([]byte(input)))
      // from "golang.org/x/crypto/blake2b"
      case "BLAKE2b_512":
        return fmt.Sprintf("%x", blake2b.Sum512([]byte(input)))
     }
    }
    

    注意

    除了提到的blake库之外,您还可以在packt.live/2PiwlmH下找到 MD4 和 SHA3 的包。

  5. 定义main()函数并调用之前定义的getHashutility()函数:

    func main() {
      fmt.Println("MD5:", getHash("Hello World!", "MD5"))
      fmt.Println("SHA256:", getHash("Hello World!", "SHA256"))
      fmt.Println("SHA512:", getHash("Hello World!", "SHA512"))
      fmt.Println("SHA3_512:", getHash("Hello World!", "SHA3_512"))
      fmt.Println("BLAKE2s_256:", getHash("Hello World!", "BLAKE2s_256"))
      fmt.Println("BLAKE2b_512:", getHash("Hello World!", "BLAKE2b_512"))
    }
    
  6. 运行程序:

    go run main.go
    

    你应该得到以下输出:

![图 19.6:预期输出图片

图 18.6:预期输出

在这个练习中,我们学习了如何使用 Go 中可用的不同哈希包生成密文。

注意

在前面的示例中,我们导入了某些哈希库,例如packt.live/2ryy9Ps

golang.org/x/下的包仍然是 Go 项目的一部分。然而,它们仍然位于主安装之外,因此您必须运行go get来安装它们。

您可以在这里找到这些包的列表:packt.live/2tbThv7

加密

加密是将数据转换为一种格式的过程,这样它就不能被未授权的接收者读取。

当处理敏感数据时,始终加密它是最佳实践。数据的性质将决定其敏感性。例如,客户的信用卡信息可以被认为是高度敏感数据,而购买的商品可能被认为不是非常敏感。

你可能会遇到“静态加密”和“传输中加密”这两个术语,它们指的是在存储(例如,在数据库中)或传输(例如,通过网络)之前应该如何加密数据。我们将在后续主题(HTTP/TLS)中涉及传输中加密。

在这个主题中,我们将关注底层加密机制。

由于(好的)加密算法本质上很复杂,一般建议始终使用现有的加密算法,而不是发明自己的。加密算法的强度应在于问题的数学复杂性,而不是加密算法工作方式的保密性。因此,“安全”的加密算法都是公开的。

Go 提供了对称加密和非对称加密库。让我们看看这两种加密类型的示例实现。

对称加密

在对称加密中,相同的密钥用于加密和解密。Go 标准库在 crypto/aescrypto/des 下提供了常见对称加密算法的实现,如 AES 和 DES。

使用字符串密钥(例如,密码)加密输入字节数组的基本步骤如下:

在 Go 中创建密文,我们可以使用 Seal() 函数。我们同样使用一个 nonce,它是一个一次性随机序列。这里的 dst 输入变量是一个字节数组,用于存储加密数据:

Seal(dst, nonce, plaintext, additionalData []byte) []byte

要解密密文,我们需要再次使用 crypto/cipher 库来利用 GCM 包装器:

func (g *gcm) Open(dst, nonce, ciphertext, data []byte) ([]byte, error)

练习 18.04:对称加密和解密

在这个练习中,我们将利用 Go 的加密库进行对称加密,并学习如何加密和解密数据:

  1. 创建一个 main.go 文件并导入以下包:

    crypto/cipher:用于块加密实现。

    crypto/aes:AES 是一种加密规范,crypto/aes 是 Go 的实现。

    crypto/rand:用于随机数生成。

    package main
    import (
      "crypto/aes"
      "crypto/cipher"
      "crypto/rand"
      "fmt"
    )
    
  2. 定义一个函数,使用 crypto/aescrypto/cipher 库加密数据。以下函数接受以字节数组形式输入的数据和一个密钥字符串,该字符串通常是秘密密码。它返回加密数据:

    func encrypt(data []byte, key string) (resp []byte, err error) {
      block, err := aes.NewCipher([]byte(key))
      if err != nil {
        return resp, err
      }
    gcm, err := cipher.NewGCM(block)
      if err != nil {
        return resp, err
      }
      nonce := make([]byte, gcm.NonceSize())
      if _, err := rand.Read(nonce); err != nil {
        return resp, err
      }
      return gcm.Seal(dst, nonce, data, []byte("test")), nil
    }
    

    需要存储 nonce 以进行解密。有许多方法可以做到这一点。在前面的实现中,我们通过将 nonce 传递给 Seal() 函数的第一个输入来实现,这是一个字节数组,dst。由于 Seal() 函数将加密数据追加到输入的字节数组中,因此生成的密文将追加到 nonce 上,并作为一个字节数组返回。如果您传递额外的数据,解密生成的密文时值必须匹配。

  3. 定义一个解密数据的函数。它应该接受以字节数组形式提供的加密数据和一个字符串形式的密码短语。它应该返回解密后的数据:

    func decrypt(data []byte, key string) (resp []byte, err error) {
      block, err := aes.NewCipher([]byte(key))
      if err != nil {
        return resp, err
      }
    gcm, err := cipher.NewGCM(block)
      if err != nil {
        return resp, err
      }
      ciphertext := data[gcm.NonceSize():]
      nonce := data[:gcm.NonceSize()]
      resp, err = gcm.Open(nil, nonce, ciphertext, []byte("test"))
      if err != nil {
        return resp, fmt.Errorf("error decrypting data: %v", err)
      }
      return resp, nil
    }
    
  4. 定义 main() 函数以测试 encryptdecrypt 函数:

    func main() {
      const key = "mysecurepassword"
      encrypted, err := encrypt([]byte("Hello World!"), key)
      if err != nil {
        fmt.Println(err)
      }
    fmt.Println("Encrypted Text: ", string(encrypted))
      decrypted, err := decrypt(encrypted, key)
      if err != nil {
        fmt.Println(err)
      }
      fmt.Println("Decrypted Text: ", string(decrypted))
    }
    
  5. 使用以下命令运行程序:

    go run main.go
    

    你应该得到以下输出。

![图 19.7:预期输出

![图片 B14177_18_07.jpg]

图 18.7:预期输出

在这个练习中,我们学习了如何执行对称加密和解密。

非对称加密

非对称加密也称为公钥加密。这种加密机制使用一对密钥,一个公钥和一个私钥。公钥可以自由地分发给愿意与您交换数据的其他合作伙伴。如果合作伙伴想要发送加密数据,他们将使用您的公钥来加密他们的数据。这些加密数据可以通过您的私钥由您解密。

Go 标准库支持常见的非对称加密算法,如 RSA 和 DSA。

例如,使用 rsa.EncryptOAEP() 函数通过公钥加密数据:

EncryptOAEP(hash hash.Hash,randomio.Reader,pub *PublicKey,msg []byte,label   []byte)([]byte,error)

使用 rsa.DecryptOAEP() 函数通过私钥解密密文:

DecryptOAEP(hash hash.Hash, random io.Reader, priv *PrivateKey, ciphertext   []byte, label []byte) ([]byte, error)

加密操作接受 rsa.PublicKey,解密操作接受 rsa.PrivateKey。密钥对可以使用 rsa.GenerateKey() 函数生成:

GenerateKey(random io.Reader, bits int) (*PrivateKey, error)

练习 18.05:非对称加密和解密

在这个练习中,我们将看到加密和解密操作的实际应用:

  1. 创建一个 main.go 文件并导入以下包:

    crypto/rand:此包中的 rand.Reader 将用于生成 rsa.PrivateKey 的种子。

    crypto/rsa:此包是生成私钥和进行 encrypt/decrypt 操作所必需的。

    crypto/sha256:对称哈希函数将被用于生成 rsa.PrivateKey 的种子。

    package main
    import (
      "crypto/rand"
      "crypto/rsa"
      "crypto/sha256"
      "fmt"
      "os"
    )
    
  2. 定义 main() 函数并生成一个 rsa 密钥对:

    func main() {
    privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
      if err != nil {
    fmt.Printf("error generating rsa key: %v", err)
      }
    publicKey := privateKey.PublicKey
    text := []byte("My Secret Text")
    
  3. 使用 publicKey 加密数据:

      ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &publicKey,     text, nil)
      if err != nil {
    fmt.Printf("error encrypting data: %v", err)
    os.Exit(1)
      }
    fmt.Println("Encrypted ciphertext: ", string(ciphertext)
    
  4. 使用 privateKey 解密第 3 步的密文:

    decrypted, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey,   ciphertext, nil)
      if err != nil {
    fmt.Printf("error decrypting data: %v", err)
    os.Exit(1)
      }
      fmt.Println("Decrypted text: ", string(decrypted))
    }
    
  5. 使用以下命令运行程序:

    go run main.go
    

    你应该得到以下输出:

![图 19.8:预期输出

![图片 B14177_18_08.jpg]

图 18.8:预期输出

我们现在已经学会了如何创建 RSA 公钥并使用它来加密和解密数据。

随机生成器

Go 标准库提供了用于创建随机数生成器的实用库。实现提供在 crypto/randmath/rand 包中。math/rand 库可以用来生成随机整数;然而,随机性无法保证。因此,这个库只应在数字可以一般随机且不涉及安全敏感的情况下使用。

否则,你应该始终使用 crypto/rand。作为旁注,crypto/rand 包依赖于操作系统随机性——例如,在 Linux 上它使用 /dev/urandom。因此,它通常比数学库实现慢。

要使用 crypto/rand 库生成一个介于 0 和用户定义数字之间的随机整数,我们可以使用以下函数:

funcInt(rand io.Reader, max *big.Int) (n *big.Int, err error)

在许多场景中,我们可能需要生成一个安全的随机数,例如,在生成唯一的会话 ID 时。在这些场景中使用随机数必须是真正随机的,并且不遵循可以被推断出的模式。例如,如果攻击者可以通过查看最后几个会话 ID 来推断下一个 sessionID,他们可能获得对该会话的非认证访问。

让我们学习如何使用 crypto/randmath/rand 库生成随机数。

练习 18.06:随机生成器

随机数生成是在尝试向数据引入一些熵时的一种常见活动。在这个练习中,我们将看到如何使用 math/randcrypto/rand 包生成随机数:

  1. 创建一个 main.go 文件并导入以下包:

    package main
    import (
      "crypto/rand"
      "fmt"
      "math/big"
      math "math/rand"
    )
    

    math "math/rand":我们添加 math 命名空间来区分它与 crypto/rand 包。

  2. main() 函数中,创建一个运行 10 次的 for 循环,并打印使用 crypto/rand 库的 rand.Int() 函数生成的介于 0 和 1000 之间的随机整数:

    func main() {
      fmt.Println("Crypto random")
      for i := 1; i<=10; i++ {
        data, _:= rand.Int(rand.Reader,big.NewInt(1000))
        fmt.Println(data)
      }
    
  3. 使用 math/rand 包创建另一个类似的 for 循环:

      fmt.Println("Math random")
      for i := 1; i<=10; i++ {
        fmt.Println(math.Intn(1000))
      }
    }
    
  4. 使用以下命令运行程序:

    go run main.go
    

    你应该得到以下输出:

图 19.9:预期输出

图 18.9:预期输出

虽然两个实现的输出可能看起来相似,但在使用随机数进行安全目的时,数字生成的底层机制很重要。

在这个练习中,我们看到了如何使用 math/randcrypto/rand 包生成随机数。

HTTPS/TLS

当你开发一个网络应用程序时,了解如何确保信息在传输过程中的安全是非常重要的。这可以通过使用 crypto/tls 包来实现。TLS 协议确保:

身份:使用数字证书提供客户端和服务器身份验证。

完整性:通过计算消息摘要确保数据在传输过程中未被篡改。

认证:客户端和服务器都可以要求使用公钥密码学进行认证。

机密性:消息在传输过程中被加密,从而保护它免受任何未授权接收者的侵害。

在以下主题中,我们将看到如何使用证书加密客户端和服务器之间的流量。

在客户端和服务器之间加密流量第一步是生成一个数字证书。

在下一个练习中,我们将生成一个自签名的 x509 证书和匹配的 RSA 私钥。此证书可以用作客户端或服务器证书。

注意

你可能会遇到 CA 这个术语,它代表证书授权机构。CA 是签发证书并将其分发给需要签发证书的用户的一方。

练习 18.07:生成证书和私钥

在这个练习中,我们将学习如何生成一个自签名的证书及其匹配的私钥,这些证书可用于客户端-服务器通信:

  1. 创建一个 main.go 文件并导入以下包:

    package main
    import (
      "crypto/rand"
      "crypto/rsa"
      "crypto/tls"
      "crypto/x509"
      "crypto/x509/pkix"
      "encoding/pem"
      "fmt"
      "io/ioutil"
      "math/big"
      "net"
      "net/http"
      "os"
      "time"
    )
    

    将使用加密包生成 x509 证书。

  2. 要生成证书,我们首先创建一个模板。在模板中,我们可以定义证书的标准;例如,证书的过期时间设置为一年。模板需要一个随机种子,可以使用 rand.Int() 函数生成:

    main.go
    28 func generate() (cert []byte, privateKey []byte, err error) {
    29   serialNumber, err := rand.Int(rand.Reader, big.NewInt(27))
    30   if err != nil {
    31     return cert, privateKey, err
    32   }
    33   notBefore := time.Now()
    // Create Certificate template
    34   ca := &x509.Certificate{
    35     SerialNumber: serialNumber,
    36     Subject: pkix.Name{
    37       Organization: []string{"example.com"},
    38     },
    The full code for this step is available at: https://packt.live/34N7jjT
    
  3. 创建 privateKey,它将被用来签名证书:

      rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
      if err != nil {
        return cert, privateKey, err
      }
    
  4. 创建一个自签名的 DER(二进制加密)证书:

      DER, err := x509.CreateCertificate(rand.Reader, ca, ca, &rsaKey.PublicKey,     rsaKey)
      if err != nil {
        return cert, privateKey, err
      }
    
  5. 将二进制编码的 DER 证书转换为 ASCII 编码的 PEM 证书。PEM(增强隐私邮件)是一种数字证书格式:

      b := pem.Block{
        Type:  "CERTIFICATE",
        Bytes: DER,
      }
      cert = pem.EncodeToMemory(&b)
      privateKey = pem.EncodeToMemory(
        &pem.Block{
          Type:  "RSA PRIVATE KEY",
          Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
        })
      return cert, privateKey, nil
    }
    
  6. 定义 main() 函数以调用 generate 函数并打印输出:

    func main() {
      serverCert, serverKey, err := generate()
      if err != nil {
        fmt.Printf("error generating server certificate: %v", err)
        os.Exit(1)
      }
      fmt.Println("Server Certificate:")
      fmt.Printf("%s\n", serverCert)
      fmt.Println("Server Key:")
      fmt.Printf("%s\n", serverKey)
    }
    

    你应该得到以下类似的输出:

图 19.10:预期输出

图 18.10:预期输出

因此,我们已经成功生成了一个自签名证书和用于应用程序的私钥。在上面的输出中,“服务器证书”是公开证书,“服务器密钥”是私钥。这可以用于加密客户端和服务器之间的数据。当传输过程中有敏感数据时,它们尤其有用,例如在银行网站上。

练习 18.08:运行 HTTPS 服务器

在接下来的练习中,我们将学习如何使用证书加密客户端和服务器之间的流量。

我们将学习如何创建公钥证书。该证书将被用来在客户端和服务器之间编码数据:

  1. 创建一个 main.go 文件并导入以下包:

    crypto/rand:用于随机数生成。

    crypto/rsa:为 RSA 证书提供包装。

    crypto/tls:为传输层安全性(TLS)协议提供包装。

    crypto/x509:为 X509 数字证书提供包装。

    package main
    import (
        "crypto/rand"
        "crypto/rsa"
        "crypto/tls"
        "crypto/x509"
        "crypto/x509/pkix"
        "encoding/pem"
        "fmt"
        "io/ioutil"
        "log"
        "math/big"
        "net"
        "net/http"
        "os"
        "time"
    )
    
  2. 定义一个名为 runServer() 的函数,用于运行具有 TLS 配置的 HTTP 服务器。该函数应接受证书文件路径、私钥文件路径和 PEM 编码的客户证书。在我们的 TLS 配置中,我们要求服务器和客户证书。服务器证书由客户端用于验证服务器的真实性。客户端证书由服务器验证以验证客户端:

    main.go
    117 func runServer(certFile string, key string, clientCert []byte) (err error) {
    118   fmt.Println("starting HTTP server")
    119   http.HandleFunc("/", hello)
    120   server := &http.Server{
    121     Addr:    ":443",
    122     Handler: nil,
    123   }
    124   cert, err := tls.LoadX509KeyPair(certFile, key)
    125   if err != nil {
    126     return err
    127   }
    The full code for this step is available at: https://packt.live/39hG58K
    
  3. 定义 hello() 函数,当启动 HTTP 服务器时,将其作为处理函数传递。每当服务器收到请求时,该函数将响应一些文本:

    func hello(w http.ResponseWriter, r *http.Request) {
      fmt.Printf("%s: Ping\n", time.Now().Format(time.Stamp))
      fmt.Fprintf(w, "Pong\n")
    }
    
  4. 现在服务器端已完成,让我们实现客户端:

    main.go
    95  func client(caCert []byte, ClientCerttls.Certificate) (err error) {
    96    certPool := x509.NewCertPool()
    97    certPool.AppendCertsFromPEM(caCert)
    98    client := &http.Client{
    99      Transport: &http.Transport{
    100       TLSClientConfig: &tls.Config{
    101         RootCAs:      certPool,
    102         Certificates: []tls.Certificate{ClientCert},
    103       },
    104     },
    105   }
    106   resp, err := client.Get("https://127.0.0.1:443")
    107   if err != nil {
    108     return err
    109   }
    The full code for this step is available at: https://packt.live/2PS72Z2
    

    这定义了一个使用 TLS 实现的 HTTP 客户端。它接受 CA 证书作为参数以验证服务器的真实性。在我们的情况下,我们使用了一个自签名证书,因此服务器证书将充当 CA 证书。该函数还会接受客户的证书,以便客户端可以与服务器进行身份验证。

  5. 让我们现在将这些函数结合起来,运行客户端和服务器握手。

    首先,我们为客户端和服务器生成证书和密钥。服务器使用 goroutine 启动并等待来自客户端的请求。客户端也在 goroutine 中启动,每 3 秒调用一次服务器:

main.go
18 func main() {
19   serverCert, serverKey, err := generate()
20   if err != nil {
21     fmt.Printf("error generating server certificate: %v", err)
22     os.Exit(1)
23   }
24   ioutil.WriteFile("private.key", serverKey, 0600)
25   ioutil.WriteFile("cert.pem", serverCert, 0777)
26   clientCert, clientKey, err := generate()
27   if err != nil {
28     fmt.Printf("error generating client certificate: %v", err)
29     os.Exit(1)
30   }
The full code for this step is available at: https://packt.live/2t0IXpW

我们现在可以运行 main() 函数。你应该在你的控制台看到以下输出:

$ cd../exercise8/
$ go run main.go
starting HTTP server
Oct 17 22:22:28: Ping
Oct 17 22:22:28: Pong
Oct 17 22:22:31: Ping
Oct 17 22:22:31: Pong

在这个练习中,我们展示了如何使用 TLS 协议来确保客户端和服务器之间的通信。我们学习了如何生成数字证书,并在客户端和服务器 TLS 配置中使用它们。

密码管理

如果你正在管理网站上的用户账户,验证用户身份的一种常见方式是通过用户名和密码的组合。这种认证机制的风险是,如果管理不当,用户凭证可能会泄露。这已经发生在世界上的许多主要网站上,并且仍然是一个令人惊讶的常见安全事件。

关于密码管理的首要规则是永远不要以明文形式存储密码(无论是在内存中还是在数据库中)。相反,实现一个经过批准的哈希算法来创建密码的单向哈希,以便你可以通过哈希来确认身份。然而,从哈希中无法检索密码。我们可以通过一个示例来观察这一过程。

以下代码展示了如何从明文字符串创建单向哈希。我们使用 bcrypt 包来生成哈希。然后我们执行密码与哈希的比较以验证匹配:

package main
import (
  "fmt"
  "golang.org/x/crypto/bcrypt"
)
func main() {
  password := "mysecretpassword"
  encrypted, _ := bcrypt.GenerateFromPassword([]byte(password), 10)
  fmt.Println("Plain Text Password:", password)
  fmt.Println("Hashed Password:    ", string(encrypted))
  err := bcrypt.CompareHashAndPassword([]byte(encrypted), []byte(password))
  if err == nil {
    fmt.Println("Password matched")
  }
}

以下为预期输出:

图 19.11:预期输出

图 18.11:预期输出

注意

椭圆曲线数字签名算法 (ECDSA) 是一种加密算法,它通过提供使用公钥和私钥对签名和验证数据的机制来验证数据的真实性。

活动 18.01:使用哈希密码在应用程序中验证用户

你正在开发一个 Web 应用程序,你需要使用哈希密码来验证用户。

创建一个存储为哈希的用户密码数据库。定义一个函数,接受用户密码作为输入,并使用数据库中存储的密码验证用户。确保查询数据库的 SQL 查询不受 SQL 注入攻击。你可以按照以下步骤获取所需的输出。

  1. 创建一个函数将数据加载到数据库中。

  2. 创建一个函数来更新数据库中的密码。在更新数据库之前,使用crypto/sha512库加密输入密码。

  3. 创建一个函数从数据库中检索密码并确认它是否与哈希匹配。

  4. 在程序的主函数中,使用一些测试数据初始化数据库。

  5. 使用定义在第 2 步中的函数更新用户密码。

    你应该得到以下输出:

图 19.12:预期输出

图 18.12:预期输出

在这里,我们使用哈希库在数据库中安全地存储用户密码,然后使用哈希密码验证用户的身份。你可以在需要存储敏感数据的场景中使用此方法。

注意

该活动的解决方案可以在第 777 页找到。

活动 18.02:使用加密库创建 CA 签名的证书

证书颁发机构(CA)需要创建来签署证书。当创建新的叶证书时,应使用 CA 证书和私钥进行签名。你需要定义一个函数,使用crypto/ecdsa库生成 ECDSA 加密密钥。该函数需要支持创建 CA 证书以及叶证书。最后,你需要验证新创建的叶证书。

这里的目的是生成 x509 证书。你可以按照以下步骤获取所需的输出:

  1. 创建一个generateCert()函数,使用crypto/ecdsa库生成 ECDSA 证书和私钥。它应该接受一个通用名称字符串、CA 证书和 CA 私钥。

    函数应具有以下定义:

    generateCert(cn string, caCert *x509.Certificate, caPrivcrypto.PrivateKey)   (cert *x509.Certificate, privateKeycrypto.PrivateKey, err error)
    
  2. 使用ecdsa.GenerateKey()函数创建一个 ECDSA 密钥。

  3. 使用该密钥生成一个 x509 证书。

  4. 返回生成的证书和私钥。

  5. main()函数中,生成 CA 证书和私钥,以及叶证书和私钥。

  6. 验证生成的叶证书。

    输出应如下所示:

    $ go run main.go
    ca certificate generated successfully
    leaf certificate generated successfully
    leaf certificate successfully verified
    

在这里,我们生成 x509 公钥证书。我们还看到了如何使用根证书生成叶证书,在尝试实现自己的 PKI 服务器时非常有用。

注意

本活动的解决方案可以在第 780 页找到。

摘要

在本章中,我们探讨了可能被用来破坏应用程序的几种攻击类型。我们还介绍了缓解这些问题的策略,并提供了实际操作的示例。

我们已经介绍了加密库的使用,用于数据的加密和解密,无论是静态存储还是传输过程中的数据。我们还涵盖了哈希库的使用,以及如何使用它们来安全地存储用户凭证。此外,我们还展示了如何使用 TLS 配置来确保客户端和服务器之间的通信安全。有了这些工具,你现在可以开始编写安全的应用程序了。

在下一章中,我们将学习 Go 语言中一些不太为人所知的包,例如反射和 unsafe。

第十九章:19. 特殊特性

概述

在本章中,我们将探讨一些在应用程序开发过程中可能非常有用的 Go 特性。

本章首先将介绍如何使用构建约束,编写适用于多个操作系统和架构的程序,并使用命令行选项构建 Go 程序。你将使用反射来检查运行时对象。到本章结束时,你将能够为你的应用程序定义构建时行为,并使用 unsafe 包在 Go 中访问运行时内存。

简介

在上一章中,我们学习了可能影响你的应用程序的漏洞以及如何减轻它们。我们学习了如何确保通信安全以及安全地存储数据。

现在我们将学习一些在 Go 语言中不太明显且难以发现的特性。如果你正在浏览标准库,可能会遇到这些特性。了解这些特性将有助于你理解执行过程中的情况,因为这些特性中的一些是隐式嵌入到语言中的。

由于 Go 语言可以在多个 操作系统OSes)和 CPU 架构上运行,因此 Go 支持配置这些参数以构建应用程序。使用这些构建参数,你将能够执行诸如交叉编译等操作,这在其他编程语言中是非常罕见的。

如内存管理之类的概念很难掌握,因此 Go 运行时管理所有内存分配和释放,减轻了程序员管理应用程序内存足迹的负担。对于程序员偶尔需要访问内存的情况,Go 通过提供一个名为 unsafe 的包提供了一些灵活性,我们将在本章中学习它。

构建约束

Go 程序可以在不同的 OS 和不同的 CPU 架构上运行。当你构建一个 Go 程序时,你的程序的编译是在当前机器的 OS 和架构上完成的。通过使用构建约束,你可以设置条件,确定哪些文件将被考虑进行编译。如果你有一个需要针对不同 OS 进行覆盖的函数,你可以使用构建约束来有多个相同函数的定义。

你可以在 Go 标准库中看到很多这样的例子。

以下链接展示了标准库中 os 包在 darwin 和 Linux 上实现相同功能的示例:

如果你恰好遇到类似的需求,Go 语言提供了构建约束,可以用来定义构建条件。

构建标签

使用构建约束有两种方法。第一种方法是定义构建标签,第二种方法是使用文件名后缀。

构建标签应出现在您的源文件中的包声明之前。这些标签在构建时进行分析,并决定是否将文件包含在编译中。

让我们看看如何评估这些标签。以下标签意味着源文件将仅在 Linux 机器上考虑 build。因此,此文件不会在 Windows 机器上编译:

// +build linux

我们可以使用构建标签定义多个构建约束:

// +build amd64,darwin 386,!gccgo

这将评估为以下内容:

( amd64 AND darwin ) OR (386 AND (NOT gccgo))

注意,在上面的例子中,我们也使用了否定来避免某些条件。

注意

确保在构建约束和代码开始(即包名)之间有一个空行。

在构建时,Go 将构建标签与环境变量进行比较,并决定如何处理这些标签。

默认情况下,Go 将读取特定的环境变量来设置构建和运行时行为。您可以通过运行以下命令来查看这些变量:

go env

图 20.1:go env 输出

图 19.1:go env 输出

最常用的变量是 GOOS,它是操作系统变量,以及 GOARCH,它是 CPU 架构变量。您可以通过将 GOOS 变量设置为除当前操作系统之外的其他值来交叉编译您的应用程序。GOOS 变量的示例值包括 Windows、darwin 和 Linux。

让我们看看一个简单的 hello world 程序,并使用构建标签的实际操作。以下程序有一个 build 标签,使得 go build 命令忽略该文件:

// +build ignore
package main
import "fmt"
func main() {
  fmt.Println("Hello World!")
}

如果您在当前目录下运行 go build,您将看到以下错误输出:

$ go build
build .: cannot find module for path .

如果您从文件中移除 build 标签然后再次运行 build,它应该生成一个没有错误的二进制文件,如下所示:

$ go run main.go
Hello World!

让我们看看使用 GOOS 变量的 build 标签的另一个示例。我们将演示 build 标签和环境变量的组合如何影响您的应用程序的编译。

我当前的操作系统 GOOS 变量是 darwin。将 darwin 替换为您自己的 GOOS 值。

要获取当前的 GOOS 变量,请运行以下命令:

go env GOOS

接下来:

// +build darwin
package main
import "fmt"
func main() {
  fmt.Println("Hello World!")
}

如果我们 build 此文件,它应该生成以下可执行二进制文件:

$go build -o good
$./goos
Hello World!

现在,将您的 GOOS 变量设置为除您自己的以外的其他值;构建应该失败:

$GOOS=linux go build -o goos
Build .: cannot find module for path .

在本例中,我们学习了如何使用 GOOS 值作为构建约束。

文件名

如前所述,使用构建约束的第二种方法是使用文件名后缀来定义约束。

使用此方法,您可以在操作系统或 CPU 架构或两者上定义约束。

例如,以下文件来自标准库中的 syscall 包。您可以看到定义在操作系统上的约束:

syscall_linux.go
syscall_windows.go
syscall_darwin.go

在运行时包中也可以找到同时使用操作系统和 CPU 架构的另一个示例:

signal_darwin_amd64.go
signal_darwin_arm.go
signal_darwin_386.go

要利用此方法,后缀必须具有以下形式:

*_GOOS
*_GOARCH
*_GOOS_GOARCH

您可以在标准库中找到此命名方案的示例:

stat_aix.go
source_windows_amd64.go
syscall_linux_386.go

让我们看看如何使用文件名来定义构建约束的例子。我们将通过 CPU 架构来定义构建约束。我们将使用 GOARCH 环境变量来控制构建。

我们有一个以当前 GOARCH 为后缀的文件。我的当前 GOARCHamd64,所以文件名将是 main_amd64.go。请将此值替换为你的文件名。要获取你的当前 GOARCH,请运行以下命令:

go env GOARCH

这将显示以下内容:

$go env GOARCH
amd64

在我的机器上,文件名将是如下所示:

main_amd64.go

在文件中,我们将定义一个简单的 "Hello World" 程序:

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

输出将如下所示:

$ls
main_amd64.go
$go build -o goarch
$./goarch
Hello World!

为了确认约束是否起作用,我们可以使用另一个 GOARCH 值来尝试检查构建是否失败:

$ls
main_amd64.go
$GOARCH=386 go build -o goarch
build .: cannot find module for path .

在前面的例子中,我们学习了如何使用 CPU 架构作为构建约束来限制在特定 CPU 架构上构建文件。

反射

反射是检查运行时代码的机制。当你不知道或不能保证函数的输入类型时,反射非常有用。在这种情况下,反射可以用来检查对象的类型并操作对象的值。

Go 的 reflect 包为你提供了在运行时检查和操作对象的功能。它不仅可以用于基本类型,如 intstring,还可以用于检查切片、数组和结构体。

让我们创建一个简单的 print() 函数来演示我们如何使用反射。我们定义了一个名为 MyPrint() 的实用打印函数,它可以打印不同类型的对象。这是通过将接口作为函数的输入来实现的。然后,在函数内部,我们使用 reflect 包根据输入的类型来改变行为。考虑以下代码:

package main
import (
  "fmt"
  "reflect"
)
type Animal struct {
  Name string
}
type Object struct {
  Type string
}
type Person struct {
  Name string
}
func MyPrint(input interface{}) {
  t := reflect.TypeOf(input)
  v := reflect.ValueOf(input)
  switch {
  case t.Name() == "Animal":
    fmt.Println("I am a ", v.FieldByName("Name"))
  case t.Name() == "Object":
    fmt.Println("I am a ", v.FieldByName("Type"))
  default:
    fmt.Println("I got an unknown entity")
  }
}
func main() {
  table := Object{Type: "Chair"}
  MyPrint(table)
  tiger := Animal{Name: "Tiger"}
  MyPrint(tiger)
  gobin := Person{Name: "Gobin"}
  MyPrint(gobin)
}

运行前面的程序,我们得到以下输出:

$go run main.go
I am a Chair
I am a Tiger
I got an unknown entity

你可以在 encoding/jsonfmt 等包中找到反射使用的示例。

让我们看看如何使用 reflect 包中的某些常见实用函数来使用反射。

TypeOf 和 ValueOf

要使用反射,你需要熟悉在 reflect 包中定义的两个类型:

reflect.Type
reflect.Value

这两种类型都提供了实用函数,可以让你访问对象的动态运行时信息。

这两个函数为你提供了对对象的 TypeValue 的控制:

func TypeOf( interface{}) Type
func ValueOf( interface{}) Value

以下程序使用这两个函数来打印传递给对象的 TypeValue

func main() {
  var x = 5
  Print(x)
  var y = []string{"test"}
  Print(y)
  var z = map[string]string{"a": "b"}
  Print(z)
}
func Print(a interface{}) {
  fmt.Println("Type: ", reflect.TypeOf(a))
  fmt.Println("Value: ", reflect.ValueOf(a))
}

前一个程序的输出应该打印 xType

$ go run main.go
Type: int
Value 5
Type: []string
Value: [test]
Type: map[string]string
Value: map[a:b]

在这个例子中,我们观察了如何使用两个函数来打印传递给对象的 TypeValue

注意

确保你谨慎地使用反射包非常重要。使用类型转换错误或调用不支持该方法的对象上的方法将导致程序崩溃。

练习 19.01:使用反射

在这个练习中,我们将使用反射包来检查运行时的对象:

  1. 创建一个名为 main.go 的文件。

  2. 导入以下包:

    package main
    import (
      "fmt"
      "math"
      "reflect"
    )
    
  3. 定义一个名为circlestruct,其中radius是其字段之一:

    type circle struct {
      radius float64
    }
    
  4. 定义另一个名为rectanglestruct,其字段为lengthbreadth

    type rectangle struct {
      length  float64
      breadth float64
    }
    
  5. 定义一个名为area()的函数,它可以计算不同形状的面积。它应该接受interface作为其输入:

    func area(input interface{}) float64 {
      inputType := reflect.TypeOf(input)
      if inputType.Name() == "circle" {
        val := reflect.ValueOf(input)
        radius := val.FieldByName("radius")
        return math.Pi * math.Pow(radius.Float(), 2)
      }
      if inputType.Name() == "rectangle" {
        val := reflect.ValueOf(input)
        length := val.FieldByName("length")
        breadth := val.FieldByName("breadth")
        return length.Float() * breadth.Float()
      }
      return 0
    }
    

    在这个函数中,我们使用reflect.TypeOf()从输入中获取一个reflect.Type对象。然后我们使用Type.Name()函数来获取struct的名称,在我们的例子中,它可以是圆或矩形。

    要检索结构体字段中的值,我们首先使用reflect.ValueOf()函数获取一个reflect.Value对象。然后我们使用Val.FieldByName()来获取字段值。

  6. 定义一个main()函数并调用area()函数:

    func main() {
      fmt.Printf("area of circle with radius 3 is : %f\n", area(circle{radius: 
        3}))
      fmt.Printf("area of rectangle with length 3 and breadth 7 is : %f\n", 
        area(rectangle{length: 3, breadth: 7}))
    }
    
  7. 使用以下命令运行程序:

    go run main.go
    

    当你运行程序时,你应该得到以下输出:

    $ go run main.go
    area of circle with radius 3 is : 28.274334
    area of rectangle with length 3 and breadth 7 is : 21.000000
    

在这个练习中,我们学习了如何使用反射来定义函数的不同实现,在这种情况下,通过检查输入对象来确定传递的是什么对象。

活动第 19.01 节:使用文件名定义构建约束

你必须定义一个根据操作系统和 CPU 架构表现不同的函数。使用文件名上的构建约束来实现这种行为。一个文件应该设置操作系统约束为darwin,另一个设置 CPU 架构约束为386

注意

darwin替换为你的当前操作系统,将386替换为你当前机器架构之外的另一个架构。

执行以下步骤:

  1. 创建一个名为custom的包。

  2. 创建一个名为print_darwin.go的文件,并在包内定义一个名为Print()的函数。它应该打印以下文本:I am running on a darwin machine

  3. 在同一个包中创建另一个名为print_386.go的文件,并定义一个名为Print()的函数,该函数打印以下文本:Hello I am running on a 386 machine

  4. 定义一个main()函数并导入custom包。在main()函数中调用custom包中的Print()函数。

到活动结束时,你应该看到以下输出:

$go run main.go
Hello I am running on a darwin machine.

在这个活动中,我们使用文件名构建约束来实现函数重载。你应该能够在 Go 标准库中看到类似的实现。

注意

这个活动的解决方案可以在第 782 页找到。

DeepEqual

如果我们在谈论reflect包,则reflect.DeepEqual()需要提及。

Go 的基本数据类型可以使用==!=运算符进行比较,但切片和映射不能使用这种方法进行比较。

当类型不可比较时,可以使用reflect.DeepEqual()函数。例如,它可以用于比较切片和映射。以下是一个使用DeepEqual比较映射和切片的示例:

package main
import (
  "fmt"
  "reflect"
)
func main() {
  runDeepEqual(nil, nil)
  runDeepEqual(make([]int, 10), make([]int, 10))
  runDeepEqual([3]int{1, 2, 3}, [3]int{1, 2, 3})
  runDeepEqual(map[int]string{1: "one", 2: "two"}, map[int]string{2:     "two", 1: "one"})
}
func runDeepEqual(a, b interface{}) {
  fmt.Printf("%v DeepEqual %v : %v\n", a, b, reflect.DeepEqual(a, b))
}

在前面的例子中,我们使用reflect.DeepEqual()比较不同的数据类型。

以下是比较操作:

  • 两个 nil 对象。

  • 两个大小相同的空切片。在这里,大小很重要。

  • 两个顺序相同的包含相同数据的切片。顺序不同的值将给出不同的输出。

  • 两个包含相同数据的映射。在这里,键的顺序并不重要,因为映射总是无序的。

如果您运行程序,您应该得到以下输出:

图 20.2:DeepEqual 输出

图 19.2:DeepEqual 输出

通配符模式

go工具有一系列命令可以帮助您进行代码开发。例如,go list命令可以帮助您列出当前目录中的 Go 文件,而go test命令可以帮助您运行当前目录中的测试文件。

您的项目可能被组织在多个子目录中,以帮助您逻辑上组织代码。如果您想使用go工具一次性运行整个代码库中的命令,它支持通配符模式,这可以帮助您做到这一点。

要列出当前目录及其子目录中的所有.go文件,您可以使用以下相对模式:

go list ./...

类似地,如果您想运行当前目录及其子目录中的所有测试,可以使用相同的模式:

go test ./...

如果您仍在使用供应商目录,好消息是这个模式将忽略./vendor目录。

让我们在 Go 工作坊仓库上尝试通配符模式。

要列出项目中的所有.go文件,您可以使用通配符运行list命令:

go list -f {{.GoFiles}}{{.Dir}} ./...

您应该得到类似于以下输出的结果:

图 20.3:通配符模式

图 19.3:通配符模式

不安全的包

Go 是一种静态类型语言,它有自己的运行时,负责内存分配和垃圾回收。因此,与 C 不同,所有与内存管理相关的任务都由运行时处理。除非您有一些特殊要求,否则您永远不需要在代码中直接处理内存。尽管如此,当有需求时,标准库中的unsafe包提供了让您可以窥视对象内存的功能。

如其名所示,通常不认为在您的代码中使用此包是安全的。另一个需要注意的事项是,unsafe包没有 Go 1 兼容性指南,这意味着功能可能在 Go 的后续版本中停止工作。

使用unsafe包的最简单例子可以在math包中找到:

func Float32bits(f float32) uint32
{
  return *(*uint32)(unsafe.Pointer(&f))
}

这需要一个float32作为输入并返回uint32float32数字被转换为unsafe.Pointer对象,然后解引用以转换为uint32

在前面的函数中也可以找到反向转换,在math包中:

func Float32frombits(b uint32) float32 {
  return *(*float32)(unsafe.Pointer(&b))
}

使用unsafe包的另一个例子,您可以在标准库中找到,是在从 Go 代码调用 C 程序时。这正式称为cgo

注意

要在 Windows 上使 cgo 生效,你需要在你的机器上安装 gcc 编译器。你可以使用 'Minimalist GNU for Windows' (packt.live/2EbOKuZ)。

在伪 C 包中提供了一些特殊函数,可以将 Go 数据类型转换为 C 数据或反之,例如:

// Converts Go string to C string
func C.CString(string) *C.char
// C data with explicit length to Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte

你可以像以下示例那样编写正常的 Go 代码并调用 C 代码编写的函数:

package main
//#include <stdio.h>
//#include <stdlib.h>
//static void myprint(char* s) {
//  printf("%s\n", s);
//}
import "C"
import "unsafe"
func main() {
  cs := C.CString("Hello World!")
  C.myprint(cs)
  C.free(unsafe.Pointer(cs))
}

你可以以下面的格式定义 C 中的函数。要使用标准库中的函数,import 语句前面有一个注释,该注释被视为你的 C 代码的头部部分:

// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }

前面的函数将输入打印到控制台。为了能够使用 C 代码,我们需要导入名为 C 的伪包。在 main 函数中,我们可以使用 C 包调用 myprint() 函数:

运行此程序应该得到以下输出:

$ go run main.go
Hello World!

练习 19.02:使用 cgo 和 unsafe

在这个练习中,我们将学习如何使用 unsafe 包获取字符串的底层内存:

  1. 创建一个 main.go 文件并执行以下导入。C 伪包需要使用 C 库:

    package main
    // #include <stdlib.h>
    import "C"
    import (
      "fmt"
      "unsafe"
    )
    
  2. 定义一个 main() 函数并声明一个 C 字符串:

    func main() {
      var cString *C.char
    
  3. cString 变量的值设置为文本 Hello World!\n。处理 C 时,始终是一个好习惯清理分配的内存,因此添加 C.free() 函数调用来执行清理:

    cString = C.CString("Hello World!\n")
      defer C.free(unsafe.Pointer(cString))
    
  4. 声明一个变量 b,作为字节数组以存储将 CString 转换为 Go byte 数组后的输出:

    var b []byte
    b = C.GoBytes(unsafe.Pointer(cString), C.int(14))
    

    C.GoBytes() 函数将 unsafe.Pointer 对象转换为 Go byte 数组。

  5. byte 数组打印到控制台:

    fmt.Print(string(b))
    }
    
  6. 使用以下命令运行程序:

    go run main.go
    

    你应该得到以下输出:

    $ go run main.go
    Hellow World!
    

在这个练习中,我们学习了如何使用 Cgo 并在 Go 中创建 C 对象。然后我们使用 unsafe 包将 CString 对象转换为 unsafe.Pointer,它直接映射到 CString 的内存。

活动 19.02:使用 Go Test 的通配符

你有一个包含多个测试文件和其中定义的多个测试用例的项目。创建多个包并在其中定义测试。使用通配符模式,使用单个命令运行项目中的所有测试用例。确保所有单元测试都使用该命令运行。

执行以下步骤:

  1. 创建一个名为 package1 的包。

  2. 创建一个名为 run_test.go 的文件,并定义一个单元测试 TestPackage1

  3. 创建一个名为 package2 的包。

  4. 创建一个名为 run_test.go 的文件,并定义一个单元测试 TestPackage2

  5. 使用通配符模式打印 TestPackage1TestPackage2 的结果:

![图 20.4:使用通配符的递归测试img/B14177_19_04.jpg

图 19.4:使用通配符的递归测试

在这个活动中,我们学习了如何使用通配符模式递归地在项目中的所有测试文件上运行测试。当你想要在持续集成管道中自动化运行测试时,这将非常有用。

注意

这个活动的解决方案可以在第 782 页找到。

摘要

在本章中,我们学习了 Go 语言中一些不那么明显的特殊功能。

我们介绍了构建约束的使用方法以及如何使用GOOSGOARCH变量进行条件编译来控制应用程序构建的行为。构建约束还可以用于在编译时忽略文件。build标签的另一个常见用途是为包含集成测试的文件添加标签。

我们已经看到了reflect包及其在运行时访问对象类型和值的函数的使用案例。反射是一种解决我们只能在运行时确定变量数据类型场景的好方法。

我们还演示了如何使用通配符在项目中的多个包上执行列表和测试。我们还学习了使用unsafe包在 Go 语言中访问运行时内存。unsafe包在使用 C 库时常用。

在本书的整个过程中,我们已经介绍了 Go 语言的基础,包括变量和各种类型声明。我们看到了 Go 语言中接口和错误的特殊行为。本书还涵盖了专注于应用程序开发的章节。处理文件和 JSON 数据在任何应用程序的开发中都非常常见,尤其是在 Web 应用程序中。关于数据库和 HTTP 服务器的章节深入探讨了如何管理数据的通信和存储。我们还探讨了如何使用 goroutines 轻松执行并发操作。最后,在本书的最后一个主题中,我们介绍了如何通过关注测试和确保应用程序的安全性来提高代码质量。最后但同样重要的是,我们探讨了 Go 语言中的特殊功能,如构建约束和unsafe包的使用。

附录

关于

本节包含以帮助学生执行书中现有的活动。它包括学生为完成和实现本书目标而要执行的详细步骤。

第一章:变量和运算符

活动一.01 定义和打印

解决方案

  1. 定义包名:

    package main
    
  2. 导入所需的包:

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

    func main() {
    
  4. 声明并初始化一个字符串变量用于给定名称:

      firstName := "Bob"
    
  5. 声明并初始化一个字符串变量用于姓氏:

      familyName := "Smith" 
    
  6. 声明并初始化一个int变量用于age

      age := 34 
    
  7. 声明并初始化一个bool变量用于peanutAllergy

      peanutAllergy := false
    
  8. 将每个变量打印到控制台:

      fmt.Println(firstName)
      fmt.Println(familyName)
      fmt.Println(age)
      fmt.Println(pean
    utAllergy)
    
  9. 关闭main()函数:

    }
    

    以下为预期的输出:

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

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

活动一.02:指针值交换

解决方案

  1. 让我们从以下代码开始练习:

    package main
    import "fmt"
    func main() {
      a, b := 5, 10
    
  2. 您需要从ab获取指针以传递给swap使用&

      swap(&a, &b)
      fmt.Println(a == 10, b == 5)
    }
    func swap(a *int, b *int) {
    
  3. 您需要首先使用*解引用值。您可以通过使用 Go 的多重赋值能力来交换值,而不需要临时变量。右侧在左侧之前解析:

      *a, *b = *b, *a
    }
    

    以下为预期的输出:

    true true
    

活动一.03:消息错误

解决方案

  1. 创建package main并添加必要的导入:

    package main
    import "fmt"
    func main() {
      count := 5
    
  2. if语句之前定义message

      var message string
      if count > 5 {
    
  3. 定义一个message,该message将在步骤 2 中更新:

      message = "Greater than 5"
      } else {
    
  4. 定义一个message,该message将在步骤 3 中更新:

      message = "Not greater than 5"
      }
      fmt.Println(message)
    }
    

    以下为预期的输出:

    Not greater than 5
    

活动一.04:坏计数错误

解决方案

  1. 让我们从以下代码开始练习:

    package main
    import "fmt"
    func main() {
      count := 0
      if count < 5 {
    
  2. 此处的赋值导致前面的count被遮蔽:

        count = 10
        count++
      }
      fmt.Println(count == 11)
    }
    

    以下为预期的输出:

    true
    

第二章:逻辑和循环

活动二.01:实现 FizzBuzz

解决方案

  1. 定义package并包含import

    package main
    import (
      "fmt"
      "strconv"
    )
    
  2. 创建main函数:

    func main() {
    
  3. 创建一个从 1 开始,直到i达到 99 的for循环:

      for i := 1; i <= 100; i++{
    
  4. 初始化一个字符串变量,该变量将保存输出:

      out := ""
    
  5. 使用模块逻辑检查可除性,如果i能被 3 整除,则将"Fizz"添加到out字符串中:

      if i%3 == 0 {
      out += "Fizz"
      }
    
  6. 如果能被 5 整除,则将"Buzz"添加到字符串中:

      if i%5 == 0 {
      out += "Buzz"
      }
    
  7. 如果都不是,将数字转换为字符串,然后添加到输出字符串中:

      if out == "" {
      out = strconv.Itoa(i)
      }
    
  8. 打印输出变量:

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

      }
    }
    
  10. 在您创建代码的文件夹中运行:

    go run main.go
    

    预期输出如下:

图 2.03:FizzBuzz 输出

图 2.03:FizzBuzz 输出

活动二.02:使用range遍历 Map 数据

解决方案

  1. 加载main包:

    package main
    
  2. 导入fmt包:

    import "fmt"
    
  3. 创建main函数:

    func main() {
    
  4. 初始化words映射:

      words := map[string]int{
        "Gonna": 3,
        "You":   3,
        "Give":  2,
        "Never": 1,
        "Up":    4,
      }
    
  5. topWord变量初始化为空字符串,将topCount变量初始化为 0:

      topWord := ""
      topCount := 0
    
  6. 创建一个使用range获取每个元素键和值的for循环:

      for key, value := range words {
    
  7. 检查当前map元素是否有比顶级计数更大的计数:

        if value > topCount {
    
  8. 如果是,则使用当前元素的值更新顶部值:

          topCount = value
          topWord = key
    
  9. 关闭if语句:

        }
    
  10. 关闭循环:

      }
    
  11. 循环完成后,你就有结果了。将其打印到控制台:

      fmt.Println("Most popular word:", topWord)
      fmt.Println("With a count of  :", topCount)
    }
    
  12. 在你创建的代码文件夹中运行:

    go run main.go
    

    以下是将显示最常用词及其计数值的预期输出:

    Most popular word: Up
    With a count of  : 4
    

活动 2.03:冒泡排序

解决方案:

  1. 定义包并添加导入的包:

    package main
    import "fmt"
    
  2. 创建main

    func main() {
    
  3. 定义一个整数切片并用未排序的数字初始化它:

      nums := []int{5, 8, 2, 4, 0, 1, 3, 7, 9, 6}
    
  4. 打印排序前的切片:

      fmt.Println("Before:", nums)
    
  5. 创建一个for循环;在initial语句中,定义一个布尔值,其初始值为true。在条件中检查该布尔值。留空post语句:

      for swapped := true; swapped; {
    
  6. 将布尔变量设置为false

        swapped = false
    
  7. 创建一个嵌套的for i循环,遍历整个int值的切片。从第二个元素开始循环:

        for i := 1; i < len(nums); i++ {
    
  8. 检查前一个元素是否大于当前元素:

          if nums[i-1] > nums[i] {
    
  9. 如果前一个元素更大,则交换元素的值:

            nums[i], nums[i-1] = nums[i-1], nums[i]
    
  10. 将我们的布尔值设置为true以指示我们进行了交换,我们需要继续进行:

            swapped = true
    
  11. 关闭if语句和两个循环:

          }
        }
      }
    
  12. 打印现在排序好的切片并关闭main

      fmt.Println("After :", nums)
    }
    
  13. 在你创建的代码文件夹中运行:

    go run main.go
    

    以下是将显示的预期输出:

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

第三章:核心类型

活动 3.01:销售税计算器

解决方案:

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

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

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

    import "fmt"
    
  4. 创建一个函数,该函数接受两个浮点数参数并返回一个浮点数:

    func salesTax(cost float64, taxRate float64) float64 {
    
  5. 将两个参数相乘并返回结果:

      return cost * taxRate
    
  6. 关闭函数:

    }
    
  7. 创建main()函数:

    func main() {
    
  8. 声明一个变量作为浮点数:

      taxTotal := .0
    
  9. cake添加到taxTotal

      // Cake
      taxTotal += salesTax(.99, .075)
    
  10. milk添加到taxTotal

      // Milk
      taxTotal += salesTax(2.75, .015)
    
  11. butter添加到taxTotal

      // Butter
      taxTotal += salesTax(.87, .02)
    
  12. taxTotal打印到控制台:

      // Total
      fmt.Println("Sales Tax Total: ", taxTotal)
    
  13. 关闭main()函数:

    }
    
  14. 保存文件,并在你创建的文件夹内运行以下命令:

    go run main.go
    

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

    Sales Tax Total: 0.1329
    

活动 3.02:贷款计算器

解决方案:

  1. 定义包:

    package main
    
  2. 导入必要的包:

    import (
      "errors"
      "fmt"
    )
    
  3. 定义分数和比率的常量:

    const (
      goodScore      = 450
      lowScoreRatio  = 10
      goodScoreRatio = 20
    )
    
  4. 预定义错误:

    var (
      ErrCreditScore = errors.New("invalid credit score")
      ErrIncome      = errors.New("income invalid")
      ErrLoanAmount  = errors.New("loan amount invalid")
      ErrLoanTerm    = errors.New("loan term not a multiple of 12")
    )
    
  5. 创建一个检查贷款详情的函数。此函数将接受一个creditScore、一个income、一个loanAmount和一个loanTerm,并返回一个错误:

    func checkLoan(creditScore int, income float64, loanAmount float64, loanTerm   float64) error {
    
  6. 设置一个基本的interest利率:

      interest := 20.0
    
  7. 良好的creditScore可以得到更好的利率:

      if creditScore >= goodScore {
        interest = 15.0
      }
    
  8. 验证creditScore,如果它有问题则返回错误:

      if creditScore < 1 {
        return ErrCreditScore
      }
    
  9. 验证income,如果它有问题则返回错误:

      if income < 1 {
        return ErrIncome
      }
    
  10. 验证loanAmount,如果它有问题则返回错误:

      if loanAmount < 1 {
        return ErrLoanAmount
      }
    
  11. 验证loanTerm,如果它有问题则返回错误:

      if loanTerm < 1 || int(loanTerm)%12 != 0 {
        return ErrLoanTerm
      }
    
  12. 将利率转换为我们可以用于计算的形式:

      rate := interest / 100
    
  13. 通过将loanAmount乘以贷款rate来计算付款。然后将结果除以loanTerm。现在将loanAmount除以loanTerm

    最后,将这两个金额相加:

      payment := ((loanAmount * rate) / loanTerm) + (loanAmount / loanTerm)
    
  14. 通过将付款乘以 loanTerm 然后减去 loanAmount 来计算贷款的总成本:

      totalInterest := (payment * loanTerm) - loanAmount
    
  15. 声明一个用于 approval 的变量:

      approved := false
    
  16. 添加一个条件来检查收入是否超过付款:

      if income > payment {
    
  17. 计算他们收入中被支付的部分所占的百分比:

        ratio := (payment / income) * 100
    
  18. 如果他们的信用评分良好,允许更高的比率:

        if creditScore >= goodScore && ratio < goodScoreRation {
          approved = true
        } else if ratio < lowScoreRation {
          approved = true
        }
      }
    
  19. 将应用程序的所有详细信息打印到控制台:

      fmt.Println("Credit Score    :", creditScore)
      fmt.Println("Income          :", income)
      fmt.Println("Loan Amount     :", loanAmount)
      fmt.Println("Loan Term       :", loanTerm)
      fmt.Println("Monthly Payment :", payment)
      fmt.Println("Rate            :", interest)
      fmt.Println("Total Cost      :", totalInterest)
      fmt.Println("Approved        :", approved)
      fmt.Println("")
    
  20. 无错误返回并关闭函数:

      return nil
    }
    
  21. 创建 main() 函数:

    func main() {
    
  22. 创建一个将被批准的示例:

      // Approved
      fmt.Println("Applicant 1")
      fmt.Println("-----------")
      err := checkLoan(500, 1000, 1000, 24)
    
  23. 如果发现任何错误,打印出来:

      if err != nil {
        fmt.Println("Error:", err)
        return
      }
    
  24. 创建一个将被拒绝的示例:

      // Denied
      fmt.Println("Applicant 2")
      fmt.Println("-----------")
      err = checkLoan(350, 1000, 10000, 12)
    
  25. 如果发现任何错误,打印出来:

      if err != nil {
        fmt.Println("Error:", err)
        return
      }
    
  26. 关闭 main() 函数:

    }
    
  27. 在编写代码的文件夹中运行以下命令:

    go run main.go
    

    以下是预期的输出:

图 3.15:贷款计算器输出

图 3.15:贷款计算器输出

第四章:复杂类型

活动 4.01:填充数组

解决方案:

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

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

    package main
    import "fmt"
    
  3. 创建一个返回数组的函数:

    func getArr() [10]int {
    
  4. 定义一个数组变量:

      var arr [10]int
    
  5. 使用 for i 循环对数组的每个元素进行操作:

      for i := 0; i < 10; i++ {
    
  6. 使用 i 和一些数学计算来设置正确的值:

      arr[i] = i + 1
      }
    
  7. 返回数组变量并关闭函数:

      return arr
    }
    
  8. main() 函数中调用函数并将返回值打印到控制台:

    func main() {
      fmt.Println(getArr())
    }
    
  9. 保存文件。然后,在步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    运行前面的代码会产生以下输出:

    [1 2 3 4 5 6 7 8 9 10]
    

活动 4.02:根据用户输入打印用户姓名

解决方案:

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

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

    package main
    import (
      "fmt"
      "os"
    )
    
  3. 定义用户数据的 map

    var users = map[string]string{
      "305": "Sue",
      "204": "Bob",
      "631": "Jake",
      "073": "Tracy",
    }
    
  4. 创建一个返回用户的姓名以及它是否存在函数:

    func getName(id string) (string, bool) {
      name, exists := users[id]
      return name, exists
    }
    
  5. main() 函数中检查传入的参数。调用函数,如果有错误则打印,如果用户不存在则退出。如果用户存在,打印问候语:

    func main() {
      if len(os.Args) < 2 {
        fmt.Println("User ID not passed")
        os.Exit(1)
      }
      name, exists := getName(os.Args[1])
      if !exists {
        fmt.Printf("error: user (%v) not found", os.Args[1])
        os.Exit(1)
      }
      fmt.Println("Hi,", name)
    }
    
  6. 保存文件。然后,在步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    运行前面的代码会产生以下输出:

    Hi, Tracy
    

活动 4.03:创建区域检查器

解决方案:

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

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

    package main
    import (
      "fmt"
      "os"
      "strings"
    )
    
  3. 定义一个包含 languageterritorylocale 结构体,这两个都将字符串:

    type locale struct {
      language string
      territory string
    }
    
  4. 创建一个返回测试数据的函数:

    func getLocales() map[locale]struct{} {
      supportedLocales := make(map[locale]struct{}, 5)
      supportedLocales[locale{"en", "US"}] = struct{}{}
      supportedLocales[locale{"en", "CN"}] = struct{}{}
      supportedLocales[locale{"fr", "CN"}] = struct{}{}
      supportedLocales[locale{"fr", "FR"}] = struct{}{}
      supportedLocales[locale{"ru", "RU"}] = struct{}{}
      return supportedLocales
    }
    
  5. 创建一个函数,使用传入的本地结构体来检查样本数据中是否存在区域:

    func localeExists(l locale) bool {
      _, exists := getLocales()[l]
      return exists
    }
    
  6. 创建 main() 函数:

    func main() {
    
  7. 检查是否已传递参数:

      if len(os.Args) < 2 {
        fmt.Println("No locale passed")
        os.Exit(1)
      }
    
  8. 处理传入的参数以确保其格式有效:

      localeParts := strings.Split(os.Args[1], "_")
      if len(localeParts) != 2 {
        fmt.Printf("Invalid locale passed: %v\n", os.Args[1])
        os.Exit(1)
      }
    
  9. 使用传入的参数数据创建一个本地结构体值:

      passedLocale := locale{
        territory: localeParts[1],
        language:  localeParts[0],
      }
    
  10. 调用函数,如果不存在则打印错误消息;否则,打印该区域受支持:

      if !localeExists(passedLocale) {
        fmt.Printf("Locale not supported: %v\n", os.Args[1])
        os.Exit(1)
      }
      fmt.Println("Locale passed is supported")
    }
    
  11. 保存文件。然后,在步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    运行前面的代码会产生以下输出:

![图 4.17:区域检查结果]

![图片/B14177_04_17.jpg]

图 4.17:区域检查结果

活动 4.04:切片一周

解决方案:

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

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

    package main
    import "fmt"
    
  3. 创建一个返回字符串切片的函数:

    func getWeek() []string {
    
  4. 定义一个切片并用一周的天数初始化,从星期一开始:

      week := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday",     "Saturday", "Sunday"}
    
  5. 创建一个从索引 6 开始到切片末尾的范围。然后,创建一个从切片开头开始到索引 6 的切片范围。使用 append 将第二个范围添加到第一个范围。捕获 append 的值:

      week = append(week[6:], week[:6]...)
    
  6. 返回结果并关闭函数:

      return week
    }
    
  7. main 中调用函数并将结果打印到控制台:

    func main() {
      fmt.Println(getWeek())
    }
    
  8. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    运行前面的代码将产生以下输出:

    [Sunday Monday Tuesday Wednesday Thursday Friday Saturday]
    

活动 4.05:从切片中移除元素

解决方案:

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

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

    package main
    import "fmt"
    
  3. 创建一个返回字符串切片的函数:

    func removeBad() []string {
    
  4. 定义一个包含 GoodBad 数据的字符串切片:

      sli := []string{"Good", "Good", "Bad", "Good", "Good"}
    
  5. 从切片的开始创建一个范围直到 Bad 索引。创建另一个从 Bad 数据后一个索引开始直到切片末尾的范围。将第二个切片追加到第一个切片并捕获结果:

      sli = append(sli[:2], sli[3:]...)
    
  6. 返回切片并关闭函数:

      return sli
    }
    
  7. main 中调用函数并将结果打印到控制台:

    func main() {
      fmt.Println(removeBad())
    }
    
  8. 保存文件。然后,在你在 步骤 1 中创建的文件夹中,使用以下命令运行代码:

    go run .
    

    预期的输出如下:

    [Good Good Good Good]
    

活动 4.06:类型检查器

解决方案:

  1. 定义包:

    package main
    
  2. 导入所需的库:

    import "fmt"
    
  3. 创建一个返回 interface{} 值切片的函数。这将包含我们的示例值:

    func getData() []interface{} {
      return []interface{}{
        1,
        3.14,
        "hello",
        true,
        struct{}{},
      }
    }
    
  4. 创建一个接受单个 interface{} 值并返回 string 的函数:

    func getTypeName(v interface{}) string {
    
  5. 使用类型 switch

      switch v.(type) {
    
  6. 为所有 int 类型添加 case

      case int, int32, int64:
    
  7. 返回表示它们的字符串:

        return "int"
    
  8. 为浮点数添加 case 并返回一个字符串:

      case float64, float32:
        return "float"
    
  9. 为布尔类型添加 case 并返回一个字符串:

      case bool:
        return "bool"
    
  10. 然后为字符串添加一个 case

      case string:
        return "string"
    
  11. 添加一个默认 case,表示你不知道类型:

      default:
        return "unknown"
    
  12. 关闭 switch 语句和函数:

      }
    }
    
  13. 创建 main() 函数:

    func main() {
    
  14. 获取示例数据并将其分配给一个变量:

      data := getData()
    
  15. 使用 for i 循环逐个遍历示例值:

      for i := 0; i < len(data); i++ {
    
  16. 将每个示例值传递给前面的函数并将结果打印到控制台:

        fmt.Printf("%v is %v\n", data[i], getTypeName(data[i]))
    
  17. 关闭循环并执行函数:

      }
    }
    
  18. 在你创建代码的文件夹中,运行以下命令:

    go run .
    

    运行前面的代码将产生以下输出:

![图 4.18:显示类型的输出]

![图片/B14177_04_18.jpg]

图 4.18:显示类型的输出

第五章:函数

活动 5.01:计算员工的工时

解决方案:

所有目录和文件都应该创建在你的 $GOPATH 内:

  1. 创建一个名为 Activity5.01 的目录。

  2. Activity5.01 中创建一个名为 main.go 的文件。

  3. Activity5.01/main.go 中,声明 main 包及其导入:

    package main
    import "fmt"
    
  4. 创建一个 Developer 类型。注意 WorkWeek 是一个包含 7 个元素的数组。这是因为一周有 7 天,我们使用数组来确保固定的大小:

    type Developer struct {
      Individual Employee
      HourlyRate int
      WorkWeek   [7]int
    }
    
  5. 创建一个 Employee 类型:

    type Employee struct {
      Id        int
      FirstName string
      LastName  string
    }
    
  6. 创建一个类型为 intWeekday

    type Weekday int
    
  7. 创建一个类型为 Weekday 的常量。这是一个工作日的枚举:

    const (
      Sunday Weekday = iota //starts at zero
      Monday
      Tuesday
      Wednesday
      Thursday
      Friday
      Saturday
    )
    
  8. main() 函数中,包括以下代码;用以下详细信息初始化 Developer

    func main() {
      d := Developer{Individual:Employee{Id: 1, FirstName: "Tony", LastName:     "Stark"}, HourlyRate: 10}
    
  9. 接下来,调用 LogHours 方法:

      d.LogHours(Monday, 8)
      d.LogHours(Tuesday, 10)
    
  10. 打印出工作周和一周的工作时数:

      fmt.Println("Hours worked on Monday:  " ,d.WorkWeek[Monday])
      fmt.Println("Hours worked on Tuesday:  " ,d.WorkWeek[Tuesday])
      fmt.Printf("Hours worked this week:  %d",d.HoursWorked())
    }
    
  11. 创建一个 LogHours 方法;它是一个指针接收器方法。它接受一个名为 Weekday 的自定义类型和一个 int。该方法将 WorkWeek 字段赋给当天工作的小时数。WorkWeek 是一个大小为 7 的固定大小数组,因为一周有 7 天:

    func (d *Developer) LogHours(day Weekday, hours int) {
      d.WorkWeek[day] = hours
    }
    
  12. 创建一个返回 int 类型的 HoursWorked 方法。HoursWorked 函数遍历 WorkWeek,将每天的小时数加到 total 上:

    func (d *Developer) HoursWorked() int {
      total := 0
      for _, v := range d.WorkWeek {
        total += v
      }
      return total
    }
    

    以下为预期输出:

    Hours worked on Monday:  8
    Hours worked on Tuesday:  10
    Hours worked this week:  18
    

活动 5.02:根据工作小时数计算员工的应付款项

解决方案:

  1. 创建一个名为 Activity5.02 的目录。

  2. 在步骤 1 中的目录中创建一个名为 main.go() 的文件。

  3. 将以下代码复制到 Activity5.02/main.go 中。这是与 Activity5.01 中步骤 3-7 相同的代码;请参阅那些步骤以了解代码的描述:

    main.go
    3  type Developer struct {
    4    Individual Employee
    5    HourlyRate int
    6    WorkWeek   [7]int
    7  }
    8  type Employee struct {
    9    Id        int
    10   FirstName string
    11   LastName  string
    12 }
    13 type Weekday int
    14 const (
    15   Sunday Weekday = iota //starts at zero
    The full code for this step is available at: https://packt.live/34NsT7T
    
  4. main() 函数中,放置以下代码。将 x 赋值为 nonLoggedHours() 的返回值。如您所回忆的,返回值是 func(int)int。以下三个 print 语句将一个值传递给 x func。每次调用 x func 时,它都会将传递给运行总量的值相加:

    func main() {
      d := Developer{Individual: Employee{Id: 1, FirstName: "Tony", LastName:     "Stark"}, HourlyRate: 10}
      x := nonLoggedHours()
      fmt.Println("Tracking hours worked thus far today: ", x(2))
      fmt.Println("Tracking hours worked thus far today: ", x(3))
      fmt.Println("Tracking hours worked thus far today: ", x(5))
      fmt.Println()
      d.LogHours(Monday, 8)
      d.LogHours(Tuesday, 10)
      d.LogHours(Wednesday, 10)
      d.LogHours(Thursday, 10)
      d.LogHours(Friday, 6)
      d.LogHours(Saturday, 8)
      d.PayDetails()
    }
    
  5. LogHoursHoursWorked 保持不变:

    func (d *Developer) LogHours(day Weekday, hours int) {
      d.WorkWeek[day] = hours
    }
    func (d *Developer) HoursWorked() int {
      total := 0
      for _, v := range d.WorkWeek {
        total += v
      }
      return total
    }
    
  6. 创建一个名为 PayDay() 的方法,它返回一个 int 和一个 bool。该方法评估 HoursWorked 是否大于 40。如果是,则计算 hoursOver 作为加班费。如果工资包括加班费,则返回总工资和 true

    func (d *Developer) PayDay() (int, bool) {
      if d.HoursWorked() > 40 {
        hoursOver := d.HoursWorked() - 40
        overTime := hoursOver * 2
        regularPay := d.HoursWorked() * d.HourlyRate
        return regularPay + overTime, true
      }
      return d.HoursWorked() * d.HourlyRate, false
    }
    
  7. 创建一个名为 nonLoggedHours() 的函数。这是一个返回类型为 func(int)int 的函数。该函数是一个闭包,它封装了匿名函数。每次调用该函数时,它都会将传递给运行总量的整数相加,并返回总量:

    func nonLoggedHours() func(int) int {
      total := 0
      return func(i int) int {
        total += i
        return total
      }
    }
    
  8. 创建一个名为 PayDetails 的方法。在 PayDetails 方法内部,它遍历 d.WorkWeek。它将 i 值赋给切片的索引,将 v 赋给切片中存储的值。i switch 是切片的索引;它代表一周中的某一天。case 语句评估 i 的值,并根据该值打印出那一天和那一周的时数。

  9. 该函数还打印出一周的工作时数、一周的工资以及是否为加班费。

  10. 第一个 print 语句打印出工作的小时数。

  11. payovertime 被分配了从 d.Payday() 返回的值。

  12. 以下 pay 语句打印出工资、是否加班以及一个空行:

main.go
64 func (d *Developer) PayDetails() {
65   for i, v := range d.WorkWeek {
66     switch i {
67     case 0:
68       fmt.Println("Sunday hours: ", v)
69     case 1:
70       fmt.Println("Monday hours: ", v)
71     case 2:
The full code for this step is available at: https://packt.live/2QeUNEF

运行此活动的结果如下:

图 5.14:可支付金额活动的输出

图 5.14:可支付金额活动的输出

第六章:错误

活动六点零一:为银行应用程序创建自定义错误信息

解决方案:

  1. 在你的 $GOPATH 内创建一个名为 Activity6.01 的目录。

  2. 步骤 1 中创建的目录内保存一个名为 main.go 的文件。

  3. 定义 package main 并导入两个包,errorsfmt

    package main
    import (
      «errors»
      «fmt»
    )
    
  4. 接下来,定义我们的自定义错误,它将返回一个显示 "invalid last name" 的错误:

    var ErrInvalidLastName = errors.New("invalid last name")
    
  5. 我们还需要一个自定义错误,它将返回一个显示 "invalid routing number" 的错误:

    var ErrInvalidRoutingNum = errors.New("invalid routing number")
    
  6. main() 函数中,我们将打印出每个错误:

    func main() {
      fmt.Println(ErrInvalidLastName)
      fmt.Println(ErrInvalidRoutingNum)
    }
    
  7. 在命令行中,导航到 步骤 1 中创建的目录。

  8. 在命令行中,输入以下内容:

    go build
    

    go build 命令将编译你的程序,并创建一个以你在 步骤 1 中创建的目录命名的可执行文件。

  9. 输入 步骤 8 中创建的文件名并按 Enter 运行可执行文件。

    预期输出如下:

invalid last name
invalid routing number

活动六点零二:验证银行客户直接存款提交

解决方案:

  1. $GOPATH 内创建一个名为 Activity6.02 的目录。

  2. 步骤 1 中创建的目录内保存一个名为 main.go 的文件。

  3. 定义 package main 并为该应用程序添加以下导入:

    package main
    import (
      "errors"
      "fmt"
      "strings"
    )
    
  4. 定义活动描述中提到的结构和字段:

    type directDeposit struct {
      lastName      string
      firstName     string
      bankName      string
      routingNumber int
      accountNumber int
    }
    
  5. 定义两个错误,稍后将在 directDeposit 方法中使用:

    var ErrInvalidLastName = errors.New("invalid last name")
    var ErrInvalidRoutingNum = errors.New("invalid routing number")
    
  6. main() 函数中,分配一个 directDeposit 类型的变量并设置其字段:

    func main() {
      dd := directDeposit{
        lastName:      "  ",
        firstName:     "Abe",
        bankName:      "XYZ Inc",
        routingNumber: 17,
        accountNumber: 1809,
      }
    
  7. 将名为 err 的变量分配给 directDepositvalidateRoutingNumbervalidateLastName 方法。如果返回错误,则打印错误:

      err := dd.validateRoutingNumber()
      if err != nil {
        fmt.Println(err)
      }
      err = dd.validateLastName()
      if err != nil {
        fmt.Println(err)
      }
    
  8. 调用 report() 方法以打印出字段的值:

      dd.report()
    }
    
  9. 创建一个方法,用于检查 routingNumber 是否小于 100。如果该条件为 true,则返回自定义错误 ErrInvalidRoutingNum,否则返回 nil

    func (dd *directDeposit) validateRoutingNumber() error {
      if dd.routingNumber < 100 {
        return ErrInvalidRoutingNum
      }
      return nil
    }
    
  10. 现在我们将添加 validateLastName 方法。此方法从 lastName 中删除所有尾随空格,并检查 lastName 的长度是否为零。如果 lastName 的长度为零,则该方法将返回错误 ErrInvalidLasName。如果 lastName 不为零,则返回 nil

    func (dd *directDeposit) validateLastName() error {
      dd.lastName = strings.TrimSpace(dd.lastName)
      if len(dd.lastName) == 0 {
        return ErrInvalidLastName
      }
      return nil
    }
    
  11. 下一个 report() 方法将打印出每个 directDeposit 字段的值:

    func (dd *directDeposit) report() {
      fmt.Println(strings.Repeat("*", 80))
      fmt.Println("Last Name: ", dd.lastName)
      fmt.Println("First Name: ", dd.firstName)
      fmt.Println("Bank Name: ", dd.bankName)
      fmt.Println("Routing Number: ", dd.routingNumber)
      fmt.Println("Account Number: ", dd.accountNumber)
    }
    
  12. 在命令行中,导航到 步骤 1 中创建的目录。

  13. 在命令行中,输入以下内容:

    go build
    

    go build 命令将编译你的程序,并创建一个以你在 步骤 1 中创建的目录命名的可执行文件。

  14. 输入 步骤 13 中创建的文件名并按 Enter 运行可执行文件。

    预期输出如下:

图 6.14:验证银行客户的直接存款提交

图 6.14:验证银行客户的直接存款提交

图 6.14:验证银行客户的直接存款提交

活动第 6.03:无效数据提交时的恐慌

解决方案

  1. 导航到活动 6.02步骤 1中使用的目录,验证银行的客户直接存款提交

  2. 将返回的ErrInvalidRoutingNum改为使用panic()函数传递ErrInvalidRoutingNum来 panic:

    func (dd *directDeposit) validateRoutingNumber() error {
      if dd.routingNumber < 100 {
        panic(ErrInvalidRoutingNum)
      }
      return nil
    }
    
  3. 在命令行中,导航到步骤 1中使用的目录。

  4. 在命令行中,键入以下内容:

    go build
    

    go build命令将编译你的程序并创建一个以你在步骤 1中使用的目录命名的可执行文件。

  5. 键入在步骤 4中创建的文件名并按Enter键运行可执行文件。

    预期输出如下:

![图 6.15:无效路由号码的 panic图 6.15:无效路由号码的 panic

图 6.15:无效路由号码的 panic

活动第 6.04:防止 panic 崩溃应用程序

解决方案

  1. 导航到活动 6.03步骤 1中使用的目录,无效数据提交时的恐慌

  2. validateRoutingNumber方法中添加一个defer函数。

  3. defer函数中,检查是否有错误从recover()函数返回。

  4. 如果有错误,从recover()函数中打印错误。

    唯一的改变是添加一个延迟函数:

    func (dd *directDeposit) validateRoutingNumber() error {
      defer func() {
        if r:= recover(); r != nil {
          fmt.Println(r)
        }
      }()
      if dd.routingNumber < 100 {
        panic(ErrInvalidRoutingNum)
      }
      return nil
    }
    
  5. 在命令行中,导航到步骤 1中使用的目录。

  6. 在命令行中,键入以下内容:

    go build
    

    go build命令将编译你的程序并创建一个以你在步骤 1中使用的目录命名的可执行文件。

  7. 步骤 6中创建的文件名中键入并按Enter键运行可执行文件。

    预期输出如下:

![图 6.16:从无效路由号码的 panic 中恢复图 6.16:从无效路由号码的 panic 中恢复

图 6.16:从无效路由号码的 panic 中恢复

第七章:接口

活动第 7.01:计算工资和绩效评估

解决方案

  1. 创建一个main.go文件。

  2. main.go文件中,我们有一个main包,我们需要导入errorsfmtos包:

    package main
    import (
      "errors"
      "fmt"
      "os"
    )
    
  3. 按照以下方式创建Employee结构体:

    type Employee struct {
      Id        int
      FirstName string
      LastName  string
    }
    
  4. 创建Developer结构体。Developer结构体将Employee结构体嵌入其中:

    type Developer struct {
      Individual        Employee
      HourlyRate        float64
      HoursWorkedInYear float64
      Review            map[string]interface{}
    }
    
  5. 创建Manager结构体;它也将Employee结构体嵌入其中:

    type Manager struct {
      Individual     Employee
      Salary         float64
      CommissionRate float64
    }
    
  6. Pay接口将由ManagerDeveloper类型使用来计算他们的工资:

    type Payer interface {
      Pay() (string, float64)
    }
    
  7. Developer结构体中添加一个FullName()方法。这是用于连接开发者的FirstNameLastName并返回它的:

    func (d Developer) FullName() string {
      fullName := d.Individual.FirstName + " " + d.Individual.LastName
      return fullName
    }
    
  8. 为开发者创建一个实现Payer接口的Pay()方法。

    Developer结构体通过有一个返回字符串和 float64 的Pay方法来满足Payer接口。Developer Pay()方法返回开发者的fullName,并通过计算Developer HourlRate * HoursWorkedInYear来返回该年的工资:

    func (d Developer) Pay() (string, float64) {
      fullName := d.FullName()
      return fullName, d.HourlyRate * d.HoursWorkedInYear
    }
    
  9. Manager结构体创建一个Pay()方法,该方法将实现Payer接口。

  10. Manager结构体通过具有名为Pay()的方法来满足Payer{}接口,该方法返回一个字符串和一个float64Manager Pay方法返回Manager结构体的fullName,并通过计算Manager工资加上Manager工资乘以经理的CommissionRate来返回当年的工资:

    func (m Manager) Pay() (string, float64) {
      fullName := m.Individual.FirstName + " " + m.Individual.LastName
      return fullName, m.Salary + (m.Salary * m.CommissionRate)
    }
    
  11. 创建一个接受Payer{}接口的payDetails()函数。它将调用传入类型的Pay()方法;Pay()方法是Payer接口所必需的。打印从Pay()方法返回的fullNameyearPay

    func payDetails(p Payer) {
      fullName, yearPay := p.Pay()
      fmt.Printf("%s got paid %.2f for the year\n", fullName, yearPay)
    }
    

    payDetails()函数接受一个Payer{}接口。然后它打印从Pay()方法返回的fullNameyearPay

  12. main函数内部,我们需要创建一个Developer类型和一个Manager类型,并设置它们的字段值:

      d := Developer{Individual: Employee{Id: 1, FirstName: "Eric", LastName: "Davis"}, HourlyRate: 35, HoursWorkedInYear: 2400, Review: employeeReview}
      m := Manager{Individual: Employee{Id: 2, FirstName: "Mr.", LastName: "Boss"}, Salary: 150000, CommissionRate: .07}
    
  13. 调用payDetails()并传递开发者和经理作为参数。由于DeveloperManager都满足Payer{}接口,我们可以将它们传递给payDetails()函数。

    main函数中,我们将d初始化为Developer结构字面量,将m初始化为Manager结构字面量:

      payDetails(d)
      payDetails(m)
    
  14. 现在我们需要为开发者创建员工评审的数据。我们将创建一个以字符串为键、接口为值的映射。如您所回忆的那样,不同的经理可以为分类分配的评分使用数值或字符串值:

      employeeReview := make(map[string]interface{})
      employeeReview["WorkQuality"] = 5
      employeeReview["TeamWork"] = 2
      employeeReview["Communication"] = "Poor"
      employeeReview["Problem-solving"] = 4
      employeeReview["Dependability"] = "Unsatisfactory"
    
  15. 对于评审评分,我们需要能够将分类的字符串评分转换为分类的整数版本。我们将创建一个名为convertReviewToInt()的函数来执行此转换,使用switch case语句。字符串上的switch语句检查评分的不同字符串版本,并返回评分的整数版本。如果评分的字符串版本未找到,则执行默认子句并返回一个错误:

    func convertReviewToInt(str string) (int, error) {
      switch str {
      case "Excellent":
        return 5, nil
      case "Good":
        return 4, nil
      case "Fair":
        return 3, nil
      case "Poor":
        return 2, nil
      case "Unsatisfactory":
        return 1, nil
      default:
        return 0, errors.New("invalid rating: " + str)
      }
    }
    

    我们需要创建一个名为OverallReview()的函数,该函数接受一个接口并返回一个整数和一个错误。

    回想一下,我们的评审过程提供了字符串和整数作为评分;这就是为什么这个函数接受一个接口,这样我们就可以评估任何类型。

    我们使用switch类型代码结构来确定接口的具体类型。v变量被分配给i的具体类型。

    评分的有效类型只有int和字符串。任何其他类型都被视为无效,并导致执行默认语句。如果类型在case语句中未找到,则默认语句将返回一个错误。

  16. 如果类型是int,它将直接返回一个int。如果接口的具体类型是字符串,则将执行case string中的代码。它将字符串传递给convertReviewToInt(v)函数。这个函数,如前所述,将查找评分的字符串版本并返回相应的整数:

    func OverallReview(i interface{}) (int, error) {
      switch v := i.(type) {
      case int:
        return v, nil
      case string:
        rating, err := convertReviewToInt(v)
        if err != nil {
          return 0, err
        }
        return rating, nil
      default:
        return 0, errors.New("unknown type")
      }
    }
    
  17. 接下来,创建ReviewRating()方法以执行开发者的评级计算。DeveloperReviewRating()方法执行Review的计算。它遍历d.Review字段,该字段是map[string]interface{}类型。它将每个 interface 值传递给OverallReview(v)函数以获取评级的整数值。每个循环迭代将那个评级加到总变量中。然后计算评级的平均值并打印结果。以下是性能评级的输出结果:

    func (d Developer) ReviewRating() error {
      total := 0
      for _, v := range d.Review {
        rating, err := OverallReview(v)
        if err != nil {
          return err
        }
        total += rating
      }
      averageRating := float64(total) / float64(len(d.Review))
      fmt.Printf("%s got a review rating of %.2f\n",d.FullName(),averageRating)
      return nil
    }
    
  18. main()函数中,调用ReviewRating()并打印任何错误:

    err := d.ReviewRating()
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  19. 接下来,为Developer类型和Manager类型调用payDetails()函数:

        payDetails(d)
        payDetails(m)
    }
    
  20. 通过在命令行中运行go build来构建程序:

    go build
    
  21. 通过在命令行中输入可执行文件名来运行程序。

    预期输出如下:

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

第八章:包

活动第 8.01 节:创建计算工资和绩效评估的函数

解决方案

所有目录和文件都应该在$GOPATH内部创建:

  1. 创建名为Activity8.01的目录。

  2. Activity8.01内部创建名为paypayroll的目录。

  3. Chapter08/Activity8.01/pay内部创建名为main.go的文件。

  4. 创建以下文件:在payroll内部创建developer.goemployee.gomanager.go

  5. 目录结构和文件应该类似于以下截图:![图 8.16:程序目录结构]

    ![img/B14177_08_16.jpg]

    图 8.16:程序目录结构

  6. Chapter08/Activity8.01/payroll/developer.go内部,将包声明为payroll

    package payroll
    import (
      "errors"
      "fmt"
    )
    
  7. Developer类型及其以下方法Pay()ReviewRating()都将可导出,因此第一个字母需要大写。这意味着它们对payroll之外的其它包可见。

  8. packt.live/2YNnfS6,将关于开发者类型和方法的代码移动到Chapter08/Activity8.01/payroll/developer.go文件中。它应该看起来像以下代码片段:

    developer.go
    1  package payroll
    2  import (
    3    "errors"
    4    "fmt"
    5  )
    6  type Developer struct {
    7    Individual        Employee
    8    HourlyRate        float64
    9    HoursWorkedInYear float64
    10   Review            map[string]interface{}
    11 }
    The full code for this step is available at: https://packt.live/34NTAtn
    
  9. Chapter08/Activity8.01/payroll/employee.go内部,将包声明为payroll

    package payroll
    import "fmt"
    
  10. Employee类型、Payer接口及其方法都将可导出,因此第一个字母需要大写。这意味着它们对payroll之外的其它包可见。

  11. packt.live/2YNnfS6,将关于员工类型和方法的代码移动到Chapter08/Activity8.01/payroll/employee.go文件中。它应该看起来像以下代码片段:

    package payroll
    import "fmt"
    type Payer interface {
      Pay() (string, float64)
    }
    type Employee struct {
      Id        int
      FirstName string
      LastName  string
    }
    func PayDetails(p Payer) {
      fullName, yearPay := p.Pay()
      fmt.Printf("%s got paid %.2f for the year\n", fullName, yearPay)
    }
    
  12. Chapter08/Activity8.01/payroll/manager.go内部,将package声明为payroll

    package payroll
    
  13. manager.go中,Manager类型及其方法将是可导出的。所有类型和方法都是可导出的,因为第一个字母是大写的。这意味着它们对payroll之外的其它包可见。

  14. packt.live/2YNnfS6,将关于员工类型和方法的代码移动到Chapter08/Activity8.01/payroll/manager.go文件。它应该看起来像以下代码片段:

    package payroll
    type Manager struct {
      Individual     Employee
      Salary         float64
      CommissionRate float64
    }
    func (m Manager) Pay() (string, float64) {
      fullName := m.Individual.FirstName + " " + m.Individual.LastName
      return fullName, m.Salary + (m.Salary * m.CommissionRate)
    }
    

    developer.goemployee.gomanager.go文件组成了payroll包。尽管payroll包被拆分成了三个文件:developer.goemployee.gomanager.go,但它们都可以在payroll包的文件中访问。这个目录中的每个文件都属于payroll包。

  15. 接下来,在Chapter08/Activity8.01/pay/main.go文件中,通过查看package声明,我们可以看到这是一个可执行的包。这是因为任何作为主包的包都是可执行的。我们还知道,由于这是主包,它将有一个main()函数:

    package main
    
  16. 从初始化过程中我们知道,包将首先初始化它们的变量和init()函数。在import声明中,我们正在导入我们的payroll包。payroll包也将被别名为pr

    import (
      "fmt"
      "os"
      pr "github.com/PacktWorkshops/Get-Ready-To-Go/Chapter08/Activity8.01/payroll"
    )
    
  17. main包的employeeReview变量将在import项之后初始化:

    var employeeReview = make(map[string]interface{})
    
  18. 接下来,创建init()函数。它将在main包中的其他函数之前运行。它将用一条消息问候用户:

    func init() {
      fmt.Println("Welcome to the Employee Pay and Performance Review")
      fmt.Println("++++++++++++++++++++++++++++++++++++++++++++++++++")
    }
    
  19. 这是main包中的第二个init()函数,它将在下一个运行。它将employeeReview变量初始化为在这个包中将使用的值:

    func init() {
      fmt.Println("Initializing variables")
      employeeReview["WorkQuality"] = 5
      employeeReview["TeamWork"] = 2
      employeeReview["Communication"] = "Poor"
      employeeReview["Problem-solving"] = 4
      employeeReview["Dependability"] = "Unsatisfactory"
    }
    
  20. 现在我们来到了main()函数。每个主包都有一个main()函数。这是我们的可执行程序的入口点:

    func main() {
    
  21. 我们在import声明中将我们的payroll别名为pr。我们通过prpayroll别名初始化可导出的Developer类型。由于payroll包中的Developer是可导出的,我们可以在main包中看到它。对于Employee类型也是如此:

      d := pr.Developer{Individual: pr.Employee{Id: 1, FirstName: "Eric", LastName: "Davis"}, HourlyRate: 35, HoursWorkedInYear: 2400, Review: employeeReview}
    
  22. 我们在import声明中将我们的payroll别名为pr。我们通过prpayroll别名初始化可导出的Manager类型。由于payroll包中的Manager是可导出的,我们可以在main包中看到它。对于Employee类型也是如此:

      m := pr.Manager{Individual: pr.Employee{Id: 2, FirstName: "Mr.", LastName: "Boss"}, Salary: 150000, CommissionRate: .07}
    
  23. Developer方法,ReviewRating()也是可导出的。这允许我们从payroll包中调用该方法:

      err := d.ReviewRating()
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  24. PayDetails函数是可导出的,我们也可以在payroll包中调用该函数。我们使用别名pr来调用它:

      pr.PayDetails(d)
      pr.PayDetails(m)
    }
    
  25. 在命令行中,进入/Exercise8.01/Activity8.01/pay目录结构。

  26. 在命令行中,输入以下内容:

    go build
    
  27. go build命令将编译你的程序并创建一个以dir区域命名的可执行文件。

  28. 输入可执行文件名并按Enter键:

    ./pay
    

    结果应该是这样的:

    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
    

第九章:基本调试

活动第 9.01:构建验证社会保险号码的程序

解决方案:

所有创建的目录和文件都应该位于你的$GOPATH内:

  1. Chapter09 目录下创建一个名为 Activity9.01 的目录。

  2. Chapter09/Activity9.01/ 目录下创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开 main.go 文件。

  4. 将以下代码添加到 main.go 中。

    这里是构建的 main 函数:

    package main
    import (
        "errors"
        "fmt"
        "log"
        "strconv"
        "strings"
    )
    
  5. 添加以下自定义错误类型。我们将使用这些自定义错误记录到我们的程序中。这些自定义错误将由它们各自的功能返回。它们将在日志中适用的情况下出现:

    var (
        ErrInvalidSSNLength     = errors.New("ssn is not nine characters long")
        ErrInvalidSSNNumbers    = errors.New("ssn has non-numeric digits")
        ErrInvalidSSNPrefix     = errors.New("ssn has three zeros as a prefix")
        ErrInvalidSSNDigitPlace = errors.New("ssn starts with a 9 requires 7 or       9 in the fourth place")
    )
    
  6. 创建一个函数,用于检查 SSN 的长度是否有效。如果长度不是 9,则返回一个错误,包括导致自定义错误 ErrInvalidSSNLength 的 SSN 详细信息:

    func validLength(ssn string) error {
        ssn = strings.TrimSpace(ssn)
        if len(ssn) != 9 {
            return fmt.Errorf("the value of %s caused an error: %v\n", ssn,           ErrInvalidSSNLength)
        }
        return nil
    }
    
  7. 创建一个函数,用于检查 SSN 中的所有字符是否都是数字。如果 SSN 无效,返回一个错误,包括导致自定义错误 ErrInvalidSSNNumbers 的 SSN 详细信息:

    func isNumber(ssn string) error {
        _, err := strconv.Atoi(ssn)
        if err != nil {
            return fmt.Errorf("the value of %s caused an error: %v\n", ssn,           ErrInvalidSSNNumbers)
        }
        return nil
    }
    
  8. 创建一个函数,用于检查 SSN 是否以 000 开头。如果 SSN 无效,返回一个错误,包括导致自定义错误 ErrInvalidSSNPrefix 的 SSN 详细信息:

    func isPrefixValid(ssn string) error {
        if strings.HasPrefix(ssn, "000") {
            return fmt.Errorf("the value of %s caused an error: %v\n", ssn,           ErrInvalidSSNPrefix)
        }
        return nil
    }
    
  9. 创建一个函数,用于检查如果 SSN 以 9 开头,那么 SSN 的第四位数字应该是 7 或 9。如果 SSN 无效,返回一个错误,包括导致自定义错误 ErrInvalidSSNDigitPlace 的 SSN 详细信息:

    func validDigitPlace(ssn string) error {
        if string(ssn[0]) == "9" && (string(ssn[3]) != "9" && string(ssn[3]) !=       "7") {
            return fmt.Errorf("the value of %s caused an error: %v\n", ssn,           ErrInvalidSSNDigitPlace)
        }
        return nil
    }
    
  10. main() 函数中,设置我们的日志标志:

    func main() {
        log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
    
  11. 初始化我们的 validateSSN 切片,包含我们将要验证的各种 SSN 号码:

        validateSSN := []string{"123-45-6789", "012-8-678", "000-12-0962", "999-      33-3333", "087-65-4321","123-45-zzzz"}
    
  12. 使用 %#v 打印 validateSSN 变量的 Go 表示形式:

        log.Printf("Checking data %#v",validateSSN)
    
  13. 接下来,创建一个 for 循环,使用 range 子句遍历 SSN 切片:

        for idx,ssn := range validateSSN {
    
  14. for 循环中,对于每个 SSN,我们想要打印一些关于 ssn 的详细信息。我们想要打印我们正在验证的切片中 SSN 的当前条目顺序,使用 %d 修饰符。最后,我们需要使用 %d 修饰符打印切片中的项目总数:

            log.Printf("Validate data %#v %d of %d ",ssn,idx+1,len(validateSSN))
    
  15. 从我们的 SSN 中删除任何破折号:

            ssn = strings.Replace(ssn, "-", "", -1)
    
  16. 调用我们用于验证 SSN 的每个函数。记录从函数返回的错误:

            Err := isNumber(ssn)
            if err != nil {
                log.Print(err)
            }
            err = validLength(ssn)
            if err != nil {
                log.Print(err)
            }
            err = isPrefixValid(ssn)
            if err != nil {
                log.Print(err)
            }
            err = validDigitPlace(ssn)
            if err != nil {
                log.Print(err)
            }
        }
    }
    
  17. 在命令行中,使用以下代码更改目录:

    cd Chapter09/Exercise9.02/ 
    
  18. 练习 9.02打印十进制、二进制和十六进制值 目录中,输入以下命令:

    go build
    

    输入由 go build 命令创建的可执行文件,并按 Enter 键。

    预期输出如下:

![图 9.15:验证 SSN 输出图片

图 9.15:验证 SSN 输出

第十章:关于时间

活动 10.01:根据用户要求格式化日期

解决方案:

  1. 创建一个名为 Chapter_10_Activity_1.go 的文件,并使用以下代码初始化它:

    package main
    import "fmt"
    import "time"
    import "strconv"
    func main(){
    
  2. 捕获以下值:datedaymonthyearhourminutesecond

      date := time.Now()
      day := strconv.Itoa(date.Day())
      month := strconv.Itoa(int(date.Month()))
      year := strconv.Itoa(date.Year())
      hour := strconv.Itoa(date.Hour())
      minute := strconv.Itoa(date.Minute())
      second := strconv.Itoa(date.Second())
    
  3. 打印连接的输出:

      fmt.Println(hour + ":" + minute + ":" + second + " " + year + "/" + month   + "/" + day)
    }
    

    预期输出如下(请注意,这取决于你何时运行代码):

    15:32:30 2019/10/17
    

活动 10.02:强制执行日期和时间的特定格式

解决方案:

  1. 创建一个名为 Chapter_10_Activity_2.go 的文件,并按以下方式初始化脚本:

    package main
    import "fmt"
    import "time"
    import "strconv"
    func main(){
    
  2. 捕获以下值:日期

      date := time.Date(2019, 1, 31, 2, 49, 21, 324359102, time.UTC)
      day := strconv.Itoa(date.Day())
      month := strconv.Itoa(int(date.Month()))
      year := strconv.Itoa(date.Year())
      hour := strconv.Itoa(date.Hour())
      minute := strconv.Itoa(date.Minute())
      second := strconv.Itoa(date.Second())
    
  3. 打印连接后的输出:

    fmt.Println(hour + ":" + minute + ":" + second + " " + year + "/" + month +   "/" + day)
    }
    

    预期输出如下:

    2:49:21 2019/1/31
    

活动 10.03:测量经过的时间

解决方案:

  1. 创建一个名为 Chapter_10_Activity_3.go 的文件,并按以下方式初始化:

    package main
    import "fmt"
    import "time"
    func main(){
    
  2. 在变量中捕获执行的开始时间,并暂停 2 秒:

      start := time.Now()
      time.Sleep(2 * time.Second)
    
  3. 在变量中捕获执行的结束时间并计算长度:

      end := time.Now()
      length := end.Sub(start)
    
  4. 打印 sleep 执行所需的时间:

    fmt.Println("The execution took exactly",length.Seconds(),"seconds!")
    }
    

    预期输出如下:

    The execution took exactly 2.0016895 seconds!
    

活动 10.04:计算未来的日期和时间

解决方案:

  1. 创建一个名为 Chapter_10_Activity_4.go 的文件,并按以下方式初始化:

    package main
    import "fmt"
    import "time"
    func main(){
    
  2. 捕获并打印 当前 时间:

      Current := time.Now()
      fmt.Println("The current time is:",Current.Format(time.ANSIC))
    
  3. 计算指定时长并创建一个名为 Future 的变量:

      SSS := time.Duration(21966 * time.Second)
      Future := Current.Add(SSS)
    
  4. 以 ANSIC 格式打印 Future 的时间值:

    fmt.Println("6 hours, 6 minutes and 6 seconds from now the time will be:   ",Future.Format(time.ANSIC))
    }
    

    预期输出如下:

    The current time: Thu Oct 17 15:16:48 2019
    6 hours, 6 minutes and 6 seconds from now the time will be:  Thu Oct 17 21:22:54 2019
    

活动 10.05:在不同时区打印本地时间

解决方案:

  1. 创建一个名为 Chapter_10_Activity_5.go 的文件,并按以下方式初始化:

    package main
    import "fmt"
    import "time"
    func main(){
    
  2. 捕获以下值:当前纽约时间洛杉矶时间

    Current := time.Now()
      NYtime, _ := time.LoadLocation("America/New_York")
      LA, _ := time.LoadLocation("America/Los_Angeles")
    
  3. 以以下格式打印值:

    fmt.Println("The local current time is:",Current.Format(time.ANSIC))
      fmt.Println("The time in New York is:     ",Current.In(NYtime).Format(time.ANSIC))
      fmt.Println("The time in Los Angeles is:     ",Current.In(LA).Format(time.ANSIC))
    }
    

    预期输出如下:

    The local current time is: Thu Oct 17 15:16:13 2019
    The time in New York is: Thu Oct 17 09:16:13 2019
    The time in Los Angeles is: Thu Oct 17 06:16:13 2019
    

第十一章:编码和解码(JSON)

活动 11.01:使用 JSON 模拟客户订单

解决方案:

所有创建的目录和文件都需要在您的 $GOPATH 内创建:

  1. 在名为 Chapter11 的目录内创建一个名为 Activity11.01 的目录。

  2. Chapter11/Activity11.01 内创建一个名为 main.go 的文件。

  3. 使用 Visual Studio Code 打开新创建的 main.go 文件。

  4. 添加以下包名和导入语句:

    package main
    import (
      "encoding/json"
      "fmt"
      "os"
    )
    
  5. 添加以下带有相应 JSON 标签的 customer struct

    type customer struct {
      UserName      string  `json:"username"`
      Password      string  `json:"-"`
      Token         string  `json:"-"`
      ShipTo        address `json:"shipto"`
      PurchaseOrder order   `json:"order"`
    }
    
  6. 添加以下带有相应 JSON 标签的 order struct

    type order struct {
      TotalPrice  int    `json:"total"`
      IsPaid      bool   `json:"paid"`
      Fragile     bool   `json:",omitempty"`
      OrderDetail []item `json:"orderdetail"`
    }
    
  7. 添加以下带有相应 JSON 标签的 item struct

    type item struct {
      Name        string `json:"itemname"`
      Description string `json:"desc,omitempty"`
      Quantity    int    `json:"qty"`
      Price       int    `json:"price"`
    }
    
  8. 添加以下带有相应 JSON 标签的 address struct

    type address struct {
      Street  string `json:"street"`
      City    string `json:"city"`
      State   string `json:"state"`
      ZipCode int    `json:"zipcode"`
    }
    
  9. 在客户类型上创建一个名为 Total() 的方法。此方法将计算客户类型 PurchaseOrderTotalPrice。计算方式为,对于每个项目,数量 * 价格

    func (c *customer) Total() {
      price := 0
      for _, item := range c.PurchaseOrder.OrderDetail {
        price += item.Quantity * item.Price
      }
      c.PurchaseOrder.TotalPrice = price
    }
    
  10. 添加一个带有 jsonData []bytemain() 函数:

    func main() {
      jsonData := []byte(`
      {
        "username" :"blackhat",
        "shipto":  
          {
              "street": "Sulphur Springs Rd",
              "city": "Park City",
              "state": "VA",
              "zipcode": 12345
          },
        "order":
          {
            "paid":false,
            "orderdetail" : 
               [{
                "itemname":"A Guide to the World of zeros and ones",
                "desc": "book",
                "qty": 3,
                "price": 50
              }]
          }
      }
      `)
    
  11. 接下来,我们需要验证 jsonData 是否是有效的 JSON。如果不是,打印一条消息并退出应用程序:

      if !json.Valid(jsonData) {
        fmt.Printf("JSON is not valid: %s", jsonData)
        os.Exit(1)
      }
    
  12. 声明一个客户类型的变量:

      var c customer
    
  13. jsonData 解码到客户变量中。检查是否有错误,如果有错误,打印错误并退出应用程序:

      err := json.Unmarshal(jsonData, &c)
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  14. 声明一个 item{} 类型的变量并设置所有字段:

      game := item{}
      game.Name = "Final Fantasy The Zodiac Age"
      game.Description = "Nintendo Switch Game"
      game.Quantity = 1
      game.Price = 50
    
  15. 声明另一个 item{} 类型的变量并设置所有字段,除了 Description 字段:

      glass := item{}
      glass.Name = "Crystal Drinking Glass"
      glass.Quantity = 11
      glass.Price = 25
    
  16. 将两个新创建的项目添加到客户订单的 OrderDetail 中:

      c.PurchaseOrder.OrderDetail = append(c.PurchaseOrder.OrderDetail, game)
      c.PurchaseOrder.OrderDetail = append(c.PurchaseOrder.OrderDetail, glass)
    
  17. 现在我们有了所有项目,我们可以通过调用 c.Total() 函数来计算价格:

      c.Total()
    
  18. 设置一些 PurchaseOrder 字段:

      c.PurchaseOrder.IsPaid = true
      c.PurchaseOrder.Fragile = true
    
  19. 将客户信息序列化为 JSON。正确设置缩进,以便可以轻松阅读 JSON。检查任何错误,如果有错误,打印消息,然后退出应用程序:

      customerOrder, err := json.MarshalIndent(c, "", "    ")
      if err != nil {
        fmt.Println(err)
        os.Exit(1)
      }
    
  20. 打印 JSON:

      fmt.Println(string(customerOrder))
    }
    
  21. 通过在命令行中运行go build来构建程序:

    go build
    
  22. 通过输入可执行文件名并按Enter键来运行可执行文件。

    结果如下:

图 11.22:客户订单打印输出

图 11.22:客户订单打印输出

第十二章:文件和系统

活动十二点零一:解析银行交易文件

解决方案

所有创建的目录和文件都应该在您的$GOPATH内部。

  1. 创建一个Chapter12/Activity12.01/目录。

  2. Chapter12/Activity12.01/内部创建一个main.go文件。

  3. 将以下代码添加到main.go文件中:

    package main
    import (
      "encoding/csv"
      "errors"
      "flag"
      "fmt"
      "io"
      "log"
      "os"
      "strconv"
      "strings"
    )
    
  4. 为燃料、食物、抵押贷款、维修、保险、公用事业和退休创建预算类别类型:

    type budgetCategory string
    const (
      autoFuel   budgetCategory = "fuel"
      food     budgetCategory = "food"
      mortgage   budgetCategory = "mortgage"
      repairs  budgetCategory = "repairs"
      insurance  budgetCategory = "insurance"
      utilities  budgetCategory = "utilities"
      retirement budgetCategory = "retirement"
    )
    
  5. 创建我们的自定义错误类型,当找不到预算类别时使用:

    var (
      ErrInvalidBudgetCategory = errors.New("budget category not found")
    )
    
  6. 创建我们的transaction struct,它将存储我们银行交易文件中的数据:

    type transaction struct {
      id     int
      payee  string
      spent  float64
      category budgetCategory
    }
    
  7. main()函数内部,我们需要创建两个标志。第一个要创建的标志是bankFilebankFile变量是 CSV 交易文件。为bankFile变量定义我们的标志。标志类型是字符串。CLI 将使用-c;这是用于存储 CSV bankFile位置的。默认值是空字符串,因此如果标志没有被设置,它的值将是空字符串。bankFile变量是存储标志值的地址:

    func main() {
      bankFile := flag.String("c", "", "location of the bank transaction csv file")
    //…
    }
    
  8. 下一个标志将是我们的logFile。这是用于记录错误的文件。定义我们的日志文件标志。标志类型是字符串。CLI 将使用-l;这是用于存储logFile变量位置的。默认值是空字符串,因此如果标志没有被设置,它的值将是空字符串。logFile变量是存储标志值的地址:

      logFile := flag.String("l", "", "logging of errors")
    
  9. 在定义标志后,您必须调用flag.Parse()来将命令行解析到定义的标志中。调用flag.Parse()-value的参数放入*bankFile*logFile。一旦您调用了flag.Parse(),标志将可用:

      flag.Parse()
    
  10. 我们的bankFile变量是必需的,因此我们需要确保它已被提供。当我们定义我们的标志时,我们将默认值设置为空字符串。如果*bankFile的值是空字符串,我们知道它没有被正确设置。如果没有提供*bankFile,我们打印一条消息,说明该字段是必需的,并带有usage语句。然后,退出程序:

      if *bankFile == "" {
      fmt.Println("csvFile is required.")
      flag.PrintDefaults()
      os.Exit(1)
      }
    
  11. 如果没有提供 CSV 文件,你应该得到以下消息:图 12.23:csvFile 是必需的消息

    图 12.23:csvFile 是必需的消息

  12. logfile变量是必需的,我们需要确保它已被提供。实现与上一步相同的代码,除了logfile

      if *logFile == "" {
      fmt.Println("logFile is required.")
      flag.PrintDefaults()
      os.Exit(1)
      }
    
  13. 实现代码以检查bankFile变量是否存在。我们正在对*bankFile文件调用os.Stat()来检查它是否存在。如果文件存在,os.Stat()方法将返回一个FileInfo。如果不存在,FileInfo将为nil,并返回错误。

  14. os.Stat()方法可以返回多个错误。我们必须检查错误以确定错误是否是由于文件不存在。标准库提供了os.IsNotExist(error),可以用来检查错误是否是由于文件不存在:

      _, err := os.Stat(*bankFile)
      if os.IsNotExist((err)) {
      fmt.Println("BankFile does not exist: ", *bankFile)
      os.Exit(1)
      }
    
  15. 同样,检查日志文件是否存在。如果存在,我们需要删除它:

      _, err = os.Stat(*logFile)
      if !os.IsNotExist((err)) {
      os.Remove(*logFile)
      }
    
  16. 接下来,打开bankFile变量。在打开bankFile时,os.Open函数返回一个满足io.Reader接口的*os.File类型,这将允许我们将其传递给下一个函数。

  17. 和往常一样,检查是否有错误返回。如果有,显示错误并退出程序:

      csvFile, err := os.Open(*bankFile)
      if err != nil {
      fmt.Println("Error opening file: ", *bankFile)
      os.Exit(1)
      }
    
  18. 我们将调用parseBankFile()函数;大部分工作都在这里完成。它将 CSV 文件转换为我们的交易结构体。然后我们需要遍历交易切片并打印交易数据:

      trxs := parseBankFile(csvFile, *logFile)
        fmt.Println()
        for _, trx := range trxs {
        fmt.Printf("%v\n", trx)
      }
    }
    
  19. 创建一个名为parseBankFile(bankTransaction io.Reader, logFile string) []transaction的函数:

    func parseBankFile(bankTransactions io.Reader, logFile string) []transaction {
    /…
    }
    
  20. 为 CSV 数据创建一个读取器。NewReader方法接受一个io.Reader类型的参数,并返回一个用于读取 CSV 数据的Reader类型。

  21. 创建一个类型为切片的transaction变量。

  22. 创建一个变量来检测 CSV 文件的标题:

      r := csv.NewReader(bankTransactions)
      trxs := []transaction{}
      header := true
    
  23. 实现代码,在无限循环中逐条读取每个记录。

  24. 在读取每条记录后,我们首先检查它是否是文件末尾(io.EOF)。我们需要执行此检查,以便在达到 EOF 时能够跳出无限循环。

  25. r.Read()方法读取一条记录;这是从r变量中获取的字段切片。它以[]string的形式返回该记录:

    for {
      trx := transaction{}
      record, err := r.Read()
      if err == io.EOF {
        break
      }
      if err != nil {
        log.Fatal(err)
      }
    
  26. 我们将使用header变量作为标志。当提供标题字段时,它们通常是文件的第一行。我们不需要处理列标题:

    if !header
    
  27. 目前,我们的第一个循环遍历 CSV 文件,但我们还需要一个循环来遍历记录中的每一列。记录是一个字段切片。idx是字段在切片中的位置:

    for idx, value := range record {
    
  28. 我们将在切片的idx(索引)上使用switch语句来识别存储在该位置的数据:

    switch idx {
    // id
    case 0:
    // payee
    case 1:
    // spent
    case 2:
    // category
    } 
    
  29. CSV 文件中的数据是字符串格式;我们需要对 CSV 文件中的字段进行各种转换。第一个字段是 ID。我们需要确保字段中没有尾随空格。

  30. 我们需要将字段从字符串转换为int,因为我们的结构体中id字段的数据类型是整数:

    // id
    case 0:
    value = strings.TrimSpace(value)
    trx.id, err = strconv.Atoi(value)
    
  31. 第二个索引值是1。该列包含payee的数据:

    // payee
    case 1:
      value = strings.TrimSpace(value)
      trx.payee = value
    

    第三个索引值是2。该列包含bankFile文件中支出列的数据。

  32. spentfloat类型,因此我们将spent列的string类型转换为float类型:

    // spent
    case 2:
      value = strings.TrimSpace(value)
      trx.spent, err = strconv.ParseFloat(value, 64)
      if err != nil {
      log.Fatal(err)
      }
    

    第三个索引值是3。此列包含银行提供的类别数据。

  33. 我们需要将 CSV 文件的类别列转换为我们的budgetCategory类型。

  34. 在类别case语句中,我们检查convertToBudgetCategory函数返回的任何错误。

  35. 如果有错误,我们不想停止处理 CSV 银行文件,因此我们通过writeErrorToLog函数将错误写入日志:

    // category
      case 3:
      trx.category, err = convertToBudgetCategory(value)
      if err != nil {
        s := strings.Join(record, ", ")
        writeErrorToLog("error converting csv category column - ", err, s, logFile)
      }
      }
    }
    
  36. 我们已经到达了记录中字段的循环末尾。现在我们需要将我们的交易添加到交易切片中:

    trxs = append(trxs, trx)
    }
    
  37. header在函数开始时为true;我们将将其设置为false,这表示在 CSV 文件的其余部分,我们将解析数据而不是header信息:

        header = false
      }
    
  38. 我们已经完成了 CSV 文件的解析。现在我们需要返回交易切片:

      return trxs
    }
    
  39. 创建一个名为convertToBudgetCategory(value string)(budgetCategory)的函数。此函数负责将银行类别映射到我们定义的类别。如果找不到类别,它将返回ErrInvalidBudgetCategory错误。

  40. 使用一个switch语句评估每个值。当它匹配时,返回相应的budgetCategory类型:

    func convertToBudgetCategory(value string) (budgetCategory, error) {
      value = strings.TrimSpace(strings.ToLower(value))
      switch value {
      case "fuel", "gas":
      return autoFuel, nil
      case "food":
      return food, nil
      case "mortgage":
      return mortgage, nil
      case "repairs":
      return repairs, nil
      case "car insurance", "life insurance":
      return insurance, nil
      case "utilities":
      return utilities, nil
      default:
      return "", ErrInvalidBudgetCategory
      }
    }
    
  41. 创建一个writeErrorToLog(msg string, err error, data string, logFile string) error函数。此函数将消息写入日志文件。

  42. 然后,它需要格式化有关错误的详细信息,包括msgerrordata

    func writeErrorToLog(msg string, err error, data string, logFile string) error {
      msg += "\n" + err.Error() + "\nData: " + data + "\n\n"
      f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
      if err != nil {
      return err
      }
      defer f.Close()
      if _, err := f.WriteString(msg); err != nil {
      return err
      }
      return nil
    }
    
  43. 运行程序:

    go run main.go -c bank.csv -l log.log
    

    这里是应用程序的一个可能的输出:

![图 12.24:活动输出img/B14177_12_24.jpg

图 12.24:活动输出

log.log文件的可能内容如下:

![图 12.25:log.log 内容img/B14177_12_25.jpg

图 12.25:log.log 内容

第十三章:SQL 和数据库

活动 13.1:在表中存储用户数据

解决方案:

  1. 使用适当的导入初始化你的脚本。让我们称它为main.go。准备一个空的main()函数:

    package main
    import "fmt"
    import "database/sql"
    import _ "github.com/lib/pq"
    func main(){
    }
    
  2. 让我们定义一个将保存用户的struct

    type Users struct {
        id int
        name string
        email string
    }
    
  3. 现在是创建两个用户的时候了:

    users := []Users{
      {1,"Szabo Daniel","daniel@packt.com"},
      {2,"Szabo Florian","florian@packt.com"},
    }
    
  4. 让我们打开到我们的Postgres服务器的连接:

    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. 我们应该使用Ping()函数来检查连接是否正常:

    connectivity := db.Ping()
    if connectivity != nil{
      panic(connectivity)
    }else{
      fmt.Println("Good to go!")
    }
    
  6. 现在我们可以为我们的表创建一个多行字符串:

    TableCreate := `
    CREATE TABLE users
    (
      ID integer NOT NULL,
      Name text COLLATE pg_catalog."default" NOT NULL,
      Email text COLLATE pg_catalog."default" NOT NULL,
      CONSTRAINT "Users_pkey" PRIMARY KEY (ID)
    )
    WITH (
      OIDS = FALSE
    )
    TABLESPACE pg_default;
    ALTER TABLE users
      OWNER to postgres;
    `
    
  7. 一旦字符串准备就绪,我们应该创建我们的表:

    _,err = db.Exec(TableCreate)
    if err != nil {
      panic(err)
    } else{
      fmt.Println("The table called Users was successfully created!")
    }
    
  8. 使用users结构,我们可以构造一个for循环来插入用户:

    insert, insertErr := db.Prepare("INSERT INTO users VALUES($1,$2,$3)")
    if insertErr != nil{
      panic(insertErr)
    }
    for _, u := range users{
      _, err = insert.Exec(u.id,u.name,u.email)
      if err != nil{
        panic(err)
      }else{
        fmt.Println("The user with name:",u.name,"and email:",u.email,"was   successfully added!")
      }
    }
    insert.Close()
    
  9. 现在有了数据库中的用户,我们可以更新相应的字段:

    update, updateErr := db.Prepare("UPDATE users SET Email=$1 WHERE ID=$2")
    if updateErr != nil{
      panic(updateErr)
    }
    _, err = update.Exec("user@packt.com",1)
    if err != nil{
      panic(err)
    } else{
      fmt.Println("The user's email address was successfully updated!")
    }
    update.Close()
    
  10. 最后的任务是删除ID=2user

    remove, removeErr := db.Prepare("DELETE FROM users WHERE ID=$1")
    if removeErr != nil{
      panic(removeErr)
    }
    _,err = remove.Exec(2)
    if err != nil{
      panic(err)
    }else{
      fmt.Println("The second user was successfully removed!")
    }
    remove.Close()
    
  11. 由于我们的工作已经完成,我们应该关闭到数据库的连接:

    db.Close()
    

成功完成后,你应该看到以下输出:

![图 13.10:可能的输出img/B14177_13_10.jpg

图 13.10:可能的输出

活动 13.2:查找特定用户的消息

解决方案:

  1. 使用适当的导入初始化您的脚本。让我们称它为main.go。准备一个空的main()函数:

    package main
    import "fmt"
    import "bufio"
    import "os"
    import "strings"
    import "database/sql"
    import _ "github.com/lib/pq"
    func main(){
    }
    
  2. 让我们定义一个struct,它将保存我们想要插入的消息:

    type Messages struct {
      UserID int
      Message string
    }
    
  3. 我们需要四个变量,这些变量将在以后使用:

    var toLookFor string
    var message string
    var email string
    var name string
    
  4. 创建一个reader函数,当需要时将从用户那里获取输入:

    reader := bufio.NewReader(os.Stdin)
    
  5. 现在,创建实际的消息:

    messages := []Messages{
      {1,"Hi Florian, when are you coming home?"},
      {1,"Can you send some cash?"},
      {2,"Hi can you bring some bread and milk?"},
      {7,"Well..."},
    }
    
  6. 连接到数据库:

    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!")
    }
    
  7. 检查数据库的连接性:

    connectivity := db.Ping()
    if connectivity != nil{
      panic(connectivity)
    }else{
      fmt.Println("Good to go!")
    }
    
  8. 如果连接正常,我们可以编写我们的表创建脚本:

    TableCreate := `
    CREATE TABLE public.messages
    (
        UserID integer NOT NULL,
        Message character varying(280) COLLATE pg_catalog."default" NOT NULL
    )
    WITH (
        OIDS = FALSE
    )
    TABLESPACE pg_default;
    ALTER TABLE public.messages
        OWNER to postgres;
    `
    
  9. 创建用于存储消息的表:

    _,err = db.Exec(TableCreate)
    if err != nil {
      panic(err)
    } else{
      fmt.Println("The table called Messages was successfully created!")
    }
    
  10. 一旦表就绪,插入消息:

    insertMessages, insertErr := db.Prepare("INSERT INTO messages   VALUES($1,$2)")
    if insertErr != nil{
      panic(insertErr)
    }
    for _, u := range messages{
      _, err = insertMessages.Exec(u.UserID,u.Message)
      if err != nil{
        panic(err)
      }else{
        fmt.Println("The UserID:",u.UserID,"with message:",u.Message,"was   successfully added!")
      }
    }
    insertMessages.Close()
    
  11. 现在您有了消息,您可以询问用户想要在过滤消息时查找的名称:

    fmt.Print("Give me the user's name: ")
    toLookFor, err = reader.ReadString('\n')
    toLookFor = strings.TrimRight(toLookFor, "\r\n")
    if err != nil{
      panic(err)
    } else {
      fmt.Println("Looking for all the messages of user with   name:",toLookFor,"##")
    }
    
  12. 以下查询将给出我们想要的结果:

    UserMessages := "SELECT users.Name, users.Email, messages.Message FROM   messages INNER JOIN users ON users.ID=messages.UserID WHERE users.Name     LIKE $1"
    
  13. 现在执行过滤查询并检查返回了多少条记录:

    usersMessages, err := db.Prepare(UserMessages)
    if err != nil {
      panic(err)
    }
    result, err := usersMessages.Query(toLookFor)
    numberof := 0
    for result.Next(){
      numberof++
    }
    
  14. 根据结果数量,打印适当的消息:

    if numberof == 0 {
       fmt.Println("The query returned nothing, no such user:",toLookFor)
    }else{
      fmt.Println("There are a total of",numberof,"messages from the     user:",toLookFor)
      result, err := usersMessages.Query(toLookFor)
      for result.Next(){
        err = result.Scan(&name, &email, &message)
        if err != nil{
        panic(err)
        }
        fmt.Println("The user:",name,"with email:",email,"has sent the following       message:",message)
      }
    }
    usersMessages.Close()
    
  15. 最后,关闭数据库连接:

    db.Close()
    

    这应该是输出,具体取决于您如何用用户名和消息填充数据库:

图 13.11:预期输出

图 13.11:预期输出

第十四章:使用 Go HTTP 客户端

活动 14.01:从 Web 服务器请求数据并处理响应

解决方案:

  1. 添加必要的导入:

    package main
    import (
        "encoding/json"
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
    )
    

    在这里,使用encoding/json解析响应并将其Marshalstruct中。使用fmt打印计数,使用io/ioutil读取响应正文。如果出现问题,使用log输出错误。net/http是我们用来进行 GET 请求的。

  2. 创建解析数据的struct

    type Names struct {
        Names []string `json:"names"`
    }
    
  3. 创建一个名为getDataAndParseResponse的函数,它返回两个整数:

    func getDataAndParseResponse() (int, int) {
    
  4. 向服务器发送GET请求:

        r, err := http.Get("http://localhost:8080")
        if err != nil {
            log.Fatal(err)
        }
    
  5. 解析响应数据:

        defer r.Body.Close()
        data, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Fatal(err)
        }
        names := Names{}
        err = json.Unmarshal(data, &names)
        if err != nil {
            log.Fatal(err)
        }
    
  6. 遍历名称并计算每个名称的出现次数:

        electricCount := 0
        boogalooCount := 0
        for _, name := range names.Names {
            if name == "Electric" {
                electricCount++
            } else if name == "Boogaloo" {
                boogalooCount++
            }
        }
    
  7. 返回计数:

        return electricCount, boogalooCount
    
  8. 打印计数:

    func main() {
        electricCount, boogalooCount := getDataAndParseResponse()
        fmt.Println("Electric Count: ", electricCount)
        fmt.Println("Boogaloo Count: ", boogalooCount)
    }
    
  9. 这是此活动的服务器代码:

server.go
12 func (srv server) ServeHTTP(w http.ResponseWriter, r      *http.Request) {
13     names := Names{}
14     // Generate random number of 'Electric' names
15     for i := 0; i < rand.Intn(5)+1; i++ {
16         names.Names = append(names.Names, "Electric")
17     }
18     // Generate random number of 'Boogaloo' names
19     for i := 0; i < rand.Intn(5)+1; i++ {
20         names.Names = append(names.Names, "Boogaloo")
21     }
22     // convert struct to bytes
23     jsonBytes, _ := json.Marshal(names)
24     log.Println(string(jsonBytes))
25     w.Write(jsonBytes)
26 }
The full code is available at: https://packt.live/2sfnWaR

将此代码添加到您创建的server.go文件中并运行它。这将创建一个您可以连接客户端的服务器。一旦创建,您应该能够运行它并看到与此类似的输出:

图 14.10:可能的输出

图 14.10:可能的输出

活动 14.02:使用 POST 和 GET 向 Web 服务器发送数据并检查数据是否已接收

解决方案:

  1. 添加所有必需的导入:

    package main
    import (
        "bytes"
        "encoding/json"
        "errors"
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
    )
    
  2. 创建发送请求和接收响应所需的struct

    var url = "http://localhost:8088"
    type Name struct {
        Name string `json:"name"`
    }
    type Names struct {
        Names []string `json:"names"`
    }
    type Resp struct {
        OK bool `json:"ok"`
    }
    
  3. 创建addNameAndParseResponse函数:

    func addNameAndParseResponse(nameToAdd string) error {
    
  4. 创建一个name结构体,将其Marshaljson,然后将其 POST 到 URL:

        name := Name{Name: nameToAdd}
        nameBytes, err := json.Marshal(name)
        if err != nil {
            return err
        }
        r, err := http.Post(fmt.Sprintf("%s/addName", url),      "text/json", bytes.NewReader(nameBytes))
        if err != nil {
            return err
        }
    
  5. 解析 POST 请求的响应:

        defer r.Body.Close()
        data, err := ioutil.ReadAll(r.Body)
        if err != nil {
            return err
        }
        resp := Resp{}
        err = json.Unmarshal(data, &resp)
        if err != nil {
            return err
        }
    
  6. 检查响应是否返回 OK:

        if !resp.OK {
            return errors.New("response not ok")
        }
        return nil
    
  7. 创建getDataAndParseResponse函数:

    func getDataAndParseResponse() []string {
    
  8. 向服务器发送 GET 请求并读取正文:

        r, err := http.Get(fmt.Sprintf("%s/", url))
        if err != nil {
            log.Fatal(err)
        }
        // get data from the response body
        defer r.Body.Close()
        data, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Fatal(err)
        }
    
  9. 将响应解包到Names结构体中并返回names数组:

        names := Names{}
        err = json.Unmarshal(data, &names)
        if err != nil {
            log.Fatal(err)
        }
        // return the data
        return names.Names
    
  10. 创建一个main函数以添加名称,从服务器请求名称并打印它们:

    func main() {
        err := addNameAndParseResponse("Electric")
        if err != nil {
            log.Fatal(err)
        }
        err = addNameAndParseResponse("Boogaloo")
        if err != nil {
            log.Fatal(err)
        }
        names := getDataAndParseResponse()
        for _, name := range names {
            log.Println(name)
        }
    }
    

    服务器代码如下:

server.go
1 package main
2 import (
3     "encoding/json"
4     "log"
5     "net/http"
6 )
7 var names []string
8 type Name struct {
9     Name string `json:"name"`
10 }
11 type Names struct {
12     Names []string `json:"names"`
13 }
14 type Resp struct {
15     OK bool `json:"ok"`
The full code for this step is available at: https://packt.live/2Qg5dE8

启动服务器并运行您的客户端。客户端的输出应类似于以下内容:

![图 14.11:可能的输出图 15.40:运行服务器第二次时的浏览器输出

图 14.11:可能的输出

第十五章:HTTP 服务器

活动十五.01:向 HTML 页面添加页面计数器

解决方案:

  1. 首先,我们导入必要的包:

    package main
    import (
    "fmt"
    "log"
    "net/http"
    )
    
  2. 在这里,"net/http"是 http 通信的常用包,"log"是用于记录的包(在这种情况下是输出到标准输出),而"fmt"是用于格式化输入和输出的包。这可以用来向标准输出发送消息,但我们在这里只是将其用作消息格式化器。

  3. 我们在这里定义了一个名为PageWithCounter的类型,它代表我们的处理器,可以计数访问次数,并为页面提供标题和一些内容。每当页面加载时,计数器都会增加:

    type PageWithCounter struct{
    counter int
    heading string
    content string
    }
    func(h *PageWithCounter) ServeHTTP(w http.ResponseWriter, r *http.Request)   {
    

    这是任何实现http.Handler接口的结构的标准处理器函数。首先注意方法接收器中的*。在这个方法中,我们想要修改一个结构的属性以增加计数器。为了做到这一点,我们需要指定我们的方法是通过指针接收的,这样我们就可以永久地修改计数器。如果没有指针接收器,我们总是会看到页面上的1(你可以尝试修改它并亲自看看)。

  4. 接下来,增加计数器,然后我们使用一些 HTML 标签格式化一个字符串。fmt.Sprintf函数将右边的变量注入到占位符%s%d所在的位置。第一个占位符期望一个字符串,而第二个期望一个数字。之后,我们像往常一样,将整个字符串作为一个字节数组写入响应中:

       h.counter++
       msg := fmt.Sprintf("<h1>%s</h1>\n<p>%s<p>\n<p>Views: %d</p>", h.heading,   h.content, h.counter)
       w.Write([]byte(msg))
    }
    
  5. 在这里,我们创建main()函数,并设置三个处理器,一个带有标题hello world和一些内容,而其他两个处理器代表你的书的头两章,因此相应地实例化。请注意,计数器没有明确设置,因为任何整数都将默认为0,这是我们的计数器开始的地方:

    func main() {
      hello := PageWithCounter{heading: "Hello World",content:"This is the main   page"}
      cha1 := PageWithCounter{heading: "Chapter 1",content:"This is the first   chapter"}
      cha2 := PageWithCounter{heading: "Chapter 2",content:"This is the second   chapter"}
    
  6. 将三个处理器添加到路由中,//chapter1/chapter2,设置它们使用之前创建的处理器。请注意,我们需要使用&传递引用,因为ServeHTTP方法有一个指针接收器:

    http.Handle("/", &hello)
    http.Handle("/chapter1", &cha1)
    http.Handle("/chapter2", &cha2)
    
  7. 现在,完成监听端口的代码:

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

当你运行服务器时,你应该看到以下内容:

![图 15.39:运行服务器第一次时的浏览器输出图 15.41:首次访问 chapter1 页面时的浏览器输出

图 15.39:运行服务器第一次时的浏览器输出

如果你刷新页面,你应该看到以下内容:

![图 15.40:运行服务器第二次时的浏览器输出图 15.41:首次访问 chapter1 页面时的浏览器输出

图 15.40:运行服务器第二次时的浏览器输出

接下来,通过在地址栏中输入localhost:8080/chapter1来导航到chapter 1。你应该能看到以下内容:

![图 15.41:首次访问 chapter1 页面时的浏览器输出图 15.41:运行服务器第二次时的浏览器输出

图 15.41:首次访问 chapter1 页面时的浏览器输出

类似地,导航到 第二章,你应该能看到以下关于查看次数的增加:

图 15.42:当你第一次访问第二章页面时的浏览器输出

图 15.42:当你第一次访问第二章页面时的浏览器输出

当你再次访问 第一章 时,你应该看到查看次数的增加如下:

图 15.43:当你第二次访问第一章页面时的浏览器输出

图 15.43:当你第二次访问第一章页面时的浏览器输出

活动 15.02:使用 JSON 有效负载服务请求

解决方案:

虽然你的浏览器可能以不同的方式显示 JSON 文档,但此活动的完整解决方案如下:

  1. 我们创建一个包并添加必要的导入,其中 "encoding/json" 是用于将文档格式化为 JSON 字符串的:

    package main
    import (
       "encoding/json"
       "log"
       "net/http"
    )
    
  2. 我们创建了一个 PageWithCounter 结构体,它看起来与 活动 1 中的完全一样。然而,需要添加一些 JSON 标签。这些标签确保在将结构体转换为 JSON 字符串时,属性采用特定的名称。Content 将变为 content,但 Heading 将变为 title,而 Counter 将变为 views。请注意,所有属性现在都是大写的。正如你所知,大写属性使它们成为导出的,这意味着其他任何包都可以看到并使用它们:

    type PageWithCounter struct{
       Counter int `json:"views"`
       Heading string `json:"title"`
       Content string `json:"content"`
    }
    
  3. 我们创建了一个常用的处理方法来服务页面:

    func(h *PageWithCounter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    
  4. 我们增加计数器:

       h.Counter++
    
  5. 现在,我们通过 json.Marshal 方法将结构体本身 h 序列化为 JSON,该方法返回表示 JSON 文档的字节数组和一个错误。在这里,导出属性很重要。如果没有这些属性,序列化函数将无法看到属性并无法转换它们,从而导致表示空文档的 JSON 字符串:

       bts, err := json.Marshal(h)
    
  6. 我们检查是否有错误,如果有,我们将代码 400 写入响应头。这意味着在序列化错误的情况下,你将看不到实际的页面,而是一个错误消息:

       if err!=nil {
      w.WriteHeader(400)
      return
       }
    
  7. 最后,如果没有错误,我们将 JSON 编码的结构体写入响应写入器:

       w.Write([]byte(bts))
    }
    
  8. 代码的其余部分几乎与 活动 15.01 中的相同,唯一的区别在于 PageWithCounter 结构体实例化时具有大写属性,因为它们现在都是导出的:

    func main() {
       hello :=PageWithCounter{Heading: "Hello World",Content:"This is the main   page"}
       cha1 := PageWithCounter{Heading: "Chapter 1",Content:"This is the first   chapter"}
       cha2 := PageWithCounter{Heading: "Chapter 2",Content:"This is the second   chapter"}
       http.Handle("/", &hello)
       http.Handle("/chapter1", &cha1)
       http.Handle("/chapter2", &cha2)
       log.Fatal(http.ListenAndServe(":8080", nil))
    }
    

    运行你的服务器,你应该能看到以下分配路由的输出:

图 15.44:当处理程序为 / 时的预期输出

图 15.45:当处理程序为 /chapter1 时的预期输出

图 15.46:当处理程序为 /chapter2

图 15.46:当处理程序为 /chapter2 时的预期输出

活动 15.03:外部模板

解决方案:

  1. 创建一个名为 index.html 的 HTML 文件:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Welcome</title>
    </head>
    <body>
    
  2. 在主体中,现在添加模板标签以用于标题:

      <h1>Hello {{if .Name}}{{.Name}}{{else}}visitor{{end}}</h1>
    

    你可以看到有一个if语句,如果Name属性不为空,则显示Name属性,否则显示visitor字符串。

  3. 现在,完成 HTML 文件,添加欢迎信息和结束标签:

      <p>May I give you a warm welcome</p>
    </body>
    </html>
    
  4. 现在,创建一个main.go文件并开始添加包和导入:

    package main
    import (
       "html/template"
       "log"
       "net/http"
       "strings"
    )
    
  5. 现在,创建Visitor结构体,这是一个用作模板模型的结构体。它只包含Name字段,因为这是我们唯一关心的东西。注意,到目前为止,我们一直在使用结构体,因为它们更安全,但你可以直接传递一个map[string]string到你的模板中,它也会工作。然而,结构体允许我们执行更好的清理。写下以下内容:

    type Visitor struct {
       Name string
    }
    
  6. 现在,创建一个处理程序。这是一个只包含模板指针的结构体:

    type Hello struct {
       tpl *template.Template
    }
    
  7. 现在需要实现处理程序接口:

    func (h Hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    
  8. 现在,我们需要从查询字符串中获取请求,所以写下以下内容:

       vl := r.URL.Query()
    
  9. 现在,我们需要为这个请求创建一个访客,所以执行以下命令:

       cust := Visitor{}
       name, ok := vl["name"]
    
  10. 如果名称存在,则将内容合并成一个字符串,以防我们有多重名称:

       if ok {
      cust.Name = strings.Join(name, ",")
       }
    
  11. 现在,执行模板以获取完整页面并将其传递给响应写入器以提供文件模板:

       h.tpl.Execute(w, cust)
    }
    
  12. 现在,创建一个函数来实例化一个新的页面,使用特定的模板文件,返回一个Hello模板指针:

    // NewHello returns a new Hello handler
    func NewHello(tplPath string) (*Hello, error){
    
  13. 解析模板并将其分配给变量:

       tmpl, err := template.ParseFiles(tplPath)
       if err != nil {
      return nil, err
       }
    
  14. 返回设置有模板文件的Hello模板:

       return &Hello{tmpl}, nil
    }
    
  15. 创建main()函数以运行:

    func main() {
    
  16. 现在,使用NewHello函数为index.html模板创建一个页面:

       hello, err := NewHello("./index.html")
       if err != nil {
      log.Fatal(err)
       }
    
  17. 使用实例化的模板处理基本路径:

       http.Handle("/", hello)
    
  18. 在你喜欢的端口上运行服务器,并在发生错误时退出:

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

    输出将如下所示:

![图 15.47:匿名访客页面img/B14177_15_47.jpg

图 15.47:匿名访客页面

包含名称的访客页面看起来可能如下截图所示:

![图 15.48:名为“Will”的访客页面img/B14177_15_48.jpg

图 15.48:名为“Will”的访客页面

第十六章:并发工作

活动 16.01:列出数字

解决方案:

  1. 创建main.go文件并导入必要的包:

    package main
    import (
        "fmt"
        "log"
        "sync"
    )
    
  2. 定义一个名为sum()的函数,它将使用指向字符串的指针来保存结果:

    func sum(from,to int, wg *sync.WaitGroup, res *string, mtx *sync.Mutex) {
        for i:=from;i<=to; i++ {
            mtx.Lock()
            *res = fmt.Sprintf("%s|%d|",*res, i)
            mtx.Unlock()
        }
        wg.Done()
        return
    }
    
  3. 创建main()函数以执行求和操作:

    func main() {
        s1 := ""
        mtx := &sync.Mutex{}
        wg := &sync.WaitGroup{}
        wg.Add(4)
        go sum(1,25, wg,&s1, mtx)
        go sum(26,50, wg, &s1, mtx)
        go sum(51,75, wg, &s1, mtx)
        go sum(76,100, wg, &s1, mtx)
        wg.Wait()
        log.Println(s1)
    }
    

    让我们分步骤分析代码:

  4. 让我们看看sum()函数:

    func sum(from,to int, wg *sync.WaitGroup, res *string, mtx *sync.Mutex) {
        for i:=from;i<=to; i++ {
    

    在这里,我们创建一个函数,其签名包含一个范围,fromto int,然后是一个 WaitGroup,一个将要用来修改共享字符串值的字符串指针,最后是一个指向互斥锁的指针,用于同步对字符串的工作。之后,我们在定义的范围内创建一个循环。

  5. 下一步是:

            mtx.Lock()
            *res = fmt.Sprintf("%s|%d|",*res, i)
            mtx.Unlock()
        }
    

    在这里,我们锁定执行并添加当前i的当前值作为字符串在s的当前值之后。然后,我们解锁进程并结束循环。

  6. 在这一点上,我们告诉 WaitGroup 例程已完成其计算,并且在这里终止:

        wg.Done()
        return
    }
    
  7. 接下来,我们定义main()函数:

    func main() {
        s1 := ""
        mtx := &sync.Mutex{}
        wg := &sync.WaitGroup{}
    

    我们将起始字符串设置为 "",然后实例化一个 mutex 和一个 WaitGroup。然后代码与你在之前的练习中看到的是类似的,即运行四个 Goroutines 并记录结果。

  8. 当你运行你的程序时,你应该看到类似这样的输出:![图 16.5:列出数字时的第一次输出 图片

    图 16.5:列出数字时的第一次输出

  9. 然而,如果你再次多次运行它,你很可能会看到不同的结果。这是由于并发性,因为机器执行顺序的不确定性:

![图 16.6:列出数字的第二次尝试以不同的顺序返回图片

图 16.6:列出数字的第二次尝试以不同的顺序返回

活动 16.02:源文件

解决方案:

  1. 使用以下导入创建 main 包:

    package main
    import (
        "bufio"
        "fmt"
        "os"
        "strconv"
        "strings"
        "sync"
    )
    
  2. 创建 source() 函数从文件中读取数字并将它们发送到通道:

    func source(filename string, out chan int, wg *sync.WaitGroup)  {
        f, err :=  os.Open(filename)
        if err != nil {
            panic(err)
        }
        rd := bufio.NewReader(f)
        for {
            str, err := rd.ReadString('\n')
            if err != nil {
                if err.Error() == "EOF" {
                    wg.Done()
                    return
                } else {
                    panic(err)
                }
            }
            iStr := strings.ReplaceAll(str, "\n", "")
            i, err := strconv.Atoi(iStr)
            if err != nil {
                panic(err)
            }
            out <- i
        }
    }
    
  3. 现在创建一个 splitter() 函数来接收数字,然后将它们发送到两个不同的通道,一个用于 odd(奇数)数字,另一个用于 even(偶数)数字:

    func splitter(in, odd, even chan int, wg *sync.WaitGroup)  {
        for i := range in {
            switch i%2 {
            case 0:
                even <- i
            case 1:
                odd <- i
            }
        }
        close(even)
        close(odd)
        wg.Done()
    )
    
  4. 现在编写一个函数来计算传入的数字的总和,并将 sum 发送到一个输出通道:

    func sum(in, out chan int, wg *sync.WaitGroup) {
        sum := 0
        for i := range in {
            sum += i
        }
        out <- sum
        wg.Done()
    }
    
  5. 现在创建一个 merger() 函数,该函数将输出偶数和奇数的总和:

    func merger(even, odd chan int, wg *sync.WaitGroup, resultFile string) {
        rs, err := os.Create(resultFile)
        if err != nil {
            panic(err)
        }
        for i:= 0; i< 2; i++{
            select {
            case i:= <- even:
                rs.Write([]byte(fmt.Sprintf("Even %d\n", i)))
            case i:= <- odd:
                rs.Write([]byte(fmt.Sprintf("Odd %d\n", i)))
            }
        }
        wg.Done()
    }
    
  6. 现在创建一个 main() 函数,在其中初始化所有通道并调用你之前创建的所有函数,以生成 sum

    func main() {
        wg := &sync.WaitGroup{}
        wg.Add(2)
        wg2 := &sync.WaitGroup{}
        wg2.Add(4)
        odd := make(chan int)
        even := make(chan int)
        out := make(chan int)
        sumodd := make(chan int)
        sumeven := make(chan int)
        go source("./input1.dat", out, wg)
        go source("./input2.dat", out, wg)
        go splitter(out, odd, even, wg2)
        go sum(even, sumeven, wg2)
        go sum(odd, sumodd,wg2)
        go merger(sumeven, sumodd, wg2, "./result.txt")
        wg.Wait()
        close(out)
        wg2.Wait()
    }
    

    让我们更详细地分析一下代码。

  7. source 函数中,我们有一个用于打开输入文件的文件名,一个用于管道消息的通道,以及一个用于通知过程结束的 WaitGroup。此函数将以两个 Goroutines 运行,每个输入文件一个。在这个函数内部,我们逐行读取文件。你应该已经学过如何从文件中读取,并且有几种优化的方法可以做到这一点。这里,我们只是逐行读取,使用 '\n' 作为分隔符:

    rd := bufio.NewReader(f)
        for {
            str, err := rd.ReadString('\n')
    

    因此,我们在文件 f 上创建了一个缓冲读取器,然后使用带有换行符 '\n' 作为分隔符的 ReadString 函数进行循环。请注意,它必须使用单引号,而不是 "\n",因为分隔符是一个字符,而不是一个字符串。

  8. 之后,我们处理错误,如果发生文件结束错误 (EOF),我们只需终止函数。注意,如果我们不这样做,代码就会崩溃:

    if err.Error() == "EOF" {
                    wg.Done()
                    return
                }
    
  9. 我们还需要去除行尾,这样我们只剩下一个数字:

    iStr := strings.ReplaceAll(str, "\n", "")
            i, err := strconv.Atoi(iStr)
            if err != nil {
                panic(err)
            }
            out <- i
        }
    }
    

    在这里,我们将字符串的最后部分 "\n" 替换为一个空字符串。之后,我们将文本转换为整数,如果它不是一个整数,我们再次陷入恐慌。最后,我们只输出数字并完成函数。

  10. 下一步是创建一个拆分函数:

    func splitter(in, odd, even chan int, wg *sync.WaitGroup)  {
    
  11. 此函数有一个通道从源获取数字,两个通道用于将数字管道传输到;一个用于偶数,一个用于奇数。再次使用 Waitgroup 来通知主程序完成。此函数的目的是拆分数字,这样我们就可以遍历通道:

        for i := range in {
    
  12. 在 for 循环中,我们可以使用 switch 来识别奇数和偶数:

            switch i%2 {
            case 0:
                even <- i
            case 1:
                odd <- i
            }
        }
    

    此代码根据除以 2 的余数来分割数字。如果余数为 0,则该数字是偶数,并将其通过偶数通道传输,否则为奇数。

  13. 我们关闭通道以通知下一个过程:

        close(even)
        close(odd)
        wg.Done()
    }
    
  14. 我们现在有了分拆器,但我们需要对传输进来的消息进行求和,这通过一个与之前练习中看到类似的函数来完成:

    func sum(in, out chan int, wg *sync.WaitGroup) {
        sum := 0
        for i := range in {
            sum += i
        }
        out <- sum
        wg.Done()
    }
    
  15. 到目前为止,我们需要合并所有结果,因此我们使用一个合并器:

    func merger(even, odd chan int, wg *sync.WaitGroup, resultFile string) {
    

    此函数包含偶数和奇数的两个通道,一个 Waitgroup 来处理完成,以及一个结果文件的名称。

  16. 然后,我们开始创建 results.txt 文件:

        rs, err := os.Create(resultFile)
        if err != nil {
            panic(err)
        }
    
  17. 我们遍历奇数和偶数的两个通道:

        for i:= 0; i< 2; i++{
    
  18. 然后我们编写代码来根据数字类型选择通道:

            select {
            case i:= <- even:
                rs.Write([]byte(fmt.Sprintf("Even %d\n", i)))
            case i:= <- odd:
                rs.Write([]byte(fmt.Sprintf("Odd %d\n", i)))
            }
        }
        wg.Done()
    }
    

    使用 Write 方法将内容写入文件,该方法需要字节。这样,我们将包含添加的数字类型(oddeven)及其总和的字符串转换为字节。

  19. 我们现在在主函数中编排所有内容:

    func main() {
        wg := &sync.WaitGroup{}
        wg.Add(2)
        wg2 := &sync.WaitGroup{}
        wg2.Add(4)
    
  20. 我们在这里使用了两个 Waitgroup;一个用于源,一个用于其他例程。你很快就会看到原因。

  21. 接下来,我们创建所有需要的通道:

        odd := make(chan int)
        even := make(chan int)
        out := make(chan int)
        sumodd := make(chan int)
        sumeven := make(chan int)
    

    out 是源函数用来将消息传输到分拆器的通道,oddeven 是将数字传输以进行求和的通道,最后两个通道包含单个数字及其总和。

  22. 然后我们启动所有需要的例程:

        go source("./input1.dat", out, wg)
        go source("./input2.dat", out, wg)
        go splitter(out, odd, even, wg2)
        go sum(even, sumeven, wg2)
        go sum(odd, sumodd,wg2)
        go merger(sumeven, sumodd, wg2, "./result.txt")
    
  23. 然后我们等待例程完成:

        wg.Wait()
        close(out)
    

    请注意,在这里,我们可以使用超过两个文件。甚至可以使用任意数量的文件。因此,分拆器无法知道如何终止执行,所以我们在源完成将数字传输到通道后关闭通道。

  24. 之后,我们为剩余的例程添加第二个 Waitgroup。本质上,我们需要保持所有例程运行,直到最后一个求和被添加:

        wg2.Wait()
    }
    
  25. 虽然你可以用作输入的文件可能不同,但请使用以下两个文件来测试输出。

    input1.dat

    1
    2
    5
    

    input2.dat

    3
    4
    6
    

    注意每个文件末尾的换行符。

  26. 现在你已经创建了输入文件,运行以下命令:

    go run main.go
    

    你应该会看到一个名为 results.txt 的文件,其内容如下。

    Odd 9
    Even 12
    

第十七章:使用 Go 工具

活动 17.01:使用 gofmt、goimport、go vet 和 go get 来修正文件

解决方案:

  1. 对文件运行 gofmt 以检查任何格式问题并确保它们有意义:

    gofmt main.go
    

    这应该输出一个看起来更整洁的文件,如下所示:

    ![图 17.11:gofmt 的预期输出 ![图 17.11:gofmt 的预期输出 图 17.11:gofmt 的预期输出 1. 使用 gofmt-w 选项来更改文件并保存更改: go gofmt -w main.go 1. 使用 goimports 检查导入是否正确: go goimport main.go 1. 使用 goimports 来修复文件中的导入语句: go goimports -w main.go 1. 最后的阶段是使用 go vet 来检查编译器可能遗漏的任何问题。运行它对 main.go 进行检查: go go vet main.go 1. 它将发现一个不可达代码的问题,如下面的输出所示:图 17.12:go vet 的预期输出

    图 17.12:go vet 的预期输出

    图 17.12:go vet 的预期输出

  2. 通过将 log.Println("completed") 行移动到 return 语句之前来纠正问题:

    func ExampleHandler(w http.ResponseWriter, r *http.Request) {
      w.WriteHeader(http.StatusOK)
      fmt.Fprintf(w, "Hello Packt")
      log.Println("completed")
      return
    }
    
  3. 你应该确保通过运行 go get 命令下载了第三方包:

    go get github.com/gorilla/mux
    
  4. 这将启动 web 服务器:图 17.13:运行代码时的预期输出

    图 17.13:通过 Firefox 访问 web 服务器时的预期输出

    图 17.13:运行代码时的预期输出

  5. 你可以通过在浏览器中访问 http://localhost:8888 来检查它是否工作:

图 17.14:通过 Firefox 访问 web 服务器时的预期输出

图 17.14:预期输出

图 18.13:通过 Firefox 访问 web 服务器时的预期输出

第十八章:安全

活动 18.01:使用散列密码在应用程序中验证用户

解决方案

  1. 创建一个 main.go 文件,并导入以下包:

    crypto/sha512:此包将提供加密密码所需的散列功能。

    database/sql:将使用此包创建用于存储用户详情的数据库。

    github.com/mattn/go-sqlite3:这是一个用于创建 sqlite 实例进行测试的第三方库。

    package main
    import (
      "crypto/sha512"
      "database/sql"
      "fmt"
      "os"
      _ "github.com/mattn/go-sqlite3"
    )
    
  2. 定义一个名为 getConnection() 的函数来初始化数据库连接:

    func getConnection() (*sql.DB, error) {
      conn, err := sql.Open("sqlite3", "test.DB")
      if err != nil {
        return nil, fmt.Errorf("could not open db connection %v", err)
      }
      return conn, nil
    }
    
  3. 定义辅助函数来设置和拆除数据库:

    main.go
    13 var testData = []*UserDetails{
    14   {
    15     Id:       "1",
    16     Password: "1234",
    17   },
    18   {
    19     Id:       "2",
    20     Password: "5678",
    21   },
    22 }
    23 func initializeDB(db *sql.DB) error {
    24   _, err := db.Exec(`CREATE TABLE IF NOT EXISTS USER_DETAILS (USER_ID TEXT,     PASSWORD TEXT)`)
    25   if err != nil {
    26     return err
    27   }
    The full code for this step is available at: https://packt.live/2sUYVlg
    
  4. 定义一个名为 GetPassword() 的函数,用于从数据库中检索用户密码:

    func GetPassword(db *sql.DB, userID string) (resp []byte, err error) {
      query := `SELECT PASSWORD FROM USER_DETAILS WHERE USER_ID = ?`
      row := db.QueryRow(query, userID)
      switch err = row.Scan(&resp); err {
      case sql.ErrNoRows:
        return resp, fmt.Errorf("no rows returned")
      case nil:
        return resp, err
      default:
        return resp, err
      }
    }
    
  5. 定义一个名为 UpdatePassword() 的函数,用于使用散列密码在数据库中更新用户密码:

    main.go
    55 func UpdatePassword(db *sql.DB, Id string, Password string) error {
    56   query := `UPDATE USER_DETAILS SET PASSWORD=? WHERE USER_ID=?`
    57   cipher := sha512.Sum512([]byte(Password))
    58   fmt.Printf("storing encrypted password:\n%x\n", string(cipher[:]))
    59   result, err := db.Exec(query, string(cipher[:]), Id)
    60   if err != nil {
    61     return err
    62   }
    63   rows, err := result.RowsAffected()
    64   if err != nil {
    65     return err
    66   }
    The full code for this step is available at: https://packt.live/35QwJi8
    
  6. 编写 main() 函数。在 main() 函数中,你应该设置数据库连接并使用一些测试数据初始化数据库。应调用 UpdatePassword() 函数将用户密码更新为散列密码。应调用 GetPassword() 函数来验证散列密码:

    main.go
    87 func main() {
    88   db, err := getConnection()
    89   if err != nil {
    90     fmt.Println(err)
    91     os.Exit(1)
    92   }
    93   err = initializeDB(db)
    94   if err != nil {
    95     fmt.Println(err)
    96     os.Exit(1)
    97   }
    98   defer tearDownDB(db)
    99   err = UpdatePassword(db, "1", "NewPassword")
    The full code for this step is available at: https://packt.live/2PVxWPH
    
  7. 使用以下命令运行程序:

    go run -v main.go
    

    你应该得到以下输出。

图 18.13:预期输出

图 18.13:预期输出

图 18.13:预期输出

在这个活动中,我们实现了使用散列存储和验证用户密码的实际情况。如果数据库详细信息泄露,散列密码本身对攻击者将没有用处。

活动 18.02:使用 Crypto 库创建 CA 签名的证书

解决方案

  1. 创建一个名为 main.go 的文件,并导入以下包:

    这里将使用 crypto 包来生成和验证 x509 证书:

    package main
    import (
      "crypto"
      "crypto/ecdsa"
      "crypto/elliptic"
      "crypto/rand"
      "crypto/x509"
      "crypto/x509/pkix"
      "fmt"
      "math/big"
      "os"
      "time"
    )
    
  2. 创建一个名为 generateCert() 的函数,该函数返回一个 x509 证书及其私钥:

    main.go
    44 func generateCert(cn string, caCert *x509.Certificate, caPriv      crypto.PrivateKey) (cert *x509.Certificate, privateKey        crypto.PrivateKey, err error) {
    45   serialNumber, err := rand.Int(rand.Reader, big.NewInt(27))
    46   if err != nil {
    47     return cert, privateKey, err
    48   }
    49   var isCA bool
    50   if caCert == nil {
    51     isCA = true
    52   }
    53   template := &x509.Certificate{
    54     SerialNumber:          serialNumber,
    The full code for this step is available at: https://packt.live/39a2R24
    
  3. 创建一个名为 main() 的函数来调用 generateCert() 函数。这将从一个根证书生成一个根证书和一个叶证书。验证叶证书:

    main.go
    14 func main() {
    15   // Generate CA certificates
    16   caCert, caPriv, err := generateCert("CA cert", nil, nil)
    17   if err != nil {
    18     fmt.Printf("error generating server certificate: %v", err)
    19     os.Exit(1)
    20   } else {
    21     fmt.Println("ca certificate generated successfully")
    22    }
    23   // User CA cert to generate and sign server certificate
    24   cert, _, err := generateCert("Test Cert", caCert, caPriv)
    The full code for this step is available at: https://packt.live/398aM04
    
  4. 使用以下命令运行 main.go 来测试代码:

    go run main.go
    

    输出应该如下所示:

    gobin:activity2 Gobin$ go run main.go
    ca certificate generated successfully 
    leaf certificate generated successfully
    leaf certificate successfully verified
    

在这个活动中,我们生成了 x509 公钥证书。我们还看到了如何使用根证书生成叶证书。当你尝试实现自己的 PKI 服务器时,这可能会很有用。

第十九章:特殊功能

活动 19.01:使用文件名定义构建约束

解决方案

  1. 创建一个名为 custom 的目录。

  2. 在此目录下创建一个名为 print_darwin.go 的文件。

  3. 定义一个名为 Print() 的函数:

    package custom
    import "fmt"
    func Print() {
      fmt.Println("Hello I am running on a darwin machine.")
    }
    
  4. custom 目录内创建另一个名为 print_386.go 的文件。

  5. 在此包内定义一个名为 Print() 的函数:

    import "fmt"
    func Print() {
      fmt.Println("Hello I am running on 386 machine.")
    }
    
  6. 使用以下命令运行程序:

    go run main.go
    

    你应该看到以下输出:

    $ go run main.go
    Hello I am running on a darwin machine.
    

活动 19.02:使用 Go Test 的通配符

解决方案

  1. 创建一个名为 package1 的目录:![图 19.5:目录结构 img/B14177_19_05.jpg

    图 19.5:目录结构

  2. 在此目录下创建一个名为 run_test.go 的文件,并定义以下测试用例:

    package package1
    import "testing"
    func TestPackage1(t *testing.T){
      t.Log("running TestPackage1")
    }
    
  3. 在父目录中,创建另一个名为 package2 的目录:![图 19.6:目录结构 img/B14177_19_06.jpg

    图 19.6:目录结构

  4. 在此目录下创建一个名为 run_test.go 的文件,并包含以下内容:

    package package2
    import "testing"
    func TestPackage2(t *testing.T){
      t.Log("running TestPackage2")
    }
    
  5. 使用以下命令从父目录运行所有测试用例:

    go test -v ./...
    

    你应该得到以下输出:

图 19.7:使用通配符模式的递归测试

图 19.7:使用通配符模式的递归测试

posted @ 2025-09-06 13:44  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报