NodeJS C++ Addons基础

  NodeJS C++插件是一种动态链接库,采用C/C++语言编写,可以通过require()将插件加载进NodeJS中进行使用。利用V8提供的API,可以实现JavaScript和C++的互相调用,打通JavaScript和C++之间的接口。在做一些高性能或者底层模块的时候,需要用到一些C++库,NodeJS C++插件可以帮助我们封装这些C++库的接口,使得JavaScript具备调用C++库的能力。本文将记录利用基础的V8 API编写NodeJS C++插件的过程,实现C++和JavaScript之间的参数传递、函数调用以及回调、异常处理以及对象函数传递等功能。记录过程中也会对部分概念和API进行阐述。

本文所使用的代码示例可以从该仓库中找到–【cpp-addons】

  备注: 本文旨在探究NodeJS C++ Addons的原生写法,了解部分底层知识,所使用的NodeJS版本为8.11.1,由于V8原生的API会发生变动,不同版本的NodeJS的支持情况可能不同。因此不保证代码能兼容所有版本的NodeJS。

一、基本概念

  • 1.1、hello world示例
      首先通过一个简单的HelloWorld示例来了解编写C++插件的基本写法和一些API的基本概念。在示例中,C++模块向JavaScript暴露了一个hello接口,在JavaScript中调用该接口后会得到返回值hello world。
#include <node.h>

namespace HelloWorldDemo {
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::Object;
  using v8::String;
  using v8::Value;

  void hello (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 通过 FunctionCallbackInfo<Value>& args 可以设置返回值 */
    args.GetReturnValue().Set(String::NewFromUtf8(isolate, "hello world."));
  }

  void init (Local<Object> exports) {
    /* 设置模块的导出方法 hello */
    /* 等价于 js 模块中的 module.exports.hello = hello */
    NODE_SET_METHOD(exports, "hello", hello);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

  JavaScript调用C++模块的方法时,会传递一个V8对象,类型为FunctionCallbackInfo。通过这个V8对象,JavaScript可以向C++接口传递参数,C++函数也可以通过这个对象来向JavaScript回传信息,即设置返回值。在C++接口中,通过参数const FunctionCallbackInfo& args可以拿到一个Isolate对象,Isolate代表一个V8虚拟机实例。通过args.GetIsolate()可以获取到运行JavaScript调用者的V8虚拟机实例。这个V8实例包含了内存堆,在C++接口中创建V8提供的JavaScript对象类型实例的时候会使用到。例如前面的hello world例子中,在创建一个JS字符串的时候需要传递isolate对象,表示在该V8虚拟机上创建了一个JS字符串对象,之后该字符串便可以被V8虚拟机上运行的JS调用者所使用。

  Local是一个模板句柄类,Local代表指向某种类型的句柄。例如模块的exports属性是一个JavaScript对象,句柄类型为Local。传递给init函数的参数其实是指向相应对象的句柄。

  NODE_MODULE是一个宏,设置模块初始化函数为init。init函数中执行模块的初始化,当模块第一次被加载进NodeJS应用中的时候就会执行init函数,init函数中可以设置exports属性将C++接口暴露出去给JavaScript使用。NODE_SET_METHOD用于设置属性或方法,第二个参数为属性名,第三个参数为方法对应的属性值。如果需要给exports对象设置多个属性或方法,可以调用多次NODE_SET_METHOD。exports对象上设置的属性方法将会作为接口暴露给外部使用。

  编写NodeJS C++插件必须遵循以下这种模式:必须有一个初始化函数对模块进行初始化(设置方法属性等),然后加上NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)设置模块名和初始化函数。初始化函数可以有两种写法,第一种写法常用于设置模块的exports对象上的某个属性或方法,第二种写法可用于直接重写整个exports对象。

// 写法1
void Initialize_1(Local<Object> exports) {
  // 进行初始化...
  // example
  // 等价于js模块中的 module.exports.hello = hello
  NODE_SET_METHOD(exports, "hello", hello);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize_1)

// 写法2
void Initialize_2(Local<Object> exports, Local<Object> module) {
  // 进行初始化...
  // example
  // 等价于js模块中的 module.exports = hello
  NODE_SET_METHOD(module, "exports", hello);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize_2)


  • 1.2、构建方法

