Deno 是代码的浏览器

2018 年 5 月,Ry 公开 Deno 的原型后不久我就开始参与这个项目了。对于 Deno,人们最常问的一个问题是“包管理器跑哪儿去了?”很多时候这都算不上问题,而只是一种吐槽。他们会说“我知道 Deno 很重视安全性,可是从互联网下载资源是不安全的。”或“没包管理器我该怎么管理依赖项啊?”

在我看来,我们应该改变自己的思维模式。因为包管理器和中心化代码库随处可见,所以很多人把它们当成了理所当然的需求。问题在于它们的流行并不是它们不可或缺的证明。这些事物之所以会出现,是因为它们以某种方式解决了问题,可大家就觉得这是解决问题的唯一途径了。我认为这种看法是不对的。

浏览器

想象一下这样的场景:在发布网站时,我们不是登录到一个谷歌中心服务器上,而是将我们的网站上传到一个存储库中。然后当有人想查看我们的网站时,他们要使用命令行工具,在我们本地计算机上的 browser.json 文件中添加一个条目,再访问并获取整个网站;另外还要获取链接到我们本地 websites 目录的其他所有网站,获取完毕后才能启动浏览器开始浏览。这也太疯狂了不是吗?那么为什么跑代码的时候就非得用这种模式呢?

Deno CLI 的工作机制和浏览器很像,只不过浏览的是代码而非网页。你在代码中导入一个 URL,Deno 将获取对应的代码并将其缓存在本地,就像浏览器那样。另一个和浏览器类似的地方是,你的代码运行在沙箱中,沙箱对其中运行的代码(不管其来源如何)的信任度为零。你(调用代码的人)需要从沙箱外部告诉里面的代码可以做什么,不能做什么。最后,代码可以要求你执行操作,你可以选择授权或拒绝,这也和浏览器是一样的。

我们了解代码所需的一切信息都可以由 HTTP 协议提供,并且 Deno 试着充分利用这种协议,这样就用不着发明新的了。

代码发现

首先要认识一点:就像浏览器一样,Deno CLI 不想对你运行的代码有任何意见(opinion)。它列出了如何获取代码,以及如何在计算机中使用沙箱运行代码的规则。我认为运行时应该表达的意见就到此为止才对。

在 Node.js/npm 生态系统中,我们将本地计算机上的代码管理与中心化代码存储库结合在了一起,从而帮助开发人员更方便地发现代码。我认为它们两者都有非常严重的缺陷。

在互联网的早期,我们就尝试过 npm 这一类的代码发现模式。你可以将你的网站添加到 Yahoo! 网站正确的分类下,然后人们就会来浏览;他们可能也会使用搜索功能,但所有这些都是基于内容提供者的观点来构建的,却并没有真正针对消费者的需求而优化。后来谷歌诞生了。为什么谷歌成为了赢家?因为它很好用。它将搜索词与满足需求的最相关网页匹配起来,用这种方式来索引网站;索引过程考虑了多种因素,内容提供商提供的元数据只是其中之一。

虽然我们还没有把这套模型纳入 Deno,但它是可行的方案。此外,我们之所以使用谷歌是因为它为我们解决了问题,而不是有人说“你必须使用谷歌”;而且谷歌还有其他可行的替代方案。

我在推特上与 Laurie Voss 进行了一场辩论,我觉得他非常了解 npm 生态系统。他认为 Deno 需要包管理器,而本文是我的观点的详尽阐述。但是 Laurie 提出了一个非常合理的观点。

图0:Deno 是代码的浏览器

GitHub 已成为开源代码的家园,因为它非常好用并解决了很多问题;它是基于源代码版本控制工具的事实标准 git 构建的。从 Deno CLI 的角度来看,源代码的来源应该没有技术上的限制,我们需要更加广阔的生态系统,以创造并发展更多途径来向社区展示 Deno 中的代码,这些途径可能是我们这些创建 CLI 的人们从未想过的创新形式。

可重复构建

