最近开发涉及到了一些Node.js调用C++的地方,于是网上搜了一下,发现网上好多文章都是比较片面的东西,没法直接使用。于是花点时间总结一下。
Android开发中Java 调用C++的部分叫JNI,Rust语言中调用C++的部分叫FFI,Node.js中调用C++的部分叫C++ Addons。
本文总结Node.js使用非N-API方式调用C++函数的示例,主要针对node 8版本,不同版本会有api差异。
主要内容有:1. 工程框架HelloWorld; 2. 两种语言间不同类型怎么转换; 3. 回调函数和异常处理;4. 如何包裹C++类函数。

Node.js 调用C++方法,其实是调用 C++ 代码生成的动态库,可以使用require() 函数加载到Node.js中,就像使用普通的Node.js模块一样。

Node.js官方提供了两种调用C++的方法一种是引用v8.h等头文件直接使用相关函数,另一种是使用其包裹的Native Abstractions for Node.js (nan)进行开发。鉴于node.js版本升级实在是太快了(Ubuntu 18.04 apt 最新版是node 8, Ubuntu 20.04 apt 最新版是node 10,官方最新版是node 15),官方推荐使用第二种方法。

但是由于我们现有项目使用的是第一种方法,且使用的是node 8版本,所以这篇文章主要介绍基于node version 8的直接引用v8.h头文件调用C++的方式,可能也会夹杂一些其他版本的说明。不同版本的node.js提供的原生接口函数形式会有一些差异,详细说明可以参考Node.js官方文档,那里示例比较齐全,我也是参考的官方文档整理的。

Hello World

我们码农都知道 HelloWorld 意味着什么,所以这一节主要通过 HelloWorld 来介绍一下这个工作流程。
先说一下工程目录结构,通常把C++代码放在src目录下面,一级目录下有个binding.gyp文件,这个是C++代码的编译脚本,使用node-gyp进行编译,binging.gyp 就是 node-gyp 的编译脚本,准确一些比喻的话 这个 node-gyp 类似 cmake,binding.gyp 类似 CMakeLists.txt,都是先生成 Makefile 再进行编译的。

HelloWorld
   ├── binding.gyp
   ├── index.js
   └── src
       └── hello.cc

C++ 文件

接下来看看hello.cc中的代码

#include <node.h>

using namespace v8;

// 一个能返回JS字符串"Hello World!"的函数
void sayHello(const FunctionCallbackInfo<Value> &args) {
    Isolate *isolate = args.GetIsolate();
    args.GetReturnValue().Set(String::NewFromUtf8(isolate, "Hello World!"));
}

// 和js模块一样,有两种初始化函数
// 导出方式类似  exports.Hello = sayHello;
void Initialize(Local<Object> exports) {
    NODE_SET_METHOD(exports, "Hello", sayHello);
}

// 导出方式类似 module.exports = sayHello;
void Initialize2(Local<Object> exports, Local<Object> module) {
    NODE_SET_METHOD(module, "exports", sayHello);
}

// 注意:
// NODE_MODULE()后面没有分号,因为它不是一个函数
// 官方文档说模块名称(这里是hello)必须与最终二进制文件的文件名匹配(不包括.node后缀),不过不匹配好像也行
NODE_MODULE(hello, Initialize)  // 这里我们使用第一种导出方法进行注册

编译脚本 binding.gyp

然后看看编译脚本 binding.gyp 的内容,这是一个JSON结构的文本,我们示例的模块名为hello,sources 后面是C++源码。

{
    'targets': [
        {
            'target_name': 'hello',
            'sources': [ 
                'src/hello.cc',
            ]
        }
    ]
}

编译C++

刚才说了要是用node-gyp命令进行编译,注意这个node-gyp要和node版本一致,所以要使用的npm install -g node-gyp进行安装。使用node-gyp configure生成Makefile,再使用node-gyp build进行编译。也可以一步到位node-gyp configure build

node-gyp configure
node-gyp build
或
node-gyp configure build

一切OK的话会生成build\Release\hello.node文件,这个node文件其实就是动态库,linux下是so,windows下是dll。node.js v8引擎会使用dlopen的方式加载这个动态库。工程目录如下所示

HelloWorld
├── binding.gyp
├── build
│   ├── binding.Makefile
│   ├── config.gypi
│   ├── hello.target.mk
│   ├── Makefile
│   └── Release
│       ├── hello.node
│       └── obj.target
│           ├── hello
│           │   └── src
│           │       └── hello.o
│           └── hello.node
├── index.js
└── src
    └── hello.cc

