Go 语言 DevOps(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:设置你的开发环境

本章我们将讨论如何设置Go开发环境,以便在我们未来的章节中使用,并为未来开发你自己的 Go 软件做准备。

我们将覆盖以下主要内容:

  • 在你的计算机上安装 Go

  • 本地构建代码

在我们开始之前,先简要介绍一下你需要了解的技术要求,然后再继续阅读。

技术要求

本章节的唯一技术要求如下:

  • 一台 Go 工具支持的操作系统的计算机

  • 需要互联网连接和网页浏览器来下载 Go 工具

在你的计算机上安装 Go

Go 编译器和工具集可以在 golang.org/dl/ 上找到。在这里,你可以找到适用于 macOS、Windows 和 Linux 平台的多个计算平台的版本。

最常见的平台是AMD64架构,适用于任何 x86 系统。对于 macOS,重要的是要注意,如果你使用的是非 Intel CPU 的机器,如 Apple M1,你需要使用arm64 版本

在接下来的章节中,我们将描述如何为主要操作系统安装 Go。你应该跳到你打算安装的操作系统部分。

使用安装包安装 macOS

安装 Go 工具集的最简单方法是使用 .pkg 安装包。下载页面提供了 .tar.gz 构建包和 .pkg 安装包。使用 tar 包时,你必须将文件解压到一个位置,并将该位置添加到路径中。这样也意味着你需要手动处理升级。只有在有高级需求时,你才应该选择这种方式。

.pkg 文件使得安装和升级变得简单。只需双击 .pkg 文件并按照屏幕上的提示进行安装。安装过程中可能需要你输入凭据。

安装完成后,打开 Applications/Utilities/terminal.app 终端,并输入 go version,应该会显示类似以下内容:

$ go version
go version go1.17.5 linux/amd64

请注意,版本输出将取决于你下载的 Go 版本和你所运行的平台。

通过 Homebrew 安装 macOS

许多 macOS 开发者更喜欢使用流行的Homebrew (brew.sh) 来安装 Go。如果你是 Homebrew 用户,安装 Go 只需要简单的两步过程,具体内容将在以下章节中进行说明。

安装 Xcode

Go 依赖于 Apple 的Xcode,需要安装 Xcode 才能正常工作。要查看是否已经安装 Xcode,请输入以下命令:

$ xcode-select -p

输出应该类似于以下内容:

$ /Library/Developer/CommandLineTools

如果出现错误,你需要通过访问此链接在 App Store 中安装 Xcode:itunes.apple.com/us/app/xcode/id497799835?mt=12&ign-mpt=uo%3D2

安装完成后,你可以通过以下命令安装单独的命令行工具:

$ xcode-select --install

现在,让我们来看一下下一步。

更新 Homebrew 并安装 Go

使用以下命令更新 Homebrew 并安装最新的 Go 工具:

$ brew update
$ brew install golang

你可以通过$ go version来验证 Go 的版本。

接下来,我们将查看 Windows 上的安装方法。

使用 MSI 安装 Windows

Windows 的安装类似于其他 Windows 应用程序的安装,使用Microsoft InstallerMSI)文件。只需下载 MSI 文件并按照屏幕上的指示进行操作。默认情况下,这将把 Go 工具安装到Program Files或**Program Files (x86)**中。

要验证 Go 是否正确安装,请点击开始菜单,在搜索框中输入cmd,然后命令提示符窗口应该会出现。输入go version,它应该会显示已安装的 Go 版本。

接下来,我们将查看 Linux 上的安装方法。

Linux

Linux 的包管理可能会成为一系列书籍的主题,正如 Linus 所指出的,这是 Linux 作为桌面系统如此惨败的原因之一。

如果你正在使用 Linux 进行开发,可能已经知道如何为你的发行版安装软件包。由于我们不能涵盖 Linux 上所有可能的安装方法,接下来我们将介绍如何通过apt、Snap 和tarball进行安装。

在 Ubuntu 上通过 APT 安装 Linux

APT是一个在多个发行版中使用的包管理器。通过 APT 安装 Go 相当简单。

更新并升级 APT 到最新版本,方法如下:

$ sudo apt update
$ sudo apt upgrade

按照以下步骤安装 Go 包:

sudo apt install golang-go

现在,在终端中输入go version,它应该会显示已安装的 Go 版本。

通过 Snap 在 Ubuntu 上安装 Linux

Snap是一个通用的包管理器,旨在通过将所有必要的文件包含在包中,使得在多个发行版或版本中安装软件包变得简单。

如果你已经安装了 Snap,你可以直接使用snap info go来查找可以安装的 Go 版本:

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

图 3.1 – 显示 snap info go 命令输出的截图

你可以选择通过输入以下命令安装最新的稳定版本的 Go:

sudo snap install go

现在,在终端中输入go version,它应该会显示已安装的 Go 版本。

请注意,您可能会收到关于 Go 包是使用具有经典限制的 Snap 版本构建的警告。在这种情况下,要通过 Snap 安装,您可能需要添加--classic,如下所示:

sudo snap install go --classic

通过 tarball 安装 Linux

为了做到这一点,你需要下载适用于 Linux 和你的平台的包。我们的示例将使用go1.16.5.linux-amd64.tar.gz。你会注意到,文件名中包含了 Go 版本(1.16.5)、操作系统(Linux)和架构(AMD64)。你需要将 Go 的当前版本和你的架构下载到一个目录中。

接下来的这些指令将使用终端。

我们希望将我们的版本安装到/usr/local/go并删除任何以前的安装。这可以通过以下方式实现:

rm -rf /usr/local/go && tar -C /usr/local -xzf go1.16.5.linux-amd64.tar.gz

现在,让我们将目录添加到 PATH 中,以便可以找到 Go 工具。这可以通过以下方式实现:

export PATH=$PATH:/usr/local/go/bin

对于大多数 shell,变化不会立即生效。最简单的方式是打开一个新的 shell 来使 PATH 更新。你也可以使用 source 命令重新加载 shell 的配置文件,前提是你知道配置文件的名称/路径——例如 source $HOME/.profile

要测试你的 PATH 是否已正确更新,请输入 go version,应返回如下信息:

$ go version
go version go1.16.5 linux/amd64

那么在其他平台上安装 Go 呢?

其他平台

Go 确实可以安装在其他平台上,比如FreeBSD,但这些内容不在本书范围内。请参阅 Go 的安装文档了解其他平台的安装方式。

关于 Go 编译器版本兼容性的说明

Go 项目由 Go 兼容性承诺管理:golang.org/doc/go1compat。其核心是,除非有重大语义版本号变更(1.x.x2.x.x),否则 Go 将保持向后兼容。虽然你可能会听到人们谈论 Go 2.0,但作者们已经明确表示,他们没有计划跳过版本 1。

这意味着为Go 1.0.0编写的软件在最新的Go 1.17.5版本中可以运行。这对于 Go 社区的稳定性来说是一个重大胜利。本书将使用 Go 1.17.5 版本进行修订。

在本节结束时,你应该已经安装了 Go 工具并测试了该工具是否适用于你选择的操作系统。接下来,我们将讨论如何在你的计算机上构建代码。

本地构建代码

当前的 Go 生态系统(Go 1.13 及以后的版本)和工具链允许你在文件系统的任何位置编写 Go 代码。大多数用户选择为其包设置本地 Git 仓库,并在该目录中进行开发。

这是通过 Go 模块实现的,Go 团队将其描述为*“存储在文件树中的 Go 包集合,根目录有一个 go.mod 文件。”* Go 模块通常代表一个 GitHub 仓库,例如 github.com/user/repository

大多数 Go 开发者会使用命令行在文件系统环境中移动,并与 Go 工具链进行交互。在本节中,我们将集中讨论如何使用 Unix 命令来访问文件系统以及使用 Go 编译器工具。Go 编译器命令在各操作系统间是相同的,但文件系统命令可能不同,文件路径也可能不同,例如 Windows 使用 \ 作为路径分隔符,而不是 /

创建模块目录和 go.mod 文件

该目录可以是文件系统中任何你可以访问的地方。godev/ 是一个不错的目录名,并且将其放在你的主目录中(主目录因操作系统而异)是一个合理的选择,这样便于查找。

在该目录中,我将为我的包创建一个新目录。以这个示例为例,我将创建一个名为 hello/ 的目录,表示我的 Go 模块:

$ cd ~
$ mkdir -p ~/godev/hello
$ cd ~/godev/hello 

创建我们的模块,我们只需要创建一个包含模块名的 go.mod 文件。模块名通常是 Git 路径,比如 github.com/johnsiilver/fs

如果你有一个 GitHub 仓库,想把这个示例存储在其中,你可以在我们的命令中替换成你的仓库地址:

$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello

这个 go.mod 文件将包含几个值得注意的关键部分:

module example.com/hello
go 1.17

第一行定义了我们的模块,这是指向 Git 仓库根目录的路径。第二行定义了可以用来编译此模块的 Go 最低版本。根据你使用的功能,模块可能兼容较早版本的 Go,你可以修改此设置来使用较低版本的 Go。

虽然这个示例中没有任何第三方包,但值得注意的是,大多数 go.mod 文件都会有一个 require 部分,列出你模块所导入的包及其版本。

添加依赖时更新模块

当添加第三方包时,你的 go.mod 文件需要修改以包含依赖信息。虽然这会是一个繁琐的任务,但 Go 提供了 go mod tidy 命令来帮助你自动处理。

运行 go mod tidy 会自动检查所有的包导入,并将它们添加到你的 go.mod 文件中。记得在添加任何外部依赖后运行此命令。

添加 hello world

为了学习如何编译和运行 Go 代码,我们将创建一个 hello world 应用程序。在 Go 中,所有的 Go 源文件都以 .go 后缀结尾。

在目录中使用你喜欢的文本编辑器创建一个名为 hello.go 的文件,并插入以下代码:

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

接下来,让我们运行我们的第一个程序。

运行我们的第一个程序

一旦你保存了这个文件,接下来我们来尝试编译并运行这段代码:

$ go run hello.go
Hello World
$

这将编译我们的源文件并作为二进制运行。你只能在名为 main 的包中使用 go run

如果我们想为这个操作系统和架构创建一个二进制文件,我们只需运行以下命令:

$ go build hello.go # Builds a program called hello
$ ./hello           # Executes the hello binary
Hello World

现在有一个名为 hello 的二进制文件,可以在相同类型的任何操作系统/架构上运行。如果我们的包不是叫做 main,这将编译该包并报告遇到的任何错误,但不会创建二进制文件。

总结

你现在已经创建了你的第一个 Go 模块,初始化了第一个 go.mod 文件,创建了一个 Go 程序,使用 go run 运行了这个程序,并为你的操作系统构建了 Go 可执行文件。本章让你掌握了创建基础 Go 模块所需的技能,以及使用 Go 命令行工具的基本知识,这些工具用于运行 Go 包和构建 Go 程序。Go 开发者在日常工作中都会用到这些技能。

在下一章,我们将介绍 Go 语言的基础知识,包括包的工作方式、测试以及更多的基本内容。

第四章:文件系统交互

每个开发者生活中的一个基本部分就是与文件的交互。文件代表了必须处理和配置的系统数据,缓存项可以被提供,还有许多其他用途。

Go 最强大的特点之一就是它对文件接口的抽象,使得一套通用工具能够与来自磁盘和网络的数据流交互。这些接口设定了一个共同标准,所有主要包都使用它们来导出数据流。不同接口间的转换变成了一个简单的任务,只需用必要的凭证访问文件系统即可。

与特定数据格式相关的包,如 CSV、JSON、YAML、TOML 和 XML,基于这些通用的文件接口构建。这些包使用标准库定义的接口从磁盘或 HTTP 流中读取这些类型的文件。

由于 Go 是跨平台的,你可能希望编写能够在不同操作系统上运行的软件。Go 提供了可以检测操作系统并处理操作系统路径差异的包。

本章将覆盖以下主题:

  • Go 中的所有 I/O 都是文件

  • 读取和写入文件

  • 流式文件内容

  • 跨操作系统路径

  • 跨操作系统文件系统

完成本章内容后,你应当掌握一套与各种介质中存储的数据交互的技能,这对你作为 DevOps 工程师的日常工作非常有帮助。

Go 中的所有 I/O 都是文件

Go 提供了一个基于文件的输入输出I/O)系统。这一点并不令人惊讶,因为 Go 是两位杰出工程师 Rob Pike 和 Ken Thompson 的心血结晶,他们在 贝尔实验室 时设计了 UNIX 和 Plan 9 操作系统——这两个系统几乎把所有事物都视作文件。

Go 提供了 io 包,其中包含与 I/O 基本操作进行交互的接口,如磁盘文件、远程文件和网络服务。

I/O 接口

I/O 的基本单元是 byte,一个 8 位值。I/O 使用字节流来实现读写操作。对于某些 I/O,你只能按顺序从头到尾读取流(如网络 I/O)。一些 I/O,如磁盘,允许你在文件中进行定位

我们在与字节流交互时进行的一些常见操作包括读取、写入、在字节流中定位某个位置,以及在完成工作后关闭流。

Go 为这些基本操作提供了以下接口:

// Read from an I/O stream.
type Reader interface {
     Read(p []byte) (n int, err error)
}
// Write to an I/O stream.
type Writer interface {
     Write(p []byte) (n int, err error)
}
// Seek to a certain spot in the I/O stream.
type Seeker interface {
     Seek(offset int64, whence int) (int64, error)
}
// Close the I/O stream.
type Closer interface {
     Close() error
}

io 包还包含复合接口,如 ReadWriterReadWriteCloser。这些接口在允许与文件或网络交互的包中很常见。通过这些接口,你可以使用常见的工具,无论底层是什么(例如本地文件系统、远程文件系统或 HTTP 连接)。

在本节中,我们已经了解到 Go 文件交互是基于[]byte类型的,并介绍了基本的 I/O 接口。接下来,我们将学习如何利用这些接口的方法来读取和写入文件。

读取和写入文件

在 DevOps 工具中最常见的场景是需要操作文件:读取写入重新格式化分析这些文件中的数据。这些文件可以有多种格式——JSON、YAML、XML、CSV 等格式,应该都对你来说并不陌生。它们用于配置本地服务以及与云网络提供商进行交互。

在本节中,我们将介绍读取和写入整个文件的基础知识。

读取本地文件

让我们开始使用os.Readfile()函数读取本地磁盘上的配置文件:

data, err := os.ReadFile("path/to/file")

ReadFile()方法从其函数参数中读取文件位置并返回该文件的内容。返回值会存储在 data 变量中。如果文件无法读取,则会返回一个错误。有关错误处理的更多信息,请参阅第二章中关于Go 中的错误处理部分,Go 语言基础

ReadFile()是一个辅助函数,它调用os.Open()并获取一个io.Readerio.ReadAll()函数用于读取io.Reader的全部内容。

