Go 语言 DevOps(一)

原文:annas-archive.org/md5/3bb23876803d0893c1924ba12cfd8f56

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当你年纪渐长,我觉得大多数人都会反思自己的生活。他们是如何走到今天的,在哪些地方成功,在哪些地方失败。坦率地说,我可以说我在职业生涯中有过失败。我知道从失败开始一本书是很不寻常的,但我想,为什么要开始一本充满关于我超乎想象成功的谎言的书呢?

我的理想更像是吉米·巴菲特,而不是沃伦·巴菲特。保持对任何事情的兴趣超过几年对我来说是一种挑战,而我认为辛勤工作的表现就是在夏威夷的海滩上喝着皮纳科拉达。唉,我的梦想没有实现。我离这个梦想最近的一次,是为一个总是穿着夏威夷衬衫的老板工作,我觉得这算不上。

这一切“自动化专业知识”都源于我希望尽可能少做工作的需求。当我还是桌面支持技术员时,我需要找到一种方法,在几个小时内完成大量机器的构建,而不是手动安装 Windows 和应用程序。我想把我的时间花在办公室里玩视频游戏、读书,或者四处走走与人交谈。当我做网络工程师时,我希望人们在我舒适地睡在校园交换机机房里的时候,停止给我发送页面。于是,我写了一些工具,让其他人能够切换 VLAN 端口或清除网络端口的安全参数,而无需打电话给我。为什么每周都手动平衡 BGP 流量,反正我可以写一个程序,利用 SFLOW 数据来完成这项工作?

一切进展得很顺利,直到我变得有些雄心壮志,去了 Google。我写了几个工具,帮助自己让工作变得更轻松,比如弄清楚是否是因为正在进行的计划工作或程序导致的值班页面,或者是为数据中心的所有负载均衡器提供配置的程序。那时候,Google 有很多按摩椅和其他设施,我宁愿利用这些,而不是在与亚特兰大超负荷工作的硬件运维技术员通话的同时,在 IRC 频道里敲打为什么我的网络排水管仍然存在。

然而,随后人们开始想要使用我的工具。我的朋友 Adel 会问我,是否能做一些工具来编程设施路由器,或者验证 Force10 路由器是否设置正确。而且他是一个非常好的人,你根本无法拒绝。或者 Kirk 会过来问我们如何自动化边缘路由器的启用,因为他的团队已经超负荷工作了。结果,我没有让我的工作变得更轻松,反而花了更多时间帮别人简化工作!

希望我的失败能帮助你成功(我父亲曾经说过,没有人是完全没用的;他们总可以作为坏榜样)。

本书充满了我在职业生涯中使用过的许多方法论,并且有我认为目前最适合 DevOps 的语言——Go。

David(我的合著者,他稍后会自我介绍)和我来自 DevOps 世界的两个极端。我来自一个几乎不使用商业或标准开源软件的思潮。所有 DevOps 工具都是内部开发的,并且根据特定环境量身定制。David 则来自一个你尽可能多使用开源软件的思潮,比如 Kubernetes、GitHub、Docker、Terraform 等等……这样,你可以利用一系列可用且流行的工具,这些工具可能不是完全符合你需求的,但它们有支持网络和众多选项。雇佣已经熟悉行业标准工具的工程师比雇佣使用自定义工具集的工程师更容易。在这本书中,你将会看到这两种思想和方法的结合。我们认为,现成工具和自定义工具的混合能够为你带来最大的性价比。

我们真诚的希望这本书不仅能为你提供使用 Go 进行 DevOps 所需的指南,还能让你具备编写自己的工具或修改现有工具的能力,利用 Go 的强大功能来扩展任何公司的运营需求。如果没有其他收获,David 和我都会把我们的收入捐赠给“无国界医生”,所以如果你购买了这本书,哪怕没有其他收获,你也将帮助一个很有意义的事业。

但也许有一天你会坐在海滩上,收着工资单,而你的自动化流程正处理日常事务。我会继续为这个目标努力,所以如果你先达成了目标,替我喝一杯。

话虽如此,我想向大家介绍我的尊敬的合著者,David Justice。

正如 John 所提到的,我们来自不同的背景,但我们发现自己在处理相似的问题领域。我的背景是软件开发和软件工程,涉及从移动应用开发、网页开发、数据库优化,到机器学习和分布式系统等各个方面。我的重点从来都不是 DevOps。我可以算是一个偶然的 DevOps 从业者。我的 DevOps 技能是为了提供不断增长的商业价值的必要性所驱动的,这要求我自动化所有与交付新功能和修复缺陷无关的工作。发展 DevOps 技能的另一个动机是我希望能够持续部署代码并安稳地过夜。没有什么能像经营一家初创公司并且是唯一一个需要在凌晨三点解决高优先级问题的人那样,鼓励你去建立弹性系统和自动化流程。

我在这里描述的动机应该为我倾向于选择那些能够迅速应用且在开源社区中有大量支持的解决方案提供依据。如果我能找到一个拥有优质文档的开源解决方案,能够很好地完成我大部分的需求,那么我可以在需要时将其余部分拼接起来(如果深入挖掘,几乎每个解决方案的底层都可能是一些肮脏的 Bash 脚本)。为了让我或我的团队投入大量时间和精力去构建定制工具,我们需要获得相当可观的投资回报。而且,当我想到定制工具时,我也会考虑到持续的维护成本和对新团队成员的培训。指引新团队成员学习像 Terraform 这样的项目是简单的,那里有很好的文档和无数的博客文章,详细描述了每个可能遇到的场景。新团队成员也很有可能已经了解 Terraform,因为他们在前一份工作中就使用过它。这种理由促使我在批准一个构建定制工具的项目时需要有充分的证据。出于这些原因,我花了相当多的时间使用开源的 DevOps 工具,并且我也把自己作为一项业务,尽力在扩展这些工具上做到最好。

在本书中,你将找到使用 Go 和标准库完成任务的各种定制工具。然而,你也会发现几个如何使用现有开源工具来完成那些本来需要大量定制代码才能实现的任务的示例。我相信我们不同的方法为内容增加了价值,并为你提供了理解在发明自己的解决方案或扩展现有解决方案以解决常见 DevOps 任务时所涉及的权衡所需的工具。

正如 John 所说,我也希望这本书能帮助你达到一种类似禅宗的自动化掌控状态,以便你能跟随 John 的步伐,过上像 Jimmy Buffet 而非 Warren Buffet 那样的生活。

本书适用对象

本书适用于任何希望使用 Go 来开发自己的 DevOps 工具或与如 Kubernetes、GitHub Actions、HashiCorp Packer 和 Terraform 等 DevOps 工具集成自定义功能的人。你应该有某种编程语言的经验,但不一定是 Go。

本书内容

第一章Go 语言基础,介绍了 Go 语言的基础知识。

第二章Go 语言基础,介绍了 Go 语言的基本特性。

第三章设置您的开发环境,解释了如何设置 Go 开发环境。

第四章文件系统交互,探讨了如何使用 Go 与本地文件系统进行交互。

第五章使用常见数据格式,讲解了如何使用 Go 读取和写入常见的文件格式。

第六章与远程数据源交互,探讨了如何使用 Go 与 gRPC 和 REST 服务进行交互。

第七章编写命令行工具,展示了如何用 Go 编写命令行工具。

第八章自动化命令行任务,介绍了如何利用 Go 的 exec 和 SSH 包来自动化工作。

第九章使用 OpenTelemetry 进行可观测性,探讨了如何使用 OpenTelemetry 与 Go 进行更好的仪表化和警报设置。

第十章使用 GitHub Actions 自动化工作流,展示了如何使用 GitHub 进行持续集成、发布自动化和使用 Go 进行自定义操作。

第十一章使用 ChatOps 提高效率,讲解了如何用 Go 编写 ChatOps 服务,以提供操作性洞察和有效地管理事件。

第十二章使用 Packer 创建不可变基础设施,解释了如何自定义 HashiCorp 的 Packer,自动化在 AWS 上创建虚拟机镜像。

第十三章使用 Terraform 进行基础设施即代码,展示了如何定义自己的自定义 Terraform 提供程序。

第十四章在 Kubernetes 中部署和构建应用程序,探讨了如何编程和扩展 Kubernetes API。

第十五章云编程,解释了如何使用 Go 来配置和交互云资源。

第十六章为混乱设计,讨论了如何使用速率限制器、集中式工作流引擎和策略来减少爆炸半径。

为了充分利用本书

你需要具备一定的编程经验,但不一定是 Go 语言经验。你需要对任何支持的操作系统的命令行工具有基本了解。具有一些 DevOps 经验会更有帮助。

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/01.jpg

如果你使用的是这本书的数字版,建议你亲自输入代码或从书籍的 GitHub 仓库获取代码(链接将在下一节提供)。这样可以避免与复制粘贴代码相关的潜在错误。

本书大量依赖 Docker 和 Docker Compose,帮助你设置在 Linux 上原生运行的集群配置。虽然可以在 Windows 上使用 Windows Subsystem for LinuxWSL)来进行本书的操作,但作者并未对此进行测试。另外,许多练习也可以在其他符合 POSIX GNU 标准的操作系统上完成。 第十二章,使用 Packer 创建不可变基础设施,需要一个运行 Linux 虚拟机的 AWS 账户,第十三章,“基础设施即代码与 Terraform” 和 第十五章,“编程云端” 需要一个 Azure 账户。

下载示例代码文件

你可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/Go-for-DevOps。如果代码有更新,将在 GitHub 仓库中更新。

我们还提供来自我们丰富书籍和视频目录的其他代码包,可以在github.com/PacktPublishing/查看。快去看看吧!

下载彩色图片

我们还提供了一份 PDF 文件,里面有本书中使用的截图和图表的彩色图像。你可以在此下载:static.packt-cdn.com/downloads/9781801818896_ColorImages.pdf

使用的约定

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

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。示例:“在你的用户主目录下设置一个名为 packer 的目录。”

代码块的格式如下:

