Node-完全参考指南-全-
Node 完全参考指南(全)
原文:
zh.annas-archive.org/md5/940cb357aab1e8ad92150cd861b4f7af译者:飞龙
前言
随着时间的推移,机器学习正变得越来越对企业具有变革性。使用 Node.js 的机器学习完整参考指南将您从 JavaScript 和服务器端开发的基础知识引导至创建、维护、部署和测试您自己的 Node.js 应用程序。
您将从学习如何使用 HTTP 服务器和客户端对象开始,使用 SQL 和 MongoDB 数据库存储数据,以及使用 Mocha 5.x 进行单元测试,并使用 Puppeteer 1.1.x 进行功能测试。然后,您将学习在 Node.js 平台上创建可扩展且丰富的 RESTful 应用,并编写一个具有自描述 URL 的简单 HTTP 请求处理器。您将学习设置准确的 HTTP 状态码,研究如何保持您的应用程序向后兼容,并探索一些认证技术以保护您的应用程序。然后,您将研究 Node.js 如何成为开发微服务的一个强有力的候选者。
通过本学习路径,您将能够使用最佳实践并创建高效的微服务。
本学习路径包括以下 Packt 产品的内容:
-
《使用 Node.js 10 设计 RESTful Web API,第三版》由瓦伦丁·博金诺夫著
-
《Node.js Web 开发,第四版》由大卫·赫伦著
-
《使用 Node.js 实践微服务》由迪戈·雷森德著
这本书面向谁
Node.js 完整参考指南是为那些对 JavaScript 和 Web 应用开发有基本了解的 Web 开发者设计的,他们渴望丰富他们的开发技能以创建 RESTful 应用,并希望利用他们的技能来构建微服务。
为了充分利用本书
本学习路径中提供的代码示例的执行需要运行在 POSIX-like 操作系统上的 Node.js,包括各种 UNIX 衍生品(例如 Solaris)或类似操作系统(Linux、macOS 等),以及 Microsoft Windows。它可以在大小不同的机器上运行,包括微小的 ARM 设备,例如用于 DIY 软件/硬件项目的 Raspberry Pi 微尺度嵌入式计算机。
它可以在大小不同的机器上运行,包括微小的 ARM 设备,例如用于 DIY 软件/硬件项目的 Raspberry Pi 微尺度嵌入式计算机。
由于许多 Node.js 包是用 C 或 C++编写的,您必须拥有 C 编译器(如 GCC)、Python 2.7(或更高版本)以及 node-gyp 包。如果您计划在网络代码中使用加密,您还需要 OpenSSL 加密库。现代 UNIX 衍生品几乎都包含这些,并且当从源代码安装时使用的 Node.js 的配置脚本将检测它们的存在。如果您需要安装它们,Python 可在python.org获取,OpenSSL 可在openssl.org获取。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保您使用最新版本解压缩或提取文件夹。
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Node.js-Complete-Reference-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理。以下是一个示例:“EventEmitter 对象定义在 Node.js 的 events 模块中。”
代码块设置如下:
if (anotherNote instanceof Note) {
... it's a Note, so act on it as a Note
}
任何命令行输入或输出如下所示:
$ npm update express
$ npm update
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“如果您需要不同的内容,请点击页眉中的下载链接以获取所有可能的下载:”
警告或重要说明如下所示。
技巧和窍门如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至 customercare@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过链接至材料的方式与我们联系至 copyright@packt.com。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com.
第一章:关于 Node.js
Node.js 是一个令人兴奋的新平台,用于开发 Web 应用、应用服务器、各种网络服务器或客户端,以及通用编程。它通过巧妙结合服务器端 JavaScript、异步 I/O 和异步编程,旨在实现网络应用的极端可伸缩性。它围绕 JavaScript 匿名函数和单线程事件驱动架构构建。
尽管只有几年历史,Node.js 已经迅速崛起,现在正发挥着重要作用。大小公司都在使用它进行大规模和小规模项目。例如,PayPal 已经将许多服务从 Java 转换为 Node.js。
Node.js 的架构与传统应用平台的选择有所不同。在其他应用平台中,线程被广泛用于扩展应用以填充 CPU,而 Node.js 由于线程固有的复杂性而放弃了线程。据说,使用单线程事件驱动架构,内存占用低,吞吐量高,负载下的延迟性能更好,编程模型更简单。Node.js 平台正处于快速增长的阶段,许多人将其视为传统使用 Java、PHP、Python 或 Ruby on Rails 的 Web 应用架构的有力替代品。
在其核心,它是一个独立的 JavaScript 引擎,通过扩展使其适用于通用编程,并明确关注应用服务器开发。尽管我们正在将 Node.js 与应用服务器平台进行比较,但它本身并不是一个应用服务器。相反,Node.js 是一种类似于 Python、Go 或 Java SE 的编程运行时。尽管有使用 Node.js 编写的 Web 应用框架和应用服务器,但它仅仅是一个执行 JavaScript 程序的系统。
它是基于非阻塞 I/O 事件循环和文件及网络 I/O 库层实现的,所有这些都是在 Chrome 浏览器背后的 V8 JavaScript 引擎之上构建的。Chrome 中实现的快速性能和功能改进迅速传递到 Node.js 平台。此外,一个团队正在开发一个在 Microsoft 的 ChakraCore JavaScript 引擎(来自 Edge 浏览器)之上运行的 Node.js 实现。这将通过不依赖于单一 JavaScript 引擎提供商来为 Node.js 社区提供更大的灵活性。访问 github.com/nodejs/node-chakracore 查看该项目。
Node.js 的 I/O 库足够通用,可以执行任何类型的服务器,执行任何 TCP 或 UDP 协议,无论是域名系统(DNS),HTTP,互联网中继聊天(IRC),还是 FTP。虽然它支持开发互联网服务器或客户端,但其最大的用例是在常规网站上,替代 Apache/PHP 或 Rails 堆栈,或补充现有网站。例如,使用 Socket.IO 库为 Node.js 添加实时聊天或监控现有网站可以轻松完成。它的轻量级和高性能特性通常使 Node.js 被用作粘合剂服务。
一个特别吸引人的组合是将使用 Docker 部署的小型服务部署到云托管基础设施中。一个大型应用程序可以分解成现在称为微服务的形式,这些服务可以使用 Docker 轻松地大规模部署。结果是适合敏捷项目管理方法,因为每个微服务都可以由一个小团队轻松管理,该团队在其各自的 API 边界上进行协作。
本书将为你介绍 Node.js。我们假设以下:
-
你已经知道如何编写软件
-
你熟悉 JavaScript
-
你对用其他语言开发 Web 应用程序有所了解
在本章中,我们将涵盖以下主题:
-
Node.js 简介
-
为什么你应该使用 Node.js
-
Node.js 的架构
-
使用 Node.js 的性能、利用率和可伸缩性
-
Node.js、微服务架构和测试
-
使用 Node.js 实现十二要素应用模型
我们将直接进入开发工作应用程序,并认识到通常最好的学习方法是通过在工作代码中翻找。
Node.js 的功能
Node.js 是一个在 Web 浏览器之外编写 JavaScript 应用程序的平台。这并不是我们在 Web 浏览器中熟悉的 JavaScript!例如,Node.js 中没有内置 DOM,也没有其他浏览器功能。
除了其执行 JavaScript 的本地能力之外,捆绑的模块提供了以下这类功能:
-
命令行工具(以 shell 脚本风格)
-
一种交互式终端风格的程序,即读取-评估-打印循环(REPL)
-
优秀的进程控制功能,用于监督子进程
-
一个用于处理二进制数据的缓冲对象
-
带有全面事件驱动回调的 TCP 或 UDP 套接字
-
DNS 查找
-
在 TCP 库文件系统访问之上构建的 HTTP、HTTPS 和 HTTP/2 客户端/服务器层
-
通过断言提供的内置基本单元测试支持
Node.js 的网络层是低级别的,但使用简单。例如,HTTP 模块允许你使用几行代码编写 HTTP 服务器(或客户端)。这很强大,但它让你,即程序员,非常接近协议请求,并要求你精确实现那些你应该在请求响应中返回的 HTTP 头。
典型的 Web 应用程序开发者不需要在 HTTP 或其他协议的低级别工作。相反,我们倾向于更高效地工作,使用高级接口。例如,PHP 开发者假设 Apache(或其他 HTTP 服务器)已经存在,提供 HTTP 协议,并且他们不需要实现堆栈的 HTTP 服务器部分。相比之下,Node.js 程序员确实实现了 HTTP 服务器,并将他们的应用程序代码附加到该服务器上。
为了简化情况,Node.js 社区有几个 Web 应用程序框架,如 Express,为典型程序员提供所需的更高级接口。你可以快速配置具有内置功能(如会话、cookies、静态文件服务、日志记录)的 HTTP 服务器,让开发者专注于他们的业务逻辑。其他框架提供 OAuth 2 支持,或专注于 REST API,等等。
Node.js 并不仅限于 Web 服务应用开发。围绕 Node.js 的社区已经将其应用于许多其他方向,
构建工具:Node.js 已成为开发用于软件开发或与服务基础设施通信的命令行工具的流行选择。Grunt 和 Gulp 被前端开发者广泛用于构建网站资产。Babel 被广泛用于将现代 ES-2016 代码转换为在旧浏览器上运行的代码。流行的 CSS 优化器和处理器,如 PostCSS,是用 Node.js 编写的。静态网站生成系统,如 Metalsmith、Punch 和 AkashaCMS,在命令行上运行并生成上传到 Web 服务器的网站内容。
Web UI 测试:Puppeteer 允许你控制一个无头-Chrome 网络浏览器实例。利用它,你可以开发控制现代全功能网络浏览器的 Node.js 脚本。典型的用例包括网络爬取和测试 Web 应用程序。
桌面应用程序:Electron 和 node-webkit(NW.js)是用于开发 Windows、macOS 和 Linux 桌面应用程序的框架。这些框架利用了大量的 Chrome,并通过 Node.js 库进行包装,使用 Web UI 技术开发桌面应用程序。应用程序使用现代的 HTML5、CSS3 和 JavaScript 编写,并可以利用领先的 Web 框架,如 Bootstrap、React 或 AngularJS。许多流行的应用程序都是使用 Electron 开发的,包括 Slack 桌面客户端应用程序、Atom 和 Microsoft Visual Code 编程编辑器、Postman REST 客户端、GitKraken GIT 客户端,以及 Etcher,它使得将操作系统镜像烧录到闪存驱动器以在单板计算机上运行变得极其简单。
移动应用:Node.js for Mobile Systems 项目允许你使用 Node.js 开发智能手机或平板电脑应用,适用于 iOS 和 Android。苹果的 App Store 规则禁止包含具有 JIT 功能的 JavaScript 引擎,这意味着正常的 Node.js 不能用于 iOS 应用。对于 iOS 应用开发,该项目使用 Node.js-on-ChakraCore 来规避 App Store 规则。对于 Android 应用开发,该项目使用常规的 Node.js 在 Android 上。截至写作时,该项目处于早期开发阶段,但看起来很有希望。
物联网(IoT):据报道,它是一种非常流行的物联网项目语言,Node.js 也能在大多数基于 ARM 的单板计算机上运行。最明显的例子是 NodeRED 项目。它提供了一个图形编程环境,允许你通过连接模块来绘制程序。它具有面向硬件的输入和输出机制,例如,与树莓派或 Beaglebone 单板计算机上的通用输入/输出(GPIO)引脚进行交互。
服务器端 JavaScript
别再挠头了!当然你在做,挠你的头,自言自语,“浏览器语言怎么会出现在服务器上?”事实上,JavaScript 在浏览器之外有着漫长且在很大程度上不为人知的历史。JavaScript 是一种编程语言,就像任何其他语言一样,更好的问题是“为什么 JavaScript 应该被困在浏览器内部?”。
回到网络时代的初期,编写网络应用的工具还处于起步阶段。有些人正在尝试使用 Perl 或 TCL 来编写 CGI 脚本,而 PHP 和 Java 语言刚刚被开发出来。即使那时,JavaScript 也已经在服务器端得到了应用。一个早期的网络应用服务器是 Netscape 的 LiveWire 服务器,它使用了 JavaScript。微软的一些 ASP 版本使用了 JScript,这是他们版本的 JavaScript。在 Java 宇宙中,还有一个更近期的服务器端 JavaScript 项目,即 RingoJS 应用框架。Java 6 和 Java 7 都配备了 Rhino JavaScript 引擎。在 Java 8 中,Rhino 被新的 Nashorn JavaScript 引擎所取代。
换句话说,浏览器之外的 JavaScript 并不是什么新鲜事物,即使它并不常见。
你为什么应该使用 Node.js?
在众多可用的网络应用开发平台中,为什么你应该选择 Node.js?有很多技术栈可供选择;是什么让 Node.js 脱颖而出,超越其他平台?我们将在接下来的章节中看到。
流行度
Node.js 正迅速成为流行的开发平台,许多大小玩家都在采用。其中之一是 PayPal,他们正在用 Node.js 编写的新系统替换现有的基于 Java 的系统。有关 PayPal 的这篇博客文章,请访问www.paypal-engineering.com/2013/11/22/node-js-at-paypal/。其他大型 Node.js 采用者包括沃尔玛的在线电子商务平台、领英和 eBay。
根据 NodeSource 的数据,Node.js 的使用正在快速增长(访问nodesource.com/node-by-numbers)。这些指标包括下载 Node.js 发布版的带宽增加、Node.js 相关 GitHub 项目的活动增加以及更多。
最好不要仅仅跟随潮流,因为潮流声称他们的软件平台可以做酷的事情。Node.js 确实做一些酷的事情,但更重要的是它的技术优势。
在堆栈的所有级别上使用 JavaScript
在服务器和客户端使用相同的编程语言一直是网络上的一个长期梦想。这个梦想可以追溯到 Java 的早期,当时 Java 小程序被设想为用 Java 编写的服务器应用程序的前端,而 JavaScript 最初被设想为这些小程序的轻量级脚本语言。由于各种原因,Java 从未实现其作为客户端编程语言的炒作。我们最终得到了 JavaScript 作为主要的浏览器端、客户端语言,而不是 Java。通常,前端 JavaScript 开发者与后端团队处于不同的语言宇宙中,后端团队可能使用 PHP、Java、Ruby 或 Python 进行编码。
随着时间的推移,浏览器中的 JavaScript 引擎变得极其强大,使我们能够编写越来越复杂的浏览器端应用程序。通过 Node.js,我们可能最终能够在客户端和服务器两端都使用 JavaScript,从而在浏览器和服务器上实现使用相同编程语言的应用程序。
前端和后端使用相同的语言提供了几个潜在的好处:
-
同样的编程人员可以在网络两端工作
-
代码可以在服务器和客户端之间更容易地迁移
-
服务器和客户端之间存在共同的数据格式(JSON)
-
服务器和客户端存在共同的软件工具
-
服务器和客户端的常见测试或质量报告工具
-
在编写网络应用程序时,可以在两端使用视图模板
由于其在网络浏览器中的普遍存在,JavaScript 语言非常受欢迎。它在与其他语言的比较中表现良好,同时拥有许多现代、高级的语言概念。由于其受欢迎程度,有经验的 JavaScript 程序员人才库非常深厚。
利用谷歌对 V8 的投资
为了使 Chrome 成为一个受欢迎且优秀的网络浏览器,谷歌投资于将 V8 打造成为一个超级快速的 JavaScript 引擎。因此,谷歌有巨大的动力不断改进 V8。V8 是 Chrome 的 JavaScript 引擎,也可以独立执行。Node.js 建立在 V8 JavaScript 引擎之上。
随着 Node.js 对 V8 团队的重要性日益增加,随着更多的人关注 V8 的改进,有可能实现更快 V8 性能的协同效应。
更精简、异步、事件驱动的模型
我们稍后会深入探讨这个问题。Node.js 的架构,一个单一的执行线程,一个巧妙的事件驱动异步编程模型,以及一个快速的 JavaScript 引擎,比基于线程的架构开销更小。
微服务架构
软件开发中的一个新趋势是微服务理念。微服务专注于将大型网络应用拆分为小型、高度专注的服务,这些服务可以由小型团队轻松开发。虽然这并不是一个全新的想法,但它更多的是对旧客户端-服务器计算模型的重构,微服务模式与敏捷项目管理技术很好地结合,并为我们提供了更细粒度的应用部署。
Node.js 是一个实现微服务的优秀平台。我们稍后会深入探讨这个问题。
Node.js 因为经历了重大分裂和敌对分支而变得更加强大
在 2014 年和 2015 年期间,Node.js 社区在政策、方向和控制方面面临了一次重大的分裂。io.js项目是由一群希望整合几个特性并改变决策过程的人推动的一个敌对分支。最终结果是 Node.js 和 io.js 仓库的合并,成立了一个独立的 Node.js 基金会来管理事务,社区正共同努力朝着共同的方向前进。
治愈这一裂痕的一个具体结果是新 ECMAScript 语言特性的快速采用。V8 引擎正在快速采用这些新特性以推进 Web 开发的状态。反过来,Node.js 团队也在 V8 中快速采用这些特性,这意味着 Promise 和async函数正迅速成为 Node.js 程序员的现实。
底线是,Node.js 社区不仅存活了下来,而且由于 io.js 分支,社区及其培养的平台变得更加强大。
线程与事件驱动架构的比较
据说 Node.js 的卓越性能归因于其异步事件驱动架构以及使用 V8 JavaScript 引擎。这听起来很好,但这个声明的依据是什么?
V8 JavaScript 引擎是 JavaScript 实现中最快的之一。因此,Chrome 不仅被广泛用于查看网站内容,还被用于运行复杂的应用程序。例如,Gmail、Google GSuite 应用程序(文档、幻灯片等)、图像编辑器如 Pixlr 以及绘图应用程序如 draw.io 和 Canva。Atom 和微软的 Visual Studio Code 都是优秀的 IDE,它们恰好是用 Node.js 和 Chrome 中的 Electron 实现的。这些应用程序的存在以及大量人群的愉快使用是对 V8 性能的证明。Node.js 从 V8 的性能改进中受益。
正常的应用服务器模型使用阻塞 I/O 来检索数据,并使用线程来实现并发。阻塞 I/O 会导致线程在等待结果时停滞。这导致在应用服务器启动和停止线程以处理请求时,线程之间产生波动。每个挂起的线程(通常在等待 I/O 操作完成)会消耗完整的堆栈跟踪内存,从而增加内存消耗开销。线程不仅增加了应用服务器的复杂性,还增加了服务器开销。
Node.js 有一个单独的执行线程,没有等待 I/O 或上下文切换。相反,有一个事件循环在寻找事件并将它们分派给处理函数。范式是任何会阻塞或需要时间来完成操作的任何操作都必须使用异步模型。这些函数应该提供一个匿名函数作为处理回调,或者(随着 ES2015 promises 的出现),函数会返回一个 Promise。处理函数或 Promise 在操作完成时被调用。在此期间,控制权返回到事件循环,它继续分派事件。
在 2017 年的 Node.js 交互式会议上,IBM 的 Chris Bailey 提出了 Node.js 是高度可扩展微服务的优秀选择。关键性能特征是 I/O 性能,以每秒事务数衡量,启动时间,因为这限制了你的服务可以多快地扩展以满足需求,以及内存占用,因为这决定了每个服务器可以部署多少个应用程序实例。Node.js 在这些方面都表现出色;随着每个后续版本的发布,它要么在改进,要么保持相当稳定。Bailey 展示了将 Node.js 与用 Spring Boot 编写的类似基准进行比较的数字,表明 Node.js 的表现要好得多。要观看他的演讲,请参阅www.youtube.com/watch?v=Fbhhc4jtGW4。
为了帮助我们理解为什么会这样,让我们回到 Node.js 的创造者 Ryan Dahl,以及激发他创造 Node.js 的关键灵感。在 2010 年 5 月的Cinco de NodeJS演讲中,www.youtube.com/watch?v=M-sc73Y-zQA,Dahl 询问我们在执行类似以下代码的行时会发生什么:
result = query('SELECT * from db');
// operate on the result
当然,程序会在那个点暂停,因为数据库层将查询发送到数据库,数据库确定结果并返回数据。根据查询的不同,这个暂停可能相当长;好吧,几毫秒,这在计算机时间中是一个漫长的时代。这个暂停很不好,因为执行线程在等待结果到来时什么都不能做。如果你的软件运行在单线程平台上,整个服务器将会被阻塞并且无响应。如果相反,你的应用程序运行在基于线程的服务器平台上,则需要线程上下文切换来满足任何到达的其他请求。服务器上未完成的连接数越多,线程上下文切换的次数就越多。上下文切换不是免费的,因为更多的线程需要更多的内存来存储每个线程的状态,以及 CPU 在线程管理开销上花费更多的时间。
仅使用异步的事件驱动 I/O,Node.js 就消除了大部分这种开销,同时引入了很少的自身开销。
使用线程来实现并发通常伴随着这样的警告:昂贵且容易出错、Java 的错误易发同步原语,或者设计并发软件可能很复杂且容易出错。复杂性来自于对共享变量的访问以及避免线程死锁和竞争的各种策略。Java 的同步原语是这种策略的一个例子,显然许多程序员发现它们很难使用。有创建框架如java.util.concurrent的倾向,以驯服线程并发的复杂性,但有些人可能会认为掩盖复杂性并不会使事情变得更简单。
Node.js 要求我们以不同的方式思考并发。从事件循环异步触发的回调是一个更简单的并发模型——更容易理解、更容易实现、更容易推理、更容易调试和维护。
Ryan Dahl 指出,对象相对访问时间可以理解异步 I/O 的需求。内存中的对象访问速度更快(以纳秒计),比磁盘上的对象或通过网络检索的对象(毫秒或秒)快。外部对象的较长时间访问以数百万个时钟周期来衡量,当你的客户坐在他们的网络浏览器上,如果页面加载时间超过两秒,他们可能就会离开。
在 Node.js 中,之前讨论的查询将如下所示:
query('SELECT * from db', function (err, result) {
if (err) throw err; // handle errors
// operate on result
});
程序员提供一个函数,当结果(或错误)可用时会被调用(因此得名回调函数)。而不是线程上下文切换,此代码几乎立即返回到事件循环。该事件循环可以自由处理其他请求。Node.js 运行时跟踪导致此回调函数的堆栈上下文,最终某个事件会触发,导致此回调函数被调用。
JavaScript 语言的进步为我们提供了实现这个想法的新选项。当使用 ES2015 Promise 时,等效代码看起来是这样的:
query('SELECT * from db')
.then(result => {
// operate on result
})
.catch(err => {
// handle errors
});
以下是一个使用 ES-2017 async函数的例子:
try {
var result = await query('SELECT * from db');
// operate on result
} catch (err) {
// handle errors
}
这三个代码片段执行的是之前写过的相同查询。区别在于查询不会阻塞执行线程,因为控制权返回到事件循环。通过几乎立即返回到事件循环,它可以自由地服务其他请求。最终,那些事件中的一个将是之前显示的查询的响应,这将调用回调函数。
使用回调或 Promise 方法,result不是作为函数调用的结果返回,而是提供给稍后将被调用的回调函数。执行顺序不是一行接一行,就像在同步编程语言中那样。相反,执行顺序由回调函数的执行顺序决定。
当使用async函数时,编码风格看起来就像原始的同步代码示例。result作为函数调用的结果返回,错误使用try/catch以自然的方式处理。await关键字集成了异步结果处理,而不会阻塞执行线程。async/await功能之下隐藏了很多东西,我们将在整本书中广泛地介绍这个模型。
通常,网页会从数十个来源收集数据。每个来源都有一个前面讨论过的查询和响应。使用异步查询,每个查询可以并行发生,其中页面构建函数可以触发数十个查询——无需等待,每个都有自己的回调——然后返回到事件循环,在完成每个查询时调用回调。因为它是并行的,所以数据可以比如果这些查询是同步地一个接一个地完成收集得更快。现在,网页浏览器的读者会更高兴,因为页面加载得更快。
性能和利用率
对 Node.js 的一些兴奋之处归因于其吞吐量(它每秒可以服务的请求数)。例如,与 Apache 等类似应用的比较基准显示,Node.js 有巨大的性能提升。
流传的一个基准是一个简单的 HTTP 服务器(借鉴自nodejs.org/en/),它直接从内存中返回一个Hello World消息:
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(8124, "127.0.0.1");
console.log('Server running at http://127.0.0.1:8124/');
这是在 Node.js 中可以构建的较简单的 Web 服务器之一。http对象封装了 HTTP 协议,它的http.createServer方法创建了一个完整的 Web 服务器,监听listen方法中指定的端口。该 Web 服务器上的每个请求(无论是任何 URL 上的GET或POST)都会调用提供的函数。它非常简单且轻量。在这种情况下,无论 URL 如何,它都返回一个简单的text/plain,即Hello World响应。
Ryan Dahl 展示了一个简单的基准测试 (www.youtube.com/watch?v=M-sc73Y-zQA),返回了一个 1 兆字节的二进制缓冲区;Node.js 提供了 822 req/sec,而 Nginx 提供了 708 req/sec,比 Nginx 提高了 15%。他还指出,Nginx 的内存峰值达到四兆字节,而 Node.js 的内存峰值达到 64 兆字节。
关键观察结果是,Node.js,运行一个解释的 JIT 编译的高级语言,在执行类似任务时与由高度优化的 C 代码构建的 Nginx 大约一样快。那次演示是在 2010 年 5 月,自那时以来,Node.js 已经取得了巨大的进步,正如我们之前提到的 Chris Bailey 的演讲中所示。
Yahoo! 的搜索工程师 Fabian Frank 发布了一篇关于使用 Apache/PHP 和两种 Node.js 栈变体实现的现实世界搜索查询建议小部件的性能案例研究 (www.slideshare.net/FabianFrankDe/nodejs-performance-case-study)。该应用程序是一个弹出面板,当用户输入短语时显示搜索建议,使用基于 JSON 的 HTTP 查询。Node.js 版本能够处理每秒八倍数量的请求,同时保持相同的请求延迟。Fabian Frank 表示,这两个 Node.js 栈线性扩展,直到 CPU 使用率达到 100%。在另一个演示 (www.slideshare.net/FabianFrankDe/yahoo-scale-nodejs) 中,他讨论了 Yahoo! Axis 是如何运行在 Manhattan + Mojito 上,以及能够在前后端都使用相同的语言(JavaScript)和框架(YUI/YQL)的价值。
LinkedIn 使用 Node.js 对其移动应用进行了大规模的重构,用 Node.js 替换了旧的 Ruby on Rails 应用程序,用于服务器端。这次切换使他们从 30 台服务器减少到三台,并且由于所有内容都是用 JavaScript 编写的,他们得以合并前端和后端团队。在选择 Node.js 之前,他们评估了带有 Event Machine 的 Rails、带有 Twisted 的 Python 以及 Node.js,并基于我们刚才讨论的原因选择了 Node.js。要了解 LinkedIn 所做的工作,请参阅 arstechnica.com/information-technology/2012/10/a-behind-the-scenes-look-at-linkedins-mobile-engineering/。
大多数关于 Node.js 性能建议的现有内容往往是为较老的 V8 版本所写,这些版本使用了 CrankShaft 优化器。V8 团队已经完全放弃了 CrankShaft,并引入了一个名为 TurboFan 的新优化器。例如,在 CrankShaft 下,使用try/catch、let/const、生成器函数等会变慢。因此,普遍的智慧是不要使用这些特性,这令人沮丧,因为我们想使用新的 JavaScript 特性,因为它们极大地改进了 JavaScript 语言。谷歌 V8 团队的一名工程师 Peter Marshall 在 Node.js Interactive 2017 上发表了一次演讲,声称在 TurboFan 下,你应该只编写自然的 JavaScript。使用 TurboFan 的目标是在 V8 中实现全面性能提升。要查看演讲,请参阅www.youtube.com/watch?v=YqOhBezMx1o。
关于 JavaScript 的一个普遍真理是它不适合重计算工作,因为 JavaScript 的本质。我们将在下一节中讨论一些与此相关的内容。Mikola Lysenko 在 Node.js Interactive 2016 的一次演讲中讨论了 JavaScript 中数值计算的一些问题以及一些可能的解决方案。常见的数值计算涉及通过数值算法处理的大数值数组,这些算法你可能已经在微积分或线性代数课程中学到。JavaScript 缺乏的是多维数组以及访问某些 CPU 指令。他提出的解决方案是一个库,用于在 JavaScript 中实现多维数组,以及另一个包含大量数值计算算法的库。要查看演讲,请参阅www.youtube.com/watch?v=1ORaKEzlnys。
事实上,Node.js 在事件驱动 I/O 吞吐量方面表现卓越。一个 Node.js 程序是否能在计算程序方面表现出色,取决于你在绕过 JavaScript 语言某些限制方面的独创性。计算编程的一个大问题是它会阻止事件循环执行,正如我们将在下一节中看到的,这可能会使 Node.js 看起来不适合任何东西。
Node.js 是不是一个有害的可扩展性灾难?
在 2011 年 10 月,软件开发者和博主 Ted Dziuba 撰写了一篇博客文章(后来从他的博客中撤下),标题为Node.js is a cancer,称其为可扩展性灾难。他用来证明的例子是一个 CPU 密集型的斐波那契数列算法实现。尽管他的论点存在缺陷,但他提出了一个有效的观点,即 Node.js 应用程序开发者必须考虑以下问题:你将把重计算任务放在哪里?
维护 Node.js 应用程序高吞吐量的关键是确保事件处理得快。因为它使用单个执行线程,如果这个线程被大计算拖累,Node.js 就无法处理事件,事件吞吐量将受到影响。
斐波那契数列,作为重型计算任务的替代品,计算起来很快就会变得非常昂贵,尤其是对于像这样的天真实现:
const fibonacci = exports.fibonacci = function(n) {
if (n === 1 || n === 2) return 1;
else return fibonacci(n-1) + fibonacci(n-2);
}
是的,有许多方法可以更快地计算斐波那契数。我们展示这个例子是为了说明当事件处理器变慢时 Node.js 会发生什么,而不是为了讨论计算数学函数的最佳方法。考虑以下服务器:
const http = require('http');
const url = require('url');
const fibonacci = // as above
http.createServer(function (req, res) {
const urlP = url.parse(req.url, true);
let fibo;
res.writeHead(200, {'Content-Type': 'text/plain'});
if (urlP.query['n']) {
fibo = fibonacci(urlP.query['n']);
res.end('Fibonacci '+ urlP.query['n'] +'='+ fibo);
} else {
res.end('USAGE: http://127.0.0.1:8124?n=## where ## is the Fibonacci number desired');
}
}).listen(8124, '127.0.0.1');
console.log('Server running at http://127.0.0.1:8124');
对于足够大的n值(例如,40),服务器会完全无响应,因为事件循环没有运行,而是这个函数因为正在缓慢地进行计算而阻塞了事件处理。
这是否意味着 Node.js 是一个有缺陷的平台?不,这只意味着程序员必须小心地识别出具有长时间运行的代码,并开发解决方案。这些包括将算法重写为与事件循环一起工作,或者为了效率重写算法,或者集成本地代码库,或者将计算密集型计算推到后端服务器上。
简单的重写通过事件循环调度计算,让服务器能够继续在事件循环上处理请求。使用回调和闭包(匿名函数),我们能够保持异步 I/O 和并发承诺:
const fibonacciAsync = function(n, done) {
if (n === 0) return 0;
else if (n === 1 || n === 2) done(1);
else if (n === 3) return 2;
else {
process.nextTick(function() {
fibonacciAsync(n-1, function(val1) {
process.nextTick(function() {
fibonacciAsync(n-2, function(val2) {
done(val1+val2); });
});
});
});
}
}
因为这是一个异步函数,所以需要对服务器进行一些小的重构:
const http = require('http');
const url = require('url');
const fibonacciAsync = // as above
http.createServer(function (req, res) {
let urlP = url.parse(req.url, true);
res.writeHead(200, {'Content-Type': 'text/plain'});
if (urlP.query['n']) {
fibonacciAsync(urlP.query['n'], fibo => {
res.end('Fibonacci '+ urlP.query['n'] +'='+ fibo);
});
} else {
res.end('USAGE: http://127.0.0.1:8124?n=## where ## is the Fibonacci number desired');
}
}).listen(8124, '127.0.0.1'); console.log('Server running at http://127.0.0.1:8124');
Dziuba 在博客文章中并没有很好地表达他的有效观点,而且在文章之后的评论中也有所遗失。具体来说,尽管 Node.js 是一个优秀的 I/O 密集型应用程序平台,但它并不是计算密集型应用程序的好平台。
在本书的后面部分,我们将更深入地探讨这个例子。
服务器利用率、商业底线和绿色网络托管
追求最优效率(每秒处理更多请求)并不仅仅是为了从优化中获得的技术满足感。这还带来了真正的商业和环境效益。每秒处理更多请求,正如 Node.js 服务器所能做到的那样,意味着在购买大量服务器和仅购买少量服务器之间的区别。Node.js 有可能让您的组织用更少的资源做更多的事情。
大体上,你购买的服务器越多,成本就越高,这些服务器的环境影响也越大。有一个专门的领域是关于降低运行 Web 服务器设施的成本和环境影响的,而这个粗略的指导原则并没有做到这一点。目标相当明显——更少的服务器,更低的成本,通过利用更高效的软件来减少环境影响。
英特尔的白皮书,通过服务器功率测量提高数据中心效率 (www.intel.com/content/dam/doc/white-paper/intel-it-data-center-efficiency-server-power-paper.pdf),提供了一个理解效率和数据中心成本的客观框架。有许多因素,例如建筑、冷却系统和计算机系统设计。高效的建筑设计、高效的冷却系统以及高效的计算机系统(数据中心效率、数据中心密度和存储密度)可以降低成本和环境影响。但如果你部署了一个低效的软件栈,迫使你购买比拥有高效软件栈时更多的服务器,那么你可能会破坏这些收益。或者,你可以通过一个高效的软件栈来放大数据中心效率的收益,这个软件栈允许你减少所需服务器的数量。
这场关于高效软件栈的讨论并不仅仅是为了环保的利他目的。这是一个绿色环保能够帮助提升企业盈利能力的案例。
拥抱 JavaScript 语言的进步
过去的几年对 JavaScript 程序员来说是非常激动人心的。负责监督 ECMAScript 标准的 TC-39 委员会添加了许多新特性,其中一些是语法糖,但有几个特性推动了我们进入 JavaScript 编程的全新时代。仅 async/await 功能就承诺了我们摆脱所谓的回调地狱的方法,或者是我们发现自己嵌套回调的情况。这是一个如此重要的特性,以至于它应该促使我们对 Node.js 和 JavaScript 生态系统中的主流回调范式进行广泛的重新思考。
回顾几页之前的内容:
query('SELECT * from db', function (err, result) {
if (err) throw err; // handle errors
// operate on result
});
这是对 Ryan Dahl 的重要洞察,也是推动 Node.js 流行起来的原因。某些操作需要很长时间才能运行,例如数据库查询,不应与快速从内存中检索数据的操作同等对待。由于 JavaScript 语言的特性,Node.js 必须以不自然的方式表达这种异步编程结构。结果不会出现在下一行代码中,而是在这个回调函数中显示。此外,错误必须以不自然的方式在回调函数内部处理。
在 Node.js 中,约定回调函数的第一个参数是一个错误指示器,后续的参数是结果。这是一个有用的约定,你会在 Node.js 的各个领域找到它。然而,它使得处理结果和错误变得复杂,因为它们都落在了不方便的位置——那个回调函数。错误和结果的自然落点应该在代码的后续行。
我们在回调函数嵌套的每一层都进一步陷入回调地狱。第七层回调嵌套比第六层回调嵌套更复杂。为什么?如果不是其他原因,那至少是因为随着回调嵌套的深度增加,错误处理的特殊考虑变得日益复杂。
var results = await query('SELECT * from db');
相反,ES2017 异步函数使我们回归到这种非常自然的编程意图的表达方式。结果和错误落在正确的位置,同时保留了使 Node.js 变得伟大的优秀事件驱动异步编程模型。我们将在本书的后面部分看到它是如何工作的。
TC-39 委员会为 JavaScript 添加了许多新特性,例如:
-
改进的类声明语法,使得对象继承和 getter/setter 函数变得非常自然。
-
一种新的模块格式,它在浏览器和 Node.js 中得到标准化。
-
新的字符串方法,例如模板字符串表示法。
-
集合和数组的新方法——例如,
map/reduce/filter操作。 -
const关键字用于定义不可改变的变量,而let关键字用于定义作用域仅限于声明它们的代码块的变量,而不是提升到函数的前面。 -
新的循环结构,以及与这些新循环一起工作的迭代协议。
-
一种新的函数类型,箭头函数,它更轻量级,意味着更少的内存和执行时间影响
-
Promise 对象代表一个承诺在未来交付的结果。单独来看,Promise 可以减轻回调地狱问题,并且它们构成了
async函数的基础部分。 -
生成器函数是表示对一组值进行异步迭代的一种有趣方式。更重要的是,它们构成了异步函数基础的一部分。
你可能会看到新的 JavaScript 被描述为 ES6 或 ES2017。那么,描述正在使用的 JavaScript 版本的首选名称是什么?
ES1 到 ES5 标记了 JavaScript 发展的各个阶段。ES5 于 2009 年发布,在现代浏览器中得到广泛实现。从 ES6 开始,TC-39 委员会决定改变命名约定,因为他们打算每年添加新的语言特性。因此,语言版本名称现在包括年份,所以 ES2015 于 2015 年发布,ES2016 于 2016 年发布,ES2017 于 2017 年发布。
部署 ES2015/2016/2017/2018 JavaScript 代码
室内那只粉红色的象意味着,由于 JavaScript 的交付方式,我们无法直接开始使用最新的 ES2017 特性。在前端 JavaScript 中,我们受限于旧浏览器仍在使用的事实。幸运的是,Internet Explorer 版本 6 几乎已经完全退役,但仍有大量旧浏览器安装在一些仍在为用户发挥有效作用的旧电脑上。旧浏览器意味着旧的 JavaScript 实现,如果我们想让我们的代码工作,我们需要它兼容旧浏览器。
使用代码重写工具,如 Babel,可以将一些新特性回滚到一些较旧的浏览器上。前端 JavaScript 程序员可以在付出更复杂的构建工具链和代码重写过程中引入的 bug 风险代价下采用(一些)新特性,有些人可能愿意这样做,而其他人则可能更愿意等待一段时间。
Node.js 世界没有这个问题。Node.js 已经迅速采用了 ES2015/2016/2017 特性,就像它们在 V8 引擎中实现的那样快。在 Node.js 8 中,我们现在可以使用异步函数作为一个原生特性,而 ES2015/2016 的大部分特性在 Node.js 版本 6 时就已经可用。新的模块格式现在在 Node.js 版本 10 中得到了支持。
换句话说,尽管前端 JavaScript 程序员可以争论他们必须等待几年才能采用 ES2015/2016/2017 特性,但 Node.js 程序员没有必要等待。我们可以简单地使用新特性,而不需要任何代码重写工具。
Node.js、微服务架构和易于测试的系统
新的能力,如云部署系统和 Docker,使得实现一种新的服务架构成为可能。Docker 使得在可重复的容器中定义服务器进程配置成为可能,这种容器易于部署到云托管系统中,适用于小型单一用途服务实例,可以将它们连接起来形成一个完整的系统。Docker 不是唯一帮助简化云部署的工具;然而,其特性非常适合现代应用程序部署的需求。
有些人已经将微服务概念普及为描述这类系统的一种方式。根据microservices.io网站,微服务由一组专注于特定领域、可独立部署的服务组成。他们将此与单体应用程序部署模式进行对比,在这种模式中,系统的每个方面都集成到一个捆绑包中(例如,Java EE 应用程序服务器的单个 WAR 文件)。微服务模型为开发者提供了急需的灵活性。
微服务的某些优势如下:
-
每个微服务都可以由一个小团队管理
-
每个团队都可以根据自己的时间表工作,只要保持服务 API 的兼容性
-
微服务可以独立部署,例如,为了更容易测试
-
更容易切换技术栈选择
Node.js 在其中扮演什么角色?其设计就像手套一样适合微服务模型:
-
Node.js 鼓励小型、紧密聚焦、单一用途的模块
-
这些模块通过优秀的 npm 包管理系统组合成一个应用程序
-
发布模块非常简单,无论是通过 NPM 仓库还是 Git URL
Node.js 和十二要素应用模型
在本书中,我们将指出十二要素应用模型的一些方面,以及如何在 Node.js 中实现这些想法。这个模型发布在 12factor.net,是一套适用于现代云计算时代的应用部署指南。十二要素应用模型并不是应用架构范例的终极解决方案。它是一套有用的想法,显然是在许多深夜调试复杂应用之后产生的,这些想法通过更容易维护和更可靠的系统为我们所有人节省了大量精力。
指南简单明了,一旦你阅读了它们,它们就会显得像纯粹的常识。作为一个最佳实践,十二要素应用模型是我们当前计算环境所要求的流动自包含云部署应用的引人入胜的策略。
摘要
你在本章中学到了很多。具体来说,你看到了 JavaScript 在网络浏览器之外也有生命,并且你了解了异步和阻塞 I/O 之间的区别。然后我们介绍了 Node.js 的属性以及它在整体网络应用平台市场中的位置,以及线程与异步软件的比较。最后,我们看到了快速事件驱动异步 I/O 的优势,以及与支持匿名闭包的语言相结合的优势。
本书关注的是开发和部署 Node.js 应用程序的实际考虑因素。我们将尽可能涵盖开发、精炼、测试和部署 Node.js 应用程序的各个方面。
现在我们已经对 Node.js 有了一个介绍,我们准备深入学习和使用它。在第二章 设置 Node.js 中,我们将介绍如何设置 Node.js 环境,让我们开始吧。
第二章:设置 Node.js
在开始使用 Node.js 之前,你必须设置你的开发环境。在接下来的章节中,我们将使用它进行开发和非生产部署。
在本章中,我们将涵盖以下主题:
-
如何在 Linux、macOS 或 Windows 上从源代码和预包装的二进制文件安装 Node.js
-
如何安装Node 包管理器(NPM)和一些流行工具
-
Node.js 模块系统
-
Node.js 和 JavaScript 语言从 ECMAScript 委员会的改进
所以,让我们开始吧。
系统要求
Node.js 运行在 POSIX-like 操作系统上,包括各种 UNIX 衍生版本(例如 Solaris)或类似系统(Linux、macOS 等),以及 Microsoft Windows。它可以在大小不同的机器上运行,包括微小的 ARM 设备,如用于 DIY 软件/硬件项目的 Raspberry Pi 微嵌入式计算机。
Node.js 现在可以通过包管理系统获得,这减少了从源代码编译和安装的需求。
由于许多 Node.js 包是用 C 或 C++编写的,你必须有一个 C 编译器(如 GCC),Python 2.7(或更高版本)和node-gyp包。如果你计划在网络代码中使用加密,你还需要 OpenSSL 加密库。现代 UNIX 衍生版本几乎都包含这些,Node.js 的配置脚本,在从源代码安装时使用,将检测它们的存在。如果你需要安装它们,Python 可以在python.org获取,OpenSSL 可以在openssl.org获取。
使用包管理器安装 Node.js
现在安装 Node.js 的首选方法是使用包管理器中可用的版本,例如apt-get或 MacPorts。包管理器通过帮助维护计算机上软件的当前版本,确保按需更新依赖包,通过输入简单的命令(如apt-get update)来简化你的生活。让我们首先来了解一下这一点。
使用 MacPorts 在 macOS 上安装
MacPorts 项目(www.macports.org/)多年来一直在为 macOS 打包大量开源软件包,他们也打包了 Node.js。在你使用他们网站上的安装程序安装 MacPorts 之后,安装 Node.js 基本上是这样的简单:
$ port search nodejs npm
...
nodejs6 @6.12.0 (devel, net)
Evented I/O for V8 JavaScript
nodejs7 @7.10.1 (devel, net)
Evented I/O for V8 JavaScript
nodejs8 @8.9.1 (devel, net)
Evented I/O for V8 JavaScript
nodejs9 @9.2.0 (devel, net)
Evented I/O for V8 JavaScript
Found 6 ports.
--
npm4 @4.6.1 (devel)
node package manager
npm5 @5.5.1 (devel)
node package manager
Found 4 ports.
$ sudo port install nodejs8 npm5
.. long log of downloading and installing prerequisites and Node
$ which node
/opt/local/bin/node
$ node --version
v8.9.1
使用 Homebrew 在 macOS 上安装
Homebrew 是另一个适用于 macOS 的开源软件包管理器,有人说它是 MacPorts 的完美替代品。它可以通过他们的主页brew.sh/获取。按照他们网站上的说明安装 Homebrew,并确保 Homebrew 正确设置后,使用以下命令:
$ brew update
... long wait and lots of output
$ brew search node
==> Searching local taps...
node  libbitcoin-node node-build node@6 nodeenv
leafnode llnode node@4 nodebrew nodenv
==> Searching taps on GitHub...
caskroom/cask/node-profiler
==> Searching blacklisted, migrated and deleted formulae...
然后,按照以下方式安装:
$ brew install node
...
==> Installing node
==> Downloading https://homebrew.bintray.com/bottles/node-8.9.1.el_capitan.bottle.tar.gz
######################################################################## 100.0%
==> Pouring node-8.9.1.el_capitan.bottle.tar.gz
==> Caveats
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
==> Summary
/usr/local/Cellar/node/8.9.1: 5,012 files, 49.6MB
一旦以这种方式安装,就可以按照以下方式运行 Node.js 命令:
$ node --version
v8.9.1
从包管理系统在 Linux、*BSD 或 Windows 上安装
Node.js 现在通过大多数包管理系统提供。Node.js 网站上的说明目前列出了适用于大量 Linux、FreeBSD、OpenBSD、NetBSD、macOS 甚至 Windows 的 Node.js 打包版本。有关更多信息,请访问 nodejs.org/en/download/package-manager/。
例如,在 Debian 和其他基于 Debian 的 Linux 发行版(如 Ubuntu)上,使用以下命令:
# curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
# sudo apt-get install -y nodejs
# sudo apt-get install -y build-essential
要下载其他 Node.js 版本(此示例显示版本 10.x),修改 URL 以适应。
在 Windows Subsystem for Linux (WSL) 中安装 Node.js
Windows Subsystem for Linux (WSL) 允许您在 Windows 上安装 Ubuntu、openSUSE 或 SUSE Linux Enterprise。这三个都可通过 Windows 10 内置的商店获得。您可能需要更新 Windows 以使安装生效。
一旦安装,Linux 特定的说明将在 Linux 子系统中安装 Node.js。
要安装 WSL,请参阅 msdn.microsoft.com/en-us/commandline/wsl/install-win10。
在 Windows 上打开具有管理员权限的 PowerShell
在 Windows 上安装工具时,您将运行的某些命令需要在具有提升权限的 PowerShell 窗口中执行。我们提到这一点是因为启用 WSL 的过程包括在这样一个 PowerShell 窗口中运行的命令。
该过程很简单:
-
在开始菜单中,在应用程序搜索框中输入 PowerShell。
-
结果菜单将列出 PowerShell。
-
右键单击 PowerShell 条目。
-
弹出的上下文菜单将有一个“以管理员身份运行”的条目。点击它。
结果命令窗口将具有管理员权限,标题栏将显示为管理员:Windows PowerShell。
从 nodejs.org 安装 Node.js 发行版
nodejs.org/en/ 网站为 Windows、macOS、Linux 和 Solaris 提供了内置的二进制文件。我们只需访问网站,点击安装按钮,然后运行安装程序。对于具有包管理器的系统,例如我们刚才讨论的系统,最好使用包管理系统。这是因为你会发现保持最新版本更容易。但是,这并不适用于所有人,因为:
-
有些人可能更愿意安装二进制文件而不是处理包管理器
-
他们的系统没有包管理器
-
他们的包管理系统中的 Node.js 实现已过时
简单地访问 Node.js 网站,您会看到如下截图。页面会尽力确定您的操作系统并提供相应的下载。如果您需要其他内容,请点击页眉中的“下载”链接以获取所有可能的下载:

对于 macOS,安装程序是一个 PKG 文件,提供了典型的安装过程。对于 Windows,安装程序只是带你通过典型的安装向导过程。
安装程序完成后,你将拥有命令行工具,如 node 和 npm,你可以使用它们运行 Node.js 程序。在 Windows 上,你将获得一个预先配置好的 Windows 命令行版本,以便与 Node.js 一起使用。
在 POSIX 类似系统上从源代码安装
安装预包装的 Node.js 发行版是首选的安装方法。然而,在几种情况下,从源代码安装 Node.js 是可取的:
-
它可以让你根据需要优化编译器设置
-
它可以让你交叉编译,例如,用于嵌入式 ARM 系统
-
你可能需要保留多个 Node.js 构建,以便进行测试
-
你可能正在处理 Node.js 本身
现在你已经对整体有了了解,让我们动手处理一些构建脚本。一般过程遵循你可能已经对其他开源软件包执行过的常规 configure、make 和 make install 流程。如果没有,不要担心,我们会引导你完成这个过程。
官方安装说明包含在源代码分布中的 README.md 文件中,地址为 github.com/nodejs/node/blob/master/README.md。
安装先决条件
有三个先决条件:C 编译器、Python 和 OpenSSL 库。Node.js 编译过程会检查它们的存在,如果 C 编译器或 Python 不存在,则会失败。安装这些软件的具体方法取决于你的操作系统。
这类命令将检查它们的存在:
$ cc --version
Apple LLVM version 7.0.2 (clang-700.1.81)
Target: x86_64-apple-darwin15.3.0
Thread model: posix
$ python
Python 2.7.11 (default, Jan 8 2016, 22:23:13)
[GCC 4.2.1 Compatible Apple LLVM 7.0.2 (clang-700.1.81)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
详细信息请参阅:github.com/nodejs/node/blob/master/BUILDING.md。
Node.js 构建工具不支持 Python 3.x。
在 macOS 上安装开发者工具
开发者工具(如 GCC)在 macOS 上是可选安装。幸运的是,它们很容易获取。
你从 Xcode 开始,它可以通过 Mac App Store 免费获取。只需搜索 Xcode 并点击获取按钮。安装 Xcode 后,打开一个终端窗口并输入以下命令:
$ xcode-select --install
这将安装 Xcode 命令行工具:

想要了解更多信息,请访问 osxdaily.com/2014/02/12/install-command-line-tools-mac-os-x/。
在所有 POSIX 类似系统上从源代码安装
从源代码编译 Node.js 的过程如下:
-
从以下地址下载源代码:
-
nodejs.org/download.使用./configure配置源代码以进行构建。 -
运行
make,然后make install。
源代码包可以通过浏览器下载,或者如下所示,替换为你喜欢的版本:
$ mkdir src
$ cd src
$ wget https://nodejs.org/dist/v10.0.0/node-v10.0.0.tar.gz
$ tar xvfz node-v10.0.0.tar.gz
$ cd node-v10.0.0
现在我们配置源,以便可以构建。这就像许多其他开源软件包一样,有一长串选项可以自定义构建:
$ ./configure --help
要使安装位于你的主目录中,请按以下方式运行:
$ ./configure --prefix=$HOME/node/10.0.0
..output from configure
如果你打算并行安装多个 Node.js 版本,将版本号放入路径中很有用,如下所示。这样,每个版本都将位于单独的目录中。通过适当地更改 PATH 变量,可以简单地切换 Node.js 版本:
# On bash shell:
$ export PATH=${HOME}/node/VERSION-NUMBER/bin:${PATH}
# On csh
$ setenv PATH ${HOME}/node/VERSION-NUMBER/bin:${PATH}
安装多个 Node.js 版本的更简单方法是后面描述的 nvm 脚本。
如果你想在系统目录中安装 Node.js,只需省略 --prefix 选项,它将默认安装到 /usr/local。
一段时间后,它将停止,并且很可能已成功配置源树以在所选目录中安装。如果这没有成功,打印的错误信息将描述需要解决的问题。一旦配置脚本满意,你可以继续下一步。
配置脚本满意后,编译软件:
$ make
.. a long log of compiler output is printed
$ make install
如果你打算在系统目录中安装,请按以下方式执行最后一步:
$ make
$ sudo make install
安装完成后,你应该确保将安装目录添加到你的 PATH 变量中,如下所示:
$ echo 'export PATH=$HOME/node/10.0.0/bin:${PATH}' >>~/.bashrc
$ . ~/.bashrc
对于 csh 用户,使用以下语法来创建一个导出的环境变量:
$ echo 'setenv PATH $HOME/node/10.0.0/bin:${PATH}' >>~/.cshrc
$ source ~/.cshrc
这应该会生成以下目录:
$ ls ~/node/10.0.0/
bin include lib share
$ ls ~/node/10.0.0/bin
在 Windows 上从源安装
之前引用的 BUILDING.md 文档中有说明。一种方法是使用 Visual Studio 的构建工具,或者使用完整的 Visual Studio 2017 产品:
-
Visual Studio 2017:
www.visualstudio.com/downloads/ -
构建工具:
www.visualstudio.com/downloads/#build-tools-for-visual-studio-2017
需要三个额外的工具:
-
Git for Windows:
git-scm.com/download/win -
Python:
www.python.org/ -
OpenSSL:
www.openssl.org/source/和wiki.openssl.org/index.php/Binaries
然后,运行包含的 .\vcbuild 脚本来执行构建。
使用 nvm 安装多个 Node.js 实例
通常,你不会安装多个 Node.js 版本,这样做会增加系统的复杂性。但是,如果你正在修改 Node.js 本身,或者正在针对不同的 Node.js 发布版测试你的软件,你可能希望拥有多个 Node.js 安装。这样做的方法是我们已经讨论过的简单变体。
在之前讨论从源代码构建 Node.js 时,我们提到可以在不同的目录中安装多个 Node.js 实例。只有当你需要自定义 Node.js 构建,大多数人会满足于预构建的 Node.js 二进制文件。它们也可以安装到不同的目录中。
要在 Node.js 版本之间切换,只需更改 PATH 变量(在 POSIX 系统上),如下所示,使用你安装 Node.js 的目录:
$ export PATH=/usr/local/node/VERSION-NUMBER/bin:${PATH}
过了一段时间后,维护这些内容开始变得有点繁琐。对于每个发布版,你都必须在你的 Node.js 安装中设置 Node.js、NPM 以及你想要的任何第三方模块。此外,显示用于更改 PATH 的命令并不十分理想。富有创造力的程序员们已经创建了几个版本管理器,以简化管理多个 Node.js/NPM 发布版,并提供智能方式更改 PATH 的命令:
-
Node 版本管理器:
github.com/tj/n -
Node 版本管理器:
github.com/creationix/nvm
它们都维护多个同时运行的 Node 版本,并允许你轻松地在版本之间切换。安装说明可在它们各自的网站上找到。
例如,使用 nvm,你可以运行以下命令:
$ nvm ls
...
v6.0.0
v6.1.0
v6.2.2
v6.3.1
v6.4.0
...
v6.11.2
v7.0.0
v7.1.0
v7.10.0
v8.0.0
v8.1.3
v8.2.1
v8.5.0
v8.9.1
v8.9.3
v9.2.0
v9.4.0
v9.5.0
v9.10.1
v9.11.1
-> v10.0.0
-> system
node -> stable (-> v8.9.1) (default)
stable -> 8.9 (-> v8.9.1) (default)
iojs -> N/A (default)
$ nvm use 10
Now using node v10.0.0 (npm v5.6.0)
$ node --version
v10.0.0
$ nvm use v4.2
Now using node v4.2.0 (npm v2.14.7)
$ node --version
v4.2.0
$ nvm install 9
Downloading https://nodejs.org/dist/v9.2.0/node-v9.2.0-darwin-x64.tar.xz...
######################################################################## 100.0%
WARNING: checksums are currently disabled for node.js v4.0 and later
Now using node v9.2.0 (npm v5.5.1)
$ node --version
v9.2.0
$ which node
/Users/david/.nvm/versions/node/v9.2.0/bin/node
$ /usr/local/bin/node --version
v8.9.1
$ /opt/local/bin/node --version
v8.9.1
这表明你可以安装一个系统范围内的 Node.js,同时使用 nvm 管理多个私有 Node.js 版本,并在需要时切换它们。当新的 Node.js 版本发布时,使用 nvm 安装它们非常简单,即使你的操作系统官方打包版本没有立即更新。
在 Windows 上安装 nvm
不幸的是,nvm 不支持 Windows。幸运的是,存在几个针对 Windows 的 nvm 概念的特定克隆:
另一种方法是使用 WSL。因为在 WSL 中,你是在与 Linux 命令行交互,所以你可以使用 nvm 本身。
本书中的许多示例都是使用 nvm-windows 应用程序测试的。它们的行为略有不同,但与 Linux 和 macOS 上的 nvm 大致相同。最大的变化是 nvm use 和 nvm install 命令中的版本号指定符。
在 Linux 和 macOS 上使用 nvm,你可以输入一个简单的版本号,例如 nvm use 8,它将自动替换指定 Node.js 版本的最新发布版。使用 nvm-windows,相同的命令会像你输入了 "nvm use 8.0.0" 一样执行。换句话说,使用 nvm-windows,你必须使用确切的版本号。幸运的是,使用 "nvm list available" 命令可以轻松地获取支持的版本列表。
原生代码模块和 node-gyp
虽然我们在这本书中不会讨论原生代码模块的开发,但我们确实需要确保它们可以被构建。NPM 仓库中的一些模块是原生代码,它们必须使用 C 或 C++编译器编译以生成相应的.node文件(.node扩展名用于二进制原生代码模块)。
模块通常会将自己描述为其他库的包装器。例如,libxslt和libxmljs模块是同名 C/C++库的包装器。模块包含 C/C++源代码,并且在安装时,会自动运行一个脚本来使用node-gyp进行编译。
node-gyp工具是一个用 Node.js 编写的跨平台命令行工具,用于编译 Node.js 的原生插件模块。我们已经多次提到原生代码模块,这正是用于将它们编译用于 Node.js 的工具。
您可以通过运行以下命令轻松看到这一过程:
$ mkdir temp
$ cd temp
$ npm install libxmljs libxslt
这是在一个临时目录中完成的,因此您可以在之后删除它。如果您的系统没有安装用于编译原生代码模块的工具,您将看到错误信息。否则,您将在输出中看到一个node-gyp执行过程,后面跟着许多与编译 C/C++文件明显相关的文本行。
node-gyp工具的先决条件类似于从源代码编译 Node.js 的先决条件。具体来说,需要一个 C/C++编译器、一个 Python 环境以及其他构建工具,如 Git。对于 Unix/macOS/Linux 系统,这些工具很容易获得。对于 Windows 系统,您应该安装:
-
Visual Studio 构建工具:
www.visualstudio.com/downloads/#build-tools-for-visual-studio-2017 -
Windows 版的 Git:
git-scm.com/download/win -
Windows 版的 Python:
www.python.org/
通常,您不需要担心安装node-gyp。这是因为它作为 NPM 的一部分在幕后安装。这样做是为了让 NPM 能够自动构建原生代码模块。
它的 GitHub 仓库包含在github.com/nodejs/node-gyp上的文档。
阅读其仓库中的node-gyp文档,将使您对之前讨论的编译先决条件以及原生代码模块的开发有更清晰的理解。
Node.js 版本策略和应使用哪个版本
在上一节中,我们提到了许多不同的 Node.js 版本号,您可能对应该使用哪个版本感到困惑。本书的目标是 Node.js 版本 10.x,并且预计我们将涵盖的所有内容都与 Node.js 10.x 及其后续版本兼容。
从 Node.js 4.x 开始,Node.js 团队采用了一种双轨方法。偶数版本的发布(4.x、6.x、8.x 等等)被称为长期支持(LTS),而奇数版本的发布(5.x、7.x、9.x 等等)是当前新功能开发的地方。虽然开发分支保持稳定,但 LTS 发布版定位为生产使用,并将接收多年的更新。
在撰写本文时,Node.js 8.x 是当前的长期支持(LTS)版本;Node.js 9.x 刚刚发布,最终将成为 Node.js 10.x,而 Node.js 10.x 最终将成为 LTS 版本。关于发布计划的完整详情,请参阅 github.com/nodejs/LTS/。
每次新的 Node.js 发布都会带来重大影响,除了通常的性能改进和错误修复之外,还会引入最新的 V8 JavaScript 引擎版本。反过来,这也意味着随着 V8 团队实现这些功能,将引入更多的 ES-2015/2016/2017 特性。在 Node.js 8.x 中,async/await 函数出现,而在 Node.js 10.x 中,对标准 ES6 模块格式的支持也出现了。
一个实际的考虑因素是新的 Node.js 发布是否会破坏您的代码。随着 V8 追上 ECMAScript,新的语言特性始终在添加,Node.js 团队有时会在 Node.js API 中进行破坏性更改。如果您在一个 Node.js 版本上进行了测试,它会在更早的版本上工作吗?Node.js 的更改是否会破坏我们做出的某些假设?
NPM 包管理器帮助我们确保我们的包能够在正确的 Node.js 版本上执行。这意味着我们可以在第三章中将要探讨的 package.json 文件中指定一个包的兼容 Node.js 版本。
我们可以在 package.json 中添加如下条目:
engines: {
"node": ">=6.x"
}
这意味着它确实意味着所提供的包与 Node.js 6.x 或更高版本兼容。
当然,您的开发机器可能安装了多个 Node.js 版本。您需要您的软件声明的支持版本,以及您希望评估的任何后续版本。
编辑器和调试器
由于 Node.js 代码是 JavaScript,任何具有 JavaScript 意识的编辑器都将是有用的。与一些其他语言如此复杂,以至于需要一个具有代码补全功能的 IDE 一样,一个简单的编程编辑器对于 Node.js 开发来说就足够了。
有两个编辑器值得特别提及,因为它们是用 Node.js 编写的:Atom 和 Microsoft Visual Studio Code。
Atom (atom.io/) 自称为 21 世纪的 hackable 编辑器。它可以通过编写使用 Atom API 的 Node.js 模块进行扩展,配置文件也易于编辑。换句话说,它以与其他许多编辑器相同的方式 hackable,追溯到 Emacs,这意味着编写一个软件模块来添加编辑器的功能。Electron 框架是为了构建 Atom 而发明的,Electron 是使用 Node.js 构建桌面应用程序的一个超级简单的方法。
微软 Visual Studio Code (code.visualstudio.com/) 也是一个可修改的编辑器——嗯,主页上说是可扩展和可定制的,这意味着相同的意思——它也是开源的,并且是用 Electron 实现的。但它不是一个空洞的模仿编辑器,模仿 Atom 而不添加任何自己的东西。相反,Visual Studio Code 是一个真正的程序员编辑器,本身就有很多有趣的功能。
关于调试器,有几个有趣的选择。从 Node.js 6.3 开始,inspector 协议使得可以使用 Google Chrome 调试器。Visual Studio Code 内置了一个也使用 inspector 协议的调试器。
要查看调试选项和工具的完整列表,请参阅 nodejs.org/en/docs/guides/debugging-getting-started/。
运行和测试命令
现在你已经安装了 Node.js,我们想做两件事——验证安装是否成功,并让你熟悉命令行工具。
Node.js 的命令行工具
Node.js 的基本安装包括两个命令,node 和 npm。我们已经看到了 node 命令的使用。它用于运行命令行脚本或服务器进程。另一个,npm,是 Node.js 的包管理器。
验证你的 Node.js 安装是否正常工作的最简单方法也是获取 Node.js 帮助的最佳方式。输入以下命令:
$ node --help
Usage: node [options] [ -e script | script.js | - ] [arguments]
node inspect script.js [arguments]
Options:
-v, --version print Node.js version
-e, --eval script evaluate script
-p, --print evaluate script and print result
-c, --check syntax check script without executing
-i, --interactive always enter the REPL even if stdin
does not appear to be a terminal
-r, --require module to preload (option can be repeated)
- script read from stdin (default; interactive mode if a tty)
--inspect[=[host:]port] activate inspector on host:port
(default: 127.0.0.1:9229)
--inspect-brk[=[host:]port]
activate inspector on host:port
and break at start of user script
--inspect-port=[host:]port
set host:port for inspector
... many more options
Environment variables:
NODE_DEBUG ','-separated list of core modules
that should print debug information
NODE_DISABLE_COLORS set to 1 to disable colors in the REPL
NODE_EXTRA_CA_CERTS path to additional CA certificates
file
NODE_ICU_DATA data path for ICU (Intl object) data
(will extend linked-in data)
NODE_NO_WARNINGS set to 1 to silence process warnings
NODE_NO_HTTP2 set to 1 to suppress the http2 module
NODE_OPTIONS set CLI options in the environment
via a space-separated list
NODE_PATH ':'-separated list of directories
prefixed to the module search path
NODE_PENDING_DEPRECATION set to 1 to emit pending deprecation
warnings
NODE_REPL_HISTORY path to the persistent REPL history
file
NODE_REDIRECT_WARNINGS write warnings to path instead of
stderr
OPENSSL_CONF load OpenSSL configuration from file
Documentation can be found at https://nodejs.org/
注意,Node.js 和 V8(在之前的命令行中未显示)都有选项。记住,Node.js 是建立在 V8 之上的;它拥有自己的选项宇宙,主要关注字节码编译或垃圾回收和堆算法的细节。输入 node --v8-options 来查看它们的完整列表。
在命令行中,你可以指定选项、单个脚本文件以及该脚本的参数列表。我们将在下一节 使用 Node.js 运行简单脚本 中进一步讨论脚本参数。
不带参数运行 Node.js 会让你进入一个交互式 JavaScript 命令行界面:
$ node
> console.log('Hello, world!');
Hello, world!
undefined
你可以在 Node.js 脚本中编写的任何代码都可以在这里编写。命令解释器提供了一个良好的面向终端的用户体验,并且对于交互式地玩弄你的代码很有用。你确实在玩你的代码,不是吗?很好!
使用 Node.js 运行简单脚本
现在,让我们看看如何使用 Node.js 运行脚本。这很简单;让我们先参考之前显示的帮助信息。命令行模式只是一个脚本文件名和一些脚本参数,这对于任何在其他语言中编写过脚本的人来说应该很熟悉。
使用任何处理纯文本文件的文本编辑器都可以创建和编辑 Node.js 脚本,例如 VI/VIM、Emacs、Notepad++、Atom、Visual Studio Code、Jedit、BB Edit、TextMate 或 Komodo。如果它是一个面向程序员的编辑器,那么语法高亮就很有帮助。
对于本书中的这个和其他示例,文件的位置实际上并不重要。然而,为了整洁起见,你可以在计算机的 home 目录中创建一个名为 node-web-dev 的目录,并在其中为每一章创建一个目录(例如,chap02 和 chap03)。
首先,创建一个名为 ls.js 的文本文件,内容如下:
const fs = require('fs');
const util = require('util');
const fs_readdir = util.promisify(fs.readdir);
(async () => {
const files = await fs_readdir('.');
for (let fn of files) {
console.log(fn);
}
})().catch(err => { console.error(err); });
接下来,通过输入以下命令来运行它:
$ node ls.js
ls.js
这是对 Unix ls 命令的一个肤浅的廉价模仿(好像你从名字中看不出来一样)。readdir 函数是 Unix readdir 系统调用的近似(在终端窗口中输入 man 3 readdir 以了解更多信息)并用于列出目录中的文件。
我们使用内联 async 函数、await 关键字和 ES2015 的 for..of 循环来编写这个。使用 util.promisify,我们可以将任何回调函数转换为返回 Promise 的函数,这样 Promise 就可以很好地与 await 关键字配合使用。
默认情况下,fs 模块函数使用回调模式,这与大多数 Node.js 模块相同。但在 async 函数中,如果函数返回 promises 则更为方便。使用 util.promisify 我们可以实现这一点。
此脚本硬编码为列出当前目录中的文件。真正的 ls 命令需要一个目录名,所以让我们稍微修改一下脚本。
命令行参数存储在名为 process.argv 的全局数组中。因此,我们可以修改 ls.js,将其复制为 ls2.js,如下所示,以查看这个数组的工作方式:
const fs = require('fs');
const util = require('util');
const fs_readdir = util.promisify(fs.readdir);
(async () => {
var dir = '.';
if (process.argv[2]) dir = process.argv[2];
const files = await fs_readdir(dir);
for (let fn of files) {
console.log(fn);
}
})().catch(err => { console.error(err); });
你可以按照以下方式运行它:
$ pwd
/Users/David/chap02
$ node ls2 ..
chap01
chap02
$ node ls2
app.js
ls.js
ls2.js
我们只是检查了命令行参数是否存在,if (process.argv[2])。如果存在,我们覆盖了 dir 变量的值,dir = process.argv[2],然后我们使用它作为 readdir 参数。
如果你给它一个不存在的目录路径名,将会抛出一个错误,并使用 catch 子句打印出来。看起来是这样的:
$ node ls2.js /nonexistent
{ Error: ENOENT: no such file or directory, scandir '/nonexistent'
errno: -2,
code: 'ENOENT',
syscall: 'scandir',
path: '/nonexistent' }
转换为异步函数和 Promise 模式
在上一节中,我们讨论了 util.promisify 以及其将回调函数转换为返回 Promise 的能力。后者在异步函数中表现良好,因此函数返回 Promise 是更可取的。
更精确地说,util.promisify需要提供一个使用错误优先回调范式的函数。这些函数的最后一个参数是一个回调函数,其第一个参数被解释为错误指示器,因此得名错误优先回调。util.promisify返回的函数将返回一个 Promise。
Promise 与错误优先回调具有相同的目的。如果指示有错误,Promise 将解析为拒绝状态,而如果指示成功,Promise 将解析为成功状态。正如我们在这些示例中看到的那样,在async函数中,Promise 被处理得非常好。
Node.js 生态系统拥有大量使用错误优先回调的错误处理函数。社区已经开始了一个转换过程,其中函数将返回一个 Promise,并且可能也会为了 API 兼容性而采用错误优先回调。
Node.js 10 中的新特性之一就是这样的转换示例。在fs模块中有一个子模块,名为fs.promises,它具有相同的 API 但生成 Promise 对象。我们可以将前面的示例重写如下:
const fs = require('fs').promises;
(async () => {
var dir = '.';
if (process.argv[2]) dir = process.argv[2];
const files = await fs.readdir(dir);
for (let fn of files) {
console.log(fn);
}
})().catch(err => { console.error(err); });
正如你所看到的,fs.promises模块中的函数在不需要回调函数的情况下返回一个 Promise。你可以将新程序保存为ls2-promises.js,然后按照以下方式运行:
$ node ls2-promises.js
(node:40329) ExperimentalWarning: The fs.promises API is experimental
app.js ls.js
ls2-promises.js
ls2.js
API 目前处于实验状态,因此我们看到了这个警告。
另一个选择是第三方模块fs-extra。此模块在标准fs模块之外具有扩展的 API。一方面,如果未提供回调函数,则其函数返回一个 Promise,否则调用回调。此外,它还包括几个有用的函数。
在本书的其余部分,我们将使用fs-extra,因为这些额外的函数。有关模块的文档,请参阅:www.npmjs.com/package/fs-extra。
使用 Node.js 启动服务器
你将要运行的许多脚本都是服务器进程。我们将在稍后运行很多这样的脚本。由于我们仍然处于验证安装和让你熟悉使用 Node.js 的双重模式,我们想要运行一个简单的 HTTP 服务器。让我们借用 Node.js 主页上的简单服务器脚本(nodejs.org)。
创建一个名为app.js的文件,包含以下内容:
const http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!\n');
}).listen(8124, '127.0.0.1');
console.log('Server running at http://127.0.0.1:8124');
按照以下方式运行:
$ node app.js
Server running at http://127.0.0.1:8124
这是你可以用 Node.js 构建的最简单的 Web 服务器。如果你对它是如何工作的感兴趣,请翻到第四章[2e4fd521-22f2-4df0-810c-54c972ed8e6e.xhtml],HTTP 服务器和客户端;第五章[e4322e55-673b-45c5-b64e-fc107d57ef03.xhtml],你的第一个 Express 应用程序;以及第六章,实现移动优先范式。目前,只需在你的浏览器中访问http://127.0.0.1:8124,即可看到 Hello, World!消息:

一个值得思考的问题是为什么当 ls.js 退出时,这个脚本没有退出。在这两种情况下,脚本的执行都达到了脚本的末尾;在 app.js 中,Node.js 进程没有退出,而在 ls.js 中则退出了。
原因在于存在活跃的事件监听器。Node.js 总是启动一个事件循环,在 app.js 中,listen 函数创建了一个实现 HTTP 协议的事件 listener。这个事件监听器使 app.js 保持运行,直到你做某些事情,例如在终端窗口中输入 Ctrl + C。在 ls.js 中,没有创建长时间运行的事件监听器的代码,因此当 ls.js 到达其脚本的末尾时,node 进程将退出。
NPM - Node.js 包管理器
Node.js 本身是一个相当基础的系统,它是一个带有一些有趣的异步 I/O 库的 JavaScript 解释器。使 Node.js 有趣的事情之一是 Node.js 第三方模块生态系统的快速增长。
在这个生态系统中心的是 NPM。虽然 Node.js 模块可以作为源代码下载并手动组装以供 Node.js 程序使用,但这很繁琐,并且很难实现可重复的构建过程。NPM 给我们提供了一个更简单的方法;NPM 是 Node.js 的既定标准包管理器,它极大地简化了下载和使用这些模块的过程。我们将在下一章详细讨论 NPM。
留意的人会注意到 npm 已经通过之前讨论的所有安装方法安装了。过去,npm 是单独安装的,但今天它已经与 Node.js 打包在一起。
现在我们已经安装了 npm,让我们快速试用一下。hexy 程序是一个用于打印文件十六进制转储的实用工具。这是一件非常 1970 年代的事情,但仍然非常有用。它现在为我们提供了快速安装和尝试的东西:
$ npm install -g hexy
/opt/local/bin/hexy -> /opt/local/lib/node_modules/hexy/bin/hexy_cmd.js
+ hexy@0.2.10
added 1 package in 1.107s
添加 -g 标志可以使模块在命令行 shell 的当前工作目录之外全局可用。全局安装当模块提供命令行界面时最有用。当一个包提供命令行脚本时,npm 会设置它。对于全局安装,命令被正确安装,以便所有计算机用户使用。
根据你的 Node.js 安装方式,可能需要使用 sudo 运行:
$ sudo npm install -g hexy
安装完成后,你可以这样运行新安装的程序:
$ hexy --width 12 ls.js
00000000: 636f 6e73 7420 6673 203d 2072 const.fs.=.r
0000000c: 6571 7569 7265 2827 6673 2729 equire('fs')
00000018: 3b0a 636f 6e73 7420 7574 696c ;.const.util
00000024: 203d 2072 6571 7569 7265 2827 .=.require('
00000030: 7574 696c 2729 3b0a 636f 6e73 util');.cons
0000003c: 7420 6673 5f72 6561 6464 6972 t.fs_readdir
00000048: 203d 2075 7469 6c2e 7072 6f6d .=.util.prom
00000054: 6973 6966 7928 6673 2e72 6561 isify(fs.rea
00000060: 6464 6972 293b 0a0a 2861 7379 ddir);..(asy
0000006c: 6e63 2028 2920 3d3e 207b 0a20 nc.().=>.{..
00000078: 2063 6f6e 7374 2066 696c 6573 .const.files
00000084: 203d 2061 7761 6974 2066 735f .=.await.fs_
00000090: 7265 6164 6469 7228 272e 2729 readdir('.')
0000009c: 3b0a 2020 666f 7220 2866 6e20 ;...for.(fn.
000000a8: 6f66 2066 696c 6573 2920 7b0a of.files).{.
000000b4: 2020 2020 636f 6e73 6f6c 652e ....console.
000000c0: 6c6f 6728 666e 293b 0a20 207d log(fn);...}
000000cc: 0a7d 2928 292e 6361 7463 6828 .})().catch(
000000d8: 6572 7220 3d3e 207b 2063 6f6e err.=>.{.con
000000e4: 736f 6c65 2e65 7272 6f72 2865 sole.error(e
000000f0: 7272 293b 207d 293b rr);.});
再次,我们将在下一章深入探讨 NPM。hexy 实用工具既是 Node.js 库,也是用于打印这些旧式十六进制转储的脚本。
Node.js、ECMAScript 2015/2016/2017 以及更远
2015 年,ECMAScript 委员会发布了 JavaScript 语言期待已久的重大更新。这次更新为 JavaScript 带来了许多新特性,如 Promises、箭头函数和类对象。语言更新为改进奠定了基础。这应该会极大地提高我们编写清晰、可理解的 JavaScript 代码的能力。
浏览器制造商正在添加这些迫切需要的特性,这意味着 V8 引擎也在添加这些特性。这些特性从 4.x 版本开始进入 Node.js。
要了解 Node.js 中 ES-2015 的当前状态,请访问nodejs.org/en/docs/es6/。
默认情况下,Node.js 只启用了 V8 认为稳定的 ES-2015/2016/2017 特性。可以通过命令行选项启用更多特性。几乎完整的特性可以通过--es_staging选项启用。网站文档提供了更多信息。
Node green 网站(node.green/)列出了 Node.js 版本中大量特性的状态。
ES2017 语言规范发布在:
www.ecma-international.org/publications/standards/Ecma-262.htm。
TC-39 委员会在 GitHub 上完成其工作github.com/tc39。
ES-2015 特性在 JavaScript 语言中做出了重大改进。其中一个特性,Promise类,应该意味着对 Node.js 编程中常见惯用的根本性重新思考。在 ES-2017 中,一对新的关键字async和await将简化 Node.js 中的异步代码编写,并应该鼓励 Node.js 社区进一步重新思考平台上的常见惯用。
有许多新的 JavaScript 特性,但让我们快速浏览其中两个我们将广泛使用的特性。
第一个是一个更轻量级的函数语法,称为箭头函数:
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) ...; // do something with the error
else ...; // do something with the data
});
这不仅仅是用肥箭头替换function关键字这种语法糖。箭头函数不仅更轻量级,而且更容易阅读。轻量级的代价是改变箭头函数内部this的值。在常规函数中,this在函数内部有一个独特的值。在箭头函数中,this与包含箭头函数的作用域具有相同的值。这意味着,当使用箭头函数时,我们不需要跳过任何障碍来将this带入回调函数,因为this在代码的两个级别上都是相同的。
下一个特性是Promise类,它用于延迟和异步计算。将代码执行延迟以实现异步行为是 Node.js 的一个关键范式,它需要两个惯用约定:
-
异步函数的最后一个参数是一个回调函数,当需要进行异步执行时会被调用
-
回调函数的第一个参数是一个错误指示器
虽然方便,但这些约定导致了多层代码金字塔,这可能难以理解和维护:
doThis(arg1, arg2, (err, result1, result2) => {
if (err) ...;
else {
// do some work
doThat(arg2, arg3, (err2, results) => {
if (err2) ...;
else {
doSomethingElse(arg5, err => {
if (err) .. ;
else ..;
});
}
});
}
});
根据特定任务所需的步骤数量,代码金字塔可以变得相当深。Promise 将使我们解开代码金字塔并提高可靠性,因为错误处理更直接且易于捕获所有错误。
创建 Promise 类的方式如下:
function doThis(arg1, arg2) {
return new Promise((resolve, reject) => {
// execute some asynchronous code
if (errorIsDetected) return reject(errorObject);
// When the process is finished call this:
resolve(result1, result2);
});
}
而不是传递回调函数,调用者接收一个 Promise 对象。当正确使用时,前面的金字塔可以编码如下:
doThis(arg1, arg2)
.then(result => {
// This can receive only one value, hence to
// receive multiple values requires an object or array
return doThat(arg2, arg3);
})
.then((results) => {
return doSomethingElse(arg5);
})
.then(() => {
// do a final something
})
.catch(err => {
// errors land here
});
这之所以有效,是因为 Promise 类支持链式调用,如果 then 函数返回一个 Promise 对象。
async/await 特性实现了 Promise 类的承诺,以简化异步编程。此特性在 async 函数内激活:
async function mumble() {
// async magic happens here
}
async 箭头函数如下:
const mumble = async () => {
// async magic happens here
};
它的使用方式如下:
async function doSomething(arg1, arg2, arg3, arg4, arg5) {
var { result1, result2 } = await doThis(arg1, arg2);
var results = await doThat(arg2, arg3);
await doSomethingElse(arg5);
// do a final something
return finalResult;
}
与我们最初使用的嵌套结构相比,这难道不是一股清新的空气吗?
await 关键字与 Promise 一起使用。它自动等待 Promise 解决。如果 Promise 成功解决,则返回值,如果它以错误解决,则抛出该错误。处理结果和抛出错误都以自然的方式处理。
此示例还展示了另一个 ES2015 特性:解构。可以使用以下方式提取对象的字段:
var { value1, value2 } = {
value1: "Value 1", value2: "Value 2", value3: "Value3"
};
我们有一个包含三个字段的对象,但只提取其中的两个字段。
使用 Babel 来使用实验性的 JavaScript 特性
Babel 转译器([babeljs.io/](http://babeljs.io/))是使用旧实现上的前沿 JavaScript 特性的绝佳方式。转译一词意味着 Babel 将 JavaScript 代码重写为其他 JavaScript 代码,具体来说,是将 ES-2015 或 ES-2016 特性重写为旧 JavaScript 代码。Babel 将 JavaScript 源代码转换为抽象语法树,然后操作该树以使用旧 JavaScript 功能重写代码,然后将该树写入 JavaScript 源代码文件。
换句话说,Babel 将 JavaScript 代码重写为 JavaScript 代码,应用所需的转换,例如将 ES2015/2016 特性转换为可以在网页浏览器中运行的 ES5 代码。
许多人使用 Babel 来尝试 TC-39 委员会正在推进的新 JavaScript 特性提案。其他人使用 Babel 在不支持这些特性的 JavaScript 引擎的项目中使用新的 JavaScript 特性。
Node Green 网站明确指出,Node.js 几乎支持所有 ES2015/2016/2017 特性。因此,从实际的角度来看,我们不再需要为 Node.js 项目使用 Babel。
对于网页浏览器来说,一组 ECMAScript 特性从提出到我们可以在浏览器端代码中可靠地使用它们之间有一个更长的时间滞后。这并不是因为网页浏览器的制造商在采用新特性方面缓慢,因为 Google、Mozilla 和 Microsoft 团队都是积极采用最新特性的。不幸的是,苹果的 Safari 团队似乎在采用新特性方面较慢。然而,更慢的是新浏览器在现有计算机群体中的渗透率。
因此,现代 JavaScript 程序员需要熟悉 Babel。
我们目前还没有准备好展示这些功能的示例代码,但我们可以继续记录 Babel 工具的设置。有关设置文档的更多信息,请访问 http://babeljs.io/docs/setup/,然后点击 CLI 按钮。
为了简要了解 Babel,我们将使用它来转换我们之前看到的脚本,以便在 Node.js 6.x 上运行。在这些脚本中,我们使用了异步函数,这些函数在 Node.js 6.x 中不受支持。
在包含 ls.js 和 ls2.js 的目录中,输入以下命令:
$ npm install babel-cli \
babel-plugin-transform-es2015-modules-commonjs \
babel-plugin-transform-async-to-generator
这将安装 Babel 软件,以及一些转换插件。Babel 有一个插件系统,这样您就可以启用项目所需的转换。在这个例子中,我们的主要目标是把之前展示的 async 函数转换为 Generator 函数。Generators 是 ES2015 中引入的一种新类型的函数,它是 async 函数实现的基础。
由于 Node.js 6.x 没有提供 util.promisify,我们需要进行一项替换:
// const fs_readdir = util.promisify(fs.readdir);
const fs_readdir = dir => {
return new Promise((resolve, reject) => {
fs.readdir(dir, (err, fileList) => {
if (err) reject(err);
else resolve(fileList);
});
});
};
这种结构大致就是 util.promisify 函数所做的事情。
接下来,创建一个名为 .babelrc 的文件,包含以下内容:
{
"plugins": [
"transform-es2015-modules-commonjs",
"transform-async-to-generator"
]
}
此文件指示 Babel 使用我们之前安装的命名转换插件。
由于我们安装了 babel-cli,因此安装了一个 babel 命令,这样我们就可以输入以下内容:
$ ./node_modules/.bin/babel -help
要转换您的代码,请运行以下命令:
$ ./node_modules/.bin/babel ls2.js -o ls2-babel.js
此命令将转换指定的文件,生成一个新文件。新文件如下:
'use strict';
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = genkey; var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
const fs = require('fs');
const util = require('util');
// const fs_readdir = util.promisify(fs.readdir);
const fs_readdir = dir => {
return new Promise((resolve, reject) => {
fs.readdir(dir, (err, fileList) => {
if (err) reject(err);
else resolve(fileList);
});
});
};
_asyncToGenerator(function* () {
var dir = '.';
if (process.argv[2]) dir = process.argv[2];
const files = yield fs_readdir(dir);
for (let fn of files) {
console.log(fn);
}
})().catch(err => {
console.error(err);
});
这段代码并不是为了让人容易阅读。相反,它是为了编辑原始源文件,然后将其转换为您的目标 JavaScript 引擎。需要注意的是,转换后的代码使用 Generator 函数代替了 async 函数,并使用 yield 关键字代替了 await 关键字。_asyncToGenerator 函数实现了与异步函数类似的功能。
转换后的脚本如下运行:
$ node ls2-babel
.babelrc
app.js
babel
ls.js
ls2-babel.js
ls2.js
node_modules
换句话说,它运行方式与 async 版本相同,但是在较旧的 Node.js 版本上。
摘要
在本章中,您学习了关于安装 Node.js、使用其命令行工具以及运行 Node.js 服务器的大量知识。我们还快速浏览了许多将在本书后面章节中详细讲解的细节,所以请耐心等待。
具体来说,我们涵盖了下载和编译 Node.js 源代码,安装 Node.js,无论是用于个人目录中的开发还是部署在系统目录中,以及安装 NPM——与 Node.js 一起使用的既定标准包管理器。我们还看到了如何运行 Node.js 脚本或 Node.js 服务器。然后,我们查看了一下 ES-2015/2016/2017 的新特性。最后,我们看到了如何使用 Babel 在你的代码中实现这些特性。
现在我们已经了解了如何设置基本系统,我们就可以开始使用 Node.js 实现应用程序了。首先,你必须学习 Node.js 应用程序和模块的基本构建块,这些内容我们将在下一章中进行讲解。
第三章:Node.js 模块
在编写 Node.js 应用程序之前,你必须了解 Node.js 模块和包。模块和包是将你的应用程序分解成更小部分的基本构建块。
在本章中,我们将涵盖以下主题:
-
定义一个模块
-
CommonJS 和 ES2015 模块规范
-
在 Node.js 中使用 ES2015/2016/2017 编码实践
-
在 Node.js 代码中使用 ES6 模块格式
-
理解 Node.js 如何查找模块
-
npm 包管理系统
那么,让我们开始吧。
定义一个模块
模块是构建 Node.js 应用程序的基本构建块。Node.js 模块封装了函数,将细节隐藏在一个保护良好的容器中,并公开一个显式声明的函数列表。
我们必须考虑两种模块格式:
-
基于 CommonJS 标准的传统 Node.js 格式自 Node.js 创建以来一直被使用。
-
随着 ES2015/2016 的引入,一个新的格式,ES6 模块,通过新的
import关键字被定义。ES6 模块将在所有 JavaScript 实现中得到支持。
由于 ES6 模块现在是标准模块格式,Node.js 技术指导委员会(TSC)致力于为 ES6 模块提供一流的支持。
我们已经在上一章中看到了模块的实际应用。在 Node.js 中使用的每个 JavaScript 文件本身就是一个模块。现在是时候看看它们是什么以及它们是如何工作的了。我们将从 CommonJS 模块开始,然后快速引入 ES6 模块。
在第二章的 ls.js 示例中,我们编写了以下代码来引入 fs 模块,从而获得对其函数的访问权限:
const fs = require('fs');
require 函数用于搜索指定模块,将模块定义加载到 Node.js 运行时中,并使其函数可用。在这种情况下,fs 对象包含了由 fs 模块导出的代码(和数据)。fs 模块是 Node.js 核心的一部分,提供了文件系统功能。
通过将 fs 声明为 const,我们获得了一点点保证,防止在编码时犯修改包含模块引用的对象的错误。
在每个 Node.js 模块中,模块内部的 exports 对象是导出到其他代码的接口。任何分配给 exports 对象字段的值都可以供其他代码使用,而其他所有内容都是隐藏的。顺便说一句,这个对象实际上是 module.exports。exports 对象是 module.exports 的别名。
require 函数和 module.exports 对象都来自 CommonJS 规范。ES6 模块有类似的概念,但实现方式不同。
在深入细节之前,让我们先简要地看看这个例子。思考一下 simple.js 模块:
var count = 0;
exports.next = function() { return ++count; };
exports.hello = function() {
return "Hello, world!";
};
我们有一个变量 count,它没有附加到 exports 对象上,还有一个函数 next,它被附加上了。现在,让我们使用它:
$ node
> const s = require('./simple');
undefined
> s.hello();
'Hello, world!'
> s.next();
1
> s.next();
2
> s.next();
3
> console.log(s.count);
undefined
undefined
>
模块中的 exports 对象是 require('./simple') 返回的对象。因此,每次调用 s.next 都会调用 simple.js 中的 next 函数。每次调用都会返回(并增加)局部变量 count 的值。尝试访问私有字段 count 会显示它无法从模块外部访问。
再次强调规则:
-
作为
exports字段分配(也称为module.exports)的任何内容(函数或对象)都可以供模块外部的其他代码使用 -
没有分配给
exports的对象对模块外部的代码不可用,除非模块通过其他机制导出这些对象
这就是 Node.js 解决基于浏览器的 JavaScript 的全局对象问题的方法。看起来像是全局变量的变量实际上只对该变量所在的模块是全局的。这些变量对任何其他代码不可见。
既然我们已经对模块有了初步的了解,让我们更深入地研究一下。
CommonJS 和 ES2015 模块格式
Node.js 的模块实现强烈受到 CommonJS 模块规范的影响,但并不完全相同。它们之间的差异可能只有在您需要在 Node 和其他 CommonJS 系统之间共享代码时才重要。
在 ES2015 的变化中,有一个标准模块格式,旨在任何地方使用。它有一些有趣的功能,由于它无处不在,它应该会推进 JavaScript 的发展状态。由于它与 CommonJS/Node.js 模块系统不兼容,因此在 Node.js 中采用 ES2015 模块意味着我们需要重新工作我们的实践和接受的标准。
实际上,在过渡期间,Node.js 程序员将同时处理这两种模块格式。我们的长期目标应该是全面采用 ES2015 模块。Node.js 平台计划在 Node.js 10 中引入对 ES2015 模块的支持。截至 Node.js 8.5,可以通过设置命令行标志来启用此功能。
CommonJS/Node.js 模块格式
我们已经通过 simple.js 示例和我们在第二章“设置 Node.js”中检查的程序看到了这种模块格式的几个例子。所以让我们更仔细地看看。
CommonJS 模块存储在扩展名为 .js 的文件中。
加载 CommonJS 模块是一个同步操作。这意味着当 require('modulename') 函数调用返回时,模块已经被定位并完全读入内存,并准备好使用。该模块被缓存在内存中,因此后续的 require('modulename') 调用将立即返回,并且所有返回的都是同一个对象。
Node.js 模块提供了一个简单的封装机制来隐藏实现细节,同时暴露一个 API。模块被处理得好像它们是这样编写的:
(function() { ... contents of module file ... })();
因此,模块内的所有内容都包含在一个匿名私有命名空间上下文中。这就是解决全局对象问题的方法;模块中看似全局的内容实际上都包含在这个私有上下文中。
可以通过 Node.js 插入到这个私有上下文中的两个自由变量来从 CommonJS 模块中公开对象和函数:module 和 exports:
-
module对象包含一些你可能觉得有用的字段。有关详细信息,请参阅在线 Node.js 文档。 -
exports对象是module.exports字段的别名。这意味着以下两行代码是等效的:
exports.funcName = function(arg, arg1) { ... };
module.exports.funcName = function(arg, arg2) { .. };
如果你这样做,你的代码可能会破坏这两个别名之间的关联:
exports = function(arg, arg1) { ... };
不要这样做,因为 exports 将不再等同于 module.exports。如果你的意图是将单个对象或函数分配为 require 返回的内容,请改为这样做:
module.exports = function(arg, arg1) { ... };
一些模块确实只导出一个函数,因为模块作者就是这样设想来交付所需功能的。
Node.js 包格式源自 CommonJS 模块系统 (commonjs.org)。在开发过程中,CommonJS 团队旨在填补 JavaScript 生态系统中的空白。当时,没有标准的模块系统,这使得打包 JavaScript 应用程序变得更加复杂。Node.js 模块的 require 函数、exports 对象以及其他方面直接来自 CommonJS 的 Modules/1.0 规范。
ES6 模块格式
ES6 模块是一种为所有 JavaScript 环境设计的全新模块格式。虽然 Node.js 整个生命周期中都有一个很好的模块系统,但浏览器端的 JavaScript 并没有。这使浏览器端的社区要么依赖于 <script> 标签,要么使用非标准化的解决方案。就这一点而言,传统的 Node.js 模块从未标准化,除了 CommonJS 努力之外。因此,ES6 模块有望为整个 JavaScript 世界带来重大改进,通过使每个人都使用相同的模块格式和机制达成共识。
副作用是,Node.js 社区需要开始关注、学习并采用 ES2015 模块格式。
Node.js 使用 .mjs 扩展名来引用 ES6 模块。在实现新的模块格式时,Node.js 团队确定他们无法同时支持 CommonJS 和 ES6 模块使用 .js 扩展名。.mjs 扩展名被选为解决方案,你可能会看到对这个文件扩展名的讽刺性引用,称为 Michael Jackson Script。
一个有趣的细节是,ES6 模块是异步加载的。这可能对 Node.js 程序员没有太大影响,但这是要求使用新的 .mjs 扩展名背后的部分原因。
在同一目录下创建一个名为 simple2.mjs 的文件,该文件是我们之前查看的 simple.js 示例:
var count = 0;
export function next() { return ++count; }
function squared() { return Math.pow(count, 2); }
export function hello() {
return "Hello, world!";
}
export default function() { return count; }
export const meaning = 42;
export let nocount = -1;
export { squared };
从模块导出的 ES6 项目使用 export 关键字声明。这个关键字可以放在任何顶层声明之前,例如变量、函数或类声明:
export function next() { .. }
这种效果类似于以下:
module.exports.next = function() { .. }
它们的意图本质上相同:使一个函数或其他对象对模块外部的代码可用。例如,export function next() 是一个命名导出,意味着导出的东西有一个名字,外部代码使用这个名字来访问对象。正如我们所看到的,命名 exports 可以是函数或对象,它们也可以是类定义。
使用 export default 可以在每个模块中只做一次,并且是模块的 default 导出。default 导出是当使用模块对象本身而不是使用模块的导出之一时,外部代码访问的内容。
你也可以声明一些东西,比如 squared 函数,然后稍后导出它。
现在我们来看看如何使用这个 ES2015 模块。创建一个名为 simpledemo.mjs 的文件,内容如下:
import * as simple2 from './simple2.mjs';
console.log(simple2.hello());
console.log(`${simple2.next()} ${simple2.squared()}`);
console.log(`${simple2.next()} ${simple2.squared()}`);
console.log(`${simple2.default()} ${simple2.squared()}`);
console.log(`${simple2.next()} ${simple2.squared()}`);
console.log(`${simple2.next()} ${simple2.squared()}`);
console.log(`${simple2.next()} ${simple2.squared()}`);
console.log(simple2.meaning);
import 语句做了它意味着的事情:它导入了从模块导出的对象。这个版本的 import 语句与传统的 Node.js require 语句最相似,这意味着它通过创建一个对象来访问模块导出的对象。
这就是代码的执行方式:
$ node --experimental-modules simpledemo.mjs
(node:63937) ExperimentalWarning: The ESM module loader is experimental.
Hello, world!
1 1
2 4
2 4
3 9
4 16
5 25
42
截至 Node.js 8.5,新的模块格式可以通过选项标志使用,如下所示。你还会看到一个警告,说明这是一个实验性功能。访问 default 导出是通过访问名为 default 的字段来完成的。访问导出的值,如 meaning 字段,不需要括号,因为它是一个值而不是一个函数。
现在来看看从模块中导入对象的另一种方式,创建另一个文件,命名为 simpledemo2.mjs,内容如下:
import {
default as simple, hello, next
} from './simple2.mjs';
console.log(hello());
console.log(next());
console.log(next());
console.log(simple());
console.log(next());
console.log(next());
console.log(next());
在这种情况下,每个导入的对象都是独立的东西,而不是附加到另一个对象上。你不需要写 simple2.next(),只需写 next()。as 子句是一种声明别名的方式,如果其他方式不可行,这样你就可以使用默认导出。我们之前已经使用了一个 as 子句,并且可以在其他需要为导出或导入的值提供别名的实例中使用它。
Node.js 模块可以从 ES2015 .mjs 代码中使用。创建一个名为 ls.mjs 的文件,内容如下:
import _fs from 'fs';
const fs = _fs.promises;
import util from 'util';
(async () => {
const files = await fs.readdir('.');
for (let fn of files) {
console.log(fn);
}
})().catch(err => { console.error(err); });
然而,你不能将 ES2015 模块 require 到常规 Node.js 代码中。ES2015 模块的查找算法不同,正如我们之前提到的,ES2015 模块是异步加载的。
另一个复杂的问题是处理 fs.promises 子模块。我们在示例中使用了这个子模块,但它是如何使用的? 这个 import 语句不起作用:
import { promises as fs } from 'fs';
这失败了,原因如下:
$ node --experimental-modules ls.mjs
(node:45186) ExperimentalWarning: The ESM module loader is experimental.
file:///Volumes/Extra/book-4th/chap03/ls.mjs:1
import { promises as fs } from 'fs';
^^^^^^^^
SyntaxError: The requested module 'fs' does not provide an export named 'promises'
at ModuleJob._instantiate (internal/modules/esm/module_job.js:89:21)
这就留下了这个结构:
import _fs from 'fs';
const fs = _fs.promises;
执行脚本会得到以下结果:
$ node --experimental-modules ls.mjs
(node:65359) ExperimentalWarning: The ESM module loader is experimental.
(node:37671) ExperimentalWarning: The fs.promises API is experimental
ls.mjs
module1.js
module2.js
simple.js
simple2.mjs
simpledemo.mjs
simpledemo2.mjs
关于 ES2015 模块代码的最后一点是,import 和 export 语句必须是顶层代码。即使在像这样简单的块中放置一个 export:
{
export const meaning = 42;
}
结果导致错误:
$ node --experimental-modules badexport.mjs
(node:67984) ExperimentalWarning: The ESM module loader is experimental.
SyntaxError: Unexpected token export
at ModuleJob.loaders.set [as moduleProvider] (internal/loader/ModuleRequest.js:32:13)
at <anonymous>
尽管还有关于 ES2015 模块的更多细节,但这些是最重要的属性。
JSON 模块
Node.js 支持使用 require('/path/to/file-name.json') 来导入 JSON 文件。它与以下代码等价:
const fs = require('fs');
module.exports = JSON.parse(
fs.readFileSync('/path/to/file-name.json', 'utf8'));
即,JSON 文件是同步读取的,文本被解析为 JSON。结果对象作为模块导出的对象可用。创建一个名为 data.json 的文件,包含以下内容:
{
"hello": "Hello, world!",
"meaning": 42
}
现在创建一个名为 showdata.js 的文件,包含以下内容:
const util = require('util');
const data = require('./data');
console.log(util.inspect(data));
它将按以下方式执行:
$ node showdata.js
{ hello: 'Hello, world!', meaning: 42 }
util.inspect 函数是一种以易于阅读的方式展示对象的有用方法。
在较老的 Node.js 版本上支持 ES6 模块
虽然 ES6 模块的支持作为 Node.js 8.5 的实验性功能出现,但在较早的 Node.js 实现中,有两种方式可以使用这些模块。
一种方法是使用 Babel 编译器重写 ES6 代码,使其能够在较老的 Node.js 版本上执行。例如,请参阅 blog.revillweb.com/using-es2015-es6-modules-with-babel-6-3ffc0870095b。
更好的方法是 Node.js 注册表中的 esm 包。只需按照以下步骤操作:
$ nvm install 6
Downloading and installing node v6.14.1...
Downloading https://nodejs.org/dist/v6.14.1/node-v6.14.1-darwin-x64.tar.xz...
######################################################################## 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v6.14.1 (npm v3.10.10)
$ nvm use 6
Now using node v6.14.1 (npm v3.10.10)
$ npm install esm
... npm output
$ node --require esm simpledemo.mjs
Hello, world!
1 1
2 4
2 4
3 9
4 16
5 25
42
要使用此模块,只需调用一次 require('esm'),ES6 模块就会被集成到 Node.js 中。--require 标志会自动加载指定的模块。无需重写代码,我们可以通过命令行选项选择性地使用 esm 模块。
这个例子演示了将 ES6 模块集成到较老的 Node.js 版本中。要成功执行 ls.mjs 示例,我们必须有对 async/await 函数和箭头函数的支持。由于 Node.js 6.x 不支持这些,ls.mjs 示例将失败,并需要重写此类代码:
$ node --version
v6.14.1
$ node -r esm ls.mjs
/Users/David/chap03/ls.mjs:5
(async () => {
^
SyntaxError: Unexpected token (
at exports.runInThisContext (vm.js:53:16)
at Module._compile (module.js:373:25)
更多信息,请参阅:
medium.com/web-on-the-edge/es-modules-in-node-today-32cff914e4b。这篇文章描述了 esm 模块的早期版本,当时命名为 @std/esm。
展示模块级别的封装
模块的一个关键属性是封装。未从模块导出的对象是模块私有的,并且不能从模块外部的代码中访问。重申一点,模块被当作如下编写来处理:
(function() { ... contents of module file ... })();
这个 JavaScript 习惯用法定义了一个匿名私有作用域。在该作用域内声明的任何内容都无法被作用域外的代码访问。也就是说,除非某些代码使对象引用对作用域外的其他代码可用。这正是 module.exports 对象的作用:它是模块作者从模块中暴露对象引用的一种机制。然后其他代码可以以受控的方式访问模块内的资源。
模块内部顶级变量的外观就像它们存在于全局作用域中。实际上,它们并不是真正的全局变量,而是安全地私有于模块,并且完全不可被其他代码访问。
让我们看看一个实际演示该封装的例子。创建一个名为 module1.js 的文件,包含以下内容:
const A = "value A";
const B = "value B";
exports.values = function() {
return { A: A, B: B };
}
然后,创建一个名为 module2.js 的文件,包含以下内容:
const util = require('util');
const A = "a different value A";
const B = "a different value B";
const m1 = require('./module1');
console.log(`A=${A} B=${B} values=${util.inspect(m1.values())}`);
console.log(`${m1.A} ${m1.B}`);
const vals = m1.values();
vals.B = "something completely different";
console.log(util.inspect(vals));
console.log(util.inspect(m1.values()));
然后,按照以下方式运行(你必须已经安装了 Node.js):
$ node module2.js
A=a different value A B=a different value B values={ A: 'value A', B: 'value B' }
undefined undefined
{ A: 'value A', B: 'something completely different' }
{ A: 'value A', B: 'value B' }
这个人为的例子演示了 module1.js 中的值与 module2.js 中的值的封装。module1.js 中的 A 和 B 值不会覆盖 module2.js 中的 A 和 B,因为它们被封装在 module1.js 中。module1.js 中的 values 函数允许 module2.js 中的代码访问这些值;然而,module2.js 不能直接访问这些值。我们可以修改从 module1.js 接收到的 module2.js 对象。但这样做不会改变 module1.js 中的值。
使用 require 查找和加载 CommonJS 和 JSON 模块
我们已经讨论了几种模块类型:CommonJS、JSON、ES2015 和原生代码模块。除了 ES2015 模块之外,所有模块都是使用 require 函数加载的。该函数有一个非常强大且灵活的算法,用于在目录层次结构中定位模块。这个算法与 npm 包管理系统的结合,为 Node.js 平台提供了大量的功能和灵活性。
文件模块
我们刚刚查看的 CommonJS 和 ES2015 模块是 Node.js 文档中所描述的 文件模块。这样的模块包含在一个单独的文件中,其文件名以 .js、.mjs、.json 或 .node 结尾。后缀为 .node 的模块是从 C 或 C++ 源代码编译的,或者甚至是其他语言,如 Rust,而前者当然是用 JavaScript 或 JSON 编写的。
我们已经查看了一些使用这些模块的示例,以及 Node.js 中传统使用的 CommonJS 格式和现在支持的新 ES2015 模块之间的区别。
集成到 Node.js 二进制中的模块
一些模块被预编译到 Node.js 二进制文件中。这些是 Node.js 网站上文档化的核心 Node.js 模块,网址为 nodejs.org/api/index.html。
它们最初是 Node.js 构建树中的源代码。构建过程将它们编译成二进制文件,以便模块始终可用。
目录作为模块
一个模块可以包含一个完整的目录结构,其中充满了各种东西。这里的“东西”是一个术语,指的是内部文件模块、数据文件、模板文件、文档、测试、资产等等。一旦存储在正确构建的目录结构中,Node.js 将将其视为一个满足 require('moduleName') 调用的模块。
这可能有点令人困惑,因为单词 module 被赋予了两个含义。在某些情况下,一个 module 是一个文件,而在其他情况下,一个 module 是一个包含一个或多个文件模块的目录。
在大多数情况下,一个作为模块的目录包含一个package.json文件。这个文件包含有关模块(称为包)的数据,Node.js 在加载模块时使用这些数据。Node.js 运行时识别这两个字段:
{ name: "myAwesomeLibrary",
main: "./lib/awesome.js" }
如果这个package.json文件位于名为awesomelib的目录中,那么require('./awesomelib')将加载./awesomelib/lib/awesome.js中的文件模块。
如果没有package.json,那么 Node.js 将寻找index.js或index.node。在这种情况下,require('./awesomelib')将加载./awesomelib/index.js中的文件模块。
在任何情况下,目录模块可以轻松地包含其他文件模块。最初加载的模块将简单地使用require('./anotherModule')一次或多次来加载其他私有模块。
npm 包管理系统能够在package.json文件中识别更多的数据。这包括包名、作者、主页 URL、问题队列 URL、包依赖项等等。我们稍后会介绍这一点。
模块标识符和路径名
通常来说,模块名称是一个路径名,但去除了文件扩展名。早些时候,当我们写下require('./simple')时,Node.js 知道要添加.js到文件名并加载simple.js。同样,Node.js 会识别simple.json或simple.node作为合法满足require('./simple')的文件名。
模块标识符有三种类型:相对、绝对和顶级:
-
相对模块标识符:这些以
./或../开头,绝对标识符以/开头。模块名称与 POSIX 文件系统语义相同。结果路径名相对于正在执行文件的当前位置进行解释。也就是说,以./开头的模块标识符将在当前目录中查找,而以../开头的则是在父目录中查找。 -
绝对模块标识符:这些以
/开头,当然,它们在文件系统的根目录中查找,但这不是推荐的做法。 -
顶级模块标识符:这些不以任何这些字符串开头,只是模块名称,或者
module-name/path/to/module。这些必须存储在node_modules目录中,Node.js 运行时有一个灵活的算法来定位正确的node_modules目录:-
在
module-name/path/to/module指定的情况下,将加载的是名为module-name的顶级模块中的path/to/module模块。 -
内置模块使用顶级模块名称指定
-
搜索从调用 require() 的文件所在的目录开始。如果该目录包含一个 node_modules 目录,该目录包含匹配的目录模块或文件模块,则搜索满足。如果本地 node_modules 目录不包含合适的模块,它将在父目录中再次尝试,并在文件系统中向上继续,直到找到合适的模块或达到根目录。
也就是说,在 /home/david/projects/notes/foo.js 文件中进行 require 调用时,将会查询以下目录:
-
/home/david/projects/notes/node_modules -
/home/david/projects/node_modules -
/home/david/node_modules -
/home/node_modules -
/node_modules
如果通过此搜索未找到模块,存在一些全局文件夹,其中可以找到模块。第一个是在 NODE_PATH 环境变量中指定的。这被解释为类似于 PATH 环境变量的冒号分隔的绝对路径列表。在 Windows 上,NODE_PATH 的元素当然由分号分隔。Node.js 将在这些目录中搜索匹配的模块。
不推荐使用 NODE_PATH 方法,因为如果人们不知道这个变量必须设置,可能会出现令人惊讶的行为。如果需要特定目录中位于 NODE_PATH 中引用的特定模块来正确运行,而变量未设置,应用程序可能会失败。正如十二要素应用程序模型所建议的,最好将所有依赖项明确声明,在 Node.js 中这意味着在 package.json 中列出所有依赖项,以便 npm 或 yarn 可以管理依赖项。
这个变量是在上述模块解析算法最终确定之前实现的。由于该算法,NODE_PATH 大部分是不必要的。
有三个其他位置可以存放模块:
-
$HOME/.node_modules -
$HOME/.node_libraries -
$PREFIX/lib/node
在这种情况下,$HOME 是你所期望的,即用户的主目录,而 $PREFIX 是 Node.js 安装的目录。
一些开始建议不要使用全局模块。其理由是重复性和可部署性的需求。如果你已经测试了一个应用程序,并且所有代码都方便地位于一个目录树中,你可以复制这个树以部署到其他机器上。但是,如果应用程序依赖于系统其他地方神奇地安装的某些文件呢?你会记得部署这些文件吗?
应用程序目录结构的示例
让我们看看典型 Node.js Express 应用程序的文件系统结构:

这是一个 Express 应用程序(我们将在第五章 Your First Express Application 中使用 Express,其中包含了一些安装在 node_modules 目录中的模块。其中之一是 Express,它有自己的 node_modules 目录,包含了一些模块。
为了让 app.js 加载 models-sequelize/notes.js,它使用了以下 require 调用:
const notesModel = require('./models-sequelize/notes');
这是一个相对模块标识符,其中路径名是相对于创建引用的文件所在的目录解析的。
使用以下代码在 models-sequelize/notes.js 中进行反向操作:
const app = require('../app');
再次强调,这是一个相对模块标识符,这次是相对于包含 models-sequelize/notes.js 的子目录解析的。
对顶级模块标识符的任何引用将首先查找这里显示的 node_modules 目录。这个目录是从 package.json 中列出的依赖中填充的,正如我们将在几页后看到的:
const express = require('express');
const favicon = require('serve-favicon');
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
所有这些都是 Express 应用程序中包含的典型模块。其中大部分在前面显示的截图中都可以清楚地看到。加载的是相应子目录中 node_modules 的主文件,例如,node_modules/express/index.js。
但是应用不能直接引用其内部 node_modules 目录中 Express 模块的依赖。模块搜索算法只在文件系统中向上移动;它不会进入子目录树。
向上搜索方向的一个副作用是处理冲突的依赖。
假设有两个模块(模块 A 和 B)列出了对同一模块(C)的依赖?在正常情况下,对模块 C 的两个依赖可以由该模块的同一实例处理。正如我们将在几页后看到的,npm 在 package.json 中的依赖列表可以使用宽松或精确的版本号引用。根据模块 C 的当前版本号,模块 A 和 B 可能或可能不会就使用哪个版本达成一致。如果它们不一致,npm 可以安排模块安装,使得模块 A 和 B 都得到它们所依赖的模块 C 的版本,而不会相互冲突。如果它们都同意使用同一模块 C 实例,则只会安装一个副本,但如果它们不一致,则 npm 将安装两个副本。这两个副本将被放置在模块搜索算法会导致每个模块找到模块 C 正确版本的位置。
让我们通过一个具体的例子来澄清刚才所说的内容。在前面显示的截图中,你可以看到 cookie 模块的两个实例。我们可以使用 npm 查询对这个模块的所有引用:
$ npm ls cookie
notes@0.0.0 /Users/David/chap05/notes
├─┬ cookie-parser@1.3.5
│ └── cookie@0.1.3
└─┬ express@4.13.4
└── cookie@0.1.5
这表示cookie-parser模块依赖于cookie的 0.1.3 版本,而 Express 依赖于 0.1.5 版本。npm 如何避免这两个冲突版本的问题?通过将一个版本放在express模块内部的node_modules目录中。这样,当Express引用此模块时,它将使用其自己的node_modules目录中的0.1.5实例,而cookie-parser模块将使用顶级node_modules目录中的0.1.3实例。
使用import查找和加载 ES6 模块
import语句用于加载 ES6 模块,并且它只能在 ES6 模块内部使用。因为 ES6 模块是异步加载的,所以require()语句不能加载 ES6 模块。正如我们之前所说的,ES6 模块通过.mjs扩展名被 Node.js 识别。ECMAScript TC-39 委员会(或计划)将此文件扩展名正式注册为已识别的权威机构,以便常规工具可以识别这两种文件扩展名作为 JavaScript。
将模块指定符传递给import语句时,它被解释为一个 URL。目前,由于在互联网上加载模块的安全影响,Node.js 将仅接受file: URL。因为它是一个 URL,所以一些字符如 :, ?, #, 或 % 必须进行特殊处理。例如:
import './foo?search';
import './foo#hash';
这些是有效的模块指定符,其中?search和#hash具有在 URL 中预期的含义。只要 Node.js 只为import语句支持file: URL,我们就无法使用该功能,但我们必须记住这一点,并避免在模块 URL 中使用这些字符串。
可以安装自定义模块加载钩子,这些钩子可能需要使用这些 URL 部分来完成某些任务。
模块搜索算法与我们之前描述的require类似。如果指定符以./, ../, 或 /开头,则指定符被解释为路径名。否则,它被解释为类似于require语句的顶级模块,有一个很大的区别。import语句将不会搜索全局模块。这是不被推荐的,但如果必须使用全局模块,可以通过符号链接来实现。
有关文档,请参阅 nodejs.org/api/esm.html。
混合 CommonJS/Node.js/ES6 模块场景
我们已经讨论了 CommonJS/Node.js 模块的格式、ES6 模块的格式以及定位和导入这两种模块的算法。最后要讨论的是那些混合情况,即我们的代码将同时使用这两种模块格式。
实际上,ES6 模块在 Node.js 平台上非常新,因此我们有一大批现有的代码是以 CommonJS/Node.js 模块的形式编写的。Node.js 市场上的许多工具都依赖于 CommonJS 格式。这意味着我们将面临 ES6 模块需要使用 CommonJS 模块,反之亦然的情况:
-
CommonJS 模块使用
require()来加载其他 CommonJS 模块。 -
CommonJS 模块不能加载 ES6 模块——除了两种方法:
-
动态导入,也称为
import(),可以异步操作加载 ES6 模块。 -
@std/esm包提供了一个require()函数,它可以异步操作加载 ES6 模块。
-
-
ES6 模块使用
import加载其他 ES6 模块,具有import语句的完整语义。 -
ES6 模块使用
import加载 CommonJS 模块。
因此,直接支持三种场景。第四种场景可以通过一个工作区模块得到支持。
当一个 ES6 模块加载 CommonJS 模块时,其module.exports对象作为模块的default导出暴露出来。这意味着你的代码使用以下模式:
import cjsModule from 'common-js-module';
...
cjsModule.functionName();
这与在另一个 CommonJS 模块中使用 CommonJS 模块极为相似。你只是简单地将require()调用转换为import语句。
使用import()进行动态导入。
ES6 模块并没有涵盖完全替代 Node.js/CommonJS 模块的所有要求。其中缺失的功能之一正在通过 TC-39 委员会的动态导入特性得到解决。
动态导入的支持在 Node.js 9.7 中实现。请参阅以下文档:
github.com/tc39/proposal-dynamic-import。
我们将在第七章“数据存储和检索”中,使用动态导入来解决动态选择要加载的模块的问题。在require()语句的正常使用中,可以使用一个简单的字符串字面量来指定模块名称。但也可以使用字符串字面量来计算模块名称,如下所示:
// Node.js dynamically determined module loading
const moduleName = require(`../models/${process.env.MODEL_NAME}`);
我们在本书的早期版本中使用了这种技术,以动态选择同一模型 API 的几个实现。ES6 的import语句不支持任何东西,而只是一个简单的字符串字面量,因此不能像这个例子那样计算模块指定器。
使用动态导入,我们有一个import()函数,其中模块指定器是一个普通字符串,允许我们做出类似的动态模块选择。与同步的require()函数不同,import()是异步的,并返回一个 Promise。因此,它不是require()的直接替代品,因为它作为一个顶级函数并不特别有用。你将在第七章“数据存储和检索”中看到如何使用它。
它带来的最重要的特性可能是,CommonJS 模块可以使用import()来加载 ES6 模块。
import.meta特性。
另一个新特性import.meta正在通过 TC-39 委员会,并为 Node.js 10.x 实现。它是一个存在于 ES6 模块作用域内的对象,提供了关于模块的一些元数据。见github.com/tc39/proposal-import-meta。
一个部分实现,仅支持 import.meta.url,已集成到 Node.js 源代码中。其使用需要 --harmony-import-meta 命令行标志。import.meta.url 的内容是当前模块的完全限定 file: URL,例如 file:///Users/david/chap10/notes/app.mjs。
这变得重要的地方在于,ES6 模块不支持 Node.js 模块中历史上使用的 __dirname、__filename 和其他全局变量。__dirname 变量通常用于从软件包目录中的文件读取资源数据。对于此类情况,应从 import.meta.url 中解析目录名称。
npm - Node.js 的包管理系统
如第二章所述,设置 Node.js,npm 是 Node.js 的包管理和分发系统。它已成为使用 Node.js 分发模块(软件包)的事实标准。从概念上讲,它与 apt-get(Debian)、rpm/yum(Red Hat/Fedora)、MacPorts(macOS)、CPAN(Perl)或 PEAR(PHP)等工具类似。它的目的是通过简单的命令行界面在互联网上发布和分发 Node.js 软件包。使用 npm,你可以快速找到满足特定目的的软件包,下载、安装它们,并管理已安装的软件包。
npm 应用程序扩展了 Node.js 的软件包格式,而 Node.js 的软件包格式又主要基于 CommonJS 软件包规范。它使用与 Node.js 本地支持的相同的 package.json 文件,但增加了额外的字段以构建额外的功能。
npm 软件包格式
一个 npm 软件包是一个目录结构,包含一个描述软件包的 package.json 文件。这正是之前提到的目录模块,只不过 npm 识别的 package.json 标签比 Node.js 多得多。npm 的 package.json 的起点是 CommonJS Packages/1.0 规范。可以通过以下命令访问 npm 的 package.json 实现文档:
$ npm help json
一个基本的 package.json 文件如下:
{ "name": "packageName",
"version": "1.0",
"main": "mainModuleName",
"modules": {
"mod1": "lib/mod1",
"mod2": "lib/mod2"
}
}
文件是 JSON 格式,作为 JavaScript 程序员,你应该熟悉这种格式。
最重要的标签是 name 和 version。名称将出现在 URL 和命令中,因此请选择一个既安全又合适的名称。如果您希望将软件包发布到公共 npm 仓库,检查特定名称是否已在 npmjs.com 或以下命令中使用是有帮助的:
$ npm search packageName
main 标签的处理方式与我们之前在目录模块部分讨论的方式相同。它定义了在调用 require('packageName') 时将返回哪个文件模块。软件包可以在自身内部包含许多模块,并且它们可以在 modules 列表中列出。
软件包可以被打包成 tar-gzip 归档(tarball),尤其是在通过网络发送时。
包可以声明对其他包的依赖。这样,npm 可以自动安装安装的模块所需的模块。依赖项如下声明:
"dependencies": {
"foo" : "1.0.0 - 2.x.x",
"bar" : ">=1.0.2 <2.1.2"
}
description 和 keyword 字段有助于人们在 npm 仓库 (www.npmjs.com/) 中搜索时找到包。包的所有权可以在 homepage、author 或 contributors 字段中记录:
"description": "My wonderful package that walks dogs",
"homepage": "http://npm.dogs.org/dogwalker/",
"author": "dogwhisperer@dogs.org"
一些 npm 包提供可执行程序,这些程序意味着它们应该位于用户的 PATH 中。这些程序使用 bin 标签声明。这是一个命令名称到实现该命令的脚本的映射。命令脚本使用给定的名称安装到包含 node 可执行文件的目录中:
bin: {
'nodeload.js': './nodeload.js',
'nl.js': './nl.js'
},
directories 标签描述了包的目录结构。lib 目录会自动扫描以加载模块。还有其他目录标签用于二进制文件、手册和文档:
directories: { lib: './lib', bin: './bin' },
脚本标签是在包生命周期的各种事件中运行的脚本命令。这些事件包括 install、activate、uninstall、update 等。有关脚本命令的更多信息,请使用以下命令:
$ npm help scripts
我们已经在展示如何设置 Babel 时使用了脚本功能。我们将在自动化构建、测试和执行过程时使用这些脚本。
这只是 npm 包格式的尝鲜;有关更多信息,请参阅文档 (npm help json)。
查找 npm 包
默认情况下,npm 模块通过网络从 npmjs.com 上维护的公共包注册表中检索。如果您知道模块名称,可以通过输入以下内容简单地安装它:
$ npm install moduleName
但如果您不知道模块名称怎么办?您如何发现有趣的模块?网站 npmjs.com 发布了该注册表中模块的可搜索索引。
npm 包还提供了一个命令行搜索功能,用于查询相同的索引:

当然,在找到模块后,它会按照以下方式安装:
$ npm install acoustid
安装模块后,您可能想查看文档,这些文档通常位于模块的网站上。package.json 中的 homepage 标签列出了该 URL。查看 package.json 文件的最简单方法是使用 npm view 命令,如下所示:
$ npm view akasharender
...
{ name: 'akasharender',
description: 'Rendering support for generating static HTML websites
or EPUB eBooks',
'dist-tags': { latest: '0.6.15' },
versions:
'0.0.1',
...
author: 'David Herron <david@davidherron.com>
(http://davidherron.com)',
repository: { type: 'git', url:
'git://github.com/akashacms/akasharender.git' },
homepage: 'http://akashacms.com/akasharender/toc.html',
...
}
您可以使用 npm view 从 package.json 中提取任何标签,如下所示,这可以让您仅查看 homepage 标签:
$ npm view akasharender homepage
http://akashacms.org/akasharender/toc.html
package.json 中的其他字段可以通过简单地给出所需的标签名称来查看。
其他 npm 命令
主要的 npm 命令有一系列子命令,用于特定的包管理操作。这些涵盖了发布包(作为包作者)的生命周期中的各个方面,以及下载、使用或删除包(作为 npm 消费者)。
您只需输入 npm(不带参数)即可查看这些命令的列表。如果您想了解更多关于某个命令的信息,请查看帮助信息:
$ npm help <command>
The help text will be shown on your screen.
Or, see the website: http://docs.npmjs.com
安装 npm 包
当您找到心仪的包时,npm install命令可以轻松地安装包,如下所示:
$ npm install express
/home/david/projects/notes/
- express@4.13.4
...
命名模块安装在本目录的node_modules中。安装的具体版本取决于命令行上列出的任何版本号,正如我们在下一节中看到的。
按版本号安装包
npm 中的版本号匹配功能强大且灵活。在package.json依赖中使用的相同类型的版本指定符也可以与npm install命令一起使用:
$ npm install package-name@tag
$ npm install package-name@version
$ npm install package-name@version-range
最后两个选项正如其名。您可以指定express@4.16.2以定位精确版本,或express@">4.1.0 < 5.0"以定位 Express V4 版本的范围内。
版本匹配指定符包括以下选项:
-
精确版本匹配:1.2.3
-
至少版本 N:>1.2.3
-
至版本 N:<1.2.3
-
两个版本之间:>=1.2.3 <1.3.0
@tag属性是一个符号名称,例如@latest、@stable或@canary。包所有者将这些符号名称分配给特定的版本号,并且可以根据需要重新分配它们。例外是@latest,每当发布包的新版本时,它都会被更新。
对于更多文档,请运行以下命令:npm help json和npm help npm-dist-tag。
全局包安装
在某些情况下,您可能希望全局安装一个模块,以便可以从任何目录中使用它。例如,Grunt 或 Gulp 构建工具非常实用,如果这些工具全局安装,您可能会发现它们很有用。只需添加-g选项:
$ npm install -g grunt-cli
如果您遇到错误,并且您在类 Unix 系统(Linux/Mac)上,您可能需要使用sudo运行此命令:
$ sudo npm install -g grunt-cli
对于那些安装可执行命令的包来说,全局安装尤为重要。我们很快就会讨论这个问题。
如果本地包安装到node_modules中,那么全局包安装会到哪里?在类 Unix 系统中,它位于PREFIX/lib/node_modules,在 Windows 系统中,它位于PREFIX/node_modules。在这种情况下,PREFIX 是指 Node.js 安装的目录。您可以像这样检查此目录的位置:
$ npm config get prefix
/Users/david/.nvm/versions/node/v8.9.1
Node.js 中require函数使用的算法会自动搜索此目录以查找包,如果在其他地方找不到该包。
记住,ES6 模块不支持全局包。
避免全局模块安装
现在 Node.js 社区中的一些人开始对全局安装包表示不满。十二要素模型中存在一个合理的理由。也就是说,如果一个软件项目的所有依赖都明确声明,那么这个项目将更加可靠。如果需要像 Grunt 这样的构建工具,但它在package.json中没有明确声明,那么应用程序的用户将需要收到安装 Grunt 的指示,并且他们必须遵循这些指示。
用户作为用户,他们可能会跳过指示,未能安装依赖项,然后抱怨应用程序无法工作。当然,我们大多数人可能都这样做过一两次。
建议通过一种机制(即 npm install 命令)在本地安装所有内容,以避免这种潜在的问题。
使用 npm 维护包依赖项
如我们之前提到的,npm install 命令本身会安装 package.json 中 dependencies 部分列出的包。这很简单且方便。只需列出所有依赖项,就可以快速轻松地安装使用该包所需的依赖项。实际上,npm 会查找 package.json 中的 dependencies 或 devDependencies 字段,并会自动安装提到的包。
您可以通过编辑 package.json 来手动管理依赖项。或者,您可以使用 npm 帮助您编辑依赖项。您可以添加新的依赖项,如下所示:
$ npm install akasharender --save
作为回应,npm 将在 package.json 中添加一个 dependencies 标签:
"dependencies": {
"akasharender": "⁰.6.15"
}
现在,当你的应用程序被安装时,npm 会自动安装该包以及该包列出的任何 dependencies。
devDependencies 是在开发过程中使用的模块。该字段与上面初始化的方式相同,但使用 --save-dev 标志。
默认情况下,当运行 npm install 命令时,会安装同时列在 dependencies 和 devDependencies 中的模块。当然,设置两个列表的目的是在某些情况下不安装 devDependencies:
$ npm install --production
这只会安装列在 dependencies 中的模块,而不会安装任何 devDependencies 模块。
在十二要素应用程序模型中,建议我们明确标识应用程序所需的依赖项。这样我们就可以可靠地构建我们的应用程序,因为我们已经针对我们仔细识别的特定依赖项进行了测试。通过安装针对应用程序已测试的精确依赖项,我们对应用程序的信心更大。在 Node.js 平台上,npm 给我们提供了这个依赖项部分,包括通过版本号声明兼容包版本的灵活机制。
自动更新 package.json 依赖项
在 npm@5(也称为 npm 版本 5)中,一个变化是不再需要在 npm install 命令中添加 --save。相反,npm 默认会像你运行了带有 --save 的命令一样操作,并自动将依赖项添加到你的 package.json 中。这是为了简化使用 npm,并且可以说现在这样做更加方便。同时,npm 自动修改 package.json 可能会非常令人惊讶且不便。可以通过使用 --no-save 标志来禁用此行为。此行为可以通过以下方式永久禁用:
$ npm config set save false
npm config 命令支持一系列可设置的选项,用于调整 npm 的行为。请参阅 npm help config 获取文档,以及 npm help 7 config 获取选项列表。
通过更新包依赖项修复错误
每个软件包都存在错误。Node.js 平台的更新可能会破坏现有的软件包,同样,应用程序使用的软件包的升级也可能导致问题。您的应用程序可能会触发它使用的软件包中的错误。在这些和其他情况下,解决问题可能只需将软件包依赖项更新到较新(或较旧)的版本。
首先确定问题存在于软件包还是您的代码中。在确定问题是另一个软件包的问题后,调查软件包维护者是否已经修复了该错误。该软件包托管在 GitHub 或其他带有公共问题队列的服务上吗?寻找有关此问题的开放问题。这项调查将告诉您是否需要将软件包依赖项更新到较新版本。有时,它将告诉您回滚到较旧版本;例如,如果软件包维护者在较旧版本中引入了不存在的问题。
有时,您会发现软件包维护者没有准备好发布新版本。在这种情况下,您可以分叉他们的存储库并创建他们软件包的修补版本。
解决此问题的一种方法是将软件包版本号锁定到一个已知可以工作的版本。您可能知道版本 6.1.2 是您的应用程序最后一次使用的版本,而从版本 6.2.0 开始,您的应用程序会崩溃。因此,在package.json中:
"dependencies": {
"module1": "6.1.2"
}
这样就冻结了您对该特定版本号的依赖。然后,您可以自由地花时间更新您的代码,以便与该模块的后续版本兼容。一旦您的代码更新,或者上游项目更新,就相应地更改依赖项。
另一种方法是将软件包的版本托管在 npm 存储库之外的地方。这将在后面的章节中介绍。
安装命令的软件包
一些软件包会安装命令行程序。安装此类软件包的一个副作用是在 shell 提示符下可以输入的新命令或可以在 shell 脚本中使用的新命令。例如,我们在第二章“设置 Node.js”中简要使用过的 hexy 程序。另一个例子是广泛使用的 Grunt 或 Gulp 构建工具。
在此类软件包中,package.json文件指定了已安装的命令行工具。命令可以安装到以下两个位置之一:
-
全局安装:它安装到诸如
/usr/local之类的目录,或者安装到 Node.js 安装的bin目录。npm bin -g命令会告诉您此目录的绝对路径名。 -
本地安装:将命令安装到正在安装模块的软件包中的
node_modules/.bin目录。npm bin命令会告诉您此目录的绝对路径名。
要运行命令,只需在 shell 提示符下输入命令名称即可。不过,为了使这个过程变得简单,需要进行一些配置。
配置 PATH 变量以处理由模块安装的命令
输入完整的路径名并不是执行命令的用户友好要求。我们希望使用模块安装的命令,并希望有一个简单的过程来做到这一点。也就是说,我们必须在PATH变量中添加适当的值,但那是什么?
对于全局包安装,可执行文件会落在可能已经在您的PATH变量中的目录中,如/usr/bin或/usr/local/bin。本地包安装需要特殊处理。node_modules/.bin目录的完整路径对于每个项目都是不同的,显然,将每个node_modules/.bin目录的完整路径添加到您的PATH中是不可行的。
将./node_modules/.bin添加到PATH变量中(或者在 Windows 上为.\node_modules\.bin)效果很好。每次当您的 shell 位于 Node.js 项目的根目录时,它将自动找到本地安装的 Node.js 包中的命令。
我们如何做这取决于您使用的命令 shell 和操作系统。
在类 Unix 系统中,命令 shell 是bash和csh。您的PATH变量将按照以下方式之一设置:
$ export PATH=./node_modules/.bin:${PATH} # bash
$ setenv PATH ./node_modules/.bin:${PATH} # csh
下一步是将命令添加到您的登录脚本中,以便变量始终设置。在bash中,将相应的行添加到您的~/.bashrc中,在csh中添加到您的~/.cshrc中。
在 Windows 上配置PATH变量
在 Windows 上,这项任务通过系统设置面板来处理:
)是 Facebook、Google 和其他几家公司的工程师之间的合作。他们宣称 Yarn 非常快,非常安全(通过使用所有内容的校验和),非常可靠(通过使用yarn-lock.json文件来记录精确的依赖关系)。
Yarn 不是运行自己的包仓库,而是在npmjs.com上的 npm 包仓库之上运行。这意味着 Node.js 社区没有被 Yarn 分叉,而是通过拥有一个改进的包管理工具而得到增强。
npm 团队在 npm@5(也称为 npm 版本 5)中回应了 Yarn,通过提高性能,并引入package-lock.json文件来提高可靠性。npm 团队在 npm@6 中宣布了额外的改进。
Yarn 已经变得非常流行,并且比 npm 更受欢迎。它们执行的功能极其相似,性能与 npm@5 没有太大差异。命令行选项的措辞不同。Yarn 带给 Node.js 社区的一个重要好处是,Yarn 和 npm 之间的竞争似乎正在催生 Node.js 包管理的更快进步。
为了让你开始,以下是最重要的命令:
-
yarn add:将包添加到当前包中使用 -
yarn init:初始化包的开发 -
yarn install:安装package.json文件中定义的所有依赖项 -
yarn publish:将包发布到包管理器 -
yarn remove:从当前包中移除一个未使用的包
单独运行yarn会执行yarn install行为。Yarn 中还有其他几个命令,yarn help会列出它们。
摘要
你在本章中学到了很多关于 Node.js 模块和包的知识。
具体来说,我们涵盖了 Node.js 模块和包的实现、管理已安装的模块和包,以及看到了 Node.js 如何定位模块。
现在你已经了解了模块和包,我们准备使用它们来构建应用程序,这是下一章的主题。
第四章:HTTP 服务器和客户端
现在你已经了解了 Node.js 模块,是时候通过构建一个简单的 Node.js Web 应用来应用这些知识了。在本章中,我们将保持应用简单,这样我们就可以探索 Node.js 的三个不同应用框架。在后面的章节中,我们将构建一些更复杂的应用,但在我们能够行走之前,我们必须学会爬行。
我们在本章中将涵盖以下主题:
-
事件发射器
-
监听 HTTP 事件和 HTTP 服务器对象
-
HTTP 请求路由
-
ES2015 模板字符串
-
使用无框架构建简单的 Web 应用
-
Express 应用框架
-
Express 中间件函数
-
如何处理计算密集型代码
-
HTTP 客户端对象
-
使用 Express 创建一个简单的 REST 服务
使用事件发射器发送和接收事件
事件发射器(EventEmitters)是 Node.js 的核心惯用法之一。如果 Node.js 的核心思想是事件驱动架构,那么从对象中发射事件是该架构的主要机制之一。事件发射器是一个在其生命周期中的不同点提供通知(事件)的对象。例如,HTTP 服务器对象会发射与服务器对象启动/关闭的每个阶段相关的事件,以及 HTTP 客户端发起 HTTP 请求时。
许多核心 Node.js 模块都是事件发射器(EventEmitters),而事件发射器是实现异步编程的一个优秀框架。事件发射器与 Web 应用开发无关,但它们是 Node.js 结构中如此重要的一部分,以至于你可能会忽略它们的实际存在。
在本章中,我们将与 HTTP 服务器和 HTTP 客户端对象一起工作。这两个对象都是EventEmitter类的子类,并且依赖于它来发送 HTTP 协议每个步骤的事件。
JavaScript 类和类继承
在开始学习EventEmitter类之前,我们需要看看 ES2015 的另一个特性:类。JavaScript 语言始终有对象和类层次结构的概念,但与其他语言相比,并没有那么正式。ES2015 类对象建立在现有的基于原型的继承模型之上,但语法看起来非常类似于其他语言中的类定义。
例如,考虑我们在本书后面将要使用到的这个类:
class Note {
constructor(key, title, body) {
this._key = key;
this._title = title;
this._body = body;
}
get key() { return this._key; }
get title() { return this._title; }
set title(newTitle) { return this._title = newTitle; }
get body() { return this._body; }
set body(newBody) { return this._body = newBody; }
}
一旦你定义了类,你就可以将类定义导出到其他模块:
module.exports.Note = class Note { .. } # in CommonJS modules
export class Note { .. } # in ES6 modules
标记有get或set关键字的函数是 getter 和 setter,使用方式如下:
var aNote = new Note("key", "The Rain in Spain", "Falls mainly on the plain");
var key = aNote.key;
var title = aNote.title;
aNote.title = "The Rain in Spain, which made me want to cry with joy";
使用new创建类的实例。你可以像访问对象上的简单字段一样访问 getter 或 setter 函数。幕后,getter/setter 函数被调用。
之前的实现并不是最佳方案,因为_title和_body字段是公开可见的,没有数据隐藏或封装。我们将在稍后的章节中介绍一个更好的实现。
通过使用instanceof运算符来测试给定对象是否属于某个特定的类:
if (anotherNote instanceof Note) {
... it's a Note, so act on it as a Note
}
最后,你使用extends运算符声明一个子类,这与在其他语言中做的类似:
class LoveNote extends Note {
constructor(key, title, body, heart) {
super(key, title, body);
this._heart = heart;
}
get heart() { return this._heart; }
set heart(newHeart) { return this._heart = newHeart; }
}
换句话说,LoveNote类有Note类的所有字段,加上这个名为heart的新字段。
EventEmitter 类
EventEmitter对象定义在 Node.js 的 events 模块中。直接使用EventEmitter类意味着执行require('events')。在大多数情况下,你会使用一个内部使用EventEmitter的现有对象,而不需要这个模块。但也有一些情况下,需求决定了需要实现一个EventEmitter子类。
创建一个名为pulser.js的文件,包含以下代码:
const EventEmitter = require('events');
class Pulser extends EventEmitter {
start() {
setInterval(() => {
console.log(`${new Date().toISOString()} >>>> pulse`);
this.emit('pulse');
console.log(`${new Date().toISOString()} <<<< pulse`);
}, 1000);
}
}
module.exports = Pulser;
这定义了一个从EventEmitter继承的Pulser类。在较老的 Node.js 版本中,这需要使用util.inherits,但新的类对象使得子类化变得更加简单。
另一个需要检查的是回调函数中的this.emit如何引用 Pulser 对象。在 ES2015 箭头函数之前,当我们的回调使用常规function时,this不会指向Pulser对象。相反,它会指向与setInterval函数相关的其他对象。因为它是箭头函数,所以箭头函数内部的this与外部函数中的this是相同的。
如果你需要使用函数而不是箭头函数,这个技巧会起作用:
class Pulser extends EventEmitter {
start() {
var self = this;
setInterval(function() {
self.emit(...);
});
}
}
与其他不同之处在于将this赋值给self。函数内部this的值是不同的,但在每个封闭的作用域中self的值保持不变。现在我们有了箭头函数,这个广泛使用的技巧变得不那么必要了。
如果你想要一个简单的 EventEmitter,但有自己的类名,扩展类的主体可以是空的:
class HeartBeat extends EventEmitter {}
const beatMaker = new HeartBeat();
Pulser类的目的是发送一个定时事件,每秒一次,到任何监听器。start方法使用setInterval来启动重复的回调执行,每秒调度一次,调用emit将pulse事件发送到任何监听器。
现在,让我们看看如何使用Pulser对象。创建一个名为pulsed.js的新文件,包含以下内容:
const Pulser = require('./pulser');
// Instantiate a Pulser object
const pulser = new Pulser();
// Handler function
pulser.on('pulse', () => {
console.log(`${new Date().toISOString()} pulse received`);
});
// Start it pulsing
pulser.start();
在这里,我们创建了一个Pulser对象并消费其pulse事件。调用pulser.on('pulse')为pulse事件设置连接,以便调用回调函数。然后调用start方法使进程开始运行。
将以下内容输入到一个文件中,并将文件命名为pulsed.js。当你运行它时,你应该看到以下输出:
$ node pulsed.js
2017-12-03T06:24:10.272Z >>>> pulse
2017-12-03T06:24:10.275Z pulse received
2017-12-03T06:24:10.276Z <<<< pulse
2017-12-03T06:24:11.279Z >>>> pulse
2017-12-03T06:24:11.279Z pulse received
2017-12-03T06:24:11.279Z <<<< pulse
2017-12-03T06:24:12.281Z >>>> pulse
2017-12-03T06:24:12.281Z pulse received
2017-12-03T06:24:12.282Z <<<< pulse
这让你对EventEmitter类有了点实际的知识。现在,让我们看看它的操作理论。
EventEmitter 理论
使用EventEmitter类,你的代码可以发出事件,其他代码可以接收这些事件。这是连接程序中两个分离部分的一种方式,有点像量子纠缠意味着两个电子可以从任何距离相互通信。看起来很简单。
事件名称可以是您认为有意义的任何名称,并且您可以定义任意多个事件名称。事件名称通过调用 .emit 并提供事件名称来定义。没有正式的操作要做,也没有事件名称的注册。只需调用 .emit 就足以定义一个事件名称。
按照惯例,事件名称 error 表示错误。
一个对象使用 .emit 函数发送事件。事件被发送到已注册接收该对象事件的任何监听器。程序通过调用该对象的 .on 方法来注册接收事件,提供事件名称和事件处理函数。
没有所有事件的中央分发点。相反,EventEmitter 对象的每个实例管理自己的监听器集,并将事件分发给这些监听器。
通常,需要在事件中发送数据。为此,只需将数据作为参数添加到 .emit 调用中,如下所示:
this.emit('eventName', data1, data2, ..);
当程序接收到该事件时,数据作为回调函数的参数出现。您的程序可以如下监听此类事件:
emitter.on('eventName', (data1, data2, ...theArgs) => {
// act on event
});
事件接收者和事件发送者之间没有握手。也就是说,事件发送者只是继续其业务,并且不会收到任何关于接收到的任何事件、采取的任何行动或发生的任何错误的任何通知。
在这个例子中,我们使用了 ES2015 的另一个特性,即 rest 操作符,在这里显示为 ...theArgs。rest 操作符将任意数量的剩余函数参数捕获到一个数组中。由于 EventEmitter 可以传递任意数量的参数,而 rest 操作符可以自动接收任意数量的参数,这是一场天作之合,或者是在 TC-39 委员会上的。
HTTP 服务器应用程序
HTTP 服务器对象是所有 Node.js 网络应用程序的基础。该对象本身非常接近 HTTP 协议,其使用需要对该协议的了解。在大多数情况下,您可以使用像 Express 这样的应用程序框架来隐藏 HTTP 协议的细节,使程序员能够专注于业务逻辑。
我们已经在第二章 设置 Node.js 中看到了一个简单的 HTTP 服务器应用程序,如下所示:
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!\n');
}).listen(8124, '127.0.0.1');
console.log('Server running at http://127.0.0.1:8124');
http.createServer 函数创建一个 http.Server 对象。因为它是一个 EventEmitter,所以可以用另一种方式来明确这一点:
const http = require('http');
const server = http.createServer();
server.on('request', (req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!\n');
});
server.listen(8124, '127.0.0.1');
console.log('Server running at http://127.0.0.1:8124');
request 事件接受一个函数,该函数接收 request 和 response 对象。request 对象包含来自网页浏览器的数据,而 response 对象用于收集要发送在响应中的数据。listen 函数使服务器开始监听并安排为每个来自网页浏览器的请求分配一个事件。
现在,让我们看看一些更有趣的内容,这些内容根据不同的 URL 执行不同的操作。
创建一个名为 server.js 的新文件,包含以下代码:
const http = require('http');
const util = require('util');
const url = require('url');
const os = require('os');
const server = http.createServer();
server.on('request', (req, res) => {
var requrl = url.parse(req.url, true);
if (requrl.pathname === '/') {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(
`<html><head><title>Hello, world!</title></head>
<body><h1>Hello, world!</h1>
<p><a href='/osinfo'>OS Info</a></p>
</body></html>`);
} else if (requrl.pathname === "/osinfo") {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(
`<html><head><title>Operating System Info</title></head>
<body><h1>Operating System Info</h1>
<table>
<tr><th>TMP Dir</th><td>${os.tmpdir()}</td></tr>
<tr><th>Host Name</th><td>${os.hostname()}</td></tr>
<tr><th>OS Type</th><td>${os.type()} ${os.platform()} ${os.arch()} ${os.release()}</td></tr>
<tr><th>Uptime</th><td>${os.uptime()} ${util.inspect(os.loadavg())}</td></tr>
<tr><th>Memory</th><td>total: ${os.totalmem()} free: ${os.freemem()}</td></tr>
<tr><th>CPU's</th><td><pre>${util.inspect(os.cpus())}</pre></td></tr>
<tr><th>Network</th><td><pre>${util.inspect(os.networkInterfaces())}</pre></td></tr>
</table>
</body></html>`);
} else {
res.writeHead(404, {'Content-Type': 'text/plain'});
res.end("bad URL "+ req.url);
}
});
server.listen(8124);
console.log('listening to http://localhost:8124');
要运行它,请输入以下命令:
$ node server.js
listening to http://localhost:8124
这个应用程序旨在类似于 PHP 的 sysinfo 函数。Node 的 os 模块被用来提供有关服务器的信息。这个示例可以很容易地扩展以收集有关服务器的其他数据:

任何 Web 应用程序的核心部分是将请求路由到请求处理程序的方法。request 对象附带了多个数据项,其中两个对路由请求很有用:request.url 和 request.method 字段。
在 server.js 中,我们通过解析(使用 url.parse)来确定要显示哪个页面,然后咨询 request.url 数据。在这种情况下,我们可以简单地比较 pathname 来确定要使用哪个处理程序方法。
一些 Web 应用程序关注所使用的 HTTP 动词(GET、DELETE、POST 等),并必须咨询 request 对象的 request.method 字段。例如,POST 经常用于 FORM 提交。
请求 URL 的 pathname 部分用于将请求调度到正确的处理程序。虽然这种基于简单字符串比较的路由方法适用于小型应用程序,但它很快就会变得难以控制。大型应用程序将使用模式匹配来使用请求 URL 的一部分来选择请求处理程序函数,并从 URL 中提取其他部分作为请求数据。我们将在查看 Express 的“Express 入门”部分时看到这一点。
在 npm 仓库中搜索 URL 匹配,会找到几个有潜力的包,可以用来实现请求匹配和路由。像 Express 这样的框架已经内置并测试了这种功能。
如果请求 URL 不可识别,服务器会使用 404 状态码发送一个错误页面。结果代码会通知浏览器请求的状态,其中 200 状态码表示一切正常,而 404 状态码表示请求的页面不存在。当然,还有许多其他的 HTTP 响应代码,每个都有其特定的含义。
ES2015 多行和模板字符串
之前的示例展示了 ES2015 中引入的两个新特性,多行和模板字符串。这个特性旨在简化我们创建文本字符串的过程。
现有的字符串表示使用单引号和双引号。模板字符串由反引号字符分隔,该字符也称为 重音符号:
`template string text`
在 ES2015 之前,实现多行字符串的一种方法是使用以下结构:
["<html><head><title>Hello, world!</title></head>",
"<body><h1>Hello, world!</h1>",
"<p><a href='/osinfo'>OS Info</a></p>",
"</body></html>"]
.join('\n')
是的,这正是之前这本书的版本中相同示例所使用的代码。这是我们可以用 ES2015 做的事情:
`<html><head><title>Hello, world!</title></head>
<body><h1>Hello, world!</h1>
<p><a href='/osinfo'>OS Info</a></p>
</body></html>`
这更加简洁直接。开引号位于第一行,闭引号位于最后一行,而两者之间的所有内容都是我们字符串的一部分。
模板字符串功能的真正目的是支持可以直接将值直接替换到字符串中的字符串。大多数其他编程语言都支持这种能力,现在 JavaScript 也支持了。
在 ES2015 之前,程序员可以编写如下代码:
[ ...
"<tr><th>OS Type</th><td>{ostype} {osplat} {osarch} {osrelease}</td></tr>"
... ].join('\n')
.replace("{ostype}", os.type())
.replace("{osplat}", os.platform())
.replace("{osarch}", os.arch())
.replace("{osrelease}", os.release())
同样,这是从本书之前版本中的相同示例中提取的。使用模板字符串,可以写成如下所示:
`...<tr><th>OS Type</th><td>${os.type()} ${os.platform()} ${os.arch()} ${os.release()}</td></tr>...`
在模板字符串中,${ .. } 括号内的部分被解释为表达式。它可以是简单的数学表达式、变量引用,或者,如本例中所示,函数调用。
最后要提到的是缩进的问题。在常规编码中,会将长参数列表缩进到与包含函数调用相同的级别。但是,对于这些多行字符串示例,文本内容与列零对齐。这是怎么回事?
这可能会妨碍代码的可读性,因此权衡代码可读性和另一个问题:HTML 输出中的多余字符是值得的。我们用来提高代码可读性的空白将变成字符串的一部分,并将在 HTML 中输出。通过使代码与列零对齐,我们不会因为牺牲一些代码可读性而向输出添加多余的空白。
这种方法也伴随着安全风险。您已经验证了数据的安全性吗?它不会成为安全攻击的基础吗?在这种情况下,我们处理的是来自安全数据源的简单字符串和数字。因此,这段代码与 Node.js 运行时的安全性相同。那么用户提供的内 容和恶意用户可能提供的不安全内容,将某种恶意软件植入目标计算机的风险怎么办?
由于这个原因以及许多其他原因,通常更安全地使用外部模板引擎。像 Express 这样的应用程序使得这样做变得容易。
HTTP Sniffer – 监听 HTTP 对话
HTTPServer 对象发出的事件可以用于超出立即交付 Web 应用程序的直接任务的其他目的。以下代码演示了一个有用的模块,它监听所有 HTTP 服务器事件。它可以是一个有用的调试工具,同时也展示了 HTTP 服务器对象是如何操作的。
Node.js 的 HTTP 服务器对象是一个 EventEmitter,HTTP Sniffer 简单地监听每个服务器事件,打印出与每个事件相关的信息。
我们接下来要做的就是:
-
创建一个名为
httpsniffer的模块,打印有关 HTTP 请求的信息。 -
将该模块添加到我们刚刚创建的
server.js脚本中。 -
重新运行该服务器以查看 HTTP 活动的跟踪。
创建一个名为 httpsniffer.js 的文件,包含以下代码:
const util = require('util');
const url = require('url');
const timestamp = () => { return new Date().toISOString(); }
exports.sniffOn = function(server) {
server.on('request', (req, res) => {
console.log(`${timestamp()} e_request`);
console.log(`${timestamp()} ${reqToString(req)}`);
});
server.on('close', errno => { console.log(`${timestamp()} e_close
${errno}`); });
server.on('checkContinue', (req, res) => {
console.log(`${timestamp()} e_checkContinue`);
console.log(`${timestamp()} ${reqToString(req)}`);
res.writeContinue();
});
server.on('upgrade', (req, socket, head) => {
console.log(`${timestamp()} e_upgrade`);
console.log(`${timestamp()} ${reqToString(req)}`);
});
server.on('clientError', () => { console.log(`${timestamp()}
e_clientError`); });
};
const reqToString = exports.reqToString = (req) => {
var ret=`req ${req.method} ${req.httpVersion} ${req.url}` +'\n';
ret += JSON.stringify(url.parse(req.url, true)) +'\n';
var keys = Object.keys(req.headers);
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i];
ret += `${i} ${key}: ${req.headers[key]}` +'\n';
}
if (req.trailers) ret += util.inspect(req.trailers) +'\n';
return ret;
};
这段代码很多!但关键在于 sniffOn 函数。当给定一个 HTTP 服务器对象时,它使用 .on 函数附加监听函数,打印出关于每个发出的事件的详细信息。它为应用程序上的 HTTP 流提供了相当详细的跟踪。
为了使用它,只需在server.js中的listen函数之前插入此代码:
require('./httpsniffer').sniffOn(server);
server.listen(8124);
console.log('listening to http://localhost:8124');
在此基础上,像之前一样运行服务器。你可以在浏览器中访问http://localhost:8124/,并看到以下控制台输出:
$ node server.js
listening to http://localhost:8124
2017-12-03T19:21:33.162Z request
2017-12-03T19:21:33.162Z request GET 1.1 /
{"protocol":null,"slashes":null,"auth":null,"host":null,"port":null,"hostname":null,"hash":null,"search":"","query":{},"pathname":"/","path":"/","href":"/"}
0 host: localhost:8124
1 upgrade-insecure-requests: 1
2 accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
3 user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0.1 Safari/604.3.5
4 accept-language: en-us
5 accept-encoding: gzip, deflate
6 connection: keep-alive
{}
2017-12-03T19:21:42.154Z request
2017-12-03T19:21:42.154Z request GET 1.1 /osinfo
{"protocol":null,"slashes":null,"auth":null,"host":null,"port":null,"hostname":null,"hash":null,"search":"","query":{},"pathname":"/osinfo","path":"/osinfo","href":"/osinfo"}
0 host: localhost:8124
1 connection: keep-alive
2 upgrade-insecure-requests: 1
3 accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
4 user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0.1 Safari/604.3.5
5 referer: http://localhost:8124/
6 accept-language: en-us
7 accept-encoding: gzip, deflate
{}
你现在有一个工具可以监视 HTTPServer 事件。这个简单的技术会打印出详细的事件数据日志。这个模式可以用于任何EventEmitter对象。你可以使用这个技术作为检查程序中EventEmitter对象实际行为的一种方式。
网络应用程序框架
HTTPServer 对象非常接近 HTTP 协议。虽然这和驾驶手动挡汽车一样,能让你对驾驶体验有低级别的控制,但在更高的层面上进行典型的网络应用程序编程会更好。有人用汇编语言编写网络应用程序吗?最好是抽象掉 HTTP 细节,专注于你的应用程序。
Node.js 开发者社区已经开发了许多应用程序框架,以帮助抽象 HTTP 协议细节的不同方面。其中,Express 是最受欢迎的,而且 Koa(koajs.com/)也应该被考虑,因为它是由同一个团队开发的,并且完全集成了对async函数的支持。
ExpressJS 维基页面上列出了建立在 ExpressJS 之上或与之配合使用的框架列表。这包括模板引擎、中间件模块等。ExpressJS 维基页面位于github.com/expressjs/express/wiki。
使用网络框架的一个原因是,它们通常提供了超过 20 年来在 Web 应用程序开发中使用的最佳实践。通常的最佳实践包括以下内容:
-
提供一个用于错误 URL(404 页面)的页面
-
筛选 URL 和表单以检测任何注入的脚本攻击
-
支持使用 cookie 来维持会话
-
记录请求以进行使用跟踪和调试
-
认证
-
处理静态文件,例如图像、CSS、JavaScript 或 HTML
-
为缓存代理提供缓存控制头
-
限制诸如页面大小或执行时间等因素
网络框架可以帮助你在任务中投入时间,而不会迷失在实现 HTTP 协议的细节中。抽象细节是程序员提高效率的传统方法。这在使用提供预包装函数的库或框架时尤其如此,这些函数会处理细节。
开始使用 Express
Express 可能是最受欢迎的 Node.js 网络应用程序框架。它如此受欢迎,以至于它是 MEAN Stack 缩写词的一部分。MEAN 代表 MongoDB、ExpressJS、AngularJS 和 Node.js。Express 被描述为类似于 Sinatra,指的是一个流行的 Ruby 应用程序框架,并且它不是一个有偏见的框架,这意味着框架作者不会强加他们对应用程序结构的看法。这意味着 Express 对您的代码结构没有任何严格的要求;您只需按照您认为最好的方式编写即可。
您可以访问 Express 的主页,网址为expressjs.com/。
很快,我们将使用 Express 实现一个简单的应用程序来计算斐波那契数,在后面的章节中,我们将使用 Express 做更多的事情。我们还将探讨如何减轻我们之前讨论的计算密集型代码的性能问题。
截至撰写本书时,Express 4.16 是当前版本,Express 5 处于 Alpha 测试阶段。根据 ExpressJS 网站,Express 4 和 Express 5 之间几乎没有区别。
让我们先安装 express-generator。虽然我们可以直接开始编写代码,但 express-generator 提供了一个空白起始应用程序。我们将使用它并进行修改。
使用以下命令进行安装:
$ mkdir fibonacci
$ cd fibonacci
$ npm install express-generator@4.x
这与 Express 网站上建议的安装方法不同,该方法是使用-g标签进行全局安装。我们还在使用显式的版本号以确保兼容性。截至撰写本书时,express-generator@5.x不存在。当它存在时,应该能够使用以下说明使用 5.x 版本。
之前,我们讨论了很多人现在推荐不要全局安装模块。在 Twelve-Factor 模型中,强烈建议不要安装全局依赖项,这正是我们所做的。
结果是在./node_modules/.bin目录中安装了一个express命令:
$ ls node_modules/.bin/
express
按照以下方式运行express命令:
$ ./node_modules/.bin/express --help
Usage: express [options] [dir]
Options:
-h, --help output usage information
-V, --version output the version number
-e, --ejs add ejs engine support (defaults to jade)
--hbs add handlebars engine support
-H, --hogan add hogan.js engine support
-c, --css <engine> add stylesheet <engine> support
(less|stylus|compass|sass) (defaults to plain css)
--git add .gitignore
-f, --force force on non-empty directory
我们可能不想每次运行express-generator应用程序或任何提供命令行工具的其他应用程序时都输入./node_modules/.bin/express,尤其是对于其他应用程序。请参考第三章中关于将此目录添加到PATH变量的讨论,标题为Node.js 模块。
现在您已经在fibonacci目录中安装了express-generator,请使用它来设置空白框架应用程序:
$ ./node_modules/.bin/express --view=hbs --git .
destination is not empty, continue? [y/N] y
create : .
create : ./package.json
create : ./app.js
create : ./.gitignore
create : ./public
create : ./routes
create : ./routes/index.js
create : ./routes/users.js
create : ./views
create : ./views/index.hbs
create : ./views/layout.hbs
create : ./views/error.hbs
create : ./bin
create : ./bin/www
create : ./public/javascripts
create : ./public/images
create : ./public/stylesheets
create : ./public/stylesheets/style.css
install dependencies:
$ cd . && npm install
run the app:
$ DEBUG=fibonacci:* npm start
$ npm uninstall express-generator
added 83 packages and removed 5 packages in 4.104s
这为我们创建了一大批文件,我们将在稍后进行讲解。node_modules目录中仍然有express-generator模块,现在它不再有用。我们可以简单地将其留在那里并忽略它,或者我们可以将其添加到它生成的package.json文件的devDependencies中。或者,我们可以像下面这样卸载它。
下一步是按照指示运行空白应用程序。显示的命令npm start依赖于提供的package.json文件的一部分:
"scripts": {
"start": "node ./bin/www"
},
npm 工具支持脚本,这是一种自动化各种任务的方式。本书中我们将利用这一功能来完成各种任务。当十二要素应用程序模型建议自动化所有管理任务时,npm 脚本功能是一个出色的机制来实现这一点。大多数 npm 脚本都是通过 npm run scriptName 命令运行的,但 start 命令被 npm 明确识别,可以像之前展示的那样运行。
步骤如下:
-
安装依赖项
npm install。 -
使用
npm start命令启动应用程序。 -
可选地修改
package.json以始终以调试模式运行。
要安装依赖项并运行应用程序,请输入以下命令:
$ npm install
$ DEBUG=fibonacci:* npm start
> fibonacci@0.0.0 start /Users/David/chap04/fibonacci
> node ./bin/www
fibonacci:server Listening on port 3000 +0ms
以这种方式设置 DEBUG 变量会打开一些调试输出,其中包括关于监听端口 3000 的消息。否则,我们不会被告知这些信息。这种语法是在 Bash shell 中运行带有环境变量的命令时使用的。如果你遇到错误,尝试只运行 "npm start",然后阅读下一节。
我们可以修改提供的 npm start 脚本,使其始终以调试模式运行应用程序。将 scripts 部分更改为以下内容:
"scripts": {
"start": "DEBUG=fibonacci:* node ./bin/www"
},
由于输出表明它正在监听端口 3000,我们将浏览器指向
在 http://localhost:3000/ 打开浏览器并查看以下输出:

在 Windows cmd.exe 命令行中设置环境变量
如果你在 Windows 上,之前的示例可能会因为 DEBUG 命令未知而失败。问题在于 Windows 的 shell,即 cmd.exe 程序,不支持 Bash 命令行结构。
在命令行开始处添加 VARIABLE=value 是一些 shell 的特性,如 Linux 和 macOS 上的 Bash。它只为正在执行的命令设置该环境变量,并且是临时覆盖特定命令环境变量的非常方便的方式。
显然,如果要让 package.json 在不同的操作系统上可用,就需要一个解决方案。
看起来最好的解决方案是 npm 仓库中的 cross-env 包,请参阅:www.npmjs.com/package/cross-env。安装此包后,package.json 中的 scripts 部分的命令可以设置环境变量,就像在 Linux/macOS 上的 Bash 一样。使用方法如下:
"scripts": {
"start": "cross-env DEBUG=fibonacci:* node ./bin/www"
},
"dependencies": {
...
"cross-env": "5.1.x"
}
然后命令执行如下:
C:\Users\david\Documents\chap04\fibonacci>npm install
... output from installing packages
C:\Users\david\Documents\chap04\fibonacci>npm run start
> fibonacci@0.0.0 start C:\Users\david\Documents\chap04\fibonacci
> cross-env DEBUG=fibonacci:* node ./bin/www
fibonacci:server Listening on port 3000 +0ms
GET / 304 90.597 ms - -
GET /stylesheets/style.css 304 14.480 ms - -
GET /fibonacci 200 84.726 ms - 503
GET /stylesheets/style.css 304 4.465 ms - -
GET /fibonacci?fibonum=22 500 1069.049 ms - 327
GET /stylesheets/style.css 304 2.601 ms - -
漫步默认 Express 应用程序
我们有一个工作状态为空的 Express 应用程序;让我们看看为我们生成的内容。我们这样做是为了在开始编写我们的 Fibonacci 应用程序之前熟悉 Express。
由于我们使用了 --view=hbs 选项,这个应用程序被设置为使用 Handlebars.js 模板引擎。Handlebars 是在 Mustache 之上构建的,最初是为在浏览器中使用而设计的;更多信息请参阅其主页 handlebarsjs.com/。这里显示的版本是为与 Express 一起使用而打包的,并在 github.com/pillarjs/hbs 上有文档。
一般而言,模板引擎使得将数据插入生成的网页成为可能。ExpressJS Wiki 列出了 Express 的模板引擎列表 github.com/expressjs/express/wiki#template-engines。
views 目录包含两个文件,error.hbs 和 index.hbs。hbs 扩展名用于 Handlebars 文件。另一个文件 layout.hbs 是默认页面布局。Handlebars 有几种配置布局模板和部分(可以在任何地方包含的代码片段)的方法。
routes 目录包含初始路由设置,即处理特定 URL 的代码。我们稍后会修改这些。
public 目录将包含应用程序不生成但直接发送到浏览器的资源。最初安装的是 CSS 文件,public/stylesheets/style.css。
package.json 文件包含我们的依赖项和其他元数据。
bin 目录包含我们之前看到的 www 脚本。这是一个 Node.js 脚本,它初始化 HTTPServer 对象,开始监听 TCP 端口,并调用我们将要讨论的最后一个文件,app.js。这些脚本初始化 Express,连接路由模块,并执行其他操作。
在 www 和 app.js 脚本中有很多事情在进行,所以让我们从应用程序初始化开始。让我们首先看看 app.js 中的几行:
var express = require('express');
...
var app = express();
...
module.exports = app;
这意味着 app.js 是一个导出 express 模块返回的对象的模块。然而,它并没有启动 HTTP 服务器对象。
现在,让我们转向 www 脚本。首先要注意的是,它从这一行开始:
#!/usr/bin/env node
这是一个 Unix/Linux 技巧,用于创建命令脚本。它指示使用 node 命令作为脚本运行以下内容。换句话说,我们有 Node.js 代码,并指示操作系统使用 Node.js 运行时执行该代码:
$ ls -l bin/www
-rwx------ 1 david staff 1595 Feb 5 1970 bin/www
我们还可以看到脚本是由 express-generator 使其可执行的。
它以如下方式调用 app.js 模块:
var app = require('../app');
...
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
...
var server = http.createServer(app);
...
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
我们可以看到端口 3000 的来源;它是 normalizePort 函数的参数。我们还可以看到设置 PORT 环境变量将覆盖默认端口 3000。最后,我们看到在这里创建了 HTTP 服务器对象,并指示它使用在 app.js 中创建的应用程序实例。尝试运行以下命令:
$ PORT=4242 DEBUG=fibonacci:* npm start
应用程序现在告诉您它正在端口 4242 上监听,在那里您可以思考生命的意义。
接下来,将 app 对象传递给 http.createServer()。查看 Node.js 文档告诉我们这个函数接受一个 requestListener,它只是一个接受我们之前看到的 request 和 response 对象的函数。因此,app 对象就是一个这样的函数。
最后,www 脚本启动服务器,监听我们指定的端口。
现在让我们更详细地走一遍 app.js:
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
这告诉 Express 在 views 目录中查找模板,并使用 EJS 模板引擎。
app.set 函数用于设置应用程序属性。在我们浏览过程中,将很有用查看 API 文档 (expressjs.com/en/4x/api.html)。
接下来是一系列 app.use 调用:
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', routes);
app.use('/users', users);
app.use 函数挂载中间件函数。这是我们很快将要讨论的 Express 行话中的一个重要部分。目前,让我们说中间件函数是在处理路由时执行的。这意味着这里命名的所有功能都在 app.js 中启用:
-
使用 Morgan 请求记录器启用日志记录。访问
www.npmjs.com/package/morgan查看其文档。 -
body-parser模块处理解析 HTTP 请求体。访问www.npmjs.com/package/body-parser查看其文档。 -
cookie-parser模块用于解析 HTTP 钩子。访问www.npmjs.com/package/cookie-parser查看其文档。 -
配置了一个静态文件网络服务器,用于在
public目录中提供资产文件。 -
两个路由模块
routes和users,用于设置哪些函数处理哪些 URL。
Express 中间件
让我们通过讨论 app.js 中的中间件函数对应用程序做了什么来完善对 app.js 的演练。脚本末尾有一个示例:
app.use(function(req, res, next) {
var err = new Error('Not found');
err.status = 404;
next(err);
});
注释说明 捕获 404 并转发到错误处理器。正如你可能知道的,HTTP 404 状态码表示请求的资源未找到。我们需要告诉用户他们的请求没有得到满足,也许可以展示一群鸟从海洋中拉出鲸鱼的图片。这是这样做的第一步。在到达报告此错误的最后一步之前,你必须了解中间件是如何工作的。
我们确实有一个中间件函数就在眼前。请参阅其文档 expressjs.com/en/guide/writing-middleware.html。
中间件函数接受三个参数。前两个,request 和 response,等同于我们之前看到的 Node.js HTTP 请求对象的 request 和 response。然而,Express 通过添加额外的数据和功能扩展了这些对象。最后一个,next,是一个回调函数,用于控制请求-响应周期何时结束,并且可以用来将错误发送到中间件管道。
进入请求首先由第一个中间件函数处理,然后是下一个,再下一个,依此类推。每次请求需要传递给中间件函数链时,都会调用next函数。如果next函数被传递一个错误对象,如这里所示,则表示正在发出错误信号。否则,控制权简单地传递给链中的下一个中间件函数。
如果没有调用next会发生什么?HTTP 请求将会挂起,因为没有给出响应。中间件函数在调用response对象上的函数时给出响应,例如res.send或res.render。
例如,考虑app.js的包含:
app.get('/', function(req, res) { res.send('Hello World!'); });
这不会调用next,而是调用res.send。这是结束请求-响应循环的正确方法,通过向请求发送响应(res.send)。如果没有调用next或res.send,则请求永远不会得到响应。
因此,中间件函数执行以下四件事情之一:
-
执行自己的业务逻辑。前面展示的请求记录器中间件就是一个例子。
-
修改请求或响应对象。
body-parser和cookie-parser这样做,寻找要添加到request对象中的数据。 -
调用
next以继续到下一个中间件函数,或者发出错误信号。 -
发送响应,结束循环。
中间件执行顺序取决于它们添加到app对象的顺序。首先添加的将被首先执行,依此类推。
中间件和请求路径
我们已经看到了两种中间件函数。在第一种中,第一个参数是处理函数。在另一种中,第一个参数是包含 URL 片段的字符串,第二个参数是处理函数。
实际上发生的事情是app.use有一个可选的第一个参数:中间件挂载的路径。路径是与请求 URL 的模式匹配,如果 URL 与模式匹配,则触发给定的函数。甚至还有一个方法可以在 URL 中提供命名参数:
app.use('/user/profile/:id', function(req, res, next) {
userProfiles.lookup(req.params.id, (err, profile) => {
if (err) return next(err);
// do something with the profile
// Such as display it to the user
res.send(profile.display());
});
});
此路径规范有一个模式:id,值将落在req.params.id上。在这个例子中,我们建议一个用户配置文件服务,并且对于这个 URL,我们想要显示有关命名用户的详细信息。
使用中间件函数的另一种方式是在特定的 HTTP 请求方法上。使用app.use,任何请求都会被匹配,但事实上,GET请求应该与POST请求有不同的行为。你调用app.METHOD,其中METHOD与 HTTP 请求动词之一匹配。也就是说,app.get匹配GET方法,app.post匹配POST,依此类推。
最后,我们到达router对象。这是一种用于根据其 URL 显式路由请求的中间件。看看routes/users.js:
var express = require('express');
var router = express.Router();
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
module.exports = router;
我们有一个模块,其exports对象是一个路由器。这个路由器只有一个路由,但它可以有任意数量的你认为合适的路由。
在app.js中,添加方式如下:
app.use('/users', users);
我们讨论的所有 app 对象的函数都适用于 router 对象。如果请求匹配,路由器会接收到请求以执行其自己的处理函数链。一个重要的细节是,当请求传递给路由器实例时,请求 URL 前缀会被移除。
你会注意到 users.js 中的 router.get 匹配 '/',并且这个路由器被挂载在 '/users' 上。实际上,那个 router.get 也匹配 /users,但由于前缀被移除,它指定了 '/'。这意味着路由器可以挂载在不同的路径前缀上,而无需更改路由器实现。
错误处理
现在,我们终于可以回到生成的 app.js 文件,404 错误页面未找到,以及应用程序可能想要向用户显示的任何其他错误。
一个中间件函数通过向 next 函数调用传递一个值来指示错误。一旦 Express 看到错误,它将跳过任何剩余的非错误路由,并且只将其传递给错误处理器。错误处理器函数的签名与我们之前看到的不同。
在我们正在检查的 app.js 文件中,这是我们的错误处理器:
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
错误处理器函数接受四个参数,其中 err 是添加到熟悉的 req、res 和 next 参数中的。对于这个处理器,我们使用 res.status 来设置 HTTP 响应状态码,并使用 res.render 来使用 views/error.hbs 模板格式化一个 HTML 响应。res.render 函数接受数据,并通过模板将其渲染成 HTML。
这意味着我们应用程序中的任何错误都会在这里处理,绕过任何剩余的中间件函数。
使用 Express 应用程序计算斐波那契序列
斐波那契数列是整数序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
列表中的每一项都是该列表中前两项之和。这个序列是在 1202 年由比萨的莱昂纳多(也被称为斐波那契)发明的。计算斐波那契序列中项的一个方法是我们之前展示的递归算法。我们将创建一个使用斐波那契实现的 Express 应用程序,然后探讨几种减轻计算密集型算法性能问题的方法。
让我们从上一步创建的空白应用程序开始。我们让您将这个应用程序命名为 Fibonacci 有原因。我们是有远见的。
在 app.js 文件的最顶部部分,进行以下更改:
const express = require('express');
const hbs = require('hbs');
const path = require('path');
const favicon = require('serve-favicon');
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const index = require('./routes/index');
const fibonacci = require('./routes/fibonacci');
const app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', index);
app.use('/fibonacci', fibonacci);
这大部分是 express-generator 给我们的。var 语句已被更改为 const,为了那一点点额外的舒适感。我们显式地导入了 hbs 模块,以便进行一些配置。我们还导入了用于斐波那契的路由器模块,我们将在稍后看到。
对于 Fibonacci 应用程序,我们不需要支持用户,因此删除了那个路由模块。我们将在下面展示的 fibonacci 模块用于查询一个数字,我们将计算其斐波那契数。
在顶级目录中,创建一个名为 math.js 的文件,包含以下极其简单的斐波那契实现:
exports.fibonacci = function(n) {
if (n === 0) return 0;
else if (n === 1 || n === 2) return 1;
else return exports.fibonacci(n-1) + exports.fibonacci(n-2);
};
在 views 目录中,查看 express-generator 创建的名为 layout.hbs 的文件:
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
{{{body}}}
</body>
</html>
此文件包含我们将用于 HTML 页面的结构。根据 Handlebars 语法,我们看到 {{title}} 出现在 HTML 的 title 标签内。这意味着当我们调用 res.render 时,我们应该提供一个 title 属性。{{{body}}} 标签是视图模板内容所在的位置。
将 views/index.hbs 修改为只包含以下内容:
<h1>{{title}}</h1>
{{> navbar}}
这充当我们应用程序的前页。它将被插入到 layout.hbs 中的 {{{body}}} 位置。标记 {{> navbar}} 指的是名为 navbar 的部分。之前,我们配置了一个名为 partials 的目录来保存部分。现在让我们创建一个文件,partials/navbar.html,包含以下内容:
<div class='navbar'>
<p><a href='/'>home</a> | <a href='/fibonacci'>Fibonacci's</a></p>
</div>
这将作为一个包含在每一页上的导航栏。
创建一个文件,views/fibonacci.hbs,包含以下代码:
<h1>{{title}}</h1>
{{> navbar}}
{{#if fiboval}}
<p>Fibonacci for {{fibonum}} is {{fiboval}}</p>
<hr/>
{{/if}}
<p>Enter a number to see its' Fibonacci number</p>
<form name='fibonacci' action='/fibonacci' method='get'>
<input type='text' name='fibonum' />
<input type='submit' value='Submit' />
</form>
记住 views 目录中的文件是模板,数据将渲染到这些模板中。它们服务于 模型-视图-控制器(MVC)模式的视图方面,因此目录名为 views。
在 routes 目录下,删除 user.js 模块。它是 Express 框架生成的,但我们在本应用程序中不会使用它。
在 routes/index.js 中,将路由函数更改为以下内容:
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: "Welcome to the Fibonacci Calculator" });
});
传递给 res.render 的匿名对象包含我们提供给布局和视图模板的数据值。
然后,最后,在 routes 目录下,创建一个名为 fibonacci.js 的文件,包含以下代码:
const express = require('express');
const router = express.Router();
const math = require('../math');
router.get('/', function(req, res, next) {
if (req.query.fibonum) {
// Calculate directly in this server
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fibonum: req.query.fibonum,
fiboval: math.fibonacci(req.query.fibonum)
});
} else {
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fiboval: undefined
});
}
});
module.exports = router;
package.json 已经设置好,我们可以使用 npm start 来运行脚本,并始终启用调试消息。现在我们准备这样做:
$ npm start
> fibonacci@0.0.0 start /Users/david/chap04/fibonacci
> DEBUG=fibonacci:* node ./bin/www
fibonacci:server Listening on port 3000 +0ms
如其所指,您可以通过 http://localhost:3000/ 访问并查看我们有什么:

此页面是从 views/index.hbs 模板渲染的。只需点击斐波那契的链接即可转到下一页,当然这一页也是从 views/fibonacci.hbs 模板渲染的。在该页面上,您将能够输入一个数字,点击提交按钮,并获得答案(提示:如果您想在一个合理的时间内得到答案,请选择小于 40 的数字):

让我们遍历应用程序来讨论它是如何工作的。
在 app.js 中有两个路由:一个是 / 路由,由 routes/index.js 处理,另一个是 /fibonacci 路由,由 routes/fibonacci.js 处理。
res.render 函数使用提供的数据值渲染命名模板,并将结果作为 HTTP 响应发出。对于本应用程序的首页,渲染代码(routes/index.js)和模板(views/index.hbs)并不多,所有的动作都在斐波那契页面上发生。
views/fibonacci.hbs 模板包含一个表单,用户可以在其中输入一个数字。因为它是一个 GET 表单,所以当用户点击提交按钮时,浏览器将对 /fibonacci URL 发出 HTTP GET 请求。区分 /fibonacci 上的一个 GET 请求与另一个请求的是 URL 是否包含名为 fibonum 的查询参数。当用户首次进入页面时,没有 fibonum,因此没有要计算的内容。在用户输入数字并点击提交后,就有 fibonum 和要计算的内容了。
Express 自动解析查询参数,使它们作为 req.query 可用。这意味着 routes/fibonacci.js 可以快速检查是否存在 fibonum。如果存在,它将调用 fibonacci 函数来计算值。
之前,我们要求您输入一个小于 40 的数字。现在请输入一个更大的数字,例如 50,但请去喝杯咖啡,因为这需要一段时间来计算。或者继续阅读下一节,我们将开始讨论计算密集型代码的使用。
计算密集型代码和 Node.js 事件循环
这个斐波那契示例故意设计得效率低下,以展示对您应用的重要考虑。当运行长时间计算时,Node.js 的事件循环会发生什么?为了看到效果,请打开两个浏览器窗口,每个窗口都打开到斐波那契页面。在一个窗口中,输入数字 55 或更大,在另一个窗口中,输入 10。注意第二个窗口会冻结,如果您让它运行足够长的时间,最终两个窗口都会弹出答案。发生的情况是 Node.js 的事件循环因为斐波那契算法正在运行而被阻塞,无法处理事件。
由于 Node.js 有一个单独的执行线程,处理请求依赖于请求处理程序快速返回到事件循环。通常,异步编码风格确保事件循环定期执行。
这甚至适用于从地球另一端的服务器加载数据的请求,因为异步 I/O 是非阻塞的,并且控制权会迅速返回到事件循环。我们选择的简单斐波那契函数不适合这个模型,因为它是一个长时间运行的阻塞操作。这种类型的事件处理程序阻止系统处理请求,并阻止 Node.js 做它应该做的事情,即成为一个极快的 Web 服务器。
在这种情况下,长时间响应问题很明显。响应时间迅速攀升到您可以休假去西藏,也许在响应斐波那契数字的时间内,您可能会在秘鲁转世为一只骆马!
为了更清楚地看到这一点,创建一个名为 fibotimes.js 的文件,包含以下代码:
const math = require('./math');
const util = require('util');
for (var num = 1; num < 80; num++) {
let now = new Date().toISOString();
console.log(`${now} Fibonacci for ${num} = ${math.fibonacci(num)}`);
}
现在运行它。您将得到以下输出:
$ node fibotimes.js
2017-12-10T23:04:42.342Z Fibonacci for 1 = 1
2017-12-10T23:04:42.345Z Fibonacci for 2 = 1
2017-12-10T23:04:42.345Z Fibonacci for 3 = 2
2017-12-10T23:04:42.345Z Fibonacci for 4 = 3
2017-12-10T23:04:42.345Z Fibonacci for 5 = 5
...
2017-12-10T23:04:42.345Z Fibonacci for 10 = 55
2017-12-10T23:04:42.345Z Fibonacci for 11 = 89
2017-12-10T23:04:42.345Z Fibonacci for 12 = 144
2017-12-10T23:04:42.345Z Fibonacci for 13 = 233
2017-12-10T23:04:42.345Z Fibonacci for 14 = 377
...
2017-12-10T23:04:44.072Z Fibonacci for 40 = 102334155
2017-12-10T23:04:45.118Z Fibonacci for 41 = 165580141
2017-12-10T23:04:46.855Z Fibonacci for 42 = 267914296
2017-12-10T23:04:49.723Z Fibonacci for 43 = 433494437
2017-12-10T23:04:54.218Z Fibonacci for 44 = 701408733
...
2017-12-10T23:06:07.531Z Fibonacci for 48 = 4807526976
2017-12-10T23:07:08.056Z Fibonacci for 49 = 7778742049
^C
这可以快速计算出斐波那契数列的前 40 个左右的成员,但到了第 40 个成员之后,每个结果都需要几秒钟的时间,并且从那里开始迅速下降。在依赖于快速返回事件循环的单线程系统中执行此类代码是不可行的。包含此类代码的 Web 服务会给用户带来较差的性能。
在 Node.js 中解决这个问题的两种通用方法:
-
算法重构:也许,就像我们选择的斐波那契函数一样,你的某个算法可能不是最优的,并且可以被重写以使其更快。或者,如果不是更快,它可以通过事件循环分派回调来分割。我们将在稍后查看这样一个方法。
-
创建后端服务:你能想象一个专门用于计算斐波那契数的后端服务器吗?好吧,可能不会,但实现后端服务器以从前端服务器卸载工作是非常常见的,我们将在本章末尾实现一个后端斐波那契服务器。
算法重构
为了证明我们手头有一个人为的问题,这里有一个更高效的 Fibonacci 函数:
exports.fibonacciLoop = function(n) {
var fibos = [];
fibos[0] = 0;
fibos[1] = 1;
fibos[2] = 1;
for (var i = 3; i <= n; i++) {
fibos[i] = fibos[i-2] + fibos[i-1];
}
return fibos[n];
}
如果我们将 math.fibonacci 的调用替换为 math.fibonacciLoop,则 fibotimes 程序将运行得更快。即使这也不是最有效的实现;例如,一个简单的预配查找表在内存消耗上有所牺牲,但速度要快得多。
按照以下方式编辑 fibotimes.js 并重新运行脚本。数字会飞快地闪过,让你的头都要转晕了:
for (var num = 1; num < 8000; num++) {
let now = new Date().toISOString();
console.log(`${now} Fibonacci for ${num} = ${math.fibonacciLoop(num)}`);
}
有些算法并不容易优化,计算结果仍然需要很长时间。在本节中,我们将探讨如何处理低效的算法,因此我们将坚持使用低效的斐波那契实现。
可以将计算分成几块,然后通过事件循环调度这些块的计算。将以下代码添加到 math.js 中:
exports.fibonacciAsync = function(n, done) {
if (n === 0) done(undefined, 0);
else if (n === 1 || n === 2) done(undefined, 1);
else {
setImmediate(() => {
exports.fibonacciAsync(n-1, (err, val1) => {
if (err) done(err);
else setImmediate(() => {
exports.fibonacciAsync(n-2, (err, val2) => {
if (err) done(err);
else done(undefined, val1+val2);
});
});
});
});
}
};
这将 fibonacci 函数从异步函数转换为传统的基于回调的异步函数。我们在计算的每个阶段使用 setImmediate,以确保事件循环定期执行,并且服务器可以轻松处理其他请求,同时进行计算。这并没有减少所需的计算量;这仍然是愚蠢且低效的斐波那契算法。我们所做的只是将计算分散到事件循环中。
在 fibotimes.js 中,我们可以使用以下代码:
const math = require('./math');
const util = require('util');
(async () => {
for (var num = 1; num < 8000; num++) {
await new Promise((resolve, reject) => {
math.fibonacciAsync(num, (err, fibo) => {
if (err) reject(err);
else {
let now = new Date().toISOString();
console.log(`${now} Fibonacci for ${num} =
${fibo}`);
resolve();
}
})
})
}
})().catch(err => { console.error(err); });
这个版本的 fibotimes.js 执行的是相同的操作,我们只需输入 node fibotimes。然而,使用 fibonacciAsync 将需要在服务器上进行更改。
因为它是一个异步函数,我们需要更改我们的路由代码。创建一个新文件,命名为 routes/fibonacci-async1.js,包含以下内容:
const express = require('express');
const router = express.Router();
const math = require('../math');
router.get('/', function(req, res, next) {
if (req.query.fibonum) {
// Calculate using async-aware function, in this server
math.fibonacciAsync(req.query.fibonum, (err, fiboval) => {
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fibonum: req.query.fibonum,
fiboval: fiboval
});
});
} else {
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fiboval: undefined
});
}
});
module.exports = router;
这与之前相同,只是为了异步斐波那契计算而重写。
在 app.js 中,进行以下更改以调整应用程序的连接:
// const fibonacci = require('./routes/fibonacci');
const fibonacci = require('./routes/fibonacci-async1');
通过这个更改,服务器在计算大斐波那契数时不再冻结。当然,计算仍然需要很长时间,但至少应用程序的其他用户不会被阻塞。
您可以通过再次在应用程序中打开两个浏览器窗口来验证这一点。在一个窗口中输入 60,在另一个窗口中开始请求较小的斐波那契数。与原始的 fibonacci 函数不同,使用 fibonacciAsync 允许两个窗口都给出答案,尽管如果您真的在第一个窗口中输入了 60,您不妨去西藏度假三个月:

选择如何最佳优化您的代码以及处理您可能有的任何长时间运行的计算取决于您自己,以及您的特定算法。
发送 HTTP 客户端请求
减少计算密集型代码的另一种方法是将其推送到后端进程。为了探索这种策略,我们将使用 HTTP 客户端对象从后端斐波那契服务器请求计算。然而,在我们查看这一点之前,让我们首先一般性地讨论使用 HTTP 客户端对象。
Node.js 包含一个 HTTP 客户端对象,这对于发送 HTTP 请求非常有用。它能够发出任何类型的 HTTP 请求。在本节中,我们将使用 HTTP 客户端对象来发送类似于调用 表示状态传输(REST)Web 服务的 HTTP 请求。
让我们从一些受 wget 或 curl 命令启发的代码开始,这些代码用于发送 HTTP 请求并显示结果。创建一个名为 wget.js 的文件,包含以下代码:
const http = require('http');
const url = require('url');
const util = require('util');
const argUrl = process.argv[2];
const parsedUrl = url.parse(argUrl, true);
// The options object is passed to http.request
// telling it the URL to retrieve
const options = {
host: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname,
method: 'GET'
};
if (parsedUrl.search) options.path += "?"+parsedUrl.search;
const req = http.request(options);
// Invoked when the request is finished
req.on('response', res => {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + util.inspect(res.headers));
res.setEncoding('utf8');
res.on('data', chunk => { console.log('BODY: ' + chunk); });
res.on('error', err => { console.log('RESPONSE ERROR: ' + err); });
});
// Invoked on errors
req.on('error', err => { console.log('REQUEST ERROR: ' + err); });
req.end();
您可以按以下方式运行脚本:
$ node wget.js http://example.com
STATUS: 200
HEADERS: { 'accept-ranges': 'bytes',
'cache-control': 'max-age=604800',
'content-type': 'text/html',
date: 'Sun, 10 Dec 2017 23:40:44 GMT',
etag: '"359670651"',
expires: 'Sun, 17 Dec 2017 23:40:44 GMT',
'last-modified': 'Fri, 09 Aug 2013 23:54:35 GMT',
server: 'ECS (rhv/81A7)',
vary: 'Accept-Encoding',
'x-cache': 'HIT',
'content-length': '1270',
connection: 'close' }
BODY: <!doctype html>
<html>
...
打印输出中还有更多内容,即 http://example.com/ 页面的 HTML。wget.js 的目的是发送 HTTP 请求并显示响应的详细信息。HTTP 请求是通过 http.request 方法发起的,如下所示:
var http = require('http');
var options = {
host: 'example.com',
port: 80,
path: null,
method: 'GET'
};
var request = http.request(options);
request.on('response', response => {
...
});
options 对象描述了要发出的请求,当响应到达时,会调用 callback 函数。options 对象相当直接,其中 host、port 和 path 字段指定了请求的 URL。method 字段必须是 HTTP 动词之一(GET、PUT、POST 等)。您还可以为 HTTP 请求提供 headers 数组。例如,您可能需要提供 cookie:
var options = {
headers: { 'Cookie': '.. cookie value' }
};
response 对象本身是一个 EventEmitter,它发出 data 和 error 事件。当数据到达时,会调用 data 事件,而 error 事件当然是在出现错误时调用。
请求对象是一个 WritableStream,这对于包含数据的 HTTP 请求非常有用,例如 PUT 或 POST。这意味着 request 对象有一个 write 函数,可以将数据写入请求者。HTTP 请求中的数据格式由标准 多用途互联网邮件扩展(MIME)指定,该标准最初是为了让我们有更好的电子邮件体验。大约在 1992 年,万维网社区与 MIME 标准委员会合作,该委员会正在开发一种用于多部分、多媒体丰富的电子邮件的格式。如今,收到看起来很花哨的电子邮件已经变得司空见惯,以至于人们可能没有意识到电子邮件曾经只是纯文本。MIME 类型被开发出来以描述每份数据的格式,而万维网社区采用了这种格式用于网页。例如,HTML 表单将以 multipart/form-data 的内容类型进行提交。
从 Express 应用程序中调用 REST 后端服务
现在我们已经看到了如何进行 HTTP 客户端请求,我们可以看看如何在 Express 网络应用程序中执行 REST 查询。这实际上意味着向后端服务器发送一个 HTTP GET 请求,该服务器通过 URL 返回斐波那契数。为此,我们将重构 Fibonacci 应用程序,以创建一个从应用程序中调用的斐波那契服务器。虽然这对于计算斐波那契数来说有点过度,但它让我们能够查看在 Express 中实现多层应用程序堆栈的基本方法。
本质上,调用 REST 服务是一个异步操作。这意味着调用 REST 服务将涉及一个函数调用来发起请求,以及一个回调函数来接收响应。REST 服务通过 HTTP 访问,因此我们将使用 HTTP 客户端对象来完成此操作。
使用 Express 实现简单的 REST 服务器
虽然 Express 拥有强大的模板系统,使其适合向浏览器提供 HTML 网页,但它也可以用来实现简单的 REST 服务。我们之前展示的参数化 URL(/user/profile/:id)可以像 REST 调用的参数一样使用。Express 使得返回编码为 JSON 的数据变得容易。
现在,创建一个名为 fiboserver.js 的文件,包含以下代码:
const math = require('./math');
const express = require('express');
const logger = require('morgan');
const app = express();
app.use(logger('dev'));
app.get('/fibonacci/:n', (req, res, next) => {
math.fibonacciAsync(Math.floor(req.params.n), (err, val) => {
if (err) next('FIBO SERVER ERROR ' + err);
else res.send({ n: req.params.n, result: val });
});
});
app.listen(process.env.SERVERPORT);
这是一个简化版的 Express 应用程序,它直接提供斐波那契计算服务。它支持的一个路由使用我们之前已经使用过的相同函数来处理斐波那契计算。
这是我们第一次看到 res.send 的使用。它是一种灵活的方式来发送响应,可以接受一个包含头部值的数组(用于 HTTP 响应头部),以及一个 HTTP 状态码。在这里的使用中,它会自动检测对象,将其格式化为 JSON 文本,并使用正确的 Content-Type 发送。
在 package.json 的 scripts 部分添加以下内容:
"server": "SERVERPORT=3002 node ./fiboserver"
这自动启动了我们的斐波那契服务。
注意,我们通过环境变量指定 TCP/IP 端口,并在应用程序中使用该变量。这是十二因素应用程序模型的一个方面:将配置数据放在环境中。
现在,让我们运行它:
$ npm run server
> fibonacci@0.0.0 server /Users/David/chap04/fibonacci
> SERVERPORT=3002 node ./fiboserver
然后,在另一个命令窗口中,我们可以使用curl程序向该服务发出一些请求:
$ curl -f http://localhost:3002/fibonacci/10
{"n":"10","result":55}
$ curl -f http://localhost:3002/fibonacci/11
{"n":"11","result":89}
$ curl -f http://localhost:3002/fibonacci/12
{"n":"12","result":144}
在服务运行的那个窗口中,我们将看到GET请求的日志以及每个请求处理所需的时间:
$ npm run server
> fibonacci@0.0.0 server /Users/David/chap04/fibonacci
> SERVERPORT=3002 node ./fiboserver
GET /fibonacci/10 200 0.393 ms - 22
GET /fibonacci/11 200 0.647 ms - 22
GET /fibonacci/12 200 0.772 ms - 23
现在,让我们创建一个简单的客户端程序,fiboclient.js,以编程方式调用 Fibonacci 服务:
const http = require('http');
[
"/fibonacci/30", "/fibonacci/20", "/fibonacci/10",
"/fibonacci/9", "/fibonacci/8", "/fibonacci/7",
"/fibonacci/6", "/fibonacci/5", "/fibonacci/4",
"/fibonacci/3", "/fibonacci/2", "/fibonacci/1"
].forEach(path => {
console.log(`${new Date().toISOString()} requesting ${path}`);
var req = http.request({
host: "localhost",
port: process.env.SERVERPORT,
path: path,
method: 'GET'
}, res => {
res.on('data', chunk => {
console.log(`${new Date().toISOString()} BODY: ${chunk}`);
});
});
req.end();
});
然后,在package.json中,在scripts部分添加以下内容:
"scripts": {
"start": "node ./bin/www",
"server": "SERVERPORT=3002 node ./fiboserver" ,
"client": "SERVERPORT=3002 node ./fiboclient"
}
然后,运行客户端应用程序:
$ npm run client
> fibonacci@0.0.0 client /Users/David/chap04/fibonacci
> SERVERPORT=3002 node ./fiboclient
2017-12-11T00:41:14.857Z requesting /fibonacci/30
2017-12-11T00:41:14.864Z requesting /fibonacci/20
2017-12-11T00:41:14.865Z requesting /fibonacci/10
2017-12-11T00:41:14.865Z requesting /fibonacci/9
2017-12-11T00:41:14.866Z requesting /fibonacci/8
2017-12-11T00:41:14.866Z requesting /fibonacci/7
2017-12-11T00:41:14.866Z requesting /fibonacci/6
2017-12-11T00:41:14.866Z requesting /fibonacci/5
2017-12-11T00:41:14.866Z requesting /fibonacci/4
2017-12-11T00:41:14.866Z requesting /fibonacci/3
2017-12-11T00:41:14.867Z requesting /fibonacci/2
2017-12-11T00:41:14.867Z requesting /fibonacci/1
2017-12-11T00:41:14.884Z BODY: {"n":"9","result":34}
2017-12-11T00:41:14.886Z BODY: {"n":"10","result":55}
2017-12-11T00:41:14.891Z BODY: {"n":"6","result":8}
2017-12-11T00:41:14.892Z BODY: {"n":"7","result":13}
2017-12-11T00:41:14.893Z BODY: {"n":"8","result":21}
2017-12-11T00:41:14.903Z BODY: {"n":"3","result":2}
2017-12-11T00:41:14.904Z BODY: {"n":"4","result":3}
2017-12-11T00:41:14.905Z BODY: {"n":"5","result":5}
2017-12-11T00:41:14.910Z BODY: {"n":"2","result":1}
2017-12-11T00:41:14.911Z BODY: {"n":"1","result":1}
2017-12-11T00:41:14.940Z BODY: {"n":"20","result":6765}
2017-12-11T00:41:18.200Z BODY: {"n":"30","result":832040}
我们正在逐步将 REST 服务添加到 Web 应用程序中。到目前为止,我们已经证明了几件事情,其中之一就是能够在我们的程序中调用 REST 服务。
我们无意中展示了一个与长时间运行的计算相关的问题。你会注意到请求是从大到小进行的,但结果却以一个非常不同的顺序出现。为什么?这是因为每个请求的处理时间,以及我们正在使用的低效算法。计算时间增加得足够多,以确保较大的请求值需要足够多的处理时间来反转顺序。
发生的事情是fiboclient.js立即发送所有请求,然后每个请求都等待响应到达。因为服务器正在使用fibonacciAsync,它将同时处理所有响应。计算最快的值将首先准备好。当响应到达客户端时,匹配的响应处理程序被触发,在这种情况下,结果打印到控制台。结果将在准备好时到达,而不是提前一毫秒。
对 Fibonacci 应用程序进行 REST 重构
现在我们已经实现了一个基于 REST 的服务器,我们可以回到Fibonacci应用程序,应用我们所学的内容来改进它。我们将从fiboclient.js中提取一些代码并将其移植到应用程序中。创建一个新文件,routes/fibonacci-rest.js,并包含以下代码:
const express = require('express');
const router = express.Router();
const http = require('http');
const math = require('../math');
router.get('/', function(req, res, next) {
if (req.query.fibonum) {
var httpreq = http.request({
host: "localhost",
port: process.env.SERVERPORT,
path: "/fibonacci/"+Math.floor(req.query.fibonum),
method: 'GET'
});
httpreq.on('response', response => {
response.on('data', chunk => {
var data = JSON.parse(chunk);
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fibonum: req.query.fibonum,
fiboval: data.result
});
});
response.on('error', err => { next(err); });
});
httpreq.on('error', err => { next(err); });
httpreq.end();
} else {
res.render('fibonacci', {
title: "Calculate Fibonacci numbers",
fiboval: undefined
});
}
});
module.exports = router;
在app.js中,进行以下更改:
const index = require('./routes/index');
// const fibonacci = require('./routes/fibonacci');
// const fibonacci = require('./routes/fibonacci-async1');
// const fibonacci = require('./routes/fibonacci-await');
const fibonacci = require('./routes/fibonacci-rest');
然后,在package.json中,将scripts条目更改为以下内容:
"scripts": {
"start": "DEBUG=fibonacci:* node ./bin/www",
"startrest": "DEBUG=fibonacci:* SERVERPORT=3002 node ./bin/www",
"server": "DEBUG=fibonacci:* SERVERPORT=3002 node ./fiboserver" ,
"client": "DEBUG=fibonacci:* SERVERPORT=3002 node ./fiboclient"
},
我们如何让所有三个scripts条目的SERVERPORT具有相同的值?答案是,该变量在不同的地方有不同的用途。在startrest中,该变量用于routes/fibonacci-rest.js以知道 REST 服务正在哪个端口运行。同样,在client中,fiboclient.js使用该变量用于相同的目的。最后,在server中,fiboserver.js脚本使用SERVERPORT变量来知道要监听哪个端口。
在start和startrest中,没有为PORT提供值。在这两种情况下,如果未指定,bin/www默认为PORT=3000。
在一个命令窗口中启动后端服务器,在另一个窗口中启动应用程序。像之前一样打开一个浏览器窗口,发送几个请求。你应该会看到类似以下的输出:
$ npm run server
> fibonacci@0.0.0 server /Users/David/chap04/fibonacci
> DEBUG=fibonacci:* SERVERPORT=3002 node ./fiboserver
GET /fibonacci/34 200 21124.036 ms - 27
GET /fibonacci/12 200 1.578 ms - 23
GET /fibonacci/16 200 6.600 ms - 23
GET /fibonacci/20 200 33.980 ms - 24
GET /fibonacci/28 200 1257.514 ms - 26
应用程序的输出如下:
$ npm run startrest
> fibonacci@0.0.0 startrest /Users/David/chap04/fibonacci
> DEBUG=fibonacci:* SERVERPORT=3002 node ./bin/www
fibonacci:server Listening on port 3000 +0ms
GET /fibonacci?fibonum=34 200 21317.792 ms - 548
GET /stylesheets/style.css 304 20.952 ms - -
GET /fibonacci?fibonum=12 304 109.516 ms - -
GET /stylesheets/style.css 304 0.465 ms - -
GET /fibonacci?fibonum=16 200 83.067 ms - 544
GET /stylesheets/style.css 304 0.900 ms - -
GET /fibonacci?fibonum=20 200 221.842 ms - 545
GET /stylesheets/style.css 304 0.778 ms - -
GET /fibonacci?fibonum=28 200 1428.292 ms - 547
GET /stylesheets/style.css 304 19.083 ms - -
由于我们没有更改模板,屏幕将看起来与之前完全一样。
我们可能会遇到这个解决方案的另一个问题。我们低效的斐波那契算法的异步实现可能会导致斐波那契服务进程耗尽内存。在 Node.js 常见问题解答(github.com/nodejs/node/wiki/FAQ)中,建议使用--max_old_space_size标志。你可以在package.json中添加如下:
"server": "SERVERPORT=3002 node ./fiboserver --max_old_space_size 5000",
然而,常见问题解答(FAQ)也提到,如果你遇到最大内存空间问题,你的应用程序可能需要进行重构。这回到了我们之前提到的几个页面,即解决性能问题的几种方法之一是应用算法重构。
为什么我们要费心开发这个 REST 服务器,而不是直接使用fibonacciAsync?
我们现在可以将这个重量级计算的 CPU 负载推送到一个单独的服务器。这样做将保留前端服务器的 CPU 容量,以便它可以处理网络浏览器。GPU 协处理器现在在数值计算中得到了广泛的应用,并且可以通过简单的网络 API 访问。重计算可以保持独立,你甚至可以部署一个位于负载均衡器后面的后端服务器集群,均匀地分配请求。
我们所展示的是,在 Node.js 和 Express 中,只需几行代码就可以实现简单的多层 REST 服务。
一些 RESTful 模块和框架
这里有一些可用的包和框架可以帮助你的基于 REST 的项目:
-
Restify (>http://restify.com/):这为 REST 事务的两端提供了客户端和服务器端框架。服务器端 API 与 Express 类似。
-
Loopback (
loopback.io/):这是 StrongLoop 提供的产品,目前是 Express 项目的赞助商。它提供了许多功能,当然,它是建立在 Express 之上的。
摘要
在本章中,你学到了很多关于 Node 的 HTTP 支持、实现 Web 应用程序和 REST 服务实现的知识。
现在我们可以继续实现一个更完整的应用程序:一个用于记笔记的应用。我们将使用笔记应用程序在接下来的几个章节中作为探索 Express 应用程序框架、数据库访问、部署到云服务或自己的服务器以及用户认证的载体。
在下一章中,我们将构建基本的基础设施。
第五章:您的第一个 Express 应用程序
现在我们已经尝试构建了 Node.js 的 Express 应用程序,让我们来构建一个执行有用功能的应用程序。我们将构建的应用程序将保存笔记列表,并让我们探索真实应用程序的一些方面。
在本章中,我们只构建应用程序的基本基础设施,而在后面的章节中,我们将大大扩展应用程序。
本章涵盖的主题包括
-
在 Express 路由函数中使用承诺(Promises)和异步函数
-
将 MVC 范式应用于 Express 应用程序
-
构建 Express 应用程序
-
JavaScript 类定义
-
实现 CRUD 范式
-
Handlebars 模板
承诺(Promises)、异步函数和 Express 路由函数
在我们开始开发我们的应用程序之前,我们必须更深入地了解一对新的 ES-2015/2016/2017 特性,这些特性共同彻底改变了 JavaScript 编程:Promise类和async函数。两者都用于延迟和异步计算,可以使深层嵌套的回调成为过去式:
-
Promise表示一个尚未完成但预期将在未来完成的操作。我们已经看到了 Promise 的使用。当承诺的结果(或错误)可用时,会调用.then或.catch函数。 -
生成器函数是一种新的函数类型,它可以暂停和恢复,并可以从函数的中间返回结果。
-
这两个特性与另一个特性混合,即迭代协议,以及一些新的语法,以创建
async函数。
async函数的魔力在于我们可以像编写同步代码一样编写异步代码。它仍然是异步代码,这意味着长时间运行的请求处理器不会阻塞事件循环。代码看起来就像我们在其他语言中编写的同步代码。一条语句跟在另一条语句后面,错误以异常的形式抛出,结果落在下一行代码上。承诺(Promise)和async函数的改进如此之大,以至于 Node.js 社区切换范式(即重写面向回调的遗留 API)极具吸引力。
在过去的几年里,已经使用了几种其他方法来管理异步代码,你可能会遇到使用这些其他技术的代码。在Promise对象标准化之前,至少有两种实现可用:Bluebird (bluebirdjs.com/) 和 Q (www.npmjs.com/package/q)。使用非标准的 Promise 库应谨慎考虑,因为与标准 Promise 对象保持兼容性是有价值的。
死亡金字塔这个名字来源于代码在几层嵌套后的形状。任何多阶段过程都可以迅速升级到 15 层深的嵌套代码。考虑以下示例:
router.get('/path/to/something', (req, res, next) => {
doSomething(arg1, arg2, (err, data1) => {
if (err) return next(err);
doAnotherThing(arg3, arg2, data1, (err2, data2) => {
if (err2) return next(err2);
somethingCompletelyDifferent(arg1, arg42, (err3, data3) => {
if (err3) return next(err3);
doSomethingElse((err4, data4) => {
if (err4) return next(err4);
res.render('page', { data });
});
});
});
});
});
将其重写为async函数将使这更加清晰。要做到这一点,我们需要检查以下想法:
-
使用 Promise 管理异步结果
-
生成器函数和 Promise
-
async函数
我们是这样生成一个 Promise 的:
exports.asyncFunction = function(arg1, arg2) {
return new Promise((resolve, reject) => {
// perform some task or computation that's asynchronous
// for any error detected:
if (errorDetected) return reject(dataAboutError);
// When the task is finished
resolve(theResult);
});
};
注意,asyncFunction是一个异步函数,但它不接收回调。相反,它返回一个Promise对象,异步代码在传递给Promise类的回调中执行。
您的代码必须通过resolve和reject函数来指示异步操作的状态。正如函数名所暗示的,reject表示发生了错误,而resolve表示成功的结果。您的调用者随后可以使用该函数如下:
asyncFunction(arg1, arg2)
.then((result) => {
// the operation succeeded
// do something with the result
return newResult;
})
.catch(err => {
// an error occurred
});
系统足够灵活,传递给.then的函数可以返回某些内容,例如另一个 Promise,并且您可以一起链式调用.then。在.then处理程序中返回的值(如果有)将成为一个新的 Promise 对象,通过这种方式,您可以构建一个.then和.catch调用的链,以管理一系列异步操作。
一系列异步操作将被实现为.then函数的链,正如我们将在下一节中看到的。
Promise 和错误处理
Promise 对象可以处于三种状态之一:
-
Pending:这是初始状态,既未满足也未拒绝
-
Fulfilled:这是执行成功时的最终状态
产生了一个结果
-
Rejected:这是执行失败时的最终状态
考虑这段代码片段类似于我们在本章后面将要使用的代码:
notes.read(req.query.key)
.then(note => { return filterNote(note); })
.then(note => { return swedishChefSpeak(note); })
.then(note => {
res.render('noteview', {
title: note ? note.title : "",
notekey: req.query.key,
note: note
});
})
.catch(err => { next(err); });
在这段小代码中,有几个地方可能会发生错误。notes.read函数有几个可能的失败模式:filterNote函数可能会在检测到跨站脚本攻击时发出警报。瑞典厨师可能会罢工。res.render或使用的模板可能会失败。但我们只有一种方法来捕获和报告错误。我们遗漏了什么吗?
Promise类会自动捕获错误,将它们发送到与Promise关联的操作链中。如果Promise类本身有错误,它会跳过.then函数,并调用它找到的第一个.catch函数。换句话说,使用 Promise 的实例提供了更高的确保捕获和报告错误的可能性。在旧的传统中,错误报告更复杂,很容易忘记添加正确的错误处理。
平滑我们的异步代码
我们要解决的问题是在 JavaScript 中异步编码会导致“死亡金字塔”。为了解释,让我们重述 Ryan Dahl 给出的作为 Node.js 主要语法的例子:
db.query('SELECT ..etc..', function(err, resultSet) {
if (err) {
// Instead, errors arrive here
} else {
// Instead, results arrive here
}
});
// We WANT the errors or results to arrive here
目标是避免使用长时间操作阻塞事件循环。使用回调函数延迟处理结果或错误是一个出色的解决方案,并且是 Node.js 的基础习语。回调函数的实现导致了这种金字塔形问题。也就是说,结果和错误落在回调中。而不是将它们传递到下一行代码,错误和结果被埋藏。
Promise 帮助简化代码,使其不再呈现金字塔形状。它们还捕获错误,确保将结果传递到有用的位置。但那些错误和结果仍然隐藏在匿名函数内部,并没有传递到下一行代码。
此外,使用 Promise 会导致一些样板代码,这会掩盖程序员的意图。它比常规回调函数的样板代码少,但样板代码仍然存在。
幸运的是,ECMAScript 委员会一直在努力解决这个问题。
Promise 和生成器催生了异步函数
生成器和相关的迭代协议是一个大主题,我们将简要介绍。
迭代协议是新的 for..of 循环和其他一些新循环结构背后的原理。这些结构可以与任何产生迭代器的对象一起使用。有关更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols.
生成器是一种可以使用 yield 关键字停止和启动的函数。生成器产生一个迭代器,其值是传递给 yield 语句的任何内容。有关更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator.
考虑这个:
$ cat gen.js
function* gen() {
yield 1;
yield 2;
yield 3;
yield 4;
}
for (let g of gen()) {
console.log(g); }
$ node gen.js
1
2
3
4
yield 语句使生成器函数暂停,并在其 next 函数的下一个调用上提供值。next 函数在这里没有明确显示,但它控制循环,是迭代协议的一部分。而不是循环,尝试多次调用 gen().next():
var geniter = gen();
console.log(geniter.next());
console.log(geniter.next());
console.log(geniter.next());
你会看到这个:
$ node gen.js
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
迭代协议指出,当 done 为 true 时,迭代器完成。在这种情况下,我们没有调用它足够多次以触发迭代器的结束状态。
生成器变得有趣的地方在于与返回 Promise 的函数一起使用。Promise 是通过迭代器提供的。消耗迭代器的代码可以等待 Promise 获取其值。一系列异步操作可以放在生成器中,并以可迭代的方式调用。
通过一个额外的函数,一个生成器函数以及返回 Promise 的异步函数可以是一种编写异步代码的好方法。我们在第二章,“设置 Node.js”,探索 Babel 时看到了这个例子。Babel 有一个插件可以将 async 函数重写为生成器以及一个辅助函数,我们查看了解译后的代码和辅助函数。co 库(www.npmjs.com/package/co)是实现生成器异步编码的流行辅助函数。创建一个名为 2files.js 的文件:
const fs = require('fs-extra');
const co = require('co');
const util = require('util');
co(function* () {
var texts = [
yield fs.readFile('hello.txt', 'utf8'),
yield fs.readFile('goodbye.txt', 'utf8')
];
console.log(util.inspect(texts));
});
然后按照以下方式运行它:
$ node 2files.js
[ 'Hello, world!\n', 'Goodbye, world!\n' ]
通常,fs.readFile 将其结果发送到回调函数,我们会构建一个金字塔形状的小段代码来完成这个任务。fs-extra 模块包含了内置 fs 模块中所有函数的实现,但改为返回 Promise 而不是回调函数。因此,这里显示的每个 fs.readFile 都返回一个在文件内容完全读入内存时解决的 Promise。co 所做的是管理等待 Promise 解决(或拒绝)的舞蹈,并返回 Promise 的值。因此,使用两个合适的文本文件,我们得到了执行 2files.js 的结果。
重要的是代码非常清晰和易于阅读。我们不会陷入管理异步操作所需的样板代码。程序员的意图非常明确。
async 函数结合了生成器和 Promise,并在 JavaScript 语言中定义了一种标准化的语法。创建一个名为 2files-async.js 的文件:
const fs = require('fs-extra');
const util = require('util');
async function twofiles() {
var texts = [
await fs.readFile('hello.txt', 'utf8'),
await fs.readFile('goodbye.txt', 'utf8')
];
console.log(util.inspect(texts));
}
twofiles().catch(err => { console.error(err); });
然后按照以下方式运行它:
$ node 2files-async.js
[ 'Hello, world!\n', 'Goodbye, world!\n' ]
清晰。易于阅读。程序员的意图非常明确。无需依赖任何附加库,语法内置在 JavaScript 语言中。最重要的是,所有操作都以自然的方式处理。错误通过抛出异常自然地指示。异步操作的结果自然地作为操作的结果出现,await 关键字有助于结果的传递。
为了看到真正的优势,让我们回到之前提到的“灾难金字塔”示例:
router.get('/path/to/something', async (req, res, next) => {
try {
let data1 = await doSomething(req.query.arg1, req.query.arg2);
let data2 = await doAnotherThing(req.query.arg3, req.query.arg2,
data1);
let data3 = await somethingCompletelyDifferent(req.query.arg1,
req.query.arg42);
let data4 = await doSomethingElse();
res.render('page', { data1, data2, data3, data4 });
} catch(err) {
next(err);
}
});
除了 try/catch 之外,与作为回调金字塔的形式相比,这个例子变得非常简洁。所有异步回调的样板代码都被删除了,程序员的意图清晰可见。
为什么需要 try/catch?通常,async 函数会捕获抛出的错误,并自动正确地报告它们。但因为这个例子是在 Express 路由函数中,所以我们受限于其功能。Express 无法识别 async 函数,因此它不知道要寻找抛出的错误。相反,我们必须 catch 它们并调用 next(err)。
此改进仅适用于在 async 函数内部执行的代码。async 函数外部的代码仍然需要回调或 Promises 来进行异步编程。此外,async 函数的返回值是一个 Promise。
有关 async 函数的详细信息,请参阅官方规范 tc39.github.io/ecmascript-asyncawait/。
Express 和 MVC 范式
Express 不强制要求您如何构建应用程序的模型、视图和控制器模块,或者是否应该遵循任何类型的 MVC 范式。正如我们在上一章中学到的,Express 生成器创建的空白应用程序提供了 MVC 模型的两个方面:
-
views目录包含模板文件,控制显示部分,对应于视图。 -
routes目录包含实现应用程序识别的 URL 的代码,并协调对每个 URL 的响应。这对应于控制器。
这让你想知道模型对应的代码应该放在哪里。模型持有应用程序数据,根据控制器的指令进行更改,并为视图代码提供所需的数据。至少,模型代码应该与控制器代码分开在不同的模块中。这是为了确保职责的清晰分离,例如,便于对每个模块进行单元测试。
我们将采用的方法是在 views 和 routes 目录的兄弟目录中创建一个 models 目录。models 目录将包含存储笔记和相关数据的模块。models 目录中模块的 API 将提供创建、读取、更新或删除数据项的函数 C****reate、R****ead、U****pdate 和 D****elete 或 D****estroy(CRUD 模型)以及其他视图代码执行其功能所需的函数。
CRUD 模型(创建、读取、更新、删除)是持久数据存储的四个基本操作。Notes 应用程序被构建为一个 CRUD 应用程序,以展示实现这些操作。
我们将使用名为 create、read、update 和 destroy 的函数来实现每个基本操作。
我们使用动词 destroy 而不是 delete,因为 delete 是 JavaScript 中的一个保留字。
创建笔记应用程序
让我们像以前一样开始创建 Notes 应用程序,使用 Express 生成器为我们提供一个起点:
$ mkdir notes
$ cd notes
$ npm install express-generator@4.x
$ ./node_modules/.bin/express --view=hbs --git .
destination is not empty, continue? [y/N] y
create : .
create : ./package.json
create : ./app.js
create : ./.gitignore
create : ./public
create : ./routes
create : ./routes/index.js
create : ./routes/users.js
create : ./views
create : ./views/index.hbs
create : ./views/layout.hbs
create : ./views/error.hbs
create : ./bin
create : ./bin/www
create : ./public/stylesheets
create : ./public/stylesheets/style.css
install dependencies:
$ cd . && npm install
run the app:
$ DEBUG=notes:* npm start
create : ./public/javascripts
create : ./public/images
$ npm install
added 82 packages and removed 5 packages in 97.188s
$ npm uninstall express-generator
up to date in 8.325s
如果您愿意,可以运行 npm start 并在浏览器中查看空白应用程序。相反,让我们继续设置代码。
您的第一个笔记模型
创建一个名为 models 的目录,作为 views 和 routes 目录的兄弟目录。
然后,在那个目录中创建一个名为 Note.js 的文件,并将此代码放入其中:
const _note_key = Symbol('key');
const _note_title = Symbol('title');
const _note_body = Symbol('body');
module.exports = class Note {
constructor(key, title, body) {
this[_note_key] = key;
this[_note_title] = title;
this[_note_body] = body;
}
get key() { return this[_note_key]; }
get title() { return this[_note_title]; }
set title(newTitle) { this[_note_title] = newTitle; }
get body() { return this[_note_body]; }
set body(newBody) { this[_note_body] = newBody; }
};
这定义了一个新的类,Note,用于在我们的 Notes 应用程序中使用。目的是存储与应用程序用户之间交换的笔记相关数据。
理解 ES-2015 类定义
这种对象类定义在 ES-2015 中是新的,它简化了类定义的过程,使得 JavaScript 中的类定义更接近其他语言的语法。在底层,JavaScript 类仍然使用基于原型的继承,但语法更简单,程序员甚至不需要考虑对象原型。
我们可以使用instanceof运算符来可靠地确定一个对象是否是笔记:
$ node
> const Note = require('./Note');
> typeof Note
'function'
> const aNote = new Note('foo', 'The Rain In Spain', 'Falls mainly on the plain');
> var notNote = {}
> notNote instanceof Note
false
> aNote instanceof Note
true
> typeof aNote
'object'
这表明使用instanceof运算符是识别对象的明显方法。typeof运算符告诉我们Note是一个函数(因为背后是基于原型的继承),以及Note类的实例是一个对象。使用instanceof,我们可以轻松地确定一个对象是否是给定类的实例。
在Note类中,我们使用了Symbol实例来提供一定程度的数据隐藏。JavaScript 类不提供数据隐藏机制——例如,你不能像 Java 中那样将字段标记为private。了解如何隐藏实现细节是有用的。这是面向对象编程的一个重要属性,因为拥有随意更改实现的能力是有用的。还有控制哪些代码可以操作对象字段的问题。
首先,我们声明了 getter 和 setter 函数来提供对值的访问。我们在第四章中介绍了正常的 getter/setter 用法,HTTP 服务器和客户端。
通过使用属性的名称来访问基于 getter 的字段,而不是调用一个函数 - aNote.title而不是aNote.title()。这看起来像是通过赋值或访问值来访问对象属性。实际上,在每次访问时都会执行在类中定义的函数。您可以通过只实现 getter 而没有 setter 来定义只读属性,就像我们对key字段所做的那样。
在前面的定义和简单地定义匿名对象之间存在显著差异:
{
key: 'foo', title: 'The Rain in Spain',
body: 'Falls mainly on the plain'
}
我们在 JavaScript 中经常编写这样的代码。它简单、快捷,是一种非常流畅的函数间数据共享方式。但是,没有隐藏实现细节的措施,也没有明确的对象类型标识。
在Note类中,我们可以使用这个constructor方法:
class Note {
constructor(key, title, body) {
this.key = key;
this.title = title;
this.body = body;
}
}
这实际上与匿名对象相同,因为没有隐藏任何细节,也没有在代码层面实现控制,即没有明确指定哪些代码可以对对象实例做什么。匿名对象唯一的优势是使用instanceof运算符来识别对象实例。
我们选择的方法使用了也是随着 ES-2015 引入的 Symbol 类。Symbol 是一个不透明的对象,有两个主要用途:
-
生成唯一的键作为属性字段,就像之前的
Note类中那样 -
作为像 COLOR_RED 这样的概念的符号标识符
您可以通过一个生成Symbol实例的工厂方法来定义一个 Symbol:
> let symfoo = Symbol('foo')
每次调用符号工厂方法时,都会创建一个新的唯一实例。例如,Symbol('foo') === Symbol('foo') 是 false,同样 symfoo === Symbol('foo') 也是 false,因为等号两边的每个实例都是新创建的。然而,symfoo === symfoo 是 true,因为它们是同一个实例。
实际上这意味着,如果我们尝试直接访问一个字段,将会失败:
> aNote[Symbol('title')]
undefined
记住,每次我们使用符号工厂方法时,我们都会得到一个新的实例。Symbol('title') 的新实例与 Note.js 模块内部使用的实例不是同一个实例。
重要的是,使用 Symbol 对象为字段提供了一定程度的功能隐藏。
填充内存笔记模型
在 models 目录中创建一个名为 notes-memory.js 的文件,包含以下代码:
const Note = require('./Note');
var notes = [];
exports.update = exports.create = async function(key, title, body) {
notes[key] = new Note(key, title, body);
return notes[key];
};
exports.read = async function(key) {
if (notes[key]) return notes[key];
else throw new Error(`Note ${key} does not exist`);
};
exports.destroy = async function(key) {
if (notes[key]) {
delete notes[key];
} else throw new Error(`Note ${key} does not exist`);
};
exports.keylist = async function() { return Object.keys(notes); };
exports.count = async function() { return notes.length; };
exports.close = async function() { }
这是一个相当直观的简单内存数据存储。每个笔记实例的 key 被用作数组的索引,该数组反过来又包含笔记实例。简单、快速且易于实现。它不支持任何长期数据持久性。存储在这个模型中的任何数据,当服务器被关闭时都会消失。
我们使用了 async 函数,因为将来我们将在文件系统或数据库中存储数据。因此,我们需要一个异步 API。
create 和 update 函数由同一个函数处理。在笔记应用的这个阶段,这两个函数的代码可以完全相同,因为它们执行的是完全相同的操作。稍后,当我们为笔记添加数据库支持时,create 和 update 函数将需要不同。例如,在 SQL 数据模型中,create 将通过 INSERT INTO 命令实现,而 update 将通过 UPDATE 命令实现。
笔记主页
我们将修改启动应用程序以支持创建、编辑、更新、查看和删除笔记。让我们先修复主页。它应该显示笔记列表,并且顶部的导航栏应该链接到一个“添加笔记”页面,这样我们就可以始终添加新的笔记。
虽然我们将修改生成的 app.js,但为了支持主页,它不需要任何修改。这些代码行与主页相关:
const index = require('./routes/index');
..
app.use('/', index);
此外,为了支持 Handlebars 模板,app.js 需要以下更改:
const hbs = require('hbs');
...
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));
我们将把 Handlebars 的 partials 放在一个名为 partials 的目录中,该目录是 views 目录的兄弟目录。将 routes/index.js 修改为以下内容:
const express = require('express');
const router = express.Router();
const notes = require('../models/notes-memory');
/* GET home page. */
router.get('/', async (req, res, next) => {
let keylist = await notes.keylist();
let keyPromises = keylist.map(key => {
return notes.read(key)
});
let notelist = await Promise.all(keyPromises);
res.render('index', { title: 'Notes', notelist: notelist });
});
module.exports = router;
这收集了将在主页上显示的笔记的相关数据。默认情况下,我们将显示一个简单的笔记标题表格。我们确实需要讨论一下这项技术。
Promise.all 函数执行一个 Promise 数组。Promise 是并行评估的,允许我们的代码可能对服务进行并行请求。这应该比逐个顺序请求执行得更快。
我们可以编写一个简单的 for 循环,如下所示:
let keylist = await notes.keylist();
let notelist = [];
for (key of keylist) {
let note = await notes.read(keylist);
notelist.push({ key: note.key, title: note.title });
}
虽然阅读起来更简单,但笔记是一次性检索的,没有机会重叠read操作。
Promise 数组是用map函数构建的。使用map,我们可以遍历一个数组以生成一个新的数组。在这种情况下,新数组包含由notes.read函数调用生成的 Promise。
因为我们在代码中写了await Promise.all,所以一旦所有 Promise 都成功,notelist数组将完全填充正确的数据。如果任何 Promise 失败——换句话说,被拒绝——将抛出异常。我们所做的是排一个异步操作列表,并优雅地等待它们全部完成。
然后,notelist数组被传递到我们即将编写的view模板中。
从views/layout.hbs开始,包含以下内容:
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
{{> header }}
{{{body}}}
</body>
</html>
这是生成的文件,增加了页面标题的部分。我们已经在partials目录中声明了partials。创建partials/header.hbs,包含以下内容:
<header>
<h1>{{ title }}</h1>
<div class='navbar'>
<p><a href='/'>Home</a> | <a href='/notes/add'>ADD Note</a></p>
</div>
</header>
将views/index.hbs更改为以下内容:
{{#each notelist}}
<ul>
<li>{{ key }}:
<a href="/notes/view?key={{ key }}">{{ title }}</a>
</li>
</ul>
{{/each}}
这只是遍历笔记数据数组并格式化一个简单的列表。每个条目都链接到带有key参数的/notes/view URL。我们还没有查看那段代码,但这个 URL 显然会显示笔记。另一个值得注意的是,如果notelist为空,则不会生成列表的 HTML。
当然,还有更多可以添加到这个里面。例如,只需在这里添加适当的script标签,就可以很容易地为每个页面添加 jQuery 支持。
我们现在已经写足够的内容来运行应用程序;让我们查看主页:
$ DEBUG=notes:* npm start
> notes@0.0.0 start /Users/David/chap05/notes
> node ./bin/www
notes:server Listening on port 3000 +0ms
GET / 200 87.300 ms - 308
GET /stylesheets/style.css 200 27.744 ms - 111
如果我们访问http://localhost:3000,我们将看到以下页面:

因为还没有任何笔记,所以没有东西可以显示。点击主页链接只是刷新页面。点击添加笔记链接会抛出错误,因为我们还没有(目前)实现那段代码。这表明app.js中提供的错误处理器正在按预期执行。
添加一个新笔记 – 创建
现在,让我们看看如何创建笔记。因为应用程序没有为/notes/add URL 配置路由,我们必须添加一个。为此,我们需要一个笔记控制器。
在app.js中,进行以下更改。
注释掉这些行:
// var users = require('./routes/users');
..
// app.use('/users', users);
在这个阶段,笔记应用不支持用户,这些路由也不需要。这将在未来的章节中改变。
我们真正需要做的是添加notes控制器的代码:
// const users = require('./routes/users');
const notes = require('./routes/notes');
..
// app.use('/users', users);
app.use('/notes', notes);
现在,我们将添加一个包含notes路由器的控制器模块。创建一个名为routes/notes.js的文件,包含以下内容:
const util = require('util');
const express = require('express');
const router = express.Router();
const notes = require('../models/notes-memory');
// Add Note.
router.get('/add', (req, res, next) => {
res.render('noteedit', {
title: "Add a Note",
docreate: true,
notekey: "", note: undefined
});
});
module.exports = router;
结果的/notes/add URL 对应于partials/header.hbs中的链接。
在views目录中,添加一个名为noteedit.hbs的模板,包含以下内容:
<form method='POST' action='/notes/save'>
<input type='hidden' name='docreate' value='<%=
docreate ? "create" : "update"%>'>
<p>Key:
{{#if docreate }}
<input type='text' name='notekey' value=''/>
{{else}}
{{#if note }}{{notekey}}{{/if}}
<input type='hidden' name='notekey'
value='{{#if note }}{{notekey}}{{/if}}'/>
{{/if}}
</p>
<p>Title: <input type='text' name='title'
value='{{#if note }}{{note.title}}{{/if}}' /></p>
<br/><textarea rows=5 cols=40 name='body' >
{{#if note }}{{note.body}}{{/if}}
</textarea>
<br/><input type='submit' value='Submit' />
</form>
我们将重用这个模板来支持编辑笔记和创建新笔记。
注意,在这种情况下传递给模板的note和notekey对象是空的。模板检测到这种条件并确保输入区域为空。此外,还传递了一个名为docreate的标志,以便表单记录它是否用于创建或更新笔记。目前,我们正在添加一个新的笔记,因此不存在笔记对象。模板代码正在被编写为防御性,以避免抛出错误。
这个模板是一个将数据POST到/notes/save URL 的表单。如果你现在运行应用程序,它将显示错误消息,因为没有为该 URL 配置任何路由。
要支持/notes/save URL,请将以下内容添加到routes/notes.js:
// Save Note (update)
router.post('/save', async (req, res, next) => {
var note;
if (req.body.docreate === "create") {
note = await notes.create(req.body.notekey,
req.body.title, req.body.body);
} else {
note = await notes.update(req.body.notekey,
req.body.title, req.body.body);
}
res.redirect('/notes/view?key='+ req.body.notekey);
});
因为这个 URL 也将用于创建和更新笔记,所以它需要检测docreate标志并调用适当的模型操作。
模型为notes.create和notes.update都返回一个 Promise。当然,我们必须根据docreate标志调用相应的模型函数。
这是一个POST操作处理程序。由于bodyParser中间件,表单数据被添加到req.body对象中。附加到req.body的字段直接对应于 HTML 表单中的元素。
现在,我们可以再次运行应用程序并使用添加笔记表单:

但在点击提交按钮后,我们得到了一个错误消息。目前还没有实现/notes/view URL。
您可以修改位置框中的 URL 以重新访问http://localhost:3000,您将在主页上看到如下截图:

笔记实际上就在那里;我们只需要实现/notes/view。让我们继续。
查看笔记 – 阅读
现在我们已经了解了如何创建笔记,我们需要继续阅读它们。这意味着实现/notes/view URL 的控制器逻辑和视图模板。
将以下路由函数添加到routes/notes.js:
// Read Note (read)
router.get('/view', async (req, res, next) => {
var note = await notes.read(req.query.key);
res.render('noteview', {
title: note ? note.title : "",
notekey: req.query.key, note: note
});
});
因为这个路由挂载在处理/notes的路由器上,所以这个路由处理/notes/view。
如果notes.read成功读取笔记,它将使用noteview模板进行渲染。如果出现问题,我们将通过 Express 向用户显示错误。
将noteview.hbs模板添加到views目录中,如下代码所示:
{{#if note}}<h3>{{ note.title }}</h3>{{/if}}
{{#if note}}<p>{{ note.body }}</p>{{/if}}
<p>Key: {{ notekey }}</p>
{{#if notekey }}
<hr/>
<p><a href="/notes/destroy?key={{notekey}}">Delete</a>
| <a href="/notes/edit?key={{notekey}}">Edit</a></p>
{{/if}}
这很简单:从笔记对象中提取数据并使用 HTML 显示。底部有两个链接,一个链接到/notes/destroy以删除笔记,另一个链接到/notes/edit以编辑它。
目前这两个代码都不存在。但这不会阻止我们继续执行应用程序:

如预期的那样,使用这段代码,应用程序正确地重定向到/notes/view,我们可以看到我们的成果。同样,如预期的那样,点击删除或编辑链接将给出错误,因为代码尚未实现。
编辑现有笔记 – 更新
现在我们已经看了 create 和 read 操作,让我们看看如何更新或编辑一个笔记。
在 routes/notes.js 中添加此路由函数:
// Edit note (update)
router.get('/edit', async (req, res, next) => {
var note = await notes.read(req.query.key);
res.render('noteedit', {
title: note ? ("Edit " + note.title) : "Add a Note",
docreate: false,
notekey: req.query.key, note: note
});
});
我们正在重用 noteedit.ejs 模板,因为它可以用于 create 和 update/edit 操作。注意,我们为 docreate 传递 false,通知模板它将被用于编辑。
在这种情况下,我们首先检索笔记对象,然后将其传递给模板。这样,模板就为编辑而不是笔记创建设置了。当用户点击提交按钮时,我们将最终进入前面截图所示的相同 /notes/save 路由处理器。它已经做了正确的事情:在模型中调用 notes.update 方法而不是 notes.create。
因为那是我们需要做的全部,我们可以继续重新运行应用程序:

在这里点击提交按钮,你将被重定向到 /notes/view 屏幕,然后可以阅读新编辑的笔记。回到 /notes/view 屏幕:我们刚刚处理了编辑链接,但删除链接仍然产生错误。
删除笔记 – 销毁
现在,让我们看看如何实现 /notes/destroy URL 来删除笔记。
在 routes/notes.js 中添加以下路由函数:
// Ask to Delete note (destroy)
router.get('/destroy', async (req, res, next) => {
var note = await notes.read(req.query.key);
res.render('notedestroy', {
title: note ? note.title : "",
notekey: req.query.key, note: note
});
});
删除笔记是一个重要的步骤,仅仅是因为如果我们出错,没有回收站可以从中恢复。因此,我们想要询问用户他们是否确定要删除那个笔记。在这种情况下,我们检索笔记,然后渲染以下页面,显示一个问题以确保他们确实想要删除笔记。
在 views 目录下,添加一个 notedestroy.hbs 模板:
<form method='POST' action='/notes/destroy/confirm'>
<input type='hidden' name='notekey' value='{{#if note}}{{notekey}}{{/if}}'>
<p>Delete {{note.title}}?</p>
<br/><input type='submit' value='DELETE' />
<a href="/notes/view?key={{#if note}}{{notekey}}{{/if}}">Cancel</a>
</form>
这是一个简单的表单,要求用户通过点击按钮来确认。取消链接只是将他们送回到 /notes/view 页面。点击提交按钮会在 /notes/destroy/confirm URL 上生成一个 POST 请求。
那个 URL 需要一个请求处理器。将以下代码添加到 routes/notes.js:
// Really destroy note (destroy)
router.post('/destroy/confirm', async (req, res, next) => {
await notes.destroy(req.body.notekey);
res.redirect('/');
});
这调用模型中的 notes.destroy 函数。如果成功,浏览器将被重定向到主页。如果不成功,将向用户显示错误消息。重新运行应用程序,我们现在可以查看其效果:

现在应用程序中的所有功能都正常工作,你可以点击任何按钮或链接,保留你想要的全部笔记。
主题化 Express 应用程序
Express 团队已经做了相当不错的工作,确保 Express 应用程序一开始看起来就很好。我们的笔记应用不会赢得任何设计奖项,但至少它不丑陋。现在基本应用已经运行,我们可以从许多方面来改进它。让我们快速看一下如何为主题化 Express 应用程序。在第六章 实现移动优先范式 中,我们将更深入地探讨,重点关注那个至关重要的目标——满足移动市场的需求。
如果你使用推荐的方法 npm start 运行 Notes 应用程序,你会在控制台窗口中看到一些活动日志。其中之一如下:
GET /stylesheets/style.css 304 0.702 ms - -
这是因为我们在 layout.hbs 中添加的这一行代码:
<link rel='stylesheet' href='/stylesheets/style.css' />
这个文件最初是由 Express Generator 自动生成的,并放置在 public 目录中。public 目录由 Express 静态文件服务器管理,使用 app.js 中的这一行:
app.use(express.static(path.join(__dirname, 'public')));
让我们打开 public/stylesheets/style.css 并查看一下:
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}
一个突出的问题是应用程序内容在屏幕的顶部和左侧有大量的空白空间。原因是 body 标签有 padding: 50px 样式。更改它非常简单。
由于 Express 静态文件服务器中没有缓存,我们可以简单地编辑 CSS 文件并重新加载页面,CSS 也会被重新加载。你可以像为生产网站那样开启缓存控制头和 ETags 生成。有关详细信息,请查看在线 Express 文档。
这需要一点工作:
body {
padding: 5px;
..
}
..
header {
background: #eeeeee;
padding: 5px;
}
因此,我们将得到以下内容:

我们使用这种方法也不会赢得任何设计奖项,但这是品牌和主题化可能性的开始。
一般而言,我们构建页面模板的方式,应用全局主题只需在 layout.hbs 中添加适当的代码,以及相应的样式表和其他资源。许多现代主题框架,如 Twitter 的 Bootstrap,通过 CDN 服务器提供 CSS 和 JavaScript 文件,使得将其集成到网站设计中变得极其简单。
对于 jQuery,请参阅jquery.com/download/。
Google 的托管库服务提供了一系列托管在 Google CDN 基础设施上的库。请参阅developers.google.com/speed/libraries/。
虽然使用第三方 CDN 托管这些资源很简单,但自己托管它们更安全。这不仅意味着你负责应用程序的带宽消耗,而且可以确保不会受到第三方服务中断的影响。尽管 Google 可能很可靠,但他们的服务可能会中断,如果这意味着 jQuery 和 Bootstrap 无法加载,你的客户可能会认为你的网站出了问题。但如果这些文件是从与你的应用程序相同的服务器加载的,那么这些文件交付的可靠性将正好等于你应用程序的可靠性。
在第六章,实现移动优先范式中,我们将探讨一种简单的方法将那些前端库添加到你的应用程序中。
扩展规模 - 运行多个 Notes 实例
现在我们已经有一个运行中的应用程序,你可能已经玩了一段时间,并创建了、读取了、更新了和删除了许多笔记。
假设一下,这不是一个玩具应用,而是一个每天能吸引一百万用户的有趣应用。处理高负载通常意味着添加服务器、负载均衡器以及许多其他东西。核心部分是同时运行多个应用实例以分散负载。
让我们看看同时运行多个 Notes 应用实例会发生什么。
第一件事是确保实例在不同的端口上。在 bin/www 中,你会看到设置 PORT 环境变量控制着正在使用的端口。如果 PORT 变量未设置,它默认为 http://localhost:3000,或者我们一直在使用的那个。
让我们打开 package.json 并将这些行添加到 scripts 部分:
"scripts": {
"start": "DEBUG=notes:* node ./bin/www",
"server1": "DEBUG=notes:* PORT=3001 node ./bin/www",
"server2": "DEBUG=notes:* PORT=3002 node ./bin/www" },
server1 脚本在 PORT 3001 上运行,而 server2 脚本在 PORT 3002 上运行。不是很好吗,所有这些都在一个地方有文档记录?
然后,在一个命令窗口中运行以下命令:
$ npm run server1
> notes@0.0.0 server1 /Users/David/chap05/notes
> DEBUG=notes:* PORT=3001 node ./bin/www
notes:server Listening on port 3001 +0ms
在另一个命令窗口中运行以下命令:
$ npm run server2
> notes@0.0.0 server2 /Users/David/chap05/notes
> DEBUG=notes:* PORT=3002 node ./bin/www
notes:server Listening on port 3002 +0ms
这给我们提供了 Notes 应用的两个实例。使用两个浏览器窗口访问 http://localhost:3001 和 http://localhost:3002。输入一些笔记,你可能会看到类似这样的内容:

在编辑并添加一些注释后,你的两个浏览器窗口可能看起来像前面的截图。这两个实例不共享相同的数据池。每个实例都在自己的进程和内存空间中运行。你在其中一个中添加注释,它不会显示在另一个屏幕上。
此外,因为模型代码没有在任何地方持久化数据,所以笔记不会被保存。你可能已经写下了有史以来最伟大的 Node.js 编程书籍,但一旦应用服务器重启,它就消失了。
通常,你运行多个应用实例以扩展性能。这就是“给服务器加把劲”的老方法。为了使其工作,数据当然必须共享,并且每个实例必须访问相同的数据源。通常,这涉及到数据库。当涉及到用户身份信息时,甚至可能需要武装警卫。
稍等——我们很快就会讨论数据库实现。在那之前,我们将介绍移动优先开发。
摘要
我们在本章中已经走了很长的路。
我们从“末日金字塔”开始,讨论 Promise 对象和 async 函数如何帮助我们驯服异步代码。我们将在整本书中使用这些技术。
我们迅速转向使用 Express 编写真实应用的框架。目前,它将数据保存在内存中,但它具有将成为支持实时协作注释的笔记应用的基本功能。
在下一章中,我们将浅尝辄止地涉足响应式、移动友好的网页设计领域。鉴于移动计算设备的日益普及,在考虑桌面电脑用户之前,首先解决移动设备的问题变得必要。为了触及每天数百万的用户,笔记应用的用户在使用智能手机时需要良好的用户体验。
在接下来的章节中,我们将继续扩展笔记应用的功能,从数据库存储模型开始。
第六章:实施移动优先范式
既然我们的第一个 Express 应用程序已经可以使用,我们将遵循这个软件时代的精神:移动优先。无论是智能手机、平板电脑、汽车仪表盘、冰箱门还是浴室镜子,移动设备正在接管世界。
另一个问题是以移动优先的索引,这意味着搜索引擎开始优先索引网站的移动版本。到目前为止,搜索引擎主要集中在索引网站的桌面版本,但随着移动设备的日益普及,搜索引擎结果偏离了人们使用的内容。谷歌表示,如果搜索结果(由桌面版本生成)与网站的移动版本不匹配,这对移动用户来说是不公平的。有关谷歌的观点,包括使用标记的技术提示,请参阅webmasters.googleblog.com/2017/12/getting-your-site-ready-for-mobile.html。
设计移动设备时的主要考虑因素是屏幕尺寸小、以触摸为导向的交互、没有鼠标以及用户界面期望的某些不同。对于笔记应用程序,我们的用户界面需求简单,没有鼠标对我们来说没有影响。
在本章中,我们不会进行太多的 Node.js 开发。相反,我们将:
-
修改模板以更好地展示移动界面
-
编辑 CSS 和 SASS 文件以自定义样式
-
了解 Bootstrap 4,这是一个流行的响应式 UI 设计框架
通过这样做,我们将涉足全栈网络工程师的含义。
问题 – 笔记应用程序不友好
让我们从量化问题开始。我们需要探索应用程序在移动设备上的表现如何(或不好)。这很简单:
-
启动笔记应用程序。确定主机的 IP 地址。
-
使用您的移动设备,通过 IP 地址连接到服务,浏览笔记应用程序,对其进行测试,注意任何困难。
另一种方法是使用您的桌面浏览器,将其调整到非常窄的尺寸。Chrome DevTools 还包含移动设备模拟器。无论哪种方式,您都可以在您的桌面上模拟智能手机的小屏幕尺寸。
要在移动屏幕上看到真正的用户界面问题,请编辑views/noteedit.ejs并更改此行:
<br/><textarea rows=5 cols=80 name='body' >
{{#if note }}{{note.body}}{{/if}}
</textarea>
发生变化的是cols=80参数。我们希望这个textarea元素非常大,这样您就可以体验非响应式 Web 应用程序在移动设备上的外观。在移动设备上查看应用程序,您会看到类似于此截图中的一个屏幕:

在 iPhone 6 上查看笔记效果良好,但编辑/添加笔记的屏幕并不理想。文本输入区域太宽,以至于超出屏幕边缘。尽管与FORM元素的交互工作良好,但操作笨拙。总的来说,浏览笔记应用提供了可接受的移动用户体验,不会令人失望,也不会让我们的用户给出好评。
移动优先范式
移动设备屏幕较小,通常是触摸导向的,并且与桌面电脑相比,对用户体验有不同期望。
为了适应较小的屏幕,我们使用响应式网页设计技术。这意味着设计应用程序以适应屏幕尺寸,并确保网站能够在广泛的设备上提供最佳观看和交互体验。技术包括更改字体大小、重新排列屏幕上的元素、使用触摸时展开的折叠元素,以及调整图片或视频的大小以适应可用空间。这被称为响应式,因为应用程序通过这些变化对设备特性做出响应。
通过移动优先,我们是指首先设计应用程序以在移动设备上良好运行,然后转向更大屏幕的设备。这是关于优先考虑移动设备。
主要技术是使用样式表中的媒体查询来检测设备特性。每个媒体查询部分针对一系列设备,使用 CSS 声明适当地重新设计内容。
让我们来看一个具体的例子。Wordpress 的Twenty Twelve主题具有直接的响应式设计实现。它没有使用任何框架,因此你可以清楚地看到机制是如何工作的,样式表足够小,易于理解。请参考 Wordpress 仓库中的源代码:themes.svn.wordpress.org/twentytwelve/1.9/style.css。
样式表以一系列重置开始,其中样式表使用明确的默认值覆盖了一些典型的浏览器样式设置。然后,样式表的大部分内容定义了移动设备的样式。在样式表的底部是一个名为媒体查询的部分,其中对于某些尺寸的屏幕,为移动设备定义的样式被覆盖,以便在更大屏幕的设备上工作。
它通过以下两个媒体查询来实现:
@media screen and (min-width: 600px) { /* Screens above 600px width */ }
@media screen and (min-width: 960px) { /* Screens above 960px width */ }
样式表的第一部分配置了所有设备的页面布局。接下来,对于任何至少600px宽的浏览器视口,重新配置页面以在更大屏幕上显示。然后,对于任何至少960px宽的浏览器视口,再次重新配置。样式表有一个最后的媒体查询来覆盖打印设备。
这些宽度被称为断点。那些阈值视口宽度是设计自我改变的地方。你可以通过访问任何响应式网站,然后调整浏览器窗口大小来查看断点的实际效果。观察设计在特定尺寸时的跳跃。那些是那个网站作者选择的断点。
关于选择断点的最佳策略存在广泛的意见分歧。你是针对特定设备还是针对一般特征?Twenty Twelve 主题在移动设备上表现相当不错,仅使用两个 viewport-size 媒体查询。CSS-Tricks 博客发布了一个针对每个已知设备的详细媒体查询列表,可在css-tricks.com/snippets/css/media-queries-for-standard-devices/找到。
我们至少应该针对以下设备:
-
小型:这包括 iPhone 5 SE。
-
中:这可能指的是平板电脑或更大的智能手机。
-
大:这包括更大的平板电脑或较小的台式电脑。
-
超大:这指的是更大的台式电脑和其他大屏幕。
-
横屏/竖屏:你可能想在横屏模式和竖屏模式之间创建一个区别。在两种模式之间切换当然会改变视口宽度,可能超过一个断点。然而,你的应用可能需要在两种模式下有不同的行为。
理论就到这里吧;让我们回到我们的代码上来。
在笔记应用中使用 Twitter Bootstrap
Bootstrap 是一个以移动设备为先的框架,由 HTML5、CSS3 和 JavaScript 代码组成,提供了一套世界级的、响应式网络设计组件。它是由 Twitter 的工程师开发的,并于 2011 年 8 月发布到全球。
该框架包括将现代功能应用于旧浏览器的代码,一个响应式的 12 列网格系统,以及一个长长的组件列表(其中一些使用 JavaScript),用于构建网络应用和网站。它的目的是提供一个强大的基础,以便构建你的应用。
有关更多详细信息,请参阅getbootstrap.com。
设置它
第一步是复制你在上一章中创建的代码。例如,如果你创建了一个名为chap05/notes的目录,那么从chap05/notes的内容中创建一个名为chap06/notes的目录。
现在,我们需要在笔记应用中添加 Bootstrap 的代码。Bootstrap 网站建议从 Bootstrap(和 jQuery)公共 CDN 加载所需的 CSS 和 JavaScript 文件。虽然这样做很简单,但我们不会这样做,原因有两个:
-
它违反了将所有依赖项本地化到应用中,而不是依赖全局依赖项的原则
-
它阻止我们生成自定义主题
相反,我们将安装一个本地的 Bootstrap 复制。安装 Bootstrap 有几种方法。例如,Bootstrap 网站提供了一个可下载的 TAR/GZIP 存档(tarball)。更好的方法是使用自动依赖管理工具。
最直接的选择是使用 npm 仓库中的 Bootstrap (www.npmjs.com/package/bootstrap)、popper.js (www.npmjs.com/package/popper.js) 和 jQuery (www.npmjs.com/package/jquery) 包。这些包不提供 Node.js 模块,而是通过 npm 分发的前端代码。
我们使用以下命令安装包:
$ npm install bootstrap@4.1.x --save
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN bootstrap@4.1.0 requires a peer of jquery@1.9.1 - 3 but none is installed. You must install peer dependencies yourself.
npm WARN bootstrap@4.1.0 requires a peer of popper.js@¹.14.0 but none is installed. You must install peer dependencies yourself.
+ bootstrap@4.1.0
added 1 package in 1.026s
$ npm install jquery@1.9.x --save
+ jquery@1.9.1
$ npm install popper.js@1.14.x --save
+ popper.js@1.14.0
正如我们所看到的,当我们安装 Bootstrap 时,它会友好地告诉我们应使用 jQuery 和popper.js的相应版本。因此,我们尽职尽责地安装了这些版本。最重要的是查看下载了什么:
$ ls node_modules/bootstrap/dist/*
... directory contents
$ ls node_modules/jquery/
... directory contents
$ ls node_modules/popper.js/dist
... directory contents
在这些目录中包含了用于浏览器的 CSS 和 JavaScript 文件。更重要的是,这些文件位于一个已知路径名的目录中——具体来说,就是我们刚才检查的目录。让我们看看如何配置我们的 Express 应用以在浏览器端使用这三个包。
将 Bootstrap 添加到应用模板中
在 Bootstrap 网站上,他们提供了一个推荐的 HTML 结构。我们将从他们的建议中提取,使用通过 CDN 提供的 Bootstrap 代码,改为使用我们刚刚安装的本地 Bootstrap、jQuery 和 Popper。请参阅getbootstrap.com/docs/4.0/getting-started/introduction/的入门页面。
我们将修改views/layout.hbs以匹配他们的推荐模板:
<!doctype html>
<html lang="en">
<head>
<title>{{title}}</title>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-
fit=no">
<link rel="stylesheet"
href="/assets/vendor/bootstrap/css/bootstrap.min.css">
<link rel='stylesheet' href='/assets/stylesheets/style.css' />
</head>
<body>
{{> header }}
{{{body}}}
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="img/jquery.min.js"></script>
<script src="img/popper.min.js"></script>
<script src="img/bootstrap.min.js"></script>
</body>
</html>
这基本上是 Bootstrap 网站上显示的模板,结合了views/layout.hbs中的先前内容。我们的样式表在 Bootstrap 样式表之后加载,这给了我们覆盖任何想要更改的 Bootstrap 的机会。不同之处在于,我们不是从各自的 CDN 加载 Bootstrap、popper.js和 jQuery 包,而是使用/assets/vendor/product-name路径。
这与 Bootstrap 网站上推荐的相同,只是 URL 指向我们的网站而不是依赖于公共 CDN。
这个/assets/vendor URL 目前不被Notes应用程序识别。为了添加此支持,编辑app.js以添加以下行:
app.use(express.static(path.join(__dirname, 'public')));
app.use('/assets/vendor/bootstrap', express.static(
path.join(__dirname, 'node_modules', 'bootstrap', 'dist')));
app.use('/assets/vendor/jquery', express.static(
path.join(__dirname, 'node_modules', 'jquery')));
app.use('/assets/vendor/popper.js', express.static(
path.join(__dirname, 'node_modules', 'popper.js', 'dist')));
在public目录下,我们需要做一些基本的整理工作。当express-generator设置初始项目时,它生成了public/images、public/javascripts和public/stylesheets目录。我们希望每个目录都在/assets目录下,所以这样做:
$ mkdir public/assets
$ mv public/images/ public/javascripts/ public/stylesheets/ public/assets/
我们现在有了我们的资产文件,包括 Bootstrap、popper.js和 jQuery,所有这些都在/assets目录下的Notes应用程序中可用。页面布局引用了这些资产,并应提供默认的 Bootstrap 主题:
$ npm start
> notes@0.0.0 start /Users/David/chap06/notes
> DEBUG=notes:* node ./bin/www
notes:server Listening on port 3000 +0ms
GET / 200 306.660 ms - 883
GET /stylesheets/style.css 404 321.057 ms - 2439
GET /assets/stylesheets/style.css 200 160.371 ms - 165
GET /assets/vendor/bootstrap/js/bootstrap.min.js 200 157.459 ms - 50564
GET /assets/vendor/popper.js/popper.min.js 200 769.508 ms - 18070
GET /assets/vendor/jquery/jquery.min.js 200 777.988 ms - 92629
GET /assets/vendor/bootstrap/css/bootstrap.min.css 200 788.028 ms - 127343
屏幕上的差异很小,但这证明了 Bootstrap 的 CSS 和 JavaScript 文件正在被加载。我们已经实现了第一个主要目标——使用现代、移动友好的框架来实现移动优先的设计。
替代布局框架
Bootstrap 并不是唯一提供响应式布局和有用组件的 JavaScript/CSS 框架。我们在这个项目中使用 Bootstrap 是因为它的流行。这些框架值得一看:
-
Pure.css (
purecss.io/): 一个注重小代码体积的响应式 CSS 框架。 -
Picnic CSS (
picnicss.com/): 一个强调小尺寸和美观的响应式 CSS 框架。 -
Shoelace (
shoelace.style/): 一个强调使用未来 CSS 的 CSS 框架,这意味着它使用 CSS 标准化的前沿结构。由于大多数浏览器不支持这些功能,因此使用了 cssnext (cssnext.io/)来提供这些功能的支持。Shoelace 使用基于 Bootstrap 网格的网格布局系统。 -
PaperCSS (
www.getpapercss.com/): 一个看起来像是手工绘制的非正式 CSS 框架。 -
Foundation (
foundation.zurb.com/): 自称为世界上最先进的响应式前端框架。 -
Base (
getbase.org/): 一个轻量级的现代 CSS 框架。
HTML5 Boilerplate (html5boilerplate.com/) 是一个非常有用的编码 HTML 和其他资产的基础。它包含了网页中 HTML 代码的最佳实践,以及用于标准化 CSS 支持和配置文件的工具。
Flexbox 和 CSS Grids
影响 Web 应用程序开发的其他新技术是两种新的 CSS 布局方法。CSS3 委员会已经在多个方面开展工作,包括页面布局。
在遥远的过去,我们使用嵌套 HTML 表格进行页面布局。这是一个我们不希望再次回顾的糟糕记忆。最近,我们一直在使用基于 DIV 的盒模型,有时甚至使用绝对或相对定位技术。所有这些技术都在多个方面表现不佳,有些甚至更差。
一种流行的布局技术是将水平空间划分为列,并将一定数量的列分配给页面上的每个元素。在一些框架中,我们甚至可以有嵌套的 DIV,每个 DIV 都有自己的列集。Bootstrap 3 和其他现代框架使用了这种布局技术。
两种新的 CSS 布局方法,Flexbox (en.wikipedia.org/wiki/CSS_flex-box_layout) 和 CSS Grids (developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout),相对于所有以前的方法都是显著的改进。我们提到这些技术,因为它们都值得注意。两者都处于采纳曲线的早期——它们已经被委员会标准化,并在最新的浏览器中得到采用,但当然,现场还有很多旧浏览器。
在 Bootstrap 4 中,Bootstrap 团队选择使用 Flexbox。因此,底层是 Flexbox CSS 结构。
Notes 应用程序的移动优先设计
我们已经了解了响应式设计和 Bootstrap 的基础知识,并将 Bootstrap 框架集成到我们的应用程序中。现在我们准备重新设计应用程序,使其在移动设备上运行良好。
建立 Bootstrap 网格基础
Bootstrap 使用 12 列网格系统来控制布局,为应用程序提供一个响应式移动优先的基础,以便构建。它自动根据视口的大小或形状变化调整组件。该方法依赖于具有类别的 <div> 元素来描述每个 <div> 在布局中的作用。
基本布局模式如下:
<div class="container-fluid">
<div class="row">
<div class="col-sm-3">Column 1 content</div> <!-- 25% -->
<div class="col-sm-9">Column 2 content</div> <!-- 75% -->
</div>
<div class="row">
<div class="col-sm-3">Column 1 content</div> <!-- 25% -->
<div class="col-sm-6">Column 2 content</div> <!-- 50% -->
<div class="col-sm-3">Column 3 content</div> <!-- 25% -->
</div>
</div>
最外层是 .container 或 .container-fluid 元素。容器提供了一种使内容居中或水平填充的方法。标记为 .container-fluid 的容器表现得像有 width: 100%,这意味着它们会扩展以填充水平空间。
.row 就像它的名字一样,是一个 "row"。技术上,行是列的包装器。容器是行的包装器,行是列的包装器,列包含显示给我们的用户的内容。明白了吗?
列通过 .col 类的变体进行标记。使用基本的列类 .col,列将等分可用空间。您可以指定一个数值列数来为每个列分配不同的宽度。Bootstrap 支持多达 12 个编号列,因此示例中的每一行都包含 12 列。
您还可以指定一个列应用的断点:
-
使用
col-xs目标超小设备(智能手机,<576px) -
使用
col-sm目标小设备 (>= 576px) -
使用
col-md目标中等设备 (>= 768px) -
使用
col-lg目标大设备 (>= 992px) -
使用
col-xl目标超大设备 (>= 1200px)
指定一个断点,例如 col-sm,意味着它适用于匹配该断点的设备或更大的设备。因此,在前面显示的示例中,列定义适用于 col-sm、col-md、col-lg 和 col-xl 设备,但不适用于 col-xs 设备。
列数附加到类名上。这意味着在不针对断点时使用col-#,例如,col-4,或者当针对断点时使用col-{breakpoint}-#,例如,col-md-4。如果列的总数超过 12 列,超出第十二列的列将自动换行成为新的一行。可以使用auto代替数字列数来使列的大小适应其内容的自然宽度。
可以混合匹配以针对多个断点:
<div class="container-fluid">
<div class="row">
<div class="col-xs-9 col-md-3 col-lg-6">Column 1 content</div>
<div class="col-xs-3 col-md-9 col-lg-6">Column 2 content</div>
</div>
...
</div>
这声明了三种不同的布局,一个用于超小设备,另一个用于中等设备,最后一个用于大设备。这为我们提供了足够的资源来开始修改Notes应用。网格系统可以做更多的事情。有关详细信息,请参阅文档:getbootstrap.com/docs/4.0/layout/grid/.
Notes 应用的响应式页面结构
我们将每个页面布局结构如下:
<!DOCTYPE html>
<html>
<head> .. headerStuff </head>
<body>
.. pageHeader
.. main content
.. bottomOfPageStuff
</body>
</html>
因此,页面内容有两个可见的行:页眉和主要内容。在页面底部有一些不可见的东西,比如 Bootstrap 和 jQuery 的 JavaScript 文件。
在views/layout.hbs中不需要进行任何更改。有人可能会认为container-fluid包装器会在这个文件中,行和列在另一个模板中指定。相反,我们将在模板中这样做,以给我们最大的布局自由度。
使用图标库和提升视觉吸引力
我们周围的世界不是由文字构成的,而是由事物构成的。因此,像图标这样的图像风格应该有助于使计算机软件更易于理解。提供良好的用户体验应该会让我们的用户在应用商店中给我们更多的点赞。
在网站上可以使用几个图标库。Bootstrap 团队在getbootstrap.com/docs/4.1/extend/icons/有一个精选列表。对于这个项目,我们将使用 Feather Icons (feathericons.com/)及其方便的 npm 包,www.npmjs.com/package/feather-icons。
在package.json中,将以下内容添加到依赖项中:
"feather-icons": ">=4.5.x"
然后运行npm install来下载新包。然后您可以检查下载的包,并看到./node_modules/feather-icons/dist/feather.js包含浏览器端代码,这使得使用图标变得容易。
我们通过在app.js中挂载它来使该目录可用,就像我们为 Bootstrap 和 jQuery 库所做的那样。将以下代码添加到app.js中:
app.use('/assets/vendor/feather-icons', express.static(
path.join(__dirname, 'node_modules', 'feather-icons', 'dist')));
根据文档,我们必须将此放在views/layout.hbs的底部以启用feather-icons支持:
<script src="img/feather.js"></script>
<script>
feather.replace();
</script>
要使用其中一个图标,请使用data-feather属性指定一个图标名称,如下所示:
<i data-feather="circle"></i>
重要的是 data-feather 属性,这是 Feather Icons 库用来识别要使用的 SVG 文件的属性。Feather Icons 库会完全替换找到 data-feather 属性的元素。因此,如果你想使图标成为一个可点击的链接,就需要用 <a> 标签包裹图标定义,而不是将 data-feather 添加到 <a> 标签中。下一节将展示一个示例。
响应式页面标题导航栏
我们之前设计的页眉部分包含一个页面标题和一个小导航栏。Bootstrap 有几种方法可以使它看起来更漂亮,甚至可以给我们一个响应式的导航栏,在小型设备上可以整洁地折叠成菜单。
在 views/pageHeader.ejs 中进行以下更改:
<header class="page-header">
<h1>{{ title }}</h1>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" href='/'><i data-feather="home"></i></a>
<button class="navbar-toggler" type="button"
data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<div class="navbar-nav col">
{{#if breadcrumb}}
<a class="nav-item nav-link" href='{{breadcrumb.url}}'>
{{breadcrumb.title}}</a>
{{/if}}
</div>
<a class="nav-item nav-link btn btn-light col-auto"
href='/notes/add'>ADD Note</a>
</div>
</nav>
</header>
添加 class="page-header" 通知 Bootstrap 这是一个页面标题。在这个范围内,我们有之前提到的 <h1> 标题,提供页面标题,然后是一个响应式的 Bootstrap navbar。
默认情况下,navbar 是展开的——这意味着 navbar 内部的组件是可见的——这是因为使用了 navbar-expand-md 类。这个 navbar 使用了一个 navbar-toggler 按钮,它控制着 navbar 的响应性。默认情况下,这个按钮是隐藏的,navbar 的主体是可见的。如果屏幕足够小,navbar-toggler 将变为可见,navbar 的主体将不可见,并且当点击现在可见的 navbar-toggler 时,会下拉一个菜单,包含 navbar 的主体:

我们选择了 home 图标,因为它表示返回主页。我们的意图是在导航到 Notes 应用程序时,navbar 的中间部分将包含一个面包屑导航。
添加笔记按钮通过一点 Flexbox 魔法粘附在右侧。容器是一个 Flexbox,这意味着我们可以使用 Bootstrap 类来控制每个项目占用的空间。在这种情况下,面包屑区域是空的,但包含它的 <div> 是存在的,并且声明了 class="col",这意味着它占用一个列单位。另一方面,添加笔记按钮声明为 class="col-auto",这意味着它只占用自身所需的空间。是空的面包屑区域会扩展以填充空间,而添加笔记按钮只填充其自身的空间,因此被推到一边。
因为它是同一个应用程序,所以所有功能都正常工作;我们只是在处理展示。我们添加了一些笔记,但主页上列表的展示还有很多需要改进的地方。标题的大小很小,不太友好,因为它没有提供一个大的目标区域供指尖点击。你能解释为什么 notekey 值必须显示在主页上吗?
改进主页上的笔记列表
当前主页有一个简单的文本列表,它并不非常友好,而且在行首显示 key 可能对用户来说难以理解。让我们来修复这个问题。
编辑 views/index.hbs 并进行以下更改:
<div class="container-fluid">
<div class="row">
<div class="col-12 btn-group-vertical" role="group">
{{#each notelist}}
<a class="btn btn-lg btn-block btn-outline-dark"
href="/notes/view?key={{ key }}">{{ title }}</a>
{{/each}}
</div>
</div>
</div>
第一个更改是放弃使用列表,转而使用垂直按钮组。通过使文本链接看起来和表现得像按钮,我们改善了用户界面,特别是其触摸友好性。我们选择了 btn-outline-dark 按钮样式,因为它在用户界面中看起来很好。我们使用大按钮(btn-lg)来填充容器宽度(btn-block)。
我们消除了向用户展示 notekey 的做法。这些信息并没有为用户体验增添任何价值:

这已经开始成形,拥有一个看起来不错的首页,能够很好地处理尺寸调整,并且触摸友好。
由于页眉区域占据了相当大的空间,我们还有更多的事情要做。当我们查看中间结果时,我们总是可以自由地重新思考计划。早些时候,我们为页眉区域创建了一个设计,但反思后,这个设计看起来太大。原本的意图是在主页图标右侧插入面包屑,并保留页眉区域顶部的 <h1> 标题。但这占用了垂直空间,我们可以使页眉更加紧凑,并可能改善外观。
编辑 partials/header.hbs 并替换为以下内容:
<header class="page-header">
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" href='/'><i data-feather="home"></i></a>
<button class="navbar-toggler" type="button"
data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<span class="navbar-text col">{{ title }}</span>
<a class="nav-item nav-link btn btn-light col-auto" href='/notes/add'>ADD Note</a>
</div>
</nav>
</header>
这移除了页眉区域顶部的 <h1> 标签,立即使展示更加紧凑。
在 navbar-collapse 区域内,我们将原本打算用作面包屑的部分替换为一个简单的 navbar-text 组件。为了使“添加笔记”按钮紧贴右侧,我们保留了 class="col" 和 class="col-auto" 设置:

哪种页眉区域设计更好?这是一个好问题。因为美是主观的,两种设计可能同样优秀。我们所展示的是通过编辑模板文件可以多么容易地更新设计。
简化笔记查看体验
查看笔记本身并不差,但用户体验可以进一步改进。例如,用户不需要看到 notekey。此外,Bootstrap 提供了更美观的按钮供我们使用。
在 views/noteview.hbs 中进行以下更改:
<div class="container-fluid">
<div class="row"><div class="col-xs-12">
{{#if note}}<h3>{{ note.title }}</h3>{{/if}}
{{#if note}}<p>{{ note.body }}</p>{{/if}}
<p>Key: {{ notekey }}</p>
</div></div>
{{#if notekey }}
<div class="row"><div class="col-xs-12">
<div class="btn-group">
<a class="btn btn-outline-dark"
href="/notes/destroy?key={{notekey}}"
role="button">Delete</a>
<a class="btn btn-outline-dark"
href="/notes/edit?key={{notekey}}"
role="button">Edit</a>
</div>
</div></div>
{{/if}}
</div>
我们声明了两行,一行用于笔记,另一行用于对笔记执行操作的按钮。两者都被声明为占用所有 12 列,因此占据全部可用宽度。按钮再次包含在一个按钮组中:

我们真的需要向用户展示 notekey 吗?我们会保留它,但这对于用户体验团队来说是一个开放的问题。否则,我们已经改善了笔记阅读体验。
简化添加/编辑笔记表单
下一个主要明显的问题是添加和编辑笔记的表单。正如我们之前所说,很容易让文本输入区域在小屏幕上溢出。另一方面,Bootstrap 提供了广泛的支持,可以制作出在移动设备上表现良好的漂亮表单。
将views/noteedit.hbs中的form改为以下内容:
<form method='POST' action='/notes/save'>
<div class="container-fluid">
{{#if docreate}}
<input type='hidden' name='docreate' value="create">
{{else}}
<input type='hidden' name='docreate' value="update">
{{/if}}
<div class="form-group row align-items-center">
<label for="notekey" class="col-1 col-form-label">Key</label>
{{#if docreate }}
<div class="col">
<input type='text' class="form-control"
placeholder="note key" name='notekey' value=''/>
</div>
{{else}}
{{#if note }}
<span class="input-group-text">{{notekey}}</span>
{{/if}}
<input type='hidden' name='notekey'
value='{{#if note }}{{notekey}}{{/if}} '/>
{{/if}}
</div>
<div class="form-group row">
<label for="title" class="col-1 col-form-label">Title</label>
<div class="col">
<input type="text" class="form-control"
id='title' name='title' placeholder="note title"
value='{{#if note }}{{note.title}}{{/if}}'>
</div>
</div>
<div class="form-group row">
<textarea class="form-control" name='body'
rows="5">{{#if note }}{{note.body}}{{/if}}</textarea>
</div>
<button type="submit" class="btn btn-default">Submit</button>
</div>
</form>
这里有很多事情在进行。我们所做的是重新组织form,以便 Bootstrap 可以正确地处理它。首先要注意的是,我们有几个这样的实例:
<div class="form-group row"> .. </div>
这些内容都在一个container-fluid中,这意味着我们在表单中设置了三行。
Bootstrap 使用form-group元素为表单添加结构,并鼓励正确使用<label>元素以及其他表单元素。使用<label>与每个<input>一起使用是一种良好的做法,以提高浏览器中的辅助功能行为,而不是简单地留下一些悬空文本。
每个表单元素都有class="form-control"。Bootstrap 使用这个类来识别控件,以便添加样式和行为。
默认情况下,Bootstrap 格式化form-group元素,使label出现在输入控件另一行。请注意,我们已将class="col-1"添加到标签,并将class="col"添加到包裹输入的<div>。这声明了两个列,第一个占用一列单位,另一个占用剩余部分。
placeholder='key'属性在空白的文本输入元素中放置示例文本。一旦用户输入内容,它就会消失,这是一种很好的提示用户输入预期内容的方式。
最后,我们将提交按钮改为了 Bootstrap 按钮。这些按钮看起来很漂亮,Bootstrap 确保它们运行得很好:

结果看起来很好,在 iPhone 上运行良好。它会自动调整大小以适应其所在的屏幕。一切表现都很正常。在这张屏幕截图中,我们将窗口调整得足够小,以至于导航栏发生了折叠。点击右侧所谓的汉堡图标(三条水平线)会导致导航栏内容弹出为菜单。
清理删除笔记窗口
用于验证删除笔记意愿的窗口看起来还不错,但可以进行改进。
编辑views/notedestroy.hbs以包含以下内容:
<form method='POST' action='/notes/destroy/confirm'>
<div class="container-fluid">
<input type='hidden' name='notekey' value='{{#if note}}{{notekey}}{{/if}}'>
<p class="form-text">Delete {{note.title}}?</p>
<div class="btn-group">
<button type="submit" value='DELETE'
class="btn btn-outline-dark">DELETE</button>
<a class="btn btn-outline-dark"
href="/notes/view?key={{#if note}}{{notekey}}{{/if}}"
role="button">
Cancel</a>
</div>
</div>
</form>
我们重新设计了所有内容,以使用 Bootstrap 表单的优点。关于删除笔记的问题用class="form-text"包裹,以便 Bootstrap 可以正确显示。
按钮仍然用class="btn-group"包裹,按钮的样式与其他屏幕上的样式完全相同,保持了应用程序中的一致外观:

存在一个问题,即导航栏中的标题文本没有使用单词Delete。在routes/notes.js中,我们可以进行以下更改:
// Ask to Delete note (destroy)
router.get('/destroy', async (req, res, next) => {
var note = await notes.read(req.query.key);
res.render('notedestroy', {
title: note ? `Delete ${note.title}` : "",
notekey: req.query.key, note: note
});
});
我们所做的是更改传递给模板的title参数。我们在/notes/edit路由处理程序中做了这件事,但似乎在这个处理程序中遗漏了这样做。
构建定制的 Bootstrap
使用 Bootstrap 的一个原因是你可以轻松构建一个定制版本。样式表使用 SASS 构建,SASS 是 CSS 预处理器之一,用于简化 CSS 开发。在 Bootstrap 的代码中,一个文件(scss/_variables.scss)包含了 Bootstrap 的其余 .scss 文件中使用的变量。更改一个变量可以自动影响 Bootstrap 的其余部分。
之前,我们使用自定义 CSS 文件 public/stylesheets/style.css 覆盖了几个 Bootstrap 的行为。这是一种改变几个特定内容的方法,但对于 Bootstrap 的大规模更改不起作用。严肃的 Bootstrap 定制化需要生成定制的 Bootstrap 构建。
Bootstrap 官方网站上的官方文档([getbootstrap.com/docs/4.1/getting-started/build-tools/](http://getbootstrap.com/docs/4.1/getting-started/build-tools/))对于了解构建过程非常有用。
如果你已经按照之前给出的说明操作,你将有一个包含 Notes 应用程序源代码的目录 chap06/notes。在 chap06/notes 中创建一个名为 theme 的目录,我们将在其中设置自定义 Bootstrap 构建。
作为十二要素应用模型的学生,我们将使用该目录中的 package.json来自动化构建过程。其中不涉及任何 Node.js 代码;npm 也是自动化软件构建过程的便捷工具。
首先,从 https://github.com/twbs/bootstrap 下载 Bootstrap 源代码树。虽然 Bootstrap npm 包包括 SASS 源文件,但这不足以构建 Bootstrap,因此必须下载源代码树。我们做的是导航到 GitHub 仓库,点击“Releases”标签,并选择最新版本的 URL。
在包含此 scripts 部分的 theme/package.json 中:
{
"scripts": {
"download": "wget -O - https://github.com/twbs/bootstrap/archive/v4.1.0.tar.gz | tar xvfz -",
"postdownload": "cd bootstrap-4.1.0 && npm install"
}
}
输入以下命令:
$ npm run download
这将从 Bootstrap 仓库下载 tar-gzip(tarball)存档并立即解压。如果你使用的是 Windows,最简单的方法是在 Windows Subsystem for Linux 中运行该脚本以执行这些命令。下载并解压存档后,postdownload 步骤会在该目录中运行 npm install。Bootstrap 团队使用他们的 package.json,不仅用于跟踪构建 Bootstrap 所需的所有依赖项,而且还用于驱动构建过程。
Bootstrap 的 npm install 需要很长时间,所以请耐心等待。
这只安装了构建 Bootstrap 所需的工具。构建 Bootstrap 文档需要安装额外的基于 Ruby 的工具(Jekyll 和一些插件)。
要构建 Bootstrap,让我们将以下行添加到 theme/package.json 中的 scripts 部分:
"scripts": {
...
"build": "cd bootstrap-4.1.0 && npm run dist",
"watch": "cd bootstrap-4.1.0 && npm run watch"
...
}
显然,你需要根据 Bootstrap 项目的新版本发布调整目录名称。在 Bootstrap 源代码树中,运行npm run dist构建 Bootstrap,而npm run watch设置一个自动化的过程来扫描更改的文件,并在任何文件更改时重新构建 Bootstrap。通过将这些行添加到我们的theme/package.json中,我们可以在终端中启动它,并且它会根据需要自动重新运行构建。
现在运行以下命令进行构建:
$ npm run build
构建文件将放在theme/bootstrap-4.1.0/dist目录中。该目录的内容将与相应的 npm 包内容相匹配。
如果一直不明显的话——这些 URL 和文件或目录名中嵌入了 Bootstrap 版本号。随着新 Bootstrap 版本的发布,你必须调整路径名以匹配当前版本号。
在继续之前,让我们看看 Bootstrap 的源代码树。scss目录包含了将被编译成 Bootstrap CSS 文件的 SASS 源代码。要生成定制的 Bootstrap 构建,需要在那个目录中进行一些修改。
bootstrap-4.1.0/scss/bootstrap.scss文件包含@import指令以引入所有 Bootstrap 组件。bootstrap-4.1.0/scss/_variables.scss文件包含 Bootstrap SASS 源代码中使用的定义。编辑或覆盖这些值将改变使用结果 Bootstrap 构建的网站的外观。
例如,以下定义决定了主要颜色值:
$white: #fff !default;
$gray-100: #f8f9fa !default;
...
$gray-800: #343a40 !default;
...
$blue: #007bff !default;
...
$red: #dc3545 !default;
$orange: #fd7e14 !default;
$yellow: #ffc107 !default;
$green: #28a745 !default;
...
$primary: $blue !default;
$secondary: $gray-600 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-100 !default;
$dark: $gray-800 !default;
这些类似于 CSS 语句。!default属性指定这些值为默认值。任何!default值都可以在不编辑_values.scss的情况下进行覆盖。
创建一个文件,theme/_custom.scss,包含以下内容:
$white: #fff !default;
$gray-900: #212529 !default;
$body-bg: $gray-900 !default;
$body-color: $white !default;
这将反转_variables.scss中$body-bg和$body-color设置的值。笔记应用现在将使用白色文本在深色背景上,而不是默认的白色背景和深色文本。因为这些声明没有使用!default,所以它们将覆盖_variables.scss中的值。
然后,在主题目录中复制scss/bootstrap.scss并对其进行修改,如下所示:
@import "custom";
@import "functions";
@import "variables";
...
我们正在导入我们刚刚创建的_custom.scss文件。最后,将此行添加到theme/package.json的scripts部分:
"prebuild": "cp _custom.scss bootstrap.scss bootstrap-4.1.0/scss",
在此基础上,在构建 Bootstrap 之前,这两个文件将被复制到相应位置。接下来,重新构建 Bootstrap:
$ npm run build
> @ prebuild /Users/David/chap06/notes/theme
> cp _custom.scss bootstrap.scss bootstrap-4.1.0/scss
> @ build /Users/David/chap06/notes/theme
> cd bootstrap-4.1.0 && npm run dist
...
当构建进行时,让我们修改notes/app.js以挂载构建目录:
// app.use('/assets/vendor/bootstrap', express.static(
// path.join(__dirname, 'node_modules', 'bootstrap', 'dist'))); app.use('/assets/vendor/bootstrap', express.static(
path.join(__dirname, 'theme', 'bootstrap-4.1.0', 'dist')));
我们所做的是将node_modules中的 Bootstrap 切换到了我们在theme目录中刚刚构建的版本。Bootstrap 的版本号会显示在这里,因此这也必须随着新 Bootstrap 版本的采用而更新。
然后,重新加载应用程序,你会看到以下内容:

要得到完全相同的效果,你可能需要在模板中做一些更改。我们之前使用的按钮元素具有btn-outline-dark类,这在浅色背景上工作得很好。现在背景变暗了,这些按钮需要使用浅色。
要更改按钮,请在views/index.hbs中进行以下更改:
<a class="btn btn-lg btn-block btn-outline-light"
href="/notes/view?key={{ key }}"> {{ title }} </a>
在views/noteview.hbs中做类似的更改:
<a class="btn btn-outline-light" href="/notes/destroy?key={{notekey}}"
role="button"> Delete </a>
<a class="btn btn-outline-light" href="/notes/edit?key={{notekey}}"
role="button"> Edit </a>
这很酷,我们现在可以按任何我们想要的方式重新工作 Bootstrap 颜色方案。不要把这个展示给你的用户体验团队,因为他们会大发雷霆。我们这样做是为了证明我们可以编辑_custom.scss并更改 Bootstrap 主题。
预构建的自定义 Bootstrap 主题
如果这一切对你来说太复杂,有几个网站提供预构建的 Bootstrap 主题,或者提供简化工具来生成 Bootstrap 构建。为了熟悉这个过程,让我们从 Bootswatch 下载一个主题(bootswatch.com/)。这是一个免费和开源主题的集合,也是一个用于生成自定义 Bootstrap 主题的构建系统(github.com/thomaspark/bootswatch/))。
让我们使用 Bootswatch 的Minty主题来探索所需的更改。你可以从网站上下载主题,或者将以下内容添加到package.json的scripts部分:
"dl-minty": "mkdir -p minty && npm run dl-minty-css && npm run dl-minty-min-css",
"dl-minty-css": "wget https://bootswatch.com/4/minty/bootstrap.css -O minty/bootstrap.css",
"dl-minty-min-css": "wget https://bootswatch.com/4/minty/bootstrap.min.css -O minty/bootstrap.min.css"
这将下载我们选择的主题的预构建 CSS 文件。顺便提一下,Bootswatch 网站提供了_variables.scss和_bootswatch.scss文件,这些文件可以使用与我们在上一节中实现的工作流程类似的工作流程使用。与 Bootswatch 网站匹配的 GitHub 存储库具有构建自定义主题的完整构建过程。
执行下载:
$ npm run dl-minty
> notes@0.0.0 dl-minty /Users/David/chap06/notes
> mkdir -p minty && npm run dl-minty-css && npm run dl-minty-min-css
> notes@0.0.0 dl-minty-css /Users/David/chap06/notes
> wget https://bootswatch.com/4/minty/bootstrap.css -O minty/bootstrap.css
> notes@0.0.0 dl-minty-min-css /Users/David/chap06/notes
> wget https://bootswatch.com/4/minty/bootstrap.min.css -O minty/bootstrap.min.css
在app.js中,我们需要将 Bootstrap 挂载更改为分别挂载 JavaScript 和 CSS 文件。使用以下内容:
// app.use('/assets/vendor/bootstrap', express.static(
// path.join(__dirname, 'node_modules', 'bootstrap', 'dist')));
// app.use('/assets/vendor/bootstrap', express.static(
// path.join(__dirname, 'theme', 'bootstrap-4.0.0', 'dist')));
app.use('/assets/vendor/bootstrap/js', express.static(
path.join(__dirname, 'node_modules', 'bootstrap', 'dist', 'js')));
app.use('/assets/vendor/bootstrap/css', express.static(
path.join(__dirname, 'minty')));
现在我们对/vendor/bootstrap的挂载不再是一个,而是每个子目录都有两个挂载。只需将/vendor/bootstrap/css挂载点设置为包含从主题提供商下载的 CSS 文件的目录。
由于 Minty 是一个浅色主题,按钮需要使用深色样式。我们之前因为背景是深色的,所以将按钮更改为使用浅色样式。现在我们必须从btn-outline-light切换回btn-outline-dark。在partials/header.hbs中,颜色方案需要更改导航栏内容:
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<span class="navbar-text text-dark col">{{ title }}</span>
<a class="nav-item nav-link btn btn-dark col-auto" href='/notes/add'>ADD Note</a>
</div>
我们选择了text-dark和btn-dark类来与背景形成对比。
重新运行应用程序,你会看到类似以下内容:

摘要
使用 Bootstrap 的可能性是无限的。虽然我们覆盖了很多内容,但我们只是触及了表面,我们本可以对笔记应用做更多的工作。
你已经了解了 Twitter Bootstrap 框架能做什么。Bootstrap 的目标是使移动响应式开发变得简单。我们使用 Bootstrap 对 Notes 应用的外观和感觉进行了重大改进。我们对 Bootstrap 进行了定制,尝试生成一个自定义主题。
现在,我们想要回到编写 Node.js 代码。我们暂停在第五章,你的第一个 Express 应用程序,讨论了持久性问题,以便在停止和重新启动 Notes 应用程序时不会丢失我们的笔记。在第七章,数据存储和检索中,我们将深入探讨使用数据库来存储我们的数据。
为了让我们自己有一些关于 ES6 模块格式的经验,我们将相应地重写 Notes 应用程序。
第七章:数据存储和检索
在前两章中,我们构建了一个小型且有一定用途的应用程序来存储笔记,然后使其在移动设备上工作。虽然应用程序工作得相当好,但它并没有在任何长期基础上存储那些笔记,这意味着当你停止服务器时,笔记就会丢失,如果你运行多个笔记实例,每个实例都有自己的笔记集。典型的下一步是引入数据库层。
在本章中,我们将探讨 Node.js 中的数据库支持,这样用户在任何访问的笔记实例中都能看到相同的笔记集,并且能够可靠地存储笔记以供长期检索。
我们将从上一章中使用的笔记应用程序代码开始。我们从一个简单的、使用数组存储笔记的内存数据模型开始,然后使其适应移动设备。在本章中,我们将:
-
发现日志操作和调试信息
-
开始使用 ES6 模块格式
-
使用多个数据库引擎实现笔记对象的持久化
让我们开始吧!
第一步是复制上一章中的代码。例如,如果你在chap06/notes中工作,将其复制到chap07/notes。
数据存储和异步代码
根据定义,外部数据存储系统在 Node.js 架构中需要异步代码。从磁盘、另一个进程或数据库中检索数据访问时间总是足够长,以至于需要延迟执行。
现有的Notes数据模型是一个内存数据存储。从理论上讲,内存数据访问不需要异步代码,因此,现有的模型模块可以使用常规函数而不是async函数。
我们知道笔记必须迁移到使用数据库,并且需要异步 API 来访问笔记数据。因此,现有的笔记模型 API 使用async函数,这样我们就可以在本章中将笔记数据持久化到数据库中。
日志
在我们进入数据库之前,我们必须解决一个高质量软件系统的一个属性:管理日志信息,包括正常系统活动、系统错误和调试信息。日志使我们能够深入了解系统的行为。它获得了多少流量?如果是一个网站,人们最多访问哪些页面?发生了多少错误,以及错误的类型是什么?是否发生了攻击?是否发送了格式不正确的请求?
日志管理也是一个问题。日志轮转意味着定期将日志文件移开,以便开始一个新的日志文件。你应该处理日志数据以生成报告。对安全漏洞的筛查必须具有高优先级。
十二因素应用程序模型建议简单地将日志信息发送到控制台,然后其他软件系统捕获该输出并将其导向日志服务。遵循他们的建议可以通过减少可能出错的事物来降低系统复杂性。在后面的章节中,我们将使用 PM2 来实现这个目的。
让我们先完成对笔记中当前信息日志的浏览。
当我们使用 Express Generator 最初创建 Notes 应用程序时,它配置了一个使用 morgan 的活动日志系统:
const logger = require('morgan');
..
app.use(logger('dev'));
这是在终端窗口中打印请求的内容。有关更多信息,请访问 github.com/expressjs/morgan。
在内部,Express 使用 Debug 包进行调试跟踪。您可以使用 DEBUG 环境变量来开启这些功能。我们应该尽量在我们的应用程序代码中使用这个包。更多信息,请访问 www.npmjs.com/package/debug。
最后,应用程序可能会生成未捕获的异常。需要捕获、记录并适当处理uncaughtException错误。
使用 Morgan 进行请求日志记录
Morgan 包有两个主要的配置区域:
-
日志格式
-
日志位置
目前,Notes 使用的是 dev 格式,它被描述为一种简洁的状态输出,旨在为开发者使用。这可以用来记录网络请求,作为衡量网站活动和受欢迎程度的一种方式。Apache 日志格式已经拥有一个庞大的报告工具生态系统,而且确实,Morgan 可以生成这种格式的日志文件。
要更改格式,只需更改app.js中的这一行:
app.use(logger(process.env.REQUEST_LOG_FORMAT || 'dev'));
然后按照以下方式运行 Notes:
$ REQUEST_LOG_FORMAT=common npm start
> notes@0.0.0 start /Users/david/chap07/notes
> node ./bin/www
::1 - - [12/Feb/2016:05:51:21 +0000] "GET / HTTP/1.1" 304 -
::1 - - [12/Feb/2016:05:51:21 +0000] "GET /vendor/bootstrap/css/bootstrap.min.css HTTP/1.1" 304 -
::1 - - [12/Feb/2016:05:51:21 +0000] "GET /stylesheets/style.css HTTP/1.1" 304 -
::1 - - [12/Feb/2016:05:51:21 +0000] "GET /vendor/bootstrap/js/bootstrap.min.js HTTP/1.1" 304 -
要恢复到之前的日志输出,只需不设置此环境变量。如果您查看过 Apache 访问日志,这种日志格式看起来会熟悉。行首的 ::1 表示法是 localhost 的 IPV6 表示法,您可能更熟悉的是 127.0.0.1。
我们可以在请求日志记录上宣布胜利,然后转向调试消息。然而,让我们看看如何将日志直接记录到文件中。虽然可以通过一个单独的进程捕获 stdout,但 Morgan 已经安装在 Notes 中,并且它确实提供了将输出直接导向文件的能力。
Morgan 文档建议如下:
// create a write stream (in append mode)
var accessLogStream = fs.createWriteStream(__dirname + '/access.log', {flags: 'a'})
// setup the logger
app.use(morgan('combined', {stream: accessLogStream}));
但这有一个问题;在不杀死和重新启动服务器的情况下,无法执行日志轮转。因此,我们将使用他们的 rotating-file-stream 包。
首先,安装该包:
$ npm install rotating-file-stream --save
然后我们将此代码添加到app.js中:
const fs = require('fs-extra');
...
const rfs = require('rotating-file-stream');
var logStream;
// Log to a file if requested
if (process.env.REQUEST_LOG_FILE) {
(async () => {
let logDirectory = path.dirname(process.env.REQUEST_LOG_FILE);
await fs.ensureDir(logDirectory);
logStream = rfs(process.env.REQUEST_LOG_FILE, {
size: '10M', // rotate every 10 MegaBytes written
interval: '1d', // rotate daily
compress: 'gzip' // compress rotated files
});
})().catch(err => { console.error(err); });
}
..
app.use(logger(process.env.REQUEST_LOG_FORMAT || 'dev', {
stream: logStream ? logStream : process.stdout
}));
在这里,我们使用环境变量 REQUEST_LOG_FILE 来控制是否将日志发送到 stdout 或文件。日志可以放入目录中,如果目录不存在,代码将自动创建该目录。通过使用 rotating-file-stream (www.npmjs.com/package/rotating-file-stream),我们确保了日志文件轮转,无需额外的系统。
使用 fs-extra 模块是因为它向 fs 模块添加了基于 Promise 的函数 (www.npmjs.com/package/fs-extra)。在这种情况下,fs.ensureDir 检查指定的目录结构是否存在,如果不存在,则创建目录路径。
调试信息
通过这种方式运行 Notes,你可以生成 Express 所做的详细跟踪:
$ DEBUG=express:* npm start
如果你想调试 Express,这非常有用。但,我们也可以在我们的代码中使用它。这类似于插入 console.log 语句,但无需记住要注释掉调试代码。
启用模块中的调试非常简单:
const debug = require('debug')('module-name');
..
debug('some message');
..
debug(`got file ${fileName}`);
捕获 stdout 和 stderr
重要信息可以打印到 process.stdout 或 process.stderr,如果未捕获该输出,则可能会丢失。十二要素模型建议使用系统功能来捕获这些输出流。在笔记中,我们将使用 PM2 来实现这一目的,这将在第十章 “部署 Node.js 应用程序” 中介绍。
logbook 模块 (github.com/jpillora/node-logbook) 在捕获 process.stdout 和 process.stderr 方面提供了一些有用的功能,并且可以将输出发送到有用的位置。
未捕获的异常
未捕获的异常是另一个可能会丢失重要信息的地方。在 Notes 应用程序中修复这个问题很容易:
const error = require('debug')('notes:error');
process.on('uncaughtException', function(err) {
error("I've crashed!!! - "+ (err.stack || err));
});
..
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
// util.log(err.message);
res.status(err.status || 500);
error((err.status || 500) +' '+ error.message);
res.render('error', {
message: err.message,
error: err
});
});
}
..
app.use(function(err, req, res, next) {
// util.log(err.message);
res.status(err.status || 500);
error((err.status || 500) +' '+ error.message);
res.render('error', {
message: err.message,
error: {}
});
});
debug 包有一个我们遵循的约定。对于具有多个模块的应用程序,所有调试对象都应该使用命名模式 app-name:module-name。在这种情况下,我们使用了 notes:error,它将用于所有错误消息。我们也可以使用 notes:memory-model 或 notes:mysql-model 来调试不同的模型。
当我们设置未捕获异常的处理程序时,将错误记录添加到错误处理程序中也是一个好主意。
未处理的 Promise 拒绝
使用 Promise 和 async 函数自动将错误引导到有用的方向。错误会导致 Promise 转换为 拒绝 状态,这最终必须在 .catch 方法中处理。由于我们都是人类,我们难免会忘记确保所有代码路径都处理了它们的拒绝的 Promise。
目前,如果 Node.js 检测到未处理的 Promise 拒绝,它会打印以下警告:
(node:4796) UnhandledPromiseRejectionWarning: Unhandled promise rejection
警告继续说明,未处理的 Promise 拒绝的默认处理程序已被弃用,并且此类 Promise 拒绝将使 Node 进程崩溃而不是打印此消息。在这种情况下,内置的process模块确实会发出事件,因此添加处理程序很容易:
import util from 'util';
...
process.on('unhandledRejection', (reason, p) => {
error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});
至少,我们可以打印如下错误消息:
notes:error Unhandled Rejection at: Promise {
notes:error <rejected> TypeError: model(...).keylist is not a function
... full stack trace
} reason: TypeError: model(...).keylist is not a function +3s
使用 ES6 模块格式
我们使用 CommonJS 模块编写了笔记应用程序,这是传统的 Node.js 模块格式。虽然应用程序可以继续使用该格式,但 JavaScript 社区已经选择在浏览器和 Node.js 代码中切换到 ES6 模块,因此切换到 ES6 模块非常重要,这样我们就可以使用统一的模块格式。让我们使用 ES6 模块重写应用程序,然后为任何新添加的内容编写 ES6 模块。
需要的更改很大,需要将require语句替换为import语句,并将文件从foo.js重命名为foo.mjs。让我们开始吧。
将 app.js 重写为 ES6 模块
让我们从app.js开始,将其名称更改为app.mjs:
$ mv app.js app.mjs
将顶部的require语句块更改为以下内容:
import fs from 'fs-extra';
import url from 'url';
import express from 'express';
import hbs from 'hbs';
import path from 'path';
import util from 'util';
import favicon from 'serve-favicon';
import logger from 'morgan';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import DBG from 'debug';
const debug = DBG('notes:debug');
const error = DBG('notes:error');
import { router as index } from './routes/index';
// const users = require('./routes/users');
import { router as notes } from './routes/notes';
// Workaround for lack of __dirname in ES6 modules
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const app = express();
import rfs from 'rotating-file-stream';
然后,在脚本的底部进行以下更改:
export default app;
让我们简要谈谈这里提到的解决方案。Node.js 自动注入到 CommonJS 模块中的有几个全局变量。这些变量不受 ES6 模块支持。对于笔记应用程序来说,关键的变量是__dirname,它在app.mjs中多处使用。这里显示的代码更改包括基于 Node.js 10.x 开始可用的全新 JavaScript 功能的一个解决方案,即import.meta.url变量。
import.meta对象旨在将有用信息注入到 ES6 模块中。正如其名所示,import.meta.url变量包含描述模块从何处加载的 URL。对于 Node.js 来说,目前,ES6 模块只能从本地文件系统上的file:// URL 加载。这意味着,如果我们提取该 URL 的pathname,我们可以轻松计算出包含模块的目录,如下所示。
为什么选择这个解决方案?为什么不使用以./开头的路径名?答案是,./文件名是相对于进程的当前工作目录进行评估的。这个目录通常与正在执行的 Node.js 模块所在的目录不同。因此,Node.js 团队添加import.meta.url功能是非常方便的。
在大多数情况下,遵循以下更改模式:
const moduleName = require('moduleName'); // in CommonJS modules
import moduleName from 'moduleName'; // in ES6 modules
记住,Node.js 在 ES6 和 CommonJS 模块中都使用相同的模块查找算法。Node.js 的require语句是同步的,这意味着在require完成时,它已经执行了模块并返回了其module.exports。相比之下,ES6 模块是异步的,这意味着模块可能还没有完成加载,你可以只导入所需的模块部分。
这里显示的大多数模块导入是为了在 node_modules 目录中安装的常规 Node.js 模块,其中大多数是 CommonJS 模块。使用 import 与 CommonJS 模块的规则是,module.exports 对象被处理为如果它是默认导出。前面显示的 import 语句将默认导出(或 module.exports 对象)命名为 import 语句中所示。对于以这种方式导入的 CommonJS 模块,您将像在 CommonJS 上下文中一样使用它,即 moduleName.functionName()。
debug 模块的使用实际上是相同的,但编码方式不同。在 CommonJS 上下文中,我们被告知如下使用该模块:
const debug = require('debug')('notes:debug');
const error = require('debug')('notes:error');
换句话说,该模块的 module.exports 是一个函数,我们立即调用它。ES6 模块没有使用 debug 模块的那种语法的语法。因此,我们必须像下面那样将其拆分,并显式调用该函数。
讨论的最后一个要点是两个路由模块的 import。最初尝试让这些模块将 router 作为默认值导出,但 Express 在那种情况下抛出了错误。因此,我们将重写这些模块,将 router 作为命名导出,然后像下面这样使用它。
将 bin/www 重写为 ES6 模块
记住 bin/www 是一个用于启动应用程序的脚本。它被编写为一个 CommonJS 脚本,但由于 app.mjs 现在是一个 ES6 模块,因此 bin/www 也必须被重写为 ES6 模块。在编写本文时,CommonJS 模块不能导入 ES6 模块。
更改文件名:
$ mv bin/www bin/www.mjs
然后,在顶部,将 require 语句更改为 import 语句:
import app from '../app.mjs';
import DBG from 'debug';
const debug = DBG('notes:server-debug');
const error = DBG('notes:server-error');
import http from 'http';
我们已经讨论了这里的一切,除了 app.mjs 将其 app 对象作为默认导出。因此,我们像下面这样使用它。
将模型代码重写为 ES6 模块
模型目录包含两个模块:Note.js 定义了 Note 类,而 notes-memory.js 包含一个内存中的数据模型。这两个模块都很容易转换为 ES6 模块。
更改文件名:
$ cd models
$ mv Note.js Note.mjs
$ mv notes-memory.js notes-memory.mjs
在 Note.mjs 中,只需进行以下更改:
export default class Note {
...
}
这使得 Note 类成为默认导出。
然后,在 notes-memory.mjs 中进行以下更改:
import Note from './Note';
var notes = [];
async function crupdate(key, title, body) {
notes[key] = new Note(key, title, body);
return notes[key];
}
export function create(key, title, body) { return crupdate(key, title, body); }
export function update(key, title, body) { return crupdate(key, title, body); }
export async function read(key) {
if (notes[key]) return notes[key];
else throw new Error(`Note ${key} does not exist`);
}
export async function destroy(key) {
if (notes[key]) {
delete notes[key];
} else throw new Error(`Note ${key} does not exist`);
}
export async function keylist() { return Object.keys(notes); }
export async function count() { return notes.length; }
export async function close() { }
这是对将函数分配给 module.exports 并使用命名导出的直接转写。
通过将 Note 类定义为 Note.mjs 模块的默认导出,它可以很好地导入使用该类的任何模块。
将路由模块重写为 ES6 模块
routes 目录包含两个路由模块。目前,每个路由模块创建一个 router 对象,向该对象添加路由函数,然后将它分配给 module.exports 字段。这表明我们应该将 router 作为默认导出,但如我们之前所说,这并没有成功。相反,我们将 router 作为命名导出。
更改文件名:
$ cd routes
$ mv index.js index.mjs
$ mv notes.js notes.mjs
然后,在每个文件的顶部,将 require 语句块更改为以下内容:
import util from 'util';
import express from 'express';
import * as notes from '../models/notes-memory';
export const router = express.Router();
这两个文件将会一样。然后在每个文件的底部,删除将 router 赋值给 module.exports 的那行代码。
让我们转向 app.mjs 并更改导入路由模块的方式。
因为 router 是一个命名导出,默认情况下你会在 app.mjs 中如下导入 router 对象:
import { router } from './routes/index';
但然后我们会遇到冲突,因为这两个模块都定义了一个 router 对象。相反,我们使用 as 子句更改了这个对象的名称:
import { router as index } from './routes/index';
import { router as notes } from './routes/notes';
每个模块的 router 对象因此被赋予了合适的名称。
在文件系统中存储笔记
文件系统是一个经常被忽视的数据库引擎。虽然文件系统没有数据库引擎支持的查询功能,但它们是存储文件的可靠位置。笔记模式足够简单,以至于文件系统可以轻松地作为其数据存储层。
让我们从向 Note.mjs 添加一个函数开始:
export default class Note {
...
get JSON() {
return JSON.stringify({
key: this.key, title: this.title, body: this.body
});
}
static fromJSON(json) {
var data = JSON.parse(json);
var note = new Note(data.key, data.title, data.body);
return note;
}
}
JSON 是一个获取器,这意味着它获取对象的值。在这种情况下,note.JSON 属性/获取器,没有括号,将简单地给出 Note 的 JSON 表示形式。我们将在写入 JSON 文件时使用这个。
fromJSON 是一个静态函数,或者工厂方法,用于在拥有 JSON 字符串的情况下辅助构建 Note 对象。区别在于 JSON 与 Note 类的实例相关联,而 fromJSON 与类本身相关联。它们可以如下使用:
const note = new Note("key", "title", "body");
const json = note.JSON; // produces JSON text
const newnote = Note.fromJSON(json); // produces new Note instance
现在,让我们创建一个新的模块,models/notes-fs.mjs,来保存文件系统模型:
import fs from 'fs-extra';
import path from 'path';
import util from 'util';
import Note from './Note';
import DBG from 'debug';
const debug = DBG('notes:notes-fs');
const error = DBG('notes:error-fs');
async function notesDir() {
const dir = process.env.NOTES_FS_DIR || "notes-fs-data";
await fs.ensureDir(dir);
return dir;
}
function filePath(notesdir, key) { return path.join(notesdir, `${key}.json`); }
async function readJSON(notesdir, key) {
const readFrom = filePath(notesdir, key);
var data = await fs.readFile(readFrom, 'utf8');
return Note.fromJSON(data);
}
notesDir 函数将在 notes-fs 中被使用,以确保目录存在。为了简化这个过程,我们使用了 fs-extra 模块,因为它向 fs 模块添加了基于 Promise 的函数(www.npmjs.com/package/fs-extra)。在这种情况下,fs.ensureDir 验证指定的目录结构是否存在,如果不存在,则创建目录路径。
环境变量 NOTES_FS_DIR 配置了一个存储笔记的目录。我们将为每篇笔记创建一个文件,并将笔记存储为 JSON 格式。如果没有指定环境变量,我们将回退到使用 notes-fs-data 作为目录名称。
因为我们要添加另一个依赖:
$ npm install fs-extra --save
每个数据文件的文件名是带有 .json 后缀的 key。这带来一个限制,即文件名不能包含 / 字符,因此我们使用以下代码进行测试:
async function crupdate(key, title, body) {
var notesdir = await notesDir();
if (key.indexOf('/') >= 0)
throw new Error(`key ${key} cannot contain '/'`);
var note = new Note(key, title, body);
const writeTo = filePath(notesdir, key);
const writeJSON = note.JSON;
await fs.writeFile(writeTo, writeJSON, 'utf8');
return note;
}
export function create(key, title, body) { return crupdate(key, title, body); }
export function update(key, title, body) { return crupdate(key, title, body); }
与 notes-memory 模块一样,create 和 update 函数使用完全相同的代码。notesDir 函数用于确保目录存在,然后我们创建一个 Note 对象,然后将数据写入文件。
注意代码因为使用了 async 函数而非常直接。我们不需要检查错误,因为它们将被 async 函数自动捕获并传递给我们的调用者:
export async function read(key) {
var notesdir = await notesDir();
var thenote = await readJSON(notesdir, key);
return thenote;
}
使用 readJSON 从磁盘读取文件。它已经生成了 Note 对象,所以我们只需返回该对象即可:
export async function destroy(key) {
var notesdir = await notesDir();
await fs.unlink(filePath(notesdir, key));
}
fs.unlink函数删除我们的文件。因为这个模块使用文件系统,所以删除文件就是删除note对象所必需的:
export async function keylist() {
var notesdir = await notesDir();
var filez = await fs.readdir(notesdir);
if (!filez || typeof filez === 'undefined') filez = [];
var thenotes = filez.map(async fname => {
var key = path.basename(fname, '.json');
var thenote = await readJSON(notesdir, key);
return thenote.key;
});
return Promise.all(thenotes);
}
keylist的合约是返回一个 Promise,该 Promise 将解析为现有笔记对象的键数组。由于它们作为单独的文件存储在notesdir中,我们必须读取该目录中的每个文件来检索其键。
Array.map从一个现有的数组(即由fs.readdir返回的文件名数组)构造一个新的数组。构造数组中的每个条目都是一个async函数,它读取笔记,返回key:
export async function count() {
var notesdir = await notesDir();
var filez = await fs.readdir(notesdir);
return filez.length;
}
export async function close() { }
计算笔记的数量只是简单地计算notesdir中文件的数量。
ES6 模块的动态导入
在我们开始修改路由函数之前,我们必须考虑如何处理多个模型。我们目前有两个数据模型模块,notes-memory和notes-fs,并且我们将在本章结束时实现更多。我们需要一个简单的方法来选择正在使用的模型。
有几种可能的方法可以做到这一点。例如,在一个 CommonJS 模块中,可以这样做:
const path = require('path');
const notes = require(process.env.NOTES_MODEL
? path.join('..', process.env.NOTES_MODEL)
: '../models/notes-memory');
这让我们可以设置一个环境变量NOTES_MODEL来选择用于数据模型的模块。
这种方法不适用于常规的import语句,因为import语句中的模块名不能是这种表达式。现在 Node.js 中的动态导入功能确实提供了一个类似于刚才显示的片段的机制。
动态导入是一个返回 Promise 的import()函数,该 Promise 将解析为导入的模块。作为一个返回 Promise 的函数,import()在模块的顶层代码中不会很有用。但是,考虑以下情况:
var NotesModule;
async function model() {
if (NotesModule) return NotesModule;
NotesModule = await import(`../models/notes-${process.env.NOTES_MODEL}`);
return NotesModule;
}
export async function create(key, title, body) {
return (await model()).create(key, title, body);
}
export async function update(key, title, body) {
return (await model()).update(key, title, body);
}
export async function read(key) { return (await model()).read(key); }
export async function destroy(key) { return (await model()).destroy(key); }
export async function keylist() { return (await model()).keylist(); }
export async function count() { return (await model()).count(); }
export async function close() { return (await model()).close(); }
将该模块保存在文件models/notes.mjs中。该模块实现了我们将用于所有笔记模型模块的相同 API。model()函数是动态选择基于环境变量的笔记模型实现的关键。
这是一个async函数,因此它的返回值是一个 Promise。该 Promise 的值是import()加载的所选模块。因为import()返回一个 Promise,所以我们使用await来知道它是否正确加载。
每个 API 方法都遵循以下模式:
export async function methodName(args) {
return (await model()).methodName(args);
}
因为model()返回一个 Promise,所以最简洁的方法是使用一个async函数,并使用await来解析 Promise。一旦 Promise 解析完成,我们只需调用methodName函数并继续我们的业务。否则,那些 API 方法函数将如下所示:
export function methodName(args) {
return model().then(notes => { return notes.*methodName*(args); });
}
这两种实现是等效的,并且很明显哪种更简洁。
在所有这些对async函数返回的 Promise 的await等待中,讨论开销是值得的。最坏的情况是在第一次调用model()时,因为选定的笔记模型尚未加载。第一次调用时,调用流程如下:
-
API 方法调用
model(),然后调用import(),然后await模块以完成加载 -
API 方法
await等待model()返回的 Promise,获取模块对象,然后调用 API 函数 -
调用者也在使用
await来接收最终结果
第一次运行时,时间主要被import()加载模块的过程所占据。在后续调用中,模块已经被加载,第一步就是简单地形成一个包含模块的已解析的 Promise。然后 API 方法可以快速地委托给实际的 API 方法。
要使用这个功能,在routes/index.mjs和routes/notes.mjs中,我们进行以下更改:
import util from 'util';
import express from 'express';
import * as notes from '../models/notes';
export const router = express.Router();
使用文件系统存储运行笔记应用程序
在package.json中,在scripts部分添加以下内容:
"start-fs": "DEBUG=notes:* NOTES_MODEL=fs node --experimental-modules ./bin/www.mjs",
当你在package.json中放入这些条目时,请确保使用正确的 JSON 语法。特别是,如果你在scripts部分的末尾留下逗号,它将无法解析,并且 npm 将抛出一个错误信息。
在放置好这段代码后,我们现在可以按照以下方式运行笔记应用程序:
$ DEBUG=notes:* npm run start-fs
> notes@0.0.0 start-fs /Users/david/chap07/notes
> NOTES_MODEL=models/notes-fs node --experimental-modules./bin/www.mjs
notes:server Listening on port 3000 +0ms
notes:fs-model keylist dir=notes-fs-data files=[ ] +4s
然后,我们可以在之前一样的方式使用http://localhost:3000上的应用程序。因为我们没有更改任何模板或 CSS 文件,所以应用程序将看起来与你在第六章实现移动优先范式结束时的样子完全一样。
因为notes:*的调试被打开,我们会看到笔记应用程序正在进行的日志。通过简单地不设置DEBUG变量,很容易关闭这个功能。
你现在可以终止并重新启动笔记应用程序,并看到完全相同的笔记。你还可以使用常规文本编辑器,如vi在命令行中编辑笔记。你现在可以在不同的端口上启动多个服务器,并看到完全相同的笔记:
"server1": "NOTES_MODEL=fs PORT=3001 node --experimental-modules./bin/www.mjs",
"server2": "NOTES_MODEL=fs PORT=3002 node --experimental-modules./bin/www.mjs",
然后,你在单独的命令窗口中启动server1和server2,就像我们在第五章你的第一个 Express 应用程序中做的那样。然后,在单独的浏览器窗口中访问这两个服务器,你将看到两个浏览器窗口都显示了相同的笔记。
最后的检查是创建一个键中包含/字符的笔记。记住,键用于生成存储笔记的文件名,因此键不能包含/字符。在浏览器打开的情况下,点击“添加笔记”并输入笔记,确保你在key字段中使用/字符。点击提交按钮时,你会看到一个错误信息,说明这是不允许的。
使用 LevelUP 数据存储存储笔记
要开始使用实际的数据库,让我们看看一个非常轻量级、小体积的数据库引擎:LevelUP。这是一个围绕 Google 开发的 LevelDB 引擎的 Node.js 友好型包装器,通常用于在网页浏览器中进行本地数据持久化。它是一个非索引的 NoSQL 数据存储,最初是为在浏览器中使用而设计的。Node.js 模块 Level 使用 LevelDB API,并支持多个后端,包括LevelDOWN,它将 C++ LevelDB 数据库集成到 Node.js 中。
访问 www.npmjs.com/package/level 获取有关模块的信息。level 包自动设置 levelup 和 leveldown 包。
要安装数据库引擎,请运行以下命令:
$ npm install level@2.1.x --save
然后,开始创建 models/notes-level.mjs 模块:
import fs from 'fs-extra';
import path from 'path';
import util from 'util';
import Note from './Note';
import level from 'level';
import DBG from 'debug';
const debug = DBG('notes:notes-level');
const error = DBG('notes:error-level');
var db;
async function connectDB() {
if (typeof db !== 'undefined' || db) return db;
db = await level(
process.env.LEVELDB_LOCATION || 'notes.level', {
createIfMissing: true,
valueEncoding: "json"
});
return db;
}
level 模块通过 db 对象为我们提供了一个与数据库交互的方式。我们为了方便使用,将此对象作为模块中的全局变量存储。如果 db 对象已设置,我们就可以立即返回它。否则,我们将使用 createIfMissing 打开数据库,以便在需要时创建数据库。
数据库的位置默认为当前目录中的 notes.level。可以通过设置环境变量 LEVELDB_LOCATION(顾名思义)来指定数据库位置:
async function crupdate(key, title, body) {
const db = await connectDB();
var note = new Note(key, title, body);
await db.put(key, note.JSON);
return note;
}
export function create(key, title, body) {
return crupdate(key, title, body);
}
export function update(key, title, body) {
return crupdate(key, title, body);
}
调用 db.put 要么创建一个新的数据库条目,要么替换现有的一个。因此,update 和 create 都被设置为相同的函数。我们将笔记转换为 JSON,以便它可以轻松地存储在数据库中:
export async function read(key) {
const db = await connectDB();
var note = Note.fromJSON(await db.get(key));
return new Note(note.key, note.title, note.body);
}
读取笔记很简单:只需调用 db.get,它将检索数据,该数据必须从 JSON 表示形式中解码。
注意,db.get 和 db.put 没有使用回调函数,我们使用 await 来获取结果值。level 导出的函数可以接受回调函数,其中回调将被调用。如果没有提供回调函数,则 level 函数将返回一个 Promise 以与 async 函数兼容:
export async function destroy(key) {
const db = await connectDB();
await db.del(key);
}
db.destroy 函数从数据库中删除记录:
export async function keylist() {
const db = await connectDB();
var keyz = [];
await new Promise((resolve, reject) => {
db.createKeyStream()
.on('data', data => keyz.push(data))
.on('error', err => reject(err))
.on('end', () => resolve(keyz));
});
return keyz;
}
export async function count() {
const db = await connectDB();
var total = 0;
await new Promise((resolve, reject) => {
db.createKeyStream()
.on('data', data => total++)
.on('error', err => reject(err))
.on('end', () => resolve(total));
});
return total;
}
export async function close() {
var _db = db;
db = undefined;
return _db ? _db.close() : undefined;
}
createKeyStream 函数使用类似于 Streams API 的事件驱动接口。它将流经每个数据库条目,在过程中发出事件。对于数据库中的每个键都会发出 data 事件,在数据库结束时发出 end 事件,在出错时发出 error 事件。结果是,没有简单的方法来将其表示为一个简单的 Promise。相反,我们调用 createKeyStream,让它运行,在运行过程中收集数据。我们必须将其包装在一个 Promise 对象中,并在 end 事件上调用 resolve。
然后将此内容添加到 package.json 的 scripts 部分:
"start-level": "DEBUG=notes:* NOTES_MODEL=level node --experimental-modules ./bin/www.mjs",
最后,您可以运行 Notes 应用程序:
$ DEBUG=notes:* npm run start-level
> notes@0.0.0 start /Users/david/chap07/notes
> node ./bin/www
notes:server Listening on port 3000 +0ms
控制台中的打印输出将与应用程序相同。您可以对其进行测试,以查看一切是否正常工作。
由于 level 不支持多个实例同时访问数据库,您将无法使用多个 Notes 应用程序场景。然而,您将能够随意停止和重新启动应用程序而不会丢失任何笔记。
使用 SQLite3 在 SQL 中存储笔记
要开始使用更常见的数据库,让我们看看如何在 Node.js 中使用 SQL。首先,我们将使用 SQLite3,这是一个轻量级、易于设置的数据库引擎,非常适合许多应用程序。
要了解该数据库引擎,请访问www.sqlite.org/。
要了解 Node.js 模块,请访问github.com/mapbox/node-sqlite3/wiki/API或www.npmjs.com/package/sqlite3。
SQLite3 的主要优势是它不需要服务器;它是一个自包含的、无需设置的 SQL 数据库。
第一步是安装模块:
$ npm install sqlite3@3.x --save
SQLite3 数据库模式
接下来,我们需要确保我们的数据库已配置。我们使用以下 SQL 表定义作为模式(将其保存为models/schema-sqlite3.sql):
CREATE TABLE IF NOT EXISTS notes (
notekey VARCHAR(255),
title VARCHAR(255),
body TEXT
);
我们如何在编写代码之前初始化这个模式?一种方法是通过您的操作系统包管理系统确保安装了sqlite3包,例如在 Ubuntu/Debian 上使用apt-get,在 macOS 上使用 MacPorts。一旦安装完成,您就可以运行以下命令:
$ sqlite3 chap07.sqlite3
SQLite version 3.21.0 2017-10-24 18:55:49
Enter ".help" for usage hints.
sqlite> CREATE TABLE IF NOT EXISTS notes (
...> notekey VARCHAR(255),
...> title VARCHAR(255),
...> body TEXT
...> );
sqlite> .schema notes
CREATE TABLE notes (
notekey VARCHAR(255),
title VARCHAR(255),
body TEXT
);
sqlite> ^D
$ ls -l chap07.sqlite3
-rwx------ 1 david staff 8192 Jan 14 20:40 chap07.sqlite3
虽然我们可以这样做,但十二要素应用程序模型表示我们必须以这种方式自动化任何管理过程。为此,我们应该编写一个小脚本,在 SQLite3 上运行 SQL 操作,并使用它来初始化数据库。
幸运的是,sqlite3命令为我们提供了这样做的方法。将以下内容添加到package.json的scripts部分:
"sqlite3-setup": "sqlite3 chap07.sqlite3 --init models/schema-sqlite3.sql",
运行设置脚本:
$ npm run sqlite3-setup
> notes@0.0.0 sqlite3-setup /Users/david/chap07/notes
> sqlite3 chap07.sqlite3 --init models/schema-sqlite3.sql
-- Loading resources from models/schema-sqlite3.sql
SQLite version 3.10.2 2016-01-20 15:27:19
Enter ".help" for usage hints.
sqlite> .schema notes
CREATE TABLE notes (
notekey VARCHAR(255),
title VARCHAR(255),
body TEXT
);
sqlite> ^D
我们可以编写一个小的 Node.js 脚本来完成这个任务,这样做也很容易。然而,通过使用包提供的工具,我们在自己的项目中需要维护的代码更少。
SQLite3 模型代码
现在,我们可以编写代码来在笔记应用程序中使用这个数据库。
创建models/notes-sqlite3.mjs文件:
import util from 'util';
import Note from './Note';
import sqlite3 from 'sqlite3';
import DBG from 'debug';
const debug = DBG('notes:notes-sqlite3');
const error = DBG('notes:error-sqlite3');
var db; // store the database connection here
async function connectDB() {
if (db) return db;
var dbfile = process.env.SQLITE_FILE || "notes.sqlite3";
await new Promise((resolve, reject) => {
db = new sqlite3.Database(dbfile,
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE,
err => {
if (err) return reject(err);
resolve(db);
});
});
return db;
}
这与notes-level.mjs中的connectDB函数具有相同的目的:管理数据库连接。如果数据库没有打开,它会继续打开,并确保数据库文件被创建(如果不存在)。但如果数据库已经打开,它将立即返回:
export async function create(key, title, body) {
var db = await connectDB();
var note = new Note(key, title, body);
await new Promise((resolve, reject) => {
db.run("INSERT INTO notes ( notekey, title, body) "+
"VALUES ( ?, ? , ? );", [ key, title, body ], err => {
if (err) return reject(err);
resolve(note);
});
});
return note;
}
export async function update(key, title, body) {
var db = await connectDB();
var note = new Note(key, title, body);
await new Promise((resolve, reject) => {
db.run("UPDATE notes "+
"SET title = ?, body = ? WHERE notekey = ?",
[ title, body, key ], err => {
if (err) return reject(err);
resolve(note);
});
});
return note;
}
这些是我们的create和update函数。正如承诺的那样,我们现在有理由定义笔记模型,使其具有create和update操作的单独函数,因为每个的 SQL 语句都不同。
调用db.run执行 SQL 查询,给我们机会将参数插入到查询字符串中。
sqlite3模块使用在 SQL 编程接口中常见的参数替换范式。程序员将 SQL 查询放入字符串中,然后在要插入值的地方放置一个问号。查询字符串中的每个问号都必须与程序员提供的数组中的值匹配。模块负责正确编码值,以确保查询字符串格式正确,同时防止 SQL 注入攻击。
db.run 函数简单地运行它所给的 SQL 查询,并不检索任何数据。因为 sqlite3 模块不产生任何类型的 Promise,我们必须在函数调用中包裹一个 Promise 对象:
export async function read(key) {
var db = await connectDB();
var note = await new Promise((resolve, reject) => {
db.get("SELECT * FROM notes WHERE notekey = ?", [key], (err,row) => {
if (err) return reject(err);
const note = new Note(row.notekey, row.title, row.body);
resolve(note);
});
});
return note;
}
要使用 sqlite3 模块检索数据,你使用 db.get、db.all 或 db.each 函数。这里使用的 db.get 函数只返回结果集的第一行。db.all 函数一次性返回结果集的所有行,如果结果集很大,这可能会成为内存问题。db.each 函数逐行检索,同时仍然允许处理整个结果集。
对于 笔记 应用程序,使用 db.get 来检索笔记是足够的,因为每个 notekey 只有一个笔记。因此,我们的 SELECT 查询无论如何最多只返回一行。但如果你的应用程序会在结果集中看到多行呢?我们将在下一分钟看看如何处理这个问题。
顺便说一句,这个 read 函数中有一个错误。看看你是否能找到错误。我们将在第十一章 单元测试和功能测试 中了解更多,当我们的测试工作揭示这个错误时:
export async function destroy(key) {
var db = await connectDB();
return await new Promise((resolve, reject) => {
db.run("DELETE FROM notes WHERE notekey = ?;", [key], err => {
if (err) return reject(err);
resolve();
});
});
}
要删除笔记,我们只需执行 DELETE FROM 语句:
export async function keylist() {
var db = await connectDB();
var keyz = await new Promise((resolve, reject) => {
var keyz = [];
db.all("SELECT notekey FROM notes", (err, rows) => {
if (err) return reject(err);
resolve(rows.map(row => row.notekey ));
});
});
return keyz;
}
db.all 函数检索结果集的所有行。
这个函数的合约是返回一个笔记键数组。rows 对象是从数据库中返回的结果数组,它包含我们要返回的数据,但格式不同。因此,我们使用 map 函数将数组转换为满足合约的格式:
export async function count() {
var db = await connectDB();
var count = await new Promise((resolve, reject) => {
db.get("select count(notekey) as count from notes",(err, row)
=> {
if (err) return reject(err);
resolve(row.count);
});
});
return count;
}
export async function close() {
var _db = db;
db = undefined;
return _db ? new Promise((resolve, reject) => {
_db.close(err => {
if (err) reject(err);
else resolve();
});
}) : undefined;
}
我们可以简单地使用 SQL 来为我们计算笔记的数量。在这种情况下,db.get 返回一个包含单个列 count 的行,这是我们想要返回的值。
使用 SQLite3 运行 Notes
最后,我们准备好使用 SQLite3 运行 笔记 应用程序。将以下代码添加到 package.json 的 scripts 部分:
"start-sqlite3": "SQLITE_FILE=chap07.sqlite3 NOTES_MODEL=sqlite3 node --experimental-modules ./bin/www.mjs",
运行 笔记 应用程序:
$ DEBUG=notes:* npm run start-sqlite3
> notes@0.0.0 start-sqlite3 /Users/david/chap07/notes
> SQLITE_FILE=chap07.sqlite3 NOTES_MODEL=models/notes-sqlite3 node ./bin/www.mjs
notes:server Listening on port 3000 +0ms
notes:sqlite3-model Opened SQLite3 database chap07.sqlite3 +5s
你现在可以浏览应用程序在 http://localhost:3000,并且像之前一样运行它。
因为 SQLite3 支持来自多个实例的并发访问,你可以通过将此添加到 package.json 的 scripts 部分来运行多服务器示例:
"server1-sqlite3": "SQLITE_FILE=chap07.sqlite3 NOTES_MODEL=sqlite3 PORT=3001 node ./bin/www.mjs",
"server2-sqlite3": "SQLITE_FILE=chap07.sqlite3 NOTES_MODEL=sqlite3 PORT=3002 node ./bin/www.mjs",
然后,像之前一样,在单独的命令窗口中运行这些命令。
因为我们没有对视图模板或 CSS 文件进行任何更改,应用程序看起来和之前一样。
当然,你可以使用 sqlite 命令,或其他 SQLite3 客户端应用程序来检查数据库:
$ sqlite3 chap07.sqlite3
SQLite version 3.10.2 2016-01-20 15:27:19
Enter ".help" for usage hints.
sqlite> select * from notes;
hithere|Hi There||ho there what there
himom|Hi Mom||This is where we say thanks
使用 Sequelize 的 ORM 方法存储笔记
有几种流行的 SQL 数据库引擎,例如 PostgreSQL、MySQL (www.npmjs.com/package/mysql) 和 MariaDB (www.npmjs.com/package/mariasql)。每个数据库引擎都对应着类似我们刚刚使用的 sqlite3 模块的 Node.js 客户端模块。程序员可以接近 SQL,这就像驾驶手动挡汽车一样有趣。但如果我们想要一个更高层次的数据库视图,以便我们可以从对象的角度而不是数据库表中的行来思考呢?对象关系映射(ORM)系统提供了这样的高级接口,甚至可以提供使用相同数据模型与多个数据库的能力。
Sequelize 模块 (www.sequelizejs.com/) 基于 Promise,提供了强大、成熟的 ORM 功能,并且可以连接到 SQLite3、MySQL、PostgreSQL、MariaDB 和 MSSQL。由于 Sequelize 基于 Promise,它将自然地与我们所编写的基于 Promise 的应用程序代码兼容。
大多数 SQL 数据库引擎的一个先决条件是能够访问数据库服务器。在上一节中,我们通过使用 SQLite3 来绕过这个问题,因为 SQLite3 不需要设置数据库服务器。虽然可以在您的笔记本电脑上安装数据库服务器,但我们想避免这种复杂性,并使用 Sequelize 来管理 SQLite3 数据库。我们还将看到,只需一个配置文件就可以运行相同的 Sequelize 代码,针对托管数据库,如 MySQL。在 第十章,部署 Node.js 应用程序 中,我们将学习如何使用 Docker 在笔记本电脑上轻松设置任何服务,包括数据库服务器,并将相同的配置部署到实时服务器。大多数网络托管提供商将 MySQL 或 PostgreSQL 作为服务的一部分提供。
在我们开始编写代码之前,让我们安装两个模块:
$ npm install sequelize@4.31.x --save
$ npm install js-yaml@3.10.x --save
第一个模块显然是安装 Sequelize 包。第二个模块 js-yaml 是为了我们可以实现一个 YAML 格式的文件来存储 Sequelize 连接配置。YAML 是一种可读性强的 数据序列化语言,这意味着 YAML 是一种易于使用的文本文件格式,用于描述数据对象。也许了解 YAML 的最佳地方是它的维基百科页面 en.wikipedia.org/wiki/YAML。
Notes 应用程序的 Sequelize 模型
让我们创建一个新的文件,models/notes-sequelize.mjs:
import fs from 'fs-extra';
import util from 'util';
import jsyaml from 'js-yaml';
import Note from './Note';
import Sequelize from 'sequelize';
import DBG from 'debug';
const debug = DBG('notes:notes-sequelize');
const error = DBG('notes:error-sequelize');
var SQNote;
var sequlz;
async function connectDB() {
if (typeof sequlz === 'undefined') {
const YAML = await fs.readFile(process.env.SEQUELIZE_CONNECT,'utf8');
const params = jsyaml.safeLoad(YAML, 'utf8');
sequlz = new Sequelize(params.dbname, params.username,
params.password, params.params);
}
if (SQNote) return SQNote.sync();
SQNote = sequlz.define('Note', {
notekey: { type: Sequelize.STRING, primaryKey: true, unique:
true },
title: Sequelize.STRING,
body: Sequelize.TEXT
});
return SQNote.sync();
}
数据库连接存储在 sequlz 对象中,通过读取配置文件(我们稍后会介绍这个文件)并实例化一个 Sequelize 实例来建立连接。数据模型 SQNote 向 Sequelize 描述我们的对象结构,以便它可以定义相应的数据库表。如果 SQNote 已经定义,我们只需返回它,否则我们定义并返回 SQNote。
Sequelize 连接参数存储在我们通过 SEQUELIZE_CONNECT 环境变量指定的 YAML 文件中。new Sequelize(..) 这一行打开数据库连接。显然,参数包含任何需要的数据库名称、用户名、密码以及其他与数据库连接所需的选项。
sequlz.define 这一行是我们定义数据库模式的地方。我们不是将模式定义为创建数据库表的 SQL 命令,而是提供了一个字段及其特性的高级描述。Sequelize 将对象属性映射到表中的列。
我们告诉 Sequelize 调用这个模式 Note,但我们使用 SQNote 变量来引用该模式。这是因为我们已将 Note 定义为一个表示笔记的类。为了避免名称冲突,我们将继续使用 Note 类,并使用 SQNote 与数据库中存储的笔记交互。
在以下位置可以找到在线文档:
Sequelize 类:docs.sequelizejs.com/en/latest/api/sequelize/。 docs.sequelizejs.com/en/latest/api/sequelize/ 定义模型:docs.sequelizejs.com/en/latest/api/model/。
将这些函数添加到 models/notes-sequelize.mjs:
export async function create(key, title, body) {
const SQNote = await connectDB();
const note = new Note(key, title, body);
await SQNote.create({ notekey: key, title: title, body: body });
return note;
}
export async function update(key, title, body) {
const SQNote = await connectDB();
const note = await SQNote.find({ where: { notekey: key } })
if (!note) { throw new Error(`No note found for ${key}`); } else {
await note.updateAttributes({ title: title, body: body });
return new Note(key, title, body);
}
}
在 Sequelize 中创建新的对象实例有几种方法。最简单的是调用一个对象的 create 函数(在这个例子中,SQNote.create)。这个函数将两个其他函数 build(用于创建对象)和 save(用于将其写入数据库)合并在一起。
更新对象实例略有不同。首先,我们必须使用 find 操作从数据库中检索其条目。find 操作提供了一个指定查询的对象。使用 find,我们检索一个实例,而 findAll 操作检索所有匹配的实例。
要了解 Sequelize 查询的文档,请访问 docs.sequelizejs.com/en/latest/docs/querying/。
和大多数或所有其他 Sequelize 函数一样,SQNote.find 返回一个 Promise。因此,在 async 函数内部,我们需要 await 操作的结果。
更新操作需要两个步骤,第一步是使用 find 操作找到相应的对象以从数据库中读取它。一旦找到实例,我们就可以使用 updateAttributes 函数简单地更新其值:
export async function read(key) {
const SQNote = await connectDB();
const note = await SQNote.find({ where: { notekey: key } })
if (!note) { throw new Error(`No note found for ${key}`); } else {
return new Note(note.notekey, note.title, note.body);
}
}
读取笔记时,我们再次使用 find 操作。可能存在空结果的可能性,我们必须抛出一个错误来匹配。
这个函数的合约是返回一个 Note 对象。这意味着使用 Sequelize 获取的字段来创建一个 Note 对象:
export async function destroy(key) {
const SQNote = await connectDB();
const note = await SQNote.find({ where: { notekey: key } })
return note.destroy();
}
要销毁一个笔记,我们使用 find 操作检索其实例,然后调用其 destroy() 方法:
export async function keylist() {
const SQNote = await connectDB();
const notes = await SQNote.findAll({ attributes: [ 'notekey' ] });
return notes.map(note => note.notekey);
}
因为keylist函数作用于所有Note对象,我们使用findAll操作。我们查询所有笔记上的notekey属性。我们得到了一个名为notekey的字段的对象数组,我们使用.map函数将其转换为笔记键的数组:
export async function count() {
const SQNote = await connectDB();
const count = await SQNote.count();
return count;
}
export async function close() {
if (sequlz) sequlz.close();
sequlz = undefined;
SQNote = undefined;
}
对于count函数,我们可以直接使用count()方法来计算所需的结果。
配置 Sequelize 数据库连接
Sequelize 在几个 SQL 数据库引擎上支持相同的 API。数据库连接是通过 Sequelize 构造函数的参数初始化的。十二要素应用程序模型建议,此类配置数据应保存在代码外部,并使用环境变量或类似机制注入。我们将使用 YAML 格式的文件来存储连接参数,并通过环境变量指定文件名。
Sequelize 库没有定义用于存储连接参数的此类文件。但开发这样的文件很简单。让我们这样做。
Sequelize 构造函数的 API 是:constructor(database: String, username: String, password: String, options: Object)。
在connectDB函数中,我们构造器编写如下:
sequlz = new Sequelize(params.dbname, params.username, params.password, params.params);
这个名为models/sequelize-sqlite.yaml的文件为我们提供了一个简单的映射,如下所示,用于 SQLite3 数据库:
dbname: notes
username:
password:
params:
dialect: sqlite
storage: notes-sequelize.sqlite3
YAML 文件是 Sequelize 构造函数参数的直接映射。此文件中的dbname、username和password字段直接对应于连接凭证,而params对象提供了额外的参数。params字段中有许多可能的属性可以使用,你可以在 Sequelize 文档中阅读有关它们的更多信息,请参阅docs.sequelizejs.com/manual/installation/usage.html。
dialect字段告诉 Sequelize 使用哪种类型的数据库。对于 SQLite 数据库,数据库文件名在storage字段中给出。
让我们先使用 SQLite3,因为不需要进一步设置。之后,我们将尝试使用 MySQL 重新配置我们的 Sequelize 模块。
如果你已经有一个不同的数据库服务器可用,创建相应的配置文件很简单。对于笔记本电脑上的一个合理的 MySQL 数据库,创建一个新的文件,例如models/sequelize-mysql.yaml,包含以下代码:
dbname: notes
username: .. user name
password: .. password
params:
host: localhost
port: 3306
dialect: mysql
这很简单。username(用户名)和password(密码)必须与数据库凭证相匹配,而host(主机)和port(端口)将指定数据库托管的位置。设置数据库dialect和其他连接信息,然后就可以开始了。
要使用 MySQL,你需要安装基础 MySQL 驱动程序,这样 Sequelize 才能使用 MySQL:
$ npm install mysql@2.x --save
使用 Sequelize 针对它支持的其它数据库,如 PostgreSQL,同样简单。只需创建一个配置文件,安装 Node.js 驱动程序,并安装/配置数据库引擎。
使用 Sequelize 运行 Notes 应用程序
现在,我们可以准备运行Notes应用程序使用 Sequelize。我们可以针对 SQLite3 和 MySQL 运行它,但让我们从 SQLite 开始。将以下条目添加到package.json中的scripts条目:
"start-sequelize": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize node --experimental-modules ./bin/www.mjs"
然后按照以下步骤运行它:
$ DEBUG=notes:* npm run start-sequelize
> notes@0.0.0 start-sequelize /Users/david/chap07/notes
> SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize node --experimental-modules./bin/www.mjs
notes:server Listening on port 3000 +0ms
如前所述,应用程序看起来完全一样,因为我们没有更改视图模板或 CSS 文件。对其进行测试,一切应该正常工作。
使用 Sequelize,多个Notes应用程序实例就像在package.json的scripts部分添加这些行一样简单,然后像以前一样启动这两个实例:
"server1-sequelize": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize PORT=3001 node --experimental-modules ./bin/www.mjs",
"server2-sequelize": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize PORT=3002 node --experimental-modules ./bin/www.mjs",
你将能够启动这两个实例,使用不同的浏览器窗口访问这两个实例,并看到它们显示相同的笔记集合。
再次强调,在给定的数据库服务器上使用基于 Sequelize 的模式:
-
安装和配置数据库服务器实例,或者获取已配置数据库服务器的连接参数。
-
安装相应的 Node.js 驱动程序。
-
编写一个与连接参数相对应的 YAML 配置文件。
-
在
package.json中创建新的scripts条目来自动化针对该数据库启动笔记。
在 MongoDB 中存储笔记
MongoDB 在 Node.js 应用程序中得到了广泛的应用,其中一个标志是流行的 MEAN 缩写:MongoDB(或 MySQL),Express,Angular 和 Node.js。MongoDB 是领先的 NoSQL 数据库之一。它被描述为可扩展的、高性能的、开源的、面向文档的数据库。它使用 JSON 风格的文档,没有预定义的、刚性的模式,并具有大量高级功能。你可以访问他们的网站获取更多信息以及文档,网址为www.mongodb.org。
MongoDB 的 Node.js 驱动程序的文档可以在www.npmjs.com/package/mongodb和mongodb.github.io/node-mongodb-native/找到。
Mongoose 是 MongoDB 的一个流行 ORM(对象关系映射)工具(mongoosejs.com/)。在本节中,我们将使用原生的 MongoDB 驱动程序,但 Mongoose 也是一个值得考虑的替代方案。
你需要一个运行的 MongoDB 实例。compose.io(www.compose.io/)和ScaleGrid.io(scalegrid.io/)托管服务提供商提供托管 MongoDB 服务。如今,将 MongoDB 作为 Docker 容器的一部分托管在由其他 Docker 容器构建的系统中的操作非常简单。我们将在第十一章,单元测试和功能测试中这样做。
有可能在笔记本电脑上设置一个临时的 MongoDB 实例进行测试。它在所有操作系统包管理系统中都可用,MongoDB 网站有说明(docs.mongodb.org/manual/installation/)。
一旦安装,就没有必要将 MongoDB 设置为后台服务。相反,你可以运行几个简单的命令,在命令窗口的前台运行 MongoDB 实例,你可以随时将其终止和重启。
在一个命令窗口中,运行以下命令:
$ mkdir data
$ mongod --dbpath data
在另一个命令窗口中,你可以按以下方式测试:
$ mongo
MongoDB shell version: 3.0.8
connecting to: test
Welcome to the MongoDB shell.
For interactive help, type "help".
For more comprehensive documentation, see
http://docs.mongodb.org/
Questions? Try the support group
http://groups.google.com/group/mongodb-user
> db.foo.save({ a: 1});
WriteResult({ "nInserted" : 1 })
> db.foo.find();
{ "_id" : ObjectId("56c0c98673f65b7988a96a77"), "a" : 1 }
>
bye
这将在名为foo的集合中保存一个文档。第二个命令在foo中查找所有文档,并将它们打印出来供你使用。_id字段由 MongoDB 添加,作为文档标识符。这对于测试和调试很有用。对于实际部署,你的 MongoDB 服务器必须在服务器上正确安装。请参阅 MongoDB 文档以获取这些说明。
Notes 应用程序的 MongoDB 模型
既然你已经证明你有了一个工作的 MongoDB 服务器,让我们开始工作。
安装 Node.js 驱动程序就像运行以下命令一样简单:
$ npm install mongodb@3.x --save
现在创建一个新的文件,models/notes-mongodb.mjs:
import util from 'util';
import Note from './Note';
import mongodb from 'mongodb';
const MongoClient = mongodb.MongoClient;
import DBG from 'debug';
const debug = DBG('notes:notes-mongodb');
const error = DBG('notes:error-mongodb');
var client;
async function connectDB() {
if (!client) client = await MongoClient.connect(process.env.MONGO_URL);
return {
db: client.db(process.env.MONGO_DBNAME),
client: client
};
}
使用MongoClient类与 MongoDB 实例连接。所需的 URL 将通过环境变量指定,格式简单:mongodb://localhost/。数据库名称通过另一个环境变量指定。
对应对象的文档可以在以下位置找到
mongodb.github.io/node-mongodb-native/2.2/api/MongoClient.html 用于 MongoClient 和 mongodb.github.io/node-mongodb-native/2.2/api/Db.html 用于 Db
这创建了一个数据库客户端,然后打开数据库连接。这两个对象都从connectDB以匿名对象的形式返回。MongoDB 操作的一般模式如下:
(async () => {
const client = await MongoClient.connect(process.env.MONGO_URL);
const db = client.db(process.env.MONGO_DBNAME);
// perform database operations using db object
client.close();
})();
因此,我们的模型方法需要同时使用client和db对象,因为它们都会用到它们。让我们看看这是如何完成的:
export async function create(key, title, body) {
const { db, client } = await connectDB();
const note = new Note(key, title, body);
const collection = db.collection('notes');
await collection.insertOne({ notekey: key, title, body });
return note;
}
export async function update(key, title, body) {
const { db, client } = await connectDB();
const note = new Note(key, title, body);
const collection = db.collection('notes');
await collection.updateOne({ notekey: key }, { $set: { title, body } });
return note;
}
我们使用解构赋值将db和client检索到单独的变量中。
MongoDB 将所有文档存储在集合中。一个集合是一组相关的文档,集合在关系数据库中类似于一个表。这意味着创建一个新的文档或更新现有的文档,首先将其构造为一个 JavaScript 对象,然后请求 MongoDB 将该对象保存到数据库中。MongoDB 自动将该对象编码为其内部表示。
db.collection方法为我们提供了一个Collection对象,我们可以用它来操作命名集合。请参阅其文档mongodb.github.io/node-mongodb-native/2.2/api/Collection.html。
如方法名所示,insertOne将一个文档插入到集合中。同样,updateOne方法首先查找一个文档(在这种情况下,通过查找匹配的notekey字段),然后根据指定更改文档中的字段。
你会看到这些方法返回一个 Promise。mongodb 驱动支持回调和 Promise。许多方法如果提供了回调函数,将调用该函数,否则它返回一个将传递结果或错误的 Promise。当然,由于我们正在使用 async 函数,await 关键字使得这个过程非常简洁。
更详细的文档可以在以下链接中找到:
插入:docs.mongodb.org/getting-started/node/insert/. 更新:.
接下来,让我们看看如何从 MongoDB 读取笔记:
export async function read(key) {
const { db, client } = await connectDB();
const collection = db.collection('notes');
const doc = await collection.findOne({ notekey: key });
const note = new Note(doc.notekey, doc.title, doc.body);
return note;
}
mongodb 驱动支持多种 find 操作的变体。在这种情况下,笔记 应用确保恰好有一个文档与给定的键匹配。因此,我们可以使用 findOne 方法。正如其名所示,findOne 将返回第一个匹配的文档。
findOne 的参数是一个查询描述符。这个简单的查询寻找 notekey 字段与请求的 key 匹配的文档。当然,一个空查询将匹配集合中的所有文档。你可以以类似的方式匹配其他字段,并且查询描述符可以做更多的事情。有关查询的文档,请访问 docs.mongodb.org/getting-started/node/query/。
我们之前使用的 insertOne 方法也采用了相同的查询描述符。
为了满足这个函数的合约,我们创建一个 Note 对象,并将其返回给调用者。因此,我们使用从数据库检索到的数据创建一个 Note:
export async function destroy(key) {
const { db, client } = await connectDB();
const collection = db.collection('notes');
await collection.findOneAndDelete({ notekey: key });
}
find 变体之一是 findOneAndDelete。正如其名所示,它会找到与查询描述符匹配的一个文档,然后删除该文档:
export async function keylist() {
const { db, client } = await connectDB();
const collection = db.collection('notes');
const keyz = await new Promise((resolve, reject) => {
var keyz = [];
collection.find({}).forEach(
note => { keyz.push(note.notekey); },
err => {
if (err) reject(err);
else resolve(keyz);
}
);
});
return keyz;
}
在这里,我们使用基本的 find 操作并给它一个空查询,以便匹配每个文档。我们要返回的是一个包含每个文档的 notekey 的数组。
所有的 find 操作都会返回一个 Cursor 对象。文档可以在 mongodb.github.io/node-mongodb-native/2.1/api/Cursor.html 找到。
如其名所示,Cursor 对象是查询结果集的一个指针。它具有许多与操作结果集相关的有用函数。例如,你可以跳过结果集中的前几个项目,或者限制结果集的大小,或者执行 filter 和 map 操作。
Cursor.forEach 方法接受两个回调函数。第一个在结果集中的每个元素上调用。在这种情况下,我们可以用它将 notekey 记录在一个数组中。第二个回调在处理完结果集中的所有元素后调用。我们使用这个来指示成功或失败,并返回 keyz 数组。
因为forEach使用这种模式,所以它没有提供 Promise 的选项,我们必须自己创建 Promise,如下所示:
export async function count() {
const { db, client } = await connectDB();
const collection = db.collection('notes');
const count = await collection.count({});
return count;
}
export async function close() {
if (client) client.close();
client = undefined;
}
count方法接受一个查询描述符,正如其名称所暗示的,它计算匹配文档的数量。
使用 MongoDB 运行笔记应用程序
现在我们有了 MongoDB 模型,我们可以准备好使用它来运行笔记。
到现在为止,你已经知道了这个步骤;将其添加到package.json的scripts部分:
"start-mongodb": "MONGO_URL=mongodb://localhost/ MONGO_DBNAME=chap07 NOTES_MODEL=mongodb node --experimental-modules ./bin/www.mjs",
MONGO_URL环境变量是连接到你的 MongoDB 数据库的 URL。
你可以按照以下方式启动笔记应用程序:
$ DEBUG=notes:* npm run start-mongodb
> notes@0.0.0 start-mongodb /Users/david/chap07/notes
> MONGO_URL=mongodb://localhost/ MONGO_DBNAME=chap07 NOTES_MODEL=mongodb node --experimental-modules ./bin/www
notes:server Listening on port 3000 +0ms
你可以在http://localhost:3000上浏览应用程序,并对其进行测试。你可以终止并重新启动应用程序,你的笔记仍然会保留在那里。
将以下内容添加到package.json的scripts部分:
"server1-mongodb": "MONGO_URL=mongodb://localhost/ MONGO_DBNAME=chap07 NOTES_MODEL=mongodb PORT=3001 node --experimental-modules ./bin/www.mjs",
"server2-mongodb": "MONGO_URL=mongodb://localhost/ MONGO_DBNAME=chap07 NOTES_MODEL=mongodb PORT=3002 node --experimental-modules ./bin/www.mjs",
你将能够启动两个笔记应用程序的实例,并看到它们共享同一组笔记。
摘要
在本章中,我们经历了一场关于不同数据库技术的真实旋风。虽然我们反复查看相同的七个功能,但接触各种数据存储模型和完成任务的方式是有用的。即便如此,我们也只是触及了从 Node.js 访问数据库和数据存储引擎选项的表面。
通过正确抽象模型实现,我们能够在不改变应用程序其余部分的情况下轻松切换数据存储引擎。我们确实跳过了设置数据库服务器的问题。正如承诺的那样,当我们在第十章部署 Node.js 应用程序中探索 Node.js 应用程序的生产部署时,我们将讨论这个问题。
通过将模型代码集中在存储数据的目的上,模型和应用程序都应该更容易进行测试。可以使用一个模拟数据模块来测试应用程序,该模块提供已知可预测的笔记,这些笔记可以可预测地进行检查。我们将在第十一章单元测试和功能测试中更深入地探讨这一点。
在下一章中,我们将专注于使用 OAuth2 对用户进行身份验证。
第八章:多用户身份验证的微服务方式
现在既然我们的笔记应用可以将其数据保存在数据库中,我们可以考虑将此应用真正化的下一阶段,即验证我们的用户。
登录网站以使用其服务是如此自然。我们每天都在做这件事,甚至信任银行和投资机构通过网站上的登录程序来保护我们的财务信息。HTTP 是一个无状态协议,Web 应用无法区分一个 HTTP 请求与另一个 HTTP 请求之间的差异。因为 HTTP 是无状态的,HTTP 请求本身并不知道驱动 Web 浏览器的用户是否已登录,用户的身份,甚至 HTTP 请求是否由人类发起。
用户身份验证的典型方法是向浏览器发送一个包含用于携带用户身份的令牌的 cookie。cookie 需要包含标识浏览器和该浏览器是否已登录的数据。然后,cookie 将与每个请求一起发送,让应用跟踪与浏览器关联的用户账户。
使用 Express,最佳做法是使用express-session中间件。它将数据存储为 cookie,并在每个浏览器请求中查找该数据。它易于配置,但不是用户身份验证的完整解决方案。有几个附加模块处理用户身份验证,其中一些甚至支持对第三方网站(如 Facebook 或 Twitter)进行用户身份验证。
一个包似乎在用户身份验证方面处于领先地位——Passport (passportjs.org/)。它支持一系列服务,可以用来进行身份验证,这使得开发允许用户使用来自另一个网站(例如 Twitter)的凭证注册的网站变得容易。另一个是 express-authentication (www.npmjs.com/package/express-authentication),它将自己定位为 Passport 的有见地的替代方案。
我们将使用 Passport 来验证用户,既包括本地存储的用户凭证数据库,也包括使用 OAuth2 验证 Twitter 账户。我们还将借此机会探索基于 Node.js 的 REST 微服务实现。
在本章中,我们将讨论这一阶段以下三个方面:
-
创建一个微服务来存储用户配置文件/身份验证数据。
-
使用本地存储的密码进行用户身份验证。
-
使用 OAuth2 支持通过第三方服务进行身份验证。具体来说,我们将使用 Twitter 作为第三方身份验证服务。
让我们开始吧!
首先要做的是复制前一章使用的代码。例如,如果你将那段代码保存在chap07/notes中,创建一个新的目录,chap08/notes。
创建用户信息微服务
我们可以通过简单地添加用户模型、一些路由和视图到现有的Notes应用程序来实现用户身份验证和账户管理。虽然这是可行的,但在现实世界的生产应用程序中我们会这样做吗?
考虑到用户身份信息的高价值,以及对于强大且可靠的用户身份验证的超级强烈需求。网站入侵事件经常发生,似乎最常被盗取的是用户身份。
您能否设计和构建一个具有所需安全级别的用户身份验证系统?一个可能对所有类型的入侵者都安全的系统?
就像许多其他软件开发问题一样,最好使用现有的身份验证库,最好是那些有着长期记录、重大漏洞已经得到修复的库。
另一个问题是如何在架构选择上促进安全性。漏洞总会出现,而那些有才能的恶意分子会趁机入侵。将用户信息数据库隔离起来是一个很好的想法,以限制风险。
维护一个用户信息数据库可以使您验证您的用户,展示用户资料,帮助用户相互连接等等。这些都是提供给网站用户的有用服务,但您如何限制数据落入错误之手的风险?
在本章中,我们将开发一个用户身份验证微服务。计划是最终将该服务隔离到一个受到良好保护的封闭区域。这模仿了一些网站所做的架构选择,即严格控制 API 甚至对用户信息数据库的物理访问,尽可能实施技术障碍以防止未经授权的访问。
微服务当然不是万能的,这意味着我们不应该试图将每个应用程序都强行塞入微服务的框架中。通过类比,微服务就像 Unix 哲学中的小型工具,每个工具都擅长做一件事,我们将它们混合、匹配、组合成更大的工具。这个词的另一个说法是可组合性。虽然我们可以用这种哲学构建许多有用的软件工具,但这适用于像 Photoshop 或 LibreOffice 这样的应用程序吗?虽然从单一用途的工具中构建系统非常灵活,但会失去组件紧密集成所带来的优势。
第一个问题是要不要使用面向 REST 服务的框架,在裸 Node.js 上编写 REST 应用程序,还是什么?您可以在内置的http模块上实现 REST 服务。使用应用程序框架的优势是框架作者已经内置了许多最佳实践、错误修复和安全措施。例如,Express 被广泛使用,非常受欢迎,可以轻松用于 REST 服务。还有其他更符合开发 REST 服务的框架,我们将使用其中之一——Restify (restify.com/)。
用户身份验证服务器将需要两个模块:
-
使用 Restify,实现 REST 接口
-
使用 Sequelize 在 SQL 数据库中存储用户数据对象的模型
为了测试服务,我们将编写几个简单的脚本,用于在数据库中管理用户信息。我们不会在 Notes 应用程序中实现一个管理用户界面,并将依赖于这些脚本来管理用户。作为副作用,我们将有一个工具来对用户服务运行几个简单的测试。
在此服务正常运行后,我们将着手修改笔记应用程序,以便从服务中访问用户信息,同时使用 Passport 来处理身份验证。
第一步是创建一个新的目录来存放用户信息微服务。这应该是笔记应用程序的兄弟目录。如果你创建了一个名为 chap08/notes 的目录来存放笔记应用程序,那么创建一个名为 chap08/users 的目录来存放微服务。
然后运行以下命令:
$ cd users
$ npm init
.. answer questions
.. name - user-auth-server
$ npm install debug@².6.x fs-extra@⁵.x js-yaml@³.10.x \
restify@⁶.3.x restify-clients@¹.5.x sequelize@⁴.31.x \
sqlite3@³.1.x --save
这使我们准备好开始编码。我们将使用 debug 模块来记录消息,使用 js-yaml 来读取 Sequelize 配置文件,使用 restify 来提供其 REST 框架,以及使用 sequelize/mysql/sqlite3 来进行数据库访问。
用户信息模型
我们将使用基于 Sequelize 的模型在 SQL 数据库中存储用户信息。在这个过程中,思考一个问题:我们应该直接将数据库代码集成到 REST API 实现中吗?这样做会将用户信息微服务简化为一个模块,其中数据库查询与 REST 处理器混合。通过将 REST 服务与数据存储模型分离,我们有自由选择除了 Sequelize/SQL 之外的其他数据存储系统。此外,数据存储模型可能以其他方式被使用,而不仅仅是 REST 服务。
在 users 目录中创建一个名为 users-sequelize.mjs 的新文件,包含以下内容:
import Sequelize from "sequelize";
import jsyaml from 'js-yaml';
import fs from 'fs-extra';
import util from 'util';
import DBG from 'debug';
const log = DBG('users:model-users');
const error = DBG('users:error');
var SQUser;
var sequlz;
async function connectDB() {
if (SQUser) return SQUser.sync();
const yamltext = await fs.readFile(process.env.SEQUELIZE_CONNECT,
'utf8');
const params = await jsyaml.safeLoad(yamltext, 'utf8');
if (!sequlz) sequlz = new Sequelize(params.dbname, params.username,
params.password,
params.params);
// These fields largely come from the Passport / Portable Contacts
schema.
// See http://www.passportjs.org/docs/profile
//
// The emails and photos fields are arrays in Portable Contacts.
// We'd need to set up additional tables for those.
//
// The Portable Contacts "id" field maps to the "username" field
here
if (!SQUser) SQUser = sequlz.define('User', {
username: { type: Sequelize.STRING, unique: true },
password: Sequelize.STRING,
provider: Sequelize.STRING,
familyName: Sequelize.STRING,
givenName: Sequelize.STRING,
middleName: Sequelize.STRING,
emails: Sequelize.STRING(2048),
photos: Sequelize.STRING(2048)
});
return SQUser.sync();
}
与我们基于 Sequelize 的笔记模型一样,我们使用 YAML 文件来存储连接配置。我们甚至使用了相同的环境变量,SEQUELIZE_CONNECT。
最好的用户身份验证数据存储服务是什么?通过使用 Sequelize,我们可以从 SQL 数据库中选择。虽然 NoSQL 数据库很流行,但使用一个来存储用户身份验证数据有什么优势?没有。一个 SQL 服务器就能很好地完成这项工作,而 Sequelize 允许我们有选择的自由。
使用相同的数据库实例来存储笔记和用户信息,并使用 Sequelize 来处理两者,这样做可能会简化整个系统。但我们选择模拟一个用于用户数据的受保护服务器。这意味着数据应该存储在独立的数据库实例中,最好是不同的服务器上。一个高度安全的应用程序部署可能会将用户信息服务放在完全独立的服务器上,可能是在一个物理上隔离的数据中心,配备精心配置的防火墙,甚至可能在大门处有武装警卫。
这里显示的用户配置文件模式是从 Passport 提供的规范化配置文件中派生出来的;有关更多信息,请参阅 www.passportjs.org/docs/profile。Passport 将第三方服务提供的信息统一到一个对象定义中。为了简化我们的代码,我们只是使用 Passport 定义的架构:
export async function create(username, password, provider, familyName, givenName, middleName, emails, photos) {
const SQUser = await connectDB();
return SQUser.create({
username, password, provider,
familyName, givenName, middleName,
emails: JSON.stringify(emails), photos: JSON.stringify(photos)
});
}
export async function update(username, password, provider, familyName, givenName, middleName, emails, photos) {
const user = await find(username);
return user ? user.updateAttributes({
password, provider,
familyName, givenName, middleName,
emails: JSON.stringify(emails),
photos: JSON.stringify(photos)
}) : undefined;
}
我们的 create 和 update 函数接受用户信息,要么添加新记录,要么更新现有记录:
export async function find(username) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
const ret = user ? sanitizedUser(user) : undefined;
return ret;
}
这使我们能够查找用户信息记录,并返回该数据的清理版本。
记住 Sequelize 返回一个 Promise 对象。因为这是在 async 函数内部执行的,所以 await 关键字将解析 Promise,导致任何错误被抛出或结果作为返回值提供。反过来,异步函数返回一个 Promise 给调用者。
由于我们将用户数据从 Notes 应用程序的其他部分分离出来,我们希望返回一个清理过的对象,而不是实际的 SQUser 对象。如果我们只是将 SQUser 对象发送回调用者,可能会发生信息泄露。稍后展示的 sanitizedUser 函数创建了一个匿名对象,其中包含我们希望暴露给其他模块的确切字段:
export async function destroy(username) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
if (!user) throw new Error('Did not find requested '+ username +' to delete');
user.destroy();
}
这使我们能够支持删除用户信息。我们像处理 Notes Sequelize 模型一样执行此操作,首先找到用户对象,然后调用其 destroy 方法:
export async function userPasswordCheck(username, password) {
const SQUser = await connectDB();
const user = await SQUser.find({ where: { username: username } });
if (!user) {
return { check: false, username: username, message: "Could not
find user" };
} else if (user.username === username && user.password ===
password) {
return { check: true, username: user.username };
} else {
return { check: false, username: username, message: "Incorrect
password" };
}
}
这使我们能够支持用户密码的检查。要处理的三种条件如下:
-
是否没有这样的用户
-
是否密码匹配
-
是否它们不匹配
我们返回的对象让调用者能够区分这些情况。check 字段指示是否允许此用户登录。如果 check 为假,则存在某些原因拒绝他们的登录请求,并且 message 是应该显示给用户的信息:
export async function findOrCreate(profile) {
const user = await find(profile.id);
if (user) return user;
return await create(profile.id, profile.password, profile.provider,
profile.familyName, profile.givenName, profile.middleName,
profile.emails, profile.photos);
}
这将两个操作合并到一个函数中:首先,验证指定的用户是否存在,如果不存在,则创建该用户。主要用途是在对第三方服务进行身份验证时:
export async function listUsers() {
const SQUser = await connectDB();
const userlist = await SQUser.findAll({});
return userlist.map(user => sanitizedUser(user));
}
列出现有用户。第一步是使用 findAll 获取用户列表,作为 SQUser 对象的数组。然后我们清理这个列表,以便我们不暴露任何我们不希望暴露的数据:
export function sanitizedUser(user) {
var ret = {
id: user.username, username: user.username,
provider: user.provider,
familyName: user.familyName, givenName: user.givenName,
middleName: user.middleName,
emails: JSON.parse(user.emails),
photos: JSON.parse(user.photos)
};
try {
ret.emails = JSON.parse(user.emails);
} catch(e) { ret.emails = []; }
try {
ret.photos = JSON.parse(user.photos);
} catch(e) { ret.photos = []; }
return ret;
}
这是我们的实用函数,以确保我们向调用者暴露一组精心控制的信息。通过这个服务,我们模拟了一个受保护的用户信息服务,该服务与其他应用程序隔离开来。正如我们之前所说的,这个函数返回一个匿名清理过的对象,其中我们确切知道对象中有什么。
解码我们放入数据库的 JSON 字符串非常重要。记住我们使用 JSON.stringify 在数据库中存储了 emails 和 photos 数据。使用 JSON.parse 解码这些值,就像向速溶咖啡中加入热水会产生可饮用的饮料一样。
用户信息的 REST 服务器
我们正在逐步将用户信息和认证集成到 Notes 应用中。下一步是将我们刚刚创建的用户数据模型包装成一个 REST 服务器。之后,我们将创建一些脚本,以便我们可以添加一些用户,执行其他管理任务,并通常验证该服务是否正常工作。最后,我们将扩展 Notes 应用以支持登录和注销。
在package.json文件中,将main标签更改为以下代码行:
"main": "user-server.mjs",
然后创建一个名为user-server.mjs的文件,包含以下代码:
import restify from 'restify';
import util from 'util';
import DBG from 'debug';
const log = DBG('users:service');
const error = DBG('users:error');
import * as usersModel from './users-sequelize';
var server = restify.createServer({
name: "User-Auth-Service",
version: "0.0.1"
});
server.use(restify.plugins.authorizationParser());
server.use(check);
server.use(restify.plugins.queryParser());
server.use(restify.plugins.bodyParser({
mapParams: true
}));
createServer方法可以接受一个很长的配置选项列表。这两个选项可能对识别信息很有用。
与 Express 应用一样,server.use调用初始化 Express 会调用的中间件函数,但 Restify 调用的是处理函数。这些是 API 为function (req, res, next)的回调函数。与 Express 一样,这些是请求和响应对象,而next是一个函数,当它被调用时,会将执行权传递给下一个处理函数。
与 Express 不同,每个处理函数都必须调用next函数。为了告诉 Restify 停止通过处理函数处理,必须以next(false)的形式调用next函数。使用error对象调用next也会导致执行结束,并将错误发送回请求者。
列出的处理函数执行两项任务:授权请求和处理从 URL 和post请求体中解析参数。authorizationParser函数查找 HTTP 基本认证头。check函数稍后展示,并模拟 API 令牌的概念来控制访问。
更多关于 Restify 内置处理函数的信息,请参阅restify.com/docs/plugins-api/。
将以下内容添加到user-server.mjs:
// Create a user record
server.post('/create-user', async (req, res, next) => {
try {
var result = await usersModel.create(
req.params.username, req.params.password,
req.params.provider,
req.params.familyName, req.params.givenName,
req.params.middleName,
req.params.emails, req.params.photos);
res.send(result);
next(false);
} catch(err) { res.send(500, err); next(false); }
});
对于 Express,server.VERB函数让我们可以定义特定 HTTP 动作的处理程序。此路由处理对/create-user的 POST 请求,正如其名,这将通过调用usersModel.create函数创建用户。
作为POST请求,参数出现在请求体中,而不是作为 URL 参数。由于bodyParams处理器的mapParams标志,HTTP 体中传递的参数被添加到req.params中。
我们只需用发送给我们的参数调用usersModel.create。完成后,result对象应该是一个user对象,我们通过res.send将其发送回请求者:
// Update an existing user record
server.post('/update-user/:username', async (req, res, next) => {
try {
var result = await usersModel.update(
req.params.username, req.params.password,
req.params.provider,
req.params.familyName, req.params.givenName,
req.params.middleName,
req.params.emails, req.params.photos);
res.send(usersModel.sanitizedUser(result));
next(false);
} catch(err) { res.send(500, err); next(false); }
});
/update-user路由以类似的方式处理。然而,我们将username参数放在了 URL 上。像 Express 一样,Restify 允许你将命名参数放入 URL,如下所示。这样的命名参数也会添加到req.params中。
我们只需用发送给我们的参数调用usersModel.update。这同样会返回一个对象,我们通过res.send将其发送回调用者:
// Find a user, if not found create one given profile information
server.post('/find-or-create', async (req, res, next) => {
log('find-or-create '+ util.inspect(req.params));
try {
var result = await usersModel.findOrCreate({
id: req.params.username, username: req.params.username,
password: req.params.password, provider:
req.params.provider,
familyName: req.params.familyName, givenName:
req.params.givenName,
middleName: req.params.middleName,
emails: req.params.emails, photos: req.params.photos
});
res.send(result);
next(false);
} catch(err) { res.send(500, err); next(false); }
});
这处理了我们的 findOrCreate 操作。我们只是将此委托给模型代码,就像之前所做的那样。
正如其名所示,我们将检查指定的用户是否已经存在,如果存在,则直接返回该用户,否则将创建新用户:
// Find the user data (does not return password)
server.get('/find/:username', async (req, res, next) => {
try {
var user = await usersModel.find(req.params.username);
if (!user) {
res.send(404, new Error("Did not find "+
req.params.username));
} else {
res.send(user);
}
next(false);
} catch(err) { res.send(500, err); next(false); }
});
在这里,我们支持根据提供的 username 查找用户对象。
如果未找到用户,则返回 404 状态码,因为这表示不存在资源。否则,我们发送检索到的对象:
// Delete/destroy a user record
server.del('/destroy/:username', async (req, res, next) => {
try {
await usersModel.destroy(req.params.username);
res.send({});
next(false);
} catch(err) { res.send(500, err); next(false); }
});
这就是我们在笔记应用中删除用户的方法。DEL HTTP 动词旨在用于在服务器上删除事物,因此对于此功能来说是一个自然的选择:
// Check password
server.post('/passwordCheck', async (req, res, next) => {
try {
await usersModel.userPasswordCheck(
req.params.username, req.params.password);
res.send(check);
next(false);
} catch(err) { res.send(500, err); next(false); }
});
这是将密码仅保留在本服务器上的另一个方面。密码检查由这个服务器执行,而不是在笔记应用中执行。我们只是调用前面显示的 usersModel.userPasswordCheck 函数,并发送它返回的对象:
// List users
server.get('/list', async (req, res, next) => {
try {
var userlist = await usersModel.listUsers();
if (!userlist) userlist = [];
res.send(userlist);
next(false);
} catch(err) { res.send(500, err); next(false); }
});
然后,最后,如果需要,我们将 Notes 应用程序用户的列表发送回请求者。如果没有用户列表可用,我们至少发送一个空数组:
server.listen(process.env.PORT, "localhost", function() {
log(server.name +' listening at '+ server.url);
});
// Mimic API Key authentication.
var apiKeys = [ {
user: 'them',
key: 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF'
} ];
function check(req, res, next) {
if (req.authorization) {
var found = false;
for (let auth of apiKeys) {
if (auth.key === req.authorization.basic.password
&& auth.user === req.authorization.basic.username) {
found = true;
break;
}
}
if (found) next();
else {
res.send(401, new Error("Not authenticated"));
next(false);
}
} else {
res.send(500, new Error('No Authorization Key'));
next(false);
}
}
与笔记应用一样,我们监听名为 PORT 环境变量的端口。通过明确只监听 localhost,我们将限制可以访问用户认证服务器的系统范围。在实际部署中,我们可能将此服务器放在防火墙后面,并有一个严格的允许访问的主机系统列表。
最后这个函数 check 实现了 REST API 本身的认证。这是我们之前添加的处理程序函数。
这要求调用者在 HTTP 请求中使用基本认证头提供凭证。authorizationParser 处理程序寻找这些凭证,并在 req.authorization.basic 对象上将其提供给我们。check 函数只是简单地验证指定的用户和密码组合是否存在于本地数组中。
这是为了模仿将 API 密钥分配给应用程序。有几种方法可以做到这一点;这只是其中一种。
这种方法不仅限于使用 HTTP 基本认证进行认证。Restify API 允许我们查看 HTTP 请求中的任何头信息,这意味着我们可以实现我们喜欢的任何类型的网络安全机制。check 函数可以实现其他安全方法,只要代码正确。
由于我们最初添加了 check 到 server.use 处理程序集合中,因此它在每个请求上都会被调用。因此,每个请求到这个服务器都必须提供 check 所需的 HTTP 基本认证凭证。
如果你想控制 API 中每个功能的访问,这种策略是好的。对于用户认证服务来说,这可能是个好主意。世界上的一些 REST 服务将某些 API 功能对全世界开放,而其他则通过 API 令牌保护。为了实现这一点,check 函数不应配置在 server.use 处理程序中。相反,它应该添加到适当的路由处理程序中,如下所示:
server.get('/request/url', authHandler, (req, res, next) => {
..
});
这样的authHandler将类似于我们的check函数编写。认证失败通过发送错误代码并使用next(false)来结束路由函数链来表示。
现在我们已经有了用户认证服务器的完整代码。它定义了几个请求 URL,并且对于每个 URL,都会调用用户模型中的相应函数。
现在我们需要一个 YAML 文件来保存数据库凭据,因此创建sequelize-sqlite.yaml,包含以下代码:
dbname: users
username:
password:
params:
dialect: sqlite
storage: users-sequelize.sqlite3
由于这是 Sequelize,只需提供不同的配置文件就可以轻松切换到其他数据库引擎。记住,此配置文件的文件名必须出现在SEQUELIZE_CONNECT环境变量中。
最后,package.json应该看起来如下:
{
"name": "user-auth-server",
"version": "0.0.1",
"description": "",
"main": "user-server.js",
"scripts": {
"start": "DEBUG=users:* PORT=3333 SEQUELIZE_CONNECT=sequelize-sqlite.yaml node --experimental-modules user-server"
},
"author": "",
"license": "ISC",
"engines": {
"node": ">=8.9"
},
"dependencies": {
"debug": "².6.9",
"fs-extra": "⁵.x",
"js-yaml": "³.10.x",
"mysql": "².15.x",
"restify": "⁶.3.x",
"restify-clients": "¹.5.x",
"sqlite3": "³.1.x",
"sequelize": "⁴.31.x"
}
}
我们将这个服务器配置为使用我们刚刚提供的数据库凭据监听端口3333,并且为服务器代码提供调试输出。
你现在可以启动用户认证服务器了:
$ npm start
> user-auth-server@0.0.1 start /Users/david/chap08/users
> DEBUG=users:* PORT=3333 SEQUELIZE_CONNECT=sequelize-mysql.yaml node user-server
users:server User-Auth-Service listening at http://127.0.0.1:3333 +0ms
但我们目前还没有任何与这个服务器交互的方法。
测试和管理用户认证服务器的脚本
为了确保用户认证服务器工作正常,让我们编写几个脚本来测试 API。因为我们不会花时间编写笔记应用的行政后端,所以这些脚本将允许我们添加和删除允许访问笔记的用户。这些脚本将位于用户认证服务器包目录中。
Restify 包支持编写 REST 服务器。对于 REST 客户端,我们使用一个配套库,restify-clients,它已经从 Restify 中分离出来。
创建一个名为users-add.js的文件,包含以下代码:
'use strict';
const util = require('util');
const restify = require('restify-clients');
var client = restify.createJsonClient({
url: 'http://localhost:'+process.env.PORT,
version: '*'
});
client.basicAuth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
client.post('/create-user', {
username: "me", password: "w0rd", provider: "local",
familyName: "Einarrsdottir", givenName: "Ashildr", middleName: "",
emails: [], photos: []
},
(err, req, res, obj) => {
if (err) console.error(err.stack);
else console.log('Created '+ util.inspect(obj));
});
这是 Restify 客户端的基本结构。我们创建Client对象——我们在这里有选择JsonClient,StringClient和HttpClient。HTTP basicAuth凭据很容易设置,如所示。
然后我们发起请求,在这种情况下是一个对/create-user的POST请求。因为这是一个POST请求,所以我们指定的对象被 Restify 格式化为HTTP POST正文参数。如我们之前所见,服务器已经配置了bodyParser处理函数,它将这些正文参数转换为req.param对象。
在 Restify 客户端中,就像在 Restify 服务器中一样,我们通过调用client.METHOD使用各种 HTTP 方法。因为这是一个POST请求,所以我们使用client.post。当请求完成时,回调函数将被调用。
在运行这些脚本之前,在一个窗口中使用以下命令启动认证服务器:
$ npm start
现在运行以下命令来执行测试脚本:
$ PORT=3333 node users-add.js
Created { id: 1, username: 'me', password: 'w0rd', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr',
middleName: '',
emails: '[]', photos: '[]',
updatedAt: '2016-02-24T02:34:41.661Z',
createdAt: '2016-02-24T02:34:41.661Z' }
我们可以使用以下命令来检查我们的成果:
$ sqlite3 users-sequelize.sqlite3
SQLite version 3.10.2 2016-01-20 15:27:19
Enter ".help" for usage hints.
sqlite> .schema users
CREATE TABLE `Users` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `username` VARCHAR(255) UNIQUE, `password` VARCHAR(255), `provider` VARCHAR(255), `familyName` VARCHAR(255), `givenName` VARCHAR(255), `middleName` VARCHAR(255), `emails` VARCHAR(2048), `photos` VARCHAR(2048), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`username`));
sqlite> select * from users;
2|me|w0rd|local|Einarrsdottir|Ashildr||[]|[]|2018-01-21 05:34:56.629 +00:00|2018-01-21 05:34:56.629 +00:00
sqlite> ^D
现在让我们编写一个脚本,users-find.js,来查找指定的用户:
'use strict';
const util = require('util');
const restify = require('restify-clients');
var client = restify.createJsonClient({
url: 'http://localhost:'+process.env.PORT,
version: '*'
});
client.basicAuth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
client.get('/find/'+ process.argv[2],
(err, req, res, obj) => {
if (err) console.error(err.stack);
else console.log('Found '+ util.inspect(obj));
});
这只是简单地调用/find URL,指定用户作为命令行参数提供的username。请注意,get操作不接收一个包含参数的对象。相反,任何参数都会添加到 URL 中。
它的运行方式如下:
$ PORT=3333 node users-find.js me
Found { username: 'me', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr',
middleName: '',
emails: '[]', photos: '[]' }
同样,我们可以针对其他 REST 函数编写脚本。但我们需要继续进行真正的目标,即将其集成到 Notes 应用中。
Notes 应用的登录支持
现在我们已经证明用户身份验证服务是正常工作的,我们可以设置 Notes 应用以支持用户登录。我们将使用 Passport 来支持登录/注销,并使用身份验证服务器来存储所需的数据。
在可用的包中,Passport 因其简洁性和灵活性而脱颖而出。它直接集成到 Express 中间件链中,Passport 社区已经开发了数百个所谓的策略模块,用于处理对大量第三方服务的身份验证。有关信息和文档,请参阅www.passportjs.org/。
访问用户身份验证 REST API
第一步是为 Notes 应用创建一个用户数据模型。而不是从数据文件或数据库中检索数据,它将使用 REST 查询我们刚刚创建的服务器。我们本可以直接创建访问数据库的用户模型代码,但由于已经讨论过的原因,我们决定将用户身份验证分离成一个独立的服务。
现在我们转向 Notes 应用,你可能将其存储为chap08/notes。我们将修改该应用,首先是为了访问用户身份验证 REST API,然后使用 Passport 进行授权和身份验证。
对于我们之前创建的测试/管理脚本,我们使用了restify-clients模块。这个包是restify库的配套包,其中restify支持 REST 协议的服务器端,而restify-clients支持客户端。它们的名字可能已经揭示了它们的目的。
尽管restify-clients库很棒,但它不支持 Promise 导向的 API,这是与async函数良好协作所必需的。另一个库superagent支持 Promise 导向的 API,在async函数中表现良好,并且有一个与该包配套的 Supertest,它在单元测试中非常有用。我们将在第十一章单元测试和功能测试中讨论单元测试时使用 Supertest。有关文档,请参阅www.npmjs.com/package/superagent:
$ npm install superagent@³.8.x
创建一个新文件,models/users-superagent.mjs,包含以下代码:
import request from 'superagent';
import util from 'util';
import url from 'url';
const URL = url.URL;
import DBG from 'debug';
const debug = DBG('notes:users-superagent');
const error = DBG('notes:error-superagent');
function reqURL(path) {
const requrl = new URL(process.env.USER_SERVICE_URL);
requrl.pathname = path;
return requrl.toString();
}
reqURL 函数替换了我们在早期模块中编写的 connectXYZZY 函数。使用 superagent,我们不会在服务上留下一个打开的连接,而是在每次请求时打开一个新的连接。通常的做法是制定请求 URL。用户预计会在 USER_SERVICE_URL 环境变量中提供一个基本 URL,例如 http://localhost:3333/。此函数会修改该 URL,使用 Node.js 中的新 WHATWG URL 支持,以使用给定的 URL 路径:
export async function create(username, password,
provider, familyName, givenName, middleName,
emails, photos) {
var res = await request
.post(reqURL('/create-user'))
.send({
username, password, provider,
familyName, givenName, middleName, emails, photos
})
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
export async function update(username, password,
provider, familyName, givenName, middleName,
emails, photos) {
var res = await request
.post(reqURL(`/update-user/${username}`))
.send({
username, password, provider,
familyName, givenName, middleName, emails, photos
})
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
这些是我们的 create 和 update 函数。在每种情况下,它们都接受提供的数据,构建一个匿名对象,并将其 POST 到服务器。
superagent 库使用一种 API 风格,其中通过链式调用方法来构建请求。方法调用的链可以以 .then 或 .end 子句结束,这两个子句都接受一个回调函数。但省略这两个子句,它将返回一个 Promise。
在整个库中,我们将使用 .auth 子句来设置所需的认证密钥。
这些匿名对象与通常的有所不同。在这里,我们使用了尚未讨论的新 ES-2015 功能。而不是使用 fieldName: fieldValue 语法来指定对象字段,ES-2015 给我们提供了当 fieldValue 使用的变量名称与所需的 fieldName 匹配时缩短此语法的选项。换句话说,我们只需列出变量名称,字段名称将自动与变量名称匹配。
在这个情况下,我们故意选择了与服务器使用的参数名称匹配的对象字段名称的变量名称。通过这样做,我们可以为匿名对象使用这种简化的表示法,并且由于从头到尾使用一致的变量名称,我们的代码也变得更加简洁:
export async function find(username) {
var res = await request
.get(reqURL(`/find/${username}`))
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
我们的 find 操作使我们能够查找用户信息:
export async function userPasswordCheck(username, password) {
var res = await request
.post(reqURL(`/passwordCheck`))
.send({ username, password })
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
我们正在向服务器发送检查密码的请求。
关于此方法的要点值得注意。它本可以用 URL 中的参数代替这里使用的请求体作为参数。但由于请求 URL 通常会被记录到文件中,将用户名和密码参数放在 URL 中意味着用户身份信息将被记录到文件中,并成为活动报告的一部分。这显然是一个非常糟糕的选择。将这些参数放在请求体中不仅避免了这种不良结果,而且如果使用了到服务的 HTTPS 连接,事务将被加密:
export async function findOrCreate(profile) {
var res = await request
.post(reqURL('/find-or-create'))
.send({
username: profile.id, password: profile.password,
provider: profile.provider,
familyName: profile.familyName,
givenName: profile.givenName,
middleName: profile.middleName,
emails: profile.emails, photos: profile.photos
})
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
findOrCreate 函数要么在数据库中找到用户,要么创建一个新用户。profile 对象将来自 Passport,但请注意我们对 profile.id 的处理。Passport 文档说明它将在 profile.id 字段中提供用户名。但我们的目的是将其存储为 username:
export async function listUsers() {
var res = await request
.get(reqURL('/list'))
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth('them', 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF');
return res.body;
}
最后,我们可以检索用户列表。
登录和注销路由函数
我们迄今为止构建的是一个用户数据模型,该模型通过 REST API 包装以创建我们的身份验证信息服务。然后,在笔记应用程序中,我们有一个模块从这个服务器请求用户数据。到目前为止,笔记应用程序中的任何内容都不知道这个用户模型的存在。下一步是创建用于登录/注销 URL 的路由模块,并将笔记的其余部分更改为使用用户数据。
路由模块是我们使用 passport 处理用户身份验证的地方。第一个任务是安装所需的模块:
$ npm install passport@⁰.4.x passport-local@1.x --save
passport 模块为我们提供了身份验证算法。为了支持不同的身份验证机制,passport 作者开发了几个策略实现。身份验证机制或策略对应于支持身份验证的各种第三方服务,例如使用 OAuth2 对 Facebook、Twitter 或 GitHub 等服务进行身份验证。
LocalStrategy 仅使用存储在应用程序本地的数据进行身份验证,例如我们的用户身份验证信息服务。
让我们从创建路由模块 routes/users.mjs 开始:
import path from 'path';
import util from 'util';
import express from 'express';
import passport from 'passport';
import passportLocal from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
import * as usersModel from '../models/users-superagent';
import { sessionCookieName } from '../app';
export const router = express.Router();
import DBG from 'debug';
const debug = DBG('notes:router-users');
const error = DBG('notes:error-users');
这引入了我们需要的 /users 路由器模块。这包括两个 passport 模块和基于 REST 的用户身份验证模型。
在 app.mjs 中,我们将添加 会话 支持,以便我们的用户可以登录和注销。这依赖于在浏览器中存储一个 cookie,cookie 名称可以在从 app.mjs 导出的这个变量中找到。我们将在稍后使用这个 cookie:
export function initPassport(app) {
app.use(passport.initialize());
app.use(passport.session());
}
export function ensureAuthenticated(req, res, next) {
try {
// req.user is set by Passport in the deserialize function
if (req.user) next();
else res.redirect('/users/login');
} catch (e) { next(e); }
}
initPassport 函数将从 app.mjs 中调用,并将 Passport 中间件安装到 Express 配置中。我们将在到达 app.mjs 变更时讨论这一点的含义,但 Passport 使用会话来检测此 HTTP 请求是否经过身份验证。它检查进入应用程序的每个请求,寻找有关此浏览器是否登录的线索,并将数据附加到请求对象作为 req.user。
ensureAuthenticated 函数将由其他路由模块使用,并应插入到任何需要经过身份验证的已登录用户的路由定义中。例如,编辑或删除笔记需要用户登录,因此 routes/notes.mjs 中的相应路由必须使用 ensureAuthenticated。如果用户未登录,此函数将重定向他们到 /users/login,以便他们可以登录:
outer.get('/login', function(req, res, next) {
try {
res.render('login', { title: "Login to Notes", user: req.user, });
} catch (e) { next(e); }
});
router.post('/login',
passport.authenticate('local', {
successRedirect: '/', // SUCCESS: Go to home page
failureRedirect: 'login', // FAIL: Go to /user/login
})
);
因为这个路由器挂载在 /users 上,所以所有这些路由都将添加 /user 前缀。/users/login 路由简单地显示一个请求用户名和密码的表单。当此表单提交时,我们将进入第二个路由声明,在 /users/login 上有一个 POST 请求。如果 passport 认为这是一个使用 LocalStrategy 成功的登录尝试,则浏览器将被重定向到主页。否则,它将被重定向到 /users/login 页面:
router.get('/logout', function(req, res, next) {
try {
req.session.destroy();
req.logout();
res.clearCookie(sessionCookieName);
res.redirect('/');
} catch (e) { next(e); }
});
当用户请求从笔记中注销时,他们将被发送到/users/logout。我们将为此在页眉模板中添加一个按钮。req.logout函数指示护照擦除他们的登录凭证,然后他们将被重定向到主页。
此函数与 Passport 文档中的内容不同。在那里,我们被告知只需调用req.logout。但只调用该函数有时会导致用户没有被注销。为了确保用户被注销,有必要销毁会话对象并清除 cookie。cookie 名称在app.mjs中定义,我们为此函数导入了sessionCookieName:
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
var check = await usersModel.userPasswordCheck(username,
password);
if (check.check) {
done(null, { id: check.username, username: check.username });
} else {
done(null, false, check.message);
}
} catch (e) { done(e); }
}
));
这里是我们定义的LocalStrategy的实现。在回调函数中,我们调用usersModel.userPasswordCheck,它向用户身份验证服务发出 REST 调用。记住,这执行密码检查然后返回一个对象,指示他们是否已登录。
当check.check为true时,表示登录成功。对于这种情况,我们告诉护照使用包含会话对象中的username的对象。否则,我们有两种方式告诉护照登录尝试失败。在一种情况下,我们使用done(null, false)来指示登录错误,并传递我们得到的错误消息。在另一种情况下,我们将捕获一个异常,并传递那个异常。
你会注意到护照使用回调风格的 API。护照提供了一个done函数,当我们知道发生了什么时,我们就调用这个函数。虽然我们使用async函数来对后端服务进行干净的异步调用,但护照不知道如何处理返回的 Promise。因此,我们必须在函数体周围抛出try/catch来捕获任何抛出的异常:
passport.serializeUser(function(user, done) {
try {
done(null, user.username);
} catch (e) { done(e); }
});
passport.deserializeUser(async (username, done) => {
try {
var user = await usersModel.find(username);
done(null, user);
} catch(e) { done(e); }
});
前面的函数负责对会话中的身份验证数据进行编码和解码。我们只需要附加到会话中的是username,就像我们在serializeUser中做的那样。deserializeUser对象在处理传入的 HTTP 请求时被调用,这是我们查找用户配置数据的地方。Passport 将此附加到请求对象。
登录/注销更改到 app.js
我们需要在app.mjs中进行一些更改,其中一些我们已经提到。我们仔细地将 Passport 模块依赖项隔离到routes/users.mjs。app.mjs中所需的更改支持routes/users.mjs中的代码。
现在是时候取消注释我们在第五章“你的第一个 Express 应用程序”中告诉你要注释掉的那一行了。路由模块的导入现在将如下所示:
import { router as index } from './routes/index';
import { router as users, initPassport } from './routes/users';
import { router as notes } from './routes/notes';
用户路由器支持/login和/logout URL 以及使用Passport进行身份验证。我们需要调用initPassport进行一些初始化:
import session from 'express-session';
import sessionFileStore from 'session-file-store';
const FileStore = sessionFileStore(session);
export const sessionCookieName = 'notescookie.sid';
因为Passport使用会话,所以我们需要在 Express 中启用会话支持,这些模块就是这样做的。session-file-store模块将我们的会话数据保存到磁盘上,这样我们可以在不丢失会话的情况下终止并重新启动应用程序。也有可能使用适当的模块将会话保存到数据库中。当所有 Notes 实例都在同一台服务器计算机上运行时,文件系统会话存储是合适的。对于分布式部署情况,您需要使用运行在全网范围内的服务(如数据库)的会话存储。
我们在这里定义sessionCookieName,以便可以在多个地方使用。默认情况下,express-session使用名为connect.sid的 cookie 来存储会话数据。作为一个小的安全措施,当有一个已发布的默认值时,使用不同的非默认值是有用的。每次我们使用默认值时,攻击者可能知道一个基于该默认值的安全漏洞。
使用以下命令安装模块:
$ npm install express-session@1.15.x session-file-store@1.2.x --save
Express Session 支持,包括所有各种 Session Store 实现,可以在其 GitHub 项目页面上找到文档:github.com/expressjs/session。
在app.mjs中添加以下内容:
app.use(session({
store: new FileStore({ path: "sessions" }),
secret: 'keyboard mouse',
resave: true,
saveUninitialized: true,
name: sessionCookieName
}));
initPassport(app);
在这里我们初始化会话支持。名为secret的字段用于签名会话 ID cookie。会话 cookie 是一个编码的字符串,部分使用这个密钥加密。在 Express Session 文档中,他们建议使用字符串keyboard cat作为密钥。但在理论上,如果 Express 有一个漏洞,知道这个密钥可能会使攻击者更容易破坏你网站上会话逻辑?因此,我们选择了一个不同的字符串作为密钥,以使其略有不同,也许更加安全。
类似地,express-session默认使用的 cookie 名称是connect.sid。这里我们将 cookie 名称更改为非默认名称。
FileStore将把其会话数据记录存储在名为sessions的目录中。此目录将根据需要自动创建:
app.use('/', index);
app.use('/users', users);
app.use('/notes', notes);
前面是 Notes 应用程序中使用的三个路由。
在routes/index.mjs中修改了登录/登出更改
此路由模块处理主页。它不需要用户登录,但如果我们知道他们已经登录,我们想稍微改变一下显示:
router.get('/', async (req, res, next) => {
try {
let keylist = await notes.keylist();
let keyPromises = keylist.map(key => { return notes.read(key) });
let notelist = await Promise.all(keyPromises);
res.render('index', {
title: 'Notes', notelist: notelist,
user: req.user ? req.user : undefined
});
} catch (e) { next(e); }
});
记住我们在deserializeUser中确保了req.user有用户配置数据,我们就是这样做的。我们只是检查这一点,并确保在渲染视图模板时添加这些数据。
我们将对大多数其他路由定义进行类似的更改。之后,我们将查看使用req.user在视图模板中显示正确按钮的更改。
在routes/notes.mjs中需要修改登录/登出更改
这里需要更改的内容更为重大,但仍然简单明了:
import { ensureAuthenticated } from './users';
我们需要使用ensureAuthenticated函数来保护某些路由不被未登录的用户使用。注意 ES6 模块如何让我们只导入所需的函数。由于该函数位于用户路由模块中,我们需要从那里导入它:
router.get('/add', ensureAuthenticated, (req, res, next) => {
try {
res.render('noteedit', {
title: "Add a Note",
docreate: true, notekey: "",
user: req.user, note: undefined
});
} catch (e) { next(e); }
});
我们首先在路由定义中调用usersRouter.ensureAuthenticated。如果用户未登录,他们将被重定向到/users/login,多亏了那个函数。
由于我们已经确保用户已认证,我们知道req.user将已经包含他们的个人资料信息。然后我们可以简单地将其传递给视图模板。
对于其他路由,我们需要进行类似更改:
router.post('/save', ensureAuthenticated, (req, res, next) => {
..
});
/save路由只需要这个更改来调用ensureAuthenticated以确保用户已登录:
router.get('/view', (req, res, next) => {
try {
var note = await notes.read(req.query.key);
res.render('noteview', {
title: note ? note.title : "",
notekey: req.query.key,
user: req.user ? req.user : undefined,
note: note
});
} catch (e) { next(e); }
});
对于这个路由,我们不需要用户登录。如果我们有的话,我们需要将用户的个人资料信息发送到视图模板:
router.get('/edit', ensureAuthenticated, (req, res, next) => {
try {
var note = await notes.read(req.query.key);
res.render('noteedit', {
title: note ? ("Edit " + note.title) : "Add a Note",
docreate: false,
notekey: req.query.key,
user: req.user ? req.user : undefined,
note: note
});
} catch (e) { next(e); }
});
router.get('/destroy', ensureAuthenticated, (req, res, next) => {
try {
var note = await notes.read(req.query.key);
res.render('notedestroy', {
title: note ? `Delete ${note.title}` : "",
notekey: req.query.key,
user: req.user ? req.user : undefined,
note: note
});
} catch (e) { next(e); }
});
router.post('/destroy/confirm', ensureAuthenticated, (req, res, next) => {
..
});
对于这些路由,我们需要用户已登录。在大多数情况下,我们需要将req.user值发送到视图模板。
支持登录/退出的视图模板更改
到目前为止,我们已经创建了一个后端用户认证服务,一个 REST 模块来访问该服务,一个路由模块来处理与登录和退出网站相关的路由,以及app.mjs中的更改以使用这些模块。我们几乎准备好了,但在模板中还有许多待解决的问题。我们正在将req.user对象传递给每个模板,因为每个模板都必须进行更改以适应用户是否已登录。
在partials/header.hbs中,进行以下添加:
...
{{#if user}}
<div class="collapse navbar-collapse"
id="navbarSupportedContent">
<span class="navbar-text text-dark col">{{ title }}</span>
<a class="btn btn-dark col-auto" href="/users/logout">
Log Out <span class="badge badge-light">{{ user.username }}
</span></a>
<a class="nav-item nav-link btn btn-dark col-auto"
href='/notes/add'>
ADD Note</a>
</div>
{{else}}
<div class="collapse navbar-collapse" id="navbarLogIn">
<a class="btn btn-primary" href="/users/login">Log in</a>
</div>
{{/if}}
...
我们在这里做的是根据用户是否登录来控制屏幕顶部的按钮显示。早期更改确保如果用户已登出,user变量将是undefined,否则它将包含用户个人资料对象。因此,只需检查这里显示的user变量就足以渲染不同的用户界面元素。
登出用户不会看到“添加笔记”按钮,而是看到“登录”按钮。否则,用户将看到“添加笔记”按钮和“登出”按钮。“登录”按钮将用户带到/users/login,而“登出”按钮将他们带到/users/logout。这两个操作都在routes/users.js中处理,并执行预期的功能。
“登出”按钮有一个 Bootstrap 徽章组件显示用户名。这添加了一个小视觉点,我们将放置已登录的用户名。正如我们稍后将会看到的,它将作为用户身份的视觉提示。
我们需要创建views/login.hbs:
<div class="container-fluid">
<div class="row">
<div class="col-12 btn-group-vertical" role="group">
<form method='POST' action='/users/login'>
<div class="form-group">
<label for="username">User name:</label>
<input class="form-control" type='text' id='username'
name='username' value='' placeholder='User Name'/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input class="form-control" type='password' id='password'
name='password' value='' placeholder='Password'/>
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
</div>
</div>
</div>
这是一个简单的表单,用 Bootstrap 的样式装饰,用于请求用户名和密码。提交后,它将创建一个POST请求到/users/login,这将调用所需的处理器来验证登录请求。该 URL 的处理程序将启动 Passport 的过程以决定用户是否已认证。
在 views/notedestroy.hbs 中,我们希望在用户未登录时显示一条消息。通常,会显示导致笔记被删除的表单,但如果用户未登录,我们希望解释情况:
<form method='POST' action='/notes/destroy/confirm'>
<div class="container-fluid">
{{#if user}}
<input type='hidden' name='notekey' value='{{#if note}}{{notekey}}{{/if}}'>
<p class="form-text">Delete {{note.title}}?</p>
<div class="btn-group">
<button type="submit" value='DELETE'
class="btn btn-outline-dark">DELETE</button>
<a class="btn btn-outline-dark"
href="/notes/view?key={{#if note}}{{notekey}}{{/if}}"
role="button">Cancel</a>
</div>
{{else}}
{{> not-logged-in }}
{{/if}}
</div>
</form>
这很简单;如果用户已登录,显示表单,否则在 partials/not-logged-in.hbs 中显示消息。我们根据 user 变量确定我们的方法。
我们可以在 partials/not-logged-in.hbs 中放入类似的内容:
<div class="jumbotron">
<h1>Not Logged In</h1>
<p>You are required to be logged in for this action, but you are not.
You should not see this message. It's a bug if this message appears.
</p>
<p><a class="btn btn-primary" href="/users/login">Log in</a></p>
</div>
在 views/noteedit.hbs 中,我们需要进行类似的更改:
..
<div class="row"><div class="col-xs-12">
{{#if user}}
..
{{else}}
{{> not-logged-in }}
{{/if}}
</div></div>
..
即,在底部添加一个段落,对于未登录用户,它会引入 not-logged-in 部分。
Bootstrap jumbotron 组件创建了一个漂亮且大型的文本显示,非常引人注目,并能吸引观众的注意力。然而,用户永远不应该看到这个,因为每个模板都只在用户已预先验证登录时使用。
这样的消息对于检查你代码中的错误很有用。假设我们犯了一个错误,未能正确确保这些表单只对已登录用户显示。假设我们还有其他错误,没有检查表单提交以确保它只由已登录用户发起。以这种方式修复模板是防止向不允许使用该功能的用户显示表单的又一层预防措施。
运行带有用户身份验证的笔记应用程序
现在我们已经准备好运行笔记应用程序并尝试登录和注销。
我们需要按照以下方式更改 package.json 中的 scripts 部分:
"scripts": {
"start": "DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 node --experimental-modules ./bin/www.mjs",
"dl-minty": "mkdir -p minty && npm run dl-minty-css && npm run dl-
minty-min-css",
"dl-minty-css": "wget https://bootswatch.com/4/minty/bootstrap.css
-O minty/bootstrap.css",
"dl-minty-min-css": "wget https://bootswatch.com/4/minty/bootstrap.min.css -O minty/bootstrap.min.css"
},
在前面的章节中,我们为运行笔记应用程序构建了许多模型和数据库的组合。这使我们只剩下一个配置,使用 Sequelize 模型为笔记,使用 SQLite3 数据库,并使用我们之前编写的新的用户身份验证服务。我们可以通过删除其他配置来简化 scripts 部分。所有其他笔记数据模型只需设置适当的环境变量即可使用。
USER_SERVICE_URL 需要与为该服务指定的端口号相匹配。
在一个窗口中,按照以下方式启动用户身份验证服务:
$ cd users
$ npm start
> user-auth-server@0.0.1 start /Users/david/chap08/users
> DEBUG=users:* PORT=3333 SEQUELIZE_CONNECT=sequelize-sqlite.yaml node user-server
users:server User-Auth-Service listening at http://127.0.0.1:3333
+0ms
然后,在另一个窗口中,启动笔记应用程序:
$ cd notes
$ DEBUG=notes:* npm start
> notes@0.0.0 start /Users/david/chap08/notes
> SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=models/notes-sequelize USERS_MODEL=models/users-rest USER_SERVICE_URL=http://localhost:3333 node ./bin/www
notes:server Listening on port 3000 +0ms
你将看到以下内容:

注意到新的按钮,登录,以及缺少的“添加笔记”按钮。我们尚未登录,因此 partials/header.hbs 被配置为只显示登录按钮。
点击登录按钮,你将看到登录界面:

这是我们从 views/login.hbs 中获取的登录表单。你现在可以登录,创建一个或几个笔记,你可能会在主页上看到以下内容:

现在你有了注销和添加笔记按钮。
你会注意到注销按钮显示了用户名(我)。经过一番思考和考虑,这似乎是显示用户是否登录以及哪个用户登录的最紧凑方式。这可能会让用户体验团队抓狂,而且你不知道这个用户界面设计是否有效,除非它被用户测试过,但对我们目前的目的来说已经足够好了。
Notes 应用程序的 Twitter 登录支持
如果你想让你的应用程序走向成功,允许用户使用第三方凭证进行注册是个好主意。互联网上的许多网站都允许你使用 Facebook、Twitter 或其他服务的账户登录。这样做可以消除潜在用户注册你服务的障碍。Passport 使得这样做变得极其简单。
支持 Twitter 需要安装TwitterStrategy,在 Twitter 上注册一个新的应用程序,并将几条路由添加到routes/user.mjs中,同时在partials/header.hbs中进行一些小的修改。集成其他第三方服务也需要类似的步骤。
在 Twitter 上注册应用程序
与其他所有第三方服务一样,Twitter 使用 OAuth 来处理身份验证,并需要使用他们的 API 编写软件时提供认证密钥。这是他们的服务,所以当然你必须遵守他们的规则。
要在 Twitter 上注册一个新应用程序,请访问apps.twitter.com/。然后点击创建新应用按钮。由于我们没有将 Notes 应用程序部署到常规服务器上,更重要的是,应用程序没有有效的域名,我们必须向 Twitter 提供在本地笔记本电脑上进行测试所需的配置。
每个提供 OAuth2 身份验证服务的都有用于注册新应用程序的管理后端。其共同目的是向服务描述应用程序,以便服务可以在使用认证令牌发出请求时正确识别应用程序。正常情况下,应用程序部署到常规服务器上,并通过类似MyNotes.info的域名进行访问。到目前为止,我们还没有这样做。
在撰写本文时,Twitter 注册过程需要以下四条信息:
-
名称:这是应用程序名称,可以是任何你喜欢的。如果 Twitter 的员工决定进行一些验证,那么在名称中使用测试会是个好主意。
-
描述:描述性短语,可以是任何你喜欢的。同样,此时将其描述为测试应用程序是很好的形式。
-
网站:这将是你想要的域名。在这里,帮助文本有用地建议如果你还没有 URL,只需在这里放置占位符,但请记住稍后更改它。
-
回调 URL:这是认证成功后返回的 URL。由于我们没有公共 URL 可以提供,因此我们在这里指定一个指向您的笔记本电脑的值。发现
http://localhost:3000可以正常工作。macOS 用户有另一个选择,因为.local域名会自动分配给他们的笔记本电脑。一直以来,我们都可以使用类似这样的 URL 来访问http://MacBook-Pro-2.local:3000/上的 Notes 应用。
通过尝试使用不同的服务执行此程序发现,Facebook(和其他)服务对在笔记本电脑上托管测试应用并不宽容。至少 Twitter 希望开发者能在他们的笔记本电脑上配置测试应用。Passport 的其他基于 OAuth 的策略将与 Twitter 相似,所以我们获得的知识将转移到那些其他认证策略。
最后要注意的是认证密钥的极端敏感性。将这些密钥检查到源代码仓库或放在任何人都可以访问的地方是不恰当的。
Twitter 会不时更改注册页面,但应该看起来像以下这样:

实现 Twitter 策略
就像许多 Web 应用一样,我们决定允许我们的用户使用 Twitter 凭证登录。OAuth2 协议被广泛用于此目的,并且是使用另一个网站维护的凭证在网站上认证的基础。
你在apps.twitter.com上遵循的应用程序注册过程为你生成了一对 API 密钥,一个消费者密钥和一个消费者密钥。这些密钥是 OAuth 协议的一部分,将由你注册的任何 OAuth 服务提供,并且应该非常小心地处理这些密钥。把它们想象成你的服务用来访问基于 OAuth 的服务(如 Twitter 等)的用户名和密码。能看见这些密钥的人越多,恶意分子看到并造成麻烦的可能性就越大。任何拥有这些秘密的人都可以像你一样写入访问服务 API。
Passport 生态系统中提供了各种第三方服务的策略包。让我们安装使用TwitterStrategy所需的包:
$ npm install passport-twitter@1.x --save
在routes/users.mjs中,让我们开始做一些更改:
import passportTwitter from 'passport-twitter';
const TwitterStrategy = passportTwitter.Strategy;
要引入我们刚刚安装的包,请添加以下内容:
const twittercallback = process.env.TWITTER_CALLBACK_HOST
? process.env.TWITTER_CALLBACK_HOST
: "http://localhost:3000";
passport.use(new TwitterStrategy({
consumerKey: process.env.TWITTER_CONSUMER_KEY,
consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
callbackURL: `${twittercallback}/users/auth/twitter/callback`
},
async function(token, tokenSecret, profile, done) {
try {
done(null, await usersModel.findOrCreate({
id: profile.username, username: profile.username, password: "",
provider: profile.provider, familyName: profile.displayName,
givenName: "", middleName: "",
photos: profile.photos, emails: profile.emails
}));
} catch(err) { done(err); }
}));
这将TwitterStrategy注册到passport中,安排在用户使用 Notes 应用注册时调用用户认证服务。当用户使用 Twitter 成功认证时,将调用此callback函数。
我们特别定义了usersModel.findOrCreate函数来处理来自 Twitter 等第三方服务的用户注册。它的任务是查找配置文件对象中描述的用户,如果该用户不存在,则在 Notes 中自动创建该用户账户。
consumerKey 和 consumerSecret 值是在你注册应用程序后由 Twitter 提供的。这些密钥在 OAuth 协议中用作身份证明,以证明对 Twitter 的身份。
TwitterStrategy 配置中的 callbackURL 设置是 Twitter 基于 OAuth1 的 API 实现的遗留问题。在 OAuth1 中,回调 URL 作为 OAuth 请求的一部分传递。由于 TwitterStrategy 使用 Twitter 的 OAuth1 服务,我们必须在这里提供 URL。我们将在稍后看到这个 URL 在 Notes 中的实现。
callbackURL、consumerKey 和 consumerSecret 都是通过环境变量注入的。由于方便,人们可能会倾向于直接将这些密钥放入源代码中。但是,你的源代码分布有多广呢?在 Slack API 文档(api.slack.com/docs/oauth-safety)中,我们被警告不要在电子邮件、分布式原生应用、客户端 JavaScript 或公共代码仓库中分发客户端密钥。
在 第十章,“部署 Node.js 应用程序”中,我们将这些密钥放入 Dockerfile 中。这并不完全安全,因为 Dockerfile 也将被提交到某个源代码库中。
在调试过程中发现,TwitterStrategy 提供的配置文件对象与 passport 网站上的文档不符。因此,我们将 passport 实际提供的对象映射为 Notes 可以使用的对象:
router.get('/auth/twitter', passport.authenticate('twitter'));
要开始用户使用 Twitter 登录,我们将他们发送到这个 URL。记住,这个 URL 实际上是 /users/auth/twitter,在模板中,我们必须使用这个 URL。当这个 URL 被调用时,passport 中间件将启动用户认证和注册过程,使用 TwitterStrategy。
一旦用户的浏览器访问此 URL,OAuth 舞蹈就开始了。它被称为舞蹈,因为 OAuth 协议涉及在几个网站之间精心设计的重定向。Passport 将浏览器发送到 Twitter 的正确 URL,Twitter 会询问用户是否同意使用 Twitter 进行认证,然后 Twitter 将用户重定向回你的回调 URL。在这个过程中,会在网站之间通过精心设计的舞蹈传递特定的令牌。
一旦 OAuth 舞蹈结束,浏览器将跳转到此处:
router.get('/auth/twitter/callback',
passport.authenticate('twitter', { successRedirect: '/',
failureRedirect: '/users/login' }));
此路由处理回调 URL,并且它与之前配置的 callbackURL 设置相对应。根据它是否表示成功注册,passport 将将浏览器重定向到主页或返回到 /users/login 页面。
因为 router 被挂载在 /user 上,所以这个 URL 实际上是 /user/auth/twitter/callback。因此,在配置 TwitterStrategy 和提供给 Twitter 时要使用的完整 URL 是 http://localhost:3000/user/auth/twitter/callback。
在处理回调 URL 的过程中,Passport 将调用前面显示的回调函数。因为我们的回调使用了 usersModel.findOrCreate 函数,所以如果需要,用户将被自动注册。
我们几乎准备好了,但需要在 Notes 的其他地方做一些小的修改。
在 partials/header.hbs 中,对代码进行以下修改:
...
{{else}}
<div class="collapse navbar-collapse" id="navbarLogIn">
<span class="navbar-text text-dark col"></span>
<a class="nav-item nav-link btn btn-dark col-auto" href="/users/login">
Log in</a>
<a class="nav-item nav-link btn btn-dark col-auto" href="/users/auth/twitter">
<img width="15px"
src="img/Twitter_Social_Icon_Rounded_Square_Color.png"/>
Log in with Twitter</a>
</div>
{{/if}}
这添加了一个新按钮,点击后会将用户带到 /users/auth/twitter,这当然会启动 Twitter 身份验证过程。
使用的图像来自官方 Twitter 品牌资产页面 about.twitter.com/company/brand-assets。Twitter 建议使用这些品牌资产,以确保所有使用 Twitter 的服务外观一致。下载整个系列,然后选择一个你喜欢的。对于这里显示的 URL,将选定的图像放置在名为 public/assets/vendor/twitter 的目录中。注意,我们强制将大小调整为足够小,以便导航栏可以容纳。
经过这些修改,我们准备好尝试使用 Twitter 登录。
如前所述启动 Notes 应用程序服务器:
$ npm start
> notes@0.0.0 start /Users/David/chap08/notes
> DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 node --experimental-modules ./bin/www.mjs
(node:42095) ExperimentalWarning: The ESM module loader is experimental.
notes:server-debug Listening on port 3000 +0ms
然后使用浏览器访问 http://localhost:3000:

注意到新按钮。它看起来相当合适,多亏了使用了官方的 Twitter 品牌图像。按钮有点大,所以你可能想咨询一下设计师。显然,如果你打算支持数十个身份验证服务,就需要不同的设计。
点击此按钮会将浏览器带到 /users/auth/twitter,这会启动 Passport 运行 OAuth2 协议事务,以进行身份验证。然后,一旦你用 Twitter 登录,你会看到如下截图:

我们现在已登录,并注意到我们的 Notes 用户名与我们的 Twitter 用户名相同。你可以浏览应用程序,创建、编辑或删除笔记。实际上,你可以对任何你喜欢的笔记这样做,即使是别人创建的。那是因为我们没有创建任何形式的访问控制或权限系统,因此每个用户都可以完全访问每个笔记。这是一个需要添加到待办事项的功能。
通过使用多个浏览器或计算机,你可以同时以不同的用户身份登录,每个浏览器一个用户。
你可以通过我们之前做过的操作来运行多个 Notes 应用程序实例:
"scripts": {
"start": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=models/notes-sequelize USERS_MODEL=models/users-rest USER_SERVICE_URL=http://localhost:3333 node ./bin/www",
"start-server1": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=models/notes-sequelize USERS_MODEL=models/users-rest USER_SERVICE_URL=http://localhost:3333 PORT=3000 node ./bin/www",
"start-server2": "SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=models/notes-sequelize USERS_MODEL=models/users-rest USER_SERVICE_URL=http://localhost:3333 PORT=3002 node ./bin/www",
"dl-minty": "mkdir -p minty && npm run dl-minty-css && npm run dl-minty-min-css",
"dl-minty-css": "wget https://bootswatch.com/4/minty/bootstrap.css -O minty/bootstrap.css",
"dl-minty-min-css": "wget https://bootswatch.com/4/minty/bootstrap.min.css -O minty/bootstrap.min.css"
},
然后,在一个命令窗口中,运行以下命令:
$ npm run start-server1
> notes@0.0.0 start-server1 /Users/David/chap08/notes
> DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3000 node --experimental-modules ./bin/www.mjs
(node:43591) ExperimentalWarning: The ESM module loader is experimental.
notes:server-debug Listening on port 3000 +0ms
在另一个命令窗口中,运行以下命令:
$ npm run start-server2
> notes@0.0.0 start-server2 /Users/David/chap08/notes
> DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3002 node --experimental-modules ./bin/www.mjs
(node:43755) ExperimentalWarning: The ESM module loader is experimental.
notes:server-debug Listening on port 3002 +0ms
如前所述,这里启动了两个 Notes 服务器实例,每个实例的 PORT 环境变量中都有不同的值。在这种情况下,每个实例将使用相同的用户身份验证服务。正如这里所示,您将能够访问两个实例,地址分别是 http://localhost:3000 和 http://localhost:3002。同样,您也可以根据需要启动和停止服务器,查看每个服务器中的相同笔记,并看到在重启服务器后笔记仍然保留。
另一件事可以尝试的是调整 会话存储。我们的会话数据被存储在 sessions 目录中。这些只是文件系统中的文件,我们可以查看一下:
$ ls -l sessions/
total 32
-rw-r--r-- 1 david wheel 139 Jan 25 19:28 -QOS7eX8ZBAfmK9CCV8Xj8v-3DVEtaLK.json
-rw-r--r-- 1 david wheel 139 Jan 25 21:30 T7VT4xt3_e9BiU49OMC6RjbJi6xB7VqG.json
-rw-r--r-- 1 david wheel 223 Jan 25 19:27 ermh-7ijiqY7XXMnA6zPzJvsvsWUghWm.json
-rw-r--r-- 1 david wheel 139 Jan 25 21:23 uKzkXKuJ8uMN_ROEfaRSmvPU7NmBc3md.json
$ cat sessions/T7VT4xt3_e9BiU49OMC6RjbJi6xB7VqG.json
{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"__lastAccess":1516944652270,"passport":{"user":"7genblogger"}}
这是使用 Twitter 账户登录后的情况;您可以看到 Twitter 账户名称存储在会话数据中。
如果您想清除一个会话怎么办?它只是文件系统中的一个文件。删除会话文件将清除会话,并且用户的浏览器将被强制注销。
如果用户长时间不使用浏览器,会话将超时。session-file-store 选项之一 ttl 控制超时时间,默认为 3,600 秒(一小时)。会话超时后,应用程序将恢复到注销状态。
安全地保存秘密和密码
我们多次警告过安全处理用户标识信息的重要性。有意愿安全处理这些数据是一回事,但重要的是要贯彻到底并真正这样做。虽然我们迄今为止使用了一些良好的做法,但就现状而言,Notes 应用程序无法通过任何类型的网络安全审计:
-
用户密码在数据库中以明文形式保存
-
Twitter 等服务的身份验证令牌在源代码中以明文形式存在
-
身份验证服务 API 密钥不是一个加密安全的东西,它只是一个明文 UUID
如果您不认识“明文”这个短语,它只是意味着未加密。任何人都可以阅读用户密码或身份验证令牌的文本。最好将两者都加密,以避免信息泄露。
Notes 应用程序堆栈
您注意到了我们之前提到的运行 Notes 应用程序堆栈吗?现在是时候向市场营销团队解释这个短语的含义了。他们可能需要在营销手册和类似材料上放置架构图。对我们这样的开发者来说,退后一步,绘制我们已创建或计划创建的图画也是很有用的。
这里是一个工程师可能会绘制的图表,向市场营销团队展示系统设计。当然,市场营销团队会雇佣一位图形艺术家来清理它:

标有“笔记应用”的框是模板和路由模块实现的面向公众的代码。根据当前配置,它可以从我们的笔记本电脑上的端口 3000 访问。它可以使用几种数据存储服务之一。它通过端口 3333(当前配置)与后端用户身份验证服务进行通信。
在第十章,“部署 Node.js 应用”中,我们将随着学习如何在真实服务器上部署,对这个图景进行一些扩展。
摘要
你在本章中覆盖了大量的内容,不仅探讨了 Express 应用中的用户身份验证,还探讨了微服务开发。
具体来说,你涵盖了 Express 中的会话管理,使用 Passport 进行用户身份验证,包括 Twitter/OAuth,使用路由中间件限制访问,创建 REST 服务使用 Restify,以及何时创建微服务。
在下一章中,我们将把笔记应用提升到一个新的水平——应用用户之间的半实时通信。为此,我们将编写一些浏览器端的 JavaScript 代码,并探索Socket.io包如何让我们在用户之间发送消息。
第九章:使用 Socket.IO 实现动态客户端/服务器交互
网络的原始设计模型类似于 20 世纪 70 年代大型机的工作方式。老式的哑终端,如 IBM 3270,和 Web 浏览器都遵循请求-响应范式。用户发送请求,远端计算机发送响应屏幕。虽然 Web 浏览器可以显示比老式的哑终端更复杂的信息,但在两种情况下,交互模式都是用户请求的来回,每次都导致服务器屏幕或,在 Web 浏览器的情况下,页面发送一屏或一页数据。
如果你想知道这个历史课是关于什么的,那么请求-响应范式在 Node.js HTTP 服务器 API 中是显而易见的,如下面的代码所示:
http.createServer(function (request, response) {
... handle request
}).listen();
这种范式再明确不过了。请求和响应就在那里。
最初的 Web 浏览器是在基于文本的用户界面之上的一个进步,HTML 混合了图像、不同颜色、字体和尺寸的文本。随着 CSS 的出现,HTML 得到了改进,iframe 允许嵌入各种类型的媒体,JavaScript 也得到了改进,所以我们有了相当不同的范式。Web 浏览器仍然在发送请求并接收数据页面,但这些数据可以非常复杂,更重要的是,JavaScript 增加了交互性。
一种新技术是保持与服务器之间的开放连接,以便在服务器和客户端之间进行持续的数据交换。这种 Web 应用程序模型的变化被称为实时网络。在某些情况下,网站保持与 Web 浏览器的开放连接,实时更新网页是其中一个目标。
一些观察者认为,传统的 Web 应用程序可能会不真实地显示其数据;也就是说,如果两个人正在查看一个页面,而一个人编辑了该页面,那么那个人的浏览器将更新为页面的正确副本,而另一个浏览器则没有更新。两个浏览器显示页面的不同版本,其中一个是不真实的。如果第一个浏览器的人删除了该页面,第二个浏览器甚至可以显示一个不再存在的页面。有些人认为,如果其他人的浏览器在页面编辑后立即刷新以显示新内容会更好。
这就是实时网络的一个可能作用;页面在页面内容变化时自动更新。各种系统都支持同一网站上的用户之间的实时交互。无论是看到 Facebook 评论在撰写时弹出,还是协作编辑的文档,Web 上出现了一种新的交互范式。
我们即将在笔记应用程序中实现这种行为。
创造 Node.js 的一个原始目的是支持实时网络。Comet 应用程序架构(Comet 与 AJAX 有关,而且巧合的是,这两个名字也是家庭清洁产品的名称)涉及长时间保持 HTTP 连接打开,数据通过该通道在浏览器和服务器之间来回流动。术语 Comet 由 Alex Russell 在 2006 年的博客文章中引入(infrequently.org/2006/03/comet-low-latency-data-for-the-browser/),作为实现客户端和服务器之间这种实时双向数据交换的架构模式的通用术语。那篇博客文章呼吁开发一个与 Node.js 非常相似的编程平台。
为了简化任务,我们将依赖 Socket.IO 库 (socket.io/)。这个库简化了浏览器和服务器之间的双向通信,并且可以支持各种协议,并为老旧的浏览器提供回退。
我们将涵盖以下主题:
-
现代网络浏览器中的实时通信
-
Socket.IO库 -
将
Socket.IO集成到 Express 应用程序以支持实时通信 -
实时通信的用户体验
介绍 Socket.IO
Socket.IO 的目标是使每个浏览器和移动设备都能实现实时应用。它支持多种传输协议,并为特定浏览器选择最佳协议。
如果你使用 WebSockets 实现你的应用程序,它将仅限于支持该协议的现代浏览器。因为 Socket.IO 落后于许多备用协议(WebSockets、Flash、XHR 和 JSONP),它支持广泛的网络浏览器,包括一些老旧的浏览器。
作为应用程序的作者,你不必担心 Socket.IO 在特定浏览器中使用的具体协议。相反,你可以实现业务逻辑,而库会为你处理细节。
Socket.IO 要求客户端库能够进入浏览器。这个库已经提供,并且易于实例化。你将在浏览器端和服务器端使用相似的 Socket.IO API 进行代码编写。
Socket.IO 提供的模型类似于 EventEmitter 对象。程序员使用 .on 方法来监听事件,使用 .emit 方法来发送它们。通过 Socket.IO 库,这些事件在浏览器和服务器之间发送,库负责来回传递。
关于 Socket.IO 的信息可在 socket.io/ 找到。
使用 Express 初始化 Socket.IO
Socket.IO 通过将自己包装在 HTTP 服务器对象周围来工作。回想一下 第四章,HTTP 服务器和客户端,在那里我们编写了一个模块,该模块挂钩到 HTTP 服务器方法,以便我们可以监视 HTTP 事务。HTTP Sniffer 为每个 HTTP 事件附加一个监听器以打印出事件。但如果你用这个想法来做实际的工作呢?Socket.IO 使用一个类似的概念,通过监听 HTTP 请求并使用 Socket.IO 协议与浏览器中的客户端代码通信来响应特定的请求。
要开始,让我们首先复制上一章的代码。如果你为那个代码创建了一个名为 chap08 的目录,创建一个新的名为 chap09 的目录并将源树复制到那里。
我们不会对用户身份验证微服务进行更改,但当然我们会使用它进行用户身份验证。
在 Notes 源目录中,安装这些新模块:
$ npm install socket.io@2.x passport.socketio@3.7.x --save
我们将使用第八章,多用户身份验证的微服务方式中使用的 passport 模块,将用户身份验证集成到我们将要实现的某些实时交互中。
要初始化 Socket.IO,我们必须对 Notes 应用程序的启动方式做一些重大的修改。到目前为止,我们使用了 bin/www.mjs 脚本和 app.mjs,每个脚本托管 Notes 启动步骤的不同部分。Socket.IO 初始化要求这些步骤按照与我们之前所做不同的顺序发生。因此,我们必须将这两个脚本合并为一个。我们将做的是将 bin/www.mjs 脚本的内容复制到 app.mjs 的适当部分,然后我们将使用 app.mjs 来启动 Notes。
在 app.mjs 的开头,添加以下 import 语句:
import http from 'http';
import passportSocketIo from 'passport.socketio';
import session from 'express-session';
import sessionFileStore from 'session-file-store';
const FileStore = sessionFileStore(session);
export const sessionCookieName = 'notescookie.sid';
const sessionSecret = 'keyboard mouse';
const sessionStore = new FileStore({ path: "sessions" });
passport.socketio 模块将 Socket.IO 与基于 PassportJS 的用户身份验证集成。我们将很快配置此支持。会话管理的配置现在在 Socket.IO、Express 和 Passport 之间共享。这些行将配置集中到 app.mjs 中的一个地方,因此我们可以更改一次以影响需要它的所有地方。
使用此方法初始化 HTTP 服务器对象:
const app = express();
export default app;
const server = http.createServer(app);
import socketio from 'socket.io';
const io = socketio(server);
io.use(passportSocketIo.authorize({
cookieParser: cookieParser,
key: sessionCookieName,
secret: sessionSecret,
store: sessionStore
}));
这将 export default app 行从文件的底部移动到这个位置。这个位置不是更有意义吗?
io 对象是我们进入 Socket.IO API 的入口点。我们需要将此对象传递给任何需要使用该 API 的代码。仅仅在其他模块中 require socket.io 模块是不够的,因为 io 对象是包装 server 对象的东西。相反,我们将把 io 对象传递给任何需要使用它的模块。
io.use 函数在 Socket.IO 中安装了类似于 Express 中间件的函数。在这种情况下,我们将 Passport 身份验证集成到 Socket.IO 中:
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
此代码是从 bin/www.mjs 复制的,并设置监听端口。它依赖于三个也将从 bin/www.mjs 复制到 app.mjs 的函数:
app.use(session({
store: sessionStore,
secret: sessionSecret,
resave: true,
saveUninitialized: true,
name: sessionCookieName
}));
initPassport(app);
这改变了 Express 会话支持的配置,以匹配我们之前设置的配置变量。这些变量与设置 Socket.IO 会话集成时使用的变量相同,这意味着它们都在同一页面上。
使用此方法在路由模块中初始化 Socket.IO 代码:
app.use('/', index);
app.use('/users', users);
app.use('/notes', notes);
indexSocketio(io);
// notesSocketio(io);
这是我们将 io 对象传递给必须使用它的模块的地方。这样,Notes 应用程序就可以向网络浏览器发送有关 Notes 变更的消息。这意味着什么将在下一部分中变得清晰。所需的是类似于 Express 路由函数的,因此从 Socket.IO 客户端发送/接收消息的代码也将位于路由模块中。
我们还没有编写这两个函数(请耐心等待)。为了支持这一点,我们需要在顶部的 import 语句中进行更改:
import { socketio as indexSocketio, router as index } from './routes/index';
每个路由模块将导出一个名为 socketio 的函数,我们将需要将其重命名,如下所示。这个函数将接收 io 对象,并处理任何基于 Socket.IO 的通信。我们还没有编写这些函数。
然后,在 app.mjs 文件的末尾,我们将复制来自 bin/www.mjs 的剩余代码,这样 HTTP 服务器就会在我们的选定端口上监听:
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) { // named pipe
return val;
}
if (port >= 0) { // port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') { throw error; }
var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' +
port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
debug('Listening on ' + bind);
}
然后,在 package.json 文件中,我们必须启动 app.mjs 而不是 bin/www.mjs:
"scripts": {
"start": "DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 node --experimental-modules ./app",
"start-server1": "DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3000 node --experimental-modules ./app",
"start-server2": "DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3002 node --experimental-modules ./app",
...
},
在这一点上,如果你愿意,可以删除 bin/www.mjs。你也可以尝试启动服务器,但它会失败,因为 indexSocketio 函数尚不存在。
Notes 主页的实时更新
我们正在努力实现的目标是 Notes 主页能够自动更新笔记列表,当笔记被编辑或删除时。我们迄今为止所做的是重构应用程序启动,以便在 Notes 应用程序中初始化 Socket.IO。目前还没有行为上的变化,除了它将因为缺少函数而崩溃。
这种方法是在笔记被创建、更新或删除时,Notes 模型类发送消息。在路由类中,我们将监听这些消息,然后将笔记标题列表发送给所有连接到 Notes 应用的浏览器。
到目前为止,Notes 模型一直是一个被动的文档存储库,现在它需要向任何感兴趣的各方发出事件。这是监听器模式,从理论上讲,将会有代码对了解笔记何时被创建、编辑或销毁感兴趣。目前,我们将利用这一知识来更新 Notes 主页,但还有许多其他潜在的应用。
Notes 模型作为一个 EventEmitter 类
EventEmitter 是实现监听器支持的类。让我们创建一个新的模块,models/notes-events.mjs,包含以下内容:
import EventEmitter from 'events';
class NotesEmitter extends EventEmitter {
noteCreated(note) { this.emit('notecreated', note); }
noteUpdate (note) { this.emit('noteupdate', note); }
noteDestroy (data) { this.emit('notedestroy', data); }
}
export default new NotesEmitter();
该模块为我们维护与 Notes 相关事件的监听器。我们创建了一个 EventEmitter 的子类,因为它已经知道如何管理监听器。该对象的实例作为默认导出。
现在,让我们更新 models/notes.mjs 以使用 notes-events 来发送事件。因为我们有一个单独的模块 notes.mjs,用于将调用分发给各个 Notes 模型,这个模块提供了一个关键点,我们可以在这里拦截操作并发送事件。否则,我们就必须将事件发送代码集成到每个 Notes 模型中。
import _events from './notes-events';
export const events = _events;
我们需要 import 这个模块以在此处使用,同时也需要导出它,以便其他模块也可以发送 Notes 事件。通过这样做,另一个导入 Notes 的模块可以调用 notes.events.function 来使用 notes-events 模块。
这种技术被称为 重新导出。有时,你需要从模块 A 导出一个实际上定义在模块 B 中的函数。因此,模块 A 从模块 B 导入该函数,并将其添加到其导出中。
然后,我们对这些函数进行了一些修改:
export async function create(key, title, body) {
const note = await model().create(key, title, body);
_events.noteCreated(note);
return note;
}
export async function update(key, title, body) {
const note = await model().update(key, title, body);
_events.noteUpdate(note);
return note;
}
export async function destroy(key) {
await model().destroy(key);
_events.noteDestroy({ key });
return key;
}
Notes 模型函数的合约是它们返回一个 Promise,因此我们的调用者将使用 await 来解析这个 Promise。这里有三个步骤:
-
调用当前
model类中的相应函数,并await它的结果 -
向我们的监听器发送相应的消息
-
返回值,因为这是一个
async函数,所以值将以Promise的形式接收,并满足这些函数的合约
Notes 主页的实时更改
Notes 模型现在在 Notes 创建、更新或销毁时发送事件。为了使其有用,这些事件必须显示给我们的用户。让事件对用户可见意味着应用程序的控制器和视图部分必须消费这些事件。
让我们从修改 routes/index.mjs 开始:
router.get('/', async (req, res, next) => {
try {
let notelist = await getKeyTitlesList();
res.render('index', {
title: 'Notes', notelist: notelist,
user: req.user ? req.user : undefined
});
} catch (e) { next(e); }
});
我们需要重用原始路由函数的一部分,以便在另一个函数中使用它。因此,我们将原本位于此块中的代码抽取到一个新的函数中,名为 getKeyTitlesList:
async function getKeyTitlesList() {
const keylist = await notes.keylist();
var keyPromises = keylist.map(key => {
return notes.read(key).then(note => {
return { key: note.key, title: note.title };
});
});
return Promise.all(keyPromises);
};
原始路由函数的这一部分现在是一个独立的函数。它使用 Promise.all 来管理读取所有内容的流程,生成一个包含所有现有 Notes 的 key 和 title 的项目数组。
export function socketio(io) {
var emitNoteTitles = async () => {
const notelist = await getKeyTitlesList()
io.of('/home').emit('notetitles', { notelist });
};
notes.events.on('notecreated', emitNoteTitles);
notes.events.on('noteupdate', emitNoteTitles);
notes.events.on('notedestroy', emitNoteTitles);
};
这里是我们在修改 app.mjs 时讨论的 socketio 函数。我们接收 io 对象,然后使用它向所有连接的浏览器发出 notestitles 事件。
io.of('/namespace') 方法限制了后续操作只能在该指定的命名空间内进行。在这种情况下,我们向 /home 命名空间发送了一个 notestitle 消息。
io.of 方法定义了 Socket.IO 所称的命名空间。命名空间限制了通过 Socket.IO 发送的消息的作用域。默认命名空间是 /,命名空间看起来像路径名,因为它们是一系列由斜杠分隔的名称。向命名空间发出的任何事件都会发送到监听该命名空间的任何套接字。
代码,在这种情况下,相当直接。它监听我们刚刚实现的事件,notecreated、noteupdate 和 notedestroy。对于这些事件中的每一个,它都会发出一个包含笔记键和标题列表的事件 notetitles。
就这些了!
随着笔记的创建、更新和删除,我们确保主页会相应刷新。主页模板views/index.hbs将需要代码来接收该事件并重写页面以匹配。
修改主页和布局模板
Socket.IO在客户端和服务器上运行,两者通过 HTTP 连接来回通信。这需要在客户端浏览器中加载客户端 JavaScript 库。我们希望在实现Socket.IO服务的笔记应用的每个页面上,都必须加载客户端库并具有针对我们应用的定制客户端代码。
Notes 中的每个页面都需要不同的Socket.IO客户端实现,因为每个页面有不同的要求。这影响了我们在 Notes 中加载 JavaScript 代码的方式。
最初,我们只是在layout.hbs的底部放置 JavaScript 代码,因为每个页面都需要相同的 JavaScript 模块集。但现在我们已经确定了每个页面需要不同的 JavaScript 集合。此外,一些 JavaScript 需要在layout.hbs底部当前加载的 JavaScript 之后加载。具体来说,jQuery 目前是在layout.hbs中加载的,但我们希望在Socket.IO客户端中使用 jQuery 在每个页面上执行 DOM 操作。因此,需要进行一些模板重构。
创建一个文件,partials/footerjs.hbs,包含以下内容:
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="img/jquery.min.js"></script>
<script src="img/popper.min.js"></script>
<script src="img/bootstrap.min.js"></script>
<script src="img/feather.js"></script>
<script>
feather.replace()
</script>
这之前是在views/layout.hbs的底部。我们现在需要按照以下方式修改该文件:
<body>
{{> header }}
{{{body}}}
</body>
然后,在每一个模板(error.hbs、index.hbs、login.hbs、notedestroy.hbs、noteedit.hbs和noteview.hbs)的底部添加以下行:
{{> footerjs}}
到目前为止,这还没有改变页面中将要加载的内容,因为footerjs包含的正是layout.hbs底部的原有内容。但它给了我们加载Socket.IO客户端代码的自由,在footerjs中的脚本加载之后。
在views/index.hbs的底部添加以下内容,在footerjs部分之后:
{{> footerjs}}
<script src="img/socket.io.js"></script>
<script>
$(document).ready(function () {
var socket = io('/home');
socket.on('notetitles', function(data) {
var notelist = data.notelist;
$('#notetitles').empty();
for (var i = 0; i < notelist.length; i++) {
notedata = notelist[i];
$('#notetitles')
.append('<a class="btn btn-lg btn-block btn-outline-dark"
href="/notes/view?key='+
notedata.key +'">'+ notedata.title +'</a>');
}
});
});
</script>
第一行是我们加载Socket.IO客户端库的地方。你会注意到我们从未设置任何 Express 路由来处理/socket.io URL。相反,Socket.IO库为我们完成了这项工作。
由于我们已经加载了 jQuery(以支持 Bootstrap),我们可以轻松确保使用$(document).ready在页面完全加载后执行此代码。
此代码首先将一个socket对象连接到/home命名空间。该命名空间用于与笔记主页相关的事件。然后我们监听notetitles事件,对于该事件,一些 jQuery DOM 操作会清除当前笔记列表并在屏幕上渲染新的列表。
就这样。我们routes/index.mjs中的代码监听了笔记模型的各种事件,并相应地向浏览器发送了notetitles事件。浏览器代码接收该笔记信息列表并重新绘制屏幕。
您可能会注意到我们的浏览器端 JavaScript 没有使用 ES-2015/2016/2017 特性。当然,如果我们这样做,代码会更为简洁。我们如何知道我们的访客是否使用足够现代的浏览器来支持这些语言特性呢?我们可以使用 Babel 将 ES-2015/2016/2017 代码转换为任何浏览器都能运行的 ES5 代码。然而,在浏览器中仍然编写 ES5 代码可能是一个有用的权衡。
运行带有实时主页更新的笔记
我们现在已经实现了足够的功能来运行应用程序并看到一些实时动作。
就像你之前做的那样,在一个窗口中启动用户信息微服务:
$ npm start
> user-auth-server@0.0.1 start /Users/david/chap09/users
> DEBUG=users:* PORT=3333 SEQUELIZE_CONNECT=sequelize-sqlite.yaml node --experimental-modules user-server
(node:11866) ExperimentalWarning: The ESM module loader is experimental.
users:service User-Auth-Service listening at http://127.0.0.1:3333 +0ms
然后,在另一个窗口中,启动笔记应用程序:
$ npm start
> notes@0.0.0 start /Users/david/chap09/notes
> DEBUG=notes:* SEQUELIZE_CONNECT=models/sequelize-sqlite.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 node --experimental-modules ./app
(node:11998) ExperimentalWarning: The ESM module loader is experimental.
notes:debug-INDEX Listening on port 3000 +0ms
然后,在浏览器窗口中,转到http://localhost:3000并登录到笔记应用程序。为了看到实时效果,请打开多个浏览器窗口。如果您可以从多台计算机上使用笔记,那么也请这样做。
在一个浏览器窗口中,开始创建和删除笔记,同时让其他浏览器窗口查看主页。创建一个笔记,它应该立即在另一个浏览器窗口的主页上显示。删除一个笔记,它应该立即消失。
查看笔记时的实时动作
现在我们可以看到笔记应用中某个部分的实时变化真是太酷了。让我们转到/notes/view页面看看我们能做什么。想到的功能如下:
-
如果其他人编辑了笔记,则更新笔记
-
如果其他人删除了笔记,则将查看者重定向到主页
-
允许用户在笔记上留下评论
对于前两个功能,我们可以依赖于来自笔记模型的现有事件。第三个功能将需要一个消息子系统,所以我们将在本章稍后讨论。
在routes/notes.mjs中,将以下内容添加到模块的末尾:
export function socketio(io) {
notes.events.on('noteupdate', newnote => {
io.of('/view').emit('noteupdate', newnote);
});
notes.events.on('notedestroy', data => {
io.of('/view').emit('notedestroy', data);
});
};
在app.mjs的顶部,进行以下修改:
import { socketio as indexSocketio, router as index } from './routes/index';
import { router as users, initPassport } from './routes/users';
import { socketio as notesSocketio, router as notes } from './routes/notes';
在app.mjs中取消注释那一行代码,因为我们现在已经实现了我们稍后要实现的功能:
indexSocketio(io);
notesSocketio(io);
这使得笔记应用程序在笔记更新或销毁时发送noteupdate和notedestroy消息。目的地是/view命名空间。我们需要对笔记视图模板进行相应的修改,以便它能够正确处理。这意味着任何查看应用程序中任何笔记的浏览器都将连接到这个命名空间。每个这样的浏览器都将接收到有关任何笔记更改的事件,即使这些笔记没有被查看。这意味着客户端代码将不得不检查键,并且只有当事件指的是正在显示的笔记时才采取行动。
为实时动作更改笔记视图模板
就像我们之前做的那样,为了使用户能够看到这些事件,我们不仅需要在模板中添加客户端代码到views/noteview.hbs,还需要对模板进行一些小的修改:
<div class="container-fluid">
<div class="row"><div class="col-xs-12">
{{#if note}}<h3 id="notetitle">{{ note.title }}</h3>{{/if}}
{{#if note}}<div id="notebody">{{ note.body }}</div>{{/if}}
<p>Key: {{ notekey }}</p>
</div></div>
{{#if user }}
{{#if notekey }}
<div class="row"><div class="col-xs-12">
<div class="btn-group">
<a class="btn btn-outline-dark"
href="/notes/destroy?key={{notekey}}"
role="button">Delete</a>
<a cl e template, views/noteview.hb
ass="btn btn-outline-dark"
href="/notes/edit?key={{notekey}}"
role="button">Edit</a>
</div>
</div></div>
{{/if}}
{{/if}}
</div>
{{> footerjs}}
{{#if notekey }}
<script src="img/socket.io.js"></script>
<script>
$(document).ready(function () {
io('/view').on('noteupdate', function(note) {
if (note.key === "{{ notekey }}") {
$('h3#notetitle').empty();
$('h3#notetitle').text(note.title);
$('#notebody').empty();
$('#notebody').text(note.body);
}
});
io('/view').on('notedestroy', function(data) {
if (data.key === "{{ notekey }}") {
window.location.href = "/";
}
});
});
</script>
{{/if}}
我们连接到/view命名空间,消息就是从这里发送的。当noteupdate或notedestroy消息到达时,我们检查键值以确定它是否与正在显示的笔记的键值匹配。
这里使用了一种重要的技术,需要理解。我们在服务器上混合了执行的 JavaScript,以及在浏览器中执行的 JavaScript。我们必须将客户端代码接收到的notekey与该页面正在查看的笔记的notekey进行比较。后者notekey值在服务器上是已知的,而前者是在客户端知道的。
记住,在{{ .. }}定界符内的代码是由服务器上的 Handlebars 模板引擎进行解释的。考虑以下内容:
if (note.key === "{{ notekey }}") {
..
}
这个比较是在浏览器中到达的消息中的notekey值和服务器上的notekey变量之间进行的。该变量包含正在显示的笔记的键值。因此,在这种情况下,我们能够确保这些代码片段仅针对屏幕上显示的笔记执行。
对于noteupdate事件,我们取新的笔记内容并在屏幕上显示。为了实现这一点,我们必须添加id=属性到 HTML 中,这样我们就可以使用 jQuery 选择器来操作 DOM。
对于notedestroy事件,我们简单地让浏览器窗口返回主页。被查看的笔记已被删除,用户继续查看一个不再存在的笔记是没有意义的。
在查看笔记的同时运行带有实时更新的笔记
到目前为止,你现在可以重新运行笔记应用并尝试这个功能。
按照之前的方式启动用户身份验证服务器和笔记应用。然后在浏览器中,打开多个笔记应用的窗口。这次,让一个窗口显示主页,另外两个窗口显示笔记。在其中一个窗口中编辑笔记以进行更改,并观察主页和查看笔记的页面上的文本是否发生了变化。
然后删除笔记,观察它从主页消失,以及查看笔记的浏览器窗口现在显示在主页上。
为笔记实现用户间聊天和评论
这很酷!我们现在在编辑、删除或创建笔记时,笔记中有了实时更新。现在让我们将其提升到下一个层次,并实现类似用户间聊天的功能。
我们可以将笔记应用的概念进行转型,并朝着社交网络的方向发展。在大多数这样的网络中,用户发布内容(笔记、图片、视频等),其他用户对这些内容进行评论。如果做得好,这些基本元素可以发展成为一个大型社区,人们相互分享笔记。虽然笔记应用有点像玩具,但它并不太远离成为一个基本社交网络。我们现在进行的评论方式是朝着这个方向迈出的一小步。
在每个笔记页面上,我们将有一个区域来显示 Notes 用户的消息。每条消息将显示用户名、时间戳以及他们的消息。我们还需要一个方法让用户发布消息,并允许用户删除消息。
这些操作将在不刷新屏幕的情况下执行。相反,运行在网页内部的代码将向服务器发送命令并动态地采取行动。
让我们开始吧。
存储消息的数据模型
我们需要首先实现一个用于存储消息的数据模型。所需的基本字段包括一个唯一的 ID、发送消息的人的用户名、消息发送到的命名空间、他们的消息,以及最后一条消息发送的时间戳。当收到或删除消息时,模型必须发出事件,以便我们可以在网页上执行正确的操作。
这个模型实现将针对Sequelize编写。如果你更喜欢其他存储解决方案,你可以完全在其他的数据库存储系统中重新实现相同的 API。
创建一个新文件,models/messages-sequelize.mjs,包含以下内容:
import Sequelize from 'sequelize';
import jsyaml from 'js-yaml';
import fs from 'fs-extra';
import util from 'util';
import EventEmitter from 'events';
class MessagesEmitter extends EventEmitter {}
import DBG from 'debug';
const debug = DBG('notes:model-messages');
const error = DBG('notes:error-messages');
var SQMessage;
var sequlz;
export const emitter = new MessagesEmitter();
这设置了正在使用的模块,并初始化了EventEmitter接口。我们还导出了EventEmitter作为emitter,以便其他模块可以使用它:
async function connectDB() {
if (typeof sequlz === 'undefined') {
const yamltext = await
fs.readFile(process.env.SEQUELIZE_CONNECT, 'utf8');
const params = jsyaml.safeLoad(yamltext, 'utf8');
sequlz = new Sequelize(params.dbname,
params.username, params.password, params.params);
}
if (SQMessage) return SQMessage.sync();
SQMessage = sequlz.define('Message', {
id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey:
true },
from: Sequelize.STRING,
namespace: Sequelize.STRING,
message: Sequelize.STRING(1024),
timestamp: Sequelize.DATE
});
return SQMessage.sync();
}
这定义了我们在数据库中的消息模式。我们将使用与 Notes 相同的数据库,但消息将存储在自己的表中。
id字段将由调用者提供;相反,它将自动生成。因为它是一个autoIncrement字段,所以数据库将为添加的每条消息分配一个新的id号:
export async function postMessage(from, namespace, message) {
const SQMessage = await connectDB();
const newmsg = await SQMessage.create({
from, namespace, message, timestamp: new Date()
});
var toEmit = {
id: newmsg.id, from: newmsg.from,
namespace: newmsg.namespace, message: newmsg.message,
timestamp: newmsg.timestamp
};
emitter.emit('newmessage', toEmit);
}
当用户发布新的评论/消息时,应该调用此操作。我们首先将其存储在数据库中,然后发出一个事件表示消息已被创建:
export async function destroyMessage(id, namespace) {
const SQMessage = await connectDB();
const msg = await SQMessage.find({ where: { id } });
if (msg) {
msg.destroy();
emitter.emit('destroymessage', { id, namespace });
}
}
当用户请求删除消息时,应该调用此操作。使用Sequelize,我们必须首先找到消息,然后通过调用其destroy方法来删除它。一旦完成,我们发出一个消息表示消息已被销毁:
export async function recentMessages(namespace) {
const SQMessage = await connectDB();
const messages = SQMessage.findAll({
where: { namespace }, order: [ 'timestamp' ], limit: 20
});
return messages.map(message => {
return {
id: message.id, from: message.from,
namespace: message.namespace, message: message.message,
timestamp: message.timestamp
};
});
}
虽然这是在查看笔记时调用的,但它被通用化以适用于任何 Socket.IO 命名空间。它找到与给定命名空间关联的最新的 20 条消息,并将清理后的列表返回给调用者。
向 Notes 路由添加消息
现在我们可以在数据库中存储消息了,让我们将其集成到 Notes 路由模块中。
在routes/notes.mjs中,将以下内容添加到import语句中:
import * as messages from '../models/messages-sequelize';
如果你希望为消息实现不同的数据存储模型,你需要更改这个import语句。你应该考虑使用环境变量来指定模块名称,就像我们在其他地方所做的那样:
// Save incoming message to message pool, then broadcast it
router.post('/make-comment', ensureAuthenticated, async (req, res, next) => {
try {
await messages.postMessage(req.body.from,
req.body.namespace, req.body.message);
res.status(200).json({ });
} catch(err) {
res.status(500).end(err.stack);
}
});
// Delete the indicated message
router.post('/del-message', ensureAuthenticated, async (req, res, next) => {
try {
await messages.destroyMessage(req.body.id, req.body.namespace);
res.status(200).json({ });
} catch(err) {
res.status(500).end(err.stack);
}
});
这对路由/notes/make-comment和/notes/del-message用于发布新的评论或删除现有的评论。每个都调用相应的数据模型函数,然后向调用者发送适当的响应。
记住postMessage将消息存储在数据库中,然后它转身向其他浏览器发出该消息。同样,destroyMessage从数据库中删除消息,然后向其他浏览器发出一条消息,表示该消息已被删除。最后,recentMessages的结果将反映数据库中的当前消息集。
这两个都会由浏览器中的 AJAX 代码调用:
module.exports.socketio = function(io) {
io.of('/view').on('connection', function(socket) {
// 'cb' is a function sent from the browser, to which we
// send the messages for the named note.
socket.on('getnotemessages', (namespace, cb) => {
messages.recentMessages(namespace).then(cb)
.catch(err => console.error(err.stack));
});
});
messages.emitter.on('newmessage', newmsg => {
io.of('/view').emit('newmessage', newmsg);
});
messages.emitter.on('destroymessage', data => {
io.of('/view').emit('destroymessage', data);
});
..
};
这是我们将要添加到之前查看的代码中的 Socket.IO 粘合代码。
来自浏览器的getnotemessages消息请求给定笔记的消息列表。这调用模型中的recentMessages函数。这使用了 Socket.IO 的一个特性,客户端可以传递一个回调函数,服务器端 Socket.IO 代码可以调用该回调,并给它一些数据。
我们还监听由消息模型发出的newmessage和destroymessage消息,向浏览器发送相应的消息。这些消息使用之前描述的方法发送。
修改消息视图模板
我们需要回到views/noteview.hbs进行更多更改,以便我们可以查看、创建和删除消息。这次,我们将添加很多代码,包括使用 Bootstrap modal 弹出窗口获取消息,几个与服务器通信的 AJAX 调用,当然,还有更多的 Socket.IO 内容。
使用 Modal 窗口来撰写消息
Bootstrap 框架有一个 Modal 组件,它在桌面应用程序的 Modal 对话框中起到类似的作用。你弹出 Modal,它阻止与其他网页部分的交互,你将内容输入到 Modal 中的字段,然后点击一个按钮使其关闭。
这段新代码替换了在views/noteview.hbs中定义的现有编辑和删除按钮段:
{{#if user}}
{{#if notekey}}
<div class="row"><div class="col-xs-12">
<div class="btn-group">
<a class="btn btn-outline-dark" href="/notes/destroy?key=
{{notekey}}"
role="button">Delete</a>
<a class="btn btn-outline-dark" href="/notes/edit?key=
{{notekey}}"
role="button">Edit</a>
<button type="button" class="btn btn-outline-dark"
data-toggle="modal"
data-target="#notes-comment-modal">Comment</button>
</div>
</div></div>
<div id="noteMessages"></div>
{{/if}}
{{/if}}
这增加了在笔记上发布评论的支持。用户将看到一个 Modal 弹出窗口,在其中他们可以写下他们的评论。我们稍后会展示 Modal 的代码。
我们添加了一个新的按钮,标签为“评论”,用户将点击它以开始发布消息的过程。这个按钮通过data-target属性中指定的元素 ID 与 Modal 连接。ID 将与包裹 Modal 的最外层div匹配。这种div元素和类名的结构来自 Bootstrap 网站getbootstrap.com/docs/4.0/components/modal/。
让我们在views/noteview.hbs的底部添加 Modal 的代码。
{{> footerjs}}
{{#if notekey}}
{{#if user}}
<div class="modal fade" id="notes-comment-modal" tabindex="-1"
role="dialog" aria-labelledby="noteCommentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-
label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 class="modal-title" id="noteCommentModalLabel">Leave a
Comment</h4>
</div>
<div class="modal-body">
<form method="POST" id="submit-comment" class="well" data-async
data-target="#rating-modal" action="/notes/make-comment">
<input type="hidden" name="from" value="{{ user.id }}">
<input type="hidden" name="namespace" value="/view-
{{notekey}}">
<input type="hidden" name="key" value="{{notekey}}">
<fieldset>
<div class="form-group">
<label for="noteCommentTextArea">
Your Excellent Thoughts, Please</label>
<textarea id="noteCommentTextArea" name="message"
class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button id="submitNewComment" type="submit" class="btn
btn-default">
Make Comment</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</div>
{{/if}}
{{/if}}
这里的关键部分是包含在div.modal-body元素内的 HTML 表单。它是一个简单、正常的 Bootstrap 增强表单,底部有一个正常的提交按钮。几个隐藏的input元素用于在请求中传递额外信息。
以这种方式设置 HTML 后,Bootstrap 将确保当用户点击评论按钮时触发此模态。用户可以通过点击关闭按钮来关闭模态。否则,我们需要实现代码来处理表单的 AJAX 提交,以避免页面重新加载。
发送、显示和删除消息
注意,这些代码片段被{{#if}}语句包裹,这样只有足够权限的用户才能显示某些用户界面元素。未登录的用户当然不应该能够发布消息。
现在我们有很多 Socket.IO 代码要添加:
{{#if notekey}}
{{#if user}}
<script>
$(document).ready(function () { ... });
{{/if}}
{{/if}}
另有一个代码部分用于处理noteupdate和notedestroy消息。这个新部分与处理评论的消息有关。
我们需要处理发布新评论的表单提交,在首次查看笔记时获取最近的消息,监听来自服务器的新消息或删除消息的事件,在屏幕上渲染消息,并处理删除消息的请求:
$(document).ready(function () {
io('/view').emit('getnotemessages', '/view-{{notekey}}', function(msgs) {
$('#noteMessages').empty();
if (msgs.length > 0) {
msgs.forEach(function(newmsg) {
$('#noteMessages').append(formatMessage(newmsg));
});
$('#noteMessages').show();
connectMsgDelButton();
} else $('#noteMessages').hide();
});
var connectMsgDelButton = function() {
$('.message-del-button').on('click', function(event) {
$.post('/notes/del-message', {
id: $(this).data("id"),
namespace: $(this).data("namespace")
},
function(response) { });
event.preventDefault();
});
};
var formatMessage = function(newmsg) {
return '<div id="note-message-'+ newmsg.id +'" class="card">'
+'<div class="card-body">'
+'<h5 class="card-title">'+ newmsg.from +'</h5>'
+'<div class="card-text">'+ newmsg.message
+' <small style="display: block">'+ newmsg.timestamp
+'</small></div>'
+' <button type="button" class="btn btn-primary message-
del-button" data-id="'
+ newmsg.id +'" data-namespace="'+ newmsg.namespace +'">'
+'Delete</button>'
+'</div>'
+'</div>';
};
io('/view').on('newmessage', function(newmsg) {
if (newmsg.namespace === '/view-{{notekey}}') {
$('#noteMessages').prepend(formatMessage(newmsg));
connectMsgDelButton();
}
});
io('/view').on('destroymessage', function(data) {
if (data.namespace === '/view-{{notekey}}') {
$('#noteMessages #note-message-'+ data.id).remove();
}
});
$('form#submit-comment').submit(function(event) {
// Abort any pending request
if (request) { request.abort(); }
var $form = $('form#submit-comment');
var $target = $($form.attr('data-target'));
var request = $.ajax({
type: $form.attr('method'),
url: $form.attr('action'),
data: $form.serialize()
});
request.done(function (response, textStatus, jqXHR) { });
request.fail(function (jqXHR, textStatus, errorThrown) {
alert("ERROR "+ jqXHR.responseText);
});
request.always(function () {
// Reenable the inputs
$('#notes-comment-modal').modal('hide');
});
event.preventDefault();
});
});
$('form#submit-comment').submit中的代码处理评论表单的表单提交。因为我们已经有了 jQuery,我们可以使用它的 AJAX 支持向服务器发送请求,而不会导致页面重新加载。
使用event.preventDefault,我们确保默认操作不会发生。对于表单提交,这意味着浏览器页面不会重新加载。实际上,会向/notes/make-comment发送一个 HTTP POST 请求,其中包含表单input元素的值作为数据负载。这些值中包含三个隐藏的输入,from、namespace和key,提供了有用的标识数据。
如果你参考了/notes/make-comment路由定义,这将调用messagesModel.postMessage将消息存储到数据库中。然后该函数发布一个事件,newmessage,我们的服务器端代码将其转发到连接到命名空间的任何浏览器。在此之后不久,浏览器将收到一个newmessage事件。
newmessage事件通过formatMessage函数添加一个消息块。消息的 HTML 被prepend到#noteMessages。
当页面首次加载时,我们希望检索当前的消息。这是通过io('/view').emit('getnotemessages', ..触发的。这个函数,正如其名所示,向服务器发送一个getnotemessages消息。我们之前已经展示了处理此消息的服务器端处理程序的实现,在routes/notes.mjs中。
如果你还记得,我们说过 Socket.IO 支持提供由服务器在响应事件时调用的回调函数。你只需将一个函数作为.emit调用的最后一个参数传递。该函数在通信的另一端可用,以便在适当的时候调用。为了使这一点更清晰,我们在浏览器端有一个由服务器端代码调用的回调函数。
在这种情况下,服务器端调用我们的回调函数,传递一个消息列表。消息列表到达客户端回调函数,它在#noteMessages区域显示它们。它使用 jQuery DOM 操作擦除任何现有消息,然后使用formatMessage函数将每条消息渲染到消息区域。
消息显示模板在formatMessage中很简单。它使用 Bootstrap card来提供良好的视觉效果。还有一个用于删除消息的按钮。
在formatMessage中,我们为每条消息创建了一个删除按钮。这些按钮需要一个事件处理程序,事件处理程序是通过connectMsgDelButton函数设置的。在这种情况下,我们向/notes/del-message发送一个 HTTP POST 请求。我们再次使用 jQuery AJAX 支持来发送该 HTTP 请求。
/notes/del-message路由反过来调用messagesModel.destroyMessage来完成这项任务。该函数随后发出一个事件,destroymessage,该事件被发送回浏览器。正如你所看到的,destroymessage事件处理程序使用 jQuery DOM 操作移除相应的消息。我们小心地为每条消息添加了一个id属性,以便于移除。
由于毁灭的背面是创造,我们需要让newmessage事件处理程序紧挨着destroymessage事件处理程序。它还使用 jQuery DOM 操作将新消息插入到#noteMessages区域。
运行笔记并传递消息
这段代码很多,但我们现在有了编写消息、在屏幕上显示它们以及删除它们的能力,而且无需页面刷新。
你可以像我们之前做的那样运行应用程序,首先在一个命令行窗口中启动用户认证服务器,然后在另一个窗口中启动笔记应用程序:

在输入消息时,模态窗口看起来是这样的:

尝试使用多个浏览器窗口查看相同的笔记或不同的笔记。这样,你可以验证笔记只出现在相应的笔记窗口中。
模态窗口的其他应用
我们使用模态和一些 AJAX 代码来避免由于表单提交而导致的页面刷新。在当前的笔记应用程序中,当创建新笔记、编辑现有笔记和删除现有笔记时,可以使用类似的技术。在这种情况下,我们会使用模态、一些 AJAX 代码来处理表单提交,以及一些 jQuery 代码来更新页面而不会引起刷新。
但是,等等,这还不是全部。考虑一下在流行的社交网络上常见的动态实时用户界面魔法。想象一下需要哪些事件和/或 AJAX 调用。
当你在 Twitter 上点击一张图片时,它会弹出一个模态窗口,展示图片的更大版本。Twitter 新发推文窗口也是一个模态窗口。Facebook 使用了许多不同的模态窗口,例如在分享帖子、举报垃圾邮件帖子或进行 Facebook 设计师认为需要弹出窗口的其他许多操作时。
如我们所见,Socket.IO 为我们提供了一个丰富的服务器和客户端之间事件传递的基础,这可以构建为用户提供的多用户、多通道通信体验。
摘要
尽管在本章中我们已经取得了很大的进步,但也许 Facebook 并不需要担心我们向将笔记应用转化为社交网络迈出的这些小步。本章为我们提供了探索一些真正酷的伪实时通信技术的机会,这些技术可以在浏览器会话之间进行。
查找“实时”这个词的技术定义,你会发现真正的实时网络并非真正意义上的实时。实时软件的实际含义涉及具有严格时间边界的软件,它必须在指定的时间约束内对事件做出响应。实时软件通常用于嵌入式系统,以响应按钮按下,应用于各种不同的应用,如垃圾食品分配器和重症监护室中的医疗设备。吃太多垃圾食品可能会导致你进入重症监护室,在这两种情况下,你都会由实时软件提供服务。试着记住这个短语的不同含义之间的区别。
在本章中,你学习了如何使用 Socket.IO 来实现伪实时网络体验,使用 EventEmitter 类在应用程序的不同部分之间发送消息,jQuery、AJAX 以及其他浏览器端的 JavaScript 技术,同时避免在发起 AJAX 调用时刷新页面。
在下一章中,我们将探讨在真实服务器上部署 Node.js 应用程序。在我们的笔记本电脑上运行代码很酷,但要达到更高的水平,应用程序需要得到适当的部署。
第十章:部署 Node.js 应用
现在笔记应用已经相当完善,是时候考虑如何将其部署到真实的服务器上了。我们已经创建了一个最小化的协作笔记概念实现,它运行得相当不错。为了成长,笔记应用必须离开我们的笔记本电脑,在真实的服务器上运行。目标是研究 Node.js 应用的部署方法。
在本章中,我们将涵盖以下主题:
-
符合传统 LSB 规范的 Node.js 部署
-
使用 PM2 提高可靠性
-
部署到虚拟专用服务器(VPS)提供商
-
使用 Docker 进行微服务部署(我们有四个不同的服务要部署)
-
部署到 Docker 托管提供商
第一项任务是复制上一章的源代码。建议你创建一个新的目录,chap10,作为chap09目录的兄弟目录,并将chap09中的所有内容复制到chap10。
笔记应用架构和部署考虑因素
在我们开始部署笔记应用之前,我们需要回顾其架构。要部署笔记应用,我们必须了解我们打算做什么。
我们已经将服务分为两组,如下所示:

用户认证服务器应该是系统更安全的部分。在我们的笔记本电脑上,我们无法创建围绕该服务的预期保护墙,但我们即将实施这种保护。
提高安全性的一个策略是尽可能少地暴露端口。这减少了所谓的攻击面,简化了我们对应用加固以防止安全漏洞的工作。在笔记应用中,我们恰好有一个需要暴露的端口,即用户通过它访问应用的 HTTP 服务。其他端口,即 MySQL 服务器端口和用户认证服务端口,应该被隐藏。
在内部,笔记应用需要访问笔记数据库和用户认证服务。该服务反过来又需要访问用户认证数据库。根据目前的设想,Notes 应用之外的服务不需要访问这两个数据库或认证服务。
实现这种分割需要两个或三个子网,具体取决于你希望走多远。第一个,FrontNet,包含笔记应用及其数据库。第二个,AuthNet,包含认证服务及其数据库。第三个可能的子网将包含笔记和认证服务。子网配置必须限制可以访问子网的主机,并在子网之间创建安全墙。
传统 Linux Node.js 服务部署
传统 Linux/Unix 服务器应用程序部署使用 init 脚本 来管理后台进程。它们会在系统启动时启动,并在系统停止时干净地关闭。虽然这是一个简单的模型,但具体细节在不同 操作系统(OS)之间差异很大。
一种常见的方法是使用 /etc/init.d 目录中的 shell 脚本来管理 init 进程的后台进程。其他操作系统使用其他进程管理器,例如 upstart 或 launchd。
Node.js 项目本身不包含任何用于管理任何操作系统上服务器进程的脚本。Node.js 更像是一个构建工具包,包含构建服务器的零件和部件,但它本身不是一个完整的、经过抛光的服务器框架。在 Node.js 上实现一个完整的网络服务意味着创建脚本以集成到您操作系统的进程管理中。开发这些脚本的任务取决于我们。
网络服务必须:
-
可靠性:例如,当服务器进程崩溃时自动重启
-
可管理性:意味着它与系统管理实践很好地集成
-
可观察性:意味着管理员必须能够从服务中获取状态和活动信息
为了展示涉及的内容,让我们使用 PM2 来实现 Notes 的后台服务器进程管理。PM2 检测系统类型,并可以自动与进程管理系统集成。它将创建一个 LSB 风格的 init 脚本(wiki.debian.org/LSBInitScripts),或根据您服务器上进程管理系统的要求创建其他脚本。
对于这次部署,我们将设置一个单独的 Ubuntu 17.10 服务器。您应该从托管提供商那里租用一个 虚拟专用服务器(VPS),并在那里进行所有安装和配置。从主要提供商那里租用一个小型机器实例,以通过本章所需的时间,只需花费几美元。
您也可以使用笔记本电脑上的 VirtualBox 来完成本节中的任务。只需在 VirtualBox 中安装 Debian 或 Ubuntu 作为虚拟机,然后按照本节中的说明操作。这不会完全像使用远程 VPS 托管提供商那样,但不需要租用服务器。
便笺和用户身份验证服务都将运行在该服务器上,以及一个单独的 MySQL 实例。虽然我们的目标是实现 FrontNet 和 AuthNet 之间的强分离,但由于有两个 MySQL 实例,我们目前不会这样做。
前置条件 – 数据库配置
Linux 软件包管理系统不允许我们安装两个 MySQL 实例。相反,我们通过使用具有不同用户名和访问权限的单独数据库在同一 MySQL 实例中实现分离。
第一步是确保 MySQL 已安装在你的服务器上。对于 Ubuntu,DigitalOcean有一个相当不错的教程:www.digitalocean.com/community/tutorials/how-to-install-mysql-on-ubuntu-14-04。虽然该教程的 Ubuntu 版本已经过时,但说明仍然足够准确。
MySQL 服务器必须支持从localhost的 TCP 连接。编辑配置文件/etc/mysql/my.cnf,添加以下行:
bind-address = 127.0.0.1
这限制了 MySQL 服务器连接到服务器上的进程。一个恶意用户必须入侵服务器才能访问你的数据库。现在我们的数据库服务器已可用,让我们设置两个数据库。
在chap10/notes/models目录下,创建一个名为mysql-create-db.sql的文件,包含以下内容:
CREATE DATABASE notes;
CREATE USER 'notes'@'localhost' IDENTIFIED BY 'notes';
GRANT ALL PRIVILEGES ON notes.* TO 'notes'@'localhost' WITH GRANT OPTION;
在chap10/users目录下,创建一个名为mysql-create-db.sql的文件,包含以下内容:
CREATE DATABASE userauth;
CREATE USER 'userauth'@'localhost' IDENTIFIED BY 'userauth';
GRANT ALL PRIVILEGES ON userauth.* TO 'userauth'@'localhost' WITH GRANT OPTION;
我们不能在服务器上运行这些脚本,因为 Notes 应用程序尚未复制到服务器上。当完成这一步后,我们将以以下方式运行脚本:
$ mysql -u root -p <chap10/users/mysql-create-db.sql
$ mysql -u root -p <chap10/notes/models/mysql-create-db.sql
这将创建两个数据库,notes和userauth,以及相关的用户名和密码。每个用户只能访问他们关联的数据库。稍后,我们将使用 YAML 配置文件设置 Notes 和用户认证服务以访问这些数据库。
在 Ubuntu 上安装 Node.js
根据 Node.js 文档(nodejs.org/en/download/package-manager/),对于 Debian 或 Ubuntu Linux 发行版,推荐的安装方法如下:
$ curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
$ sudo apt-get update
$ sudo apt-get install -y nodejs build-essential
我们之前已经见过,所以将 Node.js 期望的版本号替换到 URL 中。以这种方式安装意味着随着新的 Node.js 版本发布,升级可以通过正常的软件包管理程序轻松完成。
在服务器上设置 Notes 和用户认证
在将 Notes 和用户认证代码复制到这个服务器之前,让我们做一些编码来为迁移做准备。我们知道 Notes 和认证服务必须使用之前给出的用户名和密码通过localhost访问 MySQL 实例。
使用我们迄今为止采用的方法,这意味着为Sequelize参数创建一对 YAML 文件,并在package.json文件中更改环境变量以匹配。
创建一个chap10/notes/models/sequelize-server-mysql.yaml文件,包含以下内容:
dbname: notes
username: notes
password: notes12345
params:
host: localhost
port: 3306
dialect: mysql
在测试过程中发现,简单的密码如notes不被 MySQL 服务器接受,需要更长的密码。在chap10/notes/package.json中,将以下行添加到scripts部分:
"on-server": "SEQUELIZE_CONNECT=models/sequelize-server-mysql.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3000 node --experimental-modules ./app",
然后创建一个chap10/users/sequelize-server-mysql.yaml文件,包含以下代码:
dbname: userauth
username: userauth
password: userauth
params:
host: localhost
port: 3306
dialect: mysql
这些配置文件中显示的密码显然无法通过任何安全审计。
在chap10/users/package.json中,将以下行添加到scripts部分:
"on-server": "PORT=3333 SEQUELIZE_CONNECT=sequelize-server-mysql.yaml node --experimental-modules ./user-server",
这将配置身份验证服务以访问刚刚创建的数据库。
现在我们需要在服务器上选择一个位置来安装应用程序代码:
# ls /opt
这个空目录看起来是一个不错的选择。只需将 chap10/notes 和 chap10/users 上传到你的首选位置。在上传之前,请从两个目录中删除 node_modules 目录。
这样做是为了节省上传时间,同时也是因为简单的事实,即你在笔记本电脑上安装的任何原生代码模块都将与服务器不兼容。
在你的笔记本电脑上,你可能运行这样的命令:
$ rsync --archive --verbose ./ root@159.89.145.190:/opt/
使用分配给正在使用的服务器的实际 IP 地址或域名。
你最终应该得到以下类似的内容:
# ls /opt
notes users
然后,在每个目录中运行以下命令:
# rm -rf node_modules
# npm install
我们以 root 身份运行这些命令,而不是可以运行 sudo 命令的用户 ID。所选托管提供商(DigitalOcean)提供的机器配置为用户以 root 身份登录。其他 VPS 托管提供商将提供需要以普通用户身份登录的机器,然后使用 sudo 执行特权操作。在阅读这些说明时,请注意我们显示的命令提示符。我们遵循了以下惯例:$ 用于以普通用户身份运行的命令,# 用于以 root 身份运行的命令。如果你以普通用户身份运行,需要运行 root 命令,那么请使用 sudo 运行该命令。
最简单的方法是直接删除整个 node_modules 目录,然后让 npm install 执行其工作。记住,我们是这样设置 PATH 环境变量的:
# export PATH=./node_modules/.bin:${PATH}
你可以将这个命令放在服务器的登录脚本(.bashrc, .cshrc 等)中,以便自动启用。
最后,你现在可以运行之前编写的 SQL 脚本以设置数据库实例:
# mysql -u root -p <users/mysql-create-db.sql
# mysql -u root -p <notes/models/mysql-create-db.sql
然后,你应该能够手动启动服务以检查一切是否正常工作。MySQL 实例已经过测试,所以我们只需要启动用户身份验证和笔记服务:
# cd /opt/users
# DEBUG=users:* npm run on-server
> user-auth-server@0.0.1 on-server /opt/users
> PORT=3333 SEQUELIZE_CONNECT=sequelize-server-mysql.yaml node --experimental-modules ./user-server
(node:9844) ExperimentalWarning: The ESM module loader is experimental.
然后,在另一个终端会话中登录到服务器并运行以下命令:
# cd /opt/users/
# PORT=3333 node users-add.js
Created { id: 1, username: 'me', password: 'w0rd', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr', middleName: '',
emails: '[]', photos: '[]',
updatedAt: '2018-02-02T00:43:16.923Z', createdAt: '2018-02-02T00:43:16.923Z' }
# PORT=3333 node users-list.js
List [ { id: 'me', username: 'me', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr', middleName: '',
emails: '[]', photos: '[]' } ]
前面的命令既测试了后端用户身份验证服务是否正常工作,又提供了一个我们可以用来登录的用户账户。users-list 命令演示了它的工作原理。
你可能会遇到错误:
users:error /create-user Error: Please install mysql2 package manually
这是在 Sequelize 内部生成的。mysql2 驱动是一个替代的 MySQL 驱动程序,用纯 JavaScript 实现,并包括对返回 Promises 的支持,以便在 async 函数中平滑使用。如果你确实收到这条消息,请继续安装该软件包,并记住将此依赖项添加到你的 package.json 中。
现在,我们可以启动笔记服务:
# cd ../notes
# npm run on-server
> notes@0.0.0 on-server /opt/notes
> SEQUELIZE_CONNECT=models/sequelize-server-mysql.yaml NOTES_MODEL=sequelize USER_SERVICE_URL=http://localhost:3333 PORT=3000 node --experimental-modules ./app
(node:9932) ExperimentalWarning: The ESM module loader is experimental.
然后,我们可以使用我们的网络浏览器连接到应用程序。由于你可能没有与这个服务器关联的域名,笔记可以通过服务器的 IP 地址访问,例如 http://159.89.145.190:3000/。
在这些示例中,我们使用的是用于测试本节中指令的 VPS 的 IP 地址。您使用的 IP 地址当然会不同。
到现在为止,您应该知道验证笔记是否正常工作的步骤。创建一些笔记,打开几个浏览器窗口,查看实时通知是否工作,等等。一旦您确认笔记在服务器上正常工作,请终止进程并继续下一节,我们将设置在服务器启动时运行。
调整 Twitter 认证以在服务器上工作
我们之前为笔记设置的 Twitter 应用将无法工作,因为服务器的认证 URL 不正确。目前,我们可以使用之前创建的用户配置文件登录。如果您想看到 OAuth 与 Twitter 一起工作,请访问apps.twitter.com并重新配置应用以使用服务器的 IP 地址。
由于托管位置不是我们的笔记本电脑,Twitter 的callbackURL必须指向正确的位置。默认值是http://localhost:3000,用于笔记本电脑。但现在我们需要使用服务器的 IP 地址。在notes/package.json中,向on-server脚本添加以下环境变量:
TWITTER_CALLBACK_HOST=http://159.89.145.190:3000
使用分配给正在使用的服务器的实际 IP 地址或域名。在实际部署中,我们将在这里使用域名。
设置 PM2 以管理 Node.js 进程
有许多方法可以管理服务器进程,以确保进程崩溃时可以重启,等等。我们将使用PM2 (pm2.keymetrics.io/),因为它针对 Node.js 进程进行了优化。它将进程管理和监控集成到一个应用程序中。
让我们在init目录下创建一个目录,以便使用 PM2。PM2 网站建议您全局安装此工具,但作为十二要素应用模型的学生,我们认识到最好使用明确声明的依赖项,并避免全局未管理的依赖项。
创建一个包含以下内容的package.json文件:
{
"name": "pm2deploy",
"version": "1.0.0",
"scripts": {
"start": "pm2 start ecosystem.json",
"stop": "pm2 stop ecosystem.json",
"restart": "pm2 restart ecosystem.json",
"status": "pm2 status",
"save": "pm2 save",
"startup": "pm2 startup"
},
"dependencies": {
"pm2": "².9.3"
}
}
使用npm install命令按常规安装 PM2。
在正常的 PM2 使用中,我们使用pm2 start script-name.js启动脚本。我们可以创建一个/etc/init脚本来完成此操作,但 PM2 还支持一个名为ecosystem.json的文件,可以用来管理进程集群。我们有两个进程需要一起管理,即面向用户的笔记应用和后端的用户认证服务。
创建一个名为ecosystem.json的文件,包含以下内容:
{
"apps" : [
{
"name": "User Authentication",
"script": "user-server.mjs",
"cwd": "/opt/users",
"node_args": "--experimental-modules",
"env": {
"PORT": "3333",
"SEQUELIZE_CONNECT": "sequelize-server-mysql.yaml"
},
"env_production": { "NODE_ENV": "production" }
},
{
"name": "Notes",
"script": "app.mjs",
"cwd": "/opt/notes",
"node_args": "--experimental-modules",
"env": {
"PORT": "3000",
"SEQUELIZE_CONNECT": "models/sequelize-server-mysql.yaml",
"NOTES_MODEL": "sequelize",
"USER_SERVICE_URL": "http://localhost:3333",
"TWITTER_CONSUMER_KEY": "..",
"TWITTER_CONSUMER_SECRET": "..",
"TWITTER_CALLBACK_HOST": "http://45.55.37.74:3000"
},
"env_production": { "NODE_ENV": "production" }
}
]
}
此文件描述了包含两个服务的目录,每个服务的运行脚本,命令行选项以及要使用的环境变量。这是与package.json脚本中相同的信息,但表述得更清晰。调整TWITTER_CALLBACK_HOST以匹配服务器的 IP 地址。有关文档,请参阅pm2.keymetrics.io/docs/usage/application-declaration/。
然后,我们使用npm run start启动服务,屏幕上显示如下:

您可以再次将浏览器导航到您服务器的 URL,例如 http://159.89.145.190:3000,并检查笔记是否正在运行。一旦启动,以下是一些有用的命令:
# pm2 list
# pm2 describe 1
# pm2 logs 1
这些命令让您可以查询服务状态。
pm2 monit 命令为您提供系统活动的伪图形监控器。有关文档,请参阅 pm2.keymetrics.io/docs/usage/monitoring/。
pm2 logs 命令解决了我们在其他地方提出的应用日志管理问题。活动日志应被视为事件流,并应适当捕获和管理。使用 PM2,输出会自动捕获,可以查看,日志文件可以轮换和清除。有关文档,请参阅 pm2.keymetrics.io/docs/usage/log-management/。
如果我们重新启动服务器,这些进程不会随服务器一起启动。我们该如何处理这种情况?这非常简单,因为 PM2 可以为我们生成一个 init 脚本:
# pm2 save
[PM2] Saving current process list...
[PM2] Successfully saved in /root/.pm2/dump.pm2
# pm2 startup
[PM2] Init System found: systemd
Platform systemd
Template
[Unit]
Description=PM2 process manager
Documentation=https://pm2.keymetrics.io/
After=network.target
... more output is printed
pm2 save 命令保存当前状态。当时正在运行的所有服务都将被保存并由生成的启动脚本管理。
下一步是生成启动脚本,使用 pm startup 命令。PM2 支持在多个操作系统上生成启动脚本,但以这种方式运行时,它会自动检测系统类型并生成正确的启动脚本。它还会安装启动脚本,并启动它运行。有关更多信息,请参阅 pm2.keymetrics.io/docs/usage/startup/ 的文档。
如果您仔细查看输出,将打印出一些有用的命令。具体细节将根据您的操作系统而有所不同,因为每个操作系统都有自己的后台进程管理命令。在这种情况下,安装是针对使用 systemctl 命令的,如输出所验证:
Command list
[ 'systemctl enable pm2-root',
'systemctl start pm2-root',
'systemctl daemon-reload',
'systemctl status pm2-root' ]
[PM2] Writing init configuration in /etc/systemd/system/pm2-root.service
[PM2] Making script booting at startup...
...
[DONE]
>>> Executing systemctl start pm2-root
[DONE]
>>> Executing systemctl daemon-reload
[DONE]
>>> Executing systemctl status pm2-root
您可以自行运行以下命令:
# systemctl status pm2-root
● pm2-root.service - PM2 process manager
Loaded: loaded (/etc/systemd/system/pm2-root.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2018-02-02 22:27:45 UTC; 29min ago
Docs: https://pm2.keymetrics.io/
Process: 738 ExecStart=/opt/init/node_modules/pm2/bin/pm2 resurrect (code=exited, status=0/SUCCESS)
Main PID: 873 (PM2 v2.9.3: God)
Tasks: 30 (limit: 4915)
Memory: 171.6M
CPU: 11.528s
CGroup: /system.slice/pm2-root.service
├─873 PM2 v2.9.3: God Daemon (/root/.pm2)
├─895 node /opt/users/user-server.mjs
└─904 node /opt/notes/app.mjs
要验证 PM2 是否按广告宣传的那样启动服务,请重新启动您的服务器,然后使用 PM2 检查状态:

首先要注意的是,在最初登录到 root 账户时,pm2 status 命令不可用。我们已将 PM2 本地安装到 /opt/init,该命令仅在目录中可用。
在进入那个目录后,我们现在可以运行该命令并查看状态。请记住在 TWITTER_CALLBACK_HOST 环境变量中设置正确的 IP 地址或域名。否则,使用 Twitter 登录将失败。
现在,笔记应用已经在相当好的管理系统下。我们可以在服务器上轻松更新其代码并重启服务。如果服务崩溃,PM2 将自动重启它。日志文件会自动保存供我们查阅。
PM2 还支持从我们的笔记本电脑上的源代码进行部署,我们可以将其推送到预发布或生产环境。为了支持这一点,我们必须将部署信息添加到ecosystem.json文件中,然后运行pm2 deploy命令将代码推送到服务器。有关更多信息,请参阅 PM2 网站:pm2.keymetrics.io/docs/usage/deployment/。
虽然 PM2 在管理服务器进程方面做得很好,但我们开发的系统对于互联网规模的服务来说是不够的。如果笔记应用突然成为病毒式热门,我们突然需要在全球范围内部署一百万台服务器怎么办?像这样逐个部署和维护服务器,是不可扩展的。
我们还跳过了在开始时实现架构决策。将用户认证数据放在同一服务器上是一个安全风险。我们希望将这些数据部署到不同的服务器上,并实施更严格的安全措施。
在下一节中,我们将探讨一个新的系统,Docker,它解决了这些问题以及更多。
使用 Docker 进行 Node.js 微服务部署
Docker(docker.com)是软件行业的新宠。兴趣像野火一样迅速蔓延,催生了众多项目,其中许多项目的名称都包含与运输集装箱相关的双关语。
它被描述为一个为开发者和系统管理员提供的开放平台,用于分布式应用。它围绕 Linux 容器化技术设计,专注于描述任何 Linux 变种的软件配置。
Docker 自动化了在软件容器中的应用部署。Linux 容器的基本概念可以追溯到 20 世纪 70 年代首次实现的chroot监狱,以及其他如 Solaris Zones 这样的系统。Docker 的实现基于 Linux cgroups、内核命名空间和具有联合文件系统能力的文件系统,这些结合在一起使得 Docker 成为它所是的样子。这有点像是在说技术术语,所以让我们尝试一个更简单的解释。
Docker 容器是 Docker 镜像的运行实例。镜像是由开发者设计的,用于特定目的的 Linux 操作系统和应用配置。开发者使用Dockerfile来描述镜像。Dockerfile 是一个相对简单的脚本,它展示了 Docker 如何构建镜像。Docker 镜像被设计成可以复制到任何服务器,在那里镜像被实例化为 Docker 容器。
运行中的容器会让你感觉就像你在一个虚拟服务器内部,这个服务器运行在虚拟机上。但 Docker 容器化与 VirtualBox 这样的虚拟机系统非常不同。容器内部运行的进程实际上是在宿主操作系统上运行的。容器化技术(cgroups、内核命名空间等)创建了一种错觉,即它们在 Dockerfile 中指定的 Linux 变体上运行,即使宿主操作系统完全不同。你的宿主操作系统可能是 Ubuntu,而容器操作系统可能是 Fedora 或 OpenSUSE;Docker 使这一切都能正常工作。
相比之下,使用虚拟机软件(如 VirtualBox 和 VMWare 等),你感觉就像在使用一台真正的计算机。有一个虚拟 BIOS 和虚拟化的系统硬件,你必须安装一个完整的客户操作系统。你必须遵循计算机拥有的每一个仪式,包括如果是一个封闭源系统(如 Windows)的话,还需要确保许可证的安全。
虽然 Docker 主要针对 Linux 的 x86 版本,但它也适用于多个基于 ARM 的操作系统以及其他处理器。你甚至可以在单板计算机上运行 Docker,例如树莓派,用于面向硬件的物联网项目。像 Resin.IO 这样的操作系统已优化,仅用于运行 Docker 容器。
Docker 生态系统包含许多工具,并且它们的数量正在迅速增加。为了我们的目的,我们将专注于以下三个特定工具:
-
Docker engine:这是核心执行系统,负责协调一切。它运行在 Linux 宿主系统上,提供了一个基于网络的 API,客户端应用程序使用它来发出 Docker 请求,例如构建、部署和运行容器。
-
Docker machine:这是一个客户端应用程序,在宿主计算机上执行围绕 Docker Engine 实例配置的功能。
-
Docker compose:这可以帮助你在一个文件中定义一个多容器应用程序,包括所有依赖项。
使用 Docker 生态系统,你可以创建一个完整的子网和服务宇宙来实现你的梦想应用。这个宇宙可以在你的笔记本电脑上运行,也可以部署到全球范围内的云托管设施网络。攻击者可以攻击的表面积由开发者严格定义。多容器应用程序甚至会在服务之间限制访问,使得即使攻击者成功入侵容器,也很难突破容器。
使用 Docker,我们首先将在笔记本电脑上设计之前图中所示的系统。然后,我们将该系统迁移到服务器上的 Docker 实例。
在你的笔记本电脑上安装 Docker
学习如何在笔记本电脑上安装 Docker 的最佳地方是 Docker 文档网站。我们寻找的是 Docker 社区版(CE)。还有 Docker 企业版(EE),具有更多功能和一些支付支持费用的机会:
-
macOS 安装 –
docs.docker.com/docker-for-mac/install/ -
Windows 安装 –
docs.docker.com/docker-for-windows/install/ -
Ubuntu 安装 –
docs.docker.com/install/linux/docker-ce/ubuntu/ -
为其他几个发行版提供了说明。一些有用的 Linux 后安装说明可在
docs.docker.com/install/linux/linux-postinstall/找到。
因为 Docker 在 Linux 上运行,所以它不会在 macOS 或 Windows 上原生运行。在任一操作系统上安装都需要在虚拟机内部安装 Linux,然后在那个虚拟 Linux 机器上运行 Docker 工具。你必须自己手动设置那些日子已经一去不复返了。Docker 团队通过为 Mac 和 Windows 开发易于使用的 Docker 应用程序来简化了这一过程。Docker for Windows 和 Docker for Mac 捆绑了 Docker 工具和轻量级虚拟机软件。结果是极其轻量,Docker 容器可以在后台运行,对系统的影响很小。
你可能会在 macOS 上找到将 Docker Toolbox 作为安装 Docker 的方法的参考资料。该应用程序已经不再使用,已被 Docker for Windows 和 Docker for Mac 所取代。
使用 Docker for Windows/macOS 启动 Docker
要启动 Docker for Windows 或 Mac 非常简单。你只需找到并双击应用程序图标。它将以任何其他原生应用程序的方式启动。启动后,它管理一个虚拟机(不是 VirtualBox),其中运行着 Docker Engine 的 Linux 实例。在 macOS 上,会出现一个菜单栏图标,你可以通过它来控制Docker.app,而在 Windows 上,系统托盘中会有一个图标。
有可用的设置,使得每次启动笔记本电脑时 Docker 都会自动启动。
在两者上,CPU 必须支持虚拟化。Docker for Windows 和 Docker for Mac 捆绑的超轻量级虚拟机管理程序,反过来又需要 CPU 的虚拟化支持。
对于 Windows 系统,可能需要 BIOS 配置。请参阅docs.docker.com/docker-for-windows/troubleshoot/#virtualization-must-be-enabled。
对于 Mac,这需要 2010 年或更新的硬件,以及 Intel 对内存管理单元(MMU)虚拟化的硬件支持,包括扩展页表(EPT)和无限制模式。你可以通过运行sysctl kern.hv_support来检查此支持。它还要求 macOS 10.11 或更高版本。
尝试 Docker
设置完成后,我们可以使用本地的 Docker 实例来创建 Docker 容器,运行一些命令,并总体上学习如何使用这个惊人的系统。
就像许多软件之旅一样,这一旅程从说出Hello World开始:
$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
ca4f61b1923c: Pull complete
Digest: sha256:66ef312bbac49c39a89aa9bcc3cb4f3c9e7de3788c944158df3ee0176d32b751
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1\. The Docker client contacted the Docker daemon.
2\. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3\. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4\. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
https://cloud.docker.com/
For more examples and ideas, visit:
https://docs.docker.com/engine/userguide/
docker run 命令下载一个 Docker 镜像,该镜像名称在命令行中指定,然后从该镜像初始化一个 Docker 容器,并运行该容器。在这种情况下,名为 hello-world 的镜像在本地计算机上不存在,因此需要下载并初始化。完成这些操作后,hello-world 容器被执行并打印出这些说明。
你可以查询你的计算机以查看,尽管 hello-world 容器已经执行并完成,但它仍然存在:

docker ps 命令列出正在运行的 Docker 容器。正如我们所见,hello-world 容器不再运行,但使用 -a 开关,docker ps 也会显示那些存在但当前未运行的容器。我们还看到,这台计算机安装了一个 Nextcloud 实例及其关联的数据库。
当你完成对容器的使用后,可以使用以下命令进行清理:
$ docker rm boring_lumiere
boring_lumiere
名称 boring_lumiere 是 Docker 自动生成的容器名称。虽然镜像名称是 hello-world,但这不是容器名称。Docker 生成了容器名称,这样你就有了一个比容器 ID 列表中显示的十六进制 ID 更用户友好的标识符。在创建容器时,指定任何你喜欢的容器名称都很简单。
为用户认证服务创建 AuthNet
在我们脑海中围绕这些理论旋转的时候,是时候做一些实际的事情了。让我们先设置用户认证服务。在前面显示的图中,这将是一个标记为 AuthNet 的框,其中包含一个 MySQL 实例和认证服务器。
MySQL 的 Docker 容器
要查找公开可用的 Docker 镜像,请访问 hub.docker.com/ 并进行搜索。你会找到许多现成的 Docker 镜像。例如,Nextcloud 及其关联的数据库,在我们尝试运行 hello-world 应用程序时已展示过安装。这两个都是从它们各自的项目团队提供的,安装和运行容器的简单(或多或少)操作就是输入 docker run nextcloud。安装 Nextcloud 及其关联的数据库,以及许多其他打包的应用程序,如 GitLab,与我们要构建 AuthNet 的过程非常相似,所以你即将学习的技能非常实用。
仅针对 MySQL,就有超过 11,000 个容器可供选择。幸运的是,MySQL 团队提供的两个容器非常受欢迎且易于使用。mysql/mysql-server 镜像配置起来稍微简单一些,所以我们使用这个。
可以指定 Docker 镜像名称,以及一个通常表示软件版本号的 tag。在这种情况下,我们将使用 mysql/mysql-server:5.7,其中 mysql/mysql-server 是容器名称,5.7 是 tag。MySQL 5.7 是当前的 GA 版本。按照以下方式下载镜像:
$ docker pull mysql/mysql-server:5.7
5.7: Pulling from mysql/mysql-server
4040fe120662: Pull complete
d049aa45d358: Pull complete
a6c7ed00840d: Pull complete
853789d8032e: Pull complete
Digest: sha256:1b4c7c24df07fa89cdb7fe1c2eb94fbd2c7bd84ac14bd1779e3dec79f75f37c5
Status: Downloaded newer image for mysql/mysql-server:5.7
这个操作总共下载了四个镜像,因为此镜像是在三个其他镜像之上构建的。我们将在学习如何构建 Dockerfile 时了解它是如何工作的。
可以使用以下方式使用此镜像启动容器:
$ docker run --name=mysql --env MYSQL_ROOT_PASSWORD=f00bar mysql/mysql-server:5.7
[Entrypoint] MySQL Docker Image 5.7.21-1.1.4
[Entrypoint] Initializing database
[Entrypoint] Database initialized
...
[Entrypoint] ignoring /docker-entrypoint-initdb.d/*
[Entrypoint] Server shut down
[Entrypoint] MySQL init process done. Ready for start up.
[Entrypoint] Starting MySQL 5.7.21-1.1.4
我们以前台模式启动了这个服务。容器名称是mysql。我们设置了一个环境变量,根据镜像文档,这个环境变量初始化了root密码。在另一个窗口中,我们可以进入容器并按照以下方式运行 MySQL 客户端:
$ docker exec -it mysql mysql -u root -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 4
Server version: 5.7.21 MySQL Community Server (GPL)
Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
4 rows in set (0.00 sec)
mysql>
docker exec命令允许你在容器内部运行程序。-it选项表示命令以交互方式在分配的终端上运行。将mysql替换为bash,您就有一个交互式的bash命令外壳。
这个mysql命令实例正在容器内部运行。默认情况下,容器配置为不暴露任何外部端口,并且有一个默认的my.cnf文件。
数据库文件被锁定在容器内部。一旦删除该容器,数据库就会消失。Docker 容器旨在是短暂的,根据需要创建和销毁,而数据库旨在是永久的,有时其寿命以几十年计算。
换句话说,我们可以轻松安装和启动 MySQL 实例是很酷的。但有几个缺陷:
-
从其他软件访问数据库
-
将数据库文件存储在容器外部以延长其使用寿命
-
自定义配置,因为数据库管理员喜欢调整设置
-
需要与用户身份验证服务一起连接到 AuthNet
在继续之前,让我们清理一下。在终端窗口中,输入以下命令:
$ docker stop mysql
mysql $ docker rm mysql
mysql
这就结束了容器的关闭和清理工作。并且,为了重申之前提到的观点,该容器中的数据库已经消失。如果该数据库包含关键信息,那么您就失去了它,没有任何机会恢复数据。
初始化 AuthNet
Docker 支持在容器之间创建虚拟桥接网络。记住,Docker 容器具有许多已安装 Linux 操作系统的功能。每个容器都可以有自己的 IP 地址(或多个)和暴露的端口。Docker 支持创建一个虚拟以太网段,称为桥接网络。这些网络仅存在于主机计算机中,默认情况下,主机计算机之外的所有设备都无法访问。
因此,Docker 桥接网络具有严格受限的访问权限。任何连接到桥接网络的 Docker 容器都可以与其他连接到该网络的容器通信。容器通过主机名找到彼此,Docker 包含一个内嵌的 DNS 服务器来设置所需的主机名。该 DNS 服务器配置为不需要域名中的点,这意味着每个容器的 DNS/主机名仅仅是容器名称,而不是像container-name.service这样的名称。使用主机名来识别容器的策略是 Docker 对服务发现的实现。
创建一个名为 authnet 的目录,作为 users 和 notes 目录的兄弟目录。我们将在该目录中工作 AuthNet。
创建一个包含以下内容的文件,buildauthnet.sh:
docker network create --driver bridge authnet
输入以下内容:
$ sh -x buildauthnet.sh
+ docker network create --driver bridge authnet
3021e2069278c2acb08d94a2d31507a43f089db1c02eecc97792414b498eb785
这将创建一个 Docker 桥接网络。
Windows 上的脚本执行
在 Windows 上执行脚本的方式不同,因为它使用 PowerShell 而不是 bash,以及许多其他考虑因素。为此,以及随后的脚本,进行以下更改。
PowerShell 脚本文件名必须以 .ps1 扩展名结尾。对于这些脚本中的大多数,这就足够了,因为脚本非常简单。要执行脚本,只需在 PowerShell 窗口中键入 .\scriptname.ps1。换句话说,在 Windows 上,刚刚显示的脚本必须命名为 buildauthnet.ps1,并且以 .\buildauthnet.ps1 的方式执行。
要执行脚本,您可能需要更改 PowerShell 执行策略:
PS C:\Users\david\chap10\authnet> Get-ExecutionPolicy
Restricted
PS C:\Users\david\chap10\authnet> Set-ExecutionPolicy Unrestricted
显然,这种更改存在安全考虑,因此完成时请将执行策略改回。
在 Windows 上,一个更简单的方法是将这些命令粘贴到 PowerShell 窗口中。
链接 Docker 容器
在 Docker 的早期,我们被告知使用 --link 选项来链接容器。使用该选项,Docker 会在 /etc/hosts 中创建条目,以便一个容器可以通过其主机名引用另一个容器。该选项还安排了链接容器之间的 TCP 端口和卷的访问。这允许创建多容器服务,使用私有 TCP 端口进行通信,而不向容器外的进程暴露任何内容。
今天,我们被告知 --link 选项是一个过时特性,而我们应该使用 bridge 网络。在本章中,我们将专注于使用 bridge 网络。
您可以按以下方式列出网络:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
3021e2069278 authnet bridge local
使用以下命令查看网络的详细信息:
$ docker network inspect authnet
... much JSON output
目前,这不会显示任何连接到 authnet 的容器。输出显示了网络名称、该网络的 IP 地址范围、默认网关以及其他有用的网络配置信息。由于没有任何东西连接到网络,让我们开始构建所需的容器。
db-userauth 容器
现在我们有了网络,我们可以开始将容器连接到该网络。然后我们将探索容器,看看它们有多私密。
创建一个包含以下内容的脚本,startdb.sh:
docker run --name db-userauth --env MYSQL_RANDOM_ROOT_PASSWORD=true \
--env MYSQL_USER=userauth --env MYSQL_PASSWORD=userauth \
--env MYSQL_DATABASE=userauth \
--volume `pwd`/my.cnf:/etc/my.cnf \
--volume `pwd`/../userauth-data:/var/lib/mysql \
--network authnet mysql/mysql-server:5.7
在 Windows 上,您需要将脚本命名为 startdb.ps1,并将文本全部放在一行中,而不是使用反斜杠扩展行。此外,挂载在 /var/lib/mysql 上的卷必须单独创建。请使用以下命令:
docker volume create db-userauth-volume
docker run --name db-userauth --env MYSQL_RANDOM_ROOT_PASSWORD=true --env MYSQL_USER=userauth --env MYSQL_PASSWORD=userauth --env MYSQL_DATABASE=userauth --volume $PSScriptRoot\my.cnf:/etc/my.cnf --volume db-userauth-volume:/var/lib/mysql --network authnet mysql/mysql-server:5.7
当运行时,容器将被命名为db-userauth。为了增加一点安全性,root密码已经被随机化。我们定义了一个名为userauth的数据库,通过名为userauth的用户访问,使用密码userauth。这并不完全安全,所以请随意选择更好的名称和密码。容器连接到authnet网络。
有两个--volume选项需要我们讨论。在 Docker 术语中,卷是容器内部的一个可以从中挂载到容器外部的实体。在这种情况下,我们正在定义一个名为userauth-data的卷,在主机文件系统中将其挂载为容器内的/var/lib/mysql。同时,我们定义一个本地的my.cnf文件,在容器内用作/etc/my.cnf。
对于 Windows 版本,我们对--volume挂载进行了两个更改。我们指定/etc/my.cnf的挂载为$PSScriptRoot\my.cnf:/etc/my.cnf,因为这是在 PowerShell 中引用本地文件的方式。
对于/var/lib/mysql,我们引用了一个单独创建的卷。该卷是通过volume create命令创建的,并且在该命令中没有机会控制卷的位置。重要的是卷必须位于容器外部,这样数据库文件才能在容器的销毁/创建周期中幸存。
综合这些设置,意味着数据库文件和配置文件位于容器外部,因此将超出特定容器的生命周期。要获取my.cnf,你将不得不运行一次容器而不使用--volume pwd/my.cnf:/etc/my.cnf选项,这样你就可以将默认的my.cnf文件复制到authnet目录中。
首先不使用该选项运行脚本一次:
$ sh startdb.sh
... much output
[Entrypoint] GENERATED ROOT PASSWORD: UMyh@q]@j4qijyj@wK4s4SkePIkq
... much output
输出与之前看到的类似,但这次会给出随机的密码:
$ docker network inspect authnet
这将告诉你db-userauth容器连接到了authnet:
$ docker exec -it db-userauth mysql -u userauth -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
... much output mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| userauth |
+--------------------+
2 rows in set (0.00 sec)
mysql> use userauth;
Database changed
mysql> show tables;
Empty set (0.00 sec)
我们看到我们的数据库已经创建,但它为空。但我们这样做是为了获取my.cnf文件:
$ docker cp db-userauth:/etc/my.cnf .
$ ls
my.cnf mysql-data startdb.sh
使用docker cp命令用于在容器内外复制文件。如果你使用过scp,语法将很熟悉。
一旦你有了my.cnf文件,你可能想要进行一大堆设置更改。首先需要做的具体更改是注释掉读取socket=/var/lib/mysql/mysql.sock的行,其次是添加读取bind-address = 0.0.0.0的行。这些更改的目的是将 MySQL 服务配置为监听 TCP 端口而不是 Unix 域套接字。这使得从容器外部与 MySQL 服务进行通信成为可能。结果将是:
# socket=/var/lib/mysql/mysql.sock
bind-address = 0.0.0.0
现在停止db-userauth服务,并删除容器,就像我们之前做的那样。编辑startdb脚本以启用将/etc/my.cnf挂载到容器中的行,然后重新启动容器:
$ docker stop db-userauth
db-userauth
$ docker rm db-userauth
db-userauth $ sh ./startdb.sh
[Entrypoint] MySQL Docker Image 5.7.21-1.1.4
[Entrypoint] Starting MySQL 5.7.21-1.1.4
现在,如果我们检查authnet网络,我们会看到以下内容:
$ docker network inspect authnet
"Name": "authnet",
...
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
...
"Containers": {
"Name": "db-userauth",
"MacAddress": "02:42:ac:12:00:02",
"IPv4Address": "172.18.0.2/16",
...
换句话说,authnet网络拥有网络号172.18.0.0/16,而db-userauth容器被分配了172.18.0.2。这种详细程度很少很重要,但第一次设置时仔细检查设置是有用的,这样我们就能理解我们正在处理的内容:
# cat /etc/resolv.conf
search attlocal.net
nameserver 127.0.0.11
options ndots:0
如我们之前所述,在 Docker 桥接网络设置中运行着一个 DNS 服务器,并且域名解析被配置为使用nodots。这样,Docker 容器的名称就是该容器的 DNS 主机名:
# mysql -h db-userauth -u userauth -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 33
Server version: 5.7.21 MySQL Community Server (GPL)
使用容器名称作为主机名来访问 MySQL 服务器。
认证服务的 Dockerfile
在users目录下,创建一个名为Dockerfile的文件,包含以下内容:
FROM node:10
ENV DEBUG="users:*"
ENV PORT="3333"
ENV SEQUELIZE_CONNECT="sequelize-docker-mysql.yaml"
ENV REST_LISTEN="0.0.0.0"
RUN mkdir -p /userauth
COPY package.json sequelize-docker-mysql.yaml *.mjs *.js /userauth/
WORKDIR /userauth
RUN apt-get update -y \
&& apt-get -y install curl python build-essential git ca-certificates \
&& npm install --unsafe-perm
EXPOSE 3333
CMD npm run docker
Dockerfile 描述了在服务器上安装应用程序的过程。请参阅docs.docker.com/engine/reference/builder/以获取文档。它们记录了 Docker 容器镜像中各个组件的组装,以及 Dockerfile 中的指令用于构建 Docker 镜像。
FROM命令指定了一个预存在的镜像,从这个镜像中可以派生出一个特定的镜像。我们之前讨论过这个问题;您可以从现有的镜像开始构建 Docker 容器。我们正在使用的官方 Node.js Docker 镜像(hub.docker.com/_/node/)是从debian:jessie派生出来的。因此,容器内可用的命令是 Debian 提供的,我们使用apt-get安装更多软件包。我们使用 Node.js 10,因为它支持 ES6 模块和我们已经使用过的其他功能。
ENV命令定义环境变量。在这种情况下,我们使用的是用户身份验证服务中定义的相同环境变量,但我们有一个新的REST_LISTEN变量。我们稍后会看看这个变量。
RUN命令是我们运行构建容器所需的 shell 命令的地方。首先,我们需要创建一个/userauth目录,该目录将包含服务源代码。COPY命令将文件复制到该目录。然后我们需要运行一个npm install,以便我们可以运行服务。但首先我们使用WORKDIR命令将当前工作目录移动到/userauth,这样npm install就可以在正确的位置运行。我们还安装必要的 Debian 软件包,以便可以安装任何原生代码 Node.js 软件包。
建议您始终将apt-get update与apt-get install组合在同一条命令行中,如下所示,这是因为 Docker 构建缓存。当重新构建镜像时,Docker 从第一行更改的命令开始。通过将这两个命令放在一起,您可以确保每次更改要安装的包列表时都会执行apt-get update。关于完整讨论,请参阅docs.docker.com/develop/develop-images/dockerfile_best-practices/中的文档。
在此命令的末尾是npm install --unsafe-perm。这里的问题是这些命令是以root身份运行的。通常,当npm以root身份运行时,它会将其用户 ID 更改为非特权用户。然而,这可能会导致失败,而--unsafe-perm选项防止更改用户 ID。
EXPOSE命令通知 Docker 容器监听命名的 TCP 端口。这并不意味着端口会超出容器。
最后,CMD命令记录了容器执行时要启动的过程。RUN命令在构建容器时执行,而CMD则说明了容器启动时要执行的内容。
我们本可以在容器中安装 PM2,然后使用 PM2 命令启动服务。但 Docker 能够实现相同的功能,因为它支持在服务进程崩溃时自动重启容器。我们稍后会看到如何做到这一点。
为 Docker 配置身份验证服务
我们为SEQUELIZE_CONNECT使用不同的文件。创建一个名为users/sequelize-docker-mysql.yaml的新文件,包含以下内容:
dbname: userauth
username: userauth
password: userauth
params:
host: db-userauth
port: 3306
dialect: mysql
不同之处在于,我们不是使用localhost作为数据库主机,而是使用db-userauth。之前,我们探索了db-userauth容器并确定这是容器的主机名。通过在这个文件中使用db-userauth,身份验证服务将使用容器中的数据库。
现在,我们需要处理名为REST_LISTEN的环境变量。之前,身份验证服务器只监听http://localhost:3333。我们这样做是为了安全目的,即限制哪些进程可以连接到服务。在 Docker 中,我们需要从容器外部连接到这个服务,以便其他容器可以连接到这个服务。因此,它必须监听来自 localhost 外部的连接。
在users-server.mjs中,我们需要进行以下更改:
server.listen(process.env.PORT,
process.env.REST_LISTEN ? process.env.REST_LISTEN : "localhost",
() => { log(server.name +' listening at '+ server.url); });
即,如果存在REST_LISTEN变量,REST 服务器会告诉它监听其指示的内容,否则服务将监听localhost。通过 Dockerfile 中的环境变量,身份验证服务将监听整个世界(0.0.0.0)。我们是鲁莽行事,放弃了我们作为受托人保管所有这些用户识别信息的神圣职责吗?不。请耐心等待。我们将简要描述如何将此服务和其数据库连接到AuthNet,并防止其他任何进程访问AuthNet。
构建和运行身份验证服务的 Docker 容器
在users/package.json中,在scripts部分添加以下行:
"docker": "node --experimental-modules ./user-server",
"docker-build": "docker build -t node-web-development/userauth ."
之前,我们将配置环境变量放入了 package.json。在这种情况下,配置环境变量在 Dockerfile 中。这意味着我们需要一种方法来运行服务器,除了 Dockerfile 中的环境变量外,不使用任何其他环境变量。有了这个 scripts 条目,我们可以执行 npm run docker,然后 Dockerfile 中的环境变量将提供所有配置。
我们可以按照以下方式构建认证服务:
$ npm run docker-build
> user-auth-server@0.0.1 docker-build /Users/david/chap10/users
> docker build -t node-web-development/userauth .
Sending build context to Docker daemon 33.8MB
Step 1/11 : FROM node:9.5
---> a696309517c6
Step 2/11 : ENV DEBUG="users:*"
---> Using cache
---> f8cc103432e8
Step 3/11 : ENV PORT="3333"
---> Using cache
---> 39b24b8b554e
... more output
docker build 命令从 Dockerfile 构建一个容器。正如我们之前所说的,这个过程从 FROM 命令中定义的镜像开始。然后构建逐步进行,输出会逐字逐句地显示执行过程中的每一步。
然后创建一个脚本,authnet/startserver.sh,或者在 Windows 上命名为 startserver.ps1,包含以下命令:
docker run -it --name userauth --net=authnet node-web-development/userauth
这启动了新构建的容器,给它命名为 userauth,并将其附加到 authnet:
$ sh -x startserver.sh
+ docker run -it --name userauth --net=authnet node-web-development/userauth
> user-auth-server@0.0.1 docker /userauth
> node --experimental-modules ./user-server
(node:17) ExperimentalWarning: The ESM module loader is experimental.
users:service User-Auth-Service listening at http://0.0.0.0:3333 +0ms
这将启动用户认证服务。在 Windows 上,通过 .\startserver.ps1 启动它。你应该记得它是一个 REST 服务,因此通过 users-add.js 和其他脚本运行它。但是,由于我们没有从服务中公开端口,我们必须在容器内部运行这些脚本。
我们可以通过两种方式确定容器是否公开了一个公共端口。最简单的方法是运行 docker ps -a 并查看容器列表详情。有一个标记为 PORTS 的列,对于 userauth,我们看到 3333/tcp。这是 Dockerfile 中 EXPOSE 命令的副作用。如果该端口被公开,它将出现在 PORTS 列表中,形式为 0.0.0.0:3333->3333/tcp。记住 userauth 容器和 authnet 整体的目标是它不会公开访问,因为存在安全顾虑。
探索 Authnet
让我们探索我们刚刚创建的内容:
$ docker network inspect authnet
这会打印出一个大型的 JSON 对象,描述了网络及其附加的容器,这是我们之前看过的。如果一切顺利,我们将看到现在有两个容器附加到 authnet,而之前只有一个。
让我们进入 userauth 容器并四处看看:
$ docker exec -it userauth bash
root@a29d833287bf:/userauth# ls
node_modules user-server.mjs users-list.js
package-lock.json users-add.js users-sequelize.mjs
package.json users-delete.js
sequelize-docker-mysql.yaml users-find.js
/userauth 目录位于容器内,正好是使用 COPY 命令放入容器的文件,加上 node_modules: 中安装的文件。
root@a29d833287bf:/userauth# PORT=3333 node users-list.js
List []
root@a29d833287bf:/userauth# PORT=3333 node users-add.js
Created { id: 1, username: 'me', password: 'w0rd', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr',
middleName: '', emails: '[]', photos: '[]',
updatedAt: '2018-02-05T01:54:53.320Z', createdAt: '2018-02-
05T01:54:53.320Z' }
root@a29d833287bf:/userauth# PORT=3333 node users-list.js
List [ { id: 'me', username: 'me', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr', middleName: '',
emails: '[]', photos: '[]' } ]
我们向认证服务添加用户的测试是成功的:
root@a29d833287bf:/userauth# ps -eafw
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 01:52 pts/0 00:00:00 /bin/sh -c npm run docker
root 9 1 0 01:52 pts/0 00:00:00 npm
root 19 9 0 01:52 pts/0 00:00:00 sh -c node --experimental-modules ./user-server
root 20 19 0 01:52 pts/0 00:00:01 node --experimental-modules ./user-server
root 30 0 0 01:54 pts/1 00:00:00 bash
root 70 30 0 01:57 pts/1 00:00:00 ps -eafw
root@a29d833287bf:/userauth# ping db-userauth
PING db-userauth (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: icmp_seq=0 ttl=64 time=0.105 ms
64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.077 ms
^C--- db-userauth ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.077/0.091/0.105/0.000 ms
root@a29d833287bf:/userauth# ping userauth
PING userauth (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.132 ms
64 bytes from 172.18.0.3: icmp_seq=1 ttl=64 time=0.095 ms
^C--- userauth ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
进程列表是值得研究的。进程 PID 1 是 Dockerfile 中的 npm run docker 命令。进程从那里继续到运行实际服务器的 node 进程。
ping 命令可以证明两个容器作为与容器名称匹配的主机名是可用的。
然后,你可以登录到 db-userauth 容器并检查数据库:
$ docker exec -it db-userauth bash
bash-4.2# mysql -u userauth -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
... mysql> use userauth
Database changed
mysql> show tables;
+--------------------+
| Tables_in_userauth |
+--------------------+
| Users |
+--------------------+
1 row in set (0.00 sec)
mysql> select * from Users;
+----+----------+----------+----------+---------------+-----------+--...
| id | username | password | provider | familyName | givenName | ...
+----+----------+----------+----------+---------------+-----------+--...
| 1 | me | w0rd | local | Einarrsdottir | Ashildr | ...
+----+----------+----------+----------+---------------+-----------+--...
1 row in set (0.00 sec)
我们已经成功地将用户认证服务 Docker 化为两个容器,db-userauth 和 userauth。我们检查了运行中的容器内部,发现了一些有趣的东西。但是,我们的用户需要运行出色的笔记应用,我们不能满足于现状。
为笔记应用创建 FrontNet
我们已经将系统后端部分设置在一个 Docker 容器中,以及一个用于连接后端容器的私有网桥网络。我们现在需要设置另一个私有网桥网络,frontnet,并将系统另一半连接到该网络。
创建一个名为 frontnet 的目录,这是我们将在其中开发构建和运行该网络的工具的地方。在该目录中,创建一个名为 buildfrontnet.sh 的文件,或者在 Windows 上,创建一个名为 buildfrontnet.ps1 的文件,包含以下内容:
docker network create --driver bridge frontnet
让我们继续创建 frontnet 网桥网络:
$ sh -x buildfrontnet.sh
+ docker network create --driver bridge frontnet
f3df227d4bfff57bc7aed1e096a2ad16f6cebce4938315a54d9386a42d1ae3ed
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
3021e2069278 authnet bridge local
f3df227d4bff frontnet bridge local
我们将从这里开始,类似于 authnet 的创建方式。然而,我们可以更快地完成工作,因为我们已经掌握了基础知识。
笔记应用的 MySQL 容器
从 authnet 目录中,将 my.cnf 和 startdb.sh 文件复制到 frontnet 目录。
my.cnf 文件可能可以不修改直接使用,但我们需要对 startdb.sh 文件进行一些修改:
docker run --name db-notes --env MYSQL_RANDOM_ROOT_PASSWORD=true \
--env MYSQL_USER=notes --env MYSQL_PASSWORD=notes12345 \
--env MYSQL_DATABASE=notes \
--volume `pwd`/my.cnf:/etc/my.cnf \
--volume `pwd`/../notes-data:/var/lib/mysql \
--network frontnet mysql/mysql-server:5.7
在 Windows 上,将包含以下内容的文件命名为 startdb.ps1:
docker volume create notes-data-volume
docker run --name db-notes --env MYSQL_RANDOM_ROOT_PASSWORD=true --env MYSQL_USER=notes --env MYSQL_PASSWORD=notes12345 --env MYSQL_DATABASE=notes --volume $PSScriptRoot\my.cnf:/etc/my.cnf --volume notes-data-volume:/var/lib/mysql --network frontnet mysql/mysql-server:5.7
这些更改只是将 userauth 替换为 notes 的简单替换,然后运行它:
$ mkdir ../notes-data
$ sh -x startdb.sh
+ pwd
+ pwd
+ docker run --name db-notes --env MYSQL_RANDOM_ROOT_PASSWORD=true --env MYSQL_USER=notes --env MYSQL_PASSWORD=notes12345 --env MYSQL_DATABASE=notes --volume /home/david/nodewebdev/node-web-development-code-4th-edition/chap10/frontnet/my.cnf:/etc/my.cnf --volume /home/david/nodewebdev/node-web-development-code-4th-edition/chap10/frontnet/../notes-data:/var/lib/mysql --network frontnet mysql/mysql-server:5.7
[Entrypoint] MySQL Docker Image 5.7.21-1.1.4
[Entrypoint] Initializing database
[Entrypoint] Database initialized
[Entrypoint] GENERATED ROOT PASSWORD: 3kZ@q4hBItYGYj3Mes!AdiP83Nol
[Entrypoint] ignoring /docker-entrypoint-initdb.d/*
[Entrypoint] Server shut down
[Entrypoint] MySQL init process done. Ready for start up.
[Entrypoint] Starting MySQL 5.7.21-1.1.4
对于 Windows,只需运行 .\startdb.ps1。
此数据库将在 frontnet 上的 db-notes 域名下可用。因为它连接到 frontnet,所以它不会被连接到 authnet 的容器访问。
$ docker exec -it userauth bash
root@0a2009334b79:/userauth# ping db-notes
ping: unknown host
由于 db-notes 在不同的网络段上,我们已经实现了隔离。
Docker 化笔记应用
在 notes 目录中,创建一个名为 Dockerfile 的文件,包含以下内容:
FROM node:10
ENV DEBUG="notes:*,messages:*"
ENV SEQUELIZE_CONNECT="models/sequelize-docker-mysql.yaml"
ENV NOTES_MODEL="sequelize"
ENV USER_SERVICE_URL="http://userauth:3333"
ENV PORT="3000"
ENV NOTES_SESSIONS_DIR="/sessions"
# ENV TWITTER_CONSUMER_KEY="..."
# ENV TWITTER_CONSUMER_SECRET="..."
# Use this line when the Twitter Callback URL
# has to be other than localhost:3000
# ENV TWITTER_CALLBACK_HOST=http://45.55.37.74:3000
RUN mkdir -p /notesapp /notesapp/minty /notesapp/partials /notesapp/public /notesapp/routes /notesapp/theme /notesapp/views
COPY minty/ /notesapp/minty/
COPY models/*.mjs models/sequelize-docker-mysql.yaml /notesapp/models/
COPY partials/ /notesapp/partials/
COPY public/ /notesapp/public/
COPY routes/ /notesapp/routes/
COPY theme/ /notesapp/theme/
COPY views/ /notesapp/views/
COPY app.mjs package.json /notesapp/
WORKDIR /notesapp
RUN apt-get update -y \
&& apt-get -y install curl python build-essential git ca-certificates \
&& npm install --unsafe-perm
# Uncomment to build the theme directory
# WORKDIR /notesapp/theme
# RUN npm run download && npm run build && npm run clean
WORKDIR /notesapp
VOLUME /sessions
EXPOSE 3000
CMD node --experimental-modules ./app
这与用于认证服务的 Dockerfile 类似。我们使用 notes/package.json 中的环境变量,以及一个新的变量,这里还涉及一些新技巧,所以让我们来看看。
最明显的变化是 COPY 命令的数量。笔记应用涉及很多子目录和文件,必须安装,因此更为复杂。我们首先创建笔记应用部署树的最顶层目录,然后逐个将每个子目录复制到容器文件系统中的相应子目录。
在 COPY 命令中,目标目录的尾部斜杠很重要。为什么?因为文档说明尾部斜杠很重要。
最大的问题是:为什么使用多个 COPY 命令,例如这个?这本来是非常简单的:
COPY . /notesapp
但是,避免将 node_modules 目录复制到容器中是很重要的。容器中的 node_modules 必须在容器内构建,因为容器操作系统几乎肯定与宿主操作系统不同。任何原生代码模块都必须为正确的操作系统构建。这个限制导致了将特定文件简洁地复制到目标位置的疑问。
我们已经开发了一个过程来构建 Bootstrap 4 主题,这个主题我们在第六章 实现移动优先范式 中进行了开发。如果你有一个 Bootstrap 4 主题需要构建,只需在 Dockerfile 中取消注释相应的行。这些行将工作目录移动到 /notesapp/theme,然后运行脚本构建主题。在主题构建完成后,theme/package.json 中需要一个新的脚本,用于删除 theme/node_modules 目录:
"scripts": {
...
"clean": "rm -rf bootstrap-4.0.0/node_modules"
...
}
我们还有一个新的 SEQUELIZE_CONNECT 文件。创建 models/sequelize-docker-mysql.yaml 包含以下内容:
dbname: notes
username: notes
password: notes12345
params:
host: db-notes
port: 3306
dialect: mysql
这将通过使用命名的数据库、用户名和密码,访问 db-notes 域名下的数据库服务器。
注意到 USER_SERVICE_URL 变量不再访问 localhost 上的认证服务,而是访问 userauth。userauth 域名目前仅由 AuthNet 上的 DNS 服务器宣传,但 Notes 服务在 FrontNet 上。这意味着我们必须将 userauth 容器连接到 FrontNet 交换网络,以便在那里也能知道其名称。我们将在稍后讨论这个问题。
在 第八章多用户认证的微服务方式 中,我们讨论了保护 Twitter 提供的 API 密钥的必要性。
我们不想在源代码中提交密钥,但它们必须放在某个地方。在 Dockerfile 中为指定 TWITTER_CONSUMER_KEY 和 TWITTER_CONSUMER_SECRET 提供了占位符。
TWITTER_CALLBACK_HOST 的值需要反映 Notes 部署的位置。目前,它仍然在你的笔记本电脑上,但到本章结束时,它将被部署到服务器上,那时它将需要服务器的 IP 地址或域名。
新的变量是 NOTES_SESSIONS_DIR 和相应的 VOLUME 声明。如果我们运行多个 Notes 实例,它们可以通过共享这个卷来共享会话数据。
支持 NOTES_SESSIONS_DIR 变量需要在 app.mjs 中进行一个更改:
const sessionStore = new FileStore({
path: process.env.NOTES_SESSIONS_DIR ?
process.env.NOTES_SESSIONS_DIR : "sessions"
});
我们可以使用环境变量来定义会话数据存储的位置,而不是使用硬编码的目录名。或者,有 sessionStore 实现适用于各种服务器,如 REDIS,它可以在不同主机系统上的容器之间共享会话数据。
在 notes/package.json 中,添加以下脚本:
"scripts": {
...
"docker": "node --experimental-modules ./app",
"docker-build": "docker build -t node-web-development/notes ."
...
}
至于认证服务器,这让我们可以构建容器,然后,在容器内,我们可以运行服务。
现在我们可以构建容器镜像:
$ npm run docker-build
> notes@0.0.0 docker-build /Users/david/chap10/notes
> docker build -t node-web-development/notes .
Sending build context to Docker daemon 76.27MB
Step 1/22 : FROM node:9.5
---> a696309517c6
Step 2/22 : ENV DEBUG="notes:*,messages:*"
---> Using cache
---> 8628ecad9fa4
接下来,在frontnet目录中,创建一个名为startserver.sh的文件,或者在 Windows 上,startserver.ps1:
docker run -it --name notes --net=frontnet -p 3000:3000 node-web-development/notes
与身份验证服务不同,Notes 应用程序容器必须将端口导出到公共网络。否则,公众将永远无法享受我们正在构建的这项美好创造。-p选项是我们指示 Docker 公开端口的命令。
第一个数字是从容器发布的 TCP 端口号,第二个数字是容器内的 TCP 端口号。一般来说,此选项将容器内的端口号映射到公共可访问的端口号。
然后按照以下方式运行它:
$ sh -x startserver.sh
+ docker run -it --name notes --net=frontnet -p 3000:3000 node-web-development/notes
(node:6) ExperimentalWarning: The ESM module loader is experimental.
notes:debug-INDEX Listening on port 3000 +0ms
在 Windows 上,运行.\startserver.ps1。
到目前为止,我们可以将我们的浏览器连接到http://localhost:3000并开始使用 Notes 应用程序。但我们会很快遇到一个问题:

用户体验团队会对这个丑陋的错误消息大喊大叫,所以把它放在你的待办事项中生成一个更漂亮的错误屏幕。例如,一群鸟把鲸鱼从海洋中拉出来是受欢迎的。
这个错误意味着 Notes 无法访问名为userauth的主机上的任何内容。该主机确实存在,因为容器正在运行,但它不在frontnet上,并且从notes容器无法访问。也就是说:
$ docker exec -it notes bash
root@125a196c3fd5:/notesapp# ping userauth
ping: unknown host
root@125a196c3fd5:/notesapp# ping db-notes
PING db-notes (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: icmp_seq=0 ttl=64 time=0.136 ms
^C--- db-notes ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.136/0.136/0.136/0.000 ms
root@125a196c3fd5:/notesapp#
如果你检查 FrontNet 和 AuthNet,你会看到附加到每个容器的容器不重叠:
$ docker network inspect frontnet
$ docker network inspect authnet
在本章开头的架构图中,我们展示了notes和userauth容器之间的连接。这个连接是必需的,以便notes可以对其用户进行身份验证。但这个连接尚不存在。
不幸的是,简单地更改startserver.sh(startserver.ps1)不起作用:
docker run -it --name notes --net=authnet --net=frontnet -p 3000:3000 node-web-development/notes
虽然在启动容器时指定多个--net选项在概念上很简单,但 Docker 不支持这一点。它静默地接受显示的命令,但只连接容器到最后一个提到的网络。相反,Docker 要求你采取第二步将容器连接到第二个网络:
$ docker network connect authnet notes
在没有其他更改的情况下,Notes 应用程序现在将允许你登录并开始添加和编辑笔记。
有一个明显的架构问题正盯着我们。我们是将userauth服务连接到frontnet,还是将notes服务连接到authnet?为了验证任一方向都能解决问题,运行以下命令:
$ docker network disconnect authnet notes
$ docker network connect frontnet userauth
第一次,我们将notes连接到authnet,然后将其从authnet断开连接,然后将userauth连接到frontnet。这意味着我们尝试了两种组合,并且正如预期的那样,在两种情况下notes和userauth都能进行通信。
这是一个针对安全专家的问题,因为考虑的是任何入侵者可用的攻击向量。假设 Notes 存在一个安全漏洞,允许入侵者获取访问权限。我们如何限制通过该漏洞可访问的内容?
主要观察结果是,通过将notes连接到authnet,notes不仅能够访问userauth,还能访问db-userauth:
$ docker network disconnect frontnet userauth
$ docker network connect authnet notes
$ docker exec -it notes bash
root@7fce818e9a4d:/notesapp# ping userauth
PING userauth (172.18.0.3): 56 data bytes
64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.103 ms
^C--- userauth ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.103/0.103/0.103/0.000 ms
root@7fce818e9a4d:/notesapp# ping db-userauth
PING db-userauth (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: icmp_seq=0 ttl=64 time=0.201 ms
^C--- db-userauth ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max/stddev = 0.201/0.201/0.201/0.000 ms
root@7fce818e9a4d:/notesapp#
这个序列重新连接了notes到authnet,并展示了访问userauth和db-userauth容器的能力。因此,一个成功的入侵者可以访问db-userauth数据库,这是我们想要防止的结果。我们最初的图示显示了notes和db-userauth之间没有这样的连接。
由于我们使用 Docker 的目标是限制攻击向量,我们在两个容器/网络连接设置之间有一个清晰的区分。将userauth附加到frontnet限制了可以访问db-userauth的容器数量。入侵者必须首先入侵notes,然后入侵userauth才能访问用户信息数据库。除非,我们的业余安全审计尝试有缺陷。
控制 MySQL 数据卷的位置
db-userauth和db-notesDockerfile 包含VOLUME /var/lib/mysql,当我们启动容器时,我们给出了--volume选项,为该容器目录分配了一个主机目录:
docker run --name db-notes \
...
--volume `pwd`/../notes-data:/var/lib/mysql \
...
我们可以很容易地看到这连接了一个主机目录,因此它出现在容器中的那个位置。只需使用ls等工具检查主机目录,就可以看到在该目录中创建了与 MySQL 数据库对应的文件。
VOLUME指令指示 Docker 在容器外部创建一个目录,并将该目录映射到容器内部,在指定的路径上挂载。VOLUME指令本身并不控制主机计算机上的目录名称。如果没有给出--volume选项,Docker 仍然会安排将目录内容保留在容器外部。这很有用,至少数据可以在容器外部访问,但你没有控制位置。
如果我们不使用--volume选项为/var/lib/mysql重新启动db-notes容器,我们可以检查容器以发现 Docker 将卷放在哪里:
$ docker inspect --format '{{json .Mounts}}' db-notes
[{"Type":"bind",
"Source":"/Users/david/chap10/frontnet/my.cnf","Destination":"/etc/my.cnf",
"Mode":"","RW":true,"Propagation":"rprivate"},{"Type":"volume","Name":"39f9a80b49e3ecdebc7789de7b7dd2366c400ee7fbfedd6e4df18f7e60bad409",
"Source":"/var/lib/docker/volumes/39f9a80b49e3ecdebc7789de7b7dd2366c400ee7fbfedd6e4df18f7e60bad409/_data","Destination":"/var/lib/mysql",
"Driver":"local","Mode":"","RW":true,"Propagation":""}]
这不是一个用户友好的路径名,但你可以在该目录中窥探,并看到 MySQL 数据库确实存储在那里。使用用户友好的路径名来使用卷的最简单方法是使用我们之前显示的--volume选项。
我们拥有的另一个优点是轻松切换数据库。例如,我们可以使用预先准备好的测试数据库测试 Notes,这些数据库充满了用斯瓦希里语(notes-data-swahili)、罗马尼亚语(notes-data-romanian)、德语(notes-data-german)和英语(notes-data-english)编写的笔记。每个测试数据库都可以存储在命名的目录中,针对特定语言的测试就像运行带有不同--volume选项的 notes 容器一样简单。
在任何情况下,如果你使用--volume选项重新启动notes容器,你可以检查容器并看到目录已挂载到你指定的目录:
$ docker inspect --format '{{json .Mounts}}' db-notes
[{"Type":"bind",
"Source":"/Users/david/chap10/frontnet/my.cnf","Destination":"/etc/my.cnf",
"Mode":"","RW":true,"Propagation":"rprivate"},
{"Type":"bind",
"Source":"/Users/david/chap10/notes-data","Destination":"/var/lib/mysql",
"Mode":"","RW":true,"Propagation":"rprivate"}]
使用 --volume 选项,我们已经控制了容器目录对应的宿主目录的位置。
需要注意的最后一件事是,控制这些目录的位置使得备份和对此数据进行其他管理操作变得更加容易。
Docker 部署后台服务
使用我们迄今为止编写的脚本,Docker 容器在前台运行。这使得调试服务变得更容易,因为你可以看到错误。对于生产部署,我们需要 Docker 容器从终端分离出来,并确保它将自动重启。这两个属性很容易实现。
简单地更改这个模式:
$ docker run -it ...
到这个模式:
$ docker run --detach --restart always ...
-it 选项是导致 Docker 容器在前台运行的原因。使用这些选项会导致 Docker 容器从您的终端分离出来运行,如果服务进程死亡,容器将自动重启。
使用 Docker Compose 部署到云端
这很酷,因为我们能够创建我们所创建的软件服务的封装实例。但承诺是使用 Docker 化的应用程序在云服务上进行部署。换句话说,我们需要将所有这些学习应用到在公共互联网服务器上部署 Notes,并具有相当高安全性的任务中。
我们已经证明,使用 Docker,Notes 可以分解成四个具有高度隔离性的容器,彼此之间以及与外界之间。
另一个明显的问题是:我们上一节中的流程部分是手工的,部分是自动化的。我们创建了脚本来启动系统的各个部分,根据十二要素应用程序模型,这是一个好的实践。但我们没有自动化整个流程来启动 Notes 和认证服务。这个解决方案也无法扩展到一台机器之外。
让我们先从最后一个问题开始——可扩展性。在 Docker 生态系统中,有几种 Docker 管理器服务可用。管理器会自动在一组机器上部署和管理 Docker 容器。Docker 管理器的例子包括 Docker Swarm(内置在 Docker CLI 中)、Kubernetes、CoreOS Fleet 和 Apache Mesos。这些是能够根据需要自动增加/减少资源、将容器从一个主机移动到另一个主机等强大系统。我们提到这些系统是为了在你需求增长时进行进一步学习。
Docker compose (docs.docker.com/compose/overview/) 将解决我们已识别的其他问题。它允许我们轻松地定义和运行多个 Docker 容器作为一个完整的应用程序。它使用 YAML 文件 docker-compose.yml 来描述容器、它们的依赖关系、虚拟网络和卷。虽然我们将使用它来描述部署到单个主机机器上,但 Docker compose 可以用于多机部署,特别是当与 Docker Swarm 结合使用时。理解 Docker compose 将为理解/使用其他工具提供基础,例如 Swarm 或 Kubernetes。
Docker machine (docs.docker.com/machine/overview/) 是一个工具,用于在虚拟主机上安装 Docker Engine,无论是本地还是远程,以及在这些主机上管理 Docker 容器。我们将使用它来在云托管服务上配置服务器,并将容器推送到该服务器。它也可以用于在 VirtualBox 实例内配置笔记本电脑上的虚拟主机。
在继续之前,请确保 Docker compose 和 Docker machine 已安装。如果您已安装 Docker for Windows 或 Docker for Mac,它们将与所有其他内容一起安装。在 Linux 上,您必须按照之前提供的链接中的说明单独安装它们。
Docker compose 文件
让我们先创建一个名为 compose 的目录,作为 users 和 notes 目录的兄弟目录。在那个目录中,创建一个名为 docker-compose.yml 的文件:
version: '3'
services:
db-userauth:
image: "mysql/mysql-server:5.7"
container_name: db-userauth
command: [ "mysqld", "--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
"--bind-address=0.0.0.0" ]
expose:
- "3306"
networks:
- authnet
volumes:
- db-userauth-data:/var/lib/mysql
- ../authnet/my.cnf:/etc/my.cnf
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_USER: userauth
MYSQL_PASSWORD: userauth
MYSQL_DATABASE: userauth
restart: always
userauth:
build: ../users
container_name: userauth
depends_on:
- db-userauth
networks:
- authnet
- frontnet
restart: always
db-notes:
image: "mysql/mysql-server:5.7"
container_name: db-notes
command: [ "mysqld", "--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
"--bind-address=0.0.0.0" ]
expose:
- "3306"
networks:
- frontnet
volumes:
- db-notes-data:/var/lib/mysql
- ../frontnet/my.cnf:/etc/my.cnf
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_USER: notes
MYSQL_PASSWORD: notes12345
MYSQL_DATABASE: notes
restart: always
notes:
build: ../notes
container_name: notes
restart: always
depends_on:
- db-notes
networks:
- frontnet
ports:
- "3000:3000"
restart: always
networks:
frontnet:
driver: bridge
authnet:
driver: bridge
volumes:
db-userauth-data:
db-notes-data:
这就是对整个笔记部署的描述。它处于相当高的抽象层次,大致相当于我们迄今为止使用的命令行工具上的选项。更详细的信息位于 Dockerfile 中,这些 Dockerfile 由 compose 文件引用。
version 行表示这是一个版本 3 的 compose 文件。版本号由 docker-compose 命令检查,以便正确解释其内容。完整的文档值得一读,请参阅docs.docker.com/compose/compose-file/。
在这里使用了三个主要部分:服务、卷和网络。服务部分描述了正在使用的容器,网络部分描述了网络,卷部分描述了卷。每个部分的内容都与我们在之前运行的命令的意图/目的相匹配。我们已经处理过的所有信息都在这里,只是重新排列了一下。
有两个数据库容器,db-userauth 和 db-notes。它们都使用 image 标签引用 Dockerhub 上的镜像。对于数据库,我们没有创建 Dockerfile,而是直接从 Dockerhub 镜像构建。在 compose 文件中也是这样做的。
对于userauth和notes容器,我们创建了一个 Dockerfile。包含该文件的目录通过build标签进行引用。要构建容器,docker-compose会在指定的目录中查找名为Dockerfile的文件。build标签有更多选项,这些选项在官方文档中有讨论。
container_name属性等同于--name属性,并指定了容器的用户友好名称。我们必须指定容器名称,以便指定容器主机名,以便进行 Docker 风格的发现。
command标签覆盖了 Dockerfile 中的CMD标签。我们为两个数据库容器指定了此标签,因此我们可以指示 MySQL 绑定到 IP 地址0.0.0.0。尽管我们没有为数据库容器创建 Dockerfile,但 MySQL 维护者创建了一个 Dockerfile。
networks属性列出了此容器必须连接的网络,并且与--net参数完全等价。尽管docker命令不支持多个--net选项,但我们可以将多个网络列在compose文件中。在这种情况下,网络是桥接网络。正如我们之前所做的那样,网络本身必须单独创建,在compose文件中,这是在*networks*部分完成的。
我们系统中的每个网络都是一个桥接网络。这一事实在compose文件中有描述。
expose属性声明了从容器中暴露哪些端口,并且等同于EXPOSE标签。然而,暴露的端口不会发布到主机机器外部。ports属性声明了要发布的端口。在端口声明中,我们有两个端口号:第一个是发布的端口号,第二个是容器内的端口号。这正好等同于之前使用的-p选项。
notes容器有几个环境变量,例如TWITTER_CONSUMER_KEY和TWITTER_CONSUMER_SECRET,你可能更愿意将它们存储在这个文件中,而不是在 Dockerfile 中。
depends_on属性让我们可以控制启动顺序。依赖于另一个容器的容器将等待被依赖的容器启动后才开始。
volumes属性描述了容器目录到host目录的映射。在这种情况下,我们定义了两个卷名,db-userauth-data和db-notes-data,然后用于卷映射。
要探索卷,请从以下命令开始:
$ docker volume ls
DRIVER VOLUME NAME
...
local compose_db-notes-data
local compose_db-userauth-data
...
卷名与compose文件中的相同,但前面加上compose*_*。
你可以使用docker命令行来检查卷位置:
$ docker volume inspect compose_db-notes-data
$ docker volume inspect compose_db-userauth-data
如果更合适的话,你可以在compose文件中指定一个路径名:
db-auth:
..
volumes:
# - db-userauth-data:/var/lib/mysql
- ../userauth-data:/var/lib/mysql
db-notes:
..
volumes:
# - db-notes-data:/var/lib/mysql
- ../notes-data:/var/lib/mysql
这是我们之前所做的相同配置。它使用userauth-data和notes-data目录作为它们各自数据库容器的 MySQL 数据文件。
environment标签描述了容器将接收的环境变量。与之前一样,应使用环境变量来注入配置数据。
restart属性控制容器死亡时或何时发生的情况。当容器启动时,它会运行CMD指令中命名的程序,当该程序退出时,容器也会退出。但如果该程序旨在永远运行,Docker 是否应该知道它应该重启进程?我们可以使用后台进程管理器,如 Supervisord 或 PM2。但,我们也可以使用 Docker 的restart选项。
restart属性可以取以下四个值之一:
-
no– 不重启 -
on-failure:count– 重启最多N次 -
always– 总是重启 -
unless-stopped– 如果容器未被明确停止,则启动容器
使用 Docker Compose 运行笔记应用
在 Windows 上,我们可以直接运行本节中的命令。
在将此部署到服务器之前,让我们在笔记本电脑上使用docker-compose运行它:
$ docker stop db-notes userauth db-auth notesapp
db-notes
userauth
db-auth
notesapp
$ docker rm db-notes userauth db-auth notesapp
db-notes
userauth
db-auth
notesapp
我们首先需要停止并删除现有的容器。因为 compose 文件想要启动与我们之前构建的具有相同名称的容器,所以我们还需要删除现有的容器:
$ docker-compose build
Building db-auth
.. lots of output
$ docker-compose up
Creating db-auth
Recreating compose_db-notes_1
Recreating compose_userauth_1
Recreating compose_notesapp_1
Attaching to db-auth, db-notes, userauth, notesapp
完成这些后,我们可以构建容器,使用docker-compose build,然后启动它们运行,使用docker-compose up。
第一次测试是在userauth中执行 shell 以运行我们的用户数据库脚本:
$ docker exec -it userauth bash
root@9972adbbdbb3:/userauth# PORT=3333 node users-add.js
Created { id: 2,
username: 'me', password: 'w0rd', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr', middleName: '',
emails: '[]', photos: '[]',
updatedAt: '2018-02-07T02:24:04.257Z', createdAt: '2018-02-07T02:24:04.257Z' }
root@9972adbbdbb3:/userauth#
现在我们已经证明认证服务可以工作,顺便还创建了一个用户账户,你应该能够浏览到笔记应用并运行它。
你还可以尝试 ping 不同的容器以确保应用网络拓扑已经正确创建。
如果你使用 Docker 命令行工具来探索正在运行的容器和网络,你会看到它们有新的名称。新名称与旧名称类似,但前面有字符串compose_作为前缀。这是使用 Docker Compose 的副作用。
默认情况下,docker-compose会附加到容器,以便在终端上打印日志输出。来自所有四个容器的输出将混合在一起。幸运的是,每一行都由容器名称开头。
当你完成系统测试后,只需在终端上输入CTRL +* C*:
^CGracefully stopping... (press Ctrl+C again to force)
Stopping db-userauth ... done
Stopping userauth ... done
Stopping db-notes ... done
Stopping notes ... done
要避免在终端附加到容器的情况下运行,请使用-d选项。这表示从终端断开连接并在后台运行。
使用docker-compose down命令是降低由 compose 文件描述的系统的一种替代方法。
up命令构建、重新创建并启动容器。构建步骤可以使用docker-compose build命令单独处理。同样,启动和停止容器也可以通过使用docker-compose start和docker-compose-stop命令单独处理。
在所有情况下,你的命令行应该位于包含 docker-compose.yml 文件的目录中。这是该文件的默认名称。可以使用 -f 选项来指定不同的文件名。
使用 Docker Compose 部署到云主机
我们在我们的笔记本电脑上验证了由 compose 文件描述的服务按预期工作。现在容器启动已经自动化,解决了我们之前提到的一个问题。现在是时候看看如何将应用部署到云主机提供商了。这就是我们转向 Docker machine 的原因。
Docker machine 可以用来在你的笔记本电脑上的 VirtualBox 主机内部配置 Docker 实例。我们将要做的是在 DigitalOcean 上配置 Docker 系统。docker-machine 命令带有支持大量云主机提供商的驱动程序。很容易根据其他提供商调整这里显示的说明,只需替换不同的驱动程序即可。
在注册 DigitalOcean 账户后,点击仪表板中的 API 链接。我们需要一个 API 令牌来授予 docker-machine 访问账户的权限。完成创建令牌的过程,并保存你获得的令牌字符串。Docker 网站有一个教程,见 docs.docker.com/machine/examples/ocean/。
拿到令牌后,输入以下命令:
$ docker-machine create --driver digitalocean --digitalocean-size 2gb \
--digitalocean-access-token TOKEN-FROM-PROVIDER \
sandbox
Running pre-create checks...
Creating machine...
(sandbox) Creating SSH key...
(sandbox) Creating Digital Ocean droplet...
(sandbox) Waiting for IP address to be assigned to the Droplet...
Waiting for machine to be running, this may take a few minutes...
Detecting operating system of created instance...
Waiting for SSH to be available...
Detecting the provisioner...
Provisioning with ubuntu(systemd)...
Installing Docker...
Copying certs to the local machine directory...
Copying certs to the remote machine...
Setting Docker configuration on the remote daemon...
Checking connection to Docker...
Docker is up and running!
To see how to connect your Docker Client to the Docker Engine running on this virtual machine, run: docker-machine env sandbox
如我们之前所述,digitalocean 驱动程序与 Digital Ocean 一起使用。Docker 网站有一个驱动程序列表,见 docs.docker.com/machine/drivers/。
这里打印了很多关于设置事项的信息。最重要的是最后的消息。一系列环境变量被用来告诉 docker 命令连接到 Docker 引擎实例的位置。正如消息所说,运行:docker-machine env sandbox:
$ docker-machine env sandbox
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://45.55.37.74:2376"
export DOCKER_CERT_PATH="/home/david/.docker/machine/machines/sandbox"
export DOCKER_MACHINE_NAME="sandbox"
# Run this command to configure your shell:
# eval $(docker-machine env sandbox)
这是用来访问我们刚刚创建的 Docker 主机的环境变量。你还应该去你的云主机提供商仪表板查看,确认主机已经被创建。此命令还给我们一些要遵循的指示:
$ eval $(docker-machine env sandbox)
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM DOCKER ERRORS
sandbox * digitalocean Running tcp://45.55.37.74:2376 v18.01.0-ce
这表明我们在选择的云主机提供商的主机上运行了一个 Docker 引擎实例。
在这个阶段,一个有趣的测试是在这个终端上运行 docker ps -a,然后在另一个没有这些环境变量的终端上运行它。这应该会显示云主机没有任何容器,而你的本地机器可能有一些容器(取决于你当前正在运行的内容):
$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
ca4f61b1923c: Pull complete
Digest: sha256:66ef312bbac49c39a89aa9bcc3cb4f3c9e7de3788c944158df3ee0176d32b751
Status: Downloaded newer image for hello-world:latest
... $ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world latest f2a91732366c 2 months ago 1.85kB
在这里,我们已经验证了可以在远程主机上启动一个容器。
下一步是为新机器构建我们的容器。因为我们已经将环境变量切换到指向新服务器,所以这些命令会导致在那里而不是在我们的笔记本电脑上执行操作:
$ docker-compose build
db-userauth uses an image, skipping
db-notes uses an image, skipping
Building notes
Step 1/22 : FROM node:9.5
9.5: Pulling from library/node
f49cf87b52c1: Pull complete
7b491c575b06: Pull complete
b313b08bab3b: Pull complete
51d6678c3f0e: Pull complete
...
由于我们更改了环境变量,构建现在是在 sandbox 机器上进行的,而不是像之前那样在我们的笔记本电脑上。
这将花费一些时间,因为远程机器上的 Docker 镜像缓存为空。此外,构建notesapp和userauth容器会将整个源树复制到服务器上,并在服务器上运行所有构建步骤。
如果默认内存大小为 500 MB(在撰写本文时 DigitalOcean 的默认值),构建可能会失败。如果是这样,首先尝试将主机内存大小调整至至少 2 GB。
构建完成后,在远程机器上启动容器:
$ docker-compose up
Creating notes ... done
Recreating db-userauth ... done
Recreating db-notes ... done
Creating notes ...
Attaching to db-userauth, db-notes, userauth, notes
容器启动后,你应该像之前那样测试userauth容器。不幸的是,第一次这样做时,该命令会失败。问题在于docker-compose.yml中的这些行:
- ../authnet/my.cnf:/etc/my.cnf
...
- ../frontnet/my.cnf:/etc/my.cnf
在这种情况下,构建发生在远程机器上,docker-machine命令不会将命名文件复制到服务器。因此,当 Docker 尝试启动容器时,它无法这样做,因为该卷挂载无法满足,因为文件根本不存在。这意味着需要对docker-compose.yml进行一些修改,并添加两个新的 Dockerfile。
首先,对docker-compose.yml进行以下更改:
...
db-userauth:
build: ../authnet
container_name: db-userauth
networks:
- authnet
volumes:
- db-userauth-data:/var/lib/mysql
restart: always
...
db-notes:
build: ../frontnet
container_name: db-notes
networks:
- frontnet
volumes:
- db-notes-data:/var/lib/mysql
restart: always
我们现在不是从 Docker 镜像构建数据库容器,而是从一对 Dockerfile 构建。现在我们必须创建这两个 Dockerfile。
在authnet中创建一个名为Dockerfile的文件,包含以下内容:
FROM mysql/mysql-server:5.7
EXPOSE 3306
COPY my.cnf /etc/
ENV MYSQL_RANDOM_ROOT_PASSWORD="true"
ENV MYSQL_USER=userauth
ENV MYSQL_PASSWORD=userauth
ENV MYSQL_DATABASE=userauth
CMD [ "mysqld", "--character-set-server=utf8mb4", \
"--collation-server=utf8mb4_unicode_ci", "--bind-address=0.0.0.0" ]
这将复制docker-compose.yml中曾经是db-userauth描述的某些设置。重要的是我们现在COPY了my.cnf文件,而不是使用卷挂载。
在frontnet中创建一个包含以下内容的Dockerfile:
FROM mysql/mysql-server:5.7
EXPOSE 3306
COPY my.cnf /etc/
ENV MYSQL_RANDOM_ROOT_PASSWORD="true"
ENV MYSQL_USER=notes
ENV MYSQL_PASSWORD=notes12345
ENV MYSQL_DATABASE=notes
CMD [ "mysqld", "--character-set-server=utf8mb4", \
"--collation-server=utf8mb4_unicode_ci", "--bind-address=0.0.0.0" ]
这与之前相同,但更改了一些关键值。
在进行这些更改后,我们现在可以构建容器并启动它们:
$ docker-compose build
... much output
$ docker-compose up --force-recreate
... much output
现在我们有一个可工作的构建,并且可以启动容器,让我们检查它们并验证一切是否正常工作。
在userauth中执行一个 shell 来测试和设置用户数据库:
$ docker exec -it userauth bash
root@931dd2a267b4:/userauth# PORT=3333 node users-list.js
List [ { id: 'me', username: 'me', provider: 'local',
familyName: 'Einarrsdottir', givenName: 'Ashildr', middleName: '',
emails: '[]', photos: '[]' } ]
如前所述,这验证了userauth服务正在运行,远程容器已设置,并且我们可以继续使用笔记应用。
问题是:使用哪个 URL?服务不在localhost上,因为它在远程服务器上。我们没有分配域名,但服务器有一个 IP 地址。
运行以下命令:
$ docker-machine ip sandbox
45.55.37.74
Docker 会告诉你 IP 地址,你应该将其作为 URL 的基础。因此,在你的浏览器中访问http://IP-ADDRESS:3000
将笔记部署到远程服务器后,你应该检查我们之前查看的所有内容。桥接网络应该存在,如之前所示,容器之间有相同的有限访问。唯一的公开访问应该是notes容器的端口3000。
记住为你的服务器适当地设置TWITTER_CALLBACK_HOST环境变量。
由于我们的数据库容器挂载了一个卷来存储数据,让我们看看这个卷在服务器上的位置:
$ docker volume ls
DRIVER VOLUME NAME
local compose_db-notes-data
local compose_db-userauth-data
这些是预期的卷,每个容器一个:
$ docker volume inspect compose_db-notes-data
[
{
"CreatedAt": "2018-02-07T06:30:06Z",
"Driver": "local",
"Labels": {
"com.docker.compose.project": "compose",
"com.docker.compose.volume": "db-notes-data"
},
"Mountpoint": "/var/lib/docker/volumes/compose_db-notes-
data/_data",
"Name": "compose_db-notes-data",
"Options": {},
"Scope": "local"
}
]
这些是目录,但它们并不位于我们的笔记本电脑上。相反,它们位于远程服务器上。访问这些目录意味着登录到远程服务器以查看:
$ docker-machine ssh sandbox
Welcome to Ubuntu 16.04.3 LTS (GNU/Linux 4.4.0-112-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
Get cloud support with Ubuntu Advantage Cloud Guest:
http://www.ubuntu.com/business/services/cloud
4 packages can be updated.
0 updates are security updates.
Last login: Wed Feb 7 04:00:29 2018 from 108.213.68.139
root@sandbox:~#
从这个点开始,你可以检查这些卷对应的目录,并看到它们确实包含 MySQL 配置和数据文件:
root@sandbox:~# ls /var/lib/docker/volumes/compose_db-notes-data/_data
auto.cnf client-key.pem ib_logfile1 mysql.sock.lock public_key.pem
ca-key.pem ib_buffer_pool ibtmp1 notes server-cert.pem
ca.pem ibdata1 mysql performance_schema server-key.pem
client-cert.pem ib_logfile0 mysql.sock private_key.pem sys
你还会发现 Docker 命令行工具将正常工作。进程列表特别有趣:

仔细观察,你会看到对应于系统中每个容器的进程。这些进程在宿主操作系统中运行。Docker 为这些进程创建了配置/隔离层,以创建进程似乎在另一个操作系统下运行的假象,以及各种系统/网络配置文件,如容器截图中所指定的。
Docker 相对于虚拟化方法(如 VirtualBox)所声称的优势是 Docker 非常轻量级。我们在这里可以看到为什么 Docker 是轻量级的:没有虚拟化层,只有容器化过程(docker-containerd-shim)。
一旦你确认 Notes 在远程服务器上运行正常,你可以按照以下步骤关闭它并移除:
$ docker-compose stop
Stopping notesapp ... done
Stopping userauth ... done
Stopping db-notes ... done
Stopping db-auth ... done
这将一次性关闭所有容器:
$ docker-machine stop sandbox
Stopping "sandbox"...
Machine "sandbox" was stopped.
这将关闭远程机器。云托管提供商的控制面板将显示 Droplet 已停止。
在这一点上,如果你愿意,可以继续删除 Docker 机器实例:
$ docker-machine rm sandbox
About to remove sandbox
Are you sure? (y/n): y
Successfully removed sandbox
如果你确实确定要删除机器,前面的命令就会完成这项任务。一旦这样做,机器将从你的云托管提供商控制面板中删除。
摘要
本章已经是一次相当长的旅程。我们从仅存在于我们笔记本电脑上的应用程序,到探索两种将 Node.js 应用程序部署到生产服务器的途径。
我们首先回顾了 Notes 应用程序架构及其对部署的影响。这使得你能够理解服务器部署需要做什么。
然后,你学习了在 Linux 上使用 init 脚本来部署服务的传统方法。PM2 是在这种环境中管理后台进程的有用工具。你还学习了如何使用虚拟机托管服务来配置远程服务器。
然后,你长途跋涉进入了 Docker 的世界,这是一个用于在机器上部署服务的新兴且令人兴奋的系统。你学习了如何编写 Dockerfile,以便 Docker 知道如何构建服务镜像。你学习了在笔记本电脑或远程服务器上部署 Docker 镜像的几种方法。你还学习了如何使用 Docker Compose 描述多容器应用程序。
你几乎准备好结束这本书了。你在旅途中学到了很多;还有两件事要讨论。
在下一章,我们将学习单元测试和功能测试。虽然测试驱动开发的核心原则是在编写应用程序之前编写单元测试,但我们采取了相反的做法,将关于单元测试的章节放在这本书的末尾。这并不意味着单元测试不重要,因为它极其重要。
在最后一章,我们将探讨如何加固我们的应用程序和应用基础设施,以抵御攻击者。
第十一章:单元测试和功能测试
单元测试已成为良好软件开发实践的重要组成部分。这是一种通过测试源代码的各个单元来确保其正确功能的方法。每个单元在理论上都是应用程序中最小的可测试部分。在一个 Node.js 应用程序中,你可能将每个模块视为一个单元。
在单元测试中,每个单元都是单独测试的,尽可能地将测试单元与其他应用程序部分隔离。如果测试失败,你希望它是因为你的代码中的错误,而不是你代码所使用的包中的错误。一个常见的技巧是使用模拟对象或模拟数据来隔离应用程序的各个部分。
与此相反,功能测试并不试图测试单个组件,而是测试整个系统。一般来说,单元测试由开发团队执行,而功能测试由质量保证(QA)或质量工程(QE)团队执行。这两种测试模型都是完全认证应用程序所必需的。一个类比可能是,单元测试类似于确保句子中的每个单词都拼写正确,而功能测试则确保包含该句子的段落具有良好的结构。
在本章中,我们将涵盖:
-
断言作为软件测试的基础
-
Mocha 单元测试框架和 Chai 断言库
-
使用测试来查找错误并修复错误
-
使用 Docker 管理测试基础设施
-
测试 REST 后端服务
-
使用 Puppeteer 在真实网页浏览器中进行 UI 测试
-
使用元素 ID 属性提高 UI 可测试性
断言 – 测试方法的基础
Node.js 有一个有用的内置测试工具,即 assert 模块。它的功能与其他语言的 assert 库类似。也就是说,它是一组用于测试条件的函数,如果条件指示有错误,则 assert 函数会抛出异常。
最简单的情况下,测试套件是一系列 assert 调用来验证被测试事物的行为。例如,测试套件可以实例化用户身份验证服务,然后进行 API 调用,使用 assert 方法验证结果,然后进行另一个 API 调用,验证其结果,依此类推。
考虑一个代码片段,例如,你可以将其保存为名为 deleteFile.js 的文件:
const fs = require('fs');
exports.deleteFile = function(fname, callback) {
fs.stat(fname, (err, stats) => {
if (err) callback(new Error(`the file ${fname} does not exist`));
else {
fs.unlink(fname, err2 => {
if (err) callback(new Error(`could not delete ${fname}`));
else callback();
});
}
});
};
首先要注意的是,这里包含了几层异步回调函数。这带来了一些挑战:
-
从回调函数的深处捕获错误,以确保测试场景失败
-
检测回调函数从未被调用的情况
以下是一个使用 assert 进行测试的示例。创建一个名为 test-deleteFile.js 的文件,包含以下内容:
const fs = require('fs');
const assert = require('assert');
const df = require('./deleteFile');
df.deleteFile("no-such-file", (err) => {
assert.throws(
function() { if (err) throw err; },
function(error) {
if ((error instanceof Error)
&& /does not exist/.test(error)) {
return true;
} else return false;
},
"unexpected error"
);
});
这被称为负测试场景,因为它测试的是请求删除一个不存在的文件是否会抛出错误。
如果你正在寻找一种快速测试的方法,当这样使用时,assert 模块可能很有用。如果它运行并且没有打印出消息,那么测试就通过了。但是,它是否捕捉到了 deleteFile 回调从未被调用的情况?
$ node test-deleteFile.js
assert 模块被许多测试框架用作编写测试用例的核心工具。测试框架所做的是创建一个熟悉的测试套件和测试用例结构来封装你的测试代码。
Node.js 中有众多断言库的风格。在本章的后面部分,我们将使用 Chai 断言库 (chaijs.com/),它为你提供了三种不同的断言风格(should、expect 和 assert)的选择。
测试 Notes 模型
让我们从为 Notes 应用程序编写的数据模型开始我们的单元测试之旅。因为这是单元测试,模型应该与 Notes 应用程序的其余部分分开进行测试。
在 Notes 模型的大多数情况下,隔离它们的依赖意味着创建一个模拟数据库。你是要测试数据模型还是底层数据库?模拟数据库意味着创建一个假的数据库实现,这并不像是我们时间的高效利用。你可以争论说,测试数据模型实际上是在测试你的代码与数据库之间的交互,模拟数据库意味着没有测试这种交互,因此我们应该针对生产中使用的数据库引擎测试我们的代码。
基于这样的推理,我们将跳过模拟数据库,而是对包含测试数据的数据库运行测试。为了简化测试数据库的启动,我们将使用 Docker 启动和停止一个为测试设置的 Notes 应用程序堆栈版本。
Mocha 和 Chai – 选择的测试工具
如果你还没有这样做,复制源树以用于本章。例如,如果你有一个名为 chap10 的目录,创建一个名为 chap11 的目录,其中包含 chap10 中的所有内容。
在 notes 目录下,创建一个名为 test 的新目录。
Mocha (mochajs.org/) 是许多适用于 Node.js 的测试框架之一。正如你很快就会看到的,它帮助我们编写测试用例和测试套件,并提供测试结果报告机制。它之所以被选中,是因为它支持 Promises。它与前面提到的 Chai 断言库非常契合。此外,我们需要使用 ES6 模块从 CommonJS 编写的测试套件中,因此我们必须使用 esm 模块。
你可能会发现对早期 @std/esm 模块的引用。该模块已被弃用,esm 取而代之。
当你在 notes/test 目录中时,输入以下命令来安装 Mocha、Chai 和 esm:
$ npm init
... answer the questions to create package.json
$ npm install mocha@5.x chai@4.1.x esm --save
Notes 模型测试套件
由于我们有多个 Notes 模型,测试套件应该针对任何模型运行。我们可以使用我们开发的 Notes 模型 API 编写测试,并使用环境变量来声明要测试的模型。
由于我们使用 ES6 模块编写了 Notes 应用程序,我们面临一个小挑战。Mocha 只支持在 CommonJS 模块中运行测试,而 Node.js(截至本文写作时)不支持从 CommonJS 模块加载 ES6 模块。ES6 模块可以使用import来加载 CommonJS 模块,但 CommonJS 模块不能使用require来加载 ES6 模块。背后有各种技术原因,但最终结果是我们在这种限制下。
由于 Mocha 要求测试必须是 CommonJS 模块,我们处于必须将 ES6 模块加载到 CommonJS 模块中的位置。存在一个名为esm的模块,它允许这种组合工作。如果你回顾一下,我们在上一节中安装了该模块。让我们看看如何使用它。
在test目录中,创建一个名为test-model.js的文件,包含以下内容作为测试套件的顶层结构:
'use strict';
require = require("esm")(module,{"esm":"js"});
const assert = require('chai').assert;
const model = require('../models/notes');
describe("Model Test", function() {
..
});
通过这里显示的require('esm')语句启用了加载 ES6 模块的支持。它用esm模块中的一个替换了标准的require函数。末尾的参数列表启用了在 CommonJS 模块中加载 ES6 模块的功能。一旦这样做,你的 CommonJS 模块就可以通过require('../models/notes')在几行之后加载一个 ES6 模块。
Chai 库支持三种断言风格。我们在这里使用的是assert风格,但如果你更喜欢其他风格,也很容易切换。有关 Chai 支持的其他风格,请参阅chaijs.com/guide/styles/。
Chai 的断言包括一个非常长的有用断言函数列表,请参阅chaijs.com/api/assert/。
要测试的 Notes 模型必须通过NOTES_MODEL环境变量来选择。对于也咨询环境变量的模型,我们还需要提供该配置。
使用 Mocha,测试套件包含在一个describe块中。第一个参数是描述性文本,你用它来定制测试结果的展示。
而不是维护一个单独的测试数据库,我们可以在执行测试时动态创建一个。Mocha 有所谓的钩子,这些是在测试用例执行前后执行的函数。钩子函数让你,测试套件的作者,设置和撤销测试套件操作所需的条件。例如,为了创建一个包含已知测试内容的测试数据库:
describe("Model Test", function() {
beforeEach(async function() {
try {
const keyz = await model.keylist();
for (let key of keyz) {
await model.destroy(key);
}
await model.create("n1", "Note 1", "Note 1");
await model.create("n2", "Note 2", "Note 2");
await model.create("n3", "Note 3", "Note 3");
} catch (e) {
console.error(e);
throw e;
}
});
..
});
这定义了一个beforeEach钩子,它在每个测试用例之前执行。其他的钩子包括before、after、beforeEach和afterEach。每个钩子在测试用例执行前后被触发。
这意味着它是一个在每个测试之前的清理/准备步骤。它使用我们的笔记 API 首先从数据库中删除所有笔记(如果有),然后创建一组具有已知特征的新的笔记。这种技术通过确保我们有已知条件来测试,从而简化了测试。
我们还有一个副作用是测试 model.keylist 和 model.create 方法。
在 Mocha 中,一系列测试用例被封装在一个 describe 块中,并使用 it 块编写。describe 块的目的是描述那一组测试,而 it 块是用来检查被测试事物的特定方面的断言。你可以根据需要将 describe 块嵌套得尽可能深:
describe("check keylist", function() {
it("should have three entries", async function() {
const keyz = await model.keylist();
assert.exists(keyz);
assert.isArray(keyz);
assert.lengthOf(keyz, 3);
});
it("should have keys n1 n2 n3", async function() {
const keyz = await model.keylist();
assert.exists(keyz);
assert.isArray(keyz);
assert.lengthOf(keyz, 3);
for (let key of keyz) {
assert.match(key, /n[123]/, "correct key");
}
});
it("should have titles Node #", async function() {
const keyz = await model.keylist();
assert.exists(keyz);
assert.isArray(keyz);
assert.lengthOf(keyz, 3);
var keyPromises = keyz.map(key => model.read(key));
const notez = await Promise.all(keyPromises);
for (let note of notez) {
assert.match(note.title, /Note [123]/, "correct title");
}
});
});
策略是调用笔记 API 函数,然后测试结果以检查它们是否与预期结果匹配。
这个 describe 块位于外部的 describe 块内。describe 和 it 块中给出的描述用于使测试报告更易于阅读。it 块形成了一个类似于 it (被测试的事物) 应该这样做或那样做 的伪句子。
在 Mocha 中,重要的是不要在 describe 和 it 块中使用箭头函数。到现在为止,你可能会因为箭头函数的易用性而喜欢它们。但是,Mocha 使用一个包含对 Mocha 有用函数的 this 对象来调用这些函数。因为箭头函数避免了设置 this 对象,所以 Mocha 会崩溃。
即使 Mocha 需要 describe 和 it 块中的常规函数,我们也可以在这些函数中使用箭头函数。
Mocha 如何知道测试代码是否通过?它是如何知道测试何时完成的?这段代码展示了三种方法之一。
通常,Mocha 寻找函数是否抛出异常,或者测试用例是否执行时间过长(超时情况)。在任何情况下,Mocha 都会指示测试失败。当然,对于非异步代码来说,这很简单就能确定。但是,Node.js 全是关于异步代码的,Mocha 有两种异步代码测试模型。
在第一个(此处未展示)中,Mocha 传入一个回调函数,测试代码是调用这个回调函数。在第二个,如这里所示,它寻找测试函数返回的 Promise,并确定是否通过取决于 Promise 是否处于 resolve 或 reject 状态。
在这种情况下,我们使用 async 函数,因为它们会自动返回一个 Promise。在函数内部,我们使用 await 调用异步函数,确保任何抛出的异常都会被指示为拒绝的 Promise。
另一个需要注意的事项是之前提出的问题:如果我们正在测试的回调函数从未被调用怎么办?或者,如果 Promise 从未解决怎么办?Mocha 启动一个计时器,如果测试用例在计时器到期之前没有完成,Mocha 会失败该测试用例。
配置和运行测试
我们还有更多的测试要写,但让我们首先设置好运行测试的环境。最简单的模型要测试的是内存模型。让我们将其添加到notes/test/package.json的scripts部分:
"test-notes-memory": "NOTES_MODEL=memory mocha test-model",
要安装依赖项,我们必须在notes/test和notes目录中运行npm install。这样,测试代码的依赖项和 Notes 的依赖项都安装在了正确的位置。
然后,我们可以按照以下方式运行它:
$ npm run test-notes-memory
> notes-test@1.0.0 test-notes-memory /Users/david/chap11/notes/test
> NOTES_MODEL=memory mocha test-model
Model Test
check keylist
√ should have three entries
√ should have keys n1 n2 n3
√ should have titles Node #
3 passing (18ms)
使用mocha命令运行测试套件。
输出的结构遵循describe和it块的结构。你应该设置描述性文本字符串,使其读起来更舒服。
Notes 模型的更多测试
这还不够测试很多,所以让我们继续添加一些更多的测试:
describe("read note", function() {
it("should have proper note", async function() {
const note = await model.read("n1");
assert.exists(note);
assert.deepEqual({ key: note.key, title: note.title, body:
note.body }, {
key: "n1", title: "Note 1 FAIL", body: "Note 1"
});
});
it("Unknown note should fail", async function() {
try {
const note = await model.read("badkey12");
assert.notExists(note);
throw new Error("should not get here");
} catch(err) {
// this is expected, so do not indicate error
assert.notEqual(err.message, "should not get here");
}
});
});
describe("change note", function() {
it("after a successful model.update", async function() {
const newnote = await model.update("n1", "Note 1 title
changed", "Note 1 body changed");
const note = await model.read("n1");
assert.exists(note);
assert.deepEqual({ key: note.key, title: note.title, body:
note.body }, {
key: "n1", title: "Note 1 title changed", body: "Note 1 body
changed"
});
});
});
describe("destroy note", function() {
it("should remove note", async function() {
await model.destroy("n1");
const keyz = await model.keylist();
assert.exists(keyz);
assert.isArray(keyz);
assert.lengthOf(keyz, 2);
for (let key of keyz) {
assert.match(key, /n[23]/, "correct key");
}
});
it("should fail to remove unknown note", async function() {
try {
await model.destroy("badkey12");
throw new Error("should not get here");
} catch(err) {
// this is expected, so do not indicate error
assert.notEqual(err.message, "should not get here");
}
});
});
after(function() { model.close(); });
});
注意,对于负测试——如果抛出错误则测试通过——我们将其运行在try/catch块中。每个情况中的throw new Error行不应该执行,因为前面的代码应该抛出错误。因此,我们可以检查抛出的错误中的消息是否是接收到的消息,如果是这种情况,则测试失败。
现在,测试报告:
$ npm run test-notes-memory
> notes-test@1.0.0 test-notes-memory /Users/david/chap11/notes/test
> NOTES_MODEL=memory mocha test-model
Model Test
check keylist
√ should have three entries
√ should have keys n1 n2 n3
√ should have titles Node #
read note
√ should have proper note
√ Unknown note should fail
change note
√ after a successful model.update
destroy note
√ should remove note
√ should fail to remove unknown note
8 passing (17ms)
在这些附加测试中,我们有一些负测试。在每个我们期望失败的测试中,我们提供一个我们知道不在数据库中的notekey,然后确保模型给我们一个错误。
Chai 断言 API 包括一些非常表达式的断言。在这种情况下,我们使用了deepEqual方法,它对两个对象进行深度比较。在我们的情况下,它看起来像这样:
assert.deepEqual({ key: note.key, title: note.title, body: note.body }, {
key: "n1", title: "Note 1", body: "Note 1"
});
这在测试代码中读起来很舒服,但更重要的是,报告的测试失败看起来非常漂亮。由于这些目前都在通过,尝试通过更改其中一个预期值字符串来引入一个错误。重新运行测试后,你会看到:
Model Test
check keylist
√ should have three entries
√ should have keys n1 n2 n3
√ should have titles Node #
read note
1) should have proper note
√ Unknown note should fail
change note
√ after a successful model.update
destroy note
√ should remove note
√ should fail to remove unknown note
7 passing (42ms)
1 failing
1) Model Test
read note
should have proper note:
AssertionError: expected { Object (key, title, ...) } to deeply
equal { Object (key, title, ...) }
+ expected - actual
{
"body": "Note 1"
"key": "n1"
- "title": "Note 1"
+ "title": "Note 1 FAIL"
}
at Context.<anonymous> (test-model.js:53:16)
at <anonymous>
最上面是每个测试案例的状态报告。对于某个测试,不是用勾号表示,而是用数字,这个数字对应于底部报告的详细信息。当使用spec报告器时,Mocha 就是这样呈现测试失败的。Mocha 支持其他测试报告格式,其中一些可以产生可以发送到测试状态报告系统的数据。有关更多信息,请参阅mochajs.org/#reporters。
在这种情况下,失败是通过deepEqual方法检测到的,它以这种方式呈现检测到的对象不等式。
测试数据库模型
这很好,但我们显然不会在生产中使用内存中的 Notes 模型。这意味着我们需要测试所有其他模型。
测试 LevelUP 和文件系统模型很容易,只需将其添加到package.json的脚本部分:
"test-notes-levelup": "NOTES_MODEL=levelup mocha",
"test-notes-fs": "NOTES_MODEL=fs mocha",
然后运行以下命令:
$ npm run test-notes-fs
$ npm run test-notes-levelup
这将产生一个成功的测试结果。
最简单的数据库来测试是 SQLite3,因为它不需要任何设置。我们有两个 SQLite3 模型要测试,让我们从notes-sqlite3.js开始。将以下内容添加到package.json的脚本部分:
"test-notes-sqlite3": "rm -f chap11.sqlite3 && sqlite3 chap11.sqlite3 --init ../models/chap07.sql </dev/null && NOTES_MODEL=sqlite3 SQLITE_FILE=chap11.sqlite3 mocha test-model",
这个命令序列将测试数据库放入chap11.sqlite3文件。它首先使用sqlite3命令行工具初始化该数据库。请注意,我们已经将其输入连接到/dev/null,因为否则sqlite3命令会提示输入。然后,它运行测试套件,传递运行针对 SQLite3 模型所需的环境变量。
运行测试套件确实找到了两个错误:
$ npm run test-notes-sqlite3
> notes-test@1.0.0 test-notes-sqlite3 /Users/david/chap11/notes/test
> rm -f chap11.sqlite3 && sqlite3 chap11.sqlite3 --init ../models/chap07.sql </dev/null && NOTES_MODEL=sqlite3 SQLITE_FILE=chap11.sqlite3 mocha test-model
Model Test
check keylist
√ should have three entries
√ should have keys n1 n2 n3
√ should have titles Node #
read note
√ should have proper note
1) Unknown note should fail
change note
√ after a successful model.update (114ms)
destroy note
√ should remove note (103ms)
2) should fail to remove unknown note
6 passing (6s)
2 failing
1) Model Test
read note
Unknown note should fail:
Uncaught TypeError: Cannot read property 'notekey' of undefined
at Statement.db.get (/home/david/nodewebdev/node-web-development-
code-4th-edition/chap11/notes/models/notes-sqlite3.mjs:64:39)
2) Model Test
destroy note
should fail to remove unknown note:
AssertionError: expected 'should not get here' to not equal
'should not get here'
+ expected - actual
失败的测试调用model.read("badkey12"),一个我们知道不存在的key。编写负面测试是值得的。在models/notes-sqlite3.mjs(第 64 行)中失败的代码行如下:
const note = new Note(row.notekey, row.title, row.body);
在这之前插入console.log(util.inspect(row));非常简单,可以了解到,对于失败的调用,SQLite3 为我们提供了undefined的row,解释了错误信息。
测试套件多次调用read函数,使用一个确实存在的notekey值。显然,当提供一个无效的notekey值时,查询会返回一个空的结果集,SQLite3 会调用回调函数,同时传递undefined错误和undefined的行值。这是数据库模块的常见行为。空结果集不是错误,因此我们没有收到错误,也没有undefined的row。
实际上,我们在models/notes-sequelize.mjs中看到了这种行为。models/notes-sequelize.mjs中的等效代码做了正确的事情,并且有一个检查,我们可以将其改编。让我们将models/notes-sqlite.mjs中的read函数重写为以下内容:
export async function read(key) {
var db = await connectDB();
var note = await new Promise((resolve, reject) => {
db.get("SELECT * FROM notes WHERE notekey = ?", [ key ], (err, row)
=> {
if (err) return reject(err);
if (!row) { reject(new Error(`No note found for ${key}`)); }
else {
const note = new Note(row.notekey, row.title, row.body);
resolve(note);
}
});
});
return note;
}
这很简单,我们只需检查row是否为undefined,如果是,就抛出一个错误。虽然数据库不会将空结果集视为错误,但 Notes 会。此外,Notes 已经知道如何处理这种情况下的抛出错误。进行这个更改,特定的测试用例就通过了。
在destroy逻辑中还有一个类似的错误。测试销毁一个不存在的笔记时,在这一行没有产生错误:
await model.destroy("badkey12");
如果我们检查其他模型,它们对于不存在的键会抛出错误。在 SQL 中,如果这个 SQL(来自models/notes-sqlite3.mjs)没有删除任何东西,显然这不是一个错误:
db.run("DELETE FROM notes WHERE notekey = ?;", ... );
不幸的是,没有 SQL 选项可以在不删除任何记录时使这个 SQL 语句失败。因此,我们必须添加一个检查来查看记录是否存在。具体来说:
export async function destroy(key) {
const db = await connectDB();
const note = await read(key);
return await new Promise((resolve, reject) => {
db.run("DELETE FROM notes WHERE notekey = ?;", [ key ], err =>
{
if (err) return reject(err);
resolve();
});
});
}
因此,我们读取了笔记,作为副产品我们也验证了笔记的存在。如果笔记不存在,read将抛出一个错误,并且DELETE操作甚至不会运行。
这些是我们提到的第七章数据存储和检索中的错误。我们只是忘记在这个特定模型中检查这些条件。幸运的是,我们勤奋的测试捕捉到了这个问题。至少,这是我们要告诉经理们的故事,而不是告诉他们我们忘记检查我们已经知道可能发生的事情。
现在我们已经修复了models/notes-sqlite3.mjs,让我们也使用 SQLite3 数据库测试models/notes-sequelize.mjs。为此,我们需要一个连接对象来在SEQUELIZE_CONNECT变量中指定。虽然我们可以重用现有的一个,但让我们创建一个新的。创建一个名为test/sequelize-sqlite.yaml的文件,包含以下内容:
dbname: notestest
username:
password:
params:
dialect: sqlite
storage: notestest-sequelize.sqlite3
logging: false
这样做,我们不会用我们的测试套件覆盖生产数据库实例。由于测试套件会销毁它测试的数据库,它必须运行在我们愿意销毁的数据库上。日志参数关闭了Sequelize产生的庞大输出,这样我们就可以阅读测试结果报告。
将以下内容添加到package.json的脚本部分:
"test-notes-sequelize-sqlite": "NOTES_MODEL=sequelize SEQUELIZE_CONNECT=sequelize-sqlite.yaml mocha test-model"
然后运行测试套件:
$ npm run test-notes-sequelize-sqlite
..
8 passing (2s)
我们轻松过关!我们已经能够利用相同的测试套件针对多个笔记模型进行测试。我们甚至在其中一个模型中发现了两个错误。但是,我们还有两个测试配置需要测试。
我们测试结果矩阵如下:
-
models-fs: 通过 -
models-memory: 通过 -
models-levelup: 通过 -
models-sqlite3: 2 次失败,现已修复 -
models-sequelize: 使用 SQLite3:通过 -
models-sequelize: 使用 MySQL:未测试 -
models-mongodb: 未测试
这两个未测试的模型都需要设置数据库服务器。我们避免测试这些组合,但我们的经理不会接受这个借口,因为 CEO 需要知道我们已经完成了测试周期。笔记必须在类似生产环境的配置中进行测试。
在生产中,我们当然会使用常规数据库服务器,MySQL 或 MongoDB 是首选。因此,我们需要一种低开销的方式来运行对这些数据库的测试。对生产配置的测试必须非常容易,以至于我们不应该有任何阻力,以确保测试经常运行,以达到预期的效果。
幸运的是,我们已经有了一种支持轻松创建和销毁部署基础设施技术的经验。你好,Docker!
使用 Docker 管理测试基础设施
Docker 给我们带来的一个优点是能够在我们的笔记本电脑上安装生产环境。然后,将相同的 Docker 设置推送到云托管环境进行预发布或生产部署就变得非常容易。
在本节中,我们将演示重用之前为测试基础设施定义的 Docker Compose 配置,并使用 shell 脚本自动化在容器内执行笔记测试套件。一般来说,在运行测试时复制生产环境是很重要的。Docker 可以使这一点变得容易实现。
使用 Docker,我们将能够轻松地对数据库进行测试,并有一个简单的方法来启动和停止生产环境的测试版本。让我们开始吧。
使用 Docker Compose 编排测试基础设施
我们在使用 Docker Compose 进行笔记应用程序部署编排时获得了很好的体验。整个系统,包含四个独立的服务,可以很容易地在 compose/docker-compose.yml 中描述。我们将要做的是复制 Compose 文件,然后进行一些必要的更改以支持测试执行。
让我们从创建一个新的目录 test-compose 开始,作为 notes、users 和 compose 目录的兄弟目录。将 compose/docker-compose.yml 复制到新创建的 test-compose 目录。我们将对这个文件进行一些更改,并对现有的 Dockerfile 进行一些小的更改。
我们希望更改容器和网络名称,以便我们的测试基础设施不会破坏生产基础设施。我们将不断删除并重新创建测试容器,为了使开发者满意,我们将保持开发基础设施不变,并在单独的基础设施上执行测试。通过维护独立的测试容器和网络,我们的测试脚本可以随心所欲地执行,而不会干扰开发或生产容器。
考虑对 db-auth 和 db-notes 容器进行的以下更改:
db-userauth-test:
build: ../authnet
container_name: db-userauth-test
networks:
- authnet-test
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_USER: userauth-test
MYSQL_PASSWORD: userauth-test
MYSQL_DATABASE: userauth-test
volumes:
- db-userauth-test-data:/var/lib/mysql
restart: always
..
db-notes-test:
build: ../frontnet
container_name: db-notes-test
networks:
- frontnet-test
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_USER: notes-test
MYSQL_PASSWORD: notes12345
MYSQL_DATABASE: notes-test
volumes:
- db-notes-test-data:/var/lib/mysql
restart: always
这与之前相同,但容器和网络名称后添加了 -test。
这是第一个必须做出的更改,在 test-compose/docker-compose.yml 中的每个容器和网络名称后添加 -test。我们将进行的所有测试都将运行在完全独立的容器、主机名和网络中,与开发实例的完全不同。
这个更改将影响 notes-test 和 userauth-test 服务,因为数据库服务器的主机名现在是 db-auth-test 和 db-notest-test。有几个环境变量或配置文件需要更新。
另一个考虑因素是配置服务所需的环境变量。之前,我们在 Dockerfile 中定义了所有环境变量。重用这些 Dockerfile 非常有用,因为我们知道我们正在测试与生产中使用的相同的部署。但我们需要调整配置设置以匹配测试基础设施。
这里显示的数据库配置是一个示例。我们使用相同的 Dockerfile,但我们还在 test-compose/docker-compose.yml 中定义了环境变量。正如你所期望的,这会覆盖 Dockerfile 中的环境变量,使用这里设置的值:
userauth-test:
build: ../users
container_name: userauth-test
depends_on:
- db-userauth-test
networks:
- authnet-test
- frontnet-test
environment:
DEBUG: ""
NODE_ENV: "test"
SEQUELIZE_CONNECT: "sequelize-docker-test-mysql.yaml"
HOST_USERS_TEST: "localhost"
restart: always
volumes:
- ./reports-userauth:/reports
..
notes-test:
build: ../notes
container_name: notes-test
depends_on:
- db-notes-test
networks:
- frontnet-test
ports:
- "3000:3000"
restart: always
environment:
NODE_ENV: "test"
SEQUELIZE_CONNECT: "test/sequelize-mysql.yaml"
USER_SERVICE_URL: "http://userauth-test:3333"
volumes:
- ./reports-notes:/reports
...
networks:
frontnet-test:
driver: bridge
authnet-test:
driver: bridge
volumes:
db-userauth-test-data:
db-notes-test-data:
再次,我们将容器和网络名称更改为添加 -test。我们将一些环境变量从 Dockerfile 移动到 test-compose/docker-compose.yml。最后,我们在容器内挂载了一些数据卷。
另一件事是要设置目录来存储测试代码。在 Node.js 项目中,一个常见的做法是将测试代码放在与应用程序代码相同的目录中。在本章的早期,我们就是这样做的,在notes/test目录中实现了一个小的测试套件。目前,notes/Dockerfile没有将这个目录复制到容器中。测试代码必须存在于容器中才能执行测试。另一个问题是,不部署测试代码在生产环境中是有帮助的。
我们可以确保test-compose/docker-compose.yml将notes/test挂载到容器中:
notes-test:
...
volumes:
- ./reports-notes:/reports
- ../notes/test:/notesapp/test
这让我们得到了两者的最佳结合。
-
测试代码位于
notes/test中,这是它应该所在的位置 -
测试代码不会被复制到生产容器中
-
在测试模式下,
test目录出现在它应该出现的地方
我们还有一些配置文件剩余,用于设置Sequelize数据库连接。
对于userauth-test容器,SEQUELIZE_CONNECT变量现在指向一个不存在的配置文件,这是由于在user/Dockerfile中覆盖了变量。让我们创建该文件,命名为test-compose/userauth/sequelize-docker-mysql.yaml,包含以下内容:
dbname: userauth-test
username: userauth-test
password: userauth-test
params:
host: db-userauth-test
port: 3306
dialect: mysql
这些值与传递给db-userauth-test容器的变量相匹配。然后我们必须确保此配置文件被挂载到userauth-test容器中:
userauth-test:
...
volumes:
- ./reports-userauth:/reports
- ./userauth/sequelize-docker-test-mysql.yaml:/userauth/sequelize-
docker-test-mysql.yaml
对于notes-test,我们有一个配置文件,test/sequelize-mysql.yaml,需要放入notes/test目录:
dbname: notes-test
username: notes-test
password: notes12345
params:
host: db-notes-test
port: 3306
dialect: mysql
logging: false
再次,这匹配了db-notes-test中的配置变量。在test-compose/docker-compose.yml中,我们将该文件挂载到容器中。
在 Docker Compose 下执行测试
现在,我们准备好在容器内执行一些测试了。我们使用 Docker Compose 文件描述了 Notes 应用程序的测试环境,使用与生产环境相同的架构。测试脚本和配置已注入到容器中。问题是,我们如何自动化测试执行?
我们将使用的技术是运行一个 shell 脚本,并使用docker exec -it来执行命令以运行测试脚本。这有点自动化,经过更多的工作,它可以完全自动化。
在test-compose中,让我们创建一个名为run.sh的 shell 脚本(在 Windows 上为run.ps1):
docker-compose stop
docker-compose build
docker-compose up --force-recreate -d
docker ps
docker network ls
sleep 20
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm install
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-memory
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-fs
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-levelup
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-sqlite3
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-sequelize-sqlite
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-sequelize-mysql
docker-compose stop
在持续集成系统(如 Jenkins)中运行测试是一种常见的做法。持续集成系统会自动对软件产品进行构建或测试。构建和测试结果数据用于自动生成状态页面。访问jenkins.io/index.html,这是一个 Jenkins 作业的良好起点。
这是我们构建容器的第一步,然后是启动它们。脚本暂停几秒钟,以便容器有时间完全实例化自己。
后续的所有命令都遵循一个特定的模式,这是需要理解的重要点。由于--workdir选项,命令在/notesapp/test目录下执行。请记住,该目录是通过 Docker Compose 文件注入到容器中的。
使用-e DEBUG=我们已禁用DEBUG选项。如果设置了这些选项,测试结果中会有过多的不必要输出,所以使用此选项确保不会出现调试输出。
现在您已经了解了选项,您可以看到后续的所有命令都是在test目录下使用该目录中的package.json执行的。它首先运行npm install,然后运行测试矩阵中的每个场景。
要运行测试,只需输入:
$ sh -x run.sh
很好,我们已经将大部分测试矩阵自动化,并且处理得相当好。测试矩阵中有一个明显的漏洞,填补这个漏洞将让我们看到如何在 Docker 下设置 MongoDB。
在 Docker 下设置 MongoDB 和测试 Notes 对 MongoDB
在第七章数据存储和检索中,我们为 Notes 开发了 MongoDB 支持,并且从那时起我们一直专注于Sequelize。为了弥补这个小小的不足,让我们确保至少测试我们的 MongoDB 支持。在 MongoDB 上进行测试只需定义一个 MongoDB 数据库容器和一点配置。
访问hub.docker.com/_/mongo/获取官方 MongoDB 容器。您将能够将其修改为允许部署运行在 MongoDB 上的 Notes 应用程序。
将以下内容添加到test-compose/docker-compose.yml:
db-notes-mongo-test:
image: mongo:3.6-jessie
container_name: db-notes-mongo-test
networks:
- frontnet-test
volumes:
- ./db-notes-mongo:/data/db
这就是添加 MongoDB 容器到 Docker Compose 文件所需的所有内容。我们已经将其连接到frontnet,以便notes(notes-test)容器可以访问该服务。
然后在notes/test/package.json中添加一行以方便在 MongoDB 上运行测试:
"test-notes-mongodb": "MONGO_URL=mongodb://db-notes-mongo-test/ MONGO_DBNAME=chap11-test NOTES_MODEL=mongodb mocha --no-timeouts test-model"
只需将 MongoDB 容器添加到frontnet-test,数据库就可以通过这里显示的 URL 访问。因此,现在运行使用 Notes MongoDB 模型的测试套件变得很简单。
在对 MongoDB 测试套件进行测试时,使用--no-timeouts选项是必要的,以避免出现虚假错误。此选项指示 Mocha 不要检查测试用例执行是否过长。
最终的要求是在run.sh(或 Windows 上的run.ps1)中添加这一行:
docker exec -it --workdir /notesapp/test -e DEBUG= notes-test npm run test-notes-mongodb
这样,就确保了 MongoDB 在每次测试运行时都会被测试。
我们现在可以向经理报告最终的测试结果矩阵:
-
models-fs:通过 -
models-memory:通过 -
models-levelup:通过 -
models-sqlite3:两个失败,现已修复,通过 -
models-sequelize与 SQLite3:通过 -
models-sequelize与 MySQL:通过 -
models-mongodb:通过
经理会告诉您“做得好”,然后记住模型只是 Notes 应用程序的一部分。我们留下了两个区域完全未测试:
-
用户身份验证服务的 REST API
-
用户界面功能测试
让我们继续进行这些测试区域。
测试 REST 后端服务
现在是时候将注意力转向用户认证服务了。我们提到了对这个服务的测试,说我们稍后会讨论。我们开发了一些临时测试脚本,这些脚本一直很有用。但现在“稍后”就是现在,是时候开始进行一些真正的测试了。
关于测试认证服务应该使用哪个工具,这是一个问题。Mocha 在组织一系列测试用例方面做得很好,我们应该在这里重用它。但我们必须测试的是一个 REST 服务。这个服务的客户,即笔记应用,通过 REST API 使用它,这为我们提供了一个完美的理由在 REST 接口上进行测试。我们使用的临时脚本使用了 SuperAgent 库来简化 REST API 调用。碰巧有一个配套库,SuperTest,它是专门用于 REST API 测试的。在这里阅读其文档:www.npmjs.com/package/supertest。
我们已经创建了 test-compose/userauth 目录。在该目录中,创建一个名为 test.js 的文件:
'use strict';
const assert = require('chai').assert;
const request = require('supertest')(process.env.URL_USERS_TEST);
const util = require('util');
const url = require('url');
const URL = url.URL;
const authUser = 'them';
const authKey = 'D4ED43C0-8BD6-4FE2-B358-7C0E230D11EF';
describe("Users Test", function() {
... Test code follows
});
这设置了 Mocha 和 SuperTest 客户端。URL_USERS_TEST 环境变量指定了运行测试的服务器的基准 URL。鉴于我们在本书中早期使用的配置,你几乎肯定会使用 http://localhost:3333。SuperTest 与 SuperAgent 的初始化方式略有不同。SuperTest 模块暴露了一个函数,我们使用 URL_USERS_TEST 环境变量调用它,然后在整个脚本中使用那个 request 对象来发起 REST API 请求。
这个变量已经在 test-compose/docker-compose.yml 中设置,并包含了所需值。另一个重要的事情是一对变量,用于存储认证用户 ID 和密钥:
beforeEach(async function() {
await request.post('/create-user')
.send({
username: "me", password: "w0rd", provider: "local",
familyName: "Einarrsdottir", givenName: "Ashildr",
middleName: "",
emails: [], photos: []
})
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
});
afterEach(async function() {
await request.delete('/destroy/me')
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
});
如果你还记得,beforeEach 函数在每次测试用例之前立即运行,而 afterEach 在之后运行。这些函数使用 REST API 在运行测试之前创建我们的测试用户,然后在之后销毁测试用户。这样我们的测试就可以假设这个用户将存在:
describe("List user", function() {
it("list created users", async function() {
const res = await request.get('/list')
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
assert.exists(res.body);
assert.isArray(res.body);
assert.lengthOf(res.body, 1);
assert.deepEqual(res.body[0], {
username: "me", id: "me", provider: "local",
familyName: "Einarrsdottir", givenName: "Ashildr",
middleName: "",
emails: [], photos: []
});
});
});
现在,我们可以转向测试一些 API 方法,例如 /list 操作。
我们已经在 beforeEach 方法中保证了账户的存在,所以 /list 应该会给我们一个包含一个条目的数组。
这遵循了使用 Mocha 测试 REST API 方法的一般模式。首先,我们使用 SuperTest 的 request 对象调用 API 方法,并 await 其结果。一旦我们得到结果,我们使用 assert 方法来验证它是否符合预期:
describe("find user", function() {
it("find created users", async function() {
const res = await request.get('/find/me')
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
assert.exists(res.body);
assert.isObject(res.body);
assert.deepEqual(res.body, {
username: "me", id: "me", provider: "local",
familyName: "Einarrsdottir", givenName: "Ashildr",
middleName: "",
emails: [], photos: []
});
});
it("fail to find non-existent users", async function() {
var res;
try {
res = await request.get('/find/nonExistentUser')
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
} catch(e) {
return; // Test is okay in this case
}
assert.exists(res.body);
assert.isObject(res.body);
assert.deepEqual(res.body, {});
});
});
我们正在以两种方式检查 /find 操作:
-
寻找我们已知存在的账户——如果用户账户未找到,则表示失败
-
寻找我们知道不存在的账户——如果我们收到错误或空对象以外的任何东西,则表示失败
添加这个测试用例:
describe("delete user", function() {
it("delete nonexistent users", async function() {
var res;
try {
res = await request.delete('/destroy/nonExistentUser')
.set('Content-Type', 'application/json')
.set('Acccept', 'application/json')
.auth(authUser, authKey);
} catch(e) {
return; // Test is okay in this case
}
assert.exists(res);
assert.exists(res.error);
assert.notEqual(res.status, 200);
});
});
最后,我们应该检查 /destroy 操作。我们已经在 afterEach 方法中检查了这个操作,其中我们 destroy 一个已知的用户账户。我们还需要执行负测试并验证其行为与一个我们知道不存在的账户的行为。
所需的行为是抛出一个错误,或者结果显示一个表示错误的 HTTP 状态。实际上,当前的认证服务器代码会返回一个 500 状态码和一些其他信息。
在 test-compose/docker-compose.yml 中,我们需要将此脚本 test.js 注入到 userauth-test 容器中。我们在这里添加它:
userauth-test:
...
volumes:
- ./reports-userauth:/reports
- ./userauth/sequelize-docker-test-mysql.yaml:/userauth/sequelize-docker-test-mysql.yaml
- ./userauth/test.js:/userauth/test.js
我们有一个测试脚本,并且已经将其注入到所需的容器中(userauth-test)。下一步是自动化运行这个测试。一种方法是将此添加到 run.sh(在 Windows 上称为 run.ps1)中:
docker exec -it -e DEBUG= userauth-test npm install supertest mocha chai
docker exec -it -e DEBUG= userauth-test ./node_modules/.bin/mocha test.js
现在,如果你运行 run.sh 测试脚本,你会看到所需的包被安装,然后执行这个测试套件。
自动化测试结果报告
我们有自动化测试执行真是太酷了,Mocha 通过所有那些勾选标记使测试结果看起来很棒。如果管理层想要一个随时间变化的测试失败趋势图呢?或者可能有无数的理由将测试结果作为数据而不是在控制台上友好的打印输出。
Mocha 使用所谓的报告器来报告测试结果。Mocha 报告器是一个模块,以它支持的任何格式打印数据。相关信息可以在 Mocha 网站上找到:mochajs.org/#reporters。
你可以这样找到当前可用的 reporters 列表:
# mocha --reporters
dot - dot matrix
doc - html documentation
spec - hierarchical spec list
json - single json object
progress - progress bar
list - spec-style listing
tap - test-anything-protocol
...
然后,你可以使用特定的 reporter 如下所示:
# mocha --reporter tap test
1..4
ok 1 Users Test List user list created users
ok 2 Users Test find user find created users
ok 3 Users Test find user fail to find non-existent users
ok 4 Users Test delete user delete nonexistent users
# tests 4
# pass 4
# fail 0
测试任何协议(TAP)是一个广泛使用的测试结果格式,增加了找到高级报告工具的可能性。显然,下一步是将结果保存到某个文件中,在将主机目录挂载到容器之后。
使用 Puppeteer 进行前端无头浏览器测试
测试中的一个大成本领域是手动用户界面测试。因此,已经开发了一系列工具来自动化在 HTTP 层面上运行测试。例如,Selenium 是一个流行的用 Java 实现的工具。在 Node.js 世界中,我们有几个有趣的选择。Chai 的 chai-http 插件允许我们在 Chai 环境中与 Notes 应用程序进行 HTTP 层面的交互,同时保持熟悉的环境。
然而,对于本节,我们将使用 Puppeteer (github.com/GoogleChrome/puppeteer)。这个工具是一个高级 Node.js 模块,用于通过 DevTools 协议控制无头 Chrome 或 Chromium 浏览器。该协议允许工具对 Chromium 或 Chrome 进行仪器化、检查、调试和性能分析。
Puppeteer 旨在成为一个通用的测试自动化工具,并且为此目的拥有强大的功能集。由于 Puppeteer 很容易制作网页截图,它也可以用于截图服务。
由于 Puppeteer 控制的是真实的网络浏览器,因此你的用户界面测试将非常接近于实际浏览器测试,无需雇佣人类来完成这项工作。因为它使用的是无头版本的 Chrome,所以屏幕上不会显示任何可见的浏览器窗口,测试可以在后台运行。然而,这个吸引人的故事的缺点是 Puppeteer 只能在 Chrome 上工作。这意味着针对 Chrome 的自动化测试并不能测试你的应用程序在其他浏览器上的表现,例如 Opera 或 Firefox。
设置 Puppeteer
让我们先设置目录并安装包:
$ mkdir test-compose/notesui
$ cd test-compose/notesui
$ npm init
... answer the questions
$ npm install puppeteer@1.1.x mocha@5.x chai@4.1.x --save
在安装过程中,你会看到 Puppeteer 会像这样下载 Chromium:
Downloading Chromium r497674 - 92.5 Mb [====================] 100% 0.0s
puppeteer 模块将根据需要启动那个 Chromium 实例,将其作为后台进程管理,并使用 DevTools 协议与之通信。
在我们即将编写的脚本中,我们需要一个用户账户,我们可以用它来登录并执行一些操作。幸运的是,我们已经有了一个设置测试账户的脚本。在 users/package.json 文件中,在 scripts 部分添加以下行:
"setupuser": "PORT=3333 node users-add",
我们即将编写这个测试脚本,但让我们先完成设置,最后一步是将以下行添加到 run.sh 文件中:
docker exec -it userauth-test npm run setupuser
docker exec -it notesapp-test npm run test-docker-ui
当执行时,这两行确保测试用户被设置,然后运行用户界面测试。
提高 Notes UI 的可测试性
当 Notes 应用程序在浏览器中显示良好时,我们如何编写测试软件来区分不同的页面?关键要求是测试脚本需要检查页面,确定显示的是哪个页面,并读取页面上的数据。这意味着每个 HTML 元素都必须能够通过 CSS 选择器轻松访问。
在开发 Notes 应用程序时,我们忘记了这一点,而 软件质量工程 (SQE) 经理要求我们提供帮助。这关系到测试预算,而 SQE 团队可以自动化的测试越多,预算就会越紧张。
所需的只是给 HTML 元素添加一些 id 或 class 属性来提高可测试性。有了几个标识符,并承诺维护这些标识符,SQE 团队可以编写可重复的测试脚本以验证应用程序。
在 notes/partials/header.hbs 文件中,修改以下行:
...
<a id="btnGoHome" class="navbar-brand" href='/'>
...
{{#if user}}
...
<a class="nav-item nav-link btn btn-dark col-auto" id="btnLogout" href="/users/logout">...</a>
<a class="nav-item nav-link btn btn-dark col-auto" id="btnAddNote" href='/notes/add'>...</a>
{{else}}
...
<a class="nav-item nav-link btn btn-dark col-auto" id="btnloginlocal" href="/users/login">..</a>
<a class="nav-item nav-link btn btn-dark col-auto"
id="btnLoginTwitter" href="/users/auth/twitter">...</a>
...
{{/if}}
...
在 notes/views/index.hbs 文件中,进行以下修改:
<div id="notesHomePage" class="container-fluid">
<div class="row">
<div id="notetitles" class="col-12 btn-group-vertical" role="group">
{{#each notelist}}
<a id="{{key}}" class="btn btn-lg btn-block btn-outline-dark"
href="/notes/view?key={{ key }}">...</a>
{{/each}}
</div>
</div>
</div>
在 notes/views/login.hbs 文件中,进行以下修改:
<div id="notesLoginPage" class="container-fluid">
...
<form id="notesLoginForm" method='POST' action='/users/login'>
...
<button type="submit" id="formLoginBtn" class="btn btn-default">Submit</button>
</form>
...
</div>
在 notes/views/notedestroy.hbs 文件中,进行以下修改:
<form id="formDestroyNote" method='POST' action='/notes/destroy/confirm'>
...
<button id="btnConfirmDeleteNote" type="submit" value='DELETE'
class="btn btn-outline-dark">DELETE</button>
...
</form>
在 notes/views/noteedit.hbs 文件中,进行以下修改:
<form id="formAddEditNote" method='POST' action='/notes/save'>
...
<button id='btnSave' type="submit" class="btn btn-default">Submit</button>
...
</form>
在 notes/views/noteview.hbs 文件中,进行以下修改:
<div id="noteView" class="container-fluid">
...
<p id="showKey">Key: {{ notekey }}</p>
...
<a id="btnDestroyNote" class="btn btn-outline-dark"
href="/notes/destroy?key={{notekey}}" role="button"> ... </a>
<a id="btnEditNote" class="btn btn-outline-dark"
href="/notes/edit?key={{notekey}}" role="button"> ... </a>
<button id="btnComment" type="button" class="btn btn-outline-dark"
data-toggle="modal" data-target="#notes-comment-modal"> ... </button>
...
</div>
我们所做的是在模板中选定的元素上添加了 id= 属性。现在我们可以轻松地编写 CSS 选择器来定位任何元素。工程团队也可以开始在 UI 代码中使用这些选择器。
Notes 的 Puppeteer 测试脚本
在 test-compose/notesui 目录中,创建一个名为 uitest.js 的文件,包含以下内容:
const puppeteer = require('puppeteer');
const assert = require('chai').assert;
const util = require('util');
const { URL } = require('url');
describe('Notes', function() {
this.timeout(10000);
let browser;
let page;
before(async function() {
browser = await puppeteer.launch({ slomo: 500 });
page = await browser.newPage();
await page.goto(process.env.NOTES_HOME_URL);
});
after(async function() {
await page.close();
await browser.close();
});
});
这是 Mocha 测试套件的开始。在 before 函数中,我们通过启动 Puppeteer 实例、创建一个新的 Page 对象,并指示该 Page 访问笔记应用程序的主页来设置 Puppeteer。该 URL 使用命名环境变量传递。
首先考虑我们可能想要使用 Notes 应用程序验证的场景是有用的:
-
登录到 Notes 应用程序
-
向应用程序添加笔记
-
查看附加说明
-
删除添加的笔记
-
注销
-
等等
下面是登录场景实现的代码:
describe('Login', function() {
before(async function() { ... });
it('should click on login button', async function() {
const btnLogin = await page.waitForSelector('#btnloginlocal');
await btnLogin.click();
});
it('should fill in login form', async function() {
const loginForm = await page.waitForSelector('#notesLoginPage
#notesLoginForm');
await page.type('#notesLoginForm #username', "me");
await page.type('#notesLoginForm #password', "w0rd");
await page.click('#formLoginBtn');
});
it('should return to home page', async function() {
const home = await page.waitForSelector('#notesHomePage');
const btnLogout = await page.waitForSelector('#btnLogout');
const btnAddNote = await page.$('#btnAddNote');
assert.exists(btnAddNote);
});
after(async function() { ... });
});
此测试序列处理登录场景。它展示了几个 Puppeteer API 方法。完整 API 文档位于 github.com/GoogleChrome/puppeteer/blob/master/docs/api.md。Page 对象封装了 Chrome/Chromium 中浏览器标签页的等效功能。
waitForSelector 函数如其名所示——它等待匹配 CSS 选择器的 HTML 元素出现,并且它将在一个或多个页面刷新中等待。此函数有几个变体,允许等待多种类型的事物。此函数返回一个 Promise,这使得在我们的测试代码中使用异步函数变得值得。Promise 将解析为 ElementHandle,这是一个围绕 HTML 元素的包装器,或者抛出一个异常,这会方便地使测试失败。
命名的元素 #btnloginlocal 在 partials/header.hbs 中,只有在用户未登录时才会显示。因此,我们可以确定浏览器当前正在显示 Notes 页面,并且未登录。
click 方法如其所暗示的那样,在引用的 HTML 元素上触发鼠标按钮点击。如果您想模拟触摸,例如用于移动设备,有一个 tap 方法用于此目的。
测试序列的下一个阶段从点击那里开始。浏览器应该已经跳转到登录页面,因此此 CSS 选择器应该有效:#notesLoginPage #notesLoginForm。我们接下来要做的是在相应的表单元素中输入测试用户的用户 ID 和密码,然后点击登录按钮。
下一个测试阶段从那里开始,浏览器应该根据此 CSS 选择器位于主页上:#notesHomePage。如果我们成功登录,页面应该有注销(#btnLogout)和添加笔记按钮(#btnAddNote)。
在这种情况下,我们使用了一个不同的函数 $ 来检查添加笔记按钮是否存在。与 wait 函数不同,$ 仅查询当前页面而不等待。如果命名的 CSS 选择器不在当前页面中,它将简单地返回 null 而不是抛出异常。因此,为了确定元素是否存在,我们使用 assert.exists 而不是依赖于抛出的异常。
运行登录场景
现在我们已经输入了一个测试场景,让我们试一试。在一个窗口中,启动 Notes 测试基础设施:
$ cd test-compose
$ docker-compose up --force-rebuild
然后在另一个窗口中:
$ docker exec -it userauth bash
userauth# PORT=3333 node ./users-add.js
userauth# exit
$ cd test-compare/notesui
$ NOTES_HOME_URL=http://localhost:3000 mocha --no-timeouts uitest.js
Notes
Login
√ should click on login button
√ should fill in login form (72ms)
√ should return to home page (1493ms)
3 passing (3s)
NOTES_HOME_URL 变量是脚本查找以指导 Chromium 浏览器使用笔记应用程序的内容。为了运行测试,我们应该使用 Docker Compose 启动测试基础设施,并确保测试用户已安装到用户数据库中。
添加备注的场景
将以下内容添加到 uitest.js:
describe('Add Note', function() {
// before(async function() { ... });
it('should see Add Note button', async function() {
const btnAddNote = await page.waitForSelector('#btnAddNote');
await btnAddNote.click();
});
it('should fill in Add Note form', async function() {
const formAddEditNote = await
page.waitForSelector('#formAddEditNote');
await page.type('#notekey', 'key42');
await page.type('#title', 'Hello, world!');
await page.type('#body', 'Lorem ipsum dolor');
await page.click('#btnSave');
});
it('should view note', async function() {
await page.waitForSelector('#noteView');
const shownKey = await page.$eval('#showKey', el =>
el.innerText);
assert.exists(shownKey);
assert.isString(shownKey);
assert.include(shownKey, 'key42');
const shownTitle = await page.$eval('#notetitle', el =>
el.innerText);
assert.exists(shownTitle);
assert.isString(shownTitle);
assert.include(shownTitle, 'Hello, world!');
const shownBody = await page.$eval('#notebody', el =>
el.innerText);
assert.exists(shownBody);
assert.isString(shownBody);
assert.include(shownBody, 'Lorem ipsum dolor');
});
it('should go to home page', async function() {
await page.waitForSelector('#btnGoHome');
await page.goto(process.env.NOTES_HOME_URL);
// await page.click('#btnGoHome');
await page.waitForSelector('#notesHomePage');
const titles = await page.$('#notetitles');
assert.exists(titles);
const key42 = await page.$('#key42');
assert.exists(key42);
const btnLogout = await page.$('#btnLogout');
assert.exists(btnLogout);
const btnAddNote = await page.$('#btnAddNote');
assert.exists(btnAddNote);
});
// after(async function() { ... });
});
这是一个更复杂的场景,其中我们:
-
点击“添加备注”按钮
-
等待备注编辑屏幕出现
-
填写备注的文本并点击“保存”按钮
-
验证备注查看页面以确保正确无误
-
验证主页以确保正确无误。
这大部分是使用与之前相同的 Puppeteer 函数,但增加了一些功能。
$eval 函数查找匹配 CSS 选择器的元素,并在该元素上调用回调函数。如果没有找到元素,则会抛出错误。在此处使用时,我们从屏幕上的某些元素中检索文本,并验证它是否与测试输入的备注匹配。这是一个添加和检索备注的端到端测试。
下一个区别是使用 goto 而不是点击 #btnGoHome。
当您向测试脚本添加测试场景时,您会发现 Puppeteer 很容易产生虚假的超时,或者登录过程神秘地不起作用,或者出现其他虚假错误。
我们不会详细说明剩余的场景,而是在下一节中讨论如何缓解此类问题。但首先我们需要证明即使我们必须运行测试 10 次才能得到这个结果,该场景仍然有效:
$ NOTES_HOME_URL=http://localhost:3000 ./node_modules/.bin/mocha --no-timeouts uitest3.js
Notes
Login
√ should click on login button (50ms)
√ should fill in login form (160ms)
√ should return to home page (281ms)
Add Note
√ should see Add Note button
√ should fill in Add Note form (1843ms)
√ should view note
√ should go to home page (871ms)
7 passing (5s)
缓解/预防 Puppeteer 脚本中的虚假测试错误
目标是完全自动化测试运行,以避免需要雇佣人类来监视测试执行并花费时间重新运行测试,因为虚假错误。为此,测试需要在没有任何虚假错误的情况下可重复。Puppeteer 是一个复杂的系统——有一个 Node.js 模块与在后台运行的无头 Chromium 实例进行通信——并且似乎时间问题很容易导致虚假错误。
配置超时
Mocha 和 Puppeteer 都允许您设置超时值,并且一个较长的超时值可以避免触发错误,如果某些操作需要较长时间运行。在测试套件的顶部,我们使用了这个 Mocha 函数:
this.timeout(10000);
这为每个测试案例提供了 10 秒的时间。如果您想使用更长的超时,请增加该数值。
puppeteer.launch 函数可以在其选项对象中接受超时值。默认情况下,Puppeteer 在大多数操作上使用 30 秒的超时,并且它们都带有选项对象,可以更改该超时期间。在这种情况下,我们添加了 slowMo 选项来减慢浏览器上的操作。
跟踪页面和 Puppeteer 实例上的事件
另一个有用的策略是生成发生事件的跟踪,这样您就可以进行推理。插入 console.log 语句很繁琐,并且会使您的代码看起来有些丑陋。Puppeteer 提供了一些方法来跟踪操作,并动态地关闭跟踪。
在 uitest.js 中,添加以下代码:
function frameEvent(evtname, frame) {
console.log(`${evtname} ${frame.url()} ${frame.title()}`);
}
function ignoreURL(url) {
if (url.match(/\/assets\//) === null
&& url.match(/\/socket.io\//) === null
&& url.match(/fonts.gstatic.com/) === null
&& url.match(/fonts.googleapis.com/) === null) {
return false;
} else {
return true;
}
}
...
before(async function() {
browser = await puppeteer.launch({ slomo: 500 });
page = await browser.newPage();
page.on('console', msg => {
console.log(`${msg.type()} ${msg.text()} ${msg.args().join(' ')}`);
});
page.on('error', err => {
console.error(`page ERROR ${err.stack}`);
});
page.on('pageerror', err => {
console.error(`page PAGEERROR ${err.stack}`);
});
page.on('request', req => {
if (ignoreURL(req.url())) return;
console.log(`page request ${req.method()} ${req.url()}`);
});
page.on('response', async (res) => {
if (ignoreURL(res.url())) return;
console.log(`page response ${res.status()} ${res.url()}`);
});
page.on('frameattached', async (frame) => frameEvent('frameattached', await frame));
page.on('framedetached', async (frame) => frameEvent('framedetached', await frame));
page.on('framenavigated', async (frame) => frameEvent('framenavigated', await frame));
await page.goto(process.env.NOTES_HOME_URL);
});
...
即,页面对象提供了几个事件监听器,我们可以输出有关各种事件的详细信息,包括 HTTP 请求和响应。我们甚至可以打印出响应的 HTML 文本。ignoreURL 函数让我们抑制一些选定的 URL,这样我们不会收到大量不重要的请求和响应。
您可以使用 Puppeteer 的 DEBUG 环境变量来跟踪 Puppeteer 本身。更多信息请参阅 README:github.com/GoogleChrome/puppeteer
插入暂停
在某些点上插入较长的暂停,以便浏览器有时间执行某些操作是有用的。尝试这个函数:
function waitFor(timeToWait) {
return new Promise(resolve => {
setTimeout(() => { resolve(true); }, timeToWait);
});
};
这是我们使用 Promises 实现类似 sleep 函数的方法。使用 setTimeOut 以这种方式,结合超时值,只是简单地延迟指定的毫秒数。
要使用此函数,只需将其插入到测试场景中:
await waitFor(3000);
另一种变体是等待浏览器中内容完全渲染。例如,您可能已经看到在左上角的 Home 图标完全渲染之前有一个暂停。这个暂停可能会引起虚假的错误,而这个函数可以等待该按钮完全渲染:
async function waitForBtnGoHome() {
return page.waitForSelector('#btnGoHome');
}
要使用它:
await waitForBtnGoHome();
如果您不想维护这个额外的函数,将 waitForSelector 调用添加到您的测试用例中也很容易。
避免 WebSocket 冲突
Puppeteer 可能会抛出一个错误,Cannot find context with specified id undefined。根据 Puppeteer 问题队列中的一个问题,这可能是由于 Puppeteer 和 WebSocket 之间的意外交互引起的:github.com/GoogleChrome/puppeteer/issues/1325 这个问题反过来会影响笔记应用程序中的 Socket.IO 支持,因此,在测试运行期间禁用 Socket.IO 支持可能是有用的。
允许禁用 Socket.IO 相对简单。在 app.mjs 中,添加这个导出的函数:
export function enableSocketio() {
var ret = true;
const env = process.env.NOTES_DISABLE_SOCKETIO;
if (!env || env !== 'true') {
ret = true;
}
return ret;
}
这个函数会查找环境变量以使函数返回 true 或 false。
在 routes/index.mjs 和 routes/notes.mjs 中,添加以下行:
import { enableSocketio, sessionCookieName } from '../app';
我们这样做是为了导入前面的函数。这也展示了我们从 ES6 模块中获得的一些灵活性,因为我们可以只导入所需的函数。
在 routes/index.mjs 和 routes/notes.mjs 中,对于每个调用 res.render 以发送结果的路由函数,使用 enableSocketio 函数如下:
res.render('*view-name*', {
...
enableSocketio: enableSocketio()
});
因此,我们已经导入了该函数,并且对于每个视图,我们将 enableSocketio 作为数据传递给视图模板。
在 views/index.hbs 和 views/noteview.hbs 中,我们有一个 JavaScript 代码段来实现基于 SocketIO 的半实时功能。像这样包围每个这样的部分:
{{#if enableSocketio}}
... JavaScript code for SocketIO support
{{/if}}
通过消除客户端的 SocketIO 代码,我们确保用户界面不会打开到 SocketIO 服务的连接。这个练习的目的是避免使用 WebSockets,以避免与 Puppeteer 相关的问题。
类似地,在 views/noteview.hbs 中,可以这样禁用评论按钮:
{{#if enableSocketio}}
<button id="btnComment" type="button" class="btn btn-outline-dark"
data-toggle="modal" data-target="#notes-comment-modal">Comment</button>
{{/if}}
最后一步是在 Docker Compose 文件中设置环境变量,NOTES_DISABLE_SOCKETIO。
捕获截图
Puppeteer 的核心功能之一是捕获截图,可以是 PNG 或 PDF 文件。在我们的测试脚本中,我们可以捕获截图以跟踪测试过程中任何给定时间屏幕上的内容。例如,如果登录场景意外失败,我们可以在截图看到这一点:
await page.screenshot({
type: 'png',
path: `./screen/login-01-start.png`
});
在你的测试脚本中添加类似这样的代码片段。这里显示的文件名遵循一种约定,其中第一个部分命名测试场景,数字是测试场景内的序列号,最后一个是测试场景内的步骤描述。
捕获截图也提供了另一阶段的验证。你可能还想对你的应用程序进行视觉验证。pixelmatch 模块可以比较两个 PNG 文件,因此可以在测试运行期间维护一组所谓的黄金图像进行比较。
有关使用 Puppeteer 的示例,请参阅:meowni.ca/posts/2017-puppeteer-tests/。
摘要
在本章中,我们涵盖了大量的内容,探讨了三个不同的测试领域:单元测试、REST API 测试和 UI 功能测试。确保应用程序经过良好测试是通往软件成功之路的重要一步。一个不遵循良好测试实践的团队往往会陷入修复回归问题的困境。
我们讨论了仅使用 assert 模块进行测试的潜在简单性。虽然测试框架,如 Mocha,提供了许多功能,但我们可以通过简单的脚本走得很远。
测试框架,例如 Mocha,有其存在的理由,即使只是为了规范我们的测试用例,并生成测试结果报告。我们使用了 Mocha 和 Chai 来完成这项工作,并且这些工具非常成功。我们甚至通过一个小型的测试套件发现了几处错误。
在开始单元测试的道路上,一个设计考虑因素是模拟依赖项。但并不是总是值得花费我们的时间用模拟版本替换每个依赖项。
为了减轻运行测试的管理负担,我们使用了 Docker 来自动设置和拆除测试基础设施。正如 Docker 在自动化部署笔记应用程序时很有用一样,它也有助于自动化测试基础设施的部署。
最后,我们能够在真实网络浏览器中测试笔记网络用户界面。我们不能相信单元测试会找到每一个错误;一些错误只有在网络浏览器中才会出现。即便如此,我们只是触及了 Notes 中可能测试的冰山一角。
在这本书中,我们涵盖了 Node.js 开发的各个方面,为你提供了一个强大的基础,从这里开始开发 Node.js 应用程序。
在下一章中,我们将探讨 RESTful 网络服务。
第十二章:REST – 你不知道的
在过去几年中,我们已经开始认为数据源为内容、移动设备服务源或云计算提供动力都是现代技术,如 RESTful Web 服务驱动的。每个人都谈论他们的无状态模型如何使应用程序易于扩展,以及它如何强调数据提供与数据消费之间的清晰解耦。如今,架构师们已经开始引入微服务概念,目的是通过将核心组件拆分为执行单一任务的小型独立部分来降低系统的复杂性。因此,企业级软件即将成为此类微服务的组合。这使得维护变得容易,并在需要引入新部分时允许更好的生命周期管理。不出所料,大多数微服务都是由 RESTful 框架提供的。这一事实可能会给人留下印象,认为 REST 是在上个十年中的某个时候发明的,但事实远非如此。实际上,REST 自上个世纪的最后十年起就已经存在了!
本章将带您了解 表征状态转移 (REST) 的基础,并解释 REST 如何与 HTTP 协议相结合。您将探讨在将任何 HTTP 应用程序转换为 RESTful 服务启用应用程序时必须考虑的五个关键原则。您还将了解描述 RESTful 和基于经典 简单对象访问协议 (SOAP) 的 Web 服务之间的区别。最后,您将学习如何利用现有的基础设施来造福自己。
在本章中,我们将涵盖以下主题:
-
REST 基础知识
-
使用 HTTP 的 REST
-
与经典 SOAP 基于服务相比,在描述、发现和文档化 RESTful 服务方面的基本差异
-
利用现有基础设施
REST 基础知识
这实际上发生在 1999 年,当时通过 RFC 2616 向 互联网工程任务组 (IETF; www.ietf.org/) 提交了一个评论请求:超文本传输协议-HTTP/1.1。其作者之一,罗伊·菲尔德,后来定义了一套围绕 HTTP 和 URI 标准的原则。这诞生了我们今天所知道的 REST。
这些定义在菲尔德的论文《网络软件架构的设计与架构风格》的第五章,表征状态转移 (REST) 中给出,该论文的标题为 Architectural Styles and the Design of Network-Based Software Architectures。这篇论文仍然可在 www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm 上找到。
让我们看看围绕 HTTP 和 URI 标准的关键原则,坚持这些原则将使您的 HTTP 应用程序成为一个支持 RESTful 服务的应用程序:
-
一切都是资源
-
每个资源都有一个唯一的标识符(URI)
-
资源通过标准 HTTP 方法进行操作
-
资源可以有多个表示形式
-
以无状态的方式与资源进行通信
原则 1 – 一切都是资源
要理解这个原则,必须设想通过特定的格式来表示数据,而不是通过包含大量字节的物理文件。互联网上可用的每一份数据都有一个描述它的格式,称为内容类型;例如,JPEG 图像、MPEG 视频、HTML、XML、文本文档和二进制数据都是具有以下内容类型的资源:image/jpeg、video/mpeg、text/html、text/xml 和 application/octet-stream。
原则 2 – 每个资源都有一个唯一的标识符
由于互联网包含如此多的不同资源,它们都应该可以通过 URI 访问,并且应该具有唯一的标识。此外,尽管它们的消费者更有可能是软件程序而不是普通人类,但这些 URI 可以以人类可读的格式存在。
人类可读的 URI 使数据自我描述,并简化了对其的进一步开发。这有助于您将程序中的逻辑错误风险降到最低。
这里有一些此类 URI 的示例,它们在目录应用程序中表示不同的资源:
这些人类可读的 URI 以直接的方式揭示了不同类型的资源。在先前的示例 URI 中,数据是目录中的项目,这些项目被归类为手表。第一个链接显示了该类别的所有项目。第二个链接只显示 2018 系列中的项目。接下来是一个指向项目图片的链接,然后是一个指向示例视频的链接。最后一个链接指向一个包含前一个系列项目的 ZIP 存档的资源。每个 URI 提供的媒体类型很容易识别,假设项目的数据格式是 JSON 或 XML,因此我们可以轻松地将自描述 URL 的媒体类型映射到以下之一:
-
描述项目的 JSON 或 XML 文档
-
图片
-
视频
-
二进制存档文档
原则 3 – 通过标准 HTTP 方法操作资源
本地 HTTP 协议(RFC 2616)定义了八个动作,也称为 HTTP 动词:
-
GET -
POST -
PUT -
DELETE -
HEAD -
OPTIONS -
TRACE -
CONNECT
其中的前四个在资源上下文中感觉自然,尤其是在定义数据操作的动作时。让我们将它们与相对 SQL 数据库进行比较,在 SQL 数据库中,数据操作的原生语言是 CRUD(代表 Create, Read, Update, and Delete),分别源自不同的 SQL 语句类型,INSERT,SELECT,UPDATE 和 DELETE。同样,如果您正确应用 REST 原则,HTTP 动词应按以下方式使用:
| HTTP 动词 | 动作 | HTTP 响应状态码 |
|---|---|---|
GET |
获取现有资源。 | 200 OK 如果资源存在,404 Not Found 如果不存在,以及 500 Internal Server Error 对于其他错误。 |
PUT |
更新资源。如果资源不存在,服务器可以决定使用提供的标识符创建它,或者返回适当的状态码。 | 200 OK 如果成功更新,201 Created 如果创建了新资源,404 Not found 如果要更新的资源不存在,以及 500 Internal Server Error 对于其他意外错误。 |
POST |
在服务器端生成标识符或使用客户端提供的现有标识符创建资源,或更新资源。如果此动词仅用于创建而不用于更新,则返回适当的状态码。 | 201 CREATED 如果创建了新资源,200 OK 如果资源已成功更新,409 Conflict 如果资源已存在且不允许更新,404 Not Found 如果要更新的资源不存在,以及 500 Internal Server Error 对于其他错误。 |
DELETE |
删除资源。 | 200 OK 或 204 No Content 如果资源已成功删除,404 Not Found 如果要删除的资源不存在,以及 500 Internal Server Error 对于其他错误。 |
注意,资源可能通过 POST 或 PUT HTTP 动词创建,这取决于应用程序的策略。然而,如果资源必须在客户端提供的特定 URI 下创建,并且提供标识符,那么 PUT 是适当的操作:
PUT /categories/watches/model-abc HTTP/1.1
Content-Type: text/xml
Host: www.mycatalog.com
<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
<Brand>...</Brand>
</Price></Price>
</Item>
HTTP/1.1 201 Created
Content-Type: text/xml
Location: http://www.mycatalog.com/categories/watches/model-abc
然而,在您的应用程序中,您可能希望将决定新创建资源暴露位置的任务留给后端 RESTful 服务,因此在新适当但未知或不存在的位置下创建它。
例如,在我们的示例中,我们可能希望服务器定义新创建项的标识符。在这种情况下,只需向 URL 发送 POST 动词,而不提供标识符参数。然后,服务本身负责提供新资源的唯一有效标识符,并通过响应的 Location 标头公开此 URL:
POST /categories/watches HTTP/1.1
Content-Type: text/xml
Host: www.mycatalog.com
<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
<Brand>...</Brand>
</Price></Price>
</Item>
HTTP/1.1 201 Created
Content-Type: text/xml
Location: http://www.mycatalog.com/categories/watches/model-abc
原则 4 – 资源可以有多个表示
资源的一个关键特性是它可能以不同于存储格式的格式表示。因此,它可以以不同的表示形式请求或创建。只要指定的格式得到支持,启用 REST 的端点应该使用它。在先前的示例中,我们发布了一个手表项目的 XML 表示,但如果服务器支持 JSON 格式,以下请求也是有效的:
POST /categories/watches HTTP/1.1
Content-Type: application/json
Host: www.mycatalog.com
{
"watch": {
"id": ""watch-abc"",
"brand": "...",
"price": {
"-currency": "EUR",
"#text": "100"
}
}
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://mycatalog.com/categories/watches/watch-abc
原则 5 – 以无状态方式与资源通信
通过 HTTP 请求进行的资源操作应始终被视为原子操作。对资源的所有修改都应在 HTTP 请求中独立进行。请求执行后,资源将处于最终状态;这隐含地意味着不支持部分资源更新。你应该始终发送资源的完整状态。
回到我们的目录示例,更新某个项目的价格字段意味着发送一个包含整个数据(JSON 或 XML 格式)的完整文档的 PUT 请求,其中包括更新的价格字段。仅发送更新的价格不是无状态的,因为它意味着应用程序知道资源有一个价格字段,即它知道其状态。
你的 RESTful 应用程序要实现无状态,另一个要求是在服务部署到生产环境后,传入的请求很可能由负载均衡器提供服务,确保可扩展性和高可用性。一旦通过负载均衡器暴露,保持应用程序状态在服务器端的想法就会受到损害。这并不意味着你不允许保持应用程序的状态。这仅仅意味着你应该以 RESTful 的方式保持它。例如,将部分状态保留在 URI 中,或使用 HTTP 头提供额外的状态相关数据。
你的 RESTful API 的无状态特性将调用者与服务器端的变更隔离开来。因此,调用者不需要在连续的请求中使用相同的服务器。这允许在服务器基础设施中轻松应用变更,例如添加或删除节点。
记住,保持你的 RESTful API 无状态是你的责任,因为 API 的消费者期望它们是无状态的。
现在你已经知道 REST 大约有 18 年的历史了,一个合理的问题可能是,“为什么它最近才变得如此流行?”好吧,我们作为开发者通常拒绝简单直接的方法,并且大多数时候,我们更喜欢花更多的时间将已经复杂的解决方案变得更加复杂和精致。
以经典的 SOAP Web 服务为例。它们的 WS-规范如此之多,有时定义得如此宽松,以至于为了使不同供应商的不同解决方案互操作,已经引入了一个单独的规范,即 WS-Basic Profile。它定义了额外的互操作性规则,以确保基于 SOAP 的 Web 服务中的所有 WS-规范可以协同工作。
当涉及到通过 HTTP 使用经典 Web 服务传输二进制数据时,事情变得更加复杂,因为基于 SOAP 的 Web 服务提供了不同的二进制数据传输方式。每种方式都在其他规范集中定义,例如带有附件引用的 SOAP(SwaRef)和消息传输优化机制(MTOM)。所有这些复杂性主要是由于最初的想法是将业务逻辑远程执行,而不是传输大量数据。
现实世界已经向我们表明,在数据传输方面,事情不应该那么复杂。这就是 REST 在整体画面中发挥作用的地方——通过引入资源的概念和操作它们的标准方法。
REST 目标
现在我们已经涵盖了主要的 REST 原则,是时候深入了解遵循它们时可以取得什么成果了:
-
表示形式与资源的分离
-
可见性
-
可靠性
-
可扩展性
-
性能
表示形式与资源的分离
资源只是一组信息,根据第 4 条原则,它可以有多种表示形式;然而,其状态是原子的。指定所需的媒体类型是调用者的责任,在 HTTP 请求中使用Accept头,然后服务器应用程序相应地处理表示形式,返回资源的适当内容类型以及相关的 HTTP 状态码:
-
成功时返回
HTTP 200 OK -
如果给出不支持格式或任何其他无效请求信息,则返回
HTTP 400 Bad Request -
如果请求不支持的媒体类型,则返回
HTTP 406 Not Acceptable -
在请求处理过程中发生意外情况时,返回
HTTP 500 Internal Server Error
假设我们在服务器端以 XML 格式存储项目资源。我们可以有一个 API,允许消费者以各种格式请求项目资源,例如application/xml、application/json、application/zip、application/octet-stream等。
API 本身将负责加载请求的资源,将其转换为请求的类型(例如,JSON 或 XML),然后使用 ZIP 进行压缩或直接将其刷新到 HTTP 响应输出。
调用者将使用Accept HTTP 头指定他们期望的媒体类型。因此,如果我们想以前面章节中插入的项目数据请求 XML 格式,应执行以下请求:
GET /category/watches/watch-abc HTTP/1.1
Host: my-computer-hostname
Accept: text/xml
HTTP/1.1 200 OK
Content-Type: text/xml
<?xml version="1.0" encoding="utf-8"?>
<Item category="watch">
<Brand>...</Brand>
</Price></Price>
</Item>
要以 JSON 格式请求相同的项,需要将 Accept 标头设置为 application/json:
GET /categoery/watches/watch-abc HTTP/1.1
Host: my-computer-hostname
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"watch": {
"id": ""watch-abc"",
"brand": "...",
"price": {
"-currency": "EUR",
"#text": "100"
}
}
}
可见性
REST 被设计成可见和简单。服务的可见性意味着它的各个方面都应该具有自描述性,并遵循原则 3、4 和 5 的自然 HTTP 语言。
在外部世界的上下文中,可见性意味着监控应用程序只会对 REST 服务和调用者之间的 HTTP 通信感兴趣。由于请求和响应是无状态的且是原子的,不需要更多的信息来流应用程序的行为以及了解是否出现了错误。
记住,缓存会降低你的 RESTful 应用程序的可见性,通常应该避免,除非需要为大量调用者提供资源。在这种情况下,缓存可能是一个选项,但需要在仔细评估提供过时数据可能产生的后果后才能考虑。
可靠性
在讨论可靠性之前,我们需要定义在 REST 上下文中哪些 HTTP 方法是安全的,哪些是幂等的。因此,让我们首先定义什么是安全方法和幂等方法:
-
一个 HTTP 方法被认为是安全的,如果请求时它不会修改或对资源的任何状态产生任何副作用
-
一个 HTTP 方法被认为是幂等的,如果它的响应在请求次数无论多少的情况下都保持不变,幂等请求在重复时总是返回相同的请求。
下表列出了哪些 HTTP 方法是安全的,哪些是幂等的:
| HTTP 方法 | 安全 | 幂等 |
|---|---|---|
GET |
是 | 是 |
POST |
否 | 否 |
PUT |
否 | 是 |
DELETE |
否 | 是 |
消费者应该考虑操作的安全性以及幂等特性,以确保可靠的服务。
可扩展性和性能
到目前为止,我们强调了在 RESTful 网络应用程序中拥有无状态行为的重要性。万维网(WWW)是一个巨大的宇宙,包含大量数据和众多用户,他们渴望获取这些数据。万维网的演变带来了应用程序应该容易扩展以适应其负载增加的要求。具有状态的扩展应用程序很难实现,尤其是在期望零或接近零的运营中断时间的情况下。
这就是为什么保持无状态对于任何需要扩展的应用程序来说至关重要。在最佳情况下,扩展你的应用程序可能需要你为负载均衡器添加另一块硬件,或者在云环境中添加另一个实例。不同的节点之间不需要同步,因为它们根本不需要关心状态。可扩展性完全是关于在可接受的时间内为所有客户端提供服务。其主要思想是保持应用程序运行,并防止由大量传入请求引起的 拒绝服务(DoS)。
可扩展性不应与应用程序的性能混淆。性能是通过处理单个请求所需的时间来衡量的,而不是应用程序可以处理的请求数量。Node.js 的异步非阻塞架构和事件驱动设计使其成为实现可扩展且性能良好的应用程序的合理选择。
与 WADL 一起工作
如果您熟悉 SOAP 网络服务,您可能听说过 Web 服务定义语言 (WSDL)。它是对服务接口的 XML 描述,并定义了一个调用端点的 URL。对于 SOAP 网络服务来说,有一个这样的 WSDL 定义是强制性的。
与 SOAP 网络服务类似,RESTful 服务也可以使用一种称为 WADL 的描述语言。WADL 代表 Web 应用定义语言。与用于 SOAP 网络服务的 WSDL 不同,RESTful 服务的 WADL 描述是可选的,也就是说,使用服务与其描述无关。
这里是 WADL 文件的一个示例部分,描述了我们的目录服务的 GET 操作:
<?xml version="1.0" encoding="UTF-8"?>
<application xmlns="http://wadl.dev.java.net/2009/02" xmlns:service="http://localhost:8080/catalog/" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<grammer>
<include href="items.xsd" />
<include href="error.xsd" />
</grammer>
<resources base="http://localhost:8080/catalog/categories">
<resource path="{category}">
<method name="GET">
<request>
<param name="category" type="xsd:string" style="template" />
</request>
<response status="200">
<representation mediaType="application/xml" element="service:item" />
<representation mediaType="application/json" />
</response>
<response status="404">
<representation mediaType="application/xml" element="service:item" />
</response>
</method>
</resource>
</resources>
</application>
这段 WADL 文件的摘录展示了如何描述暴露资源的应用程序。简而言之,每个资源都必须是应用程序的一部分。资源通过 base 属性提供其位置,并在方法中描述其支持的每个 HTTP 方法。此外,可以在资源和应用程序中使用可选的 doc 元素,以提供关于服务和其操作的额外文档。
尽管 WADL 是可选的,但它显著减少了发现 RESTful 服务的努力。
使用 Swagger 记录 RESTful API
在网络上公开的公共 API 应该有良好的文档,否则开发人员很难在他们的应用程序中使用它们。虽然 WADL 定义可能被认为是文档的来源,但它们解决了一个不同的问题——服务的发现。它们为机器提供服务的元数据,而不是为人类。Swagger 项目 (swagger.io/) 解决了 RESTful API 清晰文档的需求。它定义了一个 API 的元描述,几乎以人类可读的 JSON 格式。以下是一个示例 swagger.json 文件,部分描述了目录服务:
{
"swagger": "2.0",
"info": {
"title": "Catalog API Documentation",
"version": "v1"
},
"paths": {
"/categories/{id}" : {
"get": {
"operationId": "getCategoryV1",
"summary": "Get a specific category ",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "200 OK",
"examples":
{"application/json": {
"id": 1,
"name": "Watches",
"itemsCount": 550
}
}
},
"404": {"description" : "404 Not Found"},
"500": {"description": "500 Internal Server Error"}
}
}
}
},
"consumes": ["application/json"]
}
swagger.json 文件非常简单:它定义了您的 API 的名称和版本,并为每个公开的操作提供了简短的描述,并与示例有效负载很好地结合在一起。但从中获得的真正好处来自于 Swagger 的另一个子项目,称为 swagger-ui (swagger.io/swagger-ui/),它实际上将 swagger.json 中的数据优雅地渲染成交互式网页,不仅提供文档,还允许与服务交互:

我们将查看并利用swagger-ui Node.js 模块来提供本书后面将开发的 API,并带有最新的文档。
充分利用现有基础设施
开发和分发 RESTful 应用程序的最好部分是所需的基础设施已经存在,可供您使用。由于 RESTful 应用程序大量使用现有网络空间,您在开发时只需遵循 REST 原则即可。此外,任何平台都有大量的库可供选择,我确实是指任何平台。这简化了 RESTful 应用程序的开发,因此您只需选择您偏好的平台并开始开发。
摘要
在本章中,您了解了 REST 的基础,通过查看将 Web 应用程序转变为 REST 启用应用程序的五个关键原则。我们简要比较了 RESTful 服务和经典 SOAP Web 服务,并最终探讨了 RESTful 服务的文档方式以及我们如何简化开发的服务发现。
现在您已经了解了基础知识,我们准备深入探讨 Node.js 实现 RESTful 服务的方式。在下一章中,您将学习 Node.js 的必要要素以及构建真实生活完整 Web 服务时必须使用和理解的配套工具。
第十三章:构建典型的 Web API
我们的第一版 API 将是一个只读版本,并且不会支持创建或更新目录中的项目,就像现实世界的应用那样。相反,我们将专注于 API 定义本身,并在稍后考虑数据存储。当然,使用文件存储向数百万用户公开的数据绝对不是一种选择,因此本书稍后将为我们的应用程序提供一个数据库层,在研究了现代 NoSQL 数据库解决方案之后。
我们还将涵盖内容协商这一主题,这是一种机制,允许消费者指定请求数据的预期格式。最后,我们将探讨几种公开服务不同版本的方法,以防服务以不向后兼容的方式发展。
总结来说,在本章中,你将学习以下内容:
-
如何指定 Web API
-
如何实现路由
-
如何查询 API
-
内容协商
-
API 版本控制
在本章之后,你应该能够完全指定一个 RESTful API,并将几乎准备好开始实现实际的 Node.js RESTful 服务。
指定 API
一个项目通常从定义 API 将要公开的操作开始。根据 REST 原则,操作通过 HTTP 方法和 URI 来暴露。每个操作执行的动作不应与其 HTTP 方法的自然含义相矛盾。以下表格详细说明了我们 API 的操作:
| 方法 | URI | 描述 |
|---|---|---|
GET |
/category |
获取目录中所有可用的类别。 |
GET |
/category/{category-id}/ |
获取特定类别下所有可用的项目。 |
GET |
/category/{category-id}/{item-id} |
通过其 ID 检索特定类别中的项目。 |
POST |
/category |
创建一个新的类别;如果它存在,则更新它。 |
POST |
/category/{category-id}/ |
在指定的类别中创建一个新的项目。如果项目存在,则更新它。 |
PUT |
/category/{category-id} |
更新类别。 |
PUT |
/category/{category-id}/{item-id} |
更新指定类别中的项目。 |
DELETE |
/category/{category-id} |
删除现有的类别。 |
DELETE |
/category/{category-id}/{item-id} |
删除指定类别中的项目。 |
第二步是选择适合我们的目录应用程序数据的适当格式。JSON 对象是 JavaScript 的原生支持。它们在应用程序的演变过程中易于扩展,并且几乎可以被任何平台消费。因此,JSON 格式似乎是我们逻辑上的选择。以下是本书中将使用的项目对象和类别对象的 JSON 表示:
{
"itemId": "item-identifier-1",
"itemName": "Sports Watch",
"category": "Watches",
"categoryId": 1,
"price": 150,
"currency": "EUR"
}
{
"categoryName" : "Watches",
"categoryId" : "1",
"itemsCount" : 100,
"items" : [{
"itemId" : "item-identifier-1",
"itemName":"Sports Watch",
"price": 150,
"currency" : "EUR"
}]
}
到目前为止,我们的 API 已经定义了一组操作和要使用的数据格式。下一步是实现一个模块,该模块将导出函数,为路由中的每个操作提供服务。
首先,让我们创建一个新的 Node.js Express 项目。选择一个存储你项目的目录,然后在你的 shell 终端中执行express chapter3。如果你使用的是 Windows,在生成项目之前你需要安装express-generator模块。express-generator将在所选目录中创建一个初始的 Express 项目布局。这个布局为你提供了默认的项目结构,确保你的 Express 项目遵循标准的项目结构。这使得你的项目更容易导航。
下一步是将项目导入到 Atom IDE 中。在项目标签页的任何位置右键单击,然后选择“添加项目文件夹”,然后选择 Express 为你生成的目录。
正如你所见,Express 已经为我们做了一些后台工作,并为我们的应用程序创建了一个起点:app.js。它还为我们创建了一个package.json文件。让我们看看这些文件中的每一个,从package.json开始:
{
"name": "chapter3",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "test"
},
"author": "",
"license": "ISC",
"dependencies": {
"dependencies": {
"body-parser": "~1.13.2",
"cookie-parser": "~1.3.5",
"debug": "~2.2.0",
"express": "~4.16.1",
"jade": "~1.11.0",
"morgan": "~1.6.1",
"serve-favicon": "~2.3.0"
}
}
由于我们创建了一个空的 Node.js Express 项目,我们最初只依赖 Express 框架,一些中间件模块如morgan、body-parser和cookie-parser,以及 Jade 模板语言。Jade 是一种简单的模板语言,用于在模板中生成 HTML 代码。如果你对此感兴趣,你可以在www.jade-lang.com了解更多信息。
写作时 Express 框架的当前版本是 4.16.1;要更新它,请在chapter3目录中执行npm install express@4.16.1 --save命令。此命令将更新应用程序的依赖项到所需版本。--save选项将更新并保存依赖项的新版本到项目的package.json文件中。
当你引入新的模块依赖项时,你需要自己确保package.json文件保持最新,以维护应用程序依赖的模块的准确状态。
我们将在本章稍后讨论中间件模块是什么。
现在,我们将忽略public和view目录的内容,因为它们与我们当前的 RESTful 服务无关。这些目录包含可能在我们决定稍后开发基于 Web 的服务消费者时有所帮助的自动生成的样式表和模板文件。
我们已经提到,Express 项目在app.js中为我们的 Web 应用程序创建了一个起点。让我们更深入地看看它:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
显然,Express 生成器为我们做了很多工作,因为它实例化了 Express 框架,并围绕它分配了一个完整的发展环境。它做了以下几件事:
-
配置了要在我们的应用程序中使用的中间件,
body-parser、默认的路由器以及开发环境中的错误处理中间件 -
注入了一个 morgan 中间件模块的日志实例
-
配置了 Jade 模板,因为它已被选为我们的应用程序的默认模板
-
配置了我们的 Express 应用程序将监听的默认 URI,
/和/users,并为它们创建了虚拟处理函数。
为了成功启动生成的应用程序,您必须安装app.js中使用的所有模块。此外,在安装它们后,请确保使用--save选项更新您的package.json文件的依赖项。
Express 生成器还为您创建了一个启动脚本。它位于项目的bin/www目录下,如下面的代码片段所示:
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('chapter3:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}
要启动应用程序,请执行node bin/www;这将执行上面的脚本并启动 Node.js 应用程序。因此,在浏览器中请求http://localhost:3000将导致调用默认的GET处理程序,它将返回一个欢迎响应:

Express 应用程序的默认欢迎消息
生成器创建了一个虚拟的routes/users.js;它暴露了一个与位于/users位置的虚拟模块相关联的路由。请求它将导致调用用户路由的list函数,该函数输出一个静态响应:“响应资源”。
我们的应用程序将不会使用模板语言和样式表,因此让我们删除设置应用程序配置中视图和视图引擎属性的行。此外,我们将实现自己的路由。因此,我们不需要为我们的应用程序绑定/和/users URI,也不需要user模块;相反,我们将利用一个catalog模块,并从路由:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var routes = require('./routes/index');
var catalog = require('./routes/catalog')
var app = express();
//uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', routes);
app.use('/catalog', catalog);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
//development error handler will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
因此,经过这次清理后,我们的应用程序看起来干净多了,我们准备继续前进。
在做那之前,尽管如此,有一个术语需要进一步解释:中间件。它是在调用用户定义的处理程序之前,由Express.js路由层调用的链式函数的子集。中间件函数可以完全访问request和response对象,并且可以修改它们中的任何一个。中间件链总是按照定义的顺序调用,因此了解特定中间件的具体操作至关重要。一旦中间件函数完成,它将通过将其下一个参数作为函数调用,来调用链中的下一个函数。在执行完整个链之后,将调用用户定义的请求处理程序。
这里是适用于中间件链的基本规则:
-
中间件函数具有以下签名:
function (request, response, next)。 -
中间件函数是按照它们添加到应用程序链中的顺序执行的。这意味着,如果您希望您的中间件函数在特定路由之前被调用,您需要在其声明路由之前添加它。
-
中间件函数使用它们的第三个参数
next作为一个函数来指示它们已完成工作并退出。当链中最后一个函数的next()参数被调用时,链式执行完成,request和response对象达到由中间件设置的状态,并传递给定义的处理程序。
现在我们已经知道了中间件函数是什么,让我们明确一下当前使用的中间件函数为我们应用提供了什么。body-parser中间件是 Express 框架内置的解析器。它在中间件执行完成后解析request体,并填充request对象,即它提供了 JSON 有效负载处理。
现在是时候继续前进并实现我们的用户模块,该模块将被映射到我们的 URI 上。该模块将被命名为modules/catalog.js:
var fs = require('fs');
function readCatalogSync() {
var file = './data/catalog.json';
if (fs.existsSync(file)) {
var content = fs.readFileSync(file);
var catalog = JSON.parse(content);
return catalog;
}
return undefined;
}
exports.findItems = function(categoryId) {
console.log('Returning all items for categoryId: ' + categoryId);
var catalog = readCatalogSync();
if (catalog) {
var items = [];
for (var index in catalog.catalog) {
if (catalog.catalog[index].categoryId === categoryId) {
var category = catalog.catalog[index];
for (var itemIndex in category.items) {
items.push(category.items[itemIndex]);
}
}
}
return items;
}
return undefined;
}
exports.findItem = function(categoryId, itemId) {
console.log('Looking for item with id' + itemId);
var catalog = readCatalogSync();
if (catalog) {
for (var index in catalog.catalog) {
if (catalog.catalog[index].categoryId === categoryId) {
var category = catalog.catalog[index];
for (var itemIndex in category.items) {
if (category.items[itemIndex].itemId === itemId) {
return category.items[itemIndex];
}
}
}
}
}
return undefined;
}
exports.findCategoryies = function() {
console.log('Returning all categories');
var catalog = readCatalogSync();
if (catalog) {
var categories = [];
for (var index in catalog.catalog) {
var category = {};
category["categoryId"] = catalog.catalog[index].categoryId;
category["categoryName"] = catalog.catalog[index].categoryName;
categories.push(category);
}
return categories;
}
return [];
}
目录模块是围绕存储在data目录中的catalog.json文件构建的。源文件的内容是通过readCatalogSync函数中的文件系统模块fs同步读取的。文件系统模块提供了多种有用的文件系统操作,例如创建、重命名或删除文件或目录的函数;截断;链接;chmod函数;以及同步和异步文件访问以读取和写入数据。在我们的示例应用中,我们旨在使用最直接的方法,因此我们实现了利用文件系统模块的readFileSync函数读取catalog.json文件的函数。它在一个同步调用中返回文件的内容,作为字符串。模块中的所有其他函数都导出,可以根据不同的标准查询源文件的内容。
目录模块导出以下函数:
-
findCategories: 这个函数返回一个包含catalog.json文件中所有类别的 JSON 对象数组 -
findItems (categoryId): 这个函数返回一个表示给定类别中所有项目的 JSON 对象数组 -
findItem(categoryId, itemId): 这个函数返回一个表示给定类别中单个项目的 JSON 对象
现在我们已经有了三个完整的函数,让我们看看如何将它们绑定到我们的 Express 应用中。
实现路由
在 Node.js 术语中,路由是一个 URI 和函数之间的绑定。Express 框架提供了内置的路由支持。一个express对象实例包含以每个 HTTP 动词命名的函数:get、post、put和delete。它们的语法如下:function(uri, handler);。
它们用于将处理函数绑定到在 URI 上执行的具体 HTTP 操作。处理函数通常接受两个参数:request和response。让我们通过一个简单的Hello route应用来看看它:
var express = require('express');
var app = express();
app.get('/hello', function(request, response){
response.send('Hello route');
});
app.listen(3000);
在本地主机上运行此示例并访问 http://localhost:3000/hello 将导致调用您的处理函数,并响应说 Hello route,但路由可以为您提供更多功能。它允许您定义带有参数的 URI;例如,让我们使用 /hello/:name 作为路由字符串。它告诉框架所使用的 URI 由两部分组成:一个静态部分(hello)和一个变量部分(name 参数)。
此外,当路由字符串和处理函数与 Express 实例的 get 函数定义一致时,参数集合将直接在处理函数的 request 参数中可用。为了演示这一点,让我们稍微修改一下之前的示例:
var express = require('express');
var app = express();
app.get('/hello:name', function(request, response){
response.send('Hello ' + request.params.name);
});
app.listen(3000);
如前述代码片段所示,我们使用冒号 (:) 来分隔 URI 的参数部分和静态部分。在 Express 路由中可以有多个参数;例如,/category/:category-id/items/:item-id 定义了一个用于显示属于某个类别的项目的路由,其中 category-id 和 item-id 是参数。
现在让我们试试。请求 http://localhost:3000/hello/friend 将导致以下输出:
hello friend
这就是我们可以如何使用 Express 提供参数化 URI 的方法。这是一个很好的功能,但通常还不够。在 Web 应用程序中,我们习惯于使用 GET 参数提供额外的参数。
不幸的是,Express 框架在处理 GET 参数方面并不出色。因此,我们必须利用 url 模块。它是 Node.js 内置的,提供了一种使用 URL 解析的简单方法。让我们再次使用我们的 hello 结果,并在应用程序中用其他参数扩展它,使其在请求 /hello 时输出 hello all,当请求的 URI 是 /hello?name=friend 时输出 hello friend:
var express = require('express');
var url = require('url');
var app = express();
app.get('/hello', function(request, response){
var getParams = url.parse(request.url, true).query;
if (Object.keys(getParams).length == 0) {
response.end('Hello all');
} else {
response.end('Hello ' + getParams.name);
}
});
app.listen(3000);
这里有一些值得注意的事情。我们使用了 url 模块的 parse 函数。它接受一个 URL 作为其第一个参数,并接受一个布尔值作为可选的第二个参数,该参数指定是否应该解析查询字符串。url.parse 函数返回一个关联对象。我们使用 Object.keys 与它一起使用,将关联对象中的键转换为数组,以便我们可以检查其长度。这将帮助我们检查我们的 URI 是否带有 GET 参数。除了以每个 HTTP 动词命名的路由函数外,还有一个名为 all 的函数。当使用时,它将所有 HTTP 动作路由到指定的 URI。
现在我们已经了解了在 Node.js 和 Express 环境中路由和 GET 参数是如何工作的,我们准备为 catalog 模块定义一个路由并将其绑定到我们的应用程序中。以下是在 routes/catalog.js 中定义的路由。
var express = require('express');
var catalog = require('../modules/catalog.js')
var router = express.Router();
router.get('/', function(request, response, next) {
var categories = catalog.findCategoryies();
response.json(categories);
});
router.get('/:categoryId', function(request, response, next) {
var categories = catalog.findItems(request.params.categoryId);
if (categories === undefined) {
response.writeHead(404, {'Content-Type' : 'text/plain'});
response.end('Not found');
} else {
response.json(categories);
}
});
router.get('/:categoryId/:itemId', function(request, response, next) {
var item = catalog.findItem(request.params.categoryId, request.params.itemId);
if (item === undefined) {
response.writeHead(404, {'Content-Type' : 'text/plain'});
response.end('Not found');
} else {
response.json(item);
}
});
module.exports = router;
首先,从 Express 模块创建一个 Router 实例。以下是一个很好地描述了我们刚刚实现的路由的表格。这将在我们稍后测试我们的 API 时很有帮助:
| HTTP 方法 | 路由 | 目录模块函数 |
|---|---|---|
GET |
/catalog |
findCategories() |
GET |
/catalog/:categoryId |
findItems(categoryId) |
GET |
/catalog/:categoryId/:itemId |
findItem(categoryId, itemId) |
使用测试数据查询 API
我们需要一些测试数据来测试我们的服务,因此让我们使用项目 data 目录下的 catalog.json 文件。这些数据将允许我们测试所有三个功能,但为了做到这一点,我们需要一个能够向端点发送 REST 请求的客户端。如果你还没有为测试应用程序创建 Postman 项目,现在是创建它的合适时机。
请求 /catalog 应返回 test 文件中的所有类别:

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

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

内容协商
到目前为止,目录服务只支持 JSON 格式,因此只能与媒体类型 application/json 一起工作。假设我们的服务必须以不同的格式提供数据,例如,JSON 和 XML。那么,消费者需要明确定义他们需要的数据格式。在 REST 中执行内容协商的最佳方式长期以来一直是一个非常有争议的话题。
在他关于正确实现内容协商的著名讨论中,Roy Fielding 提出了以下观点:
所有重要的资源都必须有 URI。
然而,这留下了一个如何以不同数据格式公开相同资源的空白,因此 Roy 继续以下观点:
代理驱动协商更为有效,但我在 HTTP 工作组主席和我最初为 HTTP/1.1 设计的代理驱动协商之间有很大的分歧,我的原始代理驱动设计实际上被委员会埋没了。为了正确进行协商,客户端需要了解所有替代方案以及它应该用作书签的内容。
虽然人们仍然可以选择通过提供自定义 GET 参数来保持 URI 驱动的协商,但 REST 社区已经选择了坚持 Roy 的代理驱动协商建议。现在,自从这个论点被提出以来已经近十年,已经证明他们做出了正确的决定。代理驱动协商利用了 Accept HTTP 头部。
Accept HTTP 头指定了消费者愿意处理的资源的媒体类型。除了Accept头之外,消费者还可以使用Accept-Language和Accept-Encoding头指定结果应提供什么语言和编码。如果服务器无法以预期的格式提供结果,它可以选择返回默认值或使用HTTP 406 Not acceptable,以避免在客户端引起数据混淆错误。
Node.js HTTP response对象包含一个名为format的方法,该方法根据在request对象中设置的Accept HTTP 头执行内容协商。它使用内置的request.accepts()来选择适当的请求处理器。如果没有找到,服务器将调用默认处理器,并以HTTP 406 Not acceptable响应。让我们创建一个演示,说明如何在我们的路由之一中使用format方法。为此,让我们假设我们在catalog模块中实现了一个名为list_groups_in_xml的函数,该函数以 XML 格式提供组数据:
app.get('/catalog', function(request, response) {
response.format( {
'text/xml' : function() {
response.send(catalog.findCategoiesXml());
},
'application/json' : function() {
response.json(catalog.findCategoriesJson());
},
'default' : function() {.
response.status(406).send('Not Acceptable');
}
});
});
这就是您如何以清晰直接的方式实现内容协商。
API 版本化
所有应用程序 API 的演变是一个不可避免的事实。然而,具有未知数量消费者的公共 API(如 RESTful 服务)的演变是一个敏感话题。因为消费者可能无法适当地处理修改后的数据,而且无法通知他们所有人,所以我们需要尽可能保持我们的 API 向后兼容。这样做的一种方法是为我们应用程序的不同版本使用不同的 URI。目前,我们的目录 API 可在/catalog处访问。
当推出新版本(例如,版本 2)的时机成熟时,我们可能需要将旧版本保留在另一个 URI 上以保持向后兼容。最佳实践是将版本号编码在 URI 中,例如/v1/catalog,并将/catalog映射到最新版本。因此,请求/catalog将导致重定向到/v2/catalog,并使用 HTTP 3xx状态码来指示重定向到最新版本。
另一种版本化的选择是保持 API 的 URI 稳定,并依赖于指定版本的定制 HTTP 头。但这种方法在向后兼容性方面并不稳定,因为修改应用程序中请求的 URL 比修改请求中发送的头部更自然。
自测问题
为了获得额外的信心,请通过这一系列陈述并声明它们是正确还是错误:
-
一个启用了 REST 的端点必须支持与 REST 原则相关的所有 HTTP 方法
-
当内容协商失败时,由于传递给
Accept头部的值中包含不支持的媒体类型,301 是适当的响应状态码。 -
当使用参数化路由时,开发者可以指定参数的类型,例如,它是一个数字类型还是一个字面量类型。
摘要
在本章中,我们深入探讨了一些更复杂的话题。让我们总结一下我们覆盖的内容。我们首先指定了我们的 Web API 的操作,并定义操作是一个 URI 和 HTTP 动作的组合。接下来,我们实现了路由并将它们绑定到一个操作上。然后,我们使用 Postman REST 客户端请求我们路由的 URI 来请求每个操作。在内容协商部分,我们处理了Accept HTTP 头,以提供消费者请求的格式。最后,我们讨论了 API 版本的话题,这允许我们开发向后兼容的 API。
在本章中,我们使用传统的文件系统存储来存储我们的数据。这不适合 Web 应用程序。因此,在下一章中,我们将探讨现代、可扩展和可靠的 NoSQL 存储。
第十四章:使用 NoSQL 数据库
在上一章中,我们实现了一个示例应用程序,它提供了一个只读服务,提供了目录数据。为了简化,我们在这种实现中引入了性能瓶颈,使用了文件存储。这种存储不适合 Web 应用程序。它依赖于 33 个物理文件,阻止我们的应用程序处理重负载,因为文件存储由于磁盘 I/O 操作而缺乏多租户支持。换句话说,我们绝对需要寻找一个更好的存储解决方案,当需要时,它可以轻松扩展,以满足我们 REST 启用应用程序的需求。现在,NoSQL 数据库在 Web 和云环境中被广泛使用,确保零停机时间和高可用性。与传统的交易性 SQL 数据库相比,它们有以下优势:
-
他们支持模式版本;也就是说,它们可以与对象表示一起工作,而不是根据一个或多个表的定义来填充对象状态。
-
它们是可扩展的,因为它们存储实际的对象。数据演变是隐式支持的,所以你只需要调用存储修改后对象的操作。
-
它们被设计成高度分布式和可扩展的。
几乎所有的现代 NoSQL 解决方案都支持集群,并且可以进一步扩展,包括你的应用程序的负载。此外,它们中的大多数都有通过 HTTP 的 REST 启用接口,这简化了在高可用性场景中使用负载均衡器的使用。传统的数据库驱动程序通常不可用于传统的客户端语言,如 JavaScript,因为它们需要本地库或驱动程序。然而,NoSQL 的想法源于使用文档数据存储。因此,它们中的大多数都支持 JSON 格式,这是 JavaScript 的本地格式。最后但同样重要的是,大多数 NoSQL 解决方案都是开源的,并且可以免费使用,提供了开源项目提供的所有好处:社区、示例和自由!
在本章中,我们将探讨 MongoDB NoSQL 数据库及其与之交互的 Mongoose 模块。我们将了解如何为数据库模型设计并实现自动化测试。最后,在本章的结尾,我们将移除文件存储瓶颈,并将我们的应用程序移动到几乎可以投入生产的状态。
MongoDB – 文档存储数据库
MongoDB 是一个开源的文档数据库,内置了对 JSON 格式的支持。它提供了基于文档中任何可用属性的完整索引支持。由于其可扩展性功能,它非常适合高可用性场景。MongoDB 可在 mms.mongodb.com 上使用,其管理服务为 MongoDB 管理服务(MMS)。它们利用并自动化大多数需要执行的开发操作,以保持您的云数据库处于良好状态,包括升级、进一步扩展、备份、恢复、性能和安全警报。
让我们继续前进并安装 MongoDB。Windows、Linux、macOS 和 Solaris 的安装程序可在 www.mongodb.org/downloads 获取。Linux 用户可以在所有流行的发行版仓库中找到 MongoDB,而 Windows 用户可以利用一个用户友好的向导,该向导将引导您完成安装步骤,在典型安装中,您只需接受许可协议并提供安装路径即可。
安装成功后,执行以下命令以启动 MongoDB。如果您想指定数据存储的特定位置,您必须使用 --dbpath 参数。可选地,您可以通过 --rest 参数启动 MongoDB HTTP 控制台:
mongod --dbpath ./data --rest
与 MongoDB 通信的默认端口是 27017,其 HTTP 控制台默认配置为使用比数据端口高 1,000 的端口。因此,控制台的默认端口将是 28017。HTTP 控制台提供了有关数据库的有用信息,例如日志、健康状态、可用数据库等。我强烈建议您花些时间熟悉它。控制台还可以用作数据库的 RESTful 健康检查服务,因为它提供了关于运行数据库服务和最后发生的错误的 JSON 编码信息:
GET /replSetGetStatus?text=1 HTTP/1.1
Host: localhost:28017
Connection: Keep-Alive
User-Agent: RestClient-Tool
HTTP/1.0 200 OK
Content-Length: 56
Connection: close
Content-Type: text/plain;charset=utf-8
{
"ok": 0,
"errmsg": "not running with --replSet"
}
此 REST 接口可用于脚本或应用程序中,以自动化提供数据库引擎当前状态的更改通知等。
控制台的日志部分显示您的服务器正在成功运行(如果正在运行的话)。现在我们准备进一步了解如何将 Node.js 连接到 MongoDB。
使用 Mongoose 进行数据库建模
Mongoose 是一个模块,它以对象文档映射器(ODM)风格将 Node.js 连接到 MongoDB。它为存储在数据库中的文档提供了 创建、读取、更新和删除(也称为 CRUD)功能。Mongoose 使用模式定义了使用的文档的结构。模式是 Mongoose 中数据定义的最小单元。模型是由模式定义构建的。它是一个类似于构造函数的函数,可以用来创建或查询文档。文档是模型的实例,代表与存储在 MongoDB 中的文档一对一的映射。模式-模型-文档层次结构提供了一种自我描述的方式来定义对象,并允许轻松的数据验证。
让我们从使用 npm 安装 Mongoose 开始:
npm install mongoose
现在我们已经安装了 Mongoose 模块,我们的第一步将是定义一个将代表目录中项目的模式:
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var itemSchema = new Schema ({
"itemId" : {type: String, index: {unique: true}},
"itemName": String,
"price": Number,
"currency" : String,
"categories": [String]
});
以下代码片段创建了一个项目模式的定义。定义模式是直接的,并且相当类似于 JSON 模式定义;你必须描述和属性化其类型,并可选地为每个键提供额外的属性。在目录应用程序的情况下,我们需要使用 itemId 作为唯一索引以避免有两个具有相同 ID 的不同项目。因此,除了将其类型定义为 String 之外,我们还使用 index 属性来描述 itemId 字段的值必须对每个单独的项目是唯一的。
Mongoose 引入了术语 模型。模型是从模式定义编译出的类似于构造函数的函数。模型的实例代表可以保存到或从数据库中读取的文档。创建模型实例是通过调用 mongoose 实例的 model 函数并传递模型应使用的模式来完成的:
var CatalogItem = mongoose.model('Item', itemSchema);
模型还公开了用于查询和数据操作的功能。假设我们已经初始化了一个模式并创建了一个模型,将新项目存储到 MongoDB 中就像创建一个新的 model 实例并调用其 save 函数一样简单:
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/catalog');
var db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
var watch = new CatalogItem({
itemId: 9 ,
itemName: "Sports Watch1",
brand: 'А1',
price: 100,
currency: "EUR",
categories: ["Watches", "Sports Watches"]
});
watch.save((error, item, affectedNo)=> {
if (!error) {
console.log('Item added successfully to the catalog');
} else {
console.log('Cannot add item to the catlog');
}
});
});
db.once('open', function() {
var filter = {
'itemName' : 'Sports Watch1',
'price': 100
}
CatalogItem.find(filter, (error, result) => {
if (error) {
consoloe.log('Error occured');
} else {
console.log('Results found:'+ result.length);
console.log(result);
}
});
});
以下是如何使用模型来查询代表属于 Watches 组并命名为 Sports Watches 的运动手表的文档:
db.once('open', function() {
var filter = {
'itemName' : 'Sports Watch1',
'price': 100
}
CatalogItem.findOne(filter, (error, result) => {
if (error) {
consoloe.log('Error occurred');
} else {
console.log(result);
}
});
});
模型还公开了一个 findOne 函数,这是一种方便的方式,通过其唯一索引查找对象,然后对其进行一些数据操作,即进行删除或更新操作。以下示例删除了一个项目:
CatalogItem.findOne({itemId: 1 }, (error, data) => {
if (error) {
console.log(error);
return;
} else {
if (!data) {
console.log('not found');
return;
} else {
data.remove(function(error){
if (!error) { data.remove();}
else { console.log(error);}
});
}
}
});
使用 Mocha 测试 Mongoose 模型
Mocha 是 JavaScript 最受欢迎的测试框架之一;其主要目标是提供一个简单的方式来测试异步 JavaScript 代码。让我们全局安装 Mocha,以便我们可以在未来开发的任何 Node.js 应用程序中使用它:
npm install -g mocha
我们还需要一个可以与 Mocha 一起使用的断言库。断言库提供了验证实际值与预期值的功能,当它们不相等时,断言库将导致测试失败。Should.js 断言库模块易于使用,它将成为我们的选择,因此让我们也全局安装它:
npm install -g should
现在我们已经安装了我们的测试模块,我们需要在 package.json 文件中指定我们的 testcase 文件路径。让我们通过在脚本节点中添加一个指向 Mocha 和 testcase 文件的 test 元素来修改它:
{
"name": "chapter4",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
"test": "mocha test/model-test.js"
},
"dependencies": {
"body-parser": "~1.13.2",
"cookie-parser": "~1.3.5",
"debug": "~2.2.0",
"express": "~4.16.0",
"jade": "~1.11.0",
"morgan": "~1.6.1",
"serve-favicon": "~2.3.0"
}
}
这将告诉 npm 包管理器在执行 npm 测试时触发 Mocha。
Mongoose 测试的自动化不应受数据库当前状态的影响。为了确保每次测试运行的结果都是可预测的,我们需要确保数据库状态与我们预期的完全一致。我们将在 test 目录中实现一个名为 prepare.js 的模块。它将在每次测试运行之前清除数据库:
var mongoose = require('mongoose');
beforeEach(function (done) {
function clearDatabase() {
for (var i in mongoose.connection.collections) {
mongoose.connection.collections[i].remove(function()
{});
}
return done();
}
if (mongoose.connection.readyState === 0) {
mongoose.connect(config.db.test, function (err) {
if (err) {
throw err;
}
return clearDatabase();
});
} else {
return clearDatabase();
}
});
afterEach(function (done) {
mongoose.disconnect();
return done();
});
接下来,我们将实现一个 Mocha 测试,该测试创建一个新项目:
var mongoose = require('mongoose');
var should = require('should');
var prepare = require('./prepare');
const model = require('../model/item.js');
const CatalogItem = model.CatalogItem;
mongoose.createConnection('mongodb://localhost/catalog');
describe('CatalogItem: models', function () {
describe('#create()', function () {
it('Should create a new CatalogItem', function (done) {
var item = {
"itemId": "1",
"itemName": "Sports Watch",
"price": 100,
"currency": "EUR",
"categories": [
"Watches",
"Sports Watches"
]
};
CatalogItem.create(item, function (err, createdItem) {
// Check that no error occured
should.not.exist(err);
// Assert that the returned item has is what we expect
createdItem.itemId.should.equal('1');
createdItem.itemName.should.equal('Sports Watch');
createdItem.price.should.equal(100);
createdItem.currency.should.equal('EUR');
createdItem.categories[0].should.equal('Watches');
createdItem.categories[1].should.equal('Sports Watches');
//Notify mocha that the test has completed
done();
});
});
});
});
现在执行 npm test 将导致 MongoDB 数据库被调用,从传递的 JSON 对象中创建一个项目。插入后,断言回调将执行,确保 Mongoose 传递的值与数据库返回的值相同。试一试并破坏测试——只需在断言中更改期望值为一个无效值——你会看到测试失败。
在 Mongoose 模型周围创建用户定义的模型
在看到模型如何工作之后,是时候创建一个用户定义的模块,该模块封装了目录的所有 CRUD 操作。由于我们打算在一个 RESTful 网络应用程序中使用该模块,因此将模式定义和模型创建放在模块外部,并将它们作为每个模块函数的参数提供似乎是合理的。相同的模式定义在单元测试中使用,确保模块的稳定性。现在让我们为每个 CRUD 函数添加一个实现,从 remove() 函数开始。它根据其 id 查找项目,如果存在,则从数据库中删除:
exports.remove = function (request, response) {
console.log('Deleting item with id: ' + request.body.itemId);
CatalogItem.findOne({itemId: request.params.itemId}, function(error, data) {
if (error) {
console.log(error);
if (response != null) {
response.writeHead(500, contentTypePlainText);
response.end('Internal server error');
}
return;
} else {
if (!data) {
console.log('Item not found');
if (response != null) {
response.writeHead(404, contentTypePlainText);
response.end('Not Found');
}
return;
} else {
data.remove(function(error){
if (!error) {
data.remove();
response.json({'Status': 'Successfully deleted'});
}
else {
console.log(error);
response.writeHead(500, contentTypePlainText);
response.end('Internal Server Error');
}
});
}
}
});
}
saveItem() 函数将请求体有效负载作为参数。有效的更新请求将包含一个 item 对象的新状态,该对象以 JSON 格式表示。首先,从 JSON 对象中解析出 itemId。然后执行查找。如果存在项目,则对其进行更新。否则,创建一个新的项目:
exports.saveItem = function(request, response)
{
var item = toItem(request.body);
item.save((error) => {
if (!error) {
item.save();
response.writeHead(201, contentTypeJson);
response.end(JSON.stringify(request.body));
} else {
console.log(error);
CatalogItem.findOne({itemId : item.itemId },
(error, result) => {
console.log('Check if such an item exists');
if (error) {
console.log(error);
response.writeHead(500, contentTypePlainText);
response.end('Internal Server Error');
} else {
if (!result) {
console.log('Item does not exist. Creating a new one');
item.save();
response.writeHead(201, contentTypeJson);
response.
response.end(JSON.stringify(request.body));
} else {
console.log('Updating existing item');
result.itemId = item.itemId;
result.itemName = item.itemName;
result.price = item.price;
result.currency = item.currency;
result.categories = item.categories;
result.save();
response.json(JSON.stringify(result));
}
}
});
}
});
};
toItem() 函数将 JSON 有效负载转换为 CatalogItem 模型实例,即项目文档:
function toItem(body) {
return new CatalogItem({
itemId: body.itemId,
itemName: body.itemName,
price: body.price,
currency: body.currency,
categories: body.categories
});
}
我们还需要提供一种查询数据的方法,因此让我们实现一个查询特定类别中所有项目的函数:
exports.findItemsByCategory = function (category, response) {
CatalogItem.find({categories: category}, function(error, result) {
if (error) {
console.error(error);
response.writeHead(500, { 'Content-Type': 'text/plain' });
return;
} else {
if (!result) {
if (response != null) {
response.writeHead(404, contentTypePlainText);
response.end('Not Found');
}
return;
}
if (response != null){
response.setHeader('Content-Type', 'application/json');
response.send(result);
}
console.log(result);
}
});
}
与 findItemsByCategory 类似,以下是一个根据 ID 查找项目的函数:
exports.findItemById = function (itemId, response) {
CatalogItem.findOne({itemId: itemId}, function(error, result) {
if (error) {
console.error(error);
response.writeHead(500, contentTypePlainText);
return;
} else {
if (!result) {
if (response != null) {
response.writeHead(404, contentTypePlainText);
response.end('Not Found');
}
return;
}
if (response != null){
response.setHeader('Content-Type', 'application/json');
response.send(result);
}
console.log(result);
}
});
}
最后,有一个函数可以列出存储在数据库中的所有目录项。它使用 Mongoose 模型的find函数来查找模型的所有文档,并使用其第一个参数作为过滤器。我们想要一个返回所有现有文档的函数;这就是为什么我们提供了一个空对象。这将返回所有可用的项目。结果在callback函数中可用,这是模型find函数的第二个参数:
exports.findAllItems = function (response) {
CatalogItem.find({}, (error, result) => {
if (error) {
console.error(error);
return null;
}
if (result != null) {
response.json(result);
} else {
response.json({});
}
});
};
catalog模块将是我们的 RESTful 服务的基石。它负责所有数据操作操作,以及不同类型的查询。它以可重用的方式封装了所有操作。
将 NoSQL 数据库模块连接到 Express
现在我们已经有了针对模型和用户定义模块的自动化测试,这确保了模块的稳定性,并使其准备好更广泛的应用。
是时候构建一个新的基于 Express 的应用程序并添加一个路由,将新模块公开给该应用程序:
const express = require('express');
const router = express.Router();
const catalog = require('../modules/catalog');
const model = require('../model/item.js');
router.get('/', function(request, response, next) {
catalog.findAllItems(response);
});
router.get('/item/:itemId', function(request, response, next) {
console.log(request.url + ' : querying for ' + request.params.itemId);
catalog.findItemById(request.params.itemId, response);
});
router.get('/:categoryId', function(request, response, next) {
console.log(request.url + ' : querying for ' + request.params.categoryId);
catalog.findItemsByCategory(request.params.categoryId, response);
});
router.post('/', function(request, response, next) {
console.log('Saving item using POST method);
catalog.saveItem(request, response);
});
router.put('/', function(request, response, next) {
console.log('Saving item using PUT method');
catalog.saveItem(request, response);
});
router.delete('/item/:itemId', function(request, response, next) {
console.log('Deleting item with id: request.params.itemId);
catalog.remove(request, response);
});
module.exports = router;
总结一下,我们将目录数据服务模块的每个功能路由到了 RESTful 服务的操作:
-
GET /catalog/item/:itemId: 这将调用catalog.findItemById() -
POST /catalog: 这将调用catalog.saveItem() -
PUT /catalog: 这将调用catalog.saveItem() -
DELETE / catalog/item/:id: 这将调用catalog.remove() -
GET /catalog/:category: 这将调用catalog.findItemsByCategory() -
GET /catalog/: 这将调用catalog.findAllItems()
由于我们已经公开了我们的操作,我们现在可以执行一些更严肃的 REST 测试。让我们启动 Postman 并测试新公开的端点:

花些时间彻底测试每个操作。这将帮助你建立信心,确保目录数据服务模块确实有效,同时也会让你更熟悉 HTTP 响应的提供和读取方式。作为一个 RESTful API 开发者,你应该能够流畅地阅读 HTTP 转储,这些转储显示了不同的请求负载和状态码。
自我测试问题
请回答以下问题:
-
你会如何使用 Mongoose 对多值属性的单一值执行查询?
-
定义一个测试 Node.js 模块操作 NoSQL 数据库的策略。
摘要
在本章中,我们探讨了 MongoDB,一个强大的面向文档的数据库。我们使用了它,并利用 Mocha 实现了数据库层的自动化测试。现在是时候构建一个完整的 RESTful Web 服务了。在下一章中,我们将通过包括通过文档属性进行搜索的支持来扩展用户定义的模块,并添加过滤和分页功能,这将最终演变成完整的 RESTful 服务实现。
第十五章:Restful API 设计指南
在上一章中,我们实现了一个目录模块,该模块公开了目录应用程序中项目的数据操作功能。这些函数使用express.js的请求对象解析请求体中的数据,然后执行适当的数据库操作。每个函数根据需要使用相关状态码和响应体有效载荷填充响应对象。最后,我们将每个函数绑定到一个路由,接受 HTTP 请求。
现在,是时候更深入地研究路由的 URL 和每个操作返回的 HTTP 状态码了。
在本章中,我们将涵盖以下主题:
-
端点 URL 和 HTTP 状态码最佳实践
-
可扩展性和版本控制
-
链接数据
端点 URL 和 HTTP 状态码最佳实践
每个 RESTful API 操作都是对 URL 发起的 HTTP 请求和适当的 HTTP 方法的组合。
当执行操作时,每个操作将返回一个状态码,指示调用是否成功。成功的调用通过 HTTP 2XX 状态码表示,而未正确执行的调用则通过错误状态码表示——如果错误在客户端,则为 4XX,如果服务器无法处理有效请求,则为 5xx。
拥有一个明确指定的 API 对于其采用至关重要。此类规范不仅应完全列出每个操作的状态码,还应指定预期的数据格式,即其支持的媒体类型。
以下表格定义了 Express.js Router 将如何公开 API 操作,并应作为其参考规范:
| 方法 | URI | 媒体类型 | 描述 | 状态码 |
|---|---|---|---|---|
| GET | /catalog | application/json | 返回目录中的所有项目。 | 200 OK500 内部服务器错误 |
| GET | /catalog/ | application/json | 返回所选类别的所有项目。如果该类别不存在,则返回 404。 | 200 OK,404 NOT FOUND500 内部服务器错误 |
| GET | /item/ | application/json | 返回所选 itemId 的单个项目。如果没有这样的项目,则返回 404。 | 200 OK,404 NOT FOUND500 内部服务器错误 |
| POST | /item/ | application/json | 创建新项目;如果存在具有相同标识符的项目,则将其更新。当创建项目时,返回一个Location头。它提供了可以访问新创建项目的 URL。 | 201 已创建 200 OK500 内部服务器错误 |
| PUT | /item/ | application/json | 更新现有项目;如果提供的标识符不存在,则创建它。当创建项目时,返回一个Location头。它提供了可以访问新创建项目的 URL。 | 201 已创建 200 OK500 内部服务器错误 |
| DELETE | /item/ | application/json | 删除现有项目;如果提供的标识符的项目不存在,则返回 404。 | 200 OK, 404 NOT FOUND, 500 Internal Server Error |
目录应用程序处理两种类型的实体:项目和类别。每个项目实体包含一个属于它的类别集合。正如您所看到的,类别在我们的应用程序中只是一个逻辑实体;只要至少有一个项目引用它,它就会存在,当没有项目引用它时,它将不再存在。这就是为什么应用程序只为类型为项目的资源暴露数据操作路由,而类别的操作更多或更少是只读的。更仔细地查看暴露项目数据操作 URL,我们可以看到与 REST 基本原理相一致的一个清晰模式——一个资源通过单个 URL 暴露,并且它支持由请求的 HTTP 方法确定的资源操作。总的来说,以下是良好定义的 API 应遵循的通常接受的规则。它们与每个资源操作在语义上是相关的:
-
当创建一个新资源时,服务使用201 已创建状态码,后跟一个位置头,指定新创建的资源可以访问的 URL。
-
创建资源的操作可以实施以优雅地拒绝创建已使用唯一标识符的资源;在这种情况下,操作应使用适当的409 冲突状态码或更通用的400 错误请求来指示非成功调用。然而,通用状态码始终应后跟一个有意义的错误解释。在我们的实现中,我们选择了一种不同的方法——如果存在,我们从创建操作更新资源,并通过返回200 OK状态码而不是201 已创建来通知调用者资源已被更新。
-
更新操作类似于创建操作;然而,它始终期望一个资源标识符作为参数,如果具有此标识符的资源存在,它将使用 HTTP PUT 请求正文中提供的新状态更新。200 OK状态码表示成功调用。实现可能决定使用404 Not Found状态码拒绝处理不存在的资源,或者使用传递的标识符创建新资源。在这种情况下,它将返回201 已创建状态码,后跟一个位置头,指定新创建的资源可以访问的 URL。我们的 API 使用第二种选项。
-
虽然删除可以通过204 No Content状态和进一步的负载来表示,但大多数用户代理会期望2xx HTTP 状态后面跟着一个体。因此,为了与大多数代理保持兼容,我们的 API 将使用200 OK状态码来表示成功的删除,后面跟着一个 JSON 负载:
{'Status': 'Successfully deleted'}。状态码404 Not found将表示提供的标识符不存在。 -
根据一般规则,5XX不应表示应用程序状态错误,而应表示更严重的错误,例如应用程序服务器或数据库故障。
-
最佳实践是
update和create操作应返回资源的整个状态作为有效载荷。例如,如果一个资源使用最小属性集创建,所有未指定的属性将获得默认值;响应体应包含对象的完整状态。对于更新也是如此;即使更新操作只部分更新资源状态,响应也应返回完整状态。这可能会在用户代理需要检查新状态时节省额外的 GET 请求。
现在我们已经定义了一些关于操作应该如何表现的一般性建议,现在是时候在 API 的新版本中实现它们了。
发现和探索 RESTful 服务
发现 RESTful 服务的话题有一个漫长而复杂的历史。HTTP 规范指出,资源应该是自描述的,并且应该由 URI 唯一标识。依赖资源应通过使用它们自己的唯一 URI 来链接依赖关系。发现 RESTful 服务意味着从一个服务导航到另一个服务,遵循它提供的链接。
在 2009 年,一个名为Web Application Discovery Language(WADL)的规范被发明出来。它的目的是记录从 Web 应用程序暴露的每个 URI,以及它支持的 HTTP 方法和它期望的参数。URI 的响应媒体类型也被描述。这对于文档目的非常有用,这就是 WADL 文件在 RESTful 服务提供方面能为我们提供的一切。
不幸的是,目前还没有 Node.js 模块可以自动为给定的 express 路由生成 WADL 文件。我们将不得不手动创建一个 WADL 文件来演示它是如何被其他客户端用于发现的。
下面的列表显示了一个示例 WADL 文件,描述了在/catalog、/catalog/v2/{categoryId}可用的资源:
<?xml version="1.0" encoding="UTF-8"?>
<application >
<grammer>
<include href="items.xsd" />
<include href="error.xsd" />
</grammer>
<resources base="http://localhost:8080/catalog/">
<resource path="{categoryId}">
<method name="GET">
<request>
<param name="category" type="xsd:string" style="template" />
</request>
<response status="200">
<representation mediaType="application/xml" element="service:item" />
<representation mediaType="application/json" />
</response>
<response status="404">
<representation mediaType="text/plain" element="service:item" />
</response>
</method>
</resource>
<resource path="/v2/{categoryId}">
<method name="GET">
<request>
<param name="category" type="xsd:string" style="template" />
</request>
<response status="200">
<representation mediaType="application/xml" element="service:item" />
<representation mediaType="application/json" />
</response>
<response status="404">
<representation mediaType="text/plain" element="service:item" />
</response>
</method>
</resource>
</resources>
</application>
如您所见,WADL 格式非常简单直接。它基本上描述了每个资源的 URI,提供了它使用的媒体类型和在该 URI 上预期的状态码信息。许多第三方 RESTful 客户端理解 WADL 语言,并可以从给定的 WADL 文件中生成请求消息。
让我们在 Postman 中导入 WADL 文件。点击导入按钮,并选择您的 WADL 文件:

在 Postman 中导入 WADL 文件以获取服务的存根。这是 Postman 的屏幕截图。这里的个人设置并不重要。图片的目的只是展示窗口的外观。
如您所见,导入 WADL 文件的结果是我们有一个项目可以及时测试 REST 服务的各个方面。WADL 文件中定义的所有路由现在都方便地作为右侧菜单上的单独请求实体可用。不仅如此;除了 WADL 标准,目前 Swagger 文档格式被广泛采用,并已成为描述 RESTful 服务的非正式标准,因此我们也可以用它来简化服务的采用和发现。在下一章中,我们将将这些描述文件绑定到我们的服务上。这是生产准备阶段的一个重要步骤。
可扩展性和版本控制
我们已经在 第十三章 中定义了一些基本的版本控制规则,构建典型的 Web API。现在让我们将这些规则应用到我们在上一章中实现的 MongoDB 数据库感知模块中。我们的起点将是使当前 API 的消费者能够继续在新的 URL 上使用相同的版本。这将保持向后兼容,直到他们成功采用并测试新版本。
保持 REST API 的稳定性不仅仅是将一个端点从一个 URI 移动到另一个 URI。执行重定向然后有一个行为不同的 API 是没有意义的。因此,我们需要确保移动的端点的行为保持不变。为了确保我们不改变之前实现的行为,让我们将当前的行为从 catalog.js 模块移动到一个新的模块,通过将文件重命名为 catalogV1.js。然后,将其复制到 catalogV2.js 模块,在那里我们将引入所有新的功能;但在做之前,我们必须将版本 1 从 /, /{categoryId}, /{itemId} 重定向到 /v1, /v1/{categoryId}, /v1/{itemId}:
const express = require('express');
const router = express.Router();
const catalogV1 = require('../modules/catalogV1');
const model = require('../model/item.js');
router.get('/v1/', function(request, response, next) {
catalogV1.findAllItems(response);
});
router.get('/v1/item/:itemId', function(request, response, next) {
console.log(request.url + ' : querying for ' + request.params.itemId);
catalogV1.findItemById(request.params.itemId, response);
});
router.get('/v1/:categoryId', function(request, response, next) {
console.log(request.url + ' : querying for ' + request.params.categoryId);
catalogV1.findItemsByCategory(request.params.categoryId, response);
});
router.post('/v1/', function(request, response, next) {
catalogV1.saveItem(request, response);
});
router.put('/v1/', function(request, response, next) {
catalogV1.saveItem(request, response);
});
router.delete('/v1/item/:itemId', function(request, response, next) {
catalogV1.remove(request, response);
});
router.get('/', function(request, response) {
console.log('Redirecting to v1');
response.writeHead(301, {'Location' : '/catalog/v1/'});
response.end('Version 1 is moved to /catalog/v1/: ');
});
module.exports = router;
由于我们的 API 的版本 2 尚未实现,对 / 的 GET 请求将导致收到 301 永久移动 HTTP 状态,然后重定向到 /v1/。这将通知我们的消费者 API 正在演变,他们很快需要决定是否通过显式请求其新 URI 继续使用版本 1,或者为采用版本 2 做准备。
尝试一下!启动修改后的节点应用程序,并从 Postman 发送一个 GET 请求到 http://localhost:3000/catalog:

您将看到您的请求被重定向到新的路由位置 http://localhost:3000/catalog/v1。
现在我们已经完成了目录的版本 1,是时候考虑在版本 2 中添加的进一步扩展了。目前,目录服务支持列出类别中的所有项目以及通过其 ID 获取项目。现在是时候充分利用文档数据库 MongoDB 并实现一个函数,使我们的 API 消费者能够根据其任何属性查询项目。例如,列出具有与查询参数(如价格或颜色)匹配的属性的特定类别的所有项目,或按项目名称搜索。RESTful 服务通常公开面向文档的数据。然而,它们的用途不仅限于文档。在下一章中,我们将以这种方式扩展目录,使其也能够存储与每个项目链接的二进制数据——一张图片。为此,我们将在第十六章的处理任意数据部分使用 MongoDB 的二进制格式Binary JSON(BSON)。
回到搜索扩展,我们已经使用了Mongoose.js模型的find()和findOne()函数。到目前为止,我们使用它们在 JavaScript 代码中以静态方式提供要搜索的文档属性的名称。然而,find()的此过滤参数只是一个 JSON 对象,其中键是文档属性,值是用于查询的属性值。这是我们将在版本 2 中添加的第一个新函数。它通过任意属性及其值查询 MongoDB:
exports.findItemsByAttribute = function (key, value, response) {
var filter = {};
filter[key] = value;
CatalogItem.find(filter, function(error, result) {
if (error) {
console.error(error);
response.writeHead(500, contentTypePlainText);
response.end('Internal server error');
return;
} else {
if (!result) {
if (response != null) {
response.writeHead(200, contentTypeJson);
response.end({});
}
return;
}
if (response != null){
response.setHeader('Content-Type', 'application/json');
response.send(result);
}
}
});
}
此函数使用提供的属性和值作为参数在模型上调用 find。我们将此函数绑定到路由器的/v2/item/ GET 处理器。
最后,我们的目标是实现/v2/item/?currency=USD,它只返回以美元货币出售的项目的记录,正如传递的 GET 参数的值所指示的。这样,如果我们修改模型以添加额外的属性,例如颜色和大小,我们就可以查询具有相同颜色或任何其他属性的所有项目。
当查询字符串中没有提供参数时,我们将保持返回所有可用项目的旧行为,但我们将解析第一个提供的GET参数并将其用作findItemsByAttribute()函数中的过滤器:
router.get('/v2/items', function(request, response) {
var getParams = url.parse(request.url, true).query;
if (Object.keys(getParams).length == 0) {
catalogV2.findAllItems(response);
} else {
var key = Object.keys(getParams)[0];
var value = getParams[key];
catalogV2.findItemsByAttribute(key, value, response);
}
});
在此函数中,最有趣的部分可能是 URL 解析。如您所见,我们继续使用相同的老策略来检查是否提供了任何GET参数。我们解析 URL 以获取查询字符串,然后使用内置的Object.keys函数检查解析后的键/值列表是否包含元素。如果包含,我们取第一个元素并提取其值。键和值都传递给findByAttribute函数。
你可能希望通过提供由多个GET参数提供的多个参数来进一步改进版本 2,以提供搜索支持。我将把这个留给你作为练习。
链接数据
每个目录应用程序都支持与该项目绑定的图片或图片集。为此,在下一章中,我们将看到如何与 MongoDB 中的二进制对象一起工作。然而,现在是决定如何将二进制数据语义链接到项目文档的时候了。以这种方式扩展模型模式,使其包含文档中二进制数据的 base64 表示,绝对不是一个好主意,因为将文本编码和二进制数据混合在一个格式中从来都不是一个好主意。这增加了应用程序的复杂性,并使其容易出错:
{
"_id": "5a4c004b0eed73835833cc9a",
"itemId": "1",
"itemName": "Sports Watch",
"price": 100,
"currency": "EUR",
"categories": [
"Watches",
"Sports Watches"
],
"image":"
iVBORw0KGgoAAAANSUhEUgAAAJEAAACRCAMAAAD0BqoRAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNuzjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MjMwNjQ1NDdFNjJCMTFERkI5QzU4OTFCMjJCQzEzM0EiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MjMwNjQ1NDhFNjJCMTFERkI5QzU4OTFCMjJCQzEzM0EiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoyMzA2NDU0NUU2MkIxMURGQjlDNTg5MUIyMkJDMTMzQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoyMzA2NDU0NkU2MkIxMURGQjlDNTg5MUIyMkJDMTMzQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Px5Xq1XXhWFY1+v151/b3ij5tI/GPEVP0e8U/SPAABPLjHnaJ6XvAAAAAElFTkSuQmCC
"}
想象一下,对于一个非过滤查询的结果可以有多大,如果只有几百个项目,并且所有这些项目都将图片的二进制表示作为 JSON 属性的值。为了避免这种情况,我们将为每个项目返回图片,图片的 URL 逻辑上链接到资源的 URL——/catalog/v2/item/{itemId}/image。
这样,如果某个项目分配了图片,它将在一个已知的位置被提供。然而,这种方法并没有在语义上将二进制项目与其对应的资源链接起来,就像在访问/catalog/v2/item/{itemId}时,没有任何指示表明它是否分配了图片。为了解决这个问题,让我们在项目路由的响应中使用一个自定义的 HTTP 头:
GET http://localhost:3000/catalog/v2/item/1 HTTP/1.1
Host: localhost:3000
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.1.1 (java 1.5)
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 152
Image-Url: http://localhost:3000/catalog/v2/item/1/image
ETag: W/"98-2nJj2mZdLV2YDME3WYCyEwIXfuA"
Date: Thu, 01 Feb 2018 13:50:43 GMT
Connection: keep-alive
{
"_id": "5a4c004b0eed73835833cc9a",
"itemId": "1",
"itemName": "Sports Watch",
"price": 100,
"currency": "EUR",
"__v": 0,
"categories": [
"Watches",
"Sports Watches"
]
}
当响应中存在时,Image-Url头指示该项目绑定了一个额外的资源,并且头部的值提供了它可用的地址。使用这种方法,我们将二进制资源在语义上链接到了我们的文档中。
在下一章中,我们将实现处理与目录中项目绑定的任意项目操作的路线。
摘要
在本章中,我们详细讨论了如何通过 RESTful API 公开资源;我们密切关注 URL 最佳实践,并研究了 HTTP 状态码的适当使用,这些状态码表示我们操作的各种状态。
我们讨论了版本控制和可扩展性的主题,其中我们使用了301 Moved Permanently状态码来自动将 API 调用重定向到不同的 URL。
最后,我们找到了如何将我们的资源项目与任意二进制表示的数据进行语义链接的方法。
第十六章:实现一个完整的 RESTful 服务
到目前为止,我们已经创建了我们的 RESTful 服务的第二个版本,并且通过不同的 URL 公开了这两个版本,以确保向后兼容。我们为其数据库层实现了单元测试,并讨论了如何适当地使用 HTTP 状态码。在本章中,我们将扩展该实现——通过向服务的第二个版本提供非文档—二进制数据的处理,并将其相应地链接到相关的文档。
我们将探讨一种方便地向消费者展示大型结果集的方法。为此,我们将向我们的 API 引入分页以及进一步的过滤功能。
有时候应该考虑将缓存数据响应作为一个选项。我们将探讨其优点和缺点,并在必要时决定启用缓存。
最后,我们将深入探讨 REST 服务的发现和探索。
总结一下,以下是将目录数据服务转变为完整 RESTful 服务需要进一步实现的内容:
-
处理任意数据
-
在现实世界中处理链接数据
-
分页和过滤
-
缓存
-
发现和探索
处理任意数据
MongoDB 使用 BSON(二进制 JSON)作为主要的数据格式。它是一种二进制格式,在单个称为document的实体中存储键/值对。例如,一个示例 JSON,{"hello":"world"},在 BSON 编码时变为\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00。
BSON 存储数据而不是字面量。例如,如果图像是文档的一部分,它不需要被转换为 base64 编码的字符串;相反,它将直接以二进制数据的形式存储,与普通的 JSON 不同,它通常将此类数据表示为 base64 编码的字节,但这显然不是最有效的方式。
Mongoose 模式允许通过模式类型—buffer—以 BSON 格式存储二进制内容。它可以存储高达 16 MB 的二进制内容(图像、ZIP 存档等)。相对较小的存储容量背后的原因是防止在传输过程中过度使用内存和带宽。
GridFS规范解决了 BSON 的限制,并允许您处理大于 16 MB 的数据。GridFS 将数据分成存储为单独文档条目的块。默认情况下,每个块的大小高达 255 KB。当从数据存储请求数据时,GridFS 驱动程序检索所有所需的块,并按组装顺序返回它们,就像它们从未被分割过一样。这种机制不仅允许存储大于 16 MB 的数据,还允许消费者以部分形式检索数据,这样就不必将其完全加载到内存中。因此,该规范隐式地启用了流式传输支持。
GridFS 实际上提供了更多功能——它支持为给定的二进制数据存储元数据,例如其格式、文件名、大小等。元数据存储在单独的文件中,可用于更复杂的查询。有一个非常实用的 Node.js 模块叫做 gridfs-stream。它使得在 MongoDB 中轻松进行数据流进出成为可能,就像在其他模块中一样,它作为一个 npm 包安装。因此,让我们全局安装它并看看它是如何使用的;我们还将使用 -s 选项以确保项目 package.json 中的依赖项得到更新:
npm install -g -s gridfs-stream
要创建一个 Grid 实例,你需要确保数据库连接已经打开:
const mongoose = require('mongoose')
const Grid = require('gridfs-stream');
mongoose.connect('mongodb://localhost/catalog');
var connection = mongoose.connection;
var gfs = Grid(connection.db, mongoose.mongo);
通过 createReadStream() 和 createWriteStream() 函数进行流式读写操作。每个流进数据库的数据都必须设置一个 ObjectId 属性。ObjectId 唯一地标识二进制数据条目,就像它标识 MongoDB 中的任何其他文档一样;使用这个 ObjectId,我们可以通过这个标识符在 MongoDB 集合中找到或删除它。
让我们通过添加获取、添加和删除分配给项目的图像的功能来扩展目录服务。为了简单起见,该服务将支持每个项目一个图像,因此将有一个单独的函数负责添加图像。每次调用它时,它都会覆盖现有的图像,因此合适的名称是 saveImage:
exports.saveImage = function(gfs, request, response) {
var writeStream = gfs.createWriteStream({
filename : request.params.itemId,
mode : 'w'
});
writeStream.on('error', function(error) {
response.send('500', 'Internal Server Error');
console.log(error);
return;
})
writeStream.on('close', function() {
readImage(gfs, request, response);
});
request.pipe(writeStream);
}
如您所见,我们只需要创建一个 GridFS 写入流实例来清除 MongoDB 中的数据。它需要一些选项,提供 MongoDB 条目的 ObjectId 以及一些额外的元数据,例如标题以及写入模式。然后,我们简单地调用请求的 pipe 函数。管道操作将导致将请求中的数据流到写入流中,并以这种方式安全地存储在 MongoDB 中。一旦存储,与 writeStream 关联的 close 事件就会发生,这时我们的函数会读取数据库中存储的任何内容,并在 HTTP 响应中返回该图像。
检索图像是相反的过程——创建一个带有选项的 readStream,_id 参数的值应该是任意数据的 ObjectId,可选的文件名和读取模式:
function readImage(gfs, request, response) {
var imageStream = gfs.createReadStream({
filename : request.params.itemId,
mode : 'r'
});
imageStream.on('error', function(error) {
console.log(error);
response.send('404', 'Not found');
return;
});
response.setHeader('Content-Type', 'image/jpeg');
imageStream.pipe(response);
}
在将读取流管道到响应之前,必须设置适当的 Content-Type 标头,以便将任意数据以适当的图像媒体类型(在我们的例子中是 image/jpeg)呈现给客户端。
最后,我们从我们的模块中导出一个函数,用于从 MongoDB 中检索图像。我们将使用该函数将其绑定到从数据库读取图像的 express 路由:
exports.getImage = function(gfs, itemId, response) {
readImage(gfs, itemId, response);
};
从 MongoDB 删除任意数据也是直截了当的。你必须从两个内部 MongoDB 集合中删除条目,即 fs.files,其中保存了所有文件,以及 fs.files.chunks:
exports.deleteImage = function(gfs, mongodb, itemId, response) {
console.log('Deleting image for itemId:' + itemId);
var options = {
filename : itemId,
};
var chunks = mongodb.collection('fs.files.chunks');
chunks.remove(options, function (error, image) {
if (error) {
console.log(error);
response.send('500', 'Internal Server Error');
return;
} else {
console.log('Successfully deleted image for item: ' + itemId);
}
});
var files = mongodb.collection('fs.files');
files.remove(options, function (error, image) {
if (error) {
console.log(error);
response.send('500', 'Internal Server Error');
return;
}
if (image === null) {
response.send('404', 'Not found');
return;
} else {
console.log('Successfully deleted image for primary item: ' + itemId);
response.json({'deleted': true});
}
});
}
让我们将新的功能绑定到适当的物品路由并测试它:
router.get('/v2/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.getImage(gfs, request, response);
});
router.get('/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.getImage(gfs, request, response);
});
router.post('/v2/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.saveImage(gfs, request, response);
});
router.post('/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.saveImage(gfs, request.params.itemId, response);
});
router.put('/v2/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.saveImage (gfs, request.params.itemId, response);
});
router.put('/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.saveImage(gfs, request.params.itemId, response);
});
router.delete('/v2/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.deleteImage(gfs, model.connection,
request.params.itemId, response);
});
router.delete('/item/:itemId/image',
function(request, response){
var gfs = Grid(model.connection.db, mongoose.mongo);
catalogV2.deleteImage(gfs, model.connection, request.params.itemId, response);
});
由于撰写本文时,版本 2 是我们 API 的最新版本,因此它暴露的任何新功能都应在这两个位置可用:/catalog和/v2/catalog。
让我们启动 Postman 并向现有项目发送一个图像,假设我们有一个 ID 为 14 的项目/catalog/v2/item/14/image:

使用 Postman 向项目分配图像的 POST 请求。这是一个 Postman 的截图。这里的个别设置并不重要。图像的目的只是展示窗口的外观。
请求处理完毕后,二进制数据存储在网格数据存储中,图像在响应中返回。
链接
在上一章的链接数据部分,我们定义了如果目录中的项目分配了图像,这将通过名为 Image-URL 的 HTTP 头指示。
让我们修改目录的 V2 版本的findItemById函数。我们将使用 GridFS 现有的函数来检查是否有图像绑定到所选项目;如果有项目分配了图像,其 URL 将通过 Image-Url 头在响应中可用:
exports.findItemById = function (gfs, request, response) {
CatalogItem.findOne({itemId: request.params.itemId}, function(error, result) {
if (error) {
console.error(error);
response.writeHead(500, contentTypePlainText);
return;
} else {
if (!result) {
if (response != null) {
response.writeHead(404, contentTypePlainText);
response.end('Not Found');
}
return;
}
var options = {
filename : result.itemId,
};
gfs.exist(options, function(error, found) {
if (found) {
response.setHeader('Content-Type', 'application/json');
var imageUrl = request.protocol + '://' + request.get('host') + request.baseUrl + request.path + '/image';
response.setHeader('Image-Url', imageUrl);
response.send(result);
} else {
response.json(result);
}
});
}
});
}
到目前为止,我们已经将一个项目链接到其图像;然而,这使我们的数据部分链接,因为有一个从项目到其图像的链接,但没有相反的链接。让我们改变这一点,并通过修改readImage函数向图像响应提供Item-Url头:
function readImage(gfs, request, response) {
var imageStream = gfs.createReadStream({
filename : request.params.itemId,
mode : 'r'
});
imageStream.on('error', function(error) {
console.log(error);
response.send('404', 'Not found');
return;
});
var itemImageUrl = request.protocol + '://' + request.get('host') + request.baseUrl+ request.path;
var itemUrl = itemImageUrl.substring(0, itemImageUrl.indexOf('/image'));
response.setHeader('Content-Type', 'image/jpeg');
response.setHeader('Item-Url', itemUrl);
imageStream.pipe(response);
}
现在请求http://localhost:3000/catalog/v2/item/3/将返回以 JSON 格式编码的项目:
GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1
Accept-Encoding: gzip,deflate
Host: localhost:3000
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Image-Url: http://localhost:3000/catalog/v2/item/3/image
Content-Length: 137
Date: Tue, 03 Apr 2018 19:47:41 GMT
Connection: keep-alive
{
"_id": "5ab827f65d61450e40d7d984",
"itemId": "3",
"itemName": "Sports Watch 11",
"price": 99,
"currency": "USD",
"__v": 0,
"categories": ["Watches"]
}
查看响应头,我们发现Image-Url头部的值,http://localhost:3000/catalog/v2/item/3/image提供了与项目链接的图像的 URL。
请求图像结果如下:
GET http://localhost:3000/catalog/v2/item/3/image HTTP/1.1
Host: localhost:3000
Connection: Keep-Alive
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: image/jpeg
Item-Url: http://localhost:3000/catalog/v2/item/3
Connection: keep-alive
Transfer-Encoding: chunked
<BINARY DATA>
这次,响应提供了与项目链接的图像的有效负载和一个特殊头Item-Url。其值——http://localhost:3000/catalog/v2/item/3——是项目资源可用的地址。现在如果项目图像出现在图像搜索结果中,与图像链接的项目 URL 也将是结果的一部分。通过这种方式,我们在不修改或损害其有效负载的情况下,在语义上链接了这两个数据。
实现分页和过滤
一旦部署到网络,每个服务都可供大量消费者使用。他们不仅会使用它来获取数据,还会插入新数据。在某个时刻,这不可避免地会导致数据库中存在大量数据。为了保持服务用户友好并保持合理的响应时间,我们需要注意提供合理部分的大数据,确保在请求/catalog URI 时不需要返回几十万个项目。
Web 数据消费者习惯于拥有各种分页和过滤功能。在本章的早期,我们实现了findIfindItemsByAttribute()函数,它允许通过项目的任何属性进行过滤。现在,是时候引入分页功能,以便通过 URI 参数在resultset中进行导航了:
mongoose.js模型可以利用不同的插件模块在其之上提供额外的功能。这样一个插件模块是mongoose-paginate。Express 框架还提供了一个名为express-paginate的分页中间件。它提供了与 Mongoose 结果页的即插即用链接和导航:
- 在开始开发分页机制之前,我们应该安装这两个有用的模块:
npm install -g -s express-paginate
npm install -g -s mongoose-paginate
- 下一步是在我们的应用程序中创建
express-paginate中间件的实例:
expressPaginate = require('express-paginate');
- 通过调用其
middleware()函数在应用程序中初始化分页中间件。其参数指定每页的默认限制和最大结果限制:
app.use(expressPaginate.middleware(limit, maxLimit);
- 然后,在创建模型之前,将
mongoose-pagination实例作为插件提供给CatalogItem模式。以下是item.js模块如何导出该插件以及模型的方式:
var mongoose = require('mongoose');
var mongoosePaginate = require('mongoose-paginate');
var Schema = mongoose.Schema;
mongoose.connect('mongodb://localhost/catalog');
var itemSchema = new Schema ({
"itemId" : {type: String, index: {unique: true}},
"itemName": String,
"price": Number,
"currency" : String,
"categories": [String]
});
console.log('paginate');
itemSchema.plugin(mongoosePaginate);
var CatalogItem = mongoose.model('Item', itemSchema);
module.exports = {CatalogItem : CatalogItem, connection : mongoose.connection};
- 最后,调用模型的
paginate()函数以分页方式获取请求的条目:
CatalogItem.paginate({}, {page:request.query.page, limit:request.query.limit},
function (error, result){
if(error) {
console.log(error);
response.writeHead('500',
{'Content-Type' : 'text/plain'});
response.end('Internal Server Error');
} else {
response.json(result);
}
});
第一个参数是 Mongoose 应用于其查询的过滤器。第二个参数是一个对象,指定请求的页码和每页的条目数。第三个参数是一个回调处理函数,通过其参数提供结果和任何可用的错误信息:
-
error:这指定了查询是否成功执行 -
result:这是从数据库检索到的数据
express-paginate中间件通过丰富 Express 处理函数的request和response对象,使得在 Web 环境中无缝集成mongoose-paginate模块。
request对象获得两个新属性:query.limit,它告诉中间件页面上的条目数,以及query.page,它指定请求的页码。请注意,中间件将忽略初始化中间件时指定的maxLimit值更大的query.limit值。这防止了消费者覆盖最大限制,并让你完全控制你的应用程序。
这是目录模块第二版中paginate函数的实现:
exports.paginate = function(model, request, response) {
var pageSize = request.query.limit;
var page = request.query.page;
if (pageSize === undefined) {
pageSize = 100;
}
if (page === undefined) {
page = 1;
}
model.paginate({}, {page:page, limit:pageSize},
function (error, result){
if(error) {
console.log(error);
response.writeHead('500',
{'Content-Type' : 'text/plain'});
response.end('Internal Server Error');
}
else {
response.json(result);
}
});
}
以下是从包含 11 个项目的数据集中查询,每页限制五个项目的响应:
{
"docs": [
{
"_id": "5a4c004b0eed73835833cc9a",
"itemId": "1",
"itemName": "Sports Watch 1",
"price": 100,
"currency": "EUR",
"__v": 0,
"categories": [
"Watches",
"Sports Watches"
]
},
{
"_id": "5a4c0b7aad0ebbce584593ee",
"itemId": "2",
"itemName": "Sports Watch 2",
"price": 100,
"currency": "USD",
"__v": 0,
"categories": [
"Sports Watches"
]
},
{
"_id": "5a64d7ecfa1b585142008017",
"itemId": "3",
"itemName": "Sports Watch 3",
"price": 100,
"currency": "USD",
"__v": 0,
"categories": [
"Watches",
"Sports Watches"
]
},
{
"_id": "5a64d9a59f4dc4e34329b80f",
"itemId": "8",
"itemName": "Sports Watch 4",
"price": 100,
"currency": "EUR",
"__v": 0,
"categories": [
"Watches",
"Sports Watches"
]
},
{
"_id": "5a64da377d25d96e44c9c273",
"itemId": "9",
"itemName": "Sports Watch 5",
"price": 100,
"currency": "USD",
"__v": 0,
"categories": [
"Watches",
"Sports Watches"
]
}
],
"total": 11,
"limit": "5",
"page": "1",
"pages": 3
}
docs属性包含所有作为结果的一部分的项目。其大小与所选的限制值相同。pages属性提供总页数;在此示例中,其值为 3,因为 11 个项目分布在三个页面上,每个页面包含五个项目。Total属性给出了项目总数。
启用分页的最后一步是修改 /v2/ 路由以开始使用新创建的功能:
router.get('/v2/', function(request, response) {
var getParams = url.parse(request.url, true).query;
if (getParams['page'] !=null) {
catalogV2.paginate(model.CatalogItem, request, response);
} else {
var key = Object.keys(getParams)[0];
var value = getParams[key];
catalogV2.findItemsByAttribute(key, value, response);
}
});
我们将为默认路由 /catalog 使用 HTTP 302 Found 状态。这样,所有进入的请求都将被重定向到 /v2/:
router.get('/', function(request, response) {
console.log('Redirecting to v2');
response.writeHead(302, {'Location' : '/catalog/v2/'});
response.end('Version 2 is is available at /catalog/v2/: ');
});
在这里使用适当的重定向状态码对于任何 RESTful 网络服务的生命周期至关重要。返回 302 Found,然后进行重定向,确保 API 的消费者始终可以在该位置获得最新的版本。此外,从开发角度来看,使用重定向而不是代码重复也是一种良好的实践。
当你在两个版本之间时,你应该始终考虑使用 HTTP 301 Moved Permanently 状态来显示前一个版本已移动的位置,以及使用 HTTP 302 Found 状态来显示当前版本的实际 URI。
现在,回到分页,由于请求的页码和限制数字作为 GET 参数提供,我们不希望将其与过滤功能混淆,因此有一个显式的检查。只有在请求中提供了页码或限制 GET 参数时,才会使用分页。否则,将执行搜索。
最初,我们设置了最大限制为 100 个结果和默认限制为 10,因此,在尝试新的分页功能之前,请确保将比默认限制更多的项目插入到数据库中。这将使测试结果更加明显。
现在,让我们试一试。请求 /catalog?limit=3 将返回一个只包含两个项目的列表,如下所示:

分页启用结果。这是 Postman 的截图。这里的个别设置并不重要。图片的目的只是展示窗口的外观。
如示例所示,总页数是四页。总项目数存储在数据库中为 11。由于我们没有在请求中指定页码参数,分页隐式地返回了第一页。要导航到下一页,只需将 &page=2 添加到 URI 中。
还可以尝试更改 limit 属性,请求 /catalog/v2?limit=4。这将返回前四个项目,并且响应将显示总页数为三页。
缓存
当我们讨论 Roy Fielding 定义的 REST 原则时,我们提到缓存是一个相当敏感的话题。最终,我们的消费者在执行查询时将期望获得最新的结果。然而,从统计学的角度来看,在网络上公开的数据更有可能被读取而不是更新或删除。
因此,考虑到从服务器卸载部分负载到缓存,一些由公共 URL 暴露的资源成为数百万请求的主题是合理的。HTTP 协议允许我们在给定时间段内缓存一些响应。例如,当在短时间内接收到多个请求时,查询给定组(如 /catalog/v2)的目录中的所有项目,我们的服务可以利用特殊的 HTTP 头,这将强制 HTTP 服务器在定义的时间段内缓存响应。这将防止对底层数据库服务器的重复请求。
在 HTTP 服务器级别上通过特殊的响应头来实现缓存。HTTP 服务器使用 Cache-Control 头来指定给定响应应该被缓存多长时间。缓存需要无效化之前的时间通过其 max-age 属性设置,其值以秒为单位提供。当然,有一个很好的 Node.js 模块提供了用于缓存的中间件函数,称为 express-cache-control。
在 Express 应用程序中提供 Cache-Control 头
让我们使用 NPM 软件包管理器来安装它;再次,我们将全局安装它并使用 -s 选项,这将自动更新 package.json 文件以包含新的 express-cache-control 依赖项:
npm install -g -s express-cache-control
使用 express-cache-control 中间件启用缓存需要三个简单的步骤:
- 获取模块:
CacheControl = require("express-cache-control")
- 创建
CacheControl中间件的实例:
var cache = new CacheControl().middleware;
- 将中间件实例绑定到你想启用缓存的路线:
router.get('/v2/', cache('minutes', 1), function(request, response) {
var getParams = url.parse(request.url, true).query;
if (getParams['page'] !=null || getParams['limit'] != null) {
catalogV2.paginate(model.CatalogItem, request, response);
} else {
var key = Object.keys(getParams)[0];
var value = getParams[key];
catalogV2.findItemsByAttribute(key, value, response);
}
});
通常,提供许多结果条目的常见 URI 应该是缓存的主题,而不是提供具体条目数据的 URI。在我们的应用程序中,只有 /catalog URI 会使用缓存。max-age 属性必须根据你应用程序的负载来选择,以最小化不准确响应。
让我们通过在 Postman 中请求 /catalog/v2 来测试我们的更改:

缓存控制头指示已启用缓存。这是一张 Postman 的截图。这里的个别设置并不重要。图片的目的只是展示窗口的外观。
如预期,express-cache-control 中间件已经完成了它的任务——Cache-Control 头现在包含在响应中。must-revalidate 选项确保在 max-age 间隔过期后缓存内容被无效化。现在,如果你为特定项再次发起请求,你会看到响应没有使用 express-cache-control 中间件,这是因为它需要在每个单独的路由中显式提供。它不会用于相互派生的 URI。
对任何 /v1/ 路由的 GET 请求的响应将不包含 Cache-Control 头,因为它是我们 API 的第 2 版支持的,而 Cache-Control 中间件仅在主目录路由 /catalog/v2/ 或 /catalog 中使用。
摘要
恭喜!在本章中,你成功地将一个支持 REST 的端点转换成了一个完整的 RESTful 网络服务,该服务支持过滤以提高可用性,并支持分页以方便导航。该服务提供任意和 JSON 数据,并且已准备好应对高负载场景,因为它在其关键部分实现了缓存。有一点应该引起你的注意,那就是在处理任何公共 API 的新旧版本之间的重定向时,适当使用 HTTP 状态码。
在 REST 应用程序中实现适当的 HTTP 状态非常重要,因此我们使用了相当罕见的状况,例如301 永久移动和302 找到。
第十七章:消费 RESTful API
为了展示与我们 API 消费相关的一些更高级的主题,我们将实现一个非常简单的网络客户端。这将帮助我们涵盖这些主题,并且可以作为目录消费者的参考实现。对于这个前端客户端,我们将使用著名的 JavaScript 库,jQuery。利用它将帮助我们涵盖以下内容:
-
使用 jQuery 消费 RESTful 服务
-
内容分发网络
-
在线故障排除和问题识别
-
跨源资源共享策略
-
处理不同 HTTP 状态码的客户端处理
使用 jQuery 消费 RESTful 服务
JQuery 是一个快速、轻量级且功能强大的 JavaScript 库;它通过在 DOM 三加载后直接访问 HTML 元素来消除与 DOM 相关的复杂性。要在 HTML 文档中使用 jQuery,你必须导入它:
<script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>
假设在一个 HTML 文档的某个地方,有一个定义为<input type="button" id="btnDelete" value="Delete"/>的按钮。
要将函数分配给这个按钮的点击事件,使用 JQuery 意味着我们需要做以下操作:
-
在 HTML 文档中导入 jQuery 库
-
确保 HTML 文档的 DOM 文档已完全加载
-
使用由 ID 属性定义的标识符访问按钮
-
将处理函数作为参数传递给
click事件:
$(document).ready(function() {
$('#btn').click(function () {
alert('Clicked');
});
});
$('#identifier')表达式提供了对 DOM 三中元素的直接访问,$表示一个对象被引用,括号内的值,由#前缀指定,指定了其标识符。jQuery 只有在整个文档加载完毕后才能访问元素;这就是为什么应该在${document).ready()块作用域内访问元素。
类似地,你可以通过一个标识符txt访问文本输入的值:
$(document).ready(function() {
var textValue = $('#txt').val();
});
});
jQuery 中的$(document)对象是预定义的,代表 HTML 页面的整个 DOM 文档。以类似的方式,jQuery 预定义了一个用于 AJAX 启用通信的函数,即发送 HTTP 请求到 HTTP 端点。这个函数以Asynchronous JavaScript + XML- AJAX 命名,它是 JavaScript 应用程序与 HTTP 启用后端通信的事实标准。如今,JSON被广泛使用;然而,AJAX 的命名转换仍然用作异步通信的术语,无论数据格式如何;这就是为什么 jQuery 中的预定义函数被称为$.ajax(options, handlers)。
要使用$.ajax函数发送 HTTP 请求,通过提供端点 URL、请求的 HTTP 方法和其内容类型来调用它;结果将在回调函数中返回。以下示例显示了如何从我们的目录中请求标识符为 3 的项目:
$.ajax({
contentType: 'application/json',
url: 'http://localhost:3000/catalog/v2/item/3',
type: 'GET',
success: function (item, status, xhr) {
if (status === 'success') {
//the item is successfully retrieved load & display its details here
}
}
,error: function (xhr, options, error) {
//Item was not retrieved due to an error handle it here
}
});
});
向端点发送数据相当类似:
$.ajax({
url: "http://localhost:3000/catalog/v2/",
type: "POST",
dataType: "json",
data: JSON.stringify(newItem),
success: function (item, status, xhr) {
if (status === 'success') {
//item was created successfully
}
},
error: function(xhr, options, error) {
//Error occurred while creating the iteam
}
});
简单地使用适当的选项type设置为 POST,并将dateType设置为 JSON。这将指定要发送到端点的 POST 请求是以 JSON 格式。对象的有效负载作为data属性的值提供。
调用delete方法相当类似:
$.ajax({
contentType: 'application/json',
url: 'http://localhost:3000/catalog/v2/item/3',
type: 'DELETE',
success: function (item, status, xhr) {
if (status === 'success') {
//handle successful deletion
}
}
,error: function (xhr, options, error) {
//handle errors on delete
}
});
对于本书的范围,对 jQuery 如何工作有一个基本理解就足够了。现在,让我们将这些内容粘合在一起,创建两个 HTML 页面;这样,我们将处理创建、显示和删除目录中的项,从显示项并允许其删除的页面开始。此页面使用GET请求从目录中加载项,然后在 HTML 页面中以类似表格的方式显示项的属性:
<html>
<head><title>Item</title></head>
<body>
<script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>
<script>
$(document).ready(function() {
$('#btnDelete').click(function () {
$.ajax({
contentType: 'application/json',
url: 'http://localhost:3000/catalog/v2/item/3',
type: 'DELETE',
success: function (item, status, xhr) {
if (status === 'success') {
$('#item').text('Deleted');
$('#price').text('Deleted');
$('#categories').text('Deleted');
}
}
,error: function (xhr, options, error) {
alert('Unable to delete item');
}
});
});
$.ajax({
contentType: 'application/json',
url: 'http://localhost:3000/catalog/v2/item/3',
type: 'GET',
success: function (item, status, xhr) {
if (status === 'success') {
$('#item').text(item.itemName);
$('#price').text(item.price + ' ' + item.currency);
$('#categories').text(item.categories);
}
}
,error: function (xhr, options, error) {
alert('Unable to load details');
}
});
});
</script>
<div>
<div style="position: relative">
<div style="float:left; width: 80px;">Item: </div>
<div><span id="item"/>k</div>
</div>
<div style="position: relative">
<div style="float:left; width: 80px;">Price: </div>
<div><span id="price"/>jjj</div>
</div>
<div style="position: relative">
<div style="float:left; width: 80px;">Categories: </div>
<div><span id="categories"/>jjj</div>
</div>
<div><input type="button" id="btnDelete" value="Delete"/></div>
</div>
</body>
</html>
处理创建的页面相当相似。然而,它为项目的字段提供文本输入而不是 span 标签,而在视图页面将显示加载项的属性数据。JQuery 提供了一个简化的输入控件访问模型,而不是 DOM——只需按如下方式访问输入元素:
<html>
<head><title>Item</title></head>
<body>
<script type="text/javascript" src="img/jquery-3.3.1.min.js "></script>
<script>
$(document).ready(function() {
$('#btnCreate').click(function(){
var txtItemName = $('#txtItem').val();
var txtItemPrice = $('#txtItemPrice').val();
var txtItemCurrency = $('#txtItemCurrency').val();
var newItem = {
itemId: 4,
itemName: txtItemName,
price: txtItemPrice,
currency: txtItemCurrency,
categories: [
"Watches"
]
};
$.ajax({
url: "http://localhost:3000/catalog/v2/",
type: "POST",
dataType: "json",
data: JSON.stringify(newItem),
success: function (item, status, xhr) {
alert(status);
}
});
})
});
</script>
<div>
<div style="position: relative">
<div style="float:left; width: 80px;">Id: </div>
<div><input type="text" id="id"/></div>
<div style="float:left; width: 80px;">Item: </div>
<div><input type="text" id="txtItem"/></div>
</div>
<div style="position: relative">
<div style="float:left; width: 80px;">Price: </div>
<div><input type="text" id="price"/></div>
</div>
<div style="position: relative">
<div style="float:left; width: 80px;">Categories: </div>
<div><input type="text" id="categories"/></div>
</div>
<div><input type="button" id="btnCreate" value="Create"/></div>
</div>
</body>
</html>
让我们试一试,通过在浏览器中选择直接从文件系统中打开我们的静态页面来在视图页面中加载现有项。看起来我们遇到了某种问题,因为没有显示任何内容。启用浏览器开发者工具进行客户端调试也没有提供更多信息:

它表示内容部分被阻止;然而,并不清楚这是否是由于后端相关错误,或者客户端发生了某些错误。我们将在下一节中查看如何解决此类问题。
在线故障排除和问题识别
有时客户端和服务器之间的交互会失败,此类失败的原因通常需要分析;否则,其根本原因将保持未知。我们发现我们的客户端应用程序没有加载,因此没有显示现有项的数据。让我们通过在客户端和服务器之间设置一个http隧道来尝试调查其根本原因。这将是一种基于 MiM(中间人)的调查,因为我们将在一个端口上监听并将传入的请求重定向到另一个端口,以查看服务器是否返回正确的响应或其管道在中间某处被破坏。市面上有各种 TCP 隧道;我一直在使用 GitHub 上可用的简单开源隧道,网址为github.com/vakuum/tcptunnel。其作者还维护了一个单独的网站,您可以从那里下载适用于最常见操作系统的预构建二进制文件;它们可在www.vakuumverpackt.de/tcptunnel/找到。
在您构建或下载隧道副本后,按照以下方式启动它:
./tcptunnel --local-port=3001 --remote-port=3000 --remote-host=localhost --log
这将启动应用程序监听端口 3001,并将每个传入请求转发到端口 3000 的位置;--log选项指定所有通过隧道的流量都应该在控制台中记录。最后,修改 HTML 页面以使用端口 3001 而不是 3000,然后让我们在端口3001上对新请求 id 为 3 的项目执行新的 GET 请求,这次是http://localhost:3001/catalog/v2/item/3:

令人惊讶的是,隧道显示服务器以200 OK和相关的有效负载正常响应。所以看起来问题不在服务器端。
好吧,既然错误显然不是在服务器端,我们就尝试深入调查客户端发生了什么。如今,所有流行的浏览器都有所谓的开发者工具。它们提供了对http日志、动态渲染的代码、HTML 文档的 DOM 树等的访问。让我们使用 Mozilla Firefox 调用我们的 RESTful GET 操作,看看它的 Web 控制台会记录关于我们请求的什么信息。打开 Mozilla Firefox 菜单,选择Web 开发者,然后选择浏览器控制台:

哈哈!看起来我们找到了:跨源请求被阻止:同源策略阻止读取远程资源...。
这个错误在客户端级别阻止了服务器端响应。在下一节中,我们将看到这实际上意味着什么。
跨源资源共享
跨站 HTTP 请求是指指向从请求它们的域不同的域的资源请求。在我们的情况下,我们从文件系统启动客户端,并从网络地址请求资源。这被认为是一个潜在的跨站脚本请求,根据w3.org/cors/TR/cors的W3C 建议,应该谨慎处理。这意味着如果请求外部资源,请求它的域——它的源——应该在一个头中明确指定,只要通常不允许加载外部资源。这种机制防止跨站脚本(XSS)攻击,并且基于 HTTP 头。
以下 HTTP 请求头指定了客户端如何处理外部资源:
-
Origin定义了请求的来源 -
Access-Control-Request-Method定义了请求资源所使用的 HTTP 方法 -
Access-Control-Request-Header定义了与外部资源请求一起允许的任何头
在服务器端,以下头指示响应是否适合 CORS 启用客户端请求:
-
Access-Control-Allow-Origin:此头如果存在,则指定请求者的主机被允许通过重复它,或者它可以指定所有远程源都被允许通过返回一个通配符:* -
Access-Control-Allow-Methods:此标头指定服务器允许来自跨域的 HTTP 方法 -
Access-Control-Allow-Headers:此标头指定服务器允许来自跨域的 HTTP 标头
有一些其他的 Access-Control-* 标头,可以在根据凭据和请求的最大存活时间来决定是否提供 XSS 请求时使用,但基本上,最重要的还是用于允许的来源、允许的方法和允许的标头。
有一个节点模块可以处理服务器端的 CORS 配置;它通过 npm install -g cors 安装,并且可以通过中间件模块轻松地在我们的应用程序中启用。只需在所有公开的路由中使用它,并将其传递给应用程序:
app.use(cors());
在启用 cors 中间件之后使用隧道,以查看服务器现在将优雅地处理来自不同来源的请求,通过提供设置为 '*' 的 "Access-Control-Allow-Origin" 标头:

内容分发网络
当我们将 jQuery 库导入到我们的客户端应用程序中时,我们直接从供应商那里引用其优化的源代码,如下所示:<script type="text/javascript" src="img/jquery-3.3.1.min.js "/>.
现在,假设由于某种原因,这个网站暂时或永久性地关闭;这将使我们的应用程序无法使用,因为导入将无法工作。
内容分发网络在这些情况下提供了帮助。它们作为库或其他静态媒体内容的存储库,确保即使供应商出现问题,所需资源也将可用,不会中断服务。最受欢迎的 JavaScript CDN 之一是 cdnjs.com/;它提供了最常用的 JS 库。我们将把我们的客户端切换到从这个 CDN 而不是从其供应商网站 <script type="text/javascript" src="img/jquery-3.3.1.min.js "/> 引用 jQuery 库。
虽然直接下载您的 JS 库并将它们放置在 node.js 项目的静态目录中几乎没有什么问题,但这可能会导致您的库依赖项中直接包含本地更改和修复。这很容易导致不兼容的更改,并阻止您的应用程序轻松切换到未来的新版本。只要您的依赖项是开源的,您就应该努力通过贡献修复或报告错误来改进它们,而不是在您自己的本地分支中进行修复。尽管如此,如果您不幸遇到一个您可以轻松修复的错误,您可以将库分支以更快地解决问题。然而,始终考虑将修复贡献回社区。一旦被接受,就切换回官方版本;否则,您在下一次遇到问题时会发现自己处于困难境地,如果从分支版本报告,社区会跟踪它更加困难。这就是开源的美丽之处,这就是为什么您应该始终考虑消费 JavaScript API 的内容分发网络。它们将在您应用程序生命周期的任何时刻为您提供所需的稳定性和支持。
在客户端处理 HTTP 状态码
我们花了不少时间来处理 RESTful 服务应该如何优雅地表示每个状态,包括错误状态。一个定义良好的 API 应该要求其消费者优雅地处理所有错误,并为每个状态提供尽可能多的信息,而不仅仅是说“发生了错误”。这就是为什么它应该查找返回的状态码,并清楚地区分由错误的负载引起的客户端请求,例如 400 Bad Request 或由错误的媒体类型引起的 415 Unsupported media types,或者与身份验证相关的错误,例如 401 Unauthorized。
错误响应的状态码在 jQuery 回调函数的 error 回调中可用,并应用于向请求提供详细信息:
$.ajax({
url: "http://localhost:3000/catalog/v2/",
type: "POST",
dataType: "json",
data: JSON.stringify(newItem),
success: function (item, status, jqXHR) {
alert(status);
},
error: function(jqXHR, statusText, error) {
switch(jqXHR.status) {
case 400: alert('Bad request'); break;
case 401: alert('Unauthroizaed'); break;
case 404: alert('Not found'); break;
//handle any other client errors below
case 500: alert('Internal server error); break;
//handle any other server errors below
}
}
});
失败的请求由错误回调函数处理。它将 jqXHR ——即 XmlHttpRequest JavaScript—对象作为其第一个参数提供。它携带所有请求/响应相关信息的传递,例如状态码和头信息。使用它来确定请求的服务器返回了什么,以便您的应用程序可以更细致地处理不同的错误。
摘要
在本章中,我们使用 jQuery 库实现了一个简单的基于 Web 的客户端。我们利用这个客户端来演示跨源资源共享策略的工作原理,并使用中间人手段来调试线上的问题。最后,我们探讨了客户端应该如何处理错误。这一章让我们离旅程的终点更近一步,因为我们获得了我们服务的第一个消费者。在下一章中,我们将向您介绍将服务推向生产前的最后一步——选择其安全模型。
第十八章:保护应用安全
一旦在生产环境中部署,应用就会暴露给大量的请求。不可避免地,其中一些将是恶意的。这带来了仅授予认证用户明确访问权限的需求,也就是说,认证一定数量的消费者以访问你的服务。大多数消费者将只使用服务进行数据提供。然而,少数消费者需要能够提供新的或修改现有的目录数据。为了确保只有适当的消费者能够执行POST、PUT和DELETE请求,我们必须在我们的应用中引入授权的概念,这将仅授予明确选定的用户修改权限。
数据服务可能提供敏感的私人信息,例如电子邮件地址;HTTP 协议作为一个文本协议,可能不够安全。通过它传输的信息可能受到中间人攻击,这可能导致数据泄露。为了防止这种情况,应该使用传输层安全(TLS)。HTTPS 协议加密传输的数据,确保只有拥有正确解密密钥的适当消费者才能消费服务公开的数据。
在本章中,我们将探讨 Node.js 如何启用以下安全功能:
-
基本身份验证
-
基于护照的基本身份验证
-
基于护照的第三方认证
-
授权
-
传输层安全
认证
当一个应用认为用户的身份在经过一个受信任的存储成功验证后,就认为用户已经认证。这些受信任的存储可以是任何类型的特别维护的数据库,存储着应用的凭据(基本身份验证),或者是一个第三方服务,该服务会检查给定的身份是否与其自己的受信任存储相匹配(第三方认证)。
基本身份验证
HTTP 基本身份验证是现有最流行和最直接的认证机制之一。它依赖于请求中的 HTTP 头,这些头提供了用户的凭据。服务器可以选择回复一个头,强制客户端进行认证。以下图显示了在执行基本身份验证时的客户端-服务器交互:

每当向由 HTTP 基本身份验证保护的目标发送 HTTP 请求时,服务器会回复一个 HTTP 401 未授权状态码,并且可选地,还会回复一个WWW-Authenticate头。这个头会强制客户端发送另一个请求,包含Authorization头,指定认证方法是basic。这个请求后面跟着一个 base64 编码的键/值对,提供用于认证的用户名和密码。可选地,服务器可以指定一个带有realm属性的消息给客户端。
此属性指定具有相同 realm 值的资源应支持相同的认证方式。在前面的图中,realm 消息是 MyRealmName。客户端通过发送包含值 Basic YWRtaW46YWRtaW4 的 Authentication 标头来进行认证,指定使用 Basic 认证,后跟 base64 编码的值。在图中,base64 解码的 YWRtaW46YWRtaW4 文本表示 admin:admin 文本。如果此类用户名/密码组合成功认证,HTTP 服务器将以请求项的 JSON 有效负载响应。如果认证失败,服务器将以 401 Unauthorized 状态码响应,但这次不包含 WWW-Authenticate 标头。
Passport
现在有很多认证方法可供选择。可能最受欢迎的方法是基本认证,其中每个用户都有自己的用户名和密码,以及第三方认证,用户可以使用他们已经存在的账户来识别自己,例如个人社交服务如 LinkedIn、Facebook 和 Twitter。
选择最适合 Web API 的认证类型主要取决于其消费者。显然,一个消费 API 以获取数据的程序不太可能使用个人社交账户进行认证。这种方法更适合 API 通过前端直接由人类使用的情况。
实现一种能够轻松在不同的认证方法之间切换的解决方案是一个复杂且耗时的工作。实际上,如果不考虑应用程序的初始设计阶段,这可能几乎是不可能的。
Passport 是一个针对 Node.js 的认证中间件,专门为那些需要轻松在一种认证方式之间切换的场景而创建。它具有模块化架构,使得可以使用特定的认证提供者,称为 strategy。策略负责实现所选的认证方法。
现在有很多认证策略可供选择,例如,常规的基本认证策略或基于社交平台的策略,如 Facebook、LinkedIn 和 Twitter。有关可用策略的完整列表,请参考官方 Passport 网站,www.passportjs.org/。
Passport 的基本认证策略
现在是时候看看如何利用 Passport 的策略了;我们将从基本的认证策略开始;既然我们已经知道了基本认证是如何工作的,这是一个逻辑上的选择。
如同往常,我们将使用 NPM 包管理器安装相关的模块。我们需要 passport 模块,它提供了基础功能,允许你插入不同的认证策略,以及由 passport-http 模块提供的具体的基本认证策略:
npm install passport
npm install passport-http
接下来,我们必须实例化 Passport 中间件和基本认证策略。BasicStrategy将一个回调函数作为参数,该函数检查提供的用户名/密码组合是否有效。最后,Passport 的 authenticate 方法作为中间件函数提供给 express 路由,确保未经认证的请求会被拒绝,并返回适当的401 未授权状态:
const passport = require('passport');
const BasicStrategy = require('passport-http').BasicStrategy;
passport.use(new BasicStrategy(function(username, password, done) {
if (username == 'user' && password=='default') {
return done(null, username);
}
}));
router.get('/v1/',
passport.authenticate('basic', { session: false }),
function(request, response, next) {
catalogV1.findAllItems(response);
});
router.get('/v2/',
passport.authenticate('basic', { session: false }),
function(request, response, next) {
catalogV1.findAllItems(response);
});
router.get('/',
passport.authenticate('basic', { session: false }),
function(request, response, next) {
catalogV1.findAllItems(response);
});
BasicStrategy构造函数接受一个处理函数作为参数。它为我们提供了访问客户端提供的用户名和密码,以及 Passport 中间件的done()函数,该函数通知 Passport 用户是否已成功认证。通过将user作为参数调用done()函数来授予认证,或者通过传递error参数来撤销认证:
passport.use(new BasicStrategy(
function(username, password, done) {
AuthUser.findOne({username: username, password: password},
function(error, user) {
if (error) {
return done(error);
} else {
if (!user) {
console.log('unknown user');
return done(error);
} else {
console.log(user.username + '
authenticated successfully');
return done(null, user);
}
}
});
})
);
最后,在路由中间件中使用passort authenticate()函数将其附加到特定的 HTTP 方法处理器函数。
在我们的情况下,我们指定我们不想在会话中存储任何认证细节。这是因为,当使用基本认证时,没有必要在会话中存储任何用户信息,因为每个请求都包含提供登录详情的Authorization头。
Passport 的 OAuth 策略
OAuth 是一个第三方授权的开放标准,它定义了一个用于授权第三方认证提供者的委托协议。OAuth 使用特殊的令牌,一旦颁发,就代替用户凭据来识别用户。让我们更详细地看看 OAuth 工作流程,以及一个示例场景。场景中的主要参与者是一个用户,他与Web 应用程序交互,该应用程序从提供某种数据的后端系统中消费 RESTful 服务。Web 应用程序将它的授权委托给一个单独的第三方授权服务器。

-
用户请求一个需要认证的 Web 应用程序,以与后端服务建立通信。这是初始请求,因此用户尚未认证,所以他们会被重定向到登录页面,要求他们提供相关第三方账户的凭据。
-
在成功认证后,认证服务器向 Web 应用程序颁发一个授权码。这个授权码是颁发给客户端的客户端 ID 和提供者颁发的秘密的组合。它们应该从 Web 应用程序发送到认证服务器,并交换为有限生命周期的访问令牌。
-
网络应用程序使用认证令牌进行认证,直到其过期。之后,它必须使用授权码请求一个新的令牌。
Passport.js 通过一个单独的策略模块隐藏了此过程的复杂性,该模块自动化 OAuth 工作流程。它在npm仓库中可用。
npm install passport-oauth
创建策略的实例,并为其提供请求令牌和进行身份验证的 URL,以及它个人的消费者密钥和您选择的秘密短语。
var passport = require('passport')
, OAuthStrategy = require('passport-oauth').OAuthStrategy;
passport.use('provider', new OAuthStrategy({
requestTokenURL: 'https://www.provider.com/oauth/request_token',
accessTokenURL: 'https://www.provider.com/oauth/access_token',
userAuthorizationURL: 'https://www.provider.com/oauth/authorize',
consumerKey: '123-456-789',
consumerSecret: 'secret'
callbackURL: 'https://www.example.com/auth/provider/callback'
}, function(token, tokenSecret, profile, done) {
//lookup the profile and authenticate and call done
}
));
Passport.js 提供了针对不同提供者的独立策略包装,例如领英或 GitHub。它们确保您的应用程序与令牌发行 URL 保持最新。一旦您决定了想要支持的提供者,您应该检查它们的具体策略。
Passport 的第三方身份验证策略
今天,几乎每个人都至少拥有一个个人公开的社交媒体账户,例如 Twitter、Facebook 和领英。最近,网站允许他们的访客通过点击图标来通过他们的社交账户进行身份验证,从而将他们的社交服务账户绑定到服务内部自动生成的账户,这已经成为一种非常流行的做法。
这种方法对于通常永久登录至少一个账户的 Web 用户来说非常方便。如果他们当前未登录,点击图标将把他们重定向到他们的社交服务登录页面,并在成功登录后发生另一次重定向,确保用户获得他们最初请求的内容。当涉及到通过 Web API 暴露数据时,这种方法并不是一个真正的选择。
公开暴露的 API 无法预测它们将被人类还是应用程序消费。此外,API 通常不会直接由人类消费。因此,当您作为 API 作者确信公开的数据将直接可供通过前端从互联网浏览器手动请求它的最终用户时,第三方身份验证是唯一的选择。一旦他们成功登录到他们的社交账户,一个唯一的用户标识符将被存储在会话中,因此您的服务需要能够适当地处理此类会话。
要启用 Passport 和 Express 的会话支持以存储用户登录信息,您必须在初始化 Passport 和其会话中间件之前初始化 Express 会话中间件:
app.use(express.session());
app.use(passport.initialize());
app.use(passport.session());
然后,指定 Passport 应将用户详细信息序列化/反序列化到或从会话中的用户。为此,Passport 提供了 serializeUser() 和 deserializeUser() 函数,它们将完整用户信息存储在会话中:
passport.serializeUser(function(user, done) { done(null, user); }); passport.deserializeUser(function(obj, done) { done(null, obj); });
初始化 Express 和 Passport 中间件的会话处理顺序很重要。Express 会话应首先传递给应用程序,然后是 Passport 会话。
在启用会话支持后,您必须决定依赖哪种第三方身份验证策略。基本上,第三方身份验证是通过第三方提供者创建的插件或应用程序启用的,例如社交服务网站。我们将简要了解创建一个允许通过 OAuth 标准进行身份验证的领英应用程序。
通常,这是通过一对与社交媒体应用程序关联的公钥和密钥(令牌)来完成的。创建 LinkedIn 应用程序很简单——您只需登录到www.linkedin.com/secure/developer并填写简短的应用信息表单。您将获得一个密钥和一个令牌以启用身份验证。执行以下步骤以启用 LinkedIn 身份验证:
-
安装
linkedin-strategy模块—npm install linkedin-strategy -
在启用会话支持后,通过
use()函数获取 LinkedIn 策略的实例并将其初始化为 Passport 中间件:
var passport = require('passport')
, LinkedInStrategy = require('passport-
linkedin').Strategy;
app.use(express.session());
app.use(passport.initialize());
app.use(passport.session());
passport.serializeUser(function(user, done) {
done(null, user);
});
passport.deserializeUser(function(obj, done) {
done(null, obj);
});
passport.use(new LinkedInStragety({
consumerKey: 'api-key',
consumerSecret: 'secret-key',
callbackURL: "http://localhost:3000/catalog/v2"
},
function(token, tokenSecret, profile, done) {
process.nextTick(function () {
return done(null, profile);
});
})
);
- 明确指定 LinkedIn 策略应作为每个单独路由的护照使用,确保会话处理已启用:
router.get('/v2/',
cache('minutes',1),
passport.authenticate('linked', { session: true}),
function(request, response) {
//...
}
});
- 提供一种方式,允许用户通过公开注销 URI 来注销,使用
request.logout:
router.get('/logout', function(req, res){
request.logout();
response.redirect('/catalog');
});
给定的第三方 URL 和服务数据可能会发生变化。在提供第三方身份验证时,您应始终参考服务策略。
授权
到目前为止,目录数据服务使用基本身份验证来保护其路由免受未知用户的影响;然而,目录应用程序应仅允许少数白名单用户修改目录中的条目。为了限制对目录的访问,我们将引入授权的概念,即一组经过身份验证的用户子集,允许他们拥有适当的权限。
当 Passport 的done()函数被调用以进行成功的登录认证时,它将作为参数接收一个已授权用户的user实例。done()函数将此用户模型实例添加到request对象中,并通过request.user属性提供对它的访问,在成功认证后。我们将利用该属性在成功认证后实现一个执行授权检查的函数:
function authorize(user, response) {
if ((user == null) || (user.role != 'Admin')) {
response.writeHead(403, { 'Content-Type' :
'text/plain'});
response.end('Forbidden');
return;
}
}
HTTP 403 Forbidden 状态码很容易与 405 Not Allowed 混淆。然而,405 Not Allowed 状态码表示请求的资源不支持特定的 HTTP 动词,因此它应仅在该上下文中使用。
authorize()函数将关闭response流,返回403 Forbidden状态码,这表示已识别的登录用户权限不足。这会撤销对资源的访问。此函数必须在执行数据操作的每个路由中使用。
下面是一个post路由实现授权的示例:
app.post('/v2',
passport.authenticate('basic', { session: false }),
function(request, response) {
authorize(request.user, response);
if (!response.closed) {
catalogV2.saveItem(request, response);
}
}
);
在调用authorize()之后,我们检查response对象是否仍然允许写入其输出,通过检查response对象的 closed 属性值。一旦调用response对象的 end 函数,它将返回true,这正是authorize()函数在用户缺乏管理员权限时所做的。因此,我们可以在我们的实现中依赖 closed 属性。
传输层安全
网上公开的信息很容易成为不同类型网络攻击的目标。通常,仅仅阻止所谓的“坏人”是不够的。有时,他们甚至懒得获取认证,可能更愿意执行中间人攻击(MiM),假装自己是消息的最终接收者,窃听传输数据的通信通道——或者更糟糕的是,在数据传输过程中篡改数据。
作为一种基于文本的协议,HTTP 以人类可读的格式传输数据,这使得它很容易成为 MiM 攻击的受害者。除非以加密格式传输,否则我们服务的所有目录数据都容易受到 MiM 攻击。在本节中,我们将从不安全的 HTTP 协议切换到安全的 HTTPS 协议。
HTTPS 由非对称加密技术保护,也称为公钥加密。它基于一对在数学上相关的密钥。用于加密的密钥称为公钥,用于解密的密钥称为私钥。其想法是向必须发送加密消息并使用私钥进行解密的合作伙伴自由提供加密密钥。
两个当事人,A和B之间典型的公钥加密通信场景如下:
-
当事人A编写消息,使用当事人B的公钥对其进行加密,并发送。
-
当事人B使用自己的私钥解密消息并处理它。
-
当事人B编写响应消息,使用当事人A的公钥对其进行加密,然后发送。
-
当事人A使用自己的私钥解密响应消息。
现在我们已经了解了公钥加密的工作原理,让我们通过这个图表中的 HTTPS 客户端-服务器通信示例来了解一个样本:

客户端向一个 SSL 加密的端点发送初始请求。服务器通过发送用于加密进一步传入请求的公钥来响应该请求。然后,客户端必须检查公钥的有效性并验证接收到的公钥的身份。在成功验证服务器的公钥后,客户端必须将其自己的公钥发送回服务器。最后,在密钥交换过程完成后,双方可以开始安全通信。
HTTPS 依赖于信任;因此,有一个可靠的方式来检查特定的公钥是否属于特定的服务器至关重要。公钥在 X.509 证书中进行交换,该证书具有分层结构。这种结构使客户端能够检查给定的证书是否由受信任的根证书生成。客户端应仅信任由已知证书颁发机构(CA)签发的证书。
在将我们的服务切换到使用 HTTPS 传输之前,我们需要一个公钥/私钥对。由于我们不是证书颁发机构,我们将不得不使用 OpenSSL 工具为我们生成测试密钥。
OpenSSL 可在 www.openssl.org/ 下载,那里提供了所有流行操作系统的源代码发行版。以下是如何安装 OpenSSL 的说明:
- 二进制发行版可供下载,Windows 用户可以通过执行以下命令来使用打包的发行版:
sudo apt-get install openssl
Windows 用户必须设置一个环境变量,OPENSSL_CNF,指定 openssl.cnf 配置文件的位置,通常位于安装存档中的共享目录中。
- 现在让我们使用 OpenSSL 生成一个测试的键/值对:
opensslreq -x509 -nodes -days 365 -newkey rsa:2048-keyoutcatalog.pem -out catalog.crt
OpenSSL 将提示生成证书所需的一些详细信息,例如国家代码、城市和完全合格的域名。之后,它将在 catalog.pem 文件中生成一个私钥,并在 catalog.crt 文件中生成一个有效期为一年的公钥证书。我们将使用这些新生成的文件,所以将它们复制到目录 ssl 中,该目录位于目录数据服务目录下。
现在我们已经拥有了修改我们的服务以使用 HTTPS 所需的一切:
- 首先,我们需要切换并使用 HTTPS 模块而不是 HTTP,并指定我们想要用于启用 HTTPS 通信的端口:
var https = require('https');
var app = express();
app.set('port', process.env.PORT || 3443);
- 然后,我们必须从
catalog.cem文件中读取私钥,并从catalog.crt文件中读取证书到数组中:
var options = {key : fs.readFileSync('./ssl/catalog.pem'),
cert : fs.readFileSync('./ssl/catalog.crt')
};
- 最后,我们在创建服务器时将包含密钥对的数组传递给 HTTPS 实例,并通过指定的端口开始监听:
https.createServer(options, app).listen(app.get('port'));
要为基于 Express 的应用程序启用 HTTPS,你需要做的就这些。保存你的更改,并在浏览器中请求 https://localhost:3443/catalog/v2 来尝试一下。你将看到一个警告信息,告知你你正在连接的服务器使用的证书不是由受信任的证书颁发机构签发的。这是正常的,因为我们自己生成了证书,我们肯定不是 CA,所以只需忽略那个警告。
在将服务部署到生产环境之前,你应该始终确保你使用的是受信任的 CA 签发的服务器证书。
自测问题
以下是一些问题:
-
HTTP 基本认证能否抵御中间人攻击?
-
传输层安全协议的好处是什么?
摘要
在本章中,你学习了如何通过启用身份验证和授权手段来保护暴露的数据。这是任何公开数据服务的关键方面。此外,你还学习了如何通过在服务和其用户之间使用安全层传输协议来防止中间人攻击。作为此类服务的开发者,你应该始终考虑应用程序应支持的最合适的安全功能。
我希望这是一个有用的经历!你们获得了足够的知识和实践经验,这应该使你们在理解 RESTful API 的工作原理以及它们是如何设计和开发的方面更加自信。我强烈建议你们逐章阅读代码演变部分。你们应该能够进一步重构它,使其适应自己的编码风格。当然,其中一些部分可以进一步优化,因为它们重复出现的频率相当高。这是一个有意的决定,而不是良好的实践,因为我想要强调它们的重要性。你们应该始终努力改进代码库,使其更容易维护。
最后,我想鼓励你们始终关注你们在应用程序中使用到的 Node.js 模块的发展。Node.js 拥有一个渴望快速成长的非凡社区。那里总是有令人兴奋的事情发生,所以请确保不要错过。祝你好运!
第十九章:微服务时代
数十年前,具体来说是在 1974 年,英特尔向世界推出了 8080,这是一款 2 兆赫时钟速度和 64KB 内存的 8 位处理器。这款处理器被用于 Altair,并开始了个人计算机的革命。
它可以预先组装出售,也可以作为爱好者的套件出售。这是第一台真正有足够力量用于计算的计算机。尽管它有一些设计上的缺陷,并且需要工程专业的知识才能使用和编程,但它开始了个人计算机向公众普及的传播。
技术迅速发展,处理器行业遵循摩尔定律,几乎每两年速度翻一番。处理器仍然是单核,效率比(每时钟周期的功耗)较低。因此,服务器通常只做一项特定的工作,称为服务,例如提供 HTTP 页面或管理轻量级目录访问协议(LDAP)目录。服务是单体,组件非常少,全部编译在一起以充分利用硬件处理器和内存。
在 90 年代,互联网仍然只为少数人可用。基于 HTML 和 HTTP 的超文本还处于婴儿期。文档很简单,浏览器根据需要开发语言和协议。在 Internet Explorer 和 Netscape 之间,市场份额的竞争非常激烈。后者引入了 JavaScript,微软将其作为 JScript 复制:

简单的单核服务器
进入新世纪后,处理器速度继续提高,内存增长到宽敞的尺寸,32 位对于分配内存地址来说已经不足。全新的 64 位架构出现,个人计算机处理器达到 100 瓦的功耗。服务器变得更加强壮,能够处理不同的服务。开发者仍然避免将服务拆分成部分。进程间通信被认为速度慢,服务保持在单个进程的线程中。
互联网开始变得广泛可用。电信公司开始提供三合一服务,包括互联网捆绑电视和电话服务。手机成为革命的一部分,智能手机时代开始了。
JSON 作为 JavaScript 语言的一个子集出现,尽管它被认为是一种与语言无关的数据格式。一些网络服务开始支持这种格式。
以下是一个运行了几个服务但仍然只有一个处理器的服务器的示例。

强大但单核的服务器
处理器的发展随后发生了转变。不再是我们所习惯的速度增加,处理器开始出现双核,然后是四核。随后是八核,似乎计算机的发展将遵循这条路径一段时间。
这也意味着在开发范式中的架构转变。依赖系统利用所有处理器是不明智的。服务开始利用这种新的布局,现在常见的是每个服务至少有一个处理器。只需看看任何网络服务器或代理,如 Apache 或 Nginx。
互联网现在广泛可用。移动访问互联网及其信息大约占所有互联网访问的一半左右。
2012 年,互联网工程任务组(IETF)开始为其第二个版本 HTTP 或 HTTP/2 的第一份草案,万维网联盟(W3C)也做了同样的事情,因为这两个标准都很旧,需要重做。幸运的是,浏览器同意合并新功能和规范,开发者不再需要在不同的浏览器边缘情况上开发和测试他们的想法。
以下是一个示例,展示了随着每个服务器拥有超过一个处理器,服务数量增加的情况:

强大的多核服务器
实时获取信息的需求正在增长。物联网(IoT)增加了连接到互联网的设备数量。现在人们在家中有几台设备,数量还将持续增长。应用程序需要能够处理这种增长。
在互联网上,HTTP 是通信的标准协议。路由器通常不会阻止它,因为它被认为是一种低流量协议(与视频流相比)。实际上,现在并非如此,但它现在被广泛使用,改变这种行为可能会引起麻烦。
现在,使用 HTTP 服务开发 API 并与 JSON 一起工作实际上非常普遍,以至于大多数在 2015 年之后发布任何版本的编程语言可能都原生支持这种数据格式。
由于处理器的发展,以及我们现在拥有的数据需求驱动的互联网,不仅能够将服务或应用程序扩展到多个可用核心,而且还要能够扩展到单个硬件机器之外,这一点非常重要。
许多开发者开始使用并遵循面向服务架构(SOA)原则。这是一个将架构重点放在服务上的原则,每个服务将自己呈现为应用程序组件,并向其他应用程序组件提供信息,通过某些标准通信协议传递消息。
从单体到微服务
正如我们之前所描述的,微服务架构基于一组松散耦合的服务,这些服务共同工作以实现特定的目标应用程序。在光谱的另一端,有单体应用程序。
单体应用由一组紧密耦合的组件组成。这些组件通常使用相同的语言开发,并且整个应用作为一个整体运行。第一个明显的区别可能是启动速度慢。部署也可能很慢,因为你可能需要一些依赖项才能使应用正常运行。
让我们想象一个事件应用,一个简单的应用,一个允许用户定义事件并在事件即将开始时通知他们的应用。

一个单体事件应用
让我们描述一下事件应用的功能:
-
它允许用户注册自己并将事件添加到日历中
-
在事件开始前几分钟(这就是调度器组件的作用),用户会收到包含事件信息的电子邮件(这就是SMTP组件)。
-
用户可以使用前端界面或API接口
想象一下前面的应用作为一个单体(右边的灰色区域)。
想象一下这四个部分都是同一个过程的一部分,即使它们可以在不同的线程中。想象一下数据库是直接跨应用访问的。听起来不错?
嗯,听起来很糟糕,可能对于小型应用来说不是这样,但对于中型应用,这将是混乱的体现。有一组开发者制作新功能或改进将会是一场噩梦,而对于新加入的开发者来说,在能够进行一些更改之前需要一段时间来掌握基础知识。
你应该遵循的第一个原则是不要重复自己(DRY)原则。避免多个组件访问数据源有助于未来的开发者。稍后,如果需要更改数据源或其结构的一部分,如果只有一个组件操作它,将会更容易。这并不总是可能的,但如果可能的话,你应该将数据源访问限制在最小范围内。
在我们的例子中,API 可能应该有访问权限,而所有其他应用都应该使用 API。

单一服务访问数据源
现在我们有两个服务:
-
API,这是唯一访问数据源的服务
-
前端,这是用户更改数据源的界面
虽然前端用于管理事件,但它使用 API 服务来操作数据源。除了只有一个服务管理数据源外,它还迫使你为外部开发者考虑 API。这是一个双赢的局面。
仍然有改进的空间。前端可以作为一个独立的服务,这样你可以根据用户流量扩展界面,并将其他部分放在独立的服务上。调度器和SMTP都是作为独立服务的候选者。SMTP应该被视为一个可重用的服务,用于你以后可能开发的其他应用。
让我们看看我们如何使用微服务方法构建相同的应用程序:

一个微服务事件应用
它看起来更复杂。好吧,架构更复杂。区别在于现在,我们有松耦合的组件,并且每个组件都易于理解和维护。总结这些变化和优势:
-
API 是唯一访问数据库的组件,因此它可以从 SQLite、MongoDB、MySQL 或其他任何东西改变,而不会影响其他组件
-
SMTP 可以从 前端 和 调度器 使用,如果你决定从使用本地服务改为使用第三方电子邮件发送 API,你可以轻松地进行更改
-
SMTP 可以作为其他应用程序的可重用服务,这意味着你可以在其他应用程序中使用它,或者在不同应用程序之间共享相同的服务
你可以将这些组件视为你应用程序的能力。它们可以被交换、升级、维护和扩展,而不会影响其他组件或你的应用程序。
使用这种方法的一个常被低估的优势是,你的应用程序对失败的抵抗能力要强得多。在单体应用程序中,任何部分都可能使你的应用程序离线。在这种微服务方法中,这个应用程序可能不会发送电子邮件,但仍然可以运行和访问。加入缓存到混合中,API 可以在瞬间重新启动。
微服务模式
微服务架构,就像其他架构一样,有一套易于识别的模式,构成了这种应用程序开发方法的基础。
一些这些模式可能会使初始引导变得负担,并且最终可以推迟。其他模式从一开始就是必需的,否则你将在以后迁移到完整的微服务方法时遇到困难。
以下模式并不是一个详尽的列表,但它们代表了一个坚实的基础:

服务协同工作以形成应用程序的示例
可分解性
微服务架构背后的主要模式是拥有松耦合的服务的能力。这些服务被分解,分成更小的部分。这种分解应该创建一组实现一组强相关功能的服务的集合。
每个服务都应该小巧但完整,这意味着它应该在给定的上下文中运行一系列函数。这些函数应该代表你需要的所有函数或需要支持该上下文的函数。这意味着如果你有一个处理会议事件的服务,所有会议事件函数都应该使用该服务完成,无论是创建事件、更改、删除还是获取特定事件的详细信息。这确保了事件实现的更改只会影响该服务。
将应用程序分解可以采用两种主要方法:
-
通过能力,当一个服务具有特定的权力或一系列权力,例如发送电子邮件,无论其内容如何
-
通过子域,当一个服务拥有应用程序领域子域或模块的完整知识
在我们之前的事件应用中,通过能力分解的服务,例如,是 SMTP 服务。通过领域分解的服务可能是 API 服务,假设应用程序只管理事件:

服务的测试和部署自主进行的例子,而不是整个应用程序
自主
在微服务架构中,每个服务应该是自主的。一个小团队应该能够在没有其他服务的情况下运行它,这些服务构成了你的应用程序。该团队还应该能够自主开发并修改实现,而不会影响应用程序。
开发团队应该能够:
-
测试,创建业务逻辑和单元测试以确保服务功能按预期工作
-
部署,升级功能,在此过程中无需重启其他服务
服务应该能够在不影响其他服务的情况下独立演进,保持向后兼容性,添加新功能,并在多个位置进行扩展,对架构的更改最小:

一个具有每个服务两个实例的应用程序示例,使其具有容错能力
可扩展
服务应该是可扩展的。至少应该能够并行运行两个实例,以实现容错和减少维护停机时间。服务还可以在以后地理上扩展,靠近客户,提高性能和应用程序响应速度。
为了使这种扩展有效,应用程序平台将需要服务发现和路由,这是一种其他服务可以用来注册自己并公开其能力的服务。其他服务可以在以后查询这个服务目录,并了解如何到达这些能力。
为了减少其他服务的复杂性,服务路由器可以将请求重定向到服务实例。例如,为了发送电子邮件,你可以有三个实例和一个中央路由器,它会以轮询方式重定向请求。如果其中任何一个实例离线,路由器将停止将其重定向,其余的应用程序也不需要关心它。
另一种方法可能是使用 DNS 方法。名称服务能够处理对子域的注册,然后,当另一个服务进行简单请求时,它将收到一个或所有地址,并将其连接,就像只有一个服务在运行一样:

应用程序服务之间通信的一个例子
可通信
通常,服务通过 HTTP 使用 REST 兼容的 API 进行通信。这不是你必须遵循的模式,但这是基于当今 HTTP 的普遍性自然而然产生的,使其成为显而易见的选择。
现在有很多 HTTP 服务器,这使得以最小的努力公开非 HTTP 服务变得容易。
HTTP 也是一个成熟的通信传输层。它是一个无状态的协议,为开发者和运营人员提供了许多功能,例如:
-
缓存常用且经常更新的资源
-
代理和路由请求
-
使用 TLS 加密通信

具有多个服务和通信流的复杂应用程序
概述
总结来说,微服务架构是一个良好、清晰的模式,有助于处理更复杂的项目。从长远来看,它通过吸引服务重用来降低与新产品相关的复杂性。它有助于将应用程序结构化为松散耦合的服务,这些服务可以由小型、不同的团队独立开发和测试。这需要初始的适当规划和更复杂的部署。
在创建您的第一个微服务之前,让我们先看看一些可能最终帮助您利用下一个大型项目的 Node.js 工具。这就是我们将在下一章中要涵盖的内容。
第二十章:模块和工具包
现在我们已经回顾了 Node.js 的新特性,并且我们知道什么是微服务,是时候看看我们可以使用哪些工具或模块来创建一个微服务了。我们将回顾几个选项,并构建一个简单或复杂的微服务,以便我们可以指出每种方法的优缺点。
我们将查看不同的模块:
-
Seneca:基于属性匹配的微服务工具包
-
Hydra:一个打包了几个模块的包,可以帮助你解决许多微服务问题,如分发和监控
塞涅卡
让我们看看名为 Seneca 的框架,它被设计用来帮助你开发基于消息的微服务。它有两个显著的特点:
-
T ransport agnostic:通信和消息传输与你的服务逻辑分离,并且很容易更换传输方式
-
模式匹配:消息是 JSON 对象,每个函数都根据对象属性暴露它们可以处理的消息类型
塞涅卡真正有趣的是它根据对象模式暴露函数的能力。让我们先安装塞涅卡:
npm install seneca
现在,让我们忘记传输,并在同一文件中创建生成者和消费者。让我们看看一个示例:
const seneca = require("seneca");
const service = seneca();
service.add({ math: "sum" }, (msg, next) => {
next(null, {
sum : msg.values.reduce((total, value) => (total + value), 0)
});
});
service.act({ math: "sum", values: [ 1, 2, 3 ] }, (err, msg) => {
if (err) return console.error(err);
console.log("sum = %s", msg.sum);
});
我们首先包含seneca模块并创建一个新的服务。然后我们暴露一个匹配对象,该对象具有等于sum的math属性。这意味着任何具有math属性且等于sum的请求对象都将传递给这个函数。这个函数接受两个参数。第一个,我们称之为msg,是请求对象。第二个参数,next,是函数完成或发生错误时应该调用的回调。在这种情况下,我们期望一个也具有values列表的对象,并且我们通过使用数组中可用的reduce方法来返回所有值的总和。最后,我们调用act,期望它消费我们的生成者。我们传递一个具有等于sum的math属性和值列表的对象。我们的生成者应该被调用并返回总和。
假设你将此代码放在app.js中,如果你在命令行中运行它,你应该会看到类似以下内容:
$ node app
sum = 6
让我们尝试复制我们之前的堆栈示例。这次,我们不再在代码中使用消费者和生成者,而是使用curl作为消费者,就像我们之前做的那样。
首先,我们需要创建我们的service。正如我们之前所看到的,我们通过加载 Seneca 并创建一个实例来完成这个任务:
const seneca = require("seneca");
const service = seneca({ log: "silent" });
我们明确表示我们现在不关心日志记录。现在,让我们创建一个变量来保存我们的堆栈:
const stack = [];
然后,我们创建我们的生产者。我们将创建三个:一个用于向栈中添加元素,称为push;一个用于从栈中移除最后一个元素,称为pop;一个用于查看栈,称为get。push和pop都将返回最终的栈结果。第三个生产者只是一个辅助函数,这样我们就可以在不执行任何操作的情况下查看栈。
要向栈中添加元素,我们定义:
service.add("stack:push,value:*", (msg, next) => {
stack.push(msg.value);
next(null, stack);
});
这里有一些新内容需要查看:
-
我们将模式定义为字符串而不是对象。这个动作字符串是扩展对象定义的快捷方式。
-
我们明确指出需要一个值。
-
我们还表明我们不在乎值是什么(记住,这是模式匹配)。
我们现在定义一个更简单的函数来移除stack的最后一个元素:
service.add("stack:pop", (msg, next) => {
stack.pop();
next(null, stack);
});
这个操作比较简单,因为我们不需要一个值,我们只是移除最后一个元素。我们不会处理栈已经为空的情况。一个空数组不会抛出异常,但在实际场景中,你可能希望得到另一个响应。
我们的第三个函数甚至更简单,我们只是返回stack:
service.add("stack:get", (msg, next) => {
next(null, stack);
});
最后,我们需要告诉我们的service监听消息。默认传输是 HTTP,我们只需像之前示例中那样指定端口3000:
service.listen(3000);
将所有这些代码放入一个文件中并尝试运行。你可以使用 curl 或者直接在浏览器中尝试。在这种情况下,Seneca 不会区分 HTTP 动词。让我们先检查我们的stack。URL 描述了一个我们想要执行的操作(/act),查询参数被转换成我们的模式:

然后,我们可以尝试向我们的stack添加值one并查看最终的stack:

我们可以继续添加值two并查看stack的增长情况:

如果我们尝试移除最后一个元素,我们会看到stack缩小:

就像在 Express 中一样,Seneca 也有可以安装和使用的中间件。在这种情况下,中间件被称为插件。默认情况下,Seneca 包含了一些核心插件用于传输,包括 HTTP 和 TCP 传输都得到了支持。还有更多的传输方式可用,例如高级消息队列协议(AMQP)和 Redis。
此外,还有用于持久数据的存储插件,并且支持多种数据库服务器,包括关系型和非关系型。Seneca 提供了一个类似对象关系映射(ORM)的接口来管理数据实体。你可以操作实体,并在开发中使用简单的存储,然后稍后迁移到生产存储。让我们看看一个更复杂的例子:
const async = require("async");
const seneca = require("seneca");
const service = seneca();
service.use("basic");
service.use("entity");
service.use("jsonfile-store", { folder : "data" });
const stack = service.make$("stack");
stack.load$((err) => {
if (err) throw err;
service.add("stack:push,value:*", (msg, next) => {
stack.make$().save$({ value: msg.value }, (err) => {
return next(err, { value: msg.value });
});
});
service.add("stack:pop,value:*", (msg, next) => {
stack.list$({ value: msg.value }, (err, items) => {
async.each(items, (item, next) => {
item.remove$(next);
}, (err) => {
if (err) return next(err);
return next(err, { remove: items.length });
});
});
});
service.add("stack:get", (msg, next) => {
stack.list$((err, items) => {
if (err) return next(err);
return next(null, items.map((item) => (item.value)));
});
});
service.listen(3000);
});
只需运行这段新代码,我们就可以通过发送一些请求来测试它的行为。首先,让我们通过请求来查看我们的stack:

没有什么不同。现在,让我们向stack添加值one:

嗯,我们还没有收到最终的堆栈。我们可以这样做,但相反,我们更改了服务以返回刚刚添加的确切项目。这实际上是一种确认我们刚刚做了什么的不错的方法。让我们再添加一个:

再次,它返回了我们刚刚添加的值。现在,让我们看看我们的堆栈:

我们的堆栈现在有了我们的两个值。现在与之前的代码相比有一个很大的不同。我们正在使用 Seneca 提供的 entities API,它帮助您使用类似于 ORM 的简单抽象层或对于熟悉 Ruby 的人来说,类似于ActiveRecord来存储和操作数据对象。
我们的新代码,而不是仅仅弹出最后一个值,而是移除我们指定的值。所以,让我们移除值one而不是two:

成功!我们恰好移除了一个项目。我们的代码将移除所有匹配值的堆栈中的项目(它没有重复检查,因此您可以有重复的项目)。让我们再次尝试移除相同的项:

没有更多项匹配one,所以它没有移除任何东西。我们现在可以检查我们的堆栈并确认我们仍然有值two:

正确!而且,作为额外奖励,您可以停止和重新启动代码,而您的堆栈仍然具有值two。这是因为我们正在使用 JSON 文件存储插件。
当使用 Chrome 或任何其他浏览器进行测试时,请注意,有时,当您在输入时,浏览器会提前发出请求。因为我们已经测试了我们的第一个代码,它有相同的 URL 地址,浏览器可能会重复请求,您可能会得到一个包含重复值的堆栈,而不知道为什么。这就是原因。
Hydra
让我们回到 Express。如您之前所见,它是在http模块之上的一个坚如磐石的层。尽管它在某种程度上原始的模块中添加了一个重要的基础层,但它仍然缺少您需要制作良好微服务的大多数功能。
由于市面上有大量的插件可以扩展 Express,因此挑选一个对我们有用的插件列表可能会有些困难。
在选择正确的列表后,您仍然需要做出其他决定:
-
我该如何使用多个实例分发我的服务?
-
服务如何被发现?
-
我该如何监控我的服务是否正常运行?
进入 Hydra,这是一个促进构建分布式微服务的框架。Hydra 利用 Express 的力量,并帮助您创建微服务或与微服务进行通信。
它将默认启用您:
-
进行服务注册和服务发现,使您的微服务能够被发现并可被发现。
-
与微服务进行通信,并在多个实例之间负载均衡通信,处理失败的实例,并自动将请求重定向到其他正在运行的实例。
-
监控实例,检查微服务是否可用且正常运行
与我们之前审查的其他模块不同,Hydra 有一个依赖项不能直接使用 NPM 安装。Hydra 使用 Redis 来实现其目标。在继续之前,请查找 Redis 网站上的信息 redis.io/ 在你的操作系统上安装它。如果你有 macOS 并使用 Homebrew,输入以下命令来安装 redis:

现在,让我们确保 redis 已成功启动:

之后,我们需要安装 Hydra 命令行工具:
sudo npm install -g yo generator-fwsp-hydra hydra-cli
现在我们需要配置与 Redis 的连接。我们通过创建一个配置文件来完成这个操作。输入命令并遵循指示。如果你是在本地安装的(或使用了前面的指示),你应该回答类似于以下截图的内容:

现在,让我们创建一个非常简单的微服务,只是为了看看工作流程是什么样的。Hydra 使用 yeoman 工具进行脚手架搭建。要创建一个服务,输入以下命令并遵循指示:

在服务名称处,只需输入 hello。对于其余问题,按 Enter 使用默认值。最后,进入创建的文件夹并安装依赖项:

服务现在已准备好启动。你可能已经在搭建服务时看到了这些指示。让我们启动服务:

如前一个截图所示,服务已启动,并附加到本地 IP(192.168.1.108)和端口(45394)。在你的代码编辑器中打开该文件夹:

你会在基本文件夹中看到一个名为 hello-service.js 的文件,其中包含服务路由。你可以找到指向 routes/hello-v1-routes.js 中另一个文件的 /v1/hello 路由:

在那个文件中,你会看到对该路由的响应。现在,让我们跳转到网页浏览器,看看它是否启动并运行:

我们在文件中看到的内容位于 JSON 响应的 result 属性中。我们只是部署了第一个 Hydra 微服务,而没有写一行代码!
摘要
我们刚刚介绍了一系列不同的模块和工具包,以帮助开发微服务。从 Seneca 的模式到 Hydra 包,有许多方法可供选择。
它们针对不同的受众群体,满足不同的需求。我建议你尝试其中的一些,以帮助你做出更好的选择,而不仅仅是选择一个。
让我们深入研究这些工具,并开始创建一个更完整的微服务。在下一章中,我们将构建一个有用的微服务,涵盖不同的用例,同时开发一个功能齐全且分布式的微服务。
第二十一章:构建微服务
现在我们已经看到了使用一些工具构建微服务的示例,让我们更深入地挖掘,并使用这些工具从头开始创建一个微服务。为了实现我们的目标,我们首先将使用 Hydra,然后,我们将使用 Seneca 方法创建我们的微服务。
我们可以创建许多微服务,但其中一些比其他更有趣。更具体地说,一个可以在多个应用程序中使用的微服务显然更有用。
让我们创建一个图像处理微服务。我们将从一个简单的缩略图服务开始,然后我们将逐步发展到进行一些简单的图像转换。我们将涵盖如何:
-
使用外部模块来操作图像
-
在 Hydra 和 Seneca 中构建我们的微服务
微服务名称非常重要,因为它提供了身份。让我们将其命名为 imagini,这是图像的拉丁名称。
使用 Hydra
如您所记得,Hydra 有一个框架命令,可以帮助我们快速启动服务。让我们使用它,并准备我们的基本布局。运行 yo fwsp-hydra 并回答问题。您可以将大多数问题保留为默认值。根据您使用的版本,您应该得到类似以下行所示的内容:
fwsp-hydra generator v0.3.1 yeoman-generator v2.0.2 yo v2.0.1
? Name of the service (`-service` will be appended automatically) imagini
? Your full name? Diogo Resende
? Your email address? dresende@thinkdigital.pt
? Your organization or username? (used to tag docker images) dresende
? Host the service runs on?
? Port the service runs on? 3000
? What does this service do? Image thumbnail and manipulation
? Does this service need auth? No
? Is this a hydra-express service? Yes
? Set up a view engine? No
? Set up logging? No
? Enable CORS on serverResponses? No
? Run npm install? No
create imagini-service/specs/test.js
create imagini-service/specs/helpers/chai.js
create imagini-service/.editorconfig
create imagini-service/.eslintrc
create imagini-service/.gitattributes
create imagini-service/.nvmrc
create imagini-service/.gitignore
create imagini-service/package.json
create imagini-service/README.md
create imagini-service/imagini-service.js
create imagini-service/config/sample-config.json
create imagini-service/config/config.json
create imagini-service/scripts/docker.js
create imagini-service/routes/imagini-v1-routes.js
Done!
'cd imagini-service' then 'npm install' and 'npm start'
好吧,让我们就这样做。让我们进入我们的服务文件夹并安装依赖项。如果您然后使用 npm start 启动它,并在浏览器中指向我们的服务,您应该会得到类似以下内容:

并不令人惊讶,因为 Hydra 创建了一个不同的基本路由。为了启用版本控制和让不同的服务在同一个 HTTP 后端上运行,Hydra 框架在 /v1/imagini 前缀下创建了一个路由。记住,我们使用 Express 集成构建了 Hydra,所以许多我们之前讨论过的术语在这里也将是相同的:

在我们将之前的代码选择并集成到 Hydra 之前,我们需要将我们的 Sharp 依赖项添加到 package.json 中。查找 dependencies 属性并添加 sharp。你应该得到类似以下内容:
(…)
"dependencies": {
"sharp" : "⁰.19.0",
"body-parser" : "¹.18.2",
"fwsp-config" : "1.1.5",
"hydra-express" : "1.5.5",
"fwsp-server-response" : "2.2.6"
},
(…)
现在,运行 npm install 来安装 Sharp。然后,打开位于 routes 文件夹下的 imagini-v1-routes.js 文件。基本上,它的作用是获取 Hydra 和 Express 的处理器,准备一个通用的 JSON 服务器响应(这就是 fwsp-server-response 模块的作用),创建一个 Express 路由器,附加 / 路由,然后导出它。
我们现在保持这个结构。由于我对缩进和引号有点挑剔,所以我重构了文件。我添加了我们的图像路由参数并添加了图像上传路由。我将我们之前的路由代码更改为删除 /uploads 路由前缀,并使用前面代码中看到的 sendOk 和 sendError 函数:
/**
* @name imagini-v1-api
* @description This module packages the Imagini API.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const bodyparser = require("body-parser");
const hydraExpress = require("hydra-express");
const ServerResponse = require("fwsp-server-response");
const hydra = hydraExpress.getHydra();
const express = hydraExpress.getExpress();
let serverResponse = new ServerResponse();
express.response.sendError = function (err) {
serverResponse.sendServerError(this, { result : { error : err }});
};
express.response.sendOk = function (result) {
serverResponse.sendOk(this, { result });
};
let api = express.Router();
api.param("image", (req, res, next, image) => {
if (!image.match(/\.(png|jpg)$/i)) {
return res.sendError("invalid image type/extension");
}
req.image = image;
req.localpath = path.join(__dirname, "../uploads", req.image);
return next();
});
api.post("/:image", bodyparser.raw({
limit : "10mb",
type : "image/*"
}), (req, res) => {
let fd = fs.createWriteStream(req.localpath, {
flags : "w+",
encoding : "binary"
});
fd.end(req.body);
fd.on("close", () => {
res.sendOk({ size: req.body.length });
});
});
module.exports = api;
然后,我们重新启动我们的微服务,在 imagini-service 文件夹下创建 uploads 文件夹,并尝试上传一个图像。像之前一样,我使用了 curl 来测试它:
curl -X POST -H 'Content-Type: image/png' \
--data-binary @example.png \
http://localhost:3000/v1/imagini/example.png
如预期的那样,我收到了一个包含我们的 size 属性的 JSON 响应:
{
"statusCode" : 200,
"statusMessage" : "OK",
"statusDescription" : "Request succeeded without error",
"result" : {
"size" : 55543
}
}
我们可以将上传的文件放在我们的uploads文件夹中。我们正在接近目标;只需再添加两个路由:
api.head("/:image", (req, res) => {
fs.access(req.localpath, fs.constants.R_OK , (err) => {
if (err) {
return res.sendError("image not found");
}
return res.sendOk();
});
});
我们的检查路由非常相似。我们只是将返回方法更改为使用之前定义的方法:
api.get("/:image", (req, res) => {
fs.access(req.localpath, fs.constants.R_OK , (err) => {
if (err) {
return res.sendError("image not found");
}
let image = sharp(req.localpath);
let width = +req.query.width;
let height = +req.query.height;
let blur = +req.query.blur;
let sharpen = +req.query.sharpen;
let greyscale = [ "y", "yes", "true", "1",
"on"].includes(req.query.greyscale);
let flip = [ "y", "yes", "true", "1",
"on"].includes(req.query.flip);
let flop = [ "y", "yes", "true", "1",
"on"].includes(req.query.flop);
if (width > 0 && height > 0) {
image.ignoreAspectRatio();
}
if (width > 0 || height > 0) {
image.resize(width || null, height || null);
}
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
res.setHeader("Content-Type", "image/" +
path.extname(req.image).substr(1));
image.pipe(res);
});
});
我们的下载数据方法同样相似。对于这个路由,我们不使用 JSON 响应,而是直接返回我们的图像。这允许我们在浏览器中尝试它:

我们刚刚将我们的服务从 Express 迁移到 Hydra。变化不大,但 Hydra 提供了一个更健壮的布局,我们稍后会了解更多。让我们看看我们的第三个框架:Seneca。
使用 Seneca
记住,在这个框架上路由一切都是关于模式的。现在让我们保持简单,并使用一个角色属性来指示我们想要做什么(上传、检查或下载)。
默认情况下,每条消息都应该进行 JSON 编码,因此我们将图像编码为base64,然后在 JSON 消息中将其作为字符串传递以上传和下载。
为我们的 Seneca 服务创建一个文件夹,然后在该文件夹中创建一个名为uploads的子文件夹。然后,通过运行以下命令在该文件夹中安装seneca和sharp:
npm install seneca sharp --save
然后,创建一个名为imagini.js的文件,内容如下:
const seneca = require("seneca");
const sharp = require("sharp");
const path = require("path");
const fs = require("fs");
const service = seneca();
service.add("role:upload,image:*,data:*", function (msg, next) {
let filename = path.join(__dirname, "uploads", msg.image);
let data = Buffer.from(msg.data, "base64");
fs.writeFile(filename, data, (err) => {
if (err) return next(err);
return next(null, { size : data.length });
});
});
service.listen(3000);
这所做的就是启动一个简单的服务,有一个上传的路由。由于我们直接在对象属性上接收所有图像内容,我使用了fs.writeFile。这是一个更简单的方法,如果发生错误,它会给我一个错误,我们可以将其传递给路由响应。
我还使用了Buffer.from来转换我们的图像数据,这些数据将以base64格式上传。
所以,让我们就像对待其他服务一样启动它。我包含了相同的example.png图像,并使用curl进行测试。
curl -H "Content-Type: application/json" \
--data '{"role":"upload","image":"example.png","data":"'"$( base64 example.png)"'"}' \
http://localhost:3000/act
Seneca 迅速回复如下:
{"size":55543}
这是指图像的大小。请注意,我正在利用 bash 插值(变量替换)直接将图像文件转换为base64,然后传递给curl,然后curl将这个 JSON 数据块发送到我们的服务:
service.add("role:check,image:*", function (msg, next) {
let filename = path.join(__dirname, "uploads", msg.image);
fs.access(filename, fs.constants.R_OK , (err) => {
return next(null, { exists : !err });
});
});
我们的检查路由非常相似。我们不是只回复 HTTP 404 响应代码,而是回复一个包含布尔属性exists的字符串化 JSON 对象,这将指示图像是否被找到。
这里,我们正在使用curl检查我们的图像:
curl -H "Content-Type: application/json" \
--data '{"role":"check","image":"example.png"}' \
http://localhost:3000/act
我们将响应如下:
{"exists":true}
如果你更改图像名称,它将响应false:
service.add("role:download,image:*", function (msg, next) {
let filename = path.join(__dirname, "uploads", msg.image);
fs.access(filename, fs.constants.R_OK , (err) => {
if (err) return next(err);
let image = sharp(filename);
let width = +msg.width;
let height = +msg.height;
let blur = +msg.blur;
let sharpen = +msg.sharpen;
let greyscale = !!msg.greyscale;
let flip = !!msg.flip;
let flop = !!msg.flop;
if (width > 0 && height > 0) {
image.ignoreAspectRatio();
}
if (width > 0 || height > 0) {
image.resize(width || null, height || null);
}
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
image.toBuffer().then((data) => {
return next(null, { data: data.toString("base64") });
});
});
});
我们的下载数据路由有一些变化:
-
而不是查询参数,我们直接在
msg上进行检查。一个明显的优势是我们有类型而不是只有字符串,因此我们可以直接使用布尔值和数字。 -
而不是以二进制形式返回图像,以便我们可以在浏览器中打开,我们将其转换为
base64,并在 JSON 响应中传递。
我们需要一些工具在命令行上测试这个功能。由于我经常使用 JSON,我已经安装了jq。我强烈推荐你也安装它并查看教程,这将使你的生活更轻松。使用我们之前用来编码的base64命令,我们可以解码内容并将数据管道传输到本地文件:

然后,我们可以打开文件夹并看到图像已经存在。注意,我添加了greyscale和通过传递两个额外的 JSON 参数来调整图像大小:

插件
在塞涅卡的精神下,我们应该为我们的imagini服务创建一个插件。让我们将我们的代码分成两部分:
-
imagini插件,一个处理图像的服务 -
一个 Seneca 微服务,它公开了
imagini插件,以及可能以后的其他插件
我们的代码有很多改进的空间,从我们不断重复的代码开始。当我们的服务仍然非常小的时候,检测重复是很重要的。
最常重复的部分是本地文件名。这实际上是在启动服务时你可能想要配置的东西,所以让我们将其改为一个函数。首先,将我们的imagini.js文件改为插件。清空所有内容并写入以下代码:
const sharp = require("sharp");
const path = require("path");
const fs = require("fs");
module.exports = function (settings = { path: "uploads" }) {
// plugin code goes here
};
这是我们的插件的基础。我们正在加载所需的模块,但不加载 Seneca,因为我们的插件将直接访问服务。Seneca 本身将通过调用我们的导出函数来加载插件。遵循能够配置本地图像文件夹的想法,我们定义了一个可选的settings参数,它默认为一个具有path属性的对象,其值为uploads,这是我们迄今为止一直在使用的文件夹。
现在,让我们在先前的函数中添加插件的内容:
const localpath = (image) => {
return path.join(settings.path, image);
}
我们首先定义一个函数,将我们的图像参数转换为本地路径。实际上,我们可以将这个函数简化为单行代码:
const localpath = (image) => (path.join(settings.path, image));
然后,让我们创建另一个函数来检查我们是否有权访问本地文件,并返回一个布尔值(如果存在或不存在)以及我们提供的文件名:
const access = (filename, next) => {
fs.access(filename, fs.constants.R_OK , (err) => {
return next(!err, filename);
});
};
我们可以用这个来检查图像,以及下载图像。这样,我们可以提高性能或甚至缓存结果,避免过多的文件系统访问。我们的图像检查路由现在可以非常简洁地编写:
this.add("role:check,image:*", (msg, next) => {
access(localpath(msg.image), (exists) => {
return next(null, { exists : exists });
});
});
注意,我们正在引用this对象。我们的 Seneca 服务将调用我们的插件函数,并将自身引用到this。我们还可以以更简洁的方式编写它:
this.add("role:check,image:*", (msg, next) => {
access(localpath(msg.image), (exists) => (next(null, { exists })));
});
我们的上传路由相当简单,没有变化:
this.add("role:upload,image:*,data:*", (msg, next) => {
let data = Buffer.from(msg.data, "base64");
fs.writeFile(localpath(msg.image), data, (err) => {
return next(err, { size : data.length });
});
});
下载路由使用我们之前创建的辅助函数来避免存储我们的本地文件名。我们还对width和height的处理方式做了一些调整:
this.add("role:download,image:*", (msg, next) => {
access(localpath(msg.image), (exists, filename) => {
if (!exists) return next(new Error("image not found"));
let image = sharp(filename);
let width = +msg.width || null;
let height = +msg.height || null;
let blur = +msg.blur;
let sharpen = +msg.sharpen;
let greyscale = !!msg.greyscale;
let flip = !!msg.flip;
let flop = !!msg.flop;
if (width && height) image.ignoreAspectRatio();
if (width || height) image.resize(width, height);
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
image.toBuffer().then((data) => {
return next(null, { data: data.toString("base64") });
});
});
});
实际上,我们使用了很多变量,我们只需检查消息参数即可。我们可以重写我们的下载函数并减少三分之一的代码:
this.add("role:download,image:*", (msg, next) => {
access(localpath(msg.image), (exists, filename) => {
if (!exists) return next(new Error("image not found"));
let image = sharp(filename);
let width = +msg.width || null;
let height = +msg.height || null;
if (width && height) image.ignoreAspectRatio();
if (width || height) image.resize(width, height);
if (msg.flip) image.flip();
if (msg.flop) image.flop();
if (msg.blur > 0) image.blur(blur);
if (msg.sharpen > 0) image.sharpen(sharpen);
if (msg.greyscale) image.greyscale();
image.toBuffer().then((data) => {
return next(null, { data: data.toString("base64") });
});
});
});
最后,你应该有一个包含以下内容的imagini.js文件:
const sharp = require("sharp");
const path = require("path");
const fs = require("fs");
module.exports = function (settings = { path: "uploads" }) {
const localpath = (image) => (path.join(settings.path, image));
const access = (filename, next) => {
fs.access(filename, fs.constants.R_OK , (err) => {
return next(!err, filename);
});
};
this.add("role:check,image:*", (msg, next) => {
access(localpath(msg.image), (exists) => (next(null, { exists })));
});
this.add("role:upload,image:*,data:*", (msg, next) => {
let data = Buffer.from(msg.data, "base64");
fs.writeFile(localpath(msg.image), data, (err) => {
return next(err, { size : data.length });
});
});
this.add("role:download,image:*", (msg, next) => {
access(localpath(msg.image), (exists, filename) => {
if (!exists) return next(new Error("image not found"));
let image = sharp(filename);
let width = +msg.width || null;
let height = +msg.height || null;
if (width && height) image.ignoreAspectRatio();
if (width || height) image.resize(width, height);
if (msg.flip) image.flip();
if (msg.flop) image.flop();
if (msg.blur > 0) image.blur(blur);
if (msg.sharpen > 0) image.sharpen(sharpen);
if (msg.greyscale) image.greyscale();
image.toBuffer().then((data) => {
return next(null, { data: data.toString("base64") });
});
});
});
};
我们只需要创建我们的 Seneca 服务并使用我们的插件。这实际上非常简单。创建一个名为 seneca.js 的文件,并添加以下内容:
const seneca = require("seneca");
const service = seneca();
service.use("./imagini.js", { path: __dirname + "/uploads" });
service.listen(3000);
代码逐行执行的功能如下:
-
加载
seneca模块 -
创建一个 Seneca
service -
加载
imagini.js插件并传递我们想要的路径 -
在端口
3000上启动service
就这样,我们的服务现在已经成为了一个插件,可以被任何 Seneca 服务使用!你现在应该通过运行新文件而不是直接运行 imagini.js 来启动 service:
node seneca
摘要
如你所见,编写服务在不同的框架之间变化不大。我们的代码非常相似,只有一些小的变化。Seneca 对消息格式和内容的要求更严格,所以我们使用 base64 对 JSON 消息中的图像进行编码。除此之外,一切照旧。
你可以自由选择关于服务的一切,但你需要编写大量的代码。为了方便这项任务,Hydra 可能是一个好的起点,用于一组初始插件。
对于其他工具,如 Seneca,微服务的某些方面(例如,使用 JSON 消息的通信和服务组合)已经打包。这需要以更严格的服务定义作为代价。
在下一章中,我们将讨论我们三个服务版本的安全性,以及我们如何存储状态。
第二十二章:状态
现在我们已经在不同的框架上创建了我们的微服务基础布局,是时候更仔细地阅读我们的代码,看看一切是否看起来都很好了。只是继续编写代码而停止思考我们在做什么是很容易的,但后来当我们停下来的时候,我们会浪费时间删除重复的代码和重新组织我们的服务。
在我们编码之前总是先思考更好。这是随着时间的推移你会学到的东西,那就是重视你用来规划服务或思考新功能的时间。直接开始编码从来不是一个好主意。理论上,你的服务应该位于安全层内部,与状态有良好且稳定的连接:

状态
将状态视为一个人的记忆。通常,一个服务具有状态,这意味着它有它所提供的行为和信息记忆。我们的服务将无限期运行的设想是,但有时我们被迫重新启动它,甚至因为维护或升级而停止它一段时间。
理想情况下,一个服务应该在不丢失状态的情况下恢复,给用户一种它从未停止的印象。这是通过做以下两件事之一来实现的:
-
将状态存储在持久存储中
-
在停止之前将状态保存在持久存储中,并在重启后加载该状态
第一种选择会使你的服务稍微慢一些(没有比系统内存中的状态更快的东西),但应该在你重启时提供更一致的状态。
第二种选择更复杂,因为有时我们的服务可能会突然停止,无法保存该状态,但对于这些用例,你可能并不关心状态。这取决于你。
存储状态有很多选择;这取决于你想要存储什么。对于一个微服务,你应该避免文件系统,以便使你的服务与多个操作系统更兼容。
存储状态
根据你的服务,你可以使用以下方式存储状态:
-
关系型数据库管理系统(RDBMS),例如 MySQL 或 PostgreSQL
-
非关系型数据库管理系统,或 NoSQL,例如 MongoDB 或 RethinkDB
-
内存数据库(IMDB),例如 Redis 或 Memcached
第一种选择仍然是使用最广泛的一种。你将依赖于稳定且经过充分证明的数据库系统,这些系统在多个系统中运行,并且你可以在任何你想要部署微服务的云服务中找到。除了大多数解决方案的成熟度之外,如果设置得当,关系数据库应该给你一致性。
与第一种选择相比,第二种选择更近。通常,没有像 RDBMS 那样的固定表,你通常与文档集合一起工作,这些文档只是常见的 JSON 结构。由于通常没有限制,并且每个文档可能具有不同的结构,因此它更灵活。越灵活,一致性越低。
根据您选择的特定系统,所有三种选项都支持复制,这应该能够实现容错并提高地理上分散的实例的速度。
让我们尝试使用建议的系统之一来使用这三种选项中的每一个。让我们从关系型数据库开始,并使用 MySQL。
MySQL
安装 MySQL 非常简单。只需访问官方网站并按照说明操作。您通常会被要求为 root 用户设置密码,您以后可以使用它来管理服务器设置和用户账户。
使用 Node.js 连接到 MySQL 服务器有一些选项,但最好的工具是 mysql 和 mysql2 模块。它们都满足所需的功能,并且都不是对方的下一个版本,它们只是在设计和支持的功能上略有不同。
首先,让我们将依赖项添加到我们的服务中。在终端中,进入我们的服务文件夹并输入:
npm install mysql --save
我们现在可以包含我们的依赖项并配置数据库连接。为了避免在代码中包含凭证,我们可以创建一个单独的文件,并将设置放在那里,我们可以在将来更改这些设置,而且这些设置不应该包含在代码中。我们可以利用 Node.js 能够包含 JSON 文件的能力,并直接将设置写入 JSON。
创建一个名为 settings.json 的文件,并添加以下内容:
{
"db": "mysql://root:test@localhost/imagini"
}
我们定义了一个名为 db 的设置,它有一个数据库 URI,这是一种方便的方法,使用类似于任何网站地址的地址来定义我们的数据库访问和凭证。我们的数据库使用 mysql;它在 localhost(使用默认端口),可以通过用户名 root 和密码 test 访问,我们的数据库名称为 imagini。
我们现在可以包含模块和设置,并创建连接:
const settings = require("./settings");
const mysql = require("mysql");
const db = mysql.createConnection(settings.db);
此模块仅在您进行查询时连接到数据库。这意味着服务将启动,您直到进行第一次查询之前都不会知道您的连接设置是否正确。我们不希望在服务稍后使用时才发现无法连接到数据库,所以让我们强制连接并检查服务器是否正在运行并接受我们的连接:
db.connect((err) => {
if (err) throw err;
console.log("db: ready");
// ...
// the rest of our service code
// ...
app.listen(3000, () => {
console.log("app: ready");
});
});
这样,如果数据库有任何问题,服务将无法启动并抛出异常,这会通知你检查哪里出了问题。以下是一个可能的错误示例:
Error: ER_ACCESS_DENIED_ERROR: Access denied for user 'root'@'localhost' (using password: YES)
这表明你可能输入了错误的密码,或者用户不匹配,甚至主机名或数据库名可能错误。在设置服务之前确保您连接到数据库意味着您的服务在没有适当状态的情况下不会被公开。
说到我们的微服务,它的状态非常简单。我们的状态是之前上传的图片。我们不仅可以使用文件系统,现在还可以使用数据库并创建一个表来存储它们:
db.query(
`CREATE TABLE IF NOT EXISTS images
(
id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
date_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
date_used TIMESTAMP NULL DEFAULT NULL,
name VARCHAR(300) NOT NULL,
size INT(11) UNSIGNED NOT NULL,
data LONGBLOB NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY name (name)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8`
);
我们可以在服务每次启动时执行此查询,因为它只会在表不存在时创建图像表。如果我们不更改其结构,总是这样做是可以的。
你可以看到我们正在创建一个具有唯一识别号(id)、创建日期(date_created)、知道我们的图像何时被使用的日期(date_used)、图像的name、以字节为单位的图像size和图像data的表。大小在这里有点多余,因为我们可以直接检查数据长度,但请耐心,这只是一个例子。
我们也将我们的名称定义为一个唯一的键,这意味着它有一个索引,可以快速通过名称查找图像,同时也确保我们的名称不会重复,并且没有人可以覆盖一个图像(除非先删除它)。
将图像以这种方式存储在数据库表中,给你带来几个优势,例如:
-
你有多少张图像
-
每张图像的大小和总大小
-
图像的创建时间和最后使用时间
它还使你能够改进你的服务;例如,你可以删除长时间未使用的图像。你还可以根据图像大小来设置这个条件。稍后,你可以添加认证(强制或非强制)并设置用户特定的规则。
这也很容易备份并将状态复制到另一个站点。有许多用于备份数据库的工具,你可以拥有另一个充当从属服务器的 MySQL 服务器,并将你的图像实时复制到另一个地理位置。
让我们将我们的服务从上一章改为使用我们的表,而不是之前在文件系统上使用的文件夹。
我们可以移除我们的fs模块依赖(现在不要移除路径依赖):
app.param("image", (req, res, next, image) => {
if (!image.match(/\.(png|jpg)$/i)) {
return res.status(403).end();
}
db.query("SELECT * FROM images WHERE name = ?", [ image ], (err,
images) => {
if (err || !images.length) {
return res.status(404).end();
}
req.image = images[0];
return next();
});
});
我们的app.param完全不同。我们现在验证image是否与我们的image表匹配。如果没有找到,它返回代码404。如果找到了,它将image信息存储在req.image中。我们现在可以将我们的image上传改为将image存储在我们的表中:
app.post("/uploads/:name", bodyparser.raw({
limit : "10mb",
type : "image/*"
}), (req, res) => {
db.query("INSERT INTO images SET ?", {
name : req.params.name,
size : req.body.length,
data : req.body,
}, (err) => {
if (err) {
return res.send({ status : "error", code: err.code });
}
res.send({ status : "ok", size: req.body.length });
});
});
上传图像不再使用文件系统,而是在我们的表中创建一个新的行。我们不需要指定id,因为它会自动生成。我们的创建日期也是自动的,默认为当前时间戳。我们的使用日期默认为NULL,这意味着我们还没有使用过image:
app.head("/uploads/:image", (req, res) => {
return res.status(200).end();
});
我们的image检查方法现在变得极其简单,因为它依赖于之前的app.param来检查图像是否存在,所以,如果我们到达这个点,我们已经知道图像存在(它在req.image上),所以我们只需要返回代码200。
在更新我们的图像fetch方法之前,让我们尝试我们的服务。如果你在控制台启动它,你可以立即打开任何 MySQL 管理工具并检查我们的数据库。我使用的是 Sequel Pro for macOS。尽管名字中有 Pro,但它是一款免费软件,而且非常好用:

我们的表已创建,你可以检查它是否具有我们定义的所有属性和索引。现在让我们再次上传一个image:

和之前一样,它返回一个包含成功状态和image大小的 JSON 响应。如果你再次查看 Sequel,在内容分隔符上,你会看到我们的图片数据:

让我们再次尝试上传image。之前,我们的服务会直接覆盖它。现在,由于我们的唯一索引,它应该拒绝具有相同名称的INSERT操作:

太好了!ER_DUP_ENTRY是 MySQL 的重复插入代码。我们可以依赖它并拒绝覆盖图片。
我们还可以使用check方法来检查我们的image是否存在:

如果我们使用另一个名称,我们会得到一个代码404:

看起来一切都很顺利。现在让我们改变我们的最终方法,即image操作方法。这个方法几乎一样;我们只是不需要读取image文件,因为它已经可用:
app.get("/uploads/:image", (req, res) => {
let image = sharp(req.image.data);
let width = +req.query.width;
let height = +req.query.height;
let blur = +req.query.blur;
let sharpen = +req.query.sharpen;
let greyscale = [ "y", "yes", "true", "1",
"on"].includes(req.query.greyscale);
let flip = [ "y", "yes", "true", "1",
"on"].includes(req.query.flip);
let flop = [ "y", "yes", "true", "1",
"on"].includes(req.query.flop);
if (width > 0 && height > 0) {
image.ignoreAspectRatio();
}
if (width > 0 || height > 0) {
image.resize(width || null, height || null);
}
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
db.query("UPDATE images " +
"SET date_used = UTC_TIMESTAMP " +
"WHERE id = ?", [ req.image.id ]);
res.setHeader("Content-Type", "image/" + path.extname(req.image.name).substr(1));
image.pipe(res);
});
你可以看到我们如何使用路径依赖来获取image名称的扩展名。其余的都是一样的。我们只是在每次请求此方法时更新我们的image。
我们可以使用网络浏览器来测试我们的方法并查看我们之前上传的图片:

一切都应该和之前一样正常工作,因为我们没有更改我们的图像处理依赖项,所以模糊和其他操作应该按预期工作:

现在,我们可以改进我们的服务并添加一个我们之前没有公开的方法:删除image。为此,我们可以使用 HTTP 的DELETE动词并从我们的表中删除image:
app.delete("/uploads/:image", (req, res) => {
db.query("DELETE FROM images WHERE id = ?", [ req.image.id ], (err)
=> {
return res.status(err ? 500 : 200).end();
});
});
我们只需要检查查询是否导致错误。如果是这样,我们以代码500(内部服务器错误)响应。如果不是,我们以通常的代码200响应。
让我们重新启动我们的微服务并尝试删除我们的image:

看起来它工作了;它以代码200响应。如果我们尝试在网页浏览器中打开我们的图片,我们应该看到类似这样的内容:

在 Sequel 中,表现在也应该为空:

现在,我们有一个具有跨重启持久状态的实用微服务,正如我们预期的。你现在可以部署到任何云服务,无需依赖文件系统,只需数据库即可。
你可以轻松地将 MySQL 更改为另一个数据库,或者使用对象关系映射(ORM)模块来使你能够在不更改代码的情况下更改数据库服务器。ORM 是一个库,它允许你使用一个通用接口来访问不同类型的数据库。通常,这种抽象涉及根本不使用 SQL,并将你与数据库的交互简化为更简单的查询(以允许数据库服务器之间的互操作性)。
让我们抓住这个机会,更进一步,添加一些由于这次迁移而被简化的数据库中的方法。让我们创建一个公开数据库统计信息的方法,并删除旧图像。
我们的第一种统计方法应该只返回一个包含一些有用信息的 JSON 结构。让我们公开以下内容:
-
图像的总数
-
图像的总大小
-
我们的服务运行了多久
-
上次我们上传图像的时间
这里是一个我们的统计方法可能看起来像的例子:
app.get("/stats", (req, res) => {
db.query("SELECT COUNT(*) total" +
", SUM(size) size " +
", MAX(date_created) last_created " +
"FROM images",
(err, rows) => {
if (err) {
return res.status(500).end();
}
rows[0].uptime = process.uptime();
return res.send(rows[0]);
});
});
重新启动服务,让我们试试:

如我们所见,我们没有图像,因为我们刚刚删除了我们的图像。没有大小,因为我们没有图像。也没有使用日期,服务运行时间为 5 秒。
如果我们上传之前的图像,我们将得到不同的结果,就像以下截图所示:

现在,对于我们的第二个任务,删除旧图像,我们需要定期检查我们的数据库。我们将使用间隔计时器并运行一个DELETE查询。以下查询中提到的间隔只是一个例子;你可以编写你想要的条件。
setInterval(() => {
db.query("DELETE FROM images " +
"WHERE (date_created < UTC_TIMETSTAMP - INTERVAL 1 WEEK
AND date_used IS NULL) " +
" OR (date_used < UTC_TIMETSTAMP - INTERVAL 1 MONTH)");
}, 3600 * 1000);
这个查询删除了在过去一个月内未使用(但之前使用过)的images或在过去一周内未使用(且从未使用过)的图像。这意味着上传的图像至少需要使用一次,否则它们将很快被删除。
你可以想一个不同的策略,或者如果你愿意,不使用策略并手动删除。现在我们已经看到了 MySQL,让我们继续看看另一种类型的数据库服务器。
添加代码覆盖率
现在我们已经有一个正在工作的测试套件和一个测试,让我们引入代码覆盖率。从开发初期开始添加这个功能非常容易,并将帮助我们关注需要测试的代码部分,特别是涉及特定条件的一些用例(例如我们代码中的if-then-else语句)。从开发初期开始设置它很容易。另一方面,如果你有一个完全工作的代码,并想添加测试和覆盖率,这将更困难,并且需要相当长的时间。
为了增加代码覆盖率,我们将引入另一个模块。我们将全局安装它,以便能够直接使用它运行测试:
npm install -g nyc
现在,我们可以使用以下仪器运行我们的测试:
nyc npm test
这应该会运行我们的测试,同时安装了仪器。最后,你将得到一个漂亮的控制台报告。

覆盖率结果存储在.nyc_output文件夹中。这使得你可以在不再次运行测试的情况下查看最后的测试结果。如果你的测试套件很大且需要一些时间才能完成,这很有用。
要查看结果,只需运行nyc report:

结果是一个控制台报告。还有几种其他类型的报告。其中一个特别有用的是html报告。让我们生成它:
nyc report --reporter=html
你现在应该有一个包含index.html文件的coverage文件夹。在浏览器中打开它,你应该会看到以下截图:

我们只有一个文件代表我们的微服务。如果有更多,它们将按层次列出。每个文件都有全局平均统计数据。
有三组重要的列:
-
语句:代表代码语句(条件、赋值、断言、调用等)
-
分支:代表可能的代码控制工作流程,例如 if-then-else 或 switch-case 语句的可能性
-
函数:代表我们的实际代码函数和回调
你可以在我们的文件上点击,查看其具体细节,更具体地说,可以逐行查看代码和信息:

在每一行数字的右侧,你看到一个灰色区域,在这种情况下,你会在一些行中看到2x。这是该行的执行次数。该行被执行了两次。这实际上并不重要,除非你在寻找大量执行的代码片段并想进行某种优化。
你还可以看到第 12 行有两个变化。首先,在throw err后面有一个粉红色的背景。这意味着该语句从未被执行,这在目前是正常的,因为我们总是成功连接到数据库。if语句前的标记表示该条件从未被执行:

如果你向下滚动几行,我们会看到更多带有标记的行。例如,我们可以看到我们的图片上传方法几乎被完全覆盖。唯一缺失的是错误处理。
由于我们在运行测试之前删除了测试图像,我们的图像删除方法也得到了覆盖。再次强调,唯一缺失的分支是如果数据库返回错误到我们的DELETE查询。
在进一步处理图片上传之前,让我们添加另一个名为image-parameter.js的integration测试文件,并添加一些测试来提高我们的覆盖率:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("The image parameter", () => {
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_parameter.png")
.end(() => {
return done();
});
});
it("should reply 403 for non image extension", (done) => {
chai
.request(tools.service)
.get("/uploads/test_image_parameter.txt")
.end((err, res) => {
chai.expect(res).to.have.status(403);
return done();
});
});
it("should reply 404 for non image existence", (done) => {
chai
.request(tools.service)
.get("/uploads/test_image_parameter.png")
.end((err, res) => {
chai.expect(res).to.have.status(404);
return done();
});
});
});
让我们运行我们的测试套件并看看结果如何:

刷新 HTML 报告页面,查看我们的参数方法:

如你所见,我们现在覆盖了以下条件:
if (!image.match(/\.(png|jpg)$/i)) {
以下条件:
if (err || !images.length) {
我们现在对这个方法有了全面的覆盖。
有其他一些更难测试的覆盖行,例如计时器(你可以在第 28 行看到一个),catch语句,或者来自数据库或其他存储源的外部错误。有方法来模拟这些事件,我们稍后会讨论。
RethinkDB
让我们看看使用 RethinkDB 的非关系型数据库的差异。如果你没有它,只需按照官方文档(www.rethinkdb.com/docs/)进行安装。让我们先启动服务器:
rethinkdb
这将启动服务器,它附带一个非常好的管理控制台,端口为8080。你可以在网页浏览器中打开它:

前往顶部的“表”部分查看数据库:

使用“添加数据库”按钮创建一个名为imagini的数据库。现在你应该已经有了我们的数据库准备就绪。这里你不需要其他任何东西:

要使用我们新的数据库,我们需要安装rethinkdb依赖项。你可以移除 MySQL 依赖项:
npm uninstall mysql --save
npm install rethinkdb -–save
现在,让我们改变我们的settings文件。这个模块不接受连接字符串,所以我们将使用 JSON 结构:
{
"db": {
"host" : "localhost",
"db" : "imagini"
}
}
要包含我们的依赖项,我们只需要包含这个模块:
const rethinkdb = require("rethinkdb");
然后,使用这个来连接到我们的服务器:
rethinkdb.connect(settings.db, (err, db) => {
if (err) throw err;
console.log("db: ready");
// ...
// the rest of our service code
// ...
app.listen(3000, () => {
console.log("app: ready");
});
});
连接后,我们可以像之前一样创建我们的表。这次,我们不需要指定任何结构:
rethinkdb.tableCreate("images").run(db);
rethinkdb对象是我们用来操作我们的表的,而db对象是一个连接对象,用于引用连接并指示在哪里运行我们的操作。
如果你像这样重启我们的服务,你会在之前创建的数据库中看到一个新表:

如果你再次重启我们的服务,尝试创建已经存在的表时会出错。我们需要检查它是否已经存在,并且只有在不存在的情况下才发出命令:
rethinkdb.tableList().run(db, (err, tables) => {
if (err) throw err;
if (!tables.includes("images")) {
rethinkdb.tableCreate("images").run(db);
}
});
接下来,我们的上传方法应该稍微改变一下,如下所示:
app.post("/uploads/:name", bodyparser.raw({
limit : "10mb",
type : "image/*"
}), (req, res) => {
rethinkdb.table("images").insert({
name : req.params.name,
size : req.body.length,
data : req.body,
}).run(db, (err) => {
if (err) {
return res.send({ status : "error", code: err.code });
}
res.send({ status : "ok", size: req.body.length });
});
});
如果你像这样重启服务器,你应该能够上传一个image:

我们收到相同的响应,就像 MySQL 一样。我们可以进入管理控制台中的数据探索器部分,获取我们的记录以查看它是否在那里:

看起来不错。注意我们的记录 ID 不是一个数字,它是一个通用唯一标识符(UUID)。这是因为 RethinkDB 支持分片(如果有多于一个服务器,我们的表默认是分片的)并且相对于递增数字,分片唯一标识符更容易。
接下来是我们的 Express 参数:
app.param("image", (req, res, next, image) => {
if (!image.match(/\.(png|jpg)$/i)) {
return res.status(403).end();
}
rethinkdb.table("images").filter({
name : image
}).limit(1).run(db, (err, images) => {
if (err) return res.status(404).end();
images.toArray((err, images) => {
if (err) return res.status(500).end();
if (!images.length) return res.status(404).end();
req.image = images[0];
return next();
});
});
});
通过这个改变,我们现在可以重启我们的服务并查看我们的image是否存在:

我们需要稍微改变一下下载方式。我们需要删除之前的查询以更新我们的使用日期,并用一个新的查询替换它:
app.get("/uploads/:image", (req, res) => {
let image = sharp(req.image.data);
let width = +req.query.width;
let height = +req.query.height;
let blur = +req.query.blur;
let sharpen = +req.query.sharpen;
let greyscale = [ "y", "yes", "true", "1",
"on"].includes(req.query.greyscale);
let flip = [ "y", "yes", "true", "1",
"on"].includes(req.query.flip);
let flop = [ "y", "yes", "true", "1",
"on"].includes(req.query.flop);
if (width > 0 && height > 0) {
image.ignoreAspectRatio();
}
if (width > 0 || height > 0) {
image.resize(width || null, height || null);
}
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
rethinkdb.table("images").get(req.image.id).update({ date_used :
Date.now() }).run(db);
res.setHeader("Content-Type", "image/" +
path.extname(req.image.name).substr(1));
image.pipe(res);
});
我们现在可以使用网络浏览器下载我们的图片:

接下来,我们需要更新我们的图片删除方法。它就像我们的上传一样简单:
app.delete("/uploads/:image", (req, res) => {
rethinkdb.table("images").get(req.image.id).delete().run(db, (err)
=> {
return res.status(err ? 500 : 200).end();
});
});
这次,我们使用了图片的唯一 ID 来删除它。如果我们再次使用 curl 命令尝试,我们会收到一个代码 200:

如果我们尝试获取我们表的第一条记录,我们会看到那里什么也没有:

最后,这是我们引入 MySQL 后添加的两个额外功能:统计和删除旧的不用图片。
我们的统计方法并不像运行带有聚合的 SQL 查询那样简单。我们必须计算我们每个统计量:
app.get("/stats", (req, res) => {
let uptime = process.uptime();
rethinkdb.table("images").count().run(db, (err, total) => {
if (err) return res.status(500).end();
rethinkdb.table("images").sum("size").run(db, (err, size) => {
if (err) return res.status(500).end();
rethinkdb.table("images").max("date_created").run(db, (err,
last_created) => {
if (err) return res.status(500).end();
last_created = (last_created ? new
Date(last_created.date_created) : null);
return res.send({ total, size, last_created, uptime });
});
});
});
});
我们应该得到与之前相似的结果:

删除旧图片相对容易;我们只需要过滤掉我们想要删除的图片,然后删除它们:
setInterval(() => {
let expiration = Date.now() - (30 * 86400 * 1000);
rethinkdb.table("images").filter((image) => {
return image("date_used").lt(expiration);
}).delete().run(db);
}, 3600 * 1000);
我简化了之前的策略,只是删除了 1 个月(30 天,每天 86,400 秒,每天 1,000 毫秒)之前的 images。
Redis
内存数据库与前面两种类型不同,因为它们通常不是结构化的,这意味着你没有任何表。你通常有一些可以查找和操作的类型列表,或者简单的哈希表。
利用我们之前为 Hydra 安装的 Redis 实例,让我们看看这种数据库的另一个缺点,或者实际上是特性。让我们连接到我们的 Redis 实例并执行以下指令序列:

我们在这里所做的是:
-
使用
redis-cli连接到 Redis 服务。 -
获取计数器的内容,它是 nil(无),因为我们还没有定义它。
-
增加计数器,现在是自动定义并设置为
1。 -
再次增加计数器,现在是
2。 -
获取计数器的内容,当然是
2。 -
关闭 Redis 服务。
-
启动 Redis 服务。
-
再次连接到 Redis 服务。
-
获取计数器的内容,它是 nil(无)。
我们的计数器在哪里?嗯,这是一个内存数据库,所以当我们关闭 Redis 服务时,一切都会消失。这是几乎所有内存数据库的设计。
它们被设计成快速和内存中的。它们的目的通常是缓存那些获取成本高昂的数据,比如一些复杂的计算,或者下载量大的数据,我们希望它们能够更快地可用(内存中)。
我对 Redis 并不完全公平,因为它实际上允许你的数据在服务重启之间被保存。所以,让我们看看我们可以用它来保存我们的微服务状态有多远。
如前所述,让我们卸载 rethinkdb 并安装 redis 模块:
npm uninstall rethinkdb --save
npm install redis --save
让我们忽略我们的settings.json文件(如果你喜欢,你可以将其删除)并假设 Redis 将运行在我们的本地机器上。
首先,我们需要包含redis模块并创建一个Client实例:
const redis = require("redis");
const db = redis.createClient();
然后,我们需要等待它连接:
db.on("connect", () => {
console.log("db: ready");
// ...
// the rest of our service code
// ...
app.listen(3000, () => {
console.log("app: ready");
});
});
我们可以使用 Redis 存储我们的数据的方式有很多种。为了简单起见,因为我们没有表,让我们使用散列来存储我们的图像。每个图像将有一个不同的散列,散列的名称将是图像的名称。
由于这种类型的数据库中没有表,我们的初始化代码可以简单地删除。
接下来,让我们更改我们的上传方法,以便在 Redis 上存储数据。正如我提到的,让我们将其存储在以image命名的散列中:
app.post("/uploads/:name", bodyparser.raw({
limit : "10mb",
type : "image/*"
}), (req, res) => {
db.hmset(req.params.name, {
size : req.body.length,
data : req.body.toString("base64"),
}, (err) => {
if (err) {
return res.send({ status : "error", code: err.code });
}
res.send({ status : "ok", size: req.body.length });
});
});
hmset命令允许我们设置散列的多个字段,在我们的例子中,是size和data。注意我们以base64编码存储我们的图像内容,否则我们会丢失数据。如果我们重启我们的服务并尝试上传我们的测试image,它应该可以正常工作:

然后,我们可以使用redis-cli查看我们的图像是否在那里。嗯,我们正在检查散列是否具有字段大小并且与我们的图像大小匹配:

太好了!现在我们可以更改我们的 Express 参数,以查找image散列:
app.param("image", (req, res, next, name) => {
if (!name.match(/\.(png|jpg)$/i)) {
return res.status(403).end();
}
db.hgetall(name, (err, image) => {
if (err || !image) return res.status(404).end();
req.image = image;
req.image.name = name;
return next();
});
});
我们的image检查方法现在应该可以工作。而且,为了我们的下载方法能够工作,我们只需要将图像加载更改为解码我们之前的base64编码:
app.get("/uploads/:image", (req, res) => {
let image = sharp(Buffer.from(req.image.data, "base64"));
let width = +req.query.width;
let height = +req.query.height;
let blur = +req.query.blur;
let sharpen = +req.query.sharpen;
let greyscale = [ "y", "yes", "true", "1", "on"].includes(req.query.greyscale);
let flip = [ "y", "yes", "true", "1", "on"].includes(req.query.flip);
let flop = [ "y", "yes", "true", "1", "on"].includes(req.query.flop);
if (width > 0 && height > 0) {
image.ignoreAspectRatio();
}
if (width > 0 || height > 0) {
image.resize(width || null, height || null);
}
if (flip) image.flip();
if (flop) image.flop();
if (blur > 0) image.blur(blur);
if (sharpen > 0) image.sharpen(sharpen);
if (greyscale) image.greyscale();
db.hset(req.image.name, "date_used", Date.now());
res.setHeader("Content-Type", "image/" + path.extname(req.image.name).substr(1));
image.pipe(res);
});
我们现在正从 Redis 中提供图像。作为额外的好处,我们在image散列中添加/更新了一个date_used字段,以指示它最后一次被使用的时间:

删除我们的image就像删除我们的散列一样简单:
app.delete("/uploads/:image", (req, res) => {
db.del(req.image.name, (err) => {
return res.status(err ? 500 : 200).end();
});
});
然后,我们可以尝试删除我们的test图像:

使用redis-cli检查散列是否存在,我们看到它已经消失了:

缺少的只有两个功能:统计信息和删除旧图像。
对于统计信息,这可能很困难,因为我们使用的是通用的散列表,我们无法确定定义了多少个散列表,以及是否所有或任何散列表都有图像数据。我们可能需要扫描所有散列表,这对于大型集合来说很复杂。
要删除旧图像,问题与查找具有特定条件(如字段值)的散列表的方式相同。
仍然有其他路径可以解决这个问题。例如,我们可以有一个包含我们图像名称的另一个散列表,并使用日期。但是,复杂性会增加,而且由于我们通过不同的散列表分割信息而没有确保原子性、一致性、隔离性和持久性(ACID)操作,完整性可能会受到风险。
结论
正如我们所见,有许多选项可以存储我们的微服务状态。根据我们操作的信息类型,有一些数据库更适合处理我们的数据。
这一切都取决于我们应该问自己的一些不同的问题:
-
我们的数据完整性重要吗?
-
我们的数据结构复杂吗?
-
我们需要获取哪些类型的信息?
如果我们的数据完整性很重要或数据结构复杂,不要使用内存数据库。根据复杂度,看看你是否需要一个非关系型数据库,或者是否可以使用能够处理更复杂操作和数据聚合的关系型数据库,这将有助于你实现最后一个要点。
安全
一个好的做法是迭代性地编写代码,每次我们添加一个新的小功能或改进时都进行测试,并且始终编写考虑到我们为服务设想的所有功能的代码。
考虑服务的路线图可以帮助你为未来的改进做好准备,减少以后浪费或替换的代码量。
例如,在安全方面:
-
我们的服务是否安全?它是否为某些类型的恶意攻击做好了准备?
-
我们的服务是否私密?它是否应该有一些认证或授权机制?
幸运的是,我们的框架允许我们的代码进行组合,并允许我们后来添加安全层。例如,使用 Express 或 Hydra,我们可以添加一个先导路由功能,该功能将在我们的服务方法之前运行,使我们能够强制执行,例如,认证。
查看我们的服务,因为它使用 HTTP 公开其方法,我们可以对其进行一些改进,例如:
-
认证:强制任何使用它的人进行身份验证。或者,仅对上传和删除方法进行强制。这取决于你。也可能有用户账户,每个用户都会看到他们各自的图片列表。
-
授权:限制例如哪些网络可以访问服务,无论是否有有效的认证。
-
机密性:保护用户免受网络流量中窥探者的侵害。
-
可用性:限制每个客户端对服务的最大使用频率,以确保单个客户端不会阻塞你的整个服务。
为了引入这些改进,你可以添加一个认证模块,例如 Passport 模块,并使用证书为用户提供更安全的 HTTPS 体验。
其他类型的不安全性直接来自你的代码,并且通过添加证书或强制认证并不能得到改善。我指的是:
-
缺陷、编程逻辑错误以及未正确测试的使用案例,可能导致小或严重的问题。
-
依赖性错误,你可能没有意识到,但仍然可能破坏你的服务,并可能迫使你寻找替代依赖项,这从来都不是一项愉快的任务。
为了最小化这些事件,你应该始终不断进化你的测试套件,随着用例的出现添加它们,确保解决的新问题不会在以后再次出现。关于依赖性问题,你可以订阅 Node Security Project,甚至将其集成到你的代码中,以便始终知道你的依赖项中是否有任何一个是风险的。
如果有源代码戒律,接下来的四条肯定会在列表上:
-
保持代码简单。如果代码变得复杂,就停下来,回顾一下,并将代码拆分成更简单的部分。
-
验证外部输入,无论是用户还是另一个服务。永远不要相信来自外部的数据。
-
默认拒绝而不是相反,检查某人是否有权访问资源,并拒绝任何无权访问的人。
-
从项目开始时就添加测试用例。
摘要
状态是任何服务的一部分,状态建立在数据之上。为了获得更云原生(cloud-native)的体验,服务不能依赖于传统的文件系统,而需要使用其他类型的存储结构来存储数据。数据库是一个自然的发展,根据我们的数据的重要性和复杂性,我们可以选择一些数据库类型。
假设我们的状态已安全存储在某些类型的数据库服务中,确保我们的数据不能通过我们的服务被破坏也同样重要。我们的服务中可能存在安全漏洞和错误,可能会使我们的数据处于风险之中,因此,在规划服务路线图时,编写简单的代码、验证输入和考虑安全性是很重要的。
为了推进我们的服务,让我们引入我们尚未做但应该做的事情,那就是一个合适的测试套件。在下一章中,我们将看到一些好的选择,并创建一个测试套件,确定是否需要改变以使我们的服务尽可能安全。
第二十三章:测试
当你在开发应用程序时,它最终会形成一个结构,并演变成为一个可以用于生产并出售给客户的稳定产品。一开始,一切可能看起来都很简单,许多人倾向于推迟构建合适的测试套件。
“调试是编写代码的两倍困难。”
——布赖恩·W·克尼汉和 P. J.普劳格在《程序设计风格要素》一书中
随着时间的推移,应用程序可能会变得足够复杂,以至于你犹豫是否开始测试。你可能会最终放弃,不再测试你的应用程序。这可能令人沮丧,尤其是如果你以前从未见过或使用过任何测试套件。
正确的测试不仅能提供一定程度的质量保证,还能提供:
-
可预测性:这意味着无论你的代码执行是应用程序还是只是一个模块,都将有一个预期的结果。随着你完善测试并引入不同的测试用例,你开始满足代码的所有用途,并确保其结果符合预期。
-
功能覆盖率:这意味着你可以衡量你的代码哪些部分被测试了,哪些没有。有许多工具可以检查你的代码并告诉你哪些部分在你的测试套件中没有使用,这有助于你为尚未覆盖的代码的特定部分创建特定的测试。
-
安全演变:这是一个副作用。当你的代码变得复杂时,如果你的测试套件有良好的代码覆盖率,你可以在不损害稳定性的情况下进行更改和添加功能,因为你可以持续运行测试套件并查看是否有任何东西被破坏。
正在发展中的方法包括首先为新的功能创建一个测试,然后确保测试通过。这样,你可以专注于你认为你的代码应该如何被使用(在新的测试中),然后逐步完善它(实际上开发它),以便测试不再失败并给出正确的结果。
让我们看看代码覆盖率如何在测试过程中发挥作用。最后,我们将探讨如何模拟代码的一部分。
集成测试
我们将创建我们的第一个集成测试。我们的每个测试都将单独运行,这意味着它们不应该依赖于任何其他测试,并且应该遵循可预测的工作流程。首先,我们需要更改run.js文件以运行所有测试文件。为此,我们将使用mocha并添加在integration文件夹中找到的所有文件:
const fs = require("fs");
const path = require("path");
const mocha = require("mocha");
const suite = new mocha();
fs.readdir(path.join(__dirname, "integration"), (err, files) => {
if (err) throw err;
files.filter((filename) =>
(filename.match(/\.js$/))).map((filename) => {
suite.addFile(path.join(__dirname, "integration", filename));
});
suite.run((failures) => {
process.exit(failures);
});
});
然后,让我们在test文件夹内创建一个名为integration的文件夹,并创建我们的第一个测试文件,命名为image-upload.js。将以下内容添加到文件中:
describe("Uploading image", () => {
it("should accept only images");
});
如果我们现在再次运行测试,我们应该看到默认的mocha响应,没有测试通过,也没有测试失败:
npm test
> imagini@1.0.0 test /Users/dresende/imagini
> node test/run
0 passing (2ms)
为了避免代码重复,让我们在test文件夹内创建一个tools.js文件,这样我们就可以导出每个测试文件都可以使用的常见任务。开箱即用,我想到的是我们的微服务位置和一个示例图像:
const fs = require("fs");
const path = require("path");
exports.service = require("../imagini.js");
exports.sample = fs.readFileSync(path.join(__dirname, "sample.png"));
在test文件夹中创建一个sample.png图片。当测试需要上传图片时,它将使用这个样本。将来,我们可能会有不同类型的样本,例如大图片,以测试性能和限制。
使用 chai
我们还需要对我们的微服务做一些小的改动。我们需要导出它的应用,这样chai的 HTTP 插件就可以加载它,我们可以在不运行在单独的控制台的情况下对其进行测试。将以下内容添加到我们的微服务文件末尾:
module.exports = app;
你应该有一个类似于以下截图的文件夹层次结构:

现在应该将我们的image-upload.js测试文件更改为创建我们的第一个真实测试:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("Uploading image", () => {
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_upload.png")
.end(() => {
return done();
});
});
it ("should accept a PNG image", function (done) {
chai
.request(tools.service)
.post("/uploads/test_image_upload.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
return done();
});
});
});
我们首先包括chai模块和我们的tools文件:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
然后,我们将我们的测试文件描述为上传图片:
describe("Uploading image", () => {
我们将添加我们可以想到的不同用例,与图片上传相关。
在内部,我们使用beforeEach,这是一个mocha方法,将在文件中的每个测试之前被调用。记住,我们希望我们的测试是一致的,所以我们添加这个方法在运行每个测试之前删除我们的图片。我们不在乎图片是否存在:
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_upload.png")
.end(() => {
return done();
});
});
看看我们如何使用tools.service,它指向我们的微服务。如果我们以后更改名称或使其更复杂,我们只需更改tools文件,一切都应该正常工作。
然后,我们添加我们的第一个integration文件测试——一个简单的图片上传:
it("should accept a PNG image", (done) => {
chai
.request(tools.service)
.post("/uploads/test_image_upload.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
return done();
});
});
它检查 HTTP 响应代码是否为200,以及响应体,这是一个 JSON 结构,其状态属性设置为ok。我们就完成了!
让我们再次运行我们的测试套件,看看效果如何。

覆盖所有代码
目前,让我们专注于为我们的代码添加覆盖率。当它仍然只是一个小型服务时,尽可能多地覆盖它很重要。如果我们开始添加测试和覆盖率,而它已经很大了,你会感到沮丧,而且很难找到覆盖所有内容的动力。
这样,你会发现一开始就覆盖它并尽可能保持覆盖率百分比很高是很有回报的,同时随着代码的演变。
让我们回到我们的图片上传测试,并添加另一个测试:
it("should deny duplicated images", (done) => {
chai
.request(tools.service)
.post("/uploads/test_image_upload.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
chai
.request(tools.service)
.post("/uploads/test_image_upload.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("error");
chai.expect(res.body).to.have.property("code",
"ER_DUP_ENTRY");
return done();
});
});
});
这将连续上传同一张图片两次,我们应该从数据库收到一个错误,表示有重复。让我们再次运行测试:

现在,让我们打开覆盖率报告的初始页面:

注意到我们的文件不再在红色背景中。这意味着语句覆盖率达到了50%。让我们点击我们的文件,看看我们的图片上传方法是如何被覆盖的:

完成了!我们现在可以继续了。在我们转向另一种方法之前,提醒一下:完全覆盖并不意味着没有错误。这是你需要理解的事情。你可能有一个你未预料到的用例,因此没有为它编写代码,所以没有明显的覆盖率。
例如,bodyparser模块不会限制内容的类型。如果我们上传一个带有图像名称的文本文件,我们的代码将接受它并将其存储在数据库中而不会注意到。将这个用例视为你的作业,并尝试创建一个测试来覆盖这个用例,然后修复代码。
让我们转到我们在上传方法之后看到的下一个方法:第 67 行的图像检查。让我们创建一个新的集成测试文件,命名为image-check.js,并添加一个简单的测试:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("Checking image", () => {
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_check.png")
.end(() => {
return done();
});
});
it("should return 404 if it doesn't exist", (done) => {
chai
.request(tools.service)
.head("/uploads/test_image_check.png")
.end((err, res) => {
chai.expect(res).to.have.status(404);
return done();
});
});
it("should return 200 if it exists", (done) => {
chai
.request(tools.service)
.post("/uploads/test_image_check.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
chai
.request(tools.service)
.head("/uploads/test_image_check.png")
.end((err, res) => {
chai.expect(res).to.have.status(200);
return done();
});
});
});
});
让我们运行测试套件:

我们可以看到我们的控制台报告正在变大。随着我们创建新的集成测试文件并为每个文件添加描述,mocha会写出一个漂亮的树视图,显示测试是如何运行的。在底部,我们可以看到覆盖率报告:

看着检查方法,我们看到它现在完全覆盖了。这个很简单。
我们仍然在语句覆盖的中间;图像操作的方法几乎占我们代码的一半。这意味着当我们开始覆盖它时,覆盖率将显著提高。
让我们为它创建一个integration测试:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("Downloading image", () => {
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_download.png")
.end(() => {
chai
.request(tools.service)
.post("/uploads/test_image_download.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
return done();
});
});
});
it("should return the original image size if no parameters given",
(done) => {
chai
.request(tools.service)
.get("/uploads/test_image_download.png")
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.length(tools.sample.length);
return done();
});
});
});
在每个测试之前,我们都会删除图像(如果存在)然后上传一个新的样本。然后,对于每个测试,我们将下载它并根据我们要求测试输出。
让我们尝试运行它:

嗯,这是出乎意料的。测试失败是因为我们的长度检查不匹配。这实际上是我们开始执行测试时刚刚注意到的一个很好的例子。
发生的情况是,当我们请求一个图像时,我们使用sharp模块根据查询参数对图像进行任何操作。在这种情况下,我们没有要求任何操作,但当我们通过sharp输出图像时,它实际上返回了相同大小的图像,但可能质量略低,或者它可能只是知道如何更好地编码我们的图像并从文件中删除不需要的数据。
我们不知道确切的原因,但假设我们想要原始图像,保持其未受修改的状态。我们需要更改我们的下载方法。假设如果没有定义任何查询参数,我们直接返回原始图像。让我们在我们的方法顶部添加一个条件:
if (Object.keys(req.query).length === 0) {
db.query("UPDATE images " +
"SET date_used = UTC_TIMESTAMP " +
"WHERE id = ?",
[ req.image.id ]);
res.setHeader("Content-Type", "image/" +
path.extname(req.image.name).substr(1));
return res.end(req.image.data);
}
如果我们现在运行它,我们应该没有失败:

我们的语句覆盖率没有显著提高,因为我们实际上在方法顶部创建了一个条件并立即返回,所以我们的上一个方法仍然未经过测试:

查看 第 78 行,你应该看到一个新标记,一个E,表示该行中的条件从未执行过else语句,也就是我们代码的其余部分。让我们为这个集成添加一个测试并调整我们的图片大小。
我们将需要sharp来帮助我们检查结果是否正确。让我们将其包含在我们的文件顶部:
const sharp = require("sharp");
然后,添加一个调整大小的测试:
it("should be able to resize the image as we request", (done) => {
chai
.request(tools.service)
.get("/uploads/test_image_download.png?width=200&height=100")
.end((err, res) => {
chai.expect(res).to.have.status(200);
let image = sharp(res.body);
image
.metadata()
.then((metadata) => {
chai.expect(metadata).to.have.property("width", 200);
chai.expect(metadata).to.have.property("height", 100);
return done();
});
});
});
让我们运行我们的测试套件:

现在看起来非常好。从控制台报告中,我们可以看到一些绿色。让我们看看覆盖率报告的前页:

我们在这里也看到了绿色。超过 80% 的覆盖率是好的,但我们还可以更进一步。让我们看看文件:

大概已经覆盖了。我们仍然需要覆盖所有的影响。实际上,我们可以一次运行它们所有。前两个条件也有一个E标记,但在添加一个不调整大小的测试后应该会消失。让我们添加它:
it("should be able to add image effects as we request", (done) => {
chai
.request(tools.service)
.get("/uploads/test_image_download.png?
flip=y&flop=y&greyscale=y&blur=10&sharpen=10")
.end((err, res) => {
chai.expect(res).to.have.status(200);
return done();
});
});
现在查看我们的报告,我们看到覆盖率几乎已经完成:

为了覆盖那些黄色的空值,我们需要仅使用width或height来调整图片大小。我们可以为这些情况添加两个测试:
it("should be able to resize the image width as we request", (done) => {
chai
.request(tools.service)
.get("/uploads/test_image_download.png?width=200")
.end((err, res) => {
chai.expect(res).to.have.status(200);
let image = sharp(res.body);
image
.metadata()
.then((metadata) => {
chai.expect(metadata).to.have.property("width", 200);
return done();
});
});
});
为height添加一个类似的测试,并运行测试套件。你不应该看到语句覆盖率上升,只有分支覆盖率:

唯一缺少的方法是统计方法。这个很简单。我们最终可以通过请求统计信息,进行如上传之类的更改,然后再次请求统计信息来比较来运行一个更具体的测试。我会把这个留给你。我们只需添加一个简单的请求测试:
const chai = require("chai");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("Statistics", () => {
it("should return an object with total, size, last_used and
uptime", (done) => {
chai
.request(tools.service)
.get("/stats")
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.property("total");
chai.expect(res.body).to.have.property("size");
chai.expect(res.body).to.have.property("last_used");
chai.expect(res.body).to.have.property("uptime");
return done();
});
});
});
现在,运行我们的测试套件应该会显示所有绿色:

我们看到只有两行未被覆盖:29.121。第一行是我们的计时器,第二行是在统计方法上。让我们刷新我们的 HTML 报告:

这是有回报的;我们几乎有 100% 的覆盖率。只有一个函数没有被覆盖,那就是我们的计时器。而且,只有三个语句,它们也代表了三个分支,没有被覆盖,但这些实际上并不重要。
重要的是在整个开发过程中保持这个高覆盖率。
模拟我们的服务
在你的服务中,有些部分可能更难测试。其中一些,或者大多数,与错误相关的条件有关,在这些条件下,很难让外部服务,如数据库引擎返回在正常执行期间很少发生的错误。
为了能够测试,或者至少模拟这些类型的事件,我们需要模拟我们的服务。在这方面有几个选项,而在 Node.js 生态系统中,Sinon 是最常用的一个。这个框架提供了不仅仅是模拟的功能;它还提供了以下功能:
-
间谍(Spies):记录哪些监控函数被调用以及传递的参数、返回值和其他属性
-
存根(Stubs):是增强版的间谍,具有预编程的行为,帮助我们驱动执行进入预定的路径(允许我们模拟行为)
Sinon 还允许我们通过虚拟改变服务对时间的感知来弯曲时间,并能够测试定时器间隔调用(记得我们的间隔计时器吗?)。考虑到这一点,让我们看看我们能否使我们的微服务达到 100% 的测试覆盖率。
让我们先安装框架,就像我们之前对 chai 做的那样:
npm install --save-dev sinon
现在,让我们添加一个针对图片删除的测试。这个方法通过其他测试进行了测试,这就是为什么我们之前不需要添加它,但现在我们想要完全测试它,让我们添加一个基本的测试文件 image-delete.js,内容如下:
const chai = require("chai");
const sinon = require("sinon");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe.only("Deleting image", () => {
beforeEach((done) => {
chai
.request(tools.service)
.delete("/uploads/test_image_delete.png")
.end(() => {
return done();
});
});
it("should return 200 if it exists", (done) => {
chai
.request(tools.service)
.post("/uploads/test_image_delete.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
chai
.request(tools.service)
.delete("/uploads/test_image_delete.png")
.end((err, res) => {
chai.expect(res).to.have.status(200);
return done();
});
});
});
});
注意,我添加了 Sinon 依赖项在顶部,尽管我现在还没有使用它。你可以再次运行测试,但你不应该注意到任何区别。
我们需要更改数据库的行为,所以让我们导出一个对其的引用,以便在测试中访问它。在我们连接数据库之前,在我们的微服务文件中添加以下行:
app.db = db;
现在,向该文件添加另一个测试:
it("should return 500 if a database error happens", (done) => {
chai
.request(tools.service)
.post("/uploads/test_image_delete.png")
.set("Content-Type", "image/png")
.send(tools.sample)
.end((err, res) => {
chai.expect(res).to.have.status(200);
chai.expect(res.body).to.have.status("ok");
let query = sinon.stub(tools.service.db, "query");
query
.withArgs("DELETE FROM images WHERE id = ?")
.callsArgWithAsync(2, new Error("Fake"));
query
.callThrough();
chai
.request(tools.service)
.delete("/uploads/test_image_delete.png")
.end((err, res) => {
chai.expect(res).to.have.status(500);
query.restore();
return done();
});
});
});
我们正在上传图片,但在请求删除之前,我们在 db.query 方法上创建了一个 stub。然后我们通知 Sinon,当 stub 被带有 DELETE 首个参数调用时,我们希望它异步调用第三个参数(计数从 0 开始)并返回一个假错误。对于任何其他调用,我们希望它直接通过。
然后,在删除图片之后,我们检查是否收到了 HTTP 500 错误代码,并将 stub 恢复到原始功能,以确保其他测试通过。
我们能够测试这一点,因为 mocha 以串行方式运行测试;否则,我们需要做一些体操来确保我们不会干扰其他测试。
现在,打开之前创建的测试文件 image-stats.js,在顶部包含 Sinon,并添加以下测试:
it("should return 500 if a database error happens", (done) => {
let query = sinon.stub(tools.service.db, "query");
query
.withArgs("SELECT COUNT(*) total, SUM(size) size, MAX(date_used)
last_used FROM images")
.callsArgWithAsync(1, new Error("Fake"));
query
.callThrough();
chai
.request(tools.service)
.get("/stats")
.end((err, res) => {
chai.expect(res).to.have.status(500);
query.restore();
return done();
});
});
我们现在覆盖率已经超过 97%。让我们弯曲时间并测试我们的计时器。创建一个名为 image-delete-old.js 的新测试文件,并添加以下内容:
const chai = require("chai");
const sinon = require("sinon");
const http = require("chai-http");
const tools = require("../tools");
chai.use(http);
describe("Deleting older images", () => {
let clock = sinon.useFakeTimers({ shouldAdvanceTime : true });
it("should run every hour", (done) => {
chai
.request(tools.service)
.get("/stats")
.end((err, res) => {
chai.expect(res).to.have.status(200);
clock.tick(3600 * 1000);
clock.restore();
return done();
});
});
});
在这个测试中,我们用假定时器替换了全局定时器函数(setTimeout 和 setInterval)。然后我们进行了一次简单的统计调用,然后通过一个小时的时钟调用(tick call)推进时间,然后完成。
现在,运行测试并查看结果:

我们现在在函数和行上达到了 100% 的覆盖率。只有一个分支,缺少一个语句。这是连接错误的可能性:

我会留给你去想如何模拟它。
记住,如果你成功模拟了 connect 方法,你还需要处理抛出异常的情况。
摘要
测试使我们能够确保一定的代码质量水平。在代码简单时,从一开始就包含测试非常重要,以确保我们保持测试更新,避免回归到预期的行为。
当我们看到我们的代码具有非常高的测试覆盖率时,这感觉是非常有成就感的。这种感觉迫使你保持这个高分,并间接地维护了良好的代码质量。
第二十四章:设计模式
在上一章中,我们成功地将我们的服务部署到云提供商,而无需对代码进行根本性的更改。我们使用数据库来存储我们的数据,我们唯一需要做的就是指向新的位置。
在规范和开发过程中,遇到具有一种或多种解决方法的有挑战性的问题是常见的。你在开发过程中选择的方法或路径被称为设计模式,因为它们是你设计的一部分。
一些设计模式比其他模式更常见。一些是众所周知的,而另一些则不那么为人所知。一些是好的设计模式,你应该遵循它们。其他则因为它们在短期或长期带来的缺点而不好。
在本章中,我们将探讨选择良好模式的重要性;我们将查看一些常见的架构模式,并回顾我们整本书中遵循的持续集成,直到我们成功地将服务部署到云端。
选择模式
模式不是库或类,它们是概念,是针对常见编程问题的可重用解决方案,针对特定用例进行了测试和优化。由于它们只是解决特定问题的概念,因此必须在每种语言中实现。
每个模式都有其优点和缺点,为问题选择错误模式可能会给你带来很大的麻烦。
模式可以加快开发过程,因为它们提供了经过良好测试和验证的开发范例。重用模式有助于防止问题,并提高熟悉模式的开发者之间的代码可读性。
模式在高性能应用程序中具有重大意义。有时,为了获得一些灵活性,模式会在代码中引入一个新的间接层次,这可能会降低性能。你应该选择何时引入模式,或者何时引入模式会损害你正在追求的性能指标。
了解良好的模式对于避免相反的情况:反模式至关重要。反模式是开始看起来不错,但后来却变成你做出的最糟糕决定的某种东西。反模式不是特定的模式,而更像是一些常见的错误,大多数人认为你不应该使用的策略。最常见和最频繁出现的反模式包括:
-
重复自己:不要重复过多的代码部分。退后一步,看看大局,然后重构它。一些开发者倾向于将这种重构视为应用程序的复杂性,但实际上它可以使你的应用程序更简单。如果你认为你无法理解重构的简单性,不要忘记在代码中添加一些介绍性注释。
-
金锤或银弹:不要认为你最喜欢的语言或框架是普遍适用的。大多数语言实际上可以做到任何事情,至少是成熟的那些,以及庞大的社区。这并不意味着语言在执行某些任务时表现良好。如果你的目标是性能,尝试在腰间带上几把锤子。
-
异常编程:不要在出现新情况时添加新代码来处理。这里的新情况,我说的不是新功能;我说的是代码没有预料到的行为。例如,当你制作某种类型的文件上传功能时,记住在传输过程中可能会发生错误,文件可能为空,内容奇怪,非常大,等等。
-
偶然编程:不要通过试错直到成功来编程。这真的是你应该避免的事情。偶然编程有时可能会使你的代码在某些情况下(偶然)工作,而在其他情况下产生错误的行为。
架构模式
当开发微服务,尤其是微服务生态系统时,一些模式变得非常明显,你会在不知不觉中使用它们。仅从架构模式来看,这里有一些你可能觉得有趣的模式。也许你之前已经使用过它们了。
前端控制器
前端控制器模式是指所有请求都指向你的架构中的一个单一点,称为处理器,然后它处理并调度请求到其他处理器。例如,负载均衡器和反向代理使用这种模式:

横向扩展很有用,尤其是在前端控制器只是路由请求时,这样它可以处理比每个单独控制器更多的请求,而每个控制器需要一些时间来实际处理每个请求。
这种模式在帮助其他服务不必知道控制器在哪里,并选择负载最低、应该更快处理请求的控制器方面也非常有用。
分层
分层模式在文件系统、操作系统(以及虚拟机)中很常见。这种模式包括创建不同的层,从原始数据到用户看到的数据:

理念是将不同层的复杂性分开,每一层都不必知道其他层是如何执行它们的任务:
-
处理数据结构并以快速和安全的方式存储它们
-
操作数据结构并向它们添加业务逻辑
-
处理用户请求并以本地化格式显示数据
服务定位器
服务定位器模式实际上是一种反模式。它不被认为是良好的实践,因为它会给生态系统增加更多的复杂性。该模式由一个中央注册表组成,称为服务定位器,服务在这里注册它们的能力,其他服务可以咨询注册表,了解它们所需的服务位于何处:

服务定位器与前端控制器类似,但增加了复杂性,因为你需要联系服务定位器和所需的服务,而不仅仅是向前端控制器发出简单请求。
观察者
观察者模式在 Node.js 中每天都会被使用。它由一个主题组成,该主题维护一个依赖者列表,称为观察者,它们会收到主题上发生的任何状态变化的通知:

你可以在你的网页浏览器中看到这种情况,每次某些代码(观察者)将事件监听器附加到对象或界面元素(主题)上时。
发布-订阅
另一个非常相似的模式是发布-订阅模式,通常缩写为Pub-Sub。这个模式几乎与上一个模式完全相同。你有订阅者,正如其名所示,订阅特定的事件、主题或你想要称之为什么的东西,然后你有发布者,它们会发出这些事件或向这些主题发送信息:

与之前模式的区别可能看起来非常微小,但实际上非常重要。发布-订阅模式涉及第三方服务,与观察者模式不同,发布者对订阅者没有任何了解。这消除了处理和直接通知订阅者的需求,从而简化了你的代码。
这种模式对于微服务通信非常有用。它涉及一个第三方,该第三方抽象化了状态变化的通知。此外,发布者和订阅者对彼此没有任何了解。
使用模式
选择好的设计模式本质上是在选择最佳实践。并非所有设计模式都适合每一个目的,但许多情况下,它们会使你的生活变得更轻松。也许一开始你可能不会注意到任何区别,但从长远来看,你应该会注意到。
良好的设计模式也有间接的优势。你会在网上找到更多的文档和示例,以及更广泛的选择范围,例如,在使用发布-订阅模式时,你将找到许多类型的实现,可以与你的服务集成。
选择适合你需求的设计模式也涉及到规划和了解你现在需要什么以及将来你需要什么。考虑边缘情况,看看模式是否能够处理它们。
规划你的微服务
开发微服务可能看起来像是一项简单的任务。正如其名所示,它是一个微服务。但这也并不一定正确,因为有时我们倾向于将本应简单的事情复杂化。
这并不意味着服务应该是简单的,它可以相当复杂。应该简单的是服务的目标和属性。关于它做什么以及它不应该做什么,不应该有任何疑问。
在编写任何一行代码之前,你应该先了解一些我称之为服务特征的几个方面,例如:
-
它是用来做什么的?它将执行哪些任务?
-
其他服务将如何使用它?它将使用什么协议?
-
它会取代另一个服务吗?它会覆盖相同的任务吗?
这可以用一个词来概括——目的。如果你没有明确定义它的目的,只是开始开发它能够处理的任务,你最终会得到一个混合服务,这与微服务的主要目标相偏离。
在明确目的之后,你可以选择最佳的模式并规划个别任务。第一个任务可能需要更长的时间来开发,因为你正在为服务创建基础布局。
不要忘记尽快添加测试、覆盖率和文档。我知道这可能是大多数开发者倾向于忽略的事情,但将来它会给你带来麻烦。对于第一个简单的任务,添加一个简单的测试会更简单。在这个阶段,代码覆盖率也会更容易。如果你在规划个别任务,添加文档也应该更容易。
在完成第一个任务并进行了适当的测试后,你应该设置你的第一次部署。这将结束你的第一个开发周期,并带你回到规划阶段。如果你保持这个循环,通过小任务,你会更快地开发和部署:

这种类型的小任务简单循环将允许你进行所谓的持续集成和持续部署,其中你能够开发并部署到测试集群。如果一切通过测试,更改可以自动测试和部署。
开发中的障碍
如我们之前所见,微服务架构有许多优点,例如将代码拆分为更小的孤立项目,这使得开发或甚至委派责任变得更加容易。
这些优势是有代价的,那就是构建一个更复杂的系统或应用程序。由于微服务应该如何工作,信息屏障是固有的。负责某种信息的微服务应该是唯一一个操作它的服务,迫使任何其他服务与之通信才能访问信息。
这让你对信息有更精细的控制,因为你知道负责它的服务,你可以强制服务或用户身份验证,包括授权甚至速率访问限制。但是,这意味着一个复杂的应用程序往往会惩罚网络,因为会有大量的服务间消息传递。
服务的消息传递意味着网络流量和延迟。如果服务不在同一个本地网络中,这最终会创建明显的延迟。添加某种缓存服务以加速访问也会增加更多复杂性。
尽管每个微服务的测试和开发应该比单体应用程序更容易,但整体来看微服务测试框架,并且有两个或更多服务一起测试,会更加复杂。
最后,避免一种被称为纳米服务的东西。这被认为是一种反模式,就是你过度细化你的架构,创建了过于小的服务,将你的开发复杂化到了极致。
在微服务的数量和每个微服务将执行的任务之间找到一个良好的平衡。把它们想象成一个将处理特定任务的人。这个任务是否过于简单以至于只需要一个人来做?这个人不应该有来自同一上下文的更广泛的任务集吗?
摘要
今天的应用程序有空间容纳微服务。应用程序不再是单体架构,并且早已离开了传统的计算机架构。用户不断要求应用程序之间的集成和互操作性。
微服务通过分离不同的上下文,如前端、后端、移动或简单的 API,帮助开发者降低应用程序的复杂性。它们是一个概念,或者是一个模式,当使用得当,可以给你带来巨大的力量,并分割复杂性和责任。
但是,微服务不仅仅是这样。微服务通过仅复制所需的服务而不是完整的单体应用程序来帮助你水平扩展,节省资源,最终节省金钱。
外面有太多可以探索的东西;我们只是刚刚触及了表面。有大量的云提供商和工具供你实验和选择最适合你的。记住,熟能生巧,所以回到你的工作桌前,祝你好运!


浙公网安备 33010602011771号