Node-Express-Web-开发-全-

Node Express Web 开发(全)

原文:zh.annas-archive.org/md5/0638438c1fe455a13c741db5271bca54

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书适合谁

这本书是为那些想使用 JavaScript、Node 和 Express 创建 Web 应用程序(传统网站、使用 React、Angular 或 Vue 的单页面应用程序、REST API 或介于两者之间的任何应用程序)的程序员而写的。Node 开发的一个令人兴奋的方面是,它吸引了一大批新的程序员群体。JavaScript 的易用性和灵活性吸引了来自世界各地的自学程序员。在计算机科学史上,编程从未如此易于接触。在线学习编程(以及在遇到困难时获得帮助)的资源数量和质量真是令人惊讶和鼓舞。所以对于那些新手(可能是自学的)程序员,我诚挚地欢迎您。

当然,还有像我这样在编程领域摸爬滚打多年的程序员。像我这样的程序员,从汇编语言和 BASIC 开始,经历了 Pascal、C++、Perl、Java、PHP、Ruby、C、C#和 JavaScript。在大学期间,我接触到了更多的专业语言,如 ML、LISP 和 PROLOG。这些语言中的许多都让我怀念至深,但在这些语言中,我看到的未来最具潜力的是 JavaScript。因此,我也为像我这样有丰富经验并且对特定技术有更深层见解的程序员写下了这本书。

不需要 Node 的经验,但应具有一定的 JavaScript 经验。如果您是新手程序员,我推荐 Codecademy。如果您是中级或有经验的程序员,我推荐我的书,学习 JavaScript,第三版(O’Reilly)。本书的示例可以在任何支持 Node 的系统上使用(包括 Windows、macOS 和 Linux 等)。这些示例面向命令行(终端)用户,因此您应对系统的终端有一定的熟悉。

最重要的是,这本书是为那些充满激情的程序员而写的。他们对互联网的未来充满期待,并希望成为其中的一部分。他们对学习新事物、新技术和看待 Web 开发的新方式充满激情。如果您,亲爱的读者,还没有激情,我希望在您读完本书之前,您会有所激发……

第二版说明

写这本书的第一版是一件愉快的事情,我至今对我能够在其中提出的实用建议和我的读者们的热烈反响感到满意。第一版正值 Express 4.0 从测试版发布,尽管 Express 仍然是 4.x 版本,但与 Express 配套的中间件和工具已经发生了巨大变化。此外,JavaScript 本身也在发展,甚至 Web 应用程序的设计方式也发生了地质学上的变化(从纯服务端渲染转向单页应用程序[SPA])。尽管第一版中的许多原则仍然有用和有效,但具体的技术和工具几乎完全不同。第二版已经迫在眉睫。由于 SPA 的盛行,本书的第二版也将更多地强调 Express 作为 API 和静态资产服务器,并包括一个 SPA 示例。

本书的组织方式

第一章和第二章将向你介绍 Node 和 Express 以及本书中将使用的一些工具。在第三章和第四章中,你开始使用 Express 并构建一个示例网站的框架,该示例网站将贯穿本书的其余部分作为运行示例。

第五章讨论了测试和 QA,第六章介绍了 Node 的一些重要构造以及它们如何被 Express 扩展和使用。第七章讲解了模板(使用 Handlebars),为使用 Express 构建有用网站打下了基础。第八章和第九章涵盖了 Cookies、Sessions 和表单处理,完善了你构建基本功能网站所需的知识。

第十章深入探讨了中间件,这是 Express 的核心概念。第十一章解释了如何使用中间件从服务器发送电子邮件,并讨论了与电子邮件相关的安全性和布局问题。

第十二章提前介绍了生产方面的考虑。尽管在本书的这个阶段,你可能还没有构建生产就绪网站所需的所有信息,但现在考虑生产问题可以帮助你避免未来的重大问题。

第十三章讨论持久性问题,重点关注 MongoDB(领先的文档数据库之一)和 PostgreSQL(流行的开源关系数据库管理系统)。

第十四章深入讲解 Express 路由的细节(URL 如何映射到内容),而第十五章则专注于使用 Express 编写 API。第十七章详细介绍了提供静态内容的细节,着重于最大化性能。

第十八章讨论了安全性:如何将身份验证和授权集成到您的应用程序中(重点是使用第三方身份验证提供者),以及如何通过 HTTPS 运行您的站点。

第十九章解释了如何与第三方服务集成。使用的示例包括 Twitter、Google Maps 和美国国家气象局。

第十六章利用我们对 Express 的学习,将运行示例重构为 SPA,Express 作为后端服务器提供我们在第十五章中创建的 API。

第二十章和第二十一章为您准备了大日子:您的网站上线。它们涵盖了调试,使您可以在上线前排除任何缺陷,以及上线的过程。第二十二章讨论了下一个重要(但常被忽视)阶段:维护。

本书以第二十三章结束,指向额外的资源,以便进一步学习有关 Node 和 Express 的知识,以及在哪里获取帮助。

示例网站

从第三章开始,本书将始终使用一个运行示例:Meadowlark Travel 网站。我在从里斯本旅行后不久写了第一版,当时我心里想着旅行,所以我选择的示例网站是我家乡俄勒冈州的一个虚构旅行公司(西部草地鹨是俄勒冈州的州鸟)。Meadowlark Travel 允许旅行者与当地的“业余导游”联系,并与提供自行车和滑板车租赁以及当地旅行的公司合作,专注于生态旅游。

就像任何教学示例一样,Meadowlark Travel 网站是虚构的,但它是一个涵盖真实世界网站面临的许多挑战的示例:第三方组件集成、地理位置、电子商务、性能和安全性。

由于本书的重点是后端基础设施,示例网站不会完整;它仅仅作为一个真实世界网站的虚构示例,以便为示例提供深度和背景。假设你正在开发自己的网站,你可以将 Meadowlark Travel 示例作为其模板。

本书使用的约定

本书使用以下印刷约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示应由用户按字面意思输入的命令或其他文本。

等宽斜体

显示应由用户提供值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注意事项。

警告

此元素表示警告或注意事项。

使用代码示例

可下载附加材料(代码示例、练习等)https://github.com/EthanRBrown/web-development-with-node-and-express-2e

此书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,则可以在您的程序和文档中使用它。除非您正在复制代码的大部分内容,否则不需要联系我们以获取权限。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发包含 O’Reilly 书籍示例的 CD-ROM 需要许可。通过引用本书并引用示例代码来回答问题不需要许可。将本书的大量示例代码合并到您产品的文档中需要许可。

我们感激但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Node 和 Express 进行 Web 开发,第二版 由伊桑·布朗(O’Reilly)著作权 2019 年伊桑·布朗,978-1-492-05351-4。”

如果您认为使用代码示例超出了公平使用或此处授予权限,请随时联系我们permissions@oreilly.com

O’Reilly 在线学习

注意

几乎 40 年来,O’Reilly Media 提供技术和商业培训、知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和 200 多家其他出版商的广泛文本和视频收藏。有关更多信息,请访问http://oreilly.com

如何联系我们

请联系出版商以解决有关此书的评论和问题:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/web_dev_node_express_2e

如需对本书进行评论或提出技术问题,请发送电子邮件至 bookquestions@oreilly.com

欲了解更多关于我们的书籍、课程、会议和新闻的信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

生活中有许多人对这本书的实现起到了重要作用;如果没有那些触及我生命并塑造我今日的人们的影响,这本书是不可能完成的。

首先,我要感谢 Pop Art 的所有人:在 Pop Art 的时光不仅让我对工程学有了新的热情,而且我从每个人那里学到了很多,没有他们的支持,这本书也不会存在。感谢 Steve Rosenbaum 创建了一个激励人心的工作场所,感谢 Del Olds 让我加入团队,让我感到受欢迎,并且是一个光荣的领导者。感谢 Paul Inman 对工程学持续的支持和鼓舞人心的态度,以及 Tony Alferez 的热情支持,帮助我腾出时间来写作,而不影响 Pop Art。最后,感谢所有优秀的工程师们,你们让我保持警觉:John Skelton、Dylan Hallstrom、Greg Yung、Quinn Michaels、CJ Stritzel、Colwyn Fritze-Moor、Diana Holland、Sam Wilskey、Cory Buckley 和 Damion Moyer。

我对目前在价值管理策略公司的团队深表感激。我从 Robert Stewart 和 Greg Brink 那里学到了很多关于软件业务的知识,从 Ashley Carson 那里学到了团队沟通、凝聚力和效率的重要性(感谢你的坚定支持,Scratch Chromatic)。Terry Hays、Cheryl Kramer 和 Eric Trimble,感谢你们的辛勤工作和支持!还要感谢 Damon Yeutter、Tyler Brenton 和 Brad Wells 在需求分析和项目管理中的关键工作。最重要的是,感谢那些在 VMS 与我一同不懈努力的才华横溢且敬业的开发者们:Adam Smith、Shane Ryan、Jeremy Loss、Dan Mace、Michael Meow、Julianne Soifer、Matt Nakatani 和 Jake Feldmann。

感谢所有在 School of Rock 的乐队成员!这是一段多么疯狂的旅程,也是一个充满乐趣的创造性出口。特别感谢那些分享他们对音乐的激情和知识的导师们:Josh Thomas、Amanda Sloane、Dave Coniglio、Dan Lee、Derek Blackstone 和 Cory West。谢谢你们给我成为摇滚明星的机会!

Zach Mason,感谢你给了我启发。这本书也许不像奥德赛的失落书籍那样,但这是我的,如果没有你的榜样,我可能不会如此大胆。

Elizabeth 和 Ezra,谢谢你们给我的礼物。我会永远爱你们两个。

我要感谢我的家人。我再也找不到比他们给我的更好、更有爱心的教育了,我也看到他们出色的家庭教育在我姐姐身上得到了体现。

特别感谢 Simon St. Laurent 给了我这个机会,以及 Angela Rufino(第二版)和 Brian Anderson(第一版)坚定而鼓舞人心的编辑工作。感谢 O’Reilly 的每一位员工对工作的奉献和热情。特别感谢 Alejandra Olvera-Novack、Chetan Karande、Brian Sletten、Tamas Piros、Jennifer Pierce、Mike Wilson、Ray Villalobos 和 Eric Elliot 对技术审查的彻底和建设性意见。

Katy Roberts 和 Hanna Nelson 在我“越过墙头”提案中提供了宝贵的反馈和建议,这使得这本书得以问世。非常感谢你们!感谢 Chris Cowell-Shah 在 QA 章节上的优秀反馈。

最后,感谢我亲爱的朋友们,没有你们,我肯定会变得疯狂:Byron Clayton、Mark Booth、Katy Roberts 和 Kimberly Christensen。我爱你们所有人。

第一章:介绍 Express

JavaScript 革命

在我介绍本书的主题之前,重要的是提供一些背景和历史背景,这意味着谈论 JavaScript 和 Node。JavaScript 的时代确实已经来临。从它作为客户端脚本语言的谦逊开始,它不仅在客户端完全普及,而且它作为服务器端语言的使用也终于起飞,多亏了 Node。

一种完全基于 JavaScript 的技术栈的承诺是明确的:不再需要切换上下文!你再也不必从 JavaScript 切换到 PHP、C#、Ruby 或 Python(或任何其他服务器端语言)。此外,它还赋予了前端工程师向服务器端编程迈进的能力。这并不是说服务器端编程只与语言有关;仍然有很多需要学习的地方。不过,至少使用 JavaScript 时,语言本身不会成为障碍。

这本书是为了所有看到 JavaScript 技术栈的潜力的人而写的。也许你是一名前端工程师,希望将你的经验扩展到后端开发。也许你像我一样是一名有经验的后端开发者,正在将 JavaScript 作为一种可行的替代方案来看待传统的服务器端语言。

如果你像我一样是一名软件工程师这么长时间了,你会看到许多语言、框架和 API 进入流行。有些大行其道,有些则逐渐被淘汰。你可能为自己能够快速学习新语言和新系统而感到自豪。你遇到的每种新语言都会感觉更加熟悉:你从大学学习的语言中认出一些内容,在几年前的工作中学到了另一些内容。有这样的视角感觉很好,当然,但也很疲惫。有时候你只想做点事情,而不必学习全新的技术或者重新磨练数月甚至数年未用的技能。

JavaScript 乍看起来可能不太可能成为冠军。我能理解,相信我。如果你在 2007 年告诉我,我不仅会认为 JavaScript 是我首选的语言,还会写一本关于它的书,我会告诉你你疯了。我对 JavaScript 有所有通常的偏见:我认为它是一个“玩具”语言,是给业余者和浅尝辄止者搞砸和滥用的东西。公平地说,JavaScript 确实降低了业余者的门槛,而且存在许多问题的 JavaScript,这并没有帮助改善语言的声誉。换句话说,可以说“恨玩家,不恨游戏”。

令人遗憾的是,人们对 JavaScript 持有这种偏见;这阻止了人们发现这门语言有多么强大、灵活和优雅。即使我们现在所知的这门语言自 1996 年以来已经存在了(尽管它的许多更吸引人的特性是在 2005 年添加的)。

通过阅读这本书,你可能摆脱了那种偏见:要么是因为像我一样,你已经克服了它,要么是因为你一开始就没有那种偏见。无论哪种情况,你都很幸运,我期待着向你介绍 Express,这是一项由一种令人愉快而惊喜的语言实现的技术。

2009 年,当人们开始意识到 JavaScript 作为浏览器脚本语言的强大和表现力时,Ryan Dahl 意识到 JavaScript 作为服务端语言的潜力,于是 Node.js 诞生了。这是互联网技术蓬勃发展的时期。Ruby(以及 Ruby on Rails)从学术计算机科学中吸取了一些很棒的思想,结合了一些自己的新思想,并向世界展示了一种更快构建网站和 Web 应用程序的方式。微软为了在互联网时代取得关键成就,不仅从 Ruby 和 JavaScript 中吸取了教训,还从 Java 的错误中汲取了教训,并且大量借鉴了学术界的成果。

如今,Web 开发人员有自由使用最新的 JavaScript 语言特性的权利,而不用担心排斥使用旧版浏览器的用户,这得益于 Babel 等转换技术。Webpack 已经成为管理 Web 应用程序依赖关系并确保性能的通用解决方案,而 React、Angular 和 Vue 等框架正在改变人们对 Web 开发的看法,将声明式的文档对象模型(DOM)操作库(如 jQuery)淘汰到昨天的新闻。

现在参与互联网技术是一件激动人心的事情。到处都有惊人的新思想(或者是惊人的老思想重焕生机)。创新和激情的精神现在比许多年前都要强烈。

介绍 Express

Express 网站将 Express 描述为“一个最小化且灵活的 Node.js Web 应用程序框架,为 Web 和移动应用程序提供了丰富的功能集。” 然而,这究竟意味着什么呢?让我们详细了解一下这个描述:

最小化

这是 Express 中最吸引人的方面之一。许多时候,框架开发者会忘记通常情况下“少即是多”的原则。Express 的理念是在你的大脑和服务器之间提供最小的层次。这并不意味着它不强大,或者它没有足够的有用功能。它的意思是它少给你增添麻烦,让你完全表达自己的想法,同时提供有用的东西。Express 提供了一个最小的框架,你可以根据需要添加 Express 功能的不同部分,替换不符合你需求的部分。这是一种清新的气息。这么多框架给你提供一切,使你在甚至写一行代码之前就面临庞大、神秘和复杂的项目。通常,第一项任务是浪费时间剔除不需要的功能,或者替换不符合要求的功能。Express 采取了相反的方式,允许你在需要时添加你需要的东西。

灵活

说到底,Express 所做的事情非常简单:它接受来自客户端的 HTTP 请求(可以是浏览器、移动设备、另一个服务器、桌面应用程序…… 任何能够使用 HTTP 接口的东西),然后返回一个 HTTP 响应。这一基本模式描述了几乎与互联网有关的所有事物,使得 Express 在应用中非常灵活。

Web 应用框架

也许更准确的描述应该是“Web 应用框架的服务器端组件”。今天,当你想到“Web 应用框架”,你通常会考虑到像 React、Angular 或 Vue 这样的单页应用框架。然而,除了少数独立应用程序外,大多数 Web 应用需要共享数据,并与其他服务集成。它们通常通过 Web API 来实现,这可以被认为是 Web 应用框架的服务器端组件。请注意,仍然有可能(有时也是可取的)只使用服务器端渲染构建整个应用程序,这种情况下,Express 很可能构成整个 Web 应用框架!

除了 Express 自己描述的功能,我还想补充两点:

快速

随着 Express 成为 Node.js 开发的首选 Web 框架,吸引了许多运行高性能、高流量网站的大公司的注意。这给 Express 团队带来了对性能的关注,使得 Express 现在为高流量网站提供了领先的性能。

无固执己见

JavaScript 生态系统的一个显著特点是其规模和多样性。虽然 Express 经常成为 Node.js Web 开发的核心,但在一个 Express 应用程序中,涉及到成百上千(如果不是成千上万)的社区包。Express 团队意识到了这种生态系统的多样性,并通过提供极其灵活的中间件系统来响应,使得在创建应用程序时能够轻松使用自己选择的组件。在 Express 的发展过程中,你可以看到它放弃了一些“内置”组件,转而采用可配置的中间件。

我提到 Express 是一个 Web 应用程序框架的“服务器端部分”…因此我们可能应该考虑一下服务器端应用程序和客户端应用程序之间的关系。

服务器端和客户端应用程序

服务器端应用程序 是指在服务器上渲染应用程序的页面(以 HTML、CSS、图像和其他多媒体资产以及 JavaScript 的形式)并发送给客户端的应用程序。相比之下,客户端应用程序 大多数情况下从一次性发送的初始应用程序捆绑包中渲染其自己的用户界面。也就是说,一旦浏览器接收到最初(通常非常简化的)HTML,它就使用 JavaScript 动态修改 DOM,不需要依赖服务器来显示新页面(尽管原始数据通常仍然来自服务器)。

在 1999 年之前,服务器端应用程序是标准。事实上,Web 应用程序 这个术语就是在那一年正式引入的。我认为大约在 1999 年到 2012 年之间的时期是 Web 2.0 时代,这段时间内开发了最终成为客户端应用程序的技术和技巧。到了 2012 年,随着智能手机的广泛普及,向网络发送尽可能少的信息成为常规做法,这种做法有利于客户端应用程序的发展。

服务器端应用程序通常被称为服务器端渲染(SSR),客户端应用程序通常称为单页应用程序(SPA)。客户端应用程序在诸如 React、Angular 和 Vue 等框架中得到了完全实现。我一直觉得“单页”有点不恰当,因为从用户的角度来看,确实可以有很多页面。唯一的区别在于页面是从服务器发送还是在客户端动态渲染。

实际上,在服务器端应用程序和客户端应用程序之间有许多模糊的界限。许多客户端应用程序有两到三个 HTML 捆绑包可以发送给客户端(例如,公共界面和已登录界面,或者常规界面和管理员界面)。此外,SPA 通常与 SSR 结合使用,以提高第一次加载性能并帮助搜索引擎优化(SEO)。

总的来说,如果服务器发送少量 HTML 文件(通常是一到三个),并且用户通过动态 DOM 操作体验丰富的多视图体验,我们认为这是客户端渲染。不同视图的数据(通常以 JSON 形式)和多媒体资产通常仍然来自网络。

当然,Express 并不太在乎你是在制作服务器端应用程序还是客户端应用程序;它愿意在任何角色中发挥作用。对于 Express 来说,无论您是提供一个 HTML 包还是一百个 HTML 包都没有区别。

尽管单页应用(SPAs)已经明确地“赢得了”主导的 Web 应用架构地位,本书始于与服务器端应用一致的例子。它们仍然相关,并且服务一个 HTML 包或多个之间的概念差异很小。在第十六章中有一个 SPA 示例。

Express 的简史

Express 的创造者 TJ Holowaychuk 将 Express 描述为受 Sinatra 启发的 Web 框架,Sinatra 是基于 Ruby 的 Web 框架。Express 借鉴了建立在 Ruby 上的框架并不奇怪:Ruby 衍生出了大量优秀的 Web 开发方法,旨在使 Web 开发更快、更高效和更易于维护。

尽管 Express 受到了 Sinatra 的启发,但它也与 Connect 深度交织,Connect 是 Node 的一个“插件”库。Connect 创造了“中间件”这个术语,用来描述可以处理 Web 请求的可插拔 Node 模块。在 2014 年的 4.0 版本中,Express 移除了对 Connect 的依赖,但仍然保留了其中间件的概念。

注意

Express 在 2.x 和 3.0 之间经历了相当大的重写,然后在 3.x 和 4.0 之间又重写了一次。本书侧重于 4.0 版本。

Node:一种新型 Web 服务器

从某种意义上说,Node 与其他流行的 Web 服务器(如 Microsoft 的 Internet Information Services(IIS)或 Apache)有很多共同点。不过更有趣的是它们的区别,所以让我们从这里开始。

与 Express 类似,Node 对于 Web 服务器的处理非常简化。与花费多年来掌握的 IIS 或 Apache 不同,Node 的设置和配置都很容易。这并不是说在生产环境中调优 Node 服务器以获得最佳性能是一件微不足道的事情;只是配置选项更简单,更直接。

Node 和更传统的 Web 服务器之间的另一个重要区别是 Node 是单线程的。乍一看,这似乎是向后退。事实证明,这是一个天才的举措。单线程极大地简化了编写 Web 应用程序的业务,并且如果你需要多线程应用程序的性能,你可以简单地启动更多 Node 实例,从而有效地获得多线程的性能优势。敏锐的读者可能会觉得这听起来像是虚张声势。毕竟,通过服务器并行性(与应用程序并行性相对),多线程并不是简单地将复杂性移动,而是重新分配了复杂性。也许是这样,但根据我的经验,它已经将复杂性移动到了恰到好处的位置。此外,随着云计算和将服务器视为通用商品的日益流行,这种方法变得更加合理。IIS 和 Apache 确实很强大,它们被设计用来从今天强大的硬件中挤取最后一滴性能。然而,这是有代价的:它们需要相当多的专业知识来设置和调优以达到这种性能。

在应用程序编写方式上,Node 应用与 PHP 或 Ruby 应用更为相似,而不是.NET 或 Java 应用。虽然 Node 使用的 JavaScript 引擎(Google 的 V8)将 JavaScript 编译为本机机器码(类似于 C 或 C++),但它是透明的,所以从用户的角度来看,它表现得像一个纯解释语言。没有单独的编译步骤可以减少维护和部署的麻烦:你只需要更新一个 JavaScript 文件,你的更改就会自动生效。

另一个 Node 应用的引人注目的好处是 Node 非常独立于平台。它并非第一个或唯一的平台无关服务器技术,但平台独立实际上更像是一个连续的谱而不是一个二进制命题。例如,你可以通过 Mono 在 Linux 服务器上运行.NET 应用,但由于文档不完善和系统不兼容性,这是一项艰难的任务。同样地,你可以在 Windows 服务器上运行 PHP 应用,但通常不像在 Linux 机器上那样容易设置。另一方面,Node 在所有主要操作系统(Windows、macOS 和 Linux)上都很容易设置,并支持简单的协作。在网站设计团队中,PC 和 Mac 的混合使用非常普遍。某些平台,比如.NET,给前端开发人员和设计师带来了挑战,而他们通常使用 Mac,这对协作和效率产生了巨大影响。能够在几分钟甚至几秒钟内在任何操作系统上启动一个功能齐全的服务器的想法实现了梦想。

Node 生态系统

当然,Node 位于整个堆栈的核心。它是使 JavaScript 在服务器上运行的软件,与浏览器解耦,从而允许使用 JavaScript 编写的框架(如 Express)。另一个重要组件是数据库,我们将在第十三章中更深入地讨论它。除了最简单的 Web 应用程序之外,几乎所有的 Web 应用程序都需要数据库,而有些数据库比其他数据库更适合 Node 生态系统。

毫不奇怪,针对所有主要关系数据库(如 MySQL、MariaDB、PostgreSQL、Oracle、SQL Server)都提供了数据库接口;忽视这些已经建立起来的巨头是愚蠢的。然而,Node 开发的出现使数据库存储采用了一种新的方法:所谓的 NoSQL 数据库。将某物定义为其“不是”并不总是有帮助,因此我们会补充说,这些 NoSQL 数据库更恰当地称为“文档数据库”或“键/值对数据库”。它们提供了一种概念上更简单的数据存储方法。虽然有很多种,但 MongoDB 是其中的佼佼者,也是本书中我们将使用的 NoSQL 数据库。

由于构建一个功能性网站依赖于多种技术组件,因此衍生出了一些缩写词来描述网站所依赖的“技术堆栈”。例如,Linux、Apache、MySQL 和 PHP 的组合被称为LAMP堆栈。MongoDB 工程师瓦列里·卡尔波夫创造了缩写MEAN:Mongo、Express、Angular 和 Node。虽然这确实引人注目,但它有其局限性:数据库和应用程序框架的选择如此之多,以至于“MEAN”无法捕捉到生态系统的多样性(它还忽略了我认为很重要的一个组件:渲染引擎)。

创造一个包容性缩写词是一个有趣的练习。当然,不可或缺的组件是 Node。虽然还有其他服务器端 JavaScript 容器,但 Node 正逐渐成为主导。Express 也不是唯一的 Web 应用程序框架,尽管它在与 Node 的竞争中占据了重要地位。通常对 Web 应用程序开发至关重要的另外两个组件是数据库服务器和渲染引擎(如 Handlebars 这样的模板引擎或者像 React 这样的 SPA 框架)。对于这最后两个组件来说,没有明确的佼佼者,这就是我认为局限性是一种错误。

将所有这些技术联系在一起的是 JavaScript,为了包容性,我将使用“JavaScript 堆栈”这个术语。对于本书的目的而言,这意味着 Node、Express 和 MongoDB(在第十三章中还有一个关于关系数据库的示例)。

许可证

在开发 Node 应用程序时,你可能会发现自己需要比以往更多地关注许可证问题(我确实有这种感觉)。Node 生态系统的一大优点是提供给你的大量包。然而,每个包都有其自己的许可证,更糟糕的是,每个包可能依赖于其他包,这意味着理解你编写的应用程序各部分的许可证可能会有些棘手。

不过,还有一些好消息。Node 包中最流行的许可证之一是 MIT 许可证,非常宽松,允许你几乎可以做任何你想做的事情,包括在闭源软件中使用该包。但是,你不应该假设你使用的每个包都是 MIT 许可的。

提示

在 npm 中有几个可用的包会尝试查找你项目中每个依赖项的许可证。在 npm 中搜索 nlflicense-report

虽然 MIT 是你最常遇到的许可证,但你可能也会看到以下许可证:

GNU 通用公共许可证(GPL)

GPL 是一种流行的开源许可证,精心设计用于保持软件的自由。这意味着如果你在项目中使用 GPL 许可的代码,你的项目必须使用 GPL 许可。自然地,这意味着你的项目不能是闭源的。

Apache 2.0

此许可证与 MIT 类似,允许你为你的项目选择不同的许可证,包括闭源许可证。然而,你必须包含使用 Apache 2.0 许可证组件的通知。

伯克利软件分发许可证(BSD)

类似于 Apache,该许可证允许你在项目中使用任何你希望的许可证,只要包含 BSD 许可证组件的通知。

注意

有时软件会以双重许可(以两种不同的许可证许可)发布。这样做的一个常见原因是允许软件在 GPL 项目和更宽松许可的项目中使用。(对于一个组件要用于 GPL 软件,该组件必须是 GPL 许可的。)这是我在自己的项目中经常采用的许可方案:使用 GPL 和 MIT 双重许可。

最后,如果你发现自己正在编写自己的包,你应该成为一个好公民,并为你的包选择一个许可证,并正确地记录它。对于开发者来说,没有什么比使用某人的包并不得不深入源代码查找许可证或者更糟糕的是发现它根本没有许可证更令人沮丧的了。

结论

希望本章节能让你更加深入地了解 Express 是什么以及它如何融入更大的 Node 和 JavaScript 生态系统中,同时也更加清晰地了解服务端和客户端 Web 应用程序之间的关系。

如果你仍然对 Express 究竟是什么感到困惑,不用担心:有时候,开始使用它以理解它的本质要容易得多,这本书将帮助你开始使用 Express 构建 Web 应用程序。然而,在我们开始使用 Express 之前,我们将在下一章节中对 Node 进行介绍,这是理解 Express 如何工作的重要背景信息。

^(1) 经常被称为即时(JIT)编译。

第二章:使用 Node 入门

如果你对 Node 没有任何经验,本章适合你。理解 Express 及其用途需要对 Node 有基本的了解。如果你已经有使用 Node 构建 web 应用程序的经验,可以跳过本章。在本章中,我们将使用 Node 构建一个非常简单的 web 服务器;在下一章中,我们将看到如何使用 Express 完成同样的事情。

获取 Node

在你的系统上安装 Node 无比简单。Node 团队确保安装过程在所有主要平台上都简单明了。

前往Node 主页。点击大绿色按钮,上面有一个版本号,后面跟着“LTS(推荐大多数用户使用)”。LTS 代表长期支持,比当前版本更加稳定,包含了更多的功能和性能改进。

对于 Windows 和 macOS,将下载一个安装程序来引导你完成安装过程。对于 Linux 用户,如果你使用软件包管理器,可能会更快上手。

注意

如果你是 Linux 用户,并且想使用软件包管理器,请确保按照前述网页上的说明操作。如果你不添加适当的软件包存储库,许多 Linux 发行版会安装一个非常旧的 Node 版本。

你还可以下载一个独立安装程序,如果你需要将 Node 分发给你的组织,这将非常有帮助。

使用终端

我是一个无悔的终端(也称为控制台命令提示符)的力量和生产力的粉丝。本书中的所有示例都假定你正在使用终端。如果你不熟悉终端,我强烈建议你花一些时间熟悉你选择的终端。本书中的许多实用程序都有对应的图形界面,所以如果你坚决不使用终端,你也有选择,但你将不得不找到适合自己的方法。

如果你使用 macOS 或 Linux,你有多种老牌 shell(终端命令解释器)可供选择。目前最流行的是 bash,不过 zsh 也有它的支持者。我偏向使用 bash 的主要原因(除了长期熟悉)是它的普及性。无论坐在哪台基于 Unix 的计算机前,99% 的情况下,默认 shell 都会是 bash。

如果你是 Windows 用户,情况就不那么美好了。微软从来都不太关心提供愉快的终端体验,所以你需要做更多的工作。Git 贴心地包含了一个“Git bash” shell,提供类 Unix 的终端体验(虽然只有一小部分通常可用的 Unix 命令行工具,但已经足够实用)。虽然 Git bash 提供了一个简化的 bash shell,但它仍然使用内置的 Windows 控制台应用程序,这会让人感到沮丧(即使是简单的功能如调整控制台窗口大小、选择文本、剪切和粘贴也是不直观且笨拙的)。因此,我建议安装更复杂的终端,比如ConsoleZConEmu。对于 Windows 高级用户,尤其是.NET 开发者或者专业的 Windows 系统或网络管理员,还有另一种选择:微软自家的 PowerShell。PowerShell 名副其实:有人用它做出了惊人的事情,一个熟练的 PowerShell 用户可以媲美 Unix 命令行专家。然而,如果你在 macOS/Linux 和 Windows 之间切换,我仍建议坚持使用 Git bash 以保持一致性。

如果你使用的是 Windows 10 或更新版本,现在可以直接在 Windows 上安装 Ubuntu Linux 了!这不是双系统或虚拟化,而是微软开源团队的杰出工作,将 Linux 体验带到了 Windows。你可以通过Microsoft 应用商店安装 Ubuntu 在 Windows 上。

Windows 用户的最后一个选择是虚拟化。在现代计算机的强大架构下,虚拟机(VM)的性能几乎与实际机器无异。我使用 Oracle 免费的VirtualBox效果非常好。

最后,无论你使用什么系统,都有出色的基于云的开发环境,比如Cloud9(现在是 AWS 的产品)。Cloud9 会快速搭建一个新的 Node 开发环境,让你可以快速开始 Node 的开发。

一旦你选择了一个让你满意的 shell,我建议你花一些时间了解基础知识。互联网上有许多优秀的教程(Bash 指南是一个很好的起点),通过现在学习一点点,你可以避免以后的很多麻烦。至少,你应该知道如何浏览目录;复制、移动和删除文件;以及如何退出命令行程序(通常是 Ctrl-C)。如果你想成为一个终端忍者,我鼓励你学习如何在文件中搜索文本,搜索文件和目录,将命令链接在一起(传统的“Unix 哲学”),以及重定向输出。

注意

在许多类 Unix 系统上,Ctrl-S 有着特殊的含义:它会“冻结”终端(曾经用于快速暂停输出)。由于这是“保存”的常见快捷键,人们很容易在不经意间按下它,这会导致大多数人陷入困惑的情况(这种情况对我来说比我愿意承认的更频繁发生)。要解除终端的冻结状态,只需按下 Ctrl-Q。因此,如果你曾经对终端突然冻结感到困惑,请尝试按下 Ctrl-Q 看看是否解决了问题。

编辑器

对于程序员而言,很少有什么话题像选择编辑器那样引发激烈的辩论,而且理由充分:编辑器是你的主要工具。我选择的编辑器是 vi(或具有 vi 模式的编辑器)。^(1) vi 并不适合所有人(当我告诉同事他们可以像我一样轻松做他们正在做的事情时,他们经常瞪大眼睛),但是找到一个强大的编辑器并学会使用它将显著提高你的生产力,也可以说,增加你的愉悦感。我特别喜欢 vi 的一个原因(虽然并非最重要的原因之一)是,像 bash 一样,它是无处不在的。如果你有 Unix 系统的访问权限,vi 就在那里等着你。大多数流行的编辑器都有“vi 模式”,允许你使用 vi 的键盘命令。一旦你习惯了它,很难想象使用其他任何编辑器。vi 起初是一条艰难的道路,但回报是值得的。

如果像我一样,你认为熟悉任何地方都可以用的编辑器很有价值,你的另一个选择是 Emacs。我和 Emacs 从来没有完全融洽过(通常你要么是 Emacs 用户,要么是 vi 用户),但我绝对尊重 Emacs 提供的强大和灵活性。如果 vi 的模态编辑方法不适合你,我建议你尝试一下 Emacs。

尽管掌握控制台编辑器(如 vi 或 Emacs)可能非常方便,你可能仍然希望使用一个更现代的编辑器。一个流行的选择是 Visual Studio Code(不要与不带“Code”的 Visual Studio 混淆)。我可以全心推荐 Visual Studio Code;它是一个设计精良、快速高效的编辑器,非常适合 Node 和 JavaScript 的开发。另一个流行的选择是 Atom,它在 JavaScript 社区中也很受欢迎。这两款编辑器都可以在 Windows、macOS 和 Linux 上免费使用(两者都有 vi 模式!)。

现在我们有了一个编辑代码的好工具,让我们把注意力转向 npm,它将帮助我们获取其他人编写的包,以便利用庞大而活跃的 JavaScript 社区。

npm

npm 是 Node 包的普遍包管理器(也是我们将获取并安装 Express 的方式)。在 PHP、GNU、WINE 等的讽刺传统中,npm 不是一个首字母缩略词(这也是为什么它没有大写字母);相反,它是“npm is not an acronym”的递归缩写。

广义上说,包管理器的两个主要责任是安装包和管理依赖关系。npm 是一个快速、能力强大且无痛的包管理器,我认为它在很大程度上促进了 Node 生态系统的快速增长和多样性。

注意

有一个名为 Yarn 的流行竞争包管理器,它使用与 npm 相同的包数据库;我们将在第十六章中使用 Yarn。

当您安装 Node 时,npm 也会随之安装,因此如果您按照前面列出的步骤操作,您已经拥有它了。那么让我们开始工作吧!

您将在 npm 中主要使用的命令(不足为奇地)是install。例如,要安装 nodemon(一种流行的实用程序,在您更改源代码时自动重新启动 Node 程序),您需要发出以下命令(在控制台上):

npm install -g nodemon

-g标志告诉 npm 在系统上全局安装包,这意味着它在全局范围内可用。当我们讨论package.json文件时,这种区别会变得更加清晰。目前的经验法则是,JavaScript 实用程序(如 nodemon)通常会全局安装,而专用于您的 Web 应用程序或项目的包则不会。

注意

与像 Python 这样的语言不同,它在从 2.0 到 3.0 进行了重大语言更改,需要一种能够轻松切换不同环境的方式——Node 平台还很新,因此您可能总是应该运行最新版本的 Node。但是,如果您确实需要支持多个 Node 版本,请查看nvmn,这些工具允许您切换环境。您可以通过输入node --version来查看计算机上安装的 Node 版本。

使用 Node 创建一个简单的 Web 服务器

如果您以前构建过静态 HTML 网站或者有 PHP 或 ASP 背景,您可能已经习惯于 Web 服务器(例如 Apache 或 IIS)提供您的静态文件,以便浏览器可以通过网络查看它们。例如,如果您创建了文件about.html并将其放在正确的目录中,然后您可以导航至http://localhost/about.html。根据您的 Web 服务器配置,甚至可以省略.html,但 URL 和文件名之间的关系是明确的:Web 服务器只需知道文件在计算机上的位置并将其提供给浏览器。

注意

localhost,顾名思义,指的是您所在的计算机。这是 IPv4 回环地址 127.0.0.1 或 IPv6 回环地址::1 的常见别名。您经常会看到使用 127.0.0.1,但本书中我将使用localhost。如果您正在使用远程计算机(例如使用 SSH),请记住,浏览到localhost不会连接到该计算机。

Node 提供了一个与传统 Web 服务器不同的范式:你编写的应用程序就是Web 服务器。Node 只是为你构建 Web 服务器提供了框架。

“但我不想写一个 Web 服务器”,你可能会说!这是一种自然的反应:你想写一个应用程序,而不是一个 Web 服务器。然而,Node 使编写这个 Web 服务器的业务变得非常简单(甚至只需几行代码),而你在应用程序中获得的控制力远远超过了这一点。

所以让我们开始吧。你已经安装了 Node,与终端交朋友,现在你已经准备好了。

Hello World

我一直觉得很不幸的是,经典的入门编程示例是毫无灵感的消息“Hello world”。然而,到了这一点,违背这样一个沉重的传统似乎是不可取的,所以我们将从这里开始,然后转向更有趣的东西。

在你喜爱的编辑器中创建一个名为helloworld.js(在伴随存储库中为ch02/00-helloworld.js)的文件:

const http = require('http')
const port = process.env.PORT || 3000

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('Hello world!')
})

server.listen(port, () => console.log(`server started on port ${port}; ` +
  'press Ctrl-C to terminate....'))
注意

根据你学习 JavaScript 的时间和地点,这个例子中缺少分号可能会让你感到困惑。我曾是一个坚定的分号支持者,随着我进行更多 React 开发,我不情愿地停止了使用它们。过了一段时间,我的眼前的迷雾消散了,我不禁想知道我曾经为什么对分号如此着迷!现在我坚定地支持“不加分号”团队,本书中的示例也将反映这一点。这是个人选择,如果你愿意,可以继续使用分号。

确保你在与helloworld.js相同的目录中,并输入node helloworld.js。然后打开浏览器,导航到http://localhost:3000voilà!你的第一个 Web 服务器就完成了。这个特定的服务器不提供 HTML,而是仅向你的浏览器显示纯文本消息'Hello world!'。如果你愿意,你可以尝试发送 HTML:只需将text/plain更改为text/html,并将'Hello world!'更改为包含有效 HTML 的字符串。我没有演示这一点,因为我尽量避免在 JavaScript 中编写 HTML,原因将在第七章中详细讨论。

事件驱动编程

Node 的核心理念是事件驱动编程。对于你作为程序员来说,这意味着你必须了解可用的事件以及如何对其做出响应。许多人通过实现用户界面来介绍事件驱动编程:用户点击某些东西,你处理点击事件。这是一个很好的隐喻,因为人们理解程序员无法控制用户何时或是否会点击某些东西,所以事件驱动编程实际上非常直观。在服务器上做出事件响应的概念跨越可能会更难一些,但原则是相同的。

在前面的代码示例中,事件是隐含的:正在处理的事件是 HTTP 请求。http.createServer方法接受一个函数作为参数;每次发出 HTTP 请求时都会调用该函数。我们的简单程序只是将内容类型设置为纯文本,并发送字符串“Hello world!”

一旦您开始以事件驱动的编程方式思考,您就会在各处看到事件。其中一个事件是当用户从应用程序的一个页面或区域导航到另一个页面时。您的应用程序如何响应该导航事件被称为路由

路由

路由是指为客户端提供其请求的内容的机制。对于基于 Web 的客户端/服务器应用程序,客户端在 URL 中指定所需的内容;具体来说,路径和查询字符串(URL 的各个部分将在第六章中进行更详细的讨论)。

注意

服务器路由传统上依赖于路径和查询字符串,但还有其他可用的信息:头部、域名、IP 地址等。这使得服务器可以考虑用户的大致物理位置或用户的首选语言等因素。

让我们扩展我们的“Hello world!”示例,做一些更有趣的事情。让我们提供一个非常简单的网站,包括一个主页、一个关于页面和一个未找到页面。目前,我们将继续使用我们之前的示例,只是提供纯文本而不是 HTML(在配套仓库中的ch02/01-helloworld.js):

const http = require('http')
const port = process.env.PORT || 3000

const server = http.createServer((req,res) => {
  // normalize url by removing querystring, optional
  // trailing slash, and making it lowercase
  const path = req.url.replace(/\/?(?:\?.*)?$/, '').toLowerCase()
  switch(path) {
    case '':
      res.writeHead(200, { 'Content-Type': 'text/plain' })
      res.end('Homepage')
      break
    case '/about':
      res.writeHead(200, { 'Content-Type': 'text/plain' })
      res.end('About')
      break
    default:
      res.writeHead(404, { 'Content-Type': 'text/plain' })
      res.end('Not Found')
      break
  } })

server.listen(port, () => console.log(`server started on port ${port}; ` +
  'press Ctrl-C to terminate....'))

如果您运行这个示例,您会发现现在可以浏览到主页(http://localhost:3000)和关于页面(http://localhost:3000/about)。任何查询字符串都将被忽略(因此http://localhost:3000/?foo=bar将提供主页),任何其他 URL(http://localhost:3000/foo)将提供未找到页面。

提供静态资源

现在我们已经实现了一些简单的路由功能,让我们来提供一些真实的 HTML 和一个 logo 图像。这些被称为静态资源,因为它们通常不会改变(例如,与股票行情相反:每次重新加载页面时,股票价格可能会发生变化)。

提示

使用 Node 提供静态资源适合开发和小型项目,但对于较大的项目,您可能希望使用代理服务器如 NGINX 或 CDN 来提供静态资源。有关更多信息,请参见第十七章。

如果你曾经使用过 Apache 或者 IIS,你可能习惯于只需创建一个 HTML 文件,导航到它,并自动将其传递给浏览器。Node 不是这样工作的:我们需要打开文件、读取文件,然后将其内容发送给浏览器。因此,让我们在项目中创建一个名为 public 的目录(为什么不叫 static,在下一章将会显而易见)。在该目录中,我们将创建 home.htmlabout.html404.html,一个名为 img 的子目录,并且一个名为 img/logo.png 的图片。我会留给你来完成;如果你在阅读本书,你可能知道如何编写 HTML 文件和查找图片。在你的 HTML 文件中,像这样引用 logo:<img src="/img/logo.png" alt="logo">

现在修改 helloworld.js(伴随的代码库中是 ch02/02-helloworld.js):

const http = require('http')
const fs = require('fs')
const port = process.env.PORT || 3000

function serveStaticFile(res, path, contentType, responseCode = 200) {
  fs.readFile(__dirname + path, (err, data) => {
    if(err) {
      res.writeHead(500, { 'Content-Type': 'text/plain' })
      return res.end('500 - Internal Error')
    }
    res.writeHead(responseCode, { 'Content-Type': contentType })
    res.end(data)
  })
}

const server = http.createServer((req,res) => {
  // normalize url by removing querystring, optional trailing slash, and
  // making lowercase
  const path = req.url.replace(/\/?(?:\?.*)?$/, '').toLowerCase()
  switch(path) {
    case '':
      serveStaticFile(res, '/public/home.html', 'text/html')
      break
    case '/about':
      serveStaticFile(res, '/public/about.html', 'text/html')
      break
    case '/img/logo.png':
      serveStaticFile(res, '/public/img/logo.png', 'image/png')
      break
    default:
      serveStaticFile(res, '/public/404.html', 'text/html', 404)
      break
  }
})

server.listen(port, () => console.log(`server started on port ${port}; ` +
  'press Ctrl-C to terminate....'))
注意

在这个例子中,我们对路由的设定并不是很有创意。如果你访问 http://localhost:3000/about,将会服务于 public/about.html 文件。你可以将路由更改为任何你想要的内容,并且更改文件为任何你想要的内容。例如,如果你每周有一个不同的关于页面,你可以有 public/about_mon.htmlpublic/about_tue.html 等文件,并在你的路由中提供逻辑来在用户访问 http://localhost:3000/about 时服务于适当的页面。

注意我们创建了一个辅助函数,serveStaticFile,这个函数承担了大部分工作。fs.readFile 是一个用于读取文件的异步方法。有一个同步版本的函数,fs.readFileSync,但越早开始思考异步操作,效果越好。fs.readFile 函数使用了一种叫做 回调 的模式。你需要提供一个称为 回调函数 的函数,当工作完成时,该回调函数就会被调用(可以说是“回调”)。在这个案例中,fs.readFile 读取了指定文件的内容,并在文件读取完毕时执行回调函数;如果文件不存在或者在读取文件时存在权限问题,err 变量就会被设置,函数返回 HTTP 状态码 500,表示服务器错误。如果文件成功读取,文件会以指定的响应代码和内容类型发送到客户端。响应代码将在 第六章 中详细讨论。

Tip

__dirname 将会解析为当前执行脚本所在的目录。所以如果你的脚本位于 /home/sites/app.js__dirname 将会解析为 /home/sites。尽可能使用这个方便的全局变量是个好主意。如果不这样做,当你从不同的目录运行应用时,可能会导致难以诊断的错误。

进入 Express

到目前为止,Node 可能对你来说并不那么令人印象深刻。我们基本上复制了 Apache 或 IIS 为您自动完成的工作,但现在你已经了解了 Node 如何做事情以及你有多少控制权。我们并没有做出什么特别引人注目的事情,但你可以看到我们可以将其作为一个起点,做更复杂的事情。如果我们继续这条路,编写越来越复杂的 Node 应用程序,最终可能会得到类似 Express 的东西……

幸运的是,我们不必从头开始:Express 已经存在,它可以帮助你避免实现许多耗时的基础设施。现在我们已经积累了一些 Node 经验,我们准备好开始学习 Express 了。

^(1) 当今,vi基本上等同于vim(vi 改进版)。在大多数系统上,vi被别名为vim,但我通常会输入vim来确保我在使用vim

第三章:使用 Express 节省时间

在第二章中,您学习了如何仅使用 Node 创建一个简单的 Web 服务器。在本章中,我们将使用 Express 重新创建该服务器。这将为本书其余内容提供一个起点,并介绍 Express 的基础知识。

脚手架

脚手架并非新概念,但许多人(包括我自己)是通过 Ruby 了解到这一概念的。其思想很简单:大多数项目都需要一定量的所谓样板代码,谁又愿意在每次开始新项目时重新创建这些代码呢?一个简单的方法是创建一个项目的粗略框架,每次需要新项目时,只需复制这个框架或模板即可。

Ruby on Rails 将这一概念推进了一步,提供了一个可以自动生成脚手架的程序。这种方法的优势在于,它可以生成比仅从模板集合中选择更复杂的框架。

Express 借鉴了 Ruby on Rails 的一些方法,并提供了一个实用程序来生成脚手架,以启动您的 Express 项目。

虽然 Express 脚手架实用程序很有用,但我认为从零开始学习如何设置 Express 也很有价值。除了学到更多知识外,您还可以控制所安装的内容以及项目的结构。此外,Express 脚手架实用程序面向的是服务器端 HTML 生成,对于 API 和单页面应用程序则不太相关。

虽然我们不会使用脚手架实用程序,但我鼓励您在完成本书后查看它:到那时,您将掌握评估它生成的脚手架是否对您有用的一切知识。更多信息,请参阅express-generator文档

Meadowlark Travel 网站

在本书的整个过程中,我们将使用一个运行示例:Meadowlark Travel 的虚构网站,该公司为访问俄勒冈州的人们提供服务。如果您更感兴趣创建 API,不必担心:Meadowlark Travel 网站将除了提供功能性网站外还提供 API。

初始步骤

首先创建一个新目录:这将是您项目的根目录。在本书中,无论我们提到项目目录、应用目录还是项目根目录,我们都指的是这个目录。

提示

您可能希望将 Web 应用文件与通常陪伴项目的所有其他文件分开,例如会议记录、文档等。因此,我建议将项目根目录设置为项目目录的子目录。例如,对于 Meadowlark Travel 网站,我可能会将项目保存在/projects/meadowlark*,并将项目根目录设置为*/projects/meadowlark/site

npm 在一个名为package.json的文件中管理项目依赖项以及有关项目的元数据。创建此文件的最简单方法是运行npm init:它会询问您一系列问题并生成一个package.json文件,以便您开始(对于“入口点”问题,请使用meadowlark.js作为项目名称)。

小贴士

每次运行 npm 时,可能会收到有关缺少描述或存储库字段的警告。可以安全地忽略这些警告,但如果您想消除它们,可以编辑package.json文件并为 npm 正在投诉的字段提供值。有关此文件中字段的更多信息,请参阅npm package.json文档

第一步将是安装 Express。运行以下 npm 命令:

npm install express

运行npm install将安装所命名的包到node_modules目录,并更新package.json文件。由于node_modules目录可以随时用 npm 重新生成,我们不会将其保存在仓库中。为了确保不会意外将其添加到仓库中,我们创建一个名为.gitignore的文件:

# ignore packages installed by npm
node_modules

# put any other files you don't want to check in here, such as .DS_Store
# (OSX), *.bak, etc.

现在创建一个名为meadowlark.js的文件。这将是我们项目的入口点。在整本书中,我们将简称此文件为应用文件(在伴随的仓库中是ch03/00-meadowlark.js):

const express = require('express')

const app = express()

const port = process.env.PORT || 3000

// custom 404 page
app.use((req, res) => {
  res.type('text/plain')
  res.status(404)
  res.send('404 - Not Found')
})

// custom 500 page
app.use((err, req, res, next) => {
  console.error(err.message)
  res.type('text/plain')
  res.status(500)
  res.send('500 - Server Error')
})

app.listen(port, () => console.log(
  `Express started on http://localhost:${port}; ` +
  `press Ctrl-C to terminate.`))
小贴士

许多教程以及 Express 脚手架生成器都鼓励您将主文件命名为app.js(有时是index.jsserver.js)。除非您使用要求主应用程序文件具有特定名称的托管服务或部署系统,否则我认为没有必要这样做,我更喜欢以项目命名主文件。任何曾经看着一堆标签都写着“index.html”的人立即会看出这种做法的智慧。npm init将默认为index.js;如果您使用不同的名称作为应用程序文件,请确保更新package.json中的main属性。

现在您拥有了一个最小化的 Express 服务器。您可以启动服务器(node meadowlark.js)并导航至http://localhost:3000。结果可能会让人失望:您尚未为 Express 提供任何路由,因此它将只是给出一个通用的 404 消息,表明页面不存在。

注意

注意我们如何选择我们希望应用程序运行的端口:const port = process.env.PORT || 3000。这允许我们在启动服务器之前通过设置环境变量来覆盖端口。如果在运行此示例时您的应用程序未在端口 3000 上运行,请检查您的PORT环境变量是否已设置。

让我们为主页和关于页面添加一些路由。在 404 处理程序之前,我们将添加两个新路由(在伴随的仓库中是ch03/01-meadowlark.js):

app.get('/', (req, res) => {
  res.type('text/plain')
  res.send('Meadowlark Travel');
})

app.get('/about', (req, res) => {
  res.type('text/plain')
  res.send('About Meadowlark Travel')
})

// custom 404 page
app.use((req, res) => {
  res.type('text/plain')
  res.status(404)
  res.send('404 - Not Found')
})

app.get 是我们添加路由的方法。在 Express 文档中,你会看到 app.METHOD。这并不意味着真的有一个叫做 METHOD 的方法;它只是一个占位符,代表(小写的)HTTP 动词(getpost 是最常见的)。这个方法接受两个参数:路径和一个函数。

路径 定义了路由。请注意,app.METHOD 为您完成了大部分工作:默认情况下,它不区分大小写或尾随斜杠,并且在匹配时不考虑查询字符串。因此,About 页面的路由将适用于 /about/About/about//about?foo=bar/about/?foo=bar 等。

当路由匹配时,您提供的函数将被调用。传递给该函数的参数是请求和响应对象,我们将在第六章更详细地了解它们。现在,我们只是返回带有状态码 200 的纯文本(Express 默认状态码为 200,您不必显式指定它)。

提示

我强烈建议安装一个浏览器插件,它可以显示 HTTP 请求的状态码以及任何重定向,这将有助于您快速发现代码中的重定向问题或不正确的状态码,这些问题通常被忽视。对于 Chrome,Ayima 的 Redirect Path 插件效果非常好。在大多数浏览器中,您可以在开发者工具的网络部分看到状态码。

我们不再使用 Node 的底层方法 res.end,而是切换到 Express 提供的 res.send。我们还用 res.setres.status 替换了 Node 的 res.writeHead。Express 还为我们提供了一个便捷方法 res.type,它设置 Content-Type 头部。虽然仍然可以使用 res.writeHeadres.end,但这并不是必需或建议的。

请注意,我们的自定义 404 和 500 页面必须稍有不同处理。我们不再使用 app.get,而是使用 app.useapp.use 是 Express 添加中间件的方法。我们将在第十章更深入地讨论中间件,但现在你可以将其视为一个捕捉未被路由匹配的所有请求的处理程序。这带来了一个重要的观点:在 Express 中,添加路由和中间件的顺序非常重要。如果我们将 404 处理程序放在路由之上,主页和 About 页面将无法正常工作;相反,这些 URL 将导致 404 错误。目前,我们的路由相当简单,但也支持通配符,这可能会导致顺序问题。例如,如果我们想为 About 添加子页面,如 /about/contact/about/directions,以下方法将无法按预期工作:

app.get('/about*', (req,res) => {
  // send content....
}) app.get('/about/contact', (req,res) => {
  // send content....
}) app.get('/about/directions', (req,res) => {
  // send content....
})

在这个例子中,/about/contact/about/directions 处理程序永远不会匹配,因为第一个处理程序在其路径中使用了通配符:/about*

Express 可以通过它们的回调函数接受的参数数量来区分 404 和 500 处理程序。错误路由将在第十章和第十二章中深入讨论。

现在你可以重新启动服务器,看看是否有一个正常运行的首页和关于页面。

到目前为止,我们还没有做过任何不用 Express 就能轻松完成的事情,但是 Express 已经为我们提供了一些功能,这些功能并不是立即显而易见的。还记得上一章中我们如何规范化req.url以确定所请求的资源吗?我们不得不手动去掉查询字符串和尾随斜杠,并转换为小写。现在 Express 的路由器已经自动处理了这些细节。虽然现在看起来可能不是很大的事情,但这只是 Express 路由器能够实现的功能的冰山一角。

视图和布局

如果你熟悉“模型-视图-控制器”范式,那么视图的概念对你来说不会陌生。本质上,视图就是向用户传递的内容。对于网站来说,通常意味着 HTML,尽管你也可以传递 PNG、PDF 或任何客户端可以呈现的内容。在我们的目的中,我们将视图视为 HTML。

视图与静态资源(如图像或 CSS 文件)不同之处在于视图不一定是静态的:HTML 可以动态生成,以为每个请求提供定制页面。

Express 支持许多不同的视图引擎,提供不同程度的抽象化。Express 对名为Pug的视图引擎有些偏爱(这一点并不奇怪,因为它也是 TJ Holowaychuk 的作品)。Pug 采用的方法很简洁:你写的东西完全不像 HTML,这无疑减少了很多打字(不再有尖括号或闭合标签)。然后 Pug 引擎会将其转换为 HTML。

注意

Pug 最初被称为 Jade,在第 2 版发布时更名是因为商标问题。

Pug 很吸引人,但这种抽象程度是有代价的。如果你是前端开发人员,即使你实际上是在用 Pug 编写视图,你也必须深入理解 HTML,并且要理解得很透彻。我认识的大多数前端开发人员对他们的主要标记语言被抽象化这个想法感到不舒服。因此,我建议使用另一种不那么抽象的模板框架Handlebars

Handlebars(基于流行的语言无关模板语言 Mustache)不会试图为你抽象 HTML:你需要使用特殊的标记编写 HTML,这些标记允许 Handlebars 注入内容。

注意

在这本书最初发布后的几年里,React 风靡全球……这使得 HTML 对于前端开发者来说被抽象化了!透过这个视角看,我预测前端开发者不希望 HTML 被抽象化的说法并没有经受住时间的考验。然而,JSX(大多数 React 开发者使用的 JavaScript 语言扩展)几乎与编写 HTML 相同,所以我并不完全错。

为了提供 Handlebars 支持,我们将使用 Eric Ferraiuolo 的express-handlebars包。在项目目录中,执行以下操作:

npm install express-handlebars

然后在meadowlark.js中修改前几行(ch03/02-meadowlark.js在配套存储库中):

const express = require('express')
const expressHandlebars = require('express-handlebars')

const app = express()

// configure Handlebars view engine
app.engine('handlebars', expressHandlebars({
  defaultLayout: 'main',
}))
app.set('view engine', 'handlebars')

这将创建一个视图引擎,并配置 Express 默认使用它。现在创建一个名为views的目录,其中有一个名为layouts的子目录。如果你是一名经验丰富的 Web 开发者,你可能已经对layouts(有时称为master pages)的概念感到非常熟悉。当你构建一个网站时,有一部分 HTML 在每个页面上是相同的或非常接近相同的。重复编写所有这些重复的代码不仅变得乏味,而且可能造成维护的噩梦:如果你想在每个页面上做一些改动,你必须改动所有的文件。布局解放了你的手,为你的网站上的所有页面提供了一个共同的框架。

所以让我们为我们的网站创建一个模板。创建一个名为views/layouts/main.handlebars的文件:

<!doctype html>
<html>
  <head>
    <title>Meadowlark Travel</title>
  </head>
  <body>
    {{{body}}}
  </body>
</html>

你可能之前没有见过的唯一一件事情是这个:{{{body}}}。这个表达式将被每个视图的 HTML 替换。当我们创建 Handlebars 实例时,请注意我们指定了默认布局(defaultLayout: \'main')。这意味着除非你另行指定,否则这将是任何视图使用的布局。

现在让我们为我们的主页创建视图页面,views/home.handlebars

<h1>Welcome to Meadowlark Travel</h1>

然后是我们的关于页面,views/about.handlebars

<h1>About Meadowlark Travel</h1>

然后是我们的找不到页面,views/404.handlebars

<h1>404 - Not Found</h1>

最后我们的服务器错误页面,views/500.handlebars

<h1>500 - Server Error</h1>
提示

你可能希望你的编辑器将.handlebars.hbs(Handlebars 文件的另一种常见扩展名)与 HTML 关联起来,以启用语法高亮和其他编辑器功能。对于 vim,你可以在~/.vimrc文件中添加一行au BufNewFile,BufRead *.handlebars set filetype=html。对于其他编辑器,请查阅其文档。

现在我们有了一些视图设置,我们必须用这些视图替换旧的路由(ch03/02-meadowlark.js在配套存储库中):

app.get('/', (req, res) => res.render('home'))

app.get('/about', (req, res) => res.render('about'))

// custom 404 page
app.use((req, res) => {
  res.status(404)
  res.render('404')
})

// custom 500 page
app.use((err, req, res, next) => {
  console.error(err.message)
  res.status(500)
  res.render('500')
})

请注意,我们不再需要指定内容类型或状态码:视图引擎将默认返回text/html的内容类型和状态码 200。在提供我们自定义 404 页面的通用处理程序以及 500 处理程序中,我们必须显式设置状态码。

如果你启动服务器并查看主页或关于页面,你会发现视图已经渲染完毕。如果你查看源代码,你会看到来自views/layouts/main.handlebars的样板 HTML 代码。

尽管每次访问主页时,你都会得到相同的 HTML,但这些路由被视为动态内容,因为我们每次调用路由时可以做出不同的决策(这在本书的后面部分会有很多例子)。然而,从来不变的内容,换句话说,静态内容,是常见且重要的,所以我们接下来会考虑静态内容。

静态文件和视图

Express 依赖于中间件来处理静态文件和视图。中间件是一个将在第十章中更详细介绍的概念。现在,只需知道中间件提供了模块化,使得处理请求更加容易即可。

static中间件允许你指定一个或多个目录,其中包含静态资源,这些资源会简单地提供给客户端,不需要任何特殊处理。这是你放置图片、CSS 文件和客户端 JavaScript 文件等内容的地方。

在你的项目目录中,创建一个名为public的子目录(我们称之为public,因为该目录中的任何内容都会毫不保留地提供给客户端)。然后,在声明任何路由之前,你将添加static中间件(ch03/02-meadowlark.js在配套的代码库中):

app.use(express.static(__dirname + '/public'))

static中间件的作用与创建每个要提供的静态文件的路由具有相同的效果,它渲染一个文件并将其返回给客户端。因此,让我们在public目录内创建一个img子目录,并把我们的logo.png文件放在其中。

现在我们可以简单地引用/img/logo.png(注意,我们不指定public;该目录对客户端是不可见的),而static中间件会适当地提供该文件,设置内容类型。现在让我们修改我们的布局,以便我们的 logo 出现在每一页上:

<body>
  <header>
    <img src="/img/logo.png" alt="Meadowlark Travel Logo">
  </header>
  {{{body}}}
</body>
注意

记住,中间件按顺序处理,通常首先声明或至少非常早地声明的静态中间件将覆盖其他路由。例如,如果你在public目录中放置一个index.html文件(试试看!),你会发现该文件的内容会被提供,而不是你配置的路由!因此,如果你得到混乱的结果,请检查你的静态文件,并确保没有意外匹配到路由。

视图中的动态内容

视图并不仅仅是传递静态 HTML 的复杂方式(尽管它们当然也可以这样做)。视图真正的威力在于它们可以包含动态信息。

假设在关于页面上,我们想要提供一个“虚拟幸运饼干”。在我们的meadowlark.js文件中,我们定义了一个幸运饼干的数组:

const fortunes = [
  "Conquer your fears or they will conquer you.",
  "Rivers need springs.",
  "Do not fear what you don't know.",
  "You will have a pleasant surprise.",
  "Whenever possible, keep it simple.",
]

修改视图(/views/about.handlebars)以显示一则幸运饼干:

<h1>About Meadowlark Travel</h1>
{{#if fortune}}
  <p>Your fortune for the day:</p>
  <blockquote>{{fortune}}</blockquote>
{{/if}}

现在修改路由/about,以提供随机的幸运饼干:

app.get('/about', (req, res) => {
  const randomFortune = fortunes[Math.floor(Math.random()*fortunes.length)]
  res.render('about', { fortune: randomFortune })
})

现在,如果你重新启动服务器并加载 /about 页面,你会看到一个随机的幸运语,并且每次重新加载页面都会得到一个新的幸运语。模板化非常有用,我们将在第七章中深入讲解。

结论

我们用 Express 创建了一个基本的网站。尽管它很简单,但已经包含了我们建立完整功能网站所需的所有基础。在下一章中,我们将开始准备添加更高级功能,严谨地做好每一个细节。

第四章:整理

在前两章中,我们只是在试验:可以说是试探水温。在我们继续进行更复杂的功能之前,我们将进行一些日常管理,并在我们的工作中养成一些良好的习惯。

在本章中,我们将认真开始我们的 Meadowlark Travel 项目。不过,在我们开始构建网站本身之前,我们将确保我们拥有制作高质量产品所需的工具。

提示

本书中的示例不一定是你必须遵循的示例。如果你渴望构建自己的网站,你可以按照本书示例的框架进行修改,以便在本书结束时,你可以拥有一个完成的网站!

文件和目录结构

构建应用程序的结构引发了很多宗教性的辩论,没有一种正确的方法。然而,有一些常见的惯例是有帮助的。

试图限制项目根目录中文件的数量是很典型的。通常你会找到配置文件(如package.json)、一个README.md文件和一堆目录。大多数源代码放在一个通常称为src的目录下。出于简洁起见,我们在本书中不会使用这种约定(令人惊讶的是,即使 Express 脚手架应用程序也不会这样做)。对于真实项目,如果你把源代码放在项目根目录中,你可能最终会发现项目根目录变得混乱,你会想把这些文件收集到类似src的目录下。

我曾提到我更喜欢将我的主应用程序文件(有时称为入口点)命名为项目本身的名称(meadowlark.js),而不是像index.jsapp.jsserver.js这样的通用名称。

如何构建应用程序的结构主要由你决定,我建议在README.md文件(或其链接的自述文件)中提供一个结构路线图。

至少,我建议你的项目根目录中始终有以下两个文件:package.jsonREADME.md。其余的取决于你的想象力。

最佳实践

“最佳实践”这个词汇现在被广泛使用,意味着你应该“做正确的事情”,而不是走捷径(我们稍后会具体讨论这是什么意思)。毫无疑问,你听过工程行话:“快速”、“便宜”和“好”,你可以选择其中两个。关于这种模型一直让我困扰的是,它没有考虑到正确执行事务的累积价值。第一次正确执行某事可能要比快速而肮脏地完成花费五倍的时间。但第二次只需三倍的时间。当你做了十几次正确的事情时,你几乎能和快速而肮脏的方式做得一样快。

我以前有一个击剑教练,他总是提醒我们,练习并不能造就完美;练习造就永久性。也就是说,如果你反复做某事,最终它会变得自动化,成为惯例。这种说法没错,但它并未评价你所练习事物的质量。如果你练习了不良习惯,那不良习惯也会变成惯例。相反,你应该遵循完美练习造就完美的原则。基于这种精神,我鼓励你在本书中的其余部分,像是在制作真实的网站一样,就好像你的声誉和报酬取决于结果的质量一样。利用本书不仅学习新技能,还要练习养成良好的习惯。

我们将专注于版本控制和质量保证的实践。在本章中,我们将讨论版本控制,下一章我们将讨论质量保证。

版本控制

希望我无需说服你版本控制的价值(如果需要的话,可能要写一整本书)。广义上来说,版本控制提供以下好处:

文档

能够回顾项目历史,了解所做决策的过程和组件开发顺序,可以成为有价值的文档。拥有项目的技术历史记录非常有用。

归因

如果你在团队中工作,归因可能非常重要。当你在代码中发现不透明或可疑的内容时,知道谁做出了这些改变可以节省你很多时间。也许与这些改变相关的注释足以回答你的问题,如果不行,你也知道应该找谁询问。

实验

一个好的版本控制系统能够促进实验。你可以随意尝试新事物,不必担心会影响项目的稳定性。如果实验成功,你可以将其融入项目中;如果失败,你可以放弃它。

多年前,我转向了分布式版本控制系统(DVCS)。我把选择范围缩小到 Git 和 Mercurial,最终选择了 Git,因为它普及广泛且灵活。两者都是优秀且免费的版本控制系统,我推荐你使用其中之一。在本书中,我们将使用 Git,但你也可以选择 Mercurial(或者其他版本控制系统)。

如果你对 Git 不熟悉,我推荐 Jon Loeliger 的优秀著作Version Control with Git(O’Reilly)。此外,GitHub 有一个很好的Git 学习资源列表

如何使用 Git 本书

首先确保你已经安装了 Git。输入git --version检查版本号。如果没有显示版本号,你需要安装 Git。参见Git 文档获取安装指南。

本书中跟随示例的两种方法。一种是自己打出示例并跟随 Git 命令。另一种是克隆我用于所有示例的伴随存储库,并检出每个示例的相关文件。有些人通过打出示例学得更好,而有些人则更喜欢只是看并运行更改,而无需全部输入。

如果您通过自己做来进行跟随

我们已经为我们的项目建立了一个非常粗略的框架:一些视图,一个布局,一个标志,一个主应用程序文件和一个package.json文件。让我们继续创建一个 Git 存储库并添加所有这些文件。

首先,我们进入项目目录并在那里初始化一个 Git 存储库:

git init

现在,在我们添加所有文件之前,我们将创建一个.gitignore文件,以帮助防止我们意外添加不想添加的内容。在项目目录中创建一个名为.gitignore的文本文件,您可以在其中添加任何希望 Git 默认忽略的文件或目录(每行一个)。它还支持通配符。例如,如果您的编辑器创建带有波浪线结尾的备份文件(如meadowlark.js~),您可以在.gitignore文件中放置*~。如果您使用 Mac,您会想在那里放置.DS_Store。您还需要把node_modules放在那里(稍后将讨论原因)。因此,目前文件可能如下所示:

node_modules
*~
.DS_Store
注意

.gitignore文件中的条目也适用于子目录。因此,如果您在项目根目录的.gitignore中放置了*~,则所有这样的备份文件都将被忽略,即使它们位于子目录中。

现在,我们可以添加所有现有文件。在 Git 中有很多方法可以做到这一点。我通常喜欢git add -A,这是所有变体中最全面的。如果您是 Git 的新手,我建议您逐个添加文件(例如git add meadowlark.js),如果只想提交一两个文件,或者使用git add -A添加所有更改(包括您可能删除的任何文件)。由于我们想添加所有已完成的工作,我们将使用以下操作:

git add -A
提示

Git 的新手通常会对git add命令感到困惑;它添加的是更改,而不是文件。因此,如果您修改了meadowlark.js,然后输入git add meadowlark.js,您实际上是在添加您所做的更改。

Git 有一个“暂存区”,当您运行git add时,更改会进入其中。因此,我们添加的更改实际上还没有提交,但它们已经准备就绪。要提交更改,请使用git commit

git commit -m "Initial commit."

-m "Initial commit."允许您编写与此提交相关联的消息。Git 甚至不会让您提交没有消息的提交,这是有道理的。始终努力编写有意义的提交消息;它们应简要但简洁地描述您所做的工作。

如果您通过使用官方存储库进行跟随

要获取本书的官方存储库,请运行git clone

git clone https://github.com/EthanRBrown/web-development-with-node-and-express-2e

本仓库为每个章节设有包含代码示例的目录。例如,本章的源代码可以在ch04目录中找到。每章的代码示例通常按顺序编号以便参考。在整个仓库中,我还大量添加了README.md文件,其中包含有关示例的额外说明。

注意

在本书的第一版中,我采用了一种不同的方法处理仓库,以线性历史记录的形式开发一个越来越复杂的项目。虽然这种方法愉快地反映了现实世界中项目可能发展的方式,但它给我和我的读者带来了很多困扰。随着 npm 包的更改,代码示例也会改变,除非重新编写整个仓库的历史记录,否则没有好的方法更新仓库或记录文本中的更改。虽然每个目录对应一个章节的方法更为人为,但它允许文本与仓库更紧密地同步,并且还便于社区的贡献。

当本书更新和改进时,仓库也会进行相应更新。当仓库更新时,我会添加一个版本标签,这样你就可以查看与当前阅读版本对应的仓库版本。目前仓库的版本是 2.0.0。在这里,我大致遵循语义化版本原则(本章后面将详细介绍);PATCH 增量(最后一个数字)代表了不应影响你跟进书中内容的小改动。也就是说,如果仓库版本是 2.0.15,它仍应与本书版本对应。然而,如果 MINOR 增量(第二个数字)不同(2.1.0),这意味着伴随仓库中的内容可能已偏离你当前阅读的内容,你可能需要查看一个以 2.0 开头的标签。

伴随仓库广泛使用README.md文件来为代码示例添加额外解释。

注意

如果你想进行任何实验,请记住你所检出的标签会让你进入 Git 所谓的“分离 HEAD”状态。虽然你可以自由编辑任何文件,但在未创建分支的情况下提交所做的任何更改是不安全的。因此,如果你想基于标签创建一个实验分支,只需执行一个命令:git checkout -b experiment(其中experiment是你的分支名称;你可以随意取名)。然后你可以在该分支上安全地进行编辑和提交任意数量的更改。

npm 包

你的项目依赖的 npm 包存放在一个名为 node_modules 的目录中。(遗憾的是这被称为 node_modules 而不是 npm_packages,因为 Node 模块是一个相关但不同的概念。)随意探索这个目录以满足你的好奇心或调试你的程序,但你不应该修改这个目录中的任何代码。除了这是一种不良实践之外,你所有的更改很容易就会被 npm 撤销。

如果你需要修改项目依赖的某个包,正确的做法是创建该包的分支(fork)。如果你确实采取了这种方法,并且认为你的改进对其他人有用,那么恭喜你:你现在参与了一个开源项目!你可以提交你的改动,如果它们符合项目标准,它们将被包含在官方包中。贡献到现有包和创建定制版本超出了本书的范围,但在那里有一个充满活力的开发者社区,可以在你想要贡献到现有包时提供帮助。

package.json 文件的两个主要目的是描述你的项目和列出它的依赖项。现在就去查看你的 package.json 文件吧。你应该看到类似以下内容(确切的版本号可能会不同,因为这些包经常更新):

{
  "dependencies": {
    "express": "⁴.16.4",
    "express-handlebars": "³.0.0"
  }
}

现在,我们的 package.json 文件只包含关于依赖项的信息。在包版本前面的插入符(^)表示以指定版本号开头的任何版本——直到下一个主要版本号——都可以工作。例如,这个 package.json 表明任何以 4.0.0 开头的 Express 版本都可以工作,所以 4.0.1 和 4.9.9 都可以工作,但 3.4.7 和 5.0.0 则不行。这是使用 npm install 时的默认版本特定性,并且通常是一个相当安全的选择。采用这种方法的后果是,如果你想升级到一个更新的版本,你将不得不编辑文件以指定新版本。总的来说,这是件好事,因为它可以防止依赖关系的变化在你不知情的情况下破坏你的项目。npm 中的版本号由一个名为 semver(语义化版本)的组件解析。如果你想了解更多关于 npm 中版本控制的信息,请参考Tamas Piros 的这篇文章

注意

语义化版本规范(Semantic Versioning Specification)规定,使用语义化版本的软件必须声明“公共 API”。我一直觉得这个措辞很令人困惑;他们真正的意思是“有人必须关心与你的软件的接口”。如果你从最广泛的意义上考虑,这实际上可以被理解为任何事物。因此,不要过分纠结于规范的这一部分;重要的细节在于格式。

由于 package.json 文件列出了所有依赖项,node_modules 目录实际上是一个衍生的产物。也就是说,如果你删除了它,只需运行 npm install 即可重新创建这个目录并放置所有必要的依赖项,从而使项目重新工作起来。因此,我建议将 node_modules 放入你的 .gitignore 文件中,不要将其纳入源代码控制。然而,有些人认为你的存储库应包含运行项目所需的一切,并希望将 node_modules 保留在源代码控制中。我发现这在存储库中只是“噪音”,我更倾向于省略它。

从 npm 的版本 5 开始,还会创建一个额外的文件 package-lock.json。虽然 package.json 可以在指定依赖版本时是“宽松”的(使用 ^~ 版本修饰符),package-lock.json 记录了安装的 确切 版本,如果你需要在项目中重新创建确切的依赖版本,这将非常有帮助。我建议你将这个文件纳入源代码控制,并且不要手动修改它。请参阅 package-lock.json 文档 了解更多信息。

项目元数据

package.json 文件的另一个目的是存储项目元数据,例如项目名称、作者、许可信息等等。如果你使用 npm init 来最初创建你的 package.json 文件,它将为你填充文件所需的字段,并且你随时可以更新它们。如果你打算在 npm 或 GitHub 上公开你的项目,这些元数据就变得至关重要。如果你想了解更多关于 package.json 文件中字段的信息,请参阅 package.json 文档。另一个重要的元数据是 README.md 文件。这个文件可以方便地描述网站的整体架构,以及新加入项目的人可能需要的任何关键信息。它是一种名为 Markdown 的文本基础的 wiki 格式。请参阅 Markdown 文档 了解更多信息。

Node 模块

正如前面提到的,Node 模块和 npm 包是相关但不同的概念。Node 模块,顾名思义,提供了一种模块化和封装的机制。npm 包 提供了一种标准化的方案来存储、版本化和引用项目(不仅限于模块)。例如,我们在主应用程序文件中将 Express 本身作为一个模块导入:

const express = require('express')

require是一个用于导入模块的 Node 函数。默认情况下,Node 在node_modules目录中查找模块(因此,在node_modules目录中有一个express目录应该并不奇怪)。然而,Node 也提供了创建自己模块的机制(你绝不应该在node_modules目录中创建自己的模块)。除了通过包管理器安装到node_modules中的模块外,Node 还提供了 30 多个“核心模块”,如fshttpospath。要查看完整列表,请参阅这个启发性的 Stack Overflow 问题,并参考官方 Node 文档

让我们看看如何将我们在前一章节中实现的幸运饼干功能模块化。

首先让我们创建一个目录来存储我们的模块。你可以随意命名,但是lib(代表“库”)是一个常见选择。在那个文件夹中,创建一个名为fortune.js的文件(在伴随的存储库中是ch04/lib/fortune.js):

const fortuneCookies = [
  "Conquer your fears or they will conquer you.",
  "Rivers need springs.",
  "Do not fear what you don't know.",
  "You will have a pleasant surprise.",
  "Whenever possible, keep it simple.",
]

exports.getFortune = () => {
  const idx = Math.floor(Math.random()*fortuneCookies.length)
  return fortuneCookies[idx]
}

这里需要注意的重要事项是使用全局变量exports。如果你希望某些内容在模块外可见,你必须将其添加到exports中。在这个例子中,函数getFortune将在此模块外部可用,但我们的数组fortuneCookies完全隐藏。这是件好事:封装允许更少出错和更不易破碎的代码。

注意

有几种方法可以从一个模块中导出功能。我们将在本书中涵盖不同的方法,并在第二十二章中总结它们。

现在在meadowlark.js中,我们可以移除fortuneCookies数组(虽然留下它也没有什么问题;它不会与在lib/fortune.js中定义的同名数组产生任何冲突)。在文件的顶部指定导入是传统的(但不是必需的),因此在meadowlark.js文件的顶部添加以下行(在伴随的存储库中是ch04/meadowlark.js):

const fortune = require('./lib/fortune')

注意我们的模块名前缀为./。这告诉 Node 不要在node_modules目录中查找模块;如果我们省略了这个前缀,这将会失败。

现在在关于页面的路由中,我们可以利用来自我们模块的getFortune方法:

app.get('/about', (req, res) => {
  res.render('about', { fortune: fortune.getFortune() } )
})

如果你一直在跟进,让我们提交这些更改:

git add -A git commit -m "Moved 'fortune cookie' into module."

你会发现模块是一种强大且易于封装功能的方式,这将提高项目的整体设计和可维护性,并且使得测试更容易。查看官方 Node 模块文档获取更多信息。

注意

Node 模块有时被称为CommonJS(CJS)模块,这是因为 Node 受到了一个旧规范的启发。JavaScript 语言正在采用一种官方的打包机制,称为 ECMAScript 模块(ESM)。如果你已经在 React 或其他前端语言中编写 JavaScript,你可能已经熟悉了 ESM,它使用importexport(而不是exportsmodule.exportsrequire)。欲了解更多信息,请参阅 Axel Rauschmayer 博士的博文“ECMAScript 6 modules: the final syntax”

结论

现在我们对 Git、npm 和模块有了更多的信息,我们准备讨论如何通过在编码中采用良好的质量保证(QA)实践来生产更好的产品。

我鼓励你牢记本章节的以下几点教训:

  • 版本控制使软件开发过程更安全、更可预测,我鼓励你即使在小项目中也要使用它;这将养成良好的习惯!

  • 模块化是管理软件复杂性的重要技术。除了通过 npm 提供的丰富模块生态系统外,你还可以将自己的代码打包成模块,以更好地组织项目。

  • Node 模块(也称为 CJS)使用与 ECMAScript 模块(ESM)不同的语法,当你在前端和后端代码之间切换时可能需要转换这两种语法。熟悉这两种语法是个不错的主意。

第五章:质量保证

质量保证是一个容易让开发人员心生畏惧的短语——这是不幸的。毕竟,您不是想要制造高质量的软件吗?当然是。因此,关键不在于最终目标,而是于政策的处理。我发现在 Web 开发中存在两种常见情况:

大型或财力雄厚的组织

通常会有一个质量保证部门,不幸的是,质量保证和开发之间会出现对抗性关系。这是可能发生的最糟糕的事情。虽然两个部门都在为同一个目标而战,但质量保证通常将成功定义为发现更多的缺陷,而开发则将成功定义为生成更少的缺陷,这就成为冲突和竞争的基础。

小型组织和预算有限的组织

通常情况下,没有质量保证部门;开发人员预计要同时兼顾建立质量保证和开发软件的双重角色。这并不是想象或利益冲突的荒谬伸展。然而,质量保证是与开发非常不同的学科,吸引不同的个性和才能。这不是一个不可能的情况,当然也有开发人员具备质量保证思维方式,但是在截止日期逼近时,通常是质量保证受到短缺待遇,对项目的影响不利。

对于大多数真实世界的努力来说,需要多种技能,并且越来越难成为所有这些技能的专家。然而,对于您不直接负责的领域有一定的能力将使您对团队更有价值,并使团队运作更有效。开发人员获取质量保证技能是一个很好的例子:这两个领域紧密相连,跨学科的理解极为重要。

还有一个常见的做法是将传统上由质量保证完成的活动转移到开发部门,使开发人员负责质量保证。在这种范式中,专门从事质量保证的软件工程师几乎像开发人员的顾问,帮助他们将质量保证融入其开发工作流程中。无论质量保证角色是分开还是整合的,理解质量保证对开发人员都是有益的。

本书不是针对质量保证专业人士的,而是面向开发人员的。因此,我的目标不是让您成为质量保证专家,而是为您提供一些在该领域获得经验的机会。如果您的组织有专门的质量保证人员,那么您将更容易与他们沟通和合作。如果没有,这将为您建立项目全面质量保证计划提供一个起点。

在本章中,您将学到以下内容:

  • 质量基础和有效习惯

  • 测试类型(单元测试和集成测试)

  • 如何使用 Jest 编写单元测试

  • 如何使用 Puppeteer 编写集成测试

  • 如何配置 ESLint 以帮助预防常见错误

  • 连续集成是什么以及如何开始学习它

QA 计划

开发基本上是一个创造性的过程:设想某事然后将其变成现实。相比之下,QA 更多地生活在验证和秩序的领域。因此,QA 的一个重要部分只是知道需要做什么确保它被完成。因此,QA 是一种非常适合使用清单、流程和文档的学科。我甚至可以说,QA 的主要活动不是软件本身的测试,而是创建全面且可重复的 QA 计划

我建议为每个项目创建一个 QA 计划,无论其大小如何(是的,即使是您的周末“娱乐”项目也是如此!)。QA 计划不必很大或很复杂;您可以将其放在文本文件、文字处理文档或 wiki 中。QA 计划的目标是记录您将采取的所有步骤,以确保您的产品按预期运行。

无论采取什么形式,QA 计划都是一个活的文档。您将根据以下内容更新它:

  • 新功能

  • 现有功能的变化

  • 已删除的功能

  • 测试技术或技术的变化

  • QA 计划未发现的缺陷

最后一点特别值得一提。无论您的质量保证(QA)多么健全,缺陷都会发生。当它们发生时,您应该问自己:“我们如何才能预防这种情况?”当您回答了这个问题,就可以相应地修改您的 QA 计划,以防止将来发生这种类型的缺陷。

到目前为止,您可能已经感受到 QA 所需的不小的努力,并且您可能合理地想知道您想要投入多少努力。

QA:是否值得?

QA 有时可能会非常昂贵——非常昂贵。那么这是否值得呢?这是一个复杂的公式,涉及复杂的输入。大多数组织采用某种“投资回报”模型。如果您花钱,您必须期望能够获得至少同等数量的回报(最好是更多)。然而,与 QA 相关的关系可能会变得混淆。例如,一个经过充分建立和良好评价的产品,可能比一个新的和未知的项目能够更长时间地容忍质量问题。显然,没有人制造低质量的产品,但技术上的压力很大。时间至关重要,有时候,与其在几个月后推出完美的产品,不如在市场上推出一个不完美的产品更好。

在 Web 开发中,质量可以分解为四个维度:

覆盖率

覆盖率指的是您产品的市场渗透率:访问您网站或使用您服务的人数。覆盖率与盈利能力直接相关:访问网站的人越多,购买产品或服务的人也越多。从开发的角度来看,搜索引擎优化(SEO)将对覆盖率产生最大影响,这也是为什么我们将在我们的 QA 计划中包含 SEO。

功能性

一旦人们访问您的网站或使用您的服务,您网站功能的质量将对用户保留率产生重大影响;一个按照承诺运行的网站比那些没有按照承诺运行的网站更有可能促使用户再次访问。功能性为测试自动化提供了最大的机会。

可用性

当涉及功能正确性时,可用性评估人机交互(HCI)。根本问题是:“功能以对目标受众有用的方式提供了吗?”这通常转化为“使用起来容易吗?”尽管追求易用性往往会与灵活性或强大性相对立;对程序员来说容易的东西可能与非技术消费者认为容易的东西不同。换句话说,评估可用性时必须考虑目标受众。由于可用性评估的一个基本输入是用户,因此通常无法自动化。然而,用户测试应包括在您的 QA 计划中。

美学

美学是四个维度中最主观的,因此对开发的影响最小。虽然在涉及到您网站美学时,开发上的担忧很少,但您的 QA 计划应包括对网站美学的定期审查。向代表性样本观众展示您的网站,并了解它是否显得过时或未引发预期的反应。请记住,美学是时间敏感的(审美标准随时间变化)和特定于受众的(一个受众喜欢的东西可能完全无趣于另一个受众)。

虽然您的 QA 计划应涵盖所有四个维度,但功能性测试和 SEO 可以在开发过程中进行自动化测试,因此本章将重点放在这些方面。

逻辑与呈现

广义上说,在您的网站中,有两个“领域”:逻辑(通常称为业务逻辑,我因其对商业活动的偏见而避免使用此术语)和呈现。您可以将您网站的逻辑想象成一种纯粹的智力领域。例如,在我们的 Meadowlark Travel 场景中,可能存在一个规则,即客户在租用滑板车之前必须拥有有效的驾驶证。这是一个简单的数据规则:每次滑板车预订,用户都需要有效的驾驶证。而呈现则是分离的。也许它只是订单页面最后表单上的一个复选框,或者客户必须提供一个由 Meadowlark Travel 验证的有效驾驶证号码。这是一个重要的区别,因为在逻辑领域中应尽可能清晰简单,而呈现可以根据需要复杂或简单。呈现还受可用性和美学问题的影响,而业务领域则不受此影响。

在可能的情况下,您应该寻求清晰地区分您的逻辑和表现。有很多方法可以做到这一点,在本书中,我们将专注于将逻辑封装在 JavaScript 模块中。另一方面,展示将是 HTML、CSS、多媒体、JavaScript 以及像 React、Vue 或 Angular 这样的前端框架的组合。

测试类型

在本书中,我将考虑的测试类型分为两大类:单元测试和集成测试(我认为系统测试是集成测试的一种类型)。单元测试非常精细,测试单个组件以确保其正常工作,而集成测试测试多个组件之间甚至整个系统的交互。

一般来说,单元测试在逻辑测试中更加有用和适当。集成测试在两个领域都很有用。

QA 技术概述

在本书中,我们将使用以下技术和软件来进行彻底的测试:

单元测试

单元测试覆盖应用程序中最小的功能单元,通常是单个函数。它们几乎总是由开发人员编写,而不是 QA(尽管 QA 应该有能力评估单元测试的质量和覆盖范围)。在本书中,我们将使用 Jest 进行单元测试。

单元测试

集成测试覆盖应用程序中更大的功能单元,通常涉及多个部分(函数、模块、子系统等)。由于我们正在构建 Web 应用程序,“终极”集成测试是在浏览器中呈现应用程序,操作该浏览器,并验证应用程序是否按预期行为。这些测试通常更复杂,设置和维护起来更加困难,由于本书的重点不是 QA,我们只有一个简单的示例,使用 Puppeteer 和 Jest。

Linting

Linting 并不是为了发现错误,而是潜在的错误。Linting 的一般概念是识别可能表示潜在错误的区域,或者脆弱的结构可能导致将来出现错误。我们将使用 ESLint 进行 Linting。

让我们从 Jest 开始,我们的测试框架(将运行单元测试和集成测试)。

安装和配置 Jest

在决定在本书中使用哪个测试框架方面,我有些挣扎。Jest 最初是作为测试 React 应用程序的框架而诞生的(现在仍然是这样做的明显选择),但 Jest 并不专门针对 React,它是一个优秀的通用测试框架。当然,Jest 并不是唯一的选择:MochaJasmineAvaTape也是优秀的选择。

最后,我选择了 Jest,因为我觉得它提供了最好的整体体验(这一观点得到了 Jest 在2018 JavaScript 现状调查中的优秀评分支持)。也就是说,这里提到的测试框架有很多相似之处,因此您应该能够将学到的知识应用到您喜欢的测试框架中。

要安装 Jest,请从您的项目根目录运行以下命令:

npm install --save-dev jest

(请注意,我们在这里使用了--save-dev;这告诉 npm 这是一个开发依赖项,并且不需要它来使应用程序本身正常运行;它将在package.json文件的devDependencies部分而不是dependencies部分中列出。)

在我们继续之前,我们需要一种方法来运行 Jest(它将运行项目中的任何测试)。通常的做法是在package.json中添加一个脚本。编辑package.json(在伴随的仓库中的ch05/package.json),并修改scripts属性(如果不存在则添加):

  "scripts": {
    "test": "jest"
  },

现在,您只需键入以下内容即可运行项目中的所有测试:

npm test

如果您现在尝试,可能会收到一个错误,提示没有配置任何测试……因为我们还没有添加任何测试。所以让我们编写一些单元测试!

注意

通常情况下,如果您在package.json文件中添加了一个脚本,您可以通过npm run来运行它。例如,如果您添加了一个名为foo的脚本,您可以键入npm run foo来运行它。然而,test脚本非常常见,因此 npm 知道如果您简单地键入npm test,它就会运行它。

单元测试

现在我们将把注意力转向单元测试。由于单元测试的重点是隔离单个函数或组件,因此我们首先需要学习模拟,这是实现隔离的重要技术之一。

模拟

您经常面临的挑战之一是如何编写“可测试”的代码。一般来说,试图做太多或假设很多依赖关系的代码比专注于少量或没有依赖关系的代码更难测试。

每当您有一个依赖项时,您就需要对其进行mock(模拟)以进行有效的测试。例如,我们的主要依赖项是 Express,它已经经过了彻底的测试,因此我们不需要也不想测试 Express 本身,只需要测试我们如何使用它。我们能够确定我们是否正确使用 Express 的唯一方法就是模拟 Express 本身。

我们目前拥有的路由(主页、关于页面、404 页面和 500 页面)在测试时相当困难,因为它们假设对 Express 有三个依赖:它们假设我们有一个 Express 应用程序(所以我们可以有app.get),以及请求和响应对象。幸运的是,很容易消除对 Express 应用程序本身的依赖性(请求和响应对象则更难……稍后详细讨论)。幸运的是,我们并没有从响应对象中使用太多功能(我们仅使用render方法),因此很容易对其进行 mock,我们很快就会看到。

为了增强可测试性重构应用程序

实际上,在我们的应用程序中,我们并没有太多的代码需要测试。到目前为止,我们只添加了少数路由处理程序和 getFortune 函数。

为了使我们的应用程序更易于测试,我们将提取实际的路由处理程序到它们自己的库中。创建一个文件 lib/handlers.js(在配套仓库中是 ch05/lib/handlers.js):

const fortune = require('./fortune')

exports.home = (req, res) => res.render('home')

exports.about = (req, res) =>
  res.render('about', { fortune: fortune.getFortune() })

exports.notFound = (req, res) => res.render('404')

exports.serverError = (err, req, res, next) => res.render('500')

现在,我们可以重写我们的 meadowloark.js 应用程序文件来使用这些处理程序(在配套仓库中是 ch05/meadowlark.js):

// typically at the top of the file
const handlers = require('./lib/handlers')

app.get('/', handlers.home)

app.get('/about', handlers.about)

// custom 404 page
app.use(handlers.notFound)

// custom 500 page
app.use(handlers.serverError)

现在测试这些处理程序变得更容易了:它们只是接受请求和响应对象的函数,我们需要验证我们是否正确地使用了这些对象。

编写我们的第一个测试

有多种方法可以让 Jest 找到测试。最常见的两种方法是将测试放在名为 test 的子目录中(在 test 前后加上两个下划线),以及将文件命名为 .test.js 扩展名。我个人喜欢结合这两种技术,因为它们各自在我的脑海中都有用途。将测试放在 test 目录中可以防止我的测试混杂在源代码目录中(否则,你的源目录中将会看到一份 foo.test.js 对应每个 foo.js 文件),而使用 .test.js 扩展名则意味着,如果我在编辑器中查看一堆选项卡,我一眼就能看出哪些是测试,哪些是源代码。

所以让我们创建一个名为 lib/tests/handlers.test.js(在配套仓库中是 ch05/lib/tests/handlers.test.js)的文件:

const handlers = require('../handlers')

test('home page renders', () => {
  const req = {}
  const res = { render: jest.fn() }
  handlers.home(req, res)
  expect(res.render.mock.calls[0][0]).toBe('home')
})

如果你对测试还不太熟悉,这可能看起来有点奇怪,所以让我们一步步来分析。

首先,我们导入要测试的代码(在本例中是路由处理程序)。然后,每个测试都有一个描述;我们试图描述正在测试的内容。在这种情况下,我们要确保首页得到渲染。

要调用我们的渲染器,我们需要请求和响应对象。如果我们要模拟整个请求和响应对象,可能需要写上整整一周的代码,但幸运的是,我们实际上并不需要它们太多内容。我们知道,在这种情况下,我们根本不需要请求对象中的任何内容(所以我们只是使用一个空对象),而我们从响应对象中唯一需要的是一个渲染方法。注意我们如何构造渲染函数:我们只需调用一个名为 jest.fn() 的 Jest 方法。这将创建一个通用的模拟函数,用于跟踪它的调用方式。

最后,我们来到测试的重要部分:断言。我们已经费了很大的劲来调用我们正在测试的代码,但是我们如何断言它是否按照预期工作?在这种情况下,代码应该调用响应对象的render方法,并传递字符串home。Jest 的模拟函数会跟踪它被调用的所有次数,所以我们只需验证它被调用了一次(如果调用了两次可能会有问题),这就是第一个expect所做的事情,并且它被调用时home作为第一个参数传入(第一个数组索引指定调用,第二个数组索引指定参数)。

Tip

当您每次对代码进行更改时,不断重新运行测试可能会变得乏味。幸运的是,大多数测试框架都有一个“观察”模式,它会持续监视您的代码和测试的更改并自动重新运行它们。要在观察模式下运行您的测试,请键入npm test -- --watch(额外的双破折号是必需的,让 npm 知道将--watch参数传递给 Jest)。

请继续修改您的home处理程序,以渲染除主页视图之外的其他内容;您会注意到您的测试现在失败了,并且您捕捉到了一个错误!

现在,我们可以为其他路由添加测试:

test('about page renders with fortune', () => {
  const req = {}
  const res = { render: jest.fn() }
  handlers.about(req, res)
  expect(res.render.mock.calls.length).toBe(1)
  expect(res.render.mock.calls[0][0]).toBe('about')
  expect(res.render.mock.calls[0][1])
    .toEqual(expect.objectContaining({
      fortune: expect.stringMatching(/\W/),
    }))
})

test('404 handler renders', () => {
  const req = {}
  const res = { render: jest.fn() }
  handlers.notFound(req, res)
  expect(res.render.mock.calls.length).toBe(1)
  expect(res.render.mock.calls[0][0]).toBe('404')
})

test('500 handler renders', () => {
  const err = new Error('some error')
  const req = {}
  const res = { render: jest.fn() }
  const next = jest.fn()
  handlers.serverError(err, req, res, next)
  expect(res.render.mock.calls.length).toBe(1)
  expect(res.render.mock.calls[0][0]).toBe('500')
})

注意“about”和服务器错误测试中的一些额外功能。 “about”渲染函数被调用时会带有一个幸运符,因此我们添加了一个期望,即它将获得一个包含至少一个字符的字符串作为幸运符。本书的范围不包括描述通过 Jest 及其expect方法可用的所有功能,但您可以在Jest 主页找到详尽的文档。请注意,服务器错误处理程序需要四个参数,而不是两个,因此我们必须提供额外的模拟。

测试维护

您可能意识到测试并不是一劳永逸的事务。例如,如果出于合理的原因重命名我们的“home”视图,我们的测试会失败,然后我们不得不修复代码之外还要修复测试。

因此,团队花了很多精力来设定关于应该进行测试以及测试应该有多具体的现实期望。例如,我们不必检查“about”处理程序是否被带有幸运符调用过...这样做将节省我们免于不得不修复测试的麻烦,如果我们放弃了该功能。

此外,我不能为您提供有关测试代码深入程度的建议。我预计您对测试航空电子设备或医疗设备的代码的标准会与测试营销网站背后的代码有很大不同。

我可以为您提供一种回答“我的代码有多少被测试覆盖?”这个问题的方法,答案叫做代码覆盖率,我们接下来会讨论它。

代码覆盖率

代码覆盖率提供了对您的代码有多少被测试覆盖的量化答案,但像编程中的大多数主题一样,没有简单的答案。

Jest 提供了一些有用的自动化代码覆盖分析。要查看你的代码被测试了多少,运行以下命令:

npm test -- --coverage

如果你一直在跟进,你应该看到 lib 文件夹中一堆令人放心的绿色“100%”覆盖率数字。Jest 将报告语句(Stmts)、分支、函数(Funcs)和行的覆盖率百分比。

语句是指 JavaScript 语句,例如每个表达式、控制流语句等。请注意,你可以拥有 100% 的行覆盖率,但不一定有 100% 的语句覆盖率,因为你可以在 JavaScript 中将多个语句放在一行上。分支覆盖率涉及控制流语句,例如 if-else。如果你有一个 if-else 语句,而你的测试仅执行了 if 部分,那么这个语句的分支覆盖率为 50%。

你可能注意到 meadowlark.js 并不具备 100% 的覆盖率。这并不一定是问题;如果你看一下我们重构后的 meadowlark.js 文件,你会发现现在大部分内容只是配置……我们只是把各种东西粘合在一起。我们正在用相关的中间件配置 Express 并启动服务器。不仅这段代码很难进行有意义的测试,而且合理的论点是你不应该这样做,因为它只是组装经过充分测试的代码。

你甚至可以提出一个论点,迄今为止我们编写的测试并不特别有用;它们也只是验证我们是否正确配置了 Express。

再次地,我没有简单的答案。在一天结束时,你正在构建的应用类型、你的经验水平以及团队的规模和配置将对你有多深入地进行测试产生很大影响。我鼓励你在测试方面保守一点,比不足要多一些,但随着经验的增加,你会找到“恰到好处”的甜蜜点。

集成测试

在我们的应用中目前没有什么有趣的东西可以测试;我们只有几个页面,没有互动。因此,在编写集成测试之前,让我们添加一些可以测试的功能。为了保持简单,我们让这个功能是一个链接,可以让你从主页跳转到关于页面。事实上,用户看起来这似乎很简单,但这是一个真正的集成测试,因为它不仅测试了两个 Express 路由处理程序,还测试了 HTML 和 DOM 交互(用户点击链接和结果页面导航)。让我们在 views/home.handlebars 中添加一个链接:

<p>Questions?  Checkout out our
<a href="/about" data-test-id="about">About Us</a> page!</p>

你可能会想到data-test-id属性。为了进行测试,我们需要一种方法来识别链接,以便可以(虚拟)点击它。我们可以使用 CSS 类来实现这一点,但我更喜欢将类保留用于样式化,而使用数据属性进行自动化。我们还可以搜索关于我们的文本,但这将是一个脆弱且昂贵的 DOM 搜索。我们还可以根据href参数进行查询,这也是有道理的(但这样做会使得这个测试很难失败,这是我们出于教育目的希望的)。

我们可以启动我们的应用程序,并用我们笨拙的人类手来验证功能是否按预期工作,然后再进入更自动化的内容。

在我们开始安装 Puppeteer 并编写集成测试之前,我们需要修改我们的应用程序,使其可以作为模块被引用(目前它只能直接运行)。在 Node 中做到这一点的方法有点不透明:在meadowlark.js的底部,用以下内容替换对app.listen的调用:

if(require.main === module) {
  app.listen(port, () => {
    console.log( `Express started on http://localhost:${port}` +
      '; press Ctrl-C to terminate.' )
  })
} else {
  module.exports = app
}

我将跳过这个技术解释,因为它相当冗长,但如果你感兴趣,仔细阅读Node 的模块文档将会让你明白。重要的是要知道,如果你直接用 node 运行一个 JavaScript 文件,require.main将等于全局的module;否则,它是从另一个模块中导入的。

现在我们已经搞定了,可以安装 Puppeteer 了。Puppeteer 本质上是一个可控的、无头版本的 Chrome 浏览器。(无头意味着浏览器可以在不渲染 UI 的情况下运行。)要安装 Puppeteer:

npm install --save-dev puppeteer

我们还将安装一个小工具来找到一个空闲端口,这样我们的应用程序就不会因为无法在请求的端口上启动而产生大量的测试错误:

npm install --save-dev portfinder

现在我们可以编写一个执行以下操作的集成:

  1. 在一个未占用的端口上启动我们的应用程序服务器

  2. 启动一个无头 Chrome 浏览器并打开一个页面

  3. 导航到我们应用程序的主页

  4. 查找带有data-test-id="about"的链接并点击它

  5. 等待导航发生

  6. 验证我们是否在/about页面上

创建一个名为integration-tests(如果你愿意,也可以起其他名字)的目录,并在该目录中创建一个文件basic-navigation.test.js(在伴随的仓库中是ch05/integration-tests/basic-navigation.test.js):

const portfinder = require('portfinder')
const puppeteer = require('puppeteer')

const app = require('../meadowlark.js')

let server = null
let port = null

beforeEach(async () => {
  port = await portfinder.getPortPromise()
  server = app.listen(port)
})

afterEach(() => {
  server.close()
})

test('home page links to about page', async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  await page.goto(`http://localhost:${port}`)
  await Promise.all([
    page.waitForNavigation(),
    page.click('[data-test-id="about"]'),
  ])
  expect(page.url()).toBe(`http://localhost:${port}/about`)
  await browser.close()
})

我们正在使用 Jest 的beforeEachafterEach钩子在每个测试之前启动服务器,并在每个测试之后关闭它(现在我们只有一个测试,所以当我们添加更多测试时,这将变得更有意义)。我们也可以使用beforeAllafterAll,这样我们就不会为每个测试都启动和关闭服务器,这可能会加快测试速度,但代价是每个测试都不会有一个“干净”的环境。也就是说,如果你的某个测试对后续测试结果产生影响,你就引入了难以维护的依赖关系。

我们实际测试使用了 Puppeteer 的 API,它为我们提供了大量的 DOM 查询功能。请注意,这里几乎所有的操作都是异步的,我们大量使用 await 来使测试更易于阅读和编写(几乎所有 Puppeteer API 调用都返回一个 promise)。^(1) 我们将导航和点击包装在 Promise.all 调用中,以避免竞争条件,按照 Puppeteer 文档的建议。

Puppeteer API 中有比我在本书中能够涵盖的功能多得多。幸运的是,它有 优秀的文档

测试是确保产品质量的重要后备工具,但它并不是你手头唯一的工具。代码检查帮助你在第一时间防止常见错误。

代码检查

一个好的代码检查器就像有第二双眼睛一样:它会发现那些会轻易被我们的大脑忽略的问题。最初的 JavaScript 代码检查器是道格拉斯·克罗克福德的 JSLint。2011 年,安东·科瓦留夫分叉了 JSLint,JSHint 诞生了。科瓦留夫发现 JSLint 变得过于主观,他想创建一个更可定制、由社区开发的 JavaScript 代码检查器。在 JSHint 之后是尼古拉斯·扎卡斯的 ESLint,它已成为最受欢迎的选择(在 2017 JavaScript 现状调查 中遥遥领先)。除了普及性之外,ESLint 显然是最积极维护的代码检查器,我更喜欢它灵活的配置而不是 JSHint,并且这也是我推荐的。

ESLint 可以基于每个项目安装或全局安装。为了避免无意中破坏东西,我尽量避免全局安装(例如,如果我全局安装 ESLint 并经常更新它,旧项目可能由于破坏性更改而无法成功 lint,现在我必须额外工作来更新我的项目)。

要在你的项目中安装 ESLint:

npm install --save-dev eslint

ESLint 需要一个配置文件来告诉它应用哪些规则。从零开始做这件事将是一项耗时的任务,所幸 ESLint 提供了一个实用工具来为你创建。从你的项目根目录运行以下命令:

./node_modules/.bin/eslint --init
注意

如果我们全局安装了 ESLint,我们可以直接使用 eslint --init。笨拙的 ./node_modules/.bin 路径是必须的,以便直接运行本地安装的工具。我们很快会看到,如果我们将工具添加到 package.json 文件的 scripts 部分,我们就不必这样做,这对于我们经常做的事情是推荐的。但是,创建 ESLint 配置是我们每个项目只需要做一次的事情。

ESLint 会询问你一些问题。对于大多数问题,选择默认值是安全的,但有几个值得注意:

你的项目使用哪种类型的模块?

因为我们使用的是 Node(而不是将在浏览器中运行的代码),你会想要选择“CommonJS (require/exports)”。“你的项目中可能也有客户端 JavaScript,这种情况下可能需要一个单独的 lint 配置。最简单的方法是在同一个项目中有两个分开的项目,但在同一个项目中有多个 ESLint 配置也是可能的。请参阅 ESLint 文档 获取更多信息。

你的项目使用哪个框架?

除非你在那里看到 Express(在我写作时还没有),选择“None of these”。

你的代码在哪里运行?

选择 Node。

现在 ESLint 已经设置好了,我们需要一个便捷的方式来运行它。将以下内容添加到你的 package.jsonscripts 部分:

  "lint": "eslint meadowlark.js lib"

注意,我们必须明确告诉 ESLint 我们要 lint 哪些文件和目录。这是一个建议将所有源代码集中在一个目录(通常是 src)下的理由。

现在准备好,运行以下命令:

npm run lint

你可能会看到很多看起来不太好看的错误—通常当你第一次运行 ESLint 时会发生这种情况。然而,如果你一直在进行 Jest 测试,会有一些与 Jest 相关的误报错误,看起来像这样:

   3:1   error  'test' is not defined    no-undef
   5:25  error  'jest' is not defined    no-undef
   7:3   error  'expect' is not defined  no-undef
   8:3   error  'expect' is not defined  no-undef
  11:1   error  'test' is not defined    no-undef
  13:25  error  'jest' is not defined    no-undef
  15:3   error  'expect' is not defined  no-undef

ESLint(非常合理地)不允许未识别的全局变量。Jest 注入了全局变量(特别是 testdescribejestexpect)。幸运的是,这是一个容易解决的问题。在你的项目根目录下,打开 .eslintrc.js 文件(这是 ESLint 的配置文件)。在 env 部分,添加以下内容:

"jest": true,

现在如果你再次运行 npm run lint,你应该会看到更少的错误。

那么剩下的错误怎么办?这就是我能提供智慧但无具体指导的地方。总体来说,Lint 错误有三种原因:

  • 这是一个合法的问题,你应该解决它。有时候问题可能并不明显,这时你可能需要参考 ESLint 文档中特定错误的部分。

  • 这是一个你不同意的规则,你可以简单地禁用它。ESLint 中的许多规则都是主观的。稍后我会演示如何禁用一个规则。

  • 你同意这个规则,但在特定情况下修复它是不可行或成本很高的。对于这些情况,你可以仅为文件中特定行禁用规则,我们也将看到一个示例。

如果你一直在跟进,你现在应该看到以下错误:

/Users/ethan/wdne2e-companion/ch05/meadowlark.js
  27:5  error  Unexpected console statement  no-console

/Users/ethan/wdne2e-companion/ch05/lib/handlers.js
  10:39  error  'next' is defined but never used  no-unused-vars

ESLint 抱怨控制台日志,因为这不一定是为你的应用提供输出的好方法;它可能会很嘈杂和不一致,并且根据运行方式,输出可能被忽略。然而,对于我们的用途,假设它并不影响我们,我们想要禁用这个规则。打开你的 .eslintrc 文件,找到 rules 部分(如果没有 rules 部分,请在导出对象的顶层创建一个),然后添加以下规则:

  "rules": {
    "no-console": "off",
  },

现在,如果我们再次运行npm run lint,就会看到这个错误不见了!接下来的问题有点棘手……

打开lib/handlers.js,考虑问题行:

exports.serverError = (err, req, res, next) => res.render('500')

ESLint 是正确的;我们将next作为参数传递,但没有做任何操作(我们也没有处理errreq,但由于 JavaScript 处理函数参数的方式,我们必须放置某些内容以便可以获取到res,我们确实在使用它)。

你可能会被诱惑只是删除next参数。“有什么害处?”你可能会想。确实,不会有运行时错误,并且你的代码检查工具会很高兴……但会造成一个难以察觉的伤害:你的自定义错误处理程序将停止工作!(如果你想自己看看,可以从一个路由抛出一个异常,然后尝试访问它,然后从serverError处理程序中删除next参数。)

Express 在这里做了一些微妙的事情:它使用您传递给它的实际参数数量来识别它应该是一个错误处理程序。如果没有那个next参数,无论您是否使用它,Express 都不再将其识别为错误处理程序。

注意

Express 团队在错误处理程序上所做的事情无疑是“聪明”的,但聪明的代码往往会令人困惑、容易出错或难以理解。尽管我非常喜欢 Express,但这是我认为团队做错的选择之一:我认为它应该找到一种不那么特异的、更明确的方式来指定错误处理程序。

我们无法更改处理程序代码,我们需要我们的错误处理程序,但我们喜欢这个规则,不想禁用它。我们可以忍受这个错误,但错误会积累并成为一个不断的烦恼,最终会侵蚀拥有代码检查工具的初衷。幸运的是,我们可以通过禁用该规则来解决这个问题。在lib/handlers.js中编辑,并在你的错误处理程序周围添加以下内容:

// Express recognizes the error handler by way of its four
// arguments, so we have to disable ESLint's no-unused-vars rule
/* eslint-disable no-unused-vars */
exports.serverError = (err, req, res, next) => res.render('500')
/* eslint-enable no-unused-vars */

刚开始时代码检查可能有点令人沮丧——似乎它不停地让你出错。当然,你应该随意禁用那些不适合你的规则。随着你学会避免代码检查旨在捕捉的常见错误,你会发现它越来越不令人沮丧。

测试和代码检查无疑是有用的,但任何工具如果你从不使用它就毫无价值!也许你会觉得很疯狂,你会花费时间和精力编写单元测试和设置代码检查,但我看过这种情况发生,尤其是在压力之下。幸运的是,有一种方法可以确保这些有用的工具不被遗忘:持续集成。

持续集成

我给你留下另一个非常有用的 QA 概念:持续集成(CI)。如果你在团队中工作,这尤为重要,但即使你是独自工作,它也可以提供一些有益的纪律性。

基本上,持续集成会在每次向源代码库贡献代码时运行一些或所有的测试(你可以控制这适用于哪些分支)。如果所有测试都通过了,通常不会发生任何事情(根据你的持续集成配置,你可能会收到一封“干得好”的电子邮件)。

另一方面,如果测试失败,后果通常更为……公开。同样,这取决于你如何配置你的持续集成,但通常整个团队都会收到一封邮件,告诉你“破坏了构建”。如果你的集成主管真的很刻薄,有时你的老板也会在那个邮件列表中!我甚至知道一些团队,他们设置了灯光和警报器,当有人破坏了构建时,一个微型机器人泡沫导弹发射器会向违规开发者发射软弹!这是在提交代码之前运行你的 QA 工具链的一个强大激励。

本书的范围不包括安装和配置持续集成服务器,但涉及到 QA 的一章如果没有提到它,就不算完整。

目前,Node 项目中最流行的持续集成服务器是Travis CI。Travis CI 是一种托管解决方案,这可能很吸引人(它可以帮助你避免设置自己的持续集成服务器)。如果你使用 GitHub,它提供了优秀的集成支持。CircleCI 是另一个选择。

如果你是独自工作在一个项目上,你可能不会从持续集成服务器中获得太多好处,但如果你在团队或开源项目中工作,我强烈建议你考虑为你的项目设置持续集成。

结论

本章涵盖了很多内容,但我认为这些是任何开发框架中的基本实际技能。JavaScript 生态系统非常庞大,如果你是新手,可能很难知道从哪里开始。我希望本章能指导你朝着正确的方向前进。

现在我们已经对这些工具有了一些经验,接下来我们将关注 Node 和 Express 对象的一些基本原理,这些对象包围着 Express 应用程序中发生的所有事情:请求和响应对象。

^(1) 如果你对 await 不熟悉,我推荐阅读Tamas Piros 的这篇文章

第六章:请求和响应对象

在本章中,我们将学习请求和响应对象的重要细节,这些对象基本上是 Express 应用程序中发生的一切的起点和终点。当你使用 Express 构建 Web 服务器时,你将大部分时间都是从请求对象开始,然后结束于响应对象。

这两个对象起源于 Node 并由 Express 扩展。在我们深入研究这些对象提供的内容之前,让我们先了解一下客户端(通常是浏览器)如何从服务器请求页面以及页面如何返回的背景知识。

URL 的组成部分

我们经常看到 URL,但很少停下来考虑它们的组成部分。让我们考虑三个 URL 并检查它们的组成部分。

URL 的组成部分

协议

协议决定了请求如何传输。我们将专门处理httphttps。其他常见的协议包括fileftp

主机

主机标识服务器。在你的计算机(本地主机)或本地网络上的服务器可能仅用一个单词或数字 IP 地址来标识。在互联网上,主机将以顶级域名(TLD)结尾,如.com.net。此外,可能还有子域,作为主机名的前缀。www是常见的子域,虽然可以是任何东西。子域是可选的。

端口

每台服务器都有一组编号的端口。一些端口号是特殊的,比如 80 和 443。如果省略端口,则假定为 HTTP 的端口 80 和 HTTPS 的端口 443。通常情况下,如果不使用 80 或 443 端口,则应使用大于 1023 的端口号。^(1) 使用易记的端口号如 3000、8080 和 8088 是很常见的。每个端口只能与一个服务器关联,尽管可以选择很多端口号,但如果使用了常用端口号,则可能需要更改端口号。

路径

路径通常是 URL 中你的应用程序关心的第一部分(虽然可以根据协议、主机和端口做出决策,但这不是一个好的做法)。路径应用于唯一标识你的应用程序中的页面或其他资源。

查询字符串

查询字符串是一个可选的名称/值对集合。查询字符串以问号(?)开头,名称/值对之间用和号(&)分隔。名称和值都应进行URL 编码。JavaScript 提供了内置函数来完成这个操作:encodeURIComponent。例如,空格将被加号(+)替换。其他特殊字符将被数字字符引用替换。有时查询字符串也被称为搜索字符串或简称搜索

片段

片段(或哈希)根本不会传递给服务器;它严格用于浏览器使用。一些单页面应用程序使用片段来控制应用程序导航。最初,片段的唯一目的是导致浏览器显示文档的特定部分,由锚标记标记(例如:)。

HTTP 请求方法

HTTP 协议定义了一组请求方法(通常称为HTTP 动词),客户端用它们与服务器通信。远远地,最常见的方法是 GETPOST

在浏览器中键入 URL(或点击链接)时,浏览器会向服务器发出 HTTP GET 请求。传递给服务器的重要信息是 URL 路径和查询字符串。方法、路径和查询字符串的组合是应用程序用来确定如何响应的关键。

对于网站,大多数页面将响应 GET 请求。POST 请求通常用于向服务器发送信息(例如表单处理)。服务器处理请求后,通常会使用与相应 GET 请求相同的 HTML 响应。浏览器主要在与服务器通信时使用 GETPOST 方法。然而,您的应用程序进行的 Ajax 请求可能使用任何 HTTP 动词。例如,有一种称为 DELETE 的 HTTP 方法非常适合用于 API 调用来删除事物。

在 Node 和 Express 中,您完全可以控制如何响应方法。在 Express 中,通常会编写特定方法的处理程序。

请求头

当您导航到页面时,传递给服务器的不仅是 URL。每次访问网站时,您的浏览器都会发送大量“隐形”信息。我不是在谈论神秘的个人信息(尽管如果您的浏览器感染了恶意软件,这可能会发生)。浏览器会告诉服务器它喜欢以哪种语言接收页面(例如,如果您在西班牙下载 Chrome,则会请求访问的页面的西班牙语版本,如果存在的话)。它还会发送关于用户代理(浏览器、操作系统和硬件)和其他信息的信息。所有这些信息都作为请求头发送,通过请求对象的 headers 属性可供您使用。如果您想查看浏览器发送的信息,可以创建一个简单的 Express 路由来显示该信息(在配套仓库中的 ch06/00-echo-headers.js)。

app.get('/headers', (req, res) => {
  res.type('text/plain')
  const headers = Object.entries(req.headers)
    .map(([key, value]) => `${key}: ${value}`)
  res.send(headers.join('\n'))
})

响应头

正如你的浏览器通过请求头将隐藏信息发送到服务器一样,当服务器响应时,它也会发送一些不一定由浏览器渲染或显示的信息。响应头通常包括元数据和服务器信息。我们已经看到了Content-Type头部,它告诉浏览器正在传输的内容类型(HTML、图片、CSS、JavaScript 等)。请注意,浏览器将尊重Content-Type头部,无论 URL 路径是什么。因此,你可以从 /image.jpg 的路径提供 HTML 或从 /text.html 的路径提供图片。 (没有合理的理由这样做;重要的是理解路径是抽象的,浏览器使用Content-Type来确定如何渲染内容。)除了Content-Type,头部还可以指示响应是否压缩以及使用的编码类型。响应头还可以包含有关浏览器可以缓存资源多长时间的提示。这是优化网站的重要考虑因素,我们将在第十七章中详细讨论这一点。

响应头中通常也包含一些关于服务器的信息,指示服务器类型以及有时甚至有关操作系统的详细信息。返回服务器信息的缺点是,它给黑客一个入手点来危害你的网站。极端注重安全的服务器通常会省略这些信息,甚至提供错误信息。禁用 Express 默认的X-Powered-By头部很容易(在附带的存储库中的 ch06/01-disable-x-powered-by.js):

app.disable('x-powered-by')

如果你想查看响应头,可以在浏览器的开发者工具中找到。例如,要在 Chrome 中查看响应头:

  1. 打开 JavaScript 控制台。

  2. 点击网络选项卡。

  3. 刷新页面。

  4. 从请求列表中选择 HTML(这将是第一个)。

  5. 点击头部选项卡,你将看到所有的响应头。

互联网媒体类型

Content-Type头部非常重要;没有它,客户端将痛苦地猜测如何渲染内容。Content-Type头部的格式是 互联网媒体类型,包括类型、子类型和可选参数。例如,text/html; charset=UTF-8 指定了“text”类型,“html”子类型和 UTF-8 字符编码。互联网分配号码管理局维护着互联网媒体类型的官方列表。通常情况下,“内容类型”、“互联网媒体类型”和“MIME 类型”可以互换使用。MIME(多用途互联网邮件扩展)是互联网媒体类型的前身,在大多数情况下是等效的。

请求正文

除了请求头部外,请求还可以有一个主体(就像响应的主体是实际返回的内容一样)。普通的GET请求没有主体,但POST请求通常有。最常见的POST主体媒体类型是application/x-www-form-urlencoded,它简单地编码了用与号分隔的名称/值对(本质上与查询字符串的格式相同)。如果POST需要支持文件上传,则媒体类型是multipart/form-data,这是一种更复杂的格式。最后,Ajax 请求可以使用application/json作为主体。我们将在第八章学习更多有关请求主体的内容。

请求对象

请求对象(作为请求处理程序的第一个参数传递,这意味着您可以随意命名它;通常将其命名为reqrequest)从http.IncomingMessage的实例开始其生命周期,这是一个核心 Node 对象。Express 添加了更多功能。让我们看看请求对象的最有用的属性和方法(所有这些方法都是由 Express 添加的,除了req.headersreq.url,它们起源于 Node):

req.params

包含命名路由参数的数组。我们将在第十四章学习更多相关内容。

req.query

包含查询字符串参数(有时称为GET参数)的名称/值对的对象。

req.body

包含POST参数的对象。之所以这样命名,是因为POST参数是在请求的主体中传递的,而不是像查询字符串参数那样在 URL 中。要使req.body可用,您需要能够解析主体内容类型的中间件,我们将在第十章中学习。

req.route

当前匹配路由的信息。这主要用于路由调试。

req.cookies/req.signedCookies

包含从客户端传递的 cookie 值的对象。参见第九章。

req.headers

从客户端接收的请求头部。这是一个对象,其键是头部名称,值是头部值。请注意,这是来自底层http.IncomingMessage对象的,因此您在 Express 文档中找不到它。

req.accepts(types)

一个方便的方法,用于确定客户端是否接受给定的类型或类型(可选的types可以是单个 MIME 类型,如application/json,逗号分隔的列表或数组)。这个方法对于编写公共 API 的人来说非常重要;默认情况下假定浏览器总是接受 HTML。

req.ip

客户端的 IP 地址。

req.path

请求路径(不包括协议、主机、端口或查询字符串)。

req.hostname

返回客户端报告的主机名的方便方法。此信息可以伪造,不应用于安全目的。

req.xhr

一个方便的属性,如果请求来自 Ajax 调用,则返回true

req.protocol

用于发起此请求的协议(对我们而言,可能是 httphttps)。

req.secure

一个便捷属性,如果连接安全则返回 true。这等同于 req.protocol === 'https'

req.url/req.originalUrl

有些名称并不完全准确,这些属性返回路径和查询字符串(不包括协议、主机或端口)。req.url 可以为内部路由目的重写,但 req.originalUrl 设计为保留原始请求和查询字符串。

响应对象

响应对象(作为请求处理程序的第二个参数传递,意味着可以随意命名为 resrespresponse)起初是 http.ServerResponse 的实例,这是 Node 的核心对象。Express 添加了更多功能。让我们看看响应对象的最有用的属性和方法(所有这些都是 Express 添加的):

res.status(code)

设置 HTTP 状态码。Express 默认为 200(OK),因此您需要使用这个方法来返回 404(未找到)或 500(服务器错误)等其他状态码。对于重定向(状态码 301、302、303 和 307),有一个 redirect 方法,这是首选方法。请注意,res.status 返回响应对象,这意味着您可以链接调用:res.status(404).send('Not found')

res.set(name, value)

设置响应头。这通常不是您手动操作的内容。您也可以通过传递一个对象参数一次性设置多个头部,对象的键是头部名称,值是头部的值。

res.cookie(name, value, [options])res.clearCookie(name, [options])

设置或清除将存储在客户端的 cookie。这需要一些中间件支持;请参见第九章。

res.redirect([status], url)

重定向浏览器。默认的重定向代码是 302(Found)。通常情况下,应尽量减少重定向,除非您要永久移动页面,此时应使用代码 301(Moved Permanently)。

res.send(body)

向客户端发送响应。Express 默认为 text/html 类型的内容,因此如果您希望将其更改为 text/plain(例如),您需要在调用 res.send 之前调用 res.type('text/plain')。如果 body 是对象或数组,则响应将以 JSON 形式发送(内容类型会适当设置),但如果要发送 JSON,建议直接调用 res.json

res.json(json)

向客户端发送 JSON。

res.jsonp(json)

向客户端发送 JSONP。

res.end()

结束连接而不发送响应。要了解 res.sendres.jsonres.end 之间的区别,请参阅 Tamas Piros 的这篇文章

res.type(type)

一个便捷方法来设置Content-Type头部。这基本上等同于res.set('Content-Type', type),除非您提供一个没有斜杠的字符串,它还将尝试将文件扩展名映射到互联网媒体类型。例如,res.type('txt')将导致Content-Typetext/plain。在某些情况下,这种功能可能很有用(例如,自动服务不同的多媒体文件),但通常情况下,您应该避免这样做,而是显式设置正确的互联网媒体类型。

res.format(object)

这种方法允许您根据Accept请求头发送不同的内容。这在 API 中非常有用,我们将在第十五章进一步讨论这个问题。这里有一个简单的例子:res.format({'text/plain': 'hi there', 'text/html': '<b>hi there</b>'})

res.attachment([filename])res.download(path, [filename], [callback])

这两种方法都将Content-Disposition响应头设置为attachment;这将提示浏览器下载内容而不是在浏览器中显示它。您可以指定filename作为浏览器的提示。使用res.download,您可以指定要下载的文件,而res.attachment仅设置头部;您仍然需要向客户端发送内容。

res.sendFile(path, [options], [callback])

这种方法将读取由path指定的文件,并将其内容发送到客户端。对于这种方法几乎没有什么需求;使用static中间件并将您希望客户端能够访问的文件放在public目录中更容易。但是,如果您希望根据某些条件从同一个 URL 提供不同的资源,那么这种方法可能会有用。

res.links(links)

设置Links响应头。在大多数应用程序中几乎没有实际用途的专用头部。

res.localsres.render(view, [locals], callback)

res.locals是一个包含默认渲染视图上下文的对象。res.render将使用配置的模板引擎来渲染视图(res.renderlocals参数不应与res.locals混淆:它将覆盖res.locals中的上下文,但未被覆盖的上下文仍将可用)。注意,res.render默认响应码为 200;使用res.status指定不同的响应码。视图渲染将在第七章详细讨论。

获取更多信息

由于 JavaScript 的原型继承,确切地知道你正在处理的东西有时可能是具有挑战性的。Node 提供了 Express 扩展的对象,而你添加的包也可能会扩展这些对象。有时候确切地弄清楚可用的功能是具有挑战性的。总的来说,我建议你往回推导:如果你在寻找某些功能,首先检查Express API 文档。Express 的 API 非常完整,很有可能你会在那里找到你要找的内容。

如果你需要查找未记录的信息,有时候你必须深入研究Express 源代码。我鼓励你这样做!你可能会发现这并没有你想象中的那么吓人。以下是在 Express 源代码中找到信息的快速路线图:

lib/application.js

主要的 Express 接口。如果你想了解中间件是如何链接或者如何渲染视图的,这是你应该查看的地方。

lib/express.js

这是一个相对较短的文件,主要提供createApplication函数(该文件的默认导出),用于创建 Express 应用程序实例。

lib/request.js

扩展 Node 的http.IncomingMessage对象以提供强大的请求对象。要了解有关请求对象的所有属性和方法的信息,这就是你要查找的地方。

lib/response.js

扩展 Node 的http.ServerResponse对象以提供响应对象。要了解响应对象的所有属性和方法的信息,这就是你要查找的地方。

lib/router/route.js

提供基本的路由支持。虽然路由是你应用程序的核心,但是这个文件不到 230 行;你会发现它非常简单和优雅。

当你深入研究 Express 源代码时,你可能会想参考Node 文档,特别是关于HTTP模块的部分。

简化问题

本章已经概述了请求和响应对象,这些是 Express 应用程序的核心部分。然而,你大部分时间可能只会使用它们的一个小子集功能。所以让我们按照你最频繁使用的功能来详细讨论一下。

渲染内容

当你渲染内容时,你最常使用的是res.render,它在布局中呈现视图,提供最大的价值。偶尔,你可能想要编写一个快速的测试页面,所以如果只是想要一个测试页面,你可能会使用res.send。你可以使用req.query获取查询字符串的值,使用req.session获取会话值,或者使用req.cookie/req.signedCookies获取 cookie。示例 6-1 到示例 6-8 演示了常见的内容渲染任务。

示例 6-1. 基本用法(ch06/02-basic-rendering.js
// basic usage
app.get('/about', (req, res) => {
  res.render('about')
})
示例 6-2. 除了 200 之外的响应代码(ch06/03-different-response-codes.js
app.get('/error', (req, res) => {
  res.status(500)
  res.render('error')
})

// or on one line...

app.get('/error', (req, res) => res.status(500).render('error'))
示例 6-3. 将上下文传递给视图,包括查询字符串、cookie 和会话值(ch06/04-view-with-content.js
app.get('/greeting', (req, res) => {
  res.render('greeting', {
    message: 'Hello esteemed programmer!',
    style: req.query.style,
    userid: req.cookies.userid,
    username: req.session.username
  })
})
示例 6-4. 渲染不带布局的视图(ch06/05-view-without-layout.js
// the following layout doesn't have a layout file, so
// views/no-layout.handlebars must include all necessary HTML
app.get('/no-layout', (req, res) =>
  res.render('no-layout', { layout: null })
)
示例 6-5. 使用自定义布局渲染视图(ch06/06-custom-layout.js
// the layout file views/layouts/custom.handlebars will be used
app.get('/custom-layout', (req, res) =>
  res.render('custom-layout', { layout: 'custom' })
)
示例 6-6. 渲染纯文本输出(ch06/07-plaintext-output.js
app.get('/text', (req, res) => {
  res.type('text/plain')
  res.send('this is a test')
})
示例 6-7. 添加错误处理程序(ch06/08-error-handler.js
// this should appear AFTER all of your routes
// note that even if you don't need the "next" function, it must be
// included for Express to recognize this as an error handler
app.use((err, req, res, next) => {
  console.error('** SERVER ERROR: ' + err.message)
  res.status(500).render('08-error',
    { message: "you shouldn't have clicked that!" })
})
示例 6-8. 添加一个 404 处理程序(ch06/09-custom-404.js
// this should appear AFTER all of your routes
app.use((req, res) =>
  res.status(404).render('404')
)

处理表单

在处理表单时,表单信息通常在 req.body 中(有时在 req.query 中)。您可以使用 req.xhr 来判断请求是 Ajax 请求还是浏览器请求(这将在 Chapter 8 中详细介绍)。参见 Example 6-9 和 Example 6-10。在以下示例中,您需要链接 body parser 中间件:

const bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({ extended: false }))

我们将在第八章详细学习关于 body parser 中间件的内容,Chapter 8。

示例 6-9. 基本表单处理(ch06/10-basic-form-processing.js
app.post('/process-contact', (req, res) => {
  console.log(`received contact from ${req.body.name} <${req.body.email}>`)
  res.redirect(303, '10-thank-you')
})
示例 6-10. 更健壮的表单处理(ch06/11-more-robust-form-processing.js
app.post('/process-contact', (req, res) => {
  try {
    // here's where we would try to save contact to database or other
    // persistence mechanism...for now, we'll just simulate an error
    if(req.body.simulateError) throw new Error("error saving contact!")
    console.log(`contact from ${req.body.name} <${req.body.email}>`)
    res.format({
      'text/html': () => res.redirect(303, '/thank-you'),
      'application/json': () => res.json({ success: true }),
    })
  } catch(err) {
    // here's where we would handle any persistence failures
    console.error(`error processing contact from ${req.body.name} ` +
      `<${req.body.email}>`)
    res.format({
      'text/html': () =>  res.redirect(303, '/contact-error'),
      'application/json': () => res.status(500).json({
        error: 'error saving contact information' }),
    })
  }
})

提供 API

在提供 API 时,类似处理表单,参数通常在 req.query 中,尽管您也可以使用 req.body。API 的不同之处在于,通常会返回 JSON、XML 或纯文本,而不是 HTML,并且经常使用较少见的 HTTP 方法如 PUTPOSTDELETE。API 的提供将在 Chapter 15 中介绍。Example 6-11 和 Example 6-12 使用以下“products”数组(通常从数据库中检索):

const tours = [
  { id: 0, name: 'Hood River', price: 99.99 },
  { id: 1, name: 'Oregon Coast', price: 149.95 },
]
注意

术语 endpoint 常用来描述 API 中的单个函数。

示例 6-11. 简单的 GET 端点仅返回 JSON(ch06/12-api.get.js
app.get('/api/tours', (req, res) => res.json(tours))

Example 6-12 使用 Express 中的 res.format 方法根据客户端的偏好来响应。

示例 6-12. 返回 JSON、XML 或文本的 GET 端点(ch06/13-api-json-xml-text.js
app.get('/api/tours', (req, res) => {
  const toursXml = '<?xml version="1.0"?><tours>' +
    tours.map(p =>
      `<tour price="${p.price}" id="${p.id}">${p.name}</tour>`
    ).join('') + '</tours>'
  const toursText = tours.map(p =>
      `${p.id}: ${p.name} (${p.price})`
    ).join('\n')
  res.format({
    'application/json': () => res.json(tours),
    'application/xml': () => res.type('application/xml').send(toursXml),
    'text/xml': () => res.type('text/xml').send(toursXml),
    'text/plain': () => res.type('text/plain').send(toursXml),
  })
})

在 Example 6-13 中,PUT 端点更新产品并返回 JSON。参数通过请求体传递(路由字符串中的 :id 告诉 Express 将 id 属性添加到 req.params)。

示例 6-13. 用于更新的 PUT 端点(ch06/14-api-put.js
app.put('/api/tour/:id', (req, res) => {
  const p = tours.find(p => p.id === parseInt(req.params.id))
  if(!p) return res.status(404).json({ error: 'No such tour exists' })
  if(req.body.name) p.name = req.body.name
  if(req.body.price) p.price = req.body.price
  res.json({ success: true })
})

最后,Example 6-14 展示了一个 DELETE 端点。

示例 6-14. 删除端点用于删除(ch06/15-api-del.js
app.delete('/api/tour/:id', (req, res) => {
  const idx = tours.findIndex(tour => tour.id === parseInt(req.params.id))
  if(idx < 0) return res.json({ error: 'No such tour exists.' })
  tours.splice(idx, 1)
  res.json({ success: true })
})

结论

希望本章中的微型示例让您对 Express 应用程序中常见的功能有所了解。这些示例旨在成为您将来可以重访的快速参考。

在下一章中,我们将深入探讨模板化,这在本章的渲染示例中有所涉及。

^(1) 端口 0–1023 是保留给常见服务的“众所周知的端口”。

第七章:使用 Handlebars 进行模板化

在本章中,我们将讨论 模板化,这是一种构建和格式化你的内容以显示给用户的技术。你可以将模板化视为表单信的进化:“亲爱的 [姓名]:我们很遗憾地告诉你,再也没有人使用 [过时的技术] 了,但模板化仍然活跃!”要将这封信发送给一群人,你只需要替换 [姓名] 和 [过时的技术]。

注意

这种替换字段的过程有时被称为 插值,在这个上下文中,它只是一个“提供缺失信息”的花哨词语。

尽管服务器端模板正在被 React、Angular 和 Vue 等前端框架快速取代,但它仍然有应用,比如创建 HTML 邮件。此外,Angular 和 Vue 都使用类似模板的方法编写 HTML,因此你在服务器端模板方面学到的内容可以转移到这些前端框架中。

如果你来自 PHP 背景,可能会对这些问题感到奇怪:PHP 是第一个真正可以称之为模板语言的语言之一。几乎所有为 Web 而适应的主要语言都包含了某种模板支持。如今不同的是 模板引擎 正从语言中解耦。

那么模板看起来是什么样子?让我们从考虑用一种最明显和直接的方式生成一种语言来替代的方式开始(具体来说,我们将使用 JavaScript 生成一些 HTML):

document.write('<h1>Please Don\'t Do This</h1>')
document.write('<p><span class="code">document.write</span> is naughty,\n')
document.write('and should be avoided at all costs.</p>')
document.write('<p>Today\'s date is ' + new Date() + '.</p>')

或许唯一显得“显而易见”的原因是这一直是编程教学的方式:

10 PRINT "Hello world!"

在命令式语言中,我们习惯于说:“先做这个,然后做那个,然后再做其他事情。”对于某些事情,这种方法很有效。如果你有 500 行 JavaScript 来执行一个复杂的计算,最终得到一个数字,每一步都依赖于前一步,那么这样做并没有什么问题。但反过来呢?你有 500 行 HTML 和 3 行 JavaScript。写 500 次 document.write 有意义吗?完全没有。

实际上,问题归根结底是:切换上下文是有问题的。如果你经常写大量的 JavaScript,混合 HTML 是不方便且令人困惑的。反过来就没那么糟糕。我们习惯在 <script> 块中编写 JavaScript,但希望你能看出其中的区别:仍然存在上下文切换,你要么在写 HTML,要么在 <script> 块里写 JavaScript。让 JavaScript 生成 HTML 存在许多问题:

  • 你必须经常担心哪些字符需要转义以及如何进行转义。

  • 使用 JavaScript 生成包含 JavaScript 的 HTML 很快就会导致混乱。

  • 通常会失去编辑器具有的漂亮语法高亮和其他有用的特定于语言的功能。

  • 发现格式不正确的 HTML 可能会更加困难。

  • 你的代码很难在视觉上解析。

  • 这可能会使其他人更难理解你的代码。

通过模板化,可以解决这个问题,允许你用目标语言编写,同时提供插入动态数据的能力。考虑前面的例子重写为 Mustache 模板:

<h1>Much Better</h1>
<p>No <span class="code">document.write</span> here!</p>
<p>Today's date is {{today}}.</p>

现在我们只需要为{{today}}提供一个值,这是模板语言的核心。

除了这条,没有绝对的规则

我并不建议你在 JavaScript 中从不写 HTML,只是尽可能避免。特别是在前端代码中,尤其是如果你正在使用强大的前端框架。例如,这样的代码对我来说不太容易接受:

document.querySelector('#error').innerHTML =
  'Something <b>very bad</b> happened!'

然而,假设最终变成了这样:

document.querySelector('#error').innerHTML =
  '<div class="error"><h3>Error</h3>' +
  '<p>Something <b><a href="/error-detail/' + errorNumber +
  '">very bad</a></b> ' +
  'happened.  <a href="/try-again">Try again<a>, or ' +
  '<a href="/contact">contact support</a>.</p></div>'

然后我可能会建议现在是使用模板的时候了。关键是,在决定在字符串中使用 HTML 和使用模板之间划界线时,我建议你培养良好的判断力。不过,我更倾向于使用模板,除了最简单的情况外,尽量避免在 JavaScript 中生成 HTML。

选择模板引擎

在 Node 世界中,你有很多模板引擎可供选择,那么如何选择呢?这是一个复杂的问题,非常依赖于你的需求。以下是一些需要考虑的标准:

性能

显然,你希望你的模板引擎尽可能快速。这不是你想要拖慢网站速度的东西。

客户端、服务器或两者?

大多数但并非所有的模板引擎都可以在服务器端和客户端两边使用。如果你需要在两个领域中都使用模板(你会需要的),我建议你选择在任何一种情况下都同样有能力的东西。

抽象化

想要一些熟悉的东西(比如常规的带有大括号的 HTML,例如),还是你暗地里讨厌 HTML,并且希望有些东西可以让你摆脱那些尖括号?模板(特别是服务器端的模板)在这里给你一些选择。

这些只是选择模板语言时的一些更突出的标准之一。目前,模板选项非常成熟,所以无论你选择什么,都不会太错。

Express 允许你使用任何你喜欢的模板引擎,所以如果 Handlebars 不合你的口味,你会发现很容易替换它。如果你想探索选项,你可以使用有趣且实用的模板引擎选择器(尽管它已经不再更新,但仍然很有用)。

在我们讨论 Handlebars 之前,让我们先看看一个特别抽象的模板引擎。

Pug:一种不同的方法

虽然大多数模板引擎都采用以 HTML 为中心的方法,但 Pug 通过将 HTML 的细节抽象化而脱颖而出。还值得注意的是,Pug 的创始人是 TJ Holowaychuk,他也是 Express 的创作者。因此,Pug 与 Express 的集成非常好。Pug 采取的方法是高尚的:它的核心观点是 HTML 是一种繁琐且乏味的手工编写语言。让我们来看看一个 Pug 模板的示例,以及它将输出的 HTML(原始取自Pug 主页,并稍作修改以符合本书的格式):

doctype html                        <!DOCTYPE html>
html(lang="en")                     <html lang="en">
  head                              <head>
    title= pageTitle                <title>Pug Demo</title>
    script.                         <script>
      if (foo) {                        if (foo) {
         bar(1 + 5)                         bar(1 + 5)
      }                                 }
  body                              </script>
                                    <body>
    h1 Pug                         <h1>Pug</h1>
    #container                      <div id="container">
      if youAreUsingPug
        p You are amazing           <p>You are amazing</p>
      else
        p Get on it!
      p.                            <p>
        Pug is a terse and           Pug is a terse and
        simple templating             simple templating
        language with a               language with a
        strong focus on               strong focus on
        performance and               performance and
        powerful features.            powerful features.
                                    </p>
                                    </body>
                                    </html>

Pug 显然输入较少(不再有尖括号或闭合标签)。相反,它依赖缩进和一些常识规则,使表达意思变得更容易。Pug 的另一个优势在于:从理论上讲,当 HTML 本身发生变化时,你可以简单地让 Pug 针对最新版本的 HTML 进行重新定位,从而使你的内容“未来证明”。

尽管我钦佩 Pug 的哲学和其执行的优雅,但我发现我不想让 HTML 的细节从我身边消失。作为一个网页开发者,HTML 是我所做的一切的核心,如果代价是键盘上的尖括号键被磨损,那就算了。我和许多前端开发者交流后发现,他们的看法也是如此,也许这个世界还没有准备好接受 Pug。

这就是我们与 Pug 分道扬镳的地方;你在本书中将不会看到它。然而,如果你喜欢这种抽象化的方式,你肯定不会遇到使用 Pug 与 Express 的问题,并且有很多资源可以帮助你实现这一点。

Handlebars 基础

Handlebars 是 Mustache 的扩展,另一个流行的模板引擎。我推荐 Handlebars 是因为它容易与 JavaScript 集成(前端和后端都可以),并且语法熟悉。对我来说,它在各个方面都找到了平衡点,这也是我们本书的重点内容。我们讨论的概念广泛适用于其他模板引擎,因此如果 Handlebars 不适合你,你也可以尝试其他模板引擎。

理解模板的关键在于理解 上下文 的概念。当你渲染一个模板时,你向模板引擎传递一个叫做 上下文对象 的对象,这就是允许替换操作正常工作的对象。

例如,如果我的上下文对象是

{ name: 'Buttercup' }

and my template is

<p>Hello, {{name}}!</p>

那么 {{name}} 将被替换为 Buttercup。如果你想向模板传递 HTML 呢?例如,如果我们的上下文对象改为

{ name: '<b>Buttercup</b>' }

那么使用之前的模板将导致 <p>Hello, &lt;b&gt;Buttercup&lt;b&gt;</p>,这可能不是你想要的结果。要解决这个问题,只需使用三个大括号而不是两个:{{{name}}}

Note

虽然我们已经确立了不应该在 JavaScript 中编写 HTML 的观点,但是使用三个花括号来关闭 HTML 转义的能力确实有一些重要用途。例如,如果您正在构建带有所见即所得(WYSIWYG)编辑器的内容管理系统(CMS),您可能希望能够向视图传递 HTML。此外,在 布局部分 中渲染上下文的属性时关闭 HTML 转义也很重要,我们很快会学到这一点。

在 图 7-1 中,我们看到 Handlebars 引擎如何使用上下文(用椭圆表示)结合模板来渲染 HTML。

使用 Handlebars 渲染 HTML

图 7-1. 使用 Handlebars 渲染 HTML

注释

在 Handlebars 中,注释 的形式是 {{! comment goes here }}。重要的是要理解 Handlebars 注释与 HTML 注释的区别。考虑以下模板:

{{! super-secret comment }}
<!-- not-so-secret comment -->

假设这是一个服务器端模板,则超级秘密注释永远不会发送到浏览器,而不那么秘密的注释如果用户检查 HTML 源码则会可见。您应该优先使用 Handlebars 注释来处理任何暴露实现细节或其他不希望公开的内容。

当考虑 时,情况开始变得更加复杂。块提供流程控制、条件执行和可扩展性。考虑以下上下文对象:

{
  currency: {
    name: 'United States dollars',
    abbrev: 'USD',
  },
  tours: [
    { name: 'Hood River', price: '$99.95' },
    { name: 'Oregon Coast', price: '$159.95' },
  ],
  specialsUrl: '/january-specials',
  currencies: [ 'USD', 'GBP', 'BTC' ],
}

现在让我们来看看我们可以将该上下文传递到的模板:

<ul>
  {{#each tours}}
    {{! I'm in a new block...and the context has changed }}
    <li>
      {{name}} - {{price}}
      {{#if ../currencies}}
        ({{../currency.abbrev}})
      {{/if}}
    </li>
  {{/each}}
</ul>
{{#unless currencies}}
  <p>All prices in {{currency.name}}.</p>
{{/unless}}
{{#if specialsUrl}}
  {{! I'm in a new block...but the context hasn't changed (sortof) }}
  <p>Check out our <a href="{{specialsUrl}}">specials!</p>
{{else}}
  <p>Please check back often for specials.</p>
{{/if}}
<p>
  {{#each currencies}}
    <a href="#" class="currency">{{.}}</a>
  {{else}}
    Unfortunately, we currently only accept {{currency.name}}.
  {{/each}}
</p>

这个模板涵盖了很多内容,所以让我们逐步分解它。它以each助手开始,允许我们迭代一个数组。重要的是要理解,在{{#each tours}}{{/each tours}}之间,上下文会发生变化。第一次迭代时,上下文变为{ name: 'Hood River', price: '$99.95' },第二次迭代时,上下文是{ name: 'Oregon Coast', price: '$159.95' }。因此,在这个块内部,我们可以引用{{name}}{{price}}。然而,如果我们想要访问currency对象,我们必须使用../来访问上下文。

如果上下文的属性本身是一个对象,我们可以像平常一样用点号访问它的属性,比如 {{currency.name}}

ifeach 都有一个可选的 else 块(对于 each,如果数组中没有元素,则会执行 else 块)。我们还使用了 unless 助手,它实际上是 if 助手的反义:只有在参数为假时才执行。

这个模板的最后一点是在 {{#each currencies}} 块中使用 {{.}}{{.}} 指的是当前上下文;在这种情况下,当前上下文仅仅是我们要打印输出的数组中的一个字符串。

提示

使用单独的句点访问当前上下文还有另一种用途:它可以区分帮助程序(我们将很快学习的)和当前上下文的属性。例如,如果您有一个名为foo的帮助程序和当前上下文中的一个属性也叫foo{{foo}}指的是帮助程序,{{./foo}}指的是属性。

服务器端模板

服务器端模板允许您在将 HTML 发送到客户端之前进行渲染。与客户端模板不同,后者的模板对于那些知道如何查看 HTML 源代码的好奇用户是可见的,但您的用户永远不会看到您的服务器端模板或用于生成最终 HTML 的上下文对象。

服务器端模板除了隐藏您的实现细节外,还支持模板缓存,这对性能至关重要。模板引擎将缓存编译后的模板(仅在模板本身更改时重新编译和重新缓存),这将提高模板视图的性能。默认情况下,在开发模式下禁用视图缓存,在生产模式下启用。如果您想显式启用视图缓存,可以这样做:

app.set('view cache', true)

默认情况下,Express 支持 Pug、EJS 和 JSHTML。我们已经讨论过 Pug,但我觉得 EJS 或 JSHTML 都不够(在语法上,不符合我的口味)。因此,我们需要添加一个 Node 包,为 Express 提供 Handlebars 支持:

npm install express-handlebars

然后我们将其链接到 Express 中(在伴随存储库中的ch07/00/meadowlark.js):

const expressHandlebars = require('express-handlebars')
app.engine('handlebars', expressHandlebars({
  defaultLayout: 'main',
})
app.set('view engine', 'handlebars')
小贴士

express-handlebars期望 Handlebars 模板具有.handlebars扩展名。我已经习惯了这个,但如果对您来说太啰嗦,创建express-handlebars实例时可以将扩展名更改为也很常见的.hbsapp.engine('handlebars', expressHandlebars({ extname: '.hbs' }))

视图和布局

视图通常表示网站上的单个页面(尽管它可以表示页面的 Ajax 加载部分、电子邮件或任何其他内容)。默认情况下,Express 在views子目录中查找视图。布局是一种特殊类型的视图——本质上是模板的模板。布局至关重要,因为您网站上的大多数(如果不是所有)页面将具有几乎相同的布局。例如,它们必须有一个<html>元素和一个<title>元素,它们通常加载相同的 CSS 文件等等。您不希望为每个页面都重复此代码,这就是布局的作用。让我们看一个基本的布局文件:

<!doctype html>
<html>
  <head>
    <title>Meadowlark Travel</title>
    <link rel="stylesheet" href="/css/main.css">
  </head>
  <body>
    {{{body}}}
  </body>
</html>

注意 <body> 标签中的文本:{{{body}}}。这样视图引擎就知道在哪里渲染视图内容。使用三个大括号而不是两个是很重要的:因为我们的视图很可能包含 HTML,我们不希望 Handlebars 尝试转义它。请注意,{{{body}}} 字段的放置位置没有限制。例如,如果您正在构建 Bootstrap 中的响应式布局,则可能希望将视图放在容器 <div> 内。此外,像页眉和页脚这样的常见页面元素通常位于布局而不是视图中。以下是一个例子:

<!-- ... -->
<body>
  <div class="container">
    <header>
      <div class="container">
        <h1>Meadowlark Travel</h1>
        <img src="/img/logo.png" alt="Meadowlark Travel Logo">
      </div>
    </header>
    <div class="container">
      {{{body}}}
    </div>
    <footer>&copy; 2019 Meadowlark Travel</footer>
  </div>
</body>

在 图 7-2 中,我们看到模板引擎如何结合视图、布局和上下文。这个图表清楚地表明了操作顺序的重要性。首先,这可能看起来有些违反直觉:视图在布局内部被渲染,那么布局不应该首先渲染吗?虽然技术上可以这样做,但反向操作有其优势。特别是,它允许视图本身进一步定制布局,在我们稍后讨论本章中的 sections 时将会很有用。

注意

由于操作顺序的原因,您可以将名为body的属性传递给视图,并且它将在视图中正确渲染。然而,在渲染布局时,body的值将被渲染视图覆盖。

在 Express 中使用布局(或不使用)

大多数情况下(如果不是全部),您的页面将使用相同的布局,因此每次渲染视图时都指定布局没有意义。您会注意到,当我们创建视图引擎时,我们指定了默认布局的名称:

app.engine('handlebars', expressHandlebars({
  defaultLayout: 'main',
})

默认情况下,Express 在 views 子目录中查找视图,在 views/layouts 中查找布局。因此,如果您有一个名为 views/foo.handlebars 的视图,您可以这样渲染它:

app.get('/foo', (req, res) => res.render('foo'))

它将使用 views/layouts/main.handlebars 作为布局。如果您不想使用任何布局(这意味着您必须在视图中拥有所有样板内容),您可以在上下文对象中指定 layout: null

app.get('/foo', (req, res) => res.render('foo', { layout: null }))

使用布局渲染视图

图 7-2. 使用布局渲染视图

或者,如果我们想使用不同的模板,我们可以指定模板名称:

app.get('/foo', (req, res) => res.render('foo', { layout: 'microsite' }))

这将使用 views/layouts/microsite.handlebars 渲染视图。

请记住,模板越多,您需要维护的基本 HTML 布局就越多。另一方面,如果页面在布局上有显著不同,这可能值得一试;您需要找到适合您项目的平衡。

分段

我从微软出色的Razor模板引擎中借用的一个技术是sections的概念。如果所有的视图都能完美地适应布局中的一个单一元素,那么布局工作得很好,但是当你的视图需要将自己注入布局的不同部分时会发生什么呢?一个常见的例子是视图需要向<head>元素添加内容或者插入一个<script>,有时这是布局的最后一件事,出于性能原因。

Handlebars 和express-handlebars都没有内置的方法来实现这一点。幸运的是,Handlebars 助手使这变得非常简单。当我们实例化 Handlebars 对象时,我们将添加一个名为section的助手(在伴侣存储库中的ch07/01/meadowlark.js文件中):

app.engine('handlebars', expressHandlebars({
  defaultLayout: 'main',
  helpers: {
    section: function(name, options) {
      if(!this._sections) this._sections = {}
      this._sections[name] = options.fn(this)
      return null
    },
  },
}))

现在我们可以在视图中使用section助手了。让我们添加一个视图(views/section-test.handlebars),向<head>添加一些内容和一个脚本:

{{#section 'head'}}
  <!-- we want Google to ignore this page -->
  <meta name="robots" content="noindex">
{{/section}}

<h1>Test Page</h1>
<p>We're testing some script stuff.</p>

{{#section 'scripts'}}
  <script>
    document.querySelector('body')
      .insertAdjacentHTML('beforeEnd', '<small>(scripting works!)</small>')
  </script>
{{/section}}

现在在我们的布局中,我们可以像放置{{{body}}}一样放置 sections:

{{#section 'head'}}
  <!-- we want Google to ignore this page -->
  <meta name="robots" content="noindex">
{{/section}}

<h1>Test Page</h1>
<p>We're testing some script stuff.</p>

{{#section 'scripts'}}
  <script>
    const div = document.createElement('div')
    div.appendChild(document.createTextNode('(scripting works!)'))
    document.querySelector('body').appendChild(div)
  </script>
{{/section}}

部分

很多时候,你会有一些组件希望在不同页面上重用(在前端圈子里有时称为widgets)。通过模板实现这一点的一种方法是使用partials(因为它们不会渲染整个视图或整个页面)。让我们想象我们想要一个当前天气组件,显示波特兰、本德和曼扎尼塔的当前天气状况。我们希望这个组件是可重用的,所以我们将使用一个部分。首先,我们创建一个部分文件,views/partials/weather.handlebars

<div class="weatherWidget">
  {{#each partials.weatherContext}}
    <div class="location">
      <h3>{{location.name}}</h3>
      <a href="{{location.forecastUrl}}">
        <img src="{{iconUrl}}" alt="{{weather}}">
        {{weather}}, {{temp}}
      </a>
    </div>
  {{/each}}
  <small>Source: <a href="https://www.weather.gov/documentation/services-web-api">
    National Weather Service</a></small>
</div>

请注意,我们通过以partials.weatherContext开头来为我们的上下文命名空间。因为我们希望能够在任何页面上使用这个部分,所以每次视图都传递上下文是不现实的,因此我们使用res.locals(这对每个视图都可用)。但是因为我们不想干扰单独视图指定的上下文,所以我们将所有部分上下文放在partials对象中。

警告

express-handlebars允许你将部分模板作为上下文的一部分传递。例如,如果你在上下文中添加partials.foo = "Template!",你可以使用{{> foo}}来渲染这个部分。这种用法将覆盖任何.handlebars视图文件,这就是为什么我们之前使用partials.weatherContext而不是partials.weather的原因,后者会覆盖views/partials/weather.handlebars

在第十九章中,我们将看到如何从免费的国家气象服务 API 获取当前天气信息。目前,我们将仅使用从我们称为getWeatherData的函数返回的虚拟数据。

在这个例子中,我们希望这些天气数据可以在任何视图中使用,并且最好的机制是中间件(我们将在第十章中了解更多)。我们的中间件将把天气数据注入到res.locals.partials对象中,这将作为部分的上下文可用。

为了使我们的中间件更具可测试性,我们将其放入自己的文件中,即 lib/middleware/weather.js(在配套的存储库中为 ch07/01/lib/middleware/weather.js):

const getWeatherData = () => Promise.resolve([
  {
    location: {
      name: 'Portland',
      coordinates: { lat: 45.5154586, lng: -122.6793461 },
    },
    forecastUrl: 'https://api.weather.gov/gridpoints/PQR/112,103/forecast',
    iconUrl: 'https://api.weather.gov/icons/land/day/tsra,40?size=medium',
    weather: 'Chance Showers And Thunderstorms',
    temp: '59 F',
  },
  {
    location: {
      name: 'Bend',
      coordinates: { lat: 44.0581728, lng: -121.3153096 },
    },
    forecastUrl: 'https://api.weather.gov/gridpoints/PDT/34,40/forecast',
    iconUrl: 'https://api.weather.gov/icons/land/day/tsra_sct,50?size=medium',
    weather: 'Scattered Showers And Thunderstorms',
    temp: '51 F',
  },
  {
    location: {
      name: 'Manzanita',
      coordinates: { lat: 45.7184398, lng: -123.9351354 },
    },
    forecastUrl: 'https://api.weather.gov/gridpoints/PQR/73,120/forecast',
    iconUrl: 'https://api.weather.gov/icons/land/day/tsra,90?size=medium',
    weather: 'Showers And Thunderstorms',
    temp: '55 F',
  },
])

const weatherMiddleware = async (req, res, next) => {
  if(!res.locals.partials) res.locals.partials = {}
  res.locals.partials.weatherContext = await getWeatherData()
  next()
}

module.exports = weatherMiddleware

现在一切都设置好了,我们只需在视图中使用部分即可。例如,要在主页上放置我们的小部件,请编辑 views/home.handlebars

<h2>Home</h2>
{{> weather}}

{{> partial_name}} 语法是在视图中包含部分的方式:express-handlebars 将会查找名为 partial_name.handlebars(或者在我们的示例中为 weather.handlebars)的视图,位于 views/partials 目录中。

提示

express-handlebars 支持子目录,因此如果有很多部分,您可以对其进行组织。例如,如果您有一些社交媒体部分,您可以将它们放在 views/partials/social 目录中,并使用 {{> social/facebook}}{{> social/twitter}} 等来包含它们。

完善您的模板

模板是您网站的核心。良好的模板结构将节省开发时间,促进网站的一致性,并减少布局问题的隐藏位置。但要实现这些好处,您必须花时间精心制作模板。决定您应该有多少模板是一门艺术;通常情况下,模板越少越好,但根据页面的统一性,也会有收益递减点。您的模板也是对抗跨浏览器兼容性问题和有效 HTML 的第一道防线。它们应该由精通前端开发的人士精心制作和维护。一个很好的开始地方,尤其是如果您是新手,是HTML5 Boilerplate。在之前的例子中,为了适应书籍的格式,我们使用了一个最小的 HTML5 模板,但对于我们的实际项目,我们将使用 HTML5 Boilerplate。

另一个开始模板的热门地点是第三方主题。像ThemeforestWrapBootstrap这样的网站有数百个现成的 HTML5 主题,您可以用作模板的起点。使用第三方主题的方法是从主文件(通常是 index.html)开始,将其重命名为 main.handlebars(或您选择的任何布局文件名称),并将所有资源(CSS、JavaScript、图像)放置在您用于静态文件的 public 目录中。然后,您需要编辑模板文件,并确定您想要放置 {{{body}}} 表达式的位置。

根据你的模板元素,你可能希望将其中一些元素移入局部模板中。一个很好的例子是标题横幅(一个旨在吸引用户注意力的高大横幅)。如果标题横幅出现在每个页面上(这可能是一个不好的选择),你会将标题横幅留在模板文件中。如果它只出现在一个页面上(通常是首页),那么它只会在那个视图中出现。如果它出现在几个页面上,但不是所有页面,那么你可能会考虑将其放在局部模板中。选择权在你手中,这也是制作独特、引人入胜的网站的艺术所在。

结论

我们已经看到模板化如何让我们的代码更容易编写、阅读和维护。多亏了模板,我们不必再痛苦地用 JavaScript 字符串拼凑 HTML;我们可以在我们喜爱的编辑器中编写 HTML,并使用一种紧凑而易于阅读的模板语言使其动态化。

现在我们已经学会了如何格式化我们的内容以便显示,接下来我们将把注意力转向如何使用 HTML 表单将数据输入到我们的系统中。

第八章:表单处理

收集用户信息的通常方法是使用 HTML 表单。无论您是让浏览器正常提交表单,使用 Ajax 还是使用复杂的前端控件,其基础机制通常仍然是 HTML 表单。在本章中,我们将讨论处理表单、表单验证和文件上传的不同方法。

将客户端数据发送到服务器

总体而言,发送客户端数据到服务器有两种选择:查询字符串和请求体。通常,如果你使用查询字符串,你是在进行GET请求;如果使用请求体,则是在进行POST请求。(HTTP 协议不会阻止你反过来做,但这没有意义:最好在这里坚持标准做法。)

一种常见的误解是POST是安全的,而GET不安全:实际上,如果您使用 HTTPS,两者都是安全的,如果不使用 HTTPS,则两者都不安全。如果不使用 HTTPS,入侵者可以轻松查看POST请求的正文数据,就像可以查看GET请求的查询字符串一样。但是,如果使用GET请求,用户将在查询字符串中看到所有输入的内容(包括隐藏字段),这样看起来很乱且不美观。此外,浏览器通常会对查询字符串的长度设置限制(对于请求正文长度没有此限制)。因此,我通常建议在表单提交时使用POST

HTML 表单

本书侧重于服务器端,但了解构建 HTML 表单的一些基础知识也很重要。这里有一个简单的例子:

<form action="/process" method="POST">
    <input type="hidden" name="hush" val="hidden, but not secret!">
    <div>
        <label for="fieldColor">Your favorite color: </label>
        <input type="text" id="fieldColor" name="color">
    </div>
    <div>
        <button type="submit">Submit</button>
    </div>
</form>

注意,在<form>标签中明确指定了方法为POST;如果不这样做,默认为GETaction属性指定了表单提交时将接收数据的 URL。如果省略此字段,表单将提交到加载表单的同一 URL。我建议您始终提供一个有效的action,即使您在使用 Ajax(这是为了防止数据丢失;更多信息请参见第二十二章)。

从服务器的角度来看,在<input>字段中,重要的属性是name属性:这是服务器识别字段的方式。重要的是要理解,name属性与id属性是不同的,id属性仅用于样式和前端功能(不会传递到服务器)。

注意隐藏字段:这些字段不会在用户的浏览器中显示。但是,不应将其用于秘密或敏感信息;只需查看页面源代码,隐藏字段就会被公开。

HTML 不限制您在同一页上拥有多个表单(这是一些早期服务器框架的不幸限制;ASP,我在看你)。我建议保持您的表单在逻辑上一致;一个表单应包含您希望一次提交的所有字段(可选/空字段是可以的),并且不应包含您不需要的字段。如果在页面上有两个不同的操作,应使用两个不同的表单。例如,一个用于网站搜索,另一个用于订阅电子邮件通讯。也可以使用一个大型表单,并根据用户点击的按钮决定采取什么操作,但这会很麻烦,通常对于残障人士不友好(因为辅助功能浏览器渲染表单的方式)。

在此示例中,当用户提交表单时,将调用 /process URL,并将字段值传输到请求体中的服务器。

编码

当表单提交时(无论是通过浏览器还是通过 Ajax),都必须以某种方式对其进行编码。如果您不明确指定编码方式,默认为 application/x-www-form-urlencoded(这只是“URL 编码”的一个较长的媒体类型)。这是一种基本的、易于使用的编码方式,Express 默认支持。

如果需要上传文件,则情况变得更加复杂。没有简单的方法可以使用 URL 编码发送文件,因此必须使用 multipart/form-data 编码类型,这种类型不会被 Express 直接处理。

表单处理的不同方法

如果您不使用 Ajax,唯一的选项是通过浏览器提交表单,这将重新加载页面。但是,如何重新加载页面由您决定。在处理表单时有两件事需要考虑:处理表单的路径(操作)和发送给浏览器的响应。

如果您的表单使用 method="POST"(推荐使用),通常会使用相同的路径来显示表单和处理表单:这些可以区分,因为前者是 GET 请求,后者是 POST 请求。如果采用这种方法,可以在表单上省略 action 属性。

另一种选择是使用单独的路径来处理表单。例如,如果您的联系页面使用路径 /contact,您可以使用路径 /process-contact 来处理表单(通过指定 action="/process-contact")。如果使用这种方法,您可以选择通过 GET 提交表单(我不推荐这样做;它会不必要地将您的表单字段暴露在 URL 上)。如果您有多个使用相同提交机制的 URL,则可能更喜欢使用单独的端点进行表单提交(例如,您可能在站点的多个页面上都有一个电子邮件注册框)。

无论您用于处理表单的路径是什么,都必须决定发送回浏览器的响应。以下是您的选择:

直接的 HTML 响应

处理表单后,您可以直接向浏览器发送 HTML(例如,一个视图)。这种方法会在用户尝试重新加载页面时产生警告,并且可能会干扰书签和返回按钮,因此不建议使用。

302 重定向

虽然这是一种常见的方法,但这是对 302(已找到)响应代码原始意义的误用。HTTP 1.1 添加了 303(查看其他)响应代码,这是更可取的。除非你有理由针对 1996 年之前制作的浏览器,否则应使用 303。

303 重定向

HTTP 1.1 中添加的 303(查看其他)响应代码是为了解决 302 重定向的误用。HTTP 规范明确指示浏览器在遵循 303 重定向时应使用 GET 请求,而不管原始方法如何。这是对表单提交请求做出响应的推荐方法。

由于建议您对表单提交做出 303 重定向响应,下一个问题是:“重定向指向哪里?”这个问题的答案取决于你。以下是最常见的方法:

重定向到专用成功/失败页面

该方法要求您为适当的成功或失败消息指定 URL。例如,如果用户注册了促销邮件,但出现了数据库错误,您可能希望重定向到 /error/database。如果用户的电子邮件地址无效,您可以重定向到 /error/invalid-email,如果一切顺利,您可以重定向到 /promo-email/thank-you。该方法的优势之一是它对分析友好:您 /promo-email/thank-you 页面的访问次数应该大致对应于注册促销电子邮件的人数。它也很容易实现。然而,它也有一些缺点。这确实意味着您必须为每种可能性分配 URL,这意味着需要设计页面,编写副本,并进行维护。另一个缺点是用户体验可能不够理想:用户喜欢被感谢,但后来他们必须返回到他们之前或想要去的地方。这是我们目前将要使用的方法:我们将在第九章中切换到使用闪存消息(不要与 Adobe Flash 混淆)。

重定向到原始位置并显示闪存消息

对于遍布网站的小型表单(例如电子邮件注册),最好的用户体验是不要中断用户的导航流程。也就是说,提供一种在不离开页面的情况下提交电子邮件地址的方式。当然,其中一种方法是使用 Ajax,但如果你不想使用 Ajax(或者你希望备用机制提供良好的用户体验),你可以重定向回用户最初所在的页面。实现这一点最简单的方法是在表单中使用一个隐藏字段,该字段填充当前 URL。由于你希望能够提供反馈,告知用户提交已收到,你可以使用闪存消息。

重定向到一个新的位置,并显示闪存消息。

大型表单通常有它们自己的页面,一旦提交表单,留在该页面是没有意义的。在这种情况下,你必须智能猜测用户可能希望去哪里,并相应地进行重定向。例如,如果你正在构建一个管理界面,并且有一个表单用于创建新的度假套餐,你可以合理地期望用户提交表单后想要进入显示所有度假套餐的管理页面。然而,你仍然应该使用闪存消息向用户反馈提交结果。

如果你正在使用 Ajax,我建议使用专用的 URL。虽然以一个前缀(例如 /ajax/enter)开始 Ajax 处理程序是很诱人的,但我不推荐这种方法:它将实现细节附加到 URL 上。另外,正如我们马上会看到的,你的 Ajax 处理程序应该作为常规浏览器提交的容错机制。

使用 Express 处理表单

如果你使用GET来处理表单,你的字段将会在req.query对象中。例如,如果你有一个名为email的 HTML 输入字段,其值将作为req.query.email传递给处理程序。关于这种方法,其实没有太多需要说的,就是这么简单。

如果你使用POST(我推荐的方法),你需要链接中间件来解析 URL 编码的主体。首先,安装body-parser中间件(npm install body-parser);然后,在伴随代码库中的 ch08/meadowlark.js 中链接它:

const bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({ extended: true }))

一旦链接了body-parser中间件,你会发现req.body现在可以用了,这是所有表单字段的存放位置。请注意,req.body并不妨碍你使用查询字符串。我们继续在 Meadowlark Travel 中添加一个表单,让用户可以注册邮件列表。为了演示,我们将在 /views/newsletter-signup.handlebars 中使用查询字符串、一个隐藏字段以及可见字段:

<h2>Sign up for our newsletter to receive news and specials!</h2>
<form class="form-horizontal" role="form"
    action="/newsletter-signup/process?form=newsletter" method="POST">
  <input type="hidden" name="_csrf" value="{{csrf}}">
  <div class="form-group">
    <label for="fieldName" class="col-sm-2 control-label">Name</label>
    <div class="col-sm-4">
      <input type="text" class="form-control"
      id="fieldName" name="name">
    </div>
  </div>
  <div class="form-group">
    <label for="fieldEmail" class="col-sm-2 control-label">Email</label>
    <div class="col-sm-4">
      <input type="email" class="form-control" required
          id="fieldEmail" name="email">
    </div>
  </div>
  <div class="form-group">
    <div class="col-sm-offset-2 col-sm-4">
      <button type="submit" class="btn btn-primary">Register</button>
    </div>
  </div>
</form>

请注意,我们使用 Bootstrap 样式,这将贯穿整本书。如果你对 Bootstrap 不熟悉,可以参考 Bootstrap 文档

我们已经在我们的 body 解析器中添加了链接,现在我们需要为我们的通讯录注册页面、处理函数和感谢页面添加处理程序(ch08/lib/handlers.js在伴随代码库中):

exports.newsletterSignup = (req, res) => {
  // we will learn about CSRF later...for now, we just
  // provide a dummy value
  res.render('newsletter-signup', { csrf: 'CSRF token goes here' })
}
exports.newsletterSignupProcess = (req, res) => {
  console.log('Form (from querystring): ' + req.query.form)
  console.log('CSRF token (from hidden form field): ' + req.body._csrf)
  console.log('Name (from visible form field): ' + req.body.name)
  console.log('Email (from visible form field): ' + req.body.email)
  res.redirect(303, '/newsletter-signup/thank-you')
}
exports.newsletterSignupThankYou = (req, res) =>
  res.render('newsletter-signup-thank-you')

(如果还没有,请创建一个 views/newsletter-signup-thank-you.handlebars 文件。)

最后,我们将我们的处理程序链接到我们的应用程序中(ch08/meadowlark.js在伴随代码库中):

app.get('/newsletter-signup', handlers.newsletterSignup)
app.post('/newsletter-signup/process', handlers.newsletterSignupProcess)
app.get('/newsletter-signup/thank-you', handlers.newsletterSignupThankYou)

就是这么简单。请注意,在我们的处理程序中,我们正在重定向到“感谢”视图。我们可以在这里渲染一个视图,但如果这样做,访问者浏览器中的 URL 字段将保持 /process,这可能会令人困惑。发出重定向可以解决这个问题。

注意

在这种情况下,使用 303(或 302)重定向而不是 301 重定向非常重要。301 重定向是“永久”的,这意味着您的浏览器可能会缓存重定向目标。如果您使用 301 重定向并尝试第二次提交表单,您的浏览器可能会完全跳过 /process 处理程序,直接转到 /thank-you,因为它正确地认为重定向是永久的。另一方面,303 重定向告诉您的浏览器:“是的,您的请求有效,您可以在这里找到您的响应”,并且不缓存重定向目标。

大多数前端框架更倾向于使用 fetch API 发送 JSON 格式的表单数据,我们接下来将看一下这个。不过,默认情况下了解浏览器如何处理表单提交仍然很重要,因为你仍然会发现以这种方式实现的表单。

现在让我们关注使用 fetch 进行表单提交。

使用 Fetch 发送表单数据

使用 fetch API 发送 JSON 编码的表单数据是一种更现代的方法,可以更好地控制客户端/服务器通信,并减少页面刷新。

因为我们不再向服务器发起往返请求,所以不再需要担心重定向和多个用户 URL(我们仍然会为表单处理本身有一个单独的 URL),因此,我们将整个“通讯录注册体验”统一到一个称为 /newsletter 的单一 URL 下。

让我们从前端代码开始。HTML 表单本身的内容无需更改(字段和布局都相同),但我们不需要指定 actionmethod,并且我们将在一个容器 <div> 元素中包装我们的表单,这样可以更容易地显示我们的“感谢”消息:

<div id="newsletterSignupFormContainer">
  <form class="form-horizontal role="form" id="newsletterSignupForm">
    <!-- the rest of the form contents are the same... -->
  </form>
</div>

然后我们将有一个脚本来拦截表单提交事件并取消它(使用 Event#preventDefault),这样我们就可以自己处理表单处理(ch08/views/newsletter.handlebars在伴随代码库中):

<script>
  document.getElementById('newsletterSignupForm')
    .addEventListener('submit', evt => {
      evt.preventDefault()
      const form = evt.target
      const body = JSON.stringify({
        _csrf: form.elements._csrf.value,
        name: form.elements.name.value,
        email: form.elements.email.value,
      })
      const headers = { 'Content-Type': 'application/json' }
      const container =
        document.getElementById('newsletterSignupFormContainer')
      fetch('/api/newsletter-signup', { method: 'post', body, headers })
        .then(resp => {
          if(resp.status < 200 || resp.status >= 300)
            throw new Error(`Request failed with status ${resp.status}`)
          return resp.json()
        })
        .then(json => {
          container.innerHTML = '<b>Thank you for signing up!</b>'
        })
        .catch(err => {
          container.innerHTML = `<b>We're sorry, we had a problem ` +
            `signing you up. Please <a href="/newsletter">try again</a>`
        })
  })
</script>

现在在我们的服务器文件(meadowlark.js)中,请确保我们链接了可以解析 JSON 主体的中间件,然后我们指定我们的两个端点:

app.use(bodyParser.json())

//...

app.get('/newsletter', handlers.newsletter)
app.post('/api/newsletter-signup', handlers.api.newsletterSignup)

请注意,我们将我们的表单处理端点放在以 api 开头的 URL 上;这是一种区分用户(浏览器)端点和 API 端点的常用技术,后者旨在使用 fetch 访问。

现在我们将这些端点添加到我们的 lib/handlers.js 文件中:

exports.newsletter = (req, res) => {
  // we will learn about CSRF later...for now, we just
  // provide a dummy value
  res.render('newsletter', { csrf: 'CSRF token goes here' })
}
exports.api = {
  newsletterSignup: (req, res) => {
    console.log('CSRF token (from hidden form field): ' + req.body._csrf)
    console.log('Name (from visible form field): ' + req.body.name)
    console.log('Email (from visible form field): ' + req.body.email)
    res.send({ result: 'success' })
  },
}

我们可以在表单处理处理器中进行任何我们需要的处理;通常情况下,我们会将数据保存到数据库中。如果出现问题,我们会返回一个带有 err 属性的 JSON 对象(而不是 result: *success*)。

提示

在这个例子中,我们假设所有的 Ajax 请求都希望得到 JSON 响应,但并不要求 Ajax 必须使用 JSON 进行通信(事实上,Ajax 曾经是一个首字母缩略词,其中的 “X” 代表 XML)。这种方式非常适合 JavaScript,因为 JavaScript 擅长处理 JSON 数据。如果你的 Ajax 端点是通用的或者你知道你的 Ajax 请求可能使用除 JSON 以外的其他内容,你应该根据 Accepts 头部返回合适的响应,你可以通过 req.accepts 辅助方法方便地访问它。如果你仅仅基于 Accepts 头部来响应请求,你可能还会想要查看 res.format,这是一个方便的方法,可以根据客户端的期望方便地响应适当的内容。如果这样做,你需要确保在使用 JavaScript 发送 Ajax 请求时设置 dataTypeaccepts 属性。

文件上传

我们已经提到文件上传会带来一系列复杂性问题。幸运的是,有一些很棒的项目可以帮助简化文件处理流程。

有四个流行且强大的选项用于处理多部分表单:busboymultipartyformidablemulter。我使用过这四种方式,它们都很不错,但我觉得 multiparty 维护得最好,因此我们将在这里使用它。

让我们为 Meadowlark Travel 的度假照片比赛创建一个文件上传表单 (views/contest/vacation-photo.handlebars):

<h2>Vacation Photo Contest</h2>

<form class="form-horizontal" role="form"
    enctype="multipart/form-data" method="POST"
    action="/contest/vacation-photo/{{year}}/{{month}}">
  <input type="hidden" name="_csrf" value="{{csrf}}">
  <div class="form-group">
    <label for="fieldName" class="col-sm-2 control-label">Name</label>
    <div class="col-sm-4">
      <input type="text" class="form-control"
      id="fieldName" name="name">
    </div>
  </div>
  <div class="form-group">
    <label for="fieldEmail" class="col-sm-2 control-label">Email</label>
    <div class="col-sm-4">
      <input type="email" class="form-control" required
          id="fieldEmail" name="email">
    </div>
  </div>
  <div class="form-group">
    <label for="fieldPhoto" class="col-sm-2 control-label">Vacation photo</label>
    <div class="col-sm-4">
      <input type="file" class="form-control" required  accept="image/*"
          id="fieldPhoto" name="photo">
    </div>
  </div>
  <div class="form-group">
    <div class="col-sm-offset-2 col-sm-4">
      <button type="submit" class="btn btn-primary">Register</button>
    </div>
  </div>
</form>

注意,我们必须指定 enctype="multipart/form-data" 以启用文件上传。我们还可以通过使用 accept 属性(可选)来限制可以上传的文件类型。

现在我们需要创建路由处理程序,但我们面临一个困境。我们希望能够轻松地测试我们的路由处理程序,但多部分表单处理会增加复杂性(与我们在到达处理程序之前使用中间件处理其他类型的请求主体类似)。由于我们不希望自己测试多部分表单解码过程(我们可以假设 multiparty 已经彻底处理了这个过程),我们将通过将已处理的信息传递给处理程序来保持它们的“纯洁性”。由于我们还不知道这个过程的具体情况,我们将从 meadowlark.js 中的 Express 框架开始:

const multiparty = require('multiparty')

app.post('/contest/vacation-photo/:year/:month', (req, res) => {
  const form = new multiparty.Form()
  form.parse(req, (err, fields, files) => {
    if(err) return res.status(500).send({ error: err.message })
    handlers.vacationPhotoContestProcess(req, res, fields, files)
  })
})

我们使用 multipartyparse 方法将请求数据解析成数据字段和文件。这个方法会将文件存储在服务器上的临时目录中,并将相关信息返回到传递的 files 数组中。

现在我们有了额外的信息可以传递给我们(可测试的)路由处理程序:字段(由于我们使用了不同的 body 解析器,所以它们不会像之前的例子一样在req.body中)以及收集到的文件的信息。现在我们知道它看起来是什么样子,我们可以编写我们的路由处理程序:

exports.vacationPhotoContestProcess = (req, res, fields, files) => {
  console.log('field data: ', fields)
  console.log('files: ', files)
  res.redirect(303, '/contest/vacation-photo-thank-you')
}

(年份和月份被指定为路由参数,你会在第十四章学到有关路由参数的知识。)请继续运行它并检查控制台日志。你会看到你的表单字段以你预期的方式传递过来:作为一个对象,拥有与字段名对应的属性。files对象包含更多数据,但相对来说比较简单。对于每个上传的文件,你会看到有关大小、上传路径(通常是临时目录中的随机名称)以及用户上传的文件的原始名称(只是文件名,而不是整个路径,出于安全和隐私考虑)的属性。

你对这个文件的处理现在取决于你:你可以将它存储在数据库中,复制到更永久的位置,或者上传到基于云的文件存储系统。请记住,如果你依赖于本地存储来保存文件,你的应用程序将无法很好地扩展,这对基于云的主机来说是一个不好的选择。我们将在第十三章再次讨论这个例子。

使用 Fetch 进行文件上传

令人高兴的是,使用fetch进行文件上传几乎与让浏览器处理一样。文件上传的辛苦工作实际上在于编码,而这些都在中间件中被处理。

考虑使用以下 JavaScript 使用fetch发送我们的表单内容:

<script>
  document.getElementById('vacationPhotoContestForm')
    .addEventListener('submit', evt => {
      evt.preventDefault()
      const body = new FormData(evt.target)
      const container =
        document.getElementById('vacationPhotoContestFormContainer')
      const url = '/api/vacation-photo-contest/{{year}}/{{month}}'
      fetch(url, { method: 'post', body })
        .then(resp => {
          if(resp.status < 200 || resp.status >= 300)
            throw new Error(`Request failed with status ${resp.status}`)
          return resp.json()
        })
        .then(json => {
          container.innerHTML = '<b>Thank you for submitting your photo!</b>'
        })
        .catch(err => {
          container.innerHTML = `<b>We're sorry, we had a problem processing ` +
            `your submission.  Please <a href="/newsletter">try again</a>`
        })
    })
</script>

这里需要注意的重要细节是我们将表单元素转换为FormData对象,而fetch可以直接接受该对象作为请求体。就是这么简单!因为编码方式与我们让浏览器处理时完全相同,所以我们的处理程序几乎完全相同。我们只希望返回一个 JSON 响应,而不是重定向:

exports.api.vacationPhotoContest = (req, res, fields, files) => {
  console.log('field data: ', fields)
  console.log('files: ', files)
  res.send({ result: 'success' })
}

改善文件上传 UI

从 UI 角度来看,浏览器内置的文件上传控件可以说有些欠缺。你可能已经看到过拖放界面和样式更有吸引力的文件上传按钮。

好消息是,你在这里学到的技术几乎都适用于大多数流行的“花哨”文件上传组件。归根结底,大部分都是在同一个表单上传机制上面打了一个漂亮的外壳。

一些最受欢迎的文件上传前端如下所示:

  • jQuery 文件上传

  • Uppy(这款产品的好处是支持许多热门的上传目标)

  • 带预览的文件上传(这个可以让你完全控制;你可以访问文件对象数组,然后用它们构建FormData对象,再与fetch一起使用)

结论

在本章中,您学习了用于处理表单的各种技术。我们探讨了传统的浏览器处理表单的方式(让浏览器向服务器发出POST请求,包含表单内容并渲染来自服务器的响应,通常是重定向),以及越来越普遍的方法,即防止浏览器提交表单并使用fetch自行处理。

我们学习了表单的常见编码方式:

application/x-www-form-urlencoded

默认且易于使用的编码方式,通常与传统表单处理相关联

application/json

用于使用fetch发送的(非文件)数据的常见方式

multipart/form-data

用于传输文件时使用的编码方式

现在我们已经介绍了如何将用户数据发送到服务器,让我们把注意力转向cookiessessions,它们也有助于同步服务器和客户端。

第九章:Cookies 和会话

在这一章中,您将学习如何使用 cookie 和会话,通过记住他们在页面之间以及甚至浏览器会话之间的偏好,为用户提供更好的体验。

HTTP 是一个 无状态 协议。这意味着当您在浏览器中加载页面然后导航到同一个网站上的另一个页面时,无论是服务器还是浏览器都没有内在的方式知道它是同一个浏览器访问同一个站点。另一种说法是,网络工作的方式是 每个 HTTP 请求包含了服务器满足请求所需的所有信息

然而,这是一个问题:如果故事就此结束,我们将永远无法登录任何东西。流媒体无法工作。网站将无法记住您在一个页面上的首选项。因此,需要一种方法在 HTTP 之上构建状态,这就是 cookie 和会话进入画面的地方。

遗憾的是,由于人们对 cookie 所做的恶劣之事,cookie 不幸地名声扫地。这很不幸,因为 cookie 对于“现代网络”的运行非常重要(尽管 HTML5 已经引入了一些新特性,比如本地存储,可以用于相同的目的)。

cookie 的想法很简单:服务器发送一些信息,浏览器在一定的时间内存储它。实际上,特定的信息取决于服务器。通常只是一个标识特定浏览器的唯一 ID 号码,以便保持状态的假象。

关于 cookie,有一些重要的事情需要知道:

用户能够看到 cookie,而不是保密的。

服务器发送给客户端的所有 cookie 都可以供客户端查看。你当然可以发送一些加密内容以保护其内容,但很少有这样的必要(至少如果你没有做一些恶劣的事情的话!)。签名 cookie(稍后我们会讨论)可以使 cookie 的内容变得模糊,但这绝对不能从密码窥视者的眼里得到有效的加密保护。

用户可以删除或不允许 cookie

用户对 cookie 拥有完全的控制权,浏览器使得批量或单独删除 cookie 成为可能。除非您没安好心,否则用户没有真正的理由这样做,但在测试过程中这是有用的。用户也可以拒绝 cookie,这更为麻烦,因为只有最简单的 web 应用程序可以在没有 cookie 的情况下运行。

普通的 cookie 可以被篡改。

每当浏览器向您的服务器发出具有相关 cookie 的请求,并您盲目信任该 cookie 的内容时,您就会容易遭受攻击。例如,执行包含在 cookie 中的代码是极其愚蠢的。要确保 cookie 不被篡改,可以使用签名 cookie。

cookie 可以用于攻击。

近年来出现了一类名为 跨站脚本攻击(XSS)的攻击。XSS 攻击的一种技术涉及恶意 JavaScript 修改 cookies 的内容。这是不信任回到服务器的 cookie 内容的另一个原因。使用签名 cookies 有助于(篡改会在签名 cookie 中显示出来,无论是用户还是恶意 JavaScript 修改了它),还有一个设置指定只有服务器可以修改 cookies。这些 cookies 的用处可能有限,但确实更安全。

用户会注意到如果你滥用 cookies。

如果在用户计算机上设置了大量的 cookies 或存储了大量数据,会让用户感到恼火,这是你应该避免的事情。尽量将你对 cookies 的使用保持在最低限度。

建议优先选择会话而非 cookies。

大部分情况下,你可以使用 会话(sessions) 来维护状态,这通常是明智的选择。这样做更容易,你不必担心滥用用户的存储空间,而且更安全。当然,会话依赖于 cookies,但有了会话,Express 将为你做大部分繁重的工作。

注意

Cookies 并非神奇:当服务器希望客户端存储一个 cookie 时,它发送一个名为 Set-Cookie 的头部,包含名称/值对;当客户端向服务器发送请求时,如果有 cookie,它会发送多个 Cookie 请求头,包含 cookie 的值。

外部化凭据

要使 cookies 安全,需要一个 cookie 密钥。Cookie 密钥是服务器知道并用于在发送到客户端之前加密安全 cookies 的字符串。这不是必须记住的密码,因此可以只是一个随机字符串。我通常使用 受 xkcd 启发的随机密码生成器 生成 cookie 密钥,或者仅仅使用一个随机数。

外部化第三方凭据,如 cookie 密钥、数据库密码和 API 令牌(Twitter、Facebook 等),是一种常见做法。这不仅简化了维护(通过轻松定位和更新凭据),还允许你将凭据文件从版本控制系统中排除。对于托管在 GitHub 或其他公共源代码控制库上的开源存储库尤为重要。

为此,我们将在一个 JSON 文件中外部化我们的凭据。创建一个名为 .credentials.development.json 的文件:

{
  "cookieSecret": "...your cookie secret goes here"
}

这将是我们开发工作的凭据文件。通过这种方式,你可以为生产、测试或其他环境拥有不同的凭据文件,这将非常方便。

我们将在凭据文件的顶部添加一个抽象层,以便在应用程序扩展时更容易管理我们的依赖关系。我们的版本将非常简单。创建一个名为 config.js 的文件:

const env = process.env.NODE_ENV || 'development'
const credentials = require(`./.credentials.${env}`)
module.exports = { credentials }

现在,为了确保我们不会意外地将凭据添加到我们的代码库中,请将*.credentials.*添加到您的.gitignore文件中。要将凭据导入到您的应用程序中,您只需要这样做:

const { credentials } = require('./config')

以后我们将使用同一文件存储其他凭据,但现在我们只需要我们的 cookie 密钥。

注意

如果您按照伴随仓库的方式进行操作,则必须创建自己的凭据文件,因为它未包含在仓库中。

Express 中的 Cookies

在应用程序中设置和访问 cookie 之前,您需要包含cookie-parser中间件。首先,使用npm install cookie-parser,然后(在伴随仓库中的ch09/meadowlark.js中):

const cookieParser = require('cookie-parser')
app.use(cookieParser(credentials.cookieSecret))

完成此操作后,您可以在任何可以访问响应对象的地方设置 cookie 或已签名的 cookie:

res.cookie('monster', 'nom nom')
res.cookie('signed_monster', 'nom nom', { signed: true })
注意

签名 cookie 优先于未签名 cookie。如果您将签名 cookie 命名为signed_monster,则不能同时拥有同名的未签名 cookie(它将返回undefined)。

要检索从客户端发送的 cookie 的值(如果有),只需访问请求对象的cookiesignedCookie属性:

const monster = req.cookies.monster
const signedMonster = req.signedCookies.signed_monster
注意

您可以为 cookie 名称使用任何字符串。例如,我们可以使用\'signed monster'而不是\'signed_monster',但然后我们将不得不使用括号表示法来检索 cookie:req.signedCookies[\'signed monster']。因此,建议使用不带特殊字符的 cookie 名称。

要删除 cookie,请使用req.clearCookie

res.clearCookie('monster')

设置 cookie 时,可以指定以下选项:

domain

控制 cookie 关联的域;这允许您将 cookie 分配给特定的子域。请注意,您不能为与服务器运行的不同域设置 cookie;它将简单地无效。

path

控制此 cookie 适用的路径。请注意,路径后面隐含有通配符;如果使用路径/*(默认),它将适用于站点上的所有页面。如果使用路径/foo*,它将适用于路径/foo*/foo/bar*等。

maxAge

指定客户端在删除 cookie 之前应保留该 cookie 的时间,单位为毫秒。如果您省略此项,则在关闭浏览器时将删除 cookie(您还可以使用expires选项指定到期日期,但语法很令人沮丧。建议使用maxAge)。

secure

指定此 cookie 仅在安全(HTTPS)连接上发送。

httpOnly

将此设置为true指定该 cookie 仅由服务器修改。也就是说,客户端 JavaScript 无法修改它。这有助于防止 XSS 攻击。

signed

将此设置为true将签名此 cookie,使其可在res.signedCookies中而不是res.cookies中使用。已篡改的签名 cookie 将被服务器拒绝,并且 cookie 值将重置为其原始值。

检查 Cookie

作为测试的一部分,您可能需要一种方法来检查系统上的 cookie。大多数浏览器都有查看单个 cookie 及其存储值的方法。在 Chrome 中,打开开发者工具,选择“应用程序”选项卡。在左侧的树状菜单中,您将看到“Cookie”。展开它,您将看到列出的当前访问站点。点击它,您将看到与该站点相关的所有 cookie。您还可以右键单击域名以清除所有 cookie,或右键单击单个 cookie 以具体删除它。

会话

会话实际上只是一种更方便的维护状态的方式。要实现会话,必须在客户端存储一些内容;否则,服务器将无法从一个请求识别客户端到下一个请求。通常的做法是使用包含唯一标识符的 cookie。然后服务器使用该标识符检索相应的会话信息。

Cookie 不是实现这一功能的唯一方式:在“cookie 恐慌”高峰期间(即 cookie 滥用时期),许多用户简单地禁用了 cookie,并且开发了其他维护状态的方法,比如将会话信息装饰在 URL 中。这些技术混乱、困难且效率低下,最好留在过去。HTML5 提供了另一种称为本地存储的会话选项,如果需要存储大量数据则优于 cookie。有关此选项的更多信息,请参见MDN 文档中的 Window.localStorage

广义上讲,有两种实现会话的方式:在 cookie 中存储所有信息或者仅在 cookie 中存储唯一标识符,其他信息存储在服务器上。前者被称为基于 cookie 的会话,仅仅是相对于使用 cookie 更为便利的一种方式。然而,这仍意味着你添加到会话中的所有内容都将存储在客户端的浏览器中,这是我不推荐的一种做法。我只推荐在你知道将只存储少量信息、不介意用户访问这些信息以及信息不会随时间过多增长的情况下使用这种方式。如果你想采用这种方式,请参见cookie-session 中间件

记忆存储

如果您更愿意将会话信息存储在服务器上,我建议您这样做,您必须有一个地方来存储它。入门级选项是内存会话。它们易于设置,但有一个巨大的缺点:当您重新启动服务器(在本书的过程中您将经常这样做!),您的会话信息会消失。更糟糕的是,如果您通过多个服务器进行扩展(参见第十二章),不同的服务器可能会每次服务一个请求;会话数据有时会存在,有时会不存在。这显然是无法接受的用户体验。然而,对于我们的开发和测试需求,它是足够的。我们将看到如何在第十三章中永久存储会话信息。

首先,安装express-sessionnpm install express-session);然后,在链接 cookie 解析器之后,链接express-sessionch09/meadowalrk.js在伴随存储库中):

const expressSession = require('express-session')
// make sure you've linked in cookie middleware before
// session middleware!
app.use(expressSession({
    resave: false,
    saveUninitialized: false,
    secret: credentials.cookieSecret,
}))

express-session 中间件接受带有以下选项的配置对象:

resave

即使请求未被修改,也强制将会话保存回存储中。通常情况下,将其设置为false更可取;有关更多信息,请参阅express-session文档。

saveUninitialized

将其设置为true会导致新的(未初始化的)会话保存到存储中,即使它们没有被修改。通常情况下,将其设置为false更可取,并且在需要在设置 cookie 之前获取用户许可时是必需的。有关更多信息,请参阅express-session文档。

secret

用于签名会话 ID cookie 的密钥(或键)。这可以是用于cookie-parser的相同密钥。

key

将存储唯一会话标识符的 cookie 的名称。默认为connect.sid

store

一个会话存储的实例。默认为MemoryStore的实例,对于我们当前的目的来说是可以的。我们将看到如何在第十三章中使用数据库存储。

cookie

会话 cookie 的 cookie 设置(pathdomainsecure等)。常规 cookie 默认值适用。

使用会话

设置了会话后,使用它们变得非常简单;只需使用请求对象的session变量的属性:

req.session.userName = 'Anonymous'
const colorScheme = req.session.colorScheme || 'dark'

注意,使用会话时,我们无需使用请求对象来检索值,并使用响应对象来设置值;所有操作都在请求对象上执行。(响应对象没有session属性。)要删除会话,您可以使用 JavaScript 的delete操作符:

req.session.userName = null       // this sets 'userName' to null,
                                  // but doesn't remove it

delete req.session.colorScheme    // this removes 'colorScheme'

使用会话实现闪存消息

闪存消息(不要与 Adobe Flash 混淆)只是一种为用户提供反馈的方式,不会干扰他们的导航。实施闪存消息的最简单方法是使用会话(也可以使用查询字符串,但除了这些会使 URL 更丑陋外,闪存消息还将包含在书签中,这可能不是您想要的)。现在让我们首先设置我们的 HTML。我们将使用 Bootstrap 的警告消息来显示我们的闪存消息,所以确保您已经链接了 Bootstrap(请参阅 Bootstrap 的“入门”文档;您可以在主模板中链接 Bootstrap 的 CSS 和 JavaScript 文件——附录中有一个示例)。在您的模板文件中,通常是在站点标题直接下方的显著位置,添加以下内容:

{{#if flash}}
  <div class="alert alert-dismissible alert-{{flash.type}}">
    <button type="button" class="close"
      data-dismiss="alert" aria-hidden="true">&times;</button>
    <strong>{{flash.intro}}</strong> {{{flash.message}}}
  </div>
{{/if}}

请注意,我们使用三个大括号来表示flash.message;这将允许我们在消息中提供一些简单的 HTML(我们可能想要强调单词或包含超链接)。现在让我们添加一些中间件来在会话中添加flash对象到上下文中。一旦我们显示了一条闪存消息,我们希望将其从会话中删除,以便在下一个请求中不显示它。我们将创建一些中间件来检查会话,看看是否有闪存消息,如果有,将其传输到res.locals对象中,使其在视图中可用。我们将把我们的中间件放在一个名为lib/middleware/flash.js的文件中:

module.exports = (req, res, next) => {
  // if there's a flash message, transfer
  // it to the context, then clear it
  res.locals.flash = req.session.flash
  delete req.session.flash
  next()
})

而在我们的meadowalrk.js文件中,在任何视图路由之前,我们将链接到闪存消息中间件:

const flashMiddleware = require('./lib/middleware/flash')
app.use(flashMiddleware)

现在让我们看看如何实际使用闪存消息。想象一下,我们正在注册用户的通讯,我们希望在他们注册后将他们重定向到通讯存档。这是我们的表单处理程序可能看起来像这样:

// slightly modified version of the official W3C HTML5 email regex:
// https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address
const VALID_EMAIL_REGEX = new RegExp('^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@' +
  'a-zA-Z0-9?' +
  '(?:\.a-zA-Z0-9?)+$')

app.post('/newsletter', function(req, res){
    const name = req.body.name || '', email = req.body.email || ''
    // input validation
    if(VALID_EMAIL_REGEX.test(email)) {
      req.session.flash = {
        type: 'danger',
        intro: 'Validation error!',
        message: 'The email address you entered was not valid.',
      }
      return res.redirect(303, '/newsletter')
    }
    // NewsletterSignup is an example of an object you might create; since
    // every implementation will vary, it is up to you to write these
    // project-specific interfaces. This simply shows how a typical
    // Express implementation might look in your project.
    new NewsletterSignup({ name, email }).save((err) => {
        if(err) {
          req.session.flash = {
            type: 'danger',
            intro: 'Database error!',
            message: 'There was a database error; please try again later.',
          }
          return res.redirect(303, '/newsletter/archive')
        }
        req.session.flash = {
          type: 'success',
          intro: 'Thank you!',
          message: 'You have now been signed up for the newsletter.',
        };
        return res.redirect(303, '/newsletter/archive')
    })
})

请注意,我们要仔细区分输入验证和数据库错误。请记住,即使我们在前端进行了输入验证(您应该这样做),您也应该在后端执行它,因为恶意用户可以绕过前端验证。

闪存消息是您网站中可以使用的一个很棒的机制,即使在某些领域中其他方法更合适(例如,闪存消息并不总是适合多表单“向导”或购物车结账流程)。在开发过程中,闪存消息也非常好用,因为它们是一种提供反馈的简单方法,即使稍后用不同的技术替换它们。在设置网站时,支持闪存消息是我做的第一件事情之一,本书的其余部分我们将继续使用这种技术。

提示

由于闪存消息是在中间件中从会话传输到res.locals.flash,因此您必须执行重定向才能显示闪存消息。如果要在不重定向的情况下显示闪存消息,请设置res.locals.flash而不是req.session.flash

注意

本章的示例使用了浏览器表单提交和重定向,因为使用 sessions 来控制 UI 通常不适用于使用 Ajax 进行表单提交的应用程序。在这种情况下,您希望从表单处理程序返回的 JSON 中指示任何错误,并让前端修改 DOM 以动态显示错误消息。这并不意味着 sessions 在前端渲染应用程序中没有用处,但它们很少用于此目的。

何时使用 sessions

当您希望保存跨页面适用的用户偏好时,sessions 非常有用。最常见的用途是提供用户认证信息:您登录后,会话就会被创建。之后,每次重新加载页面时,您都不必再次登录。即使没有用户账户,sessions 也可以很有用。网站通常会记住您喜欢的排序方式或日期格式偏好,所有这些都无需您登录。

虽然我鼓励您更喜欢 sessions 而不是 cookies,但理解 cookies 的工作原理很重要(特别是因为它们使 sessions 能够工作)。这将帮助您诊断问题,并了解应用程序的安全性和隐私考虑。

结论

理解cookiessessions有助于我们更好地理解 Web 应用在底层协议(HTTP)无状态的情况下如何维持状态的错觉。我们学习了处理 cookies 和 sessions 的技术,以控制用户的体验。

在我们逐步进行中编写中间件时,我们一直没有太多解释中间件的内容。在下一章中,我们将深入探讨中间件,并学习有关它的一切!

第十章:中间件

到目前为止,我们已经接触了一些中间件:我们使用了现有的中间件(body-parsercookie-parserstaticexpress-session 等),甚至编写了一些自己的中间件(用于向模板上下文添加天气数据、配置闪存消息以及我们的 404 处理程序)。但是,中间件到底是什么?

从概念上讲,中间件是一种封装功能的方式——具体来说,是对应用程序的 HTTP 请求操作的功能。实际上,中间件只是一个接受三个参数的函数:一个请求对象,一个响应对象和一个 next() 函数,后者将在稍后解释。(还有一种形式接受四个参数,用于错误处理,将在本章末尾介绍。)

中间件是在所谓的管道中执行的。你可以想象一个物理管道,里面装着水。水从一端泵入,然后在水到达目的地之前会经过仪表和阀门。这个类比的重要之处在于顺序很重要;如果你在阀门前放置压力计,与在阀门后放置压力计效果不同。同样地,如果你有一个向水中注入东西的阀门,那么从该阀门“下游”的所有内容都将包含添加的成分。在 Express 应用中,你可以通过调用 app.use 将中间件插入到管道中。

在 Express 4.0 之前,通过显式链接路由器来复杂化管道。根据你在何处链接路由器,当你混合中间件和路由处理程序时,路由可能会链接进出顺序不一致,使得管道序列不太清晰。在 Express 4.0 中,中间件和路由处理程序按照链接的顺序调用,使得顺序更加清晰。

通常的做法是在你的管道中将最后一个中间件设置为一个捕获所有请求的处理程序,即任何不匹配其他路由的请求。这个中间件通常返回状态码 404(未找到)。

那么请求如何在管道中“终止”呢?这就是传递给每个中间件的 next 函数所做的事情:如果你调用 next(),请求将在那个中间件中终止。

中间件原则

灵活思考中间件和路由处理程序的方式是理解 Express 工作原理的关键。以下是你应该记住的几点:

  • 路由处理程序(app.getapp.post 等——通常统称为 app.METHOD)可以被视为仅处理特定 HTTP 动词(GETPOST 等)的中间件。相反,中间件可以被视为处理所有 HTTP 动词的路由处理程序(这基本上等同于 app.all,处理任何 HTTP 动词;对于像 PURGE 这样的特殊动词,有一些细微差别,但对于常见动词来说效果相同)。

  • 路由处理程序需要作为第一个参数的路径。如果你希望该路径匹配任何路由,请简单地使用\*。中间件也可以接受路径作为其第一个参数,但这是可选的(如果省略,则将匹配任何路径,就像你指定了*一样)。

  • 路由处理程序和中间件接受一个回调函数,该函数接受两个、三个或四个参数(技术上,也可以有零个或一个参数,但这些形式没有合理的用途)。如果有两个或三个参数,第一个参数是请求和响应对象,第三个参数是next函数。如果有四个参数,它就成为错误处理中间件,第一个参数是错误对象,后面是请求、响应和下一个对象。

  • 如果你没有调用next(),管道将被终止,不会再处理更多的路由处理程序或中间件。如果你没有调用next(),应向客户端发送响应(res.sendres.jsonres.render 等);如果不这样做,客户端将挂起并最终超时。

  • 如果你调用next(),向客户端发送响应通常是不可取的。如果这样做,后续的中间件或路由处理程序将被执行,但它们发送的任何客户端响应将被忽略。

中间件示例

如果你想看看它的实际效果,让我们试试一些非常简单的中间件(ch10/00-simple-middleware.js 在伴随库中):

app.use((req, res, next) => {
  console.log(`processing request for ${req.url}....`)
  next()
})

app.use((req, res, next) => {
  console.log('terminating request')
  res.send('thanks for playing!')
  // note that we do NOT call next() here...this terminates the request
})

app.use((req, res, next) => {
  console.log(`whoops, i'll never get called!`)
})

这里有三个中间件示例。第一个只是将消息记录到控制台,然后通过调用next()将请求传递给管道中的下一个中间件。然后,下一个中间件实际处理请求。注意,如果这里省略了res.send,将不会向客户端返回任何响应。最终,客户端会超时。最后一个中间件永远不会执行,因为在前一个中间件中终止了所有请求。

现在让我们考虑一个更复杂的完整示例(ch10/01-routing-example.js 在伴随库中):

const express = require('express')
const app = express()

app.use((req, res, next) => {
  console.log('\n\nALLWAYS')
  next()
})

app.get('/a', (req, res) => {
  console.log('/a: route terminated')
  res.send('a')
})
app.get('/a', (req, res) => {
  console.log('/a: never called');
})
app.get('/b', (req, res, next) => {
  console.log('/b: route not terminated')
  next()
})
app.use((req, res, next) => {
  console.log('SOMETIMES')
  next()
})
app.get('/b', (req, res, next) => {
  console.log('/b (part 2): error thrown' )
  throw new Error('b failed')
})
app.use('/b', (err, req, res, next) => {
  console.log('/b error detected and passed on')
  next(err)
})
app.get('/c', (err, req) => {
  console.log('/c: error thrown')
  throw new Error('c failed')
})
app.use('/c', (err, req, res, next) => {
  console.log('/c: error detected but not passed on')
  next()
})

app.use((err, req, res, next) => {
  console.log('unhandled error detected: ' + err.message)
  res.send('500 - server error')
})

app.use((req, res) => {
  console.log('route not handled')
  res.send('404 - not found')
})

const port = process.env.PORT || 3000
app.listen(port, () => console.log( `Express started on http://localhost:${port}` +
  '; press Ctrl-C to terminate.'))

在尝试此示例之前,请想象一下结果会是什么。有哪些不同的路由?客户端会看到什么?控制台上会打印什么?如果你能正确回答所有这些问题,你就掌握了 Express 中的路由!特别注意请求到 /b/c 的区别;在两种情况下都有错误,但一个会导致 404,另一个会导致 500。

注意中间件必须是一个函数。请记住,在 JavaScript 中,从函数中返回一个函数非常容易(也很常见)。例如,你会注意到express.static是一个函数,但我们实际上调用它,所以它必须返回另一个函数。考虑以下示例:

app.use(express.static)         // this will NOT work as expected

console.log(express.static())   // will log "function", indicating
                                // that express.static is a function
                                // that itself returns a function

还要注意,一个模块可以导出一个函数,该函数可以直接用作中间件。例如,这里有一个名为 lib/tourRequiresWaiver.js 的模块(Meadowlark Travel 的攀岩套餐需要一份免责声明):

module.exports = (req,res,next) => {
  const { cart } = req.session
  if(!cart) return next()
  if(cart.items.some(item => item.product.requiresWaiver)) {
    cart.warnings.push('One or more of your selected ' +
      'tours requires a waiver.')
  }
  next()
}

我们可以像这样链接这个中间件(在伴随代码库中的 ch10/02-item-waiver.example.js 中):

const requiresWaiver = require('./lib/tourRequiresWaiver')
app.use(requiresWaiver)

更常见的做法是,你会导出一个包含中间件属性的对象。例如,让我们把所有购物车验证代码放在 lib/cartValidation.js 中:

module.exports = {

  resetValidation(req, res, next) {
    const { cart } = req.session
    if(cart) cart.warnings = cart.errors = []
    next()
  },

  checkWaivers(req, res, next) {
    const { cart } = req.session
    if(!cart) return next()
    if(cart.items.some(item => item.product.requiresWaiver)) {
      cart.warnings.push('One or more of your selected ' +
        'tours requires a waiver.')
    }
    next()
  },

  checkGuestCounts(req, res, next) {
    const { cart } = req.session
    if(!cart) return next()
    if(cart.items.some(item => item.guests > item.product.maxGuests )) {
      cart.errors.push('One or more of your selected tours ' +
        'cannot accommodate the number of guests you ' +
        'have selected.')
    }
    next()
  },

}

然后你可以像这样链接中间件(在伴随代码库中的 ch10/03-more-cart-validation.js 中):

const cartValidation = require('./lib/cartValidation')

app.use(cartValidation.resetValidation)
app.use(cartValidation.checkWaivers)
app.use(cartValidation.checkGuestCounts)
注意

在前面的例子中,我们有一个中间件在语句 return next() 中提前终止。Express 不期望中间件返回一个值(也不会处理任何返回值),因此这只是写 next(); return 的一种简化方式。

常见中间件

虽然 npm 上有成千上万的中间件项目,但其中只有少数几个是常见且基础的,至少在每个非平凡的 Express 项目中都能找到一些。其中一些中间件曾经如此常见,以至于它们实际上被捆绑到 Express 中,但长久以来已被移至单独的包中。目前仅有与 Express 本身捆绑的中间件是 static

这个列表试图涵盖最常见的中间件:

basicauth-middleware

提供基本的访问授权。请记住,基本认证只提供最基本的安全性,你应该仅在 HTTPS 下使用基本认证(否则用户名和密码将明文传输)。你应该仅在需要快速简单且使用 HTTPS 时才使用基本认证。

body-parser

提供 HTTP 请求体的解析。提供解析 URL 编码和 JSON 编码体以及其他类型的中间件。

busboymultipartyformidablemulter

所有这些中间件选项解析使用 multipart/form-data 编码的请求体。

compression

使用 gzip 或 deflate 压缩响应数据。这是一件好事,你的用户会感谢你,尤其是在慢速或移动连接下的用户。应该尽早链接此中间件,以防止其他可能发送响应的中间件。我唯一建议在 compress 之前链接的是调试或日志记录中间件(它们不发送响应)。请注意,在大多数生产环境中,压缩是由像 NGINX 这样的代理处理的,因此此中间件是不必要的。

cookie-parser

提供 cookie 支持。参见第九章。

cookie-session

提供基于 cookie 存储的会话支持。我一般不推荐这种会话方式。必须在 cookie-parser 之后链接此中间件。参见第九章。

express-session

提供会话 ID(存储在 cookie 中)的会话支持。默认使用内存存储,这在生产环境中并不适用,可以配置为使用数据库存储。参见第九章和第十三章。

csurf

提供对跨站点请求伪造(CSRF)攻击的防护。这个中间件使用会话,所以必须在express-session中间件之后链接。不幸的是,仅仅链接这个中间件并不能自动保护 against CSRF 攻击;更多信息请参见第十八章。

serve-index

为静态文件提供目录列表支持。除非你特别需要目录列表,否则不需要包含此中间件。

errorhandler

提供堆栈跟踪和错误消息给客户端。我不建议在生产服务器上链接此中间件,因为它暴露了实现细节,可能会出现安全或隐私问题。更多信息请参见第二十章。

serve-favicon

提供 favicon(出现在浏览器标题栏中的图标)。这并非绝对必要;你可以简单地在静态目录的根目录中放一个 favicon.ico,但这个中间件可以提高性能。如果使用,应该在中间件堆栈中放置得很靠前。它还允许你指定其他文件名而不是 favicon.ico

morgan

提供自动记录支持;所有请求都将被记录。更多信息请参见第二十章。

method-override

提供对 x-http-method-override 请求头的支持,允许浏览器“伪装”使用 HTTP 方法而不是 GETPOST。这在调试时可能会有用。这只在你编写 API 时需要。

response-time

在响应中添加 X-Response-Time 头,提供响应时间(毫秒)。除非进行性能调优,你通常不需要这个中间件。

static

提供静态(公共)文件服务支持。你可以多次链接此中间件,指定不同的目录。更多细节请参见第十七章。

vhost

虚拟主机(vhosts),这个术语是从 Apache 借鉴来的,让 Express 中的子域名更容易管理。更多信息请参见第十四章。

第三方中间件

目前,还没有一个全面的“商店”或第三方中间件的索引。几乎所有的 Express 中间件都可以在 npm 上找到,所以如果你在 npm 上搜索“Express”和“中间件”,你会得到一个相当不错的列表。官方的 Express 文档还包含一个有用的中间件列表

结论

在本章中,我们深入探讨了中间件的定义,如何编写自己的中间件,以及它在 Express 应用程序中的处理过程。如果你开始认为 Express 应用程序只是一组中间件的集合,那你就开始理解 Express 了!甚至我们迄今为止使用的路由处理程序只是中间件的特殊案例。

在下一章中,我们将看到另一个常见的基础设施需求:发送电子邮件(你最好相信这里面会涉及到一些中间件!)。

第十一章:发送电子邮件

你的应用程序与世界沟通的主要方式之一是电子邮件。从用户注册到密码重置说明再到促销电子邮件,发送电子邮件是一个重要的功能。在本章中,你将学习如何使用 Node 和 Express 格式化并发送电子邮件,以帮助与你的用户进行沟通。

Node 和 Express 都没有内置的发送电子邮件的方法,因此我们必须使用第三方模块。我推荐的包是 Andris Reinman 的优秀 Nodemailer。在我们深入配置 Nodemailer 之前,让我们先了解一些电子邮件的基础知识。

SMTP、MSA 和 MTA

发送电子邮件的通用语言是简单邮件传输协议(SMTP)。虽然可以使用 SMTP 直接将电子邮件发送到收件人的邮件服务器,但这通常不是一个好主意:除非你是像 Google 或 Yahoo! 这样的“受信任发送者”,否则你的邮件很可能会直接被投入垃圾箱。最好使用邮件提交代理(MSA),它会通过可信任的渠道传递邮件,从而减少邮件被标记为垃圾邮件的机会。除了确保你的邮件能送达,MSA 还处理像临时中断和退信等问题。整个过程的最后一部分是邮件传输代理(MTA),它是实际将邮件发送到最终目的地的服务。在本书中,MSAMTASMTP 服务器 本质上是等效的。

因此,你将需要访问一个 MSA。虽然可以使用像 Gmail、Outlook 或 Yahoo! 这样的免费消费者电子邮件服务开始工作,但这些服务不再像以前那样友好地支持自动化邮件(为了减少滥用)。幸运的是,有几个优秀的电子邮件服务可供选择,适合低频使用并提供免费选项:SendgridMailgun。我使用过这两个服务,都很喜欢。本书的示例将使用 SendGrid。

如果你在一个组织工作,组织本身可能有一个 MSA;你可以联系你的 IT 部门询问是否有可用于发送自动化电子邮件的 SMTP 中继。

如果你在使用 SendGrid 或 Mailgun,请立即设置你的账户。对于 SendGrid,你需要创建一个 API 密钥(它将作为你的 SMTP 密码)。

接收电子邮件

大多数网站只需要能够 发送 电子邮件,如密码重置说明和促销电子邮件。然而,有些应用程序也需要接收电子邮件。一个很好的例子是问题追踪系统,当有人更新问题时会发送电子邮件,如果你回复该邮件,则问题会自动更新为你的回复。

不幸的是,接收电子邮件涉及的内容要多得多,这本书不会涉及这方面。 如果这是你需要的功能,你应该允许你的邮件提供商维护邮箱,并定期使用像imap-simple这样的 IMAP 代理来访问它。

电子邮件标题

电子邮件消息由两部分组成:头部和正文(非常类似于 HTTP 请求)。 头部 包含有关电子邮件的信息:谁发送的、发给谁、收到的日期、主题等等。 这些头部通常在电子邮件应用程序中显示给用户,但还有许多其他头部。 大多数电子邮件客户端允许你查看头部; 如果你从未这样做过,我建议你试试。 头部提供了有关电子邮件如何到达你手中的所有信息; 每个经过的服务器和 MTA 都将在头部中列出。

人们经常会感到惊讶的是,某些头部,如“发件人”地址,可以由发件人任意设置。 当你指定一个与发送账户不同的“发件人”地址时,通常称为伪造。 除非你有合理的理由这样做,否则不要滥用。 有时这样做是合理的,但你不应该滥用它。

你发送的电子邮件必须有一个“发件人”地址。 但是,当发送自动化电子邮件时,有时可能会出现问题,这就是为什么你经常看到带有返回地址的电子邮件,如 DO NOT REPLY <do-not-reply@meadowlarktravel.com>。 是否采用这种方式或者让自动化邮件来自 Meadowlark Travel <info@meadowlarktravel.com> 是由你决定的;但是,如果你选择后者,你应该准备好回复发送到info@meadowlarktravel.com的电子邮件。

电子邮件格式

当互联网刚刚出现时,所有的电子邮件都是简单的 ASCII 文本。 自那时以来,世界发生了很大变化,人们希望用不同的语言发送电子邮件,并做更复杂的事情,比如包含格式化文本、图片和附件。 这就是事情开始变得混乱的地方:电子邮件的格式和编码是一堆可怕的技术和标准的混合。

幸运的是,我们不必真正去解决这些复杂性。 Nodemailer 会为我们处理这一切。 对你来说重要的是,你的电子邮件可以是纯文本(Unicode)或 HTML。

几乎所有现代电子邮件应用程序都支持 HTML 电子邮件,因此通常很安全地格式化你的电子邮件为 HTML。 尽管如此,还有一些“文本纯洁主义者”不喜欢 HTML 电子邮件,因此我建议始终包含文本和 HTML 电子邮件。 如果你不想写文本和 HTML 电子邮件,Nodemailer 支持一种快捷方式,可以从 HTML 自动生成纯文本版本。

HTML 电子邮件

HTML 邮件是一个可以填满整本书的话题。不幸的是,它并不像为您的网站编写 HTML 那样简单:大多数邮件客户端仅支持 HTML 的一个小子集。大多数时候,您必须像在 1996 年一样编写 HTML;这并不好玩。特别是,您必须重新使用表格来进行布局(播放悲伤的音乐)。

如果您有处理 HTML 浏览器兼容性问题的经验,您就知道它可能会让人头疼。电子邮件兼容性问题要糟糕得多。幸运的是,有一些东西可以帮助解决。

首先,我鼓励您阅读 MailChimp 关于撰写 HTML 邮件的文章。它很好地涵盖了基础知识,并解释了撰写 HTML 邮件时需要牢记的事项。

接下来是真正的时间节省器:HTML Email Boilerplate。它本质上是一个非常好的、经过严格测试的 HTML 邮件模板。

最后,有测试。您已经学习了如何编写 HTML 邮件,并且正在使用 HTML Email Boilerplate,但测试是确保您的电子邮件不会在 Lotus Notes 7 上爆炸的唯一方法(是的,人们仍在使用它)。感觉要安装 30 种不同的邮件客户端来测试一个电子邮件吗?我不这么认为。幸运的是,有一个很棒的服务可以为您完成:Litmus。这并不是一项便宜的服务;计划每月大约从 $100 起步。但是,如果您发送大量促销电子邮件,这是无法超越的。

另一方面,如果您的格式较为简单,就不需要像 Litmus 这样昂贵的测试服务了。如果您坚持使用标题、粗体/斜体文本、水平规则和一些图像链接等内容,您就非常安全了。

Nodemailer

首先,我们需要安装 Nodemailer 包:

npm install nodemailer

然后,需要引用 nodemailer 包并创建一个 Nodemailer 实例(在 Nodemailer 的术语中称为 transport):

const nodemailer = require('nodemailer')

const mailTransport = nodemailer.createTransport({

  auth: {
    user: credentials.sendgrid.user,
    pass: credentials.sendgrid.password,
  }
})

注意我们正在使用我们在 第九章 中设置的凭据模块。您需要相应地更新您的 .credentials.development.json 文件:

{
  "cookieSecret": "your cookie secret goes here",
  "sendgrid": {
    "user": "your sendgrid username",
    "password": "your sendgrid password"
  }
}

SMTP 的常见配置选项包括端口、认证类型和 TLS 选项。但是,大多数主要的邮件服务使用默认选项。要找出要使用的设置,请参阅您的邮件服务文档(尝试搜索 sending SMTP emailSMTP configurationSMTP relay)。如果您在发送 SMTP 邮件时遇到问题,您可能需要检查选项;请参阅 Nodemailer documentation 获取完整的支持选项列表。

注意

如果您在跟随伴随的仓库,您会发现凭据文件中没有任何设置。过去,我有很多读者联系我,问为什么文件丢失或为空。出于同样的原因,我故意不提供有效的凭据,就像您需要小心自己的凭据一样!亲爱的读者,我非常信任您,但不至于给您我的电子邮件密码!

发送邮件

现在我们有了邮件传输实例,我们可以开始发送邮件了。我们将从一个简单的例子开始,只向一个收件人发送文本邮件(ch11/00-smtp.js在伴随代码库中)。

try {
  const result = await mailTransport.sendMail({
    from: '"Meadowlark Travel" <info@meadowlarktravel.com>',
    to: 'joecustomer@gmail.com',
    subject: 'Your Meadowlark Travel Tour',
    text: 'Thank you for booking your trip with Meadowlark Travel.  ' +
      'We look forward to your visit!',
  })
  console.log('mail sent successfully: ', result)
} catch(err) {
  console.log('could not send mail: ' + err.message)
}
注意

在本节的代码示例中,我使用类似joecustomer@gmail.com的虚假电子邮件地址。为了验证目的,您可能需要将这些电子邮件地址更改为您控制的电子邮件地址,以便查看发生的情况。否则,可怜的joecustomer@gmail.com将会收到大量无意义的电子邮件!

您会注意到我们在这里处理了错误,但重要的是要理解,没有错误并不一定意味着您的电子邮件已成功发送给收件人。如果存在与 MSA 通信的问题(如网络或身份验证错误),则回调的error参数将被设置。如果 MSA 无法将电子邮件发送给收件人(例如由于无效的电子邮件地址或未知用户),则您将需要检查您邮件服务中的账户活动,您可以通过管理界面或 API 进行此操作。

如果您的系统需要自动确定电子邮件是否成功发送,请使用您邮件服务的 API。查阅您邮件服务的 API 文档获取更多信息。

向多个收件人发送邮件

Nodemail 支持使用逗号将邮件发送给多个收件人(ch11/01-multiple-recipients.js在伴随代码库中):

try {
  const result = await mailTransport.sendMail({
    from: '"Meadowlark Travel" <info@meadowlarktravel.com>',
    to: 'joe@gmail.com, "Jane Customer" <jane@yahoo.com>, ' +
      'fred@hotmail.com',
    subject: 'Your Meadowlark Travel Tour',
    text: 'Thank you for booking your trip with Meadowlark Travel.  ' +
      'We look forward to your visit!',
  })
  console.log('mail sent successfully: ', result)
} catch(err) {
  console.log('could not send mail: ' + err.message)
}

请注意,在此示例中,我们混合了普通电子邮件地址(joe@gmail.com)和指定收件人姓名的电子邮件地址(“Jane Customer” < jane@yahoo.com>)。这是允许的语法。

当向多个收件人发送电子邮件时,您必须注意观察您的邮件发送代理(MSA)的限制。例如,SendGrid 建议限制收件人数量(SendGrid 建议一封邮件中不超过一千个收件人)。如果您正在发送批量邮件,则可能希望发送多个消息,每个消息都有多个收件人(ch11/02-many-recipients.js在伴随代码库中):

// largeRecipientList is an array of email addresses
const recipientLimit = 100
const batches = largeRecipientList.reduce((batches, r) => {
  const lastBatch = batches[batches.length - 1]
  if(lastBatch.length < recipientLimit)
    lastBatch.push(r)
  else
    batches.push([r])
  return batches
}, [[]])
try {
  const results = await Promise.all(batches.map(batch =>
    mailTransport.sendMail({
      from: '"Meadowlark Travel", <info@meadowlarktravel.com>',
      to: batch.join(', '),
      subject: 'Special price on Hood River travel package!',
      text: 'Book your trip to scenic Hood River now!',
    })
  ))
  console.log(results)
} catch(err) {
  console.log('at least one email batch failed: ' + err.message)
}

更好的批量电子邮件选项

虽然您可以使用 Nodemailer 和适当的 MSA 发送批量邮件,但在选择这条路线之前应仔细考虑。负责任的电子邮件营销活动必须提供取消订阅的途径,这并不是一个微不足道的任务。乘以您维护的每个订阅列表(也许您有每周通讯和特别公告活动,例如)。这是一个最好不要重复造轮子的领域。像EmmaMailchimpCampaign Monitor等服务提供了您所需的一切,包括监控电子邮件营销活动成功的优秀工具。它们价格合理,我强烈推荐在促销邮件、通讯等方面使用它们。

发送 HTML 电子邮件

到目前为止,我们一直在发送纯文本电子邮件,但现在大多数人都希望看到一些更漂亮的东西。Nodemailer 允许您在同一封电子邮件中发送 HTML 和纯文本版本,允许电子邮件客户端选择显示哪个版本(通常是 HTML)(伴随存储库中的ch11/03-html-email.js):

const result = await mailTransport.sendMail({
  from: '"Meadowlark Travel" <info@meadowlarktravel.com>',
  to: 'joe@gmail.com, "Jane Customer" <jane@yahoo.com>, ' +
    'fred@hotmail.com',
  subject: 'Your Meadowlark Travel Tour',
  html: '<h1>Meadowlark Travel</h1>\n<p>Thanks for book your trip with ' +
    'Meadowlark Travel.  <b>We look forward to your visit!</b>',
  text: 'Thank you for booking your trip with Meadowlark Travel.  ' +
    'We look forward to your visit!',
})

提供 HTML 和纯文本版本会增加很多工作量,特别是如果您的用户中很少有人喜欢纯文本电子邮件。如果您想节省一些时间,可以在 HTML 中编写电子邮件,并使用像html-to-formatted-text这样的包自动生成文本。 (请记住,它的质量可能不如手工编写的文本高;HTML 并非总是能够干净地转换。)

HTML 电子邮件中的图像

虽然可以将图像嵌入 HTML 电子邮件,但我强烈不建议这样做。它们会使您的电子邮件消息变得臃肿,通常不被视为良好的做法。相反,您应该将要在电子邮件中使用的图像放在您的 Web 服务器上,并适当地进行链接。

最好在您的静态资产文件夹中有一个专门用于电子邮件图像的位置。您甚至应将在网站和电子邮件中都使用的资产分开。这样可以减少影响电子邮件布局的机会。

让我们在 Meadowlark Travel 项目中添加一些电子邮件资源。在public目录下创建一个名为email的子目录。您可以将logo.png放在这里,以及您想在电子邮件中使用的任何其他图像。然后,在您的电子邮件中,您可以直接使用这些图像:

<img src="//meadowlarktravel.com/email/logo.png"
  alt="Meadowlark Travel Logo">

显而易见,当向其他人发送电子邮件时,不应使用localhost;他们可能甚至没有运行服务器,更不用说在 3000 端口上了!根据您的邮件客户端,您可能可以在电子邮件中使用localhost进行测试,但它在您的计算机之外是无法工作的。在第十七章中,我们将讨论一些技术,以平稳地从开发过渡到生产。

使用视图发送 HTML 电子邮件

到目前为止,我们一直在 JavaScript 中的字符串中放置我们的 HTML,这是一个应尽量避免的做法。虽然我们的 HTML 足够简单,但看看HTML Email Boilerplate:您想把所有这些样板放在一个字符串中吗?绝对不。

幸运的是,我们可以利用视图来处理这个问题。让我们考虑一下我们的“感谢您与 Meadowlark Travel 预订旅行”的电子邮件示例,稍微扩展一下。假设我们有一个包含订单信息的购物车对象。该购物车对象将存储在会话中。假设我们订购过程的最后一步是一个由/cart/checkout处理的表单,该表单发送确认电子邮件。让我们首先为感谢页面创建一个视图,views/cart-thank-you.handlebars

<p>Thank you for booking your trip with Meadowlark Travel,
  {{cart.billing.name}}!</p>
<p>Your reservation number is {{cart.number}}, and an email has been
sent to {{cart.billing.email}} for your records.</p>

然后,我们将为电子邮件创建一个电子邮件模板。下载 HTML 电子邮件样板,并放入views/email/cart-thank-you.handlebars。编辑文件并修改正文:

<table cellpadding="0" cellspacing="0" border="0" id="backgroundTable">
  <tr>
    <td valign="top">
      <table cellpadding="0" cellspacing="0" border="0" align="center">
        <tr>
          <td width="200" valign="top"><img class="image_fix"
            src="//placehold.it/100x100"
            alt="Meadowlark Travel" title="Meadowlark Travel"
            width="180" height="220" /></td>
        </tr>
        <tr>
          <td width="200" valign="top"><p>
            Thank you for booking your trip with Meadowlark Travel,
            {{cart.billing.name}}.</p><p>Your reservation number
            is {{cart.number}}.</p></td>
        </tr>
        <tr>
          <td width="200" valign="top">Problems with your reservation?
          Contact Meadowlark Travel at
          <span class="mobile_link">555-555-0123</span>.</td>
        </tr>
      </table>
    </td>
  </tr>
</table>
小贴士

由于电子邮件中无法使用localhost地址,如果您的站点尚未上线,您可以使用占位符服务来获取任何图形。例如,http://placehold.it/100x100*动态提供一个您可以使用的 100 像素正方形图形。这种技术经常用于仅用于位置的(FPO)图像和布局目的。

现在我们可以为我们的购物车感谢页面创建一个路由(伴随仓库中的ch11/04-rendering-html-email.js):

app.post('/cart/checkout', (req, res, next) => {
  const cart = req.session.cart
  if(!cart) next(new Error('Cart does not exist.'))
  const name = req.body.name || '', email = req.body.email || ''
  // input validation
  if(!email.match(VALID_EMAIL_REGEX))
    return res.next(new Error('Invalid email address.'))
  // assign a random cart ID; normally we would use a database ID here
  cart.number = Math.random().toString().replace(/⁰\.0*/, '')
  cart.billing = {
    name: name,
    email: email,
  }
  res.render('email/cart-thank-you', { layout: null, cart: cart },
    (err,html) => {
        console.log('rendered email: ', html)
        if(err) console.log('error in email template')
        mailTransport.sendMail({
          from: '"Meadowlark Travel": info@meadowlarktravel.com',
          to: cart.billing.email,
          subject: 'Thank You for Book your Trip with Meadowlark Travel',
          html: html,
          text: htmlToFormattedText(html),
        })
          .then(info => {
            console.log('sent! ', info)
            res.render('cart-thank-you', { cart: cart })
          })
          .catch(err => {
            console.error('Unable to send confirmation: ' + err.message)
          })
    }
  )
})

注意我们调用了res.render两次。通常情况下,您只调用一次(调用两次将仅显示第一次调用的结果)。但在这种情况下,我们绕过了第一次调用时的正常渲染过程:请注意我们提供了一个回调函数。这样做可以防止将视图的结果呈现给浏览器。相反,回调函数在参数html中接收呈现的视图:我们只需获取呈现的 HTML 并发送电子邮件!我们指定layout: null以防止使用我们的布局文件,因为所有内容都在电子邮件模板中(另一种方法是为电子邮件创建单独的布局文件并使用该文件)。最后,我们再次调用res.render。这次,结果将像往常一样呈现为 HTML 响应。

封装电子邮件功能

如果您在整个站点上经常使用电子邮件,您可能希望封装电子邮件功能。让我们假设您始终希望您的站点从同一发送者(“草地雀旅行” <info@meadowlarktravel.com>) 发送电子邮件,并且您始终希望以 HTML 格式发送自动生成的文本电子邮件。创建一个名为lib/email.js(伴随仓库中的ch11/lib/email.js)的模块:

const nodemailer = require('nodemailer')
const htmlToFormattedText = require('html-to-formatted-text')

module.exports = credentials => {

  const mailTransport = nodemailer.createTransport({
    host: 'smtp.sendgrid.net',
    auth: {
      user: credentials.sendgrid.user,
      pass: credentials.sendgrid.password,
    },
  })

  const from = '"Meadowlark Travel" <info@meadowlarktravel.com>'
  const errorRecipient = 'youremail@gmail.com'

  return {
    send: (to, subject, html) =>
      mailTransport.sendMail({
        from,
        to,
        subject,
        html,
        text: htmlToFormattedText(html),
      }),
  }

}

现在我们只需执行以下操作即可发送电子邮件(伴随仓库中的ch11/05-email-library.js):

const emailService = require('./lib/email')(credentials)

emailService.send(email, "Hood River tours on sale today!",
  "Get 'em while they're hot!")

结论

在本章中,您了解了互联网上电子邮件传递的基础知识。如果您在跟随操作,您已设置了一个免费的电子邮件服务(很可能是 SendGrid 或 Mailgun),并使用该服务发送了文本和 HTML 电子邮件。您还了解了我们如何使用与在 Express 应用程序中渲染 HTML 相同的模板渲染机制来为电子邮件渲染 HTML。

电子邮件仍然是您的应用程序与用户沟通的重要方式。请注意不要滥用这种权力!如果您像我一样,收件箱里充斥着大量的自动化电子邮件,您大部分时间可能会忽略它们。在涉及自动化电子邮件时,少则多。您的应用程序向用户发送电子邮件可能有合法且有用的原因,但您应该始终问自己,“我的用户真的想要这封电子邮件吗?是否有其他方式来传达这些信息?”

现在,我们已经介绍了一些创建应用程序所需的基础设施,接下来我们将花一些时间讨论应用程序最终的生产发布,并需要考虑的各种因素,以确保发布成功。

第十二章:生产关注点

尽管现在开始讨论生产关注点可能感觉为时过早,但如果您从早期就开始思考生产,您将节省大量时间和痛苦。发布日将在您意识到之前就来临。

在本章中,您将了解 Express 对不同执行环境的支持,网站扩展的方法,以及如何监控网站的健康状况。您将看到如何模拟生产环境进行测试和开发,以及如何进行压力测试,以便在问题发生之前识别生产问题。

执行环境

Express 支持执行环境的概念:在生产、开发或测试模式下运行应用程序的一种方法。实际上,您可以拥有尽可能多的不同环境。例如,您可以有一个分段环境或培训环境。但请记住,开发、生产和测试是“标准”环境,Express 和第三方中间件通常根据这些环境做出决策。换句话说,如果您有一个“分段”环境,没有办法让它自动继承生产环境的属性。因此,我建议您坚持使用生产、开发和测试的标准。

虽然可以通过调用app.set('env', \'production')来指定执行环境,但这样做是不明智的;这意味着您的应用程序将始终在该环境中运行,无论情况如何。更糟糕的是,它可能会在一个环境中开始运行,然后切换到另一个环境。

最好通过环境变量NODE_ENV来指定执行环境。让我们修改我们的应用程序,通过调用app.get('env’)报告它正在运行的模式:

const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Express started in ` +
  `${app.get('env')} mode at http://localhost:${port}` +
  `; press Ctrl-C to terminate.`))

如果您现在启动服务器,您将看到它正在开发模式下运行;如果您不指定其他模式,这是默认值。让我们尝试将其置于生产模式下:

$ export NODE_ENV=production
$ node meadowlark.js

如果您使用 Unix/BSD,有一种便捷的语法可以仅在该命令的执行期间修改环境:

$ NODE_ENV=production node meadowlark.js

这将以生产模式运行服务器,但一旦服务器终止,NODE_ENV环境变量将不会被修改。我特别喜欢这个快捷方式,它减少了我意外留下环境变量设置为我不一定想要的值的机会。

注意

如果您以生产模式启动 Express,则可能会注意到有关不适合在生产模式下使用的组件的警告。如果您一直在本书的示例中跟进,您会看到connect.session正在使用内存存储,这在生产环境中是不合适的。一旦我们在第十三章中切换到数据库存储,这个警告将消失。

环境特定配置

尽管 Express 在生产模式下会在控制台上记录更多警告(例如,通知您即将删除并将来会移除的模块),但仅仅改变执行环境并不能做太多事情。此外,在生产模式下,默认情况下启用了视图缓存(参见第七章)。

主要的执行环境是一个工具,让你能够轻松地决定你的应用在不同环境下的行为。作为一种注意,你应该尽量减少开发、测试和生产环境之间的差异。也就是说,你应该谨慎使用这个功能。如果你的开发或测试环境与生产环境差异很大,那么在生产中就可能会出现不同的行为,这会导致更多的缺陷(或更难找到的缺陷)。一些差异是不可避免的;例如,如果您的应用程序高度依赖于数据库驱动,您可能不希望在开发过程中操作生产数据库,这将是适合特定环境配置的好选择。另一个低影响领域是更详细的日志记录。在开发中可能想要记录的许多事情在生产中是不必要记录的。

让我们给我们的服务器添加一些日志记录。关键在于我们希望在生产和开发环境下有不同的行为。对于开发环境,我们可以保留默认设置,但是对于生产环境,我们希望将日志记录到文件中。我们将使用morgan(别忘了npm install morgan),这是最常见的日志中间件(伴随库中的ch12/00-logging.js):

const morgan = require('morgan')
const fs = require('fs')

switch(app.get('env')) {
  case 'development':
    app.use(morgan('dev'))
    break
  case 'production':
    const stream = fs.createWriteStream(__dirname + '/access.log',
      { flags: 'a' })
    app.use(morgan('combined', { stream }))
    break
}

如果你像平常一样启动服务器(node meadowlark.js)并访问站点,你会看到活动被记录到控制台。要查看应用在生产模式下的行为,请使用NODE_ENV=production运行它。现在,如果你访问应用程序,你将不会在终端上看到任何活动(这可能是我们希望在生产服务器上的情况),但所有活动都记录在Apache 的组合日志格式中,这是许多服务器工具的基本组成部分。

我们通过创建一个可追加的写入流({ flags: *a* })并将其传递给 morgan 配置来实现这一点。Morgan 有许多选项;要查看所有选项,请查阅morgan 文档

注意

在上一个例子中,我们使用__dirname来将请求日志存储在项目本身的子目录中。如果你采用这种方法,你会希望将log添加到你的.gitignore文件中。或者,你可以采用更类似 Unix 的方法,将日志保存在/var/log的子目录中,就像 Apache 默认的做法一样。

我再次强调,当您做出特定于环境的配置选择时,应该用最佳判断力。请记住,当您的网站上线时,您的生产实例将在生产模式下运行(或应该如此)。每当您想要进行开发特定修改时,您应首先考虑这可能对生产中的质量保证造成的影响。我们将在第十三章看到一个更强大的环境特定配置的示例。

运行您的 Node 进程

到目前为止,我们一直在直接调用 node 运行我们的应用程序(例如,node meadowlark.js)。这对开发和测试来说是可以的,但在生产中有其缺点。特别是,如果您的应用程序崩溃或被终止,将没有任何保护措施。一个强大的进程管理器可以解决这个问题。

根据您的托管解决方案,如果提供了进程管理器,则可能不需要额外的进程管理器。也就是说,托管提供商将为您提供一个配置选项来指向您的应用程序文件,并将处理进程管理。

但是,如果您需要自己管理进程,则有两种流行的选项可供选择的进程管理器:

由于生产环境可能存在差异,我们不会详细讨论设置和配置进程管理器的具体事项。永不停止和 PM2 都有出色的文档,您可以在开发机器上安装和使用它们来学习如何配置。

我都用过,没有特别偏好。永不停止更为直接且易于入门,而 PM2 则提供了更多功能。

如果您想尝试一个进程管理器而又不想投入大量时间,我建议尝试使用永不停止。您可以分两步尝试它。首先,安装永不停止:

npm install -g forever

然后,使用永不停止启动您的应用程序(从应用程序根目录运行此命令):

forever start meadowlark.js

您的应用现在正在运行……即使您关闭终端窗口,它也会继续运行!您可以使用 forever restart meadowlark.js 重新启动进程,并使用 forever stop meadowlark.js 停止它。

使用 PM2 起步会比较复杂,但如果您需要在生产环境中使用自己的进程管理器,它是值得一试的。

扩展您的网站

当前,扩展通常意味着两种情况之一:扩展上升或扩展外扩。扩展上升 指的是使服务器更强大:更快的 CPU、更好的架构、更多核心、更多内存等等。扩展外扩 则意味着更多的服务器。随着云计算的普及和虚拟化的普及,服务器计算能力变得不那么重要,根据您的需求,扩展外扩通常是最具成本效益的网站扩展方法。

在开发 Node 网站时,你应该始终考虑扩展的可能性。即使你的应用程序很小(也许它甚至是一个总是有限观众的内部网络应用程序),永远不会需要扩展,养成这种习惯也是个好主意。毕竟,也许你的下一个 Node 项目将成为下一个 Twitter,扩展将是必不可少的。幸运的是,Node 对扩展的支持非常好,考虑到这一点编写你的应用程序是毫不费力的。

在构建旨在扩展的网站时最重要的事情是持久性。如果你习惯于依赖基于文件的存储来保持持久性,停下来。这条路只会导致疯狂。

我第一次面对这个问题的经历几乎是灾难性的。我们的一个客户正在进行基于 Web 的比赛,该 Web 应用程序旨在通知前 50 名获奖者他们将获得奖品。由于某些企业 IT 限制,我们无法轻松使用数据库,因此大部分持久性通过编写平面文件来实现。我按照往常的做法继续进行,将每个条目保存到一个文件中。一旦文件记录了 50 名获奖者,就不再通知更多人他们中奖了。问题在于服务器是负载平衡的,因此一半的请求由一个服务器处理,另一半由另一个服务器处理。一台服务器通知了 50 名获奖者……另一台服务器也通知了。幸运的是,奖品不是很贵重(抱毯),而不是像 iPad 之类的昂贵物品,客户承担了损失,分发了 100 个奖品而不是 50 个(我提出自掏腰包支付额外的 50 条毯子,但他们慷慨地拒绝了我的提议)。

这个故事的寓意是,除非你有一个对所有服务器都可访问的文件系统,否则不应该依赖本地文件系统来保持持久性。有些例外是只读数据,如日志和备份。例如,我通常会将表单提交数据备份到本地平面文件中,以防数据库连接失败。在数据库故障的情况下,去每台服务器收集文件是一件麻烦事,但至少没有造成损失。

扩展应用集群

Node 本身支持应用集群,这是一种简单的单服务器扩展形式。通过应用集群,你可以为系统上的每个核心(CPU)创建一个独立的服务器(如果服务器数量超过核心数,不会提高应用程序的性能)。应用集群有两个好处:首先,它们可以帮助最大化给定服务器(硬件或虚拟机)的性能,其次,这是一种低开销的方式,在并行条件下测试你的应用程序。

让我们继续为我们的网站添加集群支持。虽然在主应用程序文件中完成所有这些工作非常常见,但我们将创建一个第二个应用程序文件,在集群中运行应用程序,使用我们一直在使用的非集群应用程序文件。为了启用这一点,我们首先必须对meadowlark.js进行轻微修改(参见伴侣库中的ch12/01-server.js的简化示例):

function startServer(port) {
  app.listen(port, function() {
    console.log(`Express started in ${app.get('env')} ` +
      `mode on http://localhost:${port}` +
      `; press Ctrl-C to terminate.`)
  })
}

if(require.main === module) {
  // application run directly; start app server
  startServer(process.env.PORT || 3000)
} else {
  // application imported as a module via "require": export
  // function to create server
  module.exports = startServer
}

如果你还记得来自第五章,如果require.main === module,这意味着脚本直接运行;否则,它已经被另一个脚本通过require调用。

然后,我们创建一个新的脚本,meadowlark-cluster.js(请参见伴侣库中的ch12/01-cluster的简化示例):

const cluster = require('cluster')

function startWorker() {
  const worker = cluster.fork()
  console.log(`CLUSTER: Worker ${worker.id} started`)
}

if(cluster.isMaster){

  require('os').cpus().forEach(startWorker)

  // log any workers that disconnect; if a worker disconnects, it
  // should then exit, so we'll wait for the exit event to spawn
  // a new worker to replace it
  cluster.on('disconnect', worker => console.log(
    `CLUSTER: Worker ${worker.id} disconnected from the cluster.`
  ))

  // when a worker dies (exits), create a worker to replace it
  cluster.on('exit', (worker, code, signal) => {
    console.log(
      `CLUSTER: Worker ${worker.id} died with exit ` +
      `code ${code} (${signal})`
    )
    startWorker()
  })

} else {

    const port = process.env.PORT || 3000
    // start our app on worker; see meadowlark.js
    require('./meadowlark.js')(port)

}

当执行此 JavaScript 时,它将处于主(直接使用node meadowlark-cluster.js运行时)或工作进程的上下文中,当 Node 的集群系统执行它时。属性cluster.isMastercluster.isWorker确定你正在运行的上下文。当我们运行此脚本时,它正在主模式下执行,并使用cluster.fork为系统中的每个 CPU 启动一个工作进程。此外,我们通过监听来自工作进程的exit事件来重新启动任何死掉的工作进程。

最后,在else子句中,我们处理工作进程。由于我们将meadowlark.js配置为作为一个模块使用,我们只需导入它并立即调用它(请记住,我们将其导出为一个启动服务器的函数)。

现在启动你的新集群服务器:

node meadowlark-cluster.js
注意

如果你使用虚拟化(如 Oracle 的 VirtualBox),你可能需要配置你的虚拟机以具有多个 CPU。默认情况下,虚拟机通常只有一个 CPU。

假设你正在使用多核系统,你应该看到一些工作进程启动。如果你想看到不同的工作进程处理不同的请求证据,请在你的路由之前添加以下中间件:

const cluster = require('cluster')

app.use((req, res, next) => {
  if(cluster.isWorker)
    console.log(`Worker ${cluster.worker.id} received request`)
  next()
})

现在你可以用浏览器连接到你的应用程序。多次重新加载并查看如何在每个请求中从池中获取不同的工作进程(你可能无法;Node 被设计来处理大量连接,简单通过重新加载浏览器可能无法充分压力测试它;稍后我们将探讨压力测试,你将能更好地看到集群的运行情况)。

处理未捕获的异常

在 Node 的异步世界中,未捕获的异常是特别关注的问题。让我们从一个不会造成太多麻烦的简单例子开始(我鼓励你跟着这些例子一起学习):

app.get('/fail', (req, res) => {
  throw new Error('Nope!')
})

当 Express 执行路由处理程序时,它将它们包装在 try/catch 块中,因此这实际上不是一个未捕获的异常。这不会造成太大问题:Express 会在服务器端记录异常,并且访问者会得到一个难看的堆栈转储。然而,您的服务器是稳定的,并且其他请求将继续正常提供服务。如果我们想要提供一个“友好”的错误页面,创建一个文件 views/500.handlebars 并在所有路由之后添加一个错误处理程序:

app.use((err, req, res, next) => {
  console.error(err.message, err.stack)
  app.status(500).render('500')
})

提供自定义错误页面始终是一个好习惯;当错误发生时,它不仅会使您的用户感到更专业,还允许您在错误发生时采取行动。例如,此错误处理程序将是通知开发团队发生错误的好地方。不幸的是,这仅对 Express 能够捕获的异常有效。让我们试试更糟糕的情况:

app.get('/epic-fail', (req, res) => {
  process.nextTick(() =>
    throw new Error('Kaboom!')
  )
})

请尝试一下。结果将会非常灾难性:它会使整个服务器都崩溃!除了不向用户显示友好的错误消息之外,现在您的服务器已经宕机,没有请求正在得到服务。这是因为 setTimeout异步执行的;带有异常的函数的执行被推迟直到 Node 空闲。问题是,当 Node 空闲并且准备执行函数时,它不再具有关于正在服务的请求的上下文,因此除了不体面地关闭整个服务器外,别无选择,因为现在它处于未定义状态。(Node 无法知道函数或其调用者的目的,因此它不再假定任何进一步的函数将正常工作。)

注意

process.nextTick 类似于调用 setTimeout 参数为 0,但效率更高。我们在这里使用它进行演示目的;通常情况下,您不会在服务器端代码中使用它。然而,在接下来的章节中,我们将处理许多异步执行的事情,例如数据库访问、文件系统访问和网络访问等,它们都会遇到这个问题。

我们可以采取措施来处理未捕获的异常,但如果 Node 无法确定您的应用程序的稳定性,那么您也无法确定。换句话说,如果发生未捕获的异常,唯一的解决方法就是关闭服务器。在这种情况下,我们能做的最好的事情就是尽可能优雅地关闭并具备故障转移机制。最简单的故障转移机制是使用集群。如果您的应用程序在集群模式下运行并且一个工作进程死掉,主进程将会生成另一个工作进程来替代它。(甚至您不需要多个工作进程;一个带有一个工作进程的集群就足够了,尽管故障转移可能会稍慢一些。)

因此,在这种情况下,当我们面对未处理的异常时,如何以最优雅的方式关闭?Node 处理这种情况的机制是uncaughtException事件。(Node 还有一种称为domains的机制,但该模块已被弃用,不再推荐使用。)

process.on('uncaughtException', err => {
  console.error('UNCAUGHT EXCEPTION\n', err.stack);
  // do any cleanup you need to do here...close
  // database connections, etc.
  process.exit(1)
})

不能期望您的应用程序永远不会遇到未捕获的异常,但您应该有一个机制来记录异常并在发生时通知您,并且您应该认真对待它。尝试确定发生异常的原因,以便进行修复。像SentryRollbarAirbrakeNew Relic这样的服务是记录这类错误以进行分析的好方法。例如,要使用 Sentry,首先您必须注册一个免费账户,然后您将收到一个数据源名称(DSN),然后您可以修改您的异常处理程序:

const Sentry = require('@sentry/node')
Sentry.init({ dsn: '** YOUR DSN GOES HERE **' })

process.on('uncaughtException', err => {
  // do any cleanup you need to do here...close
  // database connections, etc.
  Sentry.captureException(err)
  process.exit(1)
})

使用多台服务器进行扩展

虽然使用集群可以最大化单个服务器的性能,但当您需要多台服务器时会发生什么呢?这时情况会变得有些复杂。要实现这种并行性,您需要一个代理服务器。(通常称为反向代理前置代理以区分常用于访问外部网络的代理,但我发现这种语言令人困惑且不必要,因此我将简称其为代理。)

两个非常流行的选项是NGINX(发音为“engine X”)和HAProxy。尤其是 NGINX 服务器如雨后春笋般普及。我最近为我的公司进行了竞争分析,发现我们的竞争对手中多达 80%正在使用 NGINX。NGINX 和 HAProxy 都是强大且高性能的代理服务器,能够应对最苛刻的应用场景。(如果你需要证据,考虑到 Netflix 占据了多达 15%的全球互联网流量,它使用的是 NGINX。)

还有一些基于 Node 的较小的代理服务器,例如node-http-proxy。如果您的需求较为简单或用于开发,这是一个不错的选择。对于生产环境,我建议使用 NGINX 或 HAProxy(它们都是免费的,但提供付费支持)。

安装和配置代理服务器超出了本书的范围,但实际并不像你想象的那么难(特别是如果你使用 node-http-proxy 或其他轻量级代理)。目前,使用集群可以确保我们的网站已经准备好进行扩展。

如果您配置了代理服务器,请确保告知 Express 您正在使用代理,并且应该信任该代理:

app.enable('trust proxy')

这样做将确保req.ipreq.protocolreq.secure将反映客户端和代理之间的连接详细信息,而不是客户端和您的应用程序之间的连接。此外,req.ips将是一个数组,指示原始客户端 IP 和任何中间代理的名称或 IP 地址。

监控您的网站

监控您的网站是您可以采取的最重要的,也是最经常被忽视的质量保证措施之一。在凌晨 3 点修复破损的网站是一件糟糕的事情,被你的老板在凌晨 3 点吵醒因为网站挂了更糟糕(或者更糟糕的是,在早上到达时意识到您的客户因为网站整晚都挂了而损失了 1 万美元并且没有人注意到)。

失败是无法避免的:它们和死亡以及税收一样,是不可避免的。但是,如果有一件事情可以让你的老板和客户相信你很擅长你的工作,那就是始终在他们之前知道故障。

第三方正常运行时间监控程序

在您网站的服务器上运行一个正常运行时间监控程序,就像在没有人居住的房子里安装烟雾报警器一样有效。它可能能够在某个页面挂掉时捕捉错误,但是如果整个服务器挂掉,可能会在不发出 SOS 的情况下就挂掉。这就是为什么您的第一道防线应该是第三方的正常运行时间监控程序。UptimeRobot免费提供 50 个监视器,并且配置简单。警报可以发送到电子邮件,短信(短信),Twitter 或 Slack(等等)。您可以监控单个页面返回的代码(除了 200 之外的任何内容都被视为错误)或者检查页面上的关键字的存在或缺失。请记住,如果您使用关键字监视器,可能会影响您的分析(您可以在大多数分析服务中排除正常运行时间监控的流量)。

如果您的需求更复杂,还有其他更昂贵的服务,比如PingdomSite24x7

压力测试

压力测试(或负载测试)旨在让您有信心,您的服务器能够承受同一时间的数百或数千个请求。这是另一个深奥的领域,可以成为专门一本书的主题:压力测试可以任意复杂,您想要多复杂取决于您项目的性质。如果您有理由相信您的网站可能会非常受欢迎,您可能需要在压力测试上投入更多时间。

让我们使用Artillery添加一个简单的压力测试。首先,通过运行npm install -g artillery来安装 Artillery;然后编辑您的package.json文件,并在scripts部分添加以下内容:

  "scripts": {
    "stress": "artillery quick --count 10 -n 20 http://localhost:3000/"
  }

这将模拟 10 个“虚拟用户”(--count 10),每个用户将向您的服务器发送 20 个请求(-n 20)。

确保你的应用正在运行(例如在单独的终端窗口中),然后运行npm run stress。你会看到像这样的统计数据:

Started phase 0, duration: 1s @ 16:43:37(-0700) 2019-04-14
Report @ 16:43:38(-0700) 2019-04-14
Elapsed time: 1 second
  Scenarios launched:  10
  Scenarios completed: 10
  Requests completed:  200
  RPS sent: 147.06
  Request latency:
    min: 1.8
    max: 10.3
    median: 2.5
    p95: 4.2
    p99: 5.4
  Codes:
    200: 200

All virtual users finished
Summary report @ 16:43:38(-0700) 2019-04-14
  Scenarios launched:  10
  Scenarios completed: 10
  Requests completed:  200
  RPS sent: 145.99
  Request latency:
    min: 1.8
    max: 10.3
    median: 2.5
    p95: 4.2
    p99: 5.4
  Scenario counts:
    0: 10 (100%)
  Codes:
    200: 200

这个测试是在我的开发笔记本上运行的。你可以看到 Express 在任何请求中的响应时间都不超过 10.3 毫秒,其中 99% 的请求在 5.4 毫秒以下完成。我无法提供具体的指导,告诉你应该寻找什么样的数字,但为了确保应用程序响应迅速,你应该寻找总连接时间在 50 毫秒以下的情况。(别忘了这仅仅是服务器传输数据给客户端所需的时间;客户端仍然需要渲染数据,这也需要时间,所以你在传输数据时花费的时间越少,越好。)

如果你定期对你的应用进行压力测试并进行基准测试,你就能够识别问题。如果你刚刚完成了一个功能,并发现你的连接时间增加了三倍,那么你可能需要对你的新功能进行一些性能调优!

结论

希望本章内容能够让你在接近应用程序发布时考虑到一些事项。生产应用程序涉及很多细节,虽然你无法预测到启动时可能发生的一切,但你越能预见,你就越能处于有利地位。借路易斯·巴斯德的话说,准备充足者多得利。

第十三章:持久性

几乎所有除了最简单的网站和网络应用程序外,都需要某种形式的持久性;也就是说,一些比易失性内存更持久的数据存储方式,以便在服务器崩溃、停电、升级和迁移时数据能够存活下来。在本章中,我们将讨论持久性的可用选项,并演示文档数据库和关系数据库。然而,在进入数据库之前,我们将从最基本的持久性形式开始:文件系统持久性。

文件系统持久性

实现持久性的一种方式是简单地将数据保存到所谓的平面文件中(平面因为文件中没有固有的结构;它只是一系列字节的序列)。Node 通过fs(文件系统)模块实现了文件系统持久性。

文件系统持久性具有一些缺点。特别是,它不易扩展。一旦您需要多台服务器来满足流量需求,您将会遇到文件系统持久性的问题,除非所有服务器都可以访问共享文件系统。此外,由于平面文件没有固有结构,定位、排序和过滤数据的负担将落在您的应用程序上。因此,出于这些原因,您应优先考虑使用数据库而不是文件系统来存储数据。唯一的例外是存储二进制文件,如图像、音频文件或视频。虽然许多数据库可以处理这类数据,但它们很少比文件系统更有效地处理(尽管通常在数据库中存储有关二进制文件的信息以便于搜索、排序和过滤)。

如果您确实需要存储二进制数据,请记住文件系统存储仍然存在不易扩展的问题。如果您的主机没有访问共享文件系统的权限(通常是这种情况),您应考虑将二进制文件存储在数据库中(通常需要一些配置,以防止数据库停滞)或云存储服务,如 Amazon S3 或 Microsoft Azure Storage。

现在我们解决了注意事项,让我们来看看 Node 的文件系统支持。我们将回顾来自第八章的度假照片竞赛。在我们的应用程序文件中,让我们填写处理该表单的处理程序(ch13/00-mongodb/lib/handlers.js在伴随代码库中):

const pathUtils = require('path')
const fs = require('fs')

// create directory to store vacation photos (if it doesn't already exist)
const dataDir = pathUtils.resolve(__dirname, '..', 'data')
const vacationPhotosDir = pathUtils.join(dataDir, 'vacation-photos')
if(!fs.existsSync(dataDir)) fs.mkdirSync(dataDir)
if(!fs.existsSync(vacationPhotosDir)) fs.mkdirSync(vacationPhotosDir)

function saveContestEntry(contestName, email, year, month, photoPath) {
  // TODO...this will come later
}

// we'll want these promise-based versions of fs functions later
const { promisify } = require('util')
const mkdir = promisify(fs.mkdir)
const rename = promisify(fs.rename)

exports.api.vacationPhotoContest = async (req, res, fields, files) => {
  const photo = files.photo[0]
  const dir = vacationPhotosDir + '/' + Date.now()
  const path = dir + '/' + photo.originalFilename
  await mkdir(dir)
  await rename(photo.path, path)
  saveContestEntry('vacation-photo', fields.email,
    req.params.year, req.params.month, path)
  res.send({ result: 'success' })
}

这里涉及很多内容,让我们来分解一下。首先,我们创建一个目录来存储上传的文件(如果目录不存在)。您可能希望将 data 目录添加到您的 .gitignore 文件中,以防意外提交上传的文件。回想一下 第八章 中,我们在 meadowlark.js 中处理实际文件上传,并已解码调用我们的处理程序。我们得到的是一个包含有关上传文件信息的对象 (files)。因为我们希望避免碰撞,所以不能仅仅使用用户上传的文件名(以防两个用户都上传 portland.jpg)。为了避免这个问题,我们基于时间戳创建一个唯一的目录;两个用户在同一毫秒内都上传 portland.jpg 的可能性非常小!然后我们将上传的文件重命名(移动)到我们构造的名称(我们的文件处理器会给它一个临时名称,我们可以从 path 属性中获取)。

最后,我们需要一种方法将用户上传的文件与他们的电子邮件地址(以及提交的月份和年份)关联起来。我们可以将这些信息编码到文件或目录名称中,但我们更倾向于将这些信息存储在数据库中。因为我们还没有学会如何做到这一点,我们将在本章后面的 vacationPhotoContest 函数中封装该功能并完成它。

注意

一般情况下,你绝对不应该信任用户上传的任何内容,因为这是攻击你的网站的一个潜在途径。例如,恶意用户可以轻易地将一个有害的可执行文件改名为 .jpg 扩展名,并上传它作为攻击的第一步(希望以后能找到某种方法来执行它)。同样,我们在这里使用浏览器提供的 name 属性来命名文件,也存在一定的风险;某些人也可以通过在文件名中插入特殊字符来滥用这一点。为了让这段代码完全安全,我们将为文件命名随机生成一个名称,仅保留扩展名(确保它只包含字母和数字字符)。

尽管文件系统持久性具有其缺点,但它经常用于中间文件存储,了解如何使用 Node 文件系统库是很有用的。然而,为了解决文件系统存储的缺陷,让我们将注意力转向云持久性。

云持久性

云存储变得越来越流行,我强烈建议您利用其中一种价格便宜且稳健的服务。

使用云服务时,您必须进行一定量的前期工作。显然,您需要创建一个帐户,但您还需要了解您的应用程序如何与云服务进行身份验证,了解一些基本术语也很有帮助(例如,AWS 将其文件存储机制称为存储桶,而 Azure 称之为容器)。详细信息超出本书的范围,并且有充分的文档支持:

一旦您完成初始配置,使用云持久性就非常简单。以下是一个示例,展示了将文件保存到 Amazon S3 帐户有多么简单:

const filename = 'customerUpload.jpg'

s3.putObject({
  Bucket: 'uploads',
  Key: filename,
  Body: fs.readFileSync(__dirname + '/tmp/ + filename),
})

查看AWS SDK 文档获取更多信息。

以下是如何在 Microsoft Azure 上执行相同操作的示例:

const filename = 'customerUpload.jpg'

const blobService = azure.createBlobService()
blobService.createBlockBlobFromFile('uploads', filename, __dirname +
  '/tmp/' + filename)

查看Microsoft Azure 文档获取更多信息。

现在我们已经了解了几种文件存储技术,让我们考虑使用数据库存储结构化数据的方法。

数据库持久性

几乎所有不是最简单的网站和 Web 应用程序都需要一个数据库。即使您的大部分数据是二进制的,而且您使用共享文件系统或云存储,您也很可能需要一个数据库来帮助目录化这些二进制数据。

传统上,“数据库”一词是“关系数据库管理系统”(RDBMS)的简称。关系数据库,如 Oracle、MySQL、PostgreSQL 或 SQL Server,基于数十年的研究和正式的数据库理论。这项技术现在已经非常成熟,这些数据库的强大是毋庸置疑的。然而,我们现在有幸扩展我们对数据库构成的理解。近年来,NoSQL 数据库变得流行起来,它们挑战了互联网数据存储的现状。

声称 NoSQL 数据库在某种程度上比关系数据库更好是愚蠢的,但它们确实具有某些优势(反之亦然)。虽然将关系数据库与 Node 应用程序集成非常容易,但也有些 NoSQL 数据库似乎几乎是为 Node 设计的。

最流行的两种 NoSQL 数据库类型是文档数据库键值数据库。文档数据库擅长存储对象,这使它们非常适合 Node 和 JavaScript。键值数据库正如其名称所示,非常简单,非常适合具有易于映射为键值对的数据模式的应用程序。

我认为文档数据库代表了在关系数据库的约束和键值数据库的简单性之间找到的最佳折衷方案,因此,我们将在第一个示例中使用文档数据库。MongoDB 是主流的文档数据库,在目前是健壮和成熟的。

对于我们的第二个示例,我们将使用 PostgreSQL,这是一个流行且强大的开源关系型数据库管理系统。

关于性能的注记

NoSQL 数据库的简单性是双刃剑。仔细规划关系型数据库可能是一个复杂的任务,但仔细规划的好处是提供了性能优异的数据库。不要被误导认为,因为 NoSQL 数据库通常更简单,就不存在调整它们以获得最大性能的艺术和科学。

传统上,关系型数据库依赖其严格的数据结构和数十年的优化研究来实现高性能。另一方面,NoSQL 数据库采纳了互联网的分布式特性,并像 Node 一样,转而专注于并发以提升性能(关系型数据库也支持并发,但通常仅用于最苛刻的应用程序)。

规划数据库性能和可扩展性是一个庞大且复杂的主题,超出了本书的范围。如果您的应用程序需要高水平的数据库性能,我建议首先阅读 Kristina Chodorow 和 Michael Dirolf 的MongoDB 权威指南(O’Reilly)。

抽象化数据库层

在本书中,我们将实施相同的功能,并展示如何在两种数据库中执行(不仅仅是两种数据库,而是两种基本不同的数据库架构)。虽然本书的目标是涵盖两种流行的数据库架构选择,但它反映了现实场景:在项目进行中切换 Web 应用程序的主要组件。这可能出于许多原因。通常归结为发现不同技术能更具成本效益,或者允许您更快地实施必要的功能。

在可能的情况下,抽象您的技术选择是有价值的,这指的是编写某种 API 层以泛化底层技术选择。如果做得好,它会减少替换问题组件的成本。然而,这是有代价的:编写抽象层是您必须编写和维护的另一件事情。

幸运的是,我们的抽象化层将非常小,因为我们只支持本书目的的一小部分功能。目前,这些功能将如下:

  • 从数据库返回一个活动度假列表

  • 存储希望在特定度假季节通知时通知的用户的电子邮件地址

尽管这看起来足够简单,但这里有很多细节。度假是什么样子的?我们总是希望从数据库获取所有假期吗?还是希望能够过滤或分页它们?我们如何识别度假?等等。

我们将保持本书中抽象化层的简单性。我们将其包含在一个名为db.js的文件中,该文件将导出两个方法,我们将从简单提供虚拟实现开始:

module.exports = {
  getVacations: async (options = {}) => {
    // let's fake some vacation data:
    const vacations = [
      {
        name: 'Hood River Day Trip',
        slug: 'hood-river-day-trip',
        category: 'Day Trip',
        sku: 'HR199',
        description: 'Spend a day sailing on the Columbia and ' +
          'enjoying craft beers in Hood River!',
        location: {
          // we'll use this for geocoding later in the book
          search: 'Hood River, Oregon, USA',
        },
        price: 99.95,
        tags: ['day trip', 'hood river', 'sailing', 'windsurfing', 'breweries'],
        inSeason: true,
        maximumGuests: 16,
        available: true,
        packagesSold: 0,
      }
    ]
    // if the "available" option is specified, return only vacations that match
    if(options.available !== undefined)
      return vacations.filter(({ available }) => available === options.available)
    return vacations
  },
  addVacationInSeasonListener: async (email, sku) => {
    // we'll just pretend we did this...since this is
    // an async function, a new promise will automatically
    // be returned that simply resolves to undefined
  },
}

这为我们的数据库实现向应用程序展示了一个期望……而我们所要做的就是使我们的数据库符合这个接口。请注意,我们引入了“可用性”的概念;我们这样做是为了能够临时禁用假期而不是从数据库中删除它们。一个示例用例是一家小旅馆通知您他们关闭几个月进行翻新。我们将这与“旺季”概念分开,因为我们可能希望在网站上列出淡季假期,因为人们喜欢提前计划。

我们还包含一些非常通用的“位置”信息;我们将在第十九章中详细介绍这一点。

现在我们已经为我们的数据库层建立了一个抽象的基础,让我们看看如何使用 MongoDB 实现数据库存储。

设置 MongoDB

设置 MongoDB 实例的难度取决于您的操作系统。因此,我们将通过使用一个出色的免费 MongoDB 托管服务 mLab 来彻底避开这个问题。

注意

mLab 并不是唯一的 MongoDB 服务提供商。MongoDB 公司现在通过其产品MongoDB Atlas免费和低成本提供数据库托管服务。虽然免费账户不建议用于生产目的。mLab 和 MongoDB Atlas 都提供生产就绪的账户,因此在做选择之前应该了解它们的定价。当您转向生产环境时,与相同的托管服务保持一致将会更加方便。

使用 mLab 开始是很简单的。只需访问https://mlab.com,然后点击注册。填写注册表格并登录,您将会进入您的主页。在数据库下,您会看到“此时没有数据库”。点击“创建新数据库”,您将被带到一个包含一些选项的页面。您首先要选择一个云提供商。对于免费(沙盒)账户,选择大体上无关紧要,尽管您应该选择靠近您的数据中心(然而,并非每个数据中心都提供沙盒账户)。选择 SANDBOX,然后选择一个区域。然后选择一个数据库名称,点击提交订单(即使它是免费的,也还是一个订单!)。您将被带回到您的数据库列表,并在几秒钟后,您的数据库将可供使用。

拥有一个设置好的数据库是成功的一半。现在我们必须知道如何使用 Node 访问它,这就是 Mongoose 的用武之地。

Mongoose

虽然有一个低级别的MongoDB 驱动程序可用,但您可能希望使用对象文档映射器(ODM)。对于 MongoDB 来说,最流行的 ODM 是Mongoose

JavaScript 的一个优点是其对象模型非常灵活。如果你想给一个对象添加属性或方法,你只需要这么做,而不需要担心修改类。不过,这种随意的灵活性可能会对你的数据库产生负面影响,因为它们可能变得碎片化并且难以优化。Mongoose 试图通过引入模式模型来取得平衡(结合起来,模式和模型类似于传统面向对象编程中的类)。这些模式灵活但仍为你的数据库提供了一些必要的结构。

在我们开始之前,我们需要安装 Mongoose 模块:

npm install mongoose

然后我们将我们的数据库凭据添加到我们的 .credentials.development.json 文件中:

"mongo": {
    "connectionString": "your_dev_connection_string"
  }
}

你可以在 mLab 的数据库页面上找到连接字符串。从你的主屏幕上,点击相应的数据库。你会看到一个框,里面有你的 MongoDB 连接 URI(它以 mongodb:// 开头)。你还需要一个数据库用户。要创建一个用户,点击 Users,然后选择“Add database user”。

注意,我们可以通过创建一个 .credentials.production.js 文件并使用 NODE_ENV=production 来为生产环境建立第二组凭据;在上线时你会需要这样做!

现在我们所有的配置都完成了,让我们实际连接到数据库并做一些有用的事情!

使用 Mongoose 进行数据库连接

我们将从创建到数据库的连接开始。我们将把我们的数据库初始化代码放在 db.js 中,与我们之前创建的虚拟 API 一起(在伴随代码库中为 ch13/00-mongodb/db.js):

const mongoose = require('mongoose')
const { connectionString } = credentials.mongo
if(!connectionString) {
  console.error('MongoDB connection string missing!')
  process.exit(1)
}
mongoose.connect(connectionString)
const db = mongoose.connection
db.on('error' err => {
  console.error('MongoDB error: ' + err.message)
  process.exit(1)
})
db.once('open', () => console.log('MongoDB connection established'))

module.exports = {
  getVacations: async () => {
    //...return fake vacation data
  },
  addVacationInSeasonListener: async (email, sku) => {
    //...do nothing
  },
}

任何需要访问数据库的文件都可以简单地导入 db.js。然而,我们希望初始化尽快完成,即在我们需要 API 之前,所以我们会从 meadowlark.js 中导入它(在那里我们不需要对 API 做任何事情):

require('./db')

现在我们正在连接到数据库,是时候考虑我们将如何结构化我们传输到数据库和从数据库传输的数据了。

创建模式和模型

让我们为 Meadowlark Travel 创建一个度假套餐数据库。我们首先定义一个模式并从中创建一个模型。创建文件 models/vacation.js(在伴随代码库中为 ch13/00-mongodb/models/vacation.js):

const mongoose = require('mongoose')

const vacationSchema = mongoose.Schema({
  name: String,
  slug: String,
  category: String,
  sku: String,
  description: String,
  location: {
    search: String,
    coordinates: {
      lat: Number,
      lng: Number,
    },
  },
  price: Number,
  tags: [String],
  inSeason: Boolean,
  available: Boolean,
  requiresWaiver: Boolean,
  maximumGuests: Number,
  notes: String,
  packagesSold: Number,
})

const Vacation = mongoose.model('Vacation', vacationSchema)
module.exports = Vacation

这段代码声明了构成我们度假模型的属性及其类型。你会看到有几个字符串属性,一些数值属性,两个布尔属性以及一个字符串数组(用 [String] 表示)。在这一点上,我们还可以在我们的模式上定义方法。每个产品都有一个库存单位 (SKU);即使我们不认为度假是“库存商品”,但 SKU 的概念在会计中是非常标准的,即使没有实体商品出售时也是如此。

一旦有了模式,我们就可以使用 mongoose.model 创建一个模型:此时,Vacation 就像传统面向对象编程中的类一样。请注意,在创建模型之前必须定义我们的方法。

注意

由于浮点数的性质,在 JavaScript 中进行财务计算时一定要小心。我们可以将价格存储为分而不是美元,这会有所帮助,但并不能完全消除问题。对于我们旅行网站的适度目的,我们不打算担心这些,但是如果您的应用涉及非常大或非常小的财务金额(例如利息的分数美分或交易量),您应该考虑使用类似 currency.jsdecimal.js-light 的库。此外,JavaScript 的 BigInt 内置对象,从 Node 10 开始可用(写作时具有有限的浏览器支持),可以用于此目的。

我们正在导出由 Mongoose 创建的 Vacation 模型对象。虽然我们可以直接使用这个模型,但这将削弱我们提供数据库抽象层的努力。因此,我们选择仅从 db.js 文件导入它,并让我们的应用程序的其余部分使用其方法。将 Vacation 模型添加到 db.js 中:

const Vacation = require('./models/vacation')

现在我们所有的结构都已经定义好了,但是我们的数据库并不是很有趣,因为里面实际上什么都没有。让我们通过一些数据来种植它,使它变得有用。

种植初始数据

我们的数据库中还没有任何度假套餐,所以我们将添加一些来启动我们。最终,您可能希望创建一种管理产品的方式,但是对于本书的目的,我们只打算在代码中执行它(伴随库中的 ch13/00-mongodb/db.js):

Vacation.find((err, vacations) => {
  if(err) return console.error(err)
  if(vacations.length) return

  new Vacation({
    name: 'Hood River Day Trip',
    slug: 'hood-river-day-trip',
    category: 'Day Trip',
    sku: 'HR199',
    description: 'Spend a day sailing on the Columbia and ' +
      'enjoying craft beers in Hood River!',
    location: {
      search: 'Hood River, Oregon, USA',
    },
    price: 99.95,
    tags: ['day trip', 'hood river', 'sailing', 'windsurfing', 'breweries'],
    inSeason: true,
    maximumGuests: 16,
    available: true,
    packagesSold: 0,
  }).save()

  new Vacation({
    name: 'Oregon Coast Getaway',
    slug: 'oregon-coast-getaway',
    category: 'Weekend Getaway',
    sku: 'OC39',
    description: 'Enjoy the ocean air and quaint coastal towns!',
    location: {
      search: 'Cannon Beach, Oregon, USA',
    },
    price: 269.95,
    tags: ['weekend getaway', 'oregon coast', 'beachcombing'],
    inSeason: false,
    maximumGuests: 8,
    available: true,
    packagesSold: 0,
  }).save()

  new Vacation({
      name: 'Rock Climbing in Bend',
      slug: 'rock-climbing-in-bend',
      category: 'Adventure',
      sku: 'B99',
      description: 'Experience the thrill of climbing in the high desert.',
      location: {
        search: 'Bend, Oregon, USA',
      },
      price: 289.95,
      tags: ['weekend getaway', 'bend', 'high desert', 'rock climbing'],
      inSeason: true,
      requiresWaiver: true,
      maximumGuests: 4,
      available: false,
      packagesSold: 0,
      notes: 'The tour guide is currently recovering from a skiing accident.',
  }).save()
})

这里使用了两个 Mongoose 方法。首先是 find,它就像它的名字一样。在这种情况下,它在数据库中找到所有 Vacation 的实例,并调用回调函数返回这个列表。我们这样做是因为我们不想不断地重新添加我们的种子假期:如果数据库中已经有了假期,那么它已经被种子化了,我们可以继续进行。然而,第一次执行这个操作时,find 将返回一个空列表,因此我们继续创建两个假期,然后在它们上调用 save 方法,将这些新对象保存到数据库中。

现在数据已经存入数据库,是时候取回它了!

检索数据

我们已经看到了 find 方法,这是我们将用来显示假期列表的方法。但是,这次我们将向 find 传递一个选项来过滤数据。具体来说,我们只想显示当前可用的假期。

为产品页面创建一个视图,views/vacations.handlebars

<h1>Vacations</h1>
{{#each vacations}}
  <div class="vacation">
    <h3>{{name}}</h3>
    <p>{{description}}</p>
    {{#if inSeason}}
      <span class="price">{{price}}</span>
      <a href="/cart/add?sku={{sku}}" class="btn btn-default">Buy Now!</a>
    {{else}}
      <span class="outOfSeason">We're sorry, this vacation is currently
      not in season.
      {{! The "notify me when this vacation is in season"
          page will be our next task. }}
      <a href="/notify-me-when-in-season?sku={{sku}}">Notify me when
      this vacation is in season.</a>
    {{/if}}
  </div>
{{/each}}

现在我们可以创建路由处理程序来连接所有这些。在 lib/handlers.js 中(不要忘记导入 ../db),我们创建处理程序:

exports.listVacations = async (req, res) => {
  const vacations = await db.getVacations({ available: true })
  const context = {
    vacations: vacations.map(vacation => ({
      sku: vacation.sku,
      name: vacation.name,
      description: vacation.description,
      price: '$' + vacation.price.toFixed(2),
      inSeason: vacation.inSeason,
    }))
  }
  res.render('vacations', context)
}

我们添加一个调用处理程序的路由,在 meadowlark.js 中:

app.get('/vacations', handlers.listVacations)

如果您运行此示例,您将只看到我们虚拟数据库实现中的一个假期。这是因为我们已初始化了数据库并且播种了数据,但我们还没有用真实的数据库替换虚拟实现。所以现在让我们来做这个。打开 db.js 并修改 getVacations

module.exports = {
  getVacations: async (options = {}) => Vacation.find(options),
  addVacationInSeasonListener: async (email, sku) => {
    //...
  },
}

这太容易了!只是一个一行代码。部分原因是因为 Mongoose 为我们做了很多繁重的工作,而且我们设计的 API 方式类似于 Mongoose 的工作方式。当我们稍后将其适应 PostgreSQL 时,您会看到我们需要做更多的工作。

注意

机敏的读者可能会担心我们的数据库抽象层并没有太多的“保护”技术中立的目标。例如,开发者可能会阅读这段代码,并且发现他们可以将任何 Mongoose 选项传递给假期模型,这样应用程序就会使用特定于 Mongoose 的功能,这将使得切换数据库变得更加困难。我们可以采取一些措施来防止这种情况发生。我们不仅仅是将东西传递给 Mongoose,而是要寻找特定的选项并明确地处理它们,以表明任何实现都必须提供这些选项。但是出于本例的考虑,我们将放任这一点,并保持这段代码的简单性。

大多数内容应该看起来很熟悉,但可能会有一些令您惊讶的地方。例如,我们如何处理假期列表的视图上下文可能看起来有点奇怪。为什么我们要将从数据库返回的产品映射到一个几乎相同的对象?一个原因是我们想以整齐格式显示价格,所以我们必须将其转换为格式化的字符串。

我们本可以通过这样做来节省一些输入:

const context = {
  vacations: products.map(vacations => {
    vacation.price = '$' + vacation.price.toFixed(2)
    return vacation
  })
}

这肯定可以节省我们几行代码,但根据我的经验,有很多理由不直接将未映射的数据库对象传递给视图。视图获得了一堆可能不需要的属性,可能还以与其不兼容的格式。我们的示例目前还相当简单,但一旦开始变得更加复杂,您可能希望对传递给视图的数据进行更多定制。此外,这也很容易意外地暴露机密信息或可能危及网站安全的信息。基于这些理由,我建议映射从数据库返回的数据,并仅将必要的内容传递给视图(必要时进行转换,就像我们对 price 做的那样)。

注意

在某些 MVC 架构的变体中,引入了一个称为 视图模型 的第三个组件。视图模型本质上是将一个(或多个)模型提炼和转换,使其更适合在视图中显示。我们在这里所做的是即时创建一个视图模型。

到目前为止,我们已经走了很长一段路。我们成功地使用数据库存储了关于我们假期的信息。但是,如果我们不能更新它们,数据库就不会太有用。让我们把注意力转向与数据库接口的这个方面。

添加数据

我们已经看到了如何添加数据(在种子化度假集合时添加了数据)以及如何更新数据(在预订度假时更新售出的套餐数量),但让我们看一个稍微复杂的场景,突显文档数据库的灵活性。

当度假不在季节时,我们显示一个链接,邀请客户在度假再次进入季节时通知他们。让我们连接这个功能。首先,我们创建模式和模型(models/vacationInSeasonListener.js):

const mongoose = require('mongoose')

const vacationInSeasonListenerSchema = mongoose.Schema({
  email: String,
  skus: [String],
})
const VacationInSeasonListener = mongoose.model('VacationInSeasonListener',
  vacationInSeasonListenerSchema)

module.exports = VacationInSeasonListener

接下来我们将创建我们的视图,views/notify-me-when-in-season.handlebars

<div class="formContainer">
  <form class="form-horizontal newsletterForm" role="form"
      action="/notify-me-when-in-season" method="POST">
    <input type="hidden" name="sku" value="{{sku}}">
    <div class="form-group">
      <label for="fieldEmail" class="col-sm-2 control-label">Email</label>
      <div class="col-sm-4">
        <input type="email" class="form-control" required
          id="fieldEmail" name="email">
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-offset-2 col-sm-4">
        <button type="submit" class="btn btn-default">Submit</button>
      </div>
    </div>
  </form>
</div>

然后是路由处理程序:

exports.notifyWhenInSeasonForm = (req, res) =>
  res.render('notify-me-when-in-season', { sku: req.query.sku })

exports.notifyWhenInSeasonProcess = (req, res) => {
  const { email, sku } = req.body
  await db.addVacationInSeasonListener(email, sku)
  return res.redirect(303, '/vacations')
}

最后,我们在 db.js 中添加了一个真实的实现:

const VacationInSeasonListener = require('./models/vacationInSeasonListener')

module.exports = {
  getVacations: async (options = {}) => Vacation.find(options),
  addVacationInSeasonListener: async (email, sku) => {
    await VacationInSeasonListener.updateOne(
      { email },
      { $push: { skus: sku } },
      { upsert: true }
    )
  },
}

这是什么魔法?我们如何在 VacationInSeasonListener 集合甚至不存在之前就“更新”记录?答案在于 Mongoose 的一个便利功能叫做 upsert(“update” 和 “insert” 的混成词)。基本上,如果不存在具有给定电子邮件地址的记录,它将被创建。如果记录已存在,则将进行更新。然后,我们使用魔法变量 $push 表示我们要向数组添加一个值。

注意:

此代码不会阻止用户多次填写表单后将多个 SKU 添加到记录中。当度假季节到来时,我们找到所有想收到通知的客户时,必须小心不要多次通知他们。

我们现在肯定已经涵盖了重要的基础知识!我们学会了如何连接到 MongoDB 实例,向其种子化数据,读取数据,并对其进行更新!然而,你可能更喜欢使用关系数据库管理系统,因此让我们改变思路,看看如何使用 PostgreSQL 来完成同样的工作。

PostgreSQL

像 MongoDB 这样的对象数据库非常棒,并且通常更快地开始使用,但如果你尝试构建一个强大的应用程序,你可能会像规划传统关系数据库一样多或更多地工作来构建你的对象数据库结构。此外,你可能已经对关系数据库有经验,或者你可能已经有一个现有的关系数据库需要连接。

幸运的是,在 JavaScript 生态系统中,每个主要的关系型数据库都有强大的支持,如果你需要使用关系型数据库,应该不会有任何问题。

让我们拿我们的度假数据库,并使用关系数据库重新实现它。在这个示例中,我们将使用 PostgreSQL,一个流行且复杂的开源关系数据库。我们将使用的技术和原则对任何关系数据库都是类似的。

与我们用于 MongoDB 的对象数据映射(ODM)类似,针对关系数据库也有对象关系映射(ORM)工具可用。然而,由于大多数对此主题感兴趣的读者可能已经熟悉关系数据库和 SQL,因此我们将直接使用 Node PostgreSQL 客户端。

和 MongoDB 一样,我们将使用一个免费的在线 PostgreSQL 服务。当然,如果你习惯于安装和配置自己的 PostgreSQL 数据库,你也可以这样做。所有要改变的只是连接字符串。如果你使用自己的 PostgreSQL 实例,请确保你使用的是 9.4 或更高版本,因为我们将使用 9.4 引入的 JSON 数据类型(我写这篇文章时,正在使用 11.3)。

有许多在线 PostgreSQL 的选择;在这个示例中,我将使用 ElephantSQL。开始使用简单至极:创建一个账户(你可以使用 GitHub 账号登录),然后点击创建新实例。你只需要给它一个名字(例如,“meadowlark”)并选择一个计划(你可以使用他们的免费计划)。你还需要指定一个区域(试着选择离你最近的那一个)。一旦你设置好了,你会在详情部分找到一些关于你实例的信息。复制 URL(连接字符串),里面包括了用户名、密码和实例位置,都在一个便捷的字符串中。

将该字符串放入你的 .credentials.development.json 文件中:

"postgres": {
  "connectionString": "your_dev_connection_string"
}

对象数据库和关系型数据库(RDBMSs)之间的一个区别是,你通常需要更多的前期工作来定义 RDBMS 的模式,并使用数据定义 SQL 来创建模式,然后再添加或检索数据。为了遵循这个范式,我们将把这作为一个单独的步骤来处理,而不是让我们的 ODM 或 ORM 来处理,就像我们在 MongoDB 中所做的那样。

我们可以创建 SQL 脚本,并使用命令行客户端执行数据定义脚本来创建我们的表,或者我们可以使用 PostgreSQL 客户端 API 在 JavaScript 中完成这项工作,但这是一个只做一次的独立步骤。因为这是关于 Node 和 Express 的书,我们会选择后者来完成这项工作。

首先,我们将需要安装 pg 客户端库 (npm install pg)。然后创建 db-init.js,这将只用于初始化我们的数据库,与我们的 db.js 文件有所区别,后者会在每次服务器启动时被使用(在 companion repo 中的 ch13/01-postgres/db.js):

const { credentials } = require('./config')

const { Client } = require('pg')
const { connectionString } = credentials.postgres
const client = new Client({ connectionString })

const createScript = `
 CREATE TABLE IF NOT EXISTS vacations (
 name varchar(200) NOT NULL,
 slug varchar(200) NOT NULL UNIQUE,
 category varchar(50),
 sku varchar(20),
 description text,
 location_search varchar(100) NOT NULL,
 location_lat double precision,
 location_lng double precision,
 price money,
 tags jsonb,
 in_season boolean,
 available boolean,
 requires_waiver boolean,
 maximum_guests integer,
 notes text,
 packages_sold integer
 );
`

const getVacationCount = async client => {
  const { rows } = await client.query('SELECT COUNT(*) FROM VACATIONS')
  return Number(rows[0].count)
}

const seedVacations = async client => {
  const sql = `
 INSERT INTO vacations(
 name,
 slug,
 category,
 sku,
 description,
 location_search,
 price,
 tags,
 in_season,
 available,
 requires_waiver,
 maximum_guests,
 notes,
 packages_sold
 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
 `
  await client.query(sql, [
    'Hood River Day Trip',
    'hood-river-day-trip',
    'Day Trip',
    'HR199',
    'Spend a day sailing on the Columbia and enjoying craft beers in Hood River!',
    'Hood River, Oregon, USA',
    99.95,
    `["day trip", "hood river", "sailing", "windsurfing", "breweries"]`,
    true,
    true,
    false,
    16,
    null,
    0,
  ])
  // we can use the same pattern to insert other vacation data here...
}

client.connect().then(async () => {
  try {
    console.log('creating database schema')
    await client.query(createScript)
    const vacationCount = await getVacationCount(client)
    if(vacationCount === 0) {
      console.log('seeding vacations')
      await seedVacations(client)
    }
  } catch(err) {
    console.log('ERROR: could not initialize database')
    console.log(err.message)
  } finally {
    client.end()
  }
})

让我们从这个文件的底部开始。我们拿到我们的数据库客户端(client)并对其调用connect(),这将建立数据库连接并返回一个 promise。当 promise 解析后,我们就可以针对数据库采取行动。

首先我们会调用 client.query(createScript),这将创建我们的 vacations 表(也称为关系)。如果我们查看 createScript,我们会看到这是数据定义的 SQL 语句。本书不涉及 SQL 的深入讨论,但如果你正在阅读这一部分,我假设你至少对 SQL 有基本的了解。你可能注意到的一件事是,我们使用蛇形命名法(snake_case)来命名字段,而不是驼峰命名法(camelCase)。也就是说,原来的“inSeason”变成了“in_season”。虽然在 PostgreSQL 中可以使用驼峰命名法来命名结构,但对于任何带有大写字母的标识符都必须加引号,这比它值得的麻烦更多。稍后我们会再次回到这个问题。

你会注意到,我们已经开始更深入地思考我们的模式。假期名称可以有多长?(这里我们随意将其限制在 200 个字符。)类别名称和 SKU 可以有多长?请注意,我们使用 PostgreSQL 的 money 类型来表示价格,并且将 slug 作为我们的主键(而不是添加一个单独的 ID)。

如果你已经熟悉关系数据库,这个简单的模式不会有什么意外。然而,我们处理“标签”的方式可能会引起你的注意。

在传统的数据库设计中,我们可能会创建一个新表来将假期与标签关联起来(这称为规范化)。我们可以在这里这样做。但是在这里,我们可能会决定在传统关系数据库设计与“JavaScript 方式”之间进行一些妥协。如果我们选择两个表(例如vacationsvacation_tags),我们将不得不从两个表中查询数据,以创建一个包含有关假期所有信息的单个对象,就像我们在 MongoDB 示例中所做的那样。可能存在性能原因需要增加这种额外的复杂性,但让我们假设没有,我们只想能够快速确定特定假期的标签。我们可以将其作为文本字段,并用逗号分隔我们的标签,但然后我们将不得不解析出我们的标签,而 PostgreSQL 给了我们一个更好的方法,即 JSON 数据类型。我们很快将看到,通过将其指定为 JSON(jsonb,通常更高性能的二进制表示),我们可以将其存储为 JavaScript 数组,JavaScript 数组与我们在 MongoDB 中看到的一样。

最后,我们通过使用与之前相同的基本概念将我们的种子数据插入到数据库中:如果 vacations 表为空,我们添加一些初始数据;否则,我们假设我们已经完成了这些操作。

你会注意到,插入我们的数据比在 MongoDB 中更加不便。有方法可以解决这个问题,但是对于这个示例,我想明确使用 SQL。我们可以编写一个函数来使插入语句更自然,或者我们可以使用 ORM(稍后详细介绍)。但是现在,SQL 完成了工作,并且对于任何已经了解 SQL 的人来说,应该是舒适的。

请注意,尽管此脚本设计为仅运行一次以初始化和填充我们的数据库,但我们已经以安全的方式编写它。我们包含了IF NOT EXISTS选项,并检查vacations表是否为空,然后再添加种子数据。

我们现在可以运行脚本来初始化我们的数据库:

$ node db-init.js

现在我们已经设置好了数据库,我们可以编写一些代码来在我们的网站中使用它。

数据库服务器通常只能同时处理有限数量的连接,因此 Web 服务器通常实现一种称为连接池的策略,以平衡建立连接的开销与长时间保持连接的危险,从而使服务器负载过重。幸运的是,这些细节由 PostgreSQL Node 客户端为您处理。

这次我们将采用稍微不同的策略处理我们的db.js文件。与其仅仅是一个我们需要导入以建立数据库连接的文件不同,它将返回一个 API,我们编写它来处理与数据库通信的详细信息。

我们在度假模型上还有一个决定要做。回想一下,当我们创建我们的模型时,我们在数据库模式中使用了 snake_case,但所有我们的 JavaScript 代码都使用 camelCase。总体来说,我们在这里有三个选项:

  • 重构我们的模式以使用 camelCase。这将使我们的 SQL 更加丑陋,因为我们必须记得正确引用我们的属性名。

  • 在我们的 JavaScript 中使用 snake_case。虽然这不是理想的,因为我们喜欢标准(对吧?)。

  • 在数据库端使用 snake_case,而在 JavaScript 端转换为 camelCase。这是我们必须做的更多工作,但它可以保持我们的 SQL 和 JavaScript 的整洁。

幸运的是,第三个选项可以自动完成。我们可以编写自己的函数来进行翻译,但我们将依赖于一个流行的实用库称为Lodash,这使得操作变得非常简单。只需运行npm install lodash来安装它。

现在,我们的数据库需求非常简单。我们只需要获取所有可用的度假套餐,因此我们的db.js文件看起来像这样(ch13/01-postgres/db.js在配套存储库中):

const { Pool } = require('pg')
const _ = require('lodash')

const { credentials } = require('./config')

const { connectionString } = credentials.postgres
const pool = new Pool({ connectionString })

module.exports = {
  getVacations: async () => {
    const { rows } = await pool.query('SELECT * FROM VACATIONS')
    return rows.map(row => {
      const vacation = _.mapKeys(row, (v, k) => _.camelCase(k))
      vacation.price = parseFloat(vacation.price.replace(/^\$/, ''))
      vacation.location = {
        search: vacation.locationSearch,
        coordinates: {
          lat: vacation.locationLat,
          lng: vacation.locationLng,
        },
      }
      return vacation
    })
  }
}

简单明了!我们导出一个名为getVacations的单一方法,它按照广告所述执行。它还使用了 Lodash 的mapKeyscamelCase函数将我们的数据库属性转换为 camelCase。

需要注意的一点是,我们必须小心处理 price 属性。PostgreSQL 的 money 类型通过 pg 库转换为已经格式化的字符串。这是有充分理由的:正如我们已经讨论过的,JavaScript 最近才添加了对任意精度数值类型(BigInt)的支持,但目前还没有一个 PostgreSQL 适配器能够利用它(并且在任何情况下这可能也不是最高效的数据类型)。我们可以改变我们的数据库模式,使用数值类型而不是 money 类型,但我们不应该让我们的前端选择来驱动我们的模式。我们也可以处理从 pg 返回的预格式化字符串,但这样做会导致我们所有现有的依赖于 price 为数字的代码都需要改变。此外,这种方法会削弱我们在前端执行数值计算的能力(例如对购物车中商品价格求和)。基于这些原因,我们选择在从数据库检索数据时将字符串解析为数字。

我们还需要将我们的位置信息(“平面”在表中)转换为更接近 JavaScript 结构的形式。我们这样做只是为了与我们的 MongoDB 示例保持一致;我们可以使用其现有的结构(或修改我们的 MongoDB 示例以具有平面结构)。

我们需要学习的最后一件事是如何使用 PostgreSQL 更新数据,所以让我们填写“旺季假期”侦听器功能。

添加数据

就像 MongoDB 示例一样,我们将使用我们的“旺季假期”侦听器示例。我们将首先在 db-init.jscreateScript 字符串中添加以下数据定义:

CREATE TABLE IF NOT EXISTS vacation_in_season_listeners (
  email varchar(200) NOT NULL,
  sku varchar(20) NOT NULL,
  PRIMARY KEY (email, sku)
);

请记住,我们小心地以非破坏性的方式编写了 db-init.js,这样我们可以随时运行它。因此,我们可以再次运行它来创建 vacation_in_season_listeners 表。

现在我们可以修改 db.js 来包含一个更新这个表的方法:

module.exports = {
  //...
  addVacationInSeasonListener: async (email, sku) => {
    await pool.query(
      'INSERT INTO vacation_in_season_listeners (email, sku) ' +
      'VALUES ($1, $2) ' +
      'ON CONFLICT DO NOTHING',
      [email, sku]
    )
  },
}

PostgreSQL 的 ON CONFLICT 子句实际上启用了 upserts。在这种情况下,如果邮箱和 SKU 的确切组合已经存在,用户已经注册以便收到通知,因此我们无需采取任何行动。如果我们在表中有其他列(例如上次注册日期),我们可能需要使用更复杂的 ON CONFLICT 子句(有关更多信息,请参阅 PostgreSQL INSERT documentation)。还要注意,此行为取决于我们如何定义表格。我们将邮箱和 SKU 定义为复合主键,这意味着不能有重复,这进而需要 ON CONFLICT 子句(否则,当用户尝试在同一个假期上注册通知时,INSERT 命令会导致错误)。

现在我们已经看到了如何连接两种类型的数据库的完整示例,一个是对象数据库,另一个是关系数据库管理系统(RDBMS)。可以清楚地看到数据库的功能是一样的:以一种一致和可扩展的方式存储、检索和更新数据。由于功能相同,我们能够创建一个抽象层,以便选择不同的数据库技术。我们可能需要一个数据库的最后一件事是用于持久化会话存储,这在第九章中有所提及。

使用数据库进行会话存储

正如我们在第九章中讨论的那样,在生产环境中使用内存存储会话数据是不合适的。幸运的是,使用数据库作为会话存储是很容易的。

虽然我们可以使用现有的 MongoDB 或 PostgreSQL 数据库作为会话存储,但完整的数据库对于会话存储来说可能有些过度,对于键值数据库来说却是一个完美的使用案例。截至我写这篇文章时,用于会话存储的最流行的键值数据库是RedisMemcached。与本章中的其他示例保持一致,我们将使用一个免费的在线服务来提供 Redis 数据库。

首先,请访问Redis Labs,创建一个账户。然后创建一个免费的订阅计划。选择缓存作为计划,并给数据库起个名字;其余的设置可以保持默认。

您将会看到一个查看数据库的屏幕,就我所知,关键信息需要几秒钟才能显示,请耐心等待。您需要的是端点字段和访问控制与安全下的 Redis 密码(默认情况下是隐藏的,但旁边有一个按钮可以显示它)。将它们放入您的.credentials.development.json文件中:

"redis": {
  "url": "redis://:<YOUR PASSWORD>@<YOUR ENDPOINT>"
}

注意这个稍微奇怪的 URL:通常在密码前面会有一个用户名,但 Redis 允许仅使用密码连接;然而,在密码前的冒号仍然是必需的。

我们将使用一个叫做connect-redis的包来提供 Redis 会话存储。一旦你安装了它(npm install connect-redis),我们就可以在主应用程序文件中设置它。我们仍然使用express-session,但现在我们传递一个新的属性store给它,这将配置它使用数据库。请注意,我们必须将expressSession传递给从connect-redis返回的函数,以获取构造函数:这是会话存储的一个常见特性(在伴随代码库的ch13/00-mongodb/meadowlark.jsch13/01-postgres/meadowlark.js中):

const expressSession = require('express-session')
const RedisStore = require('connect-redis')(expressSession)

app.use(cookieParser(credentials.cookieSecret))
app.use(expressSession({
  resave: false,
  saveUninitialized: false,
  secret: credentials.cookieSecret,
  store: new RedisStore({
    url: credentials.redis.url,
    logErrors: true,  // highly recommended!
  }),
}))

现在让我们将我们新建的会话存储用于实际的用途。假设我们希望能够以不同的货币显示度假价格。此外,我们希望网站记住用户的货币偏好。

我们将从在度假页面底部添加一个货币选择器开始:

<hr>
<p>Currency:
    <a href="/set-currency/USD" class="currency {{currencyUSD}}">USD</a> |
    <a href="/set-currency/GBP" class="currency {{currencyGBP}}">GBP</a> |
    <a href="/set-currency/BTC" class="currency {{currencyBTC}}">BTC</a>
</p>

现在来看一些 CSS 代码(你可以将其嵌入到 views/layouts/main.handlebars 文件中,或者链接到 public 目录下的 CSS 文件中):

a.currency {
  text-decoration: none;
}
.currency.selected {
  font-weight: bold;
  font-size: 150%;
}

最后,我们将添加一个路由处理程序来设置货币,并修改我们的 /vacations 路由处理程序以在当前货币中显示价格(ch13/00-mongodb/lib/handlers.js 或者 ch13/01-postgres/lib/handlers.js 在配套代码库中):

exports.setCurrency = (req, res) => {
  req.session.currency = req.params.currency
  return res.redirect(303, '/vacations')
}

function convertFromUSD(value, currency) {
  switch(currency) {
    case 'USD': return value * 1
    case 'GBP': return value * 0.79
    case 'BTC': return value * 0.000078
    default: return NaN
  }
}

exports.listVacations = (req, res) => {
  Vacation.find({ available: true }, (err, vacations) => {
    const currency = req.session.currency || 'USD'
    const context = {
      currency: currency,
      vacations: vacations.map(vacation => {
        return {
          sku: vacation.sku,
          name: vacation.name,
          description: vacation.description,
          inSeason: vacation.inSeason,
          price: convertFromUSD(vacation.price, currency),
          qty: vacation.qty,
        }
      })
    }
    switch(currency){
      case 'USD': context.currencyUSD = 'selected'; break
      case 'GBP': context.currencyGBP = 'selected'; break
      case 'BTC': context.currencyBTC = 'selected'; break
    }
    res.render('vacations', context)
  })
}

你还需要在 meadowlark.js 中添加一个设置货币的路由:

app.get('/set-currency/:currency', handlers.setCurrency)

当然,这并不是执行货币转换的好方法。我们希望利用第三方货币转换 API 来确保我们的汇率是最新的。但这对于演示目的已经足够了。你现在可以在各种货币之间切换,并且——试试看——停止和重新启动你的服务器。你会发现它记住了你的货币偏好!如果清除了你的 Cookie,货币偏好将被遗忘。你会注意到,现在我们失去了我们漂亮的货币格式化;现在它更加复杂,我将把这留给读者作为一个练习。

另一个读者的练习是将 set-currency 路由通用化,使其更有用。目前,它总是重定向到度假页面,但如果你想在购物车页面上使用它呢?看看你能不能想出一两种解决这个问题的方法。

如果你查看你的数据库,你会发现有一个名为 sessions 的新集合。如果你探索该集合,你会找到一个带有你的会话 ID(属性 sid)和你的货币偏好的文档。

结论

在这一章中,我们确实涵盖了很多内容。对于大多数 Web 应用程序来说,数据库是使应用程序有用的核心。设计和调优数据库是一个广泛的主题,可能需要多本书来覆盖,但我希望这为你提供了连接两种类型数据库和移动数据所需的基本工具。

现在我们已经放置了这个基本的部分,我们将重新访问路由和它在 Web 应用中的重要性。

第十四章:路由

路由是你的网站或网络服务中最重要的方面之一;幸运的是,在 Express 中进行路由是简单、灵活和强大的。路由是通过 URL 和 HTTP 方法指定的请求被路由到处理它们的代码的机制。正如我们已经指出的那样,路由过去是基于文件且简单的。例如,如果你在网站上放置了文件foo/about.html,你可以通过浏览器的路径/foo/about.html访问它。简单但不灵活。而且,如果你还没有注意到,在你的 URL 中有html这个词现在已经非常过时了。

在我们深入讨论使用 Express 进行路由的技术细节之前,我们应该先讨论信息架构(IA)的概念。IA 指的是你内容的概念性组织。在你开始考虑路由之前,拥有一个可扩展(但不过于复杂)的 IA 将会在以后带来巨大的回报。

有关信息架构(IA)的最聪明和永恒的文章之一是由实际上发明互联网的 Tim Berners-Lee 所写的。你现在可以(也应该)阅读它:http://www.w3.org/Provider/Style/URI.html。这篇文章写于 1998 年。让你沉思片刻;在 1998 年关于互联网技术写的东西,到今天依然有不多的能像它那样真实。

从那篇文章中,我们被要求承担起的崇高责任是:

作为网站管理员,你有责任为 URI 分配能够在 2 年、20 年甚至 200 年后仍然坚持的 URI。这需要思考、组织和承诺。

Tim Berners-Lee

如果网页设计需要像其他工程一样进行专业许可,我喜欢想象我们会为此效果发誓。(那篇文章的敏锐读者会发现,该文章的网址以.html结尾,这一点很有幽默感。)

打个比方(这可能对年轻观众来说有些难以理解),想象一下,每两年你最喜欢的图书馆都会完全重新排列杜威十进制系统。你某一天走进图书馆,却发现找不到任何东西。这正是当你重新设计 URL 结构时所发生的事情。

认真考虑你的 URL。20 年后它们是否还有意义?(200 年可能有些牵强:谁知道那时我们是否还会使用 URL 呢。但是,我钦佩那种能够这么远见的决心。)仔细考虑你内容的分类方式。逻辑地分类,并且尽量避免把自己限制在一个角落里。这既是一门科学,也是一门艺术。

或许最重要的是,与他人合作设计您的网址。即使您是方圆数英里内最好的信息架构师,您可能会惊讶地发现人们对相同内容有不同的看法。我并不是说您应该尝试制定一个从所有人的角度来看都有意义的 IA(因为这通常是不可能的),但能够从多个角度看问题会给您带来更好的想法,并暴露出您自己 IA 中的缺陷。

这里有一些建议,可帮助您实现持久的 IA:

永远不要在网址中公开技术细节

您是否曾经访问过一个网站,注意到网址以.asp结尾,并认为该网站已经过时了?请记住,曾经有一段时间,ASP 技术是尖端的。虽然我很痛苦地说,JavaScript、JSON、Node 和 Express 也将如此。希望它们能够多年多年地发挥作用,但时间对技术往往并不宽容。

避免在网址中包含无意义的信息

仔细考虑您网址中的每个单词。如果没有意义,就不要包括在内。例如,当网站在网址中使用“home”一词时,我总是感到不舒服。您的根网址就是您的主页。您不需要额外拥有像/home/directions/home/contact这样的网址。

避免过长的网址

在所有事情都相等的情况下,短网址比长网址更好。然而,你不应该试图以牺牲清晰度或 SEO 为代价来缩短网址。缩写很诱人,但需要仔细考虑。它们在成为网址之前应该是常见且无处不在的。

请一致使用单词分隔符

使用连字符分隔单词相当普遍,使用下划线稍少见一些。连字符通常被认为比下划线更美观,大多数 SEO 专家推荐使用它们。无论您选择连字符还是下划线,都要在使用上保持一致。

永远不要在网址中使用空格或不可输入字符

不建议在网址中使用空白。通常会被转换为加号(+),导致混乱。显然,您应该避免不可输入字符,并强烈警告您不要在网址中使用除字母、数字、短横线和下划线以外的任何字符。这在当时可能会感觉很聪明,但是“聪明”往往经不起时间的考验。显然,如果您的网站不是针对英语受众的,您可以使用非英语字符(使用百分比编码),尽管这可能在需要本地化您的网站时带来麻烦。

使用小写字母来编写您的网址

这个问题可能会引发一些争论。有些人认为在 URL 中使用大小写混合不仅可以接受,而且更可取。我不想在这个问题上进行宗教性的辩论,但我要指出小写的优势在于它可以始终通过代码自动生成。如果你曾经不得不处理成千上万个链接或进行字符串比较,你会欣赏这个论点。我个人觉得小写 URL 更具审美感,但最终,这个决定由你来做。

路径和 SEO

如果你希望你的网站被发现(大多数人都希望如此),那么你需要考虑 SEO 以及你的 URL 如何影响它。特别是,如果某些关键词很重要——而且有意义——考虑将它们作为 URL 的一部分。例如,Meadowlark Travel 提供多个俄勒冈海岸度假选择。为了确保这些度假选项在搜索引擎中排名靠前,我们在标题、头部、正文和元描述中使用“Oregon Coast”这个字符串,并且 URL 以/vacations/oregon-coast开头。曼扎尼塔度假套餐可以在/vacations/oregon-coast/manzanita找到。如果我们简单地使用/vacations/manzanita来缩短 URL,我们将会失去宝贵的 SEO 机会。

尽管如此,不要贸然将关键词塞进 URL 中以试图提高排名。这是行不通的。例如,试图将曼扎尼塔度假的 URL 更改为/vacations/oregon-coast-portland-and-hood-river/oregon-coast/manzanita,以此来多说一次“Oregon Coast”,同时还加入“Portland”和“Hood River”关键词,这是不合适的。这与良好的信息架构相悖,很可能会适得其反。

子域名

除了路径之外,子域名是常用于路由请求的 URL 的另一部分。子域名最好保留给应用程序的显著不同部分——例如,一个 REST API (api.meadowlarktravel.com)或管理员界面 (admin.meadowlarktravel.com)。有时候出于技术原因使用子域名。例如,如果我们用 WordPress 来构建我们的博客(而其余的网站使用 Express),使用blog.meadowlarktravel.com可能更容易(更好的解决方案是使用代理服务器,比如 NGINX)。通常通过使用子域名来分隔内容会对 SEO 产生影响,这就是为什么你通常应该将它们保留给对 SEO 不重要的站点区域,比如管理区域和 API。记住这一点,并确保在将子域名用于对 SEO 计划重要的内容之前,没有其他更好的选择。

Express 中的路由机制默认不考虑子域:app.get(*/about*)将处理关于 http://meadowlarktravel.com/abouthttp://www.meadowlarktravel.com/abouthttp://admin.meadowlarktravel.com/about 的请求。如果你想单独处理子域,可以使用一个名为vhost(代表“虚拟主机”,这来自于经常用于处理子域的 Apache 机制)的包。首先,安装这个包(npm install vhost)。要在开发机上测试基于域的路由,你需要一种“伪造”域名的方法。幸运的是,这正是你的hosts 文件需要做的。在 macOS 和 Linux 机器上,它可以在/etc/hosts中找到,在 Windows 上,它位于c:\windows\system32\drivers\etc\hosts。在你的 hosts 文件中添加以下内容(你需要管理权限来编辑它):

127.0.0.1 admin.meadowlark.local
127.0.0.1 meadowlark.local

这告诉你的计算机将meadowlark.localadmin.meadowlark.local视为常规的互联网域,但将它们映射到本地主机(127.0.0.1)。我们使用.local顶级域,这样就不会混淆(你可以使用.com或任何其他互联网域,但它会覆盖真实域名,这可能会导致挫折)。

然后你可以使用vhost中间件来使用基于域的路由(伴随仓库中的 ch14/00-subdomains.js):

// create "admin" subdomain...this should appear
// before all your other routes
var admin = express.Router()
app.use(vhost('admin.meadowlark.local', admin))

// create admin routes; these can be defined anywhere
admin.get('*', (req, res) => res.send('Welcome, Admin!'))

// regular routes
app.get('*', (req, res) => res.send('Welcome, User!'))

express.Router()本质上创建了一个新的 Express 路由的实例。你可以像处理app一样处理这个实例。你可以添加路由和中间件,就像对app做的一样。然而,在你将其添加到app之前,它不会做任何事情。我们通过vhost来添加它,将该路由实例绑定到子域。

提示

express.Router还可以用于分区你的路由,这样你可以一次链接多个路由处理程序。查看Express 路由文档以获取更多信息。

路由处理程序就是中间件

我们已经看到了匹配给定路径的基本路由。但app.get(\'/foo', ...)实际上做了什么呢?正如我们在第十章中所看到的,它只是一种专门的中间件,甚至有一个传入的next方法。让我们看一些更复杂的例子(伴随仓库中的 ch14/01-fifty-fifty.js):

app.get('/fifty-fifty', (req, res, next) => {
  if(Math.random() < 0.5) return next()
  res.send('sometimes this')
})
app.get('/fifty-fifty', (req,res) => {
  res.send('and sometimes that')
})

在上一个示例中,我们为相同的路由设置了两个处理程序。通常情况下,第一个处理程序会胜出,但在这种情况下,第一个处理程序将有大约一半的机会失败,给第二个处理程序一个机会。我们甚至不必两次使用 app.get:你可以为单个app.get调用使用多个处理程序。以下是一个示例,它有大约等概率的三种不同的响应(伴随仓库中的 ch14/02-red-green-blue.js):

app.get('/rgb',
  (req, res, next) => {
    // about a third of the requests will return "red"
    if(Math.random() < 0.33) return next()
    res.send('red')
  },
  (req, res, next) => {
    // half of the remaining 2/3 of requests (so another third)
    // will return "green"
    if(Math.random() < 0.5) return next()
    res.send('green')
  },
  function(req, res){
    // and the last third returns "blue"
    res.send('blue')
  },
)

虽然这一开始看起来可能并不特别有用,但它允许你创建能够在任何路由中使用的通用函数。例如,假设我们有一种机制,在特定页面上显示特别优惠。这些特别优惠经常变动,并非所有页面都显示。我们可以创建一个函数将特别优惠注入到res.locals属性中(你会在第七章中记得此属性)(伴随版本库中的 ch14/03-specials.js)。

async function specials(req, res, next) {
  res.locals.special = await getSpecialsFromDatabase()
  next()
}

app.get('/page-with-specials', specials, (req, res) =>
  res.render('page-with-specials')
)

我们还可以使用这种方法实现授权机制。假设我们的用户授权代码设置了一个名为 req.session.authorized 的会话变量。我们可以使用以下方法来创建一个可重用的授权过滤器(伴随版本库中的 ch14/04-authorizer.js)。

function authorize(req, res, next) {
  if(req.session.authorized) return next()
  res.render('not-authorized')
}

app.get('/public', () => res.render('public'))

app.get('/secret', authorize, () => res.render('secret'))

路由路径和正则表达式

当你在路由中指定一个路径(比如 /foo)时,Express 最终将其转换为正则表达式。路由路径中可用一些正则表达式元字符:+, ?, *, ()。让我们看一些示例。假设你希望处理 /user/username 这两个 URL 的路由相同:

app.get('/user(name)?', (req, res) => res.render('user'))

我最喜欢的一个新奇网站——现在可惜已经倒闭了——是 http://khaaan.com。那只是每个人最喜欢的星际舰队长高呼他最具代表性的台词。毫无用处,但每次看到都会让我微笑。假设我们想要创建我们自己的“KHAAAAAAAAN”页面,但我们不想让用户记住是 2 个 a 还是 3 个或 10 个。下面的方法可以实现:

app.get('/khaa+n', (req, res) => res.render('khaaan'))

并非所有常规正则表达式元字符在路由路径中都有意义,只有之前列出的那些元字符才有意义。这一点很重要,因为通常情况下,点号作为正则表达式元字符表示“任何字符”,可以在路由中未经转义地使用。

最后,如果你确实需要路由的完整正则表达式的全部功能,Express 也支持。

app.get(/crazy|mad(ness)?|lunacy/, (req,res) =>
  res.render('madness')
)

我还没有找到在我的路由路径中使用正则表达式元字符,更不用说完整的正则表达式的充分理由,但了解这些功能是存在的是很好的。

路由参数

虽然在 Express 工具箱中,正则表达式路由可能很少在日常工作中使用,但你很可能会经常使用路由参数。简而言之,这是一种将路由的一部分转换为变量参数的方法。假设在我们的网站中,我们想为每位员工创建一个页面。我们有一个带有生平和照片的员工数据库。随着公司的不断发展,每增加一位员工,为其增加一个新的路由变得越来越困难。让我们看看路由参数如何帮助我们(伴随版本库中的 ch14/05-staff.js)。

const staff = {
  mitch: { name: "Mitch",
    bio: 'Mitch is the man to have at your back in a bar fight.' },
  madeline: { name: "Madeline", bio: 'Madeline is our Oregon expert.' },
  walt: { name: "Walt", bio: 'Walt is our Oregon Coast expert.' },
}

app.get('/staff/:name', (req, res, next) => {
  const info = staff[req.params.name]
  if(!info) return next()   // will eventually fall through to 404
  res.render('05-staffer', info)
})

注意我们在路由中使用了 :name。这将匹配任意字符串(不包括斜杠)并将其放入 req.params 对象的键 name 中。这是一个我们会经常使用的功能,特别是在创建 REST API 时。我们的路由中可以有多个参数。例如,如果我们想要按城市列出我们的员工名单,我们可以使用如下方式:

const staff = {
  portland: {
    mitch: { name: "Mitch", bio: 'Mitch is the man to have at your back.' },
    madeline: { name: "Madeline", bio: 'Madeline is our Oregon expert.' },
  },
  bend: {
    walt: { name: "Walt", bio: 'Walt is our Oregon Coast expert.' },
  },
}

app.get('/staff/:city/:name', (req, res, next) => {
  const cityStaff = staff[req.params.city]
  if(!cityStaff) return next()  // unrecognized city -> 404
  const info = cityStaff[req.params.name]
  if(!info) return next()       // unrecognized staffer -> 404
  res.render('staffer', info)
})

组织路由

对您来说可能已经很清楚,在主应用程序文件中定义所有路由将变得难以管理。不仅这个文件会随着时间的推移而增长,而且它也不是一个良好的功能分离,因为该文件中已经有很多内容。一个简单的网站可能只有十几个或更少的路由,但一个更大的网站可能有数百个路由。

那么如何组织您的路由呢?好吧,您想如何组织您的路由?Express 并不关心您如何组织您的路由,因此您可以做的只受您自己想象的限制。

在接下来的章节中,我将介绍一些处理路由的流行方法,但归根结底,我建议为确定如何组织您的路由提供四个指导原则:

使用命名函数作为路由处理程序

写内联的路由处理程序,通过直接定义处理路由的函数来进行实际定义,对于小应用程序或原型设计是可以的,但随着您的网站增长,它将很快变得难以管理。

路由不应是神秘的

这个原则有意地模糊,因为一个大型复杂的网站可能需要比一个 10 页网站更复杂的组织方案。在光谱的一端是将您网站的所有路由都放在一个单一文件中,这样您就知道它们在哪里。对于大型网站来说,这可能是不理想的,因此您可以按功能区域拆分路由。然而,即使这样,应该清楚应该去哪里查找特定的路由。当您需要修复某些内容时,您最不希望做的就是花一个小时去找出路由处理的位置。我在工作中有一个 ASP.NET MVC 项目在这方面是一个噩梦。路由在至少 10 个不同的地方处理,这既不合逻辑也不一致,经常是相互矛盾的。即使我对那个(非常大的)网站非常熟悉,我仍然不得不花费大量时间去追踪某些 URL 的处理位置。

路由组织应该是可扩展的

如果您现在有 20 或 30 个路由,将它们全部定义在一个文件中可能就可以了。但是三年后当您有 200 个路由时呢?这是有可能的。无论您选择哪种方法,都应确保您有足够的空间来扩展。

不要忽视自动视图路由处理程序

如果您的网站包含许多静态页面并且具有固定的 URL,那么您的所有路由最终都会看起来像这样:app.get('/static/thing', (req, res) => res.render(\'static/thing'))。为了减少不必要的代码重复,考虑使用自动视图路由处理程序。本章稍后将介绍这种方法,并可与自定义路由一起使用。

在一个模块中声明路由

组织路由的第一步是将它们全部放入它们自己的模块中。有多种方法可以做到这一点。一种方法是使您的模块返回一个包含方法和处理程序属性的对象数组。然后您可以在应用程序文件中定义路由如下:

const routes = require('./routes.js')

routes.forEach(route => approute.method)

这种方法有其优势,并且非常适合动态存储我们的路由,比如存储在数据库或 JSON 文件中。然而,如果你不需要这种功能,我建议将 app 实例传递给模块,让它添加路由。这是我们示例中采用的方法。创建一个名为 routes.js 的文件,并将所有现有的路由移到其中:

module.exports = app => {

  app.get('/', (req,res) => app.render('home'))

  //...

}

如果我们只是简单地复制粘贴,可能会遇到一些问题。例如,如果我们有使用新上下文中不可用的变量或方法的内联路由处理程序,那么这些引用现在将会失效。我们可以添加必要的导入,但先暂停。我们很快将把处理程序移到它们自己的模块中,并在那时解决这个问题。

如何将我们的路由链接起来?简单:在 meadowlark.js 中,我们只需导入我们的路由:

require('./routes')(app)

或者我们可以更加显式,添加一个命名导入(我们将其命名为 addRoutes,以更好地反映它作为函数的性质;如果需要,我们也可以将文件命名为这样):

const addRoutes = require('./routes')

addRoutes(app)

逻辑分组处理程序

为了符合我们的第一个指导原则(为路由处理程序使用命名函数),我们需要一个地方来放置这些处理程序。一个相当极端的选择是为每个处理程序单独创建一个 JavaScript 文件。我很难想象这种方法会有什么好处。最好是以某种方式将相关功能组合在一起。这不仅使共享功能更容易利用,还使相关方法的更改更容易。

目前,让我们将功能分组到不同的文件中:handlers/main.js,我们将在其中放置主页处理程序,“关于”处理程序,以及通常没有其他逻辑归属的任何处理程序;handlers/vacations.js,用于处理与假期相关的处理程序;等等。

考虑 handlers/main.js

const fortune = require('../lib/fortune')

exports.home = (req, res) => res.render('home')

exports.about = (req, res) => {
  const fortune = fortune.getFortune()
  res.render('about', { fortune })
}

//...

现在让我们修改 routes.js 来利用这一点:

const main = require('./handlers/main')

module.exports = function(app) {

  app.get('/', main.home)
  app.get('/about', main.about)
  //...

}

这满足了我们所有的指导原则。/routes.js 非常简单直接。一眼就能看到网站中的所有路由以及它们的处理位置。我们还留了足够的空间来扩展。我们可以将相关功能分组到任意数量的不同文件中。如果 routes.js 变得过于复杂,我们可以再次使用相同的技术,将 app 对象传递给另一个模块,然后注册更多的路由(尽管这开始变得“过于复杂”——确保你确实能够证明这样一个复杂的方法是有必要的!)。

自动渲染视图

如果你发现自己想要回到过去的日子,当你只需将 HTML 文件放入目录中,然后——神奇地!——你的网站就会提供它,那么你并不孤单。如果你的网站内容丰富,但功能不多,你可能会觉得为每个视图添加路由是一种不必要的麻烦。幸运的是,我们可以解决这个问题。

假设您想要添加文件views/foo.handlebars并在路由/foo上自动使其可用。让我们看看我们可以如何做到这一点。在我们的应用程序文件中,在 404 处理程序之前,添加以下中间件(在配套存储库中的ch14/06-auto-views.js):

const autoViews = {}
const fs = require('fs')
const { promisify } = require('util')
const fileExists = promisify(fs.exists)

app.use(async (req, res, next) => {
  const path = req.path.toLowerCase()
  // check cache; if it's there, render the view
  if(autoViews[path]) return res.render(autoViews[path])
  // if it's not in the cache, see if there's
  // a .handlebars file that matches
  if(await fileExists(__dirname + '/views' + path + '.handlebars')) {
    autoViews[path] = path.replace(/^\//, '')
    return res.render(autoViews[path])
  }
  // no view found; pass on to 404 handler
  next()
})

现在我们只需在view目录中添加一个.handlebars文件,并在适当的路径上进行神奇渲染即可。请注意,常规路由将绕过此机制(因为我们将自动视图处理程序放置在所有其他路由之后),因此,如果您有一个为路由/foo渲染不同视图的路由,那么它将优先处理。

请注意,如果您删除了访问过的视图,这种方法将会遇到问题;它将被添加到autoViews对象中,因此后续的视图尝试渲染它,即使它已被删除,也会导致错误。可以通过在try/catch块中包装渲染并在发现错误时从autoViews中删除该视图来解决此问题;我将把这个增强功能留给读者来练习。

结论

路由是项目中的重要部分,比这里概述的组织路由处理程序的方法更多,所以请随意尝试并找到适合您和您的项目的技术。我鼓励您偏爱清晰且易于跟踪的技术。路由在很大程度上是从外部世界(通常是浏览器的客户端)到响应它的服务器端代码的地图。如果这张地图复杂混乱,将会使您难以追踪应用程序中的信息流,这将影响开发和调试。

第十五章:REST API 和 JSON

虽然我们在 第八章 中看到了一些 REST API 的例子,但到目前为止,我们的范式主要是“在服务器端处理数据并向客户端发送格式化的 HTML”。越来越多的现代 Web 应用程序不再采用这种默认操作模式。相反,大多数现代 Web 应用程序都是单页面应用程序(SPA),它们在一个静态捆绑包中接收所有的 HTML 和 CSS,然后依赖于接收 JSON 格式的非结构化数据,并直接操作 HTML。同样地,通过提交表单来通信并将更改发送到服务器的重要性正逐渐让位于通过 HTTP 请求直接与 API 进行通信。

现在是时候把注意力转向使用 Express 提供 API 端点,而不是预先格式化的 HTML 了。在 第十六章 中,我们将展示如何使用我们的 API 动态渲染应用程序。

在本章中,我们将简化我们的应用程序,提供一个“即将推出”的 HTML 界面:我们将在 第十六章 中填充内容。而我们将专注于一个 API,该 API 将提供对我们的假期数据库的访问,并支持注册“淡季”侦听器的 API 功能。

Web 服务 是一个泛指,指任何可以通过 HTTP 访问的应用程序编程接口(API)。Web 服务的概念已经存在了相当长的时间,但直到最近,使其能够运行的技术仍然是古板、拜占庭式和过于复杂的。仍然有一些系统使用这些技术(如 SOAP 和 WSDL),并且有 Node 包可以帮助您与这些系统进行接口。不过,我们不会涵盖这些内容。相反,我们将专注于提供所谓的 RESTful 服务,这些服务要简单得多,易于接口化。

缩写 REST 表示 表述性状态转移,而在语法上令人困扰的 RESTful 则用作描述符来描述满足 REST 原则的 Web 服务。REST 的正式描述复杂且充满计算机科学的形式化,但基本原理是 REST 是客户端和服务器之间的无状态连接。REST 的正式定义还指定服务可以被缓存,并且服务可以被分层(也就是说,当你使用 REST API 时,可能会有其他 REST API 在其下层)。

从实际角度来看,HTTP 的约束实际上使得创建非 RESTful API 变得困难;例如,你必须特意去建立状态。因此,我们的工作大部分已经为我们完成。

JSON 和 XML

提供 API 至关重要的是有一个共同的语言来交流。通信的一部分是我们必须使用 HTTP 方法与服务器通信。但过了这一点,我们可以自由选择任何数据语言。传统上,XML 一直是一个流行的选择,并且仍然是一个重要的标记语言。虽然 XML 并不特别复杂,但 Douglas Crockford 发现有更轻量级的空间,于是 JavaScript 对象表示法(JSON)诞生了。除了友好于 JavaScript(尽管它绝不是专有的;任何语言都可以轻松解析它),它还具有比 XML 更容易手工编写的优势。

大多数应用程序我更喜欢 JSON 而不是 XML:因为它有更好的 JavaScript 支持,并且它是一种更简单、更紧凑的格式。如果现有系统需要 XML 来与您的应用程序通信,我建议重点关注 JSON,只提供 XML。

我们的 API

我们将在开始实施之前计划我们的 API。除了列出假期并订阅“在季节内”的通知外,我们还将添加“删除假期”端点。由于这是一个公共 API,我们实际上不会删除假期。我们只是将其标记为“请求删除”,以便管理员可以审查。例如,您可以使用此不安全端点允许供应商请求从网站中删除假期,稍后可以由管理员审查。以下是我们的 API 端点。

GET /api/vacations

检索假期

GET /api/vacation/:sku

根据其 SKU 返回假期

POST /api/vacation/:sku/notify-when-in-season

email 作为查询字符串参数,并为指定的假期添加通知侦听器

DELETE /api/vacation/:sku

请求删除一个假期;以 email(请求删除的人)和 notes 作为查询字符串参数

[NOTE]
示例 15-1。

有许多可用的 HTTP 动词。 GETPOST 是最常见的,接着是 DELETEPUT。已经成为一种标准使用 POST创建 某物,而 PUT 用于 更新(或修改)某物。这些词的英文含义并不以任何方式支持此区分,因此您可能希望考虑使用路径来区分这两个操作,以避免混淆。如果您想了解更多关于 HTTP 动词的信息,我建议从这篇Tamas Piros 文章开始。

我们可以以许多方式描述我们的 API。在这里,我们选择使用 HTTP 方法和路径的组合来区分我们的 API 调用,以及通过查询字符串和主体参数的混合来传递数据。作为另一种选择,我们可以使用不同的路径(例如 /api/vacations/delete)来使用相同的方法。^(1) 我们还可以以一种一致的方式传递数据。例如,我们可能选择在 URL 中传递检索参数所需的所有信息,而不是使用查询字符串:DEL /api/vacation/:id/:email/:notes。为了避免过长的 URL,我建议使用请求主体来传递大块数据(例如删除请求的备注)。

提示

有一个广受欢迎且备受尊重的 JSON API 的约定,创意地命名为 JSON:API。对我来说,它有点冗长和重复,但我也相信,一个不完美的标准总比没有标准好。虽然本书不使用 JSON:API,但你将学到采纳 JSON:API 所制定的规范所需的一切。详见 JSON:API 主页 获取更多信息。

API 错误报告

HTTP API 中的错误报告通常通过 HTTP 状态码实现。如果请求返回 200(OK),客户端知道请求成功。如果请求返回 500(内部服务器错误),请求失败。然而,在大多数应用程序中,并非所有事物都能(或应该)粗略地归类为“成功”或“失败”。例如,如果您按 ID 请求某些内容,但该 ID 不存在,这并不代表服务器错误。客户端请求了不存在的内容。通常情况下,错误可以分为以下几类:

严重错误

导致服务器处于不稳定或未知状态的错误。通常情况下,这是未处理异常的结果。从灾难性错误中安全恢复的唯一方法是重新启动服务器。理想情况下,任何待处理请求都应该收到 500 响应代码,但如果故障足够严重,服务器可能无法响应,请求将超时。

可恢复的服务器错误

可恢复错误不需要服务器重新启动或任何其他英勇行动。该错误是由服务器上的意外错误条件导致的(例如,数据库连接不可用)。问题可能是暂时的或永久的。在这种情况下,500 响应代码是合适的。

客户端错误

客户端错误是客户端犯的错误,通常是缺少或无效的参数。使用 500 响应代码是不合适的。毕竟,服务器并未发生故障。一切正常工作,只是客户端没有正确使用 API。在这里您有几个选择:可以用状态码 200 响应,并在响应体中描述错误,或者您还可以尝试使用适当的 HTTP 状态码描述错误。我建议采用后一种方法。在这种情况下,最有用的响应代码是 404(未找到)、400(错误请求)和 401(未授权)。此外,响应体应包含有关错误具体信息的解释。如果您想做得更多,错误消息甚至可以包含指向文档的链接。请注意,如果用户请求一系列内容但没有返回任何内容,这并不是错误条件。直接返回空列表是合适的。

在我们的应用程序中,我们将结合使用 HTTP 响应代码和响应体中的错误消息。

跨源资源共享

如果您发布一个 API,您可能希望将 API 提供给其他人使用。这将导致跨站点 HTTP 请求。跨站点 HTTP 请求一直是许多攻击的对象,因此受到同源策略的限制,该策略限制了脚本可以加载的位置。具体而言,协议、域名和端口必须匹配。这使得您的 API 无法被其他站点使用,这就是跨源资源共享(CORS)的用武之地。CORS 允许您逐案例解除此限制,甚至允许您列出特定允许访问脚本的域。CORS 通过Access-Control-Allow-Origin头部来实现。在 Express 应用程序中实现它的最简单方法是使用cors包(npm install cors)。要为您的应用程序启用 CORS,请使用以下方法:

const cors = require('cors')

app.use(cors())

因为同源 API 有其存在的理由(防止攻击),我建议仅在必要时应用 CORS。在我们的情况下,我们想要公开我们的整个 API(但只有 API),因此我们将 CORS 限制在以/api开头的路径上:

const cors = require('cors')

app.use('/api', cors())

有关更高级 CORS 用法的信息,请参阅包文档

我们的测试

如果我们使用除了GET之外的 HTTP 动词,测试 API 可能会很麻烦,因为浏览器只知道如何发出GET请求(和表单的POST请求)。有办法解决这个问题,比如优秀的应用程序Postman。但是,无论您是否使用这样的实用程序,编写自动化测试都是一个好习惯。在为我们的 API 编写测试之前,我们需要一种实际调用REST API 的方法。为此,我们将使用一个名为node-fetch的 Node 包,它复制了浏览器的fetch API:

npm install --save-dev node-fetch@2.6.0

我们将要实现的 API 调用的测试放在tests/api/api.test.js(伴随库中的ch15/test/api/api.test.js)中:

const fetch = require('node-fetch')

const baseUrl = 'http://localhost:3000'

const _fetch = async (method, path, body) => {
  body = typeof body === 'string' ? body : JSON.stringify(body)
  const headers = { 'Content-Type': 'application/json' }
  const res = await fetch(baseUrl + path, { method, body, headers })
  if(res.status < 200 || res.status > 299)
    throw new Error(`API returned status ${res.status}`)
  return res.json()
}

describe('API tests', () => {

  test('GET /api/vacations', async () => {
    const vacations = await _fetch('get', '/api/vacations')
    expect(vacations.length).not.toBe(0)
    const vacation0 = vacations[0]
    expect(vacation0.name).toMatch(/\w/)
    expect(typeof vacation0.price).toBe('number')
  })

  test('GET /api/vacation/:sku', async() => {
    const vacations = await _fetch('get', '/api/vacations')
    expect(vacations.length).not.toBe(0)
    const vacation0 = vacations[0]
    const vacation = await _fetch('get', '/api/vacation/' + vacation0.sku)
    expect(vacation.name).toBe(vacation0.name)
  })

  test('POST /api/vacation/:sku/notify-when-in-season', async() => {
    const vacations = await _fetch('get', '/api/vacations')
    expect(vacations.length).not.toBe(0)
    const vacation0 = vacations[0]
    // at this moment, all we can do is make sure the HTTP request is successful
    await _fetch('post', `/api/vacation/${vacation0.sku}/notify-when-in-season`,
      { email: 'test@meadowlarktravel.com' })
  })

  test('DELETE /api/vacation/:id', async() => {
    const vacations = await _fetch('get', '/api/vacations')
    expect(vacations.length).not.toBe(0)
    const vacation0 = vacations[0]
    // at this moment, all we can do is make sure the HTTP request is successful
    await _fetch('delete', `/api/vacation/${vacation0.sku}`)
  })

})

我们的测试套件以一个辅助函数_fetch开始,该函数处理一些常见的清理工作。如果尚未对其进行 JSON 编码,它将对请求体进行编码,添加适当的标头,并在响应状态码不在 200 范围内时抛出适当的错误。

对于我们的每个 API 端点,我们只有一个测试。我并不是在暗示这些测试是健壮或完整的;即使对于这个简单的 API,我们也可以(而且应该)为每个端点编写几个测试。我们这里的做法更像是一个起点,用来说明测试 API 的技术。

这些测试有几个重要的特征值得一提。其中之一是我们依赖于 API 已经启动并运行在 3000 端口上。一个更健壮的测试套件会找到一个空闲端口,在设置中启动 API,并在所有测试运行完成后停止它。其次,这个测试依赖于我们 API 中已经存在的数据。例如,第一个测试期望至少存在一个假期,并且该假期具有名称和价格。在真实应用中,您可能无法做出这些假设(例如,可能从无数据开始,并且可能希望测试允许缺少数据)。同样,一个更健壮的测试框架会有一种方法来设置和重置 API 中的初始数据,以便每次都从已知状态开始。例如,您可以编写脚本设置和填充测试数据库,将 API 连接到它,并在每次测试运行时将其清理掉。正如我们在第五章中所看到的,测试是一个广泛而复杂的主题,我们只能在这里探索其表面。

第一个测试涵盖了我们的GET /api/vacations端点。它获取所有的假期,验证至少有一个假期存在,并检查第一个假期是否具有名称和价格。我们还可以考虑测试其他数据属性。我将其留给读者来思考哪些属性是最重要的测试项目。

第二个测试涵盖了我们的GET /api/vacation/:sku端点。由于我们没有一致的测试数据,我们首先获取所有假期,从第一个假期中获取 SKU,以便测试这个端点。

我们最后两个测试涵盖了我们的POST /api/vacation/:sku/notify-when-in-seasonDELETE /api/vacation/:sku端点。不幸的是,根据我们当前的 API 和测试框架,我们几乎无法验证这些端点是否按预期工作,所以我们默认调用它们,并信任 API 在不返回错误时执行正确的操作。如果我们想要使这些测试更加健壮,我们将不得不添加允许验证操作的端点(例如,确定特定假期是否已注册给定电子邮件的端点),或者以某种方式让测试可以“后门”访问我们的数据库。

如果现在运行测试,它们将超时并失败...因为我们尚未实施我们的 API 甚至启动我们的服务器。所以让我们开始吧!

使用 Express 提供 API

Express 非常适合提供 API。有许多 npm 模块可用,提供有用的功能(例如connect-restjson-api),但我发现 Express 在开箱即用时就足够好,我们将坚持使用纯 Express 实现。

我们将从在lib/handlers.js中创建处理程序开始(我们可以创建一个单独的文件,比如lib/api.js,但现在让我们保持简单):

exports.getVacationsApi = async (req, res) => {
  const vacations = await db.getVacations({ available: true })
  res.json(vacations)
}

exports.getVacationBySkuApi = async (req, res) => {
  const vacation = await db.getVacationBySku(req.params.sku)
  res.json(vacation)
}

exports.addVacationInSeasonListenerApi = async (req, res) => {
  await db.addVacationInSeasonListener(req.params.sku, req.body.email)
  res.json({ message: 'success' })
}

exports.requestDeleteVacationApi = async (req, res) => {
  const { email, notes } = req.body
  res.status(500).json({ message: 'not yet implemented' })
}

然后我们在meadowlark.js中连接 API:

app.get('/api/vacations', handlers.getVacationsApi)
app.get('/api/vacation/:sku', handlers.getVacationBySkuApi)
app.post('/api/vacation/:sku/notify-when-in-season',
  handlers.addVacationInSeasonListenerApi)
app.delete('/api/vacation/:sku', handlers.requestDeleteVacationApi)

现在应该没有什么特别令人惊讶的地方了。请注意,我们正在使用我们的数据库抽象层,因此使用我们的 MongoDB 实现或我们的 PostgreSQL 实现都没有关系(尽管您会发现根据实现的不同有些微不重要的额外字段,如果需要,我们可以删除它们)。

我将requestDeleteVacationsApi留给读者练习,主要是因为这种功能可以通过许多不同的方式实现。最简单的方法是只需修改我们的假期架构,添加“删除请求”字段,并在调用 API 时更新带有电子邮件和备注。更复杂的方法是拥有一个单独的表,比如一个审查队列,单独记录删除请求,引用相关的假期,这将更适合管理员使用。

假设您在第五章中正确设置了 Jest,您应该只需运行npm test,API 测试将被拾取(Jest 将查找以.test.js结尾的任何文件)。您会看到我们有三个通过的测试和一个失败的测试:未完成的DELETE /api/vacation/:sku

结论

我希望这一章让你想问,“就这样?”到这一点上,你可能意识到 Express 的主要功能是响应 HTTP 请求。请求是什么以及它们如何响应完全取决于您。它们需要响应 HTML 吗?CSS?纯文本?JSON?使用 Express 都很容易实现。您甚至可以响应二进制文件类型。例如,动态构建并返回图像并不困难。从这个意义上说,API 只是 Express 可以响应的众多方式之一。

在下一章中,我们将通过构建单页应用程序来利用此 API,并以不同的方式复制我们在之前章节中所做的工作。

^(1) 如果您的客户端无法使用不同的 HTTP 方法,请参阅此模块,该模块允许您“伪造”不同的 HTTP 方法。

第十六章:单页应用

“单页应用”(SPA)这个术语在某种程度上有点误导,或者至少混淆了“页面”这个词的两种含义。从用户的角度来看,SPA 可以(而且通常会)看起来像有不同页面:主页、度假页面、关于页面等。事实上,你可以创建一个对用户来说无法区分的传统服务器端渲染应用程序和 SPA。

“单页”更多地涉及 HTML 如何以及在哪里构建,而不是用户的体验。在 SPA 中,当用户首次加载应用时,服务器会提供一个单个的 HTML 捆绑包,^(1) UI 中的任何变化(对用户来说可能看起来像不同的页面)都是 JavaScript 响应用户活动或网络事件而操作 DOM 的结果。

SPA 仍然需要频繁地与服务器通信,但通常只有 HTML 作为第一次请求的一部分发送。之后,客户端和服务器之间仅传输 JSON 数据和静态资源。

理解这种现在主导的 Web 应用程序开发方法需要一点历史......

网页应用程序开发的简短历史。

我们在过去的 10 年里对待 Web 开发的方式发生了巨大的变化,但有一件事情保持相对一致:网站或 Web 应用程序中涉及的组件。具体来说:

  • HTML 和文档对象模型(DOM)。

  • JavaScript

  • CSS。

  • 静态资源(通常是多媒体:图像和视频等)。

由浏览器组合在一起的这些组件构成了用户体验。

然而,这种体验是如何构建的在 2012 年左右发生了显著变化。如今,Web 开发的主导范式是“单页应用”(SPA)。

要理解单页应用(SPA),我们需要了解与之对比的内容,因此我们将回溯到更早的时间,即 1998 年,这是“Web 2.0”一词首次被提出之前的一年,也是 jQuery 推出八年之前。

在 1998 年,交付 Web 应用程序的主流方法是在每次请求时由 Web 服务器发送 HTML、CSS、JavaScript 和多媒体资源。想象一下你在看电视,想换频道。在这里的比喻相当于你需要扔掉你的电视,买另一个,搬到家里并设置它——只是为了换台(即切换到不同页面,即使是同一站点内部)。

这种方法的问题在于涉及大量的开销。有时 HTML——或者大部分 HTML——根本不会改变。CSS 的变化更少。浏览器通过缓存资源来缓解一些开销,但 Web 应用程序的创新速度正在使这种模型变得紧张。

1999 年,"Web 2.0"这个术语被创造出来,试图描述人们开始期望从网站上获得的丰富体验。1999 年至 2012 年间,技术进步为单页面应用奠定了基础。

聪明的 Web 开发人员开始意识到,如果他们要让用户保持参与,那么每次用户想要(比喻性地)切换频道时,发送整个网站的开销都是不可接受的。这些开发人员意识到,并非应用程序中的每个变化都需要从服务器获取信息,而且并非每个需要从服务器获取信息的变化都需要整个应用程序才能实现小变化的交付。

在这个 1999 年至 2012 年的时期,页面仍然通常是页面:当您第一次访问网站时,您得到的是 HTML、CSS 和静态资产。当您导航到另一个页面时,您会得到不同的 HTML、不同的静态资产,有时候还有不同的 CSS。然而,每个页面上,页面本身可能会因用户交互而改变,而不是向服务器请求全新的应用,JavaScript 会直接改变 DOM。如果需要从服务器获取信息,这些信息会以 XML 或 JSON 的形式发送,没有所有相关的 HTML。再次说起,需要 JavaScript 解释数据并相应地更改用户界面。2006 年,jQuery 被引入,大大减轻了 DOM 操作的负担处理网络请求。

这些变化中的许多是由于计算机和——由此推断——浏览器的增强能力,Web 开发人员发现要使网站或 Web 应用程序看起来更漂亮,越来越多的工作可以直接在用户的计算机上完成,而不是在服务器上完成,然后发送给用户。

这种方法的转变在 21 世纪初进入了高速发展阶段,当时智能手机开始引入。现在,不仅浏览器能够做更多的事情,而且人们希望在无线网络上访问 Web 应用程序。突然之间,发送数据的开销上升,使尽可能少地通过网络发送数据变得更有吸引力,让浏览器尽可能多地完成工作。

到 2012 年,试图通过网络发送尽可能少的信息,并在浏览器中尽可能多地进行操作成为常见做法。就像原始汤浓缩成了第一个生命体一样,这种丰富的环境为这种技术的自然演化提供了条件:单页面应用。

这个想法很简单:对于任何给定的 web 应用程序,HTML、JavaScript 和 CSS(如果有的话)只需要发送一次。一旦浏览器得到了 HTML,就由 JavaScript 来对 DOM 进行所有更改,使用户感觉自己正在导航到一个不同的页面。例如,当用户从主页导航到"假期"页面时,服务器不再需要发送不同的 HTML。

当然,服务器仍然参与其中:它仍然负责提供最新的数据,并在多用户应用程序中作为“单一真相来源”。但在 SPA 架构中,应用程序对用户的呈现方式不再是服务器的关注点:这是 JavaScript 和支持这一巧妙幻觉的框架的关注点。

尽管 Angular 通常被认为是第一个 SPA 框架,但现在已经有许多其他框架加入其中:React、Vue 和 Ember 是 Angular 竞争中最突出的几个。

如果你是新手开发者,SPA 可能是你唯一的参考框架,使这些内容仅仅是一些有趣的历史。但如果你是老手,可能会觉得这种转变令人困惑和不适应。无论你属于哪一类,本章旨在帮助你理解 Web 应用如何以 SPA 形式提供,并且 Express 在其中的角色。

这段历史对 Express 很重要,因为在 Web 开发技术转变期间,服务器的角色发生了变化。当本书的第一版出版时,Express 还常用于提供多页面应用程序(以及支持类似 Web 2.0 功能的 API)。现在,Express 几乎完全用于提供 SPA、开发服务器和 API,反映了 Web 开发性质的变化。

有趣的是,仍然有有效的理由让 Web 应用程序能够提供特定页面(而不是由浏览器重新格式化的“通用”页面)。虽然这看起来可能是一个全面的循环,或者是丢弃 SPA 的收益,但这种技术更好地反映了 SPA 的架构。称为服务器端渲染(SSR)的这种技术允许服务器使用与浏览器相同的代码来创建单个页面,以增强首次页面加载体验。关键在于服务器不需要做太多思考:它只需使用与浏览器相同的技术来生成特定页面。这种 SSR 通常用于增强首次页面加载体验和支持搜索引擎优化。这是一个更高级的主题,我们在这里不会详细讨论,但你应该了解这种实践的存在。

现在我们对 SPA 的起源及其原因有了一些了解,让我们来看看当前可用的 SPA 框架。

SPA 技术

现在有许多选择的 SPA 技术:

React

目前,React 似乎是单页应用(SPA)领域的霸主,尽管它的一侧有昔日的巨头(Angular),另一侧有雄心勃勃的挑战者(Vue)。在 2018 年某个时刻,React 在使用统计上超过了 Angular。React 是一个开源库,但它起源于 Facebook 项目,而且 Facebook 仍然是其活跃的贡献者。我们将在 Meadowlark Travel 的重构中使用 React。

Angular

据大多数人说,作为“原始”的单页应用程序(SPA),Google 的 Angular 变得非常流行,但最终被 React 所取代。在 2014 年末,Angular 宣布了第 2 版,这是与第一版相比的巨大变化,使许多现有用户感到陌生,也吓跑了新用户。我相信这种转变(虽然可能是必要的)促使 React 最终超越了 Angular。另一个原因是 Angular 比 React 更庞大的框架。这既有优点也有缺点:Angular 提供了一个更完整的构建全应用程序的架构,始终有一个明确的“Angular 方式”去做事,而像 React 和 Vue 这样的框架则更多地由个人选择和创造力决定。无论哪种方式更好,更大的框架更笨重且演进缓慢,这给了 React 创新的优势。

Vue.js

作为 React 的一个新兴挑战者,由单一开发者 Evan You 的心血结晶。在非常短的时间内,它已经获得了令人印象深刻的追随者,其粉丝们非常喜欢它,但它仍然远远落后于 React 的独占鳌头。我对 Vue 有一些经验,我欣赏它清晰的文档和轻量级的方法,但我更喜欢 React 的架构和理念。

Ember

像 Angular 一样,Ember 提供了一个全面的应用程序框架。有一个庞大且活跃的开发社区,虽然不像 React 或 Vue 那样创新,但提供了很多功能和清晰度。我发现我更喜欢更轻量的框架,因此一直使用 React。

Polymer

我对 Polymer 没有经验,但它由 Google 支持,这使它具备了可信度。人们似乎对 Polymer 带来了什么很感兴趣,但我还没有看到有很多人急于采用它。

如果您正在寻找一个强大的开箱即用的框架,并且不介意在规定范围内操作,您应该考虑 Angular 或 Ember。如果您希望有创造性表达和创新的空间,我推荐使用 React 或 Vue。我还不知道 Polymer 的定位,但值得关注。

现在我们已经看到了这些竞争者,让我们继续使用 React,并将 Meadowlark Travel 重构为 SPA!

创建 React 应用程序

用 React 应用程序的最佳方式是使用 create-react-app(CRA)实用工具,它创建所有样板文件、开发工具,并提供一个最小的起始应用程序,供您构建。此外,create-react-app 将保持其配置最新化,因此您可以专注于构建应用程序,而不是框架工具。尽管如此,如果您需要配置工具的时候,可以“eject”应用程序:您将失去保持最新 CRA 工具的能力,但将完全控制应用程序的所有配置。

与迄今为止我们所做的不同,其中所有应用程序构件与我们的 Express 应用程序并排存在不同,SPA 最好被视为一个完全独立的应用程序。为此,我们将拥有两个应用程序根目录而不是一个。为了清晰起见,当我提到您的 Express 应用程序所在的目录时,我将称之为服务器根,而当我提到您的 React 应用程序所在的目录时,我将称之为客户端根应用程序根是现在这两个目录都位于其中的位置。

所以,进入您的应用程序根目录并创建一个名为server的目录;这将是您的 Express 服务器所在的位置。不要为客户端应用程序创建一个目录;CRA 将为我们完成这项工作。

在运行 CRA 之前,我们应该安装Yarn。Yarn 是一个像 npm 一样的包管理器...实际上,yarn 在很大程度上是 npm 的替代品。对于 React 开发并非强制使用,但它是事实上的标准,不使用它将会很麻烦。在 Yarn 和 npm 的使用方式之间有一些细微差别,但您可能唯一会注意到的是,您应该运行yarn add而不是npm install。要安装 Yarn,只需按照Yarn 安装说明操作即可。

安装了 Yarn 后,请从应用程序根目录运行以下命令:

yarn create react-app client

现在进入您的客户端目录并输入yarn start。几秒钟后,您将看到一个新的浏览器窗口弹出,其中运行着您的 React 应用程序!

不要关闭终端窗口。CRA 对“热重载”支持非常好,因此当您在源代码中进行更改时,它会快速构建,非常迅速,浏览器将自动重新加载。

React 基础

React 有出色的文档,这里不再重复。因此,如果您是 React 的新手,请从React 入门教程开始,然后参阅主要概念指南。

您将发现 React 围绕组件组织,这些组件是 React 的主要构建块。用户看到或与之交互的所有内容通常都是 React 中的组件。让我们来看看client/src/App.js(您的内容可能略有不同——CRA 随时间变化):

import React from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

React 的一个核心概念是 UI 由函数生成。最简单的 React 组件只是返回 HTML 的函数,如我们在这里看到的。您可能正在看这个并认为它不是有效的 JavaScript;它看起来像混合了 HTML!事实上,情况稍微复杂一些。React 默认启用一种称为 JSX 的 JavaScript 超集。JSX 允许您编写看起来像 HTML 的内容。它不是真正的HTML;它创建 React 元素,而 React 元素的目的是(最终)对应于 DOM 元素。

但归根结底,您可以将其视为 HTML。这里,App是一个函数,将呈现与其返回的 JSX 相对应的 HTML。

需要注意的几点:由于 JSX 与 HTML 接近但不完全相同,存在一些细微的差异。您可能已经注意到我们使用className而不是class,这是因为class是 JavaScript 中的保留字。

要指定 HTML,你只需在任何表达式预期的地方启动 HTML 元素。你还可以在 HTML 中使用花括号“回到”JavaScript。例如:

const value = Math.floor(Math.random()*6) + 1
const html = <div>You rolled a {value}!</div>

在此示例中,<div>开始 HTML,并且围绕value的花括号回到 JavaScript,以提供存储在value中的数字。我们也可以轻松地内联计算:

const html = <div>You rolled a {Math.floor(Math.random()*6) + 1}!</div>

任何有效的 JavaScript 表达式都可以包含在 JSX 中的花括号内,包括其他 HTML 元素!这种常见用例是渲染列表:

const colors = ['red', 'green', 'blue']
const html = (
  <ul>
    {colors.map(color =>
      <li key={color}>{color}</li>
    )}
  </ul>
)

关于这个例子要注意的几点事项。首先,请注意,我们映射了我们的颜色来返回<li>元素。这是至关重要的:JSX 完全通过评估表达式来工作。因此,<ul>必须包含一个表达式或表达式数组。如果您将map更改为forEach,您会发现<li>元素不会被渲染。其次,请注意,<li>元素接收一个名为key的属性:这是性能的妥协。为了让 React 知道何时重新渲染数组中的元素,它需要每个元素的唯一键。由于我们的数组元素是唯一的,我们只使用了该值,但通常您会使用 ID 或者如果没有其他可用选项,数组中项目的索引。

我鼓励您在移动之前在client/src/App.js中尝试一些这些 JSX 示例。如果您保持yarn start运行,每次保存更改时,它们都会自动反映在浏览器中,这应该加快您的学习周期。

在我们从 React 基础知识中继续之前,还有一个主题需要涉及,那就是状态。每个组件都可以有自己的状态,这基本上意味着“与组件相关联的可以改变的数据”。购物车就是一个很好的例子。购物车组件的状态将包含一个项目列表;当您向购物车添加和删除项目时,组件的状态会发生变化。这似乎是一个过于简单或显而易见的概念,但是设计和管理组件状态的细节是制作 React 应用程序的大部分内容。当我们处理假期页面时,我们将看到状态的一个示例。

让我们继续并创建我们的 Meadowlark Travel 首页。

主页

从我们的 Handlebars 视图中回想起,我们有一个主要的“布局”文件,建立了我们网站的主要外观和感觉。让我们首先关注<body>标签中的内容(除了脚本):

<div class="container">
  <header>
    <h1>Meadowlark Travel</h1>
    <a href="/"><img src="/img/logo.png" alt="Meadowlark Travel Logo"></a>
  </header>
  {{{body}}}
</div>

这将很容易重构为一个 React 组件。首先,我们将自己的 logo 复制到 client/src 目录下。为什么不放在 public 目录下?对于小型或常用的图形项,将它们嵌入 JavaScript 打包文件可能更有效,而 CRA 提供的打包工具会智能地做出选择。您从 CRA 获取的示例应用程序直接将其 logo 放在 client/src 目录下,但我仍然喜欢将图像资源收集到子目录中,因此将我们的 logo (logo.png) 放在 client/src/img/logo.png 中。

另一个棘手的地方是怎么处理 {{{body}}}?在我们的视图中,这是另一个视图将被呈现的地方——您所在页面的内容。我们可以在 React 中复制相同的基本思想。由于所有内容都以组件形式呈现,我们只需在此处呈现另一个组件。我们将从一个空的 Home 组件开始,并立即构建它:

import React from 'react'
import logo from './img/logo.png'
import './App.css'

function Home() {
  return (<i>coming soon</i>)
}

function App() {
  return (
    <div className="container">
      <header>
        <h1>Meadowlark Travel</h1>
        <img src={logo} alt="Meadowlark Travel Logo" />
      </header>
      <Home />
    </div>
  )
}

export default App

我们正在使用与样本应用程序相同的方法处理 CSS:我们可以简单地创建一个 CSS 文件并导入它。因此,我们可以编辑该文件并应用所需的任何样式。尽管在这个示例中我们保持基本设置,但在用 CSS 样式化 HTML 方面并没有根本性的变化,因此我们仍然拥有我们习惯使用的所有工具。

注意

CRA 为您设置了 linting,在本章的进行过程中,您可能会看到警告(在 CRA 终端输出和浏览器的 JavaScript 控制台中都有)。这仅是因为我们逐步添加东西;到达本章末尾时,应该不会再有警告……如果有的话,请确保没有漏掉任何步骤!您还可以检查伴随的存储库。

路由

我们在 第十四章 中学到的路由的核心概念没有改变:我们仍然使用 URL 路径来确定用户看到界面的哪一部分。不同之处在于,由客户端应用程序负责处理这一点。根据路由更改 UI 是客户端应用程序的责任:如果导航需要来自服务器的新数据或更新数据,那很好,客户端应用程序负责从服务器请求。

对于在 React 应用中进行路由,有很多选择,以及很多关于此的强烈意见。然而,有一个主要的路由库:React Router。我对 React Router 不太满意的地方很多,但它如此普遍,您肯定会遇到它。此外,它是一个很好的选择来快速启动基本项目,出于这两个原因,我们将在这里使用它。

我们将安装 React Router 的 DOM 版本开始(还有一个适用于 React Native 的版本,用于移动开发):

yarn add react-router-dom

现在我们将连接路由器,并添加关于页面和未找到页面。我们还将把站点标志链接回主页:

import React from 'react'
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from 'react-router-dom'
import logo from './img/logo.png'
import './App.css'

function Home() {
  return (
    <div>
      <h2>Welcome to Meadowlark Travel</h2>
      <p>Check out our "<Link to="/about">About</Link>" page!</p>
    </div>
  )
}

function About() {
  return (<i>coming soon</i>)
}

function NotFound() {
  return (<i>Not Found</i>)
}

function App() {
  return (
    <Router>
      <div className="container">
        <header>
          <h1>Meadowlark Travel</h1>
          <Link to="/"><img src={logo} alt="Meadowlark Travel Logo" /></Link>
        </header>
        <Switch>
          <Route path="/" exact component={Home} />
          <Route path="/about" exact component={About} />
          <Route component={NotFound} />
        </Switch>
      </div>
    </Router>
  )
}

export default App

首先要注意的是,我们将整个应用程序包装在 <Router> 组件中。这是启用路由的关键。在 <Router> 内部,我们可以使用 <Route> 根据 URL 路径条件性地渲染组件。我们将内容路由放在 <Switch> 组件中:这确保其中包含的组件只会一个被渲染。

在 Express 和 React Router 中,我们完成的路由有一些微妙的差异。在 Express 中,我们会根据第一个成功匹配的路径来渲染页面(或者如果找不到则显示 404 页面)。而在 React Router 中,路径只是一个“提示”,用于确定应该显示哪些组件的组合。因此,它比在 Express 中的路由更加灵活。由于这一点,React Router 的路由默认行为就好像在路径末尾有一个星号 (*)。也就是说,默认情况下,路径 / 将匹配 每个 页面(因为它们都以斜杠开头)。因此,我们使用 exact 属性来使此路由行为更像是 Express 中的路由。类似地,如果没有 exact 属性,/about 路径也会匹配 /about/contact,这可能不是我们想要的结果。对于主要内容路由,很可能希望除了“未找到”路由之外,所有路由都使用 exact。否则,您需要确保在 <Switch> 内正确排列它们,以便以正确的顺序匹配。

第二点需要注意的是使用 <Link>。您可能会想知道为什么我们不直接使用 <a> 标签。<a> 标签的问题在于——即使在同一网站上,如果没有额外的工作,浏览器仍会如实处理它们“跳转到其他地方”,并且会导致向服务器发送新的 HTTP 请求……从而再次下载 HTML 和 CSS,破坏了单页面应用程序的目标。它会在页面加载时工作,React Router 会按预期执行正确的操作,但速度和效率不如预期,会引发不必要的网络请求。实际上,看到差异是一个有益的练习,可以帮助理解单页面应用程序的本质。作为一个实验,创建两个导航元素,一个使用 <Link>,另一个使用 <a>

<Link to="/">Home (SPA)</Link>
<a href="/">Home (reload)</Link>

然后打开您的开发工具,切换到网络选项卡,清除流量,点击“保留日志”(在 Chrome 中)。现在点击“Home (SPA)”链接,并注意根本没有网络流量。点击“Home (reload)”链接,观察网络流量。简言之,这就是单页面应用程序的本质。

度假页面—视觉设计

到目前为止,我们只是在构建纯前端应用程序……那么 Express 又是如何介入的呢?我们的服务器仍然是真实数据的唯一来源。特别是,它维护我们希望在网站上显示的度假信息数据库。幸运的是,在第十五章中,我们已经完成了大部分工作:我们公开了一个可以返回 JSON 格式度假信息的 API,已准备好在 React 应用程序中使用。

不过,在我们连接这两件事之前,让我们继续构建我们的假期页面。我们不会有任何假期可渲染,但让我们不要因此而停下来。

在前面的部分中,我们在client/src/App.js中包含了所有内容页面,这通常被认为是不好的做法:每个组件通常应该存在于自己的文件中。因此,我们将花时间将我们的Vacations组件分离出来成为自己的组件。创建文件client/src/Vacations.js

import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'

function Vacations() {
  const [vacations, setVacations] = useState([])
  return (
    <>
      <h2>Vacations</h2>
      <div className="vacations">
        {vacations.map(vacation =>
          <div key={vacation.sku}>
            <h3>{vacation.name}</h3>
            <p>{vacation.description}</p>
            <span className="price">{vacation.price}</span>
          </div>
        )}
      </div>
    </>
  )
}

export default Vacations

到目前为止,我们的做法相当简单:我们只是返回一个包含额外<div>元素的<div>,每个元素表示一个假期。那么这个vacations变量是从哪里来的呢?在这个示例中,我们使用了 React 的一个新特性,称为React hooks。在使用 hooks 之前,如果一个组件想要有自己的状态(在本例中是假期列表),你必须使用类实现。Hooks 使我们能够有自己状态的基于函数的组件。在我们的Vacations函数中,我们调用useState来设置我们的状态。注意,我们将一个空数组传递给useState:这将是状态中vacations的初始值(我们稍后将讨论如何填充它)。setState返回的是一个包含状态值本身(vacations)和更新状态的方法(setVacations)的数组。

也许你会想为什么我们不能直接修改vacations:它只是一个数组,所以我们不能调用push来添加假期吗?我们可以,但这将违背 React 状态管理系统的初衷,该系统确保组件之间的一致性、性能和通信。

您可能还想知道我们的假期周围看起来像一个空组件(<>...</>)的情况。这被称为fragment。片段是必需的,因为每个组件必须渲染一个单一元素。在我们的情况下,我们有两个元素,<h2><div>。片段简单地提供了一个“透明”的根元素,用于包含这两个元素,以便我们可以渲染一个单一元素。

让我们将我们的Vacations组件添加到我们的应用程序中,即使现在还没有任何假期可显示。在client/src/App.js中首先导入您的假期页面:

import Vacations from './Vacations'

然后我们只需在我们路由器的<Switch>组件中创建一个路由:

<Switch>
  <Route path="/" exact component={Home} />
  <Route path="/about" exact component={About} />
  <Route path="/vacations" exact component={Vacations} />
  <Route component={NotFound} />
</Switch>

继续保存;您的应用程序应该会自动重新加载,您可以导航到/vacations页面,尽管现在还没有太多有趣的内容可见。现在我们已经大部分客户端基础设施就位,让我们转向与 Express 集成。

假期页面——服务器集成

我们已经完成了大部分假期页面所需的工作;我们有一个从数据库获取假期并以 JSON 格式返回它们的 API 端点。现在我们需要弄清楚如何使服务器和客户端进行通信。

我们可以从第十五章开始工作;我们不需要添加任何内容,但我们可以删除一些我们不再需要的东西。我们可以移除以下内容:

  • Handlebars 和视图支持(尽管我们会保留静态中间件,原因稍后会看到)。

  • Cookies 和 sessions(我们的 SPA 可能仍然使用 cookies,但它在这里不再需要服务器的帮助……而我们会以完全不同的方式思考 sessions)。

  • 所有渲染视图的路由(显然我们保留 API 路由)。

现在我们留下了一个简化的服务器。那么现在我们该怎么处理它呢?我们首先要解决的问题是我们一直在使用 3000 端口,而 CRA 开发服务器默认也使用 3000 端口。我们可以任意更改其中一个,所以我建议将 Express 端口更改为 3033—只是因为我喜欢那个数字的音调。你会记得我们在meadowlark.js中设置了默认端口,所以我们只需要改变它:

const port = process.env.PORT || 3033

当然,我们可以使用环境变量来控制它,但由于我们经常与 SPA 开发服务器一起使用它,我们可能会改变代码。

现在两个服务器都在运行,我们可以在它们之间通信。但是怎么做呢?在我们的 React 应用中,我们可以做类似这样的事情:

fetch('http://localhost:3033/api/vacations')

这种方法的问题在于我们将在整个应用程序中频繁使用这样的请求……现在我们到处都嵌入了[*http://localhost:3033*](http://localhost:3033),这在生产环境中行不通,可能在同事的电脑上也不行,因为可能需要使用不同的端口,测试服务器的端口可能也不同……这种方法会带来配置上的麻烦。当然,你可以将基础 URL 存储为一个变量,然后在所有地方使用它,但有更好的方法。

在理想情况下,从你的应用程序的角度来看,所有内容都是从同一个地方托管的:使用相同的协议、主机和端口获取 HTML、静态资产和 API。这简化了很多事情,并确保了源代码的一致性。如果所有内容都来自同一个地方,你可以简单地省略协议、主机和端口,只需调用fetch(*/api/vacations*)。这是一个很好的方法,幸运的是非常容易实现!

CRA 的配置支持proxy,允许你将 web 请求传递给你的 API。编辑你的client/package.json文件,并添加以下内容:

"proxy": "http://localhost:3033",

它添加在哪里并不重要。我通常将它放在"private""dependencies"之间,因为我喜欢在文件中尽量靠前看到它。现在,只要你的 Express 服务器运行在 3033 端口上,你的 CRA 开发服务器将通过 API 请求传递到你的 Express 服务器。

现在配置完成,让我们使用effect(另一个 React hook)来获取并更新假期数据。这里是整个Vacations组件与useEffect hook:

function Vacations() {
  // set up state
  const [vacations, setVacations] = useState([])

  // fetch initial data
  useEffect(() => {
    fetch('/api/vacations')
      .then(res => res.json())
      .then(setVacations)
  }, [])

  return (
    <>
      <h2>Vacations</h2>
      <div className="vacations">
        {vacations.map(vacation =>
          <div key={vacation.sku}>
            <h3>{vacation.name}</h3>
            <p>{vacation.description}</p>
            <span className="price">{vacation.price}</span>
          </div>
        )}
      </div>
    </>
  )
}

与之前一样,useState正在配置我们的组件状态,使其拥有一个vacations数组,并带有一个伴随的 setter。现在我们添加了useEffect,它调用我们的 API 来检索度假,并异步调用该 setter。请注意,我们将空数组作为useEffect的第二个参数传入;这是向 React 发出的一个信号,表示此效果应在组件挂载时仅运行一次。表面上看,这可能看起来是一种奇怪的信号方式,但一旦您更多地了解了 hooks,您就会发现它其实是相当一致的。要了解更多关于 hooks 的信息,请参阅React hooks 文档

Hooks 是相对较新的东西—它们是在 2019 年 2 月的 16.8 版本中添加的—因此,即使您对 React 有一些经验,您可能对 hooks 并不熟悉。我坚信 hooks 是 React 体系结构中的一个极好的创新,尽管它们一开始可能看起来很陌生,但您会发现它们实际上简化了您的组件并减少了人们普遍犯的一些棘手的与状态相关的错误。

现在我们已经学会了如何从服务器检索数据,让我们把注意力转向以相反方式发送信息。

向服务器发送信息

我们已经有一个 API 端点用于在服务器上进行更改;当(假期)回到季节时,我们有一个端点用于发送电子邮件通知。让我们继续修改我们的Vacations组件,使其显示一个针对不在季节内的度假的注册表单。按照 React 的风格,我们将创建两个新组件:我们将把单个假期视图拆分成VacationNotifyWhenInSeason组件。我们可以把它们都放在一个组件中,但 React 开发的推荐方法是拥有很多特定目的的组件,而不是庞大的通用组件(为了简洁起见,我们将不会把这些组件放在它们自己的文件中:我会把这留给读者做练习):

import React, { useState, useEffect } from 'react'

function NotifyWhenInSeason({ sku }) {
  return (
    <>
      <i>Notify me when this vacation is in season:</i>
      <input type="email" placeholder="(your email)" />
      <button>OK</button>
    </>
  )
}

function Vacation({ vacation }) {
  return (
    <div key={vacation.sku}>
      <h3>{vacation.name}</h3>
      <p>{vacation.description}</p>
      <span className="price">{vacation.price}</span>
      {!vacation.inSeason &&
        <div>
          <p><i>This vacation is not currently in season.</i></p>
          <NotifyWhenInSeason sky={vacation.sku} />
        </div>
      }
    </div>
  )
}

function Vacations() {
  const [vacations, setVacations] = useState([])
  useEffect(() => {
    fetch('/api/vacations')
      .then(res => res.json())
      .then(setVacations)
  }, [])
  return (
    <>
      <h2>Vacations</h2>
      <div className="vacations">
        {vacations.map(vacation =>
          <Vacation key={vacation.sku} vacation={vacation} />
        )}
      </div>
    </>
  )
}

export default Vacations

现在,如果您有任何inSeasonfalse的假期(除非您更改了数据库或初始化脚本),您将更新表单。现在让我们连接按钮来进行 API 调用。修改NotifyWhenInSeason

function NotifyWhenInSeason({ sku }) {
  const [registeredEmail, setRegisteredEmail] = useState(null)
  const [email, setEmail] = useState('')
  function onSubmit(event) {
    fetch(`/api/vacation/${sku}/notify-when-in-season`, {
        method: 'POST',
        body: JSON.stringify({ email }),
        headers: { 'Content-Type': 'application/json' },
      })
      .then(res => {
        if(res.status < 200 || res.status > 299)
          return alert('We had a problem processing this...please try again.')
        setRegisteredEmail(email)
      })
    event.preventDefault()
  }
  if(registeredEmail) return (
    <i>You will be notified at {registeredEmail} when
    this vacation is back in season!</i>
  )
  return (
    <form onSubmit={onSubmit}>
      <i>Notify me when this vacation is in season:</i>
      <input
        type="email"
        placeholder="(your email)"
        value={email}
        onChange={({ target: { value } }) => setEmail(value)}
        />
      <button type="submit">OK</button>
    </form>
  )
}

我们选择让该组件跟踪两个不同的值:用户在输入时的电子邮件地址,以及他们按下“确定”后的最终值。前者是一种被称为受控组件的技术,您可以在React 表单文档中了解更多信息。我们追踪后者是为了在用户执行了按下“确定”的操作时了解,以便我们可以相应地更改 UI。我们也可以简单地使用布尔值“registered”,但这样可以让我们的 UI 提醒用户他们注册时使用的电子邮件。

我们还需要与我们的 API 通信做更多的工作:我们必须指定方法(POST),将主体编码为 JSON,并指定内容类型。

注意,我们要决定返回哪种 UI。如果用户已经注册,我们返回一个简单的消息;如果他们没有,我们呈现表单。这在 React 中是非常常见的模式。

哎呀!为了那么少的功能而做那么多工作……而且功能也相当简陋。如果 API 调用出现问题,我们的错误处理功能虽然有效,但不够用户友好;而组件将只在我们停留在这个页面时记住我们注册的假期。如果我们离开再回来,我们将再次看到表单。

要使这段代码更易理解,我们可以采取一些步骤。首先,我们可以编写一个 API 包装器,处理编码输入和确定错误的混乱细节;随着我们使用更多的 API 端点,这将带来显著的回报。此外,React 还有许多流行的表单处理框架,可以大大减轻表单处理的负担。

解决“记住”用户注册了哪些假期的问题有点棘手。真正有用的是让我们的假期对象具备这些信息(无论用户是否注册)。然而,我们的专用组件对假期一无所知;它只知道 SKU。在下一节中,我们将讨论状态管理,这将指向解决这个问题的方案。

状态管理

大部分规划和设计 React 应用程序的架构工作都集中在状态管理上,通常不是单个组件的状态管理,而是它们如何共享和协调状态。我们的示例应用程序确实共享了一些状态:Vacations组件向下传递了一个假期对象给Vacation组件,而Vacation组件则将假期的 SKU 传递给NotifyWhenInSeason监听器。但到目前为止,我们的信息只在树中向下流动;当信息需要向返回时会发生什么呢?

最常见的方法是传递负责更新状态的函数。例如,Vacations组件可能有一个用于修改假期的函数,它可以传递给Vacation,然后再传递给NotifyWhenInSeason。当NotifyWhenInSeason调用它来修改假期时,树的顶部的Vacations将意识到事情已经改变,这将导致它重新渲染,从而使其所有后代都重新渲染。

听起来很累人和复杂,并且有时确实如此,但有些技术可以帮助解决这些问题。它们如此多样和有时复杂,以至于我们无法在这里完全覆盖它们(这也不是一本关于 React 的书),但我可以指引你进一步阅读:

Redux

Redux 通常是人们在考虑 React 应用程序的全面状态管理时首先想到的东西。它是最早形式化的状态管理架构之一,仍然非常流行。在概念上,它非常简单,这仍然是我喜欢的状态管理框架。即使最终你不选择 Redux,我建议你观看其创建者 Dan Abramov 的免费教程视频

MobX

MobX 在 Redux 之后出现。它在短时间内获得了令人印象深刻的追随者,并且可能是第二受欢迎的状态容器,仅次于 Redux。MobX 确实可以导致看起来更容易编写的代码,但我仍然觉得 Redux 在应用程序扩展时提供了更好的框架,即使它增加了样板代码。

Apollo

Apollo 并不是一个状态管理库* per se*,但通常它的使用方式可以替代其中的一个。它本质上是一个前端接口,用于GraphQL,作为 REST API 的替代方案,与 React 有很多集成。如果你在使用 GraphQL(或者对它感兴趣),那么它绝对值得一试。

React Context

React 本身已经通过提供内置的 Context API 参与了这场游戏。它实现了 Redux 相同的一些功能,但是减少了样板代码。然而,我觉得 React Context 不够健壮,Redux 在应用程序不断增长时是更好的选择。

当你刚开始使用 React 时,你可以基本上忽略跨应用程序的状态管理的复杂性,但很快你会意识到需要一种更有组织的方式来管理状态。当你达到这一点时,你会想要研究一些选项,并选择一个适合你的选项。

部署选项

到目前为止,我们一直在使用 CRA 内置的开发服务器——这确实是开发的最佳选择,我建议坚持使用它。然而,当谈到部署时,它并不是一个合适的选择。幸运的是,CRA 带有一个构建脚本,用于创建一个针对生产优化的捆绑包,然后你有很多选择。当你准备创建部署捆绑包时,只需运行yarn build,然后将创建一个build目录。build目录中的所有资产都是静态的,可以部署到任何地方。

我目前首选的部署方式是将 CRA 构建放入带有静态网站托管的 AWS S3 存储桶中。这远非唯一选择:每个主要的云提供商和 CDN 都提供类似的功能。

在这种配置中,我们必须创建路由,以便将 API 调用路由到您的 Express 服务器,并从 CDN 提供您的静态 bundle。对于我的 AWS 部署,我使用AWS CloudFront执行此路由;静态资产从前述的 S3 存储桶中提供,并且 API 请求被路由到 EC2 实例上的 Express 服务器或 Lambda 上。

另一个选择是让 Express 来完成所有工作。这样做的优点是能够将整个应用程序集中到一个单一的服务器上,这样部署会相当简单,管理也很方便。虽然这种方式可能不太适合可扩展性或性能,但对于小型应用程序来说是一个有效的选择。

要完全通过 Express 提供您的应用程序,只需将运行yarn build时创建的build目录中的内容复制到 Express 应用程序的public目录中。只要您链接了静态中间件,它将自动提供index.html文件,这就是您所需的一切。

不妨试试:如果你的 Express 服务器仍在 3033 端口运行,你应该能够访问http://localhost:3033,看到与 CRA 开发服务器提供的相同应用程序!

注意

如果你想了解 CRA 的开发服务器是如何工作的,它使用了一个名为webpack-dev-server的包,其底层使用了 Express!所以最终一切都与 Express 有关!

结论

本章仅仅触及了 React 及其周围的技术表面。如果你想深入了解 React,学习 React(由 Alex Banks 和 Eve Porcello 编著,O'Reilly 出版)是一个很好的起点。这本书还涵盖了使用 Redux 进行状态管理(不过目前还不包括 hooks)。官方 React 文档也非常全面和详细。

单页应用程序(SPAs)确实改变了我们思考和交付 Web 应用程序的方式,并显著提升了性能,尤其是在移动设备上。即使 Express 是在大多数 HTML 仍然主要在服务器上渲染的时代编写的,它并没有使 Express 过时。相反,为单页应用程序提供 API 的需求使得 Express 焕发了新生命!

从阅读本章中应该也能清楚地看出,这确实都是相同的游戏:数据在浏览器和服务器之间来回传递。只是数据的性质发生了变化,我们要适应通过动态 DOM 操作改变 HTML 的方式。

^(1)出于性能考虑,bundle 可能会被拆分为按需加载的“chunk”(称为lazy loading),但其原理是一样的。

第十七章:静态内容

静态内容 指的是你的应用程序将提供的在每个请求基础上不会改变的资源。以下是通常的资源:

多媒体

图像,视频和音频文件。当然,完全可以动态生成图像文件(以及视频和音频,虽然后两者较少见),但大多数多媒体资源是静态的。

HTML

如果我们的 Web 应用程序使用视图来渲染动态 HTML,通常不会被视为静态 HTML(尽管出于性能原因,您可以动态生成 HTML,将其缓存并作为静态资源提供)。正如我们所见,SPA 应用程序通常向客户端发送单个静态 HTML 文件,这是将 HTML 视为静态资源的最常见原因。请注意,要求客户端使用.html扩展名并不是很现代化,因此大多数服务器现在允许在不带扩展名的情况下提供静态 HTML 资源(因此/foo/foo.html将返回相同的内容)。

CSS

即使您使用像 LESS,Sass 或 Stylus 等抽象 CSS 语言,归根结底,您的浏览器需要普通的 CSS,这是一种静态资源。^(1)

JavaScript

即使服务器正在运行 JavaScript,也不意味着没有客户端 JavaScript。客户端 JavaScript 被视为静态资源。当然,现在这条界限开始变得有些模糊:如果有我们想在后端和客户端使用的通用代码怎么办?有解决这个问题的方法,但归根结底,发送给客户端的 JavaScript 通常是静态的。

二进制下载

这是一个涵盖所有内容的类别:任何 PDF 文档,ZIP 文件,Word 文档,安装程序等。

注意

如果你只是在构建一个 API,可能没有静态资源。如果是这样的话,你可以跳过这一章节。

性能考虑

处理静态资源的方式显著影响您网站的实际性能,特别是如果您的网站包含大量多媒体内容。两个主要的性能考虑因素是减少请求数量减少内容大小

在这两者之间,减少(HTTP)请求的数量更为关键,尤其是对于移动设备(在移动网络上进行 HTTP 请求的开销显著更高)。减少请求可以通过两种方式实现:资源合并和浏览器缓存。

结合资源主要是架构和前端关注的问题:尽可能将小图像合并为单个精灵。然后使用 CSS 设置偏移和大小,以仅显示您想要的图像部分。有关创建精灵,我强烈推荐免费服务SpritePad。它使生成精灵非常简单,并为您生成 CSS。再也没有比这更简单的事情了。SpritePad 的免费功能可能是您所需的一切,但如果您发现自己经常创建大量的精灵图,您可能会发现他们的高级服务值得一试。

浏览器缓存通过将常用的静态资源存储在客户端浏览器中来帮助减少 HTTP 请求。虽然浏览器尽可能使缓存自动化,但这并非魔法:您可以并且应该做很多事情来启用浏览器缓存您的静态资源。

最后,我们可以通过减少静态资源的大小来提高性能。一些技术是无损的(可以在不丢失任何数据的情况下实现大小减小),而一些技术是有损的(通过降低静态资源的质量来实现大小减小)。无损技术包括 JavaScript 和 CSS 的缩小以及优化 PNG 图像。有损技术包括增加 JPEG 和视频压缩级别。在本章中,我们将讨论缩小和捆绑(这也减少了 HTTP 请求)。

提示

随着 HTTP/2 变得越来越普遍,减少 HTTP 请求的重要性将逐渐减弱。HTTP/2 的主要改进之一是请求和响应复用,它减少了并行获取多个资源的开销。有关更多信息,请参见Ilya Grigorik 的《HTTP/2 简介》

内容传递网络

当您将网站投入生产时,静态资源必须托管在互联网的某个地方。您可能习惯于将它们托管在生成所有动态 HTML 的同一服务器上。到目前为止,我们的示例也采用了这种方法:当我们输入node meadowlark.js时,我们启动的 Node/Express 服务器会提供所有 HTML 以及静态资源。然而,如果您希望最大化网站的性能(或为将来做好准备),您将希望轻松地将静态资源托管在内容传递网络(CDN)上。CDN 是专为提供静态资源而优化的服务器。它利用特殊的标头(我们很快将学习到)来启用浏览器缓存。

CDN 还可以实现地理优化(通常称为边缘缓存);也就是说,它们可以从地理位置更靠近您的客户端的服务器提供您的静态内容。虽然互联网的速度确实非常快(虽然不是光速,但已经足够接近了),但将数据传输一百英里比一千英里要快得多。单个时间节省可能很小,但如果乘以所有用户、请求和资源,这些时间节省将迅速累积起来。

您的大多数静态资源将在 HTML 视图中引用(<link> 元素用于 CSS 文件,<script> 引用用于 JavaScript 文件,<img> 标签用于图像,以及多媒体嵌入标签)。通常还会在 CSS 中引用静态资源,通常是 background-image 属性。最后,有时也会在 JavaScript 中引用静态资源,例如动态更改或插入 <img> 标签或 background-image 属性的 JavaScript 代码。

注意

使用 CDN 时,通常无需担心跨域资源共享(CORS)。在 HTML 中加载的外部资源不受 CORS 策略限制:您只需为通过 Ajax 加载的资源启用 CORS 即可(参见第十五章)。

面向 CDN 的设计

您站点的架构将影响您如何使用 CDN。大多数 CDN 允许您配置路由规则以确定从何处发送传入请求。虽然您可以通过这些路由规则变得非常复杂,但通常归结为将对静态资产的请求发送到一个位置(通常由您的 CDN 提供),将对动态端点(如动态页面或 API 端点)的请求发送到另一个位置。

选择和配置 CDN 是一个大课题,我不会在这里详细介绍,但我会为您提供背景知识,以帮助您配置您选择的 CDN。

构建您的应用程序的最简单方法是,使动态资产与静态资产易于区分,以使 CDN 路由规则尽可能简单。虽然可以使用子域(例如,动态资产由 meadowlark.com 提供,静态资产由 static.meadowlark.com 提供),但这种方法会增加额外的复杂性,并使本地开发更加困难。更简单的方法是使用请求路径:以 /public/ 开头的所有内容都是静态资产,其他所有内容都是动态资产,例如。如果您是使用 Express 生成内容或者使用 Express 为单页面应用程序提供 API,则可能需要采用不同的方法。

服务器渲染网站

如果您正在使用 Express 渲染动态 HTML,简单地说,“以 /static/ 开头的所有内容都是静态资产,其他所有内容都是动态的。”通过这种方法,您的所有(动态生成的)URL 将是您想要的任何内容(当然,前提是它们不以 /static/ 开头!),而所有静态资产都将以 /static/ 作为前缀:

  <img src="/static/img/meadowlark-logo-1.png" alt="Meadowlark Logo">
  Welcome to <a href="/about">Meadowlark Travel</a>.

到目前为止,在这本书中,我们一直在使用 Express 的static中间件,就好像它在根目录托管了所有静态资产。也就是说,如果我们把静态资产foo.png放在public目录中,我们会使用 URL 路径/foo.png来引用它,而不是/static/foo.png。当然,我们可以在现有的public目录中创建一个子目录static,这样/public/static/foo.png就会有 URL/static/foo.png,但这似乎有点愚蠢。幸运的是,static中间件帮我们避免了这种愚蠢。我们在调用app.use时只需指定不同的路径即可:

app.use('/static', express.static('public'))

现在我们可以在开发环境中使用与生产环境相同的 URL 结构。如果我们仔细保持public目录与 CDN 中的内容同步,我们可以在两个地方引用相同的静态资产,并在开发和生产环境之间无缝切换。

当我们配置 CDN 的路由时(您需要查阅 CDN 的文档),您的路由将如下所示:

URL 路径 路由目的地 / 源
/static/* 静态 CDN 文件存储
/*(所有其他内容) 您的 Node/Express 服务器、代理或负载均衡器

单页面应用程序

单页面应用程序通常与服务器渲染的网站相反:只有 API 将被路由到您的服务器(例如,任何以/api前缀的请求),而其他所有内容将被重定向到您的静态文件存储。

正如我们在第十六章中所看到的,您将有某种方式为您的应用程序创建一个生产捆绑包,其中将包含所有静态资源,然后您将上传到您的 CDN。然后,您所需做的就是确保正确配置路由到您的 API。因此,您的路由将如下所示:

URL 路径 路由目的地 / 源
/api/* 您的 Node/Express 服务器、代理或负载均衡器
/*(所有其他内容) 静态 CDN 文件存储

现在我们已经看到了如何构建一个应用程序,以便我们可以在开发和生产环境之间无缝切换,让我们将注意力转向缓存的实际操作及其如何提升性能。

缓存静态资产

无论您是使用 Express 来提供静态资产还是使用 CDN,了解您的浏览器用来确定何时以及如何缓存静态资产的 HTTP 响应头是很有帮助的:

Expires/Cache-Control

这两个头部告诉浏览器资源可以被缓存的最长时间。浏览器会认真对待它们:如果它们告诉浏览器缓存某物一个月,只要它在缓存中,浏览器一个月内就不会重新下载。重要的是要理解,浏览器可能会因为你无法控制的原因提前清除图像缓存。例如,用户可能手动清除缓存,或者浏览器可能清除资源以为用户频繁访问的其他资源腾出空间。你只需要其中一个头部,而Expires更广泛支持,因此最好使用它。如果资源在缓存中且尚未过期,浏览器根本不会发出GET请求,这尤其在移动设备上提升了性能。

Last-Modified/ETag

这两个标签提供了一种类似版本控制的功能:如果浏览器需要获取资源,它会在下载内容之前检查这些标签。仍会向服务器发出GET请求,但如果这些标签的值满足浏览器资源未更改的条件,浏览器就不会继续下载文件。正如名称所示,Last-Modified允许你指定资源的最后修改日期。ETag允许你使用任意字符串,通常是版本字符串或内容哈希值。

在提供静态资源时,应该使用Expires头部和Last-Modified或者ETag之一。Express 内置的static中间件设置了Cache-Control,但并不处理Last-Modified或者ETag。因此,虽然适合开发时使用,但不是部署的理想解决方案。

如果选择在 CDN 上托管静态资源,比如 Amazon CloudFront、Microsoft Azure、Fastly、Cloudflare、Akamai 或 StackPath,优势在于它们会为你处理大部分这些细节。你可以微调这些细节,但这些服务的默认设置通常是非常好的。

改变你的静态内容

缓存显著提升了网站的性能,但也不是没有后果的。特别是,如果你改变了任何静态资源,客户端可能要等到浏览器中的缓存版本过期后才能看到变化。Google 建议缓存一个月,最好一年。想象一下,一个用户每天都在同一个浏览器上使用你的网站:那个人可能整整一年都看不到你的更新!

显然,这是一个不愿看到的情况,你不能只告诉用户清除他们的缓存。解决方案是缓存破解。缓存破解 是一种技术,可以让你控制用户的浏览器何时被迫重新下载资产。通常这意味着给资产加上版本号(main.2.cssmain.css?version=2)或者添加某种哈希值(main.e16b7e149dccfcc399e025e0c454bf77.css)。无论使用什么技术,当你更新资产时,资源名称会改变,浏览器就知道需要重新下载它。

我们可以对我们的多媒体资产采取相同的方法。例如,让我们拿我们的标志来说(/static/img/meadowlark_logo.png)。如果我们将它托管在 CDN 上以达到最佳性能,设置一年的过期时间,然后更改标志,那么用户可能需要一年才能看到更新后的标志。然而,如果你将标志重命名为 /static/img/meadowlark_logo-1.png(并在 HTML 中反映这个名称更改),浏览器将被迫重新下载它,因为它看起来像一个新资源。

如果你正在使用单页应用框架,例如 create-react-app 或类似的,它们会提供一个构建步骤来创建生产就绪的资源包,并附加哈希值。

如果你从头开始,你可能会想研究一下 打包工具(这是 SPA 框架在幕后使用的工具)。打包工具将你的 JavaScript、CSS 和其他某些静态资产尽可能地合并,并对结果进行缩小(使其尽可能小)。打包工具的配置是一个大话题,但幸运的是,有很多好的文档可以参考。目前最流行的打包工具如下:

Webpack

Webpack 是第一个真正火起来的打包工具之一,至今仍保持着庞大的用户群。它非常复杂,但这种复杂性是有代价的:学习曲线很陡峭。然而,至少了解基础是很有好处的。

Parcel

Parcel 是新进入者,并且引起了轰动。它有极其详细的文档,运行速度极快,最重要的是,学习曲线最短。如果你想快速完成任务,而不想烦扰太多,从这里开始。

Rollup

Rollup 介于 Webpack 和 Parcel 之间。像 Webpack 一样,它非常强大并且具有许多功能。然而,它比 Webpack 更容易入门,但不像 Parcel 那样简单。

结论

对于看似如此简单的事情,静态资源可能会带来很多麻烦。然而,它们很可能代表着实际传输给访问者的大部分数据,因此花些时间来优化它们将会带来可观的回报。

另一种未曾提及的静态资源的可行解决方案是从一开始就简单地将您的静态资源托管在 CDN 上,并始终在视图和 CSS 中使用资源的完整 URL。这样做的好处在于简单性,但如果您想在没有互联网访问的树林小屋里度过周末编程马拉松,您可能会遇到麻烦!

如果您的应用程序不值得进行复杂的捆绑和最小化处理,您可以在这方面节省时间。特别是,如果您的网站只包含一两个 JavaScript 文件,并且所有的 CSS 都放在一个文件中,您可能完全可以跳过捆绑,但实际应用程序往往会随时间增长。

无论您选择哪种技术来提供您的静态资源,我强烈建议您将它们分开托管,最好是在 CDN 上。如果这听起来很麻烦,让我保证,这并不像听起来那么困难,特别是如果您在部署系统上花一点时间,使得将静态资源部署到一个位置,应用程序部署到另一个位置是自动的。

如果您担心 CDN 的托管成本,请查看一下您目前托管的费用。大多数托管提供商实际上会按照带宽收费,即使您不知道。然而,如果突然您的网站被 Slashdot 提到,并且您被“Slashdotted”,您可能会发现自己有一张意想不到的托管账单。CDN 托管通常设置为按使用付费。举个例子,我曾经为一家中型地区公司管理的网站,每月带宽使用量约为 20 GB,只需每月支付几美元来托管静态资源(而且这是一个非常媒体密集的网站)。

通过将您的静态资源托管在 CDN 上,您可以实现显著的性能提升,而且这样做的成本和不便是最小的,因此我强烈建议您选择这条路线。

^(1) 在浏览器中使用未编译的 LESS 是可能的,借助一些 JavaScript 魔法。这种方法会带来性能上的后果,因此我不建议这样做。

第十八章:安全

如今大多数网站和应用程序都有某种安全性要求。如果您允许用户登录,或者存储个人身份信息(PII),则需要为您的网站实施安全性。在本章中,我们将讨论HTTP 安全(HTTPS),它为您构建安全网站奠定了基础,并介绍认证机制,重点介绍第三方认证。

安全是一个大课题,可以写成一本书。因此,我们的重点将放在利用现有的认证模块上。编写自己的认证系统当然是可能的,但是这是一个庞大而复杂的工作。此外,有很多理由倾向于使用第三方登录方法,我们将在本章后面讨论。

HTTPS

提供安全服务的第一步是使用 HTTPS。互联网的性质使得第三方有可能拦截客户端和服务器之间传输的数据包。HTTPS 对这些数据包进行加密,使得攻击者极难获取传输的信息。(我说“非常困难”,而不是“不可能”,因为没有绝对安全的事物。然而,HTTPS 被认为对于银行业务、企业安全和医疗保健是足够安全的。)

你可以把 HTTPS 看作是保护网站安全的基础。它并不提供认证,但它为认证奠定了基础。例如,你的认证系统可能涉及传输密码;如果密码未加密传输,无论认证有多复杂,都无法保护你的系统。安全性如同链条中的最弱环节一般,而这个链条的第一环就是网络协议。

HTTPS 协议基于服务器拥有的公钥证书,有时也称为 SSL 证书。当前 SSL 证书的标准格式称为X.509。证书的理念是有证书颁发机构(CA)发布证书。证书颁发机构将受信任的根证书提供给浏览器供应商。在安装浏览器时,浏览器会包含这些受信任的根证书,这就建立了 CA 与浏览器之间的信任链。为了使这个链条有效,你的服务器必须使用由 CA 颁发的证书。

结论是,要提供 HTTPS,您需要从 CA 获取证书,那么如何获取这样的证书呢?大体上讲,您可以自己生成,从免费的 CA 获取,或者从商业 CA 购买。

生成您自己的证书

生成自己的证书很容易,但通常仅适用于开发和测试目的(可能也适用于内部部署)。由于证书颁发机构建立的层次结构,浏览器只信任由已知 CA 生成的证书(而您可能不是)。如果您的网站使用来自浏览器不认识的 CA 的证书,浏览器将以非常警告的语言警告您正在与一个未知的(因此不受信任的)实体建立安全连接。在开发和测试中,这没问题:您和您的团队知道您生成了自己的证书,并且您希望浏览器表现出这种行为。如果您要将此类网站部署到面向公众的生产环境中,他们会大量流失。

注意

如果您控制浏览器的分发和安装,可以在安装浏览器时自动安装您自己的根证书。这将防止使用该浏览器的人在连接到您的网站时收到警告。然而,这并不是一件容易设置的事情,并且仅适用于您控制使用的浏览器的环境。除非您有非常充分的理由采取这种方法,否则通常比值得的麻烦多。

要生成自己的证书,您需要一个 OpenSSL 实现。表 18-1 显示如何获取一个实现。

表 18-1. 获取不同平台的实现

Platform Instructions
macOS brew install openssl
Ubuntu, Debian sudo apt-get install openssl
Other Linux http://www.openssl.org/source/; 下载并解压 tarball,然后按照说明进行操作
Windows http://gnuwin32.sourceforge.net/packages/openssl.htm 下载
提示

如果您是 Windows 用户,可能需要指定 OpenSSL 配置文件的位置,由于 Windows 路径名可能比较复杂。确定可行的方法是找到 openssl.cnf 文件(通常在安装的 share 目录中),在运行 openssl 命令之前设置 OPENSSL_CONF 环境变量:SET OPENSSL_CONF=openssl.cnf

安装完 OpenSSL 后,您可以生成私钥和公共证书:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout meadowlark.pem
	-out meadowlark.crt

在申请证书时,您将被要求提供一些详细信息,如您的国家代码、城市和州、完全合格的域名(FQDN,也称为通用名称或完全合格的主机名)和电子邮件地址。由于此证书是用于开发/测试目的,您提供的值并不特别重要(事实上,它们都是可选的,但是省略它们会导致浏览器更加怀疑您的证书)。通用名称(FQDN)是浏览器用来识别域名的。因此,如果您使用 localhost,您可以将其用作您的 FQDN,或者如果可用,您可以使用服务器的 IP 地址或服务器名称。如果在 URL 中使用的通用名称和域名不匹配,加密仍将起作用,但您的浏览器将额外警告您有关不匹配的信息。

如果您对这个命令的详细内容感兴趣,可以在OpenSSL 文档页面上了解更多。值得指出的是,-nodes 选项与 Node 或者复数形式的 "nodes" 没有关系:它实际上意味着 "no DES",即私钥未经 DES 加密。

这个命令的结果是两个文件,meadowlark.pemmeadowlark.crt。Privacy-Enhanced Electronic Mail (PEM) 文件是您的私钥,不应该提供给客户端。CRT 文件是自签名证书,将被发送到浏览器以建立安全连接。

或者,有些网站提供免费的自签名证书,比如这个网站

使用免费证书颁发机构

HTTPS 建立在信任的基础上,遗憾的是,获得互联网上信任的最简单方式之一是购买它。而且这并非都是蛇油,建立安全基础设施、保险证书以及与浏览器供应商维持关系都是昂贵的事情。

购买证书并不是生产准备好证书的唯一合法选择:Let's Encrypt,一个基于开源的免费自动化 CA,已经成为一个很好的选择。事实上,除非您已经投资于提供免费或廉价证书作为托管服务一部分的基础设施(例如 AWS),否则 Let's Encrypt 是一个很好的选择。Let's Encrypt 的唯一不足是其证书的最大生存期为 90 天。这个缺点可以通过 Let's Encrypt 非常容易地自动续订证书来抵消,并建议每 60 天设置一个自动化流程来确保证书不会过期。

所有主要的证书供应商(如 Comodo 和 Symantec)都提供免费试用证书,有效期从 30 到 90 天不等。如果您想测试商业证书,这是一个有效的选择,但是在试用期结束之前,您需要购买证书以确保服务的连续性。

购买证书

目前,每个主要浏览器分发的大约 50 个根证书中,有 90%归属于四家公司:Symantec(购并 VeriSign)、Comodo Group、Go Daddy 和 GlobalSign。直接从 CA(证书颁发机构)购买可能非常昂贵:通常起价约为每年$300(尽管有些提供少于每年$100 的证书)。通过经销商是一个更便宜的选择,你可以以每年至少$10 或更少的价格获得 SSL 证书。

确切了解你支付的内容及其原因是非常重要的,不论是支付$10、$150 还是$300(或更高)的证书。理解的第一个重要点是,$10 的证书和$1,500 的证书在加密级别上完全没有任何区别。这是昂贵的证书颁发机构宁愿你不知道的事实:他们的营销努力掩盖这一事实。

如果选择商业证书供应商,我建议在做出选择时考虑以下三个因素:

客户支持

如果你在使用证书时遇到问题,无论是浏览器支持(客户会告诉你他们的浏览器是否将你的证书标记为不可信任)、安装问题还是续订麻烦,你会感激良好的客户支持。这是你可能购买更昂贵证书的一个原因。通常,你的托管提供商将转售证书,根据我的经验,他们提供更高水平的客户支持,因为他们也希望保持你作为托管客户。

单域、多子域、通配符和多域证书

最便宜的证书通常是单域的。这听起来可能不那么糟糕,但请记住,这意味着如果你为meadowlarktravel.com购买了证书,那么该证书对www.meadowlarktravel.com或反之无效。因此,我倾向于避免单域证书,尽管对于极度节约成本的人来说可能是一个不错的选择(你始终可以设置重定向来引导请求到正确的域名)。多子域证书之所以好,是因为你可以购买一个覆盖meadowlarktravel.comwww.meadowlark.comblog.meadowlarktravel.comshop.meadowlarktravel.com等所有子域的单一证书。缺点是你必须预先知道要使用哪些子域。

如果你计划在一年内增加或使用不同的子域(需要支持 HTTPS),你可能最好选择通配符证书,它们通常更昂贵。但它们适用于任何子域,并且你不需要指定子域是什么。

最后,还有多域证书,就像通配符证书一样,往往更昂贵。这些证书支持多个完整的域,因此,例如,您可以拥有meadowlarktravel.commeadowlarktravel.usmeadowlarktravel.com以及www变体。

域名、组织和扩展验证证书

有三种类型的证书:域名、组织和扩展验证。域名证书如其名称所示,仅提供您确实正在与您认为的域名进行业务。另一方面,组织证书则对您正在打交道的实际组织提供了一些保证。这些证书更难获取:通常涉及文件工作,您必须提供州和/或联邦商业名称记录、物理地址等。不同的证书供应商可能需要不同的文件,因此,请确保向您的证书供应商询问获取这些证书所需的文件。最后是扩展验证证书,它们是 SSL 证书的劳斯莱斯。它们类似于组织证书,因为它们验证组织的存在,但它们要求更高的证明标准,甚至可能需要昂贵的审计来建立您的数据安全实践(尽管这种情况似乎越来越少见)。单个域名的扩展验证证书可以低至 150 美元。

我建议使用更便宜的域名证书或扩展验证证书。组织证书虽然验证了您的组织的存在,但与浏览器显示无异,因此根据我的经验,除非用户确实检查证书(这种情况很少),否则这与域名证书之间没有明显区别。另一方面,扩展验证证书通常会向用户显示一些线索,表明他们正在与合法的企业打交道(例如,URL 栏显示为绿色,并且组织名称显示在 SSL 图标旁边)。

如果你以前处理过 SSL 证书,你可能会想为什么我没有提到证书保险。我省略了这一价格差异化因素,因为本质上这是针对几乎不可能发生的情况的保险。其理念是,如果有人因为你网站上的交易而遭受了财务损失,并且他们能够证明这是由于不足的加密措施,那么保险将赔偿你的损失。尽管有可能,如果你的应用涉及财务交易,某人可能会因财务损失而对你提起法律诉讼,但由于加密不足导致的情况几乎为零。如果我试图因为与公司的在线服务相关的财务损失而寻求赔偿,我最后绝不会采取的方法就是试图证明 SSL 加密已被破解。如果你面对两张唯一区别在价格和保险覆盖范围的证书,那么选择便宜的证书吧。

购买证书的过程从创建私钥开始(就像我们之前为自签名证书所做的那样)。然后,你将生成一个证书签名请求(CSR),在证书购买过程中上传该请求(证书颁发机构将提供操作说明)。请注意,证书颁发机构永远不会访问你的私钥,也不会通过互联网传输你的私钥,这保护了私钥的安全性。颁发机构将随后发送证书给你,其扩展名为.crt.cer.der(证书将采用称为 Distinguished Encoding Rules 或 DER 的格式,因此也有较少见的.der扩展名)。你还将收到证书链中的任何证书。可以安全地通过电子邮件发送这些证书,因为没有你生成的私钥,这些证书无法正常工作。

为你的 Express 应用启用 HTTPS

你可以修改你的 Express 应用程序以通过 HTTPS 提供网站服务。在实践和生产中,这是极不常见的,我们将在下一节中了解更多。然而,对于高级应用程序、测试以及对 HTTPS 的理解,了解如何提供 HTTPS 服务是非常有用的。

一旦你有了私钥和证书,在你的应用中使用它们就很容易。让我们重新审视一下我们一直在创建服务器的方式:

app.listen(app.get('port'), () => {
  console.log(`Express started in ${app.get('env')} mode ` +
    `on port + ${app.get('port')}.`)
})

切换到 HTTPS 很简单。我建议你将私钥和 SSL 证书放在名为ssl的子目录中(尽管将其放在项目根目录中也很常见)。然后,你只需使用https模块而不是http,并向createServer方法传递一个options对象:

const https = require('https')
const fs = require('fs')           // usually at top of file

// ...the rest of your application configuration

const options = {
  key: fs.readFileSync(__dirname + '/ssl/meadowlark.pem'),
  cert: fs.readFileSync(__dirname + '/ssl/meadowlark.crt'),
}

const port = process.env.PORT || 3000
https.createServer(options, app).listen(port, () => {
  console.log(`Express started in ${app.get('env')} mode ` +
    `on port + ${port}.`)
})

就是这么简单。假设你仍在使用端口 3000 运行服务器,现在你可以连接到https://localhost:3000。如果你尝试连接到http://localhost:3000,连接将简单超时。

端口注意事项

不管你知不知道,当你访问一个网站时,你总是连接到一个特定的端口,即使它在 URL 中没有指定。如果你没有指定端口,HTTP 默认使用端口 80。事实上,大多数浏览器在显式指定端口 80 时将简单地去掉端口号。例如,访问 http://www.apple.com:80;页面加载时,浏览器将会直接省略 :80。它仍然是在端口 80 连接;只是隐含的。

同样地,HTTPS 有一个标准端口 443。浏览器的行为类似:如果你连接到 https://www.google.com:443,大多数浏览器将简单地不显示 :443,但实际上它们是连接到这个端口的。

如果你不使用端口 80 进行 HTTP 或端口 443 进行 HTTPS,你将不得不显式指定端口和协议才能正确连接。没有办法在同一个端口上同时运行 HTTP 和 HTTPS(从技术上讲是可能的,但没有充分理由这样做,而且实现起来非常复杂)。

如果你想在端口 80 运行你的 HTTP 应用,或者在端口 443 运行 HTTPS 应用以免显式指定端口,你需要考虑两件事情。首先是很多系统已经在端口 80 上运行了默认的 Web 服务器。

还有一件事需要知道的是,在大多数操作系统上,端口 1–1023 需要特权才能打开。例如,在 Linux 或 macOS 机器上,如果尝试在端口 80 上启动你的应用程序,可能会因为 EACCES 错误而失败。要在端口 80 或 443(或任何低于 1024 的端口)上运行,你需要通过使用 sudo 命令提升权限。如果你没有管理员权限,将无法直接在端口 80 或 443 上启动服务器。

除非你管理自己的服务器,否则你可能没有你托管账户的 root 访问权限:那么当你想在端口 80 或 443 上运行时会发生什么呢?一般来说,托管提供商会有某种特权代理服务,将请求传递到运行在非特权端口上的应用程序。我们将在下一节中详细了解更多信息。

HTTPS 和代理

正如我们所见,使用 Express 来进行 HTTPS 非常简单,并且在开发阶段可以正常工作。然而,当你想要扩展你的网站以处理更多的流量时,你会希望使用像 NGINX 这样的代理服务器(参见第十二章)。如果你的网站运行在共享主机环境中,几乎可以肯定会有一个代理服务器来路由请求到你的应用程序。

如果您使用代理服务器,那么客户端(用户的浏览器)将与代理服务器通信,而不是与您的服务器通信。代理服务器反过来很可能会通过常规的 HTTP 与您的应用程序通信(因为您的应用程序和代理服务器将在受信任的网络上一起运行)。人们经常会说 HTTPS 在代理服务器处终止,或者代理服务器正在执行“SSL 终结”。

大部分情况下,一旦您或您的托管提供商正确配置了代理服务器以处理 HTTPS 请求,您就不需要进行任何额外的工作了。唯一的例外是,如果您的应用程序需要处理安全和非安全请求。

这个问题有三种解决方案。第一种方法是简单地配置代理将所有 HTTP 流量重定向到 HTTPS,从本质上迫使所有与您的应用程序的通信都是通过 HTTPS 进行的。这种方法变得越来越普遍,显然是解决问题的简便方法。

第二种方法是以某种方式将客户端代理通信中使用的协议传达给服务器。通常的通信方式是通过X-Forwarded-Proto头部。例如,在 NGINX 中设置此头部:

proxy_set_header X-Forwarded-Proto $scheme;

然后,在您的应用程序中,您可以测试协议是否为 HTTPS:

app.get('/', (req, res) => {
  // the following is essentially
  // equivalent to: if(req.secure)
  if(req.headers['x-forwarded-proto'] === 'https') {
    res.send('line is secure')
  } else {
    res.send('you are insecure!')
  }
})
注意

在 NGINX 中,HTTP 和 HTTPS 有各自独立的server配置块。如果在对应于 HTTP 的配置块中未设置X-Forwarded-Protocol,您就有可能使客户端伪造该头部,并误导您的应用程序认为连接是安全的,尽管实际上并非如此。如果您采用这种方法,请确保始终设置X-Forwarded-Protocol头部。

当您使用代理时,Express 提供了一些便利属性,使代理更加“透明”(就像您没有使用代理一样,同时又不损失其好处)。要利用这一点,告诉 Express 信任代理,使用app.enable('trust proxy')。一旦这样做,req.protocolreq.securereq.ip将指代客户端与代理的连接,而不是您的应用程序。

跨站点请求伪造

跨站请求伪造(CSRF)攻击利用用户通常信任其浏览器并在同一会话中访问多个站点的事实。在 CSRF 攻击中,恶意站点上的脚本向另一个站点发出请求:如果您在另一个站点上已登录,则恶意站点可以成功访问另一个站点的安全数据。

要防止 CSRF 攻击,您必须有一种方法确保请求确实来自您的网站。我们做的方式是向浏览器传递一个唯一的令牌。然后当浏览器提交表单时,服务器会检查令牌是否匹配。csurf中间件会为您处理令牌的创建和验证;您只需确保令牌包含在对服务器的请求中即可。安装csurf中间件(npm install csurf);然后在链接body-parsercookie-parserexpress-session之后链接它,并在res.locals中添加一个令牌。确保在链接csurf中间件之前链接body-parsercookie-parserexpress-session

// this must come after we link in body-parser,
// cookie-parser, and express-session
const csrf = require('csurf')

app.use(csrf({ cookie: true }))
app.use((req, res, next) => {
  res.locals._csrfToken = req.csrfToken()
  next()
})

csurf中间件将csrfToken方法添加到请求对象中。我们不必将其分配给res.locals;我们可以只需将req.csrfToken()明确地传递给每个需要它的视图,但这通常会更麻烦一些。

注意

注意,包本身被称为csurf,但大多数变量和方法是csrf,没有“u”。在这里很容易被绊倒,所以要注意你的元音!

现在在所有的表单(和 AJAX 调用)中,您必须提供一个名为_csrf的字段,它必须与生成的令牌匹配。让我们看看如何将其添加到我们的一个表单中:

<form action="/newsletter" method="POST">
  <input type="hidden" name="_csrf" value="{{_csrfToken}}">
  Name: <input type="text" name="name"><br>
  Email: <input type="email" name="email"><br>
  <button type="submit">Submit</button>
</form>

csurf中间件将处理剩余的工作:如果请求体包含字段但没有有效的_csrf字段,它将引发错误(确保您的中间件中有一个错误路由!)。试着移除隐藏字段,看看会发生什么。

提示

如果您有一个 API,您可能不希望csurf中间件干扰它。如果您希望限制来自其他网站的对您的 API 的访问,请查看 API 库(如connect-rest)的“API 密钥”功能。为了防止csurf干扰您的中间件,将其链接在csurf之前。

认证

认证是一个复杂的大课题。不幸的是,它也是大多数非平凡网络应用的重要组成部分。我能给你的最重要的建议是不要试图自己做。如果你看看你的名片上没有写“安全专家”,那么你可能没有准备好设计安全认证系统所涉及的复杂考虑。

我并不是说你不应该试图理解你的应用程序中的安全系统。我只是建议你不要自己动手构建它。可以自由地研究我即将推荐的认证技术的开源代码。这肯定会让你明白为什么你可能不想单枪匹马地承担这个任务!

认证与授权

虽然这两个术语经常被交替使用,但它们有所不同。认证指的是验证用户的身份,即确认他们是他们所说的那个人。授权则指确定用户被授权访问、修改或查看什么内容。例如,顾客可能被授权访问他们的账户信息,而 Meadowlark Travel 的员工则被授权访问其他人的账户信息或销售记录。

注意

认证通常缩写为authN,而“授权”缩写为authZ

通常情况下(但并非总是如此),认证先进行,然后确定授权。授权可以非常简单(授权/未授权),也可以很广泛(用户/管理员),或者非常细化,指定对不同账户类型的读取、写入、删除和更新权限。你的授权系统的复杂程度取决于你正在编写的应用程序的类型。

由于授权非常依赖于你的应用程序的细节,所以在本书中我只会提供一个粗略的概述,使用一个非常广泛的认证方案(客户/员工)。我经常会使用“auth”的缩写,但只有在上下文中明确表明它是指“认证”还是“授权”,或者这并不重要时才会这样做。

密码的问题

密码的问题在于,每个安全系统的强度取决于它最薄弱的环节。而密码要求用户创造一个密码,这就是最薄弱的环节。人类在创建安全密码方面众所周知地很差。在对 2018 年的安全漏洞进行分析时,最流行的密码是“123456”。“password”排名第二。即使在 2018 年这样注重安全性的年代,人们仍然选择极其糟糕的密码。例如,密码策略要求包括大写字母、数字和标点符号,结果往往是一个“Password1!”这样的密码。

即使分析密码是否与常见密码列表匹配,也无法有效解决问题。随后,人们开始将他们更高质量的密码写在记事本上,存放在未加密的文件中或通过电子邮件发送给自己。

归根结底,这是一个你作为应用设计者无法解决的问题。然而,你可以采取一些措施来促进更安全的密码管理。一种方法是将认证责任交给第三方。另一种方法是使你的登录系统适合密码管理服务,比如 1Password、Bitwarden 和 LastPass。

第三方认证

第三方认证利用了几乎所有互联网用户至少在一个主要服务上拥有账户的事实,比如谷歌、Facebook、Twitter 或 LinkedIn。所有这些服务都提供一种机制,通过其服务对用户进行认证和识别。

注意

第三方认证通常被称为联合认证委托认证。这些术语基本上可以互换使用,尽管联合认证通常与安全声明标记语言(SAML)和 OpenID 相关联,而委托认证则常与 OAuth 相关联。

第三方认证有三大优势。首先,您的认证负担减轻了。您无需担心单独认证每个用户,只需与信任的第三方交互即可。第二个优势是减少密码疲劳:与拥有过多账户相关的压力。我使用LastPass,刚刚查看了我的密码保险库:我几乎有 400 个密码。作为技术专业人员,我可能比普通互联网用户拥有更多账户,但即使是偶尔上网的用户,拥有几十甚至上百个账户也并不罕见。最后,第三方认证是无摩擦的:它允许用户使用已有的凭证更快地开始使用您的网站。通常,如果用户发现他们需要再创建另一个用户名和密码,他们可能会选择放弃。

如果您不使用密码管理器,那么您很可能会为大多数网站使用相同的密码(大多数人都有一个用于银行等的“安全”密码,以及一个用于其他所有事务的“不安全”密码)。这种方法的问题在于,如果您使用的任何一个网站遭到入侵,并且您的密码泄露了,黑客将尝试在其他服务中使用相同的密码。这就像把所有的鸡蛋放在一个篮子里一样。

第三方认证也有其不足之处。难以置信的是,确实有些人并没有 Google、Facebook、Twitter 或 LinkedIn 的账户。此外,即使是拥有这些账户的人,他们出于怀疑(或希望保护隐私)可能不愿意使用这些凭证登录到您的网站上。许多网站通过鼓励用户使用现有账户来解决这个特定问题,但那些没有这些账户的人(或不愿意使用它们访问您的服务的人)可以为您的服务创建一个新的登录账户。

在您的数据库中存储用户

无论您是否依赖第三方来认证您的用户,您都希望在自己的数据库中存储用户记录。例如,如果您使用 Facebook 进行认证,那么这仅验证了用户的身份。如果您需要保存特定于该用户的设置,您不能合理地使用 Facebook 来实现:您必须在自己的数据库中存储有关该用户的信息。此外,您可能希望将电子邮件地址与您的用户关联起来,而他们可能不希望使用与 Facebook(或任何其他第三方认证服务)相同的电子邮件地址。最后,将用户信息存储在您的数据库中允许您自己执行认证,如果您希望提供该选项的话。

让我们为我们的用户创建一个模型,models/user.js

const mongoose = require('mongoose')

const userSchema = mongoose.Schema({
  authId: String,
  name: String,
  email: String,
  role: String,
  created: Date,
})

const User = mongoose.model('User', userSchema)
module.exports = User

并且使用适当的抽象修改db.js(如果您使用的是 PostgreSQL,我会留下这个抽象的实现作为练习):

const User = require('./models/user')

module.exports = {
  //...
  getUserById: async id => User.findById(id),
  getUserByAuthId: async authId => User.findOne({ authId }),
  addUser: async data => new User(data).save(),
}

请记住,MongoDB 数据库中的每个对象都有自己独特的 ID,存储在其_id属性中。然而,那个 ID 是由 MongoDB 控制的,我们需要一些方法将用户记录映射到第三方 ID,所以我们有自己的 ID 属性,称为authId。由于我们将使用多种认证策略,该 ID 将是策略类型和第三方 ID 的组合,以防止冲突。例如,Facebook 用户的authId可能是facebook:525764102,而 Twitter 用户的则可能是twitter:376841763

在我们的示例中,我们将使用两种角色:“客户”和“员工”。

认证与注册及用户体验

认证是指验证用户身份的过程,可以通过一个可信任的第三方,或者通过您提供给用户的凭据(例如用户名和密码)。注册是指用户在您的站点上获得账户的过程(从我们的角度来看,注册是在数据库中创建用户记录的过程)。

当用户第一次加入您的站点时,应清楚地告知他们他们正在注册。使用第三方认证系统,如果他们成功通过第三方进行身份验证,我们可以在未经他们同意的情况下为他们注册。这通常不被视为一种良好的做法,用户应清楚地知道他们是在为您的站点注册(无论他们是否通过第三方进行认证),并提供一个明确的机制来取消他们的会员资格。

要考虑的一个用户体验情况是“第三方认证混乱”。如果一个用户在一月份使用 Facebook 注册了您的服务,然后在七月份回来时,看到一个屏幕提供了使用 Facebook、Twitter、Google 或 LinkedIn 登录的选项,那么用户很可能已经忘记了最初使用的注册服务。这是第三方认证的一个缺点,对此几乎无能为力。这是请求用户提供电子邮件地址的另一个很好的理由:这样,您可以让用户通过电子邮件查找他们的帐户,并向该地址发送一封电子邮件,指明用于认证的服务。

如果您觉得自己对用户使用的社交网络有很好的把握,可以通过一个主要的认证服务来简化这个问题。例如,如果您相当有信心大多数用户拥有 Facebook 账号,您可以设置一个大按钮,上面写着“使用 Facebook 登录”。然后,使用较小的按钮或者仅仅是文本链接,比如“或者使用 Google、Twitter 或 LinkedIn 登录”。这种方法可以减少第三方认证可能带来的混乱情况。

护照(Passport)

Passport 是 Node/Express 的一个非常流行且强大的身份验证模块。它不与任何一个认证机制绑定;相反,它基于可插拔认证策略的概念(包括本地策略,如果你不想使用第三方认证)。理解身份验证信息的流程可能会让人感到不知所措,所以我们将从一个认证机制开始,稍后再添加更多。

重要的细节在于,使用第三方认证时,你的应用程序永远不会接收到密码。这完全由第三方处理。这是件好事:它将安全处理和存储密码的负担放在了第三方身上。^(1)

整个流程依赖于重定向(如果你的应用程序永远不会接收用户的第三方密码,这一点是必须的)。起初,你可能会对为什么可以向第三方传递localhost网址并成功验证感到困惑(毕竟,处理你请求的第三方服务器并不知道你的 localhost)。这是因为第三方简单地指示你的浏览器重定向,而你的浏览器位于你的网络内,因此可以重定向到本地地址。

基本流程如图 18-1 所示。此图表显示了功能流程的重要部分,清晰地表明认证实际上是在第三方网站上进行的。享受这张图表的简洁性——事情即将变得更加复杂。

当你使用 Passport 时,你的应用程序将负责四个步骤。考虑到第三方认证流程的更详细视图,如图 18-2 所示。

第三方认证流程

图 18-1. 第三方认证流程

为简单起见,我们使用 Meadowlark Travel 代表你的应用程序,Facebook 代表第三方认证机制。图 18-2 展示了用户从登录页面到安全账户信息页面的过程(账户信息页面仅用于说明目的:这可以是你网站上需要身份验证的任何页面)。

这张图表展示了在此背景下你通常不会考虑到但却很重要的细节。特别是,当你访问一个 URL 时,并不是在向服务器发出请求:实际上是浏览器在执行这个动作。话虽如此,浏览器可以执行三件事情:发出 HTTP 请求、显示响应并执行重定向(实质上是发出另一个请求并显示另一个响应……这反过来又可能是另一个重定向)。

在 Meadowlark 栏中,您可以看到您的应用程序实际负责的四个步骤。幸运的是,我们将利用 Passport(和可插拔策略)来执行这些步骤的详细信息;否则,这本书会变得更加冗长。

第三方认证流程详细视图

图 18-2。第三方认证流程详细视图

在我们深入实现细节之前,让我们更详细地考虑每个步骤:

登录页面

登录页面是用户可以选择登录方法的地方。如果您使用第三方认证,通常只有一个按钮或链接。如果您使用本地认证,将包括用户名和密码字段。如果用户尝试访问需要身份验证的 URL(例如我们示例中的/account),而没有登录,则可能需要重定向到此页面(或者,您可以重定向到未授权页面,并附带指向登录页面的链接)。

构建认证请求

在此步骤中,您将构建一个要发送到第三方的请求(通过重定向)。此请求的详细信息复杂且特定于认证策略。Passport(和策略插件)将在此处完成所有重要工作。认证请求包括对中间人攻击的保护,以及其他攻击者可能利用的向量。通常,认证请求的生命周期很短,因此您不能存储它并期望以后使用:这有助于通过限制攻击者有时间行动的窗口来防止攻击。在此处,您可以从第三方授权机制请求附加信息。例如,请求用户的姓名和可能的电子邮件地址是常见的。请注意,您从用户请求的信息越多,他们授权您的应用程序的可能性就越小。

验证认证响应

假设用户授权了您的应用程序,您将从第三方获得一个有效的认证响应,这是用户身份的证明。再次强调,此验证的详细信息较为复杂,将由 Passport(和策略插件)处理。如果认证响应表明用户未经授权(例如输入了无效凭据,或者用户未授权您的应用程序),则应将其重定向到适当的页面(可以是返回登录页面,或者是未授权或无法授权页面)。认证响应中将包括用户在特定第三方身份的 ID,以及您在步骤 2 中请求的任何附加详细信息。为了完成第 4 步,我们必须“记住”用户已经授权。通常的做法是设置一个包含用户 ID 的会话变量,指示此会话已经授权(也可以使用 cookies,但我建议使用会话)。

验证授权

在第 3 步,我们在会话中存储了一个用户 ID。该用户 ID 的存在允许我们从数据库中检索用户对象,该对象包含关于用户授权操作的信息。通过这种方式,我们无需为每个请求与第三方进行身份验证(这将导致用户体验缓慢而痛苦)。这项任务很简单,我们不再需要 Passport 来执行此操作:我们有自己的用户对象,其中包含我们自己的身份验证规则。(如果该对象不可用,则表示请求未经授权,我们可以重定向到登录页面或未授权页面。)

提示

使用 Passport 进行身份验证是相当多的工作,正如您在本章中所看到的那样。然而,身份验证是您的应用程序的重要部分,我认为在正确设置方面投入一些时间是明智的。有一些项目(如LockIt)试图提供更“即插即用”的解决方案。另一个越来越受欢迎的选择是Auth0,它非常强大,但设置起来不像 LockIt 那样简单。然而,为了最有效地使用 LockIt 或 Auth0(或类似的解决方案),了解身份验证和授权的详细信息对您来说是非常重要的,而这正是本章的设计目的。此外,如果您需要定制身份验证解决方案,Passport 是一个很好的起点。

设置 Passport

为了保持简单,我们将从一个身份验证提供者开始。任意地,我们选择了 Facebook。在我们可以设置 Passport 和 Facebook 策略之前,我们需要在 Facebook 上进行一些配置。对于 Facebook 身份验证,您需要一个Facebook 应用程序。如果您已经有一个合适的 Facebook 应用程序,您可以使用它,或者您可以为身份验证专门创建一个新的应用程序。如果可能的话,您应该使用您组织的官方 Facebook 帐户来创建应用程序。也就是说,如果您在 Meadowlark Travel 工作,您将使用 Meadowlark Travel 的 Facebook 帐户来创建应用程序(您始终可以将您的个人 Facebook 帐户添加为应用程序的管理员,以便更方便地进行管理)。为了测试目的,使用您自己的 Facebook 帐户是可以的,但是在生产中使用个人帐户会给您的用户留下不专业和可疑的印象。

Facebook 应用程序管理的详细信息似乎经常更改,因此我不打算在此处详细说明。如果您需要有关创建和管理您的应用程序的详细信息,请参阅Facebook 开发者文档

为了开发和测试目的,您需要将开发/测试域名与该应用关联起来。Facebook 允许您使用localhost(和端口号),这对测试非常有利。或者,您可以指定本地 IP 地址,这在使用虚拟化服务器或网络中的另一台服务器进行测试时会很有帮助。重要的是,您在浏览器中输入的用于测试应用程序的 URL(例如http://localhost:3000)与 Facebook 应用程序关联起来。目前,您只能将一个域名与您的应用程序关联起来:如果您需要使用多个域名,您将不得不创建多个应用程序(例如,您可以拥有 Meadowlark Dev、Meadowlark Test 和 Meadowlark Staging;您的生产应用程序可以简称为 Meadowlark Travel)。

配置完成应用程序后,您将需要其唯一的应用程序 ID 和应用程序密钥,这两者都可以在该应用程序的 Facebook 应用管理页面上找到。

提示

您可能会面临的最大挑战之一可能是收到来自 Facebook 的消息,例如“给定的 URL 未在应用配置中允许”。这表明回调 URL 中的主机名和端口与您在应用中配置的不匹配。如果查看浏览器中的 URL,您将看到编码的 URL,这应该会给您一个提示。例如,如果我使用了 192.168.0.103:3443,并且收到了那个消息,我会查看 URL。如果我在查询字符串中看到redirect_uri=https%3A%2F%2F192.68.0.103%3A3443%2F auth%2Ffacebook%2Fcallback,我很快就会发现错误:我在主机名中使用了 68 而不是 168。

现在让我们安装 Passport 和 Facebook 认证策略:

npm install passport passport-facebook

在我们完成之前,将会有大量的认证代码(特别是如果我们支持多种策略),我们不希望在meadowlark.js中混杂所有这些代码。相反,我们将创建一个名为lib/auth.js的模块。这将是一个大文件,因此我们将逐步进行(请参见伴随存储库中的ch18以获取完成示例)。我们将从导入和 Passport 需要的两个方法serializeUserdeserializeUser开始:

const passport = require('passport')
const FacebookStrategy = require('passport-facebook').Strategy

const db = require('../db')

passport.serializeUser((user, done) => done(null, user._id))

passport.deserializeUser((id, done) => {
  db.getUserById(id)
    .then(user => done(null, user))
    .catch(err => done(err, null))
})

Passport 使用serializeUserdeserializeUser将请求映射到经过身份验证的用户,允许您使用任何存储方法。在我们的情况下,我们只会将数据库 ID(即 _id 属性)存储在会话中。我们在这里使用 ID 的方式使得“序列化”和“反序列化”有点名不副实:实际上,我们只是在会话中存储了一个用户 ID。然后,当需要时,我们可以通过在数据库中查找该 ID 来获取用户对象。

一旦实现了这两种方法,只要存在活动会话,并且用户已经成功认证,req.session.passport.user将是从数据库检索到的相应用户对象。

接下来,我们将选择导出什么内容。为了启用 Passport 的功能,我们需要执行两个不同的活动:初始化 Passport,并注册处理身份验证和来自第三方身份验证服务的重定向回调的路由。我们不想在一个函数中将这两个活动合并,因为在我们的主应用程序文件中,我们可能希望选择何时将 Passport 链接到中间件链中(记住在添加中间件时顺序很重要)。因此,我们不是导出执行这些操作中的任一操作的模块导出函数,而是返回一个返回我们需要的方法的对象的函数。为什么不直接返回一个对象?因为我们需要嵌入一些配置值。而且,由于我们需要将 Passport 中间件链接到我们的应用程序中,使用函数可以轻松地传递 Express 应用程序对象:

module.exports = (app, options) => {
  // if success and failure redirects aren't specified,
  // set some reasonable defaults
  if(!options.successRedirect) options.successRedirect = '/account'
  if(!options.failureRedirect) options.failureRedirect = '/login'
  return {
    init: function() { /* TODO */ },
    registerRoutes: function() { /* TODO */ },
  }
}

在我们详细讨论 initregisterRoutes 方法之前,让我们看看我们将如何使用这个模块(希望这能让我们返回一个返回对象的函数的业务更加清晰):

const createAuth = require('./lib/auth')

// ...other app configuration

const auth = createAuth(app, {
  // baseUrl is optional; it will default to localhost if you omit it;
  // it can be helpful to set this if you're not working on
  // your local machine.  For example, if you were using a staging server,
  // you might set the BASE_URL environment variable to
  // https://staging.meadowlark.com
  baseUrl: process.env.BASE_URL,
  providers: credentials.authProviders,
  successRedirect: '/account',
  failureRedirect: '/unauthorized',
})

// auth.init() links in Passport middleware:
auth.init()

// now we can specify our auth routes:
auth.registerRoutes()

请注意,除了指定成功和失败重定向路径外,我们还指定了一个名为 providers 的属性,该属性已在凭据文件中外部化(参见第十三章)。我们需要将 authProviders 属性添加到 .credentials.development.json 文件中:

"authProviders": {
  "facebook": {
    "appId": "your_app_id",
    "appSecret": "your_app_secret"
  }
}
提示

另一个将认证代码打包成这样一个模块的原因是我们可以将其重用于其他项目;事实上,已经有一些认证包在基本上做我们在这里做的事情。但是,了解正在进行的一切的细节非常重要,因此即使最终使用别人编写的模块,这也将帮助您理解您的认证流程中发生的一切。

现在让我们处理我们的 init 方法(先前在 auth.js 中作为“TODO”):

init: function() {
  var config = options.providers

  // configure Facebook strategy
  passport.use(new FacebookStrategy({
    clientID: config.facebook.appId,
    clientSecret: config.facebook.appSecret,
    callbackURL: (options.baseUrl || '') + '/auth/facebook/callback',
  }, (accessToken, refreshToken, profile, done) => {
    const authId = 'facebook:' + profile.id
    db.getUserByAuthId(authId)
      .then(user => {
        if(user) return done(null, user)
        db.addUser({
          authId: authId,
          name: profile.displayName,
          created: new Date(),
          role: 'customer',
        })
          .then(user => done(null, user))
          .catch(err => done(err, null))
      })
      .catch(err => {
        if(err) return done(err, null);
      })
  }))

  app.use(passport.initialize())
  app.use(passport.sessionp))
},

这段代码非常密集,但实际上大部分只是 Passport 的样板代码。关键在于传递给 FacebookStrategy 实例的函数内部。当这个函数被调用时(用户成功认证后),profile 参数包含了关于 Facebook 用户的信息。最重要的是,它包括了 Facebook ID:这是我们将使用来关联 Facebook 帐户到我们自己的用户对象的信息。请注意,我们通过在 authId 属性前加上 *facebook:* 的前缀来命名空间化我们的属性。尽管可能性微乎其微,但这样可以防止 Facebook ID 与 Twitter 或 Google ID 冲突(同时也允许我们检查用户模型,查看用户使用的认证方法,这可能会很有用)。如果数据库已经包含了这个命名空间化的 ID 的条目,我们只需返回它(这时会调用 serializeUser,它会将我们自己的用户 ID 放入会话中)。如果没有返回用户记录,我们会创建一个新的用户对象并将其保存到数据库中。

我们要做的最后一件事是创建我们的 registerRoutes 方法(别担心,这个方法要短得多):

  registerRoutes: () => {
    app.get('/auth/facebook', (req, res, next) => {
      if(req.query.redirect) req.session.authRedirect = req.query.redirect
      passport.authenticate('facebook')(req, res, next)
    })
    app.get('/auth/facebook/callback', passport.authenticate('facebook',
      { failureRedirect: options.failureRedirect }),
      (req, res) => {
        // we only get here on successful authentication
        const redirect = req.session.authRedirect
        if(redirect) delete req.session.authRedirect
        res.redirect(303, redirect || options.successRedirect)
      }
    )
  },

现在我们有了路径 /auth/facebook;访问此路径将自动将访问者重定向到 Facebook 的身份验证界面(这是通过 passport.authenticate('facebook’) 完成的),见 图 18-1 第 2 步。请注意,我们检查是否有查询字符串参数 redirect;如果有,我们将其保存在会话中。这样我们就可以在完成身份验证后自动重定向到预定的目标。一旦用户通过 Twitter 授权,浏览器将被重定向回您的站点——具体来说,是到路径 /auth/facebook/callback(带有可选的 redirect 查询字符串,指示用户最初的位置)。

另外,在查询字符串中还有 Passport 将验证的身份验证令牌。如果验证失败,Passport 将重定向浏览器到 options.failureRedirect。如果验证成功,Passport 将调用 next,这是您的应用程序再次参与的地方。请注意中间件在处理 /auth/facebook/callback 的处理程序中是如何链接的:首先调用 passport.authenticate。如果它调用了 next,控制权将传递给您的函数,然后根据情况重定向到原始位置或 options.successRedirect,如果未指定 redirect 查询字符串参数。

小贴士

省略 redirect 查询字符串参数可能会简化您的身份验证路由,如果只有一个需要身份验证的 URL,这可能会很诱人。但是,将此功能可用将来会很方便,并提供更好的用户体验。毫无疑问,您之前也经历过这种情况:找到想要的页面,然后被要求登录。您登录后,会被重定向到默认页面,然后必须导航回原始页面。这并不是一个令人满意的用户体验。

在这个过程中 Passport 所做的“魔术”是将用户(在我们的情况下,仅仅是数据库用户 ID)保存到会话中。这是件好事,因为浏览器正在重定向,这是一个不同的 HTTP 请求:如果会话中没有这些信息,我们将无法知道用户是否已经通过了身份验证!一旦用户成功通过身份验证,req.session.passport.user 将被设置,这样未来的请求就会知道用户已经通过了身份验证。

让我们看看我们的 /account 处理程序如何检查用户是否已经通过了身份验证(此路由处理程序将在我们的主应用程序文件中,或者在一个单独的路由模块中,而不是在 /lib/auth.js 中):

app.get('/account', (req, res) => {
  if(!req.user)
    return res.redirect(303, '/unauthorized')
  res.render('account', { username: req.user.name })
})
// we also need an 'unauthorized' page
app.get('/unauthorized', (req, res) => {
  res.status(403).render('unauthorized')
})
// and a way to logout
app.get('/logout', (req, res) => {
  req.logout()
  res.redirect('/')
})

现在只有经过身份验证的用户才能看到帐户页面;其他所有人将被重定向到一个未授权页面。

基于角色的授权

到目前为止,我们在技术上并没有执行任何授权(我们只是区分了经过授权和未经授权的用户)。然而,假设我们只想让顾客看到他们的账户视图(员工可能有一个完全不同的视图,他们可以在其中查看用户账户信息)。

记住,在单个路由中,你可以有多个按顺序调用的函数。让我们创建一个名为customerOnly的函数,它将只允许顾客:

const customerOnly = (req, res, next) => {
  if(req.user && req.user.role === 'customer') return next()
  // we want customer-only pages to know they need to logon
  res.redirect(303, '/unauthorized')
}

让我们还创建一个employeeOnly函数,它将有所不同。假设我们有一个路径/sales,我们希望只有员工能够访问。此外,我们不希望非员工甚至意外地知道它的存在。如果潜在的攻击者访问了/sales路径,并看到了一个未经授权的页面,这可能会使攻击变得更容易(只需知道页面的存在)。因此,为了增加一点安全性,我们希望非员工在访问/sales页面时看到一个普通的 404 页面,让潜在的攻击者没有可利用的信息:

const employeeOnly = (req, res, next) => {
  if(req.user && req.user.role === 'employee') return next()
  // we want employee-only authorization failures to be "hidden", to
  // prevent potential hackers from even knowing that such a page exists
  next('route')
}

调用next('route’)不仅仅会执行路由中的下一个处理程序:它将完全跳过这个路由。假设后面没有处理/account的路由,最终将会传递给 404 处理程序,从而得到我们想要的结果。

使用这些功能非常容易:

// customer routes

app.get('/account', customerOnly, (req, res) => {
  res.render('account', { username: req.user.name })
})
app.get('/account/order-history', customerOnly, (req, res) => {
  res.render('account/order-history')
})
app.get('/account/email-prefs', customerOnly, (req, res) => {
  res.render('account/email-prefs')
})

// employer routes

app.get('/sales', employeeOnly, (req, res) => {
	res.render('sales')
})

应该明确的是基于角色的授权可以像你希望的那样简单或者复杂。例如,如果你想允许多个角色怎么办?你可以使用下面的函数和路由:

const allow = roles => (req, res, next) => {
  if(req.user && roles.split(',').includes(req.user.role)) return next()
  res.redirect(303, '/unauthorized')
}

希望这个例子能让你对基于角色的授权有所了解。你甚至可以根据其他属性进行授权,比如用户成为会员的时间长度或者与你预定的假期数量。

添加认证提供者

现在我们的框架已经就位,添加更多认证提供者变得很容易。假设我们想要与 Google 进行认证。在我们开始添加代码之前,你需要在你的 Google 账号上设置一个项目。

前往你的Google 开发者控制台,并在导航栏中选择一个项目(如果你还没有项目,点击新建项目并按照指示操作)。一旦选择了项目,点击“启用 API 和服务”,启用 Cloud Identity API。点击凭据,然后创建凭据,选择“OAuth 客户端 ID”,然后选择“Web 应用程序”。输入你的应用程序的适当 URL:用于测试你可以使用http://localhost:3000作为授权来源,http://localhost:3000/auth/google/callback作为授权重定向 URI。

一旦你在 Google 端设置好了一切,运行npm install passport-google-oauth20,然后将以下代码添加到lib/auth.js中:

// configure Google strategy
passport.use(new GoogleStrategy({
  clientID: config.google.clientID,
  clientSecret: config.google.clientSecret,
  callbackURL: (options.baseUrl || '') + '/auth/google/callback',
}, (token, tokenSecret, profile, done) => {
  const authId = 'google:' + profile.id
  db.getUserByAuthId(authId)
    .then(user => {
      if(user) return done(null, user)
      db.addUser({
        authId: authId,
        name: profile.displayName,
        created: new Date(),
        role: 'customer',
      })
        .then(user => done(null, user))
        .catch(err => done(err, null))
    })
    .catch(err => {
      console.log('whoops, there was an error: ', err.message)
      if(err) return done(err, null);
    })
}))

并将以下内容添加到registerRoutes方法中:

app.get('/auth/google', (req, res, next) => {
  if(req.query.redirect) req.session.authRedirect = req.query.redirect
  passport.authenticate('google', { scope: ['profile'] })(req, res, next)
})
app.get('/auth/google/callback', passport.authenticate('google',
  { failureRedirect: options.failureRedirect }),
  (req, res) => {
    // we only get here on successful authentication
    const redirect = req.session.authRedirect
    if(redirect) delete req.session.authRedirect
    res.redirect(303, req.query.redirect || options.successRedirect)
  }
)

结论

恭喜您通过了最复杂的章节!很遗憾,如此重要的功能(认证和授权)如此复杂,但在一个充满安全威胁的世界中,这是不可避免的复杂性。幸运的是,像 Passport 这样的项目(以及基于它的优秀认证方案)在某种程度上减轻了我们的负担。然而,我鼓励您不要在应用程序的这个领域马虎对待:在安全领域保持谨慎将使您成为一个优秀的网络公民。您的用户可能永远不会因此感谢您,但如果因为安全不当导致用户数据泄露,应用程序的所有者将会后悔不已。

^(1) 第三方也不太可能存储密码。密码可以通过存储所谓的盐值哈希来验证,这是密码的单向转换。也就是说,一旦你从密码生成了哈希值,就无法恢复密码。对哈希进行加盐可以提供额外的保护,防止某些类型的攻击。

第十九章:与第三方 API 集成

越来越成功的网站不再是完全独立的。与社交网络的集成对于吸引现有用户并找到新用户至关重要。为了提供门店定位器或其他位置感知服务,使用地理定位和地图服务至关重要。事情并不止于此:越来越多的组织意识到提供 API 有助于扩展其服务并使其更有用。

在本章中,我们将讨论两种最常见的集成需求:社交媒体和地理定位。

社交媒体

社交媒体是促销产品或服务的绝佳方式:如果这是您的目标,那么使用户能够轻松在社交媒体网站上分享您的内容至关重要。我写这篇文章时,主要的社交网络服务包括 Facebook、Twitter、Instagram 和 YouTube。像 Pinterest 和 Flickr 这样的网站也有它们的用途,但它们通常更加针对特定受众(例如,如果您的网站是关于 DIY 手工艺品的,您绝对需要支持 Pinterest)。笑吧,但我预测 MySpace 将会复兴。它的网站重新设计很有启发性,值得注意的是,MySpace 是建立在 Node 上的。

社交媒体插件与网站性能

大多数社交媒体集成是前端的事务。您在页面中引用适当的 JavaScript 文件,它会使得入站内容(例如来自您的 Facebook 页面的前三篇文章)和出站内容(例如在您所在页面上发布推文的能力)都变得可能。虽然这通常代表了社交媒体集成的最简单路径,但也伴随着成本:由于额外的 HTTP 请求,页面加载时间可能会增加一倍甚至三倍。如果页面性能对您很重要(尤其是对移动用户而言),您应该仔细考虑如何集成社交媒体。

话虽如此,启用 Facebook 点赞按钮或推特按钮的代码利用了浏览器中的 cookie 来代表用户发布内容,将这些功能移至后端将会很困难(有时甚至是不可能的)。因此,如果这是您需要的功能,链接适当的第三方库是最佳选择,尽管它可能会影响页面性能。

搜索推文

假设我们想要提及包含标签#Oregon #travel 的最近十条推文。我们可以使用前端组件来实现这一点,但这将涉及额外的 HTTP 请求。此外,如果我们在后端进行操作,我们可以选择缓存推文以提升性能。此外,如果我们在后端进行搜索,我们可以对“恶意推文”进行黑名单处理,这在前端会更为困难。

Twitter,类似于 Facebook,允许您创建apps。这有点误导:Twitter 应用程序并不任何事情(传统意义上)。它更像是一组您可以在站点上使用来创建实际应用程序的凭据。访问 Twitter API 的最简单和最可移植的方式是创建一个应用程序并使用它来获取访问令牌。

通过访问http://dev.twitter.com来创建 Twitter 应用程序。确保已登录,然后点击导航栏中的用户名,然后选择“Apps”。点击“创建应用程序”,按照说明进行操作。创建应用程序后,您将看到现在有一个consumer API key和一个API secret key。API secret key 应保持秘密:绝不要将其包含在发送给客户端的响应中。如果第三方获取了此秘密,他们可以代表您的应用程序进行请求,如果使用是恶意的,对您可能会产生不利后果。

现在我们有了一个 consumer API key 和 secret key,我们可以与 Twitter REST API 进行通信。

为了保持代码整洁,我们将我们的 Twitter 代码放在一个名为lib/twitter.js的模块中:

const https = require('https')

module.exports = twitterOptions => {

 return {

  search: async (query, count) => {
    // TODO
  }
 }

}

这种模式应该开始变得熟悉了。我们的模块将一个配置对象传递给调用者导出一个函数。返回的是一个包含方法的对象。通过这种方式,我们可以为我们的模块添加功能。目前,我们只提供了一个search方法。以下是我们将如何使用这个库的方法:

const twitter = require('./lib/twitter')({
  consumerApiKey: credentials.twitter.consumerApiKey,
  apiSecretKey: credentials.twitter.apiSecretKey,
})

const tweets = await twitter.search('#Oregon #travel', 10)
// tweets will be in result.statuses

(在.credentials.development.json文件中不要忘记添加twitter属性,包括consumerApiKeyapiSecretKey。)

在我们实现search方法之前,我们必须提供一些功能来对我们自己进行 Twitter 认证。这个过程很简单:我们使用 HTTPS 请求基于我们的 consumer key 和 consumer secret 的访问令牌。我们只需做一次:目前,Twitter 不会过期访问令牌(尽管您可以手动使其失效)。因为我们不想每次都请求访问令牌,所以我们将缓存访问令牌以便重复使用。

我们构建模块的方式允许我们创建对调用者不可见的私有功能。具体来说,对调用者可见的仅有module.exports。因为我们返回的是一个函数,所以只有该函数对调用者可见。调用该函数将返回一个对象,而只有该对象的属性对调用者可见。因此,我们将创建一个名为accessToken的变量,用于缓存访问令牌,以及一个名为getAccessToken的函数,用于获取访问令牌。首次调用时,它将发出 Twitter API 请求以获取访问令牌。后续调用将简单地返回accessToken的值:

const https = require('https')

module.exports = function(twitterOptions) {

  // this variable will be invisible outside of this module
  let accessToken = null

  // this function will be invisible outside of this module
  const getAccessToken = async () => {
    if(accessToken) return accessToken
    // TODO: get access token
  }

  return {
    search: async (query, count) => {
      // TODO
    }
  }

}

我们将getAccessToken标记为异步,因为我们可能需要向 Twitter API 发出 HTTP 请求(如果没有缓存的令牌)。既然我们已经建立了基本结构,让我们实现getAccessToken

const getAccessToken = async () => {
  if(accessToken) return accessToken

  const bearerToken = Buffer(
    encodeURIComponent(twitterOptions.consumerApiKey) + ':' +
    encodeURIComponent(twitterOptions.apiSecretKey)
  ).toString('base64')

  const options = {
    hostname: 'api.twitter.com',
    port: 443,
    method: 'POST',
    path: '/oauth2/token?grant_type=client_credentials',
    headers: {
      'Authorization': 'Basic ' + bearerToken,
    },
  }

  return new Promise((resolve, reject) =>
    https.request(options, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => {
        const auth = JSON.parse(data)
        if(auth.token_type !== 'bearer')
          return reject(new Error('Twitter auth failed.'))
        accessToken = auth.access_token
        return resolve(accessToken)
      })
    }).end()
  )
}

构建此调用的详细信息可在Twitter 的应用程序仅身份验证开发文档页面中找到。基本上,我们必须构造一个基于 base64 编码的消费者密钥和消费者密钥的令牌。构造了该令牌后,我们可以使用包含该令牌的Authorization头部调用/oauth2/token API 请求访问令牌。请注意,我们必须使用 HTTPS:如果尝试通过 HTTP 进行此调用,则会未加密地传输您的密钥,API 将简单地挂断您的连接。

一旦我们从 API 接收到完整的响应(我们监听响应流的end事件),我们可以解析 JSON,确保令牌类型为bearer,然后继续我们的工作。我们缓存访问令牌,然后调用回调函数。

现在我们有了获取访问令牌的机制,我们可以进行 API 调用了。让我们实现我们的search方法:

search: async (query, count) => {
  const accessToken = await getAccessToken()
  const options = {
    hostname: 'api.twitter.com',
    port: 443,
    method: 'GET',
    path: '/1.1/search/tweets.json?q=' +
      encodeURIComponent(query) +
      '&count=' + (count || 10),
    headers: {
      'Authorization': 'Bearer ' + accessToken,
    },
  }
  return new Promise((resolve, reject) =>
    https.request(options, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => resolve(JSON.parse(data)))
    }).end()
  )
},

渲染推文

现在我们有了搜索推文的能力……那么我们如何在网站上显示它们?在很大程度上取决于您,但是有一些需要考虑的事项。Twitter 希望确保其数据的使用符合品牌的一致性。为此,它确实有显示要求,其中包含您必须包含的功能元素来显示推文。

在需求方面有一些灵活性(例如,如果您在不支持图像的设备上显示,则无需包含头像图像),但大部分时间,您将最终得到非常类似于嵌入式推文的东西。这是一项很大的工作,但是有一种方法可以绕过它……但这涉及到链接到 Twitter 的小部件库,这正是我们试图避免的 HTTP 请求。

如果您需要显示推文,最好使用 Twitter 小部件库,即使这会产生额外的 HTTP 请求。对于更复杂的 API 使用,您仍然需要从后端访问 REST API,因此您可能最终会与前端脚本一起使用 REST API。

让我们继续我们的示例:我们希望显示提到标签#Oregon #travel 的前 10 条推文。我们将使用 REST API 搜索推文,并使用 Twitter 小部件库显示它们。由于我们不希望超出使用限制(或减慢服务器速度),我们将缓存这些推文和用于显示它们的 HTML 15 分钟。

我们将首先修改我们的 Twitter 库,以包含一个 embed 方法,该方法获取用于显示推文的 HTML。请注意,我们正在使用 npm 库 querystringify 来从对象构建查询字符串,因此不要忘记 npm install querystringify 并导入它(const qs = require( ‘querystringify ’)),然后将以下函数添加到 lib/twitter.js 的导出中:

embed: async (url, options = {}) => {
  options.url = url
  const accessToken = await getAccessToken()
  const requestOptions = {
    hostname: 'api.twitter.com',
    port: 443,
    method: 'GET',
    path: '/1.1/statuses/oembed.json?' + qs.stringify(options),
    headers: {
      'Authorization': 'Bearer ' + accessToken,
    },
  }
  return new Promise((resolve, reject) =>
    https.request(requestOptions, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => resolve(JSON.parse(data)))
    }).end()
  )
},

现在我们准备搜索并缓存推文了。在我们的主应用程序文件中,创建以下函数 getTopTweets

const twitterClient = createTwitterClient(credentials.twitter)

const getTopTweets = ((twitterClient, search) => {
  const topTweets = {
    count: 10,
    lastRefreshed: 0,
    refreshInterval: 15 * 60 * 1000,
    tweets: [],
  }
  return async () => {
    if(Date.now() > topTweets.lastRefreshed + topTweets.refreshInterval) {
      const tweets =
       await twitterClient.search('#Oregon #travel', topTweets.count)
      const formattedTweets = await Promise.all(
        tweets.statuses.map(async ({ id_str, user }) => {
          const url = `https://twitter.com/${user.id_str}/statuses/${id_str}`
          const embeddedTweet =
           await twitterClient.embed(url, { omit_script: 1 })
          return embeddedTweet.html
        })
      )
      topTweets.lastRefreshed = Date.now()
      topTweets.tweets = formattedTweets
    }
    return topTweets.tweets
  }
})(twitterClient, '#Oregon #travel')

getTopTweets 函数的核心不仅仅是搜索具有指定标签的推文,而是为一段合理时间内的这些推文进行缓存。请注意,我们创建了一个立即调用的函数表达式(IIFE):这是因为我们希望 topTweets 缓存在闭包内部,以防止被篡改。从 IIFE 返回的异步函数在必要时刷新缓存,然后返回缓存内容。

最后,让我们创建一个视图,views/social.handlebars,作为我们社交媒体存在的主页(目前仅包括我们选择的推文):

<h2>Oregon Travel in Social Media</h2>

<script id="twitter-wjs" type="text/javascript"
  async defer src="//platform.twitter.com/widgets.js"></script>

{{{tweets}}}

还有一个处理它的路由:

app.get('/social', async (req, res) => {
  res.render('social', { tweets: await getTopTweets() })
})

请注意,我们引用了外部脚本,Twitter 的 widgets.js。这个脚本将为页面上嵌入的推文格式化并提供功能。默认情况下,oembed API 将在 HTML 中包含对此脚本的引用,但由于我们要显示 10 条推文,这会比必要的引用该脚本多九次!因此,请回想一下,当我们调用 oembed API 时,我们传入了选项 { omit_script: 1 }。由于我们这样做了,我们在视图中提供了它。尝试从视图中删除脚本。您仍然会看到推文,但它们将没有任何格式或功能。

现在我们有了一个不错的社交媒体信息流!让我们将注意力转向另一个重要应用程序:在我们的应用程序中显示地图。

地理编码

地理编码 指的是将街道地址或地名(Bletchley Park, Sherwood Drive, Bletchley, Milton Keynes MK3 6EB, UK)转换为地理坐标(纬度 51.9976597,经度 –0.7406863)的过程。如果您的应用程序需要进行任何形式的地理计算(距离或方向)或显示地图,则需要地理坐标。

注意

您可能习惯于看到使用度分秒(DMS)指定的地理坐标。地理编码 API 和地图服务使用单个浮点数表示纬度和经度。如果您需要显示 DMS 坐标,请参阅 此维基百科文章

使用 Google 进行地理编码

Google 和 Bing 都提供优秀的地理编码 REST 服务。我们将在示例中使用 Google,但是 Bing 的服务非常类似。

如果您未将计费账户附加到您的 Google 账户,则您的地理编码请求将每天限制为一次,这将导致测试周期非常缓慢!在这本书中尽可能地,我试图避免推荐您至少在开发阶段不能免费使用的服务,并且我确实尝试了一些免费地理编码服务,并发现了足够大的可用性差距,因此我继续推荐 Google 地理编码。但是,就我所写的而言,使用 Google 进行开发量地理编码的成本是免费的:您的账户会获得每月 200 美元的信用额度,您需要做 40,000 次请求才能用尽!如果您想跟着本章进行,请转到 您的 Google 控制台,从主菜单中选择计费,并输入您的计费信息。

一旦设置了计费,您将需要 Google 地理编码 API 的 API 密钥。转到 控制台,从导航栏中选择您的项目,然后单击 API。如果地理编码 API 不在您启用的 API 列表中,请在附加 API 列表中找到并添加它。大多数 Google API 共享相同的 API 凭据,因此请单击左上角的导航菜单,返回到您的仪表板。单击凭据,如果您还没有合适的 API 密钥,则创建一个新的 API 密钥。请注意,API 密钥可以受限以防止滥用,因此请确保您的 API 密钥可以从您的应用程序中使用。如果您需要用于开发的密钥,您可以按 IP 地址限制密钥,并选择您的 IP 地址(如果您不知道它是什么,可以询问 Google,“我的 IP 地址是多少?”)。

一旦获得 API 密钥,请将其添加到 .credentials.development.json

"google": {
  "apiKey": "<YOUR API KEY>"
}

然后创建一个模块 lib/geocode.js

const https = require('https')
const { credentials } = require('../config')

module.exports = async query => {

  const options = {
    hostname: 'maps.googleapis.com',
    path: '/maps/api/geocode/json?address=' +
      encodeURIComponent(query) + '&key=' +
      credentials.google.apiKey,
  }

  return new Promise((resolve, reject) =>
    https.request(options, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => {
        data = JSON.parse(data)
        if(!data.results.length)
          return reject(new Error(`no results for "${query}"`))
        resolve(data.results[0].geometry.location)
      })
    }).end()
  )

}

现在我们有一个函数,将联系 Google API 对地址进行地理编码。如果找不到地址(或由于任何其他原因而失败),将返回错误。API 可以返回多个地址。例如,如果您搜索“10 Main Street”而没有指定城市、州或邮政编码,它将返回数十个结果。我们的实现只是选择第一个。API 返回大量信息,但目前我们感兴趣的只是坐标。您可以轻松修改此接口以返回更多信息。请参阅 Google 地理编码 API 文档 以了解 API 返回的更多信息。

使用限制

目前 Google 地理编码 API 每月有使用限制,但您每次地理编码请求支付 0.005 美元。因此,如果您在任何给定月份进行了百万次请求,您将从 Google 收到 5,000 美元的账单……因此,对您来说可能存在一个实际的限制!

提示

如果您担心发生不必要的费用——如果您意外地让一个服务运行,或者如果一个不良分子获取了您的凭证,这可能会发生——您可以添加预算并配置警报,以在接近预算时通知您。转到您的 Google 开发者控制台,并从计费菜单中选择“预算和警报”。

在撰写本文时,Google 限制您在 100 秒内进行 5000 次请求,以防止滥用,这是很难超过的。Google 的 API 还要求,如果您在网站上使用地图,您必须使用 Google Maps。也就是说,如果您使用 Google 的服务来进行地理编码您的数据,您不能反过来在 Bing 地图上显示这些信息,否则将违反服务条款。一般来说,这不是一个繁琐的限制,因为您可能不会进行地理编码,除非您打算在地图上显示位置。但是,如果您更喜欢 Bing 的地图还是 Google 的地图,您应该注意服务条款,并使用适当的 API。

编码您的数据

我们拥有一个关于俄勒冈周围度假套餐的很好的数据库,我们可能决定要显示一个带有标注的地图,显示各种度假的位置,这就是地理编码的用武之地。

我们已经在数据库中有度假数据,每个度假都有一个位置搜索字符串,可以用于地理编码,但我们还没有坐标。

现在的问题是何时以及如何进行地理编码?总体而言,我们有三个选择:

  • 当向数据库添加新的假期时进行地理编码。当我们向系统添加允许供应商动态添加假期到数据库的管理员界面时,这可能是一个很好的选择。然而,由于我们没有完成这个功能,因此我们将放弃这个选项。

  • 检索数据库中的度假时根据需要进行地理编码。这种方法会在每次从数据库获取度假信息时进行检查:如果有任何度假信息缺少坐标,我们将对其进行地理编码。这个选项听起来很吸引人,可能是三个选项中最简单的一个,但它有一些重大的缺点,使其不适用。首先是性能问题:如果您向数据库添加了一千个新的度假信息,查看度假列表的第一个人将不得不等待所有这些地理编码请求成功并写入数据库。此外,可以想象一个负载测试套件向数据库添加了一千个度假信息,然后执行了一千个请求。由于它们都在同时运行,每一个请求都会导致一千个地理编码请求,因为数据还没有写入数据库……导致一百万个地理编码请求和来自 Google 的 5000 美元账单!因此,我们将这个选项划掉。

  • 编写一个脚本来查找缺少坐标日期的假期,并对它们进行地理编码。这种方法为我们当前的情况提供了最佳解决方案。为了开发目的,我们一次性填充假期数据库,但我们还没有为添加新假期设计管理界面。此外,如果以后决定添加管理界面,这种方法与此并不冲突:事实上,我们只需在添加新假期后运行此流程,它就会正常工作。

首先,我们需要添加一种方法来更新 db.js 中的现有假期(我们还将添加一个关闭数据库连接的方法,在脚本中会很方便):

module.exports = {
  //...
  updateVacationBySku: async (sku, data) => Vacation.updateOne({ sku }, data),
  close: () => mongoose.connection.close(),
}

然后我们可以编写一个脚本 db-geocode.js

const db = require('./db')
const geocode = require('./lib/geocode')

const geocodeVacations = async () => {
  const vacations = await db.getVacations()
  const vacationsWithoutCoordinates = vacations.filter(({ location }) =>
    !location.coordinates || typeof location.coordinates.lat !== 'number')
  console.log(`geocoding ${vacationsWithoutCoordinates.length} ` +
    `of ${vacations.length} vacations:`)
  return Promise.all(vacationsWithoutCoordinates.map(async ({ sku, location }) => {
    const { search } = location
    if(typeof search !== 'string' || !/\w/.test(search))
      return console.log(`  SKU ${sku} FAILED: does not have location.search`)
    try {
      const coordinates = await geocode(search)
      await db.updateVacationBySku(sku, { location: { search, coordinates } })
      console.log(`  SKU ${sku} SUCCEEDED: ${coordinates.lat}, ${coordinates.lng}`)
    } catch(err) {
      return console.log(`  SKU {sku} FAILED: ${err.message}`)
    }
  }))
}

geocodeVacations()
  .then(() => {
    console.log('DONE')
    db.close()
  })
  .catch(err => {
    console.error('ERROR: ' + err.message)
    db.close()
  })

当您运行脚本 (node db-geocode.js) 时,您应该看到所有假期都已成功地进行了地理编码!现在我们有了这些信息,让我们学习如何在地图上显示它……

显示地图

虽然在地图上显示假期实际上属于“前端”工作,但到达这一步却看不到我们劳动成果将会非常令人失望。因此,我们将稍微偏离本书的后端重点,看看如何在地图上显示我们新编码的经销商。

我们已经创建了一个谷歌 API 密钥来进行地理编码,但我们仍然需要启用地图 API。前往 您的谷歌控制台,点击 APIs,找到 Maps JavaScript API 并启用(如果尚未启用)。

现在我们可以创建一个视图来显示我们的假期地图,views/vacations-map.handlebars。我们将从仅显示地图开始,并继续添加假期:

<div id="map" style="width: 100%; height: 60vh;"></div>
<script>
  let map = undefined
  async function initMap() {
    map = new google.maps.Map(document.getElementById('map'), {
      // approximate geographic center of oregon
      center: { lat: 44.0978126, lng: -120.0963654 },
      // this zoom level covers most of the state
      zoom: 7,
    })
  }
</script>
<script src="https://maps.googleapis.com/maps/api/js?key={{googleApiKey}}&callback=initMap"
    async defer></script>

现在是时候在地图上放置一些与我们的假期相对应的标记了。在 第十五章 中,我们创建了一个 API 端点 /api/vacations,现在将包含地理编码数据。我们将使用该端点获取我们的假期,并在地图上放置标记。修改 views/vacations-map.handlebars.js 中的 initMap 函数:

async function initMap() {
  map = new google.maps.Map(document.getElementById('map'), {
    // approximate geographic center of oregon
    center: { lat: 44.0978126, lng: -120.0963654 },
    // this zoom level covers most of the state
    zoom: 7,
  })
  const vacations = await fetch('/api/vacations').then(res => res.json())
  vacations.forEach(({ name, location }) => {
    const marker = new google.maps.Marker({
      position: location.coordinates,
      map,
      title: name,
    })
  })
}

现在我们有一张显示所有假期位置的地图了!我们可以通过很多方式来改进这个页面:可能最好的起点是链接标记与假期详细页面,这样您可以点击一个标记,它会带您到假期信息页面。我们还可以实现自定义标记或工具提示:谷歌地图 API 有很多功能,您可以从 官方谷歌文档 中了解它们。

天气数据

还记得我们在 第七章 中的“当前天气”小部件吗?让我们连接一些实时数据!我们将使用美国国家气象局 (NWS) API 来获取预报信息。与我们的 Twitter 集成和地理编码一样,我们将缓存预报信息,以防止每次对我们网站的访问都通过 NWS(如果我们的网站变得流行可能会使我们被列入黑名单)。创建一个名为 lib/weather.js 的文件:

const https = require('https')
const { URL } = require('url')

const _fetch = url => new Promise((resolve, reject) => {
  const { hostname, pathname, search } = new URL(url)
  const options = {
    hostname,
    path: pathname + search,
    headers: {
      'User-Agent': 'Meadowlark Travel'
    },
  }
  https.get(options, res => {
    let data = ''
    res.on('data', chunk => data += chunk)
    res.on('end', () => resolve(JSON.parse(data)))
  }).end()
})

module.exports = locations => {

  const cache = {
    refreshFrequency: 15 * 60 * 1000,
    lastRefreshed: 0,
    refreshing: false,
    forecasts: locations.map(location => ({ location })),
  }

  const updateForecast = async forecast => {
    if(!forecast.url) {
      const { lat, lng } = forecast.location.coordinates
      const path = `/points/${lat.toFixed(4)},${lng.toFixed(4)}`
      const points = await _fetch('https://api.weather.gov' + path)
      forecast.url = points.properties.forecast
    }
    const { properties: { periods } } = await _fetch(forecast.url)
    const currentPeriod = periods[0]
    Object.assign(forecast, {
      iconUrl: currentPeriod.icon,
      weather: currentPeriod.shortForecast,
      temp: currentPeriod.temperature + ' ' + currentPeriod.temperatureUnit,
    })
    return forecast
  }

  const getForecasts = async () => {
    if(Date.now() > cache.lastRefreshed + cache.refreshFrequency) {
      console.log('updating cache')
      cache.refreshing = true
      cache.forecasts = await Promise.all(cache.forecasts.map(updateForecast))
      cache.refreshing = false
    }
    return cache.forecasts
  }

  return getForecasts

}

你会注意到,我们厌倦了直接使用 Node 内置的https库,而是创建了一个实用函数_fetch,使我们的天气功能更易读一些。可能会有一件事引起你的注意,那就是我们将User-Agent标头设置为Meadowlark Travel。这是 NWS 天气 API 的一个怪癖:它需要一个字符串作为User-Agent。他们声明最终将其替换为 API 密钥,但现在我们只需要在这里提供一个值。

从 NWS API 获取天气数据在这里是一个两步操作。有一个 API 端点叫做points,它接受一个带有精确四位小数的纬度和经度,并返回关于该位置的信息...包括获取预报的适当 URL。一旦我们对任何给定的坐标有了那个 URL,我们就不需要再次获取它。我们只需要调用那个 URL 来获取更新的预报。

注意,预报返回的数据比我们使用的要多得多;我们可以用这个特性做更多复杂的事情。特别是,预报的 URL 返回一个期间数组,第一个元素是当前期间(例如,“下午”或“晚上”),接着是延伸到下周的期间。可以随意查看periods数组中的数据,了解可用的数据类型。

我们的缓存中有一个名为refreshing的布尔属性,值得注意的细节。这是必需的,因为更新缓存需要一定的时间,并且是异步完成的。如果在第一次缓存刷新完成之前有多个请求进来,它们都会触发刷新缓存的工作。这不会造成实质性损害,但会多出一些不必要的 API 调用。这个布尔变量只是一个标志,用来告诉任何额外的请求:“我们正在处理中。”

我们设计它成为我们在第七章创建的虚拟函数的即插即用替代品。我们所要做的就是打开lib/middleware/weather.js,并替换getWeatherData函数:

const weatherData = require('../weather')

const getWeatherData = weatherData([
  {
    name: 'Portland',
    coordinates: { lat: 45.5154586, lng: -122.6793461 },
  },
  {
    name: 'Bend',
    coordinates: { lat: 44.0581728, lng: -121.3153096 },
  },
  {
    name: 'Manzanita',
    coordinates: { lat: 45.7184398, lng: -123.9351354 },
  },
])

现在我们的小部件中有了实时天气数据!

结论

我们实际上只是初步了解了与第三方 API 集成可能完成的事情的表面。无论你看哪里,新的 API 都在不断涌现,提供各种想得到的数据(甚至波特兰市现在也通过 REST API 提供了大量的公共数据)。虽然不可能涵盖你可以使用的 API 的一小部分,但本章涵盖了你需要了解的使用这些 API 的基础知识:http.requesthttps.request和解析 JSON。

现在我们掌握了很多知识。我们走了很长一段路!但是当事情出错时会发生什么呢?在下一章中,我们将讨论调试技术,帮助我们解决事情不如预期时的情况。

第二十章:调试

“调试”可能是一个不幸的术语,因为它与缺陷有关。事实上,我们所说的“调试”是一种活动,你会发现自己一直在做,无论是实现新功能、学习某些东西的工作原理,还是实际修复一个 bug。一个更好的术语可能是“探索”,但我们还是坚持使用“调试”,因为它所指的活动是明确的,无论动机如何。

调试是一个经常被忽视的技能:似乎大多数程序员被期望天生就知道如何做。也许计算机科学教授和书籍作者认为调试是如此明显的一项技能,以至于他们忽视了它。

事实上,调试是一种可以教授的技能,它是程序员了解他们所工作的框架以及他们自己和团队代码的重要方式。在本章中,我们将讨论一些可以有效调试 Node 和 Express 应用程序的工具和技术。

调试的第一原则

顾名思义,“调试”通常指找出和消除缺陷的过程。在谈论工具之前,让我们考虑一些通用的调试原则。

我多少次告诉过你,当你排除了不可能的事情之后,无论多么不可思议,剩下的一定是真相?

阿瑟·柯南·道尔

调试的第一个和最重要的原则是排除的过程。现代计算机系统非常复杂,如果你必须在脑海中掌握整个系统,并从中挑出单个问题的源头,你可能甚至都不知道从哪里开始。每当你面对一个不明显的问题时,你的第一个想法应该是:“我可以排除作为问题来源的哪些因素?”你能排除得越多,你就需要查找的地方就越少。

排除可以采取多种形式。以下是一些常见的例子:

  • 系统地注释或禁用代码块。

  • 编写可以通过单元测试覆盖的代码;单元测试本身提供了一个排除错误的框架。

  • 分析网络流量以确定问题是客户端还是服务器端。

  • 测试与第一个相似的系统的不同部分。

  • 使用之前成功的输入,并逐步更改输入直到问题显现。

  • 使用版本控制在时间上前后移动,直到问题消失,并且你可以将其隔离到特定的更改(有关此信息,请参见git bisect)。

  • “模拟”功能以消除复杂的子系统。

虽然消除不是万能药,但它也不是银弹。通常,问题是由两个或更多组件之间复杂的交互引起的:消除(或模拟)任何一个组件,问题可能会消失,但问题不能被隔离到任何单一组件。即使在这种情况下,消除也可以帮助缩小问题的范围,即使它没有在确切位置上点亮霓虹灯。

消除最成功的时候要谨慎和有条理。当你只是肆意消除组件而不考虑这些组件对整体的影响时,很容易忽略事物。和自己玩一个游戏:当你考虑消除一个组件时,走一遍消除那个组件将如何影响系统的步骤。这将告诉你要期待什么,以及消除组件是否会告诉你有用的信息。

充分利用 REPL 和控制台

无论是 Node 还是你的浏览器都提供了一个读取-评估-打印循环(REPL);这基本上只是一种交互式编写 JavaScript 的方式。你输入一些 JavaScript 代码,按 Enter,立即看到输出。这是一个很好的玩耍方式,通常也是在小段代码中找到错误最快最直观的方式。

在浏览器中,你只需打开 JavaScript 控制台,你就有了一个 REPL。在 Node 中,你只需不带任何参数输入node,你就进入了 REPL 模式;你可以要求包、创建变量和函数,或者做你在代码中通常能做的任何其他事情(除了创建包:在 REPL 中没有有意义的方式来做这件事)。

控制台日志也是你的好朋友。这是一种粗糙的调试技术,也许是一种简单的技术(易于理解和实现)。在 Node 中调用console.log将以易于阅读的格式输出对象的内容,因此你可以轻松地发现问题。请记住,某些对象可能很大,将它们记录到控制台中将产生大量输出,这样你可能很难找到任何有用的信息。例如,尝试在你的路径处理程序中使用console.log(req)

使用 Node 的内置调试器

Node 有一个内置的调试器,允许你像陪同 JavaScript 解释器一样逐步调试你的应用程序。要开始调试你的应用程序,你只需使用inspect参数:

node inspect meadowlark.js

当你这样做时,你会立即注意到几件事情。首先,在你的控制台上你会看到一个 URL;这是因为 Node 调试器通过创建自己的 Web 服务器来工作,这允许你控制被调试应用程序的执行。现在可能并不令人印象深刻,但当我们讨论检查器客户端时,这种方法的有用性将是显而易见的。

当你处于控制台调试器时,你可以输入 help 来获取命令列表。你最常使用的命令是 n(下一步)、s(步进)和 o(步出)。n 将“跳过”当前行:它会执行它,但如果该指令调用其他函数,则这些函数将在控制返回给你之前执行。相比之下,s进入当前行:如果该行调用其他函数,你将能够逐步执行它们。o 允许你从当前执行的函数中跳出。(请注意,“步入”和“步出”仅适用于函数;它们不会进入或退出iffor块或其他流程控制语句。)

命令行调试器具有更多功能,但你可能不经常使用它。命令行非常适合许多事情,但调试不是它的长项。当需要时可以快速使用(例如,如果你只能通过 SSH 访问服务器,或者你的服务器甚至没有安装 GUI)。更常见的情况是,你会想要使用图形检视器客户端。

节点检视器客户端

虽然你可能不想在一般情况下使用命令行调试器,但 Node 通过 Web 服务公开其调试控件,给你提供了其他选项。

最直接的调试器是使用 Chrome,它使用与调试前端代码相同的调试接口。因此,如果你以前使用过该接口,你应该会很快上手。入门很容易。使用--inspect选项启动你的应用程序(这与前面提到的inspect参数是不同的):

node --inspect meadowlark.js

现在开始有趣的部分:在你的浏览器的 URL 栏中输入 *chrome://inspect*。你会看到一个开发者工具页面,在设备部分点击“为 Node 打开专用的 DevTools”。这将打开一个新窗口,即你的调试器:

启动 Chrome 调试器

点击“Sources”选项卡,然后在最左边的窗格中点击 Node.js 以展开它,接着点击“file://”。你会看到你的应用所在的文件夹;展开它,你将看到所有的 JavaScript 源代码(如果你在其他地方引入了,你只会看到 JavaScript 和有时 JSON 文件)。从这里,你可以点击任何文件以查看其源代码,并设置断点:

使用 Chrome 调试器

与我们之前在命令行调试器的经验不同,你的应用程序已经在运行:所有中间件都已链接,并且应用程序正在监听。那么我们如何逐步执行我们的代码?最简单的方法(也是你可能经常使用的方法)是设置一个断点。这只是告诉调试器在特定行上停止执行,以便你可以逐步执行代码。

设置断点的方法很简单,只需在调试器中打开“file://”浏览器中的源文件,然后点击左侧列中的行号;一个小蓝色箭头将出现,表示该行上设置了断点(再次点击可取消)。尝试在其中一个路由处理程序内设置一个断点。然后,在另一个浏览器窗口中,访问该路由。如果您使用的是 Chrome,浏览器将自动切换到调试器窗口,而原始浏览器则会停滞不前(因为服务器已暂停且未响应请求)。

在调试器窗口中,您可以以比命令行调试器更加直观的方式逐步执行程序。您会看到设置断点的那一行会被标记为蓝色。这表示当前执行的行(实际上是将要执行的下一行)。从这里,您可以像在命令行调试器中一样使用相同的命令。类似于命令行调试器,我们可以执行以下操作:

恢复脚本执行(F8)

这将简单地“让它飞翔”;除非在另一个断点停止,否则不会再逐步执行代码。通常在您已经看到所需内容或想要跳转到另一个断点时使用此操作。

跳过下一个函数调用(F10)

如果当前行调用了一个函数,调试器将不会深入到该函数中。也就是说,函数会被执行,调试器会在函数调用后继续执行下一行。当您对一个函数调用不感兴趣的详细信息时使用此操作。

进入下一个函数调用(F11)

这将深入到函数调用中,毫不隐藏地展示所有内容给您。如果这是您唯一使用的操作,最终您将看到所有执行的内容——起初听起来很有趣,但一个小时后,您会对 Node 和 Express 为您做了什么有了新的尊重!

退出当前函数(Shift-F11)

将执行您当前所在的函数的其余部分,并在调用此函数的调用方的下一行恢复调试。通常情况下,当您意外地进入一个函数或者已经看到函数的所需内容时,您会使用此操作。

除了所有的控制操作,您还可以访问一个控制台:该控制台在您应用程序的当前上下文中执行。因此,您可以检查变量甚至更改它们,或调用函数。这对于尝试一些非常简单的事情非常方便,但很容易混淆,所以我不建议您过多地以这种方式动态修改正在运行的应用程序;这样很容易迷失方向。

右侧是一些有用的数据。从顶部开始是监视表达式;这些是你可以定义的 JavaScript 表达式,在你逐步执行应用程序时会实时更新。例如,如果有特定的变量你想要跟踪,你可以在这里输入它。

在监视表达式下方是调用堆栈;它展示了你是如何到达当前位置的。也就是说,你所在的函数是被某个函数调用的,而那个函数是被某个函数调用的;调用堆栈列出了所有这些函数。在 Node.js 高度异步的世界中,调用堆栈可能非常难以解开和理解,特别是当涉及匿名函数时。列表中最顶部的条目就是你现在所在的地方。其下的条目是调用当前函数的函数,依此类推。如果你点击列表中的任何条目,你将神奇地跳转到那个上下文中:所有你的监视和控制台上下文现在都在那个上下文中。

在调用堆栈下方是作用域变量。顾名思义,这些变量是当前作用域内的(包括父作用域中对我们可见的变量)。这一部分通常能够一览你感兴趣的关键变量的大量信息。如果你有很多变量,这个列表会变得难以管理,你可能最好只定义你感兴趣的变量作为监视表达式。

接下来是所有断点的列表,这实际上只是一种簿记:如果你正在调试一个棘手的问题并且设置了很多断点,这很方便。点击其中一个将直接带你到那里(但它不会像点击调用堆栈中的条目那样改变上下文;这是有道理的,因为并不是每个断点都代表一个活动上下文,而调用堆栈中的每一项都是)。

有时,你需要调试的是你的应用程序设置(例如将中间件链接到 Express)。按照我们一直使用的调试器,这一切将在我们设置断点之前一眨眼间发生。幸运的是,这里有一个解决办法。我们只需指定--inspect-brk,而不是简单的--inspect

node --inspect-brk meadowlark.js

调试器会在你的应用程序的第一行断开,然后你可以根据需要逐步执行或设置断点。

Chrome 不是你唯一的检查客户端选择。特别是,如果你使用 Visual Studio Code,它内置的调试器效果非常好。不要使用--inspect--inspect-brk选项启动你的应用程序,而是点击 Visual Studio Code 侧边栏中的调试图标(一个有横线穿过的虫子)。在侧边栏顶部,你会看到一个小齿轮图标;点击它,会打开一些调试配置设置。你唯一需要关注的设置是“程序”;确保它指向你的入口点(例如meadowlark.js)。

提示

你可能还需要设置当前工作目录,或者"cwd"。例如,如果你在meadowlark.js所在的父目录中打开了 Visual Studio Code,你可能需要设置"cwd"(这与在运行node meadowlark.js之前必须cd到正确目录是一样的)。

一旦设置好,只需点击调试工具栏中的绿色播放箭头,你的调试器就开始运行了。界面与 Chrome 略有不同,但如果你使用的是 Visual Studio Code,你可能会觉得非常熟悉。更多信息,请参阅在 Visual Studio Code 中进行调试

调试异步函数

第一次接触异步编程时,人们最常遇到的挫折之一是调试。例如,考虑以下代码:

1 console.log('Baa, baa, black sheep,');
2 fs.readFile('yes_sir_yes_sir.txt', (err, data) => {
3	  console.log('Have you any wool?');
4	  console.log(data);
5 })
6 console.log('Three bags full.')

如果你对异步编程还不熟悉,你可能期望看到以下内容:

Baa, baa, black sheep,
Have you any wool?
Yes, sir, yes, sir,
Three bags full.

但你不会这样做;相反,你会看到这个:

Baa, baa, black sheep,
Three bags full.
Have you any wool?
Yes, sir, yes, sir,

如果你对此感到困惑,调试可能不会有帮助。你从第 1 行开始,然后跳过它,到达第 2 行。接着你步入函数,期望进入第 3 行,但实际上你却到了第 5 行!这是因为fs.readFile只有在读取文件完成后才执行函数,这将发生在你的应用程序处于空闲状态时。因此你跳过第 5 行,然后落到第 6 行……然后你继续尝试步进,但永远无法到达第 3 行(最终你会到达,但可能需要一段时间)。

如果你想调试第 3 或第 4 行,你只需在第 3 行设置断点,然后让调试器运行。当文件被读取并调用函数时,你将在那一行断点处停下,希望一切都会清楚。

调试 Express

如果像我一样,在职业生涯中见过很多过度工程化的框架,那么穿越框架源代码可能听起来像疯狂(或折磨)。探索 Express 源代码并非儿戏,但对于理解 JavaScript 和 Node 的人来说并不困难。有时,当你在代码中遇到问题时,通过穿越 Express 源代码本身(或第三方中间件)来调试这些问题可能是最好的解决方法。

本节将简要介绍 Express 源代码,以便您在调试 Express 应用程序时更加高效。对于这次的介绍,我将会给出文件名(相对于 Express 根目录,在你的 node_modules/express 目录中找得到)和函数名。我不使用行号,因为具体的 Express 版本不同,行号可能会有所不同:

Express 应用程序创建(lib/express.jsfunction createApplication

这是你的 Express 应用程序的起始点。这个函数在你的代码中调用const app = express()时被调用。

Express 应用程序初始化(lib/application.jsapp.defaultConfiguration

这是 Express 初始化的地方:这是查看 Express 默认设置的好地方。很少需要在这里设置断点,但至少需要逐步进行一次以了解默认的 Express 设置。

添加中间件(lib/application.jsapp.use

每次 Express 将中间件链接进来(无论您是否显式执行此操作,或者是由 Express 或任何第三方显式执行),都会调用此函数。这看起来非常简单,但要真正理解它需要一些努力。有时在这里设置断点是有用的(在运行应用程序时您需要使用 --debug-brk,否则所有中间件都会在您设置断点之前添加),但这可能会让人不知所措:您会惊讶地发现典型应用程序中链接的中间件有多少。

渲染视图(lib/application.jsapp.render

这是另一个非常重要的函数,但如果您需要调试与视图相关的复杂问题,则非常有用。如果您逐步执行此函数,您将看到如何选择和调用视图引擎。

请求扩展(lib/request.js

你可能会对这个文件如此简洁和易于理解感到惊讶。Express 添加到请求对象的大多数方法都是非常简单的便捷函数。很少需要逐行查看此代码或设置断点,因为代码的简单性使其很少需要。但是,查看此代码以理解某些 Express 便捷方法的工作原理通常是有帮助的。

发送响应(lib/response.jsres.send

实际上,无论您如何构造响应—.send.render.json.jsonp—最终都会到达此函数(例外是.sendFile)。因此,设置断点的地方应该是每个响应的调用点。然后您可以使用调用堆栈来查看如何到达此处,这有助于找出可能存在问题的地方。

响应扩展(lib/response.js

res.send 函数中有一些核心内容,但响应对象中的大多数其他方法都非常直观。偶尔在这些函数中设置断点可以准确查看应用程序对请求的响应方式。

静态中间件(node_modules/serve-static/index.jsfunction staticMiddleware

通常,如果静态文件未按您的预期提供服务,则问题可能出在路由设置上,而不是静态中间件上:路由优先于静态中间件。因此,如果您有一个文件 public/test.jpg,和一个路由 /test.jpg,静态中间件甚至不会被调用,以遵循路由。但是,如果您需要了解静态文件的头部如何设置不同,逐步执行静态中间件可能会有所帮助。

如果您正在思考中间件的位置,那是因为 Express 中的中间件非常少(静态中间件和路由器是明显的例外)。

当你试图解开一个困难问题时,深入了解 Express 源代码是很有帮助的,你可能需要查看你的中间件源代码。确实有太多内容需要处理,但有三个我想提一下,因为它们在理解 Express 应用程序中发生的事情方面非常基础:

会话中间件(node_modules/express-session/index.jsfunction session

很多工作需要让会话正常运行,但代码相当简单。如果与会话相关的问题困扰着你,可能需要在这个函数中设置断点。请记住,你需要自己提供会话中间件的存储引擎。

记录器中间件(node_modules/morgan/index.jsfunction logger

记录器中间件真的很适合作为调试辅助工具,而不是用来进行调试的。然而,日志记录工作的某些微妙之处,你只有在调试记录器中间件一两次后才能领会到。我第一次这样做时,有了很多“啊哈”时刻,并发现自己在应用程序中更有效地使用日志记录,因此我建议至少进行一次这个中间件的探索之旅。

URL 编码的请求体解析(node_modules/body-parser/lib/types/urlencoded.jsfunction urlencoded

请求体解析的方式通常让人感到神秘。它并不是真的很复杂,通过调试这个中间件将帮助你理解 HTTP 请求的工作方式。除了作为学习经验之外,你不会经常需要进入这个中间件进行调试。

结论

在本书中我们讨论了大量的中间件。我无法合理地列出你在探索 Express 内部时可能想查看的每一个重要点,但希望这些亮点能消除 Express 的一些神秘感,并鼓励你在需要时探索框架源代码。中间件不仅在质量上有很大差异,而且在可访问性上也有很大差异:有些中间件非常难以理解,而有些则像清澈的水池一样明了。无论如何,不要害怕去看:如果太复杂,你可以继续前进(除非你确实需要理解它),如果不是,你可能会学到一些东西。

第二十一章:上线

大日子终于到了:你花了数周或数月的心血,现在你的网站或服务已经准备好上线了。这并不像“翻开一个开关”那么简单,然后你的网站就上线了……或者说是吗?

在这一章节中(你真的应该在发布前数周而不是当天读这部分!),你将了解到一些域名注册和托管服务,以及从测试环境到生产环境的迁移技巧、部署技术以及选择生产服务时需要考虑的事项。

域名注册和托管

人们经常困惑于域名注册托管之间的区别。如果你在读这本书,你可能不会困惑,但我打赌你知道有些人是,比如你的客户或你的经理。

互联网上的每个网站和服务都可以通过一个互联网协议(IP)地址(或多个)进行识别。这些数字对人类来说并不友好(随着 IPv6 的推广,情况只会变得更糟),但是你的计算机最终需要这些数字来显示网页。这就是域名的作用。它们将一个人类友好的名称(如google.com)映射到一个 IP 地址(74.125.239.13 或 2601:1c2:1902:5b38:c256:27ff:fe70:47d1)。

一个现实世界的类比是企业名称和物理地址之间的区别。域名就像你的企业名称(苹果),IP 地址就像你的物理地址(One Apple Park Way, Cupertino, CA 95014)。如果你真的需要驾车去访问苹果总部,你就需要知道物理地址。幸运的是,如果你知道企业名称,你可能会找到物理地址。这种抽象有帮助的另一个原因是,一个组织可以搬迁(获取新的物理地址),人们仍然可以找到它,尽管它已经搬迁过(事实上,苹果在这本书的第一版和第二版之间确实搬过总部)。

另一方面,托管描述的是运行你的网站的计算机。继续使用物理类比,托管可以比作你到达物理地址后看到的建筑物。人们经常感到困惑的是,域名注册与托管几乎没有关系,你并不总是从同一实体那里购买域名和托管服务(就像通常情况下你从一个人那里购买土地,另一个人负责为你建造和维护建筑物)。

尽管可能有可能在没有域名的情况下托管你的网站,但这样做不友好:IP 地址并不具有市场化!通常情况下,当你购买托管服务时,会自动分配一个子域名(我们稍后会介绍),这可以被看作是介于一个市场友好的域名和一个 IP 地址之间的东西(例如,ec2-54-201-235-192.us-west-2.compute.amazonaws.com)。

一旦你拥有了一个域名,并且上线了,你可以通过多个 URL 访问你的网站。例如:

由于域名映射,所有这些地址都指向同一个网站。一旦请求到达你的网站,就可以根据使用的 URL 采取行动。例如,如果有人从 IP 地址访问你的网站,你可以自动重定向到域名,尽管这种情况并不常见(更常见的是从http://meadowlarktravel.com/重定向到http://www.meadowlarktravel.com/)。

大多数域名注册商提供主机服务(或与提供主机服务的公司合作)。除了 AWS,我从未觉得注册商的主机选项特别吸引人,将域名注册和主机服务分开是可以的。

域名系统(DNS)

域名系统(DNS)负责将域名映射到 IP 地址。这个系统相当复杂,但作为网站所有者,你应该了解一些关于 DNS 的事情。

安全性

你应该时刻牢记域名的价值。如果黑客完全控制了你的主机服务并接管了你的主机,但你仍然控制着域名,你可以获取新的主机并重定向域名。然而,如果你的域名被攻破,情况可能会很严重。你的声誉与域名紧密相连,好的域名是谨慎保护的。那些失去域名控制权的人发现情况可能会非常严重,世界上有些人会积极尝试攻破你的域名(特别是特别短或容易记忆的域名),以便出售它、破坏你的声誉或敲诈你。总之,你应该非常认真对待域名安全性,甚至可能比对待数据安全性更为重要(这取决于你的数据价值)。我见过有人在购买主机安全性方面投入大量时间和金钱,却在域名注册上选择最便宜、最靠不住的服务商。不要犯这样的错误。(幸运的是,高质量的域名注册并不特别昂贵。)

考虑到保护你的域名所有权的重要性,你应该在域名注册方面采取良好的安全实践。至少,你应该使用强大的、唯一的密码,并采用适当的密码卫生(不要将密码写在挂在显示器上的便条上)。最好是使用支持双因素认证的注册商。不要害怕向你的注册商提出直接的问题,询问如何授权对账户进行更改。我推荐的注册商包括 AWS Route 53、Name.com 和 Namecheap.com。这三家都提供双因素认证,我发现它们的支持服务很好,在线控制面板也易于使用且功能强大。

当你注册一个域名时,你必须提供一个与该域名相关联的第三方电子邮件地址(即,如果你注册了 meadowlarktravel.com,你不应该使用 admin@meadowlarktravel.com 作为你的注册邮箱)。由于任何安全系统的强度取决于它最薄弱的环节,你应该使用一个安全性好的电子邮件地址。使用 Gmail 或 Outlook 账户是相当普遍的,如果你使用这些账户,你应该采用与你的域名注册商账户相同的安全标准(良好的密码卫生和双因素认证)。

顶级域名

你的域名以什么结尾(比如 .com.net)被称为顶级域名(TLD)。一般来说,有两种类型的 TLD:国家代码 TLD 和通用 TLD。国家代码 TLD(例如 .us.es.uk)旨在提供地理分类。然而,几乎没有限制可以获取这些 TLD 的人(毕竟互联网是一个真正的全球网络),因此它们通常被用于“聪明”的域名,比如 placehold.itgoo.gl

通用 TLD(gTLDs)包括熟悉的 .com.net.gov.fed.mil.edu。虽然任何人都可以获取可用的 .com.net 域名,但对其他提到的域名有一些限制。欲了解更多信息,请参见表 21-1。

表 21-1. 受限制的 gTLDs

TLD 更多信息
.gov, .fed https://www.dotgov.gov
.edu https://net.educause.edu/
.mil 军事人员和承包商应联系他们的 IT 部门,或者访问国防部统一注册系统

互联网名称与数字地址分配机构(ICANN)最终负责管理顶级域名,尽管它将大部分实际管理工作委托给其他组织。最近,ICANN 授权了许多新的通用顶级域名(gTLD),如 .agency, .florist, .recipes,甚至 .ninja。在可预见的未来,.com 可能仍然是“优质”的顶级域名,也是最难获取的。在互联网发展初期,购买 .com 域名的人们因为拥有优质域名而获得了巨额回报(例如,Facebook 在 2010 年以惊人的 850 万美元购买了 fb.com 域名)。

考虑到 .com 域名的稀缺性,人们开始转向使用替代的顶级域名,或者使用 .com.us 尝试获取更符合他们组织特征的域名。在选择域名时,应考虑其将如何使用。如果您计划主要通过电子市场营销(人们更可能点击链接而不是输入域名),则应更注重获取一个引人注目或有意义的域名而不是短域名。如果您侧重于印刷广告,或者有理由相信人们会手动输入您的 URL 到他们的设备中,那么您可能应该考虑使用替代的顶级域名,以获取一个更短的域名。同时,拥有两个域名也是常见的做法:一个短而易于输入的,一个更适合市场营销的长域名。

子域名

顶级域名(TLD)位于域名之后,而子域名位于之前。迄今为止,最常见的子域名是 www。我从来没有特别喜欢这个子域名。毕竟,你是在使用互联网,我相信你不会因为没有 www 而感到困惑。因此,我建议在主域名中不使用子域名:http://meadowlarktravel.com/ 而不是 http://www.meadowlarktravel.com/。这样更短更简洁,而且通过重定向,不会因为没有 www 而失去访问量。

子域名也被用于其他用途。我经常看到像 blogs.meadowlarktravel.com, api.meadowlarktravel.com, 和 m.meadowlarktravel.com(用于移动站点)这样的东西。通常这是出于技术原因:如果你的博客使用完全不同的服务器,使用子域名可能更容易。然而,一个良好的代理服务器可以根据子域名或路径适当地重定向流量,所以使用子域名还是路径应该更多地关注内容而不是技术(记住 Tim Berners-Lee 关于 URL 表达信息架构而不是技术架构的说法)。

我建议使用子域名来分隔网站或服务中显著不同的部分。例如,我认为将您的 API 放在 api.meadowlarktravel.com 子域名下是一个很好的用法。微网站(与站点其他部分外观不同的网站,通常突出单个产品或主题)也是子域名的良好候选。另一个合理使用子域名的方法是将管理界面与公共界面分离(admin.meadowlarktravel.com,仅供员工使用)。

除非另有指定,否则您的域名注册商将重定向所有流量到您的服务器,而不管子域名如何。接下来是由您的服务器(或代理)根据子域名采取适当的操作。

名字服务器

使域名正常工作的“胶水”是名字服务器,这是您在为网站建立托管时需要提供的内容。通常情况下,这相对简单,因为您的托管服务会为您完成大部分工作。例如,假设我们选择在 DigitalOcean 上托管 meadowlarktravel.com。当您在 DigitalOcean 设置您的托管账户时,将会提供 DigitalOcean 名字服务器的名称(为了冗余通常有多个)。DigitalOcean,像大多数托管提供商一样,将它们的名字服务器称为 ns1.digitalocean.comns1.digitalocean.com 等等。前往您的域名注册商并为您想要托管的域名设置名字服务器,然后您就可以开始使用了。

在这种情况下,映射的工作方式如下:

  1. 网站访客导航到 http://meadowlarktravel.com/

  2. 浏览器将请求发送到计算机的网络系统。

  3. 计算机的网络系统已经通过互联网提供商分配了互联网 IP 地址和 DNS 服务器,请求 DNS 解析器解析 meadowlarktravel.com

  4. DNS 解析器知道 meadowlarktravel.comns1.digitalocean.com 处理,因此请求 ns1.digitalocean.commeadowlarktravel.com 提供 IP 地址。

  5. 服务器 ns1.digitalocean.com 接收请求并确认 meadowlarktravel.com 是一个活跃的账户,然后返回关联的 IP 地址。

尽管这是最常见的情况,但并不是唯一配置域映射的方式。因为实际提供网站服务的服务器(或代理)有一个 IP 地址,我们可以通过将该 IP 地址注册到 DNS 解析器中来省略名字服务器 ns1.digitalocean.com 这一中间步骤(在前述示例中有效)。要使此方法生效,您的托管服务必须分配给您一个 静态 IP 地址。通常,托管提供商会为您的服务器分配一个 动态 IP 地址,这意味着它可能会在没有通知的情况下更改,这将使这种方案失效。有时,获取静态 IP 地址可能需要额外费用,而不是动态 IP 地址:请向您的托管提供商查询。

如果你想直接将你的域名映射到你的网站(跳过主机的名称服务器),你将添加 A 记录或 CNAME 记录。A 记录直接将域名映射到 IP 地址,而CNAME将一个域名映射到另一个域名。CNAME 记录通常稍微不太灵活,因此通常更喜欢 A 记录。

提示

如果你正在使用 AWS 作为你的名称服务器,除了 A 记录和 CNAME 记录之外,它还有一种称为别名的记录,如果你指向 AWS 上托管的服务,它提供了很多优势。更多信息,请参阅AWS 文档

无论你使用什么技术,域名映射通常会被强烈缓存,这意味着当你更改域名记录时,可能需要长达 48 小时才能将域名绑定到新服务器上。请记住,这也与地理位置有关:如果你在洛杉矶看到你的域名工作正常,你在纽约的客户可能会看到域名绑定到之前的服务器上。根据我的经验,通常情况下 24 小时足以使域名在美国大陆正确解析,而国际解析可能需要长达 48 小时。

如果你需要在特定时间精确上线某些内容,不应该依赖 DNS 更改。相反,修改你的服务器以重定向到“即将推出”的站点或页面,并提前进行 DNS 更改,以便在实际切换之前完成。在约定的时刻,然后你可以让服务器切换到线上站点,你的访客无论身处何地都会立即看到变化。

托管

选择一个托管服务可能一开始看起来很困难。Node 已经大行其道,每个人都在竞相提供 Node 托管以满足需求。如何选择托管提供商非常依赖于你的需求。如果你有理由相信你的网站将成为下一个亚马逊或 Twitter,那么你将有完全不同的考虑,而如果你为本地集邮俱乐部建立网站,你将有完全不同的考虑。

传统托管还是云托管?

“云”这个术语是近年来出现的最模糊的技术术语之一。实际上,它只是“互联网”的一种花哨说法,或者说“互联网”的一部分。然而,这个术语并非完全无用。虽然不是术语的技术定义的一部分,但在云中托管通常意味着对计算资源进行某种商品化。也就是说,我们不再把“服务器”看作一个独立的物理实体:它只是云中的某个同质资源,而一个服务器和另一个服务器是一样好的。当然,我这么说有点简化了:计算资源根据它们的内存、CPU 数量等进行区分(和定价)。区别在于知道(和关心)你的应用实际托管在哪台服务器上,以及知道它托管在云中的某个服务器上,并且可以轻松地迁移到另一个服务器上,而你可能不知道(或不关心)。

云托管也高度虚拟化。也就是说,你的应用程序运行的服务器通常不是物理机器,而是运行在物理服务器上的虚拟机。这个概念并不是云托管引入的,但它已经与云托管成为同义词。

虽然云托管起源卑微,但现在意味着不仅仅是“同质化服务器”。主要的云服务提供商提供许多基础设施服务,(理论上)可以减少你的维护负担,并提供高度可伸缩性。这些服务包括数据库存储、文件存储、网络队列、认证、视频处理、电信服务、人工智能引擎等等。

云托管刚开始可能会让人有些不安,因为你并不了解你的服务器实际上是在哪台物理机上运行的,你只能相信你的服务器不会受到同一台计算机上运行的其他服务器的影响。不过,实际上并没有什么变化:当你的托管账单到来时,你仍然在支付基本相同的费用:有人负责处理物理硬件和网络,以便使你的网络应用程序得以运行。改变的只是你与硬件的距离更远了。

我相信“传统”托管(用缺乏更好术语来说)最终会完全消失。这并不是说托管公司会破产(虽然有些公司难免会),他们只是开始自行提供云托管服务。

任何即服务(XaaS)

在考虑云托管时,你会遇到缩写诸如 SaaS、PaaS、IaaS 和 FaaS:

软件即服务(SaaS)

软件即服务(SaaS)通常描述提供给你的软件(网站、应用程序):你只需使用它们。例如 Google Documents 或 Dropbox。

平台即服务(PaaS)

平台即服务(PaaS)为你提供所有基础设施(操作系统、网络等全部处理)。你只需编写你的应用程序。尽管 PaaS 和 IaaS 之间常常模糊不清(作为开发者,你会发现自己常常游走在这条界线上),但一般来说,本书讨论的是这种服务模型。如果你在运行网站或网络服务,PaaS 可能正是你所需要的。

基础设施即服务(IaaS)

IaaS 提供了最大的灵活性,但也伴随着成本。你只能获得虚拟机和基本的网络连接。然后,你需要负责安装和维护操作系统、数据库和网络策略。除非你需要对环境有这种程度的控制,否则通常会选择 PaaS。(注意,PaaS 允许你选择操作系统和网络配置,你只是不必亲自操作。)

函数即服务(FaaS)

FaaS 描述了 AWS Lambda、Google Functions 和 Azure Functions 等服务,它们提供了在云中运行单个函数的方式,而无需自行配置运行时环境。这正是通常被称为“无服务器”架构的核心。

巨头

主导互联网运行(或至少在互联网运行中投入了大量资源)的公司已经意识到,随着计算资源的商品化,它们有另一个可行的产品要销售。亚马逊、微软和谷歌都提供云计算服务,它们的服务非常优秀。

所有这些服务的定价方式大致相同:如果您的托管需求不多,三者之间的价格差异将很小。如果您具有非常高的带宽或存储需求,您将需要更仔细地评估这些服务,因为根据您的需求,成本差异可能会更大。

当我们考虑开源平台时,微软通常不会立即浮现在脑海中,但我不会忽视 Azure。这个平台不仅成熟而稳健,而且微软已经全力以赴地使其对 Node 和开源社区友好。微软提供一个月的 Azure 试用期,这是评估服务是否符合您需求的好方法;如果您正在考虑这“三巨头”之一,我绝对推荐免费试用 Azure。微软为其所有主要服务提供 Node API,包括其云存储服务。除了出色的 Node 主机托管外,Azure 还提供了出色的云存储系统(具有 JavaScript API),以及对 MongoDB 的良好支持。

亚马逊提供了最全面的资源集合,包括短信(文本消息)、云存储、电子邮件服务、支付服务(电子商务)、DNS 等等。此外,亚马逊还提供免费使用层,非常便于评估。

谷歌的云平台已经发展了很长一段路程,现在提供强大的 Node 主机托管服务,并且如您所料,与其自身的服务(尤其是映射、认证和搜索)完美集成。

除了这“三巨头”,还值得考虑 Heroku,它长期以来一直为想要托管快速灵活 Node 应用程序的人提供服务。我在 DigitalOcean 也有很好的运气,后者更专注于以非常用户友好的方式提供容器和有限数量的服务。

精品托管

较小的托管服务,我将其称为“精品”托管服务(只是没有更好的词),可能没有微软、亚马逊或谷歌的基础设施或资源,但这并不意味着它们不提供有价值的东西。

因为精品托管服务在基础设施方面无法与大公司竞争,它们通常专注于客户服务和支持。如果你需要大量支持,你可能需要考虑精品托管服务。如果你对你现有的托管提供商感到满意,不要犹豫询问它是否提供(或计划提供)Node 托管。

部署

令我惊讶的是,即使在 2019 年,仍然有人在使用 FTP 部署他们的应用程序。如果你是,请停止。FTP 绝对不安全。你的所有文件不仅以未加密的方式传输,而且你的用户名和密码也是如此。如果你的托管提供商没有给你选择的余地,请找一个新的托管提供商。如果你真的别无选择,请确保使用一个你在其他地方没有使用过的唯一密码。

至少,你应该使用 SFTP 或 FTPS(不要混淆),但你确实应该考虑使用持续交付(CD)服务。

CD 的理念是,你离可发布版本(可能是几周甚至几天)不会很远。CD 通常与持续集成(CI)一起使用,后者指的是开发者工作自动集成和测试的流程。

总的来说,你能够自动化你的流程越多,你的开发工作就会越容易。想象一下合并变更,并自动收到单元测试通过的通知,然后是集成测试通过的通知,最后看到你的变更在线上的过程……只需几分钟!这是一个伟大的目标,但你必须投入一些前期工作来设置它,并且随着时间的推移会有一些维护工作。

尽管步骤本身相似(运行单元测试、运行集成测试、部署到预发布服务器、部署到生产服务器),但设置 CI/CD 流水线(在讨论 CI/CD 时你会经常听到的词汇)的过程差异很大。

你应该查看一些可用的 CI/CD 选项,并选择符合你需求的。

AWS CodePipeline

如果你使用 AWS 托管,CodePipeline 应该是你首选,因为它是你实现 CI/CD 的最简单路径。它非常强大,但我发现它不太用户友好,比一些其他选项稍显复杂。

Microsoft Azure Web Apps

如果你使用 Azure 托管,Web Apps 是你最好的选择(你注意到这里有趋势吗?)。我对这项服务的经验不多,但在社区中似乎备受喜爱。

Travis CI

Travis CI 已经存在很长时间了,拥有庞大且忠诚的用户群和良好的文档。

Semaphore

Semaphore 易于设置和配置,但它并没有提供太多功能,其基本(低成本)计划速度较慢。

Google Cloud Build

我还没有尝试过 Google Cloud Build,但它看起来非常强大,就像 CodePipeline 和 Azure Web Apps 一样,如果你使用 Google Cloud 托管,这很可能是最佳选择。

CircleCI

CircleCI 是另一个有一段时间的 CI,而且深受喜爱。

Jenkins

Jenkins 是另一个有着庞大社区的老牌工具。根据我的经验,它在跟上现代部署实践方面没有一些其他选项做得好,但它刚刚发布了一个看起来很有前景的新版本。

在一天结束时,CI/CD 服务自动化了创建的活动。你仍然需要编写代码,确定你的版本控制方案,编写高质量的单元测试和集成测试,并且了解你的部署基础设施。这本书中的示例可以简单地自动化:几乎所有内容都可以部署到运行 Node 实例的单个服务器上。然而,随着基础设施的扩展,你的 CI/CD 流水线复杂度也会增加。

Git 在部署中的角色

Git 的最大优势(也是最大弱点)是其灵活性。它可以适应几乎任何想象得到的工作流程。为了部署,我建议创建一个或多个专门用于部署的分支。例如,你可以有一个production分支和一个staging分支。如何使用这些分支在很大程度上取决于你的个人工作流程。

一个流行的方法是从master分支流向staging,再到production。因此,一旦master分支上的某些变更准备上线,你可以将它们合并到staging分支。一旦在 staging 服务器上获得批准,你可以将staging合并到production分支。虽然这在逻辑上是合理的,但我不喜欢它造成的混乱(到处都是合并)。此外,如果有很多需要按不同顺序进行分阶段和推送到生产环境的功能,这很快会变得混乱。

我觉得更好的方法是将master合并到staging,当你准备好发布更改时,再将master合并到production。这样一来,stagingproduction就不会关联那么紧密:你甚至可以有多个用于测试不同功能的 staging 分支(你可以将不同于master的内容合并到它们)。只有在某些内容被批准用于生产环境后,才将其合并到production分支。

当需要回滚更改时会发生什么?这就是事情变得复杂的地方。有多种技术可以撤销更改,例如应用提交的反向来撤销先前的提交(git revert),这些技术不仅复杂,而且可能会引发后续问题。处理这些情况的典型方式是在每次部署时在你的production分支上创建标签(例如,git tag v1.2.0)。如果需要回滚到特定版本,你始终可以使用该标签。

最终,决定 Git 工作流的是你和你的团队。比你选择的工作流更重要的是你使用它的一致性,以及围绕它的培训和沟通。

Tip

我们已经讨论了将二进制资产(多媒体和文档)与代码存储库分开保持的价值。基于 Git 的部署为此方法提供了另一个激励。如果您的存储库中有 4 GB 的多媒体数据,它们将需要很长时间来克隆,并且您不必为每个生产服务器都保存全部数据的副本。

手动基于 Git 的部署

如果您还没有准备好设置 CI/CD,可以从手动基于 Git 的部署开始。这种方法的优点在于您将熟悉部署所涉及的步骤和挑战,这将为您在迈出自动化的步骤时提供帮助。

对于每台您想部署到的服务器,您都需要克隆存储库,检出production分支,然后设置启动/重新启动应用所需的基础设施(这将取决于您选择的平台)。当您更新production分支时,您需要进入每台服务器,运行git pull --ff-only,运行npm install --production,然后重新启动应用程序。如果您的部署不频繁,且服务器数量不多,这可能不会带来太大的困扰,但如果您经常更新,这将很快变得单调,您会想要找到某种方式来自动化系统。

提示

git pull--ff-only参数允许仅快进式拉取,防止自动合并或重新基础。如果您知道拉取仅为快进式,可以安全地省略它,但如果您习惯于这样做,您将永远不会意外地调用合并或重新基础!

从本质上讲,您在这里所做的是在远程服务器上复制您在开发中的工作方式。手动过程总会存在人为错误的风险,我建议这种方法只是向更自动化的开发迈出的一小步。

结论

部署您的网站(尤其是首次)应该是一个令人兴奋的时刻。应该有香槟和欢呼声,但往往情况是出汗、诅咒和彻夜未眠。我见过太多次由疲惫不堪的团队在凌晨三点发布网站。幸运的是,这种情况正在改变,部分归功于云部署。

无论您选择什么部署策略,您能做的最重要的事情是尽早开始生产部署,而不必等网站准备好上线。您不必连接域名,因此公众无需知道。如果在正式发布之前您已将网站部署到生产服务器半打次以上,成功发布的机会将大大提高。理想情况下,您的功能性网站在发布之前很久就已经在生产服务器上运行:您只需从旧网站切换到新网站。

第二十二章:维护

你发布了网站!恭喜恭喜,现在你永远不用再去考虑它了。什么?你还是得继续考虑它?好吧,在这种情况下,请继续阅读。

尽管在我的职业生涯中这种情况已经发生了几次,但完成一个网站然后永远不再碰它的情况并不常见(当它确实发生时,通常是因为有其他人在做这项工作,而不是因为工作不需要做)。我清楚地记得一个网站发布的“事后分析”。我插嘴说:“我们真的应该称它为产后吧?”^(1) 发布网站确实更像是一种诞生而非死亡。一旦它发布了,你就会紧盯着分析数据,焦急地等待客户的反应,半夜三点醒来检查网站是否还在运行。这是你的宝贝。

规划一个网站、设计一个网站、构建一个网站:这些都是可以计划到死的活动。但通常被忽视的是规划网站维护。本章将为你提供一些关于如何航行在这些波涛汹涌的建议。

维护原则

拥有长期计划

当客户同意建立一个网站的价格时,我总是感到惊讶,但从未讨论过网站预计的寿命有多长。我的经验是,如果你做得好,客户愿意为此付费。客户欣赏的是意外情况:例如在三年后告知他们他们的网站必须重建,而他们默默地期望它能维持五年。

互联网发展迅速。如果你用你能找到的最好和最新的技术构建了一个网站,也许两年后它会感觉像是一个老旧的遗物。或者它可以坚持七年,虽然老化,但做得很优雅(这种情况不太常见!)。

关于网站寿命的预期设定,这部分是艺术、销售技巧和科学的结合体。其中的科学部分涉及到一些所有科学家都做但很少有网页开发者做的事情:保持记录。想象一下,如果你有你的团队曾经发布的每一个网站的记录,维护请求和故障的历史记录,使用的技术以及每个网站需要重建前的使用时长。显然存在许多变量,从涉及的团队成员,到经济情况,到技术的变动,但这并不意味着数据中不能发现有意义的趋势。你可能会发现某些开发方法对你的团队效果更好,或者某些平台或技术。我几乎可以保证你会发现“拖延”和缺陷之间存在着相关性:你越长时间推迟导致痛苦的基础设施更新或平台升级,问题就会越严重。拥有良好的问题跟踪系统并保持详细的记录将使你能够向客户提供更好(也更现实)的项目生命周期图景。

它的推销点归结为当然是钱的问题。如果客户能够负担得起每三年完全重建他们的网站,那么他们不太可能因为老化基础设施而遭受损失(尽管他们会有其他问题)。另一方面,有些客户希望他们的预算能够延伸尽可能远,希望网站能够持续五年甚至七年之久。(我知道有些网站甚至拖延了更长时间,但我认为七年是有希望继续有用的网站的最大现实寿命。)你有责任对待这两类客户,它们各自带来了不同的挑战。对于那些有很多钱的客户,不要仅仅因为他们有钱而接受他们的钱:要利用这些额外的钱为他们提供一些非凡的东西。对于预算紧张的客户,你将不得不找到创造性的方法,在技术不断变化的情况下设计他们的网站,使其更具长期性。这两个极端都有各自的挑战,但都可以解决。然而重要的是,你要知道客户的期望是什么。

最后,还有这个事情的艺术性。这就是把所有事情联系在一起的东西:了解客户能承受多少,以及在哪些地方你可以诚实地说服客户多花点钱,以便他们在需要的地方得到价值。这也是理解技术未来的艺术,能够预测哪些技术在五年内将会彻底过时,哪些将继续强大。

当然,没有办法绝对确定任何事情。你可能对技术下错注,人员变动可能完全改变你组织的技术文化,技术供应商可能会倒闭(虽然在开源世界中这通常不是问题)。你认为会在产品寿命内稳定的技术可能会被证明是一种时尚,你可能会发现自己面临比预期更早重建的决定。另一方面,有时候确切的团队在确切的时间以及确切的技术下来到一起,创造出远超过任何合理期望的东西。然而,所有这些不确定性都不应阻止你制定计划:有一个走样的计划总比一直没有方向好。

现在对你来说应该是清楚的,我认为 JavaScript 和 Node 是将会持续存在一段时间的技术。Node 社区充满活力和热情,明智地基于一种显然已经胜利的语言。也许最重要的是,JavaScript 是一种多范式语言:面向对象的,函数式的,过程式的,同步的,异步的——它应有尽有。这使得 JavaScript 成为一个吸引来自不同背景开发者的平台,并且在很大程度上推动了 JavaScript 生态系统的创新步伐。

使用源代码控制

这对你可能显而易见,但不仅仅是使用源代码控制,而是要好好使用它。你为什么使用源代码控制?理解原因,并确保工具支持这些原因。使用源代码控制有许多原因,但我认为最大的回报始终是归因:知道究竟是什么改变了什么时间,以及谁做了这些改变,这样我就可以在必要时询问更多信息。版本控制是我们了解项目历史和团队协作方式的最重要工具之一。

使用问题追踪器

问题追踪器回到了开发的科学。没有系统记录项目历史的方法,就不可能得到洞察。你可能听说过“疯狂的定义是‘一遍又一遍地做同样的事情,但期待不同的结果’”(通常是被错误地归因于阿尔伯特·爱因斯坦)。如果你不知道自己在犯什么错误,如何避免重复这些错误看起来是疯狂的呢?

记录一切:客户报告的每一个缺陷;在客户看到之前你发现的每一个缺陷;每一个投诉,每一个问题,每一点赞。记录花费了多少时间,谁修复了它,涉及了哪些 Git 提交,以及谁批准了修复。关键在于找到不会使这变得过于耗时或繁重的工具。一个糟糕的问题追踪系统会闲置不用,比没用还糟糕。一个好的问题追踪系统将为你的业务、团队和客户提供重要的洞察。

保持良好的卫生习惯

我不是在说刷牙——虽然你也应该这样做——我是在说版本控制、测试、代码审查和问题追踪。你使用的工具只有在正确使用时才是有用的。代码审查是鼓励卫生习惯的好方法,因为可以触及所有内容,从讨论请求来源于哪个问题追踪系统,到必须添加的用于验证修复的测试,再到版本控制提交注释。

你从问题追踪系统收集的数据应定期审查并与团队讨论。通过这些数据,你可以获得关于什么有效和什么无效的见解。你可能会对你所发现的内容感到惊讶。

不要拖延

机构性拖延可能是最难对抗的事情之一。通常情况下,看起来不那么糟糕:你注意到你的团队每周在一个本可以通过稍加重构大幅改善的周更新上浪费了大量时间。每一周你推迟重构,就是另一周你在支付效率成本。^(2) 更糟的是,有些成本可能会随着时间的推移而增加。

一个很好的例子是未能更新软件依赖关系。随着软件的老化和团队成员的变动,要找到记得(或者曾经理解过)这个老旧软件的人变得更加困难。支持社区开始消失,不久后,技术被淘汰,你将无法获得任何支持。人们经常将此描述为技术债务,这是一个非常真实的问题。虽然你应该避免拖延,但理解网站的长期存在性可以影响这些决策:如果你正准备重新设计整个网站,消除一直积累的技术债务就没有太大的价值。

进行例行的质量保证检查

对于你的每个网站,你应该有一个记录的例行质量保证检查。该检查应包括链接检查器、HTML 和 CSS 验证以及运行你的测试。关键在于记录:如果组成质量保证检查的项目没有记录,你必然会错过一些事情。每个网站的记录质量保证检查清单不仅有助于防止忽视检查,还允许新团队成员立即生效。理想情况下,质量保证检查清单可以由非技术团队成员执行。这将增强你(可能是)非技术经理对团队的信心,并且如果你没有专门的质量保证部门,将允许你分担质量保证的责任。根据你与客户的关系,你可能还想与客户分享你的质量保证检查清单(或其部分);这是提醒他们他们为何支付费用以及你在为他们的最佳利益着想的好方法。

作为例行的质量保证检查的一部分,我建议使用Google 网站管理员工具Bing 网站管理员工具。它们易于设置,并且可以让你非常重要地查看你的网站:主要搜索引擎如何看待它。它会提醒你关于你的robots.txt文件、干扰良好搜索结果的 HTML 问题、安全问题等等的任何问题。

监控分析数据

如果你的网站上没有运行分析,你现在就需要开始:它不仅提供了网站的流行度关键洞察,还告诉你用户如何使用它。Google Analytics(GA)非常出色(而且免费!),即使你用其他分析服务来补充,也没有理由不在你的网站上包含 GA。

通常情况下,通过关注分析数据,您可以发现一些微妙的用户体验问题。某些页面是否没有获得预期的流量?这可能表明导航、促销或 SEO 问题。跳出率是否很高?这可能意味着您的页面内容需要调整(人们通过搜索进入您的网站,但一到达后发现并非他们寻找的内容)。您应该有一个分析检查清单,与您的质量保证清单一起使用(它甚至可以成为质量保证清单的一部分)。该清单应该是一个“活文件”,因为在您的网站生命周期内,您或您的客户可能会对内容的重要性有所变化。

优化性能

研究表明,性能对网站流量有显著影响。在快节奏的世界中,人们期望内容快速交付,尤其是在移动平台上。性能调优的首要原则是先进行分析,然后进行优化。“分析”意味着找出实际拖慢网站速度的因素。如果您花费数天加快内容呈现速度,而问题实际上出在社交媒体插件上,那么您就浪费了宝贵的时间和金钱。

Google PageSpeed Insights 是衡量网站性能的好方法(现在 PageSpeed 数据也记录在 Google Analytics 中,所以您可以监控性能趋势)。它不仅会为移动端和桌面端性能给出综合评分,还会提出如何优化性能的优先建议。

如果您目前没有性能问题,可能不需要定期进行性能检查(监控 Google Analytics 是否有性能分数显著变化应该足够)。然而,当您提高性能时,观察流量的增加是令人满意的。

优先考虑潜在客户追踪

在互联网世界中,访客能够向您发出的最强信号,表明他们对您的产品或服务感兴趣的是联系信息。您应该非常谨慎地处理这些信息。任何收集电子邮件或电话号码的表单都应定期作为质量保证检查的一部分进行测试,并且在收集信息时应始终保持冗余。对潜在客户做的最糟糕的事情之一就是收集联系信息,然后丢失它。

由于潜在客户追踪对您的网站成功至关重要,我建议您遵循以下五个原则来收集信息:

在 JavaScript 失效时有备用方案

通过 Ajax 收集客户信息是可以的——这通常会带来更好的用户体验。但是,如果由于任何原因 JavaScript 失败(用户可能禁用它,或者您网站上的脚本可能出现错误,导致您的 Ajax 功能不正常),表单提交应仍然可以工作。测试这一点的一个好方法是禁用 JavaScript 并使用您的表单。如果用户体验不理想也没关系:关键是不要丢失用户数据。为了实现这一点,始终在您的 <form> 标签中有一个有效且可用的 action 参数,即使您通常使用 Ajax。

如果使用 Ajax,请从表单的 action 参数获取 URL。

虽然这不是严格必要的,但这有助于防止您在 <form> 标签上意外忘记 action 参数。如果将您的 Ajax 绑定到无 JavaScript 提交成功,那么丢失客户数据将更加困难。例如,您的表单标签可以是 <form action="/submit/email" method="POST">;然后在您的 Ajax 代码中,您会从 DOM 获取表单的 action,并在 Ajax 提交代码中使用它。

提供至少一级冗余。

您可能希望将潜在客户信息保存到数据库或外部服务,如 Campaign Monitor。但是,如果您的数据库出现故障,或者 Campaign Monitor 停机,或者存在网络问题,您仍然不希望丢失该潜在客户。提供冗余的常见方法是除了存储潜在客户信息外,还发送电子邮件。如果采用这种方法,您不应使用个人电子邮件地址,而应使用共享电子邮件地址(如dev@meadowlarktravel.com):如果将其发送给一个人并且该人离开组织,冗余就没有意义。您还可以将潜在客户信息存储在备份数据库或甚至 CSV 文件中。然而,每当主要存储失败时,应有一些机制来提醒您失败。收集冗余备份是战斗的前半段;意识到故障并采取适当措施是后半段。

在完全存储失败的情况下,通知用户。

假设您有三级冗余:您的主要存储是 Campaign Monitor,如果它失败了,您会备份到 CSV 文件并发送邮件至 dev@meadowlarktravel.com。如果所有这些渠道都失败了,用户应该收到类似于“很抱歉,我们遇到技术困难,请稍后重试,或联系support@meadowlarktravel.com”的消息。

检查肯定确认,而不是错误的缺失。

Ajax 处理程序通常会返回一个带有err属性的对象以表示失败;然后客户端代码会像这样:if(data.err){ /* 通知用户操作失败 */ } else { /* 感谢用户提交成功 */ }。避免这种方法。设置err属性没有问题,但如果在 Ajax 处理程序中出现错误,导致服务器响应 500 错误代码或者响应不是有效的 JSON,这种方法可能会静默失败。用户的信息将消失无踪,他们对此一无所知。相反,为成功的提交提供一个success属性(即使主要存储失败:如果用户的信息被某种方式记录,你可以返回success)。然后你的客户端代码变成if(data.success){ /* 感谢用户提交成功 */ } else { /* 通知用户操作失败 */ }

防止“隐形”故障

我经常看到这种情况:因为开发人员赶时间,他们记录错误的方式从未得到检查。无论是日志文件,数据库中的表格,客户端控制台日志,还是发往死信箱的电子邮件,最终结果都是一样的:你的网站存在质量问题,这些问题没有被注意到

你可以对抗这个问题的头号防御措施是提供一种简单标准的错误日志记录方法。文档化它。不要让它变得复杂。不要让它变得晦涩。确保每个接触到你项目的开发人员都知道它。可以简单地暴露一个meadowlarkLog函数(其他包经常使用log)。函数是否记录到数据库、平面文件、电子邮件或其组合并不重要:重要的是它是标准化的。它还允许你改进日志机制(例如,当你扩展服务器时,平面文件的用处变小,因此你会修改meadowlarkLog函数以改为记录到数据库)。一旦日志记录机制就位,文档化并且你的团队每个人都知晓,将“检查日志”添加到你的质量保证清单中,并提供如何执行的指导。

代码重用和重构

我经常看到的一个悲剧是反复发明轮子。通常这只是一些小事情:碎片化的内容感觉重写比挖掘几个月前的项目更容易。所有这些小片段的重写累积起来。更糟糕的是,这与良好的质量保证相悖:你可能不会费心为所有这些小片段编写测试(如果你这样做了,那么浪费时间就会加倍,因为你没有重用现有的代码)。每个相同的片段可能会有不同的错误。这是一个不好的习惯。

在 Node 和 Express 中进行开发提供了一些很好的方法来解决这个问题。Node 通过模块带来了命名空间,通过 npm 带来了包的概念,而 Express 则引入了中间件的概念。有了这些工具,开发可复用的代码变得更加容易。

私有 npm 注册表

npm 注册表是存储共享代码的好地方;毕竟,这正是 npm 设计的初衷。除了简单的存储外,你还得到了版本控制,以及在其他项目中包含这些包的便捷方法。

然而,这里有一个小问题:除非你在一个完全开放源码的组织中工作,否则你可能不想为所有可复用的代码创建 npm 包。(除了知识产权保护之外,还可能有其他原因:你的包可能是如此特定于组织或项目,以至于在公共注册表上发布它们没有意义。)

处理这个问题的一种方法是私有 npm 注册表。npm 现在提供了 Orgs 功能,允许你发布私有包,并给你的开发者提供付费登录,以便让他们访问这些私有包。有关 npm Orgs 和私有包的更多信息,请参阅 npm

中间件

如同本书中所见,编写中间件并不是什么复杂可怕的事情:在本书中我们已经做了很多次了,久而久之,你甚至可以不假思索地完成。因此,下一步就是将可复用的中间件放入一个包中,并将其发布到 npm 注册表中。

如果你发现你的中间件过于特定于某个项目而无法放入一个可复用的包中,那么你应该考虑重构中间件,以便能够为更一般的用途进行配置。记住,你可以传递配置对象到中间件中,使其在各种情况下都能派上用场。下面是在 Node 模块中公开中间件的最常见方法概述。以下所有内容都假设你正在将这些模块作为一个名为meadowlark-stuff的包使用。

模块直接公开中间件函数

如果你的中间件不需要配置对象,请使用这种方法:

module.exports = (req, res, next) => {
  // your middleware goes here...remember to call next()
  // or next('route') unless this middleware is expected
  // to be an endpoint
  next()
}

使用这个中间件的方法:

const stuff = require('meadowlark-stuff')

app.use(stuff)

模块公开一个返回中间件的函数

如果你的中间件需要配置对象或其他信息,请使用这种方法:

module.exports = config => {
  // it's common to create the config object
  // if it wasn't passed in:
  if(!config) config = {}

	return (req, res, next) => {
    // your middleware goes here...remember to call next()
    // or next('route') unless this middleware is expected
    // to be an endpoint
    next()
	}
}

使用这个中间件的方法:

const stuff = require('meadowlark-stuff')({ option: 'my choice' })

app.use(stuff)

模块公开一个包含中间件的对象

如果你希望公开多个相关的中间件,可以选择使用这个选项:

module.exports = config => {
  // it's common to create the config object
  // if it wasn't passed in:
  if(!config) config = {}

	return {
    m1: (req, res, next) => {
      // your middleware goes here...remember to call next()
      // or next('route') unless this middleware is expected
      // to be an endpoint
      next()
		},
    m2: (req, res, next) => {
      next()
    },
  }
}

使用这个中间件的方法:

const stuff = require('meadowlark-stuff')({ option: 'my choice' })

app.use(stuff.m1)
app.use(stuff.m2)

结论

在构建网站时,焦点通常放在上线上,这是有充分理由的:上线是非常令人兴奋的。然而,一个对新上线网站感到满意的客户,如果在维护网站时不注意,很快就会变成一个不满意的客户。以同样的细心对待维护计划,将会提供客户喜欢的体验,从而使客户不断回头。

^(1) 事实证明,“产后”这个术语有点过于直接。我们现在称之为“回顾”。

^(2) Fuel的麦克·威尔逊有一条经验法则:“当你第三次做某事时,花点时间自动化它。”

第二十三章:附加资源

在这本书中,我为您提供了使用 Express 构建网站的全面概述。我们已经涵盖了大量内容,但仍然只是初步接触了可用的包、技术和框架。在本章中,我们将讨论您可以获取额外资源的地方。

在线文档

对于 JavaScript、CSS 和 HTML 文档,Mozilla 开发者网络(MDN)是无与伦比的。如果我需要 JavaScript 文档,我要么直接在 MDN 上搜索,要么将“mdn”附加到我的搜索查询中。否则,无可避免地,w3schools 会出现在搜索结果中。无论谁在管理 w3schools 的 SEO,都是个天才,但我建议避免访问这个网站:我发现其文档通常严重缺失。

尽管 MDN 是一个很好的 HTML 参考,如果您对 HTML5 还不熟悉(甚至已经很熟悉),您应该阅读 Mark Pilgrim 的 Dive Into HTML5。WHATWG 维护着一份优秀的 “活标准” HTML5 规范;这通常是我首先查阅的地方,用于那些难以回答的 HTML 问题。最后,HTML 和 CSS 的官方规范位于 W3C 网站;这些文档枯燥、难以阅读,但有时它们是您解决最难问题的唯一途径。

JavaScript 遵循 ECMA-262 ECMAScript 语言规范。要追踪 Node(以及各种浏览器)中 JavaScript 特性的可用性,请参阅由 @kangax 维护的优秀 指南

Node 文档非常好,内容全面,应该是关于 Node 模块(如 httphttpsfs)的权威文档的首选。Express 文档也相当不错,但可能不如人们期望的那样全面。npm 文档全面且实用。

期刊

您绝对应该订阅并认真阅读的三本免费期刊:

这三本期刊会在最新的新闻、服务、博客和教程发布时通知您。

Stack Overflow

很可能你已经使用过 Stack Overflow (SO):自 2008 年成立以来,它已成为主导的在线问答网站,是你获取 JavaScript、Node 和 Express 等问题答案的最佳资源(以及本书涵盖的任何其他技术)。Stack Overflow 是一个由社区维护、基于声誉的问答网站。声誉模型是该网站质量和持续成功的关键。用户可以通过问题或回答被“赞同”或接受答案来获得声誉。你无需声誉即可提问,并且注册是免费的。然而,有些方法可以增加你的问题得到有用回答的机会,我们将在本节讨论。

声誉是 Stack Overflow 的货币,虽然有些人真的想帮助你,但是获得声誉的机会是促使良好答案的最佳动力。SO 上有很多聪明的人,他们竞争提供你问题的第一个和/或最佳正确答案(幸运的是,提供快速但错误答案的强烈防止动机)。以下是增加你问题得到良好回答机会的方法:

成为一个消息灵通的 SO 用户

查看SO 导览,然后阅读“如何提出一个好问题?” 如果你愿意,可以继续阅读所有的帮助文档——如果你全部阅读完,你将获得一个徽章!

不要问已有答案的问题

做好尽职调查,尝试找出是否已经有人提出过你的问题。如果你提出一个在 SO 上已有明显答案的问题,你的问题很快会被关闭为重复,并且人们经常会因此给你负评,这会对你的声誉产生负面影响。

不要求别人为你编写代码

如果你仅仅问,“我该如何做X?”那么你的问题很快会被负评和关闭。SO 社区希望你在求助 SO 之前努力解决自己的问题。描述在问题中你尝试过什么以及为什么没有奏效。

一次只问一个问题

提问五件事——“我该如何做这件事,然后是那个,然后是其他事情,以及做这件事的最佳方式是什么?”——是很难回答的,也是不被鼓励的。

创建一个简化的问题示例

我经常回答很多 Stack Overflow 的问题,但我几乎自动跳过那些代码超过三页(或更多!)的问题。简单地把你的 5000 行代码文件粘贴到 Stack Overflow 问题中并不是一个获得答案的好方法(但人们却经常这样做)。这是一种懒惰的方式,通常不会得到回报。不仅你更难获得有用的答案,而且淘汰那些导致问题的因素的过程本身可以帮助你解决问题(然后你甚至不需要在 Stack Overflow 上提问)。创建一个最小化的示例对你的调试技能和批判性思维能力都有好处,并使你成为一个好的 Stack Overflow 社区成员。

学习 Markdown

Stack Overflow 使用 Markdown 来格式化问题和答案。一个格式良好的问题有更好的被回答的机会,因此你应该投入时间学习这种有用且日益普及的标记语言

接受并赞同答案

如果有人满意地回答了你的问题,你应该给予赞同并接受答案;这会增加回答者的声誉,而声誉是推动 Stack Overflow 运作的关键。如果有多人提供了可接受的答案,你应该选择你认为最好的一个并接受它,并给其他提供有用答案的人点赞。

如果在别人之前解决了自己的问题,请回答自己的问题

Stack Overflow 是一个社区资源:如果你有一个问题,很可能其他人也有同样的问题。如果你解决了它,请为了他人的利益回答你自己的问题。

如果你喜欢帮助社区,请考虑自己回答问题:这既有趣又有回报,可能会带来比单纯的声誉分更具体的好处。如果你有一个问题,在两天内没有得到有用的答案,你可以为这个问题设置悬赏,使用你自己的声誉。声誉会立即从你的账户中扣除,且不可退还。如果有人满意地回答了你的问题,并且你接受了他们的答案,他们将获得这个悬赏。当然,前提是你必须有足够的声誉来设置悬赏:最低悬赏是 50 点声誉分。虽然你可以通过提问高质量的问题来获得声誉,但通常通过提供高质量的答案来获得声誉更快。

回答别人的问题还有助于学习。我一般觉得从回答别人的问题中学到的比得到自己问题的答案更多。如果你真的想彻底学会一门技术,先学习基础知识,然后开始尝试解决 Stack Overflow 上的问题。起初你可能会被那些已经是专家的人击败,但不久之后,你会发现你也是其中的一位专家。

最后,你不应该犹豫利用你的声誉来促进你的职业生涯。一个良好的声誉绝对值得写在简历上。这对我有用,现在我自己也在面试开发者时,总是对一个好的 Stack Overflow 声誉印象深刻(我认为“好”的 Stack Overflow 声誉超过 3,000;五位数字的声誉是很棒的)。一个好的 Stack Overflow 声誉告诉我,某人不仅在他们的领域内 kompetent,而且他们是清晰的沟通者,通常也是乐于助人的。

对 Express 的贡献

Express 和 Connect 是开源项目,因此任何人都可以提交拉取请求(GitHub 术语指你希望包含在项目中的更改)。这并不容易:这些项目的开发人员是专家,是对自己项目的最终权威。我并不是在阻止你做出贡献,但我是在说,你必须投入一些重要的努力才能成为一个成功的贡献者,你不能轻视提交。

贡献的实际过程已在Express 首页上有详细说明。这个过程包括在你自己的 GitHub 账户中分叉项目,克隆该分支,进行更改,将更改推送回 GitHub,并创建一个拉取请求(PR),该请求将由项目中的一个或多个人员进行审查。如果你的提交很小或者是修复 bug,你可能只需提交拉取请求就有可能成功。如果你试图做一些重要的事情,你应该与主要开发者之一沟通,讨论你的贡献。你不希望花费几个小时或几天在一个复杂的功能上,结果发现它与维护者的视觉不符,或者已经被其他人在处理。

间接贡献(另一种)到 Express 和 Connect 的开发是发布 npm 包,特别是中间件。发布你自己的中间件不需要任何人的批准,但这并不意味着你应该随意地用低质量的中间件堆积 npm 注册表。计划、测试、实施和文档化,你的中间件将会更成功。

如果你确实发布了自己的包,这里是你应该有的最低限度的事情:

包名

虽然包命名由你决定,但显然你必须选择一个尚未被占用的名称,这有时可能是一个挑战。现在 npm 包支持按账户命名空间,因此你并不是在全球范围内竞争名称。如果你正在编写中间件,习惯上可以用connect-express-作为包名的前缀。没有任何特定关系的引人入胜的包名是可以的,但更好的是一个包名能暗示它的功能(一个很好的例子是zombie,用于无头浏览器仿真的包名)。

包描述

您的包描述应该简短、简洁和描述性。当人们搜索包时,这是主要索引的字段之一,因此最好是描述性的,而不是聪明的(文档中还是可以有一些聪明和幽默的内容的,请放心)。

作者/贡献者

为自己争取一些功劳。继续。

许可证

这经常被忽视,没有许可证的包会让人非常沮丧(不确定是否可以在项目中使用)。不要成为那种人。如果您不希望代码受任何限制,MIT 许可证是一个简单的选择。如果您希望它保持开源(并继续保持开源),另一个流行的选择是 GPL 许可证。在项目的根目录中包括许可证文件是明智之举(它们应该以 LICENSE 开头)。为了最大限度的覆盖范围,可以同时使用 MIT 和 GPL 进行双重许可。关于这一点的示例在 package.jsonLICENSE 文件中,可以参考我的 connect-bundle

版本

为了使版本控制系统正常工作,您需要对包进行版本控制。请注意,npm 的版本控制与存储库中的提交编号是分开的:您可以随意更新存储库,但这不会改变用户使用 npm 安装您的包时获取的内容。您需要增加版本号并重新发布,才能在 npm 注册表中反映出变化。

依赖项

您应该努力在包中保守地处理依赖项。我并不建议不断重新发明轮子,但是依赖项会增加包的大小和许可证复杂性。至少,您应确保没有列出您不需要的依赖项。

关键词

除了描述之外,关键词是人们查找您的包时使用的另一个主要元数据,因此请选择适当的关键词。

存储库

您应该有一个。GitHub 是最常见的选择,但其他选择也可以接受。

README.md

GitHub 和 npm 的标准文档格式都是 Markdown。这是一种类似 wiki 的简单语法,您可以快速学习。如果您希望您的包被使用,优质的文档至关重要。如果我访问一个 npm 页面发现没有文档,我通常会直接跳过而不再深入调查。至少应该描述基本用法(包括示例)。更好的做法是将所有选项都文档化。描述如何运行测试更进一步。

当您准备发布自己的包时,这个过程非常简单。注册一个免费的 npm 账号,然后按照以下步骤操作:

  1. 输入 npm adduser,使用您的 npm 凭据登录。

  2. 输入 npm publish 来发布您的包。

就这样!您可能想要从头开始创建一个项目,并通过 npm install 测试您的包。

结论

我衷心希望这本书能为你提供开始使用这一激动人心的技术栈所需的所有工具。在我的职业生涯中,从未有过像 JavaScript 这样的新技术让我感到如此振奋,尽管它有些古怪的主角。我希望我已经成功传达了这一技术栈的一些优雅和承诺。尽管我多年来专业构建网站,但我感到,得益于 Node 和 Express,我比以往任何时候都更深入地理解了互联网的运作方式。我相信,这是一项真正增强理解力的技术,而不是试图隐藏细节,同时仍提供一个快速高效构建网站的框架。

无论你是网页开发的新手,还是刚接触 Node 和 Express,我都欢迎你加入 JavaScript 开发者的行列。我期待在用户组和会议上见到你,最重要的是,期待看到你将会构建什么。

posted @ 2025-11-16 09:00  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报