packer {
  required_plugins {
    amazon = {
      version = ">= 0.0.1"

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

source "amazon-ebs" "ubuntu" {
  access_key = "your key"
  secret_key = "your secret"
  ami_name      = "ubuntu-amd64"
  instance_type = "t2.micro"

任何命令行输入或输出都写作以下格式:

sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo sudo yum -y install packer

粗体:表示一个新术语、重要词汇或屏幕上的内容。例如,菜单或对话框中的文字通常会以 粗体 显示。示例:“你需要在 GitHub 仓库的 设置 | 机密 中找到并点击提供的按钮,新建仓库机密。”

提示或重要说明

如下所示。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com,并在邮件主题中注明书名。

勘误:尽管我们已经尽力确保内容的准确性,但难免会出现错误。如果你发现本书中有错误,我们将非常感谢你向我们报告。请访问 www.packtpub.com/support/errata 并填写表单。

copyright@packt.com 以及相关链接。

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

分享你的想法

一旦你读完Go for DevOps,我们很想听听你的想法!请点击这里直接进入亚马逊书籍评论页面并分享你的反馈。

你的评价对我们以及技术社区非常重要,它将帮助我们确保提供优质的内容。

第一部分:开始使用 Go

Go 是一种类型安全的并发语言,易于开发且性能极佳。在本节中,我们将从学习 Go 语言的基础知识开始,例如类型、变量创建、函数和其他基本语言构造。接下来,我们将继续教授包括并发、context 包、测试和其他必要技能的核心内容。你将学习如何为你的操作系统设置 Go 环境,如何与本地文件系统交互,使用常见的数据格式,使用如 REST 和 gRPC 等方法与远程数据源进行通信。最后,我们将通过编写命令行工具,利用流行的包来实现自动化,并向本地和远程资源发出命令。

本节将涉及以下章节:

  • 第一章Go 语言基础

  • 第二章Go 语言必备知识

  • 第三章环境设置

  • 第四章文件系统交互

  • 第五章使用常见的数据格式

  • 第六章与远程数据源交互

  • 第七章编写命令行工具

  • 第八章自动化命令行任务

第一章:Go 语言基础

DevOps 是一个自 2000 年代初以来就存在的概念。它是依赖于编程技能的运维学科的普及,结合了 由敏捷开发推广的开发心理学

站点可靠性工程 (SRE) 现在被认为是 DevOps 的一个子学科,尽管它可能是 DevOps 的前身,并且更加依赖软件技能和 服务级义务 (SLO)/服务级协议 (SLA) 建模。

在我早期在 Google 的工作中,像今天许多 DevOps 团队一样,我们大量使用 Python。我认为 C++ 对许多 SRE 来说太痛苦了,而我们有 Python 大腕 Guido van RossumAlex Martelli

但随着时间的推移,许多使用 Python 的团队开始遇到扩展性问题。这些问题包括从 Python 内存耗尽(需要我们自己实现 malloc)到 全局解释器锁 (GIL) 阻止我们真正的多线程处理。在大规模应用中,我们发现缺乏静态类型导致了大量本应在编译时捕获的错误。这与生产环境中的服务多年前遇到的问题相似。

但 Python 带来的问题不仅仅是编译时和服务扩展的问题。仅仅将 Python 版本升级到新版本,就可能导致服务停止工作。Google 机器上运行的 Python 版本经常被升级,并暴露出旧版本中没有的代码 bug。与编译后的二进制文件不同,你不能仅仅回滚到旧版本。

我们中有几位来自不同组织的人,正在寻找不必使用 C++ 的方法来解决这些问题。就我个人的经历来说,我从我们悉尼办公室的同事那里听说了 Go嘿,Ross!)。那时 Go 还在 1.0 之前,但他们说它已经显示出很多潜力。我不能说我当时完全相信我们需要的是另一种语言。

然而,大约 6 个月后,我已经完全接受了 Go 一见钟情。它拥有我们所需的一切,而没有我们不需要的东西。虽然那时还处于 1.0 之前的阶段,所以也有一些不太愉快的变化(比如发现 Russ Cox 在周末更改了 time 包,所以我不得不重写一大堆代码)。但是,在我写完第一个服务后,其好处是不可否认的。

接下来的 4 年里,我带领我的部门从完全使用 Python 转变为几乎完全使用 Go。我开始在全球范围内开设 Go 课程,面向运维工程师,重写 Go 的核心库,并做了相当多的推广。仅仅因为 Go 是在 Google 发明的,并不意味着工程师们愿意抛弃他们的 Python 代码并学习新的东西;有很多反对的声音。

现在,Go 已经成为云编排和软件(从 Kubernetes 到 Docker)中的事实标准语言。Go 自带了所有你需要的工具,可以显著提高你工具的可靠性和可扩展性。

因为许多云服务都是用 Go 编写的,你可以通过访问它们的包来满足自己的工具需求。这可以使为云编写工具变得更加简单。

在接下来的两章中,我将分享我在全球工程师中教授 Go 语言的 10+ 年经验,为你介绍 Go 语言的基础和要点。大部分内容基于我免费的 Go 基础视频培训课程 www.golangbasics.com。这本书稍有不同,更为精简。随着你阅读本书,我们将继续扩展你对 Go 语言标准库和第三方包的了解。

本章将涵盖以下主要内容:

  • 使用 Go Playground

  • 利用 Go 包

  • 使用 Go 的变量类型

  • 在 Go 中进行循环

  • 使用条件语句

  • 学习函数

  • 定义公共和私有

  • 使用数组和切片

  • 了解结构体

  • 理解 Go 指针

  • 理解 Go 接口

现在,让我们把基础知识掌握好,让你上路!

技术要求

本章唯一的技术要求是使用Go Playground的现代 Web 浏览器。

使用 Go Playground

你可以在 play.golang.org/ 找到的 Go Playground 是一个在线代码编辑器和编译器,允许你在没有在本地安装 Go 的情况下运行 Go 代码。这是我们介绍章节的完美工具,让你可以在线保存你的工作,避免了安装 Go 工具和寻找代码编辑器的初期麻烦。

Go Playground 有四个重要部分:

  • 代码编辑窗格

  • 控制台窗口

  • 运行按钮

  • 分享按钮

代码编辑窗格是页面上黄色部分,允许你输入你的程序的 Go 代码。当你点击运行按钮时,代码将被编译并在控制台输出白色部分显示。

下面的屏幕截图展示了 Go Playground 的功能:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_01_001.jpg

图 1.1 – Go Playground 代码编辑器

点击 play.golang.org 生成一个可共享的链接,比如 play.golang.org/p/HmnNoBf0p1z。这个链接是一个唯一的 URL,你可以收藏并分享给其他人。该链接中的代码不能被修改,但如果再次点击分享按钮,将会生成一个带有任何修改的新链接。

后续章节,从 第四章文件系统交互,将需要为你的平台安装 Go 工具。

本节向你介绍了 Go Playground,以及如何使用它编写、查看、分享和运行你的 Go 代码。Playground 将在本书中广泛使用,用于分享可运行的代码示例。

现在,让我们开始编写 Go 代码,从 Go 如何定义包开始。

使用 Go 包

Go 提供了可以重复使用的代码块,可以通过包导入到其他代码中。Go 中的包等同于其他语言中的库或模块。包是 Go 程序的构建块,将内容划分为可理解的部分。

本节将讲解如何声明和导入包。我们将讨论如何处理包名冲突,探索包相关的规则,并将编写我们的第一个主包。

声明包

Go 将程序划分为 ,在其他语言中有时称为 模块。包位于一个路径上,这个路径看起来像 Unix 类文件系统中的目录路径。

目录中的所有 Go 文件必须属于同一个包。包通常被命名为它所在目录的名称。

在文件的顶部声明包,并且只应当被注释所先行。声明包就像下面这样简单:

// Package main is the entrance point for our binary.
// The double slashes provides a comment until the end of the line.
/*
This is a comment that lasts until the closing star slash.
*/
package main

package main 是特别的。其他所有包名声明的包必须导入到另一个包中才能使用。package main 将声明 func main(),这是二进制程序运行的起点。

目录中的所有 Go 文件必须具有相同的包头(由编译器强制执行)。这些文件在大多数实际应用中,作用上好像它们是连接在一起的。

假设你有以下的目录结构:

mypackage/
  file1.go
  file2.go

然后,file1.gofile2.go 应该具有以下内容:

package mypackage

mypackage 被另一个包导入时,它将包含 mypackage 目录中所有文件中声明的内容。

导入包

包大致分为两种类型:

  • 标准库 (stdlib) 包

  • 所有其他包

标准库包之所以显得特别,是因为它们的路径中不包含一些仓库信息,例如以下内容:

"fmt"
"encoding/json"
"archive/zip"

所有其他包通常会在路径前面列出仓库信息,如下所示:

"github.com/johnsiilver/golib/lru"
"github.com/kylelemons/godebug/pretty"

注意

完整的 stdlib 包列表可以在以下链接找到:golang.org/pkg/

要导入包,我们使用 import 关键字。那么,让我们导入标准库中的 fmt 包和位于 github.com/devopsforgo/mypackagemypackage 包:

package main
import (
     "fmt"
     "github.com/devopsforgo/mypackage"
)

需要注意的是,文件名不是包路径的一部分,只是目录路径的一部分。

使用包

一旦你导入了一个包,你可以通过在你想要访问的内容前加上包名和一个点,开始访问该包中声明的函数、类型或变量。

例如,fmt 包中有一个名为 Println() 的函数,可以用来打印一行到 stdout。如果我们想使用它,代码就像下面这样简单:

fmt.Println("Hello!")

包名冲突

假设你有两个名为 mypackage 的包。它们的名称相同,因此我们的程序无法判断我们在引用哪个包。你可以将包的导入重命名为任何你想要的名字:

import(
     "github.com/devopsforgo/mypackage"
     jpackage "github.com/johnsiilver/mypackage"
)

jpackage 声明在这个包中,我们将 github.com/johnsiilver/mypackage 称为 jpackage

这个功能使我们能够像下面这样使用两个同名的包:

mypackage.Print()
jpackage.Send()

现在,我们来看一下与包相关的一个重要规则,这个规则能改善编译时间和二进制文件大小。

包必须被使用

让我们向你介绍以下规则:如果你导入了一个包,你必须使用它

Go 的作者注意到,谷歌使用的许多其他编程语言常常有未使用的导入包。

这导致了编译时间比实际需要的更长,在某些情况下,二进制文件的大小比要求的要大得多。Python 文件会打包成专有格式以便在生产环境中传输,其中一些未使用的导入会增加几百兆字节的文件大小。

为了避免这些问题,Go 不会编译导入了一个包但没有使用它的程序,如下所示:

package main
import (
     "fmt"
     "sync"
)
func main() {
     fmt.Println("Hello, playground")
}

上面的代码输出如下内容:

./prog.go:5:2: imported and not used: "sync"

在某些罕见情况下,你可能需要做一个 副作用 导入,即仅仅加载该包就会引发某些事情发生,但你并不直接使用该包。这应该 总是package main 中完成,并且需要用下划线 (_) 前缀:

package main
import (
     "fmt"
     _ "sync" //Just an example 
)
func main() {
     fmt.Println("Hello, playground")
}

接下来,我们将声明一个主包并讨论编写 Go 程序的基本知识,程序将导入一个包。

一个 Go 的 Hello World

让我们写一个简单的 Hello World 程序,类似于 Go Playground 中的默认程序。这个示例将演示以下内容:

  • 声明一个包

  • 从标准库中导入 fmt 包,它可以将内容打印到我们的屏幕上

  • 声明一个程序的 main() 函数

  • 使用 := 运算符声明一个字符串变量

  • 打印变量到屏幕

让我们看看这是什么样子的:

1 package main
2 
3 import "fmt"
4
5 func main() {
6    hello := "Hello World!" fmt.Println(hello) 
7          
8 }

在第一行中,我们使用 package 关键字声明了我们的包名。任何 Go 二进制文件的入口点都是一个名为 main 的包,其中有一个名为 main() 的函数。

在第三行,我们导入了 fmt 包。fmt 包含一些函数,用于做字符串格式化和写入各种输出。

在第五行,我们声明了一个名为 main 的函数,它不接收任何参数,也不返回任何值。main() 是特殊的,因为当二进制文件运行时,它会从运行 main() 函数开始。

Go 使用 {} 来标明函数的开始和结束位置(类似于 C 语言)。

第六行使用 := 操作符声明了一个名为 hello 的变量。这个操作符表示我们希望在一行代码中创建一个新变量并为其赋值。这是最常见的声明变量的方式,但不是唯一的方式。

由于 Go 是类型化的,:= 将根据值来推断变量的类型。在这种情况下,它将是一个字符串,但如果值是 3,则会是 int 类型,如果是 2.4,则会是 float64 类型。如果我们想声明一个特定类型,比如 int8float32,我们需要做一些修改(稍后我们会讨论)。

在第七行,我们调用了 fmt 包中的一个名为 Println 的函数。Println() 将打印 hello 变量的内容到 stdout,并附加一个换行符(\n)。

你会注意到,使用另一个包中声明的函数的方式是使用包名(不带引号) + 一个句点 + 函数名。在这个例子中,就是 fmt.Println()

在这一部分,你已经学会了如何声明一个包、导入一个包、main 包的功能是什么,以及如何编写一个基本的 Go 程序并声明变量。在下一部分,我们将深入探讨如何声明和使用变量。

使用 Go 的变量类型

现代编程语言是基于称为类型的原始数据类型构建的。当你听到某个变量是 字符串整数 时,你是在谈论变量的类型。

在当今的编程语言中,有两种常见的类型系统:

  • 动态类型(也称为鸭子类型)

  • 静态类型

Go 是一种静态类型语言。对于许多可能来自 Python、Perl 和 PHP 等语言的开发者来说,这些语言是动态类型语言。

在动态类型语言中,你可以创建一个变量并存储任何内容。在这些语言中,类型仅表示存储在变量中的内容。这里是一个 Python 的示例:

v = "hello"
v = 8
v = 2.5

在这种情况下,v 可以存储任何内容,而 v 持有的类型在没有使用运行时检查的情况下是未知的(运行时意味着它无法在编译时检查)。

在静态类型语言中,变量的类型在创建时就已确定,并且该类型不能更改。在这种语言中,类型既表示变量中存储的内容,也表示可以存储的内容。这里是 Go 的示例:

v := "hello" // also can do: var v string = "hello"

v 的值不能被设置为除了字符串之外的其他类型。

可能看起来 Python 更优,因为它可以在变量中存储任何内容。但实际上,这种不具特定性的缺点是,Python 必须等到程序运行时才能发现问题(我们称之为运行时错误)。与其在软件部署后才发现问题,最好在编译时就能找出问题。

让我们以一个函数示例来加法运算两个数字。

这是 Python 版本的示例:

def add(a, b):
     return a+b

这是 Go 版本的示例:

func add(a int, b int) int {
     return a + b
}

在 Python 版本中,我们可以看到 ab 会被加在一起。但是,ab 是什么类型呢?结果类型是什么?如果我传递一个整数和一个浮点数,或者一个整数和一个字符串,会发生什么?

在某些情况下,Python 中的两种类型不能相加,这将导致运行时异常,而且你永远无法确定结果类型会是什么。

注意

Python 已经为语言添加了 类型提示,以帮助避免这些问题。但实践经验告诉我们,像 JavaScript/Dart/TypeScript/Closure 这样的语言,虽然类型支持有时能提供帮助,但可选的类型支持意味着许多问题会被忽视。

我们的 Go 版本为参数和结果定义了确切的类型。你不能传递整数和浮点数,或者整数和字符串。你只会收到整数作为返回值。这允许我们的编译器在程序编译时发现任何类型错误。在 Python 中,这种错误可能会在任何时间出现,从程序运行的瞬间到 6 个月后,当某个代码路径被执行时才会发现。

注意

几年前,曾经进行过一项关于 Rosetta Code 仓库的研究,评估了几种主要编程语言在处理时间、内存使用和运行时故障方面的表现。在运行时故障方面,Go 的故障最少,Python 排名靠后。静态类型系统显然在其中起了作用。

该研究可以在此找到:arxiv.org/pdf/1409.0252.pdf

Go 的类型

Go 拥有丰富的类型系统,不仅指定类型可能是整数,还指定了整数的大小。这使得 Go 程序员能够减少变量在内存中的大小,并在进行网络传输时进行编码。

下表显示了 Go 中最常用的类型:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/011.jpg

表 1.1 – Go 中常用类型及其描述

我们将主要讨论上述类型;不过,以下表格列出了可以使用的类型的完整列表:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/02.jpg

表 1.2 – Go 中可用类型的完整列表

Go 不仅提供这些基本类型;你还可以基于这些基本类型创建新的类型。这些自定义类型会成为自己的类型,并可以附加方法。

声明自定义类型使用 type 关键字,并将在讨论 struct 类型时讲解。目前,我们将继续讨论声明变量的基本知识。

现在我们已经讨论了变量类型,让我们看看如何创建它们。

声明变量

与大多数语言一样,声明变量会分配存储空间,用于存储某种类型的数据。在 Go 中,这些数据是有类型的,因此只能存储该类型的数据。由于 Go 提供了多种声明变量的方式,接下来的部分将讨论这些不同的声明方式。

声明变量的长方式

声明变量最具体的方式是使用var关键字。你可以在包级(即不在函数内部)和函数内部使用var声明变量。让我们来看一些使用var声明变量的示例:

var i int64

这声明了一个i变量,它可以保存int64类型的值。由于没有赋值,因此它被赋予了整数的零值,即0

var i int = 3

这声明了一个i变量,它可以保存int类型的值。值3被赋给了i

注意,intint64类型是不同的。你不能将int类型作为int64类型使用,反之亦然。然而,你可以进行类型转换以允许这两种类型的互换。稍后会讨论这个话题:

var (
     i int
     word = "hello"
)

使用(),我们将一组声明放在一起。i可以保存int类型,并且其整数零值是0word没有声明类型,但它的类型由右侧等号(=)运算符中的字符串值推断出来。

更简洁的方式

在前面的示例中,我们使用var关键字来创建变量,并使用=运算符赋值。如果没有=运算符,编译器会为该类型分配零值(稍后会详细讲解)。

重要的概念如下:

  • var创建了变量,但没有赋值。

  • =将一个值赋给变量。

在函数内(而不是在包级别),我们可以通过使用:=运算符进行创建并赋值。这既创建了一个新变量,又赋值给它:

i := 1                       // i is the int type 
word := "hello"              // word is the string type 
f := 3.2                     // f is the float64 type 

使用:=时需要记住的重要一点是,它意味着创建并赋值。如果变量已经存在,不能使用:=,而必须使用=,它仅仅是进行赋值操作。

变量作用域和变量遮蔽

作用域是程序中变量可见的部分。在 Go 语言中,我们有以下几种变量作用域:

  • 包级作用域:可以被整个包访问,在函数外部声明

  • 定义函数的{}

  • 函数中的语句{}(如for循环、if/else

在下面的程序中,word变量在包级声明。它可以被包内定义的任何函数使用:

package main
import "fmt"
var word = "hello"
func main() {
	fmt.Println(word)
}

在下面的程序中,word变量在main()函数内定义,只能在定义main{}内使用。它在外部是未定义的:

package main
import "fmt"
func main() {
	var word string = "hello"
	fmt.Println(word)
}

最后,在这个程序中,i是语句作用域的。它可以在启动for循环的那一行和循环中的{}内使用,但在循环外不存在:

package main
import "fmt"
func main() {
	for i := 0; i < 10; i++ {
		fmt.Println(i)
	}
}

最好的理解方式是,如果你的变量声明在包含{的那一行或位于一组{}内,那么它只能在这些{}内看到。

不能在同一作用域中重新声明一个变量

这一规则是,在同一作用域内不能声明两个同名的变量

这意味着在同一作用域内,两个变量不能有相同的名字:

func main() {
     var word = "hello"
     var word = "world"
     fmt.Println(word)
}

这个程序是无效的,会生成编译错误。一旦声明了word变量,你不能在相同的作用域内重新创建它。你可以将其值更改为新的值,但不能创建第二个同名的变量。

要给word赋新值,只需从这一行中去掉varvar表示在我们只想做赋值的地方创建变量

func main() {
     var word = "hello"
     word = "world"
     fmt.Println(word)
}

接下来,我们将看看在相同作用域内,但在不同代码块中声明两个相同名称的变量时会发生什么。

变量遮蔽

变量遮蔽发生在一个变量在你的变量作用域内,但不在你的局部作用域内时被重新声明。这导致局部作用域无法访问外部作用域的变量

package main
import "fmt"
var word = "hello"
func main() {
	var word = "world"
	fmt.Println("inside main(): ", word)
	printOutter()
}
func printOutter() {
	fmt.Println("the package level 'word' var: ", word)
}

如你所见,word在包级别声明。但在main内部,我们定义了一个新的word变量,它遮蔽了包级别的变量。当我们现在引用word时,我们使用的是在main()中定义的那个。

调用了printOutter(),但它没有一个局部遮蔽的word变量(即在其{}之间声明的变量),因此使用了包级别的word变量。

这是该程序的输出:

inside main():  world
the package level 'word' var:  hello

这是 Go 开发者中比较常见的一个 bug。

零值

在一些旧语言中,未赋值的变量声明具有未知的值。这是因为程序创建了一个内存位置来存储该值,但没有向其中放入任何东西。所以,表示该值的位被设置为在你创建变量之前,该内存空间中随机存在的内容。

这已经导致了许多不幸的 bug。因此,在 Go 中,声明一个变量但不进行赋值时,会自动赋一个被称为零值的值。以下是 Go 类型的零值列表:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/03.jpg

表 1.3 – Go 类型的零值

现在我们了解了什么是零值,让我们来看一下 Go 如何在我们的代码中防止未使用的变量。

函数/语句变量必须被使用

这里的规则是,如果你在函数或语句中创建一个变量,它必须被使用。这与包导入的原因差不多;声明一个没有使用的变量几乎总是一个错误。

这种写法可以像导入一样放宽,使用_,但这种情况远不如前者常见。这将someVar中存储的值赋给了一个什么也不使用的变量:

_ = someVar

这将someFunc()返回的值赋给了什么也不使用的东西:

_ = someFunc()

这种用法最常见的场景是当一个函数返回多个值,但你只需要其中一个:

needed, _ := someFunc()

这里,我们创建并赋值给needed变量,但第二个值是我们不使用的,所以我们将其丢弃。

本节提供了 Go 的基本类型知识、不同的变量声明方式、变量作用域和遮蔽规则,以及 Go 的零值

Go 中的循环

大多数语言都有几种不同类型的循环语句:forwhiledo while

Go 语言不同之处在于,只有一种循环类型,即for,它可以实现其他语言中所有类型的循环功能。

在这一节中,我们将讨论for循环及其多种用法。

C 风格

循环的最基本形式类似于 C 语言的语法:

for i := 0; i < 10; i++ {
     fmt.Println(i)
}

这声明了一个i变量,它是一个整数,只在这个循环语句中有效。i := 0;是循环初始化语句;它只在循环开始前执行一次。i < 10;是条件语句;它在每次循环开始时执行,必须评估为true,否则循环结束。

i++post语句;它会在每次循环结束时执行。i++表示将i变量增加1。Go 语言也有常见的语句,如i += 1i--

移除init语句

我们不需要init语句,如下面的例子所示:

var i int
for ;i < 10;i++ {
     fmt.Println(i)
}
fmt.Println("i's final value: ", i)

在这里,我们在循环外部声明了i。这意味着i将在循环结束后仍然可以在外部访问,而不像我们之前的例子那样只能在循环内访问。

也可以去掉post语句,这样就变成了一个while循环

许多语言有while循环,用来简单地判断某个语句是否为真。我们可以通过去掉initpost语句来实现相同的功能:

var i int
for i < 10 {
     i++
}
b := true
for b { // This will loop forever
     fmt.Println("hello")
}

你可能会问,我们如何创建一个永远运行的循环? for循环可以解决这个问题。

创建一个无限循环

有时候你希望一个循环永远运行,或者直到循环内部的某些条件满足。创建一个无限循环只需去掉所有语句:

for {
     fmt.Println("Hello World")
}

这通常对一些需要永远处理输入流的服务器很有用。

循环控制

使用循环时,你有时需要从循环内部控制循环的执行。这可能是因为你想退出循环,或者停止当前迭代并从顶部重新开始。

这是一个循环的示例,我们调用一个名为doSomething()的函数,如果循环需要结束,doSomething()将返回一个错误。在这个例子中,doSomething()做什么并不重要:

for {
     if err := doSomething(); err != nil {
          break
     }
     fmt.Println("keep going")
}

这里的break函数将会跳出循环。break也用于跳出其他语句,例如selectswitch,所以重要的是要知道,break会跳出它所在的第一个语句。

如果我们希望在某个条件下停止循环并继续执行下一次循环,可以使用continue语句:

for i := 0; i < 10; i++ {
     if i % 2 == 0 { // Only 0 for even numbers
           continue
     }
     fmt.Println("Odd number: ", i)
}

这个循环将打印出从零到九的奇数。i % 2表示i 对 2 取模。取模操作是将第一个数字除以第二个数字,并返回余数。

循环的大括号

这个规则的介绍是:for循环的左大括号必须与for关键字位于同一行。

在许多编程语言中,关于循环/条件语句的大括号应该放在哪里一直存在争议。而在 Go 语言中,作者通过编译器检查来预防这些争论。在 Go 语言中,你可以这样做:

for {
     fmt.Println("hello world")
}

然而,下面的写法是错误的,因为for循环的左大括号单独占一行:

for
{
     fmt.Println("hello world")
}

在这一部分,我们学会了使用for循环作为 C 风格循环,也作为while循环使用。

使用条件语句

Go 支持两种类型的条件语句,如下所示:

  • if/else

  • switch

标准的if语句与其他语言类似,额外增加了一个可选的init语句,它是从标准 C 风格的for循环语法中借用来的。

switch语句提供了一个有时更简洁的替代方案来替代if。所以,让我们来深入了解if条件语句。

if 语句

if语句以一个在大多数语言中都能识别的熟悉格式开始:

if [expression that evaluates to boolean] {
     ...
} 

这是一个简单的例子:

if x > 2 { 
    fmt.Println("x is greater than 2") 
}

如果x的值大于2if中的{}内的语句将被执行。

与大多数语言不同,Go 有能力在进行条件判断之前,在if作用域内执行一条语句:

if [init statement];[statement that evaluates to boolean] {
     ...
}

这是一个简单的例子,类似于for循环中的初始化语句:

if err := someFunction(); err != nil { 
    fmt.Println(err) 
}

在这里,我们初始化了一个名为err的变量。它的作用域是if块。如果err变量不等于nil值(一个特殊的值,表示某些类型未被设置——稍后会详细介绍),它将打印出错误。

else

如果你需要在if语句的条件不满足时执行某些操作,可以使用else关键字:

if condition {
     function1()
}else {
     function2()
}

在这个例子中,如果if条件为真,function1将被执行。否则,function2将被执行。

应该注意的是,大多数情况下,else的使用可以被省略,以获得更简洁的代码。如果你的if条件通过使用return关键字从函数返回,你可以省略else

下面是一个例子:

if v, err := someFunc(); err != nil {
     return err
}else{
     fmt.Println(v)
     return nil
}

这可以简化为以下内容:

v, err := someFunc()
if err != nil {
     return err 
}
fmt.Println(v)
return nil

有时,你只希望在if条件不满足而另一个条件满足时才执行代码。我们接下来来看看这种情况。

else if

一个if块也可以包含else if,提供多层次的执行。第一个匹配的ifelse if语句将被执行。

注意,Go 开发者通常选择使用switch语句作为这种类型条件语句的更简洁版本。

下面是一个例子:

if x > 0 {
     fmt.Println("x is greater than 0")
} else if x < 0 {
     fmt.Println("x is less than 0")
} else{
     fmt.Println("x is equal to 0")
}

现在我们已经看到了这个条件语句的基础,接下来我们需要讨论一下大括号风格。

if/else 的大括号

现在是时候介绍这个规则了:if/else的左大括号必须与相关的关键字在同一行。如果链中有其他语句,它必须与前一个右大括号在同一行开始。

在许多语言中,关于循环/条件语句的大括号放置位置有很多争论。

在 Go 语言中,作者决定通过编译器检查来预防这些问题。在 Go 中,你不能这样做:

if x > 0 
{ // This must go up on the previous line
     fmt.Println("hello")
}
else { // This line must start on the previous line
     fmt.Println("world")
}

所以,随着 Go 中关于大括号风格的争论已经解决,让我们来看看替代if/else的一个选项——switch语句。

switch 语句

switch语句比if/else块更优雅,它在使用上非常灵活。它可以用于精确匹配和多个真假评估。

精确匹配的 switch

以下是一个精确匹配的switch

switch [value] {
case [match]:
     [statement]
case [match], [match]:
     [statement]
default:
     [statement]
}

[value]会与每个case语句进行匹配。如果匹配,case语句就会执行。与某些语言不同,一旦匹配成功,其他的case将不会再被考虑。如果没有匹配,default语句就会执行。default语句是可选的。

这种语法比if/else更简洁,适合处理值可以是多个值的情况:

switch x {
case 3:
     fmt.Println("x is 3")
case 4, 5:  // executes if x is 4 or 5
     fmt.Println("x is 4 or 5")
default:
     fmt.Println("x is unknown")
}

switch也可以有一个init语句,类似于if语句:

switch x := someFunc(); x {
case 3:
     fmt.Println("x is 3")
} 
真/假评估开关

我们还可以省略[match],这样每个case语句就不再是精确匹配,而是一个真/假评估(就像if语句一样):

switch {
case x > 0:
     fmt.Println("x is greater than 0")
case x < 0:
     fmt.Println("x is less than 0")
default:
     fmt.Println("x must be 0")
}

在本节结束时,你应该能够使用 Go 的条件语句根据某些标准在程序中分支代码执行,并处理没有匹配语句的情况。由于条件语句是软件的标准构建块之一,我们将在接下来的许多章节中使用它们。

学习函数

Go 中的函数符合现代编程语言的预期。使 Go 函数与众不同的只有少数几个特性:

  • 支持多个返回值

  • 可变参数

  • 命名返回值

基本的函数签名如下:

func functionName([varName] [varType], ...) ([return value], [return value], ...){
}

让我们创建一个基本的函数,它将两个数字相加并返回结果:

func add(x int, y int) int {
     return x + y
}

如你所见,这个函数接收两个整数,xy,将它们相加并返回结果(这是一个整数)。让我们展示如何调用这个函数并打印它的输出:

result := add(2, 2)
fmt.Println(result)

我们可以通过使用单一的int关键字来简化函数签名,声明xy的类型:

func add(x, y int) int {
     return x + y
}

这与之前的内容是等效的。

返回多个值和命名结果

在 Go 中,我们可以返回多个值。例如,考虑一个将两个整数相除并返回两个变量(结果和余数)的函数,如下所示:

func divide(num, div int) (res, rem int) {
	result = num / div
	remainder = num % div
	return res, rem
}

这段代码演示了我们函数中的一些新特性:

  • 参数num是被除数

  • 参数div是被除数

  • 返回值res是除法的结果

  • 返回值rem是除法的余数

第一个是resrem。这些变量会自动创建,并且可以在函数内使用。

注意,我在为这些变量赋值时使用的是=而不是:=。这是因为这些变量已经存在,我们要做的是赋值(=)。:=表示创建并赋值,它只适用于创建不存在的新变量。你还会注意到,返回类型现在放在括号中。如果你使用多个返回值或命名返回值(或者在此情况中,两者都有),你将需要使用括号。

调用这个函数和之前调用add()一样简单,如下所示:

result, remainder := divide(3, 2)
fmt.Printf("Result: %d, Remainder %d", result, remainder)

严格来说,你不必使用return来返回值。然而,使用它会防止一些你最终会遇到的难看的错误。

接下来,我们将看看如何使函数接受可变数量的参数,从而创建像 fmt.Println() 这样的函数,你在本章中已经使用过它。

变参

0 到无限个参数。一个好的例子是计算整数的总和。如果没有变参,你可能会使用一个切片(可增长的数组类型,我们稍后会谈到),如下所示:

func sum(numbers []int) int {
     sum := 0
     for _, n := range numbers {
          sum += n
     }
     return sum
}

尽管这样做是可以的,但使用起来有些繁琐:

args := []int{1,2,3,4,5}
fmt.Println(sum(args))

我们可以使用变参(...)符号完成相同的事情:

func sum(numbers ...int) int {
     // Same code
}

numbers 仍然是 []int,但具有一种更优雅的调用约定:

fmt.Println(sum(1,2,3,4,5))

注意

你可以将变参与其他参数一起使用,但它必须是函数中的最后一个参数。

匿名函数

Go 具有匿名函数的概念,即没有名称的函数(也称为函数闭包)。

这对于利用一些特殊语句非常有用,这些语句尊重函数边界,例如 defergoroutines。我们稍后将展示如何在 goroutines 中利用这些语句,但现在我们先展示如何执行匿名函数。这是一个人为的示例,仅用于教学概念:

func main() {
     result := func(word1, word2 string) string {
          return word1 + " " + word2
     }("hello", "world")
     fmt.Println(result)
}

这段代码执行了以下操作:

  • 定义一个一次性函数(func(word1, word2 string) string

  • 使用 helloworld 参数执行该函数

  • string 返回值赋给 result 变量

  • 打印 result

现在,我们已经到达了这一部分的结尾,我们学习了如何声明 Go 函数,如何使用多个返回值,如何简化函数调用的变参,以及匿名函数。多个返回值在后续章节中处理错误时非常重要,而匿名函数是我们未来 defer 语句和并发使用的关键组件。

在下一部分,我们将探讨公共和私有类型。

定义公共和私有

许多现代编程语言在声明常量/变量/函数/方法时提供了一组选项,详细说明了何时可以调用某个方法。

Go 将这些可见性选择简化为两种类型:

  • 公共(已导出)

  • 私有(未导出)

公共类型是可以在包外引用的类型。私有类型只能在包内引用。为了使常量/变量/函数/方法为公共,必须以大写字母开头。如果以小写字母开头,它就是私有的。

还有第三种可见性类型我们没有在这里讨论:internal/。这些包只能被父目录中的其他包使用。你可以在这里阅读相关内容:golang.org/doc/go1.4#internalpackages

让我们声明一个包并创建一些公共和私有方法:

package say
import "fmt"
func PrintHello() {
	fmt.Println("Hello")
}
func printWorld() {
	fmt.Println("World")
}
func PrintHelloWorld() {
	PrintHello()
	printWorld()
}

我们有三个函数调用,其中两个是公共的(PrintHello()PrintHelloWorld()),一个是私有的(printWorld())。现在,让我们创建 package main,导入 say 包,并调用我们的函数:

package main
import "github.com/repo/examples/say"
func main() {
	say.PrintHello()
	say.PrintHelloWorld()
}

现在,让我们编译并运行它:

$ go run main.go
Hello
Hello
World

之所以有效,是因为 PrintHello()PrintHelloWorld() 都是 PrintHelloWorld() 调用了私有的 printWorld(),但这是合法的,因为它们位于同一个包中。

如果我们尝试将 say.printWorld() 添加到 func main() 中并运行,我们将得到以下结果:

./main.go:8:2: cannot refer to unexported name say.printWorld

公共和私有作用域适用于在函数/方法和类型声明外声明的变量。

到这节结束时,你已经掌握了 Go 语言的公共和私有类型。这将在你不希望在公共 API 中暴露类型的代码中非常有用。接下来,我们将学习数组和切片。

使用数组和切片

编程语言需要比基本类型更多的类型来存储数据。array 类型是底层语言中的核心构建块之一,提供基础的顺序数据类型。对于大多数日常使用,Go 的 slice 类型提供了一种灵活的数组,它可以根据数据需求增长,并且可以被切分成多个部分,以便共享数据的视图。

在这一节中,我们将讨论数组作为切片的构建块,二者的区别以及如何在代码中使用它们。

数组

Go 语言中的基础顺序类型是数组(这很重要,但很少使用)。数组的大小是静态的(如果你创建一个可以容纳 10 个 int 类型的数组,它将始终容纳恰好 10 个 int 类型)。

Go 提供了一个 array 类型,方法是在你希望创建数组的类型前加上 [size]。例如,var x [5]intx := [5]int{} 创建一个包含五个整数的数组,索引从 04

向数组赋值像选择索引一样简单。x[0] = 33 赋给索引 0。检索该值同样简单,只需引用该索引;fmt.Println(x[0] + 2) 将输出 5

数组与切片不同,不是指针包装类型。将数组作为函数参数传递时会传递一个副本:

func changeValueAtZeroIndex(array [2]int) {
     array[0] = 3
     fmt.Println("inside: ", array[0]) // Will print 3
}
func main() {
     x := [2]int{}
     changeValueAtZeroIndex(x)
     fmt.Println(x) // Will print 0
}

数组在 Go 语言中存在以下两个问题:

  • 数组的类型由大小决定——[2]int[3]int 是不同的。在需要 [2]int 的地方不能使用 [3]int

  • 数组的大小是固定的。如果你需要更多空间,必须创建一个新的数组。

虽然了解数组是什么很重要,但在 Go 语言中最常用的顺序类型是切片。

切片

理解切片的最简单方法是将其看作是一种构建在数组之上的类型。切片是对数组的视图。在切片的视图中改变你能看到的内容会改变底层数组的值。切片的最基本用途像数组一样,但有两个例外:

  • 切片不是静态大小的。

  • 切片可以增长以容纳新的值。

切片会追踪它的数组,当需要更多空间时,它会创建一个新数组来容纳新值,并将当前数组中的值复制到新数组中。这个过程对用户是不可见的。

创建一个切片与创建数组类似,var x = []intx := []int{}。这会创建一个长度为0的整数切片(没有空间存储值)。你可以使用len(x)来获取切片的大小。

我们可以轻松创建一个具有初始值的切片:x := []int{8,4,5,6}。现在,我们的len(x) == 4,索引范围从03

类似于数组,我们可以通过简单地引用索引来更改某个值。x[2] = 12会将前面的切片改为[]int{8,4,12,6}

与数组不同,我们可以使用append命令向切片中添加新值。x = append(x, 2)将导致底层的x数组引用被复制到一个新的数组中,并将新的数组视图返回给x。新值为[]int{8,4,12,6,2}。你也可以通过在append中添加多个用逗号分隔的值来追加多个值(例如,x = append(x, 2, 3, 4, 5))。

记住,切片只是数组的视图。我们可以创建数组的新有限视图。y := x[1:3]创建了数组的一个视图(y),返回[]int{4, 12}1是包含的,3是不包含的,即[1:3])。更改y[0]的值会改变x[1]。通过y = append(y, 10)将一个新值附加到y,这会改变x[3],结果是[]int{8,4,12,10,2}

这种用法并不常见(而且容易混淆),但重要的是要理解,切片只是数组的视图。

虽然切片是一个指针封装类型(传递给函数并更改切片中的值也会在调用者中发生变化),切片的视图本身不会发生改变。

func doAppend(sl []int) {
     sl = append(sl, 100)
     fmt.Println("inside: ", sl) // inside:  [1 2 3 100]
}
func main() { 
     x := []int{1, 2, 3}
     doAppend(x)
     fmt.Println("outside: ", x) // outside:  [1 2 3]
}

在这个例子中,slx变量都使用相同的底层数组(在两者中都发生了变化),但x的视图并没有在doAppend()中更新。要更新x以查看切片的变化,需要传递切片的指针(指针将在后续章节中讲解)或者像这里一样返回新的切片:

func doAppend(sl []int) []int {
     return append(sl, 100)
}
func main() {
     x := []int{1, 2, 3}
     x = doAppend(x)
     fmt.Println("outside: ", x) // outside:  [1 2 3 100]
}

现在你已经了解了如何创建和添加到切片,接下来我们来看如何提取切片中的值。

提取所有值

要从切片中提取值,我们可以使用旧的 C 型for循环或更常见的forrange语法。

旧的 C 风格如下:

for i := 0; i < len(someSlice); i++{
     fmt.Printf("slice entry %d: %s\n", i, someSlice[i])
}

Go 中更常见的方法是使用range

for index, val := range someSlice {
     fmt.Printf("slice entry %d: %s\n", index, val)
}

使用range时,我们通常只想使用值,而不关心索引。在 Go 中,你必须使用在函数中声明的变量,否则编译器会报错,提示如下:

index declared but not used

为了只提取值,我们可以使用_,(这告诉编译器不要存储输出),如下所示:

for _, val := range someSlice {
     fmt.Printf("slice entry: %s\n", val)
}

在非常罕见的情况下,您可能只想打印出索引而不是值。这是不常见的,因为它只会从零开始计数到项目数。然而,这可以通过简单地从for语句中删除val来实现:for index := range someSlice

在本节中,您已经了解了数组是什么,如何创建它们以及它们与切片的关系。此外,您已经掌握了创建切片、向切片添加数据和从切片中提取数据的技能。接下来让我们学习一下关于映射的知识。

理解映射

映射是用户可以使用的一组键值对,用于存储一些数据并使用键检索它。在某些语言中,这些被称为字典Python)或哈希Perl)。与数组/切片不同,映射中查找条目只需单个查找而不是迭代整个切片比较值。对于大量项目,这可以节省大量时间。

声明映射

有几种声明映射的方法。让我们首先看看使用make

var counters = make(map[string]int, 10)

刚刚分享的示例创建了一个具有string键并存储数据类型为int的映射。10表示我们要为 10 个条目预设大小。映射可以超过 10 个条目,而10可以省略。

另一种声明映射的方法是使用复合文字

modelToMake := map[string]string{
     "prius": "toyota",
     "chevelle": "chevy",
}

这将创建一个具有string键并存储string数据的映射。我们还会预先填充两个键值对的条目。您可以省略条目以获得空映射。

访问值

您可以按以下方式检索值:

carMake := modelToMake["chevelle"]
fmt.Println(carMake) // Prints "chevy"

这将把chevy的值赋给carMake

但是如果键不在映射中会发生什么呢?在这种情况下,我们将收到数据类型的零值:

carMake := modelToMake["outback"]
fmt.Println(carMake)

上述代码将打印一个空字符串,这是作为我们映射中值使用的字符串类型的零值。

我们还可以检测值是否在映射中:

if carMake, ok := modelToMake["outback"]; ok {
     fmt.Printf("car model \"outback\" has make %q", carMake)
}else{
     fmt.Printf("car model \"outback\" has an unknown make")
}

这里我们分配了两个值。第一个(carMake)是存储在键中的数据(如果未设置,则为零值),第二个(ok)是一个布尔值,指示是否找到了键。

添加新值

添加新的键值对或更新键的值,方式是相同的:

modelToMake["outback"] = "subaru"
counters["pageHits"] = 10

现在我们可以更改键值对,让我们看看如何从映射中提取值。

提取所有值

要从映射中提取值,我们可以使用我们用于切片的forrange语法。与映射相关的有几个关键区别:

  • 您将获得映射的键而不是索引。

  • 映射具有非确定性顺序。

非确定性顺序意味着迭代数据将返回相同的数据,但顺序不同。

让我们打印出我们的carMake映射中的所有值:

for key, val := range modelToMake {
     fmt.Printf("car model %q has make %q\n", key, val)
}

这将产生以下结果,但可能顺序不同:

car model "prius" has make "toyota"
car model "chevelle" has make "chevy"
car model "outback" has make "subaru"

注意

与切片类似,如果您不需要键,可以使用_。如果只需键,可以省略值val变量,例如for key := range modelToMake

在本节中,你已经了解了map类型,如何声明它们,如何向其中添加值,最后如何从中提取值。现在让我们深入学习指针。

理解 Go 中的指针

dictlistobject类型是引用类型。

在本节中,我们将讲解指针是什么,如何声明它们,以及如何使用它们。

内存地址

在之前的章节中,我们讨论了用于存储某种类型数据的变量。例如,如果我们想创建一个名为x的变量来存储一个值为23int类型,可以写作var x int = 23

在幕后,内存分配器为我们分配了存储值的空间。这个空间通过一个唯一的内存地址来引用,看起来像0xc000122020。这有点类似于一个家庭地址;它是数据所在位置的引用。

我们可以通过在变量名之前加上&来查看变量存储的内存地址:

fmt.Println(&x)

这会打印出0xc000122020,即x存储的内存地址。

这引出了一个重要的概念:函数总是会复制传入的参数。

函数参数是副本

当我们调用一个函数并将一个变量作为函数参数传递时,函数内部得到的是该变量的副本。这个概念很重要,因为当你修改变量时,实际上只是在修改函数内的副本。

func changeValue(word string) {
     word += "world" 
}

在这段代码中,word是传入值的副本。word会在函数调用结束时不再存在。

func main() {
     say := "hello"
     changeValue(say)
     fmt.Println(say)
}

这会打印出"hello"。将字符串传递并在函数中更改它不起作用,因为在函数内部我们操作的是副本。可以把每次函数调用看作是用复印机复制变量。编辑复印机出来的副本不会影响原始的变量。

指针来救场

Go 中的指针是存储值地址的类型,而不是值本身。所以,指针存储的是像0xc000122020这样的内存地址,而不是直接存储2323在内存中的存储位置就是这个地址。

指针类型可以通过在类型名前加上*来声明。如果我们想创建一个intPtr变量来存储指向int的指针,可以这样做:

var intPtr *int

你不能将int存储在intPtr中;你只能存储int的地址。要获取一个现有int的地址,可以在表示int的变量前使用&符号。

让我们将intPtr赋值为之前x变量的地址:

intPtr = &x
intPtr now stores 0xc000122020\. 

现在是大问题,这有什么用? 通过指针,我们可以引用内存中的一个值并改变它。我们通过对变量使用*操作符来实现这一点。

我们可以通过解引用指针来查看或更改存储在x中的值。下面是一个示例:

fmt.Println(x)             // Will print 23 
fmt.Println(*intPtr)       // Will print 23, the value at x 
*intPtr = 80               // Changes the value at x to 80 
fmt.Println(x)             // Will print 80 

这在函数之间也同样适用。让我们修改changeValue()使其与指针一起工作:

func changeValue(word *string) {
     // Add "world" to the string pointed to by 'word'
     *word += "world"
}
func main() {
     say := "hello"
     changeValue(&say) // Pass a pointer
     fmt.Println(say) // Prints "helloworld"
}

注意,像 * 这样的操作符被称为 * 表示指针类型,var intPtr *int。当用于变量时,* 表示解引用,fmt.Println(*intPtr)。当用于两个数字之间时,它表示乘法,y := 10 * 2。需要时间去记住在某些上下文中符号的含义。

但是,你不是说每个参数都是副本吗?!

我确实理解了。当你将指针传递给函数时,会创建指针的副本,但副本仍然持有相同的内存地址。因此,它仍然引用相同的内存。这就像是用复印机复制一张藏宝图;副本仍然指向你能找到宝藏的地方。你们中的一些人可能在想,但是地图和切片可以修改它们的值,那怎么回事?

它们是一种特殊类型,叫做 指针包装 类型。指针包装类型隐藏了内部的指针。

不要过于疯狂地使用指针

虽然在我们的例子中我们使用了基础类型的指针,但通常指针用于长生命周期的对象或存储大量数据的地方,因为复制这些数据非常昂贵。Go 的内存模型使用栈/堆模型。内存是为函数/方法调用专门创建的。栈上的分配比在上分配要快得多。

堆分配发生在 Go 中,当一个引用或指针无法被确定仅在函数调用栈中生存时。编译器通过逃逸分析来确定这一点。

通常,将副本通过参数传递到函数中并返回另一个副本,比使用指针要便宜得多。最后,要小心指针的数量。与 C 语言不同,在 Go 中很少看到指向指针的指针,比如 **someType,而且在超过 10 年的 Go 编码经验中,我只见过一次有效的 ***someType 用法。与电影《盗梦空间》不同,实际上没有理由深入下去。

总结这一部分,你已经理解了指针、如何声明指针、如何在代码中使用它们以及你可能应该在哪里使用它们。你将使用它们在长生命周期的对象或存储大量数据的类型中,因为复制这些数据的成本很高。接下来,让我们探索结构体。

了解结构体

stringintfloat64)被归为一组。这个分组在 Go 中就是一个结构体。

声明一个结构体

有两种方法可以声明一个结构体。第一种方法在测试中比较常见,因为它不允许我们重用结构体的定义来创建更多的变量。但是,正如我们稍后在测试中看到的那样,我们在这里也会涵盖它:

var record = struct{
     Name string
     Age int
}{
     Name: "John Doak",
     Age: 100, // Yeah, not publishing the real one
}

在这里,我们创建了一个包含两个字段的结构体:

  • Namestring

  • Ageint

然后我们创建了该结构体的一个实例,并设置了这些字段的值。要访问这些字段,我们可以使用点(.)操作符:

fmt.Printf("%s is %d years old\n", record.Name, record.Age)

这将输出 "John Doak is 100 years old"

声明一次性结构体,像我们这里所做的,是很少见的。当结构体用于在 Go 中创建可重用的自定义类型时,它们变得更加有用。接下来我们来看一下如何实现这一点。

声明自定义类型

到目前为止,我们创建了一个一次性结构体,这通常不是很有用。在我们讨论更常见的做法之前,让我们先讨论一下如何创建自定义类型

到目前为止,我们已经看到了语言中定义的基本类型和指针包装类型,例如:stringboolmapslice。我们可以使用 type 关键字基于这些基本类型创建我们自己的类型。让我们创建一个基于 string 类型的新类型,叫做 CarModel

type CarModel string

CarModel 现在是一个独立的类型,就像 string 一样。虽然 CarModel 是基于 string 类型的,但它是一个独立的类型。你不能用 CarModel 替代 string,反之亦然。

创建一个 CarModel 变量的方式与创建 string 类型变量类似:

var myCar CarModel = "Chevelle"

或者,通过使用类型转换,如下所示:

myCar = CarModel("Chevelle") 

因为 CarModel 是基于 string 的,所以我们可以通过类型转换将 CarModel 转换回 string

myCarAsString := string(myCar)

我们可以基于任何其他类型创建新类型,包括映射、切片和函数。这对于命名或为类型添加自定义方法非常有用(稍后我们会讨论这个问题)。

自定义结构体类型

声明结构体最常见的方式是使用 type 关键字。让我们再次创建那个记录,但这次通过声明一个类型来使它可重用:

type Record struct{
     Name string
     Age int
}
func main() {
     david := Record{Name: "David Justice", Age: 28}
     sarah := Record{Name: "Sarah Murphy", Age: 28}
     fmt.Printf("%+v\n", david)
     fmt.Printf("%+v\n", sarah)
}

通过使用 type,我们创建了一个名为 Record 的新类型,可以重复使用它来创建保存 NameAge 的变量。

注意

与在一行中定义两个相同类型的变量类似,你也可以在 struct 类型中做同样的事,比如 First, Last string

向类型添加方法

方法类似于函数,但它与类型绑定在一起,而不是独立的。例如,我们一直在使用 fmt.Println() 函数。那个函数独立于任何已声明的变量。

方法是绑定到变量上的函数。它只能用于某种类型的变量。让我们创建一个方法,返回我们之前创建的 Record 类型的字符串表示:

type Record struct{
     Name string
     Age int
}
// String returns a csv representing our record.
func (r Record) String() string {
     return fmt.Sprintf("%s,%d", r.Name, r.Age)
}

注意 func (r Record),它将函数作为方法附加到 Record 结构体上。在这个方法内,你可以通过 r.<field> 访问 Record 的字段,例如 r.Namer.Age

这个方法不能在 Record 对象之外使用。以下是一个使用它的示例:

john := Record{Name: "John Doak", Age: 100}
fmt.Println(john.String())

让我们看看如何更改字段的值。

更改字段值

结构体的值可以通过使用变量属性后跟 = 和新值来更改。以下是一个示例:

myRecord.Name = "Peter Griffin"
fmt.Println(myRecord.Name) // Prints: Peter Griffin

重要的是要记住,结构体不是引用类型。如果你将一个表示结构体的变量传递给函数并在函数中更改一个字段,这个字段在外部不会发生变化。以下是一个示例:

func changeName(r Record) {
     r.Name = "Peter"
     fmt.Println("inside changeName: ", r.Name)
}
func main() {
     rec := Record{Name: "John"}
     changeName(rec)
     fmt.Println("main: ", rec.Name)
}

这将输出如下内容:

Inside changeName: Peter 
Main: John

正如我们在 指针 部分所学到的那样,这是因为变量是被复制的,我们在修改的是该副本。对于需要更改字段的结构体类型,我们通常会传递一个指针。让我们再试一次,使用指针:

func changeName(r *Record) {
	r.Name = "Peter"
	fmt.Println("inside changeName: ", r.Name)
}
func main() {
	// Create a pointer to a Record
	rec := &Record{Name: "John"}
	changeName(rec)
	fmt.Println("main: ", rec.Name)
}
Inside changeName: Peter
Main: Peter

这将输出如下内容:

Inside changeName: Peter 
Main: Peter

请注意,. 是一个 魔术 操作符,它适用于 struct*struct

当我声明 rec 变量时,我没有设置 age。未设置的字段会被设为该类型的零值。对于 Age 类型(int),其零值为 0

在方法中更改字段值

与函数不能修改非指针结构体类似,方法也不能修改它。如果我们有一个名为 IncrAge() 的方法,用于将记录中的年龄加一,这将不会达到你想要的效果:

func (r Record) IncrAge() {
     r.Age++
}

上述代码传递了 Record 的副本,将副本的 Age 加一,然后返回。

要实际增加年龄,只需将 Record 变为指针,如下所示:

func (r *Record) IncrAge() {
     r.Age++
}

这样就能按预期工作。

提示

这里有一条基本规则,能帮助你避免一些常见问题,尤其是当你刚开始学习这门语言时。如果 struct 类型应该是指针类型,那么所有的方法都应该是指针方法;如果不应该是指针类型,那么所有方法都应为非指针方法。不要混合使用。

构造函数

在许多编程语言中,构造函数是特别声明的方法或语法,用于初始化对象的字段,并有时会执行一些内部方法作为设置过程。Go 并没有提供专门的构造代码,而是使用简单的函数,通过 构造函数模式 来实现。

构造函数通常被命名为 New()New[Type](),当声明公共构造函数时使用。如果包中没有其他类型(并且未来可能也不会有),使用 New()

如果我们想要创建一个构造函数,用来生成前面部分的 Record,它可能看起来像这样:

func NewRecord(name string, age int) (*Record, error) {
     if name == "" {
          return nil, fmt.Errorf("name cannot be the empty string")
     }
     if age <= 0 {
          return nil, fmt.Errorf("age cannot be <= 0")
     }
     return &Record{Name: name, Age: age}, nil
}

这个构造函数接受 nameage 参数,并返回一个指向 Record 的指针,且这些字段已被设置。如果我们为这些字段传递无效值,它将返回指针的零值(nil)和一个错误。使用这个构造函数的方式如下:

     rec, err := NewRecord("John Doak", 100)
     if err != nil {
          return err
     }

不用担心这个错误,我们将在本书的后续部分讨论它。

到现在为止,你已经学习了如何使用 struct,Go 的基础对象类型。这包括创建结构体、创建自定义结构体、添加方法、修改字段值以及创建构造函数。接下来,让我们看看如何使用 Go 接口来抽象类型。

理解 Go 接口

Go 提供了一种名为 interface 的类型,用于存储声明了一组方法的任何值。实现该接口的值必须声明并实现这一组方法。该值还可以有接口类型声明之外的其他方法。

如果你是第一次接触接口,理解它们可能会有些混乱。因此,我们将一步步进行讲解。

定义一个接口类型

接口最常见的定义方式是使用我们在之前结构体部分讨论过的type关键字。以下定义了一个返回表示数据的字符串的接口:

type Stringer interface {
          String() string
}

注意

Stringer是标准库fmt包中定义的一个真实类型。实现了Stringer的类型将在传递给fmt包中的print函数时调用它们的String()方法。不要让相似的名字让你困惑;Stringer是接口类型的名字,它定义了一个名为String()的方法(大写字母区分于小写的string类型)。该方法返回一个string类型,应该提供数据的某种人类可读表示。

现在,我们有了一个新类型叫做Stringer。任何具有String() string方法的变量都可以存储在Stringer类型的变量中。以下是一个示例:

type Person struct {
     First, Last string
}
func (p Person) String() string {
     return fmt.Sprintf("%s,%s", p.Last, p.First)
}

Person代表一个人的记录,包括名字和姓氏。我们为其定义了String() string方法,因此Person实现了Stringer接口:

type StrList []string
func (s StrList) String() string {
     return strings.Join(s, ",")
}

StrList是一个字符串切片。它也实现了Stringer接口。这里使用的strings.Join()函数接受一个字符串切片,并将切片中的每个条目通过逗号连接成一个单一的字符串:

// PrintStringer prints the value of a Stringer to stdout.
func PrintStringer(s Stringer) {
     fmt.Println(s.String())
}

PrintStringer()允许我们打印任何实现了Stringer接口类型的String()方法的输出。我们上面创建的两个类型都实现了Stringer

让我们看看实际效果:

func main() { 
    john := Person{First: "John", Last: "Doak"} 
    var nameList Stringer = StrList{"David", "Sarah"} 
    PrintStringer(john)     // Prints: Doak,John 
    PrintStringer(nameList) // Prints: David,Sarah 
} 

如果没有接口,我们将不得不为每个我们想打印的类型编写一个单独的Print[Type]函数。接口使我们能够传递能够执行其方法中定义的常见操作的值。

接口的重要事项

关于接口,首先要注意的是,值必须实现接口中定义的每个方法。你的值可以有接口中未定义的方法,但反过来则不行。

新手 Go 开发者常遇到的另一个问题是,一旦类型存储在接口中,你就无法访问其字段,或者接口中未定义的任何方法。

空接口——Go 的通用值

让我们定义一个空接口变量:var i interface{}i是一个没有定义任何方法的接口。那么,您可以将什么存储在其中呢?

没错,你可以存储任何东西

interface{}是 Go 的通用值容器,可以用来将任何值传递给函数,然后再弄清楚它是什么以及如何处理它。让我们将一些东西放入i

i = 3
i = "hello world"
i = 3.4
i = Person{First: "John"}

这一切都是合法的,因为这些值的类型都定义了接口所定义的所有方法(但接口没有方法)。这使我们能够在通用容器中传递值。这实际上是fmt.Printf()fmt.Println()的工作方式。以下是它们在fmt包中的定义:

func Println(a ...interface{}) (n int, err error)
func Printf(format string, a ...interface{}) (n int, err error)

然而,由于接口没有定义任何方法,i在这种形式下并不有用。所以,这非常适合传递值,但不适合使用它们。

关于 1.18 版本中的interface{}的注意事项:

Go 1.18 引入了一个别名,替代了空的 interface{},称为 any。Go 标准库现在使用 any 代替 interface{}。然而,1.18 之前的所有包仍然使用 interface{}。两者是等价的,可以互换使用。

类型断言

接口可以通过 断言 将其值转换为另一个接口类型或其原始类型。这与类型转换不同,后者是将类型从一种类型转换为另一种类型。在这种情况下,我们是在说 它已经是这种类型了

interface{} 值转换为我们可以操作的值。

这是两种常见方式中的第一种,使用 if 语法,如下所示:

if v, ok := i.(string); ok {
     fmt.Println(v)
}

i.(string) 是在断言 i 是一个 string 类型的值。如果不是,ok == false。如果 ok == true,那么 v 将是 string 类型的值。

更常见的方式是使用 switch 语句和另一个 type 关键字的用法:

switch v := i.(type) {
case int:
     fmt.Printf("i was %d\n", i)
case string:
     fmt.Printf("i was %s\n", i)
case float:
     fmt.Printf("i was %v\n", i)
case Person, *Person:
     fmt.Printf("i was %v\n", i)
default:
     // %T will print i's underlying type out
     fmt.Printf("i was an unsupported type %T\n", i)
}

我们的 default 语句在没有匹配其他任何 case 时,会打印出 i 的底层类型。%T 用于打印类型信息。

在这一部分,我们学习了 Go 的 interface 类型,了解了它如何用于提供类型抽象,并将接口转换为其具体类型以便使用。

总结

在这一章中,你已经学习了 Go 语言的基础知识。这包括变量类型、函数、循环、方法、指针和接口。本章所学的技能为接下来深入探索 Go 语言的高级特性奠定了基础。

接下来,我们将研究 Go 语言的核心功能,例如错误处理、使用并发以及 Go 的测试框架。

第二章:Go 语言基础

在上一章,我们介绍了 Go 语言的基础知识。虽然与其他语言相比,某些语法是新的,但大多数概念对于来自其他语言的程序员来说是熟悉的。

这并不是说 Go 使用这些概念的方式不导致更容易阅读和推理的代码——只是大多数内容与其他语言并无显著不同。

在本章中,我们将讨论使 Go 与其他语言不同的关键部分,从 Go 更具实用性的错误处理,到其核心并发概念 goroutine,再到 Go 语言的最新特性:泛型。

以下是将要讨论的主要主题:

  • 在 Go 中处理错误

  • 利用 Go 常量

  • 使用 deferpanicrecover

  • 利用 goroutine 实现并发

  • 理解 Go 的 Context 类型

  • 利用 Go 的测试框架

  • 泛型——新兴的技术

现在,让我们整理一下基本要点,帮助你顺利开始!

在 Go 中处理错误

许多开发者来自于使用 异常 处理 错误 的语言。Go 采用了不同的方法,将错误视为与其他数据类型一样的对象。这避免了基于异常的模型常见问题,例如异常从栈中逃逸的问题。

Go 语言有一个内建的错误类型,叫做 errorerror 基于 interface 类型,具有以下定义:

type error interface {
     Error() string
}

现在,让我们来看看如何创建一个错误。

创建一个错误

创建错误的最常见方法是使用 errors 包的 New() 方法或 fmt 包的 Errorf() 方法。当你不需要变量替换时,使用 errors.New(),当你需要变量替换时,使用 fmt.Errorf()。你可以在以下代码片段中看到这两种方法:

err := errors.New("this is an error")
err := fmt.Errorf("user %s had an error: %s", user, msg)

在前面的示例中,err 将是 error 类型。

使用错误

使用错误的最常见方式是将其作为函数或方法调用的最后一个返回值。调用者可以测试返回的错误是否为 nil,如果是,表示没有错误。

假设我们想要编写一个除法函数,并且希望检测除数是否为零。如果是这样,我们希望返回一个错误,因为计算机无法将一个数除以零。代码可能如下所示:

func Divide(num int, div int) (int, error) {
	if div == 0 {
		// We return the zero value of int (0) and an error.
		return 0, errors.New("cannot divide by 0")
	}
	return num / div, nil
}
func main() {
	divideBy := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	for _, div := range divideBy {
		res, err := Divide(100, div)
		if err != nil {
			fmt.Printf("100 by %d error: %s\n", div, err)
			continue
		}
		fmt.Printf("100 divided by %d = %d\n", div, res)
	}
}

上面的示例使用了 Go 语言的多值返回能力,返回了两个值:结果错误

在我们的 main 包中,现在可以进行除法运算并检查返回的 error 类型,看看它是否为 nil。如果是,我们知道发生了错误,应该忽略返回值。如果不是,我们知道操作已成功完成。

创建命名错误

有时,你可能希望创建表示特定类型错误的错误——比如网络错误与参数错误。这可以通过使用 var 关键字和 errors.New()fmt.Errorf() 来创建特定类型的错误,以下是示例代码:

var (
     ErrNetwork = errors.New("network error")
     ErrInput = errors.New("input error")
)

我们可以使用errors包的Is()函数来检测错误类型,并在ErrNetwork上重试,而不在其他错误上重试,如下所示:

// The loop is for retrying if we have an ErrNetwork.
for {
     err := someFunc("data")
     if err == nil {
          // Success so exit the loop
          break
     }
     if errors.Is(err, ErrNetwork) {
          log.Println("recoverable network error")
          time.Sleep(1 * time.Second)
          continue
     }
     log.Println("unrecoverable error")
     break // exit loop, as retrying is useless
}

someFunc()在此未定义。你可以在此查看完整示例:

play.golang.org/p/iPwwwmIBcAG

自定义错误

因为error类型本质上是一个接口,你可以实现自定义错误。以下是我们可以使用的更深入的网络错误:

const (
     UnknownCode = 0
     UnreachableCode = 1
     AuthFailureCode = 2
)
type ErrNetwork struct {
     Code int
     Msg string
}
func (e ErrNetwork) Error() string { 
    return fmt.Sprintf("network error(%d): %s", e.Code, e.msg)
} 

我们现在可以返回一个自定义的网络错误,例如身份验证失败,如下所示:

return ErrNetwork{
     Code: AuthFailureCode, 
     Msg: "user unrecognized",
}

当我们收到一个错误时,我们可以使用errors.As()函数检测它是否是网络错误,如下所示:

var netErr ErrNetwork
if errors.As(err, &netErr) {
     if netErr.Code == AuthFailureCode {
          log.Println("unrecoverable auth failure: ", err)
          break
     }
     log.Println("recoverable error: %s", netErr)
}
log.Println("unrecoverable error: %s", err)
break

你也可以在这里查看:play.golang.org/p/gZ5AK8-o4zA

上述代码检测网络错误是否不可恢复,例如身份验证失败。任何其他网络错误都是可恢复的。如果不是网络错误,则是不可恢复的。

错误包装

很多时候,会有一个错误链,我们希望使用net/http包。在这种情况下,你可能希望将你进行的 REST 调用的相关信息与底层错误一起记录。

我们可以包装错误,这样不仅能包含更具体的信息,还能保留底层的错误,以便以后提取。

我们通过fmt.Errorf()并使用%w来进行变量替换,传入我们的错误类型。假设我们想要从另一个函数restCall()调用someFunc()并添加更多信息,代码示例如下:

func restCall(data) error {
     if err := someFunc(data); err != nil {
          return fmt.Errorf("restCall(%s) had an error: %w", data, err)
     }
     return nil
}

使用restCall()的人可以通过errors.As()检测并提取ErrNetwork,就像我们之前做的那样。以下代码片段提供了这个示例:

for {
     if err := restCall(data); err != nil {
          var netErr ErrNetwork
          if errors.As(err, &netErr) {
               log.Println("network error: ", err)
               time.Sleep(1 * time.Second)
               continue
          }
          log.Println("unrecoverable: ", err)
     }
}

上述代码从被包装的error中提取ErrNetwork。无论错误被包装了多少层,这都能正常工作。

在本节中,你了解了 Go 如何处理错误,Go 的error类型,以及如何创建基本错误、如何创建自定义错误、如何检测特定错误类型以及如何包装/解包错误。因为良好的error处理是可靠软件的基础,所以这些知识对你编写的每一个 Go 程序都将非常有用。

利用 Go 常量

常量提供的是编译时设定的值,且无法更改。与此相对的是变量,它存储可以在运行时设置并且可以改变的值。常量提供的是不能被用户意外修改的类型,并且在软件启动时就分配使用,提供了一些速度优势和比变量声明更安全的特性。

常量可以用来存储以下内容:

  • 布尔类型

  • 字符

  • 整数类型(intint8uint16 等)

  • 浮动类型(float32/float64

  • 复杂数据类型

  • 字符串

在本节中,我们将讨论如何声明常量以及在代码中的常见用法。

声明常量

常量是使用const关键字声明的,如下代码片段所示:

const str = "hello world"
const num = 3
const num64 int64 = 3

常量与变量类型不同,它们有两种形式,如下所示:

  • 未类型化常量

  • 类型化常量

这看起来有点奇怪,因为常量存储的是一个类型化的值。但如果你没有声明确切的类型(如第三个示例中的num64,我们声明它为int64类型),则常量可以用于任何具有相同基础类型或类型家族的类型(例如整数)。这被称为未类型化常量

例如,num可以用来设置int8int16int32int64uint8uint16uint32uint64类型的值。所以,以下代码是有效的:

func add(x, y int8) int8 {
     return x + y
}
func main() {
     fmt.Println(add(num, num))  // Print: 6
}

虽然我们之前没有讨论过,但这就是我们写出像add(3, 3)这样的代码时发生的情况——3实际上是一个未类型化的常量。如果add的签名改为add(x, y int64)add(3, 3)仍然能工作,因为未类型化常量的这一特性。

这适用于任何基于该基本类型的类型。请看下面的示例:

type specialStr string
func printSpecial(str specialStr)
     fmt.Println(string(str))
}
func main() { 
    const constHelloWorld = "hello world" 
    var varHelloWorld = "hello world" 
    printSpecial(varHelloWorld) // Won't compile 
    printSpecial(constHelloWorld) // Will compile 
    printSpecial("hello world") // Will compile 
} 

从前面的代码,你将会得到以下输出:

./prog.go:18:14: cannot use varHelloWorld (type string) as type specialStr in argument to printSpecial

这是因为varHelloWorld是一个string类型,而不是specialStr类型。但未类型化常量的独特特性允许constHelloWorld满足任何基于string的类型。

通过常量进行枚举

许多语言提供了枚举类型,为一些不可更改的值提供可读的名称。这通常用于整数常量,但你可以对任何类型的常量进行此操作。

对于整数常量,特别是有一个特殊的iota关键字,可以用来生成常量。它会为每个在分组中定义的常量递增1,如下代码片段所示:

const (
     a = iota // 0
     b = iota // 1
     d = iota // 2
)

这也可以简化为只让第一个值使用iota,后续的值也会自动设置。该值也可以设置为一个公式,其中iota使用乘法器或其他数学运算。下面是这两个概念的示例:

const (
     a = iota *2 // 0
     b // 2
     d // 4
)

使用iota进行枚举很棒,只要这些值永远不会被存储在磁盘上或传送到本地或远程的其他进程。如果常量的值是由代码中常量的顺序控制的,那么看一下如果我们在第一个示例中插入c会发生什么:

const (
     a = iota // 0
     b        // 1
     c        // 2
     d        // 3
)

注意到d现在的值是3了吗?如果代码需要读取已经写入磁盘并需要重新读取的值,这会导致严重的错误。在这些值可能被其他进程使用的情况下,最佳实践是静态定义枚举值。

在 Go 中,枚举值打印出来时可能难以解释。也许你在使用它们作为错误码,并希望在打印到日志或标准输出stdout)时打印常量的名称。让我们看看如何能得到更好的输出。

打印枚举值

当以枚举名称而不是值来显示一个值时,枚举器的使用会更加简便。当常量是字符串时,如const toyota = "toyota",这可以轻松实现,但对于其他更高效的枚举器类型,如整数,打印该值将仅输出一个数字。

Go 具有内置的代码生成工具。这是一个比我们在这里讨论的内容更广泛的主题(可以在此阅读:blog.golang.org/generate)。

然而,我们将借用链接文档中的内容,展示如何利用它来设置枚举器为字符串值,以便自动打印,方法如下:

//go:generate stringer -type=Pill
type Pill int
const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
    Paracetamol
    Acetaminophen = Paracetamol
)

