读书笔记《building-distributed-applications-in-gin》第5章在Gin中提供静态HTML
Chapter 6: Scaling a Gin Application
Technical requirements
要遵循本章中的内容,您将需要以下内容:
- 完整理解上一章。本章是上一章的后续,因为它将使用相同的源代码。因此,为了避免重复,将不解释某些片段。
- 了解 Docker 及其架构。理想情况下,之前对消息队列服务(如 RabbitMQ、ActiveMQ、Kafka 等)的一些经验将是有益的。
本章的代码包托管在 GitHub 上,地址为 https: //github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/tree/main/chapter06。
Scaling workloads with a message broker
在开发 Web 应用程序时,用户体验中经常被忽略的一个重要方面是响应时间。没有什么能比一个缓慢而迟钝的应用程序更快地拒绝用户了。在之前的章节中,您学习了如何使用 Redis 减少数据库查询以加快数据访问速度。在本章中,您将进一步了解如何扩展使用 Gin 框架编写的 Web 应用程序。
在我们了解为什么需要扩展应用程序工作负载之前,让我们在架构中添加另一个块。新服务将解析 Reddit RSS 提要并将提要条目插入 MongoDB recipes
集合。下图说明了新服务如何与架构集成:
该服务将使用 subreddit RSS URL 作为参数。我们可以通过将 .rss
添加到现有 subreddit URL 的末尾来创建 RSS 提要:
https://www.reddit.com/r/THREAD_NAME/.rss
此 subreddit 将具有以下 RSS 提要 URL:
https://www.reddit.com/r/recipes/.rss
如果您访问上述 URL,您应该会收到一个 XML 响应。以下是食谱 subreddit 的 RSS URL 返回的 XML 结构示例:
<?xml version="1.0" encoding="UTF-8"?> <feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"> <title>recipes</title> <entry> <author> <name>/u/nolynskitchen</name> <uri>https://www.reddit.com/user/nolynskitchen </uri> </author> <category term="recipes" label="r/recipes"/> <id>t3_m4uvlm</id> <media:thumbnail url="https://b.thumbs. redditmedia.com /vDz3xCmo10TFkokqy9y1chopeIXdOqtGA33joNBtTDA.jpg" /> <link href="https://www.reddit.com/r/recipes /comments/m4uvlm/best_butter_cookies/" /> <updated>2021-03-14T12:57:05+00:00</updated> <title>Best Butter Cookies!</title> </entry> </feed>
- 创建一个
rss-parser
项目,加载到VSCode编辑器中,编写一个main .go
文件。在文件中,声明第一个 structEntry
之后,使用 Entry:type Feed struct 的 struct
Feed
{ Entries []Entry `xml:"entry"` } 类型条目结构{ 链接结构{ Href字符串`xml:"href,attr"` }`xml:"链接"` 缩略图结构{ URL字符串`xml:"url,attr"` }`xml:"缩略图"` 标题字符串`xml:"title"` } - Next, write a
GetFeedEntries
method, which takes the RSS URL as a parameter, and return a list of entries:func GetFeedEntries(url string) ([]Entry, error) { client := &http.Client{} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Add("User-Agent", "Mozilla/5.0 ( Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() byteValue, _ := ioutil.ReadAll(resp.Body) var feed Feed xml.Unmarshal(byteValue, &feed) return feed.Entries, nil }
此方法使用HTTP客户端向
GetFeedEntries中给出的URL发出GET请求
方法参数。然后,它将响应正文编码为Feed
结构。最后,它返回Entries
属性。请注意使用
User-Agent
请求标头来模拟从浏览器发送的请求并避免被 Reddit 服务器阻止:req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36")
笔记
可以在以下 URL 中找到有效用户代理的列表(它会定期更新):https:// developer.whatismybrowser.com/useragents/explore/。
- Next, create a web server with the Gin router and expose a POST request on the
/parse
endpoint. Then, define a route handler calledParserHandler
:func main() { router := gin.Default() router.POST("/parse", ParserHandler) router.Run(":5000") }
ParserHandler
是不言自明的:它将请求负载编组到Request
结构中。然后,它使用URL
属性 调用GetFeedEntries
方法代码类="literal">请求结构。最后,基于 方法的响应,它返回 500 错误代码或 200 状态代码,以及提要条目列表:func ParserHandler(c *gin.Context) { var request Request if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error()}) return } entries, err := GetFeedEntries(request.URL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "error while parsing the RSS feed"}) return } c.JSON(http.StatusOK, entries) }
Request
结构有一个URL
属性:type Request struct { URL string `json:"url"` }
- 要测试 ,请在不同的 端口上运行服务器(例如,
5000< /code>) 以避免与配方 API 的端口冲突(已经在端口
8080
上运行): - 在 Postman 客户端上,在
/parse
端点上发出一个 POST 请求,请求正文中包含 subreddit 的 URL。服务器将解析 RSS 提要并返回提要条目列表,如以下屏幕截图所示: - 现在,通过连接到前面章节中部署的 MongoDB 服务器,将 结果插入 MongoDB 。在
init()
方法上定义连接指令,如下:var client *mongo.Client var ctx context.Context 函数初始化(){ ctx = context.Background() 客户端,_ = mongo.Connect(ctx, options.Client().ApplyURI(os.Getenv("MONGO_URI"))) }
- 然后,更新 HTTP 处理程序以将 条目插入
recipes
集合InsertOne
操作:func ParserHandler(c *gin.Context) { ... 集合 := client.Database(os.Getenv( "MONGO_DATABASE")).Collection("recipes") 对于 _, entry := range entries[2:] { collection.InsertOne(ctx, bson.M{ "title": entry.title, “缩略图”:entry.Thumbnail.URL, "url": entry.Link.Href, }) } ... }
- 重新运行应用程序,但这一次,提供
MONGO_URI
和MONGO_DATABASE
环境变量,如下所示:MONGO_URI="mongodb ://admin:password@localhost:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" MONGO_DATABASE=demo go run main.go
- 使用 Postman 或
curl
命令重新发出 POST 请求。返回 MongoDB Compass 并刷新recipes
集合。 RSS 条目应该已成功插入,如下所示:
笔记
如果您使用的数据库和集合与前面章节中显示的相同,则可能需要在插入新配方之前删除现有文档。
您可以重复相同的步骤来解析其他 subreddit RSS 提要。但是,如果您想解析数千或数百万个 subreddit 怎么办?处理如此大量的工作负载将占用大量资源(CPU/RAM)并且非常耗时。这就是为什么我们将服务逻辑分成多个松散耦合的服务,然后根据传入的工作负载对其进行扩展。
这些服务需要相互通信,最有效的通信方法是使用消息代理。这就是 RabbitMQ 发挥作用的地方。
Deploying RabbitMQ with Docker
RabbitMQ (https://www.rabbitmq.com/#getstarted ) 是一个可靠的、高度可用的消息代理。 以下模式描述了 RabbitMQ 将如何在应用程序架构中使用:
要使用 Docker 部署 RabbitMQ,请使用官方 RabbitMQ Docker 映像 ( https://hub.docker.com/_/rabbitmq)并执行以下步骤:
- Issue the following command to run a container from the RabbitMQ image:
docker run -d --name rabbitmq -e RABBITMQ_DEFAULT_USER=user -e RABBITMQ_DEFAULT_PASS=password -p 8080:15672 -p 5672:5672 rabbitmq:3-management
用户名和密码是通过环境变量设置的。上述命令公开了端口
8080
上的 RabbitMQ 仪表板和端口5672
上的服务器。 - Once the container has been deployed, run the following command to display the server logs:
docker logs -f CONTAINER_ID
以下是启动日志的显示方式:
- 服务器初始化完成后,通过浏览器导航到
localhost:8080
。将显示一个 RabbitMQ 登录页面。使用您的用户/密码凭据登录。您将登陆仪表板: - 现在,创建 一个消息队列,RSS URL 将被推送到 到由服务。点击导航栏中的 Queues 并通过点击 Add a new queue 创建一个新队列:
- 确保将Durability字段设置为数据会在磁盘上持久保存。
随着 RabbitMQ 的启动和运行,我们可以继续并实现一个生产者服务来将传入的 RSS URL 推送到 RabbitMQ,以及一个消费者服务来使用队列中的 URL。
Exploring the Producer/Consumer pattern
在我们深入更深的实现之前,我们需要探索Producer /消费者模式。以下架构说明了这两个概念:
现在主要概念已经清楚,让我们开始吧:
- 创建 一个名为
producer
的新 Go 项目并安装 RabbitMQ SDK for Golang使用以下命令:去获取 github.com/streadway/amqp
- 编写 一个
main.go
文件并使用以下代码段设置到 RabbitMQ 服务器的 TCP 连接:var频道Amqp *amqp.Channel 函数初始化(){ amqpConnection, err := amqp.Dial(os.Getenv( "RABBITMQ_URI")) 如果错误 != nil { log.Fatal(err) } channelAmqp, _ = amqpConnection.Channel() }
通过
RABBITMQ_URI
提供AMQP连接环境字符串以及密码。 - 接下来,在
/parse
端点上定义一个 HTTP 处理程序。处理程序将使用 将请求 body 中给出的 URL 推送到 RabbitMQ 队列中a>Publish
方法:func ParserHandler(c *gin.Context) { var request 请求 if err := c.ShouldBindJSON(&request);错误!=无{ c.JSON(http.StatusBadRequest, gin.H{ “错误”:err.Error()}) 返回 } 数据,_ := json.Marshal(请求) err := channelAmqp.Publish( "", os.Getenv("RABBITMQ_QUEUE"), 假, 假, amqp.Publishing{ ContentType: "application/json", 正文: []字节(数据), }) 如果错误 != nil { fmt.Println(err) c.JSON(http.StatusInternalServerError, gin.H{"error": "发布时出错 到 RabbitMQ"}) 返回 } c.JSON(http.StatusOK, map[string]string{ “消息”:“成功”}) } 功能主要(){ 路由器 := gin.Default() router.POST("/parse", ParserHandler) router.Run(":5000") }
- 最后,使用
RABBITMQ_URI
和RABBITMQ_QUEUE
变量,如 如下:RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls go run main .go
- 然后,在
/parse
端点上执行 POST 请求。您应该会收到 200 success 消息,如下所示: - 前往 返回 RabbitMQ 仪表板,转到 Queues 部分,然后点击 rss_urls 队列上的 。您应该被重定向到 队列指标 页面。在这里,您会注意到队列中有一条消息:
随着生产者服务启动并运行,您需要构建工作器/消费者来使用 RabbitMQ 队列中可用的消息/URL:
- 创建一个名为
consumer
的新Go项目并创建一个新文件称为main.go
。在文件中写入以下代码:func main() { amqpConnection, err := amqp.Dial(os.Getenv( "RABBITMQ_URI")) 如果错误 != nil { log.Fatal(err) } 延迟 amqpConnection.Close() channelAmqp, _ := amqpConnection.Channel() 延迟通道Amqp.Close() 永远 := make(chan bool) 消息,错误 := channelAmqp.Consume( os.Getenv("RABBITMQ_QUEUE"), "", 真的, 假, 假, 假, 无, ) 去 func() { for d := range msgs { log.Printf("收到消息:%s", d.Body) } }() log.Printf(" [*] 等待消息。 退出按CTRL+C") <-永远 }
代码 很简单:它 建立到 RabbitMQ a> 代码类的服务器连接并订阅
中的新消息="literal">rss_urls
获得一个新消息。 - Run the consumer project by passing the RabbitMQ URI and queue name as environment variables:
RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls go run main.go
一旦启动,消费者将获取之前由生产者推送的消息,并将其内容显示在控制台上。然后,它将从队列中删除消息:
- 通过刷新 队列指标 页面来验证消息是否已被删除。 Queued messages 图表应确认此删除:
有了这个,你的工人/消费者已经建立起来了!
到目前为止,您已经看到消费者显示了消息的内容。现在,让我们进一步了解 并将消息正文编码到 Request
结构中,并通过调用 GetFeedEntries
方法,我们前面提到过。然后这些条目将保存在 MongoDB 的 recipes
集合中:
go func() { for d := range msgs { log.Printf("Received a message: %s", d.Body) var request Request json.Unmarshal(d.Body, &request) log.Println("RSS URL:", request.URL) entries, _ := GetFeedEntries(request.URL) collection := mongoClient.Database(os.Getenv( "MONGO_DATABASE")).Collection("recipes") for _, entry := range entries[2:] { collection.InsertOne(ctx, bson.M{ "title": entry.Title, "thumbnail": entry.Thumbnail.URL, "url": entry.Link.Href, }) } } }()
重新运行应用程序,但是这个时间,提供MongoDB连接参数,在添加到 RabbitMQ 参数:
RABBITMQ_URI="amqp://user:password@localhost:5672/" RABBITMQ_QUEUE=rss_urls MONGO_URI="mongodb://admin:password@localhost:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" MONGO_DATABASE=demo go run main.go
要对此进行测试,请在请求正文中使用 RSS 提要 URL 向生产者服务器发出 POST 请求。生产者将在 RabbitMQ 队列中发布 URL。从那里,消费者将获取消息并获取 RSS URL 的 XML 响应,将响应编码为条目数组,并将结果保存在 MongoDB 中:
您可以向生产者服务器发出多个 subreddit URL。这一次,消费者会一一获取URL,如下:
要查看已保存在 MongoDB 中的条目,请构建一个简单的仪表板以列出 recipes
集合中的所有 recipes。您可以从头开始创建一个新项目或在 我们在上一章中构建的 Web 应用程序 上公开一个额外的路由来服务于食谱的 HTML 表示:
router.GET("/dashboard", IndexHandler)
然后,确保更新 Recipe
结构字段以镜像 MongoDB 文档字段的结构:
type Recipe struct { Title string `json:"title" bson:"title"` Thumbnail string `json:"thumbnail" bson:"thumbnail"` URL string `json:"url" bson:"url"` }
路由处理程序将简单地调用 recipes
集合上的 Find
操作以返回所有食谱。然后,它将结果编码到 recipes
切片中。最后,它将 recipes
变量传递到 HTML 模板中以显示结果:
func IndexHandler(c *gin.Context) { cur, err := collection.Find(ctx, bson.M{}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer cur.Close(ctx) recipes := make([]Recipe, 0) for cur.Next(ctx) { var recipe Recipe cur.Decode(&recipe) recipes = append(recipes, recipe) } c.HTML(http.StatusOK, "index.tmpl", gin.H{ "recipes": recipes, }) }
以下 是 HTML 模板的内容。它使用 Bootstrap 框架 来构建吸引人的用户界面。它还使用 range
关键字循环遍历 recipes
切片中的每个配方并显示其详细信息(标题、缩略图和Reddit 网址):
<section class="container"> <div class="row"> <ul class="list-group"> {{range .recipes}} <li class="list-group-item"> <div style="width: 100%;"> <img src="{{ .Thumbnail }}" class="card-img-top thumbnail"> <span class="title">{{ .Title }}</span> <a href="{{ .URL }}" target="_blank" class="btn btn-warning btn-sm see_recipe">See recipe</a> </div> </li> {{end}} </ul> </div> </section>
配置 Gin 服务器以在端口 3000
上运行 并执行服务器go run main.go
命令 带有 MONGO_URI
和 MONGO_DATABASE
变量。在您的浏览器上,前往 Localhost:3000/dashboard 除了,其中应返回食谱列表,如以下屏幕截图所示:
笔记
应用程序布局和样式表可以在本书的 GitHub 存储库中找到:https://github.com/PacktPublishing/Building-Distributed-Applications-in-Gin/blob/main/chapter06/dashboard/templates/index.tmpl。
惊人的!你现在已经熟悉如何使用消息代理如RabbitMQ到扩展您的 Gin 分布式应用程序。在下一节中,我们将演示另一种通过 Docker 扩展 Gin 分布式应用程序的技术。
Scaling horizontally with Docker replicas
到目前为止,您已经学习了如何使用 Gin 框架 和 RabbitMQ 构建生产者/消费者架构。在本节中,我们将介绍如何扩展消费者组件,以便我们可以将传入的工作负载分配给多个消费者。
您可以通过构建消费者项目的 Docker 镜像并基于该镜像构建多个容器来实现此目的。 Docker 镜像是不可变的,这保证了每次容器基于运行的镜像时都是相同的环境。
以下模式说明了如何使用多个消费者/工作者:
要创建 Docker 映像,我们需要定义一个 Dockerfile
- 一个包含运行消费者项目的所有指令的蓝图。在你的 worker/consumer 目录中创建一个 Dockerfile
,内容如下:
FROM golang:1.16 WORKDIR /go/src/github.com/worker COPY main.go go.mod go.sum ./ RUN go mod download RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=0 /go/src/github.com/worker/app . CMD ["./app"]
此 Dockerfile
使用 多阶段构建功能来构建轻量级 Docker 映像。我们将在下一节中看到它是如何工作的。
Using Docker multi-stage builds
多阶段构建是在 Docker Engine 1.17.05 中引入的。它允许您在 Dockerfile
中使用多个 FROM
语句 并复制工件从一个阶段到另一个阶段,在最终图像中留下您不需要的一切。
在 之前的示例中,您使用 golang:1.16
作为基础映像来构建单个二进制文件。然后,第二个 FROM
指令开始了一个以 Alpine 映像为基础的新构建阶段。从这里,您可以使用 COPY –from=0
指令从上一阶段复制二进制文件。结果,您最终会得到一个小的 Docker 映像。
要构建映像,请运行以下命令。最后的点很重要,因为它指向当前目录:
docker build -t worker .
构建过程应该需要几秒钟才能完成。然后,您将找到以下 Docker 构建日志:
如果您查看前面的输出,您会看到 Docker 根据我们的 Dockerfile
worker 映像的每条指令>。构建映像后,运行以下命令以列出计算机中的可用映像:
docker image ls
工人/消费者形象应列在列表顶部:
使用 docker run
命令运行基于图像的容器。您需要提供 MongoDB 和 RabbitMQ URI 作为带有 –e
标志的环境变量。 –link
标志可用于与容器内的 MongoDB 和 RabbitMQ 交互:
docker run -d -e MONGO_URI="mongodb://admin:password@mongodb:27017/test?authSource=admin&readPreference=primary&appname=MongoDB%20Compass&ssl=false" -e MONGO_DATABASE=demo2 -e RABBITMQ_URI="amqp://user:password@rabbitmq:5672/" -e RABBITMQ_QUEUE=rss_urls --link rabbitmq --link mongodb --name worker worker
有了这个,你已经对工人的服务进行了码头化。接下来,我们将使用 Docker Compose 对其进行扩展。
Scaling services with Docker Compose
Docker Compose 是一个容器编排工具,建立在 Docker Engine 之上。它可以帮助您使用单个命令行管理应用程序堆栈或多个容器。
使用 Docker Compose 就像创建 Docker 映像一样简单:
- 在项目的根目录中定义一个
docker-compose.yml
文件并输入以下 YAML 代码:version: "3.9" 服务: 工人: 图片:工人 环境: - MONGO_URI="mongodb://admin:password @mongodb:27017/test?authSource=admin &readPreference=primary&ssl=false" - MONGO_DATABASE=demo2 - RABBITMQ_URI=amqp://user:password@rabbitmq:5672 - RABBITMQ_QUEUE=rss_urls 网络: -app_network external_links: -mongodb -rabbitmq 网络: 应用网络: external: true
指定工作人员需要的环境变量和网络配置。
- 定义 一个外部网络,worker、MongoDB 和 RabbitMQ 服务将在其中生存。执行以下命令:
docker network create app_network
- 重新部署 RabbitMQ 和 MongoDB 容器,但这一次,通过传递
–network
标志将它们部署在app_network
自定义网络中:docker run -d --name rabbitmq -e RABBITMQ_DEFAULT_USER=user -e RABBITMQ_DEFAULT_PASS=password -p 8080:15672 -p 5672:5672 --network app_network rabbitmq:3-management
- With the containers being configured properly, issue the following command to deploy the worker:
docker-compose up -d
-d
标志指示 Docker Compose 在后台运行容器(分离模式)。 - 发出以下命令以列出正在运行的服务:
- To scale the worker, rerun the previous command with the
–scale
flag:docker-compose up -d --scale worker=5
- 要测试所有内容,请创建一个名为
threads
的文件,其中包含 Reddit 最佳烹饪和食谱子版块的列表。为简洁起见,以下列表已被裁剪:https://www.reddit.com/r/recipes/.rss https://www.reddit.com/r/food/.rss https://www.reddit.com/r/Cooking/.rss https://www.reddit.com/r/IndianFood/.rss https://www.reddit.com/r/Baking/.rss https://www.reddit.com/r/vegan/.rss https://www.reddit.com/r/fastfood/.rss https://www.reddit.com/r/vegetarian/.rss https://www.reddit.com/r/cookingforbeginners/.rss https://www.reddit.com/r/MealPrepSunday/.rss https://www.reddit.com/r/EatCheapAndHealthy/.rss https://www.reddit.com/r/Cheap_Meals/.rss https://www.reddit.com/r/slowcooking/.rss https://www.reddit.com/r/AskCulinary/.rss https://www.reddit.com/r/fromscratch/.rss
- 然后,编写
bulk.sh
shell脚本逐行读取threads
文件,向生产者服务发出POST请求:#!/bin/bash 而 IFS= 读取 -r 线程 做 printf "\n$线程\n" curl -X POST http://localhost:5000/parse -d '{"url":"$thread"}' http://localhost:5000/parse 完成 < “线程”
- To run the script, add the execution permission and execute the file with the following command:
chmod +x bulk.sh ./bulk.sh
笔记
确保生产者服务正在运行,否则使用
curl
命令发出的 HTTP 请求将超时。脚本会逐行读取
threads
文件并发出POST请求,如下: - 运行
docker-compose logs -f
命令。这一次,您应该注意到正在使用多个 worker。此外,Docker Compose 为实例分配了颜色,并且每条消息都由不同的工作人员获取:
这样,您已成功将工作负载分配给多个工作人员。这种方法称为水平缩放。
笔记
在第 8 章中,在 AWS 上部署应用程序,我们将介绍如何在 AWS 上部署 Web 应用程序 以及如何使用 Simple Queue Service (SQS) 而不是 RabbitMQ 来扩展工作人员。
Using the NGINX reverse proxy
在上一节中,您学习了如何扩展负责解析 subreddit URL 的工作人员。在本节中,您将探索如何通过在反向代理后面提供服务来扩展我们在之前章节中构建的Recipes API。
最常用的反向代理之一是 Nginx。它面向客户端并接收传入的 HTTP(S) 请求。然后,它以循环方式将它们重定向到其中一个 API 实例。要部署 Recipes API 的多个实例,您将使用 Docker Compose 来编排容器。以下架构说明了 单实例架构和 负载平衡多实例架构与 Nginx 之间的区别:
Recipes API 扩展的另一个解决方案是垂直扩展,它包括增加服务所在系统的 CPU/RAM跑步。但是,从长远来看,这种方法往往有一些局限性(不具有成本效益)。这就是为什么在这里,您将采用水平扩展方法并在多个 API 实例之间分配负载。
笔记
Nginx 的替代品是 Traefik (https://doc.traefik.io/traefik/)。这是一个考虑到可扩展性而构建的开源项目。
要部署 Recipes API 的多个实例,请执行以下步骤:
- 构建 Docker 映像并在包含以下内容的 Recipes API 实现的文件夹中写入 Dockerfile:
FROM golang:1.16 工作目录 /go/src/github.com/api 复制 。 . RUN go mod 下载 运行 CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app 。 来自高山:最新 运行 apk --no-cache 添加 ca 证书 工作目录 /root/ 复制 --from=0 /go/src/github.com/api/app 。 CMD ["./app"]
- This
Dockerfile
uses the multi-stage feature to ship a lightweight image. Thedocker build -t recipes-api.
Command's output is as follows:构建镜像后,创建一个
docker-compose.yml
文件并定义三个服务: - 然后,在文件中添加 以下行:
version: "3.9" 服务: 接口: 图片:recipes-api 环境: - MONGO_URI=mongodb://admin:password @mongodb:27017/test?authSource=admin &readPreference=primary&ssl=false - MONGO_DATABASE=demo - REDIS_URI=redis:6379 网络: - api_network external_links: -mongodb -redis 雷迪斯: 图片:redis 网络: - api_network 蒙哥达: 图片:mongo:4.4.3 网络: - api_network 环境: - MONGO_INITDB_ROOT_USERNAME=admin - MONGO_INITDB_ROOT_PASSWORD=密码 网络: api_network:
- 接下来,使用以下代码块定义 Nginx 服务:
nginx: 图片:nginx 端口: - 80:80 卷: - $PWD/nginx.conf:/etc/nginx/nginx.conf 取决于: -api 网络: - api_network
此代码将
nginx.conf
映射到容器内的 - 在
location /api
中,设置一个反向代理,将请求转发到端口8080
(内部)。 - 使用
docker-compose up –d
命令部署整个堆栈。然后,发出以下命令以显示正在运行的服务:docker-compose ps
该命令的输出如下:
- The Nginx service is exposed on port
80
. Head tolocalhost/api/recipes
; the server will call the Recipes API and forward the recipes list response, as follows:笔记
对于生产用途,使用 HTTPS 保护 API 端点非常重要。幸运的是,您可以使用 Nginx 的“Let's Encrypt”插件自动生成 TLS 证书。
- 要确保从 Recipes API 转发响应,请使用以下命令检查 Nginx 服务日志:
docker-compose logs –f nginx
- 您应该会看到类似这样的内容:
/docker-entrypoint.sh: /docker-entrypoint.d/ 不为空,将尝试执行配置 /docker-entrypoint.sh:在 /docker-entrypoint.d/ 中寻找 shell 脚本 /docker-entrypoint.sh:启动 /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh 10-listen-on-ipv6-by-default.sh: info: 获取 /etc/nginx/conf.d/default.conf 的校验和 10-listen-on-ipv6-by-default.sh:信息:启用在 /etc/nginx/conf.d/default.conf 中侦听 IPv6 /docker-entrypoint.sh:启动 /docker-entrypoint.d/20-envsubst-on-templates.sh /docker-entrypoint.sh:启动 /docker-entrypoint.d/30-tune-worker-processes.sh /docker-entrypoint.sh:配置完成;准备启动 172.21.0.1 - - [21/Mar/2021:18:11:02 +0000] "GET /api/recipes HTTP/1.1" 200 2 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/ 537.36(KHTML,如 Gecko)Chrome/88.0.4324.192 Safari/537.36"
- 到目前为止,Recipes API 容器的一个实例正在运行。要扩展它,请使用
docker-compose up
命令的–scale
标志或在docker-compose up
命令中定义副本数code class="literal">docker-compose.yml 文件,如下:api: 图片:recipes-api 环境: - MONGO_URI=mongodb://admin:password @mongodb:27017/test?authSource=admin &readPreference=primary&ssl=false - MONGO_DATABASE=demo - REDIS_URI=redis:6379 网络: - api_network external_links: -mongodb -redis 规模:5
- Re-execute the
docker-compose up
command. Four additional services will be created based on the Recipes API Docker image. Following are the service logs:现在,当客户端发送请求时,它将命中 Nginx,然后以循环方式转发到其中一个 API 服务。这有助于我们平均分配负载。
笔记
在第 10 章,捕获 Gin 应用程序指标中,我们将学习如何设置监控平台以在需求增加时触发横向扩展事件以增加服务数量。
使用反向代理的优点是您可以为整个分布式 Web 应用程序设置一个入口点。后端和 Web 应用程序 将位于相同的 URL。这样,您就不需要在 API 服务器上处理 CORS。
- Similar to the Recipes API, create a Docker image for the
react-ui
service. The following is the content of ourDockerfile
:FROM node:14.15.1 COPY package-lock.json . COPY package.json . RUN npm install CMD npm start
如您所见,这非常简单。在这里,您使用的是预构建的 Node.js 基础映像,因为
react-ui
服务是用 JavaScript 编写的。 - 使用
`docker build -t dashboard'
构建 Docker 镜像。然后,更新docker-compose.yml
以便它使用以下代码块从映像运行 Docker 服务:dashboard: 图片:仪表板 网络: - api_network
- 接下来,更新
nginx.conf
以便它将位于 URL 根级别的传入请求转发到仪表板服务:事件{ worker_connections 1024; } http { server_tokens 关闭; 服务器 { 听着 80; root /var/www; 位置/{ proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header 主机 $http_host; proxy_pass http://dashboard:3000/; } 位置 /api/ { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header 主机 $http_host; proxy_pass http://api:8080/; } } }
- 重新运行
docker-compose up
-d
命令以使更改生效: - 前往
localhost/dashboard
;您将被重定向到我们在上一章中编写的 的 Web 仪表板:
现在,RESTful API 和仪表板都由同一个域名提供服务。
要取下容器,您可以使用以下命令:
docker-compose down
笔记
如果您使用基于会话的身份验证,则需要在 Nginx 上配置 cookie 粘性,以将用户的会话保持在启动它的服务器上。
您还可以通过将仪表板位置替换为另一个位置部分,将仪表板位置替换为">nginx.conf 文件:
location /reddit/ { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $http_host; proxy_pass http://reddit-trending:3000/; }
与 react-ui
类似,为 subreddits 应用程序创建一个 Docker 镜像。以下是我们的Dockerfile
的内容:
FROM node:14.15.1 COPY . . COPY package-lock.json . COPY package.json . RUN npm install CMD npm start
在这里,您使用的是预构建的 Node.js 基础映像,因为仪表板服务是用 JavaScript 编写的。使用 "docker build -t dashboard"
构建 Docker 镜像。
另外,不要忘记将应用程序添加到 docker-compose.yml
文件中:
reddit-trending: image: web networks: - api_network
一旦您使用 docker-compose
重新部署了堆栈,前往 localhost/reddit
;您将被重定向到以下 UI:
应用程序布局被破坏,因为 app.css
文件是从错误的后端提供的。您可以通过在 Chrome 上打开调试控制台来确认这一点:
您可以通过将以下代码块添加到 nginx.conf
:
location /assets/css/app.css { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $http_host; proxy_pass http://reddit-trending:3000/assets /css/app.css; }
现在,刷新网页;应用程序布局将被修复:
如您所见,仪表板显示食谱缩略图。然后,每次您刷新页面时,这些图像都会从 后端提供。为了减轻后端的压力,您可以配置 Nginx 来缓存静态文件。在 Nginx 配置文件的 server
部分之前插入以下代码片段:
map $sent_http_content_type $expires { default off; text/html epoch; text/css max; application/javascript max; ~image/ max; }
~image
关键字将处理各种图像(PNG、JPEG、GIF、SVG 等)。现在,使用 server
部分中的 expires
指令配置过期:
http { server { listen 80; expires $expires; ... } }
docker-compose up –d
现在应该缓存图像,这减少了到达后端的请求数量。在下一节中,我们将介绍如何在后端使用 Gin 获得相同的结果。
Caching assets with HTTP cache headers
您还可以使用 Gin 框架管理缓存。为了说明这一点,编写一个简单的 Web 应用程序来提供图像。代码如下:
func IllustrationHandler(c *gin.Context) { c.File("illustration.png") } func main() { router := gin.Default() router.GET("/illustration", IllustrationHandler) router.Run(":3000") }
当用户点击 /illustration
资源 URL 时,应用程序应该提供图像:
因为 总是传送相同的图像,所以我们需要确保我们正在缓存图像。这样,我们可以避免不必要的流量并获得更好的网络性能。让我们看看这是怎么做到的。
Setting HTTP caching headers
要缓存此 HTTP 请求,您可以附加 实体标签(ETag ) 到 HTTP 响应标头。当用户发送 HTTP 请求时,服务器会读取 HTTP 头并检查 If-None-Match
字段是否有 Etag
键值。如果
If-None-Match
字段与生成的键匹配,则将返回 304 状态代码:
func IllustrationHandler(c *gin.Context) { c.Header("Etag", "illustration") c.Header("Cache-Control", "max-age=2592000") if match := c.GetHeader("If-None-Match"); match != "" { if strings.Contains(match, "illustration") { c.Writer.WriteHeader(http.StatusNotModified) return } } c.File("illustration.png") }
使用前面的代码更新 HTTP 处理程序后,对其进行测试。当您第一次 请求 /illustration
资源时,您应该获得 200 的状态好的
。但是,对于第二个请求,您应该得到 304 StatusNotModified
响应:
您可能还注意到第二个请求的延迟比第一个请求的延迟更短。通过将查询数量保持在最低限度,您可以减轻 API 对应用程序性能的影响。
Further reading
- RabbitMQ Essentials – Second Edition, 作者:Lovisa Johansson,Packt Publishing
- Docker for Developers,作者:Richard Bullington-McGuire、Andrew K. Dennis、Michael Schwartz,Packt Publishing。