NodeJS10-REST-Web-API-设计-全-

NodeJS10 REST Web API 设计(全)

原文:zh.annas-archive.org/md5/557690262B22107951CBB4677B02B662

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

RESTful 服务已成为社交服务、新闻订阅和移动设备的事实标准数据提供者。它们向数百万用户提供大量数据。因此,它们需要满足高可用性要求,如可靠性和可扩展性。本书将向您展示如何利用 Node.js 平台实现强大和高性能的数据服务。通过本书,您将学会如何实现一个真实的 RESTful 服务,利用现代 NoSQL 数据库来提供 JSON 和二进制内容。

重要的主题,如正确的 URI 结构和安全功能也有详细的例子,向您展示开始实施强大的 RESTful API 所需的一切。

这本书是为谁准备的

这本书的目标读者是想通过学习如何基于 Node.js 平台开发可扩展的服务器端 RESTful 应用程序来丰富他们的开发技能的开发人员。您还需要了解 HTTP 通信概念,并且应该具备 JavaScript 语言的工作知识。请记住,这不是一本教你如何在 JavaScript 中编程的书。了解 REST 将是一个额外的优势,但绝对不是必需的。

为了充分利用这本书

  1. 告知读者在开始之前需要了解的事项,并明确您所假设的知识

  2. 他们需要获取的任何额外安装说明和信息

下载示例代码文件

您可以从您的帐户在www.packtpub.com下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/RESTful-Web-API-Design-with-Node.js-10-Third-Edition。如果代码有更新,将在现有的 GitHub 存储库上更新。

我们还有其他代码包,可以在我们丰富的书籍和视频目录中找到github.com/PacktPublishing/。去看看吧!

使用的约定

在这本书中,您会发现一些不同类型信息的文本样式。以下是一些这些样式的例子,以及它们的含义解释。

文本中的代码字,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:

“这告诉npm我们的包依赖于 URL 和 express 模块。”

代码块设置如下:

router.get('/v1/item/:itemId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.itemId);
  catalogV1.findItemById(request.params.itemId, response);
});

router.get('/v1/:categoryId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.categoryId);
  catalogV1.findItemsByCategory(request.params.categoryId, response);
});

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

router.get('/v1/:categoryId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.categoryId);
  catalogV1.findItemsByCategory(request.params.categoryId, response);
});

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

$ npm install -g express

粗体:表示一个新术语,一个重要的词或您在屏幕上看到的词。例如,菜单或对话框中的单词会在文本中以这种形式出现。这是一个例子:

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

提示和技巧会以这种形式出现。

第一章:REST - 你不知道的

在过去的几年里,我们已经开始认为,为内容提供数据源、移动设备服务提供数据源或云计算都是由现代技术驱动的,例如 RESTful Web 服务。每个人都在谈论他们的无状态模型如何使应用程序易于扩展,以及它如何强调数据提供和数据消费之间的明确解耦。如今,架构师已经开始引入微服务的概念,旨在通过将核心组件拆分为简单执行单个任务的小独立部分来减少系统的复杂性。因此,企业级软件即将成为这些微服务的组合。这使得维护变得容易,并且在需要引入新部分时允许更好的生命周期管理。毫不奇怪,大多数微服务都由 RESTful 框架提供服务。这个事实可能会让人觉得 REST 是在过去的十年中发明的,但事实远非如此。事实上,REST 自上个世纪的最后一个十年就已经存在了!

本章将带领您了解表述状态转移REST)的基础,并解释 REST 如何与 HTTP 协议配合。您将了解在将任何 HTTP 应用程序转换为 RESTful 服务启用应用程序时必须考虑的五个关键原则。您还将了解描述 RESTful 和经典简单对象访问协议SOAP)的 Web 服务之间的区别。最后,您将学习如何利用已有的基础设施来使自己受益。

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

  • REST 基础知识

  • REST 与 HTTP

  • 描述、发现和文档化 RESTful 服务与经典 SOAP 服务之间的基本差异

  • 利用现有基础设施

REST 基础知识

实际上,这实际上是在 1999 年发生的,当时有一份请求提交给了互联网工程任务组(IETF; www.ietf.org/),通过 RFC 2616:超文本传输协议-HTTP/1.1。其中一位作者 Roy Fielding 后来定义了围绕 HTTP 和 URI 标准构建的一组原则。这就诞生了我们今天所知的 REST。

这些定义是在 Fielding 的论文《网络软件架构的体系结构风格和设计》的第五章表述状态转移(REST)中给出的,该论文可以在www.ics.uci.edu/~fielding/pubs/dissertation/fielding_dissertation.pdf 找到。该论文仍然可以在www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm找到。

让我们看看围绕 HTTP 和 URI 标准的关键原则,坚持这些原则将使您的 HTTP 应用程序成为 RESTful 服务启用应用程序:

  1. 一切都是资源

  2. 每个资源都可以通过唯一标识符URI)进行识别

  3. 资源通过标准的 HTTP 方法进行操作

  4. 资源可以有多种表示形式

  5. 以无状态方式与资源进行通信

原则 1 - 一切都是资源

要理解这一原则,必须构想通过特定格式而不是包含一堆字节的物理文件来表示数据的想法。互联网上的每个数据都有一个描述它的格式,称为内容类型;例如,JPEG 图像、MPEG 视频、HTML、XML、文本文档和二进制数据都是具有以下内容类型的资源:image/jpeg、video/mpeg、text/html、text/xml 和 application/octet-stream。

原则 2 - 每个资源都可以通过唯一标识符进行识别

由于互联网包含了如此多不同的资源,它们都应该通过 URI 访问,并且应该被唯一标识。此外,尽管它们的使用者更可能是软件程序而不是普通人,但 URI 可以采用可读性强的格式。

可读性强的 URI 使数据自我描述,并且便于进一步开发。这有助于将程序中的逻辑错误风险降到最低。

以下是目录应用程序中表示不同资源的一些示例 URI:

这些可读性强的 URI 以直接的方式公开了不同类型的资源。在前面的示例 URI 中,很明显数据是目录中的物品,这些物品被分类为手表。第一个链接显示了该类别中的所有物品。第二个只显示了 2018 年收藏中的物品。接下来是一个指向物品图像的链接,然后是一个指向示例视频的链接。最后一个链接指向一个 ZIP 存档中包含上一收藏物品的资源。每个 URI 提供的媒体类型都很容易识别,假设物品的数据格式是 JSON 或 XML,因此我们可以很容易地将自描述 URL 的媒体类型映射到以下之一:

  • 描述物品的 JSON 或 XML 文档

  • 图像

  • 视频

  • 二进制存档文件

原则 3 - 通过标准 HTTP 方法操作资源

原生 HTTP 协议(RFC 2616)定义了八种动作,也称为 HTTP 动词:

  • 获取

  • 发布

  • 放置

  • 删除

  • 选项

  • 跟踪

  • 连接

前四个在资源上下文中感觉很自然,特别是在定义数据操作的动作时。让我们与相对 SQL 数据库进行类比,那里数据操作的本机语言是 CRUD(即 Create、Read、Update 和 Delete),源自不同类型的 SQL 语句,分别是 INSERT、SELECT、UPDATE 和 DELETE。同样地,如果你正确应用 REST 原则,HTTP 动词应该如下所示使用:

HTTP 动词 动作 HTTP 响应状态码
GET 检索现有资源。 如果资源存在则返回200 OK,如果资源不存在则返回404 Not Found,其他错误则返回500 Internal Server Error
PUT 更新资源。如果资源不存在,服务器可以决定使用提供的标识符创建它,或者返回适当的状态代码。 如果成功更新则返回200 OK,如果创建了新资源则返回201 Created,如果要更新的资源不存在则返回404 Not found,其他意外错误则返回500 Internal Server Error
POST 使用服务器端生成的标识符创建资源,或者使用客户端提供的现有标识符更新资源。如果此动词仅用于创建而不用于更新,则返回适当的状态代码。 如果创建了新资源则返回201 CREATED,如果资源已成功更新则返回200 OK,如果资源已存在且不允许更新则返回409 Conflict,如果要更新的资源不存在则返回404 Not Found,其他错误则返回500 Internal Server Error
DELETE 删除资源。 200 OK204 No Content如果资源已成功删除,404 Not Found如果要删除的资源不存在,500 Internal Server Error用于其他错误。

请注意,资源可以由POSTPUT HTTP 动词创建,具体取决于应用程序的策略。但是,如果必须在由客户端提供的特定 URI 下创建资源,则PUT是适当的操作:

PUT /categories/watches/model-abc HTTP/1.1
Content-Type: text/xml
Host: www.mycatalog.com

<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
    <Brand>...</Brand>
    </Price></Price>
</Item>

HTTP/1.1 201 Created 
Content-Type: text/xml 
Location: http://www.mycatalog.com/categories/watches/model-abc

但是,在您的应用程序中,您可能希望由后端 RESTful 服务决定在何处公开新创建的资源,并因此在适当但仍未知或不存在的位置下创建它。

例如,在我们的示例中,我们可能希望服务器定义新创建项目的标识符。在这种情况下,只需使用POST动词到 URL 而不提供标识符参数。然后由服务本身提供新的唯一且有效的标识符,并通过响应的Location标头公开此 URL:

POST /categories/watches HTTP/1.1
Content-Type: text/xml
Host: www.mycatalog.com

<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
    <Brand>...</Brand>
    </Price></Price>
</Item>

HTTP/1.1 201 Created 
Content-Type: text/xml 
Location: http://www.mycatalog.com/categories/watches/model-abc

原则 4-资源可以具有多个表示

资源的一个关键特征是它可以以与存储格式不同的格式表示。因此,可以请求或创建不同的表示。只要支持指定的格式,REST 启用的端点应该使用它。在前面的示例中,我们发布了手表项目的 XML 表示,但如果服务器支持 JSON 格式,以下请求也将有效:

POST /categories/watches HTTP/1.1
Content-Type: application/json
Host: www.mycatalog.com

{
  "watch": {
    "id": ""watch-abc"",
    "brand": "...",
    "price": {
      "-currency": "EUR",
      "#text": "100"
    }
  }
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://mycatalog.com/categories/watches/watch-abc   

原则 5-以无状态的方式与资源通信

通过 HTTP 请求进行的资源操作应始终被视为原子操作。应在 HTTP 请求中以隔离的方式执行所有对资源的修改。请求执行后,资源将处于最终状态;这隐含地意味着不支持部分资源更新。您应始终发送资源的完整状态。

回到我们的目录示例,更新给定项目的价格字段意味着使用完整文档(JSON 或 XML)进行 PUT 请求,其中包含整个数据,包括更新后的价格字段。仅发布更新后的价格不是无状态的,因为这意味着应用程序知道资源具有价格字段,也就是说,它知道它的状态。

RESTful 应用程序要求的另一个条件是,一旦服务部署在生产环境中,传入的请求很可能由负载均衡器提供服务,确保可伸缩性和高可用性。一旦通过负载均衡器公开,将应用程序状态保留在服务器端的想法就会受到威胁。这并不意味着您不允许保留应用程序的状态。这只是意味着您应该以 RESTful 的方式保留它。例如,在 URI 中保留部分状态,或使用 HTTP 标头提供附加的与状态相关的数据

您的 RESTful API 的无状态性使调用方与服务器端的更改隔离开来。因此,不希望调用方在连续请求中与同一服务器通信。这允许在服务器基础架构中轻松应用更改,例如添加或删除节点。

请记住,保持 RESTful API 的无状态性是您的责任,因为 API 的使用者期望它们是无状态的。

现在您知道 REST 大约有 18 年的历史,一个明智的问题是,“为什么它最近才变得如此受欢迎?”嗯,我们开发人员通常拒绝简单直接的方法,大多数时候更喜欢花更多时间将已经复杂的解决方案变得更加复杂和复杂。

以经典的 SOAP web 服务为例。它们的各种 WS-规范如此之多,有时定义得如此松散,以至于为了使来自不同供应商的不同解决方案能够互操作,引入了一个单独的规范 WS-Basic Profile。它定义了额外的互操作性规则,以确保 SOAP-based web 服务中的所有 WS-规范可以一起工作。

当涉及使用经典的 Web 服务通过 HTTP 传输二进制数据时,情况变得更加复杂,因为基于 SOAP 的 Web 服务提供了不同的传输二进制数据的方式。每种方式都在其他规范集中定义,比如SOAP with Attachment References (SwaRef)和Message Transmission Optimization Mechanism (MTOM)。所有这些复杂性主要是因为 Web 服务的最初想法是远程执行业务逻辑,而不是传输大量数据。

现实世界告诉我们,在数据传输方面,事情不应该那么复杂。这就是 REST 适应大局的地方——通过引入资源的概念和一种标准的方式来操作它们。

REST 的目标

现在我们已经介绍了主要的 REST 原则,是时候深入探讨遵循这些原则时可以实现什么了:

  • 表示和资源的分离

  • 可见性

  • 可靠性

  • 可扩展性

  • 性能

表示和资源的分离

资源只是一组信息,如原则 4 所定义,它可以有多种表示;但是它的状态是原子的。调用者需要在 HTTP 请求中使用Accept头指定所需的媒体类型,然后由服务器应用程序处理表示,返回资源的适当内容类型以及相关的 HTTP 状态码。

  • 在成功的情况下返回HTTP 200 OK

  • 如果给出了不支持的格式或任何其他无效的请求信息,则返回HTTP 400 Bad Request

  • 如果请求了不支持的媒体类型,则返回HTTP 406 Not Acceptable

  • 在请求处理过程中发生意外情况时,返回HTTP 500 Internal Server Error

假设在服务器端,我们有以 XML 格式存储的项目资源。我们可以有一个 API,允许消费者以各种格式请求项目资源,比如application/xmlapplication/jsonapplication/zipapplication/octet-stream等等。

由 API 自身来加载请求的资源,将其转换为请求的类型(例如 JSON 或 XML),并且可以使用 ZIP 进行压缩,或直接将其刷新到 HTTP 响应输出。

调用者将使用Accept HTTP 头来指定他们期望的响应的媒体类型。因此,如果我们想要以 XML 格式请求前一节中插入的项目数据,应执行以下请求:

GET /category/watches/watch-abc HTTP/1.1 
Host: my-computer-hostname 
Accept: text/xml 

HTTP/1.1 200 OK 
Content-Type: text/xml 
<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
    <Brand>...</Brand>
    </Price></Price>
</Item>

要请求以 JSON 格式获取相同的项目,Accept头需要设置为application/json

GET /categoery/watches/watch-abc HTTP/1.1 
Host: my-computer-hostname 
Accept: application/json 

HTTP/1.1 200 OK 
Content-Type: application/json 
{
  "watch": {
    "id": ""watch-abc"",
    "brand": "...",
    "price": {
      "-currency": "EUR",
      "#text": "100"
    }
  }
}

可见性

REST 的设计是可见和简单的。服务的可见性意味着它的每个方面都应该是自描述的,并且遵循自然的 HTTP 语言,符合原则 3、4 和 5。

在外部世界的上下文中,可见性意味着监控应用程序只对 REST 服务和调用者之间的 HTTP 通信感兴趣。由于请求和响应是无状态和原子的,没有必要流动应用程序的行为,也不需要了解是否出现了问题。

记住,缓存会降低你的 RESTful 应用的可见性,一般情况下应该避免使用,除非需要为大量调用者提供资源。在这种情况下,缓存可能是一个选择,但需要仔细评估提供过时数据的可能后果。

可靠性

在谈论可靠性之前,我们需要定义在 REST 上下文中哪些 HTTP 方法是安全的,哪些是幂等的。因此,让我们首先定义什么是安全和幂等方法:

  • 如果一个 HTTP 方法在请求时不修改或导致资源状态的任何副作用,则被认为是安全的。

  • 如果一个 HTTP 方法的响应保持不变,无论请求的次数如何,那么它被认为是幂等的,重复相同的幂等请求总是返回相同的结果。

以下表格列出了 RESTful 服务中哪些 HTTP 方法是安全的,哪些是幂等的:

HTTP 方法 安全 幂等
GET
POST
PUT
DELETE

消费者应该考虑操作的安全性和幂等性特性,以便可靠地提供服务。

可扩展性和性能

到目前为止,我们强调了对于 RESTful Web 应用程序来说,具有无状态行为的重要性。万维网WWW)是一个庞大的宇宙,包含大量数据和许多渴望获取这些数据的用户。WWW 的发展带来了这样的要求,即应用程序应该在负载增加时能够轻松扩展。具有状态的应用程序的扩展难以实现,特别是当期望零或接近零的运行停机时间时。

这就是为什么对于任何需要扩展的应用程序来说,保持无状态是至关重要的。在最理想的情况下,扩展应用程序可能需要您为负载均衡器添加另一台硬件,或者在云环境中引入另一个实例。不需要不同的节点之间进行同步,因为它们根本不需要关心状态。可扩展性的主要目标是在可接受的时间内为所有客户提供服务。其主要思想是保持应用程序运行,并防止由大量传入请求引起的拒绝服务DoS)。

可扩展性不应与应用程序的性能混淆。性能是通过处理单个请求所需的时间来衡量的,而不是应用程序可以处理的总请求数。Node.js 的异步非阻塞架构和事件驱动设计使其成为实现可扩展和性能良好的应用程序的合乎逻辑的选择。

使用 WADL

如果您熟悉 SOAP Web 服务,可能已经听说过Web 服务定义语言WSDL)。它是服务接口的 XML 描述,并定义了调用的端点 URL。对于 SOAP Web 服务来说,必须由这样的 WSDL 定义来描述。

与 SOAP Web 服务类似,RESTful 服务也可以使用一种称为 WADL 的描述语言。WADL代表Web 应用程序定义语言。与 SOAP Web 服务的 WSDL 不同,RESTful 服务的 WADL 描述是可选的,也就是说,使用服务与其描述无关。

以下是描述我们目录服务的GET操作的 WADL 文件的示例部分:

<?xml version="1.0" encoding="UTF-8"?>
<application xmlns="http://wadl.dev.java.net/2009/02" xmlns:service="http://localhost:8080/catalog/" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <grammer>
    <include href="items.xsd" />
    <include href="error.xsd" />
  </grammer>
  <resources base="http://localhost:8080/catalog/categories">
    <resource path="{category}">
      <method name="GET">
        <request>
          <param name="category" type="xsd:string" style="template" />
        </request>
        <response status="200">
          <representation mediaType="application/xml" element="service:item" />
          <representation mediaType="application/json" />
        </response>
        <response status="404">
          <representation mediaType="application/xml" element="service:item" />
        </response>
      </method>
    </resource>
  </resources>
</application>

WADL 文件的这一部分显示了如何描述公开资源的应用程序。简而言之,每个资源必须是应用程序的一部分。资源提供了一个base属性,描述了它位于何处,并在方法中描述了它支持的每个 HTTP 方法。此外,可以在资源和应用程序中使用可选的doc元素来提供有关服务及其操作的额外文档。

尽管 WADL 是可选的,但它显著减少了发现 RESTful 服务的工作量。

使用 Swagger 记录 RESTful API

在 Web 上公开的 API 应该有很好的文档,否则开发人员将难以在其应用程序中使用它们。虽然 WADL 定义可能被认为是文档的来源,但它们解决了不同的问题——服务的发现。它们为机器提供服务的元数据,而不是为人类。Swagger 项目(swagger.io/)解决了对 RESTful API 进行整洁文档的需求。它从几乎可读的 JSON 格式定义了 API 的元描述。以下是部分描述目录服务的示例swagger.json文件:

{
  "swagger": "2.0",
  "info": {
    "title": "Catalog API Documentation",
    "version": "v1"
  },
  "paths": {
    "/categories/{id}" : {
      "get": {
        "operationId": "getCategoryV1",
        "summary": "Get a specific category ",
        "produces": [
          "application/json"
        ],
        "responses": {
          "200": {
            "description": "200 OK",
            "examples": 
              {"application/json": {                
                "id": 1,
                "name": "Watches",
                "itemsCount": 550
                }                
              } 
          },
          "404": {"description" : "404 Not Found"},
          "500": {"description": "500 Internal Server Error"}
        }
      }
    }
  },
  "consumes": ["application/json"]
}

swagger.json文件非常简单:它定义了 API 的名称和版本,并简要描述了它公开的每个操作,与示例有效负载很好地结合在一起。但它的真正好处来自 Swagger 的另一个子项目,称为swagger-ui (swagger.io/swagger-ui/),它实际上将swagger.json中的数据很好地呈现为交互式网页,不仅提供文档,还允许与服务进行交互:

我们将查看并利用swagger-ui Node.js 模块来提供我们将在本书中稍后开发的 API,以及最新的文档。

利用现有基础设施

开发和分发 RESTful 应用程序最好的部分是所需的基础设施已经存在,可供您使用。由于 RESTful 应用程序大量使用现有的网络空间,因此在开发时您无需做任何其他事情,只需遵循 REST 原则。此外,针对任何平台都有大量可用的库,我是指任何平台。这简化了 RESTful 应用程序的开发,因此您只需选择您喜欢的平台并开始开发。

摘要

在本章中,您了解了 REST 的基础知识,看了五个关键原则,将 Web 应用程序转变为 REST 启用的应用程序。我们简要比较了 RESTful 服务和传统的 SOAP Web 服务,最后看了一下 RESTful 服务的文档以及我们如何简化我们开发的服务的发现。

现在您已经了解了基础知识,我们准备深入了解 Node.js 实现 RESTful 服务的方式。在下一章中,您将了解 Node.js 的基本知识以及必须使用和了解的相关工具,以构建真正完整的网络服务。

第二章:使用 Node.js 入门

在本章中,您将获得您的第一个真正的 Node.js 体验。我们将从安装 Node.js 开始,以及一些我们将在整本书中使用的模块。然后,我们将设置一个开发环境。在整本书中,将使用 Atom IDE。是的,GitHub 的在线编辑器终于登陆了桌面环境,并且可以在您喜欢的平台上使用!

