Go-分布式应用构建指南-全-

Go 分布式应用构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Gin 是一个用于在 Go 中构建 Web 应用程序和微服务的高性能 HTTP 网络框架。本书旨在通过实际示例教你 Gin 框架的方方面面。

你将从探索 Gin 框架的基础开始,然后逐步构建一个真实的 RESTful API。在这个过程中,你将学习如何编写自定义中间件,理解路由机制,以及如何绑定用户数据和验证传入的 HTTP 请求。本书还展示了如何使用 MongoDB 等 NoSQL 数据库进行大规模的数据存储和检索,以及如何使用 Redis 实现缓存层。接下来,你将了解如何使用 OAuth 2 和 JWT 等身份验证协议来保护并测试你的 API 端点。后续章节将指导你如何在服务器端渲染 HTML 模板,并使用 React 网络框架构建前端应用程序以消费 API 响应。此外,你将在 Amazon Web Services (AWS) 上部署你的应用程序,并在 Kubernetes 上进行扩展。最后,你将学习如何使用 CI/CD 管道自动化部署过程,以及如何使用 Prometheus 和 ELK 堆栈在生产环境中调试 Gin 应用程序。

在阅读完这本 Gin 书籍之后,你将能够从头开始设计、构建和部署一个使用 Gin 框架的生产级分布式应用程序。

这本书面向谁

这本书是为那些熟悉 Go 语言并希望学习使用 Gin 框架进行 REST API 设计和开发的 Go 开发者而编写的。

这本书涵盖的内容

第一章开始使用 Gin,提供了对 Gin 框架是什么、如何工作以及其特性的基础理解。它还提供了设置 Go 运行时和 Gin "Hello World" 示例的指南。

第二章设置 API 端点,涵盖了从头开始构建完整的 RESTful API 以及如何使用 OpenAPI 生成其文档。

第三章使用 MongoDB 管理数据持久性,说明了如何使用 MongoDB 等 NoSQL 数据库进行大规模的数据存储和检索。它还涵盖了如何使用 Redis 优化 API 响应时间。

第四章构建 API 身份验证,专注于遵循的最佳实践和建议,以保护 API 端点。它演示了包括 JWT、Auth0 和会话 cookie 在内的身份验证协议的使用。

第五章在 Gin 中提供静态 HTML,展示了如何使用 Gin RESTful API 作为后端构建一个 单页应用程序 (SPA),以及如何使用 Gin 渲染 HTML 模板和构建一个自包含的 Web 应用程序。

第六章, 扩展 Gin 应用,展示了如何使用 Docker 和 RabbitMQ 来提高 Gin 分布式 Web 应用的性能和可扩展性。

第七章, 测试 Gin HTTP 路由,探讨了如何使用 Docker 运行自动化测试。这包括运行 Go 单元测试和集成测试,以及使用 Snyk 检查安全漏洞。

第八章, 在 AWS 上部署应用,演示了如何在 AWS EC2 支持的云服务器上部署 Gin 分布式应用,以及如何在 Kubernetes 上对其进行扩展以应对重负载。

第九章, 实现 CI/CD 流水线,介绍了我们应该遵循的 CI/CD 实践来自动化 Gin 应用的构建、测试和部署。它还涵盖了如何使用代码即流水线的方法通过 CircleCI 实现这些实践。

第十章, 捕获 Gin 应用指标,进一步展示了如何轻松地诊断和监控生产环境中运行的 Gin 应用。

为了充分利用本书

本书适用于在 Linux、macOS 或 Windows 上工作的人。您需要安装 Go 并拥有 AWS 账户。您还需要 Git 来克隆本书提供的源代码仓库。同样,您应具备 Go 语言的基本知识。为了充分利用本书,需要具备 Go 编程语言的入门级知识。

尽管这些是基本要求,但我们在需要时将引导您完成安装。

图片 01

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

最后,请记住,本书的目的不是取代在线资源,而是旨在补充它们。因此,您显然需要互联网访问才能通过提供的链接完成阅读体验。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Building-Distributed-Applications-in-Gin。如果代码有更新,它将在现有的 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801074858_ColorImages.pdf

使用的约定

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

TestIndexHandler:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“要编写单元测试,从main_test.go开始,并定义一个返回 Gin 路由器实例的方法。”

代码块设置如下:

pm.test("More than 10 recipes", function () { 
   var jsonData = pm.response.json(); 
   pm.expect(jsonData.length).to.equal(10) 
}); 

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

$ go tool cover -html=coverage.out 

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“点击启动并分配一个密钥对或创建一个新的 SSH 密钥对。然后点击创建实例。”

小贴士或重要提示

看起来是这样的。

联系我们

我们欢迎读者的反馈。

customercare@packtpub.com.

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。

copyright@packt.com,并附有材料链接。

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

分享您的想法

一旦您阅读了《使用 Gin 构建分布式应用程序》,我们很乐意听听您的想法!请访问packt.link/r/1801074852为这本书提供反馈。

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

第一部分:Gin 框架内部

在这部分,我们将介绍 Gin 框架的炒作和性能优势。本部分还将涵盖如何编写一个简单的基于 Gin 的应用程序。因此,它包括以下章节:

  • 第一章, Gin 入门

第一章:Gin 入门

本章将为您提供一个关于 Gin 框架的基础理解,包括它是如何工作的以及其特性。我们还将提供设置 Go 运行时和开发环境的指南。此外,我们将讨论使用 Gin 作为构建分布式应用程序的 Web 框架的优势。我们将通过学习编写第一个基于 Gin 的 Web 应用程序来结束本章。

在本章中,我们将涵盖以下主题:

  • 什么是 Gin?

  • Go 运行时和 集成开发环境IDE

  • Go 模块和依赖管理

  • 编写 Gin Web 应用程序

到本章结束时,您将能够使用 Gin Web 框架构建一个基本的 HTTP 服务器。

技术要求

为了跟随本章内容,您需要以下内容:

  • 一些编程经验。本章中的代码相当简单,但了解一些 Go 知识会有所帮助。

  • 一个用于编辑代码的工具。您拥有的任何文本编辑器都可以正常工作。大多数文本编辑器都具有良好的 Go 支持。最受欢迎的是 Visual Studio Code (VSCode)(免费)、GoLand(付费)和 Vim(免费)。

  • 命令行终端。Go 在 Linux 和 Mac 上的任何终端以及 Windows 上的 PowerShell 或 CMD 中都能很好地工作。

本章的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter01

什么是 Gin?

在深入探讨 Gin 网络框架之前,我们需要了解为什么 Go 是构建可扩展和分布式应用程序时的首选。

Go(也称为 Golang)是一种开源编程语言,由罗伯特·格里泽默、罗布·派克和肯·汤普森于 2007 年在谷歌开发。它是一种编译型、静态类型语言,旨在使用户能够轻松编写可靠、可扩展和高度高效的应用程序。Go 的关键特性如下:

  • 简单且一致:Go 拥有一套丰富的库包,包括强大的标准库,用于测试、错误管理和并发。

  • 函数前的 go 关键字。

  • 高效:Go 提供高效的执行和编译。Go 还进行静态链接,这意味着编译器在最后一步调用链接器,解决所有库引用。这意味着在编译 Go 程序后,我们将得到一个没有外部依赖的二进制可执行文件。此外,它还提供了内置的垃圾回收器,以实现高效的内存利用(Go 与底层编程语言(如 C 或 C++)有许多相似之处)。

  • 社区和支持:Go 由谷歌支持,并拥有不断增长的生态系统和众多在 GitHub 上对语言做出贡献的贡献者。此外,还有许多在线资源(教程、视频和书籍)可供入门学习 Go。

Go 在企业和开源社区中变得非常受欢迎。根据 2020 年 StackOverflow 开发者调查(insights.stackoverflow.com/survey/2020),Go 是最受欢迎的编程语言前五名之一:

![图 1.1 – 根据 2020 年 StackOverflow 调查最受欢迎的编程语言图片

![图 1.1 – 根据 2020 年 StackOverflow 调查最受欢迎的编程语言 Golang 被认为是构建大规模、复杂工具和基于云的应用程序的首选。以下图片突出了使用 Go 开发的开源项目:+ Docker:一种用于使用容器创建、部署和运行应用程序的解决方案。+ Kubernetes:一个容器编排平台,用于管理跨节点/机器的容器。+ Etcd:一个可靠的分布式键值存储,用于存储分布式系统或应用程序的数据。+ InfluxDB:一个可扩展的时间序列数据库,旨在处理高写入和查询负载。+ CoreOS:一种轻量级操作系统,旨在部署基于容器的应用程序。+ Terraform:一种基础设施即代码工具,用于构建、更改和版本控制云基础设施。+ CockroachDB:一个用于数据密集型应用程序的云原生 SQL 数据库。+ Consul:一个具有服务发现、服务网格和健康检查监控能力的分布式存储:![图 1.2 – 由 Go 驱动的开源工具图片

图 1.2 – 由 Go 驱动的开源工具

如我们所见,Go 是一种适合分布式系统和基础设施工具的稳健语言。Docker、Kubernetes、Prometheus 等都是使用 Go 构建的。

Go 还因其构建各种规模和形状的 Web 应用程序而闻名。这部分得益于对标准库的出色工作,使其清洁、一致且易于使用。对于任何有抱负的 Go Web 开发者来说,最重要的包之一可能是 net/http 包。此包允许您使用其强大的组合结构在 Go 中构建 HTTP 服务器。

要构建 Web 应用程序,您需要构建一个 HTTP 服务器。客户端(例如,浏览器)会携带一些信息发出 HTTP 请求;然后服务器处理该请求并返回响应。响应可以是 JSON、XML 或 HTML 格式:

![图 1.3 – HTTP 客户端-服务器通信图片

图 1.3 – HTTP 客户端-服务器通信

这种请求-响应模式是构建 Go 中 Web 应用程序的关键焦点之一。

虽然 net/http 包允许您轻松构建 Web 应用程序,但其路由机制并不那么强大,特别是对于复杂的应用程序。这就是 Web 框架发挥作用的地方。以下表格列出了顶级 Golang Web 框架:

图片

Gin 可能是使用最广泛且运行最大的 Go 网络框架。该框架已经在 GitHub 上获得了 48,210 个星标和 5,100 个分支,这表明该框架非常受欢迎。这个模块化框架可以轻松扩展,几乎无需麻烦。它非常适合使用,因为许多组件可以直接通过 net/http 包重用。

重要提示

另一个强大但保守的框架是 Gorilla/Mux。它拥有最大的在线社区之一,互联网上有许多资源教你如何构建端到端网络应用程序。

根据官方文档 gin-gonic.com/docs/,Gin 被描述如下:

"Gin 是一个用 Go (Golang) 编写的 HTTP 网络框架。它具有类似 Martini 的 API,但性能更好——快 40 倍。如果你需要惊人的性能,就试试 Gin 吧。"

Gin 是一个简约的 Web 框架,适合构建 Web 应用程序、微服务和 RESTful API。它通过创建可重用和可扩展的代码片段来减少样板代码:你可以编写一个中间件,可以插入一个或多个请求处理器。此外,它还具备以下关键特性:

  • 文档完善:Gin 的文档广泛且全面。大多数与路由相关的工作都可以在文档中轻松找到。

  • 简洁性:Gin 是一个非常简约的框架。只包含最基本的功能和库,几乎没有样板代码来启动应用程序,这使得 Gin 成为开发高度可用的 REST API 的优秀框架。

  • 可扩展性:Gin 社区已经创建了众多经过良好测试的中间件,使得为 Gin 开发变得非常愉快。功能包括使用 GZip 进行压缩、使用授权中间件进行身份验证以及使用外部解决方案(如 Sentry)进行日志记录。

  • 性能:Gin 比 Martini 快 40 倍,与其他 Golang 框架相比表现良好。以下是我对多个 Go 库进行的基准测试结果:

![图 1.4 – Golang 网络框架基准测试]

图片 B17115_01_04.jpg

图 1.4 – Golang 网络框架基准测试

重要提示

这个基准测试是在 macOS High Sierra、2.7 GHz Intel Core i7、16 GB DDR3 计算机上进行的,运行环境为 Go 1.15.6。

话虽如此,在你能够编写第一行 Go 代码之前,你需要设置环境。让我们从安装 Go 开始。

设置 Go 环境

在撰写本书时,Go 的最新版本是 Go 1.15.6。要安装 Go,你可以下载或使用官方的二进制发行版,或者你可以从源代码安装 Go (github.com/golang/go)。

重要提示

官方的二进制发行版适用于 FreeBSD(版本 8 及以上)、Linux(2.6.23 及以上)、macOS(Snow Leopard 及以上)和 Windows(XP 及以上)。支持 32 位(386)和 64 位(amd64)x86 处理器架构。对于 FreeBSD 和 Linux,也支持 ARM 处理器架构。

要安装 Go,请从golang.org/dl/网页下载分发包,如下所示,并选择适合您平台的文件:

图 1.5 – Golang 可用包

图 1.5 – Golang 可用包

一旦您有了分发包,请根据您选择的平台安装 Go。我们将在以下章节中介绍这一点。

Linux/FreeBSD

要在 Linux 或 FreeBSD 上安装 Go,您必须下载go.-.tar.gz。64 位架构上的最新 Linux Go 是go1.15.6.linux-amd64.tar.gz

wget -c https://golang.org/dl/go1.15.6.linux-amd64.tar.gz   //64bit
wget -c https://golang.org/dl/go1.15.6.linux-386.tar.gz     //32bit

下载存档并将其提取到/usr/local文件夹中。然后,以 root 用户或通过 sudo 运行以下命令:

tar -C /usr/local -xzf go1.15.6.linux-amd64.tar.gz

/usr/local/go/bin添加到PATH环境变量中。您可以通过将以下行添加到$HOME/.profile/etc/profile(对于系统级安装)来实现:

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

通过打开命令提示符并输入以下命令来验证您已安装 Go:

go version

此命令应显示已安装的 Go 版本:

图 1.6 – 已安装的 Go 版本

图 1.6 – 已安装的 Go 版本

让我们继续看看如何在 Windows 上设置 Go 环境。

Windows

要在 Windows 上安装 Go,您可以使用 MSI 安装程序或 ZIP 存档。使用 MSI 安装更简单。64 位架构上的最新 Windows Go 是go1.15.6.windows-amd64.msi。然后您需要根据您的系统执行以下命令之一:

wget -c https://golang.org/dl/go1.15.6.windows-amd64.msi   //64bit
wget -c https://golang.org/dl/go1.15.6.windows-386.msi     //32bit

打开您下载的 MSI 文件,按照提示安装 Go。默认情况下,安装程序会将 Go 放置在C:\Go,并将C:\Go\bin设置到您的PATH环境变量中。您可以根据需要更改位置:

图 1.7 – Golang 安装向导

图 1.7 – Golang 安装向导

安装 Go 后,您需要关闭并重新打开任何打开的命令提示符,以便安装程序所做的环境更改反映在命令提示符中。

重要提示

使用 ZIP 存档同样简单。将文件提取到目录中(例如,C:\Go)并将bin子目录添加到您的PATH变量中。

安装完成后,点击cmd。然后按Enter键。在出现的命令提示符窗口中,输入go version命令,如下所示:

图 1.8 – 已安装的 Go 版本

图 1.8 – 已安装的 Go 版本

您将看到go version go1.15.6 windows/amd64,如前述截图所示。这样,您就设置好了!

MacOS

对于 MacOS,您可以下载适当的 PKG 文件;即 go1.15.6.darwin-amd64.pkg(在撰写本书时)。下载完成后,运行安装向导。该包将安装分发到 /usr/local/go,并将 /usr/local/go/bin 目录放置在您的 PATH 环境变量中:

图 1.9 – 在 MacOS 上安装 Go

图 1.9 – 在 MacOS 上安装 Go

您需要重新启动您的终端,或者在您的终端中运行此命令:

source ~/.profile

或者,您可以使用 Homebrew 安装 Go。这可以像以下这样做:

brew install golang@1.15.6

终端窗口将提供有关 Go 安装过程的反馈。安装完成可能需要几分钟。通过在命令提示符中打开并输入 go version 命令来验证您已安装 Go。

重要提示

在未来,要更新 Go,您可以运行以下命令来更新 Homebrew,然后更新 Go。您现在不需要这样做,因为您刚刚安装了最新版本:

brew update
brew upgrade golang

现在您已安装 Go,您需要正确设置它。Go 开发工具旨在与维护在公共仓库中的代码一起工作,并且模型是相同的,无论您是在开发开源程序还是其他内容。Go 代码在开发空间中开发。开发空间由三个目录组成,具体如下:

  • bin:这将包含您所有的 Go 可执行二进制文件。

  • src:这将存储您的源文件,这些文件按包组织,src 目录中的一个子目录代表一个包。

  • pkg:这将存储您的包对象。

Go 开发空间的默认目录是带有 go 子目录的家目录或 $HOME/go。使用以下命令创建您的 Go 开发空间的目录结构:

mkdir -p $HOME/go/{bin,src,pkg}

-p 选项告诉 mkdir 在目录中创建所有父目录,即使它们目前不存在。使用 {bin, src, pkg}mkdir 创建一组参数,并告诉它创建 binsrcpkg 目录。这将确保以下目录结构现在已就绪:

The go, bin and  src folders should be at the same level (remove extra spaces from go and src folders, so the folders are aligned with bin folder)
$HOME 
 └── go 
 ├── bin 
 └── src 

接下来,您需要设置 GOPATH 环境变量,如下所示:

export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin

您可以通过使用 echo 命令并检查输出,来验证您的 $PATH 是否已更新:

echo $PATH

您应该在您的家目录中看到 $GOPATH/bin。如果您以 USER 身份登录,您将在路径中看到 /Users/USER/go/bin

/Users/USER/go/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

在创建 Go 开发空间和 GOPATH 后,我们可以继续设置开发环境。

集成开发环境

在本书中,我将使用 IDE 来编写 RESTful API 和服务。使用 IDE 可以提高您的生产力,因为它提供了丰富的功能,如自动完成、代码高亮、强大的内置调试器和自定义扩展。有许多 IDE 可用。在本书中,我将使用 VSCode

要安装 VSCode,从 code.visualstudio.com/download 下载适合您系统的相应包:

图 1.10 – VS Code – 可用包

图 1.10 – VS Code – 可用包

注意

Mac 用户也可以使用 Brew 通过以下命令安装 VSCode:

brew install --cask visual-studio-code

下载完成后,运行设置向导并按照说明操作。安装完成后,启动 VSCode。您将看到以下启动屏幕:

图 1.11 – VSCode 用户界面

图 1.11 – VSCode 用户界面

VSCode 默认支持所有流行的编程语言和 Git 集成。您还可以安装扩展来扩展 VSCode 的功能。VS Code 市场包含大量免费社区插件和扩展。要启用对 Golang 的支持,您需要通过从左侧侧边栏的 扩展 选项卡导航来安装名为 Go 的扩展:

图 1.12 – VSCode 的 Golang 扩展

图 1.12 – VSCode 的 Golang 扩展

点击 安装 按钮,然后重新启动 VSCode 以使更改生效。

安装 Go 工具

接下来,我们将安装以下 Go 工具,这是一组帮助改进您编码过程中的开发工作流程和整体体验的包:

重要提示

可用的 Go 工具的完整列表可以在 pkg.go.dev/golang.org/x/tools 找到。

要安装这些工具,请点击 goinstall update/tools

图 1.13 – VSCode 上可用的 Go 工具

图 1.13 – VSCode 上可用的 Go 工具

检查所有依赖项并点击 确定。下载所有依赖项可能需要一些时间:

图 1.14 – Go 工具安装

图 1.14 – Go 工具安装

在您的计算机上安装并配置好 Go 后,现在您可以安装 Gin 框架了。

安装和配置 Gin

Gin 是一个第三方包。要在 Go 项目中安装 Gin,我们需要使用 go get 命令。该命令将安装包的 URL 作为参数。运行以下命令从 GitHub 安装 gin 包:

go get github.com/gin-gonic/gin

注意

如果您正在运行 Go 1.16 及以上版本,您需要通过 GO111MODULE=off 选项禁用 Go 模块。

当检出 gin 包时,go get 命令会在 $GOPATH/src 路径下创建一个 Gin 目录。该目录将包含 Gin 框架的源代码:

图 1.15 – Gin 包源代码

图 1.15 – Gin 包源代码

$GOHOME/src/hello-world 或任何合适的目录下创建 hello-world 项目目录:

mkdir -p $GOHOME/src/hello-world 
cd $GOHOME/src/hello-world

使用 VSCode 打开文件夹,并在项目文件夹内创建一个名为 main.go 的文件,其内容如下:

package main
import "github.com/gin-gonic/gin"
func main() {
   router := gin.Default()
   router.GET("/", func(c *gin.Context) {
       c.JSON(200, gin.H{
           "message": "hello world",
       })
   })
   router.Run()
}

第一行,package main,表示这是本项目的主模块。import 部分用于导入 gin 包。此包为我们提供了 router 变量,该变量声明在 import 下方,以及我们在 main 函数中发送响应时使用的 API 上下文。

接下来,我们在根 (/) 资源上创建一个 HTTP GET 方法,并定义一个当 HTTP 请求击中根端点时要调用的函数。该函数发送一个状态码为 200(OK)的 JSON 响应,正文为 "message": "test successful"

最后,我们必须使用 router.Run() 方法在端口 8080 上部署路由器。以下图表总结了 Gin 中 HTTP 请求的处理方式:

图 1.16 – 使用 Gin 解析传入的 HTTP 请求

图 1.16 – 使用 Gin 解析传入的 HTTP 请求

要运行应用程序,请在终端会话中执行以下命令:

go run main.go

从此以后,所有文件和执行的命令都将在此目录下。如果您遵循了设置过程,您应该在您的终端中看到以下输出:

图 1.17 – Gin 服务器日志

图 1.17 – Gin 服务器日志

将您喜欢的浏览器指向 http://localhost:8080。您应该看到一个 "hello world" 消息:

图 1.18 – Hello world 示例

图 1.18 – Hello world 示例

太棒了 – 你已经成功使用 Gin 框架在 Go 中启动了一个 HTTP 服务器。

返回终端,Gin 将跟踪 HTTP 请求:

图 1.19 – 跟踪传入的 HTTP 请求

图 1.19 – 跟踪传入的 HTTP 请求

您可以使用 cURL 命令发送 HTTP 请求:

curl -X GET http://localhost:8080

或者,您可以使用高级 REST 客户端,如 Postman。您可以从以下网址根据您的平台下载正确的版本:www.getpostman.com/apps

一旦下载完成,运行向导并打开 Postman。设置字段如下:

  • HTTP 方法: GET

  • URL: http://localhost:8080

  • 头部:将 Content-Type 设置为 application/json

请求应配置如下:

图 1.20 – 使用 Postman 客户端发起 GET 请求

图 1.20 – 使用 Postman 客户端发起 GET 请求

值得注意的是,默认情况下,HTTP 服务器正在监听端口 8080。但是,如果端口已被其他应用程序使用,您可以通过向 Run 方法添加参数来定义不同的端口:

r.Run(":5000")

此命令将在端口 5000 上运行服务器,如下面的截图所示:

图 1.21 – 在端口 5000 上运行 Gin 服务器

图 1.21 – 在端口 5000 上运行 Gin 服务器

注意,port 参数需要以冒号标点符号开头作为字符串传递。

你现在应该熟悉构建和运行简单 Web 应用程序的基础知识。在接下来的几节中,我们将介绍如何使用第三方包增强这些功能。但在我们这样做之前,让我们先了解如何管理 Go 依赖项。

Golang 的依赖项管理

目前,代码是本地存储的。然而,建议将源代码存储在远程仓库中以进行版本控制。这就是 GitHub 等解决方案发挥作用的地方。在 github.com 上注册一个免费账户。然后,创建一个名为 hello-world 的新 GitHub 仓库:

图 1.22 – 新的 GitHub 仓库

图 1.22 – 新的 GitHub 仓库

接下来,使用以下命令初始化仓库:

git init 
git remote add origin https://github.com/mlabouardy/hello-world.git

通过执行以下命令将 main.go 文件提交到远程仓库:

git add . 
git commit -m "initial commit" 
git push origin master

你的仓库现在应该看起来像这样:

图 1.23 – 在 Git 中版本控制 main.go

图 1.23 – 在 Git 中版本控制 main.go

我们可以在这里停止。但是,如果你在一个团队中工作,你需要确保所有团队成员都使用相同的 Go 版本和包。这就是 Go 模块出现的地方。Go 模块于 2018 年引入,以使依赖项管理变得更加容易。

注意

从 Go 1.16 开始,Go 模块是管理外部依赖项的默认方式。

在项目文件夹中,运行以下命令以创建一个新的模块:

go mod init hello-world

此命令将创建一个包含以下内容的 go.mod 文件。该文件定义了项目需求并锁定依赖项到它们的正确版本(类似于 Node.js 中的 package.jsonpackage-lock.json):

module github.com/mlabouardy/hello-world
go 1.15

要添加 Gin 包,我们可以发出 go get 命令。现在,我们的 go.mod 文件将如下所示:

module github.com/mlabouardy/hello-world
go 1.15
require github.com/gin-gonic/gin v1.6.3

在添加 Gin 框架(输出因简洁而被裁剪)后,将生成一个名为 go.sum 的新文件。你可以假设它是一个锁文件。但实际上,go.mod 已经提供了足够的信息来实现 100% 可重复构建。另一个文件只是为了验证目的:它包含特定模块版本内容的预期加密校验和。你可以将其视为一个额外的安全层,以确保你的项目所依赖的模块不会意外更改,无论是出于恶意还是意外的原因:

github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod

你可以使用以下命令列出你的依赖项:

go list -m all

输出如下:

github.com/mlabouardy/hello-world
github.com/davecgh/go-spew v1.1.1
github.com/gin-contrib/sse v0.1.0
github.com/gin-gonic/gin v1.6.3
github.com/go-playground/assert/v2 v2.0.1
github.com/go-playground/locales v0.13.0
github.com/go-playground/universal-translator v0.17.0
github.com/go-playground/validator/v10 v10.2.0
github.com/golang/protobuf v1.3.3
github.com/google/gofuzz v1.0.0
github.com/json-iterator/go v1.1.9
github.com/leodido/go-urn v1.2.0
github.com/mattn/go-isatty v0.0.12
github.com/modern-go/concurrent v0.0.0-20180228061459
e0a39a4cb421
github.com/modern-go/reflect2 v0.0.0-20180701023420
4b7aa43c6742
github.com/pmezard/go-difflib v1.0.0
github.com/stretchr/objx v0.1.0
github.com/stretchr/testify v1.4.0
github.com/ugorji/go v1.1.7
github.com/ugorji/go/codec v1.1.7
golang.org/x/sys v0.0.0-20200116001909-b77594299b42
golang.org/x/text v0.3.2
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
gopkg.in/yaml.v2 v2.2.8

重要提示

要删除未使用的依赖项,可以使用 go mod tidy 命令。

最后,使用以下命令将 go.modgo.sum 文件添加到远程仓库:

git add .
git commit -m "dependency management"
git push origin master

更新后的仓库将如下所示:

图 1.24 – 使用 Go 模块管理依赖项

图 1.24 – 使用 Go 模块管理依赖项

值得注意的是,下载的模块存储在您的 $GOPATH/pkg/mod 目录中。然而,有时将项目所依赖的模块或第三方包存储在本地并放置在一个文件夹中是有用的,这样它们就可以被纳入版本控制。幸运的是,Go 模块支持 vendoring:

go mod vendor

此命令将在您的项目文件夹中创建一个包含所有第三方依赖的 vendor 目录。现在,您可以提交此文件夹到您的远程 Git 仓库,以确保未来构建的稳定性,而无需依赖外部服务:

图 1.25 – 供应商依赖

img/B17115_01_25.jpg

图 1.25 – 供应商依赖

有时,您可能会想知道为什么某个特定包是依赖项。您可以通过分析或可视化项目依赖来回答这个问题。为此,我们可以使用 go mod graph 命令来显示 go.mod 文件中的模块列表:

go mod graph | sed -Ee 's/@[^[:blank:]]+//g' | sort | uniq >unver.txt

此命令将生成一个名为 unver.txt 的新文件,其中包含以下内容(输出已被裁剪以节省空间):

github.com/gin-contrib/sse github.com/stretchr/testify
github.com/gin-gonic/gin github.com/gin-contrib/sse
github.com/gin-gonic/gin github.com/go-playground/validator/v10
github.com/gin-gonic/gin github.com/golang/protobuf
github.com/gin-gonic/gin github.com/json-iterator/go
github.com/gin-gonic/gin github.com/mattn/go-isatty
github.com/gin-gonic/gin github.com/stretchr/testify
github.com/gin-gonic/gin github.com/ugorji/go/codec
github.com/gin-gonic/gin gopkg.in/yaml.v2

然后,创建一个包含以下内容的 graph.dot 文件:

digraph {
    graph [overlap=false, size=14];
    root="$(go list -m)";
    node [ shape = plaintext, fontname = "Helvetica", 
          fontsize=24];
    "$(go list -m)" [style = filled, 
                     fillcolor = "#E94762"];

此内容将使用 DOT 语言生成一个图结构。我们可以使用 DOT 来描述图(有向或无向)。换句话说,我们将使用以下命令将 unvert.txt 的输出注入到 graph.dot 文件中:

cat unver.txt | awk '{print "\""$1"\" -> \""$2"\""};' >>graph.dot
echo "}" >>graph.dot
sed -i '' 's+\("github.com/[^/]*/\)\([^"]*"\)+\1\\n\2+g' graph.dot

这将生成一个模块依赖图:

图 1.26 – 模块依赖图

img/B17115_01_26.jpg

图 1.26 – 模块依赖图

我们现在可以使用 Graphviz 渲染结果。此工具可以根据您的操作系统使用以下命令安装:

  • Linux:您可以根据您的包管理器下载官方包。对于 Ubuntu/Debian,使用以下命令:

    apt-get install graphviz
    
  • MacOS:您可以使用 MacOS 的 Homebrew 工具:

    brew install graphviz
    
  • Windows:您可以使用 Windows 的 Chocolatey (chocolatey.org/install) 软件包管理器:

    choco install graphviz.portable
    

一旦安装了 Graphviz,执行以下命令将 graph.dot 文件转换为 .svg 格式:

sfdp -Tsvg -o graph.svg graph.dot

将生成一个 graph.svg 文件。使用以下命令打开文件:

open graph.svg

这将生成以下有向图:

图 1.27 – 可视化分析模块依赖

img/B17115_01_27_v2.jpg

图 1.27 – 可视化分析模块依赖

此图完美地展示了 hello-world 项目模块/包之间的依赖关系。

注意

生成依赖关系图的另一种方法是使用 modgv 工具 (github.com/lucasepe/modgv)。此工具使用单个命令将 go mod graph 输出转换为 GraphViz 的 DOT 语言。

现在源代码已在 GitHub 中进行版本控制,我们可以进一步探索如何为 Gin 路由编写自定义函数处理器。

编写自定义 HTTP 处理器

你可以创建一个接受 *gin.Context 作为参数的处理函数,并返回状态码为 200 的 JSON 响应。然后,你可以使用 router.Get() 函数注册处理函数:

package main
import "github.com/gin-gonic/gin"
func IndexHandler(c *gin.Context){
   c.JSON(200, gin.H{
       "message": "hello world",
   })
}
func main() {
   router := gin.Default()
   router.GET("/", IndexHandler)
   router.Run()
}

重要提示

在本书的高级章节中,当处理单元测试时,将处理函数与路由器分离将非常有用。

Gin 框架最大的优势是它能够从请求 URL 中提取段。考虑以下示例:

/users/john
/hello/mark

此 URL 有一个动态段:

  • 用户名:Mark,John,Jessica 等等

你可以使用以下 :variable 模式实现动态段:

func main() {
   router := gin.Default()
   router.GET("/:name", IndexHandler)
   router.Run()
}

我们必须做的最后一件事是从变量中获取数据。gin 包自带了 c.Params.ByName() 函数,它接受参数名称并返回值:

func IndexHandler(c *gin.Context) {
   name := c.Params.ByName("name")
   c.JSON(200, gin.H{
       "message": "hello " + name,
   })
}

使用 go run 命令重新运行应用程序。在浏览器中点击 http://localhost:8080/mohamed 链接;用户将被返回:

图 1.28 – 路径参数示例

图 1.28 – 路径参数示例

现在,我们知道每次我们点击 GET /user 路由时,我们都会得到 "hello user" 的响应。如果我们点击任何其他路由,它应该返回一个 404 错误消息:

图 1.29 – Gin 中的错误处理

图 1.29 – Gin 中的错误处理

Gin 还可以处理 XML 格式的 HTTP 请求和响应。为此,定义一个包含 firstNamelastName 作为属性的 user struct。然后,使用 c.XML() 方法来渲染 XML:

func main() {
   router := gin.Default()
   router.GET("/", IndexHandler)
   router.Run()
}
type Person struct {
     XMLName xml.Name `xml:"person"`
     FirstName     string   `xml:"firstName,attr"`
     LastName     string   `xml:"lastName,attr"`
}
func IndexHandler(c *gin.Context) {
     c.XML(200, Person{FirstName: "Mohamed", 
                       LastName: "Labouardy"})
}

现在,重新运行应用程序。如果你导航到 localhost:8080,服务器将返回以下 XML 响应:

图 1.30 – XML 响应

图 1.30 – XML 响应

恭喜!到这一点,你已经在本地机器上设置了 Go 编程工作区,并且已经配置了 Gin。现在,你可以开始一个编码项目了!

摘要

在本章中,我们为您介绍了 Go 编程语言。我们学习了如何设置运行时和开发环境。我们还了解了 GOPATH 环境变量,它是 Go 中的工作空间定义,现在我们知道所有包和项目都位于该路径上。

之后,我们探讨了不同的 Go Web 框架,并了解了为什么 Gin 是构建分布式 Web 应用程序中最受欢迎的。最后,我们学习了如何从头开始使用 Gin 编写我们的第一个 hello world 项目。

在下一章中,我们将动手实践,开始使用 Gin 框架构建分布式 RESTful API。

问题

  1. 为什么 Golang 流行?

  2. 最好的 Golang Web 开发框架有哪些?

  3. 什么是 Go 模块?

  4. 使用 Gin 框架编写的 HTTP 服务器默认端口是什么?

  5. 使用什么方法来渲染 JSON 和 XML 响应?

进一步阅读

  • 用 Go 实践无服务器应用程序》,作者 Mohamed Labouardy,由 Packt Publishing 出版

  • 用 Go 实践 RESTful Web 服务(第二版)》,作者 Naren Yellavula,由 Packt Publishing 出版

第二部分:分布式微服务

本书第二部分深入探讨了 Gin 框架。它包括了一个在 Go 语言中构建真实世界生产级 RESTful API 的实用示例,并解释了如何基于 REST 与不同的微服务进行通信以构建分布式 Web 应用。本节包括以下章节:

  • 第二章, 设置 API 端点

  • 第三章*,* 使用 MongoDB 管理数据持久性*

  • 第四章*,* 构建 API 认证*

  • 第五章, 在 Gin 中服务静态 HTML

  • 第六章*,* 缩放 Gin 应用*

第二章:设置 API 端点

在上一章中,我们学习了如何构建我们的第一个 Gin 网络应用程序。在这一章中,我们将从头开始构建一个完整的 RESTful API。在这个过程中,我们将探索 HTTP 方法和高级路由功能。我们还将介绍如何编写 OpenAPI 规范以及如何生成 API 文档。

在本章中,我们将介绍以下主题:

  • 探索 API 功能

  • 实现 HTTP 路由

  • 编写 OpenAPI 规范

到本章结束时,你将熟悉 Gin 的路由机制、HTTP 方法和数据验证。

技术要求

为了跟随本章内容,你需要以下内容:

  • 一台装有 Golang 版本 1.15.6 的笔记本电脑(Windows、Linux 或 macOS),以便您可以轻松执行提供的命令

  • 对 RESTful API 和 Go 编程语言的一般理解

本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter02

探索 API 功能

为了说明如何构建 RESTful API,我们将构建一个烹饪应用程序。我们将介绍如何使用 Gin 框架集成、部署和测试应用程序。该应用程序将执行以下操作:

  • 显示用户提交的食谱,包括它们的成分和说明。

  • 允许任何人发布新的食谱。

应用程序架构和流程如下所示:

![图 2.1 – 食谱应用程序架构]

![img/B17115_02_01.jpg]

图 2.1 – 食谱应用程序架构

架构由一个使用 Gin 框架编写的微服务和用于数据持久性的数据库组成。该微服务通过 HTTP 协议公开 RESTful API 来管理食谱。

注意

在本书的后续章节中,我们将介绍如何使用 React 网络框架构建前端应用程序,以便我们可以消费 RESTful API。

在我们开始之前,我们需要创建一个 GitHub 仓库,代码源将存储在那里。为了在 Git 分支中组织代码,我们将使用 GitFlow 模型。这种方法包括以下分支:

  • master:这个分支对应于当前的生产代码。除了热修复之外,您不能直接提交。可以使用 Git 标签来标记 master 分支中的所有提交,并使用版本号(例如,为了使用语义版本控制约定semver.org/,它有三个部分:主要版本、次要版本和补丁版本,因此版本 1.2.3 的主要版本是 1,次要版本是 2,补丁版本是 3)。

  • preprod:这是一个发布分支,是生产的镜像。在它们合并到 master 分支之前,可以用来测试在 develop 分支上开发的全部新功能。

  • develop:这是开发集成分支,包含最新的集成开发代码。

  • feature/X:这是一个正在开发的个人功能分支。每个新功能都位于自己的分支中,并且通常是为最新的开发分支创建的。

  • hotfix/X:当你需要在生产代码中解决问题时,你可以使用 hotfix 分支并为 master 分支打开一个拉取请求。这个分支基于 master 分支。

以下架构说明了 GitFlow 方法:

图 2.2 – GitFlow 模型 – 主要分支

图 2.2 – GitFlow 模型 – 主要分支

一旦创建了 GitHub 仓库,克隆它到你的 Go 工作区,并创建三个主要分支,分别称为developpreprodmaster。这将帮助你组织项目并将开发中的代码与运行在生产中的代码隔离开。这种分支策略是 GitFlow 工作流程分支模型的简化版本(不要忘记将 GitHub URL 替换为你的仓库链接):

git clone https://github.com/mlabouardy/recipes-api.git 
cd recipes-api 
touch README.md 
git checkout -b preprod 
git push origin preprod 
git checkout –b develop 
git push origin develop

这将创建一个名为recipes-api的新目录。让我们通过执行以下命令将此目录作为模块的根目录。这将使我们能够使用go.modgo.sum文件来管理项目依赖项:

go mod init

在 VSCode 上打开项目文件夹,创建一个包含以下代码的main.go文件。main函数将初始化一个新的 Gin 路由器,并通过调用我们的 Gin 实例的Run()方法在端口8080上运行一个 HTTP 服务器:

package main
import "github.com/gin-gonic/gin"
func main() {
   router := gin.Default()
   router.Run()
}

注意

确保使用go get命令安装gin包。请参阅上一章以获取逐步指南。

将更改推送到 Git 远程仓库。目前,我们将直接将更改推送到develop分支。我们将在下一节学习如何打开拉取请求:

git add .
git commit –m "boilerplate"
git push origin develop

更新后的仓库应如下所示:

图 2.3 – GitHub 分支

图 2.3 – GitHub 分支

注意

如果你正在与一组开发者合作,你需要在从 GitHub 克隆项目后,使用go mod download命令来安装所需的依赖项。

在下一小节中,我们将看到如何定义数据模型。

定义数据模型

在深入研究路由定义之前,我们需要定义一个将保存有关食谱信息的模型。我们可以通过定义一个 Go 结构体来创建我们的模型。此模型将包含食谱的属性/字段。在main.go文件中声明以下结构体:

type Recipe struct {
   Name         string    `json:"name"`
   Tags         []string  `json:"tags"`
   Ingredients  []string  `json:"ingredients"`
   Instructions []string  `json:"instructions"`
   PublishedAt  time.Time `json:"publishedAt"`
}

我们的Recipe模型是自我解释的。每个食谱都应该有一个名称、一个配料清单、一个指令或步骤列表,以及一个发布日期。此外,每个食谱属于一组类别或标签(例如,纯素、意大利菜、糕点、沙拉等),以及一个 ID,这是在数据库中区分每个食谱的唯一标识符。我们还将使用反引号注释指定每个字段的标签;例如,`json:"NAME"`。这允许我们在将它们作为响应发送时将每个字段映射到不同的名称,因为 JSON 和 Go 有不同的命名约定。

一旦定义了结构体,就将更改推送到基于develop分支的新分支:

git checkout -b feature/datamodel
git add main.go
git commit -m "recipe data model"
git push origin feature/datamodel

一旦将这些更改推送到您的仓库,feature/datamodel将与develop分支合并:

![图 2.4 – GitHub 拉取请求]

图片

图 2.4 – GitHub 拉取请求

将更改合并到develop分支并删除feature/datamodel分支:

![图 2.5 – 将拉取请求合并到 develop 分支]

图片

图 2.5 – 将拉取请求合并到 develop 分支

在定义了数据模型之后,我们可以查看路由处理器的定义。API 将公开各种端点。现在让我们看看它们。

HTTP 端点

以下表格显示了我们可以使用的 HTTP 端点列表:

图片

现在,我们将建立我们 API 的端点。我们将通过在main函数中创建所有端点来设置这些端点。每个端点都需要一个单独的函数来处理请求。我们将在main.go文件中定义它们。

注意

在下一章中,我们将介绍如何根据标准的 Go 布局来构建 Go 项目。

实现 HTTP 路由

在本节中,我们将创建处理 POST、GET、PUT 和 DELETE HTTP 请求的功能处理器。所以,让我们直接进入正题。

POST /recipes

首先,让我们实现负责创建新食谱的端点。在/recipes资源上创建一个 POST 方法。然后,为该路径定义一个NewRecipeHandler方法。main.go文件可能看起来像这样:

package main
import (
   "time"
   "github.com/gin-gonic/gin"
)
type Recipe struct {
  ID           string    `json:"id"`
  Name         string    `json:"name"`
  Tags         []string  `json:"tags"`
  Ingredients  []string  `json:"ingredients"`
  Instructions []string  `json:"instructions"`
  PublishedAt  time.Time `json:"publishedAt"`
}
func NewRecipeHandler(c *gin.Context) {
}
func main() {
   router := gin.Default()
   router.POST("/recipes", NewRecipeHandler)
   router.Run()
}

在编写NewRecipeHandler方法的代码之前,我们需要定义一个名为recipes的全局变量来存储食谱列表。这个变量将临时使用,并在下一章中用数据库替换。为了初始化recipes变量,我们可以使用init()方法,该方法将在应用程序启动时执行:

var recipes []Recipe
func init() {
   recipes = make([]Recipe, 0)
}

在这里,我们将定义 NewRecipeHandler 背后的逻辑。c.ShouldBindJSON 函数将传入的请求体序列化到 Recipe 结构体中,然后使用名为 xid 的外部包分配一个唯一的标识符。接下来,它使用 time.Now() 函数分配一个发布日期,并将食谱添加到食谱列表中,这将将其保留在内存中。如果请求体无效,则处理程序将返回一个错误(400 状态码)。否则,处理程序将返回 200 状态码:

func NewRecipeHandler(c *gin.Context) {
   var recipe Recipe
   if err := c.ShouldBindJSON(&recipe); err != nil {
       c.JSON(http.StatusBadRequest, gin.H{
          "error": err.Error()})
       return
   }
   recipe.ID = xid.New().String()
   recipe.PublishedAt = time.Now()
   recipes = append(recipes, recipe)
   c.JSON(http.StatusOK, recipe)
}

在前面的代码中,我们使用了内置的状态码常量,如 http.StatusOKhttp.StatusBadRequest,而不是硬编码的 HTTP 状态码。我们还将响应类型设置为 JSON。

在运行应用程序之前,我们需要下载用于生成唯一 ID 的 xid 包:

go get github.com/rs/xid

新的依赖项将自动添加到 go.sumgo.mod 文件中。按照以下方式运行服务器:

go run main.go

将在端口 8080 上部署一个 HTTP 服务器:

![图 2.6 – Gin 服务器日志图片 B17115_02_06.jpg

图 2.6 – Gin 服务器日志

为了测试它,使用 Postman 客户端在 http://localhost:8080/recipes 上发送 POST 请求,请求体中包含以下 JSON:

![图 2.7 – 使用 Postman 客户端发送 POST 请求图片 B17115_02_07.jpg

图 2.7 – 使用 Postman 客户端发送 POST 请求

上述命令将食谱添加到食谱数组中,并返回带有分配的 ID 和发布日期的食谱。

Postman 的另一个替代方案是使用 cURL 命令。使用以下 cURL 命令,后跟 JSON 文档,使用 POST 动词:

curl --location --request POST 'http://localhost:8080/recipes' \
--header 'Content-Type: application/json' \
--data-raw '{
   "name": "Homemade Pizza",
   "tags" : ["italian", "pizza", "dinner"],
   "ingredients": [
       "1 1/2 cups (355 ml) warm water (105°F-115°F)",
       "1 package (2 1/4 teaspoons) of active dry yeast",
       "3 3/4 cups (490 g) bread flour",
       "feta cheese, firm mozzarella cheese, grated"
   ],
   "instructions": [
       "Step 1.",
       "Step 2.",
       "Step 3."
   ]
}' | jq -r

注意

jq 工具 stedolan.github.io/jq/ 用于以 JSON 格式格式化响应体。它是一个功能强大的命令行 JSON 处理器。

随着 POST 端点按预期工作,我们可以将代码更改推送到一个新的功能分支:

git checkout -b feature/new_recipe
git add .
git commit -m "new recipe endpoint"
git push origin feature/new_recipe

一旦提交,请发起一个拉取请求,将 feature/new_recipe 分支与 develop 分支合并:

![图 2.8 – 将新食谱端点功能分支合并到 develop 分支图片 B17115_02_08.jpg

![图 2.8 – 将新食谱端点功能分支合并到 develop 分支一旦更改已合并,请确保删除功能分支。现在,POST /recipes 端点已创建,我们可以实现一个 GET /recipes 端点,以列出使用 POST/recipes 端点添加的所有食谱。## GET /recipes 与之前的端点类似,在 /recipes 资源上注册一个 GET 方法并附加 ListRecipesHandler。当在 /recipes 资源上接收到传入的 GET 请求时,将调用该函数。代码很简单;它使用 c.JSON() 方法将 recipes 数组序列化为 JSON:gofunc ListRecipesHandler(c *gin.Context) {   c.JSON(http.StatusOK, recipes)}func main() {   router := gin.Default()   router.POST("/recipes", NewRecipeHandler)   router.GET("/recipes", ListRecipesHandler)   router.Run()}使用 go run main.go 命令重新部署应用程序:![图 2.9 – 暴露 GET 端点图片 B17115_02_09.jpg

![图 2.9 – 暴露 GET 端点要测试端点,向 http://localhost:8080/recipes 发起 GET 请求。这里,将返回一个空数组:图 2.10 – 获取食谱列表

图 2.10 – 获取食谱列表

相应的 cURL 命令如下:

curl -s --location --request GET 'http://localhost:8080/recipes' \
--header 'Content-Type: application/json'

空数组是由于 recipes 变量仅在应用程序运行时可用。在下一章中,我们将介绍如何将 RESTful API 连接到数据库,如 MongoDB 以实现数据持久性。但到目前为止,我们可以在启动应用程序时通过在 init() 方法中放置初始化代码来初始化 recipes 数组。

加载机制将基于一个包含我预先创建的食谱列表的 JSON 文件。完整的列表可在本书的 GitHub 仓库中找到:

图 2.11 – 食谱列表的 JSON 格式

图 2.11 – 食谱列表的 JSON 格式

我们将使用 ioutil.ReadFile() 方法读取 JSON 文件,然后使用以下代码片段将其内容转换为食谱数组:

func init() {
   recipes = make([]Recipe, 0)
   file, _ := ioutil.ReadFile("recipes.json")
   _ = json.Unmarshal([]byte(file), &recipes)
}

不要忘记在重新运行应用程序并发出对 /recipes 端点的 GET 请求之前导入 encoding/jsonio/ioutil。这次,将以 JSON 格式返回食谱列表:

图 2.12 – GET /recipes 返回食谱列表

图 2.12 – GET /recipes 返回食谱列表

您可以使用 curljq 命令来计算请求返回的食谱数量:

curl -s -X GET 'http://localhost:8080/recipes' | jq length

recipes.json 文件包含 492 个食谱;因此,HTTP 请求应返回 492 个食谱:

图 2.13 – 使用 jq 计算 JSON 项

图 2.13 – 使用 jq 计算 JSON 项

通过创建以下命令创建一个新的功能分支并将新端点代码提交到 Git:

git checkout -b feature/fetch_all_recipes
git add .
git commit -m "list recipes endpoint"
git push origin feature/fetch_all_recipes

一旦更改已推送,创建一个拉取请求并将分支合并到 develop

图 2.14 – 将食谱列表端点功能分支合并到 develop 分支

图 2.14 – 将食谱列表端点功能分支合并到 develop 分支

PUT /recipes/

要更新现有食谱,我们将使用带有路径查询参数 ID 的 PUT 动词,该参数代表要更新的食谱的标识符。在主函数内部注册 /recipes/:id 资源的端点:

router.PUT("/recipes/:id", UpdateRecipeHandler)

UpdateRecipeHandler 端点的处理代码如下片段提供。它使用 c.Param() 方法从请求 URL 中获取食谱 ID,将请求体转换为 Recipe 结构体,并遍历食谱列表,寻找要更新的食谱。如果没有找到,则发送带有 404 代码错误的错误消息;否则,使用请求体中的新值更新食谱:

func UpdateRecipeHandler(c *gin.Context) {
   id := c.Param("id")
   var recipe Recipe
   if err := c.ShouldBindJSON(&recipe); err != nil {
       c.JSON(http.StatusBadRequest, gin.H{
          "error": err.Error()})
       return
   }
   index := -1
   for i := 0; i < len(recipes); i++ {
       if recipes[i].ID == id {
           index = i
       }
   }
   if index == -1 {
       c.JSON(http.StatusNotFound, gin.H{
          "error": "Recipe not found"})
       return
   }
   recipes[index] = recipe
   c.JSON(http.StatusOK, recipe)
}

重新启动服务器,然后发出 POST 请求以创建新食谱。为了说明更新端点的工作原理,我们将创建一个 margherita 食谱的以下示例 JSON:

{
   "name": "Homemade Pizza",
   "tags" : ["italian", "pizza", "dinner"],
   "ingredients": [
       "pizza dough",
       "tomato sauce",
       "olive oil",
       "7 ounces fresh mozzarella cheese, cut into 
        1/2-inch cubes",
       "5 - 6 large fresh basil leaves"
   ],
   "instructions": []
}

为了测试它,再次使用 Postman 客户端,在 http://localhost:8080/recipes 上发出一个新的 POST 请求,使用此 JSON 文档:

![图 2.15 – 添加新食谱]

![图片 B17115_02_15.jpg]

图 2.15 – 添加新食谱

将创建 Homemade Pizza 食谱,并且您将收到新食谱的 ID(在我们的示例中,它是 c2inb6q3k1kc2p0uqetg)。假设我们想更新食谱并将其更改为 Shrimp scampi pizza。这次,我们可以使用 PUT 方法并提供食谱的 ID 作为 path 参数:

![图 2.16 – 更新现有食谱]

![图片 B17115_Figure_2.16.jpg]

图 2.16 – 更新现有食谱

请求将返回 200 状态码。为了验证更改是否生效,我们可以使用 GET /recipes 端点:

![图 2.17 – 验证更改是否应用于食谱]

![图片 B17115_Figure_2.17.jpg]

图 2.17 – 验证更改是否应用于食谱

将新端点推送到一个新的功能分支,并将分支合并到 develop

git checkout -b feature/update_recipe
git add .
git commit -m "update recipe endpoint"
git push origin feature/update_recipe

DELETE /recipes/

要删除食谱,我们需要在我们的主函数中注册 DELETE HTTP 路由,如下所示:

router.DELETE("/recipes/:id", DeleteRecipeHandler)

DeleteRecipeHandler 函数的代码将从请求参数中获取目标食谱 ID 并遍历食谱列表。如果没有找到匹配的食谱,将使用 404 状态码发送 "Recipe not found" 错误消息。否则,将使用数组上的食谱索引并基于索引删除食谱:

func DeleteRecipeHandler(c *gin.Context) {
   id := c.Param("id")
   index := -1
   for i := 0; i < len(recipes); i++ {
       if recipes[i].ID == id {
           index = i
       }
   }
   if index == -1 {
       c.JSON(http.StatusNotFound, gin.H{
          "error": "Recipe not found"})
       return
   }
   recipes = append(recipes[:index], recipes[index+1:]...)
   c.JSON(http.StatusOK, gin.H{
      "message": "Recipe has been deleted"))
}

要测试删除端点,请使用 Postman 客户端或在终端会话中发出 cURL 命令:

curl -v -sX DELETE http://localhost:8080/recipes/c0283p3d0cvuglq85log | jq -r

如果目标食谱存在,则它将被删除,并且您将看到返回成功消息:

![图 2.18 – 删除食谱]

![图片 B17115_02_18.jpg]

图 2.18 – 删除食谱

否则,将返回错误消息:

![图 2.19 – 如果找不到食谱,将返回错误 404 信息]

![图片 B17115_02_19.jpg]

图 2.19 – 如果找不到食谱,将返回错误 404 信息

再次,将更改存储在功能分支中,并将其合并到 develop

git checkout -b feature/delete_recipe
git add .
git commit -m "delete recipe endpoint"
git push origin feature/delete_recipe

GET /recipes/search

最后一个端点允许用户根据标签或关键词搜索食谱:

router.GET("/recipes/search", SearchRecipesHandler)

SearchRecipesHandler 处理器代码片段如下(不要忘记导入 strings):

func SearchRecipesHandler(c *gin.Context) {
   tag := c.Query("tag")
   listOfRecipes := make([]Recipe, 0)
   for i := 0; i < len(recipes); i++ {
       found := false
       for _, t := range recipes[i].Tags {
           if strings.EqualFold(t, tag) {
               found = true
           }
       }
       if found {
           listOfRecipes = append(listOfRecipes, 
              recipes[i])
       }
   }
   c.JSON(http.StatusOK, listOfRecipes)
}

HTTP 处理器使用 c.Query 方法获取查询参数中给出的标签值。

您可以通过在 localhost:8080/recipes/search?tag=italian 发出 GET 请求来测试端点,查找意大利食谱:

![图 2.20 – 使用查询参数搜索食谱]

![图片 B17115_02_20.jpg]

图 2.20 – 使用查询参数搜索食谱

最后,通过创建一个新的功能分支将搜索端点代码推送到远程仓库:

git checkout -b feature/search_recipe
git add .
git commit -m "search recipe by tag"
git push origin feature/search_recipe

注意

在每次提交之前,请确保运行 go mod tidy 命令以确保你的 go.modgo.sum 文件干净且准确。

到目前为止,我们已经介绍了如何使用 Gin 框架在 Golang 中构建 RESTful API。然而,如果没有有意义的 API 文档,用户将无法使用它。

文档应该成为你开发周期的一部分,以帮助你维护可扩展的 API。这就是为什么在下一节中,我们将探讨如何使用 OpenAPI 规范OAS)。

编写 OpenAPI 规范

OpenAPI 规范(以前称为 Swagger 规范)是一种 API 描述格式或 API 定义语言。它允许你描述一个 API,包括以下信息:

  • 关于 API 的一般信息

  • 可用的路径和操作(HTTP 方法)

  • 每个操作的预期输入(查询或路径参数、请求体等)和响应(HTTP 状态码、响应体等)

从现有的 API 中找到一种简单的方法来生成 OpenAPI 定义可能具有挑战性。好消息是 Swagger 工具可以帮助你轻松完成这项任务。

安装 Go Swagger

要开始,请从官方指南 goswagger.io/install.html 安装 go-swagger 工具或从 GitHub github.com/go-swagger/go-swagger/releases 下载二进制文件。在撰写本书时,最新稳定版本是 v0.25.0:

![Figure 2.21 – Go Swagger binary – latest release]

![img/B17115_02_21.jpg]

![Figure 2.21 – Go Swagger binary – latest release]

确保将其添加到 PATH 环境变量中。然后,运行以下命令以验证安装:

go-swagger version

之前的命令应该显示以下输出:

![Figure 2.22 – Go Swagger version]

![img/B17115_02_22.jpg]

图 2.22 – Go Swagger 版本

现在,是时候为食谱 API 编写我们的 OpenAPI 规范了。

注意

go-swagger 的一个替代方案是 swag (github.com/swaggo/swag). 这个工具可以将 Go 注释转换为 Swagger 文档。

Swagger 元数据

我们将首先使用 swagger:meta 注解提供一些关于 API 的基本信息。这个注解有以下属性:

![img/02.jpg]

main 包的顶部添加以下注释:

// Recipes API
//
// This is a sample recipes API. You can find out more about the API at https://github.com/PacktPublishing/Building-Distributed-Applications-in-Gin.
//
//  Schemes: http
//  Host: localhost:8080
//  BasePath: /
//  Version: 1.0.0
//  Contact: Mohamed Labouardy 
// <mohamed@labouardy.com> https://labouardy.com
//
//  Consumes:
//  - application/json
//
//  Produces:
//  - application/json
// swagger:meta
package main

这些注释包括 API 的描述、版本、基本 URL 等内容。还有更多字段可以包含(完整列表可在 goswagger.io/use/spec/meta.html 找到)。

为了生成 OpenAPI 规范,我们将使用swagger命令行工具。CLI 将解析main.go文件。如果解析器遇到与 Swagger 注释或任何支持的标签匹配的注释,它将生成相应的规范块。

在您的终端中,运行以下命令以生成spec文件:

swagger generate spec –o ./swagger.json

此命令将生成 JSON 格式的规范。您也可以通过添加.yml.yaml扩展名来生成 YAML 格式的规范。

生成的swagger.json文件内容如下:

{
"consumes": [
   "application/json"
],
"produces": [
   "application/json"
],
"schemes": [
   "http"
],
"swagger": "2.0",
"info": {
   "description": "This is a sample recipes API. You can 
    find out more about the API at https://github.com/PacktPublishing/Building-Distributed-Applications-in-Gin.",
   "title": "Recipes API",
   "contact": {
     "name": "Mohamed Labouardy",
     "url": "https://labouardy.com",
     "email": "mohamed@labouardy.com"
   },
   "version": "1.0.0"
},
"host": "localhost:8080",
"basePath": "/",
"paths": {}
} 

Swagger 命令行的另一个酷炫功能是其内置的 UI。您可以使用以下命令在本地加载生成的规范到 Swagger UI 中:

swagger serve ./swagger.json

UI 将在端口http://localhost:49566上公开:

![图 2.23 – 在 UI 中加载 Swagger 规范图片 B17115_02_23.jpg

图 2.23 – 在 UI 中加载 Swagger 规范

如果您将浏览器指向该 URL,您应该会看到基于 Redoc 模板的 UI。UI 有两种风味 – Redoc 和 Swagger UI:

![图 2.24 – 带有 Redoc 风格的 Swagger图片 B17115_02_24.jpg

图 2.24 – 带有 Redoc 风格的 Swagger

如果您是 Swagger UI 的粉丝,可以使用以下命令将 flavor 标志设置为swagger

swagger serve -F swagger ./swagger.json

这次,API 规范将由 Swagger UI 提供:

![图 2.25 – Swagger UI 模式图片 B17115_02_25.jpg

图 2.25 – Swagger UI 模式

注意

您还可以使用 Swagger 在线编辑器(editor.swagger.io/)来编辑和加载您的 OpenAPI 规范文件。

接下来,我们将为列出配方端点定义一个swagger:operation。该注释具有以下属性:

您可以在github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operationObject找到所有属性。

如下所示,注释ListRecipesHandler函数。该注释接受 HTTP 方法、路径模式和操作 ID 作为参数:

// swagger:operation GET /recipes recipes listRecipes
// Returns list of recipes
// ---
// produces:
// - application/json
// responses:
//     '200':
//         description: Successful operation
func ListRecipesHandler(c *gin.Context) {
   c.JSON(http.StatusOK, recipes)
}

对于每个操作,您可以在responses部分中描述与 HTTP 状态代码(200、404 等)匹配的 HTTP 响应。对于此端点,当响应GET /recipes时,我们只会返回 200 成功代码。description字段解释了此响应的含义。

使用 Swagger CLI 生成规范并重新加载swagger.json。这次,将添加GET /recipes操作:

![图 2.26 – 添加新的 Swagger 操作图片 B17115_02_26.jpg

图 2.26 – 添加新的 Swagger 操作

PUT /recipes/{id}端点定义另一个 Swagger 操作。类似于之前的操作,我们可以根据处理响应代码定义响应。我们还可以在parameters部分中将 ID 定义为path参数。此外,还可以提供可选的描述,如下所示:

// swagger:operation PUT /recipes/{id} recipes updateRecipe
// Update an existing recipe
// ---
// parameters:
// - name: id
//   in: path
//   description: ID of the recipe
//   required: true
//   type: string
// produces:
// - application/json
// responses:
//     '200':
//         description: Successful operation
//     '400':
//         description: Invalid input
//     '404':
//         description: Invalid recipe ID
func UpdateRecipeHandler(c *gin.Context) {}

重新生成 swagger.json 文件并重新加载 Swagger UI:

![图 2.27 – Swagger 中的 PUT 操作img/B17115_02_27.jpg

图 2.27 – Swagger 中的 PUT 操作

定义剩下的操作。你应该有类似以下的内容:

![图 2.28 – 食谱 API 操作img/B17115_02_28.jpg

图 2.28 – 食谱 API 操作

通过这样,你已经学到了 OpenAPI 规范的基础。

由于 OpenAPI 规范文件是一个简单的 JSON 文件,它可以在任何源代码管理工具(SCM)中共享和管理,就像应用程序源代码一样。使用以下命令将 spec 文件提交到 GitHub:

git checkout -b feature/openapi
git add .
git commit -m "added openapi specs"
git push origin feature/openapi

更新后的存储库将如下所示:

![图 2.29 – 在 GitHub 上存储 OpenAPI 规范img/B17115_02_29.jpg

图 2.29 – 在 GitHub 上存储 OpenAPI 规范

摘要

在本章中,你学习了如何使用 Gin 框架从头开始构建 RESTful API。我们还介绍了如何使用 Gin 数据绑定和验证方法验证传入的 HTTP 请求。然后,我们介绍了 OpenAPI 规范,并学习了如何从现有的 API 中生成它。你现在应该熟悉暴露 HTTP 方法(GET、POST、DELETE、PUT 等)以处理 HTTP 请求。

在下一章中,我们将使用 MongoDB 作为 NoSQL 数据库来管理 API 的数据持久性。

问题

  1. 什么是 GitFlow 策略?

  2. 我们如何在 Go 中定义数据模型?

  3. 我们如何在 Gin 中验证 POST 请求的正文?

  4. 定义一个可以通过 ID 获取一个食谱的 API 端点。

  5. 使用 OpenAPI 定义新食谱端点的正文参数。

进一步阅读

  • 实践 RESTful API 设计模式和最佳实践,由 Harihara Subramanian,Pethuru Raj,Packt 出版

  • 使用 GIT(Flow) Jenkins, Artifactory, Sonar, ELK, JIRA [视频],由 Nand Venegalla,Packt 出版

第三章:使用 MongoDB 管理数据持久性

在上一章中,我们学习了如何使用 Gin 网络框架构建 RESTful API。在本章中,我们将集成 MongoDB 到后端进行数据存储,同时也会介绍如何使用 Redis 作为缓存层来优化数据库查询。

在本章中,我们将涵盖以下主题:

  • 使用 Docker 设置 MongoDB

  • 配置 Go MongoDB 驱动

  • 实现 MongoDB 查询和 CRUD 操作

  • 标准的 Go 项目布局

  • 使用 Docker 部署 Redis

  • 使用缓存优化 API 响应时间

  • 使用 Apache Benchmark 进行性能基准测试

到本章结束时,您将能够使用 Go 在 MongoDB 数据库上执行 CRUD 操作。

技术要求

要跟随本章的内容,您将需要以下内容:

  • 由于本章是前一章的后续,因此您必须对前一章有完整理解;它将使用相同的源代码。因此,为了避免重复,一些代码片段将不会进行解释。

  • 对 NoSQL 概念和 MongoDB 基本查询有一定的了解。

本章的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter03

运行 MongoDB 服务器

我们迄今为止构建的 API 还未连接到数据库。对于实际应用,我们需要使用某种形式的数据存储;否则,如果 API 崩溃或托管 API 的服务器宕机,数据将会丢失。MongoDB 是最受欢迎的 NoSQL 数据库之一。

以下架构图显示了 MongoDB 将如何集成到 API 架构中:

![图 3.1 – API 架构

![图 3.1 – API 架构

图 3.1 – API 架构

在我们开始之前,我们需要部署一个 MongoDB 服务器。有大量的部署选项:

![图 3.2 – MongoDB 社区服务器

![图 3.2 – MongoDB 社区服务器

图 3.2 – MongoDB 社区服务器

  • 您可以使用名为 MongoDB Atlas 的 MongoDB 作为服务解决方案(www.mongodb.com/cloud/atlas),在云上运行一个免费的 500 MB 数据库。您可以在 AWS、Google Cloud Platform 或 Microsoft Azure 上部署一个完全管理的 MongoDB 服务器。

  • 您可以使用 Docker 等容器化解决方案在本地运行 MongoDB。DockerHub 上有多个 MongoDB 服务器配置好的 Docker 镜像,可以直接使用。

我选择使用 Docker,因为它在运行临时环境方面既受欢迎又简单。

安装 Docker CE

Docker (www.docker.com/get-started) 是一个开源项目,它允许您运行、构建和管理容器。容器就像一个独立的操作系统,但不是虚拟化;它只包含一个应用程序所需的依赖项,这使得容器可移植,可以在本地或云上部署。

以下图表显示了容器和虚拟机在架构方法上的主要区别:

图 3.3 – 虚拟机与容器对比

图 3.3 – 虚拟机与容器对比

虚拟机在硬件级别进行虚拟化,而容器在应用层进行虚拟化。因此,容器可以共享操作系统内核和库,这使得它们非常轻量级且资源高效(CPU、RAM、磁盘等)。

要开始,您需要在您的机器上安装 Docker 引擎。导航到 docs.docker.com/get-docker/ 并为您的平台安装 Docker:

图 3.4 – Docker 安装

图 3.4 – Docker 安装

注意

Mac 用户也可以使用 Homebrew 工具通过 brew install docker 命令安装 Docker。

按照安装向导进行操作,完成后,通过执行以下命令验证一切是否正常:

docker version

在撰写本书时,我正在使用 Docker 社区版 (CE) 20.10.2 版本,如下截图所示:

图 3.5 – Docker 社区版 (CE) 版本

图 3.5 – Docker 社区版 (CE) 版本

安装 Docker 后,您可以在终端会话中运行以下命令来部署您的第一个容器:

docker run hello-world

以下命令将基于 hello-world 镜像部署容器。当容器运行时,它将打印一条 Hello from Docker! 消息并退出:

图 3.6 – Docker hello-world 容器

图 3.6 – Docker hello-world 容器

恭喜!您现在已成功运行 Docker。

运行 MongoDB 容器

MongoDB 的官方镜像可以在 DockerHub 上找到 (hub.docker.com/_/mongo)。有大量的镜像可供选择,每个镜像代表 MongoDB 的不同版本。您可以使用 latest 标签来查找它们;然而,建议指定目标版本。在撰写本书时,MongoDB 4.4.3 是最新的稳定版本。执行以下命令以基于该版本部署容器:

docker run -d --name mongodb -e MONGO_INITDB_ROOT_USERNAME=admin -e MONGO_INITDB_ROOT_PASSWORD=password -p 27017:27017 mongo:4.4.3

此命令将以分离模式(-d 标志)运行 MongoDB 容器。我们还映射了容器端口到主机端口,以便我们可以从主机级别访问数据库。最后,我们必须创建一个新用户,并通过 MONGO_INITDB_ROOT_USERNAMEMONGO_INITDB_ROOT_PASSWORD 环境变量设置该用户的密码。

目前,MongoDB 凭据以纯文本形式存在。另一种通过环境变量传递敏感信息的方法是使用 Docker Secrets。如果您在 Swarm 模式下运行,可以执行以下命令:

openssl rand -base64 12 | docker secret create mongodb_password -

注意

Docker Swarm 模式原生集成在 Docker 引擎中。它是一个用于在节点集群中构建、部署和扩展容器的容器编排平台。

此命令将为 MongoDB 用户生成一个随机密码并将其设置为 Docker 机密。

接下来,更新 docker run 命令,使其使用 Docker 机密而不是纯文本中的密码:

-e MONGO_INITDB_ROOT_PASSWORD_FILE=/run/secrets/mongodb_password

docker run 命令的输出如下。它从 DockerHub 下载镜像并从中创建一个实例(容器):

图 3.7 – 从 DockerHub 拉取 MongoDB 镜像

图 3.7 – 从 DockerHub 拉取 MongoDB 镜像

值得注意的是,如果您已经运行了 MongoDB 容器,在执行前面的命令之前请确保将其删除;否则,您将收到“容器已存在”错误。要删除现有容器,请发出以下命令:

docker rm -f container_name || true 

容器创建后,通过键入以下内容来检查日志:

docker logs –f CONTAINER_ID

日志应显示 MongoDB 服务器的健康检查:

图 3.8 – MongoDB 容器运行日志

图 3.8 – MongoDB 容器运行日志

注意

建议您使用 Docker 卷将容器内的 /data/db 目录映射到底层主机系统。这样,如果 MongoDB 服务器失败或您的笔记本电脑重启,数据不会丢失(数据持久性)。在主机系统上创建一个数据目录,并使用以下命令将其挂载到 /data/db 目录:

mkdir /home/data

docker run -d --name mongodb –v /home/data:/data/db -e MONGO_INITDB_ROOT_USERNAME=admin -e MONGO_INITDB_ROOT_PASSWORD=password -p 27017:27017 mongo:4.4.3

要与 MongoDB 服务器交互,您可以使用 MongoDB 壳来在命令行中发出查询并查看数据。然而,有一个更好的选择:MongoDB Compass。

安装 MongoDB Compass

MongoDB Compass 是一个图形用户界面工具,它允许你轻松构建查询、理解你的数据库模式,并分析你的索引,而无需了解 MongoDB 的查询语法。

根据您的操作系统从 www.mongodb.com/try/download/compass?tck=docs_compass 下载 Compass:

图 3.9 – MongoDB Compass 下载页面

图 3.9 – MongoDB Compass 下载页面

下载与您的操作系统相关的包后,运行安装程序并按照其后的步骤操作。安装完成后,打开 Compass,点击 mongodb://admin:password@localhost:27017/test

MongoDB 在本地运行,因此主机名将是 localhost,端口将是 27017:

图 3.10 – MongoDB Compass – 新连接

图 3.10 – MongoDB Compass – 新连接

点击连接按钮。现在,您已连接到您的 MongoDB 服务器。您将看到可用的数据库列表:

图 3.11 – MongoDB 默认数据库

图 3.11 – MongoDB 默认数据库

到目前为止,我们已经有一个功能性的 MongoDB 部署。在下一节中,我们将使用上一章中构建的 Recipes API 与数据库进行交互。

注意

要停止 MongoDB 服务器,运行docker ps命令查看正在运行的容器列表,然后使用docker stop CONTAINER_ID停止容器。

配置 Go 的 MongoDB 驱动程序

我们在上一章中实现的 Recipes API 是用 Golang 编写的。因此,我们需要安装官方的 MongoDB Go 驱动程序(github.com/mongodb/mongo-go-driver)以与 MongoDB 服务器交互。该驱动程序完全集成到 MongoDB API 中,并支持 API 的所有主要查询和聚合功能。

输入以下命令从 GitHub 安装包:

go get go.mongodb.org/mongo-driver/mongo

这将在go.mod文件下的require部分添加包作为依赖项:

module github.com/mlabouardy/recipes-api
go 1.15
require (
   github.com/gin-gonic/gin v1.6.3
   github.com/rs/xid v1.2.1 
   go.mongodb.org/mongo-driver v1.4.5 
)

要开始使用,请在main.go文件中导入以下包:

package main
import (
   "go.mongodb.org/mongo-driver/mongo"
   "go.mongodb.org/mongo-driver/mongo/options"
   "go.mongodb.org/mongo-driver/mongo/readpref"
)

init()方法中,使用Connect函数创建一个mongo.Client。此函数接受一个上下文参数和连接字符串,该连接字符串由名为MONGO_URI的环境变量提供。同时创建以下全局变量;它们将在所有 CRUD 操作函数中使用:

var ctx context.Context
var err error
var client *mongo.Client
func init() {
   ...
   ctx = context.Background()
   client, err = mongo.Connect(ctx, 
       options.Client().ApplyURI(os.Getenv("MONGO_URI")))
   if err = client.Ping(context.TODO(), 
           readpref.Primary()); err != nil {
       log.Fatal(err)
   }
   log.Println("Connected to MongoDB")
}

注意

为了使示例易于阅读和跟随,我已省略了一些代码。完整的源代码可在本书的 GitHub 仓库中找到,位于chapter03文件夹下。

一旦Connect方法返回客户端对象,我们可以使用Ping方法来检查连接是否成功。

MONGO_URI环境变量传递给go run命令,并检查应用程序是否可以成功连接到您的 MongoDB 服务器:

MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" go run main.go

如果成功,将显示已连接到 MongoDB的消息:

图 3.12 – 使用 Go 驱动程序的 MongoDB 连接

图 3.12 – 使用 Go 驱动程序的 MongoDB 连接

现在,让我们用一些数据填充一个全新的数据库。

探索 MongoDB 查询

在本节中,我们将使用 CRUD 操作与 MongoDB 服务器进行交互,但首先,让我们创建一个数据库,用于存储 API 数据。

注意

您可以在 GoDoc 网站上查看 MongoDB Go 驱动程序的完整文档(godoc.org/go.mongodb.org/mongo-driver)。

插入多个操作

让我们使用上一章中创建的recipes.json文件初始化数据库。首先,从Client获取DatabaseCollection实例。Collection实例将用于插入文档:

func init() {
   recipes = make([]Recipe, 0)
   file, _ := ioutil.ReadFile("recipes.json")
   _ = json.Unmarshal([]byte(file), &recipes)
   ctx = context.Background()
   client, err = mongo.Connect(ctx, 
       options.Client().ApplyURI(os.Getenv("MONGO_URI")))
   if err = client.Ping(context.TODO(), 
           readpref.Primary()); err != nil {
       log.Fatal(err)
   }
   log.Println("Connected to MongoDB")
   var listOfRecipes []interface{}
   for _, recipe := range recipes {
       listOfRecipes = append(listOfRecipes, recipe)
   }
   collection := client.Database(os.Getenv(
       "MONGO_DATABASE")).Collection("recipes")
   insertManyResult, err := collection.InsertMany(
       ctx, listOfRecipes)
   if err != nil {
       log.Fatal(err)
   }
   log.Println("Inserted recipes: ", 
               len(insertManyResult.InsertedIDs))
}

上述代码读取一个 JSON 文件(github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/blob/main/chapter03/recipes.json),其中包含食谱列表,并将其编码为Recipe结构的数组。然后,它与 MongoDB 服务器建立连接并将食谱插入到recipes集合中。

要一次性插入多个文档,我们可以使用InsertMany()方法。此方法接受一个接口切片作为参数。因此,我们必须将Recipes结构切片映射到接口切片。

重新运行应用程序,但这次,将MONGO_URIMONGO_DATABASE变量设置如下:

MONGO_URI="mongodb://USER:PASSWORD@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run main.go

确保将USER替换为您的数据库用户,将PASSWORD替换为在部署 MongoDB 容器时创建的用户密码。

应用程序将被启动;首先执行init()方法,并将食谱项插入到 MongoDB 集合中:

![图 3.13 – 启动时插入食谱图片 B17115_03_13.jpg

图 3.13 – 启动时插入食谱

要验证数据是否已加载到食谱集合中,请刷新 MongoDB Compass。你应该看到你创建的条目:

![图 3.14 – 食谱集合图片 B17115_03_14.jpg

图 3.14 – 食谱集合

现在,recipes集合已经准备好了,我们需要更新每个 API 端点的代码,使它们使用集合而不是硬编码的食谱列表。但首先,我们需要更新init()方法以删除recipes.json文件的加载和编码:

func init() {
   ctx = context.Background()
   client, err = mongo.Connect(ctx, 
       options.Client().ApplyURI(os.Getenv("MONGO_URI")))
   if err = client.Ping(context.TODO(), 
                        readpref.Primary()); err != nil {
       log.Fatal(err)
   }
   log.Println("Connected to MongoDB")
}

值得注意的是,您可以使用mongoimport实用程序将recipe.json文件直接加载到recipes集合中,而无需在 Golang 中编写任何代码。此命令如下:

mongoimport --username admin --password password --authenticationDatabase admin --db demo --collection recipes --file recipes.json --jsonArray

此命令将 JSON 文件的内容导入到recipes集合中:

![图 3.15 – 使用 mongoimport 导入数据图片 B17115_03_15.jpg

图 3.15 – 使用 mongoimport 导入数据

在下一节中,我们将更新现有的函数处理程序,以便从recipes集合中读取和写入。

查找操作

要开始,我们需要实现一个返回食谱列表的函数。更新ListRecipesHandler,使其使用Find()方法从recipes集合中获取所有项目:

func ListRecipesHandler(c *gin.Context) {
   cur, err := collection.Find(ctx, bson.M{})
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
              gin.H{"error": err.Error()})
       return
   }
   defer cur.Close(ctx)
   recipes := make([]Recipe, 0)
   for cur.Next(ctx) {
       var recipe Recipe
       cur.Decode(&recipe)
       recipes = append(recipes, recipe)
   }
   c.JSON(http.StatusOK, recipes)
}

Find()方法返回一个游标,它是一系列文档的流。我们必须遍历文档流,并将每个文档一次解码到Recipe结构中。然后,我们必须将文档追加到食谱列表中。

运行应用程序,然后在/recipes端点发出 GET 请求;将在recipes集合上执行find()操作。结果,将返回一个食谱列表:

![图 3.16 – 获取所有食谱图片 B17115_03_16.jpg

图 3.16 – 获取所有食谱

端点正在工作,并从集合中检索食谱项。

InsertOne 操作

需要实现的第二个函数将负责保存新食谱。更新 NewRecipeHandler 函数,使其在 recipes 集合上调用 InsertOne() 方法:

func NewRecipeHandler(c *gin.Context) {
   var recipe Recipe
   if err := c.ShouldBindJSON(&recipe); err != nil {
       c.JSON(http.StatusBadRequest, gin.H{"error": 
           err.Error()})
       return
   }
   recipe.ID = primitive.NewObjectID()
   recipe.PublishedAt = time.Now()
   _, err = collection.InsertOne(ctx, recipe)
   if err != nil {
       fmt.Println(err)
       c.JSON(http.StatusInternalServerError, 
           gin.H{"error": "Error while inserting
                  a new recipe"})
       return
   }
   c.JSON(http.StatusOK, recipe)
}

在这里,我们在将项目保存到集合之前使用 primitive.NewObjectID() 方法设置一个唯一标识符。因此,我们需要更改 Recipe 结构体的 ID 类型。注意 bson 标签的使用,它将 struct 字段映射到 MongoDB 集合中的 document 属性:

// swagger:parameters recipes newRecipe
type Recipe struct {
   //swagger:ignore
   ID primitive.ObjectID `json:"id" bson:"_id"`
   Name string `json:"name" bson:"name"`
   Tags []string `json:"tags" bson:"tags"`
   Ingredients []string `json:"ingredients" bson:"ingredients"`
   Instructions []string `json:"instructions"                           bson:"instructions"`
   PublishedAt time.Time `json:"publishedAt"                           bson:"publishedAt"`
}

注意

默认情况下,Go 在编码结构体值时将结构体字段名转换为小写。如果需要不同的名称,可以使用 bson 标签覆盖默认机制。

通过使用 Postman 客户端调用以下 POST 请求来插入新食谱:

图 3.17 – 创建新食谱

图 3.17 – 创建新食谱

验证食谱是否已插入到 MongoDB 集合中,如下截图所示:

图 3.18 – 获取最后插入的食谱

图 3.18 – 获取最后插入的食谱

要获取最后插入的食谱,我们使用 sort() 操作。

UpdateOne 操作

最后,为了更新集合中的项目,更新 UpdateRecipeHandler 函数,使其调用 UpdateOne() 方法。此方法需要一个过滤器文档来匹配数据库中的文档,以及一个更新器文档来描述更新操作。您可以使用 bson.D{} – 一个 二进制编码的 JSONBSON)文档来构建过滤器:

func UpdateRecipeHandler(c *gin.Context) {
   id := c.Param("id")
   var recipe Recipe
   if err := c.ShouldBindJSON(&recipe); err != nil {
       c.JSON(http.StatusBadRequest, gin.H{"error":                                            err.Error()})
       return
   }
   objectId, _ := primitive.ObjectIDFromHex(id)
   _, err = collection.UpdateOne(ctx, bson.M{
       "_id": objectId,
   }, bson.D{{"$set", bson.D{
       {"name", recipe.Name},
       {"instructions", recipe.Instructions},
       {"ingredients", recipe.Ingredients},
       {"tags", recipe.Tags},
   }}})
   if err != nil {
       fmt.Println(err)
       c.JSON(http.StatusInternalServerError, 
           gin.H{"error": err.Error()})
       return
   }
   c.JSON(http.StatusOK, gin.H{"message": "Recipe 
                               has been updated"})
}

此方法通过其 Object ID 过滤文档。我们通过将 ObjectIDFromHex 应用到路由参数 ID 来获取 Object ID。这使用请求体中的新值更新匹配的食谱字段。

通过对现有食谱发出 PUT 请求来验证端点是否正常工作:

图 3.19 – 更新食谱

图 3.19 – 更新食谱

请求将匹配 ID600dcc85a65917cbd1f201b0 的食谱,并将其 name 从 "Homemade Pizza" 更改为 "Homemade Pepperoni Pizza",并将 instructions 字段更新为制作 "Pepperoni Pizza" 的额外步骤。

因此,食谱已成功更新。您可以使用 MongoDB Compass 确认这些更改:

图 3.20 – UpdateOne 操作结果

图 3.20 – UpdateOne 操作结果

您现在应该熟悉基本的 MongoDB 查询。继续实施剩余的 CRUD 操作。

最后,确保使用以下命令将更改推送到远程仓库:

git checkout –b feature/mongo_integration
git add .
git commit –m "added mongodb integration"
git push origin feature/mongo_integration

然后,创建一个拉取请求以将 feature 分支合并到 develop

图 3.21 – 新的拉取请求

图 3.21 – 新的拉取请求

注意

端点的完整实现可以在本书的 GitHub 仓库中找到(github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/blob/main/chapter03/main.go)。

你刚刚看到了如何将 MongoDB 集成到应用程序架构中。在下一节中,我们将介绍如何重构应用程序的源代码,使其在长期内可维护、可扩展和可扩展。

设计项目的布局

到目前为止,我们编写的所有代码都在main.go文件中。虽然这样没问题,但确保代码结构良好很重要;否则,当项目增长时,你将会有很多隐藏的依赖和混乱的代码(意大利面代码)。

我们将从数据模型开始。让我们创建一个models文件夹,以便我们可以存储所有的模型结构体。目前,我们有一个模型,即Recipe结构体。在models文件夹下创建一个recipe.go文件,并粘贴以下内容:

package models
import (
   "time"
   "go.mongodb.org/mongo-driver/bson/primitive"
)
// swagger:parameters recipes newRecipe
type Recipe struct {
   //swagger:ignore
   ID           primitive.ObjectID `json:"id" bson:"_id"`
   Name         string             `json:"name" 
                                               bson:"name"`
   Tags         []string           `json:"tags" 
                                               bson:"tags"`
   Ingredients  []string           `json:"ingredients" 
                                      bson:"ingredients"`
   Instructions []string           `json:"instructions" 
                                      bson:"instructions"`
   PublishedAt  time.Time          `json:"publishedAt" 
                                      bson:"publishedAt"`
}

然后,创建一个包含handler.go文件的handlers文件夹。这个文件夹,正如其名所示,通过暴露每个 HTTP 请求要调用的正确函数来处理任何传入的 HTTP 请求:

package handlers
import (
   "fmt"
   "net/http"
   "time"
   "github.com/gin-gonic/gin"
   "github.com/mlabouardy/recipes-api/models"
   "go.mongodb.org/mongo-driver/bson"
   "go.mongodb.org/mongo-driver/bson/primitive"
   "go.mongodb.org/mongo-driver/mongo"
   "golang.org/x/net/context"
)
type RecipesHandler struct {
   collection *mongo.Collection
   ctx        context.Context
}
func NewRecipesHandler(ctx context.Context, collection *mongo.Collection) *RecipesHandler {
   return &RecipesHandler{
       collection: collection,
       ctx:        ctx,
   }
}

这段代码创建了一个包含 MongoDB 集合和上下文实例的RecipesHandler结构体。在我们早期的简单实现中,我们倾向于在主包中全局保持这些变量。在这里,我们将这些变量保存在结构体中。接下来,我们必须定义一个NewRecipesHandler,这样我们就可以从RecipesHandler结构体创建一个实例。

现在,我们可以定义RecipesHandler类型的端点处理器。处理器可以访问结构体中的所有变量,例如数据库连接,因为它是RecipesHandler类型的一个方法:

func (handler *RecipesHandler) ListRecipesHandler(c *gin.Context) {
   cur, err := handler.collection.Find(handler.ctx, bson.M{})
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
           gin.H{"error": err.Error()})
       return
   }
   defer cur.Close(handler.ctx)

   recipes := make([]models.Recipe, 0)
   for cur.Next(handler.ctx) {
       var recipe models.Recipe
       cur.Decode(&recipe)
       recipes = append(recipes, recipe)
   }

   c.JSON(http.StatusOK, recipes)
}

从我们的main.go文件开始,我们将提供所有数据库凭据并连接到 MongoDB 服务器:

package main
import (
   "context"
   "log"
   "os"
   "github.com/gin-gonic/gin"
   handlers "github.com/mlabouardy/recipes-api/handlers"
   "go.mongodb.org/mongo-driver/mongo"
   "go.mongodb.org/mongo-driver/mongo/options"
   "go.mongodb.org/mongo-driver/mongo/readpref"
)

然后,我们必须创建一个全局变量来访问端点处理器。更新init()方法,如下所示:

var recipesHandler *handlers.RecipesHandler
func init() {
   ctx := context.Background()
   client, err := mongo.Connect(ctx, 
       options.Client().ApplyURI(os.Getenv("MONGO_URI")))
   if err = client.Ping(context.TODO(), 
           readpref.Primary()); err != nil {
       log.Fatal(err)
   }
   log.Println("Connected to MongoDB")
   collection := client.Database(os.Getenv(
       "MONGO_DATABASE")).Collection("recipes")
   recipesHandler = handlers.NewRecipesHandler(ctx, 
       collection)
}

最后,使用recipesHandler变量来访问每个 HTTP 端点的处理器:

func main() {
   router := gin.Default()
   router.POST("/recipes", recipesHandler.NewRecipeHandler)
   router.GET("/recipes", 
       recipesHandler.ListRecipesHandler)
   router.PUT("/recipes/:id", 
       recipesHandler.UpdateRecipeHandler)
   router.Run()
}

运行应用程序。这次,运行当前目录下的所有.go文件:

MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run *.go

应用程序将按预期工作。服务器日志如下:

![Figure 3.22 – Gin debug logs

![img/B17115_03_22.jpg]

图 3.22 – Gin 调试日志

现在,你的项目结构应该看起来像这样:

.
├── go.mod
├── go.sum
├── handlers
│   └── handler.go
├── main.go
├── models
│   └── recipe.go
├── recipes.json
└── swagger.json

这是一个 Go 应用程序项目的基本布局。我们将在接下来的章节中介绍 Go 目录。

将更改推送到 GitHub 上的功能分支,并将其合并到develop分支:

git checkout –b fix/code_refactoring
git add .
git commit –m "code refactoring"
git push origin fix/code_refactoring

当运行与数据库交互的服务时,其操作可能会成为瓶颈,从而降低用户体验并影响你的业务。这就是为什么响应时间是开发 RESTful API 时评估的最重要指标之一。

幸运的是,我们可以添加一个缓存层来存储频繁访问的数据在内存中,从而加快速度,减少对数据库的操作/查询次数。

使用 Redis 缓存 API

在本节中,我们将介绍如何为我们的 API 添加缓存机制。让我们设想一下,我们 MongoDB 数据库中有大量的食谱。每次我们尝试查询食谱列表时,都会遇到性能问题。我们可以做的是使用内存数据库,例如 Redis,来重用之前检索到的食谱,避免在每次请求中都击中 MongoDB 数据库。

由于 Redis 始终在 RAM 中,因此它在检索数据方面始终更快——这就是为什么它是缓存的一个优秀选择。另一方面,MongoDB 可能需要从磁盘检索数据以进行查询。

根据官方文档(redis.io/),Redis 是一个开源的、分布式的、内存中的键值数据库、缓存和消息代理。以下图表说明了 Redis 如何融入我们的 API 架构:

图 3.23 – API 新架构

图 3.23 – API 新架构

假设我们想要获取一个食谱列表。首先,API 将在 Redis 中查找。如果存在食谱列表,它将被返回(这被称为find({})查询,并将结果返回并保存在缓存中,以供未来的请求使用。

在 Docker 中运行 Redis

设置 Redis 最简单的方法是通过 Docker。我们将使用 DockerHub 上可用的 Redis 官方镜像来完成此操作(hub.docker.com/_/redis)。在撰写本书时,最新稳定版本是 6.0。基于该镜像运行容器:

docker run -d --name redis -p 6379:6379 redis:6.0

此命令执行以下两个主要操作:

  • -d标志以守护进程方式运行 Redis 容器。

  • -p标志将容器的 6379 端口映射到主机的 6379 端口。6379 是 Redis 服务器暴露的端口。

命令的输出如下:

图 3.24 – 从 DockerHub 拉取 Redis 镜像

图 3.24 – 从 DockerHub 拉取 Redis 镜像

总是检查 Docker 日志以查看事件链:

docker logs –f CONTAINER_ID

日志提供了大量有用的信息,例如默认配置和暴露的服务器端口:

图 3.25 – Redis 服务器日志

图 3.25 – Redis 服务器日志

Redis 容器使用基本的缓存策略。对于生产使用,建议配置驱逐策略。您可以使用redis.conf文件配置策略:

maxmemory-policy allkeys-lru
maxmemory 512mb

此配置为 Redis 分配了 512 MB 的内存,并将驱逐策略设置为最近最少使用LRU)算法,该算法删除最不常使用的缓存项。因此,我们只保留有最高再次读取概率的项。

您可以使用以下命令在容器的运行时传递配置:

docker run -d -v $PWD/conf:/usr/local/etc/redis --name redis -p 6379:6379 redis:6.0

这里,$PWD/conf 是包含 redis.conf 文件的文件夹。

现在,Redis 已启动,我们可以使用它来缓存 API 数据。但首先,让我们通过执行以下命令来安装官方的 Redis Go 驱动程序 (github.com/go-redis/redis):

go get github.com/go-redis/redis/v8

main.go 文件中导入以下包:

import "github.com/go-redis/redis"

现在,在 init() 方法中,使用 redis.NewClient() 初始化 Redis 客户端。此方法接受服务器地址、密码和数据库作为参数。接下来,我们将在 Redis 客户端上调用 Ping() 方法以检查与 Redis 服务器的连接状态:

redisClient := redis.NewClient(&redis.Options{
       Addr:     "localhost:6379",
       Password: "",
       DB:       0,
})
status := redisClient.Ping()
fmt.Println(status)

此代码将在部署后设置与 Redis 服务器的连接:

图 3.26 – 检查与 Redis 服务器的连接

图 3.26 – 检查与 Redis 服务器的连接

如果连接成功,将显示 ping: PONG 消息,如前面的截图所示。

优化 MongoDB 查询

与 Redis 服务器建立连接后,我们可以更新 RecipesHandler 结构体以存储 Redis 客户端的实例,以便处理程序可以与 Redis 交互:

type RecipesHandler struct {
   collection  *mongo.Collection
   ctx         context.Context
   redisClient *redis.Client
}
func NewRecipesHandler(ctx context.Context, collection 
    *mongo.Collection, redisClient *redis.Client) 
     *RecipesHandler {
   return &RecipesHandler{
       collection:  collection,
       ctx:         ctx,
       redisClient: redisClient,
   }
}

确保在 init() 方法中将 Redis 客户端实例传递给 RecipesHandler 实例:

recipesHandler = handlers.NewRecipesHandler(ctx, collection,        	                                            redisClient)

接下来,我们必须更新 ListRecipesHandler 以检查食谱是否已缓存在 Redis 中。如果是,我们返回一个列表。如果不是,我们将从 MongoDB 获取数据并将其缓存在 Redis 中。我们必须对代码进行的新的更改如下:

func (handler *RecipesHandler) ListRecipesHandler(c       *gin.Context) {
   val, err := handler.redisClient.Get("recipes").Result()
   if err == redis.Nil {
       log.Printf("Request to MongoDB")
       cur, err := handler.collection.Find(handler.ctx, 
                                           bson.M{})
       if err != nil {
           c.JSON(http.StatusInternalServerError, 
                  gin.H{"error": err.Error()})
           return
       }
       defer cur.Close(handler.ctx)
       recipes := make([]models.Recipe, 0)
       for cur.Next(handler.ctx) {
           var recipe models.Recipe
           cur.Decode(&recipe)
           recipes = append(recipes, recipe)
       }
       data, _ := json.Marshal(recipes)
       handler.redisClient.Set("recipes", string(data), 0)
       c.JSON(http.StatusOK, recipes)
   } else if err != nil {
       c.JSON(http.StatusInternalServerError, 
              gin.H{"error": err.Error()})
       return
   } else {
       log.Printf("Request to Redis")
       recipes := make([]models.Recipe, 0)
       json.Unmarshal([]byte(val), &recipes)
       c.JSON(http.StatusOK, recipes)
   }
}

值得注意的是,Redis 的值必须是一个字符串,因此我们必须使用 json.Marshal() 方法将 recipes 切片编码成字符串。

要测试新的更改,运行应用程序。然后,使用 Postman 客户端或 cURL 命令在 /recipes 端点发出 GET 请求。切换回你的终端并查看 Gin 日志。你应该在控制台中看到一条消息,对应于从 MongoDB 获取数据的第一次请求:

图 3.27 – 从 MongoDB 获取数据

图 3.27 – 从 MongoDB 获取数据

注意

有关如何使用 Postman 客户端或 cURL 命令的逐步指南,请参阅第一章使用 Gin 入门

如果你发出第二次 HTTP 请求,这次,数据将从 Redis 返回,因为它在第一次请求中被缓存:

图 3.28 – 从 Redis 获取数据

图 3.28 – 从 Redis 获取数据

如我们所见,从内存(Redis)中检索数据比从磁盘(MongoDB)中检索数据快得多。

我们可以通过在容器中运行 Redis CLI 来验证数据是否被缓存在 Redis 中。运行以下命令:

docker ps
docker exec –it CONTAINER_ID bash

这些命令将通过交互式终端连接到 Redis 容器并启动 bash shell。你会注意到你现在正在使用你的终端,就像你身处容器内部一样,如下面的截图所示:

![图 3.29 – 在 Redis 容器内运行交互式会话

![图片 B17115_03_29.jpg]

图 3.29 – 在 Redis 容器内运行交互式会话

现在我们已连接到 Redis 容器,我们可以使用 Redis 命令行:

redis-cli

从那里,我们可以使用 EXISTS 命令来检查 recipes 键是否存在:

EXISTS recipes

此命令将返回 1(如果键存在)或 0(如果键不存在)。在我们的例子中,配方列表已被缓存在 Redis 中:

![图 3.30 – 检查 Redis 中是否存在键

![图片 B17115_03_30.jpg]

图 3.30 – 检查 Redis 中是否存在键

您可以使用 shell 客户端完成很多事情,但您已经了解了基本概念。输入 exit 退出 MongoDB shell,然后再次输入 exit 退出交互式 shell。

对于 GUI 粉丝,您可以使用 Redis Insights (redislabs.com/fr/redis-enterprise/redis-insight/)。它提供了一个直观的界面来探索 Redis 并与其数据交互。类似于 Redis 服务器,您可以使用 Docker 部署 Redis Insights:

docker run -d --name redisinsight --link redis -p 8001:8001 redislabs/redisinsight

此命令将基于 Redis Insight 官方镜像运行容器,并在端口 8001 上公开接口。

使用浏览器导航到 http://localhost:8081。Redis Insights 主页应该会出现。点击 我已经有一个数据库 然后点击 连接到 Redis 数据库 按钮:

![图 3.31 – 配置 Redis 数据库

![图片 B17115_03_31.jpg]

图 3.31 – 配置 Redis 数据库

设置 redis6379 并命名数据库。设置如下:

![图 3.32 – 新的 Redis 设置

![图片 B17115_03_32.jpg]

图 3.32 – 新的 Redis 设置

然后,点击 添加 Redis 数据库本地数据库将被保存;点击它:

![图 3.33 – Redis Insights 数据库

![图片 B17115_03_33.jpg]

图 3.33 – Redis Insights 数据库

您将被重定向到 摘要 页面,其中包含关于 Redis 服务器的真实指标和统计数据:

![图 3.34 – Redis 服务器指标

![图片 B17115_03_34.jpg]

图 3.34 – Redis 服务器指标

如果您点击 浏览,您将看到存储在 Redis 中的所有键的列表。如图所示,recipes 键已被缓存:

![图 3.35 – Redis 键列表

![图片 B17115_03_35.jpg]

图 3.35 – Redis 键列表

现在,您可以使用该界面在 Redis 中探索、操作和可视化数据。

到目前为止,我们构建的 API 运行得很好,对吧?其实不然;想象一下,您向数据库中添加了一个新的配方:

![图 3.36 – 创建新配方

![图片 B17115_03_36.jpg]

图 3.36 – 创建新配方

现在,如果您发出 GET /recipes 请求,将找不到新的配方。这是因为数据是从缓存中返回的:

![图 3.37 – 没有找到配方

![图片 B17115_03_37.jpg]

图 3.37 – 没有找到配方

缓存引入的一个问题是,当数据发生变化时,如何保持缓存更新:

![图 3.38 – 所有后续请求都命中 Redis

![img/B17115_03_38.jpg]

图 3.38 – 所有后续请求都命中 Redis

在这种情况下,有两个分组规则来修复不一致性。首先,我们可以在 Redis 中为菜谱键添加一个生存时间TTL)字段。其次,每次插入或更新新的菜谱时,我们可以在 Redis 中清除菜谱键。

注意

缓存的 TTL 保留时间取决于你的应用程序逻辑。你可能需要保存一个小时或几天,具体取决于数据更新的频率。

我们可以通过更新NewRecipeHandler函数来实现第二个解决方案,使其在插入新菜谱时删除recipes键。在这种情况下,实现方式如下:

func (handler *RecipesHandler) NewRecipeHandler(c *gin.Context) {
   var recipe models.Recipe
   if err := c.ShouldBindJSON(&recipe); err != nil {
       c.JSON(http.StatusBadRequest, 
              gin.H{"error":err.Error()})
       return
   }
   recipe.ID = primitive.NewObjectID()
   recipe.PublishedAt = time.Now()
   _, err := handler.collection.InsertOne(handler.ctx, 
                                          recipe)
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
       gin.H{"error": "Error while inserting 
             a new recipe"})
       return
   }
   log.Println("Remove data from Redis")
   handler.redisClient.Del("recipes")
   c.JSON(http.StatusOK, recipe)
}

重新部署应用程序。现在,如果你发起一个GET /recipes请求,数据将按预期从 MongoDB 返回;然后,它将被缓存在 Redis 中。第二个 GET 请求将返回 Redis 中的数据。然而,现在,如果我们发起一个POST /recipes请求来插入一个新的菜谱,Redis 中的recipes键将被清除,如从 Redis 中删除数据消息所确认的。这意味着下一个GET /recipes请求将从 MongoDB 获取数据:

![图 3.39 – 插入请求时清除缓存

![img/B17115_03_39.jpg]

图 3.39 – 插入请求时清除缓存

现在,新的菜谱将出现在菜谱列表中:

![图 3.40 – 新插入的菜谱

![img/B17115_03_40.jpg]

图 3.40 – 新插入的菜谱

注意

更新UpdateRecipeHandler,以便在/recipes/{id}端点发生 PUT 请求时清除缓存。

虽然缓存为具有大量读取的应用程序提供了巨大的好处,但它可能对执行大量数据库更新的应用程序没有同样的好处,并且可能会减慢写入速度。

性能基准测试

我们可以更进一步,看看 API 在大量请求下的表现。我们可以使用 Apache Benchmark(httpd.apache.org/docs/2.4/programs/ab.html)模拟多个请求。

首先,让我们测试没有缓存层的 API。你可以使用以下命令在/recipes端点上运行总共 2,000 个 GET 请求,有 100 个并发请求:

ab -n 2000 -c 100 -g without-cache.data http://localhost:8080/recipes

完成所有请求可能需要几分钟。一旦完成,你应该看到以下结果:

![图 3.41 – 没有缓存层的 API

![img/B17115_03_41.jpg]

图 3.41 – 没有缓存层的 API

从这个输出中可以得出的重要信息如下:

  • 测试所用时间:这意味着完成 2,000 个请求的总时间。

  • 每个请求的时间:这意味着完成一个请求需要多少毫秒。

接下来,我们将发起相同的请求,但这次是在带有缓存的 API(Redis)上:

ab -n 2000 -c 100 -g with-cache.data http://localhost:8080/recipes

完成对 2,000 个请求的处理应该需要几秒钟:

![图 3.42 – 带有缓存层的 API

![img/B17115_03_42.jpg]

图 3.42 – 带有缓存层的 API

要比较这两个结果,我们可以使用gnuplot实用程序根据without-cache.datawith-cache.data文件绘制图表。但首先,创建一个apache-benchmark.p文件以将数据渲染成图表:

set terminal png
set output "benchmark.png"
set title "Cache benchmark"
set size 1,0.7
set grid y
set xlabel "request"
set ylabel "response time (ms)"
plot "with-cache.data" using 9 smooth sbezier with lines title "with cache", "without-cache.data" using 9 smooth sbezier with lines title "without cache"

这些命令将在同一图表上基于.data文件绘制两个图表,并将输出保存为 PNG 图像。接下来,运行gnuplot命令创建图像:

gnuplot apache-benchmark.p

将创建一个benchmark.png图像,如下所示:

图 3.43 – 带缓存和不带缓存的 API 基准测试

图 3.43 – 带缓存和不带缓存的 API 基准测试

启用缓存机制的 API 响应时间与不带缓存的 API 响应时间相比非常快。

确保使用功能分支将更改推送到 GitHub。然后,创建一个拉取请求以合并到develop分支:

git checkout –b feature/redis_integration
git add .
git commit –m "added redis integration"
git push origin feature/redis_integration

到本章结束时,你的 GitHub 仓库应该看起来像这样:

图 3.44 – 项目的 GitHub 仓库

图 3.44 – 项目的 GitHub 仓库

太好了!现在,你应该能够将 MongoDB 数据库集成到你的 API 架构中,以管理数据持久性。

摘要

在本章中,我们学习了如何构建一个利用 Gin 框架和 Go 驱动程序在 NoSQL 数据库(如 MongoDB)中创建查询和查询的 RESTful API。

我们还探讨了如何通过使用 Redis 缓存它访问的数据来加快 API 的速度。如果你的数据主要是静态的且不经常变化,这绝对是你应用程序的一个很好的补充。最后,我们介绍了如何使用 Apache Benchmark 进行性能基准测试。

我们迄今为止构建的 RESTful API 运行得非常顺畅,并且对公众开放(如果部署在远程服务器上)。如果你不验证 API,那么任何人都可以访问任何端点,这可能会非常不理想,因为用户可能会损坏你的数据。更糟糕的是,你可能会将数据库中的敏感信息暴露给整个互联网。这就是为什么在下一章中,我们将介绍如何通过身份验证来保护 API,例如使用 JWT。

问题

  1. 当发生DELETE请求时,实现一个删除食谱操作。

  2. 使用FindOne操作实现一个 GET /recipes/{id}端点。

  3. JSON 文档在 MongoDB 中是如何存储的?

  4. Redis 中的 LRU 驱逐策略是如何工作的?

进一步阅读

  • 《MongoDB 基础》,作者:Amit Phaltankar,Juned Ahsan,Michael Harrison 和 Liviu Nedov,Packt 出版社

  • 《学习 MongoDB 4.x》,作者:Doug Bierer,Packt 出版社

  • 《使用 Go 的实战 RESTful Web 服务 – 第二版》,作者:Naren Yellavula,Packt 出版社

第四章:构建 API 身份验证

本章致力于介绍在构建公共表示状态转移REST应用程序编程接口API)时需要遵循的最佳实践和建议。它探讨了如何编写一个身份验证中间件来保护 API 端点的访问,以及如何通过超文本传输协议安全HTTPS)来提供服务。

本章,我们将重点关注以下主要主题:

  • 探索身份验证

  • 介绍JavaScript 对象表示法JSONWeb 令牌JWT

  • 持久化客户端会话和 cookie

  • 使用 Auth0 进行认证

  • 构建 HTTPS 服务器

到本章结束时,你将能够构建一个具有私有和公开端点的 RESTful API。

技术要求

要遵循本章的说明,你需要以下内容:

  • 完全理解上一章的内容——本章是上一章的后续,它将使用相同的源代码。因此,一些代码片段将不会进行解释,以避免重复。

  • 对 API 认证概念和 HTTPS 协议有基本了解。

本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter04

探索身份验证

在上一章中,我们构建的 API 公开了多个端点。目前,这些端点是公开的,不需要任何认证。在现实世界的场景中,你需要保护这些端点。

以下图表展示了本章结束时需要保护的所有端点:

图 4.1 – 保护 RESTful API 端点

图 4.1 – 保护 RESTful API 端点

列出菜谱不需要认证,而负责添加更新删除菜谱的端点则需要认证。

可以使用多种方法来保护前面的端点——以下是我们可能使用的一些方法:API 密钥、基本认证、客户端会话、OpenID Connect、开放授权OAuth)2.0 等。最基本的认证机制是使用 API 密钥。

使用 API 密钥

在这种方法中,客户端提供一个秘密,称为 HTTP 请求中的X-API-KEY头;如果密钥错误或请求头中没有找到密钥,则会抛出一个未经授权的错误(401),如下面的代码片段所示(为了简洁,完整的代码已被裁剪):

func (handler *RecipesHandler) NewRecipeHandler(
             c *gin.Context) {
   if c.GetHeader("X-API-KEY") != os.Getenv("X_API_KEY") {
       c.JSON(http.StatusUnauthorized, gin.H{
          "error": "API key not provided or invalid"})
       return
   }
}

在运行 MongoDB 和 Redis 容器之后运行应用程序,但这次需要设置以下X-API-KEY环境变量:

X_API_KEY=eUbP9shywUygMx7u  MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run *.go

注意

你可以使用以下命令使用 OpenSSL 生成一个随机的秘密字符串:openssl rand -base64 16

如果你尝试添加一个新的菜谱,将会返回一个401错误消息,如下面的屏幕截图所示:

![图 4.2 – 新菜谱图片 4.2

图 4.2 – 新食谱

然而,如果在 POST 请求中包含有效的 X-API-KEY 头部,食谱将被插入,如下面的截图所示:

![图 4.3 – POST 请求中的 X-API-KEY 头部图片 4.3

图 4.3 – POST 请求中的 X-API-KEY 头部

如果你不喜欢 Postman 作为 HTTP/s 客户端,你可以在你的终端上执行以下 cURL 命令:

curl --location --request POST 'http://localhost:8080/recipes' \
--header 'X-API-KEY: eUbP9shywUygMx7u' \
--header 'Content-Type: application/json' \
--data-raw '{
   "name": "Homemade Pizza",
   "ingredients": ["..."],
   "instructions": ["..."],
   "tags": ["dinner", "fastfood"]
}'

目前,只有 POST /recipes 请求是受保护的。为了避免在其他 HTTP 端点中重复相同的代码片段,通过编写以下代码创建一个身份验证中间件:

func AuthMiddleware() gin.HandlerFunc {
   return func(c *gin.Context) {
       if c.GetHeader("X-API-KEY") != 
               os.Getenv("X_API_KEY") {
           c.AbortWithStatus(401)
       }
       c.Next()
   }
}

在路由定义中,使用身份验证中间件。最后,将端点重新组合成一个组,如下所示:

authorized := router.Group("/")
authorized.Use(AuthMiddleware()){
       authorized.POST("/recipes", 
                       recipesHandler.NewRecipeHandler)
       authorized.GET("/recipes", 
                      recipesHandler.ListRecipesHandler)
       authorized.PUT("/recipes/:id", 
                      recipesHandler.UpdateRecipeHandler)
       authorized.DELETE("/recipes/:id", 
                        recipesHandler.DeleteRecipeHandler)
}

在这个阶段,重新运行应用程序。如果你发出 GET /recipes 请求,将返回 401 错误,如下面的截图所示。这是正常的,因为列表食谱的路由处理程序位于身份验证中间件之后:

![图 4.4 – GET /recipes 需要 API 密钥图片 4.4

图 4.4 – GET /recipes 需要 API 密钥

你希望 GET /recipes 请求是公开的,因此将端点注册在组路由之外,如下所示:

func main() {
   router := gin.Default()
   router.GET("/recipes", 
              recipesHandler.ListRecipesHandler)
   authorized := router.Group("/")
   authorized.Use(AuthMiddleware())
   {
       authorized.POST("/recipes", 
                       recipesHandler.NewRecipeHandler)
       authorized.PUT("/recipes/:id", 
                      recipesHandler.UpdateRecipeHandler)
       authorized.DELETE("/recipes/:id",   
                        recipesHandler.DeleteRecipeHandler)
       authorized.GET("/recipes/:id", 
                      recipesHandler.GetOneRecipeHandler)
   }

  router.Run()
}

如果你测试它,这次端点将返回食谱列表,如下面的截图所示:

![图 4.5 – 菜单列表图片 4.5

图 4.5 – 菜单列表

API 密钥很简单;然而,任何向 API 发送请求的人都会传输他们的密钥,在理论上,当不使用加密时,密钥很容易通过中间人攻击(MITM)被捕获。这就是为什么在下一节中,我们将介绍一种称为 JWT 的更安全的身份验证机制。

备注

MITM 指的是攻击者在两个当事人之间的对话中定位自己,以窃取他们的凭证的情况。更多详情,请查看以下链接:snyk.io/learn/man-in-the-middle-attack/.

介绍 JWT

根据 请求评论 (RFC) 7519 (tools.ietf.org/html/rfc7519):

"JSON Web Token (JWT) 是一个开放标准,它定义了一种紧凑且自包含的方式,用于以 JSON 对象的形式在各方之间安全地传输信息。由于它是数字签名的,因此该信息可以验证并受到信任。JWT 可以使用密钥或公钥/私钥对进行签名。"

JWT 令牌由三个部分组成,由点分隔,如下面的截图所示:

![图 4.6 – JWT 的组成部分图片 4.6

图 4.6 – JWT 的组成部分

头部指示用于生成签名的算法。载荷包含有关用户的信息,以及令牌过期日期。最后,签名是使用密钥对头部和载荷部分进行散列的结果。

现在我们已经了解了 JWT 的工作原理,让我们将其集成到我们的 API 中。要开始,使用以下命令安装 JWT Go 实现:

go get github.com/dgrijalva/jwt-go

该包将自动添加到go.mod文件中,如下所示:

module github.com/mlabouardy/recipes-api
go 1.15
require (
   github.com/dgrijalva/jwt-go v3.2.0+incompatible 
   // indirect
   github.com/gin-gonic/gin v1.6.3
   github.com/go-redis/redis v6.15.9+incompatible
   github.com/go-redis/redis/v8 v8.4.10
   go.mongodb.org/mongo-driver v1.4.5
   golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb
)

在动手之前,让我解释一下 JWT 认证将如何实现。基本上,客户端需要使用用户名和密码进行登录。如果这些凭证有效,将生成 JWT 令牌并返回。客户端将在未来的请求中通过包含Authorization头来使用该令牌。如果向 API 发出请求,将通过将令牌的签名与使用密钥生成的签名进行比较来验证令牌。如果签名不匹配,则 JWT 被视为无效,并返回401错误。

下面的序列图展示了客户端和 API 之间的通信:

图 4.7 – 序列图

图 4.7 – 序列图

也就是说,在handlers文件夹下创建一个auth.go文件。这个文件将暴露处理认证工作流程的函数。以下是实现此功能的代码(为了简洁,已裁剪完整代码):

package handlers
import (
   "net/http"
   "os"
   "time"
   "github.com/dgrijalva/jwt-go"
)
type AuthHandler struct{}
type Claims struct {
   Username string `json:"username"`
   jwt.StandardClaims
}
type JWTOutput struct {
   Token   string    `json:"token"`
   Expires time.Time `json:"expires"`
}
func (handler *AuthHandler) SignInHandler(c *gin.Context) {}

接下来,你需要定义一个用户凭证的实体模型。在models文件夹中,创建一个具有用户名和密码属性的user.go结构体,如下所示:

package models
type User struct {
   Password string `json:"password"`
   Username string `json:"username"`
}

模型定义完成后,我们可以继续实现认证处理器。

登录 HTTP 处理器

SignInHandler将请求体编码为User结构体,并验证凭证是否正确。然后,它将颁发一个有效期为 10 分钟的 JWT 令牌。JWT 的签名是头部和有效载荷的 Base64 表示形式以及一个密钥的组合输出(注意JWT_SECRET环境变量的使用)。然后将组合传递给HS256哈希算法。值得一提的是,你必须为了安全起见,将凭证从源代码中移除。实现方式如下:

func (handler *AuthHandler) SignInHandler(c *gin.Context) {
   var user models.User
   if err := c.ShouldBindJSON(&user); err != nil {
       c.JSON(http.StatusBadRequest, gin.H{"error": 
           err.Error()})
       return
   }
   if user.Username != "admin" || user.Password != 
          "password" {
       c.JSON(http.StatusUnauthorized, gin.H{"error": 
          "Invalid username or password"})
       return
   }
   expirationTime := time.Now().Add(10 * time.Minute)
   claims := &Claims{
       Username: user.Username,
       StandardClaims: jwt.StandardClaims{
           ExpiresAt: expirationTime.Unix(),
       },
   }
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, 
                              claims)
   tokenString, err := token.SignedString([]byte(
                       os.Getenv("JWT_SECRET")))
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
              gin.H{"error": err.Error()})
       return
   }
   jwtOutput := JWTOutput{
       Token:   tokenString,
       Expires: expirationTime,
   }
   c.JSON(http.StatusOK, jwtOutput)
}

注意

关于哈希算法的工作原理的更多信息,请查看官方 RFC:tools.ietf.org/html/rfc7518

创建了SignInHandler处理器后,让我们通过更新main.go文件在POST /signin端点上注册此处理器,如下所示(为了简洁,已裁剪代码):

package main
import (
   ...
)
var authHandler *handlers.AuthHandler
var recipesHandler *handlers.RecipesHandler
func init() {
   ...
   recipesHandler = handlers.NewRecipesHandler(ctx, 
      collection, redisClient)
   authHandler = &handlers.AuthHandler{}
}
func main() {
   router := gin.Default()
   router.GET("/recipes", 
              recipesHandler.ListRecipesHandler)
   router.POST("/signin", authHandler.SignInHandler)
   ...
}

接下来,我们更新handler/auth.go中的认证中间件,以检查Authorization头而不是X-API-KEY属性。然后将头传递给ParseWithClaims方法。它使用Authorization头部的头部和有效载荷以及密钥生成签名。然后,它验证签名是否与 JWT 上的签名匹配。如果不匹配,则 JWT 被视为无效,并返回401状态码。Go 实现如下:

func (handler *AuthHandler) AuthMiddleware() gin.HandlerFunc {
   return func(c *gin.Context) {
       tokenValue := c.GetHeader("Authorization")
       claims := &Claims{}
       tkn, err := jwt.ParseWithClaims(tokenValue, claims, 
              func(token *jwt.Token) (interface{}, error) {
           return []byte(os.Getenv("JWT_SECRET")), nil
       })
       if err != nil {
           c.AbortWithStatus(http.StatusUnauthorized)
       }
       if tkn == nil ||!tkn.Valid {
           c.AbortWithStatus(http.StatusUnauthorized)
       }
       c.Next()
   }
}

使用以下命令重新运行应用程序:

JWT_SECRET=eUbP9shywUygMx7u MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run *.go

服务器日志如下所示:

图 4.8 – 应用程序日志

图 4.8 – 应用程序日志

现在,如果你尝试插入一个新的食谱,将会返回一个 401 错误,如下所示:

图 4.9 – 未授权端点

图 4.9 – 未授权端点

你需要首先使用 admin/password 凭证通过在 /signin 端点上执行 POST 请求来登录。一旦成功,端点将返回一个看起来像这样的令牌:

图 4.10 – 登录端点

图 4.10 – 登录端点

令牌由三个部分组成,由点分隔。你可以通过访问 jwt.io/ 来解码令牌,返回以下输出(你的结果可能不同):

图 4.11 – 解码 JWT 令牌

图 4.11 – 解码 JWT 令牌

注意

头部和有效负载部分是 Base64 编码的,但你可以使用 base64 命令来解码它们的值。

现在,对于后续请求,你需要将令牌包含在 Authorization 头部中,以便能够访问受保护的端点,例如发布新食谱,如下面的截图所示:

图 4.12 – 发布新食谱

图 4.12 – 发布新食谱

到目前为止,一切都很顺利——然而,10 分钟后,令牌将过期。例如,如果你尝试发布一个新的食谱,即使你包含了 Authorization 头部,也会抛出一个 401 未授权消息,正如我们可以在下面的截图中所看到的:

图 4.13 – 过期 JWT

图 4.13 – 过期 JWT

因此,让我们看看如何在令牌过期后如何刷新此令牌。

刷新 JWT

你可以将过期时间增加,使 JWT 令牌持续更长时间;然而,这并不是一个永久的解决方案。你可以做的另一件事是暴露一个端点,允许用户刷新令牌,这样客户端应用程序就可以刷新令牌,而无需再次要求用户输入用户名和密码。函数处理程序如下代码片段所示——它接受之前的令牌并返回一个新的令牌,具有更新的过期时间:

func (handler *AuthHandler) RefreshHandler(c *gin.Context) {
   tokenValue := c.GetHeader("Authorization")
   claims := &Claims{}
   tkn, err := jwt.ParseWithClaims(tokenValue, claims, 
          func(token *jwt.Token) (interface{}, error) {
       return []byte(os.Getenv("JWT_SECRET")), nil
   })
   if err != nil {
       c.JSON(http.StatusUnauthorized, gin.H{"error": 
          err.Error()})
       return
   }
   if tkn == nil ||!tkn.Valid {
       c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid	                                              token"})
       return
   }
   if time.Unix(claims.ExpiresAt, 0).Sub(time.Now()) > 
            30*time.Second {
       c.JSON(http.StatusBadRequest, gin.H{"error": 
            "Token is not expired yet"})
       return
   }
   expirationTime := time.Now().Add(5 * time.Minute)
   claims.ExpiresAt = expirationTime.Unix()
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, 
                              claims)
   tokenString, err := token.SignedString(os.Getenv(
                       "JWT_SECRET"))
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
           gin.H{"error": err.Error()})
       return
   }
   jwtOutput := JWTOutput{
       Token:   tokenString,
       Expires: expirationTime,
   }
   c.JSON(http.StatusOK, jwtOutput)
}

注意

在一个网络应用程序中,/refresh 端点可以用来在后台刷新 JWT 令牌,而无需每几分钟要求用户登录。

使用以下代码在 POST /refresh 端点上注册 RefreshHandler 处理程序:

router.POST("/refresh", authHandler.RefreshHandler)

如果你重新运行应用程序,/refresh 端点将会暴露,如下面的截图所示:

图 4.14 – /refresh 端点

图 4.14 – /refresh 端点

你现在可以在 /refresh 端点上发出 POST 请求,并将生成并返回一个新的令牌。

太棒了——你现在有一个工作的认证工作流程!然而,用户凭证仍然硬编码在应用程序代码源中。你可以通过将它们存储在 MongoDB 服务器上来改进这一点。这里提供了一个更新的序列图:

图 4.15 – 在 MongoDB 中存储凭证

图 4.15 – 在 MongoDB 中存储凭证

为了能够与 MongoDB 服务器交互,您需要在auth.go文件中将 MongoDB 集合添加到AuthHandler结构体中,如下面的代码片段所示。然后,您可以对users集合执行FindOne操作,以验证给定的凭证是否存在:

type AuthHandler struct {
   collection *mongo.Collection
   ctx        context.Context
}
func NewAuthHandler(ctx context.Context, collection 
          *mongo.Collection) *AuthHandler {
   return &AuthHandler{
       collection: collection,
       ctx:        ctx,
   }
}

接下来,更新SignInHandler方法,通过将它们与数据库中的条目进行比较来验证用户凭证是否有效。以下是实现方式:

func (handler *AuthHandler) SignInHandler(c *gin.Context) {

   h := sha256.New()
   cur := handler.collection.FindOne(handler.ctx, bson.M{
       "username": user.Username,
       "password": string(h.Sum([]byte(user.Password))),
   })
   if cur.Err() != nil {
       c.JSON(http.StatusUnauthorized, gin.H{"error": 
           "Invalid username or password"})
       return
   }
   ...
}

init()方法中,您需要设置到users集合的连接,然后将集合实例传递给AuthHandler实例,如下所示:

collectionUsers := client.Database(os.Getenv(
                   "MONGO_DATABASE")).Collection("users")
authHandler = handlers.NewAuthHandler(ctx, collectionUsers)

确保保存main.go中的更改,然后 API 就准备好了!

对密码进行哈希处理和加盐

在运行应用程序之前,我们需要使用一些用户初始化users集合。在main.go文件中创建一个新的项目,内容如下:

func main() {
   users := map[string]string{
       "admin":      "fCRmh4Q2J7Rseqkz",
       "packt":      "RE4zfHB35VPtTkbT",
       "mlabouardy": "L3nSFRcZzNQ67bcc",
   }
   ctx := context.Background()
   client, err := mongo.Connect(ctx, 
       options.Client().ApplyURI(os.Getenv("MONGO_URI")))
   if err = client.Ping(context.TODO(), 
          readpref.Primary()); err != nil {
       log.Fatal(err)
   }
   collection := client.Database(os.Getenv(
       "MONGO_DATABASE")).Collection("users")
   h := sha256.New()
   for username, password := range users {
       collection.InsertOne(ctx, bson.M{
           "username": username,
           "password": string(h.Sum([]byte(password))),
       })
   }
}

上述代码将三个用户(adminpacktmlabouardy)插入到users集合中。出于安全目的,在将密码保存到 MongoDB 之前,使用SHA256算法对密码进行哈希处理和加盐。该算法为密码生成一个唯一的 256 位签名,该签名可以解密回原始密码。这样,敏感信息可以保持安全。

注意

不建议在数据库中存储明文密码。通过对用户密码进行哈希处理和加盐,我们确保黑客无法登录,因为被盗的数据不会包含凭证。

您可以使用MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run main.go命令运行代码。使用 MongoDB Compass 检查用户是否已插入(有关如何使用 MongoDB Compass 的逐步指南,请返回到第三章**,使用 MongoDB 管理数据持久性),如下面的截图所示:

图 4.16 – 用户集合

图 4.16 – 用户集合

如您可能注意到的,密码已经进行了哈希处理和加盐。当用户被插入到 MongoDB 中时,我们可以通过发出以下负载的POST请求来测试登录端点:

图 4.17 – 登录端点

图 4.17 – 登录端点

注意

我们可以通过在客户端设置 JWT 值作为 cookie 来改进 JWT 实现。这样,cookie 就会随请求一起发送。

您可以进一步创建一个注册端点来创建新用户并将其保存到 MongoDB 数据库中。现在您应该熟悉如何使用 JWT 实现身份验证机制。

客户端会话和 cookie 的持久化

到目前为止,你必须在每个请求中都包含 Authorization 头。更好的解决方案是生成一个 会话 cookie。会话 cookie 允许用户在应用程序内被识别,而无需每次都进行身份验证。没有 cookie,每次你发出 API 请求时,服务器都会把你当作一个全新的访客。

要生成会话 cookie,请按照以下步骤操作:

  1. 使用以下命令安装 Gin 中间件 以进行会话管理:

    go get github.com/gin-contrib/sessions
    
  2. 使用以下代码将 Redis 配置为用户会话的存储:

    store, _ := redisStore.NewStore(10, "tcp", 
          "localhost:6379", "", []byte("secret"))
    router.Use(sessions.Sessions("recipes_api", store))
    

    注意

    你可以不硬编码 Redis 统一资源标识符URI),而是使用环境变量。这样,你可以将配置从 API 源代码中分离出来。

  3. 然后,更新 SignInHandler 以生成一个具有唯一 ID 的会话,如下面的代码片段所示。会话在用户登录后开始,并在之后某个时间点过期。登录用户的会话信息将存储在 Redis 缓存中:

    func (handler *AuthHandler) SignInHandler(c *gin.Context) {
       var user models.User
       if err := c.ShouldBindJSON(&user); err != nil {
           c.JSON(http.StatusBadRequest, gin.H{"error": 
               err.Error()})
           return
       }
       h := sha256.New()
       cur := handler.collection.FindOne(handler.ctx, bson.M{
           "username": user.Username,
           "password": string(h.Sum([]byte(user.Password))),
       })
       if cur.Err() != nil {
           c.JSON(http.StatusUnauthorized, gin.H{"error": 
               "Invalid username or password"})
           return
       }
       sessionToken := xid.New().String()
       session := sessions.Default(c)
       session.Set("username", user.Username)
       session.Set("token", sessionToken)
       session.Save()
       c.JSON(http.StatusOK, gin.H{"message":                                "User signed in"})
    }
    
  4. 接下来,更新 AuthMiddleware 以从请求 cookie 中获取令牌。如果 cookie 未设置,我们通过返回 http.StatusForbidden 响应来返回 403 代码(Forbidden),如下面的代码片段所示:

    func (handler *AuthHandler) AuthMiddleware() gin.	  	      HandlerFunc {
       return func(c *gin.Context) {
           session := sessions.Default(c)
           sessionToken := session.Get("token")
           if sessionToken == nil {
               c.JSON(http.StatusForbidden, gin.H{
                   "message": "Not logged",
               })
               c.Abort()
           }
           c.Next()
       }
    }
    
  5. 在端口 8080 上启动服务器,并在 /signin 端点上使用有效的用户名和密码发出 POST 请求。应该会生成一个 cookie,如下面的截图所示:

图 4.18 – 会话 cookie

图 4.18 – 会话 cookie

现在,会话将跨所有其他 API 路由持久化。因此,你可以与 API 端点交互,而无需包含任何授权头,如下面的截图所示:

图 4.19 – 基于会话的认证

图 4.19 – 基于会话的认证

之前的例子使用了 Postman 客户端,但如果你是 cURL 粉丝,请按照以下步骤操作:

  1. 使用以下命令将生成的 cookie 存储到文本文件中:

    curl -c cookies.txt -X POST http://localhost:8080/signin -d '{"username":"admin", "password":"fCRmh4Q2J7Rseqkz"}'
    
  2. 然后,在未来的请求中注入 cookies.txt 文件,如下所示:

    curl -b cookies.txt -X POST http://localhost:8080/recipes -d '{"name":"Homemade Pizza", "steps":[], "instructions":[]}'
    

    注意

    你可以实现一个刷新路由来生成一个新的会话 cookie,并更新其过期时间。

    API 生成的所有会话都将持久保存在 Redis 中。你可以使用 Redis Insight 用户界面UI)(托管在 Docker 容器中)来浏览保存的会话,如下面的截图所示:

    图 4.20 – Redis 中存储的会话列表

    图 4.20 – Redis 中存储的会话列表

  3. 要注销,你可以实现 SignOutHandler 处理器来使用以下命令清除会话 cookie:

    func (handler *AuthHandler) SignOutHandler(c       *gin.Context) {
       session := sessions.Default(c)
       session.Clear()
       session.Save()
       c.JSON(http.StatusOK, gin.H{"message":                                "Signed out..."})
    }
    
  4. 记得在 main.go 文件上注册处理器,如下所示:

    router.POST("/signout", authHandler.SignOutHandler)
    
  5. 运行应用程序,应该会暴露一个注销端点,如下面的截图所示:图 4.21 – 注销处理器

    图 4.21 – 注销处理器

  6. 现在,使用 Postman 客户端执行一个POST请求来测试它,如下所示:图 4.22 – 注销

    图 4.22 – 注销

    会话 cookie 将被删除,如果你现在尝试添加一个新的食谱,将会返回一个403错误,如下截图所示:

    图 4.23 – 添加新食谱

    图 4.23 – 添加新食谱

  7. 确保通过创建一个新的功能分支将更改提交到 GitHub。然后,将分支合并到开发模式,如下所示:

git add .
git commit -m "session based authentication"
git checkout -b feature/session
git push origin feature/session

以下截图显示了包含 JWT 和 cookie 认证功能的拉取请求:

图 4.24 – GitHub 上的拉取请求

图 4.24 – GitHub 上的拉取请求

太棒了!API 端点现在已安全,可以公开提供服务。

使用 Auth0 进行认证

到目前为止,认证机制已内置在应用程序中。维护这样一个系统可能会在长期成为瓶颈,这就是为什么你可能需要考虑使用外部服务,如Auth0。这是一个一站式认证解决方案,它为你提供了强大的报告和分析功能,以及一个基于角色的访问控制RBAC)系统。

要开始,请按照以下步骤操作:

  1. 创建一个免费账户(auth0.com/signup)。创建后,在您所在的区域设置一个租户域,如下截图所示:图 4.25 – Auth0 租户域

    图 4.25 – Auth0 租户域

  2. 然后,创建一个新的 API,命名为Recipes API。将标识符设置为api.recipes.io,并将签名算法设置为RS256,如下截图所示:图 4.26 – Auth0 新 API

    图 4.26 – Auth0 新 API

  3. 一旦创建了 API,你需要将 Auth0 服务集成到 API 中。下载以下 Go 包:

    go get -v gopkg.in/square/go-jose.v2
    go get -v github.com/auth0-community/go-auth0
    
  4. 接下来,更新AuthMiddleware,如下代码片段所示。中间件将检查是否存在访问令牌以及它是否有效。如果通过检查,请求将继续。如果不通过,将返回一个401 授权错误:

    func (handler *AuthHandler) AuthMiddleware() gin.HandlerFunc {
       return func(c *gin.Context) {
           var auth0Domain = "https://" + os.Getenv(
               "AUTH0_DOMAIN") + "/"
           client := auth0.NewJWKClient(auth0.JWKClientOptions{
               URI: auth0Domain + ".well-known/jwks.json"}, 
               nil)
           configuration := auth0.NewConfiguration(client, 
               []string{os.Getenv("AUTH0_API_IDENTIFIER")}, 
               auth0Domain, jose.RS256)
           validator := auth0.NewValidator(configuration, 	                                       nil)
           _, err := validator.ValidateRequest(c.Request)
           if err != nil {
               c.JSON(http.StatusUnauthorized,  	 	          	                  gin.H{"message": "Invalid token"})
               c.Abort()
               return
           }
           c.Next()
       }
    }
    

    Auth0 使用 RS256 算法签名访问令牌。验证过程使用位于AUTH0_DOMAIN/.well-known/jwks.json的公钥(以JSON Web Key SetJWKS)格式),来验证给定的令牌。

  5. 使用AUTH0_DOMAINAUTH0_API_IDENTIFIER运行应用程序,如下代码片段所示。确保用你在 Auth0 仪表板中复制的值替换这些变量:

    AUTH0_DOMAIN=DOMAIN.eu.auth0.com  AUTH0_API_IDENTIFIER="https://api.recipes.io" MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin" MONGO_DATABASE=demo go run *.go
    

    现在,如果你尝试向你的 API 发送请求而不发送访问令牌,你会看到以下消息:

    图 4.27 – 未授权访问

    图 4.27 – 未授权访问

  6. 现在,更新您的 API 请求以包含访问令牌,如图所示:图 4.29 – 授权访问

    图 4.28 – 使用 cURL 生成访问令牌

  7. 在您的终端会话中执行以下命令以生成一个可以用来与您的后端 API 通信的访问令牌:

    curl --request POST \
      --url https://recipesapi-packt.eu.auth0.com/oauth/token \
      --data '{"client_id":"MyFRmUZS","client_secret":"7fArWGkSva","audience":"https://api.recipes.io","grant_type":"client_credentials"}'
    

    将生成一个访问令牌,如下所示(您应该有一个不同的值):

    {
     "access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI 6IkpXVCIsImtpZCI6IkZ5T19SN2dScDdPakp3RmJQRVB3dCDz",
       "expires_in":86400,
       "token_type":"Bearer"
    }
    
  8. 注意

    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_4.29_B17115.jpg)

    图 4.29 – 授权访问

  9. 这也可以通过命令行使用 cURL 直接测试。只需将以下代码片段中显示的ACCESS_TOKEN值替换为您的测试令牌,然后将其粘贴到您的终端中:

    curl --request POST \
      --url http://localhost:8080/recipes \
      --header 'Authorization: Bearer ACCESS_TOKEN'\
      --data '{"name":"Pizza "}'
    

太棒了!你刚刚使用 Go 语言和 Gin 框架开发了一个安全的 API。

要生成访问令牌,请返回到Auth0仪表板,点击APIs,然后选择Recipes API。从那里,点击测试选项卡并复制以下截图中的 cURL 命令:图 4.28 – 使用 cURL 生成访问令牌使用 HTTPS 协议导航到转发 URL。URL 旁边应该显示连接安全消息,如图所示:图 4.32 – 通过 HTTPS 提供服务 1.  使用ngrok解决方案以支持 HTTP 和 HTTPS 的公共统一资源定位符(URL)来提供我们的本地 Web API。

在高级章节中,我们将探讨如何在云服务提供商如**亚马逊网络服务**(**AWS**)上免费购买域名并设置 HTTPS。
  1. ngrok.com/download的官方 Ngrok 页面下载基于您的操作系统OS)的 ZIP 文件。在这本书中,我们将使用版本 2.3.35。下载后,使用以下命令在终端中解压 Ngrok:

    unzip ngrok-stable-darwin-amd64.zip
    cp ngrok /usr/local/bin/
    chmod +x /usr/local/bin/ngrok
    
  2. 通过执行以下命令来验证是否正确安装:

    ngrok version
    

    应该输出以下消息:

    图 4.30 – Ngrok 版本

    图 4.30 – Ngrok 版本

  3. 通过运行以下命令配置 Ngrok 以监听并转发到 8080 端口,这是 RESTful API 暴露的端口:

    ngrok http 8080
    

    将生成一个公共 URL,可以用作代理,从互联网上与 API 交互,如图所示:

    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_4.30_B17115.jpg)

    要设置此配置,请按照以下步骤操作:

    图 4.31 – Ngrok 转发

  4. 构建 HTTPS 服务器

图 4.32 – 通过 HTTPS 提供服务

![图 4.31 – Ngrok 转发您现在可以从另一台机器或设备访问 API,或者与他人共享。在下一节中,我们将介绍如何创建自己的安全套接字层SSL)证书来保护本地运行的域名。## 自签名证书 SSL 证书是网站从 HTTP 和 HTTPS 转移所使用的。该证书使用 SSL/传输层安全性TLS)加密来保护用户数据安全,验证网站所有权,防止攻击者创建网站的假版本,并赢得用户信任。要创建自签名证书,请按以下步骤操作:1. 创建一个存储证书的目录,并使用 OpenSSL 命令行生成公钥和私钥,如下所示: go mkdir certs openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout certs/localhost.key -out certs/localhost.crt 1. 您需要填写一个简单的问卷调查——确保将完全限定主机名设置为 localhost,如图所示:![Figure 4.33 – 生成自签名证书 图片

Figure 4.33 – 生成自签名证书

在这样做的时候,将生成两个文件,如下所示:

`localhost.crt`:自签名证书

`localhost.key`:私钥

注意

在高级章节中,我们将介绍如何使用 Let's Encrypt ([`letsencrypt.org`](https://letsencrypt.org)) 免费获取生产环境的有效证书。
  1. main.go 更新为使用自签名证书在 HTTPS 上运行服务器,如下所示(注意我们现在使用的是端口 443,这是 HTTPS 的默认端口):

    router.RunTLS(":443", "certs/localhost.crt", "certs/localhost.key")
    
  2. 运行应用程序,日志将确认 API 通过 HTTPS 提供服务,如图所示:![Figure 4.34 – 监听和提供 HTTPS 图片

    Figure 4.34 – 监听和提供 HTTPS

  3. 打开浏览器,然后导航到 localhost/recipes。URL 框的左侧将显示一个安全网站图标,如图下截图所示:![Figure 4.35 – 连接到本地的加密连接 图片

    curl --cacert certs/localhost.crt https://localhost/recipes
    

    或者,您可以使用 –k 标志来跳过 SSL 验证,如下所示(如果与外部网站交互则不建议使用):

    curl -k https://localhost/recipes
    
  4. 对于开发,只需继续使用 localhost,或从自定义域名访问 API。您可以通过在您的 /etc/hosts 文件中添加以下条目在本地创建一个域名别名:

     127.0.0.1 api.recipes.io
    
  5. 保存更改后,您可以通过在 api.recipes.io 上执行 ping 命令来测试它,如下所示:

![Figure 4.37 – ping 输出图片

Figure 4.37 – ping 输出

域名可达,指向 127.0.0.1。您现在可以通过导航到 api.recipes.io:8080 来访问 RESTful API,如图下截图所示:

![Figure 4.38 – 别名域名图片

Figure 4.38 – 别名域名

太好了!现在您将能够通过身份验证来保护您的 API 端点,并通过自定义域名本地提供服务。

在我们结束本章之前,您需要更新 API 文档以包括我们在本章中实现的身份验证端点,如下所示:

  1. 首先,更新一般元数据以包括 API 请求中的 Authorization 标头,如下所示:

    // Recipes API
    //
    // This is a sample recipes API. You can find out more 
       about the API at 
       https://github.com/PacktPublishing/Building-
       Distributed-Applications-in-Gin.
    //
    //  Schemes: http
    //  Host: api.recipes.io:8080
    //  BasePath: /
    //  Version: 1.0.0
    //  Contact: Mohamed Labouardy 
    //  <mohamed@labouardy.com> https://labouardy.com
    //  SecurityDefinitions:
    //  api_key:
    //    type: apiKey
    //    name: Authorization
    //    in: header
    //
    //  Consumes:
    //  - application/json
    //
    //  Produces:
    //  - application/json
    // swagger:meta
    package main
    
  2. 然后,在SignInHandler处理器上方编写一个swagger:operation注解,如下所示:

    // swagger:operation POST /signin auth signIn
    // Login with username and password
    // ---
    // produces:
    // - application/json
    // responses:
    //     '200':
    //         description: Successful operation
    //     '401':
    //         description: Invalid credentials
    func (handler *AuthHandler) SignInHandler(c *gin.Context) {}
    
  3. RefreshHandler处理器上方编写一个swagger:operation注解,如下所示:

    // swagger:operation POST /refresh auth refresh
    // Get new token in exchange for an old one
    // ---
    // produces:
    // - application/json
    // responses:
    //     '200':
    //         description: Successful operation
    //     '400':
    //         description: Token is new and doesn't need 
    //                      a refresh
    //     '401':
    //         description: Invalid credentials
    func (handler *AuthHandler) RefreshHandler(c *gin.Context) 
    {}
    
  4. 该操作期望包含以下属性的请求体:

    // API user credentials
    // It is used to sign in
    //
    // swagger:model user
    type User struct {
      // User's password
      //
      // required: true
      Password string `json:"password"`
      // User's login
      //
      // required: true
      Username string `json:"username"`
    }
    
  5. 生成 OpenAPI 规范,然后通过执行以下命令使用 Swagger UI 提供 JSON 文件:

    /refresh and /signin) should be added to the list of operations, as shown in the following screenshot:![Figure 4.39 – Authentication operations    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_4.39_B17115.jpg)Figure 4.39 – Authentication operations
    
  6. 现在,点击signin端点,你将能够直接从 Swagger UI 中填写用户名和密码属性,如下面的截图所示:![Figure 4.40 – Sign-in credentials Figure 4.40 – Sign-in credentials

    图 4.40 – 登录凭证

  7. 接下来,点击Authorization头以与需要授权的端点交互,如下面的截图所示:

![Figure 4.41 – Authorization header

![img/Figure_4.41_B17115.jpg]

图 4.41 – 授权头

现在,你已经能够构建一个安全的 Gin RESTful API 并通过 HTTPS 协议提供服务。

摘要

在本章中,你学习了基于 Gin Web 框架构建安全 RESTful API 的一些最佳实践和建议。你还了解了如何在 Golang 中实现 JWT 以及如何在 API 请求之间持久化会话 cookie。

你还探讨了如何使用第三方解决方案,如 Auth0,作为身份验证提供者,以及如何将其与 Golang 集成以保护 API 端点。最后,你学习了如何通过 HTTPS 协议提供 API。

在下一章中,我们将使用 React Web 框架在 RESTful API 之上构建一个用户友好的 UI(也称为前端)。

问题

  1. 你会如何实现一个注册端点来创建一个新的用户账户?

  2. 你会如何实现一个个人资料端点以返回用户个人资料?

  3. 你会如何生成一个注销端点的 Swagger 规范?

进一步阅读

  • 《OAuth 2.0 食谱》 由阿道夫·埃洛伊·纳西メント著,Packt 出版社

  • 《SSL 完全指南 - HTTP 到 HTTPS》 由博甘·斯塔什丘克著,Packt 出版社

第五章:在 Gin 中提供静态 HTML

在本章中,你将学习如何构建一个消费基于 Gin 的API响应的静态 Web 应用。在这个过程中,你将学习如何使用 Gin 提供 Web 资源(JavaScript、层叠样式表CSS)和图片)以及渲染HTML模板。最后,你将了解如何使用 Go 构建一个自包含的 Web 应用,并使用 Gin 中间件解决跨源资源共享CORS)策略错误。

在本章中,我们将关注以下主题:

  • 提供静态文件

  • 渲染 HTML 模板

  • 构建自包含的 Web 应用

  • 构建单页应用SPA

到本章结束时,你将能够使用 React 构建 SPA 以消费你的 RESTful API 端点。

技术要求

要遵循本章的说明,你需要以下内容:

  • 完全理解前一章的内容——本章是前一章的后续,它将使用相同的源代码。因此,一些代码片段将不会解释,以避免重复。

  • 具备前端开发经验,理想情况下了解 Angular、React 或 Vue.js 等 Web 框架。

本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter05

提供静态文件

在前面的章节中,你已经看到了如何在index.html文件中渲染 API 响应,然后转到提供来自文件系统的静态文件,如 JavaScript、CSS 文件和图片,并最终渲染 HTML 模板。

要开始提供静态文件,请按照以下步骤操作:

  1. 创建一个新的项目文件夹,并使用index.html文件打开它,以显示带有<h2>标签的Hello world消息,如下所示:

    <html>
    <head>
       <title>Recipes Platform</title>
    </head>
    <body>
       <h2>Hello world</h2>
    </body>
    </html>
    
  2. 接下来,使用go get命令安装github.com/gin-gonic/gin,编写一个main.go文件,并使用gin.Default()方法定义一个路由器。然后,为index页面定义一个路由,并在其上注册一个IndexHandler处理器。路由处理器将使用c.File方法提供index.html文件。完整的main.go文件如下所示:

    package main
    import "github.com/gin-gonic/gin"
    func IndexHandler(c *gin.Context) {
       c.File("index.html")
    }
    func main() {
       router := gin.Default()
       router.GET("/", IndexHandler)
       router.Run()
    }
    
  3. 使用以下命令运行应用程序:

    localhost and serves on port 8080 by default, as illustrated in the following screenshot:![Figure 5.1 – Server logs    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_5.1_B17115.jpg)Figure 5.1 – Server logs
    
  4. 使用你喜欢的浏览器,转到localhost:8080,你应该看到一条Hello world消息,如下所示:图 5.2 – 提供 index.html

    图 5.2 – 提供 index.html

  5. 接下来,更新index.html文件以显示一些使用以下代码的食谱。它引用了页面样式的静态资源(app.css)和食谱图片(burger.jpgpizza.jpg):

    <html>
    <head>
       <title>Recipes Platform</title>
       <link rel="stylesheet" href="assets/css/app.css">
    </head>
    <body>
       <div class="recipes">
           <div class="recipe">
               <h2>Pizza</h2>
               <img src="img/pizza.jpg" />
           </div>
           <div class="recipe">
               <h2>Burger</h2>
               <img src="img/burger.jpg" />
           </div>
       </div>
    </body>
    </html>
    
  6. 静态文件存储在项目根存储库下的assets文件夹中。创建一个具有以下结构的assets文件夹:

    .
    ├── css
    │   └── app.css
    └── images
        ├── burger.jpg
        └── pizza.jpg
    .recipes {
       width: 100%;
    }
    .recipe {
       width: 50%;
       float: left;
    }
    .recipe img {
       height: 320px;
    }
    
  7. 为了能够从 index.html 加载这些资产,服务器也应该提供它们。这就是 router.Static 方法发挥作用的地方。添加以下指令以在 /assets 路由下提供 assets 文件夹:

    router.Static("/assets", "./assets")
    
  8. 重新运行应用程序——图片和 CSS 文件现在应该可以从 http://localhost:8080/assets/PATH 访问,如下截图所示:Figure 5.3 – 提供资产

    Figure_5.3_B17115.jpg

    Figure 5.3 – 提供资产

  9. 返回浏览器并刷新页面——现在应该会显示以下结果:

Figure_5.4 – 使用 Gin 提供 CSS 和图片

Figure_5.4_B17115.jpg

Figure 5.4 – 使用 Gin 提供 CSS 和图片

从你所能看到的,页面相当基础!现在你可以使用 Gin 提供静态资源了。

到目前为止,你已经看到了如何在 Gin 应用程序中提供 HTML 和静态文件。在下一节中,我们将介绍如何创建 HTML 模板并渲染动态内容。

渲染 HTML 模板

在本节中,你将添加功能以通过从服务器端生成 index.html 文件来动态显示食谱列表。Gin 框架在后台使用 Go 标准的 text/templatehtml/template 包来生成文本和 HTML 输出。

要开始,请按照以下步骤操作:

  1. 创建一个 Recipe 结构体。该结构体将只包含两个字段:名称和图片。这可以表示如下:

    type Recipe struct {
       Name    string `json:"name"`
       Picture string `json:"picture"`
    }
    
  2. 接下来,更新 IndexHandler 处理器以创建一个 recipes 切片。然后,通过传递 recipes 切片调用 c.HTML 方法来渲染 index.tmpl 文件。为了简化问题,我们将食谱列表保存在内存中,因此我们将使用两个硬编码的食谱初始化 recipes 切片,如下所示:

    func IndexHandler(c *gin.Context) {
       recipes := make([]Recipe, 0)
       recipes = append(recipes, Recipe{
           Name:    "Burger",
           Picture: "/assets/images/burger.jpg",
       })
       recipes = append(recipes, Recipe{
           Name:    "Pizza",
           Picture: "/assets/images/pizza.jpg",
       })
      recipes = append(recipes, Recipe{
           Name:    "Tacos",
           Picture: "/assets/images/tacos.jpg",
       })
       c.HTML(http.StatusOK, "index.tmpl", gin.H{
           "recipes": recipes,
       })
    }
    

    注意

    .tmpl 扩展名不是必需的;然而,为了清晰起见,建议在项目内保持一致性。

  3. 在项目文件夹的根目录下创建一个 templates 文件夹,并编写一个 index.tmpl 文件,内容如下:

    <html>
    <head>
       <title>Recipes</title>
       <link rel="stylesheet" href="assets/css/app.css">
    </head>
    <body>
       <div class="recipes">
           {{range .recipes}}
           <div class="recipe">
               <h2>{{ .Name }}</h2>
               <img src="img/{{ .Picture }}" />
           </div>
           {{end}}
       </div>
    </body>
    </html>
    
  4. range 关键字用于遍历 recipes 切片中的所有食谱。对于食谱范围内的每个食谱,显示其图片和名称。在范围内,每个食谱变为 {{.}},因此食谱属性变为 {{.Name}}{{.Picture}}

  5. 接下来,告诉 Gin 从 templates 目录加载模板。模板将在服务器启动时从磁盘加载一次;因此,应用程序将更快地提供 HTML 页面。以下代码片段展示了如何实现:

    router.LoadHTMLGlob("templates/*")
    
  6. 重新运行应用程序,再次访问 localhost:8080。将显示一个食谱列表,如下截图所示:Figure 5.5 – 使用 range 迭代项目

    Figure_5.5_B17115.jpg

    [
       {
           "name": "Crock Pot Roast",
           "ingredients": [
               {
                   "quantity": "1",
                   "name": "beef roast",
                   "type": "Meat"
               },
               {
                   "quantity": "1 package",
                   "name": "brown gravy mix",
                   "type": "Baking"
               },
               {
                   "quantity": "1 package",
                   "name": "dried Italian salad 
                            dressing mix",
                   "type": "Condiments"
               },
               {
                   "quantity": "1 package",
                   "name": "dry ranch dressing mix",
                   "type": "Condiments"
               },
               {
                   "quantity": "1/2 cup",
                   "name": "water",
                   "type": "Drinks"
               }
           ],
           "steps": [
               "Place beef roast in crock pot.",
               "Mix the dried mixes together in a bowl 
                    and sprinkle over the roast.",
               "Pour the water around the roast.",
               "Cook on low for 7-9 hours."
           ],
           "imageURL": "/assets/images/
                        crock-pot-roast.jpg"
       }
    ]
    
  7. Recipe 结构体更新为与 recipe 字段相对应,如下所示:

    type Recipe struct {
       Name        string       `json:"name"`
       Ingredients []Ingredient `json:"ingredients"`
       Steps       []string     `json:"steps"`
       Picture     string       `json:"imageURL"`
    }
    type Ingredient struct {
       Quantity string `json:"quantity"`
       Name     string `json:"name"`
       Type     string `json:"type"`
    }
    var recipes []Recipe
    
  8. init() 方法中,读取 JSON 文件并使用 json.Unmarshal 方法将其内容编码到 recipes 切片中,如下所示:

    func init() {
       recipes = make([]Recipe, 0)
       file, _ := ioutil.ReadFile("recipes.json")
       _ = json.Unmarshal([]byte(file), &recipes)
    }
    
  9. 然后,更新IndexHandler处理器,将recipes切片传递给名为recipes的变量,并在index.tmpl模板中使用,如下所示:

    func IndexHandler(c *gin.Context) {
       c.HTML(http.StatusOK, "index.tmpl", gin.H{
           "recipes": recipes,
       })
    }
    
  10. 最后,自定义模板文件,使用range关键字显示食谱的成分和步骤。使用字段名({{ .FieldName }})的点操作符访问食谱属性,如下面的代码片段所示:

    <html>
    <head>
       <title>Recipes</title>
       <link rel="stylesheet" href="assets/css/app.css">
       <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body class="container">
       <div class="row">
           {{range .recipes}}
           <div class="col-md-3">
               <div class="card" style="width: 18rem;">
                   <img src="img/{{ .Picture }}" class="
                       card-img-top" alt="...">
                   <div class="card-body">
                       <h5 class="card-title">{{ 
                           .Name }}</h5>
                       {{range $ingredient := 
                           .Ingredients}}
                       <span class="badge bg-danger 
                           ingredient">
                           {{$ingredient.Name}}
                       </span>
                       {{end}}
                       <ul class="steps">
                           {{range $step := .Steps}}
                           <li>{{$step}}</li>
                           {{end}}
                       </ul>
                   </div>
               </div>
           </div>
           {{end}}
       </div>
    </body>
    <script src="img/bootstrap.bundle.min.js"></script>
    </html>
    

    注意

    关于高级模板操作符和运算符的进一步说明,请参阅 Go 官方文档golang.org/pkg/text/template/

  11. 重新运行应用程序,并在浏览器中刷新页面。这次,将显示一个美观的用户界面UI),如下面的屏幕截图所示:

图 5.6 – 使用 Go 模板渲染数据

图 5.6 – 使用 Go 模板渲染数据

如您所见,您的应用程序看起来更加美观。您所做的只是添加了一些 Bootstrap 框架的 CSS(要了解更多关于框架的信息,请参阅getbootstrap.com/); 因此,从功能角度来看,没有任何变化。

如果您回到终端,您会注意到图像和其他资源是从 HTTP 服务器提供的,如下所示:

图 5.7 – 服务器图像

图 5.7 – 服务器图像

太好了!资源是从 Gin 服务器提供的,现在您可以构建一个动态网页。

创建视图模板

目前,我们构建的应用程序显示了一个食谱列表。您可以进一步创建一个食谱页面,让用户可以看到完整的食谱。为此,您需要为每个食谱创建一个唯一的标识符;这样,您就可以有一个唯一的 URL 来访问每个食谱。

要开始,请按照以下步骤操作:

  1. recipes.json文件中的食谱项中添加一个id属性,如下所示,或者您可以从书籍 GitHub 仓库中的chapter05文件夹下载recipes.json文件:

    [
       {
           "id": "603fa0f0b39c47f0e40659c2",
           "name": "Crock Pot Roast",
           "ingredients": [...],
           "steps": [...],
           "imageURL": "/assets/images/
                        crock-pot-roast.jpg"
       }
    ]
    
  2. 然后,将ID字段添加到Recipe结构体中,如下所示:

    type Recipe struct {
       ID          string       `json:"id"`
       Name        string       `json:"name"`
       Ingredients []Ingredient `json:"ingredients"`
       Steps       []string     `json:"steps"`
       Picture     string       `json:"imageURL"`
    }
    
  3. 要能够导航到食谱页面,请在index.tmpl文件中的每个食谱项中添加一个按钮。当点击按钮时,用户将被重定向到目标食谱页面。{{ .ID }}表达式将被评估并替换为食谱 ID。代码如下所示:

    <section class="container">
           <div class="row">
               {{range .recipes}}
               <div class="col-md-3">
                   <div class="card" style="width: 
                    18rem;">
                       <img src="img/{{ .Picture }}" 
                       class="card-img-top" alt="...">
                       <div class="card-body">
                           <h5 class="card-title">{{ 
                               .Name }}</h5>
                           {{range $ingredient := 
                               .Ingredients}}
                           <span class="badge bg-danger 
                               ingredient">
                               {{$ingredient.Name}}
                           </span>
                           {{end}}
                           <ul class="steps">
                               {{range $step := .Steps}}
                               <li>{{$step}}</li>
                               {{end}}
                           </ul>
                           <a href="/recipes/{{ .ID }}" 
                               class="btn btn-primary btn-
                               sm">See recipe</a>
                       </div>
                   </div>
               </div>
               {{end}}
           </div>
    </section>
    
  4. 话虽如此,在/recipes/:id上提供食谱页面。请注意此路由中的:id部分。开头的两个表示这是一个动态路由。该路由将存储食谱id属性在名为ID的路由参数中,我们可以在RecipeHandler处理器中访问它。以下是您需要的代码:

    router.GET("/recipes/:id", RecipeHandler)
    

    以下代码片段包含路由处理程序代码,内容自解释。它遍历 recipes 切片,寻找与请求参数中给出的 ID 匹配的食谱。如果找到匹配项,则将使用食谱的数据渲染 recipe.tmpl 文件。如果没有找到,用户将被重定向到 404.html 错误页面,该页面将简单地显示一个 404 插图和“食谱未找到”的消息。这可以通过以下代码实现:

    <html>
    <head>
       <title>Recipe not found</title>
       <link rel="stylesheet" href="/assets/css/app.css">
       <link href="https://cdn.jsdelivr.net/npm/ 	    bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css"  	    rel="stylesheet">
    </head>
    <body>
       <section class="container not-found">
           <h4>Recipe not found /h4>
           <img src="img/404.jpg" width="60%">
       </section>
    </body>
    <script src="img/bootstrap.bundle.min.js"></script>
    </html>
    
  5. 要测试新更改,重新运行服务器,然后返回浏览器并刷新页面。将返回食谱列表,但这次我们有一个 查看食谱 按钮,如图所示:图 5.8 – 首页

    图 5.8 – 首页

  6. 点击 查看食谱 按钮。

    您将被重定向到 食谱 页面,在那里您可以查看完整的食谱成分和说明,如图所示:

图 5.9 – 食谱页面

图 5.9 – 食谱页面

您也可以通过与朋友分享它们的 URL 来分享您最喜欢的食谱。如果某个食谱不再存在或您分享了一个错误的食谱 ID,则将显示 404 页面,如下所示:

图 5.10 – 404 错误页面

图 5.10 – 404 错误页面

您可能已经注意到,您使用了一个导航栏来能够轻松地在首页(显示食谱列表的页面)和食谱页面之间切换。相同的代码被用于两个不同的模板文件。为了避免代码重复,您可以创建 可重用模板

创建可重用模板

导航栏 是将在我们应用程序的多个页面中重用的常见功能。为了创建一个可重用模板,请按照以下步骤操作:

  1. templates 文件夹中创建一个 navbar.tmpl 文件,并将导航栏代码放入其中,如下所示:

    <nav class="container-fluid navbar navbar-expand-lg navbar-light bg-light">
       <a class="navbar-brand" href="#">
           <div class="logo">
               <img src="img/logo.svg">
               <span>Recipes</span>
           </div>
       </a>
       <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
           aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
           <span class="navbar-toggler-icon"></span>
       </button>
       <div class="collapse navbar-collapse" 
          id="navbarSupportedContent">
           <ul class="navbar-nav mr-auto">
               <li class="nav-item active">
                   <a class="nav-link" href="/">Home</a>
               </li>
           </ul>
       </div>
    </nav>
    
  2. 接下来,从 index.tmplrecipe.tmpl 文件中删除导航栏代码,并按照以下说明从 navbar.tmpl 文件导入 navbar 模板:

    <html>
    <head>
       <title>{{ .recipe.Name }} - Recipes</title>
       ...
    </head>
    <body>
       {{template "navbar.tmpl"}}
       <section class="container recipe">
           <div class="row">
               <div class="col-md-3">
                   <img src="img/{{ .recipe.Picture }}" 
                    class="card-img-top">
               </div>
               <div class="col-md-9">
                   <h4>{{ .recipe.Name }}</h4>
                   <ul class="list-group list-steps">
                       <li class="list-group-item 
                         active">Steps</li>
                       {{range $step := .recipe.Steps }}
                       <li class="list-group-
                           item">{{$step}}</li>
                       {{end}}
                   </ul>
               </div>
           </div>
       </section>
    </body>
    ...
    </html>
    

您的可重用模板已创建。

包含 {{template "navbar.tmpl"}} 的行用于动态加载导航栏。与导航栏一样,您可以从应用程序的常见部分(如页眉、页脚、表单等)创建可重用模板。在此阶段,您项目的目录结构应如下所示:

图 5.11 – 项目结构

图 5.11 – 项目结构

到目前为止,您已经看到了如何通过编译源代码在本地运行应用程序。但如果我们想构建 Web 应用程序的二进制文件以便远程部署或与他人共享呢?让我们看看如何做这件事。

构建自包含的 Web 应用程序

幸运的是,Go 是一种编译型语言,这意味着您可以使用单个命令创建包含所需依赖项的可执行文件或二进制文件,如下所示:

go build -o app main.go

备注

您可以使用 GOOSGOARCH 环境变量为不同的架构或平台(Windows、macOS、Linux 等)构建可执行文件。

该命令将在您的当前目录中创建一个名为 app 的可执行文件。默认情况下,Go 使用应用程序目录的名称来命名可执行文件。但是,您可以使用 -o 标志指定不同的名称或位置。

您现在可以使用以下命令执行二进制文件:

./app

服务器将像往常一样在端口 8080 上启动,您可以从 localhost:8080 访问网络应用程序,如下面的截图所示:

图 5.12 – 运行可执行文件

图 5.12 – 运行可执行文件

应用程序按预期工作,因为 HTML 模板和资源位于正在执行可执行文件的同一文件夹中。

如果您从不同的目录运行二进制文件,那么您需要遵循以下步骤:

  1. 使用以下命令将可执行文件复制到您的主目录:

    mv app $HOME
    cd $HOME
    
  2. 重新运行应用程序——它应该立即崩溃,因为找不到 templates 文件夹。您将看到类似以下的内容:图 5.13 – 应用程序堆栈跟踪

    图 5.13 – 应用程序堆栈跟踪

    您可以将 templatesassets 文件夹复制到主目录。然而,在升级或将二进制文件移动到新位置时,记住更新所有文件引用通常很麻烦。更好的解决方案是将所有静态文件嵌入到一个单独的二进制文件中。

  3. go-assets-builder 是一个工具,可以将任何文本或二进制文件转换为 Go 源代码,使其成为将资源嵌入 Go 应用程序的完美选择。使用以下命令安装 go-assets-builder 包:

    go get github.com/jessevdk/go-assets-builder
    
  4. 然后,调用 go-assets-builder 命令从静态文件生成 Go 代码,如下所示:

    assets.go source file. The resulting code will look like this:
    
    

    var Assets = assets.NewFileSystem(map[string][]string{

    "/": []string{"assets", "templates", "404.html",

    "recipes.json"}, "/assets/css":

    []string{"app.css"}, "/assets/images":

    []string{"stuffed-cornsquash.jpg", "curry-chicken-

    salad.jpg"}, "/templates": []string{"navbar.tmpl",

    "index.tmpl", "recipe.tmpl"}},

    map[string]*assets.File{

    "/": &assets.File{

    路径:     "/",

    FileMode: 0x800001ed,

    Mtime:    time.Unix(1615118299,

    1615118299722824447),

    数据:     nil,

    },  "/templates/navbar.tmpl": &assets.File{

    路径:     "/templates/navbar.tmpl",

    FileMode: 0x1a4,

    Mtime:    time.Unix(1614862865,

    1614862865957528581),

    数据:     []byte(_Assets9a0a5c784c66e5609ac

    d084702e97a6a733e0d56),

    }, "/recipes.json": &assets.File{

    路径:     "/recipes.json",

    FileMode: 0x1a4,

    Mtime:    time.Unix(1614782782,                              1614782782296236029),

    数据:     []byte(_Assets142ce9f9ba8b43eeb97b8

    3c79ea872ed40e6cba1),

    }, "")

    
    
  5. 接下来,更新main.go以加载 HTML 文件。模板可通过Assets.Files映射结构访问。确保导入html/template库。代码在以下代码片段中说明:

    func loadTemplate() (*template.Template, error) {
       t := template.New("")
       for name, file := range Assets.Files {
           if file.IsDir() || !strings.HasSuffix(name, 
                   ".tmpl") {
               continue
           }
           h, err := ioutil.ReadAll(file)
           if err != nil {
               return nil, err
           }
           t, err = t.New(name).Parse(string(h))
           if err != nil {
               return nil, err
           }
       }
       return t, nil
    }
    
  6. 然后,更新 HTTP 路由器,当服务器被要求提供index.tmplrecipe.tmplnavbar.tmpl模板时,调用loadTemplate方法,如下所示:

     func main() {
       t, err := loadTemplate()
       if err != nil {
           panic(err)
       }
       router := gin.Default()
       router.SetHTMLTemplate(t)
       router.GET("/", IndexHandler)
       router.GET("/recipes/:id", RecipeHandler)
       router.GET("/assets/*filepath", StaticHandler)
       router.Run()
     }
    
  7. 对于资产文件(CSS 和图像),定义一个自定义 HTTP 处理器,根据filepath参数提供正确的资产,如下所示:

    func StaticHandler(c *gin.Context) {
       filepath := c.Param("filepath")
       data := Assets.Files["/assets"+filepath].Data
       c.Writer.Write(data)
    }
    
  8. 接下来,为assets文件夹定义一个带有filepath参数的路由,如下所示:

    router.GET("/assets/*filepath", StaticHandler)
    
  9. 同时更新recipes.json文件,从Assets.Files映射中加载它,如下所示:

    func init() {
       recipes = make([]Recipe, 0)
       json.Unmarshal(Assets.Files["/recipes.json"].Data, 
          &recipes)
    }
    
  10. 确保更新index.tmplrecipe.tmpl中的navbar引用,如下所示:

    {{template "/templates/navbar.tmpl"}}
    
  11. 最后,在RecipeHandlerIndexHandler处理器中修复templates路径。

  12. 现在,使用以下命令构建应用程序,应用新更改:

    go build -o app
    mv app $HOME
    cd $HOME
    ./app
    

这次,应用程序将是功能性的,所有资产都将从二进制文件中加载。你可以在这里看到结果:

图 5.14 – 嵌入 Web 应用程序资源

图 5.14 – 嵌入 Web 应用程序资源

你现在可以与你的朋友分享这个二进制文件,并轻松地在远程服务器上部署它。

注意

第八章**,在 AWS 上部署应用程序中,我们将探讨如何使用 Docker 和 Kubernetes 在云上部署你的分布式 Web 应用程序。

静态文件打包

在本章中,我们使用 Go 1.16,它带来了新的功能和改进,例如支持嵌入式文件,无需使用像go-assets-builder这样的外部包。

注意

你可以使用gvm install go1.16命令。

Go 1.16 引入了//go:embed指令,允许你在 Go 应用程序中包含文件和目录的内容。你可以通过实现以下操作来完成:

  1. main.go中,定义一个embed.FS变量来保存一组文件。然后,在变量声明上方定义一个注释,如下所示:

    //go:embed assets/* templates/* 404.html recipes.json
    var f embed.FS
    
  2. 更新init()函数,从FS变量中读取recipes.json文件,如下所示:

    func init() {
       recipes = make([]Recipe, 0)
       data, _ := f.ReadFile("recipes.json")
       json.Unmarshal(data, &recipes)
    }
    
  3. 然后,使用http.FS文件创建一个 HTTP 文件系统来提供assets文件,如下所示:

    func main() {
       templ := template.Must(template.New("").ParseFS(f, "templates/*.tmpl"))
       fsys, err := fs.Sub(f, "assets")
       if err != nil {
           panic(err)
       }
       router := gin.Default()
       router.SetHTMLTemplate(templ)
       router.StaticFS("/assets", http.FS(fsys))
       router.GET("/", IndexHandler)
       router.GET("/recipes/:id", RecipeHandler)
       router.Run()
    }
    
  4. 使用go build命令重新构建二进制文件。

    注意

    在构建二进制文件之前,确保从 HTTP 处理器和模板文件中删除/templates前缀。

最终结果是单个准备分发的 Web 服务器二进制文件!

构建 SPA

虽然你可以通过渲染 HTML 模板和提供静态文件来使用 Gin 构建完整的 Web 应用程序,但随着应用程序的增长,维护它变得很困难。这就是为什么你可以采用流行的前端 JavaScript 框架,如 Angular、React 或 Vue.js 来构建你的 SPA。在本节中,我们将使用 React,但你也可以使用其他 JavaScript 框架获得相同的结果。

我们将要构建的应用程序将执行以下操作:

  • 在主页上显示所有食谱的列表(对所有用户)。

  • 允许用户使用用户名和密码登录。

  • 允许用户创建新的食谱(仅限已登录用户)。

首先,我们需要确保您的系统上已安装 Node.js。您可以从nodejs.org/en/download/的官方网站安装长期支持LTS)版本(14.16.0),或者您可以使用Node 版本管理器NVM)根据您的操作系统OS)轻松安装 Node.js。

要安装 NVM,请按照以下步骤操作:

  1. 运行以下脚本:

    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash
    
  2. 这可能需要几秒钟到几分钟的时间,但一旦安装完成,请发出以下命令以安装 Node.js 的 LTS 版本:

    nvm install 14.16.0 
    

在安装 Node.js 之后,我们就可以使用 React 构建我们的 Web 应用程序了。

开始使用 React

在本节中,您将设置 React 环境。为此,请执行以下步骤:

  1. 打开您的终端并导航到您的工 作区。然后,使用以下命令安装create react app命令行界面CLI)简化了 React 环境的设置过程:

    npm install -g create-react-app
    
  2. 然后,使用 CRA CLI 创建一个 React 项目。项目名称为recipes-web,如下所示:

    create-react-app recipes-web
    

    这里是命令输出:

    图 5.15 – 新建 React 项目的设置

    图 5.15 – 新建 React 项目的设置

    在运行前面的命令后,项目结构将如下所示。项目会自动创建所有必要的后台配置:

    图 5.16 – React 项目结构

    图 5.16 – React 项目结构

    注意

    确保为您的 Web 应用程序创建一个 GitHub 仓库并将所有更改推送到远程仓库。

  3. 导航到recipes-web文件夹并输入以下命令以启动应用程序:

    3000, as illustrated in the following screenshot:![Figure 5.17 – Local development server    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_5.17_B17115.jpg)Figure 5.17 – Local development server
    
  4. 在浏览器中导航到Localhost:3000Apart from;您将看到以下截图类似的内容:

图 5.18 – React “Hello World”应用程序

图 5.18 – React "Hello World"应用程序

目前,您的应用程序并没有做很多。它看起来也不像什么。让我们使用 React 组件构建食谱列表。

探索 React 组件

在构建 SPA 时的一个基本基础是组件的使用。在本节中,我们将探讨如何基于 React 组件构建食谱仪表板。

你将要做的第一件事是创建一个App组件,通过引用一个Recipe元素来列出recipes数组。为此,请按照以下步骤操作:

  1. 使用以下内容更新App.js文件:

    import React from 'react';
    import './App.css';
    import Recipe from './Recipe';
    class App extends React.Component {
     constructor(props) {
       super(props)
       this.state = {
         recipes: [
           {
             "name": "Oregano Marinated Chicken",
             "tags": [
               "main",
               "chicken"
             ],
             "ingredients": [],
             "instructions": []
           },
           {
             "name": "Green pea soup with cheddar 
                      scallion panini",
             "tags": [
               "soup",
               "main",
               "panini"
             ],
             "ingredients": [],
             "instructions": []
           }
         ]
       }
     }
     render() {
       return (<div>
         {this.state.recipes.map((recipe, index) => (
           <Recipe recipe={recipe} />
         ))}
       </div>);
     }
    

    组件构造函数在state对象中定义了一个recipes数组。该数组存储了一系列硬编码的食谱。

  2. 接下来,创建一个 Recipe 组件来显示菜谱属性(名称、步骤、配料等)。在 src 目录下创建一个名为 Recipe.js 的新文件,并将以下内容添加到其中:

    import React from 'react';
    import './Recipe.css';
    class Recipe extends React.Component {
       render() {
           return (
               <div class="recipe">
                   <h4>{this.props.recipe.name}</h4>
                   <ul>
                       {this.props.recipe.ingredients &&
                        this.props.recipe.ingredients.map(
                        (ingredient, index) => {
                           return <li>{ingredient}</li>
                       })}
                   </ul>
               </div>
           )
       }
    }
    export default Recipe;
    
  3. 创建一个名为 Recipe.css 的样式表,并添加适当的样式规则以改善 UI 元素的视觉效果。然后,在 Recipe.js 中导入样式表。

  4. 完成这些操作后,保存所有更改并在浏览器中预览,你应该会看到如下所示的内容:

![图 5.19 – 渲染 App 组件图片 5.19

图 5.19 – 渲染 App 组件

太好了!你已经成功构建了一个用于列出菜谱的单页应用 (SPA)。

目前,菜谱列表是静态的。你将通过调用上一章中构建的 Recipes API 来修复这个问题。为此,请按照以下步骤操作:

  1. 首先,看看你将要构建的架构:![图 5.20 – 应用程序架构 图片 5.20

    getRecipes() {
       fetch('http://localhost:8080/recipes')
         .then(response => response.json())
         .then(data => this.setState({ recipes: data }));
    }
    
  2. 接下来,使用以下指令在 App 组件构造函数上调用方法:

    constructor(props) {
       super(props)
       this.state = {
         recipes: []
       }
       this.getRecipes();
    }
    
  3. 确保 API 正在端口 8080 上提供服务,如下所示:![图 5.21 – 菜谱 API 日志 图片 5.21

    图 5.21 – 菜谱 API 日志

  4. 返回到你的浏览器,你应该会看到一个空白页面。如果你打开浏览器调试控制台,你会看到 API 调用被 CORS 策略阻止,如下面的截图所示:

![图 5.22 – CORS 错误图片 B17115_05_22_v2

图 5.22 – CORS 错误

由于 Recipes API 和 Web 应用程序运行在不同的端口上,你需要在 API 上设置一些头信息以允许 跨源资源共享 (CORS)

解决跨源请求

默认情况下,API 使用同源策略来限制 API 与源域外的资源交互的能力。我们可以通过 CORS 来绕过同源策略。

要解决 CORS 问题,请按照以下步骤操作:

  1. 使用以下命令下载 Gin 官方 CORS 中间件:

    go get github.com/gin-contrib/cors
    
  2. 然后,更新 Recipes API 项目的 main.go 文件,通过将 cors.Default() 方法定义为中间件来允许所有来源,如下所示:

    router.Use(cors.Default())
    
  3. 中间件将在处理传入的 HTTP 请求之前执行。cors.Default() 方法将允许所有 HTTP 方法和服务端点,但你也可以限制请求到受信任的服务端点。使用以下代码定义服务端点并允许传入的 HTTP 方法:

    router.Use(cors.New(cors.Config{
       AllowOrigins:     []string{"http://localhost
                          :3000"},
       AllowMethods:     []string{"GET", "OPTIONS"},
       AllowHeaders:     []string{"Origin"},
       ExposeHeaders:    []string{"Content-Length"},
       AllowCredentials: true,
       MaxAge: 12 * time.Hour,
    }))
    
  4. 重新启动 API 服务器,并返回到你的浏览器,刷新 Web 应用程序页面。

这次,HTTP 请求应该成功,你应该会看到一个菜谱列表,如下所示:

![图 5.23 – 菜谱列表图片 5.23

图 5.23 – 菜谱列表

现在,你可以进一步扩展项目,并添加一个使用 Auth0 的身份验证层,如下所示:

注意

要获取如何使用 Auth0 启用身份验证的逐步指南,请查看上一章。

  1. 导航到Auth0 仪表板(manage.auth0.com/dashboard),并创建一个类型为单页 Web 应用的应用程序,如图 5.24 所示:图 5.24 – Auth0 上的 SPA

    图 5.24 – Auth0 上的 SPA

  2. 接下来,点击Localhost:3000. 除此之外,如图 5.25 所示。该 URL 将在用户认证后由 Auth0 用于重定向用户:图 5.25 – 配置允许的回调 URL

    图 5.25 – 配置允许的回调 URL

  3. localhost:3000设置为授权应用程序向 Auth0 API 发起请求。

  4. 现在,要将 Auth0 集成到您的 Web 应用程序中,安装 Auth0 React软件开发工具包SDK)。在项目文件夹内运行以下命令以安装 SDK:

    npm install @auth0/auth0-react
    
  5. 接下来,使用Auth0Provider包装应用程序的根组件。更新index.js文件,如下所示:

    import { Auth0Provider } from "@auth0/auth0-react";
    ReactDOM.render(
     <Auth0Provider
       domain="AUTH0_DOMAIN"
       clientId="AUTH_CLIENT_ID"
       redirectUri={window.location.origin}
     >
       <App />
     </Auth0Provider>,
     document.getElementById("root")
    );
    

    Auth0 组件具有以下属性:

    a. domain: Auth0 域名。该值可在b. clientId: Auth0 客户端 ID。该值也可在c. redirectUri: ReactDOM.render方法在index.html中查找根元素(位于public文件夹内)并在此上加载 Auth0 组件。

  6. 然后,创建一个带有登录按钮的Navbar组件。当用户点击登录按钮时,useAuth0()钩子的loginWithRedirect方法将被调用。该方法将用户重定向到 Auth0 登录页面,在那里他们可以进行认证。认证成功后,Auth0 将根据之前定义的重定向 URL 将用户重定向回应用程序。代码如下所示:

    import React from 'react';
    import { useAuth0 } from "@auth0/auth0-react";
    import Profile from './Profile';
    const Navbar = () => {
       const { isAuthenticated, loginWithRedirect, logout,
               user } = useAuth0();
       return (
           <nav class="navbar navbar-expand-lg navbar-
               light bg-light">
               <a class="navbar-brand" 
                   href="#">Recipes</a>
               <button class="navbar-toggler" 
               type="button" data-toggle="collapse" data-
               target="#navbarTogglerDemo02" aria-
               controls="navbarTogglerDemo02" aria-
               expanded="false" aria-label="Toggle 
               navigation">
                   <span class="navbar-toggler-
                                icon"></span>
               </button>
               <div class="collapse navbar-collapse" 
                           id="navbarTogglerDemo02">
                   <ul class="navbar-nav ml-auto">
                       <li class="nav-item">
                           {isAuthenticated ? (<Profile 
                               />) : (
                               <a class="nav-link active" 
                                         onClick={() => 
                                     loginWithRedirect()}> 
                                    Login</a>
                           )}
                       </li>
                   </ul>
               </div>
           </nav >
       )
    }
    export default Navbar;
    

    注意

    为了防止任何渲染错误,使用isAuthenticated属性检查 Auth0 是否在显示使用Profile组件的登录用户名和图片之前已认证用户。

  7. 在代码中,我们使用 Bootstrap 框架提供的现有 UI 元素。可以使用以下命令安装该框架:

    npm install bootstrap
    
  8. 安装完成后,在index.js顶部使用import语句引用框架,如下所示:

    import 'bootstrap/dist/css/bootstrap.min.css';
    import 'bootstrap/dist/js/bootstrap.min.js';
    
  9. 然后,将Navbar组件添加到App.js中,如下所示:

    render() {
       return (<div>
         <Navbar />
         {this.state.recipes.map((recipe, index) => (
           <Recipe recipe={recipe} />
         ))}
       </div>);
    }
    
  10. 在此更改后预览您的应用程序,它应该看起来像这样:图 5.26 – 渲染食谱列表

    图 5.26 – 渲染食谱列表

  11. 现在,点击登录。您将被重定向到 Auth0 登录页面,在那里您可以注册新账户或使用现有账户登录,如图所示:![图 5.26 – 渲染食谱列表图 5.27 – Auth0 通用登录页面

图 5.27 – Auth0 通用登录页面

您的认证已成功!您将被重定向到主页,在那里您可以浏览由Recipes API 返回的食谱列表,如下面的截图所示:

![图 5.28 – 列出登录用户食谱的页面图 5.28 – 列出登录用户食谱的页面

图 5.28 – 列出登录用户食谱的页面

应用程序还显示了登录用户的用户名和头像。这些信息可通过useAuth0()钩子暴露的user属性获取。你现在已经成功创建了一个 SPA。

您的 Web 应用程序在功能上相当简单,但通过从头开始构建,我们几乎涵盖了 React 带来的每一个小细节。您可以进一步实现一个新的食谱表单,供登录用户发布新食谱。

摘要

在本章中,我们学习了如何使用 Gin 框架提供 HTML 模板,以及如何创建可重用模板并提供静态资源。我们介绍了如何使用 React 框架实现 SPA 以消费 Gin RESTful API,以及如何使用 Gin 解决 CORS 问题。

我们还探讨了 Auth0 如何让您快速为 React 应用程序添加身份验证。最后,我们学习了如何在构建时嵌入应用程序资源来构建一个自包含的 Web 应用程序。

在下一章中,我们将探讨使用 Gin 架构一个可扩展、分布式 Web 应用的技巧和最佳实践。

问题

  1. 你会如何创建可重用的头部和尾部模板?

  2. 你会如何使用 React 创建一个NewRecipe组件?

  3. 你会如何构建适用于 Windows、Mac 和 Linux 的自包含二进制文件?

进一步阅读

  • 全栈 React 项目 - 第二版,由 Shama Hoque 著,Packt Publishing 出版

  • 全栈 React、TypeScript 和 Node,由 David Choi 著,Packt Publishing 出版

第六章:扩展 Gin 应用程序

在本章中,你将学习如何提高使用 Gin 框架编写的分布式 Web 应用程序的性能和可伸缩性。本章将涵盖如何使用缓存机制来缓解性能瓶颈。在这个过程中,你将学习如何使用如 RabbitMQ 的消息代理解决方案来扩展 Web 应用程序。最后,你将学习如何容器化应用程序并使用Docker Compose进行扩展。

在本章中,我们将涵盖以下主题:

  • 使用消息代理扩展工作负载

  • 使用 Docker 副本水平扩展

  • 使用 Nginx 反向代理

  • 使用 HTTP 缓存头缓存资源

到本章结束时,你将能够使用 Gin 框架、Docker 和 RabbitMQ 构建一个高度可用和分布式的 Web 应用程序。

技术要求

要跟随本章的内容,你需要以下条件:

  • 完全理解前一章的内容。本章是前一章的后续,因为它将使用相同的源代码。因此,一些代码片段将不会解释,以避免重复。

  • 理解 Docker 及其架构。理想情况下,一些使用消息队列服务(如 RabbitMQ、ActiveMQ、Kafka 等)的先前经验将有所帮助。

本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter06

使用消息代理扩展工作负载

在开发 Web 应用程序时,用户体验的一个重要方面是响应时间,这往往被忽视。没有任何东西能比一个缓慢而迟钝的应用程序更快地让用户离开。在前几章中,你学习了如何使用 Redis 减少数据库查询以实现更快的数据访问。在本章中,你将进一步学习如何使用 Gin 框架编写的 Web 应用程序进行扩展。

在我们深入探讨为什么需要扩展应用程序的工作负载之前,让我们在架构中添加另一个模块。新的服务将解析 Reddit RSS 源并将源条目插入 MongoDB 的recipes集合中。以下图示说明了新服务如何与架构集成:

图 6.1 – 解析 Reddit RSS 源

图 6.1 – 解析 Reddit RSS 源

该服务将接受一个 subreddit RSS URL 作为参数。我们可以在现有的 subreddit URL 末尾添加.rss来创建一个 RSS 源:

www.reddit.com/r/THREAD_NAME/.rss

例如,让我们看看以下截图中的 recipes subreddit:

图 6.2 – Recipes subreddit

图 6.2 – Recipes subreddit

该 subreddit 的 RSS 源 URL 如下:

www.reddit.com/r/recipes/.rss

如果您访问上述 URL,您应该收到一个 XML 响应。以下是由食谱 subreddits 的 RSS URL 返回的 XML 结构示例:

<?xml version="1.0" encoding="UTF-8"?>
<feed
   xmlns="http://www.w3.org/2005/Atom"
   xmlns:media="http://search.yahoo.com/mrss/">
   <title>recipes</title>
   <entry>
       <author>
           <name>/u/nolynskitchen</name>
           <uri>https://www.reddit.com/user/nolynskitchen
           </uri>
       </author>
       <category term="recipes" label="r/recipes"/>
       <id>t3_m4uvlm</id>
       <media:thumbnail url="https://b.thumbs.
          redditmedia.com
          /vDz3xCmo10TFkokqy9y1chopeIXdOqtGA33joNBtTDA.jpg" 
        />
       <link href="https://www.reddit.com/r/recipes
                  /comments/m4uvlm/best_butter_cookies/" />
       <updated>2021-03-14T12:57:05+00:00</updated>
       <title>Best Butter Cookies!</title>
   </entry>
</feed>

要开始,请按照以下步骤操作:

  1. 创建一个 rss-parser 项目,将其加载到 VSCode 编辑器中,并编写一个 main.go 文件。在文件中,声明一个 Feed 结构体以反映 XML 结构:

    type Feed struct {
       Entries []Entry `xml:"entry"`
    }
    type Entry struct {
       Link struct {
           Href string `xml:"href,attr"`
       } `xml:"link"`
       Thumbnail struct {
           URL string `xml:"url,attr"`
       } `xml:"thumbnail"`
       Title string `xml:"title"`
    } 
    
  2. 接下来,编写一个 GetFeedEntries 方法,该方法接受 RSS URL 作为参数,并返回一个条目列表:

    func GetFeedEntries(url string) ([]Entry, error) {
       client := &http.Client{}
       req, err := http.NewRequest("GET", url, nil)
       if err != nil {
           return nil, err
       }
       req.Header.Add("User-Agent", "Mozilla/5.0 (
          Windows NT 10.0; Win64; x64) AppleWebKit/537.36 
          (KHTML, like Gecko) Chrome/70.0.3538.110 
          Safari/537.36")
       resp, err := client.Do(req)
       if err != nil {
           return nil, err
       }
       defer resp.Body.Close()
       byteValue, _ := ioutil.ReadAll(resp.Body)
       var feed Feed
       xml.Unmarshal(byteValue, &feed)
       return feed.Entries, nil
    }
    

    此方法使用 HTTP 客户端向 GetFeedEntries 方法参数中给出的 URL 发起 GET 请求。然后,将响应体编码到 Feed 结构体中。最后,它返回 Entries 属性。

    注意使用 User-Agent 请求头模拟从浏览器发送的请求,以避免被 Reddit 服务器阻止:

    req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36") 
    

    注意

    可以在以下 URL 找到有效 User-Agent 列表(它定期更新):developers.whatismybrowser.com/useragents/explore/

  3. 接下来,使用 Gin 路由器创建一个 Web 服务器,并在 /parse 端点公开 POST 请求。然后,定义一个名为 ParserHandler 的路由处理程序:

    func main() {
       router := gin.Default()
       router.POST("/parse", ParserHandler)
       router.Run(":5000")
    }
    

    ParserHandler 的名称是自解释的:它将请求有效载荷序列化为 Request 结构体。然后,它使用 Request 结构体的 URL 属性调用 GetFeedEntries 方法。最后,根据方法响应,它返回一个 500 错误代码或 200 状态代码,以及一个条目列表:

    func ParserHandler(c *gin.Context) {
       var request Request
       if err := c.ShouldBindJSON(&request); err != nil {
           c.JSON(http.StatusBadRequest, gin.H{
              "error": err.Error()})
           return
       }
       entries, err := GetFeedEntries(request.URL)
       if err != nil {
           c.JSON(http.StatusInternalServerError, 
              gin.H{"error": "Error while parsing 
                     the rss feed"})
           return
       }
       c.JSON(http.StatusOK, entries)
    } 
    

    Request 结构体有一个 URL 属性:

    type Request struct {
      URL string `json:"url"`
    }
    
  4. 为了测试它,在不同的端口(例如,5000)上运行服务器,以避免与已运行在端口 8080 上的食谱 API 发生端口冲突:图 6.3 – RSS 解析日志

    图 6.3 – RSS 解析日志

  5. 在 Postman 客户端,对 /parse 端点发起一个 POST 请求,请求体中包含 subreddits 的 URL。服务器将解析 RSS 源并返回一个条目列表,如下截图所示:图 6.4 – RSS 源条目

    图 6.4 – RSS 源条目

  6. 现在,通过连接到之前章节中部署的 MongoDB 服务器将结果插入 MongoDB。在 init() 方法中定义连接指令,如下所示:

    var client *mongo.Client
    var ctx context.Context
    func init() {
       ctx = context.Background()
       client, _ = mongo.Connect(ctx, 
          options.Client().ApplyURI(os.Getenv("MONGO_URI")))
    }
    
  7. 然后,更新 HTTP 处理器以使用 InsertOne 操作将条目插入到 recipes 集合中:

    func ParserHandler(c *gin.Context) {
       ...
       collection := client.Database(os.Getenv(
          "MONGO_DATABASE")).Collection("recipes")
       for _, entry := range entries[2:] {
           collection.InsertOne(ctx, bson.M{
               "title":     entry.Title,
               "thumbnail": entry.Thumbnail.URL,
               "url":       entry.Link.Href,
           })
       }
       ...
    }
    
  8. 重新运行应用程序,但这次,提供 MONGO_URIMONGO_DATABASE 环境变量,如下所示:

    MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" MONGO_DATABASE=demo go run main.go 
    
  9. 使用 Postman 或 curl 命令重新发起 POST 请求。回到 MongoDB Compass 并刷新 recipes 集合。RSS 条目应该已成功插入,如下所示:

图 6.5 – 食谱集合

图 6.5 – 食谱集合

注意

如果您正在使用与之前章节中显示的相同数据库和集合,在插入新食谱之前,您可能需要删除现有文档。

recipes集合现在已初始化为一系列食谱。

您可以重复相同的步骤来解析其他 subreddit RSS 源。然而,如果您想解析成千上万的 subreddits,处理如此大量的工作负载将需要大量的资源(CPU/RAM),并且会耗费大量时间。这就是为什么我们将服务逻辑分割成多个松散耦合的服务,然后根据传入的工作负载进行扩展。

这些服务需要相互通信,最有效的通信方式是使用消息代理。这就是 RabbitMQ 发挥作用的地方。

部署 RabbitMQ 与 Docker

RabbitMQ (www.rabbitmq.com/#getstarted)是一个可靠且高度可用的消息代理。以下架构描述了 RabbitMQ 将在应用程序架构中的应用方式:

图 6.6 – 使用 RabbitMQ 进行扩展

图 6.6 – 使用 RabbitMQ 进行扩展

要使用 Docker 部署 RabbitMQ,使用官方的 RabbitMQ Docker 镜像(hub.docker.com/_/rabbitmq)并执行以下步骤:

  1. 输入以下命令从 RabbitMQ 镜像运行容器:

    8080 and the server on port 5672.
    
  2. 容器部署完成后,运行以下命令以显示服务器日志:

    docker logs -f CONTAINER_ID 
    

    这就是启动日志的显示方式:

    图 6.7 – RabbitMQ 启动日志

    图 6.7 – RabbitMQ 启动日志

  3. 一旦服务器初始化完成,通过浏览器导航到localhost:8080。将显示一个 RabbitMQ 登录页面。使用您的用户/密码凭据登录。您将进入仪表板:图 6.8 – RabbitMQ 仪表板

    图 6.8 – RabbitMQ 仪表板

  4. 现在,创建一个消息队列,服务将把 RSS URL 推送到该队列。点击导航栏中的队列,然后点击添加新队列来创建一个新的队列:图 6.9 – 创建新的 RabbitMQ 队列

    图 6.9 – 创建新的 RabbitMQ 队列

  5. 确保将持久性字段设置为持久,以便在 RabbitMQ 断电时数据能够持久化到磁盘。

在 RabbitMQ 运行起来后,我们可以继续实现一个生产者服务,将传入的 RSS URL 推送到 RabbitMQ,以及一个消费者服务从队列中消费 URL。

探索生产者/消费者模式

在我们深入实现之前,我们需要探索生产者/消费者模式。以下架构说明了这两个概念:

图 6.10 – 生产者/消费者模式

图 6.10 – 生产者/消费者模式

现在主要概念已经清晰,让我们开始吧:

  1. 创建一个名为 producer 的新 Go 项目,并使用以下命令安装 RabbitMQ SDK for Golang:

    go get github.com/streadway/amqp
    
  2. 编写一个 main.go 文件,并使用以下代码片段设置与 RabbitMQ 服务器的 TCP 连接:

    var channelAmqp *amqp.Channel
    func init() {
       amqpConnection, err := amqp.Dial(os.Getenv(
          "RABBITMQ_URI"))
       if err != nil {
           log.Fatal(err)
       }
       channelAmqp, _ = amqpConnection.Channel()
    }
    

    AMQP 连接字符串将通过 RABBITMQ_URI 环境变量提供,以及密码。

  3. 接下来,在 /parse 端点定义一个 HTTP 处理器。该处理器将请求体中给出的 URL 使用 Publish 方法推送到 RabbitMQ 队列:

    func ParserHandler(c *gin.Context) {
       var request Request
       if err := c.ShouldBindJSON(&request); err != nil {
           c.JSON(http.StatusBadRequest, gin.H{
              "error": err.Error()})
           return
       }
       data, _ := json.Marshal(request)
       err := channelAmqp.Publish(
           "",
           os.Getenv("RABBITMQ_QUEUE"),
           false,
           false,
           amqp.Publishing{
               ContentType: "application/json",
               Body:        []byte(data),
           })
       if err != nil {
           fmt.Println(err)
           c.JSON(http.StatusInternalServerError, 
              gin.H{"error": "Error while publishing 
              to RabbitMQ"})
           return
       }
       c.JSON(http.StatusOK, map[string]string{
          "message": "success"})
    }
    func main() {
      router := gin.Default()
      router.POST("/parse", ParserHandler)
      router.Run(":5000")
    }
    
  4. 最后,使用 RABBITMQ_URIRABBITMQ_QUEUE 变量运行应用程序,如下所示:

    RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls go run main.go
    
  5. 然后,在 /parse 端点执行 POST 请求。你应该会收到一个 200 成功 消息,如图所示:![Figure 6.11 – 在 RabbitMQ 中发布数据

    ![img/Figure_6.11_B17115.jpg]

    图 6.11 – 在 RabbitMQ 中发布数据

  6. 返回 RabbitMQ 仪表板,转到 Queues 部分,并点击 rss_urls 队列。你应该会被重定向到 Queue metrics 页面。在这里,你会注意到队列中的一个消息:

![Figure 6.12 – 队列指标页面

![img/B17115_06_12_v2.jpg]

图 6.12 – 队列指标页面

在生产者服务运行起来后,你需要构建工作/消费者以消费 RabbitMQ 队列中可用的消息/URL:

  1. 创建一个名为 consumer 的新 Go 项目,并创建一个名为 main.go 的新文件。在文件内编写以下代码:

    func main() {
       amqpConnection, err := amqp.Dial(os.Getenv(
           "RABBITMQ_URI"))
       if err != nil {
           log.Fatal(err)
       }
       defer amqpConnection.Close()
       channelAmqp, _ := amqpConnection.Channel()
       defer channelAmqp.Close()
       forever := make(chan bool)
       msgs, err := channelAmqp.Consume(
           os.Getenv("RABBITMQ_QUEUE"),
           "",
           true,
           false,
           false,
           false,
           nil,
       )
       go func() {
           for d := range msgs {
               log.Printf("Received a message: %s", d.Body)
           }
       }()
       log.Printf(" [*] Waiting for messages. 
                  To exit press CTRL+C")
       <-forever
    }
    

    代码很简单:它设置了一个到 RabbitMQ 服务器的连接,并订阅了 rss_urls 队列。然后,它创建了一个无限循环,并从队列中获取一个消息,之后在控制台上显示消息体,并等待新消息。

  2. 通过传递 RabbitMQ URI 和队列名称作为环境变量来运行消费者项目:

    RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls go run main.go
    

    一旦启动,消费者将获取之前由生产者推送的消息,并在控制台上显示其内容。然后,它将从队列中删除该消息:

    ![Figure 6.13 – 从 RabbitMQ 订阅和获取消息

    ![img/B17115_06_13_v2.jpg]

    图 6.13 – 从 RabbitMQ 订阅和获取消息

  3. 通过刷新 Queue metrics 页面来验证消息已被删除。Queued messages 图表应确认此删除:

![Figure 6.14 – 从队列中删除消息

![img/Figure_6.14_B17115.jpg]

图 6.14 – 从队列中删除消息

这样,你的工作/消费者已经构建完成!

到目前为止,你已经看到消费者显示了消息的内容。现在,让我们更进一步,将消息体编码到 Request 结构体中,并通过调用我们之前提到的 GetFeedEntries 方法获取内容条目。然后,这些条目将被保存在 MongoDB 的 recipes 集合中:

go func() {
       for d := range msgs {
           log.Printf("Received a message: %s", d.Body)
           var request Request
           json.Unmarshal(d.Body, &request)
           log.Println("RSS URL:", request.URL)
           entries, _ := GetFeedEntries(request.URL)
           collection := mongoClient.Database(os.Getenv(
              "MONGO_DATABASE")).Collection("recipes")
           for _, entry := range entries[2:] {
               collection.InsertOne(ctx, bson.M{
                   "title":     entry.Title,
                   "thumbnail": entry.Thumbnail.URL,
                   "url":       entry.Link.Href,
               })
           }
       }
}()

重新运行应用程序,但这次,除了 RabbitMQ 参数外,还需要提供 MongoDB 连接参数:

RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" MONGO_DATABASE=demo go run main.go 

为了测试这一点,向生产服务器发送一个包含 RSS feed URL 的请求体中的 POST 请求。生产者将在 RabbitMQ 队列中发布该 URL。从那里,消费者将获取消息并获取 RSS URL 的 XML 响应,将响应编码为条目数组,并将结果保存到 MongoDB 中:

![图 6.15 – 消费者服务器日志img/B17115_06_15.jpg

图 6.15 – 消费者服务器日志

你可以向生产服务器发送多个 subreddit URL。这次,消费者将逐个获取 URL,如下所示:

![图 6.16 – 解析多个 RSS URLsimg/B17115_06_16.jpg

图 6.16 – 解析多个 RSS URLs

要查看已保存在 MongoDB 中的条目,构建一个简单的仪表板以列出 recipes 集合中的所有食谱。你可以从头创建一个新项目,或者在前一章中构建的 Web 应用程序上暴露一个额外的路由以提供食谱的 HTML 表示形式:

router.GET("/dashboard", IndexHandler)

然后,确保你更新 Recipe 结构体字段以反映 MongoDB 文档字段的架构:

type Recipe struct {
   Title     string `json:"title" bson:"title"`
   Thumbnail string `json:"thumbnail" bson:"thumbnail"`
   URL       string `json:"url" bson:"url"`
} 

路由处理程序将简单地调用 recipes 集合上的 Find 操作以返回所有食谱。然后,它将结果编码到 recipes 切片中。最后,它将 recipes 变量传递给 HTML 模板以显示结果:

func IndexHandler(c *gin.Context) {
   cur, err := collection.Find(ctx, bson.M{})
   if err != nil {
       c.JSON(http.StatusInternalServerError, 
           gin.H{"error": err.Error()})
       return
   }
   defer cur.Close(ctx)
   recipes := make([]Recipe, 0)
   for cur.Next(ctx) {
       var recipe Recipe
       cur.Decode(&recipe)
       recipes = append(recipes, recipe)
   }
   c.HTML(http.StatusOK, "index.tmpl", gin.H{
       "recipes": recipes,
   })
} 

以下为 HTML 模板的内容。它使用 Bootstrap 框架构建一个吸引人的用户界面。它还使用 range 关键字遍历 recipes 切片中的每个食谱并显示其详细信息(标题、缩略图图像和 Reddit URL):

<section class="container">
       <div class="row">
           <ul class="list-group">
               {{range .recipes}}
               <li class="list-group-item">
                   <div style="width: 100%;">
                       <img src="img/{{ .Thumbnail }}" 
                          class="card-img-top thumbnail">
                       <span class="title">{{ .Title 
                       }}</span>
                       <a href="{{ .URL }}" target="_blank"
                          class="btn btn-warning 
                          btn-sm see_recipe">See recipe</a>
                   </div>
               </li>
               {{end}}
           </ul>
       </div>
</section> 

将 Gin 服务器配置为在端口 3000 上运行,并使用 go run main.go 命令以及 MONGO_URIMONGO_DATABASE 变量来执行服务器。在你的浏览器中,转到 Localhost:3000/dashboard,除了返回食谱列表的地方,如下面的截图所示:

![图 6.17 – Trending Reddit recipesimg/B17115_06_17.jpg

图 6.17 – Trending Reddit recipes

注意

应用布局和样式表可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/blob/main/chapter06/dashboard/templates/index.tmpl

太棒了!你现在已经熟悉了如何使用像 RabbitMQ 这样的消息代理来扩展你的 Gin 分布式应用程序。在下一节中,我们将演示另一种通过 Docker 扩展 Gin 分布式应用程序的技术。

使用 Docker 副本来水平扩展

到目前为止,你已经学会了如何使用 Gin 框架和 RabbitMQ 构建生产者/消费者架构。在本节中,我们将介绍如何扩展消费者组件,以便我们可以将传入的工作量分配给多个消费者。

您可以通过构建消费者项目的 Docker 镜像并基于该镜像构建多个容器来实现这一点。Docker 镜像是不可变的,这保证了每次基于运行该镜像的镜像创建容器时,环境都是相同的。

以下架构图说明了如何使用多个消费者/工作者:

图 6.18 – 使用 Docker 规模化多个工作者

图 6.18 – 使用 Docker 规模化多个工作者

要创建 Docker 镜像,我们需要定义一个 Dockerfile – 一个包含运行消费者项目所有指令的蓝图。在您的工作者/消费者目录中创建一个 Dockerfile,内容如下:

FROM golang:1.16
WORKDIR /go/src/github.com/worker
COPY main.go go.mod go.sum ./
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest 
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/worker/app .
CMD ["./app"]

Dockerfile 使用多阶段构建功能构建一个轻量级 Docker 镜像。我们将在下一节中看到它是如何工作的。

使用 Docker 多阶段构建

在您的 Dockerfile 中的 FROM 语句和从一阶段复制工件到另一阶段,留下您在最终镜像中不需要的所有内容。

在前面的示例中,您使用了 golang:1.16 作为基础镜像来构建单个二进制文件。然后,第二个 FROM 指令以 Alpine 镜像作为其基础启动了新的构建阶段。从这里,您可以使用 COPY –from=0 指令从上一个阶段复制二进制文件。结果,您将得到一个小的 Docker 镜像。

要构建镜像,请运行以下命令。末尾的点很重要,因为它指向当前目录:

docker build -t worker . 

构建过程应在几秒钟内完成。然后,您将找到以下 Docker 构建日志:

图 6.19 – Docker 构建日志

图 6.19 – Docker 构建日志

如果您回顾前面的输出,您将看到 Docker 根据我们 Dockerfile 中的步骤记录了构建工作者镜像的每条指令。一旦镜像构建完成,运行以下命令以列出您机器上的可用镜像:

docker image ls

工作者/消费者镜像应列在列表的顶部:

图 6.20 – 工作者 Docker 镜像

图 6.20 – 工作者 Docker 镜像

使用 docker run 命令基于镜像运行容器。您需要使用 –e 标志提供 MongoDB 和 RabbitMQ 的 URI 作为环境变量。可以使用 –link 标志在容器内与 MongoDB 和 RabbitMQ 交互:

docker run -d -e MONGO_URI="mongodb://admin:password@mongodb:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" -e MONGO_DATABASE=demo2 -e RABBITMQ_URI="amqp://user:password@rabbitmq:5672/" -e RABBITMQ_QUEUE=rss_urls --link rabbitmq --link mongodb --name worker worker 

容器日志如下:

图 6.21 – 工作者的容器日志

图 6.21 – 工作者的容器日志

通过这样,您已经将工作者服务容器化了。接下来,我们将使用 Docker Compose 进行扩展。

使用 Docker Compose 规模化服务

Docker Compose 是一个基于 Docker 引擎构建的容器编排工具。它可以帮助您通过单个命令行管理应用程序堆栈或多个容器。

使用 Docker Compose 与创建 Docker 镜像一样简单:

  1. 在您项目的根目录下定义一个 docker-compose.yml 文件,并输入以下 YAML 代码:

    version: "3.9"
    services:
     worker:
       image: worker
       environment:
         - MONGO_URI="mongodb://admin:password
               @mongodb:27017/test?authSource=admin
               &readPreference=primary&ssl=false"
         - MONGO_DATABASE=demo2
         - RABBITMQ_URI=amqp://user:password@rabbitmq:5672
         - RABBITMQ_QUEUE=rss_urls
       networks:
         - app_network
       external_links:
         - mongodb
         - rabbitmq
    networks:
     app_network:
       external: true
    

    配置指定了工作节点所需的环境变量和网络拓扑。

  2. 定义一个外部网络,其中将运行工作节点、MongoDB 和 RabbitMQ 服务。执行以下命令:

    docker network create app_network
    
  3. 重新部署 RabbitMQ 和 MongoDB 容器,但这次,通过传递–network标志在自定义网络app_network中部署它们:

    docker run -d --name rabbitmq -e RABBITMQ_DEFAULT_USER=user -e RABBITMQ_DEFAULT_PASS=password -p 8080:15672 -p 5672:5672 --network app_network rabbitmq:3-management
    
  4. 容器配置正确后,执行以下命令以部署工作节点:

    docker-compose up -d
    

    -d标志指示 Docker Compose 在后台(分离模式)运行容器。

  5. 执行以下命令以列出正在运行的服务:![图 6.22 – Docker Compose 服务 图片 B17115_06_22.jpg

    图 6.22 – Docker Compose 服务

  6. 要扩展工作节点,重新运行之前的命令并带有–scale标志:

    docker-compose up -d --scale worker=5
    

    最终输出将如下所示:

    ![图 6.23 – 扩展五个工作节点 图片 B17115_06_23.jpg

    图 6.23 – 扩展五个工作节点

  7. 要测试一切,创建一个名为threads的文件,其中包含 Reddit 上最佳烹饪和食谱子版块的列表。以下列表因简洁而被裁剪:

    https://www.reddit.com/r/recipes/.rss
    https://www.reddit.com/r/food/.rss
    https://www.reddit.com/r/Cooking/.rss
    https://www.reddit.com/r/IndianFood/.rss
    https://www.reddit.com/r/Baking/.rss
    https://www.reddit.com/r/vegan/.rss
    https://www.reddit.com/r/fastfood/.rss
    https://www.reddit.com/r/vegetarian/.rss
    https://www.reddit.com/r/cookingforbeginners/.rss
    https://www.reddit.com/r/MealPrepSunday/.rss
    https://www.reddit.com/r/EatCheapAndHealthy/.rss
    https://www.reddit.com/r/Cheap_Meals/.rss
    https://www.reddit.com/r/slowcooking/.rss
    https://www.reddit.com/r/AskCulinary/.rss
    https://www.reddit.com/r/fromscratch/.rss
    
  8. 然后,编写一个bulk.shshell 脚本,逐行读取threads文件并发出对生产者服务的 POST 请求:

    #!/bin/bash
    while IFS= read -r thread
    do
       printf "\n$thread\n"
       curl -X POST http://localhost:5000/parse -d 
         '{"url":"$thread"}' http://localhost:5000/parse
    done < "threads" 
    
  9. 要运行脚本,添加执行权限,并使用以下命令执行文件:

    chmod +x bulk.sh
    ./bulk.sh 
    

    注意

    确保生产者服务正在运行,否则使用curl命令发出的 HTTP 请求将超时。

    脚本将逐行读取threads文件并发出 POST 请求,如下所示:

    ![图 6.24 – Shell 脚本输出 图片 B17115_06_24.jpg

    图 6.24 – Shell 脚本输出

  10. 运行docker-compose logs -f命令。这次,你应该注意到正在使用多个工作节点。此外,Docker Compose 为实例分配了颜色,并且每个消息都由不同的工作节点获取:

![图 6.25 – 在多个工作节点间分配工作负载图片 B17115_06_25.jpg

图 6.25 – 在多个工作节点间分配工作负载

这样,你已经成功地将工作负载分配到多个工作节点上。这种方法被称为水平扩展

注意

第八章中,在 AWS 上部署应用程序,我们将介绍如何在 AWS 上部署 Web 应用程序以及如何使用简单队列服务SQS)而不是 RabbitMQ 来扩展工作节点。

使用 NGINX 反向代理

在上一节中,你学习了如何扩展负责解析子版块 URL 的工作节点。在本节中,你将探索如何通过在反向代理后面提供服务来扩展我们在上一章中构建的 Recipes API。

最常用的反向代理之一是 Nginx。它面向客户端并接收传入的 HTTP(S)请求。然后,它以轮询的方式将它们重定向到 API 实例之一。要部署多个 Recipes API 实例,你将使用 Docker Compose 来编排容器。以下架构图说明了 单实例架构 与使用 Nginx 的 负载均衡多实例架构 之间的区别:

图 6.26 – 使用 Nginx 的负载均衡多实例架构

图 6.26 – 使用 Nginx 的负载均衡多实例架构

另一种扩展 Recipes API 的解决方案是垂直扩展,这包括增加服务运行系统的 CPU/RAM。然而,这种方法在长期来看往往有一些限制(不经济)。这就是为什么在这里,你将采用水平扩展方法,并将负载分配到多个 API 实例。

注意

Nginx 的替代方案是 Traefik (doc.traefik.io/traefik/)。这是一个以可扩展性为设计理念的开放源代码项目。

以下是对 Recipes API 输出的快速回顾:

图 6.27 – GET /recipes 端点响应

图 6.27 – GET /recipes 端点响应

要部署多个 Recipes API 实例,请按照以下步骤操作:

  1. 在包含你的 Recipes API 实现的文件夹中构建 Docker 镜像并编写 Dockerfile,内容如下:

    FROM golang:1.16
    WORKDIR /go/src/github.com/api
    COPY . .
    RUN go mod download
    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    FROM alpine:latest 
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY --from=0 /go/src/github.com/api/app .
    CMD ["./app"]
    
  2. 这个 Dockerfile 使用多阶段特性来传输一个轻量级镜像。docker build -t recipes-api. 命令的输出如下:图 6.28 – Docker 构建日志

    图 6.28 – Docker 构建日志

    构建镜像后,创建一个 docker-compose.yml 文件并定义三个服务:

    API:Recipes API 容器

    Redis:内存缓存数据库

    MongoDB:存储食谱的非关系型数据库

  3. 然后,将以下行添加到文件中:

    version: "3.9"
    services:
     api:
       image: recipes-api
       environment:
         - MONGO_URI=mongodb://admin:password
              @mongodb:27017/test?authSource=admin
              &readPreference=primary&ssl=false
         - MONGO_DATABASE=demo
         - REDIS_URI=redis:6379
       networks:
         - api_network
       external_links:
         - mongodb
         - redis
     redis:
       image: redis
       networks:
         - api_network
     mongodb:
       image: mongo:4.4.3
       networks:
         - api_network
       environment:
         - MONGO_INITDB_ROOT_USERNAME=admin
         - MONGO_INITDB_ROOT_PASSWORD=password
    networks:
       api_network:
    
  4. 接下来,定义一个 Nginx 服务,如下所示:

    nginx:
       image: nginx
       ports:
         - 80:80
       volumes:
         - $PWD/nginx.conf:/etc/nginx/nginx.conf
       depends_on:
         - api
       networks:
         - api_network 
    

    这段代码将本地的 nginx.conf 文件映射到容器内的 /etc/nginx/nginx.conf。该文件提供了 Nginx 如何处理传入的 HTTP 请求的指令。以下是一个简化版本:

    events {
       worker_connections 1024;
    }
    http {
     server_tokens off;
     server {
       listen 80;
       root  /var/www;
       location /api/ {
         proxy_set_header X-Forwarded-For $remote_addr;
         proxy_set_header Host            $http_host;
         proxy_pass http://api:8080/;
       }
     }
    } 
    
  5. location /api 中设置反向代理,将请求转发到运行在端口 8080(内部)的 API。

  6. 使用 docker-compose up –d 命令部署整个堆栈。然后,执行以下命令以显示正在运行的服务:

    docker-compose ps
    

    命令的输出如下:

    图 6.29 – 应用堆栈

    图 6.29 – 应用堆栈

  7. Nginx 服务在端口 80 上公开。访问 localhost/api/recipes;服务器将调用 Recipes API 并转发食谱列表响应,如下所示:图 6.30 – 使用 Nginx 转发 HTTP 响应

    图 6.30 – 使用 Nginx 转发 HTTP 响应

    注意

    对于生产使用,确保您使用 HTTPS 保护您的 API 端点非常重要。幸运的是,您可以使用 Nginx 的 "Let's Encrypt" 扩展来自动生成 TLS 证书。

  8. 要确保响应是从 Recipes API 转发的,请使用以下命令检查 Nginx 服务日志:

    docker-compose logs –f nginx 
    
  9. 您应该看到类似以下内容:

    /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
    /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
    /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
    10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
    10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
    /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
    /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
    /docker-entrypoint.sh: Configuration complete; ready for start up
    172.21.0.1 - - [21/Mar/2021:18:11:02 +0000] "GET /api/recipes HTTP/1.1" 200 2 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
    
  10. 到目前为止,Recipes API 容器的一个实例正在运行。要扩展它,使用 docker-compose up 命令中的 –scale 标志或在 docker-compose.yml 文件中定义副本数量,如下所示:

    api:
       image: recipes-api
       environment:
         - MONGO_URI=mongodb://admin:password
              @mongodb:27017/test?authSource=admin
              &readPreference=primary&ssl=false
         - MONGO_DATABASE=demo
         - REDIS_URI=redis:6379
       networks:
         - api_network
       external_links:
         - mongodb
         - redis
       scale: 5
    
  11. 重新执行 docker-compose up 命令。将根据 Recipes API Docker 镜像创建四个额外的服务。以下是对服务日志的描述:图 6.31 – 扩展 Recipes API

    图 6.31 – 扩展 Recipes API

    现在,当客户端发送请求时,它将击中 Nginx,然后以轮询方式转发到 API 服务之一。这有助于我们均匀地分配负载。

    注意

    第十章*,捕获 Gin 应用程序指标* 中,我们将学习如何设置监控平台以在需求增加时触发扩展事件来增加服务数量。

    使用反向代理的优势在于,您可以为您整个分布式 Web 应用程序设置一个单一的入口点。后端和 Web 应用程序都将位于相同的 URL 上。这样,您就不需要在 API 服务器上处理 CORS。

  12. 与 Recipes API 类似,为 react-ui 服务创建一个 Docker 镜像。以下是我们 Dockerfile 的内容:

    FROM node:14.15.1
    COPY package-lock.json .
    COPY package.json .
    RUN npm install
    CMD npm start
    

    如您所见,这非常简单。在这里,您正在使用预构建的 Node.js 基础镜像,因为 react-ui 服务是用 JavaScript 编写的。

  13. 使用以下命令构建 Docker 镜像 docker build -t dashboard。然后,更新 docker-compose.yml 以运行以下代码块中的 Docker 服务:

    dashboard:
       image: dashboard
       networks:
         - api_network
    
  14. 接下来,更新 nginx.conf 以确保它将传入请求转发到 URL 的根级别,到仪表板服务:

    events {
       worker_connections 1024;
    }
    http {
     server_tokens off;
     server {
       listen 80;
       root  /var/www;
       location / {
         proxy_set_header X-Forwarded-For $remote_addr;
         proxy_set_header Host            $http_host;
         proxy_pass http://dashboard:3000/;
       }
       location /api/ {
         proxy_set_header X-Forwarded-For $remote_addr;
         proxy_set_header Host            $http_host;
         proxy_pass http://api:8080/;
       }
     }
    }
    
  15. 重新运行 docker-compose up -d 命令以使更改生效:图 6.32 – 从 Nginx 提供网络仪表板

    图 6.32 – 从 Nginx 提供网络仪表板

  16. 访问 localhost/dashboard;您将被重定向到我们在上一章中编写的 Web 仪表板:

图 6.33 – 从同一 URL 提供两个后端

图 6.33 – 从同一 URL 提供两个后端

现在,RESTful API 和仪表板都从同一域名提供服务。

要关闭您的容器,您可以使用以下命令:

docker-compose down

注意

如果您使用基于会话的认证,您需要在 Nginx 上配置 cookie stickiness 以保持用户会话在启动它的服务器上。

您还可以通过将仪表板位置替换为另一个位置部分,从 Nginx 服务器提供 subreddits 应用(在本章开头构建),到nginx.conf文件:

location /reddit/ {
     proxy_set_header X-Forwarded-For $remote_addr;
     proxy_set_header Host            $http_host;
     proxy_pass http://reddit-trending:3000/;
}

react-ui类似,为 subreddits 应用创建一个 Docker 镜像。以下是我们Dockerfile的内容:

FROM node:14.15.1
COPY . .
COPY package-lock.json .
COPY package.json .
RUN npm install
CMD npm start

这里,您正在使用预构建的 Node.js 基础镜像,因为仪表板服务是用 JavaScript 编写的。使用"docker build -t dashboard"构建 Docker 镜像。

此外,别忘了将应用添加到docker-compose.yml文件中:

  reddit-trending:
    image: web
    networks:
      - api_network 

一旦您使用docker-compose重新部署了堆栈,请转到localhost/reddit;您将被重定向到以下 UI:

图 6.34 – 热门食谱应用

图 6.34 – 热门食谱应用

应用布局损坏是因为app.css文件正在从错误的后端提供服务。您可以通过在 Chrome 中打开调试控制台来确认这一点:

图 6.35 – 样式表位置

图 6.35 – 样式表位置

您可以通过在nginx.conf中添加以下代码块来强制 Nginx 从 subreddit 应用容器中提供app.css文件:

location /assets/css/app.css {
     proxy_set_header X-Forwarded-For $remote_addr;
     proxy_set_header Host            $http_host;
     proxy_pass http://reddit-trending:3000/assets
       /css/app.css;
}

现在,刷新网页;应用布局将被修复:

图 6.36 – 应用布局

图 6.36 – 应用布局

如您所见,仪表板显示了食谱缩略图。每次刷新页面时,这些图片都会从后端提供。为了减轻后端的压力,您可以配置 Nginx 来缓存静态文件。在 Nginx 配置文件中的server部分之前插入以下代码片段:

map $sent_http_content_type $expires {
   default                    off;
   text/html                  epoch;
   text/css                   max;
   application/javascript     max;
   ~image/                    max;
}

~image关键字将处理所有类型的图片(PNG、JPEG、GIF、SVG 等)。现在,在server部分使用expires指令配置过期时间:

http {
 server {
   listen 80;
   expires $expires;
   ...
 }
}

然后,使用以下命令重新部署堆栈:

docker-compose up –d

现在图片应该被缓存,这减少了击中后端请求的数量。在下一节中,我们将介绍如何使用 Gin 在后台获得相同的结果。

使用 HTTP 缓存头缓存资源

您还可以使用 Gin 框架管理缓存。为了说明这一点,编写一个简单的 Web 应用来提供图片。代码如下:

func IllustrationHandler(c *gin.Context) {
   c.File("illustration.png")
}
func main() {
   router := gin.Default()
   router.GET("/illustration", IllustrationHandler)
   router.Run(":3000")
}

当用户点击/illustration资源 URL 时,应用应提供一张图片:

图 6.37 – 使用 Gin 提供图片

图 6.37 – 使用 Gin 提供图片

由于相同的图片总是被发送,我们需要确保我们正在缓存图片。这样,我们可以避免不必要的流量并提高网页性能。让我们看看这是如何实现的。

设置 HTTP 缓存头

要缓存此 HTTP 请求,您可以将一个带有Etag键值的If-None-Match字段附加到请求中。如果If-None-Match字段与生成的键匹配,则返回 304 状态码:

func IllustrationHandler(c *gin.Context) {
   c.Header("Etag", "illustration")
   c.Header("Cache-Control", "max-age=2592000")
   if match := c.GetHeader("If-None-Match"); match != "" {
       if strings.Contains(match, "illustration") {
           c.Writer.WriteHeader(http.StatusNotModified)
           return
       }
   }
   c.File("illustration.png")
}

一旦你使用前面的代码更新了 HTTP 处理程序,就测试一下。第一次你请求 /illustration 资源时,你应该得到状态 200 OK。然而,对于第二次请求,你应该得到 304 StatusNotModified 响应:

![图 6.38 – 使用 Gin 的响应缓存img/Figure_6.38_B17115.jpg

图 6.38 – 使用 Gin 的响应缓存

你可能也注意到,第二次请求的延迟比第一次短。通过将查询数量保持在最低,你可以减轻 API 对应用程序性能的影响。

摘要

在本章中,你学习了如何使用基于微服务架构的 Gin 框架构建分布式网络应用程序。

你还探索了如何设置 RabbitMQ 作为微服务之间的消息代理,以及如何使用 Docker 扩展这些服务。在这个过程中,你学习了如何使用 Docker 的多阶段构建功能来维护服务镜像的大小,以及如何使用 Nginx 和 HTTP 缓存头来提高 API 的性能。

在下一章中,你将学习如何为 Gin 网络应用程序编写单元和集成测试。

进一步阅读

  • 《RabbitMQ 精要 – 第二版》,作者:Lovisa Johansson,Packt 出版

  • 《开发者 Docker》,作者:Richard Bullington-McGuire,Andrew K. Dennis,Michael Schwartz,Packt 出版

第三部分:超越基础

这最后一部分将通过引入高级主题,如测试、故障排除和监控,来改进上一部分构建的 API,并使其具备生产就绪状态。在这个过程中,你将使用 CI/CD 管道自动化 API 的部署,并扩展其以支持大型工作负载。本节包括以下章节:

  • 第七章, 测试 Gin HTTP 路由

  • 第八章, 在 AWS 上部署应用程序

  • 第九章, 实现 CI/CD 管道

  • 第十章, 捕获 Gin 应用程序指标

第七章:测试 Gin HTTP 路由

在本章中,您将学习如何测试基于 Gin 的 Web 应用程序,这涉及到运行 Go 单元和集成测试。在这个过程中,我们将探讨如何集成外部工具来识别 Gin Web 应用程序中的潜在安全漏洞。最后,我们将介绍如何使用 Postman 集合运行器功能测试 API HTTP 方法。

因此,我们将涵盖以下主题:

  • 测试 Gin HTTP 处理器

  • 生成代码覆盖率报告

  • 发现安全漏洞

  • 运行 Postman 集合

到本章结束时,您应该能够从头开始编写、执行和自动化 Gin Web 应用程序的测试。

技术要求

要遵循本章中的说明,您需要以下内容:

  • 完全理解前一章的内容——本章是前一章的后续,它将使用相同的源代码。因此,一些代码片段将不会进行解释,以避免重复。

  • 使用 Go 测试包的先前经验。

本章的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter07

测试 Gin HTTP 处理器

到目前为止,我们已经学习了如何使用 Gin 框架设计、构建和扩展分布式 Web 应用程序。在本章中,我们将介绍如何集成不同类型的测试以消除发布时可能出现的错误。我们将从 单元测试 开始。

注意

值得注意的是,您需要事先采用 测试驱动开发TDD)的方法,以便在编写可测试的代码方面取得领先。

为了说明如何为 Gin Web 应用程序编写单元测试,您需要直接进入一个基本示例。让我们以 第二章 中涵盖的“设置 API 端点”为例,即 hello world 示例。路由声明和 HTTP 服务器设置已从 main 函数中提取出来,以便为测试做准备,如下面的代码片段所示:

package main
import (
   "net/http"
   "github.com/gin-gonic/gin"
)
func IndexHandler(c *gin.Context) {
   c.JSON(http.StatusOK, gin.H{
       "message": "hello world",
   })
}
func SetupServer() *gin.Engine {
   r := gin.Default()
   r.GET("/", IndexHandler)
   return r
}
func main() {
   SetupServer().Run()
}

运行应用程序,然后在 localhost:8080 上发出一个 GET 请求。将返回一个 hello world 消息,如下所示:

curl localhost:8080
{"message":"hello world"}

在重构完成后,用 Go 编程语言编写一个单元测试。为此,请执行以下步骤:

  1. 在同一项目目录中定义一个 main_test.go 文件,并包含以下代码。我们之前重构的 SetupServer() 方法被注入到一个测试服务器中:

    package main
    func TestIndexHandler(t *testing.T) {
       mockUserResp := `{"message":"hello world"}`
       ts := httptest.NewServer(SetupServer())
       defer ts.Close()
       resp, err := http.Get(fmt.Sprintf("%s/", ts.URL))
       if err != nil {
           t.Fatalf("Expected no error, got %v", err)
       }
       defer resp.Body.Close()
       if resp.StatusCode != http.StatusOK {
           t.Fatalf("Expected status code 200, got %v", 
                    resp.StatusCode)
       }
       responseData, _ := ioutil.ReadAll(resp.Body)
       if string(responseData) != mockUserResp {
           t.Fatalf("Expected hello world message, got %v", 
                     responseData)
       }
    }
    

    每个测试方法都必须以 Test 前缀开始——例如,TestXYZ 将是一个有效的测试。前面的代码使用 Gin 引擎设置了一个测试服务器并发出一个 GET 请求。然后,它检查状态码和响应负载。如果实际结果与预期结果不匹配,将抛出一个错误。因此,测试将失败。

  2. 要在 Golang 中运行测试,请执行以下命令:

    go test
    

    如下所示的屏幕截图所示,测试将成功执行:

![Figure 7.1 – Test executionimg/Figure_7.1_B17115.jpg

图 7.1 – 测试执行

虽然你可以使用测试包编写完整的测试,但你也可以安装一个第三方包,如 testify,以使用高级断言。为此,请按照以下步骤操作:

  1. 使用以下命令下载 testify:

    Go get github.com/stretchr/testify
    
  2. 接下来,更新 TestIndexHandler 以使用 testify 包的 assert 属性对响应的正确性进行一些断言,如下所示:

    func TestIndexHandler(t *testing.T) {
       mockUserResp := `{"message":"hello world"}`
       ts := httptest.NewServer(SetupServer())
       defer ts.Close()
       resp, err := http.Get(fmt.Sprintf("%s/", ts.URL))
       defer resp.Body.Close()
       assert.Nil(t, err)
       assert.Equal(t, http.StatusOK, resp.StatusCode)
       responseData, _ := ioutil.ReadAll(resp.Body)
       assert.Equal(t, mockUserResp, string(responseData))
    }
    
  3. 执行 go test 命令,你将得到相同的结果。

这就是为 Gin 网络应用程序编写测试的方法。

让我们继续前进,为之前章节中涵盖的 RESTful API 的 HTTP 处理器编写单元测试。作为提醒,以下架构图说明了 REST API 提供的操作:

![Figure 7.2 – API HTTP 方法img/Figure_7.2_B17115.jpg

图 7.2 – API HTTP 方法

注意

API 源代码位于 GitHub 仓库中的 chapter07 文件夹下。建议基于仓库中可用的源代码开始本章的学习。

图像中的操作已在 Gin 默认路由器中注册,并分配给不同的 HTTP 处理器,如下所示:

func main() {
   router := gin.Default()
   router.POST("/recipes", NewRecipeHandler)
   router.GET("/recipes", ListRecipesHandler)
   router.PUT("/recipes/:id", UpdateRecipeHandler)
   router.DELETE("/recipes/:id", DeleteRecipeHandler)
   router.GET("/recipes/:id", GetRecipeHandler)
   router.Run()
}

从一个 main_test.go 文件开始,定义一个方法来返回 Gin 路由的实例。然后,为每个 HTTP 处理器编写一个测试方法。例如,TestListRecipesHandler 处理器在下面的代码片段中显示:

func SetupRouter() *gin.Engine {
   router := gin.Default()
   return router
}
func TestListRecipesHandler(t *testing.T) {
   r := SetupRouter()
   r.GET("/recipes", ListRecipesHandler)
   req, _ := http.NewRequest("GET", "/recipes", nil)
   w := httptest.NewRecorder()
   r.ServeHTTP(w, req)
   var recipes []Recipe
   json.Unmarshal([]byte(w.Body.String()), &recipes)
   assert.Equal(t, http.StatusOK, w.Code)
   assert.Equal(t, 492, len(recipes))
}

它在 GET /recipes 资源上注册了 ListRecipesHandler 处理器,然后发出一个 GET 请求。请求有效载荷随后被编码到 recipes 切片中。如果食谱的数量等于 492 且状态码为 200-OK 响应,则测试被认为是成功的。否则,将抛出错误,测试将失败。

然后,发出一个 go test 命令,但这次,禁用 Gin 调试日志并使用 -v 标志启用详细模式,如下所示:

GIN_MODE=release go test -v

命令输出如下所示:

![Figure 7.3 – 运行测试与详细输出img/Figure_7.3_B17115.jpg

图 7.3 – 以详细输出运行测试

注意

第十章*,捕获 Gin 应用程序指标* 中,我们将介绍如何自定义 Gin 调试日志以及如何将它们发送到集中式日志平台。

类似地,为 NewRecipeHandler 处理器编写一个测试。它将简单地发布一个新的食谱并检查返回的响应代码是否为 200-OK 状态。TestNewRecipeHandler 方法在下面的代码片段中显示:

func TestNewRecipeHandler(t *testing.T) {
   r := SetupRouter()
   r.POST("/recipes", NewRecipeHandler)
   recipe := Recipe{
       Name: "New York Pizza",
   }
   jsonValue, _ := json.Marshal(recipe)
   req, _ := http.NewRequest("POST", "/recipes", 
                              bytes.NewBuffer(jsonValue))
   w := httptest.NewRecorder()
   r.ServeHTTP(w, req)
   assert.Equal(t, http.StatusOK, w.Code)
}

在前面的测试方法中,你使用 Recipe 结构体声明了一个食谱。然后,该结构体被序列化为 NewRequest 函数。

执行测试,TestListRecipesHandlerTestNewRecipeHandler 都应该成功,如下所示:

![Figure 7.4 – 运行多个测试img/Figure_7.4_B17115.jpg

图 7.4 – 运行多个测试

您现在已经熟悉了为 Gin HTTP 处理器编写单元测试。接下来,继续编写其余 API 端点的测试。

生成代码覆盖率报告

在本节中,我们将介绍如何使用 Go 生成覆盖率报告。测试覆盖率描述了通过运行包的测试来执行包中代码的程度。

运行以下命令以生成一个文件,该文件包含有关您在上一节中编写的测试覆盖了多少代码的统计信息:

GIN_MODE=release go test -v -coverprofile=coverage.out ./...

命令将运行测试并显示测试覆盖的语句百分比。在以下示例中,我们覆盖了 16.9%的语句:

![图 7.5 – 测试覆盖率图片

图 7.5 – 测试覆盖率

生成的coverage.out文件包含单元测试覆盖的行数。为了简洁,已裁剪完整代码,但您可以在以下示例中看到说明:

mode: set
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:51.41,53.2 1 1
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:65.39,67.50 2 1
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:72.2,77.31 4 1
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:67.50,70.3 2 0
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:98.42,101.50 3 0
/Users/mlabouardy/github/Building-Distributed-Applications-in-Gin/chapter7/api-without-db/main.go:106.2,107.36 2 0

您可以使用go tool命令可视化代码覆盖率,如下所示:

go tool cover -html=coverage.out

命令将在您的默认浏览器中打开 HTML 演示文稿,显示绿色覆盖的源代码和红色未覆盖的代码,如下截图所示:

![图 7.6 – 查看结果图片

图 7.6 – 查看结果

现在更容易发现哪些方法被测试覆盖了,让我们为负责更新现有配方的 HTTP 处理器编写一个额外的测试。为此,请按照以下步骤操作:

  1. 将以下代码块添加到main_test.go文件中:

    func TestUpdateRecipeHandler(t *testing.T) {
       r := SetupRouter()
       r.PUT("/recipes/:id", UpdateRecipeHandler)
       recipe := Recipe{
           ID:   "c0283p3d0cvuglq85lpg",
           Name: "Gnocchi",
           Ingredients: []string{
               "5 large Idaho potatoes",
               "2 egges",
               "3/4 cup grated Parmesan",
               "3 1/2 cup all-purpose flour",
           },
       }
       jsonValue, _ := json.Marshal(recipe)
       reqFound, _ := http.NewRequest("PUT", 
          "/recipes/"+recipe.ID, bytes.NewBuffer(jsonValue))
       w := httptest.NewRecorder()
       r.ServeHTTP(w, reqFound)
       assert.Equal(t, http.StatusOK, w.Code)
       reqNotFound, _ := http.NewRequest("PUT", "/recipes/1", 
          bytes.NewBuffer(jsonValue))
       w = httptest.NewRecorder()
       r.ServeHTTP(w, reqNotFound)
       assert.Equal(t, http.StatusNotFound, w.Code)
    }
    

    代码发出两个 HTTP PUT请求。

    其中一个具有有效的配方 ID,并检查 HTTP 响应代码(200-OK)。

    另一个具有无效的配方 ID,并检查 HTTP 响应代码(404-Not found)。

  2. 重新执行测试,覆盖率百分比应从 16.9%增加到 39.0%。以下输出证实了这一点:

![图 7.7 – 更多代码覆盖率图片

图 7.7 – 更多代码覆盖率

太棒了!您现在能够运行单元测试并获取代码覆盖率报告。所以,继续前进,测试并覆盖。

虽然单元测试是软件开发的重要部分,但同样重要的是,您编写的代码不仅要在隔离状态下进行测试。集成和端到端测试通过测试应用程序的各个部分来提供额外的信心。这些部分可能单独工作得很好,但在大型系统中,代码单元很少单独工作。这就是为什么在下一节中,我们将介绍如何编写和运行集成测试。

使用 Docker 执行集成测试

集成测试的目的是验证分离开发的组件是否能够正确地协同工作。与单元测试不同,集成测试可以依赖于数据库和外部服务。

到目前为止编写的分布式 Web 应用程序与外部服务 MongoDB 和 Reddit 进行交互,如下截图所示:

![图 7.8 – 分布式 Web 应用程序图片

图 7.8 – 分布式 Web 应用程序

要开始进行集成测试,请按照以下步骤操作:

  1. 使用 Docker Compose 运行我们集成测试所需的服务。以下 docker-compose.yml 文件将启动 MongoDB 和 Redis 容器:

    version: "3.9"
    services:
     redis:
       image: redis
       ports:
         - 6379:6379
     mongodb:
       image: mongo:4.4.3
       ports:
         - 27017:27017
       environment:
         - MONGO_INITDB_ROOT_USERNAME=admin
         - MONGO_INITDB_ROOT_PASSWORD=password
    
  2. 现在,测试 RESTful API 暴露的每个端点。例如,要测试列出所有菜谱的端点,我们可以使用以下代码块:

    func TestListRecipesHandler(t *testing.T) {
       ts := httptest.NewServer(SetupRouter())
       defer ts.Close()
       resp, err := http.Get(fmt.Sprintf("%s/recipes", 	 	                                     ts.URL))
       defer resp.Body.Close()
       assert.Nil(t, err)
       assert.Equal(t, http.StatusOK, resp.StatusCode)
       data, _ := ioutil.ReadAll(resp.Body)
       var recipes []models.Recipe
       json.Unmarshal(data, &recipes)
       assert.Equal(t, len(recipes), 10)
    }
    
  3. 要运行测试,请提供 MongoDB go test 命令,如下所示:

    MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin&readPreference=primary&ssl=false" MONGO_DATABASE=demo REDIS_URI=localhost:6379 go test
    

太好了!测试将成功通过,如下所示:

![图 7.9 – 运行集成测试]

图片 7.9

![图 7.9 – 运行集成测试]

测试向 /recipes 端点发出 GET 请求,并验证端点返回的菜谱数量是否等于 10。

另一个重要但常被忽视的测试是 安全测试。确保您的应用程序没有主要安全漏洞是强制性的,否则数据泄露和数据泄露的风险很高。

发现安全漏洞

有许多工具可以帮助您识别 Gin Web 应用程序中的主要安全漏洞。在本节中,我们将介绍两个工具,这些工具是您在构建 Gin 应用程序时可以采用的几个工具之一:SnykGolang 安全检查器Gosec)。

在接下来的章节中,我们将展示如何使用这些工具来检查 Gin 应用程序中的安全漏洞。

Gosec

Gosec 是一个用 Golang 编写的工具,通过扫描 Go 抽象语法树AST)来检查源代码中的安全问题。在我们检查 Gin 应用程序代码之前,我们需要安装 Gosec 二进制文件。

可以使用以下 cURL 命令下载二进制文件。这里使用的是版本 2.7.0:

curl -sfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.0

一旦安装了命令,请在项目文件夹中运行以下命令。./... 参数设置为递归扫描所有 Go 包:

gosec ./...

命令将识别与未处理错误相关的三个主要问题(常见弱点枚举CWE703(https://cwe.mitre.org/data/definitions/703.html),如下截图所示:

![图 7.10 – 未处理的错误]

图片 7.10

![图 7.10 – 未处理的错误]

默认情况下,Gosec 将扫描您的项目并对其规则进行验证。但是,您可以排除一些规则。例如,要排除负责未处理错误问题的规则,请发出以下命令:

gosec -exclude=G104 ./...

注意

可用规则的完整列表可以在以下位置找到:

github.com/securego/gosec#available-rules

命令输出如下所示:

![图 7.11 – 排除 Gosec 规则]

图片 7.11

![图 7.11 – 排除 Gosec 规则]

现在,你应该能够扫描应用程序源代码以查找潜在的安全漏洞或错误来源。

使用 Snyk 保护 Go 模块

另一种检测潜在安全漏洞的方法是扫描 Go 模块。go.mod 文件包含了 Gin 网络应用程序所使用的所有依赖项。Snyk (snyk.io) 是一种用于识别和修复 Go 应用程序中安全漏洞的 软件即服务 (SaaS) 解决方案。

注意

Snyk 支持包括 Java、Python、Node.js、Ruby、Scala 等在内的所有主要编程语言。

解决方案相当简单。要开始,请按照以下步骤操作:

  1. 使用您的 GitHub 账户登录创建一个免费账户。

  2. 然后,使用 Node 包管理器 (npm) 安装 Snyk 官方 命令行界面 (CLI),如下所示:

    npm install -g snyk
    
  3. 接下来,通过运行以下命令将您的 Snyk 账户与 CLI 关联:

    snyk auth
    

    上述命令将打开一个浏览器标签页,并重定向您使用 Snyk 账户验证 CLI。

  4. 现在,您应该准备好使用以下命令扫描项目漏洞:

    snyk test
    

    上述命令将列出所有识别出的漏洞(主要或次要),包括它们的路径和修复指南,如下截图所示:

    图 7.12 – Snyk 漏洞发现

    图 7.12 – Snyk 漏洞发现

  5. 根据输出,Snyk 识别出两个主要问题。其中一个是与当前版本的 Gin 框架有关。点击 Info URL—you will be redirected to a dedicated page where you can learn more about the vulnerability, as illustrated in the following screenshot:图 7.13 – HTTP 响应拆分页面

    图 7.13 – HTTP 响应拆分页面

  6. 大多数安全漏洞可以通过升级到最新稳定版本来解决。运行以下命令升级您的项目依赖项:

    go.mod file will be upgraded to the latest available version, as illustrated in the following screenshot:
    

图 7.14 – 升级 Go 包

图 7.14 – 升级 Go 包

对于发现的漏洞,GitHub 上有一个已合并并可在 Gin 1.7 版本中找到的公开拉取请求,如下截图所示:

图 7.15 – 漏洞修复

图 7.15 – 漏洞修复

那就结束了——现在您已经知道如何使用 Snyk 扫描您的 Go 模块了!

注意

我们将在 第九章*,实现 CI/CD 流水线* 中介绍如何将 Snyk 集成到 持续集成/持续部署 (CI/CD) 流水线中,以持续检查应用程序的源代码中的安全漏洞。

运行 Postman 集合

在本书中,您已经学习了如何使用 Postman REST 客户端测试 API 端点。除了发送 API 请求外,Postman 还可以通过在集合中定义一组 API 请求来构建测试套件。

要设置此功能,请按照以下步骤操作:

  1. 打开 Postman 客户端,然后从标题栏点击新建按钮,然后选择集合,如图下所示截图:图 7.16 – 新建 Postman 集合

    图 7.16 – 新建 Postman 集合

  2. 将弹出一个新窗口——将集合命名为Recipes API,然后点击List Recipes,如图下所示截图:图 7.17 – 新建请求

    图 7.17 – 新建请求

  3. 在地址栏中点击http://localhost:8080/recipes并选择GET方法。

好的——现在,一旦完成这些,你将在测试部分编写一些 JavaScript 代码。

在 Postman 中,你可以编写将在发送请求之前(预请求脚本)或接收响应之后执行的 JavaScript 代码。让我们在下一节中探讨如何实现这一点。

Postman 中的脚本编写

测试脚本可以用来测试你的 API 是否按预期工作,或者检查新功能是否影响了现有请求的功能。

要编写脚本,请按以下步骤操作:

  1. 点击测试部分,粘贴以下代码:

    pm.test("More than 10 recipes", function () {
       var jsonData = pm.response.json();
       pm.expect(jsonData.length).to.least(10)
    });
    

    脚本将检查 API 请求返回的菜谱数量是否等于 10 个菜谱,如图下所示截图:

    图 7.18 – Postman 中的脚本编写

    图 7.18 – Postman 中的脚本编写

  2. 点击发送按钮,并检查 Postman 控制台,如图下所示截图:

图 7.19 – 运行测试脚本

图 7.19 – 运行测试脚本

你可以在图 7.19中看到测试脚本已通过。

你可能已经注意到 API URL 硬编码在地址栏中。虽然这样工作得很好,但如果你在维护多个环境(沙盒、预发布和生产),你需要一种方式来测试你的 API 端点而不必复制你的集合请求。幸运的是,你可以在 Postman 中创建环境变量。

要使用 URL 参数,请按以下步骤操作:

  1. 点击右上角的眼睛图标,然后点击http://localhost:8080,如图下所示截图。点击保存图 7.20 – 环境变量

    图 7.20 – 环境变量

  2. 返回到你的GET请求,并使用以下 URL 变量。确保从右上角的下拉菜单中选择测试环境,如图下所示截图:图 7.21 – 参数化请求

    图 7.21 – 参数化请求

  3. 现在,继续添加另一个 API 请求的测试脚本。以下脚本将在响应负载中查找特定的菜谱:

    pm.test("Gnocchi recipe", function () {
       var jsonData = pm.response.json();
       var found = false;
       jsonData.forEach(recipe => {
           if (recipe.name == 'Gnocchi') {
               found = true;
           }
       })
       pm.expect(found).to.true
    });
    
  4. 点击发送按钮,两个测试脚本都应成功,如图所示:

图 7.22 – 运行多个测试脚本

图 7.22 – 运行多个测试脚本

您现在可以为您的 API 端点定义多个测试用例场景。

让我们更进一步,创建另一个 API 请求,这次是为添加新食谱的端点,如下面的截图所示:

![Figure 7.23 – 新食谱请求Figure 7.23_B17115.jpg

图 7.23 – 新食谱请求

要这样做,请按照以下步骤操作:

  1. 定义一个测试脚本,以检查在成功插入操作返回的 HTTP 状态码是否为200-OK代码,如下所示:

    pm.test("Status code is 200", function () {
       pm.response.to.have.status(200);
    });
    
  2. 定义另一个来检查插入的 ID 是否为 24 个字符的字符串,如下所示:

    pm.test("Recipe ID is not null", function(){
       var id = pm.response.json().id;
       pm.expect(id).to.be.a("string");
       pm.expect(id.length).to.eq(24);
    })
    
  3. 点击401 – Unauthorized,这是正常的,因为端点期望在 HTTP 请求中有一个授权头。您可以在以下截图中看到输出:![Figure 7.24 – 401 Unauthorized response Figure 7.24_B17115.jpg

    图 7.24 – 401 未授权响应

    注意

    要了解更多关于 API 认证的信息,请回到第四章构建 API 认证,以获取逐步指南。

  4. 添加一个有效的JSON Web TokenJWT)的Authorization头。这次,测试脚本成功通过!

  5. 您现在在集合中有两个不同的 API 请求。通过点击运行按钮来运行集合。将弹出一个新窗口,如下面的截图所示:![Figure 7.25 – 集合运行器 Figure 7.25_B17115.jpg

    图 7.25 – 集合运行器

  6. 点击运行食谱 API按钮,两个 API 请求将按顺序执行,如下面的截图所示!Figure 7.26_B17115.jpg

    图 7.26 – 运行结果屏幕

  7. 您可以通过点击导出按钮导出集合和所有 API 请求。应该会创建一个具有以下结构的 JSON 文件:

    {
       "info": {},
       "item": [
           {
               "name": "New Recipe",
               "event": [
                   {
                       "listen": "test",
                       "script": {
                           "exec": [
                               "pm.test(\"Recipe ID is not 
                                   null\", function(){",
                               "var id = pm.response
                                     .json().id;",
                               "pm.expect(id).
                                       to.be.a(\"string\");",
                               "pm.expect(id.length)
                                       .to.eq(24);",
                               "})"
                           ],
                           "type": "text/javascript"
                       }
                   }
               ],
               "request": {
                   "method": "POST",
                   "header": [],
                   "body": {
                       "mode": "raw",
                       "raw": "{\n    \"name\": \"New York 
                                Pizza\"\n}",
                       "options": {
                           "raw": {
                               "language": "json"
                           }
                       }
                   },
                   "url": {
                       "raw": "{{url}}/recipes",
                       "host": [
                           "{{url}}"
                       ],
                       "path": [
                           "recipes"
                       ]
                   }
               },
               "response": []
           }
       ],
       "auth": {}
    }
    

导出 Postman 集合后,您可以使用Newman从终端运行它(github.com/postmanlabs/newman)。

在下一节中,我们将使用 Newman CLI 运行之前的 Postman 集合。

使用 Newman 运行集合

在定义了所有测试后,让我们使用 Newman 命令行执行它们。值得一提的是,您可以将这些测试进一步运行在您的 CI/CD 工作流程中作为后集成测试,以确保新的 API 更改并且功能没有生成任何回归。

要开始,请按照以下步骤操作:

  1. 使用 npm 安装Newman。这里我们使用版本 5.2.2:

    npm install -g newman
    
  2. 安装完成后,使用导出的集合文件作为参数运行 Newman,如下所示:

    newman run postman.json
    

    由于 URL 参数没有被定义,API 请求应该失败,如下面的截图所示:

    ![Figure 7.27 – 包含失败测试的集合 Figure 7.27_B17115.jpg

    图 7.27 – 包含失败测试的集合

  3. 您可以使用--env-var标志来设置其值,如下所示:

    newman run postman.json --env-var "url=http://localhost:8080"
    

    如果所有调用都通过,则应该是以下输出:

![Figure 7.28 – 包含成功测试的集合Figure 7.28_B17115.jpg

图 7.28 – 成功测试的集合

你现在应该能够使用 Postman 自动化 API 端点测试。

注意

第十章“捕获 Gin 应用程序指标”中,我们将介绍如何在 CI/CD 管道中成功发布应用程序后触发newman run命令。

摘要

在本章中,你学习了如何为 Gin Web 应用程序运行不同的自动化测试。你还探讨了如何集成外部工具,如 Gosec 和 Snyk,以检查代码质量、检测错误和发现潜在的安全漏洞。

在下一章中,我们将介绍我们在云上的分布式 Web 应用程序,主要使用 Docker 和 Kubernetes 在Amazon Web ServicesAWS)上。你现在应该能够发布几乎无错误的程序,并在发布新功能到生产环境之前发现潜在的安全漏洞。

问题

  1. UpdateRecipeHandler HTTP 处理器编写单元测试。

  2. DeleteRecipeHandler HTTP 处理器编写单元测试。

  3. FindRecipeHandler HTTP 处理器编写单元测试。

进一步阅读

Go 设计模式》由 Mario Castro Contreras 著,Packt Publishing 出版

第八章:在 AWS 上部署应用程序

本章将指导你如何在 Amazon Web ServicesAWS)上部署 API。它还将进一步解释如何使用自定义域名通过 HTTPS 提供应用程序,并在 Kubernetes 和 Amazon Elastic Container ServiceAmazon ECS)上扩展基于 Gin 的 API。

因此,我们将关注以下主题:

  • Amazon Elastic Compute CloudAmazon EC2)实例上部署 Gin 网络应用程序

  • 在 Amazon ECS弹性容器服务)上部署

  • 使用 Amazon Elastic Kubernetes ServiceAmazon EKS)在 Kubernetes 上部署

技术要求

要遵循本章的说明,你需要以下内容:

  • 对前一章的完整理解——本章是前一章的后续,它将使用相同的源代码。因此,一些片段将不会解释,以避免重复。

  • 使用 AWS 的先前经验是强制性的。

  • 需要具备对 Kubernetes 的基本理解。

本章的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter08

在 EC2 实例上部署

在本书的整个过程中,你已经学习了如何使用 Gin 框架构建分布式网络应用程序以及如何对 API 进行本地加载和测试的扩展。在本节中,我们将介绍如何在云上部署以下架构并向外部用户提供服务。

这里可以看到应用架构的概述:

![Figure 8.1 – Application architectureimg/B17115_08_01_v2.jpg

图 8.1 – 应用架构

AWS 在云服务提供商方面是领导者——它提供了一系列基础设施服务,如负载均衡器、服务器、数据库和网络服务。

要开始,请创建一个 AWS 账户 (aws.amazon.com)。大多数 AWS 服务都提供丰富的免费层资源,因此部署你的应用程序将花费你很少或没有费用。

启动 EC2 实例

创建 AWS 账户后,你现在可以启动 EC2 实例。为此,请按照以下步骤操作:

  1. 登录到 AWS 管理控制台(console.aws.amazon.com) 并搜索 EC2。在 EC2 仪表板中,单击 启动实例按钮以配置新的 EC2 实例。

  2. 选择 Amazon Linux 2 AMI 作为 Amazon Machine ImageAMI)。这是将运行 EC2 实例的 操作系统OS)。以下截图提供了该概述:![Figure 8.2 – AMI img/B17115_08_02_v2.jpg

    图 8.2 – AMI

  3. 接下来,选择实例类型。您可以从t2.micro实例开始,如果需要,稍后升级。然后,点击配置实例详情并保留默认设置,如下面的截图所示:![图 8.3 – 实例配置 图片

    Figure 8.3 – 实例配置

  4. 现在,点击GP2GP3或预配 IOPS。

    备注

    MongoDB 需要快速存储。因此,如果您计划在 EC2 上托管 MongoDB 容器,EBS 优化的类型可以提高输入/输出(I/O)操作。

  5. 然后,点击Name=application-sg,如图所示。保留安全组在默认设置(允许端口 22 的 SSH 入站流量)。然后,点击审查并启动:![图 8.4 – 安全组 图片

    Figure 8.4 – 安全组

    备注

    作为最佳实践,您应该始终仅将安全外壳协议(SSH)限制为已知的静态互联网协议(IP)地址或网络。

  6. 点击启动并分配一个密钥对或创建一个新的 SSH 密钥对。然后,点击创建实例

  7. 通过点击查看实例按钮返回实例仪表板——实例启动并运行可能需要几秒钟,但您应该能在屏幕上看到它,如下面的截图所示:![图 8.5 – EC2 仪表板 图片

    Figure 8.5 – EC2 仪表板

  8. 一旦实例准备就绪,打开您的终端会话,使用您的 SSH 密钥对中的公钥key.pem通过 SSH 连接到实例,如图所示:

    ssh ec2-user@IP –I key.pem
    
  9. 确认消息将出现——输入Yes。然后,运行以下命令安装 Git,sudo su命令用于在根级别提供权限。

    这里,我们使用 Docker 19.03.13-ce 和 Docker Compose 1.29.0 版本:

![图 8.6 – Docker 版本图片

Figure 8.6 – Docker 版本

您已成功配置并启动了 EC2 实例。

当 EC2 实例运行时,您可以部署在第第六章中介绍的Docker Compose堆栈,扩展 Gin 应用程序。为此,执行以下步骤:

  1. 克隆以下 GitHub 仓库,其中包含分布式 Gin Web 应用程序的组件和文件:

    chapter06/docker-compose.yml file:
    
    

    version: "3.9"

    services:

    api:

    image: api

    environment:

    • MONGO_URI=mongodb://admin:password

    @mongodb:27017/test?authSource=admin

    &readPreference=primary&ssl=false

    • MONGO_DATABASE=demo

    • REDIS_URI=redis:6379

    external_links:

    • mongodb

    • redis

    scale: 5

    dashboard:

    image: dashboard

    redis:

    image: redis

    mongodb:

    image: mongo:4.4.3

    environment:

    • MONGO_INITDB_ROOT_USERNAME=admin

    • MONGO_INITDB_ROOT_PASSWORD=password

    nginx:

    image: nginx

    ports:

    • 80:80

    volumes:

    • $PWD/nginx.conf:/etc/nginx/nginx.conf

    depends_on:

    • api

    • dashboard

    
    The stack consists of the following services:a. RESTful API written with Go and the Gin frameworkb. A dashboard written with JavaScript and the React frameworkc. MongoDB for data storaged. Redis for in-memory storage and API cachinge. Nginx as a reverse proxy
    
  2. 在部署堆栈之前,构建 RESTful API 和 Web 仪表板的 Docker 镜像。转到每个服务的相应文件夹并运行docker build命令。例如,以下命令用于构建 RESTful API 的 Docker 镜像:

    cd api
    docker build -t api .
    

    命令输出如下:

    ![图 8.7 – Docker 构建日志 图片

    图 8.7 – Docker 构建日志

  3. 构建镜像后,发出以下命令:

    cd ..
    docker-compose up –d
    

    服务将被部署,并将创建五个 API 实例,如下截图所示:

![图 8.8 – Docker 应用程序图片

图 8.8 – Docker 应用程序

应用程序启动并运行后,转到网页浏览器并粘贴用于连接到您的 EC2 实例的 IP 地址。然后,您应该看到以下错误消息:

![图 8.9 – 请求超时图片

图 8.9 – 请求超时

要修复此问题,您需要允许 80 端口的入站流量,这是 nginx 代理暴露的端口。转到 EC2 仪表板中的安全组,并搜索分配给运行应用程序的 EC2 实例的安全组。找到后,添加一个入站规则,如下所示:

![图 8.10 – 端口 80 上的入站规则图片

图 8.10 – 端口 80 上的入站规则

返回到您的网页浏览器并向实例 IP 发出 HTTP 请求。这次,nginx 代理将被调用并返回响应。如果您向/api/recipes端点发出请求,应该返回一个空数组,如下截图所示:

![图 8.11 – RESTful API 响应图片

图 8.11 – RESTful API 响应

MongoDB 的recipes集合为空。因此,通过在/api/recipes端点上发出以下 JSON 有效负载的POST请求来创建一个新的食谱:

![图 8.12 – 创建新食谱的 POST 请求图片

图 8.12 – 创建新食谱的 POST 请求

确保在POST请求中包含Authorization头。刷新网页浏览器页面,然后应该在网页仪表板上返回一个食谱,如下截图所示:

![图 8.13 – 新食谱图片

图 8.13 – 新食谱

现在,单击登录按钮,您应该会有一个不安全的源错误,如下所示:

![图 8.14 – Auth0 要求客户端通过 HTTPS 运行图片

图 8.14 – Auth0 要求客户端通过 HTTPS 运行

错误是由于 Auth0 需要在通过 HTTPS 协议提供服务的 Web 应用程序上运行。您可以通过在 EC2 实例之上设置一个负载均衡器来通过 HTTPS 提供服务。

使用应用程序负载均衡器进行 SSL 卸载

要通过 HTTPS 运行 API,我们需要一个 安全套接字层SSL)证书。您可以通过 AWS 证书管理器ACM)轻松获取 SSL 证书。此服务使得在 AWS 管理资源上配置、管理和部署 SSL/传输层安全性TLS)证书变得容易。要生成 SSL 证书,请按照以下步骤操作:

  1. 前往 ACM 仪表板,通过点击 请求证书 按钮,并选择 请求公共证书 来为您的域名请求免费的 SSL 证书。

  2. domain.com 上。

    备注

    domain.com 域名可以有多个子域名,例如 sandbox.domain.comproduction.domain.comapi.domain.com

  3. 选择验证方法 页面上,选择 DNS 验证 并将 ACM 提供的 规范名称CNAME)记录添加到您的 域名系统DNS)配置中。颁发公共证书可能需要几分钟,但一旦域名验证完成,证书将被颁发,并在 ACM 仪表板中显示为 已颁发 状态,如下面的截图所示:图 8.15 – 使用 ACM 请求公共证书

    图 8.15 – 使用 ACM 请求公共证书

  4. 接下来,从 EC2 仪表板中的 负载均衡器 部分创建一个应用程序负载均衡器,如下面的截图所示:图 8.16 – 应用程序负载均衡器

    图 8.16 – 应用程序负载均衡器

  5. 在随后的页面上,输入负载均衡器的名称,并从下拉列表中指定方案为 面向互联网。在 可用区 部分中,为每个 可用区AZ)选择一个子网以提高弹性。然后,在 监听器 部分中,添加一个 HTTPS 监听器和 HTTP 监听器,分别位于端口 443 和 80,如下面的截图所示:图 8.17 – HTTP 和 HTTPS 监听器

    图 8.17 – HTTP 和 HTTPS 监听器

  6. 点击 配置安全设置 按钮继续操作,并从下拉列表中选择 ACM 中创建的证书,如下面的截图所示:图 8.18 – 证书配置

    图 8.18 – 证书配置

  7. 现在,点击 配置路由 并创建一个新的名为 application 的目标组。确保协议设置为 HTTP,端口设置为 80,因为 nginx 代理监听端口 80。使用此配置,负载均衡器和实例之间的流量将使用 HTTP 传输,即使客户端向负载均衡器发出的 HTTPS 请求也是如此。您可以在以下截图中查看配置:图 8.19 – 配置目标组

    图 8.19 – 配置目标组

  8. HTTP 和路径 /api/recipes 上。

  9. 点击注册目标,选择运行应用程序的 EC2 实例,然后点击添加到已注册,如下所示:![图 8.20 – 注册 EC2 实例 图片

    图 8.20 – 注册 EC2 实例

  10. 当你完成实例选择后,选择下一步:审查。审查你选择的设置,然后点击创建按钮。配置过程可能需要几分钟,但你应该会看到如下屏幕:![图 8.21 – 负载均衡器 DNS 名称 图片

    图 8.21 – 负载均衡器 DNS 名称

  11. 一旦状态变为指向 Route 53 中负载均衡器的公共 DNS 名称的 A 记录([aws.amazon.com/route53/](https://aws.amazon.com/route53/))或在你的 DNS 注册商那里,如图下所示:

![图 8.22 – Route 53 新 A 记录图片

图 8.22 – Route 53 新 A 记录

一旦你做出必要的更改,更改可能需要长达 48 小时才能在其他 DNS 服务器上传播。

通过浏览到 HTTPS://recipes.domain.com 来验证你的域名记录更改是否已传播。这应该会导致负载均衡器显示应用程序的安全 Web 仪表板。点击浏览器地址栏中的图标,它应该显示域名和 SSL 证书的详细信息,如图下所示:

![图 8.23 – 通过 HTTPS 提供服务图片

图 8.23 – 通过 HTTPS 提供服务

你的应用程序负载均衡器现在已配置了运行在 AWS 上的 Gin 应用程序的 SSL 证书。你可以使用 Auth0 服务通过 Web 仪表板登录并添加新食谱。

在 Amazon ECS 上部署

在上一节中,我们学习了如何部署一个 EC2 实例并配置它来运行我们的 Gin 应用程序。在本节中,我们将学习如何在不需要管理 EC2 实例的情况下获得相同的结果。AWS 提出了两种容器编排服务:ECSEKS

在本节中,你将了解 ECS,这是一个完全管理的容器编排服务。在我们将应用程序部署到 ECS 之前,我们需要将应用程序 Docker 镜像存储在远程仓库中。这就是弹性容器注册库ECR)仓库发挥作用的地方。

在私有仓库中存储镜像

ECR 是一个广泛使用的私有 Docker 注册库。要在私有仓库中存储镜像,你首先需要在 ECR 中创建一个仓库。为了实现这一点,请按照以下步骤操作:

  1. mlabouardy/recipes-api跳转到 ECR 仪表板,将其作为你的 Gin RESTful API 仓库的名称,如图下所示:![图 8.24 – 新的 ECR 仓库 图片

    图 8.24 – 新的 ECR 仓库

    注意

    你可以在 Docker Hub 上托管你的 Docker 镜像。如果你选择这种方法,你可以跳过这部分。

  2. 点击创建存储库按钮,然后选择存储库并点击查看推送命令。复制命令以进行身份验证并将 API 镜像推送到存储库,如下面的截图所示:图 8.25 – ECR 登录和推送命令

    图 8.25 – ECR 登录和推送命令

    注意

    有关如何安装 AWS 命令行界面CLI)的逐步指南,请参阅官方文档docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html

  3. 按照图 8.25 中显示的命令进行 ECR 的身份验证。标记镜像并将其推送到远程存储库,如下所示(将IDREGIONUSER变量替换为您自己的值):

aws ecr get-login-password --region REGION | docker login --username AWS --password-stdin ID.dkr.ecr.REGION.amazonaws.com
docker tag api ID.dkr.ecr.REGION.amazonaws.com/USER/recipes-api:latest
docker push ID.dkr.ecr.REGION.amazonaws.com/USER/recipes-api:latest

命令日志如下所示:

图 8.26 – 将镜像推送到 ECR

图 8.26 – 将镜像推送到 ECR

镜像现在将在 ECR 上可用,如下面的截图所示:

图 8.27 – 存储在 ECR 上的镜像

图 8.27 – 存储在 ECR 上的镜像

在 ECR 中存储 Docker 镜像后,您可以在 ECS 中部署应用程序。

现在,更新docker-compose.yml文件,在image部分引用 ECR 存储库 URI,如下所示:

version: "3.9"
services:
 api:
   image: ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/
      mlabouardy/recipes-api:latest
   environment:
     - MONGO_URI=mongodb://admin:password@mongodb:27017/
           test?authSource=admin&readPreference=
           primary&ssl=false
     - MONGO_DATABASE=demo
     - REDIS_URI=redis:6379
   external_links:
     - mongodb
     - redis
   scale: 5
 dashboard:
   image: ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/
       mlabouardy/dashboard:latest

创建 ECS 集群

我们的docker-compose.yml文件现在引用了存储在 ECR 中的镜像。我们已准备好启动 ECS 集群并在其上部署应用程序。

您可以从AWS 管理控制台手动部署 ECS 集群,或通过 AWS ECS CLI 进行部署。根据您的操作系统,遵循官方说明从docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_CLI_installation.htm安装 Amazon ECS CLI。

安装完成后,通过提供 AWS 凭证和创建集群的 AWS 区域来配置 Amazon ECS CLI,如下所示:

ecs-cli configure profile --profile-name default --access-key KEY --secret-key SECRET

在配置 ECS 集群之前,定义一个任务执行IAM角色,以便允许 Amazon ECS 容器代理代表我们调用 AWS API。创建一个名为task-execution-assule-role.json的文件,并包含以下内容:

{
   "Version": "2012-10-17",
   "Statement": [
       {
           "Sid": "",
           "Effect": "Allow",
           "Principal": {
               "Service": "ecs-tasks.amazonaws.com"
           },
           "Action": "sts:AssumeRole"
       }
   ]
}

使用 JSON 文件创建任务执行角色,并将AmazonECSTaskExecutionRolePolicy任务执行角色策略附加到它,如下所示:

aws iam --region REGION create-role --role-name ecsTaskExecutionRole --assume-role-policy-document file://task-execution-assume-role.json
aws iam --region REGION attach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

使用以下命令完成配置,默认集群名称和启动类型。然后,使用ecs-cliup命令创建 Amazon ECS 集群:

ecs-cli configure --cluster sandbox --default-launch-type FARGATE --config-name sandbox --region REGION
ecs-cli up --cluster-config sandbox --aws-profile default

此命令可能需要几分钟才能完成,因为您的资源(EC2 实例、负载均衡器、安全组等)正在创建。此命令的输出如下所示:

图 8.28 – 创建 ECS 集群

图 8.28 – 创建 ECS 集群

跳转到 ECS 仪表板——沙盒集群应该已经启动并运行,如下面的截图所示:

图 8.29 – 沙盒集群

图 8.29 – 沙盒集群

要部署应用程序,您可以使用上一节中提供的docker-compose文件。除了这些,您还需要在配置文件中提供某些特定于 Amazon ECS 的参数,如下所示:

  • 子网:应替换为 EC2 实例应部署的公共子网列表

  • 安全组和资源使用中央处理单元CPU)和内存

创建一个包含以下内容的ecs-params.yml文件:

version: 1
task_definition:
 task_execution_role: ecsTaskExecutionRole
 ecs_network_mode: awsvpc
 task_size:
   mem_limit: 2GB
   cpu_limit: 256
run_params:
 network_configuration:
   awsvpc_configuration:
     subnets:
       - "subnet-e0493c88"
       - "subnet-7472493e"
     security_groups:
       - "sg-d84cb3b3"
     assign_public_ip: ENABLED

接下来,使用以下命令将docker compose文件部署到集群中。--create-log-groups选项为容器日志创建 CloudWatch 日志组:

ecs-cli compose --project-name application -f 
docker-compose.ecs.yml up --cluster sandbox 
--create-log-groups

部署日志如下所示:

图 8.30 – 任务部署

图 8.30 – 任务部署

将创建一个application任务。任务是一组元数据(内存、CPU、端口映射、环境变量),它描述了容器应该如何部署。您可以在以下位置查看其概述:

图 8.31 – 任务定义

图 8.31 – 任务定义

使用 AWS CLI,添加一个安全组规则以允许 80 端口的入站流量,如下所示:

aws ec2 authorize-security-group-ingress --group-id SG_ID --protocol tcp --port 80 --cidr 0.0.0.0/0 --region REGION

输入以下命令以查看在 ECS 中运行的容器:

ecs-cli compose --project-name application service ps –cluster--config sandbox --ecs-profile default

该命令将列出正在运行的容器以及 nginx 服务的 IP 地址和端口号。如果您将网络浏览器指向该地址,您应该能看到 Web 仪表板。

太好了!您现在有一个运行中的 ECS 集群,其中包含 Docker 化的 Gin 应用程序。

使用 Amazon EKS 在 Kubernetes 上部署

ECS 可能是一个适合初学者和小型工作负载的好解决方案。然而,对于大型部署和一定规模,您可能需要考虑转向 Kubernetes(也称为K8s)。对于那些 AWS 高级用户,Amazon EKS 是一个自然的选择。

AWS 在 EKS 服务下提供了一种托管的 Kubernetes 解决方案。

要开始,我们需要部署一个 EKS 集群,如下所示:

  1. 跳转到 EKS 仪表板,并使用以下参数创建一个新的集群:图 8.32 – EKS 集群创建

    图 8.32 – EKS 集群创建

    集群的IAM角色应包括以下AmazonEKSWorkerNodePolicyAmazonEKS_CNI_PolicyAmazonEC2ContainerRegistryReadOnly

  2. 指定网络页面,选择一个现有的虚拟专用云VPC)用于集群和子网,如图所示。其余部分保持默认设置:图 8.33 – EKS 网络配置

    图 8.33 – EKS 网络配置

  3. 对于集群端点访问,为了简单起见,启用公共访问。对于生产使用,限制对您的网络无类别域间路由CIDR)的访问或仅启用对集群 API 的私有访问。

  4. 然后,在 配置日志记录 页面上,启用所有日志类型,以便能够轻松地从 CloudWatch 控制台调试或排除网络问题。

  5. 查看信息,点击 eksctl,前往官方指南 docs.aws.amazon.com/eks/latest/userguide/create-cluster.html

  6. 一旦集群处于 活动 状态,创建一个托管节点组,容器将在其中运行。

  7. 点击集群名称,选择 workers 并创建一个节点 IAM 角色,如图所示:图 8.35 – EKS 节点组

    图 8.35 – EKS 节点组

    注意

    有关如何配置节点组的更多信息,请参阅官方文档 docs.aws.amazon.com/eks/latest/userguide/create-node-role.html#create-worker-node-role

  8. 在下一页上,选择 Amazon Linux 2 作为 AMI,并选择 t3.medium 按需 实例,如图所示:图 8.36 – 工作节点配置

    图 8.36 – 工作节点配置

    注意

    对于生产使用,您可能会使用 Spot-Instances 而不是 按需。由于可能出现的突发中断,Spot-Instances 通常会有很好的折扣。这些中断可以被 Kubernetes 优雅地处理,让您节省额外的费用。

    以下图示显示了配置是如何进行扩展的:

    图 8.37 – 扩展配置

    图 8.37 – 扩展配置

  9. 最后,指定两个节点将要部署的子网。在 审查和创建 页面上,审查您的托管节点组配置,然后点击 创建

现在您已经部署了您的 EKS 集群,您需要配置 kubectl

配置 kubectl

kubectl 是一个用于与集群 API 服务器通信的命令行工具。要安装此工具,请执行以下命令:

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/darwin/amd64/kubectl 
chmod +x ./kubectl
mv kubectl /usr/local/bin/

在这本书中,我们使用的是最新版本的 kubectl,即 1.21.0,如图所示:

Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.0", GitCommit:"cb303e613a121a29364f75cc67d3d580833a7479", GitTreeState:"clean", BuildDate:"2021-04-08T16:31:21Z", GoVersion:"go1.16.1", Compiler:"gc", Platform:"darwin/amd64"}

接下来,生成一个包含 kubectl 与 EKS 集群交互所需凭证的 kubeconfig 文件,如下所示:

aws eks update-kubeconfig --name sandbox --region eu-central-1

您现在可以通过以下命令测试凭证,列出集群的节点:

kubectl get nodes

命令将列出两个节点,正如我们所看到的:

图 8.38 – EKS 节点

图 8.38 – EKS 节点

太棒了!您已成功配置 kubectl

现在您已经设置了您的 EKS 集群,要在 Kubernetes 上运行服务,您需要将您的 compose service 定义转换为 Kubernetes 对象。Kompose 是一个开源工具,可以加快转换过程。

注意

您不必编写多个 Kubernetes YAML Ain't Markup Language (YAML)文件,您可以将整个应用程序打包在 Helm 图表中(docs.helm.sh/),并将其存储在远程注册库中进行分发。

将 Docker Compose 工作流程迁移到 Kubernetes

Kompose 是一个开源工具,可以将docker-compose.yml文件转换为 Kubernetes 部署文件。要开始使用 Kompose,请按照以下步骤操作:

  1. 导航到项目的 GitHub 发布页面(github.com/kubernetes/kompose/releases)并下载适用于您的操作系统的二进制文件。这里使用的是版本 1.22.0:

    curl -L https://github.com/kubernetes/kompose/releases/download/v1.22.0/kompose-darwin-amd64 -o kompose
    chmod +x kompose
    sudo mv ./kompose /usr/local/bin/kompose
    
  2. 安装 Kompose 后,使用以下命令将服务定义转换为:

    kompose convert -o deploy
    
  3. 运行此命令后,Kompose 将输出它创建的文件信息,如下所示:图 8.39 – 使用 Kompose 将 Docker Compose 转换为 Kubernetes 资源

    apiVersion: apps/v1
    kind: Deployment
    metadata:
     annotations:
       kompose.cmd: kompose convert
       kompose.version: 1.22.0 (955b78124)
     creationTimestamp: null
     labels:
       io.kompose.service: api
     name: api
    spec:
     replicas: 1
     selector:
       matchLabels:
         io.kompose.service: api
     strategy: {}
     template:
       metadata:
         annotations:
           kompose.cmd: kompose convert
           kompose.version: 1.22.0 (955b78124)
         creationTimestamp: null
         labels:
           io.kompose.network/api_network: "true"
           io.kompose.service: api
       spec:
         containers:
           - env:
               - name: MONGO_DATABASE
                 value: demo
               - name: MONGO_URI
                 value: mongodb://admin:password
                     @mongodb:27017/test?authSource=admin
                     &readPreference=primary&ssl=false
               - name: REDIS_URI
                 value: redis:6379
             image: ID.dkr.ecr.REGION.amazonaws.com/USER
                 /recipes-api:latest
             name: api
             resources: {}
         restartPolicy: Always
    status: {}
    
  4. 现在,通过以下命令创建 Kubernetes 对象,并测试您的应用程序是否按预期工作:

    kubectl apply -f .
    

    您将看到以下输出,表示已创建对象:

    图 8.40 – 部署和服务

    图 8.40 – 部署和服务

  5. 要检查您的 Pod 是否正在运行,使用以下命令部署 Kubernetes 仪表板:

    kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.5/aio/deploy/recommended.yaml
    
  6. 接下来,创建一个eks-admin服务帐户和集群角色绑定,您可以使用它以管理员权限安全地连接到仪表板,如下所示:

    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: eks-admin
      namespace: kube-system
    ---
    apiVersion: rbac.authorization.k8s.io/v1beta1
    kind: ClusterRoleBinding
    metadata:
      name: eks-admin
    roleRef:
      apiGroup: rbac.authorization.k8s.io
      kind: ClusterRole
      name: cluster-admin
    subjects:
    - kind: ServiceAccount
      name: eks-admin
      namespace: kube-system
    
  7. 将内容保存到eks-admin-service-account.yml文件中,并使用以下命令将服务帐户应用到您的集群中:

    kubectl apply -f eks-admin-service-account.yml
    
  8. 在连接到仪表板之前,使用以下命令获取认证令牌:

    kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep eks-admin | awk '{print $1}')
    

    Kubernetes 仪表板令牌的样式如下:

    图 8.41 – Kubernetes 仪表板令牌

    图 8.41 – Kubernetes 仪表板令牌

  9. 使用以下命令在本地运行代理:

    kubectl proxy
    
  10. 访问http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/#!/login并粘贴认证令牌。

您应该被重定向到仪表板,在那里您可以查看分布式应用程序容器,以及它们的指标和状态,如下面的截图所示:

图 8.42 – Kubernetes 仪表板

图 8.42 – Kubernetes 仪表板

您可以轻松地监控在 EKS 中运行的应用程序,并在需要时扩展 API Pod。

注意

当您完成对 EKS 的实验后,删除您创建的所有资源是个好主意,这样 AWS 就不会为此向您收费。

摘要

在本章中,您学习了如何在 AWS 上使用 Amazon EC2 服务运行 Gin Web 应用程序,以及如何通过应用程序负载均衡器和 ACM 通过 HTTPS 提供服务。

您还探索了如何在不管理底层 EC2 节点的情况下,使用 ECS 将应用程序部署到托管集群。在这个过程中,您还了解了如何使用 ECR 将 Docker 镜像存储在远程注册库中,以及如何使用 Amazon EKS 进行可扩展的应用程序部署。

在下一章中,您将了解如何使用持续集成/持续部署CI/CD)管道自动部署您的 Gin 应用程序到 AWS。

问题

  1. 您将如何配置 MongoDB 容器数据的持久卷?

  2. 在 AWS EC2 上部署 RabbitMQ。

  3. 使用 Kubernetes Secrets 创建 MongoDB 凭证。

  4. 使用kubectl将 API pod 扩展到五个实例。

进一步阅读

  • 《开发者 Docker》,作者:Richard Bullington-McGuire, Andrew K. Dennis, 和 Michael Schwartz。Packt 出版社

  • 《精通 Kubernetes – 第三版》,作者:Gigi Sayfan。Packt 出版社

  • 《亚马逊网络服务上的 Docker》,作者:Justin Menga。Packt 出版社

第九章:实现 CI/CD 管道

本章将向您展示如何构建一个 CI/CD 工作流程来自动化 Gin 服务的部署。我们还将讨论在构建基于 Gin 的 API 时采用 GitFlow 方法的重要性。

在本章中,我们将涵盖以下主题:

  • 探索 CI/CD 实践

  • 构建持续集成工作流程

  • 维护多个运行时环境

  • 实现持续交付

到本章结束时,您将能够自动化 Gin 网络应用的测试、构建和部署过程。

技术要求

为了跟随本章的内容,您需要以下条件:

  • 对上一章内容的完整理解。本章是上一章的后续,因为它将使用相同的源代码。因此,为了避免重复,一些代码片段将不会进行解释。

  • 推荐您有 CI/CD 实践的前期经验,这样您就可以轻松地跟随本章内容。

本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter09

探索 CI/CD 实践

在前面的章节中,您学习了如何在 AWS 上设计、构建和部署 Gin 网络应用。目前,部署新更改可能是一个耗时的过程。当部署到 EC2 实例、Kubernetes 或平台即服务PaaS)时,涉及一些手动步骤,这些步骤有助于将新更改推出去。

幸运的是,许多这些部署步骤可以自动化,从而节省开发时间,消除人为错误的可能性,并减少发布周期时间。这就是为什么在本节中,您将学习如何采用持续集成CI)、持续部署CD)和持续交付来加速您应用程序的上市时间TTM),以及确保每个迭代都发送高质量的功能。但首先,这些实践意味着什么?

持续集成

持续集成CI)是一个拥有集中式代码仓库(例如,GitHub、Bitbucket、GitLab 等)的过程,并且所有更改和功能在集成到远程仓库之前都需要通过一个管道。一个经典的管道会在代码提交(或推送事件)发生时触发构建,运行预集成测试,构建工件(例如,Docker 镜像、JAR、npm 包等),并将结果推送到私有注册库进行版本控制:

![图 9.1 – CI/CD 实践图 9.1 – CI/CD 实践

图 9.1 – CI/CD 实践

如前图所示,CI 工作流程包括以下阶段:检出测试构建推送

持续部署

持续部署CD)另一方面,是 CI 工作流的扩展。每个通过 CI 管道所有阶段的更改都会自动发布到预生产或预生产环境,QA 团队可以在那里运行验证和验收测试。

持续交付

持续交付与 CD 类似,但在将新版本部署到生产环境之前需要人工干预或业务验证。这种人工参与可能包括手动部署,通常由 QA 工程师执行,或者简单地点击一个按钮。这与 CD 不同,CD 中每个成功的构建都会发布到预生产环境。

采用这三个实践可以帮助提高代码的质量和可测试性,并有助于降低将损坏的发布版本发送到生产的风险。

现在你已经了解了这三个组件,到本章结束时,你将能够为我们的 Gin Web 应用程序构建一个端到端的部署流程,类似于以下图中所示:

![Figure 9.2 – CI/CD pipelineFigure 9.2 – CI/CD pipeline

Figure 9.2 – CI/CD pipeline

前面的管道分为以下阶段:

  • 检出:从项目的 GitHub 仓库中拉取最新更改。

  • 测试:在 Docker 容器内运行单元和质量测试。

  • 构建:从 Dockerfile 编译和构建 Docker 镜像。

  • 推送:标记镜像并将其存储在私有注册表中。

  • 部署:将更改部署并提升到 AWS 环境(EC2、EKS 或 ECS)。

    注意

    我们使用 CircleCI 作为 CI 服务器,但可以使用其他 CI 解决方案(如 Jenkins、Travis CI、GitHub Actions 等)实现相同的流程。

构建 CI 工作流

我们为这本书构建的应用程序在 GitHub 仓库中进行版本控制。此仓库使用 GitFlow 模型作为分支策略,其中使用了三个主要分支。每个分支代表应用程序的一个运行环境:

  • 主分支:此分支对应于在生产环境中运行的代码。

  • 预生产分支:预生产环境,是生产环境的镜像。

  • 开发分支:沙盒或开发环境。

要将应用程序从一个环境提升到另一个环境,你可以创建功能分支。你也可以为重大错误或问题创建热修复分支

注意

要了解更多关于 GitFlow 工作流和最佳实践的信息,请查看官方文档:www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow

下一个图显示了你的项目 GitHub 仓库的外观:

Figure 9.3 – 项目 GitHub 仓库

Figure 9.3 – Project's GitHub repository

Figure 9.3 – 项目 GitHub 仓库

您将使用 CircleCI来自动化 CI/CD 工作流程。如果您还没有 CircleCI 账户,请使用您的 GitHub 账户免费注册,网址为 https://circleci.com。无论 CI 服务器是什么,CI/CD 的原则都是相同的:

![图 9.4 – CircleCI 登录页面图片

图 9.4 – CircleCI 登录页面

一旦注册,您需要配置 CircleCI 以运行应用程序测试和构建 Docker 镜像。为此,您需要在模板文件中描述所有步骤,并将其保存在代码的 GitHub 仓库中。这种方法被称为 代码即管道

代码即管道

当 CircleCI 构建被触发时,它会寻找一个 .circleci/config.yml 文件。此文件包含要在 CI 服务器上执行的指令。

首先创建一个 .circleci 文件夹和一个包含以下内容的 .config.yml 文件:

version: 2.1
executors:
 environment:
   docker:
     - image: golang:1.15.6
   working_directory: 
jobs:
 test:
   executor: environment
   steps:
     - checkout
workflows:
 ci_cd:
   jobs:
     - test

以下代码片段将在由 Golang v1.15.6 Docker 镜像提供的环境中运行工作流程。大多数 CI/CD 步骤都将使用 Docker 执行,这使得在本地运行构建变得轻而易举,并且如果将来想要迁移到不同的 CI 服务器,我们仍然有选择余地(与供应商锁定相反)。首先运行的工作是测试阶段,它包括以下步骤:

jobs:
 test:
   executor: environment
   steps:
     - checkout
     - restore_cache:
         keys:
           - go-mod-v4-{{ checksum "go.sum" }}
     - run:
         name: Install Dependencies
         command: go mod download
     - save_cache:
         key: go-mod-v4-{{ checksum "go.sum" }}
         paths:
           - "/go/pkg/mod"
     - run:
         name: Code linting
         command: >
           go get -u golang.org/x/lint/golint
           golint ./...
     - run:
         name: Unit tests
         command: go test -v ./...

test 作业将使用 checkout 指令从该项目的 GitHub 仓库获取最新更改。然后,它将下载项目依赖项并将它们缓存以供将来使用(以减少工作流程持续时间),之后,它将运行一系列测试:

  • 代码检查:这会检查代码是否遵守标准编码约定。

  • 单元测试:这会执行我们在前几章中编写的单元测试。

在 CircleCI 配置就绪后,让我们在 CircleCI 上为 Gin 应用程序创建一个项目。为此,请按照以下步骤操作:

  1. 跳转到 CircleCI 控制台,点击项目仓库旁边的 设置项目:![图 9.5 – 设置 CircleCI 项目 图片

    图 9.5 – 设置 CircleCI 项目

  2. 点击 使用现有配置 按钮,因为我们已经有了 CircleCI 配置,然后点击 开始构建:![图 9.6 – CircleCI 配置 图片

    图 9.6 – CircleCI 配置

  3. 将启动一个新的管道;然而,由于代码仓库中不存在 config.yml 文件,它将失败。以下截图显示了此错误:![图 9.7 – 管道失败 图片

    图 9.7 – 管道失败

  4. 通过运行以下命令将 CircleCI 配置推送到 develop 分支上的 GitHub 仓库:

    git add .
    git commit –m "added test stage"
    git push origin develop
    

    将自动触发一个新的管道。输出将类似于以下内容:

    ![图 9.8 – 管道已自动触发 图片

    图 9.8 – 管道已自动触发

  5. 点击 config.yml 文件。所有测试都将通过,您将能够构建您的 Docker 镜像:图 9.9 – 运行自动化测试

    图 9.9 – 运行自动化测试

    值得注意的是,该管道是自动触发的,因为在设置 CircleCI 项目时,项目 GitHub 仓库中自动创建了一个 webhook。这样,对于每次推送事件,都会向 CircleCI 服务器发送通知以触发相应的 CircleCI 管道:

    图 9.10 – GitHub Webhook

    图 9.10 – GitHub Webhook

    让我们继续到集成应用程序的下一步,即构建 Docker 镜像。

  6. build 作业添加到 CI/CD 工作流程中:

    workflows:
     ci_cd:
       jobs:
         - test
         - build
    
  7. build 作业负责根据我们存储在代码仓库中的 Dockerfile 构建一个 Docker 镜像。然后,它对构建的镜像进行标记并将其存储在远程 Docker 仓库中进行版本控制:

    build:
        executor: environment
       steps:
         - checkout
         - setup_remote_docker:
            version: 19.03.13
         - run:
             name: Build image
             command: >
               TAG=0.1.$CIRCLE_BUILD_NUM
               docker build -t USER/recipes-api:$TAG .
         - run:
             name: Push image
             command: >
               docker tag USER/recipes-api:$TAG 
                   ID.dkr.ecr.REGION.amazonaws.com/USER/
                   recipes-api:$TAG
               docker tag USER/recipes-api:$TAG 
                   ID.dkr.ecr.REGION.amazonaws.com/USER/
                   recipes-api:develop
               docker push ID.dkr.ecr.REGION.amazonaws.com/
                   USER/recipes-api:$TAG
               docker push ID.dkr.ecr.REGION.amazonaws.com/
                   USER/recipes-api:develop
    

    注意

    如果您对 Dockerfile 感兴趣,可以在本书的 GitHub 仓库中找到它:github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/blob/main/chapter10/Dockerfile

    为了标记镜像,我们将使用语义版本控制(semver.org)。版本的格式是三个数字,由点分隔:

    图 9.11 – 语义版本控制

    图 9.11 – 语义版本控制

    当新更改破坏 API(向后不兼容的更改)时,主版本号会增加。当发布新功能时,次要版本号会增加,而补丁版本号会增加以修复错误。

    在 CircleCI 配置中,您使用 $CIRCLE_BUILD_NUM 环境变量为通过我们 Gin 应用程序的开发周期构建的每个 Docker 镜像创建一个唯一的版本。另一种选择是使用 CIRCLE_SHA1 变量,它是触发 CI 构建的 Git 提交的 SHA1 哈希。

  8. 一旦镜像被标记,就将其存储在私有仓库中。在先前的示例中,您使用 弹性容器注册库ECR)作为私有仓库,但也可以使用其他解决方案,如 DockerHub。

  9. 使用以下命令将更改推送到 develop 分支:

    git add .
    git commit –m "added build stage"
    git push origin develop
    

将会触发一个新的管道。一旦 test 作业完成,build 作业将被执行,如下面的截图所示:

图 9.12 – 运行 "build" 作业

图 9.12 – 运行 "build" 作业

以下为 build 作业的步骤。测试应该在 推送镜像 步骤失败,因为 CircleCI 由于缺少 AWS 权限而无法将镜像推送到 ECR:

图 9.13 – 推送镜像步骤

图 9.13 – 推送镜像步骤

让我们看看如何配置您的 CI 和 CD 工作流程:

  1. 为了允许 CircleCI 与您的 ECR 仓库交互,创建一个具有适当 IAM 策略的专用 IAM 用户。

  2. 跳转到 AWS 管理控制台(console.aws.amazon.com/),导航到身份与访问管理IAM)控制台。然后,为 CircleCI 创建一个新的 IAM 用户。勾选程序访问复选框,如图 9.14 所示:图 9.14 – CircleCI IAM 用户

    图 9.14 – CircleCI IAM 用户

  3. 将以下 IAM 策略附加到 IAM 用户。此声明允许 CircleCI 将 Docker 镜像推送到 ECR 仓库。确保根据需要替换ID

    {
        "Version": "2008-10-17",
        "Statement": [
            {
                "Sid": "AllowPush",
                "Effect": "Allow",
                "Principal": {
                    "AWS": [
                        "arn:aws:iam::ID:circleci/
                                      push-pull-user-1"
                    ]
                },
                "Action": [
                    "ecr:PutImage",
                    "ecr:InitiateLayerUpload",
                    "ecr:UploadLayerPart",
                    "ecr:CompleteLayerUpload"
                ]
            }
        ]
    }
    
  4. IAM 用户创建完成后,从安全凭证选项卡创建一个访问密钥。然后,返回 CircleCI 仪表板并跳转到项目设置图 9.15 – CircleCI 环境变量

    图 9.15 – CircleCI 环境变量

  5. 环境变量部分,点击添加环境变量按钮并添加以下变量:

    AWS_ACCESS_KEY_ID:指定与 CircleCI IAM 用户关联的 AWS 访问密钥。

    AWS_SECRET_ACCESS_KEY:指定与 CircleCI IAM 用户关联的 AWS 秘密访问密钥。

    AWS_DEFAULT_REGION:指定 ECR 仓库所在的 AWS 区域:

    图 9.16 – 将 AWS 凭证作为环境变量

    图 9.16 – 将 AWS 凭证作为环境变量

  6. 环境变量设置完成后,通过添加一个用于在执行docker push命令之前进行 ECR 身份验证的指令来更新 CircleCI 配置。新的更改将如下所示:

        -run:
            name: Push image
            command: |
                TAG=0.1.$CIRCLE_BUILD_NUM
               docker tag USER/recipes-api:$TAG 
                   ID.dkr.ecr.REGION.amazonaws.com/USER/
                   recipes-api:$TAG
               docker tag USER/recipes-api:$TAG ID.dkr.ecr. 
                   REGION.amazonaws.com/USER/
                   recipes-api:develop
               aws ecr get-login-password --region REGION | 
                   docker login --username AWS --password-
                   stdin ID.dkr.ecr.REGION.amazonaws.com
               docker push ID.dkr.ecr.REGION.amazonaws.com
                   /USER/recipes-api:$TAG
        docker push ID.dkr.ecr.REGION.amazonaws.com
                   /USER/recipes-api:develop
    
  7. USERIDREGION变量适当地替换为您自己的值,并将更改再次推送到远程仓库的 develop 分支下:

    USER, ID, and REGION values in the configuration file, you can pass their values as environment variables from the CircleCI project settings.A new pipeline will be triggered. This time, the `build` job should be successful, and you should see something similar to the following:![Figure 9.17 – Building and storing a Docker image with CircleCI    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_9.17_B17115.jpg)Figure 9.17 – Building and storing a Docker image with CircleCINow, click on the `develop` tag that points to the latest built image in the develop branch.With the test and build stages being automated, you can go even further and automate the deployment process as well. 
    
  8. 与之前的职位类似,将一个deploy任务添加到当前的工作流程中:

    workflows:
     ci_cd:
       jobs:
         - test
         - build
         - deploy
    

    deploy任务将简单地使用我们在上一章中介绍的docker-compose.yml文件,在 EC2 实例上部署应用程序堆栈。文件将如下所示(为了简洁,已裁剪完整的 YAML 文件):

    version: "3" 
    services:
      api:
        image: ID.dkr.ecr.REGION.amazonaws.com/USER/
               recipes-api:develop
        environment:
          - MONGO_URI=mongodb://admin:password@mongodb
                :27017/test?authSource=admin
                 &readPreference=primary&ssl=false
          - MONGO_DATABASE=demo
          - REDIS_URI=redis:6379
        external_links:
          - mongodb
          - redis
      redis:
        image: redis
      mongodb:
        image: mongo:4.4.3
        environment:
          - MONGO_INITDB_ROOT_USERNAME=admin
          - MONGO_INITDB_ROOT_PASSWORD=password
    
  9. 要将新更改部署到运行容器的 EC2 实例,您将需要 SSH 到远程服务器并执行两个docker-compose命令 – pullup

    deploy:
       executor: environment
       steps:
         - checkout
         - run:
             name: Deploy with Docker Compose
             command: |
               ssh -oStrictHostKeyChecking=no ec2-user@IP 
                   docker-compose pull && docker-compose up –d
    
  10. 确保将IP变量替换为运行沙盒环境的 EC2 实例的 IP 地址或 DNS 名称。

  11. 要 SSH 到 EC2 实例,将用于在 AWS 中部署 EC2 实例的 SSH 密钥对添加到 CircleCI 项目设置中。在SSH 密钥下,点击添加 SSH 密钥并粘贴 SSH 密钥对的内容:图 9.20 – 添加 SSH 密钥对

    图 9.20 – 添加 SSH 密钥对

  12. 使用以下命令提交并推送新的 CircleCI 配置到 GitHub:

    git add .
    git commit –m "added deploy stage"
    git push origin develop
    

    将会触发一个新的管道,测试、构建和部署作业将依次执行。在部署作业结束时,新构建的镜像将被部署到沙盒环境中:

![图 9.21 – 持续部署图片

图 9.21 – 持续部署

如果您正在运行弹性容器服务ECS),您可以使用以下 CircleCI 配置来强制 ECS 拉取新镜像:

version: 2.1
orbs:
 aws-ecs: circleci/aws-ecs@0.0.11
workflows:
ci_cd:
   jobs:
    - test
    - build
    - aws-ecs/deploy-service-update:
       aws-region: AWS_DEFAULT_REGION
       family: 'demo'
       cluster-name: 'sandbox'
       container-image-name-updates: 
           'container=api,tag=0.1.${CIRCLE_BUILD_NUM}'

注意

确保您为 CircleCI IAM 用户分配 ECS 权限,以成功执行任务更新。

主要变化是我们正在使用 CircleCI orbs 而不是 Docker 镜像作为运行环境。通过使用 orbs,我们可以使用预构建的命令,这减少了我们配置文件中的代码行数。部署作业将部署更新的镜像到沙盒 ECS 集群。

如果您正在运行 Kubernetes,您可以使用以下 CircleCI 规范文件来更新镜像:

version: 2.1
orbs:
   aws-eks: circleci/aws-eks@0.2.0
   kubernetes: circleci/kubernetes@0.3.0
jobs:
 deploy:
   executor: aws-eks/python3
   steps:
     - checkout
     - aws-eks/update-kubeconfig-with-authenticator:
         cluster-name: sandbox
         install-kubectl: true
         aws-region: AWS_REGION
     - kubernetes/create-or-update-resource:
         resource-file-path: "deployment/
         api.deployment.yaml"
         get-rollout-status: true
         resource-name: deployment/api
     - kubernetes/create-or-update-resource:
         resource-file-path: "deployment/api.service.yaml"

workflows:
 ci_cd:
  jobs:
    - test
    - build
    - deploy

在配置了 CI 和 CD 工作流程后,您可以通过为 Gin RESTful API 构建新功能来测试它们。您可以按照以下步骤操作:

  1. 更新main.go文件,并使用 Gin 路由器在/version资源上公开一个新的端点。该端点将显示运行的 API 版本:

    router.GET("/version", VersionHandler)
    

    HTTP 处理器是自解释的;它返回API_VERSION环境变量的值:

    func VersionHandler(c *gin.Context) {
       c.JSON(http.StatusOK, gin.H{"version": os.Getenv(
           "API_VERSION")})
    }
    
  2. 要动态注入环境变量,您可以使用 Docker 参数功能,这允许您在构建时传递值。更新我们的Dockerfile并声明API_VERSION为构建参数和环境变量:

    FROM golang:1.16
    WORKDIR /go/src/github.com/api
    COPY . .
    RUN go mod download
    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    FROM alpine:latest 
    ARG API_VERSION
    ENV API_VERSION=$API_VERSION
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY --from=0 /go/src/github.com/api/app .
    CMD ["./app"]
    
  3. 接下来,通过注入$TAG变量作为API_VERSION构建参数的值来更新构建镜像步骤:

    run:
       name: Build image
        command: |
          TAG=0.1.$CIRCLE_BUILD_NUM
          docker build -t mlabouardy/
              recipes-api:$TAG --build-arg API_VERSION=${TAG} .
    
  4. 使用以下代码将新更改推送到 develop 分支:

    deploy job will run and deploy the application on EC2:![Figure 9.22 – Deploying Docker Stack    ](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Figure_9.22_B17115.jpg)Figure 9.22 – Deploying Docker Stack
    
  5. 要测试新更改,导航到实例 IP 地址,并将您的浏览器指向/api/version资源路径:

![图 9.23 – API 运行版本图片

图 9.23 – API 运行版本

这将返回 Gin RESTful API 运行的 Docker 镜像版本。

值得注意的是,当前的 CircleCI 配置不能保证作业总是按相同的顺序运行(测试 → 构建 → 部署)。为了保持 CI/CD 顺序,使用requires关键字,如下所示:

workflows:
 ci_cd:
   jobs:
     - test
     - build:
       requires:
           - test
     - deploy:
       requires:
           - test
           - build

这样,您可以确保只有当测试和构建作业都成功时,部署作业才会执行:

![图 9.24 – CI/CD 工作流程图片

![图 9.24 – CI/CD 工作流程太棒了!现在,我们为我们的 Gin 应用程序拥有了一个完整的 CI/CD 管道。# 维护多个运行环境在实际场景中,您需要多个环境来避免在验证之前将损坏的功能或重大错误推送到沙盒或预发布环境(或者更糟,生产环境)。您可以通过运行基于我们在前几章中创建的沙盒环境的新 EC2 实例来创建一个 EC2 实例以托管预发布环境:1. 选择 沙盒 实例,从操作栏中点击 操作。然后,从 镜像和模板 下拉列表中点击 启动更多类似项图 9.25 – 复制沙盒环境

图 9.25 – 复制沙盒环境

此选项将自动将所选实例的配置详细信息填充到 Amazon EC2 启动向导中。
  1. Name 标签的值更新为 staging 并点击 启动 以配置实例:图 9.26 – 在 EC2 实例中运行的预发布环境

    图 9.26 – 在 EC2 实例中运行的预发布环境

  2. 一旦实例启动并运行,更新 CircleCI 以根据管道运行的分支名称标记 Docker 镜像。除了通过 CIRCLE_BUILD_NUM 环境变量创建的 dynamic 标签外,如果当前分支是 develop、preprod 或 master,还应推送一个 fixed 标签(develop、preprod 或 master):

    run:
       name: Push image
       command: |
         TAG=0.1.$CIRCLE_BUILD_NUM
         aws ecr get-login-password --region REGION | docker 
             login --username AWS --password-stdin 
             ID.dkr.ecr.REGION.amazonaws.com
         docker tag USER/recipes-api:$TAG 
         ID.dkr.ecr.REGION.amazonaws.com/USER/recipes-api:$TAG
         docker push ID.dkr.ecr.REGION.amazonaws.com/
             USER/recipes-api:$TAG
         if [ "${CIRCLE_BRANCH}" == "master" ] || [ 
             "${CIRCLE_BRANCH}" == "preprod" ] || [ 
             "${CIRCLE_BRANCH}" == "develop" ];
        then
            docker tag USER/recipes-api:$TAG 
                ID.dkr.ecr.REGION.amazonaws.com/USER/
                recipes-api:${CIRCLE_BRANCH}
                 docker push ID.dkr.ecr.REGION.amazonaws.com/
                     USER/recipes-api:${CIRCLE_BRANCH}
          fi
    
  3. 接下来,更新 deploy 作业,以便可以根据当前的 Git 分支名称 SSH 到正确的 EC2 实例 IP 地址:

    run:
       name: Deploy with Docker Compose
       command: |
         if [ "${CIRCLE_BRANCH}" == "master" ]
         then
            ssh -oStrictHostKeyChecking=no ec2-user@IP_PROD 
                "docker-compose pull && docker-compose up -d"
        elif [ "${CIRCLE_BRANCH}" == "preprod" ]
         then
            ssh -oStrictHostKeyChecking=no             ec2-user@IP_STAGING 
                "docker-compose pull && docker-compose up -d"
         else
            ssh -oStrictHostKeyChecking=no             ec2-user@IP_SANDBOX 
                "docker-compose pull && docker-compose up -d"
         fi
    

    注意

    IP 地址(IP_PRODIP_STAGINGIP_SANDBOX)应在 CircleCI 项目的设置中定义为环境变量。

  4. 最后,更新工作流程,以便仅在当前 Git 分支是 developpreprodmaster 分支时部署更改:

    workflows:
     ci_cd:
       jobs:
         - test
         - build:
             requires:
               - test
         - deploy:
             requires:
               - test
               - build
             filters:
               branches:
                 only:
                   - develop
                   - preprod
                   - master
    
  5. 使用以下命令在 GitHub 上提交并存储更改:

    git add .
    git commit –m "continuous deployment"
    git push origin develop
    

    开发分支上会自动触发一个新的管道,更改将部署到沙盒环境:

图 9.27 – 部署到沙盒环境

图 9.27 – 部署到沙盒环境

一旦新更改已在沙盒环境中得到验证,您应该准备好将代码提升到预发布环境。

要部署到预发布环境,请按照以下步骤操作:

  1. 创建一个 拉取请求PR)以将开发分支合并到预生产分支,如下所示:图 9.28 – 创建拉取请求

    图 9.28 – 创建拉取请求

    注意,PR 已准备好合并,因为所有检查都已通过:

    图 9.29 – 成功的 Git 检查

    图 9.29 – 成功的 Git 检查

  2. 点击 合并拉取请求 按钮。预生产分支将触发一个新的管道,并依次执行三个作业 – 测试构建部署图 9.30 – 部署到预发布环境

    图 9.30 – 部署到预发布环境

  3. 构建 阶段的末尾,预生产分支的新镜像以及其 CircleCI 构建号将被存储在 ECR 存储库中,如下所示:图 9.31 – 预生产 Docker 镜像

    图 9.31 – 预生产 Docker 镜像

  4. 现在,使用docker-compose up命令将镜像部署到预发布环境。点击预发布环境的 IP 地址;你应该看到正在运行的 Docker 镜像的版本:

图 9.32 – Docker 镜像版本。

图 9.32 – Docker 镜像版本。

太好了!你现在有一个预发布环境,可以在将 API 功能提升到生产之前对其进行验证。

到目前为止,你已经学会了如何通过推送事件实现持续部署。然而,在生产环境中,你可能希望在将新版本发布到生产之前添加额外的验证。这就是持续交付实践发挥作用的地方。

实施持续交付

要将 Gin 应用程序部署到生产环境,你需要启动一个专门的 EC2 实例或 EKS 集群。在部署到生产环境之前,你必须进行手动验证。

使用 CircleCI,你可以使用pause_workflow作业与用户交互,在恢复管道之前请求批准。要这样做,请按照以下步骤操作:

  1. deploy作业之前添加pause_workflow作业,并定义一个release作业,如下所示:

    workflows:
     ci_cd:
       jobs:
         - test
         - build:
             requires:
               - test
         - deploy:
             requires:
               - test
               - build
             filters:
               branches:
                 only:
                   - develop
                   - preprod
         - pause_workflow:
             requires:
               - test
               - build
             type: approval
             filters:
               branches:
                 only:
                   - master
         - release:
             requires:
               - pause_workflow
             filters:
               branches:
                 only:
                   - master
    
  2. 将更改推送到 develop 分支。将触发一个新的管道,并将更改部署到你的沙盒环境。接下来,创建一个拉取请求并合并 develop 和 preprod 分支。现在,提出一个拉取请求以合并 preprod 分支和主分支:图 9.33 – 向主分支合并拉取请求

    图 9.33 – 向主分支合并拉取请求

  3. 一旦 PR 被合并,主分支上将被触发一个新的管道,并且将执行测试和构建作业:图 9.34 – 在主分支上运行 CI/CD 工作流程

    图 9.34 – 在主分支上运行 CI/CD 工作流程

  4. 当达到pause_workflow作业时,管道将被暂停,如下所示:图 9.35 – 请求用户批准

    图 9.35 – 请求用户批准

  5. 如果你点击pause_workflow框,将弹出一个确认对话框,你可以允许工作流程继续运行:图 9.36 – 批准部署

    图 9.36 – 批准部署

  6. 一旦批准,管道将恢复,部署阶段将被执行。在 CI/CD 管道的末尾,应用程序将被部署到生产环境:

图 9.37 – 将应用程序部署到生产环境

图 9.37 – 将应用程序部署到生产环境

太棒了!有了这个,你已经实现了持续交付!

在结束之前,你可以通过在 CircleCI 上触发新构建时向开发团队发送Slack通知来改进工作流程,提高意识。

通过 Slack 改进反馈循环

你可以使用 Slack RESTful API 在 Slack 频道中发布通知,或者使用预构建 Slack 命令的 CircleCI Slack orb 来改进反馈循环。

要这样做,请按照以下步骤操作:

  1. 将以下代码块添加到 test 作业中:

    - slack/notify:
             channel: "#ci"
             event: always
             custom: |
               {
                 "blocks": [
                   {
                     "type": "section",
                     "text": {
                       "type": "mrkdwn",
                       "text": "*Build has started*! 
                                 :crossed_fingers:"
                     }
                   },
                   {
                     "type": "divider"
                   },
                   {
                     "type": "section",
                     "fields": [
                       {
                         "type": "mrkdwn",
                         "text": "*Project*:\
                             n$CIRCLE_PROJECT_REPONAME"
                       },
                       {
                         "type": "mrkdwn",
                         "text": "*When*:\n$(date +'%m/%d/%Y 
                                   %T')"
                       },
                       {
                         "type": "mrkdwn",
                         "text": "*Branch*:\n$CIRCLE_BRANCH"
                       },
                       {
                         "type": "mrkdwn",
                         "text": "*Author*:                              \n$CIRCLE_USERNAME"
                       }
                     ],
                     "accessory": {
                       "type": "image",
                       "image_url": "https://media.giphy.com/
                        media/3orieTfp1MeFLiBQR2/giphy.gif",
                       "alt_text": "CircleCI logo"
                     }
                   },
                   {
                     "type": "actions",
                     "elements": [
                       {
                         "type": "button",
                         "text": {
                           "type": "plain_text",
                           "text": "View Workflow"
                         },
                         "url": "https://circleci.com/
                            workflow-run/${                                     CIRCLE_WORKFLOW_ID}"
                       }
                     ]
                   }
                 ]
               }
    
  2. 然后,通过导航到 api.slack.com/apps 并从页面标题点击 Build app 来在你的 Slack 工作空间中创建一个新的 Slack 应用程序:图 9.38 – 新 Slack 应用程序

    图 9.38 – 新 Slack 应用程序

  3. 给应用程序一个有意义的名称,然后点击 创建 App 按钮。在 OAuth & Permissions 页面上,在 Bot Token Scopes 下添加以下权限:图 9.39 – Slack 机器人权限

    图 9.39 – Slack 机器人权限

  4. 从左侧导航菜单中,点击 OAuth & Permissions 并复制 OAuth token:图 9.40 – 机器人用户 OAuth token

    图 9.40 – 机器人用户 OAuth token

  5. 返回 CircleCI 项目设置,并添加以下环境变量:

    SLACK_ACCESS_TOKEN:我们之前生成的 OAuth Token。

    SLACK_DEFAULT_CHANNEL:你想要发布 CircleCI 构建通知的 Slack 频道。

    你应该看到以下内容:

    图 9.41 – Slack 环境变量

    图 9.41 – Slack 环境变量

  6. 将新的 CircleCI 配置更新推送到 GitHub。此时,将执行一个新的管道:

图 9.42 – 管道开始时的 Slack 通知

图 9.42 – 管道开始时的 Slack 通知

将发送 Slack 通知到配置的 Slack 频道,包含项目名称和 Git 分支名称:

图 9.43 – 发送 Slack 通知

图 9.43 – 发送 Slack 通知

你甚至可以更进一步,根据管道的状态发送通知。例如,如果你想当管道失败时收到警报(为了简洁,已裁剪完整的 JSON),请添加以下代码:

- slack/notify:
     channel: "#ci"
     event: fail
     custom: |
       {
         "blocks": [
           {
             "type": "section",
             "text": {
                "type": "mrkdwn",
                "text": "*Tests failed, run for your 
                         life*! :fearful:"
              }
          ]
       }

你可以通过抛出一个不同于 0 的代码错误的错误来模拟管道失败:

- run:
     name: Unit tests
     command: |
       go test -v ./...
       exit 1

现在,将更改推送到 develop 分支。当管道到达 单元测试 步骤时,将抛出错误,并且管道将失败:

图 9.44 – 以编程方式抛出错误

图 9.44 – 以编程方式抛出错误

在 Slack 频道中,你应该会收到类似于以下截图所示的通知:

图 9.45 – 当管道失败时发送通知

图 9.45 – 当管道失败时发送通知

大致就是这样。本章仅仅触及了 CI/CD 流水线可以做到的事情的表面。然而,它应该为你提供了足够的基石,以便开始实验并构建你自己的 Gin 应用程序的端到端工作流程。

摘要

在本章中,你学习了如何从头开始设置 CI/CD 流水线,以使用 CircleCI 自动化 Gin 应用的部署过程。此外,使用 CircleCI orbs 通过简化我们编写 Pipeline as Code 配置的方式提高了生产力。

你还探索了如何使用 Docker 运行自动化测试,以及如何通过 GitFlow 和多个 AWS 环境实现持续部署。在这个过程中,你设置了 Slack 通知,以便在构建失败或成功时得到提醒。

本书最后一章将涵盖如何排查和调试在生产中运行的 Gin 应用程序。

问题

  1. 为我们在第五章,“使用 Gin 提供静态 HTML”中构建的 React 网络应用程序构建 CI/CD 流水线以自动化部署过程。

  2. 在成功完成生产部署时添加 Slack 通知。

进一步阅读

  • Mohamed Labouardy 著,Packt 出版的《使用 Go 进行实战型无服务器应用程序开发

  • Salle Ingle 著,Packt 出版的《在 AWS 上实现 DevOps

第十章:捕获 Gin 应用指标

在本章的最后一部分,你将学习如何在接近实时的情况下调试、故障排除和监控 RESTful API。你还将学习如何收集 Gin 应用指标来衡量 Gin 应用的性能,并对其异常行为进行剖析。除此之外,你还将探索如何使用 ELK 堆栈将 Gin 调试日志流式传输到集中式日志平台。

因此,我们将涵盖以下主题:

  • 使用 Prometheus 暴露 Gin 应用指标

  • 监控服务器端指标

  • 将 Gin 日志流式传输到 ELK 平台

到本章结束时,你将能够仪表化和监控在生产中运行的 Docker 化 Gin 网络应用,并轻松调试其日志。

技术要求

要跟随本章的内容,你需要以下条件:

  • 对前一章内容的完整理解。本章是前一章的后续,因为它将使用相同的源代码。因此,为了避免重复,一些代码片段将不会进行解释。

  • 假设你已经具备 Docker 和容器化的知识。

本章的代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter10

使用 Prometheus 暴露 Gin 指标

在前一章中,你学习了如何自动化 Gin 应用的部署过程。然而,没有任何应用能够免疫停机或外部攻击(DDoS)。这就是为什么你需要设置正确的工具来持续监控你应用的性能。Prometheus (prometheus.io) 是一种常见的开源监控工具。

你可以通过在终端会话中运行以下命令来安装 Go 客户端:

go get github.com/prometheus/client_golang

接下来,更新 main.go 文件,使其在 /prometheus 路径上暴露一个 HTTP 路由。路由处理程序将调用 Prometheus HTTP 处理程序,该处理程序将返回一系列运行时和应用指标:

router.GET("/prometheus", gin.WrapH(promhttp.Handler()))

然后,导入以下包以使用 promhttp 结构体:

"github.com/prometheus/client_golang/prometheus/promhttp" 

接下来,重新部署应用。如果你导航到 localhost:8080/prometheus,你应该看到以下指标:

![Figure 10.1 – Prometheus 默认指标

![Figure 10.1 – Prometheus 默认指标

图 10.1 – Prometheus 默认指标

此应用仅暴露默认指标。你也可以通过仪表化 Gin 应用代码来暴露你自己的自定义指标。让我们学习如何做到这一点。

对 Gin 应用进行仪表化

仪表化是监控和测量性能、检测错误以及获取表示应用状态的跟踪信息的能力。Prometheus 允许我们注入代码来近距离监控 Gin 应用。

要添加一个自定义指标,例如计算传入请求数量,请按照以下步骤操作:

  1. 首先,我们需要创建一个中间件来拦截传入的 HTTP 请求并增加计数器:

    var totalRequests = prometheus.NewCounterVec(
       prometheus.CounterOpts{
           Name: "http_requests_total",
           Help: "Number of incoming requests",
       },
       []string{"path"},
    )
    
  2. 然后,我们必须定义一个 Gin 中间件,如下所示:

    func PrometheusMiddleware() gin.HandlerFunc {
       return func(c *gin.Context) {
           totalRequests.WithLabelValues(
              c.Request.URL.Path).Inc()
           c.Next()
       }
    }
    
  3. init()方法体内注册totalRequests计数器:

    prometheus.Register(totalRequests)
    
  4. 然后,将PrometheusMiddleware中间件传递给 Gin 路由器:

    router.Use(PrometheusMiddleware())
    
  5. 重新启动应用程序,然后刷新/prometheus URL。

在响应中,您将看到每个路径的请求数量:

图 10.2 – 仪表化 Gin 代码

图 10.2 – 仪表化 Gin 代码

注意

您的输出可能不会显示像我那么多数据,因为您没有经常访问该应用程序。获取更多数据最好的方法是向 Recipes API 发出多个 HTTP 请求。

您还可以公开的另一个有用指标是每个 HTTP 方法接收到的 HTTP 请求数量。同样,定义一个全局计数器并为相应的 HTTP 方法增加计数器:

var totalHTTPMethods = prometheus.NewCounterVec(
   prometheus.CounterOpts{
       Name: "http_methods_total",
       Help: "Number of requests per HTTP method",
   },
   []string{"method"},
)
func PrometheusMiddleware() gin.HandlerFunc {
   return func(c *gin.Context) {
       totalRequests.WithLabelValues(
          c.Request.URL.Path).Inc()
       totalHTTPMethods.WithLabelValues(
          c.Request.Method).Inc()
       c.Next()
   }
}

init()方法体内注册totalHTTPMethods计数器并重新启动应用程序。

应用程序重新启动后,在响应负载中,您应该看到按 HTTP 方法分区的请求数量:

图 10.3 – 每个 HTTP 方法的请求数量

图 10.3 – 每个 HTTP 方法的请求数量

您还可以使用以下代码块记录每秒的 HTTP 请求延迟。我们使用Histogram而不是Counter来计数来自 HTTP 请求的个别观察值:

var httpDuration = promauto.NewHistogramVec(
   prometheus.HistogramOpts{
       Name: "http_response_time_seconds",
       Help: "Duration of HTTP requests",
   },
   []string{"path"},
)
func PrometheusMiddleware() gin.HandlerFunc {
   return func(c *gin.Context) {
       timer := prometheus.NewTimer(httpDuration.
          WithLabelValues(c.Request.URL.Path))
       totalRequests.WithLabelValues(
          c.Request.URL.Path).Inc()
       totalHTTPMethods.WithLabelValues(
          c.Request.Method).Inc()
       c.Next()
       timer.ObserveDuration()
   }
}

因此,您应该有类似以下的内容:

图 10.4 – HTTP 请求的持续时间

图 10.4 – HTTP 请求的持续时间

现在指标已经公开,您可以将它们存储在时间序列数据库中,并在其上构建一个交互式仪表板。定期了解应用程序的工作方式可以帮助您识别优化其性能的方法。

注意

另一个选择是使用以下由开源社区编写的 Go 库:github.com/zsais/go-gin-prometheus。它包含一组通用的指标。

要开始,请按照以下步骤操作:

  1. 使用以下docker-compose.yml文件通过官方 Docker 镜像部署 Prometheus:

    version: "3"
    services:
     api:
       build: .
       environment:
         - MONGO_URI=mongodb://admin:password@
                     mongodb:27017/test?authSource=admin
                     &readPreference=primary&ssl=false
         - MONGO_DATABASE=demo
         - REDIS_URI=redis:6379
         - API_VERSION=1.0.0
       ports:
         - 8080:8080
       external_links:
         - mongodb
         - redis
       restart: always
     redis:
       image: redis
       restart: always
     mongodb:
       image: mongo:4.4.3
       environment:
         - MONGO_INITDB_ROOT_USERNAME=admin
         - MONGO_INITDB_ROOT_PASSWORD=password
       restart: always
     prometheus:
       image: prom/prometheus:v2.27.0
       volumes:
         - ./prometheus.yml:/etc/prometheus/prometheus.yml
       ports:
         - 9090:9090
       restart: always
    

    Prometheus 容器使用一个prometheus.yml配置文件,它定义了一个后台作业来抓取 Golang Prometheus 指标端点:

    global:
     scrape_interval:     15s
     evaluation_interval: 15s
    scrape_configs:
     - job_name: prometheus
       static_configs:
         - targets: ['localhost:9090']
     - job_name: recipes-api
       metrics_path: /prometheus
       static_configs:
         - targets:
           - api:8080 
    
  2. 使用以下命令重新部署应用程序堆栈:

    docker-compose up -d
    

    栈日志应类似于以下内容:

    图 10.5 – Docker 堆栈日志

    图 10.5 – Docker 堆栈日志

  3. 通过访问您最喜欢的浏览器中的localhost:9090导航到 Prometheus 仪表板。您可以通过使用搜索栏和编写使用Prometheus 查询语言PromQL)的查询来探索可用的指标:![图 10.6 – 从 Prometheus 仪表板探索指标

    ![图 Figure_10.6_B17115.jpg]

    图 10.6 – 从 Prometheus 仪表板探索指标

  4. 通过点击图形选项卡将指标转换为图表:![图 10.7 – 使用 Prometheus 内置的图形功能

    ![图片 B17115_10_07_v2.jpg]

    图 10.7 – 使用 Prometheus 内置的图形功能

    您可以使用如 Grafana 这样的可视化平台构建高级图表。它总结了 Prometheus 中存储的数据并提供了广泛的 UI 组件来构建用户友好的仪表板。监控工作流程在以下图中说明:

    ![图 10.8 – 使用 Prometheus 和 Grafana 收集 Gin 指标

    ![图 Figure_10.8_B17115.jpg]

    图 10.8 – 使用 Prometheus 和 Grafana 收集 Gin 指标

  5. 使用以下代码片段在 Docker 容器中部署 Grafana:

    grafana:
       image: grafana/grafana:7.5.6
       ports:
         - 3000:3000
       restart: always
    
  6. 使用以下命令启动容器:

    docker-compose up –d
    
  7. 转到localhost:3000;您将被要求输入一些用户凭据。默认的用户名和密码都是 admin:![图 10.9 – Grafana 登录页面

    ![图片 Figure_10.9_B17115.jpg]

    图 10.9 – Grafana 登录页面

  8. 接下来,通过创建数据源来连接到 Prometheus。点击侧边栏中的配置。在数据源选项卡中,点击添加数据源按钮:![图 10.10 – 添加新的数据源

    ![图 Figure_10.10_B17115.jpg]

    图 10.10 – 添加新的数据源

  9. 之后,选择Prometheus并填写字段,如图所示。然后,点击页面底部的保存 & 测试按钮:

![图 10.11 – 配置 Prometheus 数据源

![图 Figure_10.11_B17115.jpg]

图 10.11 – 配置 Prometheus 数据源

您现在可以创建您的第一个 Grafana 仪表板了!

您可以通过在legend字段中使用{{path}}关键字的同时,点击http_requests_total表达式进入查询字段来开始。

您现在应该拥有以下图表配置,它表示每个路径随时间变化的 HTTP 请求总数:

![图 10.12 – HTTP 请求总数

![图片 B17115_10_12_v2.jpg]

图 10.12 – HTTP 请求总数

保存面板并创建一个新的面板,使用http_response_time_seconds_sum表达式显示随时间变化的已服务 HTTP 请求的响应时间:

![图 10.13 – HTTP 响应时间

![图 B17115_10_13_v2.jpg]

图 10.13 – HTTP 响应时间

您还可以创建一个单独的统计计数器来显示每个 HTTP 方法的总请求数,使用以下配置:

![图 10.14 – 使用 Grafana 的单统计组件

![图 B17115_10_14_v2.jpg]

图 10.14 – 使用 Grafana 的单个统计组件

您可以通过添加其他带有指标的面板并按您的喜好自定义来实验仪表板:

![图 10.15 – 交互式和动态的 Grafana 仪表板图片

图 10.15 – 交互式和动态的 Grafana 仪表板

注意

您可以从 GitHub 仓库下的chapter10文件夹中下载dashboard.json,它包含前面仪表板的 Grafana 配置。

监控服务器端指标

到目前为止,您已经学习了如何通过为 Gin 应用程序代码添加工具来监控应用侧的指标。在本节中,您将学习如何暴露服务器端指标并监控运行在 Gin 分布式 Web 应用程序上的容器的整体健康状况。

要收集服务器端指标,您可以使用一个名为Telegraf (github.com/influxdata/telegraf)的开源解决方案,这是一个数据收集代理DCA),可以从多个输入收集指标并将它们转发到不同的来源:

![图 10.16 – 使用 Telegraf 代理收集服务器端指标图片

图 10.16 – 使用 Telegraf 代理收集服务器端指标

Telegraf 可以很容易地使用 Docker 部署。将以下代码块添加到docker-compose.yml中:

telegraf:
   image: telegraf:latest
   volumes:
     - ./telegraf.conf:/etc/telegraf/telegraf.conf
     - /var/run/docker.sock:/var/run/docker.sock 

telegraf.conf包含 Telegraf 将从其中获取数据的数据源(输入)列表。它还包含数据将被转发到的目的地(输出)列表。在以下配置文件中,Telegraf 将收集关于服务器资源(内存、CPU、磁盘和网络流量)和 Docker 守护进程(每个容器的资源使用情况)的指标,然后将这些指标转发到 Prometheus 服务器:

[[inputs.cpu]]
 percpu = false
 totalcpu = true
 fieldpass = [ "usage*" ]
[[inputs.disk]]
 fielddrop = [ "inodes*" ]
 mount_points=["/"]
[[inputs.net]]
 interfaces = [ "eth0" ]
 fielddrop = [ "icmp*", "ip*", "tcp*", "udp*" ]
[[inputs.mem]]
[[inputs.swap]]
[[inputs.system]]
[[inputs.docker]]
 endpoint = "unix:///var/run/docker.sock"
 container_names = []
[[outputs.prometheus_client]]
listen = "telegraf:9100"

注意

您还可以将这些指标转发到 InfluxDB (github.com/influxdata/influxdb),一个可扩展的时间序列数据库,并将其连接到 Grafana。

接下来,在prometheus.yml中定义一个新的作业以抓取 Telegraf 容器暴露的指标:

global:
 scrape_interval:     15s
 evaluation_interval: 15s
scrape_configs:
 - job_name: prometheus
   static_configs:
     - targets: ['localhost:9090']
 - job_name: recipes-api
   metrics_path: /prometheus
   static_configs:
     - targets:
       - api:8080
 - job_name: telegraf
   scrape_interval: 15s
   static_configs:
     - targets: ['telegraf:9100'] 

完成这些后,使用以下命令重新启动堆栈:

docker-compose up -d

然后,返回到 Prometheus 仪表板,并从状态下拉列表中选择目标。应该已经添加了一个 Telegraf 目标到列表中:

![图 10.17 – Telegraf 作业正在运行图片

图 10.17 – Telegraf 作业正在运行

现在服务器端指标已在 Prometheus 中可用,您可以在 Grafana 中创建额外的面板。

例如,您可以从指标下拉菜单中选择docker_container_mem_usage_percent表达式来监控每个容器随时间变化的内存使用情况:

![图 10.18 – 每个容器的内存使用情况图片

图 10.18 – 每个容器的内存使用情况

添加额外的指标,以便您可以监控 CPU、磁盘使用情况或运行容器的整体健康指标:

![图 10.19 – 服务器端和应用端指标图 10.19 – 图 B17115_10_19_v2.jpg

图 10.19 – 服务器端和应用端指标

干得好!现在,你有一个相当互动的仪表板,只需花费最少的时间。

创建 Grafana 通知渠道

在上一章中,你学习了如何使用 Slack 来提高团队对 CI/CD 状态的认识,以便团队可以立即采取行动。当达到某个阈值时,你可以通过在 Grafana 仪表板上配置 Slack 警报来监控 Gin 应用程序,采用相同的方法。

从 Grafana 仪表板中,点击 警报 图标,然后点击 通知渠道。点击 添加渠道 按钮,将类型更改为 Slack。然后输入 Webhook URL:

![图 10.20 – 配置 Slack 通知渠道图 10.20 – 图 10.20_B17115.jpg

图 10.20 – 配置 Slack 通知渠道

注意

要获取如何创建 Slack 应用程序并生成 Webhook URL 的分步指南,请查看 第九章实现 CI/CD 管道

要测试配置,请点击 测试 按钮。你应该会在配置的 Slack 频道中收到类似以下的消息:

![图 10.21 – 来自 Grafana 的 Slack 测试消息图 10.21 – 图 10.21_B17115.jpg

图 10.21 – 来自 Grafana 的 Slack 测试消息

现在你有了通知渠道,你可以在仪表板面板上创建一个警报规则。例如,在之前创建的 HTTP 请求 图表上创建一个警报规则,并在 通知 部分选择通知渠道。规则将如下所示:

![图 10.22 – 在 Grafana 中创建警报规则图 10.22 – 图 10.22_B17115.jpg

图 10.22 – 在 Grafana 中创建警报规则

每 30 秒,Grafana 将评估平均 HTTP 请求次数是否超过 1,000 次。如果指标违反此规则,Grafana 将等待 2 分钟。如果 2 分钟后指标仍未恢复,Grafana 将触发警报,并发送 Slack 通知。

要测试警报规则,你需要生成工作负载。你可以使用 Apache Benchmark 通过以下命令以并行方式向 Recipes API 发送 1,500 个请求:

ab -n 1500 http://localhost:8080/recipes

在这里,/recipes 端点的请求数量将超过 1,000 的阈值,如下面的图表所示:

![图 10.23 – 达到 1,000 请求限制图 10.23 – 图 10.23_B17115.jpg

图 10.23 – 达到 1,000 请求限制

2 分钟后,警报将被触发,你将在 Slack 频道中看到以下消息:

![图 10.24 – 来自 Grafana 的 Slack 警报图 10.24 – 图 10.24_B17115.jpg

图 10.24 – 来自 Grafana 的 Slack 警报

注意

设置指标警报的另一种选项是使用 Prometheus Alertmanager (prometheus.io/docs/alerting/latest/alertmanager)。

拥有 Slack 通知可以帮助你在生产环境中事情变得非常糟糕之前立即采取行动。

将 Gin 日志流式传输到 ELK 平台

在部署 Gin Web 应用程序到生产环境中时,另一个需要关注的益处是日志。日志可以帮助你找到应用程序性能不佳或崩溃的根本原因。

然而,日志可能非常冗长且垃圾信息多 – 这就是为什么你需要一个集中式平台来应用过滤器并关注重要事件。这就是为什么需要一个像ElasticsearchLogstashKibanaELK)这样的解决方案。以下方案说明了如何实现这样的解决方案:

图 10.25 – 将 Gin 日志流式传输到 ELK

图 10.25 – 将 Gin 日志流式传输到 ELK

Gin 应用程序日志将通过 Docker GELF 驱动程序([docs.docker.com/config/containers/logging/gelf/](https://docs.docker.com/config/containers/logging/gelf/))发送到 Logstash。从那里,Logstash 将处理传入的日志并将它们存储在 Elasticsearch 中。最后,日志可以通过 Kibana 的交互式仪表板进行可视化。

使用 Docker 部署 ELK 堆栈

到现在为止,你应该已经熟悉了 Docker,并且能够使用它通过 Docker Compose 部署 Docker 化的 ELK 堆栈。为此,请按照以下步骤操作:

  1. docker-compose.yml开始。容器使用最新的 Docker 镜像 v7.12.1(在撰写本章时):

    logstash:
       image: docker.elastic.co/logstash/logstash:7.12.1
       command: logstash -f /etc/logstash/logstash.conf
       volumes:
         - ./logstash.conf:/etc/logstash/logstash.conf
       ports:
         - "5000:5000"
         - "12201:12201"
         - "12201:12201/udp"
    
  2. 容器使用以下内容的logstash.conf

    input {
         gelf {
              type => docker
             port => 12201
           }          
    }
    output {
       elasticsearch {
          hosts => "elasticsearch:9200"
          index => "containers-%{+YYYY.MM.dd}"
       }
    }
    
  3. 接下来,部署负责存储和索引传入日志的第二个组件。Elasticsearch 可以以单节点模式部署,以下配置:

    elasticsearch:
       image: docker.elastic.co/elasticsearch
          /elasticsearch:7.12.1
       ports:
         - 9200:9200
       environment:
         - discovery.type=single-node
    

    注意

    对于生产使用,强烈建议以集群模式部署 Elasticsearch,并使用多个数据节点以实现高可用性和弹性。

  4. 然后,部署第三个组件以交互式方式可视化传入的 Gin 日志。以下 YAML 块负责部署 Kibana:

    kibana:
       image: docker.elastic.co/kibana/kibana:7.12.1
       ports:
         - 5601:5601
       environment:
         - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    

你的 ELK 堆栈现在已配置完成!

配置好 ELK 堆栈后,你需要将 Gin 应用程序日志流式传输到 Logstash。幸运的是,Docker 内置了支持 Logstash 的GELF驱动程序。要将 Gin 应用程序日志流式传输到 Logstash,请执行以下步骤:

  1. 将以下logging部分添加到 Recipes API YAML 块中:

    api:
       build: .
       environment:
         - MONGO_URI=mongodb://admin:password
              @mongodb:27017/test?authSource=admin
              &readPreference=primary&ssl=false
         - MONGO_DATABASE=demo
         - REDIS_URI=redis:6379
         - API_VERSION=1.0.0
       ports:
         - 8080:8080
       restart: always
       logging:
         driver: gelf
         options:
           gelf-address: "udp://127.0.0.1:12201"
           tag: "recipes-api"
    
  2. 使用docker-compose up –d重新部署整个堆栈。你可以通过运行docker-compose ps命令来检查是否所有服务都在运行:图 10.26 – 运行中的 Docker 服务列表

    图 10.26 – 运行中的 Docker 服务列表

    注意

    确保 Docker Engine 至少分配了 4 GiB 的内存。在 Docker Desktop 中,你可以在首选项中的高级选项卡中配置资源使用情况。

  3. 然后,将你的浏览器指向localhost:5601。你应该会看到 Kibana 仪表板:图 10.27 – Kibana 欢迎页面

    图 10.27 – Kibana 欢迎页面

  4. 接下来,点击添加数据并选择Elasticsearch 日志作为数据源:![图 10.28 – 从 Elasticsearch 添加数据 图片

    图 10.28 – 从 Elasticsearch 添加数据

  5. 索引模式名称字段中点击containers-*。星号用于包含来自 Logstash 的所有日志。然后,点击下一步按钮:![图 10.29 – 创建索引模式 图片

    图 10.29 – 创建索引模式

  6. 选择@timestamp作为与全局时间过滤器一起使用的首选时间字段。然后,点击containers索引:![图 10.31 – containers 索引中可用的字段列表 图片

    图 10.31 – containers 索引中可用的字段列表

  7. 当 Elasticsearch 与 Kibana 连接时,点击分析部分侧边栏中的发现。您应该看到来自 Gin RESTful API 的日志流:![图 10.32 – Kibana 中的 Gin 日志 图片

    图 10.32 – Kibana 中的 Gin 日志

    注意

    对于生产使用,您可以使用 curator 工具(www.elastic.co/guide/en/elasticsearch/client/curator/index.html)从 Elasticsearch 中删除 X 天前的索引。

  8. 展开日志列表中的一个行。

您应该看到 Gin 应用程序日志存储在一个名为message的字段中:

![图 10.33 – 消息字段内容图片

图 10.33 – 消息字段内容

现在,您有一个可以读取 Gin 日志的工作管道。然而,您会注意到日志消息的格式并不理想。您可以使用 Grok 表达式解析此字段并将重要信息拆分到多个字段中。

编写 Grok 表达式

Grok 表达式通过使用正则表达式解析文本模式并将它们分配给一个标识符来工作。语法是%{PATTERN:IDENTIFIER}。我们可以编写一系列 Grok 模式并将以下日志消息的各个部分分配给不同的标识符:

[GIN] 2021/05/13 - 18:45:44 | 200 |   37.429912ms |   172.26.0.1 | GET   "/recipes" 

Grok 模式如下:

%{DATE:date} - %{TIME:time} \| %{NUMBER:status} \| %{SPACE} %{NUMBER:requestDuration}%{GREEDYDATA:unit} \| %{SPACE} %{IP:clientIp} \| %{WORD:httpMethod} %{SPACE} %{QUOTEDSTRING:url}

注意

Grok 自带一组模式字典,您可以直接使用。然而,您始终可以定义自己的自定义模式。

您可以使用Dev Tools页面上的Grok 调试器功能测试模式。在样本数据字段中输入前面的消息,在Grok 模式中输入 Grok 模式。

然后,点击模拟;您将看到应用 Grok 模式后产生的模拟事件:

![图 10.34 – 将 Grok 模式应用于样本数据图片

图 10.34 – 将 Grok 模式应用于样本数据

注意

如果发生错误,您可以继续迭代自定义模式,直到输出与您期望的事件匹配。

现在您已经有一个工作的 Grok 模式,您可以在 Logstash 级别应用解析。为此,更新logstash.conf文件,使其包括一个过滤器部分,如下所示:

input {
   gelf {
       type => docker
       port => 12201
   }      
}
filter {
   grok {
       match => {"message" => "%{DATE:date} - %{TIME:time} 
                 \| %{NUMBER:status} \| %{SPACE}    
                %{NUMBER:requestDuration}%{GREEDYDATA:unit} 
                \| %{SPACE} %{IP:clientIp} 
                \| %{WORD:httpMethod} %{SPACE} 
                %{QUOTEDSTRING:url}"}
   }
}
output {
   elasticsearch {
       hosts => "elasticsearch:9200"
       index => "containers-%{+YYYY.MM.dd}"
   }
} 

现在,如果您重新启动 Logstash 容器,传入的日志应该被解析并分割成多个字段:

图 10.35 – 消息字段分割成多个字段

图 10.35 – 消息字段分割成多个字段

创建一个新的仪表板并单击 创建面板 以创建一个新的图表:

图 10.36 – 创建新的 Kibana 仪表板

图 10.36 – 创建新的 Kibana 仪表板

status.keyword 字段拖放到面板中。然后,选择一个 堆叠柱状图。您应该得到以下图表,它表示每个 HTTP 状态码的请求数量:

图 10.37 – 使用 Kibana 图表构建器构建图表

图 10.37 – 使用 Kibana 图表构建器构建图表

您可以将堆叠柱状图保存为小部件并将其导入仪表板。使用仪表板,您可以将多个可视化组合到单个页面上,然后通过提供搜索查询或通过在可视化中单击元素来选择过滤器来过滤它们。仪表板在您想要获取 Gin 应用程序日志的概览并在不同可视化之间建立关联时非常有用。

更新 Gin 日志格式

默认情况下,Gin 将每个请求字段记录到 标准输出stdout),这对于故障排除和调试 HTTP 请求错误非常棒。然而,对于其他开发者来说,这可能过于冗长,他们可能会很快迷失方向并错过重要事件。幸运的是,您可以通过创建自定义日志格式来覆盖此默认行为。

要使用 Gin 创建自定义日志格式,请从以下代码块开始:

router.Use(gin.LoggerWithFormatter(func(
                    param gin.LogFormatterParams) string {
       return fmt.Sprintf("[%s] %s %s %d %s\n",
           param.TimeStamp.Format("2006-01-02T15:04:05"),
           param.Method,
           param.Path,
           param.StatusCode,
           param.Latency,
       )
}))

代码将记录请求时间戳、HTTP 方法、路径、状态码和持续时间:

图 10.38 – Gin 自定义日志格式

图 10.38 – Gin 自定义日志格式

默认情况下,Gin 将所有日志输出到 stdout,但您可以通过设置 GIN_MODE 为发布模式来禁用它们,以下命令:

GIN_MODE=release go run main.go

图 10.39 – 以发布模式运行 Gin

图 10.39 – 以发布模式运行 Gin

您还可以通过以下代码块覆盖日志目标,使其成为文件而不是 stdout

gin.DisableConsoleColor()
f, _ := os.Create("debug.log")
gin.DefaultWriter = io.MultiWriter(f)

因此,应该会创建一个名为 debug.log 的新文件,与应用程序日志一起:

图 10.40 – 将日志流式传输到文件

图 10.40 – 将日志流式传输到文件

您可以使用 Filebeat 将文件内容流式传输到 Elasticsearch。Filebeat 可以作为 Logstash 的替代品:

图 10.41 – 使用 Filebeat 将日志文件发送到 ELK

图 10.41 – 使用 Filebeat 将日志文件发送到 ELK

将以下 YAML 块添加到 docker-compose.yml 以基于 Filebeat v7.12.1 映像部署容器:

filebeat:
   image: docker.elastic.co/beats/filebeat:7.12.1
   volumes:
     - ./filebeat.yml:/usr/share/filebeat/filebeat.yml
     - ./debug.log:/var/log/api/debug.log

容器将在 /usr/share/filebeat 中查找配置文件。配置文件通过绑定挂载提供(见 部分)。文件的内容如下。它将监听来自 /var/log/api/debug.log 的日志,并将接收到的任何日志回显到 Elasticsearch:

filebeat.inputs:
- type: log
  paths:
   - /var/log/api/debug.log
output.elasticsearch:
  hosts: 'http://elasticsearch:9200'

使用 docker-compose up –d 命令重新启动堆栈。正在运行的 Docker 服务列表如下:

图 10.42 – 以 Docker 容器形式运行的 Filebeat

图 10.42 – 以 Docker 容器形式运行的 Filebeat

向 Recipes API 发出几项请求。此时,Gin 将将日志转发到 debug.log,Filebeat 将它们流式传输到 Elasticsearch。从那里,你可以在 Kibana 中实时可视化它们:

图 10.43 – 来自 Filebeat 的日志可视化

图 10.43 – 来自 Filebeat 的日志可视化

太好了!你现在可以使用 Kibana 仪表板实时分析 Gin 日志。分析这些日志可以提供大量信息,有助于解决 Gin 应用程序失败的根本原因。

摘要

在本章中,你学习了如何使用 Prometheus 仪器化 Gin 应用程序代码以暴露应用程序端指标。你看到了如何使用 Grafana 构建动态仪表板,以近乎实时地监控 Gin 应用程序的整体健康状况,以及当某些阈值被跨越时如何触发 Slack 警报。

然后,你学习了如何将 Gin 日志流式传输到使用开源工具(如 Logstash、Elasticsearch 和 Kibana)构建的集中式日志平台。在这个过程中,你学习了如何使用 Grok 模式解析 Gin 日志,以及如何在这些解析字段之上构建图表。

恭喜!现在,你可以从头开始设计、构建和部署分布式 Gin 应用程序。你还有一个关于如何自动化部署工作流程和在生产中监控运行中的 Gin 应用程序的良好基础。

进一步阅读

  • 《学习 Grafana 7.0》 由 Eric Salituro 著,Packt 出版

  • 《使用 Prometheus 进行动手基础设施监控》 由 Joel Bastos 和 Pedro Arajo 著,Packt 出版

结论

我们已经完成了这本书的旅程!你已经走到了尽头。我希望你对这段旅程感到自豪。你已经了解了 Gin 框架的方方面面,并构建了一个完全功能化的分布式 Gin 应用程序。

到现在为止,你应该已经知道如何构建一个可扩展的 Docker 化 Gin 应用程序了,从处理多个 Git 分支的 GitFlow 到在 AWS 上使用 CI/CD 管道自动化构建,再到近乎实时地故障排除和监控,以及使用 OpenAPI 生成 API 文档。

在这本书中有很多东西需要吸收和学习,尤其是如果你这是第一次接触 Gin 框架。我发现最好的学习方式是通过实践,所以请将你构建的 RESTful API 添加新功能。如果你真的构建了什么,请与我联系并告诉我你做了什么。

第十一章:评估

本节包含所有章节的问题答案。

第一章 – 开始使用 Gin

  1. Golang 目前是软件开发行业中增长最快的编程语言之一。它是一种轻量级、开源的语言,适合今天的微服务架构。

  2. 存在多个 Web 框架,其中最受欢迎的是 GinMartiniGorilla

  3. Go 模块是将一组包组合在一起并为其指定一个版本号以标记其在特定时间点存在的方式。

  4. 由 Gin 框架支持的 HTTP 服务器的默认端口是 8080

  5. 您可以使用 c.JSON()c.XML() 方法返回字面量 JSON 或 XML 结构体。

第二章 – 设置 API 端点

  1. GitFlow 是开发者在使用版本控制时可以遵循的分支策略。要应用 GitFlow 模型,您需要一个中央 Git 仓库和两个主要分支:

    主分支:它存储官方发布历史。

    开发:它作为功能集成的分支。

  2. 模型是具有基本 Go 类型的普通结构体。要在 Go 中声明结构体,请使用以下格式:

    Type ModelName struct{ 
        Field1 TYPE 
        Fiel2 TYPE 
    }
    
  3. 要将请求体绑定到类型,我们使用 Gin 模型绑定。Gin 支持绑定 JSON、XML 和 YAML。Gin 提供了两套绑定方法:

    应该绑定:ShouldBindJSON()ShouldBindXML()ShouldBindYAML()

    必须绑定:使用指定的绑定引擎绑定结构体指针。如果发生任何错误,它将终止请求并返回 HTTP 400。

  4. 首先,定义一个带有 ID 作为路径参数的路由:

    router.GET("/recipes/:id", GetRecipeHandler)
    

    GetRecipeHandler 函数解析 ID 参数,并使用 go 循环遍历配方列表。如果 ID 与列表中的配方匹配,则将其返回,否则将抛出 404 错误,如下所示:

    func GetRecipeHandler(c *gin.Context) {
         id := c.Query("id")
         for i := 0; i < len(recipes); i++ {
              if recipes[i].ID == id {
                   c.JSON(http.StatusOK, recipes[i])
              }
         }
         c.JSON(http.StatusNotFound, gin.H{"error": "Recipe  	                                       not found"})
    }
    
  5. 要定义一个参数,我们使用 swagger:parameters 注解:

    // swagger:parameters recipes newRecipe
    type Recipe struct {
         //swagger:ignore
         ID string `json:"id"`
         Name string `json:"name"`
         Tags []string `json:"tags"`
         Ingredients []string `json:"ingredients"`
         Instructions []string `json:"instructions"`
         PublishedAt time.Time `json:"publishedAt"`
    }
    

    使用 swagger generate 命令生成规范并在 Swagger UI 上加载结果。

    您现在可以直接从 Swagger UI 填写配方字段来发出 POST 请求。

第三章 – 使用 MongoDB 管理数据持久性

  1. 您可以使用 collection.DeleteOne()collection.DeleteMany() 删除配方。在这里,您将 bson.D({}) 作为过滤器参数传递,这将匹配集合中的所有文档。

    按照以下方式更新 DeleteRecipeHandler

    func (handler *RecipesHandler) DeleteRecipeHandler(c *gin.Context) {
       id := c.Param("id")
       objectId, _ := primitive.ObjectIDFromHex(id)
       _, err := handler.collection.DeleteOne(handler.ctx, bson.M{
           "_id": objectId,
       })
       if err != nil {
           c.JSON(http.StatusInternalServerError,  	 	 	              gin.H{"error": err.Error()})
           return
       }
       c.JSON(http.StatusOK, gin.H{"message": "Recipe has  	                               been deleted"})
    }
    

    确保按照以下方式在 DELETE /recipes/{id} 资源上注册处理器:

    router.DELETE("/recipes/:id", recipesHandler.DeleteRecipeHandler)
    
  2. 要查找配方,您需要一个过滤器文档以及一个指向可以解码结果的值的指针。要查找单个配方,请使用 collection.FindOne()。此方法返回单个结果,可以解码到 Recipe 结构体。您将使用与更新查询中相同的过滤器变量来匹配 ID 为 600dcc85a65917cbd1f201b0 的配方。

    main.go 文件上注册处理器:

    router.GET("/recipes/:id", recipesHandler.GetOneRecipeHandler)
    

    然后,在 handler.go 中声明 GetOneRecipeHandler,内容如下:

    func (handler *RecipesHandler) GetOneRecipeHandler(c *gin.Context) {
       id := c.Param("id")
       objectId, _ := primitive.ObjectIDFromHex(id)
       cur := handler.collection.FindOne(handler.ctx, bson.M{
           "_id": objectId,
       })
       var recipe models.Recipe
       err := cur.Decode(&recipe)
       if err != nil {
           c.JSON(http.StatusInternalServerError, 	 	 	              gin.H{"error": err.Error()})
           return
       }
       c.JSON(http.StatusOK, recipe)
    }
    
  3. MongoDB 中的 JSON 文档以称为 BSON二进制编码的 JSON)的二进制表示形式存储。此格式包括如下的附加类型:

    1. 双精度

    2. 字符串

    3. 对象

    4. 数组

    5. 二进制数据

    6. 未定义

    7. 对象 ID

    8. 布尔

    9. 日期

    10. 空值

    这使得应用程序能够更可靠地处理、排序和比较数据。

  4. 最近最少使用LRU)算法使用最近过去来近似近未来。它简单地删除了最长时间未被使用的键。

第四章 – 构建 API 身份验证

  1. 为了创建用户或注册他们,我们需要定义一个带有 SignUpHandler 的 HTTP 处理器,如下所示:

    func (handler *AuthHandler) SignUpHandler(c *gin.Context) {
       var user models.User
       if err := c.ShouldBindJSON(&user); err != nil {
           c.JSON(http.StatusBadRequest, gin.H{"error":                                            err.Error()})
           return
       }
       cur := handler.collection.FindOne(handler.ctx, bson.M{
           "username": user.Username,
       })
       if curTalent.Err() == mongo.ErrNoDocuments {
           err := handler.collection.InsertOne(handler.ctx,  	                                           user)
           if err != nil {
               c.JSON(http.StatusInternalServerError, 	 	                  gin.H{"error": err.Error()})
               return
           }
           c.JSON(http.StatusAccepted, gin.H{"message": 	 	              "Account has been created"})
       }
       c.JSON(http.StatusInternalServerError, gin.H{"error":  	          "Username already taken"})
    }
    Then, register the handler on POST /signup route:
    router.POST("/signup", authHandler.SignUpHandler)
    

    为了确保用户名字段在所有用户的条目中都是唯一的,你可以为用户名字段创建一个唯一索引。

  2. 定义一个具有以下主体的 ProfileHandler

    func (handler *AuthHandler) ProfileHandler(c *gin.Context) {
       var user models.User
       username, _ := c.Get("username")
       cur := handler.collection.FindOne(handler.ctx, bson.M{
           "username": user.Username,
       })
       cur.Decode(&user)
       c.JSON(http.StatusAccepted, user)
    }
    Register the HTTP handler on the router group as below:
    authorized := router.Group("/")
    authorized.Use(authHandler.AuthMiddleware()){
           authorized.POST("/recipes",                        recipesHandler.NewRecipeHandler)
           authorized.PUT("/recipes/:id",                       recipesHandler.UpdateRecipeHandler)
           authorized.DELETE("/recipes/:id",                       recipesHandler.DeleteRecipeHandler)
           authorized.GET("/recipes/:id",                       recipesHandler.GetOneRecipeHandler)
           authorized.GET("/profile",                       authHandler.ProfileHandler)
    }
    
  3. SignOutHandler 签名上方添加以下 Swagger 注解:

    // swagger:operation POST /signout auth signOut
    // Signing out
    // ---
    // responses:
    //     '200':
    //         description: Successful operation
    func (handler *AuthHandler) SignOutHandler(c *gin.Context) {}
    

第五章 – 在 Gin 中提供静态 HTML

  1. 创建一个包含以下内容的 header.tmpl 文件:

    <head>
        <title>Recipes</title>
        <link rel="stylesheet" href="/assets/css/app.css">
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    

    然后,使用以下代码块在 recipe.tmpl 中引用该文件:

    {{template "/templates/header.tmpl.tmpl"}}
    

    按照相同的方法创建一个可重复使用的模板用于页脚部分。

  2. NewRecipe.js 组件的完整源代码可在 GitHub 仓库中找到,位于 第五章在 Gin 中提供静态 HTML 文件夹下。

  3. 通过设置指定目标操作系统和架构的必要环境变量来实现交叉编译。我们使用变量 GOOS 来指定目标操作系统,使用 GOARCH 来指定目标架构。要构建可执行文件,命令将采取以下形式:

    GOOS=target-OS GOARCH=target-architecture go build –o main *.go
    

    例如,为了构建 Windows 的二进制文件,你可以使用以下命令:

    GOOS=windows GOARCH=amd64 go build –o main main.go
    

第七章 – 测试 Gin HTTP 路由

  1. main_test.go 中定义 TestUpdateRecipeHandler 如下:

    func TestUpdateRecipeHandler(t *testing.T) { 
       ts := httptest.NewServer(SetupServer()) 
       defer ts.Close() 
    
       recipe := Recipe{ 
           ID:   "c0283p3d0cvuglq85log", 
           Name: "Oregano Marinated Chicken", 
       } 
    
       raw, _ := json.Marshal(recipe) 
       resp, err := http.PUT(fmt.Sprintf("%s/recipes/%s", ts.URL, recipe.ID), bytes.NewBuffer(raw)) 
       defer resp.Body.Close() 
       assert.Nil(t, err) 
       assert.Equal(t, http.StatusOK, resp.StatusCode) 
       data, _ := ioutil.ReadAll(resp.Body) 
    
       var payload map[string]string 
       json.Unmarshal(data, &payload) 
    
       assert.Equal(t, payload["message"], "Recipe has been updated") 
    } 
    Define TestDeleteRecipeHandler in main_test.go as follows: 
    func TestDeleteRecipeHandler(t *testing.T) { 
       ts := httptest.NewServer(SetupServer()) 
       defer ts.Close() 
    
       resp, err := http.DELETE(fmt.Sprintf("%s/recipes/c0283p3d0cvuglq85log", ts.URL)) 
       defer resp.Body.Close() 
       assert.Nil(t, err) 
       assert.Equal(t, http.StatusOK, resp.StatusCode) 
       data, _ := ioutil.ReadAll(resp.Body) 
    
       var payload map[string]string 
       json.Unmarshal(data, &payload) 
    
       assert.Equal(t, payload["message"],                 "Recipe has been deleted") 
    } 
    
  2. main_test.go 中定义 TestFindRecipeHandler 如下:

    func TestDeleteRecipeHandler(t *testing.T) { 
       ts := httptest.NewServer(SetupServer()) 
       defer ts.Close() 
    
       resp, err := http.DELETE(fmt.Sprintf("%s/recipes          /c0283p3d0cvuglq85log", ts.URL)) 
       defer resp.Body.Close() 
       assert.Nil(t, err) 
       assert.Equal(t, http.StatusOK, resp.StatusCode) 
       data, _ := ioutil.ReadAll(resp.Body) 
    
       var payload map[string]string 
       json.Unmarshal(data, &payload) 
    
       assert.Equal(t, payload["message"],                 "Recipe has been deleted") 
    }
    
  3. main_test.go 中定义 TestFindRecipeHandler 如下:

    func TestFindRecipeHandler(t *testing.T) { 
       ts := httptest.NewServer(SetupServer()) 
       defer ts.Close() 
    
       expectedRecipe := Recipe{ 
           ID:   "c0283p3d0cvuglq85log", 
           Name: "Oregano Marinated Chicken", 
           Tags: []string{"main", "chicken"}, 
       } 
    
       resp, err := http.GET(fmt.Sprintf("%s/recipes/c0283p3d0cvuglq85log", ts.URL)) 
       defer resp.Body.Close() 
       assert.Nil(t, err) 
       assert.Equal(t, http.StatusOK, resp.StatusCode) 
       data, _ := ioutil.ReadAll(resp.Body) 
    
       var actualRecipe Recipe 
       json.Unmarshal(data, &actualRecipe) 
    
       assert.Equal(t, expectedRecipe.Name,                 actualRecipe.Name) 
       assert.Equal(t, len(expectedRecipe.Tags), len(actualRecipe.Tags)) 
    }
    

第八章 – 在 AWS 上部署应用程序

  1. 使用以下命令创建一个 Docker 卷:

    docker volume create mongodata
    

    然后,在运行 Docker 容器时挂载该卷:

    docker run -d -p 27017:27017 -v mongodata:/data/db --name mongodb mongodb:4.4.3
    
  2. 要部署 RabbitMQ,可以使用 docker-compose.yml 部署基于 RabbitMQ 官方镜像的附加服务,如下所示:

    rabbitmq:
         image: rabbitmq:3-management
         ports:
           - 8080:15672
         environment:
           - RABBITMQ_DEFAULT_USER=admin
           - RABBITMQ_DEFAULT_PASS=password
    
  3. 以 Kubernetes 机密的形式创建用户的凭证:

    mongodb-deployment.yaml to use the Kubernetes secret:
    
    

    apiVersion: apps/v1

    kind: Deployment

    metadata:

    annotations:

    kompose.cmd: kompose convert

    kompose.version: 1.22.0 (955b78124)

    creationTimestamp: null

    labels:

    io.kompose.service: mongodb

    name: mongodb

    spec:

    replicas: 1

    selector:

    matchLabels:

    io.kompose.service: mongodb
    

    strategy:

    template:

    metadata:

    annotations:
    
    kompose.cmd: kompose convert
    
    kompose.version: 1.22.0 (955b78124)
    
    creationTimestamp: null
    
    标签:
    
    io.kompose.service: mongodb
    

    规范:

    容器:
    
    - 环境变量:
    
        - 名称:MONGO_INITDB_ROOT_PASSWORD
    
            值来源:
    
            密钥引用:
    
                名称:mongodb-password
    
                键:password
    
        - 名称:MONGO_INITDB_ROOT_USERNAME
    
            值:admin
    
        镜像:mongo:4.4.3
    
        名称:mongodb
    
        端口配置:
    
        - 容器端口:27017
    
        资源:{}
    
    重启策略:Always
    

    状态:

    
    
  4. 使用 kubectl 缩放 API pods,请执行以下命令:

    kubectl scale deploy
    

第九章 – 实施 CI/CD 管道

  1. 管道将包含以下阶段:

    1. 从 GitHub 仓库检出源代码。

    2. 使用 npm install 命令安装 NPM 包。

    3. 使用 npm run build 命令生成资源。

    4. 安装 AWS CLI 并将新资源推送到 S3 存储桶。

    5. config.yml 配置如下:

    version: 2.1
    executors:
     environment:
       docker:
         - image: node:lts
       working_directory: /dashboard
    jobs:
     build:
       executor: environment
       steps:
         - checkout
         - restore_cache:
             key: node-modules-{{checksum "package.json"}}
         - run:
             name: Install dependencies
             command: npm install
         - save_cache:
             key: node-modules-{{checksum "package.json"}}
             paths:
               - node_modules
         - run:
             name: Build artifact
             command: CI=false npm run build
         - persist_to_workspace:
             root: .
             paths:
               - build
     deploy:
       executor: environment
       steps:
         - attach_workspace:
             at: dist
         - run:
             name: Install AWS CLI
             command: |
               apt-get update
               apt-get install -y python3-pip
               pip3 install awscli
         - run:
             name: Push to S3 bucket
             command: |
               cd dist/build/dashboard/
               aws configure set preview.cloudfront true
               aws s3 cp --recursive . s3://YOUR_S3_BUCKET/ --region YOUR_AWS_REGION
    workflows:
     ci_cd:
       jobs:
         - build
         - deploy:
             requires:
               - build
             filters:
               branches:
                 only:
                   - master
    

    在运行管道之前,您需要给 CircleCI IAM 用户授予 S3:PutObject 访问权限。

  2. 您可以配置 Slack ORB,以便在管道成功时发送通知,如下所示:

    - slack/notify:
         event: pass
         custom: |
          {
            "blocks": [
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "Current Job: $CIRCLE_JOB"
                }
              },
              {
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "New release has been successfully  	                       deployed!"
                }
              }
             ]
          }
    

Packt 标志

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及行业领先的工具,帮助您规划个人发展并推进职业生涯。更多信息,请访问我们的网站。

第十二章:为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为您量身定制的技能计划提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

您知道 Packt 为每本书提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com

在 www.packt.com,您还可以阅读一系列免费技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

您可能还会喜欢的其他书籍

如果您喜欢这本书,您可能对 Packt 的其他书籍也感兴趣:

《使用 Fyne 构建跨平台 GUI 应用程序》封面][https://www.packtpub.com/product/building-cross-platform-gui-applications-with-fyne/9781800563162]

使用 Fyne 构建跨平台 GUI 应用程序

Andrew Williams

ISBN: 9781800563162

  • 精通 GUI 开发的历史以及 Fyne 和 Golang 编程语言如何使其更加容易

  • 探索 Fyne 工具包的架构以及提供的各种模块

  • 发现如何使用最佳实践测试和构建 Fyne 应用程序

  • 构建五个完整的应用程序并将它们部署到您的设备上

  • 通过扩展小部件和主题来自定义您应用程序的设计

  • 理解数据的分离和展示方式,以及如何测试和构建展示动态数据的应用程序

![包含文本、橙色、截图、标志的图片]

描述自动生成](https://github.com/OpenDocCN/freelearn-go-zh/raw/master/docs/bd-dist-app-go/img/Hands-On_High_Performance_with_Go.png)][https://www.packtpub.com/product/hands-on-high-performance-with-go/9781789805789]

Go 高性能实战

Bob Strecansky

ISBN: 9781789805789

  • 使用集群和作业队列有效地组织和操作数据

  • 探索常用的 Go 数据结构和算法

  • 在 Go 中编写匿名函数以构建可重用应用程序

  • 通过配置文件和跟踪 Go 应用程序来减少瓶颈并提高效率

  • 专注于性能部署、监控和迭代 Go 程序

  • 深入了解 Go 中的内存管理以及 CPU 和 GPU 并行性

Packt 正在寻找像您这样的作者

如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已经与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球科技社区。你可以提交一个一般性申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了*《使用 Gin 构建分布式应用》*,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请访问packt.link/r/1801074852获取这本书的信息,并在你购买它的网站上分享你的反馈或留下评论。

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

posted @ 2025-09-05 09:31  绝不原创的飞龙  阅读(48)  评论(0)    收藏  举报