node.js 调用

最后可以像调用普通js模块一样引用这个库了。

const hello = require('./build/Release/hello.node');

console.log(hello.Hello()); // 输出:Hello World!

// for Initialize2 第二种导出方式可以这么调用
// console.log(hello()); // Hello World!

到此为止已经对整个工作流程有了个大致的认识,接下来无非就是类型转换等 API 的使用了。

基本类型转换

前面的 HelloWorld 已经介绍了怎么调用 C++ 函数,接下来就是两种语言间不同类型的转换,类型转换分为两种,一种是JS类型转为C++类型,另一种是C++类型转为JS类型,下面通过示例来看看。(主要说明node 8版本,其他版本编译出错的话,自行查阅官方文档,不同版本之间大同小异)

整型和浮点型

整型主要有 int32 uint32 int64,浮点型主要有double。示例都只有一个参数,返回类型和输出类型一致。
For node version 8

void passInt32(const FunctionCallbackInfo<Value> &args){
    int value = args[0]->Int32Value();  // 输入参数转换为 int32 类型
    args.GetReturnValue().Set(value);   // 直接调用Set可以返回int类型
}

void passUInt32(const FunctionCallbackInfo<Value> &args){
    uint32_t value = args[0]->Uint32Value();  // 输入参数转换为 uint32 类型
    args.GetReturnValue().Set(value);
}

void passInt64(const FunctionCallbackInfo<Value> &args){
    int64_t value = args[0]->IntegerValue(); // 输入参数转换为 int64 类型
    args.GetReturnValue().Set(args[0]);  // 在v8版本里面没找到怎么返回int64类型的函数
}

void passDouble(const FunctionCallbackInfo<Value> &args){
    double value = args[0]->NumberValue();   // 输入参数转换为 double 类型
    args.GetReturnValue().Set(value);	// 可以直接返回double类型
}

下面是js调用的示例(注册函数省略了),输出数字和输入参数一样。

const mylib = require('./build/Release/mylib.node');
console.log(mylib.passInt32(-1));
console.log(mylib.passUInt32(4294967295));
console.log(mylib.passInt64(-1));
console.log(mylib.passDouble(-1.23));

布尔类型

For node version 8

void passBool(const FunctionCallbackInfo<Value> &args){
    bool value = args[0]->BooleanValue();  // 获取输入布尔类型
    args.GetReturnValue().Set(value);  // 可以直接返回bool类型
}

JS调用方法与前面的一样也没啥好说的。

const mylib = require('./build/Release/mylib.node');
console.log(mylib.passBool(false));

字符串类型

字符串类型与数值类型稍有不同。从现在开始会频繁使用到一个Isolate 类型,这个东东可以认为是v8引擎的一个沙盒,不同线程可以有多个Isolate ,一个Isolate同时只能由一个线程访问。
For node version 8

void passString(const FunctionCallbackInfo<Value> &args) {
    // 获取环境运行的沙盒isolate
    Isolate *isolate = args.GetIsolate(); 
    // 参数 args[0] 本质上是一个 v8::Value 类型,
    // 先把这个 Value转换为一个UTF8编码的字符串数组Utf8Value 类型
    // Utf8Value是一个封装`char* str_; int length_;`的类型,通过星号运算符重载返回str_
    // 然后就可以把这个类型构造成std::string类型了。
    std::string value = std::string(*String::Utf8Value(isolate, args[0]));
    // 从C++字符串转为js字符串用到了String::NewFromUtf8()函数,传入C风格字符
    args.GetReturnValue().Set(String::NewFromUtf8(isolate, value.c_str()));
}

JS调用同上。

const mylib = require('./build/Release/mylib.node');
console.log(mylib.passString('Hello'));

回调函数和异常处理

目前为止已经知道如何在JS和C++之间传递不同的基本参数类型了。回调函数是JS语言的一大特色,异常处理是现代编程语言都具备的一种语法。下面通过一个计算斐波拉契数列值的函数来看看这两种语法,此函数大概就是这样let f = (n, callback) => { callback(f(n)); }

For node version 8

#include <node.h>

using namespace v8;

// 这是一个计算斐波拉契数列的C函数
int f(int n) {
    return (n < 3) ? 1 : f(n - 1) + f(n - 2);
}