  编写完C++代码后需要将其编译构建成node文件才能够被NodeJS使用。利用node-gyp可以很方便地进行构建。首先在C++代码文件的根目录下创建一个binding.gyp文件,在文件中写入类似下面的JSON格式的内容。如果有多个插件,可以在targets数组上继续添加。数组的元素为一个对象,对象的target_name属性指明构建后的插件名称,sources属性则是C++源码路径。sources属性是一个数组,如果插件对应的源码文件有多个,只需在数组中列举出对应的多个路径即可。例如下面的示例,C++源码hello.cc经过构建之后,会生成名为addon.node的插件。

{
  "targets": [{
    "target_name": "addon",
    "sources": ["hello.cc"]
  }]
}

  写好binding.gyp以后,只需要执行以下2个命令即可完成构建,两句命令可以分开执行,也可以合并成一句。configure指令执行后会在当前目录下生成build文件夹,并在build文件夹中生成相应平台的构建文件(例如Linux下的Makefile)。build执行后则是编译源码生成拓展名为node的NodeJS插件,插件会输出到./build/Release/或./build/Debug/文件夹下。

node-gyp configure
node-gyp build

合并成一句

node-gyp configure build

  构建完成后,在JS中直接通过require便可引用到该插件,然后便可在JS中直接调用插件上设置的接口。.node文件实际上是一个动态链接库,使用require时可以省略掉拓展名.node,NodeJS会自动去加载这个动态链接库。不过这里有个要点需要注意,使用require引用模块的时候,会优先寻找拓展名为.js的模块,所以,如果相同路径下同时存在.node和.js文件,require引用到的其实是.js文件。例如:如果./build/Release/文件夹下同时存在addon.js和addon.node,这时下面这行代码执行后将加载到addon.js。

const addon = require('./build/Release/addon')
1
  • 1.3、运行结果
通过下面的binding.gyp将前面的hello world示例代码进行编译构建,生成hello_world.node文件。

{
  "targets": [{
    "target_name": "hello_world",
    "sources": ["hello_world.cc"]
  }]
}

接下来用以下代码对C++插件进行使用。可以看到成功实现了在JavaScript中调用C++接口,接口执行后返回字符串hello world。

// hello-world demo
const HelloWorld = require('./build/Release/hello_world')
console.log('[HelloWorldDemo] ' + HelloWorld.hello())

hello world示例

二、函数传参与回调

  在调用C++接口的时候,经常需要向接口传递参数。前面提到,JavaScript调用C++接口的时候会向其传递一个类型为FunctionCallbackInfo对象,通过这个对象可以实现向C++接口传递参数,参数可以是普通的JS对象,也可以是函数。传递函数参数时,还可以实现回调,即JavaScript在调用C++接口时向其传递回调函数,C++接口执行后回调JavaScript。

  接下来通过实现一个累加函数探索函数传参和回调的实现。累加函数accumulate的作用是对参数进行累加求和,然后返回求和结果。例如调用accumulate(1, 2, 3)将得到1+2+3的结果,即6。为了同时展示传参和回调,accumulate函数的求和结果不能像前面hello world例子中一样直接通过设置返回值进行返回,而是要通过回调函数进行返回。假设调用accumulate函数时通过最后一个参数传递回调函数,则调用时的写法将变为accumulate(1, 2, 3, callback)。下面是C++模块的代码。

#include <node.h>

namespace FunctionArgumentsAndCallbackDemo {
  using v8::Function;
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::Value;
  using v8::Null;

  void accumulate (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 提取通过参数传递的回调函数 */
    Local<Function> callback = Local<Function>::Cast(args[args.Length() - 1]);

    /* 遍历参数进行求和 */
    double sum = 0.0;
    for (int i = 0; i < args.Length() - 1; ++i) {
      sum += args[i]->NumberValue();
    }

    /* 将求和结果转成一个js Number, 通过回调函数进行返回 */
    Local<Number> num = Number::New(isolate, sum);
    Local<Value> argv[1] = { num };
    callback->Call(Null(isolate), 1, argv);
  }