data[]byte类型,因此如果你想将其作为string使用,可以通过 s := string(data)简单地将其转换成字符串。这被称为类型转换,即我们将一种类型转换为另一种类型。在 Go 中,只有某些类型可以进行转换。关于转换规则的完整列表,可以在golang.org/ref/spec#Conversions.strings中找到。可以使用b := []byte(s)将其转换回字节数组。其他大多数类型则需要使用一个叫做strconv的包来进行字符串转换(pkg.go.dev/strconv)。

如果文件中表示的数据是 JSON、YAML 等常见格式,那么我们可以高效地检索和写入这些数据。

写入本地文件

写入本地磁盘最常见的方式是使用os.Writefile()。这个方法会将整个文件写入磁盘。如果必要,WriteFile会创建文件,并且如果文件已存在,会将其截断:

if err := os.WriteFile(“path/to/fi”, data, 0644); err != nil {
     return err
}

上述代码以 Unix 风格的权限0644将数据写入path/to/fi。如果你之前没接触过 Unix 风格的权限,快速查阅一下网络资料就能帮助你理解。

如果你的数据存储在string中,可以通过[]byte(data)轻松地将其转换为[]byteWriteFile()是一个封装了os.OpenFile()的函数,它处理文件标志和模式,并在写入完成后关闭文件。

读取远程文件

远程文件的读取方式将取决于具体的实现。不过,这些概念仍然会基于我们之前讨论的io接口。

例如,假设我们想连接到一个存储在 HTTP 服务器上的文本文件,以收集常见的文本格式信息,比如应用程序的指标数据。我们可以连接到该服务器,并以一种与前面示例非常相似的方式检索该文件:

client := &http.Client{}
req, err := http.NewRequest("GET", "http://myserver.mydomain/myfile", nil)
if err != nil {
        return err
}
req = req.WithContext(ctx) 
resp, err := client.Do(req) 
cancel()
if err != nil {
        return err
}
// resp contains an io.ReadCloser that we can read as a file. 
// Let's use io.ReadAll() to read the entire content to data. 
data, err := io.ReadAll(resp.Body) 

正如你所看到的,获取io.ReadCloser的设置依赖于我们的 I/O 目标,但它返回的只是io包中的接口,我们可以在任何支持这些接口的函数中使用。

因为它使用了io接口,我们可以做一些非常巧妙的操作,比如将内容直接流式传输到本地文件,而不是将整个文件复制到内存中再写入磁盘。这种方式更快且更加节省内存,因为每次读取的内容都会立即写入磁盘。

让我们使用os.OpenFile()打开一个文件进行写入,并将内容从网页服务器流式传输到文件中:

flags := os.O_CREATE|os.O_WRONLY|os.O_TRUNC
f, err := os.OpenFile("path/to/file", flags, 0644)
if err != nil {
     return err
}
defer f.Close() 
if err := io.Copy(f, resp.Body); err != nil { 
    return err 
} 

OpenFile()是一个更复杂的文件打开方法,适用于你需要写入文件或更精确地控制文件操作的情况。如果你只需要从本地文件读取数据,应该使用os.Open()。这里的标志是标准的类 Unix 位掩码,其作用如下:

  • 如果文件不存在,则创建该文件:os.O_CREATE

  • 向文件写入数据:os.O_WRONLY

  • 如果文件已存在,则截断文件而不是追加:os.O_TRUNC

标志列表可以在这里找到:pkg.go.dev/os#pkg-constants

io.Copy()io.Reader读取并写入io.Writer,直到Reader为空。这将文件从 HTTP 服务器复制到本地磁盘。

在本节中,你学习了如何使用os.ReadFile()读取整个文件,如何将[]byte类型转换为string,以及如何使用os.WriteFile()将整个文件写入磁盘。我们还了解了os.Open()os.OpenFile()之间的区别,并展示了如何使用像io.Copy()io.ReadAll()这样的实用函数。最后,我们学习了 HTTP 客户端如何将数据流暴露为io接口,并通过这些相同的工具读取数据。

接下来,我们将查看如何将这些文件接口作为流来操作,而不是一次性读取和写入整个文件。

流式传输文件内容

在前面的章节中,我们学习了如何使用os.ReadFile()os.WriteFile()以大块数据进行读取和写入。

当文件较小时,这种方式效果很好,通常在进行 DevOps 自动化时会遇到这种情况。然而,有时我们想读取的文件非常大——在大多数情况下,你不会希望将一个 2 GiB 的文件全部读入内存。在这种情况下,我们希望以可管理的块流式传输文件内容,这样我们可以在保持较低内存使用的同时进行操作。

这种最基本的版本在上一节中已经展示过。在那里,我们使用了两个流来复制文件:io.ReadCloser来自 HTTP 客户端,io.WriteCloser用于写入本地磁盘。我们使用了io.Copy()函数来将网络文件复制到磁盘文件。

Go 的io接口还允许我们流式处理文件,复制内容,搜索内容,操作输入输出等。

Stdin/Stdout/Stderr 只是文件

在本书中,你会看到我们使用fmt.Println()fmt.Printf()这两个来自fmt包的函数来向控制台写入数据。这些函数实际上是向表示终端的文件读写数据。

这些函数使用一个名为os.Stdoutio.Writer。当我们在log包中使用相同的函数时,通常是向os.Stderr写入数据。

你可以使用我们一直在使用的相同接口来读写其他文件,也来读写这些文件。当我们想要复制一个文件并将其内容输出到终端时,我们可以这样做:

f, err := os.Open("path/to/file")
if err != nil {
     return err
}
if err := io.Copy(os.Stdout, f); err != nil {
     return err
}

虽然我们不会详细探讨,os.Stdin只是一个io.Reader。你可以使用iobufio包从中读取数据。

从流中读取数据

如果我们想读取一个表示用户记录的流,并通过通道返回它们,该怎么办呢?

假设记录是简单的<user>:<id>文本,每条记录由换行符(\n)分隔。这些记录可能存储在 HTTP 服务器或本地磁盘上。这对我们来说并不重要,因为它只是一个接口背后的流。假设我们接收到这个流作为一个io.Reader

首先,我们将定义一个User结构体:

type User struct{
  Name string
  ID int
}

接下来,让我们定义一个函数,来拆分我们接收到的每一行:

func getUser(s string) (User, error) {
     sp := strings.Split(s, ":")
     if len(sp) != 2 {
          return User{}, fmt.Errorf("record(%s) was not in the correct format", s)
    } 
    id, err := strconv.Atoi(sp[1]) 
     if err != nil {
          return User{}, fmt.Errorf("record(%s) had non-numeric ID", s)
     }
     return User{Name: strings.TrimSpace(sp[0]), ID: id}, nil
}

getUser()接收一个字符串并返回一个User。我们使用strings包的Split()函数将字符串按:作为分隔符拆分成[]string

Split()应该返回两个值;如果不是,我们将返回一个错误。

由于我们在拆分字符串,用户 ID 被存储为string类型。但我们希望在User记录中使用整数值。在这里,我们可以使用strconv包的Atoi()方法,将字符串形式的数字转换为整数。如果它不是整数,则说明输入无效,我们将返回一个错误。

现在,让我们创建一个函数,读取流并将User记录写入通道:

func decodeUsers(ctx context.Context, r io.Reader) chan User {
     ch := make(chan User, 1)
     go func() {
          defer close(ch)
          scanner := bufio.NewScanner(r)
          for scanner.Scan() {
               if ctx.Err() != nil {
                    ch <- User{err: ctx.Err()}
                    return
               }
               u, err := getUser(scanner.Text())
               if err != nil {
                    u.err = err
                    ch <- u
                    return
               }
               ch <- u
          }
     }()
     return ch
}

这里,我们使用的是bufio包的Scanner类型。Scanner允许我们获取一个io.Reader并扫描它,直到找到分隔符。默认情况下,分隔符是\n,但你可以使用.Split()方法来更改它。Scan()将在读取器输出结束前一直返回true。请注意,当io.Reader到达流的末尾时,会返回一个错误io.EOF

每次调用Scan()后,扫描器会存储读取的字节,你可以通过.Text()方法将其作为string提取出来。.Text()中的内容会在每次调用.Scan()时发生变化。同时,请注意,我们会检查Context对象,如果它被取消,则停止执行。

我们将该string的内容传递给我们之前定义的getUser()。如果我们收到一个error,我们将把它返回给User记录,以通知调用者错误。否则,我们返回包含所有信息的User记录。

现在,让我们对一个文件进行调用:

f, err := os.Open("path/to/file/with/users")
if err != nil {
     return err
}
defer f.Close()  
for user := range decodeUsers(ctx, f) {
     if user.err != nil {
          fmt.Println("Error: ", user.err)
          return err
     }
     fmt.Println(user)
}

在这里,我们打开磁盘上的文件并将其传递给decodeUsers()。我们从输出通道接收一个User记录,并在读取文件流的同时并发地将用户打印到屏幕上。

我们本可以通过http.Client打开文件并将其传递给decodeUsers(),而不是使用os.Open()。完整的代码可以在这里找到:play.golang.org/p/OxehTsHT6Qj

向流写入数据

向流写入数据更加简单——我们只需将User转换为string并将其写入io.Writer。如下所示:

func writeUser(ctx context.Context, w io.Writer, u User) error {
     if ctx.Err() != nil {
          return ctx.Err()
     }
     if _, err := w.Write([]byte(user.String())); err != nil {
          return err
     }
     return nil
}

在这里,我们接受一个io.Writer,它代表了写入的目标位置,以及一个我们想写入该输出的User记录。我们可以使用它将数据写入磁盘上的文件:

f, err := os.OpenFile("file", flags, 0644); err != nil{
     return err
}
defer f.Close() 
for i, u := range users {
     // Write a carriage return before the next entry, except
     // the first entry.
     if i != 0 {
          if err := w.Write([]byte("\n")); err != nil {
               return err
          }
     }
     if err := writeUser(ctx, w, u); err != nil {
          return err
     }
}

在这里,我们打开了本地磁盘上的一个文件。当我们包含的函数(未显示)返回时,文件将被关闭。然后,我们将存储在变量 users([]Users)中的User记录逐个写入文件。最后,我们在每条记录之前(除了第一条记录)写入了一个回车符("\n")

您可以在这里查看实际演示:play.golang.org/p/bxuFyPT5nSk。我们提供了一个使用通道的流式版本,您可以在这里找到:play.golang.org/p/njuE1n7dyOM

在下一部分,我们将学习如何使用path/filepath包编写适用于多个操作系统的软件,这些操作系统使用不同的路径分隔符。

操作系统无关的路径处理

Go 语言的一个最大优势是其多平台支持。开发人员可以在 Linux 工作站上开发,并将相同的 Go 程序重新编译为本地代码后,在 Windows 服务器上运行。

开发跨多个操作系统运行的软件时,访问文件是一个难点。每个操作系统的路径格式稍有不同。最明显的例子是不同操作系统的文件分隔符:Windows 上是\,而类 Unix 系统上是/。更不明显的是如何在特定操作系统上转义特殊字符,甚至在 Unix 类操作系统之间也可能有所不同。

path/filepath包提供了访问函数的功能,允许您处理本地操作系统的路径。这不应与根path包混淆,后者看起来类似,但处理的是更通用的 URL 样式路径。

我正在运行哪个操作系统/平台?

虽然我们将讨论如何使用无关操作系统的函数获取文件访问权限并执行路径处理,但了解您运行的操作系统仍然非常重要。您可能会根据运行的操作系统使用不同的文件位置。

使用runtime包,您可以检测到您运行的操作系统和平台:

fmt.Println(runtime.GOOS) // linux, darwin, ...
fmt.Println(runtime.GOARCH) // amd64, arm64, ...

这将为您提供正在运行的操作系统。我们可以使用go tool dist list命令打印出 Go 支持的当前操作系统类型和硬件架构列表。

使用 filepath

使用filepath,在处理路径时可以忽略所在操作系统的路径规则。路径被分为以下几个部分:

  • 路径中的目录

  • 路径中的文件

文件路径的最终目录或文件称为基础路径。你的二进制文件运行所在的路径称为工作目录

连接文件路径

假设我们想访问一个名为config.json的配置文件,它存储在与我们的二进制文件相同目录下的config/目录中。让我们使用ospath/filepath以一种适用于所有操作系统的方式来读取该文件:

wd, err := os.Getwd() 
if err != nil { 
    return err 
} 
content, err := os.ReadFile(filepath.Join(wd, "config", "config.json"))

在这个例子中,我们首先获取工作目录。这允许我们相对于我们的二进制文件所在位置进行调用。

filepath.Join() 允许我们将路径的各个组成部分连接成一个单一的路径。它会为你填充操作系统特定的目录分隔符,并使用本地的路径规则。在类 Unix 系统上,可能是 /home/jdoak/bin/config/config.json,而在 Windows 上,则可能是 C:\Documents and Settings\jdoak\go\bin\config\config.json

拆分文件路径

在某些情况下,根据路径分隔符将文件路径拆分开来是很重要的。filepath 提供了以下功能:

  • Base(): 返回路径的最后一个元素

  • Ext(): 返回文件扩展名(如果有)

  • Split(): 返回分割后的目录和文件

我们可以使用这些来获取路径的各个部分。当我们希望将文件复制到另一个目录并保留文件名时,这可能会很有用。

让我们将一个文件从其位置复制到我们操作系统的TMPDIR

fileName := filepath.Base(fp) 
if fileName == "." { 
    // Path is empty 
    return nil 
}
newPath := filepath.Join(os.TempDir(), fileName) 

r, err := os.Open(fp) 
if err != nil { 
    return err 
}
defer r.Close() 

w, err := os.OpenFile(newPath, O_WRONLY | O_CREATE, 0644) 
if err != nil { 
    return err 
}
defer w.Close()
// Copies the file to the temporary file.
_, err := io.Copy(w, r)
return err

现在,是时候看一下可以用来引用文件的不同路径选项,以及filepath包如何帮助你了。

相对和绝对路径处理

访问文件系统时有两种类型的路径处理方式:

  • 绝对路径: 从根目录到文件的路径处理

  • 相对路径: 在文件系统中从当前位置进行路径处理

在开发过程中,将相对路径转换为绝对路径及其反向转换通常很方便。

filepath 提供了几个函数来帮助处理这些问题:

  • Abs(): 返回绝对路径。如果它不是绝对路径,则返回工作目录以及路径。

  • Rel(): 返回路径相对于基本路径的相对路径。

我们将让你自己尝试使用这些功能。

在本节中,我们学习了如何使用path/filepathruntime包处理不同操作系统的文件路径。我们介绍了runtime.GOOS来帮助您检测用户正在使用的操作系统,os.Getwd()来确定程序在文件系统中的位置。我们还介绍了os.TempDir()来定位您的操作系统中用于临时文件的位置。最后,我们学习了path/filepath中的函数,这些函数允许您在不考虑操作系统的情况下组合和拆分文件路径,并输出特定于操作系统的结果。

接下来,我们将看看 Go 的新 io/fs 包,它是在版本 1.16 中引入的。它通过类似于 io 对文件的处理方式,引入了新的接口来抽象文件系统。

