[TOC]

前置知识

在开始写 Hello World 之前,先来了解一些 C++ Addon 的基础知识。大家都知道 Node 提供的 JS 运行时底层是来自 Google 的 V8 引擎,所以要写 Node 的 C++ Addon,我们需要对 V8 的基础知识和 API 有了解,基于 V8 提供的 API 和数据结构才能让 Addon 能被 node 调用起来。在 V8 的官方网站上也提供了丰富的文档和博文,感兴趣可以自行查阅。

数据类型

V8 作为 JS 引擎,是我们编写的 JavaScript 代码的执行环境。我们在 JavaScript 里声明了一个变量,就会在运行时由 V8 引擎的内存机制进行管理。V8 会在 C++ 的数据类型之上提供一层封装,在我们编写 Addon 时也需要用到 V8 提供的这些数据类型来声明和访问。比如在 C/C++ 里,大家如果还有印象,字符串类型是一个结尾为 '\0' 的字符数组,一般以 char*char[] 的形式定义,而在 JavaScript 中我们声明比如 let s = 'Hello World' 这样的字符串,V8 会新建一个 V8::String 类型的对象来存储和管理这个字符串,里面还会有 V8 内置的数据信息。
image.png
可以看到 V8 里对于 JS 数据类型的映射封装非常完备,原始类型和引用类型最终都继承自 Value 类型。原始类型里就有 v8::Booleanv8::Stringv8::Number 等分别映射到 JS 里的布尔、字符串、数值等类型,引用类型继承自 v8::Object,包括 v8::Arrayv8::Datev8::PromiseV8::Function 等等。这样 JS 定义的每个变量类型都对应 V8 里相应的数据结构,由 V8 的内存管理策略来跟踪管理这些变量的内存分配回收,从而使我们的 JS 程序能跑起来。具体的关于 V8 内存管理的话题不在这里展开了,有兴趣的同学可以参考朴灵老师的 《深入浅出 Node.js》,有专门的章节讲得非常详细了。

基本概念

除了数据类型之外,我们还需要了解 V8 引擎执行的一些基本概念。首先是隔离实例(Isolate),V8 中一个引擎实例的数据类型叫 Isolate,它其实是 Isolate Instance 的简称,简称为 Isolate 而不是 Instance 主要是为了突出它的隔离性,每个实例之间相互独立互不干扰。

这是 V8 中所有要执行的地方都要出现的数据。它就是一个 V8 引擎的实例,也可以理解为引擎本体。每个实例内部拥有完全独立的各种状态,包括堆管理、垃圾回收等。

通过一个实例生成的任何对象都不能在另一个实例中使用。什么意思呢?抛开 Node.js 不说,使用 Chrome V8 进行开发的开发者其实是可以在它的程序中创建多个 Isolate 实例,并且并行地在多个线程中使用的——但是一个实例不能在多线程中使用。但是实例自身并不执行 JavaScript,也没有 JavaScript 环境里面的上下文。

…在我们开发Node.js的C++扩展时,我们已经处于Chrome V8的环境中,这时就不需要再生成一个实例了,直接获取 Node.js 环境所使用的实例即可。

比如一般我们通过 args 的 API 可以获取到当前 Node.js 环境使用的实例:

void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
    v8::Isolate* isolate = args.GetIsolate();
    // ...
}

下一个概念是上下文(Context),实例本身并不执行 JavaScript,JavaScript 的执行实际是在上下文里,“它在创建的时候要指明属于哪个实例。这里可以理解为它是一个沙箱化的执行上下文环境,内部预置了一系列的对象和函数。”它的使用场景可以和下面的 Script 结合起来用,但一般在开发 C++ Addon 时不太会用到。

v8::Isolate* isolate = args.GetIsolate();
v8::Context contenxt = v8::Context::New(isolate);

接下来是脚本(Script),“顾名思义,就是一个包含一段已经编译好的JavaScript脚本的对象,数据类型就是Script。它在编译时就与一个处于活动状态的上下文进行绑定。” 它可以和上面的上下文结合起来实现在 C++ Addon 里执行一段 JS 代码,虽然一般不会这么用,以下是一个简单的示例:

void Method(const FunctionCallbackInfo<Value> &args) {
    Isolate *isolate = args.GetIsolate();
    Local<Context> context = Context::New(isolate);
    Local<String> source = String::NewFromUtf8(isolate,  "new Date()");
    Local<Script> script = Script::Compile(context, source).ToLocalChecked();
    Local<Value> result = script->Run(context).ToLocalChecked();

    args.GetReturnValue().Set(result);
}

这里其实核心的逻辑是把 new Date() 这个JavaScript 片段的执行结果返回给 JS 侧,调用起来的效果就是:

dickeylth➜dev/node-playground/addon-test» node ./index.js                                                                             [11:47:00]
2021-04-22T03:47:02.013Z

接下来的一个概念叫句柄(Handle),说白了就是数据对象的引用,在前面的示例代码里我们已经见过它了,其实就是那些 Local<T> 的类型定义。它跟指针的根本区别,就是一个更智能的指针,在 V8 执行内存垃圾回收移动对象的时候,“垃圾回收器会更新引用了这个数据块的那些句柄,让其断不了联系”,“当一个对象不再被句柄引用时,那么它将很不幸地被认为是垃圾。Chrome V8的垃圾回收机制会不时地对其进行回收。所以,句柄的引用对于Chrome V8的垃圾回收是很关键的。”