  void init (Local<Object> exports) {
    NODE_SET_METHOD(exports, "accumulate", accumulate);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

  上述代码中通过args可以获得从JavaScript传递过来的参数,这些参数都是JavaScript对象实例,使用时需要根据情况调用相关方法,例如求和的参数是多个数字,在JavaScript中数字都是Number对象实例,需要调用NumberValue()方法后才能获取到对应的double类型数值。通过Local::Cast()获取到回调函数的句柄,获取句柄后可以通过Call()方法对回调函数进行调用,调用时需要传递参数个数和参数实例(参数实例需要以数组形式进行传递)。通过这种方式实现的函数回调为同步回调,不是异步回调,异步回调的实现需要用到其他技巧,不在本文的讨论范围之内。

  给binding.gyp中的targets数组添加以下元素,然后继续使用node-gyp configure build进行编译构建生成.node文件。

{
  "target_name": "accumulate",
  "sources": ["accumulate.cc"]
}

  通过以下JavaScript代码即可使用C++插件,并验证接口是否调用成功。代码输出如果所示,accumulate接口成功对参数进行求和并通过回调函数返回了求和结果。

// function-arguments-and-callbacks demo
const Accumulate = require('./build/Release/accumulate')
Accumulate.accumulate(1, 3, 4, 7, (sum) => {
  console.log('[FunctionArgumentsAndCallbacksDemo] 1 + 3 + 4 + 7 = ' + sum)
})

函数传参与回调示例

三、异常处理

  C++插件提供的接口函数如果在运行后遇到异常,JavaScript调用者是否可以知道异常并进行处理呢?答案是可以的,V8提供的API使得C++可以直接向JavaScript抛出异常。前面提到,Isolate对象代表一个V8虚拟机实例。我们可以通过这个实例直接向该V8虚拟机抛出异常,该虚拟机实例上运行的JavaScript代码只要对异常进行捕获就可以知道异常的发生并进行相应的处理了。

  上面的累加求和例子中,没有考虑传递的参数类型就直接进行求和,在某些情况下可能发生异常。接下来,对上面的例子进行改进,增加异常处理机制,探索C++插件如何向JavaScript报告异常。代码如下所示。

#include <node.h>

namespace FunctionArgumentsAndCallbackDemo {
  using v8::Function;
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::Value;
  using v8::Null;
  using v8::Exception;
  using v8::String;

  void accumulate (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 参数不合理异常 */
    if (args.Length() < 1) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "Arguments Number Error.")
      ));
      return;
    }