操作系统无关的文件系统

在最新的 Go 版本中,最令人兴奋的两项新功能是新的 io/fsembed 包,它们是在 Go 1.16 中引入的。

尽管我们已经展示了通过 os 包访问本地文件系统的通用方式,并通过 filepath 进行通用的文件路径操作,但我们还没有看到访问整个文件系统的通用方法。

在云计算时代,文件也很可能存储在远程数据中心的文件系统中,比如 Microsoft Azure 的 Blob 存储、Google Cloud 的 Filestore 或 Amazon AWS 的 EFS,就像它们存储在本地磁盘上一样。

这些文件系统每个都有一个用于在 Go 中访问文件的客户端,但它们是特定于该网络服务的。我们不能像处理本地文件系统一样处理这些文件。io/fs 旨在提供一个基础,帮助解决这个问题。

另一个问题是许多文件必须与二进制文件一起打包,通常是在容器定义中。这些文件在程序的生命周期内不会改变。将它们包含在二进制文件中并通过文件系统接口访问会更方便。一个需要图像、HTML 和 CSS 文件的简单 Web 应用程序就是这种用例的一个典型示例。新的 embed 包旨在解决这个问题。

io.fs 文件系统

我们新的 io/fs 文件系统导出了可以由文件系统提供者实现的接口。根接口 FS 的定义最简单:

type FS interface {
     Open(name string) (File, error)
}

这让你可以打开任何文件,其中 File 被定义如下:

type File interface {
     Stat() (FileInfo, error)
     Read([]byte) (int, error)
     Close() error
}

这提供了最简单的文件系统。你可以打开路径中的文件,并获得文件的信息或读取文件。由于文件系统在功能上有差异,这是所有给定文件系统之间唯一共享的功能。

一个 FS(如 ReadDirFSStatFS),它允许进行文件遍历并提供目录信息。注意,FS 对象缺少可写性。你必须自己提供一个,因为 Go 作者没有将其定义为标准库的一部分。

embed

embed 包允许你使用 //go:embed 指令将文件直接嵌入到二进制文件中。

embed 可以通过三种方式嵌入文件,如下所示:

  • 作为字节

  • 作为一个字符串

  • 转换为 embed.FS(它实现了 fs.FS

前两个功能通过将指令放置在特定的变量类型上完成:

import _ "embed" 
//go:embed hello.txt
var s string
//go:embed world.txt
var b []byte

//go:embed hello.txt 表示 Go 指令,指示编译器获取名为 hello.txt 的文件并将其存储在变量中。

import行上的_指示编译器忽略我们没有直接使用embed的事实。这称为匿名导入,即我们需要加载一个包但不直接使用其功能。没有_时,如果未使用导入的包,我们将收到编译错误。

使用 embed.FS 的最终方法在你希望将多个文件嵌入文件系统时非常有用:

// The lines beginning with //go: are not comments, but compiler directives
//go:embed image/*
//go:embed index.html
var content embed.FS

现在我们有一个 fs.FS,它存储了我们 image 目录中的所有文件和 index.html 文件。这些文件在我们发布二进制文件时不再需要包含在容器文件系统中。

遍历我们的文件系统

io/fs 包提供了一种与文件系统无关的遍历方法,前提是文件系统支持该功能。在前面的示例中,我们有一个嵌入式文件系统中的目录,里面存放着图像文件。我们可以使用目录遍历器打印出所有 .jpg 文件:

err := fs.WalkDir(
     content,
     ".",
     func(path string, d fs.DirEntry, err error) error {
          if err != nil {
               return err
          }
          if !d.IsDir() && filepath.Ext(path) == ".jpg" {
               fmt.Println("jpeg file: ", path)
          }
          return nil
     },
)

上述函数遍历了我们嵌入式文件系统(content)的目录结构(从根目录 "." 开始),并调用了已定义的函数,将文件路径、目录条目和错误(如果有)传递给它。

在我们的函数中,如果文件不是目录且具有 .jpg 扩展名,我们只是打印文件的路径。

那么,使用 io/fs 访问其他类型文件系统的包怎么办呢?

io/fs 的未来

在写作时,io/fs 的主要用户是 embed。然而,我们开始看到第三方包实现了这个接口。

absfs 为他们的 boltfs/memfs/os 文件系统包提供了一个 io.FS 钩子(github.com/absfs)。这些包中的几个封装了流行的 afero 文件系统包(github.com/spf13/afero)。Azure 有一个非官方的包,支持 Blob 存储(github.com/element-of-surprise/azfs)。

还有一些包可以访问RedisGroupCachememfs、本地文件系统以及 github.com/gopherfs/fs 上的工具支持。

注意

github.com/element-of-surprisegithub.com/gopherfs 由作者拥有。

在这一节中,你了解了 Go 的 io/fs 包,以及它如何成为与文件系统交互的标准。你还学会了如何使用 embed 包将文件嵌入到二进制文件中,并通过 io/fs 接口访问它们。

我们只是触及了表面

我强烈建议你阅读标准库的 GoDoc 页面,以便熟悉其功能。以下是本章涉及的 GoDocs。在这里,你可以找到许多处理文件的有用工具:

在这一节中,我们学习了如何使用io接口将数据流进流出文件,以及os包的Stdin/Stdout/Stderr实现,用于读取和写入程序的输入/输出。我们还学习了如何使用bufio包按分隔符读取数据,以及如何使用strings包拆分字符串内容。

概述

本章为你提供了 Go 语言中处理文件 I/O 的基础。你学习了io包及其文件抽象,并了解了如何将文件读写到磁盘。接着,你学习了如何流式传输文件内容,以便与网络合作并提高内存效率。然后,你了解了path/filepath包,它可以帮助你处理多种操作系统。最后,你了解了 Go 的文件系统无关接口,用于与任何文件系统进行交互,从新的embed文件系统开始。

在下一章,你将学习如何使用流行的 Go 包与常见数据类型和存储交互。在那里,你将需要依赖本章中的文件和文件系统包来与数据类型进行交互。

与数据和存储系统的交互对于 DevOps 工作至关重要。它使我们能够读取和更改软件配置,存储数据并使其可搜索,要求系统代我们执行工作,并生成报告。

那么,让我们开始吧!

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

DevOps 工程师需要的关键技能之一是能够跨各种存储介质操作数据。

在上一章中,我们与本地文件系统交互,读取和流式传输文件。这为我们在本章中将要学习的技能打下了基础。

本章将重点讲解如何操作工程师常用的常见数据格式。这些格式用于配置服务、结构化日志数据,以及导出度量数据,当然还有很多其他用途。

在本章中,你将学习如何使用struct字段标签来存储有关字段的元数据。此外,你还将学习如何在处理大量数据时高效地流式传输这些格式。

掌握这些技能将使你能够通过操作配置文件、查找可能包含日志或度量数据的记录,以及将数据导出到 Excel 中进行报告,从而与服务进行交互。

本章将涉及以下主题:

  • CSV 文件

  • 流行的编码格式

在接下来的章节中,我们将深入探讨如何使用最古老的格式之一——CSV 来处理数据。

让我们开始吧!

技术要求

本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/5下载

CSV 文件

CSV 是 DevOps 工程师最常遇到的常见数据源之一。

这种简单的格式长期以来一直是企业界的主流,是将数据从系统中导出进行处理,并再导入数据存储的最简单方式之一。

许多大型云服务提供商的关键系统,如 Google 的 GCP 和 Microsoft 的 Azure,依赖 CSV 格式的关键数据源和系统。我们已经看到像网络建模和关键数据报告这样的系统存储在 CSV 中。

数据科学家喜欢 CSV,因为它易于搜索和流式处理。能够在软件中快速可视化数据的额外优点更是增加了它的吸引力。

和许多其他格式一样,它是人类可读的,这使得数据可以手动操作。

在本节中,我们将专注于使用以下方法导入和导出 CSV 数据:

  • strings包和bytes

  • encoding/csv

此外,我们还将探讨如何使用excelize包将数据导入和导出到流行的 Excel 电子表格格式。excelize是一个广泛使用的 Microsoft Excel 工具包。

现在,让我们讨论如何使用简单的字符串/字节操作包来读写 CSV 文件。

使用 strings 包进行基本的值分隔

Go 提供了几个在操作string[]byte类型时非常有用的包:

  • strings

  • bytes

这些包提供了类似的功能,如以下内容:

  • 分割数据的函数,如strings.Split()

  • 合并带分隔符数据的函数,如strings.Join()

  • 实现了io包接口的缓冲区类型,例如bytes.Bufferstrings.Builder

处理 CSV 文件时,开发人员可以选择流式读取数据或一次性读取整个文件。

许多开发人员更喜欢将整个文件读取到内存中,并将其从[]byte类型转换为string类型。字符串对开发人员来说更容易理解连接和拆分规则。

然而,这在转换过程中会创建一个副本,这可能会导致效率低下,因为你需要使用双倍的内存并且占用一些 CPU 进行复制。当出现这个问题时,开发人员通常会使用bytesbufio包。虽然这些包稍微难以使用,但它们避免了不必要的转换开销。

让我们看看如何读取整个文件并将条目转换成结构化记录。

读取整个文件后的转换

在进行基本的 CSV 操作时,有时候更简单的做法是使用换行符拆分数据,然后根据逗号或其他分隔符来拆分每一行。假设我们有一个包含名字和姓氏的 CSV 文件,我们将这个 CSV 文件拆分为记录:

type record []string
func (r record) validate() error {
     if len(r) != 2 {
          return errors.New("data format is incorrect")
     }
     return nil
}
func (r record) first() string {
     return r[0]
}
func (r record) last() string {
     return r[1]
}
func readRecs() ([]record, error) {
     b, err := os.ReadFile("data.csv")
     if err != nil {
          return nil, err
     }
     content := string(b)
     lines := strings.Split(content, "\n") // Split by line
     var records []record
     for i, line := range lines {
          // Skip empty lines
          if strings.Trimspace(line) == "" {
               continue
          }
          var rec record = strings.Split(line, ",")  
          if err := rec.validate(); err != nil {
               return nil, fmt.Errorf("entry at line %d was invalid: %w", i, err)
          }
          records = append(records, rec)
     }
     return records, nil
}

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

  1. 它基于一个字符串切片[]string定义了一个record类型。

  2. 我们可以通过调用validate()方法来检查一个record类型是否有效。

  3. 可以使用first()方法获取记录的名字。

  4. 可以使用last()方法获取记录的姓氏。

  5. 它定义了一个readRecs()函数来读取名为data.csv的文件。

  6. 它将整个文件读取到内存中,并转换为名为content的字符串。

  7. content通过换行符\n拆分,每个条目代表一行。

  8. 它通过逗号(,)来拆分每一行。

  9. 它将Split返回的每个结果(一个[]string类型)赋值给record类型。

  10. 它将所有记录汇总到一个记录切片[]record中。

你可以在play.golang.org/p/CVgQZzScO8Z查看此代码的运行情况。

按行转换

如果文件较大且我们希望提高效率,可以使用bufiobytes包:

func readRecs() ([]record, error) { 
    file, err := os.Open("data.csv") 
    if err != nil { 
        return nil, err 
    } 
    defer file.Close() 
    scanner := bufio.NewScanner(fakeFile) 
    var records []record 
    lineNum := 0 
    for scanner.Scan() { 
        line := scanner.Text() 
        if strings.TrimSpace(line) == "" { 
            continue 
        } 
        var rec record = strings.Split(line, ",") 
        if err := rec.validate(); err != nil { 
            return nil, fmt.Errorf("entry at line %d was invalid: %w", lineNum, err) 
        } 
        records = append(records, rec) 
        lineNum++ 
    } 
return records, scanner.Err()
}

这与之前的代码不同,因为以下情况发生了:

  • 我们逐行读取每一行,使用bufio.Scanner,而不是读取整个文件。

  • scanner.Scan()会读取下一组内容,直到遇到\n

  • 该内容可以通过scanner.Text()获取。

你可以在play.golang.org/p/2JPaNTchaKV查看此代码的运行情况。

在这个版本中,我们仍然对每一行进行[]byte转换为string类型。如果你对不做这种转换的版本感兴趣,请参考play.golang.org/p/RwsTHzM2dPC

写入记录

使用我们之前测试过的方法,写入 CSV 记录是相当简单的。如果在读取记录之后,我们希望对其进行排序并将其写回文件,可以使用以下代码实现:

func writeRecs(recs []record) error {
     file, err := os.OpenFile("data-sorted.csv", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
     if err != nil {
          return err
     }
     defer file.Close()
     // Sort by last name
     sort.Slice(
           recs, 
           func(i, j int) bool { 
               return recs[i].last() < recs[j].last()
           },
     )
     for _, rec := range recs {
          _, err := file.Write(rec.csv())
          if err != nil {
               return err
          }
     }
     return nil
}

我们还可以修改record类型,添加这个新方法:

// csv outputs the data in CSV format.
func (r record) csv() []byte {
     b := bytes.Buffer{}
     for _, field := range r {
          b.WriteString(field + ",")
     }
     b.WriteString("\n")
     return b.Bytes()
}

你可以在play.golang.org/p/qBCDAsOSgS6看到这段代码的运行情况。

writeRecs()函数执行以下操作:

  • 它打开data-sorted.csv进行写入。

  • 它使用sort包中的sort.Slice()对记录进行排序。

  • 它循环遍历记录并写出 CSV 文件,这是由新的csv()方法生成的。

csv()方法执行以下操作:

  • 它创建了一个bytes.Buffer接口,类似于一个内存中的文件。

  • 它循环遍历记录中的每个字段,并写入字段值,后跟逗号。

  • 它在 CSV 行的内容后写入回车符。

  • 它返回一个[]bytes类型的缓冲区,现在表示单行数据。

使用encoding/csv

为了处理符合 RFC 4180 标准的 CSV 编码,www.rfc-editor.org/rfc/rfc4180.html,标准库提供了encoding/csv包。

开发者应选择使用此包处理符合此规范的 CSV。

这个包提供了两种类型来处理 CSV:

  • Reader用于读取 CSV。

  • Writer用于写入 CSV。

在本节中,我们将解决与之前相同的问题,但我们将使用ReaderWriter类型。

一行一行读取

与之前一样,我们想要一次读取文件中的每个 CSV 条目,并将其处理为record类型:

func readRecs() ([]record, error) { 
    file, err := os.Open("data.csv") 
    if err != nil { 
        return nil, err 
    } 
    defer file.Close() 
    reader := csv.NewReader(file) 
    reader.FieldsPerRecord = 2 
    reader.TrimLeadingSpace = true 
    var recs []record 
    for { 
        data, err := reader.Read() 
        if err != nil { 
            if err == io.EOF{ 
                break 
            } 
            return nil, err 
        } 
        rec := record(data) 
        recs = append(recs, rec) 
    } 
    return recs, nil 
}

你可以在go.dev/play/p/Sf6A1AbbQAq查看这段代码的实际运行情况。

这个函数利用我们的 reader 执行以下操作:

  • 将文件传递给我们的NewReader()构造函数。

  • 设置 reader 要求每条记录有两个字段。

  • 删除行首的空格。

  • 读取每条记录并将其存储在[]record切片中。

Reader类型还有其他字段可以改变数据的读取方式。更多信息请参考pkg.go.dev/encoding/csv

此外,Reader提供了一个ReadAll()方法,可以一次性读取所有记录。

一行一行写入

CSV 的Reader类型的伴侣,Writer,使得写入文件变得简单。让我们替换之前writeRecs()函数中的写入部分:

w := csv.NewWriter(file) 
defer w.Flush() 
for _, rec := range recs {
     if err := w.Write(rec); err != nil {
          return err
     }
}
return nil

这是可运行的代码:play.golang.org/p/7-dLDzI4b3M

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

  • 它生成一个新的Writer类型,写入我们的文件。

  • 它在函数退出时将内容刷新到文件。

  • 它将每条记录写出为 CSV 文件,每行一条。

在处理 Excel 时使用 excelize

微软的 Excel 自 1980 年代以来一直是可视化数据的流行工具。尽管该程序的功能不断增强,但它的简易性帮助电子表格成为大多数企业中常见的工具。

虽然 Excel 不是 CSV 格式,但它可以导入和导出 CSV 数据。对于基本用法,你可以使用本章前面详细介绍的encoding/csv包。

然而,如果你的组织使用 Excel,使用其原生格式来写入数据并提供数据的可视化展示会更有帮助。excelize 是一个第三方 Go 包,可以帮助你完成这项工作。

包含该包的地址为 github.com/qax-os/excelize/tree/v2。此外,官方文档可以在 xuri.me/excelize/ 查阅。

还有一个 Excel 的在线版本,作为微软 Office 365 的一部分。你可以直接在那儿操作电子表格;不过,我发现离线操作电子表格然后再导入会更方便。

如果你对 REST API 感兴趣,可以在 docs.microsoft.com/en-us/sharepoint/dev/general-development/excel-services-rest-api 阅读相关内容。

创建一个 .xlsx 文件并添加一些数据

Excel 有一些特性,对于理解它非常有帮助:

  • 一个 Excel 文件具有 .xlsx 扩展名。

  • 每个 .xlsx 文件包含工作表

  • 每个工作表包括一组行和列。

  • .xlsx 文件有一个默认的工作表,称为 Sheet1

  • 一行和一列的交点称为单元格

  • 列从字母 A 开始。

  • 行从数字 1 开始。

我们将添加一些代表虚构设备舰队的服务器数据。这些数据包括服务器名称、硬件代数、获取时间以及 CPU 厂商:

func main() {
    const sheet = "Sheet1"
    xlsx := excelize.NewFile()    
    xlsx.SetCellValue(sheet, "A1", "Server Name")
    xlsx.SetCellValue(sheet, "B1", "Generation")
    xlsx.SetCellValue(sheet, "C1", "Acquisition Date")
    xlsx.SetCellValue(sheet, "D1", "CPU Vendor")
    xlsx.SetCellValue(sheet, "A2", "svlaa01")
    xlsx.SetCellValue(sheet, "B2", 12)
    xlsx.SetCellValue(sheet, "C2", mustParse("10/27/2021"))
    xlsx.SetCellValue(sheet, "D2", "Intel")
    xlsx.SetCellValue(sheet, "A3", "svlac14")
    xlsx.SetCellValue(sheet, "B3", 13)
    xlsx.SetCellValue(sheet, "C3", mustParse("12/13/2021"))
    xlsx.SetCellValue(sheet, "D3", "AMD")
    if err := xlsx.SaveAs("./Book1.xlsx"); err != nil {
        panic(err)
    }
}

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

  • 它创建了一个 Excel 电子表格。

  • 它添加了列标签。

  • 它添加了两个服务器,slvaa01slvac14

  • 它保存了 Excel 文件。

有一个 mustParse() 函数(上面使用但未定义),它将表示日期的字符串转换为 time.Time。在 Go 中,当你看到函数名之前有 must 时,按惯例如果函数遇到错误,它会引发 panic。

你可以在 github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/5/excel/simple/excel.go 仓库中找到可运行的代码。

这个示例是向工作表添加数据的最简单方式。然而,它的可扩展性不强。让我们创建一个更具可扩展性的方法:

type serverSheet struct {
    mu sync.Mutex
    sheetName string
    xlsx *excelize.File
    nextRow int
}
func newServerSheet() (*serverSheet, error) {
    s := &serverSheet{
        sheetName: "Sheet1",
        xlsx: excelize.NewFile(),
        nextRow: 2,
    }
    s.xlsx.SetCellValue(s.sheetName, "A1", "Server Name")
    s.xlsx.SetCellValue(s.sheetName, "B1", "Generation")
    s.xlsx.SetCellValue(s.sheetName, "C1", "Acquisition")
    s.xlsx.SetCellValue(s.sheetName, "D1", "CPU Vendor")
    return s, nil
}

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

  • 它为管理我们的 Excel 工作表创建了一个 serverSheet 类型。

  • 它有一个构造函数来添加我们的列标签。

现在我们需要一些方法来添加数据:

func (s *serverSheet) add(name string, gen int, acquisition time.Time, vendor CPUVendor) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    if name == "" {
            return errors.New("name cannot be blank")
    }
    if gen < 1 || gen > 13 {
            return errors.New("gen was not in range")
    }
    if acquisition.IsZero() {
            return errors.New("acquisition must be set")
    }
    if !validCPUVendors[vendor] {
            return errors.New("vendor is not valid )
    }
    s.xlsx.SetCellValue(s.sheetName, "A" +
strconv.Itoa(s.nextRow), name)
    s.xlsx.SetCellValue(s.sheetName, "B" + strconv.Itoa(s.nextRow), gen)
    s.xlsx.SetCellValue(s.sheetName, "C" + strconv.Itoa(s.nextRow), acquisition)
    s.xlsx.SetCellValue(s.sheetName, "D" + strconv.Itoa(s.nextRow), vendor)
    s.nextRow++
    return nil
}

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

  • 它使用锁来防止多次调用。

  • 它执行非常基础的数据验证检查。

  • 它添加了一行,并递增我们的内部 nextRow 计数器。

现在我们有了一个更具可扩展性的方法来向工作表添加数据。接下来,让我们讨论如何总结数据。

数据汇总

有两种方式可以总结添加的数据:

  • 在我们的对象中跟踪汇总数据

  • Excel 数据透视表

对于我们的示例,我将使用第一种方法。这个方法有几个优点:

  • 它更容易实现。

  • 它执行更快的计算。

  • 它从电子表格中删除了复杂的计算。

然而,它有一个显著的缺点:

  • 数据变化不会影响汇总。

为了跟踪我们的数据汇总,让我们添加一个struct类型:

type summaries struct {
     cpuVendor cpuVendorSum
}
type cpuVendorSum struct {
     unknown, intel, amd int
}

让我们修改之前写的add()方法来总结我们的表格:

     ...
     s.xlsx.SetCellValue(s.sheetName, "D" + strconv.Itoa(s.nextRow), vendor)
     switch vendor {
     case Intel:
          s.sum.cpuVendor.intel++
     case AMD:
          s.sum.cpuVendor.amd++
     default:
          s.sum.cpuVndor.unknown++
     }
     s.nextRow++
     return nil
}
func (s *serverSheet) writeSummaries() {
    s.xlsx.SetCellValue(s.sheetName, "F1", "Vendor Summary")
    s.xlsx.SetCellValue(s.sheetName, "F2", "Vendor")
    s.xlsx.SetCellValue(s.sheetName, "G2", "Total")
    s.xlsx.SetCellValue(s.sheetName, "F3", Intel)
    s.xlsx.SetCellValue(s.sheetName, "G3", s.summaries.cpuVendor.intel)
    s.xlsx.SetCellValue(s.sheetName, "F4", AMD)
    s.xlsx.SetCellValue(s.sheetName, "G4", s.summaries.cpuVendor.amd)
}

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

  • 它查看我们的供应商并将其添加到我们的汇总计数器中。

  • 它添加了一个方法,将我们的汇总写入工作表。

接下来,让我们讨论如何使用这些数据添加可视化。

添加可视化

使用 Excel 而不是 CSV 进行输出的原因之一是添加可视化元素。这使得你可以快速生成用户可以查看的报告,这些报告比 CSV 更具吸引力,而且比网页更容易编写。

添加图表是通过AddChart()方法完成的。AddChart()接受一个表示 JSON 的字符串,用于指示如何构建图表。在我们的示例中,你将看到一个名为chart的包,它提取了excelize中的私有类型,这些类型用于表示图表,并将其转换为公共类型。通过这种方式,我们可以使用一个类型化的数据结构,而不是已经转换成该结构的 JSON。这样也方便了发现你可能想要设置的值:

func (s *serverSheet) createCPUChart() error {
    c := chart.New()

    c.Type = "pie3D"
    c.Dimension = chart.FormatChartDimension{640, 480}
    c.Title = chart.FormatChartTitle{Name: "Server CPU Vendor Breakdown"}
    c.Format = chart.FormatPicture{
            FPrintsWithSheet: true,
            NoChangeAspect: false,
            FLocksWithSheet: false,
            OffsetX: 15,
            OffsetY: 10,
            XScale: 1.0,
            YScale: 1.0,
    }
    c.Legend = chart.FormatChartLegend{
            Position: "bottom",
            ShowLegendKey: true,
    }
    c.Plotarea.ShowBubbleSize = true
    c.Plotarea.ShowCatName = true
    c.Plotarea.ShowLeaderLines = false
    c.Plotarea.ShowPercent = true
    c.Plotarea.ShowSerName = true
    c.ShowBlanksAs = "zero"
    c.Series = append(
            c.Series,
            chart.FormatChartSeries{
                    Name: `%s!$F$1`,
                    Categories: fmt.Sprintf(`%s!$F$3:$F$4`, s.sheetName),
                    Values: fmt.Sprintf(`%s!$G$3:$G$4`, s.sheetName),
            },
    )
    b, err := json.Marshal(c)
    if err != nil {
            return err
    }
    if err := s.xlsx.AddChart(s.sheetName,  "I1", string(b)); err != nil {
            return err
    }
    return nil
}

这段代码执行以下操作:

  • 它创建了一种新的 3D 饼图类型。

  • 它设置了尺寸、标题和图例。

  • 它应用了图表的值和类别。

  • 它将图表的指令转化为 JSON 格式。

  • 它调用AddChart将图表插入到工作表中。

你可以在以下代码库中找到可运行的代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/5/excel/visualization

因此,我们已经涵盖了使用 Excel 输出报告的基本要求。还有许多其他选项,包括插入图片、数据透视表和高级格式化指令。尽管我们不推荐使用 Excel 作为系统的数据输入或数据存储格式,但它对于汇总和查看数据来说,是一个有用的数据输出系统。

流行的编码格式

CSV 是 DevOps 工程师会遇到的最基础的人类可读编码格式之一,但它绝不是唯一的。在过去的二十年里,出现了几种新格式,它们用于传输信息或为应用程序提供配置。

JavaScript 对象表示法JSON)是一种数据序列化格式,旨在将 JavaScript 对象转换为文本表示形式,以便保存或传输。由于其简洁性和清晰性,这种标记法已经被几乎所有语言采纳,用于数据传输。

