Go-全栈-Web-开发-全-
Go 全栈 Web 开发(全)
原文:
zh.annas-archive.org/md5/35adde4b967ed6be6976ef9bab9a68cb译者:飞龙
前言
使用 Go 进行全栈 Web 开发将指导您创建和开发一个完整的现代 Web 服务,从身份验证/授权、互操作性、服务器端渲染和数据库,到使用 Tailwind 和 Go 驱动的 API 的现代前端框架,包括对基本概念的逐步解释、实际示例和自我评估问题。本书将从查看如何构建应用程序结构和查看相关部分,如数据库和安全,开始,然后再将所有不同的部分整合在一起,构建一个完整的 Web 产品。
本书面向的对象
具有前端和后端开发混合经验的开发者将能够利用本书中的实用指南将他们的知识付诸实践。本书将为他们提供将技能粘合在一起的知识,使他们能够构建一个完整的堆栈 Web 应用程序。
本书涵盖的内容
第一章**,构建数据库和模型,探讨了为我们的示例应用程序构建数据库。我们还将探索使用 Golang 与数据库通信的不同方法。
第二章**,应用程序日志,考虑了设计应用程序需要检查其内部结构,而不必通过大量的代码,而实现这一点的唯一方法是进行日志记录。我们将通过运行一个集中式日志记录器来学习如何做到这一点,该日志记录器将托管我们所有的日志信息。我们还将学习如何在应用程序内部进行日志记录。
第三章**,应用程序指标和跟踪,考虑了在我们的应用程序内部应用日志记录将如何帮助在应用程序运行时解决故障。还有助于的是关于我们应用程序内部不同组件交互的信息,我们也将在本章中探讨这一点。
第四章**,提供和嵌入 HTML 内容*,我们看到开始实施我们金融应用程序所需的 REST 端点。应用程序的第一个版本将展示由后端渲染的简单内容。
第五章**,确保后端和中间件安全,指出我们需要确保我们的应用程序安全,以便用户只能看到他们应该看到的数据。我们将讨论一些我们可以使用 cookie、会话管理和其他类型的中间件来保护我们的端点的方法。
第六章**,首先迁移到 API,首先为前端应用程序消费我们的数据奠定基础。我们将介绍将数据序列化/反序列化到自定义结构中,并了解如何设置 JSON 消费端点并使用 cURL 进行验证。
第七章**,前端框架,讨论了 Web 开发的状态,介绍了 React 和 Vue 框架,并展示了我们如何使用它们创建一个类似于我们之前的一个简单应用程序。
第八章**,前端库,探讨了如何利用工具和库来帮助我们作为全栈开发者快速工作!
第九章**,Tailwind、中间件和 CORS*,让我们确保我们的应用程序安全,并使其能够与我们的 Go 后端进行通信。
第十章**,会话管理,在介绍 Vuex 进行状态管理的同时,关注会话管理,以及如何根据用户权限构建应用程序。
第十一章**,功能标志,介绍了功能标志(有时称为功能开关)作为一种技术,用于根据给定条件启用/禁用应用程序的某些功能。例如,如果一个包含新功能的新部署的应用程序存在错误,并且我们知道修复错误需要时间,那么可以决定关闭该功能,而无需部署任何新代码来实现。
第十二章**,构建持续集成,指出虽然构建应用程序是拼图的一部分,但我们还需要确保团队构建的内容可以正确验证和测试。这就是持续集成的作用所在。拥有适当的持续集成流程对于确保所有部署到生产环境的内容都已测试、验证并安全检查至关重要。
第十三章**,应用程序容器化,指出虽然开发应用程序是一方面,但另一方面是要确保它能够被我们的最终用户部署和使用。为了简化部署,我们可以将应用程序打包,以便它们可以在容器内运行。从操作的角度来看,这允许应用程序从任何地方在云中部署。
第十四章**,云部署,展示了如何将应用程序部署到云环境中是向最终用户交付功能的最后一步。云部署复杂,有时非常特定于特定的云供应商。在本章中,我们将专注于将应用程序部署到 AWS 云基础设施。
要充分利用这本书
您需要在您的计算机上安装以下内容:Node.js(版本 16 或以上)、Docker(或 Docker Desktop)、Golang 编译器和 IDE,如 GoLand 或 VSCode。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Golang 1.16 及以上 | macOS、Linux、Windows(通过 WSL2) |
| Docker | macOS、Linux、Windows(通过 WSL2) |
| IDE(VSCode 或 GoLand) | macOS、Linux、Windows |
如果您正在使用本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载此书的示例代码文件,网址为github.com/PacktPublishing/Full-Stack-Web-Development-with-Go。如果代码有更新,它将在 GitHub 仓库中更新。
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/EO4sG。
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个例子:“我们调用next.ServerHTTP(http.ResponseWriter, *http.Request)以继续并指示成功处理请求。”
代码块设置如下:
go func() {
...
s.SetAttributes(attribute.String(“sleep”, “done”))
s.SetAttributes(attribute.String(“go func”, “1”))
...
}()
任何命令行输入或输出都按照以下方式编写:
[INFO] 2021/11/26 21:11 This is an info message, with colors (if the output is terminal)
粗体: 表示新术语、重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个例子:“您将收到一条登录失败的消息。”
小贴士或重要注意事项
看起来像这样。
联系我们
欢迎读者反馈。
一般反馈: 如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 Go 进行全栈 Web 开发》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 复印本
感谢您购买此书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走?你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件访问权限
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

https://packt.link/free-ebook/9781803234199
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件
第一部分:构建 Golang 后端
在第一部分中,我们将构建我们示例应用程序的后端组件。我们将使用模型构建应用程序的 Go 后端数据库。我们还将构建具有日志记录和跟踪功能的安全的 REST API 端点。
本部分包括以下章节:
-
第一章**,构建数据库和模型
-
第二章,应用程序日志
-
第三章**,应用程序指标和跟踪
第一章:构建数据库和模型
在本章中,我们将设计我们的示例应用程序将使用的数据库。我们将逐步介绍数据库的设计,并查看我们将使用的一些工具,这些工具将帮助我们进行数据库设计之旅。我们将使用 Postgres 数据库,并查看如何使用 Docker 在本地运行它。什么是 Docker?简单来说,Docker 是一个允许开发者本地或云端运行各种应用程序的工具,如数据库、HTTP 服务器、系统工具等。Docker 消除了安装特定应用程序(如数据库)所需的所有不同依赖项的需求,并且它使得在本地和云端环境中管理维护应用程序比在裸机上安装更为容易。这是可能的,因为 Docker 将所有内容打包到一个单独的文件中,就像压缩文件内部包含不同的文件一样。
我们将学习如何设计一个支持我们想要构建的功能的数据库,例如以下内容:
-
创建练习
-
创建锻炼计划
-
登录系统
我们还将探索一些工具,这些工具将帮助我们基于 SQL 查询进行自动代码生成,这在很大程度上减少了需要编写的数据库相关代码的数量。读者将学习如何使用该工具自动生成所有相关的 CRUD 操作,而无需编写任何 Go 代码。
在本章中,我们将涵盖以下内容:
-
安装 Docker
-
设置 Postgres
-
设计数据库
-
安装 sqlc
-
使用 sqlc
-
设置数据库
-
使用 sqlc 生成 CRUD
-
构建 makefile
技术要求
在这本书中,我们将使用 Go 编程语言的 1.16 版本,但你也可以自由地使用 Go 的后续版本,因为代码无需任何修改即可运行。为了方便,本章中解释的所有相关文件都可以在 github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter01 上检出。要在此章节中工作样本代码,请确保将目录更改为 Chapter 1 – Full-Stack-Web-Development-with-Go/chapter1。如果你使用 Windows 作为开发机器,请使用 WSL2 来执行本章中解释的所有不同操作。
安装 Docker
在这本书中,我们将使用 Docker 来执行诸如运行数据库和执行数据库工具等任务。你可以安装 Docker Desktop 或 Docker Engine。要了解 Docker Desktop 和 Engine 之间的区别,请访问以下链接:docs.docker.com/desktop/linux/install/#differences-between-docker-desktop-for-linux-and-docker-engine。作者在 Linux 上使用 Docker Engine,在 Mac 上使用 Docker Desktop。
如果你正在本地机器上安装 Docker Desktop,以下链接是不同操作系统的链接:
-
Windows –
docs.docker.com/desktop/windows/install/
如果你想要安装 Docker 二进制文件,你可以按照以下指南进行:docs.docker.com/engine/install/binaries/。
设置 Postgres
我们为示例应用程序选择的数据库是 Postgres;我们选择 Postgres 而不是其他数据库,是因为有大量开源工具可用于构建、配置和维护 Postgres。自 1989 年版本 1 以来,Postgres 就是开源的,并且被全球的大型科技初创公司所使用。该项目在工具和实用程序方面拥有大量的社区支持,这使得管理和维护变得更加容易。该数据库适用于从小型到大型复制的数据库存储。
在本地运行它的最简单方法是将其作为 Docker 容器运行。首先,使用以下命令来运行 Postgres:
docker run --name test-postgres \
-e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres
命令将在端口5432上运行postgres;如果不幸有其他应用程序或其他 Postgres 实例监听此端口,则命令将失败。如果你需要在不同的端口上运行 Postgres,请将-p参数(例如,-p 5555:5432)更改为不同的端口号。
如果成功,你将看到打印出的容器 ID。ID 将与此处显示的不同:
f7bdfb7d2c10c5f0c9227c9b0a720f21d3c7fa65907eb0c546b8f20f12621102
通过使用docker ps检查 Postgres 是否正在运行。接下来要做的事情是使用psql-client工具连接到 Postgres 以测试它。不同平台上可用的不同 Postgres 客户端工具的列表可以在此找到:wiki.postgresql.org/wiki/PostgreSQL_Clients。
我们将使用 Docker 的标准postgres psql工具。打开另一个终端,并使用以下 Docker 命令来运行psql:
docker exec -it test-postgres psql -h localhost -p 5432 -U postgres -d postgres
我们所做的是在运行的 Postgres 容器内执行psql命令。你将看到如下输出,表明它已成功连接到 Postgres 数据库:
psql (12.3, server 14.5 (Debian 14.5-1.pgdg110+1))
WARNING: psql major version 12, server major version 14.
Some psql features might not work.
Type "help" for help.
postgres=#
在成功连接后,你将看到以下输出。请注意,警告信息提到了服务器主版本 14 – 这是为了表明服务器版本比根据文档(www.postgresql.org/docs/12/app-psql.xhtml)中所述的当前psql版本更新。psql客户端将与 Postgres 服务器无任何问题地工作:
psql (12.3, server 14.0 (Debian 14.0-1.pgdg110+1))
WARNING: psql major version 12, server major version 14.
Some psql features might not work.
Type "help" for help.
postgres=#
通过输入exit退出psql,回到命令提示符。
以下是在尝试连接到数据库时遇到的一些常见错误的指导:
| 错误信息 | 描述 |
|---|---|
psql: 错误:无法连接到服务器:FATAL: 密码认证失败 for user “postgres” |
运行 Postgres 时指定的密码与使用psql传入的密码不匹配。检查密码。 |
| psql: 错误:无法连接到服务器:无法连接到服务器:主机不可达 | 你用于连接 Postgres 的 IP 地址是错误的。 |
通过这样,你已经完成了 Postgres 的本地设置,现在可以开始设计数据库了。
设计数据库
在本节中,我们将探讨如何设计数据库,以便我们能够存储健身跟踪应用程序的信息。以下截图显示了应用程序的模拟:

图 1.1 – 样本应用的截图
在查看这些功能后,我们将探讨设计一个如下实体关系图所示的数据库结构:
实体关系图
实体关系图显示了存储在数据库中的实体集之间的关系。

图 1.2 – 我们健身应用的实体关系图
让我们进一步深入到每个表中,以了解它们包含的数据:
| 表名 | 描述 |
|---|---|
| 用户 | 此表包含用于登录的用户信息。密码将以哈希形式存储,而不是明文。 |
| 图片 | 此表包含用户想要做的练习的图片。此表将存储用户上传的所有练习图片。 |
| 练习 | 此表包含用户想要做的练习的名称。用户将定义他们想要做的练习类型。 |
| 组数 | 此表包含用户想要做的每个练习的组数。 |
| 训练 | 此表包含用户想要做的训练。用户将训练定义为一系列他们想要做的练习的组合,包括他们想要做的组数。 |
我们为了在数据库中存储图片所做的权衡是简化设计;在现实中,这可能不适合更大的图片和生产环境。现在我们已经定义了数据库结构并了解了它将存储的数据类型,我们需要看看如何实现它。我们想要关注的主要标准之一是完全将编写 SQL 与代码分开;这样,我们就有了一个清晰的分离,这将允许更高的可维护性。
安装 sqlc
我们已经定义了数据库结构,现在让我们更多地谈谈我们将要使用的工具,称为 sqlc。sqlc是一个开源工具,它从 SQL 生成类型安全的代码;这允许开发者专注于编写 SQL,将 Go 代码留给 sqlc。这减少了开发时间,因为 sqlc 负责查询和类型的日常编码。
工具可在 github.com/kyleconroy/sqlc 获取。该工具帮助开发者专注于编写应用程序所需的 SQL 代码,并将生成应用程序所需的所有相关代码。这样,开发者将使用纯 Go 代码进行数据库操作。这种分离是清晰且易于追踪的。
以下图表显示了开发者在使用此工具时通常采用的高级流程。

