更了不起的Deno

882 阅读15分钟

前言

Deno从诞生之初,就一直受到广大开发者的关注。曾经还因为issue事件,掀起一阵全民狂欢。主要还是因为他的作者太特殊,那个创造了node的男人。本人也是众多关注者中的一个,恰好团队把Deno立为一个技术规划方向,于是就抽出时间好好研究了下,趁着周末和大家分享一波,也作为自己的一个总结。

本文会对Deno做个全方面的介绍,包括基础篇,架构篇,实现篇,生态篇

预备知识

在学习Deno之前,需要先掌握一些前置知识,以便更好的理解Deno的实现和代码细节

ArrayBuffer

ArrayBuffer—储存二进制数据的一段内存,它不能直接读写

ArrayBuffer诞生的背景是因为WebGL,为了满足 JavaScript 与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式.于是设计了这一套标准

要操作这段内存空间,需要使用视图,JS中定义了两种类型的视图:

  • TypedArray—数组成员都是同一个数据类型视图
  • DataView—数组成员是不同数据类型的视图

TypedArray是一系列视图的合集,包括Uint8Array(无符号 8 位整数)数组视图, Int16Array(16 位整数)数组视图等等,DataView则是这些视图的组合(数组中会包含多种数据类型),来个例子:

const buffer = new ArrayBuffer(12);

const x1 = new Int32Array(buffer);
x1[0] = 1;
const x2 = new Uint8Array(buffer);
x2[0]  = 2;

x1[0] // 2
//x1和x2共享同一段内存,所以会互相影响,如果设置x2[1] = 2,x1[0]=?

编解码

TextEncoder—接受代码点流作为输入,并提供 UTF-8 字节流作为输出

TextDecoder—解码器将字节流作为输入,并提供代码点流作为输出

主要就是将字符串和TypedArray进行互相转换,Deno中文件处理会大量使用,例子:

new TextEncoder().encode('copy') ===> Uint8Array(4) [99, 111, 112, 121]

基础篇

可能大家对Deno都不是很了解,先简单介绍下

Deno 建立在 V8、Rust 和 Tokio 的基础上。 目标是兼容浏览器,为用户提供一个安全的JavaScript/TypeScript 的运行时

从Deno的发展历程中,可以看出,作者创造Deno时,借鉴了很多业界的先进经验,以及自己多年的技术沉淀,所以在设计Deno时会有一些和node明显的区别,先上一段代码感受下:

//index.js
import { serve } from "https://deno.land/std@0.79.0/http/server.ts";

let str: string = "hello";

const text = Deno.readFileSync("./data.txt");

window.addEventListener("load", ()=>{});

window.addEventListener("unload", ()=>{});



deno run  --allow-read index.js

从中可以看出一些特色:

内置APi

Deno中api很多都是直接挂载在Deno全局对象上的,不像node进行了分类:fs,crypto,path,url这些模块。使用比较方便,但似乎不太好管理

模块系统

Deno中的模块都是直接通过url直接引入的,没有node_modules这个黑洞。将npm的中心化管理变成了去中心化,对此业界讨论比较大,可能url引入不是最完美的,但是至少是解决了npm的很多问题,对模块化的一种积极尝试,未来让我们拭目以待

安全模型

Deno中对应用的权限进行了分类,需要显式指定文件、网络和环境权限,否则运行文件会报错 一般通过 --allow-*来指定对应的权限,详细的细节,会在实现篇中进行讲解

Typescript

Deno中内置了ts解析引擎,直接编写ts代码即可,Deno会自动解析,转成对应的JS代码

兼容浏览器

Deno从设计之初,就是拥抱浏览器,ECMAScript等标准,让前端代码尽可能地在Deno中运行。我们可以在Deno中使用window对象,还可以用addEventListener监听事件,以及使用console和URL等等,未来支持的会越来越多

和node简单的区别

  • 事件和异步
    • 回调 ===> promise
  • 多进程
    • child_process ===> worker
  • IO操作
    • Buffer ===> TypedArray
  • 事件循环
    • libuv ===> Tokio

到这,如果之前完全没接触过Deno的,应该有个大致的认识了,了解了Deno的一些编写风格,由于本篇不是一个使用手册,不会对所有细节进行阐述,大家可以参考官方文档 查看更多细节。