注意

这需要安装 Go 的 stringer 二进制文件。

//go:generate stringer -type=Pill 是一种特殊的语法,表示当运行 go generate 命令时,它应调用 stringer 工具并传递 -type=Pill 标志,这表示读取我们的包代码并生成一个方法,该方法基于类型 Pill 将常量反转为字符串。这将被放置在名为 pill_string.go 的文件中。

在运行命令之前,fmt.Println(Aspirin) 会打印 1;之后,它会打印 Aspirin

在本节中,你已经学习了常量如何提供不可变的值以供在代码中使用,如何使用它们创建枚举器,以及如何为枚举器生成文本输出以便更好地记录日志。在下一节中,我们将探讨如何使用 deferpanicrecover 方法。

使用 defer、panic 和 recover

现代编程语言需要提供某种方法,在一段代码执行完毕时运行某些操作。这在需要保证文件关闭或解锁互斥锁时非常有用。此外,有时程序需要停止执行并退出。这可能是由于无法访问关键资源、安全问题或其他需求导致的。

我们还需要能够从程序提前退出的情况中恢复,这种情况通常由我们无法控制的代码包引起。本节将涵盖 Go 中每种能力及其相互关系。

defer

defer 关键字允许你在包含 defer 的函数退出时执行一个函数。如果有多个 defer 语句,它们将按从后到前的顺序执行。

