vlambda博客
学习文章列表

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

Chapter 3: Managing Data Persistence with MongoDB

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

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

  • Setting up MongoDB with Docker
  • Configuring Go MongoDB driver
  • Implementing MongoDB queries & and CRUD operations
  • Standard Go project layout
  • Deploying Redis with Docker
  • Optimizing API response time with caching
  • Performance benchmark with Apache Benchmark

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

Technical requirements

要遵循本章中的内容,您将需要以下内容:

  • You must have a complete understanding of the previous chapter since this chapter is a follow-up of the previous one; it will use the same source code. Hence, some snippets won't be explained to avoid repetition.
  • Some knowledge of NoSQL concepts and MongoDB basic queries.

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

Running a MongoDB Server

到目前为止,我们构建的 API 没有连接到数据库。对于现实世界的应用程序,我们需要使用一种数据存储形式;否则,如果 API 崩溃或托管 API 的服务器出现故障,数据将丢失。 MongoDB 是最流行的 NoSQL 数据库之一。

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.1 – API 架构

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.2 – MongoDB 社区服务器

  • You can use the MongoDB as a Service solution, known as MongoDB Atlas (https://www.mongodb.com/cloud/atlas), to run a free 500 MB database on the cloud. You can deploy a fully managed MongoDB server on AWS, Google Cloud Platform, or Microsoft Azure.
  • You can run MongoDB locally with a containerization solution such as Docker. Multiple Docker images are available on DockerHub with a MongoDB server configured and ready to use out of the box.

我选择使用 Docker,因为它在运行临时环境中很受欢迎且简单。

Installing Docker CE

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.3 – 虚拟机与容器

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

首先,您需要在您的机器上安装 Docker Engine。导航到 https://docs.docker.com/get-docker/ 并安装 Docker您的平台:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.4 – Docker 安装

笔记

Mac 用户还可以使用 Homebrew 实用程序通过 brew install docker 命令安装 Docker。

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

docker version

在撰写本书时,我正在使用 Docker Community Edition (CE< /strong>) 版本 20.10.2,如以下屏幕截图所示:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

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

docker run hello-world

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.6 – Docker hello-world 容器

恭喜!你是 现在成功运行 Docker。

Running a MongoDB container

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

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

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

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

openssl rand -base64 12 | docker secret create mongodb_password -

笔记

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

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

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

-e MONGO_INITDB_ROOT_PASSWORD_FILE=/run/secrets/mongodb_password

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.7 – 从 DockerHub 拉取一个 MongoDB 镜像

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

docker rm -f container_name || true 

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

docker logs –f CONTAINER_ID

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.8 – MongoDB 容器运行时日志

笔记

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

mkdir /home/data

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

要与 MongoDB 服务器交互,您可以使用 MongoDB shell 在命令行上发出查询和查看数据。但是,还有一个更好的选择:MongoDB Compass。

安装 MongoDB 指南针

MongoDB Compass 是一个 GUI 工具,允许您轻松构建查询、了解数据库架构和分析索引,而无需了解 MongoDB 的查询语法。

https://www.mongodb.com/try/download/compass? 下载指南针? tck=docs_compass 基于您的操作系统:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.9 – MongoDB Compass 下载页面

一旦您下载了与您的操作系统相关的软件包,运行安装程序并按照它之后的步骤进行操作。安装后,打开 Compass,单击 New Connection,然后在输入字段中输入以下 URI(用您自己的凭据替换给定的凭据): mongodb://admin:password@localhost:27017/test

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.10 – MongoDB Compass – 新连接

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.11 – MongoDB 默认数据库

至此,我们拥有了一个功能性的 MongoDB 部署。在下一节中,我们将使用我们在上一章中构建的 Recipes API 与数据库进行交互。

笔记

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

Configuring Go's MongoDB driver

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

发出以下命令以从 GitHub 安装包:

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

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

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

首先,在 main.go 文件中导入 以下包:

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

init() 方法中,使用 Connectmongo.Client > 功能。此函数将上下文作为参数和连接字符串,该字符串由名为 MONGO_URI 的环境变量提供。另外,创建以下全局变量;它们将用于所有 CRUD 操作功能:

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

笔记

我省略了一些代码以使示例可读且易于理解。完整的源代码在本书的 GitHub 存储库中,位于 chapter03 文件夹下。

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

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

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

如果成功,将显示 Connected to MongoDB 消息:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

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

Exploring MongoDB queries

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

笔记

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

The InsertMany operation

让我们用 在上一章。首先,检索一个 Database,然后从 Client 检索一个 Collection 实例。 Collection 实例将用于插入文档:

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

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

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

重新运行应用程序,但这一次,设置 MONGO_URI 和 MONGO_DATABASE 变量如下:

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

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.13 – 在启动期间插入配方

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.14 – 食谱集合

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

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

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

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.15 – 使用 mongoimport 导入数据

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

The Find operation

首先,我们需要实现函数,该函数负责返回一个食谱列表。更新 ListRecipesHandler 以便它使用 Find() 方法从 获取所有项目食谱 集合:

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

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.16 – 获取所有配方

端点正在工作并从集合中获取配方的项目

The InsertOne operation

实现的第二个函数将是负责保存新配方。更新 NewRecipeHandler 函数,使其调用 recipesInsertOne() 方法代码>集合:

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

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

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

笔记

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

通过 使用 Postman 客户端调用以下 POST 请求插入一个新的 recipe:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.17 – 创建新配方

验证配方是否已 插入到 MongoDB 集合中,如以下屏幕截图所示:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.18 – 获取最后插入的配方

为了得到最后一个插入的recipe,我们使用sort()操作.

The UpdateOne operation

最后,为了更新集合中的一个项目,更新 UpdateRecipeHandler 函数以便它调用 UpdateOne() 方法。这个方法需要一个过滤器文档来匹配数据库中的文档和一个更新器文档来描述更新操作。您可以使用 bson.D{} 构建一个 过滤器 - 二进制编码 JSON< /strong> (BSON) 文件:

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

此方法按对象 ID 过滤 文档。我们通过将 ObjectIDFromHex 应用于路由参数 ID 来获得 Object ID。这将使用来自请求正文的新值更新匹配配方的字段。

通过对现有配方调用 PUT 请求来验证端点是否正常工作:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.19 – 更新配方

该请求将匹配 ID600dcc85a65917cbd1f201b0 的配方,并将更新其 名称 从“自制披萨”到“自制意大利辣香肠披萨”,以及说明 字段以及制作“Pepperoni Pizza”的附加步骤。

至此,配方已成功更新。您可以使用 MongoDB Compass 确认这些更改:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.20 – UpdateOne 操作结果

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

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

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.21 – 新的拉取请求

笔记

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

您刚刚看到 如何将 MongoDB 集成到 应用程序架构中。在下一节中,我们将介绍如何重构我们的应用程序的源代码,以便从长远来看它是可维护的、可扩展的和可扩展的。

Designing the project's layout

到目前为止,我们编写的所有 代码都在 main.go 文件中。虽然这很好用,但确保代码结构良好很重要。否则,随着项目的发展,您最终会得到很多隐藏的依赖项和混乱的代码(意大利面条式代码)。

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

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

然后,使用 handler.go 文件创建一个 handlers 文件夹 。顾名思义,此文件夹通过公开为每个 HTTP 请求调用的正确函数来处理任何传入的 HTTP 请求:

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

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

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

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

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

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

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

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

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

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

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

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

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.22 – Gin 调试日志

现在,您的项目结构应如下所示:

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

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

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

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

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

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

Caching an API with Redis

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

Redis 在检索数据方面始终更快,因为它始终位于 RAM 中——这就是为什么它是缓存的绝佳选择。另一方面,MongoDB 可能必须从磁盘中检索数据以进行查询。

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.23 – API 新架构

假设我们想要获取食谱列表。首先,API 将在 Redis 中查看。如果存在配方列表,则会返回(这称为缓存命中)。如果缓存 为空(这称为 缓存未命中),则 MongoDB find ({}) 查询将被发出,结果将被返回并保存在缓存中以供将来的请求使用。

Running Redis in Docker

设置 Redis 最简单的方法是通过 Docker。为此,我们将使用 DockerHub 提供的 Redis 官方镜像 ( https://hub.docker.com/_/redis)。在编写本书时,最新的稳定版本是 6.0。运行基于该图像的容器:

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

该命令主要做以下两件事:

  • The –d flag runs the Redis container as a daemon.
  • The –p flag maps port 6379 of the container to port 6379 of the host. Port 6379 is the port where the Redis server is exposed.

该命令的输出如下:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.24 – 从 DockerHub 拉取 Redis 镜像

始终检查 Docker 日志以查看事件链:

docker logs –f CONTAINER_ID

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.25 – Redis 服务器日志

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

maxmemory-policy allkeys-lru
maxmemory 512mb

此配置为 Redis 分配 512 MB 内存并将驱逐策略设置为 最近最少使用 (LRU) 算法,删除最近最少使用的缓存项。因此,我们只保留最有可能再次阅读的项目。

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

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

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

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

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

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

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

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

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

此代码将在部署后与 Redis 服务器建立连接

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

如果连接成功,将显示 ping: PONG 消息,如上图所示。

Optimizing MongoDB queries

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

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

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

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

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

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

值得一提的是,Redis 的值必须是字符串,所以我们必须使用 json.Marshal( ) 方法。

要测试新的 更改,请运行应用程序。然后,使用 Postman 客户端或使用 cURL 命令在 /recipes 端点上发出 GET 请求。返回您的终端并查看杜松子酒日志。您应该在控制台中看到与从 MongoDB 获取数据相对应的第一个请求的消息:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.27 – 从 MongoDB 获取数据

笔记

有关如何使用 Postman 客户端或 cURL 命令的分步指南,请查看 第 1 章Gin 入门

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.28 – 从 Redis 获取数据

正如我们所见,与从磁盘 (MongoDB) 中检索数据相比,从内存 (Redis) 中检索 数据的速度非常快。

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

docker ps
docker exec –it CONTAINER_ID bash

这些命令将使用交互式终端连接到 Redis 容器并启动 bash shell。您会注意到您现在正在使用终端,就好像您在容器中一样,如以下屏幕截图所示:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

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

redis-cli

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

EXISTS recipes

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

您可以使用 shell 客户端完成很多工作,但您已经掌握了大体思路。输入 exit 离开 MongoDB shell,然后再次输入 exit 离开交互式 shell。

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

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

该命令将运行一个基于 Redis Insight 官方镜像的容器,并将接口暴露在 8001 端口。

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.31 – 配置 Redis 数据库

Host 设置为 redisport 设置为 6379,并将数据库命名为 。设置如下:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.32 – 新的 Redis 设置

接下来,单击添加 REDIS DATABASE。 本地数据库将被保存;点击它:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.33 – Redis Insights 数据库

您将被重定向到 Summary 页面,其中包含有关 Redis 服务器的真实指标和统计信息:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.34 – Redis 服务器指标

如果您单击BROWSE,您将看到已存储在 Redis 中的所有键的列表。如以下屏幕截图所示,recipes 键已被缓存:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.35 – Redis 的键列表

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

到目前为止,我们构建的 API 是一种魅力,对吧?并不真地;假设您向数据库添加了一个新配方:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.36 – 创建新配方

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.37 – 未找到配方

缓存引入的问题之一是在数据更改时使缓存保持最新:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.38 – 所有未来的请求都在访问 Redis

这个案例中有两个组规则来修复不一致。首先,我们可以为Redis 中的 recipes 键。其次,我们可以在每次插入或更新新配方时清除 Redis 中的 recipes 键。

笔记

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

我们可以通过更新 NewRecipeHandler 函数来实现第二种解决方案,以便在插入新配方时删除 recipes 键。在这种情况下,实现将如下所示:

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

重新部署 应用程序。现在,如果您点击 GET /recipes 请求,数据将按预期从 MongoDB 返回;然后,它将被缓存在 Redis 中。第二个 GET 请求将从 Redis 返回数据。但是,现在,如果我们发出 POST /recipes 请求来插入新的配方,Redis 中的 recipes 键将被清除,正如 Remove data from Redis 消息所确认的。这意味着下一个 GET /recipes 请求将从 MongoDB 获取数据:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

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

现在,新配方将在配方列表中返回:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.40 – 新插入的配方

笔记

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

虽然缓存为具有大量读取的应用程序提供了很大的好处,但对于执行大量数据库更新并可能减慢写入速度的应用程序而言,它可能没有那么有益。

Performance benchmark

我们可以进一步了解这个 ,看看 API 在大量请求下的表现如何。我们可以使用 Apache Benchmark (https://httpd.apache.org/ docs/2.4/programs/ab.html)。

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

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

完成所有请求需要 分钟。完成后,您应该会看到以下结果:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.41 – 没有缓存层的 API

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

  • Time taken for tests: This means the total time to complete the 2,000 requests.
  • Time per request: This means how many milliseconds it takes to complete one request.

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

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

完成 2,000 个请求需要几秒钟的时间:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.42 – 带有缓存层的 API

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

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

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

gnuplot apache-benchmark.p

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

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.43 – 有缓存和无缓存的 API 基准

与没有缓存的 API 响应时间相比,启用缓存机制的 API 响应时间非常快。

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

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

在本章结束时,您的 GitHub 存储库应如下所示:

读书笔记《building-distributed-applications-in-gin》第3章使用MongoDB管理数据持久化

图 3.44 – 项目的 GitHub 存储库

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

Summary

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

我们还探讨了如何通过缓存使用 Redis 访问的数据来加速 API。如果您的数据大部分是静态的并且不会不断变化,那么它绝对是您应用程序的一个很好的补充。最后,我们介绍了如何使用 Apache Benchmark 运行性能基准测试。

到目前为止,我们构建的 RESTful API 就像一个魅力,并且对公众开放(如果部署在远程服务器上)。如果您让 API 未经身份验证,那么任何人都可以访问任何端点,这可能是非常不可取的,因为您的数据可能会被用户损坏。更糟糕的是,您可能会将数据库中的敏感信息暴露给整个互联网。这就是为什么在下一章中,我们将介绍如何使用身份验证来保护 API,例如 JWT。

Questions

  1. Implement a delete recipe operation when a DELETE request occurs.
  2. Implement a GET /recipes/{id} endpoint using the FindOne operation.
  3. How are JSON documents stored in MongoDB?
  4. How does the LRU eviction policy work in Redis?

Further reading

  • MongoDB Fundamentals, by Amit Phaltankar, Juned Ahsan, Michael Harrison, and Liviu Nedov, Packt Publishing
  • Learn MongoDB 4.x, by Doug Bierer, Packt Publishing
  • Hands-On RESTful Web Services with Go – Second Edition, by Naren Yellavula, Packt Publishing