内置工具集

  • 内置格式化工具:deno fmt myfile1.ts
    • // deno-fmt-ignore ---忽略下一行代码
    • // deno-fmt-ignore-file---忽略整个文件
  • 构建工具:deno bundle './colors.ts' colors.bundle.js
  • 生成文档:deno doc add.ts --json
  • 代码检查:deno lint --unstable myfile1.ts

Deno中内置了很多开发工具,可以看出想把当前的node生态太过分散,开发者们疲于应付各种层出不穷的工具的困扰的现状进行改进,让代码编码,构建,语法规范这些都交给Deno来做,未来这些工具集的不断完善,很有可能会改变前端百花齐放的生态

架构篇

首先,我们去github上看下Deno的源码结构:

这是目前(2020-12-5)Deno代码库的划分,对其中的一些模块进行了标注,方便大家自行研究时有个分类

谈到架构,就得上官网的核心架构图,不过这张图更新太慢,有些实现已经变了

主要变化就是libdeno已经替换成rusty_v8,js可以直接和rust进行交互了,使用rust就可以完成功能开发,后面会介绍。Tokio是一个事件驱动的非阻塞I/O平台,用于使用Rust编程语言编写异步应用程序,相当于node中libuv。Deno里面js与rust的交互都会通过Deno.core.send和Deno.core.recv这两个方法,send发起同步任务,recv用来触发异步回调。Deno为了安全的原因,对资源都做了一层映射,原本系统的文件描述符或者其他资源都映射成了一个rid(非负整数)。rust和Tokio之间通过ops进行任务的关联,ops是一个系统操作映射表,将操作和一个唯一的id进行关联,结构为:

这是官网的一张类比表,可以看出Deno团队想把Deno打造的和linux一样,成为一个JS的工作平台,而不单是一门服务端语言

实现篇

接下来,我们一起从源码的角度去看下Deno中的一些模块的具体实现

安全模型

上文提过,我们通过命令行运行Deno程序的时候,都会显示地指定具体的权限:

deno run --allow-read=/foo/bar 'https://xxxx'

在Deno内部是通过permission模块来管理这些权限的,上面的命令在Deno源码中会被解析为:

const desc = { name: "read", path: "/foo/bar" } as const;

Deno还会暴露三个核心Api来让用户去管理权限:

console.log(await Deno.permissions.query(desc));
// PermissionStatus { state: "granted" }

const status1 = await Deno.permissions.request(desc);
// ⚠️ Deno requests read access to "/foo". Grant? [g/d (g = grant, d = deny)] g
console.log(status1);
// PermissionStatus { state: "granted" }

console.log(await Deno.permissions.revoke(desc));
// PermissionStatus { state: "prompt" }

permission类中通过状态去标识对应的权限:

pub enum PermissionState {
  Granted = 0,
  Prompt = 1,
  Denied = 2,
}

pub struct Permissions {
  pub read: UnaryPermission<PathBuf>,
  pub write: UnaryPermission<PathBuf>,
  pub net: UnaryPermission<String>,
  pub env: PermissionState,
  pub run: PermissionState,
  pub plugin: PermissionState,
  pub hrtime: PermissionState,
}

从代码中可以清晰看到Deno的权限都有哪几类,接下来,我们以env权限为例,因为它的代码较少,方便梳理,源码中的实现为:

pub fn request_env(&mut self) -> PermissionState {
    if self.env == PermissionState::Prompt {
      if permission_prompt("Deno xxx") {
        self.env = PermissionState::Granted;
      } else {
        self.env = PermissionState::Denied;
      }
    }
    self.env
  }

pub fn query_env(&self) -> PermissionState {
    self.env
 }
 
 pub fn revoke_env(&mut self) -> PermissionState {
    if self.env == PermissionState::Granted {
      self.env = PermissionState::Prompt;
    }
    self.env
 }

可以看到代码结构很清晰,其中permission_prompt方法会输出终端,接受用户输入,更改权限状态,其他的逻辑就是根据对应的类型修改状态了

那么Deno程序中是怎么运用这个权限的呢,我们的程序就是一个mainWorker,会有一个全局状态:

permission就是其中的一个属性,flags属性也很关键,是我们命令行的一些参数构成的对象,通过这张图就能清楚的知道permission是怎么运作的了:

执行机制

在讲执行机制之前,先过一下之前提到过的Tokio:

Tokio是一个事件驱动的非阻塞I / O平台,用于使用Rust编程语言编写异步应用程序

Tokio中的异步模型,是基于future实现的,熟悉Rust语言的应该很了解。基于我们熟悉的promise,简单对比一下,future的基本特征:

future特征:
trait SimpleFuture {
    type Output;
    fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}
 enum Poll<T> {
    Ready(T),
    Pending,
}

js中的promise状态转移靠的是push,它本身就是一个事件源,能够主动去改变自身状态,我们在then函数中注册的回调,会自动在下一个微任务队列中执行;而future靠的是poll,需要一个excutor去轮询获取其状态,也就是说excutor会在适当的时候执行future的poll方法,当返回状态为Poll::Pending也就意味需要excutor等待进行下一次轮询,当返回Poll::Ready也就是意味future已经完成,然后根据返回的值去判断是否出错,去执行下一步代码逻辑。 当然了,excutor不会不间断地去轮询,所以future的poll方法里面是可以设置一个waker(唤醒任务),去通知excutor什么时候该来轮询的。

接下来,分析下Deno程序是怎么执行的

入口函数:cli/main.rs的main方法,主要干了两件事:

  1. 解析命令行参数然后创建对应的future(任务)
  2. 启动Tokio执行future(任务)

通过代码可以看出,关键的步骤就是Run对应的逻辑,也就是run_command方法

核心逻辑就是Deno会创建一个MainWorker,就是我们的主进程,然后是四个关键步骤:

执行用户逻辑代码->触发load事件->事件循环->触发unload事件

JS与Rust交互

代码路径:core/core.js

deno里面JS与Rust的交互只能通过send和recv这两个方法

send会根据opId去调用对应的rust方法,同步方法会直接返回,异步的话,需要通过recv去获取异步结果

Object.assign(window.Deno.core, {
    jsonOpAsync,
    jsonOpSync,
    setAsyncHandler,
    dispatch: send,
    ops,
    close,
    sharedQueue: {},
    ...
  })

其中ops很关键,之前有提到过,它是一个系统操作映射表,将操作和一个唯一的id进行关联,是JS和Rust之间沟通的桥梁

整个core.js就是给Window.Deno.core定义这些api方法,Deno中触发任务的代码如下:

//同步
Deno.core.dispatch(opId, scratchBytes, ...zeroCopy);

//异步
Deno.core.setAsyncHandler(opId,cb)
Deno.core.dispatch(opId, scratchBytes, ...zeroCopy);

接下来,我们通过关键代码,看下基本流程是什么样的

通过setAsyncHandler设定的回调函数,会进行初始化,回调函数会存储到全局的handles表里。初始化函数里设置recv的回调函数,供Tokio异步任务完成后触发。handleAsyncMsgFromRust函数则会从全局的回调队列中取出队首任务,根据opId去执行对应的handler

这一套机制和我们平时进行hybrid开发的设计是不是有点类似,对比下设计图就清晰了:

核心代码实现:

最后来到core/bingding.rs的initialize_context,在这里是deno初始化核心方法的地方(都是挂在Deno.core这个对象下),send和recv也是在这里注入到JS中的

用一张流程图,将上述的片段串一下:

生态篇

接下开看下正在茁壮成长的Deno,都有哪些周边生态

开发框架-oak

A middleware framework for Deno's net server

很明显,oak对标的是koa,连取名都这么刚,实现功能基本一样,使用它可以开发基础的web应用,不过相比koa和node,第三方插件就少一些。还有像MongoDB,Redis等数据库,都有对应的Deno版本,开发一些服务端应用还是可以的,国内很火的企业级开发框架egg并没有对应的Deno版本,开发大型企业应用这块,目前还是一个探索领域

跨端

Electron包含chromium,node.js,原生Api三大部分,借助它,我们可以开发跨端的桌面应用

