likes
comments
collection

一文读懂 Deno

作者站长头像
站长
· 阅读数 13

Deno 是一个没有外部依赖的单一二进制和多平台(Linux、MacOS、Windows)可执行文件。由 Ryan Dahl 创建,同时他也是 Node.js 的创建者。Deno 在 2020 年 5 月发布了 v1.0 版本。

为什么需要另一个 runtime?

在 Deno 之前,已经存在了 Node.js,但是由于 Node.js 中有很多难以解决的问题,所以 Ryan Dahl 决定重新开发一个全新的 runtime,并且解决掉之前存在的所有问题。所以我们可以把 Deno 看作是 Node.js 的升级版。Deno 可以用更快、更安全的方式去做和 Node.js 相同的事情。Node.js 非常成功,用户呈指数级增长,但是这些都将会成为 Dahl 改进 Node.js 的障碍。从一个干净的基础开始,Dahl 可以使用很多创新技术和最佳架构实践。

Deno 组成

Deno 构建在 JavaScript 的 V8 引擎和 Rust 的 Tokio 之上。

V8 引擎

V8 是 Google 开源的高性能 JavaScript 和 WebAssembly 引擎,用 C++ 编写。目前主要用于 Chrome 和 Node.js。他在真正执行之前会编译并执行 JavaScript 来优化机器代码。虽然最初设计只是为了执行浏览器脚本,但是最新的版本已经允许服务端脚本。

Rust

Deno 最初是用 Go 来编写的,但是由于性能问题和缺乏垃圾回收,很快就用 Rust 重写了。Rust 允许我们将数据存储在栈或堆上,并不需要在编译时再进行分配。这种方法确保了访问内存的高效,消除了对持续运行的垃圾回收的需求。通过直接访问硬件,Rust 成为了底层开发的最佳理想语言,在很多领域取代了 C++。除了技术方面,Rust 社区也非常活跃,我们可以很容易找到大量资料来学习 Rust。

Tokio

Tokio 上 Rust 的异步 runtime。它对 Deno 的意义,就像 libuv 对于 Node.js 的意义。由于使用多线程来调度程序,它可以用最小的开销提供卓越的性能。Tokio 还有一组内存安全的 API,帮助我们防止和内存相关的错误。这些功能都为 Deno 提供了坚实可靠的基础。

Deno 的基础功能

Deno 是用 Typescript 和 Rust 来编写的,这两门编程语言都是广泛使用的语言,可以提供许多优势来创建快速和高性能的应用程序。下面是 Deno 的一些功能列表:

  • 原生支持 Typescript,同时仍然可以使用 JavaScript。
  • 支持 ES Module。
  • 拥有非常多的库。
  • 没有标准的包管理器,直接通过 URL 下载库。
  • 提供完整的内置工具,包括 bundling、debugging、testing 等。
  • 拥有明确的权限系统,默认情况下非常安全。

Deno 的核心功能

Deno 和 Node.js 的主要区别在于 Deno 背后的核心功能。它提供了丰富的功能来保障改进和流畅的开发体验。Deno 的目标是:Deno aims to be a productive and secure scriptin environment for the modern programmer.(Deno 旨在为现代程序员提供高效且安全的脚本环境)

安装

在学习 Deno 的核心功能之前,我们需要先来安装一下 Deno,以便运行一些示例代码。安装 Deno 的方式非常简单,下面是不同操作系统的安装方式。

Mac、Linux

bash 方式是最通用的。

curl -fsSL https://deno.land/install.sh | sh

如果你有 Homebrew,也可以这样。

brew install deno

Windows

Windows 推荐使用 PowerSehll 来安装。

iwr https://deno.land/install.ps1 -useb | iex

安全

安全是 Deno 的一个核心功能。

沙盒安全层

默认情况下,任何 Deno 模块都在沙盒安全层中执行,防止访问磁盘、环境、网络或者运行任何外部脚本的可能性,除非通过权限明确允许。在使用命令行运行代码时,我们通过使用一些 flag 向程序授予权限。

示例

我们通过一个简单的例子来更好地理解权限是如何工作的。下面的命令作用是:执行一个远程脚本文件 cat.ts,并授予它对目标文件的读取权限。

deno run --allow-read https://deno.land/std@0.123.0/examples/cat.ts test.txt

cat.ts 是 Deno 标准库的一部分,我们可以直接从终端调用它。它接受一个或多个文件名作为参数,并且打印它们的内容,以下是 cat.ts 的源码:

// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.
import { copy } from "../streams/conversion.ts";
const filenames = Deno.args;
for (const filename of filenames) {
  const file = await Deno.open(filename);
  await copy(file, Deno.stdout);
  file.close();
}

下面我们来解释一下它关键的部分。第 3 行:通过 Deno.args 访问命令行中的参数,并赋值给 filenames。第 4-8 行:通过循环遍历所有的文件,并调用 Deno.open 方法打开文件,并调用 copy 将文件内容打印到 Deno.stdout。最后调用 file.close 方法关闭文件。如果将 --allow-read 这个 flag 去掉,那么就会在第 5 行抛出一个错误。因为 Deno 默认是安全的,它要求我们必须提供权限。Deno 的 flag 必须放在脚本名称之前,否则它会被解析成脚本参数。

缓存模块

与 Node.js 不同的是,Deno 不使用 npm,而是使用 URL 来导入模块。在使用 URL 导入模块时,Deno 会下载模块并将它换存在由环境变量定义的 DENO_DIR 中。默认值为系统的缓存目录,即 $HOME/.cache/deno。模块被缓存之后,在之后每次执行过程中,Deno 都会先去缓存中查找请求的模块是否存在,如果找到模块,就会直接使用这个模块,而不需要进行网络拉取。当我们处于没有网络的开发环境下,这个方式将会非常有用。现在尝试重新运行上面的命令,可以看到它没有再次下载 cat.ts 文件。我们可以通过添加 --reload flag 来让 Deno 重新通过网络获取模块。

权限 flag

Deno 的权限 flag 提供了非常好的粒度级别,在需要的时候可以缩小授权范围。比如我们可以通过 --allow-read=/folder 这个 flag 来限制对目标文件夹的读取访问。

完整的权限列表

  • -A/--allow-all:允许所有权限。
  • --allow-net:允许访问网络。我们可以通过逗号分隔域名或 IP 制定可以访问的网络列表。单独使用 --allow-net 表示可以访问所有域名或 IP。
deno run --allow-net=github.com,gitlab.com code.js
  • --allow-env:允许读写环境变量。我们可以通过都好分割指定可以读写的环境变量。
  • --allow-hrtime:允许高分辨率时间测量。
  • --allow-read:允许文件系统读取权限。可以通过逗号分隔授予多个文件或目录读取权限。
  • --allow-write:允许文件系统写入权限。可以通过逗号分隔授予多个文件或目录写入权限。
  • --allow-run:允许允许子进程。但子进程不在沙箱中运行,子进程没有安全限制。
  • --allow-ffi:允许加载动态库。但是不在沙箱中运行。

TypeScript 支持

Deno 支持使用 JavaScript 或者 TypeScript 编写 Deno 程序,但无论使用哪种方式,它都会自动编译 TypeScript。TypeScript 有很多好处,比如类型检查和代码提示等。使用 TypeScript 可以让我们规避很多低级错误。

ES Module 支持

Deno 可以从远程 URL 或者本地路径来导入 ES 模块。所以不需要包管理器来导入远程模块。这样意味着我们少了一个需要关心的技术组件。加载远程模块示例:

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

加载本地模块示例:

import { concat, split } from './utils/string-util.ts'

无论采用哪种方式导入文件,都需要提供完整的文件名,包括扩展名。因为 Deno 的模块解析有点类似于浏览器的模块解析。在导入远程模块时,如果不置顶特定版本,那么就会下载最新版本的模块。如果需要特定版本,可以在模块路径中添加指定版本。

// 导入最新版本
// 不鼓励使用这种方式
import { serve } from  'https://deno.land/std/http/server.ts'


// 显式指定了 0.65.0 版本
import { serve } from 'https://deno.land/std@v0.65.0/http/server.ts'
const s = serve({ port: 8000 });

因为 Deno 采用分散的方式获取这些库,所以在 Deno 的项目中我们不需要 package.json 文件和 node_modules 文件夹。

显示缓存位置

我们可以使用 deno info 命令来查看不同的缓存位置。我们还可以使用 deno info TARGET_MODULE 来获取和目标模块相关的依赖模块列表。当我们的项目变得庞大时,可以使用这个命令来确保不会错误的导入模块。我们可以使用下面的命令来测试。

deno info https://deno.land/std@0.67.0/http/server.ts

分组导入