另一种标记语言YAML)是一种数据序列化格式,常用于存储服务的配置信息。YAML 是 Kubernetes 集群的主要配置语言。

在这一部分,我们将讨论如何将数据从 Go 类型转换为这些格式,再从这些格式转换回 Go 类型的方式。

Go 字段标签

Go 有一个叫做字段标签(field tags)的功能,允许开发人员向 struct 字段添加字符串标签。这使得 Go 程序在执行操作之前,可以查看有关字段的额外元数据。标签是键/值对:

type Record struct {
     Last string `json:"last_name"`
}

在前面的代码片段中,你可以看到一个 struct 类型,包含一个名为 Last 的字段,该字段具有字段标签。字段标签是一个内联原始字符串。原始字符串用反引号表示。这将生成一个键为 "json"、值为 "last_name" 的标签。

Go 包可以使用 reflect 包来读取这些标签。这些标签使得包能够根据标签数据更改操作的行为。在这个例子中,它告诉我们的 JSON 编码器包在写入 JSON 数据时使用 last_name 而不是 Last,反之亦然。

这个特性对处理数据序列化的包至关重要。

JSON

在过去的十年中,JSON 格式已经成为数据编码到磁盘并通过 RPC 与服务通信的事实标准。在云领域,没有支持 JSON 的语言是无法成功的。

开发人员可能会遇到将 JSON 作为应用程序配置语言的情况,但由于以下原因,它并不适合这个任务:

  • 缺乏多行字符串

  • 无法添加注释

  • 对标点符号的苛求(也就是说,机器适用,人类不适用)

对于数据交换,JSON 在一些小缺点的情况下仍然非常有用,以下是其中的一些:

  • 无模式

  • 非二进制格式

  • 缺乏字节数组支持

架构(schema)是对消息内容的定义,它存在于代码之外。

无模式意味着没有严格的定义来说明一个消息包含什么内容。这意味着,对于每种受支持的语言,我们必须为该语言创建消息的定义。诸如协议缓冲区(protocol buffers)等格式已经进入这个领域,提供了一个可以用来为任何语言生成代码的架构。

JSON 也是一种人类可读的格式。这类格式在大小和速度上不如二进制格式高效。通常,当试图扩展大规模服务时,这一点很重要。然而,许多人更喜欢人类可读的格式,因为它们易于调试。

JSON 不支持字节数组也是一个缺陷。虽然 JSON 仍然可以传输原始字节,但它需要使用 base64 编码对字节进行编码和解码,并将其存储在 JSON 的 string 类型中。这需要额外的编码步骤,而这些步骤本不应存在。有几种 JSON 的超集(例如二进制 JSON,简称 BSON)包含字节数组类型,但它们并未广泛支持。

JSON 通过多种方式传递给用户:

  • 作为一个可以包含子消息的单一消息

  • 作为 JSON 消息的数组

  • 作为 JSON 消息流

JSON 最初的起源是作为一种格式,用于简单地编码 JavaScript 对象以进行传输。然而,随着使用场景的增多,发送大消息或消息流的需求也成为了一个实际应用场景。

单个大消息可能很难解码。通常,JSON 解码器会读取整个消息到内存中,并验证消息的内容。

为了简化大量的消息集或流式内容,你可能会遇到一个被括号[]包围的消息集合,或者是由回车符分隔的单个消息。这些不是按预期的有效 JSON,但已经成为处理大量数据作为小的、单独的消息组成整个流的事实标准。

因为 JSON 是云生态系统中的标准部分,Go 语言在标准库的encoding/json包中提供了内置支持。在接下来的部分中,我们将详细介绍使用 JSON 包的最常见方法。

map进行编码和解码

由于 JSON 没有固定的模式,因此在流或文件中可能存在不同类型的消息。这通常是不可取的,最好有一个顶级消息来包含这些不同类型的消息。

