跳到主要內容
黯羽輕揚每天積累一點點

Node.js C++ 擴展入門指南

免費2020-05-03#Node#Nodejs Addon#Nodejs Addon use case#Node插件#Nodejs动态链接库#Nodejs扩展教程

C++ 擴展是什麼,怎麼寫,有什麼用?

一.概念

本質上,Node.js 擴展就是 C++ 動態鏈接庫:

Addons are dynamically-linked shared objects written in C++.

相當於JS 通往 C/C++ 世界的一扇門

Addons provide an interface between JavaScript and C/C++ libraries.

這些 C++ 擴展(xxx.node 文件)也能像 JS 模塊一樣直接 require 使用,因為 [Node 模塊加載機制](/articles/node 模塊加載機制/) 提供了原生支持。

P.S.所謂動態鏈接庫,就是能在運行時動態加載的庫(.so 文件,或者 Windows 下的.dll 文件):

A shared library(.so) is a library that is linked but not embedded in the final executable, so will be loaded when the executable is launched and need to be present in the system where the executable is deployed.

與之相對的是靜態庫(.a 文件),編譯時鏈接到可執行文件中,無需從外部加載:

A static library(.a) is a library that can be linked directly into the final executable produced by the linker,it is contained in it and there is no need to have the library into the system where the executable will be deployed.

二.實現方式

在 Node.js 中,編寫一個 C++ 擴展有 3 種方式:

  • 直接手搓:基於 Node、V8、libuv 提供的 C++ API 直接寫,但要手動處理這些 API 在不同 Node 版本下的兼容性問題(尤其是 V8 API 經常發生變化)

  • 基於 nan:即 Native Abstractions for Node.js,為了屏蔽不同 Node/V8 版本間 C++ API 差異而加的一層抽象,期望將下層 API 兼容性問題的處理都收攏到這一層

  • 基於 N-API推薦方式):Node.js 提供的原生擴展支持 API,與下層的 JS 運行時(V8)完全獨立,保證 ABI 跨 Node 版本保持不變,因此不用重新編譯就能在不同的 Node 版本上運行

P.S.實際上,有了 N-API 這層獨立抽象之後,C++ 擴展還能跨 JavaScript 引擎、跨 Electron 等運行時,具體見 The Future of Native Modules in Node.js

其中,N-API 是首選方式,除非用 N-API 搞不定才考慮其它方式:

Unless there is a need for direct access to functionality which is not exposed by N-API, use N-API.

跨 Node 版本(無需重編)直接運行無疑是決定性的優勢,但只有專門提供的 N-API 才保證 ABI 穩定。也就是說,只用 N-API(不同時混用下層的 Node、V8、libuv API)才能保證 C++ 擴展在不同的 Node 版本下可以直接運行,具體見 Implications of ABI Stability

