襁褓中的 deno(一):运行时调用分析

913 阅读8分钟
原文链接: zhuanlan.zhihu.com

上一篇文章中,我们简单地认识了一下 deno,聊了聊它的主要特性与不足。现在让我们的目光从宏观转向微观,从源码角度来了解一下,在 deno 中,TS 和 Go 是如何进行相互调用的。


v8worker2 是什么?

在阐述 deno 的内部调用机制之前,我们先来看一看 Ryan 大大的另外一个项目:v8worker2

为什么我们要关注这个项目呢?v8worker2 其实是一个 v8 的 Go 语言绑定,也就是说,通过调用 v8worker2 的方法,我们可以达到间接调用 v8 的效果。很明显,这个就是利用 Go 来运行 JavaScript 代码的核心。

我们先来看看 v8worker2 是怎么用的:

// echo.go

import "github.com/ry/v8worker2"

func main() {
  //  注册 worker 实例,并注册一个接受 JS 信息的回调
  worker := v8worker2.New(func(msg []byte) []byte {
    println("In Go,", string(msg))
    return nil
  })

  utilCode := `...`
  jsCode := `
    // 在 JS 中注册接收 Go 信息的函数,并打印接收到的信息
    V8Worker2.recv(msg => {
      V8Worker2.print("In js, " + ab2str(msg));
    });
    // 从 JS 向 Go 中发送『from js』信息
    V8Worker2.send(str2ab("from js"));
  `

  // 加载 JS 工具函数代码,这里按下不表
  worker.Load("utils.js", utilCode)
  // 加载业务代码
  worker.Load("foo.js", jsCode)
  // 从 Go 中向 JS 中发送『from Go』信息
  worker.SendBytes([]byte("from Go"))
}

// 运行结果:
// In Go, from js
// In js, from Go

上面是一个最简单的 JS <—> Go 的信息交互代码示例,整体的思路非常清晰。先注册一个worker,然后在这个 worker 中加载运行相应的 JS 代码字符串,注意 worker.Load()的第一个参数看似是文件名,其实这个参数只是用于异常等需要显示文件名的场景下,本身和代码并没有什么联系,最后是利用 worker.SendBytes()函数向 JS 发送信息。

jsCode 中,我们会发现一个奇怪的变量V8Worker2, 这个变量是 JS 交互的核心,但是我们是在哪里定义这个变量的呢?让我们来简单看一下 v8worker2 的实现,答案就藏在其中。


v8worker2 实现浅析

v8worker2 非常得简洁,核心的文件只有 binding.ccworker.go 这两个,其中 binding.cc 更是重中之重。

binding.cc 中用 cpp 定义了供 JS 调用的 API(也就是上一节 JS 代码字符串中的 V8Worker2 )和供 Go 调用的 API,而 worker.go 文件中则是对 Go API 进行了简单地封装,也就变成了上一节 Go 代码中的v8worker2.Newworker.SendBytes等等。我们来看看 binding.cc 中是怎么定义 JS 的 V8Worker2的 :

// Go API v8worker2.New 的底层实现

worker* worker_new(int table_index) {
  worker* w = new (worker);

  Local<ObjectTemplate> global = ObjectTemplate::New(w->isolate);

  // 定义一个类型为 Object 的 JS 全局变量
  Local<ObjectTemplate> v8worker2 = ObjectTemplate::New(w->isolate);

  // 将上面的全局变量在 JS 中的名称设置为 V8Worker2
  global->Set(String::NewFromUtf8(w->isolate, "V8Worker2"), v8worker2);

  // 在 V8Worker2 变量上新增一个 key 为 print,value 为 Print(cpp 函数) 的属性
  v8worker2->Set(String::NewFromUtf8(w->isolate, "print"),
    FunctionTemplate::New(w->isolate, Print));

  // 在 V8Worker2 变量上新增一个 key 为 recv,value 为 Recv(cpp 函数) 的属性
  v8worker2->Set(String::NewFromUtf8(w->isolate, "recv"),
    FunctionTemplate::New(w->isolate, Recv));

  // 在 V8Worker2 变量上新增一个 key 为 send,value 为 Send(cpp 函数) 的属性
  v8worker2->Set(String::NewFromUtf8(w->isolate, "send"),
    FunctionTemplate::New(w->isolate, Send));

  context->Enter();
  return w;
}

