Go-云原生编程(全)

Go 云原生编程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

云计算和微服务是现代软件架构中非常重要的概念。它们代表了雄心勃勃的软件工程师需要掌握的关键技能,以便设计和构建能够执行和扩展的软件应用程序。Go 是一种现代的跨平台编程语言,非常强大而简单;它是微服务和云应用的绝佳选择。Go 正变得越来越受欢迎,成为一种非常有吸引力的技能。

本书将带您进入微服务和云计算的世界,借助 Go 语言。它将从涵盖云应用程序的软件架构模式开始,以及关于如何扩展、分发和部署这些应用程序的实际概念。从那里,本书将深入探讨编写生产级微服务及其在典型云环境中部署所需的技术和设计方法。

完成本书后,您将学会如何编写有效的生产级微服务,可以部署到云中,实际了解亚马逊云服务的世界,并知道如何构建非平凡的 Go 应用程序。

本书涵盖的内容

《现代微服务架构》第一章通过描述基于云的应用程序和微服务架构的典型特征来开启本书。我们还将为一个虚构的应用程序建立需求和高层架构,该应用程序将作为本书后续章节的持续示例。

第二章《使用 REST API 构建微服务》讨论了如何使用 Go 语言构建现代微服务。我们将涵盖重要且非平凡的主题。通过本章的学习,您将具备足够的知识来构建可以暴露 RESTFul API、支持持久性并能有效与其他服务通信的微服务。

第三章《保护微服务》向您展示如何保护您的微服务。您将学习如何在 Go 语言中处理证书和 HTTPS。

第四章《异步微服务架构》介绍了如何使用消息队列实现异步微服务架构。为此,我们将概述已建立的消息队列软件,如 RabbitMQ 和 Apache Kafka,并介绍 Go 库,以将这些组件集成到您的软件中。我们还将讨论与异步架构配合良好的事件协作和事件溯源等架构模式。

第五章《使用 React 构建前端》从 Go 世界稍微偏离,进入 JavaScript 世界,并向您展示如何使用 React 框架为基于微服务的项目构建 Web 前端。为此,我们将简要概述 React 的基本架构原则,以及如何为现有的 REST API 构建基于 React 的前端。

第六章《在容器中部署您的应用程序》展示了如何使用应用程序容器以便携和可重复的方式部署 Go 应用程序。您将学习安装和使用 Docker,以及如何为自己的 Go 应用程序构建自定义 Docker 镜像。此外,我们还将描述如何使用 Kubernetes 编排引擎在大规模云环境中部署容器化应用程序。

第七章《AWS - 基础知识,AWS Go SDK 和 AWS EC2》是两章中的第一章,涵盖了 AWS 生态系统。在本章中,我们将详细介绍 AWS。您将接触到一些重要的概念,比如如何设置 AWS 服务器实例,如何利用 AWS API 功能,以及如何编写能够与 AWS 交互的 Go 应用程序。

第八章,“AWS – S3、SQS、API Gateway 和 DynamoDB”,继续更详细地介绍了 AWS 生态系统。您将深入了解 AWS 世界中的热门服务。通过本章结束时,您将具备足够的知识,能够利用亚马逊云服务的功能构建非平凡的 Go 云应用程序。

第九章,“持续交付”,描述了如何为 Go 应用程序实现基本的持续交付流水线。为此,我们将描述持续交付的基本原则,以及如何使用 Travis CI 和 Gitlab 等工具实现简单的流水线。我们将使用 Docker 镜像作为部署工件,并将这些镜像部署到 Kubernetes 集群中,从而构建在第四章,“异步微服务架构”中涵盖的主题和技能基础上。

第十章,“监控您的应用程序”,向您展示了如何使用 Prometheus 和 Grafana 监控您的微服务架构。我们将介绍 Prometheus 的基本架构,并描述如何使用 Docker 设置 Prometheus 实例。此外,您还将学习如何调整您的 Go 应用程序以公开可以被 Prometheus 抓取的指标。我们还将描述如何使用 Grafana 为 Prometheus 设置图形用户界面。

第十一章,“迁移”,涵盖了从传统的单片应用程序迁移到现代微服务云应用程序时需要考虑的实际因素和方法。

第十二章,“下一步该去哪里?”,向您展示了从这里继续学习旅程的方向。它将涵盖其他现代与云相关的技术,值得探索,比如替代通信协议、其他云提供商,以及可能成为下一个大事件的新架构范式。

本书所需内容

对于本书,您应该具备一些 Go 编程语言的基本知识(如果您仍在寻求开始学习 Go,我们可以推荐 Packt 出版的 Vladimir Vivien 的书《学习 Go 编程》)。为了运行本书提供的代码示例,您还需要在本地计算机上安装一个可用的 Go SDK(Go 1.7 或更新版本)。请前往golang.org/dl/获取下载和安装说明。

在本书的许多实际示例中,您将需要一个可用的 Docker 安装(尽管不需要有使用 Docker 的先前经验)。请查看www.docker.com/community-edition获取下载和安装说明。

在第五章,“使用 React 构建前端”,您还需要一些基本的 JavaScript 编程知识,以及本地计算机上安装的 Node.JS。您可以从nodejs.org/en/#download下载当前版本的 Node.JS。

本书的目标读者

本书面向希望构建安全、弹性、健壮和可扩展云原生应用程序的 Go 开发人员。一些关于 Web 服务和 Web 编程的知识应该足以帮助您完成本书。

约定

在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄都显示如下:“react-router-dom包为我们的应用程序添加了一些新组件。”

代码块设置如下:

import * as React from "react"; 
import {Link} from "react-router-dom"; 

export interface NavigationProps { 
  brandName: string; 
} 

export class Navigation extends React.Component<NavigationProps, {}> { 
} 

任何命令行输入或输出都将如下所示:

$ npm install --save react-router-dom
$ npm install --save-dev @types/react-router-dom

新术语重要词汇以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中出现,就像这样:“为此,请在登录后点击“创建存储库”,并为您的图像选择一个新名称。”

警告或重要提示会以这样的方式出现在框中。

提示和技巧会以这样的方式出现。

第一章:现代微服务架构

在计算和软件领域,我们几乎每周都会听到许多新的、酷炫的技术和框架。有些技术会留存并持续发展,而其他一些则无法经受时间的考验而消失。毫无疑问,云计算非常舒适地属于前一类。我们生活在一个云计算几乎支配着一切需要严肃的后端计算能力的世界,从检查冰箱温度的物联网设备到向你展示多人游戏中实时得分与同伴相比较的视频游戏。

云计算使遍布全球的大型企业以及在咖啡店写代码的两个人的小型初创公司受益匪浅。有大量的材料解释了为什么云计算对现代信息技术如此重要。为了效率起见,我们将直接回答这个问题,而不会列出长长的要点、图表和冗长的段落。对于企业来说,一切都是为了赚钱和节省成本。云计算显著降低了大多数组织的成本。这是因为云计算节省了建立自己数据中心的成本。不需要购买昂贵的硬件,也不需要委托昂贵的带有花哨空调系统的建筑。此外,几乎所有的云计算服务都可以让你只支付你使用的部分。云计算还为软件工程师和 IT 管理员提供了巨大的灵活性,使他们能够快速高效地完成工作,从而实现开发人员的幸福和增加生产力。

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

  • 云原生应用的设计目标,尤其是可扩展性

  • 不同的云服务模型

  • 十二要素应用

  • 微服务架构

  • 通信模式,尤其是同步与异步通信

为什么选择 Go?

Go(或 Golang)是一种相对较新的编程语言,正在以其独特之处席卷软件开发世界。它是由谷歌开发的,旨在简化其后端软件服务的构建。然而,现在许多企业和初创公司都在使用它来编写强大的应用程序。Go 的独特之处在于,它从头开始构建,旨在提供与 C/C++等非常强大的语言竞争的性能,同时支持类似 JavaScript 等动态语言的相对简单的语法。Go 运行时提供垃圾回收;但它不依赖虚拟机来实现。Go 程序被编译成本机代码。在调用 Go 编译器时,你只需选择构建时希望二进制文件在哪个平台(Windows、Mac 等)上运行。编译器将会生成适用于该平台的单个二进制文件。这使得 Go 能够进行交叉编译并生成本机二进制文件。

Go 语言非常适合微服务架构,这在未来会变得非常普遍。微服务架构是一种架构,其中你将应用程序的责任分配给只专注于特定任务的较小服务。这些服务可以相互通信,以获取它们需要产生结果的信息。

Go 是一种新的编程语言,是在云计算时代开发的,考虑了现代软件技术。由于 Go 程序大多编译为单个二进制文件,使得在生产环境中几乎不需要依赖和虚拟机,因此 Go 被优化用于便携式微服务架构。Go 也是容器技术的先驱。Docker,软件容器的顶级名称,就是用 Go 编写的。由于 Go 的流行,主要云提供商以及第三方贡献者正在努力确保 Go 获得其在不同云平台所需的 API 支持。

本书的目标是在 Go 编程语言和现代计算的云技术之间建立知识桥梁。在本书中,您将获得关于 Go 微服务架构、消息队列、容器、云平台 Go API、SaaS 应用程序设计、监控云应用程序等方面的实际知识。

基本设计目标

为了充分利用现代云平台的优势,我们在开发应用程序时需要考虑其特性属性。

云应用程序的主要设计目标之一是可扩展性。一方面,这意味着根据需要增加应用程序的资源,以有效地为所有用户提供服务。另一方面,它还意味着在不再需要这些资源时将资源缩减到适当的水平。这使您能够以成本效益的方式运行应用程序,而无需不断地为高峰工作负载进行过度配置。

为了实现这一点,典型的云部署通常使用托管应用程序的小型虚拟机实例,并通过添加(或移除)更多这些实例来进行扩展。这种扩展方法称为水平扩展横向扩展,与垂直扩展纵向扩展相对应,后者不增加实例数量,而是为现有实例提供更多资源。出于几个原因,水平扩展通常优于垂直扩展。首先,水平扩展承诺无限的线性可扩展性。另一方面,由于现有服务器可以添加的资源数量不能无限增长,垂直扩展存在其限制。其次,水平扩展通常更具成本效益,因为您可以使用廉价的通用硬件(或在云环境中使用较小的实例类型),而较大的服务器通常会呈指数增长地更加昂贵。

水平扩展与垂直扩展;前者通过添加更多实例并在它们之间平衡工作负载来工作,而后者通过向现有实例添加更多资源来工作

所有主要的云提供商都提供根据应用程序当前资源利用率自动执行水平扩展的能力。这个功能称为自动扩展。不幸的是,您并不能免费获得水平扩展。为了能够进行扩展,您的应用程序需要遵循一些非常重要的设计目标,这些目标通常需要从一开始就考虑,如下所示:

  • 无状态性:云应用程序的每个实例都不应该有任何内部状态(这意味着任何类型的数据都保存在内存中或文件系统上以备后用)。在扩展场景中,后续请求可能由应用程序的另一个实例提供服务,因此必须不依赖于之前请求中存在任何状态。为了实现这一点,通常需要将任何类型的持久存储(如数据库和文件系统)外部化。数据库服务和文件存储通常由您在应用程序中使用的云提供商作为托管服务提供。

当然,这并不意味着你不能将有状态的应用部署到云上。它们只是会更难以扩展,阻碍你充分利用云计算环境。

  • 部署简便性:在扩展时,您需要快速部署应用程序的新实例。创建新实例不应该需要任何手动设置,而应尽可能自动化(理想情况下完全自动化)。

  • 弹性:在云环境中,特别是在使用自动扩展时,实例可能会在瞬间被关闭。此外,大多数云服务提供商不保证单个实例的极高可用性(并建议进行扩展,可选地跨多个可用区)。因此,终止和突然死亡(无论是有意的,例如自动扩展,还是无意的,例如故障)是我们在云环境中始终需要预期的事情,应用程序必须相应地处理。

实现这些设计目标并不总是容易的。云服务提供商通常通过提供托管服务(例如高度可扩展的数据库服务或分布式文件存储)来支持您完成这项任务,否则您将不得不自己担心这些问题。关于您的实际应用程序,有十二要素应用方法论(我们将在后面的部分详细介绍),它描述了构建可扩展和有弹性的应用程序的一套规则。

云服务模型

在云计算提供中,有三种主要的服务模型可供您考虑:

  • IaaS基础设施即服务):这是云服务提供商为您提供云上基础设施的模型,例如服务器(虚拟和裸金属)、网络、防火墙和存储设备。当您只需要云提供商为您管理基础设施并摆脱维护的麻烦和成本时,您可以使用 IaaS。创业公司和希望对应用程序层拥有完全控制的组织使用 IaaS。大多数 IaaS 提供都带有动态或弹性扩展选项,根据您的消耗来扩展您的基础设施。这实际上可以节省组织的成本,因为他们只支付他们使用的部分。

  • PaaS平台即服务):这是从 IaaS 上一层的服务。PaaS 提供了您运行应用程序所需的计算平台。PaaS 通常包括您开发应用程序所需的操作系统、数据库、Web 层(如果需要)和编程语言执行环境。使用 PaaS,您不必担心应用程序环境的更新和补丁;这些都由云服务提供商来处理。假设您编写了一个强大的.NET 应用程序,希望在云中运行。PaaS 解决方案将提供您运行应用程序所需的.NET 环境,结合 Windows 服务器操作系统和 IIS Web 服务器。它还将负责大型应用程序的负载平衡和扩展。想象一下,通过采用 PaaS 平台而不是在内部进行努力,您可以节省多少金钱和精力。

  • SaaS软件即服务):这是作为云解决方案可以获得的最高层。SaaS 解决方案是指通过网络交付的完全功能的软件。您可以从 Web 浏览器访问 SaaS 解决方案。SaaS 解决方案通常由软件的普通用户使用,而不是程序员或软件专业人员。一个非常著名的 SaaS 平台的例子是 Netflix——一个复杂的软件,托管在云中,可以通过网络访问。另一个流行的例子是 Salesforce。Salesforce 解决方案通过 Web 浏览器以速度和效率交付给客户。

云应用架构模式

通常,在云环境中开发应用程序并不比常规应用程序开发有太大的不同。然而,在针对云环境时,有一些特别常见的架构模式,你将在下一节中学到。

十二要素应用

十二要素应用方法论是一组用于构建可扩展和具有弹性的云应用程序的规则。它由 Heroku 发布,是主要的 PaaS 提供商之一。然而,它可以应用于各种云应用程序,独立于具体的基础设施或平台提供商。它也独立于编程语言和持久化服务,并且同样适用于 Go 编程和例如 Node.js 编程。十二要素应用方法论描述了(不出所料的)十二个因素,你应该在应用程序中考虑这些因素,以便它易于扩展、具有弹性并且独立于平台。你可以在12factor.net上阅读每个因素的完整描述。在本书中,我们将重点介绍一些我们认为特别重要的因素:

  • 因素 II:依赖-明确声明和隔离依赖:这个因素值得特别提及,因为在 Go 编程中它实际上并不像在其他语言中那么重要。通常,云应用程序不应该依赖于系统上已经存在的任何必需的库或外部工具。依赖应该被明确声明(例如,使用 Node.js 应用程序的 npm package.json文件),这样一个包管理器在部署应用程序的新实例时可以拉取所有这些依赖。在 Go 中,一个应用程序通常部署为一个已经包含所有必需库的静态编译二进制文件。然而,即使是一个 Go 应用程序也可能依赖于外部系统工具(例如,它可以调用像 ImageMagick 这样的工具)或现有的 C 库。理想情况下,你应该将这些工具与你的应用程序一起部署。这就是容器引擎(如 Docker)的优势所在。

  • 因素 III:配置-在环境中存储配置:配置是可能因不同部署而变化的任何类型的数据,例如外部服务和数据库的连接数据和凭据。这些类型的数据应该通过环境变量传递给应用程序。在 Go 应用程序中,获取这些数据就像调用os.Getenv("VARIABLE_NAME")一样简单。在更复杂的情况下(例如,当你有许多配置变量时),你也可以使用诸如github.com/tomazk/envcfggithub.com/caarlos0/env这样的库。对于繁重的工作,你可以使用github.com/spf13/viper库。

  • 因素 IV:后备服务-将后备服务视为附加资源:确保应用程序依赖的服务(如数据库、消息系统或外部 API)可以通过配置轻松替换。例如,你的应用程序可以接受一个环境变量,比如DATABASE_URL,它可能包含mysql://root:root@localhost/test用于本地开发部署,以及mysql://root:XXX@prod.XXXX.eu-central-1.rds.amazonaws.com用于生产环境设置。

  • 因素 VI:进程-将应用程序作为一个或多个无状态进程执行:运行应用程序实例应该是无状态的;任何需要持久化超出单个请求/事务的数据都需要存储在外部持久化服务中。

在构建可扩展和具有弹性的云应用程序时,有一个重要的案例需要牢记,那就是 Web 应用程序中的用户会话。通常,用户会话数据存储在进程的内存中(或者持久化到本地文件系统),期望同一用户的后续请求将由应用程序的同一实例提供。相反,尝试保持用户会话无状态,或者将会话状态移入外部数据存储,比如 Redis 或 Memcached。

  • 第九因素:可处置性-通过快速启动和优雅关闭最大限度地提高鲁棒性:在云环境中,需要预期突然终止(无论是有意的,例如在缩减规模的情况下,还是无意的,在失败的情况下)。十二因素应用程序应具有快速的启动时间(通常在几秒钟的范围内),使其能够快速部署新实例。此外,快速启动和优雅终止是另一个要求。当服务器关闭时,操作系统通常会通过发送SIGTERM信号告诉您的应用程序关闭,应用程序可以捕获并做出相应反应(例如,停止监听服务端口,完成当前正在处理的请求,然后退出)。

  • 第十一因素:日志-将日志视为事件流:日志数据通常用于调试和监视应用程序的行为。但是,十二因素应用程序不应关心其自己日志数据的路由或存储。最简单的解决方案是将日志流写入进程的标准输出流(例如,只需使用fmt.Println(...))。将事件流式传输到stdout允许开发人员在开发应用程序时简单地观看事件流。在生产环境中,您可以配置执行环境以捕获进程输出并将日志流发送到可以处理的地方(这里的可能性是无限的-您可以将它们存储在服务器的journald中,将它们发送到 syslog 服务器,将日志存储在 ELK 设置中,或将它们发送到外部云服务)。

什么是微服务?

当一个应用程序在较长时间内由许多不同的开发人员维护时,它往往会变得越来越复杂。错误修复、新的或变化的需求以及不断变化的技术变化导致您的软件不断增长和变化。如果不加控制,这种软件演变将导致您的应用程序变得更加复杂和越来越难以维护。

防止这种软件侵蚀的目标是过去几年中出现的微服务架构范式。在微服务架构中,软件系统被分割成一组(可能很多)独立和隔离的服务。这些作为单独的进程运行,并使用网络协议进行通信(当然,这些服务中的每一个本身都应该是一个十二因素应用程序)。有关该主题的更全面介绍,我们可以推荐 Lewis 和 Fowler 在martinfowler.com/articles/microservices.html上关于微服务架构的原始文章。

与传统的面向服务的架构(SOA)相比,这种架构已经存在了相当长的时间,微服务架构注重简单性。复杂的基础设施组件,如 ESB,应尽一切可能避免,而复杂的通信协议,如 SOAP,更倾向于更简单的通信方式,如 REST Web 服务(关于这一点,您将在第二章中了解更多,使用 Rest API 构建微服务)或 AMQP 消息传递(参见第四章,使用消息队列的异步微服务架构)。

将复杂软件拆分为单独的组件有几个好处。例如,不同的服务可以构建在不同的技术堆栈上。对于一个服务,使用 Go 作为运行时和 MongoDB 作为持久层可能是最佳选择,而对于其他组件,使用 Node.js 运行时和 MySQL 持久层可能是更好的选择。将功能封装在单独的服务中允许开发团队为正确的工作选择正确的工具。在组织层面上,微服务的其他优势是每个微服务可以由组织内的不同团队拥有。每个团队可以独立开发、部署和操作他们的服务,使他们能够以非常灵活的方式调整他们的软件。

部署微服务

由于它们专注于无状态和水平扩展,微服务与现代云环境非常匹配。然而,选择微服务架构时,总体上部署应用程序将变得更加复杂,因为您将需要部署更多不同的应用程序(这更加坚定了坚持十二要素应用程序方法论的理由)。

然而,每个单独的服务将比一个大型的单体应用程序更容易部署。根据服务的大小,将更容易将服务升级到新的运行时,或者完全替换为新的实现。此外,您可以单独扩展每个微服务。这使您能够在保持使用较少的组件成本高效的同时,扩展应用程序中使用频繁的部分。当然,这要求每个服务都支持水平扩展。

部署微服务在不同服务使用不同技术时变得更加复杂。现代容器运行时(如 Docker 或 RKT)提供了这个问题的一个可能解决方案。使用容器,您可以将应用程序及其所有依赖项打包到一个容器映像中,然后使用该映像快速生成一个在任何可以运行 Docker(或 RKT)容器的服务器上运行您的应用程序的容器。(让我们回到十二要素应用程序——在容器中部署应用程序是要素 II规定的依赖项隔离的最彻底解释之一。)

许多主要云提供商(如 AWS 的弹性容器服务Azure 容器服务Google 容器引擎)提供运行容器工作负载的服务。除此之外,还有容器编排引擎,如Docker SwarmKubernetesApache Mesos,您可以在 IaaS 云平台或自己的硬件上部署。这些编排引擎提供了在整个服务器集群上分发容器工作负载的可能性,并提供了非常高的自动化程度。例如,集群管理器将负责在任意数量的服务器上部署容器,根据它们的资源需求和使用自动分发它们。许多编排引擎还提供自动扩展功能,并且通常与云环境紧密集成。

您将在第六章中了解有关使用 Docker 和 Kubernetes 部署微服务的更多信息,在容器中部署您的应用程序

REST 网络服务和异步消息

在构建微服务架构时,您的各个服务需要相互通信。微服务通信的一个被广泛接受的事实标准是 RESTful 网络服务(关于这一点,您将在第二章和第三章中了解更多,使用 Rest API 构建微服务保护微服务)。这些通常建立在 HTTP 之上(尽管 REST 架构风格本身更多或多少是协议独立的),并遵循请求/回复通信模型的客户端/服务器模型。

同步与异步通信模型

这种架构通常易于实现和维护。它适用于许多用例。然而,同步请求/响应模式在实现跨多个服务的复杂流程的系统时可能会受到限制。考虑前图的第一部分。在这里,我们有一个用户服务,管理应用程序的用户数据库。每当创建新用户时,我们需要确保系统中的其他服务也知道这个新用户。使用 RESTful HTTP,用户服务需要通过 REST 调用通知其他服务。这意味着用户服务需要知道所有其他受用户管理领域影响的服务。这导致组件之间的紧耦合,这通常是您希望避免的。

可以解决这些问题的另一种通信模式是发布/订阅模式。在这里,服务发出其他服务可以监听的事件。发出事件的服务不需要知道哪些其他服务实际上正在监听这些事件。再次考虑前图的第二部分—在这里,用户服务发布一个事件,说明刚刚创建了一个新用户。其他服务现在可以订阅此事件,并在创建新用户时得到通知。这些架构通常需要使用一个特殊的基础设施组件:消息代理。该组件接受发布的消息并将其路由到其订阅者(通常使用队列作为中间存储)。

发布/订阅模式是一种非常好的方法,可以将服务解耦—当一个服务发布事件时,它不需要关心它们将去哪里,当另一个服务订阅事件时,它也不知道它们来自哪里。此外,异步架构往往比同步通信更容易扩展。通过将消息分发给多个订阅者,可以轻松实现水平扩展和负载平衡。

不幸的是,没有免费的午餐;这种灵活性和可伸缩性是以额外的复杂性为代价的。此外,跨多个服务调试单个事务变得困难。是否接受这种权衡需要根据具体情况进行评估。

在第四章中,使用消息队列的异步微服务架构,您将了解更多关于异步通信模式和消息代理的信息。

MyEvents 平台

在本书中,我们将构建一个名为MyEvents的有用的 SaaS 应用程序。MyEvents 将利用您将学习的技术,成为一个现代、可扩展、云原生和快速的应用程序。MyEvents 是一个活动管理平台,允许用户预订世界各地的活动门票。使用 MyEvents,您将能够为自己和同伴预订音乐会、嘉年华、马戏团等活动的门票。MyEvents 将记录预订、用户和活动举办地的不同位置。它将有效地管理您的预订。

我们将利用微服务、消息队列、ReactJS、MongoDB、AWS 等技术构建 MyEvents。为了更好地理解应用程序,让我们来看看我们的整体应用程序将要管理的逻辑实体。它们将由多个微服务管理,以建立明确的关注点分离,并实现我们需要的灵活性和可伸缩性:

我们将有多个用户;每个用户可以为事件预订多次,每个预订将对应一个事件。对于我们的每一个事件,都会有一个位置,事件发生的地方。在位置内,我们需要确定事件发生的大厅或房间。

现在,让我们来看看微服务架构和构成我们应用程序的不同组件:

微服务架构

我们将使用 ReactJS 前端与我们应用程序的用户进行交互。ReactJS UI 将使用 API 网关(AWS 或本地)与构成我们应用程序主体的不同微服务进行通信。有两个主要的微服务代表了 MyEvents 的逻辑:

  • 事件服务:这是处理事件、它们的位置以及发生在它们身上的变化的服务

  • 预订服务:此服务处理用户的预订

我们所有的服务将使用基于消息队列的发布/订阅架构进行集成。由于我们的目标是为您提供微服务和云计算领域的实用知识,我们将支持多种类型的消息队列。我们将支持KafkaRabbitMQ和 AWS 的SQS

持久层还将支持多种数据库技术,以便让您接触到各种实用的数据库引擎,从而增强您的项目。我们将支持MongoDBDynamoDB

我们所有的服务都将支持指标 API,这将允许我们通过Prometheus监控我们服务的统计数据。

MyEvents 平台的设计方式将为您构建微服务和云计算强大的知识基础和曝光。

摘要

在这个介绍性的章节中,您了解了云原生应用程序开发的基本设计原则。这包括设计目标,如支持(水平)可伸缩性和弹性,以及架构模式,如十二要素应用程序和微服务架构。

在接下来的章节中,您将学习在构建 MyEvents 应用程序时应用许多这些原则。在第二章中,使用 Rest API 构建微服务,您将学习如何使用 Go 编程语言实现提供 RESTful web 服务的小型微服务。在接下来的章节中,您将继续扩展这个小应用程序,并学习如何在各种云环境中处理部署和操作这个应用程序。

第二章:使用 Rest API 构建微服务

在本章中,我们将踏上学习微服务世界的旅程。我们将了解它们的结构、它们的通信方式以及它们如何持久化数据。由于今天大多数现代云应用程序在生产中都依赖微服务来实现弹性和可伸缩性,微服务的概念是一个需要涵盖的关键概念。

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

  • 深入了解微服务架构

  • RESTful web API

  • 在 Go 语言中构建 RESTful API

背景

我们在第一章中提供了微服务的实际定义。在本章中,让我们更详细地定义微服务。

为了充分理解微服务,让我们从它们崛起的故事开始。在微服务的概念变得流行之前,大多数应用程序都是单体的。单体应用程序是一个试图一次完成许多任务的单一应用程序。然后,随着需要新功能,应用程序会变得越来越庞大。这实际上会导致长期来看应用程序难以维护。随着云计算和大规模负载的分布式应用程序的出现,更灵活的应用程序架构的需求变得明显。

在第一章中,《现代微服务架构》,我们介绍了 MyEvents 应用程序,这是我们在本书中将要构建的应用程序。MyEvents 应用程序用于管理音乐会、戏剧等活动的预订。该应用程序的主要任务包括以下内容:

  • 处理预订:例如,用户预订了下个月的音乐会。我们需要存储这个预订,确保这个活动有座位可用,并确认之前没有用相同的姓名进行过预订,等等。

  • 处理活动:我们的应用程序需要了解我们预计要支持的所有音乐会、戏剧和其他类型的活动。我们需要知道活动地址、座位总数、活动持续时间等。

  • 处理搜索:我们的应用程序需要能够执行高效的搜索来检索我们的预订和活动。

以下图片显示了 MyEvents 的单体应用程序设计的样子:

单体应用程序

我们将在应用程序中构建多个软件层来处理每个需要的不同任务。我们的应用程序将成为一个具有庞大代码库的程序。由于代码都是相互连接的,一个层的变化总会影响其他层的代码。

由于它是一个单一程序,要在不同的编程语言中编写一些软件层不会很容易。当你知道语言 X 中有一个非常好的库来支持特性 Y 时,这通常是一个非常好的选择,但是语言 X 对于特性 Z 并不好。

此外,随着添加新功能或层,您的单一程序将不断增长,而没有良好的可伸缩性选项。能否在不同的服务器上运行不同的软件层,以便您可以控制应用程序的负载,而不是在一两台服务器上增加更多的硬件呢?

软件工程师们长期以来一直试图解决单体应用程序的困境。微服务是解决单体应用程序带来的问题的一种方法。在微服务这个术语变得流行之前,有 SOA 的概念,原则上类似于微服务。

在我们更深入地了解微服务之前,值得一提的是,单片应用程序并不总是坏的。这一切取决于您想要实现什么。如果您试图构建一个预期具有有限任务集的应用程序,并且不预期增长很多,那么一个单一构建良好的应用程序可能就是您所需要的。另一方面,如果您试图构建一个复杂的应用程序,预期执行许多独立任务,由多人维护,同时处理大量数据负载,那么微服务架构就是您的朋友。

那么,什么是微服务?

简而言之,微服务是这样的理念,即不是将所有代码放在一个篮子里(单片应用程序),而是编写多个小型软件服务或微服务。每个服务都预期专注于一个任务并且执行得很好。这些服务的累积将构成您的应用程序。

微服务应用程序

对于 MyEvents 应用程序,单片应用程序中的每个软件层将转化为一个软件服务。然后,它们将一起通信以构成我们的应用程序。这些软件服务中的每一个实际上都是一个微服务。

由于这些服务合作构建复杂的应用程序,它们需要能够通过它们都理解的协议进行通信。使用 Web Restful API 进行通信的微服务广泛使用 HTTP 协议。我们将在本章更详细地介绍 Restful API。

微服务内部

要构建适当的微服务,我们需要考虑几个组件。为了理解这五个组件,让我们讨论一下微服务预期承担的主要任务:

  • 微服务将需要能够与其他服务和外部世界发送和接收消息,以便任务可以和谐地进行。微服务的通信方面采取不同的形式。与外部世界互动时,Restful API 非常受欢迎,与其他服务通信时,消息队列非常有帮助。还有其他一些流行的技术也很受欢迎,比如gRPC

  • 微服务将需要一个配置层;这可以通过环境变量、文件或数据库来实现。这个配置层将告诉微服务如何操作。例如,假设我们的服务需要监听 TCP 地址和端口号以便接收消息;TCP 地址和端口号将是在服务启动时传递给我们的服务的配置的一部分。

  • 微服务将需要记录发生在其上的事件,以便我们能够排除故障并了解行为。例如,如果在向另一个服务发送消息时发生通信问题,我们需要将错误记录在某个地方,以便我们能够识别问题。

  • 微服务将需要能够通过将数据存储在数据库或其他形式的数据存储中来持久化数据;我们还需要能够在以后检索数据。例如,在 MyEvents 应用程序的情况下,我们的微服务将需要存储和检索与用户、预订和事件相关的数据。

  • 最后,有核心部分,是我们微服务中最重要的部分。核心部分是负责我们微服务预期任务的代码。例如,如果我们的微服务负责处理用户预订,那么微服务的核心部分就是我们编写处理用户预订任务的代码的地方。

因此,根据前面的五点,微服务的构建模块应该是这样的:

微服务的构建模块

这些构建块为构建高效的微服务提供了良好的基础。规则并非一成不变。您可以根据您尝试构建的应用程序使您的微服务变得更简单或更复杂。

RESTful Web API

REST代表表述性状态转移。REST 只是不同服务进行通信和交换数据的一种方式。REST 架构的核心包括客户端和服务器。服务器监听传入的消息,然后回复它,而客户端启动连接,然后向服务器发送消息。

在现代网络编程世界中,RESTful 网络应用程序使用 HTTP 协议进行通信。RESTful 客户端将是一个 HTTP 客户端,而 RESTful 服务器将是 HTTP 服务器。HTTP 协议是支持互联网的关键应用层通信协议,这就是为什么 RESTful 应用程序也可以称为网络应用程序。RESTful 应用程序的通信层通常简称为 RESTful API。

REST API 允许在各种平台上开发的应用程序进行通信。这包括在其他操作系统上运行的应用程序中的其他微服务,以及在其他设备上运行的客户端应用程序。例如,智能手机可以通过 REST 可靠地与您的 Web 服务通信。

Web RESTful API

要了解 RESTful 应用程序的工作原理,我们首先需要对 HTTP 协议的工作原理有一个相当好的理解。HTTP 是一种应用级协议,用于在整个网络、云和现代微服务世界中进行数据通信。

HTTP 是一种客户端-服务器,请求-响应协议。这意味着数据流程如下:

  • HTTP 客户端向 HTTP 服务器发送请求

  • HTTP 服务器监听传入的请求,然后在其到达时做出响应

请求和响应

HTTP 客户端请求通常是以下两种情况之一:

  • 客户端正在从服务器请求资源

  • 客户端正在请求在服务器上添加/编辑资源

资源的性质取决于您的应用程序。例如,如果您的客户端是尝试访问网页的 Web 浏览器,那么您的客户端将向服务器发送请求,请求 HTML 网页。HTML 页面将作为资源在 HTTP Web 服务器的响应中返回给客户端。

在通信微服务的世界中,REST 应用程序通常使用 HTTP 协议结合 JSON 数据格式来交换数据消息。

考虑以下情景:在我们的 MyEvents 应用程序中,我们的一个微服务需要从另一个微服务获取事件信息(持续时间、开始日期、结束日期和位置)。需要信息的微服务将是我们的客户端,而提供信息的微服务将是我们的服务器。假设我们的客户端微服务具有事件 ID,但需要服务器微服务提供属于该 ID 的事件的信息。

客户端将通过事件 ID 发送请求,询问有关事件信息;服务器将以 JSON 格式回复信息,如下所示:

带有响应的 JSON 文档

这个描述听起来很简单;然而,它并没有提供完整的图片。客户端的询问部分需要更多的阐述,以便我们了解 REST API 的真正工作原理。

REST API 客户端请求需要指定两个主要信息以声明其意图——请求 URL请求方法

请求 URL 是客户端寻找的服务器上资源的地址。URL 是一个 Web 地址,REST API URL 的一个示例是quotes.rest/qod.json,这是一个返回当天引用的 API 服务。

在我们的场景中,MyEvents 客户端微服务可以向10.12.13.14:5500/events/id/1345 URL 发送 HTTP 请求来查询事件 ID1345

请求方法基本上是我们想要执行的操作类型。这可以是从请求获取资源到编辑资源、添加资源,甚至删除资源的请求。在 HTTP 协议中,有多种类型的方法需要成为客户端请求的一部分;以下是一些最常见的方法:

  • GET:在 Web 应用程序中非常常见的 HTTP 方法;这是我们从 HTTP Web 服务器请求资源的方式;这是我们在场景中使用的请求类型,用于请求事件 ID1345的数据。

  • POST:我们用来更新或创建资源的 HTTP 方法。

假设我们想使用POST更新属于事件 ID 1345 的某些信息,那么我们将发送一个POST请求到相对 URL../events/id/1345,并在请求体中附上新的事件信息。

另一方面,如果我们想创建一个 ID 为 1346 的新事件,我们不应该发送POST请求到../events/id/1346,因为该 ID 尚不存在。我们应该只是发送一个POST请求到.../events,并在请求体中附上所有新的事件信息。

  • PUT:用于创建或覆盖资源的 HTTP 方法。

POST不同,PUT请求可以通过向之前不存在的资源 ID 发送请求来创建新资源。因此,例如,如果我们想创建一个 ID 为1346的新事件,我们可以发送一个PUT请求到../events/id/1346,Web 服务器应该为我们创建资源。

PUT也可以用于完全覆盖现有资源。因此,与POST不同,我们不应该使用PUT来仅更新资源的单个信息。

  • DELETE:用于删除资源。例如,如果我们向 Web 服务器的相对 URL../events/id/1345发送删除请求,Web 服务器将从数据库中删除资源。

Gorilla web toolkit

现在我们已经了解了 Web Restful API 的工作原理,是时候了解如何在 Go 中最佳实现它们了。Go 语言自带了一个非常强大的标准库 web 包;Go 还享受着众多第三方包的支持。在本书中,我们将使用一个非常流行的 Go web 第三方工具包,名为 Gorilla web toolkit。Gorilla web toolkit 由一系列 Go 包组成,一起帮助快速高效地构建强大的 Web 应用程序。

Gorilla web toolkit 生态系统中的关键包称为gorilla/muxmux包在包文档中被描述为请求路由器和调度器。这基本上是一个软件组件,它接受传入的 HTTP 请求,然后根据请求的性质决定要做什么。例如,假设客户端向我们的 Web 服务器发送了一个 HTTP 请求。我们的 Web 服务器中的 HTTP 路由调度器组件可以检测到传入请求包含一个相对 URL 为../events/id/1345GET方法。然后它将检索事件 ID1345的信息并将其发送回客户端。

实施 Restful API

利用该包的第一步是使用go get命令将包获取到我们的开发环境中:

$ go get github.com/gorilla/mux

有了这个,mux包将准备就绪。在我们的代码中,我们现在可以将mux包导入到我们的 web 服务器代码中:

import "github.com/gorilla/mux"

在我们的代码中,现在需要使用 Gorilla mux包创建一个路由器。这可以通过以下代码实现:

r := mux.NewRouter()

有了这个,我们将得到一个名为r的路由器对象,帮助我们定义我们的路由并将它们与要执行的操作链接起来。

从这一点开始,代码将根据所涉及的微服务而有所不同,因为不同的服务将支持不同的路由和操作。在本章的前面,我们介绍了在 MyEvents 应用程序中使用的四种不同类型的服务——Web UI 服务、搜索微服务、预订微服务和事件微服务。让我们专注于事件微服务。

事件微服务将需要支持一个 RESTFul API 接口,能够执行以下操作:

  • 通过 ID 或事件名称搜索事件

  • 一次性检索所有事件

  • 创建一个新事件

让我们专注于这些任务中的每一个。由于我们正在设计一个微服务的 Web RESTful API,因此每个任务都需要转换为一个 HTTP 方法,结合一个 URL 和一个 HTTP 正文(如果需要)。

以下是详细说明:

  • 通过搜索事件:

  • ID:相对 URL 是/events/id/3434,方法是GET,在 HTTP 正文中不需要数据

  • 名称:相对 URL 是/events/name/jazz_concert,方法是GET,在 HTTP 正文中不需要数据

  • 一次性检索所有事件:相对 URL 是/events,方法是GET,在 HTTP 正文中不需要数据

  • 创建一个新事件:相对 URL 是/events,方法是POST,并且 HTTP 正文中需要的数据是我们想要添加的新事件的 JSON 表示。假设我们想要添加在美国演出的“歌剧艾达”事件,那么 HTTP 正文将如下所示:

现在,如果您查看每个任务的 HTTP 翻译,您会注意到它们的相对 URL 都有一个共同的属性,即它们都以/events开头。在 Gorilla web 工具包中,我们可以为/events相对 URL 创建一个子路由器。子路由器基本上是一个对象,负责处理任何指向以/events开头的相对 URL 的传入 HTTP 请求。

要为以/events为前缀的 URL 创建一个子路由器,需要以下代码:

eventsrouter := r.PathPrefix("/events").Subrouter()

前面的代码使用了我们之前创建的路由器对象,然后调用了PathPrefix方法,用于捕获以/events开头的任何 URL 路径。最后,我们调用了Subrouter()方法,这将为我们创建一个新的路由器对象,以便从现在开始处理任何以/events开头的 URL 的传入请求。新的路由器称为eventsrouter

接下来,eventsrouter对象可以用来定义其余共享/events前缀的 URL 的操作。因此,让我们重新查看我们任务的 HTTP 翻译列表,并探索完成它们所需的代码:

  1. 任务:通过搜索事件:
  • id:相对 URL 是/events/id/3434,方法是GET,在 HTTP 正文中不需要数据

  • name:相对 URL 是/events/name/jazz_concert,方法是GET,在 HTTP 正文中不需要数据:

eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.findEventHandler)

前面代码中的处理程序对象基本上是实现我们期望映射到传入 HTTP 请求的功能的方法的对象。稍后再详细介绍。

  1. 任务:一次性检索所有事件——相对 URL 是/events,方法是GET,在 HTTP 正文中不需要数据
eventsrouter.Methods("GET").Path("").HandlerFunc(handler.allEventHandler)
  1. 任务:创建一个新事件——相对 URL 是“/events”,方法是POST,并且 HTTP 正文中需要的数据是我们想要添加的新事件的 JSON 表示:
eventsrouter.Methods("POST").Path("").HandlerFunc(handler.newEventHandler)

对于任务 2 和 3,代码是不言自明的。Gorilla mux包允许我们访问优雅地定义我们想要捕获的传入 HTTP 请求的属性的 Go 方法。该包还允许我们将调用链接在一起,以有效地构造我们的代码。Methods()调用定义了预期的 HTTP 方法,Path()调用定义了预期的相对 URL 路径(请注意,我们将调用放在eventsrouter对象上,它将在Path()调用中定义的相对路径后附加/events),最后是HandlerFunc()方法。

HandlerFunc()方法是我们将捕获的传入 HTTP 请求与操作关联的方式。HandlerFunc()接受一个func(http.ResponseWriter, *http.Request)类型的参数。这个参数基本上是一个具有两个重要参数的函数——一个 HTTP 响应对象,我们需要用我们的响应填充它,以响应传入的请求,以及一个 HTTP 请求对象,其中包含有关传入 HTTP 请求的所有信息。

在上述代码中,我们传递给HandlerFunc()的函数是handler.findEventHandlerhandler.allEventHandlerhandler.newEventHandler,它们都支持func(http.ResponseWriter, *http.Request)签名。handler是一个 Go 结构对象,用于承载所有这些函数。handler对象属于一个名为eventServiceHandler的自定义 Go 结构类型。

为了使eventServiceHandler类型支持任务 1、2 和 3 的 HTTP 处理程序,它需要定义如下:

type eventServiceHandler struct {}

func (eh *eventServiceHandler) findEventHandler(w http.ResponseWriter, r *http.Request) {

}

func (eh *eventServiceHandler) allEventHandler(w http.ResponseWriter, r *http.Request) {

}

func (eh *eventServiceHandler) newEventHandler(w http.ResponseWriter, r *http.Request) {

}

在上述代码中,我们将eventServiceHandler创建为一个没有字段的结构类型,然后将三个空方法附加到它上面。每一个处理程序方法都支持成为 Gorilla muxHandlerFunc()方法的参数所需的函数签名。在本章中,当我们讨论微服务的持久层时,将更详细地讨论eventServiceHandler方法的详细实现。

现在,让我们回到任务 1。我们代码中的/{SearchCriteria}/{search}路径代表了搜索事件 ID2323的等价路径/id/2323,或者搜索名称为opera aida的事件的路径/name/opera aida。我们路径中的大括号提醒 Gorilla mux包,SearchCriteriasearch基本上是预期在真实传入的 HTTP 请求 URL 中用其他内容替换的变量。

Gorilla mux包支持 URL 路径变量的强大功能。它还支持通过正则表达式进行模式匹配。因此,例如,如果我使用一个看起来像/{search:[0-9]+}的路径,它将为我提供一个名为search的变量,其中包含一个数字。

在我们完成定义路由器、路径和处理程序之后,我们需要指定本地 TCP 地址,以便我们的 Web 服务器监听传入的 HTTP 请求。为此,我们需要 Go 的net/http包;代码如下:

http.ListenAndServe(":8181", r)

在这一行代码中,我们创建了一个 Web 服务器。它将在本地端口8181上监听传入的 HTTP 请求,并将使用r对象作为请求的路由器。我们之前使用mux包创建了r对象。

现在是时候将我们到目前为止涵盖的所有代码放在一起了。假设代码位于一个名为ServeAPI()的函数中,该函数负责激活我们微服务的 Restful API 逻辑。

func ServeAPI(endpoint string) error {
  handler := &eventservicehandler{}
  r := mux.NewRouter()
  eventsrouter := r.PathPrefix("/events").Subrouter()
  eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.FindEventHandler)
  eventsrouter.Methods("GET").Path("").HandlerFunc(handler.AllEventHandler)
  eventsrouter.Methods("POST").Path("").HandlerFunc(handler.NewEventHandler)
  return http.ListenAndServe(endpoint, r)
}

我们定义了eventServiceHandler对象如下:

type eventServiceHandler struct {}

func (eh *eventServiceHandler) findEventHandler(w http.ResponseWriter, r *http.Request) {}

func (eh *eventServiceHandler) allEventHandler(w http.ResponseWriter, r *http.Request) {}

func (eh *eventServiceHandler) newEventHandler(w http.ResponseWriter, r *http.Request) {}

显然,下一步将是填写eventServiceHandler类型的空方法。我们有findEventHandler()allEventHandler()newEventHandler()方法。它们每一个都需要一个持久层来执行它们的任务。这是因为它们要么检索存储的数据,要么向存储添加新数据。

在本节中前面提到过,持久层是微服务的一个组件,负责将数据存储在数据库中或从数据库中检索数据。我们已经到了需要更详细地介绍持久层的时候了。

持久层

在设计持久层时需要做出的第一个决定是决定数据存储的类型。数据存储可以是关系型 SQL 数据库,如 Microsoft SQL 或 MySQL 等。或者,它可以是 NoSQL 存储,如 MongoDB 或 Apache Cassandra 等。

在高效和复杂的生产环境中,代码需要能够在不需要太多重构的情况下从一个数据存储切换到另一个。考虑以下例子——您为一家依赖 MongoDB 作为数据存储的初创公司构建了许多微服务;然后,随着组织的变化,您决定 AWS 基于云的 DynamoDB 将成为微服务更好的数据存储。如果代码不允许轻松地拔掉 MySQL,然后插入 MongoDB 层,那么我们的微服务将需要大量的代码重构。在 Go 语言中,我们将使用接口来实现灵活的设计。

值得一提的是,在微服务架构中,不同的服务可能需要不同类型的数据存储,因此一个微服务使用 MongoDB,而另一个服务可能使用 MySQL 是很正常的。

假设我们正在为事件微服务构建持久层。根据我们目前所涵盖的内容,事件微服务的持久层主要关心三件事:

  • 向数据库添加新事件

  • 通过 ID 查找事件

  • 通过名称查找事件

为了实现灵活的代码设计,我们需要在接口中定义前面三个功能。它会是这样的:

type DatabaseHandler interface {
    AddEvent(Event) ([]byte, error)
    FindEvent([]byte) (Event, error)
    FindEventByName(string) (Event, error)
    FindAllAvailableEvents() ([]Event, error)
}

Event数据类型是一个代表事件数据的结构类型,例如事件名称、位置、时间等。现在,让我们专注于DatabaseHandler接口。它支持四种方法,代表了事件服务持久层所需的任务。然后我们可以从这个接口创建多个具体的实现。一个实现可以支持 MongoDB,而另一个可以支持云原生的 AWS DynamoDB 数据库。

我们将在后面的章节中介绍 AWS DynamoDB。本章的重点将放在 MongoDB 上。

MongoDB

如果您对 MongoDB NoSQL 数据库引擎还不熟悉,本节将对您非常有用。

MongoDB 是一个 NoSQL 文档存储数据库引擎。理解 MongoDB 的两个关键词是NoSQL文档存储

NoSQL 是软件行业中相对较新的关键词,用于指示数据库引擎不太依赖关系数据。关系数据是指数据库中不同数据之间存在关系的概念,遵循数据之间的关系将构建出数据代表的完整图景。

以 MySQL 作为关系型数据库的例子。数据存储在多个表中,然后使用主键和外键来定义不同表之间的关系。MongoDB 不是这样工作的,这就是为什么 MySQL 被认为是 SQL 数据库,而 MongoDB 被认为是 NoSQL 数据库。

如果您还不熟悉 Mongodb,或者没有本地安装可以测试。转到docs.mongodb.com/manual/installation/,在那里您会找到一系列有用的链接,指导您完成在所选操作系统中安装和运行数据库的过程。通常,安装后,Mongodb 提供两个关键二进制文件:mongodmongomongod命令是您需要执行的,以便运行您的数据库。然后编写的任何软件都将与mongod通信,以访问 Mongodb 的数据。另一方面,mongo命令基本上是一个客户端工具,您可以使用它来测试 Mongodb 上的数据,mongo命令与mongod通信,类似于您编写的任何访问数据库的应用程序。

有两种 MongoDB:社区版和企业版。显然,企业版针对更大的企业安装,而社区版是您用于测试和较小规模部署的版本。以下是涵盖三个主要操作系统的社区版指南的链接:

总的来说,在部署 Mongodb 实例时,有三个主要步骤需要考虑:

  1. 为您的操作系统安装 Mongodb,下载页面在这里:www.mongodb.com/download-center

  2. 确保 MongoDB 的关键二进制文件在您的环境路径中定义,以便您可以从终端运行它们,无论当前目录是什么。关键二进制文件是mongodmongo。另一个值得一提的二进制文件是mongos,如果您计划使用集群,则这一点很重要

  3. 运行mongod命令,不带任何参数,这将使用所有默认设置运行 Mongodb。或者,您可以使用不同的配置。您可以使用配置文件或运行时参数。您可以在这里找到有关配置文件的信息:docs.mongodb.com/manual/reference/configuration-options/#configuration-file。要使用自定义配置文件启动mongod,可以使用--config选项,这是一个示例:mongod --config /etc/mongod.conf。另一方面,对于运行时参数,您可以在运行mongod时使用--option来更改选项,例如,您可以键入mongod --port 5454以在与默认值不同的端口上启动mongod

有不同类型的 NoSQL 数据库。其中一种类型是文档存储数据库。文档存储的概念是数据存储在许多文档文件中,堆叠在一起以表示我们要存储的内容。让我们以事件微服务所需的数据存储为例。如果我们在微服务持久层中使用文档存储,每个事件将存储在一个单独的带有唯一 ID 的文档中。假设我们有一个 Aida 歌剧事件,一个 Coldplay 音乐会事件和一个芭蕾表演事件。在 MongoDB 中,我们将创建一个名为events的文档集合,其中包含三个文档——一个用于歌剧,一个用于 Coldplay,一个用于芭蕾表演。

因此,为了巩固我们对 MongoDB 如何表示这些数据的理解,这里是事件集合的图表:

事件集合

在 MongoDB 中,集合和文档是重要的概念。生产环境中的 MongoDB 通常由多个集合组成;每个集合代表我们数据的不同部分。例如,我们的 MyEvents 应用程序由许多微服务组成,每个微服务关心不同的数据部分。预订微服务将在预订集合中存储数据,而事件微服务将在事件集合中存储数据。我们还需要将用户数据单独存储,以便独立管理我们应用程序的用户。这将看起来像这样:

我们的 MongoDB 数据库

您可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/CloudNativeprogrammingwithGolang_ColorImages.pdf

该书的代码包也托管在 GitHub 上:github.com/PacktPublishing/Cloud-Native-Programming-with-Golang

由于我们迄今为止专注于事件微服务作为构建微服务的展示,让我们深入了解事件集合,这将被事件微服务使用:

事件集合

事件集合中的每个文档都需要包含表示单个事件所需的所有信息。以下是事件文档应该看起来的样子:

如果你还没有注意到,前面的 JSON 文档与我们提供的 HTTP POST请求体示例相同,这是一个添加事件 API 的 HTTP 请求体示例。

为了编写可以处理这些数据的软件,我们需要创建模型。模型基本上是包含与我们从数据库中期望的数据匹配的字段的数据结构。在 Go 的情况下,我们将使用结构类型来创建我们的模型。以下是事件模型应该看起来的样子:

type Event struct {
    ID bson.ObjectId `bson:"_id"`
    Name string
    Duration int
    StartDate int64
    EndDate int64
    Location Location
}
type Location struct {
    Name string
    Address string
    Country string
    OpenTime int
    CloseTime int
    Halls []Hall
}
type Hall struct {
    Name string `json:"name"`
    Location string `json:"location,omitempty"`
    Capacity int `json:"capacity"`
}

Event struct是我们事件文档的数据结构或模型。它包含 ID、事件名称、事件持续时间、事件开始日期、事件结束日期和事件位置。由于事件位置需要包含比单个字段更多的信息,我们将创建一个名为 location 的结构类型来模拟位置。Location struct类型包含位置的名称、地址、国家、开放时间和关闭时间,以及该区域的大厅。大厅基本上是位置内部的房间,活动在那里举行。

因此,例如,Mountain View,位于 Mountain View 市中心的歌剧院将是位置,而位于东侧的硅谷房间将是大厅。

反过来,大厅不能由单个字段表示,因为我们需要知道它的名称、建筑物内的位置(东南、西部等)以及其容量(它可以容纳的人数)。

事件结构中的bson.ObjectId类型是表示 MongoDB 文档 ID 的特殊类型。bson包可以在mgo适配器中找到,这是与 MongoDB 通信的 Go 第三方框架的选择。bson.ObjectId类型还提供了一些有用的方法,我们可以在代码中稍后使用这些方法来验证 ID 的有效性。

在我们开始介绍mgo之前,让我们花一点时间解释一下bson的含义。bson是 MongoDB 用于表示存储文档中的数据的数据格式。它可以简单地被认为是二进制 JSON,因为它是 JSON 样式文档的二进制编码序列化。规范可以在此链接找到:bsonspec.org/

现在,让我们来介绍mgo

MongoDB 和 Go 语言

mgo 是用 Go 语言编写的流行的 MongoDB 驱动程序。包页面可以在labix.org/mgo找到。该驱动程序只是一些 Go 包,可以方便地编写能够与 MongoDB 一起工作的 Go 程序。

为了使用mgo,第一步是使用go get命令检索包:

go get gopkg.in/mgo.v2

执行上述命令后,我们可以在代码中使用mgo。我们需要导入mgo包和之前讨论过的bson包。我们用来托管我们的 MongoDB 持久层的包名叫做mongolayer

让我们来看看mongolayer包:

package mongolayer
import (
    mgo "gopkg.in/mgo.v2"
    "gopkg.in/mgo.v2/bson"
)

接下来,让我们创建一些常量来表示我们的数据库名称以及我们持久层中涉及的集合的名称。MongoDB 中的数据库名称将是myevents。我们将使用的集合名称是users,用于用户集合,以及events,用于我们数据库中的事件集合。

const (
    DB = "myevents"
    USERS = "users"
    EVENTS = "events"
)

为了公开mgo包的功能,我们需要利用属于mgo包的数据库会话对象,该会话对象类型称为*mgo.session。为了在我们的代码中使用*mgo.session,我们将其包装在名为MongoDBLayer的结构类型中,如下所示:

type MongoDBLayer struct {
    session *mgo.Session
}

现在是时候实现我们之前讨论过的DatabaseHandler接口了,以构建应用程序的具体持久层。在 Go 语言中,通常首选在实现接口时使用指针类型,因为指针保留对底层对象的原始内存地址的引用,而不是在使用时复制整个对象。换句话说,DatabaseHandler接口的实现对象类型需要是指向MongoDBLayer结构对象的指针,或者简单地说是*MongoDBLayer

然而,在我们开始实现接口之前,我们首先需要创建一个构造函数,返回*MongoDBLayer类型的对象。这在 Go 语言中是惯用的,以便我们能够在创建*MongoDBLayer类型的新对象时执行任何必要的初始化代码。在我们的情况下,初始化代码基本上是获取所需的 MongoDB 数据库地址的连接会话处理程序。构造函数代码如下所示:

func NewMongoDBLayer(connection string) (*MongoDBLayer, error) {
    s, err := mgo.Dial(connection)
    if err!= nil{
        return nil,err
    }
    return &MongoDBLayer{
        session: s,
    }, err
}

在上述代码中,我们创建了一个名为NewMongoDBLayer的构造函数,它需要一个字符串类型的单个参数。该参数表示连接字符串,其中包含建立与 MongoDB 数据库连接所需的信息。根据mgo文档godoc.org/gopkg.in/mgo.v2#Dial,连接字符串的格式需要如下所示:

如果只是本地主机连接,连接字符串将如下所示:mongodb://127.0.0.1

如果连接字符串中没有提供端口号,则端口默认为27017

现在,让我们看看构造函数内的代码。在第一行中,我们使用连接字符串作为参数调用mgo.Dial()mgo.Dial()mgo包中的函数,它将为我们返回一个 MongoDB 连接会话,以便稍后在我们的代码中使用。它返回两个结果——*mgo.Session对象和一个错误对象。我们在最后使用结构文字返回指向MongoDBLayer类型的新对象的指针,其中包含新创建的*mgo.Session对象。我们还返回错误对象,以便在初始化过程中向调用者传达任何错误。

现在,构造函数已经完成,是时候实现DatabaseHandler接口的方法了。到目前为止,我们有四种方法——AddEvent(Event)FindEvent([]byte)FindEventByName(string)FindAllAvailableEvents()

AddEvent(Event)方法的代码如下:

func (mgoLayer *MongoDBLayer) AddEvent(e persistence.Event) ([]byte, error) {
    s := mgoLayer.getFreshSession()
    defer s.Close()
    if !e.ID.Valid() {
        e.ID = bson.NewObjectId()
    }
    //let's assume the method below checks if the ID is valid for the location object of the event
    if !e.Location.ID.Valid() {
        e.Location.ID = bson.NewObjectId()
    }
    return []byte(e.ID), s.DB(DB).C(EVENTS).Insert(e)
}

该方法接受一个类型为persistence.Event的参数,该类型模拟了我们之前介绍的事件所期望的信息。它返回一个字节片,表示事件 ID,以及一个错误对象,如果没有找到错误,则为 nil。

在第一行,我们调用了getFreshSession()方法——这是我们代码中实现的一个帮助方法,用于从连接池中检索一个新的数据库会话。该方法的代码如下:

func (mgoLayer *MongoDBLayer) getFreshSession() *mgo.Session {
    return mgoLayer.session.Copy()
}

session.Copy()是每当我们从mgo包连接池中请求新会话时调用的方法。mgoLayer.session在这里基本上是我们在MongoDBLayer结构体中托管的*mgo.Session对象。在即将通过mgo包向 MongoDB 发出查询或命令的任何方法或函数的开头调用session.Copy()是惯用的。getFreshSession()方法只是一个帮助方法,它调用session.Copy()为我们返回结果的会话。

现在,让我们回到AddEvent()方法。我们现在有一个来自数据库连接池的工作*mgo.Session对象可供我们在代码中使用。首先要做的是调用defer s.Close(),以确保在AddEvent()方法退出后,该会话会返回到mgo数据库连接池中。

接下来,我们检查Event参数对象提供的事件 ID 是否有效,以及Event对象的 ID 字段是否是我们之前介绍的bson.ObjectID类型。bson.ObjectID支持Valid()方法,我们可以使用它来检测 ID 是否是有效的 MongoDB 文档 ID。如果提供的事件 ID 无效,我们将使用bson.NewObjectID()函数调用创建一个新的 ID。然后,我们将在事件内部嵌入的位置对象中重复相同的模式。

最后,我们将返回两个结果——第一个结果是添加事件的事件 ID,第二个结果是表示事件插入操作结果的错误对象。为了将事件对象插入 MongoDB 数据库,我们将使用s变量中的会话对象,然后调用s.DB(DB).C(EVENTS)来获取一个表示数据库中我们事件集合的对象。该对象将是*mgo.Collection类型。DB()方法帮助我们访问数据库;我们将给它DB常量作为参数,它包含我们的数据库名称。C()方法帮助我们访问集合;我们将给它EVENTS常量,它包含我们事件集合的名称。

DBEVENTS常量在我们的代码中早已定义。最后,我们将调用集合对象的Insert()方法,并将Event对象作为参数传递,这就是为什么代码最终看起来像这样——s.DB(DB).C(EVENTS).Insert(e)。这一行是我们需要的,以便将新文档插入到使用 Go 对象和mgo包的 MongoDB 数据库集合中。

现在,让我们看一下FindEvent()的代码,我们将使用它来从数据库中根据 ID 检索特定事件的信息。代码如下:

func (mgoLayer *MongoDBLayer) FindEvent(id []byte) (persistence.Event, error) {
    s := mgoLayer.getFreshSession()
    defer s.Close()
    e := persistence.Event{}
    err := s.DB(DB).C(EVENTS).FindId(bson.ObjectId(id)).One(&e)
    return e, err
}

请注意,ID 以字节片的形式传递,而不是bson.ObjectId类型。我们这样做是为了确保DatabaseHandler接口中的FindEvent()方法尽可能通用。例如,我们知道在 MongoDB 的世界中,ID 将是bson.ObjectId类型,但是如果我们现在想要实现一个 MySQL 数据库层呢?将 ID 参数类型传递给FindEvent()bson.ObjectId是没有意义的。这就是为什么我们选择了[]byte类型来表示我们的 ID 参数。理论上,我们应该能够将字节片转换为任何其他可以表示 ID 的类型。

重要的一点是,我们也可以选择空接口类型(interface{}),在 Go 中可以转换为任何其他类型。

FindEvent()方法的第一行中,我们像以前一样使用mgoLayer.getFreshSession()从连接池中获取了一个新的会话。然后我们调用defer s.Close()确保会话在完成后返回到连接池。

接下来,我们使用代码e:= persistence.Event{}创建了一个空的事件对象e。然后我们使用s.DB(DB).C(EVENTS)来访问 MongoDB 中的事件集合。有一个名为FindId()的方法,它由*mgoCollection对象支持mgo。该方法以bson.ObjectId类型的对象作为参数,然后搜索具有所需 ID 的文档。

FindId()返回*mgo.Query类型的对象,这是mgo中的常见类型,我们可以使用它来检索查询的结果。为了将检索到的文档数据提供给我们之前创建的e对象,我们需要调用One()方法,该方法属于*mgo.Query类型,并将e的引用作为参数传递。通过这样做,e将获得所需 ID 的检索文档的数据。如果操作失败,One()方法将返回包含错误信息的错误对象,否则One()将返回 nil。

FindEvent()方法的末尾,我们将返回事件对象和错误对象。

现在,让我们来看一下FindEventByName()方法的实现,该方法从 MongoDB 数据库中根据名称检索事件。代码如下所示:

func (mgoLayer *MongoDBLayer) FindEventByName(name string) (persistence.Event, error) {
    s := mgoLayer.getFreshSession()
    defer s.Close()
    e := persistence.Event{}
    err := s.DB(DB).C(EVENTS).Find(bson.M{"name": name}).One(&e)
    return e, err
}

该方法与FindEvent()方法非常相似,除了两个方面。第一个区别是FindEvent()需要一个字符串作为参数,该字符串表示我们想要查找的事件名称。

第二个区别是我们查询事件名称而不是事件 ID。我们查询文档的代码行使用了一个名为Find()的方法,而不是FindId(),这使得代码看起来像这样:

err := s.DB(DB).C(EVENTS).Find(bson.M{"name":name}).One(&e)

Find()方法接受一个表示我们想要传递给 MongoDB 的查询的参数。bson包提供了一个很好的类型叫做bson.M,它基本上是一个我们可以用来表示我们想要查找的查询参数的映射。在我们的情况下,我们正在寻找传递给FindEventByName的名称。我们数据库中事件集合中的名称字段简单地编码为name,而传递给我们的参数并具有名称的变量称为name。因此,我们的查询最终变为bson.M{"name":name}

最后但并非最不重要的是我们的FindAllAvailableEvents()方法。该方法返回我们数据库中所有可用的事件。换句话说,它从我们的 MongoDB 数据库返回整个事件集合。代码如下所示:

func (mgoLayer *MongoDBLayer) FindAllAvailableEvents() ([]persistence.Event, error) {
    s := mgoLayer.getFreshSession()
    defer s.Close()
    events := []persistence.Event{}
    err := s.DB(DB).C(EVENTS).Find(nil).All(&events)
    return events, err
}

代码与FindEventByName()几乎相同,除了三个简单的区别。第一个区别显然是FindAllAvailableEvents()不需要任何参数。

第二个区别是我们需要将查询结果提供给事件对象的切片,而不是单个事件对象。这就是为什么返回类型是[]persistence.Event,而不仅仅是persistence.Event

第三个区别是Find()方法将采用 nil 作为参数,而不是bson.M对象。这将导致代码如下所示:

err := s.DB(DB).C(EVENTS).Find(nil).All(&events)

Find()方法得到一个 nil 参数时,它将返回与关联的 MongoDB 集合中找到的一切。还要注意的是,在Find()之后我们使用了All()而不是One()。这是因为我们期望多个结果而不仅仅是一个。

有了这个,我们完成了对持久层的覆盖。

实现我们的 RESTful API 处理程序函数

因此,既然我们已经覆盖了我们的持久层,现在是时候返回我们的 RESTful API 处理程序并覆盖它们的实现了。在本章的前面,我们定义了eventServiceHandler结构类型如下:

type eventServiceHandler struct {}
func (eh *eventServiceHandler) findEventHandler(w http.ResponseWriter, r *http.Request) {}
func (eh *eventServiceHandler) allEventHandler(w http.ResponseWriter, r *http.Request) {}
func (eh *eventServiceHandler) newEventHandler(w http.ResponseWriter, r *http.Request) {}

eventServiceHandler类型现在需要支持我们在本章前面创建的DatabaseHandler接口类型,以便能够执行数据库操作。这将使结构看起来像这样:

type eventServiceHandler struct {
    dbhandler persistence.DatabaseHandler
}

接下来,我们需要编写一个构造函数来初始化eventServiceHandler对象;它将如下所示:

func newEventHandler(databasehandler persistence.DatabaseHandler) *eventServiceHandler {
    return &eventServiceHandler{
        dbhandler: databasehandler,
    }
}

然而,我们将eventServiceHandler结构类型的三种方法留空。让我们逐一进行。

第一个方法findEventHandler()负责处理用于查询存储在我们的数据库中的事件的 HTTP 请求。我们可以通过它们的 ID 或名称查询事件。如本章前面提到的,当搜索 ID 时,请求 URL 将类似于/events/id/3434,并且将是GET类型。另一方面,当按名称搜索时,请求将类似于/events/name/jazz_concert,并且将是GET类型。作为提醒,以下是我们如何定义路径并将其链接到处理程序的方式:

eventsrouter := r.PathPrefix("/events").Subrouter()
eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.findEventHandler)

{SearchCriteria}{Search}是我们路径中的两个变量。{SearchCriteria}可以替换为idname

以下是findEventHandler方法的代码:

func (eh *eventServiceHandler) findEventHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    criteria, ok := vars["SearchCriteria"]
    if !ok {
        w.WriteHeader(400)
        fmt.Fprint(w, `{error: No search criteria found, you can either search by id via /id/4
                   to search by name via /name/coldplayconcert}`)
        return
    }
    searchkey, ok := vars["search"]
    if !ok {
        w.WriteHeader(400)
        fmt.Fprint(w, `{error: No search keys found, you can either search by id via /id/4
                   to search by name via /name/coldplayconcert}`)
        return
    }
    var event persistence.Event
    var err error
    switch strings.ToLower(criteria) {
        case "name":
        event, err = eh.dbhandler.FindEventByName(searchkey)
        case "id":
        id, err := hex.DecodeString(searchkey)
        if err == nil {
            event, err = eh.dbhandler.FindEvent(id)
        }
    }
    if err != nil {
        fmt.Fprintf(w, "{error %s}", err)
        return
    }
    w.Header().Set("Content-Type", "application/json;charset=utf8")
    json.NewEncoder(w).Encode(&event)
}

该方法接受两个参数:http.ResponseWriter类型的对象,表示我们需要填充的 HTTP 响应,而第二个参数是*http.Request类型,表示我们收到的 HTTP 请求。在第一行,我们使用mux.Vars()和请求对象作为参数;这将返回一个键值对的地图,它将表示我们的请求 URL 变量及其值。因此,例如,如果请求 URL 看起来像/events/name/jazz_concert,我们将在我们的结果地图中有两个键值对——第一个键是"SearchCriteria",值为"name",而第二个键是"search",值为jazz_concert。结果地图存储在 vars 变量中。

然后我们在下一行从我们的地图中获取标准:

criteria, ok := vars["SearchCriteria"]

因此,如果用户发送了正确的请求 URL,标准变量现在将是nameidok变量是布尔类型;如果ok为 true,则我们将在我们的vars地图中找到一个名为SearchCriteria的键。如果为 false,则我们知道我们收到的请求 URL 无效。

接下来,我们检查是否检索到搜索标准;如果没有,我们报告错误然后退出。请注意这里我们如何以类似 JSON 的格式报告错误?这是因为通常首选使用 JSON 格式的 RESTful API 返回所有内容,包括错误。另一种方法是创建一个 JSONError 类型并将其设置为我们的错误字符串;但是,为简单起见,我将在这里的代码中明确说明 JSON 字符串。

if !ok {
    fmt.Fprint(w, `{error: No search criteria found, you can either search by id via /id/4 to search by name via /name/coldplayconcert}`)
    return
}

fmt.Fprint允许我们直接将错误消息写入包含我们的 HTTP 响应写入器的w变量。http.responseWriter对象类型支持 Go 的io.Writer接口,可以与fmt.Fprint()一起使用。

现在,我们需要对{search}变量做同样的处理:

searchkey, ok := vars["search"]
if !ok {
    fmt.Fprint(w, `{error: No search keys found, you can either search by id via /id/4
               to search by name via /name/coldplayconcert}`)
    return
}

是时候根据提供的请求 URL 变量从数据库中提取信息了;这是我们的做法:

var event persistence.Event
var err error
switch strings.ToLower(criteria) {
    case "name":
    event, err = eh.dbhandler.FindEventByName(searchkey)
    case "id":
    id, err := hex.DecodeString(searchkey)

    if nil == err {
        event, err = eh.dbhandler.FindEvent(id)
    }
}

在名称搜索标准的情况下,我们将使用FindEventByName()数据库处理程序方法按名称搜索。在 ID 搜索标准的情况下,我们将使用hex.DecodeString()将搜索键转换为字节片——如果我们成功获得字节片,我们将使用获得的 ID 调用FindEvent()

然后,我们通过检查 err 对象来检查数据库操作期间是否发生了任何错误。如果我们发现错误,我们在我们的响应中写入一个404错误头,然后在 HTTP 响应正文中打印错误:

if err != nil {
    w.WriteHeader(404)
    fmt.Fprintf(w, "Error occured %s", err)
    return
}

我们需要做的最后一件事是将响应转换为 JSON 格式,因此我们将 HTTPcontent-type头更改为application/json;然后,我们使用强大的 Go JSON 包将从我们的数据库调用中获得的结果转换为 JSON 格式:

w.Header().Set("Content-Type", "application/json;charset=utf8")
json.NewEncoder(w).Encode(&event)

现在,让我们来看一下allEventHandler()方法的代码,该方法将返回 HTTP 响应中所有可用的事件:

func (eh *eventServiceHandler) allEventHandler(w http.ResponseWriter, r *http.Request) {
    events, err := eh.dbhandler.FindAllAvailableEvents()
    if err != nil {
        w.WriteHeader(500)
        fmt.Fprintf(w, "{error: Error occured while trying to find all available events %s}", err)
        return
    }
    w.Header().Set("Content-Type", "application/json;charset=utf8")
    err = json.NewEncoder(w).Encode(&events)
    if err != nil {
        w.WriteHeader(500)
        fmt.Fprintf(w, "{error: Error occured while trying encode events to JSON %s}", err)
    }
}

我们首先调用数据库处理程序的FindAllAvailableEvents()来获取数据库中的所有事件。然后检查是否发生了任何错误。如果发现任何错误,我们将写入错误头,将错误打印到 HTTP 响应中,然后从函数中返回。

如果没有发生错误,我们将application/json写入 HTTP 响应的Content-Type头。然后将事件编码为 JSON 格式并发送到 HTTP 响应写入器对象。同样,如果发生任何错误,我们将记录它们然后退出。

现在,让我们讨论newEventHandler()处理程序方法,它将使用从传入的 HTTP 请求中检索到的数据向我们的数据库添加一个新事件。我们期望传入的 HTTP 请求中的事件数据以 JSON 格式存在。代码如下所示:

func (eh *eventServiceHandler) newEventHandler(w http.ResponseWriter, r *http.Request) {
    event := persistence.Event{}
    err := json.NewDecoder(r.Body).Decode(&event)
    if err != nil {
        w.WriteHeader(500)
        fmt.Fprintf(w, "{error: error occured while decoding event data %s}", err)
        return
    }
    id, err := eh.dbhandler.AddEvent(event)
    if nil != err {
        w.WriteHeader(500)
        fmt.Fprintf(w, "{error: error occured while persisting event %d %s}",id, err)
        return
    }

在第一行,我们创建了一个persistence.Event类型的新对象,我们将使用它来保存我们期望从传入的 HTTP 请求中解析出的数据。

在第二行,我们使用 Go 的 JSON 包获取传入 HTTP 请求的主体(通过调用r.Body获得)。然后解码其中嵌入的 JSON 数据,并将其传递给新的事件对象,如下所示:

err := json.NewDecoder(r.Body).Decode(&event)

然后像往常一样检查我们的错误。如果没有观察到错误,我们调用数据库处理程序的AddEvent()方法,并将事件对象作为参数传递。这实际上将把我们从传入的 HTTP 请求中获取的事件对象添加到数据库中。然后像往常一样再次检查错误并退出。

为了完成我们的事件微服务的最后要点,我们需要做三件事。第一件是允许我们在本章前面介绍的ServeAPI()函数调用eventServiceHandler构造函数,该函数定义了 HTTP 路由和处理程序。代码最终将如下所示:

func ServeAPI(endpoint string, dbHandler persistence.DatabaseHandler) error {
    handler := newEventHandler(dbHandler)
    r := mux.NewRouter()
    eventsrouter := r.PathPrefix("/events").Subrouter()
eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.findEventHandler)
    eventsrouter.Methods("GET").Path("").HandlerFunc(handler.allEventHandler)
    eventsrouter.Methods("POST").Path("").HandlerFunc(handler.newEventHandler)

    return http.ListenAndServe(endpoint, r)
}

我们需要做的第二个最后要点是为我们的微服务编写一个配置层。如本章前面提到的,一个设计良好的微服务需要一个配置层,它可以从文件、数据库、环境变量或类似的介质中读取。目前,我们需要支持我们的配置层的三个主要参数——我们微服务使用的数据库类型(MongoDB 是我们的默认值)、数据库连接字符串(本地连接的默认值是mongodb://127.0.0.1)和 Restful API 端点。我们的配置层最终将如下所示:

package configuration
var (
    DBTypeDefault = dblayer.DBTYPE("mongodb")
    DBConnectionDefault = "mongodb://127.0.0.1"
    RestfulEPDefault = "localhost:8181"
)
type ServiceConfig struct {
    Databasetype dblayer.DBTYPE `json:"databasetype"`
    DBConnection string `json:"dbconnection"`
    RestfulEndpoint string `json:"restfulapi_endpoint"`
}
func ExtractConfiguration(filename string) (ServiceConfig, error) {
    conf := ServiceConfig{
        DBTypeDefault,
        DBConnectionDefault,
        RestfulEPDefault,
    }
    file, err := os.Open(filename)
    if err != nil {
        fmt.Println("Configuration file not found. Continuing with default values.")
        return conf, err
    }
    err = json.NewDecoder(file).Decode(&conf)
    return conf,err
}

第三个要点是构建一个数据库层包,作为我们微服务中持久层的入口。该包将利用工厂设计模式,通过实现一个工厂函数来制造我们的数据库处理程序。工厂函数将制造我们的数据库处理程序。这是通过获取我们想要连接的数据库的名称和连接字符串,然后返回一个数据库处理程序对象,从此时起我们可以使用它来处理数据库相关的任务。目前我们只支持 MongoDB,所以代码如下:

package dblayer

import (
  "gocloudprogramming/chapter2/myevents/src/lib/persistence"
  "gocloudprogramming/chapter2/myevents/src/lib/persistence/mongolayer"
)

type DBTYPE string

const (
  MONGODB DBTYPE = "mongodb"
  DYNAMODB DBTYPE = "dynamodb"
)

func NewPersistenceLayer(options DBTYPE, connection string) (persistence.DatabaseHandler, error) {

  switch options {
  case MONGODB:
    return mongolayer.NewMongoDBLayer(connection)
  }
  return nil, nil
}

第四个也是最后一个要点是我们的main包。我们将编写主函数,利用flag包从用户那里获取配置文件的位置,然后使用配置文件初始化数据库连接和 HTTP 服务器。以下是生成的代码:

package main
func main(){
    confPath := flag.String("conf", `.\configuration\config.json`, "flag to set
                            the path to the configuration json file")
    flag.Parse()

    //extract configuration
    config, _ := configuration.ExtractConfiguration(*confPath)
    fmt.Println("Connecting to database")
    dbhandler, _ := dblayer.NewPersistenceLayer(config.Databasetype, config.DBConnection)

    //RESTful API start
    log.Fatal(rest.ServeAPI(config.RestfulEndpoint, dbhandler, eventEmitter))
}

通过这段代码,我们结束了本章。在下一章中,我们将讨论如何保护我们的微服务。

总结

在本章中,我们涵盖了关于设计和构建现代微服务的广泛主题。现在,您应该对 RESTful Web API、像 MongoDB 这样的 NoSQL 数据存储以及用于可扩展代码的适当 Go 设计模式有实际的知识。

第三章:保护微服务

欢迎来到我们学习现代 Go 云编程的第三章。在本章中,我们将保护前一章中编写的 RESTful API 服务。

在我们开始深入编写代码之前,我们需要涵盖一些关键概念,以便提供一个良好的知识基础。

正如我们在前一章中所介绍的,Web 应用程序需要使用 HTTP(这是一个应用级协议)进行通信。HTTP 本身不安全,这意味着它会以明文发送数据。显然,如果我们试图发送信用卡信息或敏感个人数据,我们绝对不希望以明文发送。幸运的是,HTTP 通信可以通过一种称为TLS传输层安全)的协议来保护。HTTP 和 TLS 的组合被称为 HTTPS。

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

  • HTTPS 的内部工作原理

  • 在 Go 中保护微服务

HTTPS

要实际理解 HTTPS,我们首先需要讨论 TLS 协议。TLS 是一种可用于加密计算机网络上通信数据的协议。TLS 依赖于两种类型的加密算法来实现其目标——对称加密公钥加密

公钥加密也被称为非对称加密。我们很快会介绍这个名字的由来。另一方面,对称加密也可以称为对称密钥算法。

对称加密

数据加密的核心思想是使用复杂的数学方程对数据进行编码(或加密),从而使这些数据对人类来说变得不可读。在安全软件通信领域,加密数据可以被发送到预期的接收者,预期的接收者将对数据进行解密,使其恢复到原始的可读形式。

在几乎所有情况下,要加密一段数据,你需要一个加密密钥。加密密钥只是用于对数据进行编码的复杂数学方程的一部分。在一些加密算法中,你可以使用相同的加密密钥将数据解密回其原始形式。在其他情况下,需要一个与加密密钥不同的解密密钥来执行解密。

对称加密或对称密钥算法是使用相同密钥来加密和解密数据的算法,这就是为什么它们被称为对称。下图显示了加密密钥用于将单词Hello加密成编码形式,然后使用相同的密钥与编码数据一起将其解密回单词Hello

对称加密

HTTPS 中的对称密钥算法

现在,让我们回到 Web 应用程序和 HTTP 的世界。一般来说,Web 应用程序只是使用 HTTP 协议进行通信的不同软件片段。正如本章前面提到的,为了保护 HTTP 并将其转换为 HTTPS,我们将其与另一个称为 TLS 的协议结合起来。TLS 协议利用对称密钥算法来加密客户端和服务器之间的 HTTP 数据。换句话说,Web 客户端和 Web 服务器通过协商一个共享的加密密钥(有些人称之为共享秘钥),然后使用它来保护它们之间来回传输的数据。

发送方应用程序使用密钥对数据进行加密,然后将其发送给接收方应用程序,接收方应用程序使用相同的密钥副本对数据进行解密。这个过程是 TLS 协议的对称密钥算法部分。

HTTPS 中的对称密钥算法

这听起来都很好,但是 Web 客户端和 Web 服务器如何确保在开始使用加密密钥发送加密数据之前,安全地达成对同一个加密密钥的共识呢?显然,Web 客户端不能只是以明文形式将密钥发送给 Web 服务器,然后期望这个密钥不会被未经授权的第三方捕获,然后简单地解密通过被窃取的密钥进行的任何安全通信。我们之前提到的答案是 TLS 协议依赖于不只一个,而是两种类型的加密算法来保护 HTTP。迄今为止,我们已经介绍了对称密钥算法,它们用于保护大部分通信;然而,公钥算法用于初始握手。这是客户端和服务器打招呼并相互识别,然后达成之后使用的加密密钥的地方。

非对称加密

与对称密钥算法不同,非对称加密或公钥算法利用两个密钥来保护数据。用于加密数据的一个密钥称为公钥,可以安全地与其他方分享。用于解密数据的另一个密钥称为私钥,不得分享。

公钥可以被任何人用来加密数据。然而,只有拥有与公钥对应的私钥的人才能将数据解密回其原始的可读形式。公钥和私钥是使用复杂的计算算法生成的。

在典型的情况下,拥有一对公私钥的人会与他们想要通信的其他人分享公钥。其他人随后会使用公钥来加密发送给密钥所有者的数据。密钥所有者反过来可以使用他们的私钥来将这些数据解密回其原始内容。

考虑一个很好的例子——维基百科提供的——展示了这个想法。假设 Alice 想要通过互联网与她的朋友安全地进行通信。为此,她使用一个生成一对公私钥的应用程序。

Alice 的公私钥

现在,Alice 的一个名叫 Bob 的朋友想要通过互联网给她发送一条安全消息。消息只是你好,Alice! Alice 首先需要向 Bob 发送她的公钥的副本,以便 Bob 可以使用它来加密他的消息然后发送给 Alice。然后,当 Alice 收到消息时,她可以使用她的私钥(不与任何人分享)来将消息解密回可读的文本,看到 Bob 说了你好。

Alice 和 Bob 之间的非对称加密

有了这个,你应该对公钥算法有足够的实际理解了。然而,这在 HTTPS 协议中是如何利用的呢?

HTTPS 中的非对称加密

正如本章前面提到的,Web 客户端和 Web 服务器之间使用非对称加密来协商一个共享的加密密钥(也称为共享秘密或会话密钥),然后在对称加密中使用。换句话说,密钥被 Web 客户端和 Web 服务器同时使用来加密相互的 HTTP 通信。我们已经介绍了这种互动的对称加密部分,现在让我们深入一点了解非对称加密是如何进行的。

Web 客户端和 Web 服务器之间发生了一个握手,在这个握手中,客户端表示其意图向服务器开始一个安全的通信会话。通常,这涉及同意一些关于加密如何发生的数学细节。

服务器随后回复一个数字证书。如果您对数字证书的概念不熟悉,那么现在是时候阐明一下它是什么了。数字证书(或公钥证书)是一种证明公钥所有权的电子文档。为了理解数字证书的重要性,让我们退后几步,回想一下公钥是什么。

正如前面所述,公钥是用于非对称加密(或公钥算法)的加密密钥;该密钥只能加密数据,但永远无法解密数据,并且可以与我们希望进行通信的任何人共享。公钥的颁发者始终持有一个称为私钥的对应密钥,该私钥可以解密由公钥加密的数据。

这听起来很棒,但是如果客户端请求与服务器通信的公钥,然后一个坏的代理拦截了这个请求,并回复了自己的公钥(这被称为中间人攻击)会发生什么?客户端将继续与这个坏的代理进行通信,认为它是合法的服务器;然后客户端可能会向坏的代理发送敏感信息,例如信用卡号或个人数据。显然,如果我们寻求真正的保护和安全,我们希望尽一切可能避免这种情况,因此需要证书。

数字证书是由受信任的第三方实体颁发的数字文档。该文档包含一个公共加密密钥,该密钥所属的服务器名称,以及验证信息正确性的受信任第三方实体的名称,以及公钥属于预期密钥所有者(也称为证书颁发者)的名称。颁发证书的受信任第三方实体被称为CA证书颁发机构)。有多个已知的 CA 颁发证书并验证企业和组织的身份。他们通常会收取一定的费用。对于较大的组织或政府机构,他们会颁发自己的证书;这个过程被称为自签名,因此他们的证书被称为自签名证书。证书可以有到期日期,到期后需要进行更新;这是为了在过去拥有证书的实体发生变化时提供额外的保护。

Web 客户端通常包含其所知的证书颁发机构列表。因此,当客户端尝试连接到 Web 服务器时,Web 服务器会回复一个数字证书。Web 客户端查找证书的颁发者,并将颁发者与其所知的证书颁发机构列表进行比较。如果 Web 客户端知道并信任证书颁发者,那么它将继续连接到该服务器,并使用证书中的公钥。

从服务器获取的公钥将用于加密通信,以安全地协商共享加密密钥(或会话密钥或共享密钥),然后在 Web 客户端和 Web 服务器之间的对称加密通信中使用。有许多算法可以用来生成会话密钥,但这超出了本章的范围。我们需要知道的是,一旦会话密钥达成一致,Web 客户端和 Web 服务器之间的初始握手将结束,允许实际的通信会话在共享会话密钥的保护下安全进行。

有了这些,我们现在对 Web 通信如何得到保护有了足够的实际理解。这用于安全的 Restful Web API 和安全的 Web 页面加载。要补充的另一个重要说明是,用于安全 Web 通信的 URL 以https://开头,而不是http://。这是显而易见的,因为安全的 Web 通信使用 HTTPS,而不仅仅是 HTTP。

Go 中的安全 Web 服务

现在是时候找出如何在 Go 语言中编写安全的 Web 服务了。幸运的是,Go 是从头开始构建的,考虑到了现代软件架构,包括安全的 Web 应用程序。Go 配备了一个强大的标准库,允许从 HTTP 服务器平稳过渡到 HTTPS 服务器。在我们开始查看代码之前,让我们先回答一个简单的问题,即如何获取数字证书以在我们的 Web 服务器中使用。

获取证书

获取数字证书的默认方法是购买验证您的身份并从证书颁发机构提供者那里颁发证书的服务。正如我们之前提到的,有多个证书颁发机构提供者。可以在维基百科上找到最受欢迎的提供者列表:en.wikipedia.org/wiki/Certificate_authority#Providers

还有一些提供免费服务的证书颁发机构。例如,在 2016 年,Mozilla 基金会电子前沿基金会密歇根大学合作成立了一个名为Let's Encrypt的证书颁发机构,网址为:letsencrypt.org/Let's Encrypt是一个免费服务,以自动化方式执行验证、签名和颁发证书。

听起来很不错。但是,如果我们只想测试一些本地 Web 应用程序,比如我们在前一章中构建的事件微服务,该怎么办?在这种情况下,我们需要一种更直接的方法来生成我们可以使用和测试的证书。然后,在部署到生产环境后,我们可以使用受信任的证书颁发机构为我们颁发证书,这些证书将受到 Web 浏览器和连接到互联网的客户端的尊重。

生成我们测试的证书的直接方法是手动创建我们自己的证书并进行自签名。这样做的优点是我们可以生成大量证书用于内部测试,而无需经过验证过程。然而,缺点是任何第三方网络客户端,如 Web 浏览器,尝试通过我们的自签名证书连接到我们的 Web 应用程序时,将无法识别这些证书的发行者,因此在允许我们继续之前会产生大量警告。

为了生成我们新鲜出炉的自签名数字证书,我们需要使用了解算法足够的专门工具来创建必要的输出。请记住,为了启动 HTTPS 会话,我们需要以下内容:

  • 包含以下内容的数字证书:

  • 一个可以与其他方共享的公钥。

  • 拥有证书的服务器名称或域名。

  • 证书的发行者。在自签名证书的情况下,发行者只是我们自己。在由受信任的证书颁发机构颁发的证书的情况下,发行者将是 CA。

  • 我们需要保密并不与任何人分享的私钥

OpenSSL

可以生成 TLS 数字证书的一种专门工具是非常流行的OpenSSL。OpenSSL 可以在以下网址找到:www.openssl.org/。OpenSSL 是一个开源商业级 TLS 工具包,可用于执行各种任务;其中之一就是生成自签名数字证书。OpenSSL 组织本身并不提供该工具的预构建二进制文件。但是,有一个维基页面列出了可以下载该工具的第三方位置。维基页面可以在以下网址找到:wiki.openssl.org/index.php/Binaries。一旦您下载了该工具,以下是如何使用它生成数字证书及其私钥的示例:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365

在前面的代码中,第一个单词显然是二进制文件的名称。让我们逐个讨论这些参数:

  • req:表示请求;它表示我们请求一个证书。

  • -x509:这将表明我们要输出一个自签名证书。在密码学世界中,X.509是一个定义公钥证书格式的标准。许多互联网协议中使用的数字证书都使用了这个标准。

  • -newkey:此选项表示我们希望一个新的带有配对私钥的证书。如前所述,证书只是一个公钥与一堆标识符的组合。因此,为了执行非对称加密,我们需要一个与这个公钥配对的私钥。

  • rsa:2048:这是-newkey选项的参数,表示我们希望使用的加密算法类型来生成密钥。

  • -keyout:此选项提供要将新创建的私钥写入的文件名。

  • key.pem:这是-keyout选项的参数。它表示我们希望将私钥存储在一个名为key.pem的文件中。正如前面提到的,这个密钥需要保持私密,不与任何人分享。

  • -out:此选项提供要将新创建的自签名证书写入的文件名。

  • cert.pem:这是-out选项的参数;它表示我们希望将证书保存在一个名为cert.pem的文件中。然后,这个证书可以与试图通过 HTTPS 与我们的网站安全通信的 Web 客户端共享。

  • -days:证书有效期的天数。

  • 365:这是-days选项的参数。这只是我们说我们希望证书有效期为 365 天,或者简单地说是一年。

generate_cert.go

在 Go 语言的世界中,除了 OpenSSL 之外,还有另一种方法可以生成用于测试的自签名证书。如果您转到GOROOT文件夹,这是 Go 语言安装的位置,然后转到/src/crypto/tls文件夹,您会发现一个名为generate_cert.go的文件。这个文件只是一个简单的工具,可以轻松高效地为我们生成证书。在我的计算机上,GOROOT文件夹位于C:\Go。以下是我机器上generate_cert.go文件的截图:

generate_cert.go 文件

generate_cert.go是一个独立的 Go 程序,可以通过go run命令简单运行。运行后,它将为您创建证书和私钥文件,并将它们放在当前文件夹中。该工具支持许多参数,但通常最常用的参数是--host,它表示我们要为哪个网站生成证书和密钥。以下是我们如何通过go run命令运行该工具的方式:

go run %GOROOT%/src/crypto/tls/generate_cert.go --host=localhost

上述命令是在 Windows 操作系统上执行的,这就是为什么它将GOROOT环境路径变量表示为%GOROOT%。环境变量的表示方式因操作系统而异。例如,在 Linux 的情况下,环境变量将表示为$GOROOT

我们现在将指示命令为名为localhost的服务器构建证书和私钥。该命令将为我们生成证书和密钥,然后将它们放在当前文件夹中,如前所述。以下是显示命令成功执行的屏幕截图:

generate_cert.go 命令

generate_cert工具支持--host之外的其他选项。值得覆盖其中一些:

  • --start-date:此选项表示证书的开始验证日期。此选项的参数需要格式化为 2011 年 1 月 1 日 15:04:05,例如。

  • --duration:此选项表示证书有效期限,以小时为单位。默认值为一年。

  • --rsa-bits:此选项表示在密钥的 RSA 加密中要使用的位数。默认值为 2,048。

  • --help:这提供了支持的选项列表及其描述。

生成证书和密钥文件后,我们可以在我们的 Web 服务器应用程序中获取并使用它们,以支持 HTTPS。我们将在下一节中看到如何做到这一点。

在 Go 中构建 HTTPS 服务器

现在终于是时候深入一些代码了。由于 Go 非常适合构建现代 Web 软件,编写 HTTPS Web 服务器非常容易。让我们从回顾我们在上一章中编写的代码片段开始,以建立一个 HTTP Web 服务器:

 http.ListenAndServe(endpoint, r)

这是一行代码,一个名为ListenAndServe()的函数,它属于标准库中的 HTTP Go 包。ListenAndServe()的第一个参数是我们希望我们的 Web 服务器监听的端点。因此,例如,如果我们希望我们的 Web 服务器监听本地端口 8181,端点将是:8181localhost:8181。第二个参数是描述 HTTP 路由及其处理程序的对象——这个对象是由 Gorilla mux包创建的。从上一章中创建它的代码如下:

r := mux.NewRouter()

要将上一章的 Web 服务器从 HTTP 转换为 HTTPS,我们只需要进行一个简单的更改——而不是调用http.ListenAndServer()函数,我们将使用另一个名为http.ListenAndServeTLS()的函数。代码将如下所示:

http.ListenAndServeTLS(endpoint, "cert.pem", "key.pem", r)

如上述代码所示,http.ListenAndServeTLS()函数比原始 http.ListenAndServe()函数接受更多的参数。额外的参数是第二个和第三个参数。它们只是数字证书文件名和私钥文件名。第一个参数仍然是 Web 服务器监听端点,而最后一个参数仍然是处理程序对象(在我们的情况下是 Gorilla *Router对象)。我们已经从上一步生成了证书和私钥文件,所以我们在这里需要做的就是确保第二个和第三个参数指向正确的文件。

就是这样。这就是我们需要做的一切,以便在 Go 中创建一个 HTTPS Web 服务器;Go HTTP 标准包将接收证书和私钥,并根据 TLS 协议的要求使用它们。

然而,如果我们想要在我们的微服务中同时支持 HTTP 和 HTTPS 怎么办?为此,我们需要有点创意。第一个逻辑步骤将是在我们的代码中运行http.ListenAndServe()http.ListenAndServeTLS()函数,但是我们遇到了一个明显的挑战:这两个函数如何在同一个本地端口上监听?我们可以通过选择一个与 HTTP 监听端口不同的端口来解决这个问题。在前面的章节中,我们使用了一个名为endpoint的变量来保存本地 HTTP 服务器的监听地址。对于 HTTPS,让我们假设本地监听地址存储在一个名为tlsendpoint的变量中。有了这个,代码将如下所示:

http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r)

听起来很棒,但现在我们面临另一个障碍,http.ListenAndServeTLS()http.ListenAndServe()都是阻塞函数。这意味着每当我们调用它们时,它们会无限期地阻塞当前的 goroutine,直到发生错误。这意味着我们不能在同一个 goroutine 上调用这两个函数。

goroutine 是 Go 语言中的一个重要语言组件。它可以被视为轻量级线程。Go 开发人员在各处都使用 goroutines 来实现高效的并发。为了在多个 goroutines 之间传递信息,我们使用另一个 Go 语言组件,称为 Go 通道。

因此,这个问题的解决方案很简单。我们在不同的 goroutine 中调用其中一个函数。这可以通过在函数名之前加上 go 这个词来简单实现。让我们在一个不同的 goroutine 中运行http.ListenAndServe()函数。代码将如下所示:

go http.ListenAndServe(endpoint,r)
http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r)

完美!有了这个,我们的 Web 服务器可以作为 HTTP 服务器为希望使用 HTTP 的客户端,或者作为 HTTPS 服务器为希望使用 HTTPS 的客户端。现在,让我们解决另一个问题:http.ListenAndServe()http.ListenAndServeTLS()函数都会返回错误对象来报告任何失败的问题;那么,即使它们在不同的 goroutines 上运行,我们是否可以捕获任一函数产生的错误?为此,我们需要使用 Go 通道,这是 Go 语言中两个 goroutines 之间通信的惯用方式。代码将如下所示:

httpErrChan := make(chan error) 
httptlsErrChan := make(chan error) 
go func() { httptlsErrChan <- http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r) }() 
go func() { httpErrChan <- http.ListenAndServe(endpoint, r) }()

在前面的代码中,我们创建了两个 Go 通道,一个叫做httpErrChan,另一个叫做httptlsErrChan。这些通道将保存一个错误类型的对象。其中一个通道将报告http.ListenAndServe()函数观察到的错误,而另一个将报告http.ListenAndServeTLS()函数返回的错误。然后,我们使用两个带有匿名函数的 goroutines 来运行这两个ListenAndServe函数,并将它们的结果推送到相应的通道中。我们在这里使用匿名函数,因为我们的代码不仅仅涉及调用http.ListenAndServe()http.ListenAndServeTLS()函数。

你可能会注意到,我们现在在两个ListenAndServe函数中都使用了 goroutines,而不仅仅是一个。我们这样做的原因是为了防止它们中的任何一个阻塞代码,这将允许我们将httpErrChanhttptlsErrChan通道都返回给调用者代码。调用者代码,也就是我们的主函数,在任何错误发生时可以自行处理这些错误。

在前面的章节中,我们将这段代码放在一个名为ServeAPI()的函数中;现在让我们来看一下在我们的更改之后这个函数的完整代码:

func ServeAPI(endpoint, tlsendpoint string, databasehandler persistence.DatabaseHandler) (chan error, chan error) { 
   handler := newEventHandler(databaseHandler)
    r := mux.NewRouter() 
    eventsrouter := r.PathPrefix("/events").Subrouter()     eventsrouter.Methods("GET").Path("/{SearchCriteria}/{search}").HandlerFunc(handler.FindEventHandler) eventsrouter.Methods("GET").Path("").HandlerFunc(handler.AllEventHandler) eventsrouter.Methods("POST").Path("").HandlerFunc(handler.NewEventHandler) 
    httpErrChan := make(chan error) 
    httptlsErrChan := make(chan error) 
    go func() { httptlsErrChan <- http.ListenAndServeTLS(tlsendpoint, "cert.pem", "key.pem", r) }() 
    go func() { httpErrChan <- http.ListenAndServe(endpoint, r) }() 
    return httpErrChan, httptlsErrChan
} 

该函数现在接受一个名为tlsendpoint的新字符串参数,它将保存 HTTPS 服务器的监听地址。该函数还将返回两个错误通道。然后,函数代码继续定义我们的 REST API 支持的 HTTP 路由。然后,它将创建我们讨论过的错误通道,调用两个单独的 goroutine 中的 HTTP 包ListenAndServe函数,并返回错误通道。我们下一个逻辑步骤是覆盖调用ServeAPI()函数的代码,并查看它如何处理错误通道。

正如前面讨论的,我们的主函数是调用ServeAPI()函数的,因此这也将使主函数承担处理返回的错误通道的负担。主函数中的代码将如下所示:

//RESTful API start 
httpErrChan, httptlsErrChan := rest.ServeAPI(config.RestfulEndpoint, config.RestfulTLSEndPint, dbhandler) 
select { 
case err := <-httpErrChan: 
     log.Fatal("HTTP Error: ", err) 
case err := <-httptlsErrChan: 
     log.Fatal("HTTPS Error: ", err) 
}

代码将调用ServeAPI()函数,然后将两个返回的错误通道捕获到两个变量中。然后我们将使用 Go 的select语句的功能来处理这些通道。在 Go 中,select语句可以阻塞当前 goroutine 以等待多个通道;无论哪个通道首先返回,都将调用与之对应的select case。换句话说,如果httpErrChan返回,将调用第一个 case,它将在标准输出中打印一条报告发生 HTTP 错误的语句,并显示错误。否则,将调用第二个 case。阻塞主 goroutine 很重要,因为如果我们不阻塞它,程序将会退出,这是我们不希望发生的事情,如果没有失败的话。过去,http.ListenAndServe()函数通常会阻塞我们的主 goroutine,并防止我们的程序在没有错误发生时退出。但是,由于我们现在已经在两个单独的 goroutine 上运行了ListenAndServe函数,我们需要另一种机制来确保我们的程序不会退出,除非我们希望它退出。

通常,每当您尝试从通道接收值或向通道发送值时,goroutine 都会被阻塞,直到传递一个值。这意味着如果ListenAndServe函数没有返回任何错误,那么值将不会通过通道传递,这将阻塞主 goroutine 直到发生错误。

除了常规通道之外,Go 还有一种称为缓冲通道的通道类型,它可以允许您在不阻塞当前 goroutine 的情况下传递值。但是,在我们的情况下,我们使用常规通道。

我们需要在这里覆盖的最后一段代码是更新配置。请记住,在上一章中,我们使用配置对象来处理微服务的配置信息。配置信息包括数据库地址、HTTP 端点等。由于我们现在还需要一个 HTTPS 端点,因此我们需要将其添加到配置中。配置代码存在于./lib/configuration.go文件中。现在它应该是这样的:

package configuration

import ( 
         "encoding/json" "fmt" 
         "gocloudprogramming/chapter3/myevents/src/lib/persistence/dblayer" 
         "os"
       )

var ( 
      DBTypeDefault       = dblayer.DBTYPE("mongodb") 
      DBConnectionDefault = "mongodb://127.0.0.1" 
      RestfulEPDefault    = "localhost:8181" 
      RestfulTLSEPDefault = "localhost:9191"
    )

type ServiceConfig struct { 
     Databasetype      dblayer.DBTYPE `json:"databasetype"` 
     DBConnection      string         `json:"dbconnection"` 
     RestfulEndpoint   string         `json:"restfulapi_endpoint"` 
     RestfulTLSEndPint string         `json:"restfulapi-tlsendpoint"`
}

func ExtractConfiguration(filename string) (ServiceConfig, error) { 
   conf := ServiceConfig{ 
               DBTypeDefault, 
               DBConnectionDefault, 
               RestfulEPDefault, 
               RestfulTLSEPDefault, 
              }
   file, err := os.Open(filename) 
   if err != nil { 
       fmt.Println("Configuration file not found. Continuing with default values.") 
       return conf, err 
    }
   err = json.NewDecoder(file).Decode(&conf) 
   return conf, err
}

在上述代码中,我们从上一章做了三件主要的事情:

  • 我们添加了一个名为RestfulTLSEPDefault的常量,它将默认为localhost:9191

  • 我们向ServiceConfig结构添加了一个新字段。该字段称为RestfulTLSEndPint;它将期望对应于名为restfulapi-tlsendpoint的 JSON 字段。

  • ExtractConfiguration()函数中,我们将初始化的ServiceConfig结构对象的RestfulTLSEndPint字段的默认值设置为RestfulTLSEPDefault

通过这三个更改,我们的配置层将能够从配置 JSON 文件中读取 HTTPS 端点值,如果存在配置覆盖。如果不存在配置文件,或者配置文件中没有设置restfulapi-tlsendpoint JSON 字段,则我们将采用默认值,即localhost:9191

任何调用ExtractConfiguration()函数的代码都将获得对这个功能的访问权限,并能够获取 HTTPS 端点的默认值或配置值。在我们的代码中,主函数将调用ExtractConfiguration()函数,并获取调用ServeAPI()函数所需的信息,该函数将运行我们的 RESTful API。

完美!有了这最后一部分,我们结束了本章。

总结

在本章中,我们深入探讨了安全的 Web 软件世界以及其内部工作原理。我们探讨了 HTTPS、对称和非对称加密,以及如何在 Go 语言中保护 Web 服务。

在下一章中,我们将涵盖分布式微服务架构世界中的一个关键主题:消息队列。

第四章:使用消息队列的异步微服务架构

在过去的两章中,您学习了如何使用 Go 编程语言构建基于 REST 的微服务。REST 架构风格既简单又灵活,这使其成为许多用例的绝佳选择。然而,基于 HTTP 构建的 REST 架构中的所有通信都将遵循客户端/服务器模型,进行请求/回复事务。在某些用例中,这可能是有限制的,其他通信模型可能更适合。

在本章中,我们将介绍发布/订阅通信模型,以及您需要实现它的技术。通常,发布/订阅架构需要一个中央基础设施组件——消息代理。在开源世界中,有许多不同的消息代理实现;因此,在本章中,我们将介绍两种我们认为最重要的消息代理——RabbitMQApache Kafka。两者都适用于特定的用例;您将学习如何设置这两种消息代理,如何连接您的 Go 应用程序,以及何时应该使用其中一种。

然后,我们将向您展示如何利用这些知识来扩展您在前几章中工作的事件管理微服务,以便在发生重要事件时发布事件。这使我们能够实现第二个微服务来监听这些事件。您还将了解通常与异步通信一起使用的高级架构模式,例如事件协作事件溯源,以及如何(以及何时)在应用程序中使用它们。

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

  • 发布/订阅架构模式

  • 事件协作

  • 事件溯源

  • 使用 RabbitMQ 的 AMQP

  • Apache Kafka

发布/订阅模式

发布/订阅模式是一种通信模式,是请求/回复模式的替代方案。与客户端(发出请求)和服务器(回复该请求)不同,发布/订阅架构由发布者和订阅者组成。

每个发布者都可以发出消息。发布者实际上并不关心谁收到了这些消息。这是订阅者的问题;每个订阅者可以订阅某种类型的消息,并在发布者发布给定类型的消息时得到通知。反过来,每个订阅者并不关心消息实际来自哪里。

请求/回复和发布/订阅通信模式

实际上,许多发布/订阅架构都需要一个中央基础设施组件——消息代理。发布者在消息代理上发布消息,订阅者在消息代理上订阅消息。然后,代理的主要任务之一是将发布的消息路由到对它们感兴趣的订阅者。

通常,消息将被路由到基于主题的方式。这意味着每个发布者都为发布的消息指定一个主题(主题通常只是一个字符串标识符,例如user.created)。每个订阅者也将订阅特定的主题。通常,代理还允许订阅者使用通配符表达式(例如user.*)订阅整个主题集。

与请求/回复相比,发布/订阅模式带来了一些明显的优势:

  • 发布者和订阅者之间的耦合非常松散。甚至它们彼此之间都不知道。

  • 发布/订阅架构非常灵活。可以添加新的订阅者(因此扩展现有流程)而无需修改发布者。反之亦然;可以添加新的发布者而无需修改订阅者。

  • 如果消息由消息代理路由,您还会获得弹性。通常,消息代理会将所有消息存储在队列中,直到它们被订阅者处理。如果订阅者变得不可用(例如由于故障或有意关闭),本应路由到该订阅者的消息将排队,直到订阅者再次可用。

  • 通常,您还会在协议级别获得消息代理的某种可靠性保证。例如,RabbitMQ 通过要求每个订阅者确认接收到的消息来保证可靠传递。只有在消息被确认后,代理才会从队列中删除消息。如果订阅者应该失败(例如,由于断开连接),当消息已经被传递但尚未被确认时,消息将被放回消息队列中。如果另一个订阅者监听同一消息队列,消息可能会被路由到该订阅者;否则,它将保留在队列中,直到订阅者再次可用。

  • 您可以轻松扩展。如果对于单个订阅者来说发布了太多消息以有效处理它们,您可以添加更多订阅者,并让消息代理负载平衡发送给这些订阅者的消息。

当然,引入消息代理这样的中心基础设施组件也带来了自己的风险。如果做得不对,您的消息代理可能会成为单点故障,导致整个应用程序在其失败时崩溃。在生产环境中引入消息代理时,您应该采取适当的措施来确保高可用性(通常是通过集群和自动故障转移)。

如果您的应用程序在云环境中运行,您还可以利用云提供商提供的托管消息排队和传递服务之一,例如 AWS 的简单队列服务SQS)或 Azure 服务总线。

在本章中,您将学习如何使用两种最流行的开源消息代理——RabbitMQ 和 Apache Kafka。在第八章中,AWS 第二部分-S3、SQS、API 网关和 DynamoDB,您将了解有关 AWS SQS 的信息。

介绍预订服务

在这一部分,我们将首先使用 RabbitMQ 实现发布/订阅架构。为此,我们需要向我们的架构添加新的微服务——预订服务将处理事件的预订。其责任将包括确保事件不会被过度预订。为此,它将需要了解现有的事件和位置。为了实现这一点,我们将修改EventService,以便在创建位置或事件时发出事件(是的,术语很混乱——确保不要将发生了某事的通知类型的事件与Metallica 在这里演出类型的事件弄混)。BookingService然后可以监听这些事件,并在有人为这些事件之一预订票时自己发出事件。

我们的微服务概述以及它们将发布和订阅的事件

事件协作

事件协作描述了一个与事件驱动的发布/订阅架构很好配合的架构原则。

考虑以下示例,使用常规的请求/响应通信模式——用户请求预订服务为某个事件预订门票。由于事件由另一个微服务(EventService)管理,因此BookingService需要从EventService请求有关事件及其位置的信息。只有这样BookingService才能检查是否还有座位可用,并将用户的预订保存在自己的数据库中。此交易所需的请求和响应如下图所示:

请求和响应

现在,考虑在发布/订阅架构中相同的场景,其中BookingServiceEventService使用事件进行集成:每当EventService中的数据发生变化时,它会发出一个事件(例如创建了一个新位置创建了一个新事件更新了一个事件等)。

现在,BookingService可以监听这些事件。它可以构建自己的所有当前存在的位置和事件的数据库。现在,如果用户请求为特定事件预订新的预订,BookingService可以简单地使用自己本地数据库中的数据,而无需从另一个服务请求此数据。请参考以下图表,以进一步说明这个原则:

使用自己本地数据库中的 BookingService

这是事件协作架构的关键点。在前面的图表中,一个服务几乎永远不需要查询另一个服务的数据,因为它通过监听其他服务发出的事件已经知道了它需要知道的一切。

显然,这种架构模式与发布/订阅非常配合。在前面的例子中,EventService将是发布者,而BookingService(可能还有其他服务)将是订阅者。当然,人们可能会对这个原则必然导致两个服务存储冗余数据感到不安。然而,这并不一定是件坏事——因为每个服务都不断监听其他服务发出的事件,整个数据集最终可以保持一致。此外,这增加了系统的整体弹性;例如,如果事件服务突然发生故障,BookingService仍然可以正常运行,因为它不再依赖事件服务的工作。

使用 RabbitMQ 实现发布/订阅

在接下来的部分,您将学习如何实现基本的发布/订阅架构。为此,我们将看一下高级消息队列协议AMQP)及其最流行的实现之一,RabbitMQ。

高级消息队列协议

在协议级别上,RabbitMQ 实现了 AMQP。在开始使用 RabbitMQ 之前,让我们先看一下 AMQP 的基本协议语义。

AMQP 消息代理管理两种基本资源——交换队列。每个发布者将其消息发布到一个交换中。每个订阅者消费一个队列。AMQP 代理负责将发布到交换中的消息放入相应的队列中。消息发布到交换后的去向取决于交换类型和称为绑定的路由规则。AMQP 有三种不同类型的交换:

  • Direct exchanges: 消息以给定的主题(在 AMQP 中称为路由键)发布,这是一个简单的字符串值。可以定义直接交换和队列之间的绑定,以精确匹配该主题。

  • Fanout exchanges: 消息通过绑定连接到扇出交换机的所有队列。消息可以有路由键,但会被忽略。每个绑定的队列将接收发布在扇出交换机中的所有消息。

  • 主题交换:这与直接交换类似。但是,现在队列是使用消息的路由键必须匹配的模式绑定到交换。主题交换通常假定路由键使用句点字符'.'进行分段。例如,您的路由键可以遵循"<entityname>.<state-change>.<location>"模式(例如,"event.created.europe")。现在可以创建包含通配符的队列绑定,使用'*''#'字符。*将匹配任何单个路由键段,而#将匹配任意数量的段。因此,对于前面的示例,有效的绑定可能如下:

  • event.created.europe(显然)

  • event.created.*(每当在世界的任何地方创建事件时都会收到通知)

  • event.#(每当在世界的任何地方对事件进行任何更改时都会收到通知)

  • event.*.europe(每当在欧洲对事件进行任何更改时都会收到通知)

下一个图表显示了一个可能的示例交换和队列拓扑结构。在这种情况下,我们有一个发布消息的服务EventService。我们有两个队列,消息将被路由到这两个队列中。第一个队列evts_booking将接收与事件的任何更改相关的所有消息。第二个队列evts_search将只接收关于新事件创建的消息。请注意,evts_booking队列有两个订阅者。当两个或更多订阅者订阅同一个队列时,消息代理将轮流将消息分发给其中一个订阅者。

消息代理将消息轮流显示给其中一个订阅者

重要的是要注意,整个 AMQP 拓扑(即所有交换和队列以及它们如何相互绑定)不是由代理定义的,而是由发布者和消费者自己定义的。AMQP 指定了客户端可以使用的几种方法来声明它们需要的交换和队列。例如,发布者通常会使用exchange.declare方法来断言它想要发布的交换实际上存在(如果之前不存在,代理将创建它)。另一方面,订阅者可能会使用queue.declarequeue.bind方法来声明它想要订阅的队列,并将其绑定到一个交换。

有多个实现 AMQP 的开源消息代理。其中最流行的之一(也是我们在本章中将要使用的)是 RabbitMQ 代理,这是一个由Pivotal开发并在Mozilla Public License下提供的开源 AMQP 代理。其他实现 AMQP 的消息代理包括Apache QPIDqpid.apache.org)和Apache ActiveMQactivemq.apache.org)。

虽然在这个例子中我们将使用 RabbitMQ,但本章中编写的代码应该适用于所有类型的 AMQP 实现。

使用 Docker 快速启动 RabbitMQ

在构建我们的发布/订阅架构之前,您需要在开发环境中设置一个正在运行的 RabbitMQ 消息代理。使用官方的 Docker 镜像是开始使用 RabbitMQ 的最简单方法。

对于本例,我们将假设您的本地机器上已经安装了 Docker。请查看官方安装说明,了解如何在您的操作系统上安装 Docker:docs.docker.com/engine/installation

您可以使用以下命令在命令行上启动一个新的 RabbitMQ 代理:

$ docker run --detach \ 
    --name rabbitmq \ 
    -p 5672:5672 \ 
    -p 15672:15672 \ 
    rabbitmq:3-management 

上述命令将在您的机器上创建一个名为rabbitmq的新容器。为此,Docker 将使用rabbitmq:3-management镜像。该镜像包含了 RabbitMQ 3 的最新版本(在撰写本文时为 3.6.6)和管理 UI。-p 5672:5672标志将指示 Docker 将 TCP 端口5672(这是 AMQP 的 IANA 分配的端口号)映射到您的localhost地址。-p 15672:15672标志将对管理用户界面执行相同的操作。

启动容器后,您将能够在浏览器中打开到amqp://localhost:5672的 AMQP 连接,并在http://localhost:15672中打开管理 UI。

当您在 Windows 上使用 Docker 时,您需要用本地 Docker 虚拟机的 IP 地址替换 localhost。您可以使用以下命令在命令行上确定此 IP 地址:$ docker-machine ip default

无论您是使用 docker-machine 还是本地 Docker 安装,RabbitMQ 用户界面应该看起来与以下截图非常相似:

RabbitMQ 的管理用户界面

在浏览器中打开管理界面(http://localhost:15672或您的 docker-machine IP 地址)。RabbitMQ 镜像提供了一个默认的 guest 用户,其密码也是guest。在生产中运行 RabbitMQ 时,这当然是您应该更改的第一件事。对于开发目的,这样做就可以了。

高级 RabbitMQ 设置

上一节中描述的基于 Docker 的设置可以让您快速入门,并且(经过一些调整)也适用于生产设置。如果您不想为消息代理使用 Docker,您还可以从软件包存储库在大多数常见的 Linux 发行版上安装 RabbitMQ。例如,在 Ubuntu 和 Debian 上,您可以使用以下命令安装 RabbitMQ:

$ echo 'deb http://www.rabbitmq.com/debian/ testing main' | \ 
    sudo tee /etc/apt/sources.list.d/rabbitmq.list 
$ wget -O- https://www.rabbitmq.com/rabbitmq-release-signing-key.asc | \ 
    sudo apt-key add - 
$ apt-get update 
$ apt-get install -y rabbitmq-server 

类似的命令也适用于CentOSRHEL

$ rpm --import https://www.rabbitmq.com/rabbitmq-release-signing-key.asc 
$ yum install rabbitmq-server-3.6.6-1.noarch.rpm 

对于生产设置,您可能希望考虑设置 RabbitMQ 作为集群,以确保高可用性。请查看官方文档www.rabbitmq.com/clustering.html了解如何设置 RabbitMQ 集群的更多信息。

使用 Go 连接 RabbitMQ

要连接到 RabbitMQ 代理(或者说是任何 AMQP 代理),我们建议您使用github.com/streadway/amqp库(这是事实上的标准 Go 库,用于 AMQP)。让我们从安装库开始:

$ go get -u github.com/streadway/amqp

然后,您可以通过将库导入到您的代码中来开始。使用amqp.Dial方法打开一个新连接:

import "github.com/streadway/amqp" 

func main() { 
  connection, err := amqp.Dial("amqp://guest:guest@localhost:5672") 
  if err != nil { 
    panic("could not establish AMQP connection: " + err.Error()) 
  } 

  defer connection.Close() 
} 

在这种情况下,"amqp://guest:guest@localhost:5672"是您的 AMQP 代理的 URL。请注意,用户凭据嵌入到 URL 中。amqp.Dial方法在成功时返回连接对象,否则返回nil和错误(与 Go 中一样,请确保您实际上检查了此错误)。此外,在不再需要连接时不要忘记使用Close()方法关闭连接。

当然,通常不建议将连接详细信息(更不用说凭据)硬编码到您的应用程序中。记住您学到的关于十二要素应用程序的知识,让我们引入一个环境变量AMQP_URL,我们可以使用它来动态配置 AMQP 代理:

import "github.com/streadway/amqp" 
import "os" 

func main() { 
  amqpURL := os.Getenv("AMQP_URL"); 
  if amqpURL == "" { 
    amqpURL = "amqp://guest:guest@localhost:5672" 
  } 

  connection, err := amqp.Dial(amqpURL) 
  // ... 
} 

在 AMQP 中,大多数操作不是直接在连接上进行的,而是在通道上进行的。通道用于在一个实际的 TCP 连接上多路复用多个虚拟连接。

通道本身不是线程安全的。在 Go 中,我们需要记住这一点,并注意不要从多个 goroutine 访问同一个通道。但是,使用多个通道,每个通道只被一个线程访问,是完全安全的。因此,当有疑问时,最好创建一个新通道。

继续在现有连接上创建一个新通道:

connection, err := amqp.Dial(amqpURL) 
if err != nil { 
  panic("could not establish AMQP connection: " + err.Error()) 
} 

channel, err := connection.Channel() 
if err != nil { 
  panic("could not open channel: " + err.Error()) 
} 

现在我们可以使用这个通道对象进行一些实际的 AMQP 操作,例如发布消息和订阅消息。

发布和订阅 AMQP 消息

在深入研究 MyEvents 微服务架构之前,让我们看一下我们可以使用的基本 AMQP 方法。为此,我们将首先构建一个小的示例程序,该程序能够向交换发布消息。

打开通道后,消息发布者应声明要发布消息的交换。为此,您可以在通道对象上使用ExchangeDeclare()方法:

err = channel.ExchangeDeclare("events", "topic", true, false, false, false, nil) 
if err != nil { 
  panic(err) 
} 

正如您所看到的,ExchangeDeclare需要相当多的参数。这些如下所示:

  • 交换名称

  • 交换类型(请记住 AMQP 知道directfanouttopic交换)

  • durable标志将导致交换在代理重新启动时保持声明状态

  • autoDelete标志将导致交换在声明它的通道关闭时被删除

  • internal标志将阻止发布者将消息发布到此队列中

  • noWait标志将指示ExchangeDeclare方法不等待来自代理的成功响应

  • args参数可能包含具有附加配置参数的映射

声明交换后,您现在可以发布一条消息。为此,您可以使用通道的Publish()方法。发出的消息将是您需要首先实例化的amqp.Publishing结构的实例:

message := amqp.Publishing { 
  Body: []byte("Hello World"),
} 

然后,使用Publish()方法发布您的消息:

err = channel.Publish("events", "some-routing-key", false, false, message) 
if err != nil { 
  panic("error while publishing message: " + err.Error()) 
} 

Publish()方法接受以下参数:

  • 要发布到的交换的名称

  • 消息的路由键

  • mandatory标志将指示代理确保消息实际上被路由到至少一个队列中

  • immediate标志将指示代理确保消息实际上被传递给至少一个订阅者

  • msg参数包含要发布的实际消息

对于发布/订阅架构,发布者不需要知道谁订阅其发布的消息,显然mandatoryimmediate标志不适用,因此在此示例(以及所有后续示例)中,我们将它们设置为 false。

您现在可以运行此程序,它将连接到您的本地 AMQP 代理,声明一个交换,并发布一条消息。当然,这条消息不会被路由到任何地方并消失。为了实际处理它,您将需要一个订阅者。

继续创建第二个 Go 程序,其中您连接到 AMQP 代理并创建一个新的通道,就像在前一节中一样。但是,现在,不是声明一个交换并发布一条消息,让我们声明一个队列并将其绑定到该交换:

_, err = channel.QueueDeclare("my_queue", true, false, false, false, nil) 
if err != nil { 
  panic("error while declaring the queue: " + err.Error()) 
} 

err = channel.QueueBind("my_queue", "#", "events", false, nil) 
if err != nil { 
  panic("error while binding the queue: " + err.Error())
} 

声明并绑定队列后,您现在可以开始消费此队列。为此,请使用通道的Consume()函数:

msgs, err := channel.Consume("my_queue", "", false, false, false, false, nil) 
if err != nil { 
  panic("error while consuming the queue: " + err.Error()) 
} 

Consume()方法接受以下参数:

  • 要消耗的队列的名称。

  • 唯一标识此消费者的字符串。当留空(就像在这种情况下)时,将自动生成唯一标识符。

  • 当设置autoAck标志时,接收到的消息将自动确认。当未设置时,您需要在处理接收到的消息后显式确认消息,使用接收到的消息的Ack()方法(请参阅以下代码示例)。

  • 当设置exclusive标志时,此消费者将是唯一被允许消费此队列的消费者。当未设置时,其他消费者可能会监听同一队列。

  • noLocal标志指示代理不应将在同一通道上发布的消息传递给此消费者。

  • noWait标志指示库不等待来自代理的确认。

  • args参数可能包含具有附加配置参数的映射。

在这个例子中,msgs将是一个通道(这次是一个实际的 Go 通道,而不是一个 AMQP 通道)的amqp.Delivery结构。为了从队列中接收消息,我们可以简单地从该通道中读取值。如果要连续读取消息,最简单的方法是使用range循环:

for msg := range msgs { 
  fmt.Println("message received: " + string(msg.Body)) 
  msg.Ack(false) 
} 

请注意,在前面的代码中,我们使用msg.Ack函数显式确认消息。这是必要的,因为我们之前将Consume()函数的autoAck参数设置为 false。

显式确认消息具有重要目的——如果您的消费者在接收和确认消息之间由于任何原因失败,消息将被放回队列,然后重新传递给另一个消费者(或者如果没有其他消费者,则留在队列中)。因此,消费者应该只在完成处理消息时确认消息。如果消息在消费者实际处理之前就被确认(这就是autoAck参数会导致的情况),然后消费者意外死机,消息将永远丢失。因此,显式确认消息是使系统具有弹性和容错性的重要步骤。

构建事件发射器

在前面的例子中,我们使用 AMQP 通道从发布者向订阅者发送简单的字符串消息。为了使用 AMQP 构建实际的发布/订阅架构,我们需要传输更复杂的带有结构化数据的消息。一般来说,每个 AMQP 消息只是一串字节。为了提交结构化数据,我们可以使用序列化格式,比如 JSON 或 XML。此外,由于 AMQP 不限于 ASCII 消息,我们还可以使用二进制序列化协议,比如MessagePackProtocolBuffers

无论您决定使用哪种序列化格式,您都需要确保发布者和订阅者都了解序列化格式和消息的实际内部结构。

关于序列化格式,我们将在本章中选择简单的 JSON 序列化格式。它被广泛采用;使用 Go 标准库和其他编程语言(这一点很重要——尽管在本书中我们专门致力于 Go,但在微服务架构中,有许多不同的应用运行时是很常见的)轻松地进行序列化和反序列化消息。

我们还需要确保发布者和订阅者都知道消息的结构。例如,一个LocationCreated事件可能有一个name属性和一个address属性。为了解决这个问题,我们将引入一个共享库,其中包含所有可能事件的结构定义,以及 JSON(反)序列化的说明。然后,这个库可以在发布者和所有订阅者之间共享。

首先在您的 GOPATH 中创建todo.com/myevents/contracts目录。我们将描述的第一种事件类型是EventCreatedEvent事件。当创建新事件时,此消息将由事件服务发布。让我们在新创建的包的event_created.go文件中将此事件定义为一个结构:

package contracts 

import "time" 

type EventCreatedEvent struct { 
  ID         string    `json:"id"` 
  Name       string    `json:"id"` 
  LocationID string    `json:"id"` 
  Start      time.Time `json:"start_time"` 
  End        time.Time `json:"end_time"` 
} 

此外,我们需要为每个事件生成一个主题名称(在 RabbitMQ 中,主题名称也将用作消息的路由键)。为此,请向您新定义的结构添加一个新方法——EventName()

func (e *EventCreatedEvent) EventName() string { 
  return "event.created" 
} 

我们现在可以使用 Go 接口来定义一个通用的事件类型。这种类型可以用来强制每种事件类型实际上都实现了一个EventName()方法。由于事件发布者和事件订阅者以后也将被用于多个服务,我们将事件接口代码放入todo.com/myevents/lib/msgqueue包中。首先创建包目录和一个新文件event.go

package msgqueue 

type Event interface { 
  EventName() string 
} 

当然,我们的示例应用程序使用的事件不仅仅是EventCreatedEvent。例如,我们还有一个LocationCreatedEvent和一个EventBookedEvent。由于在打印中显示它们的所有实现会相当重复,我们希望在本章的示例文件中查看它们。

让我们现在继续构建一个事件发射器,它可以实际将这些消息发布到 AMQP 代理。由于我们将在本章的后面部分探索其他消息代理,因此我们将首先定义任何事件发射器应该满足的接口。为此,在之前创建的msgqueue包中创建一个emitter.go文件,内容如下:

package msgqueue 

type EventEmitter interface { 
  Emit(event Event) error 
} 

此接口描述了所有事件发射器实现需要满足的方法(实际上只有一个方法)。让我们继续创建一个todo.com/myevents/lib/msgqueue/amqp子包,其中包含一个emitter.go文件。该文件将包含AMQPEventEmitter的结构定义。

考虑以下代码示例:

package amqp 

import "github.com/streadway/amqp" 

type amqpEventEmitter struct { 
  connection *amqp.Connection 
} 

请注意amqpEventEmitter类型声明为包私有,因为它使用小写名称声明。这将阻止用户直接实例化amqpEventEmitter类型。为了正确实例化,我们将提供一个构造方法。

接下来,让我们添加一个setup方法,我们可以用来声明此发布者将要发布到的交换机:

func (a *amqpEventEmitter) setup() error {
   channel, err := a.connection.Channel()
   if err != nil {
     return err
   }

   defer channel.Close() 

  return channel.ExchangeDeclare("events", "topic", true, false, false, false, nil) 
 } 

您可能想知道为什么我们在此方法中创建了一个新的 AMQP 通道,并在声明交换机后立即关闭它。毕竟,我们可以在以后重用相同的通道来发布消息。我们稍后会解决这个问题。

继续添加一个构造函数NewAMQPEventEmitter,用于构建此结构的新实例:

func NewAMQPEventEmitter(conn *amqp.Connection) (EventEmitter, error) { 
  emitter := &amqpEventEmitter{ 
    connection: conn, 
  } 

  err := emitter.setup()
   if err != nil { 
    return nil, err 
  } 

  return emitter, nil 
} 

现在,到amqpEventEmitter事件的实际核心——Emit方法。首先,我们需要将作为参数传递给方法的事件转换为 JSON 文档:

import "encoding/json"

 // ...

 func (a *amqpEventEmitter) Emit(event Event) error { 
  jsonDoc, err := json.Marshal(event) 
  if err != nil { 
    return err 
  } 
} 

接下来,我们可以创建一个新的 AMQP 通道,并将我们的消息发布到事件交换机中:

func (a *amqpEventEmitter) Emit(event Event) error { 
  // ... 

  chan, err := a.connection.Channel(); 
  if err != nil { 
    return err 
  } 

  defer chan.Close() 

  msg := amqp.Publishing{ 
    Headers:     amqpTable{"x-event-name": event.EventName()}, 
    Body:        jsonDoc, 
    ContentType: "application/json", 
  } 

  return chan.Publish( 
    "events", 
    event.EventName(), 
    false, 
    false, 
    msg 
  ) 
} 

请注意,我们使用amqp.PublishingHeaders字段来将事件名称添加到特殊的消息头中。这将使我们更容易实现事件监听器。

还要注意,在此代码中,我们为每个发布的消息创建了一个新通道。虽然理论上可以重用相同的通道来发布多个消息,但我们需要记住,单个 AMQP 通道不是线程安全的。这意味着从多个 go 协程调用事件发射器的Emit()方法可能会导致奇怪和不可预测的结果。这正是 AMQP 通道的问题所在;使用多个通道,多个线程可以使用相同的 AMQP 连接。

接下来,我们可以将新的事件发射器集成到您已经在第二章和第三章中构建的现有事件服务中。首先,在ServiceConfig结构中添加一个 AMQP 代理的配置选项:

type ServiceConfig struct { 
  // ... 
  AMQPMessageBroker string `json:"amqp_message_broker"` 
} 

这使您可以通过 JSON 配置文件指定 AMQP 代理。在ExtractConfiguration()函数中,我们还可以添加一个备用选项,如果设置了环境变量,则可以从中提取此值:

func ExtractConfiguration(filename string) ServiceConfig { 
  // ... 

  json.NewDecoder(file).Decode(&conf) 
  if broker := os.Getenv("AMQP_URL"); broker != "" { 
    conf.AMQPMessageBroker = broker 
  } 

  return conf 
} 

现在,我们可以在事件服务的main函数中使用此配置选项来构造一个新的事件发射器:

package main 

// ... 
import "github.com/streadway/amqp" 
import msgqueue_amqp "todo.com/myevents/lib/msgqueue/amqp" 

func main() { 
  // ... 

  config := configuration.ExtractConfiguration(*confPath) 
  conn, err := amqp.Dial(config.AMQPMessageBroker) 
  if err != nil { 
    panic(err) 
  } 

  emitter, err := msgqueue_amqp.NewAMQPEventEmitter(conn) 
  if err != nil { 
    panic(err) 
  } 

  // ... 
} 

现在,我们可以将此事件发射器传递给rest.ServeAPI函数,然后再传递给newEventHandler函数:

func ServeAPI(endpoint string, dbHandler persistence.DatabaseHandler, eventEmitter msgqueue.EventEmitter) error { 
  handler := newEventHandler(dbHandler, eventEmitter) 
  // ... 
} 

然后,事件发射器可以作为eventServiceHandler结构的字段存储:

type eventServiceHandler struct { 
  dbhandler persistence.DatabaseHandler 
  eventEmitter msgqueue.EventEmitter 
} 

func newEventHandler(dbhandler persistence.DatabaseHandler, eventEmitter msgqueue.EventEmitter) *eventServiceHandler { 
  return &eventServiceHandler{ 
    dbhandler: dbhandler, 
    eventEmitter: eventEmitter, 
  } 
} 

现在,eventServiceHandler持有对事件发射器的引用,您可以在实际的 REST 处理程序中使用它。例如,通过 API 创建新事件时,您可以发出EventCreatedEvent。为此,请修改eventServiceHandlernewEventHandler方法如下:

func (eh *eventServiceHandler) newEventHandler(w http.ResponseWriter, r *http.Request) { 
  id, err := eh.dbhandler.AddEvent(event) 
  if err != nil { 
    // ... 
  } 

  msg := contracts.EventCreatedEvent{ 
    ID: hex.EncodeToString(id), 
    Name: event.Name, 
    LocationID: event.Location.ID, 
    Start: time.Unix(event.StartDate, 0), 
    End: time.Unix(event.EndDate, 0), 
  } 
  eh.eventEmitter.emit(&msg) 

  // ... 
} 

构建事件订阅者

现在我们可以使用EventEmitterRabbitMQ代理上发布事件,我们还需要一种方法来监听这些事件。这将是我们将在本节中构建的EventListener的目的。

与之前一样,让我们首先定义所有事件监听器(AMQP 事件监听器是其中之一)应该满足的接口。为此,在todo.com/myevents/lib/msgqueue包中创建listener.go文件:

package msgqueue 

type EventListener interface { 
  Listen(eventNames ...string) (<-chan Event, <-chan error, error) 
} 

这个接口看起来与事件发射器的接口有很大不同。这是因为对事件发射器的每次调用Emit()方法只是立即发布一条消息。然而,事件监听器通常会长时间处于活动状态,并且需要在接收到消息时立即做出反应。这反映在我们的Listen()方法的设计中:首先,它将接受事件监听器应该监听的事件名称列表。然后返回两个 Go 通道:第一个将用于流式传输事件监听器接收到的任何事件。第二个将包含接收这些事件时发生的任何错误。

首先通过在todo.com/myevents/lib/msgqueue/amqp包中创建一个新的listener.go文件来构建 AMQP 实现:

package amqp 

import "github.com/streadway/amqp" 

type amqpEventListener struct { 
  connection *amqp.Connection 
  queue      string 
} 

类似于事件发射器,继续添加一个setup方法。在这个方法中,我们需要声明监听器将要消费的 AMQP 队列:

func (a *ampqEventListener) setup() error { 
  channel, err := a.connection.Channel() 
  if err != nil { 
    return nil 
  } 

  defer channel.Close() 

  _, err := channel.QueueDeclare(a.queue, true, false, false, false, nil) 
  return err 
} 

请注意,监听器将要消费的队列的名称可以使用amqpEventListener结构的queue字段进行配置。这是因为以后,多个服务将使用事件监听器来监听它们的事件,并且每个服务都需要自己的 AMQP 队列。

您可能已经注意到,我们尚未将新声明的队列绑定到事件交换机。这是因为我们还不知道我们实际上需要监听哪些事件(记住Listen方法的events参数吗?)。

最后,让我们添加一个构造函数来创建新的 AMQP 事件监听器:

func NewAMQPEventListener(conn *amqp.Connection, queue string) (msgqueue.EventListener, error) { 
  listener := &amqpEventListener{ 
    connection: conn, 
    queue:      queue, 
  } 

  err := listener.setup() 
  if err != nil { 
    return nil, err 
  } 

  return listener, nil 
} 

有了构建新的 AMQP 事件监听器的可能性,让我们实现实际的Listen()方法。首先要做的是使用eventNames参数并相应地绑定事件队列:

func (a *amqpEventListener) Listen(eventNames ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  channel, err := a.connection.Channel() 
  if err != nil { 
    return nil, nil, err 
  } 

  defer channel.Close() 

  for _, eventName := range eventNames { 
    if err := channel.QueueBind(a.queue, eventName, "events", false, nil); err != nil { 
      return nil, nil, err 
    } 
  } 
} 

接下来,我们可以使用通道的Consume()方法从队列中接收消息:

func (a *amqpEventListener) Listen(eventNames ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  // ... 

  msgs, err := channel.Consume(a.queue, "", false, false, false, false, nil) 
  if err != nil { 
    return nil, nil, err 
  } 
} 

msgs变量现在持有amqp.Delivery结构的通道。然而,我们的事件监听器应该返回一个msgqueue.Event的通道。这可以通过在我们自己的 goroutine 中消费msgs通道,构建相应的事件结构,然后将这些事件发布到我们从这个函数返回的另一个通道中来解决:

func (a *amqpEventListener) Listen(eventNames ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  // ... 

  events := make(chan msgqueue.Event) 
  errors := make(errors) 

  go func() { 
    for msg := range msgs { 
      // todo: Map message to actual event struct 
    } 
  }() 

  return events, errors, nil 
} 

现在棘手的部分在于内部 goroutine 中。在这里,我们需要将原始的 AMQP 消息映射到实际的事件结构之一(如之前定义的EventCreatedEvent)。

还记得 EventEmitter 在发布事件时向 AMQP 消息添加了额外的x-event-name头部吗?现在我们可以使用这个来将这些消息映射回它们各自的结构类型。让我们从 AMQP 消息头中提取事件名称开始:

以下所有代码都放在Listen方法的内部range循环中。

rawEventName, ok := msg.Headers["x-event-name"] 
if !ok { 
  errors <- fmt.Errorf("msg did not contain x-event-name header") 
  msg.Nack(false) 
  continue 
} 

eventName, ok := rawEventName.(string) 
if !ok { 
  errors <- fmt.Errorf( 
    "x-event-name header is not string, but %t", 
    rawEventName 
  ) 
  msg.Nack(false) 
  continue 
} 

前面的代码尝试从 AMQP 消息中读取x-event-name头部。由于msg.Headers属性基本上是一个map[string]interface{},我们需要一些映射索引和类型断言,直到我们实际使用事件名称。如果接收到不包含所需头部的消息,将向错误通道写入错误。此外,消息将被 nack'ed(简称否定确认),表示经纪人无法成功处理该消息。

知道事件名称后,我们可以使用简单的 switch/case 结构从这个名称创建一个新的事件结构:

var event msgqueue.Event 

switch eventName { 
  case "event.created": 
    event = new(contracts.EventCreatedEvent) 
  default: 
    errors <- fmt.Errorf("event type %s is unknown", eventName) 
    continue 
} 

err := json.Unmarshal(msg.Body, event) 
if err != nil { 
  errors <- err 
  continue 
} 

events <- event 

构建预订服务

现在我们有了一个事件监听器,我们可以使用它来实现预订服务。它的一般架构将遵循事件服务的架构,因此我们不会过多地详细介绍这个问题。

首先创建一个新的包todo.com/myevents/bookingservice,并创建一个新的main.go文件:

package main 

import "github.com/streadway/amqp" 
import "todo.com/myevents/lib/configuration" 
import msgqueue_amqp "todo.com/myevents/lib/msgqueue/amqp" 
import "flag" 

func main() { 
  confPath := flag.String("config", "./configuration/config.json", "path to config file") 
  flag.Parse() 
  config := configuration.ExtractConfiguration(*confPath) 

  dblayer, err := dblayer.NewPersistenceLayer(config.Databasetype, config.DBConnection) 
  if err != nil { 
    panic(err) 
  } 

  conn, err := amqp.Dial(config.AMQPMessageBroker) 
  if err != nil { 
    panic(err) 
  } 

  eventListener, err := msgqueue_amqp.NewAMQPEventListener(conn) 
  if err != nil { 
    panic(err) 
  } 
} 

这将使用数据库连接和工作事件监听器设置预订服务。现在我们可以使用这个事件监听器来监听事件服务发出的事件。为此,添加一个新的子包todo.com/myevents/bookingservice/listener并创建一个新的event_listener.go\文件:

package listener 

import "log" 
import "todo.com/myevents/lib/msgqueue" 
import "todo.com/myevents/lib/persistence" 
import "gopkg.in/mgo.v2/bson" 

type EventProcessor struct { 
  EventListener msgqueue.EventListener 
  Database      persistence.DatabaseHandler 
} 

func (p *EventProcessor) ProcessEvents() error { 
  log.Println("Listening to events...") 

  received, errors, err := p.EventListener.Listen("event.created") 
  if err != nil { 
    return err 
  } 

  for { 
    select { 
      case evt := <-received: 
        p.handleEvent(evt) 
      case err = <-errors: 
        log.Printf("received error while processing msg: %s", err) 
    } 
  } 
} 

ProcessEvents()函数中,我们调用事件监听器的Listen函数来监听新创建的事件。Listen函数返回两个通道,一个用于接收消息,一个用于监听期间发生的错误。然后我们将使用一个无限运行的 for 循环和一个 select 语句同时从这两个通道中读取。接收到的事件将传递给handleEvent函数(我们仍然需要编写),接收到的错误将简单地打印到标准输出。

让我们继续使用handleEvent函数:

func (p *EventProcessor) handleEvent(event msgqueue.Event) { 
  switch e := event.(type) { 
    case *contracts.EventCreatedEvent: 
      log.Printf("event %s created: %s", e.ID, e) 
      p.Database.AddEvent(persistence.Event{ID: bson.ObjectId(e.ID)}) 
    case *contracts.LocationCreatedEvent: 
      log.Printf("location %s created: %s", e.ID, e) 
      p.Database.AddLocation(persistence.Location{ID: bson.ObjectId(e.ID)}) 
    default: 
      log.Printf("unknown event: %t", e) 
  } 
} 

这个函数使用类型开关来确定传入事件的实际类型。目前,我们的事件监听器通过将EventCreatedLocationCreated两个事件存储在它们的本地数据库中来处理这两个事件。

在这个例子中,我们使用了一个共享库todo.com/myevents/lib/persistence来管理数据库访问。这仅仅是为了方便。在真实的微服务架构中,各个微服务通常使用完全独立的持久化层,可能构建在完全不同的技术栈上。

在我们的main.go文件中,现在可以实例化EventProcessor并调用ProcessEvents()函数:

func main() { 
  // ... 

  eventListener, err := msgqueue_amqp.NewAMQPEventListener(conn) 
  if err != nil { 
    panic(err) 
  } 

  processor := &listener.EventProcessor{eventListener, dblayer} 
  processor.ProcessEvents() 
} 

除了监听事件,预订服务还需要实现自己的 REST API,用户可以用来预订指定事件的门票。这将遵循您已经在第二章和第三章中学到的相同原则,使用 Rest API 构建微服务保护微服务。因此,我们将避免详细解释预订服务的 REST API,并只描述要点。您可以在本章的代码示例中找到 REST 服务的完整实现。

main.go文件中,我们需要将processor.ProcessEvents()调用移到自己的 go-routine 中。否则,它会阻塞,程序永远不会达到ServeAPI方法调用:

func main() { 
  // ... 

  processor := &listener.EventProcessor{eventListener, dblayer} 
  go processor.ProcessEvents() 

  rest.ServeAPI(config.RestfulEndpoint, dbhandler, eventEmitter) 
} 

最后,我们将转向实际的请求处理程序。它在/events/{eventID}/bookings注册为 POST 请求;它会检查当前为该事件放置了多少预订,并且事件的位置是否仍然有容量可以再预订一次。在这种情况下,它将创建并持久化一个新的预订,并发出一个EventBooked事件。查看示例文件以查看完整的实现。

事件溯源

使用异步消息传递构建应用程序为应用一些高级架构模式打开了大门,其中之一将在本节中学习。

在使用消息传递、发布/订阅和事件协作时,整个系统状态的每一次变化都以一个事件的形式反映出来,该事件由参与服务中的一个发出。通常,这些服务中的每一个都有自己的数据库,保持对系统状态的自己视图(至少是所需的),并通过不断监听其他服务发布的事件来保持最新。

然而,系统状态的每一次变化都由一个已发布的事件表示,这提供了一个有趣的机会。想象一下,有人记录并保存了任何人发布的每一个事件到事件日志中。理论上(也在实践中),你可以使用这个事件日志来重建整个系统状态,而不必依赖任何其他类型的数据库。

举个例子,考虑以下(小)事件日志:

  1. 上午 8:00—用户#1 名称为爱丽丝被创建

  2. 上午 9:00—用户#2 名称为鲍勃被创建

  3. 下午 1:00—用户#1 被删除

  4. 下午 3:00—用户#2 将名称更改为塞德里克

通过重放这些事件,很容易重建系统在一天结束时的状态——有一个名为塞德里克的用户。然而,还有更多。由于每个事件都有时间戳,你可以重建应用在任何给定时间点的状态(例如,在上午 10:00,你的应用有两个用户,爱丽丝和鲍勃)。

除了点对点恢复,事件溯源还为你提供了系统中发生的一切的完整审计日志。审计日志通常在许多情况下是一个实际的要求,但也使得在出现错误时更容易调试系统。拥有完整的事件日志可以让你在确切的时间点复制系统的状态,然后逐步重放事件,以实际重现特定的错误。

此外,事件日志使各个服务不那么依赖其本地数据库。在极端情况下,你可以完全放弃数据库,并让每个服务在启动时从事件日志中内存重建其整个查询模型。

使用 Apache Kafka 实现发布/订阅和事件溯源

在本章的其余部分,我们不会构建自己的事件溯源系统。之前,我们使用 RabbitMQ 来实现服务之间的消息传递。然而,RabbitMQ 只处理消息分发,因此如果你需要一个包含所有事件的事件日志,你需要自己实现它,监听所有事件并持久化它们。你还需要自己处理事件重放。

Apache Kafka 是一个分布式消息代理,还附带一个集成的事务日志。它最初是由 LinkedIn 构建的,并作为 Apache 许可下的开源产品提供。

在前面的部分中,我们已经使用 AMQP 连接构建了EventEmitterEventListener接口的实现。在本节中,我们将使用 Kafka 来实现相同的接口。

使用 Docker 快速开始 Kafka

与 RabbitMQ 相反,Apache Kafka 设置起来要复杂一些。Kafka 本身需要一个工作的 Zookeeper 设置,以进行领导者选举、管理集群状态和持久化集群范围的配置数据。然而,为了开发目的,我们可以使用spotify/kafka镜像。该镜像带有内置的 Zookeeper 安装,可以快速轻松地设置。

就像之前的 RabbitMQ 图像一样,使用docker run命令快速开始:

$ docker run -d --name kafka -p 9092:9092 spotify/kafka

这将启动一个单节点 Kafka 实例,并将其绑定到本地主机的 TCP 端口9092

Apache Kafka 的基本原则

Kafka 提供了一个发布/订阅消息代理,但不是基于 AMQP,因此使用不同的术语。

Kafka 中的第一个基本概念是主题。主题类似于订阅者可以写入的类别或事件名称。它包含了曾经发布到该主题的所有消息的完整日志。每个主题分为可配置数量的分区。当发布新消息时,需要包含一个分区键。代理使用分区键来决定消息应写入主题的哪个分区。

每个 Kafka 主题由可配置数量的分区组成;每个发布的消息都有一个分区键,用于决定消息应保存到哪个分区

Kafka 代理保证在每个分区内,消息的顺序与发布时的顺序相同。对于每个主题,消息将保留一段可配置的保留期。然而,当事务日志变得更大时,代理的性能并不会显著下降。因此,完全可以使用无限的保留期操作 Kafka,并以此方式将其用作事件日志。当然,您需要考虑所需的磁盘存储将成比例增长。幸运的是,Kafka 对水平扩展支持得相当好。

从每个主题,任意数量的订阅者(在 Kafka 行话中称为 消费者)可以读取消息,任意数量的发布者(生产者)可以写入消息。每个消费者都可以定义从事件日志的哪个偏移量开始消费。例如,一个刚初始化的只在内存中操作的消费者可以从头(偏移量 = 0)读取整个事件日志以重建其整个查询模型。另一个只有本地数据库并且只需要在某个时间点之后发生的新事件的消费者可以从稍后的时间点开始读取事件日志。

每个消费者都是消费者组的成员。在给定主题中发布的消息将发布到每个组的一个消费者。这可以用来实现发布/订阅通信,类似于我们已经使用 AMQP 构建的内容。以下图表说明了使用 AMQP 和 Kafka 架构的发布/订阅中的不同术语和角色。在这两种情况下,发布在交换/主题中的每条消息都将路由到每个消费者。

使用 AMQP(1)和 Apache Kafka(2)进行发布/订阅;在交换/主题中发布的每条消息都会路由到每个订阅者

在 AMQP 中,也可以有多个订阅者监听同一个队列。在这种情况下,传入的消息将不会路由到所有订阅者,而是路由到其中一个已连接的订阅者。这可以用来在不同的订阅者实例之间构建某种负载均衡。

在 Kafka 中,可以通过将多个订阅者实例放入同一消费者组来实现相同的功能。然而,在 Kafka 中,每个订阅者被分配到一个固定的(可能是多个)分区。因此,可以并行消费主题的消费者数量受到主题分区数量的限制。以下图表说明了这个例子:

使用 AMQP(1)和 Apache Kafka(2)进行负载均衡;在交换/主题中发布的每条消息都会路由到已连接的订阅者之一

如果决定在同一消费者组中有多个消费者订阅主题的同一分区,代理将简单地将该分区中的所有消息分派给最后连接的消费者。

使用 Go 连接到 Kafka

在本章的前几节中,我们连接到 AMQP 代理时使用了事实上的标准库 github.com/streadway/amqp。连接到 Kafka 代理时,可用的 Go 库之间存在更多的多样性。在撰写本书时,Go 中最受欢迎的 Kafka 客户端库如下:

  1. github.com/Shopify/sarama 提供完整的协议支持,是纯 Go 实现的。它是根据 MIT 许可证授权的。它是维护活跃的。

  2. github.com/elodina/go_kafka_client 也是纯 Go 实现的。它提供的功能比 Shopify 库更多,但似乎维护活跃度较低。它是根据 Apache 许可证授权的。

  3. github.com/confluentinc/confluent-kafka-golibrdkafka C 库提供了一个 Go 包装器(这意味着您需要在系统上安装librdkafka才能使此库工作)。据说它比Shopify库更快,因为它依赖于高度优化的 C 库。不过,出于同样的原因,它可能难以构建。它正在积极维护,尽管其社区似乎比Shopify库小。

在本章中,我们将使用github.com/Shopify/sarama库。通过go get安装它:

$ go get github.com/Shopify/sarama

在前面的部分中,我们已经在todo.com/myevents/lib/msgqueue包中定义了EventEmitterEventListener接口。在本节中,我们将为这两个接口添加替代实现。在深入之前,让我们快速看一下如何使用sarama库来连接到 Kafka 代理。

无论您是打算发布还是消费消息,您都需要首先实例化sarama.Client结构。为此,您可以使用sarama.NewClient函数。要实例化一个新客户端,您需要 Kafka 代理地址的列表(记住,Kafka 设计为在集群中运行,因此您实际上可以同时连接到许多集群代理)和一个配置对象。创建配置对象的最简单方法是使用sarama.NewConfig函数:

import "github.com/Shopify/sarama" 

func main() { 
  config := sarama.NewConfig() 
  brokers := []string{"localhost:9092"} 
  client, err := sarama.NewClient(brokers, config) 

  if err != nil { 
    panic(err) 
  } 
} 

当然,在开发设置中,将localhost作为单个代理工作正常。对于生产设置,代理列表应从环境中读取:

func main() { 
  brokerList := os.Getenv("KAFKA_BROKERS") 
  if brokerList == "" { 
    brokerList = "localhost:9092" 
  } 

  brokers := strings.Split(brokerList, ",") 
  config := sarama.NewConfig() 

  client, err := sarama.NewClient(brokers, config) 
  // ... 
} 

您可以使用config对象来微调 Kafka 连接的各种参数。对于大多数情况,默认设置就可以了。

使用 Kafka 发布消息

Sarama 库提供了两种发布消息的实现——sarama.SyncProducersarama.AsyncProducer

AsyncProducer提供了一个异步接口,使用 Go 通道来发布消息并检查这些操作的成功。它允许高吞吐量的消息,但如果您只想发出单个消息,使用起来有点笨重。因此,SyncProducer提供了一个更简单的接口,它接受一个用于生产的消息,并在从代理接收到消息已成功发布到事件日志的确认之前阻塞。

您可以使用sarama.NewSyncProducerFromClientsarama.NewAsyncProducerFromClient函数实例化一个新的生产者。在我们的示例中,我们将使用SyncProducer,您可以按以下方式创建:

producer, err := sarama.NewSyncProducerFromClient(client) 
if err != nil { 
  panic(err) 
} 

让我们继续使用SyncProducer来创建我们的EventEmitter接口的 Kafka 实现。首先创建todo.com/myevents/lib/msgqueue/kafka包和该包中的emitter.go文件:

package kafka 

type kafkaEventEmitter struct { 
  producer sarama.SyncProducer 
} 

继续添加一个构造函数来实例化这个结构:

func NewKafkaEventEmitter(client sarama.Client) (msgqueue.EventEmitter, error) { 
  producer, err := sarama.NewSyncProducerFromClient(client) 
  if err != nil { 
    return nil, err 
  } 

  emitter := &kafkaEventEmitter{ 
    producer: producer, 
  } 

  return emitter, nil 
} 

为了发送消息,您需要构建sarama.ProducerMessage结构的实例。为此,您需要主题(在我们的情况下,由msgqueue.EventEventName()方法提供)和实际的消息正文。正文需要作为sarama.Encoder接口的实现提供。您可以使用sarama.ByteEncodersarama.StringEncoder类型,将字节数组或字符串简单地强制转换为Encoder实现:

func (e *kafkaEventEmitter) Emit(event msgqueue.Event) error { 
  jsonBody, err := json.Marshal(event) 
  if err != nil { 
    return err 
  } 

  msg := &sarama.ProducerMessage{ 
    Topic: event.EventName(), 
    Value: sarama.ByteEncoder(jsonBody), 
  } 

  _, _, err = e.producer.SendMessage(msg) 
  return err 
} 

在此代码示例中,关键是生产者的SendMessage()方法。请注意,我们实际上忽略了此方法的一些返回值。前两个返回值返回了消息写入的分区号和消息在事件日志中的偏移量。

前面的代码是有效的,但有一个致命的缺陷:它为每种事件类型创建了一个新的 Kafka 主题。虽然订阅者完全可以同时消费多个主题,但无法保证处理顺序。这可能导致生产者按顺序短时间内依次发出位置#1 创建位置#1 更新,而订阅者按照不同的顺序接收它们。

为了解决这个问题,我们需要做两件事:

  • 所有消息必须发布在同一个主题上。这意味着我们需要另一种方法在消息中存储实际的事件名称。

  • 每条消息必须公开一个分区键。我们可以使用消息的分区键来确保涉及相同实体的消息(即相同事件,相同用户)存储在事件日志的单个分区中,并且按顺序路由到相同的消费者。

让我们从分区键开始。还记得todo.com/myevents/lib/msgqueue包中的Event接口吗?它看起来是这样的:

package msgqueue 

type Event interface { 
  EventName() string 
} 

继续添加一个新的PartitionKey()方法到这个接口:

package msgqueue 

type Event interface { 
  PartitionKey() string 
  EventName() string 
} 

接下来,我们可以修改之前定义的现有事件结构(例如EventCreatedEvent)来实现这个PartitionKey()方法:

func (e *EventCreatedEvent) PartitionKey() string { 
  return e.ID 
} 

现在,让我们回到kafkaEventEmitter。我们现在可以在将消息发布到 Kafka 时使用每个事件的PartitionKey()方法。现在,我们只需要在事件旁边发送事件名称。为了解决这个问题,我们将在todo.com/myevents/lib/msgqueue/kafka包的新文件payload.go中定义这个事件:

package kafka 

type messageEnvelope struct { 
  EventName string      `json:"eventName"` 
  Payload   interface{} `json:"payload"` 
} 

现在,我们可以调整kafkaEventEmitter,首先构造messageEnvelope结构的实例,然后对其进行 JSON 序列化:

func (e *kafkaEventEmitter) Emit(event msgqueue.Event) error { 
  envelope := messageEnvelope{event.EventName(), event} 
  jsonBody, err := json.Marshal(&envelope) 
  // ... 

从 Kafka 消费消息

从 Kafka 代理服务器消费消息比在 AMQP 中更复杂一些。您已经了解到 Kafka 主题可能由许多分区组成,每个消费者可以消费一个或多个(最多全部)这些分区。Kafka 架构允许通过将主题分成更多分区并让一个消费者订阅每个分区来进行水平扩展。

这意味着每个订阅者都需要知道主题的哪些分区存在,以及它应该消费其中的哪些。我们在本节中介绍的一些库(尤其是 Confluent 库)实际上支持自动订阅者分区和自动组平衡。sarama库不提供此功能,因此我们的EventListener将需要手动选择要消费的分区。

对于我们的示例,我们将实现EventListener,以便默认情况下监听主题的所有可用分区。我们将添加一个特殊属性,用于明确指定要监听的分区。

todo.com/myevents/lib/msgqueue/kafka包中创建一个新文件listener.go

package kafka 

import "github.com/Shopify/sarama" 
import "todo.com/myevents/lib/msgqueue" 

type kafkaEventListener struct { 
  consumer   sarama.Consumer 
  partitions []int32 
} 

继续为这个结构体添加一个构造函数:

func NewKafkaEventListener(client sarama.Client, partitions []int32) (msgqueue.EventListener, error) { 
  consumer, err := sarama.NewConsumerFromClient(client) 
  if err != nil { 
    return nil, err 
  } 

  listener := &kafkaEventListener{ 
    consumer: consumer, 
    partitions: partitions, 
  } 

  return listener, nil 
} 

kafkaEventListenerListen()方法遵循与我们在上一节中实现的amqpEventListener相同的接口:

func (k *kafkaEventListener) Listen(events ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  var err error 

  topic := "events" 
  results := make(chan msgqueue.Event) 
  errors := make(chan error) 
} 

首先要做的是确定应该消费哪些主题分区。我们将假设当NewKafkaEventListener方法传递了一个空切片时,监听器应该监听所有分区:

func (k *kafkaEventListener) Listen(events ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  var err error 

  topic := "events" 
  results := make(chan msgqueue.Event) 
  errors := make(chan error) 

  partitions := k.partitions 
  if len(partitions) == 0 { 
    partitions, err = k.consumer.partitions(topic) 
    if err != nil { 
      return nil, nil, err 
    } 
  } 

  log.Printf("topic %s has partitions: %v", topic, partitions) 
} 

Sarama 消费者只能消费一个分区。如果我们想要消费多个分区,我们需要启动多个消费者。为了保持EventListener的接口,我们将在Listen()方法中启动多个消费者,每个消费者在自己的 goroutine 中运行,然后让它们都写入同一个结果通道:

func (k *kafkaEventListener) Listen(events ...string) (<-chan msgqueue.Event, <-chan error, error) { 
  // ... 

  log.Printf("topic %s has partitions: %v", topic, partitions) 

  for _, partitions := range partitions { 
    con, err := k.consumer.ConsumePartition(topic, partition, 0) 
    if err != nil { 
      return nil, nil, err 
    } 

    go func() { 
      for msg := range con.Messages() { 

      } 
    }() 
  } 
} 

注意在第一个 for 循环内启动的 goroutines。其中每个都包含一个内部 for 循环,遍历给定分区中接收到的所有消息。现在我们可以对传入的消息进行 JSON 解码,并重建适当的事件类型。

以下所有代码示例都放置在kafkaEventListenerListen()方法的内部 for 循环中。

for msg := range con.Messages() { 
  body := messageEnvelope{} 
  err := json.Unmarshal(msg.Value, &body) 
  if err != nil { 
    errors <- fmt.Errorf("could not JSON-decode message: %s", err) 
    continue 
  } 
} 

现在我们有一个新问题。我们已经将事件主体解组为messageEnvelope结构。这包含了事件名称和实际事件主体。然而,事件主体只是被定义为interface{}。理想情况下,我们需要将这个interface{}类型转换回正确的事件类型(例如,contracts.EventCreatedEvent),这取决于事件名称。为此,我们可以使用github.com/mitchellh/mapstructure包,您可以通过 go get 安装:

$ go get -u github.com/mitchellh/mapstructure

mapstructure库的工作方式类似于encoding/json库,只是它不接受[]byte输入变量,而是通用的interface{}输入值。这允许您接受未知结构的 JSON 输入(通过在interface{}值上调用json.Unmarshal),然后将已解码的未知结构类型映射到已知的结构类型:

for msg := range con.Messages() { 
  body := messageEnvelope{} 
  err := json.Unmarshal(msg.Value, &body) 
  if err != nil { 
    errors <- fmt.Errorf("could not JSON-decode message: %s", err) 
    continue 
  } 

  var event msgqueue.Event 
  switch body.EventName { 
    case "event.created": 
      event = contracts.EventCreatedEvent{} 
    case "location.created": 
      event = contracts.LocationCreatedEvent{} 
    default: 
      errors <- fmt.Errorf("unknown event type: %s", body.EventName) 
      continue 
  } 

  cfg := mapstructure.DecoderConfig{ 
    Result: event, 
    TagName: "json", 
  } 
  err = mapstructure.NewDecoder(&cfg).Decode(body.Payload) 
  if err != nil { 
    errors <- fmt.Errorf("could not map event %s: %s", body.EventName, err) 
  } 
} 

在实际解码之前创建的mapstructure.DecoderConfig结构中的TagName属性指示mapstructure库尊重事件合同中已经存在的json:"..."注释。

成功解码消息后,可以将其发布到结果通道中:

for msg := range con.Messages() { 
  // ...   
  err = mapstructure.NewDecoder(&cfg).Decode(body.Payload) 
  if err != nil { 
    errors <- fmt.Errorf("could not map event %s: %s", body.EventName, err) 
  } 

  results <- event 
} 

我们的 Kafka 事件监听器现在已经完全可用。由于它实现了msgqueue.EventListener接口,您可以将其用作现有 AMQP 事件监听器的即插即用替代品。

然而,有一个警告。当启动时,我们当前的 Kafka 事件监听器总是从事件日志的开头开始消费。仔细看一下前面代码示例中的ConsumePartition调用——它的第三个参数(在我们的例子中是0)描述了消费者应该开始消费的事件日志中的偏移量。

使用0作为偏移量将指示事件监听器从头开始读取整个事件日志。如果您想要使用 Kafka 实现事件溯源,这是理想的解决方案。如果您只想将 Kafka 用作消息代理,您的服务将需要记住从事件日志中读取的最后一条消息的偏移量。当您的服务重新启动时,您可以从上次已知的位置继续消费。

总结

在本章中,您学习了如何使用消息队列(如 RabbitMQ 和 Apache Kafka)集成多个服务进行异步通信。您还了解了事件协作和事件溯源等架构模式,这有助于您构建适合云部署的可扩展和弹性的应用程序。

本章中我们使用的技术与任何特定的云提供商无关。您可以轻松地在任何云基础设施或您自己的服务器上部署自己的 RabbitMQ 或 Kafka 基础设施。在第八章中,AWS 第二部分-S3、SQS、API 网关和 DynamoDB,我们将再次关注消息队列,这次特别关注 AWS 提供给您的托管消息解决方案。

第五章:使用 React 构建前端

在之前的章节中,您已经使用 Go 构建了多个微服务,并使用 REST Web 服务和异步消息队列进行了集成。然而,即使是最可扩展的云应用程序,如果没有用户可以轻松交互的界面,也只有一半的用处(除非,当然,向用户提供 REST API 是您的实际产品)。为了使前几章中构建的 API 更具体,我们现在将为我们的应用程序添加一个基于 Web 的前端。

为此,我们将离开 Go 编程世界一段时间,并短暂地转向 JavaScript 编程世界。更确切地说,我们将看一下 React 框架,并将其用于为(现在几乎完成的)MyEvents 后端构建前端应用程序。

在构建前端应用程序时,我们还将接触到 JavaScript 生态系统中许多组件。例如,我们将使用 TypeScript 编译器以便以类型安全的方式进行编程。此外,我们将使用 Webpack 模块打包程序,以便轻松部署我们的 JavaScript 应用程序,以便在所有现代 Web 浏览器中轻松使用。

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

  • 设置 Node.js/TypeScript/React 开发环境

  • 启动一个新项目

  • React 组件

  • Webpack 模块打包程序

  • 使用 RESTful 后端构建 React 应用程序

开始使用 React

在本章中,我们将暂时离开 Go 生态系统。要使用 React,您需要一个开发环境,其中包括 Node.js、npm 和 TypeScript 编译器,我们将在下一节中设置。

设置 Node.js 和 TypeScript

JavaScript 是一种动态类型语言。尽管(像 Go 一样)它确实有数据类型的概念,但 JavaScript 变量基本上可以在任何时候具有任何类型(与 Go 不同)。由于我们不希望您在我们短暂进入 JavaScript 世界的过程中开始错过 Go 编译器和 Go 的类型安全性,因此我们将在此示例中使用 TypeScript。TypeScript 是 JavaScript 的类型安全超集,它添加了静态类型和基于类的面向对象编程。您可以使用 TypeScript 编译器(或简称为tsc)将 TypeScript 编译为 JavaScript。

首先,除了 Go 运行时,您还需要在开发机器上设置一个可用的 Node.js 运行时。查看nodejs.org/en/download了解如何在您的机器上设置 Node.js。如果您使用 Linux(或 macOS 使用 Homebrew 等软件包管理器),请查看nodejs.org/en/download/package-manager

安装了 Node.js 后,继续使用 Node 包管理器(npm)安装 TypeScript 编译器:

$ npm install -g typescript

这将下载并安装 TypeScript 编译器到您系统的PATH中。运行上述命令后,您应该能够在命令行上调用 tsc。

在这个项目中,我们还将使用 Webpack 模块打包程序。模块打包程序获取 Node.js 模块并生成静态 JavaScript 文件,可在浏览器环境中使用。您可以通过 npm 安装 Webpack,就像您为 TypeScript 编译器所做的那样:

$ npm install -g webpack

初始化 React 项目

首先为您的 React 前端应用程序创建一个新目录。接下来,将该目录初始化为一个新的npm包:

$ npm init

npm init命令将提示您输入有关您的项目的一些(多多少少)重要信息。最后,它应该会生成一个package.json文件,大致如下:

{ 
  "name": "myevents-ui", 
  "version": "1.0.0", 
  "description": "", 
  "main": "dist/bundle.js", 
  "author": "Martin Helmich", 
  "license": "MIT" 
} 

一般来说,我们的应用程序将具有以下目录结构:

  • 我们的 TypeScript 源文件将放在src/目录中。

  • 编译后的 JavaScript 文件将放在dist/目录中。由于我们将使用 Webpack 作为模块打包程序,我们的dist/目录很可能只包含一个文件,其中包含整个编译后的源代码。

  • 我们将通过 npm 安装的库作为依赖项安装到node_modules/目录中。

现在我们可以使用 npm 向我们的项目添加依赖项。让我们从安装 React 和 ReactDOM 包开始:

$ npm install --save react@16 react-dom@16 @types/react@16 @types/react-dom@16

TypeScript 编译器需要@types包。由于 React 是一个 JavaScript(而不是 TypeScript)库,TypeScript 编译器将需要有关由 react 库定义的类和它们的方法签名的额外信息。例如,这些typings可能包含有关 React 提供的某些函数所需的参数类型和它们的返回类型的信息。

我们还需要一些开发依赖项:

$ npm install --save-dev typescript awesome-typescript-loader source-map-loader

这些库将被 Webpack 模块捆绑器需要将我们的源文件编译为 JavaScript 文件。但是,我们只需要这些依赖项来构建应用程序,而不是实际运行它。因此,我们使用--save-dev标志将它们声明为开发依赖项。

接下来,我们需要配置 TypeScript 编译器。为此,在项目目录中创建一个新的tsconfig.json文件:

{ 
  "compilerOptions": { 
    "outDir": "./dist/", 
    "module": "commonjs", 
    "target": "es5", 
    "sourceMap": true, 
    "noImplicitAny": true, 
    "jsx": "react" 
  }, 
  "include": [ 
    "./src/**/*" 
  ] 
} 

请注意,我们正在配置 TypeScript 编译器,使用include属性从src/目录加载其源文件,并使用outDir属性将编译后的输出文件保存到dist/

最后,我们还需要通过创建一个webpack.config.js文件来配置 Webpack 模块捆绑器:

module.exports = { 
  entry: "./src/index.tsx", 
  output: { 
    filename: "bundle.js", 
    path: __dirname + "/dist" 
  }, 
  resolve: { 
    extensions: [".ts", ".tsx"] 
  }, 
  module: { 
    rules: [ 
      { 
        test: /\.tsx?$/, 
        loader: "awesome-typescript-loader" 
      } 
    ] 
  }, 
  externals: { 
    "react": "React", 
    "react-dom": "ReactDOM" 
  } 
} 

这个文件配置 Webpack 在所有.ts.tsx文件上使用 TypeScript 加载器,编译它们,并将所有模块捆绑到dist/bundle.js文件中。但在实际执行之前,您需要添加一些要编译的源文件。

在这样做之前,让我们看看 React 实际上是如何工作的。

基本的 React 原则

React 应用程序是由组件构建的。组件是一个 JavaScript 类,它接受一组值(称为属性,或简称为props),并返回可以由浏览器呈现的 DOM 元素树。

考虑以下简单的例子。我们将从纯 JavaScript 实现开始,然后向您展示如何使用 TypeScript 添加静态类型:

class HelloWorld extends React.Component { 
  render() { 
    return <div className="greeting"> 
      <h1>Hello {this.props.name}!</h1> 
    </div>; 
  } 
} 

即使您习惯于 JavaScript,该语法对您来说可能会很新。从技术上讲,前面的代码示例不是纯 JavaScript(任何浏览器都会拒绝实际运行此代码),而是JSX。JSX 是 JavaScript 的一种特殊语法扩展,允许您直接使用它们的 HTML 表示来定义 DOM 元素。这使得定义 React 组件变得更加容易。如果不使用 JSX,前面的代码示例将需要按照以下方式编写:

class HelloWorld extends React.Component { 
  render() { 
    return React.createElement("div", {class: "greeting"}, 
      React.createElement("h1", {}, `Hello ${this.props.name}!`) 
    ); 
  } 
} 

当实际在浏览器中运行 JSX 源代码时,它需要首先转换为普通的 JavaScript。这将在实际构建应用程序时由 Webpack 模块捆绑器完成。

还有 JSX 的 TypeScript 变体,称为TSX。它的工作方式完全相同,但具有静态类型。使用 TypeScript 构建 React 组件时,您还可以为组件 props 定义接口。

由于这实际上是一本 Go 书,很重要的一点是要注意 TypeScript 接口与 Go 接口相比是非常不同的。虽然 Go 接口描述了一个结构体需要实现的一组方法,但 TypeScript 接口定义了对象需要具有的属性和/或方法。

要将 React 组件与 props 接口关联起来,React.Component类具有一个类型参数,您可以在扩展类时指定:

export interface HelloWorldProps { 
  name: string; 
} 

export class HelloWorld extends React.Component 
<HelloWorldProps, any> { 
  render() { 
    // ... 
  } 
} 

组件可以嵌套在彼此中。例如,您现在可以在另一个组件中重用之前的HelloWorld组件:

import {HelloWorld} from "./hello_world"; 

class ExampleComponents extends React.Component<{}, any> { 
  render() { 
    return <div class="greeting-list"> 
      <HelloWorld name="Foo"/> 
      <HelloWorld name="Bar"/> 
    </div> 
  } 
} 

使用 TypeScript 的一个优点是,当您使用通过接口定义 props 的组件时,TypeScript 编译器会检查您是否实际上提供了正确的 props 给组件。例如,在前面的例子中省略name prop(或将其传递给另一个值而不是字符串)将触发编译错误。

传递给 React 组件的 props 被视为不可变的。这意味着当 prop 的值之一更改时,组件不会重新渲染。但是,每个 React 组件可能有一个内部状态,可以进行更新。每当组件的状态更改时,它将被重新渲染。考虑以下例子:

export interface CounterState { 
  counter: number; 
} 

export class Counter extends React.Component<{}, CounterState> { 
  constructor() { 
    super(); 
    this.state = {counter: 0}; 
  } 

  render() { 
    return <div>Current count: {this.state.counter}</div>; 
  } 
} 

现在,我们可以使用组件的setState()方法随时更新此状态。例如,我们可以使用计时器每秒增加计数器:

constructor() { 
  super();
   this.state = {counter: 0}; 

  setInterval(() => { 
    this.setState({counter: this.state.counter + 1}); 
  }, 1000); 
} 

改变组件的状态将导致重新渲染。在前面的例子中,这将导致计数器每秒可见地增加 1。

当然,我们也可以组合 props 和 state。一个常见的用例是使用传递给组件的 props 来初始化该组件的状态:

export interface CounterProps { 
  start: number; 
} 

export interface CounterState { 
  counter: number 
} 

export class Counter extends React.Component<CounterProps, CounterState> { 
  constructor(props: CounterProps) { 
    super(props); 

    this.state = { 
      counter: props.start 
    }; 

    setInterval(() => { 
      // ... 
  } 
} 

掌握了 React 组件的知识后,我们现在可以开始构建 MyEvents 平台的前端。

启动 MyEvents 前端

我们将首先构建一个简单的 React 应用程序,从服务器获取可用事件列表并将其显示为简单列表。

在开始之前,我们需要引导我们的 React 应用程序。为此,我们需要构建一个可以作为应用程序入口点的index.html文件。通常,这个文件不会很长,因为它的大部分逻辑将以 React 组件的形式存在:

<!DOCTYPE html> 
<html lang="en"> 
  <head> 
    <meta charset="UTF-8"> 
    <title>MyEvents</title> 
  </head> 
  <body> 
    <div id="myevents-app"></div> 

    <script src="img/react.production.min.js"></script> 
    <script src="img/react-dom.production.min.js"></script> 
    <script src="img/bundle.js"></script> 
  </body> 
</html> 

让我们更详细地看一下这个 HTML 文件。具有myevents-app ID 的DIV将成为我们的 React 应用程序将呈现的位置。然后,大部分文件由从相应的 npm 包加载 React 库和加载我们的实际应用程序包(将由 Webpack 构建)组成。

为了使我们的应用程序看起来更好一些,我们还将在前端添加 Twitter Bootstrap 框架。像往常一样,您可以使用npm来安装 Bootstrap:

$ npm install --save bootstrap@³.3.7

安装 Bootstrap 后,您可以在index.html文件的头部部分包含相应的 CSS 文件:

<!DOCTYPE html> 
<html lang="en"> 
<head> 
  <meta charset="UTF-8"> 
  <title>MyEvents</title> 
  <link rel="stylesheet" href="./node_modules/bootstrap/dist/css/bootstrap.min.css"/> 
</head> 
<body> 
  <!-- ... --> 
</body> 
</html> 

要开始,请现在添加一个新的 React 组件。为此,在项目目录中创建src/components/hello.tsx文件:

import * as React from "React"; 

export interface HelloProps { 
  name: string; 
} 

export class Hello extends React.Component<HelloProps, {}> { 
  render() { 
    return <div>Hello {this.props.name}!</div>; 
  } 
} 

我们的 React 应用程序的实际入口点将放在src/index.tsx文件中。您可能还记得,这也是我们在webpack.config.js文件中指定为 Webpack 模块打包程序的入口点的文件:

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import {Hello} from "./components/hello"; 

ReactDOM.render( 
  <div className="container"> 
    <h1>MyEvents</h1> 
    <Hello name="World"/> 
  </div>, 
  document.getElementById("myevents-app") 
); 

看一下前面代码示例中的className属性。在 JSX 或 TSX 中使用纯 HTML 元素时,您需要使用className而不是class。这是因为class是 JavaScript 和 TypeScript 中的保留关键字,因此仅使用class会严重混淆编译器。

创建了所有这些文件后,现在可以运行 Webpack 打包程序来创建您的bundle.js文件:

$ webpack

在开发过程中,您还可以让 Webpack 打包程序持续运行,每当源文件之一更改时更新您的bundle.js文件。只需在后台的 shell 窗口中保持启动的进程运行:

$ webpack --watch

现在可以在浏览器中打开index.html文件。但是,直接在浏览器中打开本地文件将在以后向后端服务发出 HTTP 请求时引起问题。您可以使用http-server npm 包快速设置一个可以提供这些本地文件的 HTTP 服务器。只需通过npm安装它,然后在项目目录中运行它:

$ npm install -g http-server
$ http-server

默认情况下,Node.js HTTP 服务器将在 TCP 端口8080上监听,因此您可以通过在浏览器中导航到http://localhost:8080来访问它:

输出(http://localhost:8080

恭喜!您刚刚构建了您的第一个 React 应用程序。当然,对于 MyEvents 平台,我们将需要比 Hello World!更多。我们的第一个任务之一将是从后端服务加载可用事件并以美观的方式显示它们。

实现事件列表

为了显示可用事件的列表,我们将需要一个解决方案来从后端服务加载这些事件,更准确地说,是您在第二章中构建的事件服务的 REST API,使用 Rest API 构建微服务,以及第三章,保护微服务

自己带来客户端

React 是一个模块化的框架。与其他 JavaScript 前端框架(如 Angular)不同,React 不提供自己的 REST 调用库,而是期望您自己带来。为了从服务器加载数据,我们将使用 fetch API。fetch API 是一个较新的 JavaScript API,用于向许多现代浏览器(主要是 Firefox 和 Chrome)中实现的后端服务进行 AJAX 调用。对于尚未实现 fetch API 的旧版浏览器,有一个polyfill库,您可以通过npm添加到您的应用程序中:

$ npm install --save whatwg-fetch promise-polyfill

您需要在index.html文件中将这两个polyfill库与其他 JavaScript 库一起包含:

<script src="img/react.min.js"></script> 
<script src="img/react-dom.min.js"></script> 
<script src="img/promise.min.js"></script> 
<script src="img/fetch.js"></script> 
<script src="img/bundle.js"></script> 

当浏览器的 fetch API 可用时,fetch polyfill库将使用浏览器的 fetch API,并在不可用时提供自己的实现。几年后,当更多的浏览器支持 fetch API 时,您将能够安全地删除polyfill

构建事件列表组件

现在让我们考虑一下我们将需要哪些 React 组件来构建我们的事件列表。以下图表显示了我们将要构建的组件的概述:

事件列表将构建的组件概述

这些组件将有以下职责:

  • EventListContainer组件将负责从后端服务加载事件列表并在其自己的状态中管理事件列表。然后,它将当前的事件集传递给EventList组件的 props。

  • EventList组件将负责呈现事件列表将呈现的容器。首先,我们将选择一个简单的表格视图。然后,这个表格将被填充一个EventListItem集合,每个事件一个。

  • EventListItem组件将在事件列表中呈现单个事件项。

从技术上讲,EventList组件可以同时加载来自后端服务的事件并管理事件列表呈现。然而,这将违反单一责任原则;这就是为什么我们有两个组件的原因——一个加载事件并将其传递给另一个,另一个向用户呈现它们。

让我们首先告诉 TypeScript 编译器事件实际上是什么样子。为此,我们将定义一个 TypeScript 接口,描述后端服务在使用GET获取 URL/events时传递的 JSON 响应。创建一个新的./src/models/event.ts文件,内容如下:

export interface Event { 
  ID string; 
  Name string; 
  Country string; 
  Location { 
    ID string; 
    Name string; 
    Address string; 
  }; 
  StartDate number; 
  EndDate number; 
  OpenTime: number; 
  CloseTime: number; 
} 

注意这个接口定义与事件服务代码中定义的persistence.Event结构是多么相似。为了使前端和后端能够良好协作,这两个定义在发生更改时需要保持同步。

现在您可以继续构建 React 组件。我们将从底部开始实现EventListItem。为此,请创建一个新的src/components/event_list_item.tsx文件:

import {Event} from "../models/event"; 
import * as React from "react"; 

export interface EventListItemProps { 
  event: Event; 
} 

export class EventListItem extends React.Component<EventListItemProps, {}> { 
  render() { 
    const start = new Date(this.props.event.StartDate * 1000); 
    const end = new Date(this.props.event.EndDate * 1000); 

    return <tr> 
      <td>{this.props.event.Name}</td> 
      <td>{this.props.event.Location.Name}</td> 
      <td>{start.toLocaleDateString()}</td> 
      <td>{end.toLocaleDateString()}</td> 
      <td></td> 
    </tr> 
  } 
} 

接下来,在src/components/event_list.tsx文件中定义EventList组件:

import {Event} from "../models/event"; 
import {EventListItem} from "./event_list_item"; 
import * as React from "react"; 

export interface EventListProps { 
  events: Event[]; 
} 

export class EventList extends React.Component<EventListProps, {}> { 
  render() { 
    const items = this.props.events.map(e => 
      <EventListItem event={e} /> 
    ); 

    return <table className="table"> 
      <thead> 
        <tr> 
          <th>Event</th> 
          <th>Where</th> 
          <th colspan="2">When (start/end)</th> 
          <th>Actions</th> 
        </tr> 
      </thead> 
      <tbody> 
        {items} 
      </tbody> 
    </table> 
  }   
} 

注意EventList组件如何使用 JavaScript 的原生map函数将事件对象数组转换为EventListItem列表(传递事件作为 prop)非常容易。EventListItem列表然后插入到EventList组件创建的表的主体中。

最后,我们可以构建EventListContainer组件。在这个组件中,我们将使用 fetch API 从服务器加载事件。首先,让我们在src/components/event_list_container.tsx文件中实现EventListContainer的 props 和 state 的定义:

import * as React from "react"; 
import {EventList} from "./event_list"; 
import {Event} from "../models/event"; 

export interface EventListContainerProps { 
  eventListURL: string; 
} 

export interface EventListContainerState { 
  loading: boolean; 
  events: Event[] 
} 

接下来,我们可以实现实际的组件:

export class EventListContainer extends React.Component 
<EventListContainerProps, EventListContainerState> { 
  construct(p: EventListContainerProps) { 
    super(p); 

    this.state = { 
      loading: true, 
      events: [] 
    }; 

    fetch(p.eventListURL) 
      .then<Event[]>(response => response.json()) 
      .then(events => { 
        this.setState({ 
          loading: false, 
          events: events 
        }); 
      }); 
  } 
} 

在构造函数中,我们将首先初始化组件的状态。在这里,重要的是要记住 JavaScript 中的 HTTP 操作通常是异步的。尽管我们在构造函数中调用fetch函数,但 JavaScript 运行时将异步执行此 HTTP 请求,并且即使没有加载数据(尚未),组件也将被创建。因此,我们的组件状态包括一个名为loading的布尔属性,指示数据是否仍在加载。稍后,组件可以根据这个状态属性调整其呈现。

fetch方法返回一个 promise。promise 是一个尚未可用的值的占位符。您可以在 promise 实例上使用then(...)函数,以便在承诺的值变为可用时立即运行代码。您还可以链接 promise;在这种情况下,fetch函数返回一个 HTTP 响应的 promise(即Response类的实例)。这个类本身有一个json()函数,它本身返回另一个解码后的 JSON 值的 promise。当传递给then(...)调用的函数返回另一个 promise 时,返回的 promise 将替换原始 promise。这意味着我们可以在链中添加另一个then()调用,当 HTTP 响应可用并且成功解码为 JSON 时将调用该调用。当发生这种情况时,我们将更新组件的状态,指示组件不再处于加载状态,并且包含实际事件列表的events属性。

最后,通过添加一个render()方法来完成EventListContainer组件:

render() { 
  if (this.state.loading) { 
    return <div>Loading...</div>; 
  } 

  return <EventList events={this.state.events} />; 
} 

为了实际在我们的页面上显示事件列表,现在可以在index.tsx文件中使用EventListContainer

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import {EventListContainer} from "./components/event_list_container"; 

ReactDOM.render( 
  <div className="container"> 
    <h1>MyEvents</h1> 
    <EventListContainer eventListURL="http://localhost:8181"/> 
  </div>, 
  document.getElementById("myevents-app") 
); 

一般来说,构建一个可以作为应用程序的单一入口点的根组件也被认为是一种良好的做法。我们可以将ReactDOM.render调用中的 DOM 元素提取到自己的组件中,然后在ReactDOM.render调用中使用它:

class App extends React.Component<{}, {}> { 
  render() { 
    return <div className="container"> 
      <h1>MyEvents</h1> 
      <EventListContainer eventListURL="http://localhost:8181"/> 
    </div> 
  } 
} 

ReactDOM.render( 
  <App/> 
  document.getElementById("myevents-app") 
); 

在后端服务中启用 CORS

在测试前端应用程序之前,您需要确保后端服务(更准确地说,事件服务和预订服务)支持跨源资源共享CORS)。否则,当前端在http://localhost:8080上提供,后端服务在其他 TCP 端口上运行时,浏览器将不会执行对任何后端服务的 HTTP 请求。

原则上,CORS 只是需要在 HTTP 响应中存在的一些额外标头。例如,为了允许来自另一个域的 AJAX 请求,HTTP 响应需要包含一个Access-Control-Allow-Origin标头。具有这样一个标头的 HTTP 响应可能如下所示:

HTTP/1.1 200 OK
 Content-Type: application/json; charset=utf-8
 Content-Length: 1524
 Date: Fri, 24 Mar 2017 16:02:55 GMT
 Access-Control-Allow-Origin: http://localhost:8080 

由于我们在事件和预订服务中都使用 Gorilla 工具包,因此添加 CORS 功能很容易。首先,我们需要获取github.com/gorilla/handlers包:

$ go get github.com/gorilla/handlers

之后,我们可以使用handlers.CORS函数将 CORS 功能添加到现有的 HTTP 服务器中。这允许我们调整事件服务的rest.go文件如下:

package rest 

import ( 
  // ... 
  "github.com/gorilla/mux" 
  "github.com/gorilla/handlers" 
) 

func ServeAPI(endpoint string, dbHandler persistence.DatabaseHandler, eventEmitter msgqueue.EventEmitter) error { 
  handler := newEventHandler(dbHandler, eventEmitter) 
  r := mux.NewRouter() 

  // ... 

  server := handlers.CORS()(r) 
  return http.ListenAndServe(endpoint, server) 
} 

以相同的方式调整预订服务。之后,您将能够从前端应用程序无问题地与两个服务进行通信。

测试事件列表

为了测试你的应用程序,请确保你的事件服务实例在本地运行,并监听 TCP 端口8181。还要确保你已经使用事件服务的 REST API 创建了一个或两个事件。然后,在你的前端应用目录中启动 Node.js http-server,并在浏览器中导航到http://localhost:8080

输出(http://localhost:8080

添加路由和导航

在我们为前端应用添加更多功能之前,让我们花时间添加一个强大的导航和路由层。这将使我们的应用在添加更多功能时保持易于维护。

为了支持多个应用视图,我们首先将react-router-dom包添加到我们的应用中:

$ npm install --save react-router-dom
$ npm install --save-dev @types/react-router-dom

react-router-dom包为我们的应用添加了一些新组件。我们可以在根组件中使用这些组件来轻松实现路由:

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import {HashRouter as Router, Route} from "react-router-dom"; 
// ... 

class App extends React.Component<{}, {}> { 
  render() { 
    const eventList = () => <EventListContainer eventServiceURL="http://localhost:8181"/> 

    return <Router> 
      <div className="container"> 
        <h1>My Events</h1> 

        <Route exact path="/" component={eventList}/> 
      </div> 
    </Router> 
  } 
} 

请注意容器中如何使用<Route>组件;在这一点上,我们可以稍后添加多个Route组件,React 路由将根据当前 URL 呈现这些组件。这允许我们的应用使用普通的旧链接将用户从一个视图引导到另一个视图。

请注意在前面的render()方法中声明的eventList常量。这是因为Route组件接受一个component属性,它引用一个组件或一个函数,每当匹配到这个Route时都会调用它。然而,我们无法指定应该传递给相应组件的 props。这就是为什么我们声明一个函数,用默认 props 初始化EventListContainer组件,允许它在Route组件中使用。

现在我们有了一个工作的路由层;让我们确保我们的用户始终能找到回到事件列表的方法。为此,我们将添加一个新的导航栏组件,我们可以在根组件中使用。创建一个新的src/components/navigation.tsx文件:

import * as React from "react"; 
import {Link} from "react-router-dom"; 

export interface NavigationProps { 
  brandName: string; 
} 

export class Navigation extends React.Component<NavigationProps, {}> { 
} 

接下来,在新组件中添加一个render()方法:

render() { 
  return <nav className="navbar navbar-default"> 
    <div className="container"> 
      <div className="navbar-header> 
        <Link to="/" className="navbar-brand"> 
          {this.props.brandName} 
        </Link> 
      </div> 

      <ul className="nav navbar-nav"> 
        <li><Link to="/">Events</Link></li> 
      </ul> 
    </div> 
  </nav> 
} 

请注意我们的Navigation组件如何使用Link组件来创建到其他 React 路由的链接,鉴于我们现在只有/路由,这并不复杂。

要实际使用我们的新导航组件,请将其添加到根组件的render方法中:

// ... 
import {Navigation} from "./components/navigation"; 

class App extends React.Component<{}, {}> { 
  render() { 
    const eventList = () => <EventListContainer eventServiceURL="http://localhost:8181"/> 

    return <Router> 
      <Navigation brandName="MyEvents"/> 
      <div className="container"> 
        <h1>My Events</h1> 

        <Route exact path="/" component={eventList}/> 
      </div> 
    </Router> 
  } 
} 

实现预订流程

现在我们已经有了一个工作的路由和导航,我们可以实现下一个功能——预订流程。为了本书的目的,我们将保持预订流程简单。我们之前实现的事件列表中的每一行都应该有一个按钮,可以将用户带到预订表单。在这个表单中,他们将被要求输入他们想要预订的票数,然后可以提交表单。提交后,前端应用将执行一个 HTTP 请求到预订服务。

当然,我们将把预订表单实现为一个 React 组件。与之前一样,我们将分开职责并构建单独的组件来处理后端通信和前端呈现。EventBookingFormContainer将负责从事件服务加载事件记录,并将实际预订保存到预订服务。然后EventBookingForm将负责表单的实际前端呈现。为了使表单呈现更容易,我们还将引入一个FormRow组件。以下图表概述了这些组件及它们之间的关系:

组件之间的关系

FormRow组件将是一个纯粹的呈现组件,以便更容易使用 Bootstrap 框架的表单 CSS 类。与之前一样,我们将自下而上实现这些组件,从最内部的组件开始。为此,创建src/components/form_row.tsx文件:

import * as React from "react"; 

export interface FormRowProps { 
  label?: string; 
} 

export class FormRow extends React.Component<FormRowProps, {}> { 
  render() { 
    return <div className="form-group"> 
      <label className="col-sm-2 control-label"> 
        {this.props.label} 
      </label> 
      <div className="col-sm-10"> 
        {this.props.children} 
      </div> 
    </div> 
  } 
} 

在这种情况下,我们使用了特殊的 propchildren。虽然我们没有在FormRowProps接口中明确定义这个 prop,但我们可以在任何 React 组件中使用childrenprop。它将包含传递给当前组件的任何 DOM 元素的子元素。这将允许您像下面这样使用FormRow组件:

<FormRow label="Some input field"> 
  <input className="form-control" placeholder="Some value..."/> 
</FormRow> 

接下来,我们可以使用FormRow组件来构建EventBookingForm组件。为此,创建一个名为src/components/event_booking_form.tsx的新文件:

import * as React from "react"; 
import {Event} from "../model/event"; 
import {FormRow} from "./form_row"; 

export interface EventBookingFormProps { 
  event: Event; 
  onSubmit: (seats: number) => any 
} 

export interface EventBookingFormState { 
  seats: number; 
} 

export class EventBookingForm
  extends React.Component<EventBookingFormProps, EventBookingFormState> { 
  constructor(p:  EventBookingFormProps) { 
    super(p); 

    this.state = {seats: 1}; 
  } 
} 

EventBookingForm组件既有输入 props,也有内部状态。输入属性包含实际的事件,为其应该呈现预订表单,并且一个回调方法。我们稍后将配置预订表单,在表单提交时调用这个回调方法。表单的内部状态包含应该预订的门票数量的变量。

现在,为EventBookingForm组件添加一个render()方法:

render() { 
  return <div> 
    <h2>Book tickets for {this.props.event.name}</h2> 
    <form className="form-horizontal"> 
      <FormRow label="Event"> 
        <p className="form-control-static"> 
          {this.props.event.name} 
        </p> 
      </FormRow> 
      <FormRow label="Number of tickets"> 
        <select className="form-control" value={this.state.seats} 
onChange={event => this.handleNewAmount(event)}> 
          <option value="1">1</option> 
          <option value="2">2</option> 
          <option value="3">3</option> 
          <option value="4">4</option> 
        </select> 
      </FormRow> 
      <FormRow> 
        <button className="btn btn-primary" 
onClick={() => this.props.onSubmit(this.state.seats)}> 
          Submit order 
        </button> 
      </FormRow> 
    </form> 
  </div> 
} 

这将生成一个小表单,用户将能够查看他们预订门票的事件,选择所需数量的门票,然后提交订单。请注意,onSubmit属性在按钮的onClick事件上被调用。

另外,请注意选择字段的onChange事件调用了一个this.handleNewAmount方法,我们还没有定义。现在让我们来做这个:

import * as React from "react"; 
import {ChangeEvent} from "react"; 
// ... 

export class EventBookingForm extends React.Component<EventBookingFormProps, EventBookingFormState> { 
  // ... 

  private handleNewAmount(event: ChangeEvent<HTMLSelectElement>) { 
    const state: EventBookingFormState = { 
      seats: parseInt(event.target.value) 
    } 

    this.setState(state); 
  } 
} 

最后但并非最不重要的是,我们现在可以实现EventBookingFormContainer组件。这个组件将负责处理与相应后端服务的 AJAX 通信(因为我们正在处理事件预订,我们还必须与我们在第四章中构建的预订服务进行通信,使用消息队列的异步微服务架构)。

让我们首先定义组件的 props 和 state。为此,创建一个新的src/components/event_booking_form_container.tsx文件:

import * as React from "react"; 
import {EventBookingForm} from "./event_booking_form"; 
import {Event} from "../model/event"; 
export class EventBookingFormContainerProps { 
  eventID: string; 
  eventServiceURL: string; 
  bookingServiceURL: string; 
} 
export class EventBookingFormContainerState { 
  state: "loading"|"ready"|"saving"|"done"|"error"; 
  event?: Event; 
} 

EventBookingFormContainer将需要对事件服务和预订服务进行 AJAX 调用。当创建这个组件的新实例时,它将通过其属性传递一个事件 ID,然后使用该 ID 从事件服务加载相应的事件数据到组件的状态中。

加载事件数据是我们可以在接下来定义的组件构造函数中做的事情:

export class EventBookingFormContainer
  extends React.Component<EventBookingFormContainerProps,  EventBookingFormContainerState> { 
  constructor(p: EventBookingFormContainerProps) { 
    super(p); 

    this.state = {state: "loading"}; 

    fetch(p.eventServiceURL + "/events/" + p.eventID) 
      .then<Event>(response => response.json()) 
      .then(event => { 
        this.setState({ 
          state: "ready", 
          event: event 
        }) 
      }); 
  } 
} 

现在,我们可以为这个组件添加一个render方法,一旦事件被加载,就会呈现实际的预订表单:

render() { 
  if (this.state.state === "loading") { 
    return <div>Loading...</div>; 
  } 

  if (this.state.state === "saving") { 
    return <div>Saving...</div>; 
  } 

  if (this.state.state === "done") { 
    return <div className="alert alert-success"> 
      Booking completed! Thank you! 
    </div> 
  } 

  if (this.state.state === "error" || !this.state.event) { 
    return <div className="alert alert-danger"> 
      Unknown error! 
    </div> 
  } 

  return <EventBookingForm event={this.state.event} 
onSubmit={seats => this.handleSubmit(seats)} /> 
} 

这个render()方法基本上涵盖了组件状态的所有可能变体,然后打印相应的状态消息。当事件成功加载时,实际的EventBookingForm就会被呈现出来。

最后,我们需要实现handleSubmit方法:

private handleSubmit(seats: number) { 
  const url = this.props.bookingServiceURL + "/events/" + this.eventID + "/bookings"; 
  const payload = {seats: seats}; 

  this.setState({ 
    event: this.state.event, 
    state: "saving" 
  }); 

  fetch(url, {method: "POST", body: JSON.stringify(payload)}) 
    .then(response => { 
      this.setState({ 
        event: this.state.event, 
        state: response.ok ? "done" : "error" 
      }); 
    }) 
} 

这结束了我们对预订表单的工作。到目前为止,我们只错过了一件小事——还没有办法访问这个表单。让我们现在修正这个疏忽。

首先,在index.tsx文件中添加一个新的路由,更确切地说,在App组件的render方法中:

render() { 
  const eventList = () => <EventListContainer eventServiceURL="http://localhost:8181" />; 
  const eventBooking = ({match}: any) => 
    <EventBookingFormContainer eventID={match.params.id} 
      eventServiceURL="http://localhost8181" 
      bookingServiceURL="http://localhost:8282" />; 

  return <Router> 
    <div className="container"> 
      <h1>My Events</h1> 

      <Route exact path="/" component={eventList} /> 
      <Route path="/events/:id/book" component={eventBooking} /> 
    </div> 
  </Router> 
} 

在这个代码示例中,你可以看到多个内容。首先,我们声明了一个新的本地组件eventBooking,它基本上返回一个带有一些默认参数的EventBookingFormContainer组件。这个组件将被传递一个带有match属性的 prop 对象(参数声明中的花括号是所谓的解构赋值)。这个 match 对象包含了前面示例中声明的/events/:id/book路由的路由参数。这允许我们将事件 ID 作为路由参数包含进去(例如,localhost:8080/#/events/58d543209cdd4128c06e59db/book)。

此外,为了使这段代码工作,我们假设您已经从第四章中获得了一个运行并监听在本地 TCP 端口8282上的预订服务的实例,使用消息队列的异步微服务架构

最后,我们需要添加一个按钮,允许用户实际到达这个路由。为此,我们将修改本章前面部分中创建的src/component/event_list_item.tsx文件中的EventListItem组件。我们将使用您之前使用过的react-router-dom包中的Link组件:

import {Link} from "react-router-dom"; 
// ... 

export class EventListItem extends React.Component<EventListItemProps, {}> { 
  render() { 
    const start = new Date(this.props.event.StartDate * 1000); 
    const end = new Date(this.props.event.EndDate * 1000); 

    return <tr> 
      <td>{this.props.event.Name}</td> 
      <td>{this.props.event.Location.Name}</td> 
      <td>{start.toLocaleDateString()}</td> 
      <td>{end.toLocaleDateString()}</td> 
      <td> 
        <Link to={`/events/${this.props.event.ID}/book`}> 
          Book now! 
        </Link> 
      </td> 
    </tr> 
  } 
} 

在您的前端应用程序中,您现在将看到一个名为“立即预订!”的额外按钮:

立即预订!按钮

活动列表中的EventistItem组件现在包含每个活动预订表单的链接。点击其中一个按钮后,应用程序将链接到相应活动的实际预订表单:

活动预订表单的实际操作

请注意包含活动 ID 的 URL。由于我们已经构建了EventBookingFormContainer,它在构建时从活动服务加载活动数据,现在甚至可以使用这个 URL 直接在浏览器中打开。React 路由将立即打开预订表单,然后从活动服务加载活动数据。这使您可以直接在 React 应用程序中打开子路由,甚至共享或收藏这些 URL。

摘要

在本章中,我们为您展示了使用 React 进行前端开发的一瞥。当然,我们只是触及了 React 框架可能性的冰山一角。在实际的现实世界应用中,我们仍然需要为前端应用程序添加相当多的功能,才能真正完成(例如,我们需要添加一些琐碎的东西,比如用户注册和更复杂的结账流程)。

到目前为止,我们大部分时间都在做实际的编程,既在后端使用 Go 语言,又在前端使用 TypeScript。然而,软件开发不仅仅是编程。在接下来的几章中,我们将关注应用程序的部署。这将包括后端服务(例如在之前章节中构建的活动和预订服务),还有持久性和消息服务(例如数据库或消息队列)。为此,我们将研究现代容器技术以及如何将其部署到云端。敬请关注。

第六章:在容器中部署您的应用程序

在过去的几章中,我们专注于我们的 Go 应用程序的实际开发。然而,软件工程不仅仅是编写代码。通常情况下,您还需要关注如何将应用程序部署到其运行时环境中的问题。特别是在微服务架构中,每个服务可能构建在完全不同的技术堆栈上,部署很快就会变成一个挑战。

当您部署使用不同技术的服务(例如,当您有使用 Go、Node.js 和 Java 编写的服务时),您需要提供一个环境,其中所有这些服务实际上可以运行。使用传统虚拟机或裸机服务器,这可能会变得非常麻烦。即使现代云提供商可以快速生成和处理虚拟机,维护所有可能类型服务的基础设施也会成为一个运营挑战。

这就是现代容器技术(如DockerRKT)的亮点所在。使用容器,您可以将应用程序及其所有依赖项打包到容器映像中,然后使用该映像快速生成在任何可以运行这些容器的服务器上运行您的应用程序的容器。需要在服务器上运行的唯一软件(无论是虚拟化还是裸机)是容器运行时环境(通常是 Docker 或 RKT)。

在本章中,我们将向您展示如何将我们在过去几章中构建的 MyEvents 应用程序打包成容器映像,并如何部署这些映像。由于我们的设想很大,我们还将研究诸如Kubernetes之类的集群管理器,它允许您一次在许多服务器上部署容器,从而使您的应用程序部署更具弹性和可伸缩性。

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

  • 使用 Docker 构建和运行容器映像

  • 使用 Docker Compose 设置复杂的多容器应用程序

  • 使用 Kubernetes 的容器云基础设施

什么是容器?

诸如 Docker 之类的容器技术使用现代操作系统提供的隔离功能,例如 Linux 中的命名空间控制组cgroups)。使用这些功能允许操作系统在很大程度上隔离多个运行中的进程。例如,容器运行时可能会使用两个完全独立的文件系统命名空间或使用网络命名空间来提供两个完全独立的网络堆栈。除了命名空间,cgroups 还可以用于确保每个进程不会使用超过先前分配的资源量(例如 CPU 时间、内存或 I/O 和网络带宽)。

与传统虚拟机相比,容器完全在主机环境的操作系统中运行;没有虚拟化的硬件和操作系统在其中运行。此外,在许多容器运行时中,您甚至没有在常规操作系统中找到的所有典型进程。例如,Docker 容器通常不会像常规 Linux 系统那样具有 init 进程;相反,容器中的根进程(PID 1)将是您的应用程序(此外,由于容器只存在于其PID 1进程存在的时间,一旦您的应用程序存在,它就会停止存在)。

当然,这并不适用于所有容器运行时。例如,LXC 将在容器中为您提供完整的 Linux 系统(至少是用户空间的部分),包括 PID 1 作为 init 进程。

大多数容器运行时还具有容器镜像的概念。这些镜像包含预打包的文件系统,可以从中生成新的容器。许多基于容器的部署实际上使用容器镜像作为部署工件,其中实际的构建工件(例如,编译的 Go 二进制文件、Java 应用程序或 Node.js 应用程序)与其运行时依赖项一起打包(对于编译的 Go 二进制文件来说,依赖项并不多;但是对于其他应用程序来说,容器镜像可能包含 Java 运行时、Node.js 安装或应用程序工作所需的其他任何内容)。为您的应用程序拥有一个容器镜像也可以帮助使您的应用程序具有可伸缩性和弹性,因为很容易从您的应用程序镜像中生成新的容器。

诸如 Docker 之类的容器运行时还倾向于将容器视为不可变(意味着容器通常在启动后不会以任何方式更改)。在容器中部署应用程序时,部署新版本应用程序的典型方式是构建一个新的容器镜像(其中包含更新版本的应用程序),然后从该新镜像创建一个新的容器,并删除运行旧版本应用程序的容器。

Docker 简介

目前,应用程序容器运行时的事实标准是Docker,尽管还有其他运行时,例如 RKT(发音为 rocket)。在本章中,我们将专注于 Docker。但是,许多容器运行时是可互操作的,并建立在共同的标准上。例如,RKT 容器可以轻松地从 Docker 镜像生成。这意味着即使您决定使用 Docker 镜像部署应用程序,也不会陷入供应商锁定。

运行简单的容器

我们之前在第四章中使用 Docker 快速设置了 RabbitMQ 和 Kafka 消息代理,但是我们没有详细介绍 Docker 的工作原理。我们假设您已经在本地机器上安装了可用的 Docker。如果没有,请查看官方安装说明,了解如何在您的操作系统上安装 Docker:docs.docker.com/engine/installation/

要测试您的 Docker 安装是否正常工作,请在命令行上尝试以下命令:

$ docker container run --rm hello-world 

上述命令使用了 Docker 1.13 中引入的新 Docker 命令结构。如果您使用的是较旧版本的 Docker,请使用docker run而不是docker container run。您可以使用docker version命令测试当前的 Docker 版本。此外,请注意,Docker 在 1.13 版本之后改变了其版本方案,因此 1.13 之后的下一个版本将是 17.03。

Docker run 命令遵循docker container run [flags...] [image name] [arguments...]模式。在这种情况下,hello-world是要运行的镜像的名称,--rm标志表示容器在完成运行后应立即被删除。运行上述命令时,您应该会收到类似以下截图中的输出:

docker container run 输出

实际上,docker run命令在这里做了多件事情。首先,它检测到hello-world镜像在本地机器上不存在,并从官方 Docker 镜像注册表下载了它(如果再次运行相同的命令,您会注意到镜像不会被下载,因为它已经存在于本地机器上)。

然后它从刚刚下载的hello-world镜像创建了一个新的容器,并启动了该容器。容器镜像只包含一个小程序,该程序将一些文本打印到命令行,然后立即退出。

记住,Docker 容器没有 init 系统,通常只有一个在其中运行的进程。一旦该进程终止,容器将停止运行。由于我们使用--rm标志创建了容器,Docker 引擎在容器停止运行后也会自动删除容器。

接下来,让我们做一些更复杂的事情。执行以下命令:

$ docker container run -d --name webserver -p 80:80 nginx 

这个命令将下载nginx镜像并从中生成一个新的容器。与hello-world镜像相比,这个镜像将运行一个无限时间的 Web 服务器。为了不让你的 shell 无限期地阻塞,使用-d标志(代表--detach)在后台启动新的容器。--name标志负责为新容器指定一个实际的名称(如果省略,容器将生成一个随机名称)。

默认情况下,在容器中运行的NGINX Web 服务器监听 TCP 端口 80。然而,每个 Docker 容器都有自己独立的网络堆栈,所以你不能通过导航到http://localhost来访问这个端口。-p 80:80标志告诉 Docker 引擎将容器的 TCP 端口 80 转发到 localhost 的端口 80。要检查容器是否实际在运行,运行以下命令:

$ docker container ls 

前面的命令列出了所有当前运行的容器,它们所创建的镜像,以及它们的端口映射。你应该会收到类似以下截图的输出:

docker 容器 ls 输出

当容器运行时,你现在可以通过http://localhost访问你刚刚启动的 Web 服务器。

构建你自己的镜像

到目前为止,你已经使用了来自 Docker Hub 的公开可用的预制镜像,比如nginx镜像(或者第四章中的 RabbitMQ 和 Spotify/Kafka 镜像,使用消息队列的异步微服务架构)。然而,使用 Docker,构建自己的镜像也很容易。通常,Docker 镜像是从Dockerfile构建的。Dockerfile 是一种新 Docker 镜像的构建手册,描述了应该如何从给定的基础镜像构建 Docker 镜像。由于从完全空的文件系统开始(即没有 shell 或标准库)很少有意义,因此镜像通常是在包含流行 Linux 发行版用户空间工具的发行版镜像上构建的。流行的基础镜像包括 Ubuntu、Debian 或 CentOS。

让我们构建一个简短的Dockerfile示例。为了演示目的,我们将构建自己版本的hello-world镜像。为此,创建一个新的空目录,并创建一个名为Dockerfile的新文件,内容如下:

FROM debian:jessie 
MAINTAINER You <you@example.com> 

RUN echo 'Hello World' > /hello.txt 
CMD cat /hello.txt 

FROM开头的行表示你正在构建自定义镜像的基础镜像。它总是需要作为Dockerfile的第一行。MAINTAINER语句只包含元数据。

RUN语句在构建容器镜像时执行(这意味着最终容器镜像将在其文件系统中有一个/hello.txt文件,内容为Hello World)。一个Dockerfile可能包含许多这样的RUN语句。

相比之下,CMD语句在从镜像创建的容器运行时执行。这里指定的命令将是从镜像创建的容器的第一个和主要进程(PID 1)。

你可以使用docker image build命令(在 1.13 版本之前的版本中使用docker build)来构建实际的 Docker 镜像,如下所示:

$ docker image build -t test-image .

docker 镜像构建输出

-t test-image标志包含你的新镜像应该得到的名称。构建完镜像后,你可以使用docker image ls命令找到它:

docker 镜像 ls 输出

使用-t指定的名称允许您使用已知的docker container run命令从前面的镜像创建和运行新的容器:

$ docker container run --rm test-image

与以前一样,这个命令将创建一个新的容器(这次是从我们新创建的镜像),启动它(实际上是启动DockerfileCMD语句指定的命令),然后在命令完成后删除容器(感谢--rm标志)。

网络容器

通常,您的应用程序由多个相互通信的进程组成(从相对简单的情况,如应用服务器与数据库通信,到复杂的微服务架构)。当使用容器管理所有这些进程时,通常每个进程都有一个容器。在本节中,我们将看看如何让多个 Docker 容器通过它们的网络接口相互通信。

为了实现容器之间的通信,Docker 提供了网络管理功能。命令行允许您创建新的虚拟网络,然后将容器添加到这些虚拟网络中。同一网络中的容器可以相互通信,并通过 Docker 内置的 DNS 服务器解析它们的内部 IP 地址。

让我们通过使用docker network create命令在 Docker 中创建一个新网络来测试一下:

$ docker network create test

之后,您将能够通过运行docker network ls来看到新的网络:

docker 网络 ls 输出

创建了一个新网络后,您可以将容器连接到这个网络。首先,从nginx镜像创建一个新容器,并使用--network标志将其附加到测试网络:

$ docker container run -d --network=test --name=web nginx 

接下来,在相同的网络中创建一个新的容器。由于我们已经启动了一个 web 服务器,我们的新容器将包含一个 HTTP 客户端,我们将使用它来连接到我们的新 web 服务器(请注意,我们没有像之前那样使用-p标志将容器的 HTTP 端口绑定到本地主机)。为此,我们将使用适当的/curl 镜像。这是一个基本上包含了 cURL 命令行实用程序的容器化版本的镜像。由于我们的 web 服务器容器的名称为 web,我们现在可以简单地使用该名称来建立网络连接:

$ docker container run --rm --network=test appropriate/curl http://web/

这个命令将简单地将 web 服务器的索引页面打印到命令行:

docker 容器运行输出

这表明从适当的/curl 镜像创建的 cURL 容器能够通过 HTTP 访问 web 容器。在建立连接时,您可以简单地使用容器的名称(在本例中为web)。Docker 将自动将此名称解析为容器的 IP 地址。

掌握了 Docker 镜像和网络的知识,现在可以将 MyEvents 应用程序打包成容器镜像并在 Docker 上运行。

使用卷

单个 Docker 容器通常存活时间很短。部署应用程序的新版本可能会导致删除多个容器并生成新的容器。如果您的应用程序在云环境中运行(我们将在本章后面看到基于云的容器环境),您的容器可能会受到节点故障的影响,并将被重新调度到另一个云实例上。对于无状态应用程序(在我们的示例中,事件服务和预订服务),这是完全可以接受的。

然而,对于有状态的容器(在我们的示例中,这将是消息代理和数据库容器),这变得困难。毕竟,如果您删除一个 MongoDB 容器,并创建一个具有类似配置的新容器,那么数据库管理的实际数据将会丢失。这就是发挥作用的地方。

卷是 Docker 使数据持久化超出单个容器生命周期的方式。它们包含文件,独立于单个容器存在。每个卷可以被挂载到任意数量的容器中,允许您在容器之间共享文件。

为了测试这一点,使用docker volume create命令创建一个新卷:

$ docker volume create test 

这将创建一个名为test的新卷。您可以使用docker volume ls命令再次找到这个卷:

$ docker volume ls 

创建了一个卷后,您可以使用docker container run命令的-v标志将其挂载到容器中:

$ docker container run --rm -v test:/my-volume debian:jessie 
/bin/bash -c "echo Hello > /my-volume/test.txt" 

这个命令创建一个新的容器,将测试卷挂载到/my-volume目录中。容器的命令将是一个创建test.txt文件的 bash shell。之后,容器将终止并被删除。

为了确保卷内的文件仍然存在,现在可以将此卷挂载到第二个容器中:

$ docker container run -rm -v test:/my-volume debian:jessie 
cat /my-volume/test.txt

该容器将test.txt文件的内容打印到命令行。这表明测试卷仍然包含所有数据,即使最初填充数据的容器已经被删除。

构建容器

我们将从构建 MyEvents 应用程序的组件容器镜像开始。目前,我们的应用程序由三个组件组成——两个后端服务(事件和预订服务)和 React 前端应用程序。虽然前端应用程序本身不包含任何后端逻辑,但我们至少需要一个 Web 服务器将此应用程序提供给用户。这总共需要构建三个容器镜像。让我们从后端组件开始。

为后端服务构建容器

事件和预订服务都是编译为单个可执行二进制文件的 Go 应用程序。因此,在 Docker 镜像中不需要包含任何源文件甚至 Go 工具链。

在这一点上需要注意的是,您将需要为接下来的步骤编译 Go 应用程序的 Linux 二进制文件。在 macOS 或 Windows 上,调用go build时需要设置GOOS环境变量:

$ GOOS=linux go build 

在 macOS 和 Linux 上,您可以使用file命令检查正确的二进制类型。对于 Linux 的ELF二进制文件,file命令应该打印类似以下的输出:

$ file eventservice 
eventservice: ELF 64-bit executable, x86-64, version 1 (SYSV),  
statically linked, not stripped 

首先编译 Linux 二进制文件,分别用于事件服务和预订服务。

当您编译了这两个服务后,继续为事件服务定义 Docker 镜像构建过程。为此,在事件服务的根目录中创建一个名为Dockerfile的新文件:

FROM debian:jessie 

COPY eventservice /eventservice 
RUN  useradd eventservice 
USER eventservice 

ENV LISTEN_URL=0.0.0.0:8181 
EXPOSE 8181 
CMD ["/eventservice"] 

这个 Dockerfile 包含了一些我们之前没有涉及的新语句。COPY语句将文件从主机的本地文件系统复制到容器镜像中。这意味着我们假设您在开始 Docker 构建之前已经使用go build构建了 Go 应用程序。USER命令导致所有后续的RUN语句和CMD语句以该用户身份运行(而不是 root)。ENV命令设置一个环境变量,将对应用程序可用。最后,EXPOSE语句声明从该镜像创建的容器将需要 TCP 端口8181

继续使用docker image build命令构建容器镜像:

$ docker image build -t myevents/eventservice .

接下来,向bookingservice添加一个类似的 Docker 文件:

FROM debian:jessie 

COPY bookingservice /bookingservice 
RUN  useradd bookingservice 
USER bookingservice 

ENV LISTEN_URL=0.0.0.0:8181 
EXPOSE 8181 
CMD ["/bookingservice"] 

再次使用docker image build命令构建镜像:

$ docker image build -t myevents/bookingservice .

为了测试我们的新镜像,现在可以生成相应的容器。但是,在启动实际应用程序容器之前,我们需要为这些容器和所需的持久性服务创建一个虚拟网络。事件和预订服务分别需要一个 MongoDB 实例和一个共享的 AMQP(或 Kafka)消息代理。

让我们从创建容器网络开始:

$ docker network create myevents

接下来,将 RabbitMQ 容器添加到您的网络中:

$ docker container run -d --name rabbitmq --network myevents 
rabbitmq:3-management

继续添加两个 MongoDB 容器:

$ docker container run -d --name events-db --network myevents mongo 
$ docker container run -d --name bookings-db --network myevents mongo 

最后,您可以启动实际的应用程序容器:

$ docker container run \ 
    --detach \ 
    --name events \ 
    --network myevents \ 
    -e AMQP_BROKER_URL=amqp://guest:guest@rabbitmq:5672/ \ 
    -e MONGO_URL=mongodb://events-db/events \ 
    -p 8181:8181 \ 
    myevents/eventservice 
$ docker container run \ 
    --detach \ 
    --name bookings \ 
    --network myevents \ 
    -e AMQP_BROKER_URL=amqp://guest:guest@rabbitmq:5672/ \ 
    -e MONGO_URL=mongodb://bookings-db/bookings \ 
    -p 8282:8181 \
    myevents/bookingservice 

注意端口映射。目前,两个服务都在 TCP 端口8181上监听其 REST API。只要这两个 API 在不同的容器中运行,这是完全有效的。但是,当将这些端口映射到主机端口(例如,用于测试目的)时,我们将会有一个端口冲突,我们通过将预订服务的端口8181映射到8282来解决这个问题。

另外,注意如何使用-e标志将环境变量传递到运行的容器中。例如,使用MONGO_URL环境变量,可以轻松地将两个应用程序容器连接到不同的数据库。

在启动了所有这些容器之后,您将能够从本地机器通过http://localhost:8181访问事件服务,通过http://localhost:8282访问预订服务。以下docker container ls命令现在应该显示您有五个正在运行的容器:

docker container ls 输出

使用静态编译以获得更小的镜像

目前,我们正在构建我们的应用程序镜像,这些镜像是基于debian:jessie镜像的。这个镜像包含了典型的 Debian 安装的用户空间工具和库,大小约为 123MB(您可以使用docker image ls命令找到这一点)。再加上另外 10MB 用于编译的 Go 应用程序,每个生成的镜像大小约为 133MB(这并不意味着我们的事件服务和预订服务的两个镜像将共同占用 266MB 的磁盘空间。它们都是基于相同的基础镜像构建的,Docker 非常有效地优化了容器镜像的磁盘空间使用)。

然而,我们的应用程序并没有使用大部分这些工具和库,所以我们的容器镜像可以更小。通过这样做,我们可以优化本地磁盘空间的使用(尽管 Docker 引擎已经相当有效),优化从镜像仓库下载镜像时的传输时间,并减少对恶意用户的攻击面。

通常,编译的 Go 二进制文件几乎没有依赖性。您不需要任何运行时库或虚拟机,您在项目中使用的所有 Go 库都直接嵌入到生成的可执行文件中。但是,如果您在 Linux 上编译应用程序,Go 编译器将会将生成的二进制文件链接到一些 C 标准库,这些库通常在任何 Linux 系统上都可用。如果您在 Linux 上,可以通过使用ldd二进制文件并将其中一个已编译的 Go 二进制文件作为参数来轻松找出您的程序链接到了哪些库。如果您的二进制文件链接到 C 标准库,您将收到以下输出:

$ ldd ./eventservice 
    linux-vdso.so.1 (0x00007ffed09b1000) 
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fd523c36000) 
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd52388b000) 
    /lib64/ld-linux-x86-64.so.2 (0x0000564d70338000)  

这意味着您的 Go 应用程序实际上需要这些 Linux 库来运行,您不能随意从镜像中删除它们以使其更小。

如果您在 Windows 或 macOS 上使用GOOS=linux环境变量交叉编译应用程序,您可能不会遇到这个问题。因为这些系统上的编译器无法访问 Linux 标准 C 库,它将默认产生一个没有任何依赖的静态链接二进制文件。当使用这样的二进制文件调用ldd时,将呈现以下输出:

$ ldd ./eventservice 
    not a dynamic executable 

在 Linux 上,您可以通过为 Go 构建命令设置CGO_ENABLED=0环境变量来强制 Go 编译器创建静态链接的二进制文件:

$ CGO_ENABLED=0 go build 
$ ldd ./eventservice 
    not a dynamic executable 

完全静态链接的二进制文件使您可以创建一个更小的容器镜像。现在,您可以使用scratch镜像而不是在debian:jessie上构建基础镜像。scratch镜像是一个特殊的镜像。它直接内置在 Docker Engine 中,您无法从 Docker Hub 下载它。scratch镜像的特殊之处在于它完全为空,没有一个文件——这意味着没有标准库,没有系统实用程序,甚至没有一个 shell。尽管这些特性通常使得 scratch 镜像难以使用,但它非常适合为静态链接应用程序构建最小的容器镜像。

按照以下方式更改事件服务的Dockerfile

FROM scratch 

COPY eventservice /eventservice 

ENV LISTEN_URL=0.0.0.0:8181 
EXPOSE 8181 
CMD ["/eventservice"] 

接下来,以类似的方式更改预订服务的Dockerfile。然后,使用前面的代码中的docker image build命令再次构建这两个容器镜像。之后,使用docker image ls命令验证您的镜像大小:

docker image ls 输出

为前端构建容器

现在我们已经为后端应用程序构建了容器镜像,我们可以将注意力转向前端应用程序。由于该应用程序在用户的浏览器中运行,我们实际上不需要为其提供容器化的运行时环境。但我们确实需要一种向用户交付应用程序的方式。由于整个应用程序由一些 HTML 和 JavaScript 文件组成,我们可以构建一个包含简单 NGINX Web 服务器的容器镜像,用于向用户提供这些文件。

为此,我们将在nginx:1.11-alpine镜像上构建。该镜像包含了一个基于 Alpine Linux 构建的最小版本的 NGINX Web 服务器。Alpine 是一个针对小尺寸进行优化的 Linux 发行版。整个nginx:1.11-alpine镜像的大小仅为 50MB。

将以下Dockerfile添加到前端应用程序目录中:

FROM nginx:1.11-alpine 

COPY index.html /usr/share/nginx/html/ 
COPY dist /usr/share/nginx/html/dist/ 
COPY node_modules/bootstrap/dist/css/bootstrap.min.css /usr/share/nginx/html/node_modules/bootstrap/dist/css/bootstrap.min.css 
COPY node_modules/react/umd/react.production.min.js /usr/share/nginx/html/node_modules/react/umd/react.production.min.js
COPY node_modules/react-dom/umd/react-dom.production.min.js /usr/share/nginx/html/node_modules/react-dom/umd/react-dom.production.min.js
COPY node_modules/promise-polyfill/promise.min.js /usr/share/nginx/html/node_modules/promise-polyfill/promise.min.js 
COPY node_modules/whatwg-fetch/fetch.js /usr/share/nginx/html/node_modules/whatwg-fetch/fetch.js 

显然,我们的 Web 服务器需要为用户提供index.htmldist/bundle.js中编译的 Webpack 捆绑文件的服务,因此这些文件将使用COPY复制到容器镜像中。然而,从node_modules/目录中安装的所有依赖项中,我们的用户只需要一个非常特定的子集。因此,我们将这五个文件明确地复制到容器镜像中,而不是仅使用COPY复制整个node_modules/目录。

在实际构建容器镜像之前,请确保您的应用程序有最新的 Webpack 构建和所有安装的依赖项。您还可以使用-p标志来触发 Webpack 创建一个针对大小进行优化的生产构建:

$ webpack -p 
$ npm install 

之后,构建您的容器:

$ docker container build -t myevents/frontend . 

现在,您可以使用以下命令启动此容器:

$ docker container run --name frontend -p 80:80 myevents/frontend

请注意,在这种情况下,我们没有传递--network=myevents标志。这是因为前端容器实际上不需要直接与后端服务通信。所有通信都是由用户的浏览器发起的,而不是从实际的前端容器内部发起的。

-p 80:80标志将容器的 TCP 端口 80 绑定到本地 TCP 端口 80。这样,您现在可以在浏览器中打开http://localhost并查看 MyEvents 前端应用程序。如果您仍然在运行前几节的后端容器,则应用程序应该可以直接使用。

使用 Docker Compose 部署应用程序

到目前为止,从现有的容器镜像部署 MyEvents 应用程序实际上涉及了许多docker container run命令。尽管这对于测试来说效果还不错,但一旦您的应用程序在生产环境中运行时,特别是当您想要部署更新或扩展应用程序时,这将变得很繁琐。

这个问题的一个可能解决方案是Docker Compose。Compose 是一个工具,允许您以声明方式描述由多个容器组成的应用程序(在本例中,是一个描述构建应用程序的组件的 YAML 文件)。

Docker Compose 是常规 Docker 安装包的一部分,因此如果你在本地机器上安装了 Docker,你也应该有 Docker Compose 可用。你可以通过在命令行上调用以下命令来轻松测试:

$ docker-compose -v 

如果你的本地机器上没有 Compose,请查阅docs.docker.com/compose/install上的安装手册,详细描述如何设置 Compose。

每个 Compose 项目都由一个docker-compose.yml文件描述。Compose 文件将包含你的应用程序所需的所有容器、网络和卷的描述。Compose 将尝试通过创建、删除、启动或停止容器等方式,将 Compose 文件中表达的期望状态与本地 Docker 引擎的实际状态协调。

在你的项目目录的根目录创建一个包含以下内容的文件:

version: "3" 
networks: 
  myevents: 

在 Compose 文件中注意version: "3"的声明。Compose 支持多种声明格式,最近的版本是版本 3。在一些文档、示例或开源项目中,你很可能会遇到为旧版本编写的 Compose 文件。根本不声明版本的 Compose 文件被解释为版本 1 文件。

现在,上述 Compose 文件仅仅声明你的应用程序需要一个名为myevents的虚拟网络。然而,你可以使用 Compose 通过运行以下命令来协调所需的状态(必须存在一个名为myevents的网络):

$ docker-compose up 

现在,上述命令将打印一个警告消息,因为我们声明了一个没有被任何容器使用的容器网络。

容器在 Compose 文件中声明在services下。每个容器都有一个名称(在 YAML 结构中用作键)和各种属性(例如要使用的镜像)。让我们继续向 Compose 文件中添加一个新的容器:

version: "3" 
networks: 
  myevents:

services: 
  rabbitmq: 
    image: rabbitmq:3-management 
    ports: 
      - 15672:15672 
    networks: 
      - myevents 

这是你之前使用docker container run -d --network myevents -p 15672:15672 rabbitmq:3-management命令手动创建的 RabbitMQ 容器。

现在,你可以通过运行以下命令创建这个容器:

$ docker-compose up -d 

-d标志与 docker container run 命令具有相同的效果;它将导致容器在后台启动。

一旦 RabbitMQ 容器开始运行,你实际上可以随意多次调用docker-compose up。由于已经运行的 RabbitMQ 容器与 Compose 文件中的规范匹配,Compose 不会采取任何进一步的操作。

让我们继续向 Compose 文件中添加两个 MongoDB 容器:

version: "3" 
networks: 
  - myevents

services: 
  rabbitmq: #... 

  events-db: 
    image: mongo 
    networks: 
      - myevents 

  bookings-db: 
    image: mongo 
    networks: 
      - myevents 

再次运行docker-compose up -d。Compose 仍然不会触及 RabbitMQ 容器,因为它仍然符合规范。但是,它将创建两个新的 MongoDB 容器。

接下来,我们可以添加两个应用程序服务:

version: "3" 
networks: 
  - myevents

services: 
  rabbitmq: #... 
  events-db: #... 
  bookings-db: #... 
  events: 
    build: path/to/eventservice 
    ports: 
      - "8181:8181" 
    networks: 
      - myevents 
    environment: 
      - AMQP_BROKER_URL=amqp://guest:guest@rabbitmq:15672/ 
      - MONGO_URL=mongodb://events-db/events 
  bookings: 
    build: path/to/bookingservice 
    ports: 
      - "8282:8181" 
    networks: 
      - myevents 
    environment: 
      - AMQP_BROKER_URL=amqp://guest:guest@rabbitmq:15672/ 
      - MONGO_URL=mongodb://bookings-db/bookings 

注意,我们没有为这两个容器指定image属性,而是使用了build属性。这将导致 Compose 根据需要从各自目录中的 Dockerfile 实际构建这些容器的镜像。

重要的是要注意,Docker 构建不会编译你的 Go 二进制文件。相反,它将依赖于它们已经存在。在第九章 持续交付中,你将学习如何使用 CI 流水线来自动化这些构建步骤。

你也可以使用docker-compose命令单独触发这个流水线的各个步骤。例如,使用docker-compose pull从 Docker Hub 下载 Compose 文件中使用的所有镜像的最新版本:

$ docker-compose pull 

对于不使用预定义镜像的容器,使用docker-compose build重新构建所有镜像:

$ docker-compose build 

使用另一个docker-compose up -d创建新的容器。

确保已停止任何先前创建的可能绑定到 TCP 端口 8181 或 8282 的容器。使用docker container lsdocker container stop命令来定位并停止这些容器。

您还可以使用docker-compose ps命令来查看与当前 Compose 项目关联的当前正在运行的容器的概述:

docker-compose ps 输出

最后,将前端应用程序添加到 Compose 文件中:

version: "3" 
networks: 
  - myevents

services: 
  rabbitmq: #... 
  events-db: #... 
  bookings-db: #... 
  events: #... 
  bookings: #... 

  frontend: 
    build: path/to/frontend 
    ports: 
      - "80:80" 

正如您在本节中学到的,Docker Compose 使您能够以声明方式描述应用程序的架构,从而可以在支持 Docker 实例的任何服务器上轻松部署和更新应用程序。

到目前为止,我们一直在单个主机上工作(很可能是您的本地机器)。这对开发来说很好,但是对于生产设置,您需要考虑将应用程序部署到远程服务器上。此外,由于云架构都是关于规模的,接下来的几节中,我们还将看看如何在规模上管理容器化应用程序。

发布您的镜像

现在,您可以从应用程序组件构建容器镜像,并在本地机器上从这些镜像运行容器。但是,在生产环境中,构建容器镜像的机器很少是您将在其上运行它的机器。要实际能够将应用程序部署到任何云环境,您需要一种将构建的容器镜像分发到任意数量的主机的方法。

这就是容器注册表发挥作用的地方。实际上,在本章的早些时候,您已经使用过一个容器注册表,也就是 Docker Hub。每当您使用本地机器上不存在的 Docker 镜像(比如,例如nginx镜像),Docker 引擎将从 Docker Hub 将此镜像拉到您的本地机器上。但是,您也可以使用诸如 Docker Hub 之类的容器注册表来发布自己的容器镜像,然后从另一个实例中拉取它们。

在 Docker Hub(您可以通过浏览器访问hub.docker.com),您可以注册为用户,然后上传自己的镜像。为此,请在登录后单击创建存储库,然后选择一个新名称作为您的镜像。

要将新镜像推送到您新创建的存储库中,您首先需要在本地机器上使用您的 Docker Hub 帐户登录。使用以下docker login命令:

$ docker login 

现在,您将能够将镜像推送到新的存储库中。镜像名称将需要以您的 Docker Hub 用户名开头,后面跟着一个斜杠:

$ docker image build -t martinhelmich/test . 
$ docker image push martinhelmich/test 

默认情况下,推送到 Docker Hub 的镜像将是公开可见的。Docker Hub 还提供了推送私有镜像作为付费功能的可能性。只有在成功使用docker login命令进行身份验证后,才能拉取私有镜像。

当然,您不必使用 Docker Hub 来分发自己的镜像。还有其他提供者,比如 Quay(quay.io),所有主要的云提供商也提供了托管容器注册表的可能性。但是,当使用除 Docker Hub 之外的注册表时,一些前面的命令将略有变化。首先,您将不得不告诉docker login命令您将要登录的注册表:

$ docker login quay.io 

此外,您想要推送的容器镜像不仅需要以您的 Docker Hub 用户名开头,还需要以整个注册表主机名开头:

$ docker image build -t quay.io/martinhelmich/test .
$ docker image push quay.io/martinhelmich/test 

如果您不想将您的容器镜像委托给第三方提供者,您还可以部署自己的容器注册表。恰当地,有一个 Docker 镜像可以让您快速设置自己的注册表:

$ docker volume create registry-images 
$ docker container run \ 
    --detach \ 
    -p 5000:5000 \ 
    -v registry-images:/var/lib/registry \ 
    --name registry \ 
    registry:2.6.1 

这将设置一个可在http://localhost:5000访问的容器注册表。您可以像对待其他第三方注册表一样对待它:

$ docker image build -t localhost:5000/martinhelmich/test . 
$ docker image push localhost:5000/martinhelmich/test 

拥有一个在localhost:5000上监听的私有容器注册表对于开发来说是可以的,但是对于生产设置,您将需要额外的配置选项。例如,您将需要为您的注册表配置 TLS 传输加密(默认情况下,Docker 引擎将拒绝除 localhost 以外的任何非加密 Docker 注册表),您还需要设置身份验证(除非您明确打算运行一个公开访问的容器注册表)。查看注册表的官方部署指南,了解如何设置加密和身份验证:docs.docker.com/registry/deploying/

将您的应用程序部署到云端

在本章结束时,我们将看看如何将容器化的应用部署到云环境中。

容器引擎,比如 Docker,允许您在隔离的环境中提供多个服务,而无需为单独的服务提供独立的虚拟机。然而,与典型的云应用一样,我们的容器架构需要易于扩展,并且对故障有弹性。

这就是容器编排系统(如 Kubernetes)发挥作用的地方。这些系统允许您在整个主机集群上部署容器化的应用程序。它们允许轻松扩展,因为您可以轻松地向现有集群添加新主机(之后新的容器工作负载可能会自动安排在它们上面),并且使您的系统具有弹性;节点故障可以被快速检测到,这允许在其他地方启动那些节点上的容器以确保它们的可用性。

Kubernetes 简介

最著名的容器编排器之一是 Kubernetes(希腊语意为舵手)。Kubernetes 是由谷歌最初开发的开源产品,现在由云原生计算基金会拥有。

以下图表显示了 Kubernetes 集群的基本架构:

每个 Kubernetes 集群的中心组件是主服务器(当然,不一定是实际的单个服务器。在生产设置中,您通常会有多个配置为高可用性的主服务器)。主服务器将整个集群状态存储在端数据存储中。API 服务器是提供 REST API 的组件,可以被内部组件(如调度器、控制器或 Kubelet)和外部用户(您!)使用。调度器跟踪各个节点上的可用资源(如内存和 CPU 使用情况),并决定集群中新容器应该在哪个节点上安排。控制器是管理高级概念的组件,如复制控制器或自动缩放组。

Kubernetes 节点是由主服务器管理的实际应用程序容器启动的地方。每个节点运行一个 Docker 引擎和一个Kubelet。Kubelet 连接到主服务器的 REST API,并负责实际启动调度器为该节点安排的容器。

在 Kubernetes 中,容器被组织在 Pod 中。Pod 是 Kubernetes 的最小调度单元,由一个或多个 Docker 容器组成。Pod 中的所有容器都保证在同一主机上运行。每个 Pod 将接收一个唯一的可路由 IP 地址,该地址在整个集群内是唯一的(这意味着在一个主机上运行的 Pod 将能够通过它们的 IP 地址与在其他节点上运行的 Pod 进行通信)。

Kube Proxy 是确保用户实际可以访问您的应用程序的组件。在 Kubernetes 中,您可以定义将多个 Pod 分组的服务。Kube Proxy 为每个服务分配一个唯一的 IP 地址,并将网络流量转发到服务匹配的所有 Pod。这样,Kube Proxy 还实现了一个非常简单但有效的负载平衡,当多个应用程序实例在多个 Pod 中运行时。

您可能已经注意到 Kubernetes 的架构非常复杂。设置 Kubernetes 集群是一项具有挑战性的任务,在本书中我们不会详细介绍。对于本地开发和测试,我们将使用 Minikube 工具,在您的本地机器上自动创建一个虚拟化的 Kubernetes 环境。当您在公共云环境中运行应用程序时,您还可以使用工具为您自动设置一个生产就绪的 Kubernetes 环境。一些云提供商甚至为您提供托管的 Kubernetes 集群(例如,Google 容器引擎Azure 容器服务都是基于 Kubernetes 构建的)。

使用 Minikube 设置本地 Kubernetes

要开始使用 Minikube,您需要在本地机器上安装三个工具:Minikube 本身(将在您的机器上设置虚拟 Kubernetes 环境),VirtualBox(将用作虚拟化环境)和 kubectl(用于与 Kubernetes 一起工作的命令行客户端)。尽管在本示例中我们使用 Minikube,但我们在接下来的章节中展示的每个 kubectl 命令几乎都适用于几乎每个 Kubernetes 集群,无论它是如何设置的。

首先设置 VirtualBox。为此,请从官方下载页面www.virtualbox.org/wiki/Downloads下载安装程序,并按照您的操作系统的安装说明进行操作。

接下来,下载最新版本的 Minikube。您可以在github.com/kubernetes/minikube/releases找到所有版本(在撰写本文时,最新版本为 0.18.0)。同样,按照您的操作系统的安装说明进行操作。或者,使用以下命令快速下载并设置 Minikube(分别将linux替换为darwinwindows):

$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/v0.18.0/minikube-linux-amd64 && chmod +x minikube && sudo mv minikube /usr/local/bin/ 

最后,设置 kubectl。您可以在以下位置找到安装说明:kubernetes.io/docs/tasks/kubectl/install。或者,使用以下命令(根据需要将linux替换为darwinwindows):

curl -LO https://storage.googleapis.com/kubernetes-release/release/1.6.1/bin/linux/amd64/kubectl && chmod +x kubectl && sudo mv kubectl /usr/local/bin 

在设置好所有要求之后,您可以使用minikube start命令启动本地 Kubernetes 环境:

$ minikube start 

该命令将下载一个 ISO 镜像,然后从该镜像启动一个新的虚拟机,并安装各种 Kubernetes 组件。喝杯咖啡,如果这需要几分钟,不要感到惊讶:

minikube start 输出

minikube start命令还会创建一个配置文件,用于使您可以在没有任何进一步配置的情况下使用 kubectl 与 minikube VM。您可以在主目录下的~/.kube/config中找到此文件。

测试整个设置是否按预期工作,请运行kubectl get nodes命令。该命令将打印出 Kubernetes 集群中所有节点的列表。在 Minikube 设置中,您应该只能看到一个节点:

$ kubectl get nodes 

kubectl get nodes 输出

Kubernetes 的核心概念

在深入了解 MyEvents 之前,让我们更仔细地看一下一些 Kubernetes 的核心概念。我们将首先创建一个包含简单 NGINX Web 服务器的新 Pod。

Kubernetes 资源(如 Pod 和服务)通常在 YAML 文件中定义,这些文件以声明方式描述了您的集群的期望状态(类似于您之前使用的 Docker Compose 配置文件)。对于我们的新 NGINX Pod,创建一个名为nginx-pod.yaml的新文件,放在您的本地文件系统的任何位置:

apiVersion: v1 
kind: Pod 
metadata: 
  name: nginx-test 
spec: 
  containers: 
  - name: nginx 
    image: nginx 
    ports: 
      - containerPort: 80 
        name: http 
        protocol: TCP 

这个所谓的清单文件描述了你的新 Pod 应该是什么样子。在metadata部分,您可以设置基本的元数据,比如 Pod 的名称或任何额外的标签(我们以后会需要这些)。spec部分包含了 Pod 应该是什么样子的实际规范。正如你所看到的,spec.containers部分被格式化为一个列表;理论上,你可以在这里添加额外的容器,然后在同一个 Pod 中运行。

创建完这个文件后,使用kubectl apply命令创建 Pod:

$ kubectl apply -f nginx-pod.yaml 

之后,您可以使用kubectl get pods命令验证您的 Pod 是否已成功创建。请注意,从ContainerCreatingRunning状态可能需要几秒钟到几分钟的时间:

$ kubectl get pods 

kubectl get pods 输出

请注意,kubectl命令直接与 Kubernetes API 服务器通信(尽管在使用 Minikube 时,这并没有太大的区别,因为所有组件都在同一台虚拟机上运行),而不是与集群节点通信。理论上,您的 Kubernetes 集群可能由许多主机组成,Kubernetes 调度程序会自动选择最适合的主机来运行您的新 Pod。

有更多的事情可以为单个 Pod 配置。例如,您可能希望限制应用程序的内存或 CPU 使用。在这种情况下,您可以将以下设置添加到您新创建的 Pod 清单中:

# ... 
spec: 
  containers: 
  - name: nginx 
    image: nginx 
    resources: 
      limits: 
        memory: 128Mi 
        cpu: 0.5 
    ports: # ... 

resources.limits部分将指示 Kubernetes 创建一个带有 128MB 内存限制和半个 CPU 核心的容器。

关于 Kubernetes Pod 的重要事情是,它们不是持久的。Pod 可能会在任何时候被终止,并且在节点失败时可能会丢失。因此,建议使用 Kubernetes 控制器(如部署控制器)为您创建 Pod。

在继续之前,使用kubectl delete命令删除您的 Pod:

$ kubectl delete pod nginx-test 

接下来,创建一个新的nginx-deployment.yaml文件:

apiVersion: apps/v1beta1 
kind: Deployment 
metadata: 
  name: nginx-deployment 
spec: 
  replicas: 2 
  template: 
    metadata: 
      labels: 
        app: nginx 
    spec: 
      containers: 
      - name: nginx 
        image: nginx 
        ports: 
        - containerPort: 80 
          name: http 
          protocol: TCP 

这个清单将为您创建一个所谓的部署控制器。部署控制器将确保在任何时候运行指定配置的给定数量的 Pod——在这种情况下,两个 Pod(在spec.replicas字段中指定)由spec.template字段描述(注意spec.template字段匹配我们之前已经编写的 Pod 定义,减去名称)。

与之前一样,使用kubectl apply命令创建部署:

$ kubectl apply -f nginx-deployment.yaml 

使用kubectl get pods命令验证您的操作的成功。您应该注意到将安排两个 Pod(名称类似于nginx-deployment-1397492275-qz8k5):

kubectl get pods 输出

部署还有更多的功能。首先,尝试使用kubectl delete命令删除一个自动生成的 Pod(请记住,在您的机器上,您将有一个不同的 Pod 名称):

$ kubectl delete pod nginx-deployment-1397492275-qz8k5 

删除 Pod 后,再次调用kubectl get pods。您会注意到,部署控制器几乎立即创建了一个新的 Pod。

此外,您可能会决定您的应用程序的两个实例不足,您希望进一步扩展您的应用程序。为此,您可以简单地增加部署控制器的spec.scale属性。要增加(或减少)规模,您可以编辑现有的 YAML 文件,然后再次调用kubectl apply。或者,您可以直接使用kubectl edit命令编辑资源:

$ kubectl edit deployment nginx-deployment 

特别是对于spec.scale属性,还有一个特殊的kubectl scale命令可以使用:

$ kubectl scale --replicas=4 deployment/nginx-deployment 

kubectl get pods 输出

服务

目前,我们有四个运行的 NGINX 容器,但没有实际访问它们的方式。这就是服务发挥作用的地方。创建一个名为nginx-service.yaml的新 YAML 文件:

apiVersion: v1 
kind: Service 
metadata: 
  name: nginx 
spec: 
  type: NodePort 
  selector: 
    app: nginx 
  ports: 
  - name: http 
    port: 80 

请注意,spec.selector属性与您在部署清单中指定的metadata.labels属性匹配。由部署控制器创建的所有 Pod 都将具有一组给定的标签(实际上只是任意的键/值映射)。服务的spec.selector属性现在指定了一个 Pod 应该具有哪些标签,以便被此服务识别。还要注意type: NodePort属性,这将在后面变得重要。

创建文件后,像往常一样使用kubectl apply来创建服务定义:

kubectl apply 输出

**$ kubectl apply -f nginx-service.yaml**

接下来,调用`kubectl get services`来检查新创建的服务定义。

在`kubectl get services`输出中,您将找到您新创建的`nginx`服务(以及始终存在的 Kubernetes 服务)。

记住在创建服务时指定的`type: NodePort`属性吗?这个属性的效果是,每个节点上的 Kube 代理现在打开了一个 TCP 端口。此端口的端口号是随机选择的。在前面的例子中,这是 TCP 端口 31455。您可以使用此端口从 Kubernetes 集群外部连接到您的服务(例如,从您的本地计算机)。在此端口接收到的任何流量都将转发到服务规范中指定的`selector`匹配的 Pod 之一。

服务的特殊之处在于,它们通常会比平均 Pod 的寿命要长得多。当添加新的 Pod(可能是因为您增加了部署控制器的副本数量)时,这些将自动添加。同样,当 Pod 被删除(可能是因为副本数量发生了变化,也可能是因为节点故障或者只是手动删除了 Pod),它们将停止接收流量。

如果您使用 Minikube,现在可以使用`minikube service`命令快速找到节点的公共 IP 地址,以在浏览器中打开此服务:

```go
$ minikube service nginx 
```

除了节点端口,还要注意前面输出中的集群 IP 属性;这是一个 IP 地址,您可以在集群内使用它来访问此服务匹配的任何 Pod。因此,在这个例子中,您可以启动一个运行您自己应用程序的新 Pod,并使用 IP 地址`10.0.0.223`来访问此应用程序中的`nginx`服务。此外,由于 IP 地址很麻烦,您还可以使用服务名称(在本例中为`nginx`)作为 DNS 名称。

# 持久卷

通常,您需要一个持久地存储文件和数据的地方。由于在 Kubernetes 环境中,单个 Pod 的生命周期相当短暂,直接在容器的文件系统中存储文件通常不是一个好的解决方案。在 Kubernetes 中,通过使用持久卷来解决这个问题,这基本上是 Docker 卷的更灵活的抽象,您之前已经使用过。

要创建一个新的持久卷,创建一个新的`example-volume.yaml`文件,内容如下:

```go
apiVersion: v1 
kind: PersistentVolume 
metadata: 
  name: volume01 
spec: 
  capacity: 
    storage: 1Gi 
  accessModes: 
  - ReadWriteOnce 
  - ReadWriteMany 
  hostPath: 
    path: /data/volume01 
```

使用`kubectl apply -f example-volume.yaml`创建卷。之后,您可以通过运行`kubectl get pv`再次找到它。

前面的清单文件创建了一个新的卷,将其文件存储在所使用的主机上的`/data/volume01`目录中。

除了在本地开发环境之外,使用 hostPath 卷来存储持久数据是一个糟糕的主意。如果使用此持久卷的 Pod 在另一个节点上重新调度,它将无法访问之前的相同数据。Kubernetes 支持多种卷类型,可以使卷在多个主机之间可访问。

例如,在 AWS 中,你可以使用以下卷定义:

```go
apiVersion: v1 
kind: PersistentVolume 
metadata: 
  name: volume01 
spec: 
  capacity: 
    storage: 1Gi 
  accessModes: 
  - ReadWriteOnce 
  awsElasticBlockStore: 
    volumeID:  
    fsType: ext4 
```

在 Pod 中使用持久卷之前,你需要索赔它。Kubernetes 在创建持久卷和在容器中使用它之间有一个重要的区别。这是因为创建持久卷的人和使用(索赔)它的人通常是不同的。此外,通过解耦卷的创建和使用,Kubernetes 还将卷在 Pod 中的使用与实际底层存储技术解耦。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/65c9aafa-74bd-4d3c-9404-2c32b6da6253.png)

接下来,通过创建一个`example-volume-claim.yaml`文件,然后调用`kubectl apply -f example-volume-claim.yaml`来创建一个`PersistentVolumeClaim`:

```go
apiVersion: v1 
kind: PersistentVolumeClaim 
metadata: 
  name: my-data 
spec: 
  accessModes: 
    - ReadWriteOnce 
  resources: 
    requests: 
      storage: 1Gi 
```

再次调用`kubectl get pv`时,你会发现`volume01`卷的状态字段已更改为`Bound`。现在你可以在创建 Pod 或 Deployment 时使用新创建的持久卷索赔:

```go
apiVersion: v1 
kind: Pod 
spec: 
  volumes: 
  - name: data 
    persistentVolumeClaim: 
      claimName: my-data 
  containers: 
  - name: nginx 
    image: nginx 
    volumeMounts: 
    - mountPath: "/usr/share/nginx/html" 
      name: data 
```

当你在云环境中操作你的 Kubernetes 集群时,Kubernetes 还能够通过与云提供商的 API 交谈来自动创建新的持久卷,例如创建新的 EBS 设备。

# 将 MyEvents 部署到 Kubernetes

现在你已经在 Kubernetes 上迈出了第一步,我们可以开始将 MyEvents 应用程序部署到 Kubernetes 集群中。

# 创建 RabbitMQ 代理

让我们从创建 RabbitMQ 代理开始。由于 RabbitMQ 不是一个无状态的组件,我们将使用 Kubernetes 提供的一个特殊控制器——`StatefulSet`控制器。这与 Deployment 控制器类似,但会创建具有持久标识的 Pod。

要创建一个新的`StatefulSet`,创建一个名为`rabbitmq-statefulset.yaml`的新文件:

```go
apiVersion: apps/v1beta1 
kind: StatefulSet 
metadata: 
  name: rmq 
spec: 
  serviceName: amqp-broker 
  replicas: 1 
  template: 
    metadata: 
      labels: 
        myevents/app: amqp-broker 
    spec: 
      containers: 
      - name: rmq 
        image: rabbitmq:3-management 
        ports: 
        - containerPort: 5672 
          name: amqp 
        - containerPort: 15672 
          name: http 
```

不过,这个定义缺少一个重要的东西,那就是持久性。目前,如果 RabbitMQ Pod 因任何原因失败,新的 Pod 将被调度而不会保留之前的状态(在这种情况下,交换、队列和尚未分发的消息)。因此,我们还应该声明一个持久卷,可以被这个`StatefulSet`使用。我们可以简单地为`StatefulSet`声明一个`volumeClaimTemplate`,让 Kubernetes 自动提供新的卷,而不是手动创建新的`PersistentVolume`和`PersistentVolumeClaim`。在 Minikube 环境中,这是可能的,因为 Minikube 附带了这样的卷的自动提供程序。在云环境中,你会找到类似的卷提供程序。

将以下部分添加到`StatefulSet`中:

```go
apiVersion: apps/v1beta1 
kind: StatefulSet 
metadata: 
  name: rmq 
spec: 
  serviceName: amqp-broker 
  replicas: 1 
  template: # ... 
  volumeClaimTemplates: 
  - metadata: 
      name: data 
      annotations: 
        volume.alpha.kubernetes.io/storage-class: standard 
    spec: 
      accessModes: ["ReadWriteOnce"] 
      resources: 
        requests: 
          storage: 1Gi 
```

`volumeClaimTemplate`将指示`StatefulSet`控制器自动为`StatefulSet`的每个实例提供新的`PersistentVolume`和新的`PersistentVolumeClaim`。如果增加副本计数,控制器将自动创建更多的卷。

最后要做的事情是实际在`rabbitmq`容器中使用卷索赔。为此,修改容器规范如下:

```go
containers: 
- name: rmq 
  image: rabbitmq:3-management 
  ports: # ... 
  volumeMounts: 
  - name: data 
    mountPath: /var/lib/rabbitmq 
```

使用`kubectl apply -f rabbitmq-statefulset.yaml`创建`StatefulSet`。之后,当你运行`kubectl get pods`时,你应该会看到一个名为`rmq-0`的新 Pod 正在启动。当分别运行`kubectl get pv`和`kubectl get pvc`时,你还应该看到自动生成的持久卷和相应的索赔。

接下来,创建一个`Service`,允许其他 Pod 访问你的 RabbitMQ 代理:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: amqp-broker 
spec: 
  selector: 
    myevents/app: amqp-broker 
  ports: 
  - port: 5672 
    name: amqp 
```

像往常一样,使用`kubectl apply -f rabbitmq-service.yaml`创建 Service。创建 Service 后,你将能够通过主机名`amqp-broker`(或其完整形式`amqp-broker.default.svc.cluster.local`)通过 DNS 解析它。

# 创建 MongoDB 容器

接下来,让我们创建 MongoDB 容器。在概念上,它们与您在前一节中创建的 RabbitMQ 容器没有太大的不同。与之前一样,我们将使用自动配置的卷来创建`StatefulSet`。将以下内容放入一个名为`events-db-statefulset.yaml`的新文件中,然后在此文件上调用`kubectl apply`:

```go
apiVersion: apps/v1beta1 
kind: StatefulSet 
metadata: 
  name: events-db 
spec: 
  serviceName: events-db 
  replicas: 1 
  template: 
    metadata: 
      labels: 
        myevents/app: events 
        myevents/tier: database 
    spec: 
      containers: 
      - name: mongo 
        image: mongo:3.4.3 
        ports: 
        - containerPort: 27017 
          name: mongo 
        volumeMounts: 
        - name: database 
          mountPath: /data/db 
  volumeClaimTemplates: 
  - metadata: 
      name: data 
      annotations: 
        volume.alpha.kubernetes.io/storage-class: standard 
    spec: 
      accessModes: ["ReadWriteOnce"] 
      resources: 
        requests: 
          storage: 1Gi 
```

接下来,通过创建一个新文件`events-db-service.yaml`,并调用`kubectl apply`来定义与此`StatefulSet`匹配的 Service:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: events-db 
spec: 
  clusterIP: None 
  selector: 
    myevents/app: events 
    myevents/tier: database 
  ports: 
  - port: 27017 
    name: mongo 
```

现在,我们需要为预订服务的 MongoDB 容器重复这个过程。您几乎可以从上面重复相同的定义;只需用`bookings`替换`events`,并创建`StatefulSet`和 Service`bookings-db`。

# 使镜像可用于 Kubernetes

现在,您需要确保 Kubernetes 集群可以访问您的镜像,然后才能部署实际的微服务。通常,您需要将自己构建的镜像可用于容器注册表。如果您使用 Minikube 并且想要避免设置自己的镜像注册表的麻烦,可以使用以下方法:

```go
$ eval $(minikube docker-env) 
$ docker image build -t myevents/eventservice .
```

第一条命令将指示您的本地 shell 连接到 Minikube VM 内的 Docker Engine,而不是本地 Docker Engine。然后,使用常规的`docker container build`命令,您可以直接在 Minikube VM 上构建要使用的容器镜像。

如果您的镜像可用于私有注册表(例如 Docker Hub、Quay.io 或自托管注册表),则需要配置 Kubernetes 集群,以便它被授权实际访问这些镜像。为此,您将把注册表凭据添加为`Secret`对象。使用`kubectl create secret`命令:

```go
$ kubectl create secret docker-registry my-private-registry \
 --docker-server https://index.docker.io/v1/ \
 --docker-username  \
 --docker-password  \
 --docker-email 
```

在上面的代码示例中,`my-private-registry`是您的 Docker 凭据集的任意选择的名称。`--docker-server`标志`https://index.docker.io/v1/`指定了官方 Docker Hub 的 URL。如果您使用第三方注册表,请记住相应地更改此值。

现在,您可以在创建新的 Pod 时使用这个新创建的`Secret`对象,通过向 Pod 规范添加`imagePullSecrets`属性:

```go
apiVersion: v1
kind: Pod
metadata:
  name: example-from-private-registry
spec:
  containers:
  - name: secret
    image: quay.io/martins-private-registry/secret-application:v1.2.3
  imagePullSecrets:
  - name: my-private-registry
```

当您使用`StatefulSet`或 Deploymet 控制器创建 Pod 时,`imagePullSecrets`属性也可以使用。

# 部署 MyEvents 组件

现在,您的容器镜像可以在 Kubernetes 集群上使用(无论是在 Minikube VM 上本地构建还是将它们推送到注册表并授权您的集群访问该注册表),我们可以开始部署实际的事件服务。由于事件服务本身是无状态的,我们将使用常规的 Deployment 对象部署它,而不是`StatefulSet`。

接下来,通过创建一个新文件`events-deployment.yaml`,并使用以下内容:

```go
apiVersion: apps/v1beta1 
kind: Deployment 
metadata: 
  name: eventservice 
spec: 
  replicas: 2 
  template: 
    metadata: 
      labels: 
        myevents/app: events 
        myevents/tier: api 
    spec: 
      containers: 
      - name: api 
        image: myevents/eventservice 
        imagePullPolicy: Never 
        ports: 
        - containerPort: 8181 
          name: http 
        environment: 
        - name: MONGO_URL 
          value: mongodb://events-db/events 
        - name: AMQP_BROKER_URL 
          value: amqp://guest:guest@amqp-broker:5672/ 
```

请注意`imagePullPolicy: Never`属性。如果您直接在 Minikube VM 上构建了`myevents/eventservice`镜像,则这是必需的。如果您有一个实际的容器注册表可用,可以将镜像推送到该注册表,您应该省略此属性(并添加`imagePullSecrets`属性)。

接下来,通过创建一个新文件`events-service.yaml`来创建相应的 Service:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: events 
spec: 
  selector: 
    myevents/app: events 
    myevents/tier: api 
  ports: 
  - port: 80 
    targetPort: 8181 
    name: http 
```

使用相应的`kubectl apply`调用创建 Deployment 和 Service。不久之后,您应该会在`kubectl get pods`输出中看到相应的容器出现。

类似地进行预订服务。您可以在本书的代码示例中找到预订服务的完整清单文件。

最后,让我们部署前端应用程序。使用以下清单创建另一个 Deployment: 

```go
apiVersion: apps/v1beta1 
kind: Deployment 
metadata: 
  name: frontend 
spec: 
  replicas: 2 
  template: 
    metadata: 
      labels: 
        myevents/app: frontend 
    spec: 
      containers: 
      - name: frontend 
        image: myevents/frontend 
        imagePullPolicy: Never 
        ports: 
        - containerPort: 80 
          name: http 
```

通过创建以下清单来创建相应的`Service`:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: frontend 
spec: 
  selector: 
    myevents/app: frontend 
  ports: 
  - port: 80 
    targetPort: 80 
    name: http 
```

# 配置 HTTP Ingress

此时,您的 Kubernetes 集群中正在运行 MyEvents 应用程序所需的所有服务。但是,目前还没有方便的方法可以从集群外部访问这些服务。使它们可访问的一种可能解决方案是使用**NodePort**服务(我们在以前的某个部分中已经做过)。但是,这将导致您的服务在一些随机选择的高 TCP 端口上暴露,这对于生产设置来说是不可取的(HTTP(S)服务应该在 TCP 端口`80`和`443`上可用)。

如果您的 Kubernetes 集群在公共云环境中运行(更确切地说是 AWS、GCE 或 Azure),您可以按以下方式创建`LoadBalancer` `Service`:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: frontend 
spec: 
  type: LoadBalancer 
  selector: 
    myevents/app: frontend 
  # ... 
```

这将为您的服务提供适当的云提供商资源(例如,在 AWS 中为**弹性负载均衡器**)以使您的服务在标准端口上公开访问。

但是,Kubernetes 还提供了另一个功能,允许您处理传入的 HTTP 流量,称为**Ingress**。Ingress 资源为您提供了更精细的控制,以确定您的 HTTP 服务应该如何从外部世界访问。例如,我们的应用程序由两个后端服务和一个前端应用程序组成,这三个组件都需要通过 HTTP 公开访问。虽然可以为这些组件的每个创建单独的`LoadBalancer`服务,但这将导致这三个服务分别获得自己的 IP 地址,并需要自己的主机名(例如,在`https://myevents.example`上提供前端应用程序,并在`https://events.myevents.example`和`https://bookings.myevents.example`上提供两个后端服务)。这可能会变得繁琐,并且在许多微服务架构中,通常需要提供外部 API 访问的单个入口点。使用 Ingress,我们可以声明路径到服务的映射,例如,使所有后端服务在`https://api.myevents.example`上可访问。

[`github.com/kubernetes/ingress/blob/master/controllers/nginx/README.md`](https://github.com/kubernetes/ingress/blob/master/controllers/nginx/README.md)。

在使用 Ingress 资源之前,您需要为 Kubernetes 集群启用 Ingress 控制器。这与您的个人环境高度相关;一些云提供商提供了处理 Kubernetes Ingress 流量的特殊解决方案,而在其他环境中,您需要自己运行。但是,使用 Minikube,启用 Ingress 是一个简单的命令:

```go
$ minikube addons enable ingress 
```

如果您打算在 Kubernetes 上运行自己的 Ingress 控制器,请查看 NGINX Ingress 控制器的官方文档。起初可能看起来很复杂,但就像许多内部 Kubernetes 服务一样,Ingress 控制器也只包括 Deployment 和 Service 资源。

在 Minikube 中启用 Ingress 控制器后,您的 Minikube VM 将开始在端口`80`和`443`上响应 HTTP 请求。要确定需要连接的 IP 地址,运行`minikube ip`命令。

为了使我们的服务对外界可访问,创建一个新的 Kubernetes 资源文件`ingress.yaml`,其中包含以下内容:

```go
apiVersions: extensions/v1beta1 
kind: Ingress 
metadata: 
  name: myevents 
spec: 
  rules: 
  - host: api.myevents.example 
    http: 
      paths: 
      - path: /events 
        backend: 
          serviceName: events 
          servicePort: 80 
      - path: /bookings 
        backend: 
          serviceName: bookings 
          servicePort: 80 
  - host: www.myevents.example 
    http: 
      paths: 
      - backend: 
          serviceName: frontend 
          servicePort: 80 
```

使用`kubectl apply -f ingress.yaml`创建 Ingress 资源。当然,`myevents.example`域名将不会公开访问(这是`.example`顶级域的整个目的);因此,要实际测试此设置,您可以将一些条目添加到您的主机文件(macOS 和 Linux 上的`/etc/hosts`;Windows 上的`C:\Windows\System32\drivers\etc\hosts`):

```go
192.168.99.100 api.myevents.example 
192.168.99.100 www.myevents.example
```

通常,`192.168.99.100`应该是 Minikube VM 的(唯一可路由的)IP 地址。通过运行`minikube ip`命令的输出进行交叉检查以确保。

# 总结

在本章中,您学会了如何使用诸如 Docker 之类的容器技术将您的应用程序及其所有依赖项打包到容器映像中。您学会了如何从您的应用程序构建容器映像,并在基于 Kubernetes 构建的生产容器环境中部署它们。

我们将在第九章回到构建容器映像,您将学习如何进一步自动化容器构建工具链,使您能够完全自动化应用程序部署,从 git push 命令开始,到在您的 Kubernetes 云中运行更新的容器映像结束。

到目前为止,我们一直相当云不可知。到目前为止,我们所看到的每个示例都可以在任何主要的公共或私有云中运行,无论是 AWS、Azure、GCE 还是 OpenStack。事实上,容器技术通常被认为是摆脱云提供商的个别怪癖并避免(潜在的昂贵)供应商锁定的绝佳方式。

所有这些将在接下来的两章中发生变化,我们将看看其中一个主要的云服务提供商——**亚马逊网络服务**(**AWS**)。您将了解每个提供商的复杂性,如何将 MyEvents 应用程序部署到这些平台上,以及如何使用它们提供的独特功能。


# 第七章:AWS I - 基础知识,Go 的 AWS SDK 和 EC2

欢迎来到我们学习 Go 语言云编程的新阶段。在本章中,我们将开始讨论云技术,涵盖热门的亚马逊网络服务(AWS)平台。AWS 是最早提供给客户在其创业公司、企业甚至个人项目中使用的云平台之一。AWS 于 2006 年由亚马逊推出,并自那时起不断增长。由于该主题的规模较大,我们将把材料分成两章。

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

+   AWS 基础知识

+   Go 的 AWS SDK

+   如何设置和保护 EC2 实例

# AWS 基础知识

AWS 的最简单定义是,它是亚马逊提供的一项服务,您可以在其云平台上购买虚拟机、数据库、消息队列、RESTful API 端点以及各种托管的软件产品。要充分了解 AWS,我们需要涵盖平台上提供的一些主要服务。然后,我们将深入学习如何利用 Go 来构建能够利用 AWS 通过其云 API 提供的服务的应用程序的能力。

+   弹性计算云(EC2):弹性计算云(EC2)是 AWS 提供的最受欢迎的服务之一。它可以简单地描述为在 AWS 上需要旋转新服务器实例时使用的服务。EC2 之所以特殊,是因为它使启动服务器和分配资源的过程对用户和开发人员来说几乎是轻而易举的。EC2 支持自动扩展,这意味着应用程序可以根据用户的需求自动扩展和缩减。该服务支持多种设置和操作系统。

+   简单存储服务(S3):S3 允许开发人员存储不同类型的数据以供以后检索和数据分析。S3 是另一个全球众多开发人员使用的热门 AWS 服务。通常,开发人员在 S3 上存储图像、照片、视频和类似类型的数据。该服务可靠、扩展性好,易于使用。S3 的用例很多;它可用于网站、移动应用程序、IOT 传感器等。

+   简单队列服务(SQS):SQS 是 AWS 提供的托管消息队列服务。简而言之,我们可以将消息队列描述为一种软件,可以可靠地接收消息、排队并在其他应用程序之间传递它们。SQS 是一种可扩展、可靠且分布式的托管消息队列。

+   亚马逊 API 网关:亚马逊 API 网关是一个托管服务,使开发人员能够大规模创建安全的 Web API。它不仅允许您创建和发布 API,还公开了诸如访问控制、授权、API 版本控制和状态监控等复杂功能。

+   DynamoDB:DynamoDB 是一种托管在 AWS 中并作为服务提供的 NoSQL 数据库。该数据库灵活、可靠,延迟仅为几毫秒。NoSQL 是用来描述非关系型且性能高的数据库的术语。非关系型数据库是一种不使用关系表来存储数据的数据库类型。DynamoDB 利用了两种数据模型:文档存储和键值存储。文档存储数据库将数据存储在一组文档文件中,而键值存储将数据放入简单的键值对中。在下一章中,您将学习如何构建能够利用 DynamoDB 强大功能的 AWS 中的 Go 应用程序。

+   Go 语言的 AWS SDK:AWS SDK for Go 是一组 Go 库,赋予开发人员编写可以与 AWS 生态系统进行交互的应用程序的能力。这些库是我们将利用的工具,用于利用我们迄今提到的不同 AWS 服务,如 EC2、S3、DynamoDB 和 SQS。

在本章和下一章中,我们将更深入地介绍这些技术。我们将在本章讨论的每个主题都是庞大的,可以用整本书来覆盖。因此,我们不会覆盖每个 AWS 服务的每个方面,而是提供对每个服务的实际见解,以及如何将它们作为一个整体来构建强大的生产级应用程序。在深入研究每个 AWS 服务之前,让我们先了解一些 AWS 世界中的一般概念。

# AWS 控制台

AWS 控制台是一个网页门户,为我们提供访问 AWS 提供的多种服务和功能。要访问该门户,您首先需要导航到[aws.amazon.com](http://aws.amazon.com),然后选择“登录到控制台”选项,如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/439b8a86-a7fb-4a58-a6f1-d6aa839a59bb.jpg)

一旦您登录控制台,您将看到一个展示 AWS 提供的服务的网页:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d546f848-b579-4e2c-942b-b3f31811b158.png)

# AWS 命令行界面(CLI)

AWS CLI 是一个开源工具,提供与 AWS 服务交互的命令。AWS CLI 是跨平台的;它可以在 Linux、macOS 和 Windows 上运行。在本章中,我们将使用该工具执行某些任务,例如从`S3`文件夹复制文件到 EC2 实例。AWS CLI 可以执行类似于 AWS 控制台执行的任务;这包括 AWS 服务的配置、部署和监控。该工具可以在以下网址找到:[`aws.amazon.com/cli/`](https://aws.amazon.com/cli/)。

# AWS 区域和可用区

AWS 服务托管在世界各地的多个地理位置。在 AWS 世界中,位置包括区域和可用区。每个区域是一个独立的地理位置。每个区域包含多个隔离的内部位置,称为可用区。一些服务 —— 例如 Amazon EC2,例如 —— 让您完全控制要为您的服务部署使用哪些区域。您还可以在区域之间复制资源。您可以在以下网址找到可用的 AWS 区域列表:[`docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions`](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions)。

对于在 AWS 中进行复杂应用程序部署的开发人员,他们通常将其微服务部署到多个区域。这样可以确保即使某个区域的亚马逊数据中心遭受故障,应用程序也能享受高可用性。

# AWS 标签

AWS 标签是 AWS 宇宙中的另一个重要概念。它允许您正确分类您的不同 AWS 资源。这非常有用,特别是当您为不同的事物使用多个 AWS 服务时。例如,您可以设置一个或多个标签来识别您用于移动应用程序的`S3`存储桶。然后可以使用相同的标签来识别您用于该移动应用程序后端的 EC2 实例。标签是键值对;值是可选的。

更好地理解 AWS 标签的资源可以在以下网址找到:[`aws.amazon.com/answers/account-management/aws-tagging-strategies/`](https://aws.amazon.com/answers/account-management/aws-tagging-strategies/)。

# AWS 弹性 Beanstalk

在我们开始实际深入研究 AWS 服务之前,有必要提到 AWS 生态系统中一个有用的服务,称为*弹性 Beanstalk*。该服务的目的是通过 AWS 控制台提供一个易于使用的配置向导,让您可以快速在 AWS 上部署和扩展您的应用程序。

这项服务在多种场景中都很有用,我们鼓励读者在阅读本章和本书的下一章之后探索它。然而,在本书中我们不会专注于 Elastic Beanstalk。这是因为本书在涉及 AWS 时的目的是为您提供关于主要 AWS 服务内部工作的实用基础知识。这些知识将使您不仅能够在 AWS 上部署和运行应用程序,还能够对事物的运作有很好的把握,并在必要时进行调整。这些基础知识也是您需要将技能提升到本书之外的下一个水平所需的。

涵盖 AWS Beanstalk 而不深入探讨使 AWS 成为开发人员的绝佳选择的关键 AWS 服务将不足以让您获得足够的知识以长期有效。然而,如果您在阅读本章和本书的下一章之后再看 AWS Beanstalk,您将能够理解幕后发生了什么。

该服务可以在[`aws.amazon.com/elasticbeanstalk/`](https://aws.amazon.com/elasticbeanstalk/)找到。

# AWS 服务

现在,是时候学习如何利用 Go 的力量与 AWS 交互并构建云原生应用程序了。在本节中,我们将开始实际深入一些构建现代生产级云应用程序所需的 AWS 服务。

# AWS SDK for Go

如前所述,AWS SDK for Go 是一组库,使 Go 能够展现 AWS 的强大功能。为了利用 SDK,我们首先需要了解一些关键概念。

我们需要做的第一步是安装 AWS SDK for Go;通过运行以下命令来完成:

```go
go get -u github.com/aws/aws-sdk-go/...
```

像任何其他 Go 包一样,这个命令将在我们的开发机器上部署 AWS SDK 库。

# 配置 AWS 区域

第二步是指定 AWS 区域;这有助于确定在进行调用时发送 SDK 请求的位置。SDK 没有默认区域,这就是为什么我们必须指定一个区域。有两种方法可以做到这一点:

+   将区域值分配给名为`AWS_REGION`的环境变量。区域值的示例是`us-west-2`或`us-east-2`。

+   在代码中指定它——稍后会更多。

# 配置 AWS SDK 身份验证

第三步是实现适当的 AWS 身份验证;这一步更加复杂,但非常重要,以确保我们的代码与不同的 AWS 服务进行交互时的安全性。为了做到这一点,我们需要向我们的应用程序提供安全凭据,以便对 AWS 进行安全调用。

生成您需要使代码在通过 SDK 与 AWS 通信时正常工作的凭据有两种主要方法:

+   创建用户,它只是代表一个人或一个服务的身份。您可以直接为用户分配单独的权限,或将多个用户组合成一个允许用户共享权限的组。AWS SDK for Go 要求用户使用 AWS 访问密钥来对发送到 AWS 的请求进行身份验证。AWS 访问密钥由两部分组成:访问密钥 ID 和秘密访问密钥。这是我们在本地服务器上运行应用程序时使用的内容。

+   下一种方法是创建一个角色。角色与用户非常相似,因为它是具有特定权限的身份。然而,角色不是分配给人员的;它是根据特定条件分配给需要它的人员。例如,可以将角色附加到 EC2 实例,这将允许在该 EC2 实例上运行的应用程序进行安全调用 AWS,而无需指定不同的用户。这是在 EC2 实例上运行应用程序时的推荐方法,其中预期应用程序将进行 AWS API 调用。

# 创建 IAM 用户

如果您是从自己的本地计算机运行应用程序,创建访问密钥的推荐方式是创建一个具有特定权限访问 AWS 服务的用户。这是通过在**AWS 身份和访问管理**(**IAM**)中创建用户来完成的。

要在 IAM 中创建用户,我们首先需要登录到 AWS 主要网络控制台,然后点击 IAM,它应该在“安全性、身份和合规性”类别下:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/bcd9b3b4-a29c-4430-95c3-736eccb72700.jpg)

接下来,我们需要点击右侧的“用户”选项,然后点击“添加用户”来创建一个新的 IAM 用户:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/eebc7730-bb98-494a-9a3c-8e94d4ea0874.png)

然后,您将被引导使用用户创建向导来帮助您创建用户并生成访问密钥。在此向导的第一步中,您将可以选择用户名并选择用户的 AWS 访问类型。AWS 访问类型包括两种主要类型:程序访问或 AWS 管理控制台访问。显然,为了创建可以被 AWS SDK 使用的用户,我们需要选择程序访问,如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/5b021144-db9b-4003-8e25-fb18962a8969.png)

下一步将涉及将权限附加到正在创建的用户。然而,在我们讨论三种方法之前,我们首先需要了解策略的概念。策略只是定义权限的一种灵活方法。例如,可以创建一个新策略来定义对特定 S3 文件夹的只读访问权限。然后,任何获得此策略附加的用户或组将只被允许对此特定 S3 文件夹进行只读访问。AWS 提供了许多预创建的策略,我们可以在我们的配置中使用。例如,有一个名为**AmazonS3FullAccess**的策略,允许其持有者对 S3 进行完全访问。现在,让我们回到为用户分配权限的三种方法:

+   **将用户添加到组中**:组是一个可以拥有自己策略的实体。多个用户可以添加到一个或多个组中。您可以将组简单地看作是用户的文件夹。特定组下的用户将享有所述组策略允许的所有权限。在这一步中的配置向导将允许您创建一个新组并为其分配策略,如果需要的话。这通常是分配权限给用户的推荐方式。

+   **从现有用户复制权限**:这允许新用户享有已为不同用户配置的所有组和策略。例如,将用户添加到新团队中时使用。

+   **直接附加现有策略**:这允许直接将策略分配给新用户,而无需经过组或从其他用户复制。这种方法的缺点是,如果每个用户都被分配了个别的策略,而没有组提供的秩序感,随着用户数量的增加,管理用户将变得繁琐。

以下是三个选项的截图:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ce462fe2-dd3a-4a7f-bc32-05af793fdade.png)

完成权限设置后,我们可以审查我们的选择并继续创建新用户。一旦创建了新用户,我们将有一个选项来下载用户的访问密钥作为 CSV 文件。我们必须这样做才能在以后的应用程序中利用这些访问密钥。访问密钥由访问密钥 ID 和秘密访问密钥值组成。

一旦您获得了访问密钥,有多种方法可以让您的代码使用它们;我们将讨论其中的三种:

**直接使用环境变量**:AWS SDK 代码将查找两个主要的环境变量,以及一个可选的第三个环境变量。我们只讨论两个主要的环境变量:

+   `AWS_ACCESS_KEY_ID`:在这里我们设置访问密钥的密钥 ID

+   `AWS_SECRET_ACCESS_KEY`:在这里我们设置访问密钥的秘密密钥值

环境变量通常在 SDK 默认情况下在移动到下一个方法之前进行检查。

**利用凭证文件**:凭证文件是一个存放访问密钥的纯文本文件。该文件必须命名为`credentials`,并且必须位于计算机主目录的`.aws/`文件夹中。主目录显然会根据您的操作系统而变化。在 Windows 中,您可以使用环境变量`%UserProfile%`指定主目录。在 Unix 平台上,您可以使用名为`$HOME`或`~`的环境变量。凭证文件是`.ini`格式的,可能如下所示:

```go
[default]
aws_access_key_id = 
aws_secret_access_key = 

[test-account]
aws_access_key_id = 
aws_secret_access_key = 

[prod-account]
; work profile
aws_access_key_id = 
aws_secret_access_key = 
```

方括号之间的名称称为**配置文件**。如前面的片段所示,您的凭证文件可以指定映射到不同配置文件的不同访问密钥。然而,接下来出现一个重要问题,那就是我们的应用程序应该使用哪个配置文件?为此,我们需要创建一个名为`AWS_PROFILE`的环境变量,该变量将指定配置文件名称和分配给其的应用程序名称。例如,假设我们的应用程序名为`testAWSapp`,我们希望它使用`test-account`配置文件,那么我们将设置`AWS_PROFILE`环境变量如下:

```go
$ AWS_PROFILE=test-account testAWSapp
```

如果未设置`AWS_PROFILE`环境变量,则默认情况下将选择*default*配置文件。

**在应用程序中硬编码访问密钥**:出于安全原因,通常不建议这样做。因此,尽管从技术上讲是可能的,但不要在任何生产系统中尝试这样做,因为任何可以访问您的应用程序代码(可能在 GitHub 中)的人都可以检索并使用您的访问密钥。

# 创建 IAM 角色

如前所述,如果您的应用程序在 Amazon EC2 实例上运行,则建议使用 IAM 角色。通过 AWS 控制台创建 IAM 角色的过程与创建 IAM 用户类似:

1.  首先登录到 AWS 控制台([aws.amazon.com](http://aws.amazon.com))

1.  然后我们从“安全,身份和合规性”类别下选择 IAM

从那里,我们将走另一条路。这一次,我们点击右侧的“角色”,然后选择“创建新角色”:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/5c9cad36-2933-4b29-b19e-59c49d9f6dca.jpg)

选择创建新角色后,我们将得到角色创建向导。

我们首先被要求选择角色类型。对于我们的情况,我们需要选择 EC2 服务角色,然后选择 Amazon EC2:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/4c35989d-e81b-4523-8448-190604016ca7.jpg)

然后,我们将点击下一步。然后,我们需要选择我们的新角色将使用的策略:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/85f26f5d-5d7e-4f21-9493-e92d1cfd1556.jpg)

为了我们的应用程序,让我们选择以下四个策略:

+   AmazonS3FullAccess

+   AmazonSQSFullAccess

+   AmazonDynamoDBFullAccess

+   AmazonAPIGatewayAdministrator

然后,我们再次点击下一步,然后我们进入最后一步,在这一步中我们可以设置角色名称,审查我们的配置,然后点击“创建角色”来创建一个新角色。为了我们的目的,我创建了一个名为`EC2_S3_API_SQS_Dynamo`的新角色:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/8a4276c7-38b6-4ee6-984b-d9b77ebcf165.jpg)

一旦我们点击“创建角色”,一个具有我们选择的策略的新角色就会被创建。

然后可以将此角色附加到 EC2 实例上,我们的应用程序代码将在其中运行。我们将在 EC2 部分探讨如何做到这一点。

# AWS SDK for Go 的基础知识

为了利用 AWS SDK for Go 的功能,我们需要掌握两个关键概念。

# 会话

第一个概念是会话的概念。会话是 SDK 中包含配置信息的对象,我们可以将其与其他对象一起使用,以与 AWS 服务进行通信。

`session`对象可以被共享并被不同的代码片段使用。应该缓存并重复使用该对象。创建新的`session`对象涉及加载配置数据,因此重用它可以节省资源。只要不被修改,`session`对象就可以安全地并发使用。

要创建一个新的`session`对象,我们可以简单地编写以下代码:

```go
session, err := session.NewSession()
```

这将创建一个新的`session`并将其存储在名为 session 的变量中。如果我们通过上述代码创建一个新的`session`,将使用默认配置。如果需要覆盖配置,可以将`aws.Config`类型结构体的对象指针作为参数传递给`NewSession()`结构体。假设我们想设置`Region`:

```go
session, err := session.NewSession(&aws.Config{
    Region: aws.String("us-east-2"),
})
```

我们可以使用另一个构造函数来创建一个新的会话,称为`NewSessionWithOptions()`;这有助于覆盖我们用于提供创建会话所需信息的一些环境变量。例如,我们之前讨论过如何定义一个配置文件来存储应用程序使用的凭据。这是它的样子:

```go
session,err := session.NewSessionWithOptions(session.Options{
   Profile: "test-account",
})
```

# 服务客户端

第二个概念是服务客户端的概念。服务客户端是一个对象,它提供对特定 AWS 服务(如 S3 或 SQS)的 API 访问。

服务客户端对象是从会话对象创建的。以下是一个利用 S3 服务客户端获取存储桶列表(S3 存储桶只是文件和文件夹的容器)并逐个打印每个存储桶名称的代码片段示例:

```go
//Don't forget to import github.com/aws/aws-sdk-go/service/s3

 sess, err := session.NewSession(&aws.Config{
    Region: aws.String("us-west-1"),
  })
  if err != nil {
    log.Fatal(err)
  }
  s3Svc := s3.New(sess)
  results, err := s3Svc.ListBuckets(nil)
  if err != nil {
    log.Fatal("Unable to get bucket list")
  }

  fmt.Println("Buckets:")
  for _, b := range results.Buckets {
    log.Printf("Bucket: %s \n", aws.StringValue(b.Name))
  }
```

只要确保不在并发代码中更改配置,服务客户端对象通常是安全的并发使用。

在底层,服务客户端使用 Restful API 调用与 AWS 进行交互。但是,它们会为您处理构建和保护 HTTP 请求所涉及的所有繁琐代码。

当我们阅读本章和下一章时,我们将创建会话和服务客户端对象,以访问不同的 AWS 服务。会话和服务客户端是我们构建适当的 AWS 云原生应用程序所需的构建代码块。SDK 允许您深入了解底层请求;如果我们想在发送请求之前执行一些操作,这通常是有帮助的。

AWS SDK 的大多数 API 方法调用都遵循以下模式:

1.  API 方法的名称通常描述某个操作。例如,假设我们有一个**简单队列服务**(**SQS**)服务客户端对象,并且我们需要获取特定队列的 URL 地址。方法名称将是`GetQueueUrl`。

1.  API 方法的输入参数通常类似于`Input`;因此,在`GetQueueUrl`方法的情况下,其输入类型是`GetQueueUrlInput`。

1.  API 方法的输出类型通常类似于Output;因此,在`GetQueueURL`方法的情况下,其输出类型是`GetQueueUrlOutput`。

# 本机数据类型

关于 SDK 方法的另一个重要说明是,几乎所有用作参数或结构字段的数据类型都是指针,即使数据类型是本机的。例如,SDK 倾向于使用`*`string 而不是使用字符串数据类型来表示字符串值,整数和其他类型也是如此。为了让开发人员的生活更轻松,AWS SDK 为 Go 提供了帮助方法,用于在确保执行 nil 检查以避免运行时恐慌的同时,在本机数据类型和它们的指针之间进行转换。

将本机数据类型转换为相同数据类型的指针的帮助方法遵循以下模式:`aws.`。例如,如果我们调用`aws.String("hello")`,该方法将返回一个指向存储`Hello`值的字符串的指针。如果我们调用`aws.Int(1)`,该方法将返回一个值为 1 的 int 的指针。

另一方面,将指针转换回其数据类型的方法在进行 nil 检查时遵循以下模式:`aws.Value`。例如,如果我们调用`aws.IntValue(p)`,其中`p`是值为 1 的 int 指针,返回的结果就是一个值为 1 的 int。为了进一步澄清,以下是 SDK 代码中`aws.IntValue`的实现:

```go
func IntValue(v *int) int {
  if v != nil {
    return *v
  }
  return 0
}
```

# 共享配置

由于不同的微服务可能需要在与 AWS 交互时使用相同的配置设置,AWS 提供了一种使用所谓的共享配置的选项。共享配置基本上是一个存储在本地的配置文件。文件名和路径是`.aws/config`。请记住,`.aws`文件夹将存在于操作系统的主文件夹中;在讨论凭据文件时已经涵盖了该文件夹。

配置文件应遵循类似于凭据文件的 ini 格式。它还支持与我们之前在凭据文件中介绍的方式类似的配置文件中的配置文件。以下是`.aws/config`应该是什么样子的示例:

```go
[default]
region=us-west-2
```

为了让特定服务器中的微服务能够使用该服务器的 AWS 配置文件,有两种方法:

1.  将`AWS_SDK_LOAD_CONFIG`环境变量设置为 true;这将导致 SDK 代码使用配置文件。

1.  创建会话对象时,利用`NewSessionWithOptions`构造函数来启用共享配置。代码如下:

```go
sess, err := session.NewSessionWithOptions(session.Options{
    SharedConfigState: SharedConfigEnable,
})
```

有关完整的 AWS Go SDK 文档,您可以访问[`docs.aws.amazon.com/sdk-for-go/api/`](https://docs.aws.amazon.com/sdk-for-go/api/)。

# 分页方法

一些 API 操作可能会返回大量结果。例如,假设我们需要发出 API 调用来从 S3 存储桶中检索项目列表。现在,假设 S3 存储桶包含大量项目,并且在一个 API 调用中返回所有项目是不高效的。AWS Go SDK 提供了一个名为**Pagination**的功能来帮助处理这种情况。通过分页,您可以在多个页面中获取结果。

您可以一次读取每页,然后在准备处理新项目时转到下一页。支持分页的 API 调用类似于<方法名称>Pages。例如,与`ListObjects` S3 方法对应的分页 API 方法调用是`ListObjectsPages`。`ListObjectPages`方法将迭代从`ListObject`操作结果的页面。它接受两个参数——第一个参数是`ListObjectInput`类型,它将告诉`ListObjectPages`我们要读取的 S3 存储桶的名称,以及我们希望每页的最大键数。第二个参数是一个函数,每页的响应数据都会调用该函数。函数签名如下:

```go
func(*ListObjectsOutput, bool) bool
```

此参数函数有两个参数。第一个参数携带我们操作的结果;在我们的情况下,结果将托管在`ListObjectsOutput`类型的对象中。第二个参数是`bool`类型,基本上是一个标志,如果我们在最后一页,则为 true。函数返回类型是`bool`;我们可以使用返回值来停止迭代页面。这意味着每当我们返回 false 时,分页将停止。

以下是 SDK 文档中的一个示例,完美展示了分页功能,利用了我们讨论过的方法。以下代码将使用分页功能来浏览存储在 S3 存储桶中的项目列表。我们将每页请求最多 10 个键。我们将打印每页的对象键,然后在最多浏览三页后退出。代码如下:

```go
svc, err := s3.NewSession(sess)
if err != nil {
    fmt.Println("Error creating session ", err)
}
inputparams := &s3.ListObjectsInput{
    Bucket: aws.String("mybucket"),
    MaxKeys: aws.Int64(10),
}
pageNum := 0
svc.ListObjectsPages(inputparams, func(page *s3.ListObjectsOutput, lastPage bool) bool {
    pageNum++
    for _, value := range page.Contents {
        fmt.Println(*value.Key)
    }
    return pageNum < 3
})
```

# 等待者

等待器是允许我们等待直到某个操作完成的 API 调用。大多数等待方法通常遵循 WaitUntil 格式。例如,在使用 DynamoDB 数据库时,有一个名为`WaitUntilTableExists`的 API 方法调用,它将简单地等待直到满足条件。

# 处理错误

AWS Go SDK 返回`awserr.Error`类型的错误,这是 AWS SDK 中的特殊接口类型,满足通用的 Go 错误接口类型。`awserr.Error`支持三种主要方法:

+   `Code()`: 返回与问题相关的错误代码

+   `Message()`: 返回错误的字符串描述

+   `OrigErr()`: 返回包装在`awserr.Error`类型中的原始错误;例如,如果问题与网络有关,`OrigErr()`将返回原始错误,该错误可能属于 Go net 包

为了暴露和利用`awserr.Error`类型,我们需要使用 Go 语言中的类型断言功能。

让我们展示如何使用实际示例中的`awserr.Error`类型。假设在我们的应用程序中,我们使用 Dynamodb 服务客户端对象通过项目 ID 从 Dynamodb 表中检索项目。但是,我们在表名中犯了一个错误,现在它不存在,这将导致调用失败。代码如下:

```go
    result, err := dynamodbsvc.GetItem(&dynamodb.GetItemInput{
      Key: map[string]*dynamodb.AttributeValue{
        "ID": {
          N: aws.String("9485"),
        },
      },
      TableName: aws.String("bla"),
    })
    if err != nil {
      if v, ok := err.(awserr.Error); ok {
        log.Println("AWS ERROR...")
        if v.Code() == dynamodb.ErrCodeResourceNotFoundException {
          log.Println("Requested resource was not found...")
          return
        }
      }
    }
```

从上述代码中,如果`dynamodbsvc.GetItem()`方法失败并且我们无法获取该项,我们捕获错误是否发生,然后使用 Go 的类型断言从我们的错误对象中获取底层的`awserr.Error`类型。然后我们继续检查错误代码并将其与我们的 SDK 中指示资源未找到问题的错误代码进行比较。如果确实是资源未找到的问题,我们打印一条指示这样的消息然后返回。以下是前面的代码中我们进行错误检测和处理的具体代码段,如当前段落所述:

```go
    if err != nil {
      if v, ok := err.(awserr.Error); ok {
        log.Println("AWS ERROR...")
        if v.Code() == dynamodb.ErrCodeResourceNotFoundException {
          log.Println("Requested resource was not found...")
          return
        }
      }
    }
```

# 弹性计算云(EC2)

与任何其他 AWS 服务一样,我们将从 AWS 控制台开始,以便能够启动和部署 EC2 实例。如前所述,EC2 简单地可以描述为在 AWS 上需要旋转新服务器实例时使用的服务。让我们探索创建和访问 EC2 实例所需的步骤。

# 创建 EC2 实例

在 AWS 控制台的主屏幕上,我们需要选择 EC2 以启动新的 EC2 实例:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/8b254480-4e67-4e85-9c40-a7ad48b3e8ba.jpg)

下一个屏幕将显示许多不同的选项来管理 EC2 实例。现在,我们需要做的是单击“启动实例”按钮。您会注意到 AWS 区域在这里显示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/a8be8561-3162-45b5-8c30-0915899f8628.png)

之后,我们将选择要在云上用作虚拟服务器的镜像。**Amazon Machine Image**(AMI)是一个缩写,用于描述 Amazon 虚拟服务器镜像以及启动所需的所有信息。AMI 包括一个模板,描述了操作系统、虚拟服务器中的应用程序、指定哪个 AWS 帐户可以使用 AMI 启动虚拟服务器实例的启动权限,以及指定一次启动后要附加到实例的卷的设备映射。亚马逊提供了许多现成的 AMI,我们可以立即使用。但是,您也可以创建自己的 AMI。

以下是 AWS 控制台中 AMI 选择屏幕的外观:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d69e094e-1c94-437d-add1-0060b6bf08d1.png)

从 AMI 描述中可以看出,AMI 定义了操作系统、命令行工具、编程语言环境(如 Python、Ruby 和 Pert)。

现在,让我们选择亚马逊 Linux AMI 选项,以继续下一步。在这一步中,我们可以选择我们想要的服务器镜像。在这里,您可以选择 CPU 核心数、内存和网络性能等。您会注意到“EBS”一词位于“实例存储”下。**弹性块存储**(**EBS**)提供云托管存储卷,并提供高可用性、可扩展性和耐用性。每个 EBS 都在其可用性区域内复制。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/51a9e520-77b9-4ad0-9ee7-51de8abda736.png)

接下来,我们可以点击“审阅并启动”按钮来启动 AMI,或者点击“下一步:配置实例详细信息”按钮来深入了解实例的配置选项。更深入的配置选项包括实例数量、子网、网络地址等。

配置实例详细信息也是我们为 EC2 分配 IAM 角色(我们之前讨论过的)的地方。我们在本章前面创建的 IAM 角色名为 EC2_S3_API_SQS_Dynamo,它将允许在此 EC2 实例上运行的应用程序访问 S3 服务、API 网关服务、SQS 服务和 Dynamo 数据库。配置页面将如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/eb2ab8db-ed50-4cb2-bec9-0f5d60b37301.jpg)

为了这一章的目的,我们将点击“审阅并启动”来审阅然后启动实例。让我们来看一下审阅页面:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ca591d91-4d43-445a-89a6-8339e3474ff6.png)

一旦我们对所有设置感到满意,我们可以继续点击“启动”。这将显示一个对话框,要求一个公钥-私钥对。公钥加密的概念在第三章中有更详细的讨论。简而言之,我们可以将公钥与其他人分享,以便他们在发送消息之前对其进行加密。加密的消息只能通过您拥有的私钥解密。

对于 AWS,为了允许开发人员安全地连接到他们的服务,AWS 要求开发人员选择公钥-私钥对以确保访问安全。公钥存储在 AWS 中,而私钥由开发人员存储。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/40ca0b47-18a0-45da-ad3b-5a37710db0e6.png)

如果您还没有在 AWS 上拥有公钥-私钥对,这是我们可以创建的步骤。AWS 还允许您在不创建密钥的情况下继续,这显然会更不安全,不建议在生产应用中使用。让我们看看当我们点击第一个列表框时会得到的三个选项:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/4aeacf13-62ec-47ca-88ac-6bcc7f158ccc.jpg)

如果您选择创建新的密钥对选项,您将有机会命名您的密钥对并下载私钥。您必须下载私钥并将其存储在安全位置,以便以后使用:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/2ff9d334-940c-4e1c-86fa-c4fc60679122.png)

最后,在我们下载私钥并准备启动实例后,我们可以点击“启动实例”按钮。这将启动启动实例的过程,并显示状态指示。下一个屏幕通常是这样的:

>![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/a6e34d0c-cdb9-4f2f-bc98-a0ade1e12ccf.png)

完美;通过这一步,我们在亚马逊云中拥有了我们自己的 Linux 虚拟机。让我们找出如何连接并探索它。

# 访问 EC2 实例

为了访问我们已经创建的 EC2 实例,我们需要首先登录 AWS 控制台,然后像之前一样选择 EC2。这将为您提供访问 EC2 仪表板的权限。从那里,我们需要点击实例,以访问我们帐户下当前创建的 EC2 实例。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/3fd786ae-a434-4833-9052-c0e58581b0d2.jpg)

这将打开一个已经创建的 EC2 实例列表。我们刚刚创建的实例是第一个;您会注意到它的实例 ID 与我们之前创建实例时显示的实例 ID 相匹配。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/72ea8a59-0a45-4664-838d-498421c065ce.jpg)

上述截图显示我们的实例目前正在 AWS 上运行。如果需要,我们可以像连接到任何远程服务器一样连接到它。让我们探讨如何做到这一点。

第一步是选择相关的实例,然后点击连接按钮。这不会直接连接到您的实例;但是,它会提供一系列有用的指令,说明如何建立与您的 EC2 实例的连接。为了建立连接,您需要使用 SSH 协议和之前下载的私人加密密钥远程登录到 EC2 虚拟服务器。**Secure Shell** (**SSH**) 是一种用户安全登录远程计算机的协议。

调用 SSH 的方法可能因操作系统而异。例如,如果您使用的是 Windows 操作系统,那么您应该使用流行的 PuTTY 工具(在 [`www.chiark.greenend.org.uk/~sgtatham/putty/latest.html`](https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html) 找到)来建立与 EC2 实例的 SSH 连接。如果您使用的是 macOS 或 Linux,您可以直接使用 SSH 命令。

# 从 Linux 或 macOS 机器访问 EC2 实例

为了从 Linux 或 macOS 机器访问在 AWS 上创建的 EC2 实例,我们需要使用 SSH 命令。

第一步是确保连接的私钥——我们在创建 EC2 实例时下载的——是安全的,不能被外部方访问。这通常是通过在终端上执行以下命令来完成的:

```go
chmod 400 my-super-secret-key-pair.pem
```

`my-super-secret-key-pair.pem` 是包含私钥的文件名。显然,如果文件名不同,那么您需要确保命令将针对正确的文件名。为了使上述命令生效,我们需要从与密钥所在的相同文件夹运行它。否则,我们需要指定密钥的路径。

在确保密钥受到公共访问的保护之后,我们需要使用 SSH 命令连接到我们的 EC2 实例。为此,我们需要三个信息:私钥文件名、EC2 镜像用户名和连接的 DNS 名称。我们已经知道了密钥文件名,这意味着我们现在需要找出连接的用户名和 DNS 名称。用户名将取决于 EC2 实例的操作系统。以下表显示了操作系统到用户名的映射:

| **操作系统** | **用户名** |
| --- | --- |
| 亚马逊 Linux | `ec2-user` |
| RHEL(Red Hat Enterprise Linux) | `ec2-user` 或 root |
| Ubuntu | ubuntu 或 root |
| Centos | centos |
| Fedora | `ec2-user` |
| SUSE | `ec2-user` 或 root |

对于其他操作系统,如果 `ec2-user` 或 root 无法使用,请与 **Amazon Machine Image** (**AMI**) 提供商确认。

现在,我们需要的剩下的信息是连接到 EC2 实例的 DNS 名称。我们可以通过简单地查看状态页面上的 EC2 实例详细信息来找到它:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ad397b79-0039-4abe-bede-8632405a7396.jpg)

有了这个,我们就有了执行 SSH 命令访问我们的 EC2 实例所需的一切;命令如下所示:

```go
ssh -i "my-super-secret-key-pair.pem" ec2-user@ec2-54-193-5-28.us-west-1.compute.amazonaws.com
```

上述命令中的私钥名称是 `my-super-secret-key-pair.pem`,用户名是 `ec2-user`,DNS 是 `ec2-54-193-5-28.us-west-1.compute.amazonaws.com`。

这个命令将允许我们访问我们刚刚创建的 EC2 实例;屏幕上会显示如下内容:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/7ff19708-efe2-4851-ac97-938e07d0f6bf.jpg)

# 从 Windows 访问 EC2

要从 Windows 访问 EC2,我们可以使用我们在前一节中介绍的 SSH 工具的 Windows 版本,或者我们可以使用 PuTTY。PuTTY 是一个非常受欢迎的 SSH 和 telnet 客户端,可以在 Windows 或 Unix 上运行。要下载 PuTTY,我们需要访问[`www.chiark.greenend.org.uk/~sgtatham/PuTTY/latest.html`](https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html)。下载 PuTTY 后,安装并运行它,主屏幕将类似于这样:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/207250d6-fde7-4379-9169-0abf6ea8e15c.jpg)

在我们可以使用 PuTTY 连接到我们的 EC2 实例之前,我们需要将之前获得的私钥文件转换为可以被 PuTTY 软件轻松消耗的不同文件类型。

要执行私钥转换,我们将需要一个名为**PuTTYgen**的工具的帮助,它随 PuTTY 一起安装。PuTTYgen 可以在所有程序>PuTTY>PuTTYgen 下找到。启动后,PuTTYgen 看起来像这样:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/cdff06dc-02fb-4712-8e2f-03c302af006f.jpg)

在参数下,确保选择 RSA 作为加密算法,生成的密钥中有 2048 位。

要继续,让我们点击“加载”按钮,以便能够将我们的 AWS 私钥加载到工具中。加载按钮将打开一个对话框,允许我们选择私钥文件。我们需要选择显示所有文件的选项,以便查看私钥文件:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/2d2982a9-f062-4409-a9a5-1db4a6b3dafa.jpg)![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/f8583255-4a60-463b-a99f-438c4c5a4202.jpg)

然后,我们可以选择密钥,然后点击“打开”,以便将密钥加载到 PuTTYgen 工具中。下一步是点击“保存私钥”以完成密钥转换。会出现一个警告,询问您是否确定要保存此密钥而不使用密码来保护它;点击“是”。密码是额外的保护层;但是,它需要用户输入才能工作。因此,如果我们想要自动化 SSH 连接到 EC2 实例,我们不应该启用密码。点击“是”后,我们可以选择转换文件的文件名;然后,我们点击“保存”以创建和保存文件。PuTTY 私钥是`*.ppk`类型的。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/8c85fbf2-43ae-4a09-aa1b-5d27f808eed9.jpg)

完美;我们现在有一个 PuTTY 私钥可以用于我们的用例。下一步是打开 PuTTY 工具,以使用此密钥通过 SSH 连接到 EC2 实例。

打开 PuTTY 后,我们需要转到连接类别下的 SSH 选项,然后从那里导航到 Auth 选项。在 Auth 窗口中,我们将搜索我们之前创建的 PuTTY 私钥文件的加载选项。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/7e53ca77-6eab-49df-8f7d-768d851c25e3.jpg)

接下来,我们需要点击右侧的“会话”类别。然后,在右侧的“主机名(或 IP 地址)”字段下,我们需要输入用户名和公共 DNS 地址,格式如下:`用户名@DNS 公共`名称。在我们的情况下,看起来是这样的:`ec2-user@ec2-54-193-5-28.us-west-1.compute.amazonaws.com`:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/4cf2b45f-2151-4049-8945-fde0f0f4e015.jpg)

从那里,我们可以点击“打开”以打开到 EC2 实例的会话。第一次尝试打开会话时,我们会收到一条消息,询问我们是否信任我们要连接的服务器。如果我们信任它,我们需要点击“是”,这将把服务器的主机密钥缓存到注册表中。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/77b9cfd9-072d-4096-921b-496eb7385b4c.jpg)

这将打开到我们的 EC2 实例的安全会话;然后我们可以随心所欲地使用它:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/00be9dc3-a3da-4f8d-a8a9-2d4181bfe384.jpg)

PuTTY 有保存现有会话信息的功能。完成配置后,我们可以选择一个名称,然后点击“另存为”,如下图所示,以保存会话信息:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/500c1a98-a1ce-4b03-8708-36d030ee784b.jpg)

# 安全组

太好了!这足以涵盖如何在不同操作系统中配置和设置 EC2 实例的实用知识。现在,我们需要涵盖的另一个主题是安全组。您可以将安全组视为围绕您的 EC2 实例的防火墙规则集合。例如,通过添加安全规则,您可以允许在您的 EC2 上运行的应用程序接受 HTTP 流量。您可以创建规则以允许访问特定的 TCP 或 UDP 端口,以及其他更多内容。

由于我们预计将 Web 服务部署到我们的 EC2 实例上,比如*事件微服务*。我们需要创建一个允许 HTTP 流量的安全组,然后将该组分配给我们的 EC2 实例。

我们需要做的第一步是打开 EC2 仪表板,方法是转到 AWS 控制台主屏幕,然后选择 EC2,就像我们之前做的那样。一旦我们进入 EC2 仪表板,我们可以点击左侧的安全组,它将位于网络和安全类别下:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/36d1816b-74f9-4ab2-990c-27c3e84882cd.jpg)

安全组仪表板将显示已经创建的所有安全组的列表。该仪表板允许我们创建新组或编辑现有组。由于在我们的情况下,我们正在创建一个新组,我们需要点击仪表板左上角的“创建安全组”。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/1670b49c-b298-42f0-8ee2-c1170abfab72.jpg)

一个表单窗口将弹出,我们需要填写字段以创建我们的安全组。首先,我们需要为安全组提供一个名称,一个可选的描述,我们的安全组将应用的虚拟私有云的名称。虚拟私有云简单地定义为 AWS 云中的逻辑隔离部分;我们可以定义自己的。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/5bd0cd79-5c5f-499d-834b-4748c570f914.jpg)

在前面的截图中,我们将我们的安全组命名为 HTTP 访问;我们将其描述为启用 HTTP 访问的安全组,然后我们选择默认 VPC。

下一步是点击“添加规则”按钮,开始定义组成我们安全组的规则。点击后,安全组规则部分将出现新行。我们需要点击“类型”列下的列表框,然后选择 HTTP。结果如下:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/b9f229e1-0ed8-4178-b573-d739f09ebedd.jpg)

您会注意到协议、端口范围和源字段将为您填写。TCP 是 HTTP 的基础协议,端口 80 是 HTTP 端口。

如果需要,我们也可以添加一个 HTTPS 规则;我们将按照相同的步骤进行,只是在选择类型时,选择 HTTPS 而不是 HTTP。您还可以探索其他选项,以了解安全规则下可以创建哪些其他异常。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/13c00405-b940-45fc-ba07-6ff2f18c67a4.jpg)

创建安全组后,我们将在我们的安全组列表中找到它:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/f075d6d8-2d43-49d2-b962-408777d5df54.jpg)

创建了安全组后,我们可以将其附加到现有的 EC2 实例。这是通过返回 EC2 仪表板,然后选择“运行中的实例”,然后从 EC2 实例列表中选择感兴趣的实例来完成的。然后,我们点击“操作”,然后“网络”,然后“更改安全组”:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/61cd6d91-ca7e-4826-bd10-8f75bfbc3c8c.jpg)

从那里,我们可以选择要附加到我们实例的安全组:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/98eb077e-f893-4185-8fdb-351b31958231.jpg)

完美;有了这个,我们的 EC2 实例现在允许在其内部运行的应用程序访问 HTTP。

另一个重要的说明是,我们可以在创建 EC2 实例时将安全组分配给 EC2 实例。我们可以通过在创建新实例时点击“配置实例详细信息”,然后按照配置向导到“配置安全组”选项来访问此选项。

# 总结

在本章中,我们开始学习如何配置 EC2 以及如何使用 AWS SDK for Go。在下一章中,我们将继续深入了解 AWS,学习一些关键的 AWS 服务以及如何编写能够正确利用它们的 Go 代码。


# 第八章:AWS II–S3、SQS、API Gateway 和 DynamoDB

在本章中,我们将继续介绍亚马逊网络服务的大主题。在本章中,我们将介绍 S3 服务、SQS 服务、AWS API 网关服务和 DynamoDB 服务。这些服务中的每一个都是您在云上构建生产应用程序的强大工具。

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

+   AWS S3 存储服务

+   SQS 消息队列服务

+   AWS API 网关服务

+   DynamoDB 数据库服务

# 简单存储服务(S3)

Amazon S3 是 AWS 负责存储和分析数据的服务。数据通常包括各种类型和形状的文件(包括音乐文件、照片、文本文件和视频文件)。例如,S3 可以用于存储静态数据的代码文件。让我们来看看如何在 AWS 中使用 S3 服务。

# 配置 S3

S3 服务将文件存储在存储桶中。每个存储桶可以直接保存文件,也可以包含多个文件夹,而每个文件夹又可以保存多个文件。

我们将使用 AWS Web 控制台来配置 S3,类似于我们在 EC2 中所做的。第一步是导航到 AWS Web 控制台,然后选择 S3:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/fa3ea49a-4f8e-4191-bfb1-ddc51cd6d8d7.png)

这将打开 Amazon S3 控制台;从那里,我们可以点击“创建存储桶”来创建一个新的存储桶来存储数据文件夹:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c2cb783f-9073-4468-8c89-c36969afa322.png)

这将启动一个向导,将引导您完成创建存储桶所需的不同步骤。这将使您有权设置存储桶名称、启用版本控制或日志记录、设置标签和设置权限。完成后,将为您创建一个新的存储桶。存储桶名称必须是唯一的,以免与其他 AWS 用户使用的存储桶发生冲突。

我创建了一个名为`mnandbucket`的存储桶;它将显示在我的 S3 主网页的存储桶列表中。如果您的存储桶比页面能显示的更多,您可以在搜索栏中搜索存储桶:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/9209eb72-1f0b-412b-8674-e8f855a5c7d3.png)

一旦进入存储桶,我们就可以创建文件夹并上传文件:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/84fb98d0-037a-4d65-b425-8a8de899ec32.png)

完美!通过这样,我们对 S3 是什么有了一个实际的了解。

您可以从以下网址下载此文件:[`www.packtpub.com/sites/default/files/downloads/CloudNativeprogrammingwithGolang_ColorImages.pdf`](https://www.packtpub.com/sites/default/files/downloads/CloudNativeprogrammingwithGolang_ColorImages.pdf)。

该书的代码包也托管在 GitHub 上,网址为[`github.com/PacktPublishing/Cloud-Native-Programming-with-Golang`](https://github.com/PacktPublishing/Cloud-Native-programming-with-Golang)。

S3 存储可以用于存储我们的应用程序文件以供以后使用。例如,假设我们构建了我们的`events`微服务以在 Linux 环境中运行,并且应用程序的文件名简单地是`events`。然后我们可以简单地将文件存储在 S3 文件夹中;然后,每当我们需要 EC2 实例获取文件时,我们可以使用 Ec2 实例中的 AWS 命令行工具来实现。

首先,我们需要确保 AWS 角色已经正确定义,以允许我们的 EC2 实例访问 S3 存储,就像之前介绍的那样。然后,从那里,要将文件从 S3 复制到我们的 EC2 实例,我们需要从我们的 EC2 实例中发出以下命令:

```go
aws s3 cp s3:////events my_local_events_copy
```

上述命令将从 S3 存储中检索`events`文件,然后将其复制到一个名为`my_local_events_copy`的新文件中,该文件将位于当前文件夹中。``和``分别表示 S3 存储中事件文件所在的存储桶和文件夹。

在将可执行文件复制到 EC2 后,我们需要通过 Linux 的`chmod`命令给予它执行权限。这是通过以下命令实现的:

```go
chmod u+x 
```

在上述命令中,``是我们想要在 EC2 实例中获得足够访问权限以执行的文件。

# 简单队列服务(SQS)

如前所述,SQS 是 AWS 提供的消息队列。可以与 SQS 交互的应用程序可以在 AWS 生态系统内发送和接收消息。

让我们从讨论如何从 Amazon 控制台配置 SQS 开始。通常情况下,第一步是登录到 Amazon 控制台,然后从主仪表板中选择我们的服务。在这种情况下,服务名称将被称为简单队列服务:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ffd92463-21cb-4417-a776-8e51ca287e69.png)

接下来,我们需要单击“入门”或“创建新队列”。队列创建页面将为我们提供配置新队列行为的能力。例如,我们可以设置允许的最大消息大小、保留消息的天数或接收消息的等待时间:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d01b6f1b-b33e-4864-8925-295cc565b8f5.png)

当您满意您的设置时,单击“创建队列”——我选择了名称`eventqueue`。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/5027860c-394a-4bde-9e32-7a8c8b3ee3f5.png)

这将创建一个新的 AWS SQS 队列,我们可以在我们的代码中使用。现在,是时候讨论如何编写代码与我们的新队列进行交互了。

太好了!有了我们创建的队列,我们准备编写一些代码,通过新创建的 AWS SQS 队列发送和接收消息。让我们开始探索我们需要编写的代码,以便发送一些数据。

AWS SDK Go SQS 包的文档可以在[`godoc.org/github.com/aws/aws-sdk-go/service/sqs`](https://godoc.org/github.com/aws/aws-sdk-go/service/sqs)找到。

与任何其他 AWS 服务一样,我们需要先完成两个关键步骤:

+   获取或创建会话对象

+   为我们想要的 AWS 服务创建服务客户端

前面的步骤通过以下代码进行了覆盖:

```go
 sess, err := session.NewSession(&aws.Config{
   Region: aws.String("us-west-1"),
 })
 if err != nil {
   log.Fatal(err)
 }
 sqsSvc := sqs.New(sess)
```

在调用`NewSession()`构造函数时,前面的代码通过代码设置了区域;但是,我们也可以选择使用共享配置,如前一章所述。我在这段代码中使用了`log.Fatal()`,因为这只是测试代码,所以如果出现任何错误,我希望退出并报告错误消息。

接下来,我们需要获取消息队列的 URL。URL 很重要,因为它在 SDK 方法调用中充当消息队列的唯一标识符。我们可以通过 AWS 控制台 SQS 页面获取 URL,当选择队列时,队列的 URL 将显示在详细信息选项卡中,也可以通过使用我们创建队列时选择的队列名称来通过代码获取 URL。在我的情况下,我称我的队列为`eventqueue`;所以,让我们看看如何通过我们的代码从该名称获取 URL:

```go
  QUResult, err := sqsSvc.GetQueueUrl(&sqs.GetQueueUrlInput{
    QueueName: aws.String("eventqueue"),
  })
  if err != nil {
    log.Fatal(err)
  }
```

`QUResult`对象是`*GetQueueUrlOutput`类型的,它是指向包含`*string`类型的`QueueUrl`字段的结构体的指针。如果`GetQueueUrl()`方法成功执行,该字段应该包含我们的队列 URL。

太好了!现在我们有了队列的 URL,我们准备通过消息队列发送一些数据。但在这样做之前,我们需要了解一些重要的定义,以理解即将到来的代码。

+   **消息主体***:* 消息主体只是我们试图发送的核心消息。例如,如果我想通过 SQS 发送一个 hello 消息,那么消息主体将是 hello。

+   **消息属性***:* 消息属性是一组结构化的元数据项。您可以简单地将它们视为您可以定义并与消息一起发送的键值对列表。消息属性是可选的;但是,它们可能非常有用,因为它们允许发送比纯文本更结构化和复杂的消息。消息属性允许我们在开始处理消息主体之前了解消息可能包含的内容。我们可以在每条消息中包含多达 10 个消息属性。消息属性支持三种主要数据类型:字符串、数字和二进制。二进制类型表示二进制数据,如压缩文件和图像。

现在,让我们回到我们的示例代码;假设我们想通过 SQS 发送一条消息给我们的事件应用,表示某些音乐会的客户预订;我们的消息将具有以下属性:

+   **消息属性**:我们希望有两个消息属性:

+   `message_type`:我们尝试发送的消息类型——在我们的情况下,此属性的值将是"RESERVATION"

+   `Count`:包含在此消息中的预订数量

+   **消息正文**:这包括以 JSON 格式表示的预订数据。数据包括预订音乐会的客户姓名和事件名称(在这种情况下是音乐会)

以下是代码的样子:

```go
sendResult, err := sqsSvc.SendMessage(&sqs.SendMessageInput{
  MessageAttributes: map[string]*sqs.MessageAttributeValue{
    "message_type": &sqs.MessageAttributeValue{
      DataType: aws.String("String"),
      StringValue: aws.String("RESERVATION"),
    },
    "Count": &sqs.MessageAttributeValue{
      DataType: aws.String("Number"),
      StringValue: aws.String("2"),
    },
  },
  MessageBody: aws.String("[{customer:'Kevin S',event:'Pink Floyd Concert'},{customer:'Angela      T',event:'Cold Play Concert'}]"),
  QueueUrl: QUResult.QueueUrl,
})
```

上述代码使用`SendMessage()`方法发送消息。`SendMessage()`接受`*SendMessageInput{}`类型的参数,我们在其中定义消息属性、消息正文,并标识队列 URL。

之后,我们可以检查是否发生了任何错误。我们可以通过以下代码获取我们创建的消息的 ID:

```go
  if err != nil {
    log.Fatal(err)
  }
  log.Println("Message sent successfully", *sendResult.MessageId)
```

完美!有了这段示例代码,我们现在知道如何通过 SQS 发送消息。现在,让我们学习如何接收它们。

在我们开始查看消息接收代码之前,有一些概念需要涵盖和问题需要回答。让我们假设我们有一个微服务架构,超过一个微服务从 SQS 消息队列中读取消息。一个重要的问题是,我们的服务接收到消息后该怎么办?该消息之后是否允许其他服务接收?这两个问题的答案取决于消息的目的。如果消息应该被消费和处理一次,那么我们需要确保第一个正确接收到消息的服务应该从队列中删除它。

在 AWS SQS 的世界中,当标准队列中的消息被接收时,消息不会从队列中删除。相反,我们需要在接收消息后明确从队列中删除消息,以确保它消失,如果这是我们的意图。然而,还有另一个复杂之处。假设微服务 A 接收了一条消息并开始处理它。然而,在微服务 A 删除消息之前,微服务 B 接收了消息并开始处理它,这是我们不希望发生的。

为了避免这种情况,SQS 引入了一个叫做**可见性超时**的概念。可见性超时简单地使消息在被一个消费者接收后一段时间内不可见。这个超时给了我们一些时间来决定在其他消费者看到并处理消息之前该怎么处理它。

一个重要的说明是,并不总是能保证不会收到重复的消息。原因是因为 SQS 队列通常分布在多个服务器之间。有时删除请求无法到达服务器,因为服务器离线,这意味着尽管有删除请求,消息可能仍然存在。

在 SQS 的世界中,另一个重要概念是长轮询或等待时间。由于 SQS 是分布式的,可能偶尔会有一些延迟,有些消息可能接收得比较慢。如果我们关心即使消息接收慢也要接收到消息,那么在监听传入消息时我们需要等待更长的时间。

以下是一个示例代码片段,显示从队列接收消息:

```go
  QUResult, err := sqsSvc.GetQueueUrl(&sqs.GetQueueUrlInput{
    QueueName: aws.String("eventqueue"),
  })
  if err != nil {
    log.Fatal(err)
  }
  recvMsgResult, err := sqsSvc.ReceiveMessage(&sqs.ReceiveMessageInput{
    AttributeNames: []*string{
      aws.String(sqs.MessageSystemAttributeNameSentTimestamp),
    },
    MessageAttributeNames: []*string{
      aws.String(sqs.QueueAttributeNameAll),
    },
    QueueUrl: QUResult.QueueUrl,
    MaxNumberOfMessages: aws.Int64(10),
    WaitTimeSeconds: aws.Int64(20),
  })
```

在上述代码中,我们尝试监听来自我们创建的 SQS 队列的传入消息。我们像之前一样使用`GetQueueURL()`方法来检索队列 URL,以便在`ReceiveMessage()`方法中使用。

`ReceiveMessage()`方法允许我们指定我们想要捕获的消息属性(我们之前讨论过的),以及一般的系统属性。系统属性是消息的一般属性,例如随消息一起传递的时间戳。在前面的代码中,我们要求所有消息属性,但只要消息时间戳系统属性。

我们设置单次调用中要接收的最大消息数为 10。重要的是要指出,这只是请求的最大消息数,因此通常会收到更少的消息。最后,我们将轮询时间设置为最多 20 秒。如果我们在 20 秒内收到消息,调用将返回捕获的消息,而无需等待。

现在,我们应该怎么处理捕获的消息呢?为了展示代码,假设我们想要将消息正文和消息属性打印到标准输出。之后,我们删除这些消息。这是它的样子:

```go
for i, msg := range recvMsgResult.Messages {
    log.Println("Message:", i, *msg.Body)
    for key, value := range msg.MessageAttributes {
      log.Println("Message attribute:", key, aws.StringValue(value.StringValue))
    }

    for key, value := range msg.Attributes {
      log.Println("Attribute: ", key, *value)
    }

    log.Println("Deleting message...")
    resultDelete, err := sqsSvc.DeleteMessage(&sqs.DeleteMessageInput{
      QueueUrl: QUResult.QueueUrl,
      ReceiptHandle: msg.ReceiptHandle,
    })
    if err != nil {
      log.Fatal("Delete Error", err)
    }
    log.Println("Message deleted... ")
  }
```

请注意,在前面的代码中,我们在`DeleteMessage()`方法中使用了一个名为`msg.ReceiptHandle`的对象,以便识别我们想要删除的消息。ReceiptHandle 是我们从队列接收消息时获得的对象;这个对象的目的是允许我们在接收消息后删除消息。每当接收到一条消息时,都会创建一个 ReceiptHandle。

此外,在前面的代码中,我们接收了消息然后对其进行了解析:

+   我们调用`msg.Body`来检索我们消息的正文

+   我们调用`msg.MessageAttributes`来获取我们消息的消息属性

+   我们调用`msg.Attributes`来获取随消息一起传递的系统属性

有了这些知识,我们就有足够的知识来为我们的`events`应用程序实现一个 SQS 消息队列发射器和监听器。在之前的章节中,我们为应用程序中的消息队列创建了两个关键接口需要实现。其中一个是发射器接口,负责通过消息队列发送消息。另一个是监听器接口,负责从消息队列接收消息。

作为一个快速的复习,发射器接口的样子是什么:

```go
package msgqueue

// EventEmitter describes an interface for a class that emits events
type EventEmitter interface {
  Emit(e Event) error
}
```

此外,以下是监听器接口的样子:

```go
package msgqueue

// EventListener describes an interface for a class that can listen to events.
type EventListener interface {
 Listen(events ...string) (<-chan Event, <-chan error, error)
 Mapper() EventMapper
}
```

`Listen`方法接受一个事件名称列表,然后将这些事件以及尝试通过消息队列接收事件时发生的任何错误返回到一个通道中。这被称为通道生成器模式。

因此,为了支持 SQS 消息队列,我们需要实现这两个接口。让我们从`Emitter`接口开始。我们将在`./src/lib/msgqueue`内创建一个新文件夹;新文件夹的名称将是`sqs`。在`sqs`文件夹内,我们创建两个文件——`emitter.go`和`listener.go`。`emitter.go`是我们将实现发射器接口的地方。

我们首先创建一个新对象来实现发射器接口——这个对象被称为`SQSEmitter`。它将包含 SQS 服务客户端对象,以及我们队列的 URL:

```go
type SQSEmitter struct {
  sqsSvc *sqs.SQS
  QueueURL *string
}
```

然后,我们需要为我们的发射器创建一个构造函数。在构造函数中,我们将从现有会话或新创建的会话中创建 SQS 服务客户端。我们还将利用`GetQueueUrl`方法来获取我们队列的 URL。这是它的样子:

```go
func NewSQSEventEmitter(s *session.Session, queueName string) (emitter msgqueue.EventEmitter, err error) {
  if s == nil {
    s, err = session.NewSession()
    if err != nil {
      return
    }
  }
  svc := sqs.New(s)
  QUResult, err := svc.GetQueueUrl(&sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    return
  }
  emitter = &SQSEmitter{
    sqsSvc: svc,
    QueueURL: QUResult.QueueUrl,
  }
  return
}
```

下一步是实现发射器接口的`Emit()`方法。我们将发射的消息应具有以下属性:

+   它将包含一个名为`event_name`的单个消息属性,其中将保存我们试图发送的事件的名称。如前所述,在本书中,事件名称描述了我们的应用程序试图处理的事件类型。我们有三个事件名称 - `eventCreated`、`locationCreated`和`eventBooked`。请记住,这里的`eventCreated`和`eventBooked`是指应用程序事件(而不是消息队列事件)的创建或预订,例如音乐会或马戏团表演。

+   它将包含一个消息正文,其中将保存事件数据。消息正文将以 JSON 格式呈现。

代码将如下所示:

```go
func (sqsEmit *SQSEmitter) Emit(event msgqueue.Event) error {
  data, err := json.Marshal(event)
  if err != nil {
    return err
  }
  _, err = sqsEmit.sqsSvc.SendMessage(&sqs.SendMessageInput{
    MessageAttributes: map[string]*sqs.MessageAttributeValue{
      "event_name": &sqs.MessageAttributeValue{
        DataType: aws.String("string"),
        StringValue: aws.String(event.EventName()),
      },
    },
    MessageBody: aws.String(string(data)),
    QueueUrl: sqsEmit.QueueURL,
  })
  return err
}
```

有了这个,我们就有了一个用于发射器接口的 SQS 消息队列实现。现在,让我们讨论监听器接口。

监听器接口将在`./src/lib/msgqueue/listener.go`文件中实现。我们从将实现接口的对象开始。对象名称是`SQSListener`。它将包含消息队列事件类型映射器、SQS 客户端服务对象、队列的 URL、从一个 API 调用中接收的消息的最大数量、消息接收的等待时间和可见性超时。这将如下所示:

```go
type SQSListener struct {
  mapper msgqueue.EventMapper
  sqsSvc *sqs.SQS
  queueURL *string
  maxNumberOfMessages int64
  waitTime int64
  visibilityTimeOut int64
}
```

我们将首先从构造函数开始;代码将类似于我们为发射器构建的构造函数。我们将确保我们有一个 AWS 会话对象、一个服务客户端对象,并根据队列名称获取我们队列的 URL:

```go
func NewSQSListener(s *session.Session, queueName string, maxMsgs, wtTime, visTO int64) (listener msgqueue.EventListener, err error) {
  if s == nil {
    s, err = session.NewSession()
    if err != nil {
      return
    }
  }
  svc := sqs.New(s)
  QUResult, err := svc.GetQueueUrl(&sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    return
  }
  listener = &SQSListener{
    sqsSvc: svc,
    queueURL: QUResult.QueueUrl,
    mapper: msgqueue.NewEventMapper(),
    maxNumberOfMessages: maxMsgs,
    waitTime: wtTime,
    visibilityTimeOut: visTO,
  }
  return
}
```

之后,我们需要实现`listener`接口的`Listen()`方法。该方法执行以下操作:

+   它将接收到的事件名称列表作为参数

+   它监听传入的消息

+   当它接收到消息时,它会检查消息事件名称并将其与作为参数传递的事件名称列表进行比较

+   如果接收到不属于请求事件的消息,它将被忽略

+   如果接收到属于已知事件的消息,它将通过“Event”类型的 Go 通道传递到外部世界

+   通过 Go 通道传递后,接受的消息将被删除

+   发生的任何错误都会通过另一个 Go 通道传递给错误对象

让我们暂时专注于将监听和接收消息的代码。我们将创建一个名为`receiveMessage()`的新方法。以下是它的分解:

1.  首先,我们接收消息并将任何错误传递到 Go 错误通道:

```go
func (sqsListener *SQSListener) receiveMessage(eventCh chan msgqueue.Event, errorCh chan error, events ...string) {
  recvMsgResult, err := sqsListener.sqsSvc.ReceiveMessage(&sqs.ReceiveMessageInput{
    MessageAttributeNames: []*string{
      aws.String(sqs.QueueAttributeNameAll),
    },
    QueueUrl: sqsListener.queueURL,
    MaxNumberOfMessages: aws.Int64(sqsListener.maxNumberOfMessages),
    WaitTimeSeconds: aws.Int64(sqsListener.waitTime),
    VisibilityTimeout: aws.Int64(sqsListener.visibilityTimeOut),
  })
  if err != nil {
    errorCh <- err
  }
```

1.  然后,我们逐条查看接收到的消息并检查它们的消息属性 - 如果事件名称不属于请求的事件名称列表,我们将通过移动到下一条消息来忽略它:

```go
bContinue := false
for _, msg := range recvMsgResult.Messages {
  value, ok := msg.MessageAttributes["event_name"]
  if !ok {
    continue
  }
  eventName := aws.StringValue(value.StringValue)
  for _, event := range events {
    if strings.EqualFold(eventName, event) {
      bContinue = true
      break
    }
  }

  if !bContinue {
    continue
  }
```

1.  如果我们继续,我们将检索消息正文,然后使用我们的事件映射器对象将其翻译为我们在外部代码中可以使用的事件类型。事件映射器对象是在第四章中创建的,*使用消息队列的异步微服务架构*;它只是获取事件名称和事件的二进制形式,然后将一个事件对象返回给我们。之后,我们获取事件对象并将其传递到事件通道。如果我们检测到错误,我们将错误传递到错误通道,然后移动到下一条消息:

```go
message := aws.StringValue(msg.Body)
event, err := sqsListener.mapper.MapEvent(eventName, []byte(message))
if err != nil {
  errorCh <- err
  continue
}
eventCh <- event
```

1.  最后,如果我们在没有错误的情况下到达这一点,那么我们知道我们成功处理了消息。因此,下一步将是删除消息,以便其他人不会处理它:

```go
    _, err = sqsListener.sqsSvc.DeleteMessage(&sqs.DeleteMessageInput{
      QueueUrl: sqsListener.queueURL,
      ReceiptHandle: msg.ReceiptHandle,
    })

    if err != nil {
      errorCh <- err
    }
  }
}
```

这很棒。然而,你可能会想,为什么我们没有直接将这段代码放在`Listen()`方法中呢?答案很简单:我们这样做是为了清理我们的代码,避免一个庞大的方法。这是因为我们刚刚覆盖的代码片段需要在循环中调用,以便我们不断地从消息队列中接收消息。

现在,让我们看一下`Listen()`方法。该方法将需要在 goroutine 内的循环中调用`receiveMessage()`。需要 goroutine 的原因是,否则`Listen()`方法会阻塞其调用线程。这是它的样子:

```go
func (sqsListener *SQSListener) Listen(events ...string) (<-chan msgqueue.Event, <-chan error, error) {
  if sqsListener == nil {
    return nil, nil, errors.New("SQSListener: the Listen() method was called on a nil pointer")
  }
  eventCh := make(chan msgqueue.Event)
  errorCh := make(chan error)
  go func() {
    for {
      sqsListener.receiveMessage(eventCh, errorCh)
    }
  }()

  return eventCh, errorCh, nil
}
```

前面的代码首先确保`*SQSListener`对象不为空,然后创建用于将`receiveMessage()`方法的结果传递给外部世界的 events 和 errors Go 通道。

# AWS API 网关

我们深入云原生应用程序的下一步是进入 AWS API 网关。如前所述,AWS API 网关是一个托管服务,允许开发人员为其应用程序构建灵活的 API。在本节中,我们将介绍有关该服务的实际介绍以及如何使用它的内容。

与我们迄今为止涵盖的其他服务类似,我们将通过 AWS 控制台创建一个 API 网关。首先,像往常一样,访问并登录到[aws.amazon.com](http://aws.amazon.com)的 AWS 控制台。

第二步是转到主页,然后从应用服务下选择 API Gateway:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c4769d0c-1552-453c-97eb-858fb84c6788.png)

接下来,我们需要从左侧选择 API,然后点击创建 API。这将开始创建一个新的 API 供我们的应用使用的过程:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ca26727e-794e-46a8-a48b-b373300c0a3a.png)

然后,我们可以选择我们的新 API 的名称,如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/6fecc87a-19a7-464a-bc6a-deea81ef9aba.png)

现在,在创建 API 之后,我们需要在 AWS API 网关和嵌入在我们的 MyEvents 应用程序中的 RESTful API 的地址之间创建映射。MyEvents 应用程序包含多个微服务。其中一个微服务是事件服务;它支持可以通过其 RESTful API 激活的多个任务。作为复习,这里是 API 任务的快速摘要和它们相对 URL 地址的示例:

1.  **搜索事件**:

+   **ID**:相对 URL 是`/events/id/3434`,方法是`GET`,HTTP 主体中不需要数据。

+   **名称**:相对 URL 是`/events/name/jazz_concert`,方法是`GET`,HTTP 主体中不需要数据。

1.  **一次检索所有事件**:相对 URL 是`/events`,方法是`GET`,HTTP 主体中不需要数据。

1.  **创建新事件**:相对 URL 是`/events`,方法是`POST`,HTTP 主体中期望的数据需要是我们想要添加的新事件的 JSON 表示。假设我们想要添加在美国演出的`aida 歌剧`。那么 HTTP 主体会是这样的:

```go
{
    name: "opera aida",
    startdate: 768346784368,
    enddate: 43988943,
    duration: 120, //in minutes
    location:{
        id : 3 , //=>assign as an index
        name: "West Street Opera House",
        address: "11 west street, AZ 73646",
        country: "U.S.A",
        opentime: 7,
        clostime: 20
        Hall: {
            name : "Cesar hall",
            location : "second floor, room 2210",
            capacity: 10
        }
    }
}
```

让我们逐个探索事件微服务 API 的任务,并学习如何让 AWS API 网关充当应用程序的前门。

从前面的描述中,我们有三个相对 URL:

+   `/events/id/{id}`,其中`{id}`是一个数字。我们支持使用该 URL 进行`GET` HTTP 请求。

+   `/events/name/{name}`,其中`{name}`是一个字符串。我们支持使用该 URL 进行`GET` HTTP 请求。

+   `/events`,我们支持使用此 URL 进行`GET`和`POST`请求。

为了在我们的 AWS API 网关中表示这些相对 URL 和它们的方法,我们需要执行以下操作:

1.  创建一个名为`events`的新资源。首先访问我们新创建的 API 页面。然后,通过点击操作并选择创建资源来创建一个新资源:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/0f3fdfa1-1a5b-484e-899b-6d84c1f92329.png)

1.  确保在新资源上设置名称和路径为`events`:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/8f64e9d5-4c76-44f1-8619-dcb9dd28cf83.png)

1.  然后,选择新创建的`events`资源并创建一个名为`id`的新资源。再次选择`events`资源,但这次创建一个名为`name`的新资源。这是它的样子:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c3ee5533-1b7b-4d69-bf25-4fc82b937394.png)

1.  选择`id`资源,然后创建一个新的资源。这一次,再次将资源名称命名为`id`;但是,资源路径需要是`{id}`。这很重要,因为它表明`id`是一个可以接受其他值的参数。这意味着这个资源可以表示一个相对 URL,看起来像这样`/events/id/3232`:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/aac14ca8-f2ab-409e-8c10-9b70f2b7b08c.png)

1.  与步骤 4 类似,我们将选择`name`资源,然后在其下创建另一个资源,资源名称为`name`,资源路径为`{name}`。这是最终的样子:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/26f4531e-fb61-48cf-b537-ede864da0d7e.png)

1.  现在,这应该涵盖了我们所有的相对 URL。我们需要将支持的 HTTP 方法附加到相应的资源上。首先,我们将转到`events`资源,然后将`GET`方法以及`POST`方法附加到它上面。为了做到这一点,我们需要点击 s,然后选择创建方法:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/136c1c91-6bc1-4cb8-b5a0-1ee19ebc5beb.png)

1.  然后我们可以选择 GET 作为方法类型:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/fb182a15-d591-4060-aafd-36923b802351.png)

1.  然后我们选择 HTTP 作为集成类型。从那里,我们需要设置端点 URL。端点 URL 需要是与此资源对应的 API 端点的绝对路径。在我们的情况下,因为我们在'events'资源下,该资源在'events'微服务上的绝对地址将是`/events`。假设 DNS 是`http://ec2.myevents.com`;这将使绝对路径为`http://ec2.myevents.com/events`。这是这个配置的样子:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/7f554357-b403-4375-b3a6-8b06db91e1d6.png)

1.  我们将重复上述步骤;但是,这一次我们将创建一个`POST`方法。

1.  我们选择`{id}`资源,然后创建一个新的`GET`方法。`EndPoint` URL 需要包括`{id}`;这是它的样子:![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/9eb620b8-1220-4b85-8857-cfb8a0c7dc39.png)

1.  我们将重复使用`{name}`资源进行相同的步骤;这是 Endpoint URL 的样子:`http://ec2.myevents.com/events/name/{name}`。

完美!通过这样,我们为我们的事件微服务 API 创建了 AWS API 网关映射。我们可以使用相同的技术在我们的 MyEvents API 中添加更多资源,这些资源将指向属于 MyEvents 应用程序的其他微服务。下一步是部署 API。我们需要做的第一件事是创建一个新的阶段。阶段是一种标识已部署的可由用户调用的 RESTful API 的方式。在部署 RESTful API 之前,我们需要创建一个阶段。要部署 API,我们需要点击操作,然后点击部署 API:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c7f19c7c-5bd3-48b3-b059-fc6327340fdc.png)

如果我们还没有阶段,我们需要选择[New Stage]作为我们的部署阶段,然后选择一个阶段名称,最后点击部署。我将我的阶段命名为`beta`:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d4035639-deb8-48ed-85a4-bfd9e61d9a85.png)

一旦我们将 RESTful API 资源部署到一个阶段,我们就可以开始使用它。我们可以通过导航到阶段,然后点击所需资源来查找我们的 AWS API 网关门到我们的事件微服务的 API URL。在下图中,我们选择了 events 资源,API URL 可以在右侧找到:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/747e1c87-2da3-44a7-bbbb-90e4df64b407.png)

# DynamoDB

DynamoDB 是 AWS 生态系统中非常重要的一部分;它通常作为众多云原生应用程序的后端数据库。DynamoDB 是一个分布式高性能数据库,托管在云中,由 AWS 作为服务提供。

# DynamoDB 组件

在讨论如何编写可以与 DynamoDB 交互的代码之前,我们需要首先了解一些关于数据库的重要概念。DynamoDB 由以下组件组成:

+   表:与典型的数据库引擎一样,DynamoDB 将数据存储在一组表中。例如,在我们的 MyEvents 应用程序中,我们可以有一个“事件”表,用于存储诸如音乐会名称和开始日期之类的事件信息。同样,我们还可以有一个“预订”表,用于存储我们用户的预订信息。我们还可以有一个“用户”表,用于存储我们用户的信息。

+   项目:项目只是 DynamoDB 表的行。项目内的信息称为属性。如果我们以“事件”表为例,项目将是该表中的单个事件。同样,如果我们以“用户”表为例,每个项目都是一个用户。表中的每个项目都需要一个唯一标识符,也称为主键,以区分该项目与表中所有其他项目。

+   属性:如前所述,属性代表项目内的信息。每个项目由一个或多个属性组成。您可以将属性视为数据的持有者。每个属性由属性名称和属性值组成。如果我们以“事件”表为例,每个“事件”项目将具有一个`ID`属性来表示事件 ID,一个“名称”属性来表示事件名称,一个“开始日期”属性,一个“结束日期”属性等等。

项目主键是项目中必须预先定义的唯一属性。但是,项目中的任何其他属性都不需要预定义。这使得 DynamoDB 成为一个无模式数据库,这意味着在填充表格数据之前不需要定义数据库表的结构。

DynamoDB 中的大多数属性都是标量的。这意味着它们只能有一个值。标量属性的一个示例是字符串属性或数字属性。有些属性可以是嵌套的,其中一个属性可以承载另一个属性,依此类推。属性允许嵌套到 32 级深度。

# 属性值数据类型

如前所述,每个 DynamoDB 属性由属性名称和属性值组成。属性值又由两部分组成:值的数据类型名称和值数据。在本节中,我们将重点关注数据类型。

有三个主要的数据类型类别:

+   标量类型:这是最简单的数据类型;它表示单个值。标量类型类别包括以下数据类型名称:

+   `S`:这只是一个字符串类型;它利用 UTF-8 编码;字符串的长度必须在零到 400 KB 之间。

+   `N`:这是一个数字类型。它们可以是正数、负数或零。它们可以达到 38 位精度。

+   `B`:二进制类型的属性。二进制数据包括压缩文本、加密数据或图像。长度需要在 0 到 400 KB 之间。我们的应用程序必须在将二进制数据值发送到 DynamoDB 之前以 base64 编码格式对二进制数据进行编码。

+   `BOOL`:布尔属性。它可以是 true 或 false。

+   文档类型:文档类型是一个具有嵌套属性的复杂结构。此类别下有两个数据类型名称:

+   `L`:列表类型的属性。此类型可以存储有序集合的值。对可以存储在列表中的数据类型没有限制。

+   `Map`:地图类型将数据存储在无序的名称-值对集合中。

+   集合类型:集合类型可以表示多个标量值。集合类型中的所有项目必须是相同类型。此类别下有三个数据类型名称:

+   `NS`:一组数字

+   `SS`:一组字符串

+   `BS`:一组二进制值

# 主键

如前所述,DynamoDB 表项中唯一需要预先定义的部分是主键。在本节中,我们将更深入地了解 DynamoDB 数据库引擎的主键。主键的主要任务是唯一标识表中的每个项目,以便没有两个项目可以具有相同的键。

DynamoDB 支持两种不同类型的主键:

+   **分区键**:这是一种简单类型的主键。它由一个称为分区键的属性组成。DynamoDB 将数据存储在多个分区中。分区是 DynamoDB 表的存储层,由固态硬盘支持。分区键的值被用作内部哈希函数的输入,生成一个确定项目将被存储在哪个分区的输出。

+   **复合键**:这种类型的键由两个属性组成。第一个属性是我们之前讨论过的分区键,而第二个属性是所谓的'排序键'。如果您将复合键用作主键,那么多个项目可以共享相同的分区键。具有相同分区键的项目将被存储在一起。然后使用排序键对具有相同分区键的项目进行排序。排序键对于每个项目必须是唯一的。

每个主键属性必须是标量,这意味着它只能保存单个值。主键属性允许的三种数据类型是字符串、数字或二进制。

# 二级索引

DynamoDB 中的主键为我们通过它们的主键快速高效地访问表中的项目提供了便利。然而,有很多情况下,我们可能希望通过除主键以外的属性查询表中的项目。DynamoDB 允许我们创建针对非主键属性的二级索引。这些索引使我们能够在非主键项目上运行高效的查询。

二级索引只是包含来自表的属性子集的数据结构。表允许具有多个二级索引,这在查询表中的数据时提供了灵活性。

为了进一步了解二级查询,我们需要涵盖一些基本定义:

+   **基本表**:每个二级索引都属于一个表。索引所基于的表,以及索引获取数据的表,称为基本表。

+   **投影属性**:投影属性是从基本表复制到索引中的属性。DynamoDB 将这些属性与基本表的主键一起复制到索引的数据结构中。

+   **全局二级索引**:具有与基本表不同的分区键和排序键的索引。这种类型的索引被认为是`全局`的,因为对该索引执行的查询可以跨越基本表中的所有数据。您可以在创建表时或以后创建全局二级索引。

+   **本地二级索引**:一个具有与基本表相同的分区键,但不同排序键的索引。这种类型的索引是`本地`的,因为本地二级索引的每个分区都与具有相同分区键值的基本表分区相关联。您只能在创建表时同时创建本地二级索引。

# 创建表

让我们利用 AWS Web 控制台创建 DynamoDB 表,然后我们可以在代码中访问这些表。第一步是访问 AWS 管理控制台主仪表板,然后点击 DynamoDB:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/bbf657e8-3fd0-422b-bce5-0a885e5138d7.png)

点击 DynamoDB 后,我们将转到 DynamoDB 主仪表板,在那里我们可以创建一个新表:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/198c41bb-7bec-468e-bd0b-503c805615c7.png)

下一步是选择表名和主键。正如我们之前提到的,DynamoDB 中的主键可以由最多两个属性组成——分区键和排序键。假设我们正在创建一个名为`events`的表。让我们使用一个简单的主键,它只包含一个名为`ID`的`Binary`类型的分区键:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ca49341a-0bd3-45b7-8476-66525e3d9459.png)

我们也将保留默认设置。稍后我们将重新访问一些设置,比如次要索引。配置完成后,我们需要点击创建来创建表格。然后我们将重复这个过程,创建所有其他我们想要创建的表格:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/480544e0-799f-4345-becd-7df929b5a06f.png)

一旦表格创建完成,我们现在可以通过我们的代码连接到它,编辑它,并从中读取。但是,在我们开始讨论代码之前,我们需要创建一个次要索引。为此,我们需要首先访问我们新创建的表格,选择左侧的 Tables 选项。然后,我们将从表格列表中选择`events`表。之后,我们需要选择 Indexes 选项卡,然后点击 Create Index 来创建一个新的次要索引:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/46bf125c-75b5-4d92-9907-664ff938dc5c.png)

次要索引名称需要是我们表格中希望用作次要索引的属性名称。在我们的情况下,我们希望用于查询的属性是事件名称。这个属性代表了我们需要的索引,以便在查询事件时通过它们的名称而不是它们的 ID 来运行高效的查询。创建索引对话框如下所示;让我们填写不同的字段,然后点击创建索引:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c1356996-0d2d-423e-9014-bbd34892db27.png)

完美!通过这一步,我们现在已经准备好我们的表格了。请注意上面的屏幕截图中索引名称为`EventName-index`。我们将在后面的 Go 代码中使用该名称。

# Go 语言和 DynamoDB

亚马逊已经为 Go 语言提供了强大的包,我们可以利用它们来构建可以有效地与 DynamoDB 交互的应用程序。主要包可以在[`docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/`](https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/)找到。

在我们开始深入代码之前,让我们回顾一下我们在第二章中讨论的`DatabaseHandler`接口,*使用 Rest API 构建微服务*。这个接口代表了我们的微服务的数据库处理程序层,也就是数据库访问代码所在的地方。在`events`服务的情况下,这个接口支持了四种方法。它看起来是这样的:

```go
type DatabaseHandler interface {
  AddEvent(Event) ([]byte, error)
  FindEvent([]byte) (Event, error)
  FindEventByName(string) (Event, error)
  FindAllAvailableEvents() ([]Event, error)
}
```

在我们努力实现如何编写可以与 DynamoDB 一起工作的应用程序的实际理解的过程中,我们将实现前面的四种方法来利用 DynamoDB 作为后端数据库。

与其他 AWS 服务类似,AWS Go SDK 提供了一个服务客户端对象,我们可以用它来与 DynamoDB 交互。同样,我们需要首先获取一个会话对象,然后使用它来创建一个 DynamoDB 服务客户端对象。代码应该是这样的:

```go
  sess, err := session.NewSession(&aws.Config{
    Region: aws.String("us-west-1"),
  })
  if err != nil {
    //handler error, let's assume we log it then exit.
    log.Fatal(err)
  }
  dynamodbsvc := dynamodb.New(sess)
```

`dynamodbsvc`最终成为我们的服务客户端对象,我们可以用它来与 DynamoDB 交互。

现在,我们需要创建一个名为 dynamolayer.go 的新文件,它将存在于相对文件夹`./lib/persistence/dynamolayer`下,这是我们应用程序的一部分:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/f489a243-776b-4e69-bfef-7f9e3020517a.png)

`dynamolayer.go`文件是我们的代码所在的地方。为了实现`databasehandler`接口,我们需要遵循的第一步是创建一个`struct`类型,它将实现接口方法。让我们称这个新类型为`DynamoDBLayer`;代码如下:

```go
type DynamoDBLayer struct {
  service *dynamodb.DynamoDB
}
```

`DynamoDBLayer`结构包含一个类型为`*dynamodb.DynamoDB`的字段;这个结构字段表示 DynamoDB 的 AWS 服务客户端,这是我们在代码中与 DynamoDB 交互的关键对象类型。

下一步是编写一些构造函数来初始化`DynamoDBLayer`结构。我们将创建两个构造函数——第一个构造函数假设我们没有现有的 AWS 会话对象可用于我们的代码。它将接受一个字符串参数,表示我们的 AWS 区域(例如,`us-west-1`)。然后,它将利用该区域字符串创建一个针对该区域的会话对象。之后,会话对象将用于创建一个 DynamoDB 服务客户端对象,该对象可以分配给一个新的`DynamoDBLayer`对象。第一个构造函数将如下所示:

```go
func NewDynamoDBLayerByRegion(region string) (persistence.DatabaseHandler, error) {
  sess, err := session.NewSession(&aws.Config{
    Region: aws.String(region),
  })
  if err != nil {
    return nil, err
  }
  return &DynamoDBLayer{
    service: dynamodb.New(sess),
  }, nil
}
```

第二个构造函数是我们在已经有现有的 AWS 会话对象时会使用的构造函数。它接受会话对象作为参数,然后使用它创建一个新的 DynamoDB 服务客户端,我们可以将其分配给一个新的`DynamoDBLayer`对象。代码将如下所示:

```go
func NewDynamoDBLayerBySession(sess *session.Session) persistence.DatabaseHandler {
  return &DynamoDBLayer{
    service: dynamodb.New(sess),
  }
}
```

太好了!现在,构造函数已经完成,让我们实现`DatabaseHandler`接口方法。

在我们继续编写代码之前,我们需要先介绍两个重要的概念:

+   `*dynamoDB.AttributeValue`:这是一个结构类型,位于 dynamodb Go 包内。它表示 DynamoDB 项目属性值。

+   `dynamodbattribute`:这是一个位于 dynamodb 包下的子包。该包的文档可以在以下位置找到:

`https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/dynamodbattribute/`。该包负责在 Go 应用程序内部将 Go 类型与`dynamoDB.AttributeValues`之间进行转换。这提供了一种非常方便的方式,将我们应用程序内部的 Go 类型转换为可以被 dynamoDB 包方法理解的类型,反之亦然。`dynamodbattribute`可以利用 marshal 和 unmarshal 方法将切片、映射、结构甚至标量值转换为`dynamoDB.AttributeValues`。

我们将从现在开始利用`dynamoDB.AttributeValue`类型的强大功能,以及`dynamodbattribute`包来编写能够与 DynamoDB 一起工作的代码。

我们将要介绍的第一个`DatabaseHandler`接口方法是`AddEvent()`方法。该方法接受一个`Event`类型的参数,然后将其作为一个项目添加到数据库中的事件表中。在我们开始介绍方法的代码之前,我们需要先了解一下我们需要利用的 AWS SDK 组件:

+   `AddEvent()`将需要使用 AWS SDK 方法`PutItem()`

+   `PutItem()`方法接受一个`PutItemInput`类型的参数

+   `PutItemInput`需要两个信息来满足我们的目的——表名和我们想要添加的项目

+   `PutItemInput`类型的表名字段是*string 类型,而项目是`map[string]*AttributeValue`类型

+   为了将我们的 Go 类型 Event 转换为`map[string]*AttributeValue`,根据前面的观点,这是我们需要为`PutItemInput`使用的项目字段类型,我们可以利用一个名为`dynamodbattribute.MarshalMap()`的方法

还有一个重要的备注我们需要介绍;以下是我们的`Event`类型的样子:

```go
type Event struct {
  ID bson.ObjectId `bson:"_id"`
  Name string 
  Duration int
  StartDate int64
  EndDate int64
  Location Location
}
```

它包含了通常需要描述诸如音乐会之类的事件的所有关键信息。然而,在使用 DynamoDB 时,`Event`类型有一个问题——在 DynamoDB 世界中,关键字`Name`是一个保留关键字。这意味着如果我们保留结构体不变,我们将无法在查询中使用 Event 结构体的 Name 字段。幸运的是,`dynamodbattribute`包支持一个名为`dynamodbav`的结构标签,它允许我们用另一个名称掩盖结构字段名。这将允许我们在 Go 代码中使用结构字段 Name,但在 DynamoDB 中以不同的名称公开它。添加结构字段后,代码将如下所示:

```go
type Event struct {
  ID bson.ObjectId `bson:"_id"`
  Name string `dynamodbav:"EventName"`
  Duration int
  StartDate int64
  EndDate int64
  Location Location
}
```

在前面的代码中,我们利用了`dynamodbav`结构标签,将`Name`结构字段定义为与 DynamoDB 交互时的`EventName`。

太好了!现在,让我们看一下`AddEvent()`方法的代码:

```go
func (dynamoLayer *DynamoDBLayer) AddEvent(event persistence.Event) ([]byte, error) {
  av, err := dynamodbattribute.MarshalMap(event)
  if err != nil {
    return nil, err
  }
  _, err = dynamoLayer.service.PutItem(&dynamodb.PutItemInput{
    TableName: aws.String("events"),
    Item: av,
  })
  if err != nil {
    return nil, err
  }
  return []byte(event.ID), nil
}
```

前面代码的第一步是将事件对象编组为`map[string]*AttributeValue`。接下来是调用属于 DynamoDB 服务客户端的`PutItem()`方法。`PutItem`接受了前面讨论过的`PutItemInput`类型的参数,其中包含了我们想要添加的表名和编组的项目数据。最后,如果没有错误发生,我们将返回事件 ID 的字节表示。

我们需要讨论的下一个`DatabaseHandler`接口方法是`FindEvent()`。该方法通过其 ID 检索事件。请记住,当我们创建`events`表时,我们将 ID 属性设置为其键。以下是我们需要了解的一些要点,以了解即将到来的代码:

+   `FindEvent()`利用了一个名为`GetItem()`的 AWS SDK 方法。

+   `FindEvent()`接受`GetItemInput`类型的参数。

+   `GetItemInput`类型需要两个信息:表名和项目键的值。

+   `GetItem()`方法返回一个名为`GetItemOutput`的结构类型,其中有一个名为`Item`的字段。`Item`字段是我们检索的数据库表项目所在的位置。

+   从数据库中获取的项目将以`map[string]*AttributeValue`类型表示。然后,我们可以利用`dynamodbattribute.UnmarshalMap()`函数将其转换为`Event`类型。

代码最终将如下所示:

```go
func (dynamoLayer *DynamoDBLayer) FindEvent(id []byte) (persistence.Event, error) {
  //create a GetItemInput object with the information we need to search for our event via it's ID attribute
  input := &dynamodb.GetItemInput{
    Key: map[string]*dynamodb.AttributeValue{
      "ID": {
        B: id,
      },
    },
    TableName: aws.String("events"),
  }
  //Get the item via the GetItem method
  result, err := dynamoLayer.service.GetItem(input)
  if err != nil {
    return persistence.Event{}, err
  }
  //Utilize dynamodbattribute.UnmarshalMap to unmarshal the data retrieved into an Event object
  event := persistence.Event{}
  err = dynamodbattribute.UnmarshalMap(result.Item, &event)
  return event, err
}
```

请注意,在前面的代码中,`GetItemInput`结构体的`Key`字段是`map[string]*AttributeValue`类型。该映射的键是属性名称,在我们的情况下是`ID`,而该映射的值是`*AttributeValue`类型,如下所示:

```go
{
  B: id,
}
```

前面代码中的`B`是`AttributeValue`中的一个结构字段,表示二进制类型,而`id`只是传递给我们的`FindEvent()`方法的字节片参数。我们使用二进制类型字段的原因是因为我们的事件表的 ID 键属性是二进制类型。

现在让我们转到事件微服务的第三个`DatabaseHandler`接口方法,即`FindEventByName()`方法。该方法通过名称检索事件。请记住,当我们之前创建`events`表时,我们将`EventName`属性设置为二级索引。我们这样做的原因是因为我们希望能够通过事件名称从`events`表中查询项目。在我们开始讨论代码之前,这是我们需要了解的关于该方法的信息:

+   `FindEventByName()`利用了一个名为`Query()`的 AWS SDK 方法来查询数据库。

+   `Query()`方法接受`QueryInput`类型的参数,其中需要四个信息:

+   我们希望执行的查询,在我们的情况下,查询只是`EventName = :n`。

+   上述表达式中`:n`的值。这是一个参数,我们需要用要查找的事件的名称来填充它。

+   我们想要为我们的查询使用的索引名称。在我们的情况下,我们为 EventName 属性创建的二级索引被称为`EventName-index`。

+   我们想要运行查询的表名。

+   如果`Query()`方法成功,我们将得到我们的结果项作为 map 切片;结果项将是`[]map[string]*AttributeValue`类型。由于我们只寻找单个项目,我们可以直接检索该地图切片的第一个项目。

+   `Query()`方法返回一个`QueryOutput`结构类型的对象,其中包含一个名为`Items`的字段。`Items`字段是我们的查询结果集所在的地方。

+   然后,我们需要利用`dynamodbattribute.UnmarshalMap()`函数将`map[string]*AttributeValue`类型的项目转换为`Event`类型。

代码如下所示:

```go
func (dynamoLayer *DynamoDBLayer) FindEventByName(name string) (persistence.Event, error) {
  //Create the QueryInput type with the information we need to execute the query
  input := &dynamodb.QueryInput{
    KeyConditionExpression: aws.String("EventName = :n"),
    ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
      ":n": {
        S: aws.String(name),
      },
    },
    IndexName: aws.String("EventName-index"),
    TableName: aws.String("events"),
  }
  // Execute the query
  result, err := dynamoLayer.service.Query(input)
  if err != nil {
    return persistence.Event{}, err
  }
  //Obtain the first item from the result
  event := persistence.Event{}
  if len(result.Items) > 0 {
    err = dynamodbattribute.UnmarshalMap(result.Items[0], &event)
  } else {
    err = errors.New("No results found")
  }
  return event, err
}
```

DynamoDB 中的查询是一个重要的主题。我建议您阅读 AWS 文档,解释查询的工作原理,可以在[`docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html`](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html)找到。

我们将在本章讨论的最后一个`DatabaseHandler`接口方法是`FindAllAvailableEvents()`方法。这个方法检索 DynamoDB 中'events'表的所有项目。在深入代码之前,我们需要了解以下内容:

+   `FindAllAvailableEvents()`需要利用一个名为`Scan()`的 AWS SDK 方法。这个方法执行扫描操作。扫描操作可以简单地定义为遍历表中的每个项目或者二级索引中的每个项目的读取操作。

+   `Scan()`方法需要一个`ScanInput`结构类型的参数。

+   `ScanInput`类型需要知道表名才能执行扫描操作。

+   `Scan()`方法返回一个`ScanOutput`结构类型的对象。`ScanOutput`结构包含一个名为`Items`的字段,类型为`[]map[string]*AttributeValue`。这就是扫描操作的结果所在的地方。

+   `Items`结构字段可以通过`dynamodbattribute.UnmarshalListofMaps()`函数转换为`Event`类型的切片。

代码如下所示:

```go
func (dynamoLayer *DynamoDBLayer) FindAllAvailableEvents() ([]persistence.Event, error) {
  // Create the ScanInput object with the table name
  input := &dynamodb.ScanInput{
    TableName: aws.String("events"),
  }

  // Perform the scan operation
  result, err := dynamoLayer.service.Scan(input)
  if err != nil {
    return nil, err
  }

  // Obtain the results via the unmarshalListofMaps function
  events := []persistence.Event{}
  err = dynamodbattribute.UnmarshalListOfMaps(result.Items, &events)
  return events, err
}
```

关于扫描操作的一个重要说明是,由于在生产环境中,扫描操作可能返回大量结果,有时建议利用我们在前一章中提到的 AWS SDK 的分页功能来进行扫描。分页功能允许您的操作结果分页显示,然后您可以进行迭代。扫描分页可以通过`ScanPages()`方法执行。

# 摘要

在本章中,我们深入了解了 AWS 世界中一些最受欢迎的服务。到目前为止,我们已经掌握了足够的知识,可以构建能够利用 AWS 为云原生应用程序提供的一些关键功能的生产级 Go 应用程序。

在下一章中,我们将进一步学习构建 Go 云原生应用程序的知识,涵盖持续交付的主题。


# 第九章:持续交付

在之前的三章中,您了解了现代容器技术和云环境,如何从您的应用程序(或更准确地说,MyEvents 应用程序)创建容器映像,以及如何将它们部署到这些环境中。

在本章中,您将学习如何为您的应用程序采用**持续集成**(**CI**)和**持续交付**(**CD**)。CI 描述了一种实践,即您持续构建和验证您的软件项目(理想情况下,对软件的每一次更改都进行构建和验证)。CD 通过在非常短的发布周期内(在这种情况下,当然是进入云环境)不断部署您的应用程序来扩展这种方法。

这两种方法都需要高度自动化才能可靠地工作,涉及到应用程序的构建和部署过程。在之前的章节中,我们已经看过您如何使用容器技术部署您的应用程序。由于 Docker 和 Kubernetes 等技术很容易自动化,它们通常与 CD 非常好地集成。

在本章的过程中,您将学习如何为采用 CI 和 CD 设置您的项目(例如,通过设置适当的版本控制和依赖管理)。我们还将介绍一些流行的工具,您可以使用这些工具在应用程序代码更改时自动触发新的构建和发布。

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

+   在版本控制中管理 Go 项目

+   使用依赖捆绑进行可重复构建

+   使用 Travis CI 和/或 GitLab 自动构建您的应用程序

+   自动将您的应用程序部署到 Kubernetes 集群

# 设置您的项目

在实际为我们的项目实施持续交付之前,让我们先做一些准备工作。稍后,这些准备工作将使我们将要使用的工具更容易地以自动化的方式构建和部署您的应用程序。

# 设置版本控制

在自动构建您的应用程序之前,您需要一个存储应用程序源代码的地方。这通常是**版本控制系统**(**VCS**)的工作。通常情况下,使您能够进行持续交付的工具与版本控制系统紧密集成,例如,通过在源代码更改时触发应用程序的新构建和部署。

如果您还没有自己做过这个,那么您现在的第一步应该是将您现有的代码库放入 VCS 中。在本例中,我们将使用当前事实上的标准 VCS,即 Git。尽管还有许多其他版本控制系统,但 Git 是最广泛采用的;您会发现许多提供商和工具为您提供 Git 存储库作为托管服务或自托管。此外,许多(如果不是大多数)CD 工具都与 Git 集成。

在本章的其余部分,我们将假设您熟悉 Git 的基本工作原理。如果您希望了解如何使用 Git,我们推荐 Packt 出版的*Git: Mastering Version Control*一书,作者是*Ferdinando Santacroce 等人*。

我们还假设您有两个远程 Git 存储库可用,您可以将 Go 应用程序源代码和前端应用程序源代码推送到这些存储库。对于我们将要使用的第一个持续交付工具,我们将假设您的存储库托管在 GitHub 的以下 URL:

+   `git+ssh://git@github.com//myevents.git`

+   `git+ssh://git@github.com//myevents-frontend.git`

当然,实际的存储库 URL 将根据您的用户名而变化。在以下示例中,我们将始终使用``作为您的 GitHub 用户名的占位符,因此请记住在必要时用您的实际用户名替换它。

您可以通过在本地机器上设置一个本地的 Git 仓库来跟踪源代码的更改。要初始化一个新的 Git 仓库,请在 Go 项目的根目录中运行以下命令(通常在 GOPATH 目录中的`todo.com/myevents`):

```go
$ git init . 
```

这将设置一个新的 Git 存储库,但尚未将任何文件添加到版本控制中。在实际将任何文件添加到存储库之前,请配置一个`.gitignore`文件,以防止 Git 将您的编译文件添加到版本控制中:

```go
/eventservice/eventservice 
/bookingservice/bookingservice 
```

创建`.gitignore`文件后,运行以下命令将当前代码库添加到版本控制系统中:

```go
$ git add . 
$ git commit -m "Initial commit" 
```

接下来,使用`git remote`命令配置远程存储库,并使用`git push`推送您的源代码:

```go
$ git remote add origin ssh://git@github.com//myevents.git 
$ git push origin master 
```

拥有一个可工作的源代码存储库是构建持续集成/交付流水线的第一步。在接下来的步骤中,我们将配置 CI/CD 工具,以便在您将新代码推送到远程 Git 存储库的主分支时构建和部署您的应用程序。

使用相同的 Git 命令为您的前端应用程序创建一个新的 Git 存储库,并将其推送到 GitHub 上的远程存储库。

# 将您的依赖项放入 vendor 中

到目前为止,我们只是使用`go get`命令安装了 MyEvents 应用程序所需的 Go 库(例如`gopkg.in/mgo.v2`或`github.com/gorilla/mux`包)。尽管这对开发来说效果还不错,但使用`go get`安装依赖有一个显著的缺点,即每次在尚未下载的包上运行`go get`时,它将获取该库的最新版本(从技术上讲,是相应源代码库的最新*master*分支)。这可能会产生不好的后果;想象一下,您在某个时间点克隆了您的存储库,并使用`go get ./...`安装了所有依赖项。一周后,您重复这些步骤,但现在可能会得到完全不同版本的依赖项(积极维护和开发的库可能每天都会有数十个新的提交到其主分支)。如果其中一个更改改变了库的 API,这可能导致您的代码从一天到另一天无法再编译。

为了解决这个问题,Go 1.6 引入了**vendoring**的概念。使用 vendoring 允许您将项目所需的库复制到包内的`vendor/`目录中(因此,在我们的情况下,`todo.com/myevents/vendor/`将包含诸如`todo.com/myevents/vendor/github.com/gorilla/mux/`的目录)。在运行`go build`编译包时,`vendor/`目录中的库将优先于 GOPATH 中的库。然后,您可以简单地将`vendor/`目录与应用程序代码一起放入版本控制,并在克隆源代码存储库时进行可重复的构建。

当然,手动将库复制到包的`vendor/`目录中很快就变得乏味。通常,这项工作是由**依赖管理器**完成的。目前,Go 有多个依赖管理器,最流行的是**Godep**和**Glide**。这两者都是社区项目;一个官方的依赖管理器,简称为**dep**,目前正在开发中,并且已经被认为是安全的用于生产,但在撰写本书时,仍被指定为实验。

您可以在[`github.com/golang/dep`](https://github.com/golang/dep)找到有关 dep 的更多信息。

在这种情况下,我们将使用 Glide 填充我们应用程序的`vendor/`目录。首先,通过运行以下命令安装 Glide:

```go
$ curl https://glide.sh/get | sh 
```

这将在您的`$GOPATH/bin`目录中放置一个 glide 可执行文件。如果您想要全局使用 glide,可以将它从那里复制到您的路径中,如下所示:

```go
$ cp $GOPATH/bin/glide /usr/local/bin/glide 
```

Glide 的工作方式类似于您可能从其他编程语言中了解的包管理器(例如,Node.js 的 npm 或 PHP 的 Compose)。它通过从包目录中读取 `glide.yaml` 文件来操作。在此文件中,您声明应用程序的所有依赖项,并可以选择为 Glide 安装这些库提供特定版本。要从现有应用程序创建 `glide.yaml` 文件,请在包目录中运行 `glide init .` 命令:

```go
$ glide init . 
```

在初始化项目时,Glide 将检查应用程序使用的库,并尝试自动优化您的依赖声明。例如,如果 Glide 发现一个提供稳定版本(通常是 Git 标签)的库,它将提示您是否希望使用这些稳定版本的最新版本,而不是依赖项的(可能更不稳定)主分支。

运行 `glide init` 时,它将产生类似于此的输出:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d741911d-8bef-4db2-bcc7-34bad737e478.png)

`glide init` 命令将在应用程序的根目录中创建一个 `glide.yaml` 文件,其中声明了所有必需的依赖项。对于 MyEvents 应用程序,此文件应该类似于这样:

```go
package: todo.com/myevents 
import: 
- package: github.com/Shopify/sarama 
  version: ¹.11.0 
- package: github.com/aws/aws-sdk-go 
  version: ¹.8.17 
  subpackages: 
  - service/dynamodb 
- package: github.com/gorilla/handlers 
  version: ¹.2.0 
# ... 
```

`glide.yaml` 文件声明了您的项目需要哪些依赖项。创建此文件后,您可以运行 `glide update` 命令来实际解析声明的依赖项并将它们下载到您的 `vendor/` 目录中。

如前面的屏幕截图所示,`glide update` 不仅会将 `glide.yaml` 文件中声明的依赖项下载到 `vendor/` 目录中,还会下载它们的依赖项。最终,Glide 将递归下载应用程序的整个依赖树,并将其放在 `vendor/` 目录中。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/bff51f22-c24e-4569-8fd8-5f46b49ee7eb.png)

对于它下载的每个包,Glide 将精确的版本写入一个新文件 `glide.lock`(您可以通过打开它来查看此文件,但实际上不应手动编辑)。`glide.lock` 文件允许您通过运行 `glide install` 在任何以后的时间点重建这组精确的依赖项及其精确的版本。您可以通过删除您的 `vendor/` 目录然后运行 `glide install` 来验证此行为。

拥有 `vendor/` 目录和 Glide 配置文件会给您以下两个选项:

+   您可以将整个 `vendor/` 目录与实际应用程序文件一起放入版本控制。好处是,现在任何人都可以克隆您的存储库(在这种情况下,任何人都包括想要构建和部署您的代码的 CI/CD 工具),并且所有依赖项的确切所需版本都可以立即使用。这样,从头构建应用程序实际上只是一个 `git clone` 或 `go build` 命令。缺点是,您的源代码存储库会变得更大,可能需要更多的磁盘空间来存储,克隆需要更多的时间。

+   或者,您可以将 `glide.yaml` 和 `glide.lock` 文件放入版本控制,并通过将其添加到 `.gitignore` 文件中来排除 `vendor/` 目录。好处是,这样可以使您的存储库更小,克隆速度更快。但是,在克隆存储库后,用户现在需要显式运行 `glide install` 从互联网下载 `glide.lock` 文件中指定的依赖项。

这两个选项都可以很好地工作,因此最终这是个人口味的问题。由于存储库大小和磁盘空间在这些天很少被考虑,而且因为它使构建过程显着更容易,所以我个人偏好于将整个 `vendor/` 目录放入版本控制:

```go
$ git add vendor 
$ git commit -m"Add dependencies" 
$ git push 
```

这关注了我们的后端服务,但我们还需要考虑前端应用程序。由于我们在第五章中使用 npm 来安装我们的依赖项,大部分工作已经为我们完成。有趣的是,关于是否将依赖项放入版本控制的确切论点(在这种情况下,是`node_modules/`目录而不是`vendor/`)也适用于 npm。是的,就像 Go 的`vendor/`目录一样,我更喜欢将整个`node_modules/`目录放入版本控制中:

```go
$ git add node_modules 
$ git commit -m "Add dependencies" 
$ git push 
```

明确声明项目的依赖关系(包括使用的版本)是确保可重现构建的重要一步。根据您选择是否将依赖项包含在版本控制中,用户在克隆源代码存储库后要么直接获得整个应用程序源代码(包括依赖项),要么可以通过运行`glide install`或`npm install`来轻松重建它。

现在我们已经将项目放入版本控制,并明确声明了依赖关系,我们可以看一下一些最流行的 CI/CD 工具,您可以使用它们来持续构建和部署您的应用程序。

# 使用 Travis CI

**Travis CI**是一个持续集成的托管服务。它与 GitHub 紧密耦合(这就是为什么您实际上需要在 GitHub 上拥有一个 Git 存储库才能使用 Travis CI)。它对于开源项目是免费的,这与其良好的 GitHub 集成一起,使其成为许多热门项目的首选。对于构建私有 GitHub 项目,有一个付费使用模式。

Travis 构建的配置是通过一个名为`.travis.yml`的文件完成的,该文件需要存在于存储库的根级别。基本上,这个文件可以看起来像这样:

```go
language: go 
go: 
  - 1.6 
  - 1.7 
  - 1.8 
 - 1.9
env: 
  - CGO_ENABLED=0 

install: true 
script: 
  - go build 
```

`language`属性描述了您的项目所使用的编程语言。根据您在这里提供的语言,您将在构建环境中有不同的工具可用。`go`属性描述了应该为哪些 Go 版本构建您的应用程序。对于可能被多种用户在潜在非常不同的环境中使用的库来说,测试您的代码是否适用于多个 Go 版本尤为重要。`env`属性包含应该传递到构建环境中的环境变量。请注意,我们之前在第六章中使用过`CGO_ENABLED`环境变量,*在容器中部署您的应用程序*,来指示 Go 编译器生成静态链接的二进制文件。

`install`属性描述了设置应用程序依赖项所需的步骤。如果完全省略,Travis 将自动运行`go get ./...`来下载所有依赖项的最新版本(这正是我们不想要的)。`install: true`属性实际上指示 Travis 不执行任何设置依赖项的操作,这正是我们应该采取的方式,如果您的依赖项已经包含在您的源代码存储库中。

如果您决定不在版本控制中包含您的`vendor/`目录,则安装步骤需要包含 Travis 下载 Glide 并使用它来安装项目的依赖项的说明:

```go
install: 
  - go get -v github.com/Masterminds/glide 
  - glide install 
```

`script`属性包含 Travis 应该运行的命令,以实际构建您的项目。当然,构建您的应用程序的最明显的步骤是`go build`命令。当然,您可以在这里添加额外的步骤。例如,您可以使用`go vet`命令来检查您的源代码是否存在常见错误:

```go
scripts: 
  - go vet $(go list ./... | grep -v vendor)
 - cd eventservice && go build 
  - cd bookingservice && go build 
```

`$(go list ./... | grep -v vendor)`命令是一个特殊的技巧,用于指示`go vet`不要分析包目录中的`vendor/`源代码。否则,`go vet`可能会抱怨您的项目依赖项中的许多问题,您可能不想(甚至无法)修复。

创建`.travis.yml`文件后,将其添加到版本控制并将其推送到远程存储库:

```go
$ git add .travis.yml 
$ git commit -m "Configure Travis CI" 
$ git push 
```

现在您的存储库中有一个*.travis.yml*文件,您可以为该存储库启用 Travis 构建。为此,请使用 GitHub 凭据登录[`travis-ci.org`](https://travis-ci.org)(如果您打算使用付费版,则使用[`travis-ci.com`](https://travis-ci.com)),登录后,您将找到您的公开可用 GitHub 存储库列表,以及一个开关,允许您为每个存储库启用 Travis 构建(就像以下截图中一样):

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/16b2df49-adb2-4bc2-9640-7f1178f4f7d8.png)

继续启用`myevents`和`myevents-frontend`存储库(如果其中一个存储库中没有`.travis.yml`文件也没关系)。

在 Travis 用户界面中启用项目后,下一次对存储库的 Git 推送将自动触发 Travis 上的构建。您可以通过对代码进行小的更改或只是在某个地方添加一个新的空文本文件并将其推送到 GitHub 来测试这一点。在 Travis 用户界面中,您会很快注意到项目的新构建弹出。

构建将运行一段时间(从计划构建到实际执行可能需要一段时间)。之后,您将看到构建是否成功完成或是否发生错误(在后一种情况下,您还将通过电子邮件收到通知),如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/88e50e75-5a21-4d8c-905d-1f5e5d6d0187.png)

如果您已经指定了多个要测试的 Go 版本,您将注意到每个提交都有多个构建作业(就像前面的截图中一样)。单击其中任何一个以接收详细的构建输出。如果您的构建因任何原因失败(当您推送无法通过`go vet`或甚至无法编译的代码时,这是非常有用的)。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/4724ad2c-300e-4671-af09-0060ff769771.png)

总的来说,Travis 与 GitHub 集成得非常好。在 GitHub 用户界面中,您还将看到每个提交的当前构建状态,并且还可以使用 Travis 在将其合并到主分支之前验证拉取请求。

到目前为止,我们已经使用 Travis 来验证存储库中的代码是否不包含任何错误并且可以编译(这通常是持续集成的目标)。但是,我们还没有配置应用程序的实际部署。这就是我们接下来要做的事情。

在 Travis 构建中,您可以使用 Docker 构建和运行容器映像。要启用 Docker 支持,请将以下属性添加到您的`.travis.yml`文件的顶部:

```go
sudo: required 
services: 
  - docker 
language: go 
go: 
  - 1.9 
```

由于我们实际上不想为多个不同版本的 Go 构建 Docker 映像,因此完全可以从 Travis 文件中删除 Go 版本 1.6 到 1.8。

由于我们的项目实际上由两个部署构件(事件服务和预订服务)组成,我们可以进行另一个优化:我们可以使用构建矩阵并行构建这两个服务。为此,请将`env`属性添加到您的`.travis.yml`文件,并调整`script`属性,如下所示:

```go
sudo: required 
services: 
  - docker 
language: go 
go: 1.9 
env: 
  global: 
    - CGO_ENABLED=0 
  matrix: 
    - SERVICE=eventservice 
    - SERVICE=bookingservice
 install: true 
script: 
  - go vet $(go list ./... | grep -v vendor) 
  - cd $SERVICE && go build 
```

有了这个配置,Travis 将为代码存储库中的每次更改启动两个构建作业,其中每个构建一个包含在该存储库中的两个服务之一。

之后,您可以将`docker image build`命令添加到`script`属性中,以从编译的服务构建容器映像:

```go
script: 
  - go vet $(go list ./... | grep -v vendor) 
  - cd $SERVICE && go build 
  - docker image build -t myevents/$SERVICE:$TRAVIS_BRANCH $SERVICE 
```

上述命令构建了一个名为`myevents/eventservice`或`myevents/bookingservice`的 Docker 镜像(取决于当前`$SERVICE`的值)。Docker 镜像是使用当前分支(或 Git 标签)名称作为标记构建的。这意味着对*master*分支的新推送将导致构建一个`myevents/eventservice:master`镜像。当推送名为*v1.2.3*的 Git 标签时,将创建一个`myevents/eventservice:v1.2.3`镜像。

最后,您需要将新的 Docker 镜像推送到注册表。为此,请将一个新属性`after_success`添加到您的`.travis.yml`文件中:

```go
after_success: 
  - if [ -n "${TRAVIS_TAG}" ] ; then 
      docker login -u="${DOCKER_USERNAME}" -p="${DOCKER_PASSWORD}"; 
      docker push myevents/$SERVICE:$TRAVIS_BRANCH; 
    fi 
```

在`after_success`中指定的命令将在`scripts`中的所有命令成功完成后运行。在这种情况下,我们正在检查`$TRAVIS_TAG`环境变量的内容;因此,只有为 Git 标签构建的 Docker 镜像才会实际推送到远程注册表。

如果您使用的是与 Docker Hub 不同的 Docker 镜像注册表,请记住在`docker login`命令中指定注册表的 URL。例如,当使用`quay.io`作为注册表时,命令应如下所示:`docker login -u="${DOCKER_USERNAME}" -p="${DOCKER_PASSWORD}" quay.io`。

为了使此命令工作,您需要定义环境变量`$DOCKER_USERNAME`和`$DOCKER_PASSWORD`。理论上,您可以在`.travis.yml`文件的`env`部分中定义这些变量。但是,对于诸如密码之类的敏感数据,将它们定义在公开可用的文件中供所有人查看是一个非常愚蠢的想法。相反,您应该使用 Travis 用户界面为构建配置这些变量。为此,请转到项目的设置页面,您可以在项目概述页面上单击“更多选项”按钮时找到:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/66335f96-033a-4f38-bb3c-71714e5d2844.png)

在项目设置中,您将找到一个名为环境变量的部分。通过指定`DOCKER_USERNAME`和`DOCKER_PASSWORD`变量在这里配置您的 Docker 注册表凭据:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d2472861-3242-4f9d-9a2d-a8fa53dafc32.png)

或者,您可以通过加密后将秘密变量添加到您的`.travis.yml`文件中,然后将其放置在版本控制中。为此,您将需要 Travis 命令行客户端 CLI。Travis CLI 是一个 Ruby 工具,您可以通过 Ruby 软件包管理器`gem`安装。

```go
$ gem install travis
```

之后,您可以使用 Travis CLI 对变量进行加密,并自动将其添加到您的`.travis.yml`文件中:

```go
$ travis encrypt DOCKER_PASSWORD="my-super-secret-password" --add
```

这将向您的`.travis.yml`文件添加一个新变量,看起来像这样:

```go
...
env:
 global:
 - secure: 
```

通过 Travis UI 添加您的秘密变量以及对其进行加密并将其添加到您的`.travis.yml`文件中,这两种方法都是处理 Travis 构建中的敏感数据的有效方法。

将新的构建配置保存在`.travis.yml`中,并将其推送到 GitHub。要构建和发布新的 Docker 镜像,现在可以推送一个新的`git`标签:

```go
$ git tag v1.0.0 
$ git push --tags 
```

此时,Travis CI 将拉取您的代码,编译所有 Go 二进制文件,并为构建配置中配置的 Docker 注册表发布两个后端服务的 Docker 镜像。

我们仍然需要为前端应用程序添加类似的构建配置。实际上,构建 Docker 镜像的步骤完全相同;但是,我们需要运行 Webpack 模块打包程序而不是`go build`。以下是一个应该涵盖整个前端构建的`.travis.yml`文件:

```go
language: node_js 
node_js: 
  - 6 
env: 
  - SERVICE=frontend 
install: 
  - npm install -g webpack typescript 
  - npm install 
script: 
  - webpack 
after_success: 
  - if [ -n "${TRAVIS_TAG}" ] ; then 
    docker login -u="${DOCKER_USERNAME}" -p="${DOCKER_PASSWORD}"; 
    docker push myevents/${SERVICE}:${TRAVIS_BRANCH}; 
    fi 
```

# 部署到 Kubernetes

使用 GitHub 和 Travis,我们现在已经自动化了从更改应用程序源代码到构建新二进制文件再到创建新的 Docker 镜像并将其推送到容器注册表的整个工作流程。这很棒,但我们仍然缺少一个关键步骤,那就是在生产环境中运行新的容器映像。

在之前的章节中,您已经使用 Kubernetes 并将容器化应用部署到 Minikube 环境中。对于本节,我们将假设您已经拥有一个正在运行的公共可访问的 Kubernetes 环境(例如,使用 AWS 中的 `kops` 提供的集群或 Azure 容器服务)。

首先,Travis CI 需要访问您的 Kubernetes 集群。为此,您可以在 Kubernetes 集群中创建一个 **服务账户**。然后,该服务账户将收到一个 API 令牌,您可以在 Travis 构建中配置为秘密环境变量。要创建服务账户,请在本地机器上运行以下命令(假设您已经设置了 `kubectl` 以与 Kubernetes 集群通信):

```go
$ kubectl create serviceaccount travis-ci 
```

上述命令将创建一个名为 `travis-ci` 的新服务账户和一个包含该账户 API 令牌的新密钥对象。要确定密钥,现在运行 `kubectl describe serviceaccount travis-ci` 命令:

```go
$ kubectl describe serviceaccount travis-ci 
Name:        travis-ci 
Namespace:   default 
Labels:       
Annotations:  

Image pull secrets:  
Mountable secrets:  travis-ci-token-mtxrh 
Tokens:             travis-ci-token-mtxrh 
```

使用令牌密钥名称(在本例中为 `travis-ci-token-mtxrh`)来访问实际的 API 令牌:

```go
$ kubectl get secret travis-ci-token-mtxrh -o=yaml 
apiVersion: v1 
kind: Secret 
data: 
  ca.crt: ... 
  namespace: ZGVmYXVsdA== 
  token: ... 
# ... 
```

您将需要 `ca.crt` 和 `token` 属性。这两个值都是 BASE64 编码的,因此您需要通过 `base64 --decode` 管道传递这两个值来访问实际值:

```go
$ echo "" | base64 --decode 
$ echo "" | base64 --decode 
```

与 API 服务器的 URL 一起,这两个值可以用于从 Travis CI(或其他 CI/CD 工具)对 Kubernetes 集群进行身份验证。

要在 Travis CI 构建中实际配置 Kubernetes 部署,请从在 `install` 部分添加以下命令开始设置 `kubectl`:

```go
install: 
  - curl -LO https://storage.googleapis.com/kubernetes- 
release/release/v1.6.1/bin/linux/amd64/kubectl && chmod +x kubectl 
  - echo "${KUBE_CA_CERT}" > ./ca.crt 
  - ./kubectl config set-credentials travis-ci --token="${KUBE_TOKEN}" 
  - ./kubectl config set-cluster your-cluster --server=https://your-kubernetes-cluster --certificate-authority=ca.crt 
  - ./kubectl config set-context your-cluster --cluster=your-cluster --user=travis-ci --namespace=default 
  - ./kubectl config use-context your-cluster 
```

要使这些步骤生效,您需要在 Travis CI 设置中将环境变量 `$KUBE_CA_CERT` 和 `$KUBE_TOKEN` 配置为秘密环境变量,并使用从上述 `kubectl get secret` 命令中获取的值。

在配置了 `kubectl` 后,您现在可以将额外的步骤添加到您的项目的 `after_success` 命令中:

```go
after_success: 
  - if [ -n "${TRAVIS_TAG}" ] ; then 
    docker login -u="${DOCKER_USERNAME}" -p="${DOCKER_PASSWORD}"; 
    docker push myevents/${SERVICE}:$TRAVIS_BRANCH; 
    ./kubectl set image deployment/${SERVICE} api=myevents/${SERVICE}:${TRAVIS_BRANCH}; 
    fi 
```

`kubectl set image` 命令将更改应该用于给定 Deployment 对象的容器镜像(在本例中,假设您有名为 `eventservice` 和 `bookingservice` 的部署)。Kubernetes 部署控制器将继续使用新的容器镜像创建新的 Pod,并关闭运行旧镜像的 Pod。

# 使用 GitLab

GitHub 和 Travis 都是构建和部署开源项目(以及私有项目,如果您不介意为其服务付费)的优秀工具。然而,在某些情况下,您可能希望在自己的环境中托管源代码管理和 CI/CD 系统,而不是依赖外部服务提供商。

这就是 GitLab 发挥作用的地方。GitLab 是一种类似于 GitHub 和 Travis 组合的服务的软件(意味着源代码管理和 CI),您可以在自己的基础设施上托管。在接下来的部分中,我们将向您展示如何设置自己的 GitLab 实例,并构建一个类似于前一节中使用 GitLab 和其 CI 功能构建的构建和部署流水线。

GitLab 提供开源的 **社区版**(**CE**)和付费的 **企业版**(**EE**),提供一些额外的功能。对于我们的目的,CE 就足够了。

# 设置 GitLab

您可以使用供应商提供的 Docker 镜像轻松地设置自己的 GitLab 实例。要启动 GitLab CE 服务器,请运行以下命令:

```go
$ docker container run --detach \
  -e GITLAB_OMNIBUS_CONFIG="external_url 'http://192.168.2.125/';" \
  --name gitlab \
  -p 80:80 \
  -p 22:22 \
  gitlab/gitlab-ce:9.1.1-ce.0
```

注意传递到容器中的 `GITLAB_OMNIBUS_CONFIG` 环境变量。此变量可用于将配置代码(用 Ruby 编写)注入到容器中;在本例中,它用于配置 GitLab 实例的公共 HTTP 地址。在本地启动 GitLab 时,通常最容易使用您的机器的公共 IP 地址(在 Linux 或 macOS 上,使用 `ifconfig` 命令找到它)。

如果您要在服务器上为生产使用设置 GitLab(而不是在本地机器上进行实验),您可能希望为配置和存储库数据创建两个数据卷,然后可以在容器中使用。这将使您能够轻松地将 GitLab 安装升级到较新的版本:

```go
$ docker volume create gitlab-config
$ docker volume create gitlab-data
```

创建卷后,在`docker container run`命令中使用`-v gitlab-config:/etc/gitlab`和`-v gitlab-data:/var/opt/gitlab`标志,以实际为 Gitlab 实例使用这些卷。

在新创建的容器中运行的 GitLab 服务器可能需要几分钟才能完全启动。之后,您可以在`http://localhost`上访问您的 GitLab 实例:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c04abec5-6eee-4d39-b32c-cb97723094e1.png)

首次在浏览器中打开 GitLab 时,您将被提示为初始用户设置新密码。设置密码后,您可以使用用户名`root`和之前设置的密码登录。如果您正在设置 GitLab 的生产实例,下一步将是设置一个新用户,您可以使用该用户登录,而不是 root。出于演示目的,继续作为 root 进行工作也是可以的。

首次登录后,您将看到一个“开始”页面,您可以在该页面上创建新的组和新项目。GitLab 项目通常与 Git 源代码存储库相关联。为了为 MyEvents 应用程序设置 CI/CD 流水线,请继续创建两个名为`myevents`和`myevents-frontend`的新项目,如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/07777c0a-aa0a-4247-9772-06718a2f6731.png)

为了将代码推送到新的 GitLab 实例中,您需要提供用于身份验证的 SSH 公钥。为此,请点击右上角的用户图标,选择“设置”,然后选择 SSH 密钥选项卡。将您的 SSH 公钥粘贴到输入字段中并保存。

接下来,将您的新 GitLab 存储库添加为现有 MyEvents 存储库的远程,并推送您的代码:

```go
$ git remote add gitlab ssh://git@localhost/root/myevents.git 
$ git push gitlab master:master 
```

类似地进行前端应用程序的设置。之后,您将能够在 GitLab Web UI 中找到您的文件:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/92b3c8ba-ce38-4039-9d42-6c2ae24deb6c.png)

# 设置 GitLab CI

为了使用 GitLab 的 CI 功能,您需要设置一个额外的组件:GitLab CI Runner。虽然 GitLab 本身负责管理应用程序的源代码并决定何时触发新的 CI 构建,但 CI Runner 负责实际执行这些作业。将实际的 GitLab 容器与 CI Runner 分开允许您分发 CI 基础设施,并且例如在不同的机器上拥有多个 Runner。 

GitLab CI Runner 也可以使用 Docker 镜像进行设置。要设置 CI Runner,请运行以下命令:

```go
$ docker container run --detach \ 
    --name gitlab-runner \ 
    --link gitlab:gitlab \ 
    -v /var/run/docker.sock:/var/run/docker.sock \ 
    gitlab/gitlab-runner:v1.11.4 
```

启动 GitLab CI Runner 后,您需要在主 GitLab 实例上注册它。为此,您将需要 Runner 的注册令牌。您可以在 GitLab UI 的管理区域中找到此令牌。通过右上角的扳手图标访问管理区域,然后选择 Runners。您将在第一个文本段落中找到 Runner 的注册令牌:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/f25a2ddd-5d9c-476b-ae80-db15d56b0664.png)

要注册您的 Runner,请运行以下命令:

```go
$ docker container exec \ 
    -it gitlab-runner \ 
    gitlab-runner register -n \ 
      --url http://gitlab \ 
      --registration-token  \ 
      --executor docker \ 
      --docker-image ubuntu:16.04 \ 
      --docker-volumes /var/run/docker.sock:/var/run/docker.sock \
      --description "Gitlab CI Runner" 
```

此命令在主 GitLab 实例上注册先前启动的 GitLab CI Runner。`--url`标志配置了主 GitLab 实例的可访问 URL(通常情况下,当您的 runner 在与主 Gitlab 实例相同的容器网络上时,这可以是`http://gitlab`;或者,您可以在这里使用您主机的公共 IP 地址,我的情况下是`http://192.168.2.125/`)。接下来,复制并粘贴`--registration-token`标志的注册令牌。`--executor`标志配置 GitLab CI Runner 在自己的隔离 Docker 容器中运行每个构建作业。`--docker-image`标志配置默认情况下应该用作构建环境的 Docker 镜像。`--docker-volumes`标志确保您可以在构建中使用 Docker Engine(这一点尤为重要,因为我们将在这些构建中构建我们自己的 Docker 镜像)。

将`/var/run/docker.sock`套接字挂载到您的 Gitlab Runner 中,将您主机上运行的 Docker 引擎暴露给您的 CI 系统的用户。如果您不信任这些用户,这可能构成安全风险。或者,您可以设置一个新的 Docker 引擎,它本身运行在一个容器中(称为 Docker-in-Docker)。有关详细的设置说明,请参阅 GitLab 文档[`docs.gitlab.com/ce/ci/docker/using_docker_build.html#use-docker-in-docker-executor`](https://docs.gitlab.com/ce/ci/docker/using_docker_build.html#use-docker-in-docker-executor)。

`docker exec`命令应该产生类似于以下截图的输出:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/8e7c0c49-fc88-4de9-ae55-15cdf43c3c10.png)

成功注册 Runner 后,您应该能够在 GitLab 管理 UI 中找到它:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/59e6a201-ec4f-4f53-ba09-fef9f12f571c.png)

现在您已经有一个工作的 CI Runner,您可以开始配置实际的 CI 作业。与 Travis CI 类似,GitLab CI 作业是通过一个配置文件进行配置的,该文件放置在源代码存储库中。与已知的`.travis.yml`类似,该文件名为`.gitlab-ci.yml`。尽管它们的名称相似,但其格式略有不同。

每个 GitLab CI 配置由多个阶段组成(默认情况下为构建、测试和部署,尽管这是完全可定制的)。每个阶段可以包含任意数量的作业。所有阶段一起形成一个流水线。流水线中的每个作业都在自己隔离的 Docker 容器中运行。

让我们从 MyEvents 后端服务开始。在项目的根目录中放置一个新文件`.gitlab-ci.yml`:

```go
build:eventservice: 
  image: golang:1.9.2 
  stage: build 
  before_script: 
    - mkdir -p $GOPATH/src/todo.com 
    - ln -nfs $PWD $GOPATH/src/todo.com/myevents 
    - cd $GOPATH/src/todo.com/myevents/eventservice 
  script: 
    - CGO_ENABLED=0 go build 
  artifacts: 
    paths: 
      - ./eventservice/eventservice 
```

那么,这段代码实际上是做什么呢?首先,它指示 GitLab CI Runner 在基于`golang:1.9.2`镜像的 Docker 容器中启动此构建。这确保您在构建环境中可以访问最新的 Go SDK。`before_script`部分中的三个命令负责设置`$GOPATH`,`script`部分中的一个命令是实际的编译步骤。

请注意,此构建配置假定您的项目的所有依赖项都已在版本控制中进行了分发。如果您的项目中只有一个`glide.yaml`文件,那么在实际运行`go build`之前,您还需要设置 Glide 并运行`glide install`。

最后,artifacts 属性定义了由 Go `build`创建的`eventservice`可执行文件应作为构建 artifact 进行存档。这将允许用户稍后下载此构建 artifact。此外,该 artifact 将在同一流水线的后续作业中可用。

现在,将`.gitlab-ci.yml`文件添加到您的源代码存储库中,并将其推送到 GitLab 服务器:

```go
$ git add .gitlab-ci.yml 
$ git commit -m "Configure GitLab CI" 
$ git push gitlab 
```

当您推送配置文件后,转到 GitLab Web UI 中的项目页面,然后转到 Pipelines 选项卡。您将找到为您的项目启动的所有构建流水线的概述,以及它们的成功情况:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/33e32a1a-00f5-495b-9f43-8f986ea74847.png)

现在,我们的流水线只包括一个阶段(`build`)和一个作业(`build:eventservice`)。您可以在`Pipelines`概述的`Stages`列中看到这一点。要查看`build:eventservice`作业的确切输出,请单击流水线状态图标,然后单击`build:eventservice`作业:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/9c40d302-b4a0-4296-9cec-2e86e028362f.png)

接下来,我们可以扩展我们的`.gitlab-ci.yml`配置文件,以包括预订服务的构建:

```go
build:eventservice: # ... 

build:bookingservice: 
  image: golang:1.9.2 
  stage: build 
  before_script: 
    - mkdir -p $GOPATH/src/todo.com 
    - ln -nfs $PWD $GOPATH/src/todo.com/myevents 
    - cd $GOPATH/src/todo.com/myevents/bookingservice 
  script: 
    - CGO_ENABLED=0 go build 
  artifacts: 
    paths: 
      - ./bookingservice/bookingservice 
```

当您再次推送代码时,您会注意到为您的项目启动的下一个流水线由两个作业并行运行(更多或更少,取决于 GitLab CI Runner 的配置及其当前工作负载):

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/5a64c80c-9044-4845-86c9-aaee701680d0.png)

接下来,我们可以添加两个构建实际 Docker 镜像的作业。这些作业需要在已经配置的构建步骤之后执行,因为我们需要编译后的 Go 二进制文件来创建 Docker 镜像。因此,我们无法将 docker 构建步骤配置为在构建阶段运行(一个阶段内的所有作业是并行执行的,至少在潜在情况下,并且不能相互依赖)。因此,我们将首先重新配置项目的构建阶段。这也是在`.gitlab-ci.yml`文件中基于每个项目的基础上完成的:

```go
stages: 
  - build 
  - dockerbuild 
  - publish 
  - deploy 

build:eventservice: # ... 
```

接下来,我们可以在实际的构建作业中使用这些新的阶段:

```go
dockerbuild:eventservice: 
  image: docker:17.04.0-ce 
  stage: dockerbuild 
  dependencies: 
    - build:eventservice 
  script: 
    - docker container build -t myevents/eventservice:$CI_COMMIT_REF_NAME eventservice 
  only: 
    - tags 
```

`dependencies`属性声明了这一步需要先完成`build:eventservice`作业。它还使得该作业的构建产物在这个作业中可用。`script`只包含`docker container build`命令(`$CI_COMMIT_REF_NAME`),其中包含当前 Git 分支或标签的名称。`only`属性确保只有在推送新的 Git 标签时才构建 Docker 镜像。

为构建预订服务容器镜像添加相应的构建作业:

```go
dockerbuild:bookingservice: 
  image: docker:17.04.0-ce 
  stage: dockerbuild 
  dependencies: 
    - build:bookingservice 
  script: 
    - docker container build -t myevents/bookingservice:$CI_COMMIT_REF_NAME bookingservice 
  only: 
    - tags 
```

将修改后的`.gitlab-ci.yml`文件添加到版本控制中,并创建一个新的 Git 标签来测试新的构建流水线:

```go
$ git add .gitlab-ci.yml 
$ git commit -m"Configure Docker builds" 
$ git push gitlab 

$ git tag v1.0.1 
$ git push gitlab --tags 
```

在流水线概述中,您现在会找到四个构建作业:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/40656dbf-18bd-45c8-8bd6-88f06b83c95c.png)

构建 Docker 镜像后,我们现在可以添加第五个构建步骤,将创建的注册表发布到 Docker 注册表中:

```go
publish: 
  image: docker:17.04.0-ce 
  stage: publish 
  dependencies: 
    - dockerbuild:eventservice 
    - dockerbuild:bookingservice 
  before_script: 
    - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD} 
  script: 
    - docker push myevents/eventservice:${CI_COMMIT_REF_NAME} 
    - docker push myevents/bookingservice:${CI_COMMIT_REF_NAME} 
  only: 
    - tags 
```

与之前的 Travis CI 构建类似,这个构建作业依赖于环境变量`$DOCKER_USERNAME`和`$DOCKER_PASSWORD`。幸运的是,GitLab CI 提供了类似于 Travis CI 的秘密环境变量的功能。为此,在 GitLab web UI 中打开项目的设置选项卡,然后选择 CI/CD Pipelines 选项卡,搜索秘密变量部分:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/392d38cf-4057-4fdd-a622-4eb0882d6e35.png)

使用此功能配置您选择的容器注册表的凭据(如果您使用的是 Docker Hub 之外的注册表,请记得相应地调整前面构建作业中的`docker login`命令)。

最后,让我们为将应用程序实际部署到 Kubernetes 集群中添加最终的构建步骤:

```go
deploy: 
  image: alpine:3.5 
  stage: deploy 
  environment: production 
  before_script: 
    - apk add --update openssl 
    - wget -O /usr/local/bin/kubectl https://storage.googleapis.com/kubernetes- 
release/release/v1.6.1/bin/linux/amd64/kubectl && chmod +x /usr/local/bin/kubectl 
    - echo "${KUBE_CA_CERT}" > ./ca.crt 
    - kubectl config set-credentials gitlab-ci --token="${KUBE_TOKEN}" 
    - kubectl config set-cluster your-cluster --server=https://your-kubernetes-cluster.example --certificate-authority=ca.crt 
    - kubectl config set-context your-cluster --cluster=your-cluster --user=gitlab-ci --namespace=default 
    - kubectl config use-context your-cluster 
  script: 
    - kubectl set image deployment/eventservice api=myevents/eventservice:${CI_COMMIT_REF_NAME} 
    - kubectl set image deployment/bookingservice api=myevents/eventservice:${CI_COMMIT_REF_NAME} 
  only: 
    - tags 
```

这个构建步骤使用了`alpine:3.5`基础镜像(一个非常小的镜像大小的极简 Linux 发行版),其中我们首先下载,然后配置`kubectl`二进制文件。这些步骤与我们在前面部分配置的 Travis CI 部署类似,并且需要在 GitLab UI 中将环境变量`$KUBE_CA_CERT`和`$KUBE_TOKEN`配置为秘密变量。

请注意,在这个例子中,我们使用了一个名为`gitlab-ci`的 Kubernetes 服务账户(之前,我们创建了一个名为`travis-ci`的账户)。因此,为了使这个例子工作,您需要使用在前面部分已经使用过的命令创建一个额外的服务账户。

到目前为止,我们基于 GitLab 的构建和部署流水线已经完成。再次查看 GitLab UI 中的流水线视图,以充分了解我们的流水线:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/910c4c84-ff01-46bf-90e0-c10a53ca8d9d.png)

GitLab 的流水线功能几乎是实现复杂构建和部署流程的完美解决方案。而其他 CI/CD 工具会限制你只能使用一个环境进行单一构建作业,GitLab 的流水线允许你为构建的每个步骤使用一个隔离的环境,甚至在可能的情况下并行运行这些步骤。

# 总结

在本章中,你学会了如何轻松自动化应用程序的构建和部署工作流程。在微服务架构中,拥有自动化的部署工作流程尤为重要,因为你会经常部署许多不同的组件。如果没有自动化,部署复杂的分布式应用程序将变得越来越繁琐,并且会影响你的生产效率。

现在我们的应用部署问题已经解决(简而言之,容器+持续交付),我们可以将注意力转向其他事项。我们部署的应用程序在运行并不意味着它实际上在做它应该做的事情。这就是为什么我们需要监控在生产环境中运行的应用程序。监控能够让你在运行时跟踪应用程序的行为并快速发现错误,这就是为什么下一章的重点将放在监控你的应用程序上。


# 第十章:监控您的应用程序

在之前的章节中,您学习了如何使用 Go 编程语言构建微服务应用程序,以及如何(持续)将其部署到各种环境中。

然而,我们的工作还没有完成。当您在生产环境中运行应用程序时,您需要确保它保持运行并且表现出您作为开发人员预期的行为。这就是监控的作用。

在本章中,我们将向您介绍**Prometheus**,这是一款开源监控软件,它在监控基于云的分布式应用程序方面迅速赢得了人气。它通常与**Grafana**一起使用,后者是用于可视化 Prometheus 收集的指标数据的前端。这两个应用程序都是根据 Apache 许可证授权的。您将学习如何设置 Prometheus 和 Grafana,以及如何将它们集成到您自己的应用程序中。

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

+   安装和使用 Prometheus

+   安装 Grafana

+   从您自己的应用程序向 Prometheus 导出指标

# 设置 Prometheus 和 Grafana

在我们自己的应用程序中使用 Prometheus 和 Grafana 之前,让我们先看一下 Prometheus 的工作原理。

# Prometheus 的基础知识

与其他监控解决方案不同,Prometheus 通过定期从客户端拉取数据(在 Prometheus 行话中称为**指标**)来工作。这个过程称为**抓取**。被 Prometheus 监控的客户端必须实现一个 HTTP 端点,可以被 Prometheus 定期抓取(默认为 1 分钟)。然后,这些指标端点可以以预定义的格式返回特定于应用程序的指标。

例如,一个应用程序可以在`/metrics`上提供一个 HTTP 端点,响应`GET`请求并返回以下内容:

```go
memory_consumption_bytes 6168432 
http_requests_count{path="/events",method="get"} 241 
http_requests_count{path="/events",method="post"} 5 
http_requests_count{path="/events/:id",method="get"} 125 
```

此文档公开了两个指标——`memory_consumption_bytes`和`http_requests_count`。每个指标都与一个值相关联(例如,当前内存消耗为 6,168,432 字节)。由于 Prometheus 以固定间隔从您的应用程序抓取这些指标,它可以使用这些瞬时值来构建此指标的时间序列。

Prometheus 指标也可以有标签。在前面的示例中,您可能注意到`http_request_count`指标实际上具有不同组合的`path`和`method`标签的三个不同值。稍后,您将能够使用这些标签使用自定义查询语言**PromQL**从 Prometheus 查询数据。

应用程序导出到 Prometheus 的指标可能会变得非常复杂。例如,使用标签和不同的指标名称,客户端可以导出一个直方图,其中数据聚合在不同的桶中:

```go
http_request_duration_seconds_bucket{le="0.1"} 6835 
http_request_duration_seconds_bucket{le="0.5"} 79447 
http_request_duration_seconds_bucket{le="1"} 80700 
http_request_duration_seconds_bucket{le="+Inf"} 80953 
http_request_duration_seconds_sum 46135 
http_request_duration_seconds_count 80953 
```

前面的指标描述了您的应用程序的 HTTP 响应时间的直方图。在这种情况下,处理了 6,835 个响应时间小于 0.1 秒的请求;79,447 个响应时间小于 0.5 秒的请求(包括前面的 6,835 个请求);等等。最后两个指标导出了处理的 HTTP 请求总数和处理这些请求所需的时间总和。这两个值可以一起用于计算平均请求持续时间。

不用担心,您不需要自己构建这些复杂的直方图指标;这就是 Prometheus 客户端库的作用。然而,首先,让我们通过实际设置一个 Prometheus 实例来开始。

# 创建初始的 Prometheus 配置文件

在我们自己的应用程序中使用 Prometheus 和 Grafana 之前,我们需要先设置它。幸运的是,您可以在 Docker Hub 上找到这两个应用程序的 Docker 镜像。在启动我们自己的 Prometheus 容器之前,我们只需要创建一个配置文件,然后将其注入到容器中。

首先,在本地机器上创建一个新目录,并在其中放置一个新的`prometheus.yml`文件:

```go
global: 
  scrape_interval: 15s 

scrape_configs: 
  - job_name: prometheus 
    static_configs: 
      - targets: ["localhost:9090"] 
```

此配置定义了全局的抓取间隔为 15 秒(默认值为 1 分钟),并且已经配置了第一个抓取目标,即 Prometheus 本身(是的,您读对了;Prometheus 导出 Prometheus 指标,然后您可以使用 Prometheus 监控)。

稍后,我们将向`scape_configs`属性添加更多配置项。目前,这就足够了。

# 在 Docker 上运行 Prometheus

创建配置文件后,我们可以使用卷挂载将此配置文件注入我们即将启动的 Docker 容器中。

在此示例中,我们假设您在本地机器上的 Docker 容器中运行了 MyEvents 应用程序,并且这些容器连接到名为`myevents`的容器网络(无论您是手动创建容器还是通过 Docker Compose 创建都无关紧要)。

因此,启动这两个应用程序非常容易。我们将首先为监控组件定义一个单独的容器网络:

```go
$ docker network create monitoring 
```

接下来,创建一个新的卷,Prometheus 服务器可以在其中存储其数据:

```go
$ docker volume create prometheus-data 
```

现在,您可以使用新创建的网络和卷来创建一个 Prometheus 容器:

```go
$ docker container run \ 
    --name prometheus \ 
    --network monitoring \ 
    --network myevents \ 
    -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml 
    -v prometheus-data:/prometheus 
    -p 9090:9090 
    prom/prometheus:v1.6.1 
```

请注意,在上面的示例中,我们将`prometheus`容器连接到`myevents`和`monitoring`网络。这是因为稍后,Prometheus 服务器将需要通过网络访问 MyEvents 服务,以从中抓取指标。

启动 Prometheus 容器后,您可以通过在浏览器中导航到[`localhost:9090`](http://localhost:9090/)来打开 Prometheus Web UI:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/3ad455d1-5604-4c92-9d13-6137d85c1a60.png)

Prometheus Web UI

在我们的配置文件中,我们已经配置了第一个抓取目标——Prometheus 服务器本身。您可以通过选择“状态”菜单项,然后选择“目标”项来查看所有配置的抓取目标的概述:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/46a650c2-6df3-4475-bb76-3d038e7dd0fb.png)

在 Prometheus Web UI 中的目标项

如前面的截图所示,Prometheus 报告了抓取目标的当前状态(在本例中为 UP)以及上次抓取的时间。

您现在可以使用“图形”菜单项来检查 Prometheus 已经收集的有关自身的指标。在那里,将`go_memstats_alloc_bytes`输入到表达式输入字段中,然后单击“执行”。之后,切换到“图形”选项卡。Prometheus 现在将打印其过去 1 小时的内存使用情况。您可以使用图表上方的控件更改观察期。默认情况下,Prometheus 将保留其时间序列数据 2 周:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/c64c97ef-6549-4af0-807b-eea5ad923242.png)

Prometheus Web UI 图形

Prometheus 还支持更复杂的表达式。例如,考虑`process_cpu_seconds_total`指标。当将其显示为图形时,您会注意到它是单调递增的。这是因为该特定指标描述了程序在其整个生命周期内使用的所有 CPU 秒数的总和(根据定义,这必须始终是递增的)。然而,出于监控目的,了解进程的当前 CPU 使用情况通常更有趣。为此,PromQL 提供了`rate()`方法,用于计算时间序列的每秒平均增加量。尝试使用以下表达式:

```go
rate(process_cpu_seconds_total[1m]) 
```

在图形视图中,您现在将找到每秒的 1 分钟平均 CPU 使用率(这可能是一个比所有已使用的 CPU 秒数总和更易理解的指标):

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ae1cd1d5-19fd-4c3c-bf9c-5e27cd34d623.png)

Prometheus Web UI 非常适合快速分析和临时查询。但是,Prometheus 不支持保存查询以供以后使用,也不支持在同一页上呈现多个图形。这就是 Grafana 发挥作用的地方。

# 在 Docker 上运行 Grafana

运行 Grafana 与运行 Prometheus 一样简单。首先设置一个用于持久存储的卷:

```go
$ docker volume create grafana-data 
```

然后,启动实际容器并将其附加到`monitoring`网络(而不是`myevents`网络;Grafana 需要与 Prometheus 服务器通信,但不需要直接与您的后端服务通信):

```go
$ docker container run \ 
    -v grafana-data \ 
    -p 3000:3000 \ 
    --name grafana \ 
    --network monitoring \ 
    grafana/grafana:4.2.0 
```

之后,您将能够在浏览器中访问`http://localhost:3000`上的 Grafana。默认凭据是用户名`admin`和密码`admin`。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/2e0ae6ea-2552-419b-99ea-0f89751f2a43.png)

Gafana 主页

在您第一次访问时,您将被提示为 Grafana 实例配置数据源。单击“添加数据源”按钮,并在下一页配置访问您的 Prometheus 服务器。在那里,选择 Prometheus 作为*类型*,输入`http://prometheus:9090`作为 URL,并选择代理*作为*访问模式。

添加数据源后,继续创建仪表板(选择左上角的按钮,选择仪表板,然后选择新建)。然后,通过单击相应按钮向仪表板添加新图形。添加图形面板后,单击面板标题并选择编辑以编辑面板:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/92a7aafc-85bf-4350-9aeb-47c78c673dec.png)

面板

然后,在指标选项卡中,将之前的 CPU 使用率查询输入到查询输入字段中。为了进一步自定义面板,您可能希望输入`{{ job }}`作为图例,以使图例更易理解,并将 Y 轴格式(在轴选项卡,左 Y 部分和单位字段)更改为百分比(0.0-1.0):

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/0b35c2ab-936f-4eb0-b813-6503c27ca6d3.png)

Gafana 新仪表板

关闭编辑面板,并通过单击保存按钮或按*Ctrl* + *S*保存您的仪表板。您的仪表板现在已保存。您可以在以后的时间点查看它,其中包括更新的指标,或与其他用户共享此仪表板。

您还可以通过向仪表板添加更多面板来进行实验,以可视化其他指标(默认情况下,Prometheus 已经导出了大量关于自身的指标,您可以进行实验)。有关 Prometheus 查询语言的详细参考,请参阅以下网址的官方文档:[`prometheus.io/docs/querying/basics/`](https://prometheus.io/docs/querying/basics/)。

现在我们已经有了一个正常运行的 Prometheus 和 Grafana 设置,我们可以看看如何将您自己的应用程序的指标导入到 Prometheus 中。

# 导出指标

如已经显示的那样,从您自己的应用程序导出指标在原则上是很容易的。您的应用程序只需要提供一个返回任意指标的 HTTP 端点,然后可以将这些指标保存在 Prometheus 中。实际上,这变得更加困难,特别是当您关心 Go 运行时的状态时(例如,CPU 和内存使用情况,Goroutine 计数等)。因此,通常最好使用 Go 的 Prometheus 客户端库,该库负责收集所有可能的 Go 运行时指标。

事实上,Prometheus 本身是用 Go 编写的,并且还使用自己的客户端库来导出有关 Go 运行时的指标(例如,您之前使用过的`go_memstats_alloc_bytes`或`process_cpu_seconds_total`指标)。

# 在您的 Go 应用程序中使用 Prometheus 客户端

您可以使用`go get`获取 Prometheus 客户端库,如下所示:

```go
$ go get -u github.com/prometheus/client_golang 
```

如果您的应用程序使用依赖管理工具(例如我们在前一章中介绍的 Glide),您可能还希望在您的`glide.yaml`文件中声明此新依赖项,并将稳定版本添加到应用程序的`vendor/`目录中。要一次完成所有这些操作,只需在应用程序目录中运行`glide get`而不是`go get`:

```go
$ glide get github.com/prometheus/client_golang 
$ glide update 
```

出于安全原因,我们将在与事件服务和预订服务的 REST API 不同的 TCP 端口上公开我们的指标 API。否则,意外地将指标 API 暴露给外部世界将太容易了。

让我们从事件服务开始。设置指标 API 不需要太多的代码,所以我们将直接在`main.go`文件中进行。在调用`rest.ServeAPI`方法之前,将以下代码添加到主函数中:

```go
import "net/http" 
import "github.com/prometheus/client_golang/prometheus/promhttp" 
// ... 

func main() { 
  // ... 

  go func() { 
    fmt.Println("Serving metrics API") 

    h := http.NewServeMux() 
    h.Handle("/metrics", promhttp.Handler()) 

    http.ListenAndServe(":9100", h) 
  }() 

  fmt.Println("Serving API") 
  // ... 
} 
```

现在,编译您的应用程序并运行它。尝试在浏览器中打开地址`http://localhost:9100/metrics`,您应该会看到新端点返回大量的指标:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/ecb16ed5-5998-4cd1-a67a-0f501e2c0952.png)

在 localhost:9100/metrics 显示的页面

现在,对预订服务进行相同的调整。还要记得在两个服务的 Dockerfile 中添加`EXPOSE 9100`语句,并使用更新后的镜像和`-p 9100:9100`标志(或`-p 9101:9100`以防止端口冲突)重新创建任何容器。

# 配置 Prometheus 抓取目标

现在我们有两个正在运行并公开 Prometheus 指标的服务,我们可以配置 Prometheus 来抓取这些服务。为此,我们可以修改之前创建的`prometheus.yml`文件。将以下部分添加到`scrape_configs`属性中:

```go
global: 
  scrape_interval: 15s 

scrape_configs: 
  - job_name: prometheus 
    static_configs: 
      - targets: ["localhost:9090"] 
  - job_name: eventservice 
    static_configs: 
      - targets: ["events:9090"] 
  - job_name: bookingservice 
    static_configs: 
      - targets: ["bookings:9090"] 
```

添加新的抓取目标后,通过运行`docker container restart prometheus`来重新启动 Prometheus 容器。之后,这两个新的抓取目标应该会显示在 Prometheus web UI 中:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/9c508298-984f-4e2a-8280-ce15c5b80974.png)

Prometheus web UI targets

现在,最好的部分——还记得之前几节创建的 Grafana 仪表板吗?现在您已经添加了两个新服务以供 Prometheus 抓取,再看一下它:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/fbf352c1-0262-4a68-a2ef-12daf019ee5e.png)

Gafana

正如您所看到的,Grafana 和 Prometheus 立即从新服务中获取指标。这是因为我们到目前为止使用的`process_cpu_seconds_total`和`go_memstats_alloc_bytes`指标实际上是由我们的三个服务中的所有服务导出的,因为它们都使用 Prometheus Go 客户端库。但是,Prometheus 为每个被抓取的指标添加了一个额外的作业标签;这允许 Prometheus 和 Grafana 区分来自不同抓取目标的相同指标并相应地呈现它们。

# 导出自定义指标

当然,您也可以使用 Prometheus 客户端库导出自己的指标。这些不需要是反映 Go 运行时某些方面的技术指标(如 CPU 使用率和内存分配),而可以是业务指标。一个可能的例子是每个事件的不同标签的预订票数。

例如,在`todo.com/myevents/bookingservice/rest`包中,您可以添加一个新文件——让我们称之为`metrics.go`*——*声明并注册一个新的 Prometheus 指标:

```go
package rest 

import "github.com/prometheus/client_golang/prometheus" 

var bookingCount = prometheus.NewCounterVec( 
  prometheus.CounterOpts{ 
    Name:      "bookings_count", 
    Namespace: "myevents", 
    Help:      "Amount of booked tickets", 
  }, 
  []string{"eventID", "eventName"}, 
) 

func init() { 
  prometheus.MustRegister(bookingCount) 
} 
```

Prometheus 客户端库在一个包中跟踪所有创建的指标对象,这是一个全局注册表,会自动初始化。通过调用`prometheus.MustRegister`函数,您可以将新的指标添加到此注册表中。当 Prometheus 服务器抓取`/metrics`端点时,所有注册的指标将自动暴露出来。

`NewCounterVec`函数创建了一个名为`myevents_bookings_count`的指标集合,但通过两个标签`eventID`和`eventName`进行区分(实际上,这些是功能相关的,您不需要两者都需要;但在 Grafana 中可视化此指标时,将事件名称作为标签非常方便)。当抓取时,这些指标可能看起来像这样:

```go
myevents_bookings_count{eventID="507...",eventName="Foo"} 251 
myevents_bookings_count{eventID="508...",eventName="Bar} 51 
```

Prometheus 客户端库知道不同类型的指标。我们在前面的代码中使用的 Counter 是其中较简单的一种。在之前的某个部分中,您看到了一个复杂的直方图是如何表示为多个不同的指标的。这在 Prometheus 客户端库中也是可能的。为了演示,让我们添加另一个指标——这次是一个直方图:

```go
var seatsPerBooking = prometheus.NewHistogram( 
  prometheus.HistogramOpts{ 
    Name: "seats_per_booking", 
    Namespace: "myevents", 
    Help: "Amount of seats per booking", 
    Buckets: []float64{1,2,3,4} 
  } 
) 

func init() { 
  prometheus.MustRegister(bookingCount) 
  prometheus.MustRegister(seatsPerBooking) 
} 
```

在被抓取时,此直方图将导出为七个单独的指标:您将获得五个直方图桶(*具有一个或更少座位的预订数量* 到*具有四个或更少座位* 和*具有无限多座位或更少*),以及一个用于所有座位和所有观察的总和的指标:

```go
myevents_seats_per_booking_bucket{le="1"} 1 
myevents_seats_per_booking_bucket{le="2"} 8 
myevents_seats_per_booking_bucket{le="3"} 18 
myevents_seats_per_booking_bucket{le="4"} 20 
myevents_seats_per_booking_bucket{le="+Inf"} 22 
myevents_seats_per_booking_sum 72 
myevents_seats_per_booking_count 22 
```

当然,我们需要告诉 Prometheus 库在被 Prometheus 服务器抓取时应该导出哪些指标值。由于这两个指标(预订数量和每个预订的座位数量)只有在进行新预订时才会改变,因此我们可以将此代码添加到处理`/events/{id}/bookings`路由上的 POST 请求的 REST 处理程序函数中。

在`booking_create.go`文件中,在原始请求处理后的某个位置添加以下代码(例如,在事件发射器上发出`EventBooked`事件之后):

```go
h.eventEmitter.emit(&msg) 

bookingCount. 
  WithLabelValues(eventID, event.Name). 
  Add(float64(request.Seats)) 
seatsPerBooking. 
  Observe(float64(bookingRequest.Seats)) 

h.database.AddBookingForUser(
   // ... 
```

第一条语句将预订的座位数量(`request.Seats`)添加到计数器指标中。由于在`CounterVec`声明中定义了一个名为`event`的标签,因此您需要使用相应的标签值调用`WithLabelValues`方法(如果指标声明包含两个标签,则需要将两个参数传递给`WithLabelValues`)。

第二条语句向直方图添加了一个新的`observation`。它将自动找到正确的桶并将其增加一个(例如,如果使用相同预订添加了三个座位,则`myevents_seats_per_booking_bucket{le="3"}`指标将增加一个)。

现在,启动您的应用程序,并确保 Prometheus 定期对其进行抓取。花点时间向您的应用程序添加一些示例记录。还在预订服务中添加一些事件预订;确保您不是一次创建它们。之后,您可以使用`myevents_bookings_count`指标在 Grafana 仪表板中创建一个新图表:

>![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/a6a1c84f-56d9-4b27-9cab-92b427f8b42a.png)

Gafana 图表

默认情况下,Prometheus 将为每个抓取实例创建一个时间序列。这意味着当您有多个预订服务实例时,您将获得多个时间序列,每个时间序列都有不同的`job`标签:

```go
myevents_bookings_count{eventName="Foo",job="bookingservice-0"} 1 
myevents_bookings_count{eventName="Foo",job="bookingservice-1"} 3 
myevents_bookings_count{eventName="Bar",job="bookingservice-0"} 2 
myevents_bookings_count{eventName="Bar",job="bookingservice-1"} 1 
```

在显示业务指标(例如,售出的门票数量)时,您可能实际上并不关心每个特定预订是在哪个实例上放置的,并且更喜欢在所有实例上使用聚合时间序列。为此,构建仪表板时可以使用 PromQL 函数`sum()`:

```go
sum(myevents_bookings_count) by (eventName) 
```

# 在 Kubernetes 上运行 Prometheus

到目前为止,我们通过将它们添加到`prometheus.yml`配置文件中手动配置了 Prometheus 的所有抓取目标。这对于测试很有效,但在更大的生产设置中很快变得乏味(并且在引入自动缩放等功能后完全没有意义)。

在 Kubernetes 集群中运行应用程序时,Prometheus 为此提供了一种一站式解决方案——使用`prometheus.yml`配置文件,您实际上可以配置 Prometheus 自动从 Kubernetes API 加载其抓取目标。例如,如果为您的预订服务定义了一个部署,Prometheus 可以自动找到由此部署管理的所有 Pod,并对它们进行抓取。如果扩展了部署,附加实例将自动添加到 Prometheus 中。

在以下示例中,我们将假设您在本地计算机上运行 Minikube VM 或在云环境中的某个 Kubernetes 集群。我们将首先部署 Prometheus 服务器。为了管理 Prometheus 配置文件,我们将使用一个以前未使用过的 Kubernetes 资源——`ConfigMap`。`ConfigMap`基本上只是一个您可以保存在 Kubernetes 中的任意键值映射。在创建 Pod(或部署或 StatefulSet)时,您可以将这些值挂载到容器中作为文件,这使得`ConfigMaps`非常适合管理配置文件:

```go
apiVersion: v1 
kind: ConfigMap 
name: prometheus-config 
data: 
  prometheus.yml: | 
    global: 
      scrape_config: 15s 

    scrape_configs: 
    - job_name: prometheus 
      static_configs: 
      - targets: ["localhost:9090"] 
```

您可以像保存其他资源一样创建`ConfigMap`,将其保存到`.yaml`文件中,然后在该文件上调用`kubectl apply -f`。当您修改了`.yaml`文件时,也可以使用相同的命令来更新`ConfigMap`。

创建了`ConfigMap`后,让我们部署实际的 Prometheus 服务器。由于 Prometheus 是一个有状态的应用程序,我们将其部署为`StatefulSet`:

```go
apiVersion: apps/v1beta1 
kind: StatefulSet 
metadata: 
  name: prometheus 
spec: 
  serviceName: prometheus 
  replicas: 1 
  template: 
    metadata: 
      labels: 
        app: prometheus 
    spec: 
      containers: 
      - name: prometheus 
        image: prom/prometheus:v1.6.1 
        ports: 
        - containerPort: 9090 
          name: http 
        volumeMounts: 
        - name: data 
          mountPath: /prometheus 
        - name: config 
          mountPath: /etc/prometheus 
      volumes: 
      - name: config 
        configMap: 
          name: prometheus-config 
  volumeClaimTemplates: 
  - metadata: 
      name: data 
      annotations: 
        volume.alpha.kubernetes.io/storage-class: standard 
    spec: 
      accessModes: ["ReadWriteOnce"] 
      resources: 
        requests: 
          storage: 5Gi 
```

还要创建相关的`Service`:

```go
apiVersion: v1 
kind: Service 
metadata: 
  name: prometheus 
spec: 
  clusterIP: None 
  selector: 
    app: prometheus 
  ports: 
  - port: 9090 
    name: http 
```

现在,您在 Kubernetes 集群内运行了一个 Prometheus 服务器;但是,目前该服务器只抓取自己的指标端点,而尚未抓取集群中运行的任何其他 Pod。

要启用对 Pod 的自动抓取,请将以下部分添加到`prometheus.yml`文件的`ConfigMap`中的`scrape_configs`部分:

```go
scrape_configs: 
  # ... 
  - job_name: kubernetes-pods 
    kubernetes_sd_configs: 
    - role: pod 
  relabel_configs: 
  - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] 
    action: keep 
    regex: true 
  - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] 
    action: replace 
    target_label: __metrics_path__ 
    regex: (.+) 
  - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] 
    action: replace 
    regex: ([^:]+)(?::\d+)?;(\d+) 
    replacement: $1:$2 
    target_label: __address__ 
  - action: labelmap 
    regex: __meta_kubernetes_pod_label_(.+) 
  - source_labels: [__meta_kubernetes_namespace] 
    action: replace 
    target_label: kubernetes_namespace 
  - source_labels: [__meta_kubernetes_pod_name] 
    action: replace 
    target_label: kubernetes_pod_name 
```

是的,这是相当多的配置,但不要惊慌。大多数这些配置是为了将已知 Kubernetes Pod 的属性(例如用户定义的 Pod 名称和标签)映射到将附加到从这些 Pod 中抓取的所有指标的 Prometheus 标签。

请注意,在更新`ConfigMap`后,您可能需要销毁您的 Prometheus Pod,以使更新后的配置生效。不用担心;即使您删除了 Pod,`StatefulSet`控制器也会立即创建一个新的:

```go
$ kubectl delete pod -l app=prometheus 
```

此配置还定义了 Prometheus 将抓取集群中具有名为`prometheus.io/scrape`的注释的所有 Pod。在定义 Pod 模板时可以设置此注释,例如在部署中。此外,您现在可以调整您的事件服务部署如下(记得将 TCP 端口`9100`添加到暴露端口列表中):

```go
apiVersion: apps/v1beta1 
kind: Deployment 
metadata: 
  name: eventservice 
spec: 
  replicas: 2 
  template: 
    metadata: 
      labels: 
        myevents/app: events 
        myevents/tier: api 
      annotations: 
        prometheus.io/scrape: true 
        prometheus.io/port: 9100 
    spec: 
      containers: 
      - name: api 
        image: myevents/eventservice 
        imagePullPolicy: Never 
        ports: 
        - containerPort: 8181 
          name: http 
        - containerPort: 9100 
          name: metrics 
        # ... 
```

更新部署后,Kubernetes 应该会自动开始重新创建事件服务 Pod。一旦创建了带有`prometheus.io/scrape`注释的新 Pod,Prometheus 将自动捕获并抓取它们的指标。如果它们再次被删除(例如在更新或缩减部署后),Prometheus 将保留从这些 Pod 中收集的指标,但停止抓取它们。

通过让 Prometheus 根据注释自动捕获新的抓取目标,管理 Prometheus 服务器变得非常容易;在初始设置之后,您可能不需要再次编辑配置文件。

# 总结

在本章中,您学习了如何使用 Prometheus 和 Grafana 来设置监控堆栈,以监视应用程序在技术层面上的健康状况(通过关注系统指标,如 RAM 和 CPU 使用情况)以及自定义的应用程序特定指标,例如,在这种情况下,预订票数的数量。

在本书的过程中,我们几乎涵盖了典型 Go 云应用程序的整个生命周期,从架构和实际编程开始,构建容器映像,不断在各种云环境中部署它们,并监视您的应用程序。

在接下来的章节中,我们将有机会详细回顾我们迄今为止取得的成就,并指出接下来要做什么。


# 第十一章:迁移

欢迎来到我们学习云原生编程和 Go 语言世界的第十一章。在本章中,我们将涵盖一些实用的技术,以将应用程序从单片架构迁移到微服务架构。我们已经在第二章中涵盖了单片和微服务架构,*使用 Rest API 构建微服务。*但是,我们将从实际定义单片和微服务架构开始本章,以防您单独阅读本章。

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

+   单片应用程序和微服务架构的回顾

+   从单片应用程序迁移到微服务应用程序的技术

+   高级微服务设计模式

+   微服务架构中的数据一致性

# 什么是单片应用程序?

**单片应用程序**只是一个软件,可以同时执行多个独立的任务。让我们以在线商店应用程序为例。在单片架构中,我们将有一个单一的软件来处理客户、他们的订单、数据库连接、网站、库存以及在线商店成功所需的任何其他任务。

一个软件执行所有任务似乎是软件设计的一种低效方法,在某些情况下确实如此。然而,重要的是要提到,单片应用程序并不总是不好的。在一些情况下,一个单一的软件服务执行所有工作是可以接受的。这包括最小可行产品或 MVP,我们试图快速构建一些东西供测试用户尝试。这还包括预期没有太多数据负载或流量的使用情况,比如面向传统棋盘游戏爱好者的在线商店。

# 什么是微服务?

**微服务架构**与单片应用程序相比,构建软件采用了不同的方法。在微服务架构中,任务分布在多个较小的软件服务中,这些服务被称为微服务。在设计良好的微服务架构中,每个微服务应该是自包含的、可部署的和可扩展的。设计良好的微服务还享有干净的 API,允许其他微服务与它们通信。独立的软件服务共同努力实现共同目标的概念并不新鲜;它在过去作为**面向服务的架构**(**SOA**)存在。然而,现代微服务架构通过坚持软件服务相对较小、独立和完全自包含的概念,将这个想法推向了更远。

让我们回到在线商店的例子。在微服务架构的情况下,我们会有一个用于处理客户的微服务,一个用于处理库存的微服务,依此类推。

典型的微服务内部包含多个必要的层,用于处理日志记录、配置、与其他微服务通信的 API 以及持久性。还有微服务的核心代码,涵盖了服务应该执行的主要任务。以下是微服务内部应该看起来的样子:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/0b75db61-3b04-471c-a000-60aa8f4b3a33.png)

微服务的内部外观

当涉及可伸缩性和灵活性时,微服务架构比单片应用程序具有重大优势。微服务允许您无限扩展,利用多种编程语言的功能,并优雅地容忍故障。

# 从单片应用程序迁移到微服务

现在,假设你有一个单片应用,你的业务正在增长,你的客户要求更多功能,你需要迁移到既灵活又可扩展的架构。是时候使用微服务了。迁移时需要牢记的第一个关键原则是,没有一套黄金步骤可以确保从单片应用成功迁移到微服务。我们需要遵循的步骤因情况而异,因组织而异。话虽如此,本章中有一些非常有用的概念和想法,可以帮助您做出明智的决策。

# 人与技术

从单片应用转向微服务时最容易被忽视的因素之一是**人员因素**。我们通常考虑技术和架构,但是谁来编写代码、管理项目和重新设计应用的团队呢?从单片应用转向微服务是一个需要在组织中进行适当规划的范式转变。

在决定转向微服务后,我们需要考虑的第一件事是参与开发过程的团队结构。通常,以下是负责单片应用的团队:

+   开发人员习惯于在单一编程语言中工作的特定部分的应用中工作

+   IT 基础设施团队通常只需更新托管单片应用及其数据库的少数服务器,部署就完成了。

+   团队负责人拥有应用的一部分,而不是从 A 到 Z 的整个软件服务

如前所述,微服务迁移代表了一种范式转变。这意味着在转向微服务架构时,组织需要采用一种新的思维方式。考虑以下内容:

+   开发人员需要分成较小的团队,每个团队应负责一个或多个微服务。开发人员需要习惯于负责整个软件服务,而不是一堆软件模块或类。当然,如果组织足够大,你仍然可以让开发人员负责微服务中的特定模块。然而,如果开发人员接受培训,将产品视为整个微服务,这将产生更好设计的微服务。开发人员还需要习惯于使用适合工作的编程语言。例如,Java 对于数据处理和流水线很重要,Go 非常适合构建快速可靠的微服务,C#适用于 Windows 服务,等等。

+   IT 基础设施团队需要了解水平扩展、冗余、可扩展的云平台以及部署大量服务所涉及的规划过程。

+   团队负责人将承担从 A 到 Z 的整个软件服务的责任。他们需要考虑实施细节,比如如何扩展服务、是否与其他服务共享数据库或拥有自己的数据库,以及服务如何与其他服务通信。

# 将单片应用切割成片

现在我们已经讨论了迁移的人员方面,让我们深入了解技术细节。几乎每个人都同意的一个黄金法则是,从头开始编写所有内容,忽略现有单片应用中的所有代码(也称为大爆炸重写)并不是一个好主意。相反,从单片应用迁移到微服务的最佳方法是随着时间的推移逐步削减单片应用。每个分离的部分都成为一个微服务。对于每个新的微服务,我们需要确保它仍然可以与单片应用以及其他新的微服务进行通信。如果这种方法进行顺利,单片应用将随着时间的推移不断缩小,直到成为一个微服务。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/78cdb5a4-136f-4f38-991d-f5659b14fdc3.png)

单片应用随时间缩小

这听起来很简单;然而,在现实生活中,通常并不是那么直截了当。让我们讨论一些规划策略,使逐步逐步的方法更具可执行性。

# 我们如何分解代码?

我们需要问的一个关键技术问题是,我们应该如何精确地分解单片应用的代码?以下是一些重要的要点:

+   如果一个应用程序编写得很好,不同类或软件模块之间会有清晰明显的分离。这使得切割代码变得更容易。

+   另一方面,如果代码中没有清晰的分离,我们需要在开始将代码片段移动到新的微服务之前对现有代码进行一些重构。

+   通常最好的做法是,不要在单片应用中添加新的代码或功能,而是尝试将新功能分离成一个新的微服务。

# 粘合代码

为了使新的微服务适应原始应用而不破坏其功能,微服务需要能够与原始应用交换信息。为了实现这一点,我们可能需要编写一些粘合代码,将新代码与旧代码链接起来。粘合代码通常包括一些 API 接口,作为原始应用和微服务之间的通信渠道。粘合代码还将包括使新的微服务与现有应用程序配合工作所需的任何代码:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/d6662e58-ee72-4ea8-92ed-ae2c223656de.png)

粘合代码

粘合代码可能是临时的,也可能是永久的,这取决于我们的应用程序。有时,粘合代码可能需要进行一些数据建模转换或与旧数据库进行通信以使事情正常运行。

如果您的应用程序是一个 Web 应用程序,粘合代码可能包括一个临时的 Web HTTP API,可以将您新分离的微服务与您的视图层连接起来。

# 微服务设计模式

在本节中,我们将讨论一些重要的设计模式和架构方法,这些方法可以帮助我们构建强大而有效的云就绪微服务。让我们开始吧。

# 牺牲性架构

**牺牲性架构**是一个重要的设计方法,通常没有得到应有的关注。Martin Folwer 在 2014 年提到了这一点,可以在[`martinfowler.com/bliki/SacrificialArchitecture.html`](https://martinfowler.com/bliki/SacrificialArchitecture.html)找到。

牺牲架构的核心思想是,我们应该以一种易于在未来替换的方式编写我们的软件。为了更好地理解前面的陈述,让我们考虑一个例子情景。假设几年前,我们构建了一个计算机网络应用程序,该应用程序利用我们的开发人员设计的自定义数据序列化格式。今天,我们需要用更现代的编程语言重写该应用程序,以处理更多的数据负载和用户请求。这个任务无论如何都不会有趣或容易,因为我们的应用程序依赖于只有应用程序的原始开发人员才能理解的自定义序列化和通信协议。

现在,如果我们使用了更标准化的序列化格式,比如协议缓冲区,那会怎么样?重写或更新应用程序的任务将变得更加容易和高效,因为协议缓冲区受到广泛的编程语言和框架支持。使用标准序列化格式构建我们的应用程序,而不是自定义的格式,这就是牺牲架构的意义所在。

当我们设计我们的软件时考虑到牺牲架构,升级、重构和/或演变我们的应用程序的任务变得更加简单。如果我们的单片应用程序设计时考虑到了牺牲架构,将应用程序的部分分离成微服务就变得容易了。

如果我们在编写我们的粘合代码时考虑到了牺牲架构,那么在未来演变粘合代码或完全摆脱它并用其他东西替换它将变得更加容易。如果我们在构建新的微服务时考虑到了牺牲架构,我们就给自己快速、无痛和高效地增长和演变微服务的能力。

# 一个四层的参与平台

**四层参与平台**是一种以整个应用程序为目标的架构方法。它在 Forrester 研究中被描述为[`go.forrester.com/blogs/13-11-20-mobile_needs_a_four_tier_engagement_platform/`](https://go.forrester.com/blogs/13-11-20-mobile_needs_a_four_tier_engagement_platform/)。这种架构非常适合面向移动和网络时代的现代应用程序。该架构允许可伸缩性、灵活性和性能。它还使得集成云服务和内部微服务变得非常容易和高效。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/3ac4697f-dd63-4e40-b05a-573ca35e2f76.png)

四层参与架构

这种架构背后的主要思想是,整个应用程序应该分为四个主要层或层:

+   **客户层**:这一层负责用户体验;它根据用户的上下文环境定制用户体验。上下文环境包括用户设备类型、用户位置、时间等。例如,如果您的产品用户使用智能手表,那么客户层应该呈现适合智能手表的内容。如果他们使用平板电脑,那么适合平板电脑的用户界面将迎接用户。如果用户正在查看来自中国的数据,客户层需要以中文显示信息。如果用户正在查看来自加拿大的数据,信息需要以英文显示。

+   **交付层**:交付层负责按照客户层的要求向用户交付优化的数据。这是通过进行即时优化来实现的,例如图像压缩或带宽减少。该层可以利用监控工具来跟踪用户活动,然后利用算法利用这些信息来提供更好的客户体验。这一层也是我们使用缓存算法和技术来确保为我们的客户提供更好性能的地方。

+   **聚合层:**这一层是将来自不同来源的数据聚合成稳定和统一的数据模型的地方,然后将其交给前面的层。这一层的任务包括以下内容:

+   在层之间充当 API 中心,提供服务可发现性和数据访问给前面的层。

+   集成来自内部服务(例如内部微服务)和外部服务(例如 AWS 云服务)的输出。

+   从不同来源类型合并数据,例如,从一个来源读取 base64 编码的消息,从另一个来源读取 JSON 编码的消息,然后将它们链接在一起形成统一的数据模型。

+   将数据编码为适合交付给用户的格式。

+   指定基于角色的数据访问。

+   **服务层:**这一层由我们的外部和内部服务组成。它为各层提供原始数据和功能。这些层由一组可部署的内部和外部服务组成。服务层是我们与数据库(如 MySQL 或 DynamoDB)通信的地方;我们会在这里使用第三方服务,如 AWS S3 或 Twilio。这一层应该被设计为可插拔的,这意味着我们可以随意地向其中添加或移除服务。

如果我们使用上述的架构模式设计我们的现代应用程序,我们将获得无限的灵活性和可扩展性。例如,我们可以在客户端层针对新的用户设备类型,而无需在其他层中改变太多代码。我们可以在服务层中添加或移除微服务或云服务,而无需在其上层改变太多代码。我们可以在聚合层中支持新的编码格式,如 Thrift 或协议缓冲区,而无需在其他层上改变太多代码。四层参与平台目前正在被 Netflix 和 Uber 等公司使用。

# 领域驱动设计中的有界上下文

**领域驱动设计**(**DDD**)是一种流行的设计模式,我们可以用它来内部设计微服务。领域驱动设计通常针对可能会随着时间呈指数增长的复杂应用程序。如果您的单片应用程序已经通过 DDD 设计,那么迁移到微服务架构将是直接的。否则,如果您期望新的微服务在范围和复杂性上增长,那么考虑 DDD 可能是一个好主意。

领域驱动设计是一个庞大的主题。维基百科文章可以在[`en.wikipedia.org/wiki/Domain-driven_design`](https://en.wikipedia.org/wiki/Domain-driven_design)找到。然而,为了本节的目的,我们将介绍一些简要的概念,这些概念可以帮助我们获得对 DDD 的实际理解。然后,从那里,您将了解为什么这种设计方法对于复杂的微服务架构是有益的。

领域驱动设计的理念是,一个复杂的应用程序应该被视为在一个*领域*内运行。领域简单地定义为知识或活动的范围。我们软件应用程序的领域可以被描述为与软件目的相关的一切。因此,例如,如果我们软件应用程序的主要目标是促进社交活动的规划,那么规划社交活动就成为我们的领域。

一个域包含*上下文*;每个上下文代表域的一个逻辑部分,人们在其中使用相同的语言。在上下文中使用的语言只能根据它所属的上下文来理解。

根据我的经验,没有例子很难理解上下文是什么。所以,让我们举一个简单的例子。假设社交活动应用背后的组织是一个大型组织,拥有销售部门、营销部门和支持部门。这意味着这个组织的领域驱动设计可能需要包括以下三个主要上下文:销售上下文、营销上下文和支持上下文。

销售人员使用的一些语言只对销售人员相关。例如,销售漏斗、销售机会或销售管道的概念对销售非常重要,但对支持部门可能并不相关。这就是为什么销售上下文可以包括销售漏斗的概念,但在支持上下文中你不会经常找到这种语言或概念。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/9124a64c-1f1b-404b-829f-138e696490c4.png)

领域

领域还包含模型。每个模型都是描述领域中独立概念的抽象。模型最终会被转化为软件模块或对象。模型通常存在于上下文中。例如,在销售上下文中,我们需要模型来表示销售合同、销售漏斗、销售机会、销售管道和客户等,而在支持上下文中,我们需要模型来显示工单、客户和缺陷。以下是一个简单的图表,显示了销售上下文和支持上下文中的一些模型:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/44d6aaf1-7ead-42a0-8734-56590e1576e1.png)

销售和支持上下文

不同的上下文可以共享相同的语言或概念,但关注不同的方面。在我们的大型组织示例中,销售人员使用的一个词可能并不总是对支持人员来说意味着相同的词。例如,对于销售部门来说,*客户*代表着一个可能从组织购买产品但尚未购买的客户。另一方面,对于支持部门来说,客户可能是已经购买产品、购买了支持合同并且正在遇到产品问题的客户。因此,这两个上下文共享客户的概念;然而,当涉及到这个概念时,它们关心的是不同的事情。

同一种语言在不同环境中可能意味着不同的事情,这引入了领域驱动设计世界中的一个关键概念,即有界上下文。有界上下文是共享概念的上下文,但它们实现了自己的概念模型。例如,*客户*的概念在销售上下文中由一个模型表示,反映了销售部门关心的客户版本。客户的概念也根据支持上下文中的版本进行建模。虽然它们是两个模型,但它们仍然是相互关联的。这是因为,归根结底,它们都代表了社交活动策划公司的客户。以下是一个简单的图表,显示了这种情况:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/39d9dcb0-771c-417f-8376-5d520ae9d179.png)

销售和支持上下文

上下文和有界上下文是领域驱动设计和微服务相遇的地方。这是复杂现代微服务的关键设计因素,因为上下文可以很容易地映射到微服务。如果你试图定义有界上下文,你会发现自己不仅在实践中定义了微服务应该是什么,还在定义应该在微服务之间共享什么信息来构建整个应用程序。有界上下文的简单定义是它是一个作为更大应用程序一部分的自包含逻辑块。这个定义也可以毫无添加地应用于描述一个设计良好的微服务。有时,一个有界上下文可以被划分为多个服务,但这通常取决于应用程序的复杂程度。

在我们的例子中,我们最终会有一个处理销售操作的微服务和一个处理支持操作的微服务。

如果您的单体应用程序已经根据 DDD 原则进行了设计,那么迁移到微服务架构会变得更容易。这是因为从形成界限上下文的代码过渡到自包含的微服务会是有意义的。

另一方面,如果您的单体应用程序没有以这种方式设计,但应用程序复杂且不断增长,那么可以利用 DDD 原则来构建未来的微服务。

# 数据一致性

支撑应用程序的数据库是一个至关重要的组成部分,在迁移到微服务架构时必须极其小心谨慎地处理和尊重。在单体应用程序的世界中,您可能会处理连接到单体应用程序的少量数据库(一个或两个)通过一个庞大的数据处理层,如下所示:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/3866d74d-1919-4e48-bd36-f0ec9e61446c.png)

单体应用程序与数据库

然而,在微服务和分布式云架构的情况下,情况可能大不相同。这是因为架构可能包括更广泛的数据模型和数据库引擎,以满足分布式微服务的需求。微服务可以拥有自己的数据库,与其他应用程序共享数据库,或同时使用多个数据库。在现代微服务架构中,数据一致性和建模是一个非常棘手的挑战,我们需要在失控之前通过良好的应用程序设计来解决。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/26f79828-f779-40ec-8730-d7f9d3ed3c27.png)

在接下来的部分中,我们将讨论一些策略,以便在从单体应用程序范式到微服务中打破数据模型时牢记。

# 数据一致性的事件驱动架构

我们可以利用的关键设计模式之一,用于保护微服务架构中的数据一致性的是事件驱动设计。微服务中数据一致性难以维护的原因是,每个微服务通常负责整个应用程序的一部分数据。应用程序微服务处理的数据存储的总和代表了应用程序的总状态。因此,这意味着当一个微服务更新其数据库时,受此数据更改影响的其他微服务需要知道这一点,以便它们可以采取适当的行动并更新自己的状态。

让我们以本章的界限上下文部分中的销售和支持微服务示例为例。如果一个新客户购买了产品,销售微服务将需要更新自己的数据库,以反映新客户的状态,即实际付费客户,而不仅仅是潜在客户。这个事件还需要通知支持微服务,以便它可以更新自己的数据库,以反映有一个新的付费客户,无论何时需要都应该得到客户或技术支持。

这种微服务之间的事件通信就是微服务世界中的事件驱动设计。微服务之间的消息队列或消息代理可以用来在微服务之间通信事件消息。消息代理在第四章中详细讨论,*使用消息队列的异步微服务架构*。需要在某个事件发生时通知的微服务将必须订阅这些事件。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/e3f1de3a-32a3-41a1-a6a4-84b5641089af.png)

例如,支持服务将需要订阅消息队列上代表客户购买产品的事件主题。销售微服务在客户购买产品时触发此事件。由于支持服务订阅了该事件,它将在不久后收到事件的通知,其中将包括新客户的信息。从那里,支持服务将能够执行自己的逻辑,以确保支持组织随时为客户提供帮助,甚至可能为新客户触发欢迎邮件。

现在,这听起来都很好,但如果支持微服务在接收新客户事件之前失败了怎么办?这意味着支持服务最终将不知道新客户的情况,因此不会对新客户的相关信息进行任何逻辑处理,也不会将其添加到支持数据库中。这是否意味着当客户以后寻求帮助时,支持团队不会帮助,因为他们在系统中看不到客户?显然,我们不希望发生这种情况。一种方法是拥有一个存储客户数据的中央数据库,该数据库将在不同的微服务之间共享,但如果我们寻求一种灵活的设计,每个微服务都完全负责自己的整个状态,该怎么办。这就是事件溯源和 CQRS 概念出现的地方。

# 事件溯源

事件溯源的基本思想是,我们需要利用记录的事件流来形成状态,而不是完全依赖于本地数据库来读取状态。为了使其工作,我们需要存储所有当前和过去的事件,以便以后可以检索它们。

我们需要一个例子来巩固这个理论定义。假设支持服务在接收新客户事件之前失败并崩溃了。如果支持服务不使用事件溯源,那么当它重新启动时,它将在自己的数据库中找不到客户信息,也永远不会知道这个客户。然而,如果它使用事件溯源,那么它不仅会查看本地数据库,还会查看与所有其他微服务共享的事件存储。事件存储将记录我们的微服务之间触发的任何事件。在事件存储中,支持服务将能够重放最近触发的新客户事件,并且会发现这个客户目前不存在于本地支持微服务数据库中。支持服务可以将这些信息处理为正常情况。

再次强调,这种设计能够成功的关键技巧是永远不要丢弃任何事件,无论是过去的还是新的。这是通过将它们保存在事件存储中来实现的;以下是它的样子:

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/2b39e000-6ae9-46f8-abcb-9d68c15174f3.png)

实现事件存储有多种方法;它可以是 SQL 数据库、NoSQL 数据库,甚至是支持永久保存事件的消息队列。Kafka 就是一个消息队列的例子,它声称也是事件溯源的良好引擎。

处理事件溯源有多种方法;我们在本节中涵盖的场景代表了一种使用事件存储和快照的方法。在这种情况下,快照是支持微服务本地数据库,它也试图保持快照状态。然而,最终状态仍然预期在事件存储中。

还有其他实现事件溯源的方法,其中不使用快照,整个状态始终必须从事件存储中派生。

事件溯源的缺点是它可能在复杂性上呈指数级增长。这是因为在某些环境中,我们可能需要重放大量事件,以构建系统的当前状态,这需要大量的处理和复杂性。我们需要运行的查询以形成从不同重放事件中联接数据的数据模型可能会变得非常痛苦。

控制事件溯源复杂性的一种流行方法是 CQRS。

# CQRS

**命令查询责任分离**(**CQRS**)的基本理念是,命令(指与更改数据相关的任何操作,如添加、更新或删除)应该与查询(指与读取数据相关的任何操作)分开。在微服务架构中,这意味着一些服务应该负责命令,而其他服务应该负责查询。

CQRS 的一个关键优势是关注点的分离。这是因为我们将写入关注点与读取关注点分开,并允许它们独立扩展。例如,假设我们使用一个复杂的应用程序,我们需要不同的数据视图可用。我们希望将所有客户数据存储在弹性搜索集群中,以便能够高效地搜索并检索它们的信息。与此同时,我们希望将所有客户数据存储在图数据库中,因为我们希望以图形方式查看数据。

在这种情况下,我们将创建微服务,负责从事件流(消息队列)中查询客户事件,然后通过事件溯源在接收到新的客户事件时更新弹性搜索和图数据库。这些服务将成为 CQRS 的查询部分。另一方面,我们将有其他微服务负责在需要时触发新事件。这些服务最终将成为 CQRS 的命令部分。

![](https://gitee.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/cld-ntv-prog-go/img/3211dba1-7403-49a0-b56a-c008bd94cfd5.png)

这些读写微服务然后可以与我们的其他服务一起工作,形成我们的应用程序。

# 摘要

在本章中,我们深入探讨了从单体应用程序迁移到微服务应用程序的实际方面。我们仔细研究了一些高级设计模式和架构,可以利用它们来从单体应用程序切换到微服务应用程序。本章结束了我们对本书的学习之旅。

在下一章中,我们将讨论一些技术和主题,您可以在掌握本书中的知识后开始探索。


# 第十二章:接下来该去哪里?

欢迎来到我们学习 Go 语言云原生编程的最后一章。到目前为止,你应该已经掌握了足够的知识来构建生产级别的微服务,设计复杂的分布式架构,利用亚马逊云服务的强大功能,为你的软件赋予容器的力量,等等。

然而,云原生编程的主题非常深入和广泛。这意味着你仍然可以学习一些主题,丰富你在这个领域的知识和技能。本章的目的是为你提供一些实用的概述,让你在吸收了本书中的知识之后,能够继续探索一些本书未涵盖的强大主题。

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

+   其他微服务通信模式和协议,比如协议缓冲区和 GRPC

+   云提供商提供的更多有用功能

+   其他云提供商(Azure、GCP 和 OpenStack)

+   无服务器计算

# 微服务通信

在本书中,我们涵盖了微服务相互通信的两种方法:

+   第一种方法是通过 RESTful API,其中一个 Web HTTP 层将被构建到一个微服务中,有效地允许微服务与任何 Web 客户端进行通信,无论这个 Web 客户端是另一个微服务还是一个 Web 浏览器。这种方法的一个优点是它赋予了微服务在需要时与外部世界通信的能力,因为 HTTP 现在是一个被所有软件堆栈支持的通用协议。然而,这种方法的缺点是 HTTP 可能是一个具有多层的重型协议,在内部微服务之间需要快速高效的通信时可能不是最佳选择。

+   第二种方法是通过消息队列,其中消息代理软件(如 RabbitMQ 或 Kafka)将促进微服务之间的消息交换。消息代理接收来自发送微服务的消息,将消息排队,然后将其传递给之前表明对这些消息感兴趣的微服务。这种方法的一个主要优势是它可以巩固大规模分布式微服务架构中的数据一致性,如第十一章 *迁移*中所解释的那样。这种方法使得事件驱动的分布式架构成为可能,比如事件溯源和 CQRS。然而,如果我们的扩展需求相对简单,这种方法可能对我们的需求来说过于复杂。这是因为它要求我们维护一个带有所有配置和后端的消息代理软件。在这些情况下,直接的微服务之间的通信可能就是我们所需要的一切。

如果你还没有注意到,这两种方法的一个明显的缺点是它们都不能提供直接高效的微服务之间的通信。我们可以采用两种流行的技术来实现直接的微服务通信:协议缓冲区和 GRPC。

# 协议缓冲区

在它们的官方文档中,协议缓冲区被定义为一种语言中立、平台中立的序列化结构化数据的机制。让我们看一个例子,帮助建立协议缓冲区是什么的清晰图景。

假设您的应用程序中有两个微服务;第一个微服务(服务 1)已经收集了有关新客户的信息,并希望将其发送给第二个微服务(服务 2)。这些数据被视为结构化数据,因为它包含结构化信息,如客户姓名、年龄、工作和电话号码。发送这些数据的一种方式是将其作为 JSON 文档(我们的数据格式)通过 HTTP 从服务 1 发送到服务 2。然而,如果我们想更快地以更小的形式发送这些数据呢?这就是协议缓冲区的作用。在服务 1 内部,协议缓冲区将获取客户对象,然后将其序列化为紧凑形式。然后,我们可以将这个编码后的紧凑数据发送到服务 2,通过高效的通信协议,如 TCP 或 UDP。

请注意,在前面的例子中,我们将协议缓冲区描述为服务内部。这是因为协议缓冲区是作为软件库提供的,我们可以导入并包含在我们的代码中。有许多编程语言的协议缓冲区包(Go、Java、C#、C++、Ruby、Python 等)。

协议缓冲区的工作方式如下:

1.  您在一个特殊的文件中定义您的数据,称为`proto`文件。

1.  您使用一个名为协议缓冲区编译器的软件来将 proto 文件编译成您选择的编程语言的代码文件。

1.  您使用生成的代码文件与您选择的编程语言的协议缓冲区软件包结合起来构建您的软件。

这就是协议缓冲区的要点。要更深入地了解协议缓冲区,请访问[`developers.google.com/protocol-buffers/`](https://developers.google.com/protocol-buffers/),那里有很好的文档可以帮助您开始使用这项技术。

目前有两个常用的协议缓冲区版本:协议缓冲区 2 和协议缓冲区 3。当前在线可用的大部分培训资源都覆盖了最新版本,协议缓冲区 3。如果您正在寻找协议缓冲区版本 2 的资源,您可以在我的网站上查看这篇文章[`www.minaandrawos.com/2014/05/27/practical-guide-protocol-buffers-protobuf-go-golang/`](http://www.minaandrawos.com/2014/05/27/practical-guide-protocol-buffers-protobuf-go-golang/)。

# GRPC

协议缓冲区技术缺少的一个关键特性是通信部分。协议缓冲区擅长将数据编码和序列化为紧凑形式,以便与其他微服务共享。然而,当协议缓冲区的概念最初被构想时,只考虑了序列化,而没有考虑实际将数据发送到其他地方的部分。因此,开发人员过去常常需要自己动手实现 TCP 或 UDP 应用层来在服务之间交换编码数据。然而,如果我们没有时间和精力来担心一个高效的通信层呢?这就是 GRPC 的作用。

GRPC 可以简单地描述为在协议缓冲区之上加上一个 RPC 层。**远程过程调用**(**RPC**)层是一种软件层,允许不同的软件部分,如微服务,通过高效的通信协议(如 TCP)进行交互。使用 GRPC,您的微服务可以通过协议缓冲区版本 3 序列化您的结构化数据,然后能够与其他微服务通信,而无需担心实现通信层。

如果您的应用程序架构需要微服务之间的高效快速交互,同时又不能使用消息队列或 Web API,那么请考虑在下一个应用程序中使用 GRPC。

要开始使用 GRPC,请访问[`grpc.io/`](https://grpc.io/)。与协议缓冲区类似,GRPC 支持多种编程语言。

# 更多关于 AWS

在本书中,我们专门介绍了 AWS 基础知识的两章内容,重点介绍了如何编写能够轻松适应亚马逊云的 Go 微服务。然而,AWS 是一个非常深入的话题,值得一整本书来覆盖,而不仅仅是几章。在本节中,我们将简要介绍一些有用的 AWS 技术,这些技术我们在本书中没有涉及到。您可以将以下部分作为学习 AWS 的下一步的介绍。

# DynamoDB 流

在第八章中,*AWS II - S3、SQS、API Gateway 和 DynamoDB*,我们介绍了流行的 AWS DynamoDB 服务。我们了解了 DynamoDB 是什么,它如何对数据进行建模,以及如何编写能够利用 DynamoDB 功能的 Go 应用程序。

在本书中,有一个强大的 DynamoDB 功能我们没有机会介绍,那就是 DynamoDB 流。DynamoDB 流允许我们捕获 DynamoDB 表中项目发生的更改,同时发生更改。实际上,这意味着我们可以实时地对数据库中发生的数据更改做出反应。和往常一样,让我们举个例子来巩固其含义。

假设我们正在构建云原生分布式微服务应用程序,为大型多人游戏提供支持。假设我们使用 DynamoDB 作为应用程序的数据库后端,并且我们的某个微服务向数据库添加了新玩家。如果我们在应用程序中使用 DynamoDB 流,其他感兴趣的微服务将能够在新玩家添加后不久捕获新玩家的信息。这使得其他微服务可以根据这些新信息采取相应的行动。例如,如果其中一个其他微服务负责在游戏地图中定位玩家,它将把新玩家附加到游戏地图上的起始位置。

DynamoDB 流的工作方式很简单。它们按顺序捕获发生在 DynamoDB 表项上的更改。信息被存储在一个长达 24 小时的日志中。我们编写的其他应用程序可以访问此日志并捕获数据更改。

换句话说,如果一个项目被创建、删除或更新,DynamoDB 流将存储项目的主键和发生的数据修改。

需要在需要监控的表上启用 DynamoDB 流。如果由于任何原因,表不再需要监控,我们也可以在现有表上禁用 DynamoDB 流。DynamoDB 流与 DynamoDB 表并行操作,这基本上意味着使用它们不会对性能产生影响。

要开始使用 DynamoDB 流,请查看[`docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html`](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)。

要开始使用 Go 编程语言中的 DynamoDB 流支持,请查看[`docs.aws.amazon.com/sdk-for-go/api/service/dynamodbstreams/`](https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodbstreams/)。

# AWS 上的自动扩展

由于 AWS 从一开始就设计用于与大规模分布式微服务应用程序一起使用,AWS 具有内置功能,允许这些大型应用程序的开发人员在云中自动扩展其应用程序,尽可能少地进行手动干预。

在 AWS 的世界中,自动扩展这个词有三个主要含义:

+   能够自动替换不健康的应用程序或不良的 EC2 实例,无需您的干预。

+   能够自动创建新的 EC2 实例来处理微服务应用程序的增加负载,无需您的干预。然后,能够在应用程序负载减少时关闭 EC2 实例。

+   当应用程序负载增加时,自动增加可用于应用程序的云服务资源的能力。AWS 云资源不仅限于 EC2。根据您的需求,可以自动增加或减少的云服务资源的一个示例是 DynamoDB 读取和写入吞吐量。

为了满足自动缩放的广泛定义,AWS 自动缩放服务提供了三个主要功能:

+   EC2 实例的车队管理:此功能允许您监视运行中的 EC2 实例的健康状况,自动替换不良实例而无需手动干预,并在配置了多个区域时在多个区域之间平衡 Ec2 实例。

+   动态缩放:此功能允许您首先配置跟踪策略,以调整应用程序的负载量。例如,监视 CPU 利用率或捕获传入请求的数量。然后,动态缩放功能可以根据您配置的目标限制自动添加或删除 EC2 实例。

+   应用程序自动缩放:此功能允许您根据应用程序的需求动态扩展超出 EC2 的 AWS 服务资源。

要开始使用 AWS 自动缩放服务,请访问[`aws.amazon.com/autoscaling/`](https://aws.amazon.com/autoscaling/)。

# 亚马逊关系数据库服务

在第八章中,*AWS II - S3、SQS、API Gateway 和 DynamoDB*,当我们涵盖 AWS 世界中的数据库服务时,我们专门涵盖了 DynamoDB。 DynamoDB 是亚马逊在 AWS 上提供的托管 NoSQL 数据库服务。如果您对数据库引擎有足够的技术专长,您可能会问一个显而易见的问题:关系数据库呢?难道也不应该有一个托管的 AWS 服务吗?

上述两个问题的答案是肯定的,它被称为 Amazon 关系数据库服务(RDS)。 AWS RDS 允许开发人员轻松在云上配置、操作、扩展和部署关系数据库引擎。

Amazon RDS 支持许多开发人员使用和喜爱的知名关系数据库引擎。这包括 PostgreSQL、MySQL、MariaDB、Oracle 和 Microsoft SQL Server。除了 RDS,亚马逊还提供一个名为数据库迁移服务的服务,允许您轻松地将现有数据库迁移到 Amazon RDS 或复制到 Amazon RDS。

要开始使用 AWS RDS,请访问[`aws.amazon.com/rds/`](https://aws.amazon.com/rds/)。要构建能够与 RDS 交互的 Go 应用程序,请访问[`docs.aws.amazon.com/sdk-for-go/api/service/rds/`](https://docs.aws.amazon.com/sdk-for-go/api/service/rds/)。

# 其他云提供商

到目前为止,我们已经专注于 AWS 作为云提供商。当然,还有其他提供商提供类似的服务,其中最大的两个是微软 Azure 云和谷歌云平台。除此之外,还有许多其他提供商也提供基于开源平台 OpenStack 的 IaaS 解决方案。

所有云提供商都采用类似的概念,因此如果您对其中一个有经验,您可能会在其他云提供商中找到自己的路。出于这个原因,我们决定不在本书中深入涵盖它们中的每一个,而是专注于 AWS,并简要展望其他提供商以及它们的不同之处。

# 微软 Azure

您可以在[`azure.microsoft.com/en-us/free/`](https://azure.microsoft.com/en-us/free/)上注册 Azure 云。与 AWS 一样,Azure 提供多个区域和可用性区域,您可以在其中运行您的服务。此外,大多数 Azure 核心服务的工作方式类似于 AWS,尽管它们通常被命名为不同的名称:

+   管理虚拟机的服务(在 AWS 术语中为 EC2)就是**虚拟机**。创建虚拟机时,您需要选择一个镜像(支持 Linux 和 Windows 镜像),提供一个 SSH 公钥,并选择一个机器大小。其他核心概念的命名方式类似。您可以使用**网络安全组**配置网络访问规则,使用**Azure 负载均衡器**(在 AWS 中称为弹性负载均衡器)负载平衡流量,并使用**VM 规模集**管理自动扩展。

+   关系型数据库(由 AWS 的关系数据库服务管理)由**Azure SQL 数据库**管理。但是,在撰写本书时,仅支持 Microsoft SQL 数据库。对 MySQL 和 PostgreSQL 数据库的支持仅作为预览服务提供。

+   类似于 DynamoDB 的 NoSQL 数据库以**Azure Cosmos DB**的形式提供。

+   提供类似于简单队列服务的消息队列服务的是**队列存储**服务。

+   可以使用**应用程序网关**访问您的服务提供的 API。

要从 Go 应用程序中使用 Azure 服务,可以使用**Azure SDK for Go**,可在[`github.com/Azure/azure-sdk-for-go`](https://github.com/Azure/azure-sdk-for-go)上获得。您可以使用通常的`go get`命令进行安装:

```go
$ go get -u github.com/Azure/azure-sdk-for-go/...
```

Azure SDK for Go 目前仍在积极开发中,应谨慎使用。为了不受 SDK 中的任何重大更改的影响,请确保使用依赖管理工具(如*Glide*)将此库的一个版本放入您的*vendor/directory*中(正如您在第九章中学到的,*持续交付*)。

# Google Cloud Platform

**Google Cloud Platform**(**GCP**)是 Google 提供的 IaaS。您可以在[`console.cloud.google.com/freetrial`](https://console.cloud.google.com/freetrial)上注册。与 Azure 云一样,您会发现许多核心功能,尽管名称不同:

+   您可以使用**Google 计算引擎**管理虚拟实例。与往常一样,每个实例都是从一个镜像、一个选择的机器类型和一个 SSH 公钥创建的。您可以使用**防火墙规则**而不是安全组,并且自动缩放组称为**托管实例组**。

+   **Cloud SQL**服务提供关系型数据库。GCP 支持 MySQL 和 PostgreSQL 实例。

+   对于 NoSQL 数据库,您可以使用**Cloud Datastore**服务。

+   **Cloud Pub/Sub**服务提供了实现复杂的发布/订阅架构的可能性(事实上,超越了 AWS 提供的 SQS 的可能性)。

由于两者都来自 Google,可以毫不夸张地说 GCP 和 Go 是密不可分的(双关语)。您可以通过通常的`go get`命令安装 Go SDK:

```go
$ go get -u cloud.google.com/go
```

# OpenStack

还有许多云提供商在开源云管理软件 OpenStack([`www.openstack.org`](https://www.openstack.org))上构建其产品。OpenStack 是一个高度模块化的软件,基于它构建的云可能在设置上有很大差异,因此很难对它们做出普遍有效的陈述。典型的 OpenStack 安装可能包括以下服务:

+   Nova 管理虚拟机实例,Neutron 管理网络。在管理控制台中,您会在“实例”和“网络”标签下找到这些功能。

+   **Zun**和**Kuryr**管理容器。由于这些组件相对较新,可能更常见的是在 OpenStack 云中找到托管的 Kubernetes 集群。

+   **Trove**为关系型和非关系型数据库(如 MySQL 或 MongoDB)提供数据库服务。

+   **Zaqar**提供类似于 SQS 的消息服务。

如果您想从 Go 应用程序访问 OpenStack 功能,则有多个库可供选择。首先,有官方客户端库 - [github.com/openstack/golang-client](http://github.com/openstack/golang-client) - 但目前尚不建议用于生产。在撰写本书时,OpenStack 的最成熟的 Go 客户端库是[github.com/gophercloud/gophercloud](http://github.com/openstack/golang-client)库。

# 在云中运行容器

在第六章中,*在容器中部署您的应用程序*,我们深入了解了如何使用现代容器技术部署 Go 应用程序。当涉及将这些容器部署到云环境时,您有多种不同的方法可以做到这一点。

部署容器化应用程序的一种可能性是使用诸如**Kubernetes**之类的编排引擎。当您使用 Microsoft Azure 云或 Google Cloud Platform 时,这尤其容易。这两个提供商都提供 Kubernetes 作为托管服务,尽管不是以这个名称; 寻找**Azure 容器服务**(**AKS**)或**Google 容器引擎**(**GKE**)。

尽管 AWS 不提供托管的 Kubernetes 服务,但他们有一个类似的服务称为**EC2 容器服务**(**ECS**)。由于 ECS 是 AWS 独家提供的服务,它与其他 AWS 核心服务紧密集成,这既是优势也是劣势。当然,您可以使用在 VM、网络和存储形式提供的构建块在 AWS 上设置自己的 Kubernetes 集群。这是非常复杂的工作,但不要绝望。您可以使用第三方工具自动在 AWS 上设置 Kubernetes 集群。其中一个工具是**kops**。

您可以在[`github.com/kubernetes/kops`](https://github.com/kubernetes/kops)下载 kops。之后,请按照 AWS 的设置说明进行设置,您可以在项目文档中找到[`github.com/kubernetes/kops/blob/master/docs/aws.md`](https://github.com/kubernetes/kops/blob/master/docs/aws.md)。

Kops 本身也是用 Go 编写的,并使用了您在第七章中已经遇到的 AWS SDK。看一下源代码,看看 AWS 客户端库的一些非常复杂的用法的真实例子。

# 无服务器架构

在使用传统的基础设施即服务时,您将获得一些虚拟机以及相应的基础设施(如存储和网络)。通常需要自己操作在这些虚拟机中运行的所有内容。这通常意味着不仅是您编译的应用程序,还包括整个操作系统,包括每个完整的 Linux(或 Windows)系统的内核和系统服务。您还需要负责基础设施的容量规划(这意味着估算应用程序的资源需求并为自动扩展组定义合理的边界)。

所有这些都意味着**操作开销**会让您无法专注于实际工作,也就是构建和部署推动业务的软件。为了减少这种开销,您可以使用平台即服务(PaaS)而不是基础设施即服务(IaaS)。一种常见的 PaaS 托管形式是使用容器技术,开发人员只需提供一个容器镜像,提供商负责运行(和可选地扩展)应用程序,并管理底层基础设施。典型的基于容器的 PaaS 提供包括 AWS 的 EC2 容器服务或任何 Kubernetes 集群,例如 Azure 容器服务或 Google 容器引擎。非基于容器的 PaaS 提供可能包括 AWS Elastic Beanstalk 或 Google App Engine。

最近,又出现了另一种方法,旨在消除 PaaS 提供的操作开销:**无服务器计算**。当然,这个名字是非常误导的,因为在无服务器架构上运行的应用程序显然仍然需要服务器。关键的区别在于这些服务器的存在完全对开发人员隐藏。开发人员只提供要执行的应用程序,提供商负责为该应用程序提供基础设施,并部署和运行它。这种方法与微服务架构很搭配,因为部署使用 web 服务、消息队列或其他方式相互通信的小代码片段变得非常容易。在极端情况下,这经常导致单个函数被部署为服务,从而产生无服务器计算的替代术语:**函数即服务**(**FaaS**)。

许多云服务提供商作为其服务的一部分提供 FaaS 功能,其中最突出的例子是**AWS Lambda**。在撰写本书时,AWS Lambda 并不正式支持 Go 作为编程语言(支持的语言包括 JavaScript、Python、Java 和 C#),而运行 Go 函数只能使用第三方包装器,例如[`github.com/eawsy/aws-lambda-go`](https://github.com/eawsy/aws-lambda-go)。

其他云服务提供商提供类似的服务。Azure 提供**Azure Functions**(支持 JavaScript、C#、F#、PHP、Bash、Batch 和 PowerShell),GCP 提供**Cloud Functions**作为 Beta 产品(仅支持 JavaScript)。如果您正在运行 Kubernetes 集群,可以使用 Fission 框架([`github.com/fission/fission`](https://github.com/fission/fission))来运行自己的 FaaS 平台(甚至支持 Go)。然而,Fission 是一个处于早期 alpha 开发阶段的产品,目前还不建议用于生产环境。

您可能已经注意到,流行的 FaaS 提供中对 Go 语言的支持还不够广泛。然而,鉴于 Go 作为一种编程语言和无服务器架构的流行,还不是所有的希望都已经失去。

# 总结

到此,我们的书就要结束了。到目前为止,您应该已经掌握了足够的知识,可以构建复杂的云原生微服务应用程序,这些应用程序具有弹性、分布式和可扩展性。通过本章,您还应该有了下一步如何将您新获得的知识提升到更高水平的想法。我们感谢您给我们提供机会,引导您完成这次学习之旅,并期待成为您未来旅程的一部分。
posted @ 2024-05-04 22:36  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报