如何用C++扩展NodeJS的能力?
文章目录
- 前言
- C++结合NodeJS的魅力
- C++和NodeJS怎么结合
- 通过Addon增强NodeJS
- 环境的准备
- 1. node-gyp
- 2. nan (Native abstraction for NodeJS)
- 编写Addon的C++代码
- JS方法的C++表示
- JS方法的传入参数 v8::Argument
- 进阶
- 进阶1: 输出一个JS包装类型
- 进阶2: 使用多线程异步计算
- 最后
前言
Javascript 是一门强大的语言,看似简单,其实包罗万象。NodeJS 是一个非常有活力的平台,JS社区是GitHub上最有创造力的社区。而C++是一门古老而强大的语言,是最有历史积淀的语言之一。那么两者结合,各自发挥自己的优势,就成了一种顺理成章的选择。
C++结合NodeJS的魅力
NodeJS 在网络,文件,数据库等方面有丰富的支持,可以几分钟就构建起一个小型网站,而C++在性能和内存占用方面有压倒性的优势。因此,在一些网络,数据库等I/O为主的场景时,使用Javascript而在一些局部计算密集任务时使用C++,这样可以同时享受JS带来的开发效率和稳定的好处,也可以享受C++带来的性能提升。
关于C++和JS的性能对比,我写了一个KMP算法的例子,完全一样的代码,C++性能大约是JS的7~8倍
详细的测试代码:C++ ,JS ,计时代码
特定的场景下,由于C++可以做一些更精细的优化,实际可以做到更大的提升。
另外从我曾经做的一些项目的改造来看,内存使用上,使用JS对象存储和使用C++的struct存储同样数据,C++可以省下90%以上的内存。
C++和NodeJS怎么结合
Google的V8引擎是用C++开发的,NodeJS本身大部分基础Addon也是C++开发的,因此C++和NodeJS的结合比想象中容易得很多:
node和V8都提供了完善的C++ API来管理JS原生的类型,对象和模块, 所有的JS对象,函数,都能在V8的API中找到相对应的类型,如果同时了解C++和JS的语法,可以极快上手。
无需定义专门的语言绑定层,例如Java的JNI。这是由于JS的所有函数在V8引擎中,实际上都是同样形式(同样的返回值和参数列表),因此无须JNI那种复杂的方法签名机制, 于是Node就帮我们包办了标准C的输出接口,开发者只要将精力更集中在实际的C++逻辑上就好了。
最重要的,Node改造了Google的GYP(Generate Your Project)作为NodeJS addon的夸平台编译工具node-gyp。GYP本身就是特点就是简单易用,而它基于JSON格式的配置文件,更是对Javascript开发者极度友好。
说了半天,可能有人要骂 No BB, show me the code,程序员不来点实际的怎么行。接下来,介绍一下NodeJS的C++需要准备哪些东西,如何使用node-gyp,以及,如何将一个C++的class做成一个JS的class,变得和js对象的实例那样变得有生命周期,自己的方法等。
通过Addon增强NodeJS
对计算密集任务可以用C++写NodeJS的Addon来提高性能的优势。然而究竟应该怎么做呢?先给出一个addon文件的例子:
-rwxrwxr-x 1 melode 88920 Jun 17 22:32 meshtool.node
可以看到NodeJS的addon就是一个后缀为node的文件,这货实际上应该就是一个动态链接库,通过JS代码
var tool = require('./build/Release/meshtool');
即可加载。然而怎么得到这个东东?待我细细道来。
环境的准备
1. node-gyp
这个是C++ addon的跨平台构建工具,有了它我们才能在各个平台编译出.node文件。可以通过一个简单的配置文件binding.gyp描述编译的内容,如源代码文件,依赖的头文件目录,静态库目录,编译器参数等。然后在主流平台上,它都可以将你的配置转化为一套构建脚本(Makefile或VS工程).
获取它很简单,可以通过npm安装:
npm install -g node-gyp
如果没有安装tnpm,用npm安装也可以,注意,在内网指定阿里内部的目录镜像可以爽到飞起:
npm install -g node-gyp --registry=http://registry.npm.aliyun.com
装完以后就可以使用 node-gyp这个命令了:)
第一次用以前,建议调用如下命令先:
node-gyp install --ensure --disturl=http://npm.taobao.org/dist
上面那个命令是安装node-gyp编译所需的node和v8等依赖库文件。若不运行也会在第一次编译时自动运行。但事先通过指定disturl安装的话,节约了等待时间。
你可以通过运行
node-gyp -v
来检查命令是否成功安装。
到此node-gyp已经安装完毕,可以用来编译项目的addon了。
首先得先准备好binding.gyp, 可以放在你项目的任何目录下,但要注意
- 要和你准备运行node-gyp命令的那个目录一致
- 配置中的涉及到的文件或目录的相对路径要从该目录开始。
我们可以将它放在项目根目录。
配置文件的内容是一个大JSON,可以非常简单:
{
"targets": [
{
"target_name": "meshtool",
"sources": [ “./meshfilereader.cpp”,"./index.cpp"]
}]
}
以上配置指定了一个叫meshtool的编译目标, 该目标需要编译的cpp文件为 index.cpp和meshfilereader.cpp.
接着在项目放binding.gyp的目录运行 node-gyp configure, 该目录下会生成一个build的目录,内含构建所需的规则和makefile.
于是我们再运行node-gyp build, 在运行目录/build/Release下我们便可以找到所需的.node文件了。
2. nan (Native abstraction for NodeJS)
当你开始写Addon不久以后,你便会发现一个令人抓狂的现实:NodeJS 的 0.12.x,0.11.x和0.10.x因为使用的V8版本不同,存在严重的API不兼容的情况。不管你愿不愿意,你发现只能做到令其中一个版本通过编译。你开始苦恼C++不能像JS代码那样,不用改一行代码就同时满足所有NodeJS版本, 直到你发现了nan.
nan 是老外的一个项目(不是"Not A Number"),解决了不同NodeJS版本间API的不兼容问题,同样可以用npm安装:
npm install nan
//or
npm install -g nan
也可以直接配置到package.json中。
安装完后要在binding.gyp中配置好nan的头文件依赖,修改如下:
{
"targets": [
{
"target_name": "meshtool",
"sources": [ “./meshfilereader.cpp”,"./index.cpp"],
"include_dirs" : [
"<!(node -e \"require('nan')\")"
}]
}
之所以include_dirs要这么写是因为这样可以自适应nan模块安装在全局和安装在本地的情况。如果确定安装方式的话,也可以直接写路径。
至此万事俱备,只欠代码。
编写Addon的C++代码
写代码前的预备知识
写Addon之前,建议先要了解一下Google V8的API,至少要了解以下的一些概念:
JS 基本类型 对应的V8原生C++ 原生类型 :
Javascript V8
Number v8::Number, v8::Integer
String v8::String
Array v8::Array
Object v8::Object
Function v8::Function
JS基本类型和V8的原生类型之间实际是等价的,也就是C++层从JS层获取到的JS基本对象和返回JS层的结果,都是以v8的上述的原生类型形式。
JS句柄 v8::Handle 它相当于一个智能指针,所有上面的C++的原生类型都是由Handle来引用的,相当于JS中的那个var 变量,因此不管是从JS层获取到的原生类型对象还是在C++内部构造出的原生类型对象,都是以 v8::Handle 形式给出来的。
v8::Handle分为两种,v8::Local和v8::Persistant, 前者只在当前Scope中有效,后者是代表全局变量。
v8::Local 的Scope由 HandleScope管理,由离最近的HandleScope分配,并随HandleScope生命周期结束而结束。而v8::Persistant的生命周期由自己的New和Dispose方法管理。
生命周期结束的Handle,其指向的对象会随时被垃圾收集回收。
JS方法的C++表示
必须为为全局函数或静态方法,根据V8版本不同固定为如下的形式:
V8 3.11
v8::Handle<v8::Value> AnyMethodName(v8::Argument args)
V8 3.28
void AnyMethodName(v8::Argument args)
JS方法的传入参数 v8::Argument
不管什么样的JS函数,其C++方法的传入参数都是一个v8::Argument对象,这是因为v8::Arugment本身就是一个list, 内含可变数量的实际参数,如果想取第i个传入参数,只需要使用args[i] 即可。另外还可以通过args.This()获取this对象。
了解完概念,我们试着写一个输出一个方法的Addon
和写普通的JS模块一样,Addon的代码需要确定模块的输出,这里就是借助Nan写输出一个叫parseMesh的JS方法的Addon:
NAN_METHOD(ParseMesh)
{
NanScope();
if(args.Length() < 2 || !args[0]->IsString() || !args[1]->IsFunction())
{
return NanThrowError("Bad Arguments");
}
Handle<String> filename = args[0].As<String>();
Handle<Function> callback = args[1].As<Function>();
...
NanReturnUndefined();
}
void init(Handle<Object> exports)
{
NODE_SET_METHOD(exports,"parseMesh",ParseMesh);
}
NODE_MODULE(meshtool, init);
以上代码最后一行定义模块名称meshtool,和加载它的时候调用的初始化方法init.
而初始化方法中则设置了输出的函数名parseMesh, 而实际接受parseMesh调用的C++方法即ParseMesh。
再来看这个ParseMesh方法,由于前面所说,因为v8::Argument的存在,所有JS的函数在C++层的方法参数和返回值都是一致的,所以它可以被一个NAN_METHOD的宏来处理,该宏根据Node版本将方法展开成对应的形式。保证ParseMesh方法可以在初始化中注册为任意版本JS函数parseMesh的的实现。
最后的NanReturnUndefined()表示该方法返回undefined (没有返回值即返回undefined). Nan还有很多其他的Return形式可以使用。
到此我们已经可以用C++ Addon来输出简单的JS函数了,这对于大多数情况已经够用。然而NodeJS还提供了一些更高大上的东西,比如输出一个自定义的JS的类型,或者在C++中使用多线程,并异步执行回调等。
进阶
进阶1: 输出一个JS包装类型
前一篇只提到了如何输出一个JS方法,但有的时候如果我们想输出的是一个C++的对象呢,这种情况在想要包装一个现有的C++库到JS的时候出现的尤其频繁。
如果我们仅有输出C++方法成为JS函数一条路,那也有笨办法,用C++代码表示:
//C++
class Body
{
Body();
void Move();
};
NAN_METHOD(CreateBody)
{
NanScope();
Body* handle = new Body();
NanReturnValue(NanNew<Integer>(reinterpret_cast<int>(handle)));
}
NAN_METHOD(BodyMove)
{
//check arguments
Body* handle = reinterpret_cast<Body*>(args[0].As<Integer>()->intValue());
handle->Move();
}
NAN_METHOD(DestroyBody)
{
//check arguments
Body* handle = reinterpret_cast<Body*>(args[0].As<Integer>()->intValue());
delete handle;
}
void init(Handle<Object> exports) {
NODE_SET_METHOD(exports,"createBody",CreateBody);
NODE_SET_METHOD(exports,"bodyMove",BodyMove);
NODE_SET_METHOD(exports,"destroyBody", DestroyBody);
}
NODE_MODULE(native_body, init)
相应的使用native addon的JS代码:
var native=require("native_body");
var handle = native.createBody();
native.bodyMove(handle);
native.bodyDestroy(handle);
其实就是将一个Body对象的指针作为JS的一个int变量让JS层持有,每当要操作该对象时,重新将该指针传回。
但是这样的实现有很多缺点:
- 所有的JS方法需要传入一个额外的由CreateBody得到的handle。
- 用完必须显式调用BodyDestroy, 否则会内存泄露。
- 不安全,如果黑客通过外部传入特定地址的handle, 内部也会将它当做Body指针而执行对应方法,轻则程序崩溃,重则程序行为被控制。(不过这个问题可以通过向外部提供‘间接’地址解决,不展开了)
NodeJS对这种需求提供了比较完美的解决方案- ObjectWrap , 通过自定义C++ class继承ObjectWrap,NodeJS可以输出和自定义JS类型等价的对象。上面的代码可以改成这样:
class BodyWrap : public ObjectWrap
{
Body* internalBody_;
public:
BodyWrap():
internalBody_(new Body())
{
}
~BodyWrap()
{
delete internalBody_;
}
static NAN_METHOD(New){
NanScope();
// arg check is omitted for brevity
BodyWrap *jsBody = new BodyWrap();
jsBody->Wrap(args.This());
NanReturnValue(args.This());
}
static NAN_METHOD(Move)
{
//check arguments
BodyWrap* thisObj = ObjectWrap::Unwrap<BodyWrap>(args.This());
thisObj->internalBody_->Move();
}
};
void init(Handle<Object> exports) {
NanScope();
Local<FunctionTemplate> t = NanNew<FunctionTemplate>(BodyWrap::New);
t->InstanceTemplate()->SetInternalFieldCount(1);
t->SetClassName(NanNew<String>("Body"));
NODE_SET_PROTOTYPE_METHOD(t, "move", BodyWrap::Move);
exports->Set(NanNew<String>("Body"), t->GetFunction());
}
NODE_MODULE(native_body, init)
相应的JS代码:
var Body = require("native_body").Body;
var b = new Body();
b.move();
从JS代码可以看到已经不存在什么handle了,需要Body实例的时候可以直接new 出来,在该实例上调用方法,对应的C++ Body类型的方法就会执行,这和普通的JS自定义class完全没什么区别。
另外还可以注意到一点,JS代码中没有执行任何类似于DestroyBody的方法。那C++的Body实例何时释放呢?-- 在上面这个代码中,当new出来的JS实例 b被垃圾回收时,C++ Body实例会被自然的析构。
进阶2: 使用多线程异步计算
通常使用C++ Addon的场景,都是计算密集的任务,另外从前面的一些实例代码可以看出,C++到JS之间的数据传递中,是有很多装箱/拆箱的消耗的(如从v8::Number 到double),因此我们为了避免这种损耗,通常希望在C++中做尽量多的事情,而不希望将任务过度切分,因此如果全部在主线程执行,无可避免的会对主线程造成阻塞。解决方案则是将主要的计算任务,放在另一个线程中执行,再将结果数据在主线程中通过回调交回给JS。
假设前面的Body对象多了一个计算量很高的 checkCollision方法检查是否与其他物体碰撞,并返回布尔值。如何将checkCollision放到其他线程,再将结果返回主线程呢?我们需要用到另一个NodeJS的基础库:libuv.
下面这个示例演示了如何创建一个线程来运行checkCollision,并在线程中使用uv_aync_send方法将结果带回到主线程回调给JS层。
class Body
{
...
bool checkCollision();
};
struct BodyContext
{
Body* body;
uv_async_t async;
bool result;
NanCallback *callback;
uv_thread_t tid;
};
void AfterCheckCollision(uv_async_t *async)
{
BodyContext* ctx = async->data;
Handle<Value> argv[] = {NanNew<Boolean>(ctx->result)};
ctx->callback->Call(1,argv);
uv_thread_t tid = ctx->tid;
uv_thread_join(&tid);
delete ctx->callback;
delete ctx;
}
void RunCheckCollision(BodyContext* ctx)
{
ctx->result = ctx->body->CheckCollision();
uv_async_init(uv_default_loop(), &ctx->async,AfterCheckCollision);
ctx->async.data = ctx;
uv_async_send(&ctx->async);
}
class BodyWrap: public ObjectWrap
{
...
static NAN_METHOD(CheckCollision)
{
//这里假设外部的this--也就是JS body对象是一直有引用持有的。否则要使用v8:Persistant进行保持不在异步执行过程中被GC.
BodyWrap* thisObj = ObjectWrap::Unwrap<BodyWrap>(args.This());
Handle<Function> cb = args[0].As<Function>();
NanCallback *callback = new NanCallback(cb);
BodyContext *ctx = new BodyContext();
ctx->body = thisObj->internalBody_;
ctx->callback = callback;
uv_thread_t tid;
uv_thread_create(&tid,RunCheckCollision,ctx);
ctx->tid = tid;
}
...
}
相比之前的示例,这个示例稍显啰嗦,但实际上可以简化为简单的几步来讲:
- JS层调用C++层的计算接口 - NAN_METHOD(CheckCollision),创建线程,预先分配异步执行需要的上下文(BodyContext) , 然后使用启动线程的API, (这里用uv_thread,实际上线程API没有特定限制) 。
- 异步线程中执行的方法体 - RunCheckCollision , 实际执行计算,并保存计算结果,然后使用 uv_async_init和uv_async_send将结束回调发送到主线程。
- 主线程中AfterCheckCollision 执行,通知JS层结果,并释放#1中预先分配的上下文。
而示例中的BodyContext贯穿于整个3步中,成为两个线程间数据交流的载体.在多个线程中共享的对象必须是在堆上分配的,因此这里的BodyContext指针以及BodyContext中含有的指针必须指向堆上分配的对象。
更进一步的,这里的线程不是必须使用libuv的接口,你可以使用任何实现,比如std::thread, 或者是真正具有工程意义的各种线程池库, 任何能让你的 RunCheckCollision 运行在非主线程的办法都可以替换示例中的uv_thread , 不过要记得最好使用跨平台库哦,否则nodejs跨平台的特性可就丢了。更多的libuv 的示例可以参考libuv-examples。
最后
文章到这里就结束了,里面介绍的方法,已经足够满足用C++给NodeJS写任何Addon的需求,如果需要在具体细节上增加了解,建议大家还是多实践,多查NODE和V8文档。