话说回来,句柄在Chrome V8中只是一个统称,它其实还分为多种类型: · 本地句柄(v8::Local); · 持久句柄(v8::Persistent); · 永生句柄(v8::Eternal); · 待实本地句柄(v8::MaybeLocal); · 其他句柄。

其中,本地句柄和持久句柄是在 Chrome V8 以及 Node.js 中最常用到的句柄,而永生句柄和其他类型的一些句柄比较罕见。

句柄存在的形式是C++的一个模板类,其需要根据不同的Chrome V8数据类型进行不同的声明。例如: · v8::Local:本地JavaScript数值类型句柄。 · v8::Persistent:持久JavaScript字符串类型句柄。

这些句柄类都能通过.方法名来访问句柄对象的一些方法。而它还重载了和->两个操作符。通过操作符能得到这个句柄所引用的JavaScript数据对象实体指针,->也一样。

假设我们有一个字符串本地句柄Local str,那么就可以有这样的一些调用:

· str.IsEmpty():句柄对象本身的函数,判断这个句柄是否是空句柄。 · str->Length():通过->得到String*,而String有一个方法Length可获取字符串长度,所以str->Length()是这个句柄所指的字符串实体的长度。

简单看一下本地句柄。“本地句柄存在于栈内存中,并在对应的析构函数被调用时被删除。”“通常情况下,一个句柄作用域(HandleScopt)对象会在一个函数体内的一开始被声明。当一个句柄作用域对象被删除的时候,之前在这个句柄作用域内创建的那些句柄所指的对象如果没有别的地方有引用的话,就可以被垃圾回收器自由释放了。而根据C++的对象栈生命周期,这个句柄作用域对象会在函数结束的时候被删除。”

大多数时候,我们会通过 Chrome V8 中的 JavaScript 数据类的一些静态方法来获得一个本地句柄,而不是使用本地句柄本身的 New 方法。比如上面的 Local<String> source = String::NewFromUtf8(isolate, "new Date()"); 就是通过 v8::String 的静态方法,传入一个 Isolate 实例以及一个 C++ 的 string 类型数据,V8 就会创建一个 JavaScript 中的 String 数据,然后返回一个指向内存中引用该数据的本地句柄。此外还有清除、判空、类型转换等几个 API,暂时先不赘述。

“持久句柄提供了一个堆内存中声明的 JavaScript 对象的引用。持久句柄与本地句柄在生命周期上的管理是两种不同的方式。”“举一个简单的例子,Google Chrome 中的 DOM 节点们在 Chrome V8 中就是以持久句柄的形式存在的——它们不局限于某个函数的作用域中。”等用到的时候我们再看看。永生句柄在 Addon 开发几乎不会用到,先忽略。

“待实本地句柄,这意味着待落实它到底是不是一个有效的本地句柄。”“用一句话总结就是,那些在以往有可能返回空句柄的接口,现在都会以待实本地句柄的形式来代替返回值了。如果开发者需要拿到真正的本地句柄,就需要调用这个待实本地句柄的ToLocalChecked函数。”

再说说上面提到的句柄作用域,“句柄作用域实际上是一个维护一堆句柄的容器。当一个句柄作用域对象的析构函数被调用时,在这个作用域中创建的所有句柄都会被从栈中抹去。于是,通常情况下这些句柄所指的对象将会失去所有引用,然后会被垃圾回收器统一处理。”“在代码中,句柄作用域以 HandleScope 或者 EscapableHandleScope 的形式存在于栈内存中”。“作用域是一个套一个地以栈的形式存在的。在栈顶的句柄作用域处于激活状态。每次创建新的被管理对象的时候,都会将对象交付给栈顶的作用域管理,当栈顶作用域生命周期结束时,这段时间创建的对象就会被回收。”不过一般我们不太需要显式创建句柄作用域,“在Node.js进行C++扩展调用时,会事先为其创建一个句柄作用域。所以开发者要按需决定是否需要再自行创建句柄作用域来管理下级的一些句柄。”

最后看一张官方的图整体再重新 review 下上面提到的概念:
image.png

① HandleScope handle_scope(isolate);:创建一个句柄作用域,根据C++的特性,在它所处的作用域结束时,其生命周期也就结束了,这个时候程序会自动调用它的构造函数来做一些事情(这种根据栈内存中对象生命周期特性来做一些事的思想还有ScopeLock[插图]等)。 ② Local context=Context::New(isolate);:创建一个Context对象,并得到它的本地句柄——这个句柄存在于handle_scope的句柄栈中。也就是被这个句柄作用域对象所管理,以及它的真实对象存在于堆内存中,被垃圾回收器盯着。 ③ Persistent persistent_context(isolate,context);:基于context我们创建一个新的持久句柄和Context对象,它脱离了句柄作用域的掌控,直接受命于Chrome V8的垃圾回收器(这段代码在这里实际上并没什么用,加上这句话主要用于讲解持久句柄)。 ④ Context::Scope context_scope(context);:不进行讲解。 ⑤ Local source=…:可参照第二步解读,创建String对象并得到本地句柄。 ⑥ Local