最近开发涉及到了一些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差异实在有些大。
总结
本文主要描述了这几个方面的示例:
- 工程框架HelloWorld;
- 两种语言间不同类型怎么转换;
- 回调函数和异常处理;
- 如何包裹C++类函数。