在 npm 生态系统中这是一个问题。由于严重依赖语义版本控制,并且复杂的依赖图往往来自 Node.js/npm 生态系统,因此想要让构建可重复就成了一个挑战。Yarn 引入了锁定文件的概念,npm 也跟上了脚步。

我个人感觉这有点像是在拆东墙,补西墙:生态系统中开发人员的行为造成了一个问题,然后为了解决它又开发出了一个不完善的解决方案。长年和生态系统打交道的人都知道,很多问题的解决办法就是 rm -rf node_modules package-lock.json && npm install。

图1:Deno 是代码的浏览器

而 Deno 为这个问题提供了两种解决方案。首先是 Deno 缓存模块。它可以将缓存 check in 到源代码版本控制中,使用 –cached-only 标志就不会检索远程模块。DENO_DIR 环境变量可用于指定高速缓存的位置,以提供更大的灵活性。

其次,Deno 支持锁定文件。–lock lock.json –lock-write 将使用给定负载所有依赖项的哈希值写出一个锁定文件。当使用 –lock lock.json 时,这将用于验证将来的运行。

还有一些命令可以管理可重复的构建。deno cache 将解析所提供模块的所有依赖项,并填充 Deno 缓存。deno bundle 可用于生成负载的单个文件“构建”,所有依赖项都已解析并包含在该文件中,因此将来的 deno run 命令只需要这一个文件即可。

信任规则

我认为这是另一个需要打破固有思维的领域。无论出于何种原因,我们都无条件地信任中心化存储库中的代码。我们甚至都没有考虑过这种信任是不是合理。不仅如此,我们相信其中的代码已完全审查了所有依赖项,进而信任这些依赖项。我们打开快速搜索框,输入 npm install some-random-package,然后就觉得万事大吉了。我认为丰富的 npm 软件包生态系统把人们惯坏了。

为了应对这种松懈和自满带来的风险,我们在工具链中加入了安全监视软件,用来分析我们的依赖项和数不清的代码,告诉我们哪些代码可能存在安全隐患。一些公司会开发自己的私有存储库,上面托管的软件包接受的审核可能比那个公共存储库中的更严格一些。

这就好像是房间中的那头大象。最佳策略是我们不应该信任任何代码。只要我们建立了这种认识,那么正视那头大象就会变得容易一些。但是,如果我们认为包管理器和中心化存储库可以解决这个问题,或者哪怕是帮助缓解了这个问题,其实我们就是在自欺欺人。实际上,我认为它们的流行让我们的警惕性下降了。“反正它是放在 npm 上,如果它有什么隐患,肯定会有人把它撤下去的。”

Deno 在这方面的工作还不尽如人意,但它起码有一个好的开始。它在启动时是零信任的,并提供了相当精细的权限调整。我个人不喜欢的一件事是 -A 标志,它基本上是在说“好的,那就允许一切权限”。焦头烂额的开发人员很难经得住它的诱惑,而不会去弄清楚自己真正需要的是哪些权限。

收回这些授权也是很困难的事情,要指明“这段代码可以做这件事,但这里的另一段代码就不行”,或者当代码提示自己要提升权限时,搞清楚这些代码是从哪儿来的——这些都是很麻烦的操作。希望我们能找到一种易用的机制,并结合一些在运行时好用且高效的方法来解决这些挑战。

不过,最近的一个变化在我看来是很不错的,那就是 Deno 不再允许你降级 imports。如果从 https:// 导入了某些内容,那么这些内容只能从其他 https:// 位置导入。这和禁止降级传输的浏览器模型是一致的。不过我还是认为从长远来看,最好取消所有未通过 https:// 进行的远程导入,就像服务 Workers 需要 HTTPS 一样。对此我们将拭目以待。

依赖管理

我认为我们需要坦率地谈谈 npm 生态系统中的依赖项。老实说,这个生态是有问题的。在这个生态系统中,这区区 5 行代码每周会下载 3 千万次:

https://github.com/juliangruber/isarray/blob/master/index.js