// 注册这个函数给JS调用
void Fibonacci_Callback(const FunctionCallbackInfo<Value> &args) {
    Isolate *isolate = args.GetIsolate();

    //检查参数个数
    if (args.Length() != 2) {
    	// 使用 String::NewFromUtf8() 构造一个JS字符串
    	// 使用 Exception::TypeError() 构造一个异常类型
    	// 使用 isolate->ThrowException 向JS抛出一个异常
        isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong number of arguments")));
        return;
    }

    // 保证参数1是个整数,参数2是个回调函数,否则抛出异常
    if (!args[0]->IsInt32() || !args[1]->IsFunction()) {
        isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong arguments")));
        return;
    }
	// 获得整数参数
    int n = args[0]->Int32Value();
    int res = f(n); // 计算斐波拉契数值

	// 把参数2转换为一个函数类型
    Local<Function> cb = Local<Function>::Cast(args[1]);
    // 构造这个回调函数的参数,参数个数argc为1,参数数组argv中存储的是实际Value参数的值
    // 如果有多个参数就塞多个值在数组中
    const unsigned argc = 1;
    Local<Value> argv[argc] = { Number::New(isolate, res) };
	
	// 调用回调函数
    cb->Call(Null(isolate), argc, argv);
}

下面来看看JS中调用方法

const mylib = require('./build/Release/mylib.node');
// 正常输出 f(10) = 55
mylib.Fibonacci_Callback(10, (result) => {
    console.log('f(10) =', result); // f(10) = 55
});

// 看一下异常
mylib.Fibonacci_Callback((result) => {
    console.log('f(10) =', result); // f(10) = 55
});

第二个函数参数不正确,运行后会抛出异常