这对于调试、解锁互斥锁、递减计数器等非常有用。以下是一个示例:

func printStuff() (value string) {
     defer fmt.Println("exiting")
     defer func() {
          value = "we returned this"
     }()
     fmt.Println("I am printing stuff")
     return ""
}
func main() {
     v := printStuff()
     fmt.Println(v)
}

这将输出以下内容:

I am printing stuff
exiting
we returned this

你也可以通过以下链接查看:

play.golang.org/p/DaoP9M79E_J

如果你运行这个示例,你会注意到我们的 defer 语句在 printStuff() 的其余部分执行完后才执行。我们使用一个延迟的匿名函数来设置我们命名的返回值 value,然后退出。你将在后续章节中看到 defer 被频繁使用。

panic

panic 关键字用于停止程序的执行并退出,同时显示一些文本和堆栈跟踪。

使用 panic 只需调用以下内容:

panic("ran into some bug")

panic 用于程序无法或不应该继续执行时。这可能是因为存在安全问题,或者在启动时无法连接到所需的数据源。

在大多数情况下,用户应返回 error 而不是 panic

一般来说,只有在 main 包中使用 panic

恢复

在某些罕见的情况下,程序可能因为不可预见的 bug 或某个包不必要的 panic 而崩溃。在超过 10 年的 Go 编程经验中,我可以数出我需要从 panic 中恢复的次数。