接下来,我们将创建一个工作空间,并开始开发我们的第一个 Node.js 应用程序。这将是一个简单的服务器应用程序,用于处理传入的 HTTP 请求。我们将进一步演示如何将我们的 JavaScript 代码模块化和单元测试。最后,我们将在 Heroku 云应用平台上部署我们的第一个应用程序。

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

  • 安装 Node.js

  • 安装 Express 框架和其他模块

  • 设置开发环境

  • 处理 HTTP 请求

  • 模块化代码

  • 测试 Node.js

  • 部署应用程序

安装 Node.js

让我们从 Node.js 安装开始我们的 Node.js 之旅。Windows 和 macOS 都可以在nodejs.org/en/download/上找到安装程序。在撰写本文时,Node.js 10 刚刚发布为当前版本,并将于 2018 年 8 月成为下一个长期支持版本。Linux 用户可以从可用的 Linux 二进制文件构建 Node.js,或者利用他们的软件包管理器,因为 Node.js 在不同 Linux 发行版的大多数流行软件包存储库中都可用。例如,Ubuntu 和其他基于 Debian 的发行版应该首先指向最新的 Node.js 10 软件包,然后通过 shell 中的apt-get命令进行安装:

curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
sudo apt-get install nodejs

如果您决定使用 macOS 或 Windows 可用的安装程序,向导将指导您完成一个相当典型的安装过程,您需要接受 Node.js 许可协议,然后提供安装路径。

通过软件包管理器执行安装的 Linux 用户需要单独安装Node Package Manager (npm);我们将在下一节中进行安装。

安装成功后,您应该已经将 Node 设置在您的PATH环境变量中。

安装程序将为您预先选择 Node.js 运行时、npm、在线文档资源的快捷方式,以及将 Node.js 和 npm 添加到操作系统PATH环境变量的选项。

要验证您的安装是否成功,请从 shell 中执行以下操作:

node --version 

在撰写本文时,最新的 Node.js 版本是 10.0.0,因此预期的输出版本号将是这个版本号。Node.js 10 将是下一个长期支持的版本,因此在接下来的几年里它将保持最新。

Npm

Node.js 通过提供npm来方便地支持第三方开源开发的模块。它允许您作为开发人员轻松安装、管理甚至提供自己的模块包。npm 包存储库位于www.npmjs.org/,可以通过其命令行界面访问。

如果您没有使用安装程序,那么您需要单独安装npm。例如,Ubuntu 用户可以按照以下方式使用他们的软件包安装程序:

apt-get npm install

如果您升级了 Node.js 安装,并且之前安装了 npm 5.6,系统会要求您将其升级到版本 6。要执行此操作,只需执行:

sudo npm i -g npm

一旦安装了 npm,通过编辑~/.profile文件将其永久设置在用户配置文件的PATH环境变量中是很有用的,以便导出 npm 的路径如下:

export PATH=$PATH:/path/to/npm

成功安装 npm 后,使用 npm 的ls选项来显示当前安装的 Node.js 模块:

bojinov@developer-machine:~$ npm ls
/home/bojinov
├─┬ accepts@1.3.3
│ ├─┬ mime-types@2.1.13
│ │ └── mime-db@1.25.0
│ └── negotiator@0.6.1
├── array-flatten@1.1.1
├─┬ cache-control@1.0.3
│ ├─┬ cache-header@1.0.3
│ │ ├── lodash.isnumber@2.4.1 deduped
│ │ ├── lodash.isstring@2.4.1
│ │ └── regular@0.1.6 deduped
│ ├─┬ fast-url-parser@1.1.3
│ │ └── punycode@1.4.1
│ ├─┬ glob-slasher@1.0.1
│ │ ├── glob-slash@1.0.0
│ │ ├─┬ lodash.isobject@2.4.1
│ │ │ └── lodash._objecttypes@2.4.1
│ │ └─┬ toxic@1.0.0
│ │ └── lodash@2.4.2
│ ├─┬ globject@1.0.1
│ │ └── minimatch@2.0.10 extraneous
│ ├── lodash.isnumber@2.4.1
│ ├── on-headers@1.0.1
│ └── regular@0.1.6
├── content-disposition@0.5.1
├── content-type@1.0.2
├── cookie@0.3.1
├── cookie-signature@1.0.6

安装 Express 框架和其他模块

现在我们安装了npm,让我们利用它并安装一些在本书中将大量使用的模块。其中最重要的是 Express 框架(www.expressjs.com/)。它是一个灵活的 Web 应用程序框架,为 Node.js 提供了一个强大的 RESTful API,用于开发单页或多页 Web 应用程序。以下命令将从 npm 仓库下载 Express 模块,并使其可用于我们的本地 Node.js 安装:

npm install -g express 

在成功安装后,你将在npm ls的结果中找到express模块。在本章的后面,我们将学习如何为我们的 Node.js 模块编写单元测试。为此,我们将需要nodeunit模块:

npm install nodeunit -g 

-g选项会全局安装nodeunit。这意味着该模块将被存储在你的文件系统的一个中央位置;通常是/usr/lib/node_modules或者/usr/lib/node,但这可以配置到你的 Node.js 的全局配置。全局安装的模块对所有正在运行的 node 应用程序都是可用的。

本地安装的模块将存储在你项目的当前工作目录的node_modules子目录中,并且只对该单个项目可用。

现在,回到nodeunit模块——它提供了用于创建基本单元测试的基本断言测试函数,以及用于执行它们的工具。

在开始使用 Node.js 开发之前,我们还有一件事要了解:Node.js 应用程序的包描述文件。

所有的 Node.js 应用程序或模块都包含一个package.json描述文件。它提供关于模块、作者和它使用的依赖的元信息。让我们来看一下我们之前安装的express模块的package.json文件:

{
  "_from": "express",
  "_id": "express@4.16.1",
  "_inBundle": false,
  "_integrity": "sha512-STB7LZ4N0L+81FJHGla2oboUHTk4PaN1RsOkoRh9OSeEKylvF5hwKYVX1xCLFaCT7MD0BNG/gX2WFMLqY6EMBw==",
  "_location": "/express",
  "_phantomChildren": {},
  "_requested": {
    "type": "tag", "registry": true, "raw": "express", "name": "express",
    "escapedName": "express","rawSpec": "", "saveSpec": null, "fetchSpec": "latest"
  },
  "_requiredBy": [
    "#USER"
  ],
  "_resolved": "https://registry.npmjs.org/express/-/express-4.16.1.tgz",
  "_shasum": "6b33b560183c9b253b7b62144df33a4654ac9ed0",
  "_spec": "express",
  "_where": "/home/valio/Downloads",
  "author": {
    "name": "TJ Holowaychuk",
    "email": "tj@vision-media.ca"
  },
  "bugs": {
    "url": "https://github.com/expressjs/express/issues"
  },
  "bundleDependencies": false,
  "contributors": [
    {
      "name": "Aaron Heckmann",
      "email": "aaron.heckmann+github@gmail.com"
    },
   ...,
    {
      "name": "Young Jae Sim",
      "email": "hanul@hanul.me"
    }
  ],
  "dependencies": {
    "accepts": "~1.3.4",
    "array-flatten": "1.1.1",
    "body-parser": "1.18.2",
    ...,
    "type-is": "~1.6.15",
    "utils-merge": "1.0.1",
    "vary": "~1.1.2"
  },
  "deprecated": false,
  "description": "Fast, unopinionated, minimalist web framework",
  "devDependencies": {
    "after": "0.8.2",
    "connect-redis": "~2.4.1",
    ...,
    "should": "13.1.0",
    "supertest": "1.2.0",
    "vhost": "~3.0.2"
  },
  "engines": {
    "node": ">= 0.10.0"
  },
  "files": ["LICENSE", "History.md", "Readme.md", "index.js","lib/"],
  "homepage": "http://expressjs.com/",
  "keywords": [
    "express", "framework", "sinatra", "web", "rest", "restful", "router", "app", "api"
  ],
  "license": "MIT",
  "name": "express",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/expressjs/express.git"
  },
  "scripts": {
    "lint": "eslint .",
    "test": "mocha --require test/support/env --reporter spec --bail --check-leaks test/ test/acceptance/"
  },
  "version": "4.16.1"
}

包的名称和版本是每个模块的必需属性。所有其他的元信息,比如贡献者列表、仓库类型和位置、许可信息等等,都是可选的。其中一个最有趣的属性是dependencies属性。它告诉 npm 你的包依赖于哪些模块。让我们深入了解一下这是如何指定的。每个依赖都有一个名称和一个版本。

这告诉 npm 该包依赖于版本为 1.3.4 的accepts模块和版本为 1.8.2 的body-parse模块。所以,当 npm 安装该模块时,它将隐式地下载并安装依赖的最新次要版本,如果它们尚未可用。

依赖的版本是以以下格式指定的:major.minor.patch-version。你可以指定 npm 如果你想让 npm 使用确切指定的版本,或者你可以让 npm 始终下载最新可用的次要版本,通过以~开头的版本;参考accepts依赖。

有关版本控制的更多信息,请访问语义版本规范的网站www.semver.org/

依赖于自动管理的版本可能导致向后不兼容,请确保每次切换版本时都测试你的应用程序。

设置开发环境

JavaScript 开发人员很少在 IDE 中开发他们的项目;他们中的大多数人使用文本编辑器,并倾向于对与他们观点相矛盾的任何东西持偏见。GitHub 终于通过发布桌面环境的 Atom IDE 来平息了他们中的大多数人。这可能解决不了关于哪种环境最好的争论,但至少会带来一些和平,并让人们专注于他们的代码,而不是工具,这最终是个人偏好的问题。本书中的示例是在 Atom IDE 中开发的,但请随意使用任何可以创建文件的软件,包括 vi 或 vim 等命令行编辑器,如果这样做会让您感觉像 JS 超级英雄,尽管请记住超级英雄已经过时了!

您可以从ide.atom.io/下载 Atom IDE。

现在是启动我们的第一个 Node.js 应用程序的时候了,一个简单的 Web 服务器响应Hello from Node.js。从您的项目中选择一个目录,例如hello-node,然后从中打开一个 shell 终端并执行npm init

npm init

package name: (hello-node) 
version: (1.0.0) 
description: Simple hello world http handler
entry point: (index.js) app.js
test command: test
git repository: 
keywords: 
author: Valentin Bojinov
license: (ISC) 
About to write to /home/valio/nodejs8/hello-node/package.json:

{
 "name": "hello-node",
 "version": "1.0.0",
 "description": "Simple hello world http handler",
 "main": "app.js",
 "scripts": {
 "test": "test"
 },
 "author": "Valentin Bojinov",
 "license": "ISC"
}

Is this ok? (yes) yes

一个命令行交互向导将询问您的项目名称,版本,以及一些其他元数据,如 Git 存储库,您的姓名等等,并最终预览要生成的package.json文件;完成后,您的第一个 Node.js 项目准备开始。

现在是花一些时间研究本书中使用的代码约定的合适时机;当需要定义短回调函数时,将使用 ES6 内联匿名函数,而当期望可重用性和可测试性时,将使用常规的 javascript 函数。

启动 Atom IDE,选择文件|添加项目文件夹...,并导入您定义项目的目录。最后,在成功导入后,您将在项目中看到生成的package.json文件。右键单击目录,选择新建文件,并创建一个名为hello-node.js的文件:

var http = require('http');

http.createServer((request, response) => {
  response.writeHead(200, {
    'Content-Type' : 'text/plain'
  });
  response.end('Hello from Node.JS');
  console.log('Hello handler requested');
}).listen(8180, '127.0.0.1', () => {
  console.log('Started Node.js http server at http://127.0.0.1:8180');
});

hello-node.js文件使用 Node.js HTTP 模块开始监听端口8180上的传入请求。它将对每个请求回复静态的Hello from Node.JS,并在控制台中记录一个 hello 日志条目。在启动应用程序之前,我们必须安装创建 HTTP 服务器的http模块。让我们全局安装它以及--save选项,这将在项目的package.json文件中添加对它的依赖。然后我们可以启动应用程序:

npm install -g http --save
node hello-node.js  

从浏览器打开http://localhost:8180/将导致向服务器应用程序发送请求,这将在控制台中记录一个日志条目,并在浏览器中输出Hello from Node.JS

处理 HTTP 请求

目前,我们的服务器应用程序无论处理什么类型的 HTTP 请求都会以相同的方式行为。让我们以这样的方式扩展它,使其更像一个 HTTP 服务器,并根据其类型开始区分传入请求,通过为每种类型的请求实现处理程序函数。

让我们创建一个名为hello-node-http-server.js的新文件:

var http = require('http');
var port = 8180;

function handleGetRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Get action was requested');
}

function handlePostRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Post action was requested');
}

function handlePutRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Put action was requested');
}

function handleDeleteRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Delete action was requested');
}

function handleBadRequest(response) {
  console.log('Unsupported http mehtod');
  response.writeHead(400, {'Content-Type' : 'text/plain'  });
  response.end('Bad request');
}

function handleRequest(request, response) {
  switch (request.method) {
    case 'GET':
      handleGetRequest(response);
      break;
    case 'POST':
      handlePostRequest(response);
      break;
    case 'PUT':
      handlePutRequest(response);
      break;
    case 'DELETE':
      handleDeleteRequest(response);
      break;
    default:
      handleBadRequest(response);
      break;
  }
  console.log('Request processing completed');
}

http.createServer(handleRequest).listen(8180, '127.0.0.1', () => {
  console.log('Started Node.js http server at http://127.0.0.1:8180');
});

当我们运行此应用程序时,我们的 HTTP 服务器将识别GETPOSTPUTDELETE HTTP 方法,并将在不同的函数中处理它们。对于所有其他 HTTP 请求,它将以HTTP 400 BAD REQUEST状态代码优雅地响应。为了与 HTTP 应用程序交互,我们将使用 Postman,可从www.getpostman.com/下载。这是一个轻量级的应用程序,用于向端点发送 HTTP 请求,指定 HTTP 标头,并提供有效载荷。试试并执行我们之前实现的每个处理程序函数的测试请求:

模块化代码

到目前为止,我们开发了一个简单的 HTTP 服务器应用程序,用于监听和处理已知的请求类型;但是,它的结构并不是很好,因为处理请求的函数不可重用。Node.js 支持模块,支持代码隔离和可重用性。

用户定义的模块是一个由一个或多个相关函数组成的逻辑单元。该模块可以向其他组件导出一个或多个函数,同时将其他函数保持对自身可见。

我们将重新设计我们的 HTTP 服务器应用程序,使整个请求处理功能都包装在一个模块中。该模块将只导出一个通用处理程序函数,该函数将以请求对象作为参数,并根据其请求类型将处理委托给模块外部不可见的内部函数。

让我们首先在项目中创建一个新的模块目录。我们将通过将以下函数提取到新创建的目录中的http-module.js文件中来重构我们以前的源文件:

function handleGetRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Get action was requested');
}

function handlePostRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Post action was requested');
}

function handlePutRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Put action was requested');
}

function handleDeleteRequest(response) {
  response.writeHead(200, {'Content-Type' : 'text/plain'});
  response.end('Delete action was requested');
}

function handleBadRequest(response) {
  console.log('Unsupported http mehtod');
  response.writeHead(400, {'Content-Type' : 'text/plain'  });
  response.end('Bad request');
}

exports.handleRequest = function(request, response) {
  switch (request.method) {
    case 'GET':
      handleGetRequest(response);
      break;
    case 'POST':
      handlePostRequest(response);
      break;
    case 'PUT':
      handlePutRequest(response);
      break;
    case 'DELETE':
      handleDeleteRequest(response);
      break;
    default:
      handleBadRequest(response);
      break;
  }
  console.log('Request processing completed');
}

该文件创建了一个用户定义的模块,导出handleRequest函数,使其可用于其他组件。所有其他函数只能在模块内部访问。虽然示例只导出一个函数,但模块可以导出尽可能多的函数。

让我们在我们的第一个项目的main目录中的main.js文件中使用新的http-module。我们必须使用 Node.js 内置的http模块创建一个http服务器,其createServer将其handleRequest函数作为参数传递。它将作为服务器在每个请求上调用的回调函数:

var http = require('http');
var port = 8180;

var httpModule = require('./modules/http-module');

http.createServer(httpModule.handleRequest).listen(8180, '127.0.0.1', () => {
  console.log('Started Node.js http server at http://127.0.0.1:8180');
});

我们将创建服务器套接字的创建与处理与其绑定的传入请求的业务逻辑分开。使用require指令导入我们的模块。它使用相对路径。也可以使用 Postman 工具执行另一个测试请求来尝试这个版本。

幸运的是,在实现支持 RESTful 的应用程序时,我们不需要创建自己的 HTTP 处理程序。Express 框架将为我们完成这些工作。本章的示例旨在清楚地展示 Node.js 在处理 HTTP 请求和实现用户模块方面的可能性。我们将在第三章中详细了解 Express 框架,构建典型的 Web API

测试 Node.js

现在我们将通过为 HTTP 模块提供一个单元测试来扩展我们的项目,但在深入研究之前,让我们先看看 Node.js 如何支持单元测试。在本章的开头,我们安装了 Nodeunit 模块。好吧,现在是时候开始尝试一下了。

首先,让我们创建另一个简单的 Node.js 模块,我们将用它来实现我们的第一个单元测试。然后我们将转向更高级的主题,比如模拟 JavaScript 对象并使用它们来为我们的 HTTP 模块创建单元测试。

我选择开发一个简单的数学模块,导出用于添加和减去整数的函数,因为它足够简单,每个操作的结果都是严格定义的。

让我们从模块开始,在我们的module目录中创建以下math.js文件:

exports.add = function (x, y) { 
  return x + y; 
}; 
exports.subtract = function (x, y) { 
  return x - y; 
}; 

下一步是在项目的test子目录中创建一个test-math.js文件:

var math = require('../modules/math');
exports.addTest = function (test) {
  test.equal(math.add(1, 1), 2);
  test.done();
};
exports.subtractTest = function (test) {
  test.equals(math.subtract(4,2), 2);
  test.done();
};

最后,使用 shell 终端运行nodeunit test/test-math.js来运行测试模块。输出将显示所有测试方法的结果,指定它们是否成功通过:

nodeunit test/test-math.js    
    test-math.js
    test-math.js
 addTest
 subtractTest

OK: 2 assertions (5ms)

让我们修改addTest,使其出现故障,看看 Nodeunit 模块如何报告测试失败:

exports.test_add = function (test) { 
    test.equal(math.add(1, 1), 3); 
    test.done(); 
}; 

这次执行测试会导致失败,并显示一些断言失败的消息,最后会有一个汇总,显示执行的测试中有多少失败了:

nodeunit test-math.js
test-math.js
 addTest
at Object.equal (/usr/lib/node_modules/nodeunit/lib/types.js:83:39)
at Object.exports.addTest (../hello-node/test/test-math.js:
(..)

AssertionError: 2 == 3
 subtractTest
FAILURES: 1/2 assertions failed (12ms)

我们刚刚创建了 Nodeunit 的第一个单元测试。但是,它以一种相对隔离的方式测试数学函数。我想你会想知道我们如何使用 Nodeunit 来测试具有复杂参数的函数,比如绑定到上下文的 HTTP 请求和响应。这是可能的,使用所谓的模拟对象。它们是复杂基于上下文的参数或函数状态的预定义版本,在我们的单元测试中,我们想要使用这些对象来测试模块的行为以获取对象的确切状态。

要使用模拟对象,我们需要安装一个支持对象模拟的模块。那里有各种类型的测试工具和模块可用。然而,大多数都是设计用于测试 JavaScript 客户端功能。有一些模块,比如 JsMockito,这是 Java 著名 Mockito 框架的 JavaScript 版本,还有 node-inspector,这是一个提供 JavaScript 调试器的模块,它会在 Google Chrome 浏览器中隐式启动。

对于 Chrome 浏览器的本地支持是合理的,因为 Node.js 是构建在 Google V8 JavaScript 引擎之上的。由于我们正在开发服务器端应用程序,这些并不是最方便的工具,因为 JsMockito 不能作为 Node.js 模块进行插件化,并且在浏览器中使用调试器来调试后端应用程序对我来说并不合适。无论如何,如果你打算深入了解 Node.js,你应该一定要试试。

为了测试服务器端 JavaScript 模块,我们将使用 Sinon.JS 模块。像所有其他模块一样,它可以在 npm 仓库中找到,因此执行以下命令来安装它:

npm install -g sinon

Sinon.JS 是一个非常灵活的 JavaScript 测试库,提供了对 JavaScript 对象进行模拟、存根和监视的功能。它可以在任何 JavaScript 测试框架中使用,网址是 sinonjs.org。让我们看看我们需要什么来测试我们的 HTTP 模块。它导出一个单一方法 handleRequest,该方法以 HTTP 请求和响应对象作为参数。基于请求的方法,该模块调用其内部函数来处理不同的请求。每个请求处理程序向响应写入不同的输出。

要在诸如 Nodeunit 这样的隔离环境中测试此功能,我们需要模拟对象,然后将其作为参数传递。为了确保模块的行为符合预期,我们需要访问存储在这些对象中的数据。

使用模拟对象

使用模拟对象时需要执行的步骤如下:

  1. 使用 sinon 作为参数调用 require 函数,并从中导出一个 test 函数:
var sinon = require('sinon'); 
exports.testAPI(test){...} 
  1. 如下所示定义要模拟的方法的 API 描述:
var api = {'methodX' : function () {},  
  'methodY' : function() {},  
  'methodZ' : function() {}}; 
  1. 在导出的函数中使用 sinon 来根据 api 描述创建模拟对象:
var mock = sinon.mock(api);
  1. 设置模拟对象的期望。期望是在模拟对象上设置的,描述了模拟方法应该如何行为,它应该接受什么参数,以及它应该返回什么值。当模拟方法以与描述不同的状态调用时,期望在后来验证时将失败:
mock.expects('methodX').once().withArgs('xyz') 
.returns('abc'); 
api.methodX('xyz') 
  1. 上面的示例期望 methodX 被调用一次,并且带有 xyz 参数,它将强制该方法返回 abc。Sinon.JS 模块使我们能够实现这一点。

调用描述对象的方法,而不是模拟对象的方法。模拟对象用于设置模拟方法的期望,并在后来检查这些期望是否已经实现。

  1. 在测试环境中使用模拟对象,然后调用其 verify() 方法。该方法将检查被测试代码是否与模拟对象正确交互,即该方法被调用的次数以及是否使用了预期的参数进行调用。如果任何期望未能满足,那么将抛出错误,导致测试失败。

  2. 我们的测试模块的导出test函数有一个参数。该参数提供了可以用来检查测试条件的断言方法。在我们的示例中,我们模拟了该方法,以便在使用'xyz'参数调用时始终返回abc。因此,为了完成测试,可以进行以下断言,并且最后需要验证模拟对象:

mock.expects('methodX').once().withArgs('xyz') 
.returns('abc');           
test.equals(api.methodX('xyz'), 'abc'); 
mock.verify(); 
  1. 尝试修改传递给methodX的参数,使其不符合预期,您将看到这会破坏您的测试。

  2. 让我们将这些步骤付诸实践,并在test目录中创建以下test-http-module.js文件:

var sinon = require('sinon');
exports.handleGetRequestTest =  (test) => {
  var response = {'writeHead' : () => {}, 'end': () => {}};
  var responseMock = sinon.mock(response);
    responseMock.expects('end').once().withArgs('Get action was requested');
    responseMock.expects('writeHead').once().withArgs(200, {
      'Content-Type' : 'text/plain'});

  var request = {};
  var requestMock = sinon.mock(request);
  requestMock.method = 'GET';

  var http_module = require('../modules/http-module');
  http_module.handleRequest(requestMock, response);
  responseMock.verify();
  test.done();
};
  1. 使用 Nodeunit 的test-http-module.js开始测试以验证其是否成功通过。您的下一步将是扩展测试,以便覆盖我们的 HTTP 模块中所有 HTTP 方法的处理:
nodeunit test/test-http-module.js 

test-http-module.js
Request processing completed
 handleGetRequestTest

OK: 0 assertions (32ms)

部署应用程序

Node.js 具有事件驱动的、非阻塞的 I/O 模型,这使其非常适合在分布式环境中良好扩展的实时应用程序,例如公共或私有云平台。每个云平台都提供工具,允许其托管应用程序的无缝部署、分发和扩展。在本节中,我们将看一下两个公开可用的 Node.js 应用程序云提供商——Nodejitsu 和 Microsoft Azure。

但首先,让我们花一些时间来了解集群支持,因为这对于理解为什么 Node.js 非常适合云环境至关重要。Node.js 内置了集群支持。在您的应用程序中使用集群模块允许它们启动尽可能多的工作进程来处理它们将面临的负载。通常建议将工作进程的数量与您的环境的线程数或逻辑核心数匹配。

您的应用程序的核心是主进程。它负责保持活动工作进程的注册表和应用程序的负载,以及如何创建它。当需要时,它还会创建更多的工作进程,并在负载减少时减少它们。

云平台还应确保在部署应用程序的新版本时没有任何停机时间。在这种情况下,主进程需要被通知要分发新版本。它应该 fork 新的工作进程的新应用程序版本,并通知当前使用旧版本的工作进程关闭它们的监听器;因此,它停止接受连接并在完成后优雅地退出。因此,所有新的传入请求将由新启动的工作进程处理,并在过时的工作进程终止后,所有运行中的工作进程将运行最新版本。

Nodejitsu

让我们更仔细地看一些 Node.js平台即服务PaaS)提供。我们将首先看一下 Nodejitsu,可在www.nodejitsu.com上找到。

