构建-Go-REST-Web-服务(全)

构建 Go REST Web 服务(全)

原文:zh.annas-archive.org/md5/57EDF27484D8AB35B253814EEB7E5A77

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

最初,基于 SOAP 的 Web 服务因 XML 而变得更受欢迎。然后,自 2012 年以来,REST 加快了步伐,并完全取代了 SOAP。新一代的 Web 语言,如 Python、JavaScript(Node.js)和 Go,展示了与传统的 ASP.NET 和 Spring 等相比,不同的 Web 开发方法。自本十年以来,由于其速度和直观性,Go 变得越来越受欢迎。少量冗长的代码、严格的类型检查和对并发的支持使 Go 成为编写任何 Web 后端的更好选择。一些最好的工具,如 Docker 和 Kubernetes,都是用 Go 编写的。谷歌在日常活动中大量使用 Go。您可以在github.com/golang/go/wiki/GoUsers上看到使用 Go 的公司列表。

对于任何互联网公司,Web 开发部门至关重要。公司积累的数据需要以 API 或 Web 服务的形式提供给客户。各种客户端(浏览器、移动应用程序和服务器)每天都会使用 API。REST 是一种定义资源消耗形式的架构模式。

Go 是一个更好的编写 Web 服务器的语言。作为中级 Go 开发人员,了解如何使用语言中提供的构造创建 RESTful 服务是其责任。一旦掌握了基础知识,开发人员应该学习其他内容,如测试、优化和部署服务。本书旨在使读者能够舒适地开发 Web 服务。

专家认为,在不久的将来,随着 Python 进入数据科学领域并与 R 竞争,Go 可能会成为与 NodeJS 竞争的 Web 开发领域的唯一选择语言。本书不是一本食谱。然而,在您的旅程中,它提供了许多技巧和窍门。通过本书,读者最终将能够通过大量示例舒适地进行 REST API 开发。他们还将了解到最新的实践,如协议缓冲区/gRPC/API 网关,这将使他们的知识提升到下一个水平。

本书涵盖内容

第一章,“开始 REST API 开发”,讨论了 REST 架构和动词的基本原理。

第二章,“为我们的 REST 服务处理路由”,描述了如何为我们的 API 添加路由。

第三章,“使用中间件和 RPC”,讲述了如何使用中间件处理程序和基本的 RPC。

第四章,“使用流行的 Go 框架简化 RESTful 服务”,介绍了使用框架进行快速原型设计 API。

第五章,“使用 MongoDB 和 Go 创建 REST API”,解释了如何将 MongoDB 用作我们 API 的数据库。

第六章,“使用协议缓冲区和 gRPC”,展示了如何使用协议缓冲区和 gRPC 来获得比 HTTP/JSON 更高的性能提升。

第七章,“使用 PostgreSQL、JSON 和 Go”,解释了使用 PostgreSQL 和 JSON 存储创建 API 的好处。

第八章,“在 Go 中构建 REST API 客户端和单元测试”,介绍了在 Go 中构建客户端软件和使用单元测试进行 API 测试的技术。

第九章,“使用微服务扩展我们的 REST API”,讲述了如何使用 Go Kit 将我们的 API 服务拆分为微服务。

第十章,“部署我们的 REST 服务”,展示了如何使用 Nginx 部署服务,并使用 supervisord 进行监控。

第十一章,“使用 API 网关监控和度量 REST API”,解释了如何通过在 API 网关后添加多个 API 来使我们的服务达到生产级别。

第十二章,“为我们的 REST 服务处理身份验证”,讨论了如何使用基本身份验证和 JSON Web Tokens(JWT)保护我们的 API。

本书所需内容

对于这本书,您需要一台安装了 Linux(Ubuntu 16.04)、macOS X 或 Windows 的笔记本电脑/个人电脑。我们将使用 Go 1.8+作为我们的编译器版本,并安装许多第三方软件包,因此需要一个可用的互联网连接。

我们还将在最后的章节中使用 Docker 来解释 API 网关的概念。建议使用 Docker V17.0+。如果 Windows 用户在本书中的任何示例中遇到原生 Go 安装的问题,请使用 Docker for Windows 并运行 Ubuntu 容器,这样会更灵活;有关更多详细信息,请参阅www.docker.com/docker-windows

在深入阅读本书之前,请在tour.golang.org/welcome/1上复习您的语言基础知识。

尽管这些是基本要求,但我们将在必要时为您安装指导。

这本书适合谁

这本书适用于所有熟悉 Go 语言并希望学习 REST API 开发的开发人员。即使是资深工程师也可以享受这本书,因为它涵盖了许多尖端概念,如微服务、协议缓冲区和 gRPC。

已经熟悉 REST 概念并从其他平台(如 Python 和 Ruby)进入 Go 世界的开发人员也可以受益匪浅。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"将前面的程序命名为basicHandler.go。"

代码块设置如下:

{
 "ID": 1,
 "DriverName": "Menaka",
 "OperatingStatus": true
 }

任何命令行输入或输出都以以下形式编写:

go run customMux.go

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:"它返回消息,说成功登录。"

警告或重要说明会以这样的形式出现在一个框中。

提示和技巧会出现在这样的形式。

第一章:开始使用 REST API 开发

Web 服务是在不同计算机系统之间定义的通信机制。没有 Web 服务,自定义的点对点通信变得繁琐且特定于平台。这就像是网络需要理解和解释的一百种不同的东西。如果计算机系统与网络易于理解的协议相一致,那将是一个很大的帮助。

Web 服务是一种旨在支持网络上可互操作的机器对机器交互的软件系统,万维网联盟W3C),www.w3.org/TR/ws-arch/

现在,简单来说,Web 服务是两个端点之间的通路,消息可以顺利传输。在这里,这种传输通常是单向的。两个独立的可编程实体也可以通过它们自己的 API 相互通信。两个人通过语言进行交流。两个应用程序通过应用程序编程接口API)进行通信。

读者可能会想知道,在当前数字世界中 API 的重要性是什么?物联网IoT)的兴起使 API 的使用比以往更加重要。对 API 的认识日益增长,每天都有数百个 API 在全球各地被开发和记录。一些重要的大型企业正在看到作为服务的 APIAAAS)的未来。一个明显的例子是亚马逊网络服务AWS)。它在云世界取得了巨大的成功。开发人员使用 AWS 提供的 REST API 编写自己的应用程序。

一些更隐秘的用例来自像 Ibibo 和 Expedia 这样的旅行网站,它们通过调用第三方网关和数据供应商的 API 来获取实时价格。如今,Web 服务通常会收费。

本章将涵盖的主题包括:

  • 可用的不同 Web 服务

  • 详细介绍表现状态转移(REST)架构

  • 介绍使用 REST 构建单页应用程序(SPA)

  • 设置 Go 项目并运行开发服务器

  • 为查找罗马数字构建我们的第一个服务

  • 使用 Gulp 自动编译 Go 代码

Web 服务的类型

随着时间的推移,出现了许多类型的 Web 服务。其中一些主要的是:

  • SOAP

  • UDDI

  • WSDL

  • REST

在这些中,SOAP在 2000 年代初变得流行,当时 XML 处于风口浪尖。各种分布式系统使用 XML 数据格式进行通信。SOAP 的实现过于复杂。SOAP 的批评者指出了 SOAP HTTP 请求的臃肿。

SOAP 请求通常由以下三个基本组件组成:

  • 信封

  • 头部

  • 主体

仅仅执行一个 HTTP 请求和响应周期,我们就必须在 SOAP 中附加大量额外的数据。一个示例 SOAP 请求如下:

POST /StockQuote HTTP/1.1
Host: www.stockquoteserver.com
Content-Type: text/xml; charset="utf-8"
Content-Length: nnnn
SOAPAction: "Some-URI"

<SOAP-ENV:Envelope
  xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
   <SOAP-ENV:Body>
       <m:GetLastTradePrice >
           <symbol>DIS</symbol>
       </m:GetLastTradePrice>
   </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

这是来自 W3C 标准的 SOAP 的标准示例(www.w3.org/TR/2000/NOTE-SOAP-20000508/)。如果我们仔细观察,它是以 XML 格式呈现的,其中特殊标签指定了信封和主体。由于 XML 操作需要大量的命名空间来运行,额外的信息也会起作用。

REST API

表现状态转移REST)这个名字是由加利福尼亚大学的 Roy Fielding 创造的。与 SOAP 相比,它是一个非常简化和轻量级的 Web 服务。性能、可伸缩性、简单性、可移植性和可修改性是 REST 设计的主要原则。

REST API 允许不同的系统以非常简单的方式进行通信和发送/接收数据。每个 REST API 调用都与 HTTP 动词和 URL 之间存在关系。应用程序中的数据库资源可以与 REST 中的 API 端点进行映射。

当您在手机上使用移动应用时,您的手机可能会秘密地与许多云服务进行通信,以检索、更新或删除您的数据。REST 服务对我们的日常生活有着巨大的影响。

REST 是一个无状态、可缓存的、简单的架构,不是协议而是一种模式。

REST 服务的特点

这些是使 REST 简单且与其前身相比独特的主要特性:

  • 基于客户端-服务器的架构: 这种架构对于现代 Web 通过 HTTP 进行通信至关重要。单个客户端-服务器最初看起来可能很天真,但许多混合架构正在发展。我们将很快讨论更多这些内容。

  • 无状态: 这是 REST 服务最重要的特点。REST HTTP 请求包含服务器理解和返回响应所需的所有数据。一旦请求被处理,服务器就不会记住请求是否在一段时间后到达。因此,操作将是无状态的。

  • 可缓存: 许多开发人员认为技术堆栈阻碍了他们的 Web 应用程序或 API。但实际上,他们的架构才是原因。数据库可以成为 Web 应用程序中的潜在调优部分。为了很好地扩展应用程序,我们需要缓存内容并将其作为响应交付。如果缓存无效,我们有责任清除它。REST 服务应该被适当地缓存以进行扩展。

  • 按需脚本: 您是否曾经设计过一个 REST 服务,该服务提供 JavaScript 文件并在运行时执行它们?这种按需代码也是 REST 可以提供的主要特点。从服务器请求脚本和数据更为常见。

  • 多层系统: REST API 可以由多个服务器提供。一个服务器可以请求另一个服务器,依此类推。因此,当客户端发出请求时,请求和响应可以在多个服务器之间传递,最终向客户端提供响应。这种易于实现的多层系统对于保持 Web 应用程序松散耦合始终是一个良好的策略。

  • 资源的表示: REST API 提供了统一的接口进行通信。它使用统一资源标识符(URI)来映射资源(数据)。它还具有请求特定数据格式作为响应的优势。互联网媒体类型(MIME 类型)可以告诉服务器请求的资源是特定类型的。

  • 实现自由: REST 只是定义 Web 服务的一种机制。它是一种可以以多种方式实现的架构风格。由于这种灵活性,您可以按照自己的意愿创建 REST 服务。只要遵循 REST 的原则,您的服务器就有自由选择平台或技术。

周到的缓存对于 REST 服务的扩展至关重要。

REST 动词和状态码

REST 动词指定要在特定资源或资源集合上执行的操作。当客户端发出请求时,应在 HTTP 请求中发送此信息:

  • REST 动词

  • 头信息

  • 正文(可选)

正如我们之前提到的,REST 使用 URI 来解码其要处理的资源。有许多 REST 动词可用,但其中六个经常被使用。它们如下:

  • GET

  • POST

  • PUT

  • PATCH

  • DELETE

  • OPTIONS

如果您是软件开发人员,您将大部分时间处理这六个。以下表格解释了操作、目标资源以及请求成功或失败时会发生什么:

REST 动词 操作 成功 失败
GET 从服务器获取记录或资源集 200 404
OPTIONS 获取所有可用的 REST 操作 200 -
POST 创建新的资源集或资源 201 404, 409
PUT 更新或替换给定的记录 200, 204 404
PATCH 修改给定的记录 200, 204 404
DELETE 删除给定的资源 200 404

前表中成功失败列中的数字是 HTTP 状态码。每当客户端发起 REST 操作时,由于 REST 是无状态的,客户端应该知道如何找出操作是否成功。因此,HTTP 为响应定义了状态码。REST 为给定操作定义了前面的状态码类型。这意味着 REST API 应严格遵循前面的规则,以实现客户端-服务器通信。

所有定义的 REST 服务都具有以下格式。它由主机和 API 端点组成。API 端点是服务器预定义的 URL 路径。每个 REST 请求都应该命中该路径。

一个微不足道的 REST API URI:http://HostName/API endpoint/Query(optional)

让我们更详细地看一下所有的动词。REST API 设计始于操作和 API 端点的定义。在实现 API 之前,设计文档应列出给定资源的所有端点。在接下来的部分中,我们将使用 PayPal 的 REST API 作为一个用例,仔细观察 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,你也可以查询资源列表,而不是像前面的例子那样查询单个资源。PayPal 的用于获取与协议相关的结算交易的 API 可以通过/v1/payments/billing-agreements/transactions获取。这一行获取了在该结算协议上发生的所有交易。在这两种情况下,数据以 JSON 响应的形式检索。响应格式应该事先设计好,以便客户端可以在协议中使用它。

查询参数的示例如下:

  • 查询参数旨在添加详细信息,以从服务器识别资源。例如,以这个虚构的 API 为例。假设这个 API 是为了获取、创建和更新书籍的详细信息而创建的。基于查询参数的GET请求将采用这种格式:
 /v1/books/?category=fiction&publish_date=2017
  • 前面的 URI 有一些查询参数。该 URI 请求一本满足以下条件的书籍:

  • 它应该是一本虚构的书

  • 这本书应该在 2017 年出版

获取所有在 2017 年出版的虚构书籍是客户端向服务器提出的问题。

Path vs Query 参数——何时使用它们?一个常见的经验法则是,Query 参数用于基于查询参数获取多个资源。如果客户端需要具有精确 URI 信息的单个资源,可以使用 Path 参数来指定资源。例如,用户仪表板可以使用 Path 参数请求,并且可以使用 Query 参数对过滤数据进行建模。

GET 请求中,对于单个资源使用 Path 参数,对于多个资源使用 Query 参数。

POST、PUT 和 PATCH

POST 方法用于在服务器上创建资源。在之前的书籍 API 中,此操作使用给定的详细信息创建新书籍。成功的 POST 操作返回 201 状态码。POST 请求可以更新多个资源:/v1/books

POST 请求的主体如下:

{"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

带有如下 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 的书籍。然后添加/修改此列的给定值

PUTPATCH 都对成功返回 200 状态,对未找到返回 404。

DELETE 和 OPTIONS

DELETE API 方法用于从数据库中删除资源。它类似于 PUT,但没有任何主体。它只需要资源的 ID 来删除。一旦资源被删除,后续的 GET 请求会返回 404 未找到状态。

对这种方法的响应不可缓存(如果实现了缓存),因为 DELETE 方法是幂等的。

OPTIONS API 方法是 API 开发中最被低估的。给定资源,该方法尝试了解服务器上定义的所有可能的方法(GETPOST等)。这就像在餐厅看菜单然后点菜一样(而如果你随机点一道菜,服务员会告诉你这道菜没有了)。在服务器上实现 OPTIONS 方法是最佳实践。从客户端确保首先调用 OPTIONS,如果该方法可用,然后继续进行。

跨域资源共享(CORS)

这个 OPTIONS 方法最重要的应用是跨域资源共享CORS)。最初,浏览器安全性阻止客户端进行跨域请求。这意味着使用 URL www.foo.com 加载的站点只能对该主机进行 API 调用。如果客户端代码需要从 www.bar.com 请求文件或数据,那么第二个服务器 bar.com 应该有一种机制来识别 foo.com 以获取其资源。

这个过程解释了 CORS:

  1. foo.combar.com 上请求 OPTIONS 方法。

  2. bar.com 在响应客户端时发送了一个头部,如 Access-Control-Allow-Origin: http://foo.com

  3. 接下来,foo.com可以访问bar.com上的资源,而不受任何限制,调用任何REST方法。

如果bar.com感觉在一次初始请求后向任何主机提供资源,它可以将访问控制设置为*(即任何)。

以下是描述依次发生的过程的图表:

状态代码的类型

有几个状态代码家族。每个家族都全局解释了一个操作状态。该家族的每个成员可能有更深层的含义。因此,REST API 应该严格告诉客户端操作后到底发生了什么。有 60 多种状态代码可用。但对于 REST,我们集中在几个代码家族上。

2xx 家族(成功)

200 和 201 属于成功家族。它们表示操作成功。纯200操作成功)是成功的 CRUD 操作:

  • 200操作成功)是 REST 中最常见的响应状态代码

  • 201创建成功)当POST操作成功在服务器上创建资源时返回

  • 204无内容)在客户端需要状态但不需要任何数据时发出

3xx 家族(重定向)

这些状态代码用于传达重定向消息。最重要的是301304

  • 301在资源永久移动到新的 URL 端点时发出。当旧的 API 被弃用时,这是必不可少的。它返回响应中的新端点和 301 状态。通过查看这一点,客户端应该使用新的 URL 以响应实现其目标。

  • 304状态代码表示内容已缓存,并且服务器上的资源未发生修改。这有助于在客户端缓存内容,并且仅在缓存被修改时请求数据。

4xx 家族(客户端错误)

这些是客户端需要解释和处理进一步操作的标准错误状态代码。这与服务器无关。错误的请求格式或格式不正确的 REST 方法可能会导致这些错误。其中,API 开发人员最常用的状态代码是400401403404405

  • 400错误请求)当服务器无法理解客户端请求时返回。

  • 401未经授权)当客户端未在标头中发送授权信息时返回。

  • 403禁止)当客户端无法访问某种类型的资源时返回。

  • 404未找到)当客户端请求的资源不存在时返回。

  • 405方法不允许)如果服务器禁止资源上的一些方法,则返回。GETHEAD是例外。

5xx 家族(服务器错误)

这些是来自服务器的错误。客户端请求可能是完美的,但由于服务器代码中的错误,这些错误可能会出现。常用的状态代码有500501502503504

  • 500内部服务器错误)状态代码给出了由一些错误的代码或一些意外条件引起的开发错误

  • 501未实现)当服务器不再支持资源上的方法时返回

  • 502错误网关)当服务器本身从另一个服务供应商那里收到错误响应时返回

  • 503服务不可用)当服务器由于多种原因而关闭,如负载过重或维护时返回

  • 504网关超时)当服务器等待另一个供应商的响应时间过长,并且为客户端提供服务的时间太长时返回

有关状态代码的更多详细信息,请访问此链接:developer.mozilla.org/en-US/docs/Web/HTTP/Status

REST API 与单页应用的崛起

您需要了解为什么单页应用程序SPA)是当今的热门话题。这些 SPA 设计使开发人员以一种完全不同的方式编写代码,而不是以传统方式构建 UI(请求网页)。有许多 MVC 框架,如 AngularJS、Angular2、React JS、Knockout JS、Aurelia 等,可以快速开发 Web UI,但它们的本质都非常简单。所有 MVC 框架都帮助我们实现一种设计模式。这种设计模式是不请求网页,只使用 REST API

自 2010 年以来,现代 Web 前端开发已经取得了很大进步。为了利用Model-View-ControllerMVC)架构的特性,我们需要将前端视为一个独立的实体,只使用 REST API(最好是 REST JSON)与后端进行通信。

SPA 中的旧和新数据流的方式

所有网站都经历以下步骤:

  1. 从服务器请求网页。

  2. 验证并显示仪表板 UI。

  3. 允许用户进行修改和保存。

  4. 根据需要从服务器请求尽可能多的网页,以在站点上显示单独的页面。

但在 SPA 中,流程完全不同:

  1. 一次性向浏览器请求 HTML 模板。

  2. 然后,查询 JSON REST API 以填充模型(数据对象)。

  3. 根据模型(JSON)中的数据调整 UI。

  4. 当用户修改 UI 时,模型(数据对象)应该自动更改。例如,在 AngularJS 中,可以通过双向数据绑定实现。最后,可以随时进行 REST API 调用,通知服务器进行更改。

这样,通信只以 REST API 的形式进行。客户端负责逻辑地表示数据。这导致系统从响应导向架构ROA)转移到服务导向架构SOA)。请看下面的图表:

SPA 减少了带宽,并提高了站点的性能。

为什么选择 Go 进行 REST API 开发?

REST 服务在现代网络中是微不足道的。SOA(我们稍后会更详细地讨论)为 REST 服务创造了一个活动空间,将 Web 开发推向了一个新的水平。Go是谷歌公司推出的一种编程语言,用于解决他们所面临的更大的问题。自首次出现以来已经过去了八年多。它随着开发者社区的加入而不断成熟,并在其中创建了大规模的系统。

Go 是 Web 的宠儿。它以一种简单的方式解决了更大的问题。

人们可以选择 Python 或 JavaScript(Node)进行 REST API 开发。Go 的主要优势在于其速度和编译时错误检测。通过各种基准测试,Go 被证明在计算性能方面比动态编程语言更快。这就是公司应该使用 Go 编写其下一个 API 的三个原因:

  • 为了扩展 API 以吸引更广泛的受众

  • 为了使您的开发人员能够构建健壮的系统

  • 为了投资未来项目的可行性

您可以查看关于 Go 的 REST 服务的不断进行的在线辩论以获取更多信息。在后面的章节中,我们将尝试构建设计和编写 REST 服务的基础知识。

设置项目并运行开发服务器

这是一本系列构建的书。它假设您已经了解 Go 的基础知识。如果没有,也没关系。您可以从 Go 的官方网站golang.org/快速入门并快速学习。Go 使用一种不同的开发项目的方式。编写一个独立的简单程序不会让您感到困扰。但是在学习了基础知识之后,人们会尝试进一步发展。因此,作为 Go 开发人员,您应该了解 Go 项目的布局方式以及保持代码清晰的最佳实践。

在继续之前,请确保已完成以下工作:

  • 在您的计算机上安装 Go 编译器

  • 设置GOROOTGOPATH环境变量

有许多在线参考资料可以了解到前面的细节。根据你的机器类型(Windows、Linux 或 macOS X),设置一个可用的 Go 编译器。我们将在下一节中看到有关GOPATH的更多细节。

解密 GOPATH

GOPATH只是你的机器上当前指定的工作空间。它是一个环境变量,告诉 Go 编译器你的源代码、二进制文件和包的位置。

来自 Python 背景的程序员可能知道 Virtualenv 工具,可以同时创建多个项目(使用不同的 Python 解释器版本)。但在某个时间点,只能激活一个环境并开发自己的项目。同样,你可以在你的机器上有任意数量的 Go 项目。在开发时,将GOPATH设置为你的一个项目。Go 编译器现在激活了该项目。

在家目录下创建一个项目并设置GOPATH环境变量是一种常见的做法,就像这样:

>mkdir /home/naren/myproject
export GOPATH=/home/naren/myproject

现在我们这样安装外部包:

go get -u -v github.com/gorilla/mux

Go 将名为mux的项目复制到当前激活的项目myproject中。

对于 Go get,使用-u标志来安装外部包的更新依赖项,使用-v来查看安装的详细信息。

一个典型的 Go 项目具有以下结构,正如官方 Go 网站上所述:

让我们在深入研究之前先了解这个结构:

  • bin:存储我们项目的可运行二进制文件

  • pkg:包含包对象的目录;一个提供包方法的编译程序

  • src:项目源代码、测试和用户包的位置

在 Go 中,你导入到你的主程序中的所有包都有一个相同的结构,github.com/user/project。但是谁创建所有这些目录?开发者需要做吗?不需要。开发者的责任是为他/她的项目创建目录。这意味着他/她只创建src/github.com/user/hello目录。

当开发者运行以下命令时,如果之前不存在,将创建binpackage目录。.bin包含我们项目源代码的二进制文件,.pkg包含我们在 Go 程序中使用的所有内部和外部包:

 go install github.com/user/project

构建我们的第一个服务-查找罗马数字

有了我们到目前为止建立的概念,让我们编写我们的第一个基本 REST 服务。这个服务从客户端获取数字范围(1-10),并返回其罗马字符串。非常原始,但比 Hello World 好。

设计:

我们的 REST API 应该从客户端获取一个整数,并返回罗马数字等价物。

API 设计文档的块可能是这样的:

HTTP 动词 路径 操作 资源
GET /roman_number/2 显示 roman_number

实施:

现在我们将逐步实现前面的简单 API。

该项目的代码可在github.com/narenaryan/gorestful上找到。

正如我们之前讨论的,你应该首先设置GOPATH。假设GOPATH/home/naren/go。在以下路径中创建一个名为romanserver的目录。用你的 GitHub 用户名替换narenaryan(这只是属于不同用户的代码的命名空间):

mkdir -p $GOPATH/src/github.com/narenaryan/romanserver

我们的项目已经准备好了。我们还没有配置任何数据库。创建一个名为main.go的空文件:

touch $GOPATH/src/github.com/narenaryan/romanserver/main.go

我们的 API 服务器的主要逻辑放在这个文件中。现在,我们可以创建一个作为我们主程序的数据服务的数据文件。再创建一个目录来打包罗马数字数据:

mkdir $GOPATH/src/github.com/narenaryan/romanNumerals

现在,在romanNumerals目录中创建一个名为data.go的空文件。到目前为止,src目录结构看起来是这样的:

现在让我们开始向文件添加代码。为罗马数字创建数据:

// data.go
package romanNumerals

var Numerals = map[int]string{
  10: "X",
  9: "IX",
  8: "VIII",
  7: "VII",
  6: "VI",
  5: "V",
  4: "IV",
  3: "III",
  2: "II",
  1: "I",
}

我们正在创建一个名为Numerals的映射。这个映射保存了将给定整数转换为其罗马等价物的信息。我们将把这个变量导入到我们的主程序中,以便为客户端的请求提供服务。

打开main.go并添加以下代码:

// main.go
package main

import (
   "fmt"
   "github.com/narenaryan/romanNumerals"
   "html"
   "net/http"
   "strconv"
   "strings"
   "time"
)

func main() {
   // http package has methods for dealing with requests
   http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
       urlPathElements := strings.Split(r.URL.Path, "/")
       // If request is GET with correct syntax
       if urlPathElements[1] == "roman_number" {
           number, _ := strconv.Atoi(strings.TrimSpace(urlPathElements[2]))
           if number == 0 || number > 10 {
           // If resource is not in the list, send Not Found status
               w.WriteHeader(http.StatusNotFound)
               w.Write([]byte("404 - Not Found"))
           } else {
             fmt.Fprintf(w, "%q", html.EscapeString(romanNumerals.Numerals[number]))
           }
       } else {
           // For all other requests, tell that Client sent a bad request
           w.WriteHeader(http.StatusBadRequest)
           w.Write([]byte("400 - Bad request"))
       }
   })
 // Create a server and run it on 8000 port
   s := &http.Server{
     Addr: ":8000",
     ReadTimeout: 10 * time.Second,
     WriteTimeout: 10 * time.Second,
     MaxHeaderBytes: 1 << 20,
   }
   s.ListenAndServe()
}

始终使用 Go fmt 工具格式化你的 Go 代码。

用法示例:go fmt github.com/narenaryan/romanserver

现在,使用 Go 命令install安装这个项目:

go install github.com/narenaryan/romanserver

这一步做了两件事:

  • 编译包romanNumerals并将副本放在$GOPATH/pkg目录中

  • 将二进制文件放入$GOPATH/bin

我们可以像这样运行前面的 API 服务器:

$GOPATH/bin/romanserver

服务器正在http://localhost:8000上运行。现在我们可以使用像浏览器CURL命令这样的客户端发出GET请求到 API。让我们用一个合适的 APIGET请求来发出一个CURL命令。

请求一如下:

curl -X GET "http://localhost:8000/roman_number/5" # Valid request

响应如下:

HTTP/1.1 200 OK
Date: Sun, 07 May 2017 11:24:32 GMT
Content-Length: 3
Content-Type: text/plain; charset=utf-8

"V"

让我们尝试一些格式不正确的请求。

请求二如下:

curl -X GET "http://localhost:8000/roman_number/12" # Resource out of range

响应如下:

HTTP/1.1 404 Not Found
Date: Sun, 07 May 2017 11:22:38 GMT
Content-Length: 15
Content-Type: text/plain; charset=utf-8

404 - Not Found

请求三如下:

curl -X GET "http://localhost:8000/random_resource/3" # Invalid resource

响应如下:

"HTTP/1.1 400 Bad request
Date: Sun, 07 May 2017 11:22:38 GMT
Content-Length: 15
Content-Type: text/plain; charset=utf-8
400 - Bad request

我们的小罗马数字 API 正在做正确的事情。正确的状态码正在被返回。这是所有 API 开发者应该牢记的要点。客户端应该被告知为什么出了问题。

代码分解

我们一次性更新了空文件并启动了服务器。现在让我解释一下main.go文件的每一部分:

  • 导入了一些包。github.com/narenaryan/romanNumerals是我们之前创建的数据服务。

  • net/http是我们用来处理 HTTP 请求的核心包,通过它的HandleFunc函数。该函数的参数是http.Requesthttp.ResponseWriter。这两个处理 HTTP 请求的请求和响应。

  • r.URL.Path是 HTTP 请求的 URL 路径。对于 CURL 请求,它是/roman_number/5。我们正在拆分这个路径,并使用第二个参数作为资源,第三个参数作为值来获取罗马数字。Split函数在一个名为strings的核心包中。

  • Atoi函数将字母数字字符串转换为整数。为了使用数字映射,我们需要将整数字符串转换为整数。Atoi函数来自一个名为strconv的核心包。

  • 我们使用http.StatusXXX来设置响应头的状态码。WriteHeaderWrite函数可用于在响应对象上分别写入头部和正文。

  • 接下来,我们使用&http创建了一个 HTTP 服务器,同时初始化了一些参数,如地址、端口、超时等。

  • time包用于在程序中定义秒。它说,在 10 秒的不活动后,自动向客户端返回 408 请求超时。

  • EscapeString将特殊字符转义为有效的 HTML 字符。例如,Fran & Freddie's 变成了Fran &amp; Freddie's&#34

  • 最后,使用ListenAndServe函数启动服务器。它会一直运行你的 Web 服务器,直到你关闭它。

应该为 API 编写单元测试。在接下来的章节中,我们将看到如何对 API 进行端到端测试。

使用 supervisord 和 Gulp 实时重新加载应用程序

Gulp 是一个用于创建工作流的好工具。工作流是一个逐步的过程。它只是一个任务流程应用程序。你需要在你的机器上安装 NPM 和 Node。我们使用 Gulp 来监视文件,然后更新二进制文件并重新启动 API 服务器。听起来很酷,对吧?

监督程序是一个在应用程序被杀死时重新加载服务器的应用程序。一个进程 ID 将被分配给你的服务器。为了正确重新启动应用程序,我们需要杀死现有的实例并重新启动应用程序。我们可以用 Go 编写一个这样的程序。但为了不重复造轮子,我们使用一个叫做 supervisord 的流行程序。

使用 supervisord 监控你的 Go Web 服务器

有时,您的 Web 应用程序可能会因操作系统重新启动或崩溃而停止。每当您的 Web 服务器被终止时,supervisor 的工作就是将其重新启动。即使系统重新启动也无法将您的 Web 服务器从客户端中移除。因此,请严格使用 supervisord 来监控您的应用程序。

安装 supervisord

我们可以使用apt-get命令在 Ubuntu 16.04 上轻松安装 supervisord:

sudo apt-get install -y supervisor

这将安装两个工具,supervisorsupervisorctlsupervisorctl用于控制 supervisord 并添加任务,重新启动任务等。

在 macOS X 上,我们可以使用brew命令安装supervisor

brew install supervisor

现在,在以下位置创建一个配置文件:

/etc/supervisor/conf.d/goproject.conf

您可以添加任意数量的配置文件,supervisord 将它们视为独立的进程来运行。将以下内容添加到之前的文件中:

[supervisord]
logfile = /tmp/supervisord.log
[program:myserver]
command=$GOPATH/bin/romanserver
autostart=true
autorestart=true
redirect_stderr=true

默认情况下,我们在/etc/supervisor/目录下有一个名为.supervisord.conf的文件。查看它以获取更多参考信息。在 macOS X 中,相同的文件将位于/usr/local/etc/supervisord.ini

关于之前的配置:

  • [supervisord]部分告诉 supervisord 的日志文件位置

  • [program:myserver]是任务块,它遍历到给定目录并执行给定的命令

现在我们可以要求我们的supervisorctl重新读取配置并重新启动任务(进程)。只需说:

  • supervisorctl reread

  • supervisorctl update

然后,使用以下命令启动supervisorctl

supervisorctl

您将看到类似于这样的内容:

supervisorctl是一个用于控制 supervisor 程序的强大工具。

由于我们在 supervisor 配置文件中将我们的 romanserver 命名为myserver,我们可以从supervisorctl启动,停止和重新启动该程序。

使用 Gulp 创建自动代码编译和服务器重新加载

在我们之前的章节中对 Gulp 进行了简要介绍,我们将编写一个 gulpfile 来告诉计算机执行一些任务。

我使用npm安装 Gulp 和 Gulp-shell:

npm install gulp gulp-shell

之后,在项目的根目录中创建一个gulpfile.js。这里是github.com/src/narenaryan/romanserver。现在将以下内容添加到gulpfile.js。首先,每当文件更改时,将执行安装二进制任务。然后,supervisor 将被重新启动。监视任务会查找任何文件更改并执行之前的任务。我们还对任务进行排序,以便它们按顺序同步执行。所有这些任务都是 Gulp 任务,并且可以通过gulp.task函数定义。它接受两个参数,任务名称和任务。sell.task允许 Gulp 执行系统命令:

var gulp = require("gulp");
var shell = require('gulp-shell');

// This compiles new binary with source change
gulp.task("install-binary", shell.task([
 'go install github.com/narenaryan/romanserver'
]));

// Second argument tells install-binary is a deapendency for restart-supervisor
gulp.task("restart-supervisor", ["install-binary"], shell.task([
 'supervisorctl restart myserver'
]))

gulp.task('watch', function() {
 // Watch the source code for all changes
 gulp.watch("*", ['install-binary', 'restart-supervisor']);

});

gulp.task('default', ['watch']);

现在,如果在source目录中运行gulp命令,它将开始监视您的源代码更改:

gulp

现在,如果我们修改了代码,那么代码会被编译,安装,并且服务器会立即重新启动:

理解 gulpfile

在 gulpfile 中,我们执行以下指令:

  1. 导入 Gulp 和 Gulp-shell。

  2. 使用shell.task创建任务作为执行函数。

  3. shell.task可以执行命令行指令。将你的 shell 命令放在该函数内。

  4. 为监视源文件添加一个监视任务。当文件被修改时,任务列表将被执行。

  5. 为运行创建一个默认任务。为其添加一个监视。

Gulp 是这类用例的绝佳工具。因此,请仔细阅读 Gulp 的官方文档gulpjs.com/

总结

在本章中,我们介绍了 REST API。我们看到 REST 不是一个协议,而是一种架构模式。HTTP 是我们可以实现 REST 服务的实际协议。我们深入了解了 REST API 的基本原理,以便清楚地了解它们实际上是什么。然后我们探讨了 Web 服务的类型。在 REST 之前,我们有一个叫做 SOAP 的东西,它使用 XML 作为数据格式。REST 使用 JSON 作为主要格式。REST 有动词和状态码。我们了解了给定状态码指的是什么。我们构建了一个简单的服务,为给定的数字提供罗马数字。在这个过程中,我们还看到了如何打包一个 Go 项目。我们了解了 GOPATH 环境变量。它是 Go 中定义变量的工作空间。所有的包和项目都驻留在这个路径中。然后我们看到了如何使用 supervisord 和 Gulp 来实时重新加载开发项目。这些都是 Node 工具,但可以帮助我们保持我们的 Go 项目正常运行。

在下一章中,我们将深入研究 URL 路由。从内置路由器开始,我们将探索 Gorilla Mux,一个强大的 URL 路由库。

第二章:处理我们的 REST 服务的路由

在本章中,我们将讨论应用程序的路由。为了创建一个 API,第一步是定义路由。因此,为了定义路由,我们需要找出 Go 中可用的构造。我们从 Go 中的基本内部路由机制开始。然后,我们看看如何创建一个自定义的多路复用器。由于 ServeMux 的功能非常有限,我们将探索一些其他用于此目的的框架。本章还包括使用第三方库(如httprouterGorilla Mux)创建路由。我们将在整本书中构建一个 URL 缩短的 API。在本章中,我们为 API 定义路由。然后,我们讨论诸如 URL 的 SQL 注入之类的主题。Web 框架允许开发人员首先创建一个路由,然后将处理程序附加到它上。这些处理程序包含应用程序的业务逻辑。本章的关键是教会您如何使用Gorilla Mux在 Go 中创建 HTTP 路由。我们还讨论 URL 缩短服务的功能,并尝试设计一个逻辑实现。

我们将涵盖以下主题:

  • 在 Go 中构建一个基本的 Web 服务器

  • 理解 net/http 包

  • ServeMux,在 Go 中的基本路由器

  • 理解 httprouter,一个路由器包

  • 介绍 Gorilla Mux,一个强大的 HTTP 路由器

  • 介绍 URL 缩短服务设计

获取代码

您可以从github.com/narenaryan/gorestful/tree/master/chapter2下载本章的代码。欢迎添加评论和拉取请求。克隆代码并在chapter2目录中使用代码示例。

理解 Go 的 net/http 包

Go 的net/http包处理 HTTP 客户端和服务器的实现。在这里,我们主要关注服务器的实现。让我们创建一个名为basicHandler.go的小型 Go 程序,定义路由和一个函数处理程序:

package main
import (
    "io"
    "net/http"
    "log"
)
// hello world, the web server
func MyServer(w http.ResponseWriter, req *http.Request) {
    io.WriteString(w, "hello, world!\n")
}
func main() {
    http.HandleFunc("/hello", MyServer)
    log.Fatal(http.ListenAndServe(":8000", nil))
}

这段代码做了以下几件事情:

  1. 创建一个名为/hello的路由。

  2. 创建一个名为MyServer的处理程序。

  3. 每当请求到达路由(/hello)时,处理程序函数将被执行。

  4. 向响应中写入hello, world

  5. 在端口8000上启动服务器。如果出现问题,ListenAndServe将返回error。因此,使用log.Fatal记录它。

  6. http包有一个名为HandleFunc的函数,使用它可以将 URL 映射到一个函数。

  7. 这里,w是一个响应写入器。ResponseWriter接口被 HTTP 处理程序用来构造 HTTP 响应。

  8. req是一个请求对象,处理 HTTP 请求的所有属性和方法。

使用日志功能来调试潜在的错误。如果有错误,ListenAndServe函数会返回一个错误。

运行代码

我们可以将上述代码作为一个独立的程序运行。将上述程序命名为basicHandler.go。将其存储在任何您希望的位置,然后使用以下命令运行它:

go run basicHandler.go

现在打开一个 shell 或浏览器来查看服务器的运行情况。在这里,我使用 CURL 请求:

curl -X GET http://localhost:8000/hello

响应是:

hello, world

Go 有一个处理请求和响应的不同概念。我们使用io库来写入响应。对于 Web 开发,我们可以使用模板自动填充细节。Go 的内部 URL 处理程序使用 ServeMux 多路复用器。

ServeMux,在 Go 中的基本路由器