使用 recover 来防止在 RPC 调用发生 panic 时导致服务器崩溃,并通知调用者问题。

如果像 RPC 框架一样,你需要捕获正在发生的 panic 或防止潜在的 panic,可以结合 defer 关键字使用 recover 关键字。以下是一个示例:

func someFunc() {
     defer func() {
        if r := recover(); r != nil {
            log.Printf("called recover, panic was: %q", r)
        }
    }()
    panic("oh no!!!")
}

你也可以在这里查看:play.golang.org/p/J8RfjOe1dMh

这与其他语言的异常类型有相似之处,但你不应混淆这两者。Go 并不打算让你以这种方式使用 panic/defer/recover——这样做将会在未来给你带来问题。

现在你已经完成了这一部分,你学习了如何延迟执行函数,如何在 main 包中引发 panic,如何从不正常的包中恢复,以及何时应该使用这些功能。让我们进入本章相关的下一个话题:goroutines

使用 goroutines 进行并发

在现代计算机时代,并发 是关键。在 2005 年之前的几年,计算机使用摩尔定律每 18 个月就将单个 中央处理单元CPU)的速度加倍。多 CPU 消费者系统很少见,系统中每个 CPU 只有一个核心。高效利用多核的软体系统稀少。

随着时间的推移,增加单核处理器的速度变得更加昂贵,多核 CPU 已成为常态。每个 CPU 核心支持多个硬件线程,操作系统OS)提供的 OS 线程映射到硬件线程,然后在进程之间共享。