这允许在云上无缝部署 Node.js 应用程序,具有许多有用的功能,用于 Node.js 应用程序的开发、管理、部署和监控。要与 jitsu 交互,您需要安装其命令行界面,该界面可作为 Node.js 模块使用:

npm install -g jitsu 

安装 jitsu 并使用jitsu启动后,您将受到热烈欢迎,友好的控制台屏幕将向您介绍基本的 jitsu 命令,如下所示:

为了与 jitsu 交互,您需要注册。Jitsu 提供不同的定价计划,以及免费试用服务。

您可以从他们的网站或使用jitsu signup命令来执行此操作。然后,您可以开始使用命令行界面提供的工具。

微软 Azure

微软的云平台即服务 Azure 也提供 Node.js 应用程序的托管。他们选择了一个略有不同的方法,而不是提供一个命令行界面来与他们的存储库交互,他们利用了他们的 Git 集成;也就是说,您与 Azure 的交互方式与您与任何其他 Git 存储库的交互方式相同。如果您对 Git 不熟悉,我强烈建议您了解更多关于这个分布式源代码版本控制系统的知识。

如果您选择 Azure 作为您的平台,您会发现以下链接非常有用:azure.microsoft.com/en-us/develop/nodejs/

Heroku

Heroku 是一个公共云服务,允许您管理、部署和扩展 Node.js 应用程序。准备将您的 Node 应用程序适应 Heroku 环境并不需要太多的努力,只要安装其命令行界面,可以在devcenter.heroku.com/articles/heroku-cli或通过您的包管理器获得。

npm install -g heroku-cli

您只需在package.json文件中提供一个'start script'元素,使用git push master heroku将其推送到相关的 Git 存储库,然后登录并创建您的应用程序,使用heroku loginheroku create命令。

自测问题

为了对您新获得的知识更有信心,浏览下一组陈述,并说明它们是真还是假:

  1. Node 模块可以向外部组件导出多个函数

  2. Node 模块是可扩展的

  3. 模块总是需要明确声明它们对其他模块的依赖关系

  4. 在测试环境中使用模拟时,模拟的方法是在模拟对象上调用的

  5. 调试 Node.js 代码并不像其他非 JavaScript 代码那样直截了当

总结

在本章中,您获得了第一个 Node.js 体验,从一个简单的Hello world应用程序开始,然后转移到一个处理传入 HTTP 请求的更复杂的样本 HTTP 服务器应用程序。更加自信地使用 Node.js,您重构了应用程序以使用用户模块,然后使用模拟框架为您的模块创建了单元测试,以消除测试环境中复杂对象的依赖关系。

现在您已经了解了如何处理和测试传入的 HTTP 请求,在下一章中,我们的下一步将是定义典型 Web API 的外观以及如何进行测试。

第三章:构建典型的 Web API

我们的第一个草案 API 将是只读版本,并且不支持创建或更新目录中的项目,就像真实世界的应用程序一样。相反,我们将集中在 API 定义本身,并且稍后会担心数据存储。当然,对于向数百万用户公开的数据使用文件存储绝非选择,因此在我们查看现代 NoSQL 数据库解决方案之后,将为我们的应用程序提供数据库层。

我们还将涵盖内容协商的主题,这是一种允许消费者指定请求数据期望格式的机制。最后,我们将看看几种暴露服务不同版本的方式,以防它以不向后兼容的方式发展。

总之,在本章中,您将学习以下内容:

  • 如何指定 Web API

  • 如何实现路由

  • 如何查询您的 API

  • 内容协商

  • API 版本控制

在本章之后,您应该能够完全指定一个 RESTful API,并且几乎准备好开始实现真实的 Node.js RESTful 服务。

指定 API

项目通常开始的第一件事是定义 API 将公开的操作。根据 REST 原则,操作由 HTTP 方法和 URI 公开。每个操作执行的操作不应违反其 HTTP 方法的自然含义。以下表格详细说明了我们 API 的操作:

方法 URI 描述
GET /category 检索目录中所有可用类别。
GET /category/{category-id}/ 检索特定类别下所有可用项目。
GET /category/{category-id}/{item-id} 通过其 ID 在特定类别下检索项目。
POST /category 创建一个新类别;如果存在,它将对其进行更新。
POST /category/{category-id}/ 在指定类别中创建一个新项目。如果项目存在,它将对其进行更新。
PUT /category/{category-id} 更新类别。
PUT /category/{category-id}/{item-id} 更新指定类别中的项目。
DELETE /category/{category-id} 删除现有类别。
DELETE /category/{category-id}/{item-id} 删除指定类别中的项目。

第二步是为我们的目录应用程序的数据选择适当的格式。JSON 对象受 JavaScript 的本地支持。它们在应用程序演变期间易于扩展,并且几乎可以被任何可用的平台消耗。因此,JSON 格式似乎是我们的逻辑选择。这是本书中将使用的项目和类别对象的 JSON 表示:

{ 
    "itemId": "item-identifier-1", 
    "itemName": "Sports Watch", 
    "category": "Watches", 
    "categoryId": 1,
    "price": 150, 
    "currency": "EUR"
} 

{
    "categoryName" : "Watches",
    "categoryId" : "1",
    "itemsCount" : 100,
    "items" : [{
            "itemId" : "item-identifier-1",
            "itemName":"Sports Watch",
            "price": 150,
            "currency" : "EUR"    
     }]
}

到目前为止,我们的 API 已经定义了一组操作和要使用的数据格式。下一步是实现一个模块,该模块将导出为路由中的每个操作提供服务的函数。

首先,让我们创建一个新的 Node.js Express 项目。选择一个存储项目的目录,并从您的 shell 终端中执行express chapter3。如果您使用 Windows,您需要在生成项目之前安装express-generator模块。express-generator将在所选目录中创建初始的 express 项目布局。该布局为您提供了默认的项目结构,确保您的 Express 项目遵循标准的项目结构。这使得您的项目更容易导航。

下一步是将项目导入 Atom IDE。在项目选项卡中的任何位置右键单击,然后选择“添加项目文件夹”,然后选择 Express 为您生成的目录。

正如您所看到的,Express 已经为我们做了一些后台工作,并为我们创建了应用程序的起点:app.js。它还为我们创建了package.json文件。让我们从package.json开始查看这些文件中的每一个:

{
  "name": "chapter3",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "test"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
 "dependencies": {
    "body-parser": "~1.13.2",
    "cookie-parser": "~1.3.5",
    "debug": "~2.2.0",
    "express": "~4.16.1",
    "jade": "~1.11.0",
    "morgan": "~1.6.1",
    "serve-favicon": "~2.3.0"

  }
}

当我们创建一个空白的 Node.js Express 项目时,我们最初只依赖于 Express 框架,一些中间件模块,如morganbody-parsercookie-parser,以及 Jade 模板语言。Jade 是一种简单的模板语言,用于在模板中生成 HTML 代码。如果您对此感兴趣,可以在www.jade-lang.com了解更多信息。

撰写时,Express 框架的当前版本是 4.16.1;要更新它,请从chapter3目录执行npm install express@4.16.1 --save。此命令将更新应用程序对所需版本的依赖。--save选项将更新并保存项目的package.json文件中的新版本依赖。

当您引入新的模块依赖项时,您需要保持package.json文件的最新状态,以便维护应用程序所依赖的模块的准确状态。

我们稍后会讲解中间件模块是什么。

目前,我们将忽略publicview目录的内容,因为它与我们的 RESTful 服务无关。它们包含了自动生成的样式表和模板文件,如果我们决定在以后阶段开发基于 Web 的服务消费者,这些文件可能会有所帮助。

我们已经提到 Express 项目在app.js中为我们的 Web 应用程序创建了一个起点。让我们深入了解一下:

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', routes);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
  app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
      message: err.message,
      error: err
    });
  });
}

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});

module.exports = app;

显然,Express 生成器为我们做了很多工作,它实例化了 Express 框架,并为其分配了完整的开发环境。它做了以下工作:

  • 配置了在我们的应用程序中使用的中间件,body-parser、默认路由器,以及我们的开发环境的错误处理中间件

  • 注入了 morgan 中间件模块的日志记录器实例

  • 配置了 Jade 模板,因为它已被选为我们应用程序的默认模板

  • 配置了我们的 Express 应用程序将监听的默认 URI,//users,并为它们创建了虚拟的处理函数

您需要安装app.js中使用的所有模块,以便成功启动生成的应用程序。此外,在安装它们后,请确保使用--save选项更新您的package.json文件的依赖项。

Express 生成器还为应用程序创建了一个起始脚本。它位于项目的bin/www目录下,看起来像下面的片段:

#!/usr/bin/env node

/**
 * Module dependencies.
 */

var app = require('../app');
var debug = require('debug')('chapter3:server');
var http = require('http');

/**
 * Get port from environment and store in Express.
 */

var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

/**
 * Create HTTP server.
 */

var server = http.createServer(app);

/**
 * Listen on provided port, on all network interfaces.
 */

server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

/**
 * Normalize a port into a number, string, or false.
 */

function normalizePort(val) {
  var port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

/**
 * Event listener for HTTP server "error" event.
 */

function onError(error) {
  if (error.syscall !== 'listen') {
    throw error;
  }

  var bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port;

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges');
      process.exit(1);
      break;
    case 'EADDRINUSE':
      console.error(bind + ' is already in use');
      process.exit(1);
      break;
    default:
      throw error;
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */

function onListening() {
  var addr = server.address();
  var bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port;
  debug('Listening on ' + bind);
}

要启动应用程序,请执行node bin/www;这将执行上面的脚本,并启动 Node.js 应用程序。因此,在浏览器中请求http://localhost:3000将导致调用默认的GET处理程序,它会给出一个热烈的欢迎响应:

Express 应用程序的默认欢迎消息

生成器创建了一个虚拟的routes/users.js;它公开了一个与/users位置上的虚拟模块相关联的路由。请求它将导致调用用户路由的list函数,该函数输出一个静态响应:respond with a resource

我们的应用程序将不使用模板语言和样式表,因此让我们摆脱在应用程序配置中设置视图和视图引擎属性的行。此外,我们将实现自己的路由。因此,我们不需要为我们的应用程序绑定//users的 URI,也不需要user模块;相反,我们将利用catalog模块和一个路由:

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var catalog = require('./routes/catalog')
var app = express();

//uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', routes);
app.use('/catalog', catalog);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

//development error handler will print stacktrace
if (app.get('env') === 'development') {
  app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
      message: err.message,
      error: err
    });
  });
}

// production error handler no stacktraces leaked to user
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});

module.exports = app;

经过这次清理之后,我们的应用程序看起来更加整洁,我们准备继续前进。

在这之前,有一个术语需要进一步解释:中间件。它是由Express.js路由层调用的一组链式函数的子集,在调用用户定义的处理程序之前。中间件函数可以完全访问requestresponse对象,并且可以修改它们中的任何一个。中间件链总是按照定义的确切顺序调用,因此您需要确切知道特定中间件正在做什么。一旦中间件函数完成,它通过调用其下一个参数作为函数来调用链中的下一个函数。在完整的链执行完毕后,将调用用户定义的请求处理程序。

以下是适用于中间件链的基本规则:

  • 中间件函数具有以下签名:function (request, response, next)

  • 中间件函数按照它们被添加到应用程序链中的确切顺序执行。这意味着如果您希望在特定路由之前调用您的中间件函数,您需要在声明路由之前添加它。

  • 中间件函数使用它们的第三个参数next作为函数来指示它们已完成工作并退出。当调用链中最后一个函数的next()参数时,链式执行完成,并且requestresponse对象以中间件设置的状态到达定义的处理程序。

现在我们知道了中间件函数是什么,让我们澄清当前使用的中间件函数为我们的应用程序提供了什么。body-parser中间件是 Express 框架内置的解析器。它解析request体,并在中间件执行完成后填充request对象,即提供 JSON 负载处理。

现在是时候继续实现我们的用户模块,该模块将映射到我们的 URI。该模块将命名为modules/catalog.js

var fs = require('fs');

function readCatalogSync() {
   var file = './data/catalog.json';
   if (fs.existsSync(file)) {
     var content = fs.readFileSync(file);
     var catalog = JSON.parse(content);
     return catalog;
   }
   return undefined;
 }

exports.findItems = function(categoryId) {
  console.log('Returning all items for categoryId: ' + categoryId);
  var catalog = readCatalogSync();
  if (catalog) {
    var items = [];
    for (var index in catalog.catalog) {
        if (catalog.catalog[index].categoryId === categoryId) {
          var category = catalog.catalog[index];
          for (var itemIndex in category.items) {
            items.push(category.items[itemIndex]);
          }
        }
    }
    return items;
  }
  return undefined;
}

exports.findItem = function(categoryId, itemId) {
  console.log('Looking for item with id' + itemId);
  var catalog = readCatalogSync();
  if (catalog) {
    for (var index in catalog.catalog) {
        if (catalog.catalog[index].categoryId === categoryId) {
          var category = catalog.catalog[index];
          for (var itemIndex in category.items) {
            if (category.items[itemIndex].itemId === itemId) {
              return category.items[itemIndex];
            }
          }
        }
    }
  }
  return undefined;
}

exports.findCategoryies = function() {
  console.log('Returning all categories');
  var catalog = readCatalogSync();
  if (catalog) {
    var categories = [];
    for (var index in catalog.catalog) {
        var category = {};
        category["categoryId"] = catalog.catalog[index].categoryId;
        category["categoryName"] = catalog.catalog[index].categoryName;

        categories.push(category);
    }
    return categories;
  }
  return [];
}

目录模块围绕存储在data目录中的catalog.json文件构建。源文件的内容使用文件系统模块fsreadCatalogSync函数内同步读取。文件系统模块提供多个有用的文件系统操作,如创建、重命名或删除文件或目录的函数;截断;链接;chmod函数;以及用于读取和写入数据的同步和异步文件访问。在我们的示例应用程序中,我们旨在使用最直接的方法,因此我们实现了利用文件系统模块的readFileSync函数读取catalog.json文件的函数。它以同步调用的方式将文件内容作为字符串返回。模块的所有其他函数都被导出,并可用于根据不同的条件查询源文件的内容。

目录模块导出以下函数:

  • findCategories: 返回包含catalog.json文件中所有类别的 JSON 对象数组

  • findItems (categoryId): 返回表示给定类别中所有项目的 JSON 对象数组

  • findItem(categoryId, itemId): 返回表示给定类别中单个项目的 JSON 对象

现在我们有了三个完整的函数,让我们看看如何将它们绑定到我们的 Express 应用程序。

实现路由

在 Node.js 术语中,路由是 URI 和函数之间的绑定。Express 框架提供了对路由的内置支持。一个express对象实例包含了每个 HTTP 动词命名的函数:getpostputdelete。它们的语法如下:function(uri, handler);。它们用于将处理程序函数绑定到在 URI 上执行的特定 HTTP 动作。处理程序函数通常接受两个参数:requestresponse。让我们通过一个简单的Hello route应用程序来看一下:

var express = require('express'); 
var app = express(); 

app.get('/hello', function(request, response){ 
  response.send('Hello route'); 
}); 

app.listen(3000); 

在本地主机上运行此示例并访问http://localhost:3000/hello将调用您的处理程序函数,并且它将响应说Hello route,但路由可以提供更多。它允许您定义带参数的 URI;例如,让我们使用/hello/:name作为路由字符串。它告诉框架所使用的 URI 由两部分组成:一个静态部分(hello)和一个变量部分(name参数)。

此外,当路由字符串和处理函数与 Express 实例的get函数一起定义时,在处理程序函数的request参数中直接提供了参数集合。为了证明这一点,让我们稍微修改我们之前的例子:

var express = require('express'); 
var app = express(); 

app.get('/hello:name', function(request, response){ 
  response.send('Hello ' + request.params.name); 
}); 

app.listen(3000); 

如您在上述代码片段中所见,我们使用冒号(:)将 URI 的参数部分与静态部分分开。您可以在 Express 路由中有多个参数;例如,/category/:category-id/items/:item-id定义了一个用于显示属于类别的项目的路由,其中category-iditem-id是参数。

现在让我们试一下。请求http://localhost:3000/hello/friend将导致以下输出:

hello friend

这就是我们如何在 Express 中提供参数化的 URI。这是一个很好的功能,但通常还不够。在 Web 应用程序中,我们习惯使用GET参数提供额外的参数。

不幸的是,Express 框架对GET参数的支持并不是很好。因此,我们必须利用url模块。它内置在 Node.js 中,提供了一种使用 URL 解析的简单方法。让我们再次在应用程序中使用我们的hello结果和其他参数,但以一种方式扩展它,使其在请求/hello时输出hello all,在请求的 URI 为/hello?name=friend时输出hello friend

var express = require('express'); 
var url = require('url'); 
var app = express(); 

app.get('/hello', function(request, response){ 
   var getParams = url.parse(request.url, true).query; 

   if (Object.keys(getParams).length == 0) {       
      response.end('Hello all');    
   } else {
      response.end('Hello ' + getParams.name); 
   }    
}); 

app.listen(3000); 

这里有几件值得一提的事情。我们使用了url模块的parse函数。它以 URL 作为第一个参数,以布尔值作为可选的第二个参数,指定是否应解析查询字符串。url.parse函数返回一个关联对象。我们使用Object.keys将其与关联对象中的键转换为数组,以便我们可以检查其长度。这将帮助我们检查我们的 URI 是否已使用GET参数调用。除了以每个 HTTP 动词命名的路由函数之外,还有一个名为all的函数。当使用时,它将所有 HTTP 动作路由到指定的 URI。

现在我们知道了在 Node.js 和 Express 环境中路由和GET参数的工作原理,我们准备为catalog模块定义一个路由并将其绑定到我们的应用程序中。以下是在routes/catalog.js中定义的路由。

var express = require('express');
var catalog = require('../modules/catalog.js')

var router = express.Router();

router.get('/', function(request, response, next) {
  var categories = catalog.findCategoryies();
  response.json(categories);
});

router.get('/:categoryId', function(request, response, next) {
  var categories = catalog.findItems(request.params.categoryId);
  if (categories === undefined) {
    response.writeHead(404, {'Content-Type' : 'text/plain'});
    response.end('Not found');
  } else {
    response.json(categories);
  }
});

router.get('/:categoryId/:itemId', function(request, response, next) {
  var item = catalog.findItem(request.params.categoryId, request.params.itemId);
  if (item === undefined) {
    response.writeHead(404, {'Content-Type' : 'text/plain'});
    response.end('Not found');
  } else {
  response.json(item);
  }
});
module.exports = router;

首先,从 Express 模块创建了一个Router实例。下面是一个很好描述我们刚刚实现的路由的表格。这将在我们测试 API 时很有帮助:

HTTP 方法 路由 目录模块函数
GET /catalog findCategories()
GET /catalog/:categoryId findItems(categoryId)
GET /catalog/:categoryId/:itemId findItem(categoryId, itemId)

使用测试数据查询 API

