10 Node.js 的 c++ 扩展

Node 的优点是处理 IO 密集型操作,对于互联网应用来说,很大一部分内容都是 IO 处理(包括文件 IO和网络IO),但是还是有部分功能属于计算密集型操作。如果遇到这种计算密集型操作,推荐的解决方案是使用其他语言来实现,然后提供一个服务,让 Node 来进行调用。不过我们这章要讲的是 Node 的 C++ 扩展,也就是说,我们可以通过这种方式是 Node 代码直接 “桥接” 到 C++ 上,以此来解决计算密集型操作。但是我在前面为什么推荐使用其他语言来提供计算密集型的服务呢,因为一旦你的这个这个计算服务稍微一上规模,你的代码开发就要面临横跨两个语种的境况,给程序调试增加了很多不确定性。所以说,如果你要做的计算应用的功能比较单一的话,可以考虑做成 C++ 扩展。

Node 的 C++ 扩展功能是依赖于 V8 来实现的,但是在 Node 每次做大的版本升级的时候,都会有可能对应升级 V8 的版本,相应的扩展 API 的定义也很有可能发生变化,所以下面要重点介绍 nan 这个第三方包的,它提供了一系列的宏定义和包装函数,来对这些不同版本的扩展 API 进行封装。

10.1 准备工作

为了能够编译我们的 C++ 扩展,我们需要做一些准备工作,首先需要全局安装 node-gyp 这个包:npm install -g node-gyp。不过此包还依赖于 python 2.7(必须得用2.7版本,安装3.0是不管用的)。同时需要安装 C++ 编译工具,在 linux 下需要使用 GCC,Mac 下需要使用 Xcode,Windows 下需要安装 Visual Studio (版本要求是2015,低于此版本的不可以,高于此版本的作者本身没有做过测试),大家可以选择安装社区版,因为专业版和旗舰版都是收费的,如果想进一步减小安装后占用磁盘的体积可以安装 Visual C++ Build Tools 。按照官方说明在windows下安装完 Visual Stuido 和 node-gyp 后,还需要使用命令 npm config set msvs_version 2015 来指定 node-gyp 使用的 VS 版本。

不过经笔者测试发现,如果在 Windows 中同时安装了多个 VS 工具时, node-gyp 有可能使用错误的版本进行编译,笔者电脑上同时安装了 build tool 2015 和 vs 2012,编译的时候老是会选择使用 vs 2012,不得已将 vs 2012 卸载掉,但是依然报错,最后在 node-gyp 命令中添加参数 --msvs_version=2015 才解决。

10.2 hello world

为了演示如何编译一个 C++ 扩展,我们从亘古不变的 hello world 程序入手,这个程序取自 Node C++扩展的官方文档。我们的目的是在 C++ 扩展中实现如下代码:

  1. exports.hello = () => 'world';

代码 10.2.1

这看上去有些拿大炮打蚊子的味道,这段代码太简单了,而我们竟然要用 C++ 将其实现一番,是的这一节关注的并不是代码本身,还是如何使用工具进行编译,所以我们选择了最简单的代码。首先我们创建 hello.cc 文件:

  1. // hello.cc
  2. #include <node.h>
  3. namespace demo {
  4. using v8::FunctionCallbackInfo;
  5. using v8::Isolate;
  6. using v8::Local;
  7. using v8::Object;
  8. using v8::String;
  9. using v8::Value;
  10. void Method(const FunctionCallbackInfo<Value>& args) {
  11. Isolate* isolate = args.GetIsolate();
  12. args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world"));
  13. }
  14. void init(Local<Object> exports) {
  15. NODE_SET_METHOD(exports, "hello", Method);
  16. }
  17. NODE_MODULE(addon, init)
  18. } // namespace demo

代码 10.2.2 hello.cc

然后创建 binding.gyp ,注意这里面的 target_name 属性要和 NODE_MODULE 宏定义中的第一个参数保持相同。

  1. {
  2. "targets": [
  3. {
  4. "target_name": "addon",
  5. "sources": [ "hello.cc" ]
  6. }
  7. ]
  8. }

配置文件 10.2.1

gypGenerate Your Project)是一种跨平台的项目构建工具,是谷歌员工在开发 chromium 项目时衍生出来的工具。Node.js 扩展说白了也是基于 V8 的 API 基础上的,所以它也采用 gyp 技术。

编写完成之后在 hello.cc 目录下运行命令行 node-gyp configure ,成功之后会生成一个 build 文件夹,里面包含当前代码生成的 c++ 编译用文件。接着运行 node-gyp build 就能生成扩展包了。