Electron目前还不支持Deno,如果在Deno中想开发桌面应用,可以使用webview_deno这个第三方库,它基于webview_rust实现,虽然在功能上与Electron有一定差距,但是可以让我们去探索Deno中的桌面世界

Serverless

Serverless代表一种无服务器架构(也被称为“无服务器计算”),并不表示不需要物理服务器,而是指不需要关注和管理服务器,直接使用服务即可,其实就是一种服务外包或者说服务托管,这些服务由第三方供应商提供。

Serverless领域最具代表性的是阿里云和亚马逊,不过他们均不原生支持Deno的运行时。我们可以通过自定义运行时来实现,以阿里云为例,实现一个 Custom Runtime,只需要满足以下 3 个条件:

  • 创建一个 HTTP Server,监听在固定端口(端口可以读取环境变量 FC_SERVER_PORT,默认为 9000)。

  • HTTP Serverr 需要在 15 秒内完成启动。

  • connection 最好设置为 keep alive,请求超时时间至少设置在 15 分钟以上 借助官方命令行工具funcraft初始化一个函数,然后配置Deno的运行时就可以了。就是学习Deno时,本机安装的过程,放到了云服务器上。执行流程大概是:

RPC(远程过程调用)

RPC基于 TCP/IP 来实现调用远程服务器的方法,在后端分布式架构中应用被广泛应用,实现RPC的三种方式:

  • Thrift:Go、Haskell、Java、Node.js、C、Perl、PHP、Python、Ruby ...

Thrift是一种接口描述语言和二进制通讯协议,它被用来定义和创建跨语言的服务。它被当作一个远程过程调用(RPC)框架来使用,是由Facebook为“大规模跨语言服务开发”而开发的

  • HTTP

使用 HTTP 协议来进行 RPC 调用也是很常见的, 相比 TCP 连接, 通过通过 HTTP 的方式性能会差一些, 但是在使用以及调试上会简单一些

  • MQ

使用消息队列 (Message Queue) 来进行 RPC 调用 (RPC over mq) 在业内有不少例子, 比较适合业务解耦/广播/限流等场景.

目前,Thrift还不支持Deno,所以构建的Deno应用想使用RPC,只能使用下面的两种形式

WebAssembly

WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。(MDN官方定义)

浏览器端一般通过js中间代码(胶水代码)做兼容,将其他语言编写的功能,用js来进行模拟。比如用web worker模拟多进程,用localStorage模拟存储等等

如果想在非浏览器端实现WebAssembly应用,需要提供WASI(WebAssembly System Interface)

WASI 通过增加“抽象层”的方式,解决了 Wasm 抽象机器(V-ISA)与实际操作系统调用之间的可移植性问题,这可以保证我们基于 WASI 编写的 Wasm 应用(模块)真正做到“一次编译,到处运行”。抽象出的“Wasm 系统调用层”将交由具体的底层基础设施(虚拟机 / 运行时)来提供实现和支持

Deno原生内置了WASI模块,在Deno可以执行运行wasm代码,上一个官网示例:

const wasmCode = new Uint8Array([
  0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127,
  3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0,
  5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145,
  128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97,
  105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0,
  65, 42, 11
]);
const wasmModule = new WebAssembly.Module(wasmCode);
const wasmInstance = new WebAssembly.Instance(wasmModule);
const main = wasmInstance.exports.main as CallableFunction
console.log(main().toString());		//42

可以看到wasm代码就是Uint8Array的数组,运行时按照WebAssembly MVP标准去解析它,拿到exports导出函数,去执行我们的逻辑

总结

最后,做一个简短的Deno总结:

  • 弥补了node的一些设计缺陷,和node共存一段时间
  • 兼容浏览器,让前端代码在deno中直接运行
  • 内置工具集待完善,周边生态建设中
  • 拥抱Rust生态圈,主打安全和高并发
  • 拓展前端边界,让JS在服务端大展拳脚

Ryan基于近些年业界的技术发展和实践,倾注了很多心血,创造了Deno。我们可以从中学习到一些设计和实现,这一点是最关键的。至于大家争论的未来是node还是Deno主导,会不会替代node,就显得没那么重要了。技术车轮一直向前,我们需要保持自己的学习力和理解力,多主动思考。