我们需要一些测试数据来测试我们的服务,所以让我们使用项目的data目录中的catalog.json文件。这些数据将允许我们测试我们的三个函数,但为了做到这一点,我们需要一个可以针对端点发送 REST 请求的客户端。如果您还没有为测试应用程序创建 Postman 项目,现在是创建它的合适时机。

请求/catalog应该返回test文件中的所有类别:

因此,请求/catalog/1应该返回属于Watches类别的所有项目的列表:

最后,请求http://localhost:3000/catalog/1/item-identifier-1将仅显示由item-identifier-1标识的项目,请求不存在的项目将导致状态码404的响应:

内容协商

到目前为止,目录服务仅支持 JSON 格式,因此仅使用媒体类型application/json。假设我们的服务必须以不同的格式提供数据,例如 JSON 和 XML。然后,消费者需要明确定义他们需要的数据格式。在 REST 中进行内容协商的最佳方式长期以来一直是一个备受争议的话题。

在他关于正确实施内容协商的著名讨论中,罗伊·菲尔丁陈述了以下观点:

所有重要资源都必须有 URI。

然而,这留下了如何以不同的数据格式公开相同资源的空白,因此罗伊继续如下:

代理驱动的谈判效果更好,但我和 HTTP 工作组主席之间存在巨大分歧,我的 HTTP/1.1 的原始代理驱动设计实际上被委员会埋没了。为了正确进行谈判,客户端需要了解所有的替代方案以及应该用作书签的内容。

虽然可以选择继续使用 URI 驱动的谈判,通过提供自定义的GET参数来提供所需的格式,但 REST 社区选择坚持罗伊的代理驱动谈判建议。现在距离这场争论开始已经将近十年了,已经证明他们做出了正确的决定。代理驱动的谈判使用Accept HTTP 头。

Accept HTTP 头指定了消费者愿意处理的资源的媒体类型。除了Accept头之外,消费者还可以使用Accept-LanguageAccept-Encoding头来指定结果应该提供的语言和编码。如果服务器未能以预期的格式提供结果,它可以返回默认值,或者使用HTTP 406 Not acceptable,以避免在客户端引起数据混淆错误。

Node.js 的 HTTP response对象包含一个名为format的方法,该方法基于request对象中设置的Accept HTTP 头执行内容协商。它使用内置的request.accepts()来为请求选择适当的处理程序。如果找不到,服务器将调用默认处理程序,该处理程序将返回HTTP 406 Not acceptable。让我们创建一个演示,演示如何在我们的路由中使用format方法。为此,让我们假设我们在我们的catalog模块中实现了一个名为list_groups_in_xml的函数,该函数以 XML 格式提供组数据:

app.get('/catalog', function(request, response) { 
    response.format( { 
      'text/xml' : function() { 
         response.send(catalog.findCategoiesXml()); 
      }, 
      'application/json' : function() { 
         response.json(catalog.findCategoriesJson()); 
      }, 
      'default' : function() {. 
         response.status(406).send('Not Acceptable'); 
      }    
    }); 
}); 

这是您可以以清晰简单的方式实施内容协商的方法。

API 版本控制

不可避免的事实是,所有应用程序 API 都在不断发展。然而,具有未知数量的消费者的公共 API 的演变,例如 RESTful 服务,是一个敏感的话题。由于消费者可能无法适当处理修改后的数据,并且没有办法通知所有消费者,我们需要尽可能保持 API 的向后兼容性。其中一种方法是为我们应用程序的不同版本使用不同的 URI。目前,我们的目录 API 在/catalog上可用。

当时机成熟,例如,版本 2 时,我们可能需要保留以前的版本在另一个 URI 上以实现向后兼容。最佳做法是在 URI 中编码版本号,例如/v1/catalog,并将/catalog映射到最新版本。因此,请求/catalog将导致重定向到/v2/catalog,并将使用 HTTP 3xx状态代码指示重定向到最新版本。

另一个版本控制的选项是保持 API 的 URI 稳定,并依赖自定义的 HTTP 标头来指定版本。但这并不是一个非常稳定的方法,因为与其在请求中修改发送的标头,不如在应用程序中修改请求的 URL 更自然。

自测问题

为了获得额外的信心,请浏览这组陈述,并说明它们是真还是假:

  1. REST 启用的端点必须支持与 REST 原则相关的所有 HTTP 方法。

  2. 当内容协商失败时,由于接受标头的值作为不支持的媒体类型,301 是适当的状态代码。

  3. 在使用参数化路由时,开发人员可以指定参数的类型,例如,它是数字类型还是文字类型。

总结

在本章中,我们深入了一些更复杂的主题。让我们总结一下我们所涵盖的内容。我们首先指定了我们的 Web API 的操作,并定义了操作是 URI 和 HTTP 动作的组合。接下来,我们实现了路由并将它们绑定到一个操作。然后,我们使用 Postman REST 客户端请求每个操作以请求我们路由的 URI。在内容协商部分,我们处理了Accept HTTP 标头,以便按照消费者请求的格式提供结果。最后,我们涵盖了 API 版本的主题,这使我们能够开发向后兼容的 API。

在本章中,我们对我们的数据使用了老式的文件系统存储。这对于 Web 应用程序来说并不合适。因此,我们将在下一章中研究现代、可扩展和可靠的 NoSQL 存储。

第四章:使用 NoSQL 数据库

在上一章中,我们实现了一个暴露只读服务的示例应用程序,提供了目录数据。为了简单起见,我们通过使用文件存储在这个实现中引入了性能瓶颈。这种存储不适合 Web 应用程序。它依赖于 33 个物理文件,阻止我们的应用程序为重负载提供服务,因为文件存储由于磁盘 I/O 操作而缺乏多租户支持。换句话说,我们绝对需要寻找更好的存储解决方案,当需要时可以轻松扩展,以满足我们的 REST 应用程序的需求。NoSQL 数据库现在在 Web 和云环境中被广泛使用,确保零停机和高可用性。它们比传统的事务 SQL 数据库具有以下优势:

  • 它们支持模式版本;也就是说,它们可以使用对象表示而不是根据一个或多个表的定义填充对象状态。

  • 它们是可扩展的,因为它们存储了一个实际的对象。数据演变得到了隐式支持,所以您只需要调用存储修改后对象的操作。

  • 它们被设计为高度分布式和可扩展的。

几乎所有现代 NoSQL 解决方案都支持集群,并且可以随着应用程序的负载进一步扩展。此外,它们中的大多数都具有基于 HTTP 的 REST 接口,可以在高可用性场景中通过负载均衡器轻松使用。传统的数据库驱动程序通常不适用于传统的客户端语言,如 JavaScript,因为它们需要本机库或驱动程序。然而,NoSQL 的理念起源于使用文档数据存储。因此,它们中的大多数都支持 JavaScript 的本机 JSON 格式。最后但并非最不重要的是,大多数 NoSQL 解决方案都是开源的,并且可以免费使用,具有开源项目提供的所有好处:社区、示例和自由!

在本章中,我们将介绍 MongoDB NoSQL 数据库和与之交互的 Mongoose 模块。我们将看到如何为数据库模型设计和实现自动化测试。最后,在本章末尾,我们将消除文件存储的瓶颈,并将我们的应用程序移至几乎可以投入生产的状态。

MongoDB - 一个文档存储数据库

MongoDB 是一个具有内置对 JSON 格式支持的开源文档数据库。它提供了对文档中任何可用属性的完整索引支持。由于其可扩展性特性,它非常适合高可用性场景。MongoDB,可在mms.mongodb.com找到,具有其管理服务MongoDB 管理服务(MMS)。它们利用和自动化大部分需要执行的开发操作,以保持您的云数据库良好运行,负责升级、进一步扩展、备份、恢复、性能和安全警报。

让我们继续安装 MongoDB。Windows、Linux、macOS 和 Solaris 的安装程序可在www.mongodb.org/downloads找到。Linux 用户可以在所有流行的发行版存储库中找到 MongoDB,而 Windows 用户可以使用用户友好的向导来指导您完成安装步骤,对于典型的安装,您只需要接受许可协议并提供安装路径。

安装成功后,执行以下命令启动 MongoDB。如果要指定数据的自定义位置,必须使用--dbpath参数。可选地,您可以通过--rest参数启动 MongoDB HTTP 控制台:

mongod --dbpath ./data --rest

与 MongoDB 通信的默认端口是27017,其 HTTP 控制台隐式配置为使用比数据端口高 1,000 的端口。因此,控制台的默认端口将是28017。HTTP 控制台提供有关数据库的有用信息,例如日志、健康状态、可用数据库等。我强烈建议您花一些时间了解它。控制台还可以用作数据库的 RESTful 健康检查服务,因为它提供有关运行中的数据库服务和上次发生的错误的 JSON 编码信息:

GET /replSetGetStatus?text=1 HTTP/1.1
Host: localhost:28017
Connection: Keep-Alive
User-Agent: RestClient-Tool

HTTP/1.0 200 OK
Content-Length: 56
Connection: close
Content-Type: text/plain;charset=utf-8

{
"ok": 0,
"errmsg": "not running with --replSet"
}

此 REST 接口可用于脚本或应用程序,以自动更改通知,提供数据库引擎的当前状态等。

控制台的日志部分显示您的服务器是否成功运行(如果是)。现在我们准备进一步了解如何将 Node.js 连接到 MongoDB。

使用 Mongoose 进行数据库建模

Mongoose是一个将 Node.js 连接到 MongoDB 的模块,采用对象文档映射器ODM)风格。它为存储在数据库中的文档提供了创建、读取、更新和删除(也称为CRUD)功能。Mongoose 使用模式定义文档的结构。模式是 Mongoose 中数据定义的最小单元。模型是根据模式定义构建的。它是一个类似构造函数的函数,可用于创建或查询文档。文档是模型的实例,并表示与存储在 MongoDB 中的文档一一映射。模式-模型-文档层次结构提供了一种自描述的定义对象的方式,并允许轻松进行数据验证。

让我们从使用npm安装 Mongoose 开始:

npm install mongoose

现在我们已经安装了 Mongoose 模块,我们的第一步将是定义一个将在目录中表示项目的模式:

var mongoose = require('mongoose'); 
var Schema = mongoose.Schema;
var itemSchema = new Schema ({
    "itemId" : {type: String, index: {unique: true}},
    "itemName": String,
    "price": Number,
    "currency" : String,
    "categories": [String]
}); 

上面的代码片段创建了一个项目的模式定义。定义模式很简单,与 JSON 模式定义非常相似;您必须描述并附加其类型,并可选择为每个键提供附加属性。在目录应用程序的情况下,我们需要使用itemId作为唯一索引,以避免具有相同 ID 的两个不同项目。因此,除了将其类型定义为String之外,我们还使用index属性来描述itemId字段的值必须对于每个单独的项目是唯一的。

Mongoose 引入了术语模型。模型是根据模式定义编译出的类似构造函数的函数。模型的实例表示可以保存到数据库中或从数据库中读取的文档。通过调用mongoose实例的model函数并传递模型应该使用的模式来创建模型实例:

var CatalogItem = mongoose.model('Item', itemSchema);

模型还公开了用于查询和数据操作的函数。假设我们已经初始化了一个模式并创建了一个模型,将新项目存储到 MongoDB 就像创建一个新的model实例并调用其save函数一样简单:

var mongoose = require('mongoose');

mongoose.connect('mongodb://localhost/catalog');
var db = mongoose.connection;

db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
  var watch = new CatalogItem({
    itemId: 9 ,
    itemName: "Sports Watch1",
    brand: 'А1',
    price: 100,
    currency: "EUR",
    categories: ["Watches", "Sports Watches"]
  });

  watch.save((error, item, affectedNo)=> {
    if (!error) {
      console.log('Item added successfully to the catalog');
    } else {
      console.log('Cannot add item to the catlog');
    }
  });
});

db.once('open', function() {
  var filter = {
    'itemName' : 'Sports Watch1',
    'price': 100
  }

  CatalogItem.find(filter, (error, result) => {
    if (error) {
      consoloe.log('Error occured');
    } else {
      console.log('Results found:'+ result.length);
      console.log(result);
    }
  });
});

以下是如何使用模型来查询表示属于Watches组的运动手表的文档的方法:

db.once('open', function() {
  var filter = {
    'itemName' : 'Sports Watch1',
    'price': 100
  }
  CatalogItem.findOne(filter, (error, result) => {
    if (error) {
      consoloe.log('Error occurred');
    } else {
      console.log(result);
    }
  });
});

模型还公开了findOne函数,这是一种方便的方法,可以通过其唯一索引查找对象,然后对其进行一些数据操作,即删除或更新操作。以下示例删除了一个项目:

CatalogItem.findOne({itemId: 1 }, (error, data) => { 
  if (error) {  
    console.log(error); 
    return; 
  } else { 
    if (!data) { 
    console.log('not found'); 
      return; 
    } else { 
      data.remove(function(error){ 
        if (!error) { data.remove();} 
        else { console.log(error);} 
        }); 
      } 
    } 
 });

使用 Mocha 测试 Mongoose 模型

Mocha 是 JavaScript 中最流行的测试框架之一;它的主要目标是提供一种简单的方法来测试异步 JavaScript 代码。让我们全局安装 Mocha,以便将来可以在任何 Node.js 应用程序中使用它:

npm install -g mocha

我们还需要一个断言库,可以与 Mocha 一起使用。断言库提供了用于验证实际值与预期值的函数,当它们不相等时,断言库将导致测试失败。Should.js断言库模块易于使用,这将是我们的选择,因此让我们也全局安装它:

npm install -g should

现在我们已经安装了测试模块,需要在package.json文件中指定我们的testcase文件路径。让我们通过在脚本节点中添加指向 Mocha 和testcase文件的test元素来修改它:

{ 
"name": "chapter4", 
"version": "0.0.0", 
"private": true, 
"scripts": { 
"start": "node ./bin/www", 
"test": "mocha test/model-test.js" 
 }, 
"dependencies": { 
"body-parser": "~1.13.2", 
"cookie-parser": "~1.3.5", 
"debug": "~2.2.0", 
"express": "~4.16.0", 
"jade": "~1.11.0", 
"morgan": "~1.6.1", 
"serve-favicon": "~2.3.0" 
 } 
} 

这将告诉 npm 包管理器在执行npm测试时触发 Mocha。

Mongoose 测试的自动化不得受到数据库当前状态的影响。为了确保每次测试运行时结果是可预测的,我们需要确保数据库状态与我们期望的完全一致。我们将在test目录中实现一个名为prepare.js的模块。它将在每次测试运行之前清除数据库:

var mongoose = require('mongoose');
beforeEach(function (done) {
  function clearDatabase() {
    for (var i in mongoose.connection.collections) {
      mongoose.connection.collections[i].remove(function() 
      {});
    }
    return done();
  }
  if (mongoose.connection.readyState === 0) {
    mongoose.connect(config.db.test, function (err) {
      if (err) {
        throw err;
      }
      return clearDatabase();
    });
  } else {
    return clearDatabase();
  }
});
afterEach(function (done) {
  mongoose.disconnect();
  return done();
});

接下来,我们将实现一个 Mocha 测试,用于创建一个新项目:

var mongoose = require('mongoose');
var should = require('should');
var prepare = require('./prepare');

const model = require('../model/item.js');
const CatalogItem = model.CatalogItem;

mongoose.createConnection('mongodb://localhost/catalog');

describe('CatalogItem: models', function () {
  describe('#create()', function () {
    it('Should create a new CatalogItem', function (done) {

      var item = {
        "itemId": "1",
        "itemName": "Sports Watch",
        "price": 100,
        "currency": "EUR",
        "categories": [
          "Watches",
          "Sports Watches"
        ]

      };

      CatalogItem.create(item, function (err, createdItem) {
        // Check that no error occured
        should.not.exist(err);
        // Assert that the returned item has is what we expect

        createdItem.itemId.should.equal('1');
        createdItem.itemName.should.equal('Sports Watch');
        createdItem.price.should.equal(100);
        createdItem.currency.should.equal('EUR');
        createdItem.categories[0].should.equal('Watches');
        createdItem.categories[1].should.equal('Sports Watches');
        //Notify mocha that the test has completed
        done();
      });
    });
  });
});

现在执行npm test将导致针对 MongoDB 数据库的调用,从传递的 JSON 对象创建一个项目。插入后,assert 回调将被执行,确保由 Mongoose 传递的值与数据库返回的值相同。尝试一下,打破测试-只需在断言中将预期值更改为无效值-您将看到测试失败。

围绕 Mongoose 模型创建用户定义的模型

看到模型如何工作后,现在是时候创建一个用户定义的模块,用于包装目录的所有 CRUD 操作。由于我们打算在 RESTful web 应用程序中使用该模块,因此将模式定义和模型创建留在模块外,并将它们作为每个模块函数的参数提供。相同的模式定义在单元测试中使用,确保模块的稳定性。现在让我们为每个 CRUD 函数添加一个实现,从remove()函数开始。它根据其id查找项目并从数据库中删除它(如果存在):

exports.remove = function (request, response) {
  console.log('Deleting item with id: '    + request.body.itemId);
  CatalogItem.findOne({itemId: request.params.itemId}, function(error, data) {
      if (error) {
          console.log(error);
          if (response != null) {
              response.writeHead(500, contentTypePlainText);
              response.end('Internal server error');
          }
          return;
      } else {
          if (!data) {
              console.log('Item not found');
              if (response != null) {
                  response.writeHead(404, contentTypePlainText);
                  response.end('Not Found');
              }
              return;
          } else {
              data.remove(function(error){
                  if (!error) {
                      data.remove();
                      response.json({'Status': 'Successfully deleted'});
                  }
                  else {
                      console.log(error);
                      response.writeHead(500, contentTypePlainText);
                      response.end('Internal Server Error');
                  }
              });
          }
      }
  });
}

saveItem()函数将请求体有效负载作为参数。有效的更新请求将包含以 JSON 格式表示的item对象的新状态。首先,从 JSON 对象中解析出itemId。接下来进行查找。如果项目存在,则进行更新。否则,创建一个新项目:

exports.saveItem = function(request, response)
{
  var item = toItem(request.body);
  item.save((error) => {
    if (!error) {
      item.save();
      response.writeHead(201, contentTypeJson);
      response.end(JSON.stringify(request.body));
    } else {
      console.log(error);
      CatalogItem.findOne({itemId : item.itemId    },
      (error, result) => {
        console.log('Check if such an item exists');
            if (error) {
                console.log(error);
                response.writeHead(500, contentTypePlainText);
                response.end('Internal Server Error');
            } else {
                if (!result) {
                    console.log('Item does not exist. Creating a new one');
                    item.save();
                    response.writeHead(201, contentTypeJson);
                    response.
                    response.end(JSON.stringify(request.body));
                } else {
                    console.log('Updating existing item');
                    result.itemId = item.itemId;
                    result.itemName = item.itemName;
                    result.price = item.price;
                    result.currency = item.currency;
                    result.categories = item.categories;
                    result.save();
                    response.json(JSON.stringify(result));
                }
           }
      });
    }
  });
};

toItem()函数将 JSON 有效负载转换为CatalogItem模型实例,即一个项目文档:

function toItem(body) {
    return new CatalogItem({
        itemId: body.itemId,
        itemName: body.itemName,
        price: body.price,
        currency: body.currency,
        categories: body.categories
    });
}

我们还需要提供一种查询数据的方法,因此让我们实现一个查询特定类别中所有项目的函数:

exports.findItemsByCategory = function (category, response) {
    CatalogItem.find({categories: category}, function(error, result) {
        if (error) {
            console.error(error);
            response.writeHead(500, { 'Content-Type': 'text/plain' });
            return;
        } else {
            if (!result) {
                if (response != null) {
                    response.writeHead(404, contentTypePlainText);
                    response.end('Not Found');
                }
                return;
            }

            if (response != null){
                response.setHeader('Content-Type', 'application/json');
                response.send(result);
            }
            console.log(result);
        }
    });
}

类似于findItemsByCategory,以下是一个按其 ID 查找项目的函数:

exports.findItemById = function (itemId, response) {
    CatalogItem.findOne({itemId: itemId}, function(error, result) {
        if (error) {
            console.error(error);
            response.writeHead(500, contentTypePlainText);
            return;
        } else {
            if (!result) {
                if (response != null) {
                    response.writeHead(404, contentTypePlainText);
                    response.end('Not Found');
                }
                return;
            }

            if (response != null){
                response.setHeader('Content-Type', 'application/json');
                response.send(result);
            }
            console.log(result);
        }
    });
}

最后,有一个列出数据库中存储的所有目录项目的函数。它使用 Mongoose 模型的find函数来查找模型的所有文档,并使用其第一个参数作为过滤器。我们需要一个返回所有现有文档的函数;这就是为什么我们提供一个空对象。这将返回所有可用的项目。结果在callback函数中可用,它是模型find函数的第二个参数:

exports.findAllItems = function (response) {
    CatalogItem.find({}, (error, result) => {
        if (error) {
            console.error(error);
            return null;
        }
        if (result != null) {
            response.json(result);
        } else {
      response.json({});
    }
    });
};

catalog模块将成为我们 RESTful 服务的基础。它负责所有数据操作,以及不同类型的查询。它以可重用的方式封装了所有操作。

将 NoSQL 数据库模块与 Express 连接起来

现在我们已经为模型和使用它们的用户定义模块自动化了测试。这确保了模块的稳定性,并使其准备好进行更广泛的采用。

是时候构建一个基于 Express 的新应用程序并添加一个路由,将新模块暴露给它:

const express = require('express');
const router = express.Router();

const catalog = require('../modules/catalog');
const model = require('../model/item.js');

router.get('/', function(request, response, next) {
  catalog.findAllItems(response);
});

router.get('/item/:itemId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.itemId);
  catalog.findItemById(request.params.itemId, response);
});

router.get('/:categoryId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.categoryId);
  catalog.findItemsByCategory(request.params.categoryId, response);
});