图 1.3 – 使用 sqlc 生成 Go 代码的流程
所有 SQL 代码都将写入 .sql 文件中,这些文件将由 sqlc 工具读取并转换为不同的 Go 代码。
使用以下命令下载并安装 SQL 二进制文件:
go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
确保你的路径包括 GOPATH/bin 目录 – 例如,在我们的案例中,我们的路径看起来如下:
…:/snap/bin:/home/nanik/goroot/go1.16.15/go/bin:/home/nanik/go/bin
如果你没有将 GOPATH 作为 PATH 环境变量的一部分,那么你可以使用以下命令来运行 sqlc:
$GOPATH/bin/sqlc
Usage:
sqlc [command]
Available Commands:
compile Statically check SQL for syntax and type
errors
completion Generate the autocompletion script for the
specified shell
generate Generate Go code from SQL
help Help about any command
init Create an empty sqlc.yaml settings file
upload Upload the schema, queries, and configuration
for this project
version Print the sqlc version number
Flags:
-x, --experimental enable experimental features (default: false)
-f, --file string specify an alternate config file (default: sqlc.yaml)
-h, --help help for sqlc
使用 "sqlc [command] --help" 获取有关命令的更多信息。
在编写本文时,sqlc 的最新版本是 v1.13.0。
现在我们已经安装了工具并了解了我们将使用此工具时遵循的开发工作流程,我们将看看如何为我们的应用程序使用此工具。
使用 sqlc
首先,让我们看看 sqlc 提供的不同命令以及它们是如何工作的。
| 命令 | 说明 |
|---|---|
compile |
此命令有助于检查 SQL 语法并报告任何类型错误。 |
completion |
此命令用于为你的环境生成自动完成脚本。以下支持的环境:Bash、Fish、PowerShell 和 zsh。 |
generate |
一个基于提供的 SQL 语句生成 .go 文件的命令。这将是我们在应用程序中大量使用的命令。 |
init |
此命令是第一个用于初始化你的应用程序以开始使用此工具的命令。 |
以下将展示如何开始使用 sqlc 来设置项目。在 chapter1 目录内创建一个目录 – 例如,dbtest – 然后将目录更改为新目录(dbtest)。接下来,我们将使用 init 命令运行 sqlc:
sqlc init
这将自动生成一个名为 sqlc.yaml 的文件,其中包含如所示的一个空白配置:
version: "1"
project:
id: ""
packages: []
sqlc.yaml 包含 sqlc 将使用它来为我们的 SQL 语句生成所有相关 .go 代码的配置信息。
让我们看看 .yaml 文件的结构,以了解不同的属性。以下是一个完成结构的示例:
version: "1"
packages:
- name: "db"
path: "db"
queries: "./sqlquery"
schema: "./sqlquery/schema/"
engine: "postgresql"
sql_engine: "database/sql"
emit_db_tags: "true"
emit_prepared_queries: true
emit_interface: false
emit_exact_table_names: false
emit_empty_slices: false
emit_exported_queries: false
emit_json_tags: true
json_tags_case_style: "snake"
output_db_file_name: "db.go"
output_models_file_name: "dbmodels.go"
output_querier_file_name: "dbquerier.go"
output_files_suffix: "_gen"
以下表格解释了不同的字段:
| 标签名称 | 描述 |
|---|---|
Name |
任何用作包名的字符串。 |
Path |
指定将托管生成的 .go 代码的目录名称。 |
Queries |
指定包含 sqlc 将用于生成 .``go 代码的 SQL 查询的目录名称。 |
Schema |
包含用于生成所有相关 .``go 文件的 SQL 文件的目录。 |
Engine |
指定将要使用的数据库引擎:sqlc 支持 MySQL 或 Postgres。 |
| emit_db_tags | 设置此为 true 将生成带有 db 标签的 struct – 例如:type ExerciseTable struct {```go ExerciseID int64 db:"exercise_id"``ExerciseName` ``string `db:"exercise_name"go}| |emit_prepared_queries| Setting this totrueinstructs sqlc to support prepared queries in the generated code. | |emit_interface| Setting this totruewill instruct sqlc to generate the querier interface. | |emit_exact_table_names| Setting this totruewill instruct sqlc to mirror the struct name to the table name. | |emit_empty_slices| Setting this totruewill instruct sqlc to return an empty slice for returning data on many sides of the table. | |emit_exported_queries| Setting this totruewill instruct sqlc to allow the SQL statement used in the auto-generated code to be accessed by an outside package. | |emit_json_tags| Setting this totruewill generate the struct with JSON tags. | |json_tags_case_style| This setting can accept the following –camel, pascal, snake, and none. The case style is used for the JSON tags used in the struct. Normally, this is used with emit_json_tags. | | output_db_file_name| Name used as the filename for the auto-generated database file. | |output_models_file_name| Name used as the filename for the auto-generated model file. | |output_querier_file_name| Name used as the filename for the auto-generated querier file. | |output_files_suffix` | Suffix to be used as part of the auto-generated query file. |
We have looked at the different parameters available in the tool, along with how to use the .yaml file to specify the different properties used to generate the relevant Go files. In the next section, we will set up our sample app database.
Setting up the database
We need to prepare and create the database using the psql client tool. The SQL database script can be found inside schema.sql under the db folder in the GitHub repository, and we are going to use this to create all the relevant tables inside Postgres.
Change the directory to chapter1 and run the Postgres database using the following Docker command:
docker run --name test-postgres -e POSTGRES_PASSWORD=mysecretpassword -v $(pwd):/usr/share/chapter1 -p 5432:5432 postgres
```go
Once `postgres` is running, use the following command to enter into `psql`:
docker exec -it test-postgres psql -h localhost -p 5432 -U postgres -d postgres
Once inside the `psql` command, run the following:
\i /usr/share/chapter1/db/schema.sql
This will instruct `psql` to execute the commands inside `schema.sql`, and on completion, you will see the following output:
postgres=# \i /usr/share/chapter1/db/schema.sql
CREATE SCHEMA
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
To reconfirm that everything is set up correctly, use the following command (do not forget to include the dot after `gowebapp`):
\dt gowebapp.*
You should see the following output:
postgres=# \dt gowebapp.*
列出关系
Schema | Name | Type | Owner
----------+-----------+-------+----------
gowebapp | exercises | table | postgres
gowebapp | images | table | postgres
gowebapp | sets | table | postgres
gowebapp | users | table | postgres
gowebapp | workouts | table | postgres
(5 rows)
Now that we have completed setting up our database, we are ready to move to the next section, where we will be setting up sqlc to generate the Go files.
# Generating CRUD with sqlc
**CRUD** stands for **Create, Read, Update, and Delete**, which refers to all the major functions that are inherent to relational databases. In this section, we will do the following for the application:
* Complete the sqlc configuration file
* Create SQL query files
Once done, we will be able to autogenerate the different files required to allow us to perform CRUD operations to the database from the application. First, open `sqlc.yaml` and enter the following configuration:
version: '1'
packages:
- name: chapter1
path: gen
schema: db/
queries: queries/
engine: postgresql
emit_db_tags: true
emit_interface: false
emit_exact_table_names: false
emit_empty_slices: false
emit_exported_queries: false
emit_json_tags: true
json_tags_case_style: camel
output_files_suffix: _gen
emit_prepared_queries: false
Our application is now complete with all that we need for the database, and sqlc will autogenerate the `.go` files. The following is how the application directory and files will look:
.
├── db
│ └── schema.sql
├── go.mod
├── queries
│ └── query.sql
└── sqlc.yaml
We can run sqlc to generate the `.go` files using the following command:
sqlc generate
By default, sqlc will look for the `sqlc.yaml` file. If the filename is different, you can specify it using the `-f` flag as follows:
sqlc generate -f sqlc.yaml
Once the operation completes, there will be no output; however, a new directory called `gen` will be generated as shown here:
./gen/
├── db.go
├── models.go
└── query.sql_gen.go
We have completed the auto-generation process using sqlc; now, let’s take a look at the schema and queries that sqlc uses to generate the code.
The following is a snippet of the `schema.sql` file that is used by sqlc to understand the structure of the database:
CREATE SCHEMA IF NOT EXISTS gowebapp;
CREATE TABLE gowebapp.users (
User_ID BIGSERIAL PRIMARY KEY,
User_Name text NOT NULL,
....
);
....
CREATE TABLE gowebapp.sets (
Set_ID BIGSERIAL PRIMARY KEY,
Exercise_ID BIGINT NOT NULL,
Weight INT NOT NULL DEFAULT 0
);
The other file sqlc uses is the query file. The query file contains all the relevant queries that will perform CRUD operations based on the database structure given here. The following is a snippet of the `query.sql` file:
-- name: ListUsers :many
-- 获取所有用户,按用户名排序
SELECT *
FROM gowebapp.users
ORDER BY user_name;
...
-- name: DeleteUserImage :exec
-- 删除特定用户的图片
DELETE
FROM gowebapp.images i
WHERE i.user_id = $1;
...
-- name: UpsertExercise :one
-- 插入或更新特定 ID 的练习
INSERT INTO gowebapp.exercises (Exercise_Name)
VALUES ($1) ON CONFLICT (Exercise_ID) DO
UPDATE
SET Exercise_Name = EXCLUDED.Exercise_Name
RETURNING Exercise_ID;
-- name: CreateUserImage :one
-- 插入一个新的图片
INSERT INTO gowebapp.images (User_ID, Content_Type,
Image_Data)
values ($1,
$2,
$3) RETURNING *;
...
Using `query.sql` and `schema.sql`, sqlc will automatically generate all the relevant `.go` files, combining information for these two files together and allowing the application to perform CRUD operations to the database by accessing it like a normal struct object in Go.
The last piece that we want to take a look at is the generated Go files. As shown previously, there are three auto-generated files inside the `gen` folders: `db.go`, `models.go`, and `query.sql_gen.go.`
Let’s take a look at each one of them to understand what they contain and how they will be used in our application:
* `db.go`:
This file contains an interface that will be used by the other auto-generated files to make SQL calls to the database. It also contains functions to create a Go struct that is used to do CRUD operations.
A new function is used to create a query struct, passing in a `DBTX` struct. The `DBTX` struct implementation is either `sql.DB` or `sql.Conn`.
The `WithTx` function is used to wrap the `Queries` object in a database transaction; this is useful in situations where there could be an update operation on multiple tables that need to be committed in a single database transaction:
func New(db DBTX) *Queries {
return &Queries{db: db}
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
* `models.go`:
This file contains the struct of the tables in the database:
type GowebappExercise struct {
ExerciseID int64 `db:"exercise_id"
json:"exerciseID"`
ExerciseName string `db:"exercise_name"
json:"exerciseName"`
}
...
type GowebappWorkout struct {
WorkoutID int64 `db:"workout_id"
json:"workoutID"`
UserID int64 db:"user_id" json:"userID"
SetID int64 db:"set_id" json:"setID"
StartDate time.Time `db:"start_date"
json:"startDate"`
}
* `query.sql_gen.go`:
This file contains CRUD functions for the database, along with the different parameters struct that can be used to perform the operation:
const deleteUsers = `-- name: DeleteUsers :exec
DELETE FROM gowebapp.users
WHERE user_id = $1
`
func (q *Queries) DeleteUsers(ctx context.Context,
userID int64) error {
_, err := q.db.ExecContext(ctx, deleteUsers, userID)
return err
}
...
const getUsers = `-- name: GetUsers :one
SELECT user_id, user_name, pass_word_hash, name, config, created_at, is_enabled FROM gowebapp.users
WHERE user_id = $1 LIMIT 1
`
func (q *Queries) GetUsers(ctx context.Context, userID int64) (GowebappUser, error) {
row := q.db.QueryRowContext(ctx, getUsers, userID)
var i GowebappUser
err := row.Scan(
&i.UserID,
&i.UserName,
&i.PassWordHash,
&i.Name,
&i.Config,
&i.CreatedAt,
&i.IsEnabled,
)
return i, err
}
...
Now that the database and auto-generated data to perform CRUD operations are complete, let’s try all this by doing a simple insert operation into the user table.
The following is a snippet of `main.go`:
包 main
import (
...
)
func main() {
...
// 打开数据库
db, err := sql.Open("postgres", dbURI)
if err != nil {
panic(err)
}
// 连通性检查
如果 db.Ping() 出现错误 {
log.Fatalln("数据库 ping 错误:", err)
}
// 创建存储
st := chapter1.New(db)
st.CreateUsers(context.Background(),
chapter1.CreateUsersParams{
用户名: "testuser",
PassWordHash: "hash",
名称: "test",
})
}
The app is doing the following:
1. Initializing the URL and opening the database
2. Pinging the database
3. Creating a new user using the `CreateUsers(..)` function
Make sure you are in the `chapter1` directory and build the application by running the following command:
go build -o chapter1
The compiler will generate a new executable called `chapter1`. Execute the file, and on a successful run, you will see the data inserted successfully into the `users` table:
2022/05/15 16:10:49 完成!
名称 : test, ID : 1
We have completed setting up everything from the database and using sqlc to generate the relevant Go code. In the next section, we are going to put everything together for ease of development.
# Building the makefile
A makefile is a file that is used by the `make` utility; it contains a set of tasks consisting of different combined shell scripts. Makefiles are most used to perform operations such as compiling source code, installing executables, performing checks, and many more. The `make` utility is available for both macOS and Linux, while in Windows, you need to use Cygwin ([`www.cygwin.com/`](https://www.cygwin.com/)) or NMake ([`docs.microsoft.com/en-us/cpp/build/reference/nmake-reference`](https://docs.microsoft.com/en-us/cpp/build/reference/nmake-reference)).
We will create the makefile to automate the steps that we have performed in this chapter. This will make it easy to do the process repetitively when required without typing it manually. We are going to create a makefile that will do tasks such as the following:
* Bringing up/down Postgres
* Generating code using sqlc
The makefile can be seen in the `chapter1` directory; the following shows a snippet of the script:
..
.PHONY : postgresup postgresdown psql createdb teardown_recreate generate
postgresup:
docker run --name test-postgres -v \((PWD):/usr/share/chapter1 -e POSTGRES_PASSWORD=\)(DB_PWD) -p 5432:5432 -d $(DB_NAME)
...
手动创建数据库的任务
createdb:
docker exec -it test-postgres psql $(PSQLURL) -c "\i /usr/share/chapter1/db/schema.sql"
...
With the makefile, you can now bring up the database easily using this command:
make postgresup
The following is used to bring down the database:
make postgresdown
sqlc will need to be invoked to regenerate the auto-generated code whenever changes are made to the schema and SQL queries. You can use the following command to regenerate the files:
make generate
# 摘要
在本章中,我们介绍了我们需要经历的各个阶段来为我们的健身应用程序设置数据库。我们还编写了一个 makefile,通过自动化开发过程中所需的不同数据库相关任务来节省我们的时间。
在下一章中,我们将探讨我们示例应用程序的日志记录。日志记录是一个简单但至关重要的组件。应用程序使用日志来提供对应用程序运行状态的可见性。
# 第二章:应用程序日志记录
构建任何类型的应用程序以满足用户需求是拼图的一部分;另一部分是弄清楚我们如何设计它,以便在出现生产问题的情况下支持它。日志记录是在出现问题时提供可见性的最重要的事情之一。应用程序日志记录是保存应用程序事件和错误的过程;简单来说,它生成一个包含关于软件应用程序中发生的事件信息的文件。在生产中支持应用程序需要快速响应,而为了实现这一点,应用程序应该记录足够的信息。
在本章中,我们将探讨构建一个用于记录事件(例如错误)的应用程序日志服务器。我们还将学习如何进行多路复用日志记录,以便根据我们的配置记录不同的事件。本章将涵盖以下内容:
+ 探索 Go 标准日志记录
+ 本地日志记录
+ 将日志消息写入日志服务器
+ 配置多个输出
# 技术要求
本章中解释的所有源代码都可以在[`github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter02`](https://github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter02)中查看,而日志服务器可以在[`github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/logserver`](https://github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/logserver)中查看
# 探索 Go 标准日志记录
在本节中,我们将探讨 Go 语言提供的默认日志库。Go 提供了一套丰富的库;然而,就像其他库一样,它也有局限性——它不提供分级日志记录(`INFO`、`DEBUG`等)、文件日志文件功能等。这些局限性可以通过使用开源日志库来克服。
Go 为应用程序提供了非常多样化和丰富的标准库。日志记录是其中之一,它位于`log`包中。以下文档链接提供了关于`pkg.go.dev/log@latest`包内不同函数的完整信息。
在 Go 标准库中可用的另一个包是`fmt`包,它提供了打印、输入等 I/O 操作的功能。更多信息可以在`https://pkg.go.dev/fmt@latest`中找到。`log`包内的可用函数与`fmt`包类似,在阅读示例代码时,我们会看到它使用起来非常简单。
以下是一些由`log`包(`https://pkg.go.dev/log`)提供的功能:
```go
func (l *Logger) Fatal(v ...interface{})
func (l *Logger) Fatalf(format string, v ...interface{})
func (l *Logger) Fatalln(v ...interface{})
func (l *Logger) Panic(v ...interface{})
func (l *Logger) Prefix() string
func (l *Logger) Print(v ...interface{})
func (l *Logger) Printf(format string, v ...interface{})
func (l *Logger) Println(v ...interface{})
func (l *Logger) SetFlags(flag int)
func (l *Logger) SetOutput(w io.Writer)
func (l *Logger) SetPrefix(prefix string)
让我们看看示例代码,来自样本仓库github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter02。main.go文件位于example/stdlog目录下。为了了解如何使用log包,请构建并运行以下代码:
go run .
在成功运行后,您将得到以下输出:
2021/10/15 10:12:38 Just a log text
main.go:38: This is number 1
10:12:38 {
«name»: «Cake»,
«batters»: {
«batter»: [
{
«id»: «001»,
«type»: «Good Food»
}
]
},
«topping»: [
{
«id»: «002»,
«type»: «Syrup»
}
]
}
输出显示标准日志库是可配置的,允许不同的日志输出格式 - 例如,您可以在以下内容中看到消息前面带有格式化的日期和时间:
2021/10/15 10:12:38 Just a log text
负责格式化日志前缀的函数是SetFlags(..)函数:
func main() {
...
// set log format to - dd/mm/yy hh:mm:ss
ol.SetFlags(log.LstdFlags)
ol.Println(«Just a log text»)
...
}
代码将标志设置为使用LstdFlags,这是一个日期和时间的组合。以下表格显示了可以使用的不同标志:
| Flag | 说明 |
|---|---|
Ldate |
一个用于指定以 YYYY/MM/DD 格式显示本地时区的日期的标志 |
Ltime |
一个用于指定以 HH:MM:SS 格式使用本地时区的时间的标志 |
Lmicroseconds |
一个用于指定微秒的标志 |
Llongfile |
一个用于指定文件名和行号的标志 |
Lshortfile |
最后的文件名元素和行号 |
LUTC |
当使用Ldate或Ltime标志时,我们可以使用此标志来指定使用 UTC 而不是本地时区 |
Lmsgprefix |
一个用于指定在消息之前显示的前缀文本的标志 |
LstdFlags |
此标志使用已定义的标准标志,基本上是Ldate或Ltime |
标准库可以覆盖一些应用程序日志需求的使用场景,但有时应用程序需要更多标准库中没有的功能 - 例如,将日志信息发送到多个输出将需要构建额外的功能,或者在某些情况下,您可能需要将嵌套的错误日志转换为 JSON 格式。在下一节中,我们将探索为我们样本应用程序的另一个替代方案。
使用 golog
现在我们已经了解了标准库中有什么可用,我们想要探索使用一个可以为我们提供更多灵活性的库。我们将查看golog开源项目(github.com/kataras/golog)。golog库是一个无依赖的日志库,它提供了诸如分级日志(INFO、ERROR等)、基于 JSON 的输出和可配置的彩色输出等功能。
日志功能中最常用的特性之一是日志级别,也称为分级日志。日志级别用于将应用程序的输出信息分类到不同的严重级别。以下表格显示了不同的严重级别:
INFO |
仅用于信息目的 |
|---|---|
WARN |
有某些东西运行不正确,所以请留意,以防出现更严重的错误 |
ERROR |
存在一个需要关注的错误 |
DEBUG |
用于在生产环境中协助故障排除或添加到应用程序中用于跟踪目的的重要信息 |
FATAL |
应用程序中发生了需要立即响应/调查的严重问题 |
示例代码可以在 example/golog 目录中找到。构建并运行代码,你将得到以下输出:

图 2.1 – golog 输出示例
日志消息的前缀颜色各不相同,这对应着不同的严重级别;当你浏览大量日志消息时,这非常有用。不同的日志级别被分配了不同的颜色,以便于查看。
生成此日志的代码与标准库代码类似,如下所示:
func main() {
golog.SetLevel(«error»)
golog.Println(«This is a raw message, no levels, no
colors.»)
golog.Info(«This is an info message, with colors (if the
output is terminal)»)
golog.Warn(«This is a warning message»)
golog.Error(«This is an error message»)
golog.Debug(«This is a debug message»)
golog.Fatal(`Fatal will exit no matter what,
but it will also print the log message if
logger›s Level is >=FatalLevel`)
}
该库提供基于级别的日志记录。这意味着库可以根据配置显示日志消息;例如,对于开发,我们希望配置记录器显示所有日志消息,而在生产中,我们只想显示错误消息。以下表格显示了为 golog 配置不同级别时的输出外观:
| 级别 | 输出 |
|---|---|
golog.SetLevel("info") |
2021/10/15 12:07 这是一条原始消息,没有级别, 没有颜色。``[INFO] 2021/10/15 12:07 这是一条 info 消息,带有颜色(如果输出 是终端)``[WARN] 2021/10/15 12:07 这是一条 警告消息``[ERRO] 2021/10/15 12:07 这是一条 错误消息``[FTAL] 2021/10/15 12:07 致命错误将退出,无论什么情况 |
golog.SetLevel("debug") |
2021/10/15 12:08 这是一条原始消息,没有级别, 没有颜色。``[INFO] 2021/10/15 12:08 这是一条 info 消息,带有颜色(如果输出 是终端)``[WARN] 2021/10/15 12:08 这是一条 警告消息``[ERRO] 2021/10/15 12:08 这是一条 错误消息``[DBUG] 2021/10/15 12:08 这是一条 调试消息``[FTAL] 2021/10/15 12:08 致命错误将退出,无论什么情况 |
golog.SetLevel("warn") |
2021/10/15 12:08 这是一条原始消息,没有级别, 没有颜色。``[WARN] 2021/10/15 12:08 这是一条 警告消息``[ERRO] 2021/10/15 12:08 这是一条 错误消息``[FTAL] 2021/10/15 12:08 致命错误将退出,无论什么情况 |
golog.SetLevel("error") |
2021/10/15 12:11 这是一条原始消息,没有级别, 没有颜色。``[ERRO] 2021/10/15 12:11 这是一条 错误消息``[FTAL] 2021/10/15 12:11 致命错误将退出,无论什么情况 |
golog.SetLevel("fatal") |
2021/10/15 12:11 这是一条原始消息,没有级别, 没有颜色。``[FTAL] 2021/10/15 12:11 致命错误将退出,无论什么情况 |
我们在本节中介绍了 golog 及其功能,现在我们对可用于日志记录的不同选项有了很好的理解。在下一节中,我们将更深入地探讨 golog。
本地日志记录
现在我们已经了解了如何使用 golog,我们将使用其更多功能来扩展它。库提供了一个函数,允许应用程序处理每个日志级别的日志消息写入——例如,一个应用程序希望将所有错误写入文件,其余的打印到控制台。
我们将查看example/gologmoutput目录内的示例代码。构建并运行它,你会看到创建了两个新文件,分别命名为infoerr.txt和infolog.txt。这两个文件的内容输出如下:
[ERRO] 2021/11/26 21:11 This is an error message [INFO] 2021/11/26 21:11 This is an info message, with colors (if the output is terminal)
应用程序使用os.OpenFile标准库创建或追加名为infolog.txt和infoerr.txt的文件,这些文件将包含使用 golog 的SetLevelOutput函数配置的不同日志信息。以下是用 golog 配置不同日志输出的函数片段:
func configureLogger() {
// open infolog.txt append if exist (os.O_APPEND) or
// create if not (os.O_CREATE) and read write
// (os.O_WRONLY)
infof, err := os.OpenFile(logFile,
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0666)
...
golog.SetLevelOutput(«info», infof)
// open infoerr.txt append if exist (os.O_APPEND) or
create if not (os.O_CREATE) and read write
// (os.O_WRONLY)
// errf, err := os.OpenFile(«infoerr.txt»,
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0666)
...
golog.SetLevelOutput(«error», errf)
}
其余的日志级别消息将被写入stdout,这是库默认配置的。
在本节中,我们学习了如何配置 golog 以允许我们分别记录错误和信息。这在生产环境中非常有用,因为如果我们将所有内容都记录到单个文件中,我们将很难处理。在下一节中,我们将探讨构建自己的简单日志服务器以接收应用程序的日志请求。
将日志消息写入日志服务器
在现代云环境中,同一应用程序的多个实例在不同的服务器上运行。由于云环境的分布式特性,跟踪不同应用程序实例产生的不同日志将很困难。这需要使用集中式日志系统,该系统能够捕获来自不同应用程序和系统的所有不同日志消息。
为了满足我们的需求,我们将构建自己的日志服务器来捕获所有日志消息在一个地方;代码可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/logserver找到。日志服务器将成为一个中心位置,将汇总应用程序的日志信息,这有助于在应用程序部署在云环境中时的故障排除。拥有集中日志服务器的缺点是,当日志服务器宕机时,我们除了访问托管应用程序的服务器外,无法看到任何日志信息。
REST代表表示状态转移;用通俗易懂的话来说,它描述了一个使用 HTTP 协议和方法与服务器中的资源进行通信的服务器。信息以不同的格式交付,其中 JSON 是最流行的格式。它是语言无关的,这意味着任何可以发送和接收 HTTP 的应用程序都可以使用日志服务器。
构建成功后,日志服务器将显示以下消息:
2021/10/15 23:37:31 Initializing logging server at port 8010
一旦日志服务器启动,返回到包含示例应用的chapter2根目录,并通过运行以下命令来测试应用:
make build
完成后,运行名为sampledb的新二进制文件。sampledb应用会将日志消息发送到日志服务器:
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Starting the application...\"\n}\n"
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Database connection fine\"\n}\n"
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Success - user creation\"\n}\n"
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Success - exercise creation\"\n}\n"
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Application complete\"\n}\n"
"{\n \"timestamp\": 1634301479,\n \"level\": \"info\",\n \"message\": \"Application complete\"\n}\nut\"\n}\n"
日志服务器作为一个普通的 HTTP 服务器运行,监听端口8010,注册一个单一端点/log以接收传入的日志消息。让我们来了解一下,并尝试理解日志服务器是如何工作的。但在那之前,让我们看看服务器代码是如何工作的:
import (
...
«github.com/gorilla/mux»
...
)
func runServer(addr string) {
router = mux.NewRouter()
initializeRoutes()
...
log.Fatal(http.ListenAndServe(addr, router))
}
服务器使用一个名为 Gorilla Mux 的框架(github.com/gorilla/mux),该框架负责接收和分发传入请求到相应的处理器。我们在这个示例中使用的gorilla/mux包被开源社区积极使用;然而,目前它正在寻找维护者以继续该项目。
负责处理请求的处理器位于initializeRoutes()函数内部,如下所示:
func initializeRoutes() {
router.HandleFunc(«/log», loghandler).Methods(http. MethodPost)
}
router.HandleFunc(..)函数配置了/log端点,该端点将由loghandler函数处理。Methods("POST")指示框架它应该只接受针对/log端点的传入请求的POST HTTP 方法。
现在我们将查看loghandler函数,该函数负责处理传入的日志消息:
func loghandler(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
...
w.WriteHeader(http.StatusCreated)
}
http.ResponseWriter参数是一个定义为一个接口的类型,用于构建 HTTP 响应 – 例如,它包含WriteHeader方法,允许将标题写入响应。http.Request参数提供了一个接口,使函数能够与服务器接收到的请求进行交互 – 例如,它提供了一个Referer函数来获取引用 URL。
loghandler函数执行以下操作:
-
读取请求体,因为它包含日志消息。
-
在成功读取体后,处理器将返回 HTTP 状态码
201(StatusCreated)。状态码201表示请求已成功处理,资源(在本例中为日志 JSON 消息)已成功创建,或者在我们的情况下,已成功打印。 -
将日志消息打印到
stdout。
关于不同标准 HTTP 状态码的更详细信息,请参考以下网站:developer.mozilla.org/en-US/docs/Web/HTTP/Status。
我们已经学习了如何向应用程序添加日志,以及如何构建一个可以独立于我们的应用程序托管的基础日志服务器。在下一节中,我们将创建一个日志包装器,允许我们的应用程序选择是本地记录还是记录到服务器。
配置多个输出
为什么我们要配置多个输出?嗯,在开发过程中,它有助于更容易地本地查看日志以进行故障排除,但在生产环境中,无法查看日志文件,因为所有内容都将位于日志服务器内部。
我们将编写一层薄薄的包装代码,该代码将包装golog库;我们将要查看的代码位于chapter2/目录下的logger/log.go文件中。为golog库编写包装代码的好处是隔离应用程序,以便直接与库进行接口;这将使得在需要时轻松切换到不同的日志库变得容易。应用程序通过将解析标志传递给SetLoggingOutput(..)函数来配置包装代码。
通过运行以下命令来构建应用程序:
make build
然后,运行它,将标志传递给true,如下所示,以将日志消息写入stdout:
./sampledb -local=true
调试日志将被打印到stdout:

图 2.2 – sampledb 的日志输出
所有信息日志消息将被打印到logs.txt文件中:

图 2.3 – logs.txt 中的日志消息
应用程序通过调用SetLoggingOutput(..)函数使用local标志配置日志记录器:
func main() {
l := flag.Bool(«local», false, «true - send to stdout, false - send to logging server»)
flag.Parse()
logger.SetLoggingOutput(*l)
logger.Logger.Debugf(«Application logging to stdout =
%v», *l)
...
包装代码中的两个主要函数执行了golog框架的大部分包装:
-
configureLocal() -
configureRemote()
...
func configureLocal() {
file, err := os.OpenFile(«logs.txt»,
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
...
Logger.SetOutput(os.Stdout)
Logger.SetLevel(«debug»)
Logger.SetLevelOutput(«info», file)
}
...
configureLocal()函数负责配置日志记录以写入stdout和配置的文件名为logs.txt。该函数将 golog 的输出设置为stdout并将级别设置为debug,这意味着所有内容都将发送到stdout。
另一个函数是configureRemote(),它配置 golog 将所有消息以 JSON 格式发送到远程服务器。SetLevelOutput()函数接受io.Writer接口,这是示例应用程序实现以发送所有信息日志消息的接口:
//configureRemote for remote logger configuration
func configureRemote() {
r := remote{}
Logger.SetLevelFormat(«info», «json»)
Logger.SetLevelOutput(«info», r)
Write(data []byte)函数执行POST操作,将日志消息传递给日志服务器:
func (r remote) Write(data []byte) (n int, err error) {
go func() {
req, err := http.NewRequest("POST",
«http://localhost:8010/log»,
bytes.NewBuffer(data),
)
...
resp, _ := client.Do(req)
defer resp.Body.Close()
}
}()
return len(data), nil
}
在本节最后,我们学习了如何创建可配置的日志记录,这将允许应用程序在本地或远程进行日志记录。这有助于我们的应用程序在不同环境中做好准备和部署。
摘要
在本章中,我们探讨了向应用程序添加日志功能的不同方法。我们还了解了golog库,它提供的灵活性和功能比标准库所能提供的更多。我们研究了创建自己的简单日志服务器,使我们的应用程序能够发送在多服务环境中使用的日志信息。
在下一章中,我们将探讨如何向应用程序添加可观察性功能。我们将探讨跟踪和指标,并介绍 OpenTelemetry 规范。
第三章:应用程序指标和跟踪
在第二章 应用程序日志中,我们探讨了日志记录以及如何在我们的后端 Go 代码中使用日志记录。在本章中,我们将继续探讨监控和跟踪。为了监控和跟踪应用程序,我们将研究不同的开源工具和库。
我们已经开始构建我们的应用程序,现在我们需要开始考虑如何支持它。一旦应用程序在生产环境中运行,我们需要了解应用程序中发生了什么。拥有这种可见性将使我们能够理解出现的问题。在软件系统中,我们经常会遇到可观察性的概念。这个概念指的是软件系统捕获和存储用于分析和故障排除目的的数据的能力。这包括用于实现允许用户观察系统中发生情况的目标的过程和工具。
在本章中,我们将介绍以下主题:
-
理解 OpenTelemetry 规范
-
跟踪应用程序
-
使用 Prometheus 为我们的应用程序添加指标
-
运行
docker-compose
技术要求
本章中解释的所有源代码都可以从 GitHub 这里获取:github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter03。
我们将使用另一个名为 OpenTelemetry 的工具,它将在下一节中解释,我们在这本书中使用的是 v1.2.0 版本,可在以下链接找到:github.com/open-telemetry/opentelemetry-go/tree/v1.2.0。
理解 OpenTelemetry
OpenTelemetry 是一个开源项目,它使开发者能够为他们的应用程序提供可观察性能力。该项目为不同的编程语言提供了软件开发工具包(SDK),其中 Go 是支持的语言之一,它与应用程序集成。SDK 用于指标收集和报告,因为它与不同的开源框架集成了,使得集成过程无缝。OpenTelemetry 还提供了一个通用标准,为应用程序提供了灵活性,可以将收集到的数据报告到不同的可观察性后端系统。OpenTelemetry 的网站是 opentelemetry.io/。

图 3.1 – OpenTelemetry 标志
OpenTelemetry 实际上是 OpenTracing 和 OpenCensus 项目的合并。该项目用于对应用程序进行仪器化、收集和导出指标、日志和跟踪。OpenTelemetry 可以跨多种语言使用,Go 是支持的语言之一。
遵循 OpenTelemetry 规范的主要好处是它是供应商无关的,这意味着使用它们的 API 编写的应用程序可以在不同的可观察性供应商之间移植。例如,编写写入文件系统的应用程序将需要几行代码更改,以便将其指标存储在 Prometheus 中,我们将在“使用 Prometheus 添加指标”部分讨论。
OpenTelemetry 的两个主要组件如下:
-
跟踪: 这为应用程序提供了通过收集数据跟踪服务请求在系统中流动的能力。例如,使用跟踪功能,我们可以看到 HTTP 请求如何通过网络中的不同系统流动。
-
指标: 这为应用程序提供了收集和存储测量数据的能力,用于检测性能异常和预测。例如,在我们的应用程序中收集指标将使我们能够了解数据库查询需要多长时间,或者处理某个特定批处理作业需要多长时间。
您可以在以下链接找到 OpenTelemetry 规范:opentelemetry.io/docs/reference/specification/。
规范允许用户轻松地插入和播放不同的 OpenTelemetry 实现,而无需依赖单一供应商的库。这意味着规范文档中概述的所有相关合同都可以实现。为了有效地使用 OpenTelemetry,以下是一些重要的概念:
-
组件: 这些基本上是核心供应商无关的规范,概述了需要实现的不同系统部分。组件包括收集器、API、SDK 和仪器库。
-
数据源: 这是规范支持的数据:跟踪、日志、指标和行李。
-
仪器化和库: 有两种方法可以集成提供的库——要么通过使用供应商提供的库或开源贡献自动进行,要么根据应用程序要求手动进行。
在下一节中,我们将探讨规范的实施方面,这涉及到 API 和 SDK 两个方面。
OpenTelemetry 的 API 和 SDK
OpenTelemetry 由几个组件组成,我们将讨论的两个主要组件是 API 和 SDK。规范定义了任何实现都必须遵守的跨语言要求,作为要求的一部分:
-
APIs: 这定义了将用于生成遥测数据的数据类型和操作
-
SDK: 这定义了 API 的处理和导出功能的实现
API 和 SDK 之间存在明显的区别——很明显,API 是由规范提供的合约,而 SDK 提供了允许处理和导出指标数据的所需的不同功能。指标数据包含诸如内存使用、CPU 使用率等信息。
规范为以下内容提供了 API:
-
上下文:这包含在 API 调用之间携带的值。这是可以在系统调用之间传递并携带应用程序信息的数据。
-
Baggage:一组描述用户定义属性的键值对。
-
跟踪:一个提供跟踪功能的 API 定义
-
指标:一个提供指标记录功能的 API 定义
我们将探讨 OpenTelemetry 跟踪 API 的外观以及如何将跟踪功能添加到应用程序中。
跟踪应用程序
在上一章中,我们学习了日志记录以及日志如何让我们了解应用程序内部正在发生的事情。日志和跟踪之间的界限模糊;我们需要理解的是,日志只是提供了关于进程当前正在做什么的信息,而跟踪则提供了跨不同组件的横切可见性,使我们能够更好地理解数据流和进程完成所需的时间。
例如,使用跟踪,我们可以回答以下问题:
-
添加到购物车的过程需要多长时间?
-
下载支付文件需要多长时间?
我们将探讨规范中概述的不同 API,并使用 OpenTelemetry 库提供的实现来实现这些 API。
下图显示了不同实体之间的链接。

图 3.2 – 跟踪 API 关系
TracerProvider 是使用跟踪 API 的入口点,它提供了对 Tracer 的访问,Tracer 负责创建 Span。Span 用于跟踪我们应用程序中的操作。在我们进一步探讨下一层,即 SDK 之前,我们将简要了解一下 Jaeger,这是 OpenTelemetry 库提供的用于跟踪的支持工具之一。
安装 Jaeger
Jaeger (www.jaegertracing.io/) 是一个流行的开源分布式跟踪平台;它为各种编程语言提供了自己的客户端库,可以在 github.com/orgs/jaegertracing/repositories 上看到。我们将以 Docker 容器的形式运行 Jaeger,以减少手动安装应用程序时所需的设置量。让我们使用以下 docker 命令启动 Jaeger:
docker run --name jaeger \
-p 5775:5775/udp \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 14268:14268 \
-p 14250:14250 \
-p 9411:9411 \
jaegertracing/all-in-one:latest
在成功启动后,将打印出大量的日志,如下所示:
{"level":"info","ts":1637930923.8576558,"caller":"flags/service.go:117","msg":"Mounting metrics handler on admin server","route":"/metrics"}
{"level":"info","ts":1637930923.857689,"caller":"flags/service.go:123","msg":"Mounting expvar handler on admin server","route":"/debug/vars"}
{"level":"info","ts":1637930923.8579082,"caller":"flags/admin.go:104","msg":"Mounting health check on admin server","route":"/"}
{"level":"info","ts":1637930923.8579528,"caller":"flags/admin.go:115","msg":"Starting admin HTTP server","http-addr":":14269"}
…
…
{"level":"info","ts":1637930923.8850179,"caller":"app/server.go:258","msg":"Starting HTTP server","port":16686,"addr":":16686"}
{"level":"info","ts":1637930923.8850145,"caller":"healthcheck/handler.go:129","msg":"Health Check state change","status":"ready"}
{"level":"info","ts":1637930923.8850334,"caller":"app/server.go:277","msg":"Starting GRPC server","port":16685,"addr":":16685"}
{"level":"info","ts":1637930924.8854718,"caller":"channelz/logging.go:50","msg":"[core]Subchannel Connectivity change to IDLE","system":"grpc","grpc_log":true}
{"level":"info","ts":1637930924.8855824,"caller":"grpclog/component.go:71","msg":"[core]pickfirstBalancer: UpdateSubConnState: 0xc00003af30, {IDLE connection error: desc = \"transport: Error while dialing dial tcp :16685: connect: connection refused\"}","system":"grpc","grpc_log":true}
{"level":"info","ts":1637930924.885613,"caller":"channelz/logging.go:50","msg":"[core]Channel Connectivity change to IDLE","system":"grpc","grpc_log":true}
Jaeger 现在已准备好,这个工具不是一个桌面应用程序,但它提供了一个可以通过浏览器访问的用户界面。打开您的浏览器,输入以下 URL:http://localhost:16686。它将打开 Jaeger 主页面(图 3.3**.3):

图 3.3 – Jaeger 主页面
目前,Jaeger 没有任何内容,因为没有应用程序在使用它。
集成 Jaeger SDK
现在 Jaeger 已经准备好了,让我们看看我们如何使用 OpenTelemetry 来编写跟踪信息。该库为 Jaeger SDK 提供了开箱即用的支持;这使得应用程序可以使用 API 将跟踪信息写入 Jaeger。
我们在本节中将使用的示例位于章节 GitHub 仓库的jaeger/opentelem/trace目录中。我们想要查看的文件是tracing.go,如下所示:
package trace
import (
«context»
«go.opentelemetry.io/otel"
«go.opentelemetry.io/otel/exporters/jaeger"
«go.opentelemetry.io/otel/sdk/resource"
«go.opentelemetry.io/otel/sdk/trace"
sc "go.opentelemetry.io/otel/semconv/v1.4.0"
)
type ShutdownTracing func(ctx context.Context) error
func InitTracing(service string) (ShutdownTracing, error)
{
// Create the Jaeger exporter.
exp, err := jaeger.New(jaeger.WithCollectorEndpoint())
if err != nil {
return func(ctx context.Context) error { return nil },
err
}
// Create the TracerProvider.
tp := trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(resource.NewWithAttributes(
sc.SchemaURL,
sc.ServiceNameKey.String(service),
)),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
让我们看看代码的每一部分在做什么。第 18 行在 OpenTelemetry 库内部初始化 Jaeger SDK。在成功初始化 Jaeger SDK 后,代码继续提供新创建的 Jaeger,并使用它与 OpenTelemetry 库一起创建一个新的TracerProvider API。如前所述,TracerProvider是作为 OpenTelemetry 主要入口的 API。这是在第 24-30 行执行的。
在获取TracerProvider后,我们需要调用全局的SetTracerProvider来让 OpenTelemetry 了解它,这是在第 32 行完成的。一旦 Jaeger SDK 成功初始化,现在就是将其用于应用程序中的时候了。
让我们看看使用跟踪功能的代码示例。我们将要查看的示例应用程序位于jaeger/opentelem目录内的main.go中。
与 Jaeger 的集成
我们将逐节解释代码的功能。以下代码部分显示了负责初始化过程的InitTracing函数被调用的情况:
package main
import (
t "chapter.3/trace/trace"
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"log"
"sync"
"time"
)
const serviceName = "tracing"
func main() {
sTracing, err := t.InitTracing(serviceName)
if err != nil {
log.Fatalf("Failed to setup tracing: %v\n", err)
}
defer func() {
if err := sTracing(context.Background()); err != nil
{
log.Printf("Failed to shutdown tracing: %v\n", err)
}
}()
ctx, span := otel.Tracer(serviceName)
.Start(context.Background(), "outside")
defer span.End()
var wg sync.WaitGroup
wg.Add(1)
go func() {
_, s := otel.Tracer(serviceName).Start(ctx, "inside")
...
wg.Done()
}()
wg.Add(1)
go func() {
_, ss := otel.Tracer(serviceName).Start(ctx,
"inside")
...
wg.Done()
}()
wg.Wait()
fmt.Println("\nDone!")
}
一旦 SDK 完成初始化过程,代码就可以开始使用 API 来编写跟踪信息,这是通过使用Tracer API 获取Span来完成的,如第 27-29 行所示。代码使用sync.WaitGroup(第 35 和 45 行)确保在 goroutine 完成之前main线程不会结束——goroutine 被添加来模拟一些需要完成的处理,以生成将被报告给 Jaeger 的跟踪。
Tracer API 只有一个Start函数,用于启动跟踪操作,当在Span上调用End函数时,跟踪操作被认为是完成的——那么,什么是Span?Span是一个用于跟踪操作的 API;它具有以下接口声明:
type Span interface {
End(options ...SpanEndOption)
AddEvent(name string, options ...EventOption)
IsRecording() bool
RecordError(err error, options ...EventOption)
SpanContext() SpanContext
SetStatus(code codes.Code, description string)
SetName(name string)
SetAttributes(kv ...attribute.KeyValue)
TracerProvider() TracerProvider
}
多个Span被拼接在一起以创建一个跟踪;它可以被认为是一个有向无环图(DAG)的Span。
DAGs
DAG 是数学和计算机科学中的一个术语。它是一个显示依赖关系的图,在我们的案例中,是应用追踪的依赖关系。
图 3**.4 展示了追踪成分的组成:

图 3.4 – 简单追踪的 DAG
示例代码创建了两个 goroutines 来执行 sleep 操作并写入追踪信息,如下所示:
go func() {
_, s := otel.Tracer(serviceName).Start(ctx, "inside")
defer s.End()
time.Sleep(1 * time.Second)
s.SetAttributes(attribute.String("sleep", "done"))
s.SetAttributes(attribute.String("go func", "1"))
wg.Done()
}()
...
...
go func() {
_, ss := otel.Tracer(serviceName).Start(ctx, "inside")
defer ss.End()
time.Sleep(2 * time.Second)
ss.SetAttributes(attribute.String("sleep", "done"))
ss.SetAttributes(attribute.String("go func", "2"))
wg.Done()
}()
使用以下命令在 jaeger/opentelem 目录中的 main.go 内运行完整的示例应用:
go run main.go
完成后,应用将追踪信息写入 Jaeger。通过在浏览器中访问 http://localhost:16686 打开 Jaeger。打开后,您将在 服务 下拉菜单中看到一个新条目,如 图 3**.5 所示:

图 3.5 – 应用追踪搜索
示例应用的追踪信息使用代码中定义的相同字符串注册,称为 tracing:
const serviceName = "tracing"
点击 查找追踪 按钮将读取存储的追踪信息(图 3**.6):

图 3.6 – 应用追踪
如 图 3**.6 所示,只有一个条目,如果您点击它,它将展开应用通过 Span API 提交的更多信息。

图 3.7 – 追踪信息
图 3**.7 展示了完整的追踪信息,这是从应用中获取的跨度组合。点击每个图表将显示包含在跨度中的更多信息,如代码所示:
go func() {
...
s.SetAttributes(attribute.String("sleep", "done"))
s.SetAttributes(attribute.String("go func", "1"))
...
}()
...
go func() {
...
ss.SetAttributes(attribute.String("sleep", "done"))
ss.SetAttributes(attribute.String("go func", "2"))
...
}()
现在我们知道了如何向我们的应用添加追踪,在下一节中,我们将探讨添加指标仪表化,这将使我们能够了解与应用相关的某些性能指标。
使用 Prometheus 添加指标
由于 OpenTelemetry 是供应商无关的,它为监控、导出和收集指标提供了广泛的支持,其中一种选择是 Prometheus。OpenTelemetry 支持的不同项目的完整列表可以在 opentelemetry.io/registry/ 找到。Prometheus 是一个开源的监控和警报系统服务器,在云环境中被广泛使用;它还提供了各种编程语言的库。
在上一节中,我们看到了如何向我们的应用添加追踪功能以及如何使用 Jaeger 检索追踪。在本节中,我们将探讨如何使用 OpenTelemetry 库创建指标。指标使我们能够获取应用的仪表化信息;它可以回答以下问题:
-
服务 A 中处理了多少个总请求?
-
通过支付网关 B 处理了多少笔总交易?
通常,收集的指标会存储一段时间,以便我们通过查看特定指标来更好地了解应用的性能。
我们将使用 Prometheus 开源项目 (prometheus.io/),它提供了一个完整的监控解决方案堆栈,并且非常易于使用。该项目提供了许多用于收集和存储指标以及监控我们的应用程序的有用功能。

图 3.8 – Prometheus 监控堆栈
与跟踪类似,OpenTelemetry 规范指定了指标和 SDK 的 API,如图 图 3.9 所示。

图 3.9 – 指标 API
以下是对指标 API 的解释:
-
计量器提供者:这是一个提供对计量器访问的 API。
-
计量器:这是负责创建仪表的,并且对于所涉及的仪表是唯一的。
-
仪表:它包含我们想要报告的指标;它可以是同步的或异步的。
使用 Prometheus 添加指标
让我们启动 Prometheus;请确保您在终端中位于 chapter3/prom/opentelem 目录内,并执行以下 docker 命令:
docker run --name prom \
-v $PWD/config.yml:/etc/prometheus/prometheus.yml \
-p 9090:9090 prom/prometheus:latest
注意:
如果您使用的是 Linux 机器,请使用以下命令:
docker run --name prom \
-v $PWD/config.yml:/etc/prometheus/prometheus.yml\
-p 9090:9090 --add-host=host.docker.internal:host-gateway prom/prometheus:latest
额外的参数 --add-host=host.docker.internal:host-gateway 将允许 Prometheus 使用 host.docker.internal 主机名访问主机机器。
用于配置 Prometheus 的 config.yml 文件位于 prom/opentelem 目录中,其外观如下:
scrape_configs:
- job_name: 'prometheus'
scrape_interval: 5s
static_configs:
- targets:
- host.docker.internal:2112
我们在本节中不会详细介绍不同可用的 Prometheus 配置选项。我们使用的配置通知 Prometheus,我们想要从容器主机获取指标,在容器内部称为 host.docker.internal,在端口 2112,以 5 秒的间隔。
一旦 Prometheus 成功运行,您将看到以下日志:
….
ts=2021-11-30T11:13:56.688Z caller=main.go:451 level=info fd_limits="(soft=1048576, hard=1048576)"
...
ts=2021-11-30T11:13:56.694Z caller=main.go:996 level=info msg="Loading configuration file" filename=/etc/prometheus/prometheus.yml
ts=2021-11-30T11:13:56.694Z caller=main.go:1033 level=info msg="Completed loading of configuration file" filename=/etc/prometheus/prometheus.yml totalDuration=282.112µs db_storage=537ns remote_storage=909ns web_handler=167ns query_engine=888ns scrape=126.942µs scrape_sd=14.003µs notify=608ns notify_sd=1.207µs rules=862ns
ts=2021-11-30T11:13:56.694Z caller=main.go:811 level=info msg="Server is ready to receive web requests."
接下来,打开您的浏览器并输入以下内容:http://localhost:9090。您将看到 Prometheus 的主 UI:

图 3.10 – Prometheus UI
图 3.11 展示了 Prometheus 通过拉取机制收集指标的方式,它通过连接到应用程序中运行的 HTTP 服务器暴露的端口 2112,从您的应用程序中 拉取 指标信息。我们稍后将看到,大部分繁重的工作都是由 OpenTelemetry 库完成的;我们的应用程序只需提供我们想要报告的指标。

图 3.11 – Prometheus 指标收集
现在 Prometheus 已经准备就绪,我们可以开始记录应用程序的指标。按照以下步骤在 prom/opentelem 目录内运行应用程序:
go run main.go
让应用程序运行一段时间,您将看到以下日志:
2021/11/30 22:42:08 Starting up server on port 8000
2021/11/30 22:42:12 Reporting metric metric.random
2021/11/30 22:42:22 Reporting metric metric.random
2021/11/30 22:42:32 Reporting metric metric.random
2021/11/30 22:42:47 Reporting metric metric.random
2021/11/30 22:42:57 Reporting metric metric.random
-
metric.totalrequest:此指标报告应用程序处理的总请求数量;示例应用程序在端口8000上运行一个 HTTP 服务器 -
metric.random:此指标报告一个随机数
在样本应用程序成功运行后,我们可以在 Prometheus UI 中看到这个指标。打开你的浏览器,转到 http://localhost:9090,并输入metric_random,你将看到类似于图 3.12所示的输出;点击执行按钮。

图 3.12 – metric_random指标
选择图形选项卡,你将看到以下图示:

图 3.13 – metric_random图形
我们还想要展示的另一个指标是样本应用程序的 HTTP 服务器处理的总请求数。为了生成一些指标,打开浏览器并输入 http://localhost:8000;多次这样做,以便生成一些指标。
再次打开 Prometheus UI(http://localhost:9090),添加metric_totalrequest指标,如图图 3.14所示,然后点击执行:

图 3.14 – metric_totalrequest指标
图形将如下所示:

图 3.15 – metric_totalrequest图形
如果你遇到问题并且看不到指标,请更改chapter3/prom/opentelem目录中的 Prometheus 配置文件config.yml,将目标从host.docker.internal更改为localhost,如下所示:
scrape_configs:
- job_name: 'prometheus'
scrape_interval: 5s
static_configs:
- targets:
- localhost:2112
metrics.go源代码包含初始化otel SDK 并为其配置 Prometheus 的代码,如下面的代码片段所示:
package metric
...
type ShutdownMetrics func(ctx context.Context) error
// InitMetrics use Prometheus exporter
func InitMetrics(service string) (ShutdownMetrics, error) {
config := prometheus.Config{}
c := controller.New(
processor.NewFactory(
selector.NewWithExactDistribution(),
aggregation.CumulativeTemporalitySelector(),
processor.WithMemory(true),
),
controller.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(service),
)),
)
exporter, err := prometheus.New(config, c)
if err != nil {
return func(ctx context.Context) error { return nil},
err
}
global.SetMeterProvider(exporter.MeterProvider())
srv := &http.Server{Addr: ":2112", Handler: exporter}
go func() {
_ = srv.ListenAndServe()
}()
return srv.Shutdown, nil
以下代码片段显示了它如何将指标发送到 Prometheus – 代码可以在chapter3/prom/opentelem目录中的main.go文件中找到:
package main
...
const serviceName = "samplemetrics"
func main() {
...
//setup handler for rqeuest
r.HandleFunc("/", func(rw http.ResponseWriter, r
*http.Request) {
log.Println("Reporting metric metric.totalrequest")
ctx := r.Context()
//add request metric counter
ctr.Add(ctx, 1)
...
}).Methods("GET")
...
}
现在我们已经成功地将指标和跟踪添加到我们的应用程序中,并且可以使用 Jaeger 和 Prometheus 查看它们;在下一节中,我们将探讨如何将这些工具组合在一起,以便作为一个单一单元运行。
运行 docker-compose
我们通常使用docker命令运行容器,但如果我们想一次性运行多个容器怎么办?这就是docker-compose发挥作用的地方。这个工具允许你配置你想要作为一个单一单元运行的容器。它还允许为不同的容器设置不同的配置 – 例如,容器 A 可以通过网络与容器 B 通信,但不能与容器 C 通信。
本书使用的docker-compose工具是 v2,这是推荐的版本。你可以在这里找到为不同操作系统安装工具的说明 – docs.docker.com/compose/install/other/。
为了方便运行 Prometheus 和 Jaeger,你可以使用docker-compose。docker-compose.yml文件如下所示:
version: '3.3'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "6831:6831/udp"
- "16686:16686"
- "14268:14268"
prometheus:
image: prom/prometheus:latest
volumes:
-./prom/opentelem/config.yml:/etc/prometheus/
prometheus.yml
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--web.console.libraries=/usr/share/prometheus/
console_libraries'
- '--web.console.templates=/usr/share/prometheus/
consoles›
ports:
- 9090:9090
network_mode: "host"
使用以下命令运行docker-compose:
docker-compose -f docker-compose.yml up
在成功运行后,你将看到以下日志:
prometheus_1 | ts=2021-12-04T07:45:02.443Z caller=main.go:406 level=info msg="No time or size retention was set so using the default time retention" duration=15d
prometheus_1 | ts=2021-12-04T07:45:02.443Z caller=main.go:444 level=info msg="Starting Prometheus" version="(version=2.31.1, branch=HEAD, revision=411021ada9ab41095923b8d2df9365b632fd40c3)"
prometheus_1 | ts=2021-12-04T07:45:02.443Z caller=main.go:449 level=info build_context="(go=go1.17.3, user=root@9419c9c2d4e0, date=20211105-20:35:02)"
prometheus_1 | ts=2021-12-04T07:45:02.443Z caller=main.go:450 level=info host_details="(Linux 5.3.0-22-generic #24+system76~1573659475~19.10~26b2022-Ubuntu SMP Wed Nov 13 20:0 x86_64 pop-os (none))"
prometheus_1 | ts=2021-12-04T07:45:02.444Z caller=main.go:451 level=info fd_limits="(soft=1048576, hard=1048576)"
prometheus_1 | ts=2021-12-04T07:45:02.444Z caller=main.go:452 level=info vm_limits="(soft=unlimited, hard=unlimited)"
jaeger_1 | 2021/12/04 07:45:02 maxprocs: Leaving GOMAXPROCS=12: CPU quota undefined
prometheus_1 | ts=2021-12-04T07:45:02.445Z caller=web.go:542 level=info component=web msg="Start listening for connections" address=0.0.0.0:9090
....
....
....
jaeger_1 | {"level":"info","ts":1638603902.657881,"caller":"healthcheck/handler.go:129","msg":"Health Check state change","status":"ready"}
jaeger_1 | {"level":"info","ts":1638603902.657897,"caller":"app/server.go:277","msg":"Starting GRPC server","port":16685,"addr":":16685"}
jaeger_1 | {"level":"info","ts":1638603902.6579142,"caller":"app/server.go:258","msg":"Starting HTTP server","port":16686,"addr":":16686"}
我们使用的up参数将在终端中启动容器并以附加模式运行,这允许您在屏幕上显示所有日志。您也可以以分离模式运行,如下所示,让容器在后台运行:
docker-compose -f docker-compose.yml up -d
摘要
在本节中,我们探讨了如何使用OpenTelemetry库将指标和跟踪添加到应用程序中。在应用程序中拥有这种可观察性将使我们能够更快地解决问题,并从提供的指标中跟踪我们应用程序的性能。我们还查看了一些开源项目,这些项目允许我们查看从我们的应用程序收集的数据。
在本章中,我们探讨了监控和跟踪我们的应用程序所需的管道和基础设施。在下一章中,我们将探讨为我们的 Web 应用程序构建动态和静态内容的不同方面,以及如何打包应用程序以使其更容易部署到任何地方。
第二部分:服务网页内容
完成本书的这一部分后,您将能够使用 HTML/template 和 Gorilla Mux 创建服务器端渲染的页面。您还将学习如何创建和公开一个将被前端使用的 API。将讨论保护 API,包括中间件。
本部分包括以下章节:
-
第四章**,服务并嵌入 HTML 内容
-
第五章**,保护后端和中间件
-
第六章**,转向 API 优先
第四章:服务和嵌入 HTML 内容
随着我们基础的构建,我们查看处理 HTTP 用户请求的另一个方面——路由。路由很有用,因为它允许我们根据不同的 HTTP 方法(如可以检索的 GET 和可以在同一路由上替换数据的 POST)来组织我们的应用程序。这个概念是设计基于 REST 的应用程序的基本原则。我们将通过查看如何使用 Go 版本 1.16 中引入的新 embed 指令将我们的 Web 应用程序打包为单个自包含的可执行文件来结束本章。本章将为我们提供处理用户数据和创建用户界面的工具。
到本章结束时,你将学会应用程序如何服务静态和动态内容。你还将学会如何使用单个二进制文件将所有不同的资产(图标、.xhtml、.css 等)嵌入到应用程序中,这些资产将由 Web 应用程序提供。在本章中,我们将涵盖以下主题:
-
处理 HTTP 函数和 Gorilla Mux
-
渲染静态和动态内容
-
使用 Go embed 打包你的内容
技术要求
本章的所有源代码都可以在 github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter04 上访问。
处理 HTTP 函数和 Gorilla Mux
当我们查看 Go 标准库 时,我们可以看到在 HTTP 库 上投入了大量的思考。你可以在这里查看 Go 标准库的文档:pkg.go.dev/net/http。然而,我们将涵盖基础并探讨如何在此基础上构建。值得注意的是,Go 标准库涵盖了客户端和服务器端的实现。我们只将关注我们需要的用于服务内容的部分。
我们将创建一个简单的应用程序,它将回复 Hello, World,同时在我们扩展了路由后,我们还将查看返回 POST 数据。
默认值下的 Hello, World
创建 Golang 服务器的基本概念如下:
1 package main
2
3 import (
4 "fmt"
5 "log"
6 "net/http"
7 "os"
8 "time"
9 )
10
11 func handlerGetHelloWorld(wr http.ResponseWriter,
req *http.Request) {
12 fmt.Fprintf(wr, "Hello, World\n")
13 log.Println(req.Method) // request method
14 log.Println(req.URL) // request URL
15 log.Println(req.Header) // request headers
16 log.Println(req.Body) // request body)
17 }
18
...
29
30 func main() {
...
43 router := http.NewServeMux()
44
45 srv := http.Server{
46 Addr: ":" + port,
47 Handler: router,
48 ReadTimeout: 10 * time.Second,
49 WriteTimeout: 120 * time.Second,
50 MaxHeaderBytes: 1 << 20,
51 }
52
...
57 router.HandleFunc("/", handlerGetHelloWorld)
58 router.Handle("/1", dummyHandler)
59 err := srv.ListenAndServe()
60 if err != nil {
61 log.Fatalln("Couldnt ListenAndServe()",
err)
62 }
63 }
你可以在 Git 仓库的 library-mux 子目录下看到这段代码。
这是如何工作的:我们定义了一个 handlerGetHelloWorld 处理函数(第 11 行),并将其作为参数传递给 router.HandleFunc 函数。HandleFunc 参数需要一个具有以下签名的函数参数:func(ResponseWriter, *Request)。
处理器的任务是接收一个请求类型和一个ResponseWriter,并根据请求做出决定;也就是说,决定向ResponseWriter写入什么。在我们的例子中,handlerGetHelloWorld处理器将通过fmt.Fprintf(...)函数发送Hello, World字符串作为响应。之所以能够将响应发送回去,是因为http.ResponseWriter实现了Write()函数,该函数在fmt.Fprintf(...)函数内部使用。
我们现在为主函数定义以下步骤:
-
首先,我们创建一个路由器:这是我们处理器将要连接的。我们通过
NewServeMux(第 43 行)创建我们自己的路由器。我们也可以使用默认库中找到的DefaultServeMux,但正如你将在github.com/golang/go/blob/5ec87ba554c2a83cdc188724f815e53fede91b66/src/expvar/expvar.go#L334中看到的那样,它包含了一些我们可能不想公开的额外调试端点。通过注册我们自己的,我们可以获得更多的控制权,并且如果我们想要的话,可以自己添加相同的端点。 -
其次,我们创建我们的服务器实例并将其绑定到一个可用的端口。服务器上的
Addr字段指定了要绑定的地址和端口。在我们的例子中,我们使用的是9002。不同的操作系统对可用的端口有不同的限制。例如,Linux 系统只允许管理员或 root 用户运行使用1到1023之间端口的程序。 -
最后一步是将我们的路由器附加到服务器上,启动服务器,并让它开始监听。这是在第 57 行完成的。我们在这里告诉路由器,当它收到对
"/"的任何 HTTP 请求,即文档根时,应该通过传递给我们的处理器来处理这个请求。 -
最后一个函数,
srv.ListenAndServe()(第 59 行),是一个阻塞函数,它启动我们的服务器并开始在服务器定义的端口上监听传入的请求。当找到有效的 HTTP 请求时,它会被传递到"/",然后我们的处理器被调用。我们可以运行我们的应用程序并访问http://localhost:9002/;我们应该收到服务器以下响应:

图 4.1 – Go 的“Hello, World!”
在这里值得注意的一点是,每个请求都会被分配一个自己的 goroutine 来并发执行,并且每个请求的生命周期由服务器管理,因此我们不需要做任何显式操作来利用这一点。
在下一节中,我们将探讨使用GET和POST构建不同的功能。
在 Gorilla Mux 的基础上构建基础知识
可在 github.com/gorilla/mux 访问的 Gorilla Mux 是 Gorilla 项目 的一个子项目。Gorilla Mux 是一个 HTTP 请求多路复用器,它使得匹配不同的处理器与匹配的传入请求变得容易。开发者通过使用这个库可以获得很多好处,因为它使得编写大量的样板代码变得不再必要。该库提供了基于不同标准(如方案和动态 URL)匹配请求的高级功能。
Go 标准库提供的服务器和路由器对于“免费”来说非常强大,但我们将看看如何将 Gorilla Mux 添加到我们的项目中,以及它提供的一些好处。
网页的使用不仅仅局限于返回 Hello World,通常,大多数网页应用接受用户提供的数据,更新数据,甚至删除数据,这是可能的,因为浏览器接受各种内容,如图片、视频、数据字段和纯文本。之前的练习专注于所谓的 GET 方法,这是在您的网页浏览器中加载页面时默认发送的方法,但还有更多。
标准库实现使得显式处理其他类型的方法变得容易,例如 GET、POST、PUT、DELETE 等,这些方法在 HTTP 标准中定义。这通常在处理器函数中完成,如下所示:
func methodFunc(wr http.ResponseWriter, req http.Request) {
...
switch req.Method {
case http.MethodGet:
// Serve page - GET is the default when you visit a
// site.
case http.MethodPost:
// Take user provided data and create a record.
case http.MethodPut:
// Update an existing record.
case http.MethodDelete:
// Remove the record.
default:
http.Error(wr, "Unsupported Method!",
http.StatusMethodNotAllowed)
}
}
让我们看看一个例子,说明我们如何将两个处理器,GET 和 POST,以及 Gorilla Mux 提供的一些辅助工具分开:
1 package main
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "io/ioutil"
8 "log"
9 "net/http"
10 "os"
11
12 "github.com/gorilla/mux"
13 )
14
15 func handlerSlug(wr http.ResponseWriter, req
*http.Request) {
16 slug := mux.Vars(req)["slug"]
17 if slug == "" {
18 log.Println("Slug not provided")
19 return
20 }
21 log.Println("Got slug", slug)
22 }
23
24 func handlerGetHelloWorld(wr http.ResponseWriter,
req *http.Request) {
25 fmt.Fprintf(wr, "Hello, World\n")
// request method
26 log.Println("Request via", req.Method)
// request URL
27 log.Println(req.URL)
// request headers
28 log.Println(req.Header)
// request body)
29 log.Println(req.Body)
30 }
31
32 func handlerPostEcho(wr http.ResponseWriter,
req *http.Request) {
// request method
33 log.Println("Request via", req.Method)
// request URL
34 log.Println(req.URL)
// request headers
35 log.Println(req.Header)
36
37 // We are going to read it into a buffer
38 // as the request body is an io.ReadCloser
39 // and so we should only read it once.
40 body, err := ioutil.ReadAll(req.Body)
41
42 log.Println("read >", string(body), "<")
43
44 n, err := io.Copy(wr, bytes.NewReader(body))
45 if err != nil {
46 log.Println("Error echoing response",
err)
47 }
48 log.Println("Wrote back", n, "bytes")
49 }
50
51 func main() {
52 // Set some flags for easy debugging
53 log.SetFlags(log.Lshortfile | log.Ldate |
log.Lmicroseconds)
54
55 // Get a port from ENV var or default to 9002
56 port := "9002"
57 if value, exists :=
os.LookupEnv("SERVER_PORT"); exists {
58 port = value
59 }
60
61 // Off the bat, we can enforce StrictSlash
62 // This is a nice helper function that means
63 // When true, if the route path is "/foo/",
// accessing "/foo" will perform a 301
// redirect to the former and vice versa.
64 // In other words, your application will
// always see the path as specified in the
// route.
65 // When false, if the route path is "/foo",
// accessing "/foo/" will not match this
// route and vice versa.
66
67 router := mux.NewRouter().StrictSlash(true)
68
69 srv := http.Server{
70 Addr: ":" + port, // Addr optionally
// specifies the listen address for the
// server in the form of "host:port".
71 Handler: router,
72 }
73
74 router.HandleFunc("/", handlerGetHelloWorld)
.Methods(http.MethodGet)
75 router.HandleFunc("/", handlerPostEcho)
.Methods(http.MethodPost)
76 router.HandleFunc("/{slug}", handlerSlug)
.Methods(http.MethodGet)
77
78 log.Println("Starting on", port)
79 err := srv.ListenAndServe()
80 if err != nil {
81 log.Fatalln("Couldnt ListenAndServe()", err)
82 }
83 }
我们已经将 Gorilla Mux 库导入为 mux 并设置了两个不同的处理器:handlerGetHelloWorld(第 24 行)和 handlerPostEcho(第 32 行)。handlerGetHelloWorld 是我们在上一个例子中定义的相同处理器,它响应 Hello, World。在这里,多亏了路由器的扩展功能,我们明确指定了处理器只能在用户对 "/" 端点执行 GET 方法时解析(第 74 行)。
让我们从首先切换到 chapter4/gorilla-mux 目录并运行以下命令开始样本:
go run main.go
我们可以使用 cURL,这是一个在 Windows 上可用的标准实用工具(使用 cmd 而不是 PowerShell),在 Linux(取决于您的 Linux 发行版)和 macOS 上默认安装。这个工具允许用户在终端中发出 HTTP 请求,而无需使用浏览器。在单独的终端中使用 curl localhost:9002 命令来测试服务器是否正在运行:
$ curl localhost:9002
Hello, World
$ # Specify DELETE as the option...
$ curl localhost:9002 -v -X DELETE
我们可以看到 GET 正常工作,但使用 -X DELETE 来告诉 cURL 使用 HTTP DELETE 方法会导致没有内容返回。在底层,端点正在响应一个 405 Method Not Allowed 错误消息。默认情况下,报告给用户的 405 错误消息来自库。
我们添加了第二个处理器(第 75 行),用于从POST请求中获取数据。POST方法的处理器handlerPostEcho(第 32 行)的行为与GET请求类似,但我们添加了一些额外的代码来读取用户提供的输入数据,存储它,打印它,然后返回未修改的数据。
我们可以使用 cURL 来查看这是如何工作的,就像之前一样:
$ curl -X POST localhost:9002 -d "Echo this back"
Echo this back
在这个阶段,我们跳过了很多验证,并且明确地检查/处理数据格式,例如 JSON,但我们将在后面的章节中逐步实现这一点。
使用 Gorilla Mux 的另一个好处是它使得路径模式匹配变得非常容易。这些路径变量或slugs使用{name}格式或{name:pattern}定义。以下表格展示了不同的slugs及其示例:
/books/{pagetitle}/page/{pageno} |
/books/mytitle/page/1, /books/anothertitle/page/100 |
|---|---|
/posts/{slug} |
/posts/titlepage/posts/anothertitle |
模式可以是正则表达式的一种类型。例如,在我们的示例代码中,我们添加了一个handlerSlug处理器(第 15 行)来执行简单的捕获。我们可以使用 cURL 来测试这一点,如下面的代码所示:
$ curl localhost:9002/full-stack-go
…
$ # Our server will show the captured variable in its output
...
2022/01/15 14:58:36.171821 main.go:21: Got slug > full-stack-go <
在本节中,我们学习了如何编写处理器并使用 Gorilla Mux。我们还探讨了如何配置 Gorilla Mux 来处理由处理器处理的动态路径。在下一节中,我们将探讨如何从我们的应用程序向用户提供内容。提供的内容将包含静态和动态内容。
渲染静态内容
在本节中,我们将学习如何将我们创建的网页作为静态内容提供。我们将使用标准的 Go net/http包来提供网页服务。所有代码和 HTML 文件都可以在static/web目录内找到(github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter04/static/web)。
使用以下命令执行服务器:
go run main.go
您将在屏幕上看到以下消息:
2022/01/11 22:22:03 Starting up server on port 3333 ...
打开您的浏览器,输入http://localhost:3333作为 URL。您将看到如图图 4.2所示的登录页面:

图 4.2 – 登录页面
要访问仪表盘页面,您可以使用 URL http://localhost:3333/dashboard.xhtml。您将看到如下截图:

图 4.3 – 仪表盘页面
快速浏览一下提供静态页面的代码:
1 package main
2
3 import (
4 "log"
5 "net/http"
6 )
7
8 func main() {
9 fs := http.FileServer(http.Dir("./static"))
10 http.Handle("/", fs)
11
12 log.Println("Starting up server on port 3333
...")
13 err := http.ListenAndServe(":3333", nil)
14 if err != nil {
15 log.Fatal("error occurred starting up
server : ", err)
16 }
17 }
如所示,这是一个简单的 HTTP 服务器,它使用了 Go 标准库中的http.FileServer(..)函数(在第 9 行显示)。该函数通过传递(./static)参数到我们想要提供服务的目录(第 9 行)来调用。示例代码可以在chapter4/static/web/static文件夹内找到。
渲染动态内容
现在我们已经了解了如何使用 net/http 包提供静态内容,让我们看看如何添加一些动态内容,使用的是在这里找到的 Gorilla Mux:github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter04/dynamic。使用以下命令执行服务器:
go run main.go
启动浏览器并输入 http://localhost:3333 作为地址;你将看到一个与静态内容相似的登录界面。在登录界面上执行以下步骤:
-
在登录界面上输入任意用户名和密码组合。
-
点击
登录按钮。
你将得到一个 登录失败 的消息,如图 4**.4 所示。

图 4.4 – 登录后的消息屏幕
我们为登录操作引入了动态内容,这意味着应用程序将根据某些条件提供页面,在这种情况下,是用户名/密码组合的成功验证。为了成功验证,请输入 admin/admin 作为用户名/密码组合,因为这在数据库中存在。
让我们进一步探索代码,以了解它是如何工作的:
1 package main
2
3 import (
4 "fmt"
5 "github.com/gorilla/mux"
6 "html/template"
7 "log"
8 "net/http"
9 "os"
10 "path/filepath"
11 "time"
12 )
13
14 type staticHandler struct {
15 staticPath string
16 indexPage string
17 }
18
19 func (h staticHandler) ServeHTTP(w
http.ResponseWriter, r *http.Request) {
20 path, err := filepath.Abs(r.URL.Path)
21 log.Println(r.URL.Path)
22 if err != nil {
23 http.Error(w, err.Error(),
http.StatusBadRequest)
24 return
25 }
26
27 path = filepath.Join(h.staticPath, path)
28
29 _, err = os.Stat(path)
30
31 http.FileServer(
http.Dir(h.staticPath)).ServeHTTP(w, r)
32 }
33
34 func postHandler(w http.ResponseWriter,
r *http.Request) {
35 result := "Login "
36 r.ParseForm()
37
38 if validateUser(r.FormValue("username"),
r.FormValue("password")) {
39 result = result + "successfull"
40 } else {
41 result = result + "unsuccessful"
42 }
43
44 t, err :=
template.ParseFiles("static/tmpl/msg.xhtml")
45
46 if err != nil {
47 fmt.Fprintf(w, "error processing")
48 return
49 }
50
51 tpl := template.Must(t, err)
52
53 tpl.Execute(w, result)
54 }
55
56 func validateUser(username string,
password string) bool {
57 return (username == "admin") &&
(password == "admin")
58 }
59
60 func main() {
61 router := mux.NewRouter()
62
63 router.HandleFunc("/login",
postHandler).Methods("POST")
64
65 spa := staticHandler{staticPath: "static",
indexPage: "index.xhtml"}
66 router.PathPrefix("/").Handler(spa)
67
68 srv := &http.Server{
69 Handler: router,
70 Addr: "127.0.0.1:3333",
71 WriteTimeout: 15 * time.Second,
72 ReadTimeout: 15 * time.Second,
73 }
74
75 log.Fatal(srv.ListenAndServe())
76 }
ServeHTTP 函数(第 19 行)负责根据在 staticHandler 结构体(第 65 行)中定义的目录指定的内容提供服务,该结构体指向包含索引页面 index.xhtml 的 static 目录。处理器的配置是通过附加到 / 路径前缀的 Gorilla Mux 注册的(第 66 行)。
下一个部分是处理 /login 端点注册的代码(第 63 行)。postHandler 函数(第 34 行)从请求中提取并验证用户名和密码信息。
网页包含两个输入元素,即用户名和密码,当用户点击 ParseForm() 函数(第 36 行)时,浏览器会发送这些元素,然后通过引用字段名 username 和 password(第 38 行)提取传递的值,这些字段名对应于 chapter04/dynamic/static/index.xhtml 文件内指定的 HTML 元素名称。
完成验证过程后,应用程序随后使用 Go 的 html/template 包(第 44 行)解析另一个 HTML 文件(static/tmpl/msg.xhtml)。应用程序将解析 HTML 文件,并使用 template.Must 函数(第 51 行)将所有相关信息插入到 HTML 页面中。
此 msg.xhtml 文件包含一个 {{.}} 占位符字符串,该字符串被 html/template 包(第 18 行)理解:
1 <!DOCTYPE html>
2 <html>
3 <head>
...
18 <p class="text-xs text-gray-50">{{.}}
</p>
...
24 </html>
在本节中,我们学习了如何渲染动态内容。在下一节中,我们将查看如何捆绑我们的静态和动态内容,以便我们可以作为一个单独的文件运行应用程序。
使用 Go embed 打包你的内容
在本节中,我们将探讨如何将应用程序打包成一个单独的二进制文件。将应用程序所需的所有内容打包成一个二进制文件,使其更容易在云中的任何地方部署。我们将使用由 Go 标准库 提供的 embed 包。以下链接提供了关于 embed 包内不同函数的更多详细信息:pkg.go.dev/embed。
注意
embed 包仅在 Go 版本 1.16 及以上版本中可用。
以下代码提供了一个使用 embed 包的简单示例,以三种不同的方式——嵌入特定文件、嵌入文件夹的全部内容以及嵌入特定文件类型:
1 package main
2
3 import (
4 "embed"
5 "fmt"
6 "github.com/gorilla/mux"
7 "html/template"
8 "io/fs"
9 "log"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15 )
16
17 var (
18 Version string = strings.TrimSpace(version)
19 //go:embed version/version.txt
20 version string
21
22 //go:embed static/*
23 staticEmbed embed.FS
24
25 //go:embed tmpl/*.xhtml
26 tmplEmbed embed.FS
27 )
28
29 type staticHandler struct {
30 staticPath string
31 indexPage string
32 }
33
34 func (h staticHandler) ServeHTTP(w
http.ResponseWriter, r *http.Request) {
35 path, err := filepath.Abs(r.URL.Path)
36 log.Println(r.URL.Path)
37 if err != nil {
38 http.Error(w, err.Error(),
http.StatusBadRequest)
39 return
40 }
41
42 path = filepath.Join(h.staticPath, path)
43
44 _, err = os.Stat(path)
45
46 log.Print("using embed mode")
47 fsys, err := fs.Sub(staticEmbed, "static")
48 if err != nil {
49 panic(err)
50 }
51
52 http.FileServer(http.FS(fsys)).ServeHTTP(w,
r)
53 }
54
55 //renderFiles renders file and push data (d) into
// the templates to be rendered
56 func renderFiles(tmpl string, w
http.ResponseWriter, d interface{}) {
57 t, err := template.ParseFS(tmplEmbed,
fmt.Sprintf("tmpl/%s.xhtml", tmpl))
58 if err != nil {
59 log.Fatal(err)
60 }
61
62 if err := t.Execute(w, d); err != nil {
63 log.Fatal(err)
64 }
65 }
66
67 func postHandler(w http.ResponseWriter,
r *http.Request) {
68 result := "Login "
69 r.ParseForm()
70
71 if validateUser(r.FormValue("username"),
r.FormValue("password")) {
72 result = result + "successfull"
73 } else {
74 result = result + "unsuccessful"
75 }
76
77 renderFiles("msg", w, result)
78 }
79
80 func validateUser(username string,
password string) bool {
81 return (username == "admin") &&
(password == "admin")
82 }
83
84 func main() {
85 log.Println("Server Version :", Version)
86
87 router := mux.NewRouter()
88
89 router.HandleFunc("/login", postHandler)
.Methods("POST")
90
91 spa := staticHandler{staticPath: "static",
indexPage: "index.xhtml"}
92 router.PathPrefix("/").Handler(spa)
93
94 srv := &http.Server{
95 Handler: router,
96 Addr: "127.0.0.1:3333",
97 WriteTimeout: 15 * time.Second,
98 ReadTimeout: 15 * time.Second,
99 }
100
101 log.Fatal(srv.ListenAndServe())
102 }
源代码位于 chapter4/embed 文件夹中。代码使用 //go:embed 指令(第 19、22 和 25 行)。这告诉编译器 version string(第 20 行)将从 version/version.txt 获取内容,其中包含我们想要向用户显示的版本信息。
我们还声明了 //go:embed 指令,告诉编译器我们想要包含 static/(第 22 行)和 tmpl/(第 25 行)文件夹中的所有内容。在编译过程中,编译器检测到前面的指令,并自动将所有不同的文件包含到二进制文件中。
tmpl 目录包含将渲染动态内容的模板,由于我们已经将其嵌入到二进制文件中,我们需要使用不同的方式来渲染它(第 56 行)。新的 renderFiles 函数使用 template.ParseFS 函数(第 57 行),它渲染 tmplEmbed 变量中声明的模板。
renderFiles 函数是从 postHandler 函数(第 77 行)调用的,传递模板名称和其他参数。
现在,当我们构建应用程序时,最终的可执行文件包含不同的文件(HTML、CSS 等)在一个文件中。我们现在可以编译应用程序,如下所示:
go build -o embed
这将生成一个可执行文件——例如,在 Linux 中,它将被称为 embed,在 Windows 中,它将被称为 embed.exe。接下来,按照以下方式运行应用程序:
./emded
打开浏览器并访问 http://localhost:3333/。它应该看起来和之前一样,只是所有内容都是通过 embed.FS 获取的。现在你拥有了一个完全嵌入的应用程序,它可以作为一个单独的二进制文件在云端部署。
摘要
这章内容相当丰富,它作为我们首次了解与用户提供的数据进行交互和处理 Web 请求的起点。我们看到了如何使用 Go 标准库添加 RESTful 端点,并学习了如何利用 Gorilla Mux 的实用函数快速为我们的应用程序添加更多功能和强大功能。我们还探索了处理请求的不同方法。在一种方法中,我们现在可以利用 Go 的html/template库动态创建内容并将其打包为从磁盘读取的目录。或者,我们可以使用新的 Go embed指令来获得一个包含所有资源的单个二进制文件,从而实现简单的部署。
在下一章中,我们将探讨如何添加中间件以帮助处理请求管道,并引入安全机制以确保内容可以安全访问。
第五章:保护后端和中间件
在前面的章节中,我们学习了如何构建我们的数据库,以服务器形式运行我们的 Web 应用程序,并服务动态内容。在本章中,我们将讨论安全性——特别是,我们将查看如何保护 Web 应用程序。安全性是一个广泛的话题,所以对于本章,我们只会查看与我们的应用程序相关的安全方面。我们还将探讨的另一个主题是中间件以及将其作为我们应用程序的一部分使用。
中间件是一种软件,它被引入到应用程序中以提供通用的功能,这些功能用于我们应用程序的入站和出站流量。中间件使得在不同部分的应用程序中集中使用功能变得容易,这将在本章的后续部分中进一步讨论。
在本章中,我们将涵盖以下主题:
-
添加身份验证
-
添加中间件
-
使用 Redis 添加 cookies 和会话
完成本章后,您将学习如何设置用户数据库并为应用添加身份验证。我们还将了解中间件以及如何将其添加到现有应用中。最后,您将学习关于 cookies、在会话中存储信息以及使用 Redis 作为这些会话的持久化存储。
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Becoming-a-Full-Stack-Go-Developer/tree/main/Chapter05中找到。
添加身份验证
构建应用程序需要在设计应用程序时进行一些考虑,并且需要提前考虑的关键部分之一是安全性。安全性有许多方面,但在这个应用程序的部分,我们将查看身份验证。
注意
身份验证是验证用户是否是他们所声称的过程。
要为我们的应用添加身份验证,我们首先需要在数据库中存储用户信息。用户信息将用于在使用应用程序之前验证用户。数据库用户表可以在db/schema.sql文件中找到:
CREATE TABLE gowebapp.users (
User_ID BIGSERIAL PRIMARY KEY,
User_Name text NOT NULL,
Password_Hash text NOT NULL,
Name text NOT NULL,
Config JSONB DEFAULT '{}'::JSONB NOT NULL,
Created_At TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
Is_Enabled BOOLEAN DEFAULT TRUE NOT NULL
下表概述了用于用户表的数据类型:
BIGSERIAL |
一种自动递增的数据类型,通常用作主键。 |
|---|---|
TEXT |
可变长度的字符字符串。 |
JSONB |
JSON 二进制数据类型适合 JSON 数据。数据库提供此数据类型以使其更容易索引、解析和直接查询 JSON 数据。 |
TIMESTAMP |
日期和时间数据类型。 |
BOOLEAN |
包含真或假的逻辑数据类型。 |
身份验证将通过检查User_Name和Pass_Word_Hash字段来执行。有一点需要注意——Pass_Word_Hash字段包含一个加密密码,我们将在稍后进一步探讨加密密码。
如同在 第一章 中讨论的,构建数据库和模型,我们正在使用 sqlc 生成与数据库通信的 Go 代码。要生成 Go 代码,请执行以下命令:
make generate
将读取用户信息的代码将存储在 gen/query.sql_gen.go 文件中,如下所示:
...
func (q *Queries) GetUserByName(ctx context.Context, userName string) (GowebappUser, error) {
row := q.db.QueryRowContext(ctx, getUserByName, userName)
var i GowebappUser
err := row.Scan(
&i.UserID,
&i.UserName,
&i.PasswordHash,
&i.Name,
&i.Config,
&i.CreatedAt,
&i.IsEnabled,
)
return i, err
}
...
GetUserByName 函数通过调用 QueryRowContext() 函数查询数据库,传递我们想要使用的查询,该查询定义如下:
const getUserByName = `-- name: GetUserByName :one
SELECT user_id, user_name, pass_word_hash, name, config, created_at, is_enabled
FROM gowebapp.users
WHERE user_name = $1
`
查询使用了 WHERE 子句,并期望一个参数,即 user_name 字段。这是通过将 userName 参数传递给 QueryRowContext() 函数来填充的。
在下一节中,我们将探讨在启动应用程序时如何创建虚拟用户。虚拟用户通常用于测试目的的用户 - 在我们的案例中,我们想要创建一个虚拟用户来测试身份验证过程。
创建我们的虚拟用户
我们的数据库是空的,因此我们需要用虚拟用户来填充它。在本节中,我们将探讨如何创建一个虚拟用户。当应用程序启动时,我们将添加代码来创建虚拟用户。main.go 中的以下函数创建虚拟用户,该用户将用于登录应用程序:
func createUserDb(ctx context.Context) {
//has the user been created
u, _ := dbQuery.GetUserByName(ctx, "user@user")
if u.UserName == "user@user" {
log.Println("user@user exist...")
return
}
log.Println("Creating user@user...")
hashPwd, _ := pkg.HashPassword("password")
_, err := dbQuery.CreateUsers(ctx,
chapter5.CreateUsersParams{
UserName: "user@user",
PassWordHash: hashPwd,
Name: "Dummy user",
})
...
}
当应用程序启动时,它将首先检查是否存在现有的测试用户,如果不存在,它将自动创建一个。这是放在应用程序中,以便我们更容易测试应用程序。createUserDb() 函数使用生成的 sqlc 函数 CreateUsers() 来创建用户。
您会注意到密码是通过以下代码片段创建的:
hashPwd, _ := pkg.HashPassword("password")
密码被传递给 HashPassword 函数,该函数将返回明文密码的哈希版本。
HashPassword 函数使用了 Go 的 crypto 或 bcrypt 标准库,这些库提供了一个函数来返回一个普通字符串的哈希值,如下所示:
func HashPassword(password string) (string, error) {
bytes, err :=
bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
从字符串密码生成的哈希值,每次调用 bcrypt.GenerateFromPassword 函数时都会不同。GenerateFromPassword() 函数使用标准密码学库来生成密码的哈希值。
密码学是将文本消息转换为不易阅读或分解的形式的实践。这提供了数据安全,使得很难分解数据的内容。Go 提供了一个标准库,该库提供了密码学函数,这些函数可在 golang.org/x/crypto 包中使用。crypto 库提供了一系列密码学函数,您可以根据应用程序的需求进行选择。在我们的示例中,我们使用了 bcrypt,这是一个密码哈希函数。
现在我们已经添加了一个在数据库中创建虚拟用户的功能,在下一节中,我们将探讨如何与数据库进行身份验证。
用户身份验证
用户认证很简单,因为应用程序将使用 sqlc 生成的函数,如下所示:
func validateUser(username string, password string) bool {
...
u, _ := dbQuery.GetUserByName(ctx, username)
...
return pkg.CheckPasswordHash(password, u.PassWordHash)
}
GetUserByName 函数用于通过将用户名作为参数传递来获取用户信息。一旦成功检索到信息,它将通过调用 CheckPasswordHash 来检查密码是否正确。
CheckPasswordHash 函数使用相同的 crypto 或 bcrypt 包,并调用 CompareHashAndPassword 函数,该函数将哈希密码与客户端发送的密码进行比较。如果密码匹配,函数返回 true。
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash),
[]byte(password))
return err == nil
}
validateUser 函数将在用户名和密码组合存在于数据库且正确时返回 true。
启动您的应用程序,并在 Web 浏览器中导航到 http://127.0.0.1:3333/,您应该会看到一个登录提示。在输入 user@user / password 之前,尝试使用错误的凭据登录 – 您现在应该被发送到成功的登录屏幕!恭喜 – 您成功认证了!
在下一节中,我们将探讨中间件,它是什么,以及如何将其添加到我们的应用程序中。
添加中间件
中间件是一段配置为 HTTP 处理器的代码。中间件将预处理和后处理请求,并位于主 Go 服务器和已声明的实际 HTTP 处理器之间。
将中间件作为我们应用程序的一部分添加有助于处理主要应用程序功能之外的任务。中间件可以处理身份验证、日志记录和速率限制等任务。在下一节中,我们将探讨添加简单的日志中间件。
基本中间件
在本节中,我们将向我们的应用程序添加一个简单的基本中间件。基本中间件的代码片段如下:
func basicMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(wr http.ResponseWriter,
req *http.Request) {
log.Println("Middleware called on", req.URL.Path)
// do stuff
h.ServeHTTP(wr, req)
})
}
Gorilla Mux 使得使用我们的中间件变得非常容易。这是通过在路由器上公开一个名为 Use() 的函数来完成的,该函数通过可变数量的参数实现,可以用来堆叠多个中间件以按顺序执行:
func (*mux.Router).Use(mwf ...mux.MiddlewareFunc)
以下代码片段显示了如何实现 Use() 函数以注册中间件:
func main() {
...
// Use our basicMiddleware
router.Use(basicMiddleware)
...
}
mux.MiddlewareFunc 简单地是 func(http.Handler) http.Handler 的类型别名,这样任何满足该接口的东西都可以工作。
要查看我们的函数如何工作,我们只需调用 router.Use(),传入我们的中间件,导航到我们的 Web 应用程序,在那里我们可以看到它被调用:
go build && ./chapter5
2022/01/24 19:51:56 Server Version : 0.0.2
2022/01/24 19:51:56 user@user exists...
2022/01/24 19:52:02 Middleware called on /app
2022/01/24 19:52:02 Middleware called on /css/minified.css
…
您可能想知道为什么您可以看到它被多次以不同的路径调用 – 原因是当请求我们的应用程序时,它正在执行针对大量托管资源的多个 GET 请求。这些请求中的每一个都像 图 5**.1 中所示的那样通过我们的中间件:

图 5.1 – 请求通过中间件传递
handlers 库(可在 github.com/gorilla/handlers 找到)包含许多其他有用的中间件方法,我们将在稍后使用其中一些,包括 handlers.CORS() 中间件,允许我们处理跨源资源共享(CORS)。我们将在第九章Tailwind, Middleware, 和 CORS.*中更详细地探讨 CORS 和使用此中间件。
在本节中,我们学习了中间件,它可以提供的不同功能,以及如何将其添加到应用程序中。在下一节中,我们将探讨会话处理和使用 cookies 跟踪用户信息,当他们使用应用程序时。
添加 cookies 和会话
在本节中,我们将探讨在使用我们的应用程序时如何跟踪用户。我们将探讨会话管理以及它如何帮助我们的应用程序理解用户是否有权访问我们的应用程序。我们还将探讨 cookies,这是我们将要使用的会话管理工具。
本章讨论的会话管理是 Gorilla 项目的组成部分,该项目可在 github.com/gorilla/sessions 找到。
Cookies 和会话处理
在本节中,我们将探讨会话处理以及如何使用它来存储与特定用户相关的信息。我们所知的网络在本质上是无状态的,这意味着请求实际上并没有与任何其他之前的请求绑定。这使得很难知道哪些请求属于哪个用户。因此,需要跟踪这一点并存储有关用户的信息。
注意
网络会话用于促进用户与在请求和响应序列中使用的不同服务之间的交互。会话对特定用户是唯一的。
会话存储在内存中,每个会话属于特定用户。如果应用程序停止运行或应用程序决定删除会话信息,会话信息将会丢失。有不同方法可以将会话信息永久存储在存储中,以便在未来使用。
图 5**.2 展示了会话创建和使用的高级流程,用于每个传入的请求。当不存在会话时,将创建新的会话,一旦会话可用,应用程序就可以使用它来存储相关的用户信息。

图 5.2 – 会话检查流程
我们知道会话用于存储特定用户的信息——问题是应用程序如何知道为哪个用户使用哪个会话。答案是应用程序和浏览器之间来回发送的一个密钥。这个密钥被称为会话密钥,如 图 5**.3 所示,它被添加到 cookie 标头中。

图 5.3 – 包含会话令牌的 cookie
如图 5**.3所示,带有session_token标签的 cookie 包含将发送回服务器以识别存储在会话中的用户的键。图 5**.3显示了浏览器的开发者控制台。对于 Firefox,您可以通过工具 > Web 开发者 > Web 开发者工具菜单打开它,如果您使用的是 Chrome,您可以通过Ctrl + Shift + J访问它。
以下代码片段展示了sessionValid函数,该函数检查传入的请求是否包含有效的session_token键。如果当前用户没有可用的现有会话,store.Get函数将自动创建一个新的会话:
//sessionValid check whether the session is a valid session
func sessionValid(w http.ResponseWriter, r *http.Request) bool {
session, _ := store.Get(r, "session_token")
return !session.IsNew
}
一旦应用程序找到用户的会话,它将检查用户的认证状态,如下所示。会话信息存储为映射,映射类型以键值对的形式存储信息,因此在我们的情况下,我们正在检查会话是否包含authenticated键:
func hasBeenAuthenticated(w http.ResponseWriter, r *http.Request) bool {
session, _ := store.Get(r, "session_token")
a, _ := session.Values["authenticated"]
...
}
如果在获取authenticated键时出现失败,应用程序将自动将请求重定向到显示登录页面,如下所示:
//if it does have a valid session make sure it has been //authenticated
if hasBeenAuthenticated(w, r) {
...
}
//otherwise it will need to be redirected to /login
...
http.Redirect(w, r, "/login", 307)
我们已经学习了会话以及如何使用它们来检查用户是否已认证。我们将进一步探讨这个问题。
存储会话信息
在上一节中,我们学习了会话和 cookie 处理。在本节中,我们将探讨如何存储与用户相关的会话信息。存储在会话中的信息存储在服务器内存中,这意味着只要服务器仍在运行,这些数据将临时可用。一旦服务器停止运行,存储在内存中的所有数据将不再可用。这就是为什么我们将在下一节中探讨如何在单独的存储系统中持久化数据。
在我们的示例应用程序中,我们存储有关用户是否成功认证的信息。只有当用户成功认证后,他们才能访问应用程序的其他部分。
运行示例应用程序,并在私密模式(Firefox)或隐身模式(Chrome)下打开您的浏览器,将http://localhost:3333/dashboard.xhtml作为地址输入。由于会话不存在,应用程序将重定向您到登录页面。检查authenticated键存在性的操作是在以下所示的storeAuthenticated函数中执行的:
func storeAuthenticated(w http.ResponseWriter, r *http.Request, v bool) {
session, _ := store.Get(r, "session_token")
session.Values["authenticated"] = v
err := session.Save(r, w)
...
}
session.Save函数在创建带有新值作为函数调用一部分的authenticated键后,将会话保存到内存中。
使用 Redis 进行会话
如前节所述,会话存储在内存中。在本节中,我们将探讨使用 Redis 永久存储会话信息。本节的代码示例可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter05-redis找到。
我们想要使用 Redis 的原因是因为它在数据存储方面的简单性,只包含键值。它还可以配置为内存和永久外部存储。对于我们的应用程序,我们需要配置redis以将信息存储在磁盘上,使其永久化。执行以下make命令以运行redis:
make redis
以下是在运行redis时使用的完整 Docker 命令:
docker run -v $(PWD)/redisdata:/data --name local-redis -p 6379:6379 -d redis --loglevel verbose
该命令使用 Docker 运行redis,并将redisdata本地目录指定为数据永久文件存储的位置。要运行示例应用程序,请确保您还使用以下命令运行postgres:
make teardown_recreate
一旦redis和postgres都启动并运行,现在您可以运行示例应用程序并使用 Web 应用程序。以下代码片段显示了initRedis()函数,该函数负责初始化 Redis。该函数使用两个不同的包,您可以在github.com/redis/go-redis和github.com/rbcervilla/redisstore找到。go-redis/redis包包含与 Redis 通信的驱动程序和 API,而rbcervilla/redisstore包含一个简单的 API,用于从 Redis 读取、写入和删除数据:
func initRedis() {
var err error
client = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
store, err = rstore.NewRedisStore(context.Background(),
client)
if err != nil {
log.Fatal("failed to create redis store: ", err)
}
store.KeyPrefix("session_token")
}
初始化完成后,store变量将用于向 Redis 写入数据并从中读取。在gorilla库中,sessions包自动使用配置的client对象来处理所有向 Redis 写入和读取信息。
添加了一个新的处理程序,允许用户从应用程序中注销,如处理程序片段所示:
func logoutHandler(w http.ResponseWriter, r *http.Request) {
if hasBeenAuthenticated(w, r) {
session, _ := store.Get(r, "session_token")
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
log.Println("failed to delete session", err)
}
}
http.Redirect(w, r, "/login", 307)
}
注销操作是通过设置会话的Options.MaxAge字段来完成的。这表示当相同的session_token下次传递给服务器时,它被视为无效/过期的会话,并将重定向到登录页面。
摘要
在本章中,我们了解了一些可以帮助我们应用程序更好地新事物。我们学习了如何向应用程序添加认证层以保护它,这有助于保护我们的应用程序免受匿名访问。我们还探讨了向应用程序添加中间件,并展示了如何轻松地向应用程序添加不同的中间件而无需更改太多代码。
最后,我们探讨了会话处理,并学习了如何使用它来跟踪用户信息和用户与我们的应用程序的旅程。由于会话处理不会永久存储,我们研究了使用redis数据存储来存储用户会话信息,这使得应用程序能够在应用程序重启时随时记住用户信息。
在下一章中,我们将探讨编写代码以在浏览器和我们的应用程序之间来回处理信息。我们将研究构建一个 REST API,该 API 将用于对我们的数据进行不同的操作。
第六章:转向 API 优先
在前面的章节中,我们学习了如何构建数据库、向应用程序添加监控、使用中间件以及处理会话。在本章中,我们将学习如何在我们的应用程序中构建 API,以及为什么 API 是编写应用程序的重要部分,因为它构成了前端和后端之间的接口。首先构建 API 很重要,因为它形成了数据交换的桥梁,可以将其视为前端和后端之间的合同。在构建应用程序之前,拥有正确和正确的合同形式很重要。
我们还将探讨 REST 和 JSON 的概念,以更好地理解它们是什么以及如何在我们的应用程序中应用。
在完成本章后,你将了解如何使用 Gorilla Mux 设计 REST API,以及如何通过将数据转换为 JSON 和从 JSON 转换回来来处理请求以执行操作。你还将学习如何处理错误。
在本章中,我们将涵盖以下主题:
-
结构化 API 优先应用程序
-
暴露 REST API
-
使用 Go 将数据转换为 JSON 以及从 JSON 转换回来
-
使用 JSON 进行错误处理
技术要求
本章中解释的所有源代码都可以从github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter06获取。
结构化应用程序
Go 应用程序在目录内结构化,每个目录包含对应用程序有意义的 Go 源代码。有多种方式可以在不同的目录中结构化你的 Go 应用程序;然而,你必须记住的是,始终给目录起一个易于他人理解的名字。随着应用程序随着时间的推移而增长,所选的目录结构和代码放置的位置对其他开发人员如何轻松地与代码库一起工作有重大影响。
定义包
到目前为止,我们保持事情相对简单,但我们将提高我们的水平,转向一个相当常见的布局。我们不会使用“标准布局”这个术语,因为在 Go 中并没有这样的东西,但我们将查看我们如何构建我们的新项目,并讨论我们如何通过最佳结构化我们的 Go 应用程序来推理它们,以便清晰和易于理解,如图6**.1所示。

图 6.1:第六章包结构
让我们更详细地检查一些这些文件,以了解这些决策。
generate.go
如果你查看这个文件,一开始可能会觉得有些混乱,但我们使用了一个叫做go generate的整洁的 Go 特性,它可以提供帮助:
package main
//go:generate echo Generating SQL Schemas
//go:generate sqlc generate
乍一看,它看起来像一条注释,因为 Go 中的注释以 // 字符开始。然而,这个以单词 go:generate 开始。这被称为 go:generate 指令;这意味着当执行 go generate(如下面的代码块所示)时,它将执行指定的命令——在我们的例子中,它将打印文本 Generating SQL Schemas 并执行 sqlc 命令行工具(sqlc generate):
$ go generate
Generating SQL Schemas
$
这是一个有用的技术,可以轻松地生成你的构建前提条件;这可以作为你的工作流程的一部分来完成,由 Makefile 执行,或者由 CI/CD 执行。Makefile 是一个包含规则集的文件,用于确定程序哪些部分需要编译以及编译源代码时使用什么命令。它可以用来编译各种编程语言的源代码。
在我们的 generate.go 文件中,我们只是确保为 sqlc 生成最新的模式文件。我们可以添加模拟生成、更多的信息性消息,或者生成存档或其他任何可能构成我们构建的有用事物。
handlers.go
这个名字纯粹来源于我们使用相同命名模式的经验,即根据其中定义的功能来命名文件。我们的 handlers 文件提供了一个单一的位置(目前是这样),我们的 HTTP 处理器可以在这里找到。我们的包含登录、注销以及与我们的应用程序交互所需的所有类型的处理程序及其请求和响应类型。我们在这个文件中不做任何超出处理程序的事情;所有连接性和中间件的添加都是作为 main.go 的一部分来执行的,以确保关注点的分离。
internal/
在 Go 的“旧时代”——在 1.0 版本发布之前——Go 源代码中有一个名为 pkg 的目录,它是仅限内部使用的代码,成为社区的习惯用语,同时也是标记子目录/包为特定项目内部的方式。
pkg 文件夹最终被从 Go 项目中移除,但它留下了一丝未满足的需求,因此创建了 internal 目录。internal 是一个特殊的目录,因为它被 Go 工具本身所识别,这允许作者限制导入包,除非它们共享一个共同的祖先。为了演示这一点,我们将我们的 API 包以及 env.go(用于简化在应用程序中读取环境变量的方式)和 auth.go(我们处理授权的特定方式)存储在这里——特别是 auth.go 或 handlers.go 文件是防止他人导入的好选择,而像 env 包这样的其他包则更为通用,可以将其移动到更高层次。
迁移、查询和存储
使用 sqlc 和 golang-migrate,我们为自己在使事情易于组织和提高快速创建应用程序的能力方面提供了优势。我们只是将事物分开,使生活变得更容易,正如这里所示的 sqlc.yaml 配置文件所示:
path: store/
schema: migrations/
queries: queries/
要了解实际应用中的工作原理,请查看仓库中提供的readme文件。
我们已经通过将应用程序的不同部分分别放入不同的文件夹来研究了应用程序的结构化。将源代码分组到不同的文件夹中,使得在维护和开发过程中更容易导航应用程序。在下一节中,我们将探讨构建一个用于消费数据的 API。
暴露我们的 REST API
让我们了解一些在本节中将要使用到的概念:
-
REST –
https://what-ever-shop.com/orders/1。 -
使用 JSON 作为交换格式 – 例如,对
https://what-ever-shop.com/orders/1的GET请求可能会返回以下响应体:GET, POST, PUT, PATCH, and DELETE. -
API – API是应用程序编程接口(Application Programming Interface)的缩写,是一种软件中介,允许两个应用程序相互通信。例如,如果你正在使用谷歌搜索引擎,你正在使用它提供的 API。
结合上述两个概念,我们得到了 REST API,我们正在构建的软件被称为 RESTful API,这意味着我们提供的 API 可以使用 REST 进行访问。
在本节中,我们将探讨暴露我们的 RESTful 处理器,这是一种 API 服务器的模式,并讨论我们的新middleware.Main会话和 API 包。
我们对新的 API-first 项目进行了一些重构。我们将 API 服务器抽象为internal/api中的独立包。其责任是提供一个服务器,该服务器接受一个要绑定的端口,并具有启动服务器、停止服务器和添加带有可选中间件的路由的能力。
以下是从chapter06/main.go中提取的我们的新主函数片段,展示了这种方法:
1 func main() {
2 ...
3 server := api.NewServer(internal.GetAsInt(
"SERVER_PORT", 9002))
4
5 server.MustStart()
6 defer server.Stop()
7
8 defaultMiddleware := []mux.MiddlewareFunc{
9 api.JSONMiddleware,
10 api.CORSMiddleware(internal.GetAsSlice(
"CORS_WHITELIST",
11 []string{
12 "http://localhost:9000",
13 "http://0.0.0.0:9000",
14 }, ","),
15 ),
16 }
17
18 // Handlers
19 server.AddRoute("/login", handleLogin(db),
http.MethodPost, defaultMiddleware...)
20 server.AddRoute("/logout", handleLogout(),
http.MethodGet, defaultMiddleware...)
21
22 // Our session protected middleware
23 protectedMiddleware :=
append(defaultMiddleware,
validCookieMiddleware(db))
24 server.AddRoute("/checkSecret",
checkSecret(db), http.MethodGet,
protectedMiddleware...)
25
26 ...
27 }
仔细注意我们是如何创建默认中间件的,它声明在defaultMiddleware变量(第 8 行)中。对于我们的受保护路由,我们将protectedMiddleware变量(第 23 行)追加到现有的defaultMiddleware变量中。我们的自定义会话验证中间件被添加到中间件链中(第 23 行),以确保在允许访问我们的其他处理器之前进行有效的登录。
我们还向这个api包中推入了两种类型的中间件,JSONMiddleware(第 9 行)和CORSMiddleware(第 10 行),它接受一个字符串切片作为CORS允许列表,我们将在下一节中更深入地探讨。
跨源资源共享(CORS)
任何使用 API 优先应用程序的人都会遇到 CORS 的概念。这是现代浏览器的一个安全特性,确保一个域上的 Web 应用程序有权限请求不同源上的 API。它是通过执行所谓的预检请求来做到这一点的,这基本上就是一个普通的OPTIONS请求。这会返回信息,告诉我们的应用程序它被允许与 API 端点通信,以及它支持的方法和源。源包含客户端在origin头中发送的相同域名,或者它可能是一个通配符(*),这意味着所有源都被允许,如图 6.2所示。

图 6.2:CORS 流程(来源:Mozilla MDN,授权于 Creative Commons)
我们的中间件包装了 Gorilla Mux CORS 中间件,使我们更容易提供我们的 CORS 白名单域名(我们愿意响应请求的域名)以及这些域的所有 HTTP 方法。
JSON 中间件
另一个功能上需要的中间件是 JSON 中间件,用于强制执行我们为 API 应用程序设定的要求。JSON,即JavaScript 对象表示法,是一个开放标准文件格式,用于以键值对和数组的形式表示数据。
JSON 中间件使用 HTTP 头检查请求中发送的数据类型。它检查Content-Type头键,该键应该包含application/json值。
如果它找不到所需的值,那么中间件将检查Accept头的值,看看是否可以找到application/json值。一旦检查完成并且找不到它正在寻找的值,它就会回复说这不是我们工作的合适内容类型。我们还添加了该头到我们的ResponseWriter中,这样我们就可以确保我们告诉消费者我们只支持 JSON,并将它发送回他们。
以下代码片段显示了 JSON 中间件:
1 func JSONMiddleware(next http.Handler)
http.Handler {
2 return http.HandlerFunc(func(wr
http.ResponseWriter, req *http.Request) {
3 contentType :=
req.Header.Get("Content-Type")
4
5 if strings.TrimSpace(contentType) == "" {
6 var parseError error
7 contentType, _, parseError =
mime.ParseMediaType(contentType)
8 if parseError != nil {
9 JSONError(wr,
http.StatusBadRequest,
"Bad or no content-type header
found")
10 return
11 }
12 }
13
14 if contentType != "application/json" {
15 JSONError(wr,
http.StatusUnsupportedMediaType,
"Content-Type not
application/json")
16 return
17 }
18 // Tell the client we're talking JSON as
// well.
19 wr.Header().Add("Content-Type",
"application/json")
20 next.ServeHTTP(wr, req)
21 })
22 }
第 14 行检查 Content-Type 头是否包含application/json值;如果不是,它将作为响应的一部分返回错误(第 15 行)。
现在我们已经理解了中间件的概念,我们将开发一些中间件来简化我们的会话处理。
会话中间件
这个会话中间件不适合我们的api包,因为它与我们的会话处理功能紧密相关,如下面的代码片段所示:
1 session, err := cookieStore.Get(req,
"session-name")
2 if err != nil {
3 api.JSONError(wr,
http.StatusInternalServerError,
"Session Error")
4 return
5 }
6
7 userID, userIDOK :=
session.Values["userID"].(int64)
8 isAuthd, isAuthdOK :=
session.Values["userAuthenticated"].(bool)
9 if !userIDOK || !isAuthdOK {
10 api.JSONError(wr,
http.StatusInternalServerError,
"Session Error")
11 return
12 }
13
14 if !isAuthd || userID < 1 {
15 api.JSONError(wr, http.StatusForbidden,
"Bad Credentials")
16 return
17 }
18 ...
19 ctx := context.WithValue(req.Context(),
SessionKey, UserSession{
20 UserID: user.UserID,
21 })
22 h.ServeHTTP(wr, req.WithContext(ctx))
23
前面的中间件所做的是尝试从cookiestore(第 1 行)中检索我们的会话,这部分我们在上一章中已经讨论过。从返回的会话映射中,我们在第 7 行对两个值进行断言,将userID赋值为int64类型,以及布尔值userIDOK。
最后,如果一切检查无误,包括对用户数据库的检查,我们使用 context.WithValue()(第 19 行)提供一个带有我们的 sessionKey 的新上下文,这是我们包中唯一的类型。
然后,我们提供了一个简单的函数 userFromSession,我们的处理程序可以调用它来检查键类型的有效性和传入的会话数据。
在本节中,我们学习了中间件,并探讨了向应用程序添加不同类型的中间件。我们还探讨了 CORS 以及在开发 Web 应用程序时它的工作方式。在下一节中,我们将更详细地探讨 JSON,并使用模型来表示请求和响应的 JSON。
转换为和从 JSON
在本节中,我们将探讨从和向 JSON 获取和发送数据。我们还将探讨创建一个结构来处理数据以及如何进行 JSON 转换。
在使用 Golang 标准库处理 JSON 时,我们有两个主要选项——json.Marshal/Unmarshal 和 json.NewEncoder(io.Writer)/NewDecoder(io.Reader)。在本章中,我们将探讨使用 Encoder/Decoder 方法。使用这些方法的原因在于我们可以将一个函数链接到返回的编码器/解码器上,并轻松地调用 .Encode 和 .Decode 函数。这种方法的另一个好处是它使用了流式接口(即 io.Reader 和 io.Writer,用于表示可以从中读取或写入字节流的对象——Reader 和 Writer 接口被标准库中的许多实用程序和函数作为输入和输出接受),因此我们除了 Marshal(它使用预分配的字节)之外还有其他选择,这意味着我们在分配上更加高效,而且速度也更快。
定义请求模型
在我们的应用程序中流动的数据将被封装在一个结构体中。结构体是一个定义用来保存数据的结构。这使得在不同部分之间传输数据变得更加容易;如果你需要将 10 个不同的数据片段传输到应用程序的不同部分,通过调用一个带有 10 个参数的函数来做这件事是没有意义的,但如果它在一个结构体内部,该函数只需要接受一个该类型的参数。为了简单起见,保存数据的结构体也被称为模型,因为结构体内部定义的字段是根据它所表示的数据来建模的。
让我们看看以下代码中定义的用于封装登录数据(用户名和密码)的模型:
func handleLogin(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
...
}
如前述代码所示,loginRequest 模型使用 json:"username" 定义声明。这告诉标准库 JSON 转换器以下内容:
-
username– 转换为 JSON 字符串时使用的键名 -
omitempty– 如果值是空的,则该键将不会包含在 JSON 字符串中
更多信息可以在pkg.go.dev/encoding/json#Marshal找到,在那里您可以查看模型可以具有的不同配置,以从/转换为 JSON。
现在我们已经在函数内部定义了模型,我们想要使用它。handleLogin函数使用存在于json标准库中的Decode函数来解码数据,如下面的代码片段所示:
payload := loginRequest{}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
...
}
一旦成功转换,代码可以使用payload变量来访问作为 HTTP 请求一部分传递的值。
让我们看看代码定义的另一个模型,用于存储用户传递的锻炼集合信息。将数据转换为newSetRequest的方式与loginRequest相同,使用Decode函数:
1 func handleAddSet(db *sql.DB) http.HandlerFunc {
2 return http.HandlerFunc(func(wr
http.ResponseWriter,
req *http.Request) {
3
4 ...
5
6 type newSetRequest struct {
7 ExerciseName string
`json:"exercise_name,omitempty"`
8 Weight int `json:"weight,omitempty"`
9 }
10
11 payload := newSetRequest{}
12 if err := json.NewDecoder(req.Body)
.Decode(&payload); err != nil {
13 ...
14 return
15 }
16
17 ...
18 })
19 }
20
函数声明了一个新的结构体(第 6 行)称为newSetRequest,它将通过调用json.NewDecoder()函数(第 12 行)来填充,该函数将填充到payload(第 11 行)变量中。
在本节中,我们查看使用模型来托管用户传递的信息。在下一节中,我们将查看使用模型发送响应。
定义响应模型
在本节中,我们将查看如何使用模型来托管将作为响应发送给用户的信息。在第一章,构建数据库和模型中,我们学习了 sqlc 工具,它生成我们将要使用应用程序的不同数据库模型。我们将使用 sqlc 定义的相同数据库模型,将其转换为 JSON 字符串作为对用户的响应。json包库足够智能,可以将模型转换为 JSON 字符串。
让我们看看当用户创建一个新的锻炼计划时返回的响应——在这个例子中,是handleAddSet函数,如下所示:
func handleAddSet(db *sql.DB) http.HandlerFunc {
return http.HandlerFunc(func(wr http.ResponseWriter,
req *http.Request) {
...
set, err :=
querier.CreateDefaultSetForExercise(req.Context(),
store.CreateDefaultSetForExerciseParams{
WorkoutID: int64(workoutID),
ExerciseName: payload.ExerciseName,
Weight: int32(payload.Weight),
})
...
json.NewEncoder(wr).Encode(&set)
})
}
如您所见,函数调用了CreateDefaultSetForExercise函数,并使用set变量作为响应通过Encode函数发送给用户。返回的set变量是GowebappSet类型,其定义如下:
type GowebappSet struct {
SetID int64 `json:"set_id"`
WorkoutID int64 `json:"workout_id"`
ExerciseName string `json:"exercise_name"`
Weight int32 `json:"weight"`
Set1 int64 `json:"set1"`
Set2 int64 `json:"set2"`
Set3 int64 `json:"set3"`
}
当模型使用Encode转换并发送作为响应时,它将看起来是这样的:
{
"set_id": 1,
"workout_id": 1,
"exercise_name": "Barbell",
"weight": 700,
"set1": 0,
"set2": 0,
"set3": 0
}
在本节中,我们查看了一个由 sqlc 生成的模型,它不仅用于在数据库中读取/写入数据,而且还用于将响应作为 JSON 字符串发送给用户。在下一节中,我们将探讨需要添加到应用程序中的另一个重要功能,即错误处理,它将使用 JSON 进行报告。
使用 JSON 报告错误
在编写 Web 应用程序时,处理错误有许多方法。在我们的示例应用程序中,我们处理错误以通知用户他们的请求发生了什么。在向用户报告关于他们请求的错误时,请记住不要过多地暴露有关系统正在发生的事情的信息。以下是一些包含此类信息的错误消息示例,报告给用户:
-
与数据库存在连接错误
-
用户名和密码无法用于连接到数据库
-
用户名验证失败
-
密码无法转换为纯文本
上述 JSON 错误用例通常用于需要向前端提供更多信息以通知用户的情况。也可以使用包含错误代码的更简单的错误消息。
使用 JSONError
标准化错误信息与编写正确的代码一样重要,以确保应用程序的可维护性。同时,它使得在调试时其他人更容易阅读和理解你的代码。
在我们的示例应用程序中,我们将使用 JSON 来封装报告给用户的错误消息。这确保了错误格式和内容的一致性。以下代码片段可以在 internal/api/wrappers.go 文件中找到:
1 func JSONError(wr http.ResponseWriter,
errorCode int, errorMessages ...string) {
2 wr.WriteHeader(errorCode)
3 if len(errorMessages) > 1 {
4 json.NewEncoder(wr).Encode(struct {
5 Status string `json:"status,omitempty"`
6 Errors []string `json:"errors,omitempty"`
7 }{
8 Status: fmt.Sprintf("%d / %s", errorCode,
http.StatusText(errorCode)),
9 Errors: errorMessages,
10 })
11 return
12 }
13
14 json.NewEncoder(wr).Encode(struct {
15 Status string `json:"status,omitempty"`
16 Error string `json:"error,omitempty"`
17 }{
18 Status: fmt.Sprintf("%d / %s", errorCode,
http.StatusText(errorCode)),
19 Error: errorMessages[0],
20 })
21 }
JSONError 函数将使用传递的 errorCode 参数和 errorMessages(line 1) 作为报告给用户的 JSON 的一部分——例如,假设我们使用以下 cURL 命令调用 /login 端点,使用错误的凭据:
curl http://localhost:9002/login -H 'Content-Type: application/json' -X POST -d '{"username" : "user@user", "password" : "wrongpassword"}
你将得到以下 JSON 错误消息:
{"status":"403 / Forbidden","error":"Bad Credentials"}
错误是通过使用在编码 JSON 字符串时定义的结构体构建的(第 14 行)。
使用 JSONMessage
样本应用程序不仅使用 JSON 报告错误消息,还用于报告成功消息。让我们看看成功消息的输出。使用以下 cURL 命令登录:
curl http://localhost:9002/login -v -H 'Content-Type: application/json' -X POST -d '{"username" : "user@user", "password" : "password"}'
你将得到如下输出的结果:
* Trying ::1:9002...
* TCP_NODELAY set
* Connected to localhost (::1) port 9002 (#0)
> POST /login HTTP/1.1
> Host: localhost:9002
…
< Set-Cookie: session-name=MTY0NTM0OTI1OXxEdi1CQkFFQ180SUFBUkFCRUFBQVJQLUNBQUlHYzNSeWFXNW5EQk1BRVhWelpYSkJkWFJvWlc1MGFXTmhkR1ZrQkdKdmIyd0NBZ0FCQm5OMGNtbHVad3dJQUFaMWMyVnlTVVFGYVc1ME5qUUVBZ0FDfHMy75qzLVPoMZ3BbNY17qBWd_puOhl6jpgY-d29ULUV; Path=/; Expires=Sun, 20 Feb 2022 09:42:39 GMT; Max-Age=900; HttpOnly
…
* Connection #0 to host localhost left intact
使用 session-name 令牌,使用以下 cURL 命令创建一个锻炼:
curl http://localhost:9002/workout -H 'Content-Type: application/json' -X POST --cookie 'session-name=MTY0NTM0OTI1OXxEdi1CQkFFQ180SUFBUkFCRUFBQVJQLUNBQUlHYzNSeWFXNW 5EQk1BRVhWelpYSkJkWFJvWlc1MGFXTmhkR1ZrQkdKdmIyd0NBZ0FCQm5OM
GNtbHVad3dJQUFaMWMyVnlTVVFGYVc1ME5qUUVBZ0FDfHMy75qzLVPoMZ3BbNY 17qBWd_puOhl6jpgY-d29ULUV'
在成功创建锻炼后,你将看到如下 JSON 消息:
{"workout_id":3,"user_id":1,"start_date":"2022-02-20T09:29:25.406523Z"}
摘要
在本章中,我们探讨了创建和利用我们自己的中间件来处理会话以及在我们的 API 上强制使用 JSON,我们还重构了我们的项目以使用通用的包布局,以帮助我们分离关注点并为未来的工作和迭代做好准备。
在本章中,我们还介绍了一些辅助函数,包括两个用于通过 JSON 和 API 包创建和报告错误和消息给用户的函数,以及一个用于抽象我们的服务器处理,使其易于理解并为我们适应 CORS 做好准备的函数。
在下一章中,我们将更详细地讨论编写前端,并学习如何使用前端框架编写前端应用程序。
第三部分:使用 Vue 和 Go 的单页应用程序
在 第三部分 中,我们在深入了解如何将 Vue 与 Go 结合以及探索不同的前端技术来为我们的示例应用程序提供动力之前,介绍了前端框架。我们将探讨实现 跨源资源共享 (CORS) 以及在我们的应用程序中使用 JWT 进行会话,以简化并保护我们的应用程序免受恶意行为者的影响!
本部分包括以下章节:
-
第七章**,前端框架
-
第八章**,前端库
-
第九章**,Tailwind,中间件和 CORS
-
第十章**,会话管理
第七章:前端框架
在本章中,我们将从高层次的角度审视现代网络开发者可用的当前 JavaScript 框架。我们将比较一些流行的框架,如 Svelte、React 和 Vue,然后在 Vue 中创建一个简单的应用,并以使用流行的 Vue Router 添加导航结束。这将为我们后来从第六章,转向 API-First时与 API 服务器通信奠定基础。
本章完成后,我们将涵盖以下内容:
-
理解服务器端渲染与单页应用之间的区别
-
查看不同的前端框架
-
使用 Vue 框架创建应用
-
理解 Vue 框架中的路由
本章为前端领域铺平了道路。在本章和下一章中,我们将学习前端开发的不同部分。
技术要求
本章中使用的所有源代码都可以从github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter07中检出。
请确保您已按照 Node.js 文档中的说明在本地机器上安装了所有必要的工具:docs.npmjs.com/downloading-and-installing-node-js-and-npm。
服务器端渲染与单页应用
在第四章,服务和嵌入 HTML 内容中,我们将应用作为服务器端渲染应用创建。这意味着所有内容、资产,包括 HTML,都是在后端生成的,并在每个页面请求时发送。这并没有什么问题;我们的出版商 Packt 在其www.packtpub.com/网站上使用服务器端渲染(SSR)。作为一项技术,SSR 被 WordPress 和其他许多内容变化较少且可能交互性较低的网站所使用。
我们将为我们的应用使用的 SSR(服务器端渲染)的替代方案是客户端渲染(CSR)。CSR 通过客户端以 JavaScript 和其他资源的包的形式获取应用,动态执行 JavaScript 和应用,并将绑定到一个接管页面渲染的元素上。应用在浏览器中动态创建和渲染每个路由。这一切都是在不需要重新加载包或内容的情况下完成的。
通过转向客户端渲染,它通过允许应用操作文档模型、通过 API 获取额外的内容和数据以及通常在不需要不断重新加载页面的情况下更接近用户可能从桌面应用中期望的性能来提高应用的用户交互性和响应性。
当我们谈论响应性时,我们描述的是应用程序状态变化自动反映在文档对象模型(DOM)中的情况。这是我们将要探索的所有框架的关键属性,包括 React、Vue 和 Svelte。
介绍 React、Vue 以及更多
如果 JavaScript 社区喜欢做的事情有一件,那就是创建新的框架!
我们将探索和对比一些最受欢迎的框架,并查看它们共有的部分以及主要的不同点。
React
React 是可用的最受欢迎的 JavaScript 库之一。它由 Meta(前身为 Facebook)创建并维护,并且受到了 Facebook 内部用于创建 PHP 组件的前任的极大启发。
React 使用 .jsx 和 .vue 文件,并将它们构建成一个最终包,可以部署为静态文件。我们将在后面的章节中探讨这一点。

图 7.1:现代 JavaScript SPA 构建过程
React 是创建应用程序的一个非常受欢迎的选择,其一个优势是在构建应用程序时有许多不同的选项可供选择,例如 Redux、Flux、BrowserRouter 或 React Router。这种灵活性很好,但可能会导致冲突和关于“唯一正确的方法”的强烈观点。React 团队通过不断强调 React 是一个库而不是框架来避免这个问题,因此选择应用程序的组件取决于个人。
React 与其他框架类似,它有一个完整的生命周期模型,可以在运行时“挂钩”以覆盖默认值(例如,render 和 shouldComponentUpdate)。
Svelte
Svelte 处于一个有趣的中间位置,被包括作为 React 和 Vue 这两个重量级选手的替代品。Svelte 采用将更多内容推入编译步骤的方法,消除了需要像比较虚拟 DOM 来将代码转换为纯 JavaScript 这样的技术。这种方法意味着浏览器需要做的工作更少,但它仍然具有与 React 和 Vue 类似的构建过程来构建包。Svelte 提供了自己的首选路由器,称为 SvelteKit,但存在替代方案,Svelte 可以成为其他产品的轻量级替代品。从更成熟的参与者来看,Svelte 是一个相当新的项目,并且背后没有那么多资源,但它对于小型项目来说仍然是可行的。
Vue
我们将要介绍的最后一个框架是 Vue,这是我们用于构建前端应用程序的首选框架。
对于我来说,最初的最大吸引力是 Vue 的上一个版本(版本 2)可以通过内容分发网络(CDN)直接加载和运行,这使得在 2016 年首次发布时进行实验和原型设计变得极其容易。
Vue 提供了一种非常熟悉的语法,这使得它很容易学习——它将表示与逻辑和样式分离,非常轻量级,并且它使用了单文件组件(SFCs)的概念。
SFC 的概念使得构建简单、范围有限的组件变得极其容易,这些组件可以在项目之间重复使用,而无需学习 React 使用的“不太像 JavaScript”的 JSX。
以下代码是一个简单的组件,它使用 Options API 显示问候语。当 Vue 首次发布时,它默认使用 Options API,但在后续版本中,它已经转向包括一个更新的 Composition API,我们将在后面探讨:
<template>
<div>
<Thing @click="greetLog" />
<p class="greeting">{{ greeting }}</p>
</div>
</template>
<script>
import Thing from '@/components/thing.vue';
export default {
name: 'Greeter',
components: ['Thing'],
props:{},
mounted(){},
methods: {
greetLog() { console.log('Greeter') };
},
data() {
return {
greeting: 'Hello World!'
}
}
}
</script>
<style scoped>
.greeting {
color: red;
font-weight: bold;
}
</style>
SFC Greeter.vue 的示例
如前述代码块所示,Vue 的 SFC 设计方法有三个部分:HTML、JavaScript 和样式(通常是 CSS,通常是“scoped”)。这意味着你可以将 <template> 的 HTML 风格与小的 Vue 特定添加(如 @click="functionName")结合起来,轻松创建我们的组件。这里使用的 @click 注解,看起来接近 HTML,是 Vue 用于扩展并将 HTML 事件绑定到我们的对象的语法——在这种情况下,替换了原生的 onClick 属性。
<script> 中包含的实例包括一个名称;props,用于从父组件提供属性给组件;mounted(),当组件首次添加到 DOM 时调用的函数;components,即被导入供组件使用的组件;其他各种方法;最后是 data() 对象,它可以保存我们组件的状态。
SFC 的最后一部分是 <style> 部分——我们在这里可以指定非 CSS 语言。例如,如果我们想使用 SCSS 而不是 CSS,我们可以使用 lang="scss"。我们还可以添加 scoped 关键字,这意味着 Vue 将使用名称混淆来确保我们的 CSS 样式仅限于这个组件实例。
使用 Vue 的另一个好处是它对构建工具采取的具有意见的方法(更倾向于创建 Vite,它利用速度极快的 esbuild 来将包构建时间缩短到毫秒级,与较慢的 React 相比),组件布局,以及路由器(Vue Router),我们将在后面的章节中探讨。Vue 的具有意见的性质与 Golang 本身的具有意见的性质很好地结合在一起,这有助于消除很多关于选择哪种方法和组件来构建你的应用的争论,确保当你引入更多团队成员并将你的成功的全栈应用交付出去时,你可以放心地知道另一个 Vue 开发者不会与你争论你是如何做到的,也不会争论选择的技术——主要是因为他们也会选择同样的技术!
到目前为止,在本节中,我们已经了解了 Vue 框架是什么。在下一节中,我们将通过创建一些简单的应用来学习 Vue 框架。
创建 Vue 应用
在上一节中,我们讨论了不同的前端框架,因此在本节中,我们将尝试使用 Vue 构建我们的前端。在本节中,我们将查看如何在 Vue 中编写我们的 UI,并讨论如何将登录页面迁移到 Vue。本节不会教你如何使用 Vue,而是将查看我们使用 Vue 编写示例应用程序前端组件的方式。
应用程序和组件
当使用 Vue 编写软件时,应用程序将通过创建应用程序实例来启动。这个实例是我们基于 Vue 的应用程序中的主要对象。一旦我们有一个实例,我们就可以开始使用组件。组件是可重用的 UI 部件,包含三个部分——模板(类似于 HTML)、样式和 JavaScript。通常,在设计前端时,我们会考虑 HTML 元素——div、href 等——但现在我们需要考虑包含所有不同部分的组件。图 7.2显示了使用 Vue 重写的登录页面示例。

图 7.2:基于 Vue 的登录
Vue 内部应用程序的概念可以被视为一个包含不同组件的自隔离容器,这些组件可以共享数据。任何网页都可以包含多个应用程序,显示不同类型的数据,即使它们是隔离的,在需要时也可以共享数据。
使用 Vue 的登录页面
在本节中,我们将查看如何直接使用登录页面,而不将其转换为组件,并作为浏览器渲染的 Vue 应用程序使用。我们首先需要通过运行以下命令安装依赖项:
npm install
这将安装所有不同的依赖项,包括我们将用于提供登录页面的http-server模块。通过运行以下命令启动服务器,确保你位于chapter7/login目录中:
npm run start
你将看到图 7.3中所示的输出:

图 7.3:使用 http-server 提供服务
打开你的浏览器,在地址栏中输入 http://127.0.0.1:3000/login.xhtml,你将看到登录页面。
让我们深入代码,看看它是如何协同工作的。以下login.xhtml内部的代码片段显示了应用程序初始化代码:
<script type="module">
import {createApp} from 'vue'
const app = createApp({
data() {
return {
loginText: 'Login to your account',
...
}
},
methods: {
handleSubmit: function () {
...
}
}
}).mount('#app')
</script>
代码从 Vue 库中导入createApp,并使用它创建一个包含页面内使用的data()和methods的应用程序。data()块声明了页面内将使用的变量,而methods包含函数。应用程序被挂载到具有 ID“app”的元素上,在这种情况下,是具有id=app的<div>。
以下代码片段显示了使用数据的页面部分:
<body class="bg-gray-900">
...
<p class="text-xs text-gray-50">{{ loginText
}}</p>
...
<p class="text-xs text-gray-50">
{{ emailText }}</p>
...
<p class="text-xs font-bold text-white">
{{ passwordText }}</p>
...
</body>
大括号内的变量({{}})将在初始化应用程序时填充之前定义的数据。
以下代码片段显示了使用handleSubmit函数的部分页面:
<body class="bg-gray-900">
...
<button @click="handleSubmit"
class="px-4 pt-2 pb-2.5 w-full
rounded-lg bg-red-500
hover:bg-red-600">
...
</body>
在按钮元素上点击@click将触发在创建 Vue 应用程序对象时定义的函数,该函数将把用户名字段中的数据写入控制台日志。
使用 Vite
回到图 7.1,构建过程的一部分是打包器。在本节中,我们将查看 Vite,它是 Vue 的打包器。什么是打包器?它是一个构建工具,将你所有的不同资产(HTML、CSS 等)合并到一个文件中,使其易于分发。
在上一节中,我们链接到了一个由 CDN 托管的 Vue 运行时版本。在本节中,我们将使用 Vite 来构建我们的应用程序并生成我们的打包代码。
Vite - 法语中意为“快速”,是由 Vue 本身的团队构建的,旨在提供更快的开发体验,具有极快的热重载,并将其与一个强大的构建阶段相结合,该阶段将转换、压缩和打包你的代码到优化的静态资产,以便部署。参考图 7.1以查看构建 SPAs 所使用的所有阶段。
在本节中,我们将查看如何将登录页面编写为组件,并使用它作为由浏览器渲染的 Vue 应用程序。代码可以在chapter7/npmvue文件夹中查看。
打开你的终端并运行以下命令:
npm install
npm run dev
一旦服务器启动并运行,你将看到图 7.4所示的输出。

图 7.4:Vite 服务器输出
打开浏览器,在地址栏中输入http://localhost:3000以访问登录页面。让我们进一步调查并查看代码的结构。我们将从以下片段中查看index.xhtml页面:
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<div id="app"></div>
<script type="module" src="img/main.js"></script>
</body>
</html>
前面的index.xhtml引用了main.js脚本,这是我们注入 Vue 初始化代码的方式。
<div..>声明是当在浏览器中渲染时应用程序将被挂载的位置,页面还包括在src/main.js中找到的脚本。
main.js包含 Vue 应用程序初始化代码,如下所示:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
createApp将使用从App.vue导入的App对象创建一个应用程序,这将是我们的应用程序的起始组件。与 Vue 相关的代码通常存储在具有.vue扩展名的文件中。App.vue文件充当应用程序容器,它承载它将使用的组件。在这种情况下,它将使用Login组件,如下面的片段所示:
<script setup>
import Login from './components/Login.vue'
</script>
<template>
<Login />
</template>
<script setup>标签被称为组合式 API,它是一组允许 Vue 组件被导入的 API。在我们的例子中,我们是从Login.vue文件中导入组件。
代码将Login.Vue文件作为组件导入,并在<template>块中使用它。查看Login.vue文件,你会看到它包含创建登录页面所需的 HTML 元素。
Login.vue片段可以在以下代码块中看到:
<script>
export default {
data() {
return {
loginText: 'Login to your account',
...
}
},
methods: {
handleSubmit: function () {
...
}
}
}
</script>
<style>
@import "../assets/minified.css";
</style>
<template>
...
<button @click="handleSubmit"
class="px-4 pt-2 pb-2.5 w-full rounded-lg
bg-red-500 hover:bg-red-600">
...
</template>
之前示例中按钮所用的类是在assets文件夹内的minified.css文件中声明的。
我们已经学习了如何使用 Vue 框架创建应用程序,并将所有不同的组件连接在一起。我们还探讨了如何使用 Vite 工具编写基于 Vue 的应用程序。在下一节中,我们将探讨如何将请求路由到不同的 Vue 组件。
使用 Vue Router 进行导航
在本节中,我们将探讨 Vue Router 并学习如何使用它。Vue Router 有助于在设计和单页应用程序(SPA)时构建前端代码。SPA 是一种将用户呈现为单个 HTML 页面的 Web 应用程序,由于 HTML 页面内的内容更新而不需要刷新页面,因此它更加响应。SPA 需要使用路由器,当从后端更新数据时,路由器将路由到不同的端点。
使用路由器允许更容易地在 URL 路径和组件之间进行映射,从而模拟页面导航。可以使用 Vue Router 配置两种类型的路由——动态路由和静态路由。当 URL 路径基于某种数据动态时,使用动态路由。例如,在/users/:id中,路径中的id将被填充为一个值,例如/users/johnny或/users/acme。静态路由是不包含任何动态数据的路由,例如/users或/orders。
在本节中,我们将探讨静态路由。本节的示例可以在chapter7/router文件夹中找到。从router文件夹运行以下命令以运行示例应用程序:
npm install
npm run server
命令将在端口8080上运行一个服务器。打开您的浏览器,在地址栏中输入http://localhost:8080。您将看到图 7**.5所示的输出:

图 7.5:路由示例应用程序
App.vue文件包含 Vue Router 信息,如下所示:
<template>
<div id="routerdiv">
<table>
...
<router-link :to="{ name: 'Home'}">Home
</router-link>
...
<router-link :to="{ name: 'Login'}">Login
</router-link>
...
</table>
<router-view></router-view>
</div>
</template>
之前定义的router-link路由位于router/index.js中,如下所示:
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
];
<router-link/>标签定义了应用程序的路由配置,在我们的例子中,它指向位于router文件夹下的index.js文件内声明的Home和Login组件,如下所示:
import Vue from 'vue';
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue';
import Login from "../views/Login.vue";
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/login',
name: 'Login',
component: Login
},
];
const router = createRouter({
history: createWebHashHistory(),
base: process.env.BASE_URL,
routes
})
export default router
每个定义的路由都映射到其相应的组件,这些组件是Home和Login组件,它们位于views文件夹内。
登录页面的路由
我们知道/login路径映射到Login组件,这与我们在上一节中查看的相同组件,即使用 Vue 的登录页面。在路由示例中的区别在于脚本的定义方式,如下所示:
<template>
...
</template>
<script type="module">
export default {
data() {
return {
loginText: 'Login to your account',
emailText: 'Email Address',
passwordText: 'Password',
username: 'enter username',
password: 'enter password',
};
},
methods: {
handleSubmit: function () {
console.log(this.$data.username)
}
}
};
</script>
与上一节不同,Vue 初始化代码已移动到main.js中,如下所示:
...
const myApp = createApp(App)
myApp.use(router)
myApp.mount('#app')
在本节中,我们探讨了如何通过使用 Vue Router 重构应用程序以作为 SPA 运行。
摘要
在本章中,我们学习了 Vue 以及如何构建我们的前端结构,使其易于过渡到组件和应用。我们探讨了不同的前端框架,并讨论了它们各自提供的内容。
我们在编写基于 Vue 的网页时,研究了组件和应用是如何协同工作的。我们通过将我们创建的简单 HTML 页面迁移到基于 Vue 的应用程序来测试我们所学的知识。最后,我们学习了 Vue Router 以及如何使用它来简化 SPA 中不同部分的导航。
在吸收了所有这些知识之后,在下一章中,我们将探讨如何将我们的应用编写成一个基于 Vue 的应用程序,该程序将与我们所构建的 REST API 进行通信。
第八章:前端库
在上一章中,我们探讨了构建前端应用程序的不同框架。在本章中,我们将探讨对构建网络应用程序有用的不同前端库。前端库是预定义的函数和类,可以在构建前端应用程序时提供功能,否则我们可能需要自己构建和开发。在本章中,我们将探讨以下库:
-
Vuetify -
Buefy -
Vuelidate -
Cleave.js
完成本章后,你将探索以下内容:
-
使用
Vuelidate验证数据 -
使用
Cleave.js进行更好的输入处理 -
使用
Vuetify处理不同的 UI 组件
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter08中检出。
确保你已经按照以下链接中提供的 node.js 文档说明在你的本地机器上安装了所有必要的工具:docs.npmjs.com/downloading-and-installing-node-js-and-npm。
本章中,将有使用 codesandbox.io 和 jsfiddle.net 分享的示例代码,这将使你更容易进行实验。
让我们通过下一节了解 Vuetify 来开始我们的旅程。
理解 Vuetify
在第七章“前端框架”中,我们学习了 Vue 框架,这是一个丰富的前端框架,它允许前端代码易于扩展和维护。Vuetify (vuetifyjs.com) 提供了许多用户界面组件,可以直接用于应用程序。该框架还允许开发者根据需要定制用户界面。
在本节中,我们将了解 Vuetify,这是一个基于 Vue 构建的 Material 设计框架。Material 是由 Google 在其网络应用和 Android 应用中推广的设计语言 – 你可以在m3.material.io/上了解更多信息 – 并且是一个非常受欢迎的选择。
设置 Vuetify
我们将查看 chapter08/vuetify/components 目录内的示例代码。示例代码演示了如何使用 Vuetify 框架。在运行示例代码之前,请确保你从 chapter08/vuetify/components 目录中运行以下命令来安装所有必要的组件:
npm install
安装完成后,使用以下命令运行示例代码:
npx vue-cli-service serve
一旦服务器启动并运行,你将得到如图 8.1 所示的输出:

图 8.1:运行 npx 的输出
您可以使用输出中指定的 URL 访问应用程序——例如,http://localhost:8080。图 8.2显示了应用程序的输出:

图 8.2:示例应用的输出
示例应用程序显示了 Vuetify 内部可用的不同组件。如您所见,除了单选按钮组和颜色选择器之外,还有许多其他组件。
在下一节中,我们将探讨如何在示例应用程序中使用 Vuetify 以及如何连接各个部分。
使用 UI 组件
Vuetify 提供了许多组件,但在这个部分,我们将只讨论其中的一些,以便了解如何使用它们。示例代码使用了颜色选择器、按钮、徽章等组件。
图 8.3显示了示例的目录结构。所有源文件都位于src/文件夹中:

图 8.3:Vuetify 示例应用的目录结构
初始化 Vue 和 Vuetify 的main.js宿主代码如下片段所示:
import Vue from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify';
Vue.config.productionTip = false
new Vue({
vuetify,
render: h => h(App)
}).$mount('#app')
代码看起来像任何其他基于 Vue 的应用程序,除了它添加了从plugins/vuetify目录导入的 Vuetify 框架,如本片段所示:
import Vue from 'vue';
import Vuetify from 'vuetify/lib/framework';
Vue.use(Vuetify);
export default new Vuetify({});
Vuetify 通过Vue.use()函数调用作为插件初始化,并导出以供代码的其他部分使用。
现在初始化已完成,让我们看看示例是如何使用 Vuetify 组件的。这里从App.vue中的代码片段显示了示例代码如何使用 Vuetify 的颜色选择器组件:
<template>
<v-app>
<v-container>
...
<v-row>
<v-col>
Color Picker
</v-col>
<v-col>
<v-color-picker/>
</v-col>
</v-row>
</v-container>
</v-app>
</template>
在片段中可以看到的标签——<v-row>、<v-col>、<v-container>等——都是 Vuetify 组件。组件可以通过可用的属性进行配置;例如,如果我们查看<v-row>的组件文档(vuetifyjs.com/en/api/v-row/#props),我们可以看到我们可以设置不同的参数,例如对齐。
在本节中,我们了解了 Vuetify 及其组件的使用方法,以及如何将它们连接起来以在 Vue 应用程序中使用。在下一节中,我们将探讨与 Vuetify 相比更轻量级的不同用户界面库。下一节我们将从 Buefy 开始。
理解 Buefy
Buefy 是建立在 Bulma 之上的另一个用户界面框架。Bulma(bulma.io/)是一个开源 CSS 项目,为 HTML 元素提供不同类型的样式;CSS 文件可以在以下链接查看:github.com/jgthms/bulma/blob/master/css/bulma.css。
让我们快速查看一个使用 Bulma CSS 的网页示例。这将让我们更好地了解 Bulma 是什么,以及 Buefy 是如何使用它的。
Bulma 示例
在您的浏览器中打开示例文件chapter08/bulma/bulma_sample.xhtml,HTML 页面将看起来像图 8.4:

图 8.4:Bulma 示例页面
以下代码片段显示了网页中使用的 Bulma CSS 文件:
<head>
...
<link rel="stylesheet" href=
"https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/
bulma.min.css">
</head>
网页使用 Bulma CSS 样式化的不同 HTML 元素标签,如下面的代码片段所示:
<section class="hero is-medium is-primary">
<div class="hero-body">
<div class="container">
<div class="columns">
...
</div>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="columns">
<div class="column is-8-desktop
is-offset-2-desktop">
<div class="content">
...
</div>
</div>
</div>
</div>
</section>
现在我们已经了解了 Bulma 是什么以及如何将其用于网页,我们将在下一节中查看如何设置 Buefy。
设置 Buefy
我们将查看位于chapter8/buefy目录中的 Buefy 示例。请确保您位于该目录中,并运行以下命令:
npm install
npx vue-cli-service serve
在您的浏览器中打开服务器,您将看到类似于图 8.5的输出:

图 8.5:Buefy 示例输出
UI 组件
网页显示了 Buefy 中可用的不同组件,例如滑块、带下拉菜单的可点击按钮和面包屑。
初始化 Buefy 与初始化任何其他 Vue 插件相同。它看起来与我们在上一节中查看 Vuetify 时看到的一样。代码将通过使用Vue.use(Buefy)初始化 Vue:
import Vue from 'vue'
import App from './App.vue'
import Buefy from "buefy";
Vue.use(Buefy);
new Vue({
render: h => h(App)
}).$mount('#app')
我们在示例应用程序中使用的一个组件是carousel,它显示一个类似于幻灯片的用户界面。要创建carousel,只需几行代码,如下面的代码片段所示,使用<b-carousel>标签:
<!--example from https://buefy.org/documentation-->
<template>
<section>
<div class="container">
<b-carousel>
<b-carousel-item v-for="(carousel, i) in carousels"
:key="i">
<section :class="`hero is-medium
is-${carousel.color}`">
<div class="hero-body has-text-centered">
<h1 class="title">{{ carousel.text }}</h1>
</div>
</section>
</b-carousel-item>
</b-carousel>
</div>
...
</section>
</template>
类似于carousel,Buefy 提供了许多不同的预构建组件,可以帮助设计复杂用户界面。
在下一节中,我们将探讨如何使用 Vuelidate 库来验证我们在用户界面中捕获和展示的数据,以确保我们正确地解释了客户的数据。
使用 Vuelidate 验证数据输入
如果您的应用程序有任何交互功能,它很可能会处理用户输入的数据,这意味着您必须检查用户提供的输入是否有效。
输入验证库可以用来确保用户只输入有效数据,并在数据接收时立即提供反馈。这意味着我们在用户点击输入字段时就开始验证!
我们将探索前端中的 HTML 表单验证以及输入和值验证之间的区别。同样重要的是要注意,无论前端验证如何,它都不能替代后端和 API 端点的验证。我们在前端的目标是防止用户犯错误;然而,您永远无法阻止坏人向您的应用程序提交不良数据。
我们可以通过两个角度来观察前端验证,因为市面上有无数种解决方案,但我们将对比两种选项并展示一个可行的解决方案——第一个是验证输入,另一个是验证值。
如果我们只想验证输入,我们可以使用 vee-validate 库,它通过让你在代码的 <template> 中编写规则来实现。例如,请看以下内容:
<script>
Vue.use(VeeValidate);
var app = new Vue({
el: '#app',
data: {
email: '',
},
methods: {
onSubmit: function(scope) {
this.errors.clear(scope);
this.$validator.validateAll(scope);
}
}
});
</script>
<template>
<div>
<form v-on:submit.prevent="onSubmit('scope')">
<div>
<div v-for="error in errors.all('scope')">
{{error}}
</div>
</div>
<div>
<label>Email Address</label>
<input type="text" v-model="email"
name="Email Address" v-validate data-scope="scope"
data-rules="required|min:6|email">
</div>
<div>
<button type="submit">
Send
</button>
</div>
</form>
<div class="debug">
email: {{email}}<br>
</div>
</div>
</template>
这种内联验证——在我们提交数据时执行 ValidateAll()——将允许我们使用预定义的规则来验证数据内容,例如字段是必需的、其最小长度,或者它必须是一个有效的电子邮件 ID 等。如果输入了无效数据,我们可以遍历错误并将它们展示给用户:

图 8.6:验证错误信息
你可以在 JS Playground 网站的 JSFiddle 上看到这一点:jsfiddle.net/vteudms5/。
这对于简单的验证很有用,但当我们想要对值和值的集合添加额外的逻辑,而不仅仅是单个输入时,这就是像 Vuelidate 这样的库变得强大的地方。
使用 Vuelidate,你会注意到验证与我们所编写的模板代码解耦,这与 vee-validate 示例中的内联验证不同。这允许我们针对数据模型编写规则,而不是针对模板中的输入。
在 Vuelidate 中,验证结果会生成一个名为 this.$v 的验证对象,我们可以用它来验证我们的模型状态。让我们重新构建之前的示例,以展示我们将如何使用 Vuelidate 验证数据——这个示例可以在 jsfiddle.net/34gr7vq0/3/ 找到:
<script>
Vue.use(window.vuelidate.default)
const { required, minLength,email } = window.validators
new Vue({
el: "#app",
data: {
text: ''
},
validations: {
text: {
required,
email,
minLength: minLength(2)
}
},
methods: {
status(validation) {
return {
error: validation.$error,
dirty: validation.$dirty
}
}
}
})
</script>
<template>
<div>
<form>
<div>
<label>Email Address</label>
<input v-model="$v.text.$model"
:class="status($v.text)">
<pre>{{ $v }}</pre>
<div>
</form>
</div>
</template>
生成的输出显示了 $v 对象。当你在框中输入时,required、email 和 minLength 字段会被触发。在我们的例子中,当我们输入 nick@bar.com 时,字段值会改变:

图 8.7:JSFiddle 示例的浏览器输出示意图
虽然在风格上与 vee-validate 的实现相似,但通过利用 $v 对象概念并允许其为验证的来源,我们可以将其连接到多个表单的额外输入,并验证整个集合。例如,如果我们有多个字段,如 formA 和 formB 中的名称、电子邮件、用户和标签,我们就能创建如下验证:
...
validations: {
name: { alpha },
email: { required, email }
users: {
minLength: minLength(2)
},
tags: {
maxLength: maxLength(5)
},
formA: ['name', 'email'],
formB: ['users', 'tags']
}
Vuelidate 有一个庞大的验证器集合可供导入。这使我们能够访问诸如条件性必需字段;长度验证器;电子邮件、字母/字母数字、正则表达式、十进制、整数和 URL 选项等验证器,以及通过导入 validators 库可以访问的更多验证器:
import { required, maxLength, email } from '@vuelidate/validators'
完整列表可在 Vuelidate 网站上找到,网址为vuelidate-next.netlify.app/validators.xhtml。
使用 Cleave.JS 进行更好的输入处理
正如我们刚才看到的,以正确的形状和形式从用户那里获取数据可能是一个挑战 - 不论是 YYYY/MM 格式的日期,带有前缀的电话号码,还是其他更结构化的输入类型。
我们之前已经讨论了验证,但你可以通过提供视觉线索和反馈来进一步帮助用户,在他们输入时防止他们因为验证错误而到达终点 - 例如,那些由流行的信用卡和在线支付处理器提供的库。Stripe 在帮助用户正确输入信用卡信息方面做得很好,但对我们预算有限的人来说,我们可以使用 Cleave.js 来获得类似体验。

图 8.7:信用卡验证(图片来自 https://nosir.github.io/cleave.js/)
令人沮丧的是,Vue 不是作为一等公民得到支持,但我们没有理由不能设置这个指令,该指令可在codesandbox.io这里找到 - bit.ly/3Ntvv27。图 8**.8显示了bit.ly/3Ntvv27中验证的工作方式:

图 8.8:codesandbox.io 上我们的 Cleave.js 示例示例
在我的硬编码示例中(CSS 留作你的练习!)它并不那么漂亮,但关键部分是从沙盒示例中如何通过以下方式对custom-input进行重载,使用我们的cleave指令:
<template>
<div id="app">
<div>
<custom-input
v-cleave="{ creditCard: true,
onCreditCardTypeChanged: cardChanged, }"
v-model="ccNumber" />
</div>
<pre>
{{ ccNumber }}
{{ cardType }}
</pre>
</template>
在未来,看到 Cleave.js 为 Vue 提供第一方实现将非常棒,但在此之前,存在许多 npm 包可以跳过我们的示例设置,并提供类似的效果,这将使我们能够为用户提供美好的体验。
要跟踪 Cleave.js 官方支持的状态,你可以查看github.com/nosir/cleave.js/blob/master/doc/vue.md。
使用 Cleave.js,我们已经到达了本章的结尾。
摘要
在本章中,我们学习了几个前端库和工具,帮助我们更快地迭代代码和设计,以便为我们的产品构建前端用户界面。
我们已经探讨了使用 Vuetify 来创建可定制的用户界面,并探讨了 Buefy,它提供了一个庞大的 UI 组件集合,使我们能够快速构建我们的应用程序。
然后,我们通过介绍和对比使用 Vuelidate 和 VeeValidate 进行输入和值验证,以及最后解释如何使用 Cleave.js 创建更智能的界面来帮助用户理解我们的应用程序期望的内容,结束了本章。
在下一章中,我们将探讨那些将前端和后端连接起来的中间件组件。
第九章:Tailwind、中间件和 CORS
在本章中,我们将基于之前介绍的前端原则,引入 Tailwind CSS,探讨我们如何通过前端应用中的 API 消费后端服务,了解我们如何利用中间件来转换我们的 JSON 请求,以及如何提供一个具有用户登录功能的单页应用(SPA)。
在本章中,我们将涵盖以下主题:
-
使用 Tailwind CSS 框架创建和设计前端应用程序
-
了解如何使用 Vite CLI 创建新的 Vue 应用程序
-
配置我们的 Go 服务以支持 CORS
-
设置 JavaScript Axios 库
-
创建中间件以管理前端和后端之间的 JSON 格式化
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/Chapter09上查看。
介绍 Tailwind
在上一章中,我们查看了一些不同的前端框架,以帮助我们更快地开发,但我们一直忽略了一个现代网络生态系统中显而易见的“大象”——Tailwind CSS。
像 Buefy 和 Vuetify 这样的框架有一个主要缺点。由于对更多和更多功能的日益增长的需求,增长和使用,它们成为了自己成功的受害者,最终变得过于庞大,使我们对自己的组件样式控制减少。
了解像 Buefy 这样的框架变得越来越具有挑战性。开发者必须学习数百个类和组件,然后可能只是为了对上游社区没有预想的微小样式调整而重新构建它们。
与其他框架不同,Tailwind 是一个 CSS 框架,它不预先内置类以添加到 HTML 标签中。相反,它采用不同的方法。通过从样式表中移除所有默认样式并使用基于实用性的类来组合和构建您的应用程序,它提供了更低的控制级别。这些基于实用性的类提供了直接操作某些 CSS 属性的方法,例如文本大小、边距、间距、填充和颜色,以及针对移动、桌面和其他视口的交互行为。通过应用不同的 Tailwind 修饰符,我们可以在确保一致样式的同时,对元素的最终外观进行细粒度控制,并在需要构建微小变化时提供简单的解决方案。这真有助于构建我们的 Vue 组件。

图 9.1:按钮示例
创建蓝色按钮的快速示例如下:
<button type="button" class="
inline-block px-6 py-2.5 bg-blue-600
text-white font-medium text-lg leading-tight
rounded shadow-md
hover:bg-blue-700 hover:shadow-lg
focus:bg-blue-700 focus:shadow-lg
focus:outline-none focus:ring-0
active:bg-blue-800 active:shadow-lg
transition duration-150 ease-in-out
">Button</button>
你可能会对自己说,“哇,对于一个按钮来说,CSS 真的很多,”但当你考虑到 Vue 如何帮助我们构建可重用的 button、link、image、div 或 paragraph 时,你会这样想。你可以查看官方文档tailwindcss.com/docs/utility-first以深入了解“实用优先”CSS 的概念以及各个类的作用。
创建一个新的 Tailwind 和 Vite 项目
要创建我们的项目,我们首先将使用 Vite CLI 生成它。这将给我们带来熟悉的“Hello World”输出,如下所示:

图 9.2:Hello World 网页输出
让我们使用以下命令使用 Vite 创建一个新的 Vue 项目:
npm create vite@latest
对于每个提出的问题,请输入此处显示的信息:
✔ Project name: … vue-frontend
✔ Select a framework: › vue
✔ Select a variant: › vue
Scaffolding project in /Users/.../vue-frontend...
Done. Now run:
cd vue-frontend
npm install
npm run dev
$ npm install
$ npm run dev
> vue-frontend@0.0.0 dev
> vite
vite v2.9.12 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 332ms.
访问 http://localhost:3000 现在将显示图 9.2 的截图。我们的项目启用了“热重载”或“实时重载”,所以当你更改项目代码时,你将在保存文件时看到浏览器中的设计更新。
之前版本的 Tailwind CSS 有一点点以生成大型样式表(3 到 15 MB!)和减慢构建时间为特点。
在 Tailwind CSS 版本 2 时代的末期,团队引入了一个新的即时编译器(JIT),它会自动生成仅用于样式化设计的必要 CSS。最初这作为一个可选插件提供,但通过减少冗余,带来了巨大的改进,并且有了 JIT,开发中的 CSS 与最终代码相同,这意味着最终构建不需要对 CSS 进行后处理。自从 Tailwind CSS 版本 3 及以上,JIT 编译器在安装 Tailwind CSS 时默认启用,所以我们不需要担心在配置文件中更改任何东西,除了我们需要的布局项目之外。
现在,我们将向我们的项目添加 Tailwind CSS 并对来自 Vue 和 Tailwind 包的脚手架提供的默认 Vue Hello World 输出进行一些修改:
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js
$ cat << EOF > tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.xhtml",
"./src/**/*.{vue,js}",
],
theme: {
extend: {},
},
plugins: [],
}
EOF
$ cat << EOF > ./src/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;
EOF
$ cat << EOF > ./src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './tailwind.css'
createApp(App).mount('#app')
EOF
在 tailwind.css 文件中,以 @tailwind 开头的指令是告诉我们即时编译器应用什么以生成 CSS 的一部分——我们将仅利用基础、组件和实用指令,并将你指引到 Tailwind CSS 官方文档以了解更多信息——tailwindcss.com/docs/functions-and-directives。
我们现在可以打开我们的 HelloWorld.vue 文件,将其内容替换为以下内容以创建我们的按钮。由于我们的开发服务器仍在运行,所以在你操作 button 类时,如果你将文件保存为实时,你应该能够看到变化:
<template>
<div class="flex space-x-2 justify-center">
<button
@click="count++"
type="button"
class="inline-block px-6 py-2.5 bg-blue-600
text-white font-medium text-lg leading-tight
normal-case rounded shadow-md hover:bg-blue-
700 hover:shadow-lg focus:bg-blue-700
focus:shadow-lg focus:outline-none
focus:ring-0 active:bg-blue-800
active:shadow-lg transition duration-150
ease-in-out"
>
Click me - my count is {{ count }}
</button>
</div>
</template>
你最终应该得到类似这样的结果:

图 9.3:点击我按钮
恭喜!您已成功创建了您的第一个 Tailwind 和 Vite 项目。您可以在 chapter9/tailwind-vite-demo 文件夹内看到完整的示例。
在下一节中,我们将探讨如何从我们的前端使用我们用 Golang 开发的 API。
消费您的 Golang API
我们将在之前的前端示例基础上添加一些功能,以从简单的后端服务中实现 GET 和 POST。源代码可以在 chapter9/backend 文件夹内找到;它主要关注两个简化的函数,这两个函数除了为 GET 返回一个固定的字符串外,还基于我们发送的 POST 请求返回一个反转的字符串。
appGET() 函数提供了执行 GET 操作的功能,而 appPOST() 函数提供了 POST 操作的功能:
func appGET() http.HandlerFunc {
type ResponseBody struct {
Message string
}
return func(rw http.ResponseWriter, req *http.Request) {
log.Println("GET", req)
json.NewEncoder(rw).Encode(ResponseBody{
Message: "Hello World",
})
}
}
func appPOST() http.HandlerFunc {
type RequestBody struct {
Inbound string
}
type ResponseBody struct {
OutBound string
}
return func(rw http.ResponseWriter, req *http.Request) {
log.Println("POST", req)
var rb RequestBody
if err := json.NewDecoder(req.Body).Decode(&rb);
err != nil {
log.Println("apiAdminPatchUser: Decode
failed:", err)
rw.WriteHeader(http.StatusBadRequest)
return
}
log.Println("We received an inbound value of",
rb.Inbound)
json.NewEncoder(rw).Encode(ResponseBody{
OutBound: stringutil.Reverse(rb.Inbound),
})
}
}
我们将使用 go run server.go 来启动我们的服务,目的是从我们的前端应用程序中消费这些数据。
我们将在我们的前端应用程序中创建两个实用函数,以便我们能够与之交互,并且我们将基于 Axios 来构建这些函数。Axios 是一个基于 Promise 的浏览器 HTTP 客户端,它抽象了与后端服务交互所需的浏览器特定代码,并在提供跨所有浏览器的单一接口进行网络请求方面做得非常出色,您可以在官方文档中了解更多信息:axios-http.com/。
我们将首先安装 axios,然后设置我们的 Axios 实例,然后我们可以添加功能层:
$ npm install axios
安装了 axios 后,您现在将想要创建一个包含以下内容的 lib/api.js 文件:
import axios from 'axios';
// Create our "axios" object and export
// to the general namespace. This lets us call it as
// api.post(), api.get() etc
export default axios.create({
baseURL: import.meta.env.VITE_BASE_API_URL,
withCredentials: true,
});
这里有几个有趣的地方需要注意;第一个是 baseURL 值,第二个是 withCredentials。
baseURL 值是 Axios 用于构建所有后续请求的基础。如果我们使用 baseURL 值为 www.packtpub.com/ 调用 axios.Patch('/foo'),它将执行一个 PATCH 调用到 www.packtpub.com/foo。这是一种在开发和生产之间切换并确保您减少错误的好方法。
但我们是如何使用 import.meta.env 的呢?这部分是 Vite 如何导入和暴露环境变量的方式。我们将把我们的 VITE_BASE_API_URL 添加到项目根目录下的 .env 文件中,包含以下内容:
VITE_BASE_API_URL="http://0.0.0.0:8000"
结合这一点和我们的新 lib/api.js 文件,我们现在可以从我们的代码中调用 axios.Put('/test'),默认情况下,它将引用 http://0.0.0.0:8000/test。您可以在 vitejs.dev/guide/env-and-mode.xhtml 了解更多关于 Vite 处理环境变量和更多信息。
另一点需要注意的是 withCredentials 属性。此值指示是否应使用诸如 cookies 和授权头之类的凭据进行跨站访问控制请求。
加粗的部分是最有趣的部分。这些展示了我们如何使用 GET 和 POST 与我们的数据一起使用,使用我们后端服务器上设置的库和 API 调用,以及我们如何在 Vue 模块中绑定数据和引用它。
安全应用的 CORS
import api from '@/lib/api';
export function getFromServer() {
return api.get(`/`);
}
export function postToServer(data) {
return api.post(`/`, data );
}
现在我们已经使用它来实例化我们的 axios 实例,我们可以在我们的前端应用程序的 src 文件夹内创建自己的 api/demo.js 文件。这个名字可能不是非常原创,但对我们来说很适用:
这里有一个小技巧,就是使用 @ 导入——这在很多设置中很常见,允许我们快速指定代码的基础路径,使事情保持整洁,并移除到处都有很多 ../.. 的相对/绝对路径。
12:23:46 [vite] Internal server error: Failed to resolve import "@/api/demo" from "src/components/HelloWorld.vue". Does the file exist?
Plugin: vite:import-analysis
File: /Users/nickglynn/Projects/Becoming-a-Full-Stack-Go-
Developer/chapter 9/frontend/src/components/
HelloWorld.vue
1 | import { ref } from 'vue';
2 | import * as demoAPI from '@/api/demo';
| ^
3 |
4 | // Sample to show how we can inspect mode
还不错!为了解决这个问题,打开你的 vite.config.js 文件,并用以下内容替换其内容:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
// Add the '@' resolver
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})
在 第六章 中,转向 API-First,我们介绍了我们的后端 CORS 中间件。现在我们需要更新我们的新后端服务。它需要响应 OPTION 预检请求,正如我们在 第六章 中讨论的,转向 API-First,还需要确定我们将允许与我们的服务通信的 URL。这是必要的,以确保我们的浏览器不会被欺骗提交/修改来自其他来源的应用程序。
现在所有这些都设置好了,我们将打开我们的 HelloWorld.vue 并更新它,目标是创建一个看起来像 图 9**.4 的东西。

我们想要这个属性的原因是我们希望所有的 cookie 设置都是一致的,但我们需要确保我们的后端应用理解它,这我们将在稍后讨论。设置 withCredentials 对同站请求没有影响。
它不起作用!我们非常接近,但首先,我们必须回顾一下我们之前章节中提到的 CORS。
<script setup>
import { ref } from 'vue';
import * as demoAPI from '@/api/demo';
// Sample to show how we can inspect mode
// and import env variables
const deploymentMode = import.meta.env.MODE;
const myBaseURL = import.meta.env.VITE_BASE_API_URL;
async function getData() {
const { data } = await demoAPI.getFromServer()
result.value.push(data.Message)
}
async function postData() {
const { data } = await demoAPI.postToServer({ Inbound: msg. value })
result.value.push(data.OutBound)
}
const result = ref([])
const msg = ref("")
defineProps({
sampleProp: String,
});
</script>
<template>
<div class="flex space-2 justify-center">
<button
@click="getData()"
type="button"
class="inline-block px-6 py-2.5 bg-blue-600
text-white font-medium text-lg leading-tight
normal-case rounded shadow-md hover:bg-blue-
700 hover:shadow-lg focus:bg-blue-700
focus:shadow-lg focus:outline-none
focus:ring-0 active:bg-blue-800
active:shadow-lg transition duration-150 ease-in-out"
>
Click to Get
</button>
</div>
<div class="flex mt-4 space-2 justify-center">
<input type="text"
class="inline-block px-6 py-2.5 text-blue-600
font-medium text-lg leading-tight
rounded shadow-md border-2 border-solid
border-black focus:shadow-lg focus:ring-1 "
v-model="msg" />
<button
@click="postData()"
type="button"
class="inline-block px-6 py-2.5 bg-blue-600
text-white font-medium text-lg leading-tight
normal-case rounded shadow-md hover:bg-blue-
700 hover:shadow-lg focus:bg-blue-700
focus:shadow-lg focus:outline-none
focus:ring-0 active:bg-blue-800
active:shadow-lg transition
duration-150 ease-in-out"
>
Click to Post
</button>
</div>
<p>You are in {{ deploymentMode }} mode</p>
<p>Your API is at {{ myBaseURL }}</p>
<li v-for="(r, index) in result">
{{ r }}
</li>
</template>
<style scoped></style>
打开你一直在运行的 backend/server.go 示例并审查主函数:
如果你忘记了这一点,你会看到这样的错误:

图 9.5:窥视 HTTP 流量
这段代码导出了两个函数,分别称为 getFromServer 和 postToServer,在后者函数中,还有一个额外的 data 参数作为 POST 主体的发送。
图 9.4:GET 和 POST 的 UI
希望在做出所有这些更改后,你的 Vite 实例仍然在运行;如果不是,请使用 npm run dev 启动它,你应该会得到 图 9**.4 的截图。点击 点击获取 按钮并输入一些要通过 点击 POST 按钮发送的数据。
我已经加粗了我们要添加的关键部分。我们告诉 Vite 使用 @ 符号作为别名,这样当我们使用 @ 在路径中时,它就会调用 path.resolve() 将路径段解析为绝对路径。
...
port := ":8000"
rtr := mux.NewRouter()
rtr.Handle("/", appGET()).Methods(http.MethodGet)
rtr.Handle("/", appPOST()).Methods(http.MethodPost,
http.MethodOptions)
// Apply the CORS middleware to our top-level router, with // the defaults.
rtr.Use(
handlers.CORS(
handlers.AllowedHeaders(
[]string{"X-Requested-With", "Origin", "Content-Type",}),
handlers.AllowedOrigins([]string{
"http://0.0.0.0:3000", "http://localhost:3000"}),
handlers.AllowCredentials(),
handlers.AllowedMethods([]string{
http.MethodGet,
http.MethodPost,
})),
)
log.Printf("Listening on http://0.0.0.0%s/", port)
http.ListenAndServe(port, rtr)
如前所述,我将关键部分加粗。你可以看到我们向我们的 POST 处理器添加了 http.MethodOptions,并且我们还添加了一些额外的中间件。
包含了 AllowedHeaders,我们特别接受 Content-Type,因为我们默认不接受 JSON – 只接受 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
我们还使用 AllowCredentials 来指定用户代理可以将身份验证详情与请求一起传递,并且最后,我们指定了我们的开发服务器的位置,包括 localhost 和 0.0.0.0 地址。这可能有点过度,但如果你的后端和前端以不同的方式启动,这可能会很有帮助。
对于我们项目的生产版本,你将想要将这些作为环境变量注入,以避免混合开发和生产配置文件。如果你利用来自 第六章 的 env.go – 可在 github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/blob/main/Chapter06/internal/env.go 获取 – 你将做如下操作:
rtr.Use(
handlers.CORS(
handlers.AllowedHeaders(
env.GetAsSlice("ALLOWED_HEADERS")),
handlers.AllowedOrigins(
env.GetAsSlice("ORIGIN_WHITELIST")),
handlers.AllowCredentials(),
handlers.AllowedMethods([]string{
http.MethodGet,
http.MethodPost,
})),
)
一旦你的服务器配置正确,重新启动后端和前端,你现在应该能够调用你的后端服务以使用 GET 和 POST。你现在已经完成了一个全栈项目!

图 9.6:显示服务器输出的 UI
在本节中,我们探讨了向我们的应用程序添加 CORS 功能,允许前端访问我们的 API。在下一节中,我们将探讨探索 Vue 中间件,这将有助于提供常见的数据转换功能。
创建 Vue 中间件
使用 Vue(和 Axios)以及 Golang,我们展示了我们可以将迄今为止的所有学习内容整合在一起,但我们遗漏了一个小的方面。我们故意从我们的 Golang 代码中省略了 JSON struct 标签。如果我们将它们添加回我们的 backend/server.go 并重新运行服务器和应用程序,我们的请求将不再工作!
func appPOST() http.HandlerFunc {
type RequestBody struct {
InboundMsg string `json:"inbound_msg,omitempty"`
}
type ResponseBody struct {
OutboundMsg string `json:"outbound_msg,omitempty"`
}
...
由于合约已更改,我们的前端和后端无法再进行通信;前端使用 CamelCase 进行通信,而后端使用 snake_case 进行通信。
这并不是一个阻止我们前进的问题,因为我们已经证明我们可以绕过它,但有时我们没有告诉后端服务使用什么格式的奢侈。幸运的是,Axios 可以修改以添加转换器到我们的请求中,这将修改传入和传出的请求以匹配我们给出的任何后端格式。
要构建我们的转换器,我们将安装并使用两个新的包来帮助我们创建转换器。这些将被用于在不同的格式/大小写类型之间进行转换:
$ npm install snakecase-keys camelcase-keys
最后,我们将修改我们的 lib/api.js 文件以使用这些库来格式化我们的有效载荷:
import axios from 'axios';
import camelCaseKeys from 'camelcase-keys';
import snakeCaseKeys from 'snakecase-keys';
function isObject(value) {
return typeof value === 'object' && value instanceof
Object;
}
export function transformSnakeCase(data) {
if (isObject(data) || Array.isArray(data)) {
return snakeCaseKeys(data, { deep: true });
}
if (typeof data === 'string') {
try {
const parsedString = JSON.parse(data);
const snakeCase = snakeCaseKeys(parsedString, { deep:
true });
return JSON.stringify(snakeCase);
} catch (error) {
// Bailout with no modification
return data;
}
}
return data;
}
export function transformCamelCase(data) {
if (isObject(data) || Array.isArray(data)) {
return camelCaseKeys(data, { deep: true });
}
return data;
}
export default axios.create({
baseURL: import.meta.env.VITE_BASE_API_URL,
withCredentials: true,
transformRequest: [...axios.defaults.transformRequest,
transformSnakeCase],
transformResponse: [...axios.defaults.transformResponse,
transformCamelCase],
});
这段代码可能看起来很多,但这是我们创建转换器所需的内容。我们创建一个to函数和一个from函数,将其作为转换器添加到 Axios 实例化中。我们将请求转换为 snake_case(蛇形命名),在出站/请求时,并将它们转换为 CamelCase(驼峰命名),在入站/响应时。如果您想深入了解为 Axios 创建转换器的具体细节,您可以在网站axios-http.com/docs/req_config上找到更多信息,其中包含了对所有其他众多可以提供给 Axios 库的配置和参数的查看。
我们可以使用几种不同的方法/库来实现相同的目标——例如,来自www.npmjs.com/package/humps的humps包是我们可以使用来提供类似功能的另一个库,但我们所使用的方法非常适合我们的用例。
摘要
本章介绍了 Tailwind CSS,并讨论了其以实用工具为先的方法。我们之前在第四章中看到了它的示例,服务和嵌入 HTML 内容,在那里我们提供了 HTML/CSS,但这是我们第一次使用它,以及我们如何快速创建组件,以及如何快速将其与我们的前端 Vue 应用程序集成,包括配置,以及如何测试其成功的安装。
在本章中,我们创建了一个全栈应用程序,将迄今为止的专长结合起来。我们成功地在 Vue 中构建了一个前端应用程序,并与我们的 Golang 后端进行通信;作为其中的一部分,我们还探讨了如何配置和使用 Axios,以及如何减轻常见的 CORS 问题,最后简要地查看如何在 Vue 应用程序中使用中间件,以便我们能够在后端的不同 JSON 模式之间进行通信。
在下一章中,我们将探讨如何确保我们的会话安全,使用 JWT 进行会话、中间件,以及在 Vue 中创建和使用导航守卫。
第十章:会话管理
在第九章**,Tailwind,中间件和 CORS中,我们创建了一个全栈应用程序,具有独立的前端和后端,通过 API 相互通信。
在本章中,我们将把所有现有的知识结合起来,介绍如何创建和验证用于会话管理和中间件的 JSON Web Tokens (JWTs),设置使用 Vue Router 的基本原则和导航守卫,以及了解错误和“捕获所有”导航守卫。
本章我们将涵盖以下主题:
-
会话管理和 JWTs
-
(重新)介绍 Vue Router
-
导航守卫
-
默认页面和错误页面
到本章结束时,我们将了解如何完成并确保一个为等待用户准备的项目。
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/chapter10中查看。
会话管理和 JWTs
我们之前在*第六章**,转向 API-First,使用 Gorilla Mux 中间件,查看过使用 cookie 进行会话管理。在我们的应用程序中,我们通过 Gorilla 会话提供的功能创建了一个内存中的 cookie 存储:github.com/gorilla/sessions。
我们之前实现了我们的中间件,以验证我们的用户是否被批准,通过编码两个值——我们从数据库中查找的用户 ID 和一个userAuthenticated布尔值。这对于我们的用例来说效果很好,但我们的实现意味着每次调用我们的 API 后端都需要往返数据库,以检查用户 ID 是否仍然存在,然后才能让调用继续。

图 10.1:使用会话 cookie 的登录和保存 API 工作流程示意图
这种方法是可以的,Gorilla 会话库提供了一些替代后端来加速这个过程,例如使用 Redis 和 SQLite,但我们将探讨使用 JWT 的替代方法。
什么是 JWT?
JWT 代表 JSON Web Token。JWT 是一个创建带有可选签名(公共或公共/私有)和/或加密的数据的标准,其有效载荷由 JSON 组成,声明了 JWT 规范中称为声明的多个内容。您可以在jwt.io上生成和检查 JWTs,它们被分解为三个部分,包括头部、有效载荷(包含声明)和签名。然后,它们使用.分隔符进行 base64 编码并连接在一起,您可以看到这里。

图 10.2:使用颜色编码的 JWT 各部分示意图
我们感兴趣的部分是有效载荷和声明。存在一些保留的声明,我们应该将其视为规范的一部分,具体如下:
-
iss(发行者):JWT 的发行者。
-
sub(主题):JWT 的主题(用户)。
-
aud(受众):JWT 的目标接收者。
-
exp(过期时间):JWT 过期后的时间。
-
nbf(不可用时间):JWT 必须在此时间之前不接受处理。
-
iat(发行时间):JWT 被发行的时间。这可以用来确定 JWT 的年龄。
-
jti(JWT ID):一个唯一的标识符。这可以用来防止 JWT 被重放(允许令牌只使用一次)。
在库中,我们将使用go-jwt,可在github.com/golang-jwt/jwt找到。这些标准声明通过 Go 结构体提供,如下所示:
// Structured version of Claims Section, as referenced at
// https://tools.ietf.org/html/rfc7519#section-4.1
type StandardClaims struct {
Audience string `json:"aud,omitempty"`
ExpiresAt int64 `json:"exp,omitempty"`
Id string `json:"jti,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
Issuer string `json:"iss,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
}
我们可以向这些声明中添加自己的附加声明,并且按照典型的 Go 风格,我们通过将StandardClaims嵌入到我们自己的结构体中来实现,我将其称为MyCustomClaims,如下所示:
mySigningKey := []byte("PacktPub")
// Your claims above and beyond the default
type MyCustomClaims struct {
Foo string `json:"foo"`
jwt.StandardClaims
}
// Create the Claims
claims := MyCustomClaims{
"bar",
// Note we embed the standard claims here
jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Minute *
1).Unix(),
Issuer: "FullStackGo",
},
}
// Encode to token
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
claims)
tokenString, err := token.SignedString(mySigningKey)
fmt.Printf("Your JWT as a string is %v\n", tokenString)
如果你执行此代码,你将得到以下输出:
$ go run chapter10/jwt-example.go
Your JWT as a string is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey Jmb28iOiJiYXIiLCJleHAiOjE2NTY3MzY2NDIsImlzcyI6IkZ1bGxTdGFja0dv In0.o4YUzyw1BUukYg5H6CP_nz9gAmI2AylvNXG0YC5OE0M
当你运行示例代码或编写自己的代码时,由于StandardClaims中的相对过期时间,它看起来会有所不同,如果你尝试解码前面的字符串,很可能会显示已经过期了几秒钟!
你可能想知道为什么你应该关心 JWT,因为你已经看到了基于数据库的中介工作。原因是我们可以节省一次往返数据库,节省时间和带宽。
由于 JWT 是经过签名的,我们可以自信地假设,只要 JWT 按照我们的预期被解码,提供的声明就可以被断言为真实。在我们的基于 JWT 的模型中,我们可以将用户详情和权限编码到 JWT 本身的声明中。

图 10.3:使用 JWT 安全会话的登录和保存 API 工作流程的说明
这看起来都很不错,但在使用 JWT 时存在一些“陷阱”,在我们开始在每个场景中使用它们之前,值得先了解它们。
“无算法”陷阱
可以创建一个未加密的 JWT,其中“alg”头参数值设置为“none”,其签名值为空字符串。
由于我们的 JWT 仅仅是 base64 编码的有效载荷,恶意黑客可以解码我们的 JWT,移除签名,将算法参数更改为“none”,然后尝试将其作为有效的 JWT 呈现给我们的 API。
$ Pipe our encoded JWT through the base64 command to decode it
$ echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -D
{"alg":"HS256","typ":"JWT"}
$ echo '{"alg":"none","typ":"JWT"}' | base64
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0K
重要的是,你使用的库要验证你收到的 JWT 与你提供的算法相同,并且在使用它之前你应该自己验证这一点。
“注销”陷阱
当你点击退出你的 web 应用时,常见的做法是将 cookie 过期时间设置为过去的日期,然后浏览器将删除 cookie。你还应该从你的数据库和/或应用中删除任何活动的会话信息。问题是,使用 JWT,它可能不会按你预期的样子工作。因为 JWT 是自包含的,它将继续工作并被认为有效,直到它过期——是 JWT 的过期,而不是 cookie 的过期——所以如果有人拦截你的 JWT,他们可以继续访问平台,直到 JWT 过期。
“黑名单”或“过时数据”陷阱
与退出时的陷阱类似,因为我们的 JWT 是自包含的,存储在其中的数据可以直到刷新时仍然过时。这可能导致访问权限/权限不同步,或者更糟糕的是,在你禁止他们之后,有人仍然能够继续登录你的应用。这在需要实时阻止用户的情况下更糟——例如,在滥用或不良行为的情况下。相反,使用 JWT 模型,用户将继续有权访问,直到令牌过期。
使用 JWT 和 cookie 以及我们的中间件
在理解了我们所有的陷阱之后,我们将编写一些简单的中间件和 cookie 处理,以构建来自第九章,Tailwind,中间件和 CORS 的简单 API 服务,结合我们在第五章**,保护后端和中间件中的知识。
这段代码全部在 GitHub 上的chapter10/simple-backend提供。
设置 cookie 和验证中间件
为了开始使用我们新的 JWT,我们将为 mux 编写一些中间件以供消费,并将它们注入到我们所有的受保护路由中。和之前一样,我们使用默认库使用的签名,其中我们接收http.Handler并返回handlerFunc。当成功时,我们调用next.ServerHTTP(http.ResponseWriter, *http.Request)以继续并指示请求处理成功:
// JWTProtectedMiddleware verifies a valid JWT exists in
// our cookie and if not, encourages the consumer to login
// again.
func JWTProtectedMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
// Grab jwt-token cookie
jwtCookie, err := r.Cookie("jwt-token")
if err != nil {
log.Println("Error occurred reading cookie", err)
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(struct {
Message string `json:"message,omitempty"`
}{
Message: "Your session is not valid –
please login",
})
return
}
// Decode and validate JWT if there is one
userEmail, err := decodeJWTToUser(jwtCookie.Value)
if userEmail == "" || err != nil {
log.Println("Error decoding token", err)
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(struct {
Message string `json:"message,omitempty"`
}{
Message: "Your session is not valid –
please login",
})
return
}
// If it's good, update the expiry time
freshToken := createJWTTokenForUser(userEmail)
// Set the new cookie and continue into the handler
w.Header().Add("Content-Type", "application/json")
http.SetCookie(w, authCookie(freshToken))
next.ServeHTTP(w, r)
})
}
这段代码正在检查我们的名为jwt-token的 cookie,并使用我们新的decodeJWTToUser对其进行解码,检查其值是否为有效的条目。在我们的情况下,我们期望userEmail,如果它不存在,我们简单地返回一个无效会话消息。在这个例子中,然后我们更新 JWT 的过期时间,并在设置最新的 cookie 后退出函数。
在实践中,我们会更加严格地检查,以确保保留一个小的有效声明窗口,然后我们会回数据库检查用户是否仍然有权访问我们的平台。
我们用于设置和操作 cookie 的功能与我们之前在第五章**,保护后端和中间件中的工作非常相似,包括域名、same-site 模式,以及最重要的是 HttpOnly 和 Secure。
我们使用 Secure 作为良好实践来确保它始终通过安全的 HTTPS 发送(除了在本地主机上进行开发时),因为尽管我们可以对我们的 JWT 的安全性有信心,但它仍然可以用诸如 jwt.io 之类的工具进行解码:
var jwtSigningKey []byte
var defaultCookie http.Cookie
var jwtSessionLength time.Duration
var jwtSigningMethod = jwt.SigningMethodHS256
func init() {
jwtSigningKey = []byte(env.GetAsString(
"JWT_SIGNING_KEY", "PacktPub"))
defaultSecureCookie = http.Cookie{
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Domain: env.GetAsString("COOKIE_DOMAIN",
"localhost"),
Secure: env.GetAsBool("COOKIE_SECURE", true),
}
jwtSessionLength = time.Duration(env.GetAsInt(
"JWT_SESSION_LENGTH", 5))
}
...
func authCookie(token string) *http.Cookie {
d := defaultSecureCookie
d.Name = "jwt-token"
d.Value = token
d.Path = "/"
return &d
}
func expiredAuthCookie() *http.Cookie {
d := defaultSecureCookie
d.Name = "jwt-token"
d.Value = ""
d.Path = "/"
d.MaxAge = -1
// set our expiration to some date in the distant
// past
d.Expires = time.Date(1983, 7, 26, 20, 34, 58,
651387237, time.UTC)
return &d
}
HttpOnly标志在我们的 cookie 包中为我们使用,之前还没有提到——那么,它是什么?
好吧,默认情况下,当我们不使用HttpOnly时,我们的前端 JavaScript 可以读取和检查 cookie 值。这对于通过前端设置临时状态和存储前端需要操作的状态很有用。这在许多场景下都是可行的,你的应用程序可能结合了多种 cookie 处理技术。
当你使用HttpOnly时,浏览器会阻止对 cookie 的访问,通常将任何读取值的返回结果作为空字符串。这对于防止跨站脚本攻击(XSS)很有用,恶意网站试图访问你的值,并阻止你向第三方/攻击者的网站发送数据。
这并不会阻止我们登录(这不会很有帮助!)。我们仍然可以使用所有 cookie 执行所有 API/后端请求,但我们需要告诉我们的前端应用程序这样做。
在为我们的后端添加了登录功能之后,我们现在将重新审视路由,以便我们可以在我们的应用程序中移动。
(重新)介绍 Vue Router
在我们深入之前,我们需要快速回顾一下我们的前端和后端是如何通信的,并确保我们知道事情是如何工作的。
你可能还记得从第九章,Tailwind, Middleware, and CORS,我们设置了我们的axios实例(在src/lib/api.js下)。通过一些默认设置,这就是withCredentials值发挥作用的地方:
export default axios.create({
baseURL: import.meta.env.VITE_BASE_API_URL,
withCredentials: true,
transformRequest: [...axios.defaults.transformRequest,
transformSnakeCase],
transformResponse: [...axios.defaults.transformResponse,
transformCamelCase],
});
我们希望确保在前后端通信时,我们所有的 Secure 和HttpOnlycookie 的工作都得到了保留,withCredentials确保所有对后端的请求都应该带有 cookie、认证头等信息。
我们将在介绍导航守卫概念时构建这个axios实例。在我们导航应用程序之前,我们将从后端获取/刷新我们的数据,以便在渲染之前。这使我们能够检查用户是否应该查看某些页面,他们是否需要登录,或者他们是否不应该窥探!
由于我们的应用程序现在将我们的 cookie 传递到每个请求中,我们可以现在利用权限,在导航应用程序时使用导航守卫。
导航守卫
Vue 中的导航守卫对于已登录用户来说是基本的。与 Vue 的任何核心功能一样,深入研究 Vue 团队提供的出色文档是值得的:router.vuejs.org/guide/advanced/navigation-guards.xhtml。
正如其名所示,导航守卫是一种根据某些守卫检查结果取消或重新路由用户的方式。它们可以被全局安装——例如,所有内容都在登录/付费墙后面——或者它们可以被放置在单个路由上。
它们在导航请求中按顺序被调用,在组件加载之前。它们也可以用来检索要提供给下一页组件的属性,并使用router.beforeEach(to, from)的语法。
以前的版本也提供了一个next参数,但这个参数已经被弃用,不应该在现代代码中使用。
导航守卫的功能如下:
-
to: 提供目标位置,即用户试图导航到的位置 -
from: 用户当前所在的位置
守卫处理器的任务是评估是否允许导航。
处理器可以通过返回false,一个新的路由位置,用于通过router.push()操作浏览器历史记录以允许额外的属性,或者简单地返回true来表示导航是被允许的。
使用文档中的简单示例,我们可以在我们的路由上安装一个全局导航守卫,以便在导航之前检查isAuthenticated变量的值:
router.beforeEach(async (to, from) => {
if (
// make sure the user is authenticated
!isAuthenticated &&
// Avoid an infinite redirect
to.name !== 'Login'
) {
// redirect the user to the login page
return { name: 'Login' }
}
// Otherwise navigation succeeds to 'from'
})
将逻辑放入每个路由可能会有些丑陋。我们将做的是在后端公开一个端点,该端点返回一个值,甚至只是一个 20x HTTP 成功响应,在我们的中间件中检查这一点,如果一切顺利,我们将允许导航。
在下面的代码中,我们在后端公开了一个/profile端点。这个端点可以返回数据,或者在这个简单的例子中,只返回 200/OK 响应,我们可以用我们的getCheckLogin()函数来检查这一点。
我们的checkAuth()函数现在检查一个名为requiresAuth的可选布尔值元值。如果没有授权要求,我们就可以成功导航;否则,我们尝试访问我们的端点。如果请求出错(非成功),我们将重定向到我们的登录页面;否则,我们允许导航继续:
export function getCheckLogin() {
return api.get('/profile');
}
export default function checkAuth() {
return async function checkAuthOrRedirect(to, from) {
if (!to?.meta?.requiresAuth) {
// non protected route, allow it
return;
}
try {
const { data } = await getCheckLogin();
return;
} catch (error) {
return { name: 'Login'};
}
};
}
这些检查可以在我们的导航守卫中变得非常复杂,但请记住,你会在每次导航时调用这些检查。如果你发现自己经常这样做,你可能需要考虑状态管理,比如 Pinia(Vue 3)或 Vuex(如果你使用 Vue 2)。
要安装这些检查和值,我们只需安装全局处理器,对于受保护的路线,我们提供meta布尔值。这在上面的代码片段中显示:
...
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
Name: 'Login',
meta: {
requiresAuth: false,
},
props: true,
component: () => import('@/views/login.vue'),
},{
path: '/dashboard,
Name: 'Dashboard',
meta: {
requiresAuth: true,
},
props: true,
component: () => import('@/views/dashboard.vue'),
}]
});
...
router.beforeEach(checkAuth());
元字段是一个有用的功能。它们允许我们将任意信息附加到我们的路由上,在我们的情况下,我们使用元信息作为检查授权的指示器。您可以在这里了解更多关于元的信息。
通过提供登录和注销状态的能力,我们现在有一个功能齐全的应用程序。为了真正完善我们的应用程序,我们需要为用户提供默认页面和错误页面,以防我们的应用程序出错或他们在其中误入歧途。
默认页面和错误页面
我们的现在与后端安全通信,并根据授权正确路由,我们几乎完成了核心功能需求。
对于我们的用户来说,可能还会出现一个最终的场景——可怕的 404——页面未找到错误!幸运的是,Vue Router 使得创建一个通配符的“通配符”路由变得容易,如果用户导航到一个不存在的页面,它将重定向用户到特定的页面。
如你所知,在 Vue 中,所有路由都是通过在特定的 URL 路径上创建规则来定义的。例如,为/user路径创建一个路由,如果用户输入 packt.com/user,就会被捕获,但如果用户输入packt.com/my-user或任何其他与路径规则设置不精确的词,则不会被捕获。
在 Vue routervue-router 4 的版本 4 中定义我们的通配符规则,我们将使用以下路由条目:
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }
我们将把这个作为最后的路由注入到我们的router.routes中。路径末尾的通配符匹配意味着我们可以导航到这个页面并捕获预期的路由。或者,如果你觉得这太神奇了,你可以使用path: ‘/*’并且不需要担心捕获预期的路由。
对于 404 页面未找到错误的最佳实践是提供错误提示,并给用户一个返回主页或导航到类似页面的方法,但这是你可以为你的NotFound组件做出的选择。
摘要
激动人心的,我们现在已经拥有了足够的知识来完成全栈应用程序的开发。在本章中,我们介绍了基于 JWT 的令牌,讨论了何时以及为什么要使用它们,并涵盖了一些“陷阱”。然后我们回顾了前后端之间的 cookie 处理,最后转向 Vue Router。
在本章结束时,我们使用 Vue Router,探讨了添加导航守卫,讨论了如何使用元值来增强我们的开发体验并标记页面以进行授权,最后通过设置我们的通配符错误处理路由来完成,以确保我们的用户有良好的体验。
在下一章中,我们将探讨将我们的应用程序投入生产并为我们的第一批用户做好准备。
第四部分:发布和部署
本书本部分的目的是了解应用程序发布过程和作为开发过程一部分的云部署。
本部分包括以下章节:
-
第十一章,功能标志
-
第十二章,构建持续集成
-
第十三章,Docker 化应用程序
-
第十四章,云部署
第十一章:特性标志
本章,我们将了解特性标志,它们是什么,如何使用它们,以及使用它们的益处。使用特性标志对于应用程序不是强制性的。然而,随着应用程序复杂性的增加,对特性标志的需求将会出现。
特性标志提供了许多不同的特性,但本章我们将专注于如何使用特性标志在应用程序中启用/禁用特定特性。我们将使用一个开源的简单版本特性标志服务器来演示前端和后端服务的集成。
本章将涵盖以下主题:
-
理解特性标志的实质
-
安装开源特性标志服务器
-
使用特性标志启用/禁用特性
-
集成前端和后端服务的特性标志
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/chapter11找到。
本章使用 cURL 工具执行 HTTP 操作。该工具适用于不同的操作系统,可以从curl.se/download.xhtml下载。
特性标志简介
在当前快速变化的世界中,开发者几乎每天都需要进行更改和推出新特性,如果不是更快的话。有时,这甚至需要在有用户需求之前构建特性。能够在不造成干扰的情况下将特性部署到生产环境中是软件开发的神圣目标。
部署到生产环境中的特性可能对用户开放也可能不开放;这完全取决于业务方面的战术决策。开发者会持续将特性发布到生产环境中,当时机成熟时,特性将通过业务端的一个按钮点击来启用。这种功能正是特性标志提供的。
简而言之,特性标志就像是我们可以在不造成干扰的情况下启用/禁用应用程序中特性的开关。启用特性也将允许公司根据市场和用户需求有策略地启用或禁用特性,这可能影响公司的底线。
作为一种工具,特性标志不仅提供了运行/关闭特性的能力,还有许多其他好处你可以从中获得:
-
根据某些条件(如地理位置、用户年龄等)对特定人群进行特性测试
-
根据网络条件对流量请求进行分割
-
进行用户体验实验以了解哪种设计效果良好
在本章中,我们将探讨一个开源项目特性标志工具,以展示如何使用和集成它。
特性标志配置
您可以通过在您的基础设施中部署功能标志或使用如 LaunchDarkly、Flagsmith 以及许多其他可用的软件即服务解决方案来使用功能标志。每个解决方案都提供自己的 API,需要将其集成到您的应用程序中。这意味着您的应用程序与您选择的解决方案绑定。没有一种适合所有情况的解决方案;这完全取决于您应用程序需要哪些功能。
让我们看看使用功能标志的不同配置。图 11.1展示了使用功能标志的最简单方法。

图 11.1:使用功能标志的 Web 客户端
Web 客户端将根据功能标志启用或禁用用户界面。例如,在应用程序中,当与菜单相关的功能标志开启时,可以启用特定的菜单选择。
图 11.2展示了不同的配置,其中 Web 客户端将根据哪个功能标志开启/关闭而调用不同的微服务:

图 11.2:功能标志微服务
在前面的示例中,当功能标志 A 开启时,Web 客户端会调用微服务 A。
在图 11.3中展示了另一个有趣的配置,其中主微服务将确定将返回给 Web 客户端的用户界面,这取决于哪个功能标志已被配置:

图 11.3:微服务的功能标志
在上述示例中,如果主微服务检测到功能标志 C 已被启用,Web 客户端将获得不同的响应以进行渲染,这将从微服务 C 获取响应。
因此,正如我们所看到的,使用功能标志有不同的方法和不同的地方,这都取决于您应用程序的需求。
在下一节中,我们将探讨如何使用开源功能标志服务器来在示例 Web 应用程序中启用/禁用按钮。
使用功能标志的用例
功能标志不仅限于可以配置为在应用程序内部开启/关闭功能的标志;还有许多其他功能和能力。在本节中,我们将探讨完整功能标志服务器提供的功能:
-
分段目标 – 假设您正在构建一个希望在应用程序中的一组用户上测试的功能。例如,您可能希望基于 PayPal 的结账功能仅适用于位于美国的特定用户组。
-
风险缓解 – 构建产品功能并不能保证功能会带来更多用户。新功能可以发布,随着时间的推移和更多的分析,如果发现该功能提供了不良的用户体验,它将作为风险缓解过程的一部分被关闭。
-
在发布前收集反馈 – 通过对特定用户组的定向发布,可以尽早从用户那里获得反馈。这些反馈将为团队提供洞察力,以决定该功能是否确实为用户提供任何额外的好处。
现在我们对功能标志的使用案例有了很好的理解,我们将在下一节中探讨安装功能标志服务器。
安装功能标志服务器
我们将使用一个开源功能标志服务器。按照以下方式从 github.com/nanikjava/feature-flags 仓库克隆项目:
git clone https://github.com/nanikjava/feature-flags
从您的终端,切换到项目目录,并使用以下命令构建服务器:
go build -o fflag .
我们使用 -o 标志来编译应用程序,并将其输出到名为 fflag 的文件中。现在服务器已经编译完成并准备好使用,请打开一个新的终端窗口,并按照以下方式运行服务器:
./fflag
您将看到以下输出:
2022/07/30 15:10:38 Feature flag is up listening on :8080
服务器现在正在监听端口 8080。现在,我们需要为我们的网络应用程序添加一个新的功能标志,其键名为 disable_get。要这样做,请使用 curl 命令行以以下方式发送数据:
curl -v -X POST http://localhost:8080/features -H "Content-Type:application/json" -d '{"key":"disable_get","enabled":false,"users":[],"groups":["dev","admin"],"percentage":0}'
curl 命令正在调用 /features 端点并传递 JSON 数据。一旦成功完成,您将看到以下输出:
{
"key": "disable_get",
"enabled": false,
"users": [],
"groups": [
"dev",
"admin"
],
"percentage": 0
}
JSON 输出显示,功能标志服务器现在有一个名为 disable_get 的新键,并且它被禁用,如 enabled: false 标志所示。输出应如下所示,显示数据已成功添加:
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
…
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
…
< Content-Length: 89
<
{"key":"disable_get","enabled":false,"users":[],"groups":["dev","admin"],"percentage":0}
* Connection #0 to host localhost left intact
功能标志服务器已准备好所需的数据。在下一节中,我们将探讨如何在我们的网络应用程序中使用该标志。
功能标志的高级架构
图 11.4 展示了开源功能标志服务器的高级架构。

图 11.4:高级架构
从图中可以看出,服务器使用 mux.Router 来路由不同的 HTTP 请求,例如 GET、POST、DELETE 和 PATCH。服务器使用内部数据库作为应用程序所需功能标志的持久存储。
服务器可以通过 HTTP 请求调用访问,这些调用可以通过使用正常 HTTP 协议的 Web 客户端和微服务进行。
功能标志集成
在我们安装了功能标志服务器之后,我们希望在应用程序中使用它。在本节中,我们将探讨如何集成功能标志以在前端启用/禁用某些用户界面元素,并仅从我们的服务器调用已启用的后端服务。
网络应用程序
我们将要使用的示例应用程序位于 chapter11/frontend-enable-disable 文件夹内;该示例应用程序演示了如何使用功能标志来启用/禁用用户界面按钮。打开终端并切换到 chapter11/frontend-enable-disable 目录以运行网络应用程序,如下所示:
npm install
npm run dev
这些命令将安装所有必要的包并运行网络应用程序。一旦命令完成,打开您的浏览器并在地址栏中输入 http://localhost:3000。你将看到如图 图 11.5 所示的 web 应用程序。

图 11.5:使用功能标志的 web 应用程序的初始视图
你在 图 11.5 中看到的是其中一个按钮已被禁用。这是基于我们在上一节中设置的标志值。标志数据如下所示:
{
"key": "disable_get",
"enabled": false,
"users": [],
"groups": [
"dev",
"admin"
],
"percentage": 0
}
disable_get 键是我们添加到服务器上的标志键,而 enabled 字段被设置为 false,这意味着按钮已被禁用。让我们将 enabled 字段更改为 true,然后看看网页如何变化。
在终端中使用以下命令更新数据:
curl -v -X PATCH http://localhost:8080/features/disable_get -H "Content-Type: application/json" -d '{"key":"disable_get","enabled":true}'
curl 命令将 enabled 字段更新为 true。刷新浏览器页面,你会看到按钮已被启用,如图 图 11.6 所示。

图 11.6:点击获取按钮已启用
以下是从 HelloWorld.vue 文件中的代码片段,负责从服务器读取键,使用它来启用/禁用按钮:
...
<script>
import axios from 'axios';
export default {
data() {
return {
enabled: true
}
},
mounted() {
axios({method: "GET", "url":
"http://localhost:8080/features/disable_get"}).then(result => {
this.enabled = result.data.enabled
console.log(result);
}, error => {
console.error(error);
});
}
}
</script>
<template>
<div v-if="enabled" class="flex space-2 justify-center">
...
</button>
</div>
...
在下一节中,我们将探讨使用功能标志在后端服务上启用/禁用某些功能。
微服务集成
在本节中,我们将使用功能标志来启用/禁用某些服务。这将使应用程序能够仅访问当前已启用的服务。图 11.7 展示了微服务的结构。应用程序位于 chapter11/multiple-service 文件夹中。

图 11.7:功能标志的微服务结构
按照上一节中的步骤运行功能标志服务器,使用以下命令创建标志:
curl -v -X POST http://localhost:8080/features -H "Content-Type: application/json" -d '{"key":"serviceb", "enabled":true,"users":[],"groups":["dev","admin"],"percentage":0}'
curl -v -X POST http://localhost:8080/features -H "Content-Type: application/json" -d '{"key":"servicea", "enabled":false,"users":[],"groups":["dev","admin"],"percentage":0}'
该命令创建两个键:servicea 和 serviceb。目前,servicea 已被禁用,而 serviceb 已被启用。一旦设置了功能标志,我们将运行不同的服务:
-
chapter11/multiple-service/mainserver目录。使用以下命令运行主服务器:go run main.go
主服务器将在端口 8080 上运行。
-
servicea– 打开终端并切换到chapter11/multiple-service/servicea目录。使用以下命令运行服务:go run main.go
servicea 将在端口 8081 上运行。
-
serviceb– 打开终端并将目录更改为chapter11/multiple-service/serviceb。使用以下命令运行服务:go run main.go
serviceb将在端口8082上运行。
现在,我们有三个不同的服务正在端口8080、8081和8082上运行。打开您的浏览器,使用http://localhost:8000访问服务。您将得到以下类似的结果:
{"Message":"-ServiceB active"}
返回的响应来自serviceb,因为根据功能标志服务器的配置,servicea已被禁用。现在,让我们使用以下命令打开servicea的标志:
curl -v -X PATCH http://localhost:8080/features/servicea -H "Content-Type: application/json" -d '{"enabled":true}'
通过使用Ctrl + C强制停止主服务器来重新启动它。使用之前讨论的相同命令重新运行它。打开您的浏览器并使用http://localhost:8000访问服务。您应该得到以下类似的结果:
{"Message":"ServiceA active-ServiceB active"}
现在两个服务都已启用,我们收到了来自这两个服务的响应。
让我们看看代码,以了解如何使用功能标志。以下代码片段显示了启动服务的一部分代码:
...
func main() {
port := ":8000"
...
wg := &sync.WaitGroup{}
wg.Add(1)
go func(w *sync.WaitGroup) {
defer w.Done()
serviceA = checkFlags("servicea")
serviceB = checkFlags("serviceb")
}(wg)
wg.Wait()
http.ListenAndServe(port, rtr)
}
代码通过 goroutine 调用功能标志服务器以获取servicea和serviceb的标志信息。一旦完成,它就开始服务器监听端口8000。服务的状态存储在servicea和serviceb变量中,这些变量将在代码的其他部分中使用,如下面的代码片段所示:
func handler() http.HandlerFunc {
type ResponseBody struct {
Message string
}
return func(rw http.ResponseWriter, req *http.Request) {
var a, b string
if serviceA {
a = callService("8081")
}
if serviceB {
b = callService("8082")
}
json.NewEncoder(rw).Encode(ResponseBody{
Message: a + "-" + b,
})
}
}
当您访问端口8000的服务器时,会调用handler()方法。在代码内部,如所见,它仅在服务启用时调用服务。一旦调用服务,服务的结果将被合并并作为单个 JSON 响应发送回客户端。
以下代码片段显示了如何访问功能标志服务器以提取不同的标志。它使用正常的 HTTP GET 调用:
func checkFlags(key string) bool {
...
requestURL := fmt.Sprintf("http://localhost:%d/features/%s", 8080, key)
res, err := http.Get(requestURL)
...
resBody, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Printf("client: could not read response body: %s\n", err)
os.Exit(1)
}
...
return f.Enabled
}
代码通过获取我们感兴趣的每个键来调用功能标志服务器。因此,在示例中,我们使用以下 URL 进行调用:
http://localhost:8080/features/servicea
http://localhost:8080/features/serviceb
例如,当调用http://localhost:8080/features/servicea时,代码将从功能标志服务器获取以下 JSON 响应:
{
"key": "servicea",
"enabled": true,
"users": [],
"groups": [
"dev",
"admin"
],
"percentage": 0
}
checkFlags()函数只对enabled字段感兴趣,它将被反序列化到如下所示的FeatureFlagServerResponse结构体中:
func checkFlags(key string) bool {
type FeatureFlagServerResponse struct {
Enabled bool `json:"enabled"`
}
...
var f FeatureFlagServerResponse
err = json.Unmarshal(resBody, &f)
...
}
在成功将 JSON 转换为结构体后,它将返回此处所示的Enabled值:
func checkFlags(key string) bool {
...
return f.Enabled
}
我们已经到达了本章的结尾。在本节中,我们探讨了在不同场景中集成功能标志,例如在 Web 应用程序内部以及将其用作访问不同微服务的功能切换。还有其他用例,其中可以使用功能标志,例如在生产中启用/禁用性能指标,以及在生产中启用跟踪以调试错误。
摘要
在本章中,我们学习了功能标志,包括它们的使用目的以及如何使用它们。我们学习了如何安装简单的功能标志服务器,并看到了如何将其与我们的示例应用程序集成。
我们已经通过两个不同的用例介绍了使用功能标志的步骤——通过检查标志来在我们的前端启用/禁用按钮,以及在后端调用不同的微服务。使用功能标志来启用或禁用某些服务,使应用程序在向前端发送响应方面具有灵活性,这给了开发者按需允许访问某些服务的能力。
在下一章中,我们将通过探索 GitHub 提供的不同功能来构建持续集成。
第十二章:构建持续集成
构建解决问题的 Web 应用程序是很好的,但我们还需要让这些应用程序对用户可用,以便他们可以开始使用它们。作为开发者,我们编写代码。但是,同时,这段代码需要被构建或编译,以便它可以部署,使用户能够使用它。我们需要了解我们如何自动构建我们的 Web 应用程序,而不需要任何手动过程来工作。这就是我们将在本章中讨论的内容。我们将探讨所谓的持续****集成(CI)。
CI 是一种自动化将不同贡献者的代码集成到项目中的实践或过程。CI 允许开发者频繁地将代码合并到代码库中,在那里它将被自动测试和构建。
在本章中,我们将学习 CI 的以下内容:
-
GitHub 工作流程
-
使用 GitHub Actions
-
发布到 GitHub Packages
技术要求
本章的源代码可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/chapter12找到。在本章中,我们还将使用另一个存储库来设置 CI 以进行说明。该存储库是GitHub.com/nanikjava/golangci。
CI 的重要性
你可以将 CI 视为你的开发过程中的一个方面。这之所以重要,主要是因为它允许你作为开发者确保所有提交到中央代码库的代码都经过测试和验证。
当你在团队环境中工作时,多个开发者正在同一个项目上工作,这一点变得至关重要。拥有适当的 CI 将会给开发者带来安心和保证,即他们所使用的所有代码都可以正确编译,并且自动测试用例已经成功运行。想象一下,你必须从 GitHub 检出一些项目,但当你尝试编译和运行一些测试用例时,它失败了;这将是一场灾难,因为你将不得不花时间修复问题,但如果项目有一个适当的 CI 流程,它将确保所有提交的代码都能正确编译,测试用例都能通过。
即使作为一个独立开发者在一个项目上工作,也强烈建议设置 CI。你从这得到的最小好处是确保你的代码可以正确构建。这也确保了当构建失败发生时,任何意外添加到代码中的与你的本地机器相关的本地依赖项被检测到。
在下一节中,我们将通过查看构建我们 Web 应用程序的 CI 所需的不同步骤,来探讨使用 GitHub 构建 CI。
设置 GitHub
在本节中,我们将解释为了在 GitHub 中获得自动化 CI 需要准备的不同事项。为了更好地理解 CI 过程,建议您创建一个单独的 GitHub 仓库,并将chapter12目录中的所有内容复制到新仓库中。最初,当创建nanikjava/golangci仓库时,它将类似于图 12.1。

图 12.1:一个全新的 GitHub 仓库
对于本章,我们已设置一个单独的仓库(GitHub.com/nanikjava/golangci),我们将将其用作本章讨论的参考指南。我们将通过在仓库中创建简单 GitHub 工作流程的步骤进行说明。GitHub 工作流程是一组运行一个或多个作业的指令。这些指令定义在仓库.GitHub/workflows目录中扩展名为.yaml的 YAML 文件中。
您可以为您的仓库定义多个工作流程,以执行不同的自动化过程。例如,您可以有一个工作流程文件来构建和测试您的应用程序,另一个用于将应用程序部署到中央位置。
让我们按照以下步骤在新的仓库中创建一个简单的工作流程文件:
- 从您的仓库中,点击操作菜单。这将带您到GitHub 操作入门页面,如图图 12.2所示。

图 12.2:GitHub 操作入门页面
- 点击自行设置工作流程链接。这将带您到一个新页面,您可以在其中开始编写工作流程,如图图 12.3所示。

图 12.3:创建新工作流程屏幕
现在,我们将创建一个简单的可以用于 GitHub 的工作流程。该工作流程可以在docs.GitHub.com/en/actions/quickstart找到。按照图 12.4所示复制并粘贴工作流程。

图 12.4:一个示例 GitHub 工作流程.yaml 文件
- 通过点击开始提交按钮来提交文件,如图图 12.3所示。在填写完所有提交信息后,点击提交新文件按钮。

图 12.5:.yaml 文件的提交信息
您的仓库现在有一个新的 GitHub 工作流程文件。如果您再次选择操作菜单,这次您将看到屏幕看起来像图 12.6。屏幕显示 GitHub 已成功运行工作流程。

图 12.6:GitHub 已成功运行工作流程
我们可以通过点击创建 main.yaml链接来查看工作流程结果。您将看到输出指示Explore-GitHub-Actions作业已成功运行,如图12.7所示。

图 12.7:Explore-GitHub-Actions 步骤已成功运行
点击Explore-GitHub-Actions作业链接后,输出将如图12.8所示。

图 12.8:Explore-GitHub-Actions 作业输出
我们在本节中创建的工作流程实际上是 GitHub Actions 工作流程。我们将在下一节中更深入地探讨它。
GitHub Actions
什么是 GitHub Actions?它是一个允许您通过自动化构建、测试和部署过程来自动化项目完整集成和交付的平台。GitHub Actions 还赋予您自动化工作流程过程,如拉取请求、问题创建等的能力。
我们现在已成功创建了我们的第一个 GitHub 工作流程。让我们查看工作流程文件,了解我们正在使用哪些 GitHub Actions。我们将使用的工作流程文件如下:
name: GitHub Actions Demo
on: [push]
jobs:
Explore-GitHub-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a
${{ GitHub.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner. os }}
server hosted by GitHub!"
- run: echo "🔎 The name of your branch is ${{ GitHub. ref }} and your repository is ${{ GitHub. repository }}."
- name: Check out repository code
uses: actions/checkout@v3
- run: echo "💡 The ${{ GitHub.repository }} repository has been cloned to the runner."
- run: echo "🖥 The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ GitHub.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
下表解释了文件中的不同配置:
| 配置键 | 说明 |
|---|---|
| Name | 我们为将用作在 Actions 页面查看结果标签的工作流程提供的通用名称。 |
| On | 告知 GitHub 哪种 Git 操作将触发工作流程。在示例中,它是push。这意味着每当在存储库中检测到 Git push操作时,工作流程将被触发。不同的 Git 事件操作可以在 GitHub 文档中查看:docs.GitHub.com/en/actions/using-workflows/triggering-a-workflow#using-events-to-trigger-workflows。 |
| Jobs | 工作流程由一个或多个作业组成。默认情况下,作业会并行运行。作业可以被视为您想在代码上执行的单个任务。在我们的例子中,我们命名作业为Explore-GitHub-Actions,它执行由run配置定义的任务。 |
| runs-on | 定义了我们想要使用的运行者。运行者是您选择在之上运行工作流程的机器。在我们的例子中,我们使用的是ubuntu-latest机器,换句话说,我们想要使用运行最新版 Ubuntu 的机器。完整的运行者列表可以在以下链接中查看:docs.GitHub.com/en/actions/using-jobs/choosing-the-runner-for-a-job。 |
| 步骤 | 每个作业包含一系列称为步骤的任务。步骤是您定义要为工作流程执行的操作的地方。在我们的示例中,我们定义了几个步骤,例如 run,我们只是打印出信息。 |
现在,我们将查看针对示例应用程序的 GitHub Action 工作流程。工作流程可以在 chapter12/.GitHub/workflows/build.yml 文件中找到,如下所示:
name: Build and Package
on:
push:
branches:
- main
pull_request:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.18
- name: Check out code
uses: actions/checkout@v1
- name: Lint Go Code
run: |
curl -sSfL
https://raw.GitHubusercontent.com/golangci/golangci- lint/
master/install.sh | sh -s -- -b $(go env GOPATH)/bin
$(go env GOPATH)/bin/golangci-lint run
build:
name: Build
runs-on: ubuntu-latest
needs: [ lint ]
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.18
- name: Check out code
uses: actions/checkout@v1
- name: Build
run: make build
我们现在将逐行分析,了解工作流程正在做什么。以下代码片段告诉 GitHub,当源代码推送到 main 分支时,将触发工作流程:
name: Build and Package
on:
push:
branches:
- main
下面的代码片段显示了当检测到事件时,GitHub 将运行的不同作业;在这种情况下,是 lint 和 build 作业。作业将在由 runs-on 配置指定的 Ubuntu 机器上运行:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
...
build:
name: Build
runs-on: ubuntu-latest
needs: [ lint ]
steps:
...
定义的工作由以下代码片段中显示的步骤组成:
...
jobs:
lint:
...
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.18
- name: Check out code
uses: actions/checkout@v1
- name: Lint Go Code
run: |
curl -sSfL
https://raw.GitHubusercontent.com/golangci/golangci- lint/
master/install.sh | sh -s -- -b $(go env GOPATH)/bin
$(go env GOPATH)/bin/golangci-lint run
build:
...
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.18
- name: Check out code
uses: actions/checkout@v1
- name: Build
run: make build
对于 lint 作业执行的步骤的解释如下:
-
使用
actions/setup-goGitHub Action 设置 Go 1.18 环境。 -
使用
actions/checkoutGitHub Action 检出源代码。 -
对源代码执行 linting 操作。shell 脚本将安装
golangci-lint工具并使用golangci-lint run命令运行它。
另一个 build 作业将执行以下步骤:
-
使用
actions/setup-goGitHub Action 设置 Go 1.18 环境。 -
使用
actions/checkoutGitHub Action 检出源代码。 -
通过执行
make build命令构建应用程序。
在作业中定义的每个步骤都使用 GitHub Actions 执行不同的操作,例如检出代码、运行 shell 脚本和设置编译 Go 应用程序的环境。
在下一节中,我们将探讨 GitHub Packages 以及如何使用它们来部署我们将为应用程序构建的 Docker 镜像。
发布 Docker 镜像
在开发完您的应用程序后,下一步是部署应用程序,以便用户可以开始使用它。为此,您需要打包您的应用程序。这就是 Docker 出现的地方。Docker 是一个用于将您的应用程序打包到单个文件的工具,使其易于部署到云环境,如 Amazon、Google 等。我们将在 第十三章**,应用程序 Docker 化 中深入了解 Docker 镜像和容器。我们将查看配置 Docker 的文件,称为 Dockerfile。我们将简要地看看这个文件的作用。
Dockerfile
Dockerfile 是用于命名包含构建应用程序镜像指令的文件的默认文件名。Dockerfile 包含 Docker 执行的步骤,以便将您的应用程序打包到 Docker 镜像中。
让我们看看 Chapter12 目录中我们拥有的 Dockerfile:
# 1\. Compile the app.
FROM golang:1.18 as builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o bin/embed
# 2\. Create final environment for the compiled binary.
FROM alpine:latest
RUN apk --update upgrade && apk --no-cache add curl ca-certificates && rm -rf /var/cache/apk/*
RUN mkdir -p /app
# 3\. Copy the binary from step 1 and set it as the default command.
COPY --from=builder /app/bin/embed /app
WORKDIR /app
CMD /app/embed
打包应用程序有三个主要步骤:
-
将我们的 Go 应用程序编译成一个名为
embed的二进制文件。 -
创建一个将用于运行我们的应用程序的环境。在我们的示例中,我们使用一个名为
alpine的环境或操作系统。 -
将第一步中构建的二进制文件复制到我们在第二步中设置的新环境中。
在下一节中,我们将使用 Dockerfile 将镜像存储在 GitHub Packages 中。
GitHub Packages
GitHub Packages 是 GitHub 提供的一项服务,允许开发者托管他们的包。这些包可以通过您的团队访问,或者对公众开放。我们将使用这项服务来发布我们的 Docker 镜像,使其可供公众使用。
在我们可以将 Docker 镜像部署到 GitHub Packages 之前,我们需要设置一些东西。本节将指导您完成设置仓库所需的步骤。在本节中,我们将使用 GitHub.com/nanikjava/golangci 作为参考。
您可以通过点击 包 链接从您的仓库访问 GitHub Packages,如图 图 12**.9 所示。

图 12.9:访问 GitHub Packages
一旦您点击 包 链接,您将看到一个类似于 图 12**.10 的屏幕。由于我们尚未发布任何包,因此将不会显示 包。

图 12.10:GitHub Packages 页面
在下一节中,我们将探讨如何将我们转换成包的 Docker 镜像发布到 GitHub Packages。
发布到 GitHub Packages
安全性是 GitHub 的重要组成部分。为了能够将 Docker 镜像写入 GitHub Packages,让我们尝试了解所需的内容。每次 GitHub 运行工作流程时,都会分配一个临时令牌给工作流程,该令牌可以用作认证密钥,允许 GitHub Actions 执行某些操作。这个密钥在内部被称为 GITHUB_TOKEN。
GITHUB_TOKEN 密钥具有默认权限,可以根据您组织的需要设置为限制性。要查看默认权限,请从您的仓库点击 设置 选项卡,如图 图 12**.11 所示。

图 12.11:设置中的操作菜单
点击 操作 菜单并选择 常规。您将看到默认权限,如图 图 12**.12 所示。如您所见,默认权限是工作流程的 读和写。

图 12.12:GITHUB_TOKEN 默认权限
我们将要查看的工作流程位于 chapter12/.GitHub/workflows/builddocker.yml 内,其外观如下:
name: Build Docker Image
on:
push:
branches:
- main
pull_request:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ GitHub.repository }}
jobs:
push_to_GitHub_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
...
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ GitHub.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME
}}/chapter12:latest
工作流程按照以下步骤顺序执行以发布 Docker 镜像:
-
工作流程使用
docker/login-action@v2GitHub Action 登录到注册表(GitHub Packages)。传递给 GitHub Action 的参数是username、password和registry。 -
username是 GitHub 用户名,它触发工作流程过程。registry参数将是REGISTRY环境变量的值,即- ghcr.io。password字段将自动使用secrets.GITHUB_TOKEN填充。 -
最后一步是使用
docker/build-push-action@v3GitHub Action 构建并发布 Docker 镜像。传递给 GitHub Action 的参数是用于构建 Docker 镜像的文件。在我们的例子中,它被称为Dockerfile。用于标记或标签 Docker 镜像的标签名称将类似于ghcr.io/golangci/chapter12:latest。
现在我们已经设置好了一切,下次你将任何代码更改推送到main分支时,工作流程将运行。一个成功的运行示例可以在图 12.13中看到。

图 12.13:成功运行的工作流程发布 Docker 镜像
Docker 镜像可以在 GitHub Packages 页面上看到,如图图 12.14所示。

图 12.14:GitHub Packages 中的 chapter12 Docker 镜像
在下一节中,我们将查看下载我们新创建的 Docker 镜像并本地使用它。
从 GitHub Packages 拉取
我们已经成功为我们的应用程序设置了 CI。现在,我们必须测试作为 CI 过程一部分运行的 Docker 镜像是否成功构建了我们的应用程序的 Docker 镜像。
我们的 Docker 镜像托管在 GitHub Packages 中,默认情况下是公开的,因为我们的仓库是公开仓库。图 12.14显示了可用的 Docker 镜像,包括拉取镜像到本地的命令。打开你的终端,然后运行以下命令:
docker pull ghcr.io/nanikjava/golangci/chapter12:latest
你将得到以下输出:
latest: Pulling from nanikjava/golangci/chapter12
213ec9aee27d: Already exists
3a904afc80b3: Pull complete
561cc7c7d83b: Pull complete
aee36b390937: Pull complete
4f4fb700ef54: Pull complete
Digest: sha256:a355f55c33a400290776faf20b33d45096eb19a6431fb0b3 f723c17236e8b03e
Status: Downloaded newer image for ghcr.io/nanikjava/golangci/chapter12:latest
镜像已下载到你的本地机器。使用以下命令运行 Docker 镜像:
docker run -p 3333:3333 ghcr.io/nanikjava/golangci/chapter12
当你看到以下输出时,你知道容器正在运行:
2022/08/18 08:03:10 Server Version : 0.0.1
打开你的浏览器,并在浏览器地址栏中输入http://localhost:3333。你会看到登录页面。我们已经成功完成了我们的 CI 过程,并且能够运行我们构建的 Docker 镜像。
摘要
在本章中,我们探讨了持续集成(CI),了解了为什么它很重要,以及通过为项目设置自动化的 CI 流程所获得的益处。我们学习了如何设置 GitHub 仓库以准备我们的 CI 流程,还学习了如何编写 GitHub Actions 工作流程,使我们能够自动化应用程序的多个步骤。
通过使用 GitHub Actions,我们能够将我们的应用程序构建成一个可执行的二进制文件。这在我们每次将代码推送到仓库时都会执行。我们学习了如何为我们的应用程序构建 Docker 镜像,以及将应用程序打包成 Docker 镜像所带来的好处。
我们学习了 GitHub Packages 以及如何配置它,以便我们可以将我们的 Docker 镜像推送到一个中央位置。将我们的应用程序打包成 Docker 镜像使得我们可以在任何地方轻松测试我们的应用程序。我们不必担心构建源代码,因为所有内容都已打包成一个单独的 Docker 镜像文件。
在下一章中,我们将学习如何将我们的应用程序打包成容器,这将使得将其作为单个镜像部署变得容易,并允许我们使用不同的云提供商在云中部署应用程序。
第十三章:将应用程序 Docker 化
在本章中,我们将学习 Docker 以及如何将应用程序打包为 Docker 镜像。了解如何将您的应用程序打包为 Docker 镜像将允许您在任何类型的环境和基础设施中部署应用程序,而无需担心设置构建应用程序的基础设施。构建 Docker 镜像将允许您在任何您喜欢的地方运行您的应用程序:构建一次,部署到任何地方。
在本章中,我们将学习以下关键主题:
-
构建 Docker 镜像
-
运行 Docker 镜像
-
从零开始创建 Docker 镜像
-
理解 Docker 镜像文件系统
-
查看 Docker Compose
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/chapter13中查看。
安装 Docker
Docker 是一个开源平台,用于软件开发,它使得打包和分发程序变得容易。Docker 允许您打包您的应用程序并在不同类型的基础设施(如云环境)中运行。
在本节中,我们将查看如何在本地机器上安装 Docker。不同的操作系统安装 Docker 的步骤不同。请参考 Docker 文档以获取适用于您操作系统的深入安装指南,该指南可在docs.docker.com/engine/install/找到。
注意
本章是在 Linux 机器上编写的,因此概述的大多数命令行应用程序仅适用于 Linux。
在我们的开发机器上安装 Docker 的步骤完成后,以下是一些确保一切正常工作的操作。
使用以下命令来检查 Docker 引擎是否正在运行:
systemctl list-units --type=service --state=running | grep -i docker && systemctl list-units --type=service --state=active | grep -i containerd
如果引擎已正确安装,您将看到以下输出:
docker.service loaded active running Docker Application Container Engine
containerd.service loaded active running containerd container runtime
输出显示了两个不同的服务正在运行 - docker.service 和 containerd.service。containerd.service 服务负责将 Docker 镜像启动到容器中,并确保所有本地机器服务都已设置,以便容器可以在 docker.service 服务管理镜像和与 Docker 命令行工具通信的同时运行。
现在我们知道这两个服务都在运行,让我们使用命令行工具来检查与引擎的通信。使用以下命令与引擎通信以列出所有本地可用的镜像 - 注意您可能需要具有 root 权限才能执行此操作,因此可能需要使用 sudo 前缀:
docker images
在我们的情况下,我们得到如图 图 13.1 所示的输出,显示我们已下载了两个镜像。在您的情况下,如果您是第一次安装 Docker,它将是空的。

图 13.1:本地机器上的 Docker 镜像
我们已经在本地机器上成功完成了 Docker 的安装。在下一节中,我们将更详细地介绍如何使用 Docker 以及理解 Docker 镜像。
使用 Docker
在本节中,我们将探讨如何使用 Docker 进行日常操作。让我们了解使用 Docker 时提到的概念——镜像和容器:
-
Docker 镜像:此镜像是一个包含我们的应用程序的文件,包括所有相关的操作系统文件。
-
容器:镜像文件被 Docker 引擎读取和执行。一旦它在本地机器上运行,它就被称为容器。您可以使用 Docker 命令行工具与容器交互。
我们将使用以下命令查看如何使用 Docker 下载和运行一个简单的Hello World应用程序:
docker run hello-world
打开您的终端并运行以下命令:
$ docker run hello-world
此命令将下载镜像文件(如果本地不存在)并执行它。然后您将看到以下消息:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
2db29710123e: Pull complete
Digest: sha256:10d7d58d5ebd2a652f4d93fdd86da8f265f5318c6a73cc5b 6a9798ff6d2b2e67
Status: Downloaded newer image for hello-world:latest
一旦图像被下载并作为容器运行,它将输出以下内容:
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1\. The Docker client contacted the Docker daemon.
…
…
https://docs.docker.com/get-started/
现在我们已经尝试了如何将镜像文件作为容器运行,我们将在下一节中更深入地探索 Docker 镜像。
Docker 镜像
Docker 镜像文件看起来就像您本地机器上的任何其他文件一样,但它们以 Docker 才能理解的特殊格式存储。在本地,镜像文件存储在/var/lib/docker/image/overlay2目录中。要查看可用的镜像,您可以查看repositories.json文件,其外观如下:
{
"Repositories": {
"hello-world": {
"hello-world:latest":
"sha256:feb5d9fea6a5e9606aa995e879d862b82
5965ba48de054caab5ef356dc6b3412",
"hello-world@sha256:
10d7d58d5ebd2a652f4d93fdd86da8f265f5318c6a7
3cc5b6a9798ff6d2b2e67":
"sha256:feb5d9fea6a5e9606aa995e879d862
b825965ba48de054caab5ef356dc6b3412"
},
"...
"redis": {
"redis:latest":
"sha256:bba24acba395b778d9522a1adf5f0d6bba3e609
4b2d298e71ab08828b880a01b",
"redis@sha256:69a3ab2516b560690e37197b71bc61ba24
5aafe4525ebdec
e1d8a0bc5669e3e2":
"sha256:bba24acba395b778d9522a1adf5f0d6bba3
e6094b2d298e71ab08828b880a01b"
}
}
}
让我们进一步探索托管镜像文件的 Docker 目录。我们可以使用以下命令获取镜像信息:
docker images
以下输出显示了有关hello-world容器的某些信息:
REPOSITORY TAG IMAGE ID CREATED SIZE
..
hello-world latest feb5d9fea6a5 7 months ago 13.3kB
..
hello-world的镜像 ID 是feb5d9fea6a5。让我们尝试使用以下命令在/var/lib/docker中找到镜像文件:
sudo find /var/lib/docker -name 'feb5d9fea6a5*'
我们将得到以下输出:
/var/lib/docker/image/overlay2/imagedb/content/sha256/feb5d9fea 6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412
现在我们使用以下命令查看该文件内部:
sudo cat /var/lib/docker/image/overlay2/imagedb/content/sha256/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412
您将看到以下输出:
{
"architecture": "amd64",
"config": {
…
],
…
},
…
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"/hello\"]"
],
"Image": "sha256:b9935d4e8431fb1a7f0989304ec8
6b3329a99a25f5efdc7f09f3f8c41434ca6d",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": {}
},
"created": "2021-09-23T23:47:57.442225064Z",
"docker_version": "20.10.7",
"history": [
{
…
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:e07ee1baac5fae6a26f30cabfe54a36d3402f96afda3
18fe0a96cec4ca393359"
]
}
}
下表概述了前一个 JSON 输出中一些相关字段的意义:
| 字段名称 | 描述 |
|---|---|
Cmd |
这是在镜像文件作为容器运行时将要执行的命令。对于hello-world示例,当容器启动时,它将执行hello可执行文件。 |
rootfs |
rootfs代表根文件系统,这意味着它包含所有必要的操作系统文件,这些文件是作为正常机器启动所必需的。 |
我们之前看到的 JSON 信息也可以使用以下命令查看:
docker image inspect hello-world:latest
您将得到如下所示输出:
[
{
"Id": "sha256:feb5d9fea6a5e9606aa995e879d862b825
965ba48de054caab5ef356dc6b3412",
"RepoTags": [
"hello-world:latest"
],
"RepoDigests": [
"hello-world@sha256:10d7d58d5ebd2a652
f4d93fdd86da8f265f5318c6a73cc5b6a9798ff6d2b2e67"
],
"Parent": "",
"Comment": "",
"Created": "2021-09-23T23:47:57.442225064Z",
"Container": "8746661ca3c2f215da94e6d3f7dfdcafaff5
ec0b21c9aff6af3dc379a82fbc72",
"ContainerConfig": {
…
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"CMD [\"/hello\"]"
],
"Image": "sha256:b9935d4e8431fb1a7f0989304ec86b
3329a99a25f5efdc7f09f3f8c41434ca6d",
…
},
…
"Architecture": "amd64",
"Os": "linux",
"Size": 13256,
"VirtualSize": 13256,
"GraphDriver": {
"Data": {
"MergedDir":
"/var/lib/docker/overlay2/c0d9b295437ab
cdeb9caeec51dcbde1b11b0aeb3dd9e469f35
7889defed757d9/merged",
"UpperDir":
"/var/lib/docker/overlay2/c0d9b295437ab
cdeb9caeec51dcbde1b11b0aeb3dd9e469f357
889defed757d9/diff",
"WorkDir":
"/var/lib/docker/overlay2/c0d9b295437ab
cdeb9caeec51dcbde1b11b0aeb3dd9e469f357
889defed757d9/work"
},
"Name": "overlay2"
},
…]
输出中一个有趣的信息片段是GraphDriver字段,它指向包含提取的 Docker 镜像的/var/lib/docker/overlay2/c0d9b295437abcdeb9caeec51dcbde1b11b 0aeb3dd9e469f357889defed757d9 目录。对于 hello-world,它将是hello可执行文件,如下所示:
total 16
drwx--x--- 3 root root 4096 Apr 30 18:36 ./
drwx--x--- 30 root root 4096 Apr 30 19:21 ../
-rw------- 1 root root 0 Apr 30 19:21 committed
drwxr-xr-x 2 root root 4096 Apr 30 18:36 diff/
-rw-r--r-- 1 root root 26 Apr 30 18:36 link
查看diff/目录,我们看到以下可执行文件:
drwxr-xr-x 2 root root 4096 Apr 30 18:36 .
drwx--x--- 3 root root 4096 Apr 30 18:36 ..
-rwxrwxr-x 1 root root 13256 Sep 24 2021 hello
现在,我们已经很好地理解了 Docker 镜像是如何在本地存储的,在下一节中,我们将查看如何使用 Docker 在本地作为容器运行镜像。
将镜像作为容器运行
在本节中,我们将查看如何将 Docker 镜像作为容器运行,并检查容器运行时我们可以看到的不同信息。
首先运行一个数据库 Docker 镜像,查看我们可以从容器状态中获得哪些信息。打开终端窗口并运行以下命令以在本地运行 Redis。Redis 是一个开源的基于内存的数据存储,用于存储数据。由于数据存储在内存中,与存储在磁盘上相比,它速度更快。该命令将运行 Redis,监听端口7777:
docker run -p 7777:7777 -v /home/user/Downloads/redis-7.0-rc3/data:/data redis --port 7777
确保将/home/user/Downloads/redis-7.0-rc3/data目录更改为您自己的本地目录,因为 Docker 将使用此目录来存储 Redis 数据文件。
当容器运行时,你会看到以下信息:
1:C 05 May 2022 11:20:08.723 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 05 May 2022 11:20:08.723 # Redis version=6.2.6, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 05 May 2022 11:20:08.723 # Configuration loaded
1:M 05 May 2022 11:20:08.724 * monotonic clock: POSIX clock_gettime
1:M 05 May 2022 11:20:08.724 * Running mode=standalone, port=7777.
…
1:M 05 May 2022 11:20:08.724 * Ready to accept connections
让我们使用 Docker 命令行工具来查看这个容器的运行状态。为了做到这一点,我们需要通过运行docker ps命令来获取容器的 ID;在我们的情况下,输出如下:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e1f58f395d06 redis "docker-entrypoint.s…" 5 minutes ago Up 5 minutes 6379/tcp, 0.0.0.0:7777->7777/tcp, :::7777->7777/tcp reverent_dhawan
Redis 容器的 ID 是e1f58f395d06。使用此信息,我们将使用docker inspect来查看运行容器的不同属性。如下使用docker inspect:
docker inspect e1f58f395d06
你将得到如下所示的信息输出:
[[
{
...
"Mounts": [
{
"Type": "bind",
"Source": "/home/user/Downloads/redis-7.0-
rc3/data",
"Destination": "/data",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
...
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:
/usr/sbin:/usr/bin:/sbin:/bin",
"GOSU_VERSION=1.14",
...
],
...
},
"NetworkSettings": {
...
"Ports": {
"6379/tcp": null,
"7777/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "7777"
},
{
"HostIp": "::",
"HostPort": "7777"
}
]
},
...
"Networks": {
"bridge": {
...
}
}
}
}
]
输出显示了关于 Redis 容器运行状态的大量信息。我们感兴趣的主要是网络和挂载。NetworkSettings部分显示了容器的网络配置,指示主机到容器的网络映射参数——容器正在使用端口7777,并且相同的端口在本地机器上暴露。
另一个有趣的事情是Mounts参数,它指向/home/user/Downloads/redis-7.0-rc3/data到容器内部/data本地主机目录的映射。挂载就像是从容器目录到本地机器目录的重定向。使用挂载确保在容器关闭时所有数据都保存在本地机器上。
我们已经了解了容器是什么以及如何查看容器的运行状态。现在,我们已经对镜像和容器有了很好的理解,我们将在本节中查看如何创建我们自己的镜像。
构建和打包镜像
在上一节中,我们学习了 Docker 镜像以及如何查看运行中容器的状态;我们还了解了 Docker 镜像是如何在本地存储的。在本节中,我们将探讨如何通过编写 Dockerfile 来创建我们自己的 Docker 镜像。
我们将查看 chapter13/embed 文件夹内的示例应用程序的构建。该示例应用程序与我们讨论过的*第四章**,即“服务与嵌入 HTML 内容”中的相同。该应用程序将运行一个监听端口 3333 的 HTTP 服务器,以提供嵌入的 HTML 页面。
我们将使用以下 Dockerfile 来构建 Docker 镜像:
# 1\. Compile the app.
FROM golang:1.18 as builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o bin/embed
# 2\. Create final environment for the compiled binary.
FROM alpine:latest
RUN apk --update upgrade && apk --no-cache add curl ca-certificates && rm -rf /var/cache/apk/*
RUN mkdir -p /app
# 3\. Copy the binary from step 1 and set it as the default command.
COPY --from=builder /app/bin/embed /app
WORKDIR /app
CMD /app/embed
让我们逐步分析命令的不同部分,以了解它在做什么。第一步是使用预构建的 Golang 1.18 Docker 镜像编译应用程序。这个镜像包含构建 Go 应用程序所需的所有工具。我们使用 WORKDIR 命令指定 /app 作为工作目录,并在最后一行使用 COPY 命令复制所有源文件,并使用标准的 go build 命令行编译源代码。
FROM golang:1.18 as builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o bin/embed
在成功编译应用程序后,下一步是准备将托管应用程序的运行时环境。在这种情况下,我们使用预构建的 Alpine Linux 操作系统 Docker 镜像。Alpine 是一个在尺寸上较小的 Linux 发行版,这使得它在创建用于在应用程序上运行的 Docker 镜像时非常理想。
接下来,我们想要做的是通过使用 -update upgrade 命令确保操作系统是最新的。这确保了操作系统包含所有最新的更新,包括安全更新。最后一步是创建一个新的 /app 目录,用于存储应用程序的二进制文件:
FROM alpine:latest
RUN apk --update upgrade && apk --no-cache add curl ca-certificates && rm -rf /var/cache/apk/*
RUN mkdir -p /app
最后一步是将上一步创建的标记为 builder 的二进制文件复制到新的 /app 目录中。CMD 命令指定了当 Docker 镜像作为容器执行时要运行的命令——在这种情况下,我们想要运行由参数 /app/embed 指定的示例应用程序 embed:
COPY --from=builder /app/bin/embed /app
WORKDIR /app
CMD /app/embed
现在我们已经了解了 Dockerfile 所做的工作,让我们创建 Docker 镜像。使用以下命令构建镜像:
docker build --tag chapter13 .
你将看到如下所示的输出,展示了 Docker 构建镜像时进行的不同步骤和过程:
Sending build context to Docker daemon 29.7kB
Step 1/10 : FROM golang:1.18 as builder
---> 65b2f1fa535f
Step 2/10 : WORKDIR /app
---> Using cache
---> 7ab996f8148c
…
Step 5/10 : FROM alpine:latest
---> 0ac33e5f5afa
…
Step 8/10 : COPY --from=builder /app/bin/embed /app
…
Step 10/10 : CMD /app/embed
---> Using cache
---> ade99a01b92e
Successfully built ade99a01b92e
Successfully tagged chapter13:latest
一旦你收到“成功标记”的消息,构建过程就完成了,镜像已准备好在你的本地机器上使用。
新镜像将被标记为 chapter13,当我们使用 docker images 命令时,它将看起来如下:
REPOSITORY TAG IMAGE ID CREATED SIZE
…
chapter13 latest ade99a01b92e 33 minutes ago 16.9MB
…
golang 1.18 65b2f1fa535f 14 hours ago 964MB
…
hello-world latest feb5d9fea6a5 7 months ago 13.3kB
使用以下命令运行新创建的镜像:
docker run -p 3333:3333 chapter13
该命令将以容器形式运行镜像,并使用 -p 端口参数,将容器内的端口 3333 映射到主机上的相同端口 3333。打开你的浏览器,输入 http://localhost:3333,你将看到如图 图 13**.2 所示的 HTML 登录页面:

图 13.2:从 Docker 容器中提供 Web 应用程序
在下一节中,我们将了解 Docker Compose。
Docker Compose
Docker 提供了另一个名为 Docker Compose 的工具,允许开发者同时运行多个容器。考虑一下这样的用例,你正在构建一个需要临时内存存储来存储购物车信息的服务器;这需要使用外部应用程序,如 Redis,它提供了一个内存数据库。
在这种场景下,我们的应用程序依赖于 Redis 正常运行,这意味着我们需要在运行应用程序的同时运行 Redis。还有许多其他不同类型的用例,其中将需要使用 Docker Compose。Docker Compose 文档提供了如何在本地机器上安装它的完整分步指南:docs.docker.com/compose/install/。
Docker Compose 实际上是一个文件,概述了我们想要使用的不同容器。让我们尝试运行位于 chapter13/embed 文件夹内的示例 Docker Compose 文件。打开终端并确保你位于 chapter13/embed 文件夹中,然后执行以下命令:
docker compose -f compose.yaml up
你将得到以下输出:
[+] Running 7/7
⠿ cache Pulled 11.6s
⠿ 213ec9aee27d Already exists 0.0s
⠿ c99be1b28c7f Pull complete 1.4s
⠿ 8ff0bb7e55e3 Pull complete 1.8s
⠿ 477c33011f3e Pull complete 4.8s
⠿ 2bbc51a93257 Pull complete 4.8s
⠿ 2d27eae19281 Pull complete 4.9s
[+] Building 7.3s (15/15) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 491B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/alpine:latest 0.0s
=> [internal] load metadata for docker.io/library/golang:1.18 0.0s
=> [builder 1/4] FROM docker.io/library/golang:1.18 0.3s
=> [stage-1 1/5] FROM docker.io/library/alpine:latest 0.1s
=> [internal] load build context 0.2s
=> => transferring context: 18.81kB 0.0s
=> [stage-1 2/5] RUN apk --update upgrade && apk --no-cache add curl ca-certificates && rm -rf /var/cache/apk/* 5.5s
=> [builder 2/4] WORKDIR /app 0.2s
=> [builder 3/4] COPY . . 0.1s
=> [builder 4/4] RUN CGO_ENABLED=0 GOOS=linux go build -a -o bin/embed 6.4s
=> [stage-1 3/5] RUN mkdir -p /app 1.4s
=> [stage-1 4/5] COPY --from=builder /app/bin/embed /app 0.1s
=> [stage-1 5/5] WORKDIR /app 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:84621b13c179c03eed57a23c66974659eae 4b50c97e3f8af13de99db1adf4c06 0.0s
=> => naming to docker.io/library/embed-server 0.0s
[+] Running 3/3
⠿ Network embed_default Created 0.1s
⠿ Container embed-cache-1 Created 0.1s
⠿ Container embed-server-1 Created 0.1s
Attaching to embed-cache-1, embed-server-1
embed-server-1 | 2022/09/10 06:24:30 Server Version : 0.0.1
embed-cache-1 | 1:C 10 Sep 2022 06:24:30.898 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
embed-cache-1 | 1:C 10 Sep 2022 06:24:30.898 # Redis version=7.0.4, bits=64, commit=00000000, modified=0, pid=1, just started
...
embed-cache-1 | 1:M 10 Sep 2022 06:24:30.899 * Running mode=standalone, port=6379.
embed-cache-1 | 1:M 10 Sep 2022 06:24:30.899 # Server initialized
...
embed-cache-1 | 1:M 10 Sep 2022 06:24:30.899 * Loading RDB produced by version 6.2.7
embed-cache-1 | 1:M 10 Sep 2022 06:24:30.899 * RDB age 10 seconds
...
embed-cache-1 | 1:M 10 Sep 2022 06:24:30.899 * Ready to accept connections
一切运行起来后,你应该能够通过打开浏览器并在地址栏中输入 http://localhost:3333 来访问服务器。
Docker Compose 文件如下所示:
version: '3'
services:
server:
build: .
ports:
- "3333:3333"
cache:z
image: redis:7.0.4-alpine
restart: always
ports:
- '6379:6379'
该文件概述了需要运行的两个容器——服务器指向我们的应用程序服务器,而 build 参数使用的是 . 点表示法。这告诉 Docker Compose,构建此容器镜像的源(Dockerfile)位于本地目录中,而缓存服务是一个 Redis 服务器,它将从 Docker 远程仓库中拉取,具体版本为 7.0.4。
摘要
在本章中,我们学习了 Docker 是什么以及如何使用它。构建应用程序是谜题的一部分,但要将它们打包以部署到云环境中,则需要开发者了解 Docker 以及如何为他们的应用程序构建 Docker 镜像。我们探讨了 Docker 在本地机器上存储镜像的方式,并检查了运行容器的状态。
我们了解到,当容器运行时,会产生大量信息,这些信息可以帮助我们了解容器运行的情况以及运行应用程序所使用的参数。我们还学习了 Dockerfile 的相关知识,并使用它将我们的示例应用程序打包进容器中,以便作为一个单独的 Docker 镜像运行。
在下一章中,我们将通过将我们的镜像部署到云环境中来应用本章所获得的知识。
第十四章:云部署
在本章中,我们将学习关于云部署的知识,特别是使用 AWS 作为云提供商。我们将查看 AWS 提供的一些基础设施服务以及如何使用它们。我们将学习如何使用和编写代码来创建不同的 AWS 基础设施服务,使用一个名为 Terraform 的开源工具。了解云以及云部署的工作原理已经成为开发者的必需品,而不是例外。深入了解云部署的不同方面将使您能够跳出思维定式,思考您的应用程序如何在云中运行。
完成本章后,我们将学习以下关键主题:
-
学习基本的 AWS 基础设施
-
理解和使用 Terraform
-
编写用于本地和云部署的 Terraform
-
部署到 AWS 弹性容器服务
本章的最终目标是为您提供有关云以及如何执行某些基本操作以部署应用程序到云的一些知识。
技术要求
本章中解释的所有源代码都可以在github.com/PacktPublishing/Full-Stack-Web-Development-with-Go/tree/main/chapter14上查看。
本章使用 AWS 服务,因此您预计将拥有一个 AWS 账户。AWS 为新用户提供免费层;更多信息可以在aws.amazon.com/free找到。
注意
使用任何类型的 AWS 服务都会产生费用。请在使用服务之前阅读并了解相关信息。我们强烈建议您阅读 AWS 网站上关于免费层的信息。
AWS 复习
AWS代表Amazon Web Services,属于亚马逊公司,该公司提供电子商务平台amazon.com.au。AWS 提供的服务允许组织在其完整的基础设施中运行其应用程序,而不需要拥有任何所需的硬件。
对于开发者来说,AWS 品牌是一个家喻户晓的名字,几乎所有开发者都直接或间接地接触过使用 AWS 工具或其服务。在本节中,我们将回顾 AWS 提供的一些服务。
我们心中浮现的问题是,为什么还要使用 AWS 这样的服务?图 14.1很好地总结了答案。AWS 提供的服务遍布世界各地的不同大陆,并准备好供组织使用以满足其需求。想象一下,如果您的组织在不同大陆都有客户,那么在没有在每个大陆投资硬件的负担的情况下,在各个大陆上运行您的应用程序会容易多少?

图 14.1:全球 AWS 区域
在下一节中,我们将探讨 AWS 提供的基本服务,称为 AWS EC2,它提供计算资源。
亚马逊弹性计算云
亚马逊弹性计算云(EC2)是开发者在其上运行应用程序的基本计算资源。您可以选择在 Amazon 基础设施上的某个位置运行的计算机配置,从 512-MB 内存的小型计算机到具有不同存储配置的 384-GB 内存的大型计算机。图 14.2显示了可以使用以下 URL 访问的实例类型探索器:aws.amazon.com/ec2/instance-explorer/。

图 14.2:实例类型探索器
在下一节中,我们将探讨另一个与计算相关的 AWS 资源,这对应用程序来说非常重要,那就是存储。
存储
计算能力非常适合运行应用程序,但应用程序需要长期存储来存储诸如日志文件和数据库之类的数据。AWS 提供了多种不同的存储类型。例如,图 14.3显示了弹性块存储(EBS),这是一种块存储服务。这种块存储类似于您在本地计算机上拥有的普通存储,提供为硬盘或固态硬盘(SSD)。

图 14.3:EBS
拥有这种存储方式的惊人之处在于其弹性特性——这意味着您可以在需要时随时增加或减少存储大小,而无需担心添加新的硬件。想象一下,如果您在本地计算机上快用完硬盘空间会发生什么。您需要购买一块新硬盘并安装和配置它,而使用 AWS 存储服务时,这些都不需要。
我们将探讨另一个与 AWS 服务一样重要的服务:网络。
虚拟专用云
现在您的应用程序正在自己的虚拟计算机上运行,包括存储,接下来的问题是我们在 AWS 中如何配置网络,以便用户可以访问应用程序。这被称为虚拟专用云(VPC)。将 VPC 视为您自己的网络设置,但没有电缆——所有配置和运行都是通过软件完成的。图 14.4显示了 VPC 的强大功能,使您能够连接在不同区域配置的不同网络。
将区域视为 AWS 存储其硬件的物理位置,如果您在不同的物理位置运行应用程序,您可以使用 VPC 将它们连接起来。

图 14.4:虚拟专用网络
您可以对应用程序运行在每个区域的网络进行完全控制,这些区域如何与您的网络通信,以及您的应用程序如何通过公共互联网访问。
在下一节中,我们将探讨许多应用程序都需要的重要服务之一,即存储在数据库中的数据。
数据库存储
无论您正在构建什么类型的应用程序,您都需要数据库来存储数据,这需要运行数据库服务器。AWS 提供了不同的数据库服务,从存储少量数据的服务到跨不同大陆的广泛分布的数据库。其中一项服务称为 Amazon 关系数据库服务(RDS),这是一种托管服务,用于设置、扩展和运行数据库。
RDS 可以支持的数据库包括 MySQL、PostgreSQL、MariaDB、Oracle 和 SQL Server。图 14**.5概述了 RDS 提供的功能。

图 14.5:RDS
弹性容器服务
在第十三章,“将应用程序 Docker 化”,我们学习了如何创建 Docker 镜像来打包我们的应用程序,使其可以作为容器运行。将应用程序打包为 Docker 镜像允许我们在任何环境中运行我们的应用程序,从本地机器到云端。AWS 提供了一个相关服务,称为弹性容器服务(ECS)。
ECS 帮助我们部署、管理和扩展已构建为容器的应用程序。ECS 的一个关键扩展功能是使用应用程序自动扩展功能来扩展您的应用程序。此功能允许开发人员根据某些条件扩展应用程序,例如以下内容:
-
步骤扩展:这意味着根据警报的触发来扩展应用程序
-
计划扩展:这是基于预定时间的扩展
AWS 工具
AWS 提供了不同的方式来使用其服务,包括 Web 用户界面和命令行界面(CLI)。Web UI 的主页可以在图 14**.6中看到。在使用任何 AWS 工具之前,您需要先注册 AWS 账户。
UI 是一个非常好的起点,可以探索不同的服务,并通过一些示例教程来更好地理解每个服务。

图 14.6:AWS web UI
另一个用于与 AWS 服务交互的 AWS 工具是 CLI,需要本地安装(docs.aws.amazon.com/cli/latest/userguide/getting-started-install.xhtml)。CLI 比 Web UI 更容易与 AWS 服务交互。如果您已本地安装,当您从终端运行aws时,您将看到以下输出:
usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters]
To see help text, you can run:
aws help
aws <command> help
aws <command> <subcommand> help
aws: error: the following arguments are required: command
在下一节中,我们将探讨如何使用这里描述的一些功能在 AWS 中部署我们的应用程序。
理解和使用 Terraform
在本节中,我们将探讨另一个使我们可以更轻松地与 AWS 服务一起工作的工具:Terraform。在前一节中,我们了解到 AWS 提供了自己的工具,这对于小任务来说很棒,但一旦开始组合不同的服务,使用起来就变得困难了。
什么是 Terraform?
Terraform (www.terraform.io/) 是一个开源工具,它提供 基础设施即代码 (IaC)。这意味着您编写代码来定义您想要使用的服务类型以及您想要如何使用它,这样,您就可以将不同的服务组合并链接在一起作为一个单一的整体。这使得作为开发者的您能够作为一个单元运行和销毁基础设施,而不是单独的片段。
Terraform 提供的另一个好处是能够像正常的应用程序代码一样对基础设施代码进行版本控制,在将基础设施部署到生产之前,它将经过正常的审查流程,包括同行评审流程和单元测试。这样,您的应用程序和基础设施现在将经历相同的发展过程,这是可追踪的。
安装 Terraform
Terraform 的安装过程很简单:您可以在 HashiCorp 文档中找到适用于您操作系统的完整指令集,网址为 www.terraform.io/downloads。
例如,在编写这本书时,我们使用的是基于 Ubuntu 的发行版,因此我们下载了来自 releases.hashicorp.com/terraform/1.3.0/terraform_1.3.0_linux_amd64.zip 的 AMD64 二进制文件,并将 Terraform 目录添加到我们的 PATH 中,如下面的代码片段所示。添加到 PATH 环境变量的目录是用于您所使用的终端的临时解决方案。为了存储它,您需要将其作为您 shell 脚本的一部分(对于 Linux,如果您使用 Bash,可以将此添加到您的 .bashrc 文件中):
export PATH=$PATH:/home/user/Downloads/
要测试安装是否成功,请打开终端并执行 Terraform:
Terraform
您应该得到以下输出:
Usage: terraform [global options] <subcommand> [args]
The available commands for execution are listed below.
The primary workflow commands are given first, followed by
less common or more advanced commands.
Main commands:
init Prepare your working directory for other
commands
...
All other commands:
console Try Terraform expressions at an interactive
command prompt
fmt Reformat your configuration in the standard
style
...
有关如何在您的环境中安装 Terraform 的详细信息,请参阅 developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli。
现在我们已经完成了 Terraform 的安装,我们将学习如何使用 Terraform 中的一些基本命令。这些命令将帮助你快速开始云部署之旅。
Terraform 基本命令
在本节中,我们将学习一些在编写代码时经常使用的 Terraform 基本命令。我们还将检查与 Terraform 相关的概念。
init 命令
每次我们开始编写 Terraform 代码时,我们运行的第一条命令是terraform init。此命令准备运行代码所需的全部必要依赖项。该命令执行以下步骤:
-
下载代码中使用的所有必要模块。
-
初始化代码中使用的插件。例如,如果代码部署在 AWS 上,它将下载 AWS 插件。
-
创建一个名为锁文件的文件,以注册代码使用的不同依赖项和版本。
为了更好地理解前面的步骤,让我们运行该命令。打开终端并切换到chapter14/simple目录,并执行以下命令:
terraform init
你将看到以下输出:
Initializing the backend...
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 2.16.0"...
- Installing kreuzwerker/docker v2.16.0...
- Installed kreuzwerker/docker v2.16.0 (self-signed, key ID BD080C4571C6104C)
...
一旦init过程完成,你的目录将看起来如下所示:
.
├── main.tf
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── kreuzwerker
│ └── docker
│ └── 2.16.0
│ └── linux_amd64
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ └── terraform-provider-docker_v2.16.0
├── .terraform.lock.hcl
└── versions.tf
.terraform目录包含代码中指定的依赖项。在这个例子中,它使用kreuzwerker/docker插件,该插件用于运行 Docker 容器。
.terraform.lock.hcl文件包含依赖项的版本信息,其外观如下:
# This file is maintained automatically by "terraform
# init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/kreuzwerker/docker" {
version = "2.16.0"
constraints = "~> 2.16.0"
hashes = [
"h1:OcTn2QyCQNjDiJYy1vqQFmz2dxJdOF/2/HBXBvGxU2E=",
...
]
}
计划命令
plan命令用于帮助我们了解 Terraform 将要执行的执行计划。这是一个非常重要的功能,因为它让我们能够看到将对我们的基础设施执行哪些更改。这将让我们更好地了解代码将影响基础设施的哪些部分。与 Chef 或 Ansible 等工具不同,Terraform 有趣的地方在于它将趋向于目标状态,并且只进行达到该状态的必要更改。例如,如果你有一个五个 EC2 实例的目标,但 Terraform 只知道三个,它将采取达到五个目标的必要步骤。
打开终端,切换到chapter14/simple目录,并执行以下命令:
terraform plan
你将得到以下输出:
...
Terraform will perform the following actions:
# docker_container.nginx will be created
+ resource "docker_container" "nginx" {
+ attach = false
+ bridge = (known after apply)
+ command = (known after apply)
+ container_logs = (known after apply)
+ entrypoint = (known after apply)
+ env = (known after apply)
+ exit_code = (known after apply)
...
+ remove_volumes = true
+ restart = "no"
+ rm = false
+ security_opts = (known after apply)
+ shm_size = (known after apply)
+ start = true
+ stdin_open = false
+ tty = false
+ healthcheck {
+ interval = (known after apply)
+ retries = (known after apply)
+ start_period = (known after apply)
+ test = (known after apply)
+ timeout = (known after apply)
}
+ labels {
+ label = (known after apply)
+ value = (known after apply)
}
+ ports {
+ external = 8000
+ internal = 80
+ ip = "0.0.0.0"
+ protocol = "tcp"
}
}
# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ id = (known after apply)
...
+ repo_digest = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
...
输出显示将有2项添加,0项更改或销毁操作,这告诉我们这是我们第一次运行代码,或者它仍然很新。
应用命令
运行 Terraform 的正常流程是在init之后运行apply(然而,如果我们不确定影响,我们会使用前面显示的plan命令)。打开终端,切换到chapter14/simple目录,并执行以下命令:
terraform apply –auto-aprove
你将得到以下输出:
...
Terraform will perform the following actions:
# docker_container.nginx will be created
+ resource "docker_container" "nginx" {
+ attach = false
+ bridge = (known after apply)
...
}
# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ id = (known after apply)
...
}
Plan: 2 to add, 0 to change, 0 to destroy.
docker_image.nginx: Creating...
docker_image.nginx: Still creating... [10s elapsed]
docker_image.nginx: Creation complete after 17s [id=sha256:2d389e545974d4a93ebdef09b650753a55f72d1ab4518d17a 30c0e1b3e297444nginx:latest]
docker_container.nginx: Creating...
docker_container.nginx: Creation complete after 2s [id=d0c94bd4 b548e6a19c3afb907a777bcb602e965bc05db8ef6d0d380601bb0694]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
如输出所示,nginx容器将被下载(如果尚未存在)然后运行。一旦命令成功运行,你可以通过打开浏览器并访问 http://localhost:8080 来测试它。你将看到类似图 14.7的内容。

图 14.7:nginx 在容器中运行
销毁命令
我们将要查看的最后一个命令是destroy。正如其名所示,它用于销毁使用apply命令创建的基础设施。如果你不确定代码对你的基础设施的影响,请谨慎使用此命令。在运行此命令之前使用plan命令以获得更好的可见性,了解将要从基础设施中移除的内容。
打开终端,从chapter14/simple目录运行以下命令:
Terraform destroy –auto-approve
你将得到以下输出:
docker_image.nginx: Refreshing state... [id=sha256:2d389e545974d4a93ebdef09b650753a55f72d1ab4518d17a30c 0e1b3e297444nginx:latest]
docker_container.nginx: Refreshing state... [id=9c46cff8 1a27edb6aba08a448d715599c644aaa79b192728016db0d903da9fb0]
...
Terraform will perform the following actions:
# docker_container.nginx will be destroyed
- resource "docker_container" "nginx" {
- attach = false -> null
- command = [
- "nginx",
- "-g",
- "daemon off;",
] -> null
- cpu_shares = 0 -> null
…
}
# docker_image.nginx will be destroyed
- resource "docker_image" "nginx" {
- id =
"sha256:2d389e545974d4a93ebdef09b650753a55f7
2d1ab4518d17a30c0e1b3e297444nginx:latest" ->
null
- keep_locally = false -> null
- latest =
"sha256:2d389e545974d4a93ebdef09b650753a55f72
d1ab4518d17a30c0e1b3e297444" -> null
- name = "nginx:latest" -> null
- repo_digest =
"nginx@sha256:0b970013351304af46f322da126351
6b188318682b2ab1091862497591189ff1" -> null
}
Plan: 0 to add, 0 to change, 2 to destroy.
docker_container.nginx: Destroying... [id=9c46cff81a27edb6aba 08a448d715599c644aaa79b192728016db0d903da9fb0]
docker_container.nginx: Destruction complete after 1s
docker_image.nginx: Destroying... [id=sha256:2d389e545974d4a93 ebdef09b650753a55f72d1ab4518d17a30c0e1b3e297444nginx:latest]
docker_image.nginx: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.
在输出中,我们可以看到有2个基础设施被破坏 – 一个是从内存中移除的容器,另一个是从本地 Docker 注册表中移除的镜像。
-auto-approve命令用于自动批准步骤;通常,如果不使用此命令,Terraform 将在每个步骤停止执行并要求用户输入Yes或No以继续。这是一项预防措施,以确保用户确实想要销毁基础设施。
在下一节中,我们将查看编写 Terraform 代码以及它是如何使用提供者的。我们将查看一些 Terraform 示例,以了解它是如何为部署应用程序启动不同的 AWS 基础设施服务的。
在 Terraform 中进行编码
Terraform 的创建者 HashiCorp 创建了HashiCorp 配置语言(HCL),它用于编写 Terraform 代码。HCL 是一种具有循环、if 语句、变量和逻辑流程等通常在编程语言中找到的功能的函数式编程语言。完整的 HCL 文档可以在www.terraform.io/language/找到。
提供者
Terraform 之所以被广泛使用,是因为公司及开源社区提供了大量的扩展;这些扩展被称为提供者。提供者是一段软件,它与其他云提供商和云中的其他资源进行交互。我们将查看 Terraform 代码,以了解更多关于提供者的信息。以下代码片段可以在chapter14/simple目录中找到:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 2.16.0"
}
}
}
resource "docker_image" "nginx" {
name = "nginx:latest"
keep_locally = false
}
resource "docker_container" "nginx" {
image = docker_image.nginx.name
name = "hello-terraform"
ports {
internal = 80
external = 8000
}
}
代码中的resource块可以用来声明基础设施或 API。在这个例子中,我们使用的是 Docker,具体是docker_image和docker_container。当 Terraform 运行代码时,它会检测到required_providers块,该块用于定义提供者。提供者是一个外部模块,代码将使用它,并且 Terraform 将从中央仓库自动下载它。在我们的例子中,我们使用的提供者是kreuzwerker/docker Docker 提供者。有关此提供者的更多信息,请参阅以下链接:registry.terraform.io/providers/kreuzwerker/docker/。
打开终端,确保你位于chapter14/simple目录中,然后运行以下命令:
terraform init
您将在终端中看到以下输出:
Initializing the backend...
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 2.16.0"...
- Installing kreuzwerker/docker v2.16.0...
- Installed kreuzwerker/docker v2.16.0 (self-signed, key ID BD080C4571C6104C)
...
Terraform 下载提供程序并将其存储在 chapter14/simple/.terraform 文件夹中。现在,让我们运行示例代码并查看结果,通过在相同的终端中运行以下命令:
terraform apply -auto-approve
您将看到以下输出:
…
# docker_container.nginx will be created
+ resource "docker_container" "nginx" {
+ attach = false
...
}
# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ id = (known after apply)
…
}
Plan: 2 to add, 0 to change, 0 to destroy.
…
docker_image.nginx: Creation complete after 22s [id=sha256:2d389e545974d4a93ebdef09b650753a55f72d1ab4518d17a 30c0e1b3e297444nginx:latest]
docker_container.nginx: Creating...
docker_container.nginx: Creation complete after 2s [id=b860780 af83a4c719a916b87171d96801cc2243a61242354815f6d82dc6a5e40]
打开您的浏览器并转到 http://localhost:8000。您将看到类似 图 14.7 的内容。
Terraform 自动将 nginx Docker 镜像下载到您的本地机器,并使用 ports 代码块中定义的端口(端口 8000)运行 nginx 容器。要销毁正在运行的容器并从 Docker 仓库中删除镜像,您只需运行以下命令:
terraform destroy -auto-approve
如果您将手动使用 Docker 命令执行相同操作的步骤进行比较,会发现它更复杂且容易出错;使用 Terraform 编写可以使运行和删除容器通过单个命令变得容易得多。
在下一节中,我们将探索更多示例,以更好地了解如何使用 Terraform 部署应用程序。
Terraform 示例
在接下来的几节中,我们将探讨不同的使用 Terraform 的方式,例如从 GitHub 拉取镜像并在本地运行,或者构建和发布 Docker 镜像。
注意
确保每次运行创建 AWS 资源的 Terraform 示例时,都要记得使用 terraform destroy 命令来销毁资源。
在 AWS 中创建的所有资源都会产生费用,通过销毁它们,您将确保不会有意外费用。
从 GitHub Packages 拉取
本节的示例代码位于 chapter14/github 文件夹中。以下片段来自 pullfromgithub.tf:
#script to pull chapter12 image and run it locally
#it also store the image locally
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 2.13.0"
}
}
}
data "docker_registry_image" "github" {
name = "ghcr.io/nanikjava/golangci/chapter12:latest"
}
resource "docker_image" "embed" {
...
}
resource "docker_container" "embed" {
...
}
代码的主要目标是下载我们在 第十二章 中构建的 Docker 镜像,即 构建持续集成。一旦下载了 Docker 镜像,它将在本地运行。打开您的终端,确保您位于 chapter14/github 目录中,并运行以下命令:
terraform init
然后运行以下命令:
terraform apply -auto-approve
您将在终端中看到类似以下内容的输出:
…
data.docker_registry_image.github: Reading...
data.docker_registry_image.github: Read complete after 1s [id=sha256:a355f55c33a400290776faf20b33d45096eb19a6431fb 0b3f723c17236e8b03e]
…
# docker_container.embed will be created
+ resource "docker_container" "embed" {
+ attach = false
…
+ ports {
+ external = 3333
+ internal = 3333
…
}
}
# docker_image.embed will be created
+ resource "docker_image" "embed" {
…
+ name =
"ghcr.io/nanikjava/golangci/chapter12:latest"
…
}
Plan: 2 to add, 0 to change, 0 to destroy.
… [id=sha256:684e34e77f40ee1e75bfd7d86982a4f4fae1dbea3286682af 3222a270faa49b7ghcr.io/nanikjava/golangci/chapter12:latest]
docker_container.embed: Creation complete after 7s [id=f47d1ab90331dd8d6dd677322f00d9a06676b71dda3edf2cb2e66 edc97748329]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
打开您的浏览器并转到 http://localhost:3333。您将看到示例应用的登录页面。
代码使用与上一节中讨论的相同的 docker 提供程序,我们使用新的 docker_registry_image 命令来指定从哪里下载 Docker 镜像的地址,在这种情况下是从 ghcr.io/nanikjava/golangci/chapter12:latest GitHub 包中下载。
我们使用的另一个 HCL 功能是 data 块,如下所示:
...
data "docker_registry_image" "github" {
name = "ghcr.io/nanikjava/golangci/chapter12:latest"
}
...
data 块与 resource 类似,但它仅用于读取值,而不是创建或销毁资源,或者获取将作为其他资源的内部配置使用的数据。在我们的示例中,它被 docker_image 资源使用,如下所示:
resource "docker_image" "embed" {
keep_locally = true
name = "${data.docker_registry_image.github.name}"
}
AWS EC2 设置
在之前的示例中,我们探讨了使用 Docker 提供者本地运行 Docker 容器。在这个示例中,我们将探讨创建 AWS 资源,特别是 EC2 实例。EC2 实例基本上是一个虚拟机,可以通过一定的配置初始化,在云中运行以托管您的应用程序。
为了在 AWS 中创建资源,您首先需要已经有一个 AWS 账户。如果您没有 AWS 账户,您可以在aws.amazon.com/创建一个。一旦您的 AWS 账户准备就绪,登录 AWS 网站,在主控制台(图 14.6)网页上,点击右侧的您的名字,它将显示一个下拉菜单,如图图 14.8所示。然后点击安全凭证。

图 14.8:安全凭证选项
您的浏览器现在将显示身份和访问管理(IAM)页面,如图图 14.9所示。选择访问密钥(访问密钥 ID 和秘密访问密钥)选项。由于您尚未创建任何密钥,因此它将是空的。点击创建新访问密钥按钮,按照指示创建一个新的密钥。

图 14.9:访问密钥部分
完成步骤后,您将获得两个密钥——访问密钥 ID 和秘密访问密钥。请妥善保管这些密钥,因为它们就像您用于在 AWS 基础设施中创建资源的用户名和密码组合一样。
现在您已经拥有了所需的密钥,您现在可以打开一个终端,切换到chapter14/simpleec2目录,并按照以下方式运行示例:
terraform init
接下来,运行以下命令创建 EC2 实例:
terraform apply -var="aws_access_key=xxxx" -var="aws_secret_key=xxx" -auto-approve
完成后,您将看到以下输出:
...
Terraform will perform the following actions:
# aws_instance.app_server will be created
+ resource "aws_instance" "app_server" {
+ ami = "ami-0ff8a91507f77f867"
...
}
# aws_subnet.default-subnet will be created
+ resource "aws_subnet" "default-subnet" {
...
}
# aws_vpc.default-vpc will be created
+ resource "aws_vpc" "default-vpc" {
+ arn = (known after apply)
...
}
Plan: 3 to add, 0 to change, 0 to destroy.
...
aws_instance.app_server: Creation complete after 24s [id=i-0358d1df58e055d70]
输出结果显示创建了三个资源——AWS 实例(EC2)、一个 IP 子网和 VPC 网络。现在,让我们看一下代码(完整的代码可以在chapter14/simpleec2目录中查看)。该代码需要您的 AWS 密钥,将它们存储在variable块中,分别命名为aws_access_key和aws_secret_key:
terraform {
...
}
variable "aws_access_key" {
type = string
}
variable "aws_secret_key" {
type = string
}
provider "aws" {
region = "us-east-1"
access_key = var.aws_access_key
secret_key = var.aws_secret_key
}
这些密钥将被传递给aws提供者,以便提供者可以使用我们的密钥与 AWS 服务进行通信。
以下代码部分创建 VPC 和 IP 子网,这些子网将被 EC2 实例用作私有网络:
resource "aws_vpc" "default-vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
env = "dev"
}
}
resource "aws_subnet" "default-subnet" {
cidr_block = "10.0.0.0/24"
vpc_id = aws_vpc.default-vpc.id
}
代码定义的最后一个资源是 EC2 实例,如下所示:
resource "aws_instance" "app_server" {
ami = "ami-0ff8a91507f77f867"
instance_type = "t2.nano"
subnet_id = aws_subnet.default-subnet.id
tags = {
Name = "Chapter14"
}
}
EC2 实例类型是t2.nano,这是可以配置的最小虚拟机。它通过将子网 ID 分配给subnet_id参数,与之前定义的 IP 子网相连接。
使用负载均衡器部署到 ECS
我们将要查看的最后一个示例是使用 AWS ECS。源代码可以在 chapter14/lbecs 目录中找到。代码将使用 ECS 来部署我们托管在 GitHub Packages 中的 第十二章 容器,并通过使用负载均衡器使其可扩展。图 14.9 展示了运行代码后的基础设施配置。

图 14.10:带有负载均衡器的 ECS
代码使用了以下服务:
-
互联网网关:正如其名所示,这是一个网关,它允许在 AWS VPC 私有网络和互联网之间建立通信。借助网关,我们将我们的应用程序向世界开放。
-
负载均衡器:这项服务帮助在不同配置的网络之间平衡传入流量,确保应用程序可以处理所有传入请求。
ECS 提供了扩展容器部署过程的能力。这意味着作为开发者,我们不必担心如何扩展运行我们应用程序的容器,因为这一切都由 ECS 来处理。更深入的信息可以在 aws.amazon.com/ecs/ 找到。应用程序的运行方式与之前的示例相同,使用 terraform init 和 terraform apply 命令。
注意
与其他示例相比,ECS 示例的执行时间要长一些。
你将得到如下所示的输出:
...
Terraform will perform the following actions:
# aws_default_route_table.lbecs-subnet-default-route-
# table will be created
+ resource "aws_default_route_table"
"lbecs-subnet-default-route-table" {
...
}
# aws_ecs_cluster.lbecs-ecs-cluster will be created
+ resource "aws_ecs_cluster" "lbecs-ecs-cluster" {
...
}
# aws_ecs_service.lbecs-ecs-service will be created
+ resource "aws_ecs_service" "lbecs-ecs-service" {
...
}
# aws_ecs_task_definition.lbecs-ecs-task-definition will
# be created
+ resource "aws_ecs_task_definition"
"lbecs-ecs-task-definition" {
...
}
# aws_internet_gateway.lbecs-igw will be created
+ resource "aws_internet_gateway" "lbecs-igw" {
...
}
# aws_lb.lbecs-load-balancer will be created
+ resource "aws_lb" "lbecs-load-balancer" {
...
}
# aws_lb_listener.lbecs-load-balancer-listener will be
# created
+ resource "aws_lb_listener"
"lbecs-load-balancer-listener" {
...
}
# aws_lb_target_group.lbecs-load-balancer-target-group
# will be created
+ resource "aws_lb_target_group"
"lbecs-load-balancer-target-group" {
...
}
# aws_security_group.lbecs-security-group will be created
+ resource "aws_security_group" "lbecs-security-group" {
...
}
# aws_subnet.lbecs-subnet will be created
+ resource "aws_subnet" "lbecs-subnet" {
...
}
# aws_subnet.lbecs-subnet-1 will be created
+ resource "aws_subnet" "lbecs-subnet-1" {
...
}
# aws_vpc.lbecs-vpc will be created
+ resource "aws_vpc" "lbecs-vpc" {
...
}
Plan: 12 to add, 0 to change, 0 to destroy.
...
aws_ecs_service.lbecs-ecs-service: Creation complete after 2m49s [id=arn:aws:ecs:us-east-1:860976549008:service/lbecs-ecs-cluster/lbecs-ecs-service]
...
Outputs:
url = "load-balancer-1956367690.us-east-1.elb.amazonaws.com"
让我们分解代码,看看它是如何使用 ECS 以及如何配置互联网网关、负载均衡器和网络的。以下代码显示了互联网网关的声明,这很简单,因为它需要连接到 VPC:
resource "aws_internet_gateway" "lbecs-igw" {
vpc_id = aws_vpc.lbecs-vpc.id
tags = {
Name = "Internet Gateway"
}
}
resource "aws_default_route_table" "lbecs-subnet-default-route-table" {
default_route_table_id =
aws_vpc.lbecs-vpc.default_route_table_id
route {
cidr_block = "0.0.0.0/0"
gateway_id = "${aws_internet_gateway.lbecs-igw.id}"
}
}
此外,网关还将连接到在 aws_default_route_table 块内声明的路由表。这是必要的,因为它告诉网关如何通过内部私有 VPC 网络路由传入和传出的流量。
现在我们内部的私有网络可以通过网关与互联网通信,我们需要设置网络规则以确保我们的网络安全,这将在以下代码中完成:
resource "aws_security_group" "lbecs-security-group" {
name = "allow_http"
description = "Allow HTTP inbound traffic"
vpc_id = aws_vpc.lbecs-vpc.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "Allow HTTP for all"
from_port = 80
to_port = 3333
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
egress 块声明了出站网络流量的规则,允许所有协议通过。入站网络流量规则在 ingress 块中声明,并允许 80-3333 端口之间的通信,并且仅通过 TCP。
使用负载均衡器需要声明两个不同的子网。在我们的代码示例中,如下所示:
resource "aws_lb" "lbecs-load-balancer" {
name = "load-balancer"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.lbecs-security-group. id]
subnets = [aws_subnet.lbecs-subnet.id,
aws_subnet.lbecs-subnet-1.id]
tags = {
env = "dev"
}
}
我们将要查看的最后一段代码是 ECS 块,如下所示:
resource "aws_ecs_cluster" "lbecs-ecs-cluster" {
name = "lbecs-ecs-cluster"
}
resource "aws_ecs_task_definition" "lbecs-ecs-task-definition" {
family = "service"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
container_definitions = jsonencode([
{
name = "lbecs-ecs-cluster-chapter14"
image =
"ghcr.io/nanikjava/golangci/chapter12:latest"
...
portMappings = [
{
containerPort = 3333
}
]
}
])
}
resource "aws_ecs_service" "lbecs-ecs-service" {
name = "lbecs-ecs-service"
cluster = aws_ecs_cluster.lbecs-ecs-cluster.id
task_definition =
aws_ecs_task_definition.lbecs-ecs-task-definition.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
...
}
load_balancer {
target_group_arn = aws_lb_target_group.lbecs-load-
balancer-target-group.arn
container_name = "lbecs-ecs-cluster-chapter14"
container_port = 3333
}
tags = {
env = "dev"
}
}
以下代码包含三个不同的代码块,具体解释如下:
-
aws_ecs_cluster:此块配置 ECS 集群的名称 -
aws_ecs_task_definition:此块配置 ECS 任务,指定它必须运行的容器类型、容器将运行的虚拟机配置、网络模式、安全组以及其他选项 -
aws_ecs_service:此块将不同的服务连接起来,以描述将要运行的完整基础设施,例如安全、ECS 任务、网络配置、负载均衡器、公网 IP 地址等
一旦 ECS 启动,它将在您的控制台中打印出您可以使用以访问应用程序的负载均衡器公网地址。例如,当它运行时,我们在终端中得到了以下输出:
…
aws_lb_listener.lbecs-load-balancer-listener: Creating...
aws_lb_listener.lbecs-load-balancer-listener: Creation complete after 1s [id=arn:aws:elasticloadbalancing:us-east-1:860976549008:listener/app/load-balancer/4ad0f8b815a06f02/d945bba078d0c365]
aws_ecs_service.lbecs-ecs-service: Creation complete after 2m27s [id=arn:aws:ecs:us-east-1:860976549008:service/lbecs-ecs-cluster/lbecs-ecs-service]
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Outputs:
url = "load-balancer-375816308.us-east-1.elb.amazonaws.com"
使用浏览器中的load-balancer-375816308.us-east-1.elb.amazonaws.com地址将显示应用程序登录页面。此地址由 AWS 动态生成,您将得到与之前输出中显示的内容不同的内容。
摘要
在本章中,我们探讨了 AWS 提供的云解决方案,并简要了解了提供的服务,如 EC2、VPC、存储等。我们了解了开源的 Terraform 工具,它使得在 AWS 中创建、管理和销毁云基础设施变得容易。
我们学习了如何在本地安装和使用 Terraform,以及如何编写 Terraform 代码以使用 Docker 作为提供者,使我们能够在本地运行容器。Terraform 还允许我们使用单个命令在本地下载、运行和销毁容器。
我们还探讨了创建 AWS 基础设施资源的不同 Terraform 示例,并查看 AWS ECS 的一个高级功能。
在本书的最后一章,您学习了将应用程序部署到 AWS 云所需执行的不同操作。


浙公网安备 33010602011771号