不用 N-API 的話,手搓一個有些複雜,涉及好幾層的知識:

  • V8:Node.js 依賴的 JavaScript 引擎,對象創建、函數調用等機制都是 V8 提供的,具體 C++ API 見頭文件 node/deps/v8/include/v8.h

  • libuv:事件循環、Worker 線程以及所有平台相關的異步行為都是 libuv 提供的,並對文件系統、socket、定時器、系統事件等提供了跨平台抽象,C++ 擴展中可以通過 libuv 以非阻塞的方式實現各種操作,從而避免 I/O 或者其它耗時任務阻塞事件循環

  • Node 內部類庫:Node.js 自身也暴露了一些 C++ API,例如 node::ObjectWrap

  • Node 依賴庫:Node.js 依賴的一些靜態鏈接庫在 C++ 擴展中也可以使用,例如 OpenSSL(更多依賴庫,見 node/deps/

P.S.關於 Node.js 源碼依賴、運行機制的更多信息,見 Node.js 架構剖析

三.Hello World

清晰起見,這裡採用最原始的方式,手搓一個最簡單的 C++ 擴展:

// hoho.cc
// 見 https://github.com/nodejs/node/blob/master/src/node.h
#include <node.h>
// 見 https://github.com/nodejs/node/blob/master/deps/v8/include/v8.h
using namespace v8;

namespace demo {
  void Method(const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    args.GetReturnValue().Set(String::NewFromUtf8(
        isolate, "hoho, there.", NewStringType::kNormal).ToLocalChecked());
  }

  void Initialize(Local<Object> exports) {
    NODE_SET_METHOD(exports, "hoho", Method);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}

注意到其中關鍵的兩行:

// 實現初始化方法
void Initialize(Local<Object> exports) { /* ... */ }
// 註冊模塊名對應的初始化邏輯
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)

C++ 擴展通過 Node.js 提供的 NODE_MODULE 宏將初始化方法(Initialize)暴露出來,其中 NODE_GYP_MODULE_NAME 是個宏(macro),在編譯前的預處理階段會被展開成 node-gyp 命令傳入的模塊名。

P.S.宏展開可以理解為字符串替換,具體見 Macros

編譯運行

在對 C++ 源碼進行編譯之前,先要有一份編譯配置:

{
  "targets": [
    {
      "target_name": "hoho",
      "sources": [ "hoho.cc" ]
    }
  ]
}

配置文件名為 binding.gyp,放在項目根目錄下(類似於 package.json),供 node-gyp 編譯使用。

P.S.binding.gyp 具體格式及各字段含義見 Input Format Reference

先要安裝 node-gyp 命令:

npm install -g node-gyp

P.S.當然,也可以 npm install node-gyp 將其安裝到當前項目,並通過 npx node-gyp 調用。

接著通過 node-gyp configure 命令,生成當前平台構建過程所需的配置文件(Unix 系統下生成 Makefile,Windows 下是 vcxproj 文件),例如(Mac OSX):

$ node-gyp configure
gyp info it worked if it ends with ok
...
gyp info ok
# 生成的文件位於 build 目錄下
$ tree build/
build/
├── Makefile
├── binding.Makefile
├── config.gypi
├── gyp-mac-tool
└── hoho.target.mk

編譯得到.node 二進制文件:

$ node-gyp build
gyp info it worked if it ends with ok
gyp info using node-gyp @6.1.0
gyp info using node @10.18.0 | darwin | x64
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
  CXX(target) Release/obj.target/hoho/hoho.o
  SOLINK_MODULE(target) Release/hoho.node
gyp info ok

編譯產物位於 Release/hoho.node,試玩一下:

// index.js
// 省略後綴名,自動找到 hoho.node 並加載、初始化
const hoho = require('./build/Release/hoho');

console.log(hoho.hoho());

運行結果:

$ node index.js
hoho, there.

上例直接使用了 Node、V8 提供的 C++ API,可能存在跨版本兼容性問題(過幾個版本可能就編譯報錯了),並且在不同版本的 Node 環境下都需要重新編譯,否則會產生運行時報錯

$ node -v
v10.18.0
# 切換到 8.17.0
$ n 8.17.0
# 不重編直接執行
$ node index.js
module.js:682
  return process.dlopen(module, path._makeLong(filename));
                ^

Error: The module '/path/to/hoho/build/Release/hoho.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

必須重新編譯、執行:

$ node-gyp rebuild
gyp info it worked if it ends with ok
...
gyp info ok
$ node index.js
hoho, there.

那麼,有沒有一勞永逸的方式?

有。N-API

四.N-API

不直接用 Node、V8 等下層 C/C++ 模塊暴露出來的 API,全都換用 N-API:

// hoho-anywhere.cc
#include <node_api.h>

namespace demo {
  napi_value Method(napi_env env, napi_callback_info args) {
    napi_value greeting;
    napi_status status;

    status = napi_create_string_utf8(env, "hoho, anywhere.", NAPI_AUTO_LENGTH, &greeting);
    if (status != napi_ok) return nullptr;
    return greeting;
  }

  napi_value init(napi_env env, napi_value exports) {
    napi_status status;
    napi_value fn;

    status = napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
    if (status != napi_ok) return nullptr;

    status = napi_set_named_property(env, exports, "hoho", fn);
    if (status != napi_ok) return nullptr;
    return exports;
  }

  NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
}

只引一個頭文件 node_api.h,值類型等也不再直接使用 v8::String

修改編譯配置 binding.gyp

{
  "targets": [
    {
      "target_name": "hoho",
      "sources": [ "hoho-anywhere.cc" ]
    }
  ]
}

編譯運行:

$ node-gyp rebuild
$ node index.js
hoho, anywhere.
# 切換 Node 版本
$ n 8.17.0
# 無需編譯,可直接運行!
$ node index.js
hoho, anywhere.

P.S.更複雜的用法,以及關於 N-API 的更多信息,見 N-API

P.S.另外,N-API 提供的都是 C 接口,對於 C++ 環境,可採用 node-addon-api

五.應用場景

有些場景下,用 C++ 擴展來實現尤為合適:

  • 計算密集型模塊,C++ 的執行性能一般要高於 JS

  • 將現有的 C++ 類庫低成本地封裝成 Node.js 擴展,供 Node 生態使用

  • Node.js 提供的原生能力無法滿足需要,比如 fsevents

  • JS 語言在一些方面存在先天不足(例如數值精度、位運算等),可以通過 C++ 來補足

P.S.注意,運行時初始化 C++ 模板本就存在一些開銷,在苛求性能的場景要把這個因素考慮進來,並且 C++ 並不總是比 JS 快(比如正則匹配的某些場景)

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論