router.post('/', function(request, response, next) {
  console.log('Saving item using POST method);
  catalog.saveItem(request, response);
});

router.put('/', function(request, response, next) {
  console.log('Saving item using PUT method');
  catalog.saveItem(request, response);
});

router.delete('/item/:itemId', function(request, response, next) {
  console.log('Deleting item with id: request.params.itemId);
  catalog.remove(request, response);
});

module.exports = router;

总之,我们将目录数据服务模块的每个函数路由到 RESTful 服务的操作:

  • GET /catalog/item/:itemId:这将调用catalog.findItemById()

  • POST /catalog: 这调用了catalog.saveItem()

  • PUT /catalog: 这调用了catalog.saveItem()

  • DELETE / catalog/item/:id: 这调用了catalog.remove()

  • GET /catalog/:category: 这调用了catalog.findItemsByCategory()

  • GET /catalog/: 这调用了catalog.findAllItems()

由于我们已经暴露了我们的操作,我们准备进行一些更严肃的 REST 测试。让我们启动 Postman 并测试新暴露的端点:

花一些时间彻底测试每个操作。这将帮助您确信目录数据服务模块确实有效,并且还会让您更加熟悉 HTTP 响应的服务和读取方式。作为一个 RESTful API 开发人员,您应该能够流利地阅读 HTTP 转储,显示不同的请求有效载荷和状态码。

自测问题

回答以下问题:

  • 你会如何使用 Mongoose 执行多值属性的单个值的查询?

  • 定义一个测试 Node.js 模块操作 NoSQL 数据库的策略。

摘要

在本章中,我们看了看 MongoDB,一个强大的面向文档的数据库。我们利用它并利用 Mocha 来实现对数据库层的自动化测试。现在是时候构建一个完整的 RESTful web 服务了。在下一章中,我们将通过包含对文档属性的搜索支持,添加过滤和分页功能来扩展用户定义的模块,最终演变成一个完整的 RESTful 服务实现。

第五章:Restful API 设计指南

在上一章中,我们实现了一个目录模块,该模块公开了目录应用程序中项目数据操作的函数。这些函数利用了express.js request对象来解析传入的数据,并执行适当的数据库操作。每个函数都使用相关的状态码和响应主体有效载荷填充了response对象(如果需要)。最后,我们将每个函数绑定到一个路由,接受 HTTP 请求。

现在,是时候更仔细地查看路由的 URL 和每个操作返回的 HTTP 状态码了。

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

  • 端点 URL 和 HTTP 状态码最佳实践

  • 可扩展性和版本控制

  • 链接数据

端点 URL 和 HTTP 状态码最佳实践

每个 RESTful API 操作都是针对 URL 的 HTTP 请求和适当的 HTTP 方法的组合。

执行时,每个操作将返回一个状态码,指示调用是否成功。成功的调用由 HTTP 2XX 状态码表示,而未正确执行的操作则用错误的状态码表示——如果错误发生在客户端,则为 4XX,或者当服务器无法处理有效请求时为 5xx。

拥有明确定义的 API 对于其采用至关重要。这样的规范不仅应完全列举每个操作的状态码,还应指定预期的数据格式,即其支持的媒体类型。

以下表格定义了 Express.js 路由器将如何公开 API 操作,并应作为其参考规范:

方法 URI 媒体类型 描述 状态码
GET /catalog application/json 返回目录中的所有项目。 200 OK500 Internal Server Error
GET /catalog/ application/json 返回所选类别的所有项目。如果类别不存在,则返回 404。 200 OK,404 NOT FOUND500 Internal Server Error
GET /item/ application/json 返回所选 itemId 的单个项目。如果没有这样的项目,则返回 404。 200 OK,404 NOT FOUND500 Internal Server Error
POST /item/ application/json 创建新项目;如果具有相同标识符的项目存在,则将更新。创建项目时,将返回Location标头。它提供了可以访问新创建项目的 URL。 201 Created200 OK500 Internal Server Error
PUT /item/ application/json 更新现有项目;如果提供的标识符不存在项目,则创建项目。创建项目时,将返回Location标头。它提供了可以访问新创建项目的 URL。 201 Created200 OK500 Internal Server Error
DELETE /item/ application/json 删除现有项目;如果提供的标识符不存在项目,则返回 404。 200 OK,404 NOT FOUND500 Internal Server Error

目录应用程序处理两种类型的实体:项目和类别。每个项目实体包含它所属的类别集合。正如你所看到的,类别只是我们应用程序中的一个逻辑实体;只要至少有一个项目引用它,它就会存在,并且当没有项目引用它时就会停止存在。这就是为什么应用程序只为项目类型的资源公开路由来公开数据操作功能,而类别的操作基本上是只读的。仔细观察暴露项目数据操作操作的 URL,我们可以看到一个清晰的模式,将 URL 与 REST 基本原则对齐——一个资源由一个单一的 URL 公开,并支持由请求的 HTTP 方法确定的资源操作。总之,以下是一个良好定义的 API 应该遵循的普遍接受的规则。它们在语义上与每个资源操作相关联:

  • 资源被创建时,服务使用201 已创建状态码,后跟指定新创建资源的 URL 的位置标头。

  • 创建资源的操作可以被实现为优雅地拒绝已经使用唯一标识符的资源的创建;在这种情况下,操作应该用适当的状态码409 冲突指示不成功的调用,或者更一般的400 错误请求。然而,通用状态码应该始终跟随一个有意义的解释,说明出了什么问题。在我们的实现中,我们选择了一种不同的方法——如果资源存在,我们会从创建操作中更新资源,并通过返回200 OK状态码通知调用者资源已被更新,而不是201 已创建

  • 更新操作类似于创建操作;然而,它总是期望资源标识符作为参数,如果存在具有此标识符的资源,它将使用 HTTP PUT 请求中提供的新状态对其进行更新。200 OK状态码表示成功的调用。实现可以决定使用404 未找到状态码拒绝处理不存在的资源,或者使用传递的标识符创建新资源。在这种情况下,它将返回201 已创建状态码,后跟指定新创建资源的 URL 的位置标头。我们的 API 使用了第二个选项。

  • 成功的删除可以用204 无内容状态和进一步的有效载荷来表示,但大多数用户代理会期望2xxHTTP 状态后跟一个主体。因此,为了与大多数代理保持兼容,我们的 API 将用200 OK状态码表示成功的删除,后跟 JSON 有效载荷:{'状态':'成功删除'}。状态码404 未找到将表示提供的标识符不存在的资源。

  • 一般规则是,5XX不应该表示应用程序状态错误,而应该表示更严重的错误,比如应用程序服务器或数据库故障。

  • 最佳实践是,更新创建操作应该作为资源的整个状态返回有效载荷。例如,如果使用最少的属性创建资源,所有未指定的属性将获得默认值;响应主体应该包含对象的完整状态。对于更新也是一样;即使更新操作部分更新资源状态,响应也应该返回完整状态。这可能会节省用户代理额外的 GET 请求,如果他们需要检查新状态的话。

现在我们已经定义了一些关于操作应该如何行为的一般建议,是时候在 API 的新版本中实现它们了。

可扩展性和版本控制

我们已经在第三章构建典型的 Web API中定义了一些基本的版本规则。让我们将它们应用到我们在上一章中实施的 MongoDB 数据库感知模块。我们的起点将是使 API 的当前消费者能够继续在不同的 URL 上使用相同的版本。这将使他们向后兼容,直到他们采用并成功测试新版本。

保持 REST API 的稳定性不仅仅是将一个端点从一个 URI 移动到另一个 URI 的问题。进行重定向然后拥有行为不同的 API 是没有意义的。因此,我们需要确保移动端点的行为保持不变。为了确保我们不改变先前实施的行为,让我们将当前行为从catalog.js模块移动到一个新模块,将文件重命名为catalogV1.js。然后,将其复制到catalogV2.js模块,我们将在其中引入所有新功能;但在这之前,我们必须将版本 1 从/, /{categoryId}, /{itemId}重定向到/v1, /v1/{categoryId}, /v1/{itemId}

const express = require('express');
const router = express.Router();

const catalogV1 = require('../modules/catalogV1');
const model = require('../model/item.js');

router.get('/v1/', function(request, response, next) {
  catalogV1.findAllItems(response);
});

router.get('/v1/item/:itemId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.itemId);
  catalogV1.findItemById(request.params.itemId, response);
});

router.get('/v1/:categoryId', function(request, response, next) {
  console.log(request.url + ' : querying for ' + request.params.categoryId);
  catalogV1.findItemsByCategory(request.params.categoryId, response);
});

router.post('/v1/', function(request, response, next) {
  catalogV1.saveItem(request, response);
});

router.put('/v1/', function(request, response, next) {
  catalogV1.saveItem(request, response);
});

router.delete('/v1/item/:itemId', function(request, response, next) {
  catalogV1.remove(request, response);
});

router.get('/', function(request, response) {
  console.log('Redirecting to v1');
  response.writeHead(301, {'Location' : '/catalog/v1/'});
  response.end('Version 1 is moved to /catalog/v1/: ');
});

module.exports = router;

由于我们的 API 的第 2 版尚未实施,对/执行GET请求将导致接收到301 Moved Permanently的 HTTP 状态,然后重定向到/v1/。这将通知我们的消费者 API 正在发展,并且他们很快将需要决定是继续使用版本 1,通过显式请求其新 URI,还是准备采用版本 2。

继续尝试吧!启动修改后的节点应用程序,并从 Postman 向http://localhost:3000/catalog发出 GET 请求:

您将看到您的请求被重定向到新路由位置http://localhost:3000/catalog/v1

现在我们已经完成了目录的第 1 版,是时候考虑我们可以在第 2 版中添加的进一步扩展了。目录服务目前支持列出类别中的所有商品和按 ID 获取商品。是时候充分利用 MongoDB 了,作为一个面向文档的数据库,并实现一个函数,使我们的 API 消费者能够根据商品的任何属性查询商品。例如,列出具有与查询参数匹配的属性的特定类别的所有商品,如价格或颜色,或按商品名称搜索。RESTful 服务通常公开面向文档的数据。但是,它们的使用不仅限于文档。在下一章中,我们将扩展目录,使其还可以存储二进制数据——可以链接到每个商品的图像。为此,我们将在第六章的使用任意数据部分中使用 MongoDB 的二进制格式二进制 JSONBSON)。

回到搜索扩展,我们已经使用了Mongoose.js模型的find()findOne()函数。到目前为止,我们在 JavaScript 代码中静态地使用它们来提供要搜索的文档属性的名称。然而,find()的这个过滤参数只是一个 JSON 对象,其中键是文档属性,值是要在查询中使用的属性的值。这是我们将在第 2 版中添加的第一个新函数。它通过任意属性和其值查询 MongoDB:

exports.findItemsByAttribute = function (key, value, response) {
      var filter = {};
      filter[key] = value;
      CatalogItem.find(filter, function(error, result) {
          if (error) {
              console.error(error);
              response.writeHead(500, contentTypePlainText);
              response.end('Internal server error');
              return;
          } else {
              if (!result) {
                  if (response != null) {
                     response.writeHead(200, contentTypeJson);
                     response.end({});
                  }
                  return;
              }
              if (response != null){
                  response.setHeader('Content-Type', 'application/json');
                  response.send(result);
              }
          }
      });
    }

这个函数调用模型上的 find,并将提供的属性和值作为参数。我们将把这个函数绑定到路由器的/v2/item/ GET 处理程序。

最后,我们的目标是有/v2/item/?currency=USD,它只返回以美元货币出售的商品记录,由传递的 GET 参数的值指示。这样,如果我们修改模型并添加额外的属性,比如颜色和尺寸,我们可以查询具有相同颜色或任何其他商品属性的所有商品。

当在查询字符串中没有提供参数时,我们将保留返回所有可用项目的旧行为,但我们还将解析查询字符串以获取第一个提供的GET参数,并将其用作findItemsByAttribute()函数中的过滤器:

router.get('/v2/items', function(request, response) {
    var getParams = url.parse(request.url, true).query;
    if (Object.keys(getParams).length == 0) {
      catalogV2.findAllItems(response);
    } else {
      var key = Object.keys(getParams)[0];
      var value = getParams[key];
      catalogV2.findItemsByAttribute(key, value, response);
    }
});

也许这个函数中最有趣的部分是 URL 解析。正如你所看到的,我们继续使用相同的旧策略来检查是否提供了任何GET参数。我们解析 URL 以获取查询字符串,然后我们使用内置的Object.keys函数来检查解析的键/值列表是否包含元素。如果是,我们取第一个元素并提取其值。键和值都传递给findByAttribute函数。

您可能希望通过多个GET参数提供的搜索支持来进一步改进版本 2。我将把这留给你作为一个练习。

发现和探索 RESTful 服务

发现 RESTful 服务的主题有着悠久而复杂的历史。HTTP 规范规定资源应该是自描述的,并且应该通过 URI 唯一标识。依赖资源应该通过其自己的唯一 URI 链接到依赖项。发现 RESTful 服务意味着从一个服务导航到另一个服务,跟随它提供的链接。

在 2009 年,发明了一种名为Web Application Discovery LanguageWADL)的规范。它旨在记录从 Web 应用程序公开的每个 URI,以及它支持的 HTTP 方法和它所期望的参数。还描述了 URI 的响应媒体类型。这对于文档目的非常有用,这就是 WADL 文件在 RESTful 服务供应方面能为我们提供的一切。

不幸的是,目前还没有一个 Node.js 模块可以自动生成给定 express 路由的 WADL 文件。我们将不得不手动创建一个 WADL 文件来演示它如何被其他客户端用于发现。

以下清单显示了描述/catalog, /catalog/v2/{categoryId}处可用资源的示例 WADL 文件:

<?xml version="1.0" encoding="UTF-8"?>
<application   >
   <grammer>
      <include href="items.xsd" />
      <include href="error.xsd" />
   </grammer>
   <resources base="http://localhost:8080/catalog/">
      <resource path="{categoryId}">
         <method name="GET">
            <request>
               <param name="category" type="xsd:string" style="template" />
            </request>
            <response status="200">
               <representation mediaType="application/xml" element="service:item" />
               <representation mediaType="application/json" />
            </response>
            <response status="404">
               <representation mediaType="text/plain" element="service:item" />
            </response>
         </method>
      </resource>
      <resource path="/v2/{categoryId}">
         <method name="GET">
            <request>
               <param name="category" type="xsd:string" style="template" />
            </request>
            <response status="200">
               <representation mediaType="application/xml" element="service:item" />
               <representation mediaType="application/json" />
            </response>
            <response status="404">
               <representation mediaType="text/plain" element="service:item" />
            </response>
         </method>
      </resource>
   </resources>
</application>

正如你所看到的,WADL 格式非常简单直接。它基本上描述了每个资源的 URI,提供了关于它使用的媒体类型以及在该 URI 处预期的状态码的信息。许多第三方 RESTful 客户端都理解 WADL 语言,并可以根据给定的 WADL 文件生成请求消息。

让我们在 Postman 中导入 WADL 文件。点击导入按钮并选择你的 WADL 文件:

在 Postman 中导入一个 WADL 文件以获得服务的存根。这是 Postman 的一个截图。这里个别设置并不重要。图片的目的只是为了展示窗口的外观。

正如你所看到的,导入 WADL 文件的结果是,我们有一个准备好测试 REST 服务的项目。WADL 文件中定义的所有路由现在都方便地作为右侧菜单上的单独请求实体可用。除了 WADL 标准之外,目前 swagger 文档格式也被广泛采用,并已成为描述 RESTful 服务的非正式标准,因此我们也可以使用它来简化服务的采用和发现。在下一章中,我们将把这些描述文件绑定到我们的服务上。这是生产准备阶段的重要步骤。

链接数据

每个目录应用程序都支持与该项目绑定的图像或一组图像。为此,在下一章中,我们将看到如何在 MongoDB 中处理二进制对象。然而,现在是决定如何在项目文档中语义链接二进制数据的时候了。以这样的方式扩展模型架构,使其包含文档中二进制数据的 base64 表示,绝非明智之举,因为在一个格式中混合文字编码和二进制数据从来都不是一个好主意。这增加了应用程序的复杂性,并使其容易出错。

{
  "_id": "5a4c004b0eed73835833cc9a",
  "itemId": "1",
  "itemName": "Sports Watch",
  "price": 100,
  "currency": "EUR",
  "categories": [
    "Watches",
    "Sports Watches"
  ],
  "image":" 
iVBORw0KGgoAAAANSUhEUgAAAJEAAACRCAMAAAD0BqoRAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNuzjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MjMwNjQ1NDdFNjJCMTFERkI5QzU4OTFCMjJCQzEzM0EiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MjMwNjQ1NDhFNjJCMTFERkI5QzU4OTFCMjJCQzEzM0EiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoyMzA2NDU0NUU2MkIxMURGQjlDNTg5MUIyMkJDMTMzQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoyMzA2NDU0NkU2MkIxMURGQjlDNTg5MUIyMkJDMTMzQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Px5Xq1XXhWFY1+v151/b3ij5tI/GPEVP0e8U/SPAABPLjHnaJ6XvAAAAAElFTkSuQmCC 
"} 

想象一下,如果所有这些项目都将图像二进制表示作为 JSON 属性的值返回,那么一个非过滤查询的结果会变得多么庞大,即使只有几百个项目。为了避免这种情况,我们将返回每个项目的图像,其 URL 在逻辑上与资源的 URL 链接在一起—/catalog/v2/item/{itemId}/image

这样,如果为一个项目分配了图像,它将被提供在一个已知的位置。然而,这种方法并没有在语义上将二进制项目与其对应的资源链接起来,因为当访问/catalog/v2/item/{itemId}时,没有迹象表明它是否分配了图像。为了解决这个问题,让我们在项目路由的响应中使用自定义的 HTTP 头部:

GET http://localhost:3000/catalog/v2/item/1 HTTP/1.1 
Host: localhost:3000 
Connection: Keep-Alive 
User-Agent: Apache-HttpClient/4.1.1 (java 1.5) 

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: application/json; charset=utf-8 
Content-Length: 152 
Image-Url: http://localhost:3000/catalog/v2/item/1/image
ETag: W/"98-2nJj2mZdLV2YDME3WYCyEwIXfuA" 
Date: Thu, 01 Feb 2018 13:50:43 GMT 
Connection: keep-alive 

{
  "_id": "5a4c004b0eed73835833cc9a",
  "itemId": "1",
  "itemName": "Sports Watch",
  "price": 100,
  "currency": "EUR",
  "__v": 0,
  "categories": [
    "Watches",
    "Sports Watches"
  ]
}

当在响应中存在时,Image-Url头部指示该项目有一个额外的资源与之绑定,并且头部值提供了它可用的地址。使用这种方法,我们在语义上将一个二进制资源链接到我们的文档。

在下一章中,我们将实现处理目录中项目的任意项目的路由。

总结

在本章中,我们详细讨论了资源应该如何通过 RESTful API 公开;我们密切关注了 URL 最佳实践,并研究了 HTTP 状态代码的适当使用,指示我们操作的每个状态。

我们涵盖了版本控制和可扩展性的主题,我们使用301 Moved Permanently状态代码自动将 API 调用重定向到不同的 URL。

最后,我们找出了如何将我们的资源项目与任意二进制表示的数据语义链接起来。

第六章:实现一个完整的 RESTful 服务

到目前为止,我们已经创建了我们的 RESTful 服务的第二个版本,并且通过不同的 URL 公开了这两个版本,确保向后兼容。我们为其数据库层实现了单元测试,并讨论了如何适当地使用 HTTP 状态码。在本章中,我们将通过为服务的第二个版本提供处理非文档二进制数据的功能,并相应地将其链接到相关的文档来扩展该实现。

我们将研究一种方便的方式来向消费者呈现大型结果集。为此,我们将引入分页以及进一步的过滤功能到我们的 API 中。

有些情况下,应该考虑将数据响应缓存起来。我们将研究它的好处和缺点,并在必要时决定启用缓存。

最后,我们将深入探讨 REST 服务的发现和探索。

总之,以下是应该进一步实现的内容,以将目录数据服务转变为一个完整的 RESTful 服务:

  • 处理任意数据

  • 在现实世界中处理关联数据

  • 分页和过滤

  • 缓存

  • 发现和探索

处理任意数据

MongoDB 使用 BSON(二进制 JSON)作为主要数据格式。它是一种二进制格式,将键/值对存储在一个称为文档的单个实体中。例如,一个样本 JSON,{"hello":"world"},在 BSON 中编码后变成\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00

BSON 存储的是数据而不是文字。例如,如果一张图片要作为文档的一部分,它不需要被转换成 base64 编码的字符串;相反,它将直接以二进制数据的形式存储,而不像普通的 JSON 通常会将这样的数据表示为 base64 编码的字节,但这显然不是最有效的方式。

Mongoose 模式通过模式类型buffer使得能够以 BSON 格式存储二进制内容。它可以存储二进制内容(图片、ZIP 归档等)高达 16MB。相对较小的存储容量背后的原因是为了防止在传输过程中过度使用内存和带宽。

GridFS规范解决了 BSON 的这一限制,并使您能够处理大于 16MB 的数据。GridFS 将数据分成存储为单独文档条目的块。默认情况下,每个块的大小最多为 255KB。当从数据存储中请求数据时,GridFS 驱动程序检索所有必需的块,并按照组装的顺序返回它们,就好像它们从未被分割过一样。这种机制不仅允许存储大于 16MB 的数据,还使消费者能够以部分方式检索数据,这样就不必完全加载到内存中。因此,该规范隐含地支持流支持。

GridFS 实际上提供了更多功能——它支持存储给定二进制数据的元数据,例如其格式、文件名、大小等。元数据存储在一个单独的文件中,并且可以用于更复杂的查询。有一个非常有用的 Node.js 模块叫做gridfs-stream。它可以方便地在 MongoDB 中进行数据的流入和流出,就像所有其他模块一样,它被安装为一个npm包。因此,让我们全局安装它并看看它的使用方法;我们还将使用-s选项来确保项目的package.json中的依赖项得到更新:

    npm install -g -s gridfs-stream

要创建一个Grid实例,你需要打开到数据库的连接:

const mongoose = require('mongoose')
const Grid = require('gridfs-stream');

mongoose.connect('mongodb://localhost/catalog');
var connection = mongoose.connection;
var gfs = Grid(connection.db, mongoose.mongo);   

通过createReadStream()createWriteStream()函数来进行流的读取和写入。流入数据库的每一部分数据都必须设置一个ObjectId属性。ObjectId唯一标识二进制数据条目,就像它在 MongoDB 中标识任何其他文档一样;使用这个ObjectId,我们可以通过这个标识符从 MongoDB 集合中找到或删除它。

让我们扩展目录服务,添加用于获取、添加和删除分配给项目的图像的功能。为简单起见,该服务将支持每个项目一个图像,因此将有一个负责添加图像的单个函数。每次调用时,它都将覆盖现有图像,因此适当的名称是saveImage

exports.saveImage = function(gfs, request, response) {

    var writeStream = gfs.createWriteStream({
            filename : request.params.itemId,
            mode : 'w'
        });

        writeStream.on('error', function(error) {
            response.send('500', 'Internal Server Error');
            console.log(error);
            return;
        })

        writeStream.on('close', function() {
            readImage(gfs, request, response);
        });

    request.pipe(writeStream);
}

如您所见,我们只需创建一个 GridFS 写流实例即可刷新 MongoDB 中的数据。它需要一些选项,这些选项提供了 MongoDB 条目的ObjectId以及一些附加的元数据,例如标题以及写入模式。然后,我们只需调用请求的 pipe 函数。管道将导致将数据从请求刷新到写入流中,以此方式将其安全存储在 MongoDB 中。存储后,与writeStream关联的close事件将发生,这时我们的函数将读取数据库中存储的任何内容,并在 HTTP 响应中返回该图像。

检索图像是另一种方式——使用选项创建readStream_id参数的值应为任意数据的ObjectId,可选文件名和读取模式:

function readImage(gfs, request, response) {

  var imageStream = gfs.createReadStream({
      filename : request.params.itemId,
      mode : 'r'
  });

  imageStream.on('error', function(error) {
    console.log(error);
    response.send('404', 'Not found');
    return;
  });

  response.setHeader('Content-Type', 'image/jpeg');
  imageStream.pipe(response);
}

在将读取流传输到响应之前,必须设置适当的Content-Type标头,以便可以将任意数据以适当的图像媒体类型image/jpeg呈现给客户端。

最后,我们从我们的模块中导出一个函数,用于从 MongoDB 中获取图像。我们将使用该函数将其绑定到从数据库中读取图像的 express 路由:

exports.getImage = function(gfs, itemId, response) {
     readImage(gfs, itemId, response);
};

从 MongoDB 中删除任意数据也很简单。您必须从两个内部 MongoDB 集合fs.filesfs.files.chunks中删除条目,其中存放着所有文件:

exports.deleteImage = function(gfs, mongodb, itemId, response) {
  console.log('Deleting image for itemId:' + itemId);

    var options = {
            filename : itemId,
    };

    var chunks = mongodb.collection('fs.files.chunks');
    chunks.remove(options, function (error, image) {
        if (error) {
            console.log(error);
            response.send('500', 'Internal Server Error');
            return;
       } else {
           console.log('Successfully deleted image for item: ' + itemId);
       }
    });

    var files = mongodb.collection('fs.files');
    files.remove(options, function (error, image) {
        if (error) {
            console.log(error);
            response.send('500', 'Internal Server Error');
            return;
        }

        if (image === null) {
            response.send('404', 'Not found');
            return;
        } else {
           console.log('Successfully deleted image for primary item: ' + itemId);
           response.json({'deleted': true});
        }
    });
}

让我们将新功能绑定到适当的项目路由并进行测试:

router.get('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.getImage(gfs, request, response);
});