ServeMux 是一个 HTTP 请求多路复用器。我们在前面的部分中使用的HandleFunc实际上是 ServeMux 的一个方法。通过创建一个新的 ServeMux,我们可以处理多个路由。在此之前,我们还可以创建自己的多路复用器。多路复用器只是处理将路由与名为ServeHTTP的函数分离的逻辑。因此,如果我们创建一个具有ServeHTTP方法的新结构,它就可以完成这项工作。

将路由视为字典(映射)中的键,然后将处理程序视为其值。路由器从路由中找到处理程序,并尝试执行ServeHTTP函数。让我们创建一个名为customMux.go的程序,并看看这个实现的效果:

package main
import (
    "fmt"
    "math/rand"
    "net/http"
)
// CustomServeMux is a struct which can be a multiplexer
type CustomServeMux struct {
}
// This is the function handler to be overridden
func (p *CustomServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path == "/" {
        giveRandom(w, r)
        return
    }
    http.NotFound(w, r)
    return
}
func giveRandom(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Your random number is: %f", rand.Float64())
}
func main() {
    // Any struct that has serveHTTP function can be a multiplexer
    mux := &CustomServeMux{}
    http.ListenAndServe(":8000", mux)
}

在这段代码中,我们创建了一个名为CustomServeMux的自定义结构,它将负责我们的路由。我们实现了一个名为ServeHTTP的函数,以便捕获请求并向其写入响应。通常使用fmt包来创建字符串。Fprinf将提供的参数组合成字符串。

在主函数中,我们创建了一个CustomServeMux的实例,并将其传递给httpListenAndServe函数。"math/rand"是负责生成随机数的库。当我们讨论向 API 服务器添加身份验证时,这个基本的基础将对我们有所帮助。

运行代码

让我们发出一个 CURL 请求并查看各种路由的响应:

go run customMux.go

现在,打开一个 shell 或浏览器来查看服务器的运行情况。在这里,我使用 CURL 请求:

curl -X GET http://localhost:8000/

响应是:

Your random number is: 0.096970

使用Ctrl + CCmd + C来停止您的 Go 服务器。如果您将其作为后台进程运行,请使用pgrep go来查找processID,然后使用kill pid来杀死它。

使用 ServeMux 添加多个处理程序

我们创建的前面的自定义 Mux 在具有不同功能的不同端点时可能会很麻烦。为了添加该逻辑,我们需要添加许多if/else条件来手动检查 URL 路由。我们可以实例化一个新的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))
})

这段代码显示了如何创建一个 ServerMux 并将多个处理程序附加到它上。randomFloatrandomInt是我们为返回一个随机float和随机int创建的两个路由。现在我们可以将这个传递给ListenAndServe函数。Intn(100)从 0-100 的范围内返回一个随机整数。有关随机函数的更多详细信息,请访问golang.org上的 Go 随机包页面。

http.ListenAndServe(":8000", newMux)

完整的代码如下:

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 customMux.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 请求路由到特定的处理程序。与基本路由器相比,它具有以下特点:

  • 允许在路由路径中使用变量

  • 它匹配 REST 方法(GETPOSTPUT等)

  • 不会影响性能

我们将在下一节中更详细地讨论这些特性。在那之前,有一些值得注意的点,使 httprouter 成为一个更好的 URL 路由器:

  • httprouter 与内置的http.Handler很好地配合

  • httprouter 明确表示一个请求只能匹配一个路由或没有

  • 路由器的设计鼓励构建合理的、分层的 RESTful API

  • 您可以构建高效的静态文件服务器

安装

要安装 httprouter,我们只需要运行get命令:

go get github.com/julienschmidt/httprouter

所以,现在我们有了httprouter。我们可以在我们的源代码中引用这个库:

import "github.com/julienschmidt/httprouter"

通过一个例子可以理解 httprouter 的基本用法。在这个例子中,让我们创建一个小型 API,从服务器获取有关文件和程序安装的信息。在直接进入程序之前,您应该知道如何在 Go 上执行系统命令。有一个叫做os/exec的包。它允许我们执行系统命令并将输出返回给程序。

import "os/exec"

然后它可以在代码中被访问为这样:

// arguments... means an array of strings unpacked as arguments in Go
cmd := exec.Command(command, arguments...)

exec.Command是一个接受命令和额外参数数组的函数。额外的参数是命令的选项或输入。它可以通过两种方式执行:

  • 立即运行命令

  • 启动并等待其完成

我们可以通过将Stdout附加到自定义字符串来收集命令的输出。获取该字符串并将其发送回客户端。代码在这里更有意义。让我们编写一个 Go 程序来创建一个 REST 服务,它可以做两件事:

  • 获取 Go 版本

  • 获取给定文件的文件内容

这个程序使用Hhttprouter创建服务。让我们将其命名为execService.go

package main
import (
        "bytes"
        "fmt"
        "log"
        "net/http"
        "os/exec"
        "github.com/julienschmidt/httprouter"
)
// This is a function to execute a system command and return output
func getCommandOutput(command string, arguments ...string) string {
        // args... unpacks arguments array into elements
        cmd := exec.Command(command, arguments...)
        var out bytes.Buffer
        var stderr bytes.Buffer
        cmd.Stdout = &out
        cmd.Stderr = &stderr
        err := cmd.Start()
        if err != nil {
                log.Fatal(fmt.Sprint(err) + ": " + stderr.String())
        }
        err = cmd.Wait()
        if err != nil {
                log.Fatal(fmt.Sprint(err) + ": " + stderr.String())
        }
        return out.String()
}
func goVersion(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
        fmt.Fprintf(w, getCommandOutput("/usr/local/bin/go", "version"))
}
func getFileContent(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
        fmt.Fprintf(w, getCommandOutput("/bin/cat",
params.ByName("name")))
}
func main() {
        router := httprouter.New()
        // Mapping to methods is possible with HttpRouter
        router.GET("/api/v1/go-version", goVersion)
        // Path variable called name used here
        router.GET("/api/v1/show-file/:name", getFileContent)
        log.Fatal(http.ListenAndServe(":8000", router))
}

程序解释

前面的程序试图使用httprouter实现 REST 服务。我们在这里定义了两个路由:

  • /api/v1/go-version

  • /api/v1/show-file/:name

这里的:name是路径参数。它表示显示名为 xyz 的文件的 API。基本的 Go 路由器无法处理这些参数,通过使用httprouter,我们还可以匹配 REST 方法。在程序中,我们匹配了GET请求。

在一个逐步的过程中,前面的程序:

  • 导入了httprouter和其他必要的 Go 包

  • 使用httprouterNew()方法创建了一个新的路由器

  • 路由器有GETPOSTDELETE等方法

  • GET方法接受两个参数,URL 路径表达式处理程序函数

  • 这个路由器可以传递给 http 的ListenAndServe函数

  • 现在,谈到处理程序,它们看起来与属于 ServeMux 的处理程序相似,但第三个参数称为httprouter.Params保存有关使用GET请求提供的所有参数的信息

  • 我们定义了路径参数(URL 路径中的变量)称为name并在程序中使用它

  • getCommandOutput函数接受命令和参数并返回输出

  • 第一个 API 调用 Go 版本并将输出返回给客户端

  • 第二个 API 执行了文件的cat命令并将其返回给客户端

如果您观察代码,我使用了/usr/local/bin/go作为 Go 可执行文件位置,因为这是我 MacBook 上的 Go 编译器位置。在执行exec.Command时,您应该给出可执行文件的绝对路径。因此,如果您在 Ubuntu 机器或 Windows 上工作,请使用可执行文件的路径。在 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 execService.go

第一个GET请求的输出如下:

curl -X GET http://localhost:8000/api/v1/go-version

结果将是这样的:

go version go1.8.3 darwin/amd64

第二个GET请求请求Greek.txt是:

curl -X GET http://localhost:8000/api/v1/show-file/greek.txt

现在,我们将看到希腊语的文件输出如下:

Οἱ δὲ Φοίνιϰες οὗτοι οἱ σὺν Κάδμῳ ἀπιϰόμενοι.. ἐσήγαγον διδασϰάλια ἐς τοὺς ῞Ελληνας ϰαὶ δὴ ϰαὶ γράμματα, οὐϰ ἐόντα πρὶν ῞Ελλησι ὡς ἐμοὶ δοϰέειν, πρῶτα μὲν τοῖσι ϰαὶ ἅπαντες χρέωνται Φοίνιϰες· μετὰ δὲ χρόνου προβαίνοντος ἅμα τῇ ϕωνῇ μετέβαλον ϰαὶ τὸν ϱυϑμὸν τῶν γραμμάτων. Περιοίϰεον δέ σϕεας τὰ πολλὰ τῶν χώρων τοῦτον τὸν χρόνον ῾Ελλήνων ῎Ιωνες· οἳ παραλαβόντες διδαχῇ παρὰ τῶν Φοινίϰων τὰ γράμματα, μεταρρυϑμίσαντές σϕεων ὀλίγα ἐχρέωντο, χρεώμενοι δὲ ἐϕάτισαν, ὥσπερ ϰαὶ τὸ δίϰαιον ἔϕερε ἐσαγαγόντων Φοινίϰων ἐς τὴν ῾Ελλάδα, ϕοινιϰήια ϰεϰλῆσϑαι.

在几分钟内构建简单的静态文件服务器

有时,作为 API 的一部分,我们应该提供静态文件。httprouter 的另一个应用是构建可扩展的文件服务器。这意味着我们可以构建自己的内容传递平台。一些客户端需要从服务器获取静态文件。传统上,我们使用 Apache2 或 Nginx 来实现这一目的。但是,从 Go 服务器内部,为了提供静态文件,我们需要通过类似这样的通用路由进行路由:

/static/*

请参阅以下代码片段以了解我们的实现。想法是使用http.Dir方法加载文件系统,然后使用httprouter实例的**ServeFiles 函数。它应该提供给定公共目录中的所有文件。通常,静态文件保存在 Linux 机器上的文件夹/var/public/www **中。由于我使用的是 OS X,我在我的主目录中创建了一个名为static的文件夹:

mkdir /users/naren/static

现在,我复制了我们为上一个示例创建的Latin.txtGreek.txt文件到之前的静态目录。在这样做之后,让我们为文件服务器编写程序。您会对httprouter的简单性感到惊讶。创建一个名为fileserver.go的程序:

package main
import (
    "github.com/julienschmidt/httprouter"
    "log"
    "net/http"
)
func main() {
    router := httprouter.New()
    // Mapping to methods is possible with HttpRouter
    router.ServeFiles("/static/*filepath",
http.Dir("/Users/naren/static"))
    log.Fatal(http.ListenAndServe(":8000", router))
}

现在运行服务器并查看输出:

go run fileserver.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.

介绍 Gorilla Mux,一个强大的 HTTP 路由器

Mux 代表多路复用器。同样,Gorilla Mux 是一个设计用于将 HTTP 路由(URL)多路复用到不同处理程序的多路复用器。处理程序是可以处理给定请求的函数。Gorilla Mux 是一个非常好的包,用于为我们的 Web 应用程序和 API 服务器编写美丽的路由。

Gorilla Mux 提供了大量选项来控制路由到您的 Web 应用程序的方式。它允许许多功能。其中一些是:

  • 基于路径的匹配

  • 基于查询的匹配

  • 基于域的匹配

  • 基于子域的匹配

  • 反向 URL 生成

安装

安装 Mux 包非常简单。您需要在终端(Mac 和 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

正如我们在上一章中讨论的,这意味着所有的包和程序都放在这个目录中。它有三个文件夹:binpkgsrc。现在,将GOPATH添加到PATH变量中,以便使用已安装的 bin 文件作为没有./executable样式的系统实用程序。参考以下命令:

PATH="$GOPATH/bin:$PATH"

这些设置会一直保留,直到您关闭计算机。因此,要使其成为永久更改,请将上述行添加到您的 bash 配置文件中:

vi ~/.profile
(or)
vi ~/.zshrc 

现在,我们已经准备好了。假设 Gorilla Mux 已安装,请继续进行基本操作。

Gorilla Mux 的基础知识

Gorilla Mux 允许我们创建一个新的路由器,类似于 httprouter。但是在两者之间,将处理程序函数附加到给定的 URL 路由的方式是不同的。如果我们观察一下,Mux 附加处理程序的方式类似于基本 ServeMux。与 httprouter 不同,它修改请求对象而不是使用附加参数将 URL 参数传递给处理程序函数。我们可以使用Vars方法访问参数。

我将从 Gorilla Mux 主页上的一个示例来解释它有多有用。创建一个名为muxRouter.go的文件,并添加以下代码:

package main
import (
    "fmt"
    "log"
    "net/http"
    "time"
    "github.com/gorilla/mux"
)
// ArticleHandler is a function handler
func ArticleHandler(w http.ResponseWriter, r *http.Request) {
    // mux.Vars returns all path parameters as a map
    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() {
    // Create a new router
    r := mux.NewRouter()
    // Attach a path with handler
    r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)
    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())
}

现在使用以下命令运行文件:

go run muxRouter.go

通过以这种方式运行 CURL 命令,我们可以得到以下输出:

curl http://localhost:8000/articles/books/123
Category is: books
ID is: 123

Mux 解析路径中的变量。通过调用Vars函数,可以使用解析的所有变量。不要陷入上述程序的自定义服务器细节中。只需观察 Mux 代码。我们将处理程序附加到 URL。我们将解析的变量写回 HTTP 响应。这一行很关键。在这里,id有一个正则表达式,表示id是一个数字(0-9),有一个或多个数字:

r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)

让我们称之为路由。有了这种模式匹配的灵活性,我们可以非常舒适地设计 RESTful API。

使用http.StatusOK写入响应的标头,以宣布 API 请求成功。同样,http 有许多状态代码,用于各种类型的 HTTP 请求。使用适当的状态代码传达正确的消息。例如,404 - 未找到,500 - 服务器错误,等等。

反向映射 URL

简单地说,反向映射 URL 就是获取 API 资源的 URL。当我们需要分享链接到我们的 Web 应用程序或 API 时,反向映射非常有用。但是为了从数据中创建 URL,我们应该将Name与 Mux 路由关联起来:

r.HandlerFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler).
  Name("articleRoute")

现在,如果我们有数据,我们可以形成一个 URL:

url, err := r.Get("articleRoute").URL("category", "books", "id", "123")
fmt.Printf(url.URL) // prints /articles/books/123

Gorilla Mux 在创建自定义路由方面提供了很大的灵活性。它还允许方法链接以向创建的路由添加属性。

自定义路径

我们可以分两步定义前面的路由:

  • 首先,在路由器上定义路径:
      r := mux.NewRouter()
  • 接下来,在路由器上定义处理程序:
      r.Path("/articles/{category}/{id:[0-  9]+}").HandlerFunc(ArticleHandler) //chaining is possible

请注意,此处链接的方法是HandlerFunc而不是前面代码中显示的HandleFunc。我们可以使用Subrouter在 Mux 中轻松创建顶级路径并为不同的处理程序添加子路径:

r := mux.NewRouter()
s := r.PathPrefix("/articles").Subrouter()
s.HandleFunc("{id}/settings", settingsHandler)
s.HandleFunc("{id}/details", detailsHandler)

因此,形式为http://localhost:8000/articles/123/settings的所有 URL 将重定向到settingsHandler,形式为http://localhost:8000/articles/123/details的所有 URL 将重定向到detailsHandler。当我们为特定 URL 路径创建命名空间时,这可能非常有用。

路径前缀

路径前缀是在定义路径之后进行匹配的通配符。一般用例是当我们从静态文件夹中提供文件并且所有 URL 都应该按原样提供时。从官方 Mux 文档中,我们可以用它来提供静态文件。这是使用httprouter在前面的程序中创建的静态文件服务器的 Mux 版本:

r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("/tmp/static"))))

这可以提供目录中的所有类型的文件:

http://localhost:8000/static/js/jquery.min.js 

Strict Slash

Strict Slash 是 Mux 路由器上的一个参数,可以让路由器将带有尾随斜杠的 URL 路由重定向到没有尾随斜杠的路由。例如,/articles/可以是原始路径,但带有/path的路由将被重定向到原始路径:

r := mux.NewRouter() 
r.StrictSlash(true)
r.Path("/articles/").Handler(ArticleHandler)

如果将StrictSlash参数设置为true,此 URL 将重定向到前面的ArticleHandler

http://localhost:8000/articles

编码路径

我们可以从一些客户端获取编码路径。为了处理这些编码路径,Mux 提供了一个名为UseEncodedPath的方法。如果我们在路由器变量上调用此方法,甚至可以匹配编码的 URL 路由并将其转发给给定的处理程序:

r := NewRouter() 
r.UseEncodedPath()
r.NewRoute().Path("/category/id")

这可以匹配 URL:

http://localhost:8000/books/1%2F2

%2F代表未编码形式中的/。如果不使用UseEncodedPath方法,路由器可能会将其理解为/v1/1/2

基于查询的匹配

查询参数是与 URL 一起传递的参数。这是我们通常在 REST GET请求中看到的。Gorilla Mux 可以创建一个路由,用于匹配具有给定查询参数的 URL:

http://localhost:8000/articles/?id=123&category=books

让我们给我们的程序添加功能:

// Add this in your main program
r := mux.NewRouter()
r.HandleFunc("/articles", QueryHandler)
r.Queries("id", "category")

它限制了前面 URL 的查询。idcategoryQueries列表匹配。参数允许为空值。QueryHandler如下所示。您可以使用request.URL.Query()在处理程序函数中获取查询参数:

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"])
  fmt.Fprintf(w, "Got parameter category:%s!", queryParams["category"])
}

基于主机的匹配

有时我们需要允许来自特定主机的请求。如果主机匹配,则请求将继续传递到路由处理程序。如果我们有多个域和子域并将它们与自定义路由匹配,这可能非常有用。

使用路由器变量上的Host方法,我们可以调节从哪些主机重定向路由:

r := mux.NewRouter()
r.Host("aaa.bbb.ccc")
r.HandleFunc("/id1/id2/id3", MyHandler)

如果我们设置了这个,来自aaa.bbb.ccc主机的形式为http://aaa.bbb.ccc/111/222/333的所有请求将被匹配。类似地,我们可以使用Schemes来调节 HTTP 方案(http,https)和使用Methods Mux 函数来调节 REST 方法(GETPOST)。程序queryParameters.go解释了如何在处理程序中使用查询参数:

package main
import (
    "fmt"
    "log"
    "net/http"
    "time"
    "github.com/gorilla/mux"
)
func QueryHandler(w http.ResponseWriter, r *http.Request) {
    // Fetch query parameters as a map
    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])
}
func main() {
    // Create a new router
    r := mux.NewRouter()
    // Attach a path with handler
    r.HandleFunc("/articles", QueryHandler)
    r.Queries("id", "category")
    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())
}

输出如下:

go run queryParameters.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包将字符串转换为整数,反之亦然。

其模式匹配功能和简单性使 Gorilla Mux 成为项目中 HTTP 路由器的热门选择。全球许多成功的项目已经在其路由需求中使用 Mux。

URL 中的 SQL 注入及避免它们的方法

SQL 注入是使用恶意脚本攻击数据库的过程。如果我们在编写安全的 URL 路由时不小心,可能会存在 SQL 注入的机会。这些攻击通常发生在POSTPUTDELETE HTTP 动词中。例如,如果我们允许客户端向服务器传递变量,那么攻击者有机会向这些变量附加一个字符串。如果我们直接将这些发送参数的用户插入到 SQL 查询中,那么它可能是可注入的。与数据库交谈的正确方式是允许驱动程序函数在插入字符串并在数据库中执行之前检查参数:

username := r.Form.Get("id")
password := r.Form.Get("category")
sql := "SELECT * FROM article WHERE id='" + username + "' AND category='" + password + "'"
Db.Exec(sql)

在这个片段中,我们试图通过 id 和类别获取有关文章的信息。我们正在执行一个 SQL 查询。但由于我们直接附加值,我们可能在查询中包含恶意的 SQL 语句,如(--)注释和(ORDER BY n)范围子句:

?category=books&id=10 ORDER BY 10--

这将泄漏表中的列信息。我们可以更改数字并查看我们从数据库收到错误消息的断点:

Unknown column '10' in 'order clause'

我们将在接下来的章节中了解更多信息,我们将在其中使用其他方法构建完整的 REST 服务,如POSTPUT等:

现在,如何避免这些注入。有几种方法:

  • 将用户级别权限设置为各种表

  • 在使用 URL 参数时,仔细观察模式

  • 使用 Go 的text/template包中的HTMLEscapeString函数来转义 API 参数中的特殊字符,如bodypath

  • 使用驱动程序代替执行原始 SQL 查询

  • 停止数据库调试消息传回客户端

  • 使用sqlmap等安全工具查找漏洞

为 URL 缩短服务创建基本的 API 布局

您是否曾经想过 URL 缩短服务是如何工作的?它们将一个非常长的 URL 转换为一个缩短、简洁和易记的 URL 提供给用户。乍一看,它看起来像魔术,但实际上是一个简单的数学技巧。

简而言之,URL 缩短服务建立在两个基础上:

  • 一种字符串映射算法,将长字符串映射到短字符串(Base 62)

  • 一个简单的 Web 服务器,将短 URL 重定向到原始 URL

URL 缩短有一些明显的优势:

  • 用户可以记住 URL;易于维护

  • 用户可以在文本长度有限的链接上使用,例如 Twitter

  • 可预测的缩短 URL 长度

看一下下面的图表:

在 URL 缩短服务中,这些事情在幕后默默发生:

  • 获取原始 URL。

  • 对其应用 Base62 编码。它会生成一个缩短的 URL。

  • 将该 URL 存储在数据库中。将其映射到原始 URL([shortened_url: orignial_url])。

  • 每当请求到达缩短的 URL 时,只需对原始 URL 进行 HTTP 重定向。

我们将在接下来的章节中实现完整的逻辑,当我们将数据库集成到我们的 API 服务器时,但在那之前,我们应该指定 API 设计文档。让我们来做。看一下下表:

URL REST 动词 动作 成功 失败
/api/v1/new POST 创建缩短的 URL 200 500, 404
/api/v1/:url GET 重定向到原始 URL 301 404

作为练习,读者可以根据我们迄今为止建立的基础来实现这一点。您可以使用一个虚拟的 JSON 文件,而不是像我们在第一章中所做的那样使用数据库。无论如何,我们将在接下来的章节中实现这一点。

摘要

在本章中,我们首先介绍了 HTTP 路由器。我们尝试使用 Go 的 http 包构建了一个基本的应用程序。然后我们简要讨论了 ServeMux,并举例说明。我们看到了如何向多个路由添加多个处理程序。然后我们介绍了一个轻量级的路由器包,名为httprouterhttprouter允许开发人员创建可扩展的路由,还可以选择解析 URL 路径中传递的参数。我们还可以使用httprouter在 HTTP 上提供文件。我们构建了一个小型服务来获取 Go 版本和文件内容(只读)。该示例可以扩展到任何系统信息。

接下来,我们介绍了流行的 Go 路由库:Gorilla Mux。我们讨论了它与httprouter的不同之处,并通过实现实例来探索其功能。我们解释了如何使用Vars来获取路径参数和使用r.URL.Query来解析查询参数。然后我们讨论了 SQL 注入以及它如何在我们的应用程序中发生。我们给出了一些建议,以避免它。当我们构建一个包含数据库的完整 REST 服务时,我们将在即将到来的章节中看到这些措施。最后,我们制定了 URL 缩短的逻辑,并创建了一个 API 设计文档。

在下一章中,我们将介绍中间件函数,它们充当 HTTP 请求和响应的篡改者。这种现象将帮助我们即时修改 API 响应。下一章还涉及RPC(远程过程调用)。

第三章:使用中间件和 RPC 进行工作

在本章中,我们将研究中间件功能。什么是中间件,我们如何从头开始构建它?接下来,我们将转向为我们编写的更好的中间件解决方案,称为 Gorilla Handlers。然后,我们将尝试理解中间件可以帮助的一些用例。之后,我们将开始使用 Go 的内部 RPC 和 JSON RPC 构建我们的 RPC 服务。然后我们将转向一个高级的 RPC 框架,如 Gorilla HTTP RPC。

本章涵盖的主题有:

  • 什么是中间件?

  • 什么是 RPC(远程过程调用)?

  • 我们如何在 Go 中实现 RPC 和 JSON RPC?

获取代码

本章的所有代码都可以在github.com/narenaryan/gorestful/tree/master/chapter3找到。请参考第一章,开始 REST API 开发,以设置 Go 项目并运行程序。最好从 GitHub 克隆整个gorestful存储库。

什么是中间件?

中间件是一个钩入服务器请求/响应处理的实体。中间件可以在许多组件中定义。每个组件都有特定的功能要执行。每当我们为我们的 URL 模式定义处理程序(就像在上一章中那样),请求会命中处理程序并执行业务逻辑。因此,几乎所有中间件都应按顺序执行这些功能:

  1. 在命中处理程序(函数)之前处理请求

  2. 处理处理程序函数

  3. 在将其提供给客户端之前处理响应

我们可以看到以可视化形式呈现的先前的要点:

如果我们仔细观察图表,请求的旅程始于客户端。在没有中间件的应用程序中,请求到达 API 服务器,并将由某个函数处理程序处理。响应立即从服务器发送回来,客户端接收到它。但在具有中间件的应用程序中,它通过一系列阶段,如日志记录、身份验证、会话验证等,然后继续到业务逻辑。这是为了过滤错误的请求,防止其与业务逻辑交互。最常见的用例有:

  • 使用记录器记录每个请求命中 REST API

  • 验证用户会话并保持通信活动

  • 如果用户未经身份验证,则对用户进行身份验证

  • 编写自定义逻辑以获取请求数据

  • 在为客户端提供服务时附加属性到响应

借助中间件,我们可以将诸如身份验证之类的杂务工作保持在适当的位置。让我们创建一个基本的中间件并在 Go 中篡改 HTTP 请求。

当需要为每个请求或 HTTP 请求子集执行一段代码时,应该定义中间件函数。如果没有它们,我们需要在每个处理程序中重复逻辑。

创建基本中间件

构建中间件简单而直接。让我们根据第二章所学的知识构建一个程序。如果您对闭包函数不熟悉,闭包函数返回另一个函数。这个原则帮助我们编写中间件。我们应该做的第一件事是实现一个满足 http.Handler 接口的函数。

一个名为closure.go的示例闭包如下:

package main
import (
    "fmt"
)
func main() {
    numGenerator := generator()
    for i := 0; i < 5; i++ {
        fmt.Print(numGenerator(), "\t")
    }
}
// This function returns another function
func generator() func() int {
    var i = 0
    return func() int {
        i++
        return i
    }
}

如果我们运行这段代码:

go run closure.go

数字将使用制表符生成并打印:

1 2 3 4 5

我们正在创建一个名为 generator 的闭包函数,并调用它以获取一个新的数字。生成器模式根据给定条件每次生成一个新项。返回的内部函数是一个匿名函数,没有参数,一个整数类型的返回类型。在外部函数中定义的变量i可用于匿名函数,使其在将来计算逻辑时有用。闭包的另一个很好的示例应用是创建一个计数器。您可以通过遵循前面代码中应用的相同逻辑来实现它。

在 Go 中,外部函数的函数签名应该与匿名函数的函数签名完全匹配。在前面的例子中,func() int是外部和内部函数的签名。

这个例子是为了理解闭包在 Go 中是如何工作的。现在,让我们使用这个概念来组合我们的第一个中间件:

package main
import (
    "fmt"
    "net/http"
)
func middleware(handler 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
        handler.ServeHTTP(w, r)
        fmt.Println("Executing middleware after response phase!")
    })
}
func mainLogic(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
    mainLogicHandler := http.HandlerFunc(mainLogic)
    http.Handle("/", middleware(mainLogicHandler))
    http.ListenAndServe(":8000", nil)
}

让我们运行代码:

go run customMiddleware.go

如果您使用 CURL 请求或在浏览器中查看http://localhost:8000,控制台将收到此消息:

Executing middleware before request phase!
Executing mainHandler...
Executing middleware after response phase!

如果您观察之前提供的中间件示意图,请求阶段由右箭头指向,响应是左箭头。这个程序实际上是最右边的矩形,也就是CustomMiddleware

简单来说,前面的程序可以分解为这样:

  • 通过将主处理程序函数(mainLogic)传递给http.HandlerFunc()来创建一个处理程序函数。

  • 创建一个接受处理程序并返回处理程序的中间件函数。

  • 方法ServeHTTP允许处理程序执行处理程序逻辑,即mainLogic

  • http.Handle函数期望一个 HTTP 处理程序。考虑到这一点,我们以这样一种方式包装我们的逻辑,最终返回一个处理程序,但执行被修改了。

  • 我们将主处理程序传递给中间件。然后中间件接管并返回一个函数,同时将主处理程序逻辑嵌入其中。这样,所有发送到处理程序的请求都会通过中间件逻辑。

  • 打印语句的顺序解释了请求的过程。

  • 最后,我们在8000端口上提供服务器。

像 Martini、Gin 这样的 Go Web 框架默认提供中间件。我们将在接下来的章节中了解更多关于它们的内容。对于开发人员来说,了解中间件的底层细节是很有益的。

以下的图表可以帮助您理解中间件中逻辑流程的发生:

多个中间件和链接

在前面的部分,我们构建了一个单个中间件,在请求到达处理程序之前或之后执行操作。也可以链接一组中间件。为了做到这一点,我们应该遵循与前一部分相同的闭包逻辑。让我们创建一个用于保存城市详细信息的城市 API。为了简单起见,API 将只有一个 POST 方法,主体包括两个字段:城市名称和城市面积。

让我们考虑一个场景,API 开发人员只允许客户端使用 JSON 媒体类型,并且需要为每个请求将服务器时间以 UTC 格式发送回客户端。使用中间件,我们可以做到这一点。

两个中间件的功能是:

  • 在第一个中间件中,检查内容类型是否为 JSON。如果不是,则不允许请求继续进行。

  • 在第二个中间件中,向响应 cookie 添加一个名为 Server-Time(UTC)的时间戳

首先,让我们创建POST API:

package main

 import (
     "encoding/json"
     "fmt"
     "net/http"
 )

 type city struct {
     Name string
     Area uint64
 }

 func mainLogic(w http.ResponseWriter, r *http.Request) {
     // Check if method is POST
     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()
         // Your resource creation logic goes here. For now it is plain print to console
         fmt.Printf("Got %s city with area of %d sq miles!\n", tempCity.Name, tempCity.Area)
         // Tell everything is fine
         w.WriteHeader(http.StatusOK)
         w.Write([]byte("201 - Created"))
     } else {
         // Say method not allowed
         w.WriteHeader(http.StatusMethodNotAllowed)
         w.Write([]byte("405 - Method Not Allowed"))
     }
 }

 func main() {
     http.HandleFunc("/city", mainLogic)
     http.ListenAndServe(":8000", nil)
 }

如果我们运行这个:

go run cityAPI.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}'

Go 给了我们以下内容:

Got New York city with area of 304 sq miles!
Got Boston city with area of 89 sq miles!

CURL 的响应将是:

201 - Created
201 - Created

为了链接,我们需要在多个中间件之间传递处理程序。

以下是简单步骤中的程序:

  • 我们创建了一个允许 POST 方法的 REST API。它还不完整,因为我们没有将数据存储到数据库或文件中。

  • 我们导入了json包,并用它解码了客户端提供的 POST 主体。接下来,我们创建了一个映射 JSON 主体的结构。

  • 然后,JSON 被解码并将信息打印到控制台。

在前面的例子中只涉及一个处理程序。但是,对于即将到来的任务,想法是将主处理程序传递给多个中间件处理程序。完整的代码看起来像这样:

package main
import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "time"
)
type city struct {
    Name string
    Area uint64
}
// Middleware to check content type as JSON
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)
    })
}
// Middleware to add server timestamp for response cookie
func setServerTimeCookie(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        handler.ServeHTTP(w, r)
        // Setting cookie to each and every 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 mainLogic(w http.ResponseWriter, r *http.Request) {
    // Check if method is POST
    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()
        // Your resource creation logic goes here. For now it is plain print to console
        log.Printf("Got %s city with area of %d sq miles!\n", tempCity.Name, tempCity.Area)
        // Tell everything is fine
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("201 - Created"))
    } else {
        // Say method not allowed
        w.WriteHeader(http.StatusMethodNotAllowed)
        w.Write([]byte("405 - Method Not Allowed"))
    }
}
func main() {
    mainLogicHandler := http.HandlerFunc(mainLogic)
    http.Handle("/city", filterContentType(setServerTimeCookie(mainLogicHandler)))
    http.ListenAndServe(":8000", nil)
}

现在,如果我们运行这个:

go run multipleMiddleware.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}' 
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

并且 cookie 将从另一个中间件设置。

在前面的程序中,我们使用 log 而不是fmt包。尽管两者都是做同样的事情,但 log 通过附加日志的时间戳来格式化输出。它也可以很容易地定向到文件。

这个程序中有一些有趣的地方。我们定义的中间件函数具有相当常见的用例。我们可以扩展它们以执行任何操作。这个程序由许多元素组成。如果逐个函数地阅读它,逻辑可以很容易地展开。看一下以下几点:

  • 创建了一个名为 city 的结构体来存储城市详情,就像上一个例子中一样。

  • filterContentType是我们添加的第一个中间件。它实际上检查请求的内容类型,并允许或阻止请求继续进行。我们使用r.Header.GET(内容类型)进行检查。如果是 application/json,我们允许请求调用handler.ServeHTTP函数,该函数执行mainLogicHandler代码。

  • setServerTimeCookie是我们设计的第二个中间件,用于在响应中添加一个值为服务器时间的 cookie。我们使用 Go 的time包来找到 Unix 纪元中的当前 UTC 时间。

  • 对于 cookie,我们设置了NameValue。cookie 还接受另一个名为Expire的参数,用于告知 cookie 的过期时间。

  • 如果内容类型不是 application/json,我们的应用程序将返回 415-不支持的媒体类型状态码。

  • 在 mainhandler 中,我们使用json.NewDecoder来解析 JSON 并将其填充到city结构体中。

  • strconv.FormatInt允许我们将int64数字转换为字符串。如果是普通的int,那么我们使用strconv.Itoa

  • 当操作成功时,返回的正确状态码是 201。对于所有其他方法,我们返回 405,即不允许的方法。

我们在这里进行的链式调用对于两到三个中间件是可读的:

http.Handle("/city", filterContentType(setServerTimeCookie(mainLogicHandler)))

如果 API 服务器希望请求通过多个中间件,那么我们如何使这种链式调用简单且可读?有一个名为 Alice 的非常好的库可以解决这个问题。它允许您按语义顺序附加中间件到主处理程序。我们将在下一章中简要介绍它。

使用 Alice 轻松进行中间件链

当中间件列表很大时,Alice库可以降低中间件链的复杂性。它为我们提供了一个清晰的 API 来将处理程序传递给中间件。为了安装它,使用go get命令,就像这样:

go get github.com/justinas/alice

现在我们可以在程序中导入 Alice 包并立即使用它。我们可以修改前面程序的部分以带来改进的链式调用相同的功能。在导入部分,添加github.com/justinas/alice,就像以下代码片段:

import (
    "encoding/json"
    "github.com/justinas/alice"
    "log"
    "net/http"
    "strconv"
    "time"
)

现在,在主函数中,我们可以修改处理程序部分,就像这样:

func main() {
    mainLogicHandler := http.HandlerFunc(mainLogic)
    chain := alice.New(filterContentType, setServerTimeCookie).Then(mainLogicHandler)
    http.Handle("/city", chain)
    http.ListenAndServe(":8000", nil)
}

这些添加更改的完整代码可在书的 GitHub 存储库的第三章文件夹中的名为multipleMiddlewareWithAlice.go的文件中找到。在掌握了前面的概念之后,让我们使用 Gorilla 工具包中的 Handlers 库构建一个日志中间件。

使用 Gorilla 的 Handlers 中间件进行日志记录

Gorilla Handlers 包提供了各种常见任务的中间件。列表中最重要的是:

  • LoggingHandler:用于记录 Apache 通用日志格式

  • CompressionHandler:用于压缩响应

  • RecoveryHandler:用于从意外的 panic 中恢复

在这里,我们使用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-Date-Method:Endpoint-ResponseStatus。编写我们自己的这样的中间件会需要一些工作。但是 Gorilla Handlers 已经为我们实现了它。看一下以下代码片段:

package main
import (
    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"
    "log"
    "os"
    "net/http"
)
func mainLogic(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("/", mainLogic)
    loggedRouter := handlers.LoggingHandler(os.Stdout, r)
    http.ListenAndServe(":8000", loggedRouter)
}

现在运行服务器:

go run loggingMiddleware.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替换 localhost,因为前者将显示为空 IP 在日志中。

来到程序,我们正在导入 Gorilla Mux 路由器和 Gorilla handlers。然后我们将一个名为mainLogic的处理程序附加到路由器上。接下来,我们将路由器包装在handlers.LoggingHandler中间件中。它返回一个更多的处理程序,我们可以安全地传递给 http.ListenAndServe。

你也可以尝试其他中间件,比如 handlers。这一节的座右铭是向你介绍 Gorilla Handlers。Go 还有许多其他外部包可用。有一个值得一提的库,用于直接在 net/http 上编写中间件。它是 Negroni(github.com/urfave/negroni)。它还提供了 Gorilla LoggingHandler 的功能。所以请看一下。

我们可以使用一个叫做 go.uuid 的库(github.com/satori/go.uuid)和 cookies 轻松构建基于 cookie 的身份验证中间件。

什么是 RPC?

远程过程调用(RPC)是在各种分布式系统之间交换信息的进程间通信。一台名为 Alice 的计算机可以以协议格式调用另一台名为 Bob 的计算机中的函数(过程),并获得计算结果。我们可以从另一个地方或地理区域的网络请求东西,而不需要在本地实现功能。

整个过程可以分解为以下步骤:

  • 客户端准备要发送的函数名和参数

  • 客户端通过拨号连接将它们发送到 RPC 服务器

  • 服务器接收函数名和参数

  • 服务器执行远程过程

  • 消息将被发送回客户端

  • 客户端收集请求的数据并适当使用它

服务器需要公开其服务,以便客户端连接并请求远程过程。看一下下面的图表:

Go 提供了一个库来实现 RPC 服务器和 RPC 客户端。在上图中,RPC 客户端通过包含主机和端口等详细信息拨号连接。它发送两件事以及请求。一个是参数和回复指针。由于它是一个指针,服务器可以修改它并发送回来。然后客户端可以使用填入指针的数据。Go 有两个库,net/rpc 和 net/rpc/jsonrpc,用于处理 RPC。让我们编写一个 RPC 服务器,与客户端通信并提供服务器时间。

创建一个 RPC 服务器

让我们创建一个简单的 RPC 服务器,将 UTC 服务器时间发送回 RPC 客户端。首先,我们从服务器开始。

RPC 服务器和 RPC 客户端应该就两件事达成一致:

  1. 传递的参数

  2. 返回的值

前两个参数的类型应该完全匹配服务器和客户端:

package main
import (
    "log"
    "net"
    "net/http"
    "net/rpc"
    "time"
)
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
}
func main() {
    // Create a new RPC server
    timeserver := new(TimeServer)
    // Register RPC server
    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)
}

我们首先创建 Args 结构。这个结构保存了从客户端(RPC)传递到服务器的参数信息。然后,我们创建了一个TimeServer数字来注册到rpc.Register。在这里,服务器希望导出一个类型为TimeServer(int64)的对象。HandleHTTP为 RPC 消息注册了一个 HTTP 处理程序到DefaultServer。然后我们启动了一个监听端口 1234 的 TCP 服务器。http.Serve函数用于将其作为一个运行程序提供。GiveServerTime是客户端将调用的函数,并返回当前服务器时间。

从前面的例子中有几点需要注意:

  • GiveServerTimeArgs对象作为第一个参数和一个回复指针对象

  • 它设置了回复指针对象,但除了错误之外没有返回任何东西

  • 这里的Args结构没有字段,因为此服务器不希望客户端发送任何参数

在运行此程序之前,让我们也编写 RPC 客户端。两者可以同时运行。

创建 RPC 客户端

现在,客户端也使用相同的 net/rpc 包,但使用不同的方法拨号到服务器并执行远程函数。获取数据的唯一方法是将回复指针对象与请求一起传递,如下面的代码片段所示:

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)}

客户端在这里执行以下操作:

  1. 进行DialHTTP连接到运行在本地主机端口1234上的 RPC 服务器。

  2. 使用Name:Function格式调用Remote函数,使用args并回复指针对象。

  3. 将收集的数据放入reply对象中。

  4. **Call **函数是顺序性的。

现在我们可以同时运行服务器和客户端来看它们的运行情况:

go run RPCServer.go

运行服务器。现在打开另一个 shell 选项卡并运行此命令:

go run RPCClient.go 

现在服务器控制台将输出以下 UNIX 时间字符串:

2017/05/28 19:26:31 1495979791

看到魔术了吗?客户端作为独立程序运行。在这里,两个程序可以在不同的机器上运行,计算仍然可以共享。这是分布式系统的核心概念。任务被分割并分配给各种 RPC 服务器。最后,客户端收集结果并将其用于进一步的操作。

自定义 RPC 代码仅在客户端和服务器都是用 Go 编写时才有用。因此,为了让 RPC 服务器被多个服务使用,我们需要定义基于 HTTP 的 JSON RPC。然后,任何其他编程语言都可以发送 JSON 字符串并获得 JSON 作为结果。

RPC 应该是安全的,因为它正在执行远程函数。在从客户端收集请求时需要授权。

使用 Gorilla RPC 进行 JSON RPC

我们看到 Gorilla 工具包通过提供许多有用的库来帮助我们。然后,我们探索了 Mux、Handlers,现在是 Gorilla RPC 库。使用这个,我们可以创建使用 JSON 而不是自定义回复指针进行通信的 RPC 服务器和客户端。让我们将前面的示例转换为一个更有用的示例。

考虑这种情况。服务器上有一个 JSON 文件,其中包含书籍的详细信息(名称、ID、作者)。客户端通过发出 HTTP 请求来请求书籍信息。当 RPC 服务器收到请求时,它从文件系统中读取并解析文件。如果给定的 ID 与任何书籍匹配,那么服务器将以 JSON 格式将信息发送回客户端。我们可以使用以下命令安装 Gorilla RPC:

go get github.com/gorilla/rpc

该包源自标准的net/rpc包,但每次调用使用单个 HTTP 请求而不是持久连接。与net/rpc相比的其他差异:在以下部分中进行了解释。

可以在同一个服务器中注册多个编解码器。编解码器是根据请求的Content-Type标头选择的。服务方法还接收http.Request作为参数。此包可用于 Google App Engine。现在,让我们编写一个 RPC JSON 服务器。在这里,我们正在实现 JSON1.0 规范。对于 2.0,您应该使用 Gorilla JSON2:

package main
import (
    jsonparse "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "github.com/gorilla/mux"
    "github.com/gorilla/rpc"
    "github.com/gorilla/rpc/json"
)
// 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:string,omitempty"`
    Name string `"json:name,omitempty"`
    Author string `"json:author,omitempty"`
}
type JSONServer struct{}
// GiveBookDetail
func (t *JSONServer) GiveBookDetail(r *http.Request, args *Args, reply *Book) error {
    var books []Book
    // Read JSON file and load data
    raw, readerr := ioutil.ReadFile("./books.json")
    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
}
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)
}

这个程序可能与前面的 RPC 服务器实现不同。这是因为包含了 Gorilla MuxGorilla rpcjsonrpc包。在解释发生了什么之前,让我们运行前面的程序。使用以下命令运行服务器:

go run jsonRPCServer.go

现在客户端在哪里?在这里,客户端可以是 CURL 命令,因为 RPC 服务器通过 HTTP 提供请求。我们需要发布 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,直接从 JSON RPC 服务器提供:

{"result":{"Id":"1234","Name":"In the sunburned country","Author":"Bill Bryson"},"error":null,"id":"1"}

现在,来到程序,我们有很多需要理解的地方。创建 RPC 服务的文档非常有限。因此,我们在程序中使用的技术可以应用于各种用例。首先,我们创建了ArgsBook结构体,分别用于保存传递的 JSON 参数和书籍结构的信息。我们在名为JSONServer的资源上定义了一个名为GiveBookDetail的远程函数。这个结构体是一个服务,用于在 RPC 服务器的RegisterService函数中注册。如果您注意到,我们还注册了 JSON 编解码器。

每当我们从客户端收到请求时,我们将名为books.json的 JSON 文件加载到内存中,然后使用 JSON 的Unmarshal方法加载到Book结构体中。jsonparse是给予 Go 包encoding/json的别名,因为 Gorilla 导入的 JSON 包具有相同的名称。为了消除冲突,我们使用了一个别名。

reply引用被传递给远程函数。在远程函数中,我们使用匹配的书籍设置了回复的值。如果客户端发送的 ID 与 JSON 中的任何书籍匹配,那么数据就会被填充。如果没有匹配,那么 RPC 服务器将发送回空数据。通过这种方式,可以创建一个 JSON RPC 以允许客户端是通用的。在这里,我们没有编写 Go 客户端。任何客户端都可以从服务中访问数据。

当多个客户端技术需要连接到您的 RPC 服务时,最好使用 JSON RPC。

总结

在本章中,我们首先研究了中间件的确切含义,包括中间件如何处理请求和响应。然后,我们通过一些实际示例探讨了中间件代码。之后,我们看到了如何通过将一个中间件传递给另一个中间件来链接我们的中间件。然后,我们使用了一个名为Alice的包来进行直观的链接。我们还研究了 Gorilla 处理程序中间件用于日志记录。接下来,我们学习了 RPC 是什么,以及如何构建 RPC 服务器和客户端。之后,我们解释了什么是 JSON RPC,并看到了如何使用 Gorilla 工具包创建 JSON RPC。我们介绍了许多第三方中间件和 RPC 包,附有示例。

在下一章中,我们将探索一些著名的 Web 框架,这些框架进一步简化了 REST API 的创建。它们具有内置的中间件和 HTTP 路由器。

第四章:使用流行的 Go 框架简化 RESTful 服务

在本章中,我们将涵盖使用框架简化构建 REST 服务相关的主题。首先,我们将快速了解 go-restful,一个 REST API 创建框架,然后转向一个名为Gin的框架。我们将在本章尝试构建一个地铁 API。我们将讨论的框架是完整的 Web 框架,也可以用来在短时间内创建 REST API。在本章中,我们将大量讨论资源和 REST 动词。我们将尝试将一个名为Sqlite3的小型数据库与我们的 API 集成。最后,我们将检查Revel.go,看看如何用它原型化我们的 REST API。

总的来说,本章我们将涵盖的主题如下:

  • 如何在 Go 中使用 SQLite3

  • 使用 go-restful 包创建 REST API

  • 介绍用于创建 REST API 的 Gin 框架

  • 介绍 Revel.go 用于创建 REST API

  • 构建 CRUD 操作的基础知识

获取代码

您可以从github.com/narenaryan/gorestful/tree/master/chapter4获取本章的代码示例。本章的示例以项目的形式而不是单个程序的形式呈现。因此,将相应的目录复制到您的GOPATH中以正确运行代码示例。

go-restful,一个用于创建 REST API 的框架

go-restful是一个用于在 Go 中构建 REST 风格 Web 服务的包。REST,正如我们在前面的部分中讨论的,要求开发人员遵循一组设计协议。我们已经讨论了 REST 动词应该如何定义以及它们对资源的影响。

使用go-restful,我们可以将 API 处理程序的逻辑分离并附加 REST 动词。这样做的好处是,通过查看代码,清楚地告诉我们正在创建什么 API。在进入示例之前,我们需要为go-restful的 REST API 安装一个名为 SQLite3 的数据库。安装步骤如下:

  • 在 Ubuntu 上,运行以下命令:
 apt-get install sqlite3 libsqlite3-dev
  • 在 OS X 上,您可以使用brew命令安装 SQLite3:
 brew install sqlite3
  • 现在,使用以下get命令安装go-restful包:
 go get github.com/emicklei/go-restful

我们已经准备好了。首先,让我们编写一个简单的程序,展示go-restful在几行代码中可以做什么。让我们创建一个简单的 ping 服务器,将服务器时间回显给客户端:

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)
}
func pingTime(req *restful.Request, resp *restful.Response) {
    // Write to the response
   io.WriteString(resp, fmt.Sprintf("%s", time.Now()))
}

如果我们运行这个程序:

go run basicExample.go

服务器将在本地主机的端口8000上运行。因此,我们可以使用 curl 请求或浏览器来查看GET请求的输出:

curl -X GET "http://localhost:8000/ping"
2017-06-06 07:37:26.238146296 +0530 IST

在上述程序中,我们导入了go-restful库,并使用restful.WebService结构的新实例创建了一个新的服务。接下来,我们可以使用以下语句创建一个 REST 动词:

webservice.GET("/ping")

我们可以附加一个函数处理程序来执行这个动词;pingTime就是这样一个函数。这些链接的函数被传递给Route函数以创建一个路由器。然后是以下重要的语句:

restful.Add(webservice)

这将注册新创建的webservicego-restful。如果您注意到,我们没有将任何ServeMux对象传递给http.ListenServe函数;go-restful会处理它。这里的主要概念是使用基于资源的 REST API 创建go-restful。从基本示例开始,让我们构建一些实际的东西。

假设你的城市正在建设新的地铁,并且你需要为其他开发人员开发一个 REST API 来消费并相应地创建一个应用程序。我们将在本章中创建这样一个 API,并使用各种框架来展示实现。在此之前,对于创建、读取、更新、删除CRUD)操作,我们应该知道如何使用 Go 代码查询或将它们插入到 SQLite 数据库中。

CRUD 操作和 SQLite3 基础知识

所有的 SQLite3 操作都将使用一个名为go-sqlite3的库来完成。我们可以使用以下命令安装该包:

go get github.com/mattn/go-sqlite3

这个库的特殊之处在于它使用了 Go 的内部sql包。我们通常导入database/sql并使用sql在数据库(这里是 SQLite3)上执行数据库查询:

import "database/sql"

现在,我们可以创建一个数据库驱动程序,然后使用Query方法在其上执行 SQL 命令:

sqliteFundamentals.go:

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")
    log.Println(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()
    // 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!")
}

这个程序解释了如何在 SQL 数据库上执行 CRUD 操作。目前,数据库是 SQLite3。让我们使用以下命令运行它:

go run sqliteFundamentals.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!

这个程序在 Windows 和 Linux 上都可以正常运行。在 Go 版本低于 1.8.1 的情况下,你可能会在 macOS X 上遇到问题,比如Signal Killed。这是因为 Xcode 版本的问题,请记住这一点。

关于程序,我们首先导入database/sqlgo-sqlite3。然后,我们使用sql.Open()函数在文件系统上打开一个db文件。它接受两个参数,数据库类型和文件名。如果出现问题,它会返回一个错误,否则返回一个数据库驱动程序。在sql库中,为了避免 SQL 注入漏洞,该包提供了一个名为Prepare的函数:

statement, err := db.Prepare("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, isbn INTEGER, author VARCHAR(64), name VARCHAR(64) NULL)")

前面的语句只是创建了一个语句,没有填充任何细节。实际传递给 SQL 查询的数据使用语句中的Exec函数。例如,在前面的代码片段中,我们使用了:

statement, _ = db.Prepare("INSERT INTO books (name, author, isbn) VALUES (?, ?, ?)")
statement.Exec("A Tale of Two Cities", "Charles Dickens", 140430547)

如果你传递了不正确的值,比如导致 SQL 注入的字符串,驱动程序会立即拒绝 SQL 操作。要从数据库中获取数据,使用Query方法。它返回一个迭代器,使用Next方法返回匹配查询的所有行。我们应该在循环中使用该迭代器进行处理,如下面的代码所示:

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)
}

如果我们需要向SELECT语句传递条件,那么你应该准备一个语句,然后将通配符(?)数据传递给它。

使用 go-restful 构建地铁 API

让我们利用前一节学到的知识,为我们在前一节谈到的城市地铁项目创建一个 API。路线图如下:

  1. 设计 REST API 文档。

  2. 为数据库创建模型。

  3. 实现 API 逻辑。

设计规范

在创建任何 API 之前,我们应该知道 API 的规范是什么样的,以文档的形式。我们在前几章中展示了一些例子,包括 URL 缩短器 API 设计文档。让我们尝试为这个地铁项目创建一个。看一下下面的表格:

HTTP 动词 路径 操作 资源
POST /v1/train (details as JSON body) 创建 火车
POST /v1/station (details as JSON body) 创建 站点
GET /v1/train/id 读取 火车
GET /v1/station/id 读取 站点
POST /v1/schedule (source and destination) 创建 路线

我们还可以包括UPDATEDELETE方法。通过实现前面的设计,用户可以很容易地自行实现它们。

创建数据库模型

让我们编写一些 SQL 字符串,为前面的火车、站点和路线资源创建表。我们将为这个 API 创建一个项目布局。项目布局将如下截图所示:

我们在$GOPATH/src/github.com/user/中创建我们的项目。这里,用户是narenaryanrailAPI是我们的项目源,dbutils是我们自己的处理数据库初始化实用函数的包。让我们从dbutils/models.go文件开始。我将在models.go文件中为火车、站点和时间表各添加三个模型:

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)
        )
`

这些都是用反引号(`)字符括起来的普通多行字符串。该时刻表保存了在给定时间到达特定车站的列车的信息。在这里,火车和车站是时间表的外键。对于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)
    }
    // 创建火车表
    _, 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。但是,由于此文件位于db utils包中,models.go中的变量是可访问的。

现在我们的初始包已经完成。你可以使用以下命令为此包构建对象代码:


go build github.com/narenaryan/dbutils

直到我们创建并运行我们的主程序才有用。所以,让我们编写一个简单的主程序,从dbutils包导入Initialize函数。让我们将文件命名为main.go


package main
import (
    "database/sql"
    "log"
    _ "github.com/mattn/go-sqlite3"
    "github.com/narenaryan/dbutils"
)
func main() {
    // 连接到数据库
    db, err := sql.Open("sqlite3", "./railapi.db")
    if err != nil {
        log.Println("Driver creation failed!")
    }
    // 创建表
    dbutils.Initialize(db)
}

并使用以下命令从railAPI目录运行程序:


go run main.go

你看到的输出应该类似于以下内容:


2017/06/10 14:05:36 所有表格成功创建/初始化!

在上述程序中,我们添加了创建数据库驱动程序的代码,并将表创建任务传递给了dbutils包中的Initialize函数。我们可以直接在主程序中完成这个任务,但是将逻辑分解成多个包和组件是很好的。现在,我们将扩展这个简单的布局,使用go-restful包创建一个 API。API 应该实现我们的 API 设计文档中的所有函数。

当我们运行我们的主程序时,上述目录树图片中的railapi.db文件将被创建。如果数据库文件不存在,SQLite3 将负责创建数据库文件。SQLite3 数据库是简单的文件。你可以使用$ sqlite3 file_name命令进入 SQLite shell。

让我们将主程序修改为一个新的程序。我们将逐步进行,并在此示例中了解如何使用go-restful构建 REST 服务。首先,向程序中添加必要的导入:


package main
import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "time"
    "github.com/emicklei/go-restful"
    _ "github.com/mattn/go-sqlite3"
    "github.com/narenaryan/dbutils"
)

我们需要两个外部包,go-restfulgo-sqlite3,用于构建 API 逻辑。第一个用于处理程序,第二个用于添加持久性特性。dbutils是我们之前创建的。timenet/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实现。我们需要为我们的 API 在go-restful中创建一个容器。然后,我们应该将 Web 服务注册到该容器中。让我们编写Register函数,如下面的代码片段所示:


// Register adds paths and routes to container
func (t *TrainResource) Register(container *restful.Container) {
    ws := new(restful.WebService)
    ws.Path("/v1/trains").
    Consumes(restful.MIME_JSON).
    Produces(restful.MIME_JSON) // you can specify this per route as well
    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)
}

go-restful中,Web 服务主要基于资源工作。所以在这里,我们定义了一个名为Register的函数在TrainResource上,接受容器作为参数。我们创建了一个新的WebService并为其添加路径。路径是 URL 端点,路由是附加到函数处理程序的路径参数或查询参数。ws是用于提供Train资源的 Web 服务。我们将三个 REST 方法,即GETPOSTDELETE分别附加到三个函数处理程序上,分别是getTraincreateTrainremoveTrain


Path("/v1/trains").
Consumes(restful.MIME_JSON).
Produces(restful.MIME_JSON)

这些语句表明 API 将只接受请求中的Content-Type为 application/JSON。对于所有其他类型,它会自动返回 415--媒体不支持错误。返回的响应会自动转换为漂亮的 JSON 格式。我们还可以有一个格式列表,比如 XML、JSON 等等。go-restful提供了这个功能。

现在,让我们定义函数处理程序:


// 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 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 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())
    }
}

所有这些 REST 方法都在TimeResource结构的实例上定义。谈到GET处理程序,它将RequestResponse作为其参数传递。可以使用request.PathParameter函数获取路径参数。传递给它的参数将与我们在前面的代码段中添加的路由保持一致。也就是说,train-id将被返回到处理程序中,以便我们可以剥离它并将其用作从我们的 SQLite 数据库中获取记录的条件。

POST处理程序函数中,我们使用 JSON 包的NewDecoder函数解析请求体。go-restful没有一个函数可以解析客户端发布的原始数据。有函数可用于剥离查询参数和表单参数,但这个缺失了。所以,我们编写了自己的逻辑来剥离和解析 JSON 主体,并使用这些结果将数据插入我们的 SQLite 数据库中。该处理程序正在为请求中提供的细节创建一个db记录。

如果您理解前两个处理程序,DELETE函数就很明显了。我们使用DB.Prepare创建一个DELETE SQL 命令,并返回 201 状态 OK,告诉我们删除操作成功了。否则,我们将实际错误作为服务器错误发送回去。现在,让我们编写主函数处理程序,这是我们程序的入口点:


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创建一个新的容器。然后,我们使用称为CurlyRouter的路由器(它允许我们在路径中使用{train_id}语法来设置路由)来为我们的容器设置路由。接下来,我们创建了TimeResource结构的实例,并将该容器传递给Register方法。该容器确实可以充当 HTTP 处理程序;因此,我们可以轻松地将其传递给http.Server

使用 request.QueryParameter 从 HTTP 请求中获取查询参数在go-restful处理程序中。

此代码可在 GitHub 仓库中找到。现在,当我们在$GOPATH/src/github.com/narenaryan目录中运行main.go文件时,我们会看到这个:


go run railAPI/main.go

并进行 curl POST请求创建一个火车:


curl -X POST \
    http://localhost:8000/v1/trains \
    -H 'cache-control: no-cache' \
    -H 'content-type: application/json' \
    -d '{"driverName": "Menaka", "operatingStatus": true}'

这会创建一个带有驾驶员和操作状态详细信息的新火车。响应是新创建的分配了火车ID的资源:


{
    "ID": 1,
    "DriverName": "Menaka",
    "OperatingStatus": true
}

现在,让我们进行一个 curl 请求来检查GET


CURL -X GET "http://localhost:8000/v1/trains/1"

您将看到以下 JSON 输出:


{
    "ID": 1,
    "DriverName": "Menaka",
    "OperatingStatus": true
}

可以对发布的数据和返回的 JSON 使用相同的名称,但为了显示两个操作之间的区别,使用了不同的变量名称。现在,使用DELETEAPI 调用删除我们在前面代码片段中创建的资源:


CURL -X DELETE "http://localhost:8000/v1/trains/1"

如果操作成功,它不会返回任何响应体,而是返回Status 200 ok。现在,如果我们尝试对ID为 1 的火车进行GET操作,它会返回以下响应:


Train could not be found.

这些实现可以扩展到PUTPATCH。我们需要在Register方法中添加两个额外的路由,并定义相应的处理程序。在这里,我们为Train资源创建了一个 web 服务。类似地,还可以为StationSchedule表上的 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。它是一个 HTTP 多路复用器,类似于 Gorilla Mux,但更快。 Gin允许以清晰的方式创建 REST 服务的高级 API。 Gin将自己与另一个名为martini的 web 框架进行比较。所有 web 框架都允许我们做更多的事情,如模板化和 web 服务器设计,除了服务创建。使用以下命令安装Gin包:


go get gopkg.in/gin-gonic/gin.v1

让我们写一个简单的 hello world 程序在Gin中熟悉Gin的构造。文件名是ginBasic.go


package main
import (
    "time"
    "github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
    /* GET takes a route and a handler function
    Handler takes the gin context object
    */
    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") // 在 0.0.0.0:8080 上监听并提供服务
}