当你需要处理多种消息类型或对消息进行发现时,Go 允许你将消息解码成map[string]interface{},其中string键表示字段名,interface{}表示值。

让我们来看一个将文件解码成map的示例:

b, err := os.ReadFile("data.json") 
if err != nil { 
    return "", 
    err
} 
data := map[string]interface{}{} 
if err := json.Unmarshal(b, &data); err != nil { 
    return "", err 
}
v, ok := data["user"]
if !ok {
     return "", errors.New("json does not contain key 'user'")
}
switch user := v.(type) {
case string:
     return user, nil
}
return "", fmt.Errorf("key 'user' is not a string, was %T", v)

上面的示例执行了以下操作:

  • 它将data.json文件的内容读取到变量b中。

  • 它创建一个名为datamap,用于存储我们的 JSON 内容。

  • 它将原始字节解码为 JSON,存储到data中。

  • 它查找data中的user键。

  • 如果user不存在,我们返回一个错误。

  • 如果确实存在,我们通过type assert来确定值的类型。

  • 如果值是字符串,我们返回其内容。

  • 如果值不是字符串,我们返回一个错误。

使用map,我们可以探索数据中的值以发现消息类型,type assertinterface{}值断言为具体类型,然后使用该具体值。记住,类型断言将interface变量转换为另一个interface变量或具体类型,例如stringint64

使用map是 JSON 数据解码中最复杂的方法。仅在 JSON 不可预测且无法控制数据提供者的情况下推荐使用这种方法。通常,最好是让数据提供者改变其行为,而不是以这种方式进行解码。

map编码为 JSON 很简单:

if err := json.Marshal(data); err != nil {
     return err
}

json.Marshal会读取我们的map并输出有效的 JSON 内容。[]byte字段会自动以base64编码转换为 JSON 的string类型。

对结构体进行编码和解码

JSON 解码的首选方法是在 Go 的 struct 类型中进行,这个类型表示数据。以下是创建用户记录结构体的示例,我们将使用它来解码 JSON 流:

type Record struct {
     Name string `json:"user_name"`
     User string `json:"user"`
     ID int
     Age int `json:"-"`
}
func main() {
     rec := Record{
          Name: "John Doak",
          User: "jdoak",
          ID: 23,
     }
     b, err := json.Marshal(rec)
     if err != nil {
          panic(err)
     }
     fmt.Printf("%s\n", b)
}

上述代码输出 {"user_name":"John Doak","user":"jdoak","ID":23}。你可以在 play.golang.org/p/LzoUpOeEN9y 找到可运行的代码。

这段代码做了以下操作:

  • 它定义了一个 Record 类型。

  • 它使用字段标签告诉 JSON 输出字段映射应为怎样。

  • 它在 Age 字段上使用了 - 的字段标签,以便该字段不会被序列化。

  • 它创建了一个名为 recRecord 类型。

  • 它将 rec 序列化为 JSON。

  • 它打印出了 JSON。

请注意,Name 字段被转换为 user_nameUser 转换为 userID 字段在输出中没有改变,因为我们没有使用字段标签。Age 字段没有输出,因为我们使用了 - 的字段标签。

由于以小写字母开头的字段是私有的,因此无法导出。这是因为 JSON 序列化器在另一个包中,无法看到当前包中的私有类型。

你可以在 encoding/json GoDoc 中阅读 JSON 支持的字段标签,位于 Marshal() 下 (pkg.go.dev/encoding/json#Marshal)。

JSON 包还包括 MarshalIndent(),它可以用来输出更易读的 JSON,其中字段之间有行分隔符和缩进。

将数据解码为 struct 类型(例如前面的 Record)可以如下进行:

rec := Record{}
if err := json.Unmarshal(b, &rec); err != nil {
     return err
}

这将表示 JSON 的文本转换为存储在 rec 变量中的 Record 类型。你可以在 play.golang.org/p/DD8TrKgTUwE 找到可运行的代码。

序列化和反序列化大型消息

有时,我们可能会收到包含 JSON 消息列表的 JSON 消息流或文件。

Go 提供了 json.Decoder 来处理一系列消息。以下是借自 GoDoc 的示例,其中每条消息由回车符分隔:

const jsonStream = `
     {"Name": "Ed", "Text": "Knock knock."}
     {"Name": "Sam", "Text": "Who's there?"}
`
type Message struct {
     Name, Text string
}
reader := strings.NewReader(jsonStream)
dec := json.NewDecoder(reader)
msgs := make(chan Message, 1)
errs := make(chan error, 1)
// Parse the messages concurrently with printing the message.
go func() {
     defer close(msgs)
     defer close(errs)
     for {
          var m Message
          if err := dec.Decode(&m); err == io.EOF {
               break
          } else if err != nil {
               errs <- err
               return
          }
          msgs <- m
     }
}()
// This will print the messages as we decode them.
for m := range msgs {
     fmt.Printf("%+v\n", m)
}
if err := <-errs; err != nil {
     fmt.Println("stream error: ", err)
}

你可以在 play.golang.org/p/kqmSvfdK4EG 查看此运行中的代码。

这个例子做了以下操作:

  • 它定义了一个 Message 结构体。

  • 它通过 strings.NewReader()jsonStream 原始输出包装在 io.Reader 中。

  • 它启动了一个 goroutine,解码消息并将其放入通道中。

  • 它读取所有发送的消息,直到输出通道被关闭。

  • 它打印出遇到的任何错误。

有时,这种流格式会在消息周围加上括号 [],并使用逗号作为条目之间的分隔符。

在这种情况下,我们可以利用解码器的另一个特性,dec.Token(),来安全地移除它们:

const jsonStream = `[
     {"Name": "Ed", "Text": "Knock knock."},
     {"Name": "Sam", "Text": "Who's there?"}
]`
dec := json.NewDecoder(reader)
_, err := dec.Token() // Reads [
if err != nil {
     return fmt.Errorf(`outer [ is missing`))
}
for dec.More() {
     var m Message
     // decode an array value (Message)
     err := dec.Decode(&m)
     if err != nil {
          return err
     }
     fmt.Printf("%+v\n", m)
}
_, err = dec.Token() // Reads ]
if err != nil {
     return fmt.Errorf(`final ] is missing`)
}

你可以在 play.golang.org/p/_PrUVUy4zRv 查看此运行中的代码。

这段代码以相同的方式工作,只是它移除了外括号,并且要求使用逗号分隔的列表。

在流中编码数据与解码非常相似。我们可以将 JSON 消息写入 io.Writer,以输出到流。以下是一个示例:

func encodeMsgs(in chan Message, output io.Writer) chan error {
     errs := make(chan error, 1)
     go func() {
          defer close(errs)
          enc := json.NewEncoder(output)
          for msg := range in {
               if err := enc.Encode(msg); err != nil {
                    errs <- err
                    return
               }
          }
     }()
     return errs
}

你可以在 play.golang.org/p/ELICEC4lcax 查看这段代码的运行情况。

这段代码的作用如下:

  • 它从 Message 类型的 channel 中读取数据。

  • 它写入 io.Writer

  • 它返回一个信号通道,表示编码器完成处理。

  • 如果返回了错误,意味着编码器遇到了问题。

这将 JSON 输出为分隔的值,不带括号。

JSON 的最终思考

encoding/json 包支持其他解码方法,这些方法在这里未涉及。你可以将 map[string]interface{} 混合到 struct 类型中,反之亦然,或者你可以逐个解码每个字段和数值。

然而,最佳的使用场景是那些直接的 struct 类型,作为单个值或值流。

这就是为什么 encoding/json 是我在编码或解码 JSON 值时的首选方法。它不是最快的,但它是最灵活的。

还有其他第三方库可以提高吞吐量,但会牺牲一些灵活性。这里列出了一些你可能想考虑的包:

YAML 编码

YAML(另一个标记语言/YAML 不等于标记语言)是一种常用于编写配置的语言。

YAML 是 Kubernetes 等服务的默认语言,用于保存配置,作为 DevOps 工程师,你很可能会在各种应用中遇到它。

YAML 相对于 JSON 在配置使用中有一些优势:

  • 支持注释

  • 对人类更灵活,如不带引号的字符串和带引号的字符串

  • 多行字符串

  • 使用锚点和引用来避免重复相同的文本数据

YAML 经常被认为有以下缺点:

  • 它是无模式的。

  • 规范很庞大,某些功能可能令人困惑。

  • 大文件可能会出现缩进错误而未被注意到。

  • 某些语言中的实现可能会意外执行嵌入在 YAML 中的代码。这可能会导致软件项目中的一些安全补丁。

Go 标准库并不原生支持 YAML,但它有一个第三方库,已经成为 YAML 序列化的事实标准包,名为 go-yaml (github.com/go-yaml/yaml)。

接下来,让我们讨论如何读取这些 YAML 文件来读取我们的配置。

对映射的序列化和反序列化

YAML 和 JSON 一样是无模式的,并且有相同的缺点。然而,与 JSON 不同的是,YAML 旨在表示配置,因此我们不需要像处理 JSON 那样去流式处理内容。

对于 YAML,一般的使用情况将涉及编码/解码为 struct 类型而不是 map。然而,如果您需要消息发现,YAML 可以像我们处理 JSON 一样处理 map 解码。

让我们看一个将文件解组为 map 的示例:

data := map[string]interface{}{}
if err := yaml.Unmarshal(yamlContent, &data); err != nil {
     return "", err
}
v, ok := data["user"]
if !ok {
     return "", errors.New("'user' key not found")
}

前面的示例做了以下几件事情:

  • 它创建了一个名为 datamap 来存储我们的 YAML 内容。

  • 它将表示 YAML 的原始字节解组为 data

  • 它查找 data 中的 user 键。

  • 如果 user 不存在,则返回错误。

要查看更完整的示例,请参阅 play.golang.org/p/wkHkmu47e6V

map 编组为 YAML 是简单的:

if err := yaml.Marshal(data); err != nil {
     return err
}

在这里,yaml.Marshal() 将读取我们的 map 并为其内容输出有效的 YAML。

编组和解组为结构体

struct 序列化是处理 YAML 的首选方式。由于 YAML 是一种配置语言,程序必须预先知道可用的字段以设置程序参数。

YAML 序列化的工作方式与 JSON 序列化类似,您会在大多数数据序列化包中找到这种相似性:

type Config struct {
     Jobs []Job
}
type Job struct {
     Name     string
     Interval time.Duration
     Cmd      string
}
func main() {
     c := Config{
          Jobs: []Job{
               {
                    Name:     "Clear tmp",
                    Interval: 24 * time.Hour,
                    Cmd:      "rm -rf " + os.TempDir(),
               },
          },
     }
     b, err := yaml.Marshal(c)
     if err != nil {
          panic(err)
     }
     fmt.Printf("%s\n", b)
}

您可以在 play.golang.org/p/SvJHLKBsdUP 上看到这段正在运行的代码。

这将输出以下内容:

jobs:
- name: Clear tmp dir
  interval: 24h0m0s
  cmd: rm -rf /tmp

前面的代码做了以下几件事情:

  • 它创建了一个名为 Config 的顶级配置。

  • 它创建了一个名为 Job 的子消息列表。

  • 它将示例编组为文本表示。

解组同样简单:

     data := []byte(`
jobs:
  - name: Clear tmp
    interval: 24h0m0s
    whatever: is not in the Job type
    cmd: rm -rf /tmp
`)
     c := Config{}
     if err := yaml.Unmarshal(data, &c); err != nil {
          panic(err)
     }
     for _, job := range c.Jobs {
          fmt.Println("Name: ", job.Name)
          fmt.Println("Interval: ", job.Interval)
     }

前面的代码做了以下几件事情:

  • 它接受由数据表示的 YAML 配置。

  • 它将其转换为 Config 类型。

  • 它打印出包含的 Job 信息。

  • 它忽略了 whatever 字段。

此代码将忽略未知的 whatever 字段。然而,在许多情况下,您不希望忽略可能拼写错误的字段。在这些情况下,我们可以使用 UnmarshalStrict()

这将导致此代码失败,并显示以下消息:

line 5: field whaterver not found in type main.Job

使用 UnmarshalStrict() 时,您必须在将其添加到配置文件之前将新字段支持到您的程序中并部署它们,否则会导致旧的二进制文件失败。

YAML 最终思考

github.com/go-yaml/yaml 包支持其他我们这里不会涵盖的序列化方法。其中最常用的一种是解码为 yaml.Node 对象,以保留注释,然后更改内容并重新写入配置。然而,这相对不常见。

在本节中,您已经学会如何使用 JSON 和 YAML 读取和写入它们各自的数据格式。在下一节中,我们将探讨如何与常用于存储数据的 SQL 数据源进行交互。

总结

这也标志着我们关于使用常见数据格式的章节结束。我们已经涵盖了如何读取和写入 CSV 文件以及 Excel 报告。此外,我们还学习了如何在 JSON 和 YAML 格式中进行数据编码和解码。本章节展示了如何在流中解码数据,同时强化了使用 goroutine 并发读取和使用数据的方法。

你刚学到的 JSON 技能将在我们下一章立即派上用场。在那一章中,我们将学习如何连接 SQL 数据库并与 RPC 服务进行交互。由于 REST RPC 服务和像 Postgres 这样的数据库可以使用 JSON,这项技能将非常有用。

那么,我们开始吧!

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

在上一章中,我们讨论了如何处理常见的数据格式,并展示了如何读取和写入这些格式的数据。但在那一章中,我们仅仅处理了可以通过文件系统访问的数据。

虽然文件系统实际上可能通过网络文件系统NFS)或服务器消息块SMB)等服务,拥有存储在远程设备上的文件,但也存在其他远程数据源。

在本章中,我们将讨论一些常见的方式,用于在远程数据源中发送和接收数据。本章将重点介绍如何使用结构化查询语言SQL)、表述性状态转移REST)和Google 远程过程调用gRPC)来访问远程系统中的数据。你将学习如何访问常见的 SQL 数据存储,重点是 PostgreSQL。我们还将探讨如何使用 REST 和 gRPC 风格的 RPC 方法创建和查询远程过程调用RPC)服务。

通过在这里学到的技能,你将能够连接并查询 SQL 数据库中的数据,向数据库添加新条目,向服务请求远程操作,并从远程服务获取信息。

本章我们将讨论以下主题:

  • 访问 SQL 数据库

  • 开发 REST 服务和客户端

  • 开发 gRPC 服务和客户端

在下一节中,我们将深入探讨使用最古老的数据格式之一——逗号分隔值CSV)。

让我们开始吧!

技术要求

本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/6/grpc下载。

访问 SQL 数据库

DevOps 工程师通常需要访问存储在数据库系统中的数据。SQL 是与数据库系统通信的标准,是 DevOps 工程师在日常工作中会遇到的内容。

Go 提供了一个标准库,用于与基于 SQL 的系统交互,称为database/sql。该包提供的接口,结合数据库驱动程序,允许用户与多种不同的 SQL 数据库进行操作。