https://www.npmjs.com/package/isarray

可是过去 9 年来所有浏览器都有这些代码,Node.js 根本用不着它们——这样的生态系统是不正常的。在这个例子中,实际的代码只有 132 个字节,但打完包就变成了 3.4kb。可运行代码只占包大小的 3.8%。“这样也行!”

我觉得这种现状背后有几点成因。其中很重要的一点是我们走反了方向,用的是颠倒过来的模型。问题在于,这种倒退的模式已经改变了我们创建网站的方式。尽管没有中央存储库,但是在构建网站时,我们将下载所有依赖的代码,并将它们烘焙到服务器上加载的内容中,然后用户将一堆代码下载到他们的本地计算机上。一些证据表明,所下载的代码中只有大约 10%是所访问的站点或 Web 应用程序独有的,剩下的那些是我们下载到开发工作站并打包起来的代码。Snowpack 等解决方案就想要解决这种因为走错方向而导致的问题。

另一个重要的问题是我们的依赖项没有与我们的代码耦合起来。我们将依赖项放入 package.json,但我们的代码是不是会真的使用这些依赖项呢?另一方面,虽然我们的代码表示我们正在使用其他一段代码中的内容,但它与后者的版本之间并没有紧密的耦合关系。问题是另外这段代码会直接影响我们正在编写的代码,因为它们之间的确存在依赖关系。

下面就轮到 Deno 模型登场了,我喜欢称其为 Deps-in-JS,因为大家都在用这种叫法。这种模型将我们的外部依赖项显式声明为 URL,意味着代码与其他代码之间的依赖关系简洁明了,并且我们的代码和依赖项会紧密地耦合在一起。如果要查看依赖图,只需对一个本地或远程模块使用 deno info:

	
$ deno info https://deno.land/x/oak/examples/server.ts

local: $deno/deps/https/deno.land/d355242ae8430f3116c34165bdae5c156dca21aeef521e45acb51fcd21c9f724

type: TypeScript

compiled: $deno/gen/https/deno.land/x/oak/examples/server.ts.js

map: $deno/gen/https/deno.land/x/oak/examples/server.ts.js.map

deps:

https://deno.land/x/oak/examples/server.ts

  ├── https://deno.land/std@0.53.0/fmt/colors.ts

  └─┬ https://deno.land/x/oak/mod.ts

    ├─┬ https://deno.land/x/oak/application.ts

    │ ├─┬ https://deno.land/x/oak/context.ts

    │ │ ├── https://deno.land/x/oak/cookies.ts

    │ │ ├─┬ https://deno.land/x/oak/httpError.ts

    │ │ │ └─┬ https://deno.land/x/oak/deps.ts

    │ │ │ ├── https://deno.land/std@0.53.0/hash/sha256.ts

    │ │ │ ├─┬ https://deno.land/std@0.53.0/http/server.ts

    │ │ │ │ ├── https://deno.land/std@0.53.0/encoding/utf8.ts

    │ │ │ │ ├─┬ https://deno.land/std@0.53.0/io/bufio.ts

    │ │ │ │ │ ├─┬ https://deno.land/std@0.53.0/io/util.ts

--snip--

Deno 没那么在乎代码的“版本”。URL 就是 URL。尽管 Deno 需要适当的媒体类型以了解如何处理代码,但关于要提供哪些代码的所有“意见”都留给了 Web 服务器来决定。服务器可以对其核心内容实施语义版本控制,或者对 URL 到所需资源进行任何形式的“魔术”映射。Deno 并不在乎这些。例如, https://deno.land/x/ 实际上只是一个 URL 重定向服务器,它会重写 URL,以在重定向的 URL 中包含一个 git commit-ish 引用。于是 https://deno.land/x/oak@v4.0.0/mod.ts 变成了 https://raw.githubusercontent.com/oakserver/oak/v4.0.0/mod.ts, 这里 GitHub 扮演了一个不错的版本化(versioned)模块的角色。当然,在整个代码库中散布“版本化”的远程 URL 没有多大意义,所以不要这样做。尽管依赖项只是代码而已,但最妙的是你可以按照自己想要的任何方式来构造它们。常见的约定是使用 deps.ts,它将重新导出你可能需要的所有依赖项。看一看 oak 服务器的例子:

	
// Copyright 2018-2020 the oak authors. All rights reserved. MIT license.