这个简单的服务器尝试实现一个向客户端提供 UTC 服务器时间的服务。我们在第三章使用中间件和 RPC 工作中实现了一个这样的服务。但在这里,如果你看,Gin允许你用几行代码做很多事情;所有的样板细节都被省去了。来到前面的程序,我们用gin.Default函数创建了一个路由器。然后,我们附加了与 REST 动词相对应的路由,就像在go-restful中做的那样;一个到函数处理程序的路由。然后,我们通过传递要运行的端口来调用Run函数。默认端口将是8080

c是保存单个请求信息的gin.Context。我们可以使用context.JSON函数将数据序列化为 JSON,然后发送回客户端。现在,如果我们运行并查看前面的程序:


go run ginExamples/ginBasic.go

发出一个 curl 请求:


curl -X GET "http://localhost:8000/pingTime"

Output
=======
{"serverTime":"2017-06-11T03:59:44.135062688Z"}

与此同时,我们运行Gin服务器的控制台上漂亮地呈现了调试消息:

这是显示端点、请求的延迟和 REST 方法的 Apache 风格的调试日志。

为了在生产模式下运行Gin,设置GIN_MODE = release环境变量。然后控制台输出将被静音,日志文件可用于监视日志。

现在,让我们在Gin中编写我们的 Rail API,以展示如何使用Gin框架实现完全相同的东西。我将使用相同的项目布局,将我的新项目命名为railAPIGin,并使用dbutils如它所在。首先,让我们准备好我们程序的导入:


package main
import (
    "database/sql"
    "log"
    "net/http"
    "github.com/gin-gonic/gin"
    _ "github.com/mattn/go-sqlite3"
    "github.com/narenaryan/dbutils"
)

我们导入了sqlite3dbutils用于与数据库相关的操作。我们导入了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是我们从请求体和来自数据库的数据解码而来的 JSON 的占位符。如果你注意到了,它与go-restful的示例略有不同。现在,让我们编写实现GETPOSTDELETE方法的station资源的处理程序:


// 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,
        })
    }
}
// CreateStation handles the POST
func CreateStation(c *gin.Context) {
    var station StationResource
    // Parse the body into our resrource
    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.</span>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())
    }
}
// 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, "")
    }
}

GetStation中,我们使用c.Param来剥离station_id路径参数。之后,我们使用该 ID 从 SQLite3 站点表中检索数据库记录。如果您仔细观察,SQL 查询有点不同。我们使用CAST方法将 SQL TIME字段检索为 Go 可以正确消耗的字符串。如果删除类型转换,将引发恐慌错误,因为我们尝试在运行时将TIME字段加载到 Go 字符串中。为了给您一个概念,TIME字段看起来像8:00:0017:31:12,等等。接下来,如果没有错误,我们将使用gin.H方法返回结果。

CreateStation中,我们试图执行插入查询。但在此之前,为了从POST请求的主体中获取数据,我们使用了一个名为c.BindJSON的函数。这个函数将数据加载到传递的结构体中。这意味着站点结构将加载来自主体提供的数据。这就是为什么StationResource具有 JSON 推断字符串来告诉期望的键值是什么。例如,这是StationResource结构的一个字段,带有推断字符串。


ID int `json:"id"`

在收集数据后,我们正在准备一个数据库插入语句并执行它。结果是插入记录的 ID。我们使用该 ID 将站点详细信息发送回客户端。在RemoveStation中,我们执行DELETE SQL 查询。如果操作成功,则返回200 OK状态。否则,我们会发送适当的原因给500 Internal Server Error

现在来看主程序,它首先运行数据库逻辑以确保表已创建。然后,它尝试创建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") // 默认监听并在 0.0.0.0:8080 上提供服务
}

我们正在使用Gin路由器注册GETPOSTDELETE路由。然后,我们将路由和处理程序传递给它们。最后,我们使用 Gin 的Run函数以8000作为端口启动服务器。运行前述程序,如下所示:


go run 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://10.102.78.140:8000/v1/stations/1"

Output
======
{"result":{"id":1,"name":"Brooklyn","opening_time":"8:12:00","closing_time":"18:23:00"}}

我们也可以使用以下命令删除站点记录:


CURL -X DELETE "http://10.102.78.140:8000/v1/stations/1"

它返回200 OK状态,确认资源已成功删除。正如我们已经讨论的那样,Gin提供了直观的调试功能,显示附加的处理程序,并使用颜色突出显示延迟和 REST 动词:

例如,200是绿色的,404是黄色的,DELETE是红色的,等等。Gin提供了许多其他功能,如路由的分类、重定向和中间件函数。

如果您要快速创建 REST Web 服务,请使用Gin框架。您还可以将其用于许多其他用途,如静态文件服务等。请记住,它是一个完整的 Web 框架。在 Gin 中获取查询参数,请使用以下方法在Gin上下文对象上:c.Query("param")

使用 Revel.go 构建一个 RESTful API

Revel.go 也是一个像 Python 的 Django 一样完整的 Web 框架。它比 Gin 还要早,并被称为高生产力的 Web 框架。它是一个异步的、模块化的、无状态的框架。与 go-restfulGin 框架不同,Revel 直接生成了一个可用的脚手架。

使用以下命令安装Revel.go


go get github.com/revel/revel

为了运行脚手架工具,我们应该安装另一个附加包:


go get github.com/revel/cmd/revel

确保 $GOPATH/bin 在您的 PATH 变量中。一些外部包将二进制文件安装在 $GOPATH/bin 目录中。如果在路径中,我们可以在系统范围内访问可执行文件。在这里,Revel 将安装一个名为revel的二进制文件。在 Ubuntu 或 macOS X 上,您可以使用以下命令执行:


export PATH=$PATH:$GOPATH/bin

将上面的内容添加到 ~/.bashrc 以保存设置。在 Windows 上,您需要直接调用可执行文件的位置。现在我们已经准备好开始使用 Revel 了。让我们在 github.com/narenaryan 中创建一个名为 railAPIRevel 的新项目:


revel new railAPIRevel

这样就可以在不写一行代码的情况下创建一个项目脚手架。这就是 Web 框架在快速原型设计中的抽象方式。Revel 项目布局树看起来像这样:


conf/         Configuration directory
app.conf      Main app configuration file
routes        路由定义文件
app/          应用程序源
init.go       拦截器注册
controllers/  这里放置应用程序控制器
views/        模板目录
messages/     消息文件
public/       公共静态资产
css/          CSS 文件
js/           Javascript 文件
images/       图像文件
tests/        测试套件

在所有那些样板目录中,有三个重要的东西用于创建一个 API。那是:

  • app/controllers

  • conf/app.conf

  • conf/routes

控制器是执行 API 逻辑的逻辑容器。app.conf 允许我们设置 hostportdev 模式/生产模式等。routes 定义了端点、REST 动词和函数处理程序(这里是控制器的函数)。这意味着在控制器中定义一个函数,并在路由文件中将其附加到路由上。

让我们使用我们之前看到的 go-restful 的相同例子,为列车创建一个 API。但由于冗余,我们将删除数据库逻辑。稍后我们将看到如何使用 Revel 为 API 构建 GETPOSTDELETE 操作。现在,将路由文件修改为这样:


# 路由配置
#
# 此文件定义了所有应用程序路由(优先级较高的路由优先)
#

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

我们还没有定义处理程序。在端点中,路径参数使用:param 注释进行访问。这意味着对于文件中的 GET 请求,train-id 将作为 path 参数传递。现在,转到 controllers 文件夹,并将 app.go 文件中的现有控制器修改为这样:


package controllers
import (
    "log"
    "net/http"
    "strconv"
    "github.com/revel/revel"
)
type App struct {
    *revel.Controller
}
// TrainResource 是用于保存铁路信息的模型
type TrainResource struct {
    ID int `json:"id"`
    DriverName string `json:"driver_name"`
    OperatingStatus bool `json:"operating_status"`
}
// GetTrain 处理对火车资源的 GET
func (c App) GetTrain() revel.Result {
    var train TrainResource
    // 从路径参数中获取值。
    id := c.Params.Route.Get("train-id")
    // 使用此 ID 从数据库查询并填充 train 表....
    train.ID,_ = strconv.Atoi(id)
    train.DriverName = "Logan" // 来自数据库
    train.OperatingStatus = true // 来自数据库
    c.Response.Status = http.StatusOK
    return c.RenderJSON(train)
}
// CreateTrain 处理对火车资源的 POST
func (c App) CreateTrain() revel.Result {
    var train TrainResource
    c.Params.BindJSON(&train)
    // 使用 train.DriverName 和 train.OperatingStatus 插入到 train 表中....
    train.ID = 2
    c.Response.Status = http.StatusCreated
    return c.RenderJSON(train)
}
// RemoveTrain 实现对火车资源的 DELETE
func (c App) RemoveTrain() revel.Result {
    id := c.Params.Route.Get("train-id")
    // 使用 ID 从 train 表中删除记录....
    log.Println("成功删除资源:", id)
    c.Response.Status = http.StatusOK
    return c.RenderText("")
}

我们在文件 app.go 中创建了 API 处理程序。这些处理程序的名称应与我们在路由文件中提到的名称匹配。我们可以使用带有 *revel.Controller 作为其成员的结构创建一个 Revel 控制器。然后,我们可以向其附加任意数量的处理程序。控制器保存了传入 HTTP 请求的信息,因此我们可以在处理程序中使用信息,如查询参数、路径参数、JSON 主体、表单数据等。

我们正在定义 TrainResource 作为一个数据持有者。在 GetTrain 中,我们使用 c.Params.Route.Get 函数获取路径参数。该函数的参数是我们在路由文件中指定的路径参数(这里是 train-id)。该值将是一个字符串。我们需要将其转换为 Int 类型以与 train.ID 进行映射。然后,我们使用 c.Response.Status 变量(而不是函数)将响应状态设置为 200 OKc.RenderJSON 接受一个结构体并将其转换为 JSON 主体。

CreateTrain 中,我们添加了 POST 请求逻辑。我们创建了一个新的 TrainResource 结构体,并将其传递给一个名为 c.Params.BindJSON 的函数。BindJSON 的作用是从 JSON POST 主体中提取参数,并尝试在结构体中查找匹配的字段并填充它们。当我们将 Go 结构体编组为 JSON 时,字段名将按原样转换为键。但是,如果我们将 jason:"id" 字符串格式附加到任何结构字段上,它明确表示从该结构编组的 JSON 应具有键 id,而不是 ID。在使用 JSON 时,这是 Go 中的一个良好做法。然后,我们向 HTTP 响应添加了一个 201 创建的状态。我们返回火车结构体,它将在内部转换为 JSON。

RemoveTrain 处理程序逻辑与 GET 类似。一个微妙的区别是没有发送任何内容。正如我们之前提到的,数据库 CRUD 逻辑在上述示例中被省略。读者可以通过观察我们在 go-restfulGin 部分所做的工作来尝试添加 SQLite3 逻辑。

最后,默认端口号是 9000,Revel 服务器运行的配置更改端口号在 conf/app.conf 文件中。让我们遵循在 8000 上运行我们的应用程序的传统。因此,将文件的 http 端口部分修改为以下内容。这告诉 Revel 服务器在不同的端口上运行:


......

# 要监听的 IP 地址。
http.addr = "0.0.0.0"
# 要监听的端口。
http.port = 8000 # 从 9000 更改为 8000 或任何端口
# 是否使用 SSL。
http.ssl = false
......

现在,我们可以使用以下命令运行 Revel API 服务器:


revel run github.com/narenaryan/railAPIRevel

我们的应用服务器在 http://localhost:8000 上启动。现在,让我们进行一些 API 请求:


CURL -X GET "http://10.102.78.140:8000/v1/trains/1"

output
=======
{
    "id": 1,
    "driver_name": "Logan",
    "operating_status": true
}

POST 请求:


curl -X POST \
    http://10.102.78.140: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
}

DELETEGET相同,但不返回主体。这里,代码是为了展示如何处理请求和响应。请记住,Revel 不仅仅是一个简单的 API 框架。它是一个类似于 Django(Python)或 Ruby on Rails 的完整的 Web 框架。我们在 Revel 中内置了模板,测试和许多其他功能。

确保为GOPATH/user创建一个新的 Revel 项目。否则,当运行项目时,Revel 命令行工具可能找不到项目。

我们在本章中看到的所有 Web 框架都支持中间件。 go-restful将其中间件命名为Filters,而Gin将其命名为自定义中间件。 Revel 将其中间件拦截器。中间件在函数处理程序之前和之后分别读取或写入请求和响应。在第三章中,使用中间件和 RPC,我们将更多地讨论中间件。

摘要

在本章中,我们尝试使用 Go 中的一些 Web 框架构建了一个地铁轨道 API。最受欢迎的是go-restfulGin GonicRevel.go。我们首先学习了如何在 Go 应用程序中进行第一个数据库集成。我们选择了 SQLite3,并尝试使用go-sqlite3库编写了一个示例应用程序。

接下来,我们探索了go-restful,并详细了解了如何创建路由和处理程序。go-restful具有在资源之上构建 API 的概念。它提供了一种直观的方式来创建可以消耗和产生各种格式(如 XML 和 JSON)的 API。我们使用火车作为资源,并构建了一个在数据库上执行 CRUD 操作的 API。我们解释了为什么go-restful轻量级,并且可以用来创建低延迟的 API。接下来,我们看到了Gin框架,并尝试重复相同的 API,但是创建了一个围绕车站资源的 API。我们看到了如何在 SQL 数据库时间字段中存储时间。我们建议使用Gin来快速原型化您的 API。

最后,我们尝试使用Revel.go网络框架在火车资源上创建另一个 API。我们开始创建一个项目,检查了目录结构,然后继续编写一些服务(没有db集成)。我们还看到了如何运行应用程序并使用配置文件更改端口。

本章的主题是为您提供一些创建 RESTful API 的精彩框架。每个框架可能有不同的做事方式,选择您感到舒适的那个。当您需要一个端到端的网络应用程序(模板和用户界面)时,请使用Revel.go,当您需要快速创建 REST 服务时,请使用Gin,当 API 的性能至关重要时,请使用go-rest

第五章:使用 MongoDB 和 Go 创建 REST API

在本章中,我们将介绍名为MongoDB的 NoSQL 数据库。我们将学习MongoDB如何适用于现代 Web 服务。我们将首先学习有关MongoDB集合和文档的知识。我们将尝试使用MongoDB作为数据库创建一个示例 API。在这个过程中,我们将使用一个名为mgo的驱动程序包。然后,我们将尝试为电子商务 REST 服务设计一个文档模型。

基本上,我们将讨论以下主题:

  • 安装和使用 MongoDB

  • 使用 Mongo shell

  • 使用 MongoDB 作为数据库构建 REST API

  • 数据库索引的基础知识

  • 设计电子商务文档模型

获取代码

您可以从github.com/narenaryan/gorestful/tree/master/chapter5获取本章的代码示例。本章的示例是单个程序和项目的组合。因此,将相应的目录复制到您的GOPATH中,以正确运行代码示例。

MongoDB 简介

MongoDB是一种受到全球开发人员青睐的流行 NoSQL 数据库。它不同于传统的关系型数据库,如 MySQL、PostgreSQL 和 SQLite3。与其他数据库相比,MongoDB 的主要区别在于在互联网流量增加时易于扩展。它还将 JSON 作为其数据模型,这使我们可以直接将 JSON 存储到数据库中。

许多大公司,如 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 相对于关系型数据库的主要优势是:

  • 易于建模(无模式)

  • 可以利用查询功能

  • 文档结构适合现代 Web 应用程序(JSON)

  • 比关系型数据库更具可扩展性

安装 MongoDB 并使用 shell

MongoDB 可以轻松安装在任何平台上。在 Ubuntu 16.04 上,我们需要在运行apt-get命令之前执行一些进程:

sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6 
 echo "deb [ arch=amd64,arm64 ] http://repo.mongodb.org/apt/ubuntu xenial/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list

sudo apt-get update && sudo apt-get install mongodb-org

它将在最后一步要求确认安装;按Y。安装完成后,我们需要使用以下命令启动 MongoDB 守护进程:

systemctl start mongod

所有前面的命令都需要由 root 用户运行。如果用户不是 root 用户,请在每个命令前使用sudo关键字。

我们还可以从网站手动下载 MongoDB,并使用~/mongodb/bin/mongod/命令运行服务器。为此,我们需要创建一个 init 脚本,因为如果关闭终端,服务器将被关闭。我们还可以使用nohup在后台运行服务器。通常最好使用apt-get进行安装。

要在 macOS X 上安装 MongoDB,请使用 Homebrew 软件。我们可以使用以下命令轻松安装它:

brew install mongodb

之后,我们需要创建 MongoDB 存储其数据库的db目录:

mkdir -p /data/db

然后,使用chown更改该文件的权限:

chown -R `id -un` /data/db

现在我们已经准备好了 MongoDB。我们可以在终端窗口中使用以下命令运行它,这将启动 MongoDB 守护进程:

mongod

请查看以下截图:

在 Windows 上,我们可以手动从网站下载安装程序二进制文件,并通过将安装的bin目录添加到PATH变量中来启动它。然后,我们可以使用mongod命令运行它。

使用 Mongo shell

每当我们开始使用 MongoDB 时,我们应该先玩一会儿。查找可用的数据库、集合、文档等可以使用一个名为 Mongo shell 的简单工具。这个 shell 是与我们在前面部分提到的安装步骤一起打包的。我们需要使用以下命令启动它:

mongo

参考以下截图:

如果您看到这个屏幕,一切都进行得很顺利。如果您遇到任何错误,服务器没有运行或者有其他问题。对于故障排除,您可以查看官方 MongoDB 故障排除指南docs.mongodb.com/manual/faq/diagnostics。客户端提供了有关 MongoDB 版本和其他警告的信息。要查看所有可用的 shell 命令,请使用help命令。

现在我们已经准备好了。让我们创建一个名为movies的新集合,并将前面的示例文档插入其中。默认情况下,数据库将是一个测试数据库。您可以使用use命令切换到一个新的数据库:

> show databases

它显示所有可用的数据库。默认情况下,admintestlocal是三个可用的数据库。为了创建一个新的数据库,只需使用use db_name

> use appdb