从上面的代码中,我们可以看出,当我们在执行worker := v8worker2.New() 这段 Go 代码的时候,在底层上我们其实是初始化了一个 v8 的 worker,然后在上面定义了V8Worker2 变量与一系列函数,并把这些 cpp 函数与 JS 变量名关联起来,这也是我们的 JS 代码能直接使用 V8Worker2.send()等函数的原因。

我们最后来看看 binding.cc 中主要定义了哪些函数和数据结构:

// worker 是 deno 的核心,包含独立的 v8 引擎和
// js 与 go 交互的相关信息
struct worker_s;
typedef struct worker_s worker;

// 信息交互的格式
struct buf_s {
  void* data;
  size_t len;
};
typedef struct buf_s buf;

/* 供 Go 调用的函数*/
// New 函数的底层实现
worker* worker_new(int table_index);

// worker.Load 函数的底层实现
int worker_load(worker* w, char* name_s, char* source_s);

// worker.SendBytes 函数的底层实现
int worker_send_bytes(worker* w, void* data, size_t len);

/* 供 JS 使用的函数*/
// V8Worker2.print 函数的实现
void Print(const FunctionCallbackInfo<Value>& args);

// V8Worker2.send 函数的实现
void Send(const FunctionCallbackInfo<Value>& args);

// V8Worker2.recv 函数的实现
void Recv(const FunctionCallbackInfo<Value>& args);

deno 中的信息交互分析

从上文中我们已经(假装)弄清了 JS 和 Go 是如何利用 v8worker2 来进行交互的,那么我们来结合 deno 中的实例来看一下。

我们来看一个很简单的例子 :

// os.ts
function readFileSync(filename: string): Uint8Array {
  const res = sendMsg("os", {
    command: pb.Msg.Command.READ_FILE_SYNC,
    readFileSyncFilename: filename
  });
  return res.readFileSyncData;
}

deno 实现了一个文件读取的方法,但是这里的实现只是简单地发送消息,然后拿到返回的消息,从中获取数据。那这里的消息是发送给谁的呢?很简单,这个消息是发送给 go 的。

// os.go

func InitOS() {
  Sub("os", func(buf []byte) []byte {
    msg := &Msg{}
    // 利用 protobuf 解码信息
    proto.Unmarshal(buf, msg)
    // 根据指令类型来进行处理函数的匹配
    switch msg.Command {
      case Msg_READ_FILE_SYNC:
        return ReadFileSync(msg.ReadFileSyncFilename)
     }
    return nil
  }) 
}

// 真正的读取文件内容的处理函数
func ReadFileSync(filename string) []byte {
  data, _err1 := afero.ReadFile(fs, filename)
  res := &Msg{
    Command: Msg_READ_FILE_SYNC_RES,
    ReadFileSyncData: data
  }
  // 利用 protobuf 来编码含有文件内容的 Msg
  out, _err2 := proto.Marshal(res)
  return out
}

这里的 InitOS 在后文中会提到,这里就闲话少表。联系上面的 os.ts 中的代码,我们能很明显地看出一条调用链:ts 发送一个信息,信息的类型为"os",指令为 READ_FILE_SYNC,还有个 filename 的参数。而在 os.go 中,我们会注册一个订阅函数,当接受到一个"os" 类型的信息时,就会执行 switch逻辑。当确认该条消息是关于文件读取时, 就会调用 ReadFileSync函数来进行真正的文件读取,并把读取到的内容封装成 Msg 返回给 ts。

整条调用关系通了,但是里面的细节却有点黑盒,我们这里再来分析一下SendMsgSub函数,希望能对信息交互方式有更多的了解。首先来看 JS 的SendMsg 方法:

// dispatch.ts
function sendMsg(channel: string, obj: pb.IMsg): null | pb.Msg {
  // 将 obj 对象和 channel 利用 protobuf 编码,并转换成 ArrayBuffer
  const payload = pb.Msg.fromObject(obj);
  const ui8Payload = pb.Msg.encode(msg).finish();
  const msg = pb.BaseMsg.fromObject({ channel, payload }); 
  const ui8 = pb.BaseMsg.encode(msg).finish();
  const ab = typedArrayToArrayBuffer(ui8);

  // 将信息发送给 Go 并获取结果
  const resBuf = V8Worker2.send(ab);
  // 将结果通过 protobuf 解码
  const res = pb.Msg.decode(new Uint8Array(resBuf));
  return res;
}

啊哈!我们看到了熟悉的 V8Worker2.send ,这菊稳了!其实整个 sendMsg主要是在处理信息编码和解码,真正和 Go 的交互只有这么一句。顺带提一下,deno 中对信息的编码利用的是 protobuf,这是 Google 推出的一种数据交换格式,和大家熟悉的 JSON、XML 什么的比较类似,不过主要面向的是复杂的跨语言、跨进程调用的场景,以后有机会可以展开再聊聊。

看完了 JS 的发送端,我们再来看看 Go 这里的接收端:

// dispatch.go
func Sub(channel string, cb Subscriber) {
  subscribers, ok := channels[channel]
  if !ok {
    subscribers = make([]Subscriber, 0)
  }
  subscribers = append(subscribers, cb)
  channels[channel] = subscribers
}

这里的Sub 其实非常简单,只是维持了一个 channel 和处理函数的 Map,真正的逻辑其实是在 deno 启动时候执行的 Init.go 之中

func Init() {
  // 注意这里执行了上面的 InitOS 函数,
  // 也就是执行了 Sub("os", ...)
  InitOS()
  // 熟悉的配方,熟悉的味道,这里的 recv 函数下面来解释
  worker = v8worker2.New(recv)
  // 获取 deno 的整个 js 代码字符串
  main_js = stringAsset("main.js")
  // 加载 deno
  worker.Load("/main.js", main_js)
} 

Init.go中,我们能看到非常熟悉的一幕——注册 worker 和 recv回调,加载 JS 代码。 其中的 recv,就是 Go 用来处理来自 JS 的信息的回调函数。

最后让我们来一窥 recv的庐山正面目:

 func recv(buf []byte) (response []byte) {
   msg := &BaseMsg{}
   // 利用 protobuf 来解码获取的信息
   proto.Unmarshal(buf, msg)
   // 利用 msg 中的 Channel 字段来获取相应的处理函数数组
   subscribers, ok := channels[msg.Channel]

   for i := 0; i < len(subscribers); i++ {
     // 挨个获取处理函数
     s := subscribers[i]
     // 调用处理函数并获取结果
     r := s(msg.Payload)
     if r != nil {
       response = r
     }
   }
   return response
}

代码清晰明了,整个 deno 中的 JS —> Go 的调用关系就此水落石出。


总结一下

deno 中 JS 和 Go 的交互 能力由 v8worker2 这个 Go 项目来提供,其中提供给 JS 的 API 主要是V8Worker2.send V8Worker2.recvV8Worker2.print,而提供给 Go 的 API 主要为 Newworker.SendBytesworker.load等等。

在 deno 启动时,Init.go 文件执行,调用 New产生独立的 V8 引擎,并注册 Go 的信息接收回调(recv函数),接着调用 worker.load来加载运行 deno 实现中的 TS 部分(已被转译成 JS)。最后在 terminal 中输入 deno xxx.ts来执行业务文件,业务文件中使用 readFileSync等 builtin 的方法,这些方法会调用 V8Worker2.send 与真正的 Go 实现进行交互,并将结果返回给业务代码。

下一篇文章我们将来聊一聊 deno 是怎么启动的,其实本篇文章的总结中已经对启动过程有了一定的描述,不过下一篇我们将从更宏观的角度来理解与抽象 runtime 的启动方式。