// This file contains the external dependencies that oak depends upon

// `std` dependencies

export { HmacSha256 } from "https://deno.land/std@0.51.0/hash/sha256.ts";

export {

  Response,

  serve,

  Server,

  ServerRequest,

  serveTLS,

} from "https://deno.land/std@0.51.0/http/server.ts";

export {

  Status,

  STATUS_TEXT,

} from "https://deno.land/std@0.51.0/http/http_status.ts";

export {

  Cookies,

  Cookie,

  setCookie,

  getCookies,

  delCookie,

} from "https://deno.land/std@0.51.0/http/cookie.ts";

export {

  basename,

  extname,

  join,

  isAbsolute,

  normalize,

  parse,

  resolve,

  sep,

} from "https://deno.land/std@0.51.0/path/mod.ts";

export { assert } from "https://deno.land/std@0.51.0/testing/asserts.ts";

// 3rd party dependencies

export {

  contentType,

  lookup,

} from "https://deno.land/x/media_types@v2.3.1/mod.ts";

我创建了 Oak 服务器,维护了大约一年半,期间经历了 Deno 和 Deno std 库的大约 40 个发行版;其中我还将 media_types 从内部移动到了 Oak,移出 std 库,让它从 std 库中“弹出”来独立存在。但我从来没有想过“嘿,我需要一个包管理器来帮忙”。TypeScript 的好处之一是,你可以全面验证代码与其他代码的兼容性。如果你的依赖项是为 Deno 编写的“原始”TypeScript,那就最好不过了,但是,假设你希望一边利用 TypeScript 对 JavaScript 的预处理,另一边还想安全地使用该远程代码。Deno 支持几种不同的方法来实现这一点,但最无缝的是对 X-TypeScript-Types 标头的支持。此标头向 Deno 指示类型文件所在的位置,可在类型检查你所依赖的 JavaScript 文件时使用。Pika CDN 支持此功能。CDN 上任何具有与之相关联的类型的软件包都将充当该标头,而 Deno 也将获取这些类型,并在检查文件类型时使用它。

综上所述,你仍然可能需要将远程(或本地)依赖项“重新映射”到代码中表达的内容上。在这种情况下,可以使用 import-maps 的一个不稳定实现。这是一个 W3C 提案规范。它允许提供一个映射,该映射会将代码中的特定依赖项映射到另一个源,可以是本地文件抑或远程模块。

https://github.com/WICG/import-maps

我们曾在 Deno 中实现了它很长一段时间,因为我们真的希望它会被广泛采用。遗憾的是,这只是 Chrome 的一项实验,尚未得到更广泛的采用。于是我们决定在 Deno 1.0 中将它放在 –unstable 标志后面。我个人认为它还是很有可能走向死胡同,应该避免使用它。

但是,但是,但是…

我想还是会有很多人对 Deno 的模型提出异议。我认为 Deno 尝试采取的策略(我非常赞同)是在出现实际问题时再做处理。我听到的很多反对意见来自刚入门 Deno 的新手,他们从未与 Deno 项目合作过,也没有试图理解不同的可能性。

话虽如此,如果我们都遇到了同一个问题,并且迫切需要在 Deno CLI 中进行某些更改,我相信 Deno 会去做的。但是很多所谓的问题根本就不存在,或者还有其他解决方法,用不着你的运行时操心那么多事情,或与外部程序耦合来管理代码。

因此,我希望大家尝试一下不用包管理器或中心化的包存储库,看看这样下来会有怎样的结果。你可能再也不会回头了!

英文原文

Denon is a browser for code

本文文字及图片出自 InfoQ

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注