Deno:Node.js之父如何用十年遗憾创造更安全运行时

2018年,Ryan Dahl 站在柏林的舞台上,做了一件没有任何科技公司创始人会做的事:公开列举自己亲手构建的系统里最严重的10个设计错误,并宣布从头开始。

Deno:Node.js之父如何用十年遗憾创造更安全运行时

Deno:Node.js之父如何用十年遗憾创造更安全运行时

2018年5月,柏林。JSConf EU 年度大会,一个科技行业最奇特的演讲开场了。

Ryan Dahl 走上舞台,打开幻灯片,第一张 slide 的标题是:“10 Things I Regret About Node.js”(关于 Node.js 我后悔的十件事)。台下坐的是几百名 JavaScript 开发者,其中很多人用了十年 Node.js,也用了十年 Dahl 最后悔的那些设计决策。

Dahl 不是在做危机公关,也不是在喷自己的竞争对手。他在公开解剖自己的代表作——那个在2009年彻底改变了后端开发世界的 Node.js——里最深的技术债务。这需要一种非常罕见的诚实,以及一种更罕见的自信:只有深刻理解自己的错误,才有资格重新开始。

Node.js 的原罪

理解 Deno,需要先理解 Node.js 在2009年是一个怎样的赌注,以及这个赌注赢了之后留下了什么。

2009年,Dahl 发明 Node.js 时,主流的服务器端开发用的是 Java、Ruby、PHP——这些语言的共同点是同步 I/O:一个请求进来,代码一行行执行,等待数据库返回,等待文件读取,直到所有操作完成才返回响应。这种模型简单、直观,但在高并发场景下性能很差,因为每个等待操作都会阻塞一个线程。

Dahl 的想法是用 JavaScript 的事件循环模型来做服务器端开发:所有 I/O 操作异步化,等待期间去处理其他请求,回调函数在 I/O 完成时被触发。这个想法在理论上很优雅,在实践中对 JavaScript 开发者来说是天然的——他们在浏览器里已经用了十年这种模式。

Node.js 成了。它的性能在某些场景下远超传统的多线程模型,整个 JavaScript 生态从浏览器蔓延到服务器,“全栈 JavaScript” 从一个笑话变成了真实的工程选型。npm 包管理器的生态爆炸式增长,到2018年已经有超过80万个包,成为有史以来最大的软件包生态系统。

但 Dahl 在舞台上列出的十条遗憾,每一条背后都是一个当初为了快速发展而做出的妥协:

最严重的是安全模型缺失。Node.js 脚本可以默认访问文件系统、网络、操作系统进程——任何系统资源,不需要任何授权。这在2009年不是一个被重视的问题,但到2018年,供应链攻击已经成为 JavaScript 生态的主要安全威胁:一个恶意的 npm 包可以在被引入的一瞬间,读取系统上的敏感文件、偷走环境变量里的密钥、向外发送网络请求。

其次是 node_modules 和 package.json 的设计。npm 的中心化包管理虽然创造了生态繁荣,但也带来了”依赖地狱”——一个中型项目的 node_modules 目录轻松超过数百 MB,包含数千个依赖,大多数开发者根本说不清楚自己的项目实际依赖了哪些包、这些包又在做什么。

第三是模块系统的混乱。Node.js 使用的是 require()module.exports 的 CommonJS 模块系统,而 Web 标准采用的是 import/export 的 ES Modules 语法。两套系统在 Node.js 里长期并存、相互不兼容,给无数开发者带来了困惑。

Dahl 演讲的最后,他宣布了 Deno 项目的存在。不是修复 Node.js,而是从头设计一个新的 JavaScript/TypeScript 运行时,让它把所有这些问题都在最底层解决掉。

“从头来一遍”的技术决策

Deno 的核心技术决策,是在2018到2020年的开发过程中逐步成形的。

选择 Rust 作为系统语言,是最有远见的决策之一。Node.js 的底层用的是 C++,这在2009年是标准选择,但 C++ 的内存管理复杂性是 Node.js 历史上大量 bug 的根源之一。Rust 在语言层面保证了内存安全,同时不引入垃圾回收的性能开销——这对一个运行时来说是完美的属性。

安全模型的设计是 Deno 最激进的创新。所有对系统资源的访问,默认全部禁止。如果一段 Deno 代码需要读取文件,需要在运行时显式传入 --allow-read 标志;需要访问网络,需要 --allow-net;需要读取环境变量,需要 --allow-env。这不只是安全工程上的改进,更是一种哲学转变:权限应该是显式授予的,而不是默认拥有的。