在本节中,我们将学习如何使用 Go 访问 Postgres 数据库,以执行基本的 SQL 操作。

重要提示

本节中的示例将要求你设置一个 Postgres 数据库。此内容超出了本书的范围。本书并不讲解 SQL。需要具备一些基本的 SQL 知识。

你可以在www.postgresql.org/download/找到关于如何为你的操作系统安装 Postgres 的信息。如果你更倾向于在本地 Docker 容器中运行 Postgres,可以在hub.docker.com/_/postgres找到相关信息。

连接到 Postgres 数据库

要连接到 Postgres 数据库,需要使用 Postgres 的数据库驱动程序。目前推荐的第三方包是github.com/jackc/pgx。该包实现了一个适用于database/sql的 SQL 驱动程序,并提供了自己的方法/类型来支持 Postgres 特定功能。

使用database/sql或 Postgres 特定类型的选择取决于是否需要确保不同数据库之间的兼容性。使用database/sql允许你编写适用于任何 SQL 数据库的函数,而使用 Postgres 特定功能则移除了兼容性,使迁移到其他数据库变得更加困难。我们将讨论如何使用这两种方法执行我们的示例。

下面是如何使用标准 SQL 包连接而不使用额外的 Postgres 功能:

/* 
dbURL might look like:
"postgres://username:password@localhost:5432/database_name"
*/
conn, err := sql.Open("pgx", dbURL)
if err != nil {
     return fmt.Errorf("connect to db error: %s\n", err)
}
defer conn.Close()
ctx, cancel := context.WithTimeout(
     context.Background(), 
     2 * time.Second
)
if err := conn.PingContext(ctx); err != nil {
  return err
}
cancel()

在这里,我们使用pgx驱动程序打开一个 Postgres 连接,该驱动程序将在导入以下包时注册:

_ "github.com/jackc/pgx/v4/stdlib"

这是一个匿名导入,意味着我们没有直接使用stdlib。这是在我们想要产生副作用时使用的,例如在使用database/sql包注册驱动程序时。

Open()调用不会测试我们的连接。你会看到conn.PingContext()来测试我们是否能够向数据库发起请求。

当你想要使用pgx-specific类型来操作 Postgres 时,设置略有不同,从不同的包导入开始:

"github.com/jackc/pgx/v4/pgxpool"

要创建该连接,请键入以下内容:

conn, err := pgxpool.Connect(ctx, dbURL)
if err != nil {
     return fmt.Errorf("connect to db error: %s\n", err)
}
defer conn.Close(ctx)

这使用连接池连接到数据库,以提高性能。你会注意到我们没有PingContext()调用,因为本地连接会在Connect()过程中自动测试连接。

现在你知道如何连接到 Postgres 了,接下来我们来看看如何进行查询。

查询 Postgres 数据库

让我们考虑一下如何向你的 SQL 数据库发起请求,获取存储在表中的用户信息。

使用标准库,键入以下内容:

type UserRec struct {
     User string
     DisplayName string
     ID int
}
func GetUser(ctx context.Context, conn *sql.DB, id int) (UserRec, error) {
     const query = `SELECT "User","DisplayName" FROM users WHERE "ID" = $1`
     u := UserRec{ID: id}
     err := conn.QueryRowContext(ctx, query, id).Scan(&u)
     return u, err
}

这个示例执行了以下操作:

  • 创建UserRec以存储用户的 SQL 数据

  • 创建一个名为query的查询语句

  • 查询我们的数据库,获取请求的 ID 对应的用户

  • 返回UserRec和一个错误(如果有的话)

我们可以通过在对象中使用预准备语句,而不是仅仅使用函数来提高此示例的效率:

type Storage struct {
     conn *sql.DB
     getUserStmt *sql.Stmt
}
func NewStorage(ctx context.Context, conn *sql.DB) *Storage
{
     return &Storage{
          getUserStmt: conn.PrepareContext(
               ctx,
               `SELECT "User","DisplayName" FROM users WHERE "ID" = $1`,
          )
     }
}
func (s *Storage) GetUser(ctx context.Context, id int) (UserRec, error) {
     u := UserRec{ID: id}
     err := s.getUserStmt.QueryRow(id).Scan(&u)
     return u, err
}

这个示例执行了以下操作:

  • 创建一个可重用的对象

  • 存储*sql.Stmt,这可以提高重复查询时的效率

  • 定义一个NewStorage构造函数来创建我们的对象

由于使用标准库的通用性,在这些示例中,任何*sql.DB的实现都可以使用。只要 MariaDB 有相同的表名和格式,切换 Postgres 为 MariaDB 也是可行的。

如果我们使用 Postgres 特定的库,代码将如下所示:

err = conn.QueryRow(ctx, query).Scan(&u)
return u, err

这种实现方式看起来和工作方式与标准库类似。但这里的 conn 对象是一个不同的非接口类型 pgxpool.Conn,而不是 sql.Conn。尽管功能相似,pgxpool.Conn 对象支持 Postgres 特有的类型和语法,例如 jsonb,而 sql.Conn 不支持。

在使用 Postgres 特有的调用时,不需要为非事务操作使用预处理语句。调用信息会自动缓存。

上面的示例比较简单,我们只是拉取了一个特定的条目。如果我们想要一个方法来检索所有 ID 在两个数字之间的用户呢?我们可以使用标准库来定义:

/* 
stmt contains `SELECT "User","DisplayName","ID" FROM users 
WHERE "ID" >= $1 AND "ID" < $2`
*/
func (s *Storage) UsersBetween(ctx context.Context, start, end int) ([]UserRec, error) {
     recs := []UserRec{}
     rows, err := s.usersBetweenStmt(ctx, start, end)
     defer rows.Close()
     for rows.Next() {
          rec := UserRec{}
          if err := rows.Scan(&rec); err != nil {
               return nil, err
          }
          recs = append(recs, rec)
     }
     return recs, nil
}

Postgres 特有的语法保持不变,只是将 s.usersBetweenStmt() 替换成了 conn.QueryRow()

空值

SQL 有一个空值概念,适用于布尔值、字符串和 int32 等基本类型。Go 没有这个约定;相反,它为这些类型提供了零值。

当 SQL 允许某列具有空值时,标准库提供了在 database/sql 中的特殊空类型:

  • sql.NullBool

  • sql.NullByte

  • sql.NullFloat64

  • sql.NullInt16

  • sql.NullInt32

  • sql.NullInt64

  • sql.NullString

  • sql.NullTime

当设计你的模式时,最好使用零值而不是空值。但有时,你需要区分一个值是否已设置与零值。在这种情况下,你可以使用这些特殊类型代替标准类型。

例如,如果我们的 UserRec 可能有一个空的 DisplayName,我们可以将 string 类型更改为 sql.NullString

type UserRec struct {
     User string
     DisplayName sql.NullString
     ID int
}

你可以在这里查看服务器如何根据 DisplayName 列的值设置这些值的示例:go.dev/play/p/KOkYdhcjhdf

向 Postgres 写入数据

向数据库写入数据很简单,但需要考虑语法。用户写入数据时最常见的两个操作如下:

  • 更新现有条目

  • 插入新条目

在标准 SQL 中,你不能执行 如果存在则更新条目;如果不存在则插入。由于这是一个常见操作,每个数据库都有自己独特的语法来完成此操作。在使用标准库时,你必须在执行更新或插入之间做出选择。如果你不知道条目是否存在,你将需要使用事务,稍后我们会详细说明。

执行更新或插入只是使用不同的 SQL 语法和 ExecContext() 调用:

func (s *Storage) AddUser(ctx context.Context, u UserRec) error {
     _, err := s.addUserStmt.ExecContext(
          ctx,
          u.User,
          u.DisplayName,
          u.ID,
     )
     return err
}
func (s *Storage) UpdateDisplayName(ctx context.Context, id int, name string) error {
     _, err := s.updateDisplayName.ExecContext(
          ctx,
          name,
          id,
     )
     return err
}

在这个示例中,我们添加了两个方法:

  • AddUser() 将新用户添加到系统中。

  • UpdateDisplayName() 更新具有特定 ID 的用户的显示名称。

  • 两者都使用 sql.Stmt 类型,它将作为对象中的一个字段,类似于 getUserStmt

使用 Postgres 原生包实现时的主要区别在于调用的方法名称,以及没有预处理语句。实现 AddUser() 的代码如下:

func (s *Storage) AddUser(ctx context.Context, u UserRec) error {
     const stmt = `INSERT INTO users (User,DisplayName,ID)
     VALUES ($1, $2, $3)`
     _, err := s.conn.Exec(
          ctx, 
          stmt, 
          u.User, 
          u.DisplayName, 
          u.ID,
     )
    return err 
}

有时候,仅仅对数据库进行读写操作是不够的。有时,我们需要原子性地执行多个操作,并将它们视为一个整体。因此,在下一节中,我们将讨论如何使用事务来实现这一点。

事务

事务提供了一系列在服务器上作为一个整体执行的 SQL 操作。它通常用于提供某种类型的原子操作,其中需要执行读和写,或者在执行写操作之前先读取数据。

在 Go 中,事务很容易创建。让我们创建一个 AddOrUpdateUser() 调用,在添加或更新数据之前检查用户是否存在:

func (s *Storage) AddOrUpdateUser(ctx context.Context, u UserRec) (err error) {
     const (
          getStmt = `SELECT "ID" FROM users WHERE "User" = $1`
          insertStmt = `INSERT INTO users (User,DisplayName,ID)
          VALUES ($1, $2, $3)`
          updateStmt = `UPDATE "users" SET "User" = $1,
          "DisplayName" = $2 WHERE "ID" = 3`
     )
     tx, err := s.conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
     if err != nil {
           return err
     }
     defer func() {
          if err != nil {
               tx.Rollback()
               return
          }
          err = tx.Commit()
     }()
     _, err := tx.QueryRowContext(ctx, getStmt, u.User)
     if err != nil {
          if err == sql.ErrNoRows {
               _, err = tx.ExecContext(ctx, insertStmt, u.User, u.DisplayName, u.ID)
               if err != nil {
                    return err
               }
          }
          return err
     }
     _, err = tx.ExecContext(ctx, updateStmt, u.User, u.DisplayName, u.ID))
     return err
}

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

  • 创建一个隔离级别为 LevelSerializable 的事务。

  • 使用 defer 语句来判断是否发生了错误:

    • 如果找到了用户,我们会回滚整个事务。

    • 如果没有找到用户,我们尝试提交事务。

  • 查询用于查找用户是否存在:

    • 它通过检查错误类型来确定这一点。

    • 如果错误是 sql.ErrNoRows,说明我们没有找到该用户。

    • 如果错误是其他类型,则表示系统错误。

  • 如果我们没有找到该用户,则执行插入语句。

  • 如果我们找到了该用户,则执行更新语句。

事务的关键要素如下:

  • conn.BeginTx,用于开始事务

  • tx.Commit(),用于提交我们的更改

  • tx.Rollback(),用于回滚我们的更改

defer 语句是一种很好的方式来处理创建事务后执行 Commit()Rollback()。它确保在函数结束时,无论如何都会执行其中一个操作。

隔离级别对事务很重要,因为它会影响系统的性能和可靠性。Go 提供了多个隔离级别;然而,并非所有数据库系统都支持所有的隔离级别。

你可以在这里了解更多关于隔离级别的内容:en.wikipedia.org/wiki/Isolation_(database_systems)#Isolation_levels

特定于 Postgres 的类型

到目前为止,我们已经向你展示了如何使用标准库和特定于 Postgres 的对象与 Postgres 交互。但我们还没有真正展示使用 Postgres 对象的充分理由。

当你需要使用不是 SQL 标准的一部分的类型或功能时,Postgres 对象非常有用。让我们重写我们的事务示例,但这次我们不通过标准列来存储数据,而是让我们的 Postgres 数据库只包含两个列:

  • int 类型的 ID

  • jsonb 类型的数据

jsonb 不是 SQL 标准的一部分,不能使用标准 SQL 库实现。jsonb 可以极大简化你的工作,因为它允许你在查询时使用 JSON 字段来存储 JSON 数据:

func (s *Storage) AddOrUpdateUser(ctx context.Context, u UserRec) (err error) {
     const (
          getStmt = `SELECT "ID" FROM "users" WHERE "ID" = $1`
          updateStmt = `UPDATE "users" SET "Data" = $1 WHERE "ID" = $2`
          addStmt = `INSERT INTO "users" (ID,Data) VALUES ($1, $2)`
     )
     tx, err := conn.BeginTx(
          ctx , 
          pgx.TxOptions{
               IsoLevel: pgx.Serializable,
               AccessMode: pgx.ReadWrite,
               DeferableMode: pgx.NotDeferrable,
          },
     )
     defer func() {
          if err != nil {
               tx.Rollback()
               return
          }
          err = tx.Commit()
     }()
     _, err := tx.QueryRow(ctx, getUserStmt, u.ID)
     if err != nil {
          if err == sql.ErrNoRows {
               _, err = tx.ExecContext(ctx, insertStmt, u.ID, u)
               if err != nil {
                    return err
               }
          }
          return err
     }
     _, err = tx.Exec(ctx, updateStmt, u.ID, u)
     return err
}

这个示例在几个方面有所不同:

  • 它有额外的 AccessModeDeferableMode 参数。

  • 我们可以将我们的对象 UserRec 作为 Data jsonb 列传递。

访问模式和可延迟模式增加了额外的约束,这些约束在标准库中无法直接使用。

使用jsonb是一个福音。现在,我们可以在我们的表上使用WHERE子句进行搜索,这些子句可以过滤jsonb字段的值。

你还会注意到,pgx足够聪明,能够识别我们的列类型,并自动将我们的UserRec转换为 JSON。

如果你想了解更多关于 Postgres 值类型的信息,可以访问www.postgresql.org/docs/9.5/datatype.html

如果你想了解更多关于jsonb以及访问其值的函数,可以访问www.postgresql.org/docs/9.5/functions-json.html

其他选项

除了标准库和特定数据库的包之外,对象关系映射ORMs)也是常见的工具。ORM 是管理服务和数据存储之间数据的流行模式。

Go 最流行的 ORM 叫做GORM,你可以在这里找到:gorm.io/index.html

另一个流行的框架是 Beego,它也包括对 REST 和 Web 服务的支持,你可以在这里找到:github.com/beego/beego

存储抽象

许多开发者倾向于直接在代码中使用存储系统,传递一个数据库连接。这并不是最优的做法,因为它可能会导致以下问题:

  • 在存储访问之前添加缓存层。

  • 为你的服务迁移到一个新的存储系统。

抽象存储在一个内部应用程序编程接口API)接口的背后将允许你通过简单地实现接口并使用新的后端,来以后更换存储层。你可以随时插入新的后端。

这里有一个简单的例子,可能是为获取用户数据添加一个接口:

type UserStorage interface {
     User(ctx context.Context, id string) (UserRec, error)
     AddUser(ctx context.Context, u UserRec) error
     UpdateDisplayName(ctx context.Context, id string, name string) error
}

