最近的任務是把計划庫的API用JavaScript語言調用起來,需要用Node.js的C++擴展,本文簡單歸總一下node.js addons官方文檔https://nodejs.org/api/addons.html
1. 基本知識介紹
在node.js中,除了用js寫代碼以外,還可以使用C++編寫擴展,這有點類似DLL,動態鏈接進js代碼中。使用上也相當方便,只需用require包含,這和一般的js模塊並沒有什么區別。C++擴展為js和C++代碼的通信提供了一個接口。
要編寫node.js的C++擴展,需要了解一些基本知識:
1. V8: Google出品的大名鼎鼎的V8引擎,它實際上是一個C++類庫,用來和 JavaScript 交互,比如創建對象,調用函數等等。V8的API大部分都聲明在v8.h頭文件中。
2. libuv:一個C實現的事件循環庫,node.js使用libuv來實現自己的事件循環、工作線程和所有的異步行為。它是一個跨平台的,高度抽象的lib,提供了簡單易用的、POSIX-like的方式來讓操作系統和系統任務進行交互。比如和文件系統、sockets、定時器和系統事件。libuv還提供了POSIX threads線程級別的抽象來增強標准事件循環中不具備的復雜異步能力。我們鼓勵C++擴展的作者思考如何通過轉換I/O或其他耗時操作到非阻塞系統操作來避免阻塞事件循環。
3. node.js內部lib,node.js本身提供了很多C/C++ API來給擴展使用,比如最重要的一個:node::ObjectWrap類。
4. node.js包含了很多靜態鏈接庫,比如OpenSSL。這些庫都放在node.js代碼樹的deps/目錄下。只有V8和OpenSSL標識符被有意地被node.js重復導出來被各種擴展使用。
下面快速地來看一個實例。
2. 第一個例子HelloWord
下面的例子是一個簡單的C++擴展,其功能相當於js的如下代碼:
module.exports.hello = () => 'world';
首先創建一個hello.cc:
// hello.cc #include <node.h> namespace demo { using v8::FunctionCallbackInfo; using v8::Isolate; using v8::Local; using v8::Object; using v8::String; using v8::Value; void Method(const FunctionCallbackInfo<Value>& args) { Isolate* isolate = args.GetIsolate(); args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world")); } void init(Local<Object> exports) { NODE_SET_METHOD(exports, "hello", Method); } NODE_MODULE(addon, init) } // namespace demo
這個最簡單的例子,已經出現了一些我們完全沒有接觸過的東西。大致解釋一下:
1. 函數Method的參數類型是FunctionCallbackInfo<Value>&,FunctionCallbackInfo
2. Isolate,英文意思是“隔離”,在這里Isolate指的是一個獨立的V8 runtime,可以理解為一個獨立的V8執行環境,它包括了自己的堆管理器、GC等組件。后續的很多操作都要依賴於這個Isolate,后面我們會看到在很多操作中,都會使用Isolate的實例作為一個上下文傳入。
(注:一個給定的Isolate在同一時間只能被一個線程訪問,但如果有多個不同的Isolate,就可以給多個線程同時訪問。不過,一個Isolate還不足以運行腳本,你還需要一個全局對象,一個執行上下文通過指定一個全局對象來定義一個完整的腳本執行環境。因此,可以有多個執行上下文存在於一個Isolate中,而且它們還可以簡單安全地共享它們的全局對象。這是因為這個全局對象實際上屬於Isolate,而卻這個全局對象被Isolate的互斥鎖保護着。)
3. 返回值需要用args.GetReturnValue().Set()來設置。
4. 向外導出方法需要在擴展的初始化函數中使用NODE_SET_METHOD(exports, Method_Name, Method);。如果有多個方法需要導出,就寫多個NODE_SET_METHOD。
注意到node.js的C++擴展都必須按以下形式導出一個初始化函數(該函數名字可以隨便設置一個):
void Initialize(Local<Object> exports);
NODE_MODULE(module_name, Initialize)
NODE_MODULE這行后面並沒有分號(;),因為它並不是一個函數,你可以認為這是一個聲明。module_name必須匹配最后生成的二進制文件的文件名(不包括.node后綴)。在hello.cc這個例子中,初始化函數是init,擴展模塊名是addon。
構建(Building)
寫好源代碼后我們就要把它編譯成二進制的addon.node文件了。binding.gyp文件用來描述我們模塊的構建配置,這個文件的內容是JSON形式的:
{ "targets": [ { "target_name": "addon", "sources": [ "hello.cc" ] } ] }
實施構建操作需要用到node-gyp,如果尚未安裝的話,需要運行(可能要用到sudo):
npm install -g node-gyp
來全局安裝node-gyp。
編寫完binding.gyp文件,我們使用:
node-gyp configure
來生成對應項目在當前平台的build目錄。這將會在build目錄下生成一個Makefile(Unix-like系統)或者一個vcxproj文件(Windows系統)還有一部分其他文件。接着,運行:
node-gyp build
來生成一個編譯過的addon.node文件,這個文件會被放在build/Release/目錄下。
build成功后,這個二進制的C++擴展就可以在node.js中使用require包含進來:
1 // hello.js 2 const addon = require('./build/Release/addon'); 3 console.log(addon.hello()); // 'world'
由於擴展的二進制文件的存放位置會根據編譯方式不同而變化(有可能放在build/Debug/目錄),所以可以用這種方式來引入擴展:
1 try { 2 return require('./build/Release/addon.node'); 3 } catch (err) { 4 return require('./build/Debug/addon.node'); 5 }
鏈接node.js依賴
node.js使用一些靜態鏈接庫,比如V8、libuv和OpenSSL。所有擴展都必須鏈接V8,還有可能需要鏈接一些其他的庫。典型情況下,使用#include <...>來include這些庫(比如鏈接V8就是#include <v8.h>),node-gyp會自動找到這些庫。然而,有幾個注意事項需要說明:
1. node-gyp運行時,它會檢測node.js的版本並且下載全部源碼文件或者只是下載頭文件。如果下載了全部源碼文件,擴展就可以使用node.js的所有依賴,如果僅僅下載了頭文件,則只有node.js導出的那些東西可以被使用。
2. node-gyp可以使用--nodedir選項來指定本地node.js映像,使用這個選項時,擴展可以使用全部的node.js依賴。
使用require加載C++擴展
經過編譯的node.js C++擴展的后綴名是.node(類似.so和.dll),require()函數會查找這些.node文件像初始化動態鏈接庫那樣初始化它們。
當使用reqiure()時,.node后綴可以被省略。需要注意的是,node.js在使用reqiure()加載模塊時,會優先加載js后綴的文件。比如說一個目錄下有一個addon.js和一個addon.node,當使用require('addon')時,node.js會優先加載addon.js。
函數參數
C++擴展可以暴露函數和對象出來讓node.js訪問。當從js中調用C++擴展中的函數時,實參和返回值必須映射到C/C++事先聲明好的代碼中。以下代碼展示了C++擴展代碼如何讀取從js傳遞過來的函數實參和如何返回值:
// addon.cc #include < node.h > namespace demo { using v8: :Exception; using v8: :FunctionCallbackInfo; using v8: :Isolate; using v8: :Local; using v8: :Number; using v8: :Object; using v8: :String; using v8: :Value; // This is the implementation of the "add" method // Input arguments are passed using the // const FunctionCallbackInfo<Value>& args struct void Add(const FunctionCallbackInfo < Value > &args) { Isolate * isolate = args.GetIsolate(); // Check the number of arguments passed. if (args.Length() < 2) { // Throw an Error that is passed back to JavaScript isolate - >ThrowException(Exception: :TypeError(String: :NewFromUtf8(isolate, "Wrong number of arguments"))); return; } // Check the argument types if (!args[0] - >IsNumber() || !args[1] - >IsNumber()) { isolate - >ThrowException(Exception: :TypeError(String: :NewFromUtf8(isolate, "Wrong arguments"))); return; } // Perform the operation double value = args[0] - >NumberValue() + args[1] - >NumberValue(); Local < Number > num = Number: :New(isolate, value); // Set the return value (using the passed in // FunctionCallbackInfo<Value>&) args.GetReturnValue().Set(num); } void Init(Local < Object > exports) { NODE_SET_METHOD(exports, "add", Add); } NODE_MODULE(addon, Init) } // namespace demo
編譯成功后,這個擴展可以被node.js使用require()包含並使用:
1 // test.js 2 const addon = require('./build/Release/addon'); 3 console.log('This should be eight:', addon.add(3, 5));
回調函數
一種很常見的做法是從js傳遞回調函數給C++調用,下面這個示例展示了如何做:
// addon.cc #include < node.h > namespace demo { using v8: :Function; using v8: :FunctionCallbackInfo; using v8: :Isolate; using v8: :Local; using v8: :Null; using v8: :Object; using v8: :String; using v8: :Value; void RunCallback(const FunctionCallbackInfo < Value > &args) { Isolate * isolate = args.GetIsolate(); Local < Function > cb = Local < Function > ::Cast(args[0]); const unsigned argc = 1; Local < Value > argv[argc] = { String: :NewFromUtf8(isolate, "hello world") }; cb - >Call(Null(isolate), argc, argv); } void Init(Local < Object > exports, Local < Object > module) { NODE_SET_METHOD(module, "exports", RunCallback); } NODE_MODULE(addon, Init) } // namespace demo
解釋:
1. 傳遞回調函數,其實和傳遞普通參數沒什么大的區別,使用
Local<Function> cb = Local<Function>::Cast(args[0]);
可以獲得這個回調函數。然后需要顯式聲明這個回調函數的參數個數和參數數組:
const unsigned argc = 1; Local<Value> argv[argc] = { String::NewFromUtf8(isolate, "hello world") };
調用這個回調函數需要傳入isolate、參數個數argc、參數數組argv:
cb->Call(Null(isolate), argc, argv);
2. Init函數和之前有點不同,上面這個擴展的Init()使用了兩個參數的形式(之前都是單參數),其中第二個參數接受一個module對象:
void Init(Local<Object> exports, Local<Object> module) { NODE_SET_METHOD(module, "exports", RunCallback); // 相當於直接導出整個模塊作為方法 }
這將允許擴展使用單個函數的形式代替之前往exports中添加函數作為屬性的方式來完全地重寫exports。因此可以直接用擴展的名字作為函數名來調用,這適用於此擴展只對外暴露一個方法的情況:
1 // test.js 2 const addon = require('./build/Release/addon'); 3 addon((msg) => { 4 console.log(msg); // 'hello world' 5 });