router.get('/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.getImage(gfs, request, response);
});

router.post('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage(gfs, request, response);
});

router.post('/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage(gfs, request.params.itemId, response);
});

router.put('/v2/item/:itemId/image',
  function(request, response){
    var gfs = Grid(model.connection.db, mongoose.mongo);
    catalogV2.saveImage (gfs, request.params.itemId, response);
});

router.put('/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.saveImage(gfs, request.params.itemId, response);
});

router.delete('/v2/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.deleteImage(gfs, model.connection,
  request.params.itemId, response);
});

router.delete('/item/:itemId/image',
function(request, response){
  var gfs = Grid(model.connection.db, mongoose.mongo);
  catalogV2.deleteImage(gfs, model.connection,  request.params.itemId, response);
});

由于在撰写本文时,版本 2 是我们 API 的最新版本,因此其提供的任何新功能都应在/catalog/v2/catalog两个位置都可用。

让我们启动 Postman 并将图像发布到现有项目,假设我们有一个 ID 为 14 的项目/catalog/v2/item/14/image

使用 Postman 分配图像给项目的 Post 请求。这是 Postman 的屏幕截图。这里个别设置并不重要。图像的目的只是为了展示窗口的外观。

请求处理后,二进制数据存储在网格数据存储中,并且图像在响应中返回。

链接

在上一章的链接数据部分,我们定义了如果目录中的项目分配了图像,则将使用名为 Image-URL 的 HTTP 标头进行指示。

让我们修改目录 V2 中的findItemById函数。我们将使用 GridFS 的现有功能来检查所选项目是否绑定了图像;如果项目分配了图像,则其 URL 将在响应中可用,并带有 Image-Url 标头:

exports.findItemById = function (gfs, request, response) {
    CatalogItem.findOne({itemId: request.params.itemId}, function(error, result) {
        if (error) {
            console.error(error);
            response.writeHead(500,    contentTypePlainText);
            return;
        } else {
            if (!result) {
                if (response != null) {
                    response.writeHead(404, contentTypePlainText);
                    response.end('Not Found');
                }
                return;
            }

            var options = {
                filename : result.itemId,
            };
            gfs.exist(options, function(error, found) {
                if (found) {
                    response.setHeader('Content-Type', 'application/json');
                    var imageUrl = request.protocol + '://' + request.get('host') + request.baseUrl + request.path + '/image';
                    response.setHeader('Image-Url', imageUrl);
                    response.send(result);
                } else {
                    response.json(result);
                }
            });
        }
    });
}

到目前为止,我们将项目与其图像链接起来;但是,这使我们的数据部分链接,因为从项目到其图像有一个链接,但反之则没有。让我们改变这一点,并通过修改readImage函数向图像响应提供一个名为 Item-Url 的标头:

function readImage(gfs, request, response) {

  var imageStream = gfs.createReadStream({
      filename : request.params.itemId,
      mode : 'r'
  });

  imageStream.on('error', function(error) {
    console.log(error);
    response.send('404', 'Not found');
    return;
  });

  var itemImageUrl = request.protocol + '://' + request.get('host') + request.baseUrl+ request.path;
  var itemUrl = itemImageUrl.substring(0, itemImageUrl.indexOf('/image'));
  response.setHeader('Content-Type', 'image/jpeg');
  response.setHeader('Item-Url', itemUrl);

  imageStream.pipe(response);
}

现在请求http://localhost:3000/catalog/v2/item/3/处的项目将以 JSON 格式返回编码的项目:

GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1 
Accept-Encoding: gzip,deflate 
Host: localhost:3000 

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: application/json; charset=utf-8 
Image-Url: http://localhost:3000/catalog/v2/item/3/image 
Content-Length: 137 
Date: Tue, 03 Apr 2018 19:47:41 GMT 
Connection: keep-alive 

{
   "_id": "5ab827f65d61450e40d7d984",
   "itemId": "3",
   "itemName": "Sports Watch 11",
   "price": 99,
   "currency": "USD",
   "__v": 0,
   "categories": ["Watches"]
}

查看响应标头,我们发现Image-Url标头及其值,http://localhost:3000/catalog/v2/item/3/image提供了与项目关联的图像的 URL。

请求该图像将产生以下结果:

GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1 
Host: localhost:3000 
Connection: Keep-Alive 

HTTP/1.1 200 OK 
X-Powered-By: Express 
Content-Type: image/jpeg 
Item-Url: http://localhost:3000/catalog/v2/item/3 
Connection: keep-alive 
Transfer-Encoding: chunked 

<BINARY DATA>

这一次,响应提供了与项目链接的图像的有效载荷和一个特殊的标题Item-Url。它的值——http://localhost:3000/catalog/v2/item/3——是项目资源可用的地址。现在,如果项目图像出现在图像搜索结果中,与图像链接的项目的 URL 也将成为结果的一部分。通过这种方式,我们在不修改或损害有效载荷的情况下,语义上链接了这两个数据。

实现分页和过滤

一旦部署到网络上,每个服务都可以提供给大量的消费者使用。他们不仅会用它来获取数据,还会用它来插入新数据。在某个时候,这将不可避免地导致数据库中有大量的数据可用。为了保持服务的用户友好性并保持合理的响应时间,我们需要确保以合理的方式提供大量数据,以确保在请求/catalog URI 时不需要返回几十万个项目。

Web 数据消费者习惯于具有各种分页和过滤功能。在本章的前面,我们实现了findIfindItemsByAttribute()函数,它可以通过项目的任何属性进行过滤。现在,是时候引入分页功能,以便通过 URI 参数在resultset中进行导航。

mongoose.js模型可以利用不同的插件模块来提供额外的功能。这样一个插件模块是mongoose-paginate。Express 框架还提供了一个名为express-paginate的分页中间件。它提供了与 Mongoose 结果页面的链接和导航:

  1. 在开始开发分页机制之前,我们应该安装这两个有用的模块:
npm install -g -s express-paginate
npm install -g -s mongoose-paginate
  1. 下一步将是在我们的应用程序中创建express-paginate中间件的实例:

expressPaginate = require('express-paginate'); 
  1. 通过调用其middleware()函数在应用程序中初始化分页中间件。它的参数指定了默认限制和每页结果的最大限制:
app.use(expressPaginate.middleware(limit, maxLimit); 
  1. 然后,在创建模型之前,将mongoose-pagination实例作为插件提供给CatalogItem模式。以下是item.js模块如何导出它以及模型:
var mongoose = require('mongoose');
var mongoosePaginate = require('mongoose-paginate');
var Schema = mongoose.Schema;

mongoose.connect('mongodb://localhost/catalog');

var itemSchema = new Schema ({
    "itemId" : {type: String, index: {unique: true}},
    "itemName": String,
    "price": Number,
    "currency" : String,
    "categories": [String]
});
console.log('paginate');
itemSchema.plugin(mongoosePaginate);
var CatalogItem = mongoose.model('Item', itemSchema);

module.exports = {CatalogItem : CatalogItem, connection : mongoose.connection};
  1. 最后,调用模型的paginate()函数以分页方式获取请求的条目:

CatalogItem.paginate({}, {page:request.query.page, limit:request.query.limit},
    function (error, result){
        if(error) {
            console.log(error);
            response.writeHead('500',
               {'Content-Type' : 'text/plain'});
            response.end('Internal Server Error');
         } else {
           response.json(result);
         }
});

第一个参数是 Mongoose 应该用于其查询的过滤器。第二个参数是一个对象,指定了请求的页面和每页的条目。第三个参数是一个回调处理函数,通过其参数提供结果和任何可用的错误信息:

  • error:这指定了查询是否成功执行

  • result:这是从数据库中检索到的数据

express-paginate中间件通过丰富 Express 处理程序函数的requestresponse对象,实现了mongoose-paginate模块在 Web 环境中的无缝集成。

request对象获得了两个新属性:query.limit,它告诉中间件页面上的条目数,以及query.page,它指定了请求的页面。请注意,中间件将忽略大于初始化中指定的maxLimit值的query.limit值。这可以防止消费者覆盖最大限制,并使您完全控制应用程序。

以下是目录模块第二个版本中paginate函数的实现:

exports.paginate = function(model, request, response) {
    var pageSize = request.query.limit;
    var page = request.query.page;
    if (pageSize === undefined) {
        pageSize = 100;
    }
    if (page === undefined) {
        page = 1;
    }

    model.paginate({}, {page:page, limit:pageSize},
            function (error, result){
                if(error) {
                    console.log(error);
                    response.writeHead('500',
                        {'Content-Type' : 'text/plain'});
                    response.end('Internal Server Error');
                }
                else {
                    response.json(result);
                }
            });
}

以下是查询包含 11 个项目的数据集并且每页限制为五个项目时的响应:

{
  "docs": [
    {
      "_id": "5a4c004b0eed73835833cc9a",
      "itemId": "1",
      "itemName": "Sports Watch 1",
      "price": 100,
      "currency": "EUR",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a4c0b7aad0ebbce584593ee",
      "itemId": "2",
      "itemName": "Sports Watch 2",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64d7ecfa1b585142008017",
      "itemId": "3",
      "itemName": "Sports Watch 3",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64d9a59f4dc4e34329b80f",
      "itemId": "8",
      "itemName": "Sports Watch 4",
      "price": 100,
      "currency": "EUR",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    },
    {
      "_id": "5a64da377d25d96e44c9c273",
      "itemId": "9",
      "itemName": "Sports Watch 5",
      "price": 100,
      "currency": "USD",
      "__v": 0,
      "categories": [
        "Watches",
        "Sports Watches"
      ]
    }
  ],
  "total": 11,
  "limit": "5",
  "page": "1",
  "pages": 3
}

docs属性包含所有作为结果一部分的项目。它的大小与所选的限制值相同。pages属性提供了总页数;在这个例子中,它的值是 3,因为 11 个项目被安排在三页中,每页包含五个项目。Total属性给出了项目的总数。

启用分页的最后一步是修改/v2/路由,开始使用新创建的函数:

  router.get('/v2/', function(request, response) {
    var getParams = url.parse(request.url, true).query;
    if (getParams['page'] !=null) {
      catalogV2.paginate(model.CatalogItem, request, response);
    } else {
      var key = Object.keys(getParams)[0];
      var value = getParams[key];
      catalogV2.findItemsByAttribute(key, value, response);
    }
});

我们将使用 HTTP 302 Found状态为默认路由/catalog。这样,所有传入的请求都将被重定向到/v2/

router.get('/', function(request, response) {
  console.log('Redirecting to v2');
  response.writeHead(302, {'Location' : '/catalog/v2/'});
  response.end('Version 2 is is available at /catalog/v2/: ');
});

在这里使用适当的重定向状态代码对于任何 RESTful web 服务的生命周期至关重要。返回302 Found,然后进行重定向,确保 API 的使用者始终可以在该位置获得最新版本。此外,从开发的角度来看,使用重定向而不是代码重复也是一个很好的实践。

当你处于两个版本之间时,应始终考虑使用 HTTP 301 Moved Permanently状态来显示先前版本已经移动到何处,以及 HTTP 302 Found状态来显示当前版本的实际 URI。

现在,回到分页,由于请求的页面和限制数字是作为GET参数提供的,我们不希望将其与过滤功能混淆,因此对它们进行了明确的检查。只有在请求中有页面或限制GET参数时,才会使用分页。否则,将进行搜索。

最初,我们设置了 100 个结果的最大限制和 10 个默认限制,因此,在尝试新的分页功能之前,请确保将更多的项目插入到数据库中。这将使测试结果更加明显。

现在,让我们试一试。请求/catalog?limit=3将返回一个只包含两个项目的列表,如下所示:

启用分页结果。这是 Postman 的屏幕截图。这里个别设置并不重要。图片的目的只是展示窗口的外观。

如示例所示,总页数为四。数据库中存储的项目总数为 11。由于我们在请求中没有指定页面参数,分页隐式返回了第一页。要导航到下一页,只需在 URI 中添加&page=2

另外,尝试更改limit属性,请求/catalog/v2?limit=4。这将返回前四个项目,并且响应将显示总页数为三。

缓存

当我们讨论罗伊·菲尔丁定义的 REST 原则时,我们提到缓存是一个相当敏感的话题。最终,我们的消费者在执行查询时会期望得到最新的结果。但是,从统计的角度来看,Web 中公开的数据更有可能被阅读而不是被更新或删除。

因此,合理的是一些公共 URL 暴露的资源成为数百万请求的对象,考虑从服务器中卸载部分负载到缓存中。HTTP 协议允许我们缓存一些响应一段时间。例如,当在短时间内收到多个请求时,查询给定组的目录中的所有项目,例如/catalog/v2,我们的服务可以利用特殊的 HTTP 头,强制 HTTP 服务器缓存响应一段时间。这将防止对底层数据库服务器的冗余请求。

通过特殊的响应头在 HTTP 服务器级别进行缓存。HTTP 服务器使用Cache-Control头来指定给定响应应该缓存多长时间。缓存需要失效之前的时间段通过其max-age属性设置,其值以秒为单位提供。当然,有一个很好的 Node.js 模块提供了一个用于缓存的中间件函数,称为express-cache-control

在 Express 应用程序中提供 Cache-Control 头

让我们使用 NPM 包管理器安装它;再次,我们将全局安装它,并使用-s选项,这将自动更新package.json文件,添加新的express-cache-control依赖项:

    npm install -g -s express-cache-control

使用express-cache-control中间件启用缓存需要三个简单的步骤:

  1. 获取模块:
      CacheControl = require("express-cache-control") 
  1. 创建CacheControl中间件的实例:
 var cache = new CacheControl().middleware;
  1. 将中间件实例绑定到要启用缓存的路由:
router.get('/v2/', cache('minutes', 1), function(request, response) {
    var getParams = url.parse(request.url, true).query;
    if (getParams['page'] !=null || getParams['limit'] != null) {
      catalogV2.paginate(model.CatalogItem, request, response);
    } else {
      var key = Object.keys(getParams)[0];
      var value = getParams[key];
      catalogV2.findItemsByAttribute(key, value, response);
    }
});

通常,提供许多结果条目的常见 URI 应该是缓存的主题,而不是为具体条目提供数据的 URI。在我们的应用程序中,只有/catalog URI 将使用缓存。max-age属性必须根据您的应用程序的负载进行选择,以最小化不准确的响应。

让我们通过在 Postman 中请求/catalog/v2来测试我们的更改:

Cache-control 头部指示缓存已启用。这是 Postman 的屏幕截图。这里不重要的是单独的设置。图片的目的只是为了展示窗口的外观。

正如预期的那样,express-cache-control中间件已经完成了它的工作——Cache-Control头现在包含在响应中。must-revalidate选项确保在max-age间隔过期后使缓存内容无效。现在,如果您对特定项目发出另一个请求,您会发现响应不使用express-cache-control中间件,这是因为它需要在每个单独的路由中显式提供。它不会在相互衍生的 URI 中使用。

针对任何路由/v1/GET请求的响应将不包含Cache-Control头部,因为它仅在我们的 API 的第 2 版中受支持,并且Cache-Control中间件仅在主目录路由/catalog/v2//catalog中使用。

摘要

恭喜!在本章中,您成功地将一个样本 REST 启用的端点转换为一个完整的支持过滤和分页的 RESTful Web 服务。该服务提供任意和 JSON 数据,并且已准备好应对高负载场景,因为它在关键部分启用了缓存。应该引起您注意的一件事是在公共 API 的新旧版本之间进行重定向时,适当使用 HTTP 状态代码。

实现适当的 HTTP 状态对于 REST 应用程序非常重要,因此我们使用了相当奇特的状态,例如301 Moved Permanently302 Found。在下一章中,我们将介绍授权概念到我们的应用程序中。

第七章:为生产准备 RESTful API

在上一章中,我们实现了一个完整的目录 RESTful API;然而,一个完全功能的 API 和一个可投入生产的 API 之间存在差异。在本章中,我们将介绍 API 应该如何进行全面的文档记录和测试。在投入生产之前,任何软件都必须完成这些关键要求。

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

  • 记录 RESTful API

  • 使用 Mocha 测试 RESTful API

  • 微服务革命

记录 RESTful API

到目前为止,我们部分地介绍了 RESTful web 服务 API 是如何由wadl描述和由swagger规范记录的。现在是时候充分利用它们,在我们的目录应用程序的 express.js 路由中公开它们的自描述元数据。这样,消费者和最终用户将有单独的 URL 来获取他们需要轻松采用服务的元数据。让我们从 wadl 定义开始。以下是wadl如何完全描述一个操作的方式:

  <resources base="http://localhost:8080/catalog/"> 
        <resource path="/catalog/item/{itemId}">
            <method name="GET">
                <request>
                    <param name="category" type="xsd:string" style="template"/>
                </request>
                <response status="200">
                    <representation mediaType="application/json" />
                </response>
                <response status="404">
                    <representation mediaType="text/plain" />
                </response>
                <response status="500">
                    <representation mediaType="text/plain" />
                </response>
            </method>
            <method name="PUT">
                <request>
                    <param name="itemId" type="xsd:string" style="template"/>
                </request>
                <response status="200">
                    <representation mediaType="application/json" />
                </response>
                <response status="201">
                    <representation mediaType="application/json" />
                </response>
                <response status="404">
                    <representation mediaType="text/plain" />
                </response>
                <response status="500">
                    <representation mediaType="text/plain" />
                </response>
            </method>
            <method name="POST">
                <request>
                    <param name="itemId" type="xsd:string" 
                     style="template"/>
                </request>
                <response status="200">
                    <representation mediaType="application/json" />
                </response>
                <response status="201">
                    <representation mediaType="application/json" />
                </response>
                <response status="404">
                    <representation mediaType="text/plain" />
                </response>
                <response status="500">
                    <representation mediaType="text/plain" />
                </response>
            </method>
            <method name="DELETE">
                <request>
                    <param name="itemId" type="xsd:string" 
                     style="template"/>
                </request>
                <response status="200">
                    <representation mediaType="application/json" />
                </response>
                <response status="404">
                    <representation mediaType="text/plain" />
                </response>
                <response status="500">
                    <representation mediaType="text/plain" />
                </response>
            </method>
        </resource>
      </resources>

每个路由都彻底描述了它所暴露的所有操作;这样,它们将被符合wadl规范的客户端索引和发现。一旦你描述了所有的操作,只需将wadl文件存储在你的express.js项目的static目录中,并从应用程序中公开它:app.use('/catalog/static', express.static('static'));

在本地启动应用程序后,你的wadl文件将准备好在http://localhost:3000/catalog/static/catalog.wadl上为客户端提供服务。

让我们试试并将其导入到 Postman 中:

将 wadl 文件导入到 Postman。这是 Postman 的截图。这里个别设置并不重要。图片的目的只是展示窗口的外观。

静态地提供wadl文件将有助于你的应用程序被搜索引擎索引;这进一步增加了你的 API 的采用率。

然而,wadl正逐渐失去地位,而swagger则更受青睐。JavaScript REST-enabled 应用程序的发展导致了对非 XML 标准的 RESTful 服务发现的需求。这就是为什么swagger成为事实上的标准的原因,不仅用于记录 RESTful 服务,还用于其广泛采用的发现格式。虽然 XML-aware 平台仍然依赖于wadl,但 JavaScript 和其他非 XML 本地平台在swagger规范上有很大依赖,不仅用于描述,还用于发现和消费,其采用速度正在迅速增加。因此,你应该考虑使用swagger描述你的 API,以确保它能够轻松地被任何平台采用。以下是swagger方言中如何完全描述一个操作的方式:

{
    "swagger": "2.0",
    "info": {
      "title": "Catalog API Documentation",
      "version": "v1"
    },
    "paths": {"/catalog/item/{itemId}": {
        "get": {
          "operationId": "getItemV2",
          "summary": "Get an existing item",
          "produces": ["application/json"],
          "responses": {
            "200": {
              "description": "200 OK",
              "examples": {
                "application/json": {
                    "_id": "5a4c004b0eed73835833cc9a",
                    "itemId": "1",
                    "itemName": "Sports Watch",
                    "price": 100,
                    "currency": "EUR",
                    "__v": 0,
                    "categories": [ "Watches", "Sports Watches"]
                  }
              }
            },
            "404": {"description": "404 Not Found"},
            "500": {"description": "500 Internal Server Error"}
          }
        },
        "post": {
          "404": {"description": "404 Not Found"},
          "500": {"description": "500 Internal Server Error"},
          "operationId": "postItemV2",
          "summary": "Creates new or updates existing item",
          "produces": ["application/json"],
          "responses": {
            "200": {
              "itemId": 19,
              "itemName": "Sports Watch 19",
              "price": 100,
              "currency": "USD",
              "__v": 0,
              "categories": [
                "Watches",
                "Sports Watches"
              ]
            },
            "201": {
              "itemId": 19,
              "itemName": "Sports Watch 19",
              "price": 100,
              "currency": "USD",
              "__v": 0,
              "categories": [ "Watches", "Sports Watches"]
            },
            "500": "text/html"
          }
        },
        "put": {
          "404": {"description": "404 Not Found"},
          "500": {"description": "500 Internal Server Error"},
          "operationId": "putItemV2",
          "summary": "Creates new or updates existing item",
          "produces": ["application/json"],
          "responses": {
            "200": {
              "itemId": 19,
              "itemName": "Sports Watch 19",
              "price": 100,
              "currency": "USD",
              "__v": 0,
              "categories": [ "Watches","Sports Watches"]
            },
            "201": {
              "itemId": 19,
              "itemName": "Sports Watch 19",
              "price": 100,
              "currency": "USD",
              "__v": 0,
              "categories": ["Watches", "Sports Watches"]
            },
            "500": "text/html"
          }
        },
        "delete": {
          "404": {"description": "404 Not Found"},
          "500": {"description": "500 Internal Server Error"},
          "operationId": "deleteItemV2",
          "summary": "Deletes an existing item",
          "produces": ["application/json"],
          "responses": {"200": {"deleted": true },
            "500": "text/html"}
        }
      }
   }
  consumes": ["application/json"]
  }
 }