这个接口允许你使用 Postgres、本地文件、SQLite、Azure Cosmos DB、内存数据结构或任何其他存储介质来实现你的存储后端。

这样做的好处是能够通过插入一个新的实现来实现从一个存储介质到另一个存储介质的迁移。作为附加好处,你可以将测试与数据库解耦。大多数测试可以使用内存数据结构,这样可以测试你的功能,而无需启动和拆除基础设施,这对于真实数据库是必要的。

添加缓存层变成了一个简单的练习,只需要编写一个UserStorage实现,它在读取时调用缓存,当缓存中未找到时,调用你的数据存储实现。你可以替换原有实现,一切保持正常工作。

请注意,这里描述的所有关于通过接口进行抽象的内容适用于对服务数据的访问。SQL API 应该仅用于您的应用程序存储和读取数据。其他服务应使用稳定的 RPC 接口。这提供了相同类型的抽象,使您可以在不迁移用户的情况下更改数据后端。

案例研究——一个编排系统的数据迁移——Google

在我任职于 Google 期间,我参与的一个系统是用于自动化网络变更的编排系统。该系统接收自动化指令并执行这些指令,针对各种目标进行操作。这些操作可能包括通过安全文件传输协议SFTP)推送文件、与网络路由器交互、更新权威数据存储或运行状态验证。

在操作中,确保表示工作流状态的数据始终是最新的至关重要。这不仅包括当前正在运行的工作流,还包括用于创建新工作流的先前工作流的状态。

为了减轻我们的操作负担,我们希望将工作流的存储系统从 Bigtable 迁移到 Spanner。Bigtable 需要更复杂的设置来处理发生问题时的故障切换到备份单元,而 Spanner 的设计就包括了这一功能。这使我们在单元出现问题时无需干预。

存储层被隐藏在存储接口背后。存储在我们的main()函数中初始化,并传递给其他需要它的模块。这意味着我们可以用新的实现替换存储层。

我们实现了一个新的存储接口,将数据同时写入 Bigtable 和 Spanner,并从它们两个中读取数据,使用最新的数据戳,并在需要时更新记录。

这使得我们在历史数据传输期间可以同时使用两个数据存储。一旦同步完成,我们将二进制文件迁移到仅包含 Spanner 实现的版本。我们的迁移完成了,且在成千上万的关键操作运行时没有发生服务停机。

到目前为止,在本章中,我们已经学习了如何使用database/sql访问通用数据存储,并特别讨论了 Postgres。我们学习了如何读取和写入 Postgres,并实现事务。我们还讨论了使用database/sql与使用数据库特定库(如pgx)之间的优劣。最后,我们展示了如何通过将实现隐藏在接口抽象背后,使您能够更轻松地更改存储后端,并对依赖于存储的代码进行封闭测试。

接下来,我们将研究如何使用 REST 或 gRPC 访问 RPC 服务。

开发 REST 服务和客户端

在互联网和现如今充斥云空间的分布式系统之前,系统间通信的标准并未广泛应用。这种通信通常称为 RPC。简单来说,这意味着一台机器上的程序调用运行在另一台机器上的函数并接收输出。

单体应用曾是常态,服务器通常要么按应用程序进行隔离并垂直扩展,要么作为作业运行在更大、更专业的硬件上,这些硬件来自 IBM、Sun、SGI 或 Cray 等公司。当系统需要相互通信时,它们往往使用自己的定制协议格式,例如你在 Microsoft SQL Server 中看到的格式。

随着 2000 年代互联网的定义,单体大系统无法以合理的成本为像 Google 搜索或 Facebook 这样的服务提供计算能力。为了为这些服务提供支持,企业需要将大量标准 PC 视为一个整体系统。单一系统可以使用 Unix 套接字或共享内存调用在进程之间进行通信,但企业需要在不同机器上运行的进程之间有共同且安全的通信方式。

随着 HTTP 成为系统间通信的事实标准,当今的 RPC 机制使用某种形式的 HTTP 来进行数据传输。这使得 RPC 可以更轻松地穿越系统,如负载均衡器,并轻松利用安全标准,如传输层安全性TLS)。这还意味着,随着 HTTP 传输的升级,这些 RPC 框架可以借助数百甚至成千上万名工程师的努力。

在这一部分,我们将讨论最流行的 RPC 机制之一——REST。REST 使用 HTTP 调用和你选择的任何消息格式,尽管大多数情况下使用 JSON 作为消息格式。

用于 RPC 的 REST

在 Go 中编写 REST 客户端相当简单。如果你在过去的 10 年里一直在开发应用程序,那么你要么已经使用过 REST 客户端,要么已经编写过一个。像 Google Cloud Platform 的 Cloud Spanner、Microsoft 的 Azure Data Explorer 或 Amazon DynamoDB 等云服务的 API 都使用 REST 通过它们的客户端库与服务进行通信。

REST 客户端可以执行以下操作:

  • 使用 GETPOSTPATCH 或任何其他类型的 HTTP 方法。

  • 支持任何序列化格式(尽管通常是 JSON)。

  • 允许数据流传输。

  • 支持查询变量。

  • 使用 URL 标准支持多个版本的 API。

Go 中的 REST 也不需要任何框架即可在服务器端实现。所需的一切都包含在标准库中。

编写一个 REST 客户端

让我们编写一个简单的 REST 客户端,访问服务器并接收一个 POST 请求 – /v1/qotd

首先,让我们定义需要发送到服务器的消息:

type getReq struct {
     Author string `json:"author"`
}
type getResp struct {
     Quote string `json:"quote"`
     Error *Error `json:"error"`
}

让我们来讨论一下这些操作的作用:

  • getReq 详细说明了服务器 /v1/qotd 函数调用的参数。

  • getResp是我们期望从服务器函数调用中返回的内容。

我们使用字段标签来允许将小写键转换为我们的公共变量(这些变量首字母大写)。为了让encoding/json包能看到这些值并进行序列化,它们必须是公共的。私有字段将无法被序列化:

type Error struct {
     Code ErrCode
     Msg string
}
func (e *Error) Error() string {
     return fmt.Errorf("(code %v): %s", e.Code, e.Msg)
}

这定义了一个自定义错误类型。通过这种方式,我们可以存储错误代码并返回给用户。这个代码在我们的响应对象旁边定义,但直到稍后的代码中才会使用。

现在,让我们定义一个 QOTD 客户端和一个构造函数,进行一些基本的地址检查并创建一个 HTTP 客户端,以便我们能够向服务器发送数据:

type QOTD struct {
     addr string
     client *http.Client
}
func New(addr string) (*QOTD, error) {
     if _, _, err := net.SplitHostPort(addr); err != nil {
          return nil, err
     }
     return &QOTD{addr: addr, client: &http.Client{}}
}

下一步是创建一个通用函数,用于进行 REST 调用。由于 REST 非常开放,难以编写一个可以处理所有类型 REST 调用的函数。编写 REST 服务器时的最佳实践是只支持POST方法;永远不要使用查询变量和简单的 URL。然而,在实际应用中,如果你不控制服务,你将处理各种各样的 REST 调用类型:

func (q *QOTD) restCall(ctx context.Context, endpoint string, req, resp interface{}) error {
     if _, ok := ctx.Deadline(); !ok {
          var cancel context.CancelFunc
          ctx, cancel = context.WithDeadline(ctx, 2 * time.Second)
          defer cancel()
     }
     b, err := json.Marshal(req)
     if err != nil {
          return err
     }
     hReq, err := http.NewRequestWithContext(
          ctx, 
          http.POST, 
          endpoint,
          bytes.NewBuffer(b), 
     )
     if err != nil {
          return err
     }
     resp, err := q.client.Do(hReq)
     if err != nil {
          return err
     }
     b, err := io.ReadAll(resp.Body)
     if err != nil {
          return err
     }
     return json.Unmarshal(b, resp)
}

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

  • 检查我们的上下文是否有截止时间:

    • 如果它有值,则会被尊重。

    • 如果没有设置,则会设置一个默认值。

    • 在调用完成后,调用cancel()

  • 将请求转换为 JSON。

  • 创建一个新的*http.Request,执行以下操作:

    • 使用POST方法。

    • 与端点进行通信。

    • 拥有一个io.Reader,用于存储 JSON 请求。

  • 使用客户端发送请求并获取响应。

  • http.Response的正文中获取响应。

  • 将 JSON 反序列化为响应对象。

你会注意到reqresp都是interface{}类型。这使得我们可以将这个例程与任何表示 JSON 请求或响应的结构体一起使用。

现在,我们将在一个方法中使用它,通过作者获取 QOTD(每日名言):

func (q *QOTD) Get(ctx context.Context, author string) (string, error) {
     const endpoint = `/v1/qotd`
     resp := getResp{}
     err := q.restCall(ctx, path.Join(q.addr, endpoint), getReq{Author: author}), &resp)
     switch {
     case err != nil:
          return "", err
     case resp.Error != nil:
          return "", resp.Error
     }
     return resp.Quote, nil
}

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

  • 为我们的get函数定义一个端点。

  • 调用我们的restCall()方法,执行以下操作:

    • 使用path.Join()将我们的服务器地址和 URL 端点连接起来。

    • 创建一个getReq对象作为restCall()req参数。

    • 将响应读取到我们的resp响应对象中。

    • 如果*http.Client返回一个错误,我们就返回那个错误。

    • 如果resp.Error被设置,我们返回它。

  • 返回响应中的引用内容。

要查看它的运行效果,你可以访问这里:play.golang.org/p/Th0PxpglnXw

我们在这里展示了如何使用 HTTP POST请求和 JSON 来创建一个基础的 REST 客户端。然而,我们仅仅触及了创建 REST 客户端的表面。你可能需要向头部添加认证信息,使用JSON Web TokenJWT)。这使用了 HTTP 而不是 HTTPS,因此没有传输安全性。我们没有尝试使用压缩,如 Deflate 或 Gzip。

虽然使用http.Client很容易,但你可能需要一个更智能的封装器,它为你处理这些功能。值得一看的一个库是resty,可以在这里找到:github.com/go-resty/resty

编写一个 REST 服务

现在我们已经写好了客户端,接下来我们写一个 REST 服务端点来接收请求并将输出发送给用户:

type server struct {
     serv *http.Server
     quotes map[string][]string
}

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

  • 创建了服务器structwhich将充当我们的服务器

  • 使用*http.Server来提供 HTTP 内容

  • quotes,它存储作者作为键,值是一个名言切片

现在,我们需要一个构造函数:

func newServer(port int) (*server, error) {
     s := &server{
          serv: &http.Server{
               Addr: ":" + strconv.Itoa(port),
          },
          quotes: map[string][]string{
               // Add quotes here
          },
     }
     mux := http.NewServeMux()
     mux.HandleFunc(`/qotd/v1/get`, s.qotdGet)
     // The muxer implements http.Handler 
     // and we assign it for our server’s URL handling.
     s.serv.Handler = mux
     return s, nil
}
func (s *server) start() error {
     return s.serv.ListenAndServe()
}

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

  • 创建了一个newServer构造函数:

    • 这有一个port参数,指定运行服务器的端口。
  • 创建一个server实例:

    • 创建一个*http.Server实例,运行在:[port]端口上

    • 填充我们的quotes map

  • 添加*http.ServeMux来将 URL 映射到方法。

    注意

    我们稍后将创建qotdGet方法。

  • 创建一个名为start()的方法,用于启动我们的 HTTP 服务器。

*http.ServeMux实现了http.Handler接口,*http.Server使用该接口。ServeMux通过模式匹配来决定哪个方法与哪个 URL 匹配。你可以在这里了解模式匹配的语法:pkg.go.dev/net/http#ServeMux

现在,让我们创建一个方法来回答我们的 REST 端点请求:

