在上一篇文章 https://zhuanlan.zhihu.com/p/37649701 中, 我们简单地认识了一下 deno, 聊了聊它的主要特性与不足. 现在让我们的目光从宏观转向微观, 从源码角度来了解一下, 在 deno 中, TS 和 Go 是如何进行相互调用的.
v8worker2 是什么?
在阐述 deno 的内部调用机制之前, 我们先来看一看 Ryan 大大的另外一个项目: https://github.com/ry/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 非常得简洁, 核心的文件只有 https://github.com/ry/v8worker2/blob/master/binding.cc 和 https://github.com/ry/v8worker2/blob/master/worker.go 这两个, 其中 binding.cc 更是重中之重.
http://binding.cc 中用 cpp 定义了供 JS 调用的 API(也就是上一节 JS 代码字符串中的 V8Worker2 )和供 Go 调用的 API, 而 worker.go 文件中则是对 Go API 进行了简单地封装, 也就变成了上一节 Go 代码中的 v8worker2.New,worker.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()等函数的原因.
我们最后来看看 http://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.
整条调用关系通了, 但是里面的细节却有点黑盒, 我们这里再来分析一下 SendMsg 和 Sub 函数, 希望能对信息交互方式有更多的了解. 首先来看 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 https://developers.google.com/protocol-buffers/ , 这是 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.recv,V8Worker2.print, 而提供给 Go 的 API 主要为 New,worker.SendBytes,worker.load 等等.
在 deno 启动时, Init.go 文件执行, 调用 New 产生独立的 V8 引擎, 并注册 Go 的信息接收回调(recv 函数), 接着调用 worker.load 来加载运行 deno 实现中的 TS 部分(已被转译成 JS). 最后在 terminal 中输入 deno xxx.ts 来执行业务文件, 业务文件中使用 readFileSync 等 builtin 的方法, 这些方法会调用 V8Worker2.send 与真正的 Go 实现进行交互, 并将结果返回给业务代码.
下一篇文章我们将来聊一聊 deno 是怎么启动的, 其实本篇文章的总结中已经对启动过程有了一定的描述, 不过下一篇我们将从更宏观的角度来理解与抽象 runtime 的启动方式.
来源: https://juejin.im/entry/5b15ec0b5188257d902547fa