最后,在swagger.json文件中描述了所有 API 的操作后,它应该被静态地公开,类似于wadl文件。由于应用程序已经有了静态目录的路由,只需将swagger.json文件放在那里,它就可以为消费者提供服务并促进发现。Swagger主要是一个文档工具,但主要面向开发者;因此,它需要一个使文档易于阅读和理解的前端。有一个npm模块——swagger-ui——为我们提供了默认的 swagger 前端。我们将在我们的应用程序中采用它,所以让我们使用包管理器来安装它——npm install swagger-ui。安装完成后,只需要求模块的一个实例以及静态swagger.json文件的一个实例,并在一个单独的路由中使用它们:

const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./static/swagger.json');

app.use('/catalog/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

在浏览器中启动你的应用程序并请求http://localhost:3000/catalog/api-docs/

正如你所看到的,swagger-ui 模块为你提供了标准的 swagger 前端。

记住,作为开发者,保持你的 API 文档的完整和最新是你的责任。

使用 Mocha 测试 RESTful API

你是否注意到app.js express 应用程序是用express-generator创建的,实际上是一个导出 express 实例的node.js模块?如果你有,你一定会问自己为什么需要这样做。嗯,将 express 实例导出为模块使其能够进行单元测试。我们已经在第四章中使用了mocha框架,使用 NoSQL 数据库,在那里我们为CatalogItem模块开发了一个单元测试。我们将再次使用mocha,并为 API 公开的每个操作包装一个单元测试。要对 express 应用程序进行单元测试,我们需要执行以下操作:

  1. 需要一个带有路由的express.js应用程序实例,利用其作为模块导出

  2. 在单元测试环境中启动express.js实例

  3. 通过测试库调用其操作并断言结果

  4. 最后,执行npm test命令来触发单元测试

在继续实施 mocha 测试之前,我们需要一个库来从单元测试中发送 HTTP 请求;我们将利用chai模块。它提供了方便的函数来发送 HTTP 请求,还捆绑了should.js断言库来验证预期结果。要安装chai,只需执行npm install chai,然后执行npm install chai-http来安装其 HTTP 插件,我们就可以开始单元测试了!

与任何其他 mocha 测试一样,我们将不得不执行以下步骤:

  1. 描述每个测试用例

  2. 准备测试装置;这次,我们将使用chai-http来调用 REST 操作

  3. 断言返回的结果

涵盖创建、访问和删除资源操作的基本单元测试如下:

var expressApp = require('../../app');
var chai = require('chai');
var chaiHttp = require('chai-http');
var mongoose = require('mongoose');
var should = chai.should();

mongoose.createConnection('mongodb://localhost/catalog-test');

chai.use(chaiHttp);

describe('/get', function() {
  it('get test', function(done) {
    chai.request(expressApp)
      .get('/catalog/v2')
      .end(function(error, response) {
        should.equal(200  , response.status);
        done();
      });
    });
  });

describe('/post', function() {
     it('post test', function(done) {
       var item ={
          "itemId":19,
          "itemName": "Sports Watch 10",
          "price": 100,
          "currency": "USD",
          "__v": 0,
          "categories": [
              "Watches",
              "Sports Watches"
          ]
      };
     chai.request(expressApp)
           .post('/catalog/v2')
           .send(item )
           .end(function(err, response){
               should.equal(201, response.status)
             done();
           });
     });
   });

   describe('/delete', function() {
        it('delete test', function(done) {
          var item ={
             "itemId":19,
             "itemName": "Sports Watch 10",
             "price": 100,
             "currency": "USD",
             "__v"cd .: 0,
             "categories": [
                 "Watches",
                 "Sports Watches"
             ]
         };
        chai.request(expressApp)
              .delete('/catalog/v2/item/19')
              .send(item )
              .end(function(err, response){
                  should.equal(200, response.status)
                done();
              });
        });
      });

将此文件存储在项目的测试目录中;默认情况下,该目录在package.json中被定义为测试目录,因此要运行单元测试,只需执行npm test

恭喜!现在你的 API 已经覆盖了单元测试,注意测试并没有模拟任何东西!它们正在运行 express 应用程序;当应用程序变得生产时,它们将以完全相同的方式运行,确保稳定性和向后兼容性!目前,单元测试仅断言状态码。花一些时间并进一步扩展它们,以便对响应主体进行断言。这将是一个很好的练习。

微服务革命

RESTful API 疯狂开始并且几乎每个人都决定 RESTful API 是正确的方式,是不是?随着Linux容器的出现,结果表明转向 REST 方法只是一半的路。目前,每个人都从容器中受益。它们提供了更好、更快、更便宜的开发和运营模式,但是微服务只是 RESTful 服务的另一个炒作术语吗?嗯,不,完全不是;RESTful 服务只是微服务的基础。

微服务是小型和独立的进程,公开了一个简单的接口,允许与它们进行通信并构建复杂的应用程序,而不依赖于库工件。这些服务类似于小型构建块,高度解耦并专注于执行小任务,促进了系统构建的模块化方法。

虽然 REST 强调资源及其自然处理,但微服务架构强调简单性、故障安全性和隔离性。RESTful API 没有每个操作的单独状态;要么整个 API 可用,要么完全不可用。微服务试图解决这个问题,提供了在单独的容器上托管每个操作,或者容器的子集,确保最大的容错能力和灵活性。

微服务预期提供单一简单的操作,没有更多。这使开发人员可以按照他们想要的方式对它们进行分组和使用。处理策略、治理、安全和监控通常不在微服务处理范围内,主要是因为它们需要某种上下文。总的来说,将上下文绑定到服务会增加其依赖性并使其可重用性降低;这就是为什么微服务将上下文留给 API 管理网关的原因,它允许您创建微服务的组合,然后将策略绑定到它,并监视网关上的每个活动。这种分布式开发模型使程序员能够快速构建一系列微服务,而无需考虑治理和安全等复杂主题。

微服务世界是一个改变游戏规则的世界,受益于 Linux 容器。目前,类似于 AWS 和 Azure 的所有基于云的服务都提供微服务托管。

摘要

在本章中,我们稍微偏离了与Express.js相关的主题。相反,我们集中讨论了如何通过提供最新的 API 文档以及 API 本身来使我们的代码基础投入生产。我们让我们的应用程序投资于预防措施,以确保通过实施更复杂的单元测试来实现向后兼容性。最后,我们决定展望未来,这一切都与微服务有关。确保您将这一热门话题保持在您的技能清单中;它将不可避免地在不久的将来发展,您对它了解得越多,就越好!

第八章:消费 RESTful API

为了演示与我们的 API 相关的一些更高级的主题,我们将实现一个非常简单的 Web 客户端。这将帮助我们涵盖这些主题,并且可以作为目录消费者的参考实现。对于这个前端客户端,我们将使用著名的 JavaScript 库 jQuery。利用它将帮助我们涵盖以下内容:

  • 使用 jQuery 消费 RESTful 服务

  • 内容交付网络

  • 在线故障排除和识别问题

  • 跨域资源共享策略

  • 客户端处理不同的 HTTP 状态码

使用 jQuery 消费 RESTful 服务

JQuery 是一个快速、轻量级和强大的 JavaScript 库;它通过在 DOM 三加载后直接访问 HTML 元素来消除与 DOM 相关的复杂性。要在 HTML 文档中使用 jQuery,您必须导入它:

<script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>

假设在 HTML 文档的某处,有一个定义为<input type="button" id="btnDelete" value="Delete"/>的按钮。

使用 JQuery 为此按钮分配一个点击事件的函数意味着我们需要执行以下操作:

  1. 在 HTML 文档中导入 jquery 库

  2. 确保 HTML 文档的 DOM 文档完全加载

  3. 使用 ID 属性定义的标识符访问按钮

  4. 将处理程序函数作为click事件的参数提供:

$(document).ready(function() {
    $('#btn').click(function () {
       alert('Clicked');
    });
});

$('#identifier')表达式直接访问 DOM 三中的元素,$表示引用一个对象,括号内的值,前缀为#指定了它的标识符。只有在整个文档加载后,jQuery 才能访问元素;这就是为什么元素应该在${document).ready()块范围内访问。

同样,您可以通过标识符txt访问文本输入的值:

  $(document).ready(function() {
    var textValue = $('#txt').val();
    });
  });

$(document)对象在 jQuery 中预定义,并表示 HTML 页面的整个 DOM 文档。类似地,jQuery 预定义了一个用于启用 AJAX 通信的函数,即向 HTTP 端点发送 HTTP 请求。这个函数被命名为异步 JavaScript + XML- AJAX,这是一种事实标准,使 JavaScript 应用程序能够与启用 HTTP 的后端进行通信。如今,JSON被广泛使用;然而,AJAX 的命名转换仍然被用作异步通信的术语,无论数据格式如何;这就是为什么 jQuery 中的预定义函数被称为$.ajax(options, handlers)

要使用$.ajax函数发送 http 请求,通过提供端点 URL、请求的 http 方法和其内容类型来调用它;结果将在回调函数中返回。以下示例显示了如何从我们的目录请求标识为 3 的项目:

  $.ajax({
      contentType: 'application/json',
      url: 'http://localhost:3000/catalog/v2/item/3',
      type: 'GET',
      success: function (item, status, xhr) {
          if (status === 'success') {
              //the item is successfully retrieved load & display its details here
          }
      }
      ,error: function (xhr, options, error) {
        //Item was not retrieved due to an error handle it here
      }
    });
  });

将数据发布到端点相当相似:

  $.ajax({
    url: "http://localhost:3000/catalog/v2/",
    type: "POST",
    dataType: "json",
    data: JSON.stringify(newItem),
     success: function (item, status, xhr) {
       if (status === 'success') {
         //item was created successfully
       }
     },
     error: function(xhr, options, error) {
       //Error occurred while creating the iteam
     }
   });

只需使用适当的选项type设置为 POST,dateType设置为 JSON。这些将指定以 JSON 格式向端点发送 POST 请求。对象的有效负载作为data属性的值提供。

调用delete方法非常相似:

      $.ajax({
        contentType: 'application/json',
        url: 'http://localhost:3000/catalog/v2/item/3',
        type: 'DELETE',
        success: function (item, status, xhr) {
            if (status === 'success') {
              //handle successful deletion
            }
        }        
        ,error: function (xhr, options, error) {
            //handle errors on delete
        }
      });

对于这本书的范围来说,对 jQuery 的基本理解就足够了。现在,让我们把所有这些粘合在一起,创建两个 HTML 页面;这样,我们将处理在我们的目录中创建、显示和删除项目,首先是显示项目并允许删除的页面。该页面使用GET请求从目录加载项目,然后以表格方式在 HTML 页面中显示项目的属性:

<html>
<head><title>Item</title></head>
<body>
    <script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>
  <script>
  $(document).ready(function() {
    $('#btnDelete').click(function () {
      $.ajax({
        contentType: 'application/json',
        url: 'http://localhost:3000/catalog/v2/item/3',
        type: 'DELETE',
        success: function (item, status, xhr) {
            if (status === 'success') {
              $('#item').text('Deleted');
              $('#price').text('Deleted');
              $('#categories').text('Deleted');
            }
        }
        ,error: function (xhr, options, error) {
          alert('Unable to delete item');
        }
      });
    });
    $.ajax({
      contentType: 'application/json',
      url: 'http://localhost:3000/catalog/v2/item/3',
      type: 'GET',
      success: function (item, status, xhr) {
          if (status === 'success') {
            $('#item').text(item.itemName);
            $('#price').text(item.price + ' ' + item.currency);
            $('#categories').text(item.categories);
          }
      }
      ,error: function (xhr, options, error) {
        alert('Unable to load details');
      }
    });
  });
  </script>
  <div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Item: </div>
      <div><span id="item"/>k</div>
    </div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Price: </div>
      <div><span id="price"/>jjj</div>
    </div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Categories: </div>
      <div><span id="categories"/>jjj</div>
    </div>
    <div><input type="button" id="btnDelete" value="Delete"/></div>
  </div>
</body>
</html>

处理创建的页面非常相似。但是,它提供了文本输入,而不是用于加载项目属性的 span 标签,视图页面将显示加载项目属性的数据。JQuery 提供了一个简化的访问模型来访问输入控件,而不是 DOM——只需按如下方式访问输入元素:

<html>
<head><title>Item</title></head>
<body>
  <script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>
  <script>
  $(document).ready(function() {
    $('#btnCreate').click(function(){
      var txtItemName = $('#txtItem').val();
      var txtItemPrice = $('#txtItemPrice').val();
      var txtItemCurrency = $('#txtItemCurrency').val();
      var newItem = {
        itemId: 4,
        itemName: txtItemName,
        price: txtItemPrice,
        currency: txtItemCurrency,
        categories: [
          "Watches"
        ]
      };
      $.ajax({
        url: "http://localhost:3000/catalog/v2/",
        type: "POST",
        dataType: "json",
        data: JSON.stringify(newItem),
        success: function (item, status, xhr) {
              alert(status);
            }
      });
    })
  });
  </script>
  <div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Id: </div>
      <div><input type="text" id="id"/></div>

      <div style="float:left; width: 80px;">Item: </div>
      <div><input type="text" id="txtItem"/></div>
    </div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Price: </div>
      <div><input type="text" id="price"/></div>
    </div>
    <div style="position: relative">
      <div style="float:left; width: 80px;">Categories: </div>
      <div><input type="text" id="categories"/></div>
    </div>
    <div><input type="button" id="btnCreate" value="Create"/></div>
  </div>
</body>
</html>

让我们试试,通过在所选的浏览器中直接从文件系统打开我们的静态页面,加载视图页面中的现有项目。看起来我们似乎有某种问题,因为没有显示任何内容。使用浏览器的开发者套件启用客户端调试也没有提供更多信息:

它指出内容部分被阻止;但是,目前还不太清楚这是由于后端错误,还是客户端出了问题。我们将在下一节中看看如何排除这种问题。

在线故障排除和问题识别

有时客户端和服务器之间的交互失败,而这些失败的原因通常需要分析;否则,它们的根本原因将不为人知。我们发现我们的客户端应用程序无法加载,因此无法显示现有项目的数据。让我们尝试通过在客户端和服务器之间设置http隧道来调查其根本原因。这将是一种 MiM(中间人)调查,因为我们将监听一个端口并将传入请求重定向到另一个端口,以查看服务器是否返回正确的响应,或者它的管道是否在中间某处中断。有各种 TCP 隧道可用;我一直在使用 GitHub 上可用的一个简单的开源隧道,网址是github.com/vakuum/tcptunnel。其作者还维护着一个单独的网站,您可以在该网站上下载最常见操作系统的预构建二进制文件;网址是www.vakuumverpackt.de/tcptunnel/

在构建或下载隧道的副本之后,启动如下:

./tcptunnel --local-port=3001 --remote-port=3000 --remote-host=localhost --log

这将启动应用程序监听端口 3001,并将每个传入请求转发到位置端口 3000;--log选项指定应在控制台中记录通过隧道传递的所有数据流。最后,修改 HTML 页面以使用端口 3001 而不是 3000,然后让我们看看在端口3001上发出新的 GET 请求获取 id 为 3 的项目后,隧道会显示我们什么:http://localhost:3001/catalog/v2/item/3

令人惊讶的是,隧道显示服务器正常响应200 OK和相关有效负载。因此,问题似乎不在服务器端。

嗯,既然错误显然不在服务器端,让我们尝试深入调查客户端发生了什么。如今,所有流行的浏览器都有所谓的 Web 开发者工具。它们提供对http日志、动态渲染的代码、HTML 文档的 DOM 树等的访问。让我们使用 Mozilla Firefox 调用我们的 RESTful GET 操作,看看它的 Web 控制台会记录我们的请求的什么信息。打开 Mozilla Firefox 菜单,选择Web Developer,然后选择Browser Console

啊哈!看起来我们找到了:跨域请求被阻止:同源策略不允许读取远程资源...

这个错误在客户端级别阻止了服务器端的响应。在下一节中,我们将看看这实际上意味着什么。

跨域资源共享

跨站点 HTTP 请求是指引用要从与最初请求它们的域不同的域加载的资源的请求。在我们的情况下,我们从我们的文件系统启动了客户端,并请求了来自网络地址的资源。这被认为是潜在的跨站点脚本请求,根据W3C 推荐w3.org/cors/TR/cors中应该小心处理。这意味着如果请求外部资源,则应该在标头中明确指定请求来源的域—其来源,只要不允许一般外部资源加载。这种机制可以防止跨站脚本(XSS)攻击,它是基于 HTTP 标头的。

以下 HTTP 请求标头指定了客户端端如何处理外部资源:

  • Origin定义了请求的来源

  • Access-Control-Request-Method定义了用于请求资源的 HTTP 方法

  • Access-Control-Request-Header定义了与外部资源请求结合使用的任何标头

在服务器端,以下标头指示响应是否符合 CORS 启用的客户端请求:

  • Access-Control-Allow-Origin:此标头要么(如果存在)通过重复指定请求者的主机来指定,要么可以通过返回通配符'*'来指定允许所有远程来源

  • Access-Control-Allow-Methods:此标头指定服务器允许从跨站点域接受的 HTTP 方法

  • Access-Control-Allow-Headers:此标头指定服务器允许从跨站点域接受的 HTTP 标头

还有一些Access-Control-*标头可用于进一步细化处理传入的 XSS 请求,或者根据凭据和请求的最大年龄来确定是否提供服务,但基本上,最重要的是允许的来源、允许的方法和允许的标头。

有一个节点模块在服务器端处理CORS配置;通过npm install -g cors进行安装,并且可以通过中间件模块轻松在我们的应用程序中启用。只需在所有公开的路由中使用它,通过将其传递给应用程序:

app.use(cors());

在启用了cors中间件后使用隧道,可以看到服务器现在通过将"Access-Control-Allow-Origin'标头设置为'*'"优雅地处理来自不同来源的请求:

内容交付网络

当我们将 jQuery 库导入我们的客户端应用程序时,我们直接引用了其优化的源自其供应商的位置,如<script type="text/javascript" src="img/jquery-3.3.1.min.js "/>

现在,想象一下,由于某种原因,这个网站要么暂时关闭,要么永久关闭;这将使我们的应用程序无法使用,因为导入功能将无法正常工作。

内容交付网络在这些情况下会提供帮助。它们作为库或其他静态媒体内容的存储库,确保所需的资源在没有停机时间的情况下可用,即使与其供应商出现问题。最受欢迎的 JavaScript CDN 之一是cdnjs.com/;它提供了最常见的 JS 库。我们将把我们的客户端切换到从这个 CDN 而不是从其供应商网站引用 jquery 库。