在多个文件中导入相同的远程模块会显得重复而且低效。Deno 中使用了类似桶文件的概念。桶文件提供一种将多个模块的导出汇总到单个模块的方法。Deno 使用 deps.ts 当作约定名,而不是 index.ts。这种想法是将导入分组到一个集中的文件中,再根据需要将依赖导入到不同的文件中。因此,具有多个模块的单个导入,而不是各种导入。如果我们在导入中针对特定版本,这是一个方便的习惯。因为我们只需要在一个地方更新版本号,确保在任何地方都是用相同的模块版本。下面是展示桶文件的代码示例:deps.ts

export { serve } from  'https://deno.land/std@v0.65.0/http/server.ts' // <- Change only here for a new version
export { Cookie } from  'https://deno.land/std@v0.65.0/http/cookie.ts'
export { SEP } from  'https://deno.land/std@v0.123.0/http/cookie.ts'

file1.ts

import { Cookie, serve, SEP } from './deps.ts';

const myCookie: Cookie;
const currentSeparator = SEP;

const s = serve({ port: 8000 });

file2.ts

import { Cookie } from './deps.ts';

const myCookie: Cookie;

file3.ts

import { serve } from './deps.ts';
import { dateMod } from './utils/date-util.ts';

遵循这种编码方式,让代码维护更加容易。如果我们需要将某个模块的版本进行更新,只需要修改 depts.ts 中对应的一行代码即可。而不需要修改多个文件的导入。如果我们使用本地模块来替换远程模块,我们可以只修改 depts.ts 中的导入路径即可。只要导出的名称和签名与之前保持一致,就不会造成任何影响。

URL 导入的潜在风险

从远程 URL 中导入模块会带来潜在风险。比如存储远程模块代码的服务器可能会出现问题或被攻击而不可用。在这种情况下,Deno 的开发团队建议将包含缓存模块的文件夹添加到源代码管理中。这样即使远程存储库出现问题,我们也可以确保所有的库都可用。

顶级 await

在 ECMAScript 中,async/await 可以让我们通过更清晰、更易读的方式来编写异步代码,而不需要显式使用 Promise 的链式 then。关键字 await 可以让代码停止执行,直到它的目标 Promise 被 reject 或 resolve 后才恢复执行。但是我们只能在 async 中使用它,否则会得到一个错误。

Deno 中的顶级 await

Deno 支持顶级 await。这意味着我们可以在 async 函数之外使用 await 关键字。下面是示例:

try {
  const decoder = new TextDecoder("utf-8");
  const data = await Deno.readFile("README.md");
  console.log(decoder.decode(data));
} catch (e) {
  console.error("An error occurrred while reading the file: ", e);
}

不过需要注意,顶级 await 只能用于模块。

Deno 库

标准库

Deno 的官方模块由核心团队提供支持。这些模块就是标准库。标准库涵盖了大多数项目中常用的功能,并且在每个版本中都会扩展。目前常见的有:

  • Async:异步任务
  • bytes:字节切片操作
  • datetime:日期/时间
  • flags:解析命令行 flag
  • fs:文件系统
  • http:HTTP 客户端/服务器功能
  • io:I/O 操作
  • path:路径操作
  • wasi:WebAssembly 接口

导入模块

我们应该使用特定版本的模块,而不是最新版本的模块。因为最新版本可能是不稳定的,并且可能带来意想不到的错误和副作用。最佳实践是始终导入特定版本来确保稳定性。

不稳定的 API

并不是标准库中的所有模块都是稳定状态,这意味着其中一些模块会使用不稳定的 Deno API。如果我们使用依赖了不稳定 API 的标准库中的模块,我们需要添加 --unstable flag 来防止在调用脚本时出现 TypeScript 错误。

第三方库

除了官方的标准库外,deno.land/x 还提供了社区开发的库的托管服务。但是这些库都未经 Deno 团队的审核。所以在使用第三方库时应该小心。

测试

测试可以确保我们的程序在部署到生产环境之前按照预期来工作,即使是在一些特殊情况下也应该如此。单元测试可以帮助我们在开发过程中保持代码的高质量,同时可以帮助我们检测意外的副作用。Deno 内置了测试运行器,所以编写测试非常简单。我们不需要引入任何第三方库,或者编写一堆配置。我们可以随时开始编写我们的测试代码。

命名约定

测试文件的命名必须满足以下三点之一:

  1. 直接命名 test.ts
  2. 以 .test 结尾,比如 user.test.ts
  3. 以 _test 结尾,比如 user_test.ts