这个模型在实践中的意义是深远的。一个 Deno 脚本,哪怕它引入了一个不可信的第三方模块,如果整个脚本没有被授予文件系统访问权限,这个恶意模块也无法读取任何文件。沙盒是强制性的,不是可选配置。

原生 TypeScript 支持在当时是一个大胆的决定。TypeScript 在2018年已经相当流行,但在 Node.js 里使用它需要配置编译步骤,这增加了工具链的复杂性。Deno 直接在运行时层面支持 TypeScript,开发者可以直接运行 .ts 文件,不需要任何配置,不需要 tsc,不需要 ts-node。

模块系统采用 URL 导入,放弃了中心化的 npm。Deno 的模块通过 HTTPS URL 直接引入:import { serve } from "https://deno.land/std/http/server.ts"。这意味着没有 node_modules,没有 package.json,所有依赖都显式指定了版本(通过 URL 里的版本号)。

2020年5月:Deno 1.0 发布

Deno 从2018年宣布到2020年5月正式发布 1.0,经历了将近两年的开发。

发布当天,技术社区的反应是复杂的。支持者认为 Deno 是 JavaScript 运行时的未来;质疑者认为 Deno 的 URL 导入过于反直觉,与 npm 生态的不兼容是一个根本性障碍;务实派认为”Node.js 够用了,迁移成本太高”。

这些质疑在一定程度上是对的。Deno 在发布初期的生态非常有限,大量 npm 包无法直接在 Deno 里使用,这对于习惯了 npm 丰富生态的开发者来说是真实的摩擦。

但 Deno 的影响不只是它自身的采用率,还有它对整个 JavaScript 生态的推动效应。Node.js 在 Deno 发布后,明显加快了对 ES Modules 的支持步伐,也开始讨论权限模型的引入(虽然进展缓慢)。Bun 在2022年的出现,某种程度上是受了 Deno “运行时应该更现代化”这个命题的影响,尽管 Bun 选择了与 Deno 不同的方向(性能优先而非安全优先)。

Deno Deploy 与商业化

2021年,Deno 公司推出了 Deno Deploy——一个基于 Deno 运行时的边缘计算平台。

Deno Deploy 的技术优势来自 Deno 运行时的特性:因为不依赖 node_modules,Deno 的冷启动速度极快(没有庞大的模块目录需要加载);因为基于 V8 隔离而不是独立进程,多租户部署的资源利用率更高;因为安全模型默认隔离,边缘部署的安全保障在架构层面就已经内置了。

Deno Deploy 与 Cloudflare Workers 形成了直接竞争,但定位有所不同:Cloudflare Workers 是一个成熟的商业边缘计算平台,有强大的基础设施支撑;Deno Deploy 则是一个更紧密地与开发工作流整合的平台,对使用 Deno 开发的项目有更原生的支持。

2021年,Deno 公司完成了2200万美元 A 轮融资,投资方包括红杉资本。这给了 Deno 足够的资源继续开发运行时本身,同时推进 Deno Deploy 的商业化。

一个诚实故事的商业现实

Deno 的故事,在技术社区里的评价是相当两极化的。

支持者认为它展示了技术创新的正确方式:不被向后兼容绑架,勇于重新设计,把安全性放在架构的第一位。批评者认为它高估了开发者切换运行时的意愿,URL 导入是一个过于反直觉的设计,而与 npm 生态的不兼容在现实工程中是难以克服的障碍。

2022年发布的 Deno 2.0,在一定程度上妥协了:它开始支持 package.json 和 npm 包的直接导入,承认了与 Node.js 生态兼容的现实必要性。这被部分人解读为 Deno 理想主义的退步,也被另一部分人解读为务实的产品演化。

Ryan Dahl 本人在 2022 年的一次访谈里,对 Deno 的未来有一个清醒的表述:“我们不是在和 Node.js 竞争,我们是在证明 JavaScript 运行时可以是什么样子。”

这个定位或许是 Deno 最准确的自我描述:不是 Node.js 的杀手,而是一个存在本身就改变了整个运行时生态讨论框架的存在。那个2018年柏林舞台上的”十条遗憾”演讲,在五年后依然是 JavaScript 社区引用最多的技术演讲之一,不是因为它的结论,而是因为它展示了一种技术工作者应该具备的品质:诚实面对自己的作品,然后有勇气重新开始。

“如果我们能回到过去,我会做出不同的选择。现在,我们有机会重新开始。” —— Ryan Dahl