编译成功后,我们就可以在 js 代码中引用这个扩展库了:

  1. // hello.js
  2. const addon = require('./build/Release/addon');
  3. console.log(addon.hello());
  4. // Prints: 'world'

代码 10.2.3 hello.js

之前讲过本章的重点是使用 nan 这个包来实现扩展编写,所以我们就先拿这个 hello world 下手。首先是安装 nan 包:npm install nan --save。然后编写 hello.cc:

  1. // hello.cc
  2. #include <nan.h>
  3. using namespace v8;
  4. NAN_METHOD(Method) {
  5. info.GetReturnValue().Set(Nan::New<String>("world").ToLocalChecked());
  6. }
  7. NAN_MODULE_INIT (Init) {
  8. Nan::Export(target, "hello", Method);
  9. }
  10. NODE_MODULE(hello_nan, Init)

代码 10.2.4 hello.cc 的 nan 版

可以看到和代码10.2.2相比代码10.2.4要简洁不少,这里 NAN_METHOD(Method) 经过宏定义解析为 void Method(const Nan::FunctionCallbackInfo<v8::Value>& info),所以你看到在函数 Method 内部会有一个 info 对象,能够在编译的时候被正确识别。同时宏定义 NAN_MODULE_INIT(Init) 会被转化为 void Init(v8::Local<v8::Object> target) 所以你会在函数内部看到一个 target 对象。同时代码 10.2.2 第13行中 Isolate* isolate = args.GetIsolate(); 这个代码在函数 NaN::New<String> 中被封装在其内部,所以在代码 10.2.4 中没有看到这段代码。

10.3 映射 C++ 类

C++ addone 最精髓的地方,就是将 一个 JavaScript 类映射为一个 C++ 类,这样就会产生一个有趣的效果,你通过 new 构建的 js 对象,它的成员函数都被映射成 C++ 类中的成员函数。

下面举的例子可能可能看上去很傻,因为我们又写 对 a+b 求值的函数了,但是这种很天真的代码最好理解不过了。注意,这个例子改写自项目 node-addon-examples 中的 object_wrap 小节。首先是 C++ 头文件定义:

  1. #ifndef MY_CALC_H
  2. #define MY_CALC_H
  3. #include <nan.h>
  4. class MyCalc : public Nan::ObjectWrap {
  5. public:
  6. static void Init(v8::Handle<v8::Object> module);
  7. private:
  8. explicit MyCalc(double value=0);
  9. ~MyCalc();
  10. static NAN_METHOD(New);
  11. static NAN_METHOD(PlusOne);
  12. static NAN_METHOD(GetValue);
  13. static Nan::Persistent<v8::Function> constructor;
  14. double _value;
  15. };
  16. #endif

代码 10.3.1 MyCalc.h

首先注意的一点是,类 MyCalc 要继承自 Nan::ObjectWrap ,按照惯例这个类中还要有一个 Persistent 类型的句柄用来承载 js 类的构造函数,MyCalc 类中唯一对外公开的函数就是 Init,其参数 module 正是对应的是 Node 中的 module 对象。

  1. #include "MyCalc.h"
  2. Nan::Persistent<v8::Function> MyCalc::constructor;
  3. MyCalc::MyCalc(double value): _value(value) {
  4. }
  5. MyCalc::~MyCalc() {
  6. }
  7. void MyCalc::Init(v8::Handle<v8::Object> module) {
  8. // Prepare constructor template
  9. v8::Local<v8::FunctionTemplate> tpl = Nan::New<v8::FunctionTemplate>(New);//使用ShmdbNan::New<Object>函数作为构造函数
  10. tpl->SetClassName(Nan::New<v8::String>("MyCalc").ToLocalChecked());//js中的类名为MyCalc
  11. tpl->InstanceTemplate()->SetInternalFieldCount(1);//指定js类的成员字段个数
  12. Nan::SetPrototypeMethod(tpl,"addOne",PlusOne);//js类的成员函数名为addOne,我们将其映射为 C++中的PlusOne函数
  13. Nan::SetPrototypeMethod(tpl,"getValue",GetValue);//js类的成员函数名为getValue,我们将其映射为 C++中的GetValue函数
  14. //Persistent<Function> constructor = Persistent<Function>::New/*New等价于js中的new*/(tpl->GetFunction());//new一个js实例
  15. constructor.Reset(tpl->GetFunction());
  16. module->Set(Nan::New<v8::String>("exports").ToLocalChecked(), tpl->GetFunction());
  17. }
  18. NAN_METHOD(MyCalc::New) {
  19. if (info.IsConstructCall()) {
  20. // 通过 `new MyCalc(...)` 方式调用
  21. double value = info[0]->IsUndefined() ? 0 : info[0]->NumberValue();
  22. MyCalc* obj = new MyCalc(value);
  23. obj->Wrap(info.This());
  24. info.GetReturnValue().Set(info.This());
  25. } else {
  26. // 通过 `MyCalc(...)` 方式调用, 转成使用构造函数方式调用
  27. const int argc = 1;
  28. v8::Local<v8::Value> argv[argc] = { info[0] };
  29. v8::Local<v8::Function> cons = Nan::New<v8::Function>(constructor);
  30. info.GetReturnValue().Set(cons->NewInstance(argc, argv));
  31. }
  32. }
  33. NAN_METHOD(MyCalc::GetValue) {
  34. MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());
  35. info.GetReturnValue().Set(Nan::New(obj->_value));
  36. }
  37. NAN_METHOD(MyCalc::PlusOne) {
  38. MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());
  39. double wannaAddValue = info[0]->IsUndefined() ? 1 : info[0]->NumberValue();
  40. obj->_value += wannaAddValue;
  41. info.GetReturnValue().Set(Nan::New(obj->_value));
  42. }