这将把当前数据库切换到appdb数据库。如果您尝试查看可用的数据库,它不会显示出来,因为 MongoDB 只有在插入数据时(第一个集合或文档)才会创建数据库。因此,现在我们可以通过从 shell 中插入一个文档来创建一个新的集合。然后,我们可以使用以下命令将前面的《星际迷航》电影记录插入到名为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 数据库中,我们使用自动递增以及一个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 } } )> 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 响应,insertId现在已经更改为非常长的59574125bf7a73d140d5ba4a。这是 MongoDB 生成的唯一哈希。现在,让我们看看我们集合中的所有文档。我们还可以使用insertMany函数一次插入一批文档:

> 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 } }

在 movies 集合上使用find函数返回集合中所有匹配的文档。为了返回单个文档,使用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 } }

前面 mongo 语句中的过滤查询是:

{year: {$eq: 2008}}

这说明搜索条件是年份,值应该是2008$eq被称为过滤操作符,它有助于关联字段和数据之间的条件。它相当于 SQL 中的=操作符。在 SQL 中,等效的查询可以写成:

SELECT * FROM movies WHERE year=2008;

我们可以简化上次编写的 mongo 查询语句为:

> db.movies.find({year: 2008})

这个查询和上面的 mongo 查询是一样的,返回相同的一组文档。前一种语法使用了$eq,这是一个查询操作符。从现在开始,让我们简单地称之为操作符。其他操作符有:

操作符 功能
$lt 小于
$gt 大于
$in
$lte 小于或等于
$ne 不等于

现在,让我们对自己提出一个问题。我们想获取所有预算超过 1.5 亿美元的文档。我们如何使用之前获得的知识进行过滤?看一下以下代码片段:

> 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 年发布的预算超过 1.5 亿美元的所有电影:

> db.movies.find({'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 中的三个操作。我们看到了如何创建数据库和集合。然后,我们使用过滤器插入文档并读取它们。现在是删除操作的时候了。我们可以使用deleteOnedeleteMany函数从给定的集合中删除文档:

> db.movies.deleteOne({"_id": ObjectId("59574125bf7a73d140d5ba4a")})
{ "acknowledged" : true, "deletedCount" : 1 }

传递给deleteOne函数的参数是过滤条件,类似于读操作。所有匹配给定条件的文档都将从集合中删除。响应中有一个很好的确认消息,其中包含被删除的文档数量。

前面的所有部分都讨论了 MongoDB 的基础知识,但是使用的是执行 JavaScript 语句的 shell。手动从 shell 执行db语句并不是很有用。我们需要使用驱动程序在 Go 中调用 Mongo DB 的 API。在接下来的部分中,我们将看到一个名为mgo的驱动程序包。官方的 MongoDB 驱动程序包括 Python、Java 和 Ruby 等语言。Go 的mgo驱动程序是一个第三方软件包。

介绍mgo,一个用于 Go 的 MongoDB 驱动程序

mgo是一个丰富的 MongoDB 驱动程序,它方便开发人员编写应用程序,与 MongoDB 进行通信,而无需使用 Mongo shell。使用mgo驱动程序,Go 应用程序可以轻松地与 MongoDB 进行所有 CRUD 操作。这是一个开源实现,可以自由使用和修改。由 Labix 维护。我们可以将其视为 MongoDB API 的包装器。安装该软件包非常简单,请参考以下命令:

go get gopkg.in/mgo.v2

这将在$GOPATH中安装软件包。现在,我们可以在我们的 Go 程序中引用该软件包,如下所示:

import "gopkg.in/mgo.v2"

让我们编写一个简单的程序,与 MongoDB 通信并插入The Dark Knight电影记录:

package main

import (
        "fmt"
        "log"

        mgo "gopkg.in/mgo.v2"
        "gopkg.in/mgo.v2/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"`
}

func main() {
        session, err := mgo.Dial("127.0.0.1")
        if err != nil {
                panic(err)
        }
        defer session.Close()

        c := session.DB("appdb").C("movies")

        // 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 into MongoDB
        err = c.Insert(darkNight)
        if err != nil {
                log.Fatal(err)
        }

        // Now query the movie back
        result := Movie{}
        // bson.M is used for nested fields
        err = c.Find(bson.M{"boxOffice.budget": bson.M{"$gt": 150000000}}).One(&result)
        if err != nil {
                log.Fatal(err)
        }

        fmt.Println("Movie:", result.Name)
}

如果您观察代码,我们导入了mgo软件包以及bson软件包。接下来,我们创建了模型我们的 JSON 要插入到数据库中的结构。在主函数中,我们使用mgo.Dial函数创建了一个会话。之后,我们使用链式方式的DBC函数获取了一个集合:

c := session.DB("appdb").C("movies")

这里,c代表集合。我们正在从appdb中获取 movies 集合。然后,我们通过填充数据创建了一个结构对象。接下来,我们在c集合上使用Insert函数将darkNight数据插入集合中。该函数还可以接受一系列结构对象,以插入一批电影。然后,我们在集合上使用Find函数来读取具有给定条件的电影。在这里,与我们在 shell 中使用的条件不同,查询条件(查询)的形成也不同。由于 Go 不是 JavaScript shell,我们需要一个可以将普通过滤查询转换为 MongoDB 可理解查询的转换器。mgo软件包中的bson.M函数就是为此而设计的:

bson.M{"year": "2008"}

但是,如果我们需要使用运算符执行高级查询怎么办?我们可以通过用bson.M函数替换普通的 JSON 语法来实现这一点。我们可以使用以下查询从数据库中找到预算超过$150,000,000 的电影:

bson.M{"boxOffice.budget": bson.M{"$gt": 150000000}}

如果将此与 shell 命令进行对比,我们只需在 JSON 查询前面添加bson.M,然后将其余查询按原样编写。操作符号应该在这里是一个字符串("$gt")。

在结构定义中还有一个值得注意的事情是,我们为每个字段添加了bson:identifier标签。没有这个标签,Go 会将 BoxOffice 存储为 boxoffice。因此,为了让 Go 保持 CamelCase,我们添加了这些标签。现在,让我们运行这个程序并查看输出:

go run mgoIntro.go

输出如下:

Movie: The Dark Knight

查询结果可以存储在一个新的结构中,并可以序列化为 JSON 供客户端使用。

使用 Gorilla Mux 和 MongoDB 构建 RESTful API

在之前的章节中,我们探讨了构建 RESTful API 的所有可能方式。我们首先研究了 HTTP 路由器,然后是 web 框架。但作为个人选择,为了使我们的 API 轻量化,我们更喜欢 Gorilla Mux 作为默认选择,以及mgo作为 MongoDB 驱动程序。在本节中,我们将构建一个完整的电影 API,其中包括数据库和 HTTP 路由器的端到端集成。我们看到了如何使用 Go 和 MongoDB 创建新资源并检索它。利用这些知识,让我们编写这个程序:

package main

import (
        "encoding/json"
        "io/ioutil"
        "log"
        "net/http"
        "time"

        "github.com/gorilla/mux"
        mgo "gopkg.in/mgo.v2"
        "gopkg.in/mgo.v2/bson"
)

// DB stores the database session imformation. Needs to be initialized once
type DB struct {
        session    *mgo.Session
        collection *mgo.Collection
}

type Movie struct {
        ID        bson.ObjectId `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"`
}

// GetMovie fetches a movie with a given ID
func (db *DB) GetMovie(w http.ResponseWriter, r *http.Request) {
        vars := mux.Vars(r)
        w.WriteHeader(http.StatusOK)
        var movie Movie
        err := db.collection.Find(bson.M{"_id": bson.ObjectIdHex(vars["id"])}).One(&movie)
        if err != nil {
                w.Write([]byte(err.Error()))
        } else {
                w.Header().Set("Content-Type", "application/json")
                response, _ := json.Marshal(movie)
                w.Write(response)
        }

}

// 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)
        // Create a Hash ID to insert
        movie.ID = bson.NewObjectId()
        err := db.collection.Insert(movie)
        if err != nil {
                w.Write([]byte(err.Error()))
        } else {
                w.Header().Set("Content-Type", "application/json")
                response, _ := json.Marshal(movie)
                w.Write(response)
        }
}

func main() {
        session, err := mgo.Dial("127.0.0.1")
        c := session.DB("appdb").C("movies")
        db := &DB{session: session, collection: c}
        if err != nil {
                panic(err)
        }
        defer session.Close()
        // Create a new router
        r := mux.NewRouter()
        // Attach an elegant path with handler
        r.HandleFunc("/v1/movies/{id:[a-zA-Z0-9]*}", db.GetMovie).Methods("GET")
        r.HandleFunc("/v1/movies", db.PostMovie).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())
}

让我们将这个程序命名为movieAPI.go并运行它:

go run movieAPI.go

接下来,我们可以使用 curl 或 Postman 发出POST API 请求来创建一个新的电影:

curl -X POST \
 http://localhost:8000/v1/movies \
 -H 'cache-control: no-cache' \
 -H 'content-type: application/json' \
 -H 'postman-token: 6ef9507e-65b3-c3dd-4748-3a2a3e055c9c' \
 -d '{ "name" : "The Dark Knight", "year" : "2008", "directors" : [ "Christopher Nolan" ], "writers" : [ "Jonathan Nolan", "Christopher Nolan" ], "boxOffice" : { "budget" : 185000000, "gross" : 533316061 }
}'

这将返回以下响应:

{"id":"5958be2a057d926f089a9700","name":"The Dark Knight","year":"2008","directors":["Christopher Nolan"],"writers":["Jonathan Nolan","Christopher Nolan"],"boxOffice":{"budget":185000000,"gross":533316061}}

我们的电影已成功创建。这里返回的 ID 是由mgo包生成的。MongoDB 希望驱动程序提供唯一的 ID。如果没有提供,那么Db会自己创建一个。现在,让我们使用 curl 发出一个GET API 请求:

curl -X GET \
 http://localhost:8000/v1/movies/5958be2a057d926f089a9700 \
 -H 'cache-control: no-cache' \
 -H 'postman-token: 00282916-e7f8-5977-ea34-d8f89aeb43e2'

它返回了我们在创建资源时得到的相同数据:

{"id":"5958be2a057d926f089a9700","name":"The Dark Knight","year":"2008","directors":["Christopher Nolan"],"writers":["Jonathan Nolan","Christopher Nolan"],"boxOffice":{"budget":185000000,"gross":533316061}}

在前面的程序中发生了很多事情。我们将在接下来的章节中详细解释。

在前面的程序中,为了简单起见,PostMovie中跳过了为操作分配正确状态代码的微不足道的逻辑。读者可以随意修改程序,为操作添加正确的状态代码(200 OK,201 Created 等)。

首先,我们导入程序所需的必要包。我们导入了mgobson用于与 MongoDB 相关的实现,Gorilla Mux 用于 HTTP 路由编码/JSON,以及 ioutil 用于在 HTTP 请求的生命周期中读取和写入 JSON。

然后,我们创建了一个名为DB的结构,用于存储 MongoDB 的 session 和 collection 信息。我们需要这个结构,以便拥有全局 session,并在多个地方使用它,而不是创建一个新的 session(客户端连接)。看一下以下代码片段:

// DB stores the database session imformation. Needs to be initialized once 
type DB struct {
   session *mgo.Session 
   collection *mgo.Collection 
}

我们需要这样做是因为 Mux 的多个 HTTP 处理程序需要这些信息。这是一种简单的将通用数据附加到不同函数的技巧。在 Go 中,我们可以创建一个结构,并向其添加函数,以便在函数中访问结构中的数据。然后,我们声明了存储电影嵌套 JSON 信息的结构。在 Go 中,为了创建嵌套的 JSON 结构,我们应该嵌套结构。

接下来,我们在DB结构上定义了两个函数。我们将在后面使用这两个函数作为 Gorilla Mux 路由器的处理程序。这两个函数可以访问 session 和 collection 信息,而无需创建新的 session。GetMovie处理程序从 MongoDB 读取数据,并将 JSON 返回给客户端。PostMovie在名为moviex的集合中在数据库中创建一个新资源(这里是电影)。

现在,来到主函数,我们在这里创建了 session 和 collection。session将在整个程序的生命周期内保持不变。但如果需要,处理函数可以通过使用session变量来覆盖 collection。这使我们能够编写可重用的数据库参数。然后,我们创建了一个新的路由器,并使用HandleFunc附加了处理函数和路由。然后,我们创建了一个在 localhost 的8000端口上运行的服务器。

PostMovie中,我们使用mgo函数的bson.NewObjectId()创建一个新的哈希 ID。这个函数每次调用时都会返回一个新的哈希。然后我们将其传递给我们插入到数据库中的结构。我们使用collection.Insert moviefunction 在集合中插入一个文档。如果出现问题,这将返回一个错误。为了发送一条消息回去,我们使用json.Marshal对一个结构进行编组。如果你仔细观察Movie结构的结构,它是这样的:

type Movie struct {
  ID        bson.ObjectId `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"`
}

右侧的标识符json:"id" bson:"_id,omitempty"是一个辅助工具,用于在对结构执行编组或解组时显示序列化的方式。bson标签显示了如何将字段插入到 MongoDB 中。json显示了我们的 HTTP 处理程序应该从客户端接收和发送数据的格式。

GetMovie中,我们使用Mux.vars映射来获取作为路径参数传递的 ID。我们不能直接将 ID 传递给 MongoDB,因为它期望的是 BSON 对象而不是普通字符串。为了实现这一点,我们使用bson.ObjectIdHex函数。一旦我们得到了给定 ID 的电影,它将被加载到结构对象中。接下来,我们使用json.Marshal函数将其序列化为 JSON,并将其发送回客户端。我们可以很容易地向前面的代码中添加PUT(更新)和DELETE方法。我们只需要定义另外两个处理程序,如下所示:

// 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)
    // Create an Hash ID to insert
    err := db.collection.Update(bson.M{"_id": bson.ObjectIdHex(vars["id"])}, bson.M{"$set": &movie})
    if err != nil {
      w.WriteHeader(http.StatusOK)
      w.Write([]byte(err.Error()))
    } else {
      w.Header().Set("Content-Type", "text")
      w.Write([]byte("Updated succesfully!"))
    }
}

// DeleteMovie removes the data from the db
func (db *DB) DeleteMovie(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    // Create an Hash ID to insert
    err := db.collection.Remove(bson.M{"_id": bson.ObjectIdHex(vars["id"])})
    if err != nil {
      w.WriteHeader(http.StatusOK)
      w.Write([]byte(err.Error()))
    } else {
      w.Header().Set("Content-Type", "text")
      w.Write([]byte("Deleted succesfully!"))
    }
}

这种方法与mgo的 DB 方法完全相同。在这里,我们使用了UpdateRemove函数。由于这些不重要,我们可以只向客户端发送状态而不发送正文。为了使这些处理程序处于活动状态,我们需要在前面程序的主块中添加这两行:

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.go文件中找到。

通过索引提高查询性能

我们都知道,在阅读一本书时,索引非常重要。当我们试图在书中搜索一个主题时,我们首先翻阅索引页。如果找到索引,然后我们去到该主题的具体页码。但这里有一个缺点。我们为了这种索引而使用了额外的页面。同样,当我们查询某些内容时,MongoDB 需要遍历所有文档。如果文档存储了重要字段的索引,它可以快速地将数据返回给我们。与此同时,我们浪费了额外的空间来进行索引。

在计算领域,B 树是一个重要的数据结构,用于实现索引,因为它可以对节点进行分类。通过遍历该树,我们可以在较少的步骤中找到我们需要的数据。我们可以使用 MongoDB 提供的createIndex函数来创建索引。让我们以学生和他们在考试中的分数为例。我们将更频繁地进行GET操作,并对分数进行排序。这种情况下的索引可以用以下形式来可视化。看一下下面的图表:

这是 MongoDB 网站提供的官方示例。由于频繁使用,分数是要进行索引的字段。一旦进行了索引,数据库就会在二叉树中存储每个文档的地址。每当有人查询这个字段时,它会检查范围运算符(在这种情况下是$lt),遍历二叉树,并以更短的步骤获取文档的地址。由于分数被索引,排序操作的成本较低。因此,数据库返回排序(升序或降序)结果所需的时间更短。

回到我们之前的电影 API 示例,我们可以为数据创建索引。默认情况下,所有_id字段都被索引,这里使用 mongo shell 来展示。以前,我们将年份字段视为字符串。让我们将其修改为整数并进行索引。使用mongo命令启动 mongo shell。使用一个新的 mongo 数据库并将一个文档插入其中:

> 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})

这一行为检索数据库记录添加了魔力。现在,所有与年份相关的查询都利用了索引:

> 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 中,我们还可以有复合索引,可以索引多个字段。

为了查看查询的执行时间,请在query函数之后使用explain函数。例如,db.movies.find({year: {$lt: 2010}}).explain("executionStats")。这将解释查询的获胜计划,以毫秒为单位的时间,使用的索引等等。

使用explain函数查看索引和非索引数据的性能。

设计电子商务数据文档模型

到目前为止,我们已经看到了如何与 MongoDB 交互,并为我们的 REST API 执行 CRUD 操作。在这里,我们将定义一个可以由 MongoDB 实现的真实世界 JSON 文档。让我们为电子商务问题的 JSON 设计提出设计。这五个组件对于任何电子商务设计都是必不可少的:

  • 产品

  • 客户/用户

  • 类别

  • 订单

  • 回顾

让我们看看每个组件的模式:

产品:

{
    _id: ObjectId("59603d3b0f41ead96110cf4f"),
    sku: 1022,
    slug: "highlander-shirt-223",
    name: "Highlander casual shirt",
    description: "A nice looking casual shirt for men",
    details: {
      model_number: 235476,
      manufacturer: "HighLander",
      color: "light blue",
      mfg_date: new Date(2017, 4, 8),
      size: 40 
    },
    reviews: 3,
    pricing: {
      cost: 23,
      retail: 29
    },
    categories: {
      ObjectId("3d3b10f41efad96g110vcf4f"),
      ObjectId("603d3eb0ft41ead96110cf4f")
    },
    tags: ["shirts", "men", "clothing"],
    reviews: {
      ObjectId("3bd310f41efad96g110vcf4f"),
      ObjectId("f4e603d3eb0ft41ead96110c"),
      ObjectId("96g3bd310f41efad110vcf4g")
    }
}

类别:

{
    _id: ObjectId("6d3b56900f41ead96110cf4f"),
    name: "Casual Shirts",
    description: "All casual shirts for men",
    slug: "casual-shirts",
    parent_categories: [{
      slug: "home"
      name: "Home",
      _id: ObjectId("3d3b10f41efad96g110vcf4f"),
    }, 
    {
      slug: "shirts"
      name: "Shirts",
      _id: ObjectId("603d3eb0ft41ead96110cf4f"),
    }]
}

用户:

{
  _id: ObjectId("4fcf3eb0ft41ead96110"),
  username: "John",
  email_address: "john.p@gmail.com",
  password: "5kj64k56hdfjkhdfkgdf98g79df7g9dfg",
  first_name: "John",
  last_name: "Pauling",
  address_multiple: [{
    type: "home"
    street: "601 Sherwood Ave",
    city: "San Bernardino",
    state: "California",
    pincode: 94565
  }, 
  {
    type: "work"
    street: "241 Indian Spring St",
    city: "Pittsburg",
    state: "California",
    pincode: 94565
  }] ,
  payments: {
    name: "Paypal",
    auth: {
      token: "dfghjvbsclka76asdadn89"
    }
  }
}

顺序:

{
  _id: ObjectId(),
  user: ObjectId("4fcf3eb0ft41ead96110"),
  state: "cart",
  item_queue: [{
    item: ObjectId("59603d3b0f41ead96110cf4f"),
    quantity: 1,
    cost: 23
  }],
  shipping_address: {
    type: "work"
    street: "241 Indian Spring St",
    city: "Pittsburg",
    state: "California",
    pincode: 94565
  },
  total: 23, 
}

回顾:

{
  _id: ObjectId("5tcf3eb0ft41ead96110"),
  product: ObjectId("4fcf3eb0ft41ead96110"),
  posted_date: new Date(2017, 2, 6),
  title: "Overall satisfied with product",
  body: "The product is good and durable. After dry wash, the color hasn't changed much",
  user: ObjectId(),
  rating: 4,
  upvotes: 3,
  downvotes: 0,
  upvoters: [ObjectId("41ea5tcf3eb0ftd9233476hg"),
             ObjectId("507f1f77bcf86cd799439011"),
             ObjectId("54f113fffba522406c9cc20f")
            ],
  downvoters: []
}

所有前述的模式都是为了让人了解如何设计电子商务 REST 服务。最终数据中应包含所有必要的字段。

请注意,前述的 JSON 不是真正的 JSON,而是 Mongo shell 中使用的形式。在创建服务时请注意这种差异。提供模式是为了让读者看到电子商务关系数据的设计方式。

由于我们已经定义了模式,读者可以进行编码练习。您能否利用我们在本章开头部分获得的知识来创建一个符合前述模式的 REST 服务?无论如何,我们将在接下来的章节中在其他数据库中实现这个模型。

总结

首先,我们从介绍 MongoDB 及其如何解决现代 Web 问题开始了本章。MongoDB 是一种与传统关系数据库不同的 NoSQL 数据库。然后,我们学习了如何在所有平台上安装 MongoDB 以及如何启动 Mongo 服务器。然后我们探索了 Mongo shell 的特性。Mongo shell 是一个用于快速检查或执行 CRUD 操作以及许多其他操作的工具。我们看了查询的操作符符号。接下来我们介绍了 Go 的 MongoDB 驱动程序mgo并学习了它的用法。我们使用mgo和 MongoDB 创建了一个持久的电影 API。我们看到了如何将 Go 结构映射到 JSON 文档。

在 MongoDB 中,并非所有查询都是高效的。因此,为了提高查询性能,我们看到了通过索引机制来减少文档获取时间的方法,通过将文档按 B 树的顺序排列。我们看到了如何使用explain命令来测量查询的执行时间。最后,我们通过提供 BSON(Mongo shell 的 JSON)来设计了一个电子商务文档。

第六章:使用协议缓冲区和 GRPC

在本章中,我们将进入协议缓冲区的世界。我们将发现使用协议缓冲区而不是 JSON 的好处,以及何时使用两者。我们将使用 Google 的proto库来编译协议缓冲区。我们将尝试使用协议缓冲区编写一些可以与 Go 或其他应用程序(如 Python、NodeJS 等)通信的 Web 服务。然后,我们将解释 GRPC,一种高级简化的 RPC 形式。我们将学习 GRPC 和协议缓冲区如何帮助我们构建可以被任何客户端消费的服务。我们还将讨论 HTTP/2 及其优势,以及其在普通 HTTP/1.1 基于 JSON 的服务上的优势。

简而言之,我们将涵盖以下主题:

  • 协议缓冲区介绍

  • 协议缓冲区的格式

  • 协议缓冲区的编译过程

  • GRPC,一个现代的 RPC 库

  • 使用 GRPC 进行双向流

获取代码

您可以从github.com/narenaryan/gorestful/tree/master/chapter6获取本章的代码示例。本章的示例是单个程序和项目的组合。因此,请将相应的目录复制到您的GOPATH中,以正确运行代码示例。

协议缓冲区介绍

HTTP/1.1 是 Web 社区采用的标准。近年来,由于其优势,HTTP/2 变得更加流行。使用 HTTP/2 的一些好处包括:

  • 通过 TLS(HTTPS)加密数据

  • 头部压缩

  • 单个 TCP 连接

  • 回退到 HTTP/1.1

  • 所有主要浏览器的支持

谷歌关于协议缓冲区的技术定义是:

协议缓冲区是一种灵活、高效、自动化的序列化结构化数据的机制 - 想象一下 XML,但更小、更快、更简单。您只需定义一次您希望数据结构化的方式,然后您可以使用特殊生成的源代码轻松地将您的结构化数据写入和从各种数据流中读取,并使用各种语言。您甚至可以更新数据结构,而不会破坏针对“旧”格式编译的已部署程序。

在 Go 中,协议缓冲区与 HTTP/2 结合在一起。它们是一种类似 JSON 但严格类型化的格式,只能从客户端到服务器理解。首先,我们将了解为什么存在 protobufs(协议缓冲区的简称)以及如何使用它们。

协议缓冲区在序列化结构化数据方面比 JSON/XML 有许多优势,例如:

  • 它们更简单

  • 它们的大小是 JSON/XML 的 3 到 10 倍

  • 它们快 20 到 100 倍

  • 它们不太模棱两可

  • 它们生成易于以编程方式使用的数据访问类

协议缓冲区语言

协议缓冲区是具有极简语法的文件。我们编译协议缓冲区,目标文件将为编程语言生成。例如,在 Go 中,编译后的文件将是一个.go文件,其中包含映射 protobuf 文件的结构。在 Java 中,将创建一个类文件。将协议缓冲区视为具有特定顺序的数据的骨架。在跳入实际代码之前,我们需要了解类型。为了使事情变得更容易,我将首先展示 JSON 及其在协议缓冲区中的等效内容。然后,我们将实施一个实例。

在这里,我们将使用proto3作为我们的协议缓冲区版本。版本之间存在细微差异,但最新版本已经发布并进行了改进。

有许多类型的协议缓冲区元素。其中一些是:

  • 标量值

  • 枚举

  • 默认值

  • 嵌套值

  • 未知类型

首先,让我们看看如何在协议缓冲区中定义消息类型。在这里,我们尝试定义一个简单的网络接口消息:

syntax 'proto3';

message NetworkInterface {
  int index = 1;
  int mtu = 2;
  string name = 3;
  string hardwareaddr = 4;
}

语法可能看起来很新。在前面的代码中,我们正在定义一个名为NetworkInterface的消息类型。它有四个字段:index最大传输单元(MTU)名称硬件地址(MAC)。如果我们希望在 JSON 中写入相同的内容,它将如下所示:

{
   "networkInterface": {
       "index" : 0,
       "mtu" : 68,
       "name": "eth0",
       "hardwareAddr": "00:A0:C9:14:C8:29"
   }
}

字段名称已更改以符合 JSON 样式指南,但本质和结构是相同的。但是,在 protobuf 文件中给字段分配的顺序号(1,2,3,4)是什么?它们是序列化和反序列化协议缓冲区数据在两个系统之间的顺序标签。这类似于提示协议缓冲区编码/解码系统按照特定顺序分别写入/读取数据。当上述 protobuf 文件被编译并生成编程语言目标时,协议缓冲区消息将被转换为 Go 结构,并且字段将填充为空的默认值。

标量值

我们为networkInterface消息中的字段分配的类型是标量类型。这些类型类似于 Go 类型,并且与它们完全匹配。对于其他编程语言,它们将转换为相应的类型。Protobuf 是为 Go 设计的,因此大多数类型(如intint32int64stringbool)完全相同,但有一些不同。它们是:

Go 类型 Protobuf 类型
float32 float
float64 double
uint32 fixed32
uint64 fixed64
[]byte bytes

在定义 protbuf 文件中的消息时,应该牢记这些事情。除此之外,我们可以自由地使用其他 Go 类型作为普通标量类型。默认值是如果用户没有为这些标量值分配值,则将填充这些类型的值。我们都知道在任何给定的编程语言中,变量是被定义和赋值的。定义为变量分配内存,赋值为变量填充值。类比地,我们在前面的消息中定义的标量字段将被分配默认值。让我们看看给定类型的默认值:

Protobuf 类型 默认值
字符串 ""
bytes 空字节[]
bool false
int,int32,int64,float,double 0
enum 0

由于协议缓冲区使用数据结构在端系统之间达成协议,因此在 JSON 中不需要为键占用额外的空间。

枚举和重复字段

枚举为给定元素集提供数字的排序。默认值的顺序是从零到 n。因此,在协议缓冲区消息中,我们可以有一个枚举类型。让我们看一个enum的例子:

syntax 'proto3';

message Schedule{
  enum Days{
     SUNDAY = 0;
     MONDAY = 1;
     TUESDAY = 2;
     WEDNESDAY = 3;
     THURSDAY = 4;
     FRIDAY = 5;
     SATURDAY = 6;
  }
}

如果我们需要为多个枚举成员分配相同的值怎么办。Protobuf3 允许使用名为allow aliases的选项来为两个不同的成员分配相同的值。例如:

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}

在这里,STARTEDRUNNING都有一个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"]之类的内容。除了重复字段,我们还可以使用其他消息作为类型。这类似于嵌套的 JSON。例如,看一下以下代码:

{
  outerJSON: {
      outerKey1: val1,
      innerJSON: {
         innerKey1: val2
      }
  }
}

我们看到innerJSON嵌套在outerJSON的成员之一。我们如何在 protobuf 中建模相同的事物?我们可以使用嵌套消息来做到这一点,如下面的代码所示:

message Site {
  string url = 1;
  int latency = 2;
  repeated Proxy proxies = 3;
}

message Proxy {
  string url = 1;
  int latency = 2;
}

在这里,我们将Proxy类型嵌套到Site中。我们很快将看到一个包含所有这些类型字段的真实示例。

使用 protoc 编译协议缓冲区

到目前为止,我们已经讨论了如何编写协议缓冲区文件,该文件以前是用 JSON 或其他数据格式编写的。但是,我们如何将其实际集成到我们的程序中呢?请记住,协议缓冲区是数据格式,不仅仅是数据格式。它们是各种系统之间的通信格式,类似于 JSON。这是我们在 Go 程序中使用 protobuf 的实际步骤:

  1. 安装protoc命令行工具和proto库。

  2. 编写一个带有.proto扩展名的 protobuf 文件。

  3. 将其编译为目标编程语言(这里是 Go)。

  4. 从生成的目标文件中导入结构并序列化数据。

  5. 在远程机器上,接收序列化数据并将其解码为结构或类。

看一下下面的图表:

第一步是在我们的机器上安装protobuf编译器。为此,请从github.com/google/protobuf/releases下载protobuf包。在 macOS X 上,我们可以使用此命令安装protobuf

brew install protobuf

在 Ubuntu 或 Linux 上,我们可以将protoc复制到/usr/bin文件夹中:

# Make sure you grab the latest version
curl -OL https://github.com/google/protobuf/releases/download/v3.3.0/protoc-3.3.0-linux-x86_64.zip
# Unzip
unzip protoc-3.3.0-linux-x86_64.zip -d protoc3
# Move only protoc* to /usr/bin/
sudo mv protoc3/bin/protoc /usr/bin/protoc

在 Windows 上,我们可以从github.com/google/protobuf/releases/download/v3.3.0/protoc-3.3.0-win32.zip复制可执行文件(.exe)到PATH环境变量。让我们编写一个简单的协议缓冲区来说明如何编译和使用目标文件中的结构。使用以下命令在$GOPATH/src/github.com/narenaryan(这是我们 Go 项目的位置)中创建一个名为protofiles的文件夹:

mkdir $GOPATH/src/github.com/narenaryan/protofiles

在这里,创建一个名为person.proto的文件,它模拟了一个人的信息。向其中添加一些消息,如下面的代码片段所示:

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;
}

我们创建了两个主要消息,称为AddressBookPersonAddressBook有一个人员列表。Personnameidemailphone Number。在第二行,我们将包声明为protofiles,如下所示:

package protofiles;

这告诉编译器将生成的文件添加到给定包名称的相关位置。Go 不能直接使用这个.proto文件。我们需要将其编译为有效的 Go 文件。编译后,此包名称protofiles将用于设置输出文件(在本例中为 Go)的包。要编译此协议缓冲区文件,请转到protofiles目录并运行此命令:

protoc --go_out=. *.proto

此命令将给定的协议缓冲区文件转换为具有相同名称的 Go 文件。运行此命令后,您将看到在同一目录中创建了一个新文件:

[16:20:27] naren: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,
}
.......

这只是该文件的一部分。将为给定的结构(如PersonAddressBook)创建许多 getter 和 setter 方法。此代码是自动生成的。我们需要在主程序中使用此代码来创建协议缓冲区字符串。现在,让我们创建一个名为protobufs的新目录。其中包含使用person.pb.go文件中的Person结构的main.go文件:

mkdir $GOPATH/src/github.com/narenaryan/protobufs

现在,为了让 Go 将结构序列化为协议二进制格式,我们需要安装 Go proto 驱动程序。使用go get命令安装它:

go get github.com/golang/protobuf/proto

之后,让我们编写main.go

package main

import (
  "fmt"

  "github.com/golang/protobuf/proto"
  pb "github.com/narenaryan/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("Marshaled proto data: ", body, "\n")
  fmt.Println("Unmarshaled struct: ", p1)
}

我们从protofiles包中导入协议缓冲区pb)。在proto files中,有一些结构映射到给定的协议缓冲区。我们使用Person结构并对其进行初始化。然后,我们使用proto.Marshal函数对结构进行序列化。如果我们运行这个程序,输出如下:

go run 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 >

序列化数据的第二个输出并不直观,因为proto库将数据序列化为二进制字节。协议缓冲区在 Go 中的另一个好处是,通过编译 proto 文件生成的结构体可以用于实时生成 JSON。让我们修改前面的例子。将其命名为main_json.go

package main

import (
  "fmt"

  "encoding/json"
  pb "github.com/narenaryan/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 main_json.go

{"name":"Roger F","id":1234,"email":"rf@example.com","phones":[{"number":"555-4321","type":1}]}

任何其他语言或平台都可以轻松加载这个 JSON 字符串并立即使用数据。那么,使用协议缓冲区而不是 JSON 有什么好处呢?首先,协议缓冲区旨在使两个后端系统以更小的开销进行通信。由于二进制的大小比文本小,协议缓冲区序列化的数据比 JSON 的大小小。

通过使用协议缓冲区,我们可以将 JSON 和协议缓冲区格式映射到 Go 结构。这通过在转换一个格式到另一个格式时实现了两全其美。

但是,协议缓冲区只是一种数据格式。如果我们不进行通信,它们就没有任何重要性。因此,在这里,协议缓冲区用于以 RPC 的形式在两个端系统之间传递消息。我们看到了 RPC 是如何工作的,并且在前几章中还创建了 RPC 客户端和服务器。现在,我们将扩展这些知识,使用Google 远程过程调用GRPC)与协议缓冲区来扩展我们的微服务通信。在这种情况下,服务器和客户端可以以协议缓冲区格式进行通信。

GRPC 简介

GRPC 是一种在两个系统之间发送和接收消息的传输机制。这两个系统通常是服务器和客户端。正如我们在前几章中所描述的,RPC 可以在 Go 中实现以传输 JSON。我们称之为 JSON RPC 服务。同样,Google RPC 专门设计用于以协议缓冲区的形式传输数据。

GRPC 使服务创建变得简单而优雅。它提供了一套不错的 API 来定义服务并开始运行它们。在本节中,我们将主要关注如何创建 GRPC 服务并使用它。GRPC 的主要优势是它可以被多种编程语言理解。协议缓冲区提供了一个通用的数据结构。因此,这种组合使各种技术堆栈和系统之间能够无缝通信。这是分布式计算的核心概念。

Square、Netflix 等公司利用 GRPC 来扩展其庞大的流量服务。Google 的前产品经理 Andrew Jessup 在一次会议上表示,在 Google,每天处理数十亿次 GRPC 调用。如果任何商业组织需要采用 Google 的做法,它也可以通过对服务进行调整来处理流量需求。

在编写服务之前,我们需要安装grpc Go 库和protoc-gen插件。使用以下命令安装它们:

go get google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go

GRPC 相对于传统的 HTTP/REST/JSON 架构具有以下优势:

  • GRPC 使用 HTTP/2,这是一种二进制协议

  • HTTP/2 中可以进行头部压缩,这意味着开销更小

  • 我们可以在一个连接上复用多个请求

  • 使用协议缓冲区进行数据的严格类型化

  • 可以进行请求或响应的流式传输,而不是请求/响应事务

看一下下面的图表:

图表清楚地显示了任何后端系统或移动应用都可以通过发送协议缓冲区请求直接与 GRPC 服务器通信。让我们使用 GRPC 和协议缓冲区在 Go 中编写一个货币交易服务。在这里,我们将展示客户端和服务器的实现方式。步骤如下:

  1. 为服务和消息创建协议缓冲区文件。

  2. 编译协议缓冲区文件。

  3. 使用生成的 Go 包创建一个 GRPC 服务器。

  4. 创建一个与服务器通信的 GRPC 客户端。

对于这个项目,在你的 Go 工作空间中创建一个名为datafiles的文件夹(这里是$GOPATH/src/github.com/narenaryan/):

mkdir grpc_example
cd grpc_example
mkdir datafiles

在其中创建一个名为transaction.proto的文件,其中定义了消息和一个服务。我们很快将看到服务是什么:

syntax = "proto3";
package datafiles;

message TransactionRequest {
   string from = 1;
   string to = 2;
   float amount = 3;
}

message TransactionResponse {
  bool confirmation = 1;
}

service MoneyTransaction {
    rpc MakeTransaction(TransactionRequest) returns (TransactionResponse) {}
}

这是服务器上的一个最简单的协议缓冲文件,用于货币交易。我们已经在 proto 文件中看到了关于消息关键字的信息。最后一个关键字service对我们来说是新的。service告诉 GRPC 将其视为服务,并且所有的 RPC 方法将作为实现此服务的服务器的接口。实现 Go 接口的结构体应该实现所有的函数。现在,让我们编译这个文件:

protoc -I datafiles/ datafiles/transaction.proto --go_out=plugins=grpc:datafiles

这个命令比我们之前使用的命令稍微长一些。这是因为这里我们使用了protoc-gen-go插件。该命令简单地表示使用数据文件作为协议文件的输入目录,并使用相同的目录输出目标 Go 文件。现在,如果我们查看文件系统,将会有两个文件:

-rw-r--r-- 1 naren staff 6215 Jul 16 17:28 transaction.pb.go
-rw-r--r-- 1 naren staff 294 Jul 16 17:28 transaction.proto

现在,在$GOPATH/src/github.com/narenaryan/grpc_example中创建另外两个目录,用于服务器和客户端逻辑。服务器实现了从 proto 文件生成的接口:

mkdir grpcServer grpcClient

现在,将一个名为server.go的文件添加到grpcServer目录中,该文件实现了交易服务:

package main

import (
  "log"
  "net"

  pb "github.com/narenaryan/grpc_example/datafiles"
  "golang.org/x/net/context"
  "google.golang.org/grpc"
  "google.golang.org/grpc/reflection"
)

const (
  port = ":50051"
)

// 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) {
  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)
  // Do database logic here....
  return &pb.TransactionResponse{Confirmation: true}, nil
}

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
    log.Fatalf("Failed to listen: %v", err)
  }
  s := grpc.NewServer()
  pb.RegisterMoneyTransactionServer(s, &server{})
  // Register reflection service on gRPC server.
  reflection.Register(s)
  if err := s.Serve(lis); err != nil {
    log.Fatalf("Failed to serve: %v", err)
  }
}

在前面的文件中发生了很多事情。首先,我们导入了所有必要的导入项。这里的新导入项是contextreflection。Context 用于创建一个context变量,它在 RPC 请求的整个生命周期内存在。这两个库都被 GRPC 用于其内部函数。

在解释下一节之前,如果我们打开生成的transaction.pb.go文件,我们可以清楚地看到有两件重要的事情:

  • RegisterMoneyTransactionServer函数

  • MakeTransaction函数作为MoneyTransactionServer接口的一部分。

为了实现一个服务,我们需要这两个东西:MakeTransaction用于实际的服务功能,以及RegisterMoneyTransactionServer用于注册服务(即创建一个在端口上运行的 RPC 服务器)。

MakeTransactionin变量具有 RPC 请求的详细信息。它基本上是一个映射到我们在协议缓冲文件中定义的TransactionRequest消息的结构。从MakeTransaction返回的是TransactionResponse。这个函数签名与我们最初在协议缓冲文件中定义的函数签名匹配:

rpc MakeTransaction(TransactionRequest) returns (TransactionResponse) {}

现在,让我们编写一个客户端。我们可以用任何编程语言编写客户端(或)服务器,但是在这里,我们为了理解 Go GRPC API,同时编写了一个客户端和服务器。在grpcClient目录中添加一个名为client.go的文件:

package main

import (
  "log"

  pb "github.com/narenaryan/grpc_example/datafiles"
  "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())
  if err != nil {
    log.Fatalf("Did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewMoneyTransactionClient(conn)

  // Prepare data. Get this from clients like Frontend or App
  from := "1234"
  to := "5678"
  amount := float32(1250.75)

  // Contact the server and print out its response.
  r, err := c.MakeTransaction(context.Background(), &pb.TransactionRequest{From: from,
    To: to, Amount: amount})
  if err != nil {
    log.Fatalf("Could not transact: %v", err)
  }
  log.Printf("Transaction confirmed: %t", r.Confirmation)
}

这个客户端也使用了grpc包。它使用一个名为context.Background()的空上下文传递给MakeTransaction函数。函数的第二个参数是TransactionRequest结构体:

&pb.TransactionRequest{From: from, To: to, Amount: amount}

它与我们在上一节讨论的理论明显相符。现在,让我们运行它并查看输出。打开一个新的控制台,并使用以下命令运行 GRPC 服务器:

go run $GOPATH/src/github.com/narenaryan/grpc_example/grpcServer/server.go

TCP 服务器开始监听端口50051。现在,打开另一个终端/Shell,并启动与该服务器通信的客户端程序:

go run $GOPATH/src/github.com/narenaryan/grpc_example/grpcClient/client.go

它打印出成功交易的输出:

2017/07/16 19:13:16 Transaction confirmed: true

同时,服务器将此消息记录到控制台中:

2017/07/16 19:13:16 Amount: 1250.750000, From A/c:1234, To A/c:5678

在这里,客户端向 GRPC 服务器发出了一个请求,并传递了From A/c号码、To A/c号码和Amount的详细信息。服务器接收这些详细信息,处理它们,并发送一个回复,表示一切正常。

由于我在我的机器上运行代码示例,我在github.com下有narenaryan作为项目目录。您可以用任何其他名称替换它。

使用 GRPC 进行双向流

GRPC 相对于传统的 HTTP/1.1 的主要优势在于它使用单个 TCP 连接在服务器和客户端之间发送和接收多个消息。我们之前看到了资金交易的示例。另一个现实世界的用例是出租车上安装的 GPS。在这里,出租车是客户端,它沿着路线发送其地理位置到服务器。最后,服务器可以根据点之间的时间和总距离计算总费用。

另一个这样的用例是当服务器需要在执行某些处理时通知客户端。这被称为服务器推送模型。当客户端仅请求一次时,服务器可以发送一系列结果。这与轮询不同,轮询中客户端每次都会请求。当需要执行一系列耗时步骤时,这可能很有用。GRPC 客户端可以将该作业升级到 GRPC 服务器。然后,服务器花费时间并将消息传递回客户端,客户端读取并执行有用的操作。让我们实现这个。

这个概念类似于 WebSockets,但适用于任何类型的平台。创建一个名为serverPush的项目:

mkdir $GOPATH/src/github.com/narenaryan/serverPush
mkdir $GOPATH/src/github.com/narenaryan/serverPush/datafiles

现在,在datafiles中编写一个与之前类似的协议缓冲区:

syntax = "proto3";
package datafiles;

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 datafiles/ datafiles/transaction.proto --go_out=plugins=grpc:datafiles

这将在datafiles目录中创建一个名为transaction.pb.go的新文件。我们将在服务器和客户端程序中使用此文件中的定义,我们将很快创建。现在,让我们编写 GRPC 服务器代码。由于引入了流,这段代码与之前的示例有些不同:

mkdir $GOPATH/src/github.com/narenaryan/serverPush/grpcServer
vi $GOPATH/src/github.com/narenaryan/serverPush/grpcServer/server.go

现在,将此程序添加到文件中:

package main

import (
  "fmt"
  "log"
  "net"
  "time"

  pb "github.com/narenaryan/serverPush/datafiles"
  "google.golang.org/grpc"
  "google.golang.org/grpc/reflection"
)

const (
  port      = ":50051"
  noOfSteps = 3
)

// server is used to create MoneyTransactionServer.
type server struct{}

// 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++ {
    // Simulating I/O or Computation process using sleep........
    // Usually this will be saving money transfer details in DB or
    // talk to the third party API
    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("Description of step %d", int32(i))}); err != nil {
      log.Fatalf("%v.Send(%v) = %v", stream, "status", err)
    }
  }
  log.Printf("Successfully transfered amount $%v from %v to %v", in.Amount, in.From, in.To)
  return nil
}

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
    log.Fatalf("Failed to listen: %v", err)
  }
  // Create a new GRPC Server
  s := grpc.NewServer()
  // Register it with Proto service
  pb.RegisterMoneyTransactionServer(s, &server{})
  // Register reflection service on gRPC server.
  reflection.Register(s)
  if err := s.Serve(lis); err != nil {
    log.Fatalf("Failed to serve: %v", err)
  }
}

MakeTransaction是我们感兴趣的函数。它以请求和流作为参数。在函数中,我们循环执行步骤的次数(这里是三次),并执行计算。服务器使用time.Sleep函数模拟模拟 I/O 或计算:

stream.Send()

这个函数从服务器向客户端发送一个流式响应。现在,让我们编写客户端程序。这也与我们在前面的代码中看到的基本 GRPC 客户端有些不同。为客户端程序创建一个新目录:

mkdir $GOPATH/src/github.com/narenaryan/serverPush/grpcClient
vi $GOPATH/src/github.com/narenaryan/serverPush/grpcClient/cilent.go

现在,在该文件中开始编写客户端逻辑:

package main

import (
  "io"
  "log"

  pb "github.com/narenaryan/serverPush/datafiles"
  "golang.org/x/net/context"
  "google.golang.org/grpc"
)

const (
  address = "localhost:50051"
)

// 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)
  }
}

func main() {
  // Set up a connection to the server.
  conn, err := grpc.Dial(address, grpc.WithInsecure())
  if err != nil {
    log.Fatalf("Did not connect: %v", err)
  }
  defer conn.Close()
  client := pb.NewMoneyTransactionClient(conn)

  // Prepare data. Get this from clients like Front-end or Android App
  from := "1234"
  to := "5678"
  amount := float32(1250.75)

  // Contact the server and print out its response.
  ReceiveStream(client, &pb.TransactionRequest{From: from,
    To: to, Amount: amount})
}

在这里,ReceiveStream是我们为了发送请求和接收一系列消息而编写的自定义函数。它接受两个参数:MoneyTransactionClientTransactionRequest。它使用第一个参数创建一个流并开始监听它。当服务器耗尽所有消息时,客户端将停止监听并终止。然后,如果客户端尝试接收消息,将返回一个io.EOF错误。我们正在记录从 GRPC 服务器收集的响应。第二个参数TransactionRequest用于第一次向服务器发送请求。现在,运行它将使我们更清楚。在终端一上,运行 GRPC 服务器:

go run $GOPATH/src/github.com/narenaryan/serverPush/grpcServer/server.go

它将继续监听传入的请求。现在,在第二个终端上运行客户端以查看操作:

go run $GOPATH/src/github.com/narenaryan/serverPush/grpcClient/client.go

这将在控制台上输出以下内容:

2017/07/16 15:08:15 Started listening to the server stream!
2017/07/16 15:08:17 Status: good, Operation: Description of step 0
2017/07/16 15:08:19 Status: good, Operation: Description of step 1
2017/07/16 15:08:21 Status: good, Operation: Description of 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 transfered amount $1250.75 from 1234 to 5678

这个过程与服务器同步进行。客户端保持活动状态,直到所有流式消息都被发送回来。服务器可以同时处理任意数量的客户端。每个客户端请求被视为一个独立的实体。这是服务器发送一系列响应的示例。还有其他情况可以使用协议缓冲区和 GRPC 实现:

  • 客户端发送流式请求,以从服务器获取最终响应。

  • 客户端和服务器都同时发送流式请求和响应

官方的 GRPC 团队在 GitHub 上提供了一个很好的出租车路线示例。您可以查看它以了解双向流的功能。

github.com/grpc/grpc-go/tree/master/examples/route_guide

总结

在本章中,我们从理解协议缓冲的基础知识开始我们的旅程。然后,我们遇到了协议缓冲语言,它有许多类型,如标量、枚举和重复类型。我们看到了 JSON 和协议缓冲之间的一些类比。我们了解了为什么协议缓冲比纯 JSON 数据格式更节省内存。我们通过模拟网络接口定义了一个样本协议缓冲。message关键字用于在协议缓冲中定义消息。

接下来,我们安装了protoc编译器来编译我们用协议缓冲语言编写的文件。然后,我们看到如何编译.proto文件以生成一个.go文件。这个 Go 文件包含了主程序消耗的所有结构和接口。接下来,我们为一个地址簿和人员编写了一个协议缓冲。我们看到了如何使用grpc.Marshal将 Go 结构序列化为二进制可传输数据。我们还发现,在 Go 中,协议缓冲与 JSON 之间的转换非常容易实现。

然后,我们转向了使用协议缓冲的谷歌 RPC 技术 GRPC。我们看到了 HTTP/2 和 GRPC 的好处。然后,我们定义了一个 GRPC 服务和协议缓冲形式的数据。接下来,我们实现了一个 GRPC 服务器和 GRPC,关于从.proto生成的文件。

GRPC 提供了双向和多路传输机制。这意味着它可以使用单个 TCP 连接进行所有消息传输。我们实现了一个这样的场景,客户端向服务器发送消息,服务器回复一系列消息。

第七章:使用 PostgreSQL、JSON 和 Go 进行工作

在本章中,我们将从宏观角度看 SQL。在之前的章节中,我们讨论了 SQLite3,这是一个用于快速原型设计的小型数据库。但是,当涉及到生产级应用程序时,人们更喜欢 MySQL 或 PostgreSQL。在 Web 应用程序领域,两者都经过了充分验证。首先,我们将讨论 PostgreSQL 的内部,然后转向在 Go 中编写数据库模型。然后,我们将尝试通过一个实例来实现 URL 缩短服务。

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

  • 介绍 PostgreSQL 数据库

  • 安装 PostgreSQL 并创建用户和数据库

  • 了解pq,Go 中的数据库驱动程序

  • 使用 PostgreSQL 和 Base62 算法实现 URL 缩短服务

  • 探索 PostgreSQL 中的 JSON 存储

  • 介绍gorm,Go 的强大 ORM

  • 实施电子商务 REST API

获取代码

您可以在以下网址找到本章的代码示例:github.com/narenaryan/gorestful/tree/master/chapter7。在上一章中,我们讨论了协议缓冲区和 GRPC。但是在这里,我们回到了使用 JSON 的 REST API,并看看 PostgreSQL 如何补充 JSON。

安装 PostgreSQL 数据库

PostgreSQL 是一个可以安装在多个平台上的开源数据库。在 Ubuntu 上,可以使用以下命令进行安装:

将存储库添加到软件包列表中:


sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" >> /etc/apt/sources.list.d/pgdg.list' 
wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O - | sudo apt-key add -

要更新软件包列表:


sudo apt-get update
apt-get install postgresql postgresql-contrib

这将在 Ubuntu 机器上安装数据库并在端口5432上启动服务器。现在,为了进入数据库 shell,使用以下命令。PostgreSQL 创建一个名为postgres的默认用户以登录。看一下以下命令:

sudo su - postgres

现在用户可以访问数据库。使用psql命令启动 PostgreSQL shell:

psql

这表明 PostgreSQL 与其他类似数据库(如 MySQL 或 SQLite3)相比,采用了不同的进入 shell 的方法。在 Windows 上,通过单击二进制安装程序文件来进行安装。这是一个基于 GUI 的安装,应提供超级用户的端口和密码。安装数据库后,我们可以使用pgAdmin3工具进行检查。macOS X 的设置与 Ubuntu 类似,只是安装是通过 Homebrew 完成的。看一下以下命令:

brew install postgresql

然后,通过使用以下命令使数据库服务器在系统重新启动时运行:

pg_ctl -D /usr/local/var/postgres start && brew services start postgresql

现在,PostgreSQL 服务器开始运行,并且可以在 macOS X 上存储和检索数据。

在 PostgreSQL 中添加用户和数据库

现在,我们应该知道如何创建新用户和数据库。为此,我们将以 Ubuntu/Mac 为一般示例。我们在一个名为psql的 shell 中执行此操作。使用\?命令可以在psql中看到所有可用命令。为了进入psql,首先切换到postgres用户。在 Ubuntu 上,您可以使用以下命令来执行:

sudo su postgres

现在,它将我们转换为一个名为postgres的用户。然后,使用psql命令启动psql shell。如果在其中输入\?,您将看到所有可用命令的输出:

要列出所有可用用户及其权限,您将在 shell 帮助的Informational部分中找到一个命令,即:

\du - List roles

角色是授予用户的访问权限。列表中的默认角色是postgres

postgres=# \du

 List of roles 
 Role name |      Attributes               | Member of 
-----------+------------------------------------------------------------+-----------
 postgres | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

上述命令列出了角色(用户)及其属性(角色允许执行的操作)和其他选项。要添加新用户,我们只需输入此psql命令:

CREATE ROLE naren with LOGIN PASSWORD 'passme123';

这将创建一个名为naren的新用户和密码passme123.现在,使用以下命令为用户授予创建数据库和进一步角色的权限:

ALTER USER naren CREATEDB, CREATEROLE;

要删除用户,只需在相同上下文中使用DROP命令:

DROP ROLE naren;

不要尝试更改默认postgres用户的密码。它旨在成为一个 sudo 帐户,不应该作为普通用户保留。相反,创建一个角色并为其分配所需的权限。

现在我们知道如何创建一个角色。让我们看看一些更多的 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;

|

现在,让我们从 Go 中看看如何与 PostgreSQL 交流,并尝试使用一个简单的例子来执行前面的操作。

pq,一个纯 PostgreSQL 数据库驱动程序

在之前的章节中,当我们处理 SQLite3 时,我们使用了一个名为go-sqlite3的外部库。同样,有一个数据库驱动程序库可用于连接 Go 和 PostgreSQL。该库称为pq。我们可以使用以下命令安装该库:

go get github.com/lib/pq

获得这个库之后,我们需要以与 SQLite3 相似的方式使用它。API 将与 Go 的database/sql包一致。为了创建一个新表,我们应该初始化 DB。要创建一个新数据库,只需在psql shell 中输入以下命令,如下所示;这是一次性的事情:

CREATE DATABASE mydb;

现在,我们将编写一个小的代码示例,解释了pq驱动程序的用法。在你的$GOPATH中创建一个名为models的目录。在这里,我的GOPATH/home/naren/workspace/。与前几章中的所有示例一样,我们将在src/目录中创建我们的包和应用程序源代码:

mkdir github.com/narenaryan/src/models

现在,添加一个名为web_urls.go的文件。这个文件将包含表创建逻辑:

package models

import (
        "database/sql"
        "log"
        _ "github.com/lib/pq"
)

func InitDB() (*sql.DB, error) {
        var err error
        db, err := sql.Open("postgres", "postgres://naren:passme123@localhost/mydb?sslmode=disable")
        if err != nil {
                return nil, err
        } else {
                // Create model for our URL service
                stmt, err := db.Prepare("CREATE TABLE WEB_URL(ID SERIAL PRIMARY KEY, URL TEXT NOT NULL);")
                if err != nil {
                        log.Println(err)
                        return nil, err
                }
                res, err := stmt.Exec()
                log.Println(res)
                if err != nil {
                        log.Println(err)
                        return nil, err
                }
                return db, nil
        }
}

我们在这里导入了pq库。我们使用sql.Open函数来启动一个新的数据库连接池。如果你观察连接字符串,它由多个部分组成。看一下下图:

连接字符串应该包括数据库类型、username:password对、数据库服务器 IP 和 sslmode 设置。然后我们创建一个名为web_url的表。所有的错误处理程序都在那里,以指定如果出现问题。InitDB函数将数据库连接对象返回给导入该函数的任何程序。让我们编写主程序来使用这个包:

package main

import (
       "log"
      "github.com/narenaryan/models"
)

func main() {
  db, err := models.InitDB()
  if err != nil {
    log.Println(db)
  }
}

该程序导入了models包,并使用了其中的InitDB函数。我们只是打印了数据库连接,这将是一个地址。如果你运行程序,你会看到对象的地址被打印出来:

go run main.go

这将在mydb数据库中创建一个web_url表。我们可以通过进入psql shell 并输入以下内容来交叉检查:

\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 | naren
(1 row)

在 PostgreSQL 中,AUTO INCREMENT 类型需要在为表创建提供模式时替换为 SERIAL。

使用 Postgres 和 pq 实现 URL 缩短服务

让我们编写 URL 缩短服务来解释我们在前一节讨论的所有概念。在那之前,让我们设计一个实现 Base62 算法的包,其中包括编码/解码函数。URL 缩短技术需要 Base62 算法来将长 URL 转换为短 URL,反之亦然。然后,我们编写一个实例来展示这种编码是如何工作的。在GOPATH中创建一个名为base62的目录:

mkdir $GOPATH/src/github.com/narenaryan/base62

现在,添加一个名为encodeutils.go的文件,其中包含我们的编码和解码函数。

定义 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 datbase ID
func ToBase10(str string) int{
    res := 0
    for _, r := range str {
        res = (b * res) + strings.Index(base, string(r))
    }
    return res
}

在上述程序中,我们定义了两个名为ToBase62ToBase10的函数。第一个函数接受一个整数并生成一个base62字符串,而后一个函数则反转了这个效果;也就是说,它接受一个base62字符串并给出原始数字。为了说明这一点,让我们创建一个简单的程序,使用这两个函数来展示编码/解码:

vi $GOPATH/src/github.com/narenaryan/usebase62.go

将以下内容添加到其中:

package main

import (
      "log"
      base62 "github.com/narenaryan/base62"
)

func main() {
  x := 100
  base62String := base62.ToBase62(x)
  log.Println(base62String)
  normalNumber := base62.ToBase10(base62String)
  log.Println(normalNumber)
}

在这里,我们使用了base62包中的函数,并尝试查看输出。如果我们使用以下命令运行这个程序(从$GOPATH/src/github.com/narenaryan):

go run usebase62.go

它打印出:

2017/08/07 23:00:05 1C
2017/08/07 23:00:05 100

100base62编码是1C。这是因为索引 100 在我们的base62逻辑中缩小为1C

const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

原始数字将用于映射此基本字符串中的字符。然后,将数字除以 62 以找出下一个字符。这种算法的美妙之处在于为每个给定的数字创建一个独特的、更短的字符串。我们使用这种技术将数据库 ID 传递到ToBase62算法中,并得到一个更短的字符串。每当 URL 缩短请求到达我们的服务器时,它应执行以下步骤:

  1. 将 URL 存储在数据库中,并获取插入记录的 ID。

  2. 将此 ID 作为 API 响应传递给客户端。

  3. 每当客户端加载缩短的 URL 时,它会访问我们的 API 服务器。

  4. 然后 API 服务器将短 URL 转换回数据库 ID,并从原始 URL 中获取记录。

  5. 最后,客户端可以使用此 URL 重定向到原始站点。

我们将在这里编写一个 Go 项目,实现上述步骤。让我们组成程序。我正在为我们的项目创建一个目录结构。我们从前面的示例中获取处理编码/解码base62和数据库逻辑的文件。目录结构如下:

urlshortener
├── main.go
├── models
│   └── models.go
└── utils
 └── encodeutils.go

2 directories, 3 files

将此目录复制到$GOPATH/src/github.com/narenaryan。再次小心。用你的用户名替换narenaryan。从前面的示例中复制encodeutils.gomodels.go。然后,开始编写主程序:

package main

import (
    "database/sql"
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
    "github.com/narenaryan/urlshortener/models"
    base62 "github.com/narenaryan/urlshortener/utils"
)

// DB stores the database session imformation. Needs to be initialized once
type DBClient struct {
  db *sql.DB
}

// Model the record struct
type Record struct {
  ID  int    `json:"id"`
  URL string `json:"url"`
}

// 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.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)
  }
}

// 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)
  json.Unmarshal(postBody, &record)
  err := driver.db.QueryRow("INSERT INTO web_url(url) VALUES($1) RETURNING id", record.URL).Scan(&id)
  responseMap := map[string]interface{}{"encoded_string": base62.ToBase62(id)}
  if err != nil {
    w.Write([]byte(err.Error()))
  } else {
    w.Header().Set("Content-Type", "application/json")
    response, _ := json.Marshal(responseMap)
    w.Write(response)
  }
}

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())
}

首先,我们导入了postgres库和其他必要的库。我们从模型中导入了数据库会话。接下来,我们导入了我们的编码/解码 base62 算法来实现我们的逻辑:

// DB stores the database session imformation. Needs to be initialized once
type DBClient struct {
  db *sql.DB
}

// Model the record struct
type Record struct {
  ID  int    `json:"id"`
  URL string `json:"url"`
}

需要DBClient以便在各种函数之间传递数据库驱动程序。记录是类似于插入数据库的记录的结构。我们在我们的代码中定义了两个函数GenerateShortURLGetOriginalURL,用于将 URL 添加到数据库,然后从数据库中获取它。正如我们已经解释了 URL 缩短的内部技术,使用此服务的客户端将得到必要的响应。让我们在跳入更多细节之前运行程序并查看输出:

go run $GOPATH/src/github.com/narenaryan/urlshortener/main.go

如果您的$GOPATH/bin已经在系统的PATH变量中,我们可以首先安装二进制文件,然后像这样运行它:

go install github.com/narenaryan/urlshortener/main.go

然后只是程序名称:

urlshortener

最好的做法是安装二进制文件,因为它可以在整个系统中使用。但对于较小的程序,我们可以通过访问程序的目录来运行main.go

现在它运行 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.forbes.com/forbes/welcome/?toURL=https://www.forbes.com/sites/karstenstrauss/2017/04/20/the-highest-paying-jobs-in-tech-in-2017/&refURL=https://www.google.co.in/&referrer=https://www.google.co.in/"
}'

它返回缩短的字符串:

{
  "encoded_string": "1"
}

编码的字符串只是"1"。Base62 算法从1开始分配更短的字符串,直到组合字母数字。现在,如果我们需要检索原始 URL,我们可以执行GET请求:

curl -X GET \
 http://localhost:8000/v1/short/1 \
 -H 'cache-control: no-cache' \

它返回以下 JSON:

{   
"url":"https://www.forbes.com/forbes/welcome/?toURL=https://www.forbes.com/sites/karstenstrauss/2017/04/20/the-highest-paying-jobs-in-tech-in-2017/\u0026refURL=https://www.google.co.in/\u0026referrer=https://www.google.co.in/"}

因此,服务可以使用此结果将用户重定向到原始 URL(站点)。在这里,生成的字符串不取决于 URL 的长度,因为只有数据库 ID 是编码的标准。

在 PostgreSQL 中需要向INSERT SQL 命令添加RETURNING关键字以获取最后插入的数据库 ID。这在 MySQL 或 SQLite3 的INSERT INTO web_url( ) VALUES($1) RETURNING id, record.URL中并非如此。这个 DB 查询返回最后插入记录的 ID。如果我们去掉RETURNING关键字,查询将返回空。

在 PostgreSQL 中探索 JSON 存储

PostgreSQL >9.2有一个突出的功能 9.2" dbid="254735"叫做 JSON 存储。PostgreSQL 引入了一种新的数据类型来存储 JSON 数据。PostgreSQL 允许用户插入一个jsonb字段类型,它保存 JSON 字符串。它在对结构更加灵活的真实世界数据进行建模时非常有用。PostgreSQL 通过允许我们存储 JSON 字符串以及关系类型来发挥了最佳的作用。

在本节中,我们将尝试实现我们在前几章中为电子商务网站定义的一些 JSON 模型。但在这里,我们将使用 JSON 字段在 PostgreSQL 中存储和检索项目。对于访问 PostgreSQL 的 JSON 存储,普通的pq库非常繁琐。因此,为了更好地处理它,我们可以使用一个称为GORM对象关系映射器ORM)。

GORM,Go 的强大 ORM

这个 ORM 具有database/sql包中可以执行的所有操作的 API。我们可以使用这个命令安装 GORM:

go get -u github.com/jinzhu/gorm

有关此 ORM 的完整文档,请访问jinzhu.me/gorm/。让我们编写一个实现用户和订单类型 JSON 模型的程序。用户可以下订单。我们将使用我们在上一章中定义的模型。我们可以在$GOPATH/src/github.com/narenaryan中创建一个名为jsonstore的新目录,并在其中为我们的模型创建一个新目录:

mkdir jsonstore
mkdir jsonstore/models
touch jsonstore/models/models.go

现在,将models.go文件编辑为:

package models

import (
  "github.com/jinzhu/gorm"
  _ "github.com/lib/pq"
)

type User struct {
  gorm.Model
  Orders []Order
  Data string `sql:"type:JSONB NOT NULL DEFAULT '{}'::JSONB" json:"-"`
}

type Order struct {
  gorm.Model
  User User
  Data string `sql:"type:JSONB NOT NULL DEFAULT '{}'::JSONB"`
}

// GORM creates tables with plural names. Use this to suppress it
func (User) TableName() string {
  return "user"
}

func (Order) TableName() string {
  return "order"
}

func InitDB() (*gorm.DB, error) {
  var err error
  db, err := gorm.Open("postgres", "postgres://naren:passme123@localhost/mydb?sslmode=disable")
  if err != nil {
    return nil, err
  } else {
    /*
    // The below AutoMigrate is equivalent to this
    if !db.HasTable("user") {
      db.CreateTable(&User{})
    }

    if !db.HasTable("order") {
      db.CreateTable(&Order{})        
    }
    */
    db.AutoMigrate(&User{}, &Order{})
    return db, nil
  }
}

这看起来与我们在本章前面定义的模型类似。在这里,对我们来说有很多新的东西。我们在 GORM 中创建的每个模型(表)都应该表示为一个结构。这就是我们创建了两个结构,UserOrder的原因。第一行应该是gorm.Model。其他字段是表的字段。默认情况下,将创建一个递增的 ID。在之前的 URL 缩短器模型中,我们在操作之前手动检查表的存在。但在这里,有一个函数:

db.AutoMigrate(&User{}, &Order{})

这个函数为作为参数传递的结构创建表。它确保如果表已经存在,它会跳过创建。如果你仔细观察,我们为这些结构添加了一个函数,TableName。默认情况下,GORM 创建的所有表名都是复数名(Userusers被创建)。为了强制它创建给定的名称,我们需要覆盖该函数。另一个有趣的事情是,在结构中,我们使用了一个叫做Data的字段。它的类型是:

`sql:"type:JSONB NOT NULL DEFAULT '{}'::JSONB" json:"-"`

是的,它是一个jsonb类型的字符串。我们现在将其类型添加为string.PostgreSQL,GORM 会处理它。然后我们将数据库连接返回给导入models包的人。

实现电子商务 REST API

在开始之前,让我们设计 API 规范表,其中显示了各种 URL 终端的 REST API 签名。请参考以下表:

终端 方法 描述
/v1/user/id GET 使用 ID 获取用户
/v1/user POST 创建新用户
/v1/user?first_name=NAME GET 通过给定的名字获取所有用户
/v1/order/id GET 获取具有给定 ID 的订单
/v1/order POST 创建新订单

现在我们来到主程序;让我们向我们的jsonstore项目添加一个文件。在这个程序中,我们将尝试实现前三个终端。我们建议读者将剩下的两个终端的实现作为一个作业。看一下以下命令:

touch jsonstore/main.go

程序结构遵循我们到目前为止看到的所有程序的相同风格。我们使用 Gorilla Mux 作为我们的 HTTP 路由器,并将数据库驱动程序导入到我们的程序中:

package main

import (
  "encoding/json"
  "io/ioutil"
  "log"
  "net/http"
  "time"

  "github.com/gorilla/mux"
  "github.com/jinzhu/gorm"
    _ "github.com/lib/pq"
  "github.com/narenaryan/jsonstore/models"
)

// DB stores the database session imformation. Needs to be initialized once
type DBClient struct {
  db *gorm.DB
}

// UserResponse is the response to be send back for User
type UserResponse struct {
  User models.User `json:"user"`
  Data interface{} `json:"data"`
}

// GetUsersByFirstName fetches the original URL for the given encoded(short) string
func (driver *DBClient) GetUsersByFirstName(w http.ResponseWriter, r *http.Request) {
  var users []models.User
  name := r.FormValue("first_name")
  // Handle response details
  var query = "select * from \"user\" where data->>'first_name'=?"
  driver.db.Raw(query, name).Scan(&users)
  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")
  //responseMap := map[string]interface{}{"url": ""}
  respJSON, _ := json.Marshal(users)
  w.Write(respJSON)
}

// GetUser fetches the original URL for the given encoded(short) string
func (driver *DBClient) GetUser(w http.ResponseWriter, r *http.Request) {
  var user = models.User{}
  vars := mux.Vars(r)
  // Handle response details
  driver.db.First(&user, vars["id"])
  var userData interface{}
  // Unmarshal JSON string to interface
  json.Unmarshal([]byte(user.Data), &userData)
  var response = UserResponse{User: user, Data: userData}
  w.WriteHeader(http.StatusOK)
  w.Header().Set("Content-Type", "application/json")
  //responseMap := map[string]interface{}{"url": ""}
  respJSON, _ := json.Marshal(response)
  w.Write(respJSON)
}

// PostUser adds URL to DB and gives back shortened string
func (driver *DBClient) PostUser(w http.ResponseWriter, r *http.Request) {
  var user = models.User{}
  postBody, _ := ioutil.ReadAll(r.Body)
  user.Data = string(postBody)
  driver.db.Save(&user)
  responseMap := map[string]interface{}{"id": user.ID}
  var err string = ""
  if err != "" {
    w.Write([]byte("yes"))
  } else {
    w.Header().Set("Content-Type", "application/json")
    response, _ := json.Marshal(responseMap)
    w.Write(response)
  }
}

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/user/{id:[a-zA-Z0-9]*}", dbclient.GetUser).Methods("GET")
  r.HandleFunc("/v1/user", dbclient.PostUser).Methods("POST")
  r.HandleFunc("/v1/user", dbclient.GetUsersByFirstName).Methods("GET")
  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())
}

这里有三个重要的方面:

  • 我们用 GORM 驱动程序替换了传统的驱动程序

  • 使用 GORM 函数进行 CRUD 操作

  • 我们将 JSON 插入到 PostgreSQL 中,并在 JSON 字段中检索结果

让我们详细解释所有的元素。首先,我们导入了所有必要的包。有趣的是:

  "github.com/jinzhu/gorm"
   _ "github.com/lib/pq"
  "github.com/narenaryan/jsonstore/models"

GORM 在内部在某种程度上使用了database/sql包。我们从我们在前面的代码中创建的包中导入了模型。接下来,我们创建了三个函数,实现了前三个 API 规范。它们是GetUsersByFirstNameGetUserPostUser。每个函数都继承了数据库驱动程序,并作为main函数中 URL 端点的处理程序函数传递:

 r.HandleFunc("/v1/user/{id:[a-zA-Z0-9]*}", dbclient.GetUser).Methods("GET")
 r.HandleFunc("/v1/user", dbclient.PostUser).Methods("POST")
 r.HandleFunc("/v1/user", dbclient.GetUsersByFirstName).Methods("GET")

现在,如果我们进入第一个函数,这很简单,这些语句会吸引我们的注意:

driver.db.First(&user, vars["id"])

上述语句告诉数据库从具有给定第二参数ID的数据库中获取第一条记录。它将返回的数据填充到user结构中。我们在GetUser中使用UserResponse而不是User结构,因为User包含数据字段,它是一个字符串。但是,为了向客户端返回完整和正确的 JSON,我们需要将数据转换为一个适当的结构,然后进行编组:

// UserResponse is the response to be send back for User
type UserResponse struct {
  User models.User `json:"user"`
  Data interface{} `json:"data"`
}

在这里,我们创建了一个可以容纳任何 JSON 数据的空接口。当我们使用驱动程序调用第一个函数时,用户结构具有一个数据字段,它是一个字符串。我们需要将该字符串转换为一个结构,然后将其与UserResponse中的其他详细信息一起发送。现在让我们看看这个过程。使用以下命令运行程序:

go run jsonstore/main.go

并制作一些 CURL 命令来查看 API 响应:

创建用户:

curl -X POST \
  http://localhost:8000/v1/user \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{
     "username": "naren",
     "email_address": "narenarya@live.com",
     "first_name": "Naren",
     "last_name": "Arya"
}'

它返回了在数据库中插入的记录:

{
  "id": 1
}

现在,如果我们GET插入记录的详细信息:

curl -X GET http://localhost:8000/v1/user/1 

它返回有关用户的所有详细信息:

{"user":{"ID":1,"CreatedAt":"2017-08-27T11:55:02.974371+05:30","UpdatedAt":"2017-08-27T11:55:02.974371+05:30","DeletedAt":null,"Orders":null},"data":{"email_address":"narenarya@live.com","first_name":"Naren","last_name":"Arya","username":"naren"}}

插入一条记录以检查名字 API:

curl -X POST \
  http://localhost:8000/v1/user \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{
     "username": "nareny",
     "email_address": "naren.yellavula@gmail.com",
     "first_name": "Naren",
     "last_name": "Yellavula"
}'

这插入了我们的第二条记录。让我们测试我们的第三个 API,GetUsersByFirstName

curl -X GET 'http://localhost:8000/v1/user?first_name=Naren' 

这将返回所有具有给定名字的用户:

[{"ID":1,"CreatedAt":"2017-08-27T11:55:02.974371+05:30","UpdatedAt":"2017-08-27T11:55:02.974371+05:30","DeletedAt":null,"Orders":null},{"ID":2,"CreatedAt":"2017-08-27T11:59:41.84332+05:30","UpdatedAt":"2017-08-27T11:59:41.84332+05:30","DeletedAt":null,"Orders":null}]

这个项目的核心宗旨是展示如何从 PostgreSQL 中存储和检索 JSON。这里的特殊之处在于,我们查询了 JSON 字段,而不是User表中的普通字段。

记住,PostgreSQL 将其用户存储在一个名为 user 的表中。如果要创建一个新的用户表,请使用"user"(双引号)。即使在检索时也要使用双引号。否则,数据库将获取内部用户详细信息。

SELECT * FROM "user"; // 正确的方式

SELECT * FROM user; // 错误的方式。它获取数据库用户

这结束了我们对 PostgreSQL 的旅程。在 Postgres 中还有很多可以探索的地方。它通过允许我们在同一张表中存储关系型数据和 JSON 数据,将两者的优点发挥到了极致。

摘要

在本章中,我们通过安装 PostgreSQL 开始了我们的旅程。我们正式介绍了 PostgreSQL,并尝试看到所有可能的 CRUD 操作的 SQL 查询。然后我们看到了如何在 PostgreSQL 中添加用户和数据库。然后我们安装并解释了pq,这是 Go 语言的 Postgres 驱动程序。我们解释了驱动程序 API 如何执行原始的 SQL 查询。

然后是 URL 缩短服务的实现部分;该 REST 服务接受原始 URL 并返回缩短的字符串。它还接受缩短的 URL 并返回原始 URL。我们编写了一个示例程序来说明支持我们服务的 Base62 算法。我们随后在我们的服务中利用了这个算法,并创建了一个 REST API。

GORM 是 Go 语言中众所周知的对象关系映射器。使用 ORM,可以轻松管理数据库操作。GORM 提供了一些有用的函数,比如AutoMigrate(如果不存在则创建表),用于在传统的database/sql驱动程序上编写直观的 Go 代码。

PostgreSQL 还允许在 9.2 版本之后存储 JSON(称为 JSON 存储)。它允许开发人员以 JSON 格式获得关系数据库的好处。我们可以在 JSON 字段上创建索引,对 JSON 字段进行查询等。我们使用 GORM 为我们在前几章中定义的电子商务模型实现了 REST API。PostgreSQL 是一个成熟的、开源的关系数据库,可以满足我们的企业需求。Go 语言的驱动程序支持非常出色,包括pqgorm

第八章:使用 Go 构建 REST API 客户端和单元测试

在本章中,我们将深入讨论 Go 客户端应用程序的工作原理。我们将探索grequests,这是一个类似 Python 请求的库,允许我们从 Go 代码中进行 API 调用。然后,我们将编写一个使用 GitHub API 的客户端软件。在此过程中,我们将尝试了解两个名为clicobra的出色库。在掌握了这些基础知识后,我们将尝试使用这些知识在命令行上编写 API 测试工具。然后我们将了解 Redis,这是一个内存数据库,我们可以用它来缓存 API 响应以备份数据。

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

  • 什么是客户端软件?

  • Go 中编写命令行工具的基础知识

  • 介绍grequests,Go 中类似 Python 请求的库

  • 从 Go 客户端检查 GitHub REST API

  • 在 Go 中创建 API 客户端

  • 缓存 API 以备后用

  • 为 API 创建一个单元测试工具

获取代码

您可以在 GitHub 存储库链接github.com/narenaryan/gorestful/tree/master/chapter8中获取本章的代码示例。本章包含单个程序和项目的组合示例。因此,请将相应的目录复制到您的GOPATH中,以正确运行代码示例。对于 URL 缩短服务的单元测试的最后一个示例,测试可在github.com/narenaryan/gorestful/tree/master/chapter7中找到。

构建 REST API 客户端的计划

到目前为止,我们主要关注编写服务器端 REST API。基本上,它们是服务器程序。在一些情况下,例如 GRPC,我们还需要客户端。但是真正的客户端程序会从用户那里获取输入并执行一些逻辑。要使用 Go 客户端,我们应该了解 Go 中的flag库。在此之前,我们应该知道如何从 Go 程序中对 API 进行请求。在之前的章节中,我们假设客户端可以是 CURL、浏览器、Postman 等。但是我们如何从 Go 中消费 API 呢?

命令行工具与 Web 用户界面一样重要,用于执行系统任务。在企业对企业B2B)公司中,软件打包为单个二进制文件,而不是多个不同的部分。作为 Go 开发人员,您应该知道如何实现为命令行编写应用程序的目标。然后,可以利用这些知识轻松而优雅地创建与 REST API 相关的 Web 客户端。

Go 中编写命令行工具的基础知识

Go 提供了一个名为flag的基本库。它指的是命令行标志。由于它已经打包在 Go 发行版中,因此无需外部安装任何内容。我们可以看到编写命令行工具的绝对基础知识。flag包具有多个函数,例如IntString,用于处理作为命令行标志给定的输入。假设我们需要从用户那里获取一个名称并将其打印回控制台。我们使用flag.String方法,如下面的代码片段所示:

import "flag"
var name = flag.String("name", "No Namer", "your wonderful name")

让我们写一个简短的程序以获得清晰的细节。在您的$GOPATH/src/github.com/narenaryan中创建一个名为flagExample.go的文件,并添加以下内容:

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接受三个参数。第一个是参数的名称。第二个和第三个是该标志的默认值和帮助文本。然后我们要求程序解析所有标志指针。当我们运行程序时,它实际上会将值从命令行填充到相应的变量中。要访问指针的值,我们使用*。首先构建,然后使用以下命令运行程序:

go build flagExample.go