    /* 没有回调函数 */
    if (!args[args.Length() - 1]->IsFunction()) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "No Callback Error.")
      ));
      return;
    }

    /* 提取通过参数传递的回调函数 */
    Local<Function> callback = Local<Function>::Cast(args[args.Length() - 1]);

    /* 遍历参数进行求和 */
    double sum = 0.0;
    for (int i = 0; i < args.Length() - 1; ++i) {
      /* 如果参数不是数字,向js抛出异常 */
      if (!args[i]->IsNumber()) {
        isolate->ThrowException(Exception::TypeError(
          String::NewFromUtf8(isolate, "Arguments Type Error.")
        ));
        return;
      } else {
        sum += args[i]->NumberValue();
      }
    }

    /* 将求和结果转成一个js Number, 通过回调函数进行返回 */
    Local<Number> num = Number::New(isolate, sum);
    Local<Value> argv[1] = { num };
    callback->Call(Null(isolate), 1, argv);
  }

  void init (Local<Object> exports) {
    NODE_SET_METHOD(exports, "accumulate", accumulate);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

  通过Isolate对象的ThrowException方法,可以直接向JavaScript抛出异常。在JavaScript中,通过try/catch机制便可以捕获和处理异常。下面是代码示例,调用C++接口的时候故意引发异常,捕获到异常后将异常信息进行输出。如果图所示,可以成功实现C++模块向JavaScript抛出异常以及JavaScript捕获处理异常。

// exception demo
try {
  Accumulate.accumulate()
} catch (err) {
  console.log('[ExceptionDemo] ' + err)
}

try {
  Accumulate.accumulate(1, 2, 3)
} catch (err) {
  console.log('[ExceptionDemo] ' + err)
}

try {
  Accumulate.accumulate(1, 2, 'a', (sum) => {
    console.log(sum)
  })
} catch (err) {
  console.log('[ExceptionDemo] ' + err)
}

四、返回对象和函数

  前面的例子中,JavaScript可以通过args向C++传递JS对象和函数给C++模块使用,如果反过来让C++传递对象和函数给JavaScript使用,是否可以实现呢?答案是可以的。前面的例子展示了C++模块可以通过args.GetReturnValue().Set(xxx)来向JavaScript返回内容,这里返回的内容除了向前面例子中的普通String、Number等对象之外,也可以返回Object或者Function。C++模块中,可以将需要的信息封装成Object然后返回给JavaScript使用,可以将部分C++函数包装成Function给JavaScript调用。

  接下来通过一个简单例子来探索传递对象和函数的做法。在这个例子中,C++模块向JavaScript提供了getPerson()和getFunction()两个方法,getPerson()方法会返回一个Object给JavaScript,在JavaScript中调用此方法获得对象后可以直接使用该对象,访问对象属性获取信息(信息包括firstname和lastname)。getFunction()方法会将一个C++函数sayHiTo()包装成一个JS函数,返回一个Function给JavaScript,在JavaScript中调用此方法获取函数对象后,可以直接对返回的函数进行调用。sayHiTo()是一个C++函数,接收参数为一个Object,返回一句打招呼的信息(”Hi, xxxx”)。

#include <node.h>

namespace ReturnObjectAndFuntionDemo {
  using v8::Function;
  using v8::FunctionTemplate;
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Object;
  using v8::String;
  using v8::Value;
  using v8::Local;

  void getPerson (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 创建对象 */
    Local<Object> obj = Object::New(isolate);
    /* 设置对象属性,Set(key, value) */
    obj->Set(
      String::NewFromUtf8(isolate, "firstname"),
      String::NewFromUtf8(isolate, "Java")
    );
    obj->Set(
      String::NewFromUtf8(isolate, "lastname"),
      String::NewFromUtf8(isolate, "Script")
    );
    /* 将对象返回给JavaScript */
    args.GetReturnValue().Set(obj);
  }

  void sayHiTo (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 提取通过参数传递的JS对象 */
    Local<Object> person = Local<Object>::Cast(args[0]);
    /* 提取对象属性值并拼接字符串 */
    Local<String> fullname = String::Concat(
      person->Get(String::NewFromUtf8(isolate, "firstname"))->ToString(),
      person->Get(String::NewFromUtf8(isolate, "lastname"))->ToString()
    );
    /* 将结果返回给JavaScript */
    args.GetReturnValue().Set(String::Concat(
      String::NewFromUtf8(isolate, "Hi, "),
      fullname
    ));
  }

  void getFunction (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 利用函数模板构造一个JavaScript函数 */
    Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, sayHiTo);
    Local<Function> fn = tpl->GetFunction();
    fn->SetName(String::NewFromUtf8(isolate, "sayHiTo"));
    /* 将函数返回给JavaScript */
    args.GetReturnValue().Set(fn);
  }

  void init (Local<Object> exports) {
    NODE_SET_METHOD(exports, "getPerson", getPerson);
    NODE_SET_METHOD(exports, "getFunction", getFunction);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

  返回对象的做法比较简单,直接创建一个Object,然后通过Set()方法设置属性和属性值,把需要返回的信息进行包装,最后通过args.GetReturnValue().Set()方法便可以直接将包装好的对象返回给JavaScript。

  返回函数的做法也是类似,创建一个Function,然后通过args.GetReturnValue().Set()方法将函数返回给JavaScript。创建函数的时候,需要用到FunctionTemplate,首先根据要包装的C++函数(sayHiTo)创建函数模板,然后调用模板的GetFunction()方法便可获得一个函数句柄,设置完函数名后便可将其返回给JavaScript使用。这里需要注意,sayHiTo是一个C++函数,而且没有通过module.exports属性来提供给外部使用,而是通过返回函数的方式来给外部使用。

  sayHiTo()是一个C++函数,通过函数返回给JavaScript之后将被JavaScript调用,所以它在调用时也会收到参数const FunctionCallbackInfo& args,跟之前通过module.exports暴露给JavaScript调用的方法一样,可以通过参数args来完成跟JavaScript的交互(参数传递、返回等)。

完成以上代码后,给binding.gyp的targets数组增加以下元素,然后进行编译构建生成.node文件。

{
  "target_name": "person",
  "sources": ["person.cc"]
}

完成编译构建之后就可以使用以下JavaScript代码进行实验,输出结果如图所示。

// return-object demo
const Person = require('./build/Release/person')
let someone = Person.getPerson()
console.log('[ReturnObjectDemo] ' + someone.firstname + someone.lastname)

// return-function demo
let sayHiTo = Person.getFunction()
console.log('[ReturnFunctionDemo] ' + sayHiTo(someone))

五、小结

  通过几个例子,大概了解如何编写NodeJS C++插件,也学会了如何处理一些常用场景,例如函数传参、回调、异常处理等。总体感觉编写C++插件实现C++和JavaScript的互相调用并不困难。主要的难点还是对V8的一些概念以及它所提供的API不熟悉,V8提供的很多API可以让我们方便地实现C++和JavaScript的互相调用,但由于对V8 API不够熟悉,编写过程中还是需要不断地查阅NodeJS和V8的官方文档。

猜你喜欢

转载自blog.csdn.net/abcd1f2/article/details/82685893