编程语言可以利用这些操作系统线程以 并发 的方式运行函数,而不是像我们在代码中一直做的那样 串行 执行。

启动操作系统线程是一个昂贵的操作,要充分利用线程的时间,需要特别关注你正在做的事情。

Go 在这一点上超越了大多数语言,使用 goroutines。Go 构建了一个运行时调度器,将这些 goroutines 映射到操作系统线程,并切换哪个 goroutine 在哪个线程上运行,以优化 CPU 的使用。

这产生了易于使用且成本低廉的并发,减少了开发人员的心理负担。

启动一个 goroutine

Go 得名于go关键字,它用于启动一个 goroutine。通过在函数调用前加上go,您可以使该函数与其余代码并发执行。以下是一个示例,它创建了 10 个 goroutine,每个打印一个数字:

for i := 0; i < 10; i++ {     
     go fmt.Println(x) // This happens concurrently
}
fmt.Println("hello")
// This is used to prevent the program from exiting
// before our goroutines above run. We will talk about
// this later in the chapter.
select{} 

输出将类似于但不一定与下面显示的顺序相同。...表示后面还有更多数字,但为简洁起见已省略。

Hello
2
0
5
3
...
fatal error: all goroutines are asleep - deadlock!

您可以在这里看到前面的例子:

play.golang.org/p/RBD3yuBA3Gd

注意

在运行后,您还会注意到此处出现错误。这是因为程序没有运行的 goroutines,这意味着程序实际上已经死了。它被 Go 的死锁检测器杀死。我们将在下一章更加优雅地处理这个问题。

运行此代码将以随机顺序打印出数字。为什么是随机的呢?因为一旦并发运行,您不能确定何时调度函数将执行。在任何给定时刻,将会有 0 到 10 个 goroutines 执行fmt.Println(x),还有另一个执行fmt.Println("hello")。没错,main()函数本身就是一个 goroutine。

一旦for循环结束,fmt.Println("hello")将会执行。hello可能会在任何数字之前、中间某处或者所有数字之后打印出来。这是因为它们都像赛马一样同时执行。我们知道所有赛马最终都会到达终点,但我们不知道哪匹会第一个到达。

同步

在进行并发编程时,有一个简单的规则:您可以同时读取一个变量而无需同步,但单个写入者需要同步。

这些是 Go 中最常见的同步方法:

  • 用于在 goroutines 之间交换数据的channel数据类型

  • 来自sync包的MutexRWMutex用于锁定数据访问

  • 用于跟踪访问的sync包中的WaitGroup

这些可以用来防止多个 goroutines 同时读写变量。如果尝试从多个 goroutines 同时读写同一变量,则结果是未定义的(换句话说,这是个坏主意)。

同时读写同一变量被称为数据竞争。Go 有一个数据竞争检测器,本书未涵盖这些问题,可以在这里阅读更多信息:golang.org/doc/articles/race_detector

WaitGroups

WaitGroup是一个同步计数器,其值从 0 开始,只有正值。它通常用于指示某些任务完成后再执行依赖于这些任务的代码。

WaitGroup有几个方法,如下所述:

  • .Add(int): 用于向WaitGroup添加某个数字

  • .Done(): 从WaitGroup减去 1

  • .Wait(): 阻塞,直到WaitGroup为 0

在我们之前关于 goroutine 的部分,我们有一个示例在运行后发生了 panic。这是因为所有的 goroutine 都停止了。我们使用了select语句(本章会介绍)来阻塞直到永远,防止程序在 goroutine 运行之前退出,但我们可以使用WaitGroup来等待 goroutine 结束并优雅地退出。

我们再做一遍,如下所示:

func main() {
     wg := sync.WaitGroup{}
     for i := 0; i < 10; i++ {
          wg.Add(1)
          go func(n int) {
               defer wg.Done()
               fmt.Println(n)
          }(i)
     }
     wg.Wait()
     fmt.Println("All work done")
}

你也可以在这里看到这个示例:play.golang.org/p/cwA3kC-d3F6

本示例使用WaitGroup来跟踪尚未完成的 goroutine 数量。我们在启动 goroutine 之前将wg加 1(不要在 goroutine 内部加)。当 goroutine 退出时,会调用defer语句,从计数器中减去 1。

重要提示

WaitGroup只能有正值。如果在WaitGroup为 0 时调用.Done(),将会引发 panic。由于它们的使用方式,创建者知道任何试图使其达到负值的操作都会是一个需要尽早捕获的严重 bug。

wg.Wait()等待所有的 goroutine 完成,调用defer wg.Done()会使我们的计数器递减直到为 0。此时,Wait()停止阻塞,程序退出main()函数。

重要提示

如果在函数或方法调用中传递WaitGroup,你需要使用wg := &sync.WaitGroup{}指针。否则,每个函数操作的是副本,而不是相同的值。如果WaitGroup在结构体中使用,则结构体或持有WaitGroup的字段必须是指针类型。

通道

通道提供了一种同步原语,其中数据由一个 goroutine 插入到通道中,并由另一个 goroutine 移除。通道可以是有缓冲区的,这意味着它可以在阻塞之前容纳一定量的数据;也可以是无缓冲区的,在这种情况下,发送方和接收方必须同时存在,数据才能在 goroutine 之间传递。

通道的常见类比是水流通过的管道。水被注入管道,然后从另一端流出。管道可以容纳的水量就是缓冲区的大小。在这里,你可以看到使用通道进行 goroutine 通信的示意图:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_02_001.jpg

图 2.1 – 使用通道进行 goroutine 通信

通道用于将数据从一个 goroutine 传递到另一个 goroutine,其中传递数据的 goroutine 停止使用该数据。这允许你将控制从一个 goroutine 传递到另一个 goroutine,每次只允许一个 goroutine 访问。这提供了同步机制。

通道是有类型的,因此只能将该类型的数据发送到通道中。由于通道是类似于mapslice的指针作用域类型,因此我们使用make()来创建它们,如下所示:

ch := make(chan string, 1)

上述语句创建了一个名为ch的通道,该通道持有string类型的数据,且具有大小为 1 的缓冲区。如果省略", 1",则会创建一个无缓冲区的通道。

发送/接收

使用 <- 语法发送到通道。要将 string 类型发送到前述通道,我们可以这样做:ch <- "word"。这试图将 “word” 字符串放入 ch 通道中。如果通道有可用缓冲区,我们继续在此 goroutine 中执行。如果缓冲区已满,则阻塞,直到缓冲区变得可用或在无缓冲通道的情况下,goroutine 尝试从通道中取出。

接收类似于在通道的对面使用相同的语法。试图从通道中拉取值的 goroutine 将执行此操作:str := <-ch。这将通道上的下一个值分配给 str 变量。

更常见的情况是在接收变量时使用 for range 语法。这使我们可以从通道中取出所有值。使用我们前述的通道的示例可能如下所示:

for val := range ch { // Acts like a <-ch
     fmt.Println(val)
}

通道可以关闭,这样将不会再向其发送数据。这是使用 close 关键字完成的。要关闭前述通道,我们可以执行 close(ch)。这应该 始终 由发送方执行。关闭通道将导致 for range 循环在通道上的所有值都被移除后退出。

让我们使用通道从一个 goroutine 发送单词到另一个 goroutine,如下所示:

func main() { 
    ch := make(chan string, 1) 
    go func() { 
        for _, word := range []string{"hello", "world"} { 
            ch <- word
            close(ch) 
        } 
    }() 
    for word := range ch { 
        fmt.Println(word) 
    } 
} 

您还可以在此处看到前述示例:

go.dev/play/p/9km80Jz6f26

重要提示

在通道关闭后,向通道发送值将导致 panic。

从关闭的通道接收将返回通道持有类型的零值。

通道可以是 nil。从 nil 通道发送或接收可能会永久阻塞。开发人员常常忘记在结构体中初始化通道,这是一个常见的 bug。

select 语句

select 语句类似于 switch 语句,但专注于监听多个通道。这使我们能够同时接收和处理多个输入。

下面的示例将监听几个通道,并在收到其中一个通道的值时执行 case 语句。在示例 case 中,我们启动一个 goroutine 来处理值,以便我们可以继续执行我们的循环以获取下一个值。如果通道上没有值,则会阻塞直到有值。如果多个通道上有值,则 select 使用伪随机方法选择要执行的 case:

for {
     select {
     case v := <-inCh1:
          go fmt.Println("received(inCh1): ", v)
     case v := <-inCh2:
          go fmt.Println("received(inCh2): ", v)
     }
}

使用 select 语句时,有时我们只想检查通道上是否有值,如果没有,我们希望继续执行。在这些情况下,我们可以使用 default 语句。如果没有其他 case 语句可以执行(与以前等待通道数据无限期的行为相反),则 default 会执行。您可以在以下代码片段中看到此示例:

select {
case s := <-ch:
     fmt.Printf("had a string(%s) on the channel\n", s)
default:
     fmt.Println("channel was empty")
}

select 还有一个我们之前见过但没有解释的用法。select{} 没有 case 语句和 default 语句,因此它会永远阻塞。这通常用于希望永远运行的服务器,防止 main() 函数退出,从而停止程序的执行。

通道作为事件信号

通道的一个常见用法是用于向另一个 goroutine 发送信号。通常,这是指示退出循环或其他某些执行的信号。

在之前的 select 示例中,我们使用了 for 循环,循环将永远继续下去,但我们可以使用通道来发出退出信号,如下所示:

func printWords(in1, in2 chan string, exit chan struct{}, wg *sync.WaitGroup) {
     defer wg.Done()
     for {
          select{
          case <-exit:
               fmt.Println("exiting")
               return
          case str := <-in1:
               fmt.Println("in1: ", str)
          case str := <-in2:
               fmt.Println("in2: ", str)
          }
     }
}

printWords() 从三个通道读取输入。如果输入来自 in1in2,它会打印通道名称和发送的字符串。如果是 exit 通道,它会打印退出信息并返回。当返回时,wg 将调用 .Done(),使其值减 1:

func main() { 
    in1 := make(chan string) 
    in2 := make(chan string) 
    wg := &sync.WaitGroup{} 
    exit := make(chan struct{}) 
    wg.Add(1) 
    go printWords(in1, in2, exit, wg) 
    in1 <- "hello" 
    in2 <- "world" 
    close(exit) 

    wg.Wait() 
} 

在这里,我们创建了 printWords() 所需的所有通道,并将 printWords 放入 goroutine 中执行。然后,我们通过输入通道发送输入,一旦输入完成,我们关闭 exit 通道以表明没有更多输入需要传递给 printWordswg.Wait() 调用会阻止 main()printWords 退出之前退出。

输出如下:

in1:  hello
in2:  world
exiting

你还可以通过以下链接查看前面的示例:

play.golang.org/p/go7Klf5JNQn

在这个示例中,exit 用于向 printWords() 发送信号,告诉它我们希望退出 for 循环。这得以实现是因为在关闭的通道上接收会返回该通道持有类型的零值。我们使用一个空的 struct{} 实例,因为它不占用内存。我们不将返回值存储在变量中,因为重要的是通道关闭时的信号。

Mutexes

一个名为 Mutexsync 包。

这用于保护一个变量或一组变量,防止它们被多个 goroutine 同时访问。记住——如果一个 goroutine 尝试在另一个 goroutine 正在读取或写入同一个值时进行写入,变量必须通过同步原语来保护。

在以下示例中,我们将启动 10 个 goroutine 来向 sum 值添加数字。由于我们在多个 goroutine 中进行读写操作,必须保护 sum 值:

type sum struct {
     mu  sync.Mutex
     sum int
}
func (s *sum) get() int {
     s.mu.Lock()
     defer s.mu.Unlock()
     return s.sum
}
func (s *sum) add(n int) {
     s.mu.Lock()
     defer s.mu.Unlock()
     s.sum += n
}
func main() {
     mySum := &sum{}
     wg := sync.WaitGroup{}
     for i := 0; i < 100; i++ {
          wg.Add(1)
          go func(x int) {
               defer wg.Done()
               mySum.add(x)
          }(i)
     }
     wg.Wait()
     fmt.Println("final sum: ", mySum.get())
}

你还可以通过以下链接查看此示例:

play.golang.org/p/mXUk8PCzBI7

这段代码使用了一个名为 mu 的 Mutex,它是 sum 结构体的一部分,用于控制对 get()add() 方法的访问。由于每次加锁,因此只有一个方法可以同时执行。我们使用 defer 语句在函数退出时解锁 Mutex。这可以防止在函数变长时忘记解锁 Mutex。

RWMutex

sync.Mutex 一起使用的是 sync.RWMutex。它通过提供读写锁来区分自己。可以同时持有任意数量的 mu.RLock() 读锁,但只有一个 mu.Lock() 写锁,且必须等待所有现有的读锁完成(新的 Rlock() 尝试会被阻塞),然后为写入者提供独占访问权限。

当并发读取量较大且写入操作不频繁时,这种方式证明更为高效。然而,标准的 Mutex 在通用情况下更为高效,因为它的实现更加简单。

在本节中,你已经掌握了使用 goroutine 进行并发操作的基本技能,了解了什么是同步以及何时需要使用同步,还了解了 Go 的各种同步和信号传递方法。让我们深入理解另一种类型,称为 context

理解 Go 的 Context 类型

Go 提供了一个名为 context 的包,具有以下两个主要用途:

  • 在某些事件(例如超时)发生后取消一连串的函数调用

  • 在一系列函数调用中传递信息(例如用户信息)

Context 对象通常在 main() 函数中创建,或者在某些请求(如 RPC)被处理时创建(例如从我们的后台 Context 对象中创建 Context 对象,如下所示)。

import "context" 
func main() { 
     ctx := context.Background()
}

context 包和 Context 类型是一个高级主题,但我想在这里介绍它,因为你会在 Go 生态系统中看到它的使用。

使用 Context 信号来表示超时

Context 通常用于传递计时器状态或终止等待条件——例如,当程序等待网络响应时。

假设我们想要调用一个函数来获取一些数据,但我们不希望等待超过 5 秒钟才能完成调用。我们可以通过 Context 来传达这一信号,如下所示:

ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
data, err := GatherData(ctx, args)
cancel()
if err != nil {
     return err
}

context.WithTimeout() 创建一个新的 Context,它将在 5 秒后自动取消,并且创建一个可以取消该 Context 的函数(context.CancelFunc)。

每个 Context 都是从另一个 Context 派生出来的。在这里,我们从 context.Background() 派生我们的 ctx 对象。context.Background() 是我们的父 Context。新的 context 对象可以从 ctx 派生,形成一个链条,这些新的 Context 对象可以有不同的超时时间。