代码 10.3.2 MyCalc.cc

注意到在 Init 函数中,定义了一个 js 类的实现,并且将其成员函数和 C++ 类的成员函数做了绑定,其中构造函数绑定为 New ,getValue 绑定为 GetValue,addOne 绑定为 PlusOne。Init 函数中的最后一行类似于我们在 js 中的 module.exports = MyCalc 操作。对于 C++ 函数 New GetValue PlusOne 来说,我们在定义的时候都使用了宏 NAN_METHOD,这样我们在函数内部就直接拥有了 info 这个变量,这个跟 代码 10.2.4 中的使用方法是一样的。

同时留意到在函数 GetValue 和 PlusOne 中,MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());,这一句将 js 对象转化为 C++对象,然后操作 C++ 对象的属性。相反在 函数 New 中 obj->Wrap(info.This()); 是一个相反的过程,将 C++ 对象转化为 js 对象。

10.4 使用线程池

前面几小节介绍了 Nan 的基本使用,可是即使使用了 C++ addon 技术,默认情况下,你所写的代码依然运行在 V8 主线程上,所以说在面对高并发的情况下,如果你的 C++ 代码是计算密集型的,它依然会抢占 V8 主线程的 CPU 时间,最严重的后果当然就是事件轮询的 CPU 时间被抢占导致整个 Node 处理效率下降。所以说釜底抽薪之术还是使用线程。

Nan 中提供了 AsyncWorker 类,它内部封装了 libuv 中的 uv_queue_work,可以在将计算代码直接丢到 libuv 的线程做处理,处理完成之后再通知 V8 主线程。

