Deno-Web-开发-全-

Deno Web 开发(全)

原文:zh.annas-archive.org/md5/05CD4283AEDF57F3F0FCDC18A95F489E

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

Deno 是一个具有安全默认设置和优秀开发者体验的 JavaScript/TypeScript 运行时。

《Deno Web Development》介绍了 Deno 的原生对象、其原则,以及开发者如何使用它们来构建真实世界的应用程序。本书分为三个主要部分:介绍 Deno,从头构建 API,以及测试和部署 Deno 应用程序。到了本书的最后,读者将能够熟练使用 Deno 来创建、维护和部署安全和可靠的 Web 应用程序。

本书适合谁阅读

本书面向所有级别的开发者,他们希望在自己的 JavaScript 和 TypeScript 技能中利用一个安全、简单和现代化的运行时,用于 Web 开发。

本书涵盖内容

第一章,《什么是 Deno?》,提供了关于 Node.js 的历史背景和导致 Deno 诞生的动机,展示了运行时架构和原则。

第二章,《工具链》,介绍了如何安装 Deno,并探索了包含在运行时二进制文件中的工具。

第三章,《运行时和标准库》,解释了如何使用 Deno 的运行时和标准库函数编写简单的脚本和应用程序。

第四章,《构建 Web 应用程序》,展示了如何使用标准库 HTTP 模块为 Web 应用程序设置基础。

第五章,《添加用户并迁移到 Oak》,讨论了使用流行的 HTTP 库 oak 来构建 REST API,并向应用程序添加持久性和用户。

第六章,《添加身份验证并连接数据库》,讨论了添加对身份验证的支持以及经过身份验证的端点,并连接到 MongoDB 数据库。

第七章,《HTTPS,提取配置和 Deno 在浏览器中》,讨论了启用 HTTPS,基于文件和环境处理配置,以及在浏览器中使用 Deno 代码。

第八章,《测试 – 单元和集成》,涵盖了为前面章节中编写的模块编写和运行单元和集成测试。

第九章,《部署 Deno 应用程序》,介绍了配置容器环境以及自动化部署 Deno 应用程序,使其在云环境中运行。

第十章,《接下来是什么?》,概述了我们在本书中学到的内容,介绍了 Deno 的路线图,解释了如何将模块发布到 Deno 的官方注册表,并带你了解 Deno 的未来和社区。

为了最大化本书的收益

本书中的所有代码示例都是在 macOS 上的 Deno 1.7.5 上测试的,但它们应该在 Deno 的未来版本中工作。在本书的过程中还使用了几个第三方包。使用它们的示例也适用于软件的新版本。

本书将为所有使用的软件提供安装说明。

本书的代码是使用 VS Code(code.visualstudio.com/)编写的,以便在使用官方 Deno 扩展时获得最佳体验。这不是一个要求,任何代码编辑器都可以跟随本书。

如果您使用本书的数字版本,我们建议您亲自输入代码或通过 GitHub 存储库访问代码(下一节中有链接)。这样做可以帮助您避免与复制和粘贴代码相关的潜在错误。

您应该熟悉使用 JavaScript 并具有 TypeScript 的基本知识。不需要 Node.js 知识,但可能会有所帮助。

下载示例代码文件

您可以从www.packt.com您的账户上下载本书的示例代码文件。如果您在其他地方购买了此书,可以访问www.packtpub.com/support并注册,以便将文件直接发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择支持标签。

  3. 点击代码下载

  4. 搜索框中输入书籍名称,并按照屏幕上的指示操作。

下载文件后,请确保使用最新版本解压缩或提取文件夹:

  • 对于 Windows,请使用 WinRAR/7-Zip

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Deno-Web-Development。如果代码有更新,它将在现有的 GitHub 存储库上进行更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,地址为github.com/PacktPublishing/。查看它们!

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,假 URL,用户输入和 Twitter 处理程序。例如:"在deps.ts文件中添加oak-middleware-jwt并导出jwtMiddleware函数。"

代码块如下所示设置:

const apiRouter = new Router({ prefix: "/api" })
apiRouter.use(async (_, next) => {
  console.log("Request was made to API Router");
  await next();
}))
…
app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());

当我们希望引起您对代码块中的特定部分的关注时,相关的行或项目将被加粗:

const app = new Application();
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.response.headers.set("X-Response-Time", `${ms}ms`);
});
…
app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());

以下写出命令行输入或输出:

$ deno --version 
deno 1.7.5 (release, x86_64-apple-darwin) 
v8 9.0.123 
typescript 4.1.4

粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇在文本中会以这种方式出现。这是一个示例:“如果您使用过 MongoDB,您可以在 Atlas 界面上通过访问集合菜单来查看您创建的用户。”

提示或重要注释

像这样出现。

联系我们

读者反馈总是受欢迎的。

一般反馈:如果您对本书的任何方面有疑问,请在消息的主题中提及书名,并通过 customercare@packtpub.com 向我们发送电子邮件。

勘误:尽管我们已经竭尽全力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激如果您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击“勘误表提交表单”链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将非常感激如果您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上有专业知识,并且您有兴趣撰写或贡献一本书,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在这本书购买的网站上留下评论呢?潜在的读者可以看到并使用您公正的意见来做出购买决策,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问packt.com

第一部分:熟悉 Deno

在本节中,你将了解 Deno 是什么,它为何被创建,以及它是如何被创建的。本节将帮助你设置环境并熟悉生态系统的相关工具。

本部分包含以下章节:

第一章:Deno 是什么?

Deno 是一个安全的 JavaScript 和 TypeScript 运行时。我猜你可能对这个实验新工具感到兴奋。你已经使用过 JavaScript 或 TypeScript,至少听说过 Node.js。Deno 对你来说将感觉 novelty 正好合适,同时对于在生态系统中工作的人来说,有些东西听起来会很熟悉。

在我们开始动手之前,我们将了解 Deno 是如何创建的以及它的动机。这样做将帮助我们更好地学习和理解它。

在这本书中,我们将重点关注实际示例。我们将编写代码,然后解释我们做出的一些决策背后的原因。如果你来自 Node.js 背景,有些概念可能对你来说很熟悉。我们还将解释 Deno 并与它的祖先 Node.js 进行比较。

一旦基础知识确立,我们将深入研究 Deno,并通过构建小型工具和实际应用程序来探索其运行时功能。

没有 Node,就没有 Deno。要深入了解后者,我们不能忽视它的 10 多年的祖先,这就是我们将在本章中要探讨的。我们将解释它在 2009 年创建的原因以及在使用十年后检测到的痛点。

之后,我们将介绍 Deno 及其解决的基本差异和挑战。我们将查看其架构、一些运行时的原则和影响以及它擅长的用例。

在了解 Deno 是如何诞生的之后,我们将探讨它的生态系统、标准库以及 Deno 可以发挥重要作用的一些用例。

阅读完这一章后,您将了解 Deno 是什么,它不是什么,为什么它不是 Node.js 的下一个版本,以及当您考虑将 Deno 用于下一个项目时应该考虑什么。

在本章中,我们将涵盖以下主题:

  • 一点历史

  • 为什么是 Deno?

  • 支持 Deno 的架构和技术

  • 掌握 Deno 的限制

  • 探索 Deno 的使用案例

让我们开始吧!

一点历史

Deno 的第一个稳定版本,v1.0.0,于 2020 年 5 月 13 日发布。

瑞安·达尔(Ryan Dahl)--Node.js 的创建者--第一次提到它是在他著名的演讲《关于 node.js 我后悔的 10 件事》中(youtu.be/M3BM9TB-8yA)。除了它展示了 Deno 的第一个非常原始版本之外,这个演讲也是值得一看的,因为它是一堂关于软件如何衰老的课。它很好地反映了决策是如何随着时间演变,即使它们是由开源社区中最聪明的人做出的,并且最终可能会走向与最初计划不同的方向。

在 2020 年 5 月发布后,由于其历史背景、核心团队以及吸引 JavaScript 社区的事实,Deno 受到了很多关注。这可能是你听说的其中一种方式,无论是通过博客文章、推文还是会议演讲。

这种热情对其运行时产生了积极影响,许多人想要贡献和使用它。由于其 Discord 频道(discord.gg/deno)和 Deno 存储库的拉取请求数量(github.com/denoland),社区正在增长。目前,它以每月一个次要版本的速度发展,交付了大量修复和改进。路线图展示了一个未来,这同样令人兴奋。凭借明确定义的路径和原则,Deno 拥有发展成为越来越重要角色的所有条件。

让我们回溯一点,回到 2009 年 Node.js 的创建。

当时,Ryan 开始质疑大多数后端语言和框架是如何处理 I/O(输入/输出)的。大多数工具将 I/O 视为一个同步操作,阻塞进程直到完成,然后继续执行代码。

从根本上说,正是这种同步阻塞操作引起了 Ryan 的质疑。

处理 I/O

当你编写必须处理每秒数千个请求的服务器时,资源消耗和速度是两个重要的因素。

对于这样的资源关键项目,重要的是基本工具——原语——具有考虑这一点的架构。当扩展时间到来时,最初做出的基本决策支持这一点是有帮助的。

Web 服务器就是这种情况之一。Web 是当今世界的一个重要平台。它从未停止增长,每天都有更多设备和新技术上网,使更多人可以访问它。Web 是世界各地人民的共同、民主、去中心化的基础。有了这个目标,这些应用程序和网站背后的服务器需要处理巨大的负载。像 Twitter、Facebook 和 Reddit 这样的 Web 应用程序以及其他许多应用程序,每分钟处理数千个请求。因此,扩展是必不可少的。

为了激发关于性能和资源效率的讨论,让我们来看看以下图表,该图表比较了最常用的两个开源 Web 服务器:Apache 和 Nginx:

图 1.1 – 每秒请求数与并发连接数 – Nginx 对 Apache

图 1.1 – 每秒请求数与并发连接数 – Nginx 对 Apache

乍一看,这告诉我们 Nginx 几乎每次都能名列前茅。我们还可以理解,随着并发连接数目的增加,Apache 每秒请求数会下降。相比之下,Nginx 每秒请求数保持相对稳定,尽管随着连接数目的增加,每秒请求数也显示出预期的下降。达到一千个并发连接后,Nginx 的每秒请求数几乎达到 Apache 的两倍。

让我们看看 RAM 内存消耗的比较:

图 1.2 – 内存消耗与并发连接数——Nginx 与 Apache 的对比

图 1.2 – 内存消耗与并发连接数——Nginx 与 Apache 的对比

Apache 的内存消耗随着并发连接数的线性增长,而 Nginx 的内存占用是恒定的。

你可能已经在好奇这是为什么。

之所以这样,是因为 Apache 和 Nginx 在处理并发连接的方式上有很大的不同。Apache 每个请求都会创建一个新的线程,而 Nginx 则使用事件循环。

每个请求一个线程架构中,每当有一个新请求进来时,它就会创建一个线程。那个线程负责处理请求直到完成。如果另一个请求在之前的请求还在处理时到来,将会创建一个新的线程。

此外,在多线程环境中处理网络编程并不被认为是特别容易的事情。你可能会遇到文件和资源锁定、线程通信问题以及常见的死锁等问题。对于开发者来说,已经够棘手了,使用线程也不是免费的,因为线程本身就有资源开销。

相比之下,在事件循环架构中,一切都在单个线程上发生。这个决定极大地简化了开发人员的生活。你不需要考虑前面提到的因素,这意味着你可以有更多的时间来处理用户的问题。

通过使用这种模式,Web 服务器只需将事件发送到事件循环。它是一个异步队列,当有可用资源时执行操作,在操作完成后异步返回代码。为了让这工作,所有操作都需要是非阻塞的,意味着它们不应该等待完成,只是发送一个事件并稍后等待响应。

阻塞与非阻塞

以读取文件为例。在一个阻塞环境中,你会读取文件,并让进程等待它完成直到执行下一行代码。当操作系统读取文件内容时,程序处于空闲状态,浪费了宝贵的 CPU 周期:

const result = readFile('./README.md');
// Use result

程序会等待文件被读取,然后继续执行代码。

使用事件循环执行相同操作的是触发“读取文件”事件并执行其他任务(例如,处理其他请求)。当文件读取操作完成后,事件循环将调用回调函数并返回结果。这次,运行时在操作系统检索文件内容时处理其他请求,更好地利用资源:

const result = readFileAsync('./README.md', function(result) {
  // Use result
});

在这个例子中,任务被分配了一个回调。当任务完成(这可能需要几秒或几毫秒)时,它会调用回调函数并返回结果。当这个函数被调用时,里面的代码是线性运行的。

为什么事件循环没有被更广泛地使用呢?

既然我们已经理解了事件循环的优势,这是一个非常合理的疑问。尽管在 Python 和 Ruby 中有一些实现,事件循环没有被更广泛地使用的原因之一是,它们需要所有基础架构和代码都是非阻塞的。非阻塞意味着不要同步执行代码。它意味着触发事件,并在稍后的某个时间点处理结果。

除此之外,许多常用的语言和库并不提供异步 API。许多语言中没有回调,像 C 这样的编程语言中也不存在匿名函数。当今软件的至关重要部分,例如 libmysqlclient,即使其内部部分可能使用异步任务执行,也不支持异步操作。异步 DNS 解析也是许多系统并非标准的另一个例子。作为另一个例子,你可能认为操作系统的手动页面就是如此。其中大多数甚至不提供了解特定函数是否执行 I/O 的方法。这些都是当今许多基础软件组件中不存在异步 I/O 能力的证据。

甚至提供这些功能的现有工具也要求开发者对异步 I/O 模式有深入的了解才能使用事件循环。像 libmysqlclient 示例中那样绕过技术限制来让某物工作是一项艰巨的任务。

JavaScript 前来救援

JavaScript 是由布兰登·艾 ich(Brendan Eich)在 1995 年为网景工作时创建的。起初它只在浏览器中运行,并允许开发者在网页中添加交互式功能。它由一些揭示为非常适合事件循环的元素组成:

  • 它有匿名函数和闭包。

  • 它一次只执行一个回调。

  • I/O 通过回调(例如,addEventListener)在 DOM 上进行。

结合了语言这三个基本方面使得事件循环对于任何习惯了在浏览器中使用 JavaScript 的人来说都是自然而然的事情。

语言特性最终使得其开发者倾向于事件驱动编程。

Node.js 登上舞台

在所有关于 I/O 以及应该如何处理它的思考和问题之后,瑞恩·达尔(Ryan Dahl)在 2009 年提出了 Node.js。它是一个基于谷歌 V8 的 JavaScript 运行时 - 一个将 JavaScript 带到服务器的 JavaScript 引擎。

Node.js 设计上是异步和单线程的。它有一个事件循环作为其核心,并以一种可扩展的方式呈现,用于开发可以处理成千上万个并发请求的后端应用程序。

事件循环为我们提供了一种干净的方式来处理并发问题,在这方面 Node.js 与 PHP 或 Ruby 等工具不同,后者使用每个请求一个线程的模型。这个单线程环境让 Node.js 用户可以不必关心线程安全问题。它非常成功地抽象了事件循环以及所有同步工具的问题,用户几乎不需要了解事件循环本身。Node.js 通过利用回调和最近承诺(promises)的运用实现了这一点。

Node.js 将自己定位为为用户提供一个低级别的、纯粹的事件驱动的、非阻塞的基础设施,让他们编程自己的应用程序。

Node.js 的崛起

告诉公司和开发者们他们可以利用已有的 JavaScript 知识迅速地编写服务器,这导致了 Node.js 的流行度上升。

自从它被发布并开始被不同规模的公司在生产环境中使用以来,这种语言很快地发展进化。

在 2011 年 Node.js 创建后的仅仅两年,Uber 和 LinkedIn 就已经在服务器上运行 JavaScript 了。2012 年,Ryan Dahl 辞去了 Node.js 社区的日常运营工作,以便致力于研究和其它项目。

据估计,到 2017 年,运行 Node.js 的实例超过 880 万个(来源:blog.risingstack.com/history-of-node-js/)。今天,从Node 包管理器npm)下载的包已经超过 1030 亿个,发布的包大约有 146 万 7527 个。

Node.js 是一个很好的平台,这一点毫无疑问。基本上任何使用过它的人都会体验到它的许多优点。流行度和社区在其中扮演了重要的角色。有很多不同经验水平和背景的人一起协作开发某项技术,这只能推动它向前发展。这就是 Node.js 所发生的,并且仍然在发生的事情。

Node.js 让开发者们可以用 JavaScript 去实现很多之前不可能的用途。这从机器人技术,到加密货币,到代码打包器,API 等等都有涉及。它是一个稳定的环境,让开发者们感到高效且速度快。它将继续它的使命,在未来很多年里支持不同规模的公司和企业。

但既然你买了这本书,那说明你相信 Deno 有一些值得探索的东西,我可以保证它确实如此。

你可能会想,既然之前的解决方案已经足够令人满意,为什么还要提出一个新的解决方案呢?我们接下来就会发现答案。

为什么是 Deno?

自从 Node.js 创建以来,许多事情已经改变。十多年过去了,JavaScript 也发生了变化,软件基础设施社区也是如此。像 Rust 和 golang 这样的语言诞生了,它们在软件社区中是非常重要的发展。这些语言使得生产本地机器代码变得容易,同时为开发者提供一个严格和可靠的环境。

然而,这种严格性是以生产率为代价的。并不是说开发者写这些语言时不觉得生产率低,因为他们确实觉得有生产力,但你可以很容易地争论,生产率是动态语言明显占优势的领域。

动态语言的开发便捷和速度使它们在脚本和原型设计方面成为非常强劲的竞争者。而当考虑到动态语言时,JavaScript 立刻浮现在脑海中。

JavaScript 是最常用的动态语言,它可以在任何装有网络浏览器的设备上运行。由于它的广泛使用和庞大的社区,人们对它进行了许多优化工作。诸如 ECMA International 等组织的创建确保了该语言稳定而谨慎地发展。

正如我们在上一节所看到的,Node.js 在将 JavaScript 带到服务器上扮演了非常成功的角色,为大量不同的用例打开了大门。它目前用于许多不同的任务,包括网络开发工具、创建网络服务器和脚本,等等。在其创建之初,为了启用这些用例,Node.js 必须为 JavaScript 发明之前不存在概念。后来,这些概念由标准化组织讨论,并以不同的方式添加到语言中,使得 Node.js 的部分内容与其母语言 ECMAScript 不兼容。十年过去了,ECMAScript 也发生了变化,围绕它的生态系统也是如此。

CommonJS模块不再是标准;JavaScript 现在有 ES 模块。TypedArrays现在已经存在,最终,JavaScript 可以直接处理二进制数据。Promises 和 async/await 是处理异步操作的首选方法。

这些功能在 Node.js 上是可用的,但它们必须与 2009 年创建的非标准功能共存,这些功能仍然需要维护。这些功能以及 Node.js 的大量用户使得系统的发展变得困难且缓慢。

为了解决这些问题,并跟上 JavaScript 语言的发展,许多社区项目被创建出来。这些项目使我们能够使用该语言的最新特性,但在许多 Node.js 项目中加入了诸如构建系统的东西,使得它们变得非常复杂。引用 Dahl 的话,“夺走了动态语言脚本的美好。”

超过 10 年的广泛使用也清楚地表明,运行时的一些基本构建设需要改进。缺乏安全沙箱是主要问题之一。在创建 Node.js 的时候,JavaScript 可以通过在 V8(它背后的 JavaScript 引擎)中创建绑定来访问“外部世界”。尽管这些绑定使 JavaScript 能够实现诸如从文件系统读取、访问网络等 I/O 功能,但它们也打破了 JavaScript 沙箱的目的。这个决定使得让开发者控制 Node.js 脚本可以访问的内容变得非常困难。例如,在当前状态下,没有办法阻止 Node.js 脚本中的第三方包读取用户可以访问的所有文件,以及其他恶意行为。

十年后,Ryan Dahl 和 Deno 背后的团队怀念一个既有趣又高效的脚本环境,可以用于执行各种任务。团队还觉得 JavaScript 景观已经发生了足够大的变化,简化是有价值的,因此他们决定创建 Deno。

介绍 Deno

"Deno 是一个简单、现代且安全的 JavaScript 和 TypeScript 运行时,它使用了 V8 引擎,并内置了 Rust 构建。" – deno.land/

Deno 的名称是通过反转其前身 no-de 的音节而构成的,即 de-no。从它的前身那里学到了很多教训,Deno 提出了以下主要特性:

  • 默认情况下是安全的

  • 一等 TypeScript 支持

  • 单一的可执行文件

  • 提供编写应用程序的基本工具

  • 完整且经过审计的标准库

  • 与 ECMAScript 和浏览器环境的兼容性

默认情况下,Deno 是安全的,并且是按照设计来创建的。它最终利用了 V8 沙箱,并提供了一个严格的权限模型,使开发者能够精确控制代码可以访问的内容。

TypeScript 也得到了一等支持,这意味着开发者可以选择不进行任何额外配置就使用 TypeScript。Deno 的所有 API 也都是用 TypeScript 编写的,因此具有正确和精确的类型和文档。标准库也是如此。

Deno 带有一个单一的可执行文件,其中包含了编写应用程序所需的所有基本工具;它总是这样。团队努力保持可执行文件的小巧(约 15 MB),以便我们可以在各种情况和环境中使用它,从简单的脚本到完整的应用程序。

不仅仅是执行代码,Deno 二进制文件提供了一整套开发者工具,具体包括一个代码检查器、一个格式化工具和一个测试运行器。

Go 语言精心打磨的标准库激发了 Deno 标准库的灵感。与 Node.js 的标准库相比,Deno 的标准库故意设计得更大、更完整。这个决定是为了应对一些 Node.js 项目中曾经出现的庞大的依赖树。Deno 的核心团队认为,通过提供一个稳定且完整的标准库,可以帮助解决这个问题。通过移除创建第三方包来处理常见用例的需求,该平台默认提供了这些功能,从而旨在减少使用大量第三方包的必要性。

为了与 ES6 和浏览器保持兼容,Deno 努力模仿浏览器 API。执行 HTTP 请求、处理 URL 或编码文本等工作,可以通过使用你在浏览器中会使用的相同 API 来完成。Deno 团队故意努力保持这些 API 与浏览器同步。

旨在提供三者的最佳特性,Deno 提供了 JavaScript 的原型能力和开发者体验,TypeScript 的类型安全和安全性,以及 Rust 的性能和简洁性。

理想情况下,正如 Dahl 在他的一次谈话中提到的,代码应该遵循从原型到生产的以下流程:开发者可以开始写 JavaScript,迁移到 TypeScript,最终得到 Rust 代码。

在撰写本文时,只能运行 JavaScript 和 TypeScript。Rust 只能通过一个(仍然不稳定的)插件 API 来使用,这可能在不太遥远的将来可能会变得稳定。

命令行脚本的网络浏览器。

随着时间的推移,Node.js 模块系统演变成现在过于复杂且维护痛苦的东西。它考虑了诸如导入文件夹、搜索依赖项、导入相对文件、搜索 index.js、第三方包和读取package.json文件等边缘情况。

它也与npmNode 包管理器)紧密耦合,后者最初是 Node.js 的一部分,但在 2014 年分离出来。

拥有一个集中式的包管理器并不非常符合网络化,借用 Dahl 的话来说。数百万应用程序依赖于一个单一的注册表来生存,这是一个负担。

Deno 通过使用 URL 来解决这个问题。它采取了一种与浏览器非常相似的方法,只需要一个到文件的绝对 URL 就可以执行或导入代码。这个绝对 URL 可以是本地、远程或基于 HTTP 的,并包括以下文件扩展名:

import { serve } from 'https://deno.land/std@0.83.0/http/server.ts'

前面的代码碰巧就是你在浏览器中在<script>标签内想要引入 ES 模块时会写的相同代码。

关于安装和离线使用,Deno 通过使用本地缓存确保用户不必为此担心。当程序运行时,它会安装所有必需的依赖项,从而消除了安装步骤。我们稍后会在第二章更深入地探讨这一点,工具链

现在我们已经熟悉了 Deno 是什么以及它解决的问题,我们就可以深入了解。通过了解幕后发生的事情,我们可以更好地理解 Deno 本身。

在下一节中,我们将探讨支持 Deno 的技术以及它们是如何连接的。

支持 Deno 的架构和技术

从架构上讲,Deno 考虑了诸如安全等各种主题,如与底层操作系统通信的干净且高效的通信方式,而不会泄露细节给 JavaScript 端。为了实现这一点,Deno 使用消息传递从 V8 内部与 Deno 后端通信。后端是用 Rust 编写的组件,与事件循环交互,进而与操作系统交互。

Deno 是由四项技术实现的:

  • V8

  • TypeScript

  • Tokio (事件循环)

  • Rust

正是这四个部分的结合,使得它能够在保证代码安全和沙盒化的同时,为开发者提供出色的体验和开发速度。如果你不熟悉这些技术,我会留下一个简短的定义:

V8 是谷歌开发的 JavaScript 引擎。它用 C++编写,可以在所有主流操作系统上运行。它还是 Chrome、Node.js 等浏览器的引擎。

TypeScript 是微软开发的一种超集 JavaScript,它为语言添加了可选的静态类型,并编译成 JavaScript。

Tokio 是为 Rust 提供编写任何规模网络应用程序的异步运行时。

Rust 是 Mozilla 设计的专注于性能和安全的服务器端语言。

使用快速发展语言 Rust 编写 Deno 的核心,使其比 Node.js 更受开发者欢迎。Node.js 的核心是用 C++编写的,这并不以特别容易处理著称。由于许多陷阱和不太好的开发者体验,C++在 Node.js 核心的发展中显示出是一个小障碍。

Deno_core作为 Rust crate(包)分发。Rust 与 Rust 之间的这种联系并非巧合。Rust 提供了许多功能,使与 JavaScript 的连接变得容易,并增加了 Deno 本身的 capabilities. Asynchronous operations in Rust typically use Futures that map very well with JavaScript Promises. Rust is also an embeddable language, and that provides direct embedding capabilities to Deno. This added to Rust being one of the first languages to create a compiler for WebAssembly, made the Deno team choose it for its core.

来自 POSIX 系统的灵感

POSIX 系统对 Deno 有很大的启发。在他的一次演讲中,Dahl 甚至提到 Deno 处理某些任务“就像一个操作系统”

下面的表格显示了来自 POSIX/Linux 系统的标准术语以及它们如何映射到 Deno 概念:

一些来自 Linux 世界的概念你可能很熟悉。比如说进程。它们代表了一个正在运行的程序的实例,该程序可能使用一个或多个线程执行。Deno 使用 WebWorker 在运行时完成同样的任务。

在第二行,我们有系统调用。如果你不熟悉它们,它们是程序向内核发出请求的方式。在 Deno 中,这些请求并不直接发送到内核;相反,它们从 Rust 核心发送到底层操作系统,但它们的工作方式相似。我们接下来有机会在即将到来的架构图中看到这一点。

这些都是如果你熟悉 Linux/POSIX 系统你可能认出的几个例子。

我们将在本书的剩余部分解释和使用上述大部分 Deno 概念。

架构

Deno 的核心最初是用 golang 编写的,但后来改用 Rust。这个决定是为了摆脱 golang,因为它是一个垃圾收集语言。它与 V8 的垃圾收集器的组合可能会导致未来的问题。

为了了解底层技术如何相互作用形成 Deno 核心,让我们看一下以下架构图:

图 1.3 – Deno 架构

图 1.3 – Deno 架构

Deno 使用消息传递与 Rust 后端进行通信。作为一个关于权限隔离的决策,Deno 从不向 Rust 暴露 JavaScript 对象句柄。V8 内部和外部的所有通信都使用 Uint8Array 实例。

对于事件循环,Deno 使用 Tokio,一个 Rust 线程池。Tokio 负责处理 I/O 工作和回调 Rust 后端,使其能够异步处理所有操作。操作ops)是 Rust 和事件循环之间来回传递的消息的名称。

所有从 Deno 代码发送到其核心(用 Rust 编写)的异步消息都会返回承诺给 Deno。更准确地说,Rust 中的异步操作通常返回未来,Deno 将它们映射到 JavaScript 承诺。每当这些未来被解决,JavaScript 的承诺也同样被解决。

为了使 V8 能够向 Rust 后端发送消息,Deno 使用 rusty_v8,这是由 Deno 团队创建的 Rust 库,它提供了 V8 到 Rust 的绑定。

Deno 还将在 V8 内部包含 TypeScript 编译器。它使用 V8 快照进行启动时间优化。快照用于在特定的执行时间保存 JavaScript 堆,并在需要时恢复它。

自从它首次提出以来,Deno 一直受到迭代、进化过程的制约。如果你好奇它变化了多少,你可以查看 2018 年由 Ryan Dahl 写的最初路线图文档(github.com/ry/deno/blob/a836c493f30323e7b40e988140ed2603f0e3d10f/Roadmap.md)。

现在,我们不仅知道 Deno 是什么,也知道它背后的幕后工作。这些知识将帮助我们在将来运行和调试我们的应用程序。Deno 的创造者做出了许多技术和架构决策,将 Deno 带到今天这个状态。这些决策推动了运行时的进步,并确保 Deno 在几种情况下都能表现出色,其中一些我们稍后会探讨。然而,为了使其在某些用例中表现良好,必须做出一些权衡。这些权衡导致了我们接下来要探讨的限制。

掌握 Deno 的限制

正如所有事情一样,选择解决方案是处理权衡的问题。那些最适合我们正在编写的项目和应用程序的解决方案是我们最终会使用的。目前,Deno 有一些限制;有些是由于它短暂的寿命,其他则是因为设计决策。像大多数解决方案一样,Deno 也不是一个万能的工具。在接下来的几页中,我们将探讨 Deno 当前的一些限制以及背后的动机。

不如 Node.js 稳定

在当前状态下,Deno 在稳定性方面无法与 Node.js 相提并论,这是显而易见的原因。Node.js 有超过 10 年的发展,而 Deno 只剩下接近两年的寿命。

尽管本书中介绍的大部分核心功能已经被认为是稳定且版本正确的,但仍然有一些功能可能会发生变化,并且标有不稳定标志。

Node.js 多年的经验确保了它经过了实战考验,并且可以在最多样化的环境中工作。这是我们希望 Deno 能够获得的,但时间和采用是关键因素。

更好的 HTTP 延迟,但吞吐量更差

Deno 从一开始就保持性能。然而,如基准页面所示(deno.land/benchmarks),在某些主题上,它仍然不是 Node.js 的水平。

它的祖先利用了直接与 C++绑定在 HTTP 服务器上,从而提高这个性能分数。由于 Deno 抵制添加本地的 HTTP 绑定并在本地的 TCP 套接字之上构建,它仍然承受着性能上的惩罚。这个决定是团队计划在优化 TCP 套接字通信之后解决的问题。

Deno HTTP 服务器每秒处理大约 25k 个请求,最大延迟为 1.3 毫秒,而 Node.js 处理 34k 个请求,但延迟在 2 到 300 毫秒之间变化。

我们无法说每秒 25k 请求不够,尤其是当我们使用 JavaScript 时。如果你的应用/网站需要的请求量超过这个数字,那么 JavaScript,以及因此 Deno,可能不是这个工作的正确工具。

与 Node.js 的兼容性

由于许多已经引入的更改,Deno 不提供与现有 JavaScript 包和工具的兼容性。一个兼容层正在标准库上创建,但它仍然远远没有完成。

由于 Node.js 和 Deno 是两个非常相似的系统,有着共同的目标,我们预计随着时间的推移,Deno 将能够默认执行越来越多的 Node.js 程序。然而,尽管目前有些 Node.js 代码是可以运行的,但目前并非如此。

TypeScript 编译器速度

如我们之前提到的,Deno 使用 TypeScript 编译器。它作为运行时最慢的部分表现出来,尤其是与 V8 解释 JavaScript 的时间相比。快照在这方面有所帮助,但这还不够。Deno 的核心团队认为他们可能需要将 TypeScript 编译器迁移到 Rust 来解决这个问题。

由于完成这项任务需要做大量的工作,这可能不会很快实现,尽管这应该是使其启动时间快得多的事情之一。

缺乏插件/扩展

尽管 Deno 有一个插件系统来支持自定义操作,但它还没有完成,被认为是不稳定的。这意味着将本地功能扩展到比 Deno 提供的更多是几乎不可能的。

到目前为止,我们应该理解 Deno 目前的限制以及这些限制存在的原因。其中一些可能随着 Deno 的成熟和演变而很快得到解决。其他的则是设计决策或路线图优先级的结果。理解这些限制在决定是否在项目中使用 Deno 时至关重要。在下一节中,我们将看看我们认为 Deno 非常适合的用例。

探索用例

正如您可能已经意识到的,Deno 本身与 Node.js 有许多共同的用例。大多数所做的更改都是为了确保运行时更安全、更简单,但随着它利用了大多数相同的技术,拥有相同的引擎,以及许多相同的目标,用例之间的差异不会太大。

然而,尽管差异并不大,可能存在一些微小的细微差别,这使得在特定情况下其中一个比另一个稍微更适合。在本节中,我们将探讨一些 Deno 的用例。

灵活的脚本语言

脚本编程是那些解释型语言总是闪耀光芒的功能之一。当我们想要快速原型化某件事时,JavaScript 是完美的。这可以包括重命名文件、迁移数据、从 API 中消费内容等等。它似乎是这些用例的正确工具。

Deno 对脚本编程给予了深思熟虑。运行时本身让用户用它来写脚本变得非常容易,从而在这方面的使用场景中提供了许多好处,特别是与 Node.js 相比。这些好处包括仅用一个 URL 就能执行代码,无需管理依赖项,以及基于 Deno 创建可执行文件的能力。

在此之上,你现在可以导入远程代码,同时控制它使用的权限,这在信任和安全方面是一个重大的步骤。

Deno 的读取-评估-打印循环 (REPL) 是进行实验工作的好地方。在我们之前提到的基础上,二进制文件的小巧以及它包含所有所需工具的事实是蛋糕上的樱桃。

更安全的桌面应用程序

尽管插件系统还不稳定,允许开发者创建桌面应用程序的包很大程度上依赖于它,但它非常有前景。

在过去的几年里,我们见证了桌面网络应用程序的兴起。Electron 框架的兴起(www.electronjs.org/)使可以创建像 VS Code 或 Slack 这样的应用程序。这些是运行在 WebView 中的网页,可以访问本地功能,是许多人日常生活的一部分。

然而,对于用户来说安装这些应用程序,他们必须盲目地信任它们。之前,我们讨论了安全性以及 JavaScript 代码曾经可以访问它运行的所有系统。Deno 在这里从根本上不同,因为由于其沙盒和所有的安全特性,这要安全得多,并且解锁的潜力巨大。

在本书中,我们将探讨如何使用 JavaScript 在 Deno 中构建桌面应用程序的大量进展。

编写工具的快速而完整的环境

Deno 的功能使它成为一个非常完整、简单且快速的编写工具的环境。当我们说工具时,这不仅仅是针对 JavaScript 或 TypeScript 项目的工具。由于单一的二进制文件包含了开发应用程序所需的所有内容,我们可以将 Deno 用于 JavaScript 世界之外的生态系统。

它的清晰性、通过 TypeScript 自动生成文档、易于运行以及 JavaScript 的普及性,使 Deno 成为编写工具(如代码生成器、自动化脚本或其他开发工具)的正确组合。

在嵌入式设备上运行

通过使用 Rust 并将核心作为 Rust crate 分发,Deno 自动启用了在嵌入式设备上的使用,从 IoT 设备到可穿戴设备和 ARM 设备。再次,它的小巧以及包含所有工具的二进制文件可能是一个巨大的胜利。

箱子可以独立提供的事实允许人们在不同地方嵌入 Deno。例如,当用 Rust 编写数据库并且想要添加 Map-Reduce 逻辑时,我们可以使用 JavaScript 和 Deno 来实现。

生成浏览器兼容代码

如果你之前没有看过 Deno,那么这可能是个惊喜。我们不是在谈论服务器端运行时吗?是的。但这个服务器端运行时一直在努力保持 API 的浏览器兼容性。它在工具链中提供了特性,使代码可以写在 Deno 中并在浏览器中执行,这将在第七章 HTTPS、提取配置和 Deno 在浏览器中 中探索。

所有的这些工作都由 Deno 团队负责,他们使自己的 API 保持与浏览器兼容,并生成可以在浏览器中打开新可能性集的浏览器代码。浏览器兼容性是我们在本书后面将会使用到的内容,在第七章 HTTPS、提取配置和 Deno 在浏览器中 中,通过编写一个完整的应用程序、客户端和服务器来构建一个 Deno 应用程序。(注:这里原文中的“in this book”翻译为“在本书后面”,以保持上下文的连贯性。)

全面的 API

Deno 和 Node.js 一样,在处理 HTTP 服务器方面投入了大量精力。拥有一个完整的标准库,为框架提供伟大的基础,毫无疑问,API 是 Deno 最强大的用例之一。TypeScript 在文档、代码生成和静态类型检查方面是一个很好的补充,帮助成熟的代码库扩展。

我们将在本书的剩余部分更多地关注这个具体的用例,因为我相信这是最重要的用例之一——Deno 发挥光彩的地方。

这些都是我们认为 Deno 非常适合的用例的几个例子。与 Node.js 一样,我们也知道还有许多新的用途等待发现。我们很高兴能陪伴这个冒险,并看到它还将揭示什么。

总结

在本书这一章中,我们穿越回 2009 年,以理解 Node.js 的创建。在那之后,我们意识到与线程模型相比,为什么要使用事件驱动的方法,以及它带来的优势。我们了解到事件驱动、异步代码是什么,以及 JavaScript 如何帮助 Node.js 和 Deno 充分利用服务器的资源。

在那之后,我们快速浏览了 Node.js 的 10 多年的历史、它的演变以及它的采用开始的情况。我们观察到运行时如何与它的基础语言 JavaScript 一起增长,同时帮助数百万企业将其伟大的产品带给客户。

然后,我们用今天的眼光来看 Node.js,生态和语言发生了什么变化?开发者遇到了哪些痛点?我们深入这些痛点,探讨为什么改变 Node.js 来解决这些问题既困难又缓慢。

随着这一章的进展,Deno 的动机变得越来越明显。在查看了 JavaScript 在服务器端的历史之后,出现一些新东西是合理的——一些可以解决以前经历的痛苦同时保留开发者所喜爱的东西的东西。

最后,我们了解了 Deno,它将成为我们这本书的朋友。我们学习了它的愿景、原则以及它如何解决某些问题。在简要介绍了使其成为可能的基础架构和组件之后,我们不禁要谈论一些权衡和当前的限制。

我们通过列举 Deno 适用的一些用例来结束这一章。稍后在本书中,当我们开始编程时,我们会回到这些用例。从这一章开始,我们的方法将更加具体和实用,始终朝着编写可以运行和探索的代码和示例前进。

既然我们已经了解了 Deno 是什么,我们就有了开始使用它的所有必要条件。在下一章中,我们将设置相应的环境并编写一个 Hello World 应用程序,同时做许多其他令人兴奋的事情。

就是这样,激动人心的冒险开始了,对吧?让我们出发吧!

第二章:工具链

如今我们熟悉了事件驱动语言,了解了 Node 的历史以及导致 Deno 产生的原因,我们就可以开始写一些代码了。

在本章中,我们首先要做的是设置环境和代码编辑器。我们将通过编写我们的第一个 Deno 程序和使用 REPL 实验运行时 API 来继续。然后,我们将探讨模块系统以及 Deno 缓存和模块解析如何通过实际示例工作。我们将了解版本控制,并将学习如何处理第三方依赖。然后,我们将使用 CLI 探索包及其文档,以及如何安装和重复使用 Deno 脚本。

在运行和安装几个脚本之后,我们将深入研究权限,学习权限系统是如何工作的以及我们如何可以保障我们运行的代码的安全。

在我们了解工具链的过程中,我们不能忽略代码格式化和验尸,所以我们在本章中也将探讨这些主题。我们将通过编写和运行一些简单的测试来探索 Deno 的测试套件,最后介绍 Deno 如何将代码打包成一个自给自足的二进制文件或单个 JavaScript 文件。

在本章中,我们将涵盖以下主题:

  • 设置环境

  • 安装 VS Code

  • Hello World

  • 模块系统和第三方依赖

  • 运行和安装脚本

  • 使用测试命令

  • 使用权限

  • 格式化和验尸代码

  • 代码打包

  • 编译成二进制

  • 使用升级命令

让我们开始吧!

技术要求

本章中出现的所有代码都可以在 github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter02 找到。

设置环境

Deno 的一个原则是使其单一的可执行文件尽可能完整。这个决定,以及其他决策,大大简化了安装步骤。在本节中,我们将安装 VS Code 和推荐插件,并学习如何在不同的系统上安装 Deno。

安装 Deno

在接下来的几页中,我们将学习如何安装 Deno。为了确保本书中写的所有内容都能顺利运行,我们将使用版本 1.7.5。

这是本书中为数不多的部分,根据您的操作系统,事情可能会有所不同。安装完成后,无论您如何安装 Deno,都没有区别。

让我们实际操作并在我们的机器上安装 Deno。下面的子弹点展示了如何在不同的操作系统上安装运行时:

  • Shell (Mac, Linux):

    $ curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.7.5
    
  • PowerShell (Windows):

    $v="1.7.5"; iwr https://deno.land/x/install/install.ps1 -useb | iex
    

然后,为了确保一切正常工作,让我们通过运行以下命令来获取当前的 Deno 版本:

$ deno --version

我们应该得到以下输出:

$ deno --version 
deno 1.7.5 (release, x86_64-apple-darwin) 
v8 9.0.123 
typescript 4.1.4

现在我们已经安装了正确版本的 Deno,我们可以开始编写和执行我们的程序了。然而,为了使我们的体验更加顺畅,我们将安装并配置我们选择的编辑器。

安装 VS Code

VS Code 是我们将在这本书中使用的编辑器。这主要是因为它有一个官方的 Deno 插件。还有其他提供 JavaScript 和 TypeScript 愉悦体验的编辑器,所以您可以自由使用它们。

这些步骤不是遵循本书剩余内容的必要步骤,所以请随意跳过它们。要安装它,请按照以下步骤操作:

  1. 访问 code.visualstudio.com/ 并点击 下载 按钮。

  2. 下载完成后,在您的系统上安装它。

  3. 安装 VS Code 后,最后一步是安装 Deno 的 VS Code 插件。

  4. Deno 上下文中,安装由 Denoland 编写的 Deno 插件,这是官方插件:

图 2.1 – VS Code 左侧栏的插件图标

](https://gitee.com/OpenDocCN/freelearn-js-pt2-zh/raw/master/docs/deno-web-dev/img/Figure_2.1_B16380.jpg)

图 2.1 – VS Code 左侧栏的插件图标

这就是 Deno 的 VS Code 插件的样子:

图 2.2 – Deno 在 VS Code 市场中的扩展

图 2.2 – Deno 在 VS Code 市场中的扩展

要在你项目中启用 Deno 插件,你必须创建一个本地 VS Code 文件夹,该文件夹将包含工作区配置文件。为此,我们将创建一个名为 .vscode 的文件夹,并在其中创建一个名为 settings.json 的文件,并在该文件中写入以下内容:

{
 "deno.enable": true
}

这将使 VS Code 激活当前文件夹内的扩展。在使用不稳定特性时,我们还可以启用 deno.unstable 设置,这也在插件文档中提到。

壳牌补全

Deno 还为我们提供了一种生成壳牌补全的方法。这样,在终端中编写 Deno 命令时,我们将获得自动完成建议。我们可以通过运行以下命令来实现:

$ deno completions <shell>

shell 的可能值有 zshbashfishpowershellelvish。确保你选择你正在使用的那个。此命令将输出补全内容到标准输出。然后你可以将内容粘贴到你的 shell 配置文件中(deno.land/manual@v1.7.5/getting_started/setup_your_environment#shell-autocomplete)。

有了这些,我们已经完成了如何安装 Deno 的步骤。我们还安装并配置了运行时和编辑器。现在,让我们用 Deno 编写一个 Hello World 程序!

Hello World

一切准备就绪后,让我们编写我们的第一个程序!

首先,我们需要创建一个名为 my-first-deno-program.js 的文件,并写一些我们熟悉的内容。我们将使用 console API 将消息写入控制台:

console.log('Hello from deno');

要执行此操作,让我们使用前面章节中安装的 CLI。我们必须使用名为 run 的命令来执行程序:

$ deno run my-first-deno-program.js
Hello from deno

提示

所有 Deno CLI 命令都可以使用 --help 标志执行,这将详细说明命令的所有可能行为。

至此,我们实际上并没有做任何我们不知道该做什么的事情。我们只是用我们熟悉的 JavaScript 语言编写了一个 console.log 文件。

有趣的是,我们已经学会了使用run命令来执行程序。我们稍后在本书中详细探讨这个。

重新加载

阅读-评估-打印循环,也称为REPL,是在解释型语言中常用的工具。它允许用户运行代码行并获得即时输出。Node.js、Ruby 和 Python 是几个大量使用它的语言例子。Deno 也不例外。

要打开它,你只需要运行以下命令:

$ deno

你现在可以花些时间去探索这门语言(提示:有标签完成功能)。如果你好奇有哪些 API 可以使用,这里是尝试它们的好地方。我们稍后会深入那些内容,但为了给你一些建议,你可以看看Deno命名空间,与 Web API 兼容的函数如fetch,或者如Mathwindow的对象,这些都在 Deno 的文档中列出(doc.deno.land/builtin/stable)。

试试它们吧!

评估

另一种执行不在文件中的代码的方法是使用eval命令:

$ deno eval "console.log('Hello from eval')"
Hello from eval

eval命令可以用来运行简单的内联脚本。

到目前为止,我们所编写的程序相当简单。我们只是以几种不同的方式将值输出到控制台。然而,当我们开始接近现实世界时,我们知道我们将编写更复杂的逻辑。更复杂的逻辑意味着更多的错误,因此需要调试我们的代码。这是我们接下来要学习的内容。

在 Deno 中调试代码

即使在我们遵循最佳实践并尽力编写简单、干净的代码时,任何相关的程序都很有可能会偶尔需要调试。

掌握快速运行和调试代码的能力是提高任何技术学习曲线的最佳方法之一。这项技能使得通过尝试和错误以及快速实验来测试和理解事物变得容易。

让我们学习一下如何调试我们的代码。

第一步是创建一个第二个程序。让我们添加几个变量,稍后可以检查。这个程序的主要目标是返回当前时间。我们将使用已知的Date对象来完成这个任务。让我们将这个文件命名为get-current-time.js,像这样:

const now = new Date();
console.log(`${now.getHours()}:${now.getMinutes()}:  ${now.getSeconds()}`);

如果我们想在它打印到控制台之前调试now变量的值,这就是调试发挥作用的地方。让我们用--inspect-brk标志运行同一个程序:

$ deno run --inspect-brk get-current-time.js
Debugger listening on ws://127.0.0.1:9229/ws/32e48d8a-5c9c-4300-8e09-ee700ab79648

我们现在可以打开 Google Chrome 浏览器,输入chrome://inspect/。在 localhost 上运行的远程目标 called deno 将会列出。点击inspect后,Chrome DevTools 检查器窗口将打开,并且执行将暂停在第一行:

图 2.3 – Chrome 在要调试的第一行停止

图 2.3 – Chrome 在要调试的第一行停止

在此阶段,我们可以添加断点、记录某些值、检查变量等等。它使得我们可以像在 Node 上或浏览器中调试时做的那样做相同的事情。

其实也可以使用--inspect标志来进行这个操作。然而,我们在这里使用了--inspect-brk以方便起见。这两个选项行为相似,但inspect需要在代码中存在一个调试器。当代码执行并解释调试器关键字时,它会尝试连接到一个已经运行的检查器实例。

既然我们已经了解了如何运行和调试代码,我们就可以开始编写自己的程序了。还有很多要学的,但我们已经熟悉了最基本的内容。

当我们开始编写程序并随着代码库的增长,我们很可能会开始将逻辑提取到不同的模块中。当这些模块变得可重用时,我们可能会将它们提取成包,以便它们可以在项目之间共享。这就是为什么我们需要了解 Deno 如何处理模块解析,我们将在下一节中进行了解。

模块和第三方依赖

Deno 使用与浏览器完全兼容的 ECMAScript 模块和导入。模块的路径是绝对的,所以它包括文件扩展名,这也是浏览器世界中的一个标准。

Deno 非常认真地采取了作为一个为脚本提供浏览器的方法。它与网络浏览器共有的一个特点是它深刻地利用了 URL。它们是分享资源最灵活的方式,在网络上表现得很美丽。为什么不用它们进行模块解析呢?浏览器就是这么做的。

模块路径是绝对的这个事实使得我们不需要依赖像 npm 这样的第三方实体,或者复杂的模块解析策略。有了绝对导入,我们可以直接从 GitHub、私有服务器,甚至从一个 gist 导入代码。唯一的要求是它有一个 URL。

这个决定使得可以采用完全去中心化的模块分布,并使得 Deno 内部的模块解析简单且与浏览器兼容。这是在 Node 上不会发生的事情。

Deno 甚至利用 URL 进行版本控制。例如,要导入标准库中 0.83.0 版本的 HTTP 服务器,我们将使用以下代码:

import { serve } from 
'https://deno.land/std@0.83.0/http/server.ts'

这就是导入一个模块有多么简单。在这里,代码是从deno.land/加载的,但模块可以从任何其他地方加载。唯一的要求是有一个链接指向它。

例如,如果您有自己的服务器,文件可以通过 URL 访问,您可以在 Deno 中直接使用它们。之前,我们了解到 Deno 会自动安装并缓存依赖项,那么让我们了解更多关于它是如何工作的。

本地缓存的依赖项

我们已经了解到 Deno 没有像node_modules这样的约定。对于来自 Node 的人来说,这可能听起来很奇怪。这意味着你的代码总是从互联网上获取模块吗?不是。你仍然可以离线工作吗?可以。

让我们在实践中看看这个。

创建一个名为hello-http-server.js的文件,并添加以下代码:

import { serve } from
"https://deno.land/std@0.84.0/http/server.ts";
for await (const req of serve(":8080")) {
  req.respond({ body: "Hello deno" });
}

正如你可能猜到的那样,这个程序在端口8080上启动一个 HTTP 服务器,并对每个请求响应Hello deno

如果你觉得这仍然很奇怪,不用担心——我们将在下一章更深入地介绍标准库。

让我们运行程序,并注意 Deno 在执行代码之前做了什么:

$ deno run hello-http-server.js
Download https://deno.land/std@0.83.0/http/server.ts
Download https://deno.land/std@0.83.0/encoding/utf8.ts
Download https://deno.land/std@0.83.0/io/bufio.ts
Download https://deno.land/std@0.83.0/_util/assert.ts
Download https://deno.land/std@0.83.0/async/mod.ts
Download https://deno.land/std@0.83.0/http/_io.ts
Download https://deno.land/std@0.83.0/textproto/mod.ts
Download https://deno.land/std@0.83.0/http/http_status.ts
Download https://deno.land/std@0.83.0/async/deferred.ts
Download https://deno.land/std@0.83.0/async/delay.ts
Download https://deno.land/std@0.83.0/async/mux_async_iterator.ts
Download https://deno.land/std@0.83.0/async/pool.ts
Download https://deno.land/std@0.83.0/bytes/mod.ts
error: Uncaught PermissionDenied: network access to "0.0.0.0:8080", run again with the --allow-net flag

发生了什么事?在运行代码之前,Deno 查看代码的导入,下载任何依赖项,编译它们,并将它们存储在本地缓存中。最后仍然有一个错误,但我们稍后再解决这个问题。

为了了解 Deno 如何处理下载的文件,我们将使用另一个名为info的命令:

$ deno info
DENO_DIR location: "/Users/alexandre/Library/Caches/deno"
Remote modules cache: "/Users/alexandre/Library/Caches/deno/deps"
TypeScript compiler cache: "/Users/alexandre/Library/Caches/deno/gen"

这会打印有关 Deno 安装的信息。注意DENO_DIR,这是 Deno 存储其本地缓存的路径。如果我们导航到那里,我们可以访问.js文件和相应的源映射。

在第一次下载并缓存模块之后,Deno 将不会重新下载它们,并将一直使用本地缓存,直到明确要求它不要这样做。

不运行代码的缓存

为了确保你有一个本地副本,而不必运行你的代码的依赖项,你可以使用以下命令:

$ deno cache hello-http-server.js

这将做与 Deno 在运行你的代码之前完全相同的事情;唯一的区别是它不会运行。由于这个原因,我们可以建立deno cache命令和 Node 上npm install所做的操作之间的并行性。

重新加载缓存

cacherun命令可以使用--reload标志来强制下载依赖项。可以使用--reload标志的参数发送需要重新加载的模块的逗号分隔列表:

$ deno cache hello-http-server.js --reload=https://deno.land/std@0.83.0/http/server.ts
Download https://deno.land/std@0.83.0/http/server.ts

在前面的示例中,只有来自deno.land/std@0.83.0/http/server.ts的模块会被重新下载,正如我们可以通过查看命令的输出确认的那样。

最后运行服务器

既然依赖项已经下载,那么阻止我们运行服务器的东西就是一个PermissionDenied错误:

error: Uncaught PermissionDenied: network access to "0.0.0.0:8080", run again with the --allow-net flag

现在,让我们遵循建议并添加--allow-net标志,这将授予我们的程序完全的网络访问权限。我们将在本章后面讨论权限:

$ deno run --allow-net hello-http-server.js

提示(Windows)

请注意,如果你使用的是 Windows,你可能会遇到 Windows 本地的网络授权弹窗,通知你有一个程序(Deno)正在尝试访问网络。如果你想让这个 Web 服务器能够运行,你应该点击允许访问

现在,我们的服务器应该正在运行。如果我们用curl访问端口8080,它会显示Hello Deno

$ curl localhost:8080
Hello deno

这是我们最简单的 Web 服务器的结束;我们将在几页后回到这个话题。

管理依赖项

如果你曾经使用过其他工具,甚至是 Node.js 本身,你可能会觉得代码中到处都是 URL 不太直观。我们也可以争论说,通过直接在代码中写入 URL,我们可能会造成一些问题,比如同一个依赖项有两个不同的版本,或者 URL 有拼写错误。

Deno 通过摒弃复杂的模块解析策略,使用 plain JavaScript 和绝对导入来解决这个问题。

跟踪依赖项的提议解决方案,不过就是一个建议,那就是使用一个导出所有所需依赖项的文件,并将其放在一个包含 URL 的单一文件中。让我们看看它是如何工作的。

创建一个名为deps.js的文件,并在其中添加我们的依赖项,导出我们需要的那些:

export { serve } from 
"https://deno.land/std@0.83.0/http/server.ts";

使用前面的语法,我们从标准库的 HTTP 服务器中导入了serve方法。

回到我们的hello-http-server.js文件,我们现在可以更改导入,以便我们可以从deps.js文件中使用导出的函数:

import { serve } from "./deps.js";
for await (const req of serve(":8080")) {
  req.respond({ body: "Hello deno" });
}

现在,每当我们添加一个依赖项时,我们可以运行deno cache deps.js来保证我们有一个模块的本地副本。

这是 Deno 管理依赖项的方式。就是这么简单——没有魔法,没有复杂的标准,只是一个导入和导出符号的文件。

完整性检查

既然你知道了如何导入和管理第三方依赖项,你可能觉得还缺少了一些东西。

怎样才能保证下次我们、同事,甚至是 CI 在尝试安装项目时,我们的依赖项没有发生变化呢?

这是一个公平的问题,而且因为这是一个 URL,这可能会发生。

我们可以通过使用完整性检查来解决这个问题。

生成锁文件

Deno 具有一种可以通过使用 JSON 文件存储和检查子资源完整性的特性,这与使用锁文件方法的其他技术类似。

要创建我们的第一个锁文件,请运行以下命令:

$ deno cache --lock=lock.json --lock-write deps.js 

使用--lock标志,我们选择文件的名称,通过使用--lock-write,我们正在给 Deno 创建或更新该文件的权限。

查看生成的lock.json文件,我们会在那里找到以下内容:

{
    "https://deno.land/std@0.83.0/_util/assert.ts":    "e1f76e77c5ccb5a8e0dbbbe6cce3a56d2556c8cb5a9a8802fc9565 af72462149",
    "https://deno.land/std@0.83.0/async/deferred.ts":    "ac95025f46580cf5197928ba90995d87f26e202c19ad961bc4e317 7310894cdc",
    "https://deno.land/std@0.83.0/async/delay.ts":    "35957d585a6e3dd87706858fb1d6b551cb278271b03f52c5a2cb70 e65e00c26a",

它生成一个 JSON 对象,其中键是依赖项的路径,值是 Deno 用来保证资源完整性的哈希值。

这个文件应该随后被提交到你的版本控制系统。

在下一节中,我们将学习如何安装依赖项,并确保每个人都运行着完全相同的代码版本。

使用锁文件安装依赖项

一旦锁文件被创建,任何想要下载代码的人都可以运行带有--lock标志的 cache 命令。这在你下载依赖项时启用完整性检查:

$ deno cache --reload --lock=lock.json deps.js

还可以使用run命令的--lock标志来启用运行时验证:

$ deno run --lock=lock.json --allow-net hello-http-server.js

重要提示

当使用run命令的锁标志时,包含尚未缓存的依赖关系的代码将不会与锁文件进行核对。

为了确保在运行时检查新的依赖关系,我们可以使用--cached-only标志。

这样,如果任何不在lock.json文件中的依赖关系被我们的代码使用,Deno 将会抛出一个错误。

这就是我们确保运行我们想要的依赖关系的确切版本,消除可能由于版本更改而出现的问题的所有工作。

导入映射

Deno 支持导入映射(github.com/WICG/import-maps)。

如果你不熟悉它们是什么,我会为你简要解释一下:它们用于控制 JavaScript 导入。如果你之前用过像 webpack 这样的 JavaScript 代码打包工具,那么这是一个类似于你所知的“别名”的功能。

重要提示

这个特性目前是不稳定的,因此必须使用--unstable标志来启用。

让我们创建一个 JSON 文件。这里文件的名字无关紧要,但为了简单起见,我们将它命名为import-maps.json

在这个文件中,我们将创建一个带有imports键的 JavaScript 对象。在这个对象中,任何键将是模块名称,任何值将是真实的导入路径。我们第一个导入映射将是将http单词映射到标准库 HTTP 模块的根部的映射:

{
  "imports": {
    "http/": "https://deno.land/std@0.83.0/http/"
  }
}

这样做后,我们现在可以在我们的deps.js文件中导入标准库的 HTTP 模块,像这样:

export { serve } from "http/server.ts"; 

运行它时,我们将使用--import-map标志。这样做时,我们可以选择包含导入映射的文件。然后,因为这个特性仍然不稳定,我们必须使用--unstable标志:

$ deno run --allow-net --import-map=import-maps.json --unstable hello-http-server.js

正如我们所看到的,我们的代码运行得非常完美。

这是一个轻松定制模块解析,且不依赖于任何外部工具的方法。它也已经被提议作为添加到浏览器中的内容。希望这个功能能在不久的将来被接受。

检查模块

我们刚刚使用了标准库的 HTTP 模块来创建一个服务器。如果你还不是非常熟悉标准库,不用担心;我们将在下一章更详细地解释它。现在,我们只需要知道我们可以在其网站上探索它的模块(deno.land/std)。

让我们看看前一个脚本中使用的模块,HTTP 模块,并使用 Deno 了解更多关于它的信息。

我们可以使用info命令来完成这个:

$ deno info https://deno.land/std@0.83.0/http/server.ts
local:/Users/alexandre/Library/Caches/deno/deps/https/deno.land/2d926cfeece184c4e5686c4a94b44c9d9a3ee01c98bdb4b5e546dea4 e0b25e49
type: TypeScript
compiled: /Users/alexandre/Library/Caches/deno/gen/https/deno.land/2d926cfeece184c4e5686c4a94b44c9d9a3ee01c98bdb4b5e546dea4 e0b25e49.js
deps: 12 unique (total 63.31KB)
https://deno.land/std@0.83.0/http/server.ts (10.23KB)
├── https://deno.land/std@0.83.0/_util/assert.ts *
├─┬ https://deno.land/std@0.83.0/async/mod.ts (202B)
│ ├── https://deno.land/std@0.83.0/async/deferred.ts *
│ ├── https://deno.land/std@0.83.0/async/delay.ts (279B)
│ ├─┬ 
…
│    └── https://deno.land/std@0.83.0/encoding/utf8.ts *
└─┬ https://deno.land/std@0.83.0/io/bufio.ts (21.15KB)
    https://deno.land/std@0.83.0/_util/assert.ts (405B)
    https://deno.land/std@0.83.0/bytes/mod.ts (4.34KB)

这个命令列出了关于 HTTP 模块的大量信息。让我们逐一分析。

在第一行,我们获取脚本的缓存版本的路径。在那之后的一行,我们看到文件的类型。我们已经知道标准库是用 TypeScript 编写的,所以这应该不会让我们感到惊讶。下一行也是一个路径,这次是模块的编译版本的路径,因为 TypeScript 模块在下载步骤中编译为 JavaScript。

命令输出的最后部分是依赖树。通过查看它,我们可以快速识别它只是链接到标准库中的其他模块。

提示

我们可以使用--unstable--json标志与deno info一起使用,以获得一个可以通过编程方式访问的 JSON 输出。

当使用第三方模块时,我们不仅需要知道它们依赖什么,还需要知道模块提供了哪些函数和对象。我们将在下一节学习这一点。

探索文档

文档是任何软件项目的一个重要方面。Deno 在这方面做得很好,所有 API 的文档都维护得很好,TypeScript 在这方面提供了很大的帮助。由于标准库和运行时函数都是用 TypeScript 编写的,因此大部分文档都是自动生成的。

文档可在doc.deno.land/找到。

如果你不能访问互联网并且想要访问你本地安装模块的文档,Deno 可以为你提供帮助。

许多编辑器,尤其是 VS Code,允许你这样做,著名的Cmd/Ctrl + 点击就是一个例子。然而,Deno 不依赖编辑器特性来实现这一点,因为doc命令提供了你将需要的所有基本功能。

让我们来看看标准库的 HTTP 模块的文档:

$ deno doc https://deno.land/std@0.83.0/http/server.ts
function _parseAddrFromStr(addr: string): HTTPOptions
    Parse addr from string
async function listenAndServe(addr: string | HTTPOptions, handler: (req: ServerRequest) => void): Promise<void>
    Start an HTTP server with given options and request handler
async function listenAndServeTLS(options: HTTPSOptions, handler: (req: ServerRequest) => void): Promise<void>
    Start an HTTPS server with given options and request 
      handler
function serve(addr: string | HTTPOptions): Server
    Create a HTTP server
...

我们现在可以看到暴露的方法和类型。

在我们之前的某个程序中,我们使用了serve方法。为了了解更多关于这个特定方法的信息,我们可以将方法(或任何其他符号)名称作为第二个参数发送:

$ deno doc https://deno.land/std@0.83.0/http/server.ts serve
Defined in https://deno.land/std@0.83.0/http/server.ts:282:0
function serve(addr: string | HTTPOptions): Server
    Create a HTTP server
        import { serve } from         "https://deno.land/std/http/server.ts";
        const body = "Hello World\n";
        const server = serve({ port: 8000 });
        for await (const req of server) {
          req.respond({ body }); add 
        }

这是一个非常有用的功能,它使开发者能够在不依赖编辑器的情况下浏览本地安装模块的文档。

正如我们在下一章将要学习的那样,通过使用 REPL,你可能会注意到 Deno 有一个内置的 API。要查看其文档,我们可以运行以下命令:

$ deno doc --builtin

输出的内容将会非常庞大,因为它列出了所有的公共方法和类型。

在*nix 系统中,这可以很容易地通过管道传送到像less这样的应用程序:

$ deno doc --builtin | less

与远程模块类似,也可以通过方法名进行过滤。例如,Deno 命名空间中存在的writeFile函数:

$ deno doc --builtin Deno.writeFile
Defined in lib.deno.d.ts:1558:2
function writeFile(path: string | URL, data: Uint8Array, options?: WriteFileOptions): Promise<void>
  Write `data` to the given `path`, by default creating a new file if needed,
  else overwriting.
  ```ts

const encoder = new TextEncoder();

const data = encoder.encode("Hello world\n");

await Deno.writeFile("hello1.txt", data);  // 覆盖"hello1.txt"或创建它

await Deno.writeFile("hello2.txt", data, {create: false});  // 只有当"hello2.txt"存在时才有效

await Deno.writeFile("hello3.txt", data, {mode: 0o777});  // 设置新文件的权限

await Deno.writeFile("hello4.txt", data, {append: true});  // 将数据添加到文件的末尾

```js
 Requires `allow-write` permission, and `allow-read` if `options.create` is `false`.

doc命令是开发工作流程中的一个有用部分。然而,如果你能访问互联网并且想要以更易消化和视觉化的方式访问它,应该去doc.deno.land/

你可以使用文档网站了解更多关于内置 API 或标准库模块的信息。此外,它还允许你显示任何可用的模块的文档。为此,我们只需将模块 URL 的://部分替换为一个反斜杠\,并在 URL 前加上doc.deno.land/

例如,要访问 HTTP 模块的文档,URL 将是 doc.deno.land/https/deno.land/std@0.83.0/http/server.ts

如果你导航到那个 URL,将显示一个干净的界面,包含模块的文档。

现在我们知道如何使用和探索第三方模块。然而,当我们开始编写我们的应用程序时,可能有一些工具我们要在各个项目中共享。我们可能还想让那个特定的包在我们系统的每个地方都可用。下一节将帮助我们做到这一点。

运行和安装脚本

在他最早的几次演讲中,在 Deno 的第一个版本发布说明中(deno.land/posts/v1#a-web-browser-for-command-line-scripts),Dahl 使用了我非常喜欢的一句话:

“Deno 是命令行脚本的网络浏览器。”

每当我使用 Deno 时,这句话变得越来越有意义。我确信随着本书的进行,它也会对你有意义。让我们更深入地探索一下。

在浏览器中,当你访问一个 URL 时,它会运行那里的代码。它解释 HTML 和 CSS,然后执行一些 JavaScript。

Deno,遵循其作为脚本浏览器的前提,只需要一个 URL 来运行代码。让我们看看它是如何工作的。

老实说,这与我们之前已经做过的事情并没有太大区别。作为复习,上次我们执行简单的 Web 服务器时,我们做了以下事情:

$ deno run --allow-net --import-map=import-maps.json --unstable hello-http-server.js

在这里,hello-http-server.js只是一个当前文件夹中的文件。

让我们尝试用一个远程文件来做同样的事情——一个通过 HTTP 提供服务的文件。

我们将从 Deno 标准库的示例集中执行一个“回声服务器”。你可以在这里查看这个代码(deno.land/std@0.83.0/examples/echo_server.ts)。这是一个回声服务器,无论发送给它什么都会回显:

$ deno run --allow-net https://deno.land/std@0.83.0/examples/ echo_server.ts
Download https://deno.land/std@0.83.0/examples/echo_server.ts
Check https://deno.land/std@0.83.0/examples/echo_server.ts
Listening on 0.0.0.0:8080

重要提示

如果你使用的是 Windows 系统,可能无法访问0.0.0.0:8080;你应该访问localhost:8080 instead. 它们都指的是你本地机器上的同一件事。然而,当0.0.0.0出现在本书的其他部分时,如果你正在运行 Windows,你应该尝试访问localhost

碰巧的是,每次文件没有被缓存时,Deno 都会下载并执行它们。

它与网络浏览器区别有多大?我认为没有太大区别。我们给了它一个 URL,它运行了代码。

为了确保它正常工作,我们可以建立一个 Telnet 连接(en.wikipedia.org/wiki/Telnet)并发送服务器回显的消息:

$ telnet 0.0.0.0 8080
Trying 0.0.0.0...
Connected to 0.0.0.0.
Escape character is '^]'.
hello buddy
hello buddy

您可以使用任何可用的 Telnet 客户端;在这里,我们使用了一个通过 Homebrew(brew.sh/)安装的 macOS 客户端。第一个“hello buddy”是我们发送的消息,而后一个是回显的消息。通过这个,我们可以验证回显服务器是否正常工作。

重要说明

如果您使用任何其他的 telnet 客户端,请确保您启用了“本地行编辑”设置。一些客户端默认不启用此设置,并且在你输入字符时发送字符,导致消息中出现重复的字符。下面的图片展示了如何在 Windows 上的 PuTTY 中配置这个设置。

图 2.4 – PuTTY 本地行编辑设置

图 2.4 – PuTTY 本地行编辑设置

这证实了我们之前所说的,即 Deno 用相同的方法运行代码和解决模块:它以类似的方式处理本地和远程代码。

安装实用脚本

有些实用程序我们写一次,而有些我们多次使用。有时,为了方便重用,我们只是将那些脚本从一个项目复制到另一个项目。对于其他的,我们保存在一个 GitHub 仓库中,并且一直去那里获取它们。我们最常使用的可能需要被包装在 shell 脚本中,添加到/usr/local/bin(在*nix 系统上)并在我们的系统上使其可用。

为此,Deno 提供了install命令。

这个命令将一个程序包装在一个薄的壳脚本中,并将其放入安装的 bin 目录中。脚本的权限在安装时设置,此后不再询问:

$ deno install --allow-net --allow-read https://deno.land/std@0.83.0/http/file_server.ts

在这里,我们使用了标准库中的另一个模块叫做file_server。它创建了一个 HTTP 服务器来服务当前目录。您可以通过访问导入 URL(deno.land/std@0.83.0/http/file_server.ts)看到它的代码。

安装命令将在您的系统上使file_server脚本可用。

为了给它一个除了file_server之外的名称,我们可以使用-n标志,如下所示:

$ deno install --allow-net --allow-read -n serve https://deno.land/std@0.83.0/http/file_server.ts 

现在,让我们服务当前目录:

$ serve
HTTP server listening on http://0.0.0.0:4507/

如果我们访问http://localhost:4507,我们会得到以下内容:

图 2.5 – Deno 文件服务器网页

图 2.5 – Deno 文件服务器网页

这适用于远程 URL,但也可以用于本地 URL。如果您有一个用 Deno 编写的程序,您想要将其转换为可执行文件,您也可以使用install命令来完成。

我们可以用我们简单的 Web 服务器来做这件事,例如:

$ deno install --allow-net --unstable hello-http-server.js

通过运行前面的代码,创建了一个名为hello-http-server的脚本,并在我们的系统中可用。

这就是我们执行本地和远程脚本所需的一切。Deno 使这非常容易,因为它以非常直接的方式处理导入和模块,非常类似于浏览器。

以前,我们使用权限允许脚本访问网络或文件系统等资源。在本节中,我们使用权限与install命令一起使用,但我们之前也这样使用过run命令。

到现在,你可能已经理解了它们是如何工作的,但我们在下一节会更详细地了解它们。

权限

当我们几页前编写我们的第一个 HTTP 服务器时,我们第一次遇到了 Deno 的权限。当时,我们必须给我们的脚本授予访问网络的权限。从那时起,我们多次使用它们,但并不太了解它们是如何工作的。

在本节中,我们将探讨权限是如何工作的。我们将了解存在哪些权限以及如何配置它们。

如果我们运行deno run --help,我们将获得run命令的帮助输出,其中列出了某些权限。为了使这更方便您,我们将列出所有现有的权限并提供每个的简要说明。

-A, --allow-all

这关闭了所有权限检查。带有此标志运行代码意味着它将拥有用户所有的访问权限,与 Node.js 默认行为非常相似。

在运行此代码时请小心,尤其是当代码不是你自己的时候。

--allow-env

这赋予了访问环境的能力。它用于程序可以访问环境变量。

--allow-hrtime

这赋予了访问高分辨率时间管理的能力。它可以用于精确的基准测试。给予错误的脚本这个权限可能会允许指纹识别和时序攻击。

--allow-net=<域名>

这赋予了访问网络的能力。如果没有参数,它允许所有的网络访问。如果有参数,它允许我们传递一个由逗号分隔的列表的域名,其中网络通信将被允许。

--allow-plugin

这允许加载插件。请注意,这仍然是一个不稳定的特性。

--allow-read=<路径>

这赋予了文件系统的读取权限。如果没有参数,它授予用户可以访问的一切。如果有参数,这只允许访问由逗号分隔的列表提供的文件夹。

--allow-run

这赋予了运行子进程的能力(例如,使用Deno.run)。请记住,子进程不是沙盒化的,应该谨慎使用。

--allow-write=<路径>

这赋予了文件系统的写入权限。如果没有参数,它授予用户可以访问的一切。如果有参数,它只允许访问由逗号分隔的列表提供的文件夹。

每次程序运行且没有正确的权限时,都会抛出一个PermissionError

权限在runinstall命令中使用。它们之间的唯一区别是授予权限的时刻。对于run,您必须在运行时授予权限,而对于install,您在安装脚本时授予权限。

对于 Deno 程序,还有一种获取权限的方式。它不需要预先授予权限,而是会在需要时请求它们。我们将在下一章中探讨这一特性,届时我们将学习 Deno 的命名空间。

就这样!除了权限之外,真的没有太多可以添加的内容,因为它是 Deno 中的一个非常重要的功能,它默认沙盒化我们的代码,并让我们决定我们的代码应该具有哪些访问权限。我们将在本书中编写应用程序时继续使用权限。

到目前为止,我们已经学习了如何运行、安装和缓存模块,以及如何使用权限。随着我们编写和运行更复杂的程序,开始需要对它们进行测试。我们可以使用test命令来实现,正如我们将在下一节中学到的。

使用测试命令

作为主二进制文件的一部分,Deno 还提供了一个测试运行器。这个命令的名字意料之中地叫做test。在本节中,我们将探索它并运行几个测试。

在本节中,我们将主要探索命令本身,而不是测试语法。我们将更详细地探讨该语法的语法和最佳实践,这将是在本书后面的一个专章中进行。

test命令根据{*_,*.,}test.{js,mjs,ts,jsx,tsx}通配符表达式查找要运行的文件。

由于通配符表达式可能不太直观,我们将简要解释它们。

它匹配任何具有jsmjstsjsxtsx扩展名的文件,并且文件名中包含test,前面有一个下划线(_)或点(.

以下是一些将匹配表达式并被认为是要测试的文件示例:

  • example.test.ts

  • example_test.js

  • example.test.jsx

  • example_test.mjs

Deno 测试也在沙盒环境中运行,因此它们需要权限。查看上一节以了解更多关于如何做到这一点的信息。

在运行测试时,也可以使用我们在本章前面学到的调试命令。

过滤测试

当你有一个完整的测试套件时,一个常见的需求是只运行其中的特定部分。为此,test命令提供了--filter标志。

想象我们有一个以下文件,其中定义了两个测试:

Deno.test("first test", () => {});
Deno.test("second test", () => {});

如果我们只想运行其中的一个,我们可以使用--filter标志,并通过传递一个字符串或模式来匹配测试名称:

$ deno test --filter second
running 1 tests
test second test ... ok (3ms)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out (3ms)

前面的代码只是运行了与过滤器匹配的测试。当我们在开发代码库的小部分测试时,这个特性非常有用,我们希望能够快速反馈关于这个过程的信息。

快速失败

在诸如持续集成服务器等环境中,如果真的不关心有多少测试失败,我们可能希望快速失败,只要测试阶段结束即可。

要做到这一点,我们可以使用 --fail-fast 标志。

这就是我们现在所需要了解的所有关于测试的内容。正如我们之前提到的,我们将在第八章,测试 - 单元和集成中回到测试主题。我们只是想在这里熟悉一下 CLI 命令。

我们认为测试是一个保证我们的代码正在运行的工具,同时也是记录我们代码行为的手段。测试是任何正在运行和发展的代码库的基础,Deno 通过在其二进制文件中包含一个测试运行器,使它们成为一等公民。然而,测试只是更大工具集的一部分——一个包括诸如代码审查和格式化等开发者需求的部分。

在下一节中,我们将了解 Deno 如何解决这些问题。

代码审查和格式化

代码审查和格式化是维护代码一致性和强制执行良好实践的两个被认为至关重要的能力。怀着这样的想法,Deno 在其 CLI 中集成了这两个工具。我们将在这一节中了解它们。

格式化

要格式化 Deno 的代码,CLI 提供了 fmt 命令。这是一个有观点的格式化器,旨在解决任何关于代码格式化的疑问。主要目标是让开发者在编写代码时不必关心代码的格式,在审查拉取请求时也不必关心。

运行以下命令(无参数)将格式化当前目录中的所有文件:

$ deno fmt
/Users/alexandre/Deno-Web-Development/Chapter02/my-first-deno-program.js
/Users/alexandre/Deno-Web-Development/Chapter02/bundle.js

如果我们想要格式化一个单独的文件,我们可以把它作为一个参数发送。

要检查文件的格式化错误,我们可以使用这个与 --check 标志一起,它将把我们文件中找到的错误输出到 stdout。

忽略行和文件

要使格式化器忽略一行或整个文件,我们可以使用 ignore 注释:

// deno-fmt-ignore
const book = 'Deno 1.x – Web Development'; 

使用 deno-fmt-ignore 忽略了注释后面的行:

// deno-fmt-ignore-file
const book = 'Deno 1.x – Web Development';
const editor = 'PacktPub'

使用deno-fmt-ignore-file将忽略整个文件。

代码审查

仍然在 unstable 标志下,lint 命令将我们在代码中找到的警告和错误打印到 stdout。

让我们通过运行名为 to-lint.js 的脚本的代码审查器来实际看看它。你可以对任何你想要的东西运行它。在这里,我们只是用一个会抛出错误的文件,因为它包含了一个 debugger:

$ deno lint --unstable to-lint.js
(no-debugger) `debugger` statement is not allowed
  debugger;
    ~~~~~~~~~
    at /Users/alexandre/dev/personal/Deno-Web-Development/Chapter02/to-lint.js:4:2
Found 1 problems

在这一节中,我们学习了如何使用 fmtlint 命令来维护代码一致性和最佳实践。

这些是 Deno CLI 提供的命令之一,在我们编写 Deno 程序的日常生活中将会使用到。它们两个都碰巧是非常有观点的,所以没有空间支持不同的标准。这应该不足为奇,因为 Deno 深受golang的启发,这种方法与gofmt等工具所能做到的相一致。

有了这个,我们知道如何格式化和检查我们的代码以遵循最佳实践。将这个添加到我们前几部分所学习的内容,没有什么能阻止我们在生产环境中运行我们的代码。

当我们进入生产环境时,我们显然希望我们的服务器尽可能快。在前一章节中,我们了解到 Deno 最慢的部分之一是 TypeScript 解析。当我们编写 TypeScript 代码时,我们不希望每次服务器启动时都牺牲时间去解析它。同时,由于我们编写干净、独立的模块,我们不希望将它们分别发送到生产环境。

这就是为什么 Deno 提供了一个允许我们将代码捆绑到单个文件的功能。我们将在下一节了解这个功能。

捆绑代码

在前一章节中,当我们介绍 Deno 时,我们选择了捆绑代码作为一个激动人心的特性,原因有很多。这个特性有巨大的潜力,我们将在第七章中更详细地探索这个特性,HTTPS、提取配置和 Deno 在浏览器中。但由于我们现在正在探索 CLI,我们将了解适当的命令。

它被称为bundle,它将代码捆绑成单个、自包含的 ES 模块。

不依赖于 Deno 命名空间的捆绑代码也可以在浏览器中使用<script type="module">和在 Node.js 中运行。

让我们用它来构建我们的get-current-time.js脚本:

$ deno bundle get-current-time.js bundle.js
Bundle file:///Users/alexandre/dev/deno-web-development/Chapter02/2-hello-world/get-current-time.js
Emit "bundle.js" (2.33 KB)

现在,我们可以运行生成的bundle.js

$ deno run bundle.js
0:11:4

这将打印当前时间。

由于它兼容 ES6 的 JavaScript(你需要安装 Node.js 才能运行下面的命令),我们也可以用 Node.js 来执行它:

$ node bundle.js
0:11:4

为了在浏览器中使用同样的代码,我们可以创建一个名为index-bundle.html的文件,并导入我们生成的捆绑包:

<!DOCTYPE html>
<html>
  <head>
    <title>Deno bundle</title>
  </head>
  <body>
    <script type="module" src="img/bundle.js"></script>
  </body>
</html>

有了前一部分所获得的知识,我们可以在当前文件夹中运行标准库的文件服务器:

$ deno run --allow-net --allow-read https://deno.land/std@0.83.0/http/file_server.ts
HTTP server listening on http://0.0.0.0:4507/ 

现在,如果你导航到http://localhost:4507/index-bundle.html,并打开浏览器控制台,你会发现当前时间已经被打印出来了。

捆绑是一个非常有前途的功能,我们将在第七章、HTTPS、提取配置和 Deno 在浏览器中中进一步探索。它允许我们将应用程序创建成单个 JavaScript 文件。

我们稍后会回到这个问题,并在本书的后面部分向你展示它所启发的功能。捆绑是一个很好的分发你的 Deno 应用程序的方式,正如我们在这个章节所看到的。但是如果你想要将你的应用程序分发到可以运行在非你的电脑上呢?bundle命令是否为我们实现了这个功能?

嗯,实际上并不是。如果代码将要执行的地方安装了 Node、Deno 或一个浏览器,它就会这样做。

但如果它没有呢?这就是我们接下来要学习的内容。

编译成二进制

当 Deno 最初推出时,Dahl 表示其目标之一是能够将 Deno 代码作为单个二进制文件发货,类似于 golang 的做法,从第一天开始。这与 nexe (github.com/nexe/nexe) 或 pkg (github.com/vercel/pkg) 的工作非常相似,后者为 Node 提供服务。

这与捆绑功能不同,后者会生成一个 JavaScript 文件。当你将 Deno 代码编译成二进制文件时,所有的运行时和代码都包含在那个二进制文件中,使其自给自足。一旦你编译好了,你就可以把这个二进制文件发送到任何地方,然后就能执行它。

重要提示

在撰写本文时,这仍然是一个具有许多限制的不稳定功能,如其在 https://deno.land/posts/v1.7#improvements-to-codedeno-compilecode 中所述。

这个过程非常简单。让我们看看我们是如何做到的。

我们只需要使用compile命令。对于这个例子,我们将使用前面章节中使用的脚本,即get-current-time.js

$ deno compile --unstable get-current-time.js
Bundle file:///Users/alexandre/dev/Deno-Web-Development/Chapter02/get-current-time.js
Compile file:///Users/alexandre/dev/Deno-Web-Development/Chapter02/get-current-time.js
Emit get-current-time

这会生成一个名为get-current-time的二进制文件,我们可以现在执行它:

$ ./get-current-time
16:10:8

这正在工作!这个功能使我们能够轻松地分发应用程序。这是可能的,因为它包括了代码及其所有依赖项,包括 Deno 运行时,使其自给自足。

随着 Deno 的不断发展,新的功能、bug 修复和改进将会被添加。以每个季度发布几个版本的速度,你可能会想要升级我们使用的 Deno 版本是非常常见的。CLI 也提供了这个命令。我们将在下一节学习这个。

使用升级命令

我们开始这一章的学习是如何安装 Deno,我们安装了运行时的单个版本。但 Deno 在不断地发布 bug 修复和改进——尤其是在这些早期版本中。

当有新的更新时,我们可以使用安装 Deno 时使用的相同包管理器来升级它。然而,Deno CLI 提供了一个命令,它可以用来升级自己。该命令称为upgrade,可以与--version标志一起使用,以选择我们要升级到的版本:

$ deno upgrade --version=1.7.4

如果没有提供版本,默认为最新版本。要在另一个位置安装新版本,而不是替换当前安装,可以使用--output标志,如下所示:

$ deno upgrade --output $HOME/my_deno

就是这样——upgrade是遵循 Deno 哲学提供编写和维护应用程序所需的一切的另一个工具,而那个周期中肯定包括更新我们的运行时。

总结

在本章中,我们的主要焦点是了解 Deno 提供的工具,包括其主二进制文件中的那些工具。这些工具将在我们的日常生活中和本书的其余部分被大量使用。

我们首先安排了我们的环境和编辑器,然后深入了解了工具链。

然后,我们编写了并执行了一个eval命令,作为启用实验和无需文件运行代码的方式。之后,我们查看了模块系统。我们不仅导入了并使用了模块,还深入了解了 Deno 如何下载并在本地缓存依赖。

在熟悉了模块系统之后,我们学习了如何管理外部依赖,即锁文件和完整性检查。我们不得不在这个部分稍微提一下一个仍然不稳定但很有前景的功能:导入映射。

之后,我们利用info命令的帮助,探索了一些第三方模块及其代码和依赖。Deno 没有忽视文档,我们还学会了如何使用documentation命令和相应的网站查看第三方代码文档。

由于脚本在 Deno 中是第一公民,我们探索了允许我们从 URL 直接运行代码并全局安装实用脚本的命令。

整本书中,我们都提到了权限是 Deno 的一大特色。在这一章,我们学习了如何在运行代码时使用权限来微调其权限。

接下来,我们学习了测试运行器,以及如何运行和筛选测试。我们还了解了一个功能,即如何根据 Deno 的标准格式化和校对我们的代码。我们了解了fmtlint命令,这两个带有观点的工具确保开发者不必担心格式化和校验,因为它们是自动处理的。

最后,我们介绍了bundlecompile命令。我们学会了如何将我们的代码打包成一个 JavaScript 文件,以及如何生成一个包含我们的代码和 Deno 运行时的二进制文件,使其自给自足。

这一章涵盖了大量的有趣内容。我保证接下来会更令人兴奋。在下一章,我们将了解标准库,并学会使用它来编写简单的应用程序,同时了解 Deno 的 API。

兴奋吗?让我们开始吧!

第三章:运行时和标准库

既然我们已经足够了解 Deno,那么我们就可以用它来编写一些真正的应用程序。在本章中,我们将不使用任何库,因为其主要目的是介绍运行时 API 和标准库。

我们将编写小型 CLI 工具、Web 服务器等,始终利用官方 Deno 团队创建的力量,没有外部依赖。

我们将从 Deno 命名空间开始,因为我们认为首先探索运行时包含的内容是有意义的。按照这个想法,我们还将查看 Deno 与浏览器共享的 Web API。我们将使用setTimeoutaddEventListenerfetch等。

仍然在 Deno 命名空间中,我们将了解程序的生命周期,与文件系统交互,并构建小型命令行程序。后来,我们将了解缓冲区,并理解它们如何用于异步读写。

我们将简要介绍标准库,并浏览一些有用的模块。这一章并不旨在取代标准库的文档;它将展示标准库的一些功能和用例。在编写小型程序的过程中,我们将了解它。

在穿越标准库的旅程中,我们将使用与文件系统、ID 生成、文本格式化和 HTTP 通信相关的模块。其中一部分将是我们稍后深入探索的介绍。您将通过编写您的第一个 JSON API 并连接到它来完成本章。

以下是我们将在本章中涵盖的主题:

  • Deno 运行时

  • 探索 Deno 命名空间

  • 使用标准库

  • 使用 HTTP 模块构建 Web 服务器

技术要求

本章的所有代码文件可以在以下 GitHub 链接找到:github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter03

Deno 运行时

Deno 提供了一组函数,这些函数作为全局变量包含在Deno命名空间中。运行时 API 在doc.deno.land/上进行文档化,可以用来做最基本的、底层的事情。

在 Deno 中,无需导入即可使用两种类型的函数:Web API 和Deno命名空间。每当 Deno 中存在与浏览器中相同的行为时,Deno 会模仿浏览器 API——这些是 Web API。由于您来自 JavaScript 世界,您可能对这些大部分都很熟悉。我们谈论的是诸如fetchaddEventListenersetTimeout等函数,以及windowEventconsole等对象 among others.

使用 Web API 编写的代码可以捆绑并在浏览器中运行,无需任何转换。

运行时暴露的 API 的大部分位于一个名为Deno的全局命名空间中。你可以使用 REPL 和文档,这两者我们在第二章中探讨过,工具链,来探索它并快速了解它包括哪些函数。在本章后面,我们还将尝试一些最常用的函数。

如果你想要访问 Deno 中包含的所有符号的文档,你可以使用带有--builtin标志的doc命令。

稳定性

Deno命名空间内的函数从版本 1.0.0 开始被认为是稳定的。这意味着 Deno 团队将努力在 newer versions 中支持它们,并将尽最大努力使它们与未来的变化保持兼容。

仍不稳定 features live under the --unstable flag,正如你可能会想到的那样,因为我们已经在之前的示例中使用过它们。

不稳定模块的文档可以通过使用doc命令的--unstable标志或通过访问doc.deno.land/builtin/unstable来获取。

标准库尚未被 Deno 团队认为是稳定的,因此它们的版本与 CLI 不同(在撰写本文时,它是版本 0.83.0)。

Deno命名空间函数相比,标准库通常不需要--unstable标志来运行,除非标准库中的任何模块正在使用来自Deno命名空间的 unstable functions。

程序生命周期

Deno 支持浏览器兼容的loadunload事件,可以用来运行设置和清理代码。

处理器可以以两种不同的方式编写:使用addEventListener和通过重写window.onloadwindow.onunload函数。load事件可以是异步的,但unload事件却不能取消,因此这是不正确的。

使用addEventListener可以注册无限数量的处理器;例如:

addEventListener("load", () => {
  console.log("loaded 1");
});
addEventListener("unload", () => {
  console.log("unloaded 1");
});
addEventListener("load", () => {
  console.log("loaded 2");
});
addEventListener("unload", () => {
  console.log("unloaded 2");
});
console.log("Exiting...");

如果我们运行前面的代码,我们得到以下输出:

$ deno run program-lifecycle/add-event-listener.js
Exiting...
loaded 1
loaded 2
unloaded 1
unloaded 2

另一种在设置和拆除阶段安排代码运行的方法是重写window对象的onloadonunload函数。这些函数的特点是只有最后一个分配的运行。这是因为它们互相覆盖;例如,请参见以下代码:

window.onload = () => {
  console.log("onload 1");
};
window.onunload = () => {
  console.log("onunload 1");
};
window.onload = () => {
  console.log("onload 2");
};
window.onunload = () => {
  console.log("onunload 2");
};
console.log("Exiting");

运行前面的程序后,我们得到了以下输出:

$ deno run program-lifecycle/window-on-load.js
Exiting
onload 2
onunload 2

如果我们然后查看我们最初编写的代码,我们可以理解前两个声明被跟在它们后面的两个声明覆盖了。当我们覆盖onunloadonload时,就会发生这种情况。

网络 API

为了展示我们可以像在浏览器中一样使用 Web API,我们将编写一个简单的程序,获取 Deno 网站的标志,将其转换为 base64,并在控制台打印一个包含图像 base64 的 HTML 页面。让我们按照以下步骤进行操作:

  1. deno.land/logo.svg开始请求:

    fetch("https://deno.land/logo.svg")
    
  2. 将其转换为blob

    fetch("https://deno.land/logo.svg")
      .then(r =>r.blob())
    
  3. blob对象中获取文本并将其转换为base64

    fetch("https://deno.land/logo.svg ")
      .then(r =>r.blob())
      .then(async (img) => {
        const base64 = btoa(
          await img.text()
        )
    });
    
  4. 向控制台打印一个包含图片标签的 HTML 页面,使用 Base64 图片:

    fetch("https://deno.land/logo.svg ")
      .then(r =>r.blob())
      .then(async (img) => {
    const base64 = btoa(
          await img.text()
        )
        console.log(`<html>
    <img src="img/svg+xml;base64,${base64}" />
    </html>
        `
        )
      })
    

    当我们运行这个时,我们得到了预期的输出:

    $ deno run --allow-net web-apis/fetch-deno-logo.js
    <html>
      <img src="data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9Ijgx My4xODQiIHdpZHRoPSI4MTMuMTUiIHhtbG5zPSJodHRwOi8vd3d3Lncz Lm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzIyMiI+PHBhdGggZD0ibTM3 NC41NzUuMjA5Yy0xLjkuMi04IC45LTEzLjUgMS40LTc4LjIgOC4yLTE1 NS4yIDQxLjMtMjE4IDkzLjktMTEuNiA5LjYtMzggMzYtNDcuNiA0Ny42 LTUyIDYyLjEtODIuNCAxMzEuOC05My42IDIxNC4zLTIuNSAxOC4z
    …
    

现在,借助*nix 的输出重定向功能,我们可以用我们脚本的输出创建一个 HTML 文件:

$ deno run --allow-net web-apis/fetch-deno-logo.js > web-apis/deno-logo.html

你现在可以检查这个文件,或者直接在浏览器中打开它来测试它是否有效。

你也可以运用前一章的知识,直接从 Deno 标准库运行一个脚本来服务当前文件夹:

$ deno run --allow-net --allow-read https://deno.land/std@0.83.0/http/file_server.ts web-apis
Check https://deno.land/std@0.65.0/http/file_server.ts
HTTP server listening on http://0.0.0.0:4507

然后,通过导航到http://localhost:4507/deno-logo.html,我们可以检查图像是否在那里并且有效:

图 3.1 - 使用 Base64 图像的 Deno.land 网页

图 3.1 - 使用 Base64 图像的 Deno.land 网页

这些都是 Deno 支持的 Web API 的例子。在这个特定例子中,我们使用了fetchbtoa,但本章还将使用更多。

请随意实验这些熟悉的 API, either by writing simple scripts or by using the REPL。在本书的其余部分,我们将使用来自 Web APIs 的已知函数。在下一节中,我们将了解 Deno 命名空间,那些只在内置 Deno 中工作的函数,以及通常提供更多低级行为的功能。

探索 Deno 命名空间

所有未通过 Web API 覆盖的功能都位于 Deno 命名空间下。这些功能是 Deno 独有的,例如,不能被捆绑以在 Node 或浏览器中运行。

在本节中,我们将探索一些这个功能。我们将构建一些小工具,模仿你每天使用的程序。

如果你想在我们动手之前探索一下可用的函数,它们可以在doc.deno.land/builtin/stable找到。

构建一个简单的 ls 命令

如果你曾经使用过*nix 系统的终端或者 Windows PowerShell,你可能对ls命令不陌生。简而言之,它列出了一个目录内的文件和文件夹。我们将要做的就是创建一个 Deno 工具,模仿ls的一些功能,也就是列出目录中的文件,并显示它们的一些详细信息。

原始命令有无数的标志,出于简洁原因,我们在这里不会实现。

我们决定显示的文件信息包括文件名、大小和最后修改日期。让我们开始动手:

  1. 创建一个名为list-file-names.js的文件,并使用Deno.readDir获取当前目录中的所有文件和文件夹的列表:

    for await (const dir of Deno.readDir(".")) {
      console.log(dir.name)
    }
    

    这将把当前目录中的文件打印在不同行上:

    readDir (https://doc.deno.land/builtin/stable#Deno.readDir) from the Deno namespace.As is mentioned in the documentation, it returns `AsyncInterable`, which we're looping through and printing the name of the file. As the runtime is written in TypeScript, we have very useful type completion and we know exactly what properties are present in every `dir` entry.Now, we want to get the current directory as a command-line argument.
    
  2. 使用Deno.argshttps://doc.deno.land/builtin/stable#Deno.args)来获取命令行参数。如果没有发送参数,使用当前目录作为默认值:

    const [path = "."] = Deno.args;
    for await (const dir of Deno.readDir(path)) {
      console.log(dir.name)
    }
    

    我们利用数组解构来获取Deno.args的第一个值,同时使用默认属性来设置path变量的默认值。

  3. 导航到demo-files文件夹(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter03/ls/demo-files)并运行以下命令:

    $ deno run --allow-read ../list-file-names.ts            
    file-with-no-content.txt
    .hidden-file
    lorem-ipsum.txt
    

    看起来它正在工作。它正在获取当前所在的文件夹中的文件并列出它们。

    现在我们需要获取文件信息以便显示它。

  4. 使用Deno.statdoc.deno.land/builtin/stable#Deno.stat)来获取有关文件的信息:

    padEnd so that the output is aligned. By running the program we just wrote, while in the Chapter03/Is folder (https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter03/ls/demo-files), we get the following output:
    
    

    deno run --allow-read index.ts ./demo-files

    12   7/4  .hidden

    96   7/4  folder

    96   7/4  second-folder

    5    7/4  my-best-file

    20   7/4  .file1

    0    7/4  .hidden-file

    
    

我们得到了作为参数发送的deno-files目录中的文件和文件夹列表,以及字节大小和创建的月份和日期。

在这里,我们使用已经知的必需的--allow-read标志来赋予 Deno 访问文件系统的权限。然而,在上一章中,我们提到了 Deno 程序请求权限的不同方式,我们称之为“动态权限”。接下来我们将学习这方面的内容。

使用动态权限

当我们自己编写 Deno 程序时,我们通常事先知道所需的权限。然而,当编写可能需要或不需要的权限的代码,或者编写交互式 CLI 工具时,一次性请求所有权限可能没有意义。这就是动态权限的目的。

动态权限允许程序在需要时请求权限,从而使得执行代码的人可以交互式地给予或拒绝特定的权限。

这是一个仍然不稳定的功能,因此其 API 可能会发生变化,但由于它所启用的潜在可能性,我认为它仍然值得提及。

您可以在doc.deno.land/builtin/unstable#Deno.permissions查看 Deno 的权限 API。

接下来我们要确保我们的ls程序请求文件系统的读取权限。让我们按照以下步骤进行:

  1. 在使用程序之前,使用Deno.permissions.request来请求读取权限:

    …
    const [path = "."] = Deno.args;
    await Deno.permissions.request({
      name: "read",
      path,
    });
    for await (const dir of Deno.readDir(path)) {
    …
    

    这请求了对程序将要运行的目录的权限。

  2. 在当前目录下运行程序并授予权限:

    g to the permission request command, we're granting it access to the current directory (.).We can now try to run the same program but denying the permissions this time.
    
  3. 运行程序并在当前目录下拒绝读取权限:

    $ deno run --unstable list-file-names-interactive-permissions.ts .
    Deno requests read access to ".". Grant? [g/d (g = grant, d = deny)] d
    error: Uncaught (in promise) PermissionDenied: read access to ".", run again with the --allow-read flag
        at processResponse (deno:core/core.js:223:11)
        at Object.jsonOpAsync (deno:core/core.js:240:12)
        at async Object.[Symbol.asyncIterator] (deno:cli/rt/30_fs.js:125:16)
        at async list-file-names-interactive-permissions.ts:10:18
    

    这就是动态权限的工作方式!

在这里,我们使用它们来控制文件系统的读取权限,但它们也可以用来请求运行时所有可用的权限(如第二章 工具链中所述)。在编写 CLI 应用程序时,它们非常有用,允许您交互式地调整正在运行的程序可以访问的权限。

使用文件系统 API

访问文件系统是我们编写程序时所需的基本需求之一。正如您在文档中可能已经看到的那样,Deno 提供了执行这些常见任务的 API。

决定与 Rust 核心标准化通信后,所有这些 API 都返回Uint8Array,解码和编码应由其消费者完成。这与 Node.js 有很大的不同,在 Node.js 中,一些函数返回转换后的格式,而其他函数则返回 blob、缓冲区等。

让我们探索这些文件系统 API 并读取一个文件的内容。

我们将使用TextDecoderDeno.readFile API 读取位于github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter03/file-system/sentence.txt的示例文件,如下脚本所示:

const decoder = new TextDecoder()
const content = await Deno.readFile('./sentence.txt');
console.log(decoder.decode(content))

您可以注意到我们使用了TextDecoder类,这是浏览器中存在的另一个 API。

不要忘记在运行脚本时使用--allow-read权限,以便它可以从文件系统中读取。

如果我们想将这个文件的内容写入另一个文件,我们可以使用writeFile

const content = await Deno.readFile("./sentence.txt");
await Deno.writeFile("./copied-sentence.txt", content)

请注意,由于我们使用从readFile获得的Uint8Array直接发送到writeFile方法,所以我们不再需要TextEncoder。记住在运行时使用--allow-write标志,因为它现在正在向文件系统写入。

正如你可能猜到的或在文档中读到的,Deno 正好提供了这样一个 API,即copyFile

await Deno.copyFile("./copied-sentence.txt", 
  "./using-copy-command.txt");

现在,你可能注意到了,我们在调用 Deno 命名空间函数时总是使用await

Deno 上的所有异步操作都返回一个承诺,这是我们这样做的主要原因。我们本可以使用等效的then语法在那里处理结果,但我们认为这样更易读。

其他用于删除、重命名、更改权限等的 API 也包含在 Deno 命名空间中,您可以在文档中找到它们。

重要提示

Deno 中的许多异步 API 都有一个等效的同步API,可以用于特定用例,在这些用例中,您希望阻塞进程并获取结果(例如,readFileSyncwriteFileSync等)。

使用缓冲区

缓冲区代表用于存储临时二进制数据的内存区域。它们通常用于处理 I/O 和网络操作。由于异步操作是 Deno 的优势之一,因此我们将在本节中探索缓冲区。

Deno 缓冲区与 Node 缓冲区不同。这是因为当 Node 被创建时,直到版本 4,JavaScript 中都没有对ArrayBuffers的支持。由于 Node 针对异步操作进行了优化(缓冲区真正闪耀的地方),其背后的团队不得不创建一个 Node 缓冲区来模拟本地缓冲区的行为。后来,ArrayBuffers被添加到语言中,Node 团队将现有的缓冲区迁移到利用它。目前它只是一个ArrayBuffers的子类。这个相同的缓冲区然后在 Node v10 中被弃用。由于 Deno 是最近创建的,它的缓冲区深度利用了ArrayBuffer

从 Deno.Buffer 读写

Deno 提供了一个动态长度的缓冲区,它是基于ArrayBuffer的固定内存分配实现的。缓冲区提供了类似队列的功能,其中数据可以被不同的消费者写入和读取。正如我们最初提到的,它们在网络和 I/O 等任务中得到了广泛应用,因为它们允许异步读写。

举个例子,假设你有一个正在写一些日志的应用程序,你想处理这些日志。你可以同步地处理它们,也可以让这个应用程序将日志写入一个缓冲区,然后有一个消费者异步地处理这些日志。

让我们为那种情况写一个小的程序。我们将写两个简短的程序。第一个将模拟一个产生日志的应用程序;第二个将使用缓冲区来消费这些日志。

我们首先编写模拟应用程序产生日志的代码。在github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter03/buffers/logs/example-log.txt,有一个文件,里面有一些示例日志我们将使用:

const encoder = new TextEncoder();
const fileContents = await Deno.readFile("./example-log.txt ");
const decoder = new TextDecoder();
const logLines = decoder.decode(fileContents).split("\n");
export default function start(buffer: Deno.Buffer) {
  setInterval(() => {
     const randomLine = Math.floor(Math.min(Math.random() *        1000, logLines.length));
     buffer.write(encoder.encode(logLines[randomLine]));
  },   100)
}

这段代码从示例文件中读取内容并将其分割成行。然后,它获取一个随机的行号,每 100 毫秒将那一行写入一个缓冲区。这个文件然后导出一个函数,我们可以调用它来开始“生成随机日志”。我们将在下一个脚本中使用这个功能来模拟一个产生日志的应用程序。

现在来到了有趣的部分:我们将按照这些步骤编写一个基本的日志处理器

  1. 创建一个缓冲区,并将其发送给我们刚刚编写的日志生产者的start函数:

    import start from "./logCreator.ts";
    const buffer = new Deno.Buffer();
    start(buffer);
    
  2. 调用processLogs函数来开始处理缓冲区中的日志条目:

    …
    start(buffer);
    processLogs();
    async function processLogs() {}
    

    正如你所看到的,processLogs函数会被调用,但是什么也不会发生,因为我们还没有实现一个程序来执行它。

  3. processLogs函数内部创建一个Uint8Array对象类型,并在那里读取缓冲区的内容:

    …
    async function processLogs() {
      const destination = new Uint8Array(100);
      const readBytes = await buffer.read(destination);
      if (readBytes) {
        // Something was read from the buffer
      }
    }
    

    文档(doc.deno.land/builtin/stable#Deno.Buffer)指出,当有东西要读取时,Deno.Bufferread函数返回读取的字节数。当没有东西可读时,缓冲区为空,它返回 null。

  4. 现在,在if内部,我们可以直接解码读取的内容,因为我们都知道它以Uint8Array格式存在:

    const decoder = new TextDecoder();
    …  
    if (readBytes) {
      const read = decoder.decode(destination);
    }
    
  5. 要在控制台上打印解码值,我们可以使用已知的console.log。我们还可以用不同的方式来实现,通过使用Deno.stdoutdoc.deno.land/builtin/stable#Deno.stdout)向标准输出写入。

    Deno.stdout是 Deno 中的一个writer对象(doc.deno.land/builtin/stable#Deno.Writer)。我们可以使用它的write方法将文本发送到那里:

    const decoder = new TextDecoder();
    const encoder = new TextEncoder();
    …  
    if (readBytes) {
      const read = decoder.decode(destination);
      await Deno.stdout.write(encoder.encode(`${read}\n`));
    }
    

    通过这样做,我们正在向Deno.stdout写入刚刚读取的值,并且在末尾添加一个换行符(\n),以便在控制台上更具可读性。

    如果我们保持这种方式,这个processLogs函数将只运行一次。由于我们希望在稍后再次运行此函数以检查buffer中是否还有更多日志,我们需要安排它稍后再次运行。

  6. 使用setTimeout在 100 毫秒后调用相同的processLogs函数:

    async function processLogs() {
      const destination = new Uint8Array(100);
      const readBytes = await buffer.read(destination);
      if (readBytes) {
        …
      }
      setTimeout(processLogs, 10);
    }
    

例如,如果我们打开example-log.txt文件,我们可以看到包含以下格式的日期的行:Thu Aug 20 22:14:31 WEST 2020

让我们想象我们只是想打印出带有Tue的日志。让我们来写一下实现这个功能的逻辑:

async function processLogs() {
  const destination = new Uint8Array(100);
  const readBytes = await buffer.read(destination);
  if (readBytes) {
    const read = decoder.decode(destination);
    if (read.includes("Tue")) {
      await Deno.stdout.write(encoder.encode(`${read}\n`));
    }
  }
  setTimeout(processLogs, 10);
}  

然后,我们在包含example-logs.txt文件的文件夹内执行程序:

$ deno run --allow-read index.ts
Tue Aug 20 17:12:05 WEST 2019
Tue Sep 17 02:19:56 WEST 2019
Tue Dec  3 14:02:01 CET 2019
Tue Jul 21 10:37:26 WEST 2020

带有日期的日志行如实地从缓冲区中读取并符合我们的条件。

这是一个关于缓冲区可以做什么的简短演示。我们能够异步地从缓冲区读取和写入。这种方法允许,例如,消费者在应用程序读取其他部分的同时处理文件的一部分。

Deno 命名空间提供了比这里尝试的更多功能。在本节中,我们决定挑选几个部分给你一个启示,看看它启用了多少功能。

第四章构建 Web 应用程序及以后,我们将使用这些函数,以及第三方模块和标准库来编写我们的 Web 服务器。

使用标准库

在本节中,我们将探讨由 Deno 的标准库提供的行为。目前,这个标准库不被运行时认为是稳定的,因此模块是单独版本化的。在我们撰写本文时,标准库处于版本 0.83.0

如我们之前提到的,Deno 在向标准库添加内容方面非常慎重。核心团队希望它提供足够的行为,这样人们就不需要依赖数百万个外部包来完成某些事情,但同时也不想添加过多的 API 表面。这是一个难以达到的微妙平衡。

受到 golang 的启发,Deno 标准库的大部分函数模仿了谷歌创建的语言。这是因为 Deno 团队真心相信golang如何发展其标准库,一个以打磨得非常好而闻名的库。作为一个有趣的注解,Ryan Dahl(Deno 和 Node 的创建者)在他的某次演讲中提到,当拉取请求向标准库添加新的 API 时,会要求提供相应的golang实现。

我们不会遍历整个库,原因与我们没有遍历整个 Deno 命名空间一样。我们将通过构建一些有用的程序来学习它所能提供的功能。我们将从生成 ID、日志记录、HTTP 通信等知名用例开始。

为我们的简单 ls 添加颜色

几页之前,我们在*nix 系统中构建了一个非常粗糙简单的ls命令的“克隆”。当时我们列出了文件,以及它们的大小和修改日期。

为了开始探索标准库,我们打算给该程序的终端输出添加一些着色。让我们使文件夹名称以红色打印,这样我们就可以轻松地区分它们。

我们将创建一个名为list-file-names-color.ts的文件。这次我们将使用 TypeScript,因为我们将得到更好的补全功能,因为标准库和 Deno 命名空间函数都是为了这个目的而编写的。

让我们探索一下标准库函数,它们允许我们给文本着色(https://deno.land/std@0.83.0/fmt/colors.ts)。

如果我们想查看一个模块的文档,我们可以直接查看代码,但我们也可以使用doc命令或文档网站。我们将使用后者。

导航到 https://doc.deno.land/https/deno.land/std@0.83.0/fmt/colors.ts。屏幕上列出了所有可用的方法:

  1. 从标准库的格式化库中导入打印红色文本的方法:

    import { red } from "https://deno.land/std@0.83.0/fmt/colors.ts";
    
  2. 在我们的async迭代器中使用它,该迭代器正在遍历当前目录中的文件:

    const [path = "."] = Deno.args;
    for await (const item of Deno.readDir(path)) {
      if (item.isDirectory) {
        console.log(red(item.name));
      } else {
        console.log(item.name);
      }
    }
    
  3. demo-files文件夹内运行它(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter03/ls),我们得到的文件夹以红色显示(这在打印的书里看不到,但你可以本地运行它):

    $ deno run –allow-read list-file-names-color.ts
    file-with-no-content.txt
    demo-folder
    .hidden-file
    lorem-ipsum.txt
    

现在我们有一个更好的ls命令,它让我们能够通过标准库的着色函数区分文件夹和文件。在本书的过程中,我们将查看标准库提供的许多其他模块。其中一些将在我们开始编写自己的应用程序时使用。

我们将特别关注的一个模块是 HTTP 模块,从下一节开始我们将大量使用它。

使用 HTTP 模块构建 Web 服务器

本书的主要内容,以及介绍 Deno 以及如何使用它,是学习如何使用它来构建 Web 应用程序。在这里,我们将创建一个简单的 JSON API 来向您介绍 HTTP 模块。

我们将构建一个 API,用于保存和列出便签。我们将这些便签称为 post-its。想象一下,这个 API 将喂养你的 post-its 板。

我们将使用 Web API 和 Deno 标准库 HTTP 模块中的函数创建一个非常简单的路由系统。记住,我们这样做是为了探索 API 本身,所以这并不是生产就绪的代码。

让我们先创建一个名为post-it-api的文件夹和一个名为index.ts的文件。再次,我们将使用 TypeScript,因为我们相信自动完成和类型检查功能可以大大提高我们的体验并减少可能的错误数量。

本节最终的代码可以在github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter03/post-it-api/steps/7.ts找到:

  1. 首先,将标准库 HTTP 模块导入我们的文件中:

    import { serve } from
      "https://deno.land/std@0.83.0/http/server.ts";
    
  2. 使用AsyncIterator编写处理请求的逻辑,就像我们之前的例子中所做的那样:

    console.log("Server running at port 8080");
    for await (const req of serve({ port: 8080 })) {
      req.respond({ body: "post-it api", status: 200 });
    }
    

    如果我们现在运行它,这就是我们会得到的。记住,为了让它具有网络访问权限,我们需要使用在权限部分提到的--allow-net标志:

    deno run --allow-net index.ts
    Server running at port 8080
    
  3. 为了清晰起见,我们可以将端口和服务器实例提取到单独的变量中:

    const PORT = 8080;
    const server = serve({ port: PORT });
    console.log("Server running at port", PORT);
    for await (const req of serve({ port: PORT })) {
    …
    

我们现在有了一个运行中的服务器,和之前一样,唯一的区别是现在代码(可以说)因为将配置变量放在文件顶部而更加可读。我们稍后会学习如何从代码中提取这些变量。

返回便签列表

我们的第一个要求是我们有一个返回便签列表的 API。这些便签将包括名称、标题和创建日期。在我们到达那里之前,为了使我们能够有多个路由,我们需要一个路由系统。

为了进行这个练习,我们将自己构建一个。这是我们了解 Deno 中一些内置 API 的方式。稍后我们会同意,在编写生产应用程序时,有时最好重用经过测试和广泛使用的软件,而不是不断重新发明轮子。然而,为了学习目的,完全重新发明轮子是可以的。

为了创建我们的基本路由系统,我们将使用一些您可能在浏览器中知道的 API。例如URLUrlSearchParams等对象。

我们的目标是能够通过其 URL 和路径定义一个路由。类似GET /api/post-its这样的东西会很好。让我们这样做!

  1. 首先,创建一个URL对象(developer.mozilla.org/en-US/docs/Web/API/URL)来帮助我们解析 URL 和其参数。我们将HOSTPROTOCOL提取到另一个变量中,这样我们就不用重复了:

    const PORT = 8080;
    const HOST = "localhost";
    const PROTOCOL = "http";
    const server = serve({ port: PORT, hostname: HOST });
    console.log(`Server running at ${HOST}:${PORT}`);
    for await (const req of server) {
      const url = new
        URL(`${PROTOCOL}://${HOST}${req.url}`);
      req.respond({ body: "post-it api", status: 200 });
    }
    
  2. 使用创建的URL对象进行一些路由。我们将使用switch case来实现。当没有匹配的路由时,应该向客户端发送404

      const pathWithMethod = `${req.method} ${url.pathname}`;
      switch (pathWithMethod) {
        case "GET /api/post-its":
          req.respond({ body: "list of all the post-its",
            status: 200 });
          continue;
        default:
          req.respond({ status: 404 });
      } 
    

    提示

    您可以同时在运行脚本时使用--unstable--watch标志,以在文件更改时重新启动它:deno run --allow-net --watch --unstable index.ts

  3. 访问http://localhost:8080/api/post-its,并确认我们得到了正确的响应。其他任何路由都会得到 404 响应。

    请注意,我们使用continue关键字让 Deno 在响应请求后跳出当前迭代(记住我们正在for循环内)。

    您可能已经注意到,目前我们只是按路径路由,而不是按方法路由。这意味着对/api/post-its的任何请求,无论是POST还是GET,都会得到相同的响应。让我们通过前进来解决这个问题。

  4. 创建一个包含请求方法和路径名的变量:

      const pathWithMethod = `${req.method} ${url.pathname}`
      switch (pathWithMethod) {
    

    现在我们可以定义我们想要的路线,GET /api/post-its。现在我们已经有了我们路由系统的基本知识,我们将编写返回便签的逻辑。

  5. 创建一个 TypeScript 接口,以帮助我们保持便签的结构:

    interface PostIt {
      title: string,
      id: string,
      body: string,
      createdAt: Date
    }
    
  6. 创建一个变量,作为我们这次练习的内存数据库

    我们将使用一个 JavaScript 对象,其中键是 ID,值是刚刚定义的PostIt类型的对象:

    let postIts: Record<PostIt["id"], PostIt> = {}
    
  7. 向我们的数据库添加几个测试数据:

    let postIts: Record<PostIt["id"], PostIt> = {
      '3209ebc7-b3b4-4555-88b1-b64b33d507ab': { title: 'Read more', body: 'PacktPub books', id: 3209ebc7-b3b4-4555-88b1-b64b33d507ab ', createdAt: new Date() },
      'a1afee4a-b078-4eff-8ca6-06b3722eee2c': { title: 'Finish book', body: 'Deno Web Development', id: '3209ebc7-b3b4-4555-88b1-b64b33d507ab ', createdAt: new Date() }
    }
    

    请注意,我们目前是手动生成ID 的。稍后,我们将使用标准库的另一个模块来完成。让我们回到我们的 API,并更改处理路由的case

  8. 更改返回所有便签的case,而不是硬编码的消息。

    由于我们的数据库是一个键/值存储,我们需要使用reduce来构建一个包含所有便签的数组(删除代码块中高亮的行):

    case GET "/api/post-its":
      req.respond({ body: "list of all the post-its", status:     200 });
      const allPostIts = Object.keys(postIts).
        reduce((allPostIts: PostIt[], postItId) => {
            return allPostIts.concat(postIts[postItId]);
          }, []);
      req.respond({ body: JSON.stringify({ postIts:     allPostIts }) });
      continue;
    
  9. 运行代码并访问/api/post-its。我们应该在那里看到我们的便签列表!

    您可能已经注意到,这仍然不是 100%正确的,因为我们的 API 返回的是 JSON,而其头部与载荷不匹配。

  10. 我们将通过使用我们来自浏览器的 API——Headers对象——来添加content-typehttps://developer.mozilla.org/en-US/docs/Web/API/Headers)。删除以下代码块中高亮的行:

    const headers = new Headers();
    headers.set("content-type", "application/json");
    const pathWithMethod = `${req.method} ${url.pathname}`
    switch (pathWithMethod) {
      case "GET /api/post-its":
    …
        req.respond({ body: JSON.stringify({ postIts: 
          allPostIts }) });
        req.respond({ headers, body: JSON.stringify({ 
          postIts: allPostIts }) });
        continue;
    

我们已经创建了一个Headers对象的实例,然后我们在req.respond上使用了它。这样,我们的 API 现在变得更加一致、易消化,并遵循标准。

向数据库添加一个便签

现在我们已经有了读取便签的方法,我们还需要一种添加新便签的方法,因为拥有一个完全静态内容的 API 并没有多大意义。这就是我们将要做的。

我们将使用我们创建的路由基础设施来添加一个允许我们插入记录到我们数据库的路由。由于我们遵循 REST 指南,该路由将位于列出post-its的路径上,但方法不同:

  1. 定义一个总是返回201状态码的路由:

        case "POST /api/post-its":
          req.respond({ status: 201 });
          continue
    
  2. 使用curl的帮助,测试它,我们可以看到它返回了正确的状态码:

    curl but feel free to use your favorite HTTP requests tool, you can even use a graphical client such as Postman (https://www.postman.com/).Let's make the new route do what it is supposed to. It should get a JSON payload and use that to create a new post-it.We know, by looking at the documentation of the standard library's HTTP module (`doc.deno.land/https/deno.land/std@0.83.0/http/server.ts#ServerRequest`) that the body of the request is a *Reader* object. The documentation includes an example on how to read from it.
    
  3. 按照建议,读取值并打印出来以更好地理解它:

    case "POST /api/post-its":
          const body = await Deno.readAll(req.body);
          console.log(body) 
    
  4. 使用curl的帮助,用body发送请求:

    201 status code. If we look at our running server though, something like this is printed to the console:
    
    

    Uint8Array(25) [

    123,  34, 116, 105, 116, 108, 101,

    34,58,32,34,84,   101, 115,

    116,  32, 112, 111, 115, 116,  45,

    105, 116,  34, 125

    ]

    
    We previously learned that Deno uses `Uint8Array` to do all its communications with the Rust backend, and this is not an exception. However, `Uint8Array` is not what we currently want, we want the actual text of the request body. 
    
  5. 使用TextDecoder将请求体作为可读值获取。这样做之后,我们再次记录输出,然后我们将发送一个新的请求:

    $ deno -X POST -d "{\"title\": \"Buy milk\"}" 
    http://localhost:8080/api/post-its
    

    这次服务器在控制台打印的内容如下:

    {"title": "Buy milk "}
    

    我们正在取得进展!

  6. 由于主体是一个字符串,我们需要将其解析为 JavaScript 对象。我们将使用我们的一位老朋友,JSON.parse

    const decoded = JSON.parse(new 
      TextDecoder().decode(body));
    

    现在我们的请求体以一种我们可以操作的格式存在,这就是我们创建新数据库记录所需要做的全部工作。让我们按照以下步骤创建一个:

  7. 使用标准库中的uuid模块(deno.land/std@0.83.0/uuid)为我们的记录生成一个随机的 UUID:

    import { v4 } from 
      "https://deno.land/std/uuid/mod.ts";
    
  8. 在我们的路由的 switch case 中,我们将使用generate方法创建一个id并将其插入到数据库中,在用户在请求负载中发送的内容顶部添加createdAt日期。为了这个例子,我们省略了验证:

    case "POST /api/post-its":
    …
        const decoded = JSON.parse(new 
          TextDecoder().decode(body));
        const id = v4.generate();
        postIts[id] = {
          ...decoded,
          id,
          createdAt: new Date()
        }
        req.respond({ status: 201, body:
          JSON.stringify(postIts[id]), headers });
    

    注意我们在这里使用的是之前定义的同一个headers对象(在GET路由中),这样我们的 API 就会返回Content-Type: application/json

    然后,再次遵循REST指南,我们返回201 Created代码和创建的记录。

  9. 保存代码,重新启动服务器,再次运行它:

    GET request to the route that lists all the post-its to check if the record was actually inserted into the database:
    
    

    $ curl http://localhost:8080/api/post-its

    {"postIts":[{"title":"Read more","body":"PacktPub books","id":"3209ebc7-b3b4-4555-88b1-b64b33d507ab","createdAt":"2021-01-10T16:28:52.210Z"},{"title":"Finish book","body":"Deno Web Development","id":"a1afee4a-b078-4eff-8ca6-06b3722eee2c","createdAt":"2021-01-10T16:28:52.210Z"},{"title":"Buy groceries","body":"1 x Milk","id":"b35b0a62-4519-4491-9ba9-b5809b4810d5","createdAt":"2021-01-10T16:29:05.519Z"}]}

    
    

而且它奏效了!现在我们有一个 API 可以返回并添加 post-its 到列表中。

这基本上结束了我们在这个章节中使用 HTTP 模块进行 API 所做的工作。像我们写的这个 API 一样,大多数 API 都是为了被前端应用程序消费而创建的,我们来做这件事来结束这个章节。

服务于前端

由于这超出了本书的范围,我们不会编写与该 API 交互的前端代码。然而,如果你想用它来获取便签并显示在一个单页应用程序上,我在书中的文件中包含了一个(github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter03/post-it-api/index.html)。

我们将学习如何使用我们刚刚构建的 Web 服务器来提供 HTML 文件:

  1. 首先,我们需要在服务器的根目录下创建一个路由。然后,我们需要设置正确的Content-Type,并使用已知的文件系统 API 返回文件内容。

    为了获取当前文件相对于 HTML 文件的路径,我们将使用 URL 对象和 JavaScript 的import.meta声明(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import.meta),其中包含当前文件的路径:

    resolve, and fromFileUrl methods from Deno's standard-library to get a URL that is relative to the current file.Note that we now need to run this with the `--allow-read` flag since our code is reading from the filesystem. 
    
  2. 为了让我们更安全,我们将指定程序可以读取的确切文件夹,通过将其传递给--allow-read标志:

    $ deno run --allow-net --allow-read=. index.ts
    Server running at http://0.0.0.0:8080 
    

    这将防止任何可能允许恶意人士读取我们文件系统的错误。

  3. 用浏览器访问该 URL,你应该会来到一个可以看到我们添加的便签post-its的页面。要添加一个新的,你也可以点击添加新便签文字并填写表单:

图 3.2 – 前端消费便签 API

](https://gitee.com/OpenDocCN/freelearn-js-pt2-zh/raw/master/docs/deno-web-dev/img/Figure_3.2_B16380.jpg)

图 3.2 – 前端消费便签 API

重要提示

请记住,在许多生产环境中,不推荐 API 为前端代码提供服务。在这里,我们这样做是为了学习目的,这样我们才能理解标准库 HTTP 模块的一些可能性。

在本节中,我们学习了如何利用标准库提供的模块。我们制作了一个ls命令的简单版本,并使用标准库的输出格式化函数给它添加了一些颜色。为了结束这一节,我们制作了一个具有几个端点的 HTTP API,用于列出和持久化记录。我们讨论了不同的需求,并学习了 Deno 如何实现它们。

总结

随着我们对本书的阅读,我们对 Deno 的了解变得更加实用,我们开始用它来处理更接近现实世界的用例。这一章就是关于这个的。

我们首先学习了运行时的基本特性,即程序生命周期,以及 Deno 如何看待模块稳定性和版本控制。我们很快转向了 Deno 提供的 Web API,通过编写一个简单的程序,从网站上获取 Deno 徽标,将其转换为 base64,并将其放入 HTML 页面中。

然后,我们进入了Deno命名空间,探索了一些其底层功能。我们使用文件系统 API 构建了几个示例,并最终用它构建了一个ls命令的简化版。

缓冲区是在 Node.js 世界中大量使用的东西,它们能够执行异步读写行为。正如我们所知,Deno 与 Node.js 有很多相同的用例,这使得在这一章节中不谈论缓冲区变得不可能。我们首先解释了 Deno 缓冲区与 Node.js 的区别,然后构建了一个小应用程序,它能够异步地从它们中读取和写入。

为了结束这一章节,我们更接近了这本书的主要目标之一,即使用 Deno 进行网络开发。我们使用 Deno 创建了第一个 JSON API。在这个过程中,我们了解了多个 Deno API,甚至构建了我们的基本路由系统。然后,我们创建了几个路由,列出并创建了我们的数据存储中的记录。在本章即将结束时,我们学习了如何处理 API 中的头部,并将其添加到我们的端点中。

我们结束了这一章节,通过我们的网络服务器直接提供了一个单页应用程序;这个单页应用程序消费并与我们 API 进行了交互。

这一章我们覆盖了很多内容。我们开始构建 API,这些 API 现在比我们之前所做的更接近现实。我们还更清楚地了解了使用 Deno 开发、使用权限和文档的感觉。

当前章节结束了我们的入门之旅,希望它让你对接下来的内容感到好奇。

在接下来的四章中,我们将构建一个网络应用程序,并探索在这一过程中所做的所有决定。到目前为止你所学的的大部分知识将在后面用到,但也有很多新的、令人兴奋的内容。在下一章,我们将开始创建一个 API,随着章节的进行,我们将继续为其添加功能。

我希望你能加入我们!

第二部分:构建应用程序

在这个动手实践的环节,你将创建一个 Deno 应用程序,从服务器端渲染的网站开始,然后过渡到代表性状态传输REST应用程序编程接口APIs),这些接口与数据库相连并具备认证功能。

本部分包含以下章节:

第四章:构建 Web 应用程序

到这里我们来了!我们走过了漫长的一段路才到达这里。这里才是所有乐趣的开始。我们已经经历了三个阶段:了解 Deno 是什么,探索它提供的工具链,以及通过其运行时了解其细节和功能。

前几章的大部分内容将在这一章中证明是有用的。希望,入门章节让您有信心开始应用我们一起学到的内容。我们将使用这些章节以及您现有的 TypeScript 和 JavaScript 知识,来构建一个完整的 Web 应用程序。

我们将编写一个包含业务逻辑、处理身份验证、授权和日志记录等内容的 API。我们将涵盖足够的基础知识,以便您最终可以自信地选择 Deno 来构建您下一个伟大的应用程序。

在本章中,我们不仅要谈论 Deno,还要回顾一些关于软件工程和应用程序架构的基本思想。我们认为,在从头开始构建应用程序时,牢记一些事情至关重要。我们将查看一些基本原理,这些原理将被证明是有用的,并帮助我们构建代码,使其易于在未来变化中进化。

稍后,我们将开始引用一些第三方模块,查看它们的方法,并决定从这里开始我们将使用什么来帮助我们处理路由和与 HTTP 相关的挑战。我们还将确保我们以一种使第三方代码隔离并作为我们想要构建的功能的使能者而不是功能本身来工作的方式来构建我们的应用程序。

本章我们将涵盖以下主题:

  • 构建 Web 应用程序的结构

  • 探索 Deno HTTP 框架

  • 让我们开始吧!

技术要求

本章使用的代码文件可在以下链接找到:github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter04/museums-api

构建 Web 应用程序的结构

当开始一个应用程序时,花时间思考其结构和架构是很重要的。这一节将从这个角度开始:通过查看应用程序架构的骨架。我们将看看它带来了什么优势,并使自己与一套原则保持一致,这些原则将帮助我们在应用程序增长时扩展它。

然后,我们将开发出将成为应用程序第一个端点的部分。然而,首先,我们将从业务逻辑开始。持久层将紧随其后,最后我们将查看一个 HTTP 端点,它将作为应用程序的入口点。

Deno 作为一个无偏见的工具

当我们使用低级别的工具,并将许多决策权委托给开发者时,如 Node.js 和 Deno,构建应用程序是随之而来的一个重大挑战。

这与具有明确观点的 Web 框架,如 PHP Symfony、Java SpringBoot 或 Ruby on Rails,有很大的不同,在这些框架中,许多这些决策已经为我们做出。

这些决策大多数与结构有关;也就是说,代码和文件夹结构。这些框架通常为我们提供处理依赖项、导入的方法,甚至在不同的应用程序层次结构上提供一些指导。由于我们使用的是原始语言和几个包,因此在这本书中我们将自己负责这些结构。

前述框架不能直接与 Deno 相比较,因为它们是构建在诸如 PHP、Java 和 Ruby 等语言之上的框架。但是当我们查看 JS 世界,尤其是 Node.js 时,我们可以观察到最常用来创建 HTTP 服务器的最受欢迎工具是 Express.js 和 Kao.js。这些通常比前述框架轻量级得多,尽管还有一些坚固完整的替代方案,如 Nest.js 或 hapi.js,但 Node.js 社区更倾向于采用方法,而不是框架方法。

尽管这些非常流行的库提供了大量功能,但许多决策仍然委托给开发者。这不是库的错,更多的是一个社区偏好。

一方面,直接访问这些原语让我们能够构建非常适合我们用例的应用程序。另一方面,灵活性是一个权衡。拥有大量的灵活性随之而来的是做出无数决策的责任。而当需要做出许多决策时,就有很多机会做出糟糕的决策。难点在于,这些通常是对代码库扩展方式产生巨大影响的决策,这也是它们如此重要的原因。

在当前状态下,Deno 及其社区在框架与库这一问题上遵循与 Node.js 非常相似的方法。社区主要押注于由开发者创建的轻量级且小巧的软件,以适应他们的特定需求。我们将在本章后面评估其中的一些。

从现在开始,在这本书的其余部分,我们将使用一种我们相信对当前用例有很大好处的应用程序结构。然而,不要期望这种结构和架构是灵丹妙药,因为我们深信软件世界中不存在这样的东西;每种架构都将随着成长而不断进化。

我们想要的不仅仅是扔进一个食谱并遵循它,而是要熟悉一种思维方式——一种推理。这应该能让我们在将来做出正确的决策,目标只有一个:编写易于更改的代码

通过编写易于更改的代码,我们总是准备好在不需要太多努力的情况下改进我们的应用程序。

应用程序最重要的部分

应用程序是为了适应一个目的而被创建的。无论这个目的是支持一个企业还是一个简单的宠物项目都不重要。归根结底,我们希望它能做些什么。那些什么就是使应用程序变得有用的原因。

这可能听起来很显然,但有时对于我们这些开发者来说,我们很容易因为对一种技术的热情而忘记,它只是达到目的的一种手段。

正如 Uncle Bob 在他的Architecture – the lost years演讲中所说(www.youtube.com/watch?v=hALFGQNeEnU),人们很容易忘记应用程序的目的,而更多地关注技术本身。在我们开发应用程序的所有阶段,记住这一点非常重要,尤其是在建立其初始结构时更是如此。接下来,我们将探讨本书剩余部分我们将要构建的应用程序的需求。

我们的应用程序是关于什么的?

虽然我们确实相信在任何应用程序中业务逻辑是最重要的事情,但在这本书中,情况有点不同。我们将创建一个示例应用程序,但它只是一个达到主要目标:学习 Deno 的手段。然而,为了使过程尽可能真实,我们希望在心中有一个清晰的目标。

我们将构建一个允许人们创建和与博物馆列表互动的应用程序。我们可以通过将其功能作为用户故事列出使其更清晰,如下所示:

  • 用户能够注册和登录。

  • 用户能够创建一个带有标题、描述和位置的博物馆。

  • 用户可以查看博物馆列表。

在这个旅程中,我们将开发 API 和支持这些功能的逻辑。

既然我们已经熟悉了最终目标,我们可以开始思考如何组织应用程序。

理解文件结构和应用程序架构

关于文件结构,我们首先需要意识到的一点,特别是当我们从零开始一个没有框架的项目时,它将随着项目的发展而不断演变。对于只有几个端点的项目来说好的文件结构,对于有数百个端点的项目来说可能不那么好。这取决于许多事情,从团队规模,到定义的标准,最终到偏好。

在定义文件结构时,重要的是我们要达到一个地步,使我们能够促进关于代码放置位置的未来决策。文件结构应该为如何做出良好的架构决策提供清晰的提示。

同时,我们当然不希望创建一个过度工程化的应用程序。我们将创建足够的抽象,使模块非常独立,并且没有超出它们领域的知识,但不会超过这个程度。牢记这一点也迫使我们构建灵活的代码和清晰的接口。

最终,最重要的是架构能够使代码库具备以下特点:

  • 可测试。

  • 易于扩展。

  • 与特定技术或库解耦。

  • 易于导航和理解。

在创建文件夹、文件和模块时,我们必须要记住,绝不能有任何妥协前面提到的话题。

这些原则与软件设计中的 SOLID 原则非常一致,由“Uncle Bob”Robert C. Martin 在一次演讲中提出(en.wikipedia.org/wiki/SOLID),该演讲值得一看(youtu.be/zHiWqnTWsn4)。

本书我们将要使用的文件夹结构,如果你有 Node.js 背景,可能会觉得熟悉。

正如发生在 Node.js 一样,我们完全可以在一个文件中创建一个完整的 API。然而,我们不会这样做,因为我们认为在初始阶段对关注点进行一些分离将大大提高我们的灵活性,而不会牺牲开发者的生产力。

在下一节中,我们将探讨不同层次的责任以及它们在我们开发应用程序功能时的相互配合。

遵循这种思路,我们努力确保模块之间的解耦程度。例如,我们希望通过确保在 web 框架中的更改不会影响到业务逻辑对象。

所有这些建议,以及我们在这本书中将会提出的建议,将有助于确保我们应用程序的核心部分是业务逻辑,其他所有内容只是插件。JSON API 只是一种将我们的数据发送给用户的方式,而数据库只是一种持久化数据的方式;这些都不应该是应用程序的核心部分。

确保我们这样做的一种方法是在编写代码时进行以下心理练习:

当你在编写业务逻辑时,想象这些对象将在不同的上下文中使用。例如,使用不同的交付机制(例如 CLI)或不同的持久化引擎(内存数据库而非 NoSQL 数据库)。

在接下来的几页中,我们将引导您如何创建不同的层次,并解释所有设计决策以及它们所启用的功能。

让我们来实际操作,开始构建我们项目的基础框架。

定义文件夹结构。

在我们项目的文件夹中,我们首先要创建一个 src 文件夹。

这里, predictably,是我们的代码将要存放的地方。我们不希望项目的根目录有任何代码,因为可能会在这里添加配置文件、READMEs、文档文件夹等。这会使代码难以区分。

在接下来的章节中,我们将在src文件夹内度过大部分时间。由于我们的应用程序是关于博物馆的,我们将在src文件夹内创建一个名为museums的文件夹。这个文件夹将存放本章将编写的 most of the logic。稍后,我们将创建类型、控制器和方法文件。然后,我们将创建src/web文件夹。

控制器的文件是我们的业务逻辑将存放的地方。仓库将处理与数据访问相关的逻辑,而网络层将处理所有与网络相关的事情。

您可以通过查看本书的 GitHub 仓库来查看最终结构:github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter04/museums-api

本章的初始要求是有一个路由,我们可以在此路由上执行 GET 请求,并接收以 JSON 格式表示的博物馆列表。

我们将在控制器文件(src/museums/controller.ts)中开始编写所需的业务逻辑。

文件夹结构应该如下所示:

└── src
    ├── museums
    │   ├── controller.ts
    │   ├── repository.ts
    │   └── types.ts
    └── web

这是我们开始的地方。与博物馆有关的所有内容都将在museums文件夹内,我们将称之为一个模块。controller文件将托管业务逻辑,repository文件将托管数据获取功能,而types文件将位于我们的类型。

现在,让我们开始编写代码!

编写业务逻辑

我们之前说过,我们的业务逻辑是应用程序最重要的部分。尽管我们的业务逻辑现在非常简单,但这是我们首先开发的。

由于我们将使用 TypeScript 来编写我们的应用程序,让我们创建一个定义Museum对象的接口。按照以下步骤操作:

  1. 进入src/museums/types.ts,并创建一个定义Museum的类型:

    export type Museum = {
      id: string,
      name: string,
      description: string,
      location: {
        lat: string,
        lng: string
      }
    }
    

    确保它被导出,因为我们将跨其他文件使用此文件。

    现在我们已经知道了类型,我们必须创建一些业务逻辑来获取博物馆列表。

  2. src/museums/types.ts中,创建一个接口,定义MuseumController。它应该包含一个列出所有博物馆的方法:

    export interface MuseumController {
      getAll: () => Promise<Museum[]>;
    }
    
  3. src/museums/controller.ts中,创建一个类,作为控制器。它应该包含一个名为getAll的函数。将来,这里将存放业务逻辑,但现在,我们只需返回一个空数组:

    import type { MuseumController } from "./types.ts";
    export class Controller implements MuseumController {
      async getAll() {
        return [];
      }
    } 
    

    我们可以用这个直接访问数据库并获取某些记录。然而,由于我们希望能够使我们的业务逻辑孤立,并且不与应用程序的其他部分耦合,所以我们不会这样做。

    此外,我们还希望我们的业务逻辑能够在没有数据库或服务器连接的情况下独立测试。为了实现这一点,我们不能直接从我们的控制器访问数据源。稍后,我们将创建一个抽象,它将负责从数据库获取这些记录。

    目前,我们知道我们需要调用一个外部模块,它将为我们获取所有的博物馆,并将它们交给我们的控制器——它来自哪里无关。

    请记住以下软件设计最佳实践:"面向接口编程,而不是面向实现。"

    简单地说,这句话的意思是我们应该定义模块的签名,然后才开始考虑它的实现。这大大有助于设计清晰的接口。

    回到我们的控制器,我们知道控制器的getAll方法最终必须调用一个模块来从数据源获取数据。

  4. src/museums/types.ts中,定义MuseumRepository,这个模块将负责从数据源获取博物馆:

    export interface MuseumRepository {
      getAll: () => Promise<Museum[]>
    }
    
  5. src/museums/controller.ts中,向构造函数中添加一个注入的类museumRepository

    museumRepository that implements the MuseumRepository interface. By creating this and *lifting the dependencies*, we no longer need to return an empty array from our controller.Before we write any more logic, let's make sure our code runs and check if it is working. We're just missing one thing.
    
  6. 创建一个名为src/index.ts的文件,导入MuseumController,实例化它,并调用getAll方法,记录其输出。现在,你可以注入一个伪仓库,它只是返回一个空数组:

    import { Controller as MuseumController } from
      "./museums/controller.ts";
    const museumController = new MuseumController({
      museumRepository: {
        getAll: async () => []
      }
    })
    console.log(await museumController.getAll())
    
  7. 运行它以检查它是否正常工作:

    $ deno run src/index.ts 
    []
    

    就这样!我们刚刚从伪仓库函数接收到了一个空数组!

有了我们创建的这种抽象,我们的控制器现在与数据源解耦。其依赖关系通过构造函数注入,允许我们稍后不更改控制器而更改仓库。

我们刚才所做的称为依赖倒置——SOLID 原则中的D——它包括将部分依赖性提升到函数调用者。这使得独立测试内部函数变得非常容易,正如我们将在第八章**测试——单元和集成中看到的,我们将涵盖测试。

为了将我们刚刚编写的代码转换为完全功能的应用程序,我们需要有一个数据库或类似的东西。我们需要能够存储和检索博物馆列表的东西。我们现在来创建这个东西。

开发数据访问逻辑

在开发控制器的过程中,我们注意到我们需要能够获取数据;也就是说,仓库。这个模块将抽象所有对数据源的调用,在这个案例中,数据源存储博物馆。它将有一套非常明确的方法,任何想要访问数据的人都应该通过这个模块来访问。

我们已经在src/museums/types.ts中定义了其部分接口,所以让我们写一个实现它的类。现在,我们不会将它连接到真实数据库。我们将其作为内存数据库使用 ES6 Map。

让我们进入我们的仓库文件,并按照以下步骤开始编写我们的数据访问逻辑:

  1. 打开 src/museums/repository.ts 文件并创建一个 Repository 类。

    它应该有一个名为 storage 的属性,这将是一个 JavaScript MapMap 的键应该是字符串,值应该是 Museum 类型的对象:

    import type { Museum, MuseumRepository } from
      "./types.ts";
    export class Repository implements MuseumRepository {
      storage = new Map<string, Museum>();
    }
    

    我们正在使用 TypeScript 泛型来设置我们的 Map 类型。请注意,我们引入了来自博物馆控制器 的 Museum 接口,以及由我们的类实现的 MuseumRepository

    现在“数据库”已经“就绪”,我们必须暴露某些方法,这样人们就可以与它交互。上一节的要求是我们可以从数据库中获取所有记录。让我们接下来实现它。

  2. 在仓库类内部,创建一个名为 getAll 的方法。它应该负责返回我们 storage Map 中的所有记录:

    export class Repository implements MuseumRepository {
      storage = new Map<string, Museum>();
    src should only be accessible from the outside through a single file. This means that whoever wants to import stuff from src/museums should only do so from a single src/museums/index.ts file.
    
  3. 创建一个名为 src/museums/index.ts 的文件,该文件导出博物馆的控制器 和仓库:

    export { Controller } from "./controller.ts";
    export { Repository } from "./repository.ts";
    export type { Museum, MuseumController,
      MuseumRepository } from "./types.ts"; 
    

    为了保持一致性,我们需要去所有之前从不是 src/museums/index.ts 的文件导入的导入,并更改它们,使它们只从这个文件导入东西。

  4. controller.tsrepository.ts 的导入更新为从 index 文件导入:

    import type { MuseumController, MuseumRepository }
      from "./index.ts";
    

    你可能已经猜到我们接下来必须做什么了…… 你还记得上一节的末尾,我们在博物馆控制器中注入了一个返回空数组的伪函数吗?让我们回到这里并使用我们刚刚编写的逻辑。

  5. 回到 src/index.ts,导入我们刚刚创建的 Repository 类,并将其注入到 MuseumController 构造函数中:

    import {
      Controller as MuseumController,
      Repository as MuseumRepository,
    } from "./museums/index.ts";
    const museumRepository = new MuseumRepository();
    const museumController = new MuseumController({
      museumRepository })
    console.log(await museumController.getAll())
    

    现在,让我们向我们的“数据库”添加一个 fixture,这样我们就可以检查它是否实际上正在打印一些内容。

  6. 访问 museumRepository 中的存储属性并为其添加一个 fixture。

    这目前是一个反模式,因为我们直接访问模块的数据库,但我们将创建一个方法,以便我们以后可以正确添加 fixtures:

    const museumRepository = new MuseumRepository();
    …
    museumRepository.storage.set
      ("1fbdd2a9-1b97-46e0-b450-62819e5772ff", {
      id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",
      name: "The Louvre",
    description: "The world's largest art museum 
        and a historic monument in Paris, France.",
      location: {
        lat: "48.860294",
        lng: "2.33862",
      },
    });
    console.log(await museumController.getAll())
    
  7. 现在,让我们再次运行我们的代码:

    $ deno run src/index.ts
    [
      {
        id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",
        name: "The Louvre",
        description: "The world's largest art
          museum and a historic monument in Paris,
            France.",
        location: { lat: "48.860294", lng: "2.33862" }
      }
    ]
    

    有了这个,我们的数据库连接就可以工作了,正如我们通过打印的 fixture 所看到的那样。

我们在上一节中创建的抽象使我们能够在不更改控制器的情况下更改数据源。这是我们正在使用的架构的一个优点。

现在,如果我们回顾一下我们的初始需求,我们可以确认我们已经完成了一半。我们已经创建了满足用例的业务逻辑——我们只是缺少 HTTP 部分。

创建网络服务器

现在我们已经有了我们的功能,我们需要通过一个网络服务器来暴露它。让我们使用我们从标准库中学到的知识来创建它,并按照以下步骤进行:

  1. src/web 文件夹中创建一个名为 index.ts 的文件,并在那里添加创建服务器的逻辑。我们可以从上一章的练习中复制和粘贴它:

    import { serve } from
      "https://deno.land/std@0.83.0/http/server.ts";
    const PORT = 8080;
    const server = serve({ port: PORT });
    console.log(`Server running at
      https://localhost:${PORT}`);
    for await (let req of server) {
      req.respond({ body: 'museums api', status: 200 })
    }
    

    由于我们希望应用程序易于配置,我们不希望port在这里是硬编码的,而是可以从外部配置的。我们需要将这个服务器创建逻辑作为一个函数导出。

  2. 将服务器逻辑创建包裹在一个函数中,该函数接收配置和port作为参数:

    import { serve } from
      "https://deno.land/std@0.83.0/http/server.ts";
    port defining its type. 
    
  3. 将这个函数的参数改为interface。这将有助于我们的文档,同时也会增加类型安全和静态检查:

    interface CreateServerDependencies {
      configuration: {
        port: number
      }
    }
    export async function createServer({
      configuration: {
        port
      }
    }: CreateServerDependencies) {
    …
    

    现在我们已经配置了 Web 服务器,我们可以考虑将其用于我们的用例。

  4. 回到src/index.ts,导入createServer,并使用它创建一个在端口8080上运行的服务器:

    import { createServer } from "./web/index.ts";
    …
    createServer({
      configuration: {
        port: 8080
      }
    })
    …
    
  5. 运行它,看看它是否正常工作:

    $ deno run --allow-net src/index.ts
    Server running at http://localhost:8080
    [
      {
        id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",
        name: "The Louvre",
        description: "The world's largest art museum and a
          historic monument in Paris, France.",
        location: { lat: "48.860294", lng: "2.33862" }
      }
    ]
    

在这里,我们可以看到有一个日志记录服务器正在运行,以及来自上一节的日志结果。

现在,我们可以用curl测试 Web 服务器,以确保它正在工作:

$ curl http://localhost:8080
museums api

正如我们所看到的,它起作用了——我们有一些相当基础的逻辑,但这仍然不能满足我们的要求,却能启动一个 Web 服务器。我们接下来要做的就是将这个 Web 服务器与之前编写的逻辑连接起来。

将 Web 服务器与业务逻辑连接

我们已经非常接近完成本章开始时计划要做的内容。我们目前有一个 Web 服务器和一些业务逻辑;缺少的是它们之间的连接。

将两件事连接起来的一个快速方法就是在src/web/index.ts上直接导入控制器并在此处使用它。在这里,应用程序将具有期望的行为,目前这样做没有任何问题。

然而,由于我们考虑的是一个可以无需太多问题就能扩展的应用程序架构,所以我们不会这样做。这是因为这将使我们的 Web 逻辑在隔离测试中变得非常难以实现,从而违背了我们的一条原则。

如果我们直接从 Web 服务器中导入控制器,每次在测试环境中调用createServer函数时,它将自动导入并调用MuseumController中的方法,这不是我们想要的结果。

我们再次使用依赖倒置将控制器的函数发送到 Web 服务器。如果这仍然看起来过于抽象,不用担心——我们马上就会看到代码。

为了确保我们没有忘记我们的初始目标,我们想要的是,当用户对/api/museums执行GET请求时,我们的 Web 服务器能够返回一个博物馆列表。

由于我们正在进行这项练习,所以我们暂时不会使用路由库。

我们只是想添加一个基本检查,以确保请求的 URL 和方法是我们想要回答的。如果是,我们想返回博物馆的列表。

让我们回到createServer函数并添加我们的路由处理程序:

export async function createServer({
  configuration: {
    port
  }
}: CreateServerDependencies) {
  const server = serve({ port });
  console.log(`Server running at
    http://localhost:${port}`);
  for await (let req of server) {
    if (req.url === "/api/museums" && req.method === "GET")     
     {
req.respond({ 
body: JSON.stringify({ 
museums: [] 
}), 
status: 200 
      })
      continue
    }
    req.respond({ body: "museums api", status: 200 })
  }
}

我们为请求 URL 和方法添加了一个基本检查,并在它们符合初始要求时返回不同的响应。运行代码看看它的行为如何:

$ deno run --allow-net src/index.ts 
Server running at http://localhost:8080

再次,用curl测试它:

$ curl http://localhost:8080/api/museums
{"museums":[]}

它起作用了——太棒了!

现在,我们需要定义一个接口,以满足这个请求所需的内容。

我们最终需要一个函数,它返回一个博物馆列表,然后将其注入到我们的服务器中。让我们按照以下步骤在CreateServerDependencies接口中添加该功能:

  1. 回到src/web/index.ts中,将MuseumController作为createServer函数的一个依赖项:

    MuseumController type we defined in the museum's module. We're also adding a museum object alongside the configuration object.
    
  2. 从博物馆控制器中调用getAll函数以获取所有博物馆的列表并响应请求:

    export async function createServer({
      configuration: {
        port
      },
      createServer function, but we're not sending it when we call createServer. Let's fix that.
    
  3. 回到src/index.ts,这是我们调用createServer函数的地方,并向MuseumController发送getAll函数。你也可以删除上一节直接调用控制器方法的代码,因为现在它没有任何用处:

    import { createServer } from "./web/index.ts";
    import {
      Controller as MuseumController,
      Repository as MuseumRepository,
    } from "./museums/index.ts";
    const museumRepository = new MuseumRepository();
    const museumController = new MuseumController({
      museumRepository })
    museumRepository.storage.set
     ("1fbdd2a9-1b97-46e0-b450-62819e5772ff", {
      id: "1fbdd2a9-1b97-46e0-b450-62819e5772ff",
      name: "The Louvre",
      description: "The world's largest art museum 
        and a historic monument in Paris, France.",
      location: {
        lat: "48.860294",
        lng: "2.33862",
      },
    });
    createServer({
      configuration: { port: 8080 },
      museum: museumController
    })
    
  4. 再次运行应用程序:

    $ deno run --allow-net src/index.ts
    Server running at http://localhost:8080
    
  5. http://localhost:8080/api/museums 发送请求;你会得到一个博物馆列表:

    $ curl localhost:8080/api/museums
    {"museums":[{"id":"1fbdd2a9-1b97-46e0-b450-62819e5772ff","name":"The Louvre","description":"The world's largest art museum and a historic monument in Paris, France.","location":{"lat":"48.860294","lng":"2.33862"}}]}
    

就这样——我们得到了博物馆列表!

我们已经完成了本节的任务,那就是将我们的业务逻辑连接到 web 服务器。

注意我们是如何使控制器方法可以被注入,而不是 web 服务器直接导入它。这之所以可能,是因为我们使用了依赖倒置。这是我们在这本书中会不断做的事情,无论何时我们想要解耦模块和函数,并提高它们的测试性。

在我们进行代码耦合的思维锻炼时,当我们想要使用不同的交付机制(如 CLI)来使用当前的业务逻辑时,没有任何阻碍。我们仍然可以重用相同的控制器和存储库。这意味着我们很好地使用了抽象来将业务逻辑与应用程序逻辑解耦。

既然我们已经了解了应用程序架构和文件结构的基础,并且也理解了背后的原因,我们可以开始查看可能帮助我们构建它的工具。

在下一节中,我们将查看 Deno 社区中现有的 HTTP 框架。我们不会花太多时间在这方面,但我们希望了解每个框架的优缺点,并最终选择一个来帮助我们完成剩余的旅程。

探索 Deno HTTP 框架

当你构建一个比简单教程更复杂的应用程序时,如果你不想采取纯粹的方法,你很可能会使用第三方软件。

显然,这不仅仅是 Deno 特有的。尽管有些社区比其他社区更愿意使用第三方模块,但所有社区都在使用第三方软件。

我们可以讨论人们为什么这样做或不做,但更常见的原因总是与可靠性和时间管理有关。这可能是因为你想使用经过实战测试的软件,而不是自己构建它。有时,这只是一个时间管理问题,即不想重新编写已经创建的东西。

我们必须说的一件重要事情是我们必须在对构建的应用程序进行耦合第三方软件时非常谨慎。我们并不是说你应该试图达到完全解耦的乌托邦,尤其是因为这会引入其他问题和很多间接性。我们要说的是,我们应该非常清楚将依赖项引入我们代码中的成本以及它引入的权衡。

在本章的第一部分,我们构建了一个 web 应用的基础,我们将在本书的其余部分向其添加功能。在其当前状态下,它仍然非常小,所以它除了标准库之外没有任何依赖。

在该应用中,我们做了一些我们相信不太容易扩展的事情,比如通过使用普通的if语句来匹配 URL 和 HTTP 方法来定义路由。

随着应用程序的增长,我们很可能会需要更高级的功能。这些需求可能从以不同格式处理 HTTP 请求体,到拥有更复杂的路由系统,处理头部和 cookies,或者连接到数据库。

因为我们不相信在开发应用程序时重新发明轮子,所以我们将分析几个目前存在于 Deno 社区中,并专注于创建 web 应用程序的库和框架。

我们将对现有的解决方案进行一般性了解,并探索它们的功能和方法。

最后,我们将选择我们认为在我们用例中提供最佳权衡的那个。

还有哪些替代方案?

在写作时,有一些第三方包提供了大量功能来创建 web 应用程序和 API。其中一些深受非常流行的 Node.js 包(如 Express.JS、Koa 或 hapi.js)的启发,而其他则受到 JavaScript 之外的其他框架(如 Laravel、Flask 等)的启发。

我们将探索其中的四个,它们在写作时非常流行且维护良好。请注意,由于 Deno 和提到的包正在快速发展,这可能会随时间而变化。

重要提示

Craig Morten 写了一篇非常好的文章,对可用的库进行了非常彻底的分析和解构。如果你想了解更多,我强烈推荐这篇文章(dev.to/craigmorten/what-is-the-best-deno-web-framework-2k69)。

我们将尝试在要探索的包方面保持多样性。有一些提供了比其他更抽象和结构化的内容,而有一些提供的不仅仅是简单的实用函数和可组合功能。

我们将要探索的包如下:

  • Drash

  • Servest

  • Oak

  • Alosaur

让我们逐一看看它们。

Drash

Drash (github.com/drashland/deno-drash) 旨在与现有的 Deno 和 Node.js 框架不同。这一动机在其维护者 Edward Bebbington 的一篇博客文章中明确提到,他比较了 Drash 与其他替代方案,并解释了其创建的动机 (dev.to/drash_land/what-makes-drash-different-idd).

这些动机很好,灵感来自于非常流行的软件工具如 Laravel、Flask 和 Tonic,这些决策大部分得到了证实。你一查看 Drash 的代码,就能发现一些相似之处。

与 Express.js 或 Koa 等库相比,它确实提供了一种不同的方法,正如文档所述:

“Deno 与 Node.js 的不同之处在于,Drash 旨在与 Express 或 Koa 不同,利用资源并采用完整的类式系统。”

主要区别在于,Drash 不想提供应用程序对象,让开发者可以注册他们的端点,像一些流行的 Node.js 框架那样。它将端点视为在类中定义的资源,与以下内容相似:

import { Drash } from
  "https://deno.land/x/drash@v1.2.2/mod.ts";
class HomeResource extends Drash.Http.Resource {
  static paths = ["/"];
  public GET() {
    this.response.body = "Hello World!";
    return this.response;
  }
}

这些资源随后被插到 Drash 的应用程序中:

const server = new Drash.Http.Server({
  response_output: "text/html",
  resources: [HomeResource]
});
server.run({
  hostname: "localhost",
  port: 1447
});

在这里,我们可以直接声明它实际上与我们在上面提到的其他框架不同。这些差异是有意的,旨在取悦喜欢这种方法并解决其他框架问题的开发者。这些用例在 Drash 的文档中解释得非常清楚。

Drash 基于资源的方法绝对值得关注。它从非常成熟的软件如 Flask 和 Tonic 得到的灵感确实为桌面带来了东西,并提出了一种解决方案,有助于解决无观点工具的常见问题。文档完整且易于理解,这使得在选择构建应用程序的工具时,它成为了一个很好的资产。

Servest

Servest (servestjs.org/) 自称为适用于 Deno 的“渐进式 HTTP 服务器”

它被创建的一个原因是因为其作者希望让标准库的 HTTP 模块中的一些 API 更容易使用,并实验新特性。后者是在需要稳定性的标准库中真正难以实现的事情。

Servest 直接关注与标准库的 HTTP 模块的比较。其项目主页上直接声明的一个主要目标,就是使其容易从标准库的 HTTP 模块迁移到 Servest。这很好地总结了 Servest 的愿景。

从 API 角度来看,Servest 与我们从 Express.js 和 Koa 熟悉的东西非常相似。它提供了一个应用程序对象,可以在其中注册路由。你也可以看到明显受到了标准库模块所提供内容的启发,正如我们在以下代码片段中所见:

import { createApp } from
  "https://servestjs.org/@v1.1.4/mod.ts";
const app = createApp();
app.handle("/", async (req) => {
  await req.respond({
    status: 200,
    headers: new Headers({
      "content-type": "text/plain",
    }),
    body: "Hello, Servest!",
  });
});
app.listen({ port: 8899 });

我们可以识别出知名 Node.js 库中的应用对象和标准库中的请求对象,以及其他内容。

在此基础上,Servest 还提供了诸如直接渲染 JSX 页面、服务静态文件和认证等常见功能,文档也非常清晰,充满了示例。

Servest 试图利用 Node.js 用户的知识和熟悉度,同时利用 Deno 提供的好处,这是一个有希望的混合。其渐进性质为桌面带来了非常漂亮的功能,承诺会让开发者的生产力比使用标准库 HTTP 包时更高。

Oak

Oak (oakserver.github.io/oak/) 目前是创建 web 应用程序的最受欢迎的 Deno 库。它的名字来源于 Koa 的词语游戏,Koa 是一个非常流行的 Node.js 中间件框架和 Oak 的主要灵感来源。

由于其深受启发,其 API 使用异步函数和上下文对象与 Koa 相似并不令人意外。Oak 还包括一个路由器,也是受@koa/router启发的。

如果你熟悉 Koa,下面的代码可能看起来会很熟悉:

import { Application } from
  "https://deno.land/x/oak/mod.ts";
const app = new Application();
app.use((ctx) => {
  ctx.response.body = "Hello world!";
});
await app.listen("127.0.0.1:8000");

对于那些不熟悉 Koa 的人来说,我们会简要地解释一下,因为理解它将帮助你理解 Oak。

Koa 通过使用现代 JavaScript 特性提供了一个最小化和无观点的方法。Koa 最初被创建(由创建 Express.js 的同一团队)的原因之一是,其创作者想要创建一个利用现代 JavaScript 特性的框架,而不是像 Express 那样,Express 是在 Node.js 的早期创建的。

团队想要使用诸如 promises 和 async/await 等新特性,然后解决开发者在使用 Express.JS 时面临的挑战。其中大多数挑战与错误处理、处理回调和某些 API 的不清晰有关。

Oak 的流行并非空穴来风,它在 GitHub 上的星级与其他选项的距离反映了这一点。单凭 GitHub 的星级并不能说明什么,但结合打开和关闭的问题、发布的版本等,我们可以看出人们为什么信任它。当然,这种熟悉度在的这个包的流行中起了很大的作用。

在其当前状态下,Oak 是一个构建 web 应用程序的固体(就 Deno 的社区标准而言),因为它提供了一组非常清晰和直接的功能。

Alosaur

Alosaur (github.com/alosaur/alosaur) 是一个基于装饰器和类的 Deno web 应用程序框架。它在某种程度上与 Drash 相似,尽管最后的实现方式有所不同。

在其主要功能中,Alosaur 提供了诸如模板渲染、依赖注入和 OpenAPI 支持等功能。这些功能是在所有我们在这里介绍的替代方案的标准之上添加的,如中间件支持和路由。

这个框架的方法是使用类定义控制器,并使用装饰器定义其行为,如下面的代码所示:

import { Controller, Get, Area, App } from
  'https://deno.land/x/alosaur@v0.21.1/mod.ts';
@Controller() // or specific path @Controller("/home")
export class HomeController {
    @Get() // or specific path @Get("/hello")
    text() {
        return 'Hello world';
    }
}
// Declare module
@Area({
    controllers: [HomeController],
})
export class HomeArea {}
// Create alosaur application
const app = new App({
    areas: [HomeArea],
});
app.listen();

在这里,我们可以看到应用程序的实例化与 Drash 有相似之处。它还使用 TypeScript 装饰器来声明框架的行为。

Alosaur 与前面提到的大多数库采取了不同的方法,主要原因在于它并不试图简约。相反,它提供了一组在构建某些类型的应用程序时证明有用的特性。

我们决定对其进行研究,不仅因为它能完成预期的工作,还因为它在 Node.js 和 Deno 领域拥有的一些不常见的特性。这包括诸如依赖注入和 OpenAPI 支持等功能,这是其他展示的解决方案所没有的。同时,它保留了诸如模板渲染等特性,这可能你们从 Express.JS 中熟悉,但在更现代的框架中就不那么熟悉了。

最终解决方案在提供的功能方面非常有前途且完整。这绝对是值得关注的东西,这样你就可以看到它是如何发展的。

结论

在审视了所有展示的解决方案并认识到它们都有优点之后,我们决定在本书的剩余部分使用 Oak。

这并不意味着本书将重点介绍 Oak。不会的,因为它只会处理 HTTP 和路由。Oak 的简约方法将与我们接下来要做的非常吻合,帮助我们逐步创建功能,而不会让它成为障碍。它还是 Deno 社区中最稳定、维护良好和最受欢迎的选项之一,这对我们的决定有明显的影响。

请注意,这个决定并不意味着我们将在接下来的几章中学到的内容不能在任何替代方案中完成。事实上,由于我们将如何组织和架构我们的代码,我们相信很容易就能跟上使用不同框架我们要做的绝大多数事情。

在本书的剩余部分,我们将使用其他第三方模块来帮助我们构建我们提出的功能。我们决定深入研究处理 HTTP 的库,原因是这是我们即将开发的应用程序的基本交付机制。

摘要

在本章中,我们终于开始构建一个利用我们对 Deno 知识的应用程序。我们首先考虑了构建应用程序时我们将拥有的主要目标及其架构。这些目标将为我们本书中关于架构和结构的多数对话定下基调,因为我们将会不断回顾它们,确保我们与它们保持一致。

我们首先创建了我们的文件结构,并试图实现我们第一个应用程序目标:拥有一个列出博物馆的 HTTP 端点。我们先构建了简单的业务逻辑,并在需要关注分离和职责隔离等需求时逐步推进。这些需求定义了我们的架构,证明了我们所创建的层和抽象的好处,并展示了它们所提供的价值。

通过明确责任和模块接口,我们理解到我们可以暂时使用内存数据库来构建我们的应用程序,这就是我们所做的。借助这种方法,我们能够构建出符合本章要求的应用程序,并且层次分离允许我们稍后回来,无需任何问题地将它更改为一个适当的持久层。在定义了业务和数据访问逻辑之后,我们使用标准库创建了一个 Web 服务器作为交付机制。在创建了一个非常简单的路由系统之后,我们插入了之前构建的业务逻辑,满足了本章的主要要求:拥有一个返回博物馆列表的应用程序。

我们在不创建业务逻辑、数据获取和交付层之间直接耦合的情况下做到了这一切。这是我们认为当我们开始添加复杂性、扩展我们的应用程序并向其添加测试时将非常有用的东西。

本章通过查看 Deno 社区目前存在的 HTTP 框架和库,并理解它们之间的差异和方法来结束。其中一些使用对 Node.js 用户熟悉的方法,而其他则深入使用 TypeScript 及其特性来创建更具结构的 Web 应用程序。通过查看四个目前可用的解决方案,我们了解到了社区正在开发的内容以及他们可能采取的方向。

我们最终选择了 Oak,这是一个非常最小化和相对成熟解决方案,以帮助我们解决在本书剩余部分遇到的路由和 HTTP 挑战。

在下一章中,我们将开始将 Oak 添加到我们的代码库中,并添加一些有用特性,如认证和授权,使用中间件等概念,并使我们的应用程序达到我们设定的目标。

让我们开始吧!

第五章:添加用户和迁移到 Oak

至此,我们已经为 Web 应用程序奠定了基础,其结构将使我们能够随着进展添加更多功能。正如您可能从本章的名称中猜到的那样,我们将从向当前 Web 应用程序中添加我们选择的的中间件框架开始本章,这个框架就是 Oak。

与 Oak 一起,由于我们的应用程序开始有更多的第三方依赖项,我们将使用前一章节中学到的知识来创建一个锁文件并在安装依赖项时执行完整性检查。这样,我们可以保证我们的应用程序在无依赖问题的情况下顺利运行。

随着本章的深入,我们将开始了解如何使用 Oak 的功能简化我们的代码。我们将使我们的路由逻辑更具可扩展性,同时也更具可伸缩性。我们最初的解决方案是使用if语句和标准库创建一个 DIY 路由解决方案,我们将在这里重构它。

完成这一步后,我们将得到更干净的代码,并能够使用 Oak 的功能,例如自动内容类型定义、处理不允许的方法和路由前缀。

然后,我们将添加一个在几乎每个应用程序中都非常重要的功能:用户。我们将创建一个与博物馆并列的模块来处理所有与用户相关的事情。在这个新模块中,我们将开发创建用户的业务逻辑,以及使用散列和盐等常见做法在数据库中创建新用户的代码。

在实现这些功能的过程中,我们将了解到 Deno 提供的其他模块,比如标准库的散列功能或包含在运行时中的加密 API。

新增这个模块并与应用程序的其他部分进行交互,将是一种很好的测试应用程序架构的方法。通过这样做,我们将了解它是如何保持相关上下文的一切在单一位置的同时进行扩展的。

本章将涵盖以下主题:

  • 管理依赖项和锁文件

  • 使用 Oak 编写 Web 服务器

  • 向应用程序添加用户

  • 让我们开始吧!

技术要求

本章将在前一章我们开发的代码基础上进行构建。本章的所有代码文件都可以在这本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter05/sections

管理依赖项和锁文件

在第二章《工具链》中,我们学习了 Deno 如何让我们进行依赖管理。在本节中,我们将使用它在一个更实用的上下文中。我们首先将我们代码中所有分散的带有 URL 的导入移除,并将它们移到集中式依赖文件中。此后,我们将创建一个锁定文件,以确保我们的尚处于初级阶段的应用程序在任何安装的地方都能顺利运行。最后,我们将学习如何根据锁定文件安装项目的依赖项。

使用集中式依赖文件

在上一章中,你可能注意到了我们直接在代码中使用 URL 来依赖项。尽管这是可能的,但我们在几章前就 discouraged 过这种做法。在我们第一个阶段,这种方法对我们有效,但随着应用程序开始增长,我们必须适当地管理我们的依赖项。我们希望避免与冲突的依赖版本、URL 中的拼写错误以及依赖项分散在各个文件中等问题作斗争。为了解决这个问题,我们必须做以下几步:

  1. src目录的根目录创建一个deps.ts文件。

    这个文件可以有任何你喜欢的名字。我们目前称之为deps.ts,因为这是 Deno 文档中提到的,也是许多模块使用的命名约定。

  2. 将所有外部依赖从我们的代码中移动到deps.ts

    目前,我们唯一拥有的依赖项是标准库中的 HTTP 模块,可以在src/web/index.ts文件中找到。

  3. 将导入移动到deps.ts文件中,并将import更改为export

    export { serve } from
      "https://deno.land/std@0.83.0/http/server.ts"
    
  4. 注意固定版本是如何出现在 URL 上的:

    export { serve } from
      "https://deno.land/std@0.83.0/http/server.ts"
    

    正如我们在第二章《工具链》中学到的,这是 Deno 中版本控制的工作方式。

    现在我们需要更改依赖文件,使它们直接从deps.ts导入,而不是直接从 URL 导入。

  5. src/web/index.ts中,从deps.ts导入serve方法:

    import { serve } from "../deps.ts";
    

通过拥有一个集中式依赖文件,我们也有了确保我们所有依赖项都本地下载的一种简单方式,而无需运行任何代码。有了这个,我们现在有了一个可以运行deno cache命令(在第二章《工具链》中提到)的单文件。

创建锁定文件

在将依赖项集中后,我们需要确保安装项目的人能够获得与我们相同的依赖项版本。这是确保代码以相同方式运行的唯一方式。我们将通过使用锁定文件来实现这一点。我们在第二章《工具链》中学习了如何做到这一点;在这里,我们将将其应用于我们的应用程序。

让我们运行带有locklock-write标志的cache命令,以及锁定文件的路径和集中式依赖文件deps.ts的路径:

$ deno cache --lock=lock.json --lock-write src/deps.ts

在当前目录下应该会生成一个lock.json文件。如果你打开它,它应该包含一个 URL 的键值对,以及用于执行完整性检查的哈希。

这个锁文件应该然后添加到版本控制中。后来,如果一个同事想要安装这个同样的项目,他们只需要运行同样的命令,但不带--lock-write标志:

$ deno cache --lock=lock.json src/deps.ts

这样一来,src/deps.ts中的所有依赖项(应该是全部依赖项)将被安装,并根据lock.json文件检查它们的完整性。

现在,每次我们在项目中安装一个新的依赖时,我们必须运行带有locklock-write标志的deno cache命令,以确保锁文件被更新。

这一节就到这里!

在这一节中,我们学习了一个确保应用程序运行顺畅的简单但非常重要的步骤。这帮助我们避免未来可能出现的诸如依赖冲突和版本间行为不匹配等复杂问题。我们还保证了资源完整性,这对于 Deno 来说尤为重要,因为它的依赖项是存储在 URL 中,而不是注册表中。

在下一节中,我们将从标准库 HTTP 服务器开始将我们的应用程序重构为 Oak,这将使我们的网络代码得到简化。

使用 Oak 编写网络服务器

在上一章的末尾,我们查看了不同的网络库。经过短暂的分析后,我们最终选择了 Oak。在本节中,我们将重写我们网络应用程序的一部分,以便我们可以使用它而不是标准库的 HTTP 模块。

让我们打开src/web/index.ts,并一步步开始处理它。

遵循 Oak 的文档(deno.land/x/oak@v6.3.1),我们唯一需要做的是实例化Application对象,定义一个中间件,并调用listen方法。让我们来这样做:

  1. deps.ts文件中添加 Oak 的导入:

    export { Application } from
      "https://deno.land/x/oak@v6.3.1/mod.ts"
    

    如果你使用的是 VSCode,那么你可能会注意到有一个警告,它说在当地找不到这个版本的依赖。

  2. 让我们运行上一节中的命令来下载它并添加到锁文件中。

    不要忘记每次添加依赖时这样做,这样我们就有更好的自动完成,并且我们的锁文件总是更新的:

    $ deno cache --lock=lock.json --reload --lock-write src/deps.ts
    Download https://deno.land/std@0.83.0/http/server.ts
    Download https://deno.land/x/oak@v6.3.1/mod.ts
    Download https://deno.land/std@0.83.0/encoding/utf8.ts
    …
    

    所有必要的依赖项都下载完成后,让我们开始在代码中使用它们。

  3. 删除src/web/index.tscreateServer函数的所有代码。

  4. src/web/index.ts内部,导入Application类并实例化它。创建一个非常简单的中间件(如文档中所述)并调用listen方法:

    import { Application } from "../deps.ts";
    …
    export async function createServer({
      configuration: {
        port
      },
      museum
    }: CreateServerDependencies) {
      const app = new Application ();
      app.use((ctx) => {
        ctx.response.body = "Hello World!";
      });
      await app.listen({ port });
    }
    

请记住,在删除旧代码的同时,我们也删除了console.log,所以它现在还不会打印任何内容。让我们运行它并验证它是否有问题:

$ deno run --allow-net src/index.ts  

现在,如果我们访问http://localhost:8080,我们将在那里看到“Hello World!”响应。

现在,您可能想知道 Oak 应用程序的use方法是什么。嗯,我们将使用这个方法来定义中间件。现在,我们只是想让它修改响应并在其主体中添加一条消息。在下一章,我们将深入学习中间件函数。

记得当我们移除了console.log,并且如果应用程序正在运行,我们就不会得到任何反馈吗?在我们学习如何向 Oak 应用程序添加事件监听器的同时,我们将学习如何做到这一点。

在 Oak 应用程序中添加事件监听器

到目前为止,我们已经设法让应用程序运行,但此刻,我们没有任何消息来确认这一点。我们将利用这一点来学习 Oak 中的事件监听器。

Oak 应用程序分发两种不同类型的事件。其中一个是listen,而另一个是the listen event,我们将用它来在应用程序运行时向控制台记录。另一个是error,我们将用它来在发生错误时向控制台记录。

首先,在我们调用app.listen语句之前,让我们添加一个listen事件的监听器:

app.addEventListener("listen", e => {
  console.log(`Application running at 
    http://${e.hostname || 'localhost'}:${port}`)
})
…
await app.listen({ port });

请注意,我们不仅将消息打印到控制台,还打印出事件中的hostname并为其发送默认值,以防它未定义。

为了安全起见,并确保我们捕获任何意外错误,让我们也添加一个错误事件监听器。如果应用程序中发生了一个未处理的错误,将触发这个错误事件:

app.addEventListener("error", e => {
  console.log('An error occurred', e.message);
})

这些处理程序,特别是error处理程序,将在我们开发时帮助我们很多,当我们想要快速了解发生了什么时。后来,当接近生产阶段时,我们将添加适当的中间件日志记录。

现在,您可能认为我们仍然缺少我们在本章开始时拥有的功能,您是对的:我们从我们的应用程序中移除了列出所有博物馆的端点。

让我们再次添加它,并学习如何在 Oak 应用程序中创建路由。

在 Oak 应用程序中处理路由

Oak 提供了另一个对象,与Application类一起使用,允许我们定义路由——Router类。我们将使用这个来重新实现我们之前的路由,该路由列出了应用程序中的所有博物馆。

让我们通过向构造函数发送前缀属性来创建它。这样做意味着那里定义的所有路由都将带有该路径的前缀:

import { Application, Router } from "../deps.ts";
…
const apiRouter = new Router ({ prefix: "/api" })

现在,让我们恢复我们的功能,通过向/api/museums发送一个GET请求返回博物馆列表:

apiRouter.get("/museums", async (ctx) => {
  ctx.response.body = {
    museums: await museum.getAll()
  }
});

这里发生了一些事情。

这里,我们使用 Oak 的路由 API 定义路由,通过发送一个 URL 和一个处理函数。然后,我们的处理程序用一个上下文对象(ctx)调用。所有这些都在 Oak 的文档中详细说明(doc.deno.land/https/deno.land/x/oak@v6.3.1/mod.ts#Router),但我留给您一个简短的总结。

在 Oak 中,所有处理程序能做的事情都是通过上下文对象完成的。发出的请求在ctx.request属性中可用,而当前请求的响应在ctx.response属性中可用。头信息、cookies、参数、正文等都在这些对象中可用。一些属性,如ctx.response.body,是可写的。

提示

您可以通过查看 Deno 的文档网站更好地了解 Oak 的功能:doc.deno.land/https/deno.land/x/oak@v6.3.1/mod.ts

在这种情况下,我们使用响应体属性来设置其内容。当 Oak 能够推断出响应的类型(这里是 JSON)时,它会自动在响应中添加正确的Content-Type头。

我们将在本书中了解更多关于 Oak 及其功能的内容。下一步是连接我们最近创建的路由器。

将路由器连接到应用程序

既然我们的路由器已经定义好了,我们需要在应用程序上注册它,这样它就可以开始处理请求了。

为此,我们将使用我们之前使用过的应用程序实例的方法——use方法。

在 Oak 中,一旦定义了一个Router(并将其注册),它提供了两个返回中间件函数的方法。这些函数可以用来在应用程序上注册路由。它们如下所示:

  • routes:在应用程序中注册已注册的路由处理程序。

  • allowedMethods:为在路由器中未定义的 API 调用注册自动处理程序,返回405 – Not allowed响应。

我们将使用它们来在我们的主应用程序中注册我们的路由器,如下所示:

const apiRouter = new Router({ prefix: "/api" })
apiRouter.get("/museums", async (ctx) => {
  ctx.response.body = {
    museums: await museum.getAll()
  }
});
app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());
app.use((ctx) => {
  ctx.response.body = "Hello World!";
});

这样做后,我们的路由器在应用程序中注册了它的处理程序,它们准备好开始处理请求。

请记住,我们必须在之前定义的 Hello World 中间件之前注册这些。如果我们不这样做,Hello World 处理程序会在它们到达我们的路由器之前响应所有请求,因此它将无法工作。

现在,我们可以通过运行以下命令来运行我们的应用程序:

$ deno run --allow-net src/index.ts
Application running at http://localhost:8080

然后,我们可以对 URL 执行一个curl命令:

$ curl http://localhost:8080/api/museums
{"museums":[{"id":"1fbdd2a9-1b97-46e0-b450-62819e5772ff","name":"The Louvre","description":"The world's largest art museum and a historic monument in Paris, France.","location":{"lat":"48.860294","lng":"2.33862"}}]}

正如我们所看到的,一切都在按预期工作!我们已经成功将我们的应用程序迁移到了 Oak。

这样做后,我们大大提高了代码的可读性。我们还使用 Oak 处理了我们不想处理的事情,并且我们成功地专注于我们的应用程序。

在下一节中,我们将向应用程序添加用户概念。将创建更多的路由,以及一个全新的模块和一些处理用户的业务逻辑。

提示

本章的代码可以在github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter05/sections找到,按章节分隔。

现在,让我们向应用程序中添加一些用户!

向应用程序添加用户

我们目前已经有了一个端点在运行,列出了应用程序中的所有博物馆,但我们离最终要求还远着呢。

我们希望添加用户,以便可以注册、登录并以身份与应用程序交互。

我们将首先创建一个定义用户的对象,然后进入业务逻辑以创建并存储它。在此之后,我们将创建端点,以便我们能够通过 HTTP 与应用程序交互,从而允许用户注册。

创建用户模块

目前,我们可以称应用程序中有一个单一的“模块”:museums模块。从控制器到仓库、对象定义等,与博物馆相关的所有内容都在这里。这个模块有一个单一的接口,即它的index.ts文件。

我们这样做是为了在模块内部拥有工作的自由,同时保持其外部 API 的稳定性,以便它总是稳定的。这为我们模块之间提供了很好的解耦。为了确保模块内部的各个部分合理地解耦,我们还必须通过构造函数注入它们的依赖项,这允许我们轻松地交换部分并独立测试它们(如您将在第八章中看到的测试 - 单元和集成)。

遵循这些指南,我们将继续使用这个“模块”系统,并通过以下步骤为我们的用户创建一个模块:

  1. 创建一个名为src/users的文件夹,并将index.ts文件放在里面。

  2. 创建一个名为src/users/types.ts的文件。我们将在这里定义User类型:

    export type User = {
      username: string,
      hash: string,
      salt: string,
      createdAt: Date
    } 
    

    我们的用户对象将非常简单:它将有一个username,一个createdAt日期,然后是hashsalt两个属性。我们将使用这些来保护存储时用户密码的安全。

  3. src/users/controller.ts中创建一个名为register的用户控制器方法。它应该接收一个用户名和一个密码,然后在数据库中创建一个用户:

    type RegisterPayload = 
      { username: string, password: string };
    export class Controller {
      public async register(payload: RegisterPayload) {
        // Logic to register users
      }
    }
    
  4. src/users/types.ts中定义RegisterPayload,并在src/users/index.ts中导出它,从src/users/controller.ts中删除它。

    src/users/types.ts中添加以下内容:

    // src/users/types
    export type RegisterPayload = 
      { username: string; password: string };
    

    src/users/index.ts中添加以下内容:

    export type {
      RegisterPayload,
    } from "./types.ts";
    

    让我们现在停下来,思考一下注册逻辑。

    要创建用户,我们必须检查该用户是否存在于数据库中。如果不存在,我们将使用输入的用户名和密码创建他们,然后返回一个不包含敏感数据的对象。

    在上一章中,每次我们想要与数据源交互时,我们都使用了仓库模式。仓库保留了所有数据访问逻辑(src/museums/repository.ts)。

    在这里,我们将做同样的操作。我们已经注意到我们的控制器需要调用UserRepository中的两个方法:一个是为了检查用户是否存在,另一个是创建用户。这是我们接下来要定义的接口。

  5. 前往src/users/types.ts并定义UserRepository接口:

    export type CreateUser = 
      Pick<User, "username" | "hash" | "salt">;
    …
    export interface UserRepository {
      create: (user: CreateUser) => Promise<User>
      exists: (username: string) => Promise<boolean>
    }
    

    注意我们是如何创建一个包含User对象所有属性(除createdAt外)的CreateUser类型的。这个createdAt应该由仓库添加。

    定义了UserRepository接口后,我们就可以继续编写用户控制器,并确保它在构造函数中接收仓库的一个实例。

  6. src/users/controller.ts中,创建一个constructor,它接收用户仓库作为注入参数,并使用相同名称设置类属性:

    userRepository, we can start writing the logic for the register method.
    
  7. 编写register方法的逻辑,检查用户是否存在,如果不存在则创建他们:

    async register(payload: RegisterPayload) {
    create method of userRepository to make sure it follows the CreateUser type we defined previously. These will have to be automatically generated, but don't worry about that for now.And with this, we've pretty much finished looking at what will happen whenever someone tries to register with our application. We're still missing one thing, though. As you may have noticed, we're returning the `User` object directly from the repository, which might contain sensitive information, namely the `hash` and `salt` properties.
    
  8. src/users/types.ts中创建一个名为UserDto的类型,定义了不包含敏感数据的User对象的格式:

    export type User = {
      username: string,
      hash: string,
      salt: string,
      createdAt: Date
    }
    Pick to choose two properties from the User object; that is, createdAt and username.With `UserDto` ([`en.wikipedia.org/wiki/Data_transfer_object`](https://en.wikipedia.org/wiki/Data_transfer_object)) defined, we can now make sure our register is returning it. 
    
  9. 在名为src/users/adapter.ts的文件中创建一个名为userToUserDto的函数,该函数将用户转换为UserDto

    import type { User, UserDto } from "./types.ts";
    export const userToUserDto = (user: User): UserDto => {
      return {
        username: user.username,
        createdAt: user.createdAt
      }
    }
    
  10. 在注册方法中使用最近创建的函数,确保我们返回的是UserDto

    import { userToUserDto } from "./adapter.ts";
    …
    public async register(payload: RegisterPayload) {
      …
      const createdUser = await
        this.userRepository.create(
        payload.username,
        payload.password
      );
      return userToUserDto(createdUser);
    }
    

这样,register方法就完成了!

我们目前发送的哈希和盐是两个没有任何意义的明文字符串。

你可能想知道为什么我们不直接发送密码。这是因为我们想确保我们不会在任何数据库中以明文形式存储密码。

为了确保我们遵循最佳实践,我们将使用哈希和加盐的方法将用户的密码存储在数据库中。同时,我们还想学习一些 Deno API。我们将在下一节中进行这些操作。

在数据库中存储用户

即使我们使用的是内存数据库,我们决定不会以明文形式存储密码。相反,我们将使用一种常见的密码存储方法,称为哈希和加盐。如果你不熟悉这个方法,auth0 有一篇非常好的文章,我强烈推荐阅读(auth0.com/blog/adding-salt-to-hashing-a-better-way-to-store-passwords/).

模式本身并不复杂,你只需要按照代码来学习它。

所以,我们所要做的就是以哈希形式存储我们的密码。我们不会存储用户输入的确切哈希密码,而是存储密码加上一个随机生成的字符串,称为盐。然后将这个盐与密码一起存储,以便稍后使用。之后,我们就不需要再次解码密码了。

有了盐,每次我们想要检查密码是否正确时,只需将盐添加到用户输入的任何密码中,对其进行哈希,并验证输出是否与数据库中存储的内容匹配。

如果这对你来说仍然很奇怪,我敢保证当你查看代码时它会变得简单得多。让我们按照这些步骤实现这些函数:

  1. src/users/util.ts文件中创建一个名为hashWithSalt的函数,该函数使用提供的盐对字符串进行哈希:

    import { createHash } from
      "https://deno.land/std@0.83.0/hash/mod.ts";
    export const hashWithSalt = 
      (password: string, salt: string) => {
        const hash = createHash("sha512")
          .update(`${password}${salt}`)
            .toString();
      return hash;
    };
    

    现在应该很清楚,这个函数将返回一个字符串,它是提供字符串的hash值加上一个salt

    正如之前文章中提到的,被认为是最佳实践的是为不同的密码使用不同的盐。通过为每个密码生成不同的salt,即使一个密码的盐被泄露,我们也能确保所有的密码都是安全的。

    让我们通过创建一个生成salt的函数来继续。

  2. 使用crypto API(doc.deno.land/builtin/stable#crypto)创建一个generateSalt函数,以获取随机值并从那里生成盐字符串:

    import { encodeToString } from
      "https://deno.land/std@0.83.0/encoding/hex.ts"
    …
    export const generateSalt = () => {
      const arr = new Uint8Array(64);
      crypto.getRandomValues(arr)
      return encodeToString(arr);
    }
    

    这就是我们为应用程序生成哈希密码所需的一切。

    现在,我们可以在我们的控制器中开始使用我们刚刚创建的实用函数。让我们创建一个方法,在那里我们可以哈希我们的密码。

  3. UserController中创建一个名为getHashedUser的私有方法,它接收一个用户名和密码,并返回一个用户,以及他们的哈希值和盐:

    import { generateSalt, hashWithSalt } from
      "./util.ts";
    …
    export class Controller implements UserController {
    … 
      private async getHashedUser
        (username: string, password: string) {
        const salt = generateSalt();
        const user = {
          username,
          hash: hashWithSalt(password, salt),
          salt
        }
        return user;
      }
    …
    
  4. register方法中使用最近创建的getHashedUser方法:

    public async register(payload: RegisterPayload) {
      if (await
        this.userRepository.exists(payload.username)) {
        return Promise.reject("Username already exists");
      }
      const createdUser = await
        this.userRepository.create(
        await this.getHashedUser
          (payload.username, payload.password)
      );
      return userToDto(createdUser);
    }
    

大功告成!这样一来,我们确保我们没有存储任何明文密码。在路径中,我们学习了 Deno 中可用的crypto API。

我们所有的实现都是在使用我们之前定义的UserRepository接口。然而,目前我们还没有一个实现它的类,所以让我们创建一个。

创建用户仓库

在前一部分,我们创建了定义UserRepository的接口,所以接下来,我们要创建一个实现它的类。让我们开始吧:

  1. 创建一个名为src/users/repository.ts的文件,其中有一个导出的Repository类:

    import type { CreateUser, User, UserRepository } from
      "./types.ts";
    export class Repository implements UserRepository {
      async create(user: CreateUser) {
      }
      async exists(username: string) {
      }
    }
    

    接口保证这两个公共方法必须存在。

    现在,我们需要一种存储用户的方法。为了本章的目的,我们再次使用内存数据库,这与我们之前的博物馆做法非常相似。

  2. src/users/repository.ts类中创建一个名为storage的属性。它应该是一个 JavaScript Map,将作为用户数据库使用:

    import { User, UserRepository } from "./types.ts";
    export class Repository implements UserRepository {
      private storage = new Map<User["username"], User>();
    …
    

    有了数据库,我们现在可以实现这两个方法的逻辑。

  3. exists方法中从数据库获取用户,如果存在则返回true,否则返回false

    async exists(username: string) {
      return Boolean(this.storage.get(username));
    }
    

    Map#get函数如果无法获取记录,则返回 undefined,所以我们将它转换为 Boolean,以确保它总是返回 true 或 false。

    exists方法相当简单;它只需要检查用户是否存在于数据库中,相应地返回一个boolean

    创建用户时,我们需要比那多做一到两个步骤。不仅仅是创建,我们还需要确保调用此函数的人还向用户发送了createdAt日期。

    现在,让我们回到我们的主要任务:在数据库中创建用户。

  4. 打开src/users/repository.ts文件,实现create方法,以正确的格式创建一个user对象。

    记得向发送给函数的user对象中添加createdDate

    async create(user: CreateUser) {
      const userWithCreatedAt = 
        { ...user, createdAt: new Date() }
      this.storage.set
       (user.username, { ...userWithCreatedAt });
      return userWithCreatedAt;
    } 
    

    这样一来,我们的仓库就完成了!

    它完全实现了我们之前在UserRepository接口中定义的内容,并已准备好使用。

    下一步是把这些碎片串起来。我们已经创建了User控制器和User仓库,但它们目前还没有在任何地方被使用。

    在我们继续之前,我们需要将用户模块中的这些对象暴露给外部世界。我们将遵循我们之前定义的规则;也就是说,模块的接口将始终是其根目录下的index.ts文件。

  5. 打开src/users/index.ts,并从模块中导出ControllerRepository类及其相应的类型:

    export { Repository } from './repository.ts';
    export { Controller } from './controller.ts';
    
    export type {
      CreateUser,
      RegisterPayload,
      User,
      UserController,
      UserRepository,
    } from "./types.ts"; 
    

    现在,我们可以确保用户模块中的每个文件都是直接从这个文件(src/users/index.ts)导入类型,而不是直接导入其他文件。

现在,任何想要从用户模块导入内容的模块都必须通过index.ts文件进行导入。现在,我们可以开始考虑用户如何与刚刚编写的业务逻辑互动。由于我们正在构建一个 API,下一节我们将学习如何通过 HTTP 暴露它。

创建注册端点

业务逻辑和数据访问逻辑准备就绪,唯一缺少的是用户可以调用以注册自己的端点。

对于注册请求,我们将实现一个POST /api/users/register接口,预期是一个包含名为user的属性,该属性包含usernamepassword两个属性的 JSON 对象。

我们首先必须做的是声明src/web/index.ts中的createServer函数将依赖于UserController接口被注入。让我们开始吧:

  1. src/users/types.ts中创建UserController接口。确保它也导出在src/users/index.ts中:

    RegisterPayload from src/users/controller.ts previously.
    
  2. 现在,为了保持整洁,前往src/users/controller.ts,确保类实现了UserController

    import { RegisterPayload, UserController,
      UserRepository } from "./types.ts";
    export class Controller implements UserController
    
  3. 回到src/web/index.ts,将UserController添加到createServer依赖项中:

    import { UserController } from "../users/index.ts";
    interface CreateServerDependencies {
      configuration: {
        port: number
      },
      museum: MuseumController,
      user: UserController
    }
    export async function createServer({
      configuration: {
        port
      },
      museum,
      user
    }: CreateServerDependencies) {
    …
    

    我们现在准备好创建我们的注册处理器。

  4. 创建一个处理器,响应/api/users/registerPOST请求,并使用注入的控制器的register方法创建用户:

    apiRouter.post method to define a route that accepts a POST request. Then, we're using the body method from the request (https://doc.deno.land/https/deno.land/x/oak@v6.3.1/mod.ts#ServerRequest) to get its output in JSON. We then do a simple validation to check if the username and password are present in the request body, and at the bottom, we use the injected register method from the controller. We're wrapping it in a try catch so that we can return HTTP status code 400 if an error happens.
    

这应该足以使 Web 层能够完美地回答我们的请求。现在,我们只需要连接所有东西在一起。

将用户控制器与 Web 层连接

我们已经创建了应用程序的基本部分。有业务逻辑,有数据访问逻辑,有 Web 服务器来处理请求。唯一缺少的是将它们连接在一起的东西。在本节中,我们将实例化我们定义的接口的实际实现,并将它们注入到期望它们的内容中。

回到src/index.ts。让我们做与museums模块类似的事情。在这里,我们将导入用户仓库和控制器,实例化它们,并将控制器发送到createServer函数。

按照以下步骤进行操作:

  1. src/index.ts中,从用户模块导入ControllerRepository,并在实例化它们时发送必要的依赖项:

    import {
      Controller as UserController,
      Repository as UserRepository,
       } from './users/index.ts';
    …
    const userRepository = new UserRepository();
    const userController = new UserController({
      userRepository });
    
  2. 将用户控制器发送到createServer函数中:

    createServer({
      configuration: { port: 8080 },
      museum: museumController,
      user: userController
    })
    

好了,到这里我们就算是完成了!为了结束这一节,让我们通过运行以下命令来运行我们的应用程序:

$ deno run --allow-net src/index.ts
Application running at http://localhost:8080

现在,让我们用curl/api/users/register发送请求来测试注册端点:

$ curl -X POST -d '{"username": "alexandrempsantos", "password": "testpw" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register
{"user":{"username":"alexandrempsantos","createdAt":"2020-10-06T21:56:54.718Z"}}

正如我们所看到的,它正在运行并返回UserDto的内容。我们这一章的主要目标已经实现:我们创建了用户模块并在其中添加了一个注册用户的端点!

总结

在这一章中,我们的应用程序经历了巨大的变化!

我们首先将我们的应用程序从标准库 HTTP 模块迁移到 Oak。我们不仅迁移了服务应用程序的逻辑,而且还开始使用 Oak 的路由器定义一些路线。我们注意到,随着 Oak 封装了以前需要手动完成的任务,应用程序逻辑开始变得简单。我们成功地将标准库中的所有 HTTP 代码迁移过来,而没有改变业务逻辑,这是一个非常好的迹象,表明我们在应用程序架构方面做得很好。

我们继续前进,并学会了如何在 Oak 应用程序中监听和处理事件。随着我们开始编写更多的代码,我们也对 Oak 变得更加熟悉,理解其功能,探索其文档,并对其进行实验。

用户是任何应用程序的重要组成部分,带着这样的想法,我们把这一章的大部分时间都花在了他们身上。我们不仅在应用程序中添加了用户,还把它作为一个独立的、自包含的模块添加了进来,与博物馆并列。

一旦我们在应用程序中开发了注册用户的业务逻辑,为它添加一个持久层就变得迫切了。这意味着我们必须开发一个用户存储库,负责在数据库中创建用户。在这里,我们深入实现了一个散列和盐机制,以在数据库上安全地存储用户的密码,并在过程中学习了一些 Deno API。

用户业务逻辑完成后,我们转向了缺失的部分:HTTP 端点。我们在 HTTP 路由器中添加了注册路线,并在 Oak 的帮助下完成了所有设置。

最后,我们使用依赖注入再次连接了所有内容。由于我们所有模块的依赖都是基于接口的,我们很容易注入所需的依赖并使我们的代码工作。

这一章是我们使应用程序更具可扩展性和可读性的旅程。我们首先移除了我们的 DIY 路由器代码并将其移动到 Oak,并以添加一个重要的大业务实体——用户结束。后者也作为我们架构的测试,并展示了它如何随着不同的业务领域而扩展。

在下一章中,我们将通过添加一些有趣的功能来不断迭代应用程序。这样做,我们将完成在这里创建的功能,例如用户登录、授权以及在真实数据库中的持久化。我们还将处理包括基本日志记录和错误处理在内的常见 API 实践。

兴奋吗?我们也是——开始吧!

第六章:添加认证并连接到数据库

在上一章中,我们在应用程序中添加了一个 HTTP 框架,极大地简化了我们的代码。之后,我们在应用程序中添加了用户的概念,并开发了注册端点。目前为止,我们的应用程序已经存储了一些东西,唯一的缺点是它存储在内存中。我们将在本章解决这个问题。

在实现 oak(HTTP 框架的选择)时,我们使用的另一个概念是中间件函数。我们将从学习中间件函数是什么以及为什么它们几乎是所有 Node.js 和 Deno 框架中代码重用的标准开始本章。

然后我们将使用中间件函数并实现登录和授权。除此之外,我们还将学习如何使用中间件添加诸如请求日志和计时等标准功能到应用程序中。

随着我们的应用程序在需求方面几乎完成,我们将用剩余的时间学习如何连接到一个真正的持久化引擎。在这本书中,我们将使用 MongoDB。我们将使用之前构建的抽象确保过渡顺利。然后我们将创建一个新的用户存储库,以便它可以像我们使用内存解决方案一样连接到数据库。

到本章结束时,我们将拥有一个完整的应用程序,支持注册和用户登录。登录后,用户还可以获取博物馆列表。所有这些都是通过 HTTP 和持久化实现的业务逻辑完成的。

在本章之后,我们将只回来添加测试并部署应用程序,从而完成构建应用程序的完整周期。

在本章中,我们将涵盖以下主题:

  • 使用中间件函数

  • 添加认证

  • 使用 JWT 添加授权

  • 连接 MongoDB

让我们开始吧!

技术要求

本章所需的代码可在以下 GitHub 链接中找到:github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06

使用中间件函数

如果您使用过任何 HTTP 框架,无论是 JavaScript 还是其他框架,您可能都熟悉中间件函数的概念。如果您不熟悉,也没关系——这就是我们将在本节解释的内容。

让我们从 Express.js 文档中借用的一个定义开始:expressjs.com/en/guide/writing-middleware.html

“中间件函数是具有访问请求对象(req)、响应对象(res)以及应用程序请求-响应周期中下一个中间件函数的函数。下一个中间件函数通常由一个名为 next 的变量表示。”

中间件函数拦截请求并具有对它们进行操作的能力。它们可以在许多不同的用例中使用,如下所示:

  • 更改请求和响应对象

  • 结束请求-响应生命周期(回答请求或跳过其他处理程序)

  • 调用下一个中间件函数

中间件函数通常用于诸如检查认证令牌、根据结果自动响应、记录请求、向请求中添加特定头、用上下文丰富请求对象和错误处理等任务。

我们将在应用程序中实现一些这些示例。

中间件是如何工作的?

中间件作为堆栈处理,每个函数都可以通过运行代码在堆栈执行前后控制响应流程。

在 oak 框架中,中间件函数是通过use函数进行注册的。这个时候,你可能还记得我们之前是如何使用 oak 的路由器的。oak 的Router对象所做的就是为注册的路由创建处理程序,并导出带有这种行为的中间件函数,以便在主应用程序上注册。这些被称为routesallowedMethods (github.com/PacktPublishing/Deno-Web-Development/blob/43b7f7a40157212a3afbca5ba0ae20f862db38c4/ch5/sections/2-2-handling-routes-in-an-oak-application/museums-api/src/web/index.ts#L38).

为了更好地理解中间件函数,我们将实现它们中的几个。我们将在下一节中这样做。

通过中间件添加请求计时

让我们在请求中添加一些基本日志记录。oak 中间件函数(github.com/oakserver/oak#application-middleware-and-context)接收两个参数。第一个是上下文对象,这是所有路由都得到的一个对象,而第二个是next函数。这个函数可以用来执行堆栈中的其他中间件,允许我们控制应用程序流程。

我们首先要添加一个中间件,为响应添加X-Response-Time头。按照以下步骤操作:

  1. 打开src/web/index.ts,并注册一个通过调用next执行剩余堆栈的中间件。

    这为响应添加了一个头,其值为从请求开始到处理完毕的毫秒差:

    const app = new Application();
    .use calls; this way, all the other middleware functions will run once this has been executed.The first lines are executed before the route handler (and other middleware functions) starts handling the request. Then, the call to `next` makes sure the route handlers execute; only then is the rest of the middleware code executed, thus calculating the difference from the initial value and the current date and adding it as a header.
    
  2. 执行以下代码以启动服务器:

    $ deno run --allow-net src/index.ts
    Application running at http://localhost:8080
    
  3. 发起一个请求,并检查是否有了所需的头:

    x-response-time header there. Note that we've used the -i flag so that we're able to see the response headers on curl. 
    

有了这个,我们首次完全理解后使用了中间件函数。我们用它们来控制应用程序的流程,通过使用next,并为请求添加了一个头。

接下来,我们将对刚刚创建的中间件进行组合并添加逻辑,以记录向服务器发起的请求。

通过中间件添加请求日志

现在我们已经构建了计算请求时间的逻辑,我们处于向应用程序添加请求日志的好位置。

最终目标是让每个向应用程序发起的请求都记录在控制台上,包括其路径、HTTP 方法和响应时间;像以下示例一样:

GET http://localhost:8080/api/museums - 65ms

当然,我们也可以每个请求分别处理,但由于这是一件需要跨应用程序做的事情,我们将把它作为中间件添加到Application对象中。

我们在上一节编写的 middleware 要求处理程序(以及中间件函数)运行,以便添加响应时间(它在执行部分逻辑之前调用 next 函数)。我们需要在之前注册当前的日志中间件,它将请求时间添加到请求中。让我们开始:

  1. 打开src/web/index.ts并在控制台上添加记录请求方法、路径和时间戳的代码:

    X-Response-Time header, which is going to be set by the previous middleware to log the request to the console. We're also using next to make sure all the handlers (and middleware functions) run before we log to the console. We need this specifically because the header is set by another piece of middleware.
    
  2. 执行以下代码以启动服务器:

    $ deno run --allow-net src/index.ts
    Application running at http://localhost:8080
    
  3. 对端点执行请求:

    $ curl http://localhost:8080/api/museums
    
  4. 检查服务器进程是否将请求记录到控制台:

    $ deno run --allow-net src/index.ts
    Application running at http://localhost:8080
    GET http://localhost:8080/api/museums - 46ms
    

这样一来,我们的中间件函数就可以协同工作了!

在这里,我们在主要的应用程序对象上注册了中间件函数。然而,也可以通过调用相同的use方法在特定的 oak 路由上执行此操作。

为了给您一个例子,我们将注册一个只会在/api路由来执行的中间件。我们将做与之前完全相同的事情,但这次调用的是 APIRouter对象的use方法,如下例所示:

const apiRouter = new Router({ prefix: "/api" })
apiRouter.use(async (_, next) => {
  console.log("Request was made to API Router");
  await next();
}))
…
app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());

想要应用程序流程正常进行的中间件函数必须调用next函数。如果这种情况没有发生,堆栈中的其余中间件和路由处理程序将不会被执行,因此请求将无法得到响应。

使用中间件函数的另一种方法是将它们直接添加到请求处理程序之前。

假设我们想要创建一个添加X-Test头的中间件。我们可以在应用程序对象上编写该中间件,或者我们可以在路由本身上直接使用它,如下代码所示:

import { Application, Router, RouterMiddleware } from
  "../deps.ts";
…
const addTestHeaderMiddleware: RouterMiddleware = async (ctx,
   next) => {
  ctx.response.headers.set("X-Test", "true");
  await next();
}
apiRouter.get("/museums", addTestHeaderMiddleware, async (ctx)
  => {
  ctx.response.body = {
    museums: await museum.getAll()
  }
});

为了让之前的代码运行,我们需要在src/deps.ts中导出RouterMiddleware类型:

export type { RouterMiddleware } from
  "https://deno.land/x/oak@v6.3.1/mod.ts";

使用这个中间件,无论何时我们想要添加X-Test头,只需要在路由处理程序之前包含addTestHeaderMiddleware。它会在处理程序代码之前执行。这不仅仅适用于一个中间件,因为可以注册多个中间件函数。

中间件函数就到这里结束!

我们已经学习了使用这种非常常见的 web 框架特性来创建和共享功能的基本知识。在我们进入下一部分时,我们将继续使用它们,在那里我们将处理认证、验证令牌和授权用户。

让我们来实现我们应用程序的认证!

添加认证

在上一章中,我们向应用程序添加了创建新用户的功能。这个功能本身很酷,但如果我们不能用它来进行认证,那么它就值不了多少。这就是我们在这里要做的。

我们先来创建检查用户名和密码组合是否正确的逻辑,然后实现一个端点来完成这个任务。

之后,我们将通过从登录端点返回令牌来过渡到授权主题,然后使用该令牌来检查用户是否已认证。

让我们一步一步来,从业务逻辑和持久性层开始。

创建登录业务逻辑

我们的一种实践是,在编写新功能时,首先从业务逻辑开始。我们认为这是直观的,因为你首先考虑“业务”和用户,然后才进入技术细节。这就是我们要在这里做的。

我们首先在UserController中添加登录逻辑:

  1. 在开始之前,让我们在src/users/types.ts中为UserController接口添加login方法:

    export type RegisterPayload = { username: string;
      password: string };
    export type LoginPayload = { username: string; password:
      string };
    export interface UserController {
      register: (payload: RegisterPayload) =>
        Promise<UserDto>;
      login: (
        { username, password }: LoginPayload,
      ) => Promise<{ user: UserDto }>;
    }
    
  2. 在控制器上声明login方法;它应该接收一个用户名和密码:

    public async login(payload: LoginPayload) {
    }
    

    让我们停下来思考一下登录流程应该是什么样子:

    • 用户发送他们的用户名和密码。

    • 应用程序通过用户名从数据库中获取用户。

    • 应用程序使用数据库中的盐对用户发送的密码进行编码。

    • 应用程序比较两个加盐密码。

    • 应用程序返回一个用户和一个令牌。

      现在我们不担心令牌。然而,流程的其余部分应该为当前部分设置要求,帮助我们思考login方法的代码。

      单从这些要求来看,我们就可以理解我们需要在UserRepository上有一个通过用户名获取用户的方法。让我们来看看这个。

  3. src/users/types.ts中,向UserRepository添加一个getByUsername方法;它应该通过用户名从数据库中获取用户:

    export interface UserRepository {
      create: (user: CreateUser) => Promise<User>;  
      exists: (username: string) => Promise<boolean>
      getByUsername: (username: string) => Promise<User>
    }
    
  4. src/users/repository.ts中实现getByUsername方法:

    export class Repository implements UserRepository {
    …
    UserController and use the recently created method to get a user from the database.
    
  5. UserControllerlogin方法内部使用来自仓库的getByUsername方法:

    public async login(payload: LoginPayload) {
      hashPassword in the previous chapter when we implemented the register logic, so let's use that.
    
  6. UserController内部创建一个comparePassword方法。

    它应该接收一个密码和一个user对象。然后,它应该将用户发送的密码一旦被加盐和哈希与数据库中存储的密码进行比较:

    import {
      LoginPayload,
      RegisterPayload,
      User,
      UserController,
      UserRepository,
    } from "./types.ts";
    import { hashWithSalt } from "./util.ts"
    …
    private async comparePassword(password: string, user:
      User) {
      const hashedPassword = hashWithSalt (password,
        user.salt);
      if (hashedPassword === user.hash) {
        return Promise.resolve(true);
      }
      return Promise.reject(false);
    }
    
  7. UserControllerlogin方法上使用comparePassword方法:

    public async login(payload: LoginPayload) {
      try {
        const user = await
         this.userRepository.getByUsername(payload.username);
        await this.comparePassword(payload.password, user);
        return { user: userToUserDto(user) };
      } catch (e) {
        console.log(e);
        throw new Error('Username and password combination is
          not correct')
      }
    }
    

有了这个,我们就有了login方法的工作!

它接收一个用户名和一个密码,通过用户名获取用户,比较哈希密码,如果一切按计划进行,则返回用户。

现在我们应该准备好实现登录端点——一个将使用我们刚刚创建的登录方法。

创建登录端点

既然我们已经创建了业务逻辑和数据获取逻辑,我们就可以开始在我们的网络层中使用它。让我们创建一个POST /api/login路由,该路由应该允许用户使用他们的用户名和密码登录。按照以下步骤操作:

  1. src/web/index.ts中创建登录路由:

    apiRouter.post("/login", async (ctx) => {
    })
    
  2. 使用request.body函数获取请求体(doc.deno.land/https/raw.githubusercontent.com/oakserver/oak/main/request.ts#Request),然后将用户名和密码发送到login方法:

    apiRouter.post("/login", async (ctx) => {
      400 Bad Request) if things didn't go well.
    
  3. 如果登录成功,它应该返回我们的user

    …
    const { user: loginUser } = await user.login({ username,
      password });
    ctx.response.body = { user: loginUser };
    ctx.response.status = 201;
    …
    

    有了这些,我们应该拥有登录用户所需的一切!让我们试一试。

  4. 运行应用程序,通过运行以下命令:

    $ deno run --allow-net src/index.ts
    Application running at http://localhost:8080
    
  5. /api/users/register发送请求以注册用户,然后尝试使用创建的用户登录到/api/login

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register
    {"user":{"username":"asantos00","createdAt":"2020-10-19T21:30:51.012Z"}}
    
  6. 现在,尝试使用创建的用户登录:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw" }' -H 'Content-Type: application/json' http://localhost:8080/api/login 
    {"user":{"username":"asantos00","createdAt":"2020-10-19T21:30:51.012Z"}}
    

而且它有效!我们在注册表上创建用户,并能够在之后使用他们登录。

在本节中,我们学习了如何向我们的应用程序添加认证逻辑,并实现了login方法,该方法允许用户使用注册的用户登录。

在下一节中,我们将学习如何使用我们创建的认证来获取一个令牌,该令牌将允许我们处理授权。我们将使博物馆路线只对认证用户可用,而不是公开可用。为此,我们需要开发授权功能。让我们深入了解一下!

使用 JWT 添加授权

现在,我们有一个允许我们登录并返回已登录用户的应用程序。然而,如果我们想要在 API 中使用登录,我们必须创建一个授权机制。这个机制应该启用 API 的用户进行认证,获取一个令牌,并使用这个令牌来标识自己并访问资源。

我们这样做是因为我们希望关闭应用程序的某些路由,使它们只对认证用户可用。

我们将开发所需内容,通过使用JSON Web TokensJWT),这是一种在 API 中相当标准的认证方式。

如果你不熟悉 JWT,我将留下一个来自jwt.io的解释:

"JSON Web Tokens 是一种开放、行业标准的 RFC 7519 方法,用于在两个方之间安全地表示声明。"

它主要用于当你希望你的客户端连接到一个认证服务,然后提供你的服务器验证该认证是否由一个你信任的服务发出。

为了避免重复jwt.io已经很好地解释过的风险,我将给你一个链接,完美地解释了这个标准是什么:[jwt.io/introduction/](https://jwt.io/introduction/)。确保阅读它;我相信你们都有足够的知识来理解我们接下来如何使用它。

在本节中,由于本书的范围,我们将不会实现生成和验证 JWT 令牌的全部逻辑。这段代码可以在本书的 GitHub 仓库中找到(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/jwt-auth)。

我们将要在这里做的是将我们当前的应用程序与一个具有生成和验证 JWT 令牌功能的模块集成,这对我们的应用程序至关重要。然后,我们使用该令牌来决定是否允许用户访问博物馆路线。

让我们开始吧!

从登录返回令牌

在前一节中,我们实现了登录功能。我们开发了一些逻辑来验证用户名和密码的组合,如果成功就返回用户。

为了授权一个用户并让他们访问私有资源,我们需要知道认证的用户是谁。一个常见的做法是通过令牌来实现。我们有各种方法可以做到这一点。它们包括基本 HTTP 认证、会话令牌、JWT 令牌等替代方案。我们选择 JWT,因为我们认为它是业界广泛使用的解决方案,你们可能会在工作中遇到。如果你们没有遇到过,也不要担心;它是足够简单的。

我们需要做的第一件事是在用户登录时向用户返回令牌。我们的UserController将不得不在与userDto结合时返回该令牌。

在提供的jwt-auth模块中(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/jwt-auth),你可以检查我们导出了一个仓库。

如果我们访问文档,使用 Deno 的文档网站在doc.deno.land/https/raw.githubusercontent.com/PacktPublishing/Deno-Web-Development/master/Chapter06/jwt-auth/repository.ts,我们可以看到它导出了两个方法:getTokengenerateToken

阅读方法的文档,我们可以理解,一个为用户 ID 获取令牌,另一个生成新令牌。

让我们使用这个方法,按照以下步骤在我们的登录用例中生成新令牌:

  1. 首先,在src/users/types.ts中的UserController返回类型中添加令牌:

    export interface UserController {
      register: (payload: RegisterPayload) =>
        Promise<UserDto>
      login: ({ username, password }: LoginPayload) =>
        Promise<{ user: UserDto, UserController knows how to return a token. Looking at its logic, we can see that it should be able to delegate that responsibility by calling a method that will return that token. From the previous chapters, we know that we don't want to import our dependencies directly; we'd rather have them injected into our `constructor`. That's what we'll do here. Another thing we know is that we want to use this "third-party module" that deals with authentication. We'll need to add it to our dependencies file.
    
  2. 前往src/deps.ts,为jwt-auth模块添加导出,运行deno cache以更新锁文件并下载依赖项:

    export type {
      Algorithm,
    } from "https://raw.githubusercontent.com/PacktPublishing/
     Deno-Web-Development/master/Chapter06/jwt-auth/mod.ts";
    export {
      Repository as AuthRepository,
    } from "https://raw.githubusercontent.com/PacktPublishing/
      Deno-Web-Development/master/Chapter06/jwt-auth/mod.ts";
    
  3. 使用AuthRepository类型定义UserController构造函数的依赖项:

    authRepository, which we've just imported. We previously discovered that it exposes a generateToken method, which will be of use to the login of UserController.
    
  4. 打开src/users/controller.ts中的登录方法,并使用authRepository中的generateToken方法来获取令牌并返回它:

    public async login(payload: LoginPayload) {
        try {
          const user = await
            this.userRepository.getByUsername
              (payload.username);
          await this.comparePassword(payload.password, user);
    authRepository to get a token. If we try to run this code, we know it will fail. In fact, we just need to open `src/index.ts` to see our editor's warnings. It is complaining that we're not sending `authRepository` to `UserController`, and we should.
    
  5. 回到src/index.ts,从jwt-auth实例化AuthRepository

    import { AuthRepository } from "./deps.ts";
    …
    const authRepository = new AuthRepository({
      configuration: {
        algorithm: "HS512",
        key: "my-insecure-key",
        tokenExpirationInSeconds: 120
      }
    });
    

    你也可以通过模块的文档来检查,因为它需要一个带有三个属性的configuration对象发送,即algorithmkeytokenExpirationInSeconds

    key应该是一个秘密值,用于创建和验证 JWT,algorithm是令牌将编码的加密算法(支持 HS256、HS512 和 RS256),tokenExpirationInSeconds是令牌过期的时间。

    关于我们刚刚提到的key变量等不应存在于代码中的秘密值,我们将在下一章学习如何处理它们,那里我们将讨论应用程序配置。

    我们现在有一个AuthRepository的实例!我们应该能够将其发送到我们的UserController并使其工作。

  6. src/index.ts中,将authController发送到UserController构造函数中:

    const userController = new UserController({
      userRepository, authRepository });
    

    现在,你应该能够运行应用程序!

    现在,如果你创建几个请求来测试它,你会注意到POST /login端点仍然没有返回令牌。让我们解决这个问题!

  7. 打开src/web/index.ts,在login路线上,确保我们从响应中的login方法返回token

    apiRouter.post("/login", async (ctx) => {
      const { username, password } = await
        ctx.request.body().value;
      try {
        const { user: loginUser, token } = await user.login({
          username, password });
        ctx.response.body = { user: loginUser, token };
        ctx.response.status = 201;
      } catch (e) {
        ctx.response.body = { message: e.message };
        ctx.response.status = 400;
      }
    })
    

我们几乎完成了!我们成功完成了第一个目标:使login端点返回一个令牌。

我们接下来要实现的是确保用户在尝试访问认证路线时始终发送令牌的逻辑。

我们继续完善认证逻辑。

创建一个认证路线

有了向用户获取令牌的能力,我们现在希望确保只有登录的用户能够访问博物馆路线。

用户必须将令牌发送到Authorization头中,正如 JWT 令牌标准所定义的。如果令牌无效或不存在,用户应显示401 Unauthorized状态码。

验证用户在请求中发送的令牌是中间件函数的一个很好的用例。

为了做到这一点,既然我们正在使用oak,我们将使用一个名为oak-middleware-jwt的第三方模块。这只是一个自动验证 JWT 令牌的中间件,基于密钥,并提供对我们有用的功能。

你可以查看其文档在nest.land/package/oak-middleware-jwt

让我们在我们的网络代码中使用这个中间件,使博物馆路线只对认证用户可用。按照以下步骤操作:

  1. deps.ts文件中添加oak-middleware-jwt,并导出jwtMiddleware函数:

    export {
      jwtMiddleware,
    } from "https://x.nest.land/
       oak-middleware-jwt@2.0.0/mod.ts";
    
  2. 回到src/web/index.ts,在博物馆路由中使用jwtMiddleware,在那里发送密钥和算法。

    不要忘记我们在上一节中提到的内容——中间件函数可以通过在路由处理程序之前发送它,在任何路由中使用:

    import { Application, src/index.ts and forget to change this.This is exactly why we should extract this and expect it as a parameter to the `createServer` function.
    
  3. createServer函数中向configuration内部添加authorization作为参数:

    import { Algorithm type from the deps.ts file, which exports it from the jwt-auth module. We're doing this so that we can ensure, via types, that the algorithms that are sent are only the supported ones.
    
  4. 现在,仍然在src/web/index.ts中,使用authorization参数发送将被注入到jwtMiddleware中的值:

    const authenticated = jwtMiddleware(authorization)
    

    我们唯一缺少的是实际上将authorization值发送到createServer函数的能力。

  5. src/index.ts中,将认证配置提取到一个变量中,以便我们可以重复使用:

    import { AuthRepository, Algorithm } from "./deps.ts";
    …
    const authConfiguration = {
      algorithm: "HS512" as Algorithm,
      key: "my-insecure-key",
      tokenExpirationInSeconds: 120
    }
    const authRepository = new AuthRepository({
      configuration: authConfiguration
    });
    
  6. 让我们重复使用那个相同的变量来发送发送到createServer所需参数:

    createServer({
      configuration: {
        port: 8080,
        authorization: {
          key: authConfiguration.key,
          algorithm: authConfiguration.algorithm
        }
      },
      museum: museumController,
      user: userController
    })
    

    大功告成!让我们测试一下我们的应用程序,看看它是否按预期工作。

    请注意,期望的行为是只有认证用户才能访问博物馆路由并看到所有的博物馆。

  7. 让我们通过运行以下命令来运行应用程序:

    $ deno run --allow-net src/index.ts
    Application running at http://localhost:8080
    
  8. 让我们注册一个用户,这样我们就可以登录了:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register
    {"user":{"username":"asantos00","createdAt" :"2020-10-27T19:14:01.984Z"}}
    
  9. 现在,让我们登录,这样我们就可以获得我们的令牌:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/login
    {"user":{"username":"asantos00","createdAt":"2020-10-27T19:14:01.984Z"},"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtdXNldW1zIiwiZXhwIjoxNjAzODI2NTEzLCJ1c2VyIjoi YXNhbnRvczAwIn0.XV1vaHDpTu2SnavFla5q8eIPKCRIfDw_Kk-j8gi1 mqcz5UN3sVnk61JWCapwlh0IJ46fJdc7cw2WoMMIh-ypcg"}
    
  10. 最后,让我们尝试使用从前一个请求返回的令牌访问博物馆路由:

    Authentication header with Bearer as a prefix, as specified by the JWT specification.
    
  11. 为了确保它按预期工作,让我们尝试在不带Authorization头的相同请求中,期望一个unauthorized响应:

    -i flag with curl so that it logs the request status code and headers.
    

就这些!现在我们已经成功创建了一个仅限认证用户访问的路由。这在任何包含用户的应用程序中都非常常见。

如果我们更深入地了解这个问题,我们可以探索 JWT refreshToken,或者甚至如何从 JWT 令牌中读取用户信息,但这些都超出了本书的范围。这是我要让您自己探索的东西。

在本节中,我们实现了我们的目标,并查看了 API 的许多不同部分。

不过还有一件事缺失:与真实持久化引擎的连接。这就是我们接下来要做的——将我们的应用程序连接到 NoSQL 数据库!

连接到 MongoDB

到目前为止,我们已经实现了一个列出博物馆的应用程序,并包含用户,允许他们进行认证。这些功能已经就位,但它们都有一个缺点:它们都在内存数据库上运行。

我们选择这种方式是为了简化问题。然而,由于我们的大部分实现都不依赖于交付机制,如果数据库发生变化,它应该不会有多大变化。

从这一节的标题中,您可能已经猜到,我们将学习如何将应用程序的一个实体移动到数据库。我们将利用我们已经创建的抽象来实现这一点。这个过程将与所有实体非常相似,因此我们决定学习如何连接数据库,只为了用户模块。

稍后,如果您好奇如果所有应用程序都连接到数据库,这会怎样工作,您将有机会检查这本书的 GitHub 仓库。

为了确保我们都对类似的数据库进行操作,我们将使用 MongoDB Atlas。Atlas 是一个提供免费 MongoDB 集群的产品,我们可以用来连接我们的应用程序。

如果你不熟悉 MongoDB,这里有一个来自他们网站的“一句话解释”(www.mongodb.com/)。请随意去那里了解更多:

"MongoDB 是一个通用目的、基于文档、分布式数据库,为现代应用程序开发人员和云时代而构建。"

准备好了吗?让我们开始吧!

创建一个用户 MongoDB 存储库

我们当前的UserRepository是负责将用户与数据库连接的模块。这就是我们想要更改的,以便我们的应用程序连接到一个 MongoDB 实例,而不是一个内存数据库。

我们将通过创建新的 MongoDB 存储库、将其暴露给世界、并将我们应用程序的其余部分连接到它的步骤。

首先,通过重新组织用户模块的内部文件结构,为新的用户存储库创建空间。

重新排列我们的用户模块

我们的用户模块最初设想只有一个存储库,因此它没有相应的文件夹;只是一个repository.ts文件。现在我们考虑将用户保存到数据库的更多方法,我们需要创建它。

记得我们第一次谈到架构时,提到了它将不断进化吗?这就是正在发生的事情。

让我们重新排列用户模块,以便它可以处理多个存储库并添加一个 MongoDB 存储库,遵循我们之前创建的UserRepository接口:

  1. src/users内创建一个名为repository的文件夹,并将实际的src/users/repository.ts移动到那里,将其重命名为inMemory.ts

    └── src
        ├── museums
        ├── users
        │   ├── adapter.ts
        │   ├── controller.ts
        │   ├── index.ts
        │   ├── repository
    │ │   ├── inMemory.ts
        │   ├── types.ts
        │   └── util.ts
    
  2. 记得修复src/users/repository/inMemory.ts内的模块导入:

    import { User, UserRepository } from "../types.ts";
    import { generateSalt, hashWithSalt } from "../util.ts";
    
  3. 为了保持应用程序的运行,让我们前往src/users/index.ts并导出正确的存储库:

    export { Repository } from './repository/inMemory.ts'
    
  4. 现在,让我们创建一个 MongoDB 存储库。将其命名为mongoDb.ts,并将其放入src/users/respository文件夹内:

    import { UserRepository } from "../types.ts";
    export class Repository implements UserRepository {
      storage
      async create(username: string, password: string) {
      }
      async exists(username: string) {
      }
      async getByUsername(username: string) {
      }
    }
    

    确保它实现了我们之前定义的UserRepository接口。

这里就是所有乐趣开始的地方!现在我们有了 MongoDB 存储库,我们将开始编写它并将其连接到我们的应用程序。

安装 MongoDB 客户端库

我们已经有了一个我们存储库需要实现的方法列表。遵循接口,我们可以保证我们的应用程序会工作,不管实现方式如何。

有一件事我们可以肯定,因为我们不想一直重新发明轮子:我们将使用第三方包来处理与 MongoDB 的连接。

我们将使用deno-mongo包进行此操作(github.com/manyuanrong/deno_mongo)。

重要提示

Deno 的 MongoDB 驱动程序使用 Deno 插件 API,该 API 仍处于不稳定状态。这意味着我们将不得不以--unstable标志运行我们的应用程序。由于它目前正在使用尚未被认为是稳定的 API,因此暂时不应在生产环境中使用。

让我们看看文档中的示例,其中建立了与 MongoDB 数据库的连接:

import { MongoClient } from
  "https://deno.land/x/mongo@v0.13.0/mod.ts";
const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");
const db = client.database("test");
const users = db.collection<UserSchema>("users");

在这里,我们可以看到我们将需要创建一个 MongoDB 客户端并使用包含主机(可能包含主机的用户名和密码)的连接字符串连接到数据库。

之后,我们需要让客户端访问一个特定的数据库(在这个例子中是test)。只有这样,我们才能拥有允许我们与集合(在这个例子中是users)交互的处理程序。

首先,让我们将deno-mongo添加到我们的依赖列表中:

  1. 前往你的src/deps.ts文件,并在那里添加MongoClient的导出:

    export { MongoClient } from
      "https://deno.land/x/mongo@v0.13.0/mod.ts";
    
  2. 现在,确保运行cache命令以安装模块。我们将不得不使用--unstable标志运行它,因为我们要安装的插件在安装时也需要不稳定的 API:

    $ deno cache --lock=lock.json --lock-write --unstable src/deps.ts
    

有了这个,我们已经用我们刚刚安装的包更新了deps.ts文件!

让我们继续使用这个包来开发我们的仓库。

开发 MongoDB 仓库

从我们从文档中获得的示例中,我们学会了如何连接到数据库并创建我们想要的用户集合的处理程序。我们知道我们的仓库需要访问这个处理程序,以便它可以与集合交互。

再次,我们可以在仓库内部直接创建 MongoDB 客户端,但这将使我们无法在没有尝试连接到 MongoDB 的情况下测试该仓库。

由于我们尽可能希望将依赖项注入到模块中,我们将通过其构造函数将 MongoDB 客户端传递给我们的仓库,这在代码的其他部分非常类似于我们做的。

让我们回到我们的 MongoDB 仓库,并按照这些步骤进行操作:

  1. 在 MongoDB 仓库内创建constructor方法。

    确保它接收一个具有名为storageDatabase类型的属性的对象,该属性是由deno-mongo包导出的:

    import { User, UserRepository } from "../types.ts";
    collection method on it, to get access to the users' collection. Once we've done that, we must set it to our storage class property. Both the method and the type require a generic to be passed in. This should represent the type of object present in that collection. In our case, it is the User type.
    
  2. 现在,我们必须进入src/deps.ts文件,并从deno-mongo中导出DatabaseCollection类型:

    export { MongoClient, Collection, Database } from
      "https://deno.land/x/mongo@v0.13.0/mod.ts";
    

现在,这只是开发满足UserRepository接口的方法的问题。

这些方法将非常类似于我们为内存数据库开发的那些方法,区别在于我们现在在与 MongoDB 集合交互,而不是我们之前使用的 JavaScript Map。

现在,我们只需要实现一些方法,这些方法将创建用户、验证用户是否存在,并通过用户名获取用户。这些方法在插件文档中可用,非常接近 MongoDB 的本地 API。

这是最终类的样子:

import { CreateUser, User, UserRepository } from
 "../types.ts";
import { Collection, Database } from "../../deps.ts";
export class Repository implements UserRepository {
  storage: Collection<User>
  constructor({ storage }: RepositoryDependencies) {
    this.storage = storage.collection<User>("users");
  }
  async create(user: CreateUser) {
    const userWithCreatedAt = { ...user, createdAt: new Date() }
    this.storage.insertOne({ ...user })
    return userWithCreatedAt;
  }
  async exists(username: string) {
    return Boolean(await this.storage.count({ username }));
  }
  async getByUsername(username: string) {
    const user = await this.storage.findOne({ username });
    if (!user) {
      throw new Error("User not found");
    }
    return user;
  }
}  

我们突出了使用deno-mongo插件访问数据库的方法。注意逻辑与我们之前做的非常相似。我们在create方法中添加了创建日期,然后从 mongo 调用create方法。在exists方法中,我们调用count方法,并将其转换为boolean。对于getByUsername方法,我们使用 mongo 库中的findOne方法,发送用户名。

如果你对如何使用这些 API 有任何疑问,请查看 deno-mongo 的文档 (github.com/manyuanrong/deno_mongo).

将应用程序连接到 MongoDB

现在,为了暴露我们创建的 MongoDB 仓库,我们需要进入src/users/index.ts并将其作为Repository暴露(删除高亮显示的行):

export { Repository } from "./repository/mongoDb.ts";
export { Repository } from "./repository/inMemory.ts";

现在,我们应该在我们的编辑器和 typescript 编译器中看到抱怨,抱怨我们在src/index.ts中实例化UserRepository时没有发送正确的依赖关系,这是正确的。所以,让我们去那里修复它。

在将数据库客户端发送到UserRepository之前,它需要被实例化。通过查看deno-mongo的文档,我们可以看到以下示例:

const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");

我们没有连接到 localhost,所以我们需要稍后更改连接 URI。

让我们按照文档的示例,编写连接到 MongoDB 实例的代码。按照以下步骤操作:

  1. 在将MongoClient的导出添加到src/deps.ts文件后,在src/index.ts中导入它:

    import { MongoClient } from "./deps.ts";
    
  2. 然后,调用connectWithUri

    const client = new MongoClient();
    client.connectWithUri("mongodb://localhost:27017");
    
  3. 然后,通过在客户端上调用database方法来获取一个数据库处理器:

    const db = client.database("getting-started-with-deno");
    

这应该是我们连接到 MongoDB 所需的所有内容。唯一缺少的是将数据库处理器发送到UserRepository的代码。所以,让我们添加这个:

const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");
const db = client.database("getting-started-with-deno");
...
const userRepository = new UserRepository({ storage: db });

不应该有任何警告出现,我们应该现在能够运行我们的应用程序了!

然而,我们仍然没有一个数据库可以连接。我们接下来会看看这个问题。

连接到 MongoDB 集群

现在,我们需要连接到一个真实的 MongoDB 实例。在这里,我们将使用一个名为 Atlas 的服务。Atlas 是 MongoDB 提供的一个云 MongoDB 数据库服务。他们的免费层非常慷慨,非常适合我们的应用程序。在那里创建一个账户。完成后,我们可以创建一个 MongoDB 集群。

重要提示

如果你有其他任何 MongoDB 实例,无论是本地的还是远程的,都可以跳过下一段,直接将数据库 URI 插入代码中。

以下链接包含创建一个集群所需的所有说明:docs.atlas.mongodb.com/tutorial/create-new-cluster/

一旦集群被创建,我们还需要创建一个可以访问它的用户。前往docs.atlas.mongodb.com/tutorial/connect-to-your-cluster/index.html#connect-to-your-atlas-cluster了解如何获取连接字符串。

它应该看起来像下面这样:

mongodb+srv://<username>:<password>@clustername.mongodb.net/
  test?retryWrites=true&w=majority&useNewUrlParser=
    true&useUnifiedTopology=true

现在我们有了连接字符串,我们只需要将其传递给之前在src/index.ts中创建的代码:

const client = new MongoClient();
client.connectWithUri("mongodb+srv://<username>:<password>
  @clustername.mongodb.net/test?retryWrites=true&w=
    majority&useNewUrlParser=true&useUnifiedTopology=true");
const db = client.database("getting-started-with-deno");

应该就是我们所需要的全部内容了,让我们开始吧!

记住,由于我们使用插件 API 连接到 MongoDB,而且它仍然不稳定,所以需要以下权限以及--unstable标志:

$ deno run --allow-net --allow-write --allow-read --allow-plugin --allow-env --unstable src/index.ts
Application running at http://localhost:8080

现在,为了测试我们的UserRepository是否运行正常并且与数据库连接,让我们尝试注册并登录看看是否可行:

  1. /api/users/register发送一个POST请求来注册我们的用户:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register
    {"user":{"username":"asantos00","createdAt":"2020-11-01T23:21:58.442Z"}}
    
  2. 现在,为了确保我们连接到永久存储,我们可以停止应用程序然后再次运行它,在尝试登录之前:

    $ deno run --allow-net --allow-write --allow-read --allow-plugin --allow-env --unstable src/index.ts
    Application running at http://localhost:8080
    
  3. 现在,让我们用刚才创建的同一个用户登录:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/login
    {"user":{"username":"asantos006"},"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtdXNl dW1zIiwiZXhwIjoxNjA0MjczMDQ1LCJ1c2VyIjoiYXNhbnRvczAwNi J9.elY48It-DHse5sSszCAWuE2PzNkKiPsMIvif4v5klY1URq0togK 84wsbSskGAfe5UQsJScr4_0yxqnrxEG8viw"}
    

我们得到了响应!我们成功地将之前连接到内存数据库的应用程序连接到了一个真实的 MongoDB 数据库。如果你使用了 MongoDB,你可以在 Atlas 界面的集合菜单中查看那里创建的用户。

你注意到我们为了更改持久性机制并没有触及到任何业务或网络逻辑了吗?这证明了我们最初创建的层和抽象现在正在发挥作用,通过允许应用程序不同部分之间的解耦。

有了这些,我们完成了这一章节并把我们的用户迁移到了一个真实的数据库。我们也可以对其他模块做同样的事情,但那将会是几乎相同的工作,并且不会为你的学习体验增加太多。我想挑战你编写其他模块的逻辑,使其能够连接到 MongoDB。

如果你想要跳过这部分但是好奇它会是怎样的,那么去看看这本书的 GitHub 仓库吧。

总结

这一章节基本上已经涵盖了我们在逻辑方面对应用程序的封装。我们稍后会在第八章 测试 - 单元和集成 中添加测试以及我们所缺少的一个特性——对博物馆进行评分的能力。然而,这部分大多数已经完成。在其当前状态下,我们有一个应用程序,它的领域被划分为可以独立使用且彼此不依赖的模块。我们相信我们已经实现了一些既易于在代码中导航又可扩展的东西。

这一过程结束了不断重构和精炼架构、管理依赖项以及调整逻辑以确保代码尽可能解耦,同时尽可能容易地在未来进行更改。在完成所有这些工作时,我们设法创建了一个具有几个功能的应用程序,同时尝试绕过行业标准。

我们通过学习中间件函数开始了这一章,这是我们之前使用过,尽管我们还没有学习过它们的东西。我们理解了它们是如何工作的,以及它们如何被利用来在应用程序和路线中添加逻辑。为了更具体一点,我们进入了具体的例子,并以在应用程序中实现几个为例结束。在这里,我们添加了诸如基本日志记录和请求计时等常见功能。

然后,我们继续完成认证的旅程。在上一章中添加了用户和注册功能后,我们开始实现认证功能。我们依赖一个外部包来管理我们的 JWT 令牌,我们稍后用于我们的授权机制。在向用户提供令牌后,我们必须确保令牌有效,然后才允许用户访问应用程序。我们在博物馆路线上添加了一个认证路线,确保它只能被认证用户访问。再次使用中间件来检查令牌的有效性并在错误情况下回答请求。

我们通过向应用程序添加一个新功能来结束这一章:连接到真实数据库。在我们这样做之前,我们所有的应用程序模块都依赖于内存中的数据库。在这里,我们将其中一个模块,“用户”,移动到 MongoDB 实例。为了做到这一点,我们利用了之前创建的层来将业务逻辑与我们的持久化和交付机制分离。在这里,我们创建并实现了我们所谓的 MongoDB 存储库,确保应用程序运行顺利,但具有真正的持久化机制。我们为此示例使用了 MongoDB Atlas。

在下一章中,我们将向我们的网络应用程序添加一些内容,具体包括管理代码之外的秘密和配置的能力,这是一个众所周知的好实践。我们还将探索 Deno 在运行浏览器代码等方面的可能性,等等。下一章将结束这本书的这一部分;也就是说,构建应用程序的功能。让我们开始吧!

第七章:HTTPS,提取配置,Deno 在浏览器中运行

在上一章中,我们基本上完成了应用程序的所有功能。我们添加了授权和持久性,最终得到了一个连接到 MongoDB 实例的应用程序。在本章中,我们将专注于一些已知的最优实践,这些实践在生产应用程序中是标准的:基本安全实践和处理配置。

首先,我们将为我们的应用程序编程接口API)添加一些基本的安全特性,从跨源资源共享CORS)保护开始,以启用基于来源的请求过滤。然后,我们将学习如何在我们的应用程序中启用安全超文本传输协议HTTPS),以便它支持加密连接。这将允许用户使用安全的连接对 API 进行请求。

到目前为止,我们使用了一些秘密值,但我们并不担心它们在代码中。在本章中,我们将提取配置和秘密值,以便它们不必存在于代码库中。我们还将学习如何安全地存储和注入这些值。这样,我们可以确保这些值保持秘密,并且不在代码中。通过这样做,我们还将使不同的部署具有不同的配置成为可能。

接下来,我们将探索由 Deno 的其中一个特定功能启用的能力:在浏览器中编译和运行代码的能力。通过使用 Deno 与 ECMAScript 6(现代浏览器支持的)的兼容性,我们将 API 和前端之间的代码共享,启用一个全新的可能性世界。

利用这个特定功能,我们将探索一个特定的场景:为 API 构建一个 JavaScript 客户端。这个客户端将使用与服务器上运行的相同类型和代码部分构建,并探索由此带来的好处。

本章结束了本书的构建应用程序部分,我们一步一步地构建了一个应用程序,用逐步增加的方法添加了一些常见应用程序特性。在学习的同时,我们还确保这个应用程序尽可能接近现实,这是一本介绍性的书籍。这使我们能够在创建功能应用程序的同时学习 Deno,它的许多 API 以及一些社区包。

到本章结束时,您将熟悉以下主题:

  • 启用 CORS 和 HTTPS

  • 提取配置和秘密值

  • 在浏览器中运行 Deno 代码

技术要求

本章所需的代码文件可以在以下 GitHub 链接中找到:

链接:github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter07/sections

启用 CORS 和 HTTPS

CORS 保护和 HTTPS 支持是任何运行中的生产应用程序考虑的两个关键因素。本节将解释如何将它们添加到我们正在构建的应用程序中。

还有许多其他的安全实践可以添加到任何 API 中。由于这些不是 Deno 特定内容,并且应该单独成书,所以我们决定专注于这两个要素。

我们将首先了解 CORS 以及如何利用oak和我们所知的中间件功能来实现它。然后,我们将学习如何使用自签名证书,并使我们的 API 处理安全 HTTP 连接。

让我们开始吧,从 CORS 开始。

启用 CORS

如果你不熟悉 CORS,它是一种机制,使服务器能够指示浏览器它们应该允许从哪些源加载资源。当应用程序在 API 相同的域上运行时,CORS 甚至是不必要的,因为名称直接表明了一切。

以下是从Mozilla 开发者网络MDN)摘录的关于 CORS 的解释:

"跨源资源共享(CORS)是一个基于 HTTP 头的机制,允许服务器指示浏览器应该允许从其自身以外的任何其他源(域、协议或端口)加载资源。CORS 还依赖于一种机制,通过这种机制,浏览器向跨源资源所在的服务器发起一个“预检”请求,以检查服务器是否允许实际请求。在预检中,浏览器发送头信息,指示实际请求中将使用的 HTTP 方法和头信息。"

为了给你一个更具体的例子,想象你有一个运行在the-best-deno-api.com的 API,并且你想处理从the-best-deno-client.com发起的请求。在这里,你希望你的服务器对the-best-deno-client.com域启用 CORS。

如果你没有启用它,浏览器将向你的 API 发起一个预检请求(使用OPTIONS方法),对这个请求的响应将不会包含Access-Control-Allow-Origin: the-best-deno-client.com头,导致请求失败并阻止浏览器进一步请求。

我们将学习如何在我们的应用程序中启用这个机制,允许从http://localhost:3000发起请求。

由于我们的应用程序使用了oak框架,我们将学习如何使用这个框架来实现。然而,这与其他任何 HTTP 框架非常相似。我们基本上需要添加一个中间件函数,该函数处理请求并将请求的来源与允许的域列表进行比对。

我们将使用一个名为cors的社区包:(https://deno.land/x/cors@v1.2.1)

重要提示

我们将使用前一章中创建的代码来启动此实现。这可以在github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/sections/4-connecting-to-mongodb/museums-api找到。你也可以查看本节完成后的代码:

github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter07/sections/3-deno-on-the-browser/museums-api

在这里,我们将向我们的应用程序添加cors包,以及我们自己的允许域名列表。最终目标是使我们可以从可信网站向此 API 发送请求。

让我们这样做。按照以下步骤进行:

  1. 通过更新deps文件安装cors模块(参考第三章,《运行时和标准库》,了解如何进行此操作)。代码如下所示:

    export { oakCors } from
      "https://deno.land/x/cors@v1.2.1/oakCors.ts";
    
  2. 接下来,运行cache命令以更新lock文件,如下所示:

    $ deno cache --lock=lock.json --lock-write --unstable src/deps.ts
    
  3. src/web/index.ts上导入oakCors,并在注册路由之前注册它,如下所示:

    import { Algorithm, oakCors } from "../deps.ts"
    …
    oakCors middleware creator function, by sending it an array of allowed origins—in this case, http://localhost:3000. This will make the API answer to the OPTIONS request with an Access-Control-Allow-Origin: http://localhost:3000 header, which will signal to the browser that if the website making requests is running on http://localhost:3000, it should allow further requests.This will work just fine. However, having this *hardcoded* domain here seems a little bit strange. We've been injecting all the similar configuration to the application. Remember what we did with the `port` configuration? Let's do the same for the allowed domains.
    
  4. createServer函数的参数更改为在configuration内部接收一个名为allowedOrigins的字符串数组,并将其传递给oakCors中间件创建函数。这段代码如下所示:

    interface CreateServerDependencies {
      configuration: {
        port: number,
        authorization: {
          key: string,
          algorithm: Algorithm
        },
        oakCors middleware creator.
    
  5. 然而,还有一件事缺失——我们需要从src/index.ts发送这个allowedOrigins数组。让我们这样做,如下所示:

    createServer({
      configuration: {
        port: 8080,
        authorization: {
          key: authConfiguration.key,
          algorithm: authConfiguration.algorithm
        },
        http://localhost:3000. 
    
  6. 让我们来测试一下,首先通过以下方式运行 API:

    $ deno run --allow-net --unstable --allow-env --allow-read --allow-write --allow-plugin src/index.ts
    Application running at http://localhost:8080
    
  7. 要测试它,请在根目录(museums-api)中创建一个名为index.html的 HTML 文件,其中包含一个执行POST请求到http://localhost:8080/api/users/register的脚本。这段代码如下所示:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width,
           initial-scale=1.0" />
        <title>Test CORS</title>
      </head>
      <body>
        <div id="status"></div>
        <script type="module">              
        div tag and altering its inner HTML code in the cases that the request works or fails so that it's easier for us to diagnose.In order for us to serve the HTML file and test this, you can leverage Deno and its ability to run remote scripts.
    
  8. 在创建index.html文件的同一目录下,让我们运行 Deno 的标准库 Web 服务器,使用-p标志将端口设置为3000--host将主机设置为localhost。这段代码如下所示:

    $ deno run --allow-net --allow-read https://deno.land/std@0.83.0/http/file_server.ts -p 3000 --host localhost
    HTTP server listening on http://localhost:3000/
    
  9. 用浏览器访问http://localhost:3000,你应该会看到一个WORKING消息,如下截图所示:Figure 7.1 – 测试 CORS API 是否正常工作

    Figure 7.1 – 测试 CORS API 是否正常工作

  10. 如果你想要测试当源不在allowedOrigins列表中时会发生什么,你可以运行相同的命令,但使用不同的端口(或主机),并检查行为。这段代码如下所示:

    $ deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts -p 3001 --host localhost
    HTTP server listening on http://localhost:3001/
    

    现在,你可以在新建的统一资源定位符URL)上用浏览器导航,你应该会看到一个NOT WORKING消息。如果你查看浏览器的控制台,你还可以确认浏览器正在警告你 CORS 预检请求失败。这是期望的行为。

这就是我们需要的,以便在 API 上启用 CORS!

我们使用的第三方模块还有一些其他选项供您探索-例如过滤特定的 HTTP 方法或用不同的状态码回答预检请求。 目前,默认选项对我们来说已经足够了。 现在,我们将进入并了解如何使用户能够通过 HTTPS 连接到应用程序,添加一个额外的安全层和加密层。

启用 HTTPS

如今任何面向用户的应用程序不仅应该允许,还应该强制其用户通过 HTTPS 连接。这是一个在 HTTP 之上添加的安全层,确保所有连接都通过可信证书进行加密。 once again,我们不会尝试给出定义,而是使用以下来自 MDN 的定义(developer.mozilla.org/en-US/docs/Glossary/https):

"HTTPS(安全超文本传输协议)是 HTTP 协议的加密版本。 它使用 SSL 或 TLS 来加密客户端和服务器之间的所有通信。 这条安全连接允许客户端安全地与服务器交换敏感数据,例如执行银行活动或在线购物时。"

通过在我们的应用程序中启用 HTTPS 连接,我们可以确保它更难拦截和解释请求。如果没有这个,恶意用户可以拦截登录请求,并获得用户的密码-用户名组合。我们在保护用户的敏感数据。

由于我们在应用程序中使用oak,我们将寻找一个解决方案,了解如何在它的文档中支持 HTTPS 连接。通过查看doc.deno.land/https/deno.land/x/oak@v6.3.1/mod.ts,我们可以看到Application.listen方法接收一个configuration对象,与我们之前用来发送port变量的对象相同。 还有一些其他选项,正如我们在这里看到的:doc.deno.land/https/deno.land/x/oak@v6.3.1/mod.ts#Application。 我们将使用它来启用 HTTPS。

让我们看看如何通过以下步骤更改oak的配置,以便它支持安全连接:

  1. 打开src/web/index.ts,并在listen方法调用中添加securekeyFilecertFile选项,如下所示:

    await app.listen({
      port,
    certFile and keyFile properties expect a path to the certificate and the key files. If you don't have a certificate or you don't know how to create a self-signed one, no worries. Since this is only for learning purposes, you can use ours from the book's files at [`github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter07/sections/1-enabling-cors-and-https/museums-api`](https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter07/sections/1-enabling-cors-and-https/museums-api). Here, you'll find `certificate.pem` and `key.pem` files that you can download and use. You can download them wherever you want in your computer, but we'll assume they're at the project root folder (`museums-api`) in the next code samples.
    
  2. 为了保持我们的代码整洁且更可配置,让我们提取这些选项并将它们作为参数发送到createServer函数中,如下所示:

    export async function createServer({
      configuration: {
        …
        secure,
        keyFile,
        certFile,
      },
      …
    }: CreateServerDependencies) {
    
  3. 这是CreateServerDependencies参数类型应该的样子:

    interface CreateServerDependencies {
      configuration: {
        port: number,
        authorization: {
          key: string,
          algorithm: Algorithm
        },
        allowedOrigins: string[],
        secure: boolean,
        keyFile: string,
        certFile: string
      },
      museum: MuseumController,
      user: UserController
    }
    
  4. 这就是之后的createServer函数的样子,带有解构的参数:

    export async function createServer({
      configuration: {
        port,
        authorization,
        allowedOrigins,
        secure,
        keyFile,
        certFile,
      },
      museum,
      user
    }: CreateServerDependencies) {
    …
    await app.listen({
      port,
      secure,
      keyFile,
      certFile
    });
    
  5. 最后,我们将从src/index.ts文件发送证书和密钥文件的路径,如下所示:

    createServer({
      configuration: {
        port: 8080,
        authorization: {
          key: authConfiguration.key,
          algorithm: authConfiguration.algorithm
        },
        allowedOrigins: ['http://localhost:3000'],
        secure: true,
        certFile: './certificate.pem',
        keyFile: './key.pem'
      },
      museum: museumController,
      user: userController
    })
    

    现在,为了保持日志的准确性,我们需要修复我们之前创建的日志程序,该程序记录应用程序正在运行。这个处理程序现在应该考虑到应用程序可能通过 HTTP 或 HTTPS 运行,并相应地记录。

  6. 回到src/web/index.ts,修复监听listen事件的监听器,使其检查连接是否安全。这段代码如下:

      app.addEventListener('listen', e => {
        console.log(`Application running at 
          ${e.secure ? 'https' : 'http'}://${e.hostname ||
            'localhost'}:${port}`)
      })
    
  7. 让我们运行这个应用程序,看看它是否工作:

    $ deno run --allow-net --unstable --allow-env --allow-read --allow-plugin src/index.ts
    Application running at https://localhost:8080
    

你现在应该能够访问该 URL 并连接到应用程序。

你可能仍然会看到安全警告,但不用担心。你可以点击高级继续访问 localhost (不安全),如图所示:

Figure 7.2 – Chrome 安全警告屏幕

Figure 7.2 – Chrome 安全警告屏幕

这是由于证书是自签名的,并没有被可信任的证书机构签名。然而,这并不会有很大影响,因为过程与生产证书完全相同。

如果你仍然有问题,你可能需要直接访问 API URL,然后打开这个页面(https://localhost:8080/)。从那里,你可以按照以下链接(https://jasonmurray.org/posts/2021/thisisunsafe/)的程序,启用与不使用可信任证书的 API 的通信。之后,访问https://localhost:8080就会正常工作。

一旦你有一个合适的证书,由可信任的证书机构签名,你可以像我们这样使用它,一切都会正常工作。

这部分就到这里!我们向现有应用程序添加了 CORS 和 HTTPS 支持,提高了其安全性。

在下一节中,我们将了解如何从我们的代码中提取配置和密钥,使其从外部更加灵活和可配置。

出发吧!

提取配置和密钥

任何应用,无论其规模如何,都会有配置参数。通过查看我们在前几章中构建的应用程序,即使我们看最简单的版本——Hello World Web 服务器——我们也会发现配置值,如port值。

同时,我们发送一个名为configuration的完整对象到createServer函数中,该函数用于启动 Web 服务器。同时,我们还有一些知道应该是密钥的值。它们目前保存在代码库中,因为这对于我们的目的(学习)来说是可行的,但我们希望改变它。

我们考虑的东西比如JSON Web TokenJWT)加密密钥,或者 MongoDB 的凭据。这些绝对不是你想放进你的版本控制系统的东西。这一节就是讲这个。

我们将查看当前存储在代码库中的配置值和秘密。我们将提取它们,以便它们可以保持机密,并且只在应用程序运行时传递给应用程序。

进行这个过程可能在应用程序中配置值分散在多个模块和文件时是一项艰巨的工作。然而,由于我们遵循一些架构最佳实践,并考虑保持代码解耦和可配置,我们使自己的生活变得稍微容易了一些。

通过查看src/index.ts,你可以确认我们正在使用的所有配置值和秘密都存储在那里。这意味着所有其他模块都不知道配置,这才是正确的做法。

我们将分两个阶段进行这个“迁移”。首先,我们将所有配置值提取到一个configuration模块中,然后我们将提取秘密。

创建配置文件

首先,让我们找出代码中哪些硬编码值应该存储在配置文件中。以下代码片段突出了我们不想在代码中存储的值:

client.connectWithUri("mongodb+srv://deno-
  api:password@denocluster.wtit0.mongodb.net/
    ?retryWrites=true&w=majority")
const db = client.database("getting-started-with-deno");
…
const authConfiguration = {
  algorithm: 'HS512' as Algorithm,
  key: 'my-insecure-key',
  tokenExpirationInSeconds: 120
}
createServer({
  configuration: {
    port: 8080,
    authorization: {
      key: authConfiguration.key,
      algorithm: authConfiguration.algorithm
    },
    allowedOrigins: ['http://localhost:3000'],
    secure: true,
    certFile: './certificate.pem',
    keyFile: './key.pem'
  },
…

通过查看我们应用程序代码中的这段代码,我们可以 already 识别出一些东西,如下所示:

  • 集群 URL 和数据库名称(用户名和密码是秘密)

  • JWT 算法和过期时间(密钥是秘密)

  • Web 服务器端口

  • CORS 允许的源

  • HTTPS 证书和密钥文件路径

这里是我们将要提取的元素。我们将从创建包含所有这些值的我们的配置文件开始。

我们将使用YAML Ain't Markup LanguageYAML),因为这是一种常用于配置的文件类型。如果你不熟悉它,不用担心——它是相当简单的。你可以在官方网站上获得它的工作方式的概述,网址为:yaml.org/

我们还将确保为不同的环境有不同的配置文件,从而创建一个以环境名命名的文件。

接下来,我们将实现一个功能,允许我们将配置存储在文件中,首先创建文件本身,如下所示:

  1. 在项目的根目录下创建一个config.dev.yaml文件,并添加所有配置,像这样:

    web:
      port: 8080
    cors:
      allowedOrigins:
        - http://localhost:3000
    https:
      key: ./key.pem
      certificate: ./certificate.pem
    jwt:
      algorithm: HS512
      expirationTime: 120
    mongoDb:
      clusterURI: deno-cluster.wtit0.mongodb.net/
        ?retryWrites=true&w=majority
      database: getting-started-with-deno
    

    我们现在需要一种将此文件加载到我们应用程序中的方法。为此,我们将在src文件夹中创建一个名为config的模块。

    为了读取配置文件,我们将使用我们在第二章《工具链》中学到的文件系统函数,以及 Deno 标准库中的encoding包。

  2. src目录下创建一个名为config的文件夹,并在其中创建一个名为index.ts的文件。

    在这里,我们将定义一个名为load的函数,并将其导出。这个函数将负责加载配置文件。这段代码展示了这个功能:

    export async function load() {
    }
    
  3. 由于我们使用 TypeScript,我们将定义将成为我们配置文件的类型,并将其作为load函数的返回类型。这应该与之前创建的配置文件的结构相匹配。这段代码如下所示:

    import type { Algorithm } from "../deps.ts";
    type Configuration = {
      web: {
        port: number
      },
      cors: {
        allowedOrigins: string[],
      },
      https: {
        key: string,
        certificate: string
      },
      jwt: {
        algorithm: Algorithm,
        expirationTime: number
      },
      mongoDb: {
        clusterURI: string,
        database: string
      },
    }
    export async function load(): Promise<Configuration> {
    …
    
  4. load函数内部,我们现在应该尝试加载我们之前创建的配置文件,通过使用 Deno 文件系统 API。由于根据环境可能会有多个文件,我们还将env作为load函数的参数,默认值为dev,如下所示:

    export async function load(env = 'dev'):
      Promise<Configuration> {
      Object so that we can access it. For this, we'll use the YAML encoding functionality from the standard library.
    
  5. 从 Deno 标准库安装 YAML 编码器模块,使用deno cache确保我们更新lock文件(参考第三章,运行时和标准库),并在src/deps.ts中导出,如下所示:

    export { parse } from
      "https://deno.land/std@0.71.0/encoding/yaml.ts"
    
  6. src/config/index.ts中导入它,并使用它解析读取文件的 contents,如下所示:

    import { Algorithm, parse } from "../deps.ts";
    …
    export async function load(env = 'dev'):
      Promise<Configuration> {
      src/index.ts and do it.
    
  7. 导入config模块,调用其load函数,并使用之前硬编码的配置值。

    这是之后src/index.ts文件应该的样子:

    import { load as loadConfiguration } from
      './config/index.ts';
    const config = await loadConfiguration();
    …
    client.connectWithUri(`mongodb+srv://
      deno-api:password @${config.mongoDb.clusterURI}`);
    …
    const authConfiguration = {
      algorithm: config.jwt.algorithm,
      key: 'my-insecure-key',
      tokenExpirationInSeconds: config.jwt.expirationTime
    }
    …
    createServer({
      configuration: {
        port: config.web.port,
        authorization: {
          key: authConfiguration.key,
          algorithm: authConfiguration.algorithm,
        },
        allowedOrigins: config.cors.allowedOrigins,
        secure: true,
        certFile: config.https.certificate,
        keyFile: config.https.key
      },
    …
    

    现在我们应该能够像之前一样运行我们的应用程序,区别在于我们所有的配置现在都存放在一个单独的文件中。

关于配置就这些!我们将配置从代码中提取到config文件中,使它们更容易阅读和维护。我们还创建了一个模块,它抽象了所有配置文件的读取和解析,确保应用程序的其余部分不关心这一点。

接下来,我们将学习如何扩展这个config模块,以便它还包括从环境变量中读取的机密值。

访问秘密值

如我之前提到的,我们使用了一些应该保密的值,但我们最初把它们放在了代码里。这些值可能会因环境而异,我们想将配置作为机密信息出于安全原因。这个要求使得它们不可能被检出到版本控制中,因此它们必须存在于其他地方。

一个常见的做法是使用环境变量获取这些值。Deno 提供了一个 API,我们将使用它来读取环境变量。我们将扩展config模块,使其在导出的Configuration对象类型中也包括机密值。

以下是仍然在代码中存在的应该保密的值:

  • MongoDB 用户名

  • MongoDB 密码

  • JWT 加密密钥

让我们将它们从代码中提取出来,并通过以下步骤将它们添加到configuration对象中:

  1. src/config/index.ts中,将 MongoDB 用户名和密码以及 JWT 密钥添加到配置中,如下所示:

    type Configuration = {
      web: {…};
      cors: {…};
      https: {…};
      jwt: {
        algorithm: Algorithm;
        expirationTime: number;
        load function so that it extends the configuration object.
    
  2. configuration对象中扩展usernamepassword缺失的属性到mongoDb,以及在jwt上的key,如下所示:

    export async function load(env = 'dev'):
      Promise<Configuration> {
      const configuration = parse(await Deno.readTextFile
        (`./config.${env}.yaml`)) as Configuration;
      return {
        ...configuration,
        mongoDb: {
          ...configuration.mongoDb,
          username: 'deno-api',
          password: 'password'
        },
        jwt: {
          ...configuration.jwt,
          key: 'my-insecure-key'
        }
      };
    }
    

    剩下要做的唯一事情就是从环境中获取这些值,而不是将它们硬编码在这里。我们将使用 Deno 的 API 来实现这一点,以便访问环境(https://doc.deno.land/builtin/stable#Deno.env)。

  3. 使用Deno.env.get从环境中获取变量。我们还应该设置一个默认值,以防env变量不存在。代码如下:

    export async function load(env = 'dev'):
     Promise<Configuration> {
      const configuration = parse(await Deno.readTextFile
       (`./config.${env}.yaml`)) as Configuration;
      return {
        ...configuration,
        mongoDb: {
          ...configuration.mongoDb,
          username: Deno.env.get
            ('MONGODB_USERNAME') ||'deno-api',
          password: Deno.env.get
            ('MONGODB_PASSWORD') || 'password'
        },
        jwt: {
          ...configuration.jwt,
          key: Deno.env.get('JWT_KEY') || 'insecure-key'
        }
      }
    }
    
  4. 让我们回到src/index.ts,并使用我们刚刚添加到configuration对象中的密钥值,如下所示:

    client.connectWithUri
    (`mongodb+srv://${--allow-env permission. Let's try it.Just make sure you add the username and password values you previously created. The code can be seen in the following snippet:
    
$ MONGODB_USERNAME=add-your-username MONGODB_PASSWORD=add-your-password JWT_KEY=add-your-jwt-key deno run --allow-net --unstable --allow-env --allow-read --allow-plugin src/index.ts
Application running at https://localhost:8080

现在,如果我们尝试注册和登录,我们将验证一切是否正常工作。应用程序连接到 MongoDB,并正确地检索到 JWT 令牌——密钥正在工作!

给 Windows 用户的提示

在 Windows 系统中,您可以使用set命令(docs.microsoft.com/en-us/windows-server/administration/windows-commands/set_1)来设置环境变量。Windows 不支持内联设置环境变量,因此,您必须在运行 API 之前运行这些命令。在整个书中,我们将使用*nix 语法,但如果您使用 Windows,您必须使用set命令,如下面的代码所示。

以下是 Windows 系统的set命令:

C:\Users\alexandre>set MONGODB_USERNAME=your-username
C:\Users\alexandre>set MONGODB_PASSWORD=your-password
C:\Users\alexandre>set JWT_KEY=jwt-key

我们刚刚成功将所有的配置和密钥从代码中提取出来!这一步通过将它们写入文件使配置更容易阅读和维护,通过将它们通过环境发送到应用程序来使密钥更加安全,而不是将它们放在代码库中。

我们正在接近一个可以在不同环境中轻松部署和配置的应用程序,我们将在第九章中介绍如何部署 Deno 应用程序。

在下一节中,我们将利用 Deno 的功能将代码打包成浏览器可用的格式,创建一个非常简单的 JavaScript 客户端,该客户端可以连接到 API。这个客户端随后可以被前端客户端使用,从而抽象出 HTTP 连接;它还将与 API 代码共享代码和类型。

加入我们吧!

在浏览器中运行 Deno 代码

我们在前一章中提到的一个事情,也是我们认为 Deno 的一个卖点,就是它对 ECMAScript6 的完全兼容。这使得 Deno 代码可以被编译并在浏览器中运行。这个编译是由 Deno 本身完成的,打包器包含在工具链中。

这个功能开启了一系列的可能性。其中很多是因为 API 和客户端之间可以共享代码,这是我们将在本节中探讨的。

我们将构建一个非常简单的 JavaScript 客户端来与刚刚构建的博物馆 API 进行交互。这个客户端然后可以被任何想要连接到 API 的浏览器应用程序使用。我们将在 Deno 中编写该客户端并将其捆绑,以便它可以被客户端使用,甚至可以由应用程序本身提供服务。

我们将要编写的客户端是一个非常基础的 HTTP 客户端,因此我们不会过多关注代码。我们这样做是为了展示如何复用 Deno 中的代码和类型来生成在浏览器上运行的代码。同时,我们也将解释将客户端及其 API 放在一起的一些优点。

让我们从创建一个名为client的新模块开始,如下所示:

  1. src内部创建一个名为client的文件夹,在文件夹内部创建一个名为index.ts的文件。

  2. 让我们创建一个名为getClient的导出方法,它应该返回具有loginregistergetMuseums三个函数的 API 客户端实例。以下代码片段显示了此内容:

    interface Config {
      baseURL: string;
    }
    export function getClient(config: Config) {
      return {
        login: () => null,
        register: () => null,
        getMuseums: () => null,
      };
    }
    

    注意我们是如何获取一个包含baseURLconfig对象的。

  3. 现在,只是实现 HTTP 逻辑以向 API 发送请求的问题。我们不会逐步指导如何实现这一点,因为这相当直接,但你可以访问书中的完整客户端文件(github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter07/sections/3-deno-on-the-browser/museums-api/src/client/index.ts).

    register方法看起来会像这样:

    import type { RegisterPayload, LoginPayload,
      UserDto  } from "../users/types.ts";
    …
    const headers = new Headers();
    headers.set("content-type", "application/json");
    …
    register: ({ username, password }: RegisterPayload):
      Promise<UserDto> => {
      return fetch(
        `${config.baseURL}/api/users/register`,
        {
          body: JSON.stringify({ username, password }),
          method: "POST",
          headers,
        },
      ).then((r) => r.json());
    },
    …
    

    注意我们是如何从users模块导入类型,并将它们添加到我们的应用程序中的。这会使我们的函数更加可读,并允许我们在使用 TypeScript 客户端编写测试时进行类型检查和补全。我们还创建了一个带有content-type头的对象,该对象将用于所有请求。

    通过创建一个 HTTP 客户端,我们可以自动处理诸如认证之类的任务。在这种情况下,我们的客户端可以在用户登录后自动保存令牌,并在未来的请求中发送它。

    这就是login方法的样子:

    export function getClient(config: Config) {
      let token = "";
      …
      return {
        …
        login: (
          { username, password }: LoginPayload,
        ): Promise<{ user: UserDto; token: string }> => {
          return fetch(
            `${config.baseURL}/api/login`,
            {
              body: JSON.stringify({ username, password }),
              method: "POST",
              headers
            },
          ).then((response) => {
            const json = await response.json();
    token = json.token;
    return json;
          });
      },
    

它目前设置了客户端实例上的token变量。该令牌随后被添加到诸如getMuseums函数之类的认证请求中,如下所示:

getMuseums: (): Promise<{ museums: Museum[] }> => {
  const authenticatedHeaders = new Headers();
authenticatedHeaders.set("authorization", `Bearer
  ${token}`);
  return fetch(
    `${config.baseURL}/api/users/register`,
    {
      headers: authenticatedHeaders,
    },
).then((r) => r.json());
},

创建客户端后,我们希望分发它。我们可以使用我们在第二章*中学习的 Deno 捆绑命令来做到这一点,《工具链》。

如果我们希望由我们的 Web 服务器提供服务,我们还可以通过添加一个处理我们客户端文件捆绑内容的服务器来完成。它看起来会像这样:

apiRouter.get("/client.js", async (ctx) => {
    const {
      diagnostics,
      files,
    } = await Deno.emit(
      "./src/client/index.ts",
      { bundle: "esm" },
    );
    if (!diagnostics.length) {
      ctx.response.type = "application/javascript";
      ctx.response.body = files["deno:///bundle.js"];
      return;
    }
  });

你可能需要回到你的.vscode/settings.json文件,并启用unstable属性,这样它才能识别我们正在使用不稳定的 API。这在下述代码片段中有所展示:

{
  …
  "deno.unstable": true
}

注意我们如何使用不稳定的Deno.emitAPI 并设置content-typeapplication/javascript

然后,我们将 Deno 生成的文件(deno:///bundle.js)作为请求体发送。

这样,如果客户端对/api/client.js执行GET请求,它将打包并服务我们刚刚编写的客户端内容。最终结果将是一个打包的、与浏览器兼容的 JavaScript 文件,该文件可用于应用程序。

最后,我们将在一个 HTML 文件中使用这个客户端进行认证并从 API 获取博物馆信息。按照以下步骤进行:

  1. 在项目的根目录下创建一个名为index-with-client.html的 HTML 文件,如下代码片段所示:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width,
          initial-scale=1.0" />
        <title>Testing client</title>
      </head>
      <body>
      </body>
    </html>
    
  2. 创建一个script标签,并直接从 API URL 导入脚本,如下所示:

    <script type="module">
      import { getClient } from
        "https://localhost:8080/api/client.js";
    </script>
    
  3. 现在,只需使用我们构建的客户端。我们将使用它登录(使用你之前创建的用户)并获取博物馆列表。代码如下片段所示:

     async function main() {
      const client = getClient
        ({ baseURL: "https://localhost:8080" });
      const username = window.prompt("Username");
      const password = window.prompt("Password");
      await client.login({ username, password });
      const { museums } = await client.getMuseums();
      museums.forEach((museum) => {
        const node = document.createElement("div");
        node.innerHTML = `${museum.name} –
          ${museum.description}`;
        document.body.appendChild(node);
      });
    }
    

    我们将在用户访问页面时使用window.prompt获取用户名和密码,然后使用这些数据登录并获取博物馆信息。在此之后,我们只需将其添加到文档对象模型DOM)中,创建一个博物馆列表。

  4. 让我们再次启动应用程序,如下所示:

    $ MONGODB_USERNAME=deno-api MONGODB_PASSWORD=your-password deno run --allow-net --allow-env --unstable --allow-read --allow-plugin --allow-write src/index.ts
    Application running at https://localhost:8080
    
  5. 然后,此次为前端应用程序提供服务,这次添加了–cert--key标志,带有各自文件的路径,以使用 HTTPS 运行文件服务器,如下代码片段所示:

    $ deno run --allow-net --allow-read https://deno.land/std@0.83.0/http/file_server.ts -p 3000 --host localhost --key key.pem --cert certificate.pem
    HTTPS server listening on https://localhost:3000/
    
  6. 现在,我们可以访问 https://localhost:3000/index-with-client.html 的网页,输入用户名和密码,并在屏幕上获取博物馆列表,如下截图所示:

图 7.3 – 使用 JavaScript 客户端从 API 获取数据的网页

图 7.3 – 使用 JavaScript 客户端从 API 获取数据的网页

在上一步登录时,你需要使用一个之前在应用程序上注册的用户。如果你没有,你可以使用以下命令创建:

$ curl -X POST -d'{"username": "your-username", "password": "your-password" }' -H 'Content-Type: application/json' https://localhost:8080/api/users/register

确保将your-username替换为所需的用户名,将your-password替换为所需的密码。

至此,我们关于在浏览器上使用 Deno 的部分就结束了!

我们刚刚所做的可以进一步探索,解锁大量的潜力;这只是适用于我们用例的快速示例。这种实践使得任何浏览器应用程序更容易与刚刚编写的应用程序集成。客户端无需处理 HTTP 逻辑,只需调用方法并接收其响应。正如我们所看到的,这个客户端还可以自动处理诸如认证和 cookies 等主题。

本节探讨了 Deno 所启用的一项功能:为浏览器编译代码。

我们在应用程序的上下文中应用了它,通过创建一个抽象了用户和 API 之间关系的 HTTP 客户端。这个特性可以用来做很多事情,目前正被用于在 Deno 内部编写前端 JavaScript 代码。

正如我们在第二章《工具链》中解释的那样,当我们为浏览器编写代码时,需要考虑的唯一事情就是不要使用Deno命名空间中的函数。遵循这些限制,我们可以非常容易地在 Deno 中使用其所有优势编写代码,并将其编译为 JavaScript 进行分发。

这只是一个非常具有前景特性的介绍。这个特性,就像 Deno 一样,还处于起步阶段,社区将会发现它有很多用途。现在你也有了这方面的认识,我相信你也会想出很多好主意。

总结

这是一个我们重点关注将应用程序实践带入可部署到生产环境状态的章节。我们首先探索了基本的安全实践,向 API 添加了 CORS 机制和 HTTPS。这两个功能几乎是任何应用程序的标准,在现有基础上提供了很大的安全性提升。

另外,考虑到应用程序的部署,我们还从代码库中抽象出了配置和机密信息。我们首先创建了一个抽象概念,它将处理配置,使配置不会分散,模块只需接收它们的配置值,而无需了解它们是如何加载的。然后,我们继续在我们的当前代码库中使用这些值,这实际上变得非常简单。这一步骤将配置值从代码中移除,并将它们移动到配置文件中。

完成配置后,我们使用了同样的抽象概念来处理应用程序中的机密信息。我们实现了一个功能,它从环境变量中加载值并将它们添加到应用程序配置中。然后,我们在需要的地方使用这些机密值,比如 MongoDB 凭据和令牌签名密钥。

我们通过探索 Deno 自第一天起就提供的可能性结束了这一章节:为浏览器打包代码。将这个特性应用到我们的应用程序上下文中,我们决定编写一个 JavaScript HTTP 客户端来连接到 API。

这一步骤探讨了 API 和客户端之间共享代码的潜力,解锁了无数的可能性。借助这个功能,我们探讨了如何在 Deno 的捆绑功能下,将文件在运行时编译并服务于用户。这个功能的部分优势也将在下一章中探讨,我们将为我们的应用程序编写单元和集成测试。其中一些测试将使用在这里创建的 HTTP 客户端,利用这种实践的一个巨大优势:客户端和服务器在同一个代码库中。

在下一章,我们将深入探讨测试。我们将为书中剩余部分编写的逻辑编写测试,从业务逻辑开始。我们将学习如何通过添加测试来提高代码库的可靠性,以及我们创建的层次结构和架构在编写它们时的关键性。我们将编写的测试从单元测试到集成测试,并探索它们适用的用例。我们将看到测试在编写新功能和维护旧功能方面所增加的价值。在这个过程中,我们将了解一些新的 Deno API。

代码编写完成的标准是测试是否完成,因此我们将编写测试来结束我们的 API。

让我们开始吧!

第三部分:测试与部署

在本节中,你将创建有意义的集成和单元测试,使应用程序能够增长,并将学习如何将 Deno 应用程序容器化并在云端部署。

本节包含以下章节:

第八章:测试 – 单元和集成

代码在相应的测试编写完成后才会创建。既然你正在阅读这一章,那么我可以假设我们可以同意这个观点。然而,你可能想知道,为什么我们一个测试都没有编写呢?这是可以理解的。

我们选择不这样做,因为我们认为这会让内容更难吸收。由于我们希望你在构建应用程序的同时专注于学习 Deno,所以我们决定不这样做。第二个原因是,我们确实希望有一个完整的章节专注于测试;即这一章。

测试是软件生命周期中的一个非常重要的部分。它可以用来节省时间,明确需求,或者只是因为你希望在以后重新编写和重构时感到自信。无论动机是什么,有一点是肯定的:你会编写测试。我也真心相信测试在软件设计中扮演着重要的角色。容易测试的代码很可能容易维护。

由于我们非常倡导测试的重要性,所以我们不能不学习它就认为这是一本完整的 Deno 指南。

在这一章中,我们将编写不同类型的测试。我们将从单元测试开始,这对于开发者和维护周期来说是非常有价值的测试。然后,我们将进行集成测试,在那里我们将运行应用程序并对其执行几个请求。最后,我们将使用在前一章中编写的客户端。在这个过程中,我们将向之前构建的应用程序添加测试,一步一步地进行,并确保我们之前编写的代码正常工作。

本章还将展示我们在这本书的开始时做出的某些架构决策将如何得到回报。这将是介绍我们如何使用 Deno 及其工具链编写简单的模拟和干净、专注的测试的入门。

在这一章中,我们将介绍以下主题:

  • 在 Deno 中编写你的第一个测试

  • 编写集成测试

  • 测试网络服务器

  • 为应用程序创建集成测试

  • 一起测试 API 和客户端

  • 基准测试应用程序的部分

让我们开始吧!

技术要求

本章中将使用的代码可以在 github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter08/sections 找到。

在 Deno 中编写你的第一个测试

在我们开始编写测试之前,记住一些事情是很重要的。其中最重要的原因是,我们为什么要测试?

对于这个问题,可能会有多个答案,但大多数都会指向保证代码正在运行。你也可能说,你使用它们以便在重构时具有灵活性,或者你重视在实施时拥有短暂的反馈周期——我们可以同意这两点。由于我们在实现这些功能之前没有编写测试,所以后者对我们来说并不适用。

在本章中,我们将保持这些目标。在本节中,我们将编写我们的第一个测试。我们将使用在前几章中编写的应用程序并为其添加测试。我们将编写两种类型的测试:集成和单元测试。

集成测试将测试应用程序不同组件之间的交互。单元测试测试隔离的层。如果我们把它看作是一个光谱,那么单元测试更接近代码,而集成测试更接近用户。在用户端的尽头,还有端到端测试。这些测试通过模拟用户行为来测试应用程序,我们将在本章不涉及这些内容。

我们在开发实际应用程序时使用的部分模式,如依赖注入和控制反转,在测试时非常有用。由于我们的代码通过注入其所有依赖关系来开发,现在,只需在测试中模拟这些依赖关系即可。记住:易于测试的代码通常也易于维护。

我们首先要做的是为业务逻辑编写测试。目前,由于我们的 API 相当简单,所以它没有太多的业务逻辑。大部分都存在于UserController中,因为MuseumController非常简单。我们从后者开始。

为了在 Deno 中编写测试,我们需要使用以下内容:

  • 在第二章,工具链中介绍的 Deno 测试运行器

  • 来自 Deno 命名空间的test方法(doc.deno.land/builtin/stable#Deno.test)

  • 来自 Deno 标准库的断言方法(doc.deno.land/https/deno.land/std@0.83.0/testing/asserts.ts)

这些都是 Deno 的组成部分,由核心团队分发和维护。社区中还有许多其他可以在测试中使用的库。我们将使用 Deno 中提供的默认设置,因为它工作得很好,并允许我们编写清晰易读的测试。

让我们去学习我们如何定义一个测试!

定义测试

Deno 提供了一个定义测试的 API。这个 API,Deno.test (doc.deno.land/builtin/stable#Deno.test),提供了两种不同的定义测试的方法。

其中一个是我们在第二章中所展示的工具链,由两部分组成;也就是说,测试名称和测试函数。这可以在以下示例中看到:

Deno.test("my first test", () => {})

我们可以这样做另一种方式是调用相同的 API,这次发送一个对象作为参数。 你可以发送函数和测试名称,以及几个其他选项,到这个对象,如你在以下示例中所见:

Deno.test({
  name: "my-second-test",
  fn: () => {},
  only: false,
  sanitizeOps: true,
  sanitizeResources: true,
});

这些标志行为在文档中解释得非常清楚(doc.deno.land/builtin/stable#Deno.test),但这里有一个总结供您参考:

  • only:只运行设置为true的测试,并使测试套件失败,因此这应该只用作临时措施。

  • sanitizeOps:如果 Deno 的核心启动的所有操作都不成功,则测试失败。这个标志默认是true

  • sanitizeResources:如果测试结束后仍有资源在运行,则测试失败(这可能表明内存泄漏)。这个标志确保测试必须有一个清理阶段,其中资源被停止,默认情况下是true

既然我们知道了 API,那就让我们去编写我们的第一个测试——对MuseumController函数的单元测试。

对 MuseumController 的单元测试

在本节中,我们将编写一个非常简单的测试,它将只涵盖我们在MuseumController中编写的功能,不多不少。

它列出了应用程序中的所有博物馆,尽管目前它还没有做什么,只是作为MuseumRepository的代理工作。我们可以通过以下步骤创建这个简单功能的测试文件和逻辑:

  1. 创建src/museums/controller.test.ts文件。

    测试运行器将自动将名称中包含.test的文件视为测试文件,以及其他在第二章《工具链》中解释的约定,章节目录.

  2. 使用Deno.test(doc.deno.land/builtin/stable#Deno.test)声明第一个测试:

    Deno.test("it lists all the museums", async () => {});
    
  3. 现在,从标准库中导出断言方法,并将其命名空间命名为t,这样我们就可以在测试文件中使用它们,通过在src/deps.ts中添加以下内容:

    export * as t from
      "https://deno.land/std@0.83.0/testing/asserts.ts";
    

    如果您想了解标准库中可用的断言方法,请查看doc.deno.land/https/deno.land/std@0.83.0/testing/asserts.ts

  4. 现在,您可以使用标准库中的断言方法来编写一个测试,该测试实例化MuseumController并调用getAll方法:

    import { t } from "../deps.ts";
    import { Controller } from "./controller.ts";
    Deno.test("it lists all the museums", async () => {
      const controller = new Controller({
    MuseumController and sending in a mocked version of museumRepository, which returns a static array. This is how we're sure we're testing only the logic inside MuseumController, and nothing more. Closer to the end of the snippet, we're making sure the getAll method's result is returning the museum being returned by the mocked repository. We are doing this by using the assertion methods we exported from the dependencies file.
    
  5. 让我们运行测试并验证它是否正常工作:

    $ deno test --unstable --allow-plugin --allow-env --allow-read –-allow-write --allow-net src/museums
    running 1 tests
    test it lists all the museums ... ok (1ms)
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (1ms)
    

我们的第一个测试成功了!

注意测试输出如何列出测试的名称、状态以及运行所需的时间,同时还包括测试运行的摘要。

MuseumController内部的逻辑相当简单,因此这也一个非常简单的测试。然而,它隔离了控制器的行为,允许我们编写一个非常专注的测试。如果您对为应用程序的其他部分创建单元测试感兴趣,它们可以在本书的存储库中找到(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter08/sections/7-final-tested-version/museums-api).

在接下来的几节中,我们将编写更多有趣的测试。这些测试将教会我们如何检查应用程序不同模块之间的集成。

编写集成测试

我们在上一节创建的第一个单元测试依赖于仓库的模拟实例,以保证我们的控制器正在工作。这个测试在检测MuseumController中的错误时增加了很大的价值,但它在了解控制器是否与仓库良好工作时并不重要。

这就是集成测试的目的:它们测试多个组件如何相互集成。

在本节中,我们将编写一个集成测试,用于测试MuseumControllerMuseumRepository。这些测试将紧密模仿应用程序运行时发生的情况,并有助于我们后来在检测这两个类之间的任何问题时提供帮助。

让我们开始:

  1. src/museums中为这个模块的集成测试创建一个文件,称为museums.test.ts,并在其中添加第一个测试用例。

    它应该测试是否可以获取所有博物馆,这次使用仓库的实例而不是模拟的一个:

    Deno.test("it is able to get all the museums from
      storage", async () => {});
    
  2. 我们将首先实例化仓库并在其中添加几个测试用例:

    import { t } from "../deps.ts";
    import { Controller, Repository } from "./index.ts";
    Deno.test("it is able to get all the museums from
      storage", async () => {
      const repository = new Repository();
      repository.storage.set("0", {
        description: "museum with id 0",
        name: "my-museum",
        id: "0",
        location: { lat: "123", lng: "321" },
      });
      repository.storage.set("1", {
        description: "museum with id 1",
        name: "my-museum",
        id: "1",
        location: { lat: "123", lng: "321" },
      });
    …
    
  3. 现在我们已经有了一个仓库,我们可以用它来实例化控制器:

    const controller = new Controller({ museumRepository:
      repository });
    
  4. 现在我们可以编写我们的断言,以确保一切正常工作:

    const allMuseums = await controller.getAll();
    t.assertEquals(allMuseums.length, 2);
    t.assertEquals(allMuseums[0].name, "my-museum", "has
      name");
    t.assertEquals(
      allMuseums[0].description,
      "museum with id 0",
      "has description",
    );
    t.assertEquals(allMuseums[0].id, "0", "has id");
    t.assertEquals(allMuseums[0].location.lat, "123", "has
      latitude");
    t.assertEquals(allMuseums[0].location.lng, "321", assertEquals, allowing us to get a proper message when this assertion fails. This is something that all assertion methods support.
    
  5. 让我们运行测试并查看结果:

    $ deno test --unstable --allow-plugin --allow-env --allow-read –-allow-write --allow-net src/museums
    running 2 tests
    test it lists all the museums ... ok (1ms)
    test it is able to get all the museums from storage ... ok (1ms)
    test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (2ms)
    

它通过了!这就是我们需要的仓库和控制器集成测试的全部!当我们要更改MuseumControllerMuseumRepository中的代码时,这个测试很有用,因为它确保它们在一起工作时没有问题。

如果你对应用程序其他部分的集成测试如何工作感到好奇,我们在这本书的仓库中提供了它们(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter08/sections/7-final-tested-version/museums-api).

在第一部分,我们创建了一个单元测试,在这里,我们创建了一个集成测试,但我们还没有为应用程序的界面编写任何测试——Web 部分,它使用 HTTP。那就是我们下一节要做的。我们将学习如何孤立地测试 Web 层中的逻辑,不使用任何其他模块。

测试 Web 服务器

到目前为止,我们已经学习了如何测试应用程序的不同部分。我们始于业务逻辑,它测试如何与与持久性(仓库)交互的模块集成,但 Web 层仍然没有测试。

确实,那些测试非常重要,但我们可以说,如果 Web 层失败,用户将无法访问任何逻辑。

这就是我们将在本节中做的事情。我们将启动我们的 web 服务器,模拟其依赖项,并向其发送几个请求以确保 web单元正在工作。

让我们通过以下步骤创建 web 模块的单元测试:

  1. 前往src/web,并创建一个名为web.test.ts的文件。

  2. 现在,为了测试 web 服务器,我们需要回到src/web/index.ts中的createServer函数,并导出它创建的Application对象:

    const app = new Application();
    …
    return { app };
    
  3. 我们还希望能够在任何时候停止应用程序。我们还没有实现这一点。

    如果我们查看 oak 的文档,我们会看到它非常完善(github.com/oakserver/oak#closing-the-server)。

    要取消由listen方法启动的应用程序,我们还需要返回AbortController。所以,让我们在createServer函数的最后这样做。

    如果你不知道AbortController是什么,我将留下一个来自 Mozilla 开发者网络的链接(developer.mozilla.org/en-US/docs/Web/API/AbortController),它解释得非常清楚。简而言之,它允许我们取消一个进行中的承诺:

    const app = new Application();
    …
    const controller = new AbortController();
    const { signal } = controller;
    …
    return { app, controller };
    

    注意我们是如何实例化AbortController的,与文档中的示例类似,并在最后返回它,以及app变量。

  4. 回到我们的测试中,让我们创建一个测试,以检查服务器是否响应hello world

    Deno.test("it responds to hello world", async () => {})
    
  5. 让我们用之前创建的函数来启动服务器的实例;也就是说,createServer。记住,要调用这个函数,我们必须发送它的依赖项。在这里,我们需要模拟它们:

    import { Controller as UserController } from
      "../users/index.ts";
    import { Controller as MuseumController } from
      "../museums/index.ts";
    import { createServer } from "./index.ts";
    …
    const server = await createServer({
      configuration: {
        allowedOrigins: [],
        authorization: {
          algorithm: "HS256",
          key: "abcd",
        },
        certFile: "",
        keyFile: "",
        port: 9001,
        secure: false,
      },
    9001 and with HTTPS disabled, along with some random algorithm and key.Note how we're using TypeScript's `as` keyword to pass mocked types into the `createServer` function without TypeScript warning us about the type.
    
  6. 现在我们可以创建一个测试,通过响应 hello world 请求来检查 web 服务器是否正常工作:

    import { t } from "../deps.ts";
    …
    const response = await fetch(
      "http://localhost:9001/",
      {
        method: "GET",
      },
    ).then((r) => r.text());
    t.assertEquals(
      response,
      "Hello World!",
      "responds with hello world",
    );
    
  7. 我们需要做的最后一件事是在测试运行后关闭服务器。Deno 默认让我们测试失败,如果我们不做这件事(因为sanitizeResources默认是true),这可能会导致内存泄漏:

      server.controller.abort();
    

这标志着我们 web 层的第一个测试结束!这是一个单元测试,它测试了启动服务器的逻辑,并确保 Hello World 运行正常。接下来,我们将为端点编写更完整的测试,包括业务逻辑。

在下一节中,我们将开始为登录和注册功能编写集成测试。这些测试比我们为博物馆模块编写的测试要复杂一些,因为它们将测试整个应用程序,包括其业务逻辑、持久性和 web 逻辑。

为应用程序创建集成测试

我们迄今为止编写的三个测试都是针对单一模块的单元测试以及两个不同模块之间的集成测试。然而,为了确信我们的代码正在工作,如果我们可以测试整个应用程序的话,那将会很酷。那就是我们在这里要做的。我们将用测试配置设置我们的应用程序,并对它运行一些测试。

我们首先调用用于初始化 Web 服务器的同一个函数,然后创建所有其依赖项(控制器、存储库等)的实例。我们会确保使用诸如内存持久化之类的东西来做到这一点。这将确保我们的测试是可复制的,并且不需要复杂的拆卸阶段或连接到真实数据库,因为这将减慢测试速度。

我们将从创建一个测试文件开始,这个文件现在将包含整个应用程序的集成测试。随着应用程序的发展,可能很有必要在每个模块内部创建一个测试文件夹,但现在,这个解决方案将完全没问题。

我们将使用与生产环境中运行的非常接近的设置实例化应用程序,并对它进行一些请求和断言:

  1. 创建src/index.test.ts文件,与src/index.ts文件并列。在它里面,创建一个测试声明,测试用户是否可以登录:

    Deno.test("it returns user and token when user logs
      in", async () => {})
    
  2. 在我们开始编写这个测试之前,我们将创建一个帮助函数,该函数将为测试设置 Web 服务器。它将包含实例化控制器和存储库的所有逻辑,以及向应用程序发送配置。它看起来像这样:

    import { CreateServerDependencies } from
      "./web/index.ts";
    …
    function createTestServer(options?: CreateServerDependencies) {
      const museumRepository = new MuseumRepository();
      const museumController = new MuseumController({
        museumRepository });
      const authConfiguration = {
        algorithm: "HS256" as Algorithm,
        key: "abcd",
        tokenExpirationInSeconds: 120,
      };
      const userRepository = new UserRepository();
      const userController = new UserController(
        {
          userRepository,
          authRepository: new AuthRepository({
            configuration: authConfiguration,
          }),
        },
      );
      return createServer({
        configuration: {
          allowedOrigins: [],
          authorization: {
            algorithm: "HS256",
            key: "abcd",
          },
          certFile: "abcd",
          keyFile: "abcd",
          port: 9001,
          secure: false,
        },
        museum: museumController,
        user: userController,
        ...options,
      });
    }
    

    我们在这里所做的是非常类似于我们在src/index.ts中做的布线逻辑。唯一的区别是,我们将显式导入内存存储库,而不是 MongoDB 存储库,如下面的代码块所示:

    import {
      Controller as MuseumController,
      InMemoryRepository as MuseumRepository,
    } from "./museums/index.ts";
    import {
      Controller as UserController,
      InMemoryRepository as UserRepository,
    } from "./users/index.ts";
    

    为了让我们能够访问MuseumsUsers模块的内存存储库,我们需要进入这些模块并将它们导出。

    这就是src/users/index.ts文件应该看起来像的样子:

    export { Repository } from "./repository/mongoDb.ts";
    Repository but also exporting InMemoryRepository at the same time.Now that we have a way to create a test server instance, we can go back to writing our tests.
    
  3. 使用我们刚刚创建的帮助函数createTestServer创建一个服务器实例,并使用fetch向 API 发送注册请求:

    Deno.test("it returns user and token when user logs
      in", async () => {
      const jsonHeaders = new Headers();
      jsonHeaders.set("content-type", "application/json");
      const server = await createTestServer();
      // Registering a user
      const { user: registeredUser } = await fetch(
        "http://localhost:9001/api/users/register",
        {
          method: "POST",
          headers: jsonHeaders,
          body: JSON.stringify({
            username: "asantos00",
            password: "abcd",
          }),
        },
      ).then((r) => r.json())
    …
    
  4. 由于我们可以访问注册的用户,我们可以尝试使用同一个用户登录:

      // Login in with the createdUser
      const response = await
        fetch("http://localhost:9001/api/login", {
          method: "POST",
          headers: jsonHeaders,
          body: JSON.stringify({
          username: registeredUser.username,
          password: "abcd",
        }),
      }).then((r) => r.json())
    
  5. 我们现在准备开发一些断言来检查我们的登录响应是否是我们预期的那样:

      t.assertEquals(response.user.username, "asantos00",
        "returns username");
      t.assert(!!response.user.createdAt, "has createdAt
        date");
      t.assert(!!response.token, "has token");
    
  6. 最后,我们需要在我们的服务器上调用abort函数:

    server.controller.abort();
    

这是我们第一次进行应用程序集成测试!我们让应用程序运行起来,对它执行注册和登录请求,并断言一切按预期进行。在这里,我们逐步构建了测试,但如果你想要查看完整的测试,它可在本书的 GitHub 仓库中找到(github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter08/sections/7-final-tested-version/museums-api/src/index.test.ts)。

为了结束本节,我们将再写一个测试。还记得在前一章节中,我们创建了一些授权逻辑,只允许已登录的用户访问博物馆列表吗?让我们用另一个测试来检查这个逻辑是否生效:

  1. src/index.test.ts中创建另一个测试,用于检测带有有效令牌的用户是否可以访问博物馆列表:

    Deno.test("it should let users with a valid token
      access the museums list", async () => {})
    
  2. 由于我们想要再次登录和注册,我们将提取这些功能到一个我们可以用于多个测试的实用函数中:

    function register(username: string, password: string) {
      const jsonHeaders = new Headers();
      jsonHeaders.set("content-type", "application/json");
      return
       fetch("http://localhost:9001/api/users/register", {
         method: "POST",
         headers: jsonHeaders,
         body: JSON.stringify({
          username,
          password,
        }),
      }).then((r) => r.json());
    }
    function login(username: string, password: string) {
      const jsonHeaders = new Headers();
      jsonHeaders.set("content-type", "application/json");
      return fetch("http://localhost:9001/api/login", {
        method: "POST",
        headers: jsonHeaders,
        body: JSON.stringify({
          username,
          password,
        }),
      }).then((r) => r.json());
    }
    
  3. 有了这些函数,我们现在可以重构之前的测试,使其看起来更简洁,如下面的代码段所示:

    Deno.test("it returns user and token when user logs
      in", async () => {
      const jsonHeaders = new Headers();
      jsonHeaders.set("content-type", "application/json");
      const server = await createTestServer();
      // Registering a user
      await register("test-user", "test-password");
      const response = await login("test-user", "test-
      password");
      // Login with the created user
      t.assertEquals(response.user.username, "test-user",
        "returns username");
      t.assert(!!response.user.createdAt, "has createdAt
        date");
      t.assert(!!response.token, "has token");
      server.controller.abort();
    });
    
  4. 让我们回到我们正在编写的测试——那个检查已认证用户是否可以访问博物馆的测试——并使用registerlogin函数来注册和认证一个用户:

    Deno.test("it should let users with a valid token
      access the museums list", async () => {
      const jsonHeaders = new Headers();
      jsonHeaders.set("content-type", "application/json");
      const server = await createTestServer();
      // Registering a user
      await register("test-user", "test-password");
      const { token } = await login("test-user", "test-
        password");
    
  5. 现在,我们可以使用login函数返回的令牌,在Authorization头中进行认证请求:

      const authenticatedHeaders = new Headers();
      authenticatedHeaders.set("content-type",
        "application/json");
      login function and sending it with the Authorization header in the request to the museums route. Then, we're checking if the API responds correctly to the request with the 200 OK status code. In this case, since our application doesn't have any museums, it is returning an empty array, which we're also asserting.Since we're testing this authorization feature, we can also test that a user with no token or an invalid token can't access this same route. Let's do it.
    
  6. 创建一个测试,检查用户是否可以在没有有效令牌的情况下访问museums路由。它应该与之前的测试非常相似,只是我们现在发送一个无效的令牌:

    Deno.test("it should respond with a 401 to a user with
      an invalid token", async () => {
      const server = await createTestServer();
      const authenticatedHeaders = new Headers();
      authenticatedHeaders.set("content-type",
        "application/json");
    authenticatedHeaders.set("authorization", 
       `Bearer invalid-token`);
      const response = await
        fetch("http://localhost:9001/api/museums", {
          headers: authenticatedHeaders,
          body: JSON.stringify({
          username: "test-user",
          password: "test-password",
        }),
      });
      t.assertEquals(response.status, 401);
      t.assertEquals(await response.text(),
       "Authentication failed");
      server.controller.abort();
    });
    
  7. 现在,我们可以运行所有测试并确认它们都通过了:

    $ deno test --unstable --allow-plugin --allow-env --allow-read –-allow-write --allow-net src/index.test.ts      
    running 3 tests
    test it returns user and token when user logs in ... Application running at http://localhost:9001
    POST http://localhost:9001/api/users/register - 3ms
    POST http://localhost:9001/api/login - 3ms
    ok (24ms)
    test it should let users with a valid token access the museums list ... Application running at http://localhost:9001
    POST http://localhost:9001/api/users/register - 0ms
    POST http://localhost:9001/api/login - 1ms
    GET http://localhost:9001/api/museums - 8ms
    ok (15ms)
    test it should respond with a 400 to a user with an invalid token ... Application running at http://localhost:9001
    An error occurred Authentication failed
    ok (5ms)
    test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (45ms)
    

本书中我们将要编写的应用程序集成测试就到这里为止!如果你想要了解更多,请不要担心——关于测试的所有代码都可在本书的 GitHub 仓库中找到(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter08/sections/7-final-tested-version/museums-api)。

我们现在对代码的信心大大增强。我们创造了机会,可以在以后更少的担忧下重构、扩展和维护代码。在隔离测试代码方面,我们所做的架构决策越来越显示出其价值。

在上一章中,当我们创建我们的 JavaScript 客户端时,我们提到了将其保存在 API 代码库中的一个优点是,我们可以轻松地为客户端和 API 编写测试,以确保它们能很好地一起工作。在下一节中,我们将展示如何做到这一点。这些测试将与我们在这里所做的非常相似,唯一的区别是,我们使用的是我们创建的 API 客户端,而不是使用fetch进行原始请求。

与应用和 API 客户端一起测试

当你向用户提供 API 客户端时,你有责任确保它与你的应用程序完美配合。确保这种配合的一种方法是拥有一个完整的测试套件,不仅测试客户端本身,还测试它与 API 的集成。在这里我们将处理后者。

我们将使用 API 客户端的一个特性,并创建一个测试,确保它正在工作。再次,你会注意到这些测试与我们在上一部分末尾编写的测试有一些相似之处。我们将复制之前测试的逻辑,但这次我们将使用客户端。让我们开始吧:

  1. 在同一个src/index.test.ts文件中,为登录功能创建一个新的测试:

    Deno.test("it returns user and token when user logs in
      with the client", async () => {})
    

    为了这次测试,我们知道我们需要访问 API 客户端。我们需要从client模块中导入它。

  2. src/client/index.ts导入getClient函数:

    import { getClient } from "./client/index.ts"
    
  3. 让我们回到src/index.test.ts测试,导入client,从而创建一个它的实例。记住,它应该使用测试网络服务器创建的相同地址:

    Deno.test("it returns user and token when user logs in
      with the client", async () => {
      const server = await createTestServer();
      const client = getClient({
    createTestServer function and this test, but for simplicity, we won't do this here.
    
  4. 现在,只需编写调用使用clientregisterlogin方法的逻辑即可。最终测试将如下所示:

    Deno.test("it returns user and token when user logs in
      with the client", async () => {
    …
      // Register a user
      await client.register(
        { username: "test-user", password: "test-password"
           },
      );
      // Login with the createdUser
      const response = await client.login({
        username: "test-user",
        password: "test-password",
      });
      t.assertEquals(response.user.username, "test-user",
        "returns username");
      t.assert(!!response.user.createdAt, "has createdAt
        date");
      t.assert(!!response.token, "has token");
    …
    });
    

    注意我们是如何使用客户端的方法进行登录和注册,同时保留来自先前测试的断言。

遵循相同的指南,我们可以为客户端的所有功能编写测试,确保它与 API 一起正常工作,从而使我们能够自信地维护它。

为了简洁起见,而且因为这些测试类似于我们之前编写的测试,我们在这里不会提供为客户端所有功能编写测试的逐步指南。然而,如果你感兴趣,你可以在本书的 GitHub 存储库中找到它们(github.com/PacktPublishing/Deno-Web-Development/blob/master/Chapter08/sections/7-final-tested-version/museums-api/src/index.test.ts).

在下一节中,我们将简要介绍一个可能位于应用程序路径末端的特性。总有一天,你会发现应用程序的某些部分似乎变得很慢,你希望追踪它们的性能,这时性能测试就派上用场了。因此,我们将引入基准测试。

基准测试应用程序的部分

当涉及到在 JavaScript 中编写基准测试时,该语言本身提供了一些函数,所有这些函数都包含在高级分辨率时间 API 中。

由于 Deno 完全兼容 ES6,这些相同的功能都可以使用。如果你有时间查看 Deno 的标准库或官方网站,你会发现人们对基准测试给予了大量的关注,并且跟踪了 Deno 各个版本中的基准测试(deno.land/benchmarks)。在检查 Deno 的源代码时,你会发现有关如何编写它们的非常不错的示例集。

对于我们的应用程序,我们可以轻松地使用浏览器上可用的 API,但 Deno 本身在其标准库中提供了功能,以帮助编写和运行基准测试,因此我们将在这里使用它。

首先,我们需要了解 Deno 的标准库基准测试工具,这样我们才知道我们可以做什么(github.com/denoland/deno/blob/ae86cbb551f7b88f83d73a447411f753485e49e2/std/testing/README.md#benching)。在本节中,我们将使用两个可用的函数编写一个非常简单的基准测试;即benchrunBenchmarks。第一个将定义一个基准测试,而第二个将运行它并将结果打印到控制台。

记得我们在第五章《添加用户和迁移到 Oak》中写的函数吗?该函数用于生成一个散列和一个盐,使我们能够将用户凭据安全地存储在数据库上。我们将按照以下步骤为此编写一个基准测试:

  1. 首先,在src/users/util.ts旁边创建一个名为utilBenchmarks.ts的文件。

  2. 导入我们要测试的util中的两个函数,即generateSalthashWithSalt

    import { generateSalt, hashWithSalt } from "./util.ts"
    
  3. 是时候将基准测试工具添加到我们的src/deps.ts文件中,并运行deno cache命令(我们在第二章《工具链》中了解到它),在此处导入它。我们将把它作为benchmark导出到src/deps.ts中,以避免命名冲突:

    export * as benchmark from
      "https://deno.land/std@0.83.0/testing/bench.ts";
    
  4. 将基准测试工具导入到我们的基准文件中,并为generateSalt函数编写第一个基准测试。我们希望它运行 1000 次:

    import { benchmarks } from "../deps.ts";
    benchmarks.bench({
      name: "runsSaltFunction1000Times",
      runs: 1000,
      func: (b) => {
        bench function (as stated in the documentation). Inside this object, we're defining the number of runs, the name of the benchmark, and the test function. That function is what will run every time, since an argument is an object of the BenchmarkTimer type with two methods; that is, start and stop. These methods are used to start and stop the timings of the benchmarks, respectively.
    
  5. 我们所缺少的就是在基准测试定义之后调用runBenchmarks

    benchmarks.bench({
      name: "runsSaltFunction1000Times",
      …
    });
    benchmarks.runBenchmarks();
    
  6. 是时候运行这个文件并查看结果了。

    记住,由于我们希望我们的基准测试精确,所以我们正在处理高级分辨率时间。为了让这段代码访问这个系统特性,我们需要以--allow-hrtime权限运行这个脚本(如第二章《工具链》中所解释):

    $ deno run --unstable --allow-plugin --allow-env --allow-read --allow-write --allow-hrtime src/users/utilBenchmarks.ts
    running 1 benchmarks ...
    benchmark runsSaltFunction1000Times ...
        1000 runs avg: 0.036691561000000206ms
    benchmark result: DONE. 1 measured; 0 filtered
    
  7. 让我们为第二个函数编写基准测试,即hashWithSalt

    benchmarks.bench({
      name: "runsHashFunction1000Times",
      runs: 1000,
      func: (b) => {
        b.start();
        hashWithSalt("password", "salt");
        b.stop();
      },
    });
    benchmarks.runBenchmarks();
    
  8. 现在,让我们运行它,以便我们得到最终结果:

    $ deno run --allow-hrtime --unstable --allow-plugin --allow-env –-allow-write --allow-read src/users/utilBenchmarks.ts     
    running 2 benchmarks ...
    benchmark runsSaltFunction100Times ...
        1000 runs avg: 0.036691561000000206ms
    benchmark runsHashFunction100Times ...
        1000 runs avg: 0.02896806399999923ms
    benchmark result: DONE. 2 measured; 0 filtered
    

就是这样!现在您可以随时使用我们刚刚编写的代码来分析这些函数的性能。您可能需要这样做,是因为您已经更改了此代码,或者只是因为您想对其进行严格跟踪。您可以将其集成到诸如持续集成服务器之类的系统中,这样您就可以定期检查这些值并保持其正常运行。

这部分结束了本书的基准测试部分。我们决定给它一个简短的介绍,并展示从 Deno 获取的哪些 API 可以促进基准测试需求。我们相信,这里介绍的概念和示例将允许您跟踪应用程序的运行情况。

总结

随着这一章的结束,我们已经完成了我们一直在构建的应用程序的开发周期。我们开始时编写了一些简单的类和业务逻辑,编写了 web 服务器,最后将其与持久化集成。我们通过学习如何测试我们编写的功能来结束这一部分,这就是我们在这章所做的。我们决定使用几种不同类型的测试,而不是深入每个模块编写所有测试,因为我们认为这样做会带来更多的价值。

我们首先为业务逻辑编写了一个非常简单的单元测试,然后进行了一个带有多个类的集成测试,后来编写了一个针对 web 服务器的测试。这些测试只能通过利用我们创建的架构、遵循依赖注入原则,并尽可能使代码解耦来编写。

随着章节的进展,我们转向了集成测试,这些测试紧密地模仿了将在生产环境中运行的队列应用程序,使我们能够提高对我们刚刚编写的代码的信心。我们创建了测试,这些测试通过测试环境实例化了应用程序,使我们能够启动带有所有应用程序层(业务逻辑、持久化和网络)的 web 服务器,并对它进行断言。在这些测试中,我们可以非常有信心地断言登录和注册行为是否正常工作,因为我们向 API 发送了真实的请求。

为了结束这一章,我们将它与前一章连接起来,我们在那一章为 API 编写了一个 JavaScript 客户端。我们利用了客户端与 API 位于同一代码库中的一个巨大优势,并一起测试了客户端及其应用程序。这是确保一切按预期工作,并在发布 API 和客户端更改时保持信心的一种很好的方式。

这一章节试图展示如何在 Deno 中使用测试来提高我们对所编写代码的信心,以及当它们用于关注简单结果时所体现的价值。这类测试在应用更改时将非常有用,因为我们可以使用它们来添加更多功能或改进现有功能。在这里,我们了解到 Deno 提供的测试套件足以编写清晰、可读的测试,而无需任何第三方包。

下一章节将关注应用开发过程中最重要的阶段之一,那就是部署。我们将配置一个非常简单的持续集成环境,在该环境中我们可以将应用部署到云端。这是一个非常重要的章节,因为我们还将体验到 Deno 在部署方面的某些优势。

迫不及待地想让你的应用供用户使用吗?我们也是——让我们开始吧!

第九章:部署 Deno 应用程序

部署是任何应用程序的关键部分。我们可能构建了一个伟大的应用程序,遵循最佳实践,并编写测试,但最终,当它到达用户手中时,它将在这里证明其价值。由于我们希望这本书能带领读者经历应用程序的所有不同阶段,因此我们将使用关于应用程序部署的这一章节来结束整个周期。

请注意,我们没有—也不会—将部署视为软件开发的最后阶段,而是视为将多次运行的周期中的一个阶段。我们坚信部署不应该是一个让大家害怕的事件。相反,我们认为它们是我们向用户发送功能的高兴时刻。这是大多数公司在现代软件项目中看待部署的方式,我们确实是这种观点的忠实倡导者。部署应该是定期、自动化且容易执行的事情。它们是我们将功能带给用户的第一步,而不是最后一步。

为了使流程具有这种灵活性并在应用程序中实现快速迭代,本章将重点学习有关容器以及如何使用它们部署 Deno 应用程序的知识。

我们将利用容器化的好处,创建一个隔离的环境来安装、运行和分发我们的应用程序。

随着章节的进行,我们将学习如何使用 Docker 和git创建一个自动化工作流程,以在云环境中部署我们的 Deno 应用程序。然后,我们将调整应用程序加载配置的方式,以支持根据环境不同而有不同的配置。

到本章结束时,我们的应用程序将在云环境中运行,并有一个自动化过程,使我们能够发送它的迭代版本。

在本章中,您将熟悉以下主题:

  • 为应用程序准备环境

  • 为 Deno 应用程序创建一个Dockerfile

  • 在 Heroku 中构建和运行应用程序

  • 配置应用程序以进行部署

技术要求

本章中使用的代码可以在以下 GitHub 链接中找到:

https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter09

为应用程序准备环境

应用程序运行的环境总是对其产生很大影响。这是导致常见说法“在我的机器上工作”的其中一个重要原因。多年来,开发者一直在创造尽可能减少这种影响的解决方案。这些解决方案可以从为应用程序自动提供新的干净实例,到创建更完整的包,其中包含应用程序依赖的一切。

我们可以将虚拟机VMs)或容器视为实现这一目标的途径。两者都是为同一问题提供的不同解决方案,但有一个很大的共同点:资源隔离。两者都试图将应用程序与周围的环境隔离。这有许多原因,从安全、自动化到可靠性。

容器是提供应用程序包的一种现代方式。现代软件项目使用它们来提供一个包含应用程序运行所需的大多数内容的单一容器镜像。

如果你不清楚容器是什么,我给你提供一下来自 Docker(一个容器引擎)官方网站的定义:

容器是“一种标准的软件单元,它将代码及其所有依赖打包在一起,使得应用程序能够从一个计算环境快速、可靠地运行到另一个计算环境。”

在我们使应用程序容易部署的路径中,我们将使用 Docker 为我们的 Deno 应用程序创建这一层隔离。

最终目标是创建一个开发者可以用来部署和测试应用程序特定版本的镜像。要使用 Docker 完成这个目标,我们需要配置应用程序将运行的运行时。这个配置定义在一个叫做Dockerfile的文件中。

这是我们接下来要学习的内容。

为 Deno 应用程序创建 Dockerfile

Dockerfile将允许我们指定创建新 Docker 镜像所需的内容。这个镜像将提供包含应用程序所有依赖的环境,既可用于开发目的,也可用于生产部署。

在本节中,我们将学习如何为 Deno 应用程序创建 Docker 镜像。Docker 提供了一个基本上只包含容器运行时和隔离的基镜像,叫做alpine。我们可以使用这个镜像,配置它,安装所有需要的工具和依赖(即 Deno),等等。然而,我认为我们在这里不应该重新发明轮子,因此我们使用一个社区 Docker 镜像。

尽管这个镜像解决了许多我们的问题,我们仍然需要调整它以适应我们的用例。Dockerfile 可以组合,这意味着它们可以扩展其他 Docker 镜像的功能,我们将使用这个特性。

重要提示

如你所想象的,我们不会深入讲解 Docker 的基础知识,因为那将是一本书的内容。如果你对 Docker 感兴趣,你可以从官方文档的入门指南开始(docs.docker.com/get-started/)。然而,如果你目前对 Docker 不是非常熟悉,也不用担心,我们会解释足够让你理解我们在这里做什么的内容。

在开始之前,请确保通过以下链接中列出的步骤在您的机器上安装了 Docker Desktop:docs.docker.com/get-docker/。安装并启动它之后,我们就有了创建我们第一个 Docker 镜像所需的一切。让我们通过以下步骤来创建它:

  1. 在我们项目的根目录下创建一个Dockerfile

  2. 正如提到的,我们将使用一个社区中已经安装了 Deno 的镜像——hayd/deno (hub.docker.com/r/hayd/deno)。

    此图像的版本管理方式与 Deno 相同,因此我们将使用版本1.7.5。Docker 的FROM命令允许我们扩展一个镜像,指定其名称和版本标签,如下面的代码片段所示:

    FROM hayd/alpine-deno:1.7.5
    
  3. 我们需要做的下一件事是在容器内部定义我们将工作的文件夹。

    Docker 容器提供了一个 Linux 文件系统,默认的workdir是它的根(/)。Docker 的WORKDIR命令将允许我们在这个文件系统内的同一个文件夹中工作,使事情变得更有条理。该命令可在此处查看:

    WORKDIR /app
    
  4. 现在,我们需要将一些文件复制到我们的容器镜像中。在COPY命令的帮助下,我们将只复制安装步骤所需的文件。在我们的案例中,这些是src/deps.tslock.json文件,如下所示:

    COPY command from Docker allows us to specify a file to copy from the local filesystem (the first parameter) into the container image (the last parameter), which is currently the app folder. By dividing our workflows and copying only the files we need, we allow Docker to cache and rerun part of the steps only when the involved files changed. 
    
  5. 在容器内部拥有文件后,我们现在需要安装应用程序的依赖项。我们将使用deno cache来完成此操作,如下所示:

    deno-mongo) and also using the lock file, we have to pass additional flags. Docker's `RUN` command enables us to run this specific command inside the container.
    
  6. 依赖项安装完成后,我们现在需要将应用程序的代码复制到容器中。再一次,我们将使用 Docker 的COPY命令来完成此操作,如下所示:

    workdir (/app folder) inside the container.
    
  7. 我们需要为我们的镜像做最后一件事情,以便它能够即插即用,那就是引入一个在任何人都“执行”这个镜像时都会运行的命令。我们将使用 Docker 的CMD命令来完成此操作,如下所示:

    CMD ["deno", "run", "--allow-net", "--unstable", "--allow-env", "--allow-read", "--allow-write", "--allow-plugin", "src/index.ts" ]
    

    这个命令接受一个命令和参数数组,当有人尝试运行我们的镜像时将被执行。

这就是我们定义我们 Deno 应用程序的 Docker 镜像所需要的一切!拥有这些功能将使我们可以像在生产环境中一样在本地上运行我们的代码,这对于调试和调查生产问题来说是一个很大的优势。

我们唯一缺少的是生成工件的实际步骤。

我们将使用 Docker -t标志的build命令来设置标签。按照以下步骤生成工件:

  1. 在项目文件夹内,运行以下命令为镜像生成标签:

    museums-api in this example) and choose whichever version you want (0.0.1 in the example).This should produce the following output:
    
    

    museums-api:0.0.1。我们现在可以在私有镜像仓库中发布它,或者使用公共的,比如 Docker Hub。我们稍后设置的持续集成(CI)管道将配置为自动执行这个构建步骤。我们现在可以做的就是在本地下载这个镜像,以验证一切是否按预期工作。

    
    
  2. 为了在本地运行镜像,我们将使用 Docker CLI 的run命令。

    由于我们正在处理一个网络应用程序,我们需要暴露它正在运行的端口(在应用程序的configuration文件中设置)。我们将通过使用-p标志告诉 Docker 将容器端口绑定到我们机器的端口,如下代码段所示:

    0.0.1 of the museums-api image, binding the 8080 container port to the 8080 host port. We can now go to http://localhost:8080 and see that the application is running.
    

稍后,我们将使用这个镜像定义在 CI 系统中,每当代码更改时,它都会创建一个镜像并将其推送到生产环境。

拥有一个包含应用程序的 Docker 镜像可以服务于多个目的。其中之一就是本章的目标:部署它;然而,这个同样的 Docker 镜像也可以用来在特定版本上运行和调试一个应用程序。

让我们学习如何在特定版本的某个应用程序中运行一个终端,这是一个非常常见的调试步骤。

在容器内运行终端

我们可以使用 Docker 镜像在容器内执行一个终端。这可能在调试或尝试特定应用程序版本的某事物时很有用。

我们可以通过使用之前相同的命令和几个不同的标志来实现这一点。

我们将使用-it标志,这将允许我们有一个与镜像内的终端的交互式连接。我们还将发送一个参数,即我们希望在镜像内首先执行的命令的名称。在这个例子中是sh,标准的 Unix 外壳,正如你在以下示例中可以看到的:

$ docker run -p 8080:8080 -it  museums-api:0.0.1 sh

这将运行museums-api:0.0.1镜像,将其8080端口绑定到宿主机的8080端口,并在带有交互式终端的其中执行sh命令,如下代码段所示:

$ docker run -p 8080:8080 -it  museums-api:0.0.1 sh        
/app # ls
Dockerfile           certificate.pem      config.staging.yaml  index.html           lock.json
README.md            config.dev.yaml      heroku.yml        key.pem              src

请注意,初始打开的目录是我们定义为WORKDIR的目录,我们的所有文件都在那里。在前面的例子中,我们还执行了ls命令。

由于我们在这个容器上附加了一个交互式外壳,我们可以用它来运行 Deno 命令,例如,如下代码段所示:

/app # deno --version
deno 1.7.2 (release, x86_64-unknown-linux-gnu)
v8 8.9.255.3
typescript 4.1.3 
/app #

这将使我们在开发和调试方面具备一整套可能性,因为我们将有能力查看应用程序在特定版本上的运行情况。

我们已经完成了这一节的讨论。在这里,我们探讨了容器化,介绍了 Docker 以及它是如何让我们创建一个“应用程序包”的。这个包将负责应用程序周围的环境,确保它无论在何处只要有 Docker 运行时就可以运行。

在下一节中,我们将使用这个相同的包,在云环境中部署我们本地构建的镜像。让我们开始吧!

在 Heroku 中构建和运行应用程序

正如我们在章节开始时提到的,我们的初步目标是有一种简单、自动化且可复制的部署应用程序的方法。在上一节中,我们创建了将作为该基础的容器镜像。下一步是创建一个管道,以便在有更新时构建和部署我们的代码。我们将使用git作为我们的真相来源和触发管道构建的机制。

我们将代码部署的平台是 Heroku。这是一个旨在通过提供一套工具简化开发人员和公司部署过程的平台,这些工具消除了诸如配置机器和设置大型 CI 基础架构等常见障碍。使用这样的平台,我们可以更专注于应用程序以及 Deno,这是本书的目的。

在这里,我们将使用我们之前创建的Dockerfile,并设置它在 Heroku 上部署并运行。我们将了解如何轻松地在那里设置应用程序,稍后我们还将探索如何通过环境变量定义配置值。

在开始之前,请确保您已经创建了账户并安装了 Heroku CLI,然后按照这里提供的两个链接进行步骤指南:

现在我们已经创建了账户并安装了 CLI,我们可以开始在 Heroku 上设置我们的项目。

在 Heroku 上创建应用程序

在这里,我们将了解在 Heroku 上进行身份验证并创建应用程序所需的步骤。我们几乎准备好了,但还有一件事我们必须先弄清楚。

重要提示

由于 Heroku 使用git作为真相来源,您将无法在书籍的文件仓库内执行以下过程,因为它已经是一个包含应用程序多个阶段的 Git 仓库。

我建议您将应用程序文件复制到另一个不同的文件夹中,位于书籍仓库外部,并从那里开始这个过程。

您可以从第八章测试 – 单元和集成(github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter08/sections/7-final-tested-version/museums-api)复制最新版本的运行中应用程序,这是我们将在这里使用的版本。

现在文件已经被复制到了一个新的文件夹(主仓库外部),接下来通过以下步骤在 Heroku 上部署Dockerfile并运行它:

  1. 我们首先要做的就是使用 CLI 登录,运行heroku login。这应该会打开一个浏览器窗口,您可以在其中输入您的用户名和密码,如下面的代码片段所示:

    $ heroku login
    heroku: Press any key to open up the browser to login or q to exit:
    Opening browser to https://cli-auth.heroku.com/auth/cli/...
    Logging in... done
    Logged in as your-login-email@gmail.com
    
  2. 由于 Heroku 部署是基于git的,而我们现在在一个不是 Git 仓库的文件夹中,我们需要初始化它,如下所示:

    $ git init
    Initialized empty Git repository in /Users/alexandre/dev/ museums-api/.git/
    
  3. 然后,我们通过使用heroku create来创建 Heroku 上的应用程序,如下所示:

    heroku, which is where we have to push our code to trigger the deployment process.
    

如果您在运行前面的命令后访问 Heroku 仪表板,您会发现那里有一个新的应用程序。当应用程序创建时,Heroku 在控制台打印一个 URL;然而,由于我们还没有配置任何内容,我们的应用程序目前还不可用。

我们接下来需要做的是配置 Heroku,以便它在每次部署时知道它应该构建和执行我们的镜像。

构建和运行 Docker 镜像

默认情况下,Heroku 试图通过运行代码使您的应用程序可用。这对于许多语言来说都是可能的,您可以在 Heroku 文档中找到相关指南。由于我们想要使用容器来运行我们的应用程序,因此该过程需要一些额外的配置。

Heroku 提供了一组功能,允许我们定义当代码发生更改时会发生什么,通过一个名为heroku.yml的文件。我们现在将创建该文件,如下所示:

  1. 在仓库根目录下创建一个heroku.yml文件,并添加以下代码行,以便使用我们在上一节中创建的Dockerfile使用 Docker 构建我们的镜像:

    build:
      docker:
        web: Dockerfile
    
  2. 现在,在同一个文件中,添加以下代码行以定义 Heroku 将执行以运行应用程序的命令:

    build:
      docker:
        web: Dockerfile
    Dockerfile, and that's true. Normally, Heroku would run the command from the `Dockerfile` to execute the image, and it would work. It happens that Heroku doesn't run these commands as root, as a security best practice. Deno, at its current stage, needs root privileges whenever you want to use plugins (an unstable feature). As our application is using a plugin to connect with MongoDB, we need this command to be explicitly defined on `heroku.yml` so that it is run with root privileges and works when Deno is starting up the application.  
    
  3. 接下来我们需要做的就是将应用程序类型设置为container,告知 Heroku 我们希望应用程序以这种方式运行。以下代码片段显示了此操作的代码:

    heroku.yml file included) to version control and push it to Heroku so that it starts the build.
    
  4. 添加所有文件以确保git正在跟踪它们:

    $ git add .
    
  5. 提交所有文件,并附上如下信息:

    -m flag that we've used is a command that allows us to create a commit with a message with a short syntax.
    
  6. 现在,关键是要把文件推送到heroku远程。

    这应该触发 Docker 镜像的构建过程,您可以在日志中进行检查。然后,在最后阶段,这个镜像被推送到 Heroku 的内部镜像注册表中,如下代码片段所示:

    Dockerfile, following all the steps specified there, as happened when we built the image locally, as illustrated in the following code snippet: 
    
    

    remote: === 正在推送 web (Dockerfile)

    remote: 标记镜像 "5c154f3fcb23f3c3c360e16e929c22b62847fcf8" 为 "registry.heroku.com/boiling-dusk-18477/web"

    remote: 使用默认标签: latest

    remote: 推送指的是仓库 [registry.heroku.com/boiling-dusk-18477/web]

    remote: 6f8894494a30: 正在准备

    remote: f9b9c806573a: 正在准备

    
    And it should be working, right? Well…, not really. We still have a couple of things that we need to configure, but we're almost there.
    

请记住,我们的应用程序依赖于配置,而配置的一部分来自环境。Heroku 不可能不知道我们需要哪些配置值。还有一些设置我们需要配置以使我们的应用程序运行,接下来我们就做这件事。

为部署配置应用程序

现在我们有一个应用程序,当代码推送到git时,它会启动构建镜像并部署它。我们目前的应用程序已经部署了,但实际上并没有运行,这是因为缺少配置。

您可能首先注意到的是,我们的应用程序总是从开发环境加载配置文件,config.dev.yml,它不应该这样做。

当我们第一次实现这个功能时,我们以为不同的环境会有不同的配置,我们是对的。当时,我们不需要为多个环境设置配置,所以我们使用了dev作为默认值。让我们解决这个问题。

记得我们创建加载配置文件的函数时,明确使用了环境参数吗?当时我们没有使用它,但我们留下了一个默认值。

请查看src/config/index.ts中的以下代码片段:

export async function load(
  env = "dev",
): Promise<Configuration> {

我们需要做的是将此更改为支持多个环境。所以,让我们按照以下步骤来做到这一点:

  1. 回到src/index.ts,确保我们将名为DENO_ENV的环境变量发送到load函数,如下所示:

    const config = await
      loadConfiguration(DENO_ENV is not defined, and allow us to load a different configuration file in production.
    
  2. 创建生产配置文件,config.production.yml

    目前,它应该与config.dev.yml没有太大区别,除了port。让我们在生产环境中以端口9001运行它,如下所示:

    web:
      port: 9001
    

    为了在本地测试这一点,我们可以使用DENO_ENV变量设置为production来运行应用程序,像这样:

    DENO_ENV). We mentioned how you can do this in *Chapter 7**, HTTPS, Extracting Configuration, and Deno in the Browser*, in the *Accessing secret values* section.And after running it we can confirm it's loading the correct file, because the application port is now `9001`.
    

有了我们刚刚实现的内容,我们现在可以根据环境控制加载哪些配置值。这是我们已经在本地测试过的,但在 Heroku 上还没有做过。

我们已经解决了部分问题——我们根据环境加载不同的配置文件,但我们的应用程序依赖的其他配置值来自环境。这些是诸如JSON Web TokenJWT)密钥或 MongoDB 凭据等秘密值。

有许多方法可以做到这一点,所有云服务提供商都提供了相应的解决方案。在 Heroku 上,我们可以通过使用config命令来实现,如下所示:

  1. 使用heroku config:set命令定义 MongoDB 凭据变量、JWT 密钥和环境,如下所示:

    DENO_ENV variable so that our application knows that, when running in Heroku, it is the production environment.If you are not using your own MongoDB cluster and you have questions about its credentials, you can go back to *Chapter 6*, *Adding Authentication and Connecting to the Database*, where we created a MongoDB cluster in MongoDB Atlas.If you're using a different cluster, remember that it is defined in the configuration file in `config.production.yml` and not in the environment, and thus you need to add your cluster URL and database in the configuration file as follows:
    
    

    MongoDB:

    集群 URL: <添加您的集群 URL>

    数据库: <添加您的数据库名称>

    
    
  2. 再次,我们将我们的更改添加到git中,如下所示:

    $ git commit -am "Configure environment variables and DENO_ENV"
    
  3. 然后,我们将更改推送到 Heroku 以触发部署过程,如下所示:

    $ git push heroku master
    …
    remote: Verifying deploy... done.
    To https://git.heroku.com/boiling-dusk-18477.git
       9340446..36a061e  master -> master
    

    然后它应该能正常工作。如果我们现在前往 Heroku 控制台(dashboard.heroku.com/),然后进入我们应用程序的控制台(dashboard.heroku.com/apps/boiling-dusk-18477,在我的案例中)并点击打开应用程序按钮,它应该能打开我们的应用程序,对吧?

    还不是,但我们快了——我们还需要解决一件事情。

从环境中获取应用程序端口

Heroku 在运行 Docker 镜像时有一些特别之处。它不允许我们设置应用程序运行的端口。它所做的是分配一个应用程序应该运行的端口,然后将来自应用程序 URL 的超文本传输协议HTTP)和安全的超文本传输协议HTTPS)流量重定向到那里。如果这听起来仍然很奇怪,不用担心——我们会搞定的。

正如你所知,我们明确地在config.production.yml文件中定义了应用程序将要运行的端口。我们需要适应这个。

Heroku 定义应用程序应该运行在哪个端口的方式是通过设置PORT环境变量。这在以下链接中有文档记录:

Heroku 容器注册表和运行时

你可能从标题中知道我们接下来要做什么。我们要更改我们的应用程序,以便来自环境的 Web 服务器端口覆盖配置文件中定义的那个。

回到应用程序中的src/config/index.ts,确保它正在从环境中读取PORT变量,覆盖来自文件的配置。代码可以在以下片段中看到:

type Configuration = {
  web: {
    port: number;
  };
  cors: { 
…
export async function load(
  env = "dev",
): Promise<Configuration> {
  const configuration = parse(
    await Deno.readTextFile(`./config.${env}.yaml`),
  ) as Configuration;
  return {
    ...configuration,
    web: {
      ...configuration.web,
      port: Number(Deno.env.get("PORT")) ||
        configuration.web.port,
    },
…

这样,我们确保我们从PORT环境变量中读取变量,使用配置文件中的值作为默认值。

这样应该就足够让我们的应用程序在 Heroku 中顺利运行了!

再次,我们可以通过访问 Heroku 仪表板(dashboard.heroku.com/apps/boiling-dusk-18477)并点击打开应用按钮来测试这一点,或者你可以直接访问 URL——在我的情况下,它是boiling-dusk-18477.herokuapp.com/

重要提示

如果你正在使用 MongoDB Atlas,正如我们在第六章中添加身份验证并连接到数据库所做的那样,并且想要允许你的应用程序访问数据库,你必须配置它使其能够从"任何地方"进行连接。这不是一个推荐的做法,如果你将应用程序暴露给你的用户,而且这只发生因为我们正在使用 Heroku 的免费层。由于它在共享集群中运行,我们没有办法知道运行应用程序的机器的固定的互联网协议IP)地址是什么,我们只能这样做。

以下链接展示了如何配置数据库的网络访问: docs.atlas.mongodb.com/security/ip-access-list。确保你在 MongoDB Atlas 网络访问屏幕上点击允许从任何地方访问

网络访问屏幕就是这样子的:

图 9.1 – MongoDB Atlas 网络访问屏幕

图 9.1 – MongoDB Atlas 网络访问屏幕

在此之后,我们的应用程序应该按预期工作;您可以尝试执行一个注册用户的请求(该请求连接到数据库)并检查一切是否正常,如下面的代码片段所示:

$ curl -X POST -d '{"username": "test-username-001", "password": "testpw1" }' -H 'Content-Type: application/json' https://boiling-dusk-18477.herokuapp.com/api/users/register
{"user":{"username":"test-username-001","createdAt":"2020-12-19T16:49:51.809Z"}}%

如果您得到的响应与前面的类似,那就大功告成了!我们成功地在云环境中配置并部署了我们的应用程序,并创建了一种自动化的方式将更新推送给我们的用户。

为了进行最后的测试,以确认代码是否成功部署,我们可以尝试更改代码的一部分并再次触发部署过程。让我们这样做!按照以下步骤进行:

  1. src/web/index.ts中的"Hello World"消息更改为"Hello Deno World!",如下面的代码片段所示:

    app.use((ctx) => {
      ctx.response.body = "Hello Deno World!";
    });
    
  2. 按照以下步骤将此更改添加到版本控制中:

    $ git commit -am "Change Hello World message"
    [master 35f7db7] Change Hello World message
     1 file changed, 1 insertion(+), 1 deletion(-)
    
  3. 将其推送到 Heroku 的git远程仓库,如下所示:

    $ git push heroku master
    Enumerating objects: 9, done.
    Counting objects: 100% (9/9), done.
    Delta compression using up to 8 threads
    Compressing objects: 100% (5/5), done.
    Writing objects: 100% (5/5), 807 bytes | 807.00 KiB/s, done.
    Total 5 (delta 4), reused 0 (delta 0)
    remote: Compressing source files… Done
    …
    remote: Verifying deploy... done.
    To https://git.heroku.com/boiling-dusk-18477.git
    
  4. 如果我们现在访问应用程序的 URL(在我们的情况下是boiling-dusk-18477.herokuapp.com/),您应该会看到Hello Deno World消息。

这意味着我们的应用程序已成功部署。由于我们使用的是提供比这里学到的更多功能的云平台,我们可以探索 Heroku 的其他功能,例如日志记录。

在 Heroku 控制面板上的打开应用按钮旁边,有一个更多按钮。其中一个选项是查看日志,正如您在下面的屏幕截图中所看到的:

图 9.2 – Heroku 控制面板中的应用更多选项

](https://gitee.com/OpenDocCN/freelearn-js-pt2-zh/raw/master/docs/deno-web-dev/img/Figure_9.2_B16380.jpg)

图 9.2 – Heroku 控制面板中的应用更多选项

如果您点击那里,将出现一个实时显示日志的界面。您可以尝试通过点击打开应用按钮在另一个标签页中打开您的应用程序来尝试它。

您会看到日志立即更新,那里应该会出现类似这样的内容:

2020-12-19T17:04:23.639359+00:00 app[web.1]: GET http://boiling-dusk-18477.herokuapp.com/ - 1ms

这对于您想要对应用程序的运行情况进行非常轻量级的监控非常有用。日志记录功能在免费层中提供,但还有许多其他功能供您探索,例如我们在这里不会进行的指标功能。

如果您想详细了解您的应用程序何时以及由谁部署,您还可以使用 Heroku 控制面板的活动部分,如下面的屏幕截图所示:

图 9.3 – Heroku 控制面板应用程序选项

图 9.3 – Heroku 控制面板应用程序选项

然后,您将看到您最近部署日志的记录,这是 Heroku 的另一个非常有趣的功能,如下面的屏幕截图所示:

图 9.4 – Heroku 控制面板应用程序的活动标签

](https://gitee.com/OpenDocCN/freelearn-js-pt2-zh/raw/master/docs/deno-web-dev/img/Figure_9.4_B16380.jpg)

图 9.4 – Heroku 控制面板应用程序的活动标签

这结束了我们在云环境中部署应用程序的部分。

我们关注的是应用程序以及可以在您的代码运行的平台独立重复使用的主题。我们迭代了加载配置的应用程序逻辑,使其能够根据环境加载不同的配置。

然后,我们学习了如何将包含机密配置值的环境变量发送到我们的应用程序,最后我们探索了在 Heroku 这个示例选择的平台上进行日志记录——就此结束。

我们成功让我们的应用程序运行起来,并且围绕它建立了一个完整的架构,这将使未来的迭代能够轻松地部署给我们的用户。希望我们经历了一些你们下次决定部署 Deno 应用程序时也会遇到阶段。

摘要

差不多完成了!本章通过部署完成了我们应用程序开发阶段的循环。我们从构建一个非常简单的应用程序开始,到向其中添加功能,到添加测试,最后——部署它。

在这里,我们学习了如何在我们的应用程序中使用一些容器化的好处。我们开始了解 Docker,我们选择的容器运行时,并迅速地创建了我们应用程序的镜像。在学习的过程中了解一些 Docker 命令,我们也体验了准备 Deno 应用程序部署是多么的容易。

创建这个 Docker 镜像使我们能够有一种可复现的方式来安装、运行和分发我们的应用程序,创建一个包含应用程序所需一切的包。

当章节进行时,我们开始探索如何使用这个应用程序包将其部署在云环境中。我们首先配置了本指南的分步指南选择的云平台 Heroku,使其每次发生变化时都会重新构建并运行我们应用程序的代码,在git和 Heroku 文档的帮助下,我们非常容易地实现了它。

当配置自动化流水线时,我们理解了需要将配置值发送到我们的应用程序。我们之前在早期章节中实现的一些相同的配置值,需要以两种不同的方式发送到应用程序,通过配置文件和通过环境变量。我们逐一解决了这些需求,首先通过迭代应用程序代码使其根据环境加载不同的配置,后来学习如何在 Heroku 上设置配置值。

最终,我们让我们的应用程序无缝运行,并完成了本章的目标:拥有一个可复现、自动化的方式将代码部署给我们的用户。与此同时,我们了解了一些关于 Docker 以及当涉及到发布代码时容器化和自动化的好处。

这本书的内容基本上已经讲到这里了。我们决定让这个过程成为一个建立应用程序的旅程,分别经历它的所有阶段并在需要时解决它们。这是最后一个阶段——部署,希望这能为您从编写第一行代码到部署的整个周期画上句号。

下一章将重点关注 Deno 接下来的发展,包括运行时和您个人方面。我希望这能让您成为 Deno 的爱好者,并且您对它以及它所开启的无限可能世界像我一样充满热情。

第十章:接下来是什么?

我们已经走了一段很长的路。我们开始了解 Deno 的基本知识,然后构建并部署了一个完整的应用程序。到目前为止,你应该已经对 Deno 感到舒适,并且对它解决的问题有很好的了解。希望我们经历的所有阶段都有助于澄清你可能有关于 Deno 的许多问题。

我们故意选择让这本书成为一段旅程,从我们的第一个脚本开始,到完成部署的应用程序结束,我们在书中边编写边迭代这个应用程序。与此同时,我们解决了许多应用程序开发者可能会遇到的挑战,并提出了解决方案。

到现在为止,你应该已经具备了帮助您决定 Deno 是否将成为您下一个项目解决方案一部分的知识。

本章将首先回顾我们已经学到的内容,包括所有阶段和学习点。然后,正如章节标题所暗示的,我们的重点将转向未来。本章将关注接下来会发生什么,既包括作为运行时的 Deno,也包括作为一名配备了新工具的开发人员。

我们会快速查看 Deno 核心团队当前的优先事项,他们正在做什么,以及提议的未来功能是什么。随着章节的进行,我们还将查看社区中正在发生的事情,突出一些有趣的倡议。

本章将通过展示我们如何将包发布到 Deno 的官方注册表等方式,结束对 Deno 社区的回馈。

到本章结束时,你将在以下领域感到舒适:

  • 回顾我们的旅程

  • Deno 的路线图

  • Deno 的未来和社区

  • 将包发布到 Deno 的官方注册表

回顾我们的旅程

我们已经覆盖了大量的地面。我们相信这本书(希望)是从不了解 Deno 到用它建造东西,最后部署一个应用程序的有趣旅程。

我们首先了解这个工具本身,首先了解它提供的功能,然后使用标准库编写简单的程序。随着我们的知识积累,我们很快就有足够的知识用它来构建一个真正的应用程序,这就是我们接下来要做的。

冒险始于使用标准库构建最简单的可能的 Web 服务器。我们大量使用 TypeScript 来帮助明确指定应用程序的边界,并成功运行了一个非常简单的应用程序,达到了我们第一个检查点:hello world

我们的应用程序在进化,随着它开始拥有更复杂的需求,我们需要深入研究 Deno 社区上可用的网络框架。在我们对所有它们进行了高层次的比较之后,根据我们的应用程序需求,我们选择了oak。下一步是将我们(仍然)简单的 Web 服务器迁移到使用我们选择的框架,这轻而易举。使用网络框架使我们的代码更简单,并允许我们将我们真的不想自己处理的事情委托出去,让我们能够专注于应用程序本身。

下一步是向我们的应用程序添加用户。我们创建了应用程序端点以实现用户注册,随着存储用户的需求出现,我们将应用程序连接到了 MongoDB。有了用户之后,实现用户认证就是一步之遥。

随着应用程序的增长,对更复杂配置的需求也在增长。从它运行的服务器端口到证书文件的位置,或者到数据库凭据,所有这些都需要独立处理。我们将配置从应用程序中抽象出来,并集中管理。在此过程中,我们添加了支持,允许配置存在于文件中或环境变量中。这使得可以根据环境运行具有不同配置的应用程序,同时将敏感值安全地保持在代码之外。

当我们的旅程即将结束时,我们想确保我们的代码足够可靠。这让我们想到了一个测试章节,在那里我们学习了 Deno 中测试的基础知识,并为我们所创建的应用程序的不同用例创建了不同的测试。我们从简单的单元测试走到了跨模块测试,再到启动应用程序并进行一些请求的测试。通过这个过程的最后,我们对我们的代码按预期工作更有信心,并将测试能力添加到我们的工具链中。

为了结束本章,我们将编写的代码变成了现实,并将其部署了出去。

我们在 Heroku 上的容器化环境中运行了应用程序。与此同时,我们学习了关于 Docker 的知识,以及如何使用它让开发者更容易运行和部署他们的代码。我们用一种自动化方式部署了一个 Deno 应用程序,结束了从代码到部署的循环。

这是我们经历了一个应用程序开发过程中的许多常见阶段,遇到挑战并使用适合我们用例的解决方案解决问题的旅程。我希望我已经涵盖了你们的一些主要关切和问题,为你们提供了坚实的基础,帮助你们在未来。

我们不知道接下来会发生什么,但我们确实知道它取决于 Deno 及其社区,我们希望您认为自己也是这个的一部分。在下一节中,我们将看看 Deno 的未来路线图,计划的内容以及他们的短期努力方向。

Deno 的路线图

自从瑞恩在 JSConf 上首次介绍 Deno 以来,很多事情都发生了变化;已经迈出了几大步。随着第一个稳定版本的运行时发布,社区爆发了,很多来自其他 JavaScript 社区的人都加入其中,带来了许多热情洋溢的想法。

目前 Deno 的核心团队正在投入大量精力推动 Deno 的发展。这种贡献不仅以代码、问题和帮助他人的形式出现,还体现在规划和界定下一步行动上。

对于短期路线图,核心团队确保其正在追踪倡议。下面两个在 GitHub 上提出的问题已经用来追踪 2020 年第四季度和 2021 年第一季度的工作:

如果您仔细查看这些内容,可以跟踪有关这些功能的每个讨论、代码和决策。我在这里列出一些当前的倡议,让您预览一下正在发生的事情:

  • Deno 的语言服务器协议LSP)和语言服务器

  • 编译成二进制文件(Deno 应用程序的单个可执行文件)

  • 数据、blob、WebAssembly 和JavaScript 对象表示法JSON)导入

  • Web Crypto应用编程接口(APIs)的支持进行了改进

  • 支持立即执行函数表达式IIFE)捆绑包

  • WebGPU 支持

  • HTTP/2 支持

这些都是 Deno 正在进行的一些重要倡议的几个例子。正如您能想象的那样,由于它还处于早期阶段,目前有很多努力旨在修复漏洞和重构代码,我没有把这些加入到这个列表中。

请随意深入查看之前提到的 GitHub 问题,以获取关于任何倡议的更多详细信息。

所有这些都是 Deno 的核心团队努力。记住,Deno 之所以存在,是因为有人在他们的业余时间致力于此。回馈社区有很多方式,无论是通过提交错误报告、代码贡献、在通讯渠道上帮助他人,还是通过捐赠。

如果 Deno 帮助您和您的公司把想法变成现实,请考虑成为赞助商,以保持其健康并继续发展。您可以在以下链接上在 GitHub 上进行赞助:github.com/sponsors/denoland

还有其他也为 Deno 负责的人,他们为 Deno 的热情、其发展以及那些人就是 Deno 社区。在下一节中,我们将介绍 Deno 社区、那里发生的一些有趣的事情以及你可以如何积极参与其中。

Deno 的未来和社区

Deno 社区正在快速增长——充满了对它感到兴奋并渴望帮助它成长的人。正如你在本书中一直所做的,当你开始使用它时,你可以为它做出非常重要的贡献。这可能是一个你遇到的错误,一个对你有意义的功能,或者只是你想更好地理解的东西。

为了让你成为其中的一员,我建议你加入 Deno 的 Discord 频道(discord.gg/deno).这是一个非常活跃的地方,你可以找到其他对 Deno 感兴趣的人,如果你想要寻找包的作者,自己构建包,或者帮助 Deno 核心,这里非常有用。根据我的经验,我只能说我在那里遇到的所有人都非常友好和乐于助人。这也是了解正在发生的事情的好方法。

另一种贡献方式是关注 Deno 在 GitHub 上的仓库(github.com/denoland).主仓库可以在github.com/denoland/deno找到,那里你可以找到 Deno命令行界面 (CLI)和 Deno 核心,而标准库则在其自己的仓库中(github.com/denoland/deno_std)。还有其他仓库,如github.com/denoland/rusty_v8,它托管了用于 V8 JavaScript 引擎的 Deno 的 Rust 绑定,或者github.com/denoland/deno_lint,Deno linter 托管在其中,还有其他一些仓库。请在 GitHub 上关注你感兴趣的仓库。

提示

在没有收到太多通知的情况下了解 Deno 上正在发生的事情的一个很好的方法是只关注 Deno 的主要仓库的发布。你会对每一个发布收到通知,你可以跟随非常全面的发布说明。我留下一个发布说明的示例,以便你知道它们看起来是什么样子。

这就是版本更新通知的样子:

图 10.1 – Deno 的 v1.6.2 发布笔记

图 10.1 – Deno v1.6.2 发布笔记

图 10.1 – Deno 的 v1.6.2 发布笔记

除了前面截图中显示的 GitHub 发布版本外,Deno 团队还努力在他们的网站上编写全面的发布说明,这是保持更新的另一种好方法(deno.land/posts)。

要成为 Deno 社区的重要一员,你可以做的事情就是使用它,报告错误,结识新朋友,其余的就会随之而来。

社区不仅由核心成员和帮助 Deno 的人组成,还包括用它构建的包和项目。

在接下来的部分,我会突出一些我认为很棒且正在推动社区前进的倡议。这是一个个人清单;把它当作一个推荐,而不是更多,因为我相信还有其他可以添加的倡议。

社区中正在发生的有趣事情

在我关注 Deno 的过去两年里,发生了许多事情。在 v1.0.0 发布后,随着更多的人加入,涌现了许多想法。我会列出一些我认为特别有趣的倡议,不仅因为它们提供功能性,而且因为它们也是学习的伟大来源。

Denon

正如在开发 Node 时 Nodemon 是首选解决方案一样,Deno 在 Deno 领域中最常被使用的工具之一。如果你还没听说过,它基本上会监视你的文件,并在你更改任何内容时重新运行你的 Deno 应用程序。它是那些在开发 Deno 时你很可能会保留在工具链中的工具之一。你可以查看他们的 GitHub 页面:github.com/denosaurs/denon

Aleph.js

尽管我们在这里没有足够的空间去探讨它,但 Deno 在浏览器上运行的能力解锁了一整套新的功能性,这导致了像 Aleph.js 这样的倡议。这个倡议称自己为 Deno 中的React 框架,并且已经得到了相当多的使用和热情。如果你还没听说过,它从 Next.js 框架(nextjs.org/)中采取了诸多方面,在 Deno 中实现它们,并添加了一些其他的东西。它虽然很新,但已经有了服务器端渲染、热模块重载和文件系统及 API 路由等功能。你可以在这里了解更多:alephjs.org/

Nest.land

尽管 Deno 有自己的注册表(我们将在下一节中使用),但社区还是创造了其他的注册表。Nest.land 是其中的一个;它是一个基于区块链技术的模块注册表,确保托管在那里的模块不会被删除。它是免费的、去中心化的,不需要 Git 就能工作,是许多包作者的首选解决方案。关于它的更多信息,请访问:nest.land/

Pagic

随着静态网站生成器越来越受欢迎,制作一些用 Deno 的静态网站生成器只是时间问题。这就是 Pagic 所做的事情——它是一个支持 React、Vue 和 M 等有趣功能的静态网站生成器。它采用约定优于配置的方式,这意味着让你的第一个网站运行起来相当简单。关于它的更多信息,请访问:pagic.org/

Webview_deno

由于现在许多人使用的应用程序都是用 JavaScript 编写的,并存在于网页视图中,它们最终会来到 Deno 只是时间问题。这个模块包括一个 Deno 插件,因此仍然被认为是稳定的。然而,尽管它有局限性,并且是一个正在进行的项目,它已经提供了许多由 Electron(Node.js 的替代品)提供的有趣功能。

除了上述所有的包之外,所有在第四章 构建网页应用 中提到的包都值得一看。它们是快速发展的网页框架,正如我们之前探索过的,为使用它们的开发者提供了不同的好处。如果您正在使用 Deno 开发网页应用,请确保您关注它们。查看它们的 GitHub 页面github.com/webview/webview_deno

你认为 Deno 上仍然缺少什么功能吗?你开发了什么你认为对更多人都有用东西吗?开源的核心依赖于这些有趣的软件和背后的人。

制作了想要分享的东西吗?不用担心——我们帮您搞定。在下一节中,您将学习如何做到这一点!

将包发布到 Deno 的官方注册表

开源的核心是由使用免费软件的个人和企业组成的,他们有回馈的愿望。当你创造了一段你认为足够有趣的代码时,你很可能会想要分享它。这不仅是帮助其他人的方式,也是改进自己代码的方式。

开源以及这种分享文化是使 Deno、Node.js 以及许多你可能使用的其他技术成为现实的原因。既然这本书都是关于 Deno 的,不提这个话题是没有意义的。

Deno 有一个官方的模块注册表,我们之前已经使用过了。这是一个任何人只要有 GitHub 账户就可以与社区分享自己模块的地方,它提供了自动化和缓存机制来保持不同版本的模块。

我们接下来要做的就是将我们自己的模块发布到这个注册表中。

我们将使用一段软件,到目前为止,我们通过直接链接到 GitHub 提供。这可以工作,但它既没有清晰的版本控制,也没有任何类型的缓存,如果代码从 GitHub 上删除,它就无法使用。

记得我们曾经使用过一个叫做jwt-auth的包内的AuthRepository吗?当时,出于实际原因,我们使用了一个直接到 GitHub 的链接,但从现在开始,我们将在 Deno 的模块注册表中发布它。

我们将使用在 GitHub 上托管的完全相同的代码,但以deno_web_development_jwt_auth的名字发布它。我们选择这个名称是为了非常清楚地表明它是这本书旅程的一部分。我们还不想为用于学习目的的包 grab 注册表中有意义的名称。

让我们开始吧!按照以下步骤进行:

  1. 为你想发布的模块创建一个仓库。如前所述,我们将使用来自第六章的jwt-auth模块,添加认证并连接到数据库 (github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/jwt-auth),但请随意使用你选择的任何其他模块。

  2. 克隆最近创建的git仓库,按照 GitHub 的说明操作。确保将你的模块文件复制到这个仓库文件夹中,并运行以下命令(这些命令与 GitHub 的说明相同):

    $ echo "# <Name of your package>" >> README.md
    $ git init
    $ git add .
    $ git commit -m "first commit"
    $ git branch -M main
    $ git remote add origin git@github.com:<your-username>/ <your_package_name>.git
    $ git push -u origin main
    
  3. 前往deno.land/x 并点击“添加模块”按钮(你可能需要滚动一点才能找到它),如下所示:图 10.2 – Deno 模块注册表中的“添加模块”按钮

    图 10.2 – Deno 模块注册表中的“添加模块”按钮

  4. 在出现的框中输入模块名称,并点击将deno_web_development_jwt_auth作为包名称,但由于明显的原因,你不能这样做。

    记住,如果你发布模块是为了测试原因,你应该使用一个测试名称。我们不希望使用“真实”的模块名称来使用用于测试目的的模块。

  5. 在出现的下一个框中,选择代码将要发布的目录。

    对于我们的模块,将包含来自第六章的jwt-auth代码,添加认证并连接到数据库,我们将留空,因为它位于步骤 1 中创建的新仓库的根目录下。

  6. 现在,只需按照说明添加 webhook 即可。

    Deno 模块注册表使用 GitHub webhook 来获取包的更新。这些 webhook 应该由新分支或标签触发,Deno 的模块注册表然后将这些 GitHub 标签创建为一个版本。

    接下来的说明出现在 Deno 的页面上,但由于实际原因,我会在这里列出它们:

    a. 导航到你想在 GitHub 上添加的仓库。

    b. 前往https://api.deno.land/webhook/gh/<package_name>(包名称应与步骤 4 中选择的名称相同)。

    f. 选择application/json作为内容类型。

    g. 选择让我选择个别事件

    h. 只选择分支或标签创建事件。

    i. 点击添加 webhook

  7. 现在,只需创建一个版本,正如我们提到的,这是通过git标签完成的。假设您已经在步骤 2 中提交了您的包代码,我们只需要创建并推送此标签,如下所示:

    $ git tag v0.0.1
    $ git push origin --tags 
    Enumerating objects: 5, done.
    Counting objects: 100% (5/5), done.
    Delta compression using up to 8 threads
    Compressing objects: 100% (3/3), done.
    Writing objects: 100% (3/3), 748 bytes | 748.00 KiB/s, done.
    Total 3 (delta 1), reused 0 (delta 0)
    remote: Resolving deltas: 100% (1/1), completed with 1 local object.
    To github.com:asantos00/deno_web_development_jwt_auth.git
     * [new tag]         v0.0.1 -> v0.0.1
    
  8. 如果我们现在导航到deno.land/x 并搜索你包的名称(在我们的例子中是deno_web_development_jwt_auth),它应该出现在那里,正如你在以下屏幕截图中所看到的:

图 10.3 – 在 Deno 的模块注册表上发布的包

图 10.3 – 在 Deno 的模块注册表上发布的包

就这样——这就是你开始与社区分享你的 Deno 代码所需的一切!从现在开始,你不仅可以使用 Deno 构建应用程序,还可以创建包并回馈社区。

这一节以及这本书就到此结束——感谢你跟进并读到了最后。我们希望它对你有用,帮助你学习了 Deno,也希望你对它像我们一样充满热情。

如果你认为有什么我能帮忙的,我会非常乐意联系。可以通过书中前言中提供的联系方式,通过 GitHub 或 Twitter 随时联系我。

总结

首先,感谢所有坚持读到书末的人!我希望这是一段有趣旅程,满足你的期望,并回答了你关于 Deno 的许多问题和疑虑。

这只是(希望是大的)旅程的开始。Deno 正在成长,你现在成为了其中的一部分。你越使用它并回馈,它就会变得越好。如果你,像我一样,认为它为编写 JavaScript 应用程序提供了许多好处,可以使之作翻天覆地的变化,不要等待,立即分享你的热情。

像我们这样有很多人正在帮助推动 Deno 的发展,帮助社区,开发模块,提交拉取请求。归根结底,在你项目中恰当使用它,是你能做出的最好推荐。

在整本书中,我不仅试图突出 Deno 的优势,还试图非常清楚地表明,它不是,也不会是 silver bullet。它在很多方面都有很大的优势,尤其是在与 Node.js 相同的用例中(正如你在第一章 Deno 是什么?中可以检查到的)。正如我们在这章中提到的,有许多功能正在增加,这将使 Deno 能够用于越来越多的用例,但我相信还有很多我们甚至不知道的东西即将到来。

从这里开始,一切都取决于你。我希望这本书让你充满热情,迫不及待地想写 Deno 应用程序。

下一步最好的做法是亲自编写应用程序。这将使你进行研究,与人们交谈,并解决你自己的问题。我尽可能地简化你前进的路径,通过回答一些最常见的问题。

我相信网上有很多资源、文章和书籍,但真正提高 Deno 技能的地方仍然是 Discord 频道和 GitHub 仓库。这些地方是最新消息的第一手来源!

我迫不及待想看到你接下来会构建什么。

posted @ 2024-05-23 14:39  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报