func (s *server) qotdGet(w http.ResponseWriter, r *http.Request) {
     req := getReq{}
     if err := req.fromReader(r.Body); err != nil {
          http.Error(w, err.Error(), http.StatusBadRequest)
          return
     }
     var quotes []string
     if req.Author == "" {
          // Map access is random, this will randomly choose a            	          // set of quotes from an author.
          for _, quotes = range s.quotes {
               break
          }
     } else {
          var ok bool
          quotes, ok = s.quotes[req.Author]
          if !ok {
               b, err := json.Marshal(
                    getResp{
                         Error: &Error{
                              Code: UnknownAuthor,
                              Msg:  fmt.Sprintf("Author %q was not found", req.Author),
                        },
                    },
               )
               if err != nil {
                    http.Error(w, err.Error(), http.StatusBadRequest)
                    return
               }
               w.Write(b)
               return
          }
     }
     i := rand.Intn(len(quotes))
     b, err := json.Marshal(getResp{Quote: quotes[i]})
     if err != nil {
          http.Error(w, err.Error(), http.StatusBadRequest)
          return
     }
     w.Write(b)
     return

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

  • 实现了http.Handler接口。

  • 读取 HTTP 请求体并将其序列化到我们的getReq中:

    • 如果请求有问题,这段代码会使用http.Error()返回 HTTP 错误代码。
  • 如果请求中没有包含“author”字段,则随机选择一个作者的名言。

  • 否则,查找作者并获取他们的名言:

    • 如果该作者不存在,则响应getResp并包含一个错误信息。
  • 随机选择一句名言并返回给客户端。

现在,我们有了一个 REST 端点,它能够回答客户端的 RPC 请求。你可以在这里看到这段代码的运行:play.golang.org/p/Th0PxpglnXw

这只是构建 REST 服务的一个简单示例。你可以在此基础上构建认证、压缩、性能追踪等功能。

为了帮助快速启动并减少一些样板代码,以下是一些可能有用的第三方包:

既然我们已经讨论过使用 REST 进行 RPC,那么让我们来看看被全球大公司广泛采用的更快速的替代方案——gRPC。

开发 gRPC 服务和客户端

gRPC 提供了一个基于 HTTP 的 RPC 框架,并使用 Google 的协议缓冲区格式,这是一种二进制格式,可以转换为 JSON,但提供了架构,并且在许多情况下比 JSON 提供了 10 倍的性能提升。

这个领域还有其他格式,例如 Apache 的 Thrift、Cap’n Proto 和 Google 的 FlatBuffers。然而,这些格式并不如协议缓冲区流行且得到广泛支持,或者只满足某些特定领域的需求,而且也较难使用。

gRPC 和 REST 一样,是一种客户端/服务器框架,用于发起 RPC 调用。gRPC 的不同之处在于,它更倾向于使用一种叫做协议缓冲区(简称proto)的二进制消息格式。

该格式有一个存储在 .proto 文件中的架构,用于通过编译器生成客户端、服务器和消息,以适应你选择的语言的本地库。当 proto 消息被序列化用于在网络上传输时,二进制表示在所有语言中都是一样的。

让我们深入了解协议缓冲区,gRPC 选择的消息格式。

协议缓冲区

协议缓冲区在一个位置定义 RPC 消息和服务,并可以使用 proto 编译器为每种语言生成一个库。协议缓冲区具有以下优点:

  • 它们编写一次,生成适用于每种语言的代码。

  • 消息可以转换为 JSON 以及二进制格式。

  • gRPC 可以使用反向代理来提供 REST 端点,这对于 Web 应用程序来说非常好。

  • 二进制协议缓冲区更小,且编码/解码速度是 JSON 的 10 倍。

然而,协议缓冲区也有一些缺点:

  • 你必须在对 .proto 文件进行任何更改后重新生成消息,才能获得更改。

  • Google 的标准 proto 编译器使用起来既痛苦又令人困惑。

  • JavaScript 原生不支持 gRPC,尽管它支持协议缓冲区。

工具可以帮助解决一些负面问题,我们将使用新的Buf工具,buf.build,来帮助生成 proto 文件。

让我们看看一个用于 QOTD 服务的协议缓冲区 .proto 文件长什么样:

syntax = "proto3";
package qotd;
option go_package = "github.com/[repo]/proto/qotd";
message GetReq {
        string author = 1;
}
message GetResp {
        string author = 1;
        string quote = 2;
}
service QOTD {
   rpc GetQOTD(GetReq) returns (GetResp) {};
}

syntax 关键字定义了我们正在使用的 proto 语言的版本。最常见的版本是 proto3,它是该语言的第三个版本。三者的 wire 格式相同,但具有不同的特性集,且生成不同的语言包。

package 定义了 proto 包名,这使得该协议缓冲区可以被其他包导入。我们用 [repo] 作为占位符来表示 GitHub 仓库。

go_package 在生成 Go 文件时专门定义了包名。尽管这被标记为 option,但在为 Go 编译时它是必需的。

message定义了一种新的消息类型,在 Go 中被生成为structmessage中的条目详细说明了字段。string author = 1struct GetReq中创建了一个名为Authorstring类型字段。1是 proto 中的字段位置。你不能在消息中有重复的字段号,字段号永远不应该更改,字段也不应该被删除(尽管可以弃用)。

service定义了一个 gRPC 服务,包含一个 RPC 端点GetQOTD。这个调用接收GetReq并返回GetResp

既然我们已经定义了这个协议缓冲文件,我们可以使用 proto 编译器为我们感兴趣的语言生成相应的包。这将包含我们所有的消息以及使用 gRPC 客户端和服务器所需的代码。

让我们来看一下如何从协议缓冲文件生成 Go 包。

说明先决条件

在本教程中使用协议缓冲时,您需要安装以下内容:

安装了这些之后,您将能够为 C++和 Go 生成代码。其他语言需要额外的插件。

生成您的包

我们需要创建的第一个文件是buf.yaml文件。我们可以通过进入proto目录并执行以下命令来生成buf.yaml文件:

buf config init

这应该生成一个包含以下内容的文件:

version: v1
lint:
  use:
    - DEFAULT
breaking:
  use:
    - FILE

接下来,我们需要一个文件来告诉我们生成什么输出。创建一个名为buf.gen.yaml的文件,并给它以下内容:

version: v1
plugins:
  - name: go
    out: ./
    opt:
      - paths=source_relative
  - name: go-grpc
    out: ./
    opt:
      - paths=source_relative

这表示我们应该在与.proto文件相同的目录中生成gogo-grpc文件。

现在,我们应该测试我们的 proto 文件是否能成功构建。我们可以通过执行以下命令来做到这一点:

buf build

如果没有输出,那么我们的 proto 文件应该能够成功编译。否则,我们将得到一个错误列表,需要我们去修复。

最后,让我们生成我们的 proto 文件:

buf generate

如果你将 proto 文件命名为qotd.proto,这将生成以下内容:

  • qotd.pb.go,它将包含你所有的消息

  • qotd_grpc.pb.go,它将包含所有的 gRPC 存根

现在我们有了 proto 包,让我们构建一个客户端。

编写一个 gRPC 客户端

在您仓库的根目录中,让我们创建两个目录:

  • client/,它将包含我们的客户端代码

  • internal/server/,它将包含我们的服务器代码

现在,让我们创建一个client/client.go文件,内容如下:

package client
import (
        "context"
        "time"
        "google.golang.org/grpc"
        pb "[repo]/grpc/proto"
)
type Client struct {
        client pb.QOTDClient
        conn   *grpc.ClientConn
}
func New(addr string) (*Client, error) {
        conn, err := grpc.Dial(addr, grpc.WithInsecure())
        if err != nil {
                return nil, err
        }
        return &Client{
                client: pb.NewQOTDClient(conn),
                conn: conn,
        }, nil
}
func (c *Client) QOTD(ctx context.Context, wantAuthor string) (author, quote string, err error) {
        if _, ok := ctx.Deadline(); !ok {
                var cancel context.CancelFunc
                ctx, cancel = context.WithTimeout(ctx, 2 * time.Second)
                defer cancel()
        }
        resp, err := c.client.GetQOTD(ctx, &pb.GetReq{Author: wantAuthor})
        if err != nil {
                return "", "", err
        }
        return resp.Author, resp.Quote, nil
}

这是围绕生成的客户端的一个简单包装,我们通过New()构造函数建立了与服务器的连接:

  • grpc.Dial()连接到服务器的地址:

    • grpc.WithInsecure()允许我们不使用 TLS。(在实际服务中,您需要使用 TLS!)
  • pb.NewQOTDClient()接受一个 gRPC 连接并返回我们生成的客户端。

  • QOTD()使用客户端调用我们在GetQOTD()原型中定义的接口:

    • 如果没有定义超时,这里会定义一个超时。服务器接收这个超时设置。

    • 这会使用生成的客户端来调用服务器。

创建一个包装器作为客户端并非严格必要。许多开发者更喜欢让用户直接使用生成的客户端与服务进行交互。

在我们看来,这对于简单的客户端来说是没问题的。更复杂的客户端通常应通过将逻辑移动到服务器或使用更符合语言习惯的自定义客户端包装器来减轻负担。

现在我们已经定义了客户端,让我们创建我们的服务器包。

编写一个 gRPC 服务器

让我们在internal/server/server.go创建一个服务器文件。

现在,让我们添加以下内容:

package server
import (
        "context"
        "fmt"
        "math/rand"
        "net"
        "sync"
        "google.golang.org/grpc"
        "google.golang.org/grpc/codes"
        "google.golang.org/grpc/status"

        pb "[repo]/grpc/proto"
)
type API struct {
     pb.UnimplementedQOTDServer
     addr string
     quotes map[string][]string
     mu sync.Mutex
     grpcServer *grpc.Server
}
func New(addr string) (*API, error) {
     var opts []grpc.ServerOption
     a := &API{
          addr: addr,
          quotes: map[string][]string{
               // Insert your quote mappings here
          },
          grpcServer: grpc.NewServer(opts...),
     }
     a.grpcServer.RegisterService(&pb.QOTD_ServiceDesc, a)
     return a, nil
}

这段代码执行以下操作:

  • 定义我们的 API 服务器:

    • pb.UnimplementedQOTDServer是一个生成的接口,包含我们服务器必须实现的所有方法。这是必需的。

    • addr是我们服务器将要运行的地址。

    • quotes包含服务器存储的引用。

  • 定义一个New()构造函数:

    • 这将创建一个我们API服务器的实例。

    • 这将实例注册到我们的grpcServer中。

现在,让我们添加启动和停止API服务器的方法:

func (a *API) Start() error {
     a.mu.Lock()
     defer a.mu.Unlock()
     lis, err := net.Listen("tcp", a.addr)
     if err != nil {
          return err
     }
     return a.grpcServer.Serve(lis)
}
func (a *API) Stop() {
     a.mu.Lock()
     defer a.mu.Unlock()
     a.grpcServer.Stop()
}

这段代码执行以下操作:

  • 定义Start()方法来启动服务器,该方法执行以下操作:

    • 使用Mutex来防止同时停止和启动

    • New()中传递的地址上创建一个 TCP 监听器

    • 使用我们的监听器启动 gRPC 服务器

  • 定义Stop()方法来停止服务器,该方法执行以下操作:

    • 使用Mutex来防止同时停止和启动

    • 告诉 gRPC 服务器优雅地停止

现在,让我们实现GetQOTD()方法:

func (a *API) GetQOTD(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
     var (
          author string
          quotes []string
     )
     if req.Author == "" {
          for author, quotes = range s.quotes {
               break
          }
     } else {
          author = req.Author
          var ok bool
          quotes, ok = s.quotes[req.Author]
          if !ok {
               return nil, status.Error(
                    codes.NotFound, 
                    fmt.Sprintf("author %q not found", req.author),
               )
          }
     }
     return &pb.GetResp{
          Author: author, 
          Quote: quotes[rand.Intn(len(quotes))],
     }, nil
}

这段代码执行以下操作:

  • 定义客户端将调用的GetQOTD()方法

  • 包含与我们的 REST 服务器类似的逻辑

  • 使用 gRPC 的错误类型,该类型在google.golang.org/grpc/status包中定义,用于返回 gRPC 错误代码

现在我们已经有了客户端和服务器包,让我们创建一个二进制文件来运行我们的服务。

创建服务器二进制文件

创建一个名为qotd.go的文件,保存我们服务器的main()函数:

package main
import (
     "flag"
     "log"
     "github.com/[repo]/internal/server"
     pb "[repo]/proto"
)
var addr = flag.String("addr", "127.0.0.1:80", "The address to run on.")
func main() {
     flag.Parse()
     s, err := server.New(*addr)
     if err != nil {
          panic(err)
     }
     done := make(chan error, 1)
     log.Println("Starting server at: ", *addr)
     go func() {
          defer close(done)
          done <-s.Start()
     }()
     err <- done
     log.Println("Server exited with error: ", err)
}

这段代码执行以下操作:

  • 创建一个标志addr,调用者传递此标志来设置服务器运行的地址。

  • 创建我们的服务器实例。

  • 写明我们正在启动服务器。

  • 启动服务器。

  • 如果服务器已存在,错误会被打印到屏幕上:

    • 这可能是某些提示,说明端口已被占用。

你可以使用以下命令运行这个二进制文件:

go run qotd.go --addr="127.0.0.1:2562"

如果你没有传递--addr标志,它将默认使用127.0.0.1:80

你应该在屏幕上看到以下内容:

Starting server at: 127.0.0.1:2562

现在,让我们创建一个二进制文件,使用客户端获取 QOTD。

创建客户端二进制文件

创建一个名为client/bin/qotd.go的文件,然后添加以下内容:

package main
import (
        "context"
        "flag"
        "fmt"
        "github.com/devopsforgo/book/book/code/1/4/grpc/client"
)
var (
        addr   = flag.String("addr", "127.0.0.1:80", "The address of the server.")
        author = flag.String("author", "", "The author whose quote to get")
)
func main() {
        flag.Parse()
        c, err := client.New(*addr)
        if err != nil {
                panic(err)
        }
        a, q, err := c.QOTD(context.Background(), *author)
        if err != nil {
                panic(err)
        }
        fmt.Println("Author: ", a)
        fmt.Printf("Quote of the Day: %q\n", q)
}

这段代码执行以下操作:

  • 设置一个标志用于服务器的地址

  • 设置一个标志用于引用你想要的名言的作者

  • 创建一个新的client.QOTD实例

  • 使用QOTD()客户端方法调用服务器

  • 将结果或错误打印到终端

你可以使用以下命令运行这个二进制文件:

go run qotd.go --addr="127.0.0.1:2562"

这将联系运行在此地址的服务器。如果你在其他地址运行服务器,你需要更改此地址以匹配。

如果没有传递--author标志,将随机选择一个作者。

你应该在屏幕上看到以下内容:

Author: [some author]
Quote: [some quote]

现在我们已经看到了如何使用 gRPC 创建一个简单的客户端和服务器应用程序。但这只是 gRPC 功能的开始。

我们仅仅是刚刚触及表面

gRPC 是云技术(如 Kubernetes)的关键基础设施组件。它是在多年的 Stubby 经验后构建的,Stubby 是谷歌的内部前身。我们仅仅触及了 gRPC 可以做的冰山一角。这里有一些额外的功能:

  • 运行 gRPC 网关以导出 REST 端点

  • 提供能够处理安全性和其他需求的拦截器

  • 提供流式数据

  • 支持 TLS

  • 用于附加信息的元数据和尾部信息

  • 客户端服务器负载均衡

以下是已经进行切换的一些大型公司:

  • Square

  • Netflix

  • IBM

  • CoreOS

  • Docker

  • CockroachDB

  • Cisco

  • Juniper Networks

  • Spotify

  • Zalando

  • Dropbox

让我们来聊聊如何在公司内部最佳地提供 REST 或 gRPC 服务。

公司标准的 RPC 客户端和服务器

谷歌技术栈成功的关键之一是围绕技术的整合。尽管技术中确实存在大量重复,谷歌在某些软件和基础设施组件上进行了标准化。在谷歌内部,很少看到不使用 Stubby(谷歌的内部 gRPC)的客户端/服务器。

工程师用于 RPC 的库被编写成在每种语言中都能一致工作。近年来,站点可靠性工程SRE)组织推动围绕 Stubby 构建的包装器,提供一系列功能和最佳实践,以防止每个团队重新发明轮子。这些功能包括:

  • 身份验证

  • 压缩处理

  • 分布式服务速率限制

  • 带回退的重试(或断路器)

通过让客户端在没有回退的情况下重试,这消除了许多基础设施的威胁,去除了团队自行设计安全模型的成本,并允许专家对这些项目进行修复。对这些库的修改使每个人受益,降低了发现已经构建好的服务的成本。

作为一个可能携带寻呼机的 DevOps 工程师或 SRE,推动 RPC 层的标准化可以带来无数的好处,例如避免接到寻呼机!

尽管选择常常被视为好事,有限的选择可以让开发团队和运维人员继续专注于他们的产品,而不是基础设施,这对于打造健壮的产品至关重要。

如果你决定提供一个 REST 框架,以下是一些推荐的实践:

  • 仅使用POST

  • 不要使用查询变量。

  • 仅使用 JSON。

  • 确保所有的参数都在你的请求中。

这将大大减少你在框架中需要编写的代码量。

在本节中,我们学习了什么是 RPC 服务以及如何使用两种流行的方法,REST 和 gRPC,来编写客户端。你还了解了 REST 有一套较为宽松的指南,而 gRPC 更倾向于使用 schema 类型,并自动生成使用系统所需的组件。

总结

本章结束了我们关于与远程数据源交互的内容。我们讨论了如何通过使用 Postgres 的示例连接到 SQL 数据库,了解了 RPC 是什么,并讨论了两种最流行的 RPC 服务类型,REST 和 gRPC。最后,我们为这两种框架编写了服务器和客户端。

本章使你能够连接到最流行的数据库和云服务,以获取和检索数据。现在你可以编写自己的 RPC 服务来开发云应用。

在下一章中,我们将利用这些知识构建工具,控制远程机器上的任务。

所以,不再多说,让我们直接进入如何编写命令行工具。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值