下面是一个简单的小例子:

  1. #include <string>
  2. #include <nan.h>
  3. #include <sstream>
  4. #ifdef WINDOWS_SPECIFIC_DEFINE
  5. #include <windows.h>
  6. typedef DWORD ThreadId;
  7. #else
  8. #include <unistd.h>
  9. #include <pthread.h>
  10. typedef unsigned int ThreadId;
  11. #endif
  12. using v8::Function;
  13. using v8::FunctionTemplate;
  14. using v8::Local;
  15. using v8::Value;
  16. using v8::String;
  17. using Nan::AsyncQueueWorker;
  18. using Nan::AsyncWorker;
  19. using Nan::Callback;
  20. using Nan::HandleScope;
  21. using Nan::New;
  22. using Nan::Null;
  23. using Nan::ThrowError;
  24. using Nan::Set;
  25. using Nan::GetFunction;
  26. NAN_METHOD(doAsyncWork);
  27. static ThreadId __getThreadId() {
  28. ThreadId nThreadID;
  29. #ifdef WINDOWS_SPECIFIC_DEFINE
  30. nThreadID = GetCurrentProcessId();
  31. nThreadID = (nThreadID << 16) + GetCurrentThreadId();
  32. #else
  33. nThreadID = getpid();
  34. nThreadID = (nThreadID << 16) + pthread_self();
  35. #endif
  36. return nThreadID;
  37. }
  38. static void __tsleep(unsigned int millisecond) {
  39. #ifdef WINDOWS_SPECIFIC_DEFINE
  40. ::Sleep(millisecond);
  41. #else
  42. usleep(millisecond*1000);
  43. #endif
  44. }
  45. class ThreadWoker : public AsyncWorker {
  46. private:
  47. std::string str;
  48. public:
  49. ThreadWoker(Callback *callback,std::string str)
  50. : AsyncWorker(callback), str(str) {}
  51. ~ThreadWoker() {}
  52. void Execute() {
  53. ThreadId tid = __getThreadId();
  54. printf("[%s]: Thread in uv_worker: %d\n",__FUNCTION__,tid);
  55. __tsleep(1000);
  56. printf("sleep 1 seconds in uv_work\n");
  57. std::stringstream ss;
  58. ss << " worker function: ";
  59. ss << __FUNCTION__;
  60. ss << " worker thread id ";
  61. ss << tid;
  62. str += ss.str();
  63. }
  64. void HandleOKCallback () {
  65. HandleScope scope;
  66. Local<Value> argv[] = {
  67. Null(),
  68. Nan::New<String>("the result:"+str).ToLocalChecked()
  69. };
  70. callback->Call(2, argv);
  71. };
  72. };
  73. NAN_METHOD(doAsyncWork) {
  74. printf("[%s]: Thread id in V8: %d\n",__FUNCTION__,__getThreadId());
  75. if(info.Length() < 2) {
  76. ThrowError("Wrong number of arguments");
  77. return info.GetReturnValue().Set(Nan::Undefined());
  78. }
  79. if (!info[0]->IsString() || !info[1]->IsFunction()) {
  80. ThrowError("Wrong number of arguments");
  81. return info.GetReturnValue().Set(Nan::Undefined());
  82. }
  83. //
  84. Callback *callback = new Callback(info[1].As<Function>());
  85. Nan::Utf8String param1(info[0]);
  86. std::string str = std::string(*param1);
  87. AsyncQueueWorker(new ThreadWoker(callback, str));
  88. info.GetReturnValue().Set(Nan::Undefined());
  89. }
  90. NAN_MODULE_INIT(InitAll) {
  91. Set(target, New<String>("doAsyncWork").ToLocalChecked(),
  92. GetFunction(New<FunctionTemplate>(doAsyncWork)).ToLocalChecked());
  93. }
  94. NODE_MODULE(binding, InitAll)

代码 10.4.1 async_simple.cc

为了简单起见,将所有代码写到一个 c++ 文件中,注意到在代码 10.4.1中使用了自定义宏定义 WINDOWS_SPECIFIC_DEFINE,在 binding.gyp 中是支持添加自定义宏定义和编译参数的,下面是这个项目中用到的 binding.gyp 文件:

  1. {
  2. 'targets': [
  3. {
  4. 'target_name': 'async-simple',
  5. 'defines': [
  6. 'DEFINE_FOO',
  7. 'DEFINE_A_VALUE=value',
  8. ],
  9. "include_dirs" : [
  10. "<!(node -e \"require('nan')\")"
  11. ],
  12. 'conditions' : [
  13. ['OS=="linux"', {
  14. 'defines': [
  15. 'LINUX_DEFINE',
  16. ],
  17. 'libraries':[
  18. '-lpthread'
  19. ],
  20. 'sources': [ 'async_simple.cc' ]
  21. }],
  22. ['OS=="win"', {
  23. 'defines': [
  24. 'WINDOWS_SPECIFIC_DEFINE',
  25. ],
  26. 'sources': [ 'async_simple.cc' ]
  27. }]
  28. ]
  29. }
  30. ]
  31. }

代码 10.4.2 binding.gyp

binding.gyp 中的最外层的 defines 变量是全局环境变量,conditions 中可以放置各种条件判断,OS=="linux" 代表当前的操作系统是 linux,其下的 defines 下定义的宏定义,只有在 linux 系统下才起作用,所以在代码 10.4.1 中的环境变量 WINDOWS_SPECIFIC_DEFINE 只用在 windows 上才起作用,我们使用这个宏定义来做条件编译,以保证能够正确使用线程函数(其实libuv 中封装了各种跨平台的线程函数,这里不做多讨论)。

继续回到代码 10.4.1,类 ThreadWorker 中,函数 Execute 用来执行耗时函数,它将在 libuv 中的线程池中执行,函数 HandleOKCallback 在函数 Execute 执行完成后被调用,用来将处理结果通知 libuv 的事件轮询,它在 V8 主线程中执行。

最终给出测试用的 js 代码,又回到了我们熟悉的回调函数模式:

  1. var asyncSimple = require('./build/Release/async-simple');
  2. asyncSimple.doAsyncWork('prefix:',function(err,result) {
  3. console.log(err, result);
  4. });

代码 10.4.3 addon.js

10.5 代码

本章代码位于 https://github.com/yunnysunny/nodebook-sample/tree/master/chapter10