虽然直接下载 JS 库并将其放置在 node.js 项目的静态目录中几乎没有什么问题,但这可能导致本地更改和修复直接在库依赖项中。这很容易导致不兼容的更改,并且可能会阻止您的应用程序轻松切换到将来的新版本。只要您的依赖项是开源的,您应该努力通过贡献修复或报告错误来改进它们,而不是在自己的本地分支中进行修复。但是,如果不幸遇到一个您可以轻松解决的错误,您可以分叉库以更快地解决问题。但是,始终考虑向社区贡献修复。一旦被接受,切换回官方版本;否则,下次遇到另一个问题时,您会发现自己处于困境之中,如果从分叉版本报告,社区将更难追踪。这就是开源的美丽之处,这就是为什么您应该始终考虑使用 JavaScript API 的内容交付网络。它们将为您提供您在应用程序生命周期的任何时候可能需要的稳定性和支持。

在客户端处理 HTTP 状态代码。

我们花了相当多的时间来解决 RESTful 服务应该如何优雅地表示每个状态,包括错误状态。一个定义良好的 API 应该要求其消费者优雅地处理所有错误,并根据需要提供尽可能多的状态信息,而不仅仅是声明“发生了错误”。这就是为什么它应该查找返回的状态代码,并清楚区分客户端请求,比如400 Bad Request415 Unsupported media types,这些请求是由于错误的有效负载、错误的媒体类型或身份验证相关错误,比如401 Unauthorized

错误响应的状态代码可以在 jQuery 回调函数的error回调中获得,并应该用于向请求提供详细信息:

 $.ajax({
        url: "http://localhost:3000/catalog/v2/",
        type: "POST",
        dataType: "json",
        data: JSON.stringify(newItem),
        success: function (item, status, jqXHR) {
            alert(status);
        },
        error: function(jqXHR, statusText, error) {
            switch(jqXHR.status) {
               case 400: alert('Bad request'); break;
               case 401: alert('Unauthroizaed'); break;
               case 404: alert('Not found'); break;
               //handle any other client errors below
               case 500: alert('Internal server error); break;
               //handle any other server errors below
            }
        }
      });

错误请求由错误回调函数处理。它提供jqXHR - XmlHttpRequest JavaScript对象作为其第一个参数。它携带了所有请求/响应相关的信息,如状态代码和标头。使用它来确定所请求的服务器返回了什么,以便您的应用程序可以更细致地处理不同的错误。

摘要

在本章中,我们使用了 jQuery 库实现了一个简单的基于 Web 的客户端。我们利用这个客户端来演示跨域资源共享策略的工作原理,并使用了中间人手段来解决线上问题。最后,我们看了一下客户端应该如何处理错误。这一章使我们离旅程的终点又近了一步,因为我们得到了我们服务的第一个消费者。在下一章中,我们将带您走完将服务带入生产之前的最后一步——选择其安全模型。

第九章:保护应用程序

一旦在生产环境中部署,应用程序将面临大量请求。不可避免地,其中一些将是恶意的。这就需要明确授予访问权限,只有经过身份验证的用户才能访问服务,即,对已选择的消费者进行身份验证,以便他们能够访问您的服务。大多数消费者只会使用服务进行数据提供。然而,少数消费者需要能够提供新的或修改现有的目录数据。为了确保只有适当的消费者能够执行POSTPUTDELETE请求,我们将不得不在应用程序中引入授权的概念,该授权将仅授予明确选择的用户修改权限。

数据服务可能提供敏感的私人信息,例如电子邮件地址;HTTP 协议作为一种文本协议,可能不够安全。通过它传输的信息容易受到中间人攻击,这可能导致数据泄露。为了防止这种情况,应使用传输层安全TLS)。HTTPS 协议加密传输的数据,确保只有具有正确解密密钥的适当消费者才能使用服务提供的数据。

在本章中,我们将看看 Node.js 如何实现以下安全功能:

  • 基本身份验证

  • 基于护照的基本身份验证

  • 基于护照的第三方身份验证

  • 授权

  • 传输层安全

身份验证

应用程序在成功针对受信任存储验证其身份后,将用户视为已经通过身份验证。这样的受信任存储可以是任何一种特别维护的数据库,存储应用程序的凭据(基本身份验证),或者是第三方服务,该服务检查给定的身份是否与其自己的受信任存储匹配(第三方身份验证)。

基本身份验证

HTTP 基本身份验证是目前最流行和直接的身份验证机制之一。它依赖于请求中的 HTTP 头,提供用户的凭据。可选地,服务器可能会回复一个头部,强制客户端进行身份验证。以下图显示了在进行基本身份验证时客户端和服务器的交互:

每当向由 HTTP 基本身份验证保护的端点发送 HTTP 请求时,服务器都会以 HTTP 401 Unauthorized状态代码进行回复,并且可选地附带WWW-Authenticate头。此头部强制客户端发送另一个请求,其中包含Authorization头,该头指定身份验证方法为basic。此请求后跟一个 base64 编码的键/值对,提供要进行身份验证的用户名和密码。可选地,服务器可以使用realm属性向客户端指定消息。

该属性指定具有相同realm值的资源应支持相同的身份验证方式。在上图中,realm消息是MyRealmName。客户端通过发送具有Basic YWRtaW46YWRtaW4值的Authentication头来进行身份验证,指定使用Basic身份验证,然后是 base64 编码的值。在图中,base64 解码的文字YWRtaW46YWRtaW4代表admin:admin文字。如果成功验证了这样的用户名/密码组合,HTTP 服务器将用所请求项目的 JSON 有效负载进行响应。如果身份验证失败,服务器将以401 Unauthorized状态代码进行响应,但这次不包括WWW-Authenticate头。

护照

现在有很多身份验证方法可供选择。也许最流行的方法是基本身份验证,每个用户都有自己的用户名和密码,以及第三方身份验证,用户可以使用他们已经存在的外部公共服务账户进行身份识别,例如个人社交服务,如 LinkedIn、Facebook 和 Twitter。

选择 Web API 的最合适的身份验证类型主要取决于其消费者。显然,使用 API 获取数据的应用程序不太可能使用个人社交账户进行身份验证。当 API 直接由人类使用前端直接使用时,这种方法更加合适。

实现一个能够轻松切换不同身份验证方法的解决方案是一个复杂且耗时的任务。事实上,如果在应用程序的初始设计阶段没有考虑到这一点,这几乎是不可能的。

Passport是专为 Node.js 设计的身份验证中间件,特别适用于身份验证方式需要轻松切换的用例。它具有模块化架构,可以使用特定的身份验证提供者,称为策略。该策略负责实现所选择的身份验证方法。

有很多身份验证策略可供选择,例如常规的基本身份验证策略或基于社交平台的策略,用于 Facebook、LinkedIn 和 Twitter 等服务。请参考官方 Passport 网站www.passportjs.org/,获取可用策略的完整列表。

Passport 的基本身份验证策略

现在是时候看看如何利用 Passport 的策略了;我们将从基本身份验证策略开始;现在我们知道基本身份验证的工作原理,这是一个合乎逻辑的选择。

像往常一样,我们将使用 NPM 包管理器安装相关模块。我们需要passport模块,它提供了允许您插入不同身份验证策略的基本功能,以及由passport-http模块提供的基本身份验证的具体策略:

  npm install passport
  npm install passport-http

接下来,我们需要实例化 Passport 中间件和基本身份验证策略。BasicStrategy以回调函数作为参数,检查提供的用户名/密码组合是否有效。最后,将 passport 的 authenticate 方法作为中间件函数提供给 express 路由,确保未经身份验证的请求将以适当的“401 未经授权”状态被拒绝:

const passport = require('passport');
const BasicStrategy = require('passport-http').BasicStrategy;

passport.use(new BasicStrategy(function(username, password, done) {
  if (username == 'user' && password=='default') {
    return done(null, username);
  }
}));

router.get('/v1/', 
  passport.authenticate('basic', { session: false }), 
     function(request,    response, next) {
       catalogV1.findAllItems(response);
});
router.get('/v2/', 
  passport.authenticate('basic', { session: false }), 
     function(request,    response, next) {
       catalogV1.findAllItems(response);
});

router.get('/', 
  passport.authenticate('basic', { session: false }), 
     function(request,    response, next) {
       catalogV1.findAllItems(response);
});

BasicStrategy构造函数以处理程序函数作为参数。它使我们能够访问客户端提供的用户名和密码,以及 Passport 中间件的“done()”函数,该函数通知 Passport 用户是否已成功验证。调用“done()”函数并将user作为参数以授予身份验证,或者将error参数传递给它以撤销身份验证:

passport.use(new BasicStrategy(
function(username, password, done) {
  AuthUser.findOne({username: username, password: password}, 
    function(error, user) {
      if (error) {
        return done(error);
      } else {
        if (!user) {
          console.log('unknown user');
          return done(error);
        } else {
          console.log(user.username + ' 
          authenticated successfully');
          return done(null, user);
        }
      }
    });  
  })
); 

最后,在路由器中间件中使用passort authenticate()函数将其附加到特定的 HTTP 方法处理程序函数。

在我们的情况下,我们指定不希望在会话中存储任何身份验证细节。这是因为,在使用基本身份验证时,没有必要在会话中存储任何用户信息,因为每个请求都包含提供登录详细信息的Authorization标头。

Passport 的 OAuth 策略

OAuth 是第三方授权的开放标准,它定义了一种委托协议,用于对抗第三方认证提供者。OAuth 使用特殊令牌,一旦发行,就用于标识用户,而不是用户凭据。让我们更仔细地看一下 OAuth 的工作流程,以一个示例场景为例。场景中的主要角色是-一个用户与一个Web 应用程序进行交互,该应用程序从后端系统中提供某种数据的 RESTful 服务。Web 应用程序将其授权委托给一个单独的第三方授权服务器

  1. 用户请求一个需要进行身份验证以与后端服务建立通信的 Web 应用程序。这是初始请求,因此用户仍未经过身份验证,因此他们被重定向到一个登录页面,要求提供相关第三方账户的凭据。

  2. 成功认证后,认证服务器向 Web 应用程序发放授权代码。这个授权代码是由提供者发行的客户端 ID 和秘密的组合。它们应该从 Web 应用程序发送到认证服务器,并且用于交换具有有限生命周期的访问令牌。

  3. Web 应用程序使用认证令牌进行身份验证,直到它过期。之后,它必须使用授权代码请求新的令牌。

Passport.js 通过一个单独的策略模块隐藏了这个过程的复杂性,自动化了 OAuth 的工作流程。它可以在npm存储库中找到。

npm install passport-oauth

创建策略的实例并为其提供请求令牌和认证的 URL,以及您的个人消费者密钥和您选择的秘密短语。

var passport = require('passport')
  , OAuthStrategy = require('passport-oauth').OAuthStrategy;

passport.use('provider', new OAuthStrategy({
    requestTokenURL: 'https://www.provider.com/oauth/request_token',
    accessTokenURL: 'https://www.provider.com/oauth/access_token',
    userAuthorizationURL: 'https://www.provider.com/oauth/authorize',
    consumerKey: '123-456-789',
    consumerSecret: 'secret'
    callbackURL: 'https://www.example.com/auth/provider/callback'
  }, function(token, tokenSecret, profile, done) {  
    //lookup the profile and authenticate   and call done
  }
));

Passport.js 提供了包装不同提供者的单独策略,如 linkedin 或 github。它们确保您的应用程序与发放令牌的 URL 保持最新。一旦您确定要支持的提供者,就应该为它们检查特定的策略。

Passport 的第三方认证策略

如今,几乎每个人都至少拥有一个个人公共社交媒体账户,如 Twitter、Facebook 和 LinkedIn。最近,让访问者通过点击一个图标来绑定他们的社交服务账户到一个服务内部自动生成的账户,已经变得非常流行。

这种方法非常方便,适用于通常至少有一个账户保持登录状态的网页用户。如果他们当前没有登录,点击图标将重定向他们到他们的社交服务登录页面,成功登录后,又会发生另一个重定向,确保用户获取他们最初请求的内容。但是,当涉及通过 Web API 公开数据时,这种方法并不是一个真正的选择。

公开的 API 无法预测它们是由人还是应用程序使用。此外,API 通常不会直接由人使用。因此,当您作为 API 作者确信公开的数据将直接通过互联网浏览器的前端手动请求的最终用户直接使用时,第三方认证是唯一的选择。一旦他们成功登录到他们的社交账户,唯一的用户标识符将被存储在会话中,因此您的服务需要能够适当地处理这样的会话。

要使用 Passport 和 Express 存储用户登录信息的会话支持,必须在初始化 Passport 及其会话中间件之前初始化 Express 会话中间件:

app.use(express.session()); 
app.use(passport.initialize()); 
app.use(passport.session()); 

然后,指定 Passport 应将哪个用户的详细信息序列化/反序列化到会话中。为此,Passport 提供了serializeUser()deserializeUser()函数,它们在会话中存储完整的用户信息:

passport.serializeUser(function(user, done) { done(null, user); }); passport.deserializeUser(function(obj, done) { done(null, obj); });

初始化 Express 和 Passport 中间件的会话处理的顺序很重要。Express 会话应该首先传递给应用程序,然后是 Passport 会话。

启用会话支持后,您必须决定依赖哪种第三方身份验证策略。基本上,第三方身份验证是通过第三方提供商创建的插件或应用程序启用的,例如社交服务网站。我们将简要介绍如何创建一个允许通过 OAuth 标准进行身份验证的 LinkedIn 应用程序。

通常,这是通过与社交媒体应用程序关联的公钥和密钥(令牌)对来完成的。创建 LinkedIn 应用程序很容易——您只需登录www.linkedin.com/secure/developer并填写简要的应用程序信息表。您将获得一个秘钥和一个令牌来启用身份验证。执行以下步骤来启用 LinkedIn 身份验证:

  1. 安装linkedin-strategy模块—npm install linkedin-strategy

  2. 获取 LinkedIn 策略的实例,并在启用会话支持后通过use()函数将其初始化为 Passport 中间件:

      var passport = require('passport')
        , LinkedInStrategy = require('passport-
        linkedin').Strategy;

        app.use(express.session());
        app.use(passport.initialize());
        app.use(passport.session());

      passport.serializeUser(function(user, done) {
        done(null, user);
      });

      passport.deserializeUser(function(obj, done) {
        done(null, obj);
      });

        passport.use(new LinkedInStragety({
          consumerKey: 'api-key',
          consumerSecret: 'secret-key',
          callbackURL: "http://localhost:3000/catalog/v2"
        },
          function(token, tokenSecret, profile, done) {
            process.nextTick(function () {
              return done(null, profile);
            });
          })
        ); 
  1. 明确指定 LinkedIn 策略应该作为每个单独路由的 Passport 使用,确保启用会话处理:
      router.get('/v2/', 
        cache('minutes',1), 
        passport.authenticate('linked', { session: true}), 
        function(request, response) {
          //...
        }
      });
  1. 提供一种方式让用户通过暴露注销 URI 来注销,利用request.logout
      router.get('/logout', function(req, res){
      request.logout();
        response.redirect('/catalog');
      });

提供的第三方 URL 和服务数据可能会发生变化。在提供第三方身份验证时,您应始终参考服务政策。

授权

到目前为止,目录数据服务使用基本身份验证来保护其路由免受未知用户的侵害;然而,目录应用程序应该只允许少数白名单用户修改目录中的项目。为了限制对目录的访问,我们将引入授权的概念,即,一组经过身份验证的用户,允许适当的权限。

当调用 Passport 的done()函数来验证成功的登录时,它以user用户的实例作为参数。done()函数将该用户模型实例添加到request对象中,并通过request.user属性提供对其的访问,以便在成功验证后执行授权检查。我们将利用该属性来实现一个在成功验证后执行授权检查的函数。

function authorize(user, response) {
  if ((user == null) || (user.role != 'Admin')) {
    response.writeHead(403, { 'Content-Type' : 
    'text/plain'});
    response.end('Forbidden');
    return;
  }
} 

HTTP 403 Forbidden 状态码很容易与 405 Not allowed 混淆。然而,405 Not Allowed 状态码表示请求的资源不支持特定的 HTTP 动词,因此只能在该上下文中使用。

authorize()函数将关闭response流,返回403 Forbidden状态码,表示已识别登录用户但权限不足。这将撤销对资源的访问。此函数必须在执行数据操作的每个路由中使用。

以下是一个post路由实现授权的示例:

app.post('/v2', 
  passport.authenticate('basic', { session: false }), 
    function(request, response) {
      authorize(request.user, response);
      if (!response.closed) {
        catalogV2.saveItem(request, response);
      }
    }
); 

调用authorize()后,我们通过检查response对象的 closed 属性的值来检查其输出是否仍然允许写入。一旦response对象的 end 函数被调用,closed 属性将返回true,这正是当用户缺少管理员权限时authorize()函数所做的。因此,我们可以在我们的实现中依赖 closed 属性。

传输层安全

网上公开的信息很容易成为不同类型的网络攻击的对象。通常仅仅把所谓的“坏人”挡在门外是不够的。有时,他们甚至不会费心获得认证,而是更喜欢进行中间人MiM)攻击,假装是消息的最终接收者,并窃听传输数据的通信渠道,甚至更糟糕的是在数据流动时修改数据。

作为一种基于文本的协议,HTTP 以人类可读的格式传输数据,这使得它很容易成为 MiM 攻击的受害者。除非以加密格式传输,否则我们服务的所有目录数据都容易受到 MiM 攻击的威胁。在本节中,我们将把我们的传输方式从不安全的 HTTP 协议切换到安全的 HTTPS 协议。

HTTPS 由非对称加密,也称为公钥加密,来保护。它基于数学相关的一对密钥。用于加密的密钥称为公钥,用于解密的密钥称为私钥。其思想是自由提供加密密钥给必须发送加密消息的合作伙伴,并用私钥执行解密。

两个方,AB 之间的典型的公钥加密通信场景如下:

  1. Party A 组成一条消息,用 B 方的公钥加密,然后发送

  2. Party B 用自己的私钥解密消息并处理它

  3. Party B 组成一个响应消息,用 A 方的公钥加密,然后发送

  4. Party A 用自己的私钥解密响应消息

现在我们知道公钥加密是如何工作的,让我们通过 HTTPS 客户端-服务器通信的示例来了解一下:

客户端对 SSL 安全端点发送初始请求。服务器对该请求做出响应,发送其公钥以用于加密进一步的传入请求。然后,客户端必须检查接收到的密钥的有效性并验证其身份。在成功验证服务器的公钥之后,客户端必须将自己的公钥发送回服务器。最后,在密钥交换过程完成后,两个方可以开始安全地通信。

HTTPS 依赖于信任;因此,有一种可靠的方式来检查特定的公钥是否属于特定的服务器是至关重要的。公钥在 X.509 证书中交换,具有分层结构。这种结构使客户端能够检查给定的证书是否是由受信任的根证书生成的。客户端应该只信任由已知的证书颁发机构CA)颁发的证书。

在将我们的服务切换到使用 HTTPS 传输之前,我们需要一个公钥/私钥对。由于我们不是证书颁发机构,我们将不得不使用 OpenSSL 工具为我们生成测试密钥。

OpenSSL 可以在www.openssl.org/下载,那里提供了所有流行操作系统的源代码分发。OpenSSL 可以按照以下方式安装:

  1. 二进制分发可供 Windows 下载,Debian 和 Ubuntu 用户可以通过执行以下命令使用打包的分发:
sudo apt-get install openssl

Windows 用户需要设置一个环境变量 OPENSSL_CNF,指定openssl.cnf配置文件的位置,通常位于安装存档的共享目录中。

  1. 现在让我们用 OpenSSL 生成一个测试的键/值对:
opensslreq -x509 -nodes -days 365 -newkey rsa:2048-keyoutcatalog.pem -out catalog.crt

OpenSSL 将提示生成证书所需的一些细节,例如国家代码、城市和完全合格的域名。之后,它将在catalog.pem文件中生成一个私钥,并在catalog.crt文件中生成一个有效期为一年的公钥证书。我们将使用这些新生成的文件,所以将它们复制到目录数据服务目录中的一个名为ssl的新子目录中。

现在我们拥有了修改我们的服务以使用 HTTPS 所需的一切:

  1. 首先,我们需要切换并使用 HTTPS 模块而不是 HTTP,并指定要使用的端口以启用 HTTPS 通信:
var https = require('https');
var app = express();
app.set('port', process.env.PORT || 3443); 
  1. 然后,我们需要将catalog.cem文件中的私钥和catalog.crt中的证书读入数组中:
var options = {key : fs.readFileSync('./ssl/catalog.pem'),
                cert : fs.readFileSync('./ssl/catalog.crt')
}; 
  1. 最后,我们将包含密钥对的数组传递给创建服务器的 HTTPS 实例,并通过指定的端口开始监听:
https.createServer(options, app).listen(app.get('port'));

这就是为 Express 应用程序启用 HTTPS 所需做的一切。保存您的更改,并尝试在浏览器中请求https://localhost:3443/catalog/v2。您将看到一个警告消息,告诉您正在连接的服务器正在使用由不受信任的证书颁发机构颁发的证书。这是正常的,因为我们自己生成了证书,而且我们肯定不是 CA,所以只需忽略该警告。

在将服务部署到生产环境之前,您应始终确保使用由受信任的 CA 颁发的服务器证书。

自测问题

回答以下问题:

  • HTTP 基本身份验证是否安全防范中间人攻击?

  • 传输层安全性有哪些好处?

摘要

在本章中,您学会了如何通过启用身份验证和授权手段来保护暴露的数据。这是任何公开可用数据服务的关键方面。此外,您还学会了如何使用服务和用户之间的安全层传输协议来防止中间人攻击。作为这类服务的开发人员,您应该始终考虑应用程序应支持的最合适的安全功能。

希望这是一个有用的经验!您获得了足够的知识和实际经验,这应该使您更加自信地理解 RESTful API 的工作原理以及它们的设计和开发方式。我强烈建议您逐章阅读代码演变。您应该能够进一步重构它,使其适应您自己的编码风格。当然,它的一些部分可以进一步优化,因为它们经常重复。这是一个故意的决定,而不是良好的实践,因为我想强调它们的重要性。您应该始终努力改进您的代码库,使其更易于维护。

最后,我想鼓励您始终关注您在应用程序中使用的Node.js模块的发展。Node.js 拥有一个迅速增长的非凡社区。那里总是有一些令人兴奋的事情发生,所以确保您不要错过。祝你好运!

posted @ 2024-05-23 15:56  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报