NodeJS-REST-API-开发高级教程-全-
NodeJS REST API 开发高级教程(全)
一、REST 101
Electronic supplementary material The online version of this chapter (doi: 10.1007/978-1-4842-0917-2_1) contains supplementary material, which is available to authorized users.
如今,缩写 REST 已经成为一个流行词,因此,它被许多技术人员非常不小心地扔进了数字风中,而没有完全理解它的真正含义。仅仅因为您可以使用 HTTP 与系统交互,并来回发送 JSON,并不意味着它是 RESTful 系统。REST 远不止这些——这也是我们将在本章讨论的内容。
让我们从罗伊·菲尔丁的论文开始,回顾一下他的观点的主要特征。我将试着解释它的主要方面,他添加的约束和原因。我将回顾一些例子,然后跳回到过去,因为即使 REST 已经被证明是分布式系统互连方面的一个巨大进步,在 Fielding 的论文流行之前,开发人员仍然在寻找问题的解决方案:如何轻松地互连一组非同构系统。
我将快速回顾一下当时开发人员用来互连系统的选项,主要是 SOAP 和 XML-RPC(REST 之前的两个主要参与者)。
最后,我将跳回到我们当前的时代,比较 REST 带给我们的优势,从而展示为什么它在今天如此受欢迎。
但是首先,需要做一个小小的澄清:正如您将在几分钟后读到的,REST 是独立于协议的(只要该协议支持 URI 方案),但是出于本书的考虑,并且因为我们将重点放在 API 设计上,所以让我们假设我们使用的协议是 HTTP,这将简化解释和示例。只要你记住其他协议(如 FTP)也是如此,你就不会有问题。
这一切是从哪里开始的?
这一切都始于 1965 年出生的美国计算机科学家罗伊·菲尔丁。他是 HTTP 协议 1 (整个 Web 基础设施所基于的协议)的主要作者之一。他也是 Apache Web 服务器 2 的合著者之一,并且是 Apache 软件基金会 3 成立前三年的主席。
如你所见,Fielding 为 IT 界做出了很多巨大的贡献,尤其是在互联网方面,但我认为他的博士论文最受关注,让他的名字在很多人当中广为人知,否则他们不会听说过他。
在 2000 年,Fielding 提交了他的博士论文,架构风格和基于网络的软件架构的设计。在这本书中,他创造了术语 REST,一种分布式超媒体系统的架构风格。
简而言之,REST(表述性状态转移的缩写)是一种架构风格,旨在帮助创建和组织分布式系统。这个定义的关键词应该是风格,因为 REST 的一个重要方面(这也是像这本书存在的主要原因之一)是它是一种架构风格——不是指南,不是标准,或者任何暗示有一组硬规则要遵循以获得 RESTful 架构的东西。
因为它是一种风格,而且没有征求意见稿(RFC)来定义它,所以它容易被阅读它的人误解。不仅如此,有些人甚至省略了某些部分,并实现了其功能的一个子集,这反过来又导致了一种普遍的、不完整的 REST 理想,省略了那些原本有用的、有助于系统用户的功能。
REST 背后的主要思想是,RESTfully 组织的分布式系统将在以下方面得到改进:
- 性能:REST 提出的通信风格是高效和简单的,允许采用它的系统的性能提升。
- 组件交互的可伸缩性:任何分布式系统都应该能够足够好地处理这一方面,REST 提出的简单交互极大地考虑到了这一点。
- 界面的简单性:一个简单的界面允许系统间更简单的交互,这反过来可以带来前面提到的好处。
- 组件的可修改性:系统的分布式本质,以及 REST 提出的关注点分离(稍后将详细介绍),允许以最小的成本和风险独立地修改组件。
- 可移植性:REST 是技术和语言不可知的,这意味着它可以被任何类型的技术实现和使用(有一些限制,我稍后会详细介绍,但是没有强制使用特定的技术)。
- 可靠性:REST 提出的无状态约束(稍后将详细介绍)允许系统在故障后更容易地恢复。
- 可见性:同样,所提出的无状态约束具有提高可见性的额外好处,因为任何监控系统都不需要看得比单个请求消息更远,就可以确定所述请求的完整状态(一旦我谈到约束,这一点就变得很清楚了)。
从这个列表中,可以推断出一些直接的好处:
- 以组件为中心的设计允许您创建容错能力很强的系统。一个组件的故障不会影响系统的整体稳定性,这对任何系统都是一大好处。
- 互连组件非常容易,从而在添加新功能或扩大或缩小规模时将风险降至最低。
- 考虑到 REST 而设计的系统,由于其可移植性(如前所述),将会被更广泛的受众所使用。通过通用接口,该系统可以被更广泛的开发者使用。
为了实现这些属性和好处,REST 中添加了一组约束来帮助定义统一的连接器接口。
REST 约束
根据菲尔丁的说法,有两种方法来定义一个系统。一种是从空白的白板开始,在需求得到满足之前,对正在构建的系统或熟悉组件的使用没有任何初步的了解。第二种方法是从系统的全套需求开始,将约束添加到各个组件,直到影响系统的各种力量能够和谐地相互作用。
REST 遵循第二种方法。为了定义一个 REST 架构,首先定义一个空状态——一个没有任何约束的系统,其中组件差异只是一个神话——然后一个接一个地添加约束。
客户端-服务器
要添加的第一个约束是基于网络的体系结构中最常见的约束之一:客户机-服务器。服务器负责处理一组服务,并监听关于所述服务的请求。反过来,请求由需要这些服务之一的客户端系统通过连接器发出(参见图 1-1 )。
图 1-1。
Client-Server architecture diagram
这个约束背后的主要原则是关注点的分离。它允许将前端代码(信息的表示和可能的 UI 相关处理)从服务器端代码中分离出来,服务器端代码应该负责数据的存储和服务器端处理。
这种约束允许两个组件独立发展,通过让客户端应用在不影响服务器代码的情况下进行改进,提供了很大的灵活性,反之亦然。
无国籍的
在前一个约束之上添加的约束是无状态约束(见图 1-2 )。客户端和服务器之间的通信必须是无状态的,这意味着客户端发出的每个请求都必须包含服务器理解它所需的所有信息,而不利用任何存储的数据。
图 1-2。
Representation of the stateless constraint
这个约束代表了对底层架构的几个改进:
- 可见性:当所有需要的信息都包含在请求中时,监控系统就变得容易了。
- 可伸缩性:由于不必在请求之间存储数据,服务器可以更快地释放资源。
- 可靠性:如前所述,无状态系统比无状态系统更容易从故障中恢复,因为唯一需要恢复的是应用本身。
- 更容易实现:编写不必跨多个服务器管理存储的状态数据的代码要容易得多,因此整个服务器端系统变得更简单。
虽然乍一看这种约束似乎很好,但正如通常发生的那样,这是一种权衡。一方面,系统获得了好处,但另一方面,由于发送重复的状态信息而对每个请求增加了少量的开销,网络流量可能会受到损害。根据所实现的系统类型和重复信息的数量,这可能不是一个可接受的折衷方案。
可缓冲的
可缓存约束被添加到当前约束组中(见图 1-3 )。它建议对请求的每个响应都必须显式或隐式地设置为可缓存的(如果适用)。
图 1-3。
Representation of a client-stateless-cache-server architecture
通过缓存响应,有一些明显的好处被添加到架构中:在服务器端,当内容被缓存时,一些交互(例如,数据库请求)被完全绕过。在客户端,性能有了明显的提高。
这种约束的代价是,由于糟糕的缓存规则,缓存的数据可能会过时。同样,这种限制取决于所实现的系统的类型。
Note
图 1-3 显示了缓存作为客户端和服务器之间的外层。这只是它的一种可能的实现。缓存层可以位于客户端(即浏览器缓存)或服务器本身内部。
统一界面
与其他替代方案相比,REST 的主要特点和优势之一是统一接口约束。通过在组件之间保持统一的接口,当客户端与您的系统交互时,您简化了客户端的工作(参见图 1-4 )。这里的另一个主要优点是,客户端的实现独立于您的实现,因此,通过为您的所有服务定义一个标准和统一的接口,您可以通过为独立客户端提供一组清晰的规则来有效地简化它们的实现。
图 1-4。
Different client types can interact seamlessly with servers thanks to the uniform interface
所说的规则不是 REST 风格的一部分,但是有一些约束可以用来为每个单独的情况创建这样的规则。
然而,这种好处不是没有代价的;与许多其他约束一样,这里有一个权衡:当存在更优化的通信形式时,为所有与系统的交互提供标准化和统一的接口可能会损害性能。特别是,REST 风格是为 Web 优化而设计的,所以你离它越远,界面就越低效。
Note
为了实现统一接口,必须向接口添加一组新的约束:资源的标识、通过表示对资源的操作、自描述消息以及作为应用状态引擎的超媒体(也称为 HATEOAS)。我将很快讨论其中的一些约束。
分层系统
REST 在设计时就考虑到了互联网,这意味着遵循 REST 的架构有望与 web 中存在的大量流量一起正常工作。
为了实现这一点,引入了层的概念(见图 1-5 )。通过将组件分成不同的层,并允许每一层只使用下面的一层,并将其输出传递给上面的一层,可以简化系统的整体复杂性,并保持组件的耦合性。这在所有类型的系统中都是很大的好处,尤其是当这种系统的复杂性不断增长时(例如,具有大量客户端的系统、当前正在发展的系统等)。).
图 1-5。
Example of a multilayered architecture
这种限制的主要缺点是,对于小型系统,由于各层之间的交互不同,它可能会给整个数据流增加不必要的延迟。
按需编码
按需编码是 REST 强加的唯一可选约束,这意味着使用 REST 的架构师可以选择是否使用该约束,要么获得其优点,要么遭受其缺点。
有了这个约束,客户端就可以下载并执行服务器提供的代码(如 Java 小程序、JavaScript 脚本等。).在 REST APIs 的情况下(这也是本书关注的重点),这种约束似乎是不必要的,因为 API 客户端要做的正常事情只是从端点获取信息,然后根据需要进行处理;但是对于 REST 的其他用途,比如 web 服务器,客户端(比如浏览器)可能会从这个约束中受益(见图 1-6 )。
图 1-6。
How some clients might execute the code-on-demand, whereas others might not
所有这些约束提供了一组虚拟的墙,架构可以在其中移动,并且仍然可以获得 REST 设计风格的好处。
但是让我们后退一步。我最初将 REST 定义为表述性状态转移的设计风格;换句话说,你通过使用某种表示来转移事物的状态。但是这些“东西”是什么呢?REST 架构的主要焦点是资源,即您正在转移的状态的所有者。就像在现实状态下(差不多),都是资源,资源,资源。
资源,资源,资源
REST 架构的主要构件是资源。任何可以命名的东西都可以是资源(网页、图像、人、气象服务报告等。).资源定义了服务的内容、要传输的信息类型以及它们的相关动作。资源是万物诞生的主要实体。
资源是任何可以概念化的东西的抽象(从图像文件到纯文本文档)。资源的结构如表 1-1 所示。
表 1-1。
Resource Structure Description
| 财产 | 描述 | | --- | --- | | 陈述 | 它可以是任何表示数据的方式(二进制、JSON、XML 等。).一个资源可以有多个表示。 | | 标识符 | 在任何给定时间只检索一个特定资源的 URL。 | | [计]元数据 | 内容类型、最后修改时间等等。 | | 控制数据 | 是-可修改的-自,缓存-控制。 |陈述
其核心是一组字节和一些描述这些字节的元数据。一个资源可以有多个表示形式;想象一下气象服务报告(它可以作为一种可能的资源)。
某一天的天气预报可能会返回以下信息:
- 报告引用的日期
- 一天的最高温度
- 一天的最低温度
- 要使用的温度单位
- 湿度百分比
- 表示天气多云程度的代码(例如,高、中、低)
既然已经定义了资源的结构,下面是同一资源的几种可能的表示形式:
JSON
{
"date": "2014-10-25",
"max_temp": 25.5,
"min_temp": 10.0,
"temp_unit": "C",
"humidity_percentage": 75.0,
"cloud_coverage": "low"
}
XML
<?xml version='1.0' encoding='UTF-8' ?>
<root>
<temp_unit value="C" />
<humidity_percentage value="75.0" />
<cloud_coverage value="low" />
<date value="2014-10-25" />
<min_temp value="10.0" />
<max_temp value="25.5" />
</root>
自定义管道分隔值:
2014-10-25|25.5|10.0|C|75.0|low
可能还有更多。它们都成功地正确表示了资源;由客户机来读取和解析信息。即使当资源有多个表示时,客户机(由于开发的简单性)通常只请求其中的一个。除非您正在对 API 进行某种一致性检查,否则请求同一资源的多个表示是没有意义的,不是吗?
有两种非常流行的方法让客户机请求一个资源上的特定表示,这个资源有不止一个。第一种直接遵循 REST 描述的原则(当使用 HTTP 作为基础时),称为内容协商,是 HTTP 标准的一部分。第二个是这个的简化版,好处有限。为了完整起见,我将快速浏览一下它们。
内容协商
如前所述,这种方法是 HTTP 标准的一部分, 5 所以它是 REST 的首选方式(至少当专注于 HTTP 之上的 API 开发时)。它也比其他方法更灵活,并提供更多优势。
它包括客户端发送一个特定的报头,该报头带有所支持的不同内容类型(或表示类型)的信息,还带有一个可选的指示符,指示该格式受支持/首选的程度。让我们来看一个来自维基百科“内容协商”页面的例子:
Accept: text/html; q=1.0, text/*; q=0.8, image/gif; q=0.6, image/jpeg; q=0.6, image/*; q=0.5, */*; q=0.1
该示例来自一个浏览器,该浏览器配置为接受各种类型的资源,但更喜欢 HTML 而不是纯文本,更喜欢 GIF 或 JPEG 图像而不是其他类型,但最终还是接受任何其他内容类型作为最后手段。
在服务器端,API 负责读取这个头,并根据客户机的偏好为每个资源找到最佳表示。
使用文件扩展名
尽管这种方法不是 REST 建议的风格的一部分,但它被广泛使用,并且是相对于稍微复杂一些的其他选项的一种相当简单的替代方法,所以我还是会介绍它。
在过去的几年中,使用文件扩展名已经成为一种比使用内容协商更受欢迎的选择;这是一个更简单的版本,它不依赖于发送的标题,而是使用文件扩展名的概念。
文件名的扩展名部分向操作系统和试图使用它的任何其他软件指示内容类型;因此,在下面的例子中,添加到资源的 URL(唯一标识符)的扩展向服务器指示所需的表示类型。
GET /api/v1/books
.json
GET /api/v1/books
.xml
两个标识符引用相同的资源——图书列表,但是它们请求不同的表示。
Note
这种方法可能看起来更容易实现,甚至更容易被人理解,但是它缺乏内容协商所带来的灵活性,只有在不需要复杂的情况下才应该使用这种方法,在这种情况下,可以用相关的首选项指定多种内容类型。
资源标识符
资源标识符应该在任何给定时刻提供唯一的标识方式,并且应该提供资源的完整路径。一个典型的错误是假设它是所使用的存储介质上的资源 ID(即数据库上的 ID)。这意味着您不能将简单的数字 ID 视为资源标识符;您必须提供完整的路径,因为我们是基于 HTTP 的 REST,所以访问资源的方式需要提供完整的 URI(唯一资源标识符)。
还有一个方面需要考虑:每个资源的标识符必须能够在任何给定的时刻明确地引用它。这是一个重要的区别,因为像下面这样的 URI 可能会在某段时间引用《哈利·波特与混血王子》,然后在一年后引用《哈利·波特与死亡之谷》。:
GET /api/v1/books/last
这使得 URI 成为无效的资源 id。相反,每本书都需要一个独特的 URI,它肯定不会随着时间的推移而改变;例如:
GET /api/v1/books
/j-k-rowling/harry-potter-and-the-deathly-hollows
GET /api/v1/books
/j-k-rowling/harry-potter-and-the-half-blood-prince
这里的标识符是唯一的,因为您可以放心地假设作者不会出版更多同名书籍。
为了提供获取最后一本书的有效示例,您可以考虑这样做:
GET /api/v1/books?limit=1&sort=created_at
前面的 URI 引用图书列表,它只要求一本书,按出版日期排序,从而呈现添加的最后一本书。
行动
识别资源很容易:您知道如何访问它,甚至知道如何请求特定的格式(如果有多个格式的话);但这并不是 REST 提出的全部。由于 REST 使用 HTTP 协议作为立足点,后者提供了一组动词,可用于引用在资源上执行的操作类型。
除了访问,客户端应用还可以对 API 提供的资源进行其他操作;这些依赖于 API 提供的服务。这些动作可能是任何东西,就像系统处理的资源类型一样。尽管如此,任何面向资源的系统都应该能够提供一组通用操作:CRUD(创建、检索、更新和删除)操作。
这些所谓的动作可以直接映射到 HTTP 动词,但是 REST 并没有强制采用一种标准化的方式来实现。然而,有一些动作是由动词自然派生出来的,还有一些动作是 API 开发社区多年来已经标准化的,如表 1-2 所示。
表 1-2。
HTTP Verbs and Their Proposed Actions
| HTTP 动词 | 提议的行动 | | --- | --- | | 得到 | 以只读模式访问资源。 | | 邮政 | 通常用于向服务器发送新资源(创建动作)。 | | 放 | 通常用于更新给定的资源(更新操作)。 | | 删除 | 用于删除资源。 | | 头 | 不是 CRUD 操作的一部分,但是动词用于询问给定的资源是否存在,而不返回它的任何表示。 | | 选择 | 不是 CRUD 动作的一部分,但是用于检索给定资源上的可用动词列表(例如,客户端可以用特定资源做什么?). |也就是说,客户端可能支持也可能不支持所有这些操作;这取决于需要实现什么目标。例如,web 浏览器——REST 客户端的一个明显而常见的例子——只支持页面 HTML 代码中的 GET 和 POST 动词,比如链接和表单(尽管使用 JavaScript 中的XMLHTTPRequest
对象可以为前面提到的主要动词提供支持)。
Note
动词及其相应动作的列表是建议。例如,有些开发人员喜欢切换 PUT 和 POST,让 PUT 添加新元素,POST 更新它们。
复杂动作
CRUD 操作通常是必需的,但它们只是客户端可以对特定资源或资源集执行的全部操作中非常小的一部分。
例如,采取常见的操作,如搜索、过滤、处理子资源(例如,某个作者的书、某本书的评论等。)、分享博客等等。所有这些动作都无法直接匹配我提到的一个动词。
许多开发人员屈服的第一个解决方案是将所采取的动作指定为 URL 的一部分;因此,您可能会得到如下结果:
GET /api/v1/blogpost/12342
/like
GET /api/v1/books
/search
GET /api/v1/authors
/filtering
这些 URL 违反了 URI 原则,因为它们在任何给定的时间都没有引用唯一的资源;相反,它们引用的是对一个资源(或一组资源)的操作。起初,它们似乎是一个好主意,但从长远来看,如果系统继续增长,将会有太多的 URL,这将增加使用 API 的客户端的复杂性。
所以为了简单起见,使用下面的经验法则:把你行为的复杂性隐藏在?签名。
这条规则可以应用于所有动词,而不仅仅是 GET,并且可以帮助实现复杂的操作,而不会影响 API 的 URL 复杂性。对于前面的示例,URIs 可能会变成这样:
PUT /api/v1/blogposts/12342
?action=like
GET /api/v1/books
?q=[SEARCH-TERM]
GET /api/v1/authors
?filters=[COMMA SEPARATED LIST OF FILTERS]
注意第一个动作是如何从 GET 变成 PUT 的,因为这个动作是通过 like 来更新资源的。
超媒体的回应和主要切入点
为了使 REST 的接口统一,必须应用几个约束。其中之一是作为应用状态引擎的超媒体,也称为 HATEOAS。我将介绍这个概念的含义,RESTful 系统如何应用它,最后,您将看到一个伟大的新特性,它允许任何 RESTful 系统客户端在只知道整个系统的一个端点(根端点)的情况下开始交互。
同样,资源的结构包含一个称为元数据的部分;在这个部分中,每个资源的表示应该包含一组超媒体链接,让客户知道如何处理每个资源。通过在响应本身中提供这些信息,任何客户端都可以采取下一步措施,从而在客户端和服务器之间提供更高级别的解耦。
通过这种方法可以提供对资源标识符的改变,或者添加和删除功能,而根本不影响客户端,或者在最坏的情况下,影响最小。
想象一个网络浏览器:它只需要帮助用户浏览一个喜欢的网站的主页 URL 之后,以下动作在表示(HTML 代码)中以链接的形式出现。这些是用户可以采取的唯一合乎逻辑的后续步骤,从那里,新的链接将被呈现,等等。
在 RESTful 服务的情况下,同样的事情也可以说:通过调用主端点(也称为书签或根端点),客户端将发现所有可能的第一步(通常是资源列表和其他相关端点)。
让我们看看清单 1-1 中的一个例子。
根端点:GET /api/v1/
Listing 1-1. Example of a JSON Response from the Root Endpoint
{
"元数据":{
"链接":{
“书籍”:{
" uri": "/books ",
"内容类型":"应用/json "
},
"作者":{
" uri": "/authors ",
"内容类型":"应用/json "
}
}
}
}
书籍列表端点:GET /api/v1/books
Listing 1-2. Example of Another JSON Response with Hyperlinks to Other Resources
{
"resources": [
{
"title": "Harry Potter and the Half Blood prince",
"description": "......",
"author": {
"name": "J.K.Rowling",
"metadata": {
"links": {
"data": {
"uri": "/authors/j-k-rowling",
"content-type": "application/json"
},
"books": {
"uri": "/authors/j-k-rowling/books",
"content-type": "application/json"
}
}
}
},
"copies": 10
},
{
"title": "Dune",
"description": "......",
"author": {
"name": "Frank Herbert",
"metadata": {
"links": {
"data": {
"uri": "/authors/frank-herbert",
"content-type": "application/json"
},
"books": {
"uri": "/authors/frank-herbert/books",
"content-type": "application/json"
}
}
}
},
"copies": 5
}
],
"total": 100,
"metadata": {
"links": {
"next": {
"uri": "/books?page=1",
"content-type": "application/json"
}
}
}
}
清单 1-2 中突出显示了三个部分;这些是响应中返回的链接。有了这些信息,客户端应用就知道了以下逻辑步骤:
How to get the information from the books authors How to get the list of books by the authors How to get the next page of results
请注意,无法通过此端点访问作者的完整列表;这是因为在这个特定的用例中不需要它,所以 API 不返回它。但是它出现在根端点上;因此,如果客户端在向最终用户显示信息时需要它,它应该仍然可用。
前面示例中的每个链接都包含一个指定该资源表示的内容类型的属性。如果资源有不止一种可能的表示,不同的格式可以作为不同的链接添加到每个资源的元数据元素中,让客户端选择最适合当前用例的,或者类型可以基于客户端的偏好而改变(内容协商)。
注意,早期的 JSON 结构(更具体地说,元数据元素的结构)并不重要。示例的相关部分是响应中提供的信息。每台服务器都可以根据需要自由设计结构。
没有标准的结构可能会损害开发人员在与系统交互时的体验,所以采用一个标准的结构可能是个好主意。REST 当然不会强制执行这一点,但这将是支持您的系统的一个要点。在这种情况下,采用超文本应用语言(Hypertext Application Language,简称 HAL, 6 是一个很好的标准,它试图在用 XML 和 JSON 表示资源时,为这两种语言创建一个标准。
关于哈尔的几点注记
HAL 试图将一个表示定义为具有两个主要元素:资源和链接。
根据 HAL 的说法,资源有链接、嵌入资源(其他与其父资源相关的资源)和状态(描述资源的实际属性)。另一方面,链接有一个目标(URI)、一个关系和一些其他可选属性来处理弃用、内容协商等等。
清单 1-3 显示了前面使用 HAL 格式表示的例子。
Listing 1-3. JSON Response Following the HAL Standard
{
"_embedded": [
{
"title": "Harry Potter and the Half Blood prince",
"description": "......",
"copies": 10,
"_embedded": {
"author": {
"name": "J.K.Rowling",
"_links": {
"self": {
"href": "/authors/j-k-rowling",
"type": "application/json+hal"
},
"books": {
"href": "/authors/j-k-rowling/books",
"type": "application/json+hal"
}
}
}
}
},
{
"title": "Dune",
"description": "......",
"copies": 5,
"_embedded": {
"author": {
"name": "Frank Herbert",
"_links": {
"self": {
"href": "/authors/frank-herbert",
"type": "application/json+hal"
},
"books": {
"href": "/authors/frank-herbert/books",
"type": "application/json+hal"
}
}
}
}
}
],
"total": 100,
"_links": {
"self": {
"href": "/books",
"type": "application/json+hal"
},
"next": {
"href": "/books?page=1",
"type": "application/json+hal"
}
}
}
清单 1-3 中的主要变化是,实际的书籍被移到了一个名为"_embedded"
的元素中,正如标准所规定的,因为它们实际上是所表示的资源(即书籍列表)中的嵌入文档(属于资源的唯一属性是"total"
,表示结果总数)。对于作者来说也是一样,现在在每本书的"_embedded"
元素里面。
状态代码
基于 HTTP 的 REST 可以受益的另一个有趣的标准是 HTTP 状态代码的使用。 7
状态代码是一个总结与其相关的响应的数字。有一些常见的错误,比如 404 表示“找不到页面”,200 表示“正常”,500 表示“内部服务器错误”(这是一个讽刺,以防不够清楚)。
状态代码有助于客户端开始解释响应,但在大多数情况下,它不应该是它的替代品。作为 API 所有者,您不能仅仅通过回复数字 500 来真正地在响应中传递到底是什么导致了您这边的崩溃。不过,在某些情况下,一个数字就足够了,比如 404;虽然一个好的响应总是会返回应该帮助客户解决问题的信息(对于 404,到主页的链接或根 URL 是很好的起点)。
这些代码根据其含义分为五组:
- 1xx:信息性的,仅在 HTTP 1.1 下定义。
- 2xx:请求通过,这是您的内容。
- 3xx:资源不知何故被移动到了某个地方。
- 4xx:请求的来源做了一些错误的事情。
- 5xx:服务器由于代码错误而崩溃。
考虑到这一点,表 1-3 列出了一些 API 可能使用的经典状态代码。
表 1-3。
HTTP Status Codes and Their Related Interpretation
| 状态代码 | 意义 | | --- | --- | | Two hundred | 好的。请求很顺利,请求的内容被返回。这通常用于 GET 请求。 | | Two hundred and one | 已创建。资源已创建,服务器已确认。这对于响应 POST 或 PUT 请求可能很有用。此外,新资源可以作为响应体的一部分返回。 | | Two hundred and four | 没有内容。操作成功,但没有返回任何内容。适用于不需要响应正文的操作,如删除操作。 | | Three hundred and one | 永久移动。该资源被移动到另一个位置,并且该位置被返回。当 URL 随时间变化时(可能是由于版本变化、迁移或一些其他破坏性变化),这个头特别有用,保留旧的 URL 并返回到新位置的重定向允许旧客户端在自己的时间内更新它们的引用。 | | four hundred | 错误的请求。发出的请求有问题(例如,可能缺少一些必需的参数)。对 400 响应的一个很好的补充可能是一个错误消息,开发人员可以用它来修复请求。 | | Four hundred and one | 未经授权。当拥有请求的用户无法访问所请求的资源时,这对于身份验证尤其有用。 | | Four hundred and three | 禁止。资源不可访问,但与 401 不同,身份验证不会影响响应。 | | Four hundred and four | 没有找到。提供的 URL 未标识任何资源。对这个响应的一个很好的补充是一组有效的 URL,客户端可以使用它们回到正轨(根 URL、以前使用的 URL 等。). | | Four hundred and five | 不允许使用方法。不允许在资源上使用 HTTP 谓词。例如,对只读资源执行 PUT 操作。 | | Five hundred | 内部服务器错误。遇到意外情况且服务器崩溃时的一般错误代码。通常,此响应会伴随一条错误消息,解释发生了什么问题。 |Note
要查看 HTTP 状态代码的完整列表及其含义,请参考 HTTP 1.1 的 RFC。 8
安息与过去
在 REST 流行之前,每个企业都希望在服务中为客户提供 RESTful API,对于希望实现系统互联的开发人员来说,还有其他选择。这些仍然被用在旧的服务上,或者被需要它们特定特性的服务使用,但是每年越来越少。
早在 20 世纪 90 年代,软件行业就开始考虑系统互操作性以及两台(或更多)计算机如何实现它。一些解决方案诞生了,比如微软创造的 COM、 9 ,对象管理组创造的 CORBA、 10 。这是当时最早的两个实现,但是它们有一个主要问题:它们彼此不兼容。
其他的解决方案也出现了,比如 RMI,但是它是专门针对 Java 的,这意味着它依赖于技术,并没有真正赶上开发社区。
到 1997 年,微软决定研究将 XML 作为主要传输语言的解决方案,并允许系统通过 HTTP 使用 RPC(远程过程调用)进行互连,从而实现某种程度上与技术无关的解决方案,这将大大简化系统互连。这项研究在 1998 年左右催生了 XML-RPC。
清单 1-4 是一个经典的 XML-RPC 请求,摘自维基百科 11 :
Listing 1-4. Example of an XML-RPC Request
<?xml version="1.0"?>
<methodCall>
<methodName>examples.getStateName</methodName>
<params>
<param>
<value><i4>40</i4></value>
</param>
</params>
</methodCall>
清单 1-5 显示了一个可能的响应。
Listing 1-5. Example of an XML-RPC Response
<?xml version="1.0"?>
<methodResponse>
<params>
<param>
<value><string>South Dakota</string></value>
</param>
</params>
</methodResponse>
从清单 1-4 和清单 1-5 所示的例子中,很明显消息(请求和响应)过于冗长,这与 XML 的使用直接相关。XML-RPC 的实现目前已经存在于几种操作系统和编程语言中,如 Apache XML-RPC 12 (用 Java 编写)、XMLRPC-EPI 13 (用 C 编写),以及用于 C 和 C++的 XML-RPC-C 14 (参见图 1-7 )。
图 1-7。
Diagram showing the basic architecture of an XML-RPC interaction
在 XML-RPC 变得更加流行之后,它变异成了 SOAP, 15 同一原则的一个更加标准化和形式化的版本。SOAP 仍然使用 XML 作为传输语言,但是消息格式现在更加丰富(因此也更加复杂)。清单 1-6 是 W3C 关于 SOAP 的规范页面中的一个例子:
Listing 1-6. Example of a SOAP Request
<?xml version='1.0' ?>
<env:Envelope xmlns:env="
http://www.w3.org/2003/05/soap-envelope
<env:Header>
<m:reservation xmlns:m="
http://travelcompany.example.org/reservation
env:role="
http://www.w3.org/2003/05/soap-envelope/role/next
env:mustUnderstand="true">
<m:reference>uuid:093a2da1-q345-739r-ba5d-pqff98fe8j7d</m:reference>
<m:dateAndTime>2001-11-29T13:20:00.000-05:00</m:dateAndTime>
</m:reservation>
<n:passenger xmlns:n="
http://mycompany.example.com/employees
env:role="
http://www.w3.org/2003/05/soap-envelope/role/next
env:mustUnderstand="true">
<n:name>Åke Jógvan Øyvind</n:name>
</n:passenger>
</env:Header>
<env:Body>
<p:itinerary
xmlns:p="
http://travelcompany.example.org/reservation/travel
<p:departure>
<p:departing>New York</p:departing>
<p:arriving>Los Angeles</p:arriving>
<p:departureDate>2001-12-14</p:departureDate>
<p:departureTime>late afternoon</p:departureTime>
<p:seatPreference>aisle</p:seatPreference>
</p:departure>
<p:return>
<p:departing>Los Angeles</p:departing>
<p:arriving>New York</p:arriving>
<p:departureDate>2001-12-20</p:departureDate>
<p:departureTime>mid-morning</p:departureTime>
<p:seatPreference/>
</p:return>
</p:itinerary>
<q:lodging
xmlns:q="
http://travelcompany.example.org/reservation/hotels
<q:preference>none</q:preference>
</q:lodging>
</env:Body>
</env:Envelope>
图 1-8 显示了清单 1-6 中示例的基本结构。
图 1-8。
Image from the W3C SOAP spec page
SOAP 服务实际上依赖于另一种叫做 Web 服务描述语言(WSDL)的技术。作为一种基于 XML 的语言,它描述了提供给想要使用它们的客户的服务。
清单 1-7 是摘自 W3C 网站的一个带注释的 WSDL 示例。 16
Listing 1-7. WSDL Example
<?xml version="1.0"?>
<!-- root element wsdl:definitions defines set of related services -->
<wsdl:definitions name="EndorsementSearch"
targetNamespace="
http://namespaces.snowboard-info.com
xmlns:es="
http://www.snowboard-info.com/EndorsementSearch.wsdl
xmlns:esxsd="
http://schemas.snowboard-info.com/EndorsementSearch.xsd
xmlns:soap="
http://schemas.xmlsoap.org/wsdl/soap/
xmlns:wsdl="
http://schemas.xmlsoap.org/wsdl/
<!-- wsdl:types encapsulates schema definitions of communication types; here using xsd -->
<wsdl:types>
<!-- all type declarations are in a chunk of xsd -->
<xsd:schema targetNamespace="
http://namespaces.snowboard-info.com
xmlns:xsd="
http://www.w3.org/1999/XMLSchema
<!-- xsd definition: GetEndorsingBoarder [manufacturer string, model string] -->
<xsd:element name="GetEndorsingBoarder">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="manufacturer" type="string"/>
<xsd:element name="model" type="string"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<!-- xsd definition: GetEndorsingBoarderResponse [. . . endorsingBoarder string . . .] -->
<xsd:element name="GetEndorsingBoarderResponse">
<xsd:complexType>
<xsd:all>
<xsd:element name="endorsingBoarder" type="string"/>
</xsd:all>
</xsd:complexType>
</xsd:element>
<!-- xsd definition: GetEndorsingBoarderFault [. . . errorMessage string . . .] -->
<xsd:element name="GetEndorsingBoarderFault">
<xsd:complexType>
<xsd:all>
<xsd:element name="errorMessage" type="string"/>
</xsd:all>
</xsd:complexType>
</xsd:element>
</xsd:schema>
</wsdl:types>
<!-- wsdl:message elements describe potential transactions -->
<!-- request GetEndorsingBoarderRequest is of type GetEndorsingBoarder -->
<wsdl:message name="GetEndorsingBoarderRequest">
<wsdl:part name="body" element="esxsd:GetEndorsingBoarder"/>
</wsdl:message>
<!-- response GetEndorsingBoarderResponse is of type GetEndorsingBoarderResponse -->
<wsdl:message name="GetEndorsingBoarderResponse">
<wsdl:part name="body" element="esxsd:GetEndorsingBoarderResponse"/>
</wsdl:message>
<!-- wsdl:portType describes messages in an operation -->
<wsdl:portType name="GetEndorsingBoarderPortType">
<!-- the value of wsdl:operation eludes me -->
<wsdl:operation name="GetEndorsingBoarder">
<wsdl:input message="es:GetEndorsingBoarderRequest"/>
<wsdl:output message="es:GetEndorsingBoarderResponse"/>
<wsdl:fault message="es:GetEndorsingBoarderFault"/>
</wsdl:operation>
</wsdl:portType>
<!-- wsdl:binding states a serialization protocol for this service -->
<wsdl:binding name="EndorsementSearchSoapBinding"
type="es:GetEndorsingBoarderPortType">
<!-- leverage off soap:binding document style @@@(no wsdl:foo pointing at the soap binding) -->
<soap:binding style="document"
transport="
http://schemas.xmlsoap.org/soap/http
<!-- semi-opaque container of network transport details classed by soap:binding above @@@ -->
<wsdl:operation name="GetEndorsingBoarder">
<!-- again bind to SOAP? @@@ -->
<soap:operation soapAction="
http://www.snowboard-info.com/EndorsementSearch
<!-- furthur specify that the messages in the wsdl:operation "GetEndorsingBoarder" use SOAP? @@@ -->
<wsdl:input>
<soap:body use="literal"
namespace="
http://schemas.snowboard-info.com/EndorsementSearch.xsd
</wsdl:input>
<wsdl:output>
<soap:body use="literal"
namespace="
http://schemas.snowboard-info.com/EndorsementSearch.xsd
</wsdl:output>
<wsdl:fault>
<soap:body use="literal"
namespace="
http://schemas.snowboard-info.com/EndorsementSearch.xsd
</wsdl:fault>
</wsdl:operation>
</wsdl:binding>
<!-- wsdl:service names a new service "EndorsementSearchService" -->
<wsdl:service name="EndorsementSearchService">
<wsdl:documentation>snowboarding-info.com Endorsement Service </wsdl:documentation>
<!-- connect it to the binding "EndorsementSearchSoapBinding" above -->
<wsdl:port name="GetEndorsingBoarderPort"
binding="es:EndorsementSearchSoapBinding">
<!-- give the binding an network address -->
<soap:address location="
http://www.snowboard-info.com/EndorsementSearch
</wsdl:port>
</wsdl:service>
</wsdl:definitions>
这些类型的服务的主要缺点是所使用的信息量,包括描述它们和使用它们。尽管 XML 提供了对在两个系统之间传输的数据进行编码的技术不可知的方法,但它也明显地掩盖了所发送的消息。
这两种技术(XML-RPC 和 SOAP + WSDL)在需要的时候为系统互连性提供了解决方案。他们提供了一种在所有系统之间使用“通用”语言传输消息的方法,但与当今的领先标准相比,他们也有几个主要问题(见表 1-4 )。这可以清楚地看到,例如,开发人员对使用 XML 而不是 JSON 的感觉。
表 1-4。
Comparison of XML-RPC/SOAP and REST Services
| XML-RCP / SOAP | REST | | --- | --- | | 必须为每种编程语言创建特定的 SOAP 客户端。即使 XML 是通用的,新的客户端也必须编写代码来解析 WSDL,以理解服务是如何工作的。 | REST 完全与技术无关,不需要特殊的客户端,只需要一种能够通过所选协议(例如 HTTP、FTP 等)进行连接的编程语言。). | | 客户端需要在开始交互之前了解关于服务的一切(因此前面提到了 WSDL)。 | 客户机只需要知道主根端点,有了响应上提供的超媒体,自我发现就成为可能。 | | 因为服务是从客户机源代码中使用的,并从服务器代码中调用特定的函数或方法,所以这两个系统之间的耦合太大了。服务器代码的重写可能会导致客户端代码的重写。 | 该接口与实现无关;完整的服务器端代码可以重写,API 的接口也不必改变。 |Note
将 XML-RPC/SOAP 与 REST 进行比较可能不完全公平(或可能),因为前两者是协议,而后者是架构风格;但是如果你记住这个区别,有些点还是可以比较的。
摘要
本章简要概述了 REST 的含义以及遵循 REST 风格会给系统带来什么样的好处。这一章还介绍了一些额外的原则,比如 HTTP 动词和状态代码,它们虽然不是 REST 风格的一部分,但确实是 HTTP 标准的一部分,这是本书所基于的协议。
最后,我讨论了 REST 之前使用的主要技术,您看到了它们与当前领先的行业标准的比较。
在下一章,我将回顾一些好的 API 设计实践,你将看到如何使用 REST 来实现它们。
Footnotes 1
https://www.ietf.org/rfc/rfc2616.txt
见。
2
3
4
http://www.ics.uci.edu/∼fielding/pubs/dissertation/rest_arch_style.htm
见。
5
http://tools.ietf.org/html/rfc7231#section-5.3
见。
6
http://stateless.co/hal_specification.html
见。
7
http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
见。
8
http://tools.ietf.org/html/rfc7231#section-6
见。
9
http://www.microsoft.com/com/default.mspx
见。
10
11
http://en.wikipedia.org/wiki/XML-RPC
见。
12
http://ws.apache.org/xmlrpc/
见。
13
http://xmlrpc-epi.sourceforge.net
见。
14
http://xmlrpc-c.sourceforge.net/
见。
15
16
http://www.w3.org/2001/03/14-annotated-WSDL-examples
见。
二、API 设计最佳实践
API 设计的实践是一件棘手的事情。即使有如此多的选择——使用的工具、应用的标准、遵循的风格——在开始任何类型的设计和开发之前,有一个基本问题需要回答,并且需要在开发人员的头脑中弄清楚……
什么定义了一个好的 API?
众所周知,“好”和“坏”的概念是非常主观的(一个人可能会读几本书来讨论这个问题),因此,不同的人有不同的观点。也就是说,多年来处理不同种类 API 的经验让开发人员社区(以及本文作者)对任何好的 API 都必须具备的特性有了很好的认识。(免责声明:像干净的代码、良好的开发实践和其他内部考虑的事情在这里不会被提及,但是会被假定,因为它们应该是每个软件任务的一部分。)
所以让我们检查一下这个列表。
- 开发者友好:使用你的 API 的开发者在处理你的系统时不应该受到影响。
- 可扩展性:您的系统应该能够在不破坏客户端的情况下处理新特性的添加。
- 最新的文档:好的文档是新开发人员获得 API 的关键。
- 适当的错误处理:因为事情会出错,你需要做好准备。
- 提供多个 SDK/库:您为开发人员简化的工作越多,他们就会越喜欢您的系统。
- 安全:任何全球系统的一个关键方面。
- 可伸缩性:伸缩能力是任何好的 API 都应该具备的正确提供服务的能力。
我将逐一讨论这些要点,并展示它们如何影响 API,以及遵循 REST 风格如何有所帮助。
开发者友好
根据定义,API 是一个应用编程接口,关键词是接口。当考虑设计一个除了你自己以外的开发人员使用的 API 时,有一个关键的方面需要考虑:开发人员体验(或 DX)。
即使当 API 将被另一个系统使用时,集成到该系统中也是首先由一个或多个开发人员完成的——将人的因素带入该集成中的人。这意味着您希望 API 尽可能易于使用,这有助于实现出色的 DX,并且应该转化为更多的开发人员和客户端应用使用 API。
然而,这是一种权衡,因为为人类简化事物可能导致界面的过度简化,这反过来可能导致处理复杂功能时的设计问题。
将 DX 作为 API 的主要方面之一来考虑是很重要的(老实说,没有开发人员使用它,API 就没有任何意义),但是在设计决策中还必须考虑其他一些方面。简单一点,但不要简单到假装。
接下来的部分提供了一些好的诊断指南。
通信协议
这是界面最基本的方面之一。在选择通信协议时,使用 API 开发人员熟悉的协议总是一个好主意。有几个标准已经在许多编程语言(例如 HTTP、FTP、SSH 等)中提供了库和模块。).
定制的协议并不总是一个好主意,因为在如此多的现有技术中,你会失去即时的可移植性。也就是说,如果您准备为最常用的语言创建支持库,并且您的定制协议对您的用例更有效,那么它可能是正确的选择。
最后,由 API 设计人员根据他工作的环境来评估最佳解决方案。
在本书中,您假设为 REST 选择的协议是 HTTP。 1 这是一个非常知名的协议;任何现代编程语言都支持它,它是整个互联网的基础。你可以放心,大多数开发者对如何使用它有基本的了解。如果没有,有大量的信息可以让你更好地了解它。
总之,没有完美的银弹协议适用于所有场景。考虑你的 API 需求,确保你选择的任何东西都与 REST 兼容,你就没事了。
易于记忆的接入点
所有客户端应用和 API 之间的接触点称为端点。API 需要提供它们来允许客户端访问它的功能。这可以通过选择任何通信协议来实现。这些访问点应该有一个简单的名字来帮助开发者理解它们的用途。
当然,名称本身不应该替代详细的文档,但是引用正在使用的资源,并在调用该访问点时提供某种操作指示,通常被认为是一个好主意。
下面是一个命名不当的访问点的好例子(用于列出书店中的书籍):
GET /books/action1
这个例子使用 HTTP 协议来指定访问点,即使使用的实体(books)被引用,动作名也不清楚;可能意味着任何事情,甚至更糟的是,这个意思将来可能会改变,但这个名字仍然合适,所以任何现有的客户无疑都会破产。
一个更好的例子——遵循 REST 和第一章中讨论的标准——是这样的:
GET /books
这将为开发人员提供足够多的信息,让他们理解对资源根(/books
)的 GET 请求将总是产生这种类型的项目列表;然后,开发人员可以将这种模式复制到其他资源中,只要接口在所有其他端点上保持一致。
统一界面
易于记忆的接入点很重要,但是在定义接入点时保持一致也很重要。同样,在使用 API 时,您必须回到人的因素:您拥有它。因此,让他们的生活更容易是必须的,如果你想让任何人使用它,你不能忘记的 DX。这意味着在定义端点名称、请求格式和响应格式时,您需要保持一致。后两者可以有多个(更具体地说,响应格式与资源可以拥有的各种表示直接相关),但是只要缺省值始终相同,就不会有问题。
不一致接口的一个很好的例子,即使不在 API 上,也可以在编程语言 PHP 中看到。它在大多数函数名上都有下划线符号,但是在一些函数名中没有使用下划线,所以开发人员总是被迫回到文档中检查如何编写这些函数(或者更糟,依靠他/她的记忆)。
例如,str_replace
是一个使用下划线分隔两个单词(str
和replace
)的函数,而htmlentities
根本没有单词分隔。
API 中糟糕的设计实践的另一个例子是基于所采取的动作而不是所处理的资源来命名端点;例如:
/getAllBooks
/submitNewBook
/updateAuthor
/getBooksAuthors
/getNumberOfBooksOnStock
这些例子清楚地显示了这个 API 遵循的模式。乍看起来,它们可能没有那么糟糕,但是考虑一下,随着新的特性和资源被添加到系统中,界面会变得多么糟糕(更不用说如果动作被修改)。系统的每一个新增加都会导致 API 接口的额外端点。客户端应用的开发者对于这些新的端点是如何命名的毫无头绪。例如,如果扩展 API 以支持书籍的封面图像,使用当前的命名方案,这些都是可能的新端点:
/addNewImageToBook
/getBooksImages
/addCoverImage
/listBooksCovers
这样的例子不胜枚举。因此,对于任何现实世界的应用,您可以放心地假设,遵循这种类型的模式将产生一个非常大的端点列表,增加服务器端代码和客户端代码的复杂性。这也将损害系统捕获新开发者的能力,因为多年来它将具有继承的复杂性。
为了解决这个问题,并在整个 API 中生成一个易于使用的统一接口,您可以将 REST 风格应用于端点。如果你还记得 REST 在第一章提出的约束,你会得到一个以资源为中心的接口。多亏了 HTTP,你还可以用动词来表示动作。
表 2-1 展示了使用 REST 之前的界面是如何变化的。
表 2-1。
List of Endpoints and How They Change When the REST Style Is Applied
| 旧历法 | REST 风格 | | --- | --- | | /getAllBooks | 获取/书籍 | | /submitNewBook | 邮件/书籍 | | /updateAuthor | 上传/作者/:id | | /getBooksAuthors | GET /books/:id/authors | | /getNumberOfBooksOnStock | GET /books(这个数字很容易作为这个端点的一部分返回。) | | /addNewImageToBook | PUT /books/:id | | /getbookimages | 获取/书籍/:id/图像 | | /addCoverImage | POST/books/:id/封面 _ 图像 | | /list books 封面 | GET /books(此信息可以使用子资源在此端点中返回。) |您不必记住九个不同的端点,只需要记住两个,额外的好处是,一旦定义了标准,所有 HTTP 动词在所有情况下都是相同的;现在没有必要记住每种情况下的特定角色(它们总是意味着相同的事情)。
运输语言
接口要考虑的另一个方面是使用的传输语言。多年来,事实上的标准是 XML 它提供了一种与技术无关的方式来表达可以在客户机和服务器之间轻松发送的数据。如今,有一种新的标准正在 XML 之上流行起来——JSON。
为什么是 JSON?
在过去的几年中,JSON 作为标准数据传输格式越来越受欢迎(见图 2-1 )。这主要是由于它提供的优势。下面列出了几个例子:
图 2-1。
Trend of Google searches for “JSON” vs. “XML” over the last few years
- 它很轻。JSON 文件中很少有数据与正在传输的信息没有直接关系。这是超越 XML 等更冗长格式的一个主要优势。 2
- 它是人类可读的。这种格式本身非常简单,人们可以很容易地阅读和书写。考虑到任何好的 API 界面的焦点都是人的因素(也称为 DX),这一点尤其重要。
- 它支持不同的数据类型。因为不是所有被传输的都是字符串,所以这个特性允许开发人员为被传输的信息提供额外的含义。
这个列表还可以继续下去,但这是帮助 JSON 在开发人员社区中赢得如此多追随者的三个主要方面。
尽管 JSON 是一种很好的格式,并且越来越受欢迎,但它并不是总能解决所有问题的灵丹妙药;所以给客户提供选择也很重要。这就是 REST 发挥作用的地方。
由于 REST 所基于的协议是 HTTP,开发人员可以使用一种叫做内容协商的机制来允许客户端指定他们想要接收的支持格式(如第一章所讨论的)。这为 API 提供了更多的灵活性,并且仍然保持了界面的统一性。
回到端点列表,最后一个谈到使用子资源作为解决方案。这可以有多种解释,因为不仅用于传输数据的语言很重要,而且您提供给被传输数据的结构也很重要。我对统一接口的最后建议是标准化所用的格式,或者更好的是,遵循现有的格式,比如 HAL。
这在前一章中已经介绍过了,所以请回头参考它以获取更多信息。
展开性
一个好的 API 永远不会完全完成。这可能是一个大胆的主张,但它来自于社区的经验。让我们来看看一些大的。 3
- 谷歌 APIs 一天 50 亿次调用4;于 2005 年推出
- 脸书 APIs 一天 50 亿个呼叫 5 个;于 2007 年推出
- Twitter APIs 一天 130 亿次调用6;于 2006 年推出
这些例子表明,即使当 API 背后有一个伟大的团队时,API 也会不断增长和变化,因为客户端应用开发人员找到了使用它的新方法,API 所有者的商业模式会随着时间的推移而变化,或者只是因为功能的添加和删除。
当发生这种情况时,可能需要扩展或更改 API,添加新的访问点或更改旧的访问点。如果最初的设计是正确的,那么从 v1 到 v2 应该没有问题,但如果不是,那么这种迁移可能会给每个人带来灾难。
扩展性是如何管理的?
当扩展 API 时,你基本上是在发布软件的一个新版本,所以你需要做的第一件事就是让你的用户(开发者)知道一旦新版本出来会发生什么。他们的应用还能用吗?这些变化是向后兼容的吗?你会在线维护你的 API 的几个版本,还是只维护最新的一个?
一个好的 API 应该考虑以下几点:
- 添加新端点有多容易?
- 新版本向后兼容吗?
- 在更新代码的同时,客户端可以继续使用旧版本的 API 吗?
- 以新 API 为目标的现有客户端会发生什么?
- 对于客户来说,瞄准 API 的新版本有多容易?
一旦所有这些问题都解决了,那么您就可以安全地发展和扩展 API 了。
通常,通过立即放弃版本 A 并使其脱机以支持版本 B 来从版本 A 升级到版本 B 被认为是一个糟糕的举动,当然,除非只有极少数客户端应用使用该版本。
对于这种情况,更好的方法是允许开发人员选择他们想要使用的 API 版本,将旧版本保留足够长的时间,以便让每个人都迁移到新版本。为了做到这一点,API 会在资源标识符(即每个资源的 URL)中包含其版本号。这种方法使版本号成为 URL 的强制部分,以清楚地显示正在使用的版本。
另一种方法可能不太清楚,就是提供一个指向 API 最新版本的无版本 URL,以及一个可选的 URL 参数来覆盖该版本。这两种方法各有利弊,需要由创建 API 的开发人员来权衡。
表 2-3。
Pros and Cons of Having the API Version Hidden from the User
| 赞成的意见 | 骗局 | | --- | --- | | 更简单的网址。 | 隐藏的版本号可能会导致混淆正在使用的版本。 | | 即时迁移到 API 的最新工作代码。 | 非向后兼容的更改将破坏没有引用特定 API 版本的客户端。 | | 从客户端的角度来看,从一个版本到下一个版本的简单迁移(只改变属性的值)。 | 使版本选择可用需要复杂的架构。 | | 根据最新版本轻松测试客户端代码(只是不要发送特定于版本的参数)。 |表 2-2。
Pros and Cons of Having the Version of the API As Part of the URL
| 赞成的意见 | 骗局 | | --- | --- | | 版本号清晰可见,有助于避免混淆正在使用的版本。 | URL 更加冗长。 | | 从客户端的角度来看,很容易从一个版本迁移到另一个版本(所有的 URL 都改变相同的部分——版本号) | 当从一个版本迁移到另一个版本时,API 代码上的错误实现可能会导致大量的工作(即,如果版本是在端点的 URL 模板上硬编码的,每个端点都有单独的版本)。 | | 当不止一个版本的 API 需要保持工作时,允许更清晰的架构。 | | | 从 API 的角度来看,从一个版本到下一个版本的迁移清晰而简单,因为两个版本可以并行工作一段时间,从而允许较慢的客户端在不中断的情况下进行迁移。 | | 正确的版本控制方案可以使补丁和向后兼容的新特性立即可用,而不需要在客户端进行更新。 |请记住,在设置软件产品的版本时,有几种版本化方案可供使用:
Ubuntu 的 7 版本号代表发布的年份和月份;所以 14.04 版本意味着是 2014 年 4 月发布的。
在 Chromium 项目中,版本号有四个部分8:MAJOR . MINOR . build . patch .以下来自 Chromium 项目关于版本的页面:MAJOR 和 MINOR 可能会随着任何重要的 Google Chrome 版本而更新(Beta 或稳定更新)。对于任何向后不兼容的用户数据更改,必须更新 MAJOR(因为该数据在更新后仍然存在)。每当从当前主干构建一个发布候选时,构建必须得到更新(对于开发通道发布候选,至少每周一次)。构建号是一个不断增加的数字,代表 Chromium 主干的一个时间点。每当从构建分支构建一个发布候选时,补丁必须得到更新。
另一种中间方法,被称为语义版本化或 SemVer, 9 被社区广泛接受。它提供了适量的信息。每个版本都有三个数字:MAJOR.MINOR.PATCH
- MAJOR 表示不向后兼容的更改。
- MINOR 表示使 API 向后兼容的新特性。
- PATCH 代表小的变化,如错误修复和代码优化。
在这种模式下,第一个数字是唯一真正与客户相关的数字,因为这是表示与客户当前版本兼容的数字。
通过始终在 API 上部署最新版本的 MINOR 和 PATCH,您可以为客户提供最新的兼容特性和错误修复,而无需客户更新他们的代码。
因此,使用这个简单的版本控制方案,端点看起来像这样:
GET /1/books?limit=10&size=10
POST /v2/photos
GET /books?v=1
选择版本化方案时,请考虑以下因素:
- 通过使用错误版本的 API,使用错误的版本化方案可能会在实现客户端应用时导致混乱或问题。例如,在你的 API 中使用 Ubuntu 的版本控制方案可能不是交流每个新版本中发生的事情的最佳方式。
- 错误的版本控制方案可能会迫使客户端进行大量更新,比如在部署一个小的补丁或添加一个新的向后兼容特性时。这些变化不需要更新客户端。因此,除非您的方案需要,否则不要强迫客户端指定版本的这些部分。
最新文档
不管你的端点有多复杂,你仍然需要文档来解释你的 API 所做的一切。无论是可选参数还是接入点的机制,文档都是获得良好 DX 的基础,这将转化为更多用户。
一个好的 API 需要的不仅仅是解释如何使用一个接入点的几行代码(没有什么比发现你需要一个接入点,但是它根本没有文档更糟糕的了),而是需要一个完整的参数列表和解释性的例子。
一些提供商给开发人员一个简单的 web 界面,让他们不用写任何代码就可以尝试他们的 API。这对新人特别有用。
有一些在线服务允许 API 开发者上传他们的文档,以及那些提供 web UI 来测试 API 的服务;比如 Mashape 免费提供这个服务(见图 2-2 )。
图 2-2。
The service provided by Mashape
详细文档的另一个好例子是脸书的开发者网站。 10 提供了脸书支持的所有平台的实现和使用示例(见图 2-3 )。
图 2-3。
Facebook’s API documentation site
图 2-4 显示了一个糟糕的文档示例。是 4chan 的 API 文档。 11
图 2-4。
Introduction to 4chan’s API documentation
是的,这个 API 看起来不够复杂,不值得写一整本书来介绍它,但是这里没有提供任何例子,只有一个关于如何找到端点和使用什么参数的一般性解释。
新手可能会发现很难理解如何实现一个使用这个 API 的简单客户端。
Note
比较 4chan 和脸书的文件是不公平的,因为团队和公司的规模完全不同。但是你应该注意到 4chan 的文档缺乏质量。
虽然在开发 API 时,这看起来不是最有成效的想法,但是团队需要考虑处理大量的文档。这是确保 API 成功或失败的主要因素之一,原因有两个:
- 它应该可以帮助新手和高级开发人员毫无问题地使用您的 API。
- 如果保持更新,它应该作为开发团队的蓝图。如果有一个关于 API 如何工作的写得很好、解释得很清楚的蓝图,那么进入项目中期开发会更容易。
Note
当 API 发生变化时,这也适用于更新文档。你需要保持更新;否则效果和根本没有文档一样。
适当的错误处理
API 上的错误处理非常重要,因为如果处理得当,它可以帮助客户端应用理解如何处理错误;从人的角度来看(DX),它可以帮助开发人员了解他们做错了什么以及如何修复它。
在 API 客户端的生命周期中,有两个非常明显的时刻需要考虑错误处理:
- 阶段 1:客户端的开发。
- 阶段 2:最终用户实现并使用客户端。
阶段 1:客户端的开发
在第一阶段,开发人员实现所需的代码来使用 API。开发人员很可能会在请求中出错(比如缺少参数、错误的端点名称等等。)在这个阶段。
这些错误需要得到适当的处理,这意味着返回足够的信息,让开发人员知道他们做错了什么,以及如何修复它。
一些系统的常见问题是它们的创建者忽略了这个阶段,当请求出现问题时,API 就会崩溃,返回的信息只是一个错误消息,带有堆栈跟踪和状态代码 500。
图 2-5 中的响应显示了当您忘记在客户端开发阶段添加错误处理时会发生什么。返回的堆栈跟踪可能会给开发人员一些线索(最好的情况是)关于到底哪里出错了,但是它也显示了许多不必要的信息,所以它最终会令人困惑。这肯定会影响开发时间,毫无疑问,这也是反对 API DX 的主要原因。
图 2-5。
A classic example of a crash on the API returning the stack trace
另一方面,让我们看看图 2-6 中相同错误的正确错误响应。
图 2-6。
A proper error response would look like this
图 2-6 清楚显示出现了错误,错误是什么,以及错误代码。响应只有三个属性,但它们都很有用:
- 错误指示器为开发人员提供了一种明确的方法来检查响应是否是错误消息(您也可以根据响应的状态代码进行检查)。
- 该错误消息显然是为开发人员准备的,它不仅指出了缺少什么,还解释了如何修复它。
- 如果在文档中解释了自定义错误代码,当这种类型的响应再次发生时,它可以帮助开发人员自动执行操作。
阶段 2:最终用户实现并使用客户端
在客户端生命周期的这一阶段,您不会再遇到任何开发人员错误,比如使用错误的端点、缺少参数等等,但是仍然有可能出现由用户生成的数据引起的问题。
向用户请求某种输入的客户端应用总是会出现用户方面的错误,尽管在输入到达 API 层之前总是有方法验证输入,但假设所有客户端都会这样做也是不安全的。因此,对于任何 API 设计者和开发者来说,最安全的做法是假设客户端没有进行任何验证,任何可能出错的地方都是数据出错。从安全性的角度来看,这也是一个安全的假设,因此它提供了一个微小的安全性改进作为副作用。
有了这种心态,实现的 API 应该坚如磐石,能够处理输入数据中的任何类型的错误。
响应应该模仿第 1 阶段的响应:应该有一个错误指示器、一条说明错误的错误消息(如果可能,还有如何修复它)和一个定制的错误代码。自定义错误代码在这一阶段特别有用,因为它将为客户端提供自定义向最终用户显示的错误的能力(甚至显示不同但仍然相关的错误消息)。
多个 SDK/库
如果您希望您的 API 在不同的技术和平台上被广泛使用,那么开发并提供对可用于您的系统的库和 SDK 的支持可能是个好主意。
通过这样做,您为开发人员提供了消费您的服务的方法,因此他们所要做的就是使用它们来创建他们的客户端应用。本质上,您正在削减潜在的几周或几个月(取决于系统的大小)的开发时间。
另一个好处是,大多数开发人员会更信任你的库,因为你是这些库所使用的服务的所有者。
最后,考虑开源你的库的代码。如今,开源社区正在蓬勃发展。如果对他们有用,开发人员无疑会帮助维护和改进你的库。
让我们再来看看一些最大的 API:
- 脸书 API 为 iOS、Android、JavaScript、PHP 和 Unity 提供 SDK。 12
- Google Maps API 提供了多种技术的 SDK,包括 iOS、Web 和 Android。 十三
- Twitter API 为他们的几个 API 提供了 SDK,包括 Java、ASP、C++、Clojure、。NET,Go,JavaScript,还有很多其他语言。 14
- 亚马逊为他们的 AWS 服务提供 SDK,包括 PHP,Ruby,。NET,还有 iOS。他们甚至在 GitHub 上发布了 SDK,任何人都可以看到。 十五
安全
保护您的 API 是开发过程中非常重要的一步,它不应该被忽略,除非您构建的足够小,并且没有敏感数据值得花费精力。
在设计 API 时,有两个大的安全问题需要处理:
- 认证:谁将访问 API?
- 授权:一旦登录,他们能够访问什么?
认证处理让合法用户访问 API 提供的特性。授权处理那些经过身份验证的用户在系统内部实际可以做什么。
在详细讨论每个具体问题之前,在处理 RESTful 系统(至少是基于 HTTP 的系统)的安全性时,需要记住一些常见的方面:
- RESTful 系统应该是无状态的。请记住,REST 将服务器定义为无状态的,这意味着在首次登录后将用户数据存储在会话中并不是一个好主意(如果您想遵守 REST 提供的准则)。
- 记得用 HTTPS。在基于 HTTP 的 RESTful 系统上,应该使用 HTTPS 来确保通道的加密,这使得捕获和读取数据流量更加困难(中间人攻击)。
进入系统
有一些广泛使用的身份验证方案可以在用户登录系统时提供不同的安全级别。最常见的有 TSL 基本认证、摘要认证、OAuth 1.0a 和 OAuth 2.0。
我将回顾一下这些,并谈谈它们各自的优缺点。我还将介绍另一种方法,它应该被证明是最 RESTful 的,也就是说它是 100%无状态的。
几乎无状态的方法
OAuth 1.0a、OAuth 2.0、摘要认证和基本认证+ TSL 是目前流行的认证方法。它们工作得非常好,它们已经在所有现代编程语言中实现,并且它们已经被证明是这项工作的正确选择(当用于正确的用例时)。也就是说,正如你将要看到的,他们没有一个是 100%无国籍的。
它们都依赖于让用户将信息存储在服务器端的某种缓存层上。这个小细节,特别是对于纯粹主义者来说,意味着在设计 RESTful 系统时不要去做,因为它违背了 REST 强加的最基本的约束之一:客户机和服务器之间的通信必须是无状态的。
这意味着用户的状态不应该存储在任何地方。
然而,在这种特殊的情况下,你会以另一种方式看待。无论如何,我将涵盖每种方法的基础,因为在现实生活中,你必须妥协,你必须在纯粹性和实用性之间找到平衡。但别担心。我将介绍一种替代设计,它将解决身份验证并保持 REST 的真实性。
与 TSL 的基本授权
由于本书的目的是将 REST 基于 HTTP,所以后者提供了大多数语言都支持的基本认证方法。
但是请记住,这个方法的名字很恰当,因为它非常基本,并且通过 HTTP 发送未加密的用户名和密码。因此,使它安全的唯一方法是通过 HTTPS (HTTP + TSL)的安全连接来使用它。
该认证方法的工作原理如下(参见图 2-7 ):
图 2-7。
The steps between client and server on Basic Auth First, a client makes a request for a resource without any special header. The server responds with a 401 unauthorized response, and within it, a WWW-Authenticate header, specifying the method to use (Basic or Digest) and the realm name. The client then sends the same request, but adds the Authorization header, with the string USERNAME:PASSWORD encoded in base 64.
在服务器端,需要一些代码来解码身份验证字符串,并从所使用的会话存储(通常是数据库)中加载用户数据。
除了这种方法是众多打破非静态约束的方法之一这一事实之外,它实现起来既简单又快速。
Note
使用此方法时,如果已登录用户的密码被重置,则根据请求发送的登录数据会变旧,并且当前会话会终止。
摘要授权
这种方法是对前一种方法的改进,因为它通过加密登录信息增加了额外的安全层。与服务器的通信以同样的方式工作,来回发送相同的头。
使用这种方法,在接收到对受保护资源的请求时,服务器将用 WWW-Authenticate 头和一些特定的参数进行响应。以下是一些最有趣的例子:
- Nounce:唯一生成的字符串。这个字符串需要在每个 401 响应中是唯一的。
- Opaque:由服务器返回的字符串,必须由客户端不加修改地发送回来。
- Qop:即使是可选的,也应该发送该参数来指定所需的保护质量(在该值中可以发送多个令牌)。发送回
auth
意味着简单的认证,而发送auth-int
意味着带有完整性检查的认证。 - 算法:该字符串指定用于计算客户端校验和响应的算法。如果不存在,则应假设 MD5。
有关参数和实现细节的完整列表,请参考 RFC。 16 以下是一些最有趣的例子:
- 用户名:未加密的用户名。
- URI:你试图进入的 URI。
- 响应:响应的加密部分。这证明了你就是你所说的那个人。
- Qop:如果存在,它应该是服务器发送的支持值之一。
为了计算响应,需要应用以下逻辑:
MD5(HA1:STRING:HA2)
HA1 的这些值计算如下:
- 如果响应中没有指定算法,那么应该使用
MD5(username:realm:password)
。 - 如果算法是 MD5-less,那么应该是
MD5(MD5(username:realm:password):nonce:cnonce)
HA2 的这些值计算如下:
- 如果
qop
是auth
,那么应该使用MD5(method:digestURI)
。 - 如果
qop
是auth-int
,那么MD5(method:digestURI:MD5(entityBody))
最后,回应如下:
MD5(HA1:nonce:nonceCount:clientNonce:HA2) //for the case when "qop" is "auth" or "auth-int"
MD5(HA1:nonce:HA2) //when "qop" is unspecified.
这种方法的主要问题是所使用的加密是基于 MD5 的,并且在 2004 年已经证明这种算法是不抗冲突的,这基本上意味着中间人攻击将使攻击者有可能获得必要的信息并生成一组有效的凭证。
对这种方法的一个可能的改进,就像它的“基本”兄弟一样,是增加 TSL;这肯定有助于使它更加安全。
oath 1.0a
OAuth 1.0a 是本节描述的四种非静态方法中最安全的。这个过程比之前描述的要繁琐一些(见图 2-8 ),但是这里的代价是安全级别的显著提高。
图 2-8。
The interaction between client and server
在这种情况下,服务提供商必须允许客户端应用的开发者在提供商的网站上注册应用。通过这样做,开发人员获得了消费者密钥(他的应用的唯一标识密钥)和消费者秘密。完成该过程后,需要执行以下步骤:
- 客户端应用需要请求令牌。目的是接收用户的批准,然后请求访问令牌。要获得请求令牌,服务器必须提供一个特定的 URL 在这个步骤中,使用消费者密钥和消费者秘密。
- 获得请求令牌后,客户端必须在特定的服务器 URL(即
http://provider.com/oauth/authorize
)上使用令牌发出请求,以获得最终用户的授权。 - 在用户授权后,客户端应用向提供商请求访问令牌和令牌密钥。
- 一旦获得访问令牌和秘密令牌,客户端应用就能够通过签署每个请求来代表用户为提供者请求受保护的资源。
有关此方法如何工作的更多详细信息,请参考完整的文档。 17
OAuth 2.0
OAuth 2.0 是 OAuth 1.0a 的发展;它关注客户端开发人员的简单性。使用 OAuth 1.0 的系统实现的主要问题是最后一步中隐含的复杂性:对每个请求进行签名。
由于其复杂性,最后一步是该算法的关键弱点:如果客户端或服务器犯了一个小错误,那么请求将不会被验证。即使同一个方面使它成为唯一不需要在 SSL(或 TSL)之上工作的方法,这种好处也是不够的。
OAuth 2.0 试图通过一些关键的改变来简化最后一步,主要是:
- 它依靠 SSL(或 TSL)来确保来回发送的信息是加密的。
- 生成令牌后,请求不需要签名。
总而言之,这个版本的 OAuth 试图简化 OAuth 1.0 引入的复杂性,同时牺牲安全性(通过依靠 TSL 来确保数据加密)。如果您处理的设备支持 TSL(计算机、移动设备等),这是优于 OAuth 1.0 的首选方法。);否则,您可能需要考虑使用其他选项。
一个无状态的选择
正如您所看到的,在实现允许用户登录 RESTful API 的安全协议时,您所拥有的选择并不是无状态的,尽管您应该准备好做出这样的承诺,以便获得保护您的应用的可靠方法的好处,但也有一种完全兼容 REST 的方法可以做到这一点。
如果你回到第一章,无状态约束基本上意味着客户端和服务器之间的任何和所有通信状态都应该包含在客户端发出的每个请求中。这当然包括用户信息,所以如果您想要无状态认证,您也需要在您的请求中包括这些信息。
如果想确保每个请求的真实性,可以借用 OAuth 1.0a 的签名步骤,通过使用客户端和服务器之间预先建立的密钥,以及 MAC(消息认证码)算法来进行签名,将其应用于每个请求(见图 2-9 )。
图 2-9。
How the MAC signing process works
由于保持无状态,生成 MAC 所需的信息也需要作为请求的一部分发送,这样服务器就可以重新创建结果并证实其有效性。
在我们的案例中,这种方法有一些明显的优势,主要是:
- 它比 OAuth 1.0a 和 OAuth 2.0 都简单。
- 需要零存储,因为验证加密所需的任何和所有信息都需要在每次请求时发送。
可量测性
最后但同样重要的是可伸缩性。
可伸缩性通常是 API 设计中被低估的一个方面,主要是因为在一个 API 发布之前,很难完全理解和预测它将达到的范围。如果团队以前有过类似项目的经验(例如,Google 可能已经很擅长在发布日之前计算新 API 的可伸缩性),估计这一点可能更容易,但是如果这是他们的第一个项目,那么可能就不那么容易了。
一个好的 API 应该能够伸缩,也就是说,它应该能够处理尽可能多的流量,而不影响其性能。但这也意味着,如果不需要资源,就不应该花费。这不仅反映了 API 所在的硬件(尽管这是一个重要的方面),也反映了 API 的底层架构。
多年来,软件体系结构中的经典单体设计已经迁移到完全分布式设计中,因此将 API 拆分成彼此交互的不同模块是有意义的。
这提供了所需的灵活性,不仅可以扩大或缩小受影响的资源,还可以提供容错能力,帮助开发人员维护更干净的代码库以及其他优势。
下图(图 2-10 )展示了一个标准的整体设计,将你的应用放在一个服务器中,就像一个单独的实体。
图 2-10。
Simple diagram of a monolithic architecture
在图 2-11 中,您可以看到一个分布式设计,如果与之前的设计相比,您可以看到优势来自哪里(更好的资源利用、容错、更容易扩展或缩小,等等)。
图 2-11。
A diagram showing an example of a distributed architecture.
使用 REST 实现分布式架构以确保可伸缩性非常简单。Fielding 的论文提出了一种基于客户机-服务器模式的分布式系统。
因此,将整个系统分割成一组更小的 API,让它们在需要时相互通信将确保前面提到的优势。
例如,让我们看一个书店的内部系统(表 2-4 ),主要实体是:
表 2-4。
List of Entities and Their Role Inside the System
| 实体 | 描述 | | --- | --- | | 书 | 表示商店的库存。它将控制一切,从书籍数据,到副本数量,等等。 | | 客户 | 客户的联系方式。 | | 用户 | 内部书店用户,他们将有权访问系统。 | | 购买 | 记录图书销售的信息。 |现在,考虑一个小书店的系统,一个刚刚起步并且只有几个员工的书店。采用单片设计是非常有诱惑力的,不会花费太多的资源,而且设计非常简单。
现在,考虑一下,如果这家小书店突然增长如此之快,以至于它扩展到其他几家书店,它们从只有一家书店发展到 100 家,员工人数增加,书籍需要更好的跟踪,购买量上升,会发生什么。
以前的简单系统不足以应对这种增长。它需要改变以支持网络、集中数据存储、分布式访问、更好的存储容量等等。换句话说,扩大规模成本太高,而且可能需要完全重写。
最后,考虑另一个开始,如果您花时间使用基于 REST 的分布式架构创建第一个系统,会怎么样?让每个子系统都成为不同的 API,并让它们相互通信。
然后,您将能够更容易地扩展整个系统,在每个子系统上独立工作,不需要完全重写,系统可能会不断增长以满足新的需求。
摘要
本章涵盖了开发人员社区所认为的“好的 API”,其含义如下:
- 记住开发者体验(DX)。
- 能够在不破坏现有客户的情况下成长和提高。
- 拥有最新的文档。
- 提供正确的错误处理。
- 提供多个 SDK 和库。
- 考虑安全问题。
- 能够根据需要向上和向下扩展。
在下一章中,您将了解为什么 Node.js 是实现您在本章中学到的所有内容的完美匹配。
Footnotes 1
http://www.w3.org/Protocols/rfc2616/rfc2616.html
见。
2
严格来说,XML 不是一种数据传输格式,但它正被用作一种数据传输格式。
3
来源: http://www.slideshare.net/3scale/apis-for-biz-dev-20-which-business-model-15473323
。
4
2010 年 4 月。
5
2009 年十月。
6
2011 年 5 月。
7
https://help.ubuntu.com/community/CommonQuestions#Ubuntu_Releases_and_Version_Numbers
见。
8
http://www.chromium.org/developers/version-numbers
见。
9
参见 semver.org。
10
https://developers.facebook.com/docs/graph-api/using-graph-api/v2.1
见。
11
https://github.com/4chan/4chan-API
见。
12
参见https://developers.facebook.com
(SDK 列表见页面底部)。
13
https://developers.google.com/maps/
见。
14
https://dev.twitter.com/overview/api/twitter-libraries
见。
15
16
https://www.ietf.org/rfc/rfc2617.txt
见。
17
三、Node.js 和 REST
目前有太多的技术存在——无论是编程语言、平台还是框架。那么,为什么 node . js——一个在撰写本文时还没有达到 1.0 版本的项目——现在如此受欢迎呢?
硬件的进步使得开发人员有可能减少对代码的过度优化以提高速度,从而让他们更多地关注开发速度;因此,一套新的工具出现了。这些工具使开发新手更容易开发新项目,同时为高级开发人员提供了使用旧工具时获得的相同类型的功能。这些工具是当今新的编程语言和框架(Ruby on Rails、Laravel、Symfony、Express.js、Node.js、Django 等等)。
在这一章中,我将介绍其中最新的一个:Node.js。它是由 Ryan Dahl 在 2009 年创建的,并由 Dahl 工作过的 Joyent 公司赞助。在其核心,Node.js 1 利用 Google V8 2 引擎在服务器端执行 JavaScript 代码。我将介绍它的主要特性,以帮助您理解为什么它是 API 开发的一个非常好的工具。
以下是本章涉及的 Node.js 的一些方面:
- 异步编程:这是 Node.js 的一个很棒的特性。我将讨论如何利用它来获得比使用其他技术更好的结果。
- 异步 I/O:虽然与异步编程有关,但这值得单独提及,因为在输入/输出繁重的应用中,这一特性是选择 Node.js 而不是其他技术的制胜法宝。
- 简单性:Node.js 使入门和编写第一个 web 服务器变得非常容易。你会看到一些例子。
- 与基于 JSON 的服务(如其他 API、MongoDB 等)惊人的集成。).
- 社区和 Node 包管理器(npm):我将回顾拥有一个使用该技术的大型开发人员社区的好处,以及 npm 是如何提供帮助的。
- 谁在使用它?最后,我将快速浏览一些在其生产平台中使用 Node.js 的大公司。
异步编程
异步(async)编程可能同时是 Node.js 最好和最令人困惑的特性之一。
异步编程意味着,对于您执行的每个异步函数,您不能期望它在继续程序流程之前返回结果。相反,您需要提供一个回调块/函数,一旦异步代码完成,就会执行这个回调块/函数。
图 3-1 显示了一个规则的非异步流程。
图 3-1。
A synchronous execution flow
图 3-1 表示一组以同步方式运行的指令。为了执行指令#4,您需要等待“长时间运行指令”花费的时间,然后等待指令#3 完成。但是如果指令#4 和指令#3 没有真正的联系呢?如果你真的不介意指令#3 和指令#4 的执行顺序,那会怎么样呢?
然后,您可以让“长时间运行指令”以异步方式执行,并提供指令#3 作为对它的回调,从而允许您更快地执行指令#4。图 3-2 显示了它的样子。
图 3-2。
An asynchronous execution flow
指令#4 不是等待它完成,而是在指令#2 启动异步“长时间运行指令”后立即执行。
这是异步编程潜在好处的一个非常简单的例子。可悲的是,就像这个数字世界中的大多数情况一样,没有什么是没有代价的,额外的好处也伴随着一个令人讨厌的交易:调试异步代码可能是一件真正令人头疼的事情。
开发人员被训练以他们编写代码的顺序方式来思考他们的代码,所以调试一个不是顺序的代码对新手来说可能是困难的。
例如,清单 3-1 和 3-2 分别显示了以同步和异步方式编写的同一段代码。
Listing 3-1. Synchronous Version of a Simple Read File Operation
console.log("About to read the file...")
var content = Fs.readFileSync("/path/to/file")
console.log("File content: ", content)
Listing 3-2. Asynchronous Version of a Simple File Read Operation with a Common Mistake
console.log("About to read the file...")
var content = ""
fs.readFile("/path/to/file", function(err, data) {
content = data
})
console.log("File content: ", content)
如果您还没有猜到,清单 3-2 将打印如下内容:
File content:
其原因与图 3-3 所示的图表直接相关。让我们用它来看看有问题的异步版本是怎么回事。
图 3-3。
The error from Listing 3-2
文件内容没有被写入的原因很清楚:回调是在最后一行console.log
之后执行的。这是新开发人员非常常见的错误,不仅是 Node.js,更具体地说是前端的 AJAX 调用。他们设置自己的代码,以便在异步调用实际结束之前使用异步调用返回的内容。
为了结束这个例子,清单 3-3 展示了需要如何编写代码才能正常工作。
Listing 3-3. Correct Version of the Asynchronous File Read Operation
console.log("About to read the file...")
var content = ""
fs.readFile("/path/to/file", function(err, data) {
content = data
console.log("File content: ", content)
})
很简单。您刚刚将最后一行console.log
移动到回调函数中,所以您确定content
变量设置正确。
异步高级
异步编程不仅仅是确保正确设置回调函数,它还允许一些有趣的流控制模式,可以用来提高应用的效率。
让我们来看看异步编程的两种截然不同且非常有用的控制流模式:并行流和串行流。
平行流
并行流背后的思想是,程序可以并行运行一组不相关的任务,但只在所有任务执行完毕后调用所提供的回调函数(以收集它们的集体输出)。
基本上,清单 3-4 显示了你想要的。
Listing 3-4. Signature of the Parallel Function
//functionX symbols are references to individual functions
parallel([function1, function2, function3, function4], function(data) {
///do something with the combined output once they all finished
})
为了知道数组中传递的每个函数何时完成执行,它们必须执行一个回调函数,并给出它们的操作结果。回调将是他们收到的唯一属性。清单 3-5 显示了并行函数。
Listing 3-5. Implementation of the Parallel Function
function parallel(funcs, callback) {
var results = [],
callsToCallback = 0
funcs.forEach(function(fn) { // iterate over all functions
setTimeout(fn(done), 200) // and call them with a 200 ms delay
})
function done(data) { // the functions will call this one when they finish and they’ll pass the results here
results.push(data)
if(++callsToCallback == funcs.length) {
callback(results)
}
}
}
清单 3-5 中的实现非常简单,但它完成了它的任务:它以并行的方式运行一组函数(您将看到,由于 Node.js 在单线程中运行,真正的并行是不可能的,所以这是您能得到的最接近的结果)。这种类型的控制流在处理对外部服务的调用时特别有用。
让我们看一个实际的例子。假设您的 API 需要执行几个操作,尽管这些操作彼此不相关,但都需要在用户看到结果之前发生。例如,从数据库加载图书列表,查询外部服务以获取本周新书的新闻,并将请求记录到文件中。如果您要执行一系列任务中的所有任务(参见清单 3-6 ),在下一个任务运行之前等待一个任务完成,那么用户很可能会遇到响应延迟,因为执行所需的总时间是所有单个时间的总和。
但是,如果您可以并行执行所有这些任务(参见清单 3-7 ,那么总时间实际上等于执行最慢的任务所花费的时间。??
让我们看看清单 3-6 和 3-7 中的两种情况。
Listing 3-6. Example of a Serial Flow (takes longer)
//request handling code...
//assume "db" is already initialized and provides an interface to the data base
db.query("books", {limit:1000, page: 1}, function(books) {
services.bookNews.getThisWeeksNews(function(news) {
services.logging.logRequest(request, function() { //nothing returned, but you need to call it so you know the logging finished
response.render({listOfBooks: books, bookNews: news})
})
})
})
Listing 3-7. Example of a Parallel Execution Flow
//request handling code...
parallel([
function(callback) { db.query("books", {limit: 1000, page: 1}, callback) }),
function(callback) { services.bookNews.getThisWeeksNews(callback) }),
function(callback) { services.logRequest(request, callback) })
], function(data) {
var books = findData(‘books’, data)
var news = findData(‘news’, data)
response.render({listOfBooks: books, bookNews: news})
})
清单 3-6 和 3-7 展示了每种方法的样子。findData
函数只是查看data
数组,并根据条目的结构返回所需的条目(第一个参数)。在parallel
的实现中,它是必需的,因为你不能确定函数以什么顺序完成,然后返回结果。
除了代码获得明显的速度提升之外,它还更容易阅读,更容易向并行流程添加新任务——只需向数组添加一个新项目。
串行流
串行流提供了方便地指定需要以特定顺序执行的功能列表的方法。这种解决方案不像并行流那样提供速度提升,但是它提供了编写这样的代码并保持其整洁的能力,远离了通常被称为意大利面条代码的东西。
清单 3-8 显示了你应该努力完成的任务。
Listing 3-8. Signature of the Serial Function
serial([
function1, function2, function3
], function(data) {
//do something with the combined results
})
清单 3-9 显示了你不应该做的事情。
Listing 3-9. Example of a Common Case of Nested Callbacks
function1(function(data1) {
function2(function(data2) {
function3(function(data3) {
//do something with all the output
}
}
}
您可以看到,如果函数的数量持续增长,清单 3-9 中的代码可能会失控。所以串行方法有助于保持代码的组织性和可读性。
让我们看看清单 3-10 中串行函数的可能实现。
Listing 3-10. Implementation of the Serial Function
function serial(functions, done) {
var fn = functions.shift() //get the first function off the list
var results = []
fn(next)
function next(result) {
results.push(result) //save the results to be passed into the final callback once you don’t have any more functions to execute.
var nextFn = functions.shift()
if (nextFn) nextFn(next)
else done(results)
}
}
这些函数还有更多的变化,比如使用一个错误参数来自动处理错误,或者限制并行流中同时执行的函数的数量。
总而言之,异步编程给实现 API 带来了很多好处。当处理外部服务时,并行工作流非常方便,这通常是任何 API 都会处理的;例如,数据库访问、其他 API、磁盘 I/O 等等。同时,串行工作流在实现像 Express.js 中间件这样的东西时也很有用。 4
对于一个在异步编程上蓬勃发展的全功能且经过测试的库,请查看 async.js. 5
异步输入输出
异步编程的一个具体案例与 Node.js 提供的一个非常有趣的特性有关:异步 I/O,这个特性与 Node.js 的内部架构高度相关(见图 3-4 )。我说过,Node.js 不提供多线程;它实际上与运行事件循环的单个线程一起工作。
图 3-4。
How the EventLoop orchestrates the execution of the code
简而言之,Node.js 的设计思想是 I/O 操作是每个操作的实际瓶颈,而不是处理能力;因此,Node 进程收到的每个请求都将在事件循环中工作,直到找到 I/O 操作。当这种情况发生时,回调被注册在一个单独的队列中,主程序的流程继续。一旦 I/O 操作完成,回调就会被触发,回调中的代码就会运行。
异步 I/O 与同步 I/O
最后,为了证明到目前为止我所说的一切都是正确的,并且 Node.js 使用异步 I/O 工作得最好,我做了一些非常简单的基准测试。我创建了一个简单的 API,它有两个端点:
/async
:在返回一个简单的 JSON 响应之前,异步读取一个 1.6MB 的文件。/sync
:在返回一个简单的 JSON 响应之前,同步读取一个 1.6MB 的文件。
两个端点做的完全一样;只是方式不同(见清单 3-11 )。这个想法是为了证明,即使在这样简单的代码中,当底层代码利用平台提供的异步 I/O 时,事件循环也可以更好地处理多个请求。
清单 3-11 是两个端点的代码;API 是用梵蒂冈 6编写的
Listing 3-11. Example of Two Endpoints Coded Using the Vatican.js Framework
//Async handler
var fs = require("fs")
module.exports = AsyncHdlr;
function AsyncHdlr(_model) { this.model = _model }
//@endpoint (url: /async method: get)
AsyncHdlr.prototype.index = function(req, res, next) {
fs.readFile(__dirname + "/../file.txt", function (err, content) {
res.send({
success: true
})
})
}
//Sync handler
var fs = require("fs")
module.exports = SyncHdlr;
function SyncHdlr(_model) { this.model = _model }
//@endpoint (url: /sync method:get)
SyncHdlr.prototype.index = function(req, res, next) {
var content = fs.readFileSync(__dirname + "/../file.txt")
res.send({
success: true
})
}
基准测试是使用 Apache 基准测试工具 7 完成的,使用了以下参数:
- 请求数:10 000
- 并发请求:100
结果如表 3-1 所示。
表 3-1。
Results from the Benchmark of the Two Endpoints Shown in Listing 3-11
| 同步端点 | 异步端点 | | --- | --- | | 每秒请求数:2411.28#/秒。每请求时间 41.472 毫秒每请求时间:0.415 毫秒传输速率:214.28 [KBps]已接收 | 每秒请求数:2960.79#/秒。每个请求的时间:33.775 毫秒每个请求的时间:0.338 毫秒传输速率:263.12 [KBps]已接收 |正如你在表 3-1 中看到的,即使是最简单的例子,在相同的时间内,异步代码处理的请求也比同步代码多 549 个。另一个有趣的项目是,每个请求在异步端点上几乎快了 8 毫秒;这可能不是一个很大的数字,但是考虑到您正在使用的代码的不存在的复杂性,这是非常相关的。
简单
Node.js(更确切地说是 JavaScript)并不是一种复杂的语言。它遵循了类似脚本语言(如 Ruby、Python 和 PHP)遵循的基本原则,但有所改变(就像所有其他语言一样)。Node.js 足够简单,任何开发人员都可以很快学会并开始编码,但它足够强大,几乎可以实现开发人员想做的任何事情。
尽管 JavaScript 是一种令人惊叹的语言,也是本书的重点,就像我已经说过并将继续说的那样:在编程方面没有灵丹妙药。多年来,JavaScript 获得了很大的吸引力,但它也吸引了很多讨厌它的人,他们有非常合理的理由:非标准的面向对象模型、this
关键字的奇怪用法、缺乏语言内置的功能(它有很多专用于实现其他语言内置的基本特性的库),等等。最后,每种工具都需要根据其优势来选择。正如您将要看到的,Node.js 是开发 API 的一个非常好的选择。
Node.js 为该语言增添了某种有用的味道,简化了开发人员开发后端代码的工作。它不仅添加了处理 I/O 所需的实用程序(出于明显的安全原因,前端 JavaScript 没有这些功能),而且还为每个 web 浏览器支持的所有不同风格的 JavaScript 提供了稳定性。这方面的一个例子是,只需几行代码就可以轻松地建立一个 web 服务器。让我们看看清单 3-12 中的内容。
Listing 3-12. Simple Example of a Web Server Written in Node.js
var http = require("http")
http.createServer(function(req, res) { //create the server
//request handler code here
});
http.listen(3000) //start it up on port 3000
JavaScript 还有一个优势,它是所有商业 web 浏览器的标准前端语言,这意味着如果你是一个有前端经验的 web 开发人员,你肯定会遇到 JavaScript。
这对从前端迁移到后端的开发人员来说更简单;既然语言基础没变,你只需要学习新的东西,换一个后端的心态。同时,这有助于公司更快地找到 Node.js 开发者。
记住所有这些,让我们看看 JavaScript 的一些主要特征,这些特征使它成为如此简单(但功能强大)的选项。
动态打字
动态类型是一个基本特性,在当今大多数通用语言中都存在,但是它的功能并没有因此而减弱。这个小特性允许开发者在声明变量时不必想太多;给它一个名字,然后继续前进。
清单 3-13 展示了一些你不能用静态类型语言做的事情。
Listing 3-13. Example of Code Taking Advantage Of Dynamic Typing
var a, b, tmp //declare the variables (just give them names)
//initialize them with different types
a = 10
b = "hello world"
//now swap the values
tmp = a
a = b //even with automatic casting, a language like C won’t be able to cast "hello world" into an integer value
b = tmp
console.log(a) //prints "hello world"
console.log(b) //prints 10
简化的面向对象编程
JavaScript 不是一种面向对象的语言,但它确实支持其中一些特性(参见清单 3-14 和清单 3-16 )。您将有足够多的概念来使用对象概念化问题和解决方案,这总是一种非常直观的思维方式,但同时,您没有处理像多态、接口或其他概念,这些概念尽管有助于构建代码,但已被证明在设计应用时是可有可无的。
Listing 3-14. Simplified Object Orientation Example
var myObject = { //JS object notation helps simplify definitions
myAttribute: "some value",
myMethod: function(param1, param2) {
//does something here
}
}
//And the just...
myObject.myMethod(...)
而对于其他语言,比如 Java(一种非常面向对象的语言),您必须做清单 3-15 中所示的事情。
Listing 3-15. Example of a Class Definition in Java
class myClass {
public string myAttribute;
public void myClass() {
}
public void myMethod(int param1, int param2) {
//does something here
}
}
//And then
myClass myObj = new myClass();
myObj.myMethod(...);
少了很多罗嗦,不是吗?
在清单 3-16 中,让我们看看另一个强大的面向对象的例子。
Listing 3-16. Another Example of the Features Provided by Object Orientation in JavaScript
var aDog = { //behave like a dog
makeNoise: function() {
console.log("woof!");
}
}
var aCat = { //behave like a cat
makeNoise: function() {
console.log("Meewww!");
}
}
var myAnimal = { //our main object
makeNoise: function() {
console.log("cri... cri....")
},
speak: function() {
this.makeNoise()
}
}
myAnimal.speak() //no change, so.. crickets!
myAnimal.speak.apply(aDog) //this will print "woof!"
//switch behavior
myAnimal.speak.apply(aCat) //this will now print "Meewww!"
您能够将一个简单的行为封装到一个对象中,并将其传递到另一个对象中,以自动覆盖其默认行为。这是语言中固有的东西。您不必编写任何特定的代码来实现这个特性。
原型遗传
与上一个相联系,原型继承特性允许在对象生命周期的任何时刻对其进行难以置信的简单扩展;强大而简单。
让我们看看清单 3-17 来更好地理解这一点。
Listing 3-17. Example of Prototypal Inheritance in JavaScript
var Parent = function() {
this.parentName = "Parent"
}
var Child = function() {
}
Child.prototype = new Parent()
var childObj = new Child();
console.log(childObj.parentName)
console.log(childObj.sayThanks) //that's undefined so far
Parent.prototype.sayThanks = function() { //you "teach" the method to the parent
console.log("Thanks!")
}
console.log(childObj.sayThanks()) //and booom! the child suddenly can say thanks now
你是否动态影响了父对象——然后子对象突然更新了?是的,刚刚发生了!强大?我会这么说!
函数式编程支持
JavaScript 不是函数式编程语言;不过话说回来,它确实支持它的一些特性(参见清单 3-18 、 3-19 和 3-20 ),比如拥有一级公民函数,允许你像传递参数一样传递它们,并且很容易返回闭包。这个特性使得使用回调成为可能,正如您已经看到的,回调是异步编程的基础。
让我们看看清单 3-18 中一个快速简单的函数式编程例子(记住,JavaScript 只提供了一些函数式编程的好东西,而不是全部)。创建一个加法函数。
Listing 3-18. Simple Example of an Adder Function Defined Using Functional Programming
function adder(x) {
return function(y) {
return x+y
}
}
var add10 = adder(10) //you create a new function that adds 10 to whatever you pass to it.
console.log(add10(100)) //will output 110
让我们看一个更复杂的例子,一个map
函数的实现,它允许你通过传递数组和转换函数来转换数组的值。让我们首先看看如何使用地图功能。
Listing 3-19. Example of a Map Function Being Used
map([1,2,3,4], function(x) { return x * 2 }) //will return [2,4,6, 8]
map(["h","e","l","l","o"], String.prototype.toUpperCase) //will return ["H","E","L","L","O"]
现在让我们来看一个使用函数方法的可能实现。
Listing 3-20. Implementation of a Map Function, Like the One Used in Listing 3-19
function reduce(list, fn, init) {
if(list.length == 0) return init
var value = list[0]
init.push(fn.apply(value, [value])) //this will allow us to get both the functions that receive the value as parameters and the methods that use it from it’s context (like toUpperCase)
return reduce(list.slice(1), fn, init) //iterate over the list using it’s tail (everything but the first element)
}
function map(list, fn) {
return reduce(list, fn, [])
}
鸭子打字
你听过这句话吗“如果它看起来像鸭子,游泳像鸭子,叫声像鸭子,那它很可能就是鸭子。”?那么,用 JavaScript 输入也是一样的。变量的类型由其内容和属性决定,而不是由固定值决定。所以同一个变量可以在脚本的生命周期中改变它的类型。鸭式打字既是一个非常强大的功能,同时也是一个危险的功能。
清单 3-21 提供了一个简单的演示。
Listing 3-21. Quick Example of Duck Typing in JavaScript
var foo = "bar"
console.log(typeof foo) //will output "string"
foo = 10
console.log(typeof foo) //this will now output "number"
对 JSON 的本机支持
这是一个棘手的问题,因为 JSON 实际上是从 JavaScript 衍生而来的,但是我们不要在这里纠缠于先有鸡还是先有蛋的问题。对现在使用的主要传输语言的本地支持是一大优势。
清单 3-22 是遵循 JSON 语法的一个简单例子。
Listing 3-22. Example of How JSON Is Natively Supported by JavaScript
var myJSONProfile = {
"first_name": "Fernando",
"last_name": "Doglio",
"current_age": 30,
"married": true,
"phone_numbers": [
{
"home_phone": "59881000293",
"cell_phone": "59823142242"
}
]
}
//And you can interact with that JSON without having to parse it or anything
console.log(myJSONProfile.first_name, myJSONProfile.last_name)
这一特殊功能在几种情况下特别有用;例如,当使用基于文档的存储解决方案(如 MongoDB)时,因为数据建模最终在两个地方(您的应用和数据库)都是本地的。此外,在开发 API 时,您已经看到目前选择的传输语言是 JSON,所以直接用原生符号格式化您的响应的能力(就此而言,您甚至可以只输出您的实体)对于易用性来说是一个非常大的优势。
这个列表还可以扩展,但是这些是 JavaScript 和 Node.js 带来的非常强大的特性,对开发人员没有太多要求。它们很容易理解和使用。
Note
提到的特性并不是 JavaScript 独有的;其他脚本语言也有一些。
npm:Node 包管理器
Node.js 的另一个优点是它惊人的包管理器。正如您现在可能知道的(或者即将发现的),Node 中的开发是非常依赖于模块的,这意味着您不会开发整个系统;很可能你会以模块的形式重用别人的代码。
这是 Node.js 的一个非常重要的方面,因为这种方法允许您专注于使您的应用独一无二的东西,并让通用代码无缝集成。您不必为 HTTP 连接重新编码库,也不必为每个项目重新编码路由处理程序(换句话说,您不必重新发明轮子);只需将项目的依赖项设置到package.json
文件中,使用最合适的模块名,然后 npm 会检查整个依赖树并安装所有需要的东西(想想 APT for Ubuntu 或 Homebrew for Mac)。
可用的活跃用户和模块数量(每月超过 100,000 个包和超过 6 亿次下载)确保您可以找到您需要的内容;在极少数情况下,如果您不知道,您可以通过将特定的模块上传到注册表来帮助下一个寻找它的开发人员。
这种模块数量也可能是一件坏事,因为如此大的数量意味着将有几个模块试图做同样的事情。(例如,email-validation、sane-email-validation、mailcover 和 mailgun-email-validation 都试图做同样的事情——使用不同的技术验证电子邮件地址;根据你的需要,你必须选择一个。)你必须浏览它们,找到最合适的候选人。
这一切都要归功于自 Node.js 于 2009 年上市以来形成的令人惊叹的开发人员社区。
要开始使用 npm,只需访问他们的网站 www.npmjs.org
。在那里,你会看到一个最近更新的软件包列表,让你开始,以及一些最受欢迎的。
图 3-5。
The npm site
如果想直接安装它,只需在您的 Linux 控制台中写入下面一行:
$ curl
https://www.npmjs.org/install.sh
您需要安装 Node.js 版才能正确使用它。完成后,您只需输入以下命令即可开始安装模块:
$ npm install [MODULE_NAME]
该命令将指定的模块下载到名为node_modules
的本地文件夹中;所以试着在你的项目文件夹中运行它。
您还可以使用 npm 开发自己的模块,并通过以下方式将它们发布到站点中:
$ npm publish #run this command from within your project’s folder
前面的命令从package.json
文件中获取属性,打包模块,并将所有内容上传到 npm 的注册表中。之后,您可以进入站点并检查您的包裹;它会列在那里。
Note
除了查看 www.npmjs.org
之外,你还可以查看 Google Groups nodejs8和 nodejs-dev 9 来直接联系 Node.js 社区中的人。
谁在用 Node.js?
这整本书旨在验证 Node.js 在开发 RESTful 系统方面有多好,并提供相关示例,同时验证让 Node.js 驱动的系统在生产中运行的想法有多有效(这是最难克服的障碍,尤其是在试图说服您的老板将堆栈转换为基于 Node.js 的堆栈时)。
但是有什么更好的验证呢,看看生产中 Node.js 的一些最重要的用户?
- PayPal:使用 Node.js 支持其网络应用。
- 易贝:使用 Node.js 主要是因为异步 I/O 带来的好处。
- LinkedIn:整个后端移动堆栈都是在 Node.js 中完成的。使用它的两个原因是与以前的堆栈相比获得的规模和性能。
- 网飞:在几个服务上使用 Node.js。经常在位于
http://techblog.netflix.com
的科技博客上写一些使用 Node.js 的经历。 - 雅虎!:在几个产品上使用 Node.js,比如 Flickr,My Yahoo!和主页)。
这个列表还可以继续下去,包括其他公司的一个非常大的列表,有些公司比其他公司更知名,但要点仍然是:Node.js 用于整个互联网的生产服务,它处理各种流量。
摘要
本章介绍了 Node.js 对于普通开发人员的优势,尤其是它的特性如何提高 I/O 密集型系统(如 API)的性能。
在下一章中,您将获得更多的实践机会,并了解在最后一章中用于开发 API 的基本架构和工具。
Footnotes 1
http://en.wikipedia.org/wiki/Node.js
见。
2
见 [http://en.wikipedia.org/wiki/V8_(JavaScript_engine
](http://en.wikipedia.org/wiki/V8_(JavaScript_engine) )
。
3
这是一个粗略的近似值,因为需要将并行功能增加的时间考虑在内,才能得到准确的数字。
4
http://expressjs.com/guide/using-middleware.html
见。
5
https://github.com/caolan/async
见。
6
7
http://httpd.apache.org/docs/2.2/programs/ab.html
见。
8
https://groups.google.com/forum/#!forum/nodejs
见。
9
https://groups.google.com/forum/#!forum/nodejs-dev
见。
四、构建 REST API
理解一个基于 REST 的架构是非常重要的,这意味着如果你的所有服务都基于 REST 风格,那么系统会是什么样子。但是在开始工作之前,了解这些 REST 服务的内部架构是什么样子也是同样重要的。
Node.js 中有几个模块,每天有成千上万的下载,可以帮助您创建一个 API,而不必太担心它的内部方面。如果您急于推出产品,这可能是一个好主意,但是既然您是来学习的,我将介绍构成标准的通用 REST API 的所有组件。
提到了这些模块,但我不会详细说明它们是如何使用的。那将在下一章出现——所以继续读下去吧!
出于本书的目的,在设计 API 时,我将采用传统的方法,而您将使用 MVC 模式(模型-视图-控制器);尽管您可能熟悉其他选项,但它是最常见的选项之一,通常非常适合作为 web 用例。
RESTful API 的基本内部架构包含以下内容:
- 请求处理程序。这是接收每个请求并在做任何事情之前处理它的焦点。
- 中间件/预处理链。这些人帮助形成请求,并为身份验证控制提供一些帮助。
- 路由处理程序。请求处理程序完成后,请求本身已经过检查,并添加了您需要的所有内容,该组件将确定谁需要处理请求。
- 控制器。这个人负责与一个特定资源相关的所有请求。
- 模型。在我们的例子中也称为资源。您将把与资源相关的大部分逻辑集中在这里。
- 制图表达层。这一层负责创建对客户端应用可见的表示。
- 响应处理程序。最后,响应处理程序负责将响应的表示发送回客户机。
Note
正如我以前多次说过的,这本书主要关注基于 HTTP 的 REST,这意味着本章提到的任何请求都是 HTTP 请求,除非另有说明。
请求处理器、预处理链和路由处理器
请求处理程序、预处理链和路由处理程序是对系统的任何请求的前三个组件,因此它们是拥有一个响应迅速的 API 的关键。幸运的是,您正在使用 Node.js,正如您在第三章中看到的,Node.js 非常擅长处理许多并发请求,因为它有事件循环和异步 I/O。
也就是说,让我们列出请求处理程序需要的属性,以便 RESTful 系统按预期工作:
- 它必须收集所有的 HTTP 头和请求体,解析它们,并向请求对象提供这些信息。
- 它需要能够与预处理链模块和路由处理器通信,以便确定需要执行哪个控制器。
- 它需要创建一个能够完成并(可选地)向客户端写回响应的响应对象。
图 4-1 显示了客户端和服务器之间初始联系的步骤:
图 4-1。
Example of how the request handler and its interactions with other components look The client application issues a request for a particular resource. The request handler gathers all information. It creates a request object and passes it along to the pre-processing chain. Once finished, the pre-processing chain returns the request object—with whatever changes made to it—to the request handler. Finally, the RH sends the request and response objects to the routes handler so that the process can continue.
图 4-1 中有一个问题引起了你的注意(或者应该引起注意):如果预处理链花费的时间太长,请求处理程序必须等待它完成,然后才能将请求移交给路由处理程序,任何其他传入的请求也必须等待。
如果预处理链正在执行一些繁重的任务,如加载用户相关数据或查询外部服务,这对 API 的性能尤其有害。
感谢您使用 Node.js 作为这里一切的基础,您可以轻松地将预处理链更改为异步操作。通过这样做,请求处理程序能够接收新的请求,同时仍然等待来自前一个请求的处理链。图 4-2 显示了图表将如何变化。
图 4-2。
Changes to the architecture show how to improve it by using a callback
正如你在图 4-2 中看到的,变化是最小的,至少在架构层面上是如此。请求处理程序建立对路由处理程序的回调;并且一旦预处理链完成,就执行该回调。在设置好回调之后,请求处理程序又可以处理下一个请求了。这显然为这个组件提供了更多的自由,允许整个系统每秒处理更多的请求。
Note
这种改变实际上不会加快预处理链的执行时间,也不会加快完成单个请求的时间,但是它将允许 API 每秒处理更多的请求,这实际上意味着避免了一个明显的瓶颈。
至于预处理链,您将使用它进行一般操作,这是您将要处理的大多数路线中所需要的。这样,您就可以从处理程序中提取代码,并将其集中到较小的代码单元(函数)中,每个请求都会按顺序调用这些代码单元。
你将在下一章看到的大多数模块都有一个版本的预处理链。例如,Express.js 将可以在链中执行的功能称为“中间件”vantage . js 将它们称为“预处理器”,以区别于该模块也提供的后处理器。
Tip
在向这个链中添加一个新函数时,要记住的主要规则是,作为一个良好的实践,这个函数应该处理一个任务,并且只处理一个任务。(这通常是软件开发的每个方面都要遵循的良好实践,有人称之为 Unix 哲学,有人称之为 KISS 不管你怎么称呼它,记住它是个好主意。)这样,在测试时启用和禁用它们变得非常容易,甚至可以改变它们的顺序。另一方面,如果您开始添加处理多件事情的函数,比如认证用户和加载他/她的首选项,您将不得不编辑函数的代码来禁用这些服务之一。
因为您希望整个预处理异步完成,以将请求处理程序从等待链完成的状态中释放出来,所以链将使用异步串行流。这样你就可以确定执行的顺序;但与此同时,您可以自由地让这些函数执行比正常情况花费更长时间的操作,比如对外部服务的异步调用、I/O 操作等等。
让我们最后看一下最后一张图。到目前为止,它看起来很棒:您能够异步处理请求,并且您可以通过在将请求交给 routes 处理程序之前对其进行预处理来对请求做一些有趣的事情。但是有一个问题:预处理链对于所有路线都是相同的。
如果 API 足够小,这可能不是问题,但为了安全起见,并提供完全可扩展的架构,让我们看看在当前版本上可以做的另一个变化,以提供您需要的自由(见图 4-3 )。
图 4-3。
Change on the architecture to provide room for multiple pre-processing chains
这个链条(如图 4-3 所示)比前一个大,但是确实解决了扩容问题。该过程现已更改为以下步骤:
The client application issues a request for a particular resource. The request handler gathers all information. It creates a request object and passes it along to the request handler to return the right controller. This action is simple enough to do synchronously. Ideally, it should be done in constant time (O(1)). Once it has the controller, it registers an asynchronous operation with the correct pre-processing chain. This time around, the developer is able to set up as many chains as needed and associates them to one specific route. The request handler also sets up the controller’s method to be executed as the callback to the chain’s process. Finally, the callback is triggered, and the request object, with the response object passed into the controller’s method to continue the execution. Note
步骤 2 提到基于请求的控制器查找应该在恒定时间内完成。这不是硬性要求,但应该是理想的结果;否则,在处理许多并发请求时,这一步可能会成为影响后续请求的瓶颈。
MVC:又名模型-视图-控制器
模型-视图-控制器(MVC)架构模式 1 可能是最广为人知的模式。忘掉四人帮的设计模式, 2 忘掉你所学的关于软件设计和架构模式的一切;如果你熟悉 MVC,你就没有什么可担心的了。
实际上,那不是真的;反正大部分都不是。MVC 是目前 web 项目中最著名和最常用的设计模式之一(这是事实)。也就是说,你不应该忘记其他人;事实上,我强烈建议您实际上熟悉最常见的(当然除了 MVC),比如单例、工厂、构建器、适配器、复合、装饰器等等。只要查一查,读一读,研究一些例子;把它们作为工具箱的一部分总是很方便的。
回到 MVC,尽管它在过去几年里变得非常流行,特别是自 2007 年以来(恰好是在这一年,流行的 web 框架 Ruby on Rails 的第 2 版发布,MVC 是其核心架构的一部分),这个坏男孩并不新鲜。事实上,它最初是由 Krasner 和 Pope 在 1988 年的 SmallTalk-80 3 上描述为一种创建用户界面的设计模式。
它在 web 项目中如此受欢迎的原因是因为它完全适合 web 提供的多层架构。考虑一下:由于客户端-服务器架构,您已经有两层了,如果您组织代码在编排和业务逻辑之间划分一些职责,您将在服务器端获得多一层,这可以转化为表 4-1 中所示的场景。
表 4-1。
List of Layers
| 层 | 描述 | | --- | --- | | 业务逻辑 | 您可以将系统的业务逻辑封装到不同的组件中,您可以将这些组件称为模型。它们代表系统处理的不同资源。 | | 管弦乐编曲 | 模型知道如何做他们的工作,但不知道什么时候使用什么样的数据。控制器会处理这个问题。 | | 表示层 | 处理创建信息的可视化表示。在普通的 web 应用中,这是 HTML 页面。在 RESTful API 中,这一层负责每个资源的不同表示。 |Note
在表 4-1 之前,我提到过客户机-服务器架构为 MVC 提供了前两层,这意味着客户机将充当表示层。这并不完全正确,稍后您会看到,但它确实作为一个概念层,这意味着您需要一种方法让应用将信息呈现给用户(客户端)。
让我们看看图 4-4 中的示意图,它代表了表 4-1 。
图 4-4。
The interaction between the three layers
图 4-4 显示了三个组件的解耦:控制器、模型(在这种情况下你也可以调用资源)和视图。这种分离允许清晰地定义每个组件的职责,这反过来有助于保持代码的整洁和易于理解。
尽管这很棒,但自从被一些 web 开发框架采用后,这种模式已经发生了一些变化,比如 Ruby on Rails 它现在看起来更像图 4-5 中所示。
图 4-5。
MVC applied to the web
模式的当前迭代移除了模型和视图之间的关系,而是将该职责交给了控制器。控制器现在还编排视图。
这个最终版本是您将添加到我们当前不断发展的架构中的版本。让我们看看它会是什么样子(见图 4-6 )。
图 4-6。
The architecture with the added MVC pattern
我们的架构中增加了步骤 5 和 6。当控制器上的正确方法被触发时(在步骤 4 中),它处理与模型的交互,收集所需的数据,然后将其发送到视图,以将其呈现给客户端应用。
这种架构非常好,但是仍然有一个可以改进的地方。使用我们的 RESTful API,表示与资源数据结构严格相关,您可以将视图一般化为视图层,它会负责将资源转换为您需要的任何格式。这种变化简化了开发,因为您将整个视图相关的代码集中到一个单独的组件(视图层)中。
图 4-7 中的图表可能变化不大,但是视图框到视图层的变化代表了代码的一般化,这最初意味着每个资源都有一个特定的视图代码。
图 4-7。
View layer added to the architecture
MVC 的替代方案
MVC 是一个伟大的架构。几乎每个开发人员都在使用它,或者在 web 项目中使用过它。当然,并不是每个人都喜欢它,因为它遭受了开发社区中其他流行事物所遭受的相同命运(Ruby on Rails 有人喜欢吗?).如果它在互联网上变得流行,每个人都在使用它做任何事情——直到他们意识到不是每个项目看起来都像 MVC 钉子,所以你必须开始寻找其他形状的锤子(其他替代架构)。
但幸运的是,还有其他选择;根据项目的特定方面,有一些类似的架构模式可能更适合您的需求。其中一些是 MVC 的直接衍生物,另一些试图从稍微不同的角度来处理同一个问题(我说“稍微”是因为,正如您将要看到的,有一些共同点)。
分层 MVC
分层 MVC 4 是 MVC 的一个更复杂的版本,因为你可以将一个 MVC 组件嵌套在另一个组件中。这使开发人员能够拥有像一个页面的 MVC 组、另一个页面内导航的 MVC 组和一个页面内容的最终 MVC 组件这样的东西。
这种方法在开发可插入组件的可重用小部件时特别有用,因为每个 MVC 组都是自包含的。当要显示的数据来自不同的相关来源时,这很有用。在这些情况下,拥有 HMVC 结构有助于保持关注点的分离完好无损,并避免组件之间不应该出现的耦合。
让我们看一个非常基本的例子。想象一下,一个用户正在阅读一篇博客文章及其下面的相关评论。有两条路可以走:用 MVC 或者用 h MVC。
使用 MVC,请求被发送到 BlogPosts 控制器,因为这是被请求的主要资源;之后,控制器加载适当的博客文章模型,并使用该模型的 ID 加载相关的评论模型。就在这里,BlogPosts 控制器和评论模型之间出现了不必要的耦合。您可以在图 4-8 中看到这一点。
图 4-8。
The problem that HMVC tries to solve
图 4-8 显示了需要去除的联轴器;从架构的角度来看,这显然是可以改进的。所以让我们看看使用 HMVC 会是什么样子(见图 4-9 )。
图 4-9。
The same diagram with the HMVC pattern applied
架构看起来当然更复杂,步骤也更多,但也更清晰,更容易扩展。现在在第 3 步中,您向一个全新的 MVC 组件发送一个请求,这个组件负责处理评论。该组件将依次与相应的模型和通用视图层进行交互,以返回注释的表示。BlogPost 控制器接收该表示,并将其附加到从 BlogPost 模型获得的数据上,然后将所有内容发送回视图层。
如果您想在博客中创建一个新的部分来显示特定的博客文章及其评论,那么您可以很容易地重用 comments 组件。
总而言之,这种模式可以被认为是普通 MVC 的一种专门化,在设计复杂系统时它会派上用场。
模型–视图–视图模型
模型-视图-视图模型模式 5 由微软在 2005 年创建,作为一种使用 WPF 和 Silverlight 促进 UI 开发的方式;它允许 UI 开发人员使用专注于用户体验(UX)的标记语言(称为 XAML)编写代码,并使用代码绑定访问动态功能。这种方法允许开发人员和 UX 开发人员独立工作,而不影响彼此的工作。
就像 MVC 一样,这个架构中的模型集中了业务逻辑,而视图模型充当模型和视图之间的中介,公开第一个模型中的数据。它还包含大部分视图逻辑,允许 ViewLayer 只专注于显示信息,而将所有动态行为留给 ViewModel。
图 4-10。
An MVVC architecture
如今,这种模式已经被微软之外的其他人采用,比如 Java 中的 ZK 框架和 KnockoutJS、AngularJS、Vue.js 以及 JavaScript 中的其他框架(由于 MVVM 是一种专门从事 UI 开发的模式,所以用 JavaScript 编写的 UI 框架是这种模式的主要采用者是有道理的)。
模型-视图-适配器
模型-视图-适配器 6 (MVA)模式非常类似于 MVC,但是有一些不同之处。主要地,在 MVC 中,主要的业务逻辑集中在每个模型中,它也包含主要的数据结构,控制器负责编排模型和视图。
在 MVA,模型只是您正在处理的数据,业务逻辑集中在适配器中,它负责与视图和模型进行交互。所以基本上,更瘦的模型和更胖的控制器。但是玩笑归玩笑,这允许视图和模型完全解耦,将所有责任交给适配器。
当切换适配器以在相同的视图和模型上实现不同的行为时,这种方法非常有效。
该模式的架构如图 4-11 所示。
图 4-11。
The MVA pattern shown as a diagram
响应处理程序
我们的 API 架构的最后一个组件是响应处理程序;它负责从视图层获取资源表示,并将其发送回客户端。响应格式(与表示的格式不同)必须与请求的格式相同;在这种情况下,它将是一个 HTTP 1.1 消息。
HTTP 响应有两个部分:头部和主体,头部包含几个指定消息属性的字段。消息体的内容是资源的实际表示。标题是我们现在最感兴趣的部分;它包含内容类型、内容长度等字段。其中一些字段是强制的,如果您打算完全遵循 REST 风格(您确实这样做了),那么其中一些字段是必需的。
- 可缓存:来自第一章中定义的 REST 所施加的约束。适用时,每个请求都必须显式或隐式设置为可缓存。这转化为 HTTP 头
cache-control
的使用。 - 内容类型:响应主体的内容类型对于客户端应用理解如何解析数据非常重要。如果您的资源只有一种可能的表示,内容类型可能是一个可选的头,因为您可以通过您的文档通知客户端应用开发人员有关格式的信息。但是如果你在将来改变它,或者添加一个新的,那么它可能会对你的客户造成一些严重的损害。因此,考虑这个标题是强制性的。
- 状态:状态代码不是强制性的,但是非常重要,正如我在前面的章节中提到的。它为客户端应用提供了请求结果的快速指示器。
- 日期:该字段应包含消息发送的日期和时间。它应该采用 HTTP-date 格式 7 (例如,Fri,2014 年 12 月 24 日 23:34:45 GMT)。
- Content-length:该字段应该包含所传输消息体的字节数(长度)。
让我们看一个用 JSON 表示资源的 HTTP 响应的例子:
HTTP/1.0 200 OK
Date: Fri, 31 Dec 1999 23:59:59 GMT
Content-Type: application/json
Cache-control: private, max-age=0, no-cache
Content-Length: 1354
{
"name": "J.K.Rolling",
"id": "ab12351bac",
"books": [
{
"title": "Harry Potter and the Philosopher’s Stone",
"isbn": "9788478888566"
},
{
"title": "Harry Potter and the Prisoner of Azkaban",
"isbn": "9788422685227"
}
]
}
如果您想获得额外的好处,还可以对响应处理程序进行一项改进。这完全是多余的,大多数 Node.js 框架都没有(除了梵蒂冈. js)。
这个想法是有一个后处理函数链,它接收视图层返回的响应内容,并转换它,或者用更多的数据丰富它。它将作为预处理链的第一个版本:整个过程的一个公共链。
有了这个想法,你可以从控制器中抽象出更多的代码,只要把它移到后处理阶段。像模式验证(我将在本书后面讨论)或响应头设置这样的代码可以集中在这里,并增加一个简单的机制来切换它或禁用链中的步骤。
让我们看看我们的 API 的最终架构(见图 4-12 )。
图 4-12。
The final architecture with the response handler and the added post-processing chain
摘要
这一章讲述了完整且实用的 RESTful API 架构的基础知识。它甚至涵盖了一些额外的东西,这些东西并不是必需的,但是拥有它们当然很好,比如预处理和后处理。您还查看了我们设计背后的主要架构(MVC)和一些替代方案,以防您的需求与 MVC 模型不完全匹配。
在下一章,我将开始讨论你将用来编写这个架构的实现的模块。
Footnotes 1
http://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller
见。
2
http://www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented/dp/0201633612/
见。
3
http://dl.acm.org/citation.cfm?id=50757.50759
见。
4
http://en.wikipedia.org/wiki/Hierarchical_model%E2%80%93view%E2%80%93controller
见。
5
http://en.wikipedia.org/wiki/Model_View_ViewModel
见。
6
http://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93adapter
见。
7
http://tools.ietf.org/html/rfc7231#section-7.1.1.1
见。
五、使用模块
正如我在第三章中所讨论的,Node.js 背后有一个巨大的开发者社区;他们愿意花费时间和精力为社区中的其他人提供高质量的模块。在这一章中,我将谈论其中的一些模块;你将看到如何使用它们来得到章节 4 中描述的架构。
更具体地说,您将需要以下部分的模块:
- HTTP 请求和响应处理:这是最基本的特性。正如你将要看到的,有很多选项可供选择。
- 路由处理:除了前面提到的,请求处理是我们系统中最重要和最关键的部分之一。
- 预处理链(中间件):你可以省略后处理,因为它是一个不太常见的特性,但预处理(或中间件)是常见的,非常有用。
- 最新的文档:这不是我们架构的一部分,但是我在第二章谈到好的实践时提到了它。而且恰好有一个模块在这里会有所帮助,所以不妨添加一下。
- 超媒体的回应:同样,不是架构的一部分,而是 REST 的一部分,所以你将使用 HAL 标准来添加它。
- 响应和请求格式验证:最后,这将是一个额外的好处;作为一个良好的实践,总是验证请求和响应的格式。
我们的选择
您将看一下每个模块,而不是单独看每一点,我将相应地对它们进行评估。正如您将看到的,其中一些处理不止一件事情,这有时会很方便,因为让不相关的模块一起工作并不总是一件容易的事情。
请求/响应处理
关于请求和响应处理,它们通常都来自同一个模块。它们是您打算开发的每个 HTTP 应用的基础。如果你不知道如何处理 HTTP 协议,你就无法前进。
因为 Node.js 非常适合 HTTP 应用,所以有很多模块可以帮助您完成这项任务。您需要具备以下功能的设备:
- 在特定端口上侦听 HTTP 流量。
- 将 HTTP 消息转换成 JavaScript 对象,这样您就可以阅读和使用它,而不必担心解析它或 HTTP 协议的任何细节。
- 编写 HTTP 响应,而不必担心 HTTP 消息格式。
编写在特定端口监听 HTTP 流量的代码很简单;实际上,Node.js 提供了您需要的所有现成工具来实现前面三点。如果我们自己可以轻松完成,为什么我们需要额外的模块?这是一个非常有效的问题,老实说,这完全取决于你的需求。如果您正在构建的系统足够小,那么自己构建 HTTP 服务器可能是个好主意;否则,使用经过良好测试和尝试的模块总是好的。它们还为您解决其他相关问题,例如路线处理,因此使用第三方模块可能是一个不错的选择。
路线处理
路由处理与请求和响应处理紧密耦合;这是处理请求的下一步。一旦将 HTTP 消息转换成可以使用的实际 JavaScript 对象,就需要知道哪段代码需要处理它。这就是路由处理的用武之地。
这部分有两个方面。首先,您需要能够在代码中设置路由,并将处理程序的代码与一个或多个特定的路由相关联。然后,系统需要获取所请求资源的路径,并将其与您的路径进行匹配。这可能不是一件容易的事情。请记住,任何复杂系统中的大多数路由都有参数化的部分,如唯一 id 和其他参数。例如,看一下表 5-1 。
表 5-1。
Routing Example
| 这个… | ....需要匹配这个 | | --- | --- | | `/v1/books/1234412` | `/v1/books/:id` | | `/v1/authors/jkrowling/books` | `/v1/:author_name/books` |通常,路由框架提供某种模板语言,允许开发人员在路由模板中设置命名参数。稍后,考虑到添加的可变部分,框架将把请求的 URL 匹配到模板。不同的框架增加了不同的变化;一会儿你会看到其中一些。
中间件
这是预处理链在 Node.js 世界中通常得到的名字,这是因为 Connect 1 框架(这是大多数其他 web 框架所基于的框架)具有这种功能。我在前一章已经谈到了这个话题,所以让我们来看一些与基于连接的框架兼容的中间件功能的例子:
//Logs every request into the standard output
function logRequests(req, res, next) {
console.log("[", req.method, "]", req.url)
next()
}
/**
Makes sure that the body of the request is a valid json object, otherwise, it throws an error
*/
function formatRequestBody(req, res, next) {
if(typeof req.body == 'string') {
try {
req.body = JSON.parse(req.body)
} catch (ex) {
next("invalid data format")
}
}
next()
}
这两个例子是不同的,但同时它们共享一个公共的函数签名。每个中间件函数都接收三个参数:请求对象、响应对象和下一个函数。这里最有趣的是最后一个参数,下一个函数,调用它是强制性的,除非你想在这里结束处理链。它调用链中的下一个中间件,除非您传入一个值,在这种情况下,它将调用它找到的第一个错误处理程序,并向它传递参数(通常是一个错误消息或对象)。
中间件的使用在认证、日志、会话处理等方面非常普遍。
最新文档
正如我已经讨论过的,如果您希望开发人员使用您的系统,保持 API 接口的最新文档是至关重要的。我将介绍一些对该领域有帮助的模块。当然,没有灵丹妙药;有些模块比其他模块增加了更多的开销,但是主要目标是拥有某种尽可能自动更新文档的系统。
超媒体在回应
如果您想不折不扣地遵循 REST 风格,您需要将它融入您的系统。这是 REST 最容易被遗忘的特性之一——也是最重要的特性,因为它允许自我发现,这是 RESTful 系统的另一个特性。
对于这个特殊的例子,您将使用一个预定义的标准 HAL(在第一章中介绍),因此您将检查一些允许您使用这种特殊格式的模块。
响应和请求验证
我还将介绍一些模块,让您可以验证响应和请求格式。我们的 API 将单独使用 JSON,但是由于客户端应用中的错误,在请求中验证 JSON 的结构总是有用的,并且在响应中确保代码更改后服务器端没有错误。
在每个请求上添加验证可能开销太大,因此一个替代方案可能是一个测试套件,它在执行时负责验证。但是必须对每个请求进行请求格式验证,以确保系统的执行不会受到无效请求的影响。
模块列表
现在,让我们来看一些模块,这些模块负责所提到的一个或几个类别;对于每一个,您将列出以下属性:
- 模块的名称
- 它所属的类别
- 当前发布的版本
- 简短的描述
- 主页 URL
- 安装说明
- 代码示例
我们不会对它们进行比较,因为考虑到一些模块只处理一件事情,而另一些模块处理几件事情,这不是一件容易的事情。因此,在看完之后,我将提出这些模块的组合,但是如果它更适合您的问题,您将有足够的信息来选择不同的组合。
哈皮
表 5-2。
HAPI Module Information
| 种类 | 请求/响应处理器、路由处理器 | | 当前版本 | Eight point one | | 描述 | HAPI 是一个以配置为中心的 web 框架,旨在创建任何类型的 web 应用,包括 API。HAPI 的主要目标是让开发人员专注于编写应用的逻辑,而将基础设施代码留给框架。 | | 主页 URL | [`http://hapijs.com/`](http://hapijs.com/) | | 装置 | 使用 npm 安装框架很简单:` NPM install-–save hapi |代码示例
安装后,初始化系统并启动和运行服务器的最基本操作如下:
var hapi = require("hapi")
var server = new hapi.Server()
server.connection({ port: 3000 })
server.start(function() {
console.log("Server running at: ", server.info.url)
})
正如您所看到的,这个例子非常简单,但是初始化应用所需的步骤已经存在。server.connection
行返回一个选择了新连接的服务器对象。这意味着您可以同时保持几个打开的连接,如下所示:
var Hapi = require('hapi');
var server = new Hapi.Server()
// Create the API server with a host and port
var apiServer = server.connection({
host: 'localhost',
port: 8000
});
//Get list of books for normal client app
apiServer.route({
method: 'GET',
path: '/books',
handler: function(req, reply) {
//... code to handle this route
}
})
// Create the Admin server with a port
var adminServer = server.connection({
port: 3000
})
//Setup route to get full list of users for admin (with with credential information)
adminSever.route({
method: 'GET',
path: '/users',
handler: function(req, reply) {
//check credentials...
//get full list of users...
//reply(list)
}
})
server.start(function() {
console.log("Server started!")
})
这段代码初始化应用,应用又设置了两个不同的服务器:一个用于 API 本身,另一个用于管理系统。在这段代码中,您还可以看到与 HAPI 建立路由是多么容易。尽管可以清楚地清理代码并把路由定义放到一个单独的文件中,但这是两个(或更多!)可以使用这个框架来配置具有各自路由的服务器。
HAPI 提供的另一个有趣的东西是你可以通过设置自己的路线模板来使用。有了它,您可以按以下方式使用命名参数:
var Hapi = require('hapi');
var server = new Hapi.Server();
server.connection({ port: 3000 });
var getAuthor = function (request, reply) {
// here the author and book parameters are inside
// request.params
};
server.route({
path: '/{author}/{book?}',
method: 'GET',
handler: getAuthor
});
在前面的代码中,当设置路由时,花括号内的任何内容都被视为命名参数。最后一个,添加了一个?
,这意味着它是可选的。
Note
只有最后一个命名的参数可以设置为可选的;否则,毫无意义。
除了?
,您可以使用另一个特殊字符告诉 HAPI 一个命名参数应该匹配的段数;该字符是*号,如果您希望它匹配任意数量的段,那么它后面应该跟一个大于 1 的数字,或者不跟任何数字。
Note
就像那个。字符,只有最后一个参数可以配置为匹配任意数量的段。
让我们看一些例子:
server.route({
path: '/person/{name*2}', // Matches '/person/john/doe'
method: 'GET',
handler: getPerson
});
server.route({
path: '/author/{name*}', // Matches '/author/j/k/rowling' or '/author/frank/herbert' or /author/
method: 'GET',
handler: getAuthor
});
function getAuthor(req, reply) {
// The different segments can be obtained like this:
var segments = req.params.name.split('/')
}
快递. js
表 5-3。
Express.js Module Information
| 种类 | 请求/响应处理器、路由处理器、中间件 | | 当前版本 | 4.11.2 | | 描述 | Express 是一个成熟的 web 框架,为 HTTP 服务器提供了小而健壮的工具,使它成为各种 web 应用(包括 RESTful APIs)的绝佳候选。 | | 主页 URL | `http://expressjs.com` | | 装置 | `$ npm install –g express-generator` |代码示例
当涉及到在 Node.js 中构建 web 应用时,Express.js 有时被认为是事实上的解决方案,就像 Ruby on Rails 在很长一段时间内对于 Ruby 的意义一样。话虽如此,但这并不意味着 Express.js 应该是唯一的选择,或者它是每个项目的正确选择;因此,在为你的项目选择一个 web 框架之前,请确保你已经充分了解了相关信息。
这个特殊的框架已经发展了很多年,现在在版本 4 中它提供了一个生成器。要初始化整个项目,您必须使用以下代码行(按照表 5-3 中的说明安装后):
$express ./express-test
该行将生成如图 5-1 所示的输出。
图 5-1。
Output of the express generator command
该框架会生成许多文件夹和文件,但一般来说,它是一个通用 web 应用的结构,该应用具有视图、样式、JavaScript 文件和其他与 web 应用相关的资源。这不适合我们,因为我们正在构建一个 RESTful API。您需要删除这些文件夹(更具体地说,是视图和公共文件夹)。
要完成这个过程,只需进入文件夹并安装依赖项;这将为您留下一个工作的 web 应用。如果你想知道初始化框架需要什么,可以查看一下app.js
文件。
现在让我们看看在 Express.js 中设置路线需要做些什么:
//...
var app = express()
//...
app.get('/', function(req, res) {
console.log("Hello world!")
})
就这样。设置路线时,您只需记住以下几点:应用。动词(URL 模板、处理程序函数)。处理函数将接收三个参数:请求对象、响应对象和下一个函数。最后一个参数仅在为同一路由和方法组合设置多个处理程序时有用;这样你就可以把这些方法像中间件一样链接起来。
看一下下面的例子:
app.route('/users/:id')
.all(checkAuthentication)
.all(loadUSerData)
.get(returnDataHandler)
.put(updateUserHandler)
在前面的代码中,发生了几件有趣的事情:
- 命名参数用于用户的 ID。
- 为每个到达
'/users/:id'
路径的动词设置了两个中间件函数。 - 它为命中 URL 的 GET 方法设置了一个处理程序,同时,它还为何时放置动词设置了一个处理程序——所有这些都在同一行代码中。
Express 提供了自己风格的命名参数(您可以在前面的代码中看到一个例子),但是您还可以做其他事情。例如,您可以使用正则表达式:
router.get(/^\/commit\/(\w+)(?:\.\.(\w+))?$/, function(req, res){
var from = req.params[0];
var to = req.params[1] || 'HEAD';
res.send('commit range ' + from + '..' + to);
});
前面的代码匹配'/commit/5bc2ab'
和'/commit/5bc2ab..57ba31'
,您可以看到在处理程序代码中获取参数也很简单。
还可以设置一个回调函数,在收到特定的命名参数时做一些处理;例如:
var router = express.Router()
router.param('user_id', function(req, res, next, id) {
loadUser(id, function(err, usr) {
if(err) {
next(new Error("There was an error loading the user's information")) //this will call erorr handler
} else {
req.user = usr
next()
}
})
})
//then on the route definition
app.get('/users/:user_id', function(req, res) {
//req.user is already defined and can be used
})
如果在user_id
回调函数上有一个错误,那么路由的处理程序将永远不会被调用,因为第一个错误处理程序将被调用。
最后,让我们看一些在 Express.js 中使用中间件的例子。我已经在前面介绍了这种类型函数的基础知识,但是您从来没有看到如何在 Express.js 中使用它。您可以通过两种方式来完成它:设置一个全局中间件或一个特定于路线的中间件。
对于全局中间件,您只需这样做:
app.use(function(req, res, next) {
//your code here will be executed on every request
next() //remember to call next unless you want the chain to end here.
})
对于特定于路由的中间件,您可以这样做:
app.use('/books', function(req, res, next){
//this function will only be called on this path
next() //always important to call next unless you don't want the process' flow to continue.
})
您甚至可以建立一个特定于路由的中间件堆栈,只需这样做:
app.use('/books', function(req, res, next){
//this function will only be called on this path
next() //always important to call next unless you don't want the process' flow to continue.
}, function(req, res, next) {
//as long as you keep calling next, the framework will keep advancing in the chain until reaching the actual handler
next()
})
重新定义
表 5-4。
Restify Module’s Information
| 种类 | 请求/响应处理器、路由处理器、中间件 | | 当前版本 | 2.8.5 | | 描述 | Restify 是一个专门为构建 REST APIs 而设计框架。它大量借鉴了 Express.js(特别是 4.0 之前的版本),因为 Express 被认为是构建 web 应用的标准。 | | 主页 URL | `http://mcavage.me/node-restify/` | | 装置 | `$ npm install restify` |代码示例
Restify 借用了 Express 的很多特性,所以我将重点放在它添加的东西上;其他示例,请参考前面的模块或访问 Restify 主页。
初始化比 Express 简单,尽管没有代码生成器。以下代码是启动服务器所需的全部内容:
var restify = require('restify');
var server = restify.createServer({
name: 'MyApp',
});
server.listen(8080);
createServer
方法提供了一些有用的选项,将在未来简化你的工作。表 5-5 列出了 Restify 的一些选项。
表 5-5。
List of Restify Options
| [计]选项 | 描述 | | --- | --- | | 证书 | 对于构建 HTTPS 服务器,请在此处传递证书的路径。 | | 键 | 对于构建 HTTPS 服务器,在这里传递密钥文件的路径。 | | 原木 | 或者,您可以传入记录器的实例。它需要是 node-bunyan 的实例。 2 | | 名字 | API 的名称。用于设置服务器响应头;默认情况下,它是“restify”。 | | 版本 | 所有路由的默认版本。 | | 格式化程序 | 一组用于内容协商的内容格式化程序。 |在最基本的方面,路由的处理就像 Express 一样:可以传入路径模板和路由处理程序,也可以传入正则表达式和处理程序。
以一种更高级的方式,Restify 提供了一些 Express 没有的好东西。以下小节提供了一些示例。
命名路线
您可以为特定的路由设置名称,这将允许您使用该属性从一个处理程序跳转到其他处理程序。让我们先看看如何设置名称:
server.get(``'/foo/:id'
next('foo2');
});
server.get({
name: 'foo2',
path: '/foo/:id'
}, function (req, res, next) {
res.send(200);
next();
});
这段代码为同一个路径设置了两个不同的处理程序,但是 Restify 只会执行它找到的第一个处理程序,所以第二个处理程序永远不会被执行,除非用第二个路径的名称调用next
语句。
在呈现响应时,命名还用于引用路由,这提供了一个有趣的特性:响应上的超媒体。老实说,Restify 提出的解决方案有点基础,它并没有真正提供一个自动添加超媒体进行自我发现的良好机制,但它比大多数其他框架都要好。它是这样工作的:
var restify = require("restify")
var server = restify.createServer()
server.get({
name: 'country-cities',
path: '/country/:id/cities'
}, function(req, res, next) {
res.send('cities')
})
server.get('/country/:id', function(req, res, next) {
res.send({
name: "Uruguay",
cities: server.router.render('country-cities', {id: "uruguay"})
})
})
server.listen(3000)
版本化路线
正如您前面看到的,Restify 提供了对全局版本号的支持,但是它也提供了在每条路由的基础上拥有不同版本的能力。此外,它还支持 Accept-version 标头选择正确的路线。
Note
如果缺少头,并且同一个路由有多个版本可用,Restify 将选择代码中定义的第一个。
下面是如何做到这一点:
function respV1(req, res, next) {
res.send("This is version 1.0.2")
}
function respV2(req, res, next) {
res.send("This is version 2.1.3")
}
var myPath = "/my/route"
server.get({path: myPath, version: "1.0.2"}, respV1)
server.get({path: myPath, version: "2.1.3"}, respV2)
现在,当点击具有不同接受版本值的路径时,表 5-6 中的信息就是您所得到的。
表 5-6。
Examples of Content Negotiation
| 使用的版本 | 反应 | 描述 | | --- | --- | --- | | | 这是版本 1.0.2 | 没有使用任何版本,所以默认情况下,服务器选择第一个定义的版本。 | | 第一个 | 这是版本 1.0.2 | 选择了版本 1.x.x,因此这是服务器的响应。 | | 【三】 | { "code": "InvalidVersion "," message": "GET /my/route 支持版本:1.0.2,2.1.3" } | 当请求不支持的版本时,会返回一条错误消息。 |内容协商
Restify 提供的另一个有趣的特性是支持内容协商。要实现此功能,您只需在初始化期间提供正确的内容格式化程序,如下所示:
restify.createServer({
formatters: {
'application/foo; q=0.9': function formatFoo(req, res, body) {
if (body instanceof Error)
return body.stack;
if (Buffer.isBuffer(body))
return body.toString('base64');
return util.inspect(body);
}
}
})
Note
默认情况下,Restify 附带了用于 application/json、text/plain 和 application/octect-stream 的格式化程序。
Restify 还提供了其他一些我没有涉及的小特性,所以请参考官方网站获取信息。
梵蒂冈. js
表 5-7。
Vatican.js Module Information
| 种类 | 请求/响应处理器、中间件、路由处理 | | 当前版本 | 1.3.2 | | 描述 | vantage . js 是一个旨在创建 RESTful APIs 的框架的又一次尝试。它不遵循快速/重新定义路径。它更关注 API 的 MVP 阶段,但是它提供了一个有趣的选择。 | | 主页 URL | [`http://www.vaticanjs.info`](http://www.vaticanjs.info/) | | 装置 | `$ npm install –g vatican` |代码示例
安装后,vantage . js 提供了一个命令行脚本来创建项目,并向其中添加资源和资源处理程序。因此,要启动项目,您需要使用以下命令:
$ vatican new test_project
前面的代码生成如图 5-2 所示的输出。
图 5-2。
Output of the Vatican.js generate action
主文件(index.js
)有以下内容:
var Vatican = require("vatican")
//Use all default settings
var app = new Vatican()
app.dbStart(function() {
console.log("Db connection stablished...")
//Start the server
app.start()
} )
梵蒂冈提供了 MongoDB 集成,因此dbStart
方法实际上是对 NoSQL 存储连接的引用。默认情况下,假设服务器位于 localhost 中,使用的数据库名称是vatican-project
。
梵蒂冈的默认端口是 8753,但是就像梵蒂冈的所有默认端口一样,它可以在实例化阶段被覆盖。这些是可以传递给构造函数的选项,如表 5-8 所示。
表 5-8。
List of Options for the Vatican.js Constructor
| [计]选项 | 描述 | | --- | --- | | 港口 | HTTP 服务器的端口。 | | 经理人 | 存储所有处理程序的文件夹的路径。默认是`./handlers`。 | | | 具有两个属性的对象:host 和 dbname。 | | 克-奥二氏分级量表 | 这是一个布尔值,表示 API 是否支持 CORS,或者是一个对象,表示每个支持的头。 |在梵蒂冈设置路线也和其他的有点不一样;命令行脚本提供了为实体/模型文件和控制器/处理程序文件自动生成代码的能力,其中还包括 CRUD 操作的基本代码。
要自动生成代码,请在项目文件夹中使用以下命令:
$ vatican g Books -a title:string description:string copies:int -m newBook:post listBooks:get removeBook:delete
该行输出类似于图 5-3 所示的内容。
图 5-3。
Output of the resource generator command
这基本上意味着梵蒂冈创建了处理程序文件和实体(在 schemas 文件夹中)。如果你检查处理程序的文件,你会注意到所有的动作都已经有了它们的代码;这是因为梵蒂冈能够通过使用它们的名称来猜测命令行中提供的操作的含义:
newBook
:使用“new”假设您正在创建资源的一个新实例。listBooks
:使用“list”假设你想生成一个物品列表。removeBook
:使用“移除”假设你正试图移除一个资源。
这些词的变体也是有效的,梵蒂冈将使用它们来猜测代码。您现在可以开始启动服务器了;端点将工作并将信息保存到数据库。
关于资源生成的最后一个评论是关于路由的;您还没有指定任何路线,但梵蒂冈已经创建了它们。在处理程序文件中,您会注意到以下形式的注释:
@endpoint (url: /books method: post)
BooksHdlr.prototype.newBook = function(req, res, next) {
var data = req.params.body
//...maybe do validation here?
this.model.create(data, function(err, obj) {
if(err) return next(err)
res.send(obj)
})
}
方法定义上面的注释不是标准的 JavaScript,但是梵蒂冈能够解析它,并在启动时将它转换成数据。这意味着梵蒂冈没有路线文件;每个路由都是在它的相关方法之上定义的,如果您想获得系统的完整路由列表,可以使用下面的命令行:
$ vatican list
它将产生如图 5-4 所示的输出,其中列出了每个处理程序的所有路径,包括方法、路径、文件和相关的方法名。
图 5-4。
Output from the list command Note
注释可以用一行注释掉,以避免你的编辑抱怨这个结构;即使这样,vantage . js 也能够解析它。
最后,梵蒂冈也属于中间件类别,这是因为尽管它不是基于 Connect 或 Express,但它支持基于 Connect 的中间件。唯一的区别是使用它的方法名。
vatican.preprocess(middlewareFunction) //generic middleware for all routes
vatican.preprocess(middelwareFunction, ['login', 'authentication']) //middleware for two routes: login and authentication.
要设置路径的名称,您可以在注释中添加该参数,如下所示:
@endpoint(url: /path method: get name: login)
还有一些更多梵蒂冈. js 提供的功能。要了解它们,请参考官方网站。
斯瓦格 Node 快车
表 5-9。
swagger-node-express Module Information
| 种类 | 最新文档 | | 当前版本 | 2.1.3 | | 描述 | 这是一个速成模块。它集成到一个 Express 应用中,并提供 Swagger 3 用于记录 API 的功能,这是一个包含每种方法的文档和尝试这些方法的能力的 web 界面。 | | 主页 URL | [`https://github.com/swagger-api/swagger-node-express`](https://github.com/swagger-api/swagger-node-express) | | 装置 | `$ npm install swagger-node-express` |代码示例
安装模块后,您需要做的第一件事是将 Swagger 集成到您的 Express 应用中。下面是实现这一点的代码:
// Load module dependencies.
var express = require("express")
, app = express()
, swagger = require("swagger-node-express").createNew(app);
// Create the application.
app.use(express.json());
app.use(express.urlencoded());
集成完成后,接下来要做的事情是添加模型和处理程序。模型是 JSON 数据的形式(这是由开发人员的偏好决定的)。处理程序包含路由处理程序的实际代码,以及作为文档的其他描述性字段。
让我们看一个模型定义的例子:
exports.models = {
"Book": {
"id": "Book",
"required": ["title", "isbn"],
"properties": {
"title": {
"type": "string",
"description": "The title of the book"
},
"isbn": {
"type": "string",
"description": "International Standard Book Number"
},
"copies": {
"type": "integer",
"format": "int64",
"description": "Number of copies of the book owned by the bookstore"
}
}
}
}
如您所见,使用的格式是 JSON Schema 4 ,维护起来可能会很繁琐,但它为 Swagger 理解我们的模型是如何创建的提供了一种标准方式。
Tip
手动维护大量的模型描述可能工作量太大,并且很容易在文档中产生错误,所以使用描述来自动生成模型的代码,或者从模型的代码中自动生成描述可能是一个好主意。
一旦模型描述完成,您可以像这样将其添加到 Swagger 中:
// Load module dependencies.
var express = require("express")
, swagger = require("swagger-node-express")
, models = require('./models-definitions').models
//....
swagger.addModels(models)
现在转到处理程序的描述,它包含描述每个方法的字段,以及要执行的实际代码。
//Book handler's file
exports.listBooks = {
"spec": {
"description": "Returns the list of books",
"path": "/books.{format}",
"method": "GET",
"type": "Book",
"nickname": "listBooks",
"produces": ["application/json"],
"parameters": [swagger.paramTypes.query("sortBy","Sort books by title or isbn", "string")]
},
"action": function(req, res) {
//...
}
}
//main file's code
var bookHandler = require("./bookHandler")
//...
swagger.addGet(bookHandler.listBooks) // adds the handler for the list action and the actual action itself
这段代码展示了如何描述一个特定的服务(一个图书列表)。同样,这些参数中的一些(在规范对象中)可以自动生成;否则,手动维护大量规范会导致文档过时。
最后,设置 Swagger UI 的 URL(它将显示文档,还将提供测试 API 的 UI)和版本:
swagger.configure("
http://myserver.com
现在让我们看一个主文件的完整例子,展示 Swagger 和 Swagger UI 的设置和配置。 5
// Load module dependencies.
var express = require("express")
, models = require("./models").models
, app = express()
, booksHandler = require("./booksHandler") //load the handler's definition
, swagger = require("swagger-node-express").createNew(app) //bundle the app to swagger
// Create the application.
app.use(express.json());
app.use(express.urlencoded());
var static_url = express.static(__dirname + '/ui') //the swagger-ui is inside the "ui" folder
swagger.configureSwaggerPaths("", "api-docs", "") //you remove the {format} part of the paths, to simplify things
app.get(/^\/docs(\/.*)?$/ , function(req, res, next) {
if(req.url === '/docs') {
res.writeHead(302, {location: req.url + "/"})
res.end()
return
}
req.url = req.url.substr('/docs'.length)
return static_url(req, res, next)
})
//add the models and the handler
swagger
.addModels(models)
.addGet(booksHandler.listBooks)
swagger.configure("``http://localhost:3000
app.listen("3000")
图 5-5 是你通过访问http://localhost:3000/docs
得到的结果 UI 的截图。
图 5-5。
The generated UI
I/odoc
表 5-10。
I/O Docs Module Information
| 种类 | 最新文档 | | 当前版本 | 不适用的 | | 描述 | I/O Docs 是一个为 RESTful APIs 设计的实时文档系统。通过使用 JSON 模式定义 API,I/O Docs 生成一个 web 接口来测试 API。 | | 主页 URL | [`https://github.com/mashery/iodocs`](https://github.com/mashery/iodocs) | | 装置 | ` cd iodocs` |代码示例
安装完成后,测试应用剩下的唯一工作就是创建一个配置文件;有一个config.json.sample
文件可以作为起点。
要启动文档服务器,请使用以下命令之一:
$ npm start #for *nix and OSX systems
C:\your-project-folder> npm startwin #for Windows systems
之后,用你的浏览器去http://localhost:3000
开始测试文档系统。
图 5-6 是一个已经配置好的示例 API 的截图。
图 5-6。
The default UI when trying out methods
正如你在图 5-6 中看到的,当方法被测试时,一个响应显示在下面。如果您想建立自己的 API,有几件事要做:
Add your API to the list of documented APIs inside public/data/apiconfig.json
like this:
{
"klout": {
"name": "Klout v2 API"
},
"egnyte": {
"name": "Egnyte API"
},
"usatoday": {
"name": "USA TODAY Census API"
},
"foursquare": {
"name": "Foursquare (OAuth 2.0 Auth Code)"
},
"rdio": {
"name": "Rdio Beta (OAuth 2.0 Client Credentials)"
},
"rdio2": {
"name": "Rdio Beta (OAuth 2.0 Implicit Grant)"
},
"requestbin": {
"name": "Requestb.in"
},
"bookstore": {
"name": "Dummy Bookstore API"
}
}
Create a new file called bookstore.json
and store it inside the public/data folder. This new JSON file will contain the description of your API and the methods in it; something like this:
{
"name": "Dummy Bookstore API",
"description": "Simple bookstore API",
"protocol": "rest",
"basePath": "
http://api.mybookstore.com
"publicPath": "/v1",
"auth": {
"key": {
"param": "key"
}
},
"headers": {
"Accept": "application/json",
"Foo": "bar"
},
"resources": {
"Books": {
"methods": {
"listBooks": {
"name": "List of books",
"path": "/books",
"httpMethod": "GET",
"description": "Returns the list of books in stock",
"parameters": {
"sortBy": {
"type": "string",
"required": false,
"default": "title",
"description": "Sort the results by title or ISBN code"
}
}
},
"showBook": {
"name": "Show book",
"path": "/books/{bookId}",
"httpMethod": "GET",
"description": "Returns the data of one specific book",
"parameters": {
"bookId": {
"type": "string",
"required": true,
"default": "",
"description": "The ID of the specific book"
}
}
}
}
}
}
}
图 5-7。
Your custom documentation translated into a web UI Start up the documentation server and point your web browser to it. You’ll see a screen that looks similar to Figure 5-7.
与 Swagger 不同,这个文档系统并不打算集成到您的项目中,所以自动生成 JSON 代码可能会有点困难。
哈斯顿
表 5-11。
Halson Module Information
| 种类 | 超媒体在回应 | | 当前版本 | 2.3.1 | | 描述 | Halson 是一个帮助创建符合 HAL 的 JSON 对象的模块,然后您就可以在 API 中将它作为响应的一部分。 | | 主页 URL | `http://github.com/seznam/halson` | | 装置 | `$ npm install halson` |代码示例
这个模块提供的 API 非常简单,如果你已经阅读了这个标准, 6 你应该不难理解如何使用它。
以下是自述文件中的示例:
var halson = require('halson');
var embed = halson({
title: "joyent / node",
description: "evented I/O for v8 javascript"
})
.addLink('self', '/joyent/node')
.addLink('author', {
href: '/joyent',
title: 'Joyent'
});
var resource = halson({
title: "Juraj Hájovský",
username: "hajovsky",
emails: [
"juraj.hajovsky@example.com",
"hajovsky@example.com"
]
})
.addLink('self', '/hajovsky')
.addEmbed('starred', embed);
console.log(JSON.stringify(resource));
上述代码将输出以下内容:
{
"title": "Juraj Hájovský",
"username": "hajovsky",
"emails": [
"``juraj.hajovsky@example.com
"``hajovsky@example.com
],
"_links": {
"self": {
"href": "/hajovsky"
}
},
"_embedded": {
"starred": {
"title": "joyent / node",
"description": "evented I/O for v8 javascript",
"_links": {
"self": {
"href": "/joyent/node"
},
"author": {
"href": "/joyent",
"title": "Joyent"
}
}
}
}
}
如你所见,该模块成功地抽象出了关于 HAL 标准的细节;你只需要知道如何添加链接和什么是嵌入对象。
硬件抽象层(Hardware Abstract Layer 的缩写)
表 5-12。
HAL Module Information
| 种类 | 超媒体在回应 | | 当前版本 | 0.1.0 | | 描述 | 哈尔是哈尔森的替代者。它提供了一个更简单的接口,但具有相同的底层功能:抽象出 HAL+JSON 格式,并为开发人员提供了一种简单的使用方法。 | | 主页 URL | [`https://www.npmjs.com/package/hal`](https://www.npmjs.com/package/hal) | | 装置 | `$ npm install hal` |代码示例
这个模块的 API 比 HALSON 提供的更简单,并且它还提供了 XML 编码(请记住,即使您没有关注 XML,它也可能是您的资源的第二种表示)。
让我们看一个简单的书店主题的例子:
var hal = require('hal');
var books = new hal.Resource({name: "Books list"}, "/books")
var listOfBooks = [
new hal.Resource({id: 1, title: "Harry Potter and the Philosopher's stone", copies: 3}, "/books/1"),
new hal.Resource({id: 2, title: "Harry Potter and the Chamber of Secrets", copies: 5}, "/books/2"),
new hal.Resource({id: 3, title: "Harry Potter and the Prisoner of Azkaban", copies: 6}, "/books/3"),
new hal.Resource({id: 4, title: "Harry Potter and the Goblet of Fire", copies: 1}, "/books/4"),
new hal.Resource({id: 5, title: "Harry Potter and the Order of the Phoenix", copies: 8}, "/books/5"),
new hal.Resource({id: 6, title: "Harry Potter and the Half-blood Prince", copies: 2}, "/books/6"),
new hal.Resource({id: 7, title: "Harry Potter and the Deathly Hollows", copies: 7},"/books/7")
]
books.embed('books', listOfBooks)
console.log(JSON.stringify(books.toJSON()))
该代码将输出以下 JSON 代码:
{
"_links": {
"self": {
"href": "/books"
}
},
"_embedded": {
"books": [
{
"_links": {
"self": {
"href": "/books/1"
}
},
"id": 1,
"title": "Harry Potter and the Philosopher's stone",
"copies": 3
},
{
"_links": {
"self": {
"href": "/books/2"
}
},
"id": 2,
"title": "Harry Potter and the Chamber of Secrets",
"copies": 5
},
{
"_links": {
"self": {
"href": "/books/3"
}
},
"id": 3,
"title": "Harry Potter and the Prisoner of Azkaban",
"copies": 6
},
{
"_links": {
"self": {
"href": "/books/4"
}
},
"id": 4,
"title": "Harry Potter and the Goblet of Fire",
"copies": 1
},
{
"_links": {
"self": {
"href": "/books/5"
}
},
"id": 5,
"title": "Harry Potter and the Order of the Phoenix",
"copies": 8
},
{
"_links": {
"self": {
"href": "/books/6"
}
},
"id": 6,
"title": "Harry Potter and the Half-blood Prince",
"copies": 2
},
{
"_links": {
"self": {
"href": "/books/7"
}
},
"id": 7,
"title": "Harry Potter and the Deathly Hollows",
"copies": 7
}
]
},
"name": "Books list"
}
JSON-Gate - 维基百科,自由的百科全书
表 5-13。
JSON-Gate Module Information
| 种类 | 请求/响应验证 | | 当前版本 | 0.8.22 | | 描述 | 该模块根据遵循 JSON 模式格式的预定义模式来验证 JSON 对象的结构和内容。 | | 主页 URL | [`https://www.npmjs.com/package/json-gate`](https://www.npmjs.com/package/json-gate) | | 装置 | `$ npm install json-gate` |代码示例
这个模块的用法非常简单。首先,您需要定义验证对象所依据的模式。这可以用createSchema
方法直接完成,或者(推荐)在一个单独的文件中完成,然后传递给验证器。添加模式后,您可以根据需要验证任意数量的对象。
这里有一个简单的例子:
var createSchema = require('json-gate').createSchema;
var schema = createSchema({
type: 'object',
properties: {
title: {
type: 'string',
minLength: 1,
maxLength: 64,
required: true
},
copies: {
type: 'integer',
maximum: 20,
default: 1
},
isbn: {
type: 'integer',
required: true
}
},
additionalProperties: false
});
var invalidInput = {
title: "This is a valid long title for a book, it might not be the best choice!",
copies: "3"
}
try {
schema.validate(invalidInput);
} catch(err) {
return console.log(err)
}
上述代码将输出以下错误:
[Error: JSON object property 'title': length is 71 when it should be at most 64]
这里有两点需要注意:
- 一方面,错误消息非常“人性化”JSON-Gate 报告的所有错误信息都是这样的,很容易理解你做错了什么。
- 另一方面,正如您可能注意到的,
invalidInput
对象在格式上有两个错误;验证会在第一个错误时停止,因此纠正多个问题可能会很慢,因为您必须一次纠正一个问题。
如果您不喜欢捕捉异常(为什么要在 Node.js 中捕捉呢?),对于validate
方法还有一个替代方法,即传入第二个参数——一个回调函数,有两个参数:错误对象和原始输入对象。
TV4
表 5-14。
TV4 Module Information
| 种类 | 请求/响应验证 | | 当前版本 | 1.1.9 | | 描述 | 该模块提供了针对 JSON 模式版本 4 的验证。 7 | | 主页 url | [`https://www.npmjs.com/package/tv4`](https://www.npmjs.com/package/tv4) | | 装置 | `$ npm install tv4` |代码示例
这个验证器和 JSON-Gate 的主要区别在于,它是针对 JSON 模式草案的第 4 版的。它还允许您在验证过程中收集多个错误并引用其他模式,因此您可以在不同的部分中重用部分模式。
让我们看一些例子:
var validator = require("tv4")
var schema ={
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
}
},
"required": ["firstName", "lastName"]
}
var invalidInput = {
firstName: 42,
age: "100"
}
var results = validator.validateMultiple(invalidInput, schema)
console.log(results)
前面的示例将输出以下错误对象:
{ errors:
[ { message: 'Missing required property: lastName',
params: [Object],
code: 302,
dataPath: '',
schemaPath: '/required/1',
subErrors: null,
stack: ‘... ...’},
{ message: 'Invalid type: number (expected string)',
params: [Object],
code: 0,
dataPath: '/firstName',
schemaPath: '/properties/firstName/type',
subErrors: null,
stack: ‘......’},
{ message: 'Invalid type: string (expected integer)',
params: [Object],
code: 0,
dataPath: '/age',
schemaPath: '/properties/age/type',
subErrors: null,
stack: '......'}]
missing: [],
valid: false }
输出比 JSON-Gate 的输出大得多,在使用它之前需要进行一些解析,但是除了简单的错误消息之外,它还提供了相当多的信息。
关于这个验证器提供的 API 的完整参考,请访问它的主页。要了解使用 JSON 模式可以完成的所有可能的验证,请访问在线草稿。
摘要
这一章涵盖了许多模块,它们将帮助你创建完美的 API 架构。在挑选工作工具的选项上,每个类别至少有两个模块。
在下一章中,你将定义你将在接下来的章节中开发的 API,有了这个定义,你也将挑选你将用来开发它的模块集(从本章的列表中)。
Footnotes 1
https://www.npmjs.com/package/connect
见。
2
https://github.com/trentm/node-bunyan
见。
3
4
5
https://github.com/swagger-api/swagger-ui
见。
6
http://stateless.co/hal_specification.html
见。
7
http://json-schema.org/latest/json-schema-core.html
见。
六、规划您的 REST API
您几乎已经准备好动手开发实际的 API 了;但是在你开始之前,让我们应用我在这一点上所说的一切:
- REST
- 定义理想的 RESTful 架构应该是什么样子
- 开发 API 时的良好实践
- 有助于实现这一理想目标的模块
在这一章中,我将为这本书将带你经历的最终发展设置基础工作:
- 我将定义一个要解决的具体问题。
- 您将为它创建一个书面规范,写下资源和端点的列表。
- 为了帮助理解所有这些资源是如何相互关联的,您将为我们的系统创建一个 UML 图。
- 我将讨论数据库引擎的一些选项,为我们的问题选择最好的一个。
本章的最终结果将是你开始开发过程所需要的所有信息(将在下一章讨论)。
问题
如果你还没有注意到,在本书中,每一个主要的(也可能是次要的)代码示例和虚假场景都是使用书店作为该示例的根来完成的。这一章保持了这一趋势,因此,与其切换到另一个领域,不如深入挖掘,充实我们的假书店。
让我们称我们的假书店为 Come&Read,并假设我们被要求创建一个分布式 API,将书店带入 21 世纪。
现在,这是一个相当不错的行业。书店目前在美国有 10 个不同的销售点;不是很多,但是公司领导层正在考虑扩展到更多的州。然而,目前的主要问题是,所有这些商店几乎都没有进入数字时代。工作和记录保存的方式非常手工化和异构化;例如:
- 一些较小的商店将记录保存在纸上,并将手工打印的周报表发送给总部。
- 虽然较大的地点倾向于使用某种 CRM 软件,但只要数字被导出为通用格式并在每周报告中发送,就没有标准。
- 根据每周报告,总店处理整个连锁店的库存事宜(商店特定库存、全球库存、单店和全球销售、员工记录等)。).
- 总的来说,书店缺乏与顾客的网络互动,而这是 21 世纪的企业所必须具备的。它的网站只列出了地址和电话号码,仅此而已。
图 6-1 显示了该连锁书店的现状。
图 6-1。
How every store connects to the main store
如图 6-1 所示,所有的二级店都通过一条非常细的线连接到位于伊利诺伊州斯普林菲尔德的总店。
我们的目标是发展业务,不仅要在全国各地开设新店,还要加强所有商店之间的联系。为了实现这一点,一切的支柱将是我们的 API。我们的系统必须是一个分散的系统,这意味着您将像对待任何其他商店一样对待主商店,并为未来可能出现的每个客户端应用提供一组通用的工具和数据源,立即允许以下事情:
- 跨商店搜索
- 全球库存自动控制
- 在全球范围内自动控制销售
- 网站和移动应用等的动态数据源
这家连锁书店的新的精神形象可能如图 6-2 所示。
图 6-2。
The new status of the bookstore chain
图 6-2 显示了生活在云中的新系统,所有商店都直接连接到它。现在这种联系更加紧密,因为一切都是自动完成的,所有商店都可以获得每一条信息。此外,这种新的基于 API 的系统允许轻松开发与潜在客户互动的新方式,包括移动应用和动态网站。
规格
现在我们知道了链的当前情况和我们系统的目标,我们需要开始写一些硬规范。这些将决定系统发展的方式,并通过给我们一个更好的项目规模的概念来帮助规划开发。规范还帮助我们在开始实现之前发现任何设计错误。
Note
我们不会在编写系统规范的过程上花费太多时间,因为这个主题超出了本书的范围。我们会列出规格,并记录下任何可能极其相关的内容;剩下的就留给你对这个过程的理解了。
为了提供所提到的一切,系统需要具有以下特征:
- 跨商店图书搜索/列表功能。
- 存储:该代码负责向所有其他实体提供信息,并与您选择的数据存储系统直接对话。
- 销售:该功能专用于店内和在线销售。
- 用户对书籍的评论:这将在书店和潜在客户之间提供一个急需的互动层。
- 认证:适用于商店员工和顾客。
表 6-1 描述了我们将在这个实现中处理的资源。
表 6-1。
Resources, Properties, and Basic Descriptions
| 资源 | 性能 | 描述 | | --- | --- | --- | | 书 | 书名作者 ISBN 代码商店流派描述评论价格 | 这是主要的实体;它具有识别一本书并在特定商店中定位它所需的所有属性。 | | 作者 | 名称描述书籍网站图像/头像 | 这个资源与一本书的资源高度相关,因为它列出了书店中每本书的作者。 | | 商店 | 姓名地址州电话号码员工 | 每个商店的基本信息,包括地址、员工等。 | | 雇员 | 名字姓氏出生日期地址电话号码电子邮件雇佣日期员工号码商店 | 对于管理员类型的用户来说,员工信息、联系数据和其他内部属性可能会很方便。 | | 客户 | 姓名地址电话号码电子邮件 | 客户的基本联系信息。 | | 图书销售 | 日期书籍商店员工客户总金额 | 一本书的销售记录。它可以与商店销售或在线销售相关。 | | 客户评论 | 客户书籍评论文章明星 | 保存客户对图书的评论的资源。客户可以输入一个简短的自由文本评论和一个 0 到 5 之间的数字来表示星级。 |Note
即使没有在表 6-1 中列出,所有的资源都会有一些与数据库相关的属性,比如id
、created_at
和updated_at
,这些属性你会在整个代码中用到。
基于表 6-1 中的资源,让我们创建一个新表,列出每个资源所需的端点。表 6-2 有助于定义每个资源所关联的功能。
表 6-2。
List of Endpoints, Associated Parameters, and HTTP Methods
| 端点 | 属性 | 方法 | 描述 | | --- | --- | --- | --- | | `/books` | `q`:可选搜索词。`genre`:可选按图书流派过滤。默认为“全部”。 | 得到 | 列出并搜索所有书籍。如果`q`参数存在,它被用作自由文本搜索;否则,端点可用于按流派返回图书列表。 | | `/books` | | 邮政 | 创建一本新书并将其保存在数据库中。 | | `/books/:id` | | 得到 | 返回特定书籍的信息。 | | `/books/:id` | | 放 | 更新书籍上的信息。 | | `/books/:id/authors` | | 得到 | 返回某本书的作者。 | | `/books/:id/reviews` | | 得到 | 返回特定图书的用户评论。 | | `/authors` | `genre`:可选;默认为“全部”。`q`:可选搜索词。 | 得到 | 返回作者列表。如果`genre`存在,它用于根据出版的书籍类型进行过滤。如果`q`存在,它用于对作者的信息进行自由文本搜索。 | | `/authors` | | 邮政 | 添加新作者。 | | `/authors/:id` | | 放 | 更新特定作者的数据。 | | `/authors/:id` | | 得到 | 返回特定作者的数据。 | | `/authors/:id/books` | | 得到 | 返回特定作者写的书籍列表。 | | `/stores` | `state`:可选;按州名过滤商店列表。 | 得到 | 返回商店列表。 | | `/stores` | | 邮政 | 将新商店添加到系统中。 | | `/stores/:id` | | 得到 | 返回特定存储的数据。 | | `/stores/:id/books` | `q`:可选;对特定书店中的图书进行全文搜索。`genre`:可选;按流派过滤结果。 | 得到 | 返回特定书店的库存图书列表。如果使用属性`q`,它将对这些书籍执行全文搜索。 | | `/stores/:id/employees` | | 得到 | 返回在特定商店工作的员工列表。 | | `/stores/:id/booksales` | | 得到 | 返回特定商店的销售额列表。 | | `/stores/:id` | | 放 | 更新特定商店的信息。 | | `/employees` | | 得到 | 返回在所有商店工作的雇员的完整列表。 | | `/employees` | | 邮政 | 向系统中添加新员工。 | | `/employees/:id` | | 得到 | 返回特定雇员的数据。 | | `/employees/:id` | | 放 | 更新特定员工的数据。 | | `/clients` | | 得到 | 按名称的字母顺序列出客户端。 | | `/clients` | | 邮政 | 向系统添加新客户端。 | | `/clients/:id` | | 得到 | 返回特定客户端的数据。 | | `/clients/:id` | | 放 | 更新特定客户端上的数据。 | | `/booksales` | `start_date`:过滤在此日期之后创建的记录。`end_date`:可选;筛选在此日期之前创建的记录。`store_id`:可选;按商店过滤记录。 | 得到 | 返回销售额列表。可以按时间范围或商店过滤结果。 | | `/booksales` | | 邮政 | 记录新书销售。 | | `/clientreviews` | | 邮政 | 保存图书的新客户评论。 |Tip
即使没有指定,所有处理列表资源的端点都将接受以下属性:page
(从 0 开始,返回的页码);perpage
(每页返回的项目数);以及一个名为sort
的特殊属性,它包含按以下格式对结果和顺序进行排序的字段名称:[字段名称]_ASC|DESC。
表 6-2 给了我们一个很好的项目规模的概念;有了它,我们就能够估计我们面前的工作量。
还有一个方面需要讨论,因为它没有包含在表 6-1 的资源中,也没有包含在表 6-2 的端点/认证中。
认证方案将是简单的。正如在第二章中所讨论的,我们将使用无状态替代方案,用 MAC(消息认证码)对每个请求进行签名。服务器将重新创建该代码,以验证请求实际上是有效的。这意味着我们的系统中不会嵌入签名过程;这可以由客户来完成。暂时不用担心这个。
Note
因为这不是本书范围的一部分,API 将不处理图书销售的收费。这意味着我们将假设图书销售是在我们的系统之外完成的,并且另一个系统将把结果发送到我们的 API 中来保存记录。在生产系统中,这是在 API 内部处理这一功能的好方法,从而提供了一个完整的解决方案。
跟踪每个商店的库存
表 6-1 显示每本书都记录了在哪家商店出售。然而,还不完全清楚的是,如果同一本书在每家书店都有不止一本,会发生什么。
为了跟踪这个数字,让我们通过分配另一个元素来增强图书和商店模型之间的关系:副本的数量。你会在 UML 图中看到这一点,但这就是系统如何保存每本书的全球库存。
UML 图
以我们目前的规范水平,我们完全可以跳过这一步,直接进入下一步;但是为了完整性和清晰的概念,让我们创建一个基本的 UML 图来提供另一种方式来显示 API 的所有资源是如何相互关联的。
正如您在图 6-3 中看到的,图表的大部分由不同资源之间的聚合组组成。商店有一个员工组、一个图书组、一个图书作者组(通常每本书只有一个作者,但也有两个或更多作者合著的书),还有一个客户评论组。
图 6-3。
UML diagram showing the relations between all resources
选择数据库存储系统
是时候停止编写端点列表和创建图表了;你需要开始挑选技术。在这种情况下,我将回顾数据库存储系统的一些最常见的选择。我会对每一个都谈一点,然后我们会选择其中的一个。
底线是所有的解决方案都是有效的——您可以选择其中的任何一个,但是我们最终需要选择一个,所以让我们来定义数据库系统需要什么:
- 开发速度:因为您希望过程快速进行,并且不希望与数据库的交互成为瓶颈,所以您需要易于集成的东西。
- 易于更改的模式:预定义了所有内容后,您对模式的外观有了一个非常明确的想法,但是您可能希望在开发过程中进行调整。如果您正在使用的存储允许这样做而没有太多拥挤,那总是更好。
- 处理实体关系的能力:这意味着键/值存储是不可能的。
- 实体代码和数据的数据库表示之间的无缝集成。
差不多就是这样。在这种情况下,我们想要的是可以快速集成、易于更改、非键/值的东西。
因此,选项如下:
- MySQL 1 :关系数据库的经典选择。
- PostgreSQL 2 :关系数据库引擎的另一个好选择。
- MongoDB 3 :基于文档的 NoSQL 数据库引擎。
现在,您已经有了我们的选项列表,让我们来分析每个选项符合我们要求的程度。
快速集成
与系统的集成意味着模块与特定数据库引擎的交互有多容易。有了 MySQL 和 PostgreSQL,还有 Sequelize, 4 提供了非常完整和强大的对象关系映射(ORM)。它让您更关注数据模型,而不是实际的引擎特性。此外,如果使用得当,您可以在两个引擎之间切换,而对代码的影响最小。
另一方面,在 MongoDB 中,你有 Mongoose.js、 5 ,它允许你从引擎中抽象出你的代码,简化你定义模式、验证等等的任务。
易于更改的模式
这一次,MySQL 和 PostgreSQL 提供的固定结构使得维护动态模式变得更加困难,因此每次进行更改时,都需要通过运行迁移来更新模式。
NoSQL 引擎提供的结构的缺乏使得 MongoDB 成为我们项目的完美选择,因为对模式进行更改就像对定义代码进行更改一样简单;不需要迁移或其他任何东西。
处理实体关系的能力
因为我们省略了像 Redis、 6 这样的键/值存储,所以我们的三个选项都能够处理实体关系。MySQL 和 PostgreSQL 都特别擅长这个,因为它们都是关系数据库引擎。但是我们不排除 MongoDB 它是基于文档的 NoSQL 存储,从而允许您拥有文档(直接转换为 MySQL 记录)和子文档,这是一种特殊的关系,我们的关系选项没有这种关系。
在处理数据时,子文档关系有助于简化模式和查询。你在图 6-3 中看到,我们的大多数关系都是基于聚合的,所以这可能是解决这个问题的好方法。
我们的模型和数据库实体之间的无缝集成
这更像是红杉和猫鼬之间的比较。因为它们都抽象了存储层,所以您需要比较这种抽象如何影响这一点。
理想情况下,我们希望我们的实体(我们的资源在代码中的表示)被传递到我们的存储层,或者与存储层交互。我们不想需要一个额外类型的对象,通常称为 DTO(数据传输对象),来在层之间传输实体的状态。
幸运的是,Sequelize 和 Mongoose 提供的实体都属于这一类,所以我们不妨称之为平局。
获胜者是…
我们需要选择一个,所以我们总结一下:
- 快速集成:让我们把这个给 Sequelize 吧,因为它有一个额外的好处,那就是能够以最小的影响切换引擎。
- 易于更改的模式:MongoDB 轻而易举地赢得了这场比赛。
- 实体关系的处理:我想把这个也交给 MongoDB,主要是由于子文档特性。
- 与我们的数据模型无缝集成:这是一个平局,所以我们不计算它。
最终结果似乎指向 MongoDB,但这是一个非常接近的胜利,所以最终,个人经验也需要考虑在内。就我个人而言,我发现 MongoDB 是一个非常有趣的选择,当原型化和创建一些新的东西时,一些可能在开发过程中多次改变的东西,但这就是为什么我们将在我们的开发中使用它。这样就有了额外的保障,如果我们需要改变一些东西,比如让我们的数据模型适应新的结构,我们可以很容易地做到,而且影响很小。
这里明显的模块选择是 Mongoose,它在 MongoDB 驱动程序上提供了一个抽象层。
为工作选择正确的模块
这是我们准备过程的最后一步。现在你已经知道了要解决的问题,并且有了一个非常明确的如何处理开发的规范,除了实际编码之外,剩下唯一要做的事情就是选择正确的模块。
在前一章,我回顾了一系列模块,它们将帮助我们实现一个相当完整的 RESTful 系统;因此,让我们快速挑选其中的一些进行开发:
- Restify 将是我们一切工作的基础。它将提供处理请求和对请求做出响应所需的结构。
- Swagger 将用于创建文档。在前一章,我谈到了 swagger-node-express,但就像这样,有一个与 Restify 一起工作的称为(不足为奇)swagger-node-restify。选择这个模块是因为它集成到我们的项目中,允许我们根据原始代码自动生成文档,而不是维护两个不同的库。
- Halson 将是我们在回复中添加超媒体的首选模块。之所以选择它,主要是因为它看起来比 HAL(本任务中检查的其他模块)更成熟。
- 最后,我们的 JSONs 的验证将使用 TV4 来完成,主要是因为它允许我们一次收集所有的验证错误。
Note
这些不是我们将使用的唯一模块;还有其他一些小的辅助模块可以在不同的情况下帮助我们,但是这里列出的是那些可以帮助我们实现 RESTful API 的模块。
摘要
我们现在有了开始编码所需要的一切。我们知道我们将为连锁书店开发的 API 的范围。我们已经规划了系统的内部架构,并选择了我们将使用的主要模块。
在下一章,我们将开始编写我们的 API。到本章结束时,我们应该有一个成熟的工作书店 API。
Footnotes 1
2
3
4
5
6
7
https://www.npmjs.com/package/swagger-node-restify
见。
七、开发您的 REST API
现在我们已经最终定义了我们将使用的工具和我们将使用它们开发的项目,我们已经准备好实际开始编码了。本章将涵盖这一部分——从文件的组织(目录结构),到开发过程中做出的小的设计决策,最后到代码本身。
这一章将展示该项目的全部源代码,但我们将只讨论相关的部分。遗憾的是,有些部分非常无聊(比如 JSON 模式定义和更简单的模型),所以我将跳过它。不管专业水平如何,这些东西对开发人员来说应该是不言自明的。
我将如下介绍开发阶段:
- 开发期间做出的微小简化/设计决策。
- 文件夹结构,因为了解所有东西的位置和原因总是很重要的。
- 代码本身,一个文件接一个文件,包括需要时的解释。
Note
本章中的代码只是解决第六章中提出的问题的无限可能的方法之一。它试图展示本书中提到的概念和模块。这也意味着只向您展示一个潜在的开发过程,它试图同时保持敏捷和动态。当然,这个过程有不同的方式,对每个读者来说可能更好或更坏。
对计划的小改动
我们花了整整两章的时间来研究不同的模块,并计划开发 API 的整个过程。我们制作了一些图表,甚至列出了我们需要的所有资源和端点。
然而,在开发过程中,计划会发生变化。不是很多,但我们仍然需要微调设计的某些方面。
不过,这不一定是件坏事。如果最初的计划发生了重大变化,那么是的,这将意味着我们肯定在计划中做错了什么;但是在这个阶段,除非你在设计阶段花更多的时间,否则小的改变是不可避免的。我在这里说的是完成全部九个步骤:编写详细的用例及其相应的边界条件、流程图——工作。这个过程——如果做得正确,并且被实现解决方案的团队所遵循——很可能在开发过程中没有任何变化。但是要做到这一点,我们需要更多的时间,让我们面对现实吧,除了开发中令人厌烦的部分(免责声明:如果你实际上更喜欢这一部分,而不是开发,那么你没有任何问题;我只是从来没有遇到过像你这样的人),这也不是这本书的重点。
因此,我们可以玩设计,使用我们在上一章中做的部分分析和规划,并承担后果,正如您将看到的,后果非常小。
简化商店:员工关系
当我列出每个资源时,我说商店会保留一个员工列表,每个员工都会保留一个对商店的直接引用。然而,在 MongoDB 中维护这些关系意味着额外的工作。由于我们并不真的需要这样做,我们将只保留员工的记录,而不知道他们被分配到的商店,并确保每个商店都有在其中工作的员工。
添加 Swagger UI
我在第五章讲过 Swagger,也简单提到过 Swagger UI,但我从来没有真正解释过很多。Swagger UI 项目是我们将用来测试我们的 API 的 UI。swagger-node-express 和 swagger-node-restify 模块提供了 UI 所需的后端基础设施;但是没有 Swagger UI 项目,我们什么都没有。
因此,只需从 https://github.com/swagger-api/swagger-ui
下载(或克隆回购)并将其添加到您的项目中。我稍后将介绍如何配置它。
简化的安全性
为了简化安全性,我们将在这样一个前提下工作,即我们并不是真的在做一个公共 API,而是为我们直接控制下的客户端做一个 API。
这意味着我们不会要求每个客户端都请求一个具有有限生命周期的访问令牌。相反,我们将在这样的假设下工作:当我们在一个新的分支机构中建立一个新的客户端时,我们共享这个秘密的密码短语;因此,客户端将总是发送使用该密码加密的 MAC 代码,API 将重新散列每个请求以确保两个结果匹配。通过这种方式,我们仍然在验证请求,并且我们对 REST 保持真实,因为这种方法是无状态的;我们只是没有简化新客户端应用的添加。
进一步解释一下,每个客户端在每次请求时都会发送两条非常具体的信息:
- 一个名为
hmacdata
的特殊头,其中的信息被加密。 - 带有加密结果值的
api_key
参数。
收到请求后,API 将从报头中获取数据,并使用正确的密码再次加密。如果结果与api_key
参数的值相同,那么它会将请求标记为可信。否则,它会用 401 错误代码拒绝请求。
斯瓦格的一个小后门
我们做的另一个改变是因为 Swagger UI 对我们的身份验证方案没有事实上的支持。我们可以发送一个固定的api_key
参数,但是我们必须改变客户端的代码,让它使用我们正在使用的算法。这就是为什么我们在代码中添加了一个小后门,让 Swagger UI 通过,而不需要验证每个请求。
破解非常简单。由于 UI 可以发送一个固定的api_key
,我们将让所有具有等于 777 的api_key
的请求通过,自动信任它们。当然,为了避免任何安全问题,在投入生产时需要移除这个后门。
手动音量调节
在第四章中,我回顾了 MVC 模式的几种变体,但从未真正选定一种用于我们的 API。就个人而言,我真的很喜欢分层 MVC 背后的想法,因为它允许一些真正干净的代码。也就是说,这也意味着开发时的额外工作。考虑到在一个控制器中处理来自另一个控制器的资源的情况并不多见,我们将尽量保持简单,使用基本的 MVC。
这意味着我们的项目将包含以下关键要素:
- 控制器:处理请求并调用模型进行进一步的操作。
- 模型:保存 API 的主要逻辑。因为在我们的简单例子中,逻辑基本上是查询数据库,这些将是 Mongoose 使用的模型。这将简化我们的架构。此外,Mongoose 提供了不同的机制来为我们的模型添加额外的行为(比如设置实例方法或动作后挂钩)。
- 视图:视图将以一种方法的形式嵌入到模型的代码中,该方法将一个模型的细节转换成可以返回给客户机的 HAL + JSON。
文件夹结构
为了完全理解我们的 API 背后的设计,让我们快速看一下我设置的文件夹结构(见图 7-1 )。
图 7-1。
Project folder structure
以下是我们将创建和使用的文件夹:
- 这个文件夹包含了我们控制器的代码。它还有一个
index.js
文件来处理其余内容的导出。这里还有一个基控制器,包含了所有控制器应该有的所有泛型方法;所以每个新的控制器都可以扩展它并继承这些方法。 lib
:这个文件夹包含的杂项代码不够大,不能有自己的文件夹,但是在我们的项目中需要跨几个不同的地方;例如,数据库访问、助手函数、配置文件等等。- 这个文件夹里面是模型文件。通常,当使用 Mongoose 时,模型的文件有模式定义,您返回该模式的实例作为您的模型。在我们的例子中,实际的定义在其他地方,所以这段代码处理加载外部定义,添加特定于每个模型的额外行为,然后返回它。
request_schemas
:这个文件夹中是用于验证不同请求的 JSON 模式。- 这些是模型的 JSON 模式,用于 Swagger 模块定义用于测试的 UI 和 Mongoose 模型的定义。我们将不得不添加一些代码来从第一个翻译到后者,因为它们不使用相同的格式。
swagger-ui
:这个文件夹包含了 Swagger UI 项目的内容。我们需要对index.html
文件做一些小的调整,让它像我们期望的那样工作。
源代码
这里我将列出该项目的全部代码,如果需要的话,包括一些代码的基本描述。我将按照图 7-1 所示的顺序一个文件夹一个文件夹地进行。
控制器
/controllers/index.js
module.exports = {
BookSales: require("./booksales"),
Stores: require("./stores"),
Employees: require("./employees"),
ClientReviews: require("./clientreviews"),
Clients: require("./clients"),
Books: require("./books"),
Authors: require("./authors")
}
该文件用于导出每个控制器。使用这种技术,我们可以像导入一个模块一样导入整个文件夹:
var controllers = require("/controllers")
/controllers/basecontroller.js
var _ = require("underscore"),
restify = require("restify"),
colors = require("colors"),
halson = require("halson")
function BaseController() {
this.actions = []
this.server = null
}
BaseController.prototype.setUpActions = function(app, sw) {
this.server = app
_.each(this.actions, function(act) {
var method = act['spec']['method']
//a bit of a logging message, to help us understand what’s going on under the hood
console.log("Setting up auto-doc for (", method, ") - ", act['spec']['nickname'])
sw'add' + method
appmethod.toLowerCase()
})
}
BaseController.prototype.addAction = function(spec, fn) {
var newAct = {
'spec': spec,
action: fn
}
this.actions.push(newAct)
}
BaseController.prototype.RESTError = function(type, msg) {
if(restify[type]) {
return new restifytype)
} else {
console.log("Type " + type + " of error not found".red)
}
}
/**
Takes care of calling the "toHAL" method on every resource before writing it
back to the client
*/
BaseController.prototype.writeHAL = function(res, obj) {
if(Array.isArray(obj)) {
var newArr = []
_.each(obj, function(item, k) {
item = item.toHAL()
newArr.push(item)
})
obj = halson (newArr)
} else {
if(obj && obj.toHAL)
obj = obj.toHAL()
}
if(!obj) {
obj = {}
}
res.json(obj)
}
module.exports = BaseController
每个控制器都扩展这个对象,获得对前面显示的方法的访问。我们将使用基本的原型继承,当我们开始列出其他控制器的代码时,你会看到。
至于这个,让我们快速浏览一下它公开的方法:
setUpActions
:该方法在控制器实例化时调用;这意味着向 HTTP 服务器添加实际的路由。该方法在所有由index.js
文件导出的控制器的初始化序列中被调用。- 这个方法定义了一个动作,这个动作由动作的规范和实际的功能代码组成。Swagger 使用这些规范来创建文档,但是我们的代码也使用它们来设置路线;所以 JSON 规范中有一些也是针对服务器的,比如
path
和method
属性。 - 这是一个简单的包装方法,包含了 Restify 提供的所有错误方法。 1 它提供了更干净代码的好处。
writeHAL
:每个定义的模型(接下来你会看到)都有一个toHAL
方法,writeHAL
方法负责为我们试图渲染的每个模型调用它。它基本上集中了处理集合或简单对象的逻辑,这取决于我们要呈现的内容。
Tip
我们在这里使用colors
模块以红色打印来自RESTError
方法的错误消息。
/controllers/books.js
var BaseController = require("./basecontroller"),
_ = require("underscore"),
swagger = require("swagger-node-restify")
function Books() {
}
Books.prototype = new BaseController()
module.exports = function(lib) {
var controller = new Books();
/**
Helper function for the POST action
*/
function mergeStores(list1, list2) {
var stores1 = {}
var stores2 = {}
_.each(list1, function(st) {
if(st.store)
stores1[st.store] = st.copies
})
_.each(list2, function(st) {
if(st.store)
stores2[st.store] = st.copies
})
var stores = _.extend(stores1, stores2)
return _.map(stores, function(v, k) {
return {store: k, copies: v}
})
}
controller.addAction({
'path': '/books',
'method': 'GET',
'summary': 'Returns the list of books',
"params": [ swagger.queryParam('q', 'Search term', 'string'), swagger.queryParam('genre','Filter by genre', 'string')],
'responseClass': 'Book',
'nickname': 'getBooks'
}, function(req, res, next) {
var criteria = {}
if(req.params.q) {
var expr = new RegExp('.*' + req.params.q + '.*')
criteria.$or = [
{title: expr},
{isbn_code: expr},
{description: expr}
]
}
if(req.params.genre) {
criteria.genre = req.params.genre
}
lib.db.model('Book')
.find(criteria)
.populate('stores.store')
.exec(function(err, books) {
if(err) return next(err)
controller.writeHAL(res, books)
})
})
controller.addAction({
'path': '/books/{id}',
'method': 'GET',
'params': [ swagger.pathParam('id', 'The Id of the book','int') ],
'summary': 'Returns the full data of a book',
'nickname': 'getBook'
}, function(req, res, next) {
var id = req.params.id
if(id) {
lib.db.model("Book")
.findOne({_id: id})
.populate('authors')
.populate('stores')
.populate('reviews')
.exec(function(err, book) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!book) {
return next(controller.RESTError('ResourceNotFoundError', 'Book not found'))
}
controller.writeHAL(res, book)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing book id'))
}
})
controller.addAction({
'path': '/books',
'method': 'POST',
'params': [ swagger.bodyParam('book', 'JSON representation of the new book','string') ],
'summary': 'Adds a new book into the collectoin',
'nickname': 'newBook'
}, function(req, res, next) {
var bookData = req.body
if(bookData) {
isbn = bookData.isbn_code
lib.db.model("Book")
.findOne({isbn_code: isbn})
.exec(function(err, bookModel) {
if(!bookModel) {
bookModel = lib.db.model("Book")(bookData)
} else {
bookModel.stores = mergeStores(bookModel.stores, bookData.stores)
}
bookModel.save(function(err, book) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, book)
})
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing content of book'))
}
})
controller.addAction({
'path': '/books/{id}/authors',
'method': 'GET',
'params': [ swagger.pathParam('id', 'The Id of the book','int') ],
'summary': 'Returns the list of authors of one specific book',
'nickname': 'getBooksAuthors'
}, function(req, res, next) {
var id = req.params.id
if(id) {
lib.db.model("Book")
.findOne({_id: id})
.populate('authors')
.exec(function(err, book) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!book) {
return next(controller.RESTError('ResourceNotFoundError', 'Book not found'))
}
controller.writeHAL(res, book.authors)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing book id'))
}
})
controller.addAction({
'path': '/books/{id}/reviews',
'method': 'GET',
'params': [ swagger.pathParam('id', 'The Id of the book','int') ],
'summary': 'Returns the list of reviews of one specific book',
'nickname': 'getBooksReviews'
}, function(req, res,next) {
var id = req.params.id
if(id) {
lib.db.model("Book")
.findOne({_id: id})
.populate('reviews')
.exec(function(err, book) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!book) {
return next(controller.RESTError('ResourceNotFoundError', 'Book not found'))
}
controller.writeHAL(res, book.reviews)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing book id'))
}
})
controller.addAction({
'path': '/books/{id}',
'method': 'PUT',
'params': [ swagger.pathParam('id', 'The Id of the book to update','string'),
swagger.bodyParam('book', 'The data to change on the book', 'string') ],
'summary': 'Updates the information of one specific book',
'nickname': 'updateBook'
}, function(req, res, next) {
var data = req.body
var id = req.params.id
if(id) {
lib.db.model("Book").findOne({_id: id}).exec(function(err, book) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!book) return next(controller.RESTError('ResourceNotFoundError', 'Book not found'))
book = _.extend(book, data)
book.save(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, data.toJSON())
})
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id received'))
}
})
return controller
}
这个控制器的代码非常简单;其中包含了为这个特定项目定义的基本机制,即如何声明一个控制器及其动作。我们还有一个 POST 操作的特例,它检查一本新书的 ISBN,看它是否在另一家书店有库存。如果 ISBN 已经存在,这本书将被合并到所有相关的书店;否则,将创建一个新记录。
理论上,我们正在创建一个继承自BaseController
的新函数,它使我们能够在特定的控制器上添加自定义行为。然而,现实将证明我们并不真的需要这样的自由。我们也可以通过在其他控制器文件上直接实例化BaseController
来做同样的事情。
API 初始化期间需要控制器文件,当这种情况发生时,lib
对象被传递给它们,如下所示:
var controller = require("/controllers/books.js")(lib)
这意味着(正如您在前面的代码中看到的),导出函数接收到了lib
对象,该函数负责实例化新的控制器,并向其添加动作以将其返回到所需的代码。
以下是代码中其他一些有趣的部分:
getBooks
动作展示了如何使用 Mongoose 进行简单的基于正则表达式的过滤。update
动作实际上并没有使用来自 Mongoose 的update
方法,而是使用来自下划线的extend
方法加载模型,最后在模型上调用save
方法。这样做的原因很简单:update
方法不会触发模型上的任何 post 挂钩,但是save
方法会,所以如果我们想要添加行为来对模型上的更新做出反应,这将是实现它的方法。
/controllers/stores.js
var BaseController = require("./basecontroller"),
_ = require("underscore"),
swagger = require("swagger-node-restify")
function Stores() {
}
Stores.prototype = new BaseController()
module.exports = function(lib) {
var controller = new Stores();
controller.addAction({
'path': '/stores',
'method': 'GET',
'summary': 'Returns the list of stores ',
'params': [swagger.queryParam('state', 'Filter the list of stores by state', 'string')],
'responseClass': 'Store',
'nickname': 'getStores'
}, function (req, res, next) {
var criteria = {}
if(req.params.state) {
criteria.state = new RegExp(req.params.state,'i')
}
lib.db.model('Store')
.find(criteria)
.exec(function(err, list) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, list)
})
})
controller.addAction({
'path': '/stores/{id}',
'method': 'GET',
'params': [swagger.pathParam('id','The id of the store','string')],
'summary': 'Returns the data of a store',
'responseClass': 'Store',
'nickname': 'getStore'
}, function(req, res, next) {
var id = req.params.id
if(id) {
lib.db.model('Store')
.findOne({_id: id})
.populate('employees')
.exec(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!data) return next(controller.RESTError('ResourceNotFoundError', 'Store not found'))
controller.writeHAL(res, data)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id'))
}
})
controller.addAction({
'path': '/stores/{id}/books',
'method': 'GET',
'params': [swagger.pathParam('id','The id of the store','string'),
swagger.queryParam('q', 'Search parameter for the books', 'string'),
swagger.queryParam('genre', 'Filter results by genre', 'string')],
'summary': 'Returns the list of books of a store',
'responseClass': 'Book',
'nickname': 'getStoresBooks'
}, function (req, res, next) {
var id = req.params.id
if(id) {
var criteria = {stores: id}
if(req.params.q) {
var expr = new RegExp('.*' + req.params.q + '.*', 'i')
criteria.$or = [
{title: expr},
{isbn_code: expr},
{description: expr}
]
}
if(req.params.genre) {
criteria.genre = req.params.genre
}
//even though this is the stores controller, we deal directly with books here
lib.db.model('Book')
.find(criteria)
.populate('authors')
.exec(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, data)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id'))
}
})
controller.addAction({
'path': '/stores/{id}/employees',
'method': 'GET',
'params': [swagger.pathParam('id','The id of the store','string')],
'summary': 'Returns the list of employees working on a store',
'responseClass': 'Employee',
'nickname': 'getStoresEmployees'
}, function (req, res, next) {
var id = req.params.id
if(id) {
lib.db.model('Store')
.findOne({_id: id})
.populate('employees')
.exec(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!data) {
return next(controller.RESTError('ResourceNotFoundError', 'Store not found'))
}
console.log(data)
controller.writeHAL(res, data.employees)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id'))
}
})
controller.addAction({
'path': '/stores/{id}/booksales',
'method': 'GET',
'params': [swagger.pathParam('id','The id of the store','string')],
'summary': 'Returns the list of booksales done on a store',
'responseClass': 'BookSale',
'nickname': 'getStoresBookSales'
}, function (req, res, next) {
var id = req.params.id
if(id) {
//even though this is the stores controller, we deal directly with booksales here
lib.db.model('Booksale')
.find({store: id})
.populate('client')
.populate('employee')
.populate('books')
.exec(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, data)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id'))
}
})
controller.addAction({
'path': '/stores',
'method': 'POST',
'summary': 'Adds a new store to the list',
'params': [swagger.bodyParam('store', 'The JSON data of the store', 'string')],
'responseClass': 'Store',
'nickname': 'newStore'
}, function (req, res, next) {
var data = req.body
if(data) {
var newStore = lib.db.model('Store')(data)
newStore.save(function(err, store) {
if(err) return next(controller.RESTError('InternalServerError', err))
res.json(controller.toHAL(store))
})
} else {
next(controller.RESTError('InvalidArgumentError', 'No data received'))
}
})
controller.addAction({
'path': '/stores/{id}',
'method': 'PUT',
'summary': "UPDATES a store's information",
'params': [swagger.pathParam('id','The id of the store','string'), swagger.bodyParam('store', 'The new information to update', 'string')],
'responseClass': 'Store',
'nickname': 'updateStore'
}, function (req, res, next) {
var data = req.body
var id = req.params.id
if(id) {
lib.db.model("Store").findOne({_id: id}).exec(function(err, store) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!store) return next(controller.RESTError('ResourceNotFoundError', 'Store not found'))
store = _.extend(store, data)
store.save(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
res.json(controller.toHAL(data))
})
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id received'))
}
})
return controller
}
商店控制器的代码与书籍控制器的代码非常相似。然而,它确实有一些值得注意的地方:getStoresBookSales
动作清楚地显示了当我们不使用分层 MVC 模型时会发生什么。我说过这不是一个常见的情况,所以对于本书的目的来说这是很好的,但是它显示了关注点的分离是如何在最严格的意义上被另一个控制器的模型打破的,而不是通过那个控制器。考虑到这种机制会给我们的代码带来额外的复杂性,我们最好暂时换个方式。
以下是剩余的控制器。与之前的代码相比,它们并没有特别展示什么新的东西,所以只需要看看代码和偶尔的代码注释。
/controllers/authors.js
var BaseController = require("./basecontroller"),
swagger = require("swagger-node-restify")
function BookSales() {
}
BookSales.prototype = new BaseController()
module.exports = function(lib) {
var controller = new BookSales()
//list
controller.addAction({
'path': '/authors',
'method': 'GET',
'summary' :'Returns the list of authors across all stores',
'params': [ swagger.queryParam('genre', 'Filter authors by genre of their books', 'string'),
swagger.queryParam('q', 'Search parameter', 'string')],
'responseClass': 'Author',
'nickname': 'getAuthors'
}, function(req, res, next) {
var criteria = {}
if(req.params.q) {
var expr = new RegExp('.*' + req.params.q + '.*', 'i')
criteria.$or = [
{name: expr},
{description: expr}
]
}
var filterByGenre = false || req.params.genre
if(filterByGenre) {
lib.db.model('Book')
.find({genre: filterByGenre})
.exec(function(err, books) {
if(err) return next(controller.RESTError('InternalServerError', err))
findAuthors(_.pluck(books, '_id'))
})
} else {
findAuthors()
}
function findAuthors(bookIds) {
if(bookIds) {
criteria.books = {$in: bookIds}
}
lib.db.model('Author')
.find(criteria)
.exec(function(err, authors) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, authors)
})
}
})
//get
controller.addAction({
'path': '/authors/{id}',
'summary': 'Returns all the data from one specific author',
'method': 'GET',
'responseClass': 'Author',
'nickname': 'getAuthor'
}, function (req, res, next) {
var id = req.params.id
if(id) {
lib.db.model('Author')
.findOne({_id: id})
.exec(function(err, author) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!author) {
return next(controller.RESTError('ResourceNotFoundError', 'Author not found'))
}
controller.writeHAL(res, author)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing author id'))
}
})
//post
controller.addAction({
'path': '/authors',
'summary': 'Adds a new author to the database',
'method': 'POST',
'params': [swagger.bodyParam('author', 'JSON representation of the data', 'string')],
'responseClass': 'Author',
'nickname': 'addAuthor'
}, function (req, res, next) {
var body = req.body
if(body) {
var newAuthor = lib.db.model('Author')(body)
newAuthor.save(function(err, author) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, author)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing author id'))
}
})
//put
controller.addAction({
'path': '/authors/{id}',
'method': 'PUT',
'summary': "UPDATES an author's information",
'params': [swagger.pathParam('id','The id of the author','string'),
swagger.bodyParam('author', 'The new information to update', 'string')],
'responseClass': 'Author',
'nickname': 'updateAuthor'
}, function (req, res, next) {
var data = req.body
var id = req.params.id
if(id) {
lib.db.model("Author").findOne({_id: id}).exec(function(err, author) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!author) return next(controller.RESTError('ResourceNotFoundError', 'Author not found'))
author = _.extend(author, data)
author.save(function(err, data) {
if(err) return next(controller.RESTError('InternalServerError', err))
res.json(controller.toHAL(data))
})
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id received'))
}
})
// /books
controller.addAction({
'path': '/authors/{id}/books',
'summary': 'Returns the data from all the books of one specific author',
'method': 'GET',
'params': [ swagger.pathParam('id', 'The id of the author', 'string')],
'responseClass': 'Book',
'nickname': 'getAuthorsBooks'
}, function (req, res, next) {
var id = req.params.id
if(id) {
lib.db.model('Author')
.findOne({_id: id})
.populate('books')
.exec(function(err, author) {
if(err) return next(controller.RESTError('InternalServerError', err))
if(!author) {
return next(controller.RESTError('ResourceNotFoundError', 'Author not found'))
}
controller.writeHAL(res, author.books)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing author id'))
}
})
return controller
}
/controllers/booksales.js
var BaseController = require("./basecontroller"),
swagger = require("swagger-node-restify")
function BookSales() {
}
BookSales.prototype = new BaseController()
module.exports = function(lib) {
var controller = new BookSales();
controller.addAction({
'path': '/booksales',
'method': 'GET',
'summary': 'Returns the list of book sales',
'params': [ swagger.queryParam('start_date', 'Filter sales done after (or on) this date', 'string'),
swagger.queryParam('end_date', 'Filter sales done on or before this date', 'string'),
swagger.queryParam('store_id', 'Filter sales done on this store', 'string')
],
'responseClass': 'BookSale',
'nickname': 'getBookSales'
}, function(req, res, next) {
console.log(req)
var criteria = {}
if(req.params.start_date)
criteria.date = {$gte: req.params.start_date}
if(req.params.end_date)
criteria.date = {$lte: req.params.end_date}
if(req.params.store_id)
criteria.store = req.params.store_id
lib.db.model("Booksale")
.find(criteria)
.populate('books')
.populate('client')
.populate('employee')
.populate('store')
.exec(function(err, sales) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, sales)
})
})
controller.addAction({
'path': '/booksales',
'method': 'POST',
'params': [ swagger.bodyParam('booksale', 'JSON representation of the new booksale','string') ],
'summary': 'Records a new booksale',
'nickname': 'newBookSale'
}, function(req, res, next) {
var body = req.body
if(body) {
var newSale = lib.db.model("Booksale")(body)
newSale.save(function(err, sale) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, sale)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Missing json data'))
}
})
return controller
}
/controllers/clientreviews.js
var BaseController = require("./basecontroller"),
_ = require("underscore"),
swagger = require("swagger-node-restify")
function ClientReviews() {
}
ClientReviews.prototype = new BaseController()
module.exports = function(lib) {
var controller = new ClientReviews();
controller.addAction({
'path': '/clientreviews',
'method': 'POST',
'summary': 'Adds a new client review to a book',
'params': [swagger.bodyParam('review', 'The JSON representation of the review', 'string')],
'responseClass': 'ClientReview',
'nickname': 'addClientReview'
}, function (req, res, next) {
var body = req.body
if(body) {
var newReview = lib.db.model('ClientReview')(body)
newReview.save(function (err, rev) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, rev)
})
}
})
return controller
}
/controllers/clients.js
var BaseController = require("./basecontroller"),
_ = require("underscore"),
swagger = require("swagger-node-restify")
function Clients() {
}
Clients.prototype = new BaseController()
module.exports = function(lib) {
var controller = new Clients();
controller.addAction({
'path': '/clients',
'method': 'GET',
'summary': 'Returns the list of clients ordered by name',
'responsClass':'Client',
'nickname': 'getClients'
}, function(req, res, next) {
lib.db.model('Client').find().sort('name').exec(function(err, clients) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, clients)
})
})
controller.addAction({
'path': '/clients',
'method': 'POST',
'params': [swagger.bodyParam('client', 'The JSON representation of the client', 'string')],
'summary': 'Adds a new client to the database',
'responsClass': 'Client',
'nickname': 'addClient'
}, function(req, res, next) {
var newClient = req.body
var newClientModel = lib.db.model('Client')(newClient)
newClientModel.save(function(err, client) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, client)
})
})
controller.addAction({
'path': '/clients/{id}',
'method': 'GET',
'params': [swagger.pathParam('id', 'The id of the client', 'string')],
'summary': 'Returns the data of one client',
'responsClass': 'Client',
'nickname': 'getClient'
}, function (req, res, next) {
var id = req.params.id
if(id != null) {
lib.db.model('Client').findOne({_id: id}).exec(function(err, client){
if(err) return next(controller.RESTError('InternalServerError',err))
if(!client) return next(controller.RESTError('ResourceNotFoundError', 'The client id cannot be found'))
controller.writeHAL(res, client)
})
} else {
next(controller.RESTError('InvalidArgumentError','Invalid client id'))
}
})
controller.addAction({
'path': '/clients/{id}',
'method': 'PUT',
'params': [swagger.pathParam('id', 'The id of the client', 'string'), swagger.bodyParam('client', 'The content to overwrite', 'string')],
'summary': 'Updates the data of one client',
'responsClass': 'Client',
'nickname': 'updateClient'
}, function (req, res, next) {
var id = req.params.id
if(!id) {
return next(controller.RESTError('InvalidArgumentError','Invalid id'))
} else {
var model = lib.db.model('Client')
model.findOne({_id: id})
.exec(function(err, client) {
if(err) return next(controller.RESTError('InternalServerError', err))
client = _.extend(client, req.body)
client.save(function(err, newClient) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, newClient)
})
})
}
})
return controller
}
/controllers/employees.js
var BaseController = require("./basecontroller"),
_ = require("underscore"),
swagger = require("swagger-node-restify")
function Employees() {
}
Employees.prototype = new BaseController()
module.exports = function(lib) {
var controller = new Employees();
controller.addAction({
'path': '/employees',
'method': 'GET',
'summary': 'Returns the list of employees across all stores',
'responseClass': 'Employee',
'nickname': 'getEmployees'
}, function(req, res, next) {
lib.db.model('Employee').find().exec(function(err, list) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, list)
})
})
controller.addAction({
'path': '/employees/{id}',
'method': 'GET',
'params': [swagger.pathParam('id','The id of the employee','string')],
'summary': 'Returns the data of an employee',
'responseClass': 'Employee',
'nickname': 'getEmployee'
}, function(req, res, next) {
var id = req.params.id
if(id) {
lib.db.model('Employee').findOne({_id: id}).exec(function(err, empl) {
if(err) return next(err)
if(!empl) {
return next(controller.RESTError('ResourceNotFoundError', 'Not found'))
}
controller.writeHAL(res, empl)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'Invalid id'))
}
})
controller.addAction({
'path': '/employees',
'method': 'POST',
'params': [swagger.bodyParam('employee', 'The JSON data of the employee', 'string')],
'summary': 'Adds a new employee to the list',
'responseClass': 'Employee',
'nickname': 'newEmployee'
}, function(req, res, next) {
var data = req.body
if(data) {
var newEmployee = lib.db.model('Employee')(data)
newEmployee.save(function(err, emp) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, emp)
})
} else {
next(controller.RESTError('InvalidArgumentError', 'No data received'))
}
})
controller.addAction({
'path': '/employees/{id}',
'method': 'PUT',
'summary': "UPDATES an employee's information",
'params': [swagger.pathParam('id','The id of the employee','string'), swagger.bodyParam('employee', 'The new information to update', 'string')],
'responseClass': 'Employee',
'nickname': 'updateEmployee'
}, function(req, res, next) {
var data = req.body
var id = req.params.id
if(id) {
lib.db.model("Employee").findOne({_id: id}).exec(function(err, emp) {
if(err) return next(controller.RESTError('InternalServerError', err))
emp = _.extend(emp, data)
emp.save(function(err, employee) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, employee)
})
})
} else {
next(controller.RESTError('InvalidArgumentError','Invalid id received'))
}
})
return controller
}
解放运动
如前所述,lib
文件夹包含了各种各样的助手函数和实用程序,它们太小了,不能放在一个单独的文件夹中,但是足够重要和通用,可以在代码的几个地方使用。
/lib/index.js
var lib = {
helpers: require("./helpers"),
config: require("./config"),
controllers: require("../controllers"),
schemas: require("../schemas"),
schemaValidator: require("./schemaValidator"),
db: require("./db")
}
module.exports = lib
该文件被认为是外部世界(项目的其余部分)和内部世界(该文件夹中分组的所有迷你模块)之间的单点联系。没什么特别的。它只需要一切,并使用预定义的密钥导出。
/lib/helpers.js
var halson = require("halson"),
_ = require("underscore")
module.exports = {
makeHAL: makeHAL,
setupRoutes: setupRoutes,
validateKey: validateKey
}
function setupRoutes(server, swagger, lib) {
for(controller in lib.controllers) {
cont = lib.controllerscontroller
cont.setUpActions(server, swagger)
}
}
/**
Makes sure to sign every request and compare it
against the key sent by the client, this way
we make sure its authentic
*/
function validateKey(hmacdata, key, lib) {
//This is for testing the swagger-ui, should be removed after development to avoid possible security problem :)
if(+key == 777) return true
var hmac = require("crypto").createHmac("md5", lib.config.secretKey)
.update(hmacdata)
.digest("hex");
//TODO: Remove this line
console.log(hmac)
return hmac == key
}
function makeHAL(data, links, embed) {
var obj = halson(data)
if(links && links.length > 0) {
_.each(links, function(lnk) {
obj.addLink(lnk.name, {
href: lnk.href,
title: lnk.title || ''
})
})
}
if(embed && embed.length > 0) {
_.each(embed, function (item) {
obj.addEmbed(item.name, item.data)
})
}
return obj
}
正如由index.js
文件导出的模块太小而不值得拥有它们自己的文件夹一样,这些函数也太小且太特殊而不值得拥有它们自己的模块,所以它们被分组在这里,在 helpers 模块内。这些功能意味着在整个项目中都是有用的(因此被命名为“助手”)。
让我们快速浏览一下这些名字:
setupRoutes
:该函数在启动时从项目主文件中调用。这意味着初始化所有的控制器,进而将实际的路由代码添加到 HTTP 服务器。validateKey
:该函数包含通过重新计算 HMAC 密钥来验证请求的代码。如前所述,它包含规则的例外,允许任何请求验证发送的密钥是否为 777。makeHAL
:这个函数把任何类型的对象变成一个 HAL JSON 对象,准备渲染。这个特殊的函数在模型代码中被大量使用。
/lib/schemaValidator.js
var tv4 = require("tv4"),
formats = require("tv4-formats"),
schemas = require("../request_schemas/")
module.exports = {
validateRequest: validate
}
function validate (req) {
var res = {valid: true}
tv4.addFormat(formats)
var schemaKey = req.route ? req.route.path.toString().replace("/", "") : ''
var actionKey = req.route.name
if(schemas[schemaKey]) {
var mySchema = schemas[schemaKey][actionKey]
var data = null
if(mySchema) {
switch(mySchema.validate) {
case 'params':
data = req.params
break
}
res = tv4.validateMultiple(data, mySchema.schema)
}
}
return res
}
该文件包含根据我们定义的 JSON 模式验证任何请求的代码。唯一感兴趣的函数是validate
函数,它验证请求对象。它还依赖于请求中的预定义结构,这是由 Swagger 添加的(route
属性)。
正如您从前面的代码中可能已经猜到的,请求的验证是可选的;并非每个请求都被验证。目前,只有查询参数被验证,但是这可以通过简单地向 switch 语句添加一个新的 case 来扩展。
这个函数在“约定优于配置”的前提下工作,这意味着如果您“正确地”设置了所有东西,那么您不需要做太多事情。在我们的特殊例子中,我们在查看request_schemas
文件夹以加载一组预定义的模式,这些模式具有非常特定的格式。在这种格式中,我们找到了要验证的动作的名称(我们设置的昵称)和我们想要验证的请求部分。在我们的特定函数中,我们只验证诸如无效格式之类的查询参数。我们现在设置要验证的唯一请求是 BookSales listing 动作;但是如果我们想要添加一个新的验证,只需要添加一个新的模式就可以了——不需要编程。
/lib/db.js
var config = require("./config"),
_ = require("underscore"),
mongoose = require("mongoose"),
Schema = mongoose.Schema
var obj = {
cachedModels: {},
getModelFromSchema: getModelFromSchema,
model: function(mname) {
return this.models[mname]
},
connect: function(cb) {
mongoose.connect(config.database.host + "/" + config.database.dbname)
this.connection = mongoose.connection
this.connection.on('error', cb)
this.connection.on('open', cb)
}
}
obj.models = require("../models/")(obj)
module.exports = obj
function translateComplexType(v, strType) {
var tmp = null
var type = strType || v['type']
switch(type) {
case 'array':
tmp = []
if(v['items']['$ref'] != null) {
tmp.push({
type: Schema.ObjectId,
ref: v['items']['$ref']
})
} else {
var originalType = v['items']['type']
v['items']['type'] = translateTypeToJs(v['items']['type'])
tmp.push(translateComplexType(v['items'], originalType))
}
break;
case 'object':
tmp = {}
var props = v['properties']
_.each(props, function(data, k) {
if(data['$ref'] != null) {
tmp[k] = {
type: Schema.ObjectId,
ref: data['$ref']
}
} else {
tmp[k] = translateTypeToJs(data['type'])
}
})
break;
default:
tmp = v
tmp['type'] = translateTypeToJs(type)
break;
}
return tmp
}
/**
Turns the JSON Schema into a Mongoose schema
*/
function getModelFromSchema(schema) {
var data = {
name: schema.id,
schema: {}
}
var newSchema = {}
var tmp = null
_.each(schema.properties, function(v, propName) {
if(v['$ref'] != null) {
tmp = {
type: Schema.ObjectId,
ref: v['$ref']
}
} else {
tmp = translateComplexType(v) //{}
}
newSchema[propName] = tmp
})
data.schema = new Schema(newSchema)
return data
}
function translateTypeToJs(t) {
if(t.indexOf('int') === 0) {
t = "number"
}
return eval(t.charAt(0).toUpperCase() + t.substr(1))
}
这个文件包含了一些有趣的函数,这些函数在模型代码中经常用到。在第五章中,我提到了 Swagger 使用的模式可能会被重用来做其他事情,比如定义模型的模式。但是要做到这一点,我们需要一个函数将标准 JSON 模式翻译成 Mongoose 定义模型所需的非标准 JSON 格式。这就是getModelFromSchema
函数发挥作用的地方;它的代码旨在检查 JSON 模式的结构,并创建一个新的、更简单的 JSON 结构,用作 Mongoose 模式。
其他函数更简单:
- connect:连接到数据库服务器,并为错误和成功情况设置回调。
- 模型:从外部访问模型。我们可以使用对象模型直接访问模型,但是提供一个包装器总是一个好主意,以防您需要添加额外的行为(比如检查错误)。
/lib/config.js
module.exports = {
secretKey: 'this is a secret key, right here',
server: {
name: 'ComeNRead API',
version: '1.0.0',
port: 9000
},
database: {
host: 'mongodb://localhost',
dbname: 'comenread'
}
}
这个文件非常简单;它只是导出一组常量值,供整个应用使用。
模型
该文件夹包含每个模型的实际代码。这些资源的定义不会在这些文件中找到,因为它们只是用来定义行为的。实际的属性是在schemas
文件夹中定义的(模型和 Swagger 都在使用这个文件夹)。
/models/index.js
module.exports = function(db) {
return {
"Book": require("./book")(db),
"Booksale": require("./booksale")(db),
"ClientReview": require("./clientreview")(db),
"Client": require("./client")(db),
"Employee": require("./employee")(db),
"Store": require("./store")(db),
"Author": require("./author")(db)
}
}
同样,和其他文件夹一样,index.js
文件允许我们一次请求每个模型,并把这个文件夹当作一个模块。这里需要注意的另一件事是将db
对象传递给每个模型,以便它们可以访问getModelFromSchema
函数。
/models/author.js
var mongoose = require("mongoose")
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/author.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.plugin(jsonSelect, '-books')
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON(),
[{name: 'books', 'href': '/authors/' + this.id + '/books', 'title': 'Books'}])
if(this.books.length > 0) {
if(this.books[0].toString().length != 24) {
halObj.addEmbed('books', _.map(this.books, function(e) { return e.toHAL() }))
}
}
return halObj
}
return mongoose.model(modelDef.name, modelDef.schema)
}
作者模型的代码展示了加载 JSON 模式、将其转换为 Mongoose 模式、定义自定义行为以及最终返回新模型的基本机制。
下面定义了主要的自定义行为:
jsonSelect
模型允许我们定义在将对象转换成 JSON 时要添加或删除的属性。我们希望从 JSON 表示中删除嵌入对象,因为它们将作为嵌入对象添加到 HAL JSON 表示中,而不是作为主对象的一部分。toHAL
方法负责以 JSON HAL 格式返回资源的表示。- 与主对象相关联的链接是手动定义的。我们可以通过进一步定制用于模型的 JSON 模式的加载和转换的代码来改进这一点。
Note
类似下面的检查(在toHAL
方法中)意在确定模型是否已经填充了一个引用,或者它仅仅是被引用对象的 id:
if(this.books[0].toString().length != 24) {
//…
}
下面是 models 文件夹中的其余代码,正如您所理解的,相同的机制在每种情况下都是重复的。
/models/book.js
var mongoose = require("mongoose"),
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/book.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.plugin(jsonSelect, '-stores -authors')
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON(),
[{name: 'reviews', href: '/books/' + this.id + '/reviews', title: 'Reviews'}])
if(this.stores.length > 0) {
if(this.stores[0].store.toString().length != 24) {
halObj.addEmbed('stores', _.map(this.stores, function(s) { return { store: s.store.toHAL(), copies: s.copies } } ))
}
}
if(this.authors.length > 0) {
if(this.authors[0].toString().length != 24) {
halObj.addEmbed('authors', this.authors)
}
}
return halObj
}
return mongoose.model(modelDef.name, modelDef.schema)
}
/models/booksale.js
var mongoose = require("mongoose"),
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/booksale.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.plugin(jsonSelect, '-store -employee -client -books')
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON())
if(this.books.length > 0) {
if(this.books[0].toString().length != 24) {
halObj.addEmbed('books', _.map(this.books, function(b) { return b.toHAL() }))
}
}
if(this.store.toString().length != 24) {
halObj.addEmbed('store', this.store.toHAL())
}
if(this.employee.toString().length != 24) {
halObj.addEmbed('employee', this.employee.toHAL())
}
if(this.client.toString().length != 24) {
halObj.addEmbed('client', this.client.toHAL())
}
return halObj
}
return mongoose.model(modelDef.name, modelDef.schema)
}
/models/client.js
var mongoose = require("mongoose"),
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/client.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON())
return halObj
}
return mongoose.model(modelDef.name, modelDef.schema)
}
/models/clientreview.js
var mongoose = require("mongoose"),
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/clientreview.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON())
return halObj
}
modelDef.schema.post('save', function(doc, next) {
db.model('Book').update({_id: doc.book}, {$addToSet: {reviews: this.id}}, function(err) {
next(err)
})
})
return mongoose.model(modelDef.name, modelDef.schema)
}
/models/employee.js
var mongoose = require("mongoose"),
jsonSelect = require('mongoose-json-select'),
helpers = require("../lib/helpers"),
_ = require("underscore")
module.exports = function(db) {
var schema = require("../schemas/employee.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON())
return halObj
}
return mongoose.model(modelDef.name, modelDef.schema)
}
/models/store.js
var mongoose = require("mongoose"),
jsonSelect = require("mongoose-json-select"),
_ = require("underscore"),
helpers = require("../lib/helpers")
module.exports = function(db) {
var schema = require("../schemas/store.js")
var modelDef = db.getModelFromSchema(schema)
modelDef.schema.plugin(jsonSelect, '-employees')
modelDef.schema.methods.toHAL = function() {
var halObj = helpers.makeHAL(this.toJSON(),
[{name: 'books', href: '/stores/' + this.id + '/books', title: 'Books'},
{name: 'booksales', href: '/stores/' + this.id + '/booksales', title: 'Book Sales'}])
if(this.employees.length > 0) {
if(this.employees[0].toString().length != 24) {
halObj.addEmbed('employees', _.map(this.employees, function(e) { return e.toHAL() }))
}
}
return halObj
}
var model = mongoose.model(modelDef.name, modelDef.schema)
return model
}
请求模式
该文件夹包含将用于验证请求的 JSON 模式。他们需要描述一个对象及其属性。我们应该能够验证包含参数的请求对象属性(通常是request.params
,但也可能是其他的东西,比如request.body
)。
由于我们为端点定义的属性类型,实际上只有一个端点需要验证:端点getBookSales
(GET /booksales
)。它接收两个日期参数,我们可能希望验证它们的格式,以 100%确定日期是有效的。
同样,为了提供“约定优于配置”所提供的使用简单性,我们的模式文件必须遵循一种非常特定的格式,然后由我们前面看到的验证器使用:
/request_schemas/``[CONTROLLER NAME]
module.exports = {
[ENDPOINT NICKNAME]
: {
validate``: [TYPE
schema
: [JSON SCHEMA]
}
}
在前面的代码中有几个部分需要解释:
- 控制器名:这意味着模式文件需要与控制器同名,全部小写。因为我们已经为我们的控制器文件这样做了,这意味着每个控制器的模式必须与每个控制器的文件具有相同的名称。
- 端点昵称:这应该是将动作添加到控制器时给动作起的昵称(使用
addAction
方法)。 - TYPE:要验证的对象的类型。目前唯一支持的值是
params
,它引用接收到的查询和路径参数。这可以扩展到支持其他对象。 - JSON 模式:这是我们添加定义请求参数的实际 JSON 模式的地方。
下面是定义getBookSales
动作验证的实际代码。
/request_schemas/booksales.js
module.exports = {
getbooksales: {
validate:``'params'
schema:
{
type: "object",
properties: {
start_date: {
type: 'string',
format:'date'
},
end_date: {
type: 'string',
format:'date'
},
store_id: {
type: 'string'
}
}
}
}
}
计划
这个文件夹包含我们的资源的 JSON 模式定义,当初始化我们的模型时,这些定义也转换成 Mongoose 模式。
这些文件中提供的详细程度非常重要,因为它也转化为实际的 Mongoose 模型。这意味着我们可以定义诸如值的范围和格式模式之类的东西,这将由 Mongoose 在创建新资源时进行验证。
例如,让我们看看 ClientReview,一个利用这种能力的模式。
/schemas/clientreview.js
module.exports = {
"id": "ClientReview",
"properties": {
"client": {
"$ref": "Client",
"description": "The client who submits the review"
},
"book": {
"$ref": "Book",
"description": "The book being reviewed"
},
"review_text": {
"type": "string",
"description": "The actual review text"
},
"stars": {
"type": "integer",
"description": "The number of stars, from 0 to 5",
"min": 0,
"max": 5
}
}
}
stars
属性清楚地设置了我们在保存新评论时可以发送的最大值和最小值。如果我们试图发送一个无效的数字,那么我们会得到如图 7-2 所示的错误。
图 7-2。
An error when trying to save an invalid value in a validated model
当定义引用其他模式时,记得正确命名引用(每个模式的名称由id
属性给出)。因此,如果你正确地设置了引用,db
模块的getModelFromSchema
方法也会在 Mongoose 中正确地设置引用(这对直接引用和集合都有效)。
这是该文件夹的主文件;index.js
的工作方式类似于其他文件夹中的索引文件:
module.exports = {
models: {
BookSale: require("./booksale"),
Book: require("./book"),
Author: require("./author"),
Store: require("./store"),
Employee: require("./employee"),
Client: require("./client"),
ClientReview: require("./clientreview")
}
}
最后,这里是为项目定义的其余模式。
/schemas/author.js
module.exports = {
"id": "Author",
"properties": {
"name": {
"type": "string",
"description": "The full name of the author"
},
"description": {
"type": "string",
"description": "A small bio of the author"
},
"books": {
"type": "array",
"description": "The list of books published on at least one of the stores by this author",
"items": {
"$ref": "Book"
}
},
"website": {
"type": "string",
"description": "The Website url of the author"
},
"avatar": {
"type": "string",
"description": "The url for the avatar of this author"
}
}
}
/schemas/book.js
module.exports = {
"id": "Book",
"properties": {
"title": {
"type": "string",
"description": "The title of the book"
},
"authors": {
"type":"array",
"description":"List of authors of the book",
"items": {
"$ref": "Author"
}
},
"isbn_code": {
"description": "Unique identifier code of the book",
"type":"string"
},
"stores": {
"type": "array",
"description": "The stores where clients can buy this book",
"items": {
"type": "object",
"properties": {
"store": {
"$ref": "Store",
},
"copies": {
"type": "integer"
}
}
}
},
"genre": {
"type": "string",
"description": "Genre of the book"
},
"description": {
"type": "string",
"description": "Description of the book"
},
"reviews": {
"type": "array",
"items": {
"$ref": "ClientReview"
}
},
"price": {
"type": "number",
"minimun": 0,
"description": "The price of this book"
}
}
}
/schemas/booksale.js
module.exports = {
"id": "BookSale",
"properties": {
"date": {
"type":"date",
"description": "Date of the transaction"
},
"books": {
"type": "array",
"description": "Books sold",
"items": {
"$ref": "Book"
}
},
"store": {
"type": "object",
"description": "The store where this sale took place",
"type": "object",
"$ref": "Store"
},
"employee": {
"type": "object",
"description": "The employee who makes the sale",
"$ref": "Employee"
},
"client": {
"type": "object",
"description": "The person who gets the books",
"$ref": "Client",
},
"totalAmount": {
"type": "integer"
}
}
}
/schemas/client.js
module.exports = {
"id": "Client",
"properties": {
"name": {
"type": "string",
"description": "Full name of the client"
},
"address": {
"type": "string",
"description": "Address of residence of this client"
},
"phone_number": {
"type": "string",
"description": "Contact phone number for the client"
},
"email": {
"type": "string",
"description": "Email of the client"
}
}
}
/schemas/employee.js
module.exports = {
"id": "Employee",
"properties": {
"first_name": {
"type": "string",
"description": "First name of the employee"
},
"last_name": {
"type": "string",
"description": "Last name of the employee"
},
"birthdate": {
"type": "string",
"description": "Date of birth of this employee"
},
"address": {
"type": "string",
"description": "Address for the employee"
},
"phone_numbers": {
"type": "array",
"description": "List of phone numbers of this employee",
"items": {
"type": "string"
}
},
"email": {
"type": "string",
"description": "Employee's email"
},
"hire_date": {
"type": "string",
"description": "Date when this employee was hired"
},
"employee_number": {
"type": "number",
"description": "Unique identifier of the employee"
}
}
}
/schemas/store.js
module.exports = {
"id": "Store",
"properties": {
"name": {
"type": "string",
"description": "The actual name of the store"
},
"address": {
"type": "string",
"description": "The address of the store"
},
"state": {
"type": "string",
"description": "The state where the store resides"
},
"phone_numbers": {
"type": "array",
"description": "List of phone numbers for the store",
"items": {
"type": "string"
}
},
"employees": {
"type": "array",
"description": "List of employees of the store",
"items": {
"$ref": "Employee"
}
}
}
}
斯瓦格-ui
这个文件夹包含了下载的 Swagger UI 项目,所以我们就不赘述这段代码了;然而,我将提到我们需要对index.html
文件(位于swagger-ui
文件夹的根目录)进行的小修改,以使 UI 正确加载。
需要的改变非常简单,有三个:
Edit the routes for all the resources loaded (CSS and JS files) to start with swagger-ui/
. Change the URL for the documentation server to http://localhost:9000/api-docs
(around line 31). Uncomment the block of code in line 73. Set the right value to the apiKey
variable (set it to 777).
有了这些改变,UI 应该能够正确加载,并允许您开始测试您的 API。
根文件夹
这是项目的根。这里只有两个文件:主文件index.js
和包含依赖项和其他项目属性的package.json
文件。
/package.json
{
"name": "come_n_read",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"colors": "¹.0.3",
"halson": "².3.1",
"mongoose": "³.8.23",
"mongoose-json-select": "⁰.2.1",
"restify": "².8.5",
"swagger-node-restify": "⁰.1.2",
"tv4": "¹.1.9",
"tv4-formats": "¹.0.0",
"underscore": "¹.7.0"
}
}
这个文件最有趣的部分是依赖项列表。其余部分是使用npm
命令行工具的init
选项自动生成的。
/index.js
var restify = require("restify"),
colors = require("colors"),
lib = require("./lib"),
swagger = require("swagger-node-restify"),
config = lib.config
var server = restify.createServer(lib.config.server)
//Middleware setup
server.use(restify.queryParser())
server.use(restify.bodyParser())
restify.defaultResponseHeaders = function(data) {
this.header('Access-Control-Allow-Origin', '*')
}
///Middleware to check for valid api key sent
server.use(function(req, res, next) {
//We move forward if we're dealing with the swagger-ui or a valid key
if(req.url.indexOf("swagger-ui") != -1 || lib.helpers.validateKey(req.headers.hmacdata || '', req.params.api_key, lib)) {
next()
} else {
res.send(401, { error: true, msg: 'Invalid api key sent'})
}
})
/**
Validate each request, as long as there is a schema for it
*/
server.use(function(req, res, next) {
var results = lib.schemaValidator.validateRequest(req)
if(results.valid) {
next()
} else {
res.send(400, results)
}
})
//the swagger-ui is inside the "swagger-ui" folder
server.get(/^\/swagger-ui(\/.*)?/, restify.serveStatic({
directory: __dirname + '/',
default: 'index.html'
}))
//setup section
swagger.addModels(lib.schemas)
swagger.setAppHandler(server)
lib.helpers.setupRoutes(server, swagger, lib)
swagger.configureSwaggerPaths("", "/api-docs", "") //we remove the {format} part of the paths, to
swagger.configure('``http://localhost:9000
//start the server
server.listen(config.server.port, function() {
console.log("Server started succesfully…".green)
lib.db.connect(function(err) {
if(err) console.log("Error trying to connect to database: ".red, err.red)
else console.log("Database service successfully started".green)
})
})
最后是主文件,也就是启动这一切的那个文件index.js
。该文件有四个不同的部分:
- 初始部分,需要所有需要的模块并实例化服务器。
- 中间件设置部分,它负责设置中间件的所有部分(我们稍后将对此进行介绍)。
- 设置部分,负责加载模型、控制器、设置路线等等。
- 服务器启动部分,启动 web 服务器和数据库客户机。
文件的开头和结尾部分不需要太多解释,因为它们非常简单明了,所以让我们看一下另外两部分。
中间件设置
中间件设置可能是 API 启动和正常运行所需的文件和引导过程中最重要的部分。但是由于中间件机制带来的易用性和简单性,它非常容易编写和理解。
我们在这里设置了五种不同的中间件:
- 查询解析器将查询参数转换成一个对象,这样我们就可以很容易地访问它们。
- 主体解析器,这样我们就可以访问 POST 的内容,并把请求作为一个对象,还有自动解析 JSON 字符串的额外好处。
- 安全检查,它负责每次重新散列请求,以确保我们处理的是经过身份验证的客户端。
- validate 检查,根据任何现有的 JSON 模式验证请求。
- 静态内容文件夹,它不完全是一个中间件,但是作为一个特定的路由集,允许 Restify 提供静态内容。
设置部分
这最后一节也非常重要;这五行代码实际上处理所有模型的实例化,链接 Swagger 和 Restify 服务器,设置所有路由(将每个动作的代码链接到 spec 部分中定义的相应路径和方法),最后,为 Swagger 后端服务器设置路由。
摘要
恭喜你!你现在应该有了我们 API 的工作版本,能够做我们在第六章中设置的几乎所有事情。您还应该更好地理解这些模块是如何工作的。理想情况下,你会在下一个项目中考虑它们。当然,还有像在第五章中讨论的那些选择,所以也不要忘记那些。
在本书的最后一章,我将回顾一些在处理这类项目时可能遇到的最常见的问题,您将看到如何解决它们。
Footnotes 1
http://mcavage.me/node-restify/#error-handling
见。
八、故障排除
就是这里。你进入了最后一章。您亲身体验了在 Node 中编写 RESTful API 的代价。你已经复习过理论了。您了解了 REST 实际代表什么,以及如何使用它来开发一个好的、有用的 API。
在这一章中,我将介绍在这个过程中可能会出错的一些事情,以及您必须考虑的一些事项,例如:
- 异步编程。我将最后一次尝试这个主题,解释它是如何在我们的代码中使用的。
- 关于 Swagger UI 配置的次要细节。有时文档是不够的。
- 潜在的 CORS 问题。我将回顾一下 CORS 的基本知识,以帮助你理解如何利用它。
- 数据类型。关于我们的代码,我将讨论的最后一个主题是如何从 JSON 模式数据类型转换到 Mongoose 类型。
异步编程
对于非 JavaScript 开发人员,甚至是非 Node.js 开发人员来说,异步编程的概念一开始可能很难理解。我说“可能”是因为这不是 JavaScript/Node.js 独有的概念;其他编程语言,如 Earlang、Python,甚至最近的 Go 都有这种能力。
也就是说,Node.js 是少数几个 web 开发人员被迫处理这个概念或者无法正确开发的环境之一。
当您开始处理外部资源时,异步编程成为任何使用 Node.js 的中型项目的必备工具,这主要是因为这意味着您将使用已经在利用这种编程技术的第三方库;所以你要么接受它,要么转换语言。
你已经介绍了这个特性是如何提高应用的性能的,你甚至看到了几个利用它的有用的设计模式,所以现在让我们讨论一下如果不能理解这个概念会如何影响你对第七章中介绍的代码的理解。
无论您是否注意到,在我们的 API 代码中,有几个地方发生了异步编程。让我们来看看其中的一些。
控制器动作的代码
每个控制器上的每个动作都有一段数据库查询形式的异步编程。这可能是最显而易见的一点,但是仔细阅读以正确理解它是很重要的。
我们不做这类事情的原因是:
var authors = lib.db.model('Author')
.find(criteria).exec()
if(!authors) return next(controller.RESTError('InternalServerError', authors))
controller.writeHAL(res, authors)
相反,我们设置了一个回调函数,就像这样:
lib.db.model('Author')
.find(criteria)
.exec(function(err, authors) {
if(err) return next(controller.RESTError('InternalServerError', err))
controller.writeHAL(res, authors)
})
这是因为,正如我已经说过的,Node.js 中的 I/O 操作是异步的,这意味着查询数据库需要像这样完成,并设置一个回调函数来处理到达的响应。Node.js 确实提供了其 I/O 功能的同步版本(比如读写文件),但它们主要是为了简化转换;不鼓励你使用它们,像 Mongoose 这样的第三方库也没有兴趣遵循这种模式。
在手动测试应用时捕捉这种类型的错误可能有点令人头疼,因为最终的行为可能并不总是相同的。当代码足够复杂时,异步函数返回响应所需的时间和代码使用该值所需的时间就成了一场竞赛。
此外,因为 Node.js 解释器不会在您错过方法/函数调用中的一些参数时抛出错误,所以您可能会像这样结束:
function libraryMethod(attr1, callback) {
asyncCall(attr1, function(response){
if(callback) callback(response)
})
}
var returnValue = libraryMethod('hello world')
前面的代码永远不会抛出错误。在你的returnValue
中,你将永远得不到定义。如果您无法访问libraryMethod
函数的代码,可能很难理解哪里出了问题。例如,您有这样一个代码:
var myResponseValue = ''
asyncCall('hello', function(response) {
myResponseValue = response
})
///some other code taking 30ms to execute
console.log(myResponseValue)
前面的代码显示了使用异步调用时的另一个常见错误:您正确地设置了回调,但是在回调之外使用了返回值。
在前面的例子中,如果asyncCall
得到响应的时间少于 30 毫秒,那么它会工作,但是直到发生了一些事情(比如代码进入生产环境)时,您才会意识到自己的错误。突然,asyncCall
执行需要 31 毫秒,现在“undefined”一直打印到控制台。但是你当然不知道为什么。解决这个问题的简单方法是在回调函数中添加任何处理响应值的代码。
中间件功能
乍看之下,这可能并不明显,但是整个中间件链都遵循着第三章 3 中提到的串行流程机制。你怎么知道?因为有next
功能;当函数结束并准备将控制权交给下一个中间件时,您需要调用它。
多亏了next
,函数中可以有更多的异步代码,并且仍然能够调用下一个函数。在某些地方,这并不明显,比如在设置queryParser
和bodyParser
中间件时:
server.use(restify.queryParser())
server.use(restify.bodyParser())
但是这些方法实际上是返回一个新函数,该函数又接收三个神奇的参数:请求对象、响应对象和下一个函数。
创建定制中间件时的一个常见问题是忘记在代码的一个可能的执行分支中调用next
函数(如果您碰巧拥有它们的话)。这种情况的症状是您的 API 似乎挂起了,您从未从服务器得到响应,并且您在控制台上看不到任何错误。这是因为执行流被中断了。突然它无法找到继续下去的方法。并且您没有发回响应(使用 response 对象)。这是一个棘手的问题,因为没有任何错误消息来清楚地说明问题。
function middleware(req, res, next){
if(req.params.q == '1') {
next()
} else {
if(req.params.q2 == '1') {
next()
}
}
//if no 'q' or 'q2' parameters are sent, or if they don't have the right values, then this middleware is breaking the serial flow and no response is ever getting back to the client.
}
项目中还使用了另一种类型的中间件:Mongoose 中间件,它是可以附加到模型上的钩子,可以在一组特定动作之前或之后执行。我们的特例在clientreview
模型上使用了 post save 挂钩:
modelDef.schema.post('save', function(doc, next) {
db.model('Book').update({_id: doc.book}, {$addToSet: {reviews: this.id}}, function(err) {
next(err)
})
})
这段代码清楚地显示了在中间件内部与异步调用结合使用的next
函数。如果您忘记调用next
,那么执行将在这个回调时被中断(并停止)。
配置 Swagger UI 的问题
设置 Swagger UI 是一项既需要修改 UI 本身又需要在后端编写一些特殊代码的任务。这不是特别容易理解,因为文档阅读起来并不简单。
一方面,我们使用 swagger-node-restify 模块来生成 UI 所需的后端端点;这通过以下几行实现:
swagger.addModels(lib.schemas)
swagger.setAppHandler(server)
lib.helpers.setupRoutes(server, swagger, lib)
swagger.configureSwaggerPaths("", "/api-docs", "")
swagger.configure('
http://localhost:9000
', '0.1')
第 1 行设置了模型,以便当端点将它们指定为响应类时,Swagger 可以返回它们。第 2 行基本上是告诉模块我们使用哪个 web 服务器来获取文档。我们可能会配置两个不同的服务器:一个用于文档,一个用于实际的 API。
第 3 行实际上是我们的一个,但是它需要 Swagger,因为我们调用它提供的addGET
、addPOST
、addDELETE
或addPUT
方法(这是由BaseController
代码在其setUpActions
方法中完成的)。
第 4 行没有说太多,但是它很有用,原因有几个:
- 最明显的一点是我们正在为文档设置路径:
/api-docs
。 - 我们还说,我们不想通过扩展(例如,
.json
)来指定格式。默认情况下,我们需要在路径中定义一个由.json
自动替换的{format}部分。有了这一行,我们就不再需要它,并简化了路径格式。
最后,第 5 行设置了整个文档 API 的基本 URL。
第七章,前端代码不得不改;我提到了具体位置。显然需要取消 API 键代码的注释和主机 URL 的更改,但是不需要资源路径的更改。由于我们在初始化阶段配置静态路径的方式,我们需要对此进行更改。
server.get(/^\/swagger-ui(\/.*)?/, restify.serveStatic({
directory: __dirname + '/',
default: 'index.html'
}))
前面的代码确保只有swagger-ui
文件夹下的任何内容作为静态内容(基本上是 Swagger UI 需要的所有内容),但是 HTML 文件中的默认路径指向根文件夹,这在我们的例子中不够好。
CORS:又名跨原产地资源共享
任何从事过一段时间的 web 开发人员都见过这个可怕的错误消息:
XMLHttpRequest cannot load
http://domain.example
. Origin
http://domain1.example
is not allowed by Access-Control-Allow-Origin
。
对于在公共 API 的 web 客户端上工作的开发人员,浏览器检查跨源资源共享(CORS)以确保请求是安全的,这意味着浏览器检查了请求的端点,因为它没有找到任何 CORS 标头,或者标头没有指定我们的域为有效,所以它出于安全原因取消了请求。
对于 API 设计者来说,这是一个非常相关的错误,因为需要考虑 CORS,要么手动允许它,要么拒绝它。如果您正在设计一个公共 API,您需要确保在响应头中指定任何域都可以发出请求。这是所有可能设置中最宽松的。另一方面,如果您正在定义一个私有 API,那么 CORS 头有助于定义唯一可以实际请求任何类型的端点资源的域。
通常,web 客户端会对每个 CORS 请求执行一系列步骤:
First, the client will ask the API server if the desired request is possible (Can the client query the wanted resource using the needed method from the current origin?). This is done by sending a “pre-flight”1 request with the Access-Control-Request-Header
header (with the headers the client needs to access) and the Access-Control-Request-Method
header (with the method needed). Then the server will answer with what is authorized, using these headers: Access-Control-Allow-Origin
with the allowed origin (or *
for anything), Access-Control-Allowed-Methods
with the valid methods, and Access-Control-Allow-Headers
with a list of valid headers to be sent. Finally, the client can do the “normal” request.
如果在飞行前请求过程中有任何东西验证失败(请求的方法或需要的头),那么响应将不是 200 OK 响应。
对于我们的例子,根据第七章中的代码,我们将采用公共 API 方法,因为我们允许任何域使用以下代码向我们的端点发出请求:
restify.defaultResponseHeaders = function(data) {
this.header('Access-Control-Allow-Origin', '*')
}
数据类型
尽管我们没有在整个 API 的 JavaScript 代码中直接处理和指定变量的类型,但是有两个非常特殊的地方需要数据类型:为我们的资源定义的 JSON 模式和定义的 Mongoose 模型。
现在,由于getModelFromSchema
函数和translateTypeToJs
函数中的代码,您可以从 JSON 模式类型转换到 Mongoose 类型,因为我们的模式中定义的大多数基本类型几乎都可以直接转换成 JavaScript 类型。
对于更复杂的类型,比如数组,由于整个定义是不同的,需要添加额外的代码,这就是getModelFromSchema
代码的用武之地。
从第七章中的代码转换而来的类型仅限于当时需要的,但是你可以很容易地扩展它来实现更多的功能,比如让required
属性同时为模式验证器和 Mongoose 验证器工作(这些确保你不会保存任何无效的东西)。让我们快速看一下如何添加对required
属性的支持。
一个对象类型由一系列属性组成,但也包括一系列必需的属性,这些属性定义在与properties
属性相同的级别:
module.exports = {
"id": "Author",
"properties": {
"name": {
"type": "string",
"description": "The full name of the author"
},
"description": {
"type": "string",
"description": "A small bio of the author"
},
"books": {
"type": "array",
"description": "The list of books published on at least one of the stores by this author",
"items": {
"$ref": "Book"
}
},
"website": {
"type": "string",
"description": "The Website url of the author"
},
"avatar": {
"type": "string",
"description": "The url for the avatar of this author"
},
"address": {
"type": "object",
"properties": {
"street": {
"type": "string"
},
"house_number": {
"type": "integer"
}
}
}
},
"required": ["name", "website"]
}
要获得这个新属性的内容,只需在getModelFromSchema
函数中添加几行代码,简单地检查属性名;如果它在所需的数组中,您可以根据需要设置它:
function getModelFromSchema(schema) {
var data = {
name: schema.id,
schema: {}
}
var newSchema = {}
var tmp = null
var requiredProperties = schema.required
_.each(schema.properties, function(v, propName) {
if( requiredProperties``&&
v.required = true
}
if(v['$ref'] != null) {
tmp = {
type: Schema.ObjectId,
ref: v['$ref']
}
} else {
tmp = translateComplexType(v)
}
newSchema[propName] = tmp
})
data.schema = new Schema(newSchema)
return data
摘要
就是这里。你做到了。你设法看完了整本书!你已经从 REST 的基础学到了一个成熟的 RESTful API,最后,在这一章中,你学习了在开发过程中会引起麻烦的主要东西,比如异步编程,配置 Swagger UI,CORS,以及从 JSON 模式类型转移到 Mongoose 类型。
感谢您的阅读,希望您能喜欢这本书。
Footnotes 1
期权申请。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)