mylib.Fibonacci_Callback((result) => {
      ^
TypeError: Wrong number of arguments
    at Object.<anonymous> (/home/xxxx/Node.js-Cpp-Addons/CommonFunctions/index.js:11:7)
    at Module._compile (module.js:653:30)
    at Object.Module._extensions..js (module.js:664:10)
    at Module.load (module.js:566:32)
    at tryModuleLoad (module.js:506:12)
    at Function.Module._load (module.js:498:3)
    at Function.Module.runMain (module.js:694:10)
    at startup (bootstrap_node.js:204:16)
    at bootstrap_node.js:625:3

调用 C++ 类方法

下面通过一个例子来展示怎么调用C++类中的方法。例子是这样的,有个C++类Clazz表示一个课堂吧,有个Add方法往里面添加学生,有个AllMembers方法返回这个课堂中有哪些人的字符串,这个例子有点呆,反正就是个类就是个集合。
这个例子目录结构是这样的。

├── binding.gyp
├── index.js
└── src
    ├── addon.cc
    ├── Clazz.cc
    └── Clazz.h

addon.cc 很简单,主要的代码都在Clazz类里面了。

#include <node.h>
#include "Clazz.h"

using namespace v8;

void InitAll(v8::Local<v8::Object> exports) {
    Clazz::Init(exports);
}

NODE_MODULE(hello, InitAll)

Clazz.h 里面声明了内部函数和一些包裹的供JS调用的函数。

#ifndef CLAZZ_H_
#define CLAZZ_H_

#include <node.h>
#include <node_object_wrap.h>

#include <set>
#include <string>

class Clazz : public node::ObjectWrap   // 要继承这个类
{
  public:
    static void Init(v8::Local<v8::Object> exports);

  private:
    static void New(const v8::FunctionCallbackInfo<v8::Value> &args);

    // 对C++成员函数就行包裹的对外函数
    static void Add(const v8::FunctionCallbackInfo<v8::Value> &args);
    static void AllMembers(const v8::FunctionCallbackInfo<v8::Value> &args);

    explicit Clazz(std::string className);
    ~Clazz();

    //C++成员函数,添加和显示成员的实际函数
    void _Add(std::string member);
    std::string _AllMembers();

    static v8::Persistent<v8::Function> constructor;

    std::set<std::string> _members;
    std::string _className;
};

#endif  // CLAZZ_H_

Clazz.cc

#include "Clazz.h"
#include <sstream>

v8::Persistent<v8::Function> Clazz::constructor;

void Clazz::Init(v8::Local<v8::Object> exports) {
    v8::Isolate *isolate = exports->GetIsolate();

    //准备构造函数(New函数里面实现构造)
    v8::Local<v8::FunctionTemplate> tpl = v8::FunctionTemplate::New(isolate, New);
    tpl->SetClassName(v8::String::NewFromUtf8(isolate, "Clazz"));
    tpl->InstanceTemplate()->SetInternalFieldCount(1);

    //注册类函数
    NODE_SET_PROTOTYPE_METHOD(tpl, "Add", Add);
    NODE_SET_PROTOTYPE_METHOD(tpl, "AllMembers", AllMembers);

	// 
    constructor.Reset(isolate, tpl->GetFunction());
    exports->Set(v8::String::NewFromUtf8(isolate, "Clazz"), tpl->GetFunction());


    // An AtExit hook is a function that is invoked after the Node.js event loop has ended
    // but before the JavaScript VM is terminated and Node.js shuts down.
    // AtExit hooks are registered using the node::AtExit API.
    // 这是个Node运行完毕后执行的回调函数,一般在这里进行释放资源的操作。
    node::AtExit([](void *) { printf("in node::AtExit\n"); }, nullptr);
}

// js调用的构造函数实现
void Clazz::New(const v8::FunctionCallbackInfo<v8::Value> &args) {
    v8::Isolate *isolate = args.GetIsolate();

	// 使用new操作符进行构造
    if (args.IsConstructCall()) {
        // Invoked as constructor: `new MyObject(...)`
        std::string cName =
          args[0]->IsUndefined() ? "Undefined" : std::string(*v8::String::Utf8Value(args[0]->ToString()));
		// new一个Clazz对象,返回给js
        Clazz *obj = new Clazz(cName);
        obj->Wrap(args.This());
        args.GetReturnValue().Set(args.This());  // Return this object
    } else {
        // Invoked as plain function `MyObject(...)`, turn into construct call.
        // js中构造可以不使用new操作符,这样给处理成使用new构造的逻辑
        const int argc = 1;
        v8::Local<v8::Value> argv[argc] = { args[0] };
        v8::Local<v8::Context> context = isolate->GetCurrentContext();
        v8::Local<v8::Function> cons = v8::Local<v8::Function>::New(isolate, constructor);
        // 执行完这句就直接进入到上面 if(args.IsConstructCall()) 语句了
        v8::Local<v8::Object> result =
          cons->NewInstance(context, argc, argv).ToLocalChecked();
        args.GetReturnValue().Set(result);
    }
}

void Clazz::Add(const v8::FunctionCallbackInfo<v8::Value> &args) {
	// 使用Unwrap获得Clazz对象的实际指针
    Clazz *obj = ObjectWrap::Unwrap<Clazz>(args.Holder());
    // 转换js字符串成c++字符串
    std::string mem = std::string(*v8::String::Utf8Value(args[0]->ToString()));
    // 调用实际工作的函数添加成员
    obj->_Add(mem);
    return;
}

void Clazz::AllMembers(const v8::FunctionCallbackInfo<v8::Value> &args) {
    v8::Isolate *isolate = args.GetIsolate();
    // 使用Unwrap获得Clazz对象的实际指针
    Clazz *obj = ObjectWrap::Unwrap<Clazz>(args.Holder());

	// 获取所有成员字符串并返回给js层
    std::string res = obj->_AllMembers();
    args.GetReturnValue().Set(v8::String::NewFromUtf8(isolate, res.c_str()));
}

Clazz::Clazz(std::string className) : _className(className) {}

// Node.js 8版本好像有点问题,没有显示这析构里面的代码。
// 14版本测试是可以显示这行log的,不知道是不是使用不当。
Clazz::~Clazz() {
    printf("~Clazz()\n");
}

void Clazz::_Add(std::string member) {
    _members.insert(member);
}

std::string Clazz::_AllMembers() {
    std::ostringstream os;
    os << "Class " << _className << " members: ";
    int i = 1;
    for (auto m : _members) {
        os << i++ << '.' << m << ' ';
    }
    os << '.';
    return os.str();
}

binding.gyp也很简单

{
    'targets': [
        {
            'target_name': 'mylib',
            'sources': [
                'src/addon.cc',
                'src/Clazz.cc'
            ]
        }
    ]
}

JS index.js调用方法

const mylib = require('./build/Release/mylib.node');

const clazz = new mylib.Clazz("Chinese");
// const clazz = mylib.Clazz("Chinese");  // 可以使用new也可以不使用new进行构造
clazz.Add('Tom');
clazz.Add('Mary');
clazz.Add('Liming');
console.log(clazz.AllMembers()); // 实际输出: Class Math: Liming Mary Tom .

这个例子是根据官方文档的用法随便写的一个小demo,js实际调用的函数其实是C++类成员函数的包裹函数。官方文档介绍的使用就这些,烦人的一点就是各个node不同版本的API差异实在有些大。

总结

本文主要描述了这几个方面的示例:

  1. 工程框架HelloWorld;
  2. 两种语言间不同类型怎么转换;
  3. 回调函数和异常处理;
  4. 如何包裹C++类函数。