这将在相同的目录中创建一个二进制文件。我们可以像运行普通可执行文件一样运行它:

./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文件时,将生成flagExample.exe。之后,我们可以通过调用程序名称从命令行运行该程序。

现在尝试添加参数,它会打印给定的名称:

./flagExample -name Albert
(or)
./flagExample -name=Albert

这两个参数都可以正常工作,给出输出:

Hello Albert, Welcome to the command line world

如果我们需要收集多个参数,我们需要修改前面的程序为:

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()函数完成的,无论主函数是否存在,它都会在 Go 程序中运行:

var name String 
func init() {
  flag.IntVar(&name, "name", "stranger", "your wonderful name")
}

这样,值将直接传递并存储在变量中。使用init()函数完全重写前面的程序如下所示:

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 中,执行从main程序开始。但是 Go 程序可以有任意数量的init函数。如果一个包中有init函数,它将被执行。

这个flag库非常基础。但是为了编写高级客户端应用程序,我们需要借助该库。在下一节中,我们将看看这样一个库。

CLI - 用于构建美观客户端的库

这是在玩flag包后 Go 开发人员的下一步。它提供了一个直观的 API,可以轻松创建命令行应用程序。它允许我们收集参数和标志。对于设计复杂的应用程序来说,这可能非常方便。要安装该包,请使用以下命令:

go get github.com/urfave/cli

之后,让我们编写一个与前面程序完全相同的程序:

cli/cliBasic.go

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结构是一个列表,定义了该应用程序的所有可能的标志。Flag的结构来自GoDoc (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
}

内置的结构,如StringFlagIntFlag,实现了Flag接口。NameValueUsage都很简单。它们类似于flag包中使用的那些。Action函数接受cli.Context参数。该上下文对象包含有关标志和命令行参数的所有信息。我们可以使用它们并对它们应用逻辑。c.Stringc.Int和其他函数用于查找标志变量。例如,在前面的程序中,c.String("name")获取了一个名为name的标志变量。该程序与以前的程序运行相同:

go build cli/cliBasic.go

在 CLI 中收集命令行参数

命令行参数和标志之间存在区别。以下图表清楚地说明了它们之间的区别:

假设我们有一个名为 storeMarks 的命令行应用程序,用于保存学生的成绩。它有一个标志(称为save)来指定是否应将详细信息推送到数据库。给定的参数是学生的姓名和实际成绩。我们已经看到如何在程序中收集标志值。在本节中,我们将看到如何以富有表现力的方式收集程序参数。

为了收集参数,我们使用c.Args函数,其中cAction函数的cli上下文。创建一个名为cli的目录,并添加一个新程序cli/storeMarks.go

package main

import (
  "github.com/urfave/cli"
  "log"
  "os"
)

func main() {
  app := cli.NewApp()
  // define flags
  app.Flags = []cli.Flag{
    cli.StringFlag{
      Name:  "save",
      Value: "no",
      Usage: "Should save to database (yes/no)",
    },
  }

  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(os.Args)
}

c.Args保存了我们输入的所有参数。由于我们知道参数的顺序,我们推断第一个参数是名称,其余的值是分数。我们正在检查一个名为save的标志,以确定是否将这些详细信息保存在数据库中(这里我们没有数据库逻辑,为简单起见)。app.Version设置了工具的版本。其他所有内容与上一个程序相同。

让我们运行这个程序,看看输出:

go build cli/storeMarks.go

运行程序:

./storeMarks --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

./storeMarks 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

这实际上使构建客户端应用程序变得更容易。它比内部的flag包更快、更直观。

命令行工具是在构建程序后生成的二进制文件。它们需要以选项运行。这就像任何系统程序一样,不再与 Go 编译器相关

grequests - 用于 Go 的 REST API 包

Python 的开发人员知道Requests库。这是一个干净、简短的库,不包括在 Python 的标准库中。Go 包grequests受到该库的启发。它提供了一组简单的函数,使用这些函数我们可以从 Go 代码中进行 API 请求,如GETPOSTPUTDELETE。使用grequests允许我们封装内置的 HTTP 请求和响应。要为 Go 安装grequests包,请运行以下命令:

go get -u github.com/levigross/grequests

现在,看一下这个基本程序,演示了使用grequests库向 REST API 发出GET请求。在 Go 源目录中创建一个名为grequests的目录,并添加一个名为basicRequest.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)
  }
  log.Println(resp.String())
}

grequests包具有执行所有 REST 操作的方法。上面的程序使用了包中的Get函数。它接受两个函数参数。第一个是 API 的 URL,第二个是请求参数对象。由于我们没有传递任何请求参数,这里的第二个参数是nilresp是从请求返回的,它有一个名为String()的函数,返回响应体:

go run grequests/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中探索的最重要的事情不是 HTTP 函数,而是RequestOptions结构。这是一个非常大的结构,包含有关 API 方法类型的各种信息。如果 REST 方法是GETRequestOptions将包含Params属性。如果方法是POST,该结构将具有Data属性。每当我们发出请求,我们都会得到一个响应。让我们看看响应的结构。根据官方文档,响应如下所示:

type Response struct {
    Ok bool
    Error error
    RawResponse *http.Response
    StatusCode int
    Header http.Header
}

响应的Ok属性保存了有关请求是否成功的信息。如果出现问题,错误将填入Error属性。RawResponse是 Go HTTP 响应,将被grequests响应的其他函数使用。StatusCodeHeader分别存储响应的状态代码和头部详细信息。Response中有一些有用的函数:

  • JSON

  • XML

  • String

  • Bytes

可以通过将空接口传递给函数来调用获取的响应,如grequests/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。

熟悉 GitHub REST API

GitHub 提供了一个很好的 REST API 供用户使用。它通过 API 向客户端开放有关用户、存储库、存储库统计等数据。当前稳定版本为 v3。API 文档可以在developer.github.com/v3/找到。API 的根端点是:

curl https://api.github.com

其他 API 将添加到此基本 API 中。现在让我们看看如何进行一些查询并获取与各种元素相关的数据。对于未经身份验证的用户,速率限制为 60/小时,而对于传递client_id(可以从 GitHub 帐户获取)的客户端,速率限制为 5,000/小时。

如果您有 GitHub 帐户(如果没有,建议您创建一个),您可以在您的个人资料|个人访问令牌区域或通过访问github.com/settings/tokens找到访问令牌。使用Generate new token按钮创建一个新的访问令牌。它要求各种权限和资源类型。全部选中。将生成一个新的字符串。将其保存到某个私人位置。我们生成的令牌可以用于访问 GitHub API(以获得更长的速率限制)。

下一步是将访问令牌保存到环境变量GITHUB_TOKEN中。为此,请打开您的~/.profile~/.bashrc文件,并将其添加为最后一行:

export GITHUB_TOKEN=YOUR_GITHUB_ACCESS_TOKEN

YOUR_GITHUB_ACCESS_TOKEN是之前从 GitHub 帐户生成并保存的。让我们创建一个程序来获取给定用户的所有存储库。创建一个名为githubAPI的新目录,并创建一个名为getRepos.go的程序文件:

package main

import (
  "github.com/levigross/grequests"
  "log"
  "os"
)

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"`
}

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
}

func main() {
  var repos []Repo
  var repoUrl = "https://api.github.com/users/torvalds/repos"
  resp := getStats(repoUrl)
  resp.JSON(&repos)
  log.Println(repos)
}

运行程序,您将看到以下输出:

2017/09/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}]

打印输出不是 JSON,而是 Go Repo struct的列表。前面的程序说明了我们如何查询 GitHub 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"`
}

这是我们用于保存存储库详细信息的结构。返回的 JSON 有许多字段,但为简单起见,我们只是从中摘取了一些重要字段:

var GITHUB_TOKEN = os.Getenv("GITHUB_TOKEN")
var requestOptions = &grequests.RequestOptions{Auth: []string{GITHUB_TOKEN, "x-oauth-basic"}}

在第一行,我们正在获取名为GITHUB_TOKEN的环境变量。os.Getenv函数通过给定的名称返回环境变量的值。为了使 GitHub 假定GET请求的来源,我们应该设置身份验证。为此,将参数传递给RequestOptions结构。该参数应该是用户名和密码的列表。

创建一个 CLI 工具作为 GitHub REST API 的 API 客户端

在查看了这个例子之后,我们能够轻松地从我们的 Go 客户端访问 GitHub API。到目前为止,我们可以结合本章学到的两种技术,来设计一个使用 GitHub API 的命令行工具。让我们创建一个新的命令行应用程序,其中:

  • 提供按用户名获取存储库详细信息的选项

  • 使用给定描述将任何文件上传到 GitHub gists(文本片段)

  • 使用个人访问令牌进行身份验证

Gists 是 GitHub 提供的存储文本内容的片段。有关更多详细信息,请访问gist.github.com

githubAPI目录中创建一个名为gitTool.go的程序。这将是前面程序规范的逻辑:

package main

import (
  "encoding/json"
  "fmt"
  "github.com/levigross/grequests"
  "github.com/urfave/cli"
  "io/ioutil"
  "log"
  "os"
)

var GITHUB_TOKEN = os.Getenv("GITHUB_TOKEN")
var requestOptions = &grequests.RequestOptions{Auth: []string{GITHUB_TOKEN, "x-oauth-basic"}}

// 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"`
}

// 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
}

// Reads the files provided and creates Gist on github
func createGist(url string, args []string) *grequests.Response {
  // get first teo 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
}

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]: goTool 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]: goTool 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)
}

在深入解释细节之前,让我们运行程序。这清楚地说明了我们如何实现该程序:

go build githubAPI/gitTool.go

它在相同的目录中创建一个二进制文件。如果您键入./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

如果您查看帮助命令,有两个命令,fetchcreatefetch获取给定用户的存储库,create创建一个带有提供的文件的gist。让我们在程序的相同目录中创建两个示例文件,以测试create命令:

echo 'I am sample1 file text' > githubAPI/sample1.txt
echo 'I am sample2 file text' > githubAPI/sample2.txt

使用第一个命令运行该工具:

./gitTool f torvalds

它返回所有属于伟大的 Linus 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

现在,来解释一下,我们首先导入grequests以进行 API 调用和cli以构建命令行工具。其他导入是必要的,以便读取文件,记录到控制台和编码 JSON。然后我们定义了三个结构:RepoFileGist。GitHub 的gists API 需要 JSON 数据来创建:

{
  "description": "the description for this gist",
  "public": true,
  "files": {
    "file1.txt": {
      "content": "String file contents"
    }
  }
}

grequestsPOST请求使用具有Data作为字段的requestOptions。但它的签名是Map[string]string],这不足以创建前面的结构。grequests允许我们传递任何结构的 JSON 字符串到 API。我们创建了结构,以便数据可以填充并编组成适当的 JSON 以使POST请求成功。

然后,我们创建了两个函数:getStats(返回给定用户的所有存储库详细信息)和createGist(使用给定的描述和文件名创建新的gist文件)。第二个函数更有趣。我们正在传递一个 URL 进行POST请求,描述和file_namesargs数组的形式。然后,我们正在迭代每个文件并获取内容。我们正在调整我们的结构,以便POST请求的最终 JSON 主体将具有相同的结构。最后,我们使用具有我们的 JSON 的requestOptions进行POST请求。

这样,我们结合了两个库来构建一个可以执行任何任务的 API 客户端。Go 的美妙之处在于我们可以将最终的二进制文件中包含命令行工具的逻辑和调用逻辑的 REST API。

对于任何 Go 程序来说,要很快读懂,首先要遵循main函数,然后进入其他函数。这样,我们可以遇到导入的包及其 API。

使用 Redis 缓存 API 数据

Redis是一个可以存储键/值对的内存数据库。它最适合缓存使用案例,其中我们需要临时存储信息,但对于大量流量。例如,像 BBC 和 The Guardian 这样的网站在仪表板上显示最新文章。他们的流量很大,如果从数据库中获取文档(文章),他们需要一直维护一个庞大的数据库集群。由于给定的一组文章不会改变(至少几个小时),BBC 可以维护一个保存文章的缓存。当第一个客户访问页面时,从数据库中获取副本,发送到浏览器,并放入 Redis 缓存中。下次客户出现时,BBC 应用服务器从 Redis 中读取内容,而不是去数据库。由于 Redis 运行在主内存中,延迟得到减少。客户可以看到他的页面在一瞬间加载。网络上的基准测试可以更多地告诉我们网站如何有效地优化其内容。

如果 Redis 中的数据不再相关怎么办?(例如,BBC 更新了其头条新闻。)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 不应该被缓存,因为它具有动态性。缓存为我们带来了性能优化,但也带来了一些麻烦。在进行缓存时要小心。全球有许多更好的实践方法。请仔细阅读它们,以了解各种架构。

为我们的 URL 缩短服务创建一个单元测试工具

在上一章中,我们创建了一个 URL 缩短服务。我们之前工作的 URL 缩短器项目的结构如下:

├── main.go
├── models
│   └── models.go
└── utils
    └── encodeutils.go

2 directories, 3 files

main.go文件中,我们创建了两个 API 处理程序:一个用于GET,一个用于POST。我们将为这两个处理程序编写单元测试。在项目的根目录中添加一个名为main_test.go的文件:

touch main_test.go

为了测试我们的 API,我们需要测试我们的 API 处理程序:

package main_test

import (
  "testing"
  "net/http"
)

func TestGetOriginalURL(t *testing.T) {
  // make a dummy reques
  response, err := http.Get("http://localhost:8000/v1/short/1")

    if http.StatusOK != response.StatusCode {
      t.Errorf("Expected response code %d. Got %d\n", http.StatusOK, response.StatusCode)
    }

    if err != nil {
      t.Errorf("Encountered an error:", err)
    }
}

Go 中有一个名为testing的测试包。它允许我们创建一些断言,并让我们进行通过或失败的测试。我们正在通过进行简单的 HTTP 请求来测试 API TestGetOriginalURL。确保数据库中至少插入了一条记录。数据库连接的高级测试主题超出了本书的范围。我们可以在项目目录中使用 Go test 命令进行测试。

摘要

我们从理解客户端软件开始我们的章节:软件客户端的工作原理以及我们如何创建一些。我们了解了编写命令行应用程序的基础知识。CLI 是一个第三方包,可以让我们创建漂亮的命令行应用程序。安装后,我们看到了如何通过工具收集命令行参数。我们还探讨了 CLI 应用程序中的命令和标志。接下来,我们研究了grequests,这是一个类似于 Python requests 的包,用于从 Go 代码中进行 API 请求。我们看到了如何从客户端程序中进行GETPOST等请求。

接下来,我们探讨了 GitHub API 如何获取仓库等详细信息。有了这两个概念的知识,我们开发了一个客户端,列出了给定用户的仓库,并创建了一个gist(GitHub 上的文本文件)。我们介绍了 Redis 架构,说明了缓存如何帮助处理速率限制的 API。最后,我们为上一章中创建的 URL 缩短服务编写了一个单元测试。

第九章:使用微服务扩展我们的 REST API

在概念上,构建 REST API 很容易。但是将它们扩展以接受大量流量是一个挑战。到目前为止,我们已经研究了创建 REST API 结构和示例 REST API 的细节。在本章中,我们将探索 Go Kit,这是一个用于构建微服务的精彩的、符合惯例的 Go 软件包。这是微服务时代,创业公司在短时间内就成为企业。微服务架构允许公司快速并行迭代。我们将从定义微服务开始,然后通过创建 REST 风格的微服务来了解 Go Kit。

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

  • 单体和微服务之间的区别

  • 微服务的需求

  • 介绍 Go Kit,一个 Go 语言的微服务工具包

  • 使用 Go Kit 创建 REST API

  • 为 API 添加日志记录

  • 为 API 添加仪表板

获取代码

您可以在 GitHub 存储库链接github.com/narenaryan/gorestful/tree/master/chapter9中获取本章的代码示例。在上一章中,我们讨论了 Go API 客户端。在这里,我们回到了具有微服务架构的 REST API。

什么是微服务?

什么是微服务?这是企业世界向计算世界提出的问题。由于团队规模较大,公司准备采用微服务来分解任务。微服务架构用粒度服务取代了传统的单体,并通过某种协议相互通信。

微服务为以下方面带来了好处:

  • 如果团队很大,人们可以在应用程序的各个部分上工作

  • 新开发人员很容易适应

  • 采用最佳实践,如持续集成CI)和持续交付CD

  • 易于替换的松散耦合架构软件

在单体应用程序(传统应用程序)中,一个巨大的服务器通过多路复用计算能力来服务传入的请求。这很好,因为我们在一个地方拥有一切,比如应用服务器、数据库和其他东西。但它也有缺点。当软件出现问题时,一切都会出现问题。此外,开发人员需要设置整个应用程序来开发一个小部分。

单体应用程序的缺点清单可能包括:

  • 紧密耦合的架构

  • 单点故障

  • 添加新功能和组件的速度

  • 工作的碎片化仅限于团队

  • 持续部署非常困难,因为需要推送整个应用程序

查看单体应用程序时,整个堆栈被视为单个实体。如果数据库出现故障,应用程序也会出现故障。如果代码中的错误导致软件应用程序崩溃,与客户端的整个连接也会中断。这实际上导致了微服务的出现。

让我们来看一个场景。Bob 经营的公司使用传统的面向服务的架构SOA),开发人员全天候工作以添加新功能。如果有发布,人们需要对每个小组件的代码进行全面测试。当所有更改完成时,项目从开发转移到测试。下一条街上的另一家公司由 Alice 经营,使用微服务架构。Alice 公司的所有软件开发人员都在个别服务上工作,这些服务通过连续的构建流水线进行测试,并且通知非常迅速。开发人员通过彼此的 REST/RPC API 交流以添加新功能。与 Bob 的开发人员相比,他们可以轻松地将其堆栈从一种技术转移到另一种技术。这个例子表明了 Alice 公司的灵活性和速度比 Bob 公司更大。

微服务还创建了一个允许我们使用容器(docker 等)的平台。在微服务中,编排和服务发现对于跟踪松散耦合的元素非常重要。诸如 Kubernetes 之类的工具用于管理 docker 容器。通常,为微服务拥有一个 docker 容器是一个很好的做法。服务发现是在飞行中自动检测 IP 地址和其他详细信息。这消除了硬编码微服务需要相互协商的东西的潜在威胁。

单体架构与微服务

行业专家建议将软件应用程序作为单体架构开始,然后逐步将其拆分为微服务。这实际上帮助我们专注于应用程序交付,而不是研究微服务模式。一旦产品稳定下来,开发人员应该找到一种松散耦合功能的方法。看一下下面的图表:

这张图描述了单体架构和微服务架构的结构。单体架构将所有内容包裹在洋葱形式中。它被称为紧密耦合的系统。相比之下,微服务是独立的,易于替换和修改。每个微服务可以通过各种传输机制(如 HTTP 和 RPC)相互通信。格式可以是 JSON 或协议缓冲区。

Go Kit,用于构建微服务的包

在企业世界中,人们了解 Netflix 的 Eureka 和 Java 社区的 Spring Boot。在 Go 中,一个试图达到那个实现水平的包显然是Go kit。这是一个用于构建微服务的工具包。

它具有 Go 风格的添加服务的方式,这让我们感觉良好。它带有一个添加微服务的过程。在接下来的章节中,我们将看到如何按照 Go Kit 定义的步骤创建微服务。它主要由许多层组成。在 Go Kit 中,有三个层,请求和响应在其中流动:

  • 传输层:这负责将数据从一个服务传输到另一个服务

  • 终端层:这负责为给定服务构建终端

  • 服务层:这是 API 处理程序的实际业务逻辑

使用以下命令安装 Go Kit:

go get github.com/go-kit/kit

让我们为我们的第一个微服务制定计划。我们都知道消息的加密。可以使用密钥加密消息字符串,输出一个无意义的消息,可以通过网络传输。接收者解密消息并获得原始字符串。这个过程称为加密。我们将尝试将其作为微服务示例的一部分实现:

  • 首先,开发加密逻辑

  • 然后,将其与 Go Kit 集成

Go 自带了用于加密消息的包。我们需要从这些包中导入加密算法并使用它们。作为第一步,我们将编写一个使用高级加密标准AES)的项目。

GOPATH/src/user目录中创建一个名为encryptString的目录:

mkdir $GOPATH/src/github.com/narenaryan/encryptString
cd $GOPATH/src/github.com/narenaryan/encryptString

现在让我们在新目录中再添加一个,名为 utils。在项目目录中添加两个文件,main.go和在名为utils的新目录中添加utils.go。目录结构如下:

└── encryptString
    ├── main.go
    └── utils
        └── utils.go

现在让我们在我们的utils.go文件中添加加密逻辑。我们创建两个函数,一个用于加密,另一个用于解密消息,如下所示:

package utils
import (
    "crypto/aes"
    "crypto/cipher"
    "encoding/base64"
)

AES 算法需要初始化向量。让我们首先定义它:

// Implements AES encryption algorithm(Rijndael Algorithm)
/* 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}

现在,让我们实现加密和解密的逻辑:

// 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)
}

EncryptString函数中,我们正在使用密钥创建一个新的密码块。然后我们将该块传递给密码块加密器函数。该加密器接受块和初始化向量。然后我们通过在密码块上进行XORKeyStream来生成密文(加密消息)。它填充了密文。然后我们需要进行 Base64 编码以生成受保护的字符串:

// 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)
}

DecryptString函数中,解码 Base64 编码并使用密钥创建一个密码块。将这个密码块与初始化向量传递给NewCFBEncrypter。接下来,使用XORKeyStream将密文加载到明文中。基本上,这是一个在XORKeyStream中交换加密和解密消息的过程。这完成了utils.go文件。

现在让我们编辑main.go文件,以利用前面的utils包:

package main
import (
    "log"
    "github.com/narenaryan/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包中导入加密/解密函数,并使用它们来展示一个例子。

如果我们运行这个程序,我们会看到以下输出:

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 Kit 构建 REST 微服务

有了这些知识,我们准备构建我们的第一个提供加密/解密 API 的微服务。我们使用 Go Kit 和我们的加密utils来编写这个微服务。正如我们在前一节中讨论的,Go-Kit 微服务应该逐步构建。要创建一个服务,我们需要事先设计一些东西。它们是:

  • 服务实现

  • 端点

  • 请求/响应模型

  • 传输

坐稳。这个术语现在似乎很陌生。我们很快就会对它感到很舒适。让我们创建一个具有以下目录结构的目录。每个 Go Kit 项目都可以在这个项目结构中。让我们称我们的项目为encryptService。在encryptService目录中以相同的树结构创建这些文件:

├── helpers
│   ├── endpoints.go
│   ├── implementations.go
│   ├── jsonutils.go
│   └── models.go
└── main.go

我们将逐个查看每个文件,看看应该如何构建。首先,在 Go Kit 中,创建一个接口,告诉我们的微服务执行所有功能。在这种情况下,这些功能是EncryptDecryptEncrypt接受密钥并将文本转换为密码消息。Decrypt使用密钥将密码消息转换回文本。看一下以下代码:

import (
  "context"
)
// EncryptService is a blueprint for our service

type EncryptService interface {
  Encrypt(context.Context, string, string) (string, error)
  Decrypt(context.Context, string, string) (string, error)
}

服务需要实现这些函数以满足接口。接下来,为您的服务创建模型。模型指定服务可以接收和产生的数据。在项目的helpers目录中创建一个models.go文件:

encryptService/helpers/models.go

package helpers

// EncryptRequest strctures request coming from client
type EncryptRequest struct {
  Text string `json:"text"`
  Key  string `json:"key"`
}

// EncryptResponse strctures response going to the client
type EncryptResponse struct {
  Message string `json:"message"`
  Err     string `json:"error"`
}

// DecryptRequest strctures request coming from client
type DecryptRequest struct {
  Message string `json:"message"`
  Key     string `json:"key"`
}

// DecryptResponse strctures response going to the client
type DecryptResponse struct {
  Text string `json:"text"`
  Err  string `json:"error"`
}

由于我们有两个服务函数,所以有四个函数映射到请求和响应。下一步是创建一个实现前面定义的接口EncryptService的结构体。因此,在以下路径中的实现文件中创建该逻辑:

encryptService/helpers/implementations.go

首先,让我们导入所有必要的包。同时,给出包的名称:

package helpers
import (
    "context"
    "crypto/aes"
    "crypto/cipher"
    "encoding/base64"
    "errors"
)
// EncryptServiceInstance is the implementation of interface for micro service
type EncryptServiceInstance struct{}
// Implements AES encryption algorithm(Rijndael Algorithm)
/* 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}
// Encrypt encrypts the string with given key
func (EncryptServiceInstance) Encrypt(_ context.Context, key string, text string) (string, error) {
    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), nil
}
// Decrypt decrypts the encrypted string to original
func (EncryptServiceInstance) Decrypt(_ context.Context, key string, text string) (string, error) {
    if key == "" || text == "" {
        return "", errEmpty
    }
    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), nil
}
var errEmpty = errors.New("Secret Key or Text should not be empty")

这利用了我们在前面示例中看到的相同的 AES 加密。在这个文件中,我们创建了一个名为EncyptionServiceInstance的结构体,它有两个方法,EncryptDecrypt。因此它满足了前面的接口。现在,我们如何将这些实际的服务实现与服务请求和响应联系起来呢?我们需要为此定义端点。因此,添加以下端点以将服务请求与服务业务逻辑链接起来。

我们使用Capitalized函数和变量名称,因为在 Go 中,任何以大写字母开头的函数或变量都是从该包名导出的。在main.go中,要使用所有这些函数,我们需要首先将它们导出。给予大写名称使它们对主程序可见。

helpers目录中创建endpoints.go

package helpers
import (
    "context"
    "github.com/go-kit/kit/endpoint"
)
// EncryptService is a blueprint for our service
type EncryptService interface {
    Encrypt(context.Context, string, string) (string, error)
    Decrypt(context.Context, string, string) (string, error)
}
// MakeEncryptEndpoint forms endpoint for request/response of encrypt function
func MakeEncryptEndpoint(svc EncryptService) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(EncryptRequest)
        message, err := svc.Encrypt(ctx, req.Key, req.Text)
        if err != nil {
            return EncryptResponse{message, err.Error()}, nil
        }
        return EncryptResponse{message, ""}, nil
    }
}
// MakeDecryptEndpoint forms endpoint for request/response of decrypt function
func MakeDecryptEndpoint(svc EncryptService) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(DecryptRequest)
        text, err := svc.Decrypt(ctx, req.Key, req.Message)
        if err != nil {
            return DecryptResponse{text, err.Error()}, nil
        }
        return DecryptResponse{text, ""}, nil
    }
}

在这里,我们将之前的接口定义代码与端点定义代码结合在一起。端点以服务作为参数并返回一个函数。这个函数又以请求为参数并返回一个响应。这些东西与我们在models.go文件中定义的内容相同。我们检查错误,然后返回响应的结构体。

现在,一切都很好。在我们之前的 REST API 示例中,我们总是试图将 JSON 字符串解组为 Go 结构。对于响应,我们通过编组将结构转换回 JSON 字符串。在这里,我们分别解组和编组请求和响应。为此,我们编写一个用于编码/解码逻辑的文件。让我们称该文件为jsonutils.go并将其添加到helpers目录中:

package helpers
import (
    "context"
    "encoding/json"
    "net/http"
)
// DecodeEncryptRequest fills struct from JSON details of request
func DecodeEncryptRequest(_ context.Context, r *http.Request) (interface{}, error) {
    var request EncryptRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}
// DecodeDecryptRequest fills struct from JSON details of request
func DecodeDecryptRequest(_ context.Context, r *http.Request) (interface{}, error) {
    var request DecryptRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}
// EncodeResponse is common for both the reponses from encrypt and decrypt services
func EncodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
    return json.NewEncoder(w).Encode(response)
}

EncodeResponse用于编组EncyptServiceDecryptService的响应,但是在将 JSON 解码为结构时,我们需要两种不同的方法。我们将它们定义为DecodeEncryptRequestDecodeDecryptRequest。这些函数使用 Go 的内部 JSON 包来编组和解组数据。

现在我们有了所有需要创建微服务的构造的辅助文件。让我们设计main函数,导入现有的内容并将微服务连接到服务器:

package main
import (
    "log"
    "net/http"
    httptransport "github.com/go-kit/kit/transport/http"
    "github.com/narenaryan/encryptService/helpers"
)
func main() {
    svc := helpers.EncryptServiceInstance{}
    encryptHandler := httptransport.NewServer(helpers.MakeEncryptEndpoint(svc),
        helpers.DecodeEncryptRequest,\
        helpers.EncodeResponse)
    decryptHandler := httptransport.NewServer(helpers.MakeDecryptEndpoint(svc),
        helpers.DecodeDecryptRequest,
        helpers.EncodeResponse)
    http.Handle("/encrypt", encryptHandler)
    http.Handle("/decrypt", decryptHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

我们正在导入 Go Kit 的 transport/http 作为httptransport来创建处理程序。处理程序附加了端点、JSON 解码器和 JSON 编码器。然后,使用 Go 的 net/http,我们处理给定 URL 端点的 HTTP 请求。httptransport.NewServer接受一些参数:一个端点,JSON 解码器和 JSON 编码器。服务执行的逻辑在哪里?它在端点中。端点接受请求模型并输出响应模型。现在,让我们在encryptService目录中运行这个项目:

go run main.go

我们可以使用 curl 进行POST请求来检查输出:

curl -XPOST -d'{"key":"111023043350789514532147", "text": "I am A Message"}' localhost:8080/encrypt

{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}

我们向微服务提供了密钥和消息。它返回了密文消息。这意味着服务加密了文本。通过传递相同的密钥以及密文消息,再发出一个请求来解密消息:

curl -XPOST -d'{"key":"111023043350789514532147", "message": "8/+JCfTb+ibIjzQtmCo="}' localhost:8080/decrypt

{"text":"I am A Message","error":""}

它返回了我们最初传递的确切消息。万岁!我们编写了我们的第一个用于加密/解密消息的微服务。除了处理正常的 HTTP 请求外,Go Kit 还提供了许多其他有用的构造,例如用于中间件的:

  • 传输日志

  • 应用程序日志

  • 应用程序仪表化

  • 服务发现

在接下来的章节中,我们将讨论前面列表中的一些重要构造。

为您的微服务添加日志记录

在本节中,让我们学习如何向我们的 Go Kit 微服务添加传输级别日志和应用程序级别日志。我们使用上面的示例,但稍作修改。让我们称我们的新项目为encryptServiceWithLogging。在本书的 GitHub 项目中,您将找到这个目录。在本书中,我们多次讨论了中间件的概念。作为复习,中间件是在到达相应的请求处理程序之前/之后篡改请求/响应的函数。Go Kit 允许我们创建记录中间件,将其附加到我们的服务上。该中间件将具有记录逻辑。在这个示例中,我们尝试记录到 Stderr(控制台)。如下所示,将一个名为middleware.go的新文件添加到helpers目录中:

package helpers
import (
    "context"
    "time"
    log "github.com/go-kit/kit/log"
)
// LoggingMiddleware wraps the logs for incoming requests
type LoggingMiddleware struct {
    Logger log.Logger
    Next EncryptService
}
// Encrypt logs the encyption requests
func (mw LoggingMiddleware) Encrypt(ctx context.Context, key string, text string) (output string, err error) {
    defer func(begin time.Time) {
        _ = mw.Logger.Log(
            "method", "encrypt",
            "key", key,
            "text", text,
            "output", output,
            "err", err,
            "took", time.Since(begin),
        )
    }(time.Now())
    output, err = mw.Next.Encrypt(ctx, key, text)
    return
}
// Decrypt logs the encyption requests
func (mw LoggingMiddleware) Decrypt(ctx context.Context, key string,
text string) (output string, err error) {
    defer func(begin time.Time) {
        _ = mw.Logger.Log(
            "method", "decrypt",
            "key", key,
            "message", text,
            "output", output,
            "err", err,
            "took", time.Since(begin),
        )
    }(time.Now())
    output, err = mw.Next.Decrypt(ctx, key, text)
    return
}

我们需要创建一个具有记录器和我们的服务实例的结构。然后,在该结构上定义一些方法,这些方法的名称与服务方法相似(在本例中,它们是encryptdecrypt)。Logger是 Go Kit 的记录器,具有Log函数。这个Log函数接受一些参数。它接受一对参数。第一个和第二个是一组。第三个和第四个是另一组。请参考以下代码片段:

mw.Logger.Log(
      "method", "decrypt",
      "key", key,
      "message", text,
      "output", output,
      "err", err,
      "took", time.Since(begin),
    )

我们需要维护日志应该打印的顺序。在记录我们的请求详细信息后,我们确保允许请求通过这个函数继续到下一个中间件/处理程序。NextEncryptService类型,它是我们的实际实现:

mw.Next.(Encrypt/Decrypt)

对于加密函数,中间件记录加密请求并将其传递给服务的实现。为了将创建的中间件挂接到我们的服务中,修改main.go如下:

package main
import (
    "log"
    "net/http"
    "os"
    kitlog "github.com/go-kit/kit/log"
    httptransport "github.com/go-kit/kit/transport/http"
    "github.com/narenaryan/encryptService/helpers"
)
func main() {
    logger := kitlog.NewLogfmtLogger(os.Stderr)
    var svc helpers.EncryptService
    svc = helpers.EncryptServiceInstance{}
    svc = helpers.LoggingMiddleware{Logger: logger, Next: svc}
    encryptHandler := httptransport.NewServer(helpers.MakeEncryptEndpoint(svc),
        helpers.DecodeEncryptRequest,
        helpers.EncodeResponse)
    decryptHandler := httptransport.NewServer(helpers.MakeDecryptEndpoint(svc),
        helpers.DecodeDecryptRequest,
        helpers.EncodeResponse)
    http.Handle("/encrypt", encryptHandler)
    http.Handle("/decrypt", decryptHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

我们从 Go Kit 中导入日志作为kitlog。我们使用NewLogfmtLogger(os.Stderr)创建了一个新的记录器。这将日志附加到控制台。现在,将这个记录器和服务传递给LoggingMiddleware。它返回可以传递给 HTTP 服务器的服务。现在,让我们从encryptServiceWithLogging运行程序,看看控制台上的输出日志:

go run main.go

它启动我们的微服务。现在,从CURL命令发出客户端请求:

curl -XPOST -d'{"key":"111023043350789514532147", "text": "I am A Message"}' localhost:8080/encrypt

curl -XPOST -d'{"key":"111023043350789514532147", "message": "8/+JCfTb+ibIjzQtmCo="}' localhost:8080/decrypt
{"text":"I am A Message","error":""}

这在服务器控制台上记录以下消息:

method=encrypt key=111023043350789514532147 text="I am A Message" output="8/+JCfTb+ibIjzQtmCo=" err=null took=11.32µs

method=decrypt key=111023043350789514532147 message="8/+JCfTb+ibIjzQtmCo=" output="I am A Message" err=null took=6.773µs

这是为了记录每个应用程序/服务的消息。系统级别的日志记录也是可用的,并且可以从 Go Kit 的文档中获取。

为您的微服务添加仪表

对于任何微服务,除了日志记录,仪表是至关重要的。Go Kit 的metrics包记录有关服务运行时行为的统计信息:计算已处理作业的数量,记录请求完成后的持续时间等。这也是一个篡改 HTTP 请求并收集指标的中间件。要定义一个中间件,只需添加一个与日志中间件类似的结构。除非我们监视,否则指标是无用的。Prometheus是一个可以收集延迟、给定服务的请求数等指标的指标监控工具。Prometheus 从 Go Kit 生成的指标中抓取数据。

您可以从这个网站下载最新稳定版本的 Prometheus。在使用 Prometheus 之前,请确保安装 Go Kit 需要的这些包:

go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp

安装了这些包之后,尝试将最后讨论的日志服务项目复制到一个名为encryptServiceWithInstrumentation的目录中。该目录与原来完全相同,只是我们在helpers目录中添加了一个名为instrumentation.go的文件,并修改了我们的main.go以导入仪表中间件。项目结构如下:

├── helpers
│   ├── endpoints.go
│   ├── implementations.go
│   ├── instrumentation.go
│   ├── jsonutils.go
│   ├── middleware.go
│   └── models.go
└── main.go

仪表可以测量每个服务的请求数和延迟,以参数如CounterHistogram为单位。我们尝试创建一个具有这两个测量(请求数、延迟)并实现给定服务的函数的中间件。在这些中间件函数中,我们尝试调用 Prometheus 客户端 API 来增加请求数、记录延迟等。核心的 Prometheus 客户端库尝试以这种方式增加请求计数:

// Prometheus
c := prometheus.NewCounter(stdprometheus.CounterOpts{
    Name: "request_duration",
    ...
}, []string{"method", "status_code"})
c.With("method", "MyMethod", "status_code", strconv.Itoa(code)).Add(1)

NewCounter创建一个新的计数器结构,需要计数器选项。这些选项是操作的名称和其他细节。然后,我们需要在该结构上调用With函数,传入方法、方法名称和错误代码。这个特定的签名是 Prometheus 要求生成计数器指标的。最后,我们使用Add(1)函数调用增加计数器。

新添加的instrumentation.go文件的实现如下:

package helpers
import (
    "context"
    "fmt"
    "time"
    "github.com/go-kit/kit/metrics"
)
// InstrumentingMiddleware is a struct representing middleware
type InstrumentingMiddleware struct {
    RequestCount metrics.Counter
    RequestLatency metrics.Histogram
    Next EncryptService
}
func (mw InstrumentingMiddleware) Encrypt(ctx context.Context, key string, text string) (output string, err error) {
    defer func(begin time.Time) {
        lvs := []string{"method", "encrypt", "error", fmt.Sprint(err != nil)}
        mw.RequestCount.With(lvs...).Add(1)
        mw.RequestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
    }(time.Now())
    output, err = mw.Next.Encrypt(ctx, key, text)
    return
}
func (mw InstrumentingMiddleware) Decrypt(ctx context.Context, key string, text string) (output string, err error) {
    defer func(begin time.Time) {
        lvs := []string{"method", "decrypt", "error", "false"}
        mw.RequestCount.With(lvs...).Add(1)
        mw.RequestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
    }(time.Now())
    output, err = mw.Next.Decrypt(ctx, key, text)
    return
}

这与日志中间件代码完全相同。我们创建了一个带有几个字段的结构体。我们附加了加密和解密服务的函数。在中间件函数内部,我们正在寻找两个指标;一个是计数,另一个是延迟。当一个请求通过这个中间件时:

mw.RequestCount.With(lvs...).Add(1)

这一行增加了计数器。现在看看另一行:

mw.RequestLatency.With(lvs...).Observe(time.Since(begin).Seconds())

这一行通过计算请求到达时间和最终时间之间的差异来观察延迟(由于使用了 defer 关键字,这将在请求和响应周期完成后执行)。简而言之,前面的中间件将请求计数和延迟记录到 Prometheus 客户端提供的指标中。现在让我们修改我们的main.go文件,使其看起来像这样:

package main
import (
    "log"
    "net/http"
    "os"
    stdprometheus "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    kitlog "github.com/go-kit/kit/log"
    httptransport "github.com/go-kit/kit/transport/http"
    "github.com/narenaryan/encryptService/helpers"
    kitprometheus "github.com/go-kit/kit/metrics/prometheus"
)
func main() {
    logger := kitlog.NewLogfmtLogger(os.Stderr)
    fieldKeys := []string{"method", "error"}
    requestCount := kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{
        Namespace: "encryption",
        Subsystem: "my_service",
        Name: "request_count",
        Help: "Number of requests received.",
    }, fieldKeys)
    requestLatency := kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{
        Namespace: "encryption",
        Subsystem: "my_service",
        Name: "request_latency_microseconds",
        Help: "Total duration of requests in microseconds.",
    }, fieldKeys)
    var svc helpers.EncryptService
    svc = helpers.EncryptServiceInstance{}
    svc = helpers.LoggingMiddleware{Logger: logger, Next: svc}
    svc = helpers.InstrumentingMiddleware{RequestCount: requestCount, RequestLatency: requestLatency, Next: svc}
    encryptHandler := httptransport.NewServer(helpers.MakeEncryptEndpoint(svc),
        helpers.DecodeEncryptRequest,
        helpers.EncodeResponse)
    decryptHandler := httptransport.NewServer(helpers.MakeDecryptEndpoint(svc),
        helpers.DecodeDecryptRequest,
        helpers.EncodeResponse)
    http.Handle("/encrypt", encryptHandler)
    http.Handle("/decrypt", decryptHandler)
    http.Handle("/metrics", promhttp.Handler())
    log.Fatal(http.ListenAndServe(":8080", nil))
}

我们导入了 kit Prometheus 包来初始化指标模板,以及客户端 Prometheus 包来提供选项结构。我们创建了requestCountrequestLatency类型的指标结构,并将它们传递给我们从helpers导入的InstrumentingMiddleware。如果你看到这一行:

 requestCount := kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{
    Namespace: "encryption",
    Subsystem: "my_service",
    Name:      "request_count",
    Help:      "Number of requests received.",
  }, fieldKeys)

这就是我们如何创建一个模板,与helpers.go中的InstrumentingMiddleware结构中的RequestCount匹配。我们传递的选项将附加到一个字符串中,同时生成指标:

encryption_my_service_request_count

这是一个唯一可识别的服务仪器,告诉我们,“这是一个用于名为 Encryption 的我的微服务的请求计数操作”。我们还在main.go的服务器部分的代码中添加了一行有趣的内容:

"github.com/prometheus/client_golang/prometheus/promhttp"
...
http.Handle("/metrics", promhttp.Handler())

这实际上创建了一个端点,可以生成一个包含收集到的指标的页面。Prometheus 可以解析此页面以存储、绘制和显示指标。如果我们运行程序并对加密服务进行 5 次 HTTP 请求,并对解密服务进行 10 次 HTTP 请求,指标页面将记录请求的计数和它们的延迟:

go run main.go # This starts the server

从另一个 bash shell(在 Linux 中)循环对加密服务进行 5 次 CURL 请求:

for i in 1 2 3 4 5; do curl -XPOST -d'{"key":"111023043350789514532147", "text": "I am A Message"}' localhost:8080/encrypt; done

{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}
{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}
{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}
{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}
{"message":"8/+JCfTb+ibIjzQtmCo=","error":""}

对解密服务进行 10 次 CURL 请求(输出已隐藏以保持简洁):

for i in 1 2 3 4 5 6 7 8 9 10; do curl -XPOST -d'{"key":"111023043350789514532147", "message": "8/+JCfTb+ibIjzQtmCo="}' localhost:8080/decrypt; done

现在,访问 URLhttp://localhost:8080/metrics,您将看到 Prometheus Go 客户端为我们生成的页面。页面的内容将包含以下信息:

# HELP encryption_my_service_request_count Number of requests received.
# TYPE encryption_my_service_request_count counter
encryption_my_service_request_count{error="false",method="decrypt"} 10
encryption_my_service_request_count{error="false",method="encrypt"} 5
# HELP encryption_my_service_request_latency_microseconds Total duration of requests in microseconds.
# TYPE encryption_my_service_request_latency_microseconds summary
encryption_my_service_request_latency_microseconds{error="false",method="decrypt",quantile="0.5"} 5.4538e-05
encryption_my_service_request_latency_microseconds{error="false",method="decrypt",quantile="0.9"} 7.6279e-05
encryption_my_service_request_latency_microseconds{error="false",method="decrypt",quantile="0.99"} 8.097e-05
encryption_my_service_request_latency_microseconds_sum{error="false",method="decrypt"} 0.000603101
encryption_my_service_request_latency_microseconds_count{error="false",method="decrypt"} 10
encryption_my_service_request_latency_microseconds{error="false",method="encrypt",quantile="0.5"} 5.02e-05
encryption_my_service_request_latency_microseconds{error="false",method="encrypt",quantile="0.9"} 8.8164e-05
encryption_my_service_request_latency_microseconds{error="false",method="encrypt",quantile="0.99"} 8.8164e-05
encryption_my_service_request_latency_microseconds_sum{error="false",method="encrypt"} 0.000284823
encryption_my_service_request_latency_microseconds_count{error="false",method="encrypt"} 5

如您所见,有两种类型的指标:

  • encryption_myservice_request_count

  • encryption_myservice_request_latency_microseconds

如果您看到对encrypt方法和decrypt方法的请求数,它们与我们发出的 CURL 请求相匹配。

encryption_myservice指标类型对加密和解密微服务都有计数和延迟指标。方法参数告诉我们这些指标是从哪个微服务中提取的。

这些类型的指标为我们提供了关键的见解,例如哪个微服务被大量使用以及延迟趋势随时间的变化等。但是,要看到数据的实际情况,您需要安装 Prometheus 服务器,并为 Prometheus 编写一个配置文件,以从 Go Kit 服务中抓取指标。有关在 Prometheus 中创建目标(生成指标页面的主机)的更多信息,请访问prometheus.io/docs/operating/configuration/

我们还可以将来自 Prometheus 的数据传递给 Grafana,这是一个用于漂亮实时图表的图形化和监控工具。Go Kit 还提供了许多其他功能,例如服务发现。只有在系统松散耦合、监控和优化的情况下,微服务才能进行扩展。

总结

在本章中,我们从微服务的定义开始。单体应用程序和微服务之间的主要区别在于紧密耦合的架构是如何被分解为松散耦合的架构。微服务之间使用基于 REST 的 JSON 或基于 RPC 的协议缓冲区进行通信。使用微服务,我们可以将业务逻辑分解为多个部分。每个服务都很好地完成了一项工作。这种方法也带来了一个缺点。监控和管理微服务是痛苦的。Go 提供了一个名为 Go Kit 的精彩工具包。这是一个微服务框架,使用它我们可以为微服务生成样板代码。

我们需要在 Go Kit 中定义一些东西。我们需要为 Go-Kit 服务创建实现、端点和模型。端点接收请求并返回响应。实现具有服务的实际业务逻辑。模型是解码和编码请求和响应对象的一种好方法。Go Kit 提供了各种中间件,用于执行重要任务,如日志记录、仪表(指标)和服务发现。

小型组织可以从单体应用开始,但在规模更大的组织中,拥有庞大团队的微服务更合适。在下一章中,我们将看到如何使用 Nginx 部署我们的 Go 服务。服务需要部署才能暴露给外部世界。

第十章:部署我们的 REST 服务

在本章中,我们将看到如何使用 Nohup 和 Nginx 等工具部署我们的 Go 应用程序。要使网站对互联网可见,我们需要有一个虚拟专用服务器VPS)和部署工具。我们首先将看到如何运行一个 Go 可执行文件并使用 Nohup 将其作为后台进程。接下来,我们将安装 Nginx 并配置它以代理 Go 服务器。

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

  • 什么是 Nginx 代理服务器?

  • 学习 Nginx 服务器块

  • Nginx 中的负载均衡策略

  • 使用 Nginx 部署我们的 Go 服务

  • 限制速率和保护我们的 Nginx 代理服务器

  • 使用名为 Supervisord 的工具监视我们的 Go 服务

获取代码

本章的代码可在github.com/narenaryan/gorestful/tree/master/chapter10找到。将其复制到GOPATH并按照章节中给出的说明运行。

安装和配置 Nginx

Nginx 是一个高性能的 Web 服务器和负载均衡器,非常适合部署高流量的网站。尽管这个决定是有意见的,但 Python 和 Node 开发人员通常使用它。

Nginx 还可以充当上游代理服务器,允许我们将 HTTP 请求重定向到在同一服务器上运行的多个应用程序服务器。Nginx 的主要竞争对手是 Apache 的 httpd。Nginx 是一个出色的静态文件服务器,可以被 Web 客户端使用。由于我们正在处理 API,我们将研究处理 HTTP 请求的方面。

在 Ubuntu 16.04 上,使用以下命令安装 Nginx:

sudo apt-get update
sudo apt-get install nginx

在 macOS X 上,您可以使用brew安装它:

brew install nginx

brew.sh/是一个非常有用的 macOS X 用户软件打包系统。我的建议是使用它来安装软件。安装成功后,您可以通过在浏览器中打开机器 IP 来检查它。在您的 Web 浏览器中打开http://localhost/。您将看到这个:

这意味着 Nginx 已成功安装。它正在端口80上提供服务并提供默认页面。在 macOS 上,默认的 Nginx 监听端口将是8000

sudo vi /usr/local/etc/nginx/nginx.conf

在 Ubuntu(Linux)上,文件将位于此路径:

sudo vi /etc/nginx/nginx.conf

打开文件,搜索服务器并将端口80修改为8000

server {
        listen 8080; # Change this to 80 
        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/)访问它,而不需要端口(http://localhost:3000)。这个基本服务器从一个名为html的目录中提供静态文件。root参数可以修改为我们放置 Web 资产的任何目录。您可以使用以下命令检查 Nginx 的状态:

service nginx status

Windows 操作系统上的 Nginx 相当基本,实际上并不适用于生产级部署。开源开发人员通常更喜欢 Debian 或 Ubuntu 服务器来部署带有 Nginx 的 API 服务器。

什么是代理服务器?

代理服务器是一个保存原始服务器信息的服务器。它充当客户端请求的前端。每当客户端发出 HTTP 请求时,它可以直接进入应用服务器。但是,如果应用服务器是用编程语言编写的,您需要一个可以将应用程序响应转换为客户端可理解响应的翻译器。通用网关接口CGI)也是这样做的。对于 Go,我们可以运行一个简单的 HTTP 服务器,它可以作为一个普通服务器运行(不需要翻译)。那么,为什么我们要使用另一个名为 Nginx 的服务器?我们使用 Nginx 是因为它将许多东西带入了视野。

拥有代理服务器(Nginx)的好处:

  • 它可以充当负载均衡器

  • 它可以坐在应用程序集群的前面并重定向 HTTP 请求

  • 它可以以良好的性能提供文件系统

  • 它可以很好地流媒体

如果同一台机器正在运行多个应用程序,那么我们可以将所有这些应用程序放在一个伞下。Nginx 也可以充当 API 网关,可以是多个 API 端点的起点。我们将在下一章中看到一个专门的 API 网关,但 Nginx 也可以起到这样的作用。参考以下图表:

如果您看到,图示客户端直接与 Nginx 通信,而不是其他应用程序运行的端口。在图表中,Go 正在8000端口上运行,其他应用程序正在不同的端口上运行。这意味着不同的服务器提供不同的 API 端点。如果客户端希望调用这些 API,则需要访问三个端口。相反,如果我们有 Nginx,它可以作为所有三个的代理服务器,并简化客户端的请求-响应周期。

Nginx 也被称为上游服务器,因为它为其他服务器提供请求。从图示中,Python 应用程序可以顺利地从 Go 应用程序请求 API 端点。

重要的 Nginx 路径

有一些重要的 Nginx 路径,我们需要了解如何使用代理服务器。在 Nginx 中,我们可以同时托管多个站点(www.example1.comwww.exampl2.com等)。看一下下表:

类型 路径 描述
配置 /etc/nginx/nginx.con 这是基本的 Nginx 配置文件。它可以用作默认文件。
配置 /etc/nginx/sites-available/ 如果我们在 Nginx 中运行多个站点,我们可以有多个配置文件。
配置 /etc/nginx/sites-enabled/ 这些是当前在 Nginx 上激活的站点。
日志 /var/log/nginx/access.log 此日志文件记录服务器活动,如时间戳和 API 端点。
日志 /var/log/nginx/error.log 此日志文件记录所有与代理服务器相关的错误,如磁盘空间,文件系统权限等。

这些路径在 Linux 操作系统中。对于 macOS X,请使用/usr/local/nginx作为基本路径。

使用服务器块

服务器块是实际的配置部分,告诉服务器要提供什么以及在哪个端口上监听。我们可以在sites-available文件夹中定义多个服务器块。在 Ubuntu 上,位置将是:

/etc/nginx/sites-available

在 macOS X 上,位置将是:

/usr/local/etc/nginx/sites-avaiable

直到我们将sites-available复制到sites-enabled目录,配置才会生效。因此,对于您创建的每个新配置,始终为sites-available创建到sites-enabled的软链接。

创建一个示例 Go 应用程序并对其进行代理

现在,让我们在 Go 中创建一个简单的应用程序服务器,并记录日志:

mkdir -p $GOPATH/src/github.com/narenaryan/basicServer
vi $GOPATH/src/github.com/narenaryan/basicServer/main.go

这个文件是一个基本的 Go 服务器,用来说明代理服务器的功能。然后,我们向 Nginx 添加一个配置,将端口8000(Go 运行端口)代理到 HTTP 端口(80)。现在,让我们编写代码:

package main
import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
)
// Book holds data of a book
type Book struct {
    ID int
    ISBN string
    Author string
    PublishedYear string
}
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 attache sprogram logs to file
    log.SetOutput(f)
    // Function handler for handling requests
    http.HandleFunc("/", 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)
    })
    s := &http.Server{
        Addr: ":8000",
        ReadTimeout: 10 * time.Second,
        WriteTimeout: 10 * time.Second,
        MaxHeaderBytes: 1 << 20,
    }
    log.Fatal(s.ListenAndServe())
}

这是一个简单的服务器,返回书籍详细信息作为 API(这里是虚拟数据)。运行程序并在8000端口上运行。现在,打开一个 shell 并进行 CURL 命令:

CURL -X GET "http://localhost:8000"

它返回数据:

{
  "ID":123,
  "ISBN":"0-201-03801-3",
  "Author":"Donald Knuth",
  "PublishedYear":"1968"
}

但是客户端需要在这里请求8000端口。我们如何使用 Nginx 代理此服务器?正如我们之前讨论的,我们需要编辑默认的 sites-available 服务器块,称为default

vi /etc/nginx/sites-available/default

编辑此文件,找到服务器块,并在其中添加一行:

server {
        listen 80 default_server;
        listen [::]:80 default_server ipv6only=on;

        root /usr/share/nginx/html;
        index index.html index.htm;

        # Make site accessible from http://localhost/
        server_name localhost;

        location / {
                # First attempt to serve request as file, then
                # as directory, then fall back to displaying a 404.
                try_files $uri $uri/ =404;
                # Uncomment to enable naxsi on this location
                # include /etc/nginx/naxsi.rules
                proxy_pass http://127.0.0.1:8000;
        }
}

config文件的这一部分称为服务器块。这控制了代理服务器的设置,其中listen表示nginx应该监听的位置。rootindex指向静态文件,如果需要提供任何文件。server_name是您的域名。由于我们还没有准备好域名,它只是本地主机。location是这里的关键部分。在location中,我们可以定义我们的proxy_pass,它可以代理给定的URL:PORT。由于我们的 Go 应用程序正在8000端口上运行,我们在那里提到了它。如果我们在不同的机器上运行它,比如:

http://example.com:8000

我们可以将相同的内容作为参数传递给proxy_pass。为了使这个配置生效,我们需要重新启动 Nginx 服务器。使用以下命令进行:

service nginx restart

现在,进行 CURL 请求到http://localhost,您将看到 Go 应用程序的输出:

CURL -X GET "http://localhost"
{
  "ID":123,
  "ISBN":"0-201-03801-3",
  "Author":"Donald Knuth",
  "PublishedYear":"1968"
}

location是一个指令,定义了可以代理给定server:port组合的统一资源标识符URI)。这意味着通过定义各种 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/

使用 Nginx 进行负载均衡

在实际情况下,我们使用多个服务器来处理大量的 API 请求。但是谁需要将传入的客户端请求转发到服务器实例?负载均衡是一个过程,其中中央服务器根据某些标准将负载分配给各个服务器。参考以下图表:

这些请求标准被称为负载均衡方法。让我们看看每个方法在一个简单的表中是如何工作的:

负载均衡方法 描述
轮询 请求均匀分布到服务器上,并且考虑服务器权重。
最少连接 请求被发送到当前为最少客户端提供服务的服务器。
IP 哈希 用于将来自特定客户端 IP 的请求发送到给定服务器。只有在该服务器不可用时才会被发送到另一个服务器。
最少时间 客户端的请求被发送到平均延迟(为客户端提供服务的时间)最低且活动连接最少的机器。

我们现在看到了如何在 Nginx 中实际实现负载均衡,用于我们的 Go API 服务器。这个过程的第一步是在 Nginx 配置文件的http部分创建一个upstream

http {
    upstream cluster {
        server site1.mysite.com weight=5;
        server site2.mysite.com weight=2;
        server backup.mysite.com backup;
    }
}

在这里,服务器是运行相同代码的服务器的 IP 地址或域名。我们在这里定义了一个名为backendupstream。这是一个我们可以在位置指令中引用的服务器组。权重应该根据可用资源进行分配。在前面的代码中,site1被赋予更高的权重,因为它可能是一个更大的实例(内存和磁盘)。现在,在位置指令中,我们可以使用proxy_pass命令指定服务器组:

server {
    location / {
        proxy_pass http://cluster;
    }
}

现在,运行的代理服务器将传递所有命中/的 API 端点的请求到集群中的机器。默认的请求路由算法将是轮询,这意味着所有服务器的轮流将一个接一个地重复。如果我们需要更改它,我们在上游定义中提到。看一下以下代码片段:

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_conn是我们用来指定负载均衡方法的字符串。其他值可以是ip_hashleast_time。您可以通过在局域网LAN)中拥有一组机器来尝试这个。或者,我们可以安装 Docker,并使用多个虚拟容器作为不同的机器来测试负载均衡。

我们需要在/etc/nginx/nginx.conf文件中添加http块,而服务器块在/etc/nginx/sites-enabled/default中。最好将这两个设置分开。

限制我们的 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 分配的存储空间,用于执行其记忆功能。它可以取值如 8m(8MB)或 16m(16MB)。现在,让我们看看在哪里添加这些设置。前面的设置应该作为全局设置添加到nginx.conf文件中的http指令中:

http {
    limit_conn_zone $server_name zone=servers:10m;
}

这为 Nginx 分配了用于使用的共享内存。现在,在 sites-available/default 的服务器指令中,添加以下内容:

server {
   limit_conn servers 1000;
}

在前面的配置中,使用limit_conn限制给定服务器的连接总数不会超过 1K。如果我们尝试从给定 IP 地址对客户端进行速率限制,那么可以使用这个:

server {
  location /api {
      limit_conn addr 1;
  }
}

此设置阻止客户端(IP 地址)向服务器(例如在线铁路订票)打开多个连接。如果我们有一个客户端下载文件并需要设置带宽约束,可以使用limit_rate

server {
  location /download {
      limit_conn addr 10;
      limit_rate 50k;
  }
}

通过这种方式,我们可以控制客户端与 Nginx 代理的服务的交互。如果我们直接使用 Go 二进制文件运行服务,就会失去所有这些功能。

保护我们的 Nginx 代理服务器

这是 Nginx 设置中最重要的部分。在本节中,我们将看到如何使用基本身份验证限制对服务器的访问。这对于我们的 REST API 服务器非常重要,因为假设我们有服务器 X、Y 和 Z 彼此通信。X 可以直接为客户端提供服务,但 X 通过调用内部 API 与 Y 和 Z 交流获取一些信息。由于我们知道客户端不应该访问 Y 或 Z,我们可以设置只允许 X 访问资源。我们可以使用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_http_access_module上的文档。我们还可以为 Nginx 提供的静态文件添加密码保护访问。这在 API 中通常不适用,因为在那里,应用程序负责对用户进行身份验证。

使用 Supervisord 监控我们的 Go API 服务器

Nginx 坐在我们的 Go API 服务器前面,只是代理一个端口,这是可以的。但是,有时 Web 应用程序可能会因操作系统重新启动或崩溃而停止。每当您的 Web 服务器被终止时,就有人的工作是自动将其恢复。Supervisord 就是这样一个任务运行程序。为了使我们的 API 服务器一直运行,我们需要对其进行监控。Supervisord 是一个可以监控运行中进程(系统)并在它们被终止时重新启动它们的工具。

安装 Supervisord

我们可以使用 Python 的pip命令轻松安装 Supervisord。在 Ubuntu 16.04 上,只需使用apt-get命令:

sudo apt-get install -y supervisor

这将安装两个工具,supervisorsupervisorctlSupervisorctl用于控制 supervisor 并添加任务、重新启动任务等。让我们使用我们为 Nginx 创建的basicServre.go程序来说明这一点。将二进制文件安装到$GOPATH/bin目录中。在这里,假设我的GOPATH/root/workspace

go install github.com/narenaryan/basicServer

始终将当前GOPATHbin文件夹添加到系统路径中。每当安装项目二进制文件时,它将作为普通可执行文件在整个系统环境中可用。您可以通过将以下行添加到~/.profile文件来实现:export PATH=$PATH:/usr/local/go/bin

现在,在以下位置创建一个配置文件:

/etc/supervisor/conf.d/goproject.conf

您可以添加任意数量的配置文件,supervisord将它们视为要运行的单独进程。将以下内容添加到前述文件中:

[supervisord]
logfile = /tmp/supervisord.log
[program:myserver]
command=/root/workspace/bin/basicServer
autostart=true
autorestart=true
redirect_stderr=true

默认情况下,我们在/etc/supervisor/有一个名为supervisord.conf的文件。查看它以供参考:

  • [supervisord]部分提供了supervisord的日志文件位置。

  • [program:myserver]是遍历到给定目录并执行给定命令的任务块。

现在,我们可以要求我们的supervisorctl重新读取配置并重新启动任务(进程)。为此,只需说:

supervisorctl reread
supervisorctl update

然后,使用以下命令启动我们的supervisorctl

supervisorctl

您将看到类似于这样的内容:

因此,我们的书籍服务正在被Supervisor监视。让我们试图杀死进程,看看Supervisor会做什么:

kill 6886

现在,尽快,Supervisor通过运行二进制文件启动一个新进程(不同的pid):

这在生产场景中非常有用,因为服务需要在任何崩溃或操作系统重新启动的情况下保持运行。这里有一个问题,我们如何启动/停止应用程序服务?使用supervisorctlstartstop命令进行平稳操作:

supervisorctl> stop myserver
supervisorctl> start myserver

有关 Supervisor 的更多详细信息,请访问supervisord.org/

摘要

本章专门介绍了如何将 API 服务部署到生产环境中。一种方法是运行 Go 二进制文件,并直接从客户端访问IP:端口组合。该 IP 将是虚拟专用服务器VPS)的 IP 地址。相反,我们可以注册一个域名并指向 VPS。第二种更好的方法是将其隐藏在代理服务器后面。Nginx 就是这样一个代理服务器,使用它,我们可以在一个伞下拥有多个应用服务器。

我们看到了如何安装 Nginx 并开始配置它。Nginx 提供了诸如负载平衡和速率限制之类的功能,在向客户端提供 API 时可能至关重要。负载平衡是在类似服务器之间分配负载的过程。我们看到了有哪些类型的负载均衡机制可用。其中一些是轮询、IP 哈希、最小连接等。然后,我们通过允许和拒绝一些 IP 地址集来为我们的服务器添加了认证。

最后,我们需要一个进程监视器,可以将我们崩溃的应用程序恢复过来。Supervisord 是这项工作的一个非常好的工具。我们看到了如何安装 Supervisord,以及如何启动 supervisorctl,一个用于控制运行服务器的命令行应用程序。

在下一章中,我们将看到如何使用 API 网关使我们的 API 达到生产级别。我们将深入讨论如何将我们的 API 置于一个负责认证和速率限制的实体后面。

第十一章:使用 API 网关监视和度量 REST API

一旦我们开发了 API,我们需要将其暴露给外部世界。在这个过程中,我们部署它们。但这足够了吗?我们不需要跟踪我们的 API 吗?哪些客户端正在连接?请求的延迟是多少,等等?有许多其他的 API 开发后步骤,人们应该遵循,使其 API 达到生产级别。它们是身份验证、日志记录、速率限制等。添加这些功能的最佳方式是使用 API 网关。在本章中,我们将探索一个名为 Kong 的开源 API 网关。与云提供商相比,开源软件更可取,因为减少了供应商锁定的风险。所有 API 网关在实现上有所不同,但执行相同的任务。

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

  • 为什么需要 API 网关?

  • 介绍 Kong,一个开源的 API 网关

  • Docker 中的示例说明

  • 将开发的 API 添加到 Kong

  • 在 Kong 中登录

  • Kong 中的身份验证和速率限制

  • Kong CLI 中的重要命令

获取代码

您可以在以下链接找到本章的代码示例:github.com/narenaryan/gorestful/tree/master/chapter11。本章中文件的用法在各自的部分中有解释。您还可以从存储库中导入 Postman 客户端集合(JSON 文件)来测试 API,我们将在本章中介绍。

为什么需要 API 网关?

假设一个名为 XYZ 的公司为其内部目的开发了 API。它以两种方式将 API 暴露给外部使用:

  • 使用已知客户端的身份验证进行暴露

  • 将其作为 API 服务公开

在第一种情况下,此 API 由公司内部的其他服务使用。由于它们是内部的,我们不限制访问。但在第二种情况下,由于 API 细节提供给外部世界,我们需要一个中间人来检查和验证请求。这个中间人就是 API 网关。API 网关是一个位于客户端和服务器之间的中间人,并在满足特定条件时将请求转发到服务器。

现在,XYZ 有一个用 Go 和 Java 编写的 API。有一些通用的事情适用于任何 API:

  • 身份验证

  • 请求和响应的日志记录

没有 API 网关,我们需要编写另一个跟踪请求和 API 身份验证等内容的服务器。当新的 API 不断添加到组织中时,实施和维护是繁琐的。为了处理这些基本事项,API 网关是一个很好的中间件。

基本上,API 网关会执行以下操作:

  • 日志记录

  • 安全

  • 流量控制

  • 转换

日志记录是跟踪请求和响应的方式。如果我们需要组织级别的日志记录,与 Go kit 中的应用级别日志记录相反,我们应该在 API 网关中启用日志记录。安全性是身份验证的工作方式。它可以是基本身份验证,基于令牌的身份验证,OAuth2.0 等。限制对有效客户端的 API 访问是至关重要的。

当 API 是付费服务时,流量控制就会发挥作用。当组织将数据作为 API 出售时,需要限制每个客户端的操作。例如,客户端每月可以发出 10,000 个 API 请求。速率可以根据客户选择的计划进行设置。这是一个非常重要的功能。转换就像在命中应用程序服务器之前修改请求,或者在发送回客户端之前修改响应。看一下以下图表:

我们可以看到如何将上述功能添加到我们的 Web 服务中。从图表中,API 网关可以将请求重定向到任何给定的内部服务器。客户端看到所有 API 都在组织的单个实体下。

Kong,一个开源的 API 网关

Kong 是一个开源的 API 网关和微服务管理层,提供高性能和可靠性。它是两个值得一提的库的组合。一个是OpenResty,另一个是Nginx。Kong 是这两个主要组件的包装器。OpenResty 是一个完整的 Web 平台,集成了 Nginx 和 Lua。Lua 是一种类似于 Go 的编程语言。Kong 是用 Lua 编写的。我们使用 Kong 作为部署我们的 Go REST 服务的工具。我们要讨论的主要主题是:

  • 安装 Kong 和 Kong 数据库

  • 将我们的 API 添加到 Kong

  • 使用插件

  • 登录 Kong

  • 在 Kong 中进行速率限制

Kong 需要一个数据库才能运行。它可以是 Cassandra 或 PostgreSQL。由于我们已经熟悉 PostgreSQL,我们选择了它。在哪里安装它们?为了说明问题,我们可以在本地机器上安装它们,但有一个缺点;它可能会损坏我们的机器。为了测试设置,我们将使用 Docker。Docker 可以创建容器化应用程序并在可预测的隔离环境中运行它们。

使用 Kong,我们可以将我们的 API 隐藏在一个网关下。我们可以为我们的 API 创建消费者(客户端)。Kong 通过 REST API 执行所有操作。Kong 有两种 API:

  • 应用程序 API(运行在端口8000上)

  • 管理 API(运行在端口8001上)

使用应用程序 API,我们可以访问我们的 Web 服务。管理 API 允许我们在网关下添加/删除 API。我们将在接下来的部分中更详细地了解这些内容。有关 Kong 的更多详细信息,请访问getkong.org/

介绍 Docker

Docker 是一个可以创建操作系统的虚拟化工具,以微小容器的形式。它就像在单个主机上有多个操作系统。开发人员通常抱怨说在我的环境中工作,同时面临部署问题。Docker 通过定义镜像形式的 OS 环境来消除这些情况。Docker 镜像包含了在特定时间给定 OS 的所有信息。它允许我们任意多次地复制该环境。

最初只适用于 Linux,但现在适用于 macOS X 和 Windows。要下载和安装 Docker,请访问docs.docker.com/engine/installation/。对于 Windows 和 Mac,二进制文件可在 Docker 网站上找到并且可以轻松安装。安装后,使用以下命令验证 Docker 安装:

docker -v
Docker version 17.09.0-ce, build afdb6d4

它将提供版本号;始终选择最新的 Docker。现在 Docker 准备就绪,让我们运行一些命令来安装 Kong。接下来的部分需要一些 Docker 知识。如果不够自信,请阅读网上关于 Docker 基础知识的精彩文章。

我们的最终目标是创建三个容器:

  • Kong 数据库

  • Go 容器

  • Kong 应用

当这三个容器运行时,它为在 API 网关后面设置 Web 服务的舞台。

安装 Kong 数据库和 Kong

首先,安装 PostgreSQL DB。一个条件是我们需要暴露5432端口。用户和数据库名称应为kong,并且应作为环境变量传递给容器:

docker run -d --name kong-database \
 -p 5432:5432 \
 -e "POSTGRES_USER=kong" \
 -e "POSTGRES_DB=kong" \
 postgres:9.4

这个命令的工作方式是这样的:

  1. 从 Docker 存储库获取名为postgres:9.4的镜像。

  2. 给镜像命名为kong-database

  3. 在名为POSTGRES_USERPOSTGRES_DB的容器中设置环境变量。

这将通过拉取托管在DockerHubhub.docker.com/)存储库上的 PostgreSQL 镜像来创建一个 Docker 容器。现在,通过运行另一个 Docker 命令来应用 Kong 所需的迁移:

docker run --rm \
 --link kong-database:kong-database \
 -e "KONG_DATABASE=postgres" \
 -e "KONG_PG_HOST=kong-database" \
 kong:latest kong migrations up

它在先前创建的 PostgreSQL DB 容器上应用迁移。该命令有一个名为--rm的选项,表示一旦迁移完成,删除此容器。在安装 Kong 容器之前,让我们准备好我们的 Go 服务。这将是一个简单的项目,其中包含一个健康检查GET API。

现在,转到主机上的任何目录并创建一个名为kongExample的项目:

mkdir kongExample

在该目录中创建一个名为main.go的程序,该程序获取GET请求的健康检查(日期和时间):

package main
import (
    "fmt"
    "github.com/gorilla/mux"
    "log"
    "net/http"
    "time"
)
func HealthcheckHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, time.Now().String())
}
func main() {
    // Create a new router
    r := mux.NewRouter()
    // Attach an elegant path with handler
    r.HandleFunc("/healthcheck", HealthcheckHandler)
    srv := &http.Server{
        Handler: r,
        Addr: "0.0.0.0:3000",
        // Good practice: enforce timeouts for servers you create!
        WriteTimeout: 15 * time.Second,
        ReadTimeout: 15 * time.Second,
    }
    log.Fatal(srv.ListenAndServe())
}

该程序在请求时返回日期和时间。现在,我们需要将这个应用程序 Docker 化。Docker 化意味着创建一个运行的容器。将 Dockerfile 添加到当前目录(在相同级别的kongExample中):

FROM golang
ADD kongExample /go/src/github.com/narenaryan/kongExample
RUN go get github.com/gorilla/mux
RUN go install github.com/narenaryan/kongExample
ENTRYPOINT /go/bin/kongExample

我们使用这个 Dockerfile 构建一个容器。它告诉我们从 DockerHub 拉取golang容器(自动安装 Go 编译器并设置GOPATH),并将这个kongExample项目复制到容器中。安装项目所需的必要软件包(在本例中是 Gorilla Mux),然后编译二进制文件并启动服务器。运行此命令创建容器:

docker build . -t gobuild

注意docker build命令后的.-t选项是为镜像打标签。它告诉 Docker 查看当前目录中的 Dockerfile,并根据给定的指令创建一个 Docker 镜像。我们需要实际运行这个镜像来创建一个容器:

docker run  -p 3000:3000 --name go-server -dit gobuild

它创建一个名为go-server的容器,并在端口3000上启动 Go Web 服务器。现在安装 Kong 容器,就像这样:

docker run -d --name kong \
 --link kong-database:kong-database \
 --link go-server:go-server \
 -e "KONG_DATABASE=postgres" \
 -e "KONG_PG_HOST=kong-database" \
 -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" \
 -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" \
 -e "KONG_PROXY_ERROR_LOG=/dev/stderr" \
 -e "KONG_ADMIN_ERROR_LOG=/dev/stderr" \
 -p 8000:8000 \
 -p 8443:8443 \
 -p 8001:8001 \
 -p 8444:8444 \
 kong:latest

这个命令与第一个命令类似,只是我们暴露了许多其他端口供 Kong 使用。我们还从 DockerHub 拉取kong:latest镜像。其他的是 Kong 所需的环境变量。我们将kong-database链接到名为kong-database的主机名,将go-server链接到go-server。主机名是 Docker 环境中的一个有用的实体,用于从一个容器识别和访问另一个容器。Docker 维护一个内部的域名空间DNS),用于跟踪 Docker 容器的 IP 地址到链接名称的映射。这将启动 Kong 容器并使用名为kong.conf.default的默认文件启动 Kong 服务。

现在,如果我们查看正在运行的容器,它列出了三个容器 ID:

docker ps -q
b6cd3ad39f75
53d800fe3b15
bbc9d2ba5679

Docker 容器只是用于运行应用程序的隔离环境。将微服务运行在不同的容器中是最佳实践,因为它们松散耦合,一个环境不会干扰另一个环境。

这意味着我们成功地为 Kong API 网关设置了基础设施。让我们看看如何在 Kong 中添加来自go-server的 API。为了检查 Kong 的状态,只需向此 URL 发出GET请求:

curl -X GET http://localhost:8001/status

它返回数据库的状态以及 Kong 的统计信息:

{
  "database": {
    "reachable": true
  },
  "server": {
    "connections_writing": 1,
    "total_requests": 13,
    "connections_handled": 14,
    "connections_accepted": 14,
    "connections_reading": 0,
    "connections_active": 2,
    "connections_waiting": 1
  }
}

向 Kong 添加 API

Kong 提供了一个直观的 REST API 来将自定义 API 添加到网关。为了添加上述的健康检查 API,我们需要向运行在端口8001上的 Kong 管理 API 发出POST请求。从现在开始,我们使用 Postman REST 客户端来显示所有 API 请求。这些 API 请求也作为 JSON 文件集合在本章的存储库中提供,供读者下载并分别导入到他们的 Postman 客户端中。有关导出和导入 Postman 集合的更多信息,请访问www.getpostman.com/docs/postman/collections/data_formats

从 Postman 向 Kong 管理 URLhttp://localhost:8001/apis发出POST请求,并在 JSON 主体中使用这些字段:

{
    "name": "myapi",
    "hosts": "server1",
    "upstream_url": "http://go-server:3000",
    "uris":["/api/v1"],
    "strip_uri": true,
    "preserve_host": false
}

它将我们的健康检查 API 添加到 Kong。Postman 屏幕看起来像以下截图所示,显示了所有更改。Postman 是一个很棒的工具,允许 Windows、macOS X 和 Linux 用户进行 HTTP API 请求的测试。您可以在这里下载它www.getpostman.com/

一旦我们这样做,我们就会得到包含 API 详细信息的响应 JSON。这个新的myapi将被赋予一个 ID:

{
  "created_at": 1509195475000,
  "strip_uri": true,
  "id": "795409ae-89ae-4810-8520-15418b96161f",
  "hosts": [
    "server1"
  ],
  "name": "myapi",
  "http_if_terminated": false,
  "preserve_host": false,
  "upstream_url": "http://go-server:3000",
  "uris": [
    "/api/v1"
  ],
  "upstream_connect_timeout": 60000,
  "upstream_send_timeout": 60000,
  "upstream_read_timeout": 60000,
  "retries": 5,
  "https_only": false
}

向此 URL 发出GET请求,http://localhost:8001/apis/myapi返回新添加的myapi的元数据。

关于我们发布到POST API 的字段,name是 API 的唯一名称。我们需要使用这个来在网关上标识 API。hosts是网关可以接受和转发请求的主机列表。上游 URL 是 Kong 转发请求的实际地址。由于我们在开始时链接了go-server容器,我们可以直接从 Kong 中引用http://go-server:3000uris字段用于指定相对于上游代理(Go 服务器)的路径,以获取资源。

例如,如果 URI 是/api/v1,而 Go 服务器的 API 是/healthcheck,则生成的网关 API 将是:

http://localhost:8000/api/v1/healthcheck

preserve_host是一个属性,它表示 Kong 是否应该将请求的主机字段更改为上游服务器的主机名。有关更多信息,请参阅getkong.org/docs/0.10.x/proxy/#the-preserve_host-property。其他设置,如upstream_connect_timeout,都很简单。

我们将我们的 API 添加到 Kong。让我们验证它是否将我们的健康检查请求转发到 Go 服务器。不要忘记为所有 API 请求添加一个名为Host值为server1的标头。这非常重要。API 调用如下图所示:

我们成功收到了响应。这是我们的main.go程序中的HealthcheckHandler返回的响应。

如果收到 404 错误,请尝试从头开始执行该过程。问题可能是容器没有运行,或者 Kong 容器无法访问上游 URL。另一个关键错误可能来自于未在请求标头中添加主机。这是在添加 API 时给出的主机。

这个健康检查 API 实际上是作为 Go 服务运行的。我们向 API 网关发出了 API 请求,它正在将其转发到 Go。这证明我们成功地将我们的 API 与 API 网关链接起来。

这是 API 的添加,只是冰山一角。其他事情呢?我们将逐个研究 API 网关的每一个功能,并尝试为我们的 API 实现它们。

在 Kong 中,除了基本路由之外,还提供了其他功能,如日志记录和速率限制。我们需要使用插件将它们启用到我们的 API 中。Kong 插件是一个内置组件,可以让我们轻松地插入任何功能。有许多类型的插件可用。其中,我们将在下一节讨论一些有趣的插件。让我们从日志记录插件开始。

Kong 中的 API 日志记录

Kong 中有许多插件可用于将请求记录到多个目标。目标是收集日志并将其持久化的系统。以下是可用于日志记录的重要插件:

  • 文件日志

  • Syslog

  • HTTP 日志

第一个是文件日志记录。如果我们需要 Kong 服务器以 JSON 格式将请求和响应日志存储到文件中,使用此插件。我们应该调用 Kong 的管理 REST API(http://localhost:8001/apis/myapi/plugins)来执行:

点击发送按钮,网关将返回响应,如下所示:

{
  "created_at": 1509202704000,
  "config": {
    "path": "/tmp/file.log",
    "reopen": false
  },
  "id": "57954bdd-ee11-4f00-a7aa-1a48f672d36d",
  "name": "file-log",
  "api_id": "795409ae-89ae-4810-8520-15418b96161f",
  "enabled": true
}

它基本上告诉 Kong,对于名为myapi的 API,将每个请求记录到名为/tmp/file.log的文件中。现在,向 API 网关发出健康检查的另一个请求(http://localhost:8000/api/v1/healthcheck)。此请求的日志将保存在给定的文件路径中。

我们如何查看这些日志?这些日志将保存在容器的/tmp文件夹中。打开一个新的终端标签,并使用以下命令进入 Kong 容器:

docker exec -i -t kong /bin/bash

这将带您进入容器的 bash shell。现在,检查日志文件:

cat /tmp/file.log

然后你会看到一个长长的 JSON 写入文件:

{"api":{"created_at":1509195475000,"strip_uri":true,"id":"795409ae-89ae-4810-8520-15418b96161f","hosts":["server1"],"name":"myapi","headers":{"host":["server1"]},"http_if_terminated":false,"https_only":false,"retries":5,"uris":["\/api\/v1"],"preserve_host":false,"upstream_connect_timeout":60000,"upstream_read_timeout":60000,"upstream_send_timeout":60000,"upstream_url":"http:\/\/go-server:3000"},"request":{"querystring":{},"size":"423","uri":"\/api\/v1\/healthcheck","request_uri":"http:\/\/server1:8000\/api\/v1\/healthcheck","method":"GET","headers":{"cache-control":"no-cache","cookie":"session.id=MTUwODY2NTE3MnxOd3dBTkZaUVNqVTBURmRTUlRSRVRsUlpRMHhGU2xkQlZVNDFVMFJNVmxjMlRFNDJUVXhDTWpaWE1rOUNORXBFVkRJMlExSXlSMEU9fNFxTxKgoEsN2IWvrF-sJgH4tSLxTw8o52lfgj2DwnHI","postman-token":"b70b1881-d7bd-4d8e-b893-494952e44033","user-agent":"PostmanRuntime\/3.0.11-hotfix.2","accept":"*\/*","connection":"keep-alive","accept-encoding":"gzip, deflate","host":"server1"}},"client_ip":"172.17.0.1","latencies":{"request":33,"kong":33,"proxy":0},"response":{"headers":{"content-type":"text\/plain; charset=utf-8","date":"Sat, 28 Oct 2017 15:02:05 GMT","via":"kong\/0.11.0","connection":"close","x-kong-proxy-latency":"33","x-kong-upstream-latency":"0","content-length":"58"},"status":200,"size":"271"},"tries":[{"balancer_latency":0,"port":3000,"ip":"172.17.0.3"}],"started_at":1509202924971}

这里记录的 IP 地址是 Docker 分配给容器的内部 IP。这个日志还包含有关 Kong 代理、Go 服务器等的延迟信息的详细信息。您可以在getkong.org/plugins/file-log/了解有关记录字段格式的更多信息。Kong 管理 API 用于启用其他日志记录类型与file-log类似。

我们从 Postman 向管理 API 发出的POST请求具有Content-Type: "application/json"的标头。

Kong 中的 API 身份验证

正如我们提到的,API 网关应该负责多个 API 在其后运行的身份验证。在 Kong 中有许多插件可用于提供即时身份验证。在下一章中,我们将详细了解身份验证概念。目前,使用这些插件,我们可以通过调用 Kong 管理 API 为特定 API 添加身份验证。

基于 API 密钥的身份验证如今变得很有名。Kong 提供以下身份验证模式:

  • 基于 API 密钥的身份验证

  • OAuth2 身份验证

  • JWT 身份验证

为了简单起见,让我们实现基于 API 密钥的身份验证。简而言之,基于密钥的身份验证允许外部客户端使用唯一令牌消耗 REST API。为此,在 Kong 中,首先启用密钥身份验证插件。要启用插件,请向http://localhost:8001/apis/myapi/plugins URL 发出POST请求,并在 JSON 主体中包含两个内容:

  1. namekey-auth

  2. config.hide_credentialstrue

第二个选项是剥离/隐藏凭据以传递给 Go API 服务器。看一下以下截图:

它返回 JSON 响应与创建的api_id

    {
      "created_at": 1509212748000,
      "config": {
        "key_in_body": false,
        "anonymous": "",
        "key_names": [
          "apikey"
        ],
        "hide_credentials": true
      },
      "id": "5c7d23dd-6dda-4802-ba9c-7aed712c2101",
      "enabled": true,
      "api_id": "795409ae-89ae-4810-8520-15418b96161f",
      "name": "key-auth"
    }

现在,如果我们尝试进行健康检查 API 请求,我们会收到 401 未经授权的错误:

{
  "message": "No API key found in request"
}

那么我们如何使用 API?我们需要创建一个消费者并为他授予权限访问 API。该权限是一个 API 密钥。让我们看看如何做到这一点。

要创建一个消费者,我们需要创建一个代表使用 API 的用户的消费者。向 Kong 管理 API 的消费者发出 API 调用。URL 端点将是http://localhost:8001/consumers。参考以下截图:

POST主体应该有username字段。响应将是创建的消费者的 JSON:

{
  "created_at": 1509213840000,
  "username": "johnd",
  "id": "df024acb-5cbd-4e4d-b3ed-751287eafd36"
}

现在,如果我们需要授予 API 权限给johnd,请向http://localhost:8001/consumers/johnd/key-auth admin URL 发出POST请求:

这将返回 API 密钥:

{
  "id": "664435b8-0f16-40c7-bc7f-32c69eb6c39c",
  "created_at": 1509214422000,
  "key": "89MH58EXzc4xHBO8WZB9axZ4uhZ1vW9d",
  "consumer_id": "df024acb-5cbd-4e4d-b3ed-751287eafd36"
}

我们可以在随后的 API 调用中使用此 API 密钥生成。现在,在标头中使用apikey重新进行健康检查,其值是前面响应中的密钥,它将成功返回日期和时间以及200 OK。参考以下截图:

Kong 中的 API 速率限制

我们可以限制特定消费者的 API 速率。例如,GitHub 限制客户端每小时进行 5000 次请求。之后,它会抛出 API 速率限制错误。我们可以使用 Kong 的rate-limiting插件为我们的 API 添加类似的速率限制约束。

我们可以使用此 API 进行启用:http://localhost:8001/apis/myapi/plugins,使用POST nameconfig.hourconsumer_id作为 body 参数:

这个 API 调用正在创建速率限制规则。consumer_id是用户名johnd的 ID。这个 JSON 响应有一个ID

{
  "created_at": 1509216578000,
  "config": {
    "hour": 5000,
    "redis_database": 0,
    "policy": "cluster",
    "hide_client_headers": false,
    "redis_timeout": 2000,
    "redis_port": 6379,
    "limit_by": "consumer",
    "fault_tolerant": true
  },
  "id": "b087a740-62a2-467a-96b5-9cee1871a368",
  "enabled": true,
  "name": "rate-limiting",
  "api_id": "795409ae-89ae-4810-8520-15418b96161f",
  "consumer_id": "df024acb-5cbd-4e4d-b3ed-751287eafd36"
}

现在,消费者(johnd)在 API 上有速率限制。他每小时只能允许对我们的健康检查 API 进行 5000 次请求。如果超过,他将收到以下错误:

{"message":"API rate limit exceeded"}

客户端应该如何知道剩余的请求次数作为速率控制的一部分?当客户端向 API 发出请求时,Kong 在响应中设置了一些标头。尝试进行 10 次健康检查请求并检查响应标头;您将在响应标头中找到以下内容,证明速率限制正在起作用:

X-RateLimit-Limit-hour →5000
X-RateLimit-Remaining-hour →4990

通过这种方式,Kong 提供了许多优秀的功能,可以将我们的 API 提升到更高的水平。这并不意味着 API 网关是绝对必要的,但它可以让您享受许多很酷的功能,而无需编写一行代码。它是一个开源软件,旨在避免在 Web 服务业务逻辑中重新编写通用定义的 API 网关功能。有关诸如负载平衡和请求转换之类的更多功能,请查看 Kong 的文档konghq.com/plugins/

Kong CLI

Kong 配备了一个命令行工具,用于更改 Kong 的行为。它有一组命令来启动、停止和修改 Kong。Kong 默认使用配置文件。如果我们需要修改它,我们需要重新启动 Kong 才能应用这些更改。因此,所有这些基本工作都已经编码到 Kong CLI 工具中。基本功能包括:

  • kong start:用于启动 Kong 服务器

  • kong reload:用于重新加载 Kong 服务器

  • kong stop:用于停止 Kong 服务器

  • kong check:用于验证给定的 Kong 配置文件

  • kong health:用于检查必要的服务,如数据库,是否正在运行

请查看 Kong CLI 的文档以获取更多命令getkong.org/docs/0.9.x/cli/

其他 API 网关

市场上有许多其他 API 网关提供商。正如我们之前提到的,所有网关都执行相同类型的功能。像亚马逊 API 网关这样的企业网关服务提供商与 EC2 和 Lambdas 兼容。Apigee 是另一个知名的 API 网关技术,是 Google Cloud 的一部分。云服务提供商的问题在于它们可能导致供应商锁定(无法轻松迁移到另一个平台)。因此,对于初创公司来说,开源替代方案总是不错的选择。

总结

在本章中,我们从 API 网关的基础知识开始。API 网关尝试做一些事情;它充当我们的 API 的代理。通过充当代理,它将请求转发到不同域的多个 API。在转发的过程中,网关可以阻止请求,对其进行速率限制,还可以转换请求/响应。

Kong 是一个适用于 Linux 平台的优秀的开源 API 网关。它具有许多功能,如身份验证、日志记录和速率限制。我们看到了如何在 Docker 容器中安装 Kong、Kong 数据库和我们的 REST 服务。我们使用 Docker 而不是主机机器,因为容器可以随意销毁和创建。这减少了损坏主机系统的机会。在了解安装后,我们了解到 Kong 有两种类型的 REST API。一种是管理 API,另一种是应用程序 API。管理 API 是我们用来将 API 添加到网关的 API。应用程序 API 是我们应用程序的 API。我们看到了如何将 API 添加到 Kong。然后,我们了解了 Kong 插件。Kong 插件是可以插入 Kong 的功能模块。日志记录插件可用。Kong 还提供身份验证插件和速率限制插件。

我们使用 Postman 客户端进行了请求,并看到了返回的示例 JSON。对于身份验证,我们使用了基于apikey的消费者。然后,我们使用 Kong 的key-auth插件模拟了 GitHub 每小时 5000 次请求。

最后,我们介绍了 Kong CLI,并检查了其他企业 API 网关,如 Apigee 和亚马逊 API 网关。在下一章中,我们将更详细地了解身份验证的工作原理,并在没有 API 网关的情况下尝试保护我们的 API。

第十二章:处理我们的 REST 服务的身份验证

在本章中,我们将探讨 Go 中的身份验证模式。这些模式是基于会话的身份验证、JSON Web Tokens(JWT)和 Open Authentication 2(OAuth2)。我们将尝试利用 Gorilla 包的 sessions 库来创建基本会话。然后,我们将尝试进入高级 REST API 身份验证策略,比如使用无状态 JWT。最后,我们将看到如何实现我们自己的 OAuth2,并了解有哪些包可用来提供给我们现成的 OAuth2 实现。在上一章中,API 网关为我们实现了身份验证(使用插件)。如果 API 网关不在我们的架构中,我们如何保护我们的 API?你将在本章中找到答案。

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

  • 认证工作原理

  • 介绍 Postman,一个用于测试 API 的可视化客户端

  • Go 中基于会话的身份验证

  • 引入 Redis 来存储用户会话

  • 介绍 JSON Web Tokens(JWT)

  • OAuth2 架构和基础知识

获取代码

您可以在github.com/narenaryan/gorestful/tree/master/chapter12获取本章的代码示例。由于示例程序不是包,读者需要按照 GOPATH 的方式创建项目文件。

认证工作原理

传统上,身份验证或简单身份验证以会话为中心的方式工作。请求服务器资源的客户端试图证明自己是任何给定资源的合适消费者。流程开始如下。客户端使用用户凭据向服务器发送身份验证请求。服务器接受这些凭据并将其与服务器上存储的凭据进行匹配。如果匹配成功,它会在响应中写入一个称为 cookie 的东西。这个 cookie 是一小段信息,传输到后续请求中。现代网站的用户界面(UI)是单页应用程序(SPA)。在那里,静态网页资产如 HTML、JS 是从 CDN 提供的,以渲染网页。从下一次开始,网页和应用服务器之间的通信只通过 REST API/Web 服务进行。

会话是记录用户在一定时间内的通信的一种好方法。会话通常存储在 cookie 中。以下图表可以总结认证(简称 auth)的整个过程:

现在看看实际的方法。客户端(例如浏览器)向服务器的登录 API 发送请求。服务器尝试使用数据库检查这些凭据,如果凭据存在,就会在响应中写入一个 cookie,表示这个用户已经通过身份验证。cookie 是服务器在以后的时间点要消耗的消息。当客户端接收到响应时,它会在本地存储该 cookie。如果是 Web 浏览器是客户端,它会将其存储在 cookie 存储中。从下一次开始,客户端可以自由地通过显示 cookie 作为通行证来请求服务器的资源。当客户端决定终止会话时,它调用服务器上的注销 API。服务器在响应中销毁会话。这个过程继续进行。服务器还可以在 cookie 上设置过期时间,以便在没有活动的情况下,认证窗口在一定时间内有效。这就是所有网站的工作原理。

现在,我们将尝试使用 Gorilla kit 的sessions包来实现这样的系统。我们已经在最初的章节中看到了 Gorilla kit 如何提供 HTTP 路由。这个 sessions 包就是其中之一。我们需要首先使用以下命令安装这个包:

go get github.com/gorilla/sessions

现在,我们可以使用以下语句创建一个新的会话:

var store = sessions.NewCookieStore([]byte("secret_key"))

secret_key应该是 Gorilla sessions 用来加密会话 cookie 的密钥。如果我们将会话添加为普通文本,任何人都可以读取它。因此,服务器需要将消息加密为一个随机字符串。为此,它要求提供一个密钥。这个密钥可以是任何随机生成的字符串。将密钥保存在代码中并不是一个好主意,所以我们尝试将其存储为环境变量,并在代码中动态读取它。我们将看到如何实现这样一个系统。

基于会话的身份验证

在 GOPATH 中创建一个名为simpleAuth的项目,并添加一个名为main.go的文件,其中包含我们程序的逻辑:

mkdir simpleAuth
touch main.py

在这个程序中,我们将看到如何使用 Gorilla sessions 包创建基于会话的身份验证。参考以下代码片段:

package main
import (
    "log"
    "net/http"
    "os"
    "time"
    "github.com/gorilla/mux"
    "github.com/gorilla/sessions"
)
var store =
sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
var users = map[string]string{"naren": "passme", "admin": "password"}
// 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)
    }
}
// LoginHandler validates the user credentials
func LoginHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session.id")
    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")
    if originalPassword, ok := users[username]; ok {
        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"))
}
// 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(""))
}
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())
}

这是一个 REST API,允许用户访问系统的健康状况(正常或异常)。为了进行身份验证,用户需要首先调用登录端点。该程序导入了两个名为 mux 和 sessions 的主要包,来自 Gorilla kit。Mux 用于将 HTTP 请求的 URL 端点链接到函数处理程序,sessions 用于在运行时创建新会话和验证现有会话。

在 Go 中,我们需要将会话存储在程序内存中。我们可以通过创建CookieStore来实现。这行明确告诉程序从名为SESSION_SECRET的环境变量中选择一个密钥来创建一个密钥。

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))

sessions有一个名为NewCookieStore的新函数,返回一个存储。我们需要使用这个存储来管理 cookie。我们可以通过这个语句获取一个 cookie 会话。如果会话不存在,它将返回一个空的会话:

session, _ := store.Get(r, "session.id")

session.id是我们为会话指定的自定义名称。使用这个名称,服务器将在客户端响应中发送一个 cookie。LoginHandler尝试解析客户端提供的多部分表单数据。这一步在程序中是必不可少的:

err := r.ParseForm()

这将使用解析后的键值对填充r.PostForm映射。该 API 需要用户名和密码进行身份验证。因此,我们对usernamepassword感兴趣。一旦LoginHandler接收到数据,它会尝试与名为users的映射中的详细信息进行检查。在实际场景中,我们使用数据库来验证这些详细信息。为了简单起见,我们硬编码了值并尝试进行身份验证。如果用户名不存在,则返回一个资源未找到的错误。如果用户名存在但密码不正确,则返回一个UnAuthorized错误消息。如果一切顺利,通过设置 cookie 值返回一个 200 响应,如下所示:

session.Values["authenticated"] = true
session.Save(r, w)

第一条语句将名为"authenticated"的 cookie 键设置为true。第二条语句实际上将会话保存在响应中。它以请求和响应写入器作为参数。如果我们删除这个语句,cookie 将不会产生任何效果。现在,来看看HealthCheckHandler,它最初与LoginHandler做同样的事情,如下所示:

session, _ := store.Get(r, "session.id")

然后,它检查给定的请求是否具有名为"authenticated"的 cookie 键。如果该键存在且为 true,则表示之前服务器经过身份验证的用户。但是,如果该键不存在或"authenticated"值为false,则会话无效,因此返回一个StatusForbidden错误。

客户端应该有一种方式来使登录会话失效。它可以通过调用服务器的注销 API 来实现。该 API 只是将"authenticated"值设置为false。这告诉服务器客户端未经身份验证:

session, _ := store.Get(r, "session.id")
session.Values["authenticated"] = false
session.Save(r, w)

通过这种方式,可以在任何编程语言中使用会话来实现简单的身份验证,包括 Go。

不要忘记添加这个语句,因为这是实际修改和保存 cookie 的语句:session.Save(r, w)

现在,让我们看看这个程序的执行。与其使用 CURL,我们可以使用一个名为 Postman 的绝妙工具。其主要好处是它可以在包括 Microsoft Window 在内的所有平台上运行;不再需要 CURL 了。

错误代码可能意味着不同的事情。例如,当用户尝试在没有身份验证的情况下访问资源时,会发出 Forbidden(403)错误,而当给定资源在服务器上不存在时,会发出 Resource Not Found(404)错误。

介绍 Postman,一个用于测试 REST API 的工具

Postman 是一个很棒的工具,允许 Windows、macOS X 和 Linux 用户进行 HTTP API 请求。您可以在www.getpostman.com/下载它。

安装 Postman 后,在“输入请求 URL”文本框中输入 URL。选择请求类型(GETPOST等)。对于每个请求,我们可以有许多设置,如头部、POST主体和其他详细信息。请查阅 Postman 文档以获取更多详细信息。Postman 的基本用法很简单。请看下面的截图:

构建器是我们可以添加/编辑请求的窗口。上面的截图显示了我们尝试发出请求的空构建器。运行上面的simpleAuth项目中的main.go,尝试调用健康检查 API,就像这样。单击发送按钮,您会看到响应被禁止:

这是因为我们还没有登录。Postman 在身份验证成功后会自动保存 cookie。现在,将方法类型从GET更改为 POST,URL 更改为http://localhost:8000/login,调用登录 API。我们还应该将 auth 详细信息作为多部分表单数据传递。它看起来像下面的截图:

现在,如果我们点击发送,它会进行身份验证并接收 cookie。它返回一条消息,说成功登录。我们还可以通过点击右侧“发送”和“保存”按钮下方的 Cookies 链接来检查 cookies。它会显示已保存的 cookie 列表,你会在那里找到一个名为session.id的 cookie,内容看起来像这样:

session.id=MTUwODYzNDcwN3xEdi1CQkFFQ180SUFBUkFCRUFBQUpmLUNBQUVHYzNSeWFXNW5EQThBRFdGMWRHaGxiblJwWTJGMFpXUUVZbTl2YkFJQ0FBRT189iF-ruBQmyTdtAOaMR-Rr9lNtsf1OJgirBDkcBpdEa0=; path=/; domain=localhost; Expires=Tue Nov 21 2017 01:11:47 GMT+0530 (IST);

尝试再次调用健康检查 API,它会返回系统日期和时间:

2017-10-22 06:54:36.464214959 +0530 IST

如果客户端向注销 API 发出GET请求:

http://localhost:8000/logout

会话将被使无效,并且访问资源将被禁止,直到进行另一个登录请求。

使用 Redis 持久化客户端会话

到目前为止我们创建的会话都存储在程序内存中。这意味着如果程序崩溃或重新启动,所有已登录的会话都将丢失。客户端需要重新进行身份验证以获取新的会话 cookie。有时这可能会很烦人。为了将会话保存在某个地方,我们选择了Redis。Redis 是一个键值存储,非常快,因为它存在于主内存中。

Redis 服务器存储我们提供的任何键值对。它提供基本的数据类型,如字符串、列表、哈希、集合等。有关更多详细信息,请访问redis.io/topics/data-types。我们可以在 Ubuntu 16.04 上使用以下命令安装 Redis:

sudo apt-get install redis-server

在 macOS X 上,我们可以这样说:

brew install redis

对于 Windows,也可以在 Redis 网站上找到二进制文件。安装 Redis 后,可以使用以下命令启动 Redis 服务器:

redis-server

它在默认端口6379上启动服务器。现在,我们可以使用 Redis CLI(命令行工具)在其中存储任何内容。打开一个新的终端,输入redis-cli。一旦启动了 shell,我们可以执行 Redis 命令将数据存储和检索到用户定义的类型变量中:

[7:30:30] naren:~ $ redis-cli
127.0.0.1:6379> SET Foo  1
OK
127.0.0.1:6379> GET Foo
"1"

我们可以使用SET Redis 命令存储键值。它将值存储为字符串。如果我们尝试执行GET,它会返回字符串。我们有责任将它们转换为数字。Redis 为我们提供了方便的函数来操作这些键。例如,我们可以像这样递增一个键:

127.0.0.1:6379> INCR Foo
(integer) 2

Redis 在内部将整数视为整数。如果尝试递增非数字字符串,Redis 会抛出错误:

127.0.0.1:6379> SET name "redis"
OK
127.0.0.1:6379> INCR name
(error) ERR value is not an integer or out of range

为什么我们在这里讨论 Redis?因为我们正在展示 Redis 的工作原理,并介绍 Redis 服务器上的一些基本命令。我们将把项目从simpleAuth修改为simpleAuthWithRedis

在该项目中,我们使用 Redis 而不是在程序内存中存储会话。即使程序崩溃,会话也不会丢失,因为它们保存在外部服务器中。谁为此编写了桥接逻辑?我们应该。幸运的是,我们有一个包来处理 Redis 和 Go 会话包之间的协调。

使用以下命令安装该包:

go get gopkg.in/boj/redistore.v1

并创建一个带有一些修改的新程序。在这里,我们使用redistore包,而不是使用会话库。redistore有一个名为NewRediStore的函数,它以 Redis 配置作为参数以及秘钥。所有其他函数保持不变。现在,在simpleAuthWithRedis目录中添加一个main.go文件:

package main
import (
    "log"
    "net/http"
    "os"
    "time"
    "github.com/gorilla/mux"
    redistore "gopkg.in/boj/redistore.v1"
)
var store, err = redistore.NewRediStore(10, "tcp", ":6379", "", []byte(os.Getenv("SESSION_SECRET")))
var users = map[string]string{"naren": "passme", "admin": "password"}
// 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)
    }
}
// LoginHandler validates the user credentials
func LoginHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session.id")
    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")
    if originalPassword, ok := users[username]; ok {
        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"))
}
// LogoutHandler removes the session
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session.id")
    session.Options.MaxAge = -1
    session.Save(r, w)
    w.Write([]byte(""))
}
func main() {
    defer store.Close()
    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())
}

一个有趣的变化是,我们删除了会话,而不是将其值设置为false

  session.Options.MaxAge = -1

这个改进的程序与之前的程序完全相同,只是会话保存在 Redis 中。打开 Redis CLI 并输入以下命令以获取所有可用的键:

[15:09:48] naren:~ $ redis-cli
127.0.0.1:6379> KEYS *
1) "session_VPJ54LWRE4DNTYCLEJWAUN5SDLVW6LN6MLB26W2OB4JDT26CR2GA"
127.0.0.1:6379>

那个冗长的"session_VPJ54LWRE4DNTYCLEJWAUN5SDLVW6LN6MLB26W2OB4JDT26CR2GA"是由redistore存储的键。如果我们删除该键,客户端将自动被禁止访问资源。现在停止运行程序并重新启动。您会看到会话没有丢失。通过这种方式,我们可以保存客户端会话。我们也可以在 SQLite 数据库上持久化会话。许多第三方包都是为了使这一点更容易而编写的。

Redis可以用作 Web 应用程序的缓存。它可以存储临时数据,如会话、频繁请求的用户内容等。通常与memcached进行比较。

JSON Web Tokens(JWT)和 OAuth2 简介

以前的身份验证方式是明文用户名/密码和基于会话的。它有一个通过将它们保存在程序内存或 Redis/SQLite3 中来管理会话的限制。现代 REST API 实现了基于令牌的身份验证。在这里,令牌可以是服务器生成的任何字符串,允许客户端通过显示令牌来访问资源。在这里,令牌是以这样一种方式计算的,即客户端和服务器只知道如何编码/解码令牌。JWT试图通过使我们能够创建可以传递的令牌来解决这个问题。

每当客户端将认证详细信息传递给服务器时,服务器会生成一个令牌并将其传递回客户端。客户端将其保存在某种存储中,例如数据库或本地存储(在浏览器的情况下)。客户端使用该令牌向服务器定义的任何 API 请求资源:

这些步骤可以更简要地总结如下:

  1. 客户端将用户名/密码在POST请求中传递给登录 API。

  2. 服务器验证详细信息,如果成功,生成 JWT 并返回,而不是创建 cookie。客户端有责任存储这个令牌。

  3. 现在,客户端有了 JWT。它需要在后续的 REST API 调用中添加这个令牌,比如GETPOSTPUTDELETE

  4. 服务器再次检查 JWT,如果成功解码,服务器通过查看作为令牌一部分提供的用户名发送数据。

JWT 确保数据来自正确的客户端。创建令牌的技术负责处理这个逻辑。JWT 利用基于秘钥的加密。

JSON web token 格式

我们在前面的部分讨论的一切都围绕着 JWT 令牌。我们将在这里看到它真正的样子以及它是如何生成的。JWT 是在执行几个步骤后生成的字符串。它们如下:

  1. 通过对标头 JSON 进行Base64Url编码来创建 JWT 标头。

  2. 通过对有效负载 JSON 进行Base64Url编码来创建 JWT 有效负载。

  3. 通过使用秘钥对附加的标头和有效负载进行加密来创建签名。

  4. JWT 字符串可以通过附加标头、有效负载和签名来获得。

标头是一个简单的 JSON 对象。在 Go 中,它看起来像以下代码片段:

`{
  "alg": "HS256",
  "typ": "JWT"
}`

"alg"是用于创建签名的算法(HMAC 与 SHA-256)的简写形式。消息类型是"JWT"。这对所有标头都是通用的。算法可能会根据系统而变化。

有效负载看起来像这样:

`{
  "sub": "1234567890",
  "username": "Indiana Jones",
  "admin": true
}`

有效负载对象中的键称为声明。声明是指定服务器某些特殊含义的键。有三种类型的声明:

  • 公共声明

  • 私有声明(更重要)

  • 保留声明

保留声明

保留声明是由 JWT 标准定义的声明。它们是:

  • iat: 发行时间

  • iss: 发行者名称

  • sub: 主题文本

  • aud: 受众名称

  • exp: 过期时间

例如,服务器在生成令牌时可以在有效负载中设置一个exp声明。然后客户端使用该令牌来访问 API 资源。服务器每次验证令牌时。当过期时间过去时,服务器将不再验证令牌。客户端需要通过重新登录生成新的令牌。

私有声明

私有声明是用来识别一个令牌与另一个令牌的名称。它可以用于授权。授权是识别哪个客户端发出了请求的过程。多租户是在系统中有多个客户端。服务器可以在令牌的有效负载上设置一个名为username的私有声明。下次,服务器可以读取这个有效负载并获取用户名,然后使用该用户名来授权和自定义 API 响应。

"username": "Indiana Jones"是前面示例有效负载上的私有声明。公共声明类似于私有声明,但它们应该在 IANA JSON Web Token 注册表中注册为标准。我们限制了这些的使用。

可以通过执行以下操作来创建签名(这不是代码,只是一个示例):

signature = HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

这只是对使用秘钥的 Base64URL 编码的标头和有效负载执行加密算法。这个秘钥可以是任何字符串。它与我们在以前的 cookie 会话中使用的秘钥完全相似。这个秘钥通常保存在环境变量中,并加载到程序中。

现在我们附加编码的标头、编码的有效负载和签名以获得我们的令牌字符串:

tokenString = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature

这就是 JWT 令牌是如何生成的。我们在 Go 中要手动做所有这些事情吗?不。在 Go 或任何其他编程语言中,有一些可用的包来包装令牌的手动创建和验证。Go 有一个名为jwt-go的精彩、流行的包。我们将在下一节中创建一个使用jwt-go来签署 JWT 并验证它们的项目。可以使用以下命令安装该包:

go get github.com/dgrijalva/jwt-go 

这是该项目的官方 GitHub 页面:github.com/dgrijalva/jwt-go。该包提供了一些函数,允许我们创建令牌。还有许多其他具有不同附加功能的包。您可以在jwt.io/#libraries-io上查看所有可用的包和支持的功能。

在 Go 中创建 JWT

jwt-go包有一个名为NewWithClaims的函数,它接受两个参数:

  1. 签名方法如 HMAC256、RSA 等

  2. 声明映射

例如,它看起来像以下代码片段:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "username": "admin",
    "iat":time.Now().Unix(),
})

jwt.SigningMethodHS256是包中可用的加密算法。第二个参数是一个带有声明的映射,例如私有(这里是用户名)和保留(发行于)。现在我们可以使用SignedString函数在令牌上生成一个tokenString

tokenString, err := token.SignedString("my_secret_key")

然后应将此tokenString传回客户端。

在 Go 中读取 JWT

jwt-go还为我们提供了解析给定 JWT 字符串的 API。Parse函数接受字符串和密钥函数作为参数。key函数是一个自定义函数,用于验证算法是否正确。假设这是由前面的编码生成的示例令牌字符串:

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 架构和基础知识

OAuth 2 是用于在不同系统之间创建身份验证模式的身份验证框架。在此,客户端不是向资源服务器发出请求,而是首先请求某个名为资源所有者的实体。资源所有者返回客户端的身份验证授权(如果凭据成功)。客户端现在将此身份验证授权发送到另一个名为身份验证服务器的实体。此身份验证服务器接受授权并返回访问令牌。此令牌是客户端访问 API 资源的关键。它需要使用此访问令牌向资源服务器发出 API 请求,并提供响应。在整个流程中,第二部分可以使用 JWT 完成。在此之前,让我们了解身份验证和授权之间的区别。

身份验证与授权

身份验证是识别客户端是否真实的过程。当服务器对客户端进行身份验证时,它会检查用户名/密码对并创建会话 cookie/JWT。

授权是在成功身份验证后区分一个客户端与另一个客户端的过程。在云服务中,客户端请求的资源需要通过检查资源是否属于该客户端而不是其他客户端来提供。不同客户端的权限和资源访问也不同。例如,管理员拥有资源的最高权限。普通用户的访问受到限制。

OAuth2 是用于对多个客户端进行身份验证的协议,而 JWT 是一种令牌格式。我们需要对 JWT 令牌进行编码/解码以实现 OAuth 2 的第二阶段(以下截图中的虚线)。

看一下以下图表:

在这个图表中,我们可以使用 JWT 实现虚线部分。身份验证发生在身份验证服务器级别,授权发生在资源服务器级别。

在下一节中,让我们编写一个程序,完成两件事:

  1. 对客户端进行身份验证并返回 JWT 字符串。

  2. 通过验证 JWT 授权客户端 API 请求。

创建一个名为jwtauth的目录并添加main.go

package main
import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
    jwt "github.com/dgrijalva/jwt-go"
    "github.com/dgrijalva/jwt-go/request"
    "github.com/gorilla/mux"
)
var secretKey = []byte(os.Getenv("SESSION_SECRET"))
var users = map[string]string{"naren": "passme", "admin": "password"}
// Response is a representation of JSON response for JWT
type Response struct {
    Token string `json:"token"`
    Status string `json:"status"`
}
// HealthcheckHandler returns the date and time
func HealthcheckHandler(w http.ResponseWriter, r *http.Request) {
    tokenString, err := request.HeaderExtractor{"access_token"}.ExtractToken(r)
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // Don't forget to validate the alg is what you expect:
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
        }
        // hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
        return secretKey, nil
    })
    if err != nil {
        w.WriteHeader(http.StatusForbidden)
        w.Write([]byte("Access Denied; Please check the access token"))
        return
    }
    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
        // If token is valid
        response := make(map[string]string)
        // response["user"] = claims["username"]
        response["time"] = time.Now().String()
        response["user"] = claims["username"].(string)
        responseJSON, _ := json.Marshal(response)
        w.Write(responseJSON)
    } else {
        w.WriteHeader(http.StatusForbidden)
        w.Write([]byte(err.Error()))
    }
}
// LoginHandler validates the user credentials
func getTokenHandler(w http.ResponseWriter, r *http.Request) {
    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")
    if originalPassword, ok := users[username]; ok {
        if password == originalPassword {
            // Create a claims map
            claims := jwt.MapClaims{
                "username": username,
                "ExpiresAt": 15000,
                "IssuedAt": time.Now().Unix(),
            }
            token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
            tokenString, err := token.SignedString(secretKey)
            if err != nil {
                w.WriteHeader(http.StatusBadGateway)
                w.Write([]byte(err.Error()))
            }
            response := Response{Token: tokenString, Status: "success"}
            responseJSON, _ := json.Marshal(response)
            w.WriteHeader(http.StatusOK)
            w.Header().Set("Content-Type", "application/json")
            w.Write(responseJSON)
        } else {
            http.Error(w, "Invalid Credentials", http.StatusUnauthorized)
            return
        }
    } else {
        http.Error(w, "User is not found", http.StatusNotFound)
        return
    }
}
func main() {
    r := mux.NewRouter()
    r.HandleFunc("/getToken", getTokenHandler)
    r.HandleFunc("/healthcheck", HealthcheckHandler)
    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())
}

这是一个非常冗长的程序。首先,我们导入jwt-go及其名为request的子包。我们为两个端点创建了一个 REST API;一个用于通过提供身份验证详细信息获取访问令牌,另一个用于获取授权用户的健康检查 API。

getTokenHandler处理函数中,我们正在将用户名和密码与我们自定义定义的用户映射进行比较。这也可以是一个数据库。如果身份验证成功,我们将生成一个 JWT 字符串并将其发送回客户端。

HealthcheckHandler中,我们从名为access_token的标头中获取访问令牌,并通过解析 JWT 字符串来验证它。谁编写验证逻辑?JWT 包本身。当创建新的 JWT 字符串时,它应该有一个名为ExpiresAt的声明。参考以下代码片段:

      claims := jwt.MapClaims{
        "username": username,
        "ExpiresAt": 15000,
        "IssuedAt": time.Now().Unix(),
      } 

程序的内部验证逻辑查看IssuedAtExpiresAt声明,并尝试计算并查看给定的令牌是否已过期。如果是新鲜的,那么意味着令牌已验证。

现在,当令牌有效时,我们可以在HealthCheckHandler中读取有效载荷,解析作为 HTTP 请求标头的access_token字符串。username是我们为授权插入的自定义私有声明。因此,我们知道实际发送此请求的是谁。对于每个请求,不需要传递会话。每个 API 调用都是独立的且基于令牌的。信息已经编码在令牌中。

token.Claims.(jwt.MapClaims)返回一个值为接口而不是字符串的映射。为了将值转换为字符串,我们应该这样做claims["username"].(string)

让我们通过 Postman 工具来看看这个程序是如何运行的:

这将返回一个包含 JWT 令牌的 JSON 字符串。将其复制到剪贴板。如果您尝试在不传递 JWT 令牌作为其中一个标头的情况下向健康检查 API 发出请求,您将收到此错误消息而不是 JSON:

Access Denied; Please check the access token

现在,将该令牌复制回来,并进行GET请求,添加一个access_token标头,其值为令牌字符串。在 Postman 中,标头部分可用于添加标头和键值对。请参阅以下屏幕截图:

它将正确返回时间作为 API 响应的一部分。我们还可以看到这是哪个用户的 JWT 令牌。这证实了我们的 REST API 的授权部分。我们可以将令牌验证逻辑放在每个 API 处理程序中,也可以将其作为中间件,并将其应用于所有处理程序。请参阅第三章,使用中间件和 RPC,并修改前面的程序以具有验证 JWT 令牌的中间件。

基于令牌的认证通常不提供注销 API 或用于删除会话基础认证中提供的令牌的 API。只要 JWT 没有过期,服务器就会向客户端提供授权资源。一旦过期,客户端需要刷新令牌,也就是说,向服务器请求一个新令牌。

摘要

在本章中,我们介绍了认证的过程。我们看到了认证通常是如何工作的。认证可以分为两种类型:基于会话的认证和基于令牌的认证。基于会话的认证也被称为简单认证,客户端成功登录时会创建一个会话。该会话被保存在客户端并在每个请求中提供。这里有两种可能的情况。在第一种情况下,会话将保存在服务器的程序内存中。当应用程序重新启动时,这种会话将被清除。第二种情况是将会话 cookie 保存在 Redis 中。Redis 是一个可以作为任何 Web 应用程序缓存的内存数据库。Redis 支持存储一些数据类型,如字符串、列表、哈希等。我们探讨了一个名为redistore的包,它用于替换用于持久化会话 cookie 的内置会话包。

接下来,我们了解了 JWT。JWT 是执行一些步骤的输出的令牌字符串。首先,创建一个标头、有效载荷和签名。通过使用base64URL编码和应用诸如 HMAC 之类的加密算法,可以获得签名。在基于令牌的认证中,客户端需要一个 JWT 令牌来访问服务器资源。因此,最初,它请求服务器提供访问令牌(JWT 令牌)。一旦客户端获得此令牌,下次它使用 JWT 令牌进行 API 调用,并将服务器返回响应。

我们引入了 OAuth 2.0,一个认证框架。在 OAuth 2 中,客户端首先向资源所有者请求授权。一旦获得授权,它就会向认证服务器请求访问令牌。认证服务器会提供访问令牌,客户端可以用它来请求 API。我们用 JWT 实现了 OAuth 2 的第二步。

我们使用一个叫做 Postman 的工具来测试所有的 API。Postman 是一个很棒的工具,可以帮助我们在任何机器上快速测试我们的 API。CURL 只能在 Linux 和 macOS X 上使用。Postman 对于 Windows 来说是一个明智的选择,因为它拥有 CURL 的所有功能。

通过学习如何创建 HTTP 路由、中间件和处理程序,我们从第一章走了很长的路。然后我们将我们的应用程序与数据库连接起来,以存储资源数据。在掌握了基础知识之后,我们探索了微服务和 RPC 等性能调优方面。最后,我们学会了如何部署我们的 Web 服务,并使用认证来保护它们。

posted @ 2024-05-04 22:36  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报