我们使用 deno test 命令来运行测试。这条指令可以制定一个跟路径路径,测试运行器会以递归的方式在路径中搜索和执行所有测试文件。如果不指定路径,那么就是运行命令的路径。

测试风格

下面是一段 Deno 的测试代码。我通过它展示了单元测试的不同风格。

import { assertEquals } from "https://deno.land/std@0.123.0/testing/asserts.ts";

const textToTest = "Test this text!";

// 使用单元测试名字和函数
// 这类似于 Jasmine 的语法
Deno.test("Compact Form Test", () => {
  assertEquals(textToTest, "Test this text!");
});

// 使用命名函数
Deno.test(function namedFnTest() {
  assertEquals(textToTest, "Test this text!");
});

// 使用配置对象
Deno.test({
  name: "Test definition",
  fn: () => {
    assertEquals(textToTest, "Test this text!");
  },
});

// 可以添加配置对象作为第二个参数
Deno.test("Additional Configuration", { permissions: { read: true } }, () => {
  assertEquals(textToTest, "Test this text!");
});

// 可以添加测试函数作为第二个参数
Deno.test(
  { name: "Test Function as Param", permissions: { read: true } },
  () => {
    assertEquals(textToTest, "Test this text!");
  },
);

// 可以传递配置参数和命名函数作为参数
Deno.test({ permissions: { read: true } }, function helloWorld6() {
  assertEquals(textToTest, "Test this text!");
});

上面的代码风格和很多主流的 Node.js 测试框架很相似。我们来分析第一个单元测试(第 7-9 行)。

  • 第 7 行:我们使用 Deno.test 方法创建单元测试。Compact Form Test 作为测试标题。
  • 第 8 行:我们提供了一个断言,这就是我们要测试的代码部分。assertEquals 会比较第一个参数和第二个参数是否相等,会返回一个测试结果,成功或者失败。

运行单元测试的命令如下(注意需要添加权限的 flag):

deno test --allow-read --allow-net test.ts

过滤测试

给单元测试设置一个有意义的名称不仅可以理解它的作用,同时还可以使用 --filter flag 在一个组中执行它们。比如我们开发了如下不同的单元测试:

Deno.test({ name: "user-creation", fn: testCreation });
Deno.test({ name: "user-account-creation", fn: testAccount1 });
Deno.test({ name: "user-account-deletion", fn: testAccount2 });
Deno.test({ name: "user-account-update", fn: testAccount3 });

Deno.test({ name: "settings", fn: testSettings });
Deno.test({ name: "login", fn: testLogin });

现在我们只想执行用户相关的单元测试,就可以使用 --filter 来和测试名称进行匹配。

deno test --filter "user" test.ts

如果我们只能执行用户测试的子集怎么办?Deno 还可以接受正则表达式作为 filter 的参数进行匹配特定的测试。比如我们要运行所有和账户相关的测试,可以使用下面的命令:

$ deno test --filter "/user-account-\*d/" test.ts

单个测试

与 Jasmine 一样,Deno 也提供了一种机制来临时跳过所有其他测试,只运行一个测试。在配置对象中设置 only 属性为 true,就可以让测试运行器跳过其他所有测试,只运行这一个测试。

Deno.test({
  name: 'critical test',
  only: true,
  fn() {
    testComplexFeature()
  }
})

只要在任何测试中存在 only 选项,无论这个单元测试是否成功,整个测试都会失败。因为 only 是一种临时方案,这种做法可以防止我们忘记删除 only 选项。

断言

断言可以帮助我们来定义测试需要满足的要求。Deno 内置了丰富的断言库,我们只需要从 deno.land/std@VERSION… 模块中导入它们。下面是一些常用的断言方法:

  • assert:参数为布尔值,true 为成功,false 为失败
  • assertEquals:两个值相等
  • assertNotEquals:两个值不相等
  • assertExists:验证一个值不是 null 或 undefined
  • assertStrictEquals:严格比较两个值是否相等,对于非原始值,会比较引用
  • assertStringIncludes:实际字符串中是否包含预期字符串
  • assertArrayIncludes:在数组中查找一个值
  • assertMatch:参数是否和正则表达式匹配
  • assertNotMatch:参数不与正则表达式匹配
  • assertObjectMatch:参数对象是否和预期对象的属性匹配
  • assertThrows:预期传递的函数抛出异常
  • assertRejects:和 assertThrows 类似,但它需要返回一个 Promise 对象