如何用C++扩展NodeJS的能力?

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

文章目录

前言

Javascript 是一门强大的语言看似简单其实包罗万象。NodeJS 是一个非常有活力的平台JS社区是GitHub上最有创造力的社区。而C++是一门古老而强大的语言是最有历史积淀的语言之一。那么两者结合各自发挥自己的优势就成了一种顺理成章的选择。

C++结合NodeJS的魅力

NodeJS 在网络文件数据库等方面有丰富的支持可以几分钟就构建起一个小型网站而C++在性能和内存占用方面有压倒性的优势。因此在一些网络数据库等I/O为主的场景时使用Javascript而在一些局部计算密集任务时使用C++这样可以同时享受JS带来的开发效率和稳定的好处也可以享受C++带来的性能提升。

关于C++和JS的性能对比我写了一个KMP算法的例子完全一样的代码C++性能大约是JS的7~8倍
chart

详细的测试代码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, 可以放在你项目的任何目录下但要注意

  1. 要和你准备运行node-gyp命令的那个目录一致
  2. 配置中的涉及到的文件或目录的相对路径要从该目录开始。
    我们可以将它放在项目根目录。

配置文件的内容是一个大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.x0.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层持有每当要操作该对象时重新将该指针传回。
但是这样的实现有很多缺点

  1. 所有的JS方法需要传入一个额外的由CreateBody得到的handle。
  2. 用完必须显式调用BodyDestroy, 否则会内存泄露。
  3. 不安全如果黑客通过外部传入特定地址的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;
}
...
}

相比之前的示例这个示例稍显啰嗦但实际上可以简化为简单的几步来讲:

  1. JS层调用C++层的计算接口 - NAN_METHOD(CheckCollision),创建线程预先分配异步执行需要的上下文(BodyContext) , 然后使用启动线程的API, (这里用uv_thread,实际上线程API没有特定限制) 。
  2. 异步线程中执行的方法体 - RunCheckCollision , 实际执行计算并保存计算结果然后使用 uv_async_init和uv_async_send将结束回调发送到主线程。
  3. 主线程中AfterCheckCollision 执行通知JS层结果并释放#1中预先分配的上下文。

而示例中的BodyContext贯穿于整个3步中成为两个线程间数据交流的载体.在多个线程中共享的对象必须是在堆上分配的因此这里的BodyContext指针以及BodyContext中含有的指针必须指向堆上分配的对象。

更进一步的这里的线程不是必须使用libuv的接口你可以使用任何实现比如std::thread, 或者是真正具有工程意义的各种线程池库, 任何能让你的 RunCheckCollision 运行在非主线程的办法都可以替换示例中的uv_thread , 不过要记得最好使用跨平台库哦否则nodejs跨平台的特性可就丢了。更多的libuv 的示例可以参考libuv-examples。

最后

文章到这里就结束了里面介绍的方法已经足够满足用C++给NodeJS写任何Addon的需求如果需要在具体细节上增加了解建议大家还是多实践多查NODE和V8文档。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: NodeJSc++