Go-开发者秘籍-全-
Go 开发者秘籍(全)
原文:
zh.annas-archive.org/md5/ac039830e277e1ecf6e500c7e90f2185
译者:飞龙
前言
Go 语言,凭借其简洁的语法和实用的约定,已经巩固了自己在网络编程、网络服务、数据处理等领域开发者首选语言的地位。本书旨在通过提供最新、实用的解决方案,帮助工程师解决常见的编程挑战。
旅程从基础原则开始,包括针对各种项目类型组织包和结构化代码的有效方法。从那里,本书深入到现实世界的工程挑战,提供网络编程、进程管理、数据库交互、数据处理管道和测试方面的实用解决方案。每一章都展示了工作解决方案和现成的代码片段,适用于顺序和并发编程环境。
利用 Go 语言最新的语言特性——如泛型和结构化日志——本书中的食谱主要依赖于 Go 标准库,确保最小化对第三方包的依赖,并最大化兼容性。
在本书结束时,你将拥有丰富的经过验证的、实用的解决方案,以加速你的 Go 开发之旅,并自信地应对现代软件工程的复杂性。
本书面向对象
本书旨在面向对 Go 语言有基本理解的开发者。更有经验的开发者也可以将其作为参考,提供适用于各种用例的实用示例。
本书涵盖内容
第一章**,项目组织,涵盖了模块、包、源树组织、导入包、模块版本和工作空间。
第二章**,字符串操作,包含如何处理字符串、国际化、编码、正则表达式、解析以及使用模板生成格式化文本的食谱。
第三章**,日期和时间操作,展示了如何正确处理日期、时间和持续时间值,考虑时区,格式化/解析日期和时间值,执行周期性任务,以及安排函数稍后运行。
第四章**,数组、切片和映射操作,介绍了构成许多数据结构的基本容器类型。
第五章**,类型、结构体和接口操作,展示了如何定义新类型,扩展现有类型以共享功能,接口及其用法。特别是,本章包括使用接口的两种方法,即接口作为契约和定义接口的位置。
第六章**,泛型操作,介绍了编写泛型函数和泛型类型的基礎食谱,并附有示例。
第七章**,并发,包括使用 goroutines 和 channels 编写并发程序的基本配方。这里还讨论了使用互斥锁实现互斥。
第八章**,错误与恐慌,展示了如何生成错误、传递错误、处理错误以及在项目中组织错误。它还讨论了如何生成和处理恐慌。
第九章**,上下文包,介绍了 Go 的 Context,这对于控制请求生命周期和在并发程序中传递请求作用域的值非常有用。
第十章**,处理大量数据*,包括在并发环境中使用工作池和并发管道处理大量数据的配方。
第十一章**,处理 JSON,包括编码和解码 JSON、序列化和反序列化简单和复杂数据类型、使用自定义序列化逻辑、编码/解码多态结构以及流式传输 JSON 数据的配方。
第十二章**,进程,展示了如何运行和与外部程序交互、处理环境变量、处理管道以及使用信号实现优雅终止。
第十三章**,网络编程,提供了 TCP 和 UDP 服务器和客户端的配方,处理 TLS、截止日期、HTTP 客户端/服务器、请求多路复用和 HTML 表单。
第十四章**,流式输入/输出*,包括使用读取器和写入器、处理文件和文件系统以及管道的配方。
第十五章**,数据库,展示了如何使用标准库包以安全的方式与 SQL 数据库进行交互。
第十六章**,日志记录,展示了标准库 log 和 slog 包的常见用法。
第十七章**,测试、基准测试和性能分析,提供了编写和运行单元测试、测试 HTTP 服务器、基准测试和性能分析的配方。
为了充分利用这本书
您需要一个最新的 Go 版本(任何高于 1.22 的版本都可以)并与您喜欢的开发环境集成。一些示例程序使用了 Docker。
如果您使用的是这本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将有助于您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Go-Recipes-for-Developers
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“注意InitDB
的大小写。”
代码块设置如下:
ctx:=context.Background()
cancelable, cancel:=context.WithCancel(ctx)
defer cancel()
提示或重要注意事项
看起来像这样。
部分
在本书中,你会发现一些经常出现的标题(准备就绪、如何做…、如何 操作…)。
为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:
准备就绪
本节会告诉你食谱中可以期待什么,并描述如何设置任何软件或食谱所需的任何初步设置。
如何做…
本节包含遵循食谱所需的步骤。
如何操作…
本节通常包含对上一节发生情况的详细解释。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送电子邮件至 customercare@packtpub.com。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata,选择你的书,点击勘误表提交表单链接,并输入详细信息。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果你能提供位置地址或网站名称,请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为本书做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《Go Recipes for Developers》,我们很乐意听到你的想法!请点击此处直接进入本书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制并粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限
按照以下简单步骤获取福利:
- 扫描二维码或访问以下链接
packt.link/free-ebook/978-1-83546-439-7
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱
第一章:项目组织
本章介绍如何开始一个新项目,组织源代码树,以及管理您在开发程序时所需的包。一个良好的项目结构非常重要,因为当其他开发者处理您的项目或尝试使用其中的组件时,他们可以快速轻松地找到他们需要的东西。本章将首先回答您在开始新项目时可能遇到的一些问题。然后,我们将探讨如何使用 Go 包系统,与标准库和第三方包一起工作,并使其他开发者更容易使用您的包。
本章包括以下内容:
-
创建模块
-
创建源代码树
-
构建和运行程序
-
导入第三方包
-
导入特定版本的包
-
使用内部包以减少 API 表面积
-
使用模块的本地副本
-
工作区
-
管理模块的版本
模块和包
首先,关于模块和包的一些简要介绍可能会有所帮助。一个 main
包,构建它将生成一个可执行文件。您可以在不生成二进制文件的情况下运行 main
包(更具体地说,Go 构建系统首先构建包,在临时位置生成二进制文件,然后运行它)。要使用另一个包,您需要导入它。模块有助于组织多个包以及项目内包引用的解析。一个 go.mod
文件,以及该模块内容的校验和将被添加到 go.sum
文件中。模块还有助于您管理程序的版本。
一个包的所有文件都存储在文件系统中的单个目录下。每个包都使用 package
指令声明一个名称,该名称由包中的所有源文件共享。包名称通常与包含文件的目录名称相匹配,但这并不一定。例如,main
包通常不在名为 main/
的目录下。包的目录决定了包的“导入路径”。您使用 import <importPath>
语句将另一个包导入到当前包中。一旦导入了一个包,您就使用该包中声明的名称,通过其包名称(这不一定与目录名称相同)来使用这些名称。
模块名称指向模块内容在互联网上版本控制系统中的存储位置。在撰写本文时,这不是一个硬性要求,因此您实际上可以创建不遵循此约定的模块名称。应避免这样做,以防止未来与构建系统的潜在不兼容性。您的模块名称应该是这些模块包的导入路径的一部分。特别是,那些第一个组件(第一个 /
之前的部分)没有 .
的模块名称是为标准库保留的。
这些概念在 图 1.1 中得到了说明。
图 1.1 – 模块和包
-
在
go.mod
中声明的模块名称是模块可以找到的仓库路径。 -
main.go
中的导入路径定义了导入的包可以找到的位置。Go 构建系统将使用此导入路径定位包,然后通过扫描包路径的父目录来定位包含该包的模块。一旦找到模块,它将被下载到模块缓存中。 -
在导入模块中定义的包名是你用来访问该包符号的包名。这可以与导入路径的最后一部分不同。在我们的例子中,包名是
example
,但这个包的导入路径是github.com/bserdar/go-recipes-module
。 -
Example
函数位于example
包中。 -
example
包还导入了同一模块中包含的另一个包。构建系统将识别这个包是同一模块的一部分,并使用下载的模块版本解决引用。
技术要求
你需要在你的计算机上安装一个较新的 Go 版本来构建和运行本章中的示例。本书中的示例使用的是Go 版本 1.22。本章的代码可以在github.com/PacktPublishing/Go-Recipes-for-Developers/tree/main/src/chp1
找到。
创建模块
当你开始一个新的项目时,首先要做的是为它创建一个模块。模块是 Go 管理依赖的方式。
如何操作...
-
创建一个目录来存储新的模块。
-
在那个目录下,使用
go mod init <moduleName>
创建新的模块。go.mod
文件标记了模块的根目录。除非该目录也有go.mod
文件,否则此目录下的任何包都将成为该模块的一部分。尽管构建系统支持嵌套模块,但从中获得的收益并不多。 -
要导入同一模块中的包,使用
moduleName/packagePath
。当moduleName
与模块在互联网上的位置相同时,你引用的内容不会有歧义。 -
对于模块下的包,模块的根是包含
go.mod
文件的最近父目录。模块根目录下的所有其他包引用都将在这个目录树中进行查找。 -
首先创建一个目录来存储项目文件。你的当前目录可以在文件系统的任何位置。我见过人们使用一个常见的目录来存储他们的工作,例如
$HOME/projects
(或在 Windows 中为\user\myUser\projects
)。你也可以选择使用类似于模块名称的目录结构,例如$HOME/github.com/mycompany/mymodule
(或在 Windows 中为\user\myUser\github.com\mycompany\mymodule
)。根据你的操作系统,你可能找到一个更合适的位置。
警告
不要在你的 Go 安装目录的 src/
下工作。那是 Go 标准库的源代码。
小贴士
你不应该有一个环境变量 GOPATH
;如果你必须保留它,不要在其下工作。这个变量曾用于旧的操作模式(Go 版本 <1.13),现在已被 Go 模块系统所取代。
在本章中,我们将使用一个简单的程序,该程序在网页浏览器中显示表单并将输入的信息存储在数据库中。
在创建模块目录后,使用 go mod init
。以下命令将在 projects
目录下创建一个 webform
目录,并在其中初始化一个 Go 模块:
$ cd projects
$ mkdir webform
$ go mod init github.com/examplecompany/webform
这将在该目录下创建一个 go.mod
文件,其外观如下:
module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform
go 1.21.0
使用一个描述你的模块位置的名称。始终使用类似 <host>.<domain>/location/to/module
的 URL 结构(例如,github.com/bserdar/jsonom
)。特别是,模块名称的第一个组成部分应该有一个点(.
)(Go 构建系统会检查这一点)。
因此,即使你可以将模块命名为 webform
或 mywork/webform
,也不要这样做。然而,你可以使用类似 workspace.local/webform
的名称。如果有疑问,请使用代码仓库名称。
创建源树
一旦你有了一个新的模块,就是时候决定你将如何组织源文件了。
如何操作...
根据项目,有几种已建立的约定:
-
使用标准布局,例如
github.com/golang-standards/project-layout
。 -
焦点狭窄的库可以将所有导出名称放在模块根目录下,实现细节可以可选地存储在内部包中。仅生成单个可执行文件且具有相对较少或没有可重用组件的模块也可以使用扁平的目录结构。
对于像我们这样的生成可执行文件的项目,github.com/golang-standards/project-layout
中描述的结构是合适的。因此,让我们遵循这个模板:
webform/
go.mod
cmd/
webform/
main.go
web/
static/
pkg/
...
internal/
...
build/
ci/
package/
configs/
在这里,cmd/webform
目录将包含 main
包。正如你所见,这是一个包名与其所在目录不匹配的例子。Go 构建系统将使用目录名创建可执行文件,因此当你将在 cmd/webform
目录下构建 main
包时,你会得到一个名为 webform
的可执行文件。如果你在单个模块内构建了多个可执行文件,你可以通过在 cmd/
目录下创建一个与程序名匹配的单独的 main
包来容纳它们。
pkg/
目录将包含程序的导出包。这些是可以被其他项目导入和重用的包。
如果你有一些在这个项目外不可用的包,你应该将它们放在internal/
目录下。Go 构建系统识别这个目录,并且不允许你从包含internal/
目录的外部包导入internal/
下的包。使用这种设置,我们webform
程序的所有包都将能够访问internal/
下的包,但它将无法被导入此模块的包访问。
web/
目录将包含任何与 Web 相关的资产。在这个例子中,我们将有一个包含静态网页的web/static
目录。如果你有任何服务器端模板,你也可以添加web/templates
来存储。
build/package
目录应包含打包脚本和云、容器、打包系统(dep
、rpm
、pkg
等)的配置。
build/ci
目录应包含持续集成工具脚本和配置。如果你使用的持续集成工具需要将其文件放在除这个目录之外的特定目录中,你可以创建符号链接,或者简单地将这些文件放在工具需要的目录中而不是/build/ci
。
configs/
目录应包含配置文件模板和默认配置。
你也可以看到在模块根目录下有main
包的项目,消除了cmd/
目录。当模块只有一个可执行文件时,这是一个常见的布局:
webform/
go.mod
go.sum
main.go
internal/
...
pkg/
...
然后还有一些没有main
包的模块。这些通常是你可以导入到你的项目中的库。例如,github.com/google/uuid
包含使用平面目录结构的流行 UUID 实现。
构建和运行程序
现在你已经有一个模块和一些 Go 文件的源树,你可以构建或运行你的程序。
如何做到这一点...
-
使用
go build
来构建当前包 -
使用
go build ./path/to/package
在给定目录下构建包 -
使用
go build <moduleName>
构建模块 -
使用
go run
来运行当前的main
包 -
使用
go run ./path/to/main/package
在给定目录下构建和运行main
包 -
使用
go run <moduleName/mainpkg>
在给定目录下构建和运行模块的main
让我们编写一个启动 HTTP 服务器的main
函数。以下片段是cmd/webform/main.go
:
package main
import (
"net/http"
)
func main() {
server := http.Server{
Addr: ":8181",
Handler: http.FileServer(http.Dir("web/static")),
}
server.ListenAndServe()
}
目前,main
只导入标准库的net/http
包。它启动一个服务器,为web/static
目录下的文件提供服务。注意,为了使这生效,你必须从模块根目录运行程序:
$ go run ./cmd/webform
总是运行main
包;避免使用go run main.go
。这将运行main.go
,排除main
包中的任何其他文件。如果你在main
包中有其他包含辅助函数的.go
文件,它将失败。
如果你从这个目录之外运行此程序,它将无法找到web/static
目录;因为它是一个相对路径,它是相对于当前目录解析的。
当你通过go run
运行程序时,程序的可执行文件会被放置在一个临时目录中。要构建可执行文件,请使用以下命令:
$ go build ./cmd/webform
这将在当前目录中创建一个二进制文件。二进制文件的名字将由主包的最后一个部分决定——在这种情况下,是webform
。要使用不同的名字构建二进制文件,请使用以下命令:
$ go build -o wform ./cmd/webform
这将构建一个名为wform
的二进制文件。
导入第三方包
大多数项目将依赖于必须导入到它们中的第三方库。Go 模块系统管理这些依赖。
如何做到这一点...
-
找到你项目中需要使用的包的导入路径。
-
将必要的导入添加到你在外部包中使用的源文件中。
-
使用
go get
或go mod tidy
命令将模块添加到go.mod
和go.sum
中。如果模块之前没有下载,这一步也会下载模块。
小贴士
你可以使用pkg.go.dev
来发现包。这也是你发布 Go 项目文档的地方。
让我们在上一节中添加一个数据库到我们的程序,这样我们就可以存储由网页表单提交的数据。对于这个练习,我们将使用SQLite
数据库。
将cmd/webform/main.go
文件修改为导入数据库包并添加必要的数据库初始化代码:
package main
import (
"net/http"
"database/sql"
_ "modernc.org/sqlite"
"github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/
webform/pkg/commentdb"
)
func main() {
db, err := sql.Open("sqlite", "webform.db")
if err != nil {
panic(err)
}
commentdb.InitDB(db)
server := http.Server{
Addr: ":8181",
Handler: http.FileServer(http.Dir("web/static")),
}
server.ListenAndServe()
}
_ "modernc.org/sqlite"
这一行将SQLite
驱动程序导入到项目中。下划线是sqlite
包不是直接由这个文件使用,只是为了它的副作用。如果没有空白标识符,编译器会抱怨导入没有被使用。在这种情况下,modernc.org/sqlite
包是一个数据库驱动程序,当你导入它时,它的init()
函数将注册所需的驱动程序到标准库中。
下一个声明从我们的模块中导入commentdb
包。注意,使用完整的模块名称来导入包。构建系统将识别这个导入声明的前缀作为当前模块名称,并将其转换为本地文件系统引用,在这种情况下,是webform/pkg/commentdb
。
在db, err := sql.Open("sqlite", "webform.db")
这一行,我们使用database/sql
包的Open
函数来启动一个SQLite
数据库实例。sqlite
命名了数据库驱动程序,它是由导入的_ "modernc.org/sqlite"
注册的。
commentdb.InitDB(db)
语句将调用commentdb
包中的一个函数。
现在,让我们看看commentdb.InitDB
是什么样子。这是webform/pkg/commentdb/initdb.go
文件:
package commentdb
import (
"context"
"database/sql"
)
const createStmt=`create table if not exists comments (
email TEXT,
comment TEXT)`
func InitDB(conn *sql.DB) {
_, err := conn.ExecContext(context.Background(), createStmt)
if err != nil {
panic(err)
}
}
如你所见,这个函数会在尚未创建的情况下创建数据库表。
注意 InitDB
的首字母大写。如果一个包中声明的符号名的第一个字母是大写,则该符号可以从其他包访问(即,它是 导出的)。如果不是,该符号只能在声明它的包中使用(即,它 未导出的)。createStmt
常量未导出,对其他包不可见。
让我们构建程序:
$ go build ./cmd/webform
cmd/webform/main.go:7:2: no required module provides package modernc.org/sqlite; to add it:
go get modernc.org/sqlite
你可以运行 go get modernc.org/sqlite
将模块添加到你的项目中。或者,你可以运行以下命令:
$ go get
这将获取所有缺失的模块。或者,你可以运行以下命令:
$ go mod tidy
go mod tidy
将下载所有缺失的包,更新 go.mod
和 go.sum
以包含更新的依赖项,并删除对任何未使用模块的引用。go get
只会下载缺失的模块。
导入特定版本的包
有时,你需要第三方包的特定版本,因为 API 不兼容或依赖于特定的行为。
如何操作...
-
要获取特定版本的包,指定版本标签:
$ go get modernc.org/sqlite@v1.26.0
-
要获取特定主版本的包的最新版本,请使用以下命令:
$ go get gopkg.in/yaml.v3
或者,使用以下命令:
$ go get github.com/ory/dockertest/v3
-
要导入最新可用的版本,请使用以下命令:
$ go get modernc.org/sqlite
-
你也可以指定不同的分支。以下命令将从一个
devel
分支获取模块,如果存在的话:$ go get modernc.org/sqlite@devel
-
或者,你可以获取一个特定的提交:
$ go get modernc.org/sqlite@a8c3eea199bc8fdc39391d5d261eaa3577566050
如你所见,你可以使用 @
revision
习惯用法来获取模块的特定版本:
$ go get modernc.org/sqlite@v1.26.0
URL 的修订部分由版本控制系统评估,在这种情况下是 git
,因此可以使用任何有效的 git
修订语法。
小贴士:
你可以通过检查你的 Go 安装下的 src/cmd/go/alldocs.go
文件来找出支持的版本控制系统。
这也意味着你可以使用分支:
$ go get modernc.org/sqlite@master
小贴士
gopkg.in
服务将版本号转换为与 Go 构建系统兼容的 URL。请参阅该网站上的说明了解如何使用它。
与模块缓存一起工作
模块缓存是一个目录,Go 构建系统在其中存储下载的模块文件。本节描述了如何与模块缓存一起工作。
如何操作...
默认情况下,模块缓存位于 $GOPATH/pkg/mod
,当未设置 GOPATH
时为 $HOME/go/pkg/mod
:
-
默认情况下,Go 构建系统在模块缓存下创建只读文件,以防止意外修改。
-
要验证模块缓存未被修改且反映了模块的原始版本,请使用以下命令:
go mod verify
-
要清理模块缓存,请使用以下命令:
go clean -modcache
关于模块缓存的权威信息来源是 Go Modules 参考 (go.dev/ref/mod
)。
使用内部包来减少 API 表面积
并非所有代码都是可重用的。较小的 API 表面积使得其他人更容易适应和使用你的代码。因此,你不应该导出仅针对你程序的 API。
如何操作...
创建 internal
包以隐藏其他包的实现细节。任何在 internal
包下的内容只能从包含该 internal
包的包中导入 – 即,任何在 myproject/internal
下的内容只能从 myproject
下的包中导入 – 那就是任何在 myproject
下的内容。
在我们的例子中,我们将数据库访问代码放入一个包中,以便其他程序可以访问。然而,将 HTTP 路由暴露给其他人没有意义,因为它们是特定于这个程序的。所以,我们将它们放在 webform/internal
包下。
这是 internal/routes/routes.go
文件:
package routes
import (
"database/sql"
"github.com/gorilla/mux"
"net/http"
)
func Build(router *mux.Router, conn *sql.DB) {
router.Path("/form").
Methods("GET").HandlerFunc(func(w http.ResponseWriter, r
*http.Request) {
http.ServeFile(w, r, "web/static/form.html")
})
router.Path("/form").
Methods("POST").HandlerFunc(func(w http.ResponseWriter, r
*http.Request) {
handlePost(conn, w, r)
})
}
func handlePost(conn *sql.DB, w http.ResponseWriter, r *http.Request) {
email := r.PostFormValue("email")
comment := r.PostFormValue("comment")
_, err := conn.ExecContext(r.Context(), "insert into comments
(email,comment) values (?,?)",
email, comment)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/form", http.StatusFound)
}
然后,我们将 main.go
文件更改为使用内部包:
package main
import (
"database/sql"
"net/http"
"github.com/gorilla/mux"
_ "modernc.org/sqlite"
"github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/
webform/internal/routes"
"github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/
webform/pkg/commentdb"
)
func main() {
db, err := sql.Open("sqlite", "webform.db")
if err != nil {
panic(err)
}
commentdb.InitDB(db)
r := mux.NewRouter()
routes.Build(r, db)
server := http.Server{
Addr: ":8181",
Handler: r,
}
server.ListenAndServe()
}
使用模块的本地副本
有时,您将工作在多个模块上,或者从存储库下载一个模块,对其进行一些更改,然后想使用更改后的版本而不是存储库上的版本。
如何操作...
使用 go.mod
中的 replace
指令指向包含模块的本地目录。
让我们回到我们的例子 – 假设你想对 sqlite
包做一些更改:
-
克隆它:
$ ls webform $ git clone git@gitlab.com:cznic/sqlite.git $ ls sqlite webform
-
修改您项目下的
go.mod
文件以指向模块的本地副本。go.mod
变为以下内容:module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform go 1.22.1 replace modernc.org/sqlite => ../sqlite require ( github.com/gorilla/mux v1.8.1 modernc.org/sqlite v1.27.0 ) ...
-
您现在可以在系统上的
sqlite
模块中进行更改,并且这些更改将构建到您的应用程序中。
在多个模块上工作 – 工作空间
有时你需要与多个相互依赖的模块一起工作。一个方便的方法是通过定义工作空间来实现。工作空间只是一组模块。如果工作空间内的某个模块引用了同一工作空间内另一个模块的包,它将本地解析而不是从网络上下载该模块。
如何操作...
-
要创建工作空间,你必须有一个包含所有工作模块的父目录:
$ cd ~/projects $ mkdir ws $ cd ws
-
然后,使用以下命令启动工作空间:
$ go work init
这将在当前目录下创建一个
go.work
文件。 -
将您正在工作的模块放入此目录。
让我们用我们的例子来演示这一点。假设我们有以下目录结构:
$HOME/ projects/ ws/ go.work webform sqlite
现在,我们想要将两个模块
webform
和sqlite
添加到工作空间中。为此,使用以下命令:$ go work use ./webform $ go work use ./sqlite
这些命令将两个模块添加到您的工作空间中。任何来自
webform
模块的sqlite
引用现在将解析为使用模块的本地副本。
管理您模块的版本
Go 工具使用语义版本控制系统。这意味着版本号采用 X.Y.z
的形式,具体如下:
-
X
用于主要发布,这些发布可能不是向后兼容的。 -
Y
用于增量但向后兼容的小版本发布进行递增 -
z
用于向后兼容的补丁进行递增
你可以在 semver.org
上了解更多关于语义版本化的信息。
如何操作...
-
要发布补丁或小版本,请使用新版本号标记包含您更改的分支:
$ git tag v1.0.0 $ git push origin v1.0.0
-
如果你想要发布一个与先前版本不兼容的新版本,你应该增加该模块的主版本号。要发布模块的新主要版本,使用一个新的分支:
$ git checkout -b v2
然后,将
go.mod
中的模块名称更改为以/v2
结尾,并更新源树中的所有引用以使用模块的/v2
版本。
例如,假设你发布了 webform
模块的第一版,v1.0.0
。然后,你决定你想要添加新的 API 端点。这不会是一个破坏性变更,所以你只需简单地增加次要版本号——v1.1.0
。但后来发现你添加的一些 API 造成了问题,所以你移除了它们。现在,这是一个破坏性变更,所以你应该发布带有它的 v2.0.0
。你该如何做到这一点呢?
答案是,你在版本控制系统中使用一个新的分支。创建 v2
分支:
$ git checkout -b v2
然后,将 go.mod
更改为反映新版本:
module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform/v2
go 1.22.1
require (
...
)
如果模块中有多个包,你必须更新源树,以便该模块内对包的任何引用也使用 v2
版本。
提交并推送新分支:
$ git add go.mod
$ git commit -m "New version"
$ git push origin v2
要使用新版本,你现在必须导入包的 v2
版本:
import "github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform/v2/pkg/commentdb"
摘要和进一步阅读
本章重点介绍了设置和管理 Go 项目的概念和机制。这绝对不是一份详尽的参考,但这里提供的食谱应该能让你掌握有效使用 Go 构建系统的基本知识。
Go 模块的确切指南是 Go Modules 参考 (go.dev/ref/mod
)。
查看关于依赖管理的详细讨论,请访问 管理依赖项 链接 (go.dev/doc/modules/managing-dependencies
)。
在下一章中,我们将开始处理文本数据。
第二章:字符串操作
字符串是 Go 中的基本数据类型之一。
Go 使用不可变的 UTF-8 编码字符串。对于新开发者来说,这可能会令人困惑;毕竟,这可以工作:
x:="Hello"
x+=" World"
fmt.Println(x)
// Prints Hello World
我们不是刚刚修改了x
吗?是的,我们确实修改了。在这里不可变的是"Hello"
和" World"
字符串。所以,字符串本身是不可变的,但字符串变量x
是可变的。要修改字符串变量,你需要创建字节数组或 runes(它们是可变的)的切片,然后与它们一起工作,最后将它们转换回字符串。
UTF-8 是用于 Web 和互联网技术的最常见编码。这意味着每次你在 Go 程序中处理文本时,你都在处理 UTF-8 字符串。如果你必须以不同的编码处理数据,你首先将其转换为 UTF-8,处理它,然后再将其编码回原始编码。
UTF-8 是一种变长编码,每个码点使用一个到四个字节。大多数码点代表一个字符,但也有一些代表其他信息,如格式化。这可能会引起一些意外。例如,字符串的长度(即它占用的字节数)与字符数不同。要找到字符串中的字符数,你需要逐个计数。当你切片字符串时,你必须注意码点边界。
Go 使用rune
类型来表示码点。因此,字符串可以被视为字节序列,也可以被视为 rune 序列。这如图图 2.1所示。在这里,x
是一个字符串变量,它指向不可变的字符串,这是一个字节序列,也可以被视为 rune 序列。尽管 UTF-8 是一种变长编码,但rune
是一个固定长度的 32 位类型(uint32
)。较小的码点,如以下字符H
,是一个 32 位的十进制数,72,而字节H
是一个 8 位值。
图 2.1 – 字符串、字节和 rune
在本章中,我们将探讨一些涉及字符串和文本的常见操作。本章包含的食谱如下:
-
创建字符串
-
格式化字符串
-
合并字符串
-
大写、小写和标题大小写比较
-
处理国际化字符串
-
处理编码
-
迭代字符串的字节和 rune
-
分割
-
正则表达式
-
逐行或逐字读取字符串
-
去除空白
-
模板
创建字符串
在这个食谱中,我们将探讨如何在程序中创建字符串。
如何做到这一点...
-
使用字符串字面量。Go 中有两种字符串字面量:
-
使用解释字符串字面量,用双引号括起来:
x := "Hello world"
-
在解释字符串字面量中,你必须转义某些字符:
x:="This is how you can include a \" in your string literal" y:="You can also use a newline \n, tab \t"
-
你可以包括 Unicode 码点或十六进制字节,使用
\'
转义:w:="\u65e5 本\U00008a9e" x:="\xff"
-
在解释字符串中,你不能有换行符或未转义的引号:
-
使用反引号创建原始字符串字面量。原始字符串字面量可以包含任何字符(包括换行符),但不能包含反引号。在原始字面量中无法转义反引号。
x:=`This is a multiline raw string literal. Backslash will print as backslash \`
如果需要在原始字符串字面量中包含反引号,请这样做:
x:=`This is a raw string literal with `+"`"+` in it`
字符串格式化
Go 标准库提供了多种在文本模板中替换值的方法。在这里,我们将讨论 fmt
包中的文本格式化工具。它们提供了一种简单方便的方法来在文本模板中替换值。
如何做到这一点...
-
使用
fmt.Print
系列函数来格式化值 -
fmt.Print
将使用其默认格式打印一个值 -
字符串值将按原样打印
-
数字值将首先被转换为字符串,作为整数、十进制数,或者使用科学记数法表示大指数
-
布尔值将打印为
true
或false
-
结构化值将按字段列表打印
如果一个 Print
函数以 ln
结尾(例如 fmt.Println
),将在字符串后输出一个新行。
如果一个 Print
函数以 f
结尾(例如 fmt.Println
),则该函数将接受一个格式参数,该参数将用作模板,并将值替换到其中。
fmt.Sprintf
将格式化一个字符串并返回它。
fmt.Fprintf
将格式化一个字符串并将其写入 io.Writer
,这可以是文件、网络连接等。
fmt.Printf
将格式化一个字符串并将其写入标准输出。
它是如何工作的...
所有这些函数都使用 %[选项]动词>
格式从参数列表中消耗一个参数。要在输出中产生一个 %
字符,请使用 %%
:
func main() {
fmt.Printf("Print integers using %%d: %d|\n", 10)
// Print integers using %d: 10|
fmt.Printf("You can set the width of the printed number, left
aligned: %5d|\n", 10)
// You can set the width of the printed number, left
// aligned: 10|
fmt.Printf("You can make numbers right-aligned with a given
width: %-5d|\n", 10)
// You can make numbers right-aligned with a given width: 10 |
fmt.Printf("The width can be filled with 0s: %05d|\n", 10)
// The width can be filled with 0s: 00010|
fmt.Printf("You can use multiple arguments: %d %s %v\n", 10,
"yes", true)
// You can use multiple arguments: 10 yes true
fmt.Printf("You can refer to the same argument multiple times :
%d %s %[2]s %v\n", 10, "yes", true)
// You can refer to the same argument multiple times : 10 yes
// yes true
fmt.Printf("But if you use an index n, the next argument will be
selected from n+1 : %d %s %[2]s %[1]v %v\n", 10, "yes", true)
// But if you use an index n, the next argument will be selected
// from n+1 : 10 yes yes 10 yes
fmt.Printf("Use %%v to use the default format for the type: %v %v
%v\n", 10, "yes", true)
// Use %v to use the default format for the type: 10 yes true
fmt.Printf("For floating point, you can specify precision:
%5.2f\n", 12.345657)
// For floating point, you can specify precision: 12.35
fmt.Printf("For floating point, you can specify precision:
%5.2f\n", 12.0)
// For floating point, you can specify precision: 12.00
type S struct {
IntValue int
StringValue string
}
s := S{
IntValue: 1,
StringValue: `foo "bar"`,
}
// Print the field values of a structure, in the order they are
// declared
fmt.Printf("%v\n", s)
// {1 foo "bar"}
// Print the field names and values of a structure
fmt.Printf("%+v\n", s)
//{IntValue:1 StringValue:foo "bar"}
}
字符串组合
Go 标准库提供了多种从组件构建字符串的方法。最佳方法取决于你处理的是哪种类型的字符串,以及它们的长度。本节展示了构建字符串的几种方法。
如何做到这一点...
-
要组合少量固定数量的字符串,或向另一个字符串添加 runes,请使用
+
或+=
运算符或string.Builder
-
要算法性地构建一个字符串,请使用
strings.Builder
-
要组合字符串切片,请使用
strings.Join
-
要组合 URL 路径的一部分,请使用
path.Join
-
要从路径段构建文件系统路径,请使用
filepath.Join
它是如何工作的...
要构建常量值或进行简单的连接,请使用 +
或 +=
运算符:
var TwoLines = "This is the first line \n"+
"This is the second line"
func ThreeLines(newLine string) string {
return TwoLines+"\n"+newLine
}
你可以用相同的方式向字符串添加 runes:
func AddNewLine(line string) string {
return line+string('\n')
}
小贴士
在性能意识强烈的团队中,使用 +
运算符进行字符串连接可能会引起争议。确实,+
运算符可能会变得低效,因为多次添加可能会创建不必要的临时字符串来存储中间结果。同样正确的是,对于某些用例,编译器可以生成比手动编写的更好的代码。然而,除非你在 for
循环中使用 +
运算符创建字符串,否则它们很少是性能问题的原因。例如,x+y
几乎总是优于 fmt.Sprintf("%s%s",x,y)
。如果有疑问,请编写基准测试并测量。以下是我笔记本电脑上的结果:
BenchmarkXPlusY-12 98628536 ``11.31 ns/op
BenchmarkSprintf-12 12120278``97.70 ns/op
BenchmarkBuilder-12 33077902 ``34.89 ns/op
对于非平凡情况,其中你必须添加许多短字符串来构建一个更长的字符串,请使用 strings.Builder
。尽管 strings.Builder
看起来像是字节切片的便利前端,但它做得更多。它从底层字节切片中创建字符串而不进行复制,因此它几乎总是优于使用字节切片然后从中创建字符串。
提示
这是一个示例,说明为什么你应该优先选择标准库函数而不是第三方库或手动优化。这些函数经过积极优化,并依赖于 Go 内部功能,而不创建可移植性问题:
builder := strings.Builder{} // Zero-value is ready to use
for i:=0; i< 10000; i++ {
builder.WriteString(getShortString(i))
}
fmt.Println(builder.String())
使用 strings.Join
来合并字符串切片。如果你正在处理文件名并且需要合并多级目录,请使用 filepath.Join
以避免平台特定的分隔符字符。filepath.Join
在 Windows 平台上使用 \
,在基于 Linux 的平台上使用 /
。如果你正在处理 URL 并且需要合并多个段,请使用 path.Join
,它始终使用 /
来合并部分:
package main
import (
"fmt"
"path"
"path/filepath"
"strings"
)
func main() {
words := []string{"foo", "bar", "baz"}
fmt.Println(strings.Join(words, " "))
// foo bar baz
fmt.Println(strings.Join(words, ""))
// foobarbaz
fmt.Println(path.Join(words...))
// foo/bar/baz
fmt.Println(filepath.Join(words...))
// foo/bar/baz or foo\bar\baz, depending on the host system
paths := []string{"/foo", "//bar", "baz"}
fmt.Println(strings.Join(paths, " "))
// /foo //bar baz
fmt.Println(path.Join(paths...))
// /foo/bar/baz
fmt.Println(filepath.Join(paths...))
// /foo/bar/baz or \foo\bar\baz depending on the host system
}
处理字符串大小写
当处理文本数据时,与字符串大小写相关的问题经常出现。文本搜索应该是大小写敏感的还是不敏感的?我们如何将字符串转换为小写或大写?在本节中,我们将探讨一些用于以可移植方式处理这些常见问题的方法。
如何做到这一点...
-
使用
strings.ToUpper
和strings.ToLower
函数分别将字符串转换为大写和小写。 -
当处理具有特殊大小写映射的语言中的文本(例如土耳其语,其中“İ”是“I”的大写形式)时,请使用
strings.ToUpperSpecial
和strings.ToLowerSpecial
-
要将文本转换为用于标题的大写,请使用
strings.ToTitle
-
要进行字典序字符串比较,请使用比较运算符
-
要测试忽略大小写的字符串等价性,请使用
strings.EqualFold
它是如何工作的...
将字符串转换为大小写很简单:
greet := "Hello World!"
fmt.Println(strings.ToUpper(greet))
fmt.Println(strings.ToLower(greet))
该程序输出以下内容:
HELLO WORLD!
hello world!
但大小写可能因语言而异。例如,突厥语系的一些语言有特殊的情况:
word := "ilk"
fmt.Println(strings.ToUpper(word))
这将打印以下内容:
ILK
然而,这并不是土耳其语正确的使用大写字母的方式。让我们尝试以下操作:
import (
"fmt"
"strings"
"unicode"
)
func main() {
word := "ilk"
fmt.Println(strings.ToUpperSpecial(unicode.TurkishCase,word))
}
前面的程序将打印以下内容:
İLK
标题大小写与大小写主要在处理连字符和双字母时不同——即表示为单个字符的多个字符,例如 LJ
(U+01C7)
:
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.ToTitle("LJ")) // U+01C7
fmt.Println(strings.ToUpper("LJ"))
fmt.Println(strings.ToLower("LJ"))
}
该程序打印以下内容:
Lj
LJ
lj
大写、小写和标题大小写定义了如何使用特定的映射来打印字符串。这些都是映射。折叠是将文本转换为相同的大小写以用于比较的过程。
对于字典序大小写敏感的比较,请使用关系运算符:
fmt.Prinln("a" < "b") // true
要以不区分大小写的方式比较两个 Unicode 字符串,请使用 strings.EqualFold
:
fmt.Println(strings.EqualFold("here", "Here")) // true
fmt.Println(strings.EqualFold("here", "Here")) // true
fmt.Println(strings.EqualFold("GÖ", "gö")) // true
还有更多...
虽然标准库 strings
包包括了您需要的几乎所有字符串比较函数,但在处理国际化字符串时,它们可能不足以满足需求。例如,在许多情况下,您可能希望将 Montréal
和 montreal
视为相等。strings.EqualFold
无法做到这一点。许多用于处理内部文本处理的辅助函数都在 golang.org/x/text
下的包中。
Unicode 提供了多种表示给定字符串的方法。例如,Montréal
中的 é
可以表示为一个单独的字符,\u00e9
或 e
,后面跟着一个重音符号,e\u0301
。\u0301
是“组合重音符号”,“◌́”或“”和它修改了它前面的代码点。根据 Unicode 标准,
é和
e+
◌́是“规范等价”的。也存在兼容等价,例如
\ufb00,表示
ff为单个代码点,以及
ff` 序列。规范等价序列也是兼容的,但并非所有兼容序列都是规范等价的。
因此,如果您需要从文本中移除变音符号(即非间隔符号),您可以将其分解,移除变音符号,然后按以下方式重新组合:
// Based on the blog post https://go.dev/blog/normalization
package main
import (
"fmt"
"io"
"strings"
"unicode"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
func main() {
isMn := func(r rune) bool {
return unicode.Is(unicode.Mn, r) // Mn: nonspacing marks
}
t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.
NFC)
rd := transform.NewReader(strings.NewReader("Montréal"), t)
str, _ := io.ReadAll(rd)
fmt.Println(string(str))
}
上述程序将打印以下内容:
Montreal
处理编码
如果您的程序有可能需要处理由不同系统产生的数据,您应该了解不同的文本编码。这是一个很大的话题,但本节应该提供一些线索来触及表面。
如何做到这一点...
-
使用
golang.org/x/text/encoding
包来处理不同的编码。 -
要按名称查找编码,请使用以下之一:
-
golang.org/x/text/encoding/ianaindex
-
golang.org/x/text/encoding/htmlindex
-
-
一旦您有了编码,就可以使用它将文本转换为 UTF-8 或从 UTF-8 转换文本。
它是如何工作的...
使用其中一个索引来查找编码。然后,使用该编码来读取/写入数据:
package main
import (
"fmt"
"os"
"golang.org/x/text/encoding/ianaindex"
)
func main() {
enc, err := ianaindex.MIME.Encoding("US-ASCII")
if err != nil {
panic(err)
}
b, err := os.ReadFile("ascii.txt")
if err != nil {
panic(err)
}
decoder := enc.NewDecoder()
encoded, err := decoder.Bytes(b)
if err != nil {
panic(err)
}
fmt.Println(string(encoded))
}
迭代字符串的字节和字符
Go 语言的字符串可以看作是字节序列,或者看作是字符序列。本节展示了如何以这两种方式迭代字符串。
如何做到这一点...
要迭代字符串的字节,使用索引:
for i:=0;i<len(str);i++ {
fmt.Print(str[i]," ")
}
要迭代字符串的字符,使用 range
:
for index, c:=range str {
fmt.Print(c," ")
}
它是如何工作的...
Go 语言的字符串是字节切片,因此您可能会认为可以编写一个 for 循环来迭代字符串的字节和字符。您可能会认为可以这样做:
strBytes := []byte(str)
strRunes := []rune(str)
然而,将字符串转换为字节数组或字符数组是一个昂贵的操作。第一个操作创建了 str
字符串字节的可写副本,第二个操作创建了 str
字符串字符的可写副本。请记住,rune
是 uint32
。
有两种形式的 for 循环可以迭代字符串的元素。以下 for 循环将迭代字符串的字节:
str:="Hello 世界"
for i:=0;i<len(str);i++ {
fmt.Print(str[i]," ")
}
输出如下:
72 101 108 108 111 32 228 184 150 231 149 140
此外,请注意,str[i]
将为您提供第 i 个字节,而不是第 i 个字符。
以下形式迭代字符串的 rune:
for i,r:=range str {
fmt.Printf("( %d %d %s)", i, r, string(r))
}
输出如下:
(0 72 H)(1 101 e)(2 108 l)(3 108 l)(4 111 o)(5 32 )(6 19990 世)(9 30028 界)
注意索引 - 它们按照 0、1、2、3、4、5、6、9 的顺序排列。这是因为str[6]
包含一个 3 字节的 rune,同样str[9]
也是。
当你处理的是[]byte
而不是字符串时,你可以模拟 rune 迭代,如下所示:
import (
"unicode/utf8"
"fmt"
)
str:=[]byte("Hello 世界")
for i:=0;i<len(str); {
r, n:=utf8.DecodeRune(str[i:])
fmt.Print("(",i,r, " ",string(r),")")
i+=n
}
utf8.DecodeRune
函数从字节切片中解码下一个 rune,并返回该 rune 以及消耗的字节数。这样,你可以在不首先将其转换为字符串的情况下解码字节切片中的 rune。
分割
strings
包提供了方便的函数来分割字符串以获取字符串切片。
如何做到...
-
要使用分隔符将字符串分割成组件,请使用
strings.Split
。 -
要分割字符串中由空格分隔的组件,请使用
strings.Fields
。
它是如何工作的...
如果你需要解析由固定分隔符分隔的字符串,请使用strings.Split
。如果你需要解析字符串中的空格分隔部分,请使用strings.Fields
:
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.Split("a,b,c,d", ","))
// ["a", "b", "c", "d"]
fmt.Println(strings.Split("a, b, c, d", ","))
// ["a", " b", " c", " d"]
fmt.Println(strings.Fields("a b c d "))
// ["a", "b", "c", "d"]
fmt.Println(strings.Split("a---b---c--d--", "-"))
// ["a", "", "", "b", "", "", "c", "", "d", "", ""]
}
注意,当分隔符重复时,strings.Split
可能会导致一些意外。例如,使用"-"
作为分隔符时,"a---b"
会分割成"a"
、""
、""
和"b"
。这两个空字符串是第一个和第二个"-"
之间以及第二个和第三个"-"
之间的。
逐行或逐字读取字符串
在处理大量文本或用户输入时,有许多使用字符串流处理的使用场景。这个配方展示了如何使用bufio.Scanner
来完成这个目的。
如何做到...
-
使用
bufio.Scanner
读取行、单词或自定义块。 -
创建一个
bufio.Scanner
实例 -
设置分割方法
-
在 for 循环中读取扫描的标记
它是如何工作的...
Scanner
的工作方式类似于迭代器 - 每次调用Scan()
方法都会返回true
,如果它解析了下一个标记,或者返回false
,如果没有更多的标记。可以通过Text()
方法获取标记:
package main
import (
"bufio"
"fmt"
"strings"
)
const input = `This is a string
that has 3
lines.`
func main() {
lineScanner := bufio.NewScanner(strings.NewReader(input))
line := 0
for lineScanner.Scan() {
text := lineScanner.Text()
line++
fmt.Printf("Line %d: %s\n", line, text)
}
if err := lineScanner.Err(); err != nil {
panic(err)
}
wordScanner := bufio.NewScanner(strings.NewReader(input))
wordScanner.Split(bufio.ScanWords)
word := 0
for wordScanner.Scan() {
text := wordScanner.Text()
word++
fmt.Printf("word %d: %s\n", word, text)
}
if err := wordScanner.Err(); err != nil {
panic(err)
}
}
输出如下:
Line 1: This is a string
Line 2: that has 3
Line 3: lines.
word 1: This
word 2: is
word 3: a
word 4: string
word 5: that
word 6: has
word 7: 3
word 8: lines.
修剪字符串的末尾
用户输入通常很杂乱,包括在重要文本前后添加额外的空格。这个配方展示了如何使用字符串修剪函数来完成这个目的。
如何做到...
使用如这里所示的strings.Trim
函数族:
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.TrimRight("Break-------", "-"))
// Break
fmt.Println(strings.TrimRight("Break with spaces-- -- --", "- "))
// Break with spaces
fmt.Println(strings.TrimSuffix("file.txt", ".txt"))
// file
fmt.Println(strings.TrimLeft(" \t Indented text", " \t"))
// Indented text
fmt.Println(strings.TrimSpace(" \t \n Indented text \n\t"))
// Indented text
}
正则表达式
正则表达式提供了确保文本数据与给定模式匹配、搜索模式、提取和替换文本部分的高效方法。通常,你编译一个正则表达式一次,然后多次使用该编译后的正则表达式来有效地验证、搜索、提取或替换字符串的部分。
验证输入
格式验证是确保来自用户输入或其他来源的数据处于可识别格式的过程。正则表达式可以成为此类验证的有效工具。
如何做到...
使用预编译的正则表达式来验证应该符合模式的输入值。
package main
import (
"fmt"
"regexp"
)
var integerRegexp = regexp.MustCompile("^[0-9]+$")
func main() {
fmt.Println(integerRegexp.MatchString("123")) // true
fmt.Println(integerRegexp.MatchString(" 123 ")) // false
}
为了确保精确匹配,请确保包含开始(^
)和文本结束标记($
);否则,您将接受匹配正则表达式的字符串包含的输入。
并非所有类型的输入都适合正则表达式验证。一些输入具有复杂的正则表达式(例如电子邮件或密码策略的正则表达式),因此对于这些输入,自定义验证可能更有效。
搜索模式
您可以使用正则表达式遍历文本数据以定位匹配模式的字符串。
如何操作...
使用 regexp.Find
函数族来搜索匹配模式的子字符串。
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`[0-9]+`)
fmt.Println(re.FindAllString("This regular expression find
numbers, like 1, 100, 500, etc.", -1))
}
这里是输出结果:
[1 100 500]
从字符串中提取数据
您可以使用正则表达式定位和提取在模式中出现的文本。
如何操作...
使用捕获组提取匹配模式的子字符串。
它是如何工作的...
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`^(\w+)=(\w+)$`)
result := re.FindStringSubmatch(`property=12`)
fmt.Printf("Key: %s value: %s\n", result[1], result[2])
result = re.FindStringSubmatch(`x=y`)
fmt.Printf("Key: %s value: %s\n", result[1], result[2])
}
这里是输出结果:
Key: property value: 12
Key: x value: y
让我们看看这个正则表达式:
-
^(\w+)
:行首由一个或多个单词字符组成的字符串(捕获组 1) -
=
:一个“=
”符号 -
(\w+)$
:由一个或多个单词字符(捕获组 2)组成的字符串,然后是行尾
注意,捕获组位于括号中。
FindStringSubmatch
方法返回匹配的字符串作为切片的 0th 元素,然后是每个捕获组。使用捕获组,您可以像上面那样提取数据。
替换字符串的部分
您可以使用正则表达式遍历文本,用其他字符串替换匹配模式的部分。
如何操作...
使用 Replace
函数族将字符串中的模式替换为其他内容:
package main
import (
"fmt"
"regexp"
)
func main() {
// Find numbers, capture the first digit
re := regexp.MustCompile(`([0-9])[0-9]*`)
fmt.Println(re.ReplaceAllString("This example replaces
numbers with 'x': 1, 100, 500.", "x"))
// This example replaces numbers with 'x': x, x, x.
fmt.Println(re.ReplaceAllString("This example replaces all
numbers with their first digits: 1, 100, 500.", "${1}"))
// This example replaces all numbers with their first digits: 1,
// 1, 5.
}
模板
模板对于生成数据驱动的文本输出非常有用。text/template
包可以在以下环境中使用:
-
使用
env
映射变量创建环境敏感的配置logfile: {{.env.logDir}}/log.json
-
报告:使用模板为命令行应用程序和报告生成输出
-
html/template
包提供了基于模板的 HTML 生成功能,用于构建 Web 应用程序
值替换
模板的主要用途是将数据元素插入到结构化文本中。本节描述了如何将程序中计算出的值插入到模板中。
如何操作...
使用 {{.name}}
语法在模板中替换一个值。
以下代码段使用不同的输入执行模板:
package main
import (
"os"
"text/template"
)
type Book struct {
Title string
Author string
PubYear int
}
const tp = `The book "{{.Title}}" by {{.Author}} was published in {{.PubYear}}.
`
func main() {
book1 := Book{
Title: "Pride and Prejudice",
Author: "Jane Austen",
PubYear: 1813,
}
book2 := Book{
Title: "The Lord of the Rings",
Author: "J.R.R. Tolkien",
PubYear: 1954,
}
tmpl, err := template.New("book").Parse(tp)
if err != nil {
panic(err)
}
tmpl.Execute(os.Stdout, book1)
tmpl.Execute(os.Stdout, book2)
}
前面的程序输出以下内容:
The book "Pride and Prejudice" by Jane Austen was published in 1813.
The book "The Lord of the Rings" by J.R.R. Tolkien was published in 1954.
template.New(name)
调用创建了一个具有给定名称的空模板(稍后会有更多关于这个的介绍)。返回的模板对象代表一个模板体(在 New()
调用后是空的)。Go 模板引擎使用代表体的模板,以及零个或多个与该体关联的命名模板。tmpl.Parse(tp)
调用将 tp
模板解析为给定 named
模板的体。如果 tp
中有其他使用 {{define}}
构造定义的模板定义,那些也会保留在 tmpl
中。
tmpl.Execute(os.Stdout,book1)
执行模板,将输出写入 os.Stdout
。第二个参数 book1
是用于评估模板的数据。您可以通过 ".
" 访问它。因此,例如,当 {{.Author}}
被评估时,模板引擎读取 book1.Author
,使用反射,并输出其值。换句话说,.
对于第一个 tmpl.Execute
调用是 book1
,而对于前面示例中的第二个 tmpl.Execute
调用,.
是 book2
。
由于这是使用反射完成的,以下产生相同的输出:
tmpl.Execute(os.Stdout,map[string]any {
"Title":"Pride and Prejudice",
"Author": "Jane Austen",
"PubYear": 1813,
})
迭代
模板可以包含使用程序中计算的切片或映射填充的表格数据或列表。模板提供了一种迭代机制来渲染此类内容。
如何做到这一点...
-
对于切片/数组,执行以下操作:
{{ range <slice> }} // Here, {{.}} refers the subsequent elements of the slice/array {{end}}
-
对于映射,执行以下操作:
{{ range <map> }} // Here, {{.}} refers to the subsequent values (not keys) of the map // The iteration order of the map is not guaranteed {{end}}
或者,执行以下操作:
{{ range $key, $value := <map> }} // Here, {{$key}} and {{$value}} are variables that are set to // subsequent key-value pairs of the map {{end}}
它是如何工作的...
使用 range
遍历切片和映射。
使用以下方式修改前面的示例:
const tpIter = `{{range .}}
The book "{{.Title}}" by {{.Author}} was published in {{.PubYear}}.
{{end}}`
然后,使用以下方式修改它:
...
tmpl, err = template.New("bookIter").Parse(tpIter)
if err != nil {
panic(err)
}
tmpl.Execute(os.Stdout, []Book{book1, book2})
这里是输出:
The book "Pride and Prejudice" by Jane Austen was published in 1813.
The book "The Lord of the Rings" by J.R.R. Tolkien was published in 1954.
现在,请注意,.
是书籍的切片,因此我们可以遍历其元素。在评估 {{range .}}
内部的部分时,.
被设置为切片的连续元素 - 在第一次迭代期间,.
是 book1
,在第二次迭代期间,.
是 book2
。
我们将很快处理空行。
对于映射,发生相同的事情:
tmpl.Execute(os.Stdout, map[int]Book{
1: book1,
2: book2,
})
变量和作用域
通常需要在模板中定义局部变量以保留计算值。在模板中定义的变量遵循与在函数中定义的变量类似的范围规则 - {{range}}
、{{if}}
、{{with}}
和 {{define}}
块创建一个新的作用域。
在作用域中定义的变量可以在该作用域包含的所有作用域中访问,但不能在作用域外部访问。
如何做到这一点...
.
(点)代表“当前对象”,如下所示:
-
在顶级作用域中,
.
指的是作为Execute
方法data
参数传递的对象 -
在
{{range}}
内部,.
指的是当前的切片/数组/映射元素 -
在
{{with <expr>}}
内部,.
指的是<expr>
的值 -
在
{{define}}
块内部,.
指的是传递给{{template "``name" <object>}}
的对象的值 -
.X
指的是当前对象中名为X
的成员:-
如果
.
是映射,那么.X
评估为具有X
键的元素 -
如果
.
是一个结构体,那么.X
评估为X
成员变量
-
小贴士
注意当前对象中 X
的强调。如果当前对象是一个结构体,反射只能访问导出的字段,因此你不能访问未导出的变量。然而,如果当前对象是一个映射,这变成了一次键查找,并且没有这样的限制。换句话说,{{.name}}
只在 .
是映射时才有效,但 {{.Name}}
对 .
结构体和 .
映射都有效。
使用以下方式定义一个在当前作用域中可见的新局部变量:
$name := value
它是如何工作的...
使用 $name
语法将计算值赋给变量,而不是每次都重新计算它:
{{ $disabled := false }}
{{ if eq .Selection "1"}}
{{ $disabled = true }}
{{ end }}
<input type="text" value="{{.Value1}}" {{if $disabled}}disabled{{end}}>
<input type="text" value="{{.Value2}}" {{if $disabled}}disabled{{end}}>
此模板的第一个部分等同于以下内容:
disabled := false
if data.Selection == "1" {
disabled=true
}
$
是变量名的第一个字符所必需的。如果没有它,模板引擎会认为 name
是一个函数。
更多的是 – 嵌套循环和条件
当你处理嵌套循环或条件时,作用域可能会成为一个挑战。每个 {{range}}
、{{if}}
和 {{with}}
都创建一个新的作用域。在作用域内定义的变量只在该作用域及其所有封装的作用域中可访问。你可以使用这个特性来创建嵌套循环并仍然访问封装作用域中定义的变量:
type Book struct {
Title string
Author string
Editions []Edition
}
type Edition struct {
Edition int
PubYear int
}
const tp = `{{range $bookIndex, $book := .}}
{{$book.Author}}
{{range $book.Editions}}
{{$book.Title}} Edition: {{.Edition}} {{.PubYear}}
{{end}}
{{end}}`
在此模板中,第一个 range
定义了循环索引 $bookIndex
和循环变量 $book
,它们可以在嵌套作用域中使用。在这个阶段,.
指向 Book
字段的切片。下一个 range
遍历当前的 $book.Editions
– 即,.
现在指向 Book.Editions
切片的连续元素。嵌套模板从封装作用域中访问 Edition
字段和 Book
字段。
处理空行
模板操作(即放置在模板中的代码元素)可能会导致不想要的空格和行。Go 模板系统提供了一些机制来处理这些不想要的空格。
如何做到这一点...
在模板分隔符旁边使用 -
:
-
{{-
将会移除在此模板元素之前输出的所有空格、制表符和换行符 -
-}}
将会移除在此模板元素之后的所有空格、制表符和换行符
如果模板指令产生了输出,例如变量的值,它将被写入输出流。但如果模板指令没有生成任何输出,例如一个 {{range}}
或 {{if}}
语句,那么它将被替换为空字符串。如果这些语句单独占一行,那么这些行也将被写入输出,如下所示:
{{range .}}
{{if gt . 1}}
{{.}}
{{end}}
{{end}}
此模板每四行产生一次输出。如果没有内容输出,它将打印出三行空行。
通过在 {{ }}
构造中使用 “-
” 来修复这个问题。{{ -}}
将移除之后的所有空格(包括行),而 {{- }}
将移除之前的所有空格,如下所示:
{{range . -}}
{{ if gt . 1 }}
{{- . }}
{{end -}}
{{end -}}
这里是输出:
2
3
4
5
我们如何去除每行的开头空格?首先,我们必须找出它们为什么在那里,如下所示:
{{- . }}
__{{end -}}
第一个“-”将移除值之前的所有空格。我们无法在这行中放置-}}
或{{- end}}
,因为这些解决方案也会删除换行符。但我们可以这样做:
{{range . -}}
{{ if gt . 1 }}
{{- . }}
{{end -}}
{{end -}}
这将生成以下内容:
2
3
4
5
模板组合
随着模板的增长,它们可能会变得重复。为了减少这种重复,Go 模板系统提供了命名块(组件),可以在模板内部重复使用,就像程序中的函数一样。然后,最终的模板可以由这些组件组成。
如何实现...
你可以创建可以在多个上下文中重复使用的模板“组件”。要定义一个命名模板,使用{{define "``name"}}
结构:
{{define "template1"}}
...
{{end}}
{{define "template2"}}
...
{{end}}
然后,使用{{template "name" .}}
结构调用该模板,就像它是一个具有单个参数的函数一样:
{{template "template1" .}}
{{range .List}}
{{template "template2" .}}
{{end}}
它是如何工作的...
以下示例使用命名模板打印书籍列表:
package main
import (
"os"
"text/template"
)
const tp = `{{define "line"}}
{{.Title}} {{.Author}} {{.PubYear}}
{{end}}
Book list:
{{range . -}}
{{template "line" .}}
{{end -}}
`
type Book struct {
Title string
Author string
PubYear int
}
var books = []Book{
{
Title: "Pride and Prejudice",
Author: "Jane Austen",
PubYear: 1813,
},
{
Title: "To Kill a Mockingbird",
Author: "Harper Lee",
PubYear: 1960,
},
{
Title: "The Great Gatsby",
Author: "F. Scott Fitzgerald",
PubYear: 1925,
},
{
Title: "The Lord of the Rings",
Author: "J.R.R. Tolkien",
PubYear: 1954,
},
}
func main() {
tmpl, err := template.New("body").Parse(tp)
if err != nil {
panic(err)
}
tmpl.Execute(os.Stdout, books)
}
在这个例子中,tmpl
模板包含两个模板——名为"body"
的模板(因为它是用template.New("body")
创建的),以及名为"line"
的模板(因为模板包含{{define "line"}}
。)对于切片的每个元素,"body"
模板使用books
切片的连续元素实例化"line"
。
这相当于以下内容:
const lineTemplate = `{{.Title}} {{.Author}} {{.PubYear}}`
const bodyTemplate = `Book list:
{{range . -}}
{{template "line" .}}
{{end -}}`
func main() {
tmpl, err := template.New("body").Parse(bodyTemplate)
if err != nil {
panic(err)
}
_, err = tmpl.New("line").Parse(lineTemplate)
if err != nil {
panic(err)
}
tmpl.Execute(os.Stdout, books)
}
模板组合 – 布局模板
在开发 Web 应用程序时,通常希望有几个模板来指定页面布局。完整的网页是通过组合作为独立模板开发的页面组件来构建的。不幸的是,Go 模板引擎迫使你考虑替代方案,因为 Go 模板引用是静态的。这意味着你需要为每个页面创建一个单独的布局模板。
但有其他选择。
我将向你展示一个基本思路,演示如何使用模板组合,以便你可以根据你的用例进行扩展,或者如何使用一个可用的第三方库来完成这项工作。在布局模板中使用组合的关键思想是,如果你使用已定义的模板名称定义了一个新模板,新的定义将覆盖旧的模板。
如何实现...
-
创建一个布局模板。使用空模板或具有默认内容的模板来定义你将在每个场合重新定义的各个部分。
-
创建一个配置系统,其中你定义每个可能的组合。每个组合包括布局模板,以及定义布局模板中各个部分的模板。
-
将每个组合编译为单独的模板。
它是如何工作的...
创建一个布局模板:
const layout=`
<!doctype html>
<html lang="en">
<head>
<title>{{template "pageTitle" .}}</title>
</head>
<body>
{{template "pageHeader" .}}
{{template "pageBody" .}}
{{template "pageFooter" .}}
</body>
</html>
{{define "pageTitle"}}{{end}}
{{define "pageHeader"}}{{end}}
{{define "pageBody"}}{{end}}
{{define "pageFooter"}}{{end}}`
此布局模板定义了四个没有内容的命名模板。对于每个新页面,我们可以重新创建这些组件:
const mainPage=`
{{define "pageTitle"}}Main Page{{end}}
{{define "pageHeader"}}
<h1>Main page</h1>
{{end}}
{{define "pageBody"}}
This is the page body.
{{end}}
{{define "pageFooter"}}
This is the page footer.
{{end}}`
我们可以定义第二个页面,类似于第一个页面:
const secondPage=`
{{define "pageTitle"}}Second page{{end}}
{{define "pageHeader"}}
<h1>Second page</h1>
{{end}}
{{define "pageBody"}}
This is the page body for the second page.
{{end}}`
现在,我们将layout
与mainPage
组合以获取主页的模板,然后与secondPage
组合以获取第二页的模板:
import (
"html/template"
)
func main() {
mainPageTmpl := template.Must(template.New("body").Parse(layout))
template.Must(mainPageTmpl.Parse(mainPage))
secondPageTmpl := template.Must(template.New("body").
Parse(layout))
template.Must(secondPageTmpl.Parse(secondPage))
mainPageTmpl.Execute(os.Stdout, nil)
secondPageTmpl.Execute(os.Stdout, nil)
}
您可以将此模式扩展到使用布局模板构建复杂的 Web 应用程序,以及一个配置文件,定义每个页面的所有有效模板组合。这样的 YAML 文件看起来如下所示:
mainPage:
- layouts/main.html
- mainPage.html
- fragments/status.html
detailPage:
- layouts/2col.html
- detailPage.html
- fragments/status.html
...
当应用程序启动时,您将按照给定顺序解析每个模板的组成部分,为mainPage
和detailPage
构建每个模板,并将每个模板放入映射中。然后,您可以查找您想要生成的模板名称并使用解析后的模板。
还有更多...
Go 标准库文档始终是您获取最新信息和优秀示例的最佳来源,例如以下内容:
以下链接也很有用:
-
万维网字符模型:字符串匹配:
www.w3.org/TR/charmod-norm/
-
字符属性、大小写映射与名称 FAQ:
unicode.org/faq/casemap_charprop.html
-
RFC7564:PRECIS
www.rfc-editor.org/rfc/rfc7564
-
这是一篇关于 Unicode 规范化过程的优秀博客文章:
go.dev/blog/normalization
-
对于所有由标准库未处理的所有编码、国际化以及 Unicode 相关的问题,在搜索其他任何内容之前,请先查看这里的包:
pkg.go.dev/golang.org/x/text
第三章:处理日期和时间
在任何编程语言中处理日期和时间都可能很困难。Go 的标准库提供了易于使用的工具来处理日期和时间结构。这些可能与许多人习惯的不同。例如,不同语言中的库会在 time
类型与 date
类型之间做出区分。Go 的标准库只包含 time.Time
类型。这可能会让你在处理 Go 的时间时感到困惑。
我希望认为 Go 对日期/时间的处理减少了创建微妙错误的机会。你看,当你谈论时间时,你必须非常小心和明确你所说的意思:你是谈论一个时间点还是一个时间段?实际上,日期是一个时间段(例如,08/01/2024 从 08/01/2024T00:00:00 开始,一直持续到 08/01/2024T23:59:59),尽管通常这不是意图。特定的日期/时间值也取决于你测量时间的位置。在科罗拉多州的丹佛,2023-11-05T08:00 与在德国柏林的 2023-11-05T08:00 是不同的。时间总是向前移动,但日期/时间可能会跳过或倒退:在科罗拉多州的丹佛,2023-11-05T02:59 之后,时间会倒退到 2023-11-05T02:00,因为那是科罗拉多州夏令时结束的时候。因此,实际上对于 2023-11-05T02:10:10 有两个时间实例,一个在山地夏令时,另一个在山地标准时。
目前生产中的许多软件错误都处理时间不正确。例如,如果你在计算客户订阅何时结束,你必须考虑该客户的位置和订阅结束的时间,否则,他们的订阅可能在最后一天提前(或延迟)结束。
本章包含以下关于正确处理日期/时间的食谱:
-
处理 Unix 时间
-
日期/时间组件
-
日期/时间算术
-
日期/时间的格式化和解析
-
处理时区
-
计时器
-
计时器
-
存储时间信息
处理 Unix 时间
Unix 时间是从 1970 年 1 月 1 日 UTC(纪元)开始经过的秒数(或毫秒、微秒或纳秒)。Go 使用 int64
来表示这些值,因此 Unix 时间以秒为单位可以表示过去或未来数十亿年的时间。Unix 时间以纳秒为单位可以表示 1678 年至 2262 年之间的日期值。Unix 时间是自纪元以来(或直到纪元)的绝对时间实例度量。它是独立于位置的,因此如果有两个 Unix 时间,s
和 t
,如果 s<t
,则 s
发生在 t
之前,无论位置如何。由于这些属性,Unix 时间通常用作标记事件发生时间(日志写入时、记录插入时等)的时间戳。
如何做到...
-
获取当前 Unix 时间,请使用以下方法:
-
time.Now().Unix() int64
:Unix 时间以秒为单位 -
time.Now().UnixMilli() int64
:Unix 时间以毫秒为单位 -
time.Now().UnixMicro() int64
:Unix 时间以微秒为单位 -
time.Now().UnixNano() int64
: 以纳秒为单位的 Unix 时间
-
-
给定一个 Unix 时间,使用以下方法将其转换为
time.Time
类型:-
time.Unix(sec, nanosec int64) time.Time
: 将秒和/或纳秒级的 Unix 时间转换为time.Time
-
time.UnixMilli(int64) time.Time
: 将毫秒级的 Unix 时间转换为time.Time
-
time.UnixMicro(int64) time.Time
: 将微秒级的 Unix 时间转换为time.Time
-
-
要将 Unix 时间转换为本地时间,请使用
localTime := time.Unix(unixTimeSeconds,0).In(location)
,其中location
是要解释 Unix 时间的位置的*time.Location
日期/时间组成部分
当处理日期值时,你通常需要从其组成部分组合一个日期/时间,或者需要访问日期/时间的组成部分。这个菜谱展示了如何做到这一点。
如何操作...
-
要从各个部分构建日期/时间值,请使用
time.Date
函数 -
要获取日期/时间值的各个部分,请使用
time.Time
方法:-
time.Day() int
-
time.Month() time.Month
-
time.Year() int
-
time.Date() (year, month, day int)
-
time.Hour() int
-
time.Minute() int
-
time.Second() int
-
time.Nanosecond() int
-
time.Zone() (name string,offset int)
-
time.Location() *time.Location
-
time.Date
将从其组成部分创建一个时间值:
d := time.Date(2020, 3, 31, 15, 30, 0, 0, time.UTC)
fmt.Println(d)
// 2020-03-31 15:30:00 +0000 UTC
输出将被标准化,如下所示:
d := time.Date(2020, 3, 0, 15, 30, 0, 0, time.UTC)
fmt.Println(d)
// 2020-02-29 15:30:00 +0000 UTC
由于月份的日期从 1 日开始,使用0
天创建的日期将导致上一个月的最后一天。
一旦你有一个time.Time
值,你可以获取其组成部分:
d := time.Date(2020, 3, 0, 15, 30, 0, 0, time.UTC)
fmt.Println(d.Day())
// 29
再次强调,time.Date
会标准化日期值,所以d.Day()
将返回29
。
日期/时间算术
日期/时间算术对于回答以下问题等是必要的:
-
完成一次操作需要多长时间?
-
5 分钟后将会是什么时间?
-
下个月还有多少天?
这个菜谱展示了如何使用time
包来回答这些问题。
如何操作...
-
要找出两个时间实例之间经过的时间,请使用
Time.Sub
方法来减去它们。 -
要找出从现在到较晚时间的时间间隔,请使用
time.Until(laterTime)
。 -
要找出从给定时间以来经过的时间,请使用
time.Since(beforeTime)
。 -
要找出经过一定时间后将会是什么时间,请使用
Time.Add
方法。使用负持续时间来查找在某个时间之前的时间。 -
要在时间上添加/减去年、月或日,请使用
Time.AddDate
方法。 -
要比较两个
time.Time
值,请使用以下方法:-
使用
Time.Equal
来检查两个时间值是否表示相同的实例 -
使用
Time.Before
或Time.After
来检查时间值是否在给定时间值之前或之后
-
它是如何工作的...
time.Duration
类型表示两个实例之间的时间间隔(以纳秒为单位)作为一个int64
值。换句话说,如果你从一个time.Time
值减去另一个值,你得到一个time.Duration
:
dur := tm1.Sub(tm2)
由于Duration
是一个表示纳秒的int64
,你可以进行持续时间算术:
// Add 1 day to duration
dur+=time.Hour*24
注意前面提到的最后一个操作也涉及到乘法,因为 time.Hour
本身就是 time.Duration
类型。
你可以将持续时间值添加到 time.Time
值中:
now := time.Now()
then := now.Add(dur)
小贴士
持续时间是一个 int64
类型意味着 time.Duration
值限制在大约 290 年左右。这对于大多数实际案例应该足够了。然而,如果你不满足这种情况,你需要为自己构建解决方案或寻找第三方库。
你可以通过添加一个负持续时间值从 time.Time
值中减去持续时间:
fmt.Println( then.Add(-dur).Equal(now) )
注意 Time.Equal
方法的使用。它比较两个时间实例,考虑到它们的时间区可能不同。例如,Time.Equal
将对 2024-01-09 09:00 MST
和 2024-01-09 08:00 PST
返回 true
。
使用 Time.Before
和 Time.After
来比较时间值。例如,你可以通过以下方式检查一个具有到期日期的对象是否已过期:
if object.Expiration.After(time.Now()) {
// Object expired
}
你也可以给一个给定的日期添加年/月/日:
t:=time.Now()
// Subtract 1 year from now to get this moment in last year
lastYear := t.AddDate(-1,0,0)
// Add 1 day to get same time tomorrow
tomorrow := t.AddDate(0,0,1)
// Add 1 day to get the next month
nextMonth := t.AddDate(0,1,0)
这些操作的结果将被标准化。例如,如果你从 2020-02-29
减去一年,你会得到 2019-03-01
。这在你处理月底的日期并需要加减月份值时会引起问题。将月份加到 2020-03-31
两次将得到 2020-06-01
,但加两个月将得到 2020-05-31
:
d := time.Date(2020, 3, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(d.AddDate(0, 1, 0).AddDate(0, 1, 0))
// 2020-06-01 00:00:00 +0000 UTC
fmt.Println(d.AddDate(0, 2, 0))
// 2020-05-31 00:00:00 +0000 UTC
日期/时间的格式化和解析
Go 使用了一个有趣且有些有争议的日期/时间格式化方案。日期/时间格式使用一个特定的时间点来表示,调整后使得日期/时间的每个组成部分都是唯一的数字:
-
1 是月份:“Jan” “January” “01” “1”
-
2 是月份中的天:“2” “_2” “02”
-
3 是 12 小时制中的小时:“15” “3” “03”
-
15 是 24 小时制中的小时,
-
4 是分钟:“4” “04”
-
5 是秒:“5” “05”
-
6 是年份:“2006” “06”
-
MST 是时区:“-0700” “-07:00” “-07” “-070000” “-07:00:00” “MST”
-
0 是填充了 0 的毫秒:“0” “000”
-
9 是未填充的毫秒:“9” “999”
如何做到这一点...
-
使用
time.Parse
和适当的格式来解析日期/时间。在格式中未指定的日期/时间部分将被初始化为其零值,月份为 1 月,年份为 1,月份中的天为 1,其余部分为 0。如果缺少时区信息,解析的日期/时间将使用 UTC。 -
使用
time.ParseInLocation
在指定位置解析日期/时间。时区将根据日期值和位置确定。 -
使用
Format()
方法来格式化日期/时间值。
func main() {
t := time.Date(2024, 3, 8, 18, 2, 13, 500, time.UTC)
fmt.Println("Date in yyyy/mm/dd format", t.Format("2006/01/02"))
// Date in yyyy/mm/dd format 2024/03/08
fmt.Println("Date in yyyy/m/d format", t.Format("2006/1/2"))
// Date in yyyy/m/d format 2024/3/8
fmt.Println("Date in yy/m/d format", t.Format("06/1/2"))
// Date in yy/m/d format 24/3/8
fmt.Println("Time in hh:mm format (12 hr)", t.Format("03:04"))
// Time in hh:mm format (12 hr) 06:02
fmt.Println("Time in hh:m format (24 hr)", t.Format("15:4"))
// Time in hh:m format (24 hr) 18:2
fmt.Println("Date-time with time zone", t.Format("2006-01-02
13:04:05 -07:00"))
// Date-time with time zone 2024-03-08 36:02:13 +00:00
}
时区根据位置和日期而变化。在以下示例中,即使使用相同的位置来解析日期,时区也会变化,因为 7 月 9 日是山地夏令时,而 1 月 9 日是山地标准时:
loc, _ := time.LoadLocation("America/Denver")
const format = "Jan 2, 2006 at 3:04pm"
str, _ := time.ParseInLocation(format, "Jul 9, 2012 at 5:02am", loc)
fmt.Println(str)
// 2012-07-09 05:02:00 -0600 MDT
str, _ = time.ParseInLocation(format, "Jan 9, 2012 at 5:02am", loc)
fmt.Println(str)
// 2012-01-09 05:02:00 -0700 MST
处理时区
Go 的 time.Time
值包括 time.Location
,这可以是两种情况之一:
-
一个真实的位置,例如
America/Denver
。如果是这种情况,实际时区将取决于时间值。对于Denver
,时区将是MDT
(山地夏令时)或MST
(山地标准时),具体取决于实际的时间值。 -
一个提供偏移量的固定时区。
一些应用程序使用 本地时间。这是在特定位置捕获的日期/时间值,并在任何地方解释为相同的值,而不是解释为相同的时间点。生日(因此,年龄)通常使用本地时间来解释。也就是说,如果你在 2005-07-14 出生,你将在 2007-07-14 00:00(东部时区)在纽约被认为是 2 岁,但在同一点时间在洛杉矶,即 2007-07-13 21:00(太平洋时区),你仍然是 1 岁。
如何做到这一点...
如果你正在处理时间点,始终使用相关位置捕获日期/时间值。这些日期/时间值可以轻松转换为其他时区。
如果你正在处理多个时区的本地时间,在新的位置或时区中重新创建 time.Time
以进行转换。
它是如何工作的...
当你创建一个 time.Time
时,它总是与一个位置相关联:
// Create a new time using the local time zone
t := time.Date(2021,12,31,15,0,0,0, time.Local)
// 2021-12-31 15:00:00 -0700 MST
一旦你有一个 time.Time
,你就可以在不同的时区中获取同一时间点:
utcTime := t.In(time.UTC)
fmt.Println(utcTime)
// 2021-12-31 22:00:00 +0000 UTC
ny,err:=time.LoadLocation("America/New_York")
if err!=nil {
panic(err)
}
nyTime := t.In(ny)
fmt.Println(nyTime)
// 2021-12-31 17:00:00 -0500 EST
这些是在不同时区中同一时间点的不同表示。
你也可以创建一个自定义时区:
zone30 := time.FixedZone("30min", 30)
fmt.Println(t.In(zone30))
// 2021-12-31 22:00:30 +0000 30min
当你处理本地时间时,你会丢弃位置和时间区域信息:
// Create a local time, UTC zone
t := time.Date(2021,12,31,15,0,0,0, time.UTC)
// 2021-12-31 15:00:00 +0000 UTC
要在纽约获取相同的时间值,请使用以下方法:
ny,err:=time.LoadLocation("America/New_York")
if err!=nil {
panic(err)
}
nyTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), ny)
fmt.Println(nyTime)
// 2021-12-31 15:00:00 -0500 EST
存储时间信息
一个常见问题是将日期/时间信息以可移植的方式存储在数据库、文件中等,以便可以正确解释。
如何做到这一点...
你应该首先确定确切的需求:你需要存储一个时间点还是一天中的时间?
-
要存储一个时间点,执行以下操作之一:
-
在所需粒度上存储 Unix 时间(即
time.Unix
用于秒,time.UnixMilli
用于毫秒等)。 -
存储 UTC 时间 (
time.UTC()
)
-
-
要存储一天中的时间,存储表示一天中瞬间的
time.Duration
值。以下函数计算该天内的瞬间作为time.Duration
:func GetTimeOfDay(t time.Time) time.Duration { beginningOfDay:=time.Date(t.Year(),t.Month(),t. Day(),0,0,0,0,t.Location()) return t.Sub(beginningOfDay) }
-
要存储日期值,你可以清除
time.Time
的时间部分:date:=time.Date(t.Year(), t.Month(), t.Day(), 0,0,0,0,t.Location())
注意,以这种方式存储的日期比较可能会出现问题,因为每个时区都会将每天解释为不同的瞬间。
定时器
使用 time.Timer
来安排将来要执行的一些工作。当定时器到期时,你将从一个通道接收到一个信号。你可以使用定时器在以后运行一个函数或取消运行时间过长的进程。
如何做到这一点...
你可以通过两种方式之一创建一个定时器:
-
使用
time.NewTimer
或time.After
。定时器将在到期时通过通道发送一个信号。使用select
语句或从通道读取以接收定时器到期信号。 -
使用
time.AfterFunc
在计时器到期时调用一个函数。
它是如何工作的...
使用 time.Duration
创建 time.Timer
计时器:
// Create a 10-second timer
timer := time.NewTimer(time.Second*10)
计时器包含一个通道,在 10 秒后将会接收到当前的时间戳。计时器创建时通道容量为 1
,因此计时器运行时总能向该通道写入并停止计时器。换句话说,如果你未能从计时器中读取,它不会泄漏;它最终会被垃圾回收。
计时器可以用来停止一个长时间运行的过程:
func longProcess() {
timer := time.NewTimer(time.Second*10)
for {
processData()
select {
case <-timer.C:
// 2 seconds passed
return
default:
}
}
}
以下示例展示了如何使用计时器来限制函数返回所需的时间。如果计算在一秒内完成,则返回响应。如果计算时间更长,则函数返回一个调用者可以使用以接收结果的通道。此函数还演示了如何停止计时器:
func longComputation() (concurrent chan Result, result Result) {
timer:=time.NewTimer(time.Second)
concurrent=make(chan Result)
// Start the concurrent computation. Its result will be received
// from the channel
go func() {
concurrent <- processData()
}()
// Wait until result is available, or timer expires
select {
case result:=<-concurrent:
// Result became available quickly. Stop the timer and return
//the result.
timer.Stop()
return nil,result
case <-timer.C:
// Timer expired before result is computed. Return the channel
return concurrent,Result{}
}
}
注意,计时器可能在调用 timer.Stop()
之前就到期了。这是可以的。计时器最终都会到期并被垃圾回收。调用 timer.Stop()
只是为了防止计时器比必要的持续时间更长。
提示
当另一个 goroutine 正在监听计时器时,你不能并发地调用 Timer.Stop
。所以,如果你必须调用 Timer.Stop
,请从监听计时器通道的同一个 goroutine 中调用它。
同样可以使用 time.After
实现:
concurrent=make(chan Result)
// Start the concurrent computation. Its result will be received
// from the channel
go func() {
concurrent <- processData()
}()
select {
case result:=<-concurrent:
return nil,result
case <-time.After(time.Second):
return concurrent,Result{}
}
计时器
使用 time.Ticker
定期执行任务。你将通过通道定期接收到信号。与 time.Timer
不同,你必须小心处理计时器。如果你忘记停止计时器,一旦超出作用域,它就不会被垃圾回收,并且会发生泄漏。
如何做到这一点...
-
使用
time.Ticker
创建一个新的计时器。 -
从计时器的通道读取以接收周期性的滴答声。
-
当你完成对计时器的使用后,停止它。你不需要排空计时器的通道。
它是如何工作的...
使用计时器进行周期性事件。一个常见的模式如下:
func poorMansClock(done chan struct{}) {
// Create a new ticker with a 1 second period
ticker:=time.NewTicker(time.Second)
// Stop the ticker once we're done
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Println(time.Now())
}
}
}
如果你错过了滴答声会发生什么?如果你运行了一个长时间的过程,阻止你监听计时器通道,那么当你再次开始监听时,计时器会发送大量的滴答声吗?
与 time.Timer
类似,time.Ticker
也使用一个容量为 1
的通道。因此,如果你不从这个通道读取,它最多只能存储一个滴答声。当你再次从通道开始监听时,你会立即接收到你错过的滴答声,以及在其周期到期时的下一个滴答声。例如,考虑以下每秒调用给定函数的程序:
func everySecond(f func(), done chan struct{}) {
// Create a new ticker with a 1 second period
ticker:=time.NewTicker(time.Second)
start:=time.Now()
// Stop the ticker once we're done
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Println(time.Since(start).Milliseconds())
// Call the function
f()
}
}
}
假设第一次调用 f()
运行时间为 10 毫秒,但第二次调用运行时间为 1.5 秒。在 f()
运行期间,没有人从计时器的通道读取,因此会错过一个滴答声。一旦 f()
返回,select
语句将立即读取这个错过的滴答声,并在 500 毫秒后接收到下一个滴答声。输出看起来像这样:
1000
2000
3500
4000
5000
...
提示
与time.Timer
不同,你可以在从其通道读取的同时并发地停止一个计时器。
第四章:与数组、切片和映射一起工作
数组、切片和映射是 Go 语言定义的内置容器类型。它们是几乎所有程序的基本组成部分,通常是其他数据结构的基本构建块。本节描述了使用这些基本数据结构的某些常见模式,因为它们可能对新手不明显。
在本章中,我们将讨论以下内容:
-
与数组一起工作
-
与切片一起工作
-
使用切片实现栈
-
与映射一起工作
-
实现集合
-
使用映射进行线程安全的缓存
与数组一起工作
数组是固定大小的数据结构。无法调整数组的大小或使用变量作为其大小创建数组(换句话说,[n]int
仅在n
是一个常量整数时有效)。正因为如此,数组对于表示具有固定元素数量的对象非常有用,例如 SHA256 哈希,它是 32 字节。
数组的零值对于数组的每个元素都是零值。例如,[5]int
初始化为五个整数,全部为 0。字符串数组将包含空字符串。
创建数组并在它们之间传递
本食谱展示了如何创建数组并将数组值传递给函数和方法。我们还将讨论传递数组作为值的效应。
如何做到...
-
使用固定大小创建数组:
var arr [2]int // Array of 2 ints x := [...]int{1,2} // Array of 2 ints
你可以指定数组索引,类似于定义映射:
y := [...]int{1, 4: 10} // Array of 5 ints, // [0]1, y[4]=10, all other elements are 0 // [1 0 0 0 10]
-
使用数组定义新的固定大小数据类型:
// SHA256 hash is 256 bits - 32 bytes type SHA256 [32]byte
-
数组是通过值传递的:
func main() { var h SHA256 h = getHash() // f will get a 32-byte array that is a copy of h f(h) ... } func f(hash SHA256) { hash[0]=0 // This changes the copy of `hash` passed to `f`. // It does not affect the `h` value declared in main ... }
警告
通过值传递数组意味着每次你将数组作为函数的参数使用时,数组都会被复制。如果你将一个[1000]int64
数组传递给一个函数,运行时将分配和复制 8,000 字节(int64 是 64 位,即 8 字节,1,000 个 int64 值是 8,000 字节。)复制将是一个浅复制——也就是说,你传递了一个包含指针的数组,或者如果你传递了一个包含包含指针的结构体的数组,指针将被复制,而不是这些指针的内容。
请参阅以下示例:
func f(m [2]map[string]int) {
m[0]["x"]=1
}
func main() {
array := [2]map[string]int{}
// A copy of array is passed to f
// but array[0] and array[1] are maps
// Contents of those maps are not copied.
f(array)
fmt.Println(array[0])
// This will print [x:1]
}
与切片一起工作
切片是数组的视图。你可能正在处理多个与相同底层数据一起工作的切片。
切片的零值是 nil。读取或写入 nil 切片将panic
;然而,你可以向 nil 切片追加,这将创建一个新的切片。
创建切片
有几种方法可以创建切片。
如何做到...
使用make(sliceType,length[,capacity])
:
slice1 := make([]int,0)
// len(slice1)=0, cap(slice1)=0
slice2 := make([]int,0,10)
// len(slice2)=0, cap(slice2)=10
slice3 := make([]int,10)
// len(slice3)=10, cap(slice3)=10
在前面的代码片段中,你看到了make
的三种不同用法来创建切片:
-
slice1:=make([]int,0)
创建了一个空切片,0
是切片的长度。slice1
变量初始化为一个非空、0 长度的切片。 -
slice2 := make([]int,0,10)
创建了一个容量为10
的空切片。如果你知道这个切片可能的最大大小,你应该选择这种方式。这种切片分配避免了在追加第 11 个元素之前的分配/复制操作。 -
slice3 := make([]int,10)
创建了一个大小为10
、容量为10
的切片。切片元素被初始化为 0。一般来说,使用这种形式,分配的切片将被初始化为其元素类型的零值。
小贴士
注意使用非零长度的切片分配。我本人就因为误将 make([]int,10)
错写成 make([]int,0,10)
,然后继续向分配的切片追加 10 个元素,最终导致有 20 个元素。
请参阅以下示例:
values:=make([]string,10)
for _,s:=range results {
if someFunc(s) {
values=append(values,s)
}
}
之前的代码片段创建了一个包含 10
个空字符串的字符串切片,然后通过循环将这些字符串追加进去。
你也可以使用字面量初始化切片:
slice := []int{1,2,3,4,5}
// len(slice)=5 cap(slice)=5
或者,你可以将切片变量留为 nil
,然后向其追加。内置的 append
函数将接受一个 nil
切片,并创建一个新的:
// values slice is nil after declaration
var values []string
for _,x:=range results {
if someFunc(s) {
values=appennd(values, s)
}
}
从数组创建切片
许多函数将接受切片而不是数组。如果你有一个值数组,需要将其传递给需要一个切片的函数,你需要从数组创建一个切片。这很简单且高效。从数组创建切片是一个常数时间操作。
如何实现...
使用 [:]
语法从数组创建切片。该切片将以数组作为其底层存储:
arr := [...]int{0, 1, 2, 3, 4, 5}
slice := arr[:] // slice has all elements of arr
slice[2]=10
// Here, arr = [...]int{0,1,10,3, 4,5}
// len(slice) = 6
// cap(slice) = 6
你可以创建一个指向数组某部分的切片:
slice2 := arr[1:3]
// Here, slice2 = {1,10}
// len(slice2) = 2
// cap(slice2) = 5
你可以切片现有的切片。切片操作的界限由原始切片的容量决定:
slice3 := slice2[0:4]
// len(slice3)=4
// cap(slice3)=5
// slice3 = {1,10,3,4}
它是如何工作的...
切片是一个包含三个值的数据结构:切片长度、容量以及指向底层数组的指针。切片一个数组简单地说就是创建这样一个数据结构,并将指针初始化为数组。这是一个常数时间操作。
图 4.1 – 数组 arr 和切片 arr[:] 之间的区别
追加/插入/删除切片元素
切片使用数组作为其底层存储,但在空间不足时无法增长数组。正因为如此,如果 append
操作超出了切片容量,就会分配一个新的更大的数组,并将切片内容复制到这个新数组中。
如何实现...
要向切片的末尾添加新值,请使用 append
内置函数:
// Create an empty integer slice
islice := make([]int, 0)
// Append values 1, 2, 3 to islice, assign it to newSlice
newSlice := append(islice, 1, 2, 3)
// islice: []
// newSlice: [1 2 3]
// Create an empty integer slice
islice = make([]int, 0)
// Another integer slice with 3 elements
otherSlice := []int{1, 2, 3}
// Append 'otherSlice' to 'islice'
newSlice = append(islice, otherSlice...)
newSlice = append(newSlice, otherSlice...)
// islice: []
// otherSlice: [1 2 3]
// newSlice: [1 2 3 1 2 3]
要从切片的开始或结束处删除元素,请使用切片:
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// Slice elements starting from index 1
suffix := slice[1:]
// suffix: [1 2 3 4 5 6 7 8 9]
// Slice elements starting from index 3
suffix2 := slice[3:]
// suffix2: [3 4 5 6 7 8 9]
// Slice elements up to index 5 (excluding 5)
prefix := slice[:5]
// prefix: [0 1 2 3 4]
// Slice elements from 3 up to index 6 (excluding 6)
mid := slice[3:6]
// [3 4 5]
使用 slices
包在切片的任意位置插入/删除元素:
-
slices.Delete(slice,i,j)
从切片中删除slice[i:j]
的元素,并返回修改后的切片 -
slices.Insert(slice,i,value...)
在索引i
处插入值,并将从i
开始的所有元素移动以腾出空间
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// Remove the section slice[3:7]
edges := slices.Delete(slice, 3, 7)
// edges: [0 1 2 7 8 9]
// slice: [0 1 2 7 8 9 0 0 0 0]
inserted := slices.Insert(slice, 3, 3, 4)
// inserted: [0 1 2 3 4 7 8 9 0 0 0 0]
// edges: [0 1 2 7 8 9]
// slices: [0 1 2 7 8 9 0 0 0 0]
或者,你可以使用循环从切片中删除元素并截断它,如下所示:
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// Keep an index to write to
write:=0
for _, elem := range slice {
if elem %2 == 0 { // Copy only even numbers
slice[write]=elem
write++
}
}
// Truncate the slice
slice=slice[:write]
它是如何工作的...
切片是数组的视图。它包含三个信息:
-
ptr
:指向数组元素的指针,这是切片的起始位置 -
len
:切片中元素的数量 -
cap
:此切片在基础数组中剩余的容量
如果向切片添加的元素超出了其容量,运行时会分配一个更大的数组,并将切片的内容复制到那里。之后,新的切片指向一个新的数组。
这对许多人来说是一个混淆的来源。切片可能与其他切片共享其元素。因此,修改一个切片也可能修改其他切片。
图 4.2 展示了一个使用相同基础数组为四个不同切片的情况:
图 4.2 – 共享相同基础数组的切片
请看以下示例:
// Appends 1 to a slice, and returns the new slice
func Append1(input []int) []int {
return append(input,1)
}
func main() {
slice:= []int{0,1,2,3,4,5,6,7,8,9}
shortSlice := slice[:4]
// shortSlice: []int{0,1,2,3}
newSlice:=Append1(slice[:4])
// newSlice:= []int{0,1,2,3,1}
// slice: []int{0,1,2,3,1,5,6,7,8,9}
}
注意,向 newSlice
添加元素也会修改 slice
的一个元素,因为 newSlice
有足够的容量来容纳一个额外的元素,这会覆盖 slice[4]
。
截断切片只是创建一个比原始切片更短的新的切片。基础数组不会改变。请看以下:
slice:= []int{0,1,2,3,4,5,6,7,8,9}
newSlice:=slice[:5]
// newSlice: []int{0,1,2,3,4}
记住,newSlice
只是一个包含相同 ptr
和 cap
的数据结构,但 len
较短。正因为如此,从现有切片或数组创建新切片是一个常数时间操作(O(1))。
使用切片实现栈
切片的一个令人惊讶的常见用途是实现栈。以下是实现方式。
如何实现...
栈的推送操作简单就是 append
:
// A generic stack of type T
type Stack[T any] []T
func (s *Stack[T]) Push(val T) {
*s = append(*s, val)
}
要实现 pop
,截断切片:
func (s *Stack[T]) Pop() (val T) {
val = (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return
}
再次注意括号和间接引用的使用。我们不能写 *s[len(*s)-1]
,因为这被解释为 *(s[len(*s)-1])
。为了防止这种情况,我们使用 (*s)
。
与映射一起工作
您可以使用整数索引访问数组或切片的元素。映射提供类似的语法来使用索引键,这些键不仅限于整数,还可以是任何“可比较”的类型(这意味着可以使用 ==
或 !=
进行比较。)映射是一种关联数据类型——也就是说,它存储键值对。映射中每个键只出现一次。Go 映射为其元素提供平均常数时间的访问(也就是说,当从时间角度测量时,映射元素访问应该看起来像是一个常数时间操作。)
Go 的 map
类型提供了对底层复杂数据结构的方便访问。它是一种“引用”类型——也就是说,将映射变量赋值给另一个映射只是分配了对底层结构的指针,并不会复制映射的元素。
警告
映射是无序集合。不要依赖于映射中元素的顺序。相同的插入顺序可能在不同的程序中不同时间导致不同的迭代顺序。
定义、初始化和使用映射
与切片类似,映射的零值是 nil。从 nil 映射读取的结果与从没有元素的 non-nil 映射读取的结果相同。向 nil 映射写入将引发 panic。本节展示了初始化和使用映射的不同方式。
如何实现...
使用 make
创建一个新的映射,或使用字面量。您不能向 nil 映射写入(但可以从中读取!),因此您必须使用 make
或字面量初始化所有映射:
func main() {
// Make a new empty map
m1 := make(map[int]string)
// Initilize a map using empty map literal
m2 := map[int]string{}
// Initialize a map using a map literal
m3 := map[int]string {
1: "a",
2: "b",
}
...
与切片不同,映射的值不是 可寻址的:
type User struct {
Name string
}
func main() {
usersByID := make(map[int]User)
usersByID[1]=User{Name:"John Doe"}
fmt.Println(usersByID[1].Name)
// Prints: John Doe
// The following will give a compile error
usersByID[1].Name="James"
...
}
在上一个示例中,您不能设置存储在映射中的结构体的成员变量。当您使用 usersByID[1]
访问该映射元素时,您得到的是映射中存储的 User
的副本,将它的 Name
设置为其他值的效果将会丢失,因为这个副本没有存储在任何地方。
因此,相反,您可以读取并将映射值赋给可寻址的变量,更改它,并将其设置回:
user := usersByID[1]
user.Name="James"
usersByID[1]=user
或者,您可以在映射中存储指针:
userPtrsByID := make(map[int]*User)
userPtrsByID[1]=&User {
Name: "John Doe"
}
userPtrsByID[1].Name = "James" // This works.
如果映射没有给定键的元素,它将返回映射值类型的零值:
user := usersByID[2] // user is set to User{}
userPtr := userPtrsByID[2] // userPtr is set to nil
为了区分零值是返回因为映射没有元素,还是因为零值存储在映射中,请使用映射查找的两个值版本:
user, exists := usersByID[1] // exists = true
userPtr, exists := userPtrsByID[2] // exists = false
使用 delete
从映射中删除一个元素:
delete(usersByID, 1)
使用映射实现集合
集合用于从值集合中删除重复项。通过利用零大小值结构,映射可以有效地用作集合。
如何实现...
使用键类型为集合元素类型,值类型为 struct{}
的映射:
stringSet := make(map[string]struct{})
使用 struct{}{}
值向集合中添加值:
stringSet[value]=struct{}{}
使用映射查找的两个值版本检查值的存在:
if _,exists:=stringSet[str]; exists {
// String str exists in the set
}
映射是无序的。如果元素的排序很重要,请保留一个与映射一起的切片:
// Remove duplicate inputs from the input, preserving order
func DedupOrdered(input []string) []string {
set:=make(map[string]struct{})
output:=make([]string,0,len(input))
for _,in:=range input {
if _,exists:=set[in]; exists {
continue
}
output=append(output,in)
set[in]=struct{}{}
}
return output
}
它是如何工作的...
struct{}
结构体是一个零大小对象。这样的对象由编译器和运行时分别处理。当用作映射中的值时,映射只为它的键分配存储空间。因此,这是一种实现集合的高效方式。
警告
从不依赖于零大小结构的指针等价性。编译器可能会选择将两个具有零大小的不同变量放置在相同的内存位置。
以下比较的结果是未定义的:
x:=&struct{}{}
y:=&struct{}{}
if x==y {
//
执行某些操作
}
x==y
的结果可能返回 true
或 false
。
复合键
当您有多个值用于标识特定对象时,您需要复合键。例如,假设您正在处理一个系统,其中用户可能有多个会话。您可以将这些信息存储在映射的映射中,或者创建一个包含用户 ID 和会话 ID 的复合键。
如何实现...
使用可比较的结构体或数组作为映射键。一般来说,可比较的结构体是一个不包含以下内容的结构体:
-
切片
-
通道
-
函数
-
映射
-
其他不可比较的结构体
因此,要使用复合键,请执行以下步骤:
- 定义一个可比较的结构体:
type Key struct {
UserID string
SessionID string
}
type User struct {
Name string
...
}
var compositeKeyMap = map[Key]User{}
- 使用映射键的实例来访问元素:
compositeKeyMap[Key{
UserID: "123",
SessionID: "1",
}] = User {
Name: "John Doe",
}
- 您可以使用一个字面量映射来初始化它:
var compositeKeyMap = map[Key]User {
Key {
UserID: "123",
SessionID: "1",
}: User {
Name: "John Doe",
},
}
它是如何工作的...
映射实现从其键生成哈希值,然后使用比较运算符来检查等价性。因此,任何可比较的数据结构都可以用作键值。
注意指针比较。包含指针字段的 struct 将检查指针的等价性。考虑以下键:
type KeyWithPointer struct {
UserID string
SessionID *int
}
var sessionMap = map[KeyWithPointer]{}
func main() {
session := 1
key := KeyWithPointer{
UserID: "John",
SessionID: &session,
}
sessionMap[key]=User{ Name: "John Doe"}
在前面的代码片段中,复合映射键包含对 session
的指针和一个整数。在你将元素添加到映射后,更改 session
的值不会影响指向该变量的映射键。映射键仍然指向相同的变量。只有当 KeyWithPointer
的另一个实例也指向相同的 session
变量时,才能使用以下方式定位 User
对象:
fmt.Println( sessionMap[KeyWithPointer{
UserID: "John",
SessionID: &session,
}].Name) // "John Doe"
但:
i:=1
fmt.Println( sessionMap[KeyWithPointer{
UserID: "John",
SessionID: &i,
}].Name) // ""
使用映射的线程安全缓存
有时为了达到可接受的性能,缓存是必要的。想法是重用之前计算或检索的值。映射是缓存此类值的自然选择,但由于其本质,缓存通常在多个 goroutine 之间共享,因此在使用它们时必须小心。
简单缓存
这是一个简单的缓存,具有 get/put
方法,用于从缓存中检索对象并将其放入其中。
如何实现...
要缓存可以通过键访问的值,请使用包含映射和互斥锁的结构:
type ObjectCache struct {
mutex sync.RWMutex
values map[string]*Object
}
// Initialize and return a new instance of the cache
func NewObjectCache() *ObjectCache {
return &ObjectCache{
values: make(map[string]*Object),
}
}
应防止直接访问缓存内部,以确保在每次使用缓存时都遵守适当的协议:
// Get an object from the cache
func (cache *ObjectCache) Get(key string) (*Object, bool) {
cache.mutex.RLock()
obj, exists := cache.values[key]
cache.mutex.RUnlock()
return obj, exists
}
// Put an object into the cache with the given key
func (cache *ObjectCache) Put(key string, value *Object) {
cache.mutex.Lock()
cache.values[key] = value
cache.mutex.Unlock()
}
具有阻塞行为的缓存
如果多个 goroutine 从前面示例中的简单缓存请求相同的键,它们都可能决定检索对象并将其放回缓存。这是低效的。通常,你希望其中一个 goroutine 检索对象,而其他 goroutine 等待。这可以通过使用 sync.Once
来实现。
如何实现...
缓存元素是包含 sync.Once
的结构,以确保一个 goroutine 获取对象,而其他 goroutine 等待它。此外,缓存包含一个 Get
方法,该方法使用 getObjectFunc
回调函数来检索对象,如果它不在缓存中:
type cacheItem struct {
sync.Once
object *Object
}
type ObjectCache struct {
mutex sync.RWMutex
values map[string]*cacheItem
getObjectFunc func(string) (*Object, error)
}
func NewObjectCache(getObjectFunc func(string) (*Object,error)) *ObjectCache {
return &ObjectCache{
values: make(map[string]*cacheItem),
getObjectFunc: getObjectFunc,
}
}
func (item *cacheItem) get(key string, cache *ObjectCache) (err error) {
// Calling item.Once.Do
item.Do(func() {
item.object, err=cache.getObjectFunc(key)
})
return
}
func (cache *ObjectCache) Get(key string) (*Object, error) {
cache.mutex.RLock()
object, exists := cache.values[key]
cache.mutex.RUnlock()
if exists {
return object.object, nil
}
cache.mutex.Lock()
object, exists = cache.values[key]
if !exists {
object = &cacheItem{}
cache.values[key] = object
}
cache.mutex.Unlock()
err := object.get(key, cache)
return object.object, err
}
它是如何工作的...
Get
方法首先读取锁定缓存。然后它检查键是否存在于缓存中,并解锁它。如果值已缓存,则返回。
如果值不在缓存中,则缓存被写入锁定,因为这将是 values
映射的并发修改。再次检查 values
映射,以确保另一个 goroutine 尚未将其放入其中。如果没有,此 goroutine 将在缓存中放入一个未初始化的 cacheItem
并解锁它。
cacheItem
包含一个 sync.Once
,这允许只有一个 goroutine 在其他 goroutine 正在等待获胜调用完成时调用 Once.Go
。这就是从 cacheItem.get
方法调用 getObjectFunc
回调的时候。在这个时候,不可能发生内存竞争,因为只有一个 goroutine 可以执行 item.Do
函数。函数的结果将被存储在 cacheItem
中,因此不会对 values
映射的使用者造成任何问题。实际上,请注意,当 getObjectFunc
正在运行时,缓存没有被锁定。可以有多个其他 goroutine 读取和/或写入缓存。
第五章:处理类型、结构体和接口
Go 是一种强类型语言。这意味着程序中的每个值都必须使用一组预定义的基本类型来定义。类型系统的规则决定了可以对这些值做什么,以及不同类型的值如何交互。Go 的类型系统采用了一种简化的方法;它只允许在不同兼容类型的值之间进行显式转换。
Go 还是一种静态类型语言,这意味着值的类型在编译时被显式声明和检查,而不是在运行时检查。这与 Python 或 JavaScript 这样的脚本语言不同。
在本章中,我们将探讨 Go 类型系统的一些属性,定义新类型、结构体和接口,并考虑如何有效地使用它来实现一些常见模式。
本章包含以下食谱:
-
创建新类型
-
使用组合来扩展类型
-
初始化结构体
-
处理接口
-
工厂模式
-
多态容器
创建新类型
您想要定义新类型的原因有很多。其中之一是确保类型安全。类型安全确保操作接收正确的数据类型。类型安全的程序没有类型错误,将程序中的错误限制为仅逻辑错误。
创建新类型的其他原因还包括以下:
-
您可以通过 嵌入 来在多个不同类型中共享类型的方法和数据字段。
-
在本章的后面部分,我们将探讨接口。您可以定义一组方法来实现给定的接口,以便在不同的上下文中使用该类型。
基于现有类型创建新类型
创建新类型允许您强制执行类型安全规则,并添加类型特定的方法。
如何做到...
使用以下语法根据现有类型创建新类型:
type <NewTypeName> <ExistingTypeName>
例如,以下声明定义了一个新的数据类型 Duration
,作为一个无符号 64 位整数:
type Duration uint64
这是 Go 标准库定义 time.Duration
的方式。要调用 time.Sleep(d Duration)
函数,您现在必须使用 time.Duration
值,或者显式地将数值值转换为 time.Duration
值。
警告
当您从现有类型创建新类型时,即使现有类型已定义了方法,新类型也会创建而没有任何方法。
创建类型安全的枚举
在这个食谱中,我们将使用新类型定义一组常量(枚举)。
如何做到...
-
定义新类型:
type Direction int
-
使用新类型创建表示枚举值的常量序列。您可以使用
iota
为数值常量生成递增的数字:const ( DirectionLeft Direction = iota DirectionRight )
-
在期望此新类型的函数或数据元素中使用新类型:
func SetDirection(dir Direction) {...} func main() { SetDirection(DirectionLeft) SetDirection(Direction(0)) ... }
小贴士
这并不阻止某人调用 SetDirection(Direction(3))
,这是一个无效的值。这通常只会在从用户输入或第三方来源读取枚举值时成为问题。你应该在那个点验证输入。
创建结构体类型
Go 结构体是一系列字段的集合。定义结构体以将相关数据字段分组,形成一个记录。这个菜谱展示了如何在程序中创建新的结构体类型。
如何做到这一点...
使用以下语法创建结构体类型:
type NewTypeName struct {
// List of fields
}
例如:
type User struct {
Username string
Password string
}
扩展类型
Go 通过嵌入使用类型组合,并通过使用接口实现结构化类型。让我们首先检查这些意味着什么。
当你将一个现有类型嵌入到另一个类型中时,为嵌入类型定义的方法和数据字段将成为嵌入类型的方法和数据字段。如果你使用过面向对象的语言,这可能会让你觉得类似于类继承,但有一个关键的区别:如果一个类 A
从类 B
继承,那么 A
是 B
的一个实例,意味着在需要 B
的任何地方,你可以用一个 A
的实例来替换。在使用组合的情况下,如果 A
嵌入了 B
,那么 A
和 B
是不同的类型,你不能在需要 B
的地方使用 A
。
提示
Go 中没有类型继承。Go 选择组合而不是继承。这样做的主要原因是为了简化组合组件以构建更复杂的组件。面向对象语言中继承的大多数用例都可以通过组合、接口和结构化类型重新设计。在这里我故意使用了“rearchitecting”这个词:不要试图通过模拟继承来将现有的面向对象程序移植到 Go 中。相反,重新设计和重构它们,以使用组合和接口成为惯用的 Go 程序。
下一个菜谱将探讨如何实现这一点。
扩展基类型
首先,我们将看看我们如何扩展一个基类型,以便在新类型中共享其数据元素和方法。
如何做到这一点...
假设你有一些在多个数据类型之间共享的数据字段和功能。然后你可以创建一个基数据类型,并将其嵌入到多个其他数据类型中,以共享共同的部分:
type Common struct {
commonField int
}
func (a Common) CommonMethod() {}
type A struct {
Common
aField int
}
func (a A) AMethod() {}
type B struct {
Common
bField int
}
func (b B) BMethod() {}
在前面的代码片段中,每个结构体的字段和方法如下:
类型 | 字段 | 方法 |
---|---|---|
Common | commonField | CommonMethod |
A | commonField, aField | CommonMethod, AMethod |
B | commonField, bField | CommonMethod, BMethod |
它是如何工作的...
在上一节中,我们使用了结构体嵌入来共享常见的数据元素和功能。以下示例展示了两个结构体,Customer
和 Product
,它们共享相同的 Metadata
结构体。Metadata
包含记录的唯一标识符、创建日期和修改日期:
type Metadata struct {
ID string
CreatedAt time.Time
ModifiedAt time.Time
}
// New initializes metadata fields
func (m *Metadata) New() {
m.ID=uuid.New().String()
m.CreatedAt=time.Now()
m.ModifiedAt=m.CreatedAt
}
// Customer.New() uses the promoted Metadata.New() method.
// Calling Customer.New() will initialize Customer.Metadata, but
// will not modify Customer specific fields.
type Customer struct {
Metadata
Name string
}
// Product.New(string) shadows `Metadata.New() method. You cannot
// call `Product.New()`, but call `Product.New(string)` or
// `Product.Metadata.New()`
type Product struct {
Metadata
SKU string
}
func (p *Product) New(sku string) {
// Initialize the metadata part of product
p.Metadata.New()
p.SKU=sku
}
func main() {
c:=Customer{}
c.New() // Initialize customer metadata
p:=Product{}
p.New("sku") // Initialize product metadata and sku
// p.New() // Compile error: p.New() takes a string argument
}
嵌入不是继承。嵌入结构体方法的接收者不是定义的结构体的副本。在上面的代码片段中,当我们调用 c.New()
时,Metedata.New()
方法得到的接收者是一个 *Metadata
实例,而不是 *Customer
实例。
初始化结构体
这个配方展示了如何使用结构字面量初始化包含嵌入结构的复杂数据结构。
如何实现...
Go 保证所有声明的变量都被初始化为其零值。如果你有一个复杂的数据结构,应该使用默认值或非空指针组件进行初始化,这并不很有用。在这种情况下,使用类似构造函数的函数来创建结构体的新实例。对于类型 X
,已建立的约定是编写一个 NewX
函数,该函数初始化 X
或 *X
的实例并返回它。
这里,NewIndex
创建了一个初始化的 Index
类型的新实例:
type Index struct {
index map[string]any
name string
}
func NewIndex(name string) *Index {
return &Index{
index:make(map[string]any),
name:name,
}
}
func (index *Index) Name() string {return index.name}
func (index *Index) Add(key string, value any) {
index.index[key]=value
}
此外,请注意,Index.name
和 Index.index
字段没有导出。因此,它们只能通过 Index
的导出方法访问。这种模式对于防止意外修改数据字段很有用。
定义接口
Go 使用“结构化类型”。如果一个类型 T
定义了一个接口 I
的所有方法,那么 T
就实现了 I
。这导致了一些熟悉使用命名类型语言的开发者(如 Java,其中你必须显式地命名组成类型)感到困惑。
Go 的接口仅仅是方法集。当一个数据类型定义了一组方法时,它也会自动实现包含其方法子集的所有接口。例如,如果数据类型 A
定义了一个 func (A) F()
方法,那么 A
也实现了 interface { func F() }
和 interface{}
接口。如果接口 A
是接口 B
的子集,那么实现接口 B
的数据类型可以在需要 A
的任何地方使用。
接口作为契约
接口可以用作“规范”,或像“契约”一样定义实现应满足的某些函数。
如何实现...
定义一个接口或一组接口以指定对象的预期行为。当预期有多个不同接口的实现时,这很合适。例如,标准库 database/driver
SQL 驱动程序包定义了一组接口,这些接口应由不同的数据库驱动程序实现。
例如,以下代码片段定义了一个用于存储文件的存储后端:
type Storage interface {
Create(name string, reader io.Reader) error
Read(name string) (io.ReadCloser,error)
Update(name string, reader io.Reader) error
Delete(name string) error
}
你可以使用实现 Storage
接口的对象的实例在不同的后端存储数据,例如文件系统或某些网络存储系统。
在许多情况下,用于声明接口方法的类型本身依赖于实际的实现。在这种情况下,需要一个接口系统。标准库 database/driver
包使用这种方法。例如,考虑以下认证提供者接口:
// Authenticator uses implementation-specific credentials to create an
// implementation-specific session
type Authenticator interface {
Login(credentials Credentials) (Session,error)
}
// Credentials contains the credentials to authenticate a user to the
// backend
type Credentials interface {
Serialize() []byte
Type() string
}
// CredentialParse implementation parses backend-specific credentials
// from []byte input
type CredentialParser interface {
Parse([]byte) (Credentials, error)
}
// A backend-specific session identifies the user and provides a way
// to close the session
type Session interface {
UserID() string
Close()
}
工厂
本节展示了常用于支持可扩展结构(如数据库驱动程序)的配方,其中导入特定的数据库驱动程序包会自动将驱动程序“注册”到工厂中。
如何实现...
-
定义一个接口,或一组接口,指定实现应该如何行为。
-
创建一个注册表(映射)和一个用于注册实现的函数。
-
每个不同的实现都会使用
init()
函数将自己注册到注册表中。 -
使用
main
包导入将包含在程序中的实现。
让我们使用上一节中的 Authenticator
示例来实现一个认证框架。我们将允许 Authenticator
框架的不同实现。
首先,定义一个工厂接口和一个用于存储所有已注册实现的映射:
package auth
type AuthenticatorFactory interface {
NewInstance() Authenticator
}
var registry = map[string]AuthenticatorFactory{}
然后,声明一个导出的 Register
函数:
func RegisterAuthenticator(name string, factory AuthenticatorFactory) {
registry[name]=factory
}
为了动态创建认证器的实例,我们需要一个类似这样的函数:
func NewInstance(authType string) Authenticator {
// Create a new instance using the selected factory.
// If the given authType has not been registered, this will panic
return registry[authType].NewInstance()
}
实现可以使用 init()
函数注册它们自己的工厂:
type factory struct{}
func (factory) NewInstance() auth.Authenticator {
// Create and return a new instance of db authenticator
}
func init() {
auth.RegisterAuthenticator("dbauthenticator",factory{})
}
最后,你必须将这些部分拼接在一起。Go 的构建系统只会包含那些被从 main()
可访问的代码直接或间接使用过的包,并且实现不会被直接引用。我们必须确保这些包被导入,因此,实现被注册。所以,在 main
中导入它们:
package main
import (
_ "import/path/of/the/implementation"
...
)
前面的 import
语句将包含实现包到程序中。由于该包被包含在程序中,其 init()
函数将在程序初始化期间被调用,并且它提供的认证器类型将被注册。
在使用它们的地方定义接口
结构化类型允许你在需要使用接口时定义它,而不是预先定义导出的接口。这有时会与“鸭子类型”(如果某物像鸭子走路,像鸭子说话,那么它就是鸭子)混淆。区别在于,鸭子类型是通过在运行时查看类型的结构子集来确定数据类型兼容性的,而结构化类型是指在编译时查看类型的结构。这个配方展示了你可以如何按需定义接口。
如何实现...
假设你有一段如下所示的代码:
type A struct {
...
options map[string]any
}
func (a A) GetOptions() map[string]any {return a.options}
type B struct {
...
options map[string]any
}
func (b B) GetOptions() map[string]any {return b.options}
如果你想要编写一个将操作类型 A
或 B
(或任何具有选项的类型)的变量的选项的函数,你可以在那里简单地定义一个接口:
type withOptions interface {
GetOptions() map[string]any
}
func ProcessOptions(item withOptions) {
for key, value:=range item.GetOptions() {
...
}
}
它是如何工作的...
记住,Go 使用结构化类型。因此,你可以创建一个指定了一组方法的接口,任何声明了这些方法的类型将自动实现该接口。因此,你可以随意创建这样的接口,并编写接受这些接口实例的函数来处理可能的大量数据类型。
如果你使用的是命名语言,你将不得不指定那些类型实现了你的接口。但在 Go 中并非如此。
这也意味着,如果你有一个接口A
和另一个接口B
,其中A
声明了与B
相同的方法,那么任何实现了A
的类型也实现了B
。换句话说,如果你不能导入一个接口,因为它在一个如果导入将导致循环依赖的包中,或者如果该接口没有由该包导出,你可以在当前包中简单地定义一个等效的接口。
使用函数作为接口
有时,你可能会遇到需要接口时却有一个函数的情况。这个示例展示了如何定义一个新的函数数据类型,它同时也实现了接口。
如何实现...
如果你需要实现一个没有数据元素的单一方法接口,你可以基于一个空结构体定义一个新的类型,并为该类型声明一个方法以实现该接口。或者,你也可以简单地使用该函数本身作为该接口的实现。以下摘录来自标准库net/http
包:
// An interface with a single function
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
// Define a new function type matching the interface method signature
type HandlerFunc func(ResponseWriter, *Request)
// Implement the method for the function type
func (h HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
h(w.r) // Call the underlying function
}
在这里,你可以在需要Handler
接口实现的地方使用HandlerFunc
类型的函数。
它是如何工作的...
Go 的类型系统将函数类型视为任何其他定义的类型。因此,你可以为函数类型声明方法。当你为函数类型声明方法时,该函数类型将自动实现定义了所有或部分这些方法的接口。
让我们通过一个示例来检查这个声明。我们可以声明一个新的空类型作为Handler
接口的实现:
type MyHandler struct{}
func (MyHandler) ServeHTTP(w ResponseWriter, r *Request) {...}
使用这个声明,你可以在需要Handler
的地方使用MyHandler
的实例。然而,请注意MyHandler
没有数据元素,只有一个方法。因此,我们定义了一个函数类型:
type MyHandler func(ResponseWriter,*Request)
现在MyHandler
是一个新的命名类型。这并不比将MyHandler
声明为结构体有太大的不同,但在这个情况下,MyHandler
是一个具有固定签名的函数。
由于MyHandler
是一个命名类型,我们可以为它定义方法:
func (h MyHandler) ServeHTTP(w ResponseWriter, r *Request) {
h(w,r)
}
由于MyHandler
现在定义了ServeHTTP
方法,它实现了Handler
接口。然而,MyHandler
是一个函数类型,所以h
实际上是一个具有与ServeHTTP
相同签名的函数。正因为如此,h(w,r)
调用才有效,MyHandler
可以在需要Handler
的地方使用。
在运行时发现数据类型的特性——测试"实现"关系
接口提供了一种调用底层数据对象的方法。如果许多不同的类型实现了相同的接口,你可以通过使用它们的公共接口来简单地使用一个函数来操作不同的数据类型。然而,很多时候,你需要访问接口中存储的底层对象。Go 提供了几种机制来实现这一点。我们将探讨type-assertion
和type-switch
。
如何做到这一点...
使用接口和类型断言来发现一个类型提供的方法。记住,接口是一组方法。实现接口中给定方法的类型自动实现该接口。
使用以下模式来确定数据类型是否有方法:
func f(rd io.Reader) {
// Is rd also an io.Writer?
if wr, ok:= rd.(io.Writer); ok {
// Yes, rd is an io.Writer, and wr is that writer.
...
}
// Does rd have a function ReadLine() (string,error)?
// Define an interface here
type hasReadLine interface {
ReadLine() (string,error)
}
// And see if rd implements it:
if readLine, ok:=rd.(hasReadLine); ok {
// Yes, you can use readLine:
line, err:=readLine.ReadLine()
...
}
// You can even define anonymous interfaces inline:
if readLine, ok:=rd.(interface{ReadLine()(string,error)}); ok {
line, err:=readLine.ReadLine()
}
}
它是如何工作的...
类型断言有两种形式。以下形式测试intf
接口变量是否包含concreteValue
类型的具体值:
value, ok:=intf.(concreteValue)
如果接口包含该类型的值,那么value
现在具有该值,并且ok
变为true
。
第二种形式测试intf
接口中包含的具体值是否也实现了otherIntf
接口:
value, ok:=intf.(otherIntf)
如果intf
中包含的值也具有otherIntf
声明的那些方法,那么value
现在是一个包含与intf
相同具体值的otherIntf
类型接口值,并且ok
设置为true
。
使用这种第二种形式,你可以测试接口变量是否实现了你需要的那些方法。
你可能认为你可以使用反射来做同样的事情。反射是在运行时发现类型字段和方法名称的方法。这不是一个高效或简单的方法来检查这种类型等价性。
测试接口值是否是已知类型之一
类型切换用于测试接口值是否是已知的具体类型,或者是否实现了某个接口。这个示例展示了如何使用它。
如何做到这一点...
如果需要检查接口与多个类型,请使用类型切换而不是一系列类型断言。
以下示例使用interface{}
来添加两个值。这两个值可以是int
,也可以是float64
。该函数还提供了一种覆盖加法行为的方法:如果值有一个兼容的Add
方法,它将调用该方法:
// a and b must have the same types. They can be int, float64, or
// another type
// that has Add method.
func Add(a, b interface{}) interface{} {
// type switch:
// In this form, a matching case block will declare aValue
// with the correct type
switch aValue:=a.(type) {
case int:
// Here, aValue is an int
// b must be an int!
bValue:=b.(int)
return aValue+bValue
case float64:
// Here, aValue is a float64
// b must be a float64!
bValue:=b.(float64)
return aValue+bValue
case interface { Add(interface{}) interface{} }:
// Here, aValue is an interface {Add{interface{}) interface{}}
return aValue.Add(b)
default:
// Here, aValue is not defined
// This is an unhandled case
return nil
}
}
注意类型切换的使用方式,如果匹配,则提取接口中包含的值。这仅在情况列表只有一个类型,并且情况不是default
情况时才有效。对于这些情况,变量简单地未定义,你只需与接口一起工作。
在开发过程中确保类型实现接口
在项目的开发阶段,接口类型可能会快速变化,通过添加新方法,或者通过更改参数类型或返回类型来修改现有方法签名。开发者如何确保那些接口的实现不会被这些更改破坏?
如何做到这一点...
假设你的团队定义了以下接口:
type Car interface {
Move(int,int)
}
我们还假设你使用以下结构体实现了该接口:
type RaceCar struct {
X, Y int
}
func (r *RaceCar) Move(dx, dy int) {
r.X+=dx
r.Y+=dy
}
然而,在开发后期,结果发现并非所有汽车都能成功移动,因此接口的签名更改为以下:
type Car interface {
Move(int,int) error
}
这个更改后,RaceCar
不再实现Car
。很多时候这个错误会在编译时被发现,但并不总是如此。例如,如果*RaceCar
的实例被传递到需要any
的函数中,编译将成功,但如果通过类型断言将该参数转换为Car
或*RaceCar
,将会引发运行时恐慌:
rc := item.(Car)
假设你声明了以下:
var _ Car = &RaceCar{}
对Car
接口的任何修改,如果导致*RaceCar
不再实现Car
接口,将会导致编译错误。
所以,一般来说:声明一个接口类型的空变量,并将其分配给具体类型:
type I interface {...}
type Implem struct { ... }
// If something changes in Implem or I that causes Implem
// to no longer implement interface I, this will give a
// compile-time error
var _ I = Implem{}
// Same as above, but this ensures *Implem implements I
var _ I = &Implem{}
如果有更改导致类型不再实现该接口,将会引发编译错误。
决定是否为方法使用指针接收者或值接收者
在这个菜谱中,我们将探讨如何选择方法使用指针接收者还是值接收者。
如何做到这一点...
通常,使用一种类型,而不是两种。这样做有两个原因:
-
代码的一致性。
-
混合值接收者和指针接收者可能导致数据竞争。
如果方法修改接收者对象,请使用指针接收者。如果方法不修改接收者对象,或者如果方法依赖于获取接收者对象的副本,你可以使用值接收者。
如果你正在实现一个不可变类型,在大多数情况下,你应该使用值接收者。
如果你的结构体很大,使用指针接收者可以减少复制开销。你可以找到不同的指南来判断一个结构体是否可以被认为是大的。如果有疑问,编写基准测试并测量。
它是如何工作的...
对于类型T
,如果你使用值接收者声明了一个方法,那么这个方法既适用于T
也适用于*T
。该方法获取接收者的副本,而不是指向它的指针,因此对接收者进行的任何修改都不会反映到调用该方法使用的对象上。
例如,以下方法在修改一个字段的同时返回原始对象的副本:
type Action struct {
Option string
}
// Returns a copy of a with the given option. The original a is not
// modified.
func (a Action) WithOption(option string) Action {
a.Option=option
return a
}
func main() {
x:=Action{
Option:"a",
}
y:=x.WithOption("b")
fmt.Println(x.Option, y.Option) // Outputs: a b
}
值接收者会创建原始值的浅拷贝。如果接收者结构体包含映射、切片或其他对象的指针,则只会复制映射头、切片头或指针,而不会复制指向的对象内容。这意味着,即使在以下示例中方法获取了一个值接收者,对映射的更改也会反映在原始值和副本中:
type T struct {
m map[string]int
}
func (t T) add(k string, v int) {
t.m[k]=v
}
func main() {
t:=T{
m:make(map[string]int,
}
t.add("a",1)
fmt.Println(t) // [a:1]
}
注意这如何影响切片操作。切片是一个三元组(pointer, len, cap)
,当你传递值接收者时,这是被复制的:
type T struct {
s []string
}
func (t T) set(i int, s string) {
t.s[i]=s
}
func (t T) add(s string) {
t.s=append(t.s,s)
}
func main() {
t:=T{
s: []string{"a","b"},
}
fmt.Println(t.s) // [a, b]
// Setting a slice element contained in the value receiver will be
// visible here
t.set(0,"x")
fmt.Println(t.s) // [x, b]
// Appending to the slice contained in the value receiver will not
// be visible here
// The appended slice header is set in the copy of t, the original
// never sees that update
t.add("y")
fmt.Println(t.s) // [x, b]
}
指针接收器更容易处理。方法总是获取它被调用的对象的指针。在先前的例子中,使用指针接收器声明add
方法的行为符合预期:
func (t *T) add(s string) {
t.s=append(t.s,s)
}
...
t.add("y")
fmt.Println(t.s) // [x, b, y]
在本节的开始,我也提到了混合指针和值接收器会导致数据竞争。以下是它发生的方式。
记住,当 goroutine 从另一个 goroutine 正在并发修改的变量中读取时,就会发生数据竞争。考虑以下示例,其中Version
方法使用值接收器,这会导致创建T
的一个副本:
type T struct {
X int
}
func (t T) Version() int {return 1}
func (t *T) SetValue(x int) {t.X=x}
func main() {
t:=T{}
go func () {
t.SetValue(1) // Writes to t.X
}()
ver := t.Version() // Makes a copy of t, which reads t.X
...
}
调用t.Version()
的行为会创建变量t
的一个副本,在它被修改的同时并发读取t.X
,因此导致竞争。如果t.Version
明确地从t.X
读取,这种竞争会更明显。没有保证该读取操作将看到 goroutine 中的写入操作的效果。
多态容器
在这个上下文中,容器是一个包含许多对象的数据结构。本节的原则也可以应用于单个对象。换句话说,当你有一个单例多态变量或结构体字段时,你可以使用相同的思想。
如何实现...
-
定义一个包含所有将存储在容器中的数据类型共有方法的接口。
-
使用该接口声明容器类型。
-
将实际对象的实例放入容器中。
-
当从容器中检索对象时,你可以通过接口与对象一起工作,或者进行类型断言,获取实际类型或另一个接口,并使用它。
它是如何工作的...
这里有一个简单的例子,它适用于Shape
对象。Shape
对象是可以在图像上绘制并移动的东西:
type Shape interface {
Draw(image.Image)
Move(dx, dy int)
}
Shape
有几个实现:
type Rectangle struct {
rect image.Rectangle
color color.Color
}
func (r *Rectangle) Draw(target image.Image) {...}
func (r *Rectangle) Move(dx, dy int) {...}
type Circle struct {
center image.Point
color color.Color
}
func (c *Circle) Draw(target image.Image) {...}
func (c *Circle) Move(dx, dy int) {...}
*Rectangle
和*Circle
都实现了Shape
接口(注意Rectangle
和Circle
没有实现)。现在我们可以处理一个Shapes
的切片:
func Draw(target image.Image, shapes []Shape) {
for _,shape:=range shapes {
shape.Draw(targeT)
}
}
这就是shapes
切片的样子:
图 5.1 – 接口变量的切片
由于每个接口都包含对实际形状的指针,因此可以使用该接口调用修改对象的方法:
func Move(dx, dy int, shapes []Shape) {
for _,shape:=range shapes {
shape.Move(dx, dy)
}
}
通过接口未直接暴露的对象部分
在处理接口时,有许多场合需要访问底层对象。这是通过类型断言实现的,即测试接口值的类型是否满足给定的类型,如果是,则检索它。
如何实现...
使用类型断言或类型选择来测试接口中包含的对象的类型:
func f(shape Shape) {
if rect, ok := shape.(*Rectangle); ok {
// shape contains a *Rectangle, and rect now points to it
}
switch actualShape := shape.(type) {
case *Circle :
// shape is a *Circle, and actualShape is a *Circle variable
case *Rectangle:
// shape is a *Rectangle, and actualShape is a *Rectangle
// variable
default:
// shape is not a circle or rectangle. actualShape is not
// defined here
}
}
从嵌入的结构体访问嵌入的结构体
在像 Java 或 C++这样的面向对象语言中,有抽象方法或虚方法的概念,以及类型继承。这个特性的一个效果是,如果你调用基类base
的M
方法,那么在运行时执行的是为运行时实际对象声明的M
的实现。换句话说,你可以调用将被其他声明覆盖的方法,但你并不知道你实际上调用的是哪个方法。
在 Go 中,有几种做同样事情的方法。这个示例展示了如何做。
如何做到这一点...
假设你需要编写一个循环链表数据结构,其元素将包含一个基结构体的结构体:
type ListNodeHeader struct {
next Node
prev Node
list *List
}
列表本身如下所示:
type List struct {
first Node
}
因此,列表指向first
节点,这是一个列表中的任意节点,每个节点都指向下一个节点,最后一个节点指向第一个节点。
我们需要一个定义维护列表机制的Node
接口。当然,Node
接口将由ListNodeHeader
实现,因此,由列表的所有节点实现:
type Node interface {
...
}
列表的用户应该嵌入ListHeader
以实现一个list
节点:
type ByteSliceElement struct {
ListNodeHeader
Payload []byte
}
type StringElement struct {
ListNodeHeader
Payload string
}
现在困难的部分是实现Node
接口。假设你想要在这个列表中插入一个ByteSliceElement
。由于ByteSliceElement
嵌入ListNodeHeader
,它具有所有的方法,因此实现了Node
。然而,我们无法编写例如Insert
方法为ListNodeHeader
,除非我们知道实际被插入的对象。
实现这一点的其中一种方式是使用以下模式:
type Node interface {
Insert(list *List, this Node)
getHeader() *ListNodeHeader
}
func (header *ListNodeHeader) getHeader() *ListNodeHeader {return header}
func (header *ListNodeHeader) Insert(list *List,this Node) {
// If list is empty, this is the only node
if list.first == nil {
list.first = this
header.next = this
header.prev = this
return
}
header.next=list.first
header.prev=list.first.getHeader().prev
header.prev.getHeader().next=this
header.next.getHeader().prev=this
}
这里有几个事情在进行。首先,Insert
方法获取要插入的节点的两个视图。如果被插入的节点是*ByteSliceElement
,那么它获取这个节点的Node
版本,然后它还获取嵌入在ByteSliceElement
中的*ListNodeHeader
作为接收者。使用这个,它可以调整ByteSliceElement
的成员以指向前一个和下一个节点。
然而,它不能访问Node
的prev
和next
成员。
一个选项是如下所示:在Node
接口中声明一个未导出的方法,该方法将从给定的节点返回ListNodeHeader
。另一个选项是向接口中添加getNext/setNext
和getPrev/setPrev
方法。
现在你已经实现了两件事:首先,任何在这个包外使用这个列表结构的用户必须嵌入ListNodeHeader
以实现一个列表节点。接口中有一个未导出的方法。没有在其他包中实现这样一个接口的方法。唯一的方法是嵌入一个已经实现了它的结构体。
其次,你有一个多态的容器数据结构,其机制由一个基结构体管理。
检查接口是否为 nil
你可能会想知道这甚至是一个问题。毕竟,你不是只是比较 nil 吗?并不总是这样。
接口包含两个值:接口中包含的值的类型,以及指向该值的指针。如果这两个值都是 nil,则接口为 nil。存在一些情况,接口可能指向一个非 nil 类型的 nil 值,这使得接口非 nil。
你无法轻松检查这种情况。你必须避免创建包含 nil 值的接口。
如何做到这一点...
避免将指针转换为可能为 nil 的变量:
type myerror struct{}
func (myerror) Error() string { return "" }
func main() {
var x *myerror
var y error
y = x // Avoid this
if y!=nil {
// y is not nil!
}
}
而应显式检查 nil 接口值,例如以下内容:
var y error
if x!=nil {
y=x
}
或者,使用值错误而不是指针。以下代码完全避免了这个问题:
var x myerror
x
不可能为 nil。
它是如何工作的...
如我之前解释的,接口包含两个值:类型和值。你试图避免的是创建一个包含 nil 值且类型非 nil 的接口。
在接下来的声明之后,y
接口为 nil,因为它的类型和值都是 nil:
var y error
在以下赋值之后,存储在y
中的类型现在是x
的类型,而值是 nil。因此,y
不再为 nil:
y=x
这也适用于从函数返回:
func f() error {
var x *myerror
return x
}
f
函数从不返回 nil。
第六章:与泛型一起工作
经常发生的情况是,您编写了一个使用特定类型(例如整数)的值的函数来进行某些计算,但随着开发的进行,您突然需要使用另一种数据类型(例如 float64
)来做同样的事情。因此,您复制粘贴第一个函数并修改它以具有不同的名称和数据类型。这种情况最明显和最著名的例子可能是容器数据类型,如映射和集合。您为整数值构建容器类型,然后为字符串做同样的事情,然后为结构体,依此类推。
泛型是一种在编译时使用代码模板进行代码复制粘贴的方式。首先,您创建一个函数模板(泛型函数)或数据类型模板(泛型类型)。通过提供类型来实例化泛型函数或类型。编译器会负责使用您提供的类型实例化模板,并检查实例化的泛型类型或函数是否可以与您提供的类型一起编译。
在本章中,您将学习如何使用泛型函数和数据类型处理常见场景:
-
泛型函数
-
编写一个添加数字的泛型函数
-
将约束声明为接口
-
将泛型函数用作适配器和访问器
-
-
泛型类型
-
编写一个类型安全的集合
-
有序映射 -- 使用多个类型参数
-
泛型函数
一个泛型函数是一个函数模板,它接受类型作为参数。泛型函数必须为其参数的所有可能的类型赋值编译。泛型函数可以接受的数据类型由“类型约束”描述。我们将在本节中学习这些概念。
编写一个添加数字的泛型函数
一个很好的泛型示例是添加数字的函数。这些数字可以是各种整数或浮点数类型。在这里,我们将研究具有不同功能的几个配方。
如何做到这一点...
接受 int
和 float64
数字的一个泛型求和函数如下:
func SumT int | float64 T {
var result T
for _, x := range values {
result += x
}
return result
}
构造 [T int | float64]
定义了 Sum
函数的类型参数:
-
T
是类型名称。例如,如果您为int
实例化Sum
函数,那么T
就是int
。 -
int | float64
表达式是T
的类型约束。在这种情况下,它意味着“T
要么是int
,要么是float64
。”这个约束告诉编译器,Sum
函数只能实例化为int
或float64
值。
如我之前所解释的,泛型函数只是一个模板。例如,您不能声明一个函数变量并将其分配给 Sum
,因为 Sum
不是一个真正的函数。以下语句为 int
实例化了 Sum
泛型函数:
fmt.Println(Sumint)
对于许多情况,编译器可以推断类型参数,因此以下也是有效的。由于所有参数都是 int
值,编译器推断出这里的意思是 Sum[int]
:
fmt.Println(Sum(1,2,3))
但在以下情况下,实例化的函数是Sum[float64]
,并且参数被解释为float64
值:
fmt.Println(Sumfloat64)
泛型函数必须对所有可能的T
成功编译。在这种情况下,T
可以是int
或float64
,因此函数体必须对T
是int
和T
是float64
有效。类型约束允许编译器产生有意义的编译时错误。例如,[T int | float64 | big.Int]
约束无法编译,因为result+=x
对big.Int
不起作用。
Sum
函数对从int
或float64
派生的类型不起作用,例如:
type ID int
即使ID
是int
类型,Sum[ID]
也会导致编译错误,因为ID
是一个新类型。要包含从int
派生的所有类型,在约束中使用~int
- 例如:
func SumT ~int | ~float64 T{...}
此声明将处理从int
和float64
派生的所有类型。
将约束声明为接口
在声明新函数时重复约束并不实用。相反,您可以在接口中定义它们,作为类型列表或方法列表。
如何实现...
Go 接口指定了一个方法集。Go 泛型实现扩展了此定义,以便当用作约束时,接口定义类型集。这需要对基本类型进行一些修改,因为基本类型(如int
)没有方法。因此,在接口作为约束时,有两种语法:
-
类型列表指定了可以替代类型参数的类型列表。例如,以下
UnsignedInteger
约束接受所有无符号整数类型以及从无符号整数派生的所有类型:type UnsignedInteger interface { ~uint8 | ~uint16 | ~uint32 | ~uint64 }
-
方法集指定了可接受类型必须实现的方法。以下
Stringer
约束接受所有具有String()
string
方法的类型:type Stringer interface { String() string }
这些约束可以组合。例如,以下UnsignedIntegerStringer
约束接受从无符号整数类型派生的类型,并且具有String()
string
方法:
type UnsignedIntegerString interface {
UnsignedInteger
Stringer
}
Stringer
接口既可以用作约束,也可以用作接口。UnsignedInteger
和UnsignedIntegerString
接口只能用作约束。
将泛型函数用作访问器和适配器
泛型函数为类型安全的访问器和类型适配器提供了实用解决方案。例如,使用常量值初始化*int
变量需要声明一个临时值,这可以通过泛型函数简化。这个配方包括几个这样的访问器和适配器。
如何实现...
此泛型函数将任意值转换为指针:
func ToPtrT any *T {
return &value
}
这可以用来初始化指针而不使用临时变量:
type UpdateRequest struct {
Name *string
...
}
...
request:=UpdateRequest {
Name:ToPtr("test"),
}
同样,这个泛型函数可以从任意值创建切片:
func ToSliceT any []T {
return []T{value}
}
func main() {
fmt.Println(ToSlice(1))
// Prints an int slice: [1]
}
以下泛型函数返回切片的最后一个元素:
func LastT any (T, bool) {
if len(slice) == 0 {
var zero T
return zero, false
}
return slice[len(slice)-1], true
}
如果切片为空,则返回false
。
以下泛型函数可以用来适配返回值和错误的函数,以便在只接受值的上下文中使用。如果存在错误,函数会引发恐慌:
func MustT any T {
if err != nil {
panic(err)
}
return value
}
这将 f() (T, error)
函数适配为 Must(f()) T
。
从泛型函数返回零值
如我之前所说,一个泛型函数必须对所有允许的类型约束进行编译。这可能在创建零值时引起麻烦。
如何做...
要创建参数化类型的零值,只需声明一个变量:
func Search[T []E, E comparable](slice T,value E) (E, bool) {
for _,v:=range slice {
if v==value {
return v,true
}
}
// Declare a zero value like this
var zero E
return zero, false
}
在泛型参数上使用类型断言
有时,根据泛型函数中值的类型,你需要做不同的事情。这需要类型断言或类型选择器 – 两者都适用于接口。然而,没有保证函数会为接口实例化。这个配方展示了你如何实现这一点。
如何做...
假设你有一个处理整数不同的泛型函数:
func PrintT any {
// The following does not work because value is not necessarily an
// interface{}.
if intValue, ok:=value.(int); ok {
...
} else {
...
}
}
要使这生效,你必须确保 value
是一个接口:
func PrintT any {
// Convert value to an interface
valueIntf := any{value)
if intValue, ok:=valueIntf.(int); ok {
// Value is an integer
} else {
// Value is not an integer
}
}
同样的想法也适用于类型选择器:
func PrintT any {
switch v:=any(value).(type) {
case int:
// Value is an integer
default;
// Value is not an integer
}
}
泛型类型
泛型函数的语法自然扩展到泛型类型。泛型类型也有相同的类型参数和约束,并且该类型的每个方法也隐式具有与类型本身相同的参数。
编写类型安全的集合
可以使用 map[T]struct{}
实现一个类型安全的集合。需要注意的一件事是 T
不能是任何类型。只有可比较的类型可以作为映射键,并且有一个预定义的约束来满足这一需求。
如何做...
-
使用
map
声明一个参数化集合类型:type Set[T comparable] map[T]struct{}
-
使用相同的类型参数(s)声明类型的函数。在声明方法时,你必须通过名称引用类型参数:
// Has returns if the set has the given value
func (s Set[T]) Has(value T) bool {
_, exists := s[value]
return exists
}
// Add adds values to s
func (s Set[T]) Add(values ...T) {
for _, v := range values {
s[v] = struct{}{}
}
}
// Remove removes values from s
func (s Set[T]) Remove(values ...T) {
for _, v := range values {
delete(s, v)
}
}
- 如果需要,为新的类型创建一个泛型构造函数:
// NewSet creates a new set
func NewSet[T comparable]() Set[T] {
return make(Set[T])
}
- 实例化类型以使用它:
stringSet := NewSet[string]()
注意 NewSet
函数使用 string
类型参数的显式实例化。编译器无法推断你指的是什么类型,所以你必须明确写出 NewSet[string]()
。然后编译器实例化 Set[string]
类型,这也实例化了该类型的所有方法。
有序映射 – 使用多个类型参数
这种有序映射的实现允许你使用切片与映射的组合来保持添加到映射中的元素的顺序。
如何做...
- 定义一个包含两个类型参数的结构体:
type OrderedMap[Key comparable, Value any] struct {
m map[Key]Value
slice []Key
}
由于 Key
将用作映射键,它必须是 comparable
的。对值类型没有约束。
为类型定义方法。现在方法使用 Key
和 Value
同时声明:
// Add key:value to the map
func (m *OrderedMap[Key, Value]) Add(key Key, value Value) {
_, exists := m.m[key]
if exists {
m.m[key] = value
} else {
m.slice = append(m.slice, key)
m.m[key] = value
}
}
// ValueAt returns the value at the given index
func (m *OrderedMap[Key, Value]) ValueAt(index int) Value {
return m.m[m.slice[index]]
}
// KeyAt returns the key at the given index
func (m *OrderedMap[Key, Value]) KeyAt(index int) Key {
return m.slice[index]
}
// Get returns the value corresponding to the key, and whether or not
// key exists
func (m *OrderedMap[Key, Value]) Get(key Key) (Value, bool) {
v, bool := m.m[key]
return v, bool
}
小贴士
接收器的类型参数是通过位置匹配的,而不是通过名称。换句话说,你可以这样定义一个方法:
func (m *OrderedMap[K, V]) ValueAt(index int) V {
return m.m[m.slice[index]]
}
在这里,K
代表 Key
,而 V
代表 Value
。
- 如果需要,定义一个构造函数泛型函数:
func NewOrderedMap[Key comparable, Value any]() *OrderedMap[Key, Value] {
return &OrderedMap[Key, Value]{
m: make(map[Key]Value),
slice: make([]Key, 0),
}
}
小贴士
在这种情况下需要一个构造函数,因为我们想在泛型结构体中初始化映射。每次想要向容器中添加内容时检查空映射很有诱惑力。你必须在拥有零值即可使用的容器类型带来的便利和每次添加内容时检查空映射所付出的性能代价之间做出选择。
第七章:并发
并发是 Go 语言的核心部分。与许多其他通过丰富的多线程库支持并发的语言不同,Go 提供了相对较少的语言原语来编写并发程序。
首先,强调并发不是并行。并发是关于你如何编写程序;并行是关于程序如何运行。一个并发程序指定了程序中哪些部分可以并行运行。根据实际执行情况,程序中的并发部分可能按顺序运行,使用时间共享并发运行,或者并行运行。一个正确的并发程序无论以何种方式运行都会产生相同的结果。
本章通过食谱介绍了 Go 并发原语的一些内容。在本章中,您将学习以下内容:
-
创建 goroutines
-
并行运行多个独立函数并等待它们结束
-
使用通道发送和接收数据
-
从多个 goroutine 向通道发送数据
-
使用通道收集并发计算的结果
-
使用
select
语句处理多个通道 -
取消一个 goroutine
-
使用非阻塞
select
检测取消 -
并发更新共享变量
使用 goroutine 进行并发操作
Goroutine 是一个与其他 goroutine 并行运行的函数。当程序启动时,Go 运行时会创建几个 goroutines。其中之一运行垃圾回收器。另一个运行 main
函数。随着程序的执行,它会根据需要创建更多的 goroutines。一个典型的 Go 程序可能有数千个并发运行的 goroutines。Go 运行时会将这些 goroutines 调度到操作系统线程。每个操作系统线程都会分配一定数量的 goroutines,并使用时间共享来运行它们。在任何给定时刻,活跃的 goroutine 数量可以与逻辑处理器的数量相同:
Number of threads per core * Number of cores per CPU * Number of CPUs
创建 goroutines
Goroutines 是 Go 语言的一个基本组成部分。您可以使用 go
关键字创建 goroutines。
如何做到这一点...
使用 go
关键字后跟函数调用来创建 goroutines:
func f() {
// Do some work
}
func main() {
go f()
...
}
当 go f()
被评估时,运行时会创建一个新的 goroutine 并调用 f
函数。运行 main
的 goroutine 也会继续运行。换句话说,当 go
关键字被评估时,程序执行会分为两个并发执行流——一个是原始执行流(在前面的例子中,运行 main
的流)和另一个运行 go
关键字后面的函数。
函数可以根据需要接受参数:
func f(i int) {
// Do some work
}
func main() {
var x int
go f(x)
...
}
函数的参数在 goroutine 开始之前被评估。也就是说,main
goroutine 首先评估 f
的参数(在这种情况下,是 x
值),然后创建一个新的 goroutine 并运行 f
。
使用闭包运行 goroutine 是一种常见的做法。它们提供了理解代码所需的上下文。它们还防止将许多变量作为参数传递给 goroutine:
func main() {
var x int
var y int
...
go func(i int) {
if y > 0 {
// Do some work
}
}(x)
...
}
在这里,x
作为参数传递给 goroutine,但 y
被捕获。
当使用 go
关键字运行的函数结束时,goroutine 将终止。
并发运行多个独立函数并等待它们完成
当你有多个不共享数据的独立函数时,你可以使用此食谱来并发运行它们。我们还将使用 sync.WaitGroup
来等待 goroutine 完成。
如何实现...
-
创建一个
sync.WaitGroup
实例以等待 goroutine:wg := sync.WaitGroup{}
sync.WaitGroup
简单来说是一个线程安全的计数器。我们将为每个创建的 goroutine 使用wg.Add(1)
,并在每个 goroutine 结束时使用wg.Done()
减去 1。然后我们可以等待等待组达到零,这表示所有 goroutine 的终止。 -
对于每个将要并发运行的函数,执行以下操作:
-
向等待组添加 1
-
启动一个新的 goroutine
-
调用
defer wg.Done()
确保你发出 goroutine 终止的信号wg.Add(1) go func() { defer wg.Done() // Do work }()
-
小贴士
你可以不向等待组为每个 goroutine 添加 1,而只需添加 goroutine 的数量。例如,如果你知道你将创建 5 个 goroutine,你可以在创建第一个 goroutine 之前简单地做 wg.Add(5)
。
-
等待 goroutine 结束:
wg.Wait()
此调用将阻塞,直到
wg
达到零,即直到所有 goroutine 调用wg.Done()
。 -
现在,你可以使用所有 goroutine 的结果。
此食谱的关键细节是所有 goroutine 都是独立的,这意味着以下内容:
每个 goroutine 编写的所有变量都仅由该 goroutine 使用,直到
wg.Done()
。goroutine 可以读取共享变量,但不能写入它们。在wg.Done()
之后,所有 goroutine 都将终止,它们所写入的变量可以被使用。 -
没有 goroutine 依赖于另一个 goroutine 的结果。
在 wg.Wait
之前,你不应该尝试读取 goroutine 的结果。这是一个具有未定义行为的内存竞争。
当你与其他写入或读取同时写入共享变量时,会发生内存竞争。包含内存竞争的程序的结果是未定义的。
使用通道在 goroutine 之间进行通信
更多的时候,多个 goroutine 需要通信和协调以分配工作、管理状态和汇总计算结果。通道是这种情况下首选的机制。通道是一种带有可选固定大小缓冲区的同步机制。
小贴士:
以下示例展示了已关闭的通道。关闭通道是一种传达数据结束的方法。如果你不需要向接收者传达数据结束的信号,则不需要关闭通道。
使用通道发送和接收数据
如果有另一个 goroutine 正在等待从它接收数据,或者在有缓冲的 channel 中,channel 缓冲区中有空间,那么 goroutine 可以向 channel 发送数据。否则,goroutine 将被阻塞,直到它可以发送。
如果有另一个 goroutine 正在等待向它发送数据,或者在有缓冲的 channel 中,channel 缓冲区中有数据,那么 goroutine 可以从 channel 接收数据。否则,接收器将被阻塞,直到它可以接收数据。
如何做到这一点...
-
创建一个传递数据的 channel 类型。以下示例创建了一个可以传递字符串的 channel。
ch := make(chan string)
-
在 goroutine 中,将数据元素发送到 channel。当所有数据元素都发送完毕后,关闭 channel:
go func() { for _, str := range stringData { // Send the string to the channel. This will block until // another goroutine can receive from the channel. ch <- str } // Close the channel when done. This is the way to signal the // receiver goroutine that there is no more data available. close(ch) }()
-
在另一个 goroutine 中从 channel 接收数据。在以下示例中,主 goroutine 从 channel 接收字符串并打印它们。
for
循环在 channel 关闭时结束:for str := range ch { fmt.Println(str) }
从多个 goroutines 向 channel 发送数据
有时候,你有很多 goroutine 在处理问题的某个部分,当它们完成后,它们会通过 channel 发送结果。这种情况的一个问题是决定何时关闭 channel。这个方法展示了如何做到这一点。
如何做到这一点...
-
创建一个传递数据的 result channel:
ch := make(chan string)
-
创建监听 goroutine 和一个等待组,稍后等待其完成。这个 goroutine 将在其他 goroutine 开始发送数据时被阻塞:
// Allocate results results := make([]string,0) // WaitGroup will be used later to wait for the listener // goroutine to end listenerWg := sync.WaitGroup{} listenerWg.Add(1) go func() { defer listenerWg.Done() // Collect results and store in a slice for str:=range ch { results=append(results,str) } }()
-
创建一个等待组来跟踪将要写入结果 channel 的 goroutines。然后,创建向 channel 发送数据的 goroutines:
wg := sync.WaitGroup{} for _,input := range inputs { wg.Add(1) go func(data string) { defer wg.Done() ch <- processInput(data) }(input) }
-
等待处理 goroutines 结束并关闭结果 channel:
// Wait for all goroutines to end wg.Wait() // Close the channel to signal end of data // This will signal the listener goroutine that no more data // will be arriving via the channel close(ch)
-
等待监听 goroutine 结束:
listenerWg.Wait()
现在你可以使用results
切片。
使用 channels 收集并发计算的结果
通常,你有多达多个 goroutine 在处理问题的各个部分,你必须收集每个 goroutine 的结果来编译一个单一的结果对象。Channels 是完成这个任务的完美机制。
如何做到这一点...
-
创建一个 channel 来收集计算的结果:
resultCh := make(chan int)
在这个示例中,
resultCh
channel 是一个int
值的 channel。也就是说,计算的结果将是整数。 -
创建一个
sync.WaitGroup
实例来等待 goroutines:wg := sync.WaitGroup{}
-
在 goroutines 之间分配工作。每个 goroutine 都应该能够访问
resultCh
。将每个 goroutine 添加到等待组中,并确保在 goroutine 中调用defer wg.Done()
。 -
在 goroutine 中执行计算,并将结果发送到
resultCh
:var inputs [][]int=[]int{...} ... for i:=range inputs { wg.Add(1) go func(data []int) { defer wg.Done() // Perform the computation // computeResult takes a []int, and returns int // Send the result to resultCh resultCh <- computeResult(data) }(inputs[i]) }
-
在这里,你必须做两件事:等待所有 goroutines 完成并从
resultCh
收集结果。你可以这样做两种方式:-
在等待 goroutines 并发结束的同时收集结果。也就是说,创建一个 goroutine 并等待 goroutines 结束。当所有 goroutines 完成后,关闭 channel:
go func() { // Wait for the goroutines to end wg.Wait() // When all goroutines are done, close the channel close(resultCh) }() // Create a slice to contain results of the computations results:=make([]int,0) // Collect the results from the `resultCh` // The for-loop will terminate when resultCh is closed for result:=range resultCh { results=append(results,result) }
-
在等待 goroutine 结束的同时异步收集结果。当所有 goroutine 完成后,关闭通道。然而,当你关闭通道时,收集结果的 goroutine 可能仍在运行。我们必须等待该 goroutine 结束。为此,我们可以使用另一个等待组:
results:=make([]int,0) // Create a new wait group just for the result collection // goroutine collectWg := sync.WaitGroup{} // Add the collection goroutine to the waitgroup collectWg.Add(1) go func() { // Announce the completion of this goroutine defer collectWg.Done() // Collect results. The for-loop will terminate when resultCh // is closed. for result:= range resultCh { results=append(results,result) } }() // Wait for the goroutines to end. wg.Wait() // Close the channel so the result collection goroutine can // finish close(resultCh) // Now wait for the result collection goroutine to finish collectWg.Wait() // results slice is ready
-
使用 select
语句处理多个通道
在任何给定时间,你只能从通道发送数据或接收数据。如果你正在与多个 goroutine(以及因此,多个并发事件)交互,你需要一个语言结构,它将允许你同时与多个通道交互。这个结构就是 select
语句。
本节展示了如何使用 select
。
如何做到...
阻塞 select
语句从零个或多个分支中选择一个活跃的分支。每个分支是一个通道发送或接收事件。如果没有活跃的分支(也就是说,没有通道可以发送到或从接收),select
将被阻塞。
在下面的示例中,select
语句等待从两个通道中的一个接收数据。程序只从其中一个通道接收数据。如果两个通道都准备好了,其中一个通道将被随机选择。另一个通道将被留下未读取:
ch1:=make(chan int)
ch2:=make(chan int)
go func() {
ch1<-1
}()
go func() {
ch2<-2
}()
select {
case data1:= <- ch1:
fmt.Println("Read from channel 1: %v", data1)
case data2:= <- ch2:
fmt.Println("Read from channel 2: %v", data2)
}
取消 goroutine
在 Go 中创建 goroutine 很简单且高效,但你也要确保你的 goroutine 最终能够结束。如果一个 goroutine 无意中被留下运行,它被称为“泄漏”的 goroutine。如果一个程序持续泄漏 goroutine,最终它可能会因为内存不足错误而崩溃。
一些 goroutine 执行有限数量的操作并自然终止,但有些会无限期地运行,直到接收到外部刺激。对于长时间运行的 goroutine 接收此类刺激的常见模式是使用一个 done
通道。
如何做到...
-
创建一个数据类型为空的
done
通道:done:=make(chan struct{})
-
创建一个通道为 goroutine 提供输入:
input := make(chan int)
-
创建如下所示的 goroutine:
go func() { for { select { case data:= <- input: // Process data case <-done: // Done signal. Terminate return } } }()
要取消 goroutine(s),只需关闭 done
通道:
close(done)
这将启用所有监听 done
通道的 goroutine 中的 case <-done
分支,并且它们将终止。
使用非阻塞 select
检测取消
非阻塞 select
有一个 default
分支。当 select
语句运行时,它会检查所有可用的分支,如果没有一个可用,则选择 default
分支。这允许 select
在不阻塞的情况下继续。
如何做到...
-
创建一个数据类型为空的
done
通道:done:=make(chan struct{})
-
创建如下所示的 goroutine:
go func() { for { select { case <-done: // Done signal. Terminate return default: // Done signal is not sent. Continue } // Do work } }()
要取消 goroutine(s),只需关闭 done
通道。
close(done)
共享内存
最著名的 Go 惯用语句之一是:“不要通过共享内存来通信,要通过通信来共享内存。”通道是通过通信来共享内存的。通过共享内存进行通信是通过多个 goroutine 中的共享变量来完成的。尽管不鼓励这样做,但有许多情况下共享内存比通道更有意义。如果至少有一个 goroutine 更新了一个被其他 goroutine 读取的共享变量,你必须确保没有内存竞争。
当一个 goroutine 在另一个 goroutine 读取或写入变量的同时更新变量时,会发生内存竞争。当这种情况发生时,无法保证其他 goroutine 会看到该变量的更新。这种情况的一个著名例子是busy-wait
循环:
func main() {
done:=false
go func() {
// Wait while done==false
for !done {}
fmt.Println("Done is true now")
}()
done=true
// Wait indefinitely
select{}
}
这个程序有一个内存竞争。done=true
的赋值与for !done
循环并发。这意味着,尽管主 goroutine 运行done=true
,但读取done
的 goroutine 可能永远看不到这个更新,无限期地停留在for
循环中。
并发更新共享变量
Go 内存模型保证在一个 goroutine 中,变量写入的效果只对写入之后执行的指令可见。也就是说,如果你更新一个共享变量,你必须使用特殊的工具来确保这个更新对其他 goroutine 可见。确保这一点的简单方法是使用互斥锁。互斥锁代表“互斥排他”。互斥锁是一个你可以用来确保以下内容的工具:
-
任何给定时间只有一个 goroutine 更新一个变量
-
一旦更新完成并且互斥锁被释放,所有 goroutine 都可以看到这个更新
在这个菜谱中,我们展示了如何做到这一点。
如何做到这一点...
更新共享变量的程序部分是一个“临界区”。你使用互斥锁来确保只有一个 goroutine 可以进入其临界区。
声明一个互斥锁来保护临界区:
// cacheMutex will be used to protect access to cache
var cacheMutex sync.Mutex
var cache map[string]any = map[string]any{}
互斥锁保护一组共享变量。例如,如果你有 goroutine 更新一个单个整数,你为更新该整数的临界区声明一个互斥锁。每次读取或写入该整数值时,你必须使用相同的互斥锁。
在更新共享变量时,首先锁定互斥锁。然后执行更新并解锁互斥锁:
cacheMutex.Lock()
cache[key]=value
cacheMutex.Unlock()
使用这种模式,如果有多个 goroutine 尝试更新cache
,它们将在cacheMutex.Lock()
处排队,并且只允许一个进行更新。当那个 goroutine 执行更新时,它将调用cacheMutex.Unlock()
,这将允许一个等待的 goroutine 获取锁并再次更新缓存。
在读取共享变量时,首先锁定互斥锁。然后执行读取,然后解锁互斥锁:
cacheMutex.Lock()
cachedValue, cached := cache[key]
cacheMutex.Unlock()
if cached {
// Value found in cache
}
第八章:错误和恐慌
Go 的错误处理一直备受争议。那些来自具有异常处理语言背景(如 Java)的人往往讨厌它,而那些来自错误是函数返回值语言背景(如 C)的人则对此感到舒适。
在两者都有背景的情况下,我认为错误处理的显式性质迫使你在开发的每一步都考虑异常情况。错误生成、错误传递和错误处理需要与“正常路径”(即没有错误发生时)相同的纪律和审查。
如果你注意到了,我在处理错误的三个阶段中做了区分:
-
错误的检测和生成处理涉及检测异常情况并捕获诊断信息
-
错误的传递处理允许错误向上传播到堆栈,可选地用上下文信息装饰它们
-
错误处理涉及实际解决错误,这可能包括终止程序
在本章中,你将了解以下内容:
-
如何生成错误
-
如何通过使用上下文信息来注释它们来传递它们
-
如何处理错误
-
在项目中组织错误
-
处理恐慌
错误的返回和处理
这个菜谱展示了如何检测错误以及如何用额外的上下文信息包装错误。
如何做...
使用函数或方法的最后一个返回值作为错误:
func DoesNotReturnError() {...}
func MayReturnError() error {...}
func MayReturnStringAndError() (string,error) {...}
如果函数或方法成功,它将返回nil
错误。如果在函数或方法中检测到错误条件,则直接返回该错误或用包含上下文信息的另一个错误包装该错误:
func LoadConfig(f string) (*Config, error) {
file, err:=os.Open(f)
if err!=nil {
return nil, fmt.Errorf("file %s: %w", f,err)
}
defer file.Close()
var cfg Config
err = json.NewDecoder(file).Decode(&cfg)
if err!=nil {
return nil, fmt.Errorf("While unmarshaling %s: %w",f,err)
}
return &cfg, nil
}
小贴士
不要用panic
来代替错误。panic
应该用来表示潜在的 bug 或不可恢复的情况。错误用来表示上下文相关的情况,例如缺少文件或无效输入。
它是如何工作的...
Go 使用显式的错误检测和处理。这意味着没有隐式或隐藏的执行路径用于错误(如抛出异常)。Go 的错误仅仅是接口值,一个错误为nil
被解释为没有错误。上述函数调用了一些可能返回错误的文件管理函数。当这种情况发生时(即,当函数返回非nil
错误时),这个函数只是用额外信息包装那个错误并返回它。这些额外信息允许调用者,有时是程序的用户确定正确的行动方案。
包装错误以添加上下文信息
使用标准库errors
包,你可以用包含额外上下文信息的另一个错误包装一个错误。此包还提供了设施和约定,让你检查错误树是否包含特定错误或从错误树中提取特定错误。
如何做...
使用fmt.Errorf
向错误添加上下文信息。在下面的例子中,返回的错误将包含来自os.Open
的错误,并且它还将包括文件名:
file, err := os.Open(fileName)
if err!=nil {
return fmt.Errorf("%w: While opening %s",err,fileName)
}
注意上面fmt.Errorf
中使用%w
动词。%w
动词用于创建一个包装其参数的错误。如果我们使用%v
或%s
,返回的错误将包含原始错误的文本,但它不会包装它。
比较错误
当你用一个额外的信息包装错误时,新的错误值与原始错误不是同一类型或值。例如,如果文件未找到,os.Open
可能会返回os.ErrNotExist
,如果你用额外的信息(如文件名)包装这个错误,调用这个函数的调用者将需要一个方法来获取原始错误以正确处理它。这个菜谱展示了如何处理这样的包装错误值。
如何做到这一点...
检查是否有错误很简单:检查错误值是否为nil
或不是:
file, err := os.Open(fileName)
if err!=nil {
// File could not be opened
}
使用errors.Is
来检查错误是否是你期望的:
file, err := os.Open(fileName)
if errors.Is(err,os.ErrNotExist) {
// File does not exist
}
它是如何工作的...
errors.Is(err,target error)
通过以下方式比较err
是否等于target
:
-
它检查
err==target
。 -
如果这还失败,它会检查
err
是否有Is(error) bool
方法,通过调用err.Is(target)
。 -
如果这还失败,它会检查
err
是否有Unwrap() error
方法,并且err.Unwrap()
不是nil
,通过检查err.Unwrap()
是否等于target
。 -
如果这还失败,它会检查
err
是否有Unwrap() []error
方法,并且target
等于那些切片元素中的任何一个。
这意味着如果你包装了一个错误,调用者仍然可以检查包装的错误是否发生,并相应地行事。
如果你使用errors.New()
或fmt.Errorf()
定义了一个错误,那么返回的错误接口包含一个指向对象的指针。在这种情况下,两个错误具有相同的字符串表示并不意味着它们是相等的。以下程序展示了这种情况:
var e1 = errors.New("test")
var e2 = errors.New("test")
if e1 != e2 {
fmt.Println("Errors are different!")
}
在上面,即使错误字符串相同,e1
和e2
都是指向不同对象的指针。程序将打印Errors are different
。因此,声明如下错误是有效的:
var (
ErrNotFound = errors.New("Not found")
)
与ErrNotFound
的比较将检查错误值是否是指向与ErrNotFound
相同对象的指针。
结构化错误
一个结构化错误提供了在错误到达程序用户之前处理错误时可能至关重要的上下文信息。这个菜谱展示了如何使用这样的错误。
如何做到这一点...
-
定义一个包含捕获错误情况的元数据的结构体。
-
实现一个
Error() string
方法来使其成为一个error
。 -
如果错误可以包装其他错误,包括一个
error
或[]error
来存储那些。 -
可选地实现
Is(error) bool
方法来控制如何比较这个错误。 -
可选地实现
Unwrap() error
或Unwrap() []error
来返回包装的错误。
它是如何工作的...
任何实现了 error
接口(只包含一个方法,Error() string
)的数据类型都可以用作错误。这意味着你可以创建包含详细错误信息的数据结构,稍后可以对其进行操作。所以,如果你需要几个数据字段来描述一个错误,而不是构建一个复杂的字符串并通过 fmt.Errorf
返回它,你可以使用一个结构体。
例如,假设你正在解析多行格式化的文本输入。向用户提供准确和有用的信息很重要;没有人会喜欢在没有显示错误位置的情况下收到 Syntax error
消息。因此,你声明这个错误结构:
type ErrSyntax struct {
Line int
Col int
Diag string
}
func (err ErrSyntax) Error() string {
return fmt.Sprintf("Syntax error line: %d col: %d, %s", err.Line,
err.Col, err.Diag)
}
你现在可以生成有用的错误信息:
func ParseInput(input string) error {
...
if nextRune != ',' {
return ErrSyntax {
Line: line,
Col: col,
Diag: "Expected comma",
}
}
...
}
你可以使用这些错误信息向用户显示有用的消息或控制交互式响应,例如将光标定位到错误位置或突出显示错误附近的文本。
包装结构化错误
结构化错误可以通过包装它来装饰另一个错误,并添加额外的信息。这个配方展示了如何做到这一点。
如何做到这一点...
-
在结构中保留一个错误成员变量(或错误切片)来存储根本原因。
-
实现
Unwrap() error
(或Unwrap() []error
)方法。
它是如何工作的...
你可以将根原因错误包装在一个结构化错误中。这允许你添加有关错误的额外结构化上下文信息:
type ErrFile struct {
Name string
When string
Err error
}
func (err ErrFile) Error() string {
return fmt.Sprintf("%s: file %s, when %s", err.Err, err.Name, err.
When)
}
func (err ErrFile) Unwrap() error { return err.Err }
func ReadConfigFile(name string) error {
f, err:=os.Open(name)
if err!=nil {
return ErrFile {
Name: name,
Err:err,
When: "opening configuration file",
}
}
...
}
注意,Unwrap
是必要的。如果没有它,以下代码将无法检测到错误是否源自 os.ErrNotFound
:
err:=ReadConfig("config.json")
if errors.Is(err,os.ErrNotFound) {
// file not found
}
使用 Unwrap
方法,errors.Is
函数可以遍历封装的错误,并确定其中至少有一个是 os.ErrNotFound
。
通过类型比较结构化错误
在支持 try
-catch
块的语言中,你通常根据错误类型来捕获错误。你可以依靠 errors.Is
来模拟相同的功能。
如何做到这一点...
在你的错误类型中实现 Is(error) bool
方法来定义你关心的等价类型。
它是如何工作的...
你可能记得,errors.Is(err,target)
函数首先检查 err = target
,如果失败,则检查 err.Is(target)
,前提是 err
实现了 Is(error) bool
方法。因此,你可以使用 Is(error) bool
方法来调整如何比较你的自定义错误类型。如果没有 Is(error) bool
方法,errors.Is
将使用 ==
进行比较,即使两个错误的类型相同,如果它们的内容不同,比较也会失败。以下示例允许你检查给定的错误是否在错误树中包含 ErrSyntax
:
type ErrSyntax struct {
Line int
Col int
Err error
}
func (err ErrSyntax) Error() string {...}
func (err ErrSyntax) Is(e error) bool {
_,ok:=e.(ErrSyntax)
return ok
}
现在,你可以测试一个错误是否是语法错误:
err:=Parse(input)
if errors.Is(err,ErrSyntax{}) {
// err is a syntax error
}
从错误树中提取特定错误
如何做到这一点...
使用 errors.As
函数遍历错误树,找到特定错误,并提取它。
它是如何工作的...
与 errors.Is
函数类似,errors.As(err error, target any) bool
会遍历 err
的错误树,直到找到一个可以赋值给 target
的错误。这是通过以下方式完成的:
-
它检查
target
指向的值是否可以分配给err
指向的值。 -
如果这失败了,它会检查
err
是否有As(error) bool
方法,通过调用err.As(target)
。如果它返回true
,那么就找到了一个错误。 -
如果没有,它会检查
err
是否有Unwrap() error
方法,并且err.Unwrap()
不是nil
,然后向下遍历树。 -
否则,它会检查
err
是否有Unwrap() []error
方法,并且如果它返回一个非空切片,它会为这些切片中的每一个向下遍历树,直到找到匹配项。
换句话说,errors.As
将可以分配给target
的错误复制到target
中。
以下示例可以用来从一个错误树中提取ErrSyntax
的实例:
func (err ErrSyntax) As(target any) bool {
if tgt, ok:=target.(*ErrSyntax); ok {
*tgt=err
return true
}
return false
}
func main() {
...
err:=Parse(in)
var syntaxError ErrSyntax
if errors.As(err,&syntaxError) {
// syntaxError has a copy of the ErrSyntax
}
...
}
注意这里指针的使用。错误结构体被用作值,而你想要一个错误结构体的副本,所以你传递一个指向它的指针:一个ErrSyntax
实例可以被复制到一个*ErrSyntax
实例中。如果你的程序使用*ErrSyntax
作为错误值,你需要通过声明var syntaxError *ErrSyntax
并将&syntaxError
传递以复制指针到双指针指向的内存位置。
处理恐慌
通常,恐慌是一个不可恢复的情况,例如资源耗尽或违反了不变量(即错误)。一些恐慌,如内存不足或除以零,将由运行时(或由硬件引发并作为恐慌传递给程序)引发。当你检测到错误时,你应该在程序中引发恐慌。但是,你如何决定一个情况是否是错误并且你应该恐慌呢?
通常情况下,外部输入(用户输入、API 提交的数据或从文件读取的数据)不应该引起恐慌。这种情况下应该检测并返回给用户有意义的错误。在这种情况下,恐慌可能是指定为你程序中常量字符串的正则表达式编译失败。输入不是可以通过重新运行程序并使用不同输入来修复的东西;它只是一个错误。
如果没有使用recover
处理恐慌,程序将通过打印诊断输出(包括恐慌的原因和活动 goroutine 的堆栈)来终止。
在必要时恐慌
大多数时候,决定是否恐慌或返回错误不是一个容易的决定。这个方法提供了一些指导方针,使这个决定更容易。
如何做到这一点...
有两种情况下你可以恐慌。如果以下任何一个情况成立,就恐慌:
-
违反了不变量
-
程序无法在当前状态下继续
不变量是在程序中不能被违反的条件。因此,如果你检测到它被违反,而不是返回一个错误,你应该恐慌。
以下示例来自我编写的一个图形库。一个图包含节点和边,由*Graph
结构管理。Graph.NewEdge
方法在两个节点之间创建一条新边。这两个节点必须属于与NewEdge
方法接收者相同的图,因此如果情况不是这样,应该恐慌,如下所示:
func (g *Graph) NewEdge(from,to *Node) *Edge {
if from.graph!=g {
panic("from node is not in graph")
}
if to.graph!=g {
panic("to node is not in graph")
}
...
}
在上面,从这个方法返回错误没有任何好处。这显然是一个调用者没有意识到的错误,如果程序被允许继续,Graph
对象的完整性将被违反,从而产生难以发现的错误。最好的做法是恐慌。
第二种情况是一个无法继续的情况。例如,假设你正在编写一个 Web 应用程序,并从文件系统中加载 HTML 模板。如果此类模板的编译失败,程序无法继续。你应该恐慌。
从恐慌中恢复
未处理的恐慌将终止程序。通常,这是唯一正确的做法。然而,有些情况下,你希望失败导致错误的原因,记录它,并继续。例如,一个处理多个并发请求的服务器不会因为其中一个请求恐慌而终止。这个配方展示了你如何从恐慌中恢复。
如何操作...
在defer
函数中使用recover
语句:
func main() {
defer func() {
if r:=recover(); r != nil {
// deal with the panic
}
}()
...
}
它是如何工作的...
当程序恐慌时,在所有延迟块执行完毕后,恐慌的函数将返回。该 goroutine 的堆栈将一个接一个地展开函数,通过运行它们的deferred
语句进行清理,直到达到 goroutine 的开始,或者其中一个延迟函数调用了recover
。如果没有恢复恐慌,程序将通过打印诊断信息和堆栈信息而崩溃。如果恢复了恐慌,recover()
函数将返回传递给panic
的任何参数,这可以是任何值。
因此,如果你从恐慌中恢复过来,你应该检查恢复的值是否是一个可以用来提供更多有用信息的错误。
在恢复中更改返回值
当你从恐慌中恢复时,你通常想返回某种类型的错误来描述发生了什么。这个配方展示了你如何做到这一点。
如何操作...
当从恐慌中恢复函数的返回值时,使用命名返回值。
它是如何工作的...
命名返回值允许你访问和设置函数的返回值。如下所示,你可以使用命名返回值来更改函数的返回值:
func process() (err error) {
defer func() {
r:=recover()
if e, ok:=r.(error); ok {
err = e
}
捕获恐慌的堆栈跟踪
当检测到恐慌时打印或记录堆栈跟踪是识别运行时问题的关键工具。这个配方展示了你如何将堆栈跟踪添加到你的日志消息中。
如何操作...
使用带有recover
的debug.Stack
函数:
import "runtime/debug"
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
stackTrace := string(debug.Stack())
// Work with stackTrace
fmt.Println(stackTrace)
}
}()
f()
}
func f() {
var i *int
*i=0
}
当在恢复函数内部时,debug.Stack
函数将返回正在恢复的 panic 的堆栈,而不是调用它的堆栈。因此,如果你能记录这个信息或打印它,它将显示 panic 源的确切位置。
警告
以这种方式获取堆栈是一个昂贵的操作。请谨慎使用,并且仅在必要时使用。
前面的程序将打印以下内容:
goroutine 1 [running]:
runtime/debug.Stack()
/usr/local/go-faketime/src/runtime/debug/stack.go:24 +0x5e
main.main.func1()
/tmp/sandbox381445105/prog.go:13 +0x25
panic({0x48bbc0?, 0x5287c0?})
/usr/local/go-faketime/src/runtime/panic.go:770 +0x132
main.f(...)
/tmp/sandbox381445105/prog.go:23
main.main()
/tmp/sandbox381445105/prog.go:18 +0x2e
这里:
-
prog.go:13
是调用debug.Stack()
的位置 -
prog.go:23
是执行*i=0
的位置 -
prog.go:18
是调用f()
的位置
正如你所见,堆栈精确地指出了错误的所在位置 (prog.go:23
)。
第九章:上下文包
上下文是某事发生的情境。当我们谈论程序时,上下文是程序环境、设置等。对于服务器程序(响应客户端请求的 HTTP 服务器、响应函数调用的 RPC 服务器等)或响应用户请求的程序(交互式程序、命令行应用程序等),你可以谈论特定请求的上下文。特定请求的上下文是在服务器或程序开始处理特定请求时创建的,在处理结束时终止。请求上下文包含有助于你在处理请求时识别日志消息的请求标识符等信息,或者调用者的身份,以便你可以确定调用者的访问权限。context
包的一个用途是提供此类请求上下文的抽象,即保持请求特定数据的对象。
你可能还对请求的运行时间有所顾虑。你通常希望限制请求处理的时间,或者你可能想检测客户端是否不再对请求的结果感兴趣(例如 WebSocket 对等端断开连接)。context
包旨在处理这些用例。
context
包定义了context.Context
接口。它有两个用途:
-
为请求处理添加超时和/或取消
-
将请求特定的元数据传递到堆栈中
context.Context
的使用不仅限于服务器程序。术语“请求处理”应从广义上理解:请求可以是 TCP 连接的网络请求、HTTP 请求、从命令行读取的命令、以特定标志运行程序等。因此,context.Context
的用途更加多样化。
本章展示了context
包的常见用法。在本章中,你将了解以下内容:
-
使用上下文传递请求作用域的数据
-
使用上下文进行取消和超时
使用上下文传递请求作用域的数据
请求作用域的对象是在请求处理开始时创建的,在请求处理结束时丢弃的对象。这些通常是轻量级对象,例如请求标识符、标识调用者的认证信息或记录器。在本节中,你将了解如何使用上下文传递这些对象。
如何实现...
向上下文中添加数据值的惯用方法是以下:
-
定义上下文键类型。这可以避免意外的名称冲突。使用以下未导出类型名称是常见的做法。这种模式限制了将此特定类型的上下文值放入或从当前包中获取的能力:
type requestIDKeyType int
警告
你可能会想在这里使用 struct{}
而不是 int
。毕竟,struct{}
不消耗任何额外的内存。当你与 0 大小结构工作时必须非常小心,因为 Go 语言规范没有提供关于两个 0 大小结构等价性的任何保证。也就是说,如果你创建了多个 0 大小类型的变量,它们有时可能相等,有时可能不相等。简而言之,不要使用 struct{}
。
-
使用键类型定义键值,或值。在以下代码行中,
requestIDKey
被定义为requestIDKeyType
类型,其值为0
(requestIDKey
在声明时初始化为其0
值):var requestIDKey requestIDKeyType
-
使用
context.WithValue
将新值添加到上下文中。你可以定义一些辅助函数来设置和获取上下文中的值:func WithRequestID(ctx context.Context,requestID string) context.Context { return context.WithValue(ctx,requestIDKey,requestID) } func GetRequestID(ctx context.Context) string { id,_:=ctx.Value(requestIDKey).(string) return id }
-
将新上下文传递给从当前函数调用的函数:
newCtx:=WithRequestID(ctx,requestID) handleRequest(newCtx)
它是如何工作的...
你可能已经注意到,context.Context
并不完全像一个键值映射(没有 SetValue
方法;实际上,context.Context
是不可变的),尽管你可以用它来存储键值对。实际上,你不能向上下文中添加键值,但你可以在保持旧上下文的同时获取包含该键值的新上下文。上下文就像洋葱一样有层级;向上下文中添加的每个元素都会创建一个新的上下文,它与旧上下文相连,但具有更多功能:
// ctx: An empty context
ctx := context.Background()
// ctx1: ctx + {key1:value1}
ctx1 := context.WithValue(ctx, "key1", "value1")
// ctx2: ctx1 + {key2:value2}
ctx2 := context.WithValue(ctx, "key2", "value2")
在前面的代码中,ctx
、ctx1
和 ctx2
是三个不同的上下文。ctx
上下文为空。ctx1
包含 ctx
和 key1: value1
键值对。ctx2
包含 ctx1
和 key2: value2
键值对。所以,如果你这样做:
val1,_ := ctx2.Value("key1")
val2,_ := ctx2.Value("key2")
fmt.Println(val1, val2)
这将打印以下内容:
value1 value2
假设你对 ctx1
做同样的操作:
val1,_ = ctx1.Value("key1")
val2,_ = ctx1.Value("key2")
fmt.Println(val1, val2)
这将打印以下内容:
value1 <nil>
以下用于 ctx
:
val1,_ = ctx.Value("key1")
val2,_ = ctx.Value("key2")
fmt.Println(val1, val2)
这将打印以下内容:
<nil> <nil>
小贴士
即使你无法在上下文中设置值(也就是说,上下文是不可变的),你仍然可以设置一个指向结构的指针,并在该结构中设置值。
也就是说:
type ctxData struct {
value int
}
...
ctx:=context.WithValue(context.Background(),dataKey, &ctxData{})
...
if data,exists:=ctx.Value(dataKey); exists {
data.(*ctxData).value=1
}
标准库提供了一些预定义的上下文值:
-
context.Background()
返回一个没有值且无法取消的上下文。这通常是大多数操作的基础上下文。 -
context.TODO()
与context.Background()
类似,其名称表明在任何使用它的地方最终都应该重构以接受真实上下文。
还有更多...
上下文通常在多个 goroutine 之间共享。你必须小心并发问题,特别是如果你在上下文中放置对象的指针。看看以下示例,它展示了 HTTP 服务的身份验证中间件:
type AuthInfo struct {
// Set when AuthInfo is created
UserID string
// Lazy-initialized
privileges map[string]Privilege
}
type authInfoKeyType int
var authInfoKey authInfoKeyType
// Set the privileges if is it not initialized.
// Do not do this!!
func (auth *AuthInfo) GetPrivileges() map[string]Privilege {
if auth.privileges==nil {
auth.privileges=GetPrivileges(auth.UserID)
}
return auth.privileges
}
// Authentication middleware
func AuthMiddleware(next http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.
Request) {
// Authenticate the caller
var authInfo *AuthInfo
var err error
authInfo, err = authenticate(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Create a new context with the authentication info
newCtx := context.WithValue(r.Context(), authInfoKey,
authInfo)
// Pass the new context to the next handler
next.ServeHTTP(w, r.WithContext(newCtx))
})
}
}
认证中间件创建了一个 *AuthInfo
实例,并使用带有认证信息的上下文调用链中的下一个处理器。在这段代码中存在的问题是 *AuthInfo
包含一个 privileges
字段,它在调用 AuthInfo.GetPrivileges
时被初始化。由于上下文可以通过处理器传递给多个 goroutines,这种延迟初始化方案容易发生数据竞争;多个 goroutines 调用 AuthInfo.GetPrivileges
可能会尝试多次初始化映射,一次覆盖另一次。
可以使用互斥锁来纠正这个问题:
type AuthInfo struct {
sync.Mutex
UserID string
privileges map[string]Privilege
}
func (auth *AuthInfo) GetPrivileges() map[string]Privilege {
// Use mutex to initialize the privileges in a thread-safe way
auth.Lock()
defer auth.Unlock()
if auth.privileges==nil {
auth.privileges=GetPrivileges(auth.UserID)
}
return auth.privileges
}
这也可以通过在中间件中一次性初始化权限来纠正:
authInfo, err=authenticate(r)
if err!=nil {
http.Error(w,err.Error(),http.StatusUnauthorized)
return
}
// Initialize the privileges here when the structure is created
authInfo.GetPrivileges()
使用上下文进行取消操作
你可能想要取消计算的原因有几个:客户端可能已经断开连接,或者你可能有多条 goroutines 正在处理一个计算,其中一条失败了,因此你不再希望其他继续。你可以使用其他方法,例如关闭一个 done
通道来发出取消信号,但根据使用情况,上下文可能更方便。上下文可以被取消多次(只有第一次调用实际上会取消;其余的将被忽略),而你不能关闭已经关闭的通道,因为这会导致 panic。此外,你可以创建一个上下文树,其中取消一个上下文只会取消它控制的 goroutines,而不会影响其他 goroutines。
如何做到...
这些是创建可取消上下文和检测取消的步骤:
-
使用
context.WithCancel
基于现有上下文和一个取消函数创建一个新的可取消上下文:ctx:=context.Background() cancelable, cancel:=context.WithCancel(ctx) defer cancel()
确保最终调用了
cancel
函数。取消释放了与上下文关联的资源。 -
将可取消的上下文传递给可以取消的计算或 goroutines:
go cancelableGoroutine1(cancelable) go cancelableGoroutine2(cancelable) cancelableFunc(cancelable)
-
在取消函数中,使用
ctx.Done()
通道或ctx.Err()
检查上下文是否被取消:func cancelableFunc(ctx context.Context) { // Process some data // Check context cancelation select { case <-ctx.Done(): // Context canceled return default: } // Continue computation }
或者,使用以下方法:
func cancelableFunc(ctx context.Context) { // Process some data // Check context cancelation if ctx.Err()!=nil { // Context canceled return } // Continue computation }
-
要手动取消一个函数,调用取消函数:
ctx:=context.Background() cancelable, cancel:=context.WithCancel(ctx) defer cancel() wg:=sync.WaitGroup{} wg.Add(1) go cancelableGoroutine1(cancelable,&wg) if err:=process(ctx); err!=nil { // Cancel the context cancel() // Do other things } wg.Wait()
-
确保最终调用了
cancel
函数(使用defer cancel()
):
cancelable, cancel := context.WithCancel(ctx)
defer cancel()
...
警告
确保调用 cancel
是很重要的。如果你没有取消一个可取消的上下文,与该上下文关联的 goroutines 将会泄漏(即,将无法终止这些 goroutines,它们将消耗内存)。
提示
cancel
函数可以被多次调用。后续的调用将被忽略。
它是如何工作的...
context.WithCancel
返回一个新的上下文和 cancel
闭包。返回的上下文是基于原始上下文的一个可取消上下文:
// Empty context, no cancelation
originalContext := context.Background()
// Cancelable context based on originalContext
cancelableContext1, cancel1 := context.WithCancel(originalContext)
你可以使用这个上下文来控制多个 goroutines:
go f1(cancelableContext1)
go f2(cancelableContext1)
你也可以基于一个可取消的上下文创建其他可取消的上下文:
cancelableContext2, cancel2 := context.WithCancel(cancelableContext)
go g1(cancelableContext2)
go g2(cancelableContext2)
现在,我们有两个可取消的上下文。调用 cancel2
将只会取消 cancelableContext2
:
cancal2() // canceling g1 and g2 only
调用 cancel1
将会取消 cancelableContext1
和 cancelableContext2
:
cancel1() // canceling f1, f2, g1, g2
上下文取消不是自动取消 goroutines 的方式。您必须检查上下文取消并进行相应的清理:
func f1(cancelableContext context.Context) {
for {
if cancelableContext.Err()!=nil {
// Context is canceled
// Cleanup and return
return
}
// Process
}
}
使用上下文进行超时
超时简单来说就是自动取消。上下文将在计时器到期后取消。这在限制不太可能完成的计算的资源消耗时很有用。
如何做到这一点...
创建具有超时并检测超时事件发生的步骤如下:
-
使用
context.WithTimeout
创建一个新的可取消上下文,该上下文将在给定持续时间后自动取消,基于现有上下文和取消函数:ctx:=context.Background() timeoutable, cancel:=context.WithTimeout(ctx,5*time.Second) defer cancel()
或者,您可以使用
WithDeadline
在指定时刻取消上下文。确保最终调用
cancel
函数。 -
将超时上下文传递给可能超时的计算或 goroutine:
go longRunningGoroutine1(timeoutable) go longRunningGoroutine2(timeoutable)
-
在 goroutine 中,使用
ctx.Done()
通道或ctx.Err()
检查上下文是否被取消:func longRunningGoroutine(ctx context.Context) { // Process some data // Check context cancelation select { case <-ctx.Done(): // Context canceled return default: } // Continue computation }
或者,使用以下方法:
func cancelableFunc(ctx context.Context) { // Process some data // Check context cancelation if ctx.Err()!=nil { // Context canceled return } // Continue computation }
-
要手动取消函数,请调用取消函数:
ctx:=context.Background() timeoutable, cancel:=context.WithTimeout(ctx, 5*time.Second) defer cancel() wg:=sync.WaitGroup{} wg.Add(1) go longRunningGoroutine(timeoutable,&wg) if err:=process(ctx); err!=nil { // Cancel the context cancel() // Do other things } wg.Wait()
-
确保最终调用
cancel
函数(使用defer cancel()
):timeoutable, cancel := context.WithTimeout(ctx,5*time.Second) defer cancel() ...
它是如何工作的...
超时功能简单来说就是附加了计时器的取消。当计时器到期时,上下文将被取消。
还有更多...
可能存在 goroutine 阻塞而没有明显取消方法的情况。例如,您可能阻塞等待从网络连接读取:
func readData(conn net.Conn) {
// Read a block of data from the connection
msg:=make([]byte,1024)
n, err:=conn.Read(msg)
...
}
此操作无法取消,因为 Read
不接受 Context
。如果您想取消此类操作,可以异步关闭底层连接(或文件)。以下代码片段演示了一个用例,其中必须在 1 秒内读取连接的所有数据,否则 goroutine 将异步关闭连接:
timeout, cancel := context.WithTimeout(context.Background(),1*time.Second)
defer cancel()
// Close the connection when context times out
go func() {
// Wait for cancelation signal
<-cancelable.Done()
// Close the connection
conn.Close()
}()
wg:=sync.WaitGroup()
wg.Add(1)
// This goroutine must complete within a second, or the connection
// will be closed
go func() {
defer wg.Done()
// Read a block of data from the connetion
msg:=make([]byte,1024)
// This call may block
n, err:=conn.Read(msg)
if err!=nil {
return
}
// Process data
}()
wg.Wait() // Wait for the processing of connection to complete
...
在服务器中使用取消和超时
网络服务器通常在接收到新请求时启动一个新的上下文。通常,服务器在请求者关闭连接时取消上下文。大多数 HTTP 框架,包括标准库,都遵循这个基本模式。如果您正在编写自己的 TCP 服务器,您必须自己实现它。
如何做到这一点...
处理具有超时或取消的网络连接的步骤如下:
-
当您接受网络连接时,创建一个新的带有取消或超时的上下文:
-
确保上下文最终被取消。
-
将上下文传递给处理器:
ln, err:=net.Listen("tcp",":8080") if err!=nil { return err } for { conn, err:=ln.Accept() if err!=nil { return err } go func(c net.Conn) { // Step 1: // Request times out after duration: RequestTimeout ctx, cancel:=context.WithTimeout(context. Background(),RequestTimeout) // Step 2: // Make sure cancel is called defer cancel() // Step 3: // Pass the context to handler handleRequest(ctx,c) }(conn) }
第十章:处理大量数据
你可以通过几种方式利用 Go 并发原语有效地处理大量数据。与线程不同,goroutines 可以创建而不需要太多开销。在程序中拥有数千个 goroutines 是很常见的。考虑到这一点,我们将探讨一些处理大量数据并发的常见模式。
本章包括以下食谱:
-
工作者池
-
连接池
-
管道
-
处理大量结果集
工作者池
假设你有很多数据元素(例如,图像文件),并且你想将相同的逻辑应用于每个元素。你可以编写一个处理输入实例的函数,然后在for
循环中调用此函数。这样的程序将顺序处理输入元素,如果每个元素处理需要t
秒,所有输入最终将在n.t
秒内完成,其中n
是输入的数量。
如果你想通过使用并发编程来提高吞吐量,你可以创建一个工作者 goroutine 池。你可以将下一个输入提供给空闲的工作者池成员,在处理过程中,你可以将后续输入分配给另一个成员。如果你有p
个逻辑处理器(可以是物理处理器的核心)并行运行,结果可以在n.t/p
秒内可用(这是一个理论上的上限,因为并行进程之间的负载分布并不总是完美的,而且还有同步和通信开销)。
接下来,我们将探讨两种实现工作者池的不同方法。
有限工作者池
如果每个工作者的初始化成本不高(例如,加载文件或建立网络连接可能成本较高),那么最好根据需要创建工作者,并限制工作者的数量。
如何做...
为每个输入创建一个新的 goroutine。使用通道作为同步计数器以限制最大工作者数量(在此,通道用作信号量)。如果需要,使用输出通道收集结果:
// Establish a maximum pool size
const maxPoolSize = 100
func main() {
// 1\. Initialization
// Receive outputs from the pool via outputCh
outputCh := make(chan Output)
// A semaphore to limit the pool size
sem := make(chan struct{}, maxPoolSize)
// 2\. Read outputs
// Reader goroutine reads results until outputCh is closed
readerWg := sync.WaitGroup{}
readerWg.Add(1)
go func() {
defer readerWg.Done()
for result := range outputCh {
// process result
fmt.Println(result)
}
}()
// 3\. Processing loop
// Create the workers as needed, but the number of active workers
// are limited by the capacity of sem
wg := sync.WaitGroup{}
// This loop sends the inputs to workers, creating them as
// necessary
for {
nextInput, done := getNextInput()
if done {
break
}
wg.Add(1)
// This will block if there are too many goroutines
sem <- struct{}{}
go func(inp Input) {
defer wg.Done()
defer func() {
<-sem
}()
outputCh <- doWork(inp)
}(nextInput)
}
// 4\. Wait until processing is complete
// This goroutine waits until all worker pool goroutines are done,
// then closes the output channel
go func() {
// Wait until processing is complete
wg.Wait()
// Close the output channel so the reader goroutine can
// terminate
close(outputCh)
}()
// Wait until the output channel is closed
readerWg.Wait()
// If we are here, all goroutines are done
}
它是如何工作的...
-
首先是初始化。我们创建两个通道:
-
outputCh
:工作者池的输出。每个工作者将结果写入此通道。 -
sem
:将用于限制活动工作者数量的信号量通道。它具有maxPoolSize
容量。当我们启动新的工作者 goroutine 时,我们向此通道发送一个元素。只要sem
通道中的元素少于maxPoolSize
,发送操作就不会阻塞。当一个工作者 goroutine 完成时,它从通道接收一个元素,释放容量。由于此通道具有maxPoolSize
容量,如果正在运行maxPoolSize
个工作者,则发送操作将阻塞,直到 goroutine 结束并从通道接收。
-
-
在开始处理之前关闭
outputCh
,这样就可以在将所有输入发送到工作者之前读取结果。由于工作者的数量有限,创建maxPoolSize
个工作者后,工作者将阻塞,因此我们必须在创建工作者池之前开始监听输出。 -
wg
WaitGroup,稍后将用于等待工作者完成。在创建新工作者之前,我们向信号量通道发送一个元素。如果有maxPoolSize
个工作者在运行,这将阻塞,直到其中之一终止。工作者处理输入,将输出写入outputCh
并终止,从信号量接收一个元素。 -
此 goroutine 等待跟踪工作者的 WaitGroup。当所有工作者都完成后,输出通道被关闭。这也标志着在步骤 2中创建的读取 WaitGroup。
-
等待输出处理完成。程序必须等待所有输出都生成。这只有在
outputCh
关闭(在#4 发生)之后,然后释放readerWg
才会发生。
固定大小的工作者池
如果创建工作者是一个昂贵的操作,固定大小的工作者池是有意义的。只需创建从公共输入通道读取的最大数量的工作者。此输入通道负责在可用的工作者之间分配工作。
如何做...
有几种方法可以实现这一点。我们将查看两种。
在以下函数中,使用poolSize
个工作者创建了一个固定大小的工作者池。所有工作者都从相同的输入通道读取,并将输出写入相同的输出通道。此程序使用一个读取 goroutine 从工作者池收集结果,同时在调用者的同一个 goroutine 中提供输入:
const poolSize = 50
func workerPoolWithConcurrentReader() {
// 1\. Initialization
// Send inputs to the pool via inputCh
inputCh := make(chan Input)
// Receive outputs from the pool via outputCh
outputCh := make(chan Output)
// 2\. Create the pool of workers
wg := sync.WaitGroup{}
for i := 0; i < poolSize; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for work := range inputCh {
outputCh <- doWork(work)
}
}()
}
// 3.a Reader goroutine
// Reader goroutine reads results until outputCh is closed
readerWg := sync.WaitGroup{}
readerWg.Add(1)
go func() {
defer readerWg.Done()
for result := range outputCh {
// process result
fmt.Println(result)
}
}()
// 4\. Wait workers
// This goroutine waits until all worker pool goroutines are done,
// then closes the output channel
go func() {
// Wait until processing is complete
wg.Wait()
// Close the output channel so the reader goroutine can
// terminate
close(outputCh)
}()
// 5.a Send inputs
// This loop sends the inputs to the worker pool
for {
nextInput, done := getNextInput()
if done {
break
}
inputCh <- nextInput
}
// Close the input channel, so worker pool goroutines terminate
close(inputCh)
// Wait until the output channel is closed
readerWg.Wait()
// If we are here, all goroutines are done
}
以下版本使用 goroutine 将工作提交给工作者池,同时在调用者的同一个 goroutine 中读取结果:
func workerPoolWithConcurrentWriter() {
// 1\. Initialization
// Send inputs to the pool via inputCh
inputCh := make(chan Input)
// Receive outputs from the pool via outputCh
outputCh := make(chan Output)
// 2\. Create the pool of workers
wg := sync.WaitGroup{}
for i := 0; i < poolSize; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for work := range inputCh {
outputCh <- doWork(work)
}
}()
}
// 3.b Writer goroutine
// Writer goroutine submits work to the worker pool
go func() {
for {
nextInput, done := getNextInput()
if done {
break
}
inputCh <- nextInput
}
// Close the input channel, so worker pool goroutines
// terminate
close(inputCh)
}()
// 4\. Wait workers
// This goroutine waits until all worker pool goroutines are done,
// then closes the output channel
go func() {
// Wait until processing is complete
wg.Wait()
// Close the output channel so the reader goroutine can
// terminate
close(outputCh)
}()
// 5.b Read results
// Read results until outputCh is closed
for result := range outputCh {
// process result
fmt.Println(result)
}
}
它是如何工作的...
-
首先是初始化。我们创建两个通道:
-
inputCh
:这是工作者池的输入。池中的每个工作者都在for-range
循环中从相同的inputCh
读取,因此当工作者收到输入时,它停止监听通道,允许另一个工作者拾取下一个输入。 -
outputCh
:这是工作者池的输出。所有工作者在完成时都将输出写入此通道。
-
-
创建工作者池:由于这是一个固定大小的池,我们可以通过简单的 for-loop 创建工作者。需要一个
WaitGroup
,这样我们就可以等待处理完成。每个工作者从inputCh
读取,直到它关闭,处理输入,并将输出写入outputCh
。
该算法的其余部分对于两个示例是不同的。让我们先看看第一个案例:
-
outputCh
已关闭。当outputCh
关闭时,readerWg
被信号。 -
当
inputCh
关闭时),它关闭了outputCh
。 -
此
for
循环将输入发送到inputCh
,然后关闭inputCh
。这将在工作线程完成其工作后导致所有工作线程终止。当所有工作线程终止时,由 #4 处创建的 goroutine 将关闭outputCh
。当输出处理完成时,readerWg
被通知,从而终止计算。
接下来,让我们看看第二种情况:
-
逐个读取
inputCh
,当所有输入都发送完毕后,关闭inputCh
,这将导致工作线程池终止。 -
等待工作线程:这些工作与前面的情况相同。
-
for
循环从outputCh
读取结果,直到其关闭。当所有工作线程完成时,outputCh
将被关闭。
连接池
连接池在处理稀缺资源的多个用户时非常有用,例如网络连接或数据库连接,因为建立该资源的实例可能成本高昂。使用一对通道,您可以实现一个高效的线程安全连接池。
如何实现...
创建一个具有 PoolSize
容量的连接池类型:
-
available
保留已建立但返回到池中的连接 -
total
保留连接总数,即available
的数量加上正在积极使用的连接数量。
要从池中获取连接,检查 available
通道。如果有一个可用,则返回它。否则,检查 total
连接池,如果未超过限制,则创建一个新的连接。
使用此池的用户在完成使用后应通过将连接发送到 available
通道将连接返回到池中。
以下代码片段展示了这样一个连接池:
type ConnectionPool struct {
// This channel keeps connections returned to the pool
available chan net.Conn
// This channel counts the total number of connection active
total chan struct{}
}
func NewConnectionPool(poolSize int) *ConnectionPool {
return &ConnectionPool {
available: make(chan net.Conn,poolSize),
total: make(chan struct{}, poolSize),
}
}
func (pool *ConnectionPool) GetConnection() (net.Conn, error) {
select {
// If there are connections available in the pool, return one
case conn := <-pool.available:
fmt.Printf("Returning an idle connection.\n")
return conn, nil
default:
// No connections are available
select {
case conn := <-pool.available:
fmt.Printf("Returning an idle connection.\n")
return conn, nil
case pool.total <- struct{}{}: // Wait until pool is not full
fmt.Println("Creating a new connection")
// Create a new connection
conn, err := net.Dial("tcp", "localhost:2000")
if err != nil {
return nil, err
}
return conn, nil
}
}
}
func (pool *ConnectionPool) Release(conn net.Conn) {
pool.available <- conn
fmt.Printf("Releasing a connection. \n")
}
func (pool *ConnectionPool) Close(conn net.Conn) {
fmt.Println("Closing connection")
conn.Close()
<-pool.total
}
它是如何工作的...
-
使用
PoolSize
初始化连接池:pool := NewConnectionPool(PoolSize)
-
这将创建两个通道,容量均为
PoolSize
。available
通道将保留所有返回到池中的连接,而total
将保留已建立的连接数。 -
要获取新的连接,请使用以下方法:
conn, err := pool.GetConnection()
GetConnection
的这种实现说明了如何建立通道优先级。如果available
通道中有可用空闲连接,GetConnection
将返回一个空闲连接。否则,它将进入default
情况,在那里它将创建一个新的连接或使用返回到available
通道的连接。注意
GetConnection
中嵌套select
语句的模式。这是实现通道之间优先级的常见模式。如果有可用的连接,则case conn := <-pool.available
将被选择,并且连接将从可用的连接通道中移除。然而,如果在第一次select
语句运行时没有可用的连接,则执行default
情况,这将执行conn:=<-pool.available
和pool.total<-struct{}{}
情况之间的select
。如果第一个情况变得可用(这发生在其他 goroutine 将连接返回到池中时),该连接将被返回给调用者。如果第二个情况变得可用(这发生在连接关闭,从而从pool.total
中移除一个元素时),将创建一个新的连接并返回给调用者。 -
当池的客户端完成连接时,它应该调用以下操作:
pool.Release(conn)
-
这会将连接添加到
available
通道。如果连接变得无响应,客户端可以将其关闭。当这种情况发生时,池应该被通知,
total
应该递减,但连接不应添加到available
。这是通过以下操作完成的:pool.Close(conn)
管道
当你在输入上执行多个操作阶段时,你可以构建一个管道。goroutines 和 channels 可以用来构建具有不同结构的具有高吞吐量的处理管道。
没有扇出/扇入的简单管道
可以通过使用 channels 连接在各自 goroutine 中运行的每个阶段来构建一个简单的管道。管道的结构看起来像图 10**.1。
图 10.1:简单的异步管道
如何做到这一点...
此管道使用单独的错误通道来报告处理错误。我们使用自定义错误类型来捕获诊断信息:
type PipelineError struct {
// The stage in which error happened
Stage int
// The payload
Payload any
// The actual error
Err error
}
每个阶段都实现为一个函数,该函数创建一个新的 goroutine。该 goroutine 从输入通道读取输入数据,并将输出写入输出通道:
func Stage1(input <-chan InputPayload, errCh chan<- error) <-chan Stage2Payload {
// 1\. Create the output channel for this stage.
// This will be the input for the next stage
output := make(chan Stage2Payload)
// 2\. Create processing goroutine
go func() {
// 3\. Close the output channel when done
defer close(output)
// 4\. Process all inputs until input channel is closed
for in := range input {
// 5\. Process data
err := processData(in.Id)
// 6\. Send errors to the error channel
if err != nil {
errCh <- PipelineError{
Stage: 1,
Payload: in,
Err: err,
}
continue
}
// 7\. Send the output to the next stage
output <- Stage2Payload{
Id: in.Id,
}
}
}()
return output
}
阶段 2 和 3 使用相同的模式实现。
管道按照以下方式组合:
func main() {
// 1\. Create the input and error channels
errCh := make(chan error)
inputCh := make(chan InputPayload)
// 2\. Prepare the pipeline by attaching stages
outputCh := Stage3(Stage2(Stage1(inputCh, errCh), errCh), errCh)
// 3\. Feed input asynchronously
go func() {
defer close(inputCh)
for i := 0; i < 1000; i++ {
inputCh <- InputPayload{
Id: i,
}
}
}()
// 4\. Listen to the error channel asynchronously
go func() {
for err := range errCh {
fmt.Println(err)
}
}()
// 5\. Read outputs
for out := range outputCh {
fmt.Println(out)
}
// 6\. Close the error channel
close(errCh)
}
对于每个阶段,遵循以下步骤:
-
为阶段创建输出通道。这将被传递到下一个阶段作为
input
通道。 -
处理 goroutine 在阶段函数返回后继续运行。
-
确保在处理 goroutine 终止时关闭此阶段的输出通道。
-
从上一个阶段读取输入,直到输入通道关闭。
-
处理输入。
-
如果发生错误,将错误发送到错误通道。不会生成任何输出。
-
将输出发送到下一个阶段。
警告
每个阶段都在自己的 goroutine 中运行。这意味着一旦你将有效负载传递到下一个阶段,就不应该在当前阶段访问该有效负载。如果有效负载包含指针,或者有效负载本身就是一个指针,可能会发生数据竞争。
管道设置如下:
-
创建输入通道和错误通道。
将阶段附加到管道中。阶段
n
的输出成为阶段n+1
的输入。最后一个阶段的输出成为output
通道。 -
异步将输入发送到输入通道。当所有输入都发送后,关闭输入通道。这将终止第一个阶段,关闭其输出通道,该输出通道也是阶段 2 的输入通道。这个过程一直持续到所有阶段退出。
-
启动一个 goroutine 来监听和记录错误。
-
收集输出。
-
关闭错误通道,以便错误收集 goroutine 终止。
以工人池作为阶段的管道
之前的例子为每个阶段使用了一个工作者。您可以通过将每个阶段替换为工作池来提高管道的吞吐量。结果管道如图 图 10**.2 所示。
图 10.2:作为阶段的工人池的管道
如何操作...
每个阶段现在创建多个 goroutine,所有 goroutine 都从相同的输入通道读取(扇出)。每个工作者的输出写入一个公共输出通道(扇入),它成为下一阶段的输入。由于现在有多个 goroutine 写入该输出通道,因此我们不能再在关闭输入通道时关闭阶段输出通道。相反,我们使用一个 wait group 和一个第二个 goroutine 在所有处理 goroutine 终止时关闭输出:
func Stage1(input <-chan InputPayload, errCh chan<- error, nInstances int) <-chan Stage2Payload {
// 1\. Create the common output channel
output := make(chan Stage2Payload)
// 2\. Close the output channel when all the processing is done
wg := sync.WaitGroup{}
// 3\. Create nInstances goroutines
for i := 0; i < nInstances; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Process all inputs
for in := range input {
// Process data
err := processData(in.Id)
if err != nil {
errCh <- PipelineError{
Stage: 1,
Payload: in,
Err: err,
}
continue
}
//Send output to the common output channel
output <- Stage2Payload{
Id: in.Id,
}
}
}()
}
// 4\. Another goroutine waits until all workers are done, and
//closes the output channel
go func() {
wg.Wait()
close(output)
}()
return output
}
管道构建与上一个案例相同:
func main() {
errCh := make(chan error)
inputCh := make(chan InputPayload)
nInstances := 5
// Prepare the pipeline by attaching stages
outputCh := Stage3(Stage2(Stage1(inputCh, errCh, nInstances),
errCh, nInstances), errCh, nInstances)
// Feed input asynchronously
go func() {
defer close(inputCh)
for i := 0; i < 1000; i++ {
inputCh <- InputPayload{
Id: i,
}
}
}()
// Listen to the error channel asynchronously
go func() {
for err := range errCh {
fmt.Println(err)
}
}()
// Read outputs
for out := range outputCh {
fmt.Println(out)
}
// Close the error channel
close(errCh)
}
它是如何工作的...
对于每个阶段,遵循以下步骤:
-
创建输出通道,它将成为下一阶段的输入通道。
在 for-range 循环中,有多个 goroutine 从相同的输入通道读取,因此当输入通道关闭时,所有这些 goroutine 都将终止。然而,我们不能使用
defer close
关闭输出通道,因为这会导致输出通道多次关闭(这将引发 panic)。因此,我们使用WaitGroup
来跟踪工作者 goroutine。一个单独的 goroutine 等待该 wait group,当所有 goroutine 终止时,它关闭输出通道。 -
创建
nInstances
个 goroutine,它们都从相同的输入通道读取,并将输出写入输出通道。在发生错误的情况下,工作者将错误发送到错误通道。 -
这是等待工作者 goroutine 完成的 goroutine。当它们完成时,它关闭输出通道。
管道设置与上一节相同,不同之处在于初始化时还会将工作池大小发送到阶段函数。
具有扇出和扇入的管道
在此设置中,阶段通过专用通道依次连接,如图 图 10**.3 所示:
图 10.3:具有扇出和扇入的管道
如何操作...
每个管道阶段从给定的输入通道读取,并写入输出通道,如下所示:
func Stage1(input <-chan InputPayload, errCh chan<- error) <-chan Stage2Payload {
output := make(chan Stage2Payload)
go func() {
defer close(output)
// Process all inputs
for in := range input {
// Process data
err := processData(in.Id)
if err != nil {
errCh <- PipelineError{
Stage: 1,
Payload: in,
Err: err,
}
continue
}
output <- Stage2Payload{
Id: in.Id,
}
}
}()
return output
}
一个独立的fanIn
函数接收一个输出通道列表,并使用监听每个通道的 goroutine 将它们合并:
func fanIn(inputs []<-chan OutputPayload) <-chan OutputPayload {
result := make(chan OutputPayload)
// Listen to input channels in separate goroutines
inputWg := sync.WaitGroup{}
for inputIndex := range inputs {
inputWg.Add(1)
go func(index int) {
defer inputWg.Done()
for data := range inputs[index] {
// Send the data to the output
result <- data
}
}(inputIndex)
}
// When all input channels are closed, close the fan in ch
go func() {
inputWg.Wait()
close(result)
}()
return result
}
管道是通过将每个阶段的输出组合到下一个阶段输入中,在 for 循环中设置的。生成的输出通道都导向fanIn
函数:
func main() {
errCh := make(chan error)
inputCh := make(chan InputPayload)
poolSize := 5
outputs := make([]<-chan OutputPayload, 0)
// All Stage1 goroutines listen to a single input channel
for i := 0; i < poolSize; i++ {
outputCh1 := Stage1(inputCh, errCh)
outputCh2 := Stage2(outputCh1, errCh)
outputCh3 := Stage3(outputCh2, errCh)
outputs = append(outputs, outputCh3)
}
outputCh := fanIn(outputs)
// Feed input asynchronously
go func() {
defer close(inputCh)
for i := 0; i < 1000; i++ {
inputCh <- InputPayload{
Id: i,
}
}
}()
// Listen to the error channel asynchronously
go func() {
for err := range errCh {
fmt.Println(err)
}
}()
// Read outputs
for out := range outputCh {
fmt.Println(out)
}
// Close the error channel
close(errCh)
}
它是如何工作的...
工作阶段与简单的管道情况相同。fan-in 阶段的工作方式如下。
对于每个输出通道,fan-in 函数创建一个 goroutine,该 goroutine 从该输出通道读取数据并将其写入一个公共通道。这个公共通道成为管道的合并输出通道。fan-in 函数创建另一个 goroutine,该 goroutine 等待一个wait
组,该组跟踪所有 goroutine。当它们都完成时,这个 goroutine 关闭输出通道。
main
通过连接每个阶段的输出到下一个阶段的输入来构建管道。最后一个阶段的输出通道被存储在一个切片中,并传递给 fan-in 函数。fan-in 函数的输出通道成为管道的合并输出。
注意,所有这些管道变体都使用一个单独的错误通道。另一种方法是存储任何错误在有效负载中,并将其传递到下一个阶段。如果传入的有效负载有一个非 nil 的错误,所有阶段都会将其传递到下一个阶段,因此有效负载可以在管道的末尾记录为错误:
type Stage2Paylaod struct {
// Payload data
Err error
}
func Stage2(input <-chan Stage2Payload) <-chan Stage3Payload {
output := make(chan Stage2Payload)
go func() {
defer close(output)
// Process all inputs
for in := range input {
// If there is error, pass it
if in.Err!=nil {
output <- StagerPayload {
Err: in.Err,
}
continue
}
...
还要注意,除了简单的管道情况外,它们还以无序的方式返回结果,因为任何给定时刻有多个输入通过管道,并且无法保证它们到达输出的顺序。
处理大型结果集
当处理可能很大的结果集时,可能并不总是可行将所有数据加载到内存中并对其进行处理。你可能需要以受控的方式流式传输数据元素。本节展示了如何使用并发原语处理此类情况。
使用 goroutine 进行流式结果
在此用例中,一个 goroutine 通过通道发送查询结果。可以使用上下文来取消流式 goroutine。
如何做到这一点...
创建一个包含数据元素和错误信息的结构体:
type Result struct {
Err error
// Other data elements
}
StreamResults
函数运行数据库查询并创建一个 goroutine,该 goroutine 迭代查询结果。goroutine 通过通道发送每个结果:
func StreamResults(
ctx context.Context,
db *sql.DB,
query string,
args ...any,
) (<-chan Result, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
output := make(chan Result)
go func() {
defer rows.Close()
defer close(output)
var result Result
for rows.Next() {
// Check context cancellation
if result.Err = ctx.Err(); result.Err != nil {
// Context canceled. return
output <- result
return
}
// Set result fields
result.Err = buildResult(rows, &result)
output <- result
}
// If there was an error, return it
if result.Err = rows.Err(); result.Err != nil {
output <- result
}
}()
return output, nil
}
如下使用流式结果:
// Setup a cancelable context
cancelableCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Call the streaming API
results, err := StreamResults(cancelableCtx,db,"SELECT EMAIL FROM USERS")
if err!=nil {
return err
}
// Collect and process results
for result:=range results {
if result.Err!=nil {
// Handle error in the result
continue
}
// Process the result
if err:=ProcessResult(result); err!=nil {
// Processing error. Cancel streaming results
cancel()
// Expect to receive at least one more message from the channel,
// because the streaming gorutine sends the error
for range results {}
}
}
它是如何工作的...
尽管我们查看了一个数据库查询示例,但这种模式在处理任何生成大量数据的函数时都很有用。而不是将所有数据加载到内存中,这种模式一次加载和处理一个数据项。
StreamResults
生成函数启动一个 goroutine 闭包,该闭包捕获生成结果所需的环境和附加信息(在这种情况下,一个sql.Rows
实例)。生成函数创建一个通道并立即返回。goroutine 收集结果并将它们发送到通道。当所有结果都处理完毕或检测到错误时,通道被关闭。
现在轮到调用者与 goroutine 进行通信。调用者从通道中收集结果,直到通道关闭,并逐个处理它们。调用者还会检查接收到的消息中的错误字段,以处理 goroutine 检测到的任何错误。
此方案使用可取消的上下文。当上下文被取消时,goroutine 在关闭通道之前通过通道发送另一条消息,因此如果发生上下文取消,调用者必须清空通道。
第十一章:与 JSON 一起工作
JSON 是 JavaScript 对象表示法的缩写。它是一种流行的数据交换格式,因为 JSON 对象与结构化类型(Go 中的struct
)非常相似,并且它是基于文本的编码,使得编码后的数据可读。它支持数组、对象(键值对)以及相对较少的基本类型(字符串、数字、布尔值和null
)。这些特性使得 JSON 成为一个相对容易处理的格式。
编码是指将数据元素转换成一系列字节的进程。当你使用 JSON 编码(或序列化)数据元素时,你会在遵循 JSON 语法规则的同时创建这些数据元素的文字表示。相反的过程,解码(或反序列化)将 JSON 值分配给 Go 对象。编码过程是有损的:你必须将数据值描述为文本,而对于复杂的数据类型来说,这并不总是显而易见的。当你解码这样的数据时,你必须知道如何解释文本表示,以便你能正确地解析 JSON 表示。
在本章中,我们将首先查看基本数据类型的编码和解码。然后我们将查看一些处理更复杂的数据类型和用例的食谱。在实现自己的解决方案时,你应该将这些食谱作为指南。这些食谱展示了特定用例的解决方案,你可能需要根据你的具体需求对其进行调整。
本章包括以下食谱:
-
结构体的编码
-
处理嵌套结构
-
不定义结构体的编码
-
结构体的解码
-
使用接口、映射和切片进行解码
-
解码数字的其他方式
-
自定义数据类型的序列化和反序列化
-
对象键的自定义序列化和反序列化
-
动态字段名
-
多态数据结构
-
流式传输 JSON 数据
序列化和反序列化基础
标准库中的encoding/json
包提供了方便的函数和约定来编码/解码 JSON 数据。
结构体的编码
Go 结构体类型通常被编码为 JSON 对象。本节展示了处理数据类型编码的标准库工具。
如何做...
- 使用
json
标签注释结构体字段及其 JSON 键:
type Config struct {
Version string `json:"ver"` // Encoded as "ver"
Name string // Encoded as "Name"
Type string `json:"type,omitempty"` // Encoded as "type",
// and will be omitted if
// empty
Style string `json:"-"` // Not encoded
value string // Unexported field, not encoded
kind string `json:"kind"` // Unexported field, not encoded
}
- 使用
json.Marshal
函数将 Go 数据对象编码为 JSON。标准库为基本类型使用以下约定:
Go 声明 | 值 | JSON 输出 |
---|---|---|
NumberValue int json:”num” |
0 |
“``num”: 0 |
NumberValue *``int json:”num” |
nil |
“``num”: null |
NumberValue *``int json:”num,omitempty” |
nil |
omitted |
BoolValue bool json:”bvalue” |
true |
“``bvalue”: true |
BoolValue *``bool json:”bvalue” |
nil |
“``bvalue”: null |
BoolValue *``bool json:”bvalue,omitempty” |
nil |
omitted |
StringValue string json:”svalue” |
“``str” |
“``svalue”:”str” |
StringValue string json:”svalue” |
“” |
“``svalue”:”” |
StringValue string json:”svalue,omitempty” |
“``str” |
“``svalue”:”str” |
StringValue string json:"svalue,omitempty" |
"" |
被省略 |
StringValue *```gostring json:”svalue”`` |
nil |
“``svalue”: null |
StringValue *```string json:”svalue,omitempty”`` |
nil |
被省略 |
-
struct
和map
类型被编码为 JSON 对象 -
切片和数组类型被编码为 JSON 数组
-
如果一个类型实现了
json.Marshaler
接口,则调用变量的实例的json.Marshaler.MarshalJSON
方法来编码数据 -
如果一个类型实现了
encoding.TextMarshaler
接口,则值将被编码为 JSON 字符串,字符串值是从值的encoding.TextMarshaler.MarshalText
方法中获得的 -
其他任何内容都将因
UnsupportedValueError
而失败
提示
只有结构体类型的导出字段可以被编码。
提示
如果结构体字段没有 JSON 标签,其 JSON 对象键将与字段名相同。
考虑以下代码段:
type Config struct {
Version string `json:"ver"` // Encoded as "ver"
Name string // Encoded as "Name"
Type string `json:"type,omitempty"` // Encoded as "type",
// and will be omitted if
// empty
Style string `json:"-"` // Not encoded
value string // Unexported field, not encoded
kind string `json:"kind"` // Unexported field, not encoded
}
...
cfg := Config{
Version: "1.1",
Name: "name",
Type: "example",
Style: "json",
value: "example config value",
kind: "test",
}
data, err := json.Marshal(cfg)
fmt.Println(string(err))
这将打印以下内容:
{"ver":"1.1","Name":"name","type":"example"}
提示
编码 JSON 对象中字段的顺序与字段声明的顺序相同。
处理嵌套结构体
结构体类型的字段将被编码为 JSON 对象。如果存在嵌套的结构体,则编码器有两个选择:将嵌套的结构体编码在与封装结构体相同的级别,或者作为一个新的 JSON 对象。
如何实现...
-
使用 JSON 标签命名封装结构体字段和嵌套结构体字段:
type Enclosing struct { Field string `json:"field"` Embedded } type Embedded struct { Field string `json:"embeddedField"` }
-
使用
json.Marshal
将结构体编码为 JSON 对象:enc := Enclosing{ Field: "enclosing", Embedded: Embedded{ Field: "embedded", }, } data, err = json.Marshal(enc) // {"field":"enclosing","embeddedField":"embedded"}
-
在嵌套结构体中添加
json
标签将创建一个嵌套的 JSON 对象:type Enclosing struct { Field string `json:"field"` Embedded `json:"embedded"` } type Embedded struct { Field string `json:"embeddedField"` } ... enc := Enclosing{ Field: "enclosing", Embedded: Embedded{ Field: "embedded", }, } data, err = json.Marshal(enc) // {"field":"enclosing","embedded":{"embeddedField":"embedded"}}
不定义结构体的编码
基本数据类型、切片和映射可以用来编码 JSON 数据。
如何实现...
-
使用映射来表示 JSON 对象:
config:=map[string]any{ "ver": "1.0", "Name": "config", "type": "example", } data, err:=json.Marshal(config) // `{"ver":"1.0","Name":"config","type":"example"}`
-
使用切片来表示 JSON 数组:
numbersWithNil:=[]any{ 1, 2, nil, 3 } data, err:=json.Marshal(numbersWithNil) // `[1,2,null,3]`
-
将所需的 JSON 结构与 Go 等效项匹配:
configurations:=map[string]map[string]any { "cfg1": { "ver": "1.0", "Name": "config1", }, "cfg2": { "ver": "1.1", "Name" : "config2", }, } data, err:=json.Marshal(configurations) // {"cfg1":{"Name":"config1","ver":"1.0"}, "cfg2":{"Name":"config2","ver":"1.1"}}`
解码结构体
在 JSON 中编码 Go 数据对象是一个相对简单的任务:定义良好的数据类型和语义被转换为表达性较低的表现形式,通常会导致一些信息丢失。例如,整数变量和 float64
变量可能被编码为相同的输出。因此,解码 JSON 数据通常更困难。
如何实现...
-
使用 JSON 标签将 JSON 键映射到结构体字段。
-
使用
json.Unmarshal
函数将 JSON 数据解码为 Go 数据对象。标准库使用以下约定来处理基本类型:
JSON 输入 | Go 类型 | 结果 |
---|---|---|
"strValue" |
string |
"strValue" |
1 (number) |
int |
1 |
1.2 (number) |
int |
error |
1.2 (number) |
float64, float32 |
1.2 |
true |
bool |
true |
null |
string |
变量保持未修改 |
null |
int |
变量保持未修改 |
"strValue" |
*string |
"strValue" |
null |
*string |
nil |
1 |
*int |
1 |
null |
*int |
nil |
true |
*bool |
true |
null |
*bool |
nil |
如果 Go 类型是 interface{}
,标准库将使用以下约定创建对象:
JSON 输入 | 结果 |
---|---|
"strValue" |
"strValue" |
1 |
float64(1) |
1.2 |
float64(1.2) |
true |
true |
null |
nil |
JSON 对象 | map[string]any |
JSON 数组 | []any |
-
如果目标 Go 类型实现了
json.Unmarshaler
接口,则调用json.Unmarshal.UnmarshalJSON
来解码数据。此操作可能需要根据需要创建目标类型的新实例。 -
如果目标 Go 类型实现了
encoding.TextUnmarshaler
接口,并且输入是引号中的 JSON 字符串,则调用encoding.TextUnmarshaler.UnmarshalText
来解码值。 -
其他任何内容都将因
UnsupportedValueError
而失败。
提示
如果 JSON 输入包含各种数值类型的值,数值可能会导致混淆。例如,如果 JSON 数值被反序列化为 int
值,如果 JSON 数据可以表示为整数,则可以正常工作,但如果 JSON 数据包含浮点值,则将失败。
提示
JSON 解码器永远不会更改结构体的非导出字段。解码器使用反射,并且只有导出字段可以通过反射访问。
提示
没有匹配 Go 字段的 JSON 字段将被忽略。
使用接口、映射和切片进行解码
当将 Go 值解码为 JSON 时,Go 值类型决定了 JSON 编码的方式。JSON 没有像 Go 那样丰富的类型系统。有效的 JSON 类型是字符串、数字、布尔值、对象、数组和 null。当你将 JSON 数据解码到 Go 结构体时,仍然是 Go 类型系统决定了如何解释 JSON 数据。但是,当你将 JSON 解码到 interface{}
时,情况就改变了。现在 JSON 数据决定了如何构建 Go 值,这有时会导致意外的结果。
如何操作...
要将 JSON 数据反序列化到接口中,请使用以下方法:
var output interface{}
err:=json.Unmarshal(jsonData,&output)
这将根据以下翻译规则创建基于以下翻译规则的对象树:
JSON | Go |
---|---|
对象 | map[string]interface{} |
数组 | []interface{} |
数字 | float64 |
布尔值 | bool |
字符串 | string |
null | nil |
解码数字的其他方式
当解码到 interface{}
时,JSON 数值被转换为 float64
。这并不总是期望的结果。你可以使用 json.Number
代替。
如何操作...
使用 json.Decoder
并设置 UseNumber
:
var output interface{}
decoder:=json.NewDecoder(strings.NewReader(`[1.1,2,3,4.4]`))
// Tell the decoder to use json.Number instead of float64
decoder.UseNumber()
err:=decoder.Decode(&output)
// [1.1 2 3 4.4]
在前面的示例中,output
的每个元素都是 json.Number
的实例。你可以根据需要将其转换为 int
、float64
或 big.Int
。
处理缺失和可选值
通常你必须处理缺少字段的 JSON 输入,并生成省略空字段的 JSON。本节提供了处理这些场景的配方。
编码时省略空字段
在 JSON 编码中省略空字段通常可以节省空间并使 JSON 更易于阅读。然而,“空”的含义应该是明确的。
如何操作...
使用 ,omitempty
JSON 标签省略空字符串值、零整数/浮点值、零 time.Duration
值和 nil
指针值。
,omitempty
标签对 time.Time
值不起作用。使用 *time.Time
并将其设置为 nil
以省略空时间值:
type Config struct {
...
Type string `json:"type,omitempty"`
IntValue int `json:"intValue,omitempty"`
FloatValue float64 `json:"floatValue,omitempty"`
When *time.Time `json:"when,omitempty"`
HowLong time.Duration `json:"howLong,omitempty"`
}
有时区分空字符串和 null 字符串很重要。在 JavaScript 和 JSON 中,null
是字符串的有效值。如果是这种情况,请使用 *string
:
type Config struct {
Value *string `json:"value,omitempty"`
...
}
...
emptyString := ""
emptyValue := Config {
Value: &emptyString,
}
// JSON output: { "value": "" }
nullValue := Config {
Value: nil,
}
// JSON output: {}
解码时处理缺失字段
有几种用例,开发者必须处理不包含所有数据字段的稀疏 JSON 数据。例如,部分更新 API 调用可能接受一个仅包含应更新的字段而不修改任何未指定数据字段的 JSON 对象。在这种情况下,识别哪些字段被提供变得很重要。然后还有适合为缺失字段假设默认值的用例。
如何做到这一点...
如果你想确定在 JSON 输入中指定了哪些字段,请使用指针字段。输入中缺失的任何字段都将保持为 nil
。
要为缺失字段提供默认值,在反序列化之前将这些字段初始化为其默认值:
type APIRequest struct {
// If type is not specified, it will be nil
Type *string `json:"type"`
// There will be a default value for seq
Seq int `json:"seq"`
...
}
func handler(w http.ResponseWriter,r *http.Request) {
data, err:=io.ReadAll(r.Body)
if err!=nil {
http.Error(w, "Bad request",http.StatusBadRequest)
return
}
req:=APIRequest{
Seq: 1, // Set the default value
}
if err:=json.Unmarshal(data, &req); err!=nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Check which fields are provided
if req.Type!=nil {
...
}
// If seq is provided in the input, req.Seq will be set to that
// value. Otherwise, it will be 1.
if req.Seq==1 {
...
}
}
自定义 JSON 编码/解码
有时某些数据结构的 JSON 编码与其在程序中的表示不匹配。当这种情况发生时,你必须自定义如何将某个特定数据元素编码为 JSON 或从 JSON 中解码。
序列化/反序列化自定义数据类型
当你有数据元素,其 JSON 表示需要程序化生成时,请使用这些食谱。
如何做到这一点...
要控制数据对象在 JSON 中的编码方式,实现 json.Marshaler
接口:
// TypeAndID is encoded to JSON as type:id
type TypeAndID struct {
Type string
ID int
}
// Implementation of json.Marshaler
func (t TypeAndID) MarshalJSON() (out []byte, err error) {
s := fmt.Sprintf(`"%s:%d"`,t.Type,t.ID)
out=[]byte(s)
return
}
要控制数据对象从 JSON 中解码的方式,实现 json.Unmarshaler
接口:
提示
解码器必须有一个指针接收器。
// Implementation of json.Unmarshaler. Note the pointer receiver
func (t *TypeAndID) UnmarshalJSON(in []byte) (err error) {
if len(in)<2 || in[0] != '"' || in[len(in)-1] != '"' {
err = ErrInvalidTypeAndID
return
}
in = in[1 : len(in)-1]
parts := strings.Split(string(in), ":")
if len(parts) != 2 {
err = ErrInvalidTypeAndID
return
}
// The second part must be a valid integer
t.ID, err = strconv.Atoi(parts[1])
if err != nil {
return
}
t.Type = parts[0]
return
}
自定义对象键的序列化/反序列化
映射作为 JSON 对象进行序列化/反序列化。但如果你有一个键类型不是字符串类型的映射,你如何将其序列化/反序列化为 JSON?
如何做到这一点...
解决方案取决于键的确切类型:
-
从字符串或整数类型派生的键类型的映射可以采用标准库方法进行序列化/反序列化:
type Key int64 func main() { var m map[Key]int err := json.Unmarshal([]byte(`{"123":123}`), &m) if err!=nil { panic(err) } fmt.Println(m[123]) // Prints 123 }
-
如果映射键在序列化/反序列化时需要额外的处理,实现
encoding.TextMarshaler
和encoding.TextUnmarshaler
接口:// Key is an uint that is encoded as an hex strings for JSON key type Key uint func (k *Key) UnmarshalText(data []byte) error { v, err := strconv.ParseInt(string(data), 16, 64) if err != nil { return err } *k = Key(v) return nil } func (k Key) MarshalText() ([]byte, error) { s := strconv.FormatUint(uint64(k), 16) return []byte(s), nil } func main() { input := `{ "13AD": "5037", "3E22": "15906", "90A3": "37027" }` var data map[Key]string if err := json.Unmarshal([]byte(input), &data); err != nil { panic(err) } fmt.Println(data) d, err := json.Marshal(map[Key]any{ Key(123): "123", Key(255): "255", }) if err != nil { panic(err) } fmt.Println(string(d)) }
动态字段名
有时字段名(对象键)不是固定的。例如,一个 API 可能更喜欢以 JSON 对象的形式返回对象的列表,其中每个对象的唯一标识符是键。在这种情况下,无法在结构体中使用 json
标签。
如何做到这一点...
使用 map[string]ValueType
来表示具有动态字段名的对象:
type User struct {
Name string `json:"name"`
Type string `json:"type"`
}
type Users struct {
Users map[string]User `json:"users"`
}
func main() {
input := `{
"users": {
"abb64dfe-d4a8-47a5-b7b0-7613fe3fd11f": {
"name": "John",
"type": "admin"
},
"b158161c-0588-4c67-8e4b-c07a8978f711": {
"name": "Amy",
"type": "editor"
}
}
}`
var users Users
if err := json.Unmarshal([]byte(input), &users); err != nil {
panic(err)
}
}
多态数据结构
多态数据结构可以是几种不同类型中的一种,这些类型共享一个公共接口。实际类型是在运行时确定的。对于运行时对象,Go 的类型系统通过使用这些字段确保类型安全的操作。使用接口,多态对象可以轻松地序列化为 JSON。当你需要反序列化一个多态 JSON 对象时,就会出现问题。在这个菜谱中,我们将探讨实现这一目标的一种方法。
使用两次遍历进行自定义反序列化
第一次遍历会反序列化判别器字段,而将输入的其余部分留待未处理。根据判别器,构建并反序列化对象的具体实例。
如何做...
-
在本节中,我们将使用一个示例
Key
结构。Key
结构包含不同类型的加密公钥,其类型在Type
字段中给出:type KeyType string const ( KeyTypeRSA = "rsa" KeyTypeED25519 = "ed25519" ) type Key struct { Type KeyType `json:"type"` Key crypto.PublicKey `json:"key"` }
-
按照惯例定义数据结构的 JSON 标签。大多数多态结构可以不使用自定义序列化器进行序列化,因为在序列化期间已知对象的运行时类型。
定义另一个与原始结构相同的结构,将动态类型部分替换为
json.RawMessage
类型字段:type keyUnmarshal struct { Type KeyType `json:"type"` Key json.RawMessage `json:"key"` }
-
为原始结构创建一个反序列化器。在这个反序列化器中,首先将输入反序列化到步骤 2 中创建的结构实例:
func (k *Key) UnmarshalJSON(in []byte) error { var key keyUnmarshal err := json.Unmarshal(in, &key) if err != nil { return err }
-
使用类型判别器字段,决定如何解码动态部分。以下示例使用工厂来获取特定类型的反序列化器:
k.Type = key.Type unmarshaler := KeyUnmarshalers[key.Type] if unmarshaler == nil { return ErrInvalidKeyType }
-
将动态类型部分(它是一个
json.RawMessage
)反序列化到正确类型的变量实例中:k.Key, err = unmarshaler(key.Key) if err != nil { return err } return nil }
工厂是一个简单的映射,它知道不同类型键的反序列化器:
var ( KeyUnmarshalers = map[KeyType]func(json.RawMessage) (crypto.PublicKey, error){} ) func RegisterKeyUnmarshaler(keyType KeyType, unmarshaler func(json.RawMessage) (crypto.PublicKey, error)) { KeyUnmarshalers[keyType] = unmarshaler } ... RegisterKeyUnmarshaler(KeyTypeRSA, func(in json.RawMessage) (crypto.PublicKey, error) { var key rsa.PublicKey if err := json.Unmarshal(in, &key); err != nil { return nil, err } return &key, nil }) RegisterKeyUnmarshaler(KeyTypeED25519, func(in json.RawMessage) (crypto.PublicKey, error) { var key ed25519.PublicKey if err := json.Unmarshal(in, &key); err != nil { return nil, err } return &key, nil })
这是一个可扩展的工厂框架,可以在构建时初始化额外的反序列化器。只需为对象类型创建一个反序列化函数,并使用前面的 RegisterKeyUnmarshaler
函数注册它以支持新的键类型。
小贴士
注册此类功能的一种常见方式是使用包的 init()
函数。当你导入该包时,该包支持的反序列化类型将被注册。
流式传输 JSON 数据
当你需要高效地处理大量数据时,你应该考虑流式传输数据而不是一次性处理整个数据集。本节描述了一些流式传输 JSON 数据的方法。
流式传输对象数组
如果你有一个生成器(goroutine、数据库游标等)产生数据元素,并且你想将这些元素作为 JSON 数组流式传输而不是在序列化之前存储所有内容,这个菜谱就很有用。
如何做...
-
创建一个生成器。这可以是
-
一个通过通道发送数据元素的 goroutine,
-
一个类似游标的对象,包含一个
Next()
方法, -
或其他数据生成器。
-
-
创建一个
json.Encoder
实例,它使用io.Writer
表示目标。目标可以是文件、标准输出、缓冲区、网络连接等。 -
为数组写入开始分隔符,即
[
。 -
如果需要,在数据元素前加上逗号进行编码。
-
写入数组的结束分隔符,即
]
。
以下示例假设存在一个生成器 goroutine 将Data
实例写入input
通道。当没有更多的Data
实例时,生成器会关闭通道。在这里,我们假设Data
是 JSON 可序列化的:
func stream(out io.Writer, input <-chan Data) error {
enc := json.NewEncoder(out)
if _, err := out.Write([]byte{'['}); err != nil {
return err
}
first := true
for obj := range input {
if first {
first = false
} else {
if _, err := out.Write([]byte{','}); err != nil {
return err
}
}
if err := enc.Encode(obj); err != nil {
return err
}
}
if _, err := out.Write([]byte{']'}); err != nil {
return err
}
return nil
}
解析对象数组
如果你有一个提供对象数组的 JSON 数据源,你可以解析这些元素并使用json.Decoder
处理它们。
如何实现...
-
创建从输入流读取的
json.Decoder
。 -
使用
json.Decoder.Token()
解析数组开始分隔符([
)。 -
解码数组中的每个元素,直到解码失败。
-
当解码失败时,你必须确定是流结束了,还是确实存在错误。为了检查这一点,使用
json.Decoder.Token()
读取下一个标记。如果成功读取下一个标记,并且它是数组结束分隔符]
,那么流解析成功结束。否则,输入数据中存在错误。
以下示例假设json.Decoder
已经构建好,用于从输入流中读取。输出存储在切片中。或者,可以在解析元素时处理输出,或者将每个元素通过通道发送到处理 goroutine:
func parse(input *json.Decoder) (output []Data, err error) {
// Parse the array beginning delimiter
var tok json.Token
tok, err = input.Token()
if err != nil {
return
}
if tok != json.Delim('[') {
err = fmt.Errorf("Array begin delimiter expected")
return
}
// Parse array elements using Decode
for {
var data Data
err = input.Decode(&data)
if err != nil {
// Decode failed. Either there is an input error, or
// we are at the end of the stream
tok, err = input.Token()
if err != nil {
// Data error
return
}
// Are we at the end?
if tok == json.Delim(']') {
// Yes, there is no error
err = nil
break
}
}
output = append(output, data)
}
return
}
其他流式传输 JSON 的方式
还有其他方式来流式传输 JSON:
-
连接 JSON 简单地连续写入 JSON 对象
-
换行符分隔的 JSON 将每个 JSON 对象作为单独的一行写入
-
记录分隔符分隔的 JSON 使用特殊的记录分隔符字符 0x1E,并在每个 JSON 对象之间可选地添加换行符
-
长度前缀 JSON 将每个 JSON 对象的字符串长度作为十进制数字进行前缀
所有这些都可以使用json.Decoder
和json.Encoder
进行读取和写入。一个简单的 JSON 流式传输包可以在以下位置找到:github.com/bserdar/jsonstream
。
安全考虑
无论何时从你的应用程序外部接受数据(用户输入的数据、API 调用、读取文件等),你都必须关注恶意输入。JSON 输入相对安全,因为 JSON 解析器不执行数据扩展,如 YAML 或 XML 解析器所做的那样。然而,在处理 JSON 数据时,你仍然需要考虑一些事情。
如何实现...
-
在接受第三方 JSON 输入时限制数据量。不要盲目使用
io.ReadAll
或json.Decode
:const MessageSizeLimit = 10240 func handler(w http.ResponseWriter, r *http.Request) { reader:=http.MaxBytesReader(w,r.Body,MessageSizeLimit) data, err := io.ReadAll(reader) if errors.Is(err,&http.MaxBytesError{}) { // If this happens, error is already sent. return } ... }
-
基于从第三方输入读取的数据,始终为资源分配提供上限。例如,如果你正在读取一个长度前缀的 JSON 流,其中每个 JSON 对象都由其长度前缀,不要分配一个
[]byte
来存储下一个对象。如果长度太大,则拒绝输入。
第十二章:进程
本章提供了如何运行外部程序、如何与它们交互以及如何优雅地终止进程的食谱。在处理外部进程时,以下是一些需要记住的关键点:
-
当你启动一个外部进程时,它与你的程序并发运行。
-
如果你需要与子进程通信,你必须使用进程间通信机制,例如管道。
-
当你运行一个子进程时,它的标准输入和标准输出流对父进程来说似乎是独立的并发流。你不能依赖从这些流中接收到的数据的顺序。
本节涵盖了以下主要食谱:
-
运行外部程序
-
向进程传递参数
-
使用管道处理子进程的输出
-
向子进程提供输入
-
更改子进程的环境变量
-
使用信号优雅地终止
运行外部程序
有许多用例,你想要执行外部程序以执行任务。通常,这是因为在你自己的程序中执行相同的任务是不可能的或不容易的。例如,你可能选择执行多个外部图像处理程序的实例来修改一组图像。另一个用例是当你想使用制造商提供的程序配置某些设备时。本食谱包括执行外部程序的几种方法。
如何做到...
使用 exec.Command
或 exec.CommandContext
从你的程序中运行另一个程序。如果不需要取消(终止)子进程或设置超时,则 exec.Command
是合适的。否则,使用 exec.CommandContext
,并取消或超时上下文来终止子进程:
-
使用程序名称及其参数创建
exec.Command
(或exec.CommandContext
)对象:-
如果你需要在平台的可执行命令路径中搜索程序,不要包含任何路径分隔符
-
如果你在程序名称中使用路径分隔符,它必须是相对于
exec.Command.Dir
的路径,或者如果exec.Command.Dir
为空,它必须是相对于当前工作目录的路径 -
如果你知道可执行文件的位置,请使用绝对路径
-
-
准备输入和输出流以捕获程序输出,或通过标准输入流发送输入。
-
启动程序。
-
等待程序结束。
以下示例在 sub/
目录下使用 go
命令构建一个 Go 程序:
// Run "go build" to build the subprocess in the "sub" directory
func buildProgram() {
// Create a Command with the executable and its arguments
cmd := exec.Command(
"go", "build", "-o", "subprocess", ".")
// Set the working directory
cmd.Dir = "sub"
// Collect the stdout and stderr as a combined output from the
// process
// This will run the process, and wait for it to end
output, err := cmd.CombinedOutput()
if err != nil {
panic(err)
}
// The build command will not print anything if successful. So if
// there is any output, it is a failure.
if len(output) > 0 {
panic(string(output))
}
}
上述示例将收集进程输出作为一个合并的字符串。程序的标准输出和标准错误将作为一个字符串返回,因此你无法识别输出字符串的哪些部分来自标准输出,哪些来自标准错误。确保你可以正确解析输出。
警告
进程的标准输出和标准错误流是独立的并发流。通常,没有可移植的方法来确定哪个流首先产生了输出。这可能会产生严重的影响。例如,假设您执行了一个程序,该程序在stdout
上产生一系列行,但每当它检测到错误时,它会将类似于“最后打印的行有问题
”的消息打印到标准错误。但是当您在程序中读取错误时,最后打印的行可能还没有到达您的程序。
以下程序演示了exec.CommandContext
和管道的使用:
// Run the program built by buildProgram function for 10ms, reading
// from the output
// and error pipes concurrently
func runSubProcessStreamingOutputs() {
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.
Millisecond)
defer cancel()
// Create the command that will timeout in 10ms
cmd := exec.CommandContext(ctx, "sub/subprocess")
// Pipe the output and error streams
stdout, err := cmd.StdoutPipe()
if err != nil {
panic(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
panic(err)
}
// Read from stderr from a separate goroutine
go func() {
io.Copy(os.Stderr, stderr)
}()
// Start running the program
err = cmd.Start()
if err != nil {
panic(err)
}
// Copy the stdout of the child program to our stdout
io.Copy(os.Stdout, stdout)
// Wait for the program to end
err = cmd.Wait()
if err != nil {
fmt.Println(err)
}
}
之前的例子利用了子进程的标准输出和标准错误输出。请注意,程序在程序开始之前就开始从stderr
流读取。那个 goroutine 将阻塞,直到子进程输出错误或子进程终止,此时stderr
管道将关闭,goroutine 将终止。从标准输出读取的部分在主 goroutine 中运行,在cmd.Wait
之前。这种顺序很重要。如果子进程开始在stdout
上产生输出,但父程序没有监听,子进程将阻塞。在此处调用cmd.Wait
将创建死锁,但运行时无法检测到这一点,因为父程序依赖于子程序的行为。
您可以将相同的流分配给子进程的stdout
和stderr
,如下所示:
// Run the build subprocess for 10 ms with combined output
func runSubProcessCombinedOutput() {
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.
Millisecond)
defer cancel()
// Define the command with the context
cmd := exec.CommandContext(ctx, "sub/subprocess")
// Assign both stdout and stderr to the same stream. This is
// equivalent to calling CombinedOutput
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stdout
// Start the process
err := cmd.Start()
if err != nil {
panic(err)
}
// Wait until it ends. The output will be printed to our stdout
err = cmd.Wait()
if err != nil {
fmt.Println(err)
}
}
前述方法与使用CombinedOutput
运行子进程类似。将cmd.Stdout
和cmd.Stderr
分配到同一流具有将子进程的输出合并在一起的效果。
向进程传递参数
将参数传递给子进程的机制可能会令人困惑。Shell 环境会解析和展开进程参数。例如,一个*.txt
参数会被替换为匹配该模式的文件名列表,并且每个文件名都成为一个单独的参数。本食谱讨论了如何正确地将此类参数传递给子进程。
有两种方法可以将参数传递给子进程。
扩展参数
第一种选择是手动执行 shell 参数处理。
如何操作...
要手动执行 shell 处理,请按照以下步骤进行:
-
从参数中移除 shell 特定的引号,例如以下 shell 命令:
-
./prog "test
directory"
shell 命令变为cmd:=exec.Command("./prog","test directory")
。 -
./prog dir1 "long dir name" '"quoted name"'
Bash 命令变为cmd:=exec.Command("./prog", "long dir name", "'\"quoted name\"'")
。注意 Bash 对引号的特定处理。
-
-
扩展模式。
./prog *.txt
变为cmd:=exec.Command("./prog",listFiles("*.txt")...)
,其中listFiles
是一个返回文件名切片的函数。
小贴士
通过空格分隔的文件列表作为单个参数传递。也就是说,cmd:=exec.Command("./prog","file1.txt file2.txt")
将向进程传递单个参数,即file1.txt file2.txt
。
-
替换环境变量。
/.prog $HOME
变为cmd:=exec.Command("./prog", os.Getenv("HOME"))
。运行cmd:=exec.Command("./prog", "$HOME")
将字符串$HOME
传递给程序,而不是其环境中的值。 -
最后,您必须手动处理管道。也就是说,对于
./prog >output.txt
的 shell 命令,您必须运行cmd:=exec.Command("./prog")
,创建一个output.txt
文件,并将cmd.Stdout=outputFile
。
通过 shell 运行命令
第二种选择是通过 shell 运行程序。
如何做到这一点...
使用特定平台的 shell 及其语法来运行命令:
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("cmd", "/C", "echo test>test.txt")
case "darwin": // Mac OS
cmd = exec.Command("/bin/sh", "-c", "echo test>test.txt")
case "linux": // Linux system, assuming there is bash
cmd = exec.Command("/bin/bash", "-c", "echo test>test.txt")
default: // Some other OS. Assume it has `sh`
cmd = exec.Command("/bin/sh", "-c", "echo test>test.txt")
}
out, err := cmd.Output()
此示例为 Windows 平台选择cmd
,Darwin(Mac)选择/bin/sh
,Linux 选择/bin/bash
,其他任何平台选择/bin/sh
。传递给 shell 的命令包含一个重定向,由 shell 处理。命令的输出将被写入到test.txt
文件中。
使用管道处理子进程的输出
请记住,进程的标准输出和标准错误流是并发流。如果子进程生成的输出可能是无界的,您可以在单独的 goroutine 中处理它。这个示例展示了如何做。
如何做到这一点...
关于管道的一些话。管道是 Go 通道的基于流的类似物。它是一个先进先出(FIFO)的通信机制,有两个端点:一个写入器和一个读取器。读取器端在写入器写入内容之前阻塞,写入器端在读取器读取内容之前阻塞。当您完成管道时,您关闭写入器端,这也会关闭管道的读取器端。这发生在子进程终止时。如果您关闭管道的读取器端然后写入它,程序将收到信号并可能终止。如果父程序在子程序之前终止,就会发生这种情况。
-
创建命令,并获取其
StdoutPipe
:ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() cmd := exec.CommandContext(ctx, "sub/subprocess") pipe, err := cmd.StdoutPipe() if err != nil { panic(err) }
-
创建一个新的 goroutine 并从子进程的 stdout 读取。在这个 goroutine 中处理子进程的输出:
// Read from the pipe in a separate goroutine go func() { // Filter lines that contain "0" scanner := bufio.NewScanner(pipe) for scanner.Scan() { line := scanner.Text() if strings.Contains(line, "0") { fmt.Printf("Filtered line: %s\n", line) } } if err := scanner.Err(); err != nil { fmt.Println("Scanner error: %v", err) } }()
-
启动进程:
err = cmd.Start() if err != nil { panic(err) }
-
等待进程结束:
err = cmd.Wait() if err != nil { fmt.Println(err) }
向子进程提供输入
您可以使用两种方法向子进程提供输入:将cmd.Stdin
设置为流或使用cmd.StdinPipe
获取发送输入到子进程的写入器。
如何做到这一点...
-
创建命令:
// Run grep and search for a word cmd := exec.Command("grep", word)
-
通过设置
Stdin
流为进程提供输入:// Open a file input, err := os.Open("input.txt") if err != nil { panic(err) } cmd.Stdin = input
-
运行程序并等待其结束:
if err = cmd.Start(); err != nil { panic(err) } if err = cmd.Wait(); err != nil { panic(err) }
或者,您可以使用管道提供流式输入。
-
创建命令:
// Run grep and search for a word cmd := exec.Command("grep", word)
-
获取输入管道:
input, err:=cmd.StdinPipe() if err!=nil { panic(err) }
-
通过管道将输入发送到程序。完成后,关闭管道:
go func() { // Defer close the pipe defer input.Close() // Open a file file, err := os.Open("input.txt") if err != nil { panic(err) } defer file.Close() io.Copy(input,file) }()
-
运行程序并等待其结束:
if err = cmd.Start(); err != nil { panic(err) } if err = cmd.Wait(); err != nil { panic(err) }
修改子进程的环境变量
环境变量是与进程相关联的键值对。它们对于传递特定于环境的信息非常有用,例如当前用户的家目录、可执行搜索路径、配置选项等。在容器化部署中,环境变量是传递程序所需凭证的便捷方式。
进程的环境变量由其父进程提供,但一旦进程开始,就会为子进程分配提供的环境变量的副本。因此,父进程在子进程开始运行后不能更改其子进程的环境变量。
如何操作...
-
当启动子进程时,要使用与当前进程相同的环境变量,请将
Command.Env
设置为nil
。这将复制当前进程的环境变量到子进程中。 -
要使用额外的环境变量启动子进程,将这些新变量追加到当前进程变量中:
// Run the server cmd:=exec.Command("./server") // Copy current process environment variables cmd.Env=os.Environ() // Append new environment variables // Set the authentication key as an environment variable // of the current process cmd.Env=append(cmd.Env,fmt.Sprintf("AUTH_KEY=%s", authkey)) // Start the server process. Parent process environment is copied to cmd.Start()
使用信号进行优雅终止
要优雅地终止程序,你应该执行以下操作:
-
不再接受新的请求
-
完成已接受但未完成的任何请求
-
允许一定的时间让任何长时间运行的过程完成,如果它们在给定时间内无法完成,则终止它们
在基于云的服务开发中,优雅终止尤为重要,因为大多数云服务都是短暂的,并且经常被新的实例所取代。这个配方展示了如何实现它。
如何操作...
-
处理中断和终止信号。中断信号(
SIGINT
)通常由用户发起(例如,通过按下 Ctrl + C),而终止信号(SIGTERM
)通常由宿主操作系统发起,或者在容器化环境中,由容器编排系统发起。 -
禁止接受任何新的请求。
-
等待现有请求完成,并设置超时
-
终止进程。
下面的例子展示了如何操作。这是一个简单的 HTTP 回显服务器。当程序启动时,它创建一个 goroutine,监听响应 SIGINT
和 SIGTERM
信号的通道。当接收到这些信号中的任何一个时,它会关闭服务器(首先禁止单新请求的接受,然后等待现有请求完成,直到超时),然后终止程序:
func main() {
// Create a simple HTTP echo service
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, r.Body)
})
server := &http.Server{Addr: ":8080"}
// Listen for SIGINT and SIGTERM signals
// Terminate the server with the signal
sigTerm := make(chan os.Signal, 1)
signal.Notify(sigTerm, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigTerm
// 5 second timeout for the server to shutdown
ctx, cancel := context.WithTimeout(context.Background(), 5*time.
Second)
defer cancel()
server.Shutdown(ctx)
}()
// Start the server. When the server shuts down, program will end
server.ListenAndServe()
}
第十三章:网络编程
网络编程是应用程序开发者的一项关键技能。关于这个主题的广泛论述将是一项艰巨的任务,因此我们将探讨您在工作中可能会遇到的一些精选示例。需要记住的一个重要观点是,网络编程是创建应用程序漏洞的主要手段。网络程序本质上也是并发的,这使得正确和安全的网络编程特别困难。因此,本节将包括一些考虑安全和可扩展性的示例。
本章包含以下示例:
-
编写 TCP 服务器
-
编写 TCP 客户端
-
编写基于行的 TCP 服务器
-
使用 TCP 连接发送/接收文件
-
编写 TLS 客户端/服务器
-
用于 TLS 终止和负载均衡的 TCP 代理
-
设置读写截止日期
-
解除阻塞的读写操作
-
编写 UDP 客户端/服务器
-
发送 HTTP 调用
-
运行 HTTP 服务器
-
设置 TLS 服务器以使用 HTTPS
-
编写 HTTP 处理器
-
在文件系统上提供静态文件
-
处理 HTML 表单
-
编写用于下载大文件的处理器
-
将 HTTP 上传文件和表单作为流处理
TCP 网络
传输控制协议(TCP)是一种面向连接的协议,它提供了以下保证:
-
可靠性:发送者将知道预期的接收者是否已收到数据
-
顺序:消息将以发送的顺序接收
-
错误检查:消息将在传输过程中受到保护,防止损坏
由于这些保证,TCP 相对容易处理。它是许多高级协议(如 HTTP 和 WebSockets)的基础。在本节中,我们将探讨一些示例,展示如何编写 TCP 服务器和客户端。
编写 TCP 服务器
TCP 服务器是一个监听网络端口连接请求的程序。一旦与客户端建立连接,客户端和服务器之间的通信将通过 net.Conn
对象进行。服务器可以继续监听新的连接。这样,单个服务器就可以与多个客户端通信。
如何做到...
-
选择一个将连接到客户端服务器的端口号。
这通常是一个应用程序配置问题。前 1,024 (
0
到1023
) 个端口通常需要服务器程序具有 root 权限。这些端口中的大多数都保留给了知名的服务器程序,例如端口 22 用于 ssh,或端口 80 用于 HTTP。端口 1024 及以上是临时端口。只要没有其他程序监听,您的服务器程序可以使用 1,024 及以上的任何端口号,而无需额外的权限。使用端口号 0 让内核选择一个随机未使用的端口。您可以创建一个监听端口
0
的监听器,然后查询监听器以找出所选的端口号。 -
创建一个监听器。监听器是一种绑定
address:port
的机制。一旦使用端口号创建了一个监听器,同一主机或同一容器上的其他进程就无法使用该端口号来监听网络流量。下面的程序片段显示了如何创建监听器:
// The address:port to listen. If none given, use :0 to select // port randomly addr:=":8080" // Create a TCP listener listener, err := net.Listen("tcp", addr) if err != nil { panic(err) } // Print out the address we are listening fmt.Println("Listening on ", listener.Addr()) defer listener.Close()
程序首先确定要监听的网络地址。地址的确切格式取决于选择的协议,在这个例子中是 TCP。如果没有提供主机名或 IP 地址,监听器将监听本地系统上所有可用的单播 IP 地址。如果你提供了一个主机名或 IP 地址,监听器将只监听来自给定 IP 地址的流量。这意味着如果你提供了
localhost:1234
,监听器将只监听来自localhost
的流量。它不会监听外部流量。上述示例打印
listener.Addr()
。如果你提供:0
作为监听地址,或者根本不提供地址,这将很有用。在这种情况下,监听器将监听一个随机端口,listener.Addr()
将返回客户端可以连接到的地址。 -
监听并接受连接。使用
Listener.Accept()
接受传入的连接。这通常在一个循环中完成,如下所示:// Listen to incoming TCP connections for { // Accept a connection conn, err := listener.Accept() if err != nil { fmt.Println(err) return } // Handle the connection in its own goroutine go handleConnection(conn) }
在这个例子中,如果监听器被关闭,
listener.Accept
调用将失败并返回错误。 -
在自己的 goroutine 中处理连接。这样,监听器将继续接受连接,同时服务器在其自己的 goroutine 中与连接的客户端通信,使用为这些客户端创建的特定连接。
简单回声服务器的连接处理器可以编写如下:
func handleConnection(conn net.Conn) { io.Copy(conn,conn) } This program will write everything it reads from the connection back to the connection, forming an echo service. When the client terminates the connection, the read operation will return `io.EOF`, terminating the copy operation.
它是如何工作的...
net.Conn
接口既有 Read([]byte) (int,error)
方法(这使得它成为一个 io.Reader
),也有 Write([]byte) (int,error)
方法(这也使得它成为一个 io.Writer
)。正因为如此,从连接中读取的任何内容都会被写回连接。
你可能会注意到,由于 io.Copy
,读取的每个字节都会被写回连接,所以这不是一个基于行的协议。
编写 TCP 客户端
TCP 客户端连接到一个在某个主机端口上监听的 TCP 服务器。一旦建立连接,通信是双向的。换句话说,服务器和客户端的区别在于连接是如何建立的。当我们说“服务器”时,我们指的是等待监听端口的程序,当我们说“客户端”时,我们指的是连接到正在被服务器监听的主机端口的程序。一旦建立连接,双方将异步发送和接收数据。TCP 保证消息将以发送的顺序接收,并且消息不会丢失,但无法保证消息将在何时被对方接收。
如何实现...
-
客户端必须知道服务器地址和端口号。这应该由环境(命令行、配置等)提供。
-
使用
net.Dial
创建到服务器的连接:conn, err := net.Dial("tcp", addr) if err != nil { // Handle error }
-
使用返回的
net.Conn
对象向服务器发送数据,或从服务器接收数据:// Send a line of text text := []byte("Hello echo server!") conn.Write(text) // Read the response response := make([]byte, len(text)) conn.Read(response) fmt.Println(string(response))
-
完成后关闭连接:
conn.Close()
这里是完整的程序:
var address = flag.String("a", ":8008", "Server address")
func main() {
flag.Parse()
conn, err := net.Dial("tcp", *address)
if err != nil {
panic(err)
}
// Send a line of text
text := []byte("Hello echo server!")
conn.Write(text)
// Read the response
response := make([]byte, len(text))
conn.Read(response)
fmt.Println(string(response))
conn.Close()
}
此示例演示了与服务器之间的请求-响应类型交互。这并不一定是始终如此。网络连接提供了io.Writer
和io.Reader
接口,并且它们可以并发使用。
编写基于行的 TCP 服务器
在这个菜谱中,我们将查看一个使用行而不是字节而不是字节的 TCP 服务器。在从网络连接读取行时,有一些需要注意的点,特别是与服务器安全相关。仅仅因为你在期待读取行,并不意味着客户端会发送格式良好的行。
如何做到这一点...
-
使用与上一节中给出的相同结构来设置服务器。
-
在连接处理程序中,使用
bufio.Reader
或bufio.Scanner
读取行。 -
使用
io.LimitedReader
包装连接以限制行长度。
让我们看看这是如何工作的。以下示例展示了如何实现连接处理程序:
// Limit line length to 1KiB.
const MaxLineLength = 1024
func handleConnection(conn net.Conn) error {
defer conn.Close()
// Wrap the connection with a limited reader
// to prevent the client from sending unbounded
// amount of data
limiter := &io.LimitedReader {
R: conn,
N: MaxLineLength+1, // Read one extra byte to detect long lines
}
reader := bufio.NewReader(limiter)
for {
bytes, err := reader.ReadBytes(byte('\n'))
if err != nil {
if err != io.EOF {
// Some error other than end-of-stream
return err
}
// End of stream. It could be because the line is too long
if limiter.N==0 {
// Line was too long
return fmt.Errorf("Received a line that is too long")
}
// End of stream
return nil
}
// Reset the limiter, so the next line can be read with
// newlimit
limiter.N=MaxLineLength+1
// Process the line: send it back to client
if _, err := conn.Write(bytes); err != nil {
return err
}
}
}
连接处理例程首先将连接包装在io.LimitedReader
中。这是必要的,以防止reader.ReadBytes
在没有看到换行符的情况下读取无限量的数据。如果没有这个,恶意客户端可以发送大量数据而没有换行符,消耗所有服务器内存。对行长度设置硬限制可以防止这种攻击向量。读取每一行后,我们将limiter.N
重置为其原始值,以便使用相同的限制读取下一行。请注意,限制器被设置为读取一个额外的字节。这是因为io.LimitedReader
对于合法的EOF
(意味着客户端断开连接)和超出限制的读取都返回io.EOF
。如果读取器超出限制,这意味着最后读取的行至少比限制多一个字节,这使我们能够决定这是一条无效的行。
使用 TCP 连接发送/接收文件
通过 TCP 连接发送和接收文件演示了网络编程的几个重要点,即协议设计(处理何时发送什么)和编码(处理数据元素如何在线路上表示)。此示例将展示如何在 TCP 连接上传输元数据和八位字节流。
如何做到这一点...
-
使用与上一节相同的结构来设置服务器。
-
在发送端(客户端)执行以下操作:
-
编码包含文件名、大小和模式的文件元数据并发送。
-
发送文件内容。
-
关闭连接。
-
-
在接收端(服务器)执行以下操作:
-
解码文件元数据。创建一个文件以存储接收到的文件内容,并使用给定的模式。
-
接收文件内容并写入文件。
-
在所有文件内容接收完毕后,关闭文件。
-
第一部分是传输关于文件的元数据。有几种方法可以实现这一点:你可以使用基于文本的编码方案,如键值对或 JSON,但这类方案的问题是它们不是固定长度的。一个简单、有效且可移植的编码方案是使用 encoding/binary
包的二进制编码。这并不能解决文件名的编码问题,因为文件名不是一个固定大小的字符串。因此,我们在文件元数据中包含文件名的长度,并使用恰好必要的字节数来编码文件名。
固定大小的 fileMetadata
结构如下:
type fileMetadata struct {
Size uint64
Mode uint32
NameLen uint16
}
这种结构在所有平台上都是 14 字节(8 字节的 Size
,4 字节的 Mode
和 2 字节的 NameLen
)。使用 binary/encoding.Write
,你可以使用 binary.BigEndian
或 binary.LittleEndian
编码在网络上编码这个固定大小的结构,接收端将成功解码它。
关于字节序的更详细信息将在下一章中介绍。
客户端的其余部分如下:
var address = flag.String("a", ":8008", "Server address")
var file = flag.String("file", "", "File to send")
func main() {
flag.Parse()
// Open the file
file, err := os.Open(*file)
if err != nil {
panic(err)
}
// Connect the receiver
conn, err := net.Dial("tcp", *address)
if err != nil {
panic(err)
}
// Encode file metadata
fileInfo, err := file.Stat()
if err != nil {
panic(err)
}
md := fileMetadata{
Size: uint64(fileInfo.Size()),
Mode: uint32(fileInfo.Mode()),
NameLen: uint16(len(fileInfo.Name())),
}
if err := binary.Write(conn, binary.LittleEndian, md); err != nil {
panic(err)
}
// The file name
if _, err := conn.Write([]byte(fileInfo.Name())); err != nil {
panic(err)
}
// The file contents
if _, err := io.Copy(conn, file); err != nil {
panic(err)
}
conn.Close()
}
注意使用 io.Copy
来传输文件的实际内容。使用 io.Copy
,你可以将任意大小的文件传输给接收者,而不会消耗大量的内存。
现在让我们看看服务器(接收者):
func handleConnection(conn net.Conn) {
defer conn.Close()
// Read the file metadata
var meta fileMetadata
err := binary.Read(conn, binary.LittleEndian, &meta)
if err != nil {
fmt.Println(err)
return
}
// Do not allow file names that are too long
if meta.NameLen > 255 {
fmt.Println("File name too long")
return
}
// Read the file name
name := make([]byte, meta.NameLen)
_, err = io.ReadFull(conn, name)
if err != nil {
fmt.Println(err)
return
}
path:=filepath.Join("downloads",string(name))
// Create the file
file, err := os.OpenFile(
path,
os.O_CREATE|os.O_WRONLY,
os.FileMode(meta.Mode),
)
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
// Copy the file contents
_, err = io.CopyN(file, conn, int64(meta.Size))
if err != nil {
// Remove file in case of error
os.Remove(path)
fmt.Println(err)
return
}
fmt.Printf("Received file %s: %d bytes\n", string(name), meta.
Size)
}
首个操作是读取文件元数据的固定大小读取操作。然后我们读取文件名。注意在读取文件名之前的文件名长度检查。这是验证和限制所有涉及从外部系统或用户读取的大小内存分配的重要防御方法。在这里,我们拒绝长度超过 255 字节的文件名。然后,我们使用给定的模式创建文件,并使用 io.CopyN
从输入中读取确切的文件大小字节。如果发生错误,我们将删除部分下载的文件。
编写 TLS 客户端/服务器
传输层安全性(TLS)提供端到端加密,同时不泄露加密密钥以防止中间人攻击。它还提供对等方的身份验证和消息完整性保证。本食谱展示了如何设置 TLS 服务器以保护网络通信。然而,首先,关于公钥加密的一些话可能是有用的。
密码学 密钥对 包含一个私钥和一个公钥。私钥是保密的,公钥是公开的。
这就是密钥对如何用于加密消息:由于一方的公钥是公开的,任何人都可以创建一条消息并使用该公钥加密它,然后将其发送给拥有私钥的一方。只有私钥的所有者才能解密该消息。这也意味着,如果私钥被泄露,拥有该私钥的任何人都可以监听此类消息。
这就是如何使用密钥对来确保消息完整性:私钥的所有者可以使用其私钥创建一个消息的签名(散列)。任何拥有公钥的人都可以验证消息的完整性,也就是说,公钥可以用来验证签名是否由相应的私钥生成。
公钥以数字证书的形式分发。数字证书是一个文件,包含由受信任的第三方、证书颁发机构(CA)签名的实体的公钥。有许多知名的 CA 会将其公钥作为证书(根证书)发布,这些根证书通常包含在大多数现代操作系统中,因此当你获得一个证书时,你可以使用签发该证书的 CA 的公钥来验证其真实性。一旦验证公钥是真实的,你就可以将公钥的所有者(拥有相应的私钥)连接起来,并建立一个安全通道。
CA 的根证书通常由 CA 本身签名。
如果你需要为内部服务器创建证书,你通常通过创建一个自签名的根 CA 来为你的环境创建一个 CA。你将那个 CA 的私钥保密,并在内部发布公钥。有一些自动化工具可以帮助你为服务器创建 CA 和证书。
如何操作...
下面是如何设置 TLS 服务器和客户端的步骤:
-
为你的服务器创建或购买一个 X.509 证书。如果服务器不是面向互联网的服务器,自签名证书通常就足够了。如果是面向互联网的服务器,你必须从 CA 组织之一获取证书,或者发布你自己的公钥证书,以便想要连接到你的服务器的客户端可以使用该证书进行身份验证和加密流量。
-
对于服务器,请执行以下操作:
-
使用
crypto/tls.LoadX509KeyPair
加载证书。 -
使用证书创建一个
crypto/tls.Config
。 -
使用
crypto/tls.Listen
创建一个监听器。 -
服务器其余部分遵循相同的 TCP 服务器布局。
-
以下代码段说明了这些步骤:
var (
address = flag.String(
"a", ":4433", "Address to listen")
certificate = flag.String(
"c", "../server.crt", "Certificate file")
key = flag.String(
"k", "../privatekey.pem", "Private key")
)
func main() {
flag.Parse()
// 2.1 Load the key pair
cer, err := tls.LoadX509KeyPair(*certificate, *key)
if err != nil {
panic(err)
}
// 2.2 Create TLS configuration for the listener
config := &tls.Config{
Certificates: []tls.Certificate{cer},
}
// 2.3 Create the listener
listener, err := tls.Listen("tcp", *address, config)
if err != nil {
panic(err)
return
}
defer listener.Close()
fmt.Println("Listening TLS on ", listener.Addr())
// 2.4 Listen to incoming TCP connections
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println(err)
return
}
go handleConnection(conn)
}
}
注意,设置服务器需要证书和私钥。一旦 TLS 监听器设置完成,其余的代码与未加密的 TCP 服务器相同。
对于客户端,请按照以下步骤操作:
-
如果你使用的是知名 CA 的证书,请使用
crypto/x509.SystemCertPool
。如果你有自签名证书或其他自定义证书,请使用crypto/x509.NewCertPool
创建一个空的证书池。 -
加载服务器证书,并将其添加到证书池中。
-
使用使用证书池初始化的 TLS 配置的
crypto/tls.Dial
。 -
客户端其余部分遵循这里描述的相同的 TCP 客户端布局。
以下代码段展示了这些步骤:
var (
addr = flag.String(
"addr", "", "Server address")
certFile = flag.String(
"cert", "../server.crt", "TLS certificate file")
)
func main() {
flag.Parse()
// 3.1 Create new certificate pool
roots := x509.NewCertPool()
// 3.2 Load server certificate
certData, err := os.ReadFile(*certFile)
if err != nil {
panic(err)
}
ok := roots.AppendCertsFromPEM(certData)
if !ok {
panic("failed to parse root certificate")
}
// 3.3 Connect the server
conn, err := tls.Dial("tcp", *addr, &tls.Config{
RootCAs: roots,
})
if err != nil {
panic(err)
}
// 3.4 Send a line of text
text := []byte("Hello echo server!")
conn.Write(text)
// Read the response
response := make([]byte, len(text))
conn.Read(response)
fmt.Println(string(response))
conn.Close()
}
再次强调,只有在服务器证书由操作系统未识别的 CA 签名时,才需要加载证书并将其添加到证书池中。许多使用 HTTPS 的网站都由知名的 CA 签名,这就是为什么您可以在不安装自定义证书的情况下连接到它们:操作系统已经信任该 CA。
注意
在本书的 GitHub 页面下有这个示例(github.com/PacktPublishing/Go-Recipes-for-Developers/tree/main/src/chp13
)。
用于 TLS 终止和负载均衡的 TCP 代理
大多数面向互联网的应用程序都使用反向代理(入口)来将内部资源与外部世界分开。反向代理通常通过外部客户端使用加密连接(TLS)连接,并通过未加密的通道(图 11.1)或使用内部 CA 重新加密连接将请求转发到后端服务。反向代理通常还执行某种形式的负载均衡,以均匀分配工作。
图 13.1 – 带有轮询负载均衡和 TLS 终止的 TLS 代理
在本节中,我们将探讨这样一个反向代理,它接受来自外部主机的 TLS 流量,并使用未加密的 TCP 将流量转发到后端服务器,同时以轮询方式将这些请求分配给服务器。
作为 Go 开发者,您不太可能编写自己的反向代理或负载均衡器,因为已经有很多选项可供选择。然而,这是一个有趣的应用,我在这里包括它,以展示如何在 Go 中完成类似的事情,特别是代理本身。
如何操作...
在这里,我们假设代理已获得可用后端服务器的列表。很多时候,您需要使用特定平台的发现机制来找出可用的服务器:
-
使用代理主机的证书和密钥创建一个面向外部的 TLS 接收器。
-
监听传入的 TLS 连接。
-
当客户端连接时,选择一个后端服务器并连接。
-
启动一个代理 goroutine,将来自外部主机的所有流量转发到后端服务器,并将来自后端服务器的流量转发到外部主机。
-
当其中一个连接关闭时,终止代理。
以下程序说明了这些步骤:
var (
tlsAddress = flag.String(
"a", ":4433", "TLS address to listen")
serverAddresses = flag.String(
"s", ":8080", "Server addresses, comma separated")
certificate = flag.String(
"c", "../server.crt", "Certificate file")
key = flag.String(
"k", "../privatekey.pem", "Private key")
)
func main() {
flag.Parse()
// 1\. Create external facing TLS receiver
// Load the key pair
cer, err := tls.LoadX509KeyPair(*certificate, *key)
if err != nil {
panic(err)
}
// Create TLS configuration for the listener
config := &tls.Config{
Certificates: []tls.Certificate{cer},
}
// Create the tls listener
tlsListener, err := tls.Listen("tcp", *tlsAddress, config)
if err != nil {
panic(err)
}
defer tlsListener.Close()
fmt.Println("Listening TLS on ", tlsListener.Addr())
// Listen to incoming TLS connections
servers := strings.Split(*serverAddresses, ",")
fmt.Println("Forwarding to servers: ", servers)
nextServer := 0
for {
// 2\. Listen to incoming TLS connections
conn, err := tlsListener.Accept()
if err != nil {
fmt.Println(err)
return
}
retries := 0
for {
// 3\. Select the next server
server := servers[nextServer]
nextServer++
if nextServer >= len(servers) {
nextServer = 0
}
// Start a connection to this server
targetConn, err := net.Dial("tcp", server)
if err != nil {
retries++
fmt.Errorf("Cannot connect to %s", server)
if retries > len(servers) {
panic("None of the servers are available")
}
continue
}
// 4\. Start the proxy
go handleProxy(conn, targetConn)
}
}
}
我们已经在之前的菜谱中介绍了设置 TLS 接收器的详细情况,所以让我们看看后端服务器是如何选择的。这个实现提供了一个所有可用后端服务器的列表。每个接受的客户端连接都被分配到列表中的下一个服务器,由nextServer
索引指向。代理使用net.Dial
连接选定的服务器,如果连接失败(服务器可能暂时不可用),它将跳转到列表中的下一个服务器。如果这失败了len(servers)
次,那么所有后端服务器都不可用,程序将终止。然而,如果选择了一个服务器,将启动代理,并且主 goroutine 返回监听新的连接。
让我们看看代理处理器的编写方式:
func handleProxy(conn, targetConn net.Conn) {
defer conn.Close()
defer targetConn.Close()
// Copy data from the client to the server
go io.Copy(targetConn, conn)
// Copy data from the server to the client
io.Copy(conn, targetConn)
}
如前所述,网络连接包含两个并发流,一个从客户端主机到服务器,另一个从服务器到客户端主机。这两个流可以同时包含正在传输的数据。因此,代理 TCP 连接涉及两个io.Copy
操作,一个从服务器到客户端,另一个从客户端到服务器。此外,至少有一个操作必须在单独的 goroutine 中运行。在前面的示例中,来自外部连接到后端服务器的流量在一个单独的 goroutine 中复制,来自后端服务器到外部主机的流量在代理 goroutine 中复制。如果任一方关闭连接,复制操作将终止,这将导致最后一个复制操作终止,并关闭另一个连接。
设置读写截止时间
如果您不想无限期地等待连接的主机发送数据,或者等待连接的主机接收您发送的数据,您必须设置一个截止时间。
如何操作...
根据您的特定协议,您可以设置读写截止时间,您可以选择为单个 I/O 操作设置这些截止时间,或者全局设置:
-
在操作之前设置截止时间:
conn.SetDeadline(time.Now().Add(timeoutSeconds * timeSecond)) if n, err:=conn.Read(data); err!=nil { if errors.Is(err, os.ErrDeadlineExceeded) { // Deadline exceeded. } else { // Some other error } }
-
如果您将在截止时间超过后继续使用连接,您必须重置截止时间:
conn.SetDeadline(time.Time{})
或者,设置一个未来的时间的新截止时间。
解锁阻塞的读写操作
有时,您可能需要根据外部事件解锁一个读写操作。这个菜谱展示了您如何解锁这样的 I/O 操作。
如何操作...
-
如果您无意再次重用连接,只想解锁 I/O 操作,则异步关闭连接:
cancel:=make(chan struct{}) done:=make(chan struct{}) // Close the connection if a message is sent to cancel channel go func() { select { case <-cancel: conn.Close() case <-done: } }() go handleConnection(conn)
-
如果您想解锁 I/O 操作但不终止它,将截止时间设置为现在:
unblock:=make(chan struct{}) // Unblock the connection if a message is sent to unblock channel go func() { <-unblock conn.SetDeadline(time.Now()) }() timedout:=false if n, err:=conn.Read(data); err!=nil { if errors.Is(err,os.ErrDeadlineExceeded) { // Reset connection deadline conn.SetDeadline(time.Time{}) timedout=true // continue using the connection } else { // Handle error } } if timedout { // Read timedout } else { // Read did not timeout }
它是如何工作的...
TCP 读取操作会阻塞,直到有可读内容,这只有在从对等方接收到数据时才会发生。TCP 写入操作将在发送方无法再缓冲更多数据时阻塞。前面的菜谱展示了两种您可以解锁这些调用的方法。
关闭连接会在等待数据到达或等待写入数据时因连接关闭而阻塞读写操作,并返回一个错误。关闭连接会丢弃所有未读或未写的数据,并销毁为该连接分配的所有资源。
异步设置超时将为等待操作设置一个截止日期,当该截止日期通过时,操作失败但连接保持打开。你可以重置截止日期并重试操作。
编写 UDP 客户端/服务器
与 TCP 不同,UDP 是无连接的。这意味着你不需要与另一个对等方建立连接并来回发送数据,你只需发送数据包并接收它们。没有交付或排序保证。
UDP 的一个显著用途是域名服务(DNS)协议。UDP 也是许多流式协议(如 IP 语音、视频流等)的选择,在这些协议中,偶尔的数据包丢失是可以容忍的。网络监控工具也更倾向于使用 UDP。
尽管 UDP 是无连接的,但 UDP 网络 API 提供了与 TCP 网络 API 类似的接口。在这里,我们将展示一个简单的客户端-服务器 UDP 回显服务器,以演示如何使用这些 API。
如何做到这一点...
以下步骤展示了如何编写 UDP 服务器:
-
使用
net.ResolveUDPAddr
解析服务器将监听的 UDP 地址:addr, err := net.ResolveUDPAddr("udp4", *address) if err != nil { panic(err) }
-
创建一个 UDP 监听器:
// Create a UDP connection conn, err := net.ListenUDP("udp4", addr) if err != nil { panic(err) } defer conn.Close()
即使
net.ListenUDP
返回一个*net.UDPConn
,返回的对象更像是一个监听器而不是一个连接。UDP 是无连接的,所以这个调用开始在给定地址上监听 UDP 数据包。客户端实际上并没有连接服务器并启动双向流;他们只是发送一个数据包。这就是为什么在下一步中,读取操作也会返回发送者的地址,以便可以发送响应。 -
从监听器读取。这将返回对等方的远程地址:
// Listen to incoming UDP connections buf := make([]byte, 1024) n, remoteAddr, err := conn.ReadFromUDP(buf) if err != nil { // Handle the error } fmt.Printf("Received %d bytes from %s\n", n, remoteAddr)
-
使用上一步获得的地址向对等方发送响应:
if n > 0 { _, err := conn.WriteToUDP(buf[:n], remoteAddr) if err != nil { // Handle the error } }
现在让我们看看 UDP 客户端:
-
解析服务器的地址:
addr, err := net.ResolveUDPAddr("udp4", *serverAddress) if err != nil { panic(err) }
-
创建一个 UDP 连接。这需要一个本地地址和一个远程地址。如果本地地址为 nil,则自动选择本地地址。如果远程地址为 nil,则假定是本地系统:
// Create a UDP connection, local address chosen randomly conn, err := net.DialUDP("udp4", nil, addr) if err != nil { panic(err) } fmt.Printf("UDP server %s\n", conn.RemoteAddr()) defer conn.Close()
再次,UDP 是无连接的。前面的
DialUDP
调用创建了一个将在后续调用中使用的套接字。它不会创建到服务器的连接。 -
使用
conn.Write
向服务器发送数据:// Send a line of text text := []byte("Hello echo server!") n, err := conn.Write(text) if err != nil { panic(err) } fmt.Printf("Written %d bytes\n", n)
-
使用
conn.Read
从服务器读取数据:// Read the response response := make([]byte, 1024) conn.ReadFromUDP(response)
与 HTTP 一起工作
HTTP 是一种客户端-服务器协议,其中客户端(用户代理或代理)向服务器发送请求,服务器返回响应。它是一种应用层超文本协议,是万维网的基础。
进行 HTTP 调用
Go 标准库提供了两种基本方式来发出 HTTP 调用以与网站和 Web 服务交互:如果你不需要配置超时、传输属性或重定向策略,只需使用共享客户端。如果你需要做额外的配置,使用http.Client
。这个食谱演示了两种方法。
如何做到这一点...
-
标准库包含一个共享的 HTTP 客户端。你可以使用它来使用默认配置与 Web 服务器交互:
response, err := http.Get("http://example.com") if err!=nil { // Handle error } // Always close response body defer response.Body.Close() if response.StatusCode/100==2 { // HTTP 2xx, call was successful. // Work with response.Body }
-
如果你需要应用不同的超时值,更改重定向策略,或配置传输,创建一个新的
http.Client
,初始化它,并使用它:client:=http.Client{ // Set a timeout for all outgoing calls. // If the call does not complete within 30 seconds, timeout. Timeout: 30*time.Second, } response, err:=client.Get("http://example.com") if err!=nil { // handle error } // Always close response body defer response.Body.Close()
-
如果操作系统已经拥有该网站证书的 CA 证书,你可以使用 HTTPS(使用 TLS)来调用网站。这在互联网上大多数公共网站上都是这种情况:
response, err := http.Get("https://example.com")
-
如果你使用的是自定义 CA 的 TLS,或者如果你使用的是自签名证书,你必须创建一个包含证书的
http.Client
的Transport
。-
创建一个新的证书池:
roots := x509.NewCertPool()
-
加载服务器证书:
certData, err := os.ReadFile(*certFile) if err != nil { panic(err) }
-
将证书添加到证书池中:
ok := roots.AppendCertsFromPEM(certData) if !ok { panic("failed to parse root certificate") }
-
创建一个 TLS 配置:
config:=tls.Config{ RootCAs: roots, }
-
使用 TLS 配置创建一个 HTTP 传输:
transport := &http.Transport { TLSClientConfig: &config, }
-
创建 HTTP 客户端:
client:= &http.Client{ Transport: transport, }
-
使用客户端:
resp, err:=client.Get(url) if err!=nil { // Handle error } defer resp.Body.Close()
-
小贴士
完成与响应体的工作后,始终关闭它,并尝试读取身体中所有可用的数据。response.Body
代表到服务器的流连接。只要传输中有数据,服务器就会保留连接的资源,并且客户端保持连接打开。它还防止客户端重用 keep-alive 连接。
运行 HTTP 服务器
标准 Go 库提供了一个具有合理默认值的 HTTP 服务器,你可以直接使用,类似于 HTTP 客户端的实现方式。如果你需要配置传输特定设置、超时等,则可以创建一个新的http.Server
并与之一起工作。本节描述了这两种方法。
如何做到这一点...
-
创建一个
http.Handler
来处理 HTTP 请求:func myHandler(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { // Handle an HTTP GET request } ... }
-
调用
http.ListenAndServe
:err:=http.ListenAndServe(":8080",http.HandlerFunc(myHandler)) log.Fatal(err)
-
ListenAndServe
函数要么因为设置网络监听器时出错(例如,如果地址已被占用)而立即返回,要么成功开始监听。当服务器异步关闭(通过调用server.Close()
或server.Shutdown()
)时,它返回ErrServerClosed
。 -
或者,你可以使用
http.Server
结构体来更好地控制服务器选项:-
按照描述创建一个
http.Handler
。 -
初始化一个
http.Server
实例:server := http.Server { // The address to listen Addr: ":8080", // The handler function Handler: http.HandlerFunc(myHandler), // The handlers must read the request within 10 seconds ReadTimeout: 10*time.Second, // The headers of a request must be read within 5 seconds ReadHeaderTimeout: 5*time.Second, }
-
监听 HTTP 请求:
err:=server.ListenAndServe() log.Fatal(err)
-
小贴士
创建 HTTP 处理器的常见方法是使用请求多路复用器。关于使用请求多路复用器的食谱将在稍后介绍。
HTTPS – 设置 TLS 服务器
要启动 TLS 服务器,你需要一个证书和一个私钥。你可以从 CA 购买一个,或者使用你的内部 CA 生成自己的证书。一旦你有了证书,你就可以使用本节中的食谱来启动你的 HTTPS 服务器。
如何做到这一点...
要创建 TLS HTTP 服务器,可以使用以下方法之一:
-
使用证书和密钥文件调用
Server.ListenAndServeTLS
方法:server := http.Server { Addr: ":4443", Handler: handler, } server.ListenAndServeTLS("cert.pem", "key.pem")
-
要使用默认 HTTP 服务器,设置处理程序函数(或
http.Handler
)并调用http.ListenAndServeTLS
:http.HandleFunc("/",func(w http.ResponseWriter, req *http.Request) { // Handle request }) http.ListenAndServeTLS("cert.pem", "key.pem")
-
或者准备一个带有证书的
http.Transport
:3.1 加载 TLS 证书:
cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem") if err!=nil { panic(err) }
3.2 使用证书创建一个
tls.Config
:tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, }
3.3 使用
tlsConfig
创建一个http.Server
:server := http.Server{ Addr: ":4443", Handler: handler, TLSConfig: tlsConfig, }
3.4 调用
server.ListenAndServeTLS
server.ListenAndServeTLS("","")
编写 HTTP 处理程序
当 HTTP 请求到达服务器时,服务器会查看 HTTP 方法(GET、POST 等)、客户端使用的域名(Host
标头)和 URL,以决定如何处理请求。确定哪个处理程序应该处理此类请求的机制称为 请求多路复用器。Go 标准库自带了一个,还有许多第三方开源的多路复用器。在本节中,我们将查看标准库多路复用器及其使用方法。
如何实现...
-
对于简单情况,例如健康检查端点,您可以使用匿名函数:
mux := http.NewServeMux() mux.HandleFunc("GET /health",func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("Ok") }) ... server := http.Server { Handler: mux, Addr: ":8080", ... } server.ListenAndServe()
前面的处理程序将响应
GET /health
端点请求,返回Ok
和HTTP
200
状态码。 -
您可以使用实现
http.Handler
接口的数据类型:-
创建一个新的数据类型,这可以是一个包含您将需要实现处理程序所需信息的结构:
// The RandomService reads random data from a source, and // returns random numbers type RandomService struct { rndSource io.Reader }
-
实现
http.Handler
接口:func (svc RandomService) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Read 4 bytes from the random number source, convert it to string data:= make([]byte,4) _,err:=svc.rndSource.Read(data) if err!=nil { // This will return an HTTP 500 error with the error message // as the message body http.Error(w,err.Error(),http.StatusInternalServerError) return } // Decode random data using binary little endian encoding value:=binary.LittleEndian.Uint32(data) // Write the data to the output w.Write([]byte(strconv.Itoa(int(value)))) }
-
创建处理程序类型的实例并初始化它
file, err:=os.Open("/dev/random") if err!=nil { panic(err) } defer file.Close() svc:=RandomService { rndSource: file, }
-
-
创建一个多路复用器:
mux:=http.NewServeMux()
-
将处理程序分配给模式。以下示例将
/rnd
路径的GET
请求分配给在 步骤 3 中构建的实例。mux.Handle("GET /rnd", svc)
-
启动服务器。
server := http.Server { Handler: mux, Addr: ":8080", ... } server.ListenAndServe()
-
一种更通用的方法涉及创建具有多个方法作为处理程序的数据类型。这种模式对于 Web 服务开发特别有用,因为它允许创建服务于特定业务域所有相关 API 的结构:
-
创建一个数据类型。这可以是一个包含实现处理程序所需所有必要信息的结构,例如数据库连接、公钥/私钥等:
type UserHandler struct { DB *sql.DB }
-
-
使用
http.HandlerFunc
的签名创建方法以实现多个 API 端点:func (hnd UserHandler) GetUser(w http.ResponseWriter, req *http.Request) { ... }
-
创建并初始化处理程序。
userDb, err:=sql.Open(driver, UserDBUrl) if err!=nil { panic(err) } userHandler := UserHandler { DB: userDb, }
-
创建请求多路复用器
mux := http.NewServeMux()
-
将处理程序方法分配给模式:
mux.Handle("GET /users/{userId}",userHandler.GetUser) mux.Handle("POST /users", userHandler.NewUser) mux.Handle("DELETE /users/{userId}", userHandler.DeleteUser)
-
使用多路复用器启动服务器。
server := http.Server{ Addr: serverAddr, Handler: mux, } server.ListenAndServe()
以下代码片段说明了在编写 HTTP 处理程序时如何使用标准库请求多路复用工具:
func (hnd UserHandler) GetUser(w http.ResponseWriter, req *http.Request) {
// User req.PathValue("userId") to get userId portion of /users/
// {userId}
// That is, if this API is invoked with GET /users/123, then after
// the following line `userId` is assigned to "123"
userId:=req.PathValue("userId")
// Get user data from the DB
user, err:=GetUserInformation(hnd.DB,userId)
if err!=nil {
http.Error(w,err.Error(),http.StatusNotFound)
return
}
// Marshal user data to JSON
data, err:=json.Marshal(user)
if err!=nil {
http.Error(w, err.Error(),http.StatusInternalServerError)
return
}
// Set the content type header. You **must** set all headers before
// writing the body. Once the body is placed on the write, there is
// no way to change a header that is already written.
w.Header().Set("Content-Type","application/json")
w.Write(data)
}
在文件系统上提供静态文件
不是所有由 Web 应用程序提供的文件都是动态生成的。JavaScript 文件、层叠样式表和一些 HTML 页面通常以纯文本形式提供。本节展示了提供此类文件的一些方法。
如何实现...
静态文件可以通过多种方式通过 HTTP 提供服务:
-
要在目录下提供所有静态文件,请使用
http.FileServer
创建处理程序:fileHandler := http.FileServer(http.Dir("/var/www")) server:=http.Server{ Addr: addr, Handler: fileHandler, } http.ListenAndServe() /var/www at the root path. That is, a GET /index.html request will serve the /var/www/index.html file with Content-Type: text/html. Similarly, a GET /css/styles.css will serve /var/www/css/styles.css with Content-Type: text/css.
-
要在目录下提供所有静态文件,但使用不同的 URL 路径前缀,请使用
http.StripPrefix
:fileHandler := http.StripPrefix("/static/", http.FileHandler(http.Dir("/var/www"))
上述调用将给定的文件处理程序包装在另一个处理程序中,该处理程序从 URL 路径中删除给定的前缀。对于
GET /static/index.hml
请求,此处理程序将提供/var/www/index.html
,并设置Content-Type: text/html
。如果路径不包含给定的前缀,这将返回HTTP 404
Not Found
。 -
要向 URL-文件名映射添加额外的逻辑,实现
http.FileSystem
接口,并使用FileServerFS
与该文件系统一起使用。您可以将此处理程序与http.StripPrefix
结合使用,以进一步更改 URL 路径处理:// Serve only HTML files in the given directory type htmlFS struct { fs *http.FileSystem } // Filter file names by their extension before opening them func (h htmlFS) Open(name string) (http.File, error) { if strings.ToLower(filepath.Ext(name))==".html" { return h.fs.Open(name) } return nil, os.ErrNotFound } ... htmlHandler := http.FileHandler(htmlFS{fs:http.Dir("/var/www")) // htmlHandler serves all HTML files under /var/www
处理 HTML 表单
HTML 表单是捕获 Web 应用程序数据的一个基本组件。HTML 表单可以通过使用 Form
HTML 元素在服务器端进行处理,或者可以使用 JavaScript 在客户端进行处理。在本节中,我们将探讨处理服务器端 HTTP 表单提交。
如何做到这一点...
在客户端,执行以下操作。
-
在
Form
HTML 元素中封装数据输入字段:<form method="POST" action="/auth/login"> <input type="text" name="userName"> <input type="password" name="password"> <button type="submit">Submit</button> </form>
在这里,
method
属性确定 HTTP 方法,它是POST
,而action
属性确定 URL。请注意,此 URL 是相对于当前页面 URL 的。当表单提交时,客户端处理程序将为给定的 URL 准备一个POST
请求,并将输入字段的名称-值对编码为application/x-www-form-urlencoded
编码发送。 -
在服务器端,执行以下操作:
-
编写一个处理
POST
请求的处理程序。在处理程序中,执行以下操作:-
调用
http.Request.ParseForm
来处理提交的数据。 -
从
http.Request.PostForm
获取提交的信息。 -
处理请求。
-
以下示例实现了一个简单的登录场景,使用提交的用户名和密码。处理程序使用一个执行实际用户验证并返回 cookie(如果登录成功)的验证器。此 cookie 包含在后续调用中识别用户的信息:
type UserHandler struct { Auth Authenticator } func (h UserHandler) HandleLogin(w http.ResponseWriter, req *http.Request) { // Parse the submitted form. This fills up req.PostForm // with the submitted information if err:=req.ParseForm(); err!=nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Get the submitted fields userName := req.PostForm.Get("userName") password := req.PostForm.Get("password") // Handle the login request, and get a cookie cookie,err:=h.Auth.Authenticate(userName,password); if err!=nil { // Send the user back to login page, setting an error // cookie containing an error message http.SetCookie(w,h.NewErrorCookie("Username or password invalid")) http.Redirect(w, req, "/login.html", http.StatusFound) return } // Set the cookie representing user session http.SetCookie(w,cookie) // Redirect the user to the main page http.Redirect(w,req,"/dashboard.html",http.StatusFound) }
-
将处理程序注册为处理 URL 的
POST
请求:userHandler := UserHandler { Auth: authenticator, } mux := http.NewServeMux() mux.HandleFunc("POST /auth/login", userHandler.HandleLogin) mux.HandleFunc("GET /login.html", userHandler.ShowLoginPage)
-
提示
在处理 cookie 时必须小心。在我们的示例中,cookie 是由服务器应用程序创建并发送给客户端的。随后的对服务器的调用将包括该 cookie,以便服务器跟踪用户会话。然而,无法保证客户端提交的 cookie 是一个有效的 cookie。恶意客户端可以发送伪造或过期的 cookie。使用加密方法确保 cookie 是由服务器创建的,例如使用秘密密钥签名 cookie,或使用 JSON Web Token。
提示
上述示例还包括了使用 cookie 从一个页面发送状态信息到另一个页面的另一个用法。如果登录失败,用户将被重定向到登录页面,并带有包含错误信息的 cookie。登录页面处理程序可以检查该 cookie 的存在并显示消息。
这里给出了一个示例实现:
func (h UserHandler) ShowLoginPage(w http.ResponseWriter, req *http.Request) {
loginFormData:=map[string]any{}
cookie, err:= req.Cookie("error_cookie")
if err==nil {
loginFormData["error"] = cookie.Value
// Unset the cookie
http.SetCookie(w, &http.cookie {
Name: "error_cookie",
MaxAge: 0,
})
}
w.Header().Set("Content-Type","text/html")
loginFormTemplate.Execute(w,loginFormData)
}
NewErrorCookie
方法的实现如下:
func (h UserHandler) NewErrorCookie(msg string) *http.Cookie {
return &http.Cookie {
Name: "error_cookie",
Value: msg,
MaxAge: 60, // Cookie lives for 60 seconds
Path: "/",
}
}
编写用于下载大文件的处理器
当 HTTP 客户端请求大文件时,通常不可行一次性加载所有文件数据然后发送给客户端。使用io.Copy
将大内容流式传输到客户端。
如何操作...
这就是如何编写一个用于下载大文件的处理器:
-
设置
Content-Type
头。 -
设置
Content-Length
头。 -
使用
io.Copy
写入文件内容。
这些步骤在此处展示:
func DownloadHandler(w http.ResponseWriter, req *http.Request) {
fileName := req.PathValue("fileName")
f, err:= os.Open(filepath.Join("/data",fileName))
if err!=nil {
http.Error(w,err.Error(),http.StatusNotFound)
return
}
defer f.Close()
w.Header.Set("Content-Type","application/octet-stream")
w.Header.Set("Content-Length", strconv.Itoa(f.Length()))
io.Copy(w,f)
}
将 HTTP 上传文件和表单作为流处理
标准库提供了处理文件上传的方法。你可以调用http.Request.ParseMultipartForm
,并处理上传的文件。这种方法有一个问题:ParseMultipartForm
处理所有上传直到一个给定的内存限制。它甚至可能使用临时文件。如果你处理的是大文件,这不是一个可扩展的方法。本节描述了如何在不需要创建临时文件或大内存占用的情况下处理文件上传。
如何操作...
在客户端,执行以下操作:
-
创建一个使用
multipart/form-data
编码的 HTML 表单。 -
添加你计划上传的表单字段和文件。
这里提供了一个示例:
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="textField">
<input type="file" name="fileField">
<button type="submit">submit</button>
</form>
当提交此表单时,它将创建一个包含两部分的复合消息:
-
也有一个部分带有
Content-Disposition: form-data; name="textField"
。这个部分的内容将包含用户为textField
输入字段输入的输入。 -
也有一个部分带有
Content-Disposition: form-data; name="fileField"; filename=<用户选择的文件名>
。这个部分的 内容将包含文件内容。
在服务器端,执行以下操作:
-
使用
http.Request.MultipartReader
从请求中获取一个复合体部分读取器。如果请求不是一个复合请求(multipart/mixes 或 multipart/form-data),这将失败:reader, err:=request.MultipartReader() if err!=nil { http.Error(w,"Not a multipart request",http.StatusBadRequest) return }
-
通过调用
MultipartReader.NextPart
逐个处理提交数据的部分:for { part, err:= reader.NextPart() if errors.Is(err,io.EOF) { break } if err!=nil { http.Error(w,err.Error(),http.StatusBadRequest) return } }
-
使用
Content-Disposition
头检查部分是否为表单数据或文件:-
如果
Content-Disposition
是form-data
但没有filename
参数,那么这个部分包含一个表单字段。 -
如果
Content-Disposition
是带有filename
参数的form-data
,那么这个部分是一个文件。你可以从体中读取文件内容。formValues:=make(url.Values) if fileName:=part.FileName(); fileName!="" { // This part contains a file output, err:=os.Create(fileName) if err!=nil { // Handle error } defer output.Close() if err:=io.Copy(output,part); err!=nil { // Handle error } } else if fieldName := part.FormName(); fieldName!="" { // This part contains form data for an input field data, err := io.ReadAll(part) if err!=nil { // Handle error } formValues[fieldName]=append(formValues[fieldName], string(data)) }
-
第十四章:流式输入/输出
简单性中蕴含着灵活性和优雅。与决定实现功能丰富的流式框架的几种语言不同,Go 选择了简单的基于能力的途径:读取器是从中读取字节的东西,写入器是写入字节的东西。内存缓冲区、文件、网络连接等都是读取器和写入器,由io.Reader
和io.Writer
定义。文件也是一个io.Seeker
,因为你可以随机更改读取/写入位置,但网络连接不是。文件和网络连接可以被关闭,因此它们都是io.Closer
,但内存缓冲区不是。这种简单而优雅的抽象是编写可用于不同上下文的算法的关键。
在本章中,我们将探讨一些示例,展示如何惯用性地使用这种基于能力的流式框架。我们还将探讨如何处理文件和文件系统。本章涵盖的食谱分为以下主要部分:
-
读取器/写入器
-
处理文件
-
处理二进制数据
-
复制数据
-
处理文件系统
-
处理管道
读取器/写入器
记住,Go 使用结构化类型系统。这使得任何实现了Read([]byte) (int,error)
的数据类型都是io.Reader
,任何实现了Write([]byte) (int,error)
的数据类型都是io.Writer
。标准库中有许多使用这个特性的例子。在本食谱中,我们将探讨读取器和写入器的常见用法。
从读取器读取数据
io.Reader
填充你传递给它的字节切片。通过传递一个切片,你实际上传递了两条信息:你想要读取多少(切片的长度)以及将读取的数据放在哪里(切片的底层数组)。
如何做到这一点...
-
创建一个足够大的字节切片以容纳你想要读取的数据:
buffer := make([]byte,1024)
-
将数据读取到字节切片中:
nRead, err := reader.Read(buffer)
-
检查读取了多少。实际读取的字节数可能小于缓冲区大小:
buffer = buffer[:nRead]
-
检查错误。如果错误是
io.EOF
,则表示读取器已到达流末尾。如果错误是其他内容,则处理错误或返回它:if errors.Is(err,io.EOF) { // End of file reached. Return data return buffer, nil } if err!=nil { // Some other error, handle it or return return nil,err }
注意步骤3和4的顺序。返回io.EOF
不一定是错误,它仅仅意味着已到达文件末尾或网络连接已关闭,因此你应该停止读取。缓冲区中可能还有一些数据被读取,你应该处理这些数据。读取器返回读取的数据量。
将数据写入写入器
-
将要写入的数据编码为字节切片;例如,使用
json.Marshal
将你的数据编码为[]byte
形式的 JSON 表示:buffer, err:=json.Marshal(data) if err!=nil { return err }
-
写入编码后的数据:
_, err:= writer.Write(buffer) if err!=nil { return err }
-
检查并处理错误。
警告
与读取器不同,所有来自写入器的错误都应被视为错误。写入器不返回io.EOF
。即使有错误,写入也可能已写入数据的一部分。
从字节切片读取和写入
读取器或写入器不一定是文件或网络连接。本节展示了如何将字节切片作为读取器和写入器进行操作。
如何做到这一点...
-
要从一个
[]byte
创建一个读取器,使用bytes.NewReader
。以下示例将一个数据结构序列化为 JSON(返回一个[]byte
),然后通过从它创建一个读取器将这个[]byte
发送到 HTTPPOST
请求:data, err:=json.Marshal(myStruct) if err!=nil { return err } rsp, err:=http.Post(postUrl, "application/json", bytes.NewReader(data))
-
要将
[]byte
用作写入器,使用bytes.Buffer
。缓冲区会在你写入时追加到底层的字节切片。当你完成时,你可以获取缓冲区的内容:buffer := &bytes.Buffer{} encoder := json.NewEncoder(buffer) if err:=encoder.Encode(myStruct); err!=nil { return err } data := buffer.Bytes()
bytes.Buffer
也是一个io.Reader
,具有单独的读取位置。写入bytes.Buffer
将追加到底层的切片末尾。从bytes.Buffer
读取将从底层切片的开始读取。因此,你可以读取你写入的字节,如下所示:
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
if err:=encoder.Encode(myStruct); err!=nil {
return err
}
rsp,err:=http.Post(postUrl, "application/json", buffer)
从字符串读取和写入
要从一个字符串创建一个读取器,使用strings.NewReader
,如下所示:
rsp, err:=http.Post(postUrl,"application/json",strings.NewReader(`{"key":"value"}`))
不要使用bytes.NewReader([]byte(stringValue))
代替strings.NewReader(stringValue)
。前者将字符串的内容复制到创建的字节切片中。后者不复制直接访问底层字节。
要将字符串用作io.Writer
,使用strings.Builder
。例如,作为io.Writer
,strings.Builder
可以被传递给fmt.Fprint
函数族:
query:=strings.Builder{}
args:=make([]interface{},0)
query.WriteString("SELECT id,name FROM users ")
if !createdAt.IsZero() {
args=append(args,createdAt)
fmt.Fprintf(&query,"where createdAt < $%d",len(args))
}
rows, err:=tx.Query(ctx,query.String(),args...)
文件处理
文件在存储系统上只是字节序列的简单集合。有两种处理文件的方式:作为随机访问的字节序列或作为字节流。我们将在本节中查看这两种类型的配方。
创建和打开文件
要处理文件的内容,你首先必须打开它或创建它。这个配方展示了如何做到这一点。
如何做到这一点...
要打开一个现有文件进行读取,使用os.Open
:
file, err := os.Open(fileName)
if err!=nil {
// handle error
}
你可以从返回的文件对象中读取数据,完成后,你应该使用file.Close()
关闭它。因此,你可以将其用作io.Reader
或io.ReadCloser
(*os.File
实现了更多接口!)
如果你尝试写入文件,你将收到一个来自写入操作的错误。在我的 Linux 系统中,这个错误是一个*fs.PathError
消息,表示bad
file descriptor
。
要创建一个新文件或覆盖现有文件,使用os.Create
:
file, err := os.Create(fileName)
if err!=nil {
// handle error
}
如果上述调用成功,返回的文件可以读取或写入。文件是用0o666 & ^umask
创建的。如果在此调用之前文件已经存在,它将被截断到长度为0
。
小贴士
umask
定义了应用程序不能设置在文件上的权限集合。在前面文本中,0o666
表示所有者、组和其他人都可以读取和写入文件。例如,0o022
的umask
值将文件模式从0o666
更改为0o644
,这意味着所有者可以读取和写入,但组和其他人只能读取。
要打开一个现有文件进行读取/写入,使用os.OpenFile
。这是 open/create 函数族中最通用的形式:
-
要以读写模式打开一个现有文件,请使用以下方法:
file, err := os.OpenFile(fileName,os.O_RDWR, 0)
最后一个参数是
0
。这个参数仅在创建文件是一个选项时使用。我们将在稍后看到这个情况。 -
要以只读模式打开一个现有文件,请使用以下方法:
file, err := os.OpenFile(fileName,os.O_RDONLY, 0)
-
要以只写模式打开一个现有文件,请使用以下方法:
file, err := os.OpenFile(fileName,os.O_WRONLY, 0)
-
要以追加模式打开一个现有文件,请使用以下方法:
file, err := os.OpenFile(fileName,os.O_WRONLY|os.O_APPEND, 0)
尝试在文件末尾之外的地方写入将会失败。
-
要打开一个现有文件或创建一个如果它不存在,请使用以下方法:
file, err := os.OpenFile(fileName,os.O_RDWR|os.O_CREATE, 0o644)
上述操作将在文件存在时打开文件以供读写。如果文件不存在,它将使用
0o644 & ^umask
权限位创建。0o644
意味着所有者可以读写(06
),同一组的用户可以读(04
),其他用户可以读(04
)。
以下与os.Create
等价;即,如果文件存在则截断并打开,如果不存在则创建:
file, err:= os.Open(fileName, os.O_RDWR|os.O_CREATE|os.O_TRUNC,0o644)
如果你只想在文件不存在时创建文件,请使用“独占”位:
file, err := os.Open(fileName, os.O_RDWR|os.O_CREATE|os.O_EXCL,0o644)
如果文件已经存在,这个调用将会失败。
提示
这是一种常见的确保一个进程实例正在运行的方法,或者在没有锁定资源的情况下锁定资源。例如,如果你想锁定一个目录,你可以使用这个调用来创建一个锁文件。如果其他进程已经锁定了它(在你之前创建了文件),那么它将会失败。
关闭文件
你应该始终显式关闭你打开的文件有两个原因:
-
当你关闭文件时,所有存储在缓冲区中的数据都会被刷新。
-
你在任何给定时间可以保持打开的文件数量是有限的。这些限制因平台而异。
以下步骤显示了如何始终如一地完成这项操作。
如何做到这一点...
当你完成与文件的交互后,请关闭它。尽可能使用defer file.Close()
:
file, err:=os.Open(fileName)
if err!=nil {
// handle error
}
defer file.Close()
// Work with the file
如果你正在处理许多文件,不要依赖defer
。不要这样做:
for _,fileName:=range files {
file, err:=os.Open(fileName)
if err!=nil {
// handle error
}
defer file.Close()
// Work with file
}
延迟调用将在函数返回时执行,而不是在你使用它们的代码块结束时执行。上述代码将保持所有文件打开,直到函数返回,如果文件数量很多,一旦超过打开文件限制,os.Open
将会开始失败。你可以做两件事之一。第一是显式关闭所有退出点的文件:
for _,fileName:=range files {
file, err:=os.Open(fileName)
if err!=nil {
return err
}
// Work with file
err:=useFile(file)
if err!=nil {
file.Close()
return err
}
err:=useFileAgain(file)
if err!=nil {
file.Close()
return err
}
// Do more work
file.Close()
}
第二种方法是使用带有defer
的闭包:
for _,fileName:=range files {
file, err:=os.Open(fileName)
if err!=nil {
return err
}
err=func() error {
defer file.Close()
// Work with file
err:=useFile(file)
if err!=nil {
return err
}
err:=useFileAgain(file)
if err!=nil {
return err
}
// Do more work
return nil
}()
if err!=nil {
return err
}
}
提示
文件会被垃圾回收。如果你打开/创建文件然后直接使用文件描述符而不是使用*os.File
来工作,垃圾回收器就不会是你的朋友。使用runtime.KeepAlive(file)
来防止垃圾回收器在你通过文件描述符和/或系统调用与之交互时关闭文件。避免依赖垃圾回收器来关闭你的文件。始终显式关闭文件。
从/向文件读取/写入数据
当您以读取和写入方式打开文件时,操作系统会保留 0
。然后如果您从文件中读取 10 个字节,当前位置将变为 10
(假设文件大于 10 个字节)。下次您从文件中读取或写入时,您将从偏移量 10
开始读取内容或写入。请记住这种行为,特别是如果您正在对文件进行读写混合操作。
如何操作...
-
要从当前位置读取一些数据,请使用
file.Read
:file, err:=os.Open(fileName) if err!=nil { return err } // Current location: 0 buffer:=make([]byte,100) // Read 100 bytes n, err:=file.Read(buffer) // Current location: n // n tells how many bytes actually read data:=buffer[:n] if err!=nil { if errors.Is(err, io.EOF) { } }
检查
n
(读取的字节数)和检查是否有错误顺序很重要。io.Reader
可能会进行部分读取并返回读取的字节数以及一个错误。这个错误可能是io.EOF
,表示文件中的数据少于您尝试读取的数据。例如,一个包含 10 个字节的文件将返回n=10
和err=io.EOF
。还请注意,这种行为取决于文件当前的位置。以下代码段将文件作为字节切片的切片读取:slices := make([][]byte,0) for { buffer:=make([]byte,1024) n, err:=file.Read(buffer) if n>0 { slices=append(slices,buffer[:n]) buffer=make([]byte,1024) } if err!=nil { if errors.Is(err,io.EOF) { break } return err } }
如果在先前的代码开始时文件中的当前位置是
0
,则在每次读取操作后,当前位置将前进n
。请注意,除了最后一个之外,所有的字节切片都是1024
字节。最后一个切片的大小可以从1
到1024
字节不等,具体取决于文件大小。 -
写入文件的操作类似:
buffer:=[]byte("Hello world!") n, err:=io.Write(buffer) if err!=nil { return err }
写入操作不会返回
io.EOF
。如果您写过了文件的末尾,文件将扩展以容纳写入的字节。如果写入操作不能写入所有给定的字节,错误始终是非空的,您应该检查并处理该错误。如果当前位置在开始时为
0
,则在写入操作后它将是n
。 -
要从文件中读取所有内容,请使用
os.ReadFile
:data, err:= os.ReadFile("config.yaml") if err!=nil { // Handle error }
小贴士
使用 os.ReadFile
时要小心。它分配了一个大小与文件相同的 []byte
。只有当您确信您正在读取的文件大小合理时,才使用此函数。
-
要以固定大小的块读取大文件,分配一个固定大小的缓冲区并迭代读取,直到返回
io.EOF
:// Read file in 10K chunks buf:=make([]byte,10240) for { n, err:=file.Read(buf) if n>0 { // Process buffer contents: processData(buf[:n]) } // Check for errors. Check for io.EOF and handle it if err!=nil { if errors.Is(err,io.EOF) { // End of file. We are done break } // Some other error return err } }
-
要将字节切片写入新文件,请使用
os.WriteFile
:err:=os.WriteFile("config.yaml", data, 0o644)
从/到特定位置读取/写入
我们之前讨论了当前位置的概念。本节内容是关于将当前位置移动到文件的起始位置以进行读取或写入操作。
如何操作...
您可以使用 File.Seek
来更改当前位置。
-
要将当前位置相对于文件开头设置,请使用以下方法:
// Move to offset 100 in file newLocation, err := file.Seek(100,io.SeekStart)
返回的
newLocation
是文件的新当前位置。后续的读取或写入操作将从该位置开始。 -
要将当前位置相对于文件末尾设置,请使用以下方法:
// Move to the end of the file: newLocation, err := file.Seek(0,io.SeekEnd)
这也是一种快速确定当前文件大小的方法,因为
newLocation
在文件末尾之前是 0 字节。 -
你可以定位到文件末尾之外的位置。从这样的位置读取将读取 0 字节。写入这样的位置将扩展文件大小以容纳在该位置写入的数据:
// Go to 100 after the end of file and write 1 byte newLocation, err:=file.Seek(100, io.SeekEnd) if err!=nil { panic(err) } // Write 1 byte. file.Write([]byte{0}) // The file is 101 bytes larger now.
小贴士
当你以这种方式扩展文件时,文件末尾和新增字节之间的区域会被填充为 0s。底层平台可能会将其实现为一个空洞;也就是说,未写入的区域可能实际上并未分配。
-
os.File
支持用于此类随机访问的附加方法。File.WriteAt
将数据写入指定的位置(相对于文件开始的位置),而不会移动当前位置。File.ReadAt
将从指定的位置读取,而不会移动当前位置:// Go to offset 1000 _,err:=file.Seek(1000,io.SeekStart) // Write "Hello world" to offset 10. n, err:=file.WriteAt([]byte("Hello world!"),10) if err!=nil { panic(err) } // Write to offset 1000, because WriteAt does not move // the current location _,err:=file.WriteAt([]byte{"offset 1000") buffer:=make([]byte,5) file.ReadAt(buffer,10) fmt.Println(string(buffer)) // Prints "Hello"
改变文件大小
扩展文件通常是通过向其末尾写入更多数据来实现的,但如何缩小现有文件呢?这个配方描述了改变文件大小的不同方法。
如何操作...
-
要将文件截断到
0
大小,你可以使用截断标志打开文件:file, err:=os.OpenFile("test.txt", os.O_RDWR|os.O_TRUNC,0o644) // File is opened and truncated to 0 size
-
如果文件已经打开,你可以使用
File.Truncate
来设置文件大小。File.Truncate
具有双向功能——你可以扩展文件,也可以缩小它:// Truncate the file to 0-size err:=file.Truncate(0) if err!=nil { panic(err) } // Extend the file to 100-bytes err=file.Truncate(100) if err!=nil { panic(err) }
-
你也可以通过追加内容来扩展文件。你可以通过两种方式之一来完成这个操作。你可以以追加模式打开文件:
file, err:=os.OpenFile("test.txt", os.O_WRONLY|os.O_APPEND,0) // File is opened for writing, current location is set to the // end of the file
如果你以追加模式打开文件,你不能从文件的其他位置进行读写,你只能追加到它。
-
或者,你可以定位到文件末尾并从那里开始写入:
// Seek to the end _,err:=file.Seek(0,io.SeekEnd) if err!=nil { panic(err) } // Write new data to the end of the file _,err:=file.Write(data)
查找文件大小
如果文件已打开,你可以按以下方式获取文件大小:
fileSize, err:= file.Seek(0,io.SeekEnd)
这将返回当前文件大小,包括任何已追加但尚未刷新的数据。
上述操作将移动文件指针到文件末尾。为了保留当前位置,请使用以下方法:
// Get current location
currentLocation, err:=file.Seek(0,io.SeekCurrent)
if err!=nil {
return err
}
// Find file size
fileSize, err:=file.Seek(0,io.SeekEnd)
if err!=nil {
return err
}
// Move back to the saved location
_,err:=file.Seek(currentLocation,io.SeekStart)
if err!=nil {
return err
}
如果文件未打开,请使用os.Stat
:
fileInfo, err:=os.Stat(fileName)
if err!=nil {
return err
}
fileSize := fileInfo.Size()
小贴士
如果你已经打开了文件并向其中追加数据,os.Stat
报告的文件大小可能与通过File.Seek
获得的文件大小不同。os.Stat
函数从目录中读取文件信息。File.Seek
方法使用进程特定的文件信息,这些信息可能尚未反映在目录条目中。
处理二进制数据
如果你需要通过网络连接发送数据或将其存储在文件中,你首先必须对其进行编码(或序列化,或打包。)这是必要的,因为网络连接另一端的系统或将要读取你写入的文件的程序可能运行在不同的平台上。一种便携、易于调试但可能不是最高效的方法是使用基于文本的编码,如 JSON。如果性能至关重要或使用场景要求这样做,你可以使用二进制编码。
有许多高级的二进制编码方案。Gob (pkg.go.dev/encoding/gob
) 是一种特定于 Go 的编码方案,可用于网络应用程序。协议缓冲区 (protobuf.dev
) 提供了一种语言无关、可扩展、基于模式的机制,用于编码结构化数据。还有更多。在这里,我们将探讨每个软件工程师都应该了解的二进制编码基础知识。
编码数据涉及将数据元素转换成字节流。如果您有一个单字节的数据元素或已经是一系列字节的数据元素,您可以逐字编码它们。当处理多字节数据类型(int16
、int32
、int64
等)时,字节顺序变得很重要。例如,如果您有一个 int16
值 0xABCD
,您应该如何将这些字节编码为 []byte
?有两种选择:
-
0xABCD
被编码为[]``byte{0xCD, 0xAB}
-
0xABCD
被编码为[]``byte{0xAB, 0xCD}
类似地,一个 32 位整数,0x01234567
,以小端字节序编码给出 []byte{0x67,0x45,0x23,0x01}
,以大端字节序编码给出 []byte{0x01,0x23,0x45,0x67}
。大多数现代硬件使用小端字节序在内存中表示值。网络协议(如 IP)倾向于使用大端。
如何做到这一点...
编码二进制数据主要有两种方法:
- 第一种方法是使用固定结构。在这种方法中,数据字段的顺序和类型是固定的。例如,IPv4 头部定义了每个头部字段开始和结束的位置。这种方法无法省略字段或添加扩展。一个例子在 图 14**.1 中展示。
图 14.1:固定长度编码示例
- 第二种方法是使用动态编码方案,例如
string
字段,然后是长度,然后是字符串本身。一个 TLV 编码方案的例子在 图 14**.2 中展示。
图 14.2:TLV 编码示例
这个例子使用了 16 位字符串长度和 64 位切片长度编码。
使用 encoding/binary
对数据进行大端或小端字节序编码。
对于固定长度编码,您可以使用 encoding.Write
来编码,使用 encoding.Read
来解码数据:
type Data struct {
IntValue int64
BoolValue bool
ArrayValue [2]int64
}
func main() {
output := bytes.Buffer{}
data:=Data{
IntValue: 1,
BoolValue: true,
ArrayValue: [2]int64{1,2},
}
// Encode data using big endian byte order
binary.Write(&output, binary.BigEndian, data)
stream := output.Bytes()
fmt.Printf("Big endian encoded data : %v\n", stream)
// Decode data
var value1 Data
binary.Read(bytes.NewReader(stream), binary.BigEndian, &value1)
fmt.Printf("Decoded data: %v\n", value1)
// Encode data using little endian byte order
output = bytes.Buffer{}
binary.Write(&output, binary.LittleEndian, data)
stream = output.Bytes()
fmt.Printf("Little endian encoded data: %v\n", stream)
// Decode data
var value2 Data
binary.Read(bytes.NewReader(stream), binary.LittleEndian, &value2)
fmt.Printf("Decoded data: %v\n", value2)
}
这个程序输出以下内容:
Big endian encoded data : [0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 2]
Decoded data: {1 true [1 2]}
Little endian encoded data: [1 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0]
Decoded data: {1 true [1 2]}
在定义 Data
结构时,请特别注意。如果您想使用 encoding.Read
或 encoding.Write
,则不能使用可变长度或平台特定的类型:
-
没有
int
,因为int
的大小是平台特定的 -
没有切片
-
没有映射
-
没有字符串
我们如何编码这些值呢?让我们看看一个 LV 编码方案来编码一个字符串值:
func EncodeString(s string) []byte {
// Allocate the output buffer for string length (int16) +
// len(string)
buffer:=make([]byte, 0, len(s)+2)
// Encode the length little endian - 2 bytes
binary.LittleEndian.PutUint16(buffer,uint16(len(s)))
// Copy the string bytes
copy(buffer[2:],[]byte(s))
return buffer
}
这里有一个解码字符串值的例子:
func DecodeString(input []byte) (string, error) {
// Read the string length. It must be at least 2 bytes
if len(input) < 2 {
return "", fmt.Errorf("invalid input")
}
n := binary.LittleEndian.Uint16(input)
if int(n)+2 > len(input) {
return "", fmt.Errorf("invalid input")
}
return string(input[2 : n+2]), nil
}
复制数据
io.Copy
从读取器读取数据并将其写入写入器,直到其中一个操作失败或读取器返回 io.EOF
。有许多需要从读取器获取数据块并将其发送到写入器的情况。io.Copy
在一个抽象层上工作,允许您从文件复制数据到网络连接,或从字符串复制到文件。它还执行基于能力的优化以最小化数据复制。例如,如果平台支持 splice 系统调用,io.Copy
可以使用它来绕过缓冲区使用。在本节中,我们将看到 io.Copy
的一些用法。
复制文件
如何做到这一点...
要复制文件,请按照以下步骤操作:
-
打开源文件。
-
创建目标文件。
-
使用
io.Copy
来复制数据。 -
关闭两个文件。
这些步骤在此处展示:
sourceFile, err:=os.Open(sourceFileName)
if err!=nil {
panic(err)
}
defer sourceFile.Close()
targetFile, err:=os.Create(targetFileName)
if err!=nil {
panic(err)
}
defer targetFile.Close()
if _,err:=io.Copy(targetFile,sourceFile);err!=nil {
panic(err)
}
由于 io.Copy
与 io.Reader
和 io.Writer
一起工作,任何实现这些接口的对象都可以用作源或目标。例如,以下代码段在 HTTP 请求的响应中返回一个文件:
// Handle GET /path/{fileName}
func HandleGetImage(w http.ResponseWriter, req *http.Request) {
// Get the file name from the request
file, err:=os.Open(req.PathValue("fileName"))
if err!=nil {
http.Error(w,err.Error(),http.StatusNotFound)
return
}
defer file.Close()
// Write file contents to the response writer
io.Copy(w,file)
}
处理文件系统
文件系统有许多方面是特定于平台的。本节讨论了在文件系统中使用便携式方法的讨论。
处理文件名
使用 path/filepath
包以便携方式处理文件名。
如何做到这一点...
-
要从多个路径段构建路径,请使用
filepath.Join
:fmt.Println(filepath.Join("/a/b/","/c/d") // Prints /a/b/c fmt.Println(filepath.Join("/a/b/c/d/","../../x") // Prints a/b/x
注意,
filepath.Join
不允许连续的分隔符,并且正确解释".."
。 -
要将路径分割为其目录和文件名部分,请使用
filepath.Split
:fmt.Println(filepath.Split("/home/bserdar/work.txt")) // dir: "/home/bserdar" file: "work.txt" fmt.Println(filepath.Split("/home/bserdar/projects/")) // dir: "/home/bserdar/projects/" file: ""
-
避免在您的代码中使用路径分隔符(
/
和\
)。使用filepath.Separator
,这是一个特定于平台的 rune 值。
创建临时目录和文件
有时,您需要创建唯一的目录名和文件名,通常用于临时数据。
如何做到这一点...
-
要在平台特定的默认临时文件目录下创建临时目录,请使用
os.MkdirTemp("",prefix)
:dir, err:=os.MkdirTemp("","tempdir") if err!=nil { // Handle error } // Clean up when done defer os.RemoveAll(dir) fmt.Println(dir) // Prints /tmp/example10287493
创建的名称是唯一的。如果有多个调用创建临时目录,每个都会生成一个唯一的名称。
-
要在特定目录下创建临时目录,请使用
os.MkdirTemp(dir,prefix)
:// Create a temporary directory under the current directory dir, err:=os.MkdirTemp(".","tempdir") if err!=nil { // Handle error } // Cleanup when done defer os.RemoveAll(dir)
-
要创建一个名称随机部分不是后缀的临时目录,请使用
*
。随机字符串将替换最后一个*
字符:dir, err:=os.MkdirTemp(".", "myapp.*.txt") if err!=nil { // Handle error } defer os.RemoveAll(dir) fmt.Println(dir) // Prints ./myapp.13984873.txt
-
要创建临时文件,请使用
os.CreateTemp
。创建一个唯一的文件并打开以供读写。创建的文件名可以通过返回的file.Name
值获得:file, err:=os.CreateTemp("","app.*.txt") if err!=nil { // Handle error } fmt.Println("Temp file", file.Name) // Cleanup when done defer os.Remove(file.Name) defer file.Close()
与 os.MkdirTemp
类似,如果文件名包含 *
,则在最后一个 *
字符处插入随机字符串。如果文件名不包含 *
,则随机字符串附加在名称的末尾。
读取目录
使用 os.ReadDir
列出或发现目录下的文件。
如何做到这一点...
-
调用
os.ReadDir
获取目录的内容。这返回按名称排序的目录条目:entries, err:=os.ReadDir(".") if err!=nil { // handle error } for _, entry:=range entries { // Name contains the file name only, not the directory name := entry.Name() if entry.IsDir() { // This is a directory } else { // This is not a directory. Does not mean it is a regular // file Can be a named pipe, device, etc. } }
你可能会注意到,如果你处理的是可能非常大的目录,
os.ReadDir
并不是你的最佳选择。它返回一个无界切片,并且它还花费时间进行排序。 -
对于性能和内存敏感的应用程序,打开目录并使用
File.ReadDir
读取它:// Open the directory dir, err:= os.Open("/tmp") if err!=nil { panic(err) } defer dir.Close() // Read directory entries unordered, 10 at a time for { entries, err:=dir.ReadDir(10) // Are we done reading if errors.Is(err, io.EOF) { break } if err!=nil { panic(err) } // There are at most 10 fileInfo entries for _,entry:=range entries { // Process the entry } }
-
以可移植的方式递归迭代目录条目,请使用
io.fs.WalkDir
。此函数无论在哪个平台上都使用"/"
作为路径分隔符。以下示例打印出/tmp
下的所有文件,跳过目录:err:=fs.WalkDir(os.DirFS("/"), "/tmp", func(path string,d fs.DirEntry,err error) error { if err!=nil { fmt.Println("Error during directory traversal", err) return err } if !d.IsDir() { // This is not a directory fmt.Println(filepath.Join(path,d)) } return nil })
-
要递归迭代目录条目,请使用
filepath.WalkDir
。此函数使用特定平台的路径分隔符。以下示例递归打印出/tmp
下的所有目录:err:=filepath.WalkDir("/tmp", func(path string,d fs.DirEntry,err error) error { if err!=nil { fmt.Println("Error during directory traversal", err) return err } if d.IsDir() { // This is a directory fmt.Println(filepath.Join(path,d), " directory") } return nil })
使用管道进行工作
如果你有一段期望读取器的代码和另一段期望写入器的代码,你可以使用 io.Pipe
将它们连接起来。
将期望读取器的代码与期望写入器的代码连接起来
这种用例的一个好例子是准备一个 HTTP POST
请求,它需要一个读取器。如果你已经有了所有数据,或者如果你已经有了读取器(例如 os.File
),你可以使用它。然而,如果数据是由一个接受写入器的函数产生的,请使用管道。
如何做到这一点...
管道是一个同步连接的读取器和写入器。也就是说,如果你向管道写入,必须有一个读取器并发地从中消费。所以请确保你将数据生产方(使用写入器的地方)放在与数据消费方(使用读取器的地方)不同的 goroutine 中。
-
使用
io.Pipe
创建管道读取器和管道写入器:pipeReader, pipeWriter := io.Pipe()
pipeReader
将读取写入到pipeWriter
的所有内容。 -
在一个 goroutine 中使用
pipeWriter
产生数据。当所有内容都写入后,关闭pipeWriter
:go func() { // Close the writer side, so the reader knows when it is done defer pipeWriter.Close() encoder:=json.NewEncoder(pipeWriter) if err:=encoder.Encode(payload); err!=nil { if errors.Is(err,io.ErrClosedPipe) { // The reader side terminated with error } else { // Handle error } } }()
-
在需要读取器的地方使用
pipeReader
。如果函数失败并且管道中的所有内容都无法消费,请关闭pipeReader
以使写入器能够终止:if _, err:= http.Post(serverURL, "application/json", pipeReader); err!=nil { // Close the reader, so the writing goroutine terminates pipeReader.Close() // Handle error }
在上面的例子中,编码 JSON 数据的 goroutine 将阻塞,直到 POST
请求建立连接并流式传输数据。如果在过程中出现错误,pipeReader.Close()
确保编码 JSON 数据的 goroutine 不会泄漏。
使用 TeeReader 拦截读取器
在管道中,tee 管道是一种具有 T 形的配件。它将流量分成两部分。TeeReader
的名字就来源于此。io.TeeReader(r io.Reader, w io.Writer) io.Reader
函数返回一个新的读取器,它从 r
读取的同时将读取到的数据写入 w
。这对于拦截通过读取器的数据非常有用。
如何做到这一点...
-
创建一个管道:
pipeReader, pipeWriter := io.Pipe()
-
从另一个读取器创建一个
TeeReader
,使用pipeWriter
作为接收数据的写入器:file, err:=os.Open(dataFile) if err!=nil { // Handle error } defer file.Close() tee := io.TeeReader(file, pipeWriter)
在这个阶段,从
tee
读取数据将同时从file
读取数据并将其写入pipeWriter
。 -
在另一个 goroutine 中使用
pipeReader
处理从原始读取器读取的数据:go func() { // Copy the file to stdout io.Copy(os.Stdout,pipeReader) }()
-
使用
TeeReader
读取数据:_,err:=http.Post(serverURL, "text/plain", tee) if err!=nil { // Make sure pipe is closed pipeReader.Close() }
注意,使用管道至少需要另一个 goroutine,其中涉及到向管道写入或从管道读取。如果发生错误,确保所有与管道一起工作的 goroutine 通过关闭管道的一端来终止。
第十五章:数据库
大多数应用程序至少需要与一种类型的数据库进行交互。SQL 数据库足够常见,以至于 Go 标准库提供了一个统一的方式来连接和使用它们。本章展示了你可以使用的某些模式,以与 SQL 包的标准库实现一起工作。
许多数据库在功能和查询语言方面都提供了非标准扩展。即使你使用标准库与数据库接口,你也应该始终检查特定供应商的数据库驱动程序,以了解潜在的限制、实现差异和支持的 SQL 语法。
在这里,提到 NoSQL 数据库可能会有所帮助。Go 标准库不提供 NoSQL 数据库包。这是因为,与 SQL 不同,大多数 NoSQL 数据库都有非标准的查询语言,这些语言是为特定数据库专门定制的。为特定工作负载构建的 NoSQL 数据库比通用 SQL 数据库表现要好得多。如果你正在使用此类数据库,请参阅其文档。然而,本章中提出的许多概念在一定程度上也适用于 NoSQL 数据库。
本章包含以下食谱:
-
连接到数据库
-
执行 SQL 语句
-
不使用显式事务执行 SQL 语句
-
使用事务执行 SQL 语句
-
在事务中执行预定义语句
-
从查询中获取值
-
动态构建 SQL 语句
-
构建
UPDATE
语句 -
构建
WHERE
子句
连接到数据库
你可以将数据库集成到你的应用程序中的两种方式:你可以使用数据库服务器或嵌入式数据库。让我们首先定义一下它们是什么。
数据库服务器作为一个独立进程在同一主机或不同主机上运行,但与你的应用程序无关。通常,你的应用程序通过网络连接连接到这个数据库服务器,因此你必须知道它的网络地址和端口号。通常有一个库需要导入到你的程序中,这是一个针对你使用的数据库服务器的“数据库驱动程序”。这个驱动程序通过管理连接、查询、事务等,为你的应用程序和数据库提供接口。
嵌入式数据库不是一个独立的进程。它作为库包含在你的应用程序中,并在相同的地址空间中运行。数据库驱动程序充当适配器,向应用程序提供一个标准接口(即使用 database/sql
包)。当使用嵌入式数据库时,你必须注意与其他进程共享的资源。许多嵌入式数据库不允许多个程序访问相同的基本数据。
在执行任何操作之前,你必须连接到数据库服务器(如 MySQL 或 PostgreSQL 服务器)或嵌入式数据库引擎(如 SQLite)。
小贴士
此页面包含 SQL 驱动程序的列表:go.dev/wiki/SQLDrivers
。
如何做到这一点...
找到你需要的数据库特定驱动程序。这个驱动程序可能由数据库供应商提供,或者作为一个开源项目发布。在main
包中导入这个数据库驱动程序。
你需要一个特定于驱动程序的驱动程序名称和连接字符串来连接到数据库服务器或嵌入式数据库引擎。如果你正在连接到数据库服务器,这个连接字符串通常包括主机/端口信息、认证信息和连接选项。如果是嵌入式数据库引擎,它可能包括文件名/目录信息。然后,你可以调用sql.Open
或使用特定于驱动程序的连接函数,该函数返回一个*sql.DB
。
数据库驱动程序可能会延迟实际连接到第一次数据库操作。也就是说,使用sql.Open
连接到数据库可能不会立即连接。为确保你已连接到数据库,请使用DB.Ping
。嵌入式数据库驱动程序通常不需要 ping。
以下是一个连接到 MySQL 数据库的示例:
package main
import (
"fmt"
"database/sql"
"context"
// Import the mysql driver
_ "github.com/go-sql-driver/mysql"
)
func main() {
// Use mysql driver name and driver specific connection string
db, err := sql.Open("mysql", "username:password@tcp(host:port)/
databaseName")
if err != nil {
panic(err.Error())
}
defer db.Close()
// Check if database connection succeeded, with 5 second timeout
ctx, cancel := context.WithTimeout(context.
Background(),5*time,Second)
defer cancel()
if err:=db.PingContext(ctx); err!=nil {
panic(err)
}
fmt.Println("Success!")
}
以下是一个使用本地文件连接到内存中 SQLite 数据库的示例:
package main
import (
"database/sql"
"fmt"
"os"
// Import the database driver
_ "github.com/mattn/go-sqlite3"
)
func main() {
// Open the sqlite database using the given local file ./database.
// db
db, err := sql.Open("sqlite3", "./database.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// You don't need to ping an embedded database
}
小贴士
注意使用空白标识符_
来导入数据库驱动程序。这意味着包仅为了其副作用而导入,在这种情况下,是注册数据库驱动的init()
函数。例如,在main
包中导入go-sqlite3
包会导致在go-sqlite3
中声明的init()
函数使用名称sqlite3
注册自己。
运行 SQL 语句
在获取*sql.DB
实例后,你可以运行 SQL 语句来修改或查询数据。这些查询只是 SQL 字符串,但 SQL 的样式在不同的数据库供应商之间有所不同。
不使用显式事务运行 SQL 语句
当与数据库交互时,一个重要的考虑因素是确定事务边界。如果你需要执行单个操作,例如插入一行或运行一个查询,你通常不需要显式创建事务。你可以执行一个将开始和结束事务的单个 SQL 语句。然而,如果你有多个 SQL 语句,这些语句应该作为一个原子单元运行或者根本不运行,你必须使用事务。
如何操作...
-
要运行 SQL 语句更新数据,请使用
DB.Exec
或DB.ExecContext
:result, err:=db.ExecContext(ctx,`UPDATE users SET user.last_login=? WHERE user_id=?",time.Now(), userId) if err!=nil { // Handle error } n, err:=result.RowsAffected() if err!=nil { // Handle error } if n!=1 { return errors.New("Cannot update last login time") }
要多次运行相同的语句但使用不同的值,请使用预编译语句。预编译语句通常将语句发送到数据库服务器,在那里它被解析和准备。然后,你可以简单地使用不同的参数运行这个解析后的语句,绕过数据库引擎的解析和优化阶段。
当你完成使用预编译语句时,你应该关闭它:
func AddUsers(db *sql.DB, users []User) error { stmt, err := db.Prepare(`INSERT INTO users (user_name,email) VALUES (?,?)`) if err!=nil { return err } // Close the prepared statement when done defer stmt.Close() for _,user:=range users { // Run the prepared statement with different arguments _, err := stmt.Exec(user.Name,user.Email) if err!=nil { return err } } return nil }
小贴士
在连接到数据库后,你可以创建预编译语句并在程序结束时使用它们。预编译语句可以从多个 goroutines 并发执行。
要运行返回结果的查询,请使用DB.Query
或DB.QueryContext
。要运行预期最多返回一行的查询,你可以使用DB.QueryRow
或DB.QueryRowContext
便利函数。
DB.Query
和DB.QueryContext
方法返回一个*sql.Rows
对象,它本质上是对查询结果的单向游标。这提供了一个接口,允许你在不将所有结果加载到内存的情况下处理大型结果集。数据库引擎通常分批返回结果,而*sql.Rows
对象允许你逐行遍历结果行,按需批量获取结果。
另一点需要记住的是,许多数据库引擎会延迟查询的实际执行,直到你开始获取结果。换句话说,仅仅因为你运行了一个查询,并不意味着该查询实际上被服务器评估。查询评估可能发生在你获取第一行结果时:
func GetUserNamesLoggedInAfter(db *sql.DB, after time.Time) ([]string,error) {
rows, err:=db.Query(`SELECT users.user_name FROM users WHERE
last_login > ?`, after)
if err!=nil {
return nil,err
}
defer rows.Close()
names:=make([]string,0)
for rows.Next() {
var name string
if err:=rows.Scan(&name); err!=nil {
return nil,err
}
names=append(names,name)
}
// Check if iteration produced any errors
if err:=rows.Err(); err!=nil {
return nil,err
}
return names,nil
}
如果预期的结果集最多只有一行(换句话说,你正在寻找一个可能存在也可能不存在的特定对象),你可以通过使用DB.QueryRow
或DB.QueryRowContext
来缩短上述模式。你可以通过检查返回的错误是否为sql.ErrNoRows
来确定操作是否找到了该行:
func GetUserByID(db *sql.DB, id string) (*User, error) {
var user User
err:=db.QueryRow(`SELECT user_id, user_name, last_login FROM
users WHERE user_id=?`,id).
Scan(&user.Id, &user.Name, &user.LastLogin)
if errors.Is(err,sql.ErrNoRows) {
return nil,nil
}
if err!=nil {
return nil,err
}
return &user,nil
}
永远不要在未经验证的情况下使用用户提供的值、从配置文件中读取的值或从 API 请求中接收的值来构建 SQL 语句。使用查询参数来避免 SQL 注入攻击。
使用事务运行 SQL 语句
如果你需要原子性地执行多个更新,你必须在一个事务中执行这些更新。在这种情况下,原子性意味着要么所有更新都成功完成,要么没有任何一个更新完成。
事务隔离级别决定了其他并发事务如何看到事务内执行的更新。你可以找到许多描述事务隔离级别的资源。在这里,我将提供一个总结,帮助你决定哪种隔离级别最适合你的用例:
-
sql.LevelReadUncommitted
:这是最低的事务隔离级别。一个事务可能看到另一个事务执行的未提交更改。另一个事务可能读取一些未提交的数据,并基于读取的内容执行业务逻辑。而这些未提交的数据可能会回滚,从而使业务逻辑无效。 -
sql.ReadCommitted
:一个事务只读取另一个事务执行的已提交更改。这意味着如果一个事务试图读取/写入另一个事务正在修改的数据,第一个事务必须等待第二个事务完成。然而,一旦一个事务在 ReadCommitted 隔离级别读取了数据,另一个事务可能改变它。 -
sql.RepeatableRead
:事务只读取另一个事务执行的已提交更改。此外,在可重复读隔离级别下,事务读取的值保证在事务提交或回滚之前保持不变。任何尝试修改可重复读事务读取的数据的其他事务都将等待直到可重复读事务结束。然而,此隔离级别不能防止其他事务向表中插入满足可重复读事务查询准则的行,因此使用范围查询查询同一表可能会得到不同的结果。 -
sql.Serializable
:这是最高的事务隔离级别。可序列化事务只读取已提交的更改,防止其他事务修改它所读取的数据,并防止其他事务插入/更新/删除与事务内执行的任何查询的准则相匹配的行。
随着事务隔离级别的提高,并发级别会降低。这也影响性能:较低的隔离级别更快。你必须仔细选择隔离级别:选择对操作安全的最低隔离级别。通常,如果你没有明确指定级别,将使用特定于驱动程序的默认隔离级别。
如何操作...
使用期望的隔离级别启动事务:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 1\. Start transaction
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err!=nil {
// Handle error
}
// 2\. Call rollback with defer, so in case of error, transaction
// rolls back
defer tx.Rollback()
确保事务要么提交要么回滚。你可以通过延迟调用tx.Rollback
来实现这一点。如果在函数返回之前没有提交事务,这将导致事务回滚。如果事务成功,你将提交事务。一旦事务被提交,延迟回滚将不会有任何效果。
使用事务执行数据库操作。所有使用*sql.Tx
方法执行的数据库操作都将在该事务内完成:
_, err:= tx.Exec(`UPDATE users SET user.last_login=? WHERE user_id=?",time.Now()`, userId)
if err!=nil {
// Do not commit, handle error
}
如果没有错误,提交事务:
tx.Commit()
小贴士
一些数据库驱动程序可能在由于约束违反(如唯一索引上的重复值)而无法完成查询时回滚并取消事务。请检查你的驱动程序文档,以查看它是否执行自动回滚。
在事务内运行准备好的语句
可以通过调用事务结构体的*sql.Tx.Prepare
或*sql.Tx.PrepareContext
方法来准备一个语句。这两个方法返回的准备语句仅与该事务相关联。也就是说,你不能使用一个事务准备一个语句,然后用于另一个事务。
如何操作...
你有两种方法可以在事务中使用准备好的语句。
第一种方法是使用由*DB
准备的语句:
-
使用
DB.Prepare
或DB.PrepareContext
准备语句。 -
获取特定于事务的事务副本:
txStmt := tx.Stmt(stmt)
-
使用新语句运行操作。
-
第二种方法是使用由
*Tx
准备的语句: -
使用
Tx.Prepare
或Tx.PrepareContext
准备语句。 -
使用此语句运行操作。
从查询中获取值
SQL 查询返回 *sql.Rows
,或者如果你使用 QueryRow
方法,它返回 *sql.Row
。接下来你必须做的事情是遍历行并将值扫描到 Go 变量中。
如何做到这一点...
运行 Query
或 QueryContext
意味着你期望从查询中获取零行或多行。因此,它返回 *sql.Rows
。
对于本节中的代码片段,我们使用以下 User
结构体:
type User struct {
ID uint64
Name string
LastLogin time.Time
AvatarURL string
}
这与以下表定义一起使用:
CREATE TABLE users (
user_id int not null,
user_name varchar(32) not null,
last_login timestamp null,
avatar_url varchar(128) null
)
遍历行并处理每个单独的结果行。在以下示例中,查询返回零行或多行。对 rows.Next
的第一次调用将移动到结果集的第一行,对 rows.Next
的后续调用将移动到下一行。这允许使用 for
语句,如下面的示例所示:
rows, err := db.Query(`SELECT user_id, user_name, last_login, avatar_url FROM users WHERE last_login > ?`, after)
if err!=nil {
return err
}
// Close the rows object when done
defer rows.Close()
for rows.Next() {
// Retrieve data from this row
}
对于每一行,使用 Scan
将数据复制到 Go 变量中:
users:=make([]User,0)
for rows.Next() {
// Retrieve data from this row
var user User
// avatar column is nullable, so we pass a *string instead of string
var avatarURL *string
if err:=rows.Scan(
&user.ID,
&user.Name,
&user.LastLogin,
&avatarURL);err!=nil {
return err
}
// avatar URL can be nil in the db
if avatarURL!=nil {
user.AvatarURL=*avatarURL
}
users=append(users,user)
}
Scan
的参数顺序必须与从 SELECT
语句检索到的列的顺序相匹配。也就是说,第一个参数 &user.ID
对应于 user_id
列;下一个参数 &user.Name
对应于 user_name
列;依此类推。因此,Scan
的参数数量必须等于检索到的列数。
SQL 驱动程序将数据库原生类型转换为 Go 数据类型。如果转换导致数据或精度丢失,驱动程序通常会返回一个错误。例如,如果你尝试将大整数值扫描到 int16
变量中,并且转换无法表示该值,Scan
将返回一个错误。
如果数据库列定义为可空(在这个例子中,avatar_url varchar(128) NULL
),并且如果从数据库检索到的数据值为空,那么 Go 值必须能够容纳空值。例如,如果我们使用 &user.AvatarURL
在 Scan
中,并且数据库中的值是空,那么 Scan
将返回一个错误,抱怨空值不能扫描到字符串。为了防止此类错误,我们使用了 *string
而不是 string
。一般来说,如果底层数据库列是可空的,你应该在 Scan
中为该列使用指针。
在获取所有行后检查错误:
// Check if there was an error during iteration
if err:=rows.Err(); err!=nil {
return err
}
关闭 *sql.Rows
。这通常通过之前的 defer rows.Close()
语句来完成。
运行 QueryRow
或 QueryRowContext
意味着你期望从查询中获取零行或一行。然后,返回一个 *sql.Row
对象,你可以使用它来扫描值并检查错误。
运行 QueryRow
或 QueryRowContext
,并按前面描述的方式扫描值:
var user User
row:=db.QueryRow(`SELECT user_id, user_name, last_login, avatar_url FROM users WHERE user_id = ?`, id)
if err:=row.Scan(
&user.ID,
&user.Name,
&user.LastLogin,
&avatarURL);err!=nil {
return err
}
return user
如果查询执行期间发生错误,它将通过行返回。
动态构建 SQL 语句
在任何使用 SQL 数据库的非平凡应用程序中,你将不得不动态构建 SQL 语句。这在以下情况下变得必要:
-
使用灵活的搜索条件,这些条件可能根据用户输入或请求而变化
-
根据请求的字段可选地连接多个表
-
选择性地更新列子集
-
插入可变数量的列
本节展示了构建 SQL 语句的几种常见方法,适用于不同的用例。
小贴士
有许多开源查询构建器包。在编写自己的包之前,您可能想要探索这些包。
构建 UPDATE 语句
如果您需要更新表中的一定数量的列而不修改其他列,您可以遵循本节中给出的模式。
如何操作...
-
执行 UPDATE 语句需要两份数据:
-
要更新的数据:描述此类信息的一种常见方式是使用指针来表示更新的值。考虑以下示例:
type UpdateUserRequest struct { Name *string LastLogin *time.Time AvatarURL *string }
在这里,只有当相应的字段不为空时,列才会被更新。例如,在以下
UpdateUserRequest
实例中,只有LastLogin
和AvatarURL
字段将被更新:now:=time.Now() urlString:="https://example.org/avatar.jpg" update:=UpdateUserRequest { LastLogin: &now, AvatarURL: &urlString, }
- 记录定位器:这通常是需要更新的行的唯一标识符。然而,也常见使用一个查询来定位多个记录。
根据这些信息,编写更新函数的常见方式如下:
func UpdateUser(ctx context.Context, db *sql.DB, userId uint64, req *UpdateUserRequest) error { ... }
在前面的代码中,记录定位器是
userId
。-
使用
strings.Builder
构建语句,同时在一个切片中跟踪查询参数:query:=strings.Builder{} args:=make([]interface{},0) // Start building the query. Be mindful of spaces to separate // query clauses query.WriteString("UPDATE users SET ")
-
-
为每个需要更新的列创建一个
SET
子句:if req.Name != nil { args=append(args,*req.Name) query.WriteString("user_name=?") } if req.LastLogin!=nil { if len(args)>0 { query.WriteString(",") } args=append(args,*req.LastLogin) query.WriteString("last_login=?") } if req.AvatarURL!=nil { if len(args)>0 { query.WriteString(",") } args=append(args,*req.AvatarURL) query.WriteString("avatar_url=?") }
-
添加
WHERE
子句:query.WriteString(" WHERE user_id=?") args=append(args,userId)
-
执行该语句:
_,err:=db.ExecContext(ctx,query.String(),args...)
并非所有数据库驱动程序都使用?
作为查询参数。例如,Postgres 的一个驱动程序使用$n
,其中n
是从 1 开始的数字,表示参数的顺序。对于此类驱动程序,算法略有不同:
if req.Name != nil {
args=append(args,*req.Name)
fmt.Fprintf(&query,"user_name=$%d",len(args))
}
if req.LastLogin!=nil {
if len(args)>0 {
query.WriteString(",")
}
args=append(args,*req.LastLogin)
fmt.Fprintf(&query,"last_login=$%d",len(args))
}
if req.AvatarURL!=nil {
if len(args)>0 {
query.WriteString(",")
}
args=append(args,*req.AvatarURL)
fmt.Fprintf(&query,"avatar_url=$%d",len(args))
}
构建 WHERE 子句
WHERE
子句可以是SELECT
、UPDATE
或DELETE
语句的一部分。在这里,我将展示一个SELECT
示例,您可以将此扩展到UPDATE
和DELETE
。请注意,UPDATE
语句将包括更新列值的参数。
如何操作...
此示例显示了在搜索条件中使用 AND 的情况:
-
您需要一个数据结构,以确定在
WHERE
子句中包含哪些列。以下是一个示例:type UserSearchRequest struct { Ids []uint64 Name *string LoggedInBefore *time.Time LoggedInAfter *time.Time AvatarURL *string }
使用此结构,搜索函数如下所示:
func SearchUsers(ctx context.Context, db *sql.DB, req *UserSearchRequest) ([]User,error) { ... }
-
使用
strings.Builder
构建语句部分,同时在一个切片中跟踪查询参数:query:=strings.Builder{} where:= strings.Builder{} args:=make([]interface{},0) // Start building the query. Be mindful of spaces to separate // query clauses query.WriteString("SELECT user_id, user_name, last_login, avatar_url FROM users ")
-
为每个搜索项构建谓词:
if len(req.Ids)>0 { // Add this to the WHERE clause with an AND if where.Len()>0 { where.WriteString(" AND ") } // Build an IN clause. // We have to add one argument for each id where.WriteString("user_id IN (") for i,id:=range req.Ids { if i>0 { where.WriteString(",") } args=append(args,id) where.WriteString("?") } where.WriteString(")") } if req.Name!=nil { if where.Len()>0 { where.WriteString(" AND ") } args=append(args,*req.Name) where.WriteString("user_name=?") } if req.LoggedInBefore!=nil { if where.Len()>0 { where.WriteString(" AND ") } args=append(args,*req.LoggedInBefore) where.WriteString("last_login<?") } if req.LoggedInAfter!=nil { if where.Len()>0 { where.WriteString(" AND ") } args=append(args,*req.LoggedInAfter) where.WriteString("last_login>?") } if req.AvatarURL!=nil { if where.Len()>0 { where.WriteString(" AND ") } args=append(args,*req.AvatarURL) where.WriteString("avatar_url=?") }
-
构建并运行查询:
if where.Len()>0 { query.WriteString(" WHERE ") query.WriteString(where.String()) } rows, err:= db.QueryContext(ctx,query.String(), args...)
再次强调,并非所有数据库驱动程序都使用?
占位符。如果您的数据库驱动程序是这些之一,请参阅上一节以获取替代方案。
第十六章:日志记录
从程序中打印日志消息可以是故障排除的重要工具。日志消息告诉您在任何给定时刻正在发生什么,并在出现问题提供所需上下文信息。Go 标准库提供了方便的包来生成和管理程序中的日志消息。在这里,我们将探讨使用log
包,它可以用来生成文本消息,以及slog
包,它可以用来从程序中生成结构化日志消息。
本章包含以下食谱:
-
使用标准日志记录器
-
编写日志消息
-
控制格式
-
更改日志记录位置
-
-
使用结构化日志记录器
-
使用全局日志记录器进行日志记录
-
使用不同级别编写结构化日志
-
在运行时更改日志级别
-
使用具有附加属性的日志记录器
-
更改日志记录位置
-
从上下文中添加日志信息
-
使用标准日志记录器
标准库日志记录器定义在log
包中。它是一个简单的日志库,可以用来打印格式化的日志消息,显示程序的进度。对于大多数实际用途,标准库日志记录器功能过于有限,但它可以是一个有用的工具,对于概念验证和较小的程序,它需要最少的设置。对于任何非平凡项目,请使用结构化日志记录器log/slog
包。
编写日志消息
标准日志记录器是一个简单的日志实现,用于打印诊断消息。它不提供结构化输出或多个日志级别,但对于日志消息面向最终用户或开发者的程序来说可能很有用。
如何做到这一点...
您可以使用默认日志记录器来打印日志消息:
log.Println("This is a log message similar to fmt.Println")
log.Printf("This is a log message similar to fmt.Printf")
这里是输出:
2024/09/17 23:05:26 This is a log message similar to fmt.Println
2024/09/17 23:05:26 This is a log message similar to fmt.Printf
上述函数使用log.Logger
的单例实例,可以通过log.Default()
获取。换句话说,调用log.Println
相当于调用log.Default().Println
。
您还可以创建一个新的日志记录器,配置它,并将其传递出去:
logger := log.New(os.Stderr, "", log.LstdFlags)
logger.Println("This is a log message written to stderr")
这里是输出:
2024/09/17 23:10:34 This is a log message written to stderr
除了log.Println
和log.Printf
之外,您还可以使用log.Fatal
或log.Panic
来停止程序:
log.Fatal("Fatal error")
这将使程序以退出代码1
终止并输出以下内容:
2024/09/17 23:05:26 Fatal error
我们可以通过以下内容观察到类似的情况:
log.Panic("Fatal error")
这将引发恐慌并生成以下输出:
2024/09/17 23:05:26 Fatal error
panic: Fatal error
goroutine 1 [running]:
log.Panic({0xc000104f30?, 0xc00007c060?, 0x556310?})
/usr/local/go-faketime/src/log/log.go:432 +0x5a
main.main()
/tmp/sandbox255937470/prog.go:8 +0x38
控制格式
您可以使用位标志来控制日志记录器的输出格式。您还可以为后续的日志消息定义一个前缀。
如何做到这一点...
您可以创建一个新的日志记录器,并使用以下方式为其设置前缀:
logger := log.New(log.Writer(), "prefix: ", log.LstdFlags)
logger.Println("This is a log message with a prefix")
这将输出以下内容:
prefix: 2024/09/17 23:10:34 This is a log message with a prefix
您还可以设置现有日志记录器的前缀:
logger.SetPrefix("newPrefix")
logger.Println("This is a log message with the new prefix")
这里是输出:
newPrefix: 2024/09/17 23:10:34 This is a log message with the new prefix
输出字段及其打印方式由标志控制。log.LstdFlags
告诉日志记录器日志还应包含日期和时间。
log.Lshortfile
打印出文件名和行号,显示日志语句的位置:
logger.SetFlags(log.LstdFlags | log.Lshortfile)
logger.Println("This is a log message with a prefix and file name")
这将产生以下输出:
prefix: 2024/09/17 23:10:34 main.go:17: This is a log message with a prefix and file name
log.Llongfile
打印出完整路径:
logger.SetFlags(log.LstdFlags | log.Llongfile)
logger.Println("This is a log message with a prefix and long file name")
这里是输出:
prefix: 2024/09/17 23:10:34 /home/github.com/PacktPublishing/Go-Recipes-for-Developers/blob/main/src/chp16/stdlogger/main.go:19: This is a log message with a prefix and long file name
您可以使用位运算符 |
或操作符组合多个标志。log.Lmsgprefix
将前缀字符串(如果存在)移动到消息的开始,而不是日志行的开始:
logger.SetFlags(log.LstdFlags | log.Lshortfile | log.Lmsgprefix)
logger.Println("This is a log message with a prefix moved to the beginning of the message
这里是输出结果:
2024/09/17 23:10:34 main.go:21: prefix: This is a log message with a prefix moved to the beginning of the message
以下标志会以 UTC 时间打印时间和日期,以及短文件名:
logger.SetFlags(log.LstdFlags | log.Lshortfile | log.LUTC)
logger.Println("This is a log message with with UTC time") ```
```go
This outputs the following:
前缀:2024/09/18 05:10:34 main.go:23: 这是一条带有 UTC 时间的日志消息
## Changing where to log
By default, the logging output goes to standard error (`os.Stderr`), but it can be changed without affecting the logging directives.
### How to do it...
You can create a logger with a given output using `log.NewLogger`. The following example creates `logger` to print its output to standard error:
logger := log.New(os.Stderr, "", log.LstdFlags)
You can then change the logging target using `Logger.SetOutput`:
output, err := os.Create("log.txt")
if err != nil {
log.Fatal(err)
}
defer output.Close()
logger.SetOutput(output)
logger.Println("这是要记录到 log.txt 的日志消息")
logger.SetOutput(os.Stderr)
logger.Println("要记录到 log.txt 的消息已写入")
Use `io.Discard` as the log output to stop logging:
logger.SetOutput(io.Discard)
logger.Println("这条消息不会被记录")
# Using the structured logger
Since the standard logger has limited practical use, many third-party logging libraries were developed by the community. Some of the patterns that emerged from these libraries emphasized structured logging and performance. The structured logging package was added to the standard library with these usage patterns in mind. The `log` package is still a useful tool for development as it provides a simple interface for developers and the users of the program, but the `log/slog` package is a production quality library that enables automated log analysis tools while providing a simple-to-use and flexible interface.
## Logging using the global logger
Similar to the `log` package, there is a global structured logger accessible via the `slog.Default()` function. You can simply configure a global logger and use that in your program.
Tip
It is advisable to pass an instance of a logger around for any nontrivial project. The logging requirements may change from environment to environment, so having a dedicated logger helps.
### How to do it...
Use `slog` logging functions to write logs:
slog.Debug("这是一条调试信息")
slog.Info("这是一条包含整数字段的 info 信息", "arg", 42)
slog.Info("这是一条包含整数字段的 info 信息", slog.Int("arg",42))
You cannot modify the settings of the default logger, but you can create a new one and set it as the default. The following example shows how you can set a JSON logger as the default logger:
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
级别:slog.LevelDebug,
},
))
slog.SetDefault(logger)
Tip
`slog.SetDefault()` also sets the `log` package default logger, so the `log` package functions call the `slog` functions. Use `slog.SetLogLoggerLevel` to set the level of the log package messages.
## Writing structured logs using different levels
The structured logger allows you to log messages at different levels. For instance, you can log detailed messages at the `slog.LevelDebug` level, warning messages at the `slog.LevelWarn` level, and error messages at the `slog.LevelError` level, and set the logging level of your program from a configuration or command line argument.
### How to do it...
1. Create a `slog.Handler` with `slog.HandlerOptions.Level` set to the desired level. The following example creates a text log handler that prints every log message as a separate line of text. It uses `os.Stderr` as the output, and the logging level is set to `slog.LevelDebug`:
```
handler:= slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
级别:slog.LevelDebug,
})
```go
2. Create a logger using the handler:
```
logger := slog.New(handler)
```go
3. Use the logger to create messages at different levels. Only those messages that are equal to or above the level determined by the handler options will be printed to the output:
```
logger.Debug("这是一条调试信息")
logger.Info("这是一条包含整数字段的 info 信息", "arg", 42)
logger.Warn("这是一条包含字符串参数的警告信息", "arg", "foo")
```go
4. If logging performance is a concern, you can check whether a specific logging level is enabled:
```
// 检查是否为特定级别启用了日志记录
if logger.Enabled(context.Background(), slog.LevelError) {
logger.Error("这是一条错误信息", slog.String("arg", "foo"))
}
```go
## Changing log level at runtime
Most applications set up a logger at the beginning of the application using a command line option or a configuration file and do not change logging at runtime. However, the ability to set log levels at runtime can be an invaluable tool to identify production problems. You can set the debug level of a running server to `slog.LevelDebug`, record logs to find out about a troubling behavior, and set it back to its original level. This recipe shows how you can do this.
### How to do it...
1. Use a `slog.LevelVar` to wrap a log level value (this is called **boxing** a variable):
```
level = new(slog.LevelVar)
```go
2. Set the initial log level:
```
level.Set(slog.LevelError)
```go
3. Create a handler using the `boxed` level:
```
handler:=slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
级别:level,
})
```go
4. Create a logger using the handler:
```
logger:=slog.New(handler)
```go
5. Change `level` to control the log level:
```
level.Set(slog.LevelDebug)
// 现在所有日志记录器都将开始打印调试级别的消息
```go
## Using loggers with additional attributes
Let’s say you have a server where you handle requests using functions that are shared among multiple request handlers. When the request is received, you can log which handler is running, but when you pass that logger to the common functions, they lose that information. They don’t know which request handler called. Instead of passing this information to those common functions (after all, they don’t really need that information), you can decorate a logger with such information and pass the logger.
### How to do it...
1. Create a new logger using `Logger.With`, and attach additional attributes:
```
func HandlerA(w http.ResponseWriter, req *http.Request) {
reqId:=getRequestIdFromRequest(req)
// 创建一个新的具有附加属性的日志记录器
logger:=slog.With(slog.String("handler", "a"),slog.
String("reqId",reqId))
logger.Debug("开始处理请求")
defer logger.Debug("请求完成")
```go
2. Use this logger to log messages:
```
HandleRequest(logger, w,req)
```go
This will output a log message that looks like this:
```
{"time":"2024-09-19T14:49:42.064787730-06:00","level":"DEBUG","msg":"开始处理请求","handler":"a","reqId":"123"}
{"time":"2024-09-19T14:49:42.308187758-06:00","level":"DEBUG","msg":"这是一条调试信息","handler":"a","reqId":"123"}
{"time":"2024-09-19T14:49:42.945674637-06:00","level":"DEBUG","msg":"请求完成","handler":"a","reqId":"123"}
```go
## Changing where to log
The default logger writes to `os.Stderr`, and similar to the `log` package, this can be changed when you create the logger.
### How to do it...
The logger output is determined by the `slog.Handler`. The following example creates `logger` to print its output to standard error:
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
级别:slog.LevelDebug,
}))
Unlike the `log` package, you cannot change where to log after creating a logger, unless you write your own handler.
## Adding logging information from the context
Often, the information you need to log is available in the context. Every `slog` logging function has two variants, one with context and one without. If you use the variants with context, you can write a handler that can extract information from that context containing information from the call site.
### How to do it...
Create a new handler, potentially wrapping an existing one. The following code snippet shows a handler that will extract an `id` from the context by wrapping a `slog.Handler`:
type ContextIDHandler struct {
slog.Handler
}
Define the `Handle` method. Extract information from the context, modify the log record, and pass it to the wrapped handler:
func (h ContextIDHandler) Handle(ctx context.Context, r slog.Record) error {
// 如果上下文有一个字符串 id,检索它并将其添加到
// 记录
if id, ok := ctx.Value("id").(string); ok {
r.Add(slog.String("id", id))
}
return h.Handler.Handle(ctx, r)
}
Use the logging functions that take `context.Context`:
func Handler(w http.ResponseWriter, req *http.Request) {
logger.Debug(req.Context(),"处理器启动")
...
This will add the `id` from the request context to the log message if there is one:
{"时间":"2024-09-19T15:02:12.163787730-06:00","级别":"DEBUG","信息":"处理器启动","ID":"123"}
# 第十七章:测试、基准测试和性能分析
为你的代码编写测试和基准测试将帮助你以多种方式。在开发过程中,测试确保你正在开发的功能正常工作,并且在开发工作中不会破坏现有功能。基准测试确保你的程序保持在某些资源和时间限制内。在开发完成后,相同的测试和基准测试将确保任何维护工作(错误修复、功能增强等)不会在现有功能中引入错误。因此,你应该将编写测试和基准测试视为核心开发活动,并一起开发你的程序及其测试。
测试应关注在一切正常时测试预期的行为(“正常路径测试”)以及当事情失败时的行为。它不应专注于测试所有可能的执行路径。旨在测试所有可能实现选择的测试很快就会比程序本身更难维护。你应该在实用性和测试覆盖率之间找到平衡。
本节展示了处理几个常见测试和基准测试场景的惯用方法。这些是本章涵盖的主题:
+ 与单元测试一起工作
+ 编写单元测试
+ 运行单元测试
+ 测试中的日志记录
+ 跳过测试
+ 测试 HTTP 服务器
+ 测试 HTTP 处理器
+ 检查测试覆盖率
+ 基准测试
+ 编写基准测试
+ 编写具有不同输入大小的多个基准测试
+ 运行基准测试
+ 性能分析
# 与单元测试一起工作
我们将处理一个示例函数,该函数按升序或降序对`time.Time`值进行排序,如下所示:
```go
package sort
import (
"sort"
"time"
)
// Sort times in ascending or descending order
func SortTimes(input []time.Time, asc bool) []time.Time {
output := make([]time.Time, len(input))
copy(output, input)
if asc {
sort.Slice(output, func(i, j int) bool {
return output[i].Before(output[j])
})
return output
}
sort.Slice(output, func(i, j int) bool {
return output[j].Before(output[i])
})
return output
}
我们将使用 Go 构建系统和标准库提供的内置测试工具。为此,假设我们将前面的函数存储在一个名为sort.go
的文件中。那么,这个函数的单元测试将在这个sort.go
文件所在的目录中的sort_test.go
文件中。Go 构建系统将识别以_test.go
结尾的源文件为单元测试,并将它们排除在常规构建之外。
编写一个单元测试
单元测试理想情况下测试单个单元(一个函数、一组相互关联的函数或类型的成员函数)是否按预期行为。
如何做...
-
创建具有
_test.go
后缀的单元测试文件。对于sort.go
,我们创建sort_test.go
。以_test.go
结尾的文件将不会被常规构建包含:package sort
小贴士
你也可以在以_test
结尾的单独测试包中编写测试。在这个例子中,它变成了package sort_test
。在单独的包中编写测试允许你从外部测试包中的函数,因为你将无法访问正在测试的包中的未导出名称。你将不得不导入正在测试的包。
-
Go 测试系统将运行遵循
Test<Feature>(*testing.T)
模式的函数。声明一个符合此模式的测试函数,并编写一个测试该行为的单元测试:func TestSortTimesAscending(t *testing.T) { // 2.a Prepare input data input := []time.Time{ time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local), time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local), time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local), time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local), time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local), } // 2.b Call the function under test output := SortTimes(input, true) // 2.c Make sure the output is what is expected for i := 1; i < len(output); i++ { if !output[i-1].Before(output[i]) { t.Error("Wrong order") } } }
-
测试函数的布局通常遵循以下结构:
-
准备输入数据和测试函数运行所需的任何必要环境
-
使用必要的输入调用测试函数
-
确保测试函数返回了正确的结果或表现如预期。
-
-
如果测试检测到错误,使用
t.Error
系列函数通知测试系统测试失败。
运行单元测试
使用 Go 构建系统工具运行单元测试。
如何操作...
-
要运行当前包中的所有单元测试,输入以下内容:
go test PASS ok github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort 0.001s
-
要运行包中的所有单元测试,输入以下内容:
go test <packageName> go test ./<folder> go test github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort
或者,您可以输入以下内容:
go test ./sorting
-
要递归地运行模块中所有包的所有单元测试,输入以下内容:
go test ./...
从模块的根目录执行此操作。
-
要运行当前包中的单个测试,输入以下内容:
go test -run TestSortTimesAscending go test -run ^TestSortTimesAscending$
在这里,
^
表示正则表达式中的字符串开头符号,$
表示字符串结尾符号。例如,以下将运行所有以
Ascending
结尾的测试:go test -run Ascending$
测试中的日志记录
通常,对于测试来说,额外的日志功能很有用,可以显示关键变量的状态,尤其是在发生失败时。默认情况下,如果测试通过,Go 测试执行器不会打印任何日志信息,但如果测试失败,则日志信息也会包含在输出中。
如何操作...
-
使用
testing.T.Log
和testing.T.Logf
函数在测试中记录日志信息:func TestSortTimeAscending(t *testing.T) { ... t.Logf("Input: %v",input) output:=SortTimes(input,true) t.Logf("Output: %v", output)
-
运行测试。如果测试通过,则不会打印日志信息。如果测试失败,则打印日志。
要使用日志运行测试,使用
-v
标志:$ go test -v === RUN TestSortTimesAscending sort_test.go:17: Input: [2023-02-01 12:08:37 -0700 MST 2021-05-06 09:48:11 -0600 MDT 2022-11-13 17:13:54 -0700 MST 2022-06-23 22:29:28 -0600 MDT 2023-03-17 04:05:09 -0600 MDT] sort_test.go:19: Output: [2021-05-06 09:48:11 -0600 MDT 2022-06-23 22:29:28 -0600 MDT 2022-11-13 17:13:54 -0700 MST 2023-02-01 12:08:37 -0700 MST 2023-03-17 04:05:09 -0600 MDT] --- PASS: TestSortTimesAscending (0.00s)
跳过测试
您可以根据输入标志跳过某些测试。此功能允许您快速测试,其中只运行测试子集,以及全面测试,其中运行所有测试。
如何操作...
-
检查
testing.Short()
标志以确定应从短测试运行中排除的测试:func TestService(t *testing.T) { if testing.Short() { t.Skip("Service") } ... }
-
使用
test.short
标志运行测试:$ go test -test.short -v === RUN TestService service_test.go:15: Service --- SKIP: TestService (0.00s) === RUN TestHandler --- PASS: TestHandler (0.00s) PASS
测试 HTTP 服务器
net/http/httptest
包通过提供快速创建测试 HTTP 服务器的设施来补充 testing
包。
对于本节,假设我们通过将其转换为 HTTP 服务来扩展我们的排序函数,如下所示:
package service
import (
"encoding/json"
"io"
"net/http"
"time"
"github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/
sorting/sort"
)
// Common handler function for parsing the input, sorting, and
// preparing the output
func HandleSort(w http.ResponseWriter, req *http.Request, ascending bool) {
var input []time.Time
data, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := json.Unmarshal(data, &input); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
output := sort.SortTimes(input, ascending)
data, err = json.Marshal(output)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// Prepares a multiplexer that handles POST /sort/asc and POST /sort/
// desc endpoints
func GetServeMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("POST /sort/asc", func(w http.ResponseWriter, req
*http.Request) {
HandleSort(w, req, true)
})
mux.HandleFunc("POST /sort/desc", func(w http.ResponseWriter, req
*http.Request) {
HandleSort(w, req, false)
})
return mux
}
GetServeMux
函数准备一个请求多路复用器,该复用器处理 POST /sort/asc
和 POST /sort/desc
HTTP 端点,分别用于升序和降序排序请求。输入是时间值的 JSON 数组。处理程序返回排序后的 JSON 数组。
如何操作...
-
使用包含对测试服务器支持的
net/http/httptest
包:import ( "net/http/httptest" "testing" ... )
-
在测试函数中,创建一个处理程序或多路复用器,并使用它来创建测试服务器。确保测试结束时服务器关闭 -- 使用
defer server.Close()
:func TestService(t *testing.T) { mux := GetServeMux() server := httptest.NewServer(mux) defer server.Close()
-
使用
server.URL
调用服务器。这是由httptest.NewServer
函数初始化的,使用未分配的本地端口。在以下示例中,我们向服务器发送无效输入以验证服务器是否返回错误:rsp, err := http.Post(server.URL+"/sort/asc", "application/json", strings.NewReader("test")) if err != nil { t.Error(err) return } // Must return http error if rsp.StatusCode/100 == 2 { t.Errorf("Error was expected") return }
注意,
http.Post
函数不会返回错误。http.Post
的错误意味着POST
操作失败。在这种情况下,POST
操作是成功的,但返回了 HTTP 错误状态。 -
你可以向服务器发出多个调用以测试不同的输入并检查输出:
data, err := json.Marshal([]time.Time{ time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local), time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local), time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local), time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local), time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local), )) if err != nil { t.Error(err) return } rsp, err = http.Post(server.URL+"/sort/asc", "application/json", bytes.NewReader(data)) if err != nil { t.Error(err) return } defer rsp.Body.Close() if rsp.StatusCode != 200 { t.Errorf("Expected status code 200, got %d", rsp.StatusCode) return } var output []time.Time if err := json.NewDecoder(rsp.Body).Decode(&output); err != nil { t.Error(err) return } for i := 1; i < len(output); i++ { if !output[i-1].Before(output[i]) { t.Errorf("Wrong order") } }
测试 HTTP 处理器
net/http/httptest
包还包含 ResponseRecorder
,它可以作为 http.ResponseWriter
用于 HTTP 处理器,以测试单个处理器而不创建服务器。
如何做...
-
创建
ResponseRecorder
:func TestHandler(t *testing.T) { w := httptest.NewRecorder()
-
调用处理器,传递响应记录器而不是
http.ResponseWriter
:data, err := json.Marshal([]time.Time{ time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local), time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local), time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local), time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local), time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local), }) if err != nil { t.Error(err) return } req, _ := http.NewRequest("POST", "localhost/sort/asc", bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") HandleSort(w, req, true)
-
响应记录器存储由处理器构建的 HTTP 响应。验证响应是否正确:
if w.Result().StatusCode != 200 { t.Errorf("Expecting HTTP 200, got %d", w.Result().StatusCode) return } var output []time.Time if err := json.NewDecoder(w.Result().Body).Decode(&output); err != nil { t.Error(err) return } for i := 1; i < len(output); i++ { if !output[i-1].Before(output[i]) { t.Errorf("Wrong order") } }
检查测试覆盖率
测试覆盖率报告显示哪些源代码行被测试覆盖。
如何做...
-
要快速获取覆盖率结果,使用
cover
标志运行测试:$ go test -cover PASS coverage: 76.2% of statements
-
要将测试覆盖率配置文件写入到单独的文件中,以便你可以获取详细的报告,给测试运行指定一个覆盖率配置文件名:
$ go test -coverprofile=cover.out PASS coverage: 76.2% of statements $ go tool cover -html=cover.out
此命令打开浏览器并允许你看到哪些行被测试覆盖。
基准测试
单元测试检查正确性,而基准测试检查性能和内存使用。
编写基准测试
与单元测试类似,基准测试存储在 _test.go
文件中,但这些函数以 Benchmark
开头而不是 Test
。基准测试给定一个数字 N
,其中你重复相同的操作 N
次同时运行时测量性能。
如何做...
-
在
_test.go
文件中创建一个基准测试函数。以下示例在sort_test.go
文件中:func BenchmarkSortAscending(b *testing.B) {
-
在基准测试循环之前进行设置,否则你将基准测试设置代码以及实际算法:
input := []time.Time{ time.Date(2023, 2, 1, 12, 8, 37, 0, time.Local), time.Date(2021, 5, 6, 9, 48, 11, 0, time.Local), time.Date(2022, 11, 13, 17, 13, 54, 0, time.Local), time.Date(2022, 6, 23, 22, 29, 28, 0, time.Local), time.Date(2023, 3, 17, 4, 5, 9, 0, time.Local), }
-
编写一个迭代
b.N
次的for
循环并执行将被基准测试的操作:for i := 0; i < b.N; i++ { SortTimes(input, true) }
小贴士
在基准测试循环中避免记录或打印数据。
编写具有不同输入大小的多个基准测试
你通常想看到你的算法在不同输入大小下的行为。Go 测试框架只提供了基准测试应该运行多少次,而不是使用什么输入大小。使用以下模式来练习不同的输入大小。
如何做...
-
定义一个未导出的参数化基准测试函数,该函数接受输入大小信息或不同大小的输入。以下示例获取项目数量和排序方向作为参数,并在执行基准测试之前创建一个给定大小的随机打乱输入切片:
func benchmarkSort(b *testing.B, nItems int, asc bool) { input := make([]time.Time, nItems) t := time.Now().UnixNano() for i := 0; i < nItems; i++ { input[i] = time.Unix(0, t-int64(i)) } rand.Shuffle(len(input), func(i, j int) { input[i], input[j] = input[j], input[i] }) for i := 0; i < b.N; i++ { SortTimes(input, asc) } }
-
通过调用公共基准测试并使用不同的值来定义导出的基准测试函数:
func BenchmarkSort1000Ascending(b *testing.B) { benchmarkSort(b, 1000, true) } func BenchmarkSort100Ascending(b *testing.B) { benchmarkSort(b, 100, true) } func BenchmarkSort10Ascending(b *testing.B) { benchmarkSort(b, 10, true) } func BenchmarkSort1000Descending(b *testing.B) { benchmarkSort(b, 1000, false) } func BenchmarkSort100Descending(b *testing.B) { benchmarkSort(b, 100, false) } func BenchmarkSort10Descending(b *testing.B) { benchmarkSort(b, 10, false) }
运行基准测试
Go 工具在运行基准测试之前运行单元测试——对失败的代码进行基准测试没有意义。
如何操作...
-
使用
go test -bench=<regexp>
工具。要运行所有基准测试,请使用以下命令:go test -bench=.
-
如果你想运行基准测试的子集,请输入一个基准正则表达式。以下命令仅运行名称中包含
1000
的基准测试:go test -bench=1000 goos: linux goarch: amd64 pkg: github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort cpu: AMD Ryzen 5 7530U with Radeon Graphics BenchmarkSort1000Ascending-12 9753 105997 ns/op BenchmarkSort1000Descending-12 9813 105192 ns/op PASS
分析
分析器通过采样运行中的程序来查找在特定函数中花费的时间。你可以分析基准测试,创建配置文件,然后检查该配置文件以找到程序中的瓶颈。
如何操作…
要获取 CPU 配置文件并进行分析,请按照以下步骤操作:
-
使用
cpuprofile
标志运行基准测试:$ go test -bench=1000Ascending --cpuprofile=profile goos: linux goarch: amd64 pkg: github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort cpu: AMD Ryzen 5 7530U with Radeon Graphics BenchmarkSort1000Ascending-12 10000 106509 ns/op
-
使用配置文件启动
pprof
工具:$ go tool pprof profile File: sort.test Type: cpu
-
使用
topN
命令查看配置文件中的前N
个样本:(pprof) top5 Showing nodes accounting for 780ms, 71.56% of 1090ms total Showing top 5 nodes out of 47 flat flat% sum% cum cum% 250ms 22.94% 22.94% 360ms 33.03% github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort.SortTimes.func1 230ms 21.10% 44.04% 620ms 56.88% sort.partition_func 120ms 11.01% 55.05% 120ms 11.01% runtime.memmove 90ms 8.26% 63.30% 340ms 31.19% internal/ reflectlite.Swapper.func9 90ms 8.26% 71.56% 230ms 21.10% internal/ reflectlite.typedmemmove
这表明大部分时间都花在比较两个时间值的匿名函数中。
flat
列显示了在函数中花费的时间,不包括由该函数调用的函数所花费的时间。cum
(累积)包括在函数中花费的时间,定义为函数返回的时间点减去函数开始运行的时间点。也就是说,累积值包括该函数调用的函数所花费的时间。例如sort.partition_func
运行了620ms
,但其中只有230ms
是在sort.partition_func
中花费的,其余时间是在sort.partition_func
调用的函数中花费的。 -
使用
web
命令查看调用图和每个函数花费的时间的视觉表示。
要获取内存配置文件并进行分析,请按照以下步骤操作:
-
使用
memprofile
标志运行基准测试:$ go test -bench=1000Ascending --memprofile=mem goos: linux goarch: amd64 pkg: github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort cpu: AMD Ryzen 5 7530U with Radeon Graphics BenchmarkSort1000Ascending-12 10000 106509 ns/op
-
使用配置文件启动
pprof
工具:$ go tool pprof mem File: sort.test Type: alloc_space
-
使用
topN
命令查看配置文件中的前N
个样本:pprof) top5 Showing nodes accounting for 493.37MB, 99.90% of 493.87MB total Dropped 2 nodes (cum <= 2.47MB) flat flat% sum% cum cum% 492.86MB 99.80% 99.80% 493.36MB 99.90% github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort.SortTimes 0.51MB 0.1% 99.90% 493.87MB 100% github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort.benchmarkSort 0 0% 99.90% 493.87MB 100% github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp17/sorting/sort.BenchmarkSort1000Ascending 0 0% 99.90% 493.87MB 100% testing.(*B).launch 0 0% 99.90% 493.87MB 100% testing.(*B).runN
与 CPU 配置文件输出类似,此表显示了每个函数分配了多少内存。同样,
flat
指的是仅在该函数中分配的内存,而cum
指的是在该函数及其调用的任何函数中分配的内存。在这里,你可以看到sort.SortTimes
是分配最多内存的函数。这是因为它首先创建切片的副本,然后对其进行排序。 -
使用
web
命令查看内存分配的视觉表示。
参见
-
Go 程序分析的权威指南可在
go.dev/blog/pprof
找到 -
pprof
的 README 文件解释了节点和边的表示:github.com/google/pprof/blob/main/doc/README.md