直接通过 cancel() 或通过超时或截止日期取消 Context 会导致该 Context 及其子 Context 被一起取消。

上面的代码执行了以下操作:

  • 创建一个在 5 秒后取消的 Context

  • 调用 GatherData() 并传递 Context

  • 一旦调用完成,如果 Context 还没有被取消,我们就会取消它。

现在,我们需要设置 GatherData() 以响应我们的 Context 取消请求。

在接收时遵守上下文

如果我们正在执行 GatherData() 函数,我们需要遵守这个上下文。可以通过几种方式来做到这一点,最基本的是调用 ctx.Err(),如下所示:

func GatherData(ctx context.Context, args Args) ([]file, error) { 
    if ctx.Err() != nil { 
        return nil, err 
    } 
    localCtx, localCancel := context.WithTimeout(ctx, 2 * time.Second) 
    local, err := getFilesLocal(localCtx, args.local) 
    localCancel() 
    if err != nil { 
        return nil, err 
    } 
    remoteCtx, remoteCancel := context.WithTimeout(ctx, 3 * time.Second) 
    remote, err := getFilesRemote(remoteCtx, args.remote) 
    remoteCancel() 
    if err != nil { 
        return nil, err 
    } 
    return append(local, remote), nil 
} 

GatherData()检查ctx.Err()的值,看是否返回错误。如果是的话,我们知道Context已经被取消,直接返回即可。

在这个例子中,我们派生了两个新的Context对象,它们共享ctx的父级。如果ctx被取消,localCtxremoteCtx也会被取消。取消localCtxremoteCtx不会影响ctx。在大多数情况下,传递ctx而不是派生新的Context对象是常见做法,但我们希望展示如何派生新的Context对象。

Context还支持.Done()方法,如果你需要在select语句中检查取消状态,可以使用.Done().Done()返回一个通道,如果该通道关闭,则表示已取消。使用它非常简单,如下所示:

select {
case <-ctx.Done():
     return ctx.Err()
case data := <-ch:
     return date, nil
}

现在我们已经展示了如何将Context添加到你的函数中,让我们来谈谈它是如何在标准库中工作的,以及为什么它与我们展示的例子不同。

标准库中的Context

context包是在Go 1.7中新增的,远晚于 Go 标准库的引入。不幸的是,这意味着它不得不被“黑客”加入标准库包中,以避免破坏 Go 1.0 的兼容性承诺。

这是 Go 语言中新增的一个特性,但它也带来了一些丑陋的部分。之前我们向你展示了如何使用Context,它应该作为函数的第一个参数ctx传入。然而,标准库并未按此方式实现。

在标准库中使用Context时,你会看到一个常见的模式,那就是通过方法将其添加进来。这里有一个例子,展示了如何使用Contexthttp.Client来获取www.golang.org并打印到屏幕上:

client := &http.Client{}
req, err := http.NewRequest("GET", "http://www.golang.org", nil)
if err != nil {
        fmt.Println("error: ", err)
        return
}
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
// Attach it to our request.
req = req.WithContext(ctx)
// Get our resp.
resp, err := client.Do(req)
cancel() 
if err != nil {
        fmt.Println("error: ", err)
        return
}
// Print the page to stdout
io.Copy(os.Stdout, resp.Body)

在这段代码中,我们做了以下操作:

  • 创建一个HTTP客户端

  • 创建一个*http.Request (req)以获取www.golang.org页面。

  • 创建Contextctx)和CancelFunccancel),其中Context会在 3 秒后被取消。

  • ctx附加到req,以防止*http.Request超时超过 3 秒。

  • 使用cancel()来取消Context的内部 goroutine,该 goroutine 在client.Do()调用完成后追踪超时。

到目前为止,我们已经讨论了如何使用Context进行取消操作。现在让我们谈谈Context的另一个用途——在调用链中传递值。

Context传递值

Context的另一个主要用途是传递值,目的是传递那些在每次调用时都有用的值,而非作为通用存储。

这两种情况是传递值给Context的最佳用途:

  • 用户发起调用时的安全信息。

  • OpenTelemetry中使用的数据类型等遥测信息。

在安全信息的情况下,你正在通知系统用户是谁,可能是通过OpenID ConnectOIDC)信息。这使得调用栈能够进行授权检查。

对于遥测,这允许服务记录与特定调用相关的信息,用于跟踪函数执行时间、数据库延迟、输入和错误。这可以调节用于调试服务问题。我们将在后续章节讨论遥测。

Context 传递一个值需要小心。存储在 Context 中的值是键值对,为了防止多个包之间的键被覆盖,我们需要创建自己的自定义键类型,该类型只能由我们的包实现。通过这种方式,不同包的键将具有不同的类型。实现此功能的代码如下所示:

type key int
const claimsKey key = 0 
func NewContext(ctx context.Context, claims Claims)
context.Context {
    return context.WithValue(ctx, claimsKey, claims)
}
func ClaimsFromContext(ctx context.Context) (Claims, bool) 
{
    // ctx.Value returns nil if ctx has no value for the key;
    // the Claims type assertion returns ok=false for nil.
    claims, ok := ctx.Value(userIPKey).(Claims)
    return claims, ok
}

这段代码执行以下操作:

  • 定义一个名为 key 的私有类型,防止其他包实现它

  • 定义一个类型为 keyclaimsKey 常量。它作为存储 OIDC IDToken 声明的值的键

  • NewContext() 提供一个函数,将 Claim 附加到我们的 Context

  • ClaimsFromContext() 提供一个函数,从 Context 中提取 Claims 并指示是否找到了 Claims

上面的代码可能存在于一个安全包中,因为 Claims 代表我们已验证的用户数据。NewContext() 允许我们在某些中间件中将此信息添加到上下文中,而 ClaimsFromContext() 允许我们在调用链中需要的地方提取该信息。

最佳实践

我建议所有公共函数和方法都应有一个初始参数 ctx context.Context。这样可以让你为公共函数/方法/接口添加未来兼容性,如果将来需要添加 Context 提供的功能,即使现在没有使用它,也能轻松做到。

重要提示

未来兼容方法/函数/接口是指添加当前未使用的参数和返回值,以防止将来某个时间点破坏它们(和用户)——例如,为一个当前无法返回错误的构造函数添加一个返回的 error,但将来可能会返回。

也许你不需要处理取消(执行过快或无法取消),但添加遥测功能可能在以后会派上用场。

在本节中,你了解了 Go 的 Context 对象及其如何用于发出取消信号并通过调用堆栈传递值。你将会在许多第三方包中看到它的使用。该章节的最后一部分将讨论 Go 的测试包。让我们立即深入探讨。

使用 Go 的测试框架

测试 是任何编程语言中最重要且最不受欢迎的部分之一。测试能让开发者知道某些功能是否按预期工作。我无法计数多少次编写单元测试证明某个函数或方法没有按预期工作。这为我节省了无数的调试时间。

为此,测试需要具备以下特性:

  • 易于编写

  • 执行速度快

  • 简单重构

  • 容易理解

为了满足这些需求,Go 通过以下方式处理测试:

  • 将测试拆分到独立的文件中

  • 提供一个简单的 testing

  • 使用一种名为 表驱动测试TDTs)的测试方法论

在本节中,我们将介绍如何编写基本测试、Go 的标准 TDT 方法论、通过接口创建虚拟对象,以及—最后—我使用过的一些第三方包和一些流行但不一定推荐的包。

创建一个基本的测试文件

Go 测试包含在以 _test.go 结尾的包文件中。这些文件具有相同的包名,你可以根据需要包含任意数量的测试文件。通常的规则是,每个你想要测试的包文件写一个测试文件,以确保一一对应,便于清晰。

每个测试在测试文件中都是一个函数,函数名以 Test 为前缀,并且有一个参数 t *testing.T,没有返回值。代码如下所示:

func TestFuncName(t *testing.T) {
}

t 是由 go test 命令传递的,提供了执行测试所需的工具。常用的方法列举如下:

  • t.Error()

  • t.Errorf()

  • t.Fatalf()

  • t.Log()

  • t.Logf()

当测试执行时,如果没有调用 panic/Error/Errorf/Fatal/Fatalf,测试会被视为通过。如果调用了其中任何一个,测试就会失败。使用 Error/Errorf,测试会继续执行并积累这些错误信息。使用 Fatal/Fatalf,测试会立即失败。

Log()/Logf() 调用是信息性的,只有在测试失败时或传递了其他标志时才会显示。

创建一个简单的测试

借鉴 golang.org 的教程 (golang.org/doc/tutorial/add-a-test),让我们为一个名为 Greeter() 的函数创建一个简单的测试,该函数接受一个名字作为参数并返回 "Hello [name]"。代码如下所示:

package greetings 
import ( 
"testing" 
)
func TestGreet(t *testing.T) { 
     name := "Bob"
     want := "Hello Bob"
     got, err := Greet(name)
     if got != want || err != nil {
          t.Fatalf("TestGreet(%s): got %q/%v, want %q/nil", name, got, err, want)
     }
}

你也可以在这里看到这个例子:play.golang.org/p/vjAhW0hfwHq

要运行测试,我们只需在包目录中运行 go test。如果测试成功,我们应该看到以下内容:

=== RUN   TestGreet
--- PASS: TestGreet (0.00s)
PASS

为了展示失败的情况,我将 want 改成了 Hello Sarah,同时保留了名字 Bob,如下所示:

=== RUN   TestGreet
    prog.go:21: TestGreet(Bob): got "Hello Bob"/<nil>, want "Hello Sarah"/nil
--- FAIL: TestGreet (0.00s)
FAIL

