Go-REST-Web-服务实用指南-全-
Go REST Web 服务实用指南(全)
原文:
zh.annas-archive.org/md5/13d436a85f0129b7c19c7c796575c2ae译者:飞龙
前言
我与这本书的联系一直是一段难忘的旅程。三年前,当 Packt 联系我为他们写一本书时,我并不确定我能否首先实现这个目标。但是,在家人、朋友和工作中几位优秀的导师的支持下,我成功地做到了。我总是有解释事物的习惯,并想将这种习惯转化为更重要的形式——一本书。这种愿望最终演变成了一本名为《使用 Go 构建 RESTful Web 服务》的书,于 2017 年出版。回顾过去,这根本不是一个坏主意。
除了全职的软件开发工作外,我还是一个开源博客作者。我写的内容是我工作中所学到的。每个月,我都会开发许多功能,修复许多错误,并审查许多合并请求。我将所有这些经验转化为文章。这本书是许多这些经验的宝贵集合。你可以问我是什么促使我写这本书?那就是分享我所知道的强烈愿望。软件工程是一项硬技能,它一直是一门实践学科,与学术研究不同。这驱使我写了《动手实践 Go 的 RESTful Web 服务》,这是我的第一本书的超级续集。
在这个信息技术时代,产品通过应用程序编程接口(APIs)相互交流。在过去十年中,新一代 Web 语言(如 Python、JavaScript(Node.js)和 Go)的兴起,展示了与传统 Web 开发(如 ASP.NET 和 Java 的 Spring)不同的方法。特别是,Go 编程语言在企业和原型领域之间找到了一个完美的平衡点。我们可以将 Go 同时与“Python 是原型”和“Java 是企业”进行比较。一些最好的开源工具,如 Docker、Terraform 和 Kubernetes,都是用 Go 编写的。谷歌大量使用它来为其内部服务。你可以在github.com/golang/go/wiki/GoUsers上看到使用 Go 的公司列表。
由于代码简洁、严格的类型检查和对并发的支持,Go 是编写现代 Web 服务器的更好语言。一个中级 Go 开发者可以通过了解如何使用 Go 创建 RESTful 服务而受益匪浅。这本书试图让读者对 Web 服务开发感到舒适。记住,这是一本实践指南。
行业专家建议,不久的将来,Python 可能会进一步进入数据科学领域,这可能会在 Web 开发领域造成真空。Go 拥有填补这一空白的全部资格。从单体到微服务的范式转变,以及对于强大 API 接口的需求,可能会使 Go 在解释型语言之上占据更高的地位。
尽管这本书不是一本食谱书,但它为读者在阅读过程中的旅程提供了许多技巧和窍门。这本书是为希望使用 Go 开发 RESTful Web 服务和 API 的软件开发人员和 Web 开发人员而写的。它还将帮助对使用 Go 进行 Web 开发感兴趣的 Python 和 Node.js 开发者。
我希望您喜欢这本书,并且它能帮助您的事业达到新的高度!
本书面向的对象
这本书是为任何熟悉 Go 语言并希望学习 REST API 开发的 Go 开发者而写的。即使是资深工程师也会喜欢这本书,因为它讨论了许多前沿概念,例如构建微服务、使用 GraphQL 开发 API、使用协议缓冲区、异步 API 设计和基础设施即代码。
已经熟悉 REST 概念并从其他平台(如 Python 和 Ruby)进入 Go 世界的开发者,阅读这本书也会受益匪浅。
本书涵盖的内容
第一章,开始 REST API 开发,讨论了 REST 架构和动词的基本原理。
第二章,处理我们的 REST 服务的路由,描述了如何定义 REST API 的基本路由和处理函数。
第三章,与中间件和 RPC 一起工作,涵盖了与中间件处理程序和基本 RPC 一起工作的内容。
第四章,使用流行的 Go 框架简化 RESTful 服务,展示了使用几个开源框架快速原型设计 REST API。
第五章,使用 MongoDB 和 Go 创建 REST API,解释了如何将 MongoDB 用作 REST API 的后端存储。
第六章,与协议缓冲区和 gRPC 一起工作,展示了如何使用协议缓冲区和 gRPC 通过 HTTP/JSON 获得性能提升。
第七章,与 PostgreSQL、JSON 和 Go 一起工作,解释了如何将 PostgreSQL 用作后端存储并利用 JSON 存储来创建 REST API。
第八章,在 Go 中构建 REST API 客户端,介绍了构建客户端软件和 API 测试工具的技术。
第九章,异步 API 设计,展示了通过利用异步设计模式来扩展 API 的技术。
第十章,GraphQL 和 Go,讨论了与 REST 不同的 API 查询语言。
第十一章,使用微服务扩展我们的 REST API,介绍了使用 Go Micro 构建微服务。
第十二章,为部署容器化 REST 服务,展示了如何为 API 部署准备容器化生态系统。
第十三章,在亚马逊网络服务上部署 REST 服务,展示了如何使用基础设施即代码将容器化生态系统部署到 AWS 云。
第十四章,为我们的 REST 服务处理身份验证,讨论了使用简单身份验证和JSON Web Tokens(JWT)来保护 API。
为了充分利用本书
对于本书,您需要一个安装了 Linux(Ubuntu 18.04)、macOS X >=10.13 或 Windows 的笔记本电脑/PC。我们将使用 Go 1.13.x 作为编译器的版本,并将安装许多第三方包,因此需要一个有效的互联网连接。
我们还将在最后几章中使用 Docker 来解释 API 网关的概念。建议使用 Docker 的最新稳定版本。如果 Windows 用户在本地 Go 安装或使用 CURL 进行任何示例时遇到问题,请使用 Docker Desktop for Windows 并运行 Ubuntu 容器来测试您的代码示例;有关更多详细信息,请参阅www.docker.com/docker-windows。
在深入本书之前,请先在tour.golang.org/welcome/1刷新您的语言基础知识。
尽管这些是基本要求,但我们在需要时将引导您完成安装。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”标签。
-
点击“代码下载”。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
下载文件后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838643577_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将前面的程序命名为basicHandler.go。”
代码块如下设置:
{
"ID": 1,
"DriverName": "Menaka",
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
{
"ID": 1,
"DriverName": "Menaka",
}
任何命令行输入或输出都如下所示:
> go run customMux.go
粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“它返回一条消息说已成功登录。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送电子邮件。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问 packt.com。
第一章:开始使用 REST API 开发
网络服务是在各种计算机系统之间定义的一种通信机制。没有网络服务,定制的对等通信会变得繁琐且与平台相关。网络需要理解和解释以协议形式存在的上百种不同事物。如果计算机系统能够与网络可以轻松理解的协议对齐,这将大有裨益。
网络服务是一种软件系统,旨在支持通过网络进行可互操作的机器到机器交互,这是由万维网联盟(World Wide Web Consortium,W3C)在www.w3.org/TR/ws-arch/定义的。
简单来说,一个网络服务是两个端点之间的一条道路,消息在这里可以顺畅地传输。消息传输通常是单向的。两个独立的可编程实体也可以通过它们自己的 API 相互通信。两个人通过语言交流,两个应用程序通过应用程序编程接口(Application Programming Interface,API)交流。
读者可能会想,在当前的数字世界中,API 的重要性是什么?物联网(Internet of Things,IoT)的兴起使得 API 的使用比以前更加频繁。对 API 的认识每天都在增长,每天都有数百个 API 在全球范围内被开发和记录。值得注意的主要企业都看到了API 即服务(API as a Service,AaS)的未来。一个最近的亮点例子是亚马逊网络服务(Amazon Web Services,AWS)。AWS 在云世界中取得了巨大的成功。开发者使用 AWS 提供的表示状态转移(Representational State Transfer,REST)API 编写自己的应用程序,并通过命令行界面(Command-Line Interface,CLI)访问它。
一些隐藏的使用案例来自像Booking.com和www.expedia.com/这样的旅行网站,它们通过调用第三方网关和数据供应商的 API 来获取实时价格。如今,网络服务的使用通常按数据请求量收费。
在本章中,我们将关注以下主题:
-
可用的不同网络服务
-
详细介绍 REST 架构
-
基于 REST 的单页应用程序(Single-page applications,SPAs)的兴起
-
设置 Go 项目并运行开发服务器
-
构建我们的第一个服务,从全球托管的 Debian 服务器列表中找到最快的镜像站点
-
开放 API 规范和 Swagger 文档
技术要求
以下是在本章中运行代码示例之前应预先安装的软件组件:
-
操作系统:Linux(Ubuntu 18.04)/ Windows 10/Mac OS X >=10.13
-
软件:Docker >= 18(适用于 Windows 和 Mac OS X 的 Docker Desktop)
-
Go 编译器的最新版本 == 1.13.5
本书使用 Docker 运行几个沙盒环境。Docker 是一个虚拟化平台,它在一个沙盒中模仿操作系统。使用它,我们可以干净地运行应用程序或服务,而不会影响宿主系统。
你可以在本书的 GitHub 仓库中找到本章使用的代码,网址为 github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter1。
Web 服务的类型
随着时间的推移,已经演变出许多类型的 Web 服务。其中一些较为突出的如下:
-
简单对象访问协议(SOAP)
-
通用描述、发现和集成(UDDI)
-
Web 服务描述语言(WSDL)
-
表征状态转移(REST)
在这些中,SOAP 在 2000 年代初变得流行,当时 XML 正在经历高潮。XML 数据格式被各种分布式系统用于相互通信。
一个 SOAP 请求通常由这三个基本组件组成:
-
封装
-
标头
-
主体
仅为了执行 HTTP 请求和响应周期,我们不得不在 SOAP 中附加大量额外的数据。一个针对虚构的图书服务器 www.example.org 的示例 SOAP 请求看起来如下:
POST /Books HTTP/1.1
Host: www.example.org
Content-Type: application/soap+xml; charset=utf-8
Content-Length: 299
SOAPAction: "https://www.w3.org/2003/05/soap-envelope"
<?xml version="1.0"?>
<soap:Envelope >
<soap:Header>
</soap:Header>
<soap:Body>
<m:GetBook>
<m:BookName>Alice in the wonderland</m:BookName>
</m:GetBook>
</soap:Body>
</soap:Envelope>
这是一个获取图书数据的 SOAP 请求的标准示例。如果我们仔细观察,它是以 XML 格式编写的,带有特殊的标签来指定封装和主体。由于 XML 通过定义大量的命名空间来工作,因此响应会变得庞大。
SOAP 的主要缺点是它对于实现 Web 服务来说过于复杂,并且是一个重量级的框架。一个 SOAP HTTP 请求可能会变得非常大,并可能导致带宽浪费。专家们寻找了一个简单的替代方案,于是出现了 REST。在下一节中,我们将简要讨论 REST。
REST API
表征状态转移(REST)这个名称是由加州大学的 Roy Fielding 提出的。与 SOAP 相比,它是一个非常简化和轻量级的 Web 服务。性能、可扩展性、简单性、可移植性和灵活性是 REST 设计背后的主要原则。
REST API 允许不同的系统以非常简单的方式通信和发送/接收数据。每个 REST API 调用都与 HTTP 动词和 URL 之间有一个关系。应用程序数据库中的资源可以与 REST 架构中的 API 端点相对应。
当你在手机上使用移动应用时,你的手机可能在与许多云服务进行通信,以检索、更新或删除你的数据。REST 服务对我们日常生活有着巨大的影响。
REST 是一种无状态、可缓存且简单的架构,它不是一个协议,而是一种模式。这种模式允许不同的端点通过 HTTP 互相通信。
REST 服务的特性
这些是使 REST 相比其前辈简单且独特的最主要属性:
-
基于客户端-服务器架构:这种架构对于现代网络通过 HTTP 进行通信至关重要。单个客户端-服务器最初可能看起来很天真,但许多混合架构正在演变。我们很快会讨论更多这些内容。
-
无状态:这是 REST 服务最重要的特性。一个 REST HTTP 请求包含了服务器理解并返回响应所需的所有数据。一旦请求被处理,服务器不会记住请求是否在一段时间后到达。因此,操作将是无状态的。
-
可缓存:为了良好地扩展应用程序,我们需要缓存某些响应。REST 服务可以被缓存以提高吞吐量。
-
资源的表示:REST API 提供了统一的接口来进行通信。它使用统一资源标识符(URI)将资源(数据)进行映射。它还有请求特定数据的优势
-
实现自由度:REST 只是定义你的 Web 服务的一种机制。它是一种可以以多种方式实现的架构风格。正因为这种灵活性,你可以以你希望的方式创建 REST 服务。只要它遵循 REST 的原则,你就有权选择你的服务器平台或技术。
考虑周到的缓存对于 REST 服务的扩展至关重要。
我们已经看到了 Web 服务的类型,并了解了什么是 REST API。我们还探讨了使 REST 服务独特的特性。在下一节中,我们将探讨 REST 动词和状态码,并介绍一些路径参数的示例。
REST 动词和状态码
REST 动词指定对特定资源或一组资源要执行的操作。当客户端发起请求时,它应该在 HTTP 请求中发送以下信息:
-
REST 动词
-
头部信息
-
主体(可选)
如我们之前所述,REST 使用 URI 来解码要处理的数据资源。有相当多的 REST 动词可用,但其中六个被特别频繁地使用。它们及其预期的操作如下表所示:
| REST 动词 | 操作 |
|---|---|
GET |
从服务器获取记录或一组资源 |
OPTIONS |
获取所有可用的 REST 操作 |
POST |
创建资源或一组新资源 |
PUT |
更新或替换指定的记录 |
PATCH |
修改指定的记录 |
DELETE |
删除指定的资源 |
这些操作的状态可以通过 HTTP 状态码得知。每当客户端发起一个 REST 操作时,由于 REST 是无状态的,客户端应该知道一种方式来找出操作是否成功。因此,HTTP 响应有一个状态码。REST 为特定操作定义了几种标准状态码类型。这意味着 REST API 应该严格遵循以下规则,以在客户端-服务器通信中实现稳定的结果。根据错误类型,有三个重要的范围可用。请参阅以下表格以了解错误范围:
| 状态码类型 | 数字范围 | 操作 |
|---|---|---|
成功 |
200 - 226 | 2xx 系列用于成功响应。 |
Error |
400 - 499 (客户端), 500 - 599 (服务器) | 4xx 系列用于指示客户端错误。5xx 用于指示服务器无法处理请求。 |
Redirect |
300 - 308 | 3xx 系列用于 URL 重定向。 |
每个状态码的作用非常精确地定义,并且每年代码的总数都在增加。我们将在下一节中提到重要的那些。
所有对 REST 服务的请求都具有以下格式。它由主机和 API 端点组成。API 端点是服务器预先定义的 URL 路径。它还可以包含可选的查询参数。
一个简单的 REST API URI 看起来如下:http://HostName/APIEndpoint/?key=value(可选)
让我们更详细地看看所有的动词。REST API 设计始于定义操作和 API 端点。在实现 API 之前,设计文档应该列出给定资源的所有端点。
在下一节中,我们仔细观察了 REST API 端点,以 PayPal 的 REST API 作为用例。
GET
GET方法从服务器获取指定的资源。为了指定资源,GET使用几种类型的 URI 查询:
-
查询参数
-
基于路径的参数
如果你不知道,你大部分的网页浏览都是通过向服务器执行GET请求来完成的。例如,如果你输入www.google.com,你实际上是在向服务器发送一个GET请求以获取搜索页面。在这里,你的浏览器是客户端,而 Google 的 Web 服务器是 Web 服务的后端实现者。成功的GET操作返回200状态码。
路径参数的示例
每个人都知道PayPal。PayPal 与公司建立账单协议。如果你通过 PayPal 注册支付系统,他们将为你提供用于所有账单需求的 REST API。获取账单协议信息的示例GET请求如下:/v1/payments/billing-agreements/agreement_id。
在这里,资源查询是与路径参数一起的。当服务器看到这一行时,它将其解释为我收到了一个需要 agreement_id 的 HTTP 请求,来自账单协议。然后它通过数据库搜索,进入billing-agreements表,并找到具有给定agreement_id的协议。如果该资源存在,它将发送包含详细信息的副本作为响应(200 OK),否则它将发送一条表示“资源未找到”的响应(404)。
使用GET,你也可以查询资源列表,而不是像上一个示例中那样查询单个资源。可以通过/v1/payments/billing-agreements/transactions获取与协议相关的账单交易的 PayPal API。这一行获取了在该账单协议上发生的所有交易。在这两种情况下,数据都以 JSON 响应的形式检索。响应格式应该事先设计好,以便客户端可以在协议中消费它。
查询参数的示例如下:
- 查询参数的目的是向服务器添加详细的信息以标识资源。例如,想象一个示例虚构的 API。假设这个 API 是为了获取、创建和更新书籍的详细信息而创建的。基于查询参数的
GET请求将具有以下格式:
/v1/books/?category=fiction&publish_date=2017
-
前面的 URI 包含一些查询参数。该 URI 请求
books资源中的一个书籍,满足以下条件: -
应该是一本小说
-
这本书应该在 2017 年出版
客户向服务器提出的问题是获取 2017 年发布的所有小说。
Path参数与Query参数——何时使用它们?一个常见的经验法则是使用Query参数根据Query参数获取多个资源。如果客户端需要一个具有精确 URI 信息的单一资源,它可以使用Path参数来指定资源。例如,用户仪表板可以使用Path参数请求,并且可以使用Query参数来模拟过滤数据获取。
在GET请求中,使用Path参数用于单一资源,使用Query参数用于多个资源。
POST、PUT 和 PATCH
POST方法用于在服务器上创建资源。在之前的books API 中,这个操作会创建一个具有给定详细信息的新的书籍。一个成功的POST操作返回 2xx 状态码。POST请求可以更新多个资源:/v1/books。
POST请求可以有一个如下的 JSON 体:
{"name" : "Lord of the rings", "year": 1954, "author" : "J. R. R. Tolkien"}
这实际上在数据库中创建了一个新的书籍记录。为这个记录分配了一个 ID,以便当我们GET资源时,URL 被创建。所以,POST应该只在开始时进行一次。实际上,《指环王》是在1955 年出版的。所以我们输入的出版日期是错误的。为了更新资源,让我们使用PUT请求。
PUT方法类似于POST。它用于替换已经存在的资源。主要区别在于PUT是一个幂等操作。一个POST调用会创建两个具有相同数据的新实例。但是PUT更新一个已经存在的单一资源:
/v1/books/1256
PUT使用包含 JSON 语法的体来实现,如下所示:
{"name" : "Lord of the rings", "year": 1955, "author" : "J. R. R. Tolkien"}
1256 是这本书的 ID。它更新了之前的书籍信息,year:1955。你是否注意到了PUT方法的缺点?它实际上是用新的记录替换了整个旧的记录。我们只需要更改一个列。但是PUT替换了整个记录。这是不好的。因此,引入了PATCH请求。
PATCH方法类似于PUT,但它不会替换整个记录。正如其名所示,PATCH会修补正在修改的列。让我们用一个新的列ISBN更新书籍1256:
/v1/books/1256
让我们把以下 JSON 放入体中:
{"isbn" : "0618640150"}
它告诉服务器,“搜索 ID 为 1256* 的书籍。然后添加/修改这个列的给定值*。”
PUT和PATCH在成功时都返回 2xx 状态码,在未找到时返回 404。
DELETE 和 OPTIONS
DELETE API 方法用于从数据库中删除资源。它与 PUT 类似,但没有主体。它只需要要删除的资源 ID。一旦资源被删除,后续的 GET 请求将返回 404 未找到状态。
对此方法的响应 不可缓存(如果应该实现缓存的话),因为 DELETE 方法是幂等的。
OPTIONS API 方法在 API 开发中被低估了。给定资源,此方法试图找到服务器上定义的所有可能的方法(GET、POST 等)。这就像在餐厅看菜单卡,然后点一个可用的菜(而如果你随机点菜,服务员会告诉你它不可用)。在服务器上实现 OPTIONS 方法是最佳实践。从客户端确保首先调用 OPTIONS,如果该方法可用,则继续进行。
跨源资源共享(CORS)
这种 OPTIONS 方法的最重要应用是 跨源资源共享(CORS)。最初,浏览器安全机制阻止客户端进行跨源请求。这意味着加载了 www.foo.com URL 的站点只能对该主机进行 API 调用。如果客户端代码需要从 www.bar.com 请求文件或数据,那么第二个服务器 bar.com 应该有一个机制来识别 foo.com 以获取其资源。
下图展示了 CORS 流程:

让我们检查前面 CORS 图表中遵循的步骤:
-
foo.com在bar.com上请求OPTIONS方法 -
bar.com在对客户端的响应中发送一个类似于Access-Control-Allow-Origin: http://foo.com的头信息 -
接下来,
foo.com可以无任何限制地访问bar.com上的资源,调用任何REST方法
如果 bar.com 在一个初始请求之后感觉可以向任何主机提供资源,它可以设置访问控制为 *。
在下一节中,我们将看到为什么 REST API 在下一代 Web 服务中扮演如此重要的角色。SPA 使得可以利用 API 用于所有目的,包括 UI、客户端等。
REST API 与单页应用(SPAs)的兴起
让我们尝试理解为什么单页应用(SPAs)已经成为今天网络的行业标准。与传统方式(即请求渲染的网页)构建 UI 不同,SPA 设计允许开发者以完全不同的方式编写代码。有许多 模型-视图-控制器(MVC)框架,包括 Angular、React、Vue.js 等,用于快速开发 Web UI,但它们的本质都很简单。所有 MVC 框架都帮助我们实现一个设计模式。这个设计模式是 不请求网页,只使用 REST API。
过去十年(2010-2020 年)现代前端 Web 开发取得了很大的进步。为了利用 MVC 架构的特性,我们必须将前端视为一个独立的实体,它仅通过 REST API(最好是使用 JSON 数据)与后端通信。
单页应用(SPA)中数据流的传统和新方法
在传统的请求处理流程中,顺序如下:
-
客户端从服务器请求网页
-
服务器进行身份验证并返回渲染的响应
-
每个渲染的响应都是 HTML,并嵌入数据
然而,使用 SPAs 的流程却相当不同:
-
一次性使用浏览器请求 HTML 模板
-
然后,查询 JSON REST API 以填充模型(数据对象)
-
根据模型中的数据(JSON 格式)调整 UI
-
从浏览器通过 API 调用将更改推回服务器
这样,通信仅以 REST API 的形式发生。客户端负责逻辑上表示数据。这导致系统从面向响应的架构(ROA)转向面向服务的架构(SOA)。请看以下图表:

单页应用(SPAs)减少了带宽使用并提高了网站性能。SPAs 对以 API 为中心的服务器开发是一个巨大的推动,因为现在服务器可以满足浏览器和 API 客户端的双重需求。
为什么使用 Go 进行 REST API 开发?
在现代 Web 中,REST 服务很简单。SOA(我们将在后面更详细地讨论)为 REST 服务创造了一个活动空间,将 Web 开发提升到下一个层次。Go是谷歌公司为解决他们更大的问题而开发的一种编程语言。自从它首次出现以来已经超过十年了。它在开发社区的参与下不断成熟,并在其中创建了大规模的系统。
Go 是 Web 的宠儿。它以简单的方式解决了更大的问题。
我们可以选择 Python 或 JavaScript (Node.js) 来开发我们的 REST API,但 Go 的主要优势在于其速度和编译时错误检测。根据各种基准测试,Go 在计算性能方面已被证明比动态编程语言更快。以下是公司应该用 Go 编写下一个 API 的三个原因:
-
为更广泛的受众扩展您的 API
-
使您的开发者能够构建健壮的系统
-
从简单开始,逐步扩大规模
随着我们在这本书中的进展,我们将学习如何在 Go 中构建高效的 REST 服务。
设置项目和运行开发服务器
这是一系列构建书籍。它假设你已经了解了 Go 的基础知识。如果不是,不用担心。你可以从 Go 的官方网站 golang.org/ 快速入门并学习基础知识。用 Go 编写一个简单的独立程序很简单。但对于大型项目,我们必须设置一个干净的项目布局。因此,作为一个 Go 开发者,你应该了解 Go 项目的布局和保持代码整洁的最佳实践。
在继续之前,请确保你已经完成了以下事项:
-
在你的机器上安装 Go 编译器
-
设置
GOROOT和GOPATH环境变量
有许多在线参考资料,你可以从中了解前面的细节。根据你的机器类型(Windows、Linux 或 Mac OS X),设置一个可工作的 Go 编译器。我们将在下一节中看到更多关于 GOPATH 的细节。
揭秘 GOPATH
GOPATH 仅仅是你在机器上指定的当前工作区。它是一个环境变量,告诉 Go 编译器你的源代码、二进制文件和包存放的位置。
来自 Python 背景的程序员可能熟悉 Virtualenv 工具,它可以同时创建多个项目(具有不同的 Python 解释器版本)。但在特定时间,你可以激活你想要工作的项目的环境并开发你的项目。同样,你可以在你的机器上拥有任意数量的 Go 项目。在开发时,将 GOPATH 设置为你的一个项目。Go 编译器现在激活了该项目。
在 home 目录下创建项目并设置 GOPATH 环境变量是一种常见的做法,如下所示:
mkdir /home/user/workspace
export GOPATH=/home/user/workspace
现在,我们这样安装外部包:
go get -u -v github.com/gorilla/mux
Go 会从 GitHub 复制一个名为 mux 的项目到当前激活的项目 workspace。
对于 go get,使用 -u 标志来安装外部包的更新依赖,使用 -v 来查看安装的详细日志。
如官方 Go 网站所述,一个典型的 Go 项目 hello 应该位于 GOPATH 的 src 目录下:

在深入研究之前,让我们了解这个结构:
-
bin: 存储我们的项目二进制文件;可以直接运行的可分发二进制文件 -
pkg: 包含包对象;提供包方法的编译程序 -
src: 这是你的项目源代码、测试和用户包的存放地
在 Go 中,所有导入到主程序中的包都具有相同的结构,github.com/user/project。但是,谁创建所有这些目录呢?应该是开发者来做。这意味着开发者只需创建他们项目的 src/github.com/user/hello 目录。
当开发者运行install命令时,如果之前不存在,则会创建bin和package目录。.bin包含我们项目源代码的二进制文件,而.pkg包含我们在 Go 程序中使用的所有内部和外部包:
go install github.com/user/project
让我们构建一个小型服务来提高我们的 Go 语言技能。例如,Debian 和 Ubuntu 操作系统在多个 FTP 服务器上托管其发布镜像。这些被称为镜像。镜像有助于从离客户端最近的位置提供操作系统镜像。让我们构建一个从镜像列表中找到最快镜像的服务。
构建我们的第一个服务 - 从列表中找到最快的镜像站点
使用我们至今已建立的概念,让我们编写我们的第一个 REST 服务。许多镜像站点用于托管操作系统镜像,包括 Ubuntu 和 Debian。这里的镜像站点只是托管 OS 镜像的网站,以便地理位置上靠近下载机器。
让我们看看我们如何创建我们的第一个服务:
问题:
构建一个 REST 服务,该服务返回从大量镜像中下载给定操作系统的最快镜像的信息。让我们以 Debian 操作系统镜像列表为例。您可以在www.debian.org/mirror/list找到该列表。
我们在实现我们的服务时使用该列表作为输入。
设计:
我们的 REST API 应该返回最快镜像的 URL。
API 设计文档的块可能看起来像这样:
| HTTP 动词 | 路径 | 操作 | 资源 |
|---|---|---|---|
GET |
/fastest-mirror |
获取 | URL: 字符串 |
实现:
现在我们将逐步实现前面的 API:
该项目的代码可在github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go的chapter1子目录中找到。
- 如我们之前讨论的,你应该首先设置
GOPATH变量。假设GOPATH变量为/home/user/workspace。在以下路径中创建一个名为mirrorFinder的目录。git-user应替换为该项目所在的 GitHub 用户名:
mkdir -p $GOPATH/src/github.com/git-user/chapter1/mirrorFinder
- 我们的项目已经准备好了。我们还没有配置任何数据存储。创建一个名为
main.go的空文件:
touch $GOPATH/src/github.com/git-user/chapter1/mirrorFinder/main.go
- 我们 API 服务器的主体逻辑将放入这个文件。目前,我们可以创建一个数据文件,作为我们主程序的数据服务。为打包镜像列表数据创建一个额外的目录:
mkdir $GOPATH/src/github.com/git-user/chapter1/mirrors
- 现在,在
mirrors目录下创建一个名为data.go的空文件。到目前为止,src目录结构如下所示:
github.com \
-- git-user \
-- chapter1
-- mirrorFinder \
-- main.go
-- mirrors \
-- data.go
- 让我们开始向文件中添加代码。为我们的 API 创建一个名为
data.go的输入数据文件:
package mirrors
// MirrorList is list of Debian mirror sites
var MirrorList = [...]string{
"http://ftp.am.debian.org/debian/", "http://ftp.au.debian.org/debian/",
"http://ftp.at.debian.org/debian/", "http://ftp.by.debian.org/debian/",
"http://ftp.be.debian.org/debian/", "http://ftp.br.debian.org/debian/",
"http://ftp.bg.debian.org/debian/", "http://ftp.ca.debian.org/debian/",
"http://ftp.cl.debian.org/debian/", "http://ftp2.cn.debian.org/debian/",
"http://ftp.cn.debian.org/debian/", "http://ftp.hr.debian.org/debian/",
"http://ftp.cz.debian.org/debian/", "http://ftp.dk.debian.org/debian/",
"http://ftp.sv.debian.org/debian/", "http://ftp.ee.debian.org/debian/",
"http://ftp.fr.debian.org/debian/", "http://ftp2.de.debian.org/debian/",
"http://ftp.de.debian.org/debian/", "http://ftp.gr.debian.org/debian/",
"http://ftp.hk.debian.org/debian/", "http://ftp.hu.debian.org/debian/",
"http://ftp.is.debian.org/debian/", "http://ftp.it.debian.org/debian/",
"http://ftp.jp.debian.org/debian/", "http://ftp.kr.debian.org/debian/",
"http://ftp.lt.debian.org/debian/", "http://ftp.mx.debian.org/debian/",
"http://ftp.md.debian.org/debian/", "http://ftp.nl.debian.org/debian/",
"http://ftp.nc.debian.org/debian/", "http://ftp.nz.debian.org/debian/",
"http://ftp.no.debian.org/debian/", "http://ftp.pl.debian.org/debian/",
"http://ftp.pt.debian.org/debian/", "http://ftp.ro.debian.org/debian/",
"http://ftp.ru.debian.org/debian/", "http://ftp.sg.debian.org/debian/",
"http://ftp.sk.debian.org/debian/", "http://ftp.si.debian.org/debian/",
"http://ftp.es.debian.org/debian/", "http://ftp.fi.debian.org/debian/",
"http://ftp.se.debian.org/debian/", "http://ftp.ch.debian.org/debian/",
"http://ftp.tw.debian.org/debian/", "http://ftp.tr.debian.org/debian/",
"http://ftp.uk.debian.org/debian/", "http://ftp.us.debian.org/debian/",
}
我们创建一个名为MirrorList的字符串映射。这个映射包含到达镜像站点的 URL 信息。我们将把这些信息导入到我们的主程序中,以服务来自客户端的请求。
- 打开
main.go并添加以下代码:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/git-user/chapter1/mirrors"
)
type response struct {
FastestURL string `json:"fastest_url"`
Latency time.Duration `json:"latency"`
}
func main() {
http.HandleFunc("/fastest-mirror", func(w http.ResponseWriter,
r *http.Request) {
response := findFastest(mirrors.MirrorList)
respJSON, _ := json.Marshal(response)
w.Header().Set("Content-Type", "application/json")
w.Write(respJSON)
})
port := ":8000"
server := &http.Server{
Addr: port,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
fmt.Printf("Starting server on port %sn", port)
log.Fatal(server.ListenAndServe())
}
我们创建了一个主函数,用于运行 HTTP 服务器。Go 提供了net/http包来实现这个目的。我们的 API 响应是一个包含两个字段的 struct:
-
fastest_url:最快的镜像站点 -
latency:从 Debian OS 仓库下载 README 所需的时间
- 我们将编写一个名为
findFastest的函数,向所有镜像发送请求并计算最快的。为此,我们不是依次对每个 URL 进行顺序的 API 调用,而是使用 goroutines 并行请求 URL,一旦 goroutine 返回,我们就停止并返回该数据。:
func findFastest(urls []string) response {
urlChan := make(chan string)
latencyChan := make(chan time.Duration)
for _, url := range urls {
mirrorURL := url
go func() {
start := time.Now()
_, err := http.Get(mirrorURL + "/README")
latency := time.Now().Sub(start) / time.Millisecond
if err == nil {
urlChan <- mirrorURL
latencyChan <- latency
}
}()
}
return response{<-urlChan, <-latencyChan}
}
findFastest函数接受一个 URL 列表并返回响应 struct。该函数为每个镜像站点 URL 创建一个 goroutine。它还创建了两个通道,urlChan和latencyChan,这些通道被传递给 goroutines。在 goroutines 中,我们计算延迟(请求所需的时间)。
这里的智能逻辑是,每当 goroutine 收到响应时,它会将数据写入两个分别带有 URL 和延迟信息的通道。在接收到数据后,这两个通道会创建响应 struct 并从findFastest函数返回。当该函数返回时,所有从该函数产生的 goroutine 都会停止它们正在做的事情。因此,我们将在urlChan中获得最短的 URL,在latencyChan中获得最小的延迟。
- 现在,如果你将此函数添加到主文件(
main.go),我们的代码就完成了这个任务:
总是使用 Go 的fmt工具来格式化你的 Go 代码。fmt的一些示例用法如下:go fmt github.com/narenaryan/romanserver
- 现在,使用 Go 命令
install安装此项目:
go install github.com/git-user/chapter1/mirrorFinder
此步骤执行两个操作:
-
编译
mirrors包并将其副本放置在$GOPATH/pkg目录中 -
将二进制文件放置在
$GOPATH/bin
- 我们可以像这样运行前面的 API 服务器:
$GOPATH/bin/mirrorFinder
服务器正在http://localhost:8000上运行。现在我们可以使用浏览器或 curl 命令等客户端向 API 发送GET请求。让我们使用正确的 API GET请求发送一个curl命令。
请求如下:
curl -i -X GET "http://localhost:8000/fastest-mirror" # Valid request
响应如下:
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 27 Mar 2019 23:13:42 GMT
Content-Length: 64
{"fastest_url":"http://ftp.sk.debian.org/debian/","latency":230}
我们的最快镜像查找 API 运行得很好。返回了正确的状态码。输出可能会随着每次 API 调用而变化,但它会获取任何给定时刻的最低延迟链接。此示例还展示了 goroutines 和 channels 的优势。
在下一节中,我们将查看一个名为 Open API 的 API 规范。API 规范是用于记录 REST API 的。为了可视化规范,我们将使用 Swagger UI 工具。
开放 API 和 Swagger
由于 API 非常常见,因此 Open API 规范是OpenAPI Initiative内部的一个社区驱动的开放规范,OpenAPI Initiative 是一个 Linux Foundation 协作项目。
OpenAPI 规范(OAS),以前称为 Swagger 规范,是 REST API 的 API 描述格式。Open API 文件允许您描述您的整个 API,包括以下内容:
-
可用端点
-
端点操作(GET、PUT、DELETE 等)
-
每个操作的参数输入和输出
-
认证方法
-
联系信息、许可、使用条款和其他信息。
Open API 有许多版本,并且正在快速发展。当前稳定版本是 3.0。
OAS 支持两种格式,JSON 和 YAML。Swagger 和 Open API 是不同的。Swagger 有许多产品,包括以下内容:
-
Swagger UI(用于验证 Open API 文件和交互式文档)
-
Swagger Codegen(用于生成服务器存根)
每当我们开发 REST API 时,创建一个 Open API/Swagger 文件以捕获 API 所需的所有详细信息和描述是一个更好的做法。然后可以使用 Swagger UI 创建交互式文档。
安装 Swagger UI
Swagger UI 可以在各种操作系统上安装/下载,但最好的方法可能是使用 Docker。Docker Hub 上有一个 Swagger UI Docker 镜像。然后我们可以将我们的 Open API/Swagger 文件传递到我们运行的 Docker 容器中。在此之前,我们需要创建一个 JSON 文件。Swagger JSON 文件有几个部分:
-
info -
servers -
paths
让我们为第一个构建的服务创建一个具有前面各节的 Swagger 文件。让我们称它为 openapi.json:
{
"openapi": "3.0.0",
"info": {
"title": "Mirror Finder Service",
"description": "API service for finding the fastest mirror from the
list of given mirror sites",
"version": "0.1.1"
},
"servers": [
{
"url": "http://localhost:8000",
"description": "Development server[Staging/Production are different
from this]"
}
],
"paths": {
"/fastest-mirror": {
"get": {
"summary": "Returns a fastest mirror site.",
"description": "This call returns data of fastest reachable mirror
site",
"responses": {
"200": {
"description": "A JSON object of details",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fastest_mirror": {
"type": "string"
},
"latency": {
"type": "integer"
}
}
}
}
}
}
}
}
}
}
}
请注意我们如何定义了 info、servers 和 paths 部分。
openapi 标签指定我们正在使用的 API 文档的版本。
info 部分包含与服务相关的描述。servers 部分包含应用程序/服务器运行的服务器 URL。我们使用了 localhost:8000,因为我们是在本地运行。paths 部分包含服务提供的所有 API 端点的信息。它还包含有关请求体、响应类型和体结构的信息。甚至可能的错误代码也可以封装到 paths 中。
现在让我们安装 Swagger UI 并使用我们的 Swagger 文件:
- 要通过 Docker 安装 Swagger UI,请在您的 shell 中运行以下命令:
docker pull swaggerapi/swagger-ui
如果您使用的是 Windows 10/Mac OS X,请确保 Docker Desktop 正在运行。在 Linux 上,一旦安装,Docker 就始终可用。
- 这将从 Docker Hub 将镜像拉取到您的本地机器。现在我们可以运行一个容器,该容器可以接受一个
openapi.json文件并启动 Swagger UI。假设您在chapter1目录中拥有此文件,让我们使用以下命令:
docker run --rm -p 80:8080 -e SWAGGER_JSON=/app/openapi.json -v $GOPATH/github.com/git-user/chapter1:/app swaggerapi/swagger-ui
此命令告诉 Docker 执行以下操作:
-
使用
swaggerapi/swagger-ui镜像运行容器 -
将
chapter1(openapi.json所在的位置)挂载到容器中的/app目录 -
将主机端口
80暴露给容器端口8080 -
将
SWAGGER_JSON环境变量设置为/app/openapi.json
当容器启动时,在浏览器中打开http://locahost。您将看到您 API 的精美文档:

通过这种方式,我们无需任何成本,就可以使用 Swagger UI 和 Open API 3.0 创建我们 REST API 的即时文档。
要在 Swagger UI 中测试 API,REST API 服务器需要对 Docker 容器可访问(例如,通过网络桥接)。
从现在开始,在所有章节中,我们将尝试创建 Swagger 文件来记录我们的 API 设计。首先创建 API 规范,然后进入实现,这是一个明智的决定。我希望这一章能帮助您复习 REST API 基础。在接下来的章节中,我们将深入探讨许多不同的主题。
摘要
在本章中,我们介绍了 REST API。我们了解到 REST 不是一个协议,而是一种架构模式。HTTP 是我们可以在其上实现 REST 服务的实际协议。我们深入探讨了 REST API 的基础,以清楚地了解它们实际上是什么。然后我们探讨了 Web 服务的类型。在 REST 之前,我们有一种叫做 SOAP 的东西,它使用 XML 作为其数据格式。REST 以 JSON 作为其主要格式。REST 有动词和状态码。我们看到了这些状态码指的是什么。
我们设计和实现了一个简单的服务,该服务从全球所有 Debian 镜像站点中找到下载 OS 镜像的最快镜像站点。在这个过程中,我们还看到了如何将 Go 项目打包成二进制文件。我们了解了GOPATH环境变量,它是 Go 中的工作空间定义。我们现在知道所有包和项目都位于该路径上。
接下来,我们通过介绍 Swagger UI 和 Swagger 文件,跳入了 OpenAPI 规范的世界。简要讨论了这些文件的结构以及如何使用 Docker 运行 Swagger UI。我们还看到了为什么开发者应该通过编写 Swagger 文件形式的规范来开始 API 开发。
在下一章中,我们将深入探讨 URL 路由。从内置路由器开始,我们将探索 Gorilla Mux,这是一个强大的 URL 路由库。
第二章:处理 REST 服务的路由
在本章中,我们将讨论 REST 应用程序的路由。要创建 API,第一步是定义路由。要定义路由,我们必须了解 Go 中可用的系统包。我们将从探索 Go 中的基本内部路由机制开始本章。然后,我们将看到如何创建一个自定义的多路复用器,该实体将给定的 URL 与已注册的模式匹配。多路复用器基本上允许开发者创建一个路由来监听客户端请求,并附加包含应用程序业务逻辑的处理程序。ServeMux包是 Go 提供的基礎多路复用器。然后,我们将探索一些其他框架,因为ServeMux的功能非常有限。
本章还包括类似httprouter和gorilla/mux的第三方库。然后,我们将讨论诸如 SQL 注入等主题。本章的核心是教您如何使用gorilla/mux在 Go 中创建优雅的 HTTP 路由器。我们还将简要讨论设计 URL 缩短服务。
我们将涵盖以下主题:
-
理解 Go 的
net/http包 -
ServeMux——Go 中的基本路由器 -
理解
httprouter——一个轻量级的 HTTP 路由器 -
介绍
gorilla/mux——一个强大的 HTTP 路由器 -
读者挑战:一个 URL 缩短 API
技术要求
以下是在运行代码示例之前应预安装的软件:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Go 最新版本编译器 >= 1.13.5
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter2下载本章的代码。克隆代码并使用chapter2目录中的代码示例。
理解 Go 的net/http包
接受 HTTP 请求是 Web 服务器的主要目标。在 Go 中,有一个系统级的包帮助开发者创建 HTTP 服务器和客户端。该包的名称是net/http。我们可以通过创建一个小示例来理解net/http包的功能。该示例接受传入的请求并返回服务器的时间戳。让我们看看创建此类服务器的步骤:
- 按照以下方式创建程序文件:
touch -p $GOPATH/src/github.com/git-user/chapter2/healthCheck/main.go
现在,我们有一个文件,我们可以开发一个带有健康检查 API 的服务器,该 API 返回日期/时间字符串。
- 导入
net/http包并创建一个名为HealthCheck的函数处理程序。http.HandleFunc是一个方法,它接受一个路由和一个函数处理程序作为其参数。这个函数处理程序必须返回一个http.ResponseWriter对象:
package main
import (
"io"
"log"
"net/http"
"time"
)
// HealthCheck API returns date time to client
func HealthCheck(w http.ResponseWriter, req *http.Request) {
currentTime := time.Now()
io.WriteString(w, currentTime.String())
}
func main() {
http.HandleFunc("/health", HealthCheck)
log.Fatal(http.ListenAndServe(":8000", nil))
}
上一段代码创建了一个HealthCheck函数并将其附加到一个 HTTP 路由上。HandleFunc用于将路由模式附加到处理函数。ListenAndServe启动一个新的 HTTP 服务器。如果服务器启动失败,它将返回一个错误。它将address:port作为第一个参数,第二个参数是nil,表示使用默认的多路复用器。我们将在接下来的章节中详细了解多路复用器。
使用log函数来调试潜在的错误。如果存在错误,ListenAndServe函数将返回一个错误。
- 现在,我们可以使用以下命令启动 Web 服务器:
go run $GOPATH/src/github.com/git-user/chapter2/healthCheck/main.go
从 shell 中运行healthCheck.go文件。
- 现在,打开一个 shell 或浏览器来查看服务器运行的情况。在这里,我们使用
curl请求:
curl -X GET http://localhost:8000/health
响应如下:
2019-04-10 17:54:05.450783 +0200 CEST m=+6.612810181
Go 在处理请求和响应方面有一个不同的概念。我们使用了io库来写入响应。对于 Web 开发,我们可以使用模板来自动填充细节。Go 的内部 URL 处理程序使用 ServeMux 多路复用器。在下一节中,我们将进一步讨论 ServeMux,Go 内置的 URL 路由器。
ServeMux – Go 中的基本路由器
ServeMux 是一个 HTTP 请求多路复用器。在前一节中我们使用的HandleFunc实际上是 ServeMux 的一个方法。通过使用 ServeMux,我们可以处理多个路由。我们也可以创建自己的多路复用器。多路复用器通过一个名为ServeHTTP的函数来处理分离路由的逻辑。因此,如果我们创建一个具有ServeHTTP方法的 Go 结构体,它就可以像内置的多路复用器一样完成工作。
将路由视为 Go 字典(map)中的键,将多路复用器视为其值。Go 从路由中查找多路复用器并尝试执行ServeHTTP函数。在下一节中,我们将通过创建一个生成 UUID 字符串的 API 来查看 ServeMux 的使用。
使用 ServeMux 开发 UUID 生成 API
UUID 是一个资源或事务的唯一标识符。UUID 被广泛用于标识 HTTP 请求。让我们开发一个生成 UUID 的 API。请按照以下步骤操作:
- 按照以下方式创建程序文件:
touch -p $GOPATH/src/github.com/git-user/chapter2/uuidGenerator/main.go
- 任何具有几个专用 HTTP 方法的 Go 结构体都符合成为 ServeMux 的资格。例如,我们可以创建一个自定义的
UUID结构体并实现**ServeHTTP**函数,使其成为一个 ServeMux 对象。以下是uuidGenerator.go模块的实现:
import (
"crypto/rand"
"fmt"
)
// UUID is a custom multiplexer
type UUID struct {
}
func (p *UUID) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
giveRandomUUID(w, r)
return
}
http.NotFound(w, r)
return
}
func giveRandomUUID(w http.ResponseWriter, r *http.Request) {
c := 10
b := make([]byte, c)
_, err := rand.Read(b)
if err != nil {
panic(err)
}
fmt.Fprintf(w, fmt.Sprintf("%x", b))
}
它由作为 ServeMux 对象的UUID结构体组成。我们可以在处理函数中访问 URL 路径,并使用这些信息手动将请求路由到不同的响应生成器。
giveRandomUUID是一个响应生成函数,它将一个随机的 UUID 字符串设置到响应中。Go 的crypto包有一个Read函数,可以将随机字符填充到一个字节数组中。
- 现在向模块中添加一个主函数,使用 ServeMux 对象。我们应该将我们的 ServeMux 传递给
http.ListenAndServe函数以提供我们的内容。我们在端口8000上提供服务:
package main
import (
"net/http"
)
func main() {
mux := &UUID{}
http.ListenAndServe(":8000", mux)
}
在 ListenAndServe 函数中,我们使用 UUID 作为多路复用器,该函数启动一个 HTTP 服务器。服务器执行在 mux 对象之前定义的 ServeHTTP 方法。
- 从您的 shell/Terminal 运行以下命令:
go run $GOPATH/src/github.com/git-user/chapter2/uuidGenerator/main.go
- 我们可以像这样发送
curl请求来向监听端口8000的 Web 服务器发送请求:
curl -X GET http://localhost:8000/
返回的响应将是一个随机字符串:
544f5519592ac25bb2c0
使用 Ctrl + C 或 Cmd + C 来停止您的 Go 服务器。如果您将其作为后台进程运行,请使用 sudo kill sudo lsof -t -i:8000 来终止在端口 8000 上运行的进程。
到目前为止,我们一直使用单个处理器。让我们看看如何使用 ServeMux 将多个处理器添加到不同的函数处理器路由中。
使用 ServeMux 添加多个处理器
假设我们有一个 API 需求,它生成不同类型的随机数,如 int、float 等。当我们有多个具有不同功能的端点时,我们开发的自定义 多路复用器(mux)可能会变得繁琐。为了添加该逻辑,我们需要添加多个 if/else 条件来手动检查 URL 路由。为了克服这种复杂的代码结构,我们可以实例化一个新的内置 ServeMux 对象并定义许多处理器。让我们看看带有 ServeMux 的代码:
newMux := http.NewServeMux()
newMux.HandleFunc("/randomFloat", func(w http.ResponseWriter,
r *http.Request) {
fmt.Fprintln(w, rand.Float64())
})
newMux.HandleFunc("/randomInt", func(w http.ResponseWriter,
r *http.Request) {
fmt.Fprintln(w, rand.Int(100))
})
此代码片段展示了如何创建 ServeMux 并将其附加到多个处理器。
randomFloat 和 randomInt 是我们创建的两个路由,分别用于返回随机的 float 和随机的 int。现在,我们将这些传递给 ListenAndServe 函数。Int(100) 返回一个 0-100 范围内的随机整数。
更多关于随机函数的详细信息,请访问 Go 随机包页面:golang.org。
让我们看看一个完整的示例:
- 创建一个文件来存放我们的程序,并将其命名为
multipleHandlers.go,路径如下:
touch -p $GOPATH/src/github.com/git-user/chapter2/multipleHandlers/main.go
-
现在创建一个主函数,并添加创建
ServeMux对象和函数处理器的代码。 -
最后,使用
http.ListenAndServe方法运行服务器:
package main
import (
"fmt"
"math/rand"
"net/http"
)
func main() {
newMux := http.NewServeMux()
newMux.HandleFunc("/randomFloat", func(w http.ResponseWriter,
r *http.Request) {
fmt.Fprintln(w, rand.Float64())
})
newMux.HandleFunc("/randomInt", func(w http.ResponseWriter,
r *http.Request) {
fmt.Fprintln(w, rand.Intn(100))
})
http.ListenAndServe(":8000", newMux)
}
- 我们可以直接使用
run命令来运行程序:
go run $GOPATH/src/github.com/git-user/chapter2/multipleHandlers/main.go
- 现在,让我们执行两个
curl命令并查看输出:
curl -X GET http://localhost:8000/randomFloat
curl -X GET http://localhost:8000/randomInt
响应将包括:
0.6046602879796196
87
我们看到了如何使用基本的 Go 构造创建 URL 路由器。让我们看看一些在 Go 社区中广泛使用的、用于 API 服务器的流行 URL 路由框架。
理解 httprouter – 一个轻量级 HTTP 路由器
httprouter,正如其名所示,将 HTTP 请求路由到特定的处理器。httprouter 是 Go 中创建具有优雅 API 的简单路由器的知名包。来自 Python/Django 社区的开发者非常熟悉 Django 框架中的完整 URL 分派器。httprouter 提供了类似的功能:
-
允许在路由路径中使用变量
-
匹配 REST 方法(
GET、POST、PUT等) -
不妥协性能
在下一节中,我们将更详细地讨论这些特性。在此之前,有一些值得注意的点使得 httprouter 成为一个更好的 URL 路由器:
-
httprouter与内置的http.Handler兼容得很好 -
httprouter明确表示一个请求只能匹配到一个路由或没有路由 -
路由器的设计鼓励构建合理、分层的 RESTful API
-
你可以构建简单高效的静态文件服务器
在下一节中,我们将看到 httprouter 的安装及其基本用法。
安装 httprouter
httprouter 是一个开源的 Go 包,可以使用 go get 命令安装。让我们看看以下步骤中的安装和基本用法:
- 使用以下命令安装
httprouter:
go get github.com/julienschmidt/httprouter
我们可以在源代码中导入这个库,如下所示:
import "github.com/julienschmidt/httprouter"
-
通过示例可以理解
httprouter的基本用法。让我们用 Go 编写一个 REST 服务,提供以下两个功能:
-
获取 Go 编译器版本
-
获取指定文件的正文
我们需要使用一个名为 os/exec 的系统包来获取前面的详细信息。
os/exec包有一个Command函数,我们可以用它来执行任何系统调用,其函数签名如下:
// arguments... means an array of strings unpacked as arguments
// in Go
cmd := exec.Command(command, arguments...)
exec.Command函数接收命令和一个额外的参数数组。额外的参数是命令的选项或输入。然后可以通过调用Output函数来执行它,如下所示:
out, err := cmd.Output()
- 这个程序使用
httprouter创建服务。让我们在以下路径创建它:
touch -p $GOPATH/src/github.com/git-user/chapter2/httprouterExample/main.go
程序的主函数创建了两个路由和两个函数处理器。函数处理器的职责是:
-
获取当前 Go 编译器版本
-
获取文件的正文
程序正在尝试使用 httprouter 实现 REST 服务。 我们在这里定义了两个路由:
-
/api/v1/go-version -
/api/v1/show-file/:name
package main
import (
"fmt"
"io"
"log"
"net/http"
"os/exec"
"github.com/julienschmidt/httprouter"
)
func main() {
router := httprouter.New()
router.GET("/api/v1/go-version", goVersion)
router.GET("/api/v1/show-file/:name", getFileContent)
log.Fatal(http.ListenAndServe(":8000", router))
}
:name 是一个路径参数。基本的 Go 路由器无法定义这些特殊参数。通过使用 httprouter,我们可以匹配 REST 方法。在主块中,我们正在匹配 GET 请求到相应的路由。
现在我们将转向三个处理器函数的实现:
func getCommandOutput(command string, arguments ...string) string {
out, _ := exec.Command(command, arguments...).Output()
return string(out)
}
func goVersion(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
response := getCommandOutput("/usr/local/go/bin/go", "version")
io.WriteString(w, response)
return
}
func getFileContent(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
fmt.Fprintf(w, getCommandOutput("/bin/cat", params.ByName("name")))
}
exec.Command 接收 bash 命令及其相应选项作为其参数,并返回一个对象。该对象有一个 Output 方法,它返回命令执行的结果输出。我们在 goVersion 和 getFileContent 处理器中都使用了这个 getCommandOutput 函数。我们在处理器中使用如 go --version 和 cat file_name 这样的 shell 命令格式。
如果观察代码,我们使用了 /usr/local/go/bin/go 作为 Go 可执行文件的位置,因为它是 Mac OS X 中的 Go 编译器位置。在执行 exec.Command 时,你应该给出可执行文件的绝对路径。因此,如果你在 Ubuntu 机器或 Windows 上工作,请使用你安装的 Go 可执行文件的路径。在 Linux 机器上,你可以通过使用 $ which go 命令轻松找到它。
现在,在同一个目录中创建两个新文件。这些文件将由我们的文件服务器程序提供服务。你可以在该目录中创建任何自定义文件进行测试:
Latin.txt:
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
Greek.txt:
Οἱ δὲ Φοίνιϰες οὗτοι οἱ σὺν Κάδμῳ ἀπιϰόμενοι.. ἐσήγαγον διδασϰάλια ἐς τοὺς ῞Ελληνας ϰαὶ δὴ ϰαὶ γράμματα, οὐϰ ἐόντα πρὶν ῞Ελλησι ὡς ἐμοὶ δοϰέειν, πρῶτα μὲν τοῖσι ϰαὶ ἅπαντες χρέωνται Φοίνιϰες· μετὰ δὲ χρόνου προβαίνοντος ἅμα τῇ ϕωνῇ μετέβαλον ϰαὶ τὸν ϱυϑμὸν τῶν γραμμάτων. Περιοίϰεον δέ σϕεας τὰ πολλὰ τῶν χώρων τοῦτον τὸν χρόνον ῾Ελλήνων ῎Ιωνες· οἳ παραλαβόντες διδαχῇ παρὰ τῶν Φοινίϰων τὰ γράμματα, μεταρρυϑμίσαντές σϕεων ὀλίγα ἐχρέωντο, χρεώμενοι δὲ ἐϕάτισαν, ὥσπερ ϰαὶ τὸ δίϰαιον ἔϕερε ἐσαγαγόντων Φοινίϰων ἐς τὴν ῾Ελλάδα, ϕοινιϰήια ϰεϰλῆσϑαι.
使用以下命令运行程序。这次,我们不再使用curl命令,而是使用浏览器作为GET请求的输出。Windows 用户可能没有 curl 作为首选应用程序。在开发 REST API 时,他们可以使用 Postman 客户端等 API 测试软件。看看以下命令:
go run $GOPATH/src/github.com/git-user/chapter2/httprouterExample/main.go
第一次GET请求的输出如下所示:
curl -X GET http://localhost:8000/api/v1/go-version
结果将是这样的:
go version go1.13.5 darwin/amd64
第二个请求Greek.txt的GET请求是:
curl -X GET http://localhost:8000/api/v1/show-file/greek.txt
现在,我们将看到希腊语的文件输出:
Οἱ δὲ Φοίνιϰες οὗτοι οἱ σὺν Κάδμῳ ἀπιϰόμενοι.. ἐσήγαγον διδασϰάλια ἐς τοὺς ῞Ελληνας ϰαὶ δὴ ϰαὶ γράμματα, οὐϰ ἐόντα πρὶν ῞Ελλησι ὡς ἐμοὶ δοϰέειν, πρῶτα μὲν τοῖσι ϰαὶ ἅπαντες χρέωνται Φοίνιϰες· μετὰ δὲ χρόνου προβαίνοντος ἅμα τῇ ϕωνῇ μετέβαλον ϰαὶ τὸν ϱυϑμὸν τῶν γραμμάτων. Περιοίϰεον δέ σϕεας τὰ πολλὰ τῶν χώρων τοῦτον τὸν χρόνον ῾Ελλήνων ῎Ιωνες· οἳ παραλαβόντες διδαχῇ παρὰ τῶν Φοινίϰων τὰ γράμματα, μεταρρυϑμίσαντές σϕεων ὀλίγα ἐχρέωντο, χρεώμενοι δὲ ἐϕάτισαν, ὥσπερ ϰαὶ τὸ δίϰαιον ἔϕερε ἐσαγαγόντων Φοινίϰων ἐς τὴν ῾Ελλάδα, ϕοινιϰήια ϰεϰλῆσϑαι.
永远不要让用户在 REST API 上执行系统命令。在exec示例中,我们让处理程序使用getCommandOutput辅助函数来执行系统命令。
在exec示例中定义的端点/api/v1/show-file/并不那么高效。使用httprouter,我们可以构建高级和性能优化的文件服务器。在下一节中,我们将学习如何做到这一点。
几分钟内构建一个简单的静态文件服务器
有时,一个 API 可以提供文件。除了路由之外,httprouter的另一个应用是构建高效的文件服务器。这意味着我们可以构建自己的内容交付平台。一些客户端需要从服务器获取静态文件。传统上,我们使用 Apache2 或 Nginx 来达到这个目的。如果必须完全使用 Go 创建类似的东西,他们可以利用httprouter。
让我们来构建一个。从 Go 服务器开始,为了提供静态文件,我们需要通过一个通用路由来路由它们,如下所示:
/static/*
计划使用http包的Dir方法来加载文件系统,并将它返回的文件系统处理程序传递给httprouter。我们可以使用httprouter实例的ServeFiles函数将路由器附加到文件系统处理程序。它应该为给定的公共目录中的所有文件提供服务。通常,静态文件保存在 Linux 机器上的/var/public/www文件夹中。在你的主目录中创建一个名为static的文件夹:
mkdir -p /users/git-user/static
现在,将我们为前一个示例创建的Latin.txt和Greek.txt文件复制到前面的静态目录中。完成此操作后,让我们按照以下步骤编写文件服务器的程序。你会对httprouter的简单性感到惊讶:
- 在以下路径创建一个程序:
touch -p $GOPATH/src/github.com/git-user/chapter2/fileServer/main.go
- 更新代码如下。你必须添加一个路由,将静态文件路径路由链接到文件系统处理程序:
package main
import (
"log"
"net/http"
"github.com/julienschmidt/httprouter"
)
func main() {
router := httprouter.New()
// Mapping to methods is possible with HttpRouter
router.ServeFiles("/static/*filepath",
http.Dir("/Users/git-user/static"))
log.Fatal(http.ListenAndServe(":8000", router))
}
- 现在运行服务器并查看输出:
go run $GOPATH/src/github.com/git-user/chapter2/fileServer/main.go
- 打开另一个终端并发出以下
curl请求:
http://localhost:8000/static/latin.txt
- 现在,输出将是一个来自我们的文件服务器的静态文件内容服务器:
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu.
在下一节中,我们将讨论一个广泛使用的 HTTP 路由器,称为gorilla/mux。
介绍 gorilla/mux – 一个强大的 HTTP 路由器
单词 Mux 代表多路复用器。gorilla/mux 是一个设计用于多路复用 HTTP 路由(URL)到不同处理器的多路复用器。处理器是可以处理给定请求的函数。gorilla/mux 是一个编写我们 API 服务器美丽路由的绝佳包。
gorilla/mux 提供了大量选项来控制如何对您的 Web 应用程序进行路由。它允许许多功能,例如:
-
基于路径的匹配
-
基于查询的匹配
-
基于域的匹配
-
基于子域的匹配
-
反向 URL 生成
应使用哪种类型的路由取决于请求服务器的客户端类型。我们首先看到安装,然后是一个基本示例,以了解 gorilla/mux 包。
安装 gorilla/mux
按照以下步骤安装 mux 包:
- 您需要在终端(Mac OS X 和 Linux)中运行此命令:
go get -u github.com/gorilla/mux
- 如果您收到任何错误消息说
package github.com/gorilla/mux: cannot download, $GOPATH not set. For more details see--go help gopath,请使用以下命令设置$GOPATH环境变量:
export GOPATH=~/go
- 正如我们在第一章“REST API 开发入门”中讨论的那样,所有包和程序都放入 GOPATH。它有三个文件夹:
bin、pkg和src。现在,将GOPATH添加到PATH变量中,以便将安装的 bin 文件作为没有./executable风格的系统实用程序使用。请参考以下命令:
PATH="$GOPATH/bin:$PATH"
- 这些设置将持续到您关闭计算机。因此,要使其成为永久更改,请将前面的行添加到您的
bash/zsh配置文件中:
vi ~/.profile
(or)
vi ~/.zshrc
我们可以在程序中导入 gorilla/mux,如下所示:
import "github.com/gorilla/mux"
现在,我们准备出发。假设 gorilla/mux 已安装,我们现在可以探索其基础知识。
gorilla/mux 的基础
gorilla/mux 包主要帮助创建路由器,类似于 httprouter。两者之间的区别在于将处理函数附加到给定的 URL 上。如果我们观察,gorilla/mux 附加处理函数的方式类似于基本的 ServeMux。与 httprouter 不同,gorilla/mux 将 HTTP 请求的所有信息封装到一个单一的请求对象中。
gorilla/mux API 提供的三个重要工具是:
-
mux.NewRouter方法 -
*http.Request对象 -
*http.ResponseWriter对象
NewRouter 方法创建一个新的路由器对象。该对象基本上将路由映射到函数处理器。gorilla/mux 将修改后的 *http.Request 和 *http.ResponseWriter 对象传递给函数处理器。这些特殊对象包含有关头部、路径参数、请求体和查询参数的大量附加信息。让我们解释如何在 gorilla/mux 中定义和使用不同类型的路由器,使用两种常见类型:
-
基于路径的匹配
-
基于查询的匹配
基于路径的匹配
HTTP GET 请求的 URL 中的路径参数看起来像这样:
https://example.org/articles/books/123
由于它们是在基础 URL 和 API 端点之后传递的,在这个例子中是https://example.org/articles/,因此它们被称为路径参数。在上面的 URL 中,books和123是路径参数。让我们看看如何创建可以消费作为路径参数提供的数据的路由的示例。按照以下步骤操作:
- 在以下路径创建我们程序的新的文件:
touch -p $GOPATH/src/github.com/git-user/chapter2/muxRouter/main.go
- 策略是创建一个新的路由器,
mux.NewRouter,并使用内置的http.Server作为处理程序。我们可以将 URL 端点附加到这个路由对象上的处理程序函数。附加的 URL 端点也可以是正则表达式。一个简单的程序,用于从客户端 HTTP 请求中收集路径参数并返回相同的内容,如下所示:
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/gorilla/mux"
)
func ArticleHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Category is: %v\n", vars["category"])
fmt.Fprintf(w, "ID is: %v\n", vars["id"])
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
- 现在在 shell 中使用以下命令运行服务器:
go run $GOPATH/src/github.com/git-user/chapter2/muxRouter/main.go
- 在另一个 shell 中发出
curl请求,我们可以得到以下输出:
curl http://localhost:8000/articles/books/123 Category is: books ID is: 123
这个示例展示了如何匹配和解析路径参数。还有另一种从 HTTP 请求中收集变量信息的方法,那就是使用查询参数。在下一节中,我们将看到如何创建匹配带有查询参数的 HTTP 请求的路由。
基于查询的匹配
查询参数是随 HTTP 请求一起传递的变量。这是我们通常在 REST GET请求中看到的内容。"gorilla/mux"路由可以匹配并收集查询参数。以下是一个示例 URL:
http://localhost:8000/articles?id=123&category=books
它有id和category作为查询参数。所有查询参数都开始于?字符之后。
让我们将我们之前的示例的副本修改成一个新的,命名为queryParameters/main.go。修改路由对象,使其指向一个新的名为QueryHandler的处理程序,如下所示:
// Add this in your main program
r := mux.NewRouter()
r.HandleFunc("/articles", QueryHandler)
在QueryHandler中,我们可以使用request.URL.Query()从 HTTP 请求中获取查询参数。"QueryHandler"看起来像这样:
// QueryHandler handles the given query parameters
func QueryHandler(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Got parameter id:%s!\n", queryParams["id"][0])
fmt.Fprintf(w, "Got parameter category:%s!", queryParams["category"][0])
}
这个程序与之前的示例类似,但处理查询参数而不是路径参数。
运行新的程序:
go run $GOPATH/src/github.com/git-user/chapter2/queryParameters/main.go
在终端中按照以下格式发出curl请求:
curl -X GET http://localhost:8000/articles\?id\=1345\&category\=birds
我们需要在 shell 中转义特殊字符。如果在浏览器中,则没有转义的问题。输出看起来像这样:
Got parameter id:1345!
Got parameter category:birds!
r.URL.Query()函数返回一个包含所有参数和值对的映射。它们基本上是字符串,为了在程序逻辑中使用它们,我们需要将数字字符串转换为整数。我们可以使用 Go 的strconv包将字符串转换为整数,反之亦然。
我们已经使用http.StatusOK来写入成功的 HTTP 响应。同样,为不同的 REST 操作使用适当的状态码。例如,404 – 未找到,500 – 服务器错误,等等。
猩猩/mux 的其他显著特性
我们已经看到了两个基本示例。接下来是什么?gorilla/mux包提供了许多方便的特性,使得 API 开发者的生活变得容易。它在创建路由时提供了很多灵活性。在本节中,我们尝试讨论一些重要特性。第一个引人注目的特性是使用反向映射技术生成动态 URL。
简而言之,反向映射 URL 是获取 API 资源的完整 API 路由。反向映射在分享我们的 Web 应用程序或 API 链接时非常有用。然而,为了从数据创建 URL,我们应该将一个Name与gorilla/mux路由关联起来。你可以像这样命名多路复用路由:
r.HandlerFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler).
Name("articleRoute")
现在,我们可以通过使用url方法获取动态生成的 API 路由:
url, err := r.Get("articleRoute").URL("category", "books", "id", "123")
fmt.Printf(url.Path) // prints /articles/books/123
如果一个路由包含定义的附加路径参数,我们应该将那些数据作为参数传递给URL方法。
URL 路由的下一个重要特性是路径前缀。路径前缀是一个通配符路由,用于匹配所有可能的路径。它匹配所有以根词为前缀的 API 服务器路由。路径前缀的一般用例是静态文件服务器。然后,当我们从静态文件夹中提供文件时,API 路径应该与文件系统路径匹配,以成功返回文件内容。
例如,如果我们定义/static/为路径前缀,那么具有此根词作为前缀的每个 API 路由都将被路由到附加的处理程序。
这些路径将被匹配:
-
http://localhost:8000/static/js/jquery.min.js -
http://localhost:8000/static/index.html -
http://localhost:8000/static/some_file.extension
使用 gorilla/mux 的PathPrefix和StripPrefix方法,我们可以编写一个静态文件服务器,如下所示:
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("/tmp/static"))))
下一个重要特性是严格斜杠。在gorilla/mux路由器上激活严格斜杠允许 URL 重定向到末尾添加了/的相同 URL,反之亦然。
例如,假设我们有一个附加到ArticleHandler处理器的/articles/路由:
r.StrictSlash(true)
r.Path("/articles/").Handler(ArticleHandler)
在前一个例子中,严格斜杠设置为true。然后,路由器会将/articles(末尾没有'/')重定向到ArticleHandler。如果设置为false,路由器将/articles/和/articles视为不同的路径。
URL 路由的下一个重要特性是匹配编码的路径参数。可以在路由器上调用gorilla/mux UseEncodedPath方法来匹配编码的路径参数。
服务器可以从几个客户端接收编码路径。我们可以匹配编码的路径参数,甚至可以匹配编码的 URL 路由并将其转发给指定的处理器:
r.UseEncodedPath()
r.NewRoute().Path("/category/id")
这可以匹配以下 URL:
http://localhost:8000/books/2
此外:
http://localhost:8000/books%2F2
其中%2F2代表编码形式中的/2。
它的模式匹配特性和简单性使得gorilla/mux成为 HTTP 路由器在项目中的热门选择。全球许多成功的项目已经使用 mux 来满足他们的路由需求。
我们可以自由地为我们的应用程序定义路由。由于路由是任何 API 的入口点,因此开发者应该小心处理从客户端接收到的数据。客户端也可能是攻击者,他们可以在路径或查询参数中注入恶意脚本。这种情况被称为 安全漏洞。API 容易受到一种常见的应用程序漏洞,即 SQL 注入。在下一节中,我们将简要介绍它并查看可能的对策步骤。
URL 中的 SQL 注入及其避免方法
SQL 注入 是一种使用恶意脚本攻击数据库的过程。在定义 URL 路由时如果不小心,可能会给 SQL 注入留下机会。这些攻击可能发生在所有类型的 REST 操作中。例如,如果我们允许客户端向服务器传递参数,那么攻击者就有可能向这些参数中添加格式不正确的字符串。如果我们直接将这些变量/参数用于在数据库上执行的 SQL 查询,可能会导致潜在的安全漏洞。
看以下 Go 代码片段,它将 username 和 password 详细信息插入到数据库中。它从 HTTP POST 请求中收集值并将原始值附加到 SQL 查询中:
username := r.Form.Get("id")
password := r.Form.Get("category")
sql := "SELECT * FROM article WHERE id='" + username + "' AND category='" + password + "'"
Db.Exec(sql)
在这个片段中,我们正在执行数据库 SQL 查询,但由于我们直接附加了值,我们可能会在查询中包含恶意 SQL 语句,如 -- 注释和 ORDER BY n 范围子句:
?category=books&id=10 ORDER BY 10--
如果应用程序直接将数据库响应返回给客户端,可能会泄露有关表列的信息。攻击者可以将 ORDER BY 改为另一个数字并提取敏感信息:
Unknown column '10' in 'order clause'
我们将在接下来的章节中了解更多关于这一点,其中我们将使用其他方法,如 POST、PUT 等,构建完整的 REST 服务:
那么,如何避免这些注入呢?有几种预防措施:
-
将用户权限设置为数据库中的各种表
-
记录请求并找出可疑的请求
-
使用 Go 的
text/template包中的HTMLEscapeString函数来转义 API 参数中的特殊字符,例如body和path -
使用驱动程序程序而不是执行原始 SQL 查询
-
停止将数据库调试消息回传给客户端
-
使用安全工具如
sqlmap来发现漏洞
在了解了路由和安全的基础之后,在下一节中,我们将向读者提出一个有趣的挑战。那就是创建一个 URL 缩短服务。我们将在下一节中简要提供所有背景细节。
读者挑战 - URL 缩短 API
在学习了到目前为止的所有基础知识之后,尝试实现一个 URL 缩短服务。URL 缩短器接受一个非常长的 URL,并返回一个简短、清晰且易于记忆的 URL 给用户。乍一看,这似乎像魔术,但实际上是一个简单的数学技巧。
在一个单独的语句中,URL 缩短服务建立在两个东西之上:
-
一种将长字符串映射到短字符串的字符串映射算法(Base 62)
-
一个简单的网络服务器,将短 URL 重定向到原始 URL
URL 缩短有几个明显的优势:
-
用户可以记住 URL;易于维护
-
用户可以在文本长度有限的地方使用链接,例如 Twitter
-
可预测的缩短 URL 长度
看看以下图表:

在 URL 缩短服务的底层,以下事情会发生:
-
取原始 URL
-
对其应用 BASE62 编码;它生成一个 缩短的 URL
-
将该 URL 存储在数据库中。将其映射到原始 URL (
[shortened_url: original_url]) -
当请求到达缩短的 URL 时,只需进行 HTTP 重定向到原始 URL
当我们在 API 服务器中集成数据库时,我们将在接下来的章节中实现一个完整的示例,但在那之前,我们应该指定 API 设计文档。
看看以下表格:
| URL | REST 动词 | 操作 | 成功 | 失败 |
|---|---|---|---|---|
/api/v1/new |
POST |
创建缩短的 URL |
200 |
500, 404 |
/api/v1/:url |
GET |
重定向到原始 URL |
301 |
404 |
你现在可以使用一个虚拟的 JSON 文件/Go 地图来存储 URL,而不是使用数据库。
概述
在本章中,我们首先介绍了 HTTP 路由器。我们尝试使用 Go 的 net/http 包创建 HTTP 路由。然后,我们通过一个示例简要讨论了 ServeMux。我们看到了如何向多个路由添加多个处理函数。然后,我们介绍了一个名为 httprouter 的轻量级路由包,它允许开发者创建优雅的路由,并可选择解析 URL 路径中传递的参数。
我们还可以使用 httprouter 通过 HTTP 提供文件。我们构建了一个小型服务来获取 Go 版本和文件内容(只读)。该示例可以扩展为获取任何系统信息或运行系统命令。
接下来,我们介绍了流行的 Go 路由库 gorilla/mux。我们讨论了它与 httprouter 的不同之处,并通过实现两个示例来探索其功能。我们解释了如何使用 Vars 获取路径参数和 r.URL.Query 解析查询参数。
作为保护 API 路由的一部分,我们讨论了 SQL 注入及其在我们的应用程序中可能发生的方式。我们还看到了对策。在本章结束时,可以定义路由和处理函数以接受 HTTP API 请求。
在下一章中,我们将探讨 Middleware 函数,这些函数作为 HTTP 请求和响应的篡改者。这种现象帮助我们即时修改 API 响应。下一章还将介绍 远程过程调用(RPC)。
第三章:与中间件和 RPC 一起工作
在本章中,我们将探讨两个新的概念。首先,我们将学习中间件,以及如何从头开始构建它。然后,我们将转向社区编写的一个更好的中间件解决方案,称为Gorilla 处理器。然后,我们将看到中间件有帮助的使用案例。之后,我们将学习使用 Go 的内部 RPC 和 JSON-RPC 开发远程过程调用(RPC)服务。然后,我们将转向一个名为 Gorilla HTTP RPC 的高级 RPC 框架。
本章涉及的主题如下:
-
什么是中间件?
-
多个中间件和链式调用
-
使用
alice轻松实现中间件链式调用 -
使用 Gorilla 处理器中间件进行日志记录
-
RPC 是什么?
-
使用 Gorilla RPC 进行 JSON-RPC
技术要求
以下软件应预先安装以运行代码示例:
-
操作系统:Linux(Ubuntu 18.04)/Windows 10/Mac OS X >= 10.13
-
软件:Docker >= 18(适用于 Windows 和 Mac OS X 的 Docker Desktop)
-
Go 最新版本编译器 >= 1.13.5
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter3下载本章的代码。克隆代码并使用chapter3目录中的代码示例。
什么是中间件?
中间件是一个实体,它可以钩入服务器的请求/响应生命周期。中间件可以定义在许多组件中。每个组件都有特定的功能来执行。每当我们在 URL 模式(如第二章,处理我们的 REST 服务路由)中定义处理器时,处理器就会为每个传入的请求执行一些业务逻辑。但是,正如其名称所指定的,中间件位于请求和处理器之间,或者位于处理器和响应之间。因此,几乎每个中间件都可以执行以下功能:
-
在到达处理器(函数)之前处理请求
-
将修改后的请求传递给处理器函数(执行一些业务逻辑)
-
处理来自处理器的响应
-
将修改后的响应传递给客户端
我们可以将前面的点以以下图表的形式进行视觉说明:

如果我们仔细观察图表,请求的旅程是从客户端开始的。请求首先到达一个名为AUTH MIDDLEWARE的中间件,然后被转发到FUNCTION HANDLER。一旦处理器生成响应,它就会被转发到另一个名为CUSTOM MIDDLEWARE的中间件,该中间件可以修改响应。
在没有中间件的程序中,请求直接到达 API 服务器并由函数处理器处理。响应立即从服务器发送,客户端接收它。但在配置了中间件到函数处理器的程序中,它可以通过一系列阶段,如记录、认证、会话验证等,然后继续到业务逻辑。这是为了过滤请求与业务逻辑的交互。最常见的情况如下:
-
使用记录器记录每个 REST API 的请求
-
验证用户的会话并保持通信活跃
-
如果未识别,则验证用户
-
在服务客户端时附加属性到响应
通过中间件的帮助,我们可以在适当的位置执行任何家务工作,例如认证。让我们创建一个基本的中间件并在 Go 中篡改 HTTP 请求。
当许多函数处理器有相同业务逻辑要执行时,中间件函数很有用。
创建一个基本的中间件
构建中间件函数既简单又直接。让我们基于从第二章《处理我们的 REST 服务路由》中获得的知识构建一个程序。如果你不熟悉闭包函数,闭包函数返回另一个函数。这个原则帮助我们编写中间件。一个中间件应该返回另一个函数,这个函数可以是中间件或函数处理器。这类似于 JavaScript 链式方法,其中一个函数返回一个新的函数作为返回值。让我们在 Go 中通过以下方式创建一个闭包函数:
- 创建一个程序文件,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/closureExample/main.go
我们使用此文件添加我们的代码。
- 闭包函数返回另一个函数。让我们创建一个闭包函数,该函数使用以下代码生成正整数:
// This function returns another function
func generator() func() int { // Outer function
var i = 0
return func() int { // Inner function
i++
return i
}
}
函数是一个生成器,它返回一系列整数。生成器模式根据给定的条件每次生成一个新项目。内部函数返回一个无参数的匿名函数,返回类型为整数。在外部函数内部定义的i变量对匿名函数可用,使其能够在即将到来的函数调用之间记住状态。
- 现在,我们可以在我们的
main程序中使用之前的生成器,如下所示:
package main
import (
"fmt"
)
...
func main() {
numGenerator := generator()
for i := 0; i < 5; i++ {
fmt.Print(numGenerator(), "\t")
}
}
- 我们可以将之前的代码作为一个独立的程序运行,如下所示:
go run $GOPATH/src/github.com/git-user/chapter3/closureExample/main.go
以下数字将使用制表符空格生成并打印:
1 2 3 4 5
在 Go 中,外部函数的函数签名应该与匿名函数的签名完全匹配。在上一个例子中,func() int是外部和内部函数的签名。唯一的例外是外部函数可以有一个接口作为返回类型,而内部函数可以实现该接口。我们将在接下来的几行中看到这一点。
现在,让我们来看看闭包如何帮助构建中间件:任何可以返回满足http.Handler接口的另一个函数的生成器函数都可以作为中间件。以下是一个示例,以验证这个陈述:
- 为我们的程序创建一个文件,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/customMiddleware/main.go
- 中间件接受一个正常的 HTTP 处理器函数作为其参数,并返回另一个处理器函数。该函数看起来像这样:
func middleware(originalHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
fmt.Println("Executing middleware before request phase!")
// Pass control back to the handler
originalHandler.ServeHTTP(w, r)
fmt.Println("Executing middleware after response phase!")
})
}
如果你注意前面的中间件函数,它接受originalHandler,一个 HTTP 处理器,作为其参数,并返回另一个 HTTP 处理器。内部函数使用原始处理器来执行逻辑。在处理器之前和之后是中间件对请求和响应对象进行操作的地方。这使得所有发送到主处理器的请求都通过中间件逻辑。
- 现在,让我们定义使用我们创建的中间件函数的主要逻辑,如下所示:
package main
import (
"fmt"
"net/http"
)
func handle(w http.ResponseWriter, r *http.Request) {
// Business logic goes here
fmt.Println("Executing mainHandler...")
w.Write([]byte("OK"))
}
func main() {
// HandlerFunc returns a HTTP Handler
originalHandler := http.HandlerFunc(handle)
http.Handle("/", middleware(originalHandler))
http.ListenAndServe(":8000", nil)
}
- 运行代码,如下所示:
go run $GOPATH/src/github.com/git-user/chapter3/customMiddleware/main.go
- 如果你使用
curl请求或在浏览器中访问http://localhost:8000,控制台将收到以下消息:
Executing middleware before request phase!
Executing mainHandler...
Executing middleware after response phase!
在前面的图中,该程序由右侧的矩形块表示,标签为 CUSTOM MIDDLEWARE。如果你观察之前提供的中间件视觉说明,请求阶段的方向是向右,响应方向是向左。
Go 网络框架,如 Martini 和 Gin,默认提供中间件。我们将在第四章中了解更多关于它们的信息,使用流行的 Go 框架简化 RESTful 服务。了解中间件的底层细节对开发者来说是有益的。
以下图表可以帮助你理解中间件中逻辑流程是如何发生的。此图表解释了处理器是如何转换为包装处理器的:

我们已经看到了简单中间件的创建,但在实际场景中,需要多个中间件来记录请求、进行身份验证等。在下一节中,我们将看到如何链式连接多个中间件。
多个中间件和链式连接
在上一节中,我们构建了一个单个中间件,在请求击中处理器之前或之后执行操作。也可以链式连接一组中间件。为了做到这一点,我们应该遵循上一节中相同的闭包逻辑。让我们创建一个cityAPI程序来保存城市详情。为了简单起见,该 API 将有一个POST方法,并且正文将包含两个字段:城市名称和城市面积。
让我们考虑一个场景,即客户端只能向 API 发送 JSON Content-Type请求。API 的主要功能是向客户端发送响应,并附加 UTC 时间戳 cookie。我们可以在中间件中添加这个内容检查。
两个中间件的功能如下:
-
在第一个中间件中,检查内容类型是否为 JSON。如果不是,则不允许请求继续进行。
-
在第二个中间件中,将一个名为 Server-Time (UTC) 的时间戳添加到响应 cookie 中。
在添加中间件之前,让我们创建一个POST API,该 API 收集城市的名称和区域,并返回状态码为201的消息,以表明它已被成功创建。这可以通过以下方式完成:
- 创建一个用于程序的文件,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/cityAPI/main.go
- 现在,编写处理客户端
POST请求的函数。它解码请求体并读取名称和区域,并将它们填充到名为city的结构体中,如下所示:
type city struct {
Name string
Area uint64
}
func postHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
var tempCity city
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&tempCity)
if err != nil {
panic(err)
}
defer r.Body.Close()
fmt.Printf("Got %s city with area of %d sq miles!\n",
tempCity.Name, tempCity.Area)
w.WriteHeader(http.StatusOK)
w.Write([]byte("201 - Created"))
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("405 - Method Not Allowed"))
}
}
postHandler在这个片段中处理客户端请求。如果客户端尝试执行GET请求,则返回状态码405 - Method Not Allowed。json.NewDecoder用于从请求中读取请求体。Decode将请求体参数映射到city类型的结构体。
- 现在是主要逻辑,如下所示:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/city", postHandler)
http.ListenAndServe(":8000", nil)
}
- 我们可以使用以下代码启动 API 服务器:
go run $GOPATH/src/github.com/git-user/chapter3/cityAPI/main.go
- 然后,发送几个
curl请求,如下所示:
curl -H "Content-Type: application/json" -X POST http://localhost:8000/city -d '{"name":"New York", "area":304}'
curl -H "Content-Type: application/json" -X POST http://localhost:8000/city -d '{"name":"Boston", "area":89}'
- 服务器记录了以下输出:
Got New York city with area of 304 sq miles!
Got Boston city with area of 89 sq miles!
curl的响应如下:
201 - Created
201 - Created
- 现在是内容检查环节。为了链式调用中间件函数,我们必须在多个中间件之间传递处理器。在先前的例子中只有一个处理器参与。但现在,对于即将到来的任务,我们的想法是将主处理器传递给多个中间件处理器。我们可以将
cityAPI程序修改为一个新的文件,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/multipleMiddleware/main.go
- 让我们先创建内容检查中间件。让我们称它为
filterContentType。此中间件检查请求的MIME头,如果它不是 JSON,则返回状态码为415- Unsupported Media Type的响应,如下面的代码块所示:
func filterContentType(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
log.Println("Currently in the check content type middleware")
// Filtering requests by MIME type
if r.Header.Get("Content-type") != "application/json" {
w.WriteHeader(http.StatusUnsupportedMediaType)
w.Write([]byte("415 - Unsupported Media Type. Please send JSON"))
return
}
handler.ServeHTTP(w, r)
})
}
- 现在,让我们定义第二个中间件,称为
setServerTimeCookie。在向客户端发送响应后收到适当的内容类型,此中间件会添加一个名为Server-Time(UTC)的 cookie,其值为服务器的 UTC 时间戳,如下面的代码块所示:
func setServerTimeCookie(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter,
r *http.Request) {
handler.ServeHTTP(w, r)
// Setting cookie to every API response
cookie := http.Cookie{Name: "Server-Time(UTC)",
Value: strconv.FormatInt(time.Now().Unix(), 10)}
http.SetCookie(w, &cookie)
log.Println("Currently in the set server time middleware")
})
}
- 主函数在将路由映射到处理器方面略有变化。它使用嵌套函数调用进行中间件链式调用,如下所示:
func main() {
originalHandler := http.HandlerFunc(handle)
http.Handle("/city",
filterContentType(setServerTimeCookie(originalHandler)))
http.ListenAndServe(":8000", nil)
}
我们通过使用filterContentType(setServerTimeCookie(originalHandler))来链式调用中间件。请仔细观察链式调用的顺序。
- 现在,按照以下方式运行更新后的服务器:
go run $GOPATH/src/github.com/git-user/chapter3/multipleMiddleware/main.go
然后,发送一个curl请求,如下所示:
curl -i -H "Content-Type: application/json" -X POST http://localhost:8000/city -d '{"name":"Boston", "area":89}'
响应输出如下:
HTTP/1.1 200 OK
Date: Sat, 27 May 2017 14:35:46 GMT
Content-Length: 13
Content-Type: text/plain; charset=utf-8
201 - Created
- 但如果我们从
curl请求中移除Content-Type: application/json,中间件将阻止我们执行主处理器,如下面的代码块所示:
curl -i -X POST http://localhost:8000/city -d '{"name":"New York", "area":304}'
Result:HTTP/1.1 415 Unsupported Media Type
Date: Sat, 27 May 2017 15:36:58 GMT
Content-Length: 46
Content-Type: text/plain; charset=utf-8
415 - Unsupported Media Type. Please send JSON
这是在 Go API 服务器中链式调用中间件的最简单方式。
如果一个 API 服务器希望请求通过许多中间件,那么我们如何使链式调用简单且易于阅读?有一个非常好的库叫做alice来解决这个问题。它允许你以语义顺序将中间件附加到主处理器。我们将在下一节简要介绍它。
使用 Alice 实现无痛苦的中件间链式调用
当中间件列表很长时,alice 库简化了中间件链的连接。它为我们提供了一个干净的 API 来将处理器传递给中间件。这是一个轻量级的解决方案,与其他中间件链的 Go 包不同。
使用以下命令安装 alice:
go get github.com/justinas/alice
现在,我们可以在我们的程序中导入 alice 包并立即使用它。我们可以修改之前程序的相应部分以实现相同的功能,并改进链式调用。让我们将程序目录命名为 multipleMiddlewareWithAlice,并在该位置创建一个主程序:
touch -p $GOPATH/src/github.com/git-user/chapter3/multipleMiddlewareWithAlice/main.go
在 import 部分,添加 github.com/justinas/alice,如下面的代码片段所示:
import (
"encoding/json"
"github.com/justinas/alice"
"log"
"net/http"
"strconv"
"time"
)
现在,在 main 函数中,我们可以修改处理器部分,如下所示:
func main() {
originalHandler := http.HandlerFunc(handle)
chain := alice.New(filterContentType,
setServerTimeCookie).Then(originalHandler)
http.Handle("/city", chain)
http.ListenAndServe(":8000", nil)
}
这个程序的输出与上一个程序类似。在了解前面的概念之后,让我们使用 Gorilla 工具包中的 handlers 库构建一个日志中间件。
使用 Gorilla handlers 中间件进行日志记录
Gorilla handlers 包提供了各种预先编写的中间件以供常见任务使用。列表中最重要的是:
-
LoggingHandler:用于 Apache 通用日志格式(CLF)的日志记录 -
CompressionHandler:用于压缩响应 -
RecoveryHandler:用于从意外的恐慌中恢复
在这里,我们使用 LoggingHandler 中间件执行 API 全局日志记录。首先,使用以下方式使用 go get 安装此库:
go get "github.com/gorilla/handlers"
这个日志服务器使我们能够创建一个带有时间和选项的服务器样式的日志。例如,当你看到 apache.log 时,你会在标准格式中找到日志,如下面的代码块所示:
192.168.2.20 - - [28/Jul/2006:10:27:10 -0300] "GET /cgi-bin/try/ HTTP/1.0" 200 3395
127.0.0.1 - - [28/Jul/2006:10:22:04 -0300] "GET / HTTP/1.0" 200 2216
格式如下:IP-日期-方法:端点-响应状态。编写模仿 Apache 风格日志的自定义中间件需要一些努力,但 Gorilla Handlers 已经为我们实现了。让我们通过创建一个新的程序来更新之前的程序,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/loggingMiddleware/main.go
现在,让我们按照以下步骤编写程序:
-
首先,我们创建一个
Gorilla路由器并将其附加到LoggingHandler。 -
LoggingHandler注册一个标准输出(在我们的例子中,是os.Stdout)并返回一个新的路由器。我们使用这个新的路由器将 HTTP 服务器注册,如下面的代码块所示:
package main
import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"log"
"os"
"net/http"
)
func handle(w http.ResponseWriter, r *http.Request) {
log.Println("Processing request!")
w.Write([]byte("OK"))
log.Println("Finished processing request")
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/", handle)
loggedRouter := handlers.LoggingHandler(os.Stdout, r)
http.ListenAndServe(":8000", loggedRouter)
}
- 通过运行以下代码启动服务器:
go run $GOPATH/src/github.com/git-user/chapter3/loggingMiddleware/main.go
- 现在,在浏览器中打开
http://127.0.0.1:8000,或者发送一个curl请求,你将看到以下输出:
2017/05/28 10:51:44 Processing request!
2017/05/28 10:51:44 Finished processing request
127.0.0.1 - - [28/May/2017:10:51:44 +0530] "GET / HTTP/1.1" 200 2
127.0.0.1 - - [28/May/2017:10:51:44 +0530] "GET /favicon.ico HTTP/1.1" 404 19
如果你观察,最后两个日志是由中间件生成的。Gorilla LoggingMiddleware 在响应时写入它们。
在之前的例子中,我们总是在本地主机上检查 API。在这个例子中,我们明确指定用 127.0.0.1 替换本地主机,因为前者在日志中会显示为空 IP。
在程序方面,我们正在导入 gorilla/mux 路由器和 gorilla/handlers。然后,我们将一个名为 handle 的处理程序附加到路由器上。接下来,我们将路由器包装在 handlers.LoggingHandler 中间件中。它返回一个额外的处理程序,我们可以安全地将其传递给 http.ListenAndServe。
您也可以尝试 handlers 中的其他中间件。本节的目标是向您介绍 gorilla/handlers。Go 有许多其他外部包可用。有一个值得注意的库是直接在 net/http 上编写中间件的库,它是 Negroni (github.com/urfave/negroni)。它还提供了 alice,Gorilla LoggingHandler 的功能。所以,请查看它。
我们可以使用名为 go.uuid 的库(github.com/satori/go.uuid)和 cookies,轻松构建基于 cookie 的身份验证中间件。
系统通过 Web 服务相互交谈。客户端 API 可以由多个服务器实例提供支持。RPC 是一种以可理解的方式将工作委托给远程服务器的机制。RPC 是 Go 中的一个重要概念,因为它可以在支持客户端的 REST 服务中发挥作用。Gorilla 工具包提供了支持 RPC 的包。我们将在下一节中详细了解它。
RPC 是什么?
RPC 是一种进程间通信,它在不同分布式系统之间交换信息。一台称为 Alice 的计算机可以在另一台称为 Bob 的计算机上以协议格式调用函数(过程),并获取计算结果。我们可以在不实现本地功能的情况下,从另一个地方或地理区域请求网络上的事物。
整个过程可以分解为以下步骤:
-
客户端准备要发送的函数名和参数
-
客户端通过拨号连接将它们发送到 RPC 服务器
-
服务器接收函数名和参数
-
服务器执行远程进程
-
消息将被发送回客户端
-
客户端从请求中收集数据并适当使用
服务器需要暴露其服务,以便客户端连接并请求远程过程。请查看以下图表:

Go 提供了一个库来实现 RPC 服务器 和 RPC 客户端。在上面的图表中,RPC 客户端使用主机和端口等详细信息拨号连接。它随请求发送两件事。一是参数,二是回复指针。由于它是一个指针,服务器可以修改它并发送回来。然后,客户端可以使用填充到指针中的数据。Go 有两个库,net/rpc 和 net/rpc/jsonrpc,用于处理 RPC。让我们编写一个与客户端交谈并发送服务器时间的 RPC 服务器。
创建 RPC 服务器
让我们创建一个简单的 RPC 服务器,将 UTC 服务器时间发送回 RPC 客户端。RPC 服务器和 RPC 客户端应该就以下两点达成一致:
-
传递的参数
-
返回的值
这两个参数的类型应该完全匹配服务器和客户端。让我们看看创建 RPC 服务器的步骤,如下所示:
- 让我们创建一个 RPC 服务器程序,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/rpcServer/main.go
-
我们应该创建一个
Args struct和一个回复指针来保存 RPC 调用的数据。 -
然后,创建一个远程客户端要执行的功能,命名为
GiveServerTime,如下面的代码块所示:
type Args struct{}
type TimeServer int64
func (t *TimeServer) GiveServerTime(args *Args, reply *int64) error {
// Fill reply pointer to send the data back
*reply = time.Now().Unix()
return nil
}
- 现在,我们可以使用名为
rpc.Register的方法激活TimeServer。主逻辑如下:
package main
import (
"log"
"net"
"net/http"
"net/rpc"
"time"
)
func main() {
timeserver := new(TimeServer)
rpc.Register(timeserver)
rpc.HandleHTTP()
// Listen for requests on port 1234
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)
}
从前面的示例中,我们可以注意以下几点:
-
GiveServerTime将Args对象作为第一个参数和一个回复指针对象 -
它设置了回复指针对象,但没有返回任何内容,除了错误信息
-
这里
Args结构体没有字段,因为这个服务器不期望从客户端接收任何参数
在使用此 RPC 服务器之前,让我们也编写 RPC 客户端。
创建 RPC 客户端
客户端也使用相同的 net/rpc 包,但使用不同的方法拨号到服务器并执行远程函数。获取数据的唯一方法是将回复指针对象与请求一起传递。让我们看看创建 RPC 客户端的步骤,如下所示:
- 让我们定义这个客户端程序,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/rpcClient/main.go
- 客户端使用
rpc.DialHTTP方法拨号到 RPC 服务器。它返回一个client对象。一旦拨号成功,就可以使用client.Call方法执行远程函数,如下面的代码块所示:
package main
import (
"log"
"net/rpc"
)
type Args struct {
}
func main() {
var reply int64
args := Args{}
client, err := rpc.DialHTTP("tcp", "localhost"+":1234")
if err != nil {
log.Fatal("dialing:", err)
}
err = client.Call("TimeServer.GiveServerTime",
args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
log.Printf("%d", reply)}
- 现在,我们可以运行服务器和客户端以观察它们的工作情况。这会运行服务器,如下所示:
go run $GOPATH/src/github.com/git-user/chapter3/rpcServer/main.go
- 现在,打开另一个 shell 标签并运行此命令,如下所示:
go run $GOPATH/src/github.com/git-user/chapter3/rpcClient/main.go
现在,服务器控制台将输出以下 Unix 时间字符串:
2017/05/28 19:26:31 1495979791
你看到了这个魔法吗?客户端作为一个独立的程序在服务器上运行。在这里,这两个程序可以位于不同的机器上,计算仍然可以共享。这是分布式系统的核心概念。任务被分割并分配给各个 RPC 服务器。最后,客户端收集结果并使用它们做出进一步的决策。
RPC 应该被加密,因为它正在执行远程函数。在从客户端收集请求时,授权是必须的。
自定义 RPC 代码仅在客户端和服务器都使用 Go 语言编写时才有用。因此,为了使 RPC 服务器被多个服务消费,我们需要定义通过 HTTP 的 JSON-RPC。然后,任何其他编程语言都可以发送 JSON 字符串并获取 JSON 作为结果返回。
使用 Gorilla RPC 的 JSON-RPC
我们看到 Gorilla 工具包通过提供许多有用的库来帮助我们。它有如 Mux 用于路由、Handlers 用于中间件,现在还有 gorilla/rpc 库。使用这个库,我们可以创建使用 JSON 而不是自定义回复指针进行通信的 RPC 服务器和客户端。让我们将前面的示例转换为更有用的一个。
考虑这个场景。我们在服务器上有一个包含书籍详细信息(名称、ID、作者)的 JSON 文件。客户端通过发送 HTTP 请求来请求书籍信息。当 RPC 服务器接收到请求时,它会从文件系统中读取文件并解析它。如果给定的 ID 与任何书籍匹配,则服务器以 JSON 格式将信息发送回客户端。让我们看看这里的步骤:
- 我们可以使用
go get命令安装 Gorilla RPC:
go get github.com/gorilla/rpc
这个包源自标准的 net/rpc 包,但在每次调用时使用单个 HTTP 请求而不是持久连接。与其他 net/rpc 相比的不同之处将在接下来的几行中解释。可以在同一服务器上注册多个编解码器。编解码器是根据请求的 Content-Type 头部来选择的。服务方法也接收 http.Request 作为参数。
- 现在,让我们编写一个 RPC JSON 服务器。在这里,我们正在实现 JSON 1.0 规范。对于 2.0,你应该使用 Gorilla JSON2。让我们定义一个示例 JSON 文件,其中包含有关书籍的信息,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter3/jsonRPCServer/books.json
- 让我们在 JSON 文件中添加一些书籍,如下所示:
[
{
"id": "1234",
"name": "In the sunburned country",
"author": "Bill Bryson"
},
{
"id":"2345",
"name": "The picture of Dorian Gray",
"author": "Oscar Wilde"
}
]
- 现在,我们有一个书籍数据库文件(在这个例子中是 JSON)。让我们编写一个与前面示例类似的 RPC 服务器,通过运行以下代码:
touch -p $GOPATH/src/github.com/git-user/chapter3/jsonRPCServer/main.go
- 流程是定义一个结构体来保存书籍的类型。然后,创建一个
JSONServer结构体用于与 RPC 服务器注册。它应该有一个作为 RPC 动作的方法。使用内置的filepath实用函数从给定的文件中读取 JSON 文件。JSONServer的reply参数填充了匹配的书籍信息,如下面的代码块所示:
// Args holds arguments passed to JSON-RPC service
type Args struct {
ID string
}
// Book struct holds Book JSON structure
type Book struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Author string `json:"author,omitempty"`
}
type JSONServer struct{}
// GiveBookDetail is RPC implementation
func (t *JSONServer) GiveBookDetail(r *http.Request, args *Args, reply *Book) error {
var books []Book
// Read JSON file and load data
absPath, _ := filepath.Abs("chapter3/books.json")
raw, readerr := ioutil.ReadFile(absPath)
if readerr != nil {
log.Println("error:", readerr)
os.Exit(1)
}
// Unmarshal JSON raw data into books array
marshalerr := jsonparse.Unmarshal(raw, &books)
if marshalerr != nil {
log.Println("error:", marshalerr)
os.Exit(1)
}
// Iterate over each book to find the given book
for _, book := range books {
if book.ID == args.ID {
// If book found, fill reply with it
*reply = book
break
}
}
return nil
}
它看起来与上一个示例相似,但一个明显的区别是,这里的服务器期望从客户端接收一个 ID。这个 ID 是从 JSON 中获取书籍的关键。在 GiveBookDetail 中,我们使用 ioutil.ReadFile 读取文件内容并将其反序列化到 books 结构体中。然后我们遍历书籍列表以匹配键,并将匹配的书籍填充到 reply 指针中。
- 现在,让我们完成
main块,将之前定义的JSONServer作为 RPC 服务进行注册,如下所示:
package main
import (
jsonparse "encoding/json"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"github.com/gorilla/mux"
"github.com/gorilla/rpc"
"github.com/gorilla/rpc/json"
)
func main() {
// Create a new RPC server
s := rpc.NewServer()
// Register the type of data requested as JSON
s.RegisterCodec(json.NewCodec(), "application/json")
// Register the service by creating a new JSON server
s.RegisterService(new(JSONServer), "")
r := mux.NewRouter()
r.Handle("/rpc", s)
http.ListenAndServe(":1234", r)
}
这里的一个细微差别是我们必须使用 RegisterCodec 方法注册编解码器类型。在这种情况下是 JSON 编解码器。然后,我们可以使用 RegisterService 方法注册服务并启动一个正常的 HTTP 服务器。如果你注意到了,我们使用了 jsonparse 作为 encoding/json 包的别名,因为它可能会与另一个包 github.com/gorilla/rpc/json 冲突。
- 我们可以像这样启动这个
jsonRPCServer:
go run $GOPATH/src/github.com/git-user/chapter3/jsonRPCServer/main.go
- 现在,我们是否必须开发一个客户端?不一定,因为客户端可以是一个
curl程序,因为 RPC 服务器正在通过 HTTP 提供服务,我们需要发送带有书籍 ID 的 JSON 来获取详细信息。所以,打开另一个 shell 并执行这个curl请求:
curl -X POST \
http://localhost:1234/rpc \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{
"method": "JSONServer.GiveBookDetail",
"params": [{
"ID": "1234"
}],
"id": "1"
}'
- 输出将是一个直接从 JSON-RPC 服务器提供的漂亮的 JSON,如下所示:
{"result":{"id":"1234","name":"In the sunburned country","author":"Bill Bryson"},"error":null,"id":"1"}
当多个客户端技术需要连接到您的 RPC 服务时,请将 JSON-RPC 作为首选选择。
RPC 是在编程语言中定义类型化服务的一种非常常见的方式。Gorilla 工具包是在使用 Go 语言时一个非常实用的包。当需要提供 REST JSON API 时,你可以继续使用 JSON-RPC。
摘要
在本章中,我们首先探讨了什么是中间件以及中间件如何处理请求和响应。然后,我们通过一些实际例子探讨了中间件代码。之后,我们看到了如何将多个中间件一个接一个地串联起来。例如,alice 包可以用于直观的链式调用。Gorilla 工具包中的一个包,gorilla/handlers,提供了用于日志记录、压缩和跨源资源共享(CORS)的各种中间件。
接下来,我们学习了 RPC 是什么,以及如何构建 RPC 服务器和客户端。然后,我们解释了什么是 JSON-RPC,并展示了如何使用 Gorilla 工具包创建 JSON-RPC。在过程中,我们介绍了许多用于中间件和 RPC 的第三方包。
在下一章中,我们将探讨一些著名的网络框架,这些框架进一步简化了 REST API 的创建。
第四章:使用流行的 Go 框架简化 RESTful 服务
在本章中,我们将使用不同的框架来简化构建 REST 服务的流程。首先,我们将快速浏览go-restful,这是一个 REST API 创建框架,然后转向一个名为 Gin 的框架。在本章中,我们将构建一个地铁铁路 API。我们将讨论的框架是功能齐全的 Web 框架,也可以在短时间内创建 REST API。在本章中,我们还将大量讨论资源和 REST 动词。然后,我们将尝试将一个小型数据库 SQLite3 与我们的 API 集成。最后,我们将探索revel.go,看看如何用它来原型化我们的 REST API。
在本章中,我们将涵盖以下主题:
-
go-restful– 用于创建 REST API 的框架 -
SQLite3 基础知识及 CRUD 操作
-
使用
go-restful构建地铁铁路 API -
使用 Gin 框架构建 RESTful API
-
使用
revel.go构建 RESTful API
技术要求
以下是在运行代码示例之前应该预先安装的软件:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
软件:Docker >= 18 (Docker Desktop for Windows and Mac OS X)
-
Go 最新版本编译器 >= 1.13.5
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter4下载本章的代码。克隆代码,并使用chapter4目录中的代码示例。
介绍 go-restful – 一个 REST API 框架
go-restful是一个用于在 Go 中构建 REST 风格 Web 服务的包。正如我们在第一章“开始 REST API 开发”中讨论的,REST 要求开发者遵循一系列设计协议。在那里我们也讨论了 REST 动词是如何定义的以及它们对资源做了什么。
使用go-restful,我们可以将 API 处理器的逻辑分离并附加 REST 动词。好处是,通过查看代码可以清楚地显示哪些资源被操作。在进入示例之前,我们必须为使用go-restful的 REST API 安装一个名为 SQLite3 的数据库。安装步骤如下:
- 首先,安装
go-restful包的依赖项。在 Ubuntu 上运行以下命令:
> apt-get install sqlite3 libsqlite3-dev
在 Mac OS X 上,您可以使用brew命令安装sqlite3:
> brew install sqlite3
- 现在,使用以下
get命令安装go-restful包:
> go get github.com/emicklei/go-restful
在 Windows 操作系统上,您可以从www.sqlite.org下载 SQLite3 的可执行文件。
我们现在准备出发。首先,让我们编写一个简单的程序,展示go-restful在几行代码中能做什么。它提供了一个WebService,我们可以用它来将路由附加到处理器。用例是创建一个简单的 ping 服务器,将服务器时间回显给客户端。步骤如下:
- 让我们创建一个
basicExample.go程序:
touch -p $GOPATH/src/github.com/git-user/chapter4/basicExample/main.go
- 现在,创建一个函数,将服务器时间写入响应。它接受
Request和Response对象:
func pingTime(req *restful.Request, resp *restful.Response) {
// Write to the response
io.WriteString(resp, fmt.Sprintf("%s", time.Now()))
}
pingTime 处理器很简单,只是将服务器时间写入响应。
- 我们必须创建一个
restful.WebService实例,以便将给定的路由附加到一个动词和一个处理器上。以下是如何在main块中实现它的示例:
package main
import (
"fmt"
"github.com/emicklei/go-restful"
"io"
"net/http"
"time"
)
func main() {
// Create a web service
webservice := new(restful.WebService)
// Create a route and attach it to handler in the service
webservice.Route(webservice.GET("/ping").To(pingTime))
// Add the service to application
restful.Add(webservice)
http.ListenAndServe(":8000", nil)
}
- 现在,运行程序:
go run $GOPATH/src/github.com/git-user/chapter4/basicExample/main.go
- 服务器将在本地主机的
8000端口上运行。因此,我们可以通过发送curl请求或使用浏览器来查看GET请求的输出:
curl -X GET "http://localhost:8000/ping"
2020-01-01 07:37:26.238146296 +0530 CET
在前面的程序中,我们导入了 go-restful 库,并使用 restful.WebService 结构体实例创建了一个新的服务。
- 接下来,我们将使用以下语句创建一个 REST 动词:
webservice.GET("/ping")
- 然后,我们将一个函数处理器附加到执行此动词;
pingTime就是一个这样的函数。这些链式函数被传递给Route函数以创建路由器。接下来是以下重要语句:
restful.Add(webservice)
这将把新创建的 webservice 注册到 go-restful 中。如果你观察的话,我们没有向 http.ListenServe 函数传递任何 ServeMux 对象;go-restful 会处理它。
这里的主要概念是使用基于资源的 REST API 创建 go-restful。从基本示例开始,让我们构建一些实用的东西。
假设你的城市正在实施一个新的地铁铁路项目,你必须为其他开发者开发一个围绕它创建应用的 REST API。我们将在本章中创建这样一个 API,并使用各种框架来展示实现。在此之前,对于 创建、读取、更新、删除(CRUD)操作,我们应该知道如何使用 Go 代码查询或插入数据库中的数据。我们选择最简单的一个,称为 SQLite3,并在下一节中讨论它。
SQLite3 基本和 CRUD 操作
SQLite3 是一种轻量级的基于文件的 SQL 数据库。它对于快速构建 API 的持久性非常有用。它利用 SQL 语言和关系数据库。在本节中,我们将了解如何从 Go 语言中与 SQLite3 交互。
所有 SQLite3 操作都将使用 go-sqlite3 库来完成。我们可以使用以下命令安装该包:
go get github.com/mattn/go-sqlite3
这个库的特殊之处在于它使用了 Go 的内部 sql 包。我们通常导入 database/sql 并使用 SQL 在数据库(此处为 SQLite3)上执行数据库查询:
import "database/sql"
现在,我们可以使用以下步骤来创建一个数据库驱动程序,然后使用 Query 方法在它上面执行 SQL 命令:
- 让我们在该路径下创建一个文件:
touch -p $GOPATH/src/github.com/git-user/chapter4/sqliteExample/main.go
- 让我们定义一个主块,如果不存在则创建一个表,并调用另一个函数进行 CRUD 操作:
package main
import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
)
// Book is a placeholder for book
type Book struct {
id int
name string
author string
}
func main() {
db, err := sql.Open("sqlite3", "./books.db")
if err != nil {
log.Println(err)
}
// Create table
statement, err := db.Prepare("CREATE TABLE IF NOT EXISTS books
(id INTEGER PRIMARY KEY, isbn INTEGER, author VARCHAR(64),
name VARCHAR(64) NULL)")
if err != nil {
log.Println("Error in creating table")
} else {
log.Println("Successfully created table books!")
}
statement.Exec()
dbOperations(db)
}
我们正在创建一个名为 books.db 的数据库,并使用 db.Prepare 方法创建一个 books 表的 SQL 语句。然后我们使用语句的 Exec 方法执行它。
- 如果你注意到,我们调用了
dbOperations函数来执行 CRUD 操作。在那个函数中,我们创建了一个书籍,读取它,然后更新,最后删除它。让我们看看实现:
func dbOperations(db *sql.DB) {
// Create
statement, _ := db.Prepare("INSERT INTO books (name, author,
isbn) VALUES (?, ?, ?)")
statement.Exec("A Tale of Two Cities", "Charles Dickens",
140430547)
log.Println("Inserted the book into database!")
// Read
rows, _ := db.Query("SELECT id, name, author FROM books")
var tempBook Book
for rows.Next() {
rows.Scan(&tempBook.id, &tempBook.name, &tempBook.author)
log.Printf("ID:%d, Book:%s, Author:%s\n", tempBook.id,
tempBook.name, tempBook.author)
}
// Update
statement, _ = db.Prepare("update books set name=? where id=?")
statement.Exec("The Tale of Two Cities", 1)
log.Println("Successfully updated the book in database!")
//Delete
statement, _ = db.Prepare("delete from books where id=?")
statement.Exec(1)
log.Println("Successfully deleted the book in database!")
}
除了Prepare函数外,现在我们还有一个Query方法。这主要用于从数据库中读取数据。Exec是在 SQLite 上执行准备/查询语句的常见函数。
准备语句用于在数据库上执行导致数据库变化的操作,而查询用于只读操作。
- 让我们运行
sqliteFunamentals程序:
go run $GOPATH/src/github.com/git-user/chapter4/sqliteExample/main.go
输出看起来如下,打印所有日志语句:
2017/06/10 08:04:31 Successfully created table books!
2017/06/10 08:04:31 Inserted the book into database!
2017/06/10 08:04:31 ID:1, Book:A Tale of Two Cities, Author:Charles Dickens
2017/06/10 08:04:31 Successfully updated the book in database!
2017/06/10 08:04:31 Successfully deleted the book in database!
在运行查询时,有一个与安全相关的重要事项。从前面的代码中取一个语句:
statement, _ = db.Prepare("INSERT INTO books (name, author, isbn) VALUES (?, ?, ?)")
statement.Exec("A Tale of Two Cities", "Charles Dickens", 140430547)
如果你传递了错误的数据,例如可能导致 SQL 注入的字符串,驱动程序会立即拒绝 SQL 操作。这是为了避免任何原始字符串被数据库引擎执行。这可能是危险的,因为 SQL 可以做任何事情,甚至删除数据库。始终先准备语句,然后传递必要的详细信息。
在下一节中,我们尝试使用go-restful和 SQLite3 构建一个示例 API。
使用 go-restful 构建地铁铁路 API
让我们利用我们获得的go-restful和 SQLite3 的知识,为我们在上一节中讨论的地铁铁路项目创建一个 API。路线图如下:
-
设计 REST API 文档
-
为数据库创建模型
-
实现 API 逻辑
让我们详细了解每个部分。
设计规范
在创建任何 API 之前,我们应该知道 API 的规范,以文档的形式呈现。我们在第二章,处理我们的 REST 服务路由中展示了示例,其中我们展示了 URL 缩短器 API 设计文档。让我们尝试为这个地铁铁路项目创建一个。看看下面的表格:
| HTTP 动词 | 路径 | 操作 | 资源 |
|---|---|---|---|
POST |
/v1/train (details as JSON body) |
Create |
Train |
POST |
/v1/station (details as JSON body) |
Create |
Station |
GET |
/v1/train/id |
Read |
Train |
GET |
/v1/station/id |
Read |
Station |
POST |
/v1/schedule (source and destination) |
Create |
Route |
我们还可以包括UPDATE和DELETE方法。通过实现前面的设计,用户将很容易自己实现它们。
创建数据库模型
让我们为前面提到的train、station和route资源编写一些 SQL 字符串以创建表。我们将为这个 API 创建一个项目布局。在$GOPATH/src/github.com/git-user/chapter4中创建两个名为railAPI和dbutils的目录。
在这里,railAPI是我们项目的源代码,而dbutils是我们自己用于处理数据库初始化实用函数的包。按照以下步骤操作:
- 让我们从
dbutils/models.go文件开始。在models.go文件中为train、station和schedule每个添加三个模型:
package dbutils
const train = `
CREATE TABLE IF NOT EXISTS train (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
DRIVER_NAME VARCHAR(64) NULL,
OPERATING_STATUS BOOLEAN
)
`
const station = `
CREATE TABLE IF NOT EXISTS station (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
NAME VARCHAR(64) NULL,
OPENING_TIME TIME NULL,
CLOSING_TIME TIME NULL
)
`
const schedule = `
CREATE TABLE IF NOT EXISTS schedule (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
TRAIN_ID INT,
STATION_ID INT,
ARRIVAL_TIME TIME,
FOREIGN KEY (TRAIN_ID) REFERENCES train(ID),
FOREIGN KEY (STATION_ID) REFERENCES station(ID)
)
`
这些是普通的由反引号 ` 定界的多行字符串。schedule 保存了火车在特定时间到达特定站点的信息。在这里,train 和 station 是 schedule 表的外键。对于 train,与之相关的细节是列。包名为 dbutils。当我们使用包名时,该包中的所有 Go 程序都可以在不进行显式导入的情况下共享变量和函数。
- 现在,让我们在
init-tables.go文件中添加初始化(创建表)数据库的代码:
package dbutils
import "log"
import "database/sql"
func Initialize(dbDriver *sql.DB) {
statement, driverError := dbDriver.Prepare(train)
if driverError != nil {
log.Println(driverError)
}
// Create train table
_, statementError := statement.Exec()
if statementError != nil {
log.Println("Table already exists!")
}
statement, _ = dbDriver.Prepare(station)
statement.Exec()
statement, _ = dbDriver.Prepare(schedule)
statement.Exec()
log.Println("All tables created/initialized successfully!")
}
我们导入 database/sql 以传递函数中的参数类型。函数中的所有其他语句都与我们在前一小节中给出的 SQLite3 示例类似。它是在 SQLite3 数据库中创建三个表。我们的主程序应该将数据库驱动程序传递给此函数。如果你注意到这里,我们没有导入 train、station 和 schedule。然而,由于此文件位于 dbutils 包中,models.go 中的变量在这里是可访问的。
- 现在,我们的初始包已经完成。使用以下命令为该包构建对象代码:
go build $GOPATH/src/github.com/git-user/chapter4/dbutils
- 直到我们创建并运行主程序之前,这都没有什么用处。所以,让我们编写一个简单的主程序,从
dbutils包中导入Initialize函数。让我们称这个文件为main.go:
touch -p $GOPATH/src/github.com/git-user/chapter4/railAPI/main.go
- 现在,在主函数中,让我们导入
dbutils包并初始化表:
package main
import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
"github.com/git-user/chapter4/dbutils"
)
func main() {
// Connect to Database
db, err := sql.Open("sqlite3", "./railapi.db")
if err != nil {
log.Println("Driver creation failed!")
}
// Create tables
dbutils.Initialize(db)
}
- 使用以下命令从
railAPI目录运行程序:
go run $GOPATH/src/github.com/git-user/chapter4/railAPI/main.go
- 输出应该类似于以下内容:
2020/01/10 14:05:36 All tables created/initialized successfully!
在先前的 railAPI 示例中,我们将创建表的任务委托给了 dbutils 包中的 Initialize 函数。我们可以在主程序中直接这样做,但将逻辑分解到多个包中是一种良好的实践。
在前一个目录树截图中的 railapi.db 文件会在我们运行主程序时创建。如果不存在,SQLite3 将负责创建数据库文件。SQLite3 数据库是简单的文件。你可以使用 $ sqlite3 file_name 命令进入 SQLite shell。
让我们扩展 railAPI 的主程序。我们的目标是创建在 设计规范 部分提到的 API。我们将一步一步地了解如何使用本例中的 go-restful 和 SQLite3 构建 REST 服务:
- 首先,向程序中添加必要的导入:
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/emicklei/go-restful"
_ "github.com/mattn/go-sqlite3"
"github.com/git-user/chapter4/dbutils"
)
我们需要两个外部包,go-restful 和 go-sqlite3,来构建 API 逻辑。第一个用于处理程序,第二个包用于添加存储。dbutils 在 railAPI 示例中保持不变。time 和 net/http 包用于通用任务。
- 尽管 SQLite 数据库表中的列有具体的名称,但在 Go 编程中,我们需要几个结构体类型来处理数据库的输入和输出数据。请看以下代码片段,它定义了必要的结构体来存储数据:
// DB Driver visible to whole program
var DB *sql.DB
// TrainResource is the model for holding rail information
type TrainResource struct {
ID int
DriverName string
OperatingStatus bool
}
// StationResource holds information about locations
type StationResource struct {
ID int
Name string
OpeningTime time.Time
ClosingTime time.Time
}
// ScheduleResource links both trains and stations
type ScheduleResource struct {
ID int
TrainID int
StationID int
ArrivalTime time.Time
}
DB变量被分配来存储全局数据库驱动程序。所有前面的结构体都是 SQL 数据库模型的确切表示。Go 的time.Time结构类型实际上可以存储数据库中的Time字段。
- 现在是实际的
go-restful实现。我们需要在go-restful中为我们的 API 创建一个容器。然后,我们应该将网络服务注册到该容器中。我们现在需要做的是选择一个资源,并在其上定义一个Register方法。在我们的例子中,假设TrainResource结构体是一个资源。方法参数将是一个go-restful容器,我们可以将其附加到一个命名空间,如下面的代码片段所示:
// Register adds paths and routes to a new service instance
func (t *TrainResource) Register(container *restful.Container) {
ws := new(restful.WebService)
ws.Path("/v1/trains").Consumes(restful.MIME_JSON).Produces
(restful.MIME_JSON)
ws.Route(ws.GET("/{train-id}").To(t.getTrain))
ws.Route(ws.POST("").To(t.createTrain))
ws.Route(ws.DELETE("/{train-id}").To(t.removeTrain))
container.Add(ws)
}
我们首先创建了一个服务,然后为资源添加了路径和路由。最后,我们将服务附加到容器中。路径是 URL 端点,路由是附加到函数处理器的路径参数或查询参数。
我们将三个 REST 方法,即GET、POST和DELETE分别附加到三个函数处理器getTrain、createTrain和removeTrain。我们尚未实现这些处理器,但很快就会。
如果你看看这个特殊的语句:
ws.Path("/v1/trains").Consumes(restful.MIME_JSON).Produces(restful.MIME_JSON)
它告诉我们我们的 API 将只接受请求中的 Content-Type 为 application/JSON。对于所有其他类型,它将自动返回一个415--Media Not Supported错误。
返回的响应将自动转换为漂亮的 JSON。我们还可以有 XML、JSON 等多种格式。go-restful提供了这个功能。
- 现在,让我们定义函数处理器。
getTrain处理器接收一个 HTTP 请求,访问path参数,然后创建一个DB查询语句从数据库中检索行。WriteEntity用于将结构体作为 JSON 写入响应:
// GET http://localhost:8000/v1/trains/1
func (t TrainResource) getTrain(request *restful.Request,
response *restful.Response) {
id := request.PathParameter("train-id")
err := DB.QueryRow("select ID, DRIVER_NAME, OPERATING_STATUS
FROM train where id=?", id).Scan(&t.ID, &t.DriverName,
&t.OperatingStatus)
if err != nil {
log.Println(err)
response.AddHeader("Content-Type", "text/plain")
response.WriteErrorString(http.StatusNotFound, "Train could
not be found.")
} else {
response.WriteEntity(t)
}
}
- 现在是
POST处理程序,createTrain。它与GET类似,但不是从路径参数中获取信息,而是解码传入请求的正文。然后它准备一个数据库查询语句来插入正文数据。它以201-created状态响应返回插入记录的ID:
// POST http://localhost:8000/v1/trains
func (t TrainResource) createTrain(request *restful.Request, response *restful.Response) {
log.Println(request.Request.Body)
decoder := json.NewDecoder(request.Request.Body)
var b TrainResource
err := decoder.Decode(&b)
log.Println(b.DriverName, b.OperatingStatus)
// Error handling is obvious here. So omitting...
statement, _ := DB.Prepare("insert into train (DRIVER_NAME,
OPERATING_STATUS) values (?, ?)")
result, err := statement.Exec(b.DriverName, b.OperatingStatus)
if err == nil {
newID, _ := result.LastInsertId()
b.ID = int(newID)
response.WriteHeaderAndEntity(http.StatusCreated, b)
} else {
response.AddHeader("Content-Type", "text/plain")
response.WriteErrorString(http.StatusInternalServerError,
err.Error())
}
}
- 如果你理解了前面的两个处理器,
DELETE函数就非常明显了。我们使用DB.Prepare创建了一个DELETESQL 命令,并返回一个201状态创建,告诉我们删除操作成功。否则,我们将实际错误作为服务器错误发送回来:
// DELETE http://localhost:8000/v1/trains/1
func (t TrainResource) removeTrain(request *restful.Request, response *restful.Response) {
id := request.PathParameter("train-id")
statement, _ := DB.Prepare("delete from train where id=?")
_, err := statement.Exec(id)
if err == nil {
response.WriteHeader(http.StatusOK)
} else {
response.AddHeader("Content-Type", "text/plain")
response.WriteErrorString(http.StatusInternalServerError,
err.Error())
}
}
- 现在,让我们编写主函数处理器,它是我们程序的入口点。它创建一个
go-restful容器并注册TrainResource:
func main() {
var err error
DB, err = sql.Open("sqlite3", "./railapi.db")
if err != nil {
log.Println("Driver creation failed!")
}
dbutils.Initialize(DB)
wsContainer := restful.NewContainer()
wsContainer.Router(restful.CurlyRouter{})
t := TrainResource{}
t.Register(wsContainer)
log.Printf("start listening on localhost:8000")
server := &http.Server{Addr: ":8000", Handler: wsContainer}
log.Fatal(server.ListenAndServe())
}
前几行是执行数据库相关的维护工作。然后,我们使用restful.NewContainer创建一个新的容器。go-restful包提供了一个名为CurlyRouter的路由器(它允许我们在设置路由时使用{train_id}语法),为我们的容器提供路由,还有其他类型。我们选择了这个路由器来处理传入的 HTTP 请求。然后,我们创建了一个TrainResource结构体的实例,并将其传递给Register方法。这个容器可以作为包装的 HTTP 处理器,因此我们可以轻松地将其传递给http.Server。
-
使用
request.QueryParameter在go-restful处理器中从 HTTP 请求中获取查询参数。 -
让我们运行程序:
go run $GOPATH/src/github.com/git-user/chapter4/railAPI/main.go
- 现在,发送一个
curlPOST请求来创建一个火车:
curl -X POST \
http://localhost:8000/v1/trains \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{"driverName": "Veronica", "operatingStatus": true}'
这将创建一个新的火车,包括驾驶员和操作状态详情。响应是新创建的资源,其中包含了分配的火车ID:
{
"ID": 1,
"DriverName": "Veronica",
"OperatingStatus": true
}
- 现在,让我们发送一个
curl请求来检查GET:
curl -X GET "http://localhost:8000/v1/trains/1"
你将看到以下 JSON 输出:
{
"ID": 1,
"DriverName": "Veronica",
"OperatingStatus": true
}
我们可以为发布数据和返回的 JSON 使用相同的名称,但为了显示两个操作之间的区别,使用了不同的变量名称。
- 现在,使用
DELETEAPI 调用删除前面代码片段中创建的资源:
curl -X DELETE "http://localhost:8000/v1/trains/1"
它不会返回任何响应体;如果操作成功,则返回状态200 OK。
- 现在,如果我们尝试对
ID 1的火车进行GET操作,那么它将返回以下响应:
Train could not be found.
为了支持更多 API 操作,例如PUT和PATCH,我们需要在Register方法中添加两个额外的路由到 web 服务,并定义相应的处理器。在这里,我们为TrainResource创建了一个 web 服务。以类似的方式,可以为对Station和Schedule表进行 CRUD 操作创建 web 服务。这项任务留给读者作为练习。
go-restful是一个轻量级的库,以优雅的方式创建 RESTful 服务非常强大。主要主题是将资源(模型)转换为可消费的 API。使用其他重型框架可能会加快开发速度,但 API 可能会因为代码的包装而变得较慢。go-restful是一个精简的底层包,用于 API 创建。
go-restful还提供了对使用 swagger 记录 REST API 的内置支持。它是一个运行并生成我们构建的 REST API 文档模板的工具。通过将其与基于go-restful的 web 服务集成,我们可以即时生成文档。更多信息,请访问github.com/emicklei/go-restful-swagger12。
使用 Gin 框架构建 RESTful API
Gin-Gonic 是一个基于httprouter的框架。我们在第二章,为我们的 REST 服务处理路由中学习了httprouter。它是一个类似于gorilla/mux的 HTTP 多路复用器,但速度更快。Gin 允许以干净的方式创建 REST 服务的高级 API。
Gin 可以与 Go 中另一个名为 Martini 的 Web 框架进行比较。所有 Web 框架都允许我们做更多的事情,例如模板和 Web 服务器设计,以及除了服务创建之外的事情。
可以使用以下命令安装 Gin 包:
go get gopkg.in/gin-gonic/gin.v1
让我们在 Gin 中编写一个简单的 hello world 程序,以熟悉 Gin 构造:
- 首先,创建一个包含我们程序的文件:
touch -p $GOPATH/src/github.com/git-user/chapter4/ginExample/main.go
- Gin 提供了一个
Default方法来创建 HTTP 路由/动词/处理程序组合。它还在处理程序函数内部提供了一个上下文对象,以便轻松操作 HTTP 请求和响应。请看这里使用 Gin 创建的请求serverTime UTC的 API:
package main
import (
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/pingTime", func(c *gin.Context) {
// JSON serializer is available on gin context
c.JSON(200, gin.H{
"serverTime": time.Now().UTC(),
})
})
r.Run(":8000") // Listen and serve on 0.0.0.0:8080
}
这个简单的服务器试图实现一个服务,该服务向客户端提供 UTC 服务器时间。如果你仔细观察,Gin 允许你仅用几行代码就做很多事情;所有样板细节,如路由,都被移除了。
来到程序,我们使用 gin.Default 函数创建一个路由器。然后,我们像在 go-restful 中那样使用 REST 动词附加路由到函数处理程序。然后,我们通过传递要运行的端口来调用 Run 函数。默认端口将是 8080。
c 是一个上下文变量,它包含有关单个请求的信息。我们可以使用 context.JSON 函数在发送回客户端之前将数据序列化为 JSON。
- 现在,如果我们运行
ginBasic程序:
go run $GOPATH/src/github.com/git-user/chapter4/ginExample/main.go
- 发送一个
curl请求以查看响应:
curl -X GET "http://localhost:8000/pingTime"
{"serverTime":"2020-02-27T19:08:05.470955Z"}
同时,Gin 服务器控制台捕获了关于服务器 HTTP 请求的美丽日志:

这是一个 Apache 风格的日志,显示了 <端点,请求的延迟和 REST 方法>。
为了在生产模式下运行 Gin,设置 GIN_MODE=release 环境变量。然后控制台输出将被静音,日志文件可用于监控日志。
现在,让我们用 Gin 编写我们的地铁铁路 API,以展示如何使用不同的框架实现完全相同的 API。我们使用相同的工程布局,将新项目命名为 railAPIGin,并直接使用 dbutils。让我们看看步骤:
- 首先,让我们为我们的程序准备导入:
package main
import (
"database/sql"
"log"
"net/http"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"github.com/git-user/chapter4/dbutils"
)
我们导入了 sqlite3 和 dbutils 以进行数据库相关操作。我们导入了 gin 以创建我们的 API 服务器。net/http 在提供与响应一起发送的直观状态码方面很有用。
- 让我们定义一个结构体来表示程序内存中的一个站点和数据库驱动程序:
// DB Driver visible to whole program
var DB *sql.DB
// StationResource holds information about locations
type StationResource struct {
ID int `json:"id"`
Name string `json:"name"`
OpeningTime string `json:"opening_time"`
ClosingTime string `json:"closing_time"`
}
StationResource 是两种数据的占位符。首先,对于来自 HTTP 请求的 POST 主体,其次对于从数据库查询的数据。这就是为什么它与 go-restful 的 railAPI 示例略有不同。
现在,让我们编写处理程序,实现站点资源的 GET、POST 和 DELETE 方法。我们定义了类似于之前地铁铁路 API go-restful 示例的 CRUD 处理程序。
- 第一个处理程序是一个
GET处理程序。在GetStation中,我们使用c.Param来剥离station_id路径参数。我们使用该值作为 ID,从 SQLite3 站点表中查询数据库记录:
// GetStation returns the station detail
func GetStation(c *gin.Context) {
var station StationResource
id := c.Param("station_id")
err := DB.QueryRow("select ID, NAME, CAST(OPENING_TIME as
CHAR), CAST(CLOSING_TIME as CHAR) from station where id=?",
id).Scan(&station.ID, &station.Name, &station.OpeningTime,
&station.ClosingTime)
if err != nil {
log.Println(err)
c.JSON(500, gin.H{
"error": err.Error(),
})
} else {
c.JSON(200, gin.H{
"result": station,
})
}
}
如果你仔细观察,SQL 查询略有不同。我们正在使用CAST方法将SQL TIME字段作为字符串检索,以便 Go 能够正确消费。如果你取消转换,将会引发 panic 错误,因为我们试图在运行时将TIME字段加载到 Go 字符串中。为了给你一个概念,TIME字段看起来像8:00:00、17:31:12等等。如果没有错误,我们使用gin.H方法返回结果。
- 在
POST处理程序CreateStation中,我们执行数据库插入。我们需要使用 Gin 中的c.BindJSON函数从请求体中提取数据。此函数将数据加载到作为参数传递的结构体中。我们的想法是将站点结构体加载到具有体详细信息的结构体中。这就是为什么StationResource有 JSON 推断字符串来告诉预期的键值。请看函数体:
// CreateStation handles the POST
func CreateStation(c *gin.Context) {
var station StationResource
// Parse the body into our resource
if err := c.BindJSON(&station); err == nil {
// Format Time to Go time format
statement, _ := DB.Prepare("insert into station (NAME,
OPENING_TIME, CLOSING_TIME) values (?, ?, ?)")
result, _ := statement.Exec(station.Name,
station.OpeningTime, station.ClosingTime)
if err == nil {
newID, _ := result.LastInsertId()
station.ID = int(newID)
c.JSON(http.StatusOK, gin.H{
"result": station,
})
} else {
c.String(http.StatusInternalServerError, err.Error())
}
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
在从POST请求体收集数据后,我们正在准备数据库插入语句并执行它。结果是插入记录的 ID。我们使用该 ID 将站点详情发送回客户端。
- 在 HTTP
DELETE函数处理程序RemoveStation中,我们应该使用DELETESQL 查询。如果操作成功,我们返回一个200 OK状态。否则,我们发送带有500-Internal Server Error的适当响应:
// RemoveStation handles the removing of resource
func RemoveStation(c *gin.Context) {
id := c.Param("station-id")
statement, _ := DB.Prepare("delete from station where id=?")
_, err := statement.Exec(id)
if err != nil {
log.Println(err)
c.JSON(500, gin.H{
"error": err.Error(),
})
} else {
c.String(http.StatusOK, "")
}
}
现在是主程序,它首先运行数据库逻辑以确保创建表。然后,它尝试创建 Gin 路由器并将其路由添加到其中:
func main() {
var err error
DB, err = sql.Open("sqlite3", "./railapi.db")
if err != nil {
log.Println("Driver creation failed!")
}
dbutils.Initialize(DB)
r := gin.Default()
// Add routes to REST verbs
r.GET("/v1/stations/:station_id", GetStation)
r.POST("/v1/stations", CreateStation)
r.DELETE("/v1/stations/:station_id", RemoveStation)
r.Run(":8000") // Default listen and serve on 0.0.0.0:8080
}
我们正在使用 Gin 路由器注册GET、POST和DELETE路由。然后,我们将路由和处理程序传递给它们。最后,我们使用 Gin 的Run函数以8000作为端口启动服务器。按照以下方式运行前面的程序:
go run $GOPATH/src/github.com/git-user/chapter4/railAPIGin/main.go
现在,我们可以通过执行POST请求来创建一个新的站点:
curl -X POST \
http://localhost:8000/v1/stations \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{"name":"Brooklyn", "opening_time":"8:12:00", "closing_time":"18:23:00"}'
它返回:
{"result":{"id":1,"name":"Brooklyn","opening_time":"8:12:00","closing_time":"18:23:00"}}
现在尝试使用GET获取站点详情:
CURL -X GET "http://localhost:8000/v1/stations/1"
Output
======
{"result":{"id":1,"name":"Brooklyn","opening_time":"8:12:00","closing_time":"18:23:00"}}
我们也可以使用以下命令删除站点记录:
curl -X DELETE "http://localhost:8000/v1/stations/1"
它返回一个200 OK状态,确认资源已被成功删除。正如我们之前讨论的,Gin 在控制台提供了直观的调试功能,显示附加的处理程序,并以颜色突出显示延迟和 REST 动词:

例如,200 是绿色,404 是黄色,DELETE 是红色,等等。Gin 还提供了许多其他功能,如路由分类、重定向和中间件函数。
如果你需要快速原型化 RESTful Web 服务,请使用 Gin 框架。你还可以用它来做许多其他事情,如静态文件服务等等。请记住,它是一个完整的 Web 框架。在 Gin 中获取查询参数时,请使用 Gin 上下文对象上的以下方法:c.Query(参数)。
使用 revel.go 构建 RESTful API
revel.go 也是一个完整的网络框架,类似于 Python 的 Django。它比 Gin 更老,被称为高度生产力的网络框架。它是一个异步的、模块化的、无状态的框架。与 go-restful 和 Gin 框架不同,我们在其中自己创建了项目,Revel 生成骨架以直接工作:
- 使用以下命令安装
revel.go:
go get github.com/revel/revel
- 为了运行骨架工具,我们应该安装一个额外的补充包:
go get github.com/revel/cmd/revel
确保将 $GOPATH/bin 添加到您的 PATH 变量中。一些外部包将二进制文件安装到 $GOPATH/bin 目录中。如果它在路径中,我们可以全局访问可执行文件。在这里,Revel 安装了一个名为 revel 的二进制文件。
- 在 Ubuntu 或 Mac OS X 上,您可以使用以下命令确保将 Go 二进制文件指向系统路径:
export PATH=$PATH:$GOPATH/bin
将此导出语句添加到 ~/.bashrc 以永久保存设置。在 Windows 上,您必须直接通过其位置调用可执行文件。现在我们准备好使用 Revel 进行操作了。
- 让我们在
github.com/git-user/chapter4中创建一个名为railAPIRevel的新项目:
revel new railAPIRevel
- 这创建了一个无需编写任何代码的项目骨架。这就是网络框架如何抽象事物以快速原型化的方式。一个 Revel 项目布局树看起来像这样:
conf/ Configuration directory
app.conf Main app configuration file
routes Routes definition file
app/ App sources
init.go Interceptor registration
controllers/ App controllers go here
views/ Templates directory
messages/ Message files
public/ Public static assets
css/ CSS files
js/ Javascript files
images/ Image files
tests/ Test suites
在所有这些样板目录中,有三个东西对于创建 API 很重要。那些是:
-
app/controllers -
conf/app.conf -
conf/routes
控制器是执行 API 逻辑的逻辑容器。app.conf 文件允许我们设置主机、端口、开发模式/生产模式等。routes 定义了端点、REST 动词和函数处理器的三元组(在这里,是控制器的函数)。这是组合路由、动词和函数处理器所必需的。
让我们使用我们与 go-restful 和 Gin 一起开发的相同 Rail API 示例。然而,在这里,由于冗余,我们省略了数据库逻辑。我们将很快看到如何使用 Revel 构建 GET、POST 和 DELETE 操作:
- 现在,修改路由文件如下:
# Routes Config
#
# This file defines all application routes (Higher priority routes
first)
#
module:testrunner
# module:jobs
GET /v1/trains/:train-id App.GetTrain
POST /v1/trains App.CreateTrain
DELETE /v1/trains/:train-id App.RemoveTrain
语法可能看起来有点新。这是一个配置文件,我们只需以这种格式定义一个路由:
VERB END_POINT HANDLER
VERB 是 REST 动词,END_POINT 是 API 端点,HANDLER 是处理请求的函数名称。
我们尚未定义处理器。在端点中,路径参数使用 :param 符号访问。这意味着对于服务器的 GET 请求,train-id 将作为路径参数传递。
-
现在,导航到
controllers文件夹并修改app.go文件中的现有控制器。 -
我们首先创建一个表示我们的应用程序上下文的
struct,让我们称它为App。我们还应该为TrainResource定义另一个struct,它包含铁路信息:
type App struct {
*revel.Controller
}
// TrainResource is the model for holding rail information
type TrainResource struct {
ID int `json:"id"`
DriverName string `json:"driver_name"`
OperatingStatus bool `json:"operating_status"`
}
- 现在,让我们在 Revel 中定义 CRUD 处理器。首先是
GetTrain。为什么控制器名称要以大写字母开头?因为 Revel 期望控制器从包中导出。Go 包只导出以大写字母开头的名称。控制器访问路径参数以获取火车 ID,并使用它来查询数据库。在这里,我们为了简洁起见模拟了数据库结果:
// GetTrain handles GET on train resource
func (c App) GetTrain() revel.Result {
var train TrainResource
// Getting the values from path parameters.
id := c.Params.Route.Get("train-id")
// use this ID to query from database and fill train table....
train.ID, _ = strconv.Atoi(id)
train.DriverName = "Logan" // Comes from DB
train.OperatingStatus = true // Comes from DB
c.Response.Status = http.StatusOK
return c.RenderJSON(train)
}
- 在
CreateTrain中,我们添加了POST请求逻辑。我们应该创建一个TrainResource结构体的对象,并将其传递给名为c.Params.BindJSON的函数。JSON 标签('json:"id"``)为我们提供了定义输出字段的灵活性。在 Go 语言中处理 JSON 时,这是一个好的实践。然后,我们返回一个带有201 created状态的 HTTP 响应。我们可以使用上下文上的RenderJSON` 方法即时将结构体序列化为 JSON:
// CreateTrain handles POST on train resource
func (c App) CreateTrain() revel.Result {
var train TrainResource
c.Params.BindJSON(&train)
// Use train.DriverName and train.OperatingStatus
// to insert into train table....
train.ID = 2
c.Response.Status = http.StatusCreated
return c.RenderJSON(train)
}
RemoveTrain处理器逻辑与GET相似。一个细微的区别是,在体中没有发送任何内容。正如我们之前提到的,数据库 CRUD 逻辑已从前面的示例中省略。这是读者尝试通过观察我们在go-restful和 Gin 部分所做的工作来添加 SQLite3 逻辑的练习:
// RemoveTrain implements DELETE on train resource
func (c App) RemoveTrain() revel.Result {
id := c.Params.Route.Get("train-id")
// Use ID to delete record from train table....
log.Println("Successfully deleted the resource:", id)
c.Response.Status = http.StatusOK
return c.RenderText("")
}
- 最后,Revel 服务器运行的默认端口是
9000。更改端口号的配置在conf/app.conf文件中。让我们遵循在8000上运行我们的应用程序的传统。因此,将app.conf中的 HTTP 端口部分修改如下。这告诉 Revel 服务器在不同的端口上运行:
......
# The IP address on which to listen.
http.addr =
# The port on which to listen.
http.port = 8000 # Change from 9000 to 8000 or any port
# Whether to use SSL or not.
http.ssl = false
......
- 现在,我们可以使用以下命令运行我们的 Revel API 服务器:
revel run github.com/git-user/chapter4/railAPIRevel
- 我们的应用程序服务器在
http://localhost:8000上启动。现在,让我们进行一些 API 请求:
curl -X GET "http://localhost:8000/v1/trains/1"
Output
=======
{
"id": 1,
"driver_name": "Logan",
"operating_status": true
}
POST 请求:
curl -X POST \
http://localhost:8000/v1/trains \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{"driver_name":"Magneto", "operating_status": true}'
Output
======
{
"id": 2,
"driver_name": "Magneto",
"operating_status": true
}
DELETE 与 GET 相同,但不会返回任何体。在这里,代码展示了如何处理请求和响应。记住,Revel 不仅仅是一个简单的 API 框架。它是一个完整的网络框架,类似于 Django(Python)或 Ruby on Rails。我们在 revel.go 中获得了模板、测试和许多其他功能。它主要用于网络开发,但也可以用它快速开发 REST API。
确保你为 GOPATH/user 创建一个新的 Revel 项目,否则你的 Revel 命令行工具在运行项目时可能找不到项目。
本章中我们看到的所有网络框架都支持中间件。go-restful 将其中间件命名为 Filters,Gin 命名为 Custom Middleware,而 Revel 则将其命名为 Interceptors。中间件在函数处理器前后分别读取或写入请求和响应。
在 第三章 与中间件和 RPC 一起工作 中,我们已经简要讨论了中间件。
摘要
在本章中,我们在 Go 语言的一些网络框架的帮助下构建了一个地铁铁路 API。最受欢迎的框架是 go-restful、Gin Gonic 和 revel.go。我们在本章中引入了数据库层。我们选择了 SQLite3,并尝试使用 go-sqlite3 库编写一个示例应用程序。
然后,我们探索了 go-restful 并详细探讨了如何创建路由和处理程序。go-restful 有在资源之上构建 API 的概念。我们解释了为什么 go-restful 是轻量级的,并且可以用来创建低延迟的 API。
接下来,我们介绍了 Gin 框架,并尝试重新实现 railAPI。最后,我们尝试在火车资源上创建另一个 API,但这次使用的是 revel.go 网络框架。Revel 是一个类似于 Django 和 Ruby on Rails 的框架。它为大多数服务器需求提供脚手架,如路由、处理程序和中间件。
本章的主要主题是建议您使用可用的框架进行 REST API 开发。当您除了 REST API 之外还有端到端 Web 应用程序(模板和 UI)时,使用 revel.go,当 API 的性能至关重要时,使用 Gin 快速创建 REST 服务,当 API 的性能至关重要时,使用 go-restful。
我们还使用了一种名为 SQLite3 的关系型数据库。在下一章中,我们将介绍一个流行的非关系型数据库 MongoDB,用于构建 API。
第五章:使用 MongoDB 和 Go 创建 REST API
在本章中,我们将介绍一个流行的 NoSQL 数据库 MongoDB。我们将通过存储文档而不是关系来了解 MongoDB 如何适合现代网络服务。我们将从学习 MongoDB 集合和文档开始,并使用 MongoDB 作为数据库创建一个示例 API。在这个过程中,我们将使用一个名为 mongo-driver 的驱动程序包。然后,我们将尝试为配送物流问题设计一个文档模型架构。
在本章中,我们将讨论以下主题:
-
MongoDB 简介
-
安装 MongoDB 并使用 shell
-
介绍
mongo-driver,它是 Go 的官方 MongoDB 驱动程序 -
基于
gorilla/mux和 MongoDB 的 RESTful API -
通过索引提高查询性能
-
为配送物流设计 MongoDB 文档
技术要求
如果您希望运行本书中的代码示例,以下软件需要预先安装:
-
操作系统:Linux(Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Dep:Go 的依赖管理工具 >= 0.5.3
-
Go 编译器 >= 1.13.5
-
MongoDB >= 4.2.3
您可以从 github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter5 下载本章的代码。克隆代码并使用 chapter5 目录中的代码示例。
MongoDB 简介
MongoDB 是一个流行的 NoSQL 数据库,吸引了全球许多开发者的关注。它与传统的如 MySQL、PostgreSQL 和 SQLite3 的关系数据库不同。与其它数据库相比,MongoDB 的主要区别在于它是无模式的,并存储集合和文档。将 MongoDB 集合视为表,将文档视为 SQL 数据库中的行。然而,在 MongoDB 中,集合之间没有关系。这种无模式的设计允许 MongoDB 通过称为 Sharding 的机制进行水平扩展。MongoDB 将数据存储在磁盘上的 BSON 文件中。BSON 是一种高效的操作和数据传输的二进制格式。几乎所有的 MongoDB 客户端在插入或检索文档时都将 JSON 转换为 BSON,反之亦然。
许多大型公司,如 Expedia、Comcast 和 MetLife,都基于 MongoDB 构建了他们的应用程序。它已被证明是现代互联网业务中的一个关键元素。MongoDB 以文档的形式存储数据;将其视为 SQL 数据库中的一行。所有 MongoDB 文档都存储在集合中,这个集合类似于表(在 SQL 的意义上)。让我们看看一个例子。一个 IMDb 电影的示例文档有几个键,如名称、年份和导演。这些键的值可以是数字、布尔值、字符串、列表或映射。这看起来可能类似于以下内容:
{
_id: 5,
name: 'Star Trek',
year: 2009,
directors: ['J.J. Abrams'],
writers: ['Roberto Orci', 'Alex Kurtzman'],
boxOffice: {
budget:150000000,
gross:257704099
}
}
MongoDB 相对于关系数据库的主要优势如下:
-
易于建模(无模式)
-
可以利用查询能力
-
文档结构适合现代网络应用程序(JSON)
-
比关系型数据库更可扩展(通过 分片)
既然我们已经了解了 MongoDB 是什么,让我们更详细地看看它。在下一节中,我们将学习如何安装 MongoDB 并尝试从 MongoDB 壳中访问它。
安装 MongoDB 和使用壳
MongoDB 可以轻松地安装在任何平台上。在 Ubuntu 18.04 上,在运行 apt-get 命令之前,我们需要执行一些步骤:
sudo apt-get update
sudo apt-get install -y mongodb
一旦安装完成,检查 mongo 进程是否正在运行。如果没有,可以使用以下命令启动 MongoDB 守护进程:
systemctl start mongod
如果用户是 root,可以在每个命令之前删除 sudo 关键字。
我们也可以从网站上手动下载 MongoDB 并将其复制到 /usr/local/bin。为此,我们必须为服务器创建一个初始化脚本,因为当系统关闭时,服务器会停止。我们可以使用 nohup 工具在后台运行服务器。通常,使用 apt-get 安装它更好。
要在 Mac OS X 上安装 MongoDB,您需要使用 Homebrew 软件。按照以下步骤进行操作:
- 我们可以使用以下命令轻松安装它:
brew tap mongodb/brew
brew install mongodb-community
- 然后,我们需要创建 MongoDB 存储数据库的
db目录:
mkdir -p /data/db
- 然后,使用
chown修改该文件的权限:
chown -R `id -un` /data/db
- 现在,MongoDB 已经准备好了。为了交互式地查看其日志,我们需要停止 MongoDB 作为进程并在壳中运行它。要停止服务,请使用以下命令:
systemctl stop mongod
- 现在,在终端窗口中,运行以下命令,这将交互式地启动 MongoDB(不在后台):
mongod
上述命令会产生以下输出:

上述命令显示了数据库状态的一些列。从这些 日志 中,我们可以推断出服务器在端口 27017 上启动。它显示了构建环境、使用的存储引擎等等。
在 Windows 上,我们可以手动下载安装程序二进制文件,通过将安装 bin 目录添加到 PATH 变量中来启动它。然后,我们可以使用 mongod 命令运行它。与 MongoDB 安装一起来的还有一个名为 Mongo 的客户端壳。我们将在下一节简要介绍它。
使用 MongoDB 壳工作
每当我们开始使用 MongoDB 时,我们首先需要探索的是我们可以用来与之交互的可用命令。通过一个简单的客户端工具查找可用的数据库、集合、文档等,称为 MongoDB 壳。它与 MySQL 客户端类似。这个壳程序包含在标准的 MongoDB 服务器安装中。我们可以使用以下命令启动它:
mongo
参考以下截图:

如果您看到已经创建了一个 session ID,如前面的截图所示,那么一切正常。如果您收到错误,服务器可能没有按预期运行。为了排除故障,请查看 MongoDB 故障排除指南docs.mongodb.com/manual/faq/diagnostics。客户端提供有关 MongoDB 版本和其他警告的信息。要查看所有可用的 shell 命令,请使用 help 命令。
让我们创建一个名为 movies 的新集合,并将前面的示例文档插入其中。按照以下步骤操作:
- 默认情况下,数据库将是一个测试数据库:
> show databases
admin 0.000GB
config 0.000GB
local 0.000GB
test 0.000GB
前面的 show 命令列出了所有可用的数据库。admin、config、test 和 local 是默认可用的四个数据库。
- 要创建新数据库或切换到现有数据库,只需键入
use db_name。在我们的例子中,让我们将我们的数据库命名为appDB。在 MongoDB shell 中键入以下内容:
> use appDB
这会将当前数据库切换到 appDB 数据库。如果您尝试列出可用的数据库,appDB 不会显示,因为 MongoDB 只有在向其中插入一些数据时(第一个集合或文档)才会创建物理数据库。
- 现在,我们可以通过插入第一个文档来创建一个新的集合。我们可以使用以下命令将 IMDb 电影的示例文档插入到名为
movies的集合中:
> db.movies.insertOne({ _id: 5, name: 'Star Trek', year: 2009, directors: ['J.J. Abrams'], writers: ['Roberto Orci', 'Alex Kurtzman'], boxOffice: { budget:150000000, gross:257704099 } } )
{
"acknowledged" : true,
"insertedId" : 5
}
您插入的 JSON 中有一个名为 _id 的 ID。我们可以在插入文档时提供它,或者 MongoDB 本身可以为您生成一个。
- 在 SQL 数据库中,我们使用 auto-increment 与
ID架构一起增加ID字段。在这里,MongoDB 生成一个唯一的哈希ID而不是序列。让我们插入一个关于《黑暗骑士》的更多文档,但这次我们不会传递_id字段:
> db.movies.insertOne({ name: 'The Dark Knight ', year: 2008, directors: ['Christopher Nolan'], writers: ['Jonathan Nolan', 'Christopher Nolan'], boxOffice: { budget:185000000, gross:533316061 } } )
{
"acknowledged" : true,
"insertedId" : ObjectId("59574125bf7a73d140d5ba4a")
}
如确认 JSON 响应所示,insertedId 已更改为一个非常长的 59574125bf7a73d140d5ba4a。这是由 MongoDB 生成的唯一哈希。
我们还可以使用 insertMany 函数在给定时间内插入一批文档。
- 在电影集合上使用不带参数的
find函数将返回所有匹配的文档,如下所示:
> db.movies.find()
{ "_id" : 5, "name" : "Star Trek", "year" : 2009, "directors" : [ "J.J. Abrams" ], "writers" : [ "Roberto Orci", "Alex Kurtzman" ], "boxOffice" : { "budget" : 150000000, "gross" : 257704099 } }
{ "_id" : ObjectId("59574125bf7a73d140d5ba4a"), "name" : "The Dark Knight ", "year" : 2008, "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 } }
- 为了返回单个文档,请使用
findOne函数。这返回多个结果中最旧的文档:
> db.movies.findOne()
{ "_id" : 5, "name" : "Star Trek", "year" : 2009, "directors" : [ "J.J. Abrams" ], "writers" : [ "Roberto Orci", "Alex Kurtzman" ], "boxOffice" : { "budget" : 150000000, "gross" : 257704099 }}
- 我们如何查询文档?在 MongoDB 中,查询被称为过滤数据并返回结果。如果我们需要过滤 2008 年发布的电影,我们可以这样做:
> db.movies.find({year: {$eq: 2008}})
{ "_id" : ObjectId("59574125bf7a73d140d5ba4a"), "name" : "The Dark Knight ", "year" : 2008, "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 } }
前面的 MongoDB shell 语句中的过滤查询如下:
{year: {$eq: 2008}}
这表示搜索标准是 year,并且值应该是 2008。$eq 被称为 过滤运算符,它有助于在字段和数据之间建立条件关系。它在 SQL 中相当于 = 运算符。在 SQL 中,等效的查询可以写成以下内容:
SELECT * FROM movies WHERE year=2008;
- 我们可以将之前编写的 MongoDB shell 语句简化为以下内容:
> db.movies.find({year: 2008})
这个过滤查询和之前的过滤查询是相同的,因为它们返回相同的文档集。前者的语法是使用$eq,这是一个查询操作符。从现在起,我们将一个查询操作符简单地称为操作符。
其他主要操作符如下:
| 操作符 | 函数 |
|---|---|
$lt |
小于 |
$gt |
大于 |
$in |
在...之中 |
$lte |
小于或等于 |
$ne |
不等于 |
您可以在这里找到所有可用的操作符:docs.mongodb.com/manual/reference/operator/.
- 现在,让我们给自己提出一个问题。我们有一个需求,需要获取所有预算超过$150,000,000 的文档。如何使用我们之前学到的查询知识来过滤这些文档?看看下面的代码片段:
> db.movies.find({'boxOffice.budget': {$gt: 150000000}})
{ "_id" : ObjectId("59574125bf7a73d140d5ba4a"), "name" : "The Dark Knight ", "year" : 2008, "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 } }
如您所见,我们使用boxOffice.budget在 JSON 中访问了budget键。MongoDB 的美丽之处在于它允许我们以很大的自由度查询 JSON。
- 我们在获取文档时不能添加两个或更多操作符到标准吗?是的,我们可以!让我们找到数据库中所有在
2009年上映且预算超过$150,000,000 的电影:
> db.movies.find({'boxOffice.budget': {$gt: 150000000}, year: 2009})
这没有返回任何内容,因为我们没有符合给定标准的任何文档。默认情况下,以逗号分隔的查询字段,如'boxOffice.budget': {$gt: 150000000}, year: 2009,会与AND操作结合。
- 现在,让我们放宽条件,找到任何在
2009年上映或预算超过$150,000,000 的电影:
> db.movies.find({$or: [{'boxOffice.budget': {$gt: 150000000}}, {year: 2009}]})
{ "_id" : 5, "name" : "Star Trek", "year" : 2009, "directors" : [ "J.J. Abrams" ], "writers" : [ "Roberto Orci", "Alex Kurtzman" ], "boxOffice" : { "budget" : 150000000, "gross" : 257704099 } }
{ "_id" : ObjectId("59574125bf7a73d140d5ba4a"), "name" : "The Dark Knight ", "year" : 2008, "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 } }
这里,查询略有不同。我们使用了一个名为$or的操作符来找到两个条件的谓词。结果将是获取文档的标准。$or需要分配给一个 JSON 条件对象的列表(见前面的查询)。由于 JSON 可以嵌套,条件也可以嵌套。这种查询风格可能对来自 SQL 背景的人来说是新的。MongoDB 团队设计它以直观地过滤数据。我们也可以通过巧妙地使用操作符,在 MongoDB 中轻松编写如内连接、外连接、嵌套查询等高级查询。
- 到目前为止,我们已经探讨了两个创建、读取、更新和删除(CRUD)操作,以便在 MongoDB 文档上创建和读取。现在,我们将查看更新和删除操作。要更新一个文档,使用
db.collection.update方法。语法包括标准和设置操作:
db.movies.update(CRITERIA, SET)
- 让我们更新《星际迷航》(ID:5)的票房预算。我们的目标是把
150000000改为200000000:
db.movies.update({"_id": 5}, {$set: {"boxOffice.budget": 200000000}})
update方法的第一个参数是过滤标准。第二个参数是一个$set操作符,它更改文档中的字段/部分。
- 现在,让我们看看删除操作。我们可以使用
deleteOne和deleteMany函数从给定的集合中删除一个文档:
> db.movies.deleteOne({"_id": ObjectId("59574125bf7a73d140d5ba4a")})
{ "acknowledged" : true, "deletedCount" : 1 }
传递给 deleteOne 函数的参数是一个过滤条件,这与读取和更新操作类似。所有符合给定条件的文档都将从集合中删除。响应包含一个友好的确认消息,其中包含已删除文档的数量。
这一部分和前面的章节讨论了使用 MongoDB shell 的 MongoDB 基础知识。然而,我们如何从一个 Go 程序中完成相同的事情?我们需要使用一个驱动程序包。在下一节中,我们将探索官方的 MongoDB Go 驱动程序包,称为 mongo-driver。MongoDB 支持包括 Python、Java、Ruby 和 Go 在内的主要语言的官方驱动程序。
介绍 mongo-driver,Go 的官方 MongoDB 驱动程序
mongo-driver 是一个功能丰富的 MongoDB 驱动程序,允许开发者编写使用 MongoDB 作为数据库的应用程序。Go 应用程序可以使用 mongo 驱动程序轻松地与 MongoDB 进行所有 CRUD 操作。这是一个由 MongoDB 维护的开源实现,可以自由使用和修改。我们可以将其视为 MongoDB API 的包装器。安装此包的命令与其他 go get 命令类似。然而,在本章中,我们将介绍一个新的 Go 包工具,称为 dep。
dep 是一个类似于 Python 的 pip 或 JavaScript 的 npm 的 Go 包安装工具。按照以下网页安装 dep 工具到各种平台:golang.github.io/dep/docs/installation.html。
让我们编写一个 Go 程序,将 The Dark Knight 电影文档插入到 MongoDB 中。按照以下步骤操作:
- 为我们的项目创建一个目录:
mkdir $GOPATH/src/github.com/git-user/chapter5/intro
- 现在,切换到
intro目录并初始化dep工具。它创建了一些文件,以便我们可以跟踪包依赖项:
dep init
- 将
mongo-driver依赖项添加到dep中:
dep ensure -add "go.mongodb.org/mongo-driver/mongo@~1.0.0"
- 创建一个
main文件,如下所示:
touch $GOPATH/src/github.com/git-user/chapter5/intro/main.go
- 这就是我们设置所有文件和依赖项所需做的全部工作。为了表示电影和票房,我们必须创建模仿 BSON 数据的结构体。这些结构体看起来像这样:
// Movie holds a movie data
type Movie struct {
Name string `bson:"name"`
Year string `bson:"year"`
Directors []string `bson:"directors"`
Writers []string `bson:"writers"`
BoxOffice `bson:"boxOffice"`
}
// BoxOffice is nested in Movie
type BoxOffice struct {
Budget uint64 `bson:"budget"`
Gross uint64 `bson:"gross"`
}
我们为结构体字段使用了 bson 标签。我们这样做的原因是 mongo-driver 包使用另一个名为 bson 的包将 Go 结构体序列化为 BSON 格式。这个 bson 包需要一些以标签形式存在的元信息来处理字段。因此,我们附加了一些 helper 标签。前面的结构体代表了内存中的 BSON 文档。
- 现在,我们必须从
mongo-driver中导入两个名为mongo和options的包。如果我们希望对 MongoDB 集合执行查询,则需要bson包。程序中的导入部分看起来像这样:
package main
import (
"context"
"fmt"
"log"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"gopkg.in/mgo.v2/bson"
)
- 现在,在
main函数中,我们必须创建一个数据库客户端并连接到它。这应该在程序的 main 块中完成。根据mongo-driverAPI,我们创建一个ClientOptions实例。ClientOptions对象包含数据库服务器信息(主机和端口)等细节。然后,我们使用上下文和ClientOptions对象创建一个客户端。上下文用作请求超时。借助客户端,我们可以使用Ping方法 ping 数据库。如果数据库 ping 成功,我们可以获取集合的引用。创建客户端和 ping 服务器的逻辑如下:
clientOptions := options.Client().ApplyURI("mongodb://localhost:27017")
client, err := mongo.Connect(context.TODO(), clientOptions)
if err != nil {
panic(err)
}
err = client.Ping(context.TODO(), nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("Connected to MongoDB successfully")
collection := client.Database("appDB").Collection("movies")
- 现在集合已准备就绪,我们可以将电影记录插入到数据库中。
mongo-driver为集合提供了一个名为InsertOne的方法。我们可以将结构体插入到数据库集合中,如下所示:
// Create a movie
darkNight := Movie{
Name: "The Dark Knight",
Year: "2008",
Directors: []string{"Christopher Nolan"},
Writers: []string{"Jonathan Nolan", "Christopher Nolan"},
BoxOffice: BoxOffice{
Budget: 185000000,
Gross: 533316061,
},
}
// Insert a document into MongoDB
_, err := collection.InsertOne(context.TODO(), darkNight)
if err != nil {
log.Fatal(err)
}
- 通过这样做,一条记录已被插入到数据库中。让我们使用带有过滤器的查询检索它,即票房预算超过 150 亿美元的电影。我们应该创建一个空的电影结构体来存储结果。可以使用
bson.M结构体构建一个过滤器查询。它是一个通用的映射,包含KEY:VALUE对,便于创建 BSON 查询。collection.FindOne方法接受一个过滤器查询并返回一个SingleResult对象。我们可以将此对象解码到空的电影结构体中,如下所示:
queryResult := &Movie{}
// bson.M is used for building map for filter query
filter := bson.M{"boxOffice.budget": bson.M{"$gt": 150000000}}
result = collection.FindOne(context.TODO(), filter)
err = result.Decode(queryResult)
if err != nil {
log.Fatal(err)
}
fmt.Println("Movie:", queryResult)
- 最后,在我们的操作完成后,从数据库断开连接:
err = client.Disconnect(context.TODO())
if err != nil {
panic(err)
}
fmt.Println("Disconnected from MongoDB")
- 我们可以使用以下代码运行整个程序:
go run $GOPATH/src/github.com/git-user/chapter5/intro/main.go
输出如下所示:
Connected to MongoDB successfully
Movie: &{ObjectID("5cfd106733090c1e34713c43")}
Disconnected from MongoDB
查询的结果可以存储在一个新的结构体中,并可以序列化为 JSON,以便客户端也能使用它。为此,你应该在结构体中添加 JSON 元标签,以及 BSON 标签。
使用 gorilla/mux 和 MongoDB 的 RESTful API
在前面的章节中,我们探讨了构建 RESTful API 的所有可能方式。我们使用了基本的 HTTP 路由器,以及许多其他网络框架。然而,为了保持简单,我们可以使用 gorilla/mux 与 mongo-driver 作为 MongoDB 驱动器。在本节中,我们将构建一个端到端的电影 API,同时集成数据库和 HTTP 路由器。在前一节中,我们学习了如何使用 mongo-driver 创建新的 MongoDB 文档并检索它。通过整合我们对 HTTP 路由器和数据库的知识,我们可以创建一个电影 API。
让我们制定计划,以便我们可以创建 API:
-
准备结构体来存储电影信息和数据库连接。
-
创建一个用于托管 API 的服务器。
-
准备 API 端点的路由。
-
实现路由的处理程序。
我们必须遵循以下步骤才能实现我们的目标:
- 创建一个目录来存放我们的项目:
mkdir $GOPATH/src/github.com/git-user/chapter5/movieAPI
- 在项目中添加一个
main.go文件:
touch $GOPATH/src/github.com/git-user/chapter5/movieAPI/main.go
请使用 dep 工具安装 mongo-driver 包,就像我们在前一节中所做的那样。
- 让我们看看我们需要创建的结构体;即,
DB、Movie和BoxOffice。Movie和BoxOffice存储电影信息。DB结构体在 MongoDB 数据库中存储一个集合,该集合可以在多个函数之间传递。相应的代码如下:
type DB struct {
collection *mongo.Collection
}
type Movie struct {
ID interface{} `json:"id" bson:"_id,omitempty"`
Name string `json:"name" bson:"name"`
Year string `json:"year" bson:"year"`
Directors []string `json:"directors" bson:"directors"`
Writers []string `json:"writers" bson:"writers"`
BoxOffice BoxOffice `json:"boxOffice" bson:"boxOffice"`
}
type BoxOffice struct {
Budget uint64 `json:"budget" bson:"budget"`
Gross uint64 `json:"gross" bson:"gross"`
}
- 为了实现我们的 API,我们需要几个重要的包。这些是
gorilla/mux、mongo-driver和几个其他的helper包。让我们看看如何导入这些包:
...
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/bson"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
我们需要 primitive 包从字符串生成 ObjectID,bson 包用于创建查询过滤器,以及 mongo/options 包用于创建 MongoDB 客户端。
- 让我们创建
main函数,这是创建 MongoDB 客户端的地方。客户端是通过传递选项给Connect方法创建的。一旦我们连接到本地运行的 MongoDB(端口27017),我们就可以使用Database.Collection方法访问集合。我们可以使用defer关键字延迟清理连接:
func main() {
clientOptions :=
options.Client().ApplyURI("mongodb://localhost:27017")
client, err := mongo.Connect(context.TODO(), clientOptions)
if err != nil {
panic(err)
}
defer client.Disconnect(context.TODO())
collection := client.Database("appDB").Collection("movies")
db := &DB{collection: collection}
...
}
defer 关键字在 Go 程序中是特殊的。它延迟一个函数调用,以便它在包含的外部函数返回之前执行。它通常用于 I/O 连接清理。
在我们的情况下,包含的函数是 main,延迟的函数是 client.Disconnect。所以,当 main 返回/终止时,defer 语句会正确地关闭 MongoDB 连接。
- 接下来,我们为电影上的
GET和POST操作创建一些 HTTP 路由。让我们分别称它们为GetMovie和PostMovie。代码如下:
r := mux.NewRouter()
r.HandleFunc("/v1/movies/{id:[a-zA-Z0-9]*}",
db.GetMovie).Methods("GET")
r.HandleFunc("/v1/movies", db.PostMovie).Methods("POST")
- 现在,我们可以使用
http.Server方法启动服务器,如下面的代码所示:
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
- 现在是处理器的实际实现。
GetMovie,像任何其他的 mux 处理器一样,接收响应和请求对象。它从路径参数中接收电影的ObjectId(十六进制字符串)并从数据库中查询匹配的文档。我们可以使用mux.Vars映射来收集路径参数。
我们不能简单地使用原始 ID 形成过滤查询。我们必须使用来自 mongo-driver/bson/primitive 包的 primitive.ObjectIDFromHex 方法将传递的十六进制字符串转换为 ObjectID。我们应该在 filter 查询中使用这个 ObjectID。
然后,我们使用 collection.FindOne 方法运行查询。结果可以解码为 Movie 结构体字面量并作为 JSON response 返回。请看以下 GetMovie 函数处理器的代码:
// GetMovie fetches a movie with a given ID
func (db *DB) GetMovie(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var movie Movie
objectID, _ := primitive.ObjectIDFromHex(vars["id"])
filter := bson.M{"_id": objectID}
err := db.collection.FindOne(context.TODO(),
filter).Decode(&movie)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.Header().Set("Content-Type", "application/json")
response, _ := json.Marshal(movie)
w.WriteHeader(http.StatusOK)
w.Write(response)
}
}
PostMovie与GET处理器函数具有完全相同的函数签名。它不是从路径参数中读取信息,而是从请求体中读取 JSON 信息并将其反序列化为Movie结构体。然后,我们使用collection.InsertOne方法执行数据库插入操作。JSON 的结果作为 HTTP 响应发送回。PostMovie处理器函数的代码如下:
// PostMovie adds a new movie to our MongoDB collection
func (db *DB) PostMovie(w http.ResponseWriter, r *http.Request) {
var movie Movie
postBody, _ := ioutil.ReadAll(r.Body)
json.Unmarshal(postBody, &movie)
result, err := db.collection.InsertOne(context.TODO(), movie)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.Header().Set("Content-Type", "application/json")
response, _ := json.Marshal(result)
w.WriteHeader(http.StatusOK)
w.Write(response)
}
}
- 现在,让我们运行程序:
go run $GOPATH/src/github.com/git-user/chapter5/movieAPI/main.go
- 接下来,我们打开终端,使用
curl或Postman发送POSTAPI 请求以创建一个新的电影:
curl -X POST \
http://localhost:8000/v1/movies \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{ "name" : "The Dark Knight", "year" : "2008", "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 }
}'
这返回以下响应:
{"InsertedID":"5cfd6cf0c281945c6cfefaab"}
- 我们的电影已成功创建。接下来,让我们检索它。使用
curl发送GETAPI 请求:
curl -X GET http://localhost:8000/v1/movies/5cfd6cf0c281945c6cfefaab
它返回我们在创建资源时得到的数据:
{"id":"5cfd6cf0c281945c6cfefaab","name":"The Dark Knight","year":"2008","directors":["Christopher Nolan"],"writers":["Jonathan Nolan","Christopher Nolan"],"boxOffice":{"budget":185000000,"gross":533316061}}
- 我们可以轻松地添加
PUT(更新)和DELETE方法到/从前面的代码中。我们只需要定义两个额外的处理器。首先,看看UpdateMovie处理器。它通过路径参数获取ObjectID以更新 MongoDB 中的文档,如下面的代码所示:
// UpdateMovie modifies the data of given resource
func (db *DB) UpdateMovie(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var movie Movie
putBody, _ := ioutil.ReadAll(r.Body)
json.Unmarshal(putBody, &movie)
objectID, _ := primitive.ObjectIDFromHex(vars["id"])
filter := bson.M{"_id": objectID}
update := bson.M{"$set": &movie}
_, err := db.collection.UpdateOne(context.TODO(), filter, update)
...
}
- 接下来,处理器函数是
DeleteMovie。它从路径参数中获取对象 ID,并尝试使用DeleteOne方法在数据库中删除具有相同 ID 的文档,如下所示:
// DeleteMovie removes the data from the db
func (db *DB) DeleteMovie(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
objectID, _ := primitive.ObjectIDFromHex(vars["id"])
filter := bson.M{"_id": objectID}
_, err := db.collection.DeleteOne(context.TODO(), filter)
...
}
在这些 API 操作中,我们也可以简单地向客户端发送状态,而不需要 HTTP 主体。
为了让这些处理器由 gorilla/mux 激活,我们必须将两个新的 HTTP 端点注册到路由器中,如下所示:
r.HandleFunc("/v1/movies/{id:[a-zA-Z0-9]*}", db.UpdateMovie).Methods("PUT")
r.HandleFunc("/v1/movies/{id:[a-zA-Z0-9]*}", db.DeleteMovie).Methods("DELETE")
这些添加的完整代码可在 chapter5/movieAPI_updated/main.go 文件中找到。如果您运行更新后的程序,您将拥有一个基于 MongoDB 后端的完整 CRUD API。
通过索引提高查询性能
我们都知道,在阅读书籍时,索引非常重要。当我们试图在书中搜索一个主题时,我们会滚动索引页面。如果主题在索引中找到,我们就去该主题的具体页码。但这里有一个缺点。我们为了这个索引使用了额外的页面。同样,MongoDB 需要每次查询时都遍历所有文档。如果文档存储了重要字段的索引,它可以快速提供数据。同时,我们应该记住,存储索引需要额外的空间。
在计算机科学中,B-tree 是实现索引的重要数据结构,因为它可以分类节点。通过遍历该树,我们可以以更少的步骤找到所需的数据。我们可以使用 MongoDB 提供的 createIndex 函数创建索引。以学生及其考试成绩为例。GET 操作在排序成绩时更为频繁。这种场景的索引可以可视化如下:

这是由 MongoDB 网站提供的官方示例。score 是因为频繁使用而被索引的字段。一旦它被索引,数据库就会将每个文档的地址存储在一个二叉树中。每当有人查询这个字段时,它会检查范围运算符(在这种情况下,它是 $lt),遍历二叉树,并在更少的步骤中获取文档的地址。由于 score 已被索引,排序操作的成本较低。因此,数据库返回排序(升序或降序)结果所需的时间更短。
回到我们之前提到的电影 API 的示例,我们可以为数据创建索引。默认情况下,所有_id字段都是索引的,所以我们使用 MongoDB shell 来显示这一点。之前,我们将year字段视为字符串。让我们将其修改为整数并对其进行索引。使用mongo命令启动 MongoDB shell。从一个 MongoDB shell 连接到一个新数据库;例如,test,并向其中插入一个文档:
> db.movies.insertOne({ name: 'Star Trek', year: 2009, directors: ['J.J. Abrams'], writers: ['Roberto Orci', 'Alex Kurtzman'], boxOffice: { budget:150000000, gross:257704099 } } )
{
"acknowledged" : true,
"insertedId" : ObjectId("595a6cc01226e5fdf52026a1")
}
插入一个包含不同数据的类似文档:
> db.movies.insertOne({ name: 'The Dark Knight ', year: 2008, directors: ['Christopher Nolan'], writers: ['Jonathan Nolan', 'Christopher Nolan'], boxOffice: { budget:185000000, gross:533316061 } } )
{
"acknowledged" : true,
"insertedId" : ObjectId("59603d3b0f41ead96110cf4f")
}
现在,让我们使用createIndex函数为年份添加索引:
db.movies.createIndex({year: 1})
这一行添加了检索数据库记录更快的方法。现在,所有与year相关的查询都利用了索引:
> db.movies.find({year: {$lt: 2010}})
{ "_id" : ObjectId("5957397f4e5c31eb7a9ed48f"), "name" : "Star Trek", "year" : 2009, "directors" : [ "J.J. Abrams" ], "writers" : [ "Roberto Orci", "Alex Kurtzman" ], "boxOffice" : { "budget" : 150000000, "gross" : 257704099 } }
{ "_id" : ObjectId("59603d3b0f41ead96110cf4f"), "name" : "The Dark Knight ", "year" : 2008, "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 } }
查询结果没有差异。然而,由于索引,通过MongoDB查找文档的机制已经改变。对于大量文档,这可能会大大减少查找时间。
索引是有成本的。如果索引没有正确设置,一些查询在不同字段上运行会非常慢。MongoDB 中还可以有复合索引,可以索引多个字段。
MongoDB 附带一个名为query planner的工具。要查看查询的执行时间,请在query函数之后使用explain函数,例如,db.movies.find({year: {$lt: 2010}}).explain("executionStats")。这解释了查询的获胜计划、所需时间(以毫秒为单位)、使用的索引等。
您可以使用explain函数查看索引和非索引数据的性能。请访问 MongoDB 网站了解有关索引的更多信息:docs.mongodb.com/manual/indexes/.
在您的口袋里拥有 MongoDB 和driver API 的所有知识后,您可以从开发使用 NoSQL 作为后端的 REST API 开始。在下一节中,我们将展示配送物流的架构,并指导您开发一个示例 API。
为配送物流 API 设计 MongoDB 文档
对于许多情况,可以开发 REST API。其中一种情况是配送物流。在物流世界中,许多实体都扮演着重要的角色。为了知道要实现什么,你需要了解物流中使用的术语。在这里,我们将模拟一些可以用于 MongoDB 的 JSON 文档。在本节学习完毕后,请尝试使用此架构信息作为构建物流 REST API 的指南。
在任何配送物流设计中,以下六个最小组件是必不可少的:
-
发件人
-
收件人
-
包裹
-
付款
-
承运人
-
装运
让我们看看每个组件的架构:
- 发件人是发送包裹的人:
{
_id: ObjectId("5cfe142a7ba402aacb71f710"),
first_name: "Philip",
last_name: "Zorn",
address: {
type: "work"
street: "241 Indian Spring St",
city: "Pittsburg",
state: "California",
pincode: 94565,
country: "USA"
},
"phone": "(xxx) yyy-zzzz"
}
- 收件人从发件人那里接收包裹:
{
_id: ObjectId("5cfe142a7ba402aacb71f706"),
first_name: "Max",
last_name: "Charles",
address: {
type: "home"
street: "Ludwig Str. 5",
city: "Ansbach",
state: "Bayern",
pincode: 91522,
country: "Deutschland"
},
"phone": "xx-yyyyyy-zzzzz"
}
- 发件人向收件人发送包裹。因此,我们必须创建一个包含包裹信息的文档,例如厘米和克的尺寸和重量:
{
_id: ObjectId("5cfe15607ba402aacb71f711"),
dimensions: {
width: 21,
height: 12
},
weight: 10,
is_damaged: false,
status: "In transit"
}
- 当发送者购买配送服务时,应记录支付交易。它应包含支付交易详情以供进一步参考:
{
_id: ObjectId("5cfe162a7ba402aacb71f713"),
initiated_on: ISODate("2019-06-10T08:38:30.894Z"),
successful_on: ISODate("2019-06-10T08:39:06.894Z").
merchant_id: 112543,
mode_of_payment: "paypal",
payment_details: {
transaction_token: "dfghjvbsclka76asdadn89"
}
}
- 现在是承运商。我们有一个第三方国际供应商,将代表我们发送包裹:
{
_id: ObjectId("5cfe1a4e7ba402aacb71f714"),
name: "PHL International",
carrier_code: 988,
is_partner: true
}
- 最后,有了所有这些细节,我们有一个包含关于所有其他利益相关者信息的运输文档:
{
_id: ObjectId("5cfe162a7ba402aacb71f712"),
sender: ObjectId("5cfe142a7ba402aacb71f710"),
receiver: ObjectId("5cfe142a7ba402aacb71f706"),
package: ObjectId("5cfe15607ba402aacb71f711"),
payment: ObjectId("5cfe162a7ba402aacb71f713"),
carrier: ObjectId("5cfe1a4e7ba402aacb71f714"),
promised_on: ISODate("2019-07-15T08:54:11.694Z")
}
一个运输包含 sender(发送者)、receiver(接收者)、payment(支付)、carrier(承运商)和 package(包裹)详情。这是交付物流的最小文档设计。
你可以在本项目的仓库中找到所有之前的 MongoDB Shell 架构,即 chapter5/delivery_logistics。
所有的前一个架构都已实现,以便你了解如何为 MongoDB 作为存储系统设计 REST 服务。
注意,前面的格式是针对 MongoDB shell 的。在创建服务时,请注意这个差异。
这里有一个编码练习给你:你能利用本章前几节学到的知识创建一个物流 REST 服务吗?
摘要
我们本章开始时介绍了 MongoDB 以及它是如何解决现代网络问题的。MongoDB 是一种不同于传统关系型数据库的 NoSQL 数据库。然后,我们学习了如何在所有平台上安装 MongoDB,如何启动 MongoDB 服务器,以及我们探讨了 MongoDB shell 的功能。MongoDB shell 是一个工具,可以用来快速检查或执行 CRUD 操作,以及在 MongoDB 中执行许多其他操作。我们查看查询的运算符符号。然后,我们介绍了 Go 的 MongoDB 驱动程序 mongo-driver 并学习了它的用法。我们借助 mongo-driver 和 Go 创建了一个持久电影 API。最后,我们学习了如何将 Go 结构体映射到 JSON 文档。
并非每个查询在 MongoDB 中都高效。因此,为了提高查询性能,我们引入了索引机制,通过按 B 树的顺序排列文档来减少文档检索时间。我们学习了如何使用 explain 命令来衡量查询的执行时间。最后,我们通过提供 BSON(MongoDB shell 语法)来制定物流文档设计。
有时,你的 REST API 需要支持额外的后台服务和传输。其中一种服务是 远程过程调用(RPC)。当分布式系统支持 REST API 时,后台可能会有成千上万的 RPC 调用。这些 RPC 调用可以调用不同的端点,使用不同的数据格式和传输方式等。如果你想在分布式系统中开发 API,了解这些至关重要。在下一章中,我们将学习如何使用名为 gRPC 的 RPC 方法和一个名为 Protocol Buffers 的数据格式。
第六章:使用协议缓冲区和 gRPC 进行工作
在本章中,我们将进入协议缓冲区的世界。REST API 需要来自其他内部服务的支持。这些内部服务可以实现远程过程调用(RPC)并使用协议缓冲区作为数据交换格式。首先,我们将发现使用协议缓冲区而不是 JSON 对服务的好处,以及在哪里使用两者。我们将使用谷歌的proto库来编译协议缓冲区。我们还将尝试使用协议缓冲区编写一些可以与 Go 或其他应用程序(如 Python 和 Node.js)通信的 Web 服务。然后,我们将解释 gRPC,这是一种高级简化的 RPC 形式。我们将学习如何使用 gRPC 和协议缓冲区帮助我们构建低带宽服务,这些服务可以被不同的客户端消费。最后,我们将讨论 HTTP/2 及其相对于基于纯 HTTP/1.1 服务的优势。
简而言之,我们将涵盖以下主题:
-
协议缓冲区简介
-
协议缓冲区语言
-
使用 protoc 编译协议缓冲区
-
gRPC 简介
-
使用 gRPC 的双向流
技术要求
为了运行本章中的代码示例,您需要预先安装以下软件:
-
操作系统:Linux(Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Go 的最新版本编译器 >= 1.13.5
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter6下载本章的代码。克隆代码并使用chapter6目录中的代码示例。
协议缓冲区简介
HTTP/1.1 是网络社区采用的行业标准。近年来,由于它的优势,HTTP/2 变得越来越受欢迎。使用 HTTP/2 的一些好处如下:
-
发送者和接收者之间的流量控制
-
更好的 HTTP 头部压缩
-
双向流的单一 TCP 连接
-
在单一 TCP 连接上发送文件的推送支持
-
所有主流浏览器的支持
来自谷歌关于协议缓冲区的技术定义如下:
协议缓冲区是一种灵活、高效、自动化的机制,用于序列化结构化数据——想象一下 XML,但更小、更快、更简单。您只需定义一次您希望数据如何结构化,然后您就可以使用特别生成的源代码轻松地将结构化数据写入和读取到各种数据流中,并使用各种语言。您甚至可以在不破坏针对“旧”格式编译的已部署程序的情况下更新您的数据结构。
让我们详细看看。协议缓冲是一种强类型规范语言。紧凑的数据接口对于设计微服务至关重要。协议缓冲允许我们定义多个系统之间的数据合约。一旦定义了协议缓冲文件,我们就可以将其编译为目标编程语言。编译的输出将是目标编程语言中的类和函数。发送者将数据序列化为二进制格式,通过网络传输。接收者反序列化数据并消费它。基本上,协议缓冲类似于 JSON 和 XML 等数据格式,但后者的格式是基于文本的,而协议缓冲是二进制的。
在 Go 中,协议缓冲可以通过不同的传输方式传输,例如 HTTP/2 和 高级消息队列协议 (AMQP)。它们是一种类似于 JSON 的传输格式,但具有严格的类型,并且只能在客户端和服务器之间理解。首先,我们将了解为什么 协议缓冲 (protobufs) 存在以及如何使用它们。
协议缓冲在序列化结构化数据(如以下内容)方面比 JSON/XML 有许多优势:
-
它们有一个强大的接口
-
它们比基于文本的数据格式小得多
-
它们在序列化/反序列化方面通常比 JSON/XML 快
-
由于类型和顺序,它们更少含糊不清
-
它们生成易于编程使用的数据访问类
我们将在本章后面讨论几个示例时证明这些观点。
协议缓冲语言
协议缓冲是一个具有极简语言语法的文件。我们编译协议缓冲,为目标编程语言生成一个新的文件。例如,在 Go 中,编译后的文件将是一个 .go 文件,其中包含映射 protobuf 文件的 struct。在 Java 中,将创建一个 class 文件。将协议缓冲视为具有类型的数据结构。协议缓冲语言提供了各种类型,我们可以使用它们来创建接口。首先,我们将讨论所有与等效 JSON 片段相对应的类型。之后,我们将实现一个完整的协议缓冲示例。从现在开始,我们将交替使用术语 protobuf/s 和协议缓冲。
在这里,我们将使用 proto3 作为我们的 protobuf 版本。版本之间有一些细微的差异,所以请注意在使用较旧版本时的差异。
首先,让我们学习如何在 protobuf 中建模消息。消息是一种传输给接收者的资源。在这里,我们试图定义一个简单的网络接口消息:
syntax 'proto3';
message NetworkInterface {
int index = 1;
int mtu = 2;
string name = 3;
string hardwareaddr = 4;
}
这种语法可能对你来说很新。在前面的代码中,我们正在定义一个名为 NetworkInterface 的消息类型。它有四个字段:index、最大传输单元 (MTU)、name 和硬件地址(MAC)。如果我们用 JSON 写同样的内容,它看起来会是这样:
{
"networkInterface": {
"index" : 0,
"mtu" : 68,
"name": "eth0",
"hardwareAddr": "00:A0:C9:14:C8:29"
}
}
字段名称被更改为符合 JSON 风格指南,但本质和结构保持不变。但是,前一个 protobuf 文件中字段所赋予的顺序数字(1、2、3、4)是什么?它们是用于在两个系统之间序列化和反序列化协议缓冲区数据的顺序标签。这就像是在暗示协议缓冲区编码/解码系统按特定顺序写入/读取数据。当前一个 protobuf 文件以 Go 为目标编译时,协议缓冲区消息将被转换为 Go 结构体,字段将被填充为空默认值。
在 protobuf 语言中,有许多基本类型。其中一些重要的类型如下:
-
标量值
-
枚举和重复字段
-
嵌套字段
我们将在接下来的部分中简要讨论每个类型。
标量值
我们分配给networkInterface消息中的字段的类型是标量类型。这些类型与 Go 类型相似,并且与它们匹配。对于其他编程语言,它们将被转换为它们各自的数据类型。Protobuf 支持许多不同的类型,如int、int32、int64、string和bool,它们类似于 Go 类型,但有一些变化。
它们如下所示:
| Go 类型 | Protobuf 类型 |
|---|---|
float32 |
float |
float64 |
double |
uint32 |
fixed32 |
uint64 |
fixed64 |
[]byte |
bytes |
这些类型可以在定义protobuf文件中的字段时使用。在编译时,这些字段和类型在 protobuf 中将转换为相应的 Go 变量和类型。Go 将为未分配的变量填充其空值。让我们看看 protobuf 消息类型在 Go 中的几个默认空值:
| Protobuf 类型 | 默认值 |
|---|---|
string |
"" |
bytes |
empty bytes[] |
bool |
false |
int、int32、int64、float、double |
0 |
enum |
0 |
由于 protobufs 在端系统之间使用数据结构预先就消息和字段达成协议,因此它们不会像 JSON 那样为键占用额外的空间。
枚举和重复字段
枚举(enum)为给定的一组元素提供数字的顺序。值的默认顺序是从0到n。因此,在协议缓冲区消息中,我们可以有一个枚举类型。让我们看看enum的一个例子:
syntax 'proto3';
message Schedule{
enum Days{
SUNDAY = 0;
MONDAY = 1;
TUESDAY = 2;
WEDNESDAY = 3;
THURSDAY = 4;
FRIDAY = 5;
SATURDAY = 6;
}
}
如果我们必须为多个枚举成员分配相同的值怎么办?
Protobuf3 有一个名为allow_alias的选项,我们可以使用它为两个不同的成员分配相同的值,如下所示:
enum EnumAllowingAlias {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
RUNNING = 1;
}
在这里,STARTED和RUNNING都有一个1标签。这意味着它们在数据中可以具有相同的值。如果我们尝试删除重复的值,我们也应该删除allow_alias选项。否则,proto 编译器将抛出错误(我们很快就会看到 proto 编译器是什么)。
repeated字段是协议缓冲区消息中的字段,代表一个项目列表。在 JSON 中,我们有一个给定键的元素列表。同样,重复字段允许我们定义特定类型的元素数组/列表:
message Site{
string url = 1;
int latency = 2;
repeated string proxies = 3;
}
在前面的代码中,第三个字段是重复字段,这意味着它是一个代理的数组/列表。值可能如下所示:["100.104.112.10", "100.104.112.12"]。
嵌套字段
我们还可以将一个消息用作另一个消息的类型。它类似于 map 数据结构。它类似于嵌套的 JSON。
例如,看看下面的 JSON 代码:
{
"site": {
"url": "https://example.org",
"latency": "5ms",
"proxies": [
{"url": "https://example-proxy-1.org", "latency": "6ms"},
{"url": "https://example-proxy-2.org", "latency": "4ms"}
]
}
}
前面的 JSON 包含有关具有代理列表的站点的信息。每个代理本身就是一个映射,并包含诸如url和latency之类的详细信息。
我们如何在 protobufs 中建模相同的东西呢?我们可以使用嵌套消息来实现,如下面的代码所示:
message Site {
string url = 1;
int latency = 2;
repeated Proxy proxies = 3;
}
message Proxy {
string url = 1;
int latency = 2;
}
在这里,我们将Proxy类型嵌套到Site中。我们很快就会查看所有这些字段类型。您可以在developers.google.com/protocol-buffers/docs/proto找到更多关于类型的详细信息。
在下一节中,我们将了解 protobuf 编译器及其使用方法。
使用 protoc 编译协议缓冲
到目前为止,我们已经讨论了如何通过定义消息及其字段类型来编写协议缓冲文件。但我们是怎样实际上将其集成到我们的 Go 程序中的呢?记住,protobufs 是各种系统之间通信的格式,类似于 JSON。但实际传输的数据是二进制的。protoc编译器会自动从.proto文件生成 Go 结构体。之后,这些结构体可以被导入以创建二进制数据。
以下是我们使用 protobufs 在 Go 程序中遵循的实际步骤:
-
安装
protoc命令行工具和proto库。 -
使用
.proto扩展名编写 protobuf 文件。 -
编译文件,使其针对编程语言(在我们的例子中是 Go)。
-
从生成的目标文件导入结构体并添加必要的数据。
-
将数据序列化为二进制格式并发送给接收器。
-
在远程机器上,接收器反序列化数据并解码数据。
这些步骤可以在以下图中看到:

第一步是在我们的机器上安装protobuf编译器。为此,从github.com/google/protobuf/releases下载protobuf包。在 Mac OS X 上,我们可以使用以下命令安装protobuf:
brew install protobuf
在 Ubuntu 或 Linux 上,我们可以将protoc复制到/usr/bin文件夹:
# Make sure you grab the latest version
curl -OL https://github.com/protocolbuffers/protobuf/releases/download/
v3.11.3/protoc-3.11.3-linux-x86_64.zip
# Unzip
unzip protoc-3.11.3-linux-x86_64.zip -d protoc3
# Move only protoc* to /usr/bin/
sudo mv protoc3/bin/protoc /usr/bin/protoc
在 Windows 上,我们只需将可执行文件(.exe)从github.com/protocolbuffers/protobuf/releases/download/v3.11.3/protoc-3.11.3-win64.zip复制到PATH环境变量中。让我们编写一个简单的协议缓冲文件来展示如何编译和使用目标文件中的结构体。在你的GOPATH中创建一个名为protobufs的文件夹:
mkdir -r $GOPATH/src/github.com/git-user/chapter6/protobufs
在protobufs内部,创建一个名为protofiles的新目录。此目录包含来自协议缓冲的编译文件。
在protofiles目录中,创建一个名为person.proto的文件,该文件模拟一个人的信息。它定义了姓名、ID、电子邮件和电话号码。向其中添加一些消息,如下面的代码片段所示:
syntax = "proto3";
package protofiles;
message Person {
string name = 1;
int32 id = 2; // Unique ID number for this person.
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
}
// Our address book file is just one of these.
message AddressBook {
repeated Person people = 1;
}
在这里,我们创建了两个主要的消息,称为AddressBook和Person。AddressBook消息包含一个人员列表。一个Person具有name、id、email和PhoneNumber。在第二行,我们将包声明为protofiles,如下所示:
package protofiles;
这告诉编译器添加与给定包名称相关的生成文件。Go 不能直接消费这个.proto文件。我们需要将其编译为有效的 Go 文件。编译后,protofiles包将用于创建 Go 包。要编译我们的person.proto协议缓冲文件,转到protofiles目录并运行以下命令:
protoc --go_out=. *.proto
此命令将给定的协议缓冲文件转换为具有相同名称的 Go 文件。运行此命令后,你会在同一目录中看到一个新文件已创建:
[16:20:27] git-user:protofiles git:(master*) $ ls -l
total 24
-rw-r--r-- 1 naren staff 5657 Jul 15 16:20 person.pb.go
-rw-r--r--@ 1 naren staff 433 Jul 15 15:58 person.proto
新文件的名称是person.pb.go。如果我们打开并检查此文件,我们会看到它包含自动生成的代码块:
....
type Person_PhoneType int32
const (
Person_MOBILE Person_PhoneType = 0
Person_HOME Person_PhoneType = 1
Person_WORK Person_PhoneType = 2
)
var Person_PhoneType_name = map[int32]string{
0: "MOBILE",
1: "HOME",
2: "WORK",
}
var Person_PhoneType_value = map[string]int32{
"MOBILE": 0,
"HOME": 1,
"WORK": 2,
}
.....
这只是该文件的一部分。输出文件将为给定的结构体(如Person和AddressBook)创建许多 getter 和 setter 方法。
前面的person.pb.go包是由proto编译器自动生成的样板代码。我们需要在主程序中消费该包以创建协议缓冲字符串。现在,我们应该创建main.go文件,该文件使用person.pb.go文件中的Person结构体,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter6/protobufs/basicExample/main.go
现在,为了使 Go 能够将结构体序列化为二进制格式,我们需要安装 Go 的proto驱动程序。使用go get命令安装它:
go get github.com/golang/protobuf/proto
我们将要创建的程序的目标是从自动生成的包中读取Person结构体,并使用proto.Marshal方法将其序列化为缓冲字符串。现在,我们将main.go填写如下:
package main
import (
"fmt"
"github.com/golang/protobuf/proto"
pb "github.com/git-user/chapter6/protobufs/protofiles"
)
func main() {
p := &pb.Person{
Id: 1234,
Name: "Roger F",
Email: "rf@example.com",
Phones: []*pb.Person_PhoneNumber{
{Number: "555-4321", Type: pb.Person_HOME},
},
}
p1 := &pb.Person{}
body, _ := proto.Marshal(p)
_ = proto.Unmarshal(body, p1)
fmt.Println("Original struct loaded from proto file:", p, "\n")
fmt.Println("Marshalled proto data: ", body, "\n")
fmt.Println("Unmarshalled struct: ", p1)
}
在这里,我们正在从protofiles包导入协议缓冲(pb)。我们使用详细信息初始化了Person结构体。然后,我们使用proto.Marshal函数序列化结构体。如果我们运行这个程序,输出将如下所示:
go run $GOPATH/src/github.com/git-user/chapter6/protobufs/basicExample/main.go
Original struct loaded from proto file: name:"Roger F" id:1234 email:"rf@example.com" phones:<number:"555-4321" type:HOME >
Marshaled proto data: [10 7 82 111 103 101 114 32 70 16 210 9 26 14 114 102 64 101 120 97 109 112 108 101 46 99 111 109 34 12 10 8 53 53 53 45 52 51 50 49 16 1]
Unmarshaled struct: name:"Roger F" id:1234 email:"rf@example.com" phones:<number:"555-4321" type:HOME >
marshaled data 的第二种输出并不明显,因为proto库将数据序列化为二进制字节。在 Go 中使用协议缓冲区的另一个优点是,通过编译 proto 文件生成的 struct 可以用来动态生成 JSON。让我们将前面的示例修改成一个新的程序。命名为jsonExample:
touch -p $GOPATH/src/github.com/narenaryan/chapter6/protobufs/jsonExample/main.go
在这个程序中,我们将使用 JSON 的 marshaler 而不是 protobuf 的 marshaler。Go 接口的美丽之处在于它允许协议缓冲区 struct 作为不同类型 marshaler 的输入。以下是将Person struct 转换为 JSON 的修改后的代码:
package main
import (
"fmt"
"encoding/json"
pb "github.com/git-user/chapter6/protobufs/protofiles"
)
func main() {
p := &pb.Person{
Id: 1234,
Name: "Roger F",
Email: "rf@example.com",
Phones: []*pb.Person_PhoneNumber{
{Number: "555-4321", Type: pb.Person_HOME},
},
}
body, _ := json.Marshal(p)
fmt.Println(string(body))
}
如果我们运行这个程序,它将打印出一个可以发送给任何理解 JSON 的客户端的 JSON 字符串:
go run $GOPATH/src/github.com/git-user/chapter6/protobufs/jsonExample/main.go
{"name":"Roger F","id":1234,"email":"rf@example.com","phones":[{"number":"555-4321","type":1}]}
任何其他 web 服务/接收器都可以轻松地立即消费这个 JSON 字符串。那么,使用协议缓冲区而不是 JSON 的好处是什么?首先,协议缓冲区旨在让两个后端系统通过一个强大的接口和较小的有效载荷大小相互通信。由于二进制的大小小于文本,因此协议序列化的数据始终比 JSON 文本小得多。
protobuf编译器生成的输出仅仅是普通的 Go struct。这允许你轻松地将 protobuf 转换为 JSON。
协议缓冲区仅仅是一种数据格式。它们需要一个传输模式在系统之间移动。我们看到了 RPC 是如何工作的,并在第三章,与中间件和 RPC 一起工作中创建了一个 RPC 客户端和服务器。现在,我们将扩展这些知识,使用带有协议缓冲区的Google 远程过程调用(gRPC)来高效地传输数据。在这种情况下,服务器和客户端可以使用协议缓冲区格式相互通信。
gRPC 简介
gRPC是一种在两个系统之间发送和接收消息的传输机制。传统上,这些系统是服务器和客户端。正如我们在前面的章节中描述的,RPC 可以在 Go 中实现以传输 JSON。我们称之为JSON RPC服务。同样,gRPC 专门设计用来以协议缓冲区的形式传输数据。
gRPC 使服务创建变得简单而优雅。它提供了一套不错的 API,我们可以使用这些 API 来定义服务并启动它们。在本节中,我们将重点介绍如何创建 gRPC 服务以及如何使用它。gRPC 的主要优势是它可以被多种编程语言理解。协议缓冲区提供了一种公共的数据结构。因此,这种组合使得各种技术栈和系统之间的通信无缝。这是分布式计算的基本概念。
Square、Netflix 和其他许多巨头利用 gRPC 来扩展他们巨大的流量密集型服务。Google 在他们的网络服务中大量使用 gRPC。我们可以利用它来在两个内部服务之间获得更好的吞吐量。
在编写服务之前,我们需要安装 grpc Go 库和一个 protoc-gen 插件。使用以下命令安装它们:
go get google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go
与传统的 HTTP/REST/JSON 架构相比,gRPC 有以下优点:
-
gRPC 使用 HTTP/2,这是一个二进制协议。
-
HTTP/2 中可以实现头部压缩,这意味着开销更小。
-
我们可以在一个连接上多路复用多个请求。
-
我们可以使用 protobufs 来进行数据的严格类型化。
-
使用流式请求或响应,而不是使用请求/响应事务,是可能的。
看一下下面的图表:

前面的图表清楚地表明,任何后端系统或移动应用都可以直接通过协议缓冲区与 gRPC 服务器通信。让我们用 Go 语言和 gRPC 以及协议缓冲区编写一个货币交易服务。gRPC 中的一个服务是一个 RPC 合同。它接收一个消息并返回另一个消息。
实现货币交易服务的步骤如下:
-
创建包含服务和消息定义的协议缓冲区。
-
编译协议缓冲区文件。
-
使用生成的 Go 包创建一个 gRPC 服务器。
-
创建一个与服务器通信的 gRPC 客户端。
为了理解这些步骤,让我们为即将到来的示例创建项目目录,如下所示:
mkdir -r $GOPATH/src/github.com/git-user/chapter6/grpcExample
mkdir -r $GOPATH/src/github.com/git-user/chapter6/grpcExample/protofiles
创建一个名为 transaction.proto 的文件来定义 gRPC 服务:
touch -p $GOPATH/src/github.com/git-user/chapter6/grpcExample/protofiles/transaction.proto
现在,在 transaction.proto 文件中,定义服务和交易消息,如下所示:
syntax = "proto3";
package protofiles;
message TransactionRequest {
string from = 1;
string to = 2;
float amount = 3;
}
message TransactionResponse {
bool confirmation = 1;
}
service MoneyTransaction {
rpc MakeTransaction(TransactionRequest) returns (TransactionResponse) {}
}
这是一个简单的服务器端货币交易协议缓冲区。我们在讨论协议缓冲区时介绍了 message 关键字。新的关键字 service 定义了一个 gRPC 服务。这个新关键字仅与 gRPC 相关,protoc-gen-go 辅助插件通过 protoc 编译器将其转换为可理解格式。现在,让我们使用 grpcExample 目录中的 protoc 编译此文件:
protoc -I protofiles/ protofiles/transaction.proto --go_out=plugins=grpc:protofiles
这个命令比我们之前使用的编译命令稍大。这是因为我们使用了 protoc-gen-go 插件。这个命令只是说使用数据文件作为 proto 文件的输入目录,并使用相同的目录输出目标 Go 文件。现在,如果我们列出 protofiles 目录,我们会看到一个自动生成的文件,名为 transaction.pb.go:
ls protofiles
-rw-r--r-- 1 git-user staff 6215 Jan 16 17:28 transaction.pb.go
-rw-r--r-- 1 git-user staff 294 Jan 16 17:28 transaction.proto
现在,我们必须构建一个服务器和客户端,它们消费之前构建的 protobufs。在 grpcExample 中创建两个额外的目录,用于服务器和客户端逻辑,如下所示:
mkdir grpcServer grpcClient
让我们首先创建一个 gRPC 服务器。在 grpcServer 目录中添加一个名为 server.go 的文件,该文件实现了交易服务。我们的目标是创建一个服务器,它可以从客户端收集交易请求并返回确认。
我们在这里需要更多包的帮助,即 context 和 reflection。context 用于创建一个在 RPC 请求整个生命周期中存在的 context 变量。这两个库都由 gRPC 用于其内部函数:
import (
...
pb "github.com/git-user/chapter6/grpcExample/protofiles"
"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
如果我们在 protofiles 中打开自动生成的 transaction.pb.go 包,我们可以清楚地看到有两件重要的事情:
-
MakeTransaction函数作为MoneyTransactionServer接口的一部分 -
RegisterMoneyTransactionServer函数
MakeTransaction 用于实现服务。让我们看看实现细节。它定义了一个结构和一种方法。该方法使用通过 *pb.TransactionRequest 参数提供的数据进行货币交易:
// server is used to create MoneyTransactionServer.
type server struct{}
// MakeTransaction implements MoneyTransactionServer.MakeTransaction
func (s *server) MakeTransaction(ctx context.Context, in *pb.TransactionRequest) (*pb.TransactionResponse, error) {
// Use in.Amount, in.From, in.To and perform transaction logic
return &pb.TransactionResponse{Confirmation: true}, nil
}
MakeTransaction 包含 RPC 请求细节。它基本上是一个结构体,映射到我们在协议缓冲文件中定义的 TransactionRequest 消息。MakeTransaction 返回的是 TransactionResponse。此函数签名与我们在协议缓冲文件中最初定义的相匹配:
rpc MakeTransaction(TransactionRequest) returns (TransactionResponse) {}
接下来是主块。在这里,我们必须创建 gRPC 服务器的实例,并将其与服务器结构注册。我们在端口 50051 上运行这个 gRPC 服务器:
const (
port = ":50051"
)
func main() {
lis, err := net.Listen("tcp", port)
...
s := grpc.NewServer()
pb.RegisterMoneyTransactionServer(s, &server{})
reflection.Register(s)
if err := s.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
现在,我们需要编写一个客户端。在 grpcClient 目录中添加一个名为 client.go 的文件。客户端应该连接到服务器并获取连接。使用该连接,我们可以调用远程函数并获取结果。gRPC 客户端也使用相同的 protobuf 模板类,以确保与服务器同步。以下是为客户端编写的代码:
package main
import (
"log"
pb "github.com/git-user/chapter6/grpcExample/protofiles"
"golang.org/x/net/context"
"google.golang.org/grpc"
)
const (
address = "localhost:50051"
)
func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure())
...
// Create a client
c := pb.NewMoneyTransactionClient(conn)
from := "1234"
to := "5678"
amount := float32(1250.75)
// Make a server request.
r, err := c.MakeTransaction(context.Background(),
&pb.TransactionRequest{From: from,
To: to, Amount: amount})
...
}
此客户端也使用 grpc 包。它使用一个名为 context.Background() 的空上下文传递给 MakeTransaction 函数。函数的第二个参数是 TransactionRequest 结构体:
&pb.TransactionRequest{From: from, To: to, Amount: amount}
现在,让我们同时运行服务器和客户端并查看输出。打开一个新的控制台,并使用以下命令运行 gRPC 服务器:
go run $GOPATH/src/github.com/git-user/chapter6/grpcExample/grpcServer/
server.go
TCP 服务器开始监听端口 50051。现在,打开另一个终端/外壳,并启动与该服务器通信的客户端程序:
go run $GOPATH/src/github.com/git-user/chapter6/grpcExample/grpcClient/
client.go
它打印了成功交易的输出:
2020/01/10 19:13:16 Transaction confirmed: true
同时,服务器将此消息记录到控制台:
2020/01/10 19:13:16 Amount: 1250.750000, From A/c:1234, To A/c:5678
在这里,客户端向 gRPC 服务器发送了一个单一请求,并传递了 From A/c 账号、To A/c 账号和 Amount 的详细信息。服务器选择这些详细信息,处理它们,并发送响应表示一切正常。
gRPC 客户端可以请求 gRPC 服务器执行计算密集型/安全操作。客户端也可以是移动设备。
完整的程序可以在本章的项目仓库中找到。在下一节中,我们将探讨 gRPC 中的双向流。
gRPC 的双向流
gRPC 相对于传统 HTTP/1.1 的主要优势是它可以使用单个 TCP 连接在服务器和客户端之间发送和接收多个消息。我们之前看到了货币交易的例子。另一个现实世界的用例是出租车中安装的 GPS。在这里,出租车是客户端,它在其路线中将地理坐标发送到服务器。最后,服务器可以根据两点之间花费的时间和总距离计算总费用。
另一个用例是服务器向客户端推送数据。这被称为服务器推送模型,其中服务器可以将结果流发送回客户端。这与轮询不同,轮询中客户端每次都会创建一个新的请求/响应周期。服务器推送对于构建实时应用非常有用。让我们实现一个示例来说明这一点:
- 创建一个名为
serverPush的项目,如下所示:
mkdir -r $GOPATH/src/github.com/git-user/chapter6/serverPush
mkdir -r $GOPATH/src/github.com/git-user/chapter6/serverPush/
protofiles
- 现在,将交易添加到
protofiles中,这是一个类似于我们在之前的 gRPC 货币交易示例中使用的协议缓冲区,但MakeTransaction的返回类型是一个流:
syntax = "proto3";
package protofiles;
message TransactionRequest {
string from = 1;
string to = 2;
float amount = 3;
}
message TransactionResponse {
string status = 1;
int32 step = 2;
string description = 3;
}
service MoneyTransaction {
rpc MakeTransaction(TransactionRequest) returns (stream
TransactionResponse) {}
}
在协议缓冲区文件中定义了两个消息和一个服务。最有趣的部分在于服务;我们返回一个流而不是一个普通响应:
rpc MakeTransaction(TransactionRequest) returns (stream TransactionResponse) {}
这个项目的用例是,客户端向服务器发送货币转账请求,服务器执行一些任务,然后将这些步骤详情作为响应流发送回服务器。
- 现在,让我们编译
.proto文件:
protoc -I protofiles/ protofiles/transaction.proto
--go_out=plugins=grpc:protofiles
这在protofiles目录中创建了一个新文件transaction.pb.go。我们在服务器和客户端程序中使用此文件中的定义,我们将很快创建这些程序。
- 现在,让我们编写 gRPC 服务器代码。这段代码与之前的示例略有不同,因为引入了流:
mkdir $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcServer
touch $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcServer/server.go
我们跳过导入部分,看看程序的主要逻辑。主函数与之前的 gRPC 示例类似,但最有趣的是处理程序。假设处理程序从客户端接收请求并执行三个步骤。在每个步骤的末尾,服务器向客户端发送通知。这是一个长期连接,与之前我们看到的一次性 RPC 调用不同。以下是为流MakeTransaction编写的代码:
const (
port = ":50051"
noOfSteps = 3
)
// MakeTransaction implements MoneyTransactionServer.MakeTransaction
func (s *server) MakeTransaction(in *pb.TransactionRequest, stream pb.MoneyTransaction_MakeTransactionServer) error {
log.Printf("Got request for money transfer....")
log.Printf("Amount: $%f, From A/c:%s, To A/c:%s", in.Amount,
in.From, in.To)
// Send streams here
for i := 0; i < noOfSteps; i++ {
time.Sleep(time.Second * 2)
// Once task is done, send the successful message
// back to the client
if err := stream.Send(&pb.TransactionResponse{Status: "good",
Step: int32(i),
Description: fmt.Sprintf("Performing step %d",
int32(i))}); err != nil {
log.Fatalf("%v.Send(%v) = %v", stream, "status", err)
}
}
log.Printf("Successfully transferred amount $%v from %v to %v",
in.Amount, in.From, in.To)
return nil
}
MakeTransaction函数接受一个请求和一个流作为其参数。在函数中,我们正在遍历步骤的数量(这里有三步)并执行计算。服务器正在使用time.Sleep函数模拟模拟 I/O 或计算。发送消息的关键服务器方法是Send:
stream.Send()
这个函数从服务器向客户端发送流响应。
- 现在,让我们编写客户端程序。这与我们在货币交易示例中看到的基 gRPC 客户端代码略有不同。为客户端程序创建一个新的目录:
mkdir $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcClient
touch $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcClient/cilent.go
- 现在,客户端应该无限期地监听消息流。为此,我们使用了
for loop和break。让我们将我们之前的客户端处理程序修改为一个新的名为ReceiveStream的处理程序:
// ReceiveStream listens to the stream contents and use them
func ReceiveStream(client pb.MoneyTransactionClient,
request *pb.TransactionRequest) {
log.Println("Started listening to the server stream!")
stream, err := client.MakeTransaction(context.Background(),
request)
if err != nil {
log.Fatalf("%v.MakeTransaction(_) = _, %v", client, err)
}
// Listen to the stream of messages
for {
response, err := stream.Recv()
if err == io.EOF {
// If there are no more messages, get out of loop
break
}
if err != nil {
log.Fatalf("%v.MakeTransaction(_) = _, %v", client, err)
}
log.Printf("Status: %v, Operation: %v", response.Status,
response.Description)
}
}
在这里,ReceiveStream是我们为了发送请求和接收消息流而编写的自定义函数。它接受两个参数:MoneyTransactionClient和TransactionRequest。它使用第一个参数创建一个流并开始监听它。每当服务器耗尽所有消息时,客户端将停止监听并终止。如果客户端尝试接收消息,将返回一个io.EOF错误。我们正在记录从 gRPC 服务器收集到的响应。第二个参数TransactionRequest用于首次将请求发送到服务器。运行此操作将使此过程对我们来说更加清晰。
为了简洁起见,省略了服务器和客户端的导入和主要逻辑。请参阅此项目的存储库以获取完整的程序:chapter6/serverPush。
- 在第一个终端上运行 gRPC 服务器:
go run $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcServer/server.go
它将持续监听传入的请求。
- 现在,在第二个终端上运行客户端以查看其效果:
go run $GOPATH/src/github.com/git-user/chapter6/serverPush/
grpcClient/client.go
这将在控制台输出以下内容:
2019/06/10 20:43:53 Started listening to the server stream!
2019/06/10 20:43:55 Status: good, Operation: Performing step 0
2019/06/10 20:43:57 Status: good, Operation: Performing step 1
2019/06/10 20:43:59 Status: good, Operation: Performing step 2
同时,服务器也在第一个终端上记录了自己的消息:
2017/07/16 15:08:15 Got request for money Transfer....
2017/07/16 15:08:15 Amount: $1250.750000, From A/c:1234, To A/c:5678
2017/07/16 15:08:21 Successfully transferred amount $1250.75 from 1234 to 5678
此过程与服务器同步进行。客户端会保持活跃状态,直到最后一个流式消息被发送回。服务器可以同时处理任意数量的客户端。每个客户端请求都被视为一个独立实体。这是一个服务器发送流式响应的示例。还有其他可以使用协议缓冲和 gRPC 实现的使用案例:
-
客户端发送流式请求以从服务器获取一个最终的响应
-
客户端和服务器都可以同时发送和接收流式请求和响应
官方的 gRPC 团队在 GitHub 上提供了一个很好的示例,演示了如何在 GitHub 上路由出租车。github.com/grpc/grpc-go/tree/master/examples/route_guide。
摘要
在本章中,我们通过了解协议缓冲的基本知识开始了我们的旅程。然后,我们遇到了协议缓冲语言,它有许多类型,如标量、枚举和重复类型。我们探讨了 JSON 和协议缓冲之间的几个类比。我们了解到,由于协议缓冲是基于二进制的,因此它们比纯 JSON 数据格式更节省内存。
接下来,我们安装了protoc编译器来编译我们用协议缓冲语言编写的文件。然后,我们学习了如何编译.proto文件以生成包含样板代码的.go文件。这个 Go 文件包含了主程序所需的所有结构和接口。接下来,我们为地址簿和人员编写了协议缓冲。
然后,我们转向了 gRPC,这是 Google 的一种使用协议缓冲区的 RPC 技术。我们看到了 HTTP/2 和 gRPC 的好处。然后,我们定义了一个 gRPC 服务和一些以协议缓冲区形式的数据。接下来,我们实现了从.proto文件生成的 gRPC 服务器和 gRPC。
gRPC 技术为流数据提供了一种双向和全双工的传输机制。这意味着它可以使用单个 TCP 连接来传输所有消息。我们实现了一个这样的场景,其中客户端向服务器发送消息,服务器以消息流的形式回复。
在下一章中,我们将学习如何将 PostgreSQL 用作 API 的后端存储。在那里,我们将学习如何与关系型数据库和 Go 一起工作。我们还将学习如何以 Docker 容器的形式运行数据库。
第七章:与 PostgreSQL、JSON 和 Go 一起工作
在本章中,我们将从宏观的角度审视 SQL。在前面的章节中,我们讨论了 SQLite3,它是一个用于快速原型设计的轻量级数据库。但是,当涉及到开发企业级应用程序时,MySQL 或 PostgreSQL 是首选的选择。两者都是经过充分验证的、功能强大的开源数据库。在本章中,我们将选择 PostgreSQL 作为我们的主要主题。
首先,我们将讨论 PostgreSQL 的内部结构,然后转向使用 Go 数据库。本章的目标是让读者能够舒适地使用 PostgreSQL 和 Go。我们还将构建一个需要数据库层的 URL 缩短服务。
在本章中,我们将涵盖以下主题:
-
讨论 PostgreSQL 的安装选项
-
介绍
pq,一个纯 PostgreSQL 数据库驱动程序,用于 Go -
使用 PostgreSQL 和
pq实现 URL 缩短服务 -
探索 PostgreSQL 中的 JSONStore 功能
技术要求
为了运行代码示例,以下软件应预先安装:
-
操作系统:Linux(Ubuntu 18.04)/Windows 10/MacOS X >= 10.13
-
软件:Docker >= 18(Windows 和 MacOS X 的 Docker Desktop)
-
Go 编译器:稳定版本 >= 1.13.5
-
PostgreSQL:稳定版本 >= 10.8
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter7下载本章的代码。克隆代码,并使用chapter7目录中的代码示例。
讨论 PostgreSQL 的安装选项
PostgreSQL 是一个开源数据库,可以在多个平台上安装。安装 PostgreSQL 有两种标准选项:
-
在机器上手动安装服务器
-
在 Docker 容器中安装
在操作系统上手动安装可以是通用安装。您可以在以下官方 PostgreSQL 链接中找到安装说明:www.postgresql.org/download/。
对于 MacOS X 和 Windows,您将获得直接的安装程序。对于各种 Linux 版本,PostgreSQL 网站有很好的文档,提供了详细的说明。使用通用安装的唯一缺点是,每当您更改版本时,您都必须安装/卸载 PostgreSQL 数据库。在基于容器的系统中,执行环境与主机系统隔离。Docker 就是这样一种流行的容器系统。在接下来的小节中,我们将探讨如何在 Docker 容器中安装 PostgreSQL。
通过 Docker 安装
我们也可以通过 Docker 安装 PostgreSQL。由于简化了复杂性和易于安装的过程,这种方法现在是最常见的。假设 Docker 已在您的机器上设置,请按照以下步骤操作:
- 最新稳定版本是
10.8。拉取带有版本作为标签的 Docker 镜像,如下所示:
docker pull postgres:10.8
- 使用以下命令查看所有可用镜像的列表:
docker images
- 通过将数据库文件映射到本地文件,在容器内以端口
5432启动 PostgreSQL 服务器,如下所示:
docker run --name postgres-local -p 5432:5432 -v ~/.postgres-data:/var/lib/postgresql/data -e POSTGRES_PASSWORD=YOUR_PASSWORD -d postgres:10.8
这将在localhost:5432上启动 PostgreSQL 服务器。它还将 PostgreSQL 数据库的数据目录挂载到名为postgres-data的本地目录。
在运行前面的命令时,将YOUR_PASSWORD替换为实际密码。-d选项用于在命令中运行容器作为守护进程。现在,PostgreSQL 服务器正在我们的机器上运行。它使用我们之前拉取的postgres:10.8 Docker 镜像。
我们也可以使用docker run命令而不拉取镜像。docker pull命令是明确显示我们正在使用postgres镜像。
一旦我们安装了 PostgreSQL,我们必须创建默认用户以访问数据库。我们将在下一节中探讨这个问题。
在 PostgreSQL 中添加用户和数据库
现在,我们可以创建一个新的用户和数据库。为此,我们将使用 Ubuntu/MacOS X 作为一个通用示例。我们在名为psql的 shell 中这样做。我们可以使用\?命令在psql中查看所有可用的命令。为了进入psql shell,首先,切换到postgres用户。在 Ubuntu 上,对于通用安装,你可以使用以下命令进入psql shell:
sudo su postgres
现在,它使我们成为名为postgres的用户。然后,使用以下命令启动psql shell:
psql
在 PostgreSQL 在 Docker 容器中运行的情况下,使用以下命令直接启动psql shell:
docker exec -i -t postgres-local-1 psql -U postgres
一旦你进入psql shell,在 shell 中输入\?帮助命令,你将看到所有可用的命令输出,如下面的截图所示:

要列出所有可用的用户及其权限,你将在帮助 shell 的信息部分找到以下命令:
\du - List roles
角色是授予用户的访问权限。使用\du命令,你可以看到默认用户是postgres,附有以下角色:
postgres | Superuser, Create role, Create DB, Replication, Bypass RLS
我们需要一个新用户来与 PostgreSQL 一起工作。要添加新用户,只需在psql shell 中输入此SQL命令:
CREATE ROLE git-user with LOGIN PASSWORD 'YOUR_PASSWORD'; # Caution: Choose strong password here
这创建了一个名为gituser的新用户和密码YOUR_PASSWORD。现在,使用以下命令给用户权限以创建数据库和进一步的角色:
ALTER USER gituser CREATEDB, CREATEROLE;
要删除用户,使用相同上下文中的DROP命令,如下所示:
DROP ROLE git-user;
不要尝试更改默认postgres用户的密码。它被设计为一个超级用户账户,不应作为普通用户保留。相反,创建一个新的角色并为其分配所需的权限。使用强密码。
如果你没有使用命令行界面(CLI),你可以安装图形用户界面(GUI)客户端,如pgAdmin 4,以访问数据库。
你可以在这里找到更多关于如何将pgAdmin 4作为 Docker 应用程序安装的详细信息:hub.docker.com/r/dpage/pgadmin4/。
现在我们知道了如何创建一个角色,让我们看看一些更常见的创建、读取、更新和删除(CRUD)SQL 命令,这些命令在大多数关系型数据库中都很常见。看看下面的表格:
| 操作 | SQL 命令 |
|---|---|
| 创建数据库 |
CREATE DATABASE mydb;
|
| 创建表 |
|---|
CREATE TABLE products (
product_no integer,
name text,
price numeric
);
|
| 向表中插入 |
|---|
INSERT INTO products VALUES (1, 'Rice', 5.99);
|
| 更新表 |
|---|
UPDATE products SET price = 10 WHERE price = 5.99;
|
| 从表中删除 |
|---|
DELETE FROM products WHERE price = 5.99;
|
这些基本命令可以由许多高级 SQL 运算符支持,例如LIMIT、ORDER BY和GROUP BY。SQL 还有许多其他概念,例如在表之间连接关系。
你可以在这里找到更多关于 PostgreSQL 支持的 SQL 查询的详细信息:www.postgresql.org/docs/10/sql.html。
在下一节中,我们将看到 Go 程序如何与 PostgreSQL 服务器通信。我们将尝试利用一个名为pq的数据库驱动程序。使用该驱动程序包,我们将看到一个将 Web URL 插入 PostgreSQL 的例子。
介绍 pq,一个纯 PostgreSQL 数据库驱动程序,适用于 Go
在第四章,《使用流行的 Go 框架简化 RESTful 服务》,我们使用了一个名为go-sqlite3的驱动包来与 SQLite3 交互。同样地,pq是一个适用于 Go 的数据库驱动包。我们可以通过使用go get命令在系统范围内安装该库,如下所示:
go get github.com/lib/pq
我们还可以使用dep工具安装此包。我们将在本例中使用它。让我们看看安装步骤:
- 在
GOPATH中创建一个名为basicExample的新项目目录,如下所示:
touch -p $GOPATH/src/github.com/git-user/chapter7/basicExample
- 现在,切换到
basicExample目录,并使用dep安装该目录中的pq包,如下所示:
dep init
dep ensure --add github.com/lib/pq
这将创建一些配置文件,并在同一目录下添加一个包到供应商中。现在,我们可以创建我们的程序并使用那个pq包。
- 为了创建一个新的表,我们应该在 PostgreSQL 服务器中创建一个新的数据库。要创建一个新的数据库,请进入
psql外壳或使用pgAdmin 4,如下所示(你只需这样做一次):
CREATE DATABASE mydb;
让我们看看一个简短的例子,解释了pq驱动程序的使用。在后面的章节中,我们将实现一个 URL 缩短服务。这是该服务的预步骤。我们将按照以下步骤创建一个名为web_url的表:
- 在项目中创建一个名为
helper的目录,如下所示:
mkdir $GOPATH/src/github.com/git-user/chapter7/basicExample/helper
这有助于启动数据库操作,如创建表。
-
现在,添加一个名为
models.go的文件。这个文件将包含表创建逻辑。我们使用sql.Open来连接到 PostgreSQL。该函数接受数据库类型和数据库字符串作为参数。我们可以使用db.Prepare命令准备一个 SQL 语句。 -
在程序中导入必要的包,如下所示:
package helper
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // sql behavior modified
)
- 现在,创建一些常量来保存数据库连接信息。数据库连接需要一个主机名、端口号、用户名、密码和数据库名,如下面的代码块所示:
const (
host = "127.0.0.1"
port = 5432
user = "git-user"
password = "YOUR_PASSWORD"
dbname = "mydb"
)
密码应该是你在创建用户时提供的密码。
- 接下来,创建一个名为
InitDB的函数。它使用连接字符串来打开一个新的数据库连接到 PostgreSQL。在成功连接后,它应该准备一个 SQL 语句来创建一个名为web_url的表。该函数的代码如下:
func InitDB() (*sql.DB, error) {
var connectionString = fmt.Sprintf("host=%s port=%d user=%s "+
"password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
var err error
db, err := sql.Open("postgres", connectionString)
if err != nil {
return nil, err
}
stmt, err := db.Prepare("CREATE TABLE IF NOT EXISTS web_url(ID SERIAL PRIMARY KEY, URL TEXT NOT NULL);")
if err != nil {
return nil, err
}
_, err = stmt.Exec()
if err != nil {
return nil, err
}
return db, nil
}
sql.Open 方法打开连接字符串。然后准备并执行一个 CREATE TABLE 查询来创建一个 web_url 表(如果该表尚不存在)。如果数据库上的任何操作失败,InitDB 将返回一个错误。
- 让我们创建一个
main.go程序来使用helper包,如下所示:
mkdir $GOPATH/src/github.com/git-user/chapter7/basicExample/main.go
- 在
main块中,我们可以导入一个InitDB辅助函数并像这样使用它:
package main
import (
"log"
"github.com/git-user/chapter7/basicExample/helper"
)
func main() {
_, err := helper.InitDB()
if err != nil {
log.Println(err)
}
log.Println("Database tables are successfully initialized.")
}
此程序导入了 helper 包并使用其中的 InitDB 函数。如果表成功创建,我们将记录一个成功的初始化消息;否则,我们将记录一个错误。
- 如果你运行程序,你将看到以下信息被打印出来:
go run main.go
2020/02/13 22:15:34 Database tables are successfully initialized.
这在 mydb 数据库中创建了一个 web_url 表。
- 我们可以通过进入
psql壳并输入以下内容来交叉检查:
\c mydb \dt
这将用户连接到 mydb 数据库并列出所有可用的表,如下面的代码片段所示:
You are now connected to database "mydb" as user "postgres".
List of relations
Schema | Name | Type | Owner
--------+---------+-------+-------
public | web_url | table | user
(1 row)
在 PostgreSQL 中,创建表时需要将 AUTO_INCREMENT 类型替换为 SERIAL。
正如我们之前承诺的,在下一节中,我们将尝试实现一个 URL 缩短服务。我们首先将概述构建此类服务所需的基本知识。然后,我们将继续进行实现。URL 缩短服务将帮助你清楚地了解 PostgreSQL 如何用于解决问题。
使用 PostgreSQL 和 pq 实现 URL 缩短服务
让我们编写 URL 缩短服务的代码来解释前面章节中讨论的所有概念。在编写我们服务的 API 之前,我们需要一些基础知识。首先,我们需要设计一个实现 Base62 算法并带有编码/解码函数的包。URL 缩短技术需要 Base62 算法将长 URL 转换为短 URL,反之亦然。设计完包后,我们将编写一个示例来展示这种编码是如何工作的。
定义 Base62 算法
Base62 算法是一个数字编码器,它将给定的数字转换为字符串。它是如何做到这一点的?输入数字是从 62 个字符映射过来的。这个算法的美丽之处在于它为每个给定的数字创建唯一的、较短的字符串。即使输入很大,它也可以生成易于记忆的短字符串。我们使用这种技术将数据库 ID 传递到我们将要创建的 ToBase62 函数中,并得到一个短字符串。让我们编写一个实现 Base62 算法的示例。逻辑完全是数学性的,可以以不同的方式实现。按照以下步骤进行:
- 创建一个名为
base62Example的项目,如下所示:
mkdir $GOPATH/src/github.com/git-user/chapter7/base62Example
- 创建一个名为
base62的包并添加一个名为encodeutils.go的文件,如下所示:
mkdir $GOPATH/src/github.com/git-user/chapter7/base62Example/base62 touch $GOPATH/src/github.com/git-user/chapter7/base62Example/base62
/encodeutils.go
- 定义两个函数,分别命名为
ToBase62和ToBase10。第一个函数接收一个整数并生成一个base62字符串,而第二个函数则相反,它接收一个base62字符串并返回原始数字。编码/解码的程序如下:
package base62
import (
"math"
"strings"
)
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const b = 62
// Function encodes the given database ID to a base62 string
func ToBase62(num int) string{
r := num % b
res := string(base[r])
div := num / b
q := int(math.Floor(float64(div)))
for q != 0 {
r = q % b
temp := q / b
q = int(math.Floor(float64(temp)))
res = string(base[int(r)]) + res
}
return string(res)
}
// Function decodes a given base62 string to database ID
func ToBase10(str string) int{
res := 0
for _, r := range str {
res = (b * res) + strings.Index(base, string(r))
}
return res
}
- 创建另一个程序,使用这些实用函数,如下所示:
vi $GOPATH/src/github.com/git-user/chapter7/base62Example/base62/usebase62.go
此程序使用从 encodeutils.go 导入的函数并计算一个编码字符串。然后,它将其解码回原始数字并打印出数字和字符串,如下所示:
package main
import (
"log"
"github.com/git-user/chapter7/base62Example/base62"
)
func main() {
x := 100
base62String := base62.ToBase62(x)
log.Println(base62String)
normalNumber := base62.ToBase10(base62String)
log.Println(normalNumber)
}
在这里,我们使用 base62 包中的 encode/decode 函数并尝试打印转换。
- 我们可以使用以下命令运行程序:
go run usebase62.go
它会打印以下内容:
2020/02/14 21:24:43 1C
2020/02/14 21:24:43 100
数字 100 的 Base62 编码是 1C。这是因为数字 100 在我们的 base62 逻辑中缩小为 1C。
在学习 Base62 编码的基础知识之后,让我们实现一个 URL 缩短服务,我们将利用 Base62 算法生成一个短 URL。实现策略如下所示:
-
设计一个 API 路由以从客户端收集一个长 URL。
-
将该长 URL 插入数据库并获取该记录的 ID。
-
使用该 ID 生成一个
Base62字符串,并将其与 API 服务器主机名一起作为响应中的缩短 URL 传递。 -
当客户端使用该缩短 URL 时,它会击中我们的 API 服务器。
-
API 服务器然后将
Base62字符串解码回数据库 ID 并获取原始 URL。 -
最后,客户端可以使用此 URL 跳转到原始网站。
记住,我们正在构建一个支持 URL 缩短的服务。这些服务利用我们的 API 来 encode/decode 逻辑请求缩短 URL。
我们将编写一个 Go API 服务,实现上述策略。我们将重用以下文件:
-
从
base62Example项目中获取encodeutils.go以进行编码/解码 -
从
basicExample项目中获取base62和models.go以支持数据库逻辑
我们将使用 gorilla/mux 包进行 URL 多路复用,并使用 pq 在 PostgreSQL 数据库中存储和检索结果。让我们创建项目结构,如下所示:
- 目录结构如下所示:
urlshortener
├── main.go
├── helper
│ └── models.go
└── utils
└── encodeutils.go
2 directories, 3 files
-
将
encodeutils.go和models.go从前面的示例复制到前面代码块中显示的目录。 -
在主程序中,我们需要两个数据结构:一个用于存储数据库连接,另一个用于存储 URL 缩短服务响应。让我们将响应称为
Record类型。创建两个结构体,如下所示:
type DBClient struct {
db *sql.DB
}
type Record struct {
ID int `json:"id"`
URL string `json:"url"`
}
-
现在,创建
main函数,其中我们定义两个 URL 处理函数。这些应用程序路由需要缩短 URL 和检索原始 URL。主块应该创建一个新的数据库连接和两个mux路由。我们将GenerateShortURL和GetOriginalURL函数处理程序附加到这两个 mux 路由。 -
最后,我们运行 HTTP 服务器,提供 API 服务。以下代码是
main块:
func main() {
db, err := models.InitDB()
if err != nil {
panic(err)
}
dbclient := &DBClient{db: db}
if err != nil {
panic(err)
}
defer db.Close()
// Create a new router
r := mux.NewRouter()
// Attach an elegant path with handler
r.HandleFunc("/v1/short/{encoded_string:[a-zA-Z0-9]*}",
dbclient.GetOriginalURL).Methods("GET")
r.HandleFunc("/v1/short",
dbclient.GenerateShortURL).Methods("POST")
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
// Good practice: enforce timeouts for servers you create!
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
看看POST操作。GenerateShortURL函数处理程序接收一个 HTTP 请求并执行以下操作:
-
将来自 HTTP 请求体的 URL 插入数据库并检索新记录的 ID。
-
使用
base62将 ID 转换为字符串,并在 HTTP 响应中发送,如下所示:
// GenerateShortURL adds URL to DB and gives back shortened string
func (driver *DBClient) GenerateShortURL(w http.ResponseWriter,
r *http.Request) {
var id int
var record Record
postBody, _ := ioutil.ReadAll(r.Body)
err := json.Unmarshal(postBody, &record)
err = driver.db.QueryRow("INSERT INTO web_url(url)
VALUES($1) RETURNING id", record.URL).Scan(&id)
responseMap := map[string]string{"encoded_string":
base62.ToBase62(id)}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
response, _ := json.Marshal(responseMap)
w.Write(response)
}
}
客户端认为原始 URL 已被缩短,但实际上,ID 是通过base62算法映射到一个更短的字符串。
现在是GET操作。GetOriginalURL函数处理程序接收缩短的 URL 并将其转换回原始 URL。逻辑是将base62字符串(缩短的字符串)转换为数字,并使用该数字从 PostgreSQL 数据库中检索记录。我们解析输入请求并收集encoded_string参数。我们使用它从数据库中检索原始 URL。以下代码是GetOriginalURL函数处理程序:
// GetOriginalURL fetches the original URL for the given encoded(short) string
func (driver *DBClient) GetOriginalURL(w http.ResponseWriter,
r *http.Request) {
var url string
vars := mux.Vars(r)
// Get ID from base62 string
id := base62.ToBase10(vars["encoded_string"])
err := driver.db.QueryRow("SELECT url FROM web_url
WHERE id = $1", id).Scan(&url)
// Handle response details
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
responseMap := map[string]interface{}{"url": url}
response, _ := json.Marshal(responseMap)
w.Write(response)
}
}
DBClient结构体是必需的,以便在各个函数之间传递数据库驱动程序。运行程序,如下所示:
go run $GOPATH/src/github.com/git-user/chapter7/urlshortener/main.go
另一个选项是安装一个二进制文件。如果你的$GOPATH/bin已经包含在系统PATH变量中,我们可以首先安装二进制文件并像这样运行它:
go install $GOPATH/src/github.com/git-user/chapter7/urlshortener/main.go
使用二进制文件名,如下所示:
./urlshortener
安装二进制文件是一个最佳实践,因为它在系统范围内可用。但对于较小的程序,我们可以从程序的目录中运行main.go。
现在,它将在端口8000上运行 HTTP 服务器并开始收集 URL 缩短服务的请求。打开控制台并输入以下curl命令:
curl -X POST \
http://localhost:8000/v1/short \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{
"url": "https://www.packtpub.com/eu/game-development/unreal-engine-4-shaders-and-effects-cookbook"
}'
它返回缩短的字符串,如下所示:
{
"encoded_string": "1"
}
编码的字符串只是"1"。Base62算法从"1"开始分配更短的字符串,直到一个由字母数字字符组合。现在,如果我们需要检索原始 URL,我们可以执行一个GET请求,如下所示:
curl -X GET http://localhost:8000/v1/short/1
它返回以下 JSON 代码:
{
"url":"https://www.packtpub.com/eu/game-development/unreal-engine-4-shaders-and-effects-cookbook"
}
因此,该服务可以使用此结果将用户重定向到原始 URL(网站)。在这里,生成的字符串不依赖于 URL 的长度,因为数据库 ID 是编码的唯一标准。
为了简洁起见,省略了urlshortener包的导入。请参考chapter7 GitHub 仓库中的项目代码。
在 PostgreSQL 中,需要在INSERT SQL 命令中添加RETURNING关键字来获取最后插入的数据库 ID。MySQL 或 SQLite3 的INSERT INTO web_url( ) VALUES($1) RETURNING id, record.URL查询并非如此。此数据库查询返回最后插入记录的 ID。如果我们删除那个RETURNING关键字,查询将返回空值。
在下一节中,我们将探讨 PostgreSQL 的一个重要特性,称为 JSONStore。与其它关系型数据库不同,PostgreSQL 允许以字段形式存储 JSON。它还提供了一种用于 JSON 的查询语言。
探索 PostgreSQL 中的 JSONStore 特性
PostgreSQL >9.2有一个名为 JSONStore 的显著特性。PostgreSQL 为 9.2 版本引入了两种新的数据类型用于存储 JSON 数据。PostgreSQL 允许用户将 JSON 数据作为json字段或jsonb字段插入。这对于需要更灵活结构的现实世界数据建模非常有用。PostgreSQL 通过允许我们存储 JSON 字符串以及关系型数据类型,结合了两者的优点。
json和jsonb之间的主要区别在于,json字段以纯文本形式存储数据,而jsonb字段以二进制字段形式存储相同的数据。每个都有其自身的优点。例如,json字段通常比jsonb字段占用更少的空间,因为它是一个简单的插入操作,但jsonb字段对 JSON 进行了索引,以便更好地查询。您应根据 JSON 文档是整体获取还是基于内部键查询来选择合适的字段。
在本节中,我们将尝试理解之前章节中为物流用例定义的一些 JSON 模型,但在这里,我们将使用jsonb字段在 PostgreSQL 中存储和检索项目。为了更好地处理 PostgreSQL 的 JSONStore,普通的pq库非常繁琐。因此,为了更好地处理,我们可以使用一个名为Grails Object Relational Mapping(GORM)的对象关系映射器(ORM)。在下一节中,我们将简要讨论这一点。
介绍 GORM,一个强大的 Go 语言 ORM
GORM提供了在database/sql包中可以执行的所有操作的函数。我们可以使用dep工具安装 GORM。在本节中,我们将通过示例展示如何使用GORM包插入、检索和查询 PostgreSQL 的JSON。
欲获取此 ORM 的完整文档,请访问jinzhu.me/gorm/。让我们编写一个程序来实现Shipment和Package类型作为 JSON 模型。我们将使用之前章节中定义的相同模型进行物流。让我们看看步骤:
- 在
$GOPATH/src/github.com/git-user/chapter7中创建一个名为jsonstore的新目录,并创建文件,如下所示:
mkdir jsonstore
mkdir jsonstore/helper
touch jsonstore/helper/models.go
- 通过
dep在jsonstore目录下安装所有依赖,如下所示:
dep init
dep ensure --add "github.com/gorilla/mux" "github.com/jinzhu/gorm" "github.com/lib/pq"
- 现在,编辑
helper.go文件以添加Shipment和Package模型。我们创建的每个模型(表)都应该在gorm中以结构体的形式表示。这就是我们创建两个结构体的原因:Package和Shipment。第一行应该是gorm.Model。其他字段是表中的字段。默认情况下,将在数据库中插入的文档中创建一个自增 ID。请参见以下代码:
package helper
import (
"github.com/jinzhu/gorm"
_ "github.com/lib/pq"
)
type Shipment struct {
gorm.Model
Packages []Package
Data string `sql:"type:JSONB NOT NULL DEFAULT '{}'::JSONB" json:"-"`
}
type Package struct {
gorm.Model
Data string `sql:"type:JSONB NOT NULL DEFAULT '{}'::JSONB"`
}
// GORM creates tables with plural names.
// Use this to suppress it
func (Shipment) TableName() string {
return "Shipment"
}
func (Package) TableName() string {
return "Package"
}
如果您注意到,在前面的代码块中,Data是为Shipment和Package表创建的jsonb字段。
- 完成结构体的定义后,我们可以编写一个表初始化逻辑,将这些结构体迁移到 PostgreSQL 数据库中的表。为了做到这一点,利用 GORM 包中的
Open方法获取数据库连接。然后,我们可以使用之前创建的结构体运行AutoMigrate方法,将它们保存到数据库中。请参见以下迁移代码:
func InitDB() (*gorm.DB, error) {
var err error
db, err := gorm.Open("postgres",
"postgres://git-user:YOUR_PASSWORD@
localhost/mydb?sslmode=disable")
if err != nil {
return nil, err
}
db.AutoMigrate(&Shipment{}, &Package{})
return db, nil
}
这个InitDB逻辑与使用pq库定义的逻辑相似。这个辅助文件迁移表并将数据库连接返回给调用InitDB函数的人。在下一节中,我们将利用这个连接与Shipment和Package表交互。
实现物流 REST API
在深入之前,让我们设计一个 API 规范表,该表显示了各种 URL 端点的 REST API 签名。请参考以下表格:
| 端点 | 方法 | 描述 |
|---|---|---|
/v1/shipment/id |
GET |
根据 ID 获取一个运输 |
/v1/package/id |
GET |
根据 ID 获取一个包装 |
/v1/package?weight=n |
GET |
获取给定重量的包装(以克为单位) |
/v1/shipment |
POST |
创建一个新的运输 |
/v1/package |
POST |
创建一个新的包装 |
要实现前面的 API,我们需要一个主程序,该程序将 API 路由注册到处理函数。向我们的jsonstore项目添加一个额外的文件,如下所示:
touch jsonstore/main.go
在这个程序中,我们将尝试实现Package的POST和GET端点。我们建议将Shipment的剩余两个端点作为读者的作业来实现。按照以下步骤操作:
- 程序结构遵循我们迄今为止看到的程序相同的风格。我们从辅助包中收集数据库连接并使用它来创建
DBClient。我们使用gorilla/mux作为我们的 HTTP 路由器,并使用gorm包进行数据库操作。我们应该在我们的程序中有以下路由和处理函数:
type DBClient struct {
db *gorm.DB
}
func main(){
...
db, err := models.InitDB()
dbclient := &DBClient{db: db}
r.HandleFunc("/v1/package/{id:[a-zA-Z0-9]*}",
dbclient.GetPackage).Methods("GET")
r.HandleFunc("/v1/package",
dbclient.PostPackage).Methods("POST")
r.HandleFunc("/v1/package",
dbclient.GetPackagesbyWeight).Methods("GET")
...
}
POST处理函数将一个包装对象保存到数据库中。它返回插入记录的 ID。PostPackage处理函数的代码如下:
// PostPackage saves the package information
func (driver *DBClient) PostPackage(w http.ResponseWriter,
r *http.Request) {
var Package = models.Package{}
postBody, _ := ioutil.ReadAll(r.Body)
Package.Data = string(postBody)
driver.db.Save(&Package)
responseMap := map[string]interface{}{"id": Package.ID}
w.Header().Set("Content-Type", "application/json")
response, _ := json.Marshal(responseMap)
w.Write(response)
}
这个函数正在从响应中读取POST正文,并调用 ORM 函数来保存包装数据,如下所示:
driver.db.Save(&Package)
在成功将包装保存到数据库后,前面的函数将 ID 作为响应的一部分返回。
- 现在,让我们编写
GetPackage处理函数的代码。它与前面的处理函数类似,只是它使用了一个不同的数据库函数。在这段代码中,我们不是读取请求体,而是必须读取PATH变量并使用它来查询数据。请参阅以下代码:
type PackageResponse struct {
Package helper.Package `json:"Package"`
}
// GetPackage fetches the original URL for the given
// encoded(short) string
func (driver *DBClient) GetPackage(w http.ResponseWriter,
r *http.Request) {
var Package = models.Package{}
vars := mux.Vars(r)
driver.db.First(&Package, vars["id"])
var PackageData interface{}
json.Unmarshal([]byte(Package.Data), &PackageData)
var response = PackageResponse{Package: Package}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respJSON, _ := json.Marshal(response)
w.Write(respJSON)
}
在这种情况下,查询如下所示:
driver.db.First(&Package, vars["id"])
- 现在来进行另一个
GET操作。GetPackagebyWeight处理函数查询数据库以获取给定重量的包。在这里,我们对data字段使用 JSON 查询。我们使用特殊的column ->> field语法,如下所示的处理代码:
// GetPackagesbyWeight fetches all packages with given weight
func (driver *DBClient) GetPackagesbyWeight(w http.ResponseWriter, r *http.Request) {
var packages []models.Package
weight := r.FormValue("weight")
// Handle response details
var query = "select * from \"Package\" where data->>'weight'=?"
driver.db.Raw(query, weight).Scan(&packages)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respJSON, _ := json.Marshal(packages)
w.Write(respJSON)
}
在 PostgreSQL 中查询 JSON 的其他变体有很多。您可以在以下链接中找到它们:www.postgresql.org/docs/10/functions-json.html。
在 GetPackagesbyWeight 处理函数中,我们通过使用 db.Raw 方法对数据库进行原始查询。它返回所有符合重量标准的包列表。对于这个 GET API,重量标准列表是由客户端作为查询参数发送的。
在这个 JSONStore 示例中,有四个重要的方面,如下所示:
-
我们将传统的驱动程序
pq替换为了GORM驱动程序。 -
我们使用了 GORM 函数进行 CRUD 操作。
-
我们将 JSON 插入 PostgreSQL 并检索结果。
-
我们执行了原始 SQL 并对 JSON 字段进行了过滤。
这完成了我们程序的重要逻辑。请参考 chapter7 仓库以获取完整的代码。
现在,使用以下命令运行程序:
go run jsonstore/main.go
它运行一个 Go 服务器。执行几个 curl 命令以查看 API 响应,如下所示:
- 创建包(
POST),如下所示:
curl -X POST \
http://localhost:8000/v1/package \
-H 'cache-control: no-cache' \
-H 'content-type: application/json' \
-d '{
"dimensions": {
"width": 21,
"height": 12
},
"weight": 10,
"is_damaged": false,
"status": "In transit"
}'
它返回数据库中插入的记录,如下所示:
{
"id": 1
}
- 现在,我们需要
GET插入包的详细信息,如下所示:
curl -X GET http://localhost:8000/v1/package/1
它返回了 ID:1 的包的所有详细信息,如下所示:
{"Package":{"ID":1,"CreatedAt":"2020-02-15T11:14:52.859073Z","UpdatedAt":"2020-02-15T11:14:52.859073Z","DeletedAt":null,"Data":"{\"status\": \"In transit\", \"weight\": 10, \"dimensions\": {\"width\": 21, \"height\": 12}, \"is_damaged\": false}"}}
- 让我们测试我们的第二个
GETAPI,如下所示:
curl -X GET 'http://localhost:8000/v1/package?weight=10'
这返回了所有重量为 10 克的包,如下所示:
[{"ID":1,"CreatedAt":"2020-02-15T11:14:52.859073Z","UpdatedAt":"2020-02-15T11:14:52.859073Z","DeletedAt":null,"Data":"{\"status\": \"In transit\", \"weight\": 10, \"dimensions\": {\"width\": 21, \"height\": 12}, \"is_damaged\": false}"}]
这个项目的目标是展示如何将 JSON 存储在 PostgreSQL 中并从中检索。这标志着我们通过 PostgreSQL 的旅程结束。在 PostgreSQL 中还有更多东西可以探索,但这超出了本书的范围。PostgreSQL 通过允许我们在同一张表中存储关系型数据以及 JSON 数据,将两者的优点结合在一起。它还允许我们查询 JSON 数据。
摘要
在本章中,我们通过介绍 PostgreSQL 开始了我们的旅程。我们看到了如何在 Docker 中运行 PostgreSQL,并列出了一些基本的 SQL 查询以进行 CRUD 操作。我们学习了如何在 PostgreSQL 中添加新用户和角色。然后,我们讨论了 pq,这是 Go 的 PostgreSQL 驱动程序包的一个示例。
我们使用 Base62 算法设计了一个 URL 缩短服务。我们利用 pq 和 gorilla/mux 来实现该服务。
从版本 9.2 开始,PostgreSQL 也允许JSON 存储(JSONStore)。它允许开发者在数据库中插入和检索 JSON 文档。它结合了关系型和非关系型数据库的 JSONStore 功能。
我们还介绍了 GORM,这是 Go 中一个知名的 ORM。使用 ORM,可以轻松管理数据库操作。GORM 提供了一些有用的函数——例如AutoMigrate(如果不存在则创建表)——以便在传统的database/sql驱动程序上编写直观的 Go 代码。
最后,我们使用 GORM 实现了物流的 REST API。PostgreSQL 是一个成熟的、开源的关系型数据库,可以成为 Go 的良好存储后端。本章的主要目标是让您在 PostgreSQL 和 Go 进行 REST API 开发时感到舒适。
到目前为止,我们已经探讨了构建提供 REST API 的服务器。有时,开发者需要客户端工具来消费 REST API。了解客户端如何消费 REST API 以创建更好的 API 也是很有用的。在下一章中,我们将大致了解如何在 Go 中构建客户端软件。在那里,我们也为 GitHub REST API 开发了 API 客户端。
第八章:在 Go 中构建 REST API 客户端
在本章中,我们将深入讨论 Go 客户端应用程序的工作原理。我们将探索 grequests,这是一个类似 Python requests 的库,允许我们从 Go 代码中发起 API 调用。然后,我们将编写一些使用 GitHub API 的客户端软件。在这个过程中,我们将尝试了解两个名为 cli 和 cobra 的 Go 库。在了解这些库的基本原理之后,我们将编写一个用于命令行的 API 测试工具。然后,我们将介绍 Redis,这是一个内存数据库,我们可以用它来缓存 API 响应以备份数据。
在本章中,我们将涵盖以下主题:
-
构建 REST API 客户端的计划
-
在 Go 中编写命令行工具的基础
-
grequests– Go 的 REST API 包 -
熟悉 GitHub REST API
-
Cobra,一个高级 CLI 库
-
为 GitHub REST API 创建 CLI 工具
-
使用 Redis 缓存 API 数据
技术要求
以下软件需要预先安装,以便您可以在本章中运行代码示例:
-
操作系统:Linux(Ubuntu 18.04)/ Windows 10/Mac OS X >=10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep: Go >= 0.5.3 的依赖管理工具
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter8下载本章的代码。克隆代码并使用chapter8目录中的代码示例。
构建 REST API 客户端的计划
到目前为止,我们主要关注编写服务器端 REST API。基本上,这些都是服务器程序。在某些情况下,例如 gRPC,我们需要一个客户端。客户端程序从用户那里获取输入并执行一些逻辑。为了开发 Go 客户端,我们应该了解 Go 中的 flag 库。在此之前,我们应该了解如何从 Go 程序中发起 API 请求。在前几章中,我们使用了不同的客户端,如 cURL、浏览器、Postman 等。但如何将 Go 程序转换为客户端?
命令行工具与网络用户界面同等重要,用于执行系统任务。在 企业对企业(B2B)公司中,软件被打包成一个单一的二进制文件,而不是多个不同的包。作为一名 Go 开发者,您应该知道如何实现为命令行编写应用程序的目标。然后,您可以将这些知识用于轻松优雅地创建与 REST API 相关的 Web 客户端。
让我们探索如何在 Go 中编写 命令行界面(CLI)工具的基础。
在 Go 中编写命令行工具的基础
Go 提供了一个名为flag的内置库,用于编写 CLI 工具。它指的是命令行标志。由于它已经包含在 Go 发行版中,因此无需外部安装任何东西。flag包具有多个函数,如Int和String,用于处理作为命令行标志提供的相应类型输入。假设我们从用户那里收集一个名字并将其打印回控制台。
要做到这一点,我们可以使用flag.String方法,如下面的代码片段所示:
import "flag"
var name = flag.String("name", "stranger", "your wonderful name")
让我们编写一个简短的程序来更详细地说明flagAPI:
- 在
GOPATH中创建一个名为flagExample.go的文件,如下所示:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/basic/
touch $GOPATH/src/github.com/git-user/chapter8/basic/flagExample.go
- 现在,我们可以使用
flag包的String方法从命令行作为选项接收一个字符串,如下所示:
package main
import (
"flag"
"log"
)
var name = flag.String("name", "stranger", "your wonderful name")
func main(){
flag.Parse()
log.Printf("Hello %s, Welcome to the command line world",
*name)
}
在这个程序中,我们创建了一个名为name的标志。它是一个字符串指针。flag.String接受三个参数。第一个参数是选项的名称。第二个和第三个参数是该标志的默认值和帮助文本。我们已要求程序在主块中解析所有标志指针。
当我们运行程序时,它会将命令行选项提供的值映射到相应的变量。要在代码中访问标志的值,我们使用*,这是一个指针值引用;例如,在先前的代码块中的*name。
- 使用以下命令构建并运行程序:
go build $GOPATH/src/github.com/git-user/chapter8/basic/ flagExample.go
这在basic目录中创建了一个二进制文件。
- 我们可以像正常的可执行文件一样运行它,如下所示:
./flagExample
它给出了以下输出:
Hello stranger, Welcome to the command line world
正如你可能注意到的,我们没有将name参数传递给命令。然而,我们确实为该参数分配了默认值。Go 的标志默认值会继续执行。
- 现在,为了查看可用的选项并了解它们,我们需要请求帮助,如下所示:
./flagExample -h
Output
========
Usage of ./flagExample:
-name string
your wonderful name (default "stranger")
这就是为什么我们将帮助文本作为标志命令的第三个参数传递的原因。
在 Windows 中,当我们构建.go文件时,将生成.exe文件。之后,从命令行,我们可以通过调用程序名称来运行程序。
- 现在,尝试传递带有值的
name选项:
./flagExample -name Albert
(or)
./flagExample -name=Albert
两种样式都可以正常工作,输出会打印出提供的值:
Hello Albert, Welcome to the command line world
- 如果我们希望传递多个选项,请将
basic目录中的先前的程序修改为添加年龄,并将其命名为flagExampleMultipleParam.go:
package main
import (
"flag"
"log"
)
var name = flag.String("name", "stranger", "your wonderful name")
var age = flag.Int("age", 0, "your graceful age")
func main(){
flag.Parse()
log.Printf("Hello %s (%d years), Welcome to the command line
world", *name, *age)
}
- 这有两个选项,加上不同类型的添加。如果我们构建并运行它,我们将看到以下输出:
./flagExampleMultiParam -name Albert -age 24
Hello Albert (24 years), Welcome to the command line world
这正是我们预期的。
- 我们可以不使用指针,而是通过
init()函数将变量绑定到解析后的输出。这种绑定是通过init()函数完成的,它在 Go 程序中运行,无论是否存在main函数:
var name String
func init() {
flag.StringVar(&name, "name", "stranger", "your wonderful name")
}
这样,值将直接存储在变量中。
- 完全重写先前的程序以使用
init()函数创建一个新的程序,可以在以下代码片段中看到:
basic/initFlag.go
package main
import (
"flag"
"log"
)
var name string
var age int
func init() {
flag.StringVar(&name, "name", "stranger", "your wonderful
name")
flag.IntVar(&age, "age", 0, "your graceful age")
}
func main(){
flag.Parse()
log.Printf("Hello %s (%d years), Welcome to the command
line world", name, age)
}
输出与先前的程序完全相同。在这里,我们能够直接将数据加载到我们的变量中,而不是使用指针。
在 Go 中,执行从主程序开始。然而,一个 Go 程序可以有任意数量的init函数。如果一个包中有一个init函数,那么它将在main函数之前执行。
这个flag库非常易于使用。然而,为了编写高级客户端应用程序,我们需要借助 CLI 包的帮助。在下一节中,我们将探讨一个名为cli的包,它将做这件事。
CLI – 用于构建美观客户端的包
cli包是 Go 开发者在使用flag包之后的下一步。它提供了一个直观的 API,可以轻松地创建命令行应用程序。它允许 Go 程序收集参数和标志。对于设计复杂应用程序来说,它非常方便。
首先,为我们的示例程序创建一个目录。对于我们的基本 CLI 示例,创建以下目录:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/cli/example1
要安装此包,我们使用dep工具。首先初始化工具,然后将cli包作为依赖项添加:
dep init
dep ensure --add github.com/urfave/cli
现在,我们可以编写一个程序,它的工作方式与先前的标志示例完全相同。创建一个名为example1/main.go的文件。
cli包提供了三个主要元素:
-
应用程序
-
标志
-
行动
Apps用于在应用程序中定义命名空间。标志是一个实际的容器,用于存储传递的选项。Action是一个在收集的选项上执行的函数。让我们看看以下示例代码以获得更深入的了解。在这里,我们试图使用cli API 创建 App、Flags 和 Action。我们必须将包导入到我们的程序中:
package main
import (
"log"
"os"
"github.com/urfave/cli"
)
func main() {
// Create new app
app := cli.NewApp()
// add flags with three arguments
app.Flags = []cli.Flag {
cli.StringFlag{
Name: "name",
Value: "stranger",
Usage: "your wonderful name",
},
cli.IntFlag{
Name: "age",
Value: 0,
Usage: "your graceful age",
},
}
// This function parses and brings data in cli.Context struct
app.Action = func(c *cli.Context) error {
// c.String, c.Int looks for value of given flag
log.Printf("Hello %s (%d years), Welcome to the command line world",
c.String("name"), c.Int("age"))
return nil
}
// Pass os.Args to cli app to parse content
app.Run(os.Args)
}
这比我们之前看到的要长,但表达性更强。在这里,我们使用cli.NewApp函数创建了一个新的应用程序。这创建了一个新的结构体。我们需要将参数附加到这个结构体上,特别是Flags结构体和Action函数。Flags结构体是一个列表,定义了此应用程序的所有可能的标志。
GoDoc 中Flag的结构如下(godoc.org/github.com/urfave/cli#Flag):
type Flag interface {
fmt.Stringer
// Apply Flag settings to the given flag set
Apply(*flag.FlagSet)
GetName() string
}
内置的结构体,如StringFlag和IntFlag,实现了这个Flag接口。Name、Value和Usage字段很简单。它们与我们使用的flag包中的类似。Action函数接受cli.Context参数。
上下文对象持有有关标志和命令行参数的任何信息。c.String、c.Int和其他函数用于查找标志变量。例如,在先前的程序中,c.String("name")获取一个名为name的标志变量。这个程序运行效果与先前的标志示例相同。你可以这样构建和运行程序:
go build example1/main.go
./main -name "Albert" # Run program
关于cli包的基本用法就这么多。此包还提供了标志和参数的高级组合。我们将在下一节中查看这一点。
在 CLI 中收集命令行参数
在 bash 术语中,命令行参数和标志之间有一个区别。以下图表清楚地说明了它们之间的区别:

假设我们有一个名为storeMarks的命令行应用程序,用于保存学生的分数。它有一个标志(称为save),用于指定是否应该持久化详细信息。提供的参数是学生的姓名和实际分数。我们已经看到了如何在程序中收集标志值。在本节中,我们将学习如何以表达性的方式收集程序参数。按照以下步骤操作:
- 对于收集参数,我们使用
c.Args函数,其中c是Action函数的cli上下文。为我们的项目添加一个名为example2的新目录:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/cli/example2
然后,创建一个名为example2/main.go的程序文件。
- 在主块中定义应用程序
cli.NewApp创建一个新的应用程序:
app := cli.NewApp()
- 接下来,我们定义
app.cli.Flag上的标志,它包含一些预定义的标志,例如整数标志或字符串标志。在这里,我们需要一个字符串标志:
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "save",
Value: "no",
Usage: "Should save to database (yes/no)",
},
}
- 现在,我们必须在应用程序上定义操作。操作是定义给定标志上逻辑动态的控制结构。这些选项如下:
-
--save=no,跳过将参数保存到数据库 -
--save=yes(或)没有标志,将保存参数到数据库:
app.Version = "1.0"
// define action
app.Action = func(c *cli.Context) error {
var args []string
if c.NArg() > 0 {
// Fetch arguments in a array
args = c.Args()
personName := args[0]
marks := args[1:len(args)]
log.Println("Person: ", personName)
log.Println("marks", marks)
}
// check the flag value
if c.String("save") == "no" {
log.Println("Skipping saving to the database")
} else {
// Add database logic here
log.Println("Saving to the database", args)
}
return nil
}
所有的前述语句都将进入主函数。
- 我们必须使用
app.Run运行应用程序,以便使工具运行并收集参数:
package main
import (
"github.com/urfave/cli"
"log"
"os"
)
func main() {
// Here goes app, flags, actions
app.Run(os.Args)
}
c.Args存储与命令一起提供的所有参数。由于我们知道参数的顺序,我们推断第一个参数是名称,其余的值是分数。我们正在检查一个名为save的标志,以确定是否将这些详细信息保存到数据库中(为了简单起见,这里没有数据库逻辑)。app.Version设置工具的版本。其他所有内容都与之前的cli入门示例相同。
首先,让我们构建程序:
go build $GOPATH/src/github.com/git-user/chapter8/cli/example2
现在,从example2目录中,通过传递标志及其参数来运行构建的工具:
./main --save=yes Albert 89 85 97
2017/09/02 21:02:02 Person: Albert
2017/09/02 21:02:02 marks [89 85 97]
2017/09/02 21:02:02 Saving to the database [Albert 89 85 97]
如果我们不提供标志,默认是save=no:
./main Albert 89 85 97
2017/09/02 21:02:59 Person: Albert
2017/09/02 21:02:59 marks [89 85 97]
2017/09/02 21:02:59 Skipping saving to the database
到目前为止,一切看起来都很不错。但我们如何使命令行工具在用户需要时显示帮助文本呢?cli库为给定的应用程序创建了一个很好的帮助部分。如果您输入以下任何命令,将自动生成一些帮助文本:
-
./storeMarks -h -
./storeMarks -help -
./storeMarks --help -
./storeMarks help
出现了一个很好的帮助部分,如下面的代码所示,它显示了版本细节和可用的标志(全局选项)、命令和参数:
NAME:
storeMarks - A new cli application
USAGE:
storeMarks [global options] command [command options] [arguments...]
VERSION:
1.0
COMMANDS:
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--save value Should save to database (yes/no) (default: "no")
--help, -h show help
--version, -v print the version
cli包简化了客户端应用程序的开发。它比内部的flag包更快、更直观。
命令行工具是在构建程序后生成的二进制文件。它们需要带选项运行。它们就像任何系统程序一样,不再与 Go 编译器相关。确保你为想要运行它们的目标架构构建它们。
我们可以使用flag包或cli来构建 REST API 客户端。然而,对于高级应用程序,我们可能需要一个功能丰富的强大库。在下一节中,我们将探讨这样一个名为cobra的库,它用于创建命令行工具。
Cobra,一个高级 CLI 库
与cli一样,cobra是一个用于编写客户端二进制的包,但采用不同的方法。在 cobra 中,我们必须创建单独的命令并在我们的主应用程序中使用它们。我们可以使用 dep 安装cobra。让我们创建我们的 cobra 项目仓库:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/cobraCLI
现在,让我们在项目中创建一个名为cmd的另一个目录来定义命令。在 cobra 应用程序中,将有一个根命令。这可以有多个子命令。我们可以实现与标志包相同的示例。使用 cobra 从命令行输入姓名和年龄。
让我们定义一个根命令:
var rootCmd = &cobra.Command{
Use: "details",
Short: "This project takes student information",
Long: `A long string about description`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
name := cmd.PersistentFlags().Lookup("name").Value
age := cmd.PersistentFlags().Lookup("age").Value
log.Printf("Hello %s (%s years), Welcome to the command line
world", name, age)
},
}
这创建了一个名为“details”的命令。它有几个属性,如Use、Short、Long、Args和Run。请参阅以下表格以了解它们的精确含义:
| 参数 | 含义 |
|---|---|
Use |
命令名称 |
Short |
短描述 |
Long |
长描述 |
Args |
预期参数数量 |
Run |
收集后处理输入 |
在Run命令中,我们期望两个参数:name和age。然而,为了收集它们,我们必须定义它们。在哪里定义它们?Cobra 要求开发者在一个特殊函数Execute中定义它们:
// Execute is Cobra logic start point
func Execute() {
rootCmd.PersistentFlags().StringP("name", "n", "stranger", "Name of
the student")
rootCmd.PersistentFlags().IntP("age", "a", 25, "Age of the student")
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
我们需要使用之前定义的根命令来附加标志。PersistentFlags有各种类型,可以用来收集标志。现在,创建主程序并导入此命令:
touch $GOPATH/src/github.com/git-user/chapter8/cobraCLI/main.go
现在,在这个文件中,你可以导入命令并调用Execute函数:
package main
import "github.com/git-user/chapter8/cobraExample/cmd"
func main() {
cmd.Execute()
}
就这样。我们有一个客户端应用程序,可以用来收集学生的姓名和年龄。当我们构建它时,它会生成一个二进制文件:
go build $GOPATH/src/github.com/git-user/chapter8/cobraCLI
现在,我们可以作为客户端工具运行该二进制文件:
./cobraExample details -n Albert -a 23
它将日志打印到控制台:
Hello Albert (23 years), Welcome to the command line world
我们也可以以不同的顺序传递标志:
./cobraExample details --age=23 --name=Albert
我们也可以在这个命令之上创建许多子命令并做更多的事情。这只是一个基本示例。在下一节中,我们将看到一个高级示例,你将使用 cobra 实现相同的操作。
在本章的后面部分,我们将讨论在 Go 中创建 REST 客户端。在那之前,你应该知道如何从 Go 程序中发送 HTTP 请求。尽管这可以通过 Go 的内置net/http包实现,但我们需要一个更直观的包。在下一节中,我们将查看grequests,这是一个类似于 Python 的Requests的用于发送 HTTP 请求的包。
grequests,Go 的 REST API 包
在 Python 上工作的开发者都知道Requests库。这是一个干净、简洁的库,它不包括在 Python 的标准库中。
Go 的grequests包受Requests的启发。它提供了一套简单的函数,通过这些函数我们可以从 Go 代码中发出GET、POST、PUT和DELETE等 API 请求。使用grequests允许我们封装内置的 HTTP 请求和响应。
使用dep工具创建项目目录并安装grequests:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/requestExample/
touch $GOPATH/src/github.com/git-user/chapter8/requestExample/
basicRequest.go
要为 Go 安装grequests包,请运行以下dep命令:
dep init
dep ensure --add github.com/levigross/grequests
现在,让我们编写一个基本程序,说明如何使用grequests库向 REST API 发出GET请求。它使用了grequests库中的Get方法:
package main
import (
"github.com/levigross/grequests"
"log"
)
func main() {
resp, err := grequests.Get("http://httpbin.org/get", nil)
// You can modify the request by passing an optional
// RequestOptions struct
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
log.Println(resp.String())
}
grequests包包含执行所有 REST 操作的方法。前面的程序使用了包中的Get函数。它接受两个函数参数。第一个参数是 API 的 URL,而第二个参数是请求参数对象。由于我们没有传递任何请求参数,这里的第二个参数是nil。resp是从请求返回的,它有一个名为String()的函数,该函数返回响应体:
go run requestExample/basicRequest.go
输出是httpbin返回的 JSON 响应:
{
"args": {},
"headers": {
"Accept-Encoding": "gzip",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "GRequests/0.10"
},
"origin": "116.75.82.9",
"url": "http://httpbin.org/get"
}
在这里,我们了解了如何使用grequests。然而,为了利用其功能,我们应该了解其 API(函数)。
在下一节中,我们将更详细地了解grequests库。我们将学习如何配置请求参数和响应属性。
grequests API 概述
在grequests中要探索的最重要的事情不是 HTTP 函数,而是RequestOptions结构体。这是一个非常大的结构体,包含有关正在使用的 API 方法类型的各种信息。如果 REST 方法是GET,则RequestOptions包含Params属性。如果方法是POST,则结构体将有一个Data属性。每次我们向 URL 端点发出请求时,我们都会得到一个响应。让我们看看响应的结构。根据官方文档,响应看起来是这样的:
type Response struct {
Ok bool
Error error
RawResponse *http.Response
StatusCode int
Header http.Header
}
响应的Ok属性包含有关请求是否成功的信息。如果出现问题,Error属性中将会找到错误。RawResponse是 Go HTTP 响应,它将被grequests响应的其他函数使用。StatusCode和Header分别存储响应的状态码和头部细节。Response中有一些有用的函数
-
Response.JSON() -
Response.XML() -
Response.String() -
Response.Bytes()
前面的函数可以将响应数据填充到一个通用映射中。让我们看看一个例子,即requestExample/jsonRequest.go:
package main
import (
"github.com/levigross/grequests"
"log"
)
func main() {
resp, err := grequests.Get("http://httpbin.org/get", nil)
// You can modify the request by passing an optional
// RequestOptions struct
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
var returnData map[string]interface{}
resp.JSON(&returnData)
log.Println(returnData)
}
在这里,我们声明了一个接口来保存 JSON 值。然后,我们使用resp.JSON函数填充returnData(空接口)。这个程序打印的是映射而不是纯 JSON。
您可以通过查看项目文档来了解所有可用的选项:godoc.org/github.com/levigross/grequests。
在下一节中,我们将了解 GitHub API 版本 3 的工作原理,并利用我们对命令行参数的了解来开发一个从 GitHub API 获取有用信息的客户端。
熟悉 GitHub REST API
GitHub 提供了一个编写良好、易于消费的 REST API。它通过良好的 API 向客户端开放了有关用户、仓库、仓库统计等信息。当前稳定版本是 v3。API 文档可以在developer.github.com/v3/找到。API 的根端点是https://api.github.com。
所有 GitHub API 路由都将附加到这个根端点上。让我们学习如何进行一些查询并获取数据。对于未经身份验证的客户端,速率限制是每小时 60 次,而对于传递client_id(我们可以从他们的 GitHub 账户控制台获取)的客户端,则是每小时 5,000 次。
如果您有 GitHub 账户(如果没有,强烈建议您创建一个),您可以在“您的资料”|“个人访问令牌”部分找到访问令牌,或者通过访问github.com/settings/tokens来找到。使用“生成新令牌”按钮创建一个新的访问令牌。您将被要求为不同的资源提供各种权限。勾选repo和gist选项。将为您生成一个新的个人令牌字符串。将其保存在安全且私密的地方。生成的令牌现在可以用来访问 GitHub API(以更长的速率限制)。
下一步是将访问令牌导出为环境变量,GITHUB_TOKEN。您可以使用export命令来设置它,如下所示:
export GITHUB_TOKEN=YOUR_GITHUB_ACCESS_TOKEN
YOUR_GITHUB_ACCESS_TOKEN是从 GitHub 账户生成并保存的。您还可以将前面的导出命令添加到您的~/.bashrc文件中,以便在下次 shell 启动时保持持久性。
让我们编写一个程序来获取一个用户的全部仓库:
- 创建一个新的目录和程序,如下所示:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/githubAPI touch $GOPATH/src/github.com/git-user/chapter8/githubAPI/main.go
我们应该使用这个逻辑从 Go 程序中发出GET请求。此程序从 GitHub API 获取仓库信息。
- 创建一个结构体来保存仓库的信息。让我们称它为
Repo。我们还将定义一个环境变量来获取GITHUB_TOKEN。现在,我们可以从这个令牌创建请求选项。为了使 GitHub 验证GET请求的来源,我们应该将一个名为Auth的参数传递给RequestOptions结构体。这可以在以下代码块中看到:
var GITHUB_TOKEN = os.Getenv("GITHUB_TOKEN")
var requestOptions = &grequests.RequestOptions{Auth: []string{GITHUB_TOKEN, "x-oauth-basic"}}
type Repo struct {
ID int `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Forks int `json:"forks"`
Private bool `json:"private"`
}
- 现在,定义一个函数处理程序,它接受一个 URL 作为输入并返回 GitHub API 的
Response。它会对给定的 URL 位置执行一个简单的 GET 请求。我们使用grequests包来对 GitHub 进行 API 调用:
func getStats(url string) *grequests.Response{
resp, err := grequests.Get(url, requestOptions)
// You can modify the request by passing an optional RequestOptions struct
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
return resp
}
- 现在,定义主块,它将 GitHub 链接传递给前面的函数,并将响应存储在
Repo结构体中:
package main
import (
"github.com/levigross/grequests"
"log"
"os"
)
func main() {
var repos []Repo
var repoUrl = "https://api.github.com/users/torvalds/repos"
resp := getStats(repoUrl)
resp.JSON(&repos)
log.Println(repos)
}
Response 包含多个仓库,因此我们必须将响应 JSON 加载到 Repo 的数组中。
- 如果你运行前面的程序,你将收到以下输出:
go run $GOPATH/src/github.com/git-user/chapter8/githubAPI/main.go 2019/07/03 17:59:41 [{79171906 libdc-for-dirk torvalds/libdc-for-dirk 10 false} {2325298 linux torvalds/linux 18274 false} {78665021 subsurface-for-dirk torvalds/subsurface-for-dirk 16 false} {86106493 test-tlb torvalds/test-tlb 25 false}]
前面的程序说明了我们如何查询 GitHub API 并将数据加载到我们自定义的 Repo 结构体中。返回的 JSON 包含许多字段,但为了简单起见,我们只选择了一些重要的字段。
到目前为止,我们已经看到了如何向 GitHub API 发送 HTTP 请求。在下一节中,我们将创建一个客户端,根据用户命令向 GitHub API 发送 HTTP 请求。
创建一个 CLI 工具作为 GitHub REST API 的 API 客户端
在查看这个示例之后,我们将能够轻松地从我们的 Go 客户端访问 GitHub API。我们可以结合本章中我们学到的两种技术来创建一个消耗 GitHub API 的命令行工具。让我们创建一个新的命令行应用程序,执行以下操作:
-
提供通过用户名获取仓库详情的选项
-
使用给定的描述将文件上传到 GitHub Gist(文本片段)
-
使用个人访问令牌进行身份验证
我们将使用 cli 包和 grequests 来构建这个工具。你还可以在 cobra 中重新实现相同的示例。
Gist 是 GitHub 提供的存储文本内容的小片段。有关更多详情,请访问 gist.github.com。
在本章的目录中创建一个名为 gitTool 的目录,并将 main 文件添加到其中,如下所示:
mkdir -p $GOPATH/src/github.com/git-user/chapter8/gitTool
touch $GOPATH/src/github.com/git-user/chapter8/gitTool/main.go
首先,让我们定义主块,包含一些 cli 命令,这样我们就可以输入用于仓库详细信息和大纲上传操作的命令。在这里,我们使用 cli 包中的 app 创建 Commands。我们在这里定义了两个命令:
func main() {
app := cli.NewApp()
// define command for our client
app.Commands = []cli.Command{
{
Name: "fetch",
Aliases: []string{"f"},
Usage: "Fetch the repo details with user. [Usage]: githubAPI
fetch user",
Action: func(c *cli.Context) error {
if c.NArg() > 0 {
// Github API Logic
var repos []Repo
user := c.Args()[0]
var repoUrl = fmt.Sprintf("https://api.github.com/
users/%s/repos", user)
resp := getStats(repoUrl)
resp.JSON(&repos)
log.Println(repos)
} else {
log.Println("Please give a username. See -h to
see help")
}
return nil
},
},
{
Name: "create",
Aliases: []string{"c"},
Usage: "Creates a gist from the given text.
[Usage]: githubAPI name 'description' sample.txt",
Action: func(c *cli.Context) error {
if c.NArg() > 1 {
// Github API Logic
args := c.Args()
var postUrl = "https://api.github.com/gists"
resp := createGist(postUrl, args)
log.Println(resp.String())
} else {
log.Println("Please give sufficient arguments.
See -h to see help")
}
return nil
},
},
}
app.Version = "1.0"
app.Run(os.Args)
}
如您所见,getStats 和 createGist 是用于实际 API 调用的函数。我们将在下面定义这些函数,但在定义之前,我们应该准备一些数据结构来保存有关以下信息的数据:
-
仓库
-
要作为大纲上传的文件
-
GitHub 上的 Gist(文件列表)
现在,我们需要创建三个结构体来保存前面的信息,如下所示:
// Struct for holding response of repositories fetch API
type Repo struct {
ID int `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Forks int `json:"forks"`
Private bool `json:"private"`
}
// Structs for modelling JSON body in create Gist
type File struct {
Content string `json:"content"`
}
type Gist struct {
Description string `json:"description"`
Public bool `json:"public"`
Files map[string]File `json:"files"`
}
现在,创建一个请求选项,构建一个头并使用环境变量中的 GitHub 令牌:
var GITHUB_TOKEN = os.Getenv("GITHUB_TOKEN")
var requestOptions = &grequests.RequestOptions{Auth: []string{GITHUB_TOKEN, "x-oauth-basic"}}
现在,是时候编写 getStats 和 createGist 函数了。让我们先编写 getStats:
// Fetches the repos for the given Github users
func getStats(url string) *grequests.Response {
resp, err := grequests.Get(url, requestOptions)
// you can modify the request by passing an optional
// RequestOptions struct
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
return resp
}
这个函数发送一个 GET 请求并返回响应对象。代码很简单,是一个通用的 GET 请求。
现在,让我们看看 createGist。在这里,我们必须做更多的事情。一个大纲包含多个文件。因此,在我们的程序中,我们需要做以下事情:
-
从命令行参数中获取文件列表。
-
读取文件内容并将其存储在以文件名为键、内容为值的文件映射中。
-
将这个映射转换为 JSON。
-
使用前面的 JSON 作为正文向 Gist API 发送
POST请求。
我们必须向 Gist API 发出POST请求。createGist函数接受一个 URL 字符串和其他参数。该函数应返回POST请求的响应:
// Reads the files provided and creates Gist on github
func createGist(url string, args []string) *grequests.Response {
// get first two arguments
description := args[0]
// remaining arguments are file names with path
var fileContents = make(map[string]File)
for i := 1; i < len(args); i++ {
dat, err := ioutil.ReadFile(args[i])
if err != nil {
log.Println("Please check the filenames. Absolute path
(or) same directory are allowed")
return nil
}
var file File
file.Content = string(dat)
fileContents[args[i]] = file
}
var gist = Gist{Description: description, Public: true,
Files: fileContents}
var postBody, _ = json.Marshal(gist)
var requestOptions_copy = requestOptions
// Add data to JSON field
requestOptions_copy.JSON = string(postBody)
// make a Post request to Github
resp, err := grequests.Post(url, requestOptions_copy)
if err != nil {
log.Println("Create request failed for Github API")
}
return resp
}
我们使用grequests.Post将文件传递给 GitHub 的 Gist API。在成功创建并包含在响应体中的 gist 详细信息时,它返回Status: 201 Created。
现在,让我们构建命令行工具:
go build $GOPATH/src/github.com/git-user/chapter8/gitTool
这将在同一目录中创建一个二进制文件。如果我们输入./gitTool -h,它将显示以下内容:
NAME:
gitTool - A new cli application
USAGE:
gitTool [global options] command [command options] [arguments...]
VERSION:
1.0
COMMANDS:
fetch, f Fetch the repo details with user. [Usage]: goTool fetch user
create, c Creates a gist from the given text. [Usage]: goTool name
'description' sample.txt
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
--version, -v print the version
如果你查看帮助命令,你会看到两个命令,fetch和create。fetch命令检索给定用户的仓库,而create命令使用提供的文件创建一个gist。让我们在程序的同一目录中创建两个示例文件来测试create命令:
echo 'I am sample1 file text' > githubAPI/sample1.txt
echo 'I am sample2 file text' > githubAPI/sample2.txt
使用第一个命令运行工具:
./gitTool f torvalds
这返回属于伟大的林纳斯·托瓦兹的所有仓库。日志消息打印出填充的结构体:
[{79171906 libdc-for-dirk torvalds/libdc-for-dirk 10 false} {2325298 linux torvalds/linux 18310 false} {78665021 subsurface-for-dirk torvalds/subsurface-for-dirk 16 false} {86106493 test-tlb torvalds/test-tlb 25 false}]
现在,让我们检查第二个命令。这个命令使用给定的描述和一组文件作为参数创建gist:
./gitTool c "I am doing well" sample1.txt sample2.txt
它返回有关创建的 gist 的 JSON 详细信息。这是一个非常长的 JSON,所以这里省略了输出。现在,如果你打开你的gist.github.com账户,你会看到创建的gist:

记住,GitHub 的gistsAPI 期望以下格式的 JSON 数据作为正文:
{
"description": "the description for this gist",
"public": true,
"files": {
"file1.txt": {
"content": "String file contents"
}
}
}
对于任何 Go 程序快速读取和理解,请遵循main函数,然后进入其他函数。通过这样做,我们可以从整个应用程序中阅读代码。
作为练习,在cobra中根据前面的要求构建一个命令行工具。
使用 Redis 缓存 API 数据
Redis是一个内存数据库,可以存储键/值对。它最适合存储大量读密集型数据的使用场景。例如,BBC 和《卫报》等新闻机构在他们的仪表板上显示最新的新闻文章。他们的流量很高,如果需要从数据库中检索文档,他们必须始终维护一个巨大的数据库集群。
由于给定的新闻文章集合在数小时内不会改变,一个机构可以维护一个文章缓存。当第一个客户访问页面时,文章的副本从数据库中提取出来,放置在 Redis 缓存中,然后发送到浏览器。然后,对于另一个客户,新闻机构服务器从 Redis 读取内容而不是直接击中数据库。由于 Redis 运行在主内存中,延迟最小。因此,客户看到页面加载更快。网络上的基准测试可以告诉我们一个网站如何有效地优化其内容。
如果数据在 Redis 中不再相关怎么办?(例如,机构更新了其头条新闻。)Redis 提供了一种使存储在其中的keys:values过期的方法。我们可以运行一个调度器,在过期时间过后更新 Redis。
同样,我们可以缓存给定请求(GET)的第三方 API 响应。我们需要这样做,因为像 GitHub 这样的第三方系统有速率限制(建议我们保守)。对于给定的 GET URL,我们可以将 URL 存储为键,将 Response 存储为值。当在下一个时间(在键过期之前)收到相同的请求时,只需从 Redis 中拉取响应,而不是击中 GitHub 服务器。
此方法也适用于我们的 REST API。最频繁且未更改的 REST API 响应可以被缓存,以减少对主数据库的负载。
对于 Go 语言,有一个可以与 Redis 通信的出色库。它可以在 github.com/go-redis/redis 找到。这是一个广为人知的库,许多开发者都推荐使用。以下图表很好地说明了这个概念:

这里有一个需要注意的地方是 API 的过期。由于其实时性,实时 API 不应该被缓存。缓存将性能优化带到了我们的餐桌上,同时也带来了一些关于数据同步的头痛问题。
在缓存时请小心。始终实现一个健壮的缓存失效方法。全球有许多更好的实践。请查阅它们,以了解各种架构。
我们将在下一章更详细地讨论 Redis,其中我们将讨论可以用来开发异步 API 的策略。
摘要
我们从理解客户端软件开始本章:软件客户端是如何工作的,以及我们如何创建几个。我们看到了编写命令行应用程序的基础。cli 是一个第三方包,它允许我们创建美观的命令行应用程序。安装后,我们学习了如何通过该工具收集命令行参数。我们还探索了 CLI 应用程序中的命令和标志。接下来,我们研究了 grequest,这是一个类似于 Python 请求的包,用于从 Go 代码中发送 API 请求。我们学习了如何从客户端程序中制作 GET、POST 和其他请求。我们还查看了一个名为 cobra 的新包,用于创建命令/子命令。
然后,我们探索了 GitHub API 以及如何获取存储库的详细信息。在了解这两个概念之后,我们开发了一个客户端,该客户端列出给定用户的存储库,并创建一个 gist(GitHub 上的文本片段集合)。最后,我们介绍了 Redis 架构以及缓存如何帮助我们处理速率限制的 API。
在下一章中,我们将讨论在队列和缓存的帮助下构建异步 API 的策略。
第九章:异步 API 设计
在本章中,我们将讨论如何为客户设计异步 API。我们将探讨如队列任务和发布/订阅模式等策略。同步请求会在服务器上等待直到返回结果。另一方面,异步(asynchronous)请求会立即收到一个包含最终结果信息的响应。现实世界由许多同步和异步事件组成。
异步事件在浏览器中非常流行。异步 API 模仿了现代浏览器中事件循环的行为。在本章中,我们将查看请求类型之间的差异。我们还将用 Go 编写一些客户端,以消费异步 API。
在本章中,我们将涵盖以下主题:
-
理解同步/异步 API 请求
-
服务的扇入/扇出
-
使用队列延迟 API 作业
-
长运行任务设计
-
API 的缓存策略
-
事件驱动 API
技术要求
您需要安装以下软件来运行本章的代码示例:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep: Go >= 0.5.3 的依赖管理工具
-
Docker 版本 >= 18.09.2
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter9下载本章的代码。克隆代码并使用chapter9目录中的代码示例。
理解同步/异步 API 请求
同步请求是一个会阻塞服务器直到返回响应的 HTTP 请求。大多数网络服务都是以这种方式运行的。如今,随着分布式系统和松耦合的出现,API 请求也可以是异步的。换句话说,异步请求返回的信息可以用来获取进程的信息。这些服务器上的异步请求与服务器如何并发地为多个客户端执行作业密切相关。让我们看看同步请求的样子:

在此类请求中,Web 服务器执行所有操作并返回一个即时响应给Web 客户端/移动客户端。这种方法的缺点是,如果服务器渲染结果花费太多时间,客户端会被阻塞在服务器的操作上。
异步请求会立即返回一个响应,但不包含结果。它为查找请求操作的状态发出一个票据。客户端可以使用该票据(不同的响应)来检查操作的状态和最终结果:

如前图所示,客户端正在向服务器发送请求,服务器向客户端返回响应。这个响应不是客户端可以立即消费的东西。长时间运行的任务/作业可以通过服务器异步执行。然后,客户端可以使用收到的响应来了解作业的状态。一旦作业完成,服务器可以通知客户端,或者客户端可以通过查看状态来轮询结果。到目前为止,我们只构建了一个同步 API。本章将详细讨论其实现。
在下一节中,我们将讨论 API 如何分化成多个或合并成一个调用。这些技术分别称为输出和输入。
服务输入/输出
让我们以一个电子商务网站与第三方支付网关集成的真实世界例子为例。在这里,网站使用支付网关的 API 弹出支付界面并输入安全凭证。同时,网站可能调用另一个名为分析(analytics)的 API 来记录支付尝试。将单个请求分解成多个请求的过程称为输出。在现实世界中,单个客户端请求可能涉及许多输出服务。
另一个例子是MapReduce。Map 是一个输入操作,而 Reduce 是一个输出操作。服务器可以将一条信息扇出到下一组服务(API),并忽略结果,或者等待从那些服务器返回的所有响应。如图所示,服务器正在将一个进入的请求多路复用到两个出去的请求:

这是一个简单的输出过程。
输入是一种操作,其中两个或多个进入的请求汇聚成一个单一请求。这种场景是 API 如何从多个后端服务聚合结果,并即时返回给客户端。例如,考虑一个酒店价格聚合器或航班票务聚合器,它从各种数据提供商获取关于多个酒店或航班的请求信息,并将它们显示出来。以下图显示了输入操作如何结合多个请求,并准备一个最终响应,该响应被客户端消费:

客户端也可以是一个服务器,它为其他客户端提供服务。如图所示,左侧的手动服务器正在收集来自酒店 A、酒店 B和航空公司 A的响应,并为不同的客户端准备另一个响应。因此,输入和输出操作并不总是完全独立的。大多数情况下,它将是一个混合场景,其中输入和输出操作相互匹配。
请记住,对下一组服务器的输出操作也可以是异步的。对于输入请求可能不成立。输入操作有时被称为 API 调用。
在本节中,我们看到了扇入和扇出的工作方式。要使用这些技术,我们需要了解如何实现异步服务(API)。在下一节中,我们将尝试使用称为作业排队的机制来实现此类服务。
使用排队延迟 API 作业
在同步 API 中,阻塞代码在准备发送给客户端的响应中起着至关重要的作用。然而,在异步设计中,非阻塞是关键。队列和工作者可以帮助实现非阻塞代码。服务器可以并行运行多个工作者,他们可以耗尽队列的内容并对其进行处理。每当客户端通过异步 API 请求操作时,服务器可以将该请求放入作业队列,所有工作者都可以在他们轮到时选择任务。
这种方法可以将 API 服务器卸载,并专注于其业务逻辑,而不是在并行/独立任务(如发送电子邮件、联系第三方服务等)上阻塞。
排队的一些用例如下:
-
压缩图像并通过电子邮件发送最终结果
-
自动背压(将服务器负载限制在可预测的量)
为了详细解释这个概念,让我们制定一个示例并尝试实现它。
让我们开发一个可以执行两种不同类型作业的异步 API 服务器:
-
将给定信息记录到数据库
-
发送电子邮件
条件是它不应该阻塞其他操作。API 应该返回一个 Job ID 票据给客户端,客户端可以使用该信息来获取作业的运行信息。
在开始实施之前,我们应该了解一些启用排队到我们服务的基本知识。我们可以从头开始实现队列/工作者,但有许多优秀的开源排队系统可供选择,例如 RabbitMQ 或 ZeroMQ。
作为实施上述问题的一部分,我们将使用 RabbitMQ,因为它很受欢迎,Go 绑定也很成熟。
强大的消息队列 RabbitMQ
RabbitMQ 实现了一种名为 高级消息队列协议 (AMQP)的消息协议。它使用它来支持工作者队列。它还支持许多其他数据交换模式,例如以下内容:
-
发布/订阅
-
主题/订阅
-
路由消息
-
远程过程调用 (RPC)
在本节中,我们将重点关注 RabbitMQ 的消息功能。我们可以使用 Docker 在我们的系统上安装 RabbitMQ,如下所示:
docker run --hostname rabbitmq-host --name rabbitmq-server -p 5672:5672 -p 15672:15672 rabbitmq:3
它使用给定的主机名 rabbitmq-host 和容器名 rabbitmq-server 启动 RabbitMQ 代理。我们使用 rabbitmq:3 作为我们服务的基础镜像。Docker 从 Docker Hub 拉取镜像并创建一个容器。您将看到类似以下内容的输出:
Starting broker...
2019-08-10 08:19:20.371 [info] <0.223.0>
node : rabbit@rabbitmq-host
home dir : /var/lib/rabbitmq
config file(s) : /etc/rabbitmq/rabbitmq.conf
cookie hash : tUgaG2zTrSrf/yZv3KRV5Q==
log(s) : <stdout>
database dir : /var/lib/rabbitmq/mnesia/rabbit@rabbitmq-host
....
2019-08-10 08:19:20.873 [info] <0.497.0> started TCP listener on [::]:5672
RabbitMQ 使用默认端口 5672 进行操作。您可以使用 Docker 命令的初始设置来更改此端口。
前面的 RabbitMQ 代理在前台运行。然而,在生产中,您必须将其在后台运行。这意味着您需要将-d标志传递给 Docker 命令以在后台运行,如下所示:
docker run -d --hostname rabbitmq-host --name rabbitmq-server -p 5672:5672 -p 15672:15672 rabbitmq:3
默认情况下,如果我们启动容器时(docker run ...)不传递用户凭证,将为代理创建一个默认的<guest:guest>用户的凭证。您可以在任何时候重置它们或在启动容器时传递它们。您可以在hub.docker.com/_/rabbitmq了解更多信息。
使用 Go 与 RabbitMQ 通信
现在,我们有一个消息代理(RabbitMQ)。在构建异步 API 之前,我们应该了解 Go 程序如何与消息代理通信并发送/接收消息。在这个过程中,我们将创建用于生产和消费的客户端。
首先,我们必须创建一个connection以连接到代理。如果连接成功,需要从连接中创建一个Channel。它具有在消息代理上执行操作的 API。然后,我们可以定义一个消息发送到的队列。最后,我们向队列发布一条消息。
我们使用一个名为amqp的开源 Go 包来与 RabbitMQ 一起工作。
让我们创建本章的第一个程序:
- 为消息发送者创建一个如下所示的目录:
mkdir -p $GOPATH/src/github.com/git-user/chapter9/basicSender
- 使用
dep工具安装amqp包:
dep ensure --add "github.com/streadway/amqp"
这将在目录中创建Gopkg.toml和Gopkg.lock文件。
现在,我们准备就绪。我们将查看一个示例,该示例在 RabbitMQ 中创建一个队列并向其发送消息:
- 首先,让我们在
main.go内部导入必要的包/库。这些是log和amqp:
package main
import (
"log"
"github.com/streadway/amqp"
)
- 现在,我们需要一个处理程序来处理每一步将生成的错误。Go 的错误处理可能很混乱,这会妨碍可读性。为了有一个干净的代码结构,我们需要在单个位置处理错误:
func handleError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
这个函数接收一个错误和一个消息,并将信息记录到STDOUT。
- 现在,让我们编写发送和接收消息的逻辑。在程序的主块中,创建一个连接和通道。然后,使用包含用户凭证的连接字符串连接到 RabbitMQ。一旦连接成功,获取
Channel对象以推送消息。代码如下所示:
func main() {
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
handleError(err, "Dialing failed to RabbitMQ broker")
defer conn.Close()
channel, err := conn.Channel()
handleError(err, "Fetching channel failed")
defer channel.Close()
}
这是连接字符串:
amqp://guest:guest@localhost:5672/
它由protocol ://user:password@host:port的详细信息组成,其中host、port、user和password是 RabbitMQ 服务器的凭证。
在生产环境中,你绝不应该使用 RabbitMQ 的默认凭证。请为所有敏感信息设置强密码,包括 – RabbitMQ。
- 声明一个名为
test的队列以发布消息:
testQueue, err := channel.QueueDeclare(
"test", // Name of the queue
false, // Message is persisted or not
false, // Delete message when unused
false, // Exclusive
false, // No Waiting time
nil, // Extra args
)
handleError(err, "Queue creation failed")
- 现在,我们有一个队列。让我们准备一个
amqp消息(RabbitMQ 消息)以将其推入队列。假设消息体是服务器时间的日志:
serverTime := time.Now()
message := amqp.Publishing{
ContentType: "text/plain",
Body: []byte(serverTime.String()),
}
- 将前面的消息发布到预定义的队列,即
testQueue:
err = channel.Publish(
"", // exchange
testQueue.Name, // routing key(Queue)
false, // mandatory
false, // immediate
message,
)
handleError(err, "Failed to publish a message")
log.Println("Successfully published a message to the queue")
Publish方法将给定消息发布到 RabbitMQ 队列。
我们已经完成了发送器的创建。现在,如果我们运行这个程序,它会立即推送一个消息。
现在,让我们编写一个接收器(工作程序)来消费这些消息:
- 逻辑是定义一个
Consumer并接收消息。工作程序的代码基本上与之前相同:
mkdir -p $GOPATH/src/github.com/git-user/chapter9/basicReceiver touch $GOPATH/src/github.com/git-user/chapter9/basicReceiver/
main.go
- 使用连接字符串连接到 RabbitMQ,获取
Channel,并为testQueue创建一个表示:
package main
import (
"log"
"github.com/streadway/amqp"
)
func handleError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
func main() {
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
handleError(err, "Dialing failed to RabbitMQ broker")
defer conn.Close()
channel, err := conn.Channel()
handleError(err, "Fetching channel failed")
defer channel.Close()
testQueue, err := channel.QueueDeclare(
"test", // Name of the queue
false, // Message is persisted or not
false, // Delete message when unused
false, // Exclusive
false, // No Waiting time
nil, // Extra args
)
handleError(err, "Queue creation failed")
}
- 我们需要添加一些额外的功能来消费。在主部分的
handleError之后,我们需要定义要消费的队列及其属性:
messages, err := channel.Consume(
testQueue.Name, // queue
"", // consumer
true, // auto-acknowledge
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
handleError(err, "Failed to register a consumer")
messages是一个可消费的,可以读取推送到testQueue的消息。让我们看看我们如何读取它:
go func() {
for message := range messages {
log.Printf("Received a message from the queue: %s",
message.Body)
}
}()
这运行了一个 goroutine,它启动了一个函数,该函数运行一个无限循环以收集消息并处理它们。goroutine 是由 Go 的运行时引擎管理的轻量级线程。我们可以从一个函数中启动 goroutine。在这里,我们只是在我们的 goroutine 中将消息记录到STDOUT。如果我们不阻塞主程序,整个过程将结束,快速杀死 goroutine。
- 让我们创建一个通道并从中读取以阻塞主程序:
log.Println("Worker has started")
wait := make(chan bool)
<-wait
这样,我们就有一个工作程序。
- 让我们运行这两个程序来看看它们是如何工作的。首先,运行工作程序。如果不存在,它会创建一个名为"text"的队列。然后,在终端上运行发送程序以向队列发送消息:
go run $GOPATH/src/github.com/git-user/chapter9/basicReceiver/
main.go
2019/08/10 16:02:29 Worker has started
- 在另一个终端窗口(shell)中运行发送程序,将服务器时间日志推送到队列:
go run $GOPATH/src/github.com/git-user/chapter9/basicSender/
main.go
2019/08/10 16:03:15 Successfully published a message to the queue
- 如果你检查第一个终端,你会看到以下消息:
2019/08/10 16:03:15 Received a message from the queue: 2019-08-10 16:03:15.367476 +0200 CEST m=+0.014980319
这意味着工作程序能够成功从队列中检索消息。这个功能可以被 API 服务器利用,将长运行作业放入消息队列,并让专门的工人处理它们。
在掌握队列的基础知识和它如何帮助我们构建异步 API 之后,我们应该实现一个现实世界的问题。在下一节中,我们将定义问题陈述并尝试设计一个同时执行各种功能的长运行任务。
长运行任务设计
到目前为止,我们已经了解了队列的基础知识以及如何延迟作业。现在,我们将设计一个解决方案来解决有关异步 API 的问题。问题是,我们想要构建一个可以处理以下场景请求的系统:
-
服务器应该将信息作为一个操作保存到数据库中。
-
它应该发送电子邮件到指定的电子邮件地址。
-
它应该执行一个长运行的任务并将结果 POST 到一个回调。这被称为 web 钩子。
假设这三个操作是异步和长运行的。我们需要一个机制来促进具有以下特征的长运行过程:
-
客户端可以触发一个 API 并返回一个作业 ID。
-
作业以相应的消息格式推送到队列中。
-
工作者选择工作并开始执行。
-
最后,工作者将结果保存在各种端点上,并将状态发送到数据库。
以下图详细显示了前面的要求:

以下图显示了几个引人入胜的实体:
-
API 服务器
-
数据库服务器
-
队列
-
工作者
API 服务器正在接受来自客户端的异步请求,并将这些工作推送到消息队列。然后,工作者选择这些工作并对它们执行一些操作。
工作者 A 将消息中的信息保存到数据库中。工作者 B 选择一个工作。在处理消息后,它将一些信息发布到作为请求一部分接收的回调。工作者 C 的任务是发送电子邮件。
为了简化,我们将模拟最终操作(数据库插入、发送电子邮件和回调)。我们这样做是为了专注于异步 API 设计而不是具体操作。
为了设计这个流程,我们需要重用相同的消息并使其适合所有用例。JSON 是存储关于作业信息的更好格式。
我们需要创建一些结构来保存关于工作的信息,如下所示:
-
作业:作业的全局存储
-
日志:专门针对作业 A 的信息
-
回调:专门针对作业 B 的信息
-
邮件:专门针对作业 C 的信息
A、B 和 C 是前面图中提到的工作者类型。
现在,让我们定义我们的项目。创建项目目录并开发前面架构中显示的每个部分是这个过程的一部分:
- 创建项目:
mkdir -p $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
我们将其命名为 V1(版本 1),因为我们第一次尝试实现异步性。在接下来的章节中,我们将添加更多功能,并推出更多版本。
- 我们需要将我们的结构存储在
models目录中。创建一个名为models的包,并添加一个新文件来存储前面的结构:
mkdir -p $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
/models touch $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
/models/job.go
- 对于字段,我们有
UUID来跟踪工作,type来区分工作,以及针对各自工作的特定额外数据。我们使用 Google 的UUID包生成UUID字符串并设置作业 ID。type可以是 "A","B" 或 "C"。Log用于与时间相关的操作,因此需要一个时间字段。callback需要一个回调 URL 来发布数据。mail需要一个电子邮件地址来发送消息。结构文件包含以下结构:
package models
import (
"time"
"github.com/google/uuid"
)
// Job represents UUID of a Job
type Job struct {
ID uuid.UUID `json:"uuid"`
Type string `json:"type"`
ExtraData interface{} `json:"extra_data"`
}
// Worker-A data
type Log struct {
ClientTime time.Time `json:"client_time"`
}
// CallBack data
type CallBack struct {
CallBackURL string `json:"callback_url"`
}
// Mail data
type Mail struct {
EmailAddress string `json:"email_address"`
}
之前文件中的重要字段是 ExtraData:
ExtraData interface{} `json:"extra_data"`
我们将其定义为接口,并使其成为 Log、Callback 和 Mail 的占位符。当我们发布消息时,我们实例化相应的结构。
- 在主程序中,我们必须定义一些辅助函数和常量。我们将这些添加到项目的主文件中:
touch $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
/main.go
- 定义队列名称、HTTP 服务器运行的地址以及处理任何错误的错误处理器:
const queueName string = "jobQueue"
const hostString string = "127.0.0.1:8000"
func handleError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
这样做是为了避免重复代码。
我们必须在我们的项目中再烘焙几个组件:
-
一个 HTTP 服务器
-
工作者
-
URL 处理器
Handler接收一个传入的请求并尝试创建一个即时作业 ID。一旦它成功将作业放入队列,它就会将作业 ID 返回给调用者。现在,已经启动并监听作业队列的工作者将选择这些任务并发地执行它们。
- 为工作者创建一个文件:
touch $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
/worker.go
Workers是一个struct,它包含对消息队列的连接。使用该连接,所有工作者从队列中读取:
type Workers struct {
conn *amqp.Connection
}
在某个时候,我们需要启动工作者。为此,我们需要定义一个运行方法来启动/引导工作者。工作者应该监听消息队列中的消息并消费它们。
- 一旦有传入的消息,检查工作类型并将其委派给相应的函数,即
dbWork、callbackWork和emailWork:
func (w *Workers) run() {
log.Printf("Workers are booted up and running")
channel, err := w.conn.Channel()
handleError(err, "Fetching channel failed")
defer channel.Close()
jobQueue, err := channel.QueueDeclare(
queueName, // Name of the queue
false, // Message is persisted or not
false, // Delete message when unused
false, // Exclusive
false, // No Waiting time
nil, // Extra args
)
handleError(err, "Job queue fetch failed")
messages, err := channel.Consume(
jobQueue.Name, // queue
"", // consumer
true, // auto-acknowledge
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
go func() {
for message := range messages {
job := models.Job{}
err = json.Unmarshal(message.Body, &job)
log.Printf("Workers received a message from the queue:
%s", job)
handleError(err, "Unable to load queue message")
switch job.Type {
case "A":
w.dbWork(job)
case "B":
w.callbackWork(job)
case "C":
w.emailWork(job)
}
}
}()
defer w.conn.Close()
wait := make(chan bool)
<-wait // Run long-running worker
}
-
函数结束时,我们关闭了通道并阻塞了工作者,因为 goroutines 在后台运行。
-
现在,我们可以通过为三个函数
dbWork、callbackWork和emailWork添加延迟来模拟实际工作者的工作。我们使用延迟来模拟后台工作并相应地记录消息。我们将在workers结构体上定义这些函数,以便函数紧密关联:
func (w *Workers) dbWork(job models.Job) {
result := job.ExtraData.(map[string]interface{})
log.Printf("Worker %s: extracting data..., JOB: %s",
job.Type, result)
time.Sleep(2 * time.Second)
log.Printf("Worker %s: saving data to database...,
JOB: %s", job.Type, job.ID)
}
func (w *Workers) callbackWork(job models.Job) {
log.Printf("Worker %s: performing some long running process...,
JOB: %s", job.Type, job.ID)
time.Sleep(10 * time.Second)
log.Printf("Worker %s: posting the data back to the given
callback..., JOB: %s", job.Type, job.ID)
}
func (w *Workers) emailWork(job models.Job) {
log.Printf("Worker %s: sending the email..., JOB: %s",
job.Type, job.ID)
time.Sleep(2 * time.Second)
log.Printf("Worker %s: sent the email successfully,
JOB: %s", job.Type, job.ID)
}
这些工作者独立于主程序工作。它们监听消息队列并按照其类型处理传入的消息。通过这种方式,我们定义了端点/工作者。
- 现在,是时候为我们的 HTTP 服务器定义一些端点了,这些端点接受 API 请求并将消息发布到队列。这些将放入一个名为
handlers.go的新文件中:
touch $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV1
/handlers.go
- 我们的管理员也需要访问消息队列的连接,这是一个我们可以发布消息的通道。因此,最好为服务器定义一个结构体,并将处理程序定义为方法。让我们称它为
JobStruct:
// JobServer holds handler functions
type JobServer struct {
Queue amqp.Queue
Channel *amqp.Channel
Conn *amqp.Connection
}
- 我们应该在前面定义的结构体上附加一个名为
publish的方法。所有处理程序都可以使用此方法将 JSON 体发布到消息队列。这与我们在介绍 RabbitMQ 通道时探索的逻辑类似:
func (s *JobServer) publish(jsonBody []byte) error {
message := amqp.Publishing{
ContentType: "application/json",
Body: jsonBody,
}
err := s.Channel.Publish(
"", // exchange
queueName, // routing key(Queue)
false, // mandatory
false, // immediate
message,
)
handleError(err, "Error while generating JobID")
return err
}
现在,让我们定义三个处理程序,它们将处理三种类型的工作,如下所示:
-
第一个处理程序为工作类型 A 创建一个任务——将客户端时间保存到数据库中。
-
第二个处理程序为工作类型 B 创建一个任务——在一段时间后回调 URL。
-
第三个处理程序为工作类型 C 创建一个任务——发送电子邮件。
对于第一个处理程序,我们从 HTTP 请求中获取一个名为client_time的查询参数,并将其用于在数据库中保存。我们使用json和strconv进行所需的数据转换。一旦我们有了工作者所需的所有信息,我们就可以组合 JSON 并将其发布到队列中:
func (s *JobServer) asyncDBHandler(w http.ResponseWriter,
r *http.Request) {
jobID, err := uuid.NewRandom()
queryParams := r.URL.Query()
// Ex: client_time: 1569174071
unixTime, err := strconv.ParseInt(queryParams.Get("client_time"),
10, 64)
clientTime := time.Unix(unixTime, 0)
handleError(err, "Error while converting client time")
jsonBody, err := json.Marshal(models.Job{ID: jobID,
Type: "A",
ExtraData: models.Log{ClientTime: clientTime},
})
handleError(err, "JSON body creation failed")
if s.publish(jsonBody) == nil {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write(jsonBody)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
}
如您所见,此函数处理程序为处理"A"(数据库作业)的工作者组合了所需的消息。额外的信息在ExtraData字段中传递。正如我们之前提到的,接口可以适应任何新的结构体。因此,在运行时,我们在ExtraData中设置可以放入的内容。
其他两个处理程序看起来完全一样,只是jsonBody的组成不同。
处理程序 2 的 JSON 消息如下:
jsonBody, err := json.Marshal(models.Job{ID: jobID,
Type: "B",
ExtraData: "", // Can be custom data, Ex: {"client_time":
// "2020-01-22T20:38:15+02:00"}
})
处理程序 3 的 JSON 消息如下:
jsonBody, err := json.Marshal(models.Job{ID: jobID,
Type: "C",
ExtraData: "", // Can be custom data, Ex: {"email_address":
// "packt@example.org"}
})
接下来是主程序。我们必须使用我们的主逻辑将工人、处理程序和结构体粘合在一起。之前,我们添加了常量,但现在我们必须扩展这一点,以使工人和 API 活跃起来。
最后,我们应该将迄今为止构建的每一部分都粘合在一起。按照以下步骤操作:
- 我们需要一个返回
JobServer对象的函数。让我们将这个名为getServer的函数添加到main.go文件中。作业服务器持有连接和队列。代码如下:
func getServer(name string) JobServer {
/*
Creates a server object and initiates
the Channel and Queue details to publish messages
*/
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
handleError(err, "Dialing failed to RabbitMQ broker")
channel, err := conn.Channel()
handleError(err, "Fetching channel failed")
jobQueue, err := channel.QueueDeclare(
name, // Name of the queue
false, // Message is persisted or not
false, // Delete message when unused
false, // Exclusive
false, // No Waiting time
nil, // Extra args
)
handleError(err, "Job queue creation failed")
return JobServer{Conn: conn, Channel: channel, Queue: jobQueue}
}
使用这个服务器,我们可以将 URL 端点链接到处理程序函数。这些函数使用 RabbitMQ/消息队列的实例化连接属性。
- 现在,通过调用前面的函数并传入
queueName(我们将其定义为常量)来获取JobServer:
func main() {
jobServer := getServer(queueName)
// Rest of the code goes here....
}
- 接下来,我们应该启动工人。如果我们正常启动他们,他们将阻塞主执行线程。因此,我们必须将它们变成 goroutines:
// Start Workers
go func(conn *amqp.Connection) {
workerProcess := Workers{
conn: jobServer.Conn,
}
workerProcess.run()
}(jobServer.Conn)
- 为了接收客户端请求并使我们的应用程序成为可能,我们必须将处理程序附加到 URL。可以使用 Gorilla Mux 路由器来完成此操作。我们在第二章中详细讨论了它,处理我们的 REST 服务的路由。我们将重用那里使用的相同模式将路由附加到处理程序:
router := mux.NewRouter()
// Attach handlers
router.HandleFunc("/job/database", jobServer.asyncDBHandler)
router.HandleFunc("/job/mail", jobServer.asyncMailHandler)
router.HandleFunc("/job/callback", jobServer.asyncCallbackHandler)
httpServer := &http.Server{
Handler: router,
Addr: hostString,
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
// Run HTTP server
log.Fatal(httpServer.ListenAndServe())
这将启动一个 HTTP 服务器并将请求路由到我们之前定义的 URL。正如你可能已经注意到的,我们正在使用作业服务器的处理程序作为端点。
- 最后但同样重要的是,我们应该安全地关闭连接和通道:
// Cleanup resources
defer jobServer.Channel.Close()
defer jobServer.Conn.Close()
这就完成了我们的示例。让我们从项目根目录(longRunningTask)构建 Go 项目并查看输出:
确保你的 RabbitMQ 服务器没有关闭。我们的作业服务器使用 RabbitMQ 作为消息队列。
- 运行
go build命令:
go build .
- 这将生成一个与项目同名的可执行文件,即
longRunningTaskV1。我们可以这样启动我们的 HTTP 服务器:
./longRunningTaskV1
2019/09/22 20:36:06 Workers are booted up and running
- 服务器现在正在端口
8000上运行。向服务器发送几个curlGET请求:
> curl -X GET http://localhost:8000/job/database\?client_time\=1569177495
{"uuid":"9dfbc374-a046-4b29-b6f8-5414a277aaa2","type":"A","extra_data":{"client_time":"2019-09-22T20:38:15+02:00"}}
> curl -X GET http://localhost:8000/job/callback
{"uuid":"ac297c92-74ec-4fcb-b3e6-6dfb96eb45e0","type":"B","extra_data":""}
> curl -X GET http://localhost:8000/job/mail
{"uuid":"4ed59a6f-24d8-4179-8432-fe4adcdd4f51","type":"C","extra_data":""}
- 服务器不会阻塞请求,而是快速返回任务的工作 ID。让我们看看服务器日志:
2019/09/22 20:39:56 Workers received a message from the queue: {9dfbc374-a046-4b29-b6f8-5414a277aaa2 A map[client_time:2019-09-22T20:38:15+02:00]}
2019/09/22 20:39:56 Worker A: extracting data..., JOB: map[client_time:2019-09-22T20:38:15+02:00]
2019/09/22 20:39:58 Worker A: saving data to database..., JOB: 9dfbc374-a046-4b29-b6f8-5414a277aaa2
2019/09/22 20:40:29 Workers received a message from the queue: {ac297c92-74ec-4fcb-b3e6-6dfb96eb45e0 B }
2019/09/22 20:40:29 Worker B: performing some long running process..., JOB: ac297c92-74ec-4fcb-b3e6-6dfb96eb45e0
2019/09/22 20:40:39 Worker B: posting the data back to the given callback..., JOB: ac297c92-74ec-4fcb-b3e6-6dfb96eb45e0
2019/09/22 20:40:39 Workers received a message from the queue: {4ed59a6f-24d8-4179-8432-fe4adcdd4f51 C }
2019/09/22 20:40:39 Worker C: sending the email..., JOB: 4ed59a6f-24d8-4179-8432-fe4adcdd4f51
2019/09/22 20:40:41 Worker C: sent the email successfully, JOB: 4ed59a6f-24d8-4179-8432-fe4adcdd4f51
发送邮件有 2 秒的延迟,但客户端不会在该决策上阻塞。这就是异步 API 按设计工作的方式。
在设计异步 API 之前,总是要提前准备一个设计。由于对于给定的问题没有银弹,你必须探索各种架构,例如消息队列。
好的,但客户端如何检索作业的状态,无论是已启动、进行中还是已完成?为了启用这个功能,我们必须将作业的状态存储在某个地方。这可以是一个数据库或临时缓存。现代应用程序通过轮询 API 来获取作业状态,进行大量的读取操作。Redis 是这类问题的良好缓存解决方案。我们可以通过 Redis 扩展这个示例来解决查找作业 ID 的状态的问题。
在下一节中,我们将介绍 Redis,包括安装 Redis 并将其链接到 Go 程序。之后,我们将构建一个具有作业状态的长时间运行任务的扩展版本。
API 的缓存策略
Redis 是一个优秀的开源缓存解决方案,用于缓存高读取配置/信息。它是一个键/值对存储,由于其内存存储,读取速度更快。一个键/值对存储的例子是一个媒体网站,其中一些文章会在主页上固定显示几个小时。
而不是让每个读者都去数据库中检索记录,媒体机构可以使用 Redis 来存储文章内容。这是 Redis 的许多应用之一。
作业状态是临时信息,一旦作业完成并将状态记录到日志存储中,就变得无关紧要。因此,Redis 是实现作业状态缓存的最好选择。我们计划做以下事情:
-
写入作业状态
-
读取作业状态
这两个操作都是由我们的作业服务器执行的,但时间不同。状态可以有以下三种形式:
-
已启动
-
进行中
-
完成
Redis 提供了一组丰富的数据结构来临时存储信息。其中,我们使用简单的Key:String来存储作业状态。作业 ID 可以是键,状态是其值。用简单的符号表示,它看起来像这样:
{ '4ed59a6f-24d8-4179-8432-fe4adcdd4f51': 'In Progress'
我们可以很容易地使用 Docker 运行 Redis 实例。只需运行一个 Redis 容器并公开端口6379:
> docker run --name some-redis -p 6379:6379 -d redis
上述命令在本地主机的6379端口上运行 Redis 服务器。进程将以守护进程的方式运行,带有-d选项。要检查哪个容器是 Redis 的,你可以简单地运行以下 Docker 命令:
> docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2f0b2b457ed7 redis "docker-entrypoint.s…" 8 minutes ago Up 8 minutes 0.0.0.0:6379->6379/tcp some-redis
c3b2a0a0295d rabbitmq:3 "docker-entrypoint.s…" 6 weeks ago Up 11 hours 4369/tcp, 0.0.0.0:5672->5672/tcp, 5671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp rabbitmq-server
redis-cli是一个可以用来快速检查 Redis 服务器的工具。你不需要单独安装它。你的 Redis Docker 实例已经内置了它。你只需要对 Redis 容器执行一个命令,就像这样:
> docker exec -i -t some-redis redis-cli
127.0.0.1:6379>
你可以使用以下命令获取 Redis 中存储的所有可用键:
127.0.0.1:6379> KEYS *
你也可以在 CLI 中设置一个带有过期日期的键值,就像这样:
127.0.0.1:6379> SET topic async
OK
上述命令将一个名为topic的键设置为async。服务器在成功插入后会返回OK。CLI 易于使用,但在大多数情况下,你也可以从应用程序中访问 Redis。在下一节中,我们将学习如何从 Go 程序中这样做。
go-redis,用于与 Redis 通信的 Go 客户端
有一个广泛使用的类型安全的 Go 客户端,用于与 Redis 服务器通信,称为go-redis。我们可以通过创建一个类似于 RabbitMQ 的客户端来连接到 Redis 服务器。让我们看看执行此操作的步骤:
- 首先,我们需要创建一个名为
redisIntro的简单项目,以展示其基本用法:
mkdir -p $GOPATH/src/github.com/git-user/chapter9/redisIntro
- 使用
dep工具从项目的根目录初始化必要的依赖项并安装go-redis包:
dep init
dep ensure --add "github.com/go-redis/redis"
- 现在,创建一个小的客户端,调用默认的
PING命令并返回PONG:
touch $GOPATH/src/github.com/git-user/chapter9/redisIntro/main.go
- 可以使用
go-redis包中的redis.NewClient方法创建一个客户端:
package main
import (
"fmt"
"github.com/go-redis/redis"
)
func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
pong, _ := client.Ping().Result() // Ignoring error
fmt.Println(pong)
}
- Redis 客户端可以在服务器上执行命令。其中一个命令是
PING。SET或GET命令的工作方式相同。现在,让我们运行程序:
> go run $GOPATH/src/github.com/git-user/chapter9/redisIntro/
main.go
PONG
这会打印出PONG消息,这是 Redis 服务器给出的响应。有了这个,我们可以确认我们的程序已经成功连接到服务器,并且查询工作正常。
使用 Redis 进行作业状态缓存
现在我们已经介绍了 Redis 和 Go 的 Redis 客户端,让我们快速添加作业状态功能到我们之前设计的异步 API 中。我们需要对那个项目进行以下更改:
-
添加一个新的路由以从客户端收集作业 ID
-
添加一个新的处理程序以从 Redis 获取作业状态
-
每当有人添加一个新的作业时,我们需要在作业生命周期的每个阶段将状态写入 Redis
我们将创建一个新的项目来实现这个功能。它的结构与我们之前创建的longRunningTaskV1类似。代码和文件都是相同的。你可以克隆该项目并将其重命名为longRunningTaskV2。
让我们看看实现,包括依赖项安装和我们必须对先前项目进行的修改。我们不会展示完整的代码,以避免任何冗余:
- 为了确保你有所有必要的依赖项,运行
dep命令:
dep ensure
- 将 Redis 包添加到缓存中,以便存储/检索作业的状态:
dep ensure --add github.com/go-redis/redis
第一个更改是添加一个路由以访问作业状态。状态可以是以下三者之一:
-
STARTED -
IN PROGRESS -
DONE
- 让我们向
JobServer结构体添加一个名为redisClient的新属性。它存储到已启动并运行的 Redis 容器的客户端连接:
vi $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV2
/handlers.go
- 添加 Redis 包并修改其结构:
import (
...
"github.com/go-redis/redis"
)
// JobServer holds handler functions
type JobServer struct {
Queue amqp.Queue
Channel *amqp.Channel
Conn *amqp.Connection
redisClient *redis.Client
}
- 现在,添加一个处理函数,该函数接受一个
UUID作为参数,并通过从 Redis 获取作业状态来构造响应:
...
func (s *JobServer) statusHandler(w http.ResponseWriter,
r *http.Request) {
queryParams := r.URL.Query()
// fetch UUID from query
uuid := queryParams.Get("uuid")
w.Header().Set("Content-Type", "application/json")
jobStatus := s.redisClient.Get(uuid)
status := map[string]string{"uuid": uuid, "status":
jobStatus.Val()}
response, err := json.Marshal(status)
handleError(err, "Cannot create response for client")
w.Write(response)
}
此处理程序使用 Redis 客户端的Get函数从 Redis 服务器获取键的值。在此之后,将 HTTP JSON 响应发送回客户端。
- 现在,更改
main.go文件并添加一个新的路由:
import (
...
"github.com/go-redis/redis" // Add redis import
)
func main() {
jobServer := getServer(queueName)
// Create a client and attach to job server
jobServer.redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
...
router := mux.NewRouter()
// Attach handlers
router.HandleFunc("/job/database", jobServer.asyncDBHandler)
router.HandleFunc("/job/mail", jobServer.asyncMailHandler)
router.HandleFunc("/job/callback",
jobServer.asyncCallbackHandler)
// Add a new route here
router.HandleFunc("/job/status", jobServer.statusHandler)
}
这导入了 Redis 包并创建了一个新的 Redis 客户端。它还添加了一个新的路由来收集客户端的 UUID 作业字符串。新的路由是"job/status",我们将新创建的处理程序statusHandler附加到它。
- 我们可以通过 API 调用获取工作的状态,但待办功能是在每次执行新工作项时在 Redis 中写入工作状态。为此,我们必须稍微修改我们的工作函数。我们将修改的文件如下:
vi $GOPATH/src/github.com/git-user/chapter9/longRunningTaskV2
/worker.go
在这里,我们应该修改工作结构体,使其保持一个额外的 Redis 连接,以便可以在缓存中写入工作的状态。我们的计划是将工作 ID 作为键,将状态作为值。
- 将 Redis 包添加到导入
NewClient:
import (
...
"github.com/go-redis/redis"
}
- 修改
Worker结构体以添加redisClient。这将保持对 Redis 服务器的新连接:
// Workers do the job. It holds connections
type Workers struct {
conn *amqp.Connection
redisClient *redis.Client
}
- 在
run函数中,创建一个具体的 Redis 客户端连接:
func (w *Workers) run() {
...
// Create a new connection
w.redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
...
}
- 修改工作函数以添加状态消息。例如,让我们看看
dbWork:
...
func (w *Workers) dbWork(job models.Job) {
result := job.ExtraData.(map[string]interface{})
w.redisClient.Set(job.ID.String(), "STARTED", 0)
log.Printf("Worker %s: extracting data..., JOB: %s",
job.Type, result)
w.redisClient.Set(job.ID.String(), "IN PROGRESS", 0)
time.Sleep(2 * time.Second)
log.Printf("Worker %s: saving data to database..., JOB: %s",
job.Type, job.ID)
w.redisClient.Set(job.ID.String(), "DONE", 0)
}
...
我们正在将消息写入 Redis 键,其中消息作为值。当过程转移到下一阶段时,相同的键被覆盖。这是通过调用 Redis 的redisClient.Set()函数实现的。
如果你想知道为什么提供了第三个参数,那是因为它是 Redis 服务器上键的过期时间。我们也可以设置一个只存在一段时间的关键。现在,我们希望持久化我们的键,所以过期设置为zero,这意味着在 Redis 中没有过期。
我们可以将相同的过程应用于其他两个工作函数,即callbackWork和emailWork。
现在,是时候测试我们的新特性了:
- 构建
longRunningTaskV2项目并使用 curl 调用一个工作项。现在,使用我们添加的新端点找到该工作的状态:
go build .
./longRunningTaskV2
- 创建一个新的工作项,如下所示:
curl -X GET http://localhost:8000/job/database\?client_time\=1569177495
- 这将返回以下 JSON,其中包含工作详情:
{"uuid":"07050695-ce75-4ae8-99d3-2ab068cafe9d","type":"A","extra_data":{"client_time":"2019-09-23T00:08:15+05:30"}}
- 现在,我们可以使用
uuid找到工作的状态:
curl -X GET http://localhost:8000/job/status\?uuid\=07050695-ce75-4ae8-99d3-2ab068cafe9d
- 这将返回以下状态:
{"uuid":"07050695-ce75-4ae8-99d3-2ab068cafe9d","status":"DONE"}
这条消息根据客户端何时调用 API 而变化。但它是一种向客户端透明地提供异步工作状态的方法。
还有一种 API 类别实现了异步行为。这被称为事件驱动 API。服务器和客户端可以监听广播的事件,而不是明确请求它们。这种方法与传统异步实现不同。我们将在下一节中查看这一点。
事件驱动 API
我们之前解释的策略是请求/响应协议的实例,其中客户端通过 API 调用执行工作。还有许多其他类似架构,例如事件驱动 API,其中系统生成一系列事件,其他系统可以监听并从中接收更新。为了客户端接收事件,他们应该订阅。
这与某些语言中的回调类似,例如 JavaScript,其中事件循环持续运行并收集事件。这种方法适用于非阻塞客户端和服务器。
一个简单的例子包括客户端将 HTTP 端点注册到 API。每当有有用的信息可用时,服务器可以触发 API 作为事件。以下是一些实际例子:
-
一个向订阅客户端(例如,手机)发送一系列事件的气象站
-
亚马逊的简单通知服务(SNS)向端点发布消息
-
一个注册到 API 的 Slack webhook 以获取事件;例如,代码管道失败
实现事件驱动架构的一些协议如下:
-
发布/订阅
-
WebSocket 通信
-
Webhooks/ REST hooks
-
服务器推送(SSE)
根据实际用例,这些协议被用于不同的地方。我们将在第十一章使用微服务扩展我们的 REST API 中简要讨论发布/订阅。在那里,我们将学习如何构建事件驱动系统,从另一方消费事件,以及更多。
摘要
在本章中,我们介绍了异步 API。首先,我们解释了同步 API 和异步 API 之间的关键区别。然后,我们学习了多个 API 请求如何导致服务的扇入或扇出。
之后,我们介绍了一个名为 RabbitMQ 的队列系统。队列可以保存作业并允许服务器处理它们。我们学习了如何创建队列并将作业写入其中。我们还创建了一些可以从队列中提取作业并处理它们的 RabbitMQ 客户端。
我们还设计了一个具有多个工作者和队列的长运行任务。工作者始终监听队列并接受工作。我们定义了三种类型的工作者:数据库(DB)、电子邮件(Email)和回调(Callback)。
Redis 是一个存储键/值对的内存数据库。我们可以将其用作缓存来存储作业状态。我们扩展了我们的长运行任务,通过在 Redis 中存储作业状态来添加状态信息。
最后,我们介绍了事件驱动 API,并了解到,使用发布/订阅和 WebSocket,我们可以在客户端和服务器之间设置事件驱动的数据交换。
在下一章中,我们将探讨 GraphQL 的基础知识,以及如何在 Go 中编写 GraphQL 客户端和服务器示例。
第十章:GraphQL 和 Go
在本章中,我们将介绍一种名为 GraphQL 的新查询语言。传统的 API 定义未能解决欠取和过取 API 的问题。欠取 API 是指为特定请求提供最小细节集的 API。其缺点是开发者必须始终创建新的 API 或更新现有的 API。为了克服这一点,他们可以提供客户端可以安全忽略的额外数据。这导致另一个副作用;那就是它增加了响应的有效负载大小。这种情况被称为过取。过取 API 为客户端提供不必要的或不受欢迎的数据。当设计针对客户端的 API 时,如果网络带宽有限制,响应大小至关重要。
GraphQL 是一种解决这个问题的查询语言。在本章中,我们将学习客户端如何使用 GraphQL 高效地从 API 中查询数据。与每个框架一样,GraphQL 也有一些限制,但它的优点超过了这些限制。
在本章中,我们将涵盖以下主题:
-
什么是 GraphQL?
-
REST API 中的过取和欠取问题
-
GraphQL 基础
-
在 Go 中创建 GraphQL 客户端
-
在 Go 中创建 GraphQL 服务器
技术要求
为了运行本章中的代码示例,以下软件需要预先安装:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep: Go >= 0.5.3 的依赖管理工具
-
Docker 版本 >= 18.09.2
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter10下载本章的代码。克隆代码并使用chapter10目录中的代码示例。
什么是 GraphQL?
GraphQL 是一种提供一组规则的查询语言。使用这些规则和结构,我们可以设计一个高效的 API。根据官方文档,GraphQL 的定义如下:
"GraphQL 提供了 API 中数据的完整和可理解的描述,赋予客户端请求所需数据的权力,而不需要更多,这使得 API 随时间演变更加容易,并使强大的开发者工具成为可能。"
GraphQL 提供了一些开箱即用的功能:
-
模式(一种类型系统)
-
无版本 API
-
从模式到代码
GraphQL 模式是定义 API 边界的语法。边界包含有关通过 API 暴露的服务器资源的信息。由于它允许在客户端失败之前动态更新模式,因此它帮助我们创建无版本 API。GraphQL 为处理模式中定义的资源提供了客户端和服务器库。
GraphQL 是一种语言,而不是运行时。因此,有人必须将 GraphQL 模式翻译成编程语言可以理解的代码。一些 GraphQL 客户端和服务器库可以从模式定义中自动生成一些代码。
关于其功能,让我们看看传统 API 和 GraphQL API 之间的区别。
例如,在电子商务中,购物车页面或愿望清单页面几乎获取相同的资源(大多数字段),如产品链接、图片和成本。然而,有一些事情是不同的。例如,购物车页面需要一个送货地址,而愿望清单则不需要。
API 的流程如下:

假设前面请求的响应如下所示:
购物车页面(网页):
{
'product': 'shoe',
'cost': '20$',
'link': 'http://example-product/1',
'image': 'http://example-image.com'
'shipping_address': 'some_square, Germany'
}
愿望清单(移动端):
{
'product': 'shoe',
'cost': '20$',
'link': 'http://example-product/1',
'image': 'http://example-image.com'
'related_products': ['sports_band']
}
API 开发者通常定义两个端点,一个用于购物车,另一个用于愿望清单。如果两个响应的内容几乎包含相同的数据,它们可以合并为一个以提高可维护性。对于 API 调用中的一个,可能会出现过度获取的问题。
GraphQL 提供给客户端他们确切需要的东西。通过网络传输的数据始终符合客户端的请求。以下图表更详细地展示了这一点:

同一个 API 端点可以用于多个客户端,这些客户端接受相同的资源,但数据字段没有差异。这正是 GraphQL 的美丽之处。我们将在接下来的章节中查看更多实际示例。
在下一节中,我们将向您展示一个如何在一个 API 中发生过度获取和不足获取的稳固示例。
REST API 中的过度获取和不足获取问题
过度获取发生在 API 上,当服务器发送客户端不需要的数据时。API 是预先定义的,客户端只需要遵循 API 文档。这种做法的缺点是带宽被浪费。让我们看看一个例子。
你试图使用 GitHub 用户的 REST API 来创建用户头像显示。这里的主要目的是查看他们的关注者、他们的公开代码片段、他们标记的仓库和公司名称。然而,当你使用用户octocat调用 GitHub API (developer.github.com/v3/users/#get-a-single-user)时,它返回了一个看起来像这样的 JSON:
{
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url":
"https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url":
"https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url":
"https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url":
"https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false,
"name": "monalisa octocat",
"company": "GitHub",
"blog": "https://github.com/blog",
"location": "San Francisco",
"email": "octocat@github.com",
"hireable": false,
"bio": "There once was...",
"public_repos": 2,
"public_gists": 1,
"followers": 20,
"following": 0,
"created_at": "2008-01-14T04:33:35Z",
"updated_at": "2008-01-14T04:33:35Z",
"private_gists": 81,
"total_private_repos": 100,
"owned_private_repos": 100,
"disk_usage": 10000,
"collaborators": 8,
"two_factor_authentication": true,
"plan": {
"name": "Medium",
"space": 400,
"private_repos": 20,
"collaborators": 0
}
}
这是一个大 JSON 文件,其中包含空格和换行符,而你只需要上述数据字段。因此,在 37 个字段中,我们只将消费四个字段,忽略其余的。
忽略并不是问题,但所有数据都通过网络传输到客户端。这是带宽的不必要浪费。这被称为过度获取。
不足获取是指 API 服务器发送的响应不足以让客户端做出决策。这导致以下条件:
-
客户端必须对不同的端点进行后续的 API 调用以获取数据。
-
客户端必须手动在获取的数据上计算,然后合并它们。
这是一种非常低效的方法,因为与服务器相比,客户端的资源非常有限。例如,如果移动设备因为数据获取不足而必须执行昂贵的操作,API 必须改变其策略以提供足够的数据。这可能导致数据获取过多。找到正确的平衡总是很棘手。
让我们以前面从 GitHub API 获取用户信息的例子为例。要获取星标仓库,我们必须调用User,然后使用starred_url API 端点进行进一步查询:
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
这是一个经典的欠获取问题,限制了 GitHub API 直到版本 3(V3)。他们通过引入 GraphQL API V4(developer.github.com/v4)来解决这个问题。
GraphQL 通过采取全新的方法来解决此问题。它将每个实体视为一个资源,并试图围绕它构建一个 API。这使 GraphQL 能够动态组合数据。
在下一节中,我们将探讨 GraphQL 的基础知识,如语法和定义。这包括类型、查询和用于操作数据的功能。
GraphQL 基础知识
GraphQL 模式由许多构建块组成。这些构建块如下:
-
类型
-
查询
-
函数
-
别名
-
变量
-
变更
所有这些块对于构建一个功能性的 GraphQL API 都是必不可少的。我们可以将这些组件分为两大类:
-
模式和类型
-
查询和变更
每个类别中都有许多功能,但我们只会讨论那些可以帮助你理解 GraphQL 的最重要功能。让我们以从 API 获取用户记录为例。
注意:从现在起,我们展示的所有代码片段都位于第十章“GraphQL 和 Go”的 intro 目录中。
一个典型的 GraphQL 模式看起来像这样:
type Query {
user: Person
}
type Person {
name: String,
address: [Address]
}
type Address {
city: String,
street: String,
postalCode: Float
}
这是一个包含三种类型的 GraphQL 模式:一个称为Query的特殊类型和两个其他自定义类型Person和Address。
此模式是 API 的规范。它定义了可用于查询的资源类型。它还定义了一个特殊类型,称为Query,客户端使用它来查询数据。在先前的 GraphQL 模式中,只能查询Person类型的user。
客户端对/api/users端点的查询看起来是这样的:
{
user {
name
address {
city
street
postalCode
}
}
}
服务器随后发送足够的信息以供之前请求的字段使用:
{
"data": {
"user": {
"name": "alice",
"address": [{
"city": "Munich",
"street": "Marianplatz",
"postalCode": "80331"
}]
}
}
}
如果客户端不需要address字段而只需要name字段,它只能向同一端点请求name,即/api/users:
{
user {
name
}
}
服务器的响应可能如下所示:
{
"data": {
"user": {
"name": "alice"
}
}
}
这个响应只保留了name字段并省略了address字段。这可以节省大量的带宽。
GraphQL 响应的形状直接匹配查询,因此客户端可以预测他们能得到什么。
我们将在下一节中学习类型和查询。
类型与查询
GraphQL 有一个类型系统,服务器应该了解以便准备模式。有四种高级类型:
-
对象级别
-
字段级别
-
非空
-
枚举
让我们详细看看每种类型。
对象级别类型
对象级别类型用于定义对象级别结构,如查询和资源。它们对于定义 API 上允许的资源和方法非常有用。以下是我们前面例子中看到的内容,其中我们定义了一个查询:
type Query {
user: Person
}
这些是特殊类型,不应与编程语言中的类型混淆。
字段级别类型
正如其名所示,字段级别类型是在资源/查询字段上定义的。它们与编程语言中的类型相似。它告诉我们 API 将返回什么数据类型。它可以进一步分为两种类型:
-
标量类型(String,Int,Float,Boolean,ID)
-
自定义类型(地址)
在前面的例子中,Person 对象级别类型有以下字段:
-
name -
address
Person资源的 Go 结构体看起来像这样:
type Person {
name: String,
address: [Address]
}
name字段有一个名为String的类型,而address字段有一个名为[Address]的类型,它是一组地址。
非空类型
这是一个特殊类型,它使用特殊语法的普通字段类型并使字段成为强制性的。当一个类型具有非空类型字段时,它应向客户端返回非空数据。类型以结尾的!(感叹号)定义。
以Person类型为例:
type Person {
name: String,
address: [Address]
}
如果我们将name字段设置为非空,它将看起来像这样:
type Person {
name: String!,
address: [Address]
}
这意味着如果客户端请求某些数据,则响应必须为name字段返回一个非空值。它不能为空。我们还可以有一个非空列表,如下所示:
address: [Address]!
上述语法可以在响应中返回零个或多个Address类型的元素。
如果我们需要返回至少一个地址,则可以将非空规则应用于列表元素:
address: [Address!]!
上述规则创建了一个包含Address类型列表的地址字段。该列表应至少返回一个地址给客户端。
接下来,我们将介绍另一个重要的类型,称为枚举。
枚举
枚举(Enums)是特殊类型,在定义一系列标量类型时提供了灵活性。它们对于向客户端传递一系列信息非常有用。它给 API 带来了以下好处:
-
它允许 API 使用一组类型验证字段。
-
在不放弃类型系统的情况下,它表明可访问的字段值将位于一个有限集合中。
让我们看看一个示例模式:
type Query {
vehicle: Vehicle
}
enum Vehicle {
Car
Bus
}
type Car {
name: String,
wheels: Int
}
type Bus {
name: String,
wheels: Int
}
在这个 GraphQL 模式中,我们定义了一个以vehicle为字段的查询类型。Vehicle是一个枚举。车辆有Car和Bus作为其成员。这意味着对这个模式的查询可以期望得到一个Car或Bus。这给了我们比预定义类型系统更多的灵活性。
在本节中,我们介绍了定义模式的基础以及我们可以定义的类型。在下一节中,我们将学习如何在客户端编写高级查询以获取数据。
查询和突变
到目前为止,我们已经看到了客户端 GraphQL 查询的工作方式。它描述了应该提供的数据相关的字段。但如果我们需要关于某些标准的数据呢?我们可以用一些值进行查询吗?是的!GraphQL 是基于查询构建的,它提供了我们在查询时可以使用的各种选项。
让我们回到我们最初展示的初始示例,即具有 name 和 address 的用户 API。客户端查询如下所示:
{
user {
name
address {
street
}
}
}
前面的查询从 GraphQL 服务器获取所有用户。我们也可以通过名称查询一条记录。查询语法在字段上使用括号和冒号(:)。让我们检索名为 "alice" 的用户。前面的客户端查询应该使用括号进行修改,如下所示:
{
user {
name(name: "alice")
address {
city
street
}
}
}
此查询仅获取名为 "alice" 的记录/记录。这种结构类似于编程语言中的函数调用。name: "alice" 被称为查询的 参数。
GraphQL 客户端可以使用查询参数进行查询。参数的值可以是标量值、自定义类型,甚至是枚举。我们也可以在多个级别上进行查询。例如,让我们搜索一个名为 name "alice" 且来自 city: "Munich" 的用户:
{
user(name: "alice"){
name
address(city: "Munich") {
city
street
}
}
在多个级别上进行查询避免了多个 API 端点获取的概念。相同的 API 端点可以灵活地更改返回的数据。
突变和输入
REST API 有 GET、POST、PUT 和 DELETE 等方法。方法本身描述了 API 调用的操作。GraphQL 有类似的东西吗?是的——突变。突变 是一个更新服务器上资源状态的 GraphQL 客户端查询。
让我们看看一个计数器 API 的示例。计数器 API 允许客户端增加并返回计数器的值。在 REST 中,这是一个 POST 方法调用。在 GraphQL 中,我们必须在客户端查询中定义突变来从服务器创建并获取结果。
假设 GraphQL 模式如下所示:
type Query {
counter: Count
}
type Count {
id: Int
value: Int
}
客户端查询可以获取 id 的计数值:
{
counter(id: "250") {
value
}
}
此查询获取 id:"250" 的计数器值。如果服务器(存储)中的计数为 1,则返回以下 JSON:
{
"data": {
"counter": {
"value": 1
}
}
}
但如何将此 API 转换为创建后获取的 API?这可以通过使用 GraphQL 突变和输入来完成。
一个名为 input 的特殊类型在 GraphQL 服务器上定义了一个查询参数类型。到目前为止,我们只看到了标量类型作为参数。我们也可以创建自定义类型并将它们传递到客户端查询中。输入用作突变的参数。
input 的语法如下所示:
input CounterInput {
value: Int
}
定义 input 后,我们可以定义一个更新状态的突变:
type Mutation {
updateCounter(id: Int!, input: CounterInput)
}
此突变定义了一个输入 CounterInput 的查询函数并更新其 value。让我们更新我们的模式,使其包括这些更改:
input CounterInput {
value: Int
}
type Query {
getCounter(id: Int!): Count
}
type Count {
id: Int
value: Int
}
type Mutation {
updateCounter(id: Int!, input: CounterInput)
}
现在,客户端应该调用一个查询来更新id: "250"的新值:
mutation {
updateCounter(id: "250", CounterInput: {value: 2}) {
id
value
}
}
这个客户端查询将计数器(id: "250")的值更新为2,并返回更新记录的 ID 和值。这就是 GraphQL 如何执行创建然后获取操作。
GraphQL 查询等同于 REST 的GET。
GraphQL 突变等同于 REST 的PUT、POST和DELETE。
你关于类型、模式、查询和突变的了解应该足够你理解 GraphQL 是如何工作的。
在下一节中,我们将创建一个 Go 客户端查询并访问 GitHub 的 GraphQL API。这将证实我们迄今为止所获得的理论知识。
在 Go 中创建 GraphQL 客户端
当 Go 程序是 GraphQL 服务器的客户端时,客户端应该了解如何正确地组合 GraphQL 查询并将它们发送到服务器。Go 本身不能这样做,但可以通过一个名为machinebox/graphql的外部包来实现。这是一个轻量级的客户端,允许开发者向服务器发送查询和突变。
我们可以使用dep工具来安装包:
dep ensure -add github.com/machinebox/graphql
让我们编写一个从 GitHub 的 GraphQL API 获取数据的工具。为此,创建一个名为graphqlClient的项目目录:
mkdir -p $GOPATH/src/github.com/git-user/chapter10/graphqlClient touch $GOPATH/src/github.com/git-user/chapter10/graphqlClient/main.go
这里的目标是获取 GitHub 项目中所有可用许可证的详细信息。GitHub 提供了一个 API 来获取所有可用的许可证,但让我们假设我们只对Apache2.0许可证感兴趣。因此,我们使用 GitHub GraphQL API 来获取许可证资源。为了向 GitHub 进行 API 调用,你应该在头部添加一个 bearer 令牌,以及请求。
我们在第八章,在 Go 中构建 REST API 客户端中与 GitHub API 一起工作,以创建一个 CLI 客户端。在那里,我们使用了GITHUB_TOKEN,它作为 API 请求的个人访问令牌或 bearer 令牌。访问令牌是一个字符串,用于验证用户。我们假设你手头有访问令牌(第八章,在 Go 中构建 REST API 客户端,以了解如何以及在哪里获取它)。
首先,导入必要的包。我们需要graphql包和一些其他标准包,例如os来读取访问令牌和log来打印响应:
package main
import (
"context"
"log"
"os"
"github.com/machinebox/graphql"
)
我们像这样导入了graphql包。如果它不可用,请运行以下命令:
dep init
dep ensure -add github.com/machinebox/graphql
GitHub 许可证的响应如下(来自文档:developer.github.com/v4/object/license/):
{
"data": {
"license": {
"name": "string",
"description": "string"
}
}
}
模式中有很多字段,但让我们假设我们只对名称和描述感兴趣。因此,在我们的主程序中创建一个结构体来保存这个数据结构:
// Response of API
type Response struct {
License struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"license"`
}
graphql包提供了一个名为NewClient的函数来创建 GraphQL 客户端。它只接受一个 GraphQL 服务器端点作为参数。
一旦声明了客户端,我们就可以使用 graphql.NewRequest 函数创建一个新的 GraphQL 客户端请求。它接受一个客户端查询字符串作为参数:
func main() {
// create a client (safe to share across requests)
client := graphql.NewClient("https://api.github.com/graphql")
// make a request to GitHub API
req := graphql.NewRequest(`
query {
license(key: "apache-2.0") {
name
description
}
}
`)
// Next code goes here....
}
一旦我们有了客户端和请求对象,我们就可以进行查询。然而,GitHub API 是受保护的,需要访问令牌进行授权。为此,我们应该添加一个名为 Authorization 的头信息,并包含 'bearer' 令牌。头信息可以计算如下:
Authorization: 'bearer' + personal_access_token
我们应该将 GitHub 访问令牌与 'bearer ' 字符串连接(注意 "r" 后面的空格),以形成一个 bearer 令牌。我们应该将整个字符串作为头信息传递给 GitHub GraphQL 服务器。相应的代码如下:
var GithubToken = os.Getenv("GITHUB_TOKEN")
req.Header.Add("Authorization", "bearer "+GithubToken)
在这里,我们从环境变量中读取一个个人访问令牌,并将其放入 Authorization 头信息中。之后,我们应该创建一个上下文,并使用 client.Run 函数向服务器发送请求。为此,我们可以声明一个 Response 结构体实例,并将其传递给 Run 函数。当查询成功时,JSON 响应被加载到结构体实例中,这样我们就可以访问结果:
// define a Context for the request
ctx := context.Background()
// run it and capture the response
var respData Response
if err := client.Run(ctx, req, &respData); err != nil {
log.Fatal(err)
}
log.Println(respData.License.Description)
在这里,respData 是一个结果结构体,它包含了从 GraphQL 服务器返回的响应。一旦我们收到响应,我们就可以将 Apache2.0 许可证的描述记录到控制台。
让我们运行程序并查看输出:
go run github.com/git-user/chapter10/graphqlClient/main.go
这会将许可证描述打印到控制台:
2019/12/15 23:16:25 A permissive license whose main conditions require preservation of copyright and license notices. Contributors provide an express grant of patent rights. Licensed works, modifications, and larger works may be distributed under different terms and without source code.
这就是 Go 客户端如何与 GraphQL 服务器交互。客户端查询可以更改,但程序始终相同。
在下一节中,我们将学习如何实现一个类似于推动 API V4 的 GitHub GraphQL 服务器的服务器。我们将以一个简单的多人游戏为例,并尝试定义一个模式 API。
在 Go 中创建 GraphQL 服务器
到目前为止,我们已经看到了如何创建 REST API。但在 Go 或其他编程语言中,我们如何创建 GraphQL API 呢?我们不能直接这样做。我们需要一些包的帮助来构建可以处理客户端请求的 GraphQL 服务器。客户端可以是基于 Web 或移动的。构建 GraphQL 服务器需要两个关键要素:
-
模式
-
解析器
模式是我们在本章早期阶段讨论的内容。另一方面,解析器是生成 HTTP 响应的实体。模式仅验证并路由请求到相应的资源;解析器执行实际的逻辑计算结果,如数据库查询或任何其他后端操作。
在本节中,我们将创建一个简单的服务器,该服务器响应多人游戏中玩家数据的查询。让我们开始吧:
- 假设模式如下:
query {
players {
highScore
id
isOnline
levelsUnlocked
}
}
假设服务器应该返回这些信息。让我们开始实现这个服务。我们将模拟数据到我们的多人游戏 API 中。相同的数据可以从数据库中查询,或者从文件中获取。首先,安装必要的包。我们需要两个包:
-
graphql-go:用于创建模式和添加解析器 -
graphql-go-handler:用于运行可以将请求路由到解析器的服务器
- 让我们创建项目仓库:
mkdir -p $GOPATH/src/github.com/git-user/chapter10/graphqlServer touch $GOPATH/src/github.com/git-user/chapter10/graphqlServer/
main.go
- 我们可以使用
dep工具安装这两个包:
dep init
dep ensure -add "github.com/graphql-go/graphql"
dep ensure -add "github.com/graphql-go/handler"
- 现在,让我们编写我们的
main.go文件。它应该包含所有必要的导入。主要导入来自我们安装的包以及net/http:
import (
"net/http"
"github.com/graphql-go/graphql"
"github.com/graphql-go/handler"
)
- 现在,让我们定义我们的模拟数据,我们将通过解析器提供这些数据。定义一个结构体,它返回先前客户端查询的响应:
// Player holds player response
type Player struct {
ID int `json:"int"`
Name string `json:"name"`
HighScore int `json:"highScore"`
IsOnline bool `json:"isOnline"`
Location string `json:"location"`
LevelsUnlocked []string `json:"levelsUnlocked"`
}
var players = []Player{
Player{ID: 123, Name: "Pablo", HighScore: 1100, IsOnline: true,
Location: "Italy"},
Player{ID: 230, Name: "Dora", HighScore: 2100, IsOnline: false,
Location: "Germany"},
}
之前定义的结构是一个简单的 Go 结构体和一个列表。我们稍后会使用这些信息。
-
现在,使用
graphql.NewObject函数定义一个玩家对象。这需要一个graphql.ObjectConfig实例,它定义了对象的字段及其类型。 -
graphql包提供了标量类型和复合类型,如列表。以下是对玩家对象的定义:
var playerObject = graphql.NewObject(
graphql.ObjectConfig{
Name: "Player",
Fields: graphql.Fields{
"id": &graphql.Field{
Type: graphql.Int,
},
"name": &graphql.Field{
Type: graphql.String,
},
"highScore": &graphql.Field{
Type: graphql.String,
},
"isOnline": &graphql.Field{
Type: graphql.Boolean,
},
"location": &graphql.Field{
Type: graphql.String,
},
"levelsUnlocked": &graphql.Field{
Type: graphql.NewList(graphql.String),
},
},
},
)
这些对象字段将被映射到我们之前定义的 Player 结构体字段。
- 接下来是我们的主函数。在这里,我们必须定义三件事:
-
根查询
-
模式配置
-
模式
根查询定义了查询时的根对象。一个模式定义了 GraphQL 响应的结构。可以从模式配置创建一个新的模式。我们的主函数做了所有这些事情。
- 然后,我们创建一个
fields部分,并将其附加到根查询。这些字段有一个resolver,当客户端进行查询时会被调用。比如说,当有人查询根对象时,我们返回所有玩家:
func main() {
// Schema
fields := graphql.Fields{
"players": &graphql.Field{
Type: graphql.NewList(playerObject),
Description: "All players",
Resolve: func(p graphql.ResolveParams) (interface{},
error) {
return players, nil
},
},
}
rootQuery := graphql.ObjectConfig{Name: "RootQuery",
Fields: fields}
schemaConfig := graphql.SchemaConfig{Query:
graphql.NewObject(rootQuery)}
schema, _ := graphql.NewSchema(schemaConfig)
...
}
- 现在,我们有一个模式。但是,为了通过 HTTP 提供它,我们应该将此模式传递给
graphql-go包的handler.New函数。我们还可以创建一个名为GraphiQL的交互式 GraphQL 浏览器。让我们看看如何在代码中实现这一点:
h := handler.New(&handler.Config{
Schema: &schema,
Pretty: true,
GraphiQL: true,
})
http.Handle("/graphql", h)
http.ListenAndServe(":8000", nil)
handler.New 接受一个模式以及用于美化 GraphQL 响应的选项。GraphiQL 选项用于启用暴露的 API 的文档和交互式浏览器编辑器。
- 现在,运行程序,如下所示:
go run $GOPATH/src/github.com/git-user/chapter10/graphqlServer/
main.go
这将在 localhost:8000 上启动一个 GraphQL 服务器。
- 打开浏览器并访问
http://localhost:8000。你应该能看到交互式的 GraphQL 编辑器。现在,将以下客户端查询粘贴到左侧面板:
query {
players {
highScore
id
isOnline
levelsUnlocked
}
}
你将在右侧面板看到我们的 GraphQL 服务器提供的响应:
{
"data": {
"players": [
{
"highScore": "1100",
"id": 123,
"isOnline": true,
"levelsUnlocked": []
},
{
"highScore": "2100",
"id": 230,
"isOnline": false,
"levelsUnlocked": []
}
]
}
}
响应包含模拟数据,但在现实世界的 API 中,这些数据应该是动态生成的。有关在服务器内创建高级查询和变异的更多详细信息,请参阅 graphql-go 文档 (github.com/graphql-go/graphql)。
你可以使用 GraphiQL(一个交互式的 GraphQL 编辑器)作为你的 GraphQL 模式的 API 文档服务。它与 Swagger API 规范类似。查看 GitHub 交互式 API 编辑器以获取灵感:developer.github.com/v4/explorer/。
摘要
在本章中,我们了解了 API 的语义。API 可能会对客户端过度或不足地检索结果,这可能导致数据中产生额外的噪声。带宽浪费也可能成为一个大问题。为了克服这个问题,GraphQL 提供了一种灵活的方法来从 API 请求数据。这允许开发者编写实用的 API,这些 API 正好满足客户端的需求。
然后,我们通过理解查询、变更、模式和类型来深入探讨了 GraphQL。我们介绍了许多示例,并看到了 GraphQL 服务器响应的样子。
最后,我们使用名为machinebox/graphql的包为 GitHub API 实现了一个 Go 客户端。之后,我们创建了一个响应客户端查询的服务器。
在下一章中,我们将讨论使用微服务来扩展 API。
第十一章:使用微服务扩展我们的 REST API
从概念上讲,构建 REST API 很容易。但将其扩展以接受大量流量是一个挑战。到目前为止,我们已经探讨了创建 REST API 结构和示例 REST API 的细节。在本章中,我们将探讨 Go Micro,这是一个用于构建微服务的优秀、惯用的 Go 包。
这是微服务的时代,大型应用程序通常被分解为松耦合的组件。微服务架构允许公司快速并行迭代。我们将首先定义术语微服务,然后通过创建远程过程调用(RPC)/REST 风格的微服务来介绍 Go Micro。
在本章中,我们将涵盖以下主题:
-
什么是微服务?
-
单体与微服务
-
介绍 Go Micro,一个用于构建微服务的包
-
为微服务添加日志记录
技术要求
以下软件应预先安装以运行代码示例:
-
操作系统:Linux(Ubuntu 18.04)/Windows 10/Mac OS X >= 10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep: 用于 Go 的依赖管理工具 >= 0.5.3
-
Docker 版本 >= 18.09.2
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter11下载本章的代码。克隆代码,并使用chapter11目录中的代码示例。
什么是微服务?
什么是微服务?这是企业界向计算界提出的问题。产品的可持续性取决于其可修改性。如果无法得到适当的维护,大型产品应在某个时间点退役。微服务架构用细粒度的服务取代了传统的单体,这些服务通过某种协议相互通信。
微服务带来了以下好处:
-
小型团队可以通过专注于一组小的功能来并行迭代。
-
对于新开发者来说,适应性很容易。
-
它们允许系统各个组件的持续集成(CI)和持续交付(CD)。
-
它们提供易于替换的软件,具有松耦合的架构。
-
架构不依赖于特定的技术
在单体应用程序(传统应用程序)中,单个应用程序通过共享计算能力来服务传入的请求。这很好,因为我们把所有东西都放在一个地方,并且易于管理。但是,单体有一些问题,例如以下内容:
-
紧耦合的架构。
-
单点故障。
-
添加新特性和组件的速度。
-
工作碎片化仅限于团队。
-
持续集成(CI)和持续交付(CD)是困难的,因为即使是微小的更改也需要重新部署整个应用程序。
在单体应用程序中,整个软件被视为一个单一实体。如果数据库失败,应用程序也会失败。如果代码中的错误导致软件应用程序崩溃,与客户的整个连接都会中断。这一需求为微服务铺平了道路。
让我们来看一个场景。由鲍勃经营的公司使用传统的单体模型,开发者昼夜不停地添加新功能。在软件发布时,人们需要测试代码的每个小组件。当所有更改完成后,项目从开发转移到测试。
在隔壁街道的另一家公司,由爱丽丝经营,使用微服务架构。爱丽丝公司的软件开发者专注于单个服务,然后测试它们各自的组件。开发者通过对方的 REST/RPC API 进行交流以添加新功能。与鲍勃的团队相比,他们可以轻松地将技术栈从一种技术切换到另一种技术。
这个例子表明,爱丽丝的公司比鲍勃的公司更加灵活。
在讨论微服务时,编排和服务发现是非常重要需要考虑的方面。可以使用像 Kubernetes 这样的工具来编排 Docker 容器。通常,每个微服务拥有一个 Docker 容器是一个好的实践。
服务发现是在运行时自动检测微服务实例的 IP 地址。这消除了硬编码 IP 地址的潜在威胁,这可能导致服务之间连接失败。在下一节中,我们将使用图表来了解单体架构和微服务之间关键的区别。
单体与微服务对比
通常情况下,软件应用的开发都是从单体架构开始,然后在长期发展中将其拆分为微服务。这实际上有助于关注应用交付,而不是盲目遵循微服务模式。一旦产品稳定,开发者就应该找到一种方法来拆分产品特性。请看以下图表,了解单体架构和微服务之间的区别:

此图表展示了单体架构和微服务架构的结构。单体架构将所有内容封装在单个系统中。它被称为紧密耦合的架构。相比之下,微服务是易于替换和可修改的独立实体。每个微服务可以通过各种传输机制(如 HTTP、REST 或 RPC)相互通信。服务之间交换的数据格式可以是 JSON 或协议缓冲区。微服务还可以处理各种请求入口点,如UI和API 客户端。
微服务可以用任何技术(Java、Go、Python 等)实现,并且由于它们的松散耦合性质,可以用任何技术进行替换。
在下一节中,我们将探讨如何使用名为 Go Micro 的轻量级框架在 Go 中创建微服务。在那里,我们将了解如何开发可以相互通信的微型服务。
介绍 Go Micro,一个用于构建微服务的包
Netflix 的 Eureka 和来自 Java 社区的 Spring Boot 以构建微服务而闻名。Go Micro 包提供了相同的功能集。它是一个用于在 Go 中构建微服务的工具包。它是轻量级的,这意味着从小处着手,逐步发展。
它具有 Go 风格的服务添加方式,这使得开发者感觉良好。在接下来的章节中,我们将看到如何按照 Go Micro 定义的步骤创建一个微服务。Go Micro 提供了实现 RPC 和 事件驱动架构(EDAs)的要求。它还有一个可插拔接口,我们可以在这里插入任何外部功能。
Go Micro 支持的主要功能如下:
-
请求/响应
-
服务发现
-
负载均衡
-
消息编码
-
异步消息
-
可插拔接口
请求/响应 是一个简单的 HTTP/RPC 调用。服务发现 用于在运行时查找微服务实例。负载均衡 用于将请求路由到多个相同类型的应用程序。消息编码 对于服务相互理解至关重要。异步消息 涉及事件的产生和消费。Go Micro 的 可插拔接口 提供了诸如用于翻译的编解码器以及用于存储系统的代理等功能。
使用 dep 工具以这种方式在任何项目中安装 Go Micro:
> dep init > dep ensure -add "github.com/micro/go-micro"
在下一节中,我们将制定我们的第一个微服务的计划。我们将了解如何在 Go 中加密和解密消息。然后,我们将使用 Go Micro 构建一个加密/解密服务。
理解加密
我们都知道消息加密。加密 是一个使用基本消息和密钥通过一个只能使用原始密钥解码的数学算法产生编码消息的过程。该消息可以通过网络传输。接收者使用密钥解密消息并获取原始消息。我们将创建一个提供加密和解密功能的微服务。
查看我们第一个微服务开发的计划:
-
开发加密/解密实用函数。
-
然后,将其与 Go Micro 集成以生成服务。
Go 内置了用于加密消息的包。我们需要从这些包中导入加密算法并使用它们。为此,我们创建了一个使用 高级加密标准(AES)的项目,如下步骤所示:
- 在你的
GOPATH/src/github.com目录下创建一个名为encryptString的目录,具体步骤如下:
> mkdir -p $GOPATH/src/github.com/git-user/chapter11/encryptString cd $GOPATH/src/github.com/git-user/chapter11/encryptString
- 然后,在新的目录中添加一个名为
utils的文件。在项目目录中添加main.go,在新的utils目录中添加utils.go。目录结构如下所示:
└── encryptString
├── main.go
└── utils
└── utils.go
- 现在,让我们在我们的
utils.go文件中添加加密逻辑。我们创建两个函数,一个用于加密,另一个用于消息的解密。首先,导入必要的包crypto和encoding,如下面的代码块所示:
package utils
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
)
- AES 算法需要一个初始化向量。该向量是一个任意字节数组,可以与密钥一起用于数据加密。定义如下:
/* Initialization vector for the AES algorithm
More details visit this link https://en.wikipedia.org/wiki/Advanced_Encryption_Standard */
var initVector = []byte{35, 46, 57, 24, 85, 35, 24, 74, 87, 35, 88, 98, 66, 32, 14, 05}
向量中的值也可以随机生成。这里,我们使用一个预定义的向量。
- 现在,让我们实现加密逻辑。它使用
aes.NewCipher和aes.NewCFBEncryptor函数声明一个新的密文。然后,我们在密文上执行名为XORKeyStream的函数以获取加密字符串。然后,我们需要进行base64编码以生成受保护的字符串,如下所示:
// EncryptString encrypts the string with given key
func EncryptString(key, text string) string {
block, err := aes.NewCipher([]byte(key))
if err != nil {
panic(err)
}
plaintext := []byte(text)
cfb := cipher.NewCFBEncrypter(block, initVector)
ciphertext := make([]byte, len(plaintext))
cfb.XORKeyStream(ciphertext, plaintext)
return base64.StdEncoding.EncodeToString(ciphertext)
}
-
接下来,以同样的方式,让我们定义一个
DecryptString函数,它接受一个密钥和ciphertext,并生成原始消息。在DecryptString函数中,首先,解码base64编码的文本,并使用密钥创建一个密文块。将此密文块与初始化向量一起传递给NewCFBEncrypter。 -
然后,使用
XORKeyStream从ciphertext加载内容到plaintext。基本上,这是一个在XORKeyStream中交换加密和解密消息的过程。代码如下:
// DecryptString decrypts the encrypted string to original
func DecryptString(key, text string) string {
block, err := aes.NewCipher([]byte(key))
if err != nil {
panic(err)
}
ciphertext, _ := base64.StdEncoding.DecodeString(text)
cfb := cipher.NewCFBEncrypter(block, initVector)
plaintext := make([]byte, len(ciphertext))
cfb.XORKeyStream(plaintext, ciphertext)
return string(plaintext)
}
这完成了加密和解密工具文件的定义。
- 现在,让我们编辑
main.go文件,利用前面的utils包及其函数。main函数应该使用EncryptString函数加密一条消息,然后使用DecryptString函数解密一条消息,如下所示:
package main
import (
"log"
"github.com/git-user/chapter11/encryptString/utils"
)
// AES keys should be of length 16, 24, 32
func main() {
key := "111023043350789514532147"
message := "I am A Message"
log.Println("Original message: ", message)
encryptedString := utils.EncryptString(key, message)
log.Println("Encrypted message: ", encryptedString)
decryptedString := utils.DecryptString(key, encryptedString)
log.Println("Decrypted message: ", decryptedString)
}
原始消息不应改变。
- 这里,我们从
utils包中导入encrypting/decrypting函数,并使用它们来展示一个示例。如果我们运行这个程序,我们会看到以下输出:
> go run main.go
Original message: I am A Message
Encrypted message: 8/+JCfTb+ibIjzQtmCo=
Decrypted message: I am A Message
这个程序说明了我们如何使用 AES 算法加密一条消息,并使用相同的密钥将其恢复。此算法也称为Rijndael(发音为rain-dahl)算法。
在下一节中,我们使用这些加密知识使用 Go Micro 创建一个微服务。
使用 Go Micro 构建微服务
我们将使用 Go Micro 和utils中的加密逻辑来编写一个微服务。一个 Go 微服务应该逐步构建。要创建一个服务,我们需要提前设计几个实体。它们如下:
-
用于定义服务 RPC 方法的协议缓冲文件
-
具有方法实际实现的处理器文件
-
一个公开 RPC 方法的服务器
-
一个可以发出 RPC 请求并获取结果的客户端
我们需要两个系统级工具protoc和protoc-gen-micro来编译协议缓冲到 Go 包。让我们看看创建加密微服务的步骤,如下:
- 让我们使用
go get命令安装这些编译器,如下所示:
> go get -u github.com/golang/protobuf/protoc-gen-go
> go get github.com/micro/protoc-gen-micro
- 让我们创建我们的项目目录,如下所示:
> mkdir -p $GOPATH/src/github.com/git-user/chapter11/encryptService mkdir $GOPATH/src/github.com/git-user/chapter11/encryptService/
proto
- 现在,在
proto目录中定义一个encryption.proto协议缓冲文件,如下所示:
syntax = "proto3";
service Encrypter {
rpc Encrypt(Request) returns (Response) {}
rpc Decrypt(Request) returns (Response) {}
}
message Request {
string message = 1;
string key = 2;
}
message Response {
string result = 2;
}
它应该有一个名为 Encrypter 的服务以及两个名为 Request 和 Response 的消息。这两个消息用于请求加密和解密。
上述文件的语法是 "proto3"。Request 消息有两个字段,分别称为 message 和 key。客户端使用这些字段发送 plaintext/ciphertext 消息。
Response 消息有一个名为 result 的字段。它是加密/解密过程的结果。Encrypter 服务有两个名为 Encrypt 和 Decrypt 的 RPC 方法。两者都接受一个 Request 并返回一个 Response。
- 现在,我们可以通过编译
.proto文件来生成 Go 文件,如下所示:
> protoc -I=. --micro_out=. --go_out=. proto/encryption.proto
这是命令的分解:
| 选项 | 含义 |
|---|---|
-I |
项目根目录的输入 |
--go_out |
自动生成方法的 Go 文件输出 |
--micro_out |
与 --go_out 类似,但生成一个包含 Go 微方法的额外文件 |
proto/encryption.proto |
编译的协议缓冲文件路径 |
它在项目的 proto 目录中生成两个新的文件。它们的名称如下:
-
encryption.pb.go -
encryption.pb.micro.go
这些由代码生成的文件不应手动修改。
- 让我们复制在
encryptString示例中定义的utils.go文件。它可以原样重用,除了包名的小幅更改,如下所示:
> cp $GOPATH/src/github.com/git-user/chapter11/encryptString/utils/
utils.go $GOPATH/src/github.com/git-user/chapter11/encryptService/
- 复制后,将文件中的包名从
utils更改为main(因为现在,此文件位于新项目的根目录),如下所示:
package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
)
...
通过这种方式,我们可以在整个 Go 项目中使用 EncryptString 和 DecryptString 函数。
- 现在,添加一个名为
handlers.go的文件,其中我们定义了服务的业务逻辑。它导出Encrypter结构体和一些处理 RPC 请求的方法。此代码如下所示:
> touch $GOPATH/src/github.com/git-user/chapter11/encryptService/
handlers.go
Encrypter结构体应该有两个方法,Encrypt和Decrypt。每个方法都接受一个上下文对象、一个 RPC 请求对象和一个 RPC 响应对象。每个方法执行的操作是调用相应的实用函数,并设置响应对象的结果,如下所示:
package main
import (
"context"
proto "github.com/git-user/chapter11/encryptService/proto"
)
// Encrypter holds the information about methods
type Encrypter struct{}
// Encrypt converts a message into cipher and returns response
func (g *Encrypter) Encrypt(ctx context.Context,
req *proto.Request, rsp *proto.Response) error {
rsp.Result = EncryptString(req.Key, req.Message)
return nil
}
// Decrypt converts a cipher into message and returns response
func (g *Encrypter) Decrypt(ctx context.Context,
req *proto.Request, rsp *proto.Response) error {
rsp.Result = DecryptString(req.Key, req.Message)
return nil
}
Encrypt和Decrypt方法映射到协议缓冲文件中的这些 RPC 方法,如下所示:
rpc Encrypt(Request) returns (Response) {}
rpc Decrypt(Request) returns (Response) {}
- 现在,我们必须将这些处理程序插入到我们的
main程序中,如下所示:
> touch $GOPATH/src/github.com/git-user/chapter11/encryptService/
main.go
main程序导入proto和go-micro包,并尝试创建一个新的微服务实例。然后,它将服务注册到从handlers.go文件中导出的Encrypter处理程序。最后,它运行服务。所有这些都在以下代码块中展示:
package main
import (
fmt "fmt"
proto "github.com/git-user/chapter11/encryptService/proto"
micro "github.com/micro/go-micro"
)
func main() {
// Create a new service. Optionally include some options here.
service := micro.NewService(
micro.Name("encrypter"),
)
// Init will parse the command line flags.
service.Init()
// Register handler
proto.RegisterEncrypterHandler(service.Server(),
new(Encrypter))
// Run the server
if err := service.Run(); err != nil {
fmt.Println(err)
}
}
在前面的程序中,micro.NewService 被用来创建一个新的微服务。它返回一个 service 对象。我们也可以通过运行 service.Init() 来收集命令行参数。在我们的例子中,我们没有传递任何参数。我们可以使用 RegisterEncrypterHandler 方法将服务注册到处理器。该方法是由协议缓冲区编译器动态生成的。最后,service.Run 启动服务器。让我们运行这个服务。
- 尝试从项目根目录
encryptService构建项目,如下所示:
> go build && ./encryptService
2019/12/22 16:16:18 log.go:18: Transport [http] Listening on [::]:58043
2019/12/22 16:16:18 log.go:18: Broker [http] Connected to [::]:58044
2019/12/22 16:16:18 log.go:18: Registry [mdns] Registering node: encrypter-4d68d94a-727d-445b-80a3-24a1db3639dd
如你所见,从服务器输出中,Go Micro 使用传输和消息代理启动了微服务。现在,客户端可以对这些端口发起请求。如果没有客户端来消费 API,这个服务就没有太大用处。所以,在下一节中,我们将尝试构建一个 Go Micro 客户端,并看看如何连接到前面的服务器。
如果你想运行程序而不构建它,你必须包含在 main 文件中导入的所有包。
例如,go run main.go handlers.go utils.go 等同于 go build && ./encryptService。
使用 Go Micro 构建 RPC 客户端
在 第六章 使用协议缓冲区和 gRPC 中,我们讨论了协议缓冲区,我们提到服务器和客户端应该就相同的协议缓冲区达成一致。同样,Go Micro 期望服务和客户端使用相同的 .proto 文件——在我们的例子中是 encryption.proto。客户端可以是请求某些信息的另一个服务。
我们可以使用 Go Micro 构建客户端。它包括连接到微服务和进行 RPC 调用的所有必要构造。我们的计划是创建一个客户端,并要求服务加密和解密消息。这些请求将是 RPC 调用。让我们看看创建和使用 Go Micro 客户端的步骤,如下所示:
- 为客户端创建一个新的项目,如下所示:
> mkdir -p $GOPATH/src/github.com/git-user/chapter11/encryptClient/ > mkdir $GOPATH/src/github.com/git-user/chapter11/encryptClient/
proto
- 现在,在
proto目录中添加一个encryption.proto文件,其外观与service完全相同,如下所示:
syntax = "proto3";
service Encrypter {
rpc Encrypt(Request) returns (Response) {}
rpc Decrypt(Request) returns (Response) {}
}
message Request {
string message = 1;
string key = 2;
}
message Response {
string result = 2;
}
- 如果仔细观察,服务名称、消息及其定义与
service中的相匹配。现在,从encryptClient项目根目录编译协议缓冲区,如下所示:
> protoc -I=. --micro_out=. --go_out=. proto/encryption.proto
- 之后,客户端在
proto目录中生成了两个文件。这些文件不应该被修改。现在,我们已经准备好了我们的设置。为进行 RPC 调用到服务添加一个main.go文件,如下所示:
> touch $GOPATH/src/github.com/git-user/chapter11/encryptClient/
main.go
main程序导入了proto和go-micro包,我们在其中编译了协议缓冲区,如下所示:
package main
import (
"context"
"fmt"
proto "github.com/git-user/chapter11/encryptClient/proto"
micro "github.com/micro/go-micro"
)
- 客户端也应该使用名为
micro.NewService的函数创建,如下所示:
func main() {
// Create a new service
service := micro.NewService(micro.Name("encrypter.client"))
// Initialise the client and parse command line flags
service.Init()
// Create new encrypter service instance
encrypter := proto.NewEncrypterService("encrypter",
service.Client())
...
}
它可以被初始化来收集环境变量。客户端和服务之间的关键区别在于,对于客户端,我们使用 proto.NewEncrypterService 函数创建 service 的实例。我们使用该实例进行 API 调用。记住,该函数是由 protoc 命令自动生成的。
encrypter是代码中的服务实例。接下来,我们可以通过直接在服务实例上调用 RPC 方法来执行 RPC 调用。让我们传递一个名为"I am a Message"的文本和密钥"111023043350789514532147"来加密方法,如下所示:
// Call the encrypter
rsp, err := encrypter.Encrypt(context.TODO(), &proto.Request{
Message: "I am a Message",
Key: "111023043350789514532147",
})
if err != nil {
fmt.Println(err)
}
// Print response
fmt.Println(rsp.Result)
函数,如协议缓冲区中指定的,返回一个包含Result字段的响应。我们将该值打印到控制台。
- 接下来,让我们将这个结果作为密文传递给
Decrypt函数。它应该返回原始消息。我们使用与加密相同的密钥,如下所示:
// Call the decrypter
rsp, err = encrypter.Decrypt(context.TODO(), &proto.Request{
Message: rsp.Result,
Key: "111023043350789514532147",
})
if err != nil {
fmt.Println(err)
}
// Print response
fmt.Println(rsp.Result)
- 这两个块放入
main函数中。一旦我们添加了它们,让我们构建并运行客户端,如下所示:
> go build && ./encryptClient
8/+JCfT7+ibIjzQtmCo=
I am a Message
我们传递了明文和密钥,原始消息作为最终结果返回。这证实了encrypt和decryptRPC 调用正常工作。Go Micro 的好处是,用几行代码就可以创建微服务和客户端。
在下一节中,我们将看到 Go Micro 如何支持 EDAs,其中服务和客户端可以通过事件进行通信。
构建事件驱动型微服务
在第九章的异步 API 设计中,我们学习了异步编程。异步 API 可以通过事件实现。服务和客户端可以使用事件相互通信。他们不必等待一方完成工作。
事件生成器是一个生成事件的实体。事件消费者从其他方消费事件。发布/订阅是一种可以通过事件实现的设计模式。Go Micro 通过使用消息代理接口支持发布/订阅。
查看以下图表以了解事件流:

Go Micro 客户端可以订阅一个主题。Go 微服务可以将消息发布到该主题。在这种情况下,事件从右向左流动。
它自带内置的 HTTP 消息代理,可以轻松替换为广泛使用的消息代理,如 RabbitMQ 或 Kafka。在我们的讨论中,我们坚持使用默认的 HTTP 代理。
我们将使用示例来说明发布/订阅。假设一个微服务应该每 5 秒推送一次天气警报。而不是客户端调用服务 API,服务可以将这些更改发布到一个客户端可以订阅的主题。客户端消费这些警报并进行处理。
在我们正在工作的所有项目中,我们应该使用dep工具安装 Go Micro 并运行以下代码:
> dep init
> dep ensure -add "github.com/micro/go-micro"
我们将创建一个asyncServer和一个asyncClient。asyncServer生成天气事件,客户端消费它们。让我们看看这个示例的步骤:
- 创建一个项目目录,如下所示:
> mkdir -p $GOPATH/src/github.com/git-user/chapter11/asyncService > mkdir $GOPATH/src/github.com/git-user/chapter11/asyncServer/proto > mkdir -p $GOPATH/src/github.com/git-user/chapter11/asyncClient > mkdir $GOPATH/src/github.com/git-user/chapter11/asyncClient/proto
- 在
asyncService和asyncClient的proto目录中创建一个weather.proto文件。它包含用于通信的结构和 RPC 方法。此文件定义了一个Event,它是一个天气警报,代码如下所示:
syntax = "proto3";
// Example message
message Event {
// city name
string city = 1;
// unix timestamp
int64 timestamp = 2;
// temperaure in Celsius
int64 temperature = 3;
}
它有三个字段,如下所示:
-
城市名称 -
Unix 时间戳 -
摄氏度温度
服务应该将此事件发布到名为 alerts 的主题。
- 现在,在服务和客户端中编译
.proto文件,并获取自动生成的 Go 文件,如下所示:
> protoc -I=. --micro_out=. --go_out=. proto/weather.proto
为了简洁,我们在这个例子中省略了导入,请访问章节仓库以获取完整的代码。
- 来到服务端,
main.go文件应该声明一个微服务和发布者。发布者是通过micro.NewPublisher方法创建的。它接受主题名称alerts和service.Client()作为参数,如下所示:
func main() {
// Create a new service. Optionally include some options here.
service := micro.NewService(
micro.Name("weather"),
)
p := micro.NewPublisher("alerts", service.Client())
...
}
- 接下来,我们创建一个虚拟的计时器,每
15秒发布一次天气警报。我们通过使用内置的 Go 方法time.Tick来实现这一点。我们启动一个无限循环的go-routine,监听一个滴答声,并使用publisher.Publish方法将事件发布到主题。Publish方法接受一个上下文对象和一个包含数据的事件作为参数,如下面的代码块所示:
go func() {
for now := range time.Tick(15 * time.Second) {
log.Println("Publishing weather alert to Topic: alerts")
p.Publish(context.TODO(), &proto.Event{
City: "Munich",
Timestamp: now.UTC().Unix(),
Temperature: 2,
})
}
}()
- 然后,最后,我们必须通过调用
service.Run方法来运行服务,如下所示:
// Run the server
if err := service.Run(); err != nil {
log.Println(err)
}
service和go-routine并行运行。当你运行此服务时,你会看到以下输出:
> go build && ./asyncService
2019/12/22 21:31:03 log.go:18: Transport [http] Listening on [::]:60243
2019/12/22 21:31:03 log.go:18: Broker [http] Connected to [::]:60244
2019/12/22 21:31:03 log.go:18: Registry [mdns] Registering node: weather-83982bda-5e9e-445b-9ce2-5439d1560d1f
2019-12-22 21:31:18.379616 I | Publishing event to Topic: alerts
2019-12-22 21:31:33.376924 I | Publishing event to Topic: alerts
- 现在,服务正在推送事件,但没有客户端来消费它们。让我们更新
main.go文件中的asyncClient以包含消费逻辑。在客户端中,我们应该声明一个处理函数来处理事件。处理函数在收到事件时执行。在我们的例子中,它打印出事件,如下所示:
// ProcessEvent processes a weather alert
func ProcessEvent(ctx context.Context, event *proto.Event) error {
log.Println("Got alert:", event)
return nil
}
- 在定义处理函数以处理事件后,我们可以将客户端与主题关联起来。
micro.RegisterSubscriber函数将ProcessEvent处理函数附加到alerts主题,如下所示:
func main() {
// Create a new service
service := micro.NewService(micro.Name("weather_client"))
// Initialise the client and parse command line flags
service.Init()
micro.RegisterSubscriber("alerts", service.Server(),
ProcessEvent)
if err := service.Run(); err != nil {
log.Fatal(err)
}
}
- 如果我们运行此程序,它将消费我们之前定义的服务发布的警报,如下所示:
> go build && ./asyncClient
2019/12/22 21:48:07 log.go:18: Transport [http] Listening on [::]:60445
2019/12/22 21:48:07 log.go:18: Broker [http] Connected to [::]:60446
2019/12/22 21:48:07 log.go:18: Registry [mdns] Registering node: weather_client-73496273-31ca-4bed-84dc-60df07a1570d
2019/12/22 21:48:07 log.go:18: Subscribing weather_client-73496273-31ca-4bed-84dc-60df07a1570d to topic: alerts
2019-12-22 21:48:18.436189 I | Got event: city:"Munich" timestamp:1577047698 temperature:2
2019-12-22 21:48:33.431529 I | Got event: city:"Munich" timestamp:1577047713 temperature:2
这就是在微服务中实现异步行为的方式。客户端和服务的边界可能会变得模糊,因为任何人都可以发布或订阅。在一个分布式系统中,服务是其他服务的客户端。因此,Go Micro 提供了一种轻量级且灵活的方法来创建微服务。
在下一节中,我们将讨论微服务的日志记录和仪表化。
向微服务添加日志记录
日志记录是微服务的一个关键方面。我们可以编写中间件来捕获进入和离开服务的所有请求和响应。即使是客户端,我们也可以在向服务进行 RPC 调用时捕获日志。
Go Micro 是一个轻量级的框架,默认不强制执行日志记录。我们可以轻松地将自定义日志记录器包装在服务处理函数中。例如,在 encryptService 示例中,我们有一个名为 handlers.go 的文件。
为了以自定义格式激活每个请求的日志记录,我们必须定义一个包装器,并将其链接到服务。例如,如果我们必须记录每个传入的加密请求,请按照以下步骤操作:
- 创建一个新的
wrapper函数。它接受Context、Request和Response作为参数。在这里,我们只打印请求到达的时间,如下所示:
func logWrapper(fn server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req *proto.Request,
rsp *proto.Response) error {
fmt.Printf("encryption request at time: %v", time.Now())
return fn(ctx, req, rsp)
}
}
- 在
service中,我们可以附加包装器,如下所示:
service := micro.NewService(
micro.Name("encrypter"),
// wrap the client
micro.WrapClient(logWrap),
)
现在,服务以包装函数中定义的格式记录每个请求,如下所示:
encryption request at time: 2019/12/22 23:07:3
关于日志记录的更多信息,请参阅micro.mu/docs/go-micro.html#wrappers的文档。
服务的仪器化超出了本书的范围,但有一个名为OpenTracing(opentracing.io/)的开放标准。它定义了如何度量 API 端点、请求数量等的标准。请随意探索它。
本章中我们创建的 API 是基于 RPC 的。要将它们转换为 REST,只需使用一个名为Micro Web的插件。更多信息,请参阅此链接以轻松转换为 REST(micro.mu/docs/go-web.html)。
摘要
在本章中,我们从微服务的定义开始。单体应用和微服务之间的主要区别在于紧密耦合架构分解为松散耦合架构的方式。
微服务使用基于 REST 的 JSON 或基于 RPC 的协议缓冲区相互通信。使用微服务,我们可以将业务逻辑分解成多个部分。每个服务都做得相当不错。Go 有一个轻量级的框架称为Go Micro。使用它,我们可以创建服务和客户端。
我们首先使用Micro go创建了一个加密服务。然后我们开发了一个用于消费服务的客户端。Go Micro 还通过提供发布/订阅模式允许异步编程。任何客户端/服务都可以订阅或向一个主题推送事件。它默认使用 HTTP 代理,但可以轻松配置为 RabbitMQ 或 Kafka。Go Micro 还提供诸如服务发现和多种传输机制(如协议缓冲区、JSON 等)等功能。小型组织可以从单体应用开始,但在大型组织中有庞大团队的情况下,微服务更适合。
在下一章中,我们将看到如何使用 nginx 部署我们的 Go 服务。服务需要部署才能对外公开。我们还使用docker-compose和容器进行干净部署。
第十二章:为部署容器化 REST 服务
在本章中,我们将探讨如何使用 Docker、Docker Compose、Nginx 和 Supervisord 等工具将我们的 Go 应用程序容器化。容器化是为了在应用程序部署过程中避免平台依赖。为了正确部署应用程序,我们必须准备一个生态系统。这个生态系统包括一个 Web 服务器、一个应用服务器和一个进程监控器。本章将讨论如何将我们的 API 服务器从独立应用程序转变为生产级服务。
近年来,大多数云服务提供商倾向于托管 Web 应用程序。一些大型玩家,如 AWS、Azure、Google Cloud Platform,以及初创公司如 DigitalOcean 和 Heroku,都是这样的例子。在接下来的章节中,我们将专注于为部署 REST 服务准备平台。在下一章中,我们将探讨如何在著名的云服务提供商 AWS 上部署此生态系统。
Nginx 是一个可以作为 Web 应用程序反向代理的 Web 服务器。当多个服务器实例运行时,它还可以充当负载均衡器。Supervisord 确保在崩溃或系统重启的情况下,应用程序服务器处于运行状态。应用程序服务器/REST 服务是相同的,所以请在本章中同等考虑它们。
本章将涵盖以下主题:
-
安装 Nginx 服务器
-
什么是反向代理服务器?
-
使用 Nginx 部署 Go 服务
-
使用 Supervisord 监控我们的 Go API 服务器
-
基于
Makefile和 Docker Compose 的部署
技术要求
以下是在运行代码示例之前应预先安装的软件:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >=10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep:Go >= 0.5.3 的依赖管理工具
-
Docker 版本 >= 18.09.2
-
Docker Compose >= 1.23.2
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter12下载本章的代码。克隆代码并使用chapter12目录中的代码示例。
安装 Nginx 服务器
Nginx 是一个高性能的 Web 服务器和负载均衡器。它非常适合部署高流量网站和 API 服务器。尽管这个决定是主观的,但它是一个社区驱动、行业强大的 Web 服务器。它与 Apache2 Web 服务器类似。
Nginx 也可以作为反向代理服务器,允许我们将 HTTP 请求重定向到同一网络上的多个运行的应用服务器。Nginx 的主要竞争对手是 Apache 的 httpd。Nginx 是一个优秀的静态文件服务器,可以被 Web 客户端使用。由于我们处理的是 API,我们将探讨如何处理 HTTP 请求。
我们可以通过两种方式访问 Nginx:
-
在裸机上安装
-
使用预先安装的 Docker 容器
让我们更详细地了解这两者。
在裸机上的安装
在 Ubuntu 18.04 上,使用以下命令安装 Nginx:
> sudo apt-get update
> sudo apt-get install nginx
在 Mac OS X 上,你可以使用 brew 来安装它:
> brew install nginx
brew (https://brew.sh/) 是一个针对 Mac OS X 用户非常有用的软件打包系统。我的建议是您用它来安装软件。一旦成功安装,您可以通过在浏览器中打开机器 IP 来检查它。在您的网页浏览器上打开 http://localhost/。您应该看到以下内容:

如果您看到前面的消息,这意味着 Nginx 已成功安装。它监听端口 80 并提供默认页面。在 Mac OS X 上,默认的 Nginx 监听端口将是 80:
> sudo vi /usr/local/etc/nginx/nginx.conf
在 Ubuntu (Linux) 上,文件将位于此路径:
> sudo vi /etc/nginx/nginx.conf
打开文件并搜索服务器块。如果它在端口 80 上监听,那么一切正常。然而,如果它在其他端口上,例如 8080,那么将其改为 80:
server {
listen 80; # Nginx listen port
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
...
}
现在,一切准备就绪。服务器运行在 80 HTTP 端口上,这意味着客户端可以使用 URL (http://localhost/) 访问它。这个基本服务器从一个名为 html 的目录中提供静态文件。root 参数可以被修改为放置我们 Web 资产的任何目录。您可以使用以下命令检查 Nginx 的状态:
> service nginx status
Windows 操作系统上的 Nginx 非常基础,并不真正适用于生产级部署。开源开发者通常更喜欢 Debian 或 Ubuntu 服务器来部署带有 Nginx 的 API 服务器。
我们还可以获取一个已经安装了 Nginx 的 Docker 镜像。在下一节中,我们将演示如何将其作为 Docker 容器安装。
通过 Docker 容器安装
获取预装了 Nginx 的容器有两个好处:
-
容器化很容易。
-
我们可以多次销毁和重新创建容器。
要获取最新的 Nginx 镜像并启动一个容器,请运行以下命令:
> docker run --name nginxServer -d -p 80:80 nginx
这将从 Docker Hub 拉取 nginx 镜像(确保您已连接到互联网)。如果镜像已经拉取,它将重用该镜像。然后,它将以 nginxServer 为名启动一个容器,并在端口 80 上提供服务。现在,从您的浏览器访问 http://localhost,您将看到 Nginx 的主页。
然而,前面的命令在容器启动后配置 Nginx 时并不适用。我们必须从 localhost 挂载一个目录到容器,或者将文件复制到容器中,以更改 Nginx 配置文件。让我们修改命令:
> docker run --name nginxServer -d -p 80:80 --mount source=/host/path/nginx.conf,destination=/etc/nginx/nginx.conf:readonly nginx
额外的命令是 --mount,它将文件/目录从源(主机)挂载到目标(容器)。如果您在那个目录中修改了主机系统上的文件,那么它也会反映在容器上。readonly 选项阻止用户/系统进程修改容器内的 Nginx 配置。
在前面的命令中,我们正在挂载 Nginx 配置文件,nginx.conf。在本章的后半部分,我们将使用基于 Docker 容器的部署,其中我们使用docker-compose来部署我们的应用程序。
反向代理服务器是什么?
反向代理服务器是一个包含有关原始服务器信息的服务器。它作为客户端请求的前端实体。每当客户端发起一个 HTTP 请求时,它可以直接访问应用程序服务器。然而,如果应用程序是用编程语言编写的,那么你需要一个可以将应用程序响应转换为客户端可理解响应的翻译器。通用网关接口(CGI)就做同样的事情。
我们可以运行一个简单的 Go HTTP 服务器,它可以处理入站请求(不需要 CGI)。我们还应该保护我们的应用程序服务器免受拒绝服务(DoS)攻击。那么,为什么我们使用另一个名为 Nginx 的服务器呢?因为它带来了很多好处。
拥有反向代理服务器(Nginx)的好处如下:
-
它可以作为负载均衡器。
-
它可以提供访问控制和速率限制。
-
它可以位于应用程序集群的前端,并重定向 HTTP 请求。
-
它可以以良好的性能服务文件系统。
-
它在流媒体方面表现良好。
如果同一台机器运行着多个应用程序,那么我们可以将这些应用程序统一在一个大伞下。Nginx 还可以作为 API 网关,可以作为多个 API 端点的起点。我们将在下一章中探讨专门的 API 网关,但了解 Nginx 也可以作为网关使用是很好的。
Nginx 作为入站请求的交通路由器。它是我们应用程序服务器的保护盾。
看看下面的图:

它运行着三个使用不同编程语言编写的应用程序,并且客户端只知道一个 API 端点。假设所有这些应用程序都在不同的端口上运行。
正如你所见,图中客户端是直接与Nginx对话,而不是与其他应用程序运行的端口。在图中,Go 运行在端口8000上,其他应用程序运行在不同的端口上。这意味着不同的服务器提供不同的 API 端点。
没有 Nginx,如果客户端想要调用 API,它需要访问三个不同的端点(端口)。相反,如果我们有 Nginx,它可以作为三个应用程序的反向代理服务器,简化客户端请求-响应周期。
Nginx 也是一个上游服务器。上游服务器负责将一个服务器的请求转发到另一个服务器。从图中可以看出,Python 应用程序可以从 Go 应用程序请求 API 端点,而 Nginx 将负责路由它们。
重要的 Nginx 路径
为了与代理服务器一起工作,我们需要了解一些重要的 Nginx 路径。在 Nginx 中,我们可以同时托管多个网站(例如www.example1.com、www.example2.com等)。这意味着许多 API 服务器可以在一个 Nginx 实例下运行。
你应该了解表中的以下路径,以便正确配置 Nginx。高级部署可能需要绕过身份验证(例如,健康检查 API)、速率限制和日志备份。
看一下下面的表格:
| 类型 | 路径 | 描述 |
|---|---|---|
配置 |
/etc/nginx/nginx.conf |
这是基本的 Nginx 配置文件。它可以作为默认文件使用。 |
配置 |
/etc/nginx/sites-available/ |
如果我们在 Nginx 中运行多个网站,我们可以为每个网站有一个配置文件。 |
配置 |
/etc/nginx/sites-enabled/ |
这些是当前在 Nginx 上激活的网站。 |
日志 |
/var/log/nginx/access.log |
此日志文件记录服务器活动,如时间戳和 API 端点。 |
日志 |
/var/log/nginx/error.log |
此日志文件记录所有代理服务器相关的错误,如磁盘空间、文件系统权限等。 |
这些路径位于 Linux 操作系统上。对于 Mac OS X,使用/usr/local/nginx作为基本路径。
在下一节中,我们将探讨主要用于配置 Nginx 应用程序的服务器块。
使用服务器块
服务器块是实际配置组件,告诉服务器要提供什么服务以及要在哪个端口上监听。我们可以在sites-available文件夹中定义多个服务器块。在 Ubuntu 上,位置如下:
/etc/nginx/sites-available
在 Mac OS X 上,位置如下:
/usr/local/etc/nginx/sites-available
在我们创建从sites-available到sites-enabled目录的符号链接之前,配置没有效果。因此,始终为每个新创建的配置创建sites-available到sites-enabled的符号链接。
使用 Nginx 部署 Go 服务
正如我们已经讨论过的,Nginx 可以作为一个 Go 应用的反向代理。假设我们有一个提供 REST API 以访问书籍数据的服务器。客户端可以发送请求并返回 JSON 格式的响应。服务器还把所有日志存储在外部文件中。让我们看看创建此应用程序的步骤:
- 让我们给我们的项目命名为
bookServer:
> mkdir -p $GOPATH/src/github.com/git-user/chapter12/bookServer
touch $GOPATH/src/github.com/git-user/chapter12/bookServer/main.go
此文件是一个基本的 Go 服务器,用于说明反向代理服务器的工作原理。我们首先在端口8000上运行我们的程序。然后,我们添加一个配置,将8000(Go 的运行端口)映射到80(Nginx HTTP 端口)。
- 现在,让我们编写代码。我们将为我们的服务器使用几个包。我们可以使用 Go 的内置
net/http包来实现服务器:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
- 现在我们需要一个结构体来存储书籍信息。让我们创建一个具有
ID、ISBN、Author和PublishedYear等字段的 struct:
// Book holds data of a book
type Book struct {
ID int
ISBN string
Author string
PublishedYear string
}
- 现在进入我们的
main函数。它应该打开一个文件用于写入日志。我们可以使用os.Openfile函数来实现。这个函数需要文件和模式作为参数。让我们将文件命名为app.log:
func main() {
// File open for reading, writing and appending
f, err := os.OpenFile("app.log",
os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
fmt.Printf("error opening file: %v", err)
}
defer f.Close()
// This attaches program logs to file
log.SetOutput(f)
// further code goes here...
}
文件权限,os.O_RDWR|os.O_CREATE|os.O_APPEND,允许 Go 程序创建、写入和追加到文件。log.SetOutput(f) 将应用日志重定向到文件。
- 现在,创建一个函数处理程序并将其附加到一个路由上,使用
net/http函数。处理程序将结构体转换为 JSON 并将其作为 HTTP 响应返回。还将该处理程序附加到名为/api/books的路由:
// Function handler for handling requests
http.HandleFunc("/api/books", func(w http.ResponseWriter,
r *http.Request) {
log.Printf("%q", r.UserAgent())
// Fill the book details
book := Book{
ID: 123,
ISBN: "0-201-03801-3",
Author: "Donald Knuth",
PublishedYear: "1968",
}
// Convert struct to JSON using Marshal
jsonData, _ := json.Marshal(book)
w.Header().Set("Content-Type", "application/json")
w.Write(jsonData)
})
之前的代码块在客户端请求 /api/books 时实际上返回一本书。
- 现在,启动一个在端口
8000上为整个应用程序提供服务的 HTTP 服务器:
s := &http.Server{
Addr: ":8000",
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(s.ListenAndServe())
这完成了主程序。
- 我们可以运行我们的应用程序并查看它是否运行正确:
> go run $GOPATH/src/github.com/git-user/chapter12/bookServer/
main.go
- 现在,打开一个 shell 并执行一个
curl命令:
> curl -X GET "http://localhost:8000/api/books"
它返回数据:
{
"ID":123,
"ISBN":"0-201-03801-3",
"Author":"Donald Knuth",
"PublishedYear":"1968"
}
- 然而,客户端需要请求端口
8000。那么,我们如何使用 Nginx 代理这个服务器呢?正如我们之前讨论的,我们需要编辑默认的sites-available服务器块,称为default:
> vi /etc/nginx/sites-available/default
- 编辑前面的文件,找到服务器块,并添加
proxy_pass到它:
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
location / {
proxy_pass http://127.0.0.1:8000;
}
}
这个 config 文件的这一部分被称为 server 块。它控制代理服务器的设置,其中 listen 表示 nginx 应该监听的位置。root 和 index 指向静态文件,如果我们需要提供任何文件。server_name 是你的域名。
由于我们没有域名,所以这里只是 localhost。location 是这里的关键部分。在 location 中,我们可以定义我们的 proxy_pass,它可以反向代理到给定的 URL:PORT。由于我们的 Go 应用程序运行在端口 8000 上,我们在那里提到了它。让我们尝试在不同的域名 example.com 上运行我们的应用程序:
http://example.com:8000
我们可以将相同的名称作为参数传递给 proxy_pass。为了使此配置生效,我们需要重新启动 Nginx 服务器。你可以使用以下命令来完成:
> service nginx restart
- 现在,向
http://localhost发送一个curl请求,你将看到 Go 应用程序的输出:
> curl -X GET "http://localhost"
{
"ID":123,
"ISBN":"0-201-03801-3",
"Author":"Donald Knuth",
"PublishedYear":"1968"
}
location是一个指令,它定义了一个 统一资源标识符(URI),它可以代理给定的server:port组合。这意味着,通过定义各种 URI,我们可以代理同一服务器上运行的多达多个应用程序。它看起来像这样:
server {
listen ...;
...
location / {
proxy_pass http://127.0.0.1:8000;
}
location /api {
proxy_pass http://127.0.0.1:8001;
}
location /mail {
proxy_pass http://127.0.0.1:8002;
}
...
}
这里,有三个应用程序在不同的端口上运行。这些,在被添加到我们的配置文件后,可以被客户端如下访问:
http://localhost/
http://localhost/api/
http://localhost/mail/
在下一节中,我们将探讨如何将 API 请求负载均衡到应用程序的多个实例。
使用 Nginx 进行负载均衡
在实际情况下,为了处理大量针对 API 的传入请求,通常会部署多个服务器而不是一个。但是,谁应该将传入的客户端请求转发到服务器实例呢?负载均衡器负责这项工作。负载均衡是一个过程,其中中央服务器根据某些标准将负载分配给各个服务器。参考以下图表:

负载均衡器采用一些策略,如 Round Robin 或 Least Connection,来路由请求到实例。让我们通过一个简单的表格来看看每个方法的作用:
| 负载均衡方法 | 描述 |
|---|---|
Round Robin |
根据服务器权重标准,将传入请求均匀地分配到服务器。 |
Least Connection |
请求被发送到当前正在为最少客户端服务的服务器。 |
IP Hash |
这用于将来自给定客户端 IP 的请求发送到指定的服务器。只有当该服务器不可用时,才会将其发送到另一个服务器。 |
Least Time |
来自客户端的请求被发送到具有最低平均延迟(服务客户端的时间)和最少活动连接数的机器。 |
我们可以在 Nginx 配置中设置用于负载均衡的策略。
让我们探索如何在 Nginx 中实际实现负载均衡,以适用于我们的 Go API 服务器。这个过程的第一步是在 Nginx 配置文件的 http 部分创建一个 upstream cluster:
http {
upstream cluster {
server site1.mysite.com weight=5;
server site2.mysite.com weight=2;
server backup.mysite.com backup;
}
}
在这里,服务器是运行相同代码的服务器的 IP 地址或域名。我们在这里定义了一个名为 cluster 的上游。它是一个服务器组,我们可以在 location 指令中引用它。权重应根据可用的资源成比例分配。在前面的代码中,site1 被分配了更高的权重,因为它可能是一个更大的实例(内存和 CPU)。现在,在 location 指令中,我们可以使用 proxy_pass 命令指定服务器组:
server {
location / {
proxy_pass http://cluster;
}
}
现在,正在运行的代理服务器将把所有针对 / 端点的 API 端点请求传递给集群中的机器。默认的请求路由算法将是 Round Robin,这意味着服务器的轮次将依次重复。如果我们需要更改它,我们可以在上游定义中提及。看看以下代码片段:
http {
upstream cluster {
least_conn;
server site1.mysite.com weight=5;
server site2.mysite.com;
server backup.mysite.com backup;
}
}
server {
location / {
proxy_pass http://cluster;
}
}
上述配置表示要创建一个由三台机器组成的集群,并将负载均衡方法设置为 least connections。"least_conn" 是我们用来提及负载均衡方法的字符串。其他值可以是 ip_hash 或 least_time。您可以通过在 局域网(LAN)中有一组机器来尝试此操作。否则,我们可以安装 Docker 并使用多个虚拟容器作为不同的机器来测试负载均衡。
我们需要在 /etc/nginx/nginx.conf 文件中添加那个 http 块,而服务器块在 /etc/nginx/sites-enabled/default 中。最好将这两个设置分开。
这里有一个小练习:尝试在不同的端口上运行三个 bookServer 实例,并在 Nginx 上启用负载均衡。在下一节中,我们将探讨如何在 Nginx 中对特定客户端的 API 进行速率限制。
限制我们的 REST API
我们还可以通过速率限制来限制对 Nginx 代理服务器的访问速率。这提供了一个名为 limit_conn_zone 的指令(nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone)。其格式如下:
limit_conn_zone client_type zone=zone_type:size;
client_type 可以是两种类型之一:
-
IP 地址(限制来自特定 IP 地址的请求)
-
服务器名称(限制来自服务器的请求)
zone_type 也会根据 client_type 而变化。它取值如下表所示:
| 客户端类型 | 区域类型 |
|---|---|
$binary_remote_address |
addr |
$server_name |
servers |
Nginx 必须在内存中保存一些东西以记住用于速率限制的 IP 地址和服务器。size 参数是我们为 Nginx 分配以执行其内存操作的空间。它取值如 8 m(8 MB)或 16 m(16 MB)。现在,让我们看看在哪里添加这些设置。前面的设置应作为全局设置添加到 nginx.conf 文件中的 http 指令:
http {
limit_conn_zone $server_name zone=servers:10m;
}
这为 Nginx 使用分配了共享内存。现在,在 sites-available/default 的服务器指令中添加以下内容:
server {
limit_conn servers 1000;
}
在前面的配置中使用 limit_conn,给定服务器的总连接数不会超过 1000。如果我们尝试将速率限制从给定 IP 地址到客户端,则使用以下方法:
server {
location /api {
limit_conn addr 1;
}
}
此设置阻止客户端(即 IP 地址)向服务器打开超过一个连接(例如,在在线铁路订票会话中,用户每个 IP 地址只能使用一个会话来订票)。如果我们有一个客户端下载的文件并需要设置带宽限制,请使用 limit_rate:
server {
location /download {
limit_conn addr 10;
limit_rate 50k;
}
}
以这种方式,我们可以控制客户端与我们的在 Nginx 下代理的服务之间的交互。
保护我们的 Nginx 代理服务器
这是在 Nginx 设置中最重要的一部分。在本节中,我们将探讨如何使用基本身份验证来限制对服务器的访问。这对我们的 REST API 服务器非常重要,因为,假设我们有服务器 X、Y 和 Z 可以相互通信。X 可以直接服务客户端,但 X 通过调用内部 API 向 Y 和 Z 获取一些信息。我们应该阻止客户端访问 Y 和 Z。我们可以使用 nginx 访问模块允许或拒绝 IP 地址。它看起来是这样的:
location /api {
...
deny 192.168.1.2;
allow 192.168.1.1/24;
allow 127.0.0.1;
deny all;
}
此配置告诉 Nginx 允许来自 192.168.1.1/24 范围内的客户端请求,排除 192.168.1.2。下一行告诉我们允许来自同一主机的请求,并阻止来自任何其他客户端的所有其他请求。完整的服务器块如下所示:
server {
listen 80 default_server;
root /usr/share/nginx/html;
location /api {
deny 192.168.1.2;
allow 192.168.1.1/24;
allow 127.0.0.1;
deny all;
}
}
关于此方面的更多信息,您可以参考 nginx.org/en/docs/http/ngx_http_access_module.html?_ga=2.117850185.1364707364.1504109372-1654310658.1503918562 上的文档。我们还可以为我们的 Nginx 服务器提供的静态文件添加密码保护的访问。这主要不适用于 API,因为在那里,应用程序负责验证用户。整个想法是只允许我们批准的 IP,并拒绝所有其他请求。
Nginx 只能在应用程序服务器健康时提供服务请求。如果应用程序崩溃,我们必须手动重启它。崩溃可能由系统关闭、网络存储问题或各种其他外部因素引起。在下一节中,我们将讨论一个名为 supervisord 的进程监控工具,它可以自动重启崩溃的应用程序。
使用 Supervisord 监控我们的 Go API 服务器
有时,由于操作系统重启或崩溃,Web 应用程序服务器可能会停止。每当 Web 服务器被终止时,就有人的工作是将其恢复。如果这可以自动化,那就太好了。Supervisord 是一个救命的工具。为了使我们的 API 服务器始终运行,我们需要监控它并快速恢复。Supervisord 是一个通用的工具,可以监控正在运行的过程(系统),并在它们终止时重新启动它们。
安装 Supervisord
我们可以使用 Python 的 pip 命令轻松安装 Supervisord。
> sudo pip install supervisor
在 Ubuntu 18.04 上,您还可以使用 apt-get 命令:
> sudo apt-get install -y supervisor
这将安装两个工具,supervisor 和 supervisorctl。Supervisorctl 用于控制 supervisor,以添加任务、重启任务等。
让我们使用我们为说明进程监控而创建的 bookServer.go 程序。使用以下命令将二进制文件安装到 $GOPATH/bin 目录:
> go install $GOPATH/src/github.com/git-user/chapter12/bookServer/main.go
总是添加 $GOPATH/bin 到系统路径。每次安装项目二进制文件时,它都可以作为普通可执行文件从整体系统环境中使用。您可以将以下行添加到 ~/.profile 或 ~/.bashrc 文件:
export PATH=$PATH:$GOPATH/bin
现在,为 supervisor 创建一个新的配置文件:
/etc/supervisor/conf.d/supervisord.conf
Supervisor 读取此文件,并查找要监控的进程以及它们启动/停止时应用的规则。
您可以添加任意数量的配置文件,supervisord 将它们视为单独的进程来运行。
默认情况下,我们在 /etc/supervisor/ 中有一个名为 supervisord.conf 的文件。查看它以获取更多信息:
-
[supervisord]部分给出了supervisord的日志文件位置。 -
[程序:myserver]是一个任务块,它定义了一个命令。
将 supervisord.conf 文件的内容修改为以下内容:
[supervisord]
logfile = /tmp/supervisord.log
[program:myserver]
command=/root/workspace/bin/bookServer
autostart=true
autorestart=true
redirect_stderr=tru
文件中的命令是启动应用程序服务器的命令。/root/workspace 是 $GOPATH。
在 Supervisord 中运行命令时,请使用绝对路径。默认情况下,相对路径将不起作用。
现在,我们可以要求我们的 supervisorctl reread 重新读取配置并启动任务(进程)。为此,只需说出以下内容:
> supervisorctl reread
> supervisorctl update
然后,启动控制器工具,supervisorctl:
> supervisorctl
你应该看到类似以下内容:

因此,在这里,我们的书籍服务正在被 Supervisor 监控。让我们尝试手动终止进程并看看 Supervisor 会做什么:
> kill 6886
现在,Supervisor 会立即启动一个新的进程(使用不同的 pid),通过运行二进制文件:

这在生产环境中非常有用,因为一个服务需要最少的停机时间。那么,我们如何手动启动/停止应用程序服务呢?嗯,你可以使用 supervisorctl 的 start 和 stop 命令来执行这些操作:
> supervisorctl> stop myserver
> supervisorctl> start myserver
更多关于 supervisor 的信息,请访问 supervisord.org/。
在下一节中,我们将尝试通过使用容器来简化我们的部署。我们将分别启动应用程序和 Nginx 作为独立的容器,并借助 docker-compose 在它们之间建立通信通道。
Makefile 和基于 Docker Compose 的部署
到目前为止,我们已经看到了反向代理服务器(Nginx)的手动部署。让我们通过将事物粘合在一起来自动化它。我们将使用一些工具,如下所示:
-
Make -
docker-compose
在基于 Linux 的机器(Ubuntu 和 Mac OS X)上,Make 作为 GCC(C 语言工具链)的一部分可用。你可以使用 Python 的 pip 工具安装 docker-compose:
> sudo pip install docker-compose
在 Windows 操作系统上,docker-compose 已经作为 Docker Desktop 的一部分可用。我们的目标是使用单个 Make 命令将所有可部署实体捆绑在一起。Makefile 用于编写应用程序的控制命令。你应该定义一个规则,然后 Make 工具将执行它(www.gnu.org/software/make/manual/make.html#Rule-Example)。
让我们创建一个名为 deploySetup 的目录。它包含了我们将要展示的所有代码。它包含两个子目录——一个用于应用程序,另一个用于 Nginx:
> mkdir -p $GOPATH/src/github.com/git-user/chapter12/deploySetup mkdir $GOPATH/src/github.com/git-user/chapter12/deploySetup/nginx-conf
现在,让我们将我们的 bookServer 项目复制到 deploySetup 中,如下所示:
> cp -r $GOPATH/src/github.com/git-user/chapter12/bookServer $GOPATH/src/github.com/git-user/chapter12/deploySetup
我们需要这样做来构建可执行文件并将其复制到容器中。为了将 Go 应用程序和 Nginx 一起使用,我们应该将它们都容器化。因此,这是创建此类工作流程的计划:
-
创建一个
Dockerfile以将 Go 构建复制到容器中。 -
创建一个名为
nginx.conf的 Nginx 配置文件以复制到 Nginx 容器中。 -
编写一个
Makefile以构建二进制文件以及部署容器。
因此,首先,我们应该构建并运行应用程序和 Nginx 的 docker 容器。为此,我们可以使用docker-compose。docker-compose工具在管理多个容器时非常方便。它还可以动态构建和运行容器。
在bookServer目录中,我们需要一个 Dockerfile 来存储项目构建的二进制文件。假设我们在app中构建我们的项目。我们使用 Alpine Linux(轻量级)作为基础 Docker 镜像,因此我们应该将构建目标设置为该 Linux 平台。我们应该在 Docker 容器中复制二进制文件并执行它。假设我们选择了 app 路径为/go/bin/app。在这个位置创建一个Dockerfile:
> touch $GOPATH/src/github.com/git-user/chapter12/deploySetup/
bookServer/Dockerfile
Dockerfile看起来像这样:
FROM alpine
WORKDIR /go/bin/
COPY app .
CMD ["./app"]
Dockerfile 基本上是拉取 Alpine Linux 镜像。它为应用程序的二进制文件创建并设置工作目录。然后,它将应用程序的二进制文件复制到指定的路径,即/go/bin。复制完成后,它运行该二进制文件。
在复制应用程序二进制文件之前,必须有人构建它。让我们在这个Makefile中编写一个构建bookServer的Make命令:
> touch $GOPATH/src/github.com/git-user/chapter12/deploySetup/Makefile
它由命令及其相应的执行组成。首先,让我们添加一个build命令:
PROJECT_NAME=bookServer
BINARY_NAME=app
GOCMD=go
GOBUILD=$(GOCMD) build
build:
$(info Building the book server binary...)
cd ${PROJECT_NAME} && GOOS=linux GOARCH=arm ${GOBUILD}
-o "$(BINARY_NAME)" -v
Makefile中的顶级变量声明了项目根目录和构建(二进制)名称。它还组合了构建命令。有趣的命令是build,它只是简单地使用一些GOOS和GOARCH标志调用 Go 构建工具。这些build标志是针对 Alpine Linux 的二进制文件所必需的。现在从deploySetup目录运行此命令:
> make build
Building the book server binary...
cd bookServer && GOOS=linux GOARCH=arm go build -o "app" -v
如果你查看bookServer目录,会发现有一个新创建的app二进制文件。那是我们的应用程序服务器。我们直接在容器中启动这个二进制文件。
现在,让我们创建一个定义两个服务的docker-compose文件:
-
App 服务 -
Nginx 服务
这些服务中的每一个都有关于在哪里构建镜像、要打开哪些端口、要使用哪个网络桥接器等指令。有关docker-compose的更多信息,请参阅(https://docs.docker.com/compose/)。让我们在deploySetup目录中创建一个docker-compose.yml文件:
# Docker Compose file Reference (https://docs.docker.com/compose/compose-file/)
version: '3'
services:
# App Service
app:
build:
context: ./bookServer
dockerfile: Dockerfile
expose:
- 8000
restart: unless-stopped
networks:
- app-network
# Nginx Service
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx-conf:/etc/nginx/conf.d
depends_on:
- app
networks:
- app-network
networks:
app-network:
driver: bridge
在这个文件中,我们定义了一个名为app-network的网络和两个服务,即app和nginx。对于app服务,我们指向bookServer以选择Dockerfile来构建镜像。
在 Docker 部署中,我们不需要supervisord工具,因为docker-compose负责重启崩溃的容器。它从docker-compose文件中的restart: unless-stopped选项做出决定。
在 Compose 文件中的nginx服务从 Docker Hub 拉取默认的nginx:alpine镜像。然而,由于我们必须将我们自己的配置文件复制到 Nginx 服务器,我们应该在nginx-conf目录中创建一个文件:
> touch $GOPATH/src/github.com/git-user/chapter12/deploySetup/nginx-conf/nginx.conf
我们可以使用 volumes 选项将 nginx-conf 目录中的配置文件挂载到容器中的 /etc/nginx/conf.d。这两个服务使用相同的网络,以便它们可以相互发现。Nginx 服务将端口 80 暴露给主机,但 app 只在内部打开 8000 端口。
我们的 nginx.conf 文件应该包含如下代理信息:
upstream service {
server app:8000;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
location / {
proxy_pass http://service;
}
}
nginx.conf 文件定义了一个上游服务。它连接到 docker-compose.yml 中的应用服务。这是由于网络的桥接才成为可能。docker-compose 负责为应用容器分配主机名。在最后一个块中,我们定义了一个位置,它将反向代理请求转发到 upstream service。
现在,一切准备就绪。docker-compose.yml 文件、Supervisord 配置和 Nginx 配置都已就绪。docker-compose 有一个选项可以通过构建 compose 文件中指定的镜像来启动 Docker 容器。我们可以使用此命令启动容器:
> docker-compose up --build
让我们更新 Makefile 以添加两个新命令——一个用于 deploy,另一个用于 build 和 deploy 容器:
PROJECT_NAME=bookServer
BINARY_NAME=app
GOCMD=go
GOBUILD=$(GOCMD) build
all:
make build && make deploy
build:
$(info Building the book server binary...)
cd ${PROJECT_NAME} && GOOS=linux GOARCH=arm ${GOBUILD}
-o "$(BINARY_NAME)" -v
deploy:
docker-compose rm -f
docker-compose up --build
使用 deploy 命令,我们首先清理容器,然后启动新的容器。我们添加了一个名为 all 的额外命令。
make all 命令是一个通用命令,在没有传递任何命令时执行。例如,考虑以下情况:
> make all
这将执行 make all。我们的计划是构建二进制文件,并使用 docker-compose 启动 Docker 容器。
现在,我们已经拥有了所需的一切。从终端运行 make 命令以查看服务器正在运行:
> make
make build && make deploy
Building the book server binary...
cd bookServer && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "app" -v
docker-compose rm -f
No stopped containers
docker-compose up --build
Building app
Step 1/4 : FROM alpine
---> c85b8f829d1f
Step 2/4 : WORKDIR /go/bin/
---> Using cache
---> cc95562482f0
Step 3/4 : COPY app .
---> Using cache
---> 865952cdc77a
Step 4/4 : CMD ["./app"]
---> Using cache
---> 18d0f4ec074f
Successfully built 18d0f4ec074f
Successfully tagged deploysetup_app:latest
Creating deploysetup_app_1 ... done
Creating deploysetup_nginx_1 ... done
Attaching to deploysetup_app_1, deploysetup_nginx_1
你也可以使用 docker ps 命令来确认容器正在运行:
> docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5f78ea862376 nginx:alpine "nginx -g 'daemon of…" About a minute ago Up About a minute 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp deploysetup_nginx_1
44973a15783a deploysetup_app "/usr/bin/supervisor…" About a minute ago Up About a minute 8000/tcp deploysetup_app_1
现在,使用 curl 发送请求以查看服务器输出:
> curl -X GET "http://localhost/api/books"
{"ID":123,"ISBN":"0-201-03801-3","Author":"Donald Knuth","PublishedYear":"1968"}
现在不是通过端口调用 API,客户端现在通过 Nginx 访问 REST API。Nginx 将请求路由到容器中启动的应用服务器。在这种部署设置中,我们可以对代码进行更改,只需运行 make 命令来更新应用服务。
这就是使用 Makefile 和 docker-compose 对 Go 应用进行容器化的方法。当你按下 *Ctrl *+ C 时,服务器会优雅地停止。如果你想让它们在后台运行,只需在 Makefile 的 deploy 命令中添加一个 -d 标志:
> docker-compose up --build -d
-d 代表以守护进程运行容器。现在,容器在后台静默运行,可以使用 docker inspect CONTAINER_ID 命令查看 nginx 和 app 容器的日志。
如果更改了容器的基础镜像(在我们的例子中是 Alpine Linux),可能会导致某些功能无法正常工作。始终考虑针对特定镜像的默认配置路径(对于 Nginx 是 /etc/nginx/conf.d)以复制自定义配置。
摘要
本章演示了如何为生产部署准备 API 服务。我们需要一个网络代理服务器、应用服务器和一个进程监控器。
Nginx 是一个网络代理服务器,可以将请求传递到同一主机或不同主机上运行的多台服务器。
我们学习了如何安装 Nginx 并开始对其进行配置。Nginx 提供了诸如负载均衡和速率限制等特性,这些特性对于 API 来说非常重要。负载均衡是指将负载分配到相似服务器的过程。我们探讨了所有可用的加载机制类型:轮询(Round Robin)、IP 哈希(IP Hash)、最少连接(Least Connection)等。然后,我们探讨了如何通过允许和拒绝一些 IP 地址集合来为我们的服务器添加访问控制。我们必须在 Nginx 服务器块中添加规则来实现这一点。
最后,我们看到了一个名为 Supervisord 的进程监控工具,它可以将崩溃的应用程序恢复到正常状态。我们学习了如何安装 Supervisord 以及如何启动 supervisorctl,这是一个用于控制运行服务器的命令行应用程序。然后,我们尝试通过创建 Makefile 和 docker-compose 文件来自动化部署过程。我们还探讨了如何使用 Docker 和 Docker Compose 将 Go 应用程序与 Nginx 容器化。在现实世界中,容器是部署软件的首选方式。
在下一章中,我们将演示如何借助 AWS EC2 和 Amazon API Gateway 使我们的 REST 服务公开可见。
第十三章:在亚马逊网络服务上部署 REST 服务
准备好可部署的生态系统后,我们必须在云服务提供商上托管该生态系统,以便使应用程序编程接口(API)端点对公共互联网可见。我们需要利用云服务,如亚马逊网络服务(AWS)的弹性计算云(EC2)来部署网络服务。
部署完成后,旅程并未结束。我们必须跟踪我们的 API 使用情况和性能,以便更好地了解客户。连接到 API 的客户是谁?他们的请求频率如何?失败的授权数量等因素对于微调 API 非常重要。为了更好的安全性,API 服务器不应直接暴露给公共互联网。
在本章中,我们将探讨 AWS。然而,坚持使用单一云服务提供商可能会在后续迁移时遇到问题。因此,我们将使用一个名为 Terraform 的工具来定义和创建我们的资源。Terraform 是一个基础设施即代码(IaC)工具,它具有云无关性。我们配置了一个 EC2 实例和一个 API 网关,以便正确部署我们的表示状态传输(REST)服务。
在本章中,我们将涵盖以下主题:
-
AWS 工作基础
-
使用 Terraform 进行 IaC
-
为什么需要 API 网关?
-
介绍 AWS API 网关
-
其他 API 网关
技术要求
以下软件应预先安装以运行代码示例:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X>= 10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep:Go 的依赖管理工具 >= 0.5.3
-
Docker 版本 >= 18.09.2
-
Terraform 版本 >= 0.12.18
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter13下载本章的代码。克隆代码,并使用chapter13目录中的代码示例。
AWS 工作基础
AWS 是一个管理云应用程序基础设施的云服务提供商。其他主要玩家包括微软 Azure 和谷歌云平台(GCP)。它们都配备了多种多样的解决方案来管理各种工件,如下所示:
-
应用程序
-
数据库
-
消息队列
-
网络
-
Docker 镜像管理
-
事件总线
运行应用程序的托管服务有多种类型。我们将在下一节讨论其中的一些。
AWS 的应用程序托管服务
一个应用程序应该托管在云服务器上,以向公共互联网提供 API 服务。该服务器可以是独立机器或容器。AWS 提供了一个名为虚拟机(VM)的独立服务器,即 EC2。AWS EC2 是一种托管服务,它提供了 VM 的轻松创建和拆卸。
弹性容器服务 (ECS),AWS 的另一种托管服务,允许开发者在其容器中运行应用程序。Go 应用程序可以打包到 Docker 镜像中,并在 AWS ECS 上部署。
AWS Lambda 是另一种托管服务,可以运行无服务器函数。这是一个运行 Go 函数的服务。这些函数是短暂的,适用于例如 数据提取-转换-加载 (ETL) 的数据 等用例。Lambda 函数定义接受编译后的 Go 代码,并且可以根据需求运行数千个 Lambda 实例。
根据用例,我们应该选择适合运行我们应用程序的正确服务。由于简化了构建、推送和部署周期,基于 Docker 容器的 ECS 在运行长期运行服务和短暂应用程序方面比 EC2 更受欢迎。
在本章中,我们将尝试利用 AWS EC2 来部署 API 服务器。接下来,我们将使用 Amazon API Gateway 保护我们的服务器。以下图表可以指导您选择合适的 AWS 服务来管理 Go 应用程序:
| 类型 | 使用位置 |
|---|---|
AWS Lambda |
存活时间少于 15 分钟(根据写作时间)的函数 |
AWS ECS |
使用 AWS 管理容器的短暂和长期运行服务 |
AWS EC2 |
使用自管理 VM 的长期运行服务 |
在下一节中,我们将了解如何设置 AWS 免费层账户。我们将使用该账户在本章的所有代码示例中。
设置 AWS 账户
我们需要 AWS 账户来完成本章的工作。如果您还没有,您可以使用 AWS 免费层计划试用 1 年:aws.amazon.com/free/。
在注册免费层后,我们可以通过设置密码来访问我们的 AWS 账户。AWS 账户有一个自定义 URL,账户管理员和其他用户可以登录到账户仪表板:console.aws.amazon.com/billing/home?#/account。
所有主要服务都是免费的,但有一些限制。因此,在测试时始终监控 AWS 服务的免费层使用情况。AWS 提供了一种独特的角色模型,称为 身份和访问管理 (IAM)。这允许创建新用户并授予各种服务的权限。
在我们设置 AWS 账户后,我们应该创建 IAM 用户和角色。但为了简单起见,我们将使用之前创建的账户,其中创建者自动是管理员。我们应该允许以编程方式访问我们的 AWS 账户以便部署应用程序。
我们有三种方式可以与 AWS 交互以提供托管服务:
-
AWS 控制台
-
AWS 命令行界面 (CLI) 工具
-
第三方 软件开发工具包 (SDK)
在第一种选项中,用户登录 AWS 账户并手动配置 AWS 资源。在第二种情况下,用户可以在他们的机器上安装客户端并使用命令行 API 管理资源。第三种选项非常底层,第三方库封装 AWS API 并提供干净的接口。
对于第二种和第三种选项,必须生成安全凭证。安全凭证由两个密钥组成:
-
访问密钥 ID
-
秘密访问密钥
此安全凭证用于验证任何第三方应用程序与 AWS。可以通过在 AWS 账户中导航到 IAM| 用户| 用户| 名称| 安全凭证并执行创建访问密钥操作来获取。
创建 access_key_id 也会生成一个 secret_access_key。这些应该存储在安全的地方。如果您丢失了秘密密钥,您必须从 IAM 安全凭证中删除它并创建一个新的。
一旦用户成功获得访问密钥 ID 和秘密访问密钥,他们应该在 home 路径的 .aws 目录中创建两个文件。
在 Linux 和 Mac OS X 上,创建两个名为 credentials 和 config 的文件:
~/.aws/credentials:
[default]
aws_access_key_id=YOUR_ACCESS_KEY_ID
aws_secret_access_key=YOUR_SECRET_KEY
~/.aws/config:
[default]
region=eu-central-1
output=json
凭证文件包含有关访问密钥和秘密访问密钥的信息,以便与 AWS 进行身份验证。配置文件配置设置,例如操作的 AWS 区域和 AWS CLI 输出格式,如 JSON、XML 等。
在 Windows 上,文件应在 C:\> dir "%UserProfile%\.aws" 中创建。
您必须将 YOUR_ACCESS_KEY_ID 和 YOUR_SECRET_KEY 变量替换为 AWS 账户中的实际安全凭证。
配置文件中的区域是应用程序托管的地域位置。在前面的配置中,我们选择了法兰克福(eu-central-1)作为首选区域。您应该选择离客户端较近的区域。
我们的目标是在 AWS 的 API Gateway 后运行应用程序。而不是手动从 AWS 控制台进行操作,我们将使用一个名为 Terraform 的工具。Terraform 提供了 IaC,我们可以让 Terraform 脚本记录 AWS 上的资源创建。AWS 提供了一个内部 IaC 解决方案,称为 CloudFormation。与 AWS CloudFormation 相比,Terraform 更简单,以及更简洁。在下一节中,我们将探讨 Terraform 及其内部结构。
使用 Terraform 进行 IaC
Terraform 是一种用于在云平台上(包括 AWS)提供基础设施的软件工具。使用 Terraform,我们可以创建、配置或删除资源。与 AWS 控制台相比,Terraform 允许自动资源提供。与低级 REST API 和 SDK 相比,Terraform 具有干净、高级的 API。Terraform 将已提供基础设施的当前状态存储在状态文件中。
假设要在另一个账户上复制在账户上配置的基础设施作为灾难恢复的一部分。如果没有 IaC,所有资源都必须手动重新配置。然而,如果整个基础设施以 Terraform 脚本的形式建模,那么在任意数量的账户上重新播放基础设施就变得容易了。与在 AWS 控制台上手动配置基础设施相比,这种方法可读性和可维护性更强。
Terraform 在云上几乎提供了所有 AWS 托管服务。它应该在本地机器上运行。在提供时,它生成状态文件。请参阅以下图示以了解提供方向:

所有平台的 Terraform 安装二进制文件都可以在这里获得:www.terraform.io/downloads.html。
对于 Linux 和 Mac OS X,将可执行文件复制到相应的二进制路径,以便在系统范围内可用。使用此命令确认您的安装。它将打印出您安装的Terraform软件的version:
terraform version
Terraform v0.12.18
为了对 Terraform 进行简要介绍,让我们按照以下步骤为我们的 REST API 服务器提供 EC2 实例:
- 创建一个名为
intro的项目目录来存放提供 EC2 实例的脚本,如下所示:
mkdir -p $GOPATH/src/github.com/git-user/chapter13/intro
- 所有 Terraform 文件都有
.tf文件扩展名。因此,添加一个名为api_server.tf的脚本,如下所示:
touch $GOPATH/src/github.com/git-user/chapter13/intro/api_server.tf
- Terraform 文件的语法看起来像这样:
<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" {
# Block body
<IDENTIFIER> = <EXPRESSION> # Argument
}
如我们所见,Terraform 脚本由四个基本构建块组成:
-
块类型:Terraform 预定义的一组块类型——例如,资源和数据。
-
块标签:Terraform 脚本中块类型的命名空间。
-
标识符:块内的变量。
-
表达式:块内变量的值。
您可以在www.terraform.io/docs/configuration/index.html查看这些四个实体的所有可能值。
- 现在是实际的脚本,
api_server.tf。它应该有两个块,provider和resource,如下所示:
provider "aws" {
profile = "default"
region = "eu-central-1"
}
resource "aws_instance" "api_server" {
ami = "ami-03818140b4ac9ae2b"
instance_type = "t2.micro"
}
provider块定义了要使用的云提供商类型,并配置了安全凭据和区域。resource块用于定义要提供的资源类型及其属性。在这里,我们正在提供 EC2 实例,因此我们提供了aws_instance作为资源类型。api_server是创建的实例的名称。EC2 提供了许多实例类型。在这里,我们使用容量较小的实例t2.micro。
AWS 使用Amazon Machine Image(AMI)来创建虚拟机。我们在 Terraform 文件中选择了 Ubuntu 18.04 作为ami-03818140b4ac9ae2b操作系统映像。您可以在以下位置找到与您所在区域最接近的 AMI 映像:cloud-images.ubuntu.com/locator/ec2/。
属性可以根据资源类型而变化。因此,如果我们选择不同的资源,我们必须检查 Terraform 文档以获取适当的属性。在前面的资源块中,我们只定义了两个属性:ami和instance_type。这两个属性对于 AWS EC2 API 是强制性的。所有其他属性,如网络、安全组和 CPU,默认为合理的值。
- 现在,从
intro目录运行脚本,如下所示:
terraform apply
- 脚本输出以下消息,并要求确认
apply过程:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_instance.api_server: Creating...
aws_instance.api_server: Still creating... [10s elapsed]
aws_instance.api_server: Still creating... [20s elapsed]
aws_instance.api_server: Creation complete after 24s [id=i-07a023fc92b73fc06]
它成功创建了 EC2 实例。我们可以导航到 AWS 控制台上的 EC2 部分,查看我们的实例正在运行,如下面的截图所示:

由于我们没有在 Terraform 文件中将它们指定为属性,因此自动分配了如可用区、公共 IP 等详细信息。AWS 创建了一个默认的虚拟专用网络(VPC)、子网和一个公共域名系统(DNS)。
如果你仔细观察,terraform apply会在目录中生成以下附加文件:
-
terraform.tfstate:这包含与 AWS 执行的 JSON 计划。 -
.terraform:这是一个目录,包含插件,取决于提供者。在我们的例子中,提供者类型是 AWS。
Terraform 在项目的.terraform目录中安装与提供者相关的可执行文件。这是为了减少 Terraform 二进制文件的文件大小,排除编译脚本到其他云提供者的包。
.terraform/plugins中的插件版本也有一个版本号。你应该使用最新的插件来从最新的 Terraform 语法中受益。否则,引用另一个资源时可能会引发错误。为了安全起见,请使用以下命令升级插件到最新版本:terraform 0.12upgrade。
我们已经成功配置了一个 EC2 实例,但直到我们可以 SSH 进入它之前,它都是无用的。为此,我们应该提供一个密钥对。让我们看看如何操作的步骤,如下所示:
- 你可以在本地机器上生成一个公钥/私钥对,如下所示:
ssh-keygen -t rsa -b 4096
-
这将在
~/.ssh目录中生成公钥和私钥文件。公钥用于其他方加密数据,私钥用于所有者解密该数据。你的公钥文件的默认名称是id_rsa.pub。 -
在
api_server.tf文件中创建一个新的资源类型名为aws_key_pair,如下所示:
resource "aws_key_pair" "api_server_key" {
key_name = "api-server-key"
public_key = "ssh-rsa ABCD...XYZ naren@Narens-MacBook-Air.local"
}
在前面的块中,Terraform 创建了一个名为api_server_key的新 AWS 密钥对资源。它需要一个key_name和一个public_key。这是你新创建的公钥。AWS 将此密钥添加到 EC2 实例上的~/.ssh/known_hosts,以便在配置成功后登录到虚拟机。
- 接下来,我们应该将这个新创建的资源链接到我们的主要资源
aws_instance,如下所示:
resource "aws_instance" "api_server" {
...
key_name = aws_key_pair.api_server_key.key_name
}
- 现在,我们可以看到使用
terraform plan命令执行的 Terraform 计划,如下所示:
terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan,
but will not be persisted to local or remote state storage.
aws_instance.api_server: Refreshing state... [id=i-07a023fc92b73fc06]
------------------------------------------------------------------------
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.api_server must be replaced
-/+ resource "aws_instance" "api_server" {
...
+ key_name = "api-server-key" # forces replacement
...
}
# aws_key_pair.api_server_key will be created
如前述日志中明确指出的,Terraform 执行了一个新资源 aws_key_pair 的创建,并重新创建了服务器。Terraform 的 plan 步骤是在 AWS 上应用更改之前检查更改的好方法。
- 现在,让我们实际使用
terraform apply命令来应用这些更改,如下所示:
terraform apply
...
aws_key_pair.api_server_key: Creating...
aws_key_pair.api_server_key: Creation complete after 1s [id=api-server-key]
aws_instance.api_server: Destroying... [id=i-07a023fc92b73fc06]
aws_instance.api_server: Still destroying... [id=i-07a023fc92b73fc06, 30s elapsed]
aws_instance.api_server: Destruction complete after 30s
aws_instance.api_server: Creating...
aws_instance.api_server: Still creating... [30s elapsed]
aws_instance.api_server: Creation complete after 33s [id=i-050d7ec98b4d6a814]
- 接下来,在 AWS 账户控制台(浏览器)中,导航到 EC2| 网络 & 安全 |密钥对部分。您将在那里找到新添加的密钥对,如下面的截图所示:

-
现在,为了 SSH 进入我们的 EC2 实例,我们需要实例的公共 DNS。我们可以从 AWS 控制台或从
terraform.tfstate文件中获取公共 DNS。 -
在我们这个例子中,公共 DNS 是
ec2-52-59-192-138.eu-central-1.compute.amazonaws.com。现在我们可以以 Ubuntu 用户身份 SSH 进入这个系统,如下所示:
ssh ubuntu@ec2-52-59-192-138.eu-central-1.compute.amazonaws.com
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-1052-aws x86_64)
....
52 packages can be updated.
0 updates are security updates.
此命令选择 ~/.ssh 文件夹并定位到私钥。然后它与与 EC2 实例关联的公钥进行握手。
Ubuntu 镜像几乎没有任何配置。Go 编译器、Docker 和 docker-compose 等软件默认情况下并未安装在 Ubuntu EC2 实例上。在部署我们的应用程序之前,我们必须安装它们。确保您已 SSH 进入该机器。
- 安装最新版本的 Go 编译器和 Docker,如下所示:
sudo snap install go --classic
sudo snap install docker
- 安装
docker-compose,如下所示:
sudo pip install docker-compose
正如你可能注意到的,我们用于 SSH 的用户名是 ubuntu。这取决于用于实例配置的 AMI。例如,如果镜像是一个亚马逊 Linux 镜像,那么 SSH 用户名将是 ec2-user。
在下一节中,我们将介绍在 EC2 实例上部署 REST API 的过程。我们将使用我们旅程中之前配置的机器。
在 EC2 上部署服务
到目前为止,我们已经配置了一个具有公共 DNS 的 EC2 实例。现在,我们需要一个 API 来在实例上部署。让我们使用 第十二章 中的 bookServer 容器化应用程序,“为部署容器化 REST 服务”。在那里,我们开发了一个 Go API 服务器,在端点上提供书籍详情。在本节中,让我们尝试在 AWS EC2 实例上部署该生态系统。让我们看看在 AWS EC2 上部署 bookServer 的步骤,如下所示:
- 将
chapter12/deploySetup中的代码复制到实例的/home/ubuntu目录。您可以使用scp命令完成此操作,如下所示:
scp -r $GOPATH/src/github.com/git-user/chapter12/deploySetup ubuntu@ec2-52-59-192-138.eu-central-1.compute.amazonaws.com:/home/ubuntu
此命令将 第十二章,“为部署容器化 REST 服务”,中的源代码复制到目标实例。我们的应用程序代码已经准备好了。代码有一个 Makefile,用于构建 Go 可执行文件并部署各种容器。
- 如果您还记得从 第十二章,“为部署容器化 REST 服务”中构建的
deploySetup应用程序,那么您会记得我们可以使用make命令启动 nginx 应用服务器和supervisord,如下所示:
sudo make
- 此步骤在后台构建并启动 Docker 容器,如下所示:
Creating deploysetup_app_1 ... done
Creating deploysetup_nginx_1 ... done
由于用户权限,我们需要使用 sudo make 而不是 make,因为默认的 ubuntu 用户默认没有对 Docker 守护进程的权限。
- 现在,nginx 容器和应用容器都在运行。我们可以使用以下代码块中的
docker ps命令来确认这一点:
sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a016732f8174 nginx:alpine "nginx -g 'daemon of…" 2 minutes ago Up 2 minutes 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp deploysetup_nginx_1
29b75b09082d deploysetup_app "/usr/bin/supervisor…" 2 minutes ago Up 2 minutes 8000/tcp deploysetup_app_1
这表明我们的 nginx 和应用容器正在 EC2 实例上运行,nginx 正在端口 80 上提供服务。
- 从 AWS 控制台或
terraform.tfstate文件中获取公网 IP。
总是确认已暴露的 Docker 容器端口。格式 0.0.0.0:80->80/tcp 表示容器 TCP 端口 80 将数据包转发到主机端口 80。在我们的例子中,主机是一个 EC2 实例,容器是 nginx。
在我们的例子中,实例的公网 IP 是 52.59.192.138。请参考以下截图,以了解我们如何在 AWS 控制台 EC2 实例部分找到公网 IP:

- 从您的宿主机(不是从 EC2 实例)向
http://public-ip/api/books端点发起curl请求。您将从服务器获得以下 JSON 响应:
curl http://52.59.192.138/api/books
{"ID":123,"ISBN":"0-201-03801-3","Author":"Donald Knuth","PublishedYear":"1968"}
哈喽!我们的 API 已经发布到网络上,并且可以在全球范围内访问。在这里,nginx 正在充当 HTTP 请求的入口点。我们部署的设置是在 AWS 上发布 API 的最小方式。在现实世界的场景中,你必须做更多的事情,例如以下内容,以保护 API:
-
通过添加证书在 HTTPS 上处理请求
-
正确配置 VPC、子网和安全组
将前面的建议添加到我们的 EC2 实例超出了本书的范围。请参考 AWS 关于这些主题的文档以获取更多信息。
在下一节中,我们将使用 Amazon API Gateway 配置我们的 EC2 实例。正如我们在本章开头讨论的那样,AWS 网关是保护 EC2 实例的主要方式之一。
为什么需要 API 网关?
假设一家名为 XYZ 的公司为其内部用途开发了一个 API。它有两种方式将此 API 暴露给外部使用:
-
它使用已知客户端的认证来暴露它。
-
它将其作为一项服务以 API 的形式暴露。
在第一种情况下,此 API 由公司内部的其他服务消费。由于它们是内部的,我们没有限制访问。但在第二种情况下,由于 API 详细信息已经提供给外部世界,我们需要一个中间代理来检查和验证请求。这个代理就是 API 网关。API 网关是一个位于客户端和服务器之间的代理,在满足特定条件后将请求转发到服务器。
现在,公司 XYZ 有一个用 Go 编写的 API 和一个用 Java 编写的 API。有一些适用于任何 API 的共同点:
-
认证
-
记录请求和响应
没有 API 网关,我们需要编写另一个服务器来跟踪诸如请求和 API 认证等事项。当组织不断添加新的 API 时,实现和维护这些基本功能可能会变得很繁琐。为了处理这些基本事务,API 网关是一块很好的中间件。
基本上,API 网关做以下事情:
-
记录日志
-
安全性
-
交通控制
-
中间件
记录日志是我们跟踪请求和响应的方式。与在 Go 网络服务器中发生的应用级日志记录不同,API 网关可以支持跨多个应用程序的组织级日志记录。
认证是应用安全的一部分。它可以是基本认证、基于令牌的认证、OAuth2.0 等。对于限制对 API 的有效客户/客户端的访问至关重要。
当 API 是一项付费服务时,交通控制就派上用场。当组织以 API 的形式销售数据时,需要限制每个客户端的操作次数。例如,一个客户端每月可以发起 10,000 次 API 请求。可以根据客户端选择的计划设置速率。这是一个非常重要的功能。
中间件用于在请求到达应用服务器之前修改请求,或者在将响应发送回客户端之前修改响应。
看一下以下图示:

上述图示显示了接受所有客户端请求的API 网关。API 网关可以根据 HTTP 头、URL 路径前缀或 IP 地址将请求转发到相应的 API 服务器。一旦 API 服务器完成其工作,API 网关收集中间响应并将其返回给客户端。在本章中,我们将尝试利用 Amazon API 网关。
介绍 Amazon API 网关
Amazon API 网关具有以下功能:
-
反向代理服务
-
速率限制
-
监控
-
认证
反向代理是将 REST API 请求传递到另一个端点的过程。Amazon API 网关可以注册一个具有自定义路径和方法的 REST 端点。它将匹配的请求转发到应用服务器。它还可以使用 AWS 用户凭证以及安全令牌进行认证。用户必须在 AWS IAM 上创建,才能访问 API。
通过编写网关规则可以实现监控。日志可以被定向到 AWS CloudWatch,这是另一项由 Amazon 提供的服务。当有可疑的入站请求时,网关还可以触发 CloudWatch 警报。CloudWatch 警报是针对特殊情况的通知。这些通知可以触发其他操作,例如发送电子邮件或记录事件。
现在,让我们为我们的 EC2 实例配置一个 API 网关。架构图看起来像这样:

在前面的图中,Amazon API Gateway 定义了方法和集成。目标是部署了 books API 的 EC2 实例。我们应该配置六种类型的组件来在 AWS 上运行 API Gateway。具体如下:
-
网关 REST API
-
网关方法请求
-
网关方法响应
-
网关集成请求
-
网关集成响应
-
网关部署
为什么我们必须创建前面的组件?Amazon API Gateway 架构在其设计中定义了这些组件。参见以下图表,了解 API 在 Amazon API Gateway 上的表示方式:

客户端请求通过网关方法请求和集成请求阶段发送。集成请求阶段然后将请求转发到配置的 API 端点。该端点将是 /api/books,使用 GET 方法,并且将在 EC2 实例上运行。这完成了请求的生命周期。
接下来,端点从 EC2 实例返回一个响应。此响应被转发到集成响应阶段,然后到网关方法响应阶段。这完成了响应的生命周期。
每个阶段都可以进一步配置,以将响应转换为不同的格式。为了简单起见,我们保留每个阶段的默认设置。在下一节中,我们将尝试在 Terraform 中构建前面的组件。
在编写 Terraform 脚本之前,在 AWS 控制台中手动创建我们的 API 的 API Gateway。这有助于你理解 Amazon API Gateway 的基本词汇。
在 Amazon API Gateway 后部署我们的服务
暂停理论,让我们快速跳入一个例子。我们的目标是设置 API Gateway,作为之前部署的 books API 的目标。按照以下步骤操作:
- 让我们创建一个新的项目并编写一个新的 Terraform 脚本,该脚本在 Amazon API Gateway 上创建和部署一个 API,如下所示:
touch $GOPATH/src/github.com/git-user/chapter13/intro/api_gateway.tf
它还链接了我们的 EC2 实例和 API 端点。
- 让我们在脚本中添加网关 REST API 组件:
// New API on Amazon API Gateway
resource "aws_api_gateway_rest_api" "test" {
name = "EC2Example"
description = "Terraform EC2 REST API Example"
endpoint_configuration {
types = ["REGIONAL"]
}
}
它包含一些重要的属性,如下所示:
-
name: API 的名称 -
description: 关于 API 的文本 -
endpoint_configuration: 定义要发布的 API 的模式(REGIONAL或EDGE)
这些详细信息用于在 Amazon API Gateway 中识别一个 API。我们给我们的 API 命名为 EC2Example。aws_api_gateway_rest_api 资源类型是 Terraform 资源类型。我们的资源名称是 test。从现在开始,我们将看到所有其他资源类型都使用类似的名字。
当 AWS 创建 aws_api_gateway_rest_api 组件时,它也在 AWS 上创建了一个默认的网关资源。网关资源是我们作为集成部分配置的端点的相对路径。
-
接下来,我们必须创建一个名为
test的网关方法。它接受rest_api_id、resource_id和http_method属性。这三个属性对所有组件都是通用的。让我们称这些为DEFAULT_ATTRIBUTES。 -
此外,我们在该组件上设置了
Authorization=NONE。如果我们设置授权为AWS_IAM,那么客户端必须提供 AWS 访问密钥和令牌,与请求一起提供。目前我们正在禁用网关认证,如下面的代码块所示:
// Method request configuration
resource "aws_api_gateway_method" "test" {
rest_api_id = aws_api_gateway_rest_api.test.id
resource_id = aws_api_gateway_rest_api.test.root_resource_id
http_method = "GET"
authorization = "NONE"
}
- 在添加方法请求后,我们应该添加方法响应组件。这也需要
DEFAULT_ATTRIBUTES加上status_code。这意味着每当方法响应从集成响应中接收到200OK时,它将作为成功消息传递给客户端,如下面的代码块所示:
// Method response configuration
resource "aws_api_gateway_method_response" "test" {
rest_api_id = aws_api_gateway_rest_api.test.id
resource_id = aws_api_gateway_rest_api.test.root_resource_id
http_method = aws_api_gateway_method.test.http_method
status_code = "200"
}
- 接下来,添加集成组件。根据上一节中的 API 架构图,我们有两个集成组件。
integration response组件与method_response组件类似,如下面的代码块所示:
// Integration response configuration
resource "aws_api_gateway_integration_response" "MyDemoIntegrationResponse" {
rest_api_id = aws_api_gateway_rest_api.test.id
resource_id = aws_api_gateway_rest_api.test.root_resource_id
http_method = aws_api_gateway_method.test.http_method
status_code = aws_api_gateway_method_response.test.status_code
}
API 网关和运行在 EC2 实例上的我们的 API 之间的主要链接是在integration request组件中创建的。它包含DEFAULT_ATTRIBUTES,以及三个重要属性:
-
integration_http_method:决定应在端点上调用哪种 HTTP 方法 -
type:表示正在使用哪种类型的端点:HTTP、Lambda或AWS_PROXY -
uri:端点的实际引用
在我们的案例中,因为我们想将网关和 EC2 实例链接起来,我们使用HTTP作为我们的type,并将我们的 EC2 实例的公网 DNS 作为uri。Terraform 块看起来像这样:
// Integration request configuration
resource "aws_api_gateway_integration" "test" {
rest_api_id = aws_api_gateway_rest_api.test.id
resource_id = aws_api_gateway_method.test.resource_id
http_method = aws_api_gateway_method.test.http_method
integration_http_method = "GET"
type = "HTTP"
uri = "http://${aws_instance.api_server.public_dns}/api/books"
}
我们已将integration_http_method设置为GET,因为我们的books API 只有一个使用 GET 方法的端点。对于uri属性值,我们引用了aws_instance.api_server EC2 实例资源中的public_dns。由于 Terraform 脚本api_server.tf和api_gateway.tf都在同一个intro项目目录中,我们可以从另一个脚本中导入资源。
这完成了 API 的所有五个关键组件。我们必须使用测试环境部署 API。Terraform 提供了一个名为aws_api_gateway_deployment的资源类型来创建部署。部署对于测试或发布 Amazon API 网关端点到 Web 非常有用。部署需要以下属性:
// Deploy API on Gateway with test environment
resource "aws_api_gateway_deployment" "test" {
depends_on = [
aws_api_gateway_integration.test
]
rest_api_id = aws_api_gateway_rest_api.test.id
stage_name = "test"
}
部署还依赖于integration request,因此我们添加了depends_on属性。stage_name属性可以接受test阶段或生产环境类型。这样,我们就完成了 API 网关的创建。让我们运行脚本,在 Amazon API 网关上创建和部署我们的 API,如下所示:
terraform apply -auto-approve
aws_key_pair.api_server_key: Refreshing state... [id=api-server-key]
aws_instance.api_server: Refreshing state... [id=i-050d7ec98b4d6a814]
aws_api_gateway_rest_api.test: Creating...
aws_api_gateway_method.test: Creating...
aws_api_gateway_method.test: Creation complete after 1s [id=agm-kvp9kg9jv6-hognbzcre0-GET]
aws_api_gateway_method_response.test: Creating...
aws_api_gateway_integration.test: Creating...
aws_api_gateway_method_response.test: Creation complete after 0s [id=agmr-kvp9kg9jv6-hognbzcre0-GET-200]
aws_api_gateway_integration_response.MyDemoIntegrationResponse: Creating...
aws_api_gateway_integration.test: Creation complete after 0s [id=agi-kvp9kg9jv6-hognbzcre0-GET]
aws_api_gateway_deployment.test: Creating...
.....
现在,客户端在哪里可以访问 API 网关的新 URL?您可以从terraform.tfstate文件中获取invoke_url,如下所示:
{
"mode": "managed",
"type": "aws_api_gateway_deployment",
"name": "test",
"provider": "provider.aws",
"instances": [
{
"schema_version": 0,
"attributes": {
......
"invoke_url":"https://kvp9kg9jv6.execute-api.eu-central-
1.amazonaws.com/test",
......
},
......
}
]
}
invoke_url是 API 网关端点。在发布 API 时,此端点应附加到自定义域名。如果您向前面的 URL 发出curl请求,您应该收到如下所示的 JSON 响应:
curl https://kvp9kg9jv6.execute-api.eu-central-1.amazonaws.com/test
{"ID":123,"ISBN":"0-201-03801-3","Author":"Donald Knuth","PublishedYear":"1968"}
这确认了所有组件都在正常工作,并且所有请求/响应都通过 API 网关路由。您可以定义许多这样的端点并配置组件以实现所需的行为。添加身份验证留作练习。
解决这个练习的技巧:尝试修改正确的组件来验证和授权客户端请求。
在下一节中,我们将提到其他重要的 API 网关。
其他 API 网关
市场上还有许多其他 API 网关提供商。正如我们之前提到的,所有网关都提供相同的功能集。类似于 AWS API 网关,Apigee 是另一个广为人知的 API 网关技术,它是 Google Cloud 的一部分。云提供商的问题在于它们可能导致供应商锁定(即,它们不能轻易迁移到另一个平台)。市场上有许多开源 API 网关。
选择 API 网关的正确方式取决于业务条件。如果 API 服务器位于 AWS 云上,AWS API 网关是一个不错的选择。对于能够自行管理网关的公司,尝试以下开源替代方案是值得的:
-
Kubernetes
-
Kong
-
Tyk
-
KrakenD
没有最佳选择,但如果工作负载不是很大,nginx 也可以用作 API 网关。有关更多详细信息,请参阅www.nginx.com/learn/api-gateway/。
Kubernetes (kubernetes.io/) 对于喜欢自己管理 API 网关的人来说是一个明智的选择。另外,使用 Kubernetes 的另一个好理由是它正在被广泛采用。
亚马逊还提供了弹性 Kubernetes 服务(EKS)来在不同的区域运行高可用集群。使用 EKS,API 网关作为已安装的组件包含在内。
摘要
在本章中,我们从 AWS 的基本操作开始。亚马逊提供了一个免费层,以便实验他们的云服务。一旦我们注册了免费层,我们应该能够访问 AWS 控制台并能够创建安全凭证。这些安全凭证是应用程序访问 AWS 所必需的。
我们然后看到了如何使用像 Terraform 这样的工具来配置云资源。我们选择了 AWS EC2 作为部署 API 的选择。我们编写了一个 Terraform 脚本来配置 EC2 实例,以及一个密钥对。这个密钥对是登录实例所必需的。
一旦我们能够登录到 EC2 实例,我们就安装了我们 API 服务器所需的全部依赖。我们重用了来自第十二章,容器化 REST 服务以进行部署的项目代码,其中我们准备了一个 API 生态系统。我们成功地将books API 从 EC2 实例部署出去。
一个简单的 API 服务器在客户端的速率限制、认证和授权方面功能较少。我们需要一个专门的 API 网关来将请求传递到 API 服务器。AWS 提供了一个名为Amazon API Gateway的托管网关解决方案。我们看到了 Amazon API 网关的架构,并使用 Terraform 在该网关上配置了一个 API。该架构有六个重要的组件,将在详细讨论。
最后,我们提到了市场上可用的其他网关解决方案。在下一章中,我们将详细讨论 API 认证模式,包括JSON Web Token(JWT)认证。
第十四章:处理我们的 REST 服务的认证
在本章中,我们将探讨表示状态转移(REST)API 认证模式。这些模式是基于会话的认证、JSON Web Tokens(JWT)和开放认证 2(OAuth 2.0)。我们将尝试利用 Gorilla 包的sessions库来创建基本会话。然后,我们将继续探讨高级 REST API 认证策略,如无状态 JWT。最后,我们将讨论 OAuth 2.0 认证模式以及 API 的安全方面。在前一章中,亚马逊网络服务(AWS)API 网关为我们处理了认证(使用身份和访问管理(IAM)角色)。如果没有 API 网关,我们如何保护我们的 API?您将在本章中找到答案。
在本章中,我们将涵盖以下主题:
-
简单认证是如何工作的
-
介绍 Postman,一个用于测试 REST API 的视觉客户端
-
使用 Redis 持久化客户端会话
-
介绍 JWT 和 OAuth 2.0
-
OAuth 2.0 工作流程中的 JWT
-
读者练习
-
API 的安全方面
技术要求
为了运行代码示例,以下软件应预先安装:
-
操作系统:Linux (Ubuntu 18.04)/Windows 10/Mac OS X >= 10.13
-
Go 稳定版本编译器 >= 1.13.5
-
Dep:Go >= 0.5.3 的依赖管理工具
-
Docker 版本 >= 18.09.2
您可以从github.com/PacktPublishing/Hands-On-Restful-Web-services-with-Go/tree/master/chapter14下载本章的代码。克隆代码,并使用chapter14目录中的代码示例。
简单认证是如何工作的
传统上,认证或简单认证与会话一起工作。流程开始如下。客户端使用用户凭据向服务器发送认证请求。服务器获取这些凭据并将它们与服务器上存储的凭据进行匹配。如果匹配成功,它将在响应中写入一个称为cookie的东西。这个 cookie 是一小段信息,客户端会将其传输给所有后续请求。现代网站正在被设计成单页应用程序(SPAs)。在这些网站上,静态资源如 HTML 和 JavaScript 文件最初是从内容分发网络(CDN)提供的,以渲染网页。之后,网页与应用服务器之间的通信仅通过 REST API/网络服务进行。
会话是一种记录特定时间段内用户通信的好方法。会话是一个概念,其中认证信息存储在 cookie 中。以下图表解释了基于会话的基本认证过程中发生了什么:

现在,让我们看看一个实际的方法。一个客户端(例如,浏览器)向服务器的登录 API发送请求。服务器会尝试与数据库核对那些凭证,如果凭证存在,就会在响应中写回一个cookie,表示该用户已认证。
cookie是一个在稍后时间由服务器消费的消息。当客户端收到响应时,它会将 cookie 本地存储。之后,客户端可以通过显示 cookie 作为通行证的关键来从服务器请求资源。
当客户端决定终止会话时,它会在服务器上调用注销 API。服务器在响应中销毁会话。对于每个登录/注销,这个过程都会重复。服务器还可以对 cookies 设置过期时间,这样在没有活动的情况下,认证窗口在一定时间内有效。这就是许多网站的工作方式。
现在,我们将尝试使用gorilla/sessions包实现这样一个系统。我们在第一章中已经学习了 gorilla/mux。首先,我们需要使用以下命令安装该包:
go get github.com/gorilla/mux
go get github.com/gorilla/sessions
或者,我们可以通过使用 Dep 工具来完成这项工作,如下所示:
dep init
dep ensure -add github.com/gorilla/mux
dep ensure -add github.com/gorilla/sessions
我们可以使用sessions.NewCookieStore方法从sessions包中创建一个新的会话,如下所示:
var store = sessions.NewCookieStore([]byte("secret_key"))
那个secret_key应该是gorilla/sessions用来加密会话 cookie 的密钥。如果我们以普通文本添加会话,任何人都可以读取它。因此,服务器需要将消息加密为随机字符串。为此,它要求提供秘密密钥。这个秘密密钥可以是任何随机生成的字符串。
将秘密密钥保存在代码中不是一个好主意,所以我们尝试将其存储为环境变量,并在代码中即时读取。在下一节中,我们将查看一个会话认证的示例。
一个简单的认证示例
让我们构建一个安全的 API,只有登录后才能让客户端访问。在这个过程中,我们将定义三个端点:
-
/login -
/logout -
/healthcheck
/healthcheck是数据 API,但首先必须使用/login端点进行登录。我们的 API 应该拒绝所有未经认证的请求。创建一个名为simpleAuth的项目目录,如下所示:
mkdir -p $GOPATH/src/github.com/git-user/chapter14/simpleAuth
touch $GOPATH/src/github.com/git-user/chapter14/simpleAuth/main.go
在程序中,我们可以看到如何使用 gorilla/sessions 包来启用基于会话的 API 端点认证。按照以下步骤操作:
- 我们需要为我们的程序导入一些内容。主要的是
mux和sessions,如下面的代码块所示:
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
)
- 现在,创建一个 cookie 存储来存储写入的 cookie 信息。我们可以使用
sessions.NewCookieStore方法来完成。它需要一个包含秘密密钥的字节序列。秘密密钥从SESSION_SECRET环境变量中获取,如下所示:
var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
您可以设置任何您想要的键到那个环境变量中。
- 由于我们没有注册机制,让我们创建一些模拟的
用户名和密码,如下所示:
var users = map[string]string{"naren": "passme", "admin": "password"}
这些模拟的用户名/密码组合将与客户端请求进行核对。
- 现在,添加一个设置 cookie 的登录处理程序。API 是一个带有在 POST 主体中提供的凭据的
POST请求,如下所示:
func LoginHandler(w http.ResponseWriter, r *http.Request) {
...
}
- 在这个函数中,我们首先应该解析 POST 主体,并获取
username和password,如下所示:
err := r.ParseForm()
if err != nil {
http.Error(w, "Please pass the data as URL form encoded",
http.StatusBadRequest)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
- 一旦我们收集了
username和password,我们的计划是使用模拟数据验证它们。如果凭据匹配,则将请求会话设置为已认证。否则,相应地显示错误消息,如下所示:
if originalPassword, ok := users[username]; ok {
session, _ := store.Get(r, "session.id")
if password == originalPassword {
session.Values["authenticated"] = true
session.Save(r, w)
} else {
http.Error(w, "Invalid Credentials", http.StatusUnauthorized)
return
}
} else {
http.Error(w, "User is not found", http.StatusNotFound)
return
}
w.Write([]byte("Logged In successfully"))
这样就完成了登录处理程序。
- 以类似的方式,让我们定义注销处理程序。注销处理程序接收一个进入的
GET请求并将会话变量 authenticated 设置为false,如下所示:
// LogoutHandler removes the session
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session.id")
session.Values["authenticated"] = false
session.Save(r, w)
w.Write([]byte(""))
}
- 如果您看到了注销处理程序的实现,那么我们已经修改了
session对象以使客户端会话无效,如下所示:
session, _ := store.Get(r, "session.id")
session.Values["authenticated"] = false
session.Save(r, w)
- 以这种方式,可以使用任何编程语言中的客户端会话来实现简单的身份验证,包括 Go。
不要忘记在修改后保存 cookie。这个操作的代码是 session.Save(r, w)。
- 现在,让我们定义我们的
/healthcheck数据 API。这是一个发送系统时间的 API。如果客户端会话已认证,则返回响应。否则,返回403 Forbidden响应。请求中的session对象可用于有效性检查,如下所示:
// HealthcheckHandler returns the date and time
func HealthcheckHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session.id")
if (session.Values["authenticated"] != nil) && session.Values
["authenticated"] != false {
w.Write([]byte(time.Now().String()))
} else {
http.Error(w, "Forbidden", http.StatusForbidden)
}
}
- 所有 API 处理程序都已就绪。我们必须编写
main函数,在该函数中将 API 端点(路由)映射到前面的处理程序。我们为此使用mux路由器。然后,我们将此路由器传递到在http://localhost:8000上运行的 HTTP 服务器,如下所示:
func main() {
r := mux.NewRouter()
r.HandleFunc("/login", LoginHandler)
r.HandleFunc("/healthcheck", HealthcheckHandler)
r.HandleFunc("/logout", LogoutHandler)
http.Handle("/", r)
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
// Good practice: enforce timeouts for servers you create!
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
- 这样就完成了受保护的应用程序。我们可以通过在
simpleAuth根目录下输入以下代码来运行程序:
go run main.go
这将在 http://localhost:8000 上启动服务器。
错误代码可以表示不同的含义。例如,当用户尝试未经认证访问资源时,会发出 Forbidden (403),而当给定的资源在服务器上不存在时,会发出 Resource Not Found (404)。
在下一节中,我们将介绍一个新的查询 API 的工具,称为 Postman。Postman 工具有一个很好的 用户界面(UI),并且运行在所有平台上。我们将使用这个新工具测试 simpleAuth 示例。
介绍 Postman,一个用于测试 REST API 的可视化客户端
Postman 是一个优秀的 UI 客户端,允许 Windows、Mac OS X 和 Linux 用户进行 HTTP API 请求。您可以从这里下载它:www.getpostman.com/product/api-client. 我们将使用 Postman 工具而不是使用 curl 来发送 API 请求。我们将从上一节中的 simpleAuth 示例中选择。请看以下步骤:
- 安装后,打开 Postman 工具,然后在“输入请求 URL”输入框中尝试一个 www.example.org URL。您可以从下拉菜单中选择请求类型(GET、POST 等)。对于每个请求,您都可以配置许多设置,如 Headers、POST 主体和其他细节,这些都可以在 URL 下作为菜单进行配置。玩转这些选项,并熟悉它们。
请查阅 Postman 文档以获取更多详细信息。它提供了多种重放 API 查询的选项。花些时间探索 learning.postman.com/getting-started/ 中的功能。
看看下面的截图:

构建器是顶级菜单项,我们可以在这里添加/编辑请求。前面的截图显示了空白的构建器,我们在这里尝试发送请求。
- 接下来,运行前面
simpleAuth项目中的main.go文件,并尝试调用/healthcheckAPI。点击“发送”按钮。您将看到响应是 403 禁止,如下面的截图所示:

这是因为我们还没有登录。Postman 在身份验证成功后自动保存 cookie。
- 现在,通过将方法类型从 GET 改为 POST,并将 URL 改为 http://localhost:8000/login,调用登录 API。我们应该以
multipart/form-data的形式传递凭证。我们应该看到以下内容:

- 点击蓝色“发送”按钮。这会向之前运行的服务器发送登录请求。它返回一条消息,表示登录成功。我们可以通过点击“Cookies”链接来检查 cookies,就在右侧的“保存”按钮旁边。
这显示了保存的 cookies 列表,您将在 localhost 网站找到一个名为 session.id 的 cookie。内容看起来像这样:
session.id=MTU3NzI4NTk2NXxEdi1CQkFFQ180SUFBUkFCRUFBQUpmLUNBQUVHYzNSeWFXNW5EQThBRFdGMWRHaGxiblJwWTJGMFpXUUVZbTl2YkFJQ0FBRT18Be0S-fIy6T7U-hnASBnPxLU2gFJ0jnAdaKWI6X04GPo=; path=/; domain=localhost; Expires=Fri, 24 Jan 2020 14:59:25 GMT;
- 再次尝试调用
/healthcheckAPI,该 API 返回系统日期和时间作为响应,如下所示:
2019-12-25 16:00:03.501678 +0100 CET m=+169.811215440
假设客户端向 logout API 发送另一个 GET 请求,如下所示:
http://localhost:8000/logout
如果发生这种情况,会话将被无效化,直到完成另一个登录请求,否则将禁止访问资源。
我们示例中使用的管理员和密码凭证仅用于说明目的,不应在生产环境中使用。
总是使用随机生成的强密码!
使用 Redis 持久化客户端会话
我们迄今为止创建的会话存储在程序内存中。这意味着如果程序崩溃或重启,所有已记录的会话都将丢失。它需要客户端再次登录以获取新的会话 cookie。这对审计 cookie 没有帮助。为了将会话存储在某个地方,我们可以使用 Redis。
我们在 第九章,异步 API 设计 中讨论了在 Docker 容器中运行 Redis。为了回顾,Redis 服务器存储键值对。它提供了基本的数据类型,如字符串、列表、散列、集合等。更多详情,请访问 redis.io/topics/data-types。
现在,是时候将我们的 Redis 知识付诸实践了。我们将修改我们的项目,从 simpleAuth 更改为 simpleAuthWithRedis。新的项目现在应该使用 Redis 作为会话存储。将前一个示例中的代码复制到新的项目中。
在 第九章,异步 API 设计 中,我们使用了 go-redis 包从 Go 程序与 Redis 服务器交互。在本章中,我们将介绍一个新的方便的包 redistore.v1,这样我们就可以在 Redis 中存储会话。
使用以下 dep 命令安装包:
dep init
dep ensure -add gopkg.in/boj/redistore.v1
创建一个新的程序,进行一些修改。在这里,我们不会使用 gorilla/sessions 包,而是使用 redistore 包。redistore 包有一个名为 NewRediStore 的函数,它接受 Redis 配置作为其参数,以及密钥。它返回与 gorilla/sessions 相同的会话对象。
代码的其他部分保持不变。为了简洁,我们只需做出以下更改:
package main
import (
...
redistore "gopkg.in/boj/redistore.v1"
)
var store, err = redistore.NewRediStore(10, "tcp", ":6379", "", []byte(os.Getenv("SESSION_SECRET")))
在注销处理程序中,你会看到以下代码:
session.Values["authenticated"] = false
将前面的代码更改为以下内容:
session.Options.MaxAge = -1
此步骤从 Redis 存储中删除密钥,相当于从客户端撤销认证。这个改进的程序与之前的程序非常相似,只是会话现在保存在 Redis 中。
运行新的程序,重复使用 Postman 工具中的 API 查询。一旦登录成功,在 Docker 容器中启动 redis-cli,如下所示:
docker exec -i -t some-redis redis-cli
some-redis 是运行容器的 Redis 服务器名称。现在,在 shell 中输入 KEYS * 命令以查看新存储的会话,如下所示:
127.0.0.1:6379> KEYS *
1) "session_VPJ54LWRE4DNTYCLEJWAUN5SDLVW6LN6MLB26W2OB4JDT26CR2GA"
127.0.0.1:6379>
长达 session_VPJ54LWRE4DNTYCLEJWAUN5SDLVW6LN6MLB26W2OB4JDT26CR2GA
key 是由 redistore 包存储的密钥。如果我们删除该密钥,客户端将无法访问 /healthcheck API。现在,停止运行程序并重新启动它。你会看到会话没有被丢失。这样,我们可以保存客户端会话。
Redis 可以作为你的 Web 应用程序的缓存服务。它可以存储临时数据,如会话、频繁请求的用户内容等。它通常与 memcached 相比。
在下一节中,我们将探讨一种新的认证方式,称为 JWT。它不同于会话,并且不会在客户端机器上存储任何 cookies。
介绍 JWT 和 OAuth2
现代的 REST API 实现基于令牌的认证。在这里,令牌可以是服务器生成的任何字符串,允许客户端通过生成令牌来访问资源。令牌的计算方式使得只有客户端和服务器知道如何编码/解码令牌。
之前的例子与基于会话的身份验证相关。这有一个限制,即通过在程序内存中保存会话或 Redis/SQLite3 来管理会话。JWT 采取不同的方法,创建了可以用于身份验证的令牌。
当Client将身份验证详细信息传递给Server时,服务器生成令牌并将其返回给Client。客户端将其保存在某种存储中,例如 AWS Secrets Manager、数据库或本地存储(在浏览器的情况下)。Client使用该令牌从服务器定义的任何 API 请求资源。这个过程可以在以下图中看到:

前面图中所显示的步骤可以更简要地总结如下:
-
Client在登录 API 的
POST请求中传递用户名/密码。 -
Server验证详细信息,如果成功,则生成JWT并将其返回而不是创建 cookie。存储此令牌是客户端的责任。
-
现在,Client拥有了 JWT。它需要将其添加到头部部分以进行后续的 REST API 调用。
-
Server检查来自头部的 JWT,如果成功解码,则服务器验证客户端。
JWT 确保数据是从正确的客户端发送的。创建令牌的技术负责处理这种逻辑。JWT 利用基于密钥的加密。
JWT 格式
我们在前面章节中讨论的每一件事都是关于传递 JWT 令牌。在本节中,我们将看到 JWT 的样子以及它是如何生成的。生成 JWT 的高级步骤如下:
-
通过对 JSON 头部进行
Base64Url编码来创建一个JWT头部。 -
通过对 JSON 有效载荷进行
Base64Url编码来创建一个JWT有效载荷。 -
通过使用密钥加密附加的头部和有效载荷来创建一个签名。
-
通过附加 JWT 头部、JWT 有效载荷和签名可以获得 JWT 字符串。
头部是一个简单的 JSON 对象。它看起来像以下 Go 代码片段:
{
"alg": "HS256",
"typ": "JWT"
}
HS256是用于创建签名的算法(HMAC 与 SHA-256)的简称。消息类型是JWT。这将是所有头部的常见格式。算法可能会根据系统而变化。
有效载荷看起来像这样:
{
"sub": "1234567890",
"username": "Indiana Jones",
"admin": true
}
有效载荷对象中的键称为声明。声明是一个键,它指定了对于服务器的一些特殊含义。有三种类型的声明:
-
保留声明
-
私有声明(更重要)
-
公共声明
我们将在接下来的章节中详细讨论这些声明。
保留声明
保留声明是由 JWT 标准定义的。它们如下:
-
iat: 发布时间
-
iss: 发布者名称
-
sub: 主题文本
-
aud: 受众名称
-
exp: 过期时间
例如,服务器在生成令牌时,可以在有效负载中设置一个 exp 声明。然后客户端使用该令牌来访问 API 资源。服务器每次都会验证令牌。当过期时间过去后,服务器将不再验证令牌。客户端需要再次登录以生成新的令牌。
私有声明
私有声明用于区分一个令牌与另一个令牌。它们可用于授权。授权是一个识别哪个客户端发出了请求的过程。多租户指的是多个客户端访问系统上的 API 的情况。服务器可以在令牌的有效负载上设置一个名为用户名的私有声明。下次,服务器可以读取这个有效负载并获取用户名,然后使用该用户名进行授权并定制 API 响应。这与 cookie 类似,但方式不同。
例如,username: Indiana Jones是以下有效负载中的私有声明:
{
"sub": "1234567890",
"username": "Indiana Jones",
"admin": true
}
公共声明与私有声明类似,但它们应该在 IANA JWT 注册表中注册,以使其成为标准。我们限制这些的使用。
可以通过执行此操作创建签名(这不是代码,只是一个说明):
signature = HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
这只是对 Base64Url 编码的头部和有效负载使用一个秘密进行加密算法。这个秘密可以是任何字符串。它与我们在上一个 cookie 会话中使用的秘密完全相同。这个秘密通常保存在环境变量中,并加载到程序中。
现在,我们将编码的头部、编码的有效负载和签名附加在一起,以获取我们的令牌字符串,如下所示:
tokenString = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature
这就是 JWT 令牌的生成方式。有几个 Go 包可以生成和验证 JWT。其中一个流行的包是jwt-go。在下一节中,我们将创建一个使用jwt-go来签名 JWT 并验证它的项目。我们可以使用以下dep命令安装此包:
dep ensure -add github.com/dgrijalva/jwt-go
这是项目的官方 GitHub 页面:github.com/dgrijalva/jwt-go。
包提供了一些函数,允许我们创建令牌。还有许多其他具有不同附加功能的包。您可以在jwt.io/#libraries-io查看所有可用的包和受支持的特性。
在 Go 中创建 JWT
jwt-go包有一个名为NewWithClaims的函数,它接受两个参数:
-
一种签名方法,如
HMAC256、RSA等 -
声明映射
例如,它看起来像以下代码片段:
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": "admin",
"iat":time.Now().Unix(),
})
jwt.SigningMethodHS256是包内可用的加密算法。第二个参数是一个包含声明,如私有(这里,用户名)和保留(发行 iat)的映射。现在,我们可以使用令牌上的SignedString函数生成tokenString,如下所示:
tokenString, err := token.SignedString("my_secret_key")
然后,应该将此tokenString作为成功登录响应的一部分发送回客户端。
在 Go 中读取 JWT
jwt-go 还为我们提供了解析给定 JWT 字符串的 API。Parse 函数接受一个字符串和一个键函数作为参数。键函数是一个自定义函数,用于验证算法是否真实。让我们假设这是一个由前面的编码生成的示例令牌字符串:
tokenString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoiMTUwODc0MTU5MTQ2NiJ9.5m6KkuQFCgyaGS_xcVy4xWakwDgtAG3ILGGTBgYVBmE"
我们可以使用以下代码解析并获取原始 JSON:
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// key function
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v",
token.Header["alg"])
}
return "my_secret_key", nil
})
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
// Use claims for authorization if token is valid
fmt.Println(claims["username"], claims["iat"])
} else {
fmt.Println(err)
}
token.Claims 是通过一个名为 MapClaims 的映射实现的。我们可以从该映射中获取原始 JSON 的键值对。
在下一节中,我们将讨论 OAuth 2.0 的工作流程以及认证和授权之间的区别。
JWT 在 OAuth2.0 的工作流程中
OAuth 2.0 是一个用于在不同系统之间创建认证模式的认证框架。在这里,客户端不是向资源服务器发出请求,而是向一个称为 资源所有者 的实体发出初始请求。这个资源所有者会向客户端发送认证授权(如果凭证已验证)。客户端现在将这个认证授权发送给另一个称为 认证服务器 的实体。认证服务器接收授权并返回访问令牌。访问令牌是一个
认证与授权的比较
认证 是识别客户端是否真实的过程。当服务器认证客户端时,它会检查用户名/密码对并创建一个会话 cookie/JWT。
授权 是评估用户是否有权访问给定资源的过程。在云服务中,应该有一种机制来限制某些用户/角色的资源访问范围,而授权正是实现这一点的手段。
简而言之,认证决定了一个服务的客户是谁,而授权决定了客户在资源访问方面的界限。
OAuth 2.0 是一个用于认证多个客户端到服务的协议,而 JWT 是一种令牌格式。我们需要对 JWT 令牌进行编码/解码以实现 OAuth 2.0 的第二阶段(以下图中虚线部分):

在前面的图中,虚线部分是客户端从 认证服务器 请求访问令牌的地方。这就是 JWT 生命周期开始的地方。
在下一节中,我们将提供一个开发练习供您完成。这个练习可以通过结合我们迄今为止学到的所有概念来完成。
练习
您能设计以下要求吗?
开发一个使用令牌认证的 /healthcheck API。其主要职责应如下:
-
认证客户端并返回 JWT 字符串
-
通过验证 JWT 授权客户端 API 请求
您应该使用上一节中关于 jwt-go 包的知识。您必须构建两个端点,如下所示:
-
/getToken -
/healthcheck
第一个端点应该成功登录客户端并返回 JWT 令牌。然后客户端应使用带有令牌的第二个端点来接收成功响应。
开发完成后,最终的 API 测试场景应该看起来类似于以下内容:
- 如果你向
/healthcheckAPI 发出没有令牌的GET请求,你应该收到一个访问被拒绝的消息,如下所示:
Access Denied; Please check the access token
- 你应该能够通过
POST请求从 API 服务器进行认证并获取 JWT 令牌,如下面的截图所示:

- 使用返回的 JWT,你现在可以成功地向
/healthcheck发出带有 access_token 头的GET请求,如下所示:

如果你能够实现前面的要求,你已经对 JWT 有了很好的理解。如果没有,不要担心。挑战的解决方案可以在chapter14/jwtauth项目目录中找到。
基于令牌的认证通常不提供退出 API 或删除基于会话认证提供的令牌的 API。只要 JWT 没有过期,服务器就会将授权资源提供给客户端。一旦它过期,客户端就需要刷新令牌——也就是说,请求服务器提供一个新的令牌。
在下一节中,我们将讨论一些确保 API 安全的技巧。
API 的安全方面
每个开发的 REST API 都可以是公开的或受保护的。公开 API 对请求资源的客户端数量没有限制。但大多数商业 API 都是受保护的。那么,关于安全性,我们应该注意哪些重要事项?在以下列表中,我们将指出所有应该注意的因素,以确保 REST API 的安全:
-
总是使用 HTTPS 来提供 API 的传输层安全性(TLS)。
-
使用用户访问令牌来限制 API 的速率。
-
在 API 上设计各种认证和授权角色。
-
当客户端和服务器内部时,使用公钥/私钥加密来签名 JWT。
-
永远不要将用户凭证存储在明文文件中。
-
清理 URL 查询参数;使用 POST 体来处理传入的请求。
-
正如我们在上一章中提到的,使用 API 网关以获得更好的性能和保护。
-
使用云服务,如 AWS Secrets Manager 来存储密钥/密码。
大多数现代 API 都在云提供商上作为容器/虚拟专用服务器(VPS)运行。那里的安全性可以在两个级别上应用:
-
网络级别
-
应用程序级别
在云上开发和部署应用程序的 API 开发者应该意识到上述级别。了解安全漏洞的应对措施和对 API 进行修补是您将需要的核心安全技能。
此外,暴露的 REST API 是攻击如拒绝服务(DoS)攻击的最容易受害的目标。在防火墙服务后面部署 API 可以增加对这些攻击的安全性。在内部 API 的情况下,公司服务仅与其他内部服务通信,但来自不同的地理区域,VPN 是理想的选择。
安全性是其自身领域的猛兽,需要在 API 开发的各个方面进行仔细监控。
摘要
在本章中,我们介绍了身份验证的过程。我们看到了身份验证通常是如何工作的。身份验证可以是三种类型之一:基本身份验证、基于会话的身份验证或基于令牌的身份验证。基本身份验证要求每个 HTTP 请求都提供 用户名 和 密码。基于会话的身份验证使用保存的会话来验证客户端。
存储在程序内存中的会话一旦网络服务器崩溃/重启就会丢失。可以使用名为 redistore 的软件包与 Redis 一起使用,以帮助存储会话 cookie。
接下来,我们学习了 JWT(基于令牌的认证),客户端从服务器请求 JWT 令牌。一旦客户端拥有 JWT 令牌,它可以在请求 API 资源时将令牌传递到 HTTP 头部。
然后,我们介绍了 OAuth 2.0,这是一个身份验证框架。在那里,我们看到了客户端如何从资源所有者请求授权。一旦获得授权,它就会从身份验证服务器请求访问令牌。在从身份验证服务器获得访问令牌后,客户端可以使用该令牌来请求 API。
我们使用 curl 和 Postman 测试了所有我们的 API。Postman 是一款出色的工具,它可以帮助我们在任何机器上快速测试我们的 API,而 curl 则仅限于 Linux 和 Mac OS X。
通过学习如何创建 HTTP 路由、中间件和处理程序,我们从第一章走了很长的路。然后我们探索了 API 的各种 SQL 和 NoSQL 存储后端。在基础知识之后,我们探讨了性能调优方面,如异步设计、GraphQL API 和微服务。最后,我们学习了如何将我们的网络服务部署到云端,并通过启用身份验证来确保它们的安全。


浙公网安备 33010602011771号