包含足够的信息来调试测试非常重要。我喜欢包括以下内容:

  • 测试的名称

  • 如果是表驱动的,执行的表格行的描述

  • 我得到的结果(称为 got

  • 我期望的结果(称为 want

现在,让我们来谈谈 Go 的测试首选风格——TDTs。

表驱动测试(TDT)

对于非常简单的测试,上述方法可以正常工作,但通常,你需要测试一个函数的多种成功和失败类型,例如以下场景:

  • 如果他们传递了一个错误的参数怎么办?

  • 如果网络出现问题并返回错误怎么办?

  • 如果数据不在磁盘上怎么办?

为每个条件编写一个测试会导致测试文件中出现大量变动,变得难以阅读和理解。TDT 来救场!TDT 使用了我们在第一章《Go 语言基础》中讨论过的非命名结构体概念。这是唯一一个常见使用它的地方。

这个概念是创建一个结构体列表,其中每个结构体条目表示我们希望看到的测试条件和结果。我们一次执行一个结构体条目来测试函数。

让我们将之前的测试转换为 TDT。在这种情况下,我们的Greet()函数的反应只有两种预期方式,如下所示:

  • 我们为name传递一个空字符串,导致出现错误。

  • 其他任何情况都将返回"Hello"和名字。

让我们编写一个处理这两种情况的 TDT,如下所示:

func TestGreet(t *testing.T) {
     tests := []struct{
          desc string // What we are testing
          name string // The name we will pass
          want string // What we expect to be returned
          expectErr bool // Do we expect an error
     }{
          {
               desc: "Error: name is an empty string",
               expectErr: true,
               // name and want are "", the zero value for string
          },
          {
               desc: "Success",
               name: "John",
               want: "Hello John",
               // expectErr is set to the zero value, false
          },
     }
     // Executes each test.
     for _, test := range tests {
          got, err := Greet(test.name)
          switch {
          // We did not get an error, but expected one
          case err == nil && test.expectErr:
               t.Errorf("TestGreet(%s): got err == nil, want err != nil", test.desc)
               continue
          // We got an error but did not expect one
          case err != nil && !test.expectErr:
               t.Errorf("TestGreet(%s): got err == %s, want err == nil", test.desc, err)
               continue
          // We got an error we expected, so just go to the next test
          case err != nil:
               continue
          }
          // We did not get the result we expected
          if got != test.want {
               t.Errorf("TestGreet(%s): got result %q, want %q", test.desc, got, test.want)
          }
     }
}

这个示例也可以在以下链接找到:play.golang.org/p/vYWW-GiyT-M

正如你所看到的,TDT 测试比较长,但具有明确的测试参数和清晰的错误输出。

与之前的示例不同,这个测试验证了当name == ""时,我们的错误条件会发生。对于这么简单的情况,使用 TDT 有些过度,但在编写针对 Go 中更复杂函数的测试时,它会成为工具箱中强大的工具。

使用接口创建假对象

测试通常应该是封闭的,也就是说,测试不应该使用位于机器上本地以外的资源。

如果我们正在测试一个客户端与 REST 服务的连接,它不应该实际调用 REST 服务。应该有集成测试来测试与服务的基本连接性,但这些测试应该是小型且少见的,我们在这里不会讨论这些。

为了测试远程资源的行为,我们使用接口创建所谓的假对象。让我们编写一个客户端,通过网络客户端与服务交互,获取用户记录。我们不想测试服务器的逻辑(我们之前测试过的那种逻辑),而是想测试如果 REST 客户端出现错误或从服务端获取到错误记录时会发生什么。

首先,假设我们在一个名为client的包中使用一个Fetch客户端,如下所示:

type Fetch struct{
     // Some internals, like an http.Client
}
func (f *Fetch) Record(name string) (Record, error){
     // Some code to talk to the server
}

我们在名为Greeter()的函数中使用Fetch来获取我们可能会用来更改对某人的响应的信息,如下所示:

func Greeter(name string, fetch *client.Fetch) (string, error) { 
    rec, err := fetch.Record(name) 
    if err != nil { 
        return "", err 
    } 
    if rec.Name != name {
          return "", fmt.Errorf("server returned record for %s, not %s", rec.Name, name)
     }
     if rec.Age < 18 {
          return "Greetings young one", nil
     }
     return fmt.Sprintf("Greetings %s", name), nil
}

由于Fetch是一个与服务通信的具体类型,这很难进行封闭式测试。然而,我们可以将其改为Fetch实现的接口,然后使用假对象。首先,让我们添加接口并更改Greeter的参数,如下所示:

type recorder interface {
     Record(name string) (Record, error)
}
func Greeter(name string, fetch recorder) (string, error) {

现在,我们可以传递一个*client.Fetch实例,或者传递任何其他实现了recorder的东西。让我们创建一个实现了recorder的假对象,能够返回对测试有用的结果,如下所示:

type fakeRecorder struct {
     data Record
     err bool
}
func (f fakeRecorder) Record(name string) (Record, error) {
     if f.err  {
          return "", errors.New("error")
     }
     return f.data, nil
}

现在,让我们将其集成到 TDT 中,像这样:

func TestGreeter(t *testing.T) {
     tests := []struct{
          desc string
          name string
          recorder recorder
          want string
          expectErr bool
     }{
          {
               desc: "Error: recorder had some server error",
               name: "John",
               recorder: fakeRecorder{err: true},
               expectErr: true,
          },
          {
               desc: "Error: server returned wrong name",
               name: "John",
               recorder: fakeRecorder{
                    Record: Record{Name: "Bob", Age: 20},
               },
               expectErr: true,
          },
          {
               desc: "Success",
               name: "John",
               recorder: fakeRecorder{
                    Record: Record{Name: "John", Age: 20},
               },
               want: "Greetings John",
          },
     }
     for _, test := range tests {
          got, err := Greeter(test.name)
          switch {
          case err == nil && test.expectErr:
               t.Errorf("TestGreet(%s): got err == nil, want err != nil", test.desc)
               continue
          case err != nil && !test.expectErr:
               t.Errorf("TestGreet(%s): got err == %s, want err == nil", test.desc, err)
               continue
          case err != nil:
               continue
          }
          if got != test.want {
               t.Errorf("TestGreet(%s): got result %q, want %q", test.desc, got, want)
          }
     }
}

这个示例可以在这里找到:play.golang.org/p/fjj2WrbGlKY

现在我们只是模拟从真实客户端Fetch获取的响应。在使用Greeter()的代码中,他们可以简单地传入真实客户端,而在我们的测试中,我们传入fakeRecorder实例。这让我们能够控制测试环境,确保我们的函数以预期的方式处理每种类型的响应。这个测试缺少一个检查当返回Record实例且Age值设置为< 18时的结果。我们将这个作为练习留给你。

第三方测试包

当我写测试时,几乎只有一个工具是我常用的:pkg.go.dev/github.com/kylelemons/godebug/pretty?utm_source=godoc

pretty允许我轻松地测试两个复杂的结构体/映射/切片是否等价。像这样在测试中使用它非常简单:

if diff := pretty.Compare(want, got); diff != "" {
     t.Errorf("TestSomeFunc(%s): -want/+got:\n%s", diff)
}

这个输出以可读的格式显示了缺失的部分(前面加上-)和接收到的部分(前面加上+)。为了更好地控制比较的内容,包提供了一个可以自定义的Config类型。

这段代码更新不频繁,因为它已经能正常工作,但 Kyle 会回应 bug 请求,因此项目仍然在持续维护。

许多 Go 社区的人使用github.com/stretchr/testify这个包集,特别是assertmock包。

我在这里列出它们是因为它们在 Go 社区中很受欢迎;然而,我会给出以下几点警告:

  • 多年来,Go 中使用断言被认为是不好的实践。

  • Go 中的模拟框架通常有一些非常棘手的边缘情况。

Go 的最初作者认为,使用断言对于该语言来说是一种不好的实践且没有必要。当前的 Go 团队已经放宽了这一立场。Go 中的模拟框架通常大量依赖interface{},并且存在一些尖锐的边缘情况。我发现使用模拟对象会导致测试出一些不重要的行为(例如调用顺序或哪些调用被执行了),而不是测试给定输入是否导致预期输出。这对于代码的更改来说,负担较小且不易出错。

原始的模拟框架(github.com/golang/mock)在 Google 被认为是不安全的,因此其使用受到了限制。

总结这一部分,我们了解了 Go 的testing包,如何使用该包编写测试,TDT 方法论,以及我(John Doak)对第三方测试包的看法。

现在,既然你已经了解了如何进行测试,我们将着眼于 Go 1.18 版本中新增的一个重要特性——泛型。

泛型——新晋的“新秀”

泛型是 Go 1.18 中的一项新特性,它对 Go 的未来可能产生深远影响。泛型提供了一种新的方式,通过引入一个名为type的参数,来表示多种类型,从而使得函数可以处理多种类型的数据。

这与标准的interface{}不同,后者的操作总是在运行时进行,你必须将interface{}转换为具体类型才能进行操作。

泛型是一个新特性,因此我们只能给出一个非常概括的概述。目前,Go 社区和 Go 的开发者们还没有一套经过严格测试的最佳实践。这需要在使用特性时积累经验,而目前我们处于泛型的初期阶段,未来会有更多关于泛型的功能发布。

类型参数

类型参数可以添加到函数或struct类型中,以支持泛型类型。然而,一个关键的陷阱是它们不能用于方法!这是最被请求的特性;然而,它给语言带来了一些挑战,语言的开发者们目前还不确定如何处理这些问题(或者是否能够处理)。

类型参数在函数名后面用括号定义。我们来看一个基本的例子:

func sortIntsI int8 |int16 |int32 |int64 {

这样就创建了一个可以排序任何有符号整数类型的函数。I是类型参数,并且它被限制为括号中列出的类型。|管道符号充当or语句,表示I可以是int8int16类型,依此类推。

一旦I被定义,我们就可以在函数参数中将其用作类型。我们的函数将基于I的切片类型进行操作。需要注意的是,I中的所有值必须是相同的类型;不能是int8int64类型的混合。

让我们来看一下如何用一个简单的冒泡排序实现来演示这个过程,如下所示:

func sortIntsI int8 |int16 |int32 |int64 {
     sorted := false
     for !sorted {
          sorted = true
          for i := range slice[:len(slice)-1] {
               if slice[i] > slice[i+1] {
                    sorted = false
                    slice[i], slice[i+1] = slice[i+1], slice[i]
               }
          }
     }
}

你可以在这里看到这个例子:go.dev/play/p/jly7i9hz0YT

我们现在有一个可以用来排序任何有符号整数类型的函数。如果我们没有泛型,这个函数需要一个interface{}类型的参数,并且需要根据切片类型进行类型转换。然后,我们还需要为每种类型编写相应的处理函数。你可以在这里看到一个例子:go.dev/play/p/lqVUk9GQFPX

另一种选择是使用reflect包中的运行时反射,这种方式较慢且笨重。reflect是一个高级包,包含许多潜在的陷阱,除非绝对必要,否则应该避免使用。这里是这种方法的一个例子:go.dev/play/p/3euBYL9dcsU

正如你所看到的,泛型版本的实现要简单得多,并且可以显著减少代码量。

让我们来看一下如何通过使用类型约束使代码更易读。

使用类型约束

在我们上一个例子中,int8 |int16 |int32 |int64是我们的类型约束。它限制了我们可以用于I值类型参数的类型,但每次都打出这些类型会显得繁琐,因此我们也可以定义命名的类型约束。

这是引入泛型可能会引起混淆的地方。类型约束是使用interface类型来定义的。以下是一个包含我们之前内容的类型约束的例子:

type SignedInt interface {
     int8 |int16 |int32 |int64
}

我们现在可以在之前的代码中使用它,如下所示:

func sortIntsI SignedInt {

这减少了我们需要写的样板代码量。重要的是要注意,SignedInt是类型约束,而不是类型。I是一个已定义的类型参数,充当类型。我经常发现自己写这样的代码:

func sortIntsI SignedInt {

然而,那个语法是不正确的。这里的SignedInt仅仅是约束的定义,而不是一个可以使用的类型。I才是泛型函数中使用的类型。

另一个陷阱是SignedInt只能用于这里定义的确切基本类型。你可能会创建自己的类型,像这样:

type myInt8 int8

如果这样做,你不能将其作为SignedInt类型约束使用。但不用担心——如果我们希望它能够处理基于带符号整数的任何类型,我们可以将其改为以下内容:

type SignedInt interface {
     ~int8 |~int16 |~int32 |~int64
}

~表示我们希望允许基于此类型的任何类型。

现在,让我们看看如何编写我们的排序函数,以处理不仅仅是带符号整数的情况。

我们可以通过约束做得更好

我们在这里做的可以应用于不仅仅是带符号整数。我们可以更改支持的类型,而我们的函数在更大的切片类型集合上仍然可以正常工作。

我们的函数能工作的唯一条件是类型必须能够对共享相同类型的两个变量使用>操作。这就是if slice[i] > slice[i+1]语句能够工作的原因。

截至本文写作时,当前的 Go 版本没有定义一些计划在未来版本中发布的基本类型约束。这个未来的包,可能会叫做constraints,正在这里开发:pkg.go.dev/golang.org/x/exp/constraints

它包括一个像这样的类型约束:

type Ordered interface {
     ~int | ~int8 | ~int16 | ~int32 | ~int64 |
          ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
          ~float32 | ~float64 |
          ~string
}

我们将在这里借用它并更改我们的函数签名,如下所示:

func sortSliceO constraints.Ordered {

现在,我们的函数可以排序任何可以使用><进行比较的切片类型。可以在这里看到它的工作效果:go.dev/play/p/PwrXXLk5rOT

当前的内建约束

Go 当前有两个内建的约束,如下所示:

  • comparable

  • any

comparable包含所有支持==!=操作符的类型。这在编写使用map类型的泛型时特别有用。map类型的关键总是comparable类型。

anyinterface{}的别名。Go 团队已经将 Go 标准库中所有的interface{}引用更改为any。你可以交替使用它们,并且any作为类型约束允许任何类型。

这是一个使用这些约束从map类型中提取所有键的函数示例:

func ExtractMapKeysK comparable, V any []K {
    var keys = make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

这里它在 playground 中运行,试试看吧:go.dev/play/p/h8aKwoTaOLj

让我们看看如果我们进行类型约束,并且通过要求方法来约束一个类型(如标准接口)会发生什么。

带方法的类型约束

类型约束可以像标准接口一样起作用,它可以要求方法附加到类型上。以下是一个例子:

type StringPrinter interface {
     ~string
     Print()
}

这个类型约束只能由基于string的类型满足,并且该类型必须定义了Print()方法。

这里的一个关键要求是我们使用~string而不是string。标准的string类型永远无法拥有Print()方法,因此这个类型约束永远无法满足。

这是一个简单的约束使用示例:

func PrintStringsS StringPrinter {
     for _, s := range slice {
          s.Print()
     }
}

现在,让我们来看看为什么你可能想要向结构体类型添加类型参数。

向结构体类型添加类型参数

之前,我们编写了一个名为SortSlice()的通用函数来排序切片,但它有一些局限性,因为它只能处理符合constraints.Ordered约束的类型的切片。通常,我们可能需要处理包含基于struct的类型的切片——例如,像下面这样一个类型:

type Record struct {
     First, Last string
}

我们的SortSlice()函数无法处理[]Record,因此我们需要做一些不同的处理来应对这种类型的情况。

对于这个例子,我们想使用 Go 内置的sort.Sort()函数。这是一个经过高度优化的排序算法,它根据切片的大小使用多种排序算法。

要使用它,你需要一个实现了sort.Interface类型的类型。该interface类型定义如下:

type Interface interface {
     Len() int
     Less(i, j int) bool
     Swap(i, j int)
}

在 Go 泛型之前,你需要为每个你想排序的类型实现一个适配器类型。例如,下面是一个用来排序[]int的适配器:

type intAdapter struct {
     sl []int
}
func (in intAdapter) Len() int {
     return len(in.sl)
}
func (in intAdapter) Swap(i, j int) {
     in.sl[i], in.sl[j] = in.sl[j], in.sl[i]
}
func (in intAdapter) Less(i, j int) bool {
     return in.sl[i] < in.sl[j]
}

你可以这样使用它:

ints := []int{5, 3, 7, 1}
sort.Sort(intAdapter{ints})

你可以在这里看到它的运行:go.dev/play/p/Yl6Al9ylEhd

然后,你需要对每一个其他的有符号类型或你想排序的其他类型做类似的操作。想象一下,如果要为所有的int8int16int32int64有符号整型做这些操作,你会怎么做?你还需要为所有你想排序的其他类型做同样的事情。

所以,我们想做的是使用泛型来给我们一个单一的适配器类型,这样我们就可以对任何元素类型的切片进行排序。

让我们在结构体上使用type参数,以便创建一个通用适配器,这样我们就可以将任何切片适配到sort.Interface类型,如下所示:

type sortableSlice[T any] struct { 
    slice []T 
    less func(T, T) bool 
}
func (s sortableSlice[T]) Len() int { 
    return len(s.slice) 
}
func (s sortableSlice[T]) Swap(i, j int) { 
    s.slice[i], s.slice[j] = s.slice[j], s.slice[i] 
}
func (s sortableSlice[T]) Less(i, j int) bool { 
    return s.less(s.slice[i], s.slice[j]) 
} 

这与之前的intAdapter非常相似,主要有两个区别,具体如下:

  • 切片元素是一个T类型参数,它可以是任何值。

  • 我们添加了一个less字段,它是一个函数,当调用Less()时执行比较操作。

让我们创建一个能够实现func(T, T) bool的函数,适用于我们的Record类型。这个函数会首先比较姓氏,然后再比较全名。代码如下所示:

func recordLess(a, b Record) bool {
     aCmp := a.Last + a.First
     bCmp := b.Last + b.First
     return aCmp < bCmp
}

最后,我们可以使用sortableSlice来编写一个通用排序函数,利用现有的sort.Sort()函数对我们可以进行比较的任何切片进行排序。以下是我们需要执行的代码:

func SortSliceT any bool) {
     sort.Sort(sortableSlice[T]{slice: slice, less: less})
}

这是它的实际应用:go.dev/play/p/6Gd7DLgVQ_y

你会注意到,当我们创建 sortableSlice 实例时,我们在语法中使用了 [T]。这用于告诉 Go T 将是什么类型,在本例中就是传递给 SortSlice 的泛型 T 类型。如果你尝试删除 [T],你会收到以下信息:

cannot use generic type sortableSlice[T any] without instantiation

我们将在下一节中讨论这个问题。

当然,如果你想在不使用 sort.Sort() 函数的情况下进行泛型排序,

这样做可以更少的复杂性。这里是一个使用泛型的快速排序算法的泛型版本:go.dev/play/p/gvPl9jHtAS4

现在,我们将讨论在 Go 无法推断出泛型函数所使用的类型时,如何调用泛型函数。

调用泛型函数时指定类型

到目前为止,所有泛型案例直到 sortableSlice 函数都允许 Go 编译器推断出将使用的类型,因此也知道如何处理调用该函数。

但是 Go 并不总是能够推断出它需要使用哪种类型。我们可以在上一节中看到,我们告诉 sortableSlice 使用我们定义的 T 泛型类型。

让我们创建一个可以与 SortSlice() 一起使用的函数,用于在类型为 constraints.Ordered 时进行小于比较。代码如下所示:

func orderedCmpO constraints.Ordered bool {
     return a < b
}

有了这个,我们可以调用 SortSlice(),并传入任何包含在 constraints.Ordered 中的类型切片,以及我们的新 orderedCmp 泛型函数来排序切片。

让我们试试看,具体如下:

strings := []string{"hello", "I", "must", "be", "going"}
SortSlice(strings, orderedCmp)

哎呀—Go 似乎无法做到这一点,因为我们收到了以下信息:

cannot use generic function orderedCmp without instantiation

这是因为我们传递的是函数,而不是调用该函数。Go 的推断只会在查看接收到的调用类型时进行。目前它不会在 SortSlice() 内部推断 orderedCmp() 被调用并传递 string 类型。所以,要使用它,我们需要告诉它在调用时将使用哪种类型。

相比之下,SortSlice() 不需要这样做,因为它是直接调用的,并且可以从查看传入的参数 strings 推断出 T 将是 string 类型。

通过使用 [string],我们可以为 orderedCmp 提供更多信息,从而使其工作,具体如下:

SortSlice(strings, orderedCmp[string])

现在它知道我们将比较 string 类型,它已经准备好工作了,正如你在这里看到的:go.dev/play/p/kd6sylV17Jz

如果我们想非常详细地写出来,我们可以做如下操作:

SortSlicestring

现在,让我们来看看一些你在尝试使用泛型时可能会遇到的常见陷阱。

需要注意的陷阱

当你在玩泛型时,有很多陷阱,其中错误信息并不总是很清楚。所以,让我们谈谈其中的一些,以便你能够避免我曾经犯过的错误。

首先是不可行的类型约束。看看你能否在以下代码中找到问题:

type Values interface {
     int8 | int16 | int32 |int64
     string | []byte
}
func PrintV Values {
     fmt.Println(v)
}
func main() {
     Printstring
}

如果你运行这个,你会得到以下结果:

cannot implement Values (empty type set)

这是因为 Values 被错误地定义了。我忘记在 int64 后加上 |。没有这个,约束条件就会说值必须是 int8int16int32int64string[]byte 类型。这是一个不可能的类型,也就是说没有东西能实现它。你可以在这里看到这个问题:go.dev/play/p/Nxsz4HKxdc4

下一个难点是返回实现类型参数的 struct 类型时的实例化问题。以下是一个示例:

type ValueType interface {
     string | bool | int
}
type Value[T ValueType] struct {
     val T
}
func NewT ValueType Value { 
     return Value[T]{val: v}
}
func (v Value[T]) Value() T {
     return v.val
}

尝试编译此代码时,将显示以下消息:

cannot use generic type Value[T ValueType] without instantiation

一段时间内我没有弄清楚问题出在哪里。结果发现,我还需要在返回值上添加 type 参数。下面是修改后的版本:

func NewT ValueType Value[T] {

有了这个修改,一切正常。尝试这个坏掉的版本(go.dev/play/p/EGTr2zd7qZW)并通过前面提到的修改修复它,以便熟悉操作。

我预期在不久的将来,我们的开发工具会提供更好的错误信息和更好的检测功能。

现在我们已经介绍了泛型的基础知识,接下来讨论一下你应该在什么情况下考虑使用泛型。

何时使用泛型

目前唯一的指导原则来自 Go 泛型功能的创造者 Ian Taylor,如下所示:

如果你发现自己在多次编写相同代码,唯一的区别只是代码使用了不同的类型,考虑一下是否可以使用类型参数。

我发现这转化为以下内容:

如果你的函数需要对泛型类型进行 switch 语句,可能应该使用标准接口而不是泛型。

在总结泛型时,我想给你留下一点思考:这是一项新的语言特性,关于它的最佳使用方式还没有定论。我能给出的最佳建议是,在使用这一特性时要保持谨慎。

总结

在这一章中,你已经学习了 Go 语言的核心部分。内容包括错误处理、使用 Go 并发、利用 Go 的测试框架,以及 Go 最新特性泛型的介绍。通过本章学到的技能,对未来的章节至关重要。

现在你应该具备了阅读本书剩余部分中的 Go 代码的能力。此外,本章还给你提供了编写 Go 代码所需的必要技能。我们将利用这些技能操作文件系统中的文件、在远程机器上执行命令,以及构建可以执行各种任务的 RPC 服务。你还将构建聊天机器人以进行 基于聊天的操作ChatOps),并编写软件来扩展 Kubernetes。这些学到的东西是真正的基础。

接下来,我们将介绍如何设置你的 Go 环境,以便在本地机器上编译代码。让我们开始吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值