6

用 Addon 增强 Node.js 和 Electron 应用的原生能力

 5 months ago
source link: https://juejin.cn/post/7304538094735753228
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

用 Addon 增强 Node.js 和 Electron 应用的原生能力

2023-11-23 7,394 阅读15分钟

作者:傅泽楠

Node.js Addon 是 Node.js 中为 JavaScript 环境提供 C/C++ 交互能力的机制。其形态十分类似 Java 的 JNI,都是通过提供一套 C/C++ SDK,用于在 C/C++ 中创建函数方法、进行数据转换,以便 JavaScript / Java 等语言进行调用。这样编写的代码通常叫做 Bindings。

此外还有基于 C ABI Calling Convention (例如 stdcall / System-V 等标准) 直接进行跨语言调用的方案,例如 Rust FFI、Python 的 ctypes、Node.js 的 ffi 包等。这两者的差别在于 Rust 等原生语言是直接针对平台来将函数调用编译为机器码,而 ctypes 和 ffi 包则是基于 libffi 动态生成机器码来完成函数调用的。和 Node.js Addon 的差别则在于调用和类型转换的开销上。

本文将围绕 Node.js Addon 进行介绍,即创建一个 Bindings 来增强 Node.js 或 Electron 应用的原生能力,使其可以和系统进行交互,或者使用一些基于 C/C++ 编写的第三方库。

Node.js Electron 的关系

Electron 在主进程和渲染进程中都包含了完整的 Node.js 环境,因此本文既适用于 Node.js 程序,也适用于 Electron 程序。

Node.js Addon 的类型

在 Node.js 的 Addon,有三种类型:

三种类型.png

本文主要介绍 Node-API 的原理,以及以 node-addon-api 作为例子。

Node-API 基本原理

nodeapi.png

Node.js 本质上是一个动态链接库(即 Windows 下的 .dll 文件、MacOS 下的 .dylib 文件、Linux 下的 .so 文件),只不过在分发时会将文件的扩展名改为 .node

Node.js Addon 通常通过 CommonJS 的 require 函数进行导入和初始化。require 在被 .node 扩展名路径作为参数进行调用的情况下,最终会利用 dlopen(Windows 下是 LoadLibrary)方法来动态加载这个以 .node 扩展名的动态链接库:

加载.png

github.com/nodejs/node… 作为参考:

static napi_value Init(napi_env env, napi_value exports) {
  napi_status status;
  napi_property_descriptor desc = DECLARE_NAPI_METHOD("hello", Method);
  status = napi_define_properties(env, exports, 1, &desc);
  assert(status == napi_ok);
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

NAPI_MODULE 宏用来绑定一个 C 函数作为初始化函数。这个函数中可以用来给模块的 exports 对象添加所需要的功能。

例如上述的代码中,给 exports 添加了一个叫做 hello 的函数。这样一来,我们在 Node.js 中 require 这个模块之后,就能获得到一个包含 hello 函数的 exports 对象:

de0ffb9011304ba89015695cbafc8793~tplv-k3u1fbpfcp-jj-mark:3024:0:0:0:q75.awebp#?w=366&h=116&s=12530&e=png&b=ffffff

github.com/nodejs/node… 作为参考:

static napi_value Method(napi_env env, napi_callback_info info) {
  napi_status status;
  napi_value world;
  status = napi_create_string_utf8(env, "world", 5, &world);
  assert(status == napi_ok);
  return world;
}

Method 本身是一个 C 函数,接受 napi_env 作为 JavaScript 的上下文信息。napi_callback_info 作为当前函数调用的信息,例如函数参数等。返回一个 napi_value 作为函数的返回结果。

从这个函数的例子中可以看到,在 C 中是可以获取到函数的调用参数,并且产生一个值作为函数的返回结果。稍后我们会以 node-addon-api 作为例子来具体介绍其编写方式。

编写方式.png

模块编写指南

本节介绍使用 C++ 配合 node-addon-api 开发模块时常见的一些模式和样板代码,仅供参考。

更多用法详见官方文档:github.com/nodejs/node…

模块初始化

使用 NODE_API_MODULE 宏绑定一个 C++ 函数进行模块初始化:

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(Napi::String::New(env, "hello"),
              Napi::Function::New(env, Method));
  return exports;
}

NODE_API_MODULE(hello, Init)
  • 其中 Napi::Env 是对 napi_env 的封装,代表一个 JavaScript 上下文,大部分和 JavaScript 交互的场景都需要这个上下文,可以保存起来以供下次使用(但是不要跨线程使用)。
  • Napi::Object exports 则是这个模块的 exports 对象,可以把想要给 JavaScript 暴露的值和函数都设置到这个上面。

创建 JavaScript 函数

首先需要创建一个如下函数签名的 C++ 函数:

Napi::Value Add(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  double arg0 = info[0].As<Napi::Number>().DoubleValue();
  double arg1 = info[1].As<Napi::Number>().DoubleValue();
  Napi::Number num = Napi::Number::New(env, arg0 + arg1);
  return num;
}

其中函数的返回值可以是任何派生自 Napi::Value 的类型,也可以是 Napi::Value 本身。

获取函数参数

通过 Napi::CallbackInfo& 来获取函数参数,例如 info[0] 代表第一个参数。

info[n] 会获取一个 Napi::Value 值,我们需要调用它的 As<T> 方法来转换为具体的值,我们才能将它继续转换为 C/C++ 可用的数据类型。例如,我们希望将函数的第一个参数转换为字符串,我们需要经过两个步骤:

  1. 将 Napi::Value 转换为 Napi::String:
Napi::String js_str = info[0].As<Napi::String>();
  1. 将 Napi::String 转换为 std::string
std::string cpp_str = js_str.Utf8Value();

其他数据类型例如 Napi::NumberNapi::Buffer<T> 均有类似的方法。

返回函数结果

我们可以直接创建一个 JavaScript 值并在 C++ 函数中返回。具体创建值的方法详见下一小节。

创建 JavaScript 值

我们可以利用各种实例化方法,来从 C/C++ 的数据类型中创建 JavaScript 的值,下面举几个常见的例子。

创建字符串

Napi::String::New(env, "字符串内容")
Napi::Number::New(env, 123)

创建 Buffer

创建 Buffer 是一个有风险的操作。Node-API 提供了两种创建方式:

  • 提供一个指针和数据长度,创建一个数据的拷贝

    • ✅ 安全,首选这种方法
    • ✅ v8 会负责这个 Buffer 的垃圾回收
Napi::Buffer::Copy(napi_env env, const T* data, size_t length)
  • 直接基于指针和数据长度创建一个 External Buffer

    • ⚠️ 同一个指针(相同的内存地址)只能创建一个 Buffer,重复创建会引起错误
    • ⚠️ v8 / Node.js 不负责这个 Buffer 的内存管理
Napi::Buffer::New(napi_env env, const T* data, size_t length)

异步函数通常用于实现一些异步 IO 任务、事件,例如实现一个异步网络请求库的绑定。

异步函数通常有两种实现方式:回调 和 Promise。

同线程回调

同线程回调的使用场景比较少:

  • 使用了 libuv 来运行了一些异步任务,并且这个异步任务会在 libuv 主线程唤醒事件循环来返回结果,这时候可以比较安全地直接进行同线程回调。但是要求事先把 Napi::Env 保存在一个地方。
  • 实现一个函数的时候,在实现中直接同步调用一个 Napi::Function。

通常我们会从函数调用的参数中获取到 Napi::Function,一般来说我们需要在当次调用就把这个函数给使用掉,避免后续被 v8 GC 回收。

持久化函数

如果我们确实需要在之后的其他时机去使用函数,我们需要将它通过 Napi::Persistent 持久化:

Napi::FunctionReference func_persist = Napi::Persistent(func);

使用时,可以作为一个正常的函数去使用。

无论是 Napi::Function 还是 Napi::FunctionReference,我们都可以通过 Call 方法来调用:

Napi::Value ret_val = func_persist.Call({
  Napi::String::New(env, "Arg0")
});

跨线程回调

跨线程回调是比较常见使用场景,因为我们通常会想在另外一个线程调用 JavaScript 函数。

使用线程安全函数 (ThreadSafeFunction)

为了在其他线程中调用 JavaScript 函数,我们需要基于 Napi::Function 去创建一个 Napi::ThreadSafeFunction

Napi::ThreadSafeFunction tsfn = Napi::ThreadSafeFunction::New(
  env,                     // Napi::Env
  info[0].As<Function>(),  // JavaScript 函数
  "handler",               // 异步函数的名称,用于调试的识别
  0,                       // 队列最大大小,通常指定为 0 代表没有限制。如果队列已满则可能会导致调用时阻塞。
  1                        // 初始线程数量,通常指定为 1。实际上是作为内存管理使用。可参考这篇文档。
);

接着就可以把 tsfn 保存在任何位置,并且并不需要同时保存一份 Napi::Env

调用线程安全函数

调用线程函数有两种形式,一种是同步调用,另一种是异步调用。

同步调用指的是如果我们限制了 ThreadSafeFunction 的队列大小,并对其进行了多次调用,从而创建了许多调用任务,则会导致队列已满,调用就会被阻塞,直到成功插入队列后返回结果。

这是进行一次同步调用的例子:

const char* value = "hello world";
napi_status status = tsfn.BlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});

这样一来就能顺利地在任意线程去调用 JavaScript 函数。

但是我们发现,实际上我们并不能同步地获取函数调用的返回结果。并且 Node-API 或者 node-addon-api 都没有提供这么一种机制。但是我们可以借助 libuv 的信号量来达到这个目的。

uv_sem_t sem;
uv_sem_init(&sem, 0);
const char* value = "hello world";
Napi::Value ret_val;
napi_status status = tsfn.BlockingCall(value, [&ret_val](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  *ret_val = callback.Call({ arg0 });
  uv_sem_post(&sem);
});
uv_sem_wait(&sem);

// 直至 JavaScript 运行结束并返回结果,才会走到这里
// 这里就可以直接使用 ret_val 了

异步调用则会在队列已满时直接返回错误状态而不进行函数调用。除此之外的使用方法同 “同步调用” 完全一致:

const char* value = "hello world";
napi_status status = tsfn.NonBlockingCall(value, [](Napi::Env env, Napi::Function callback, const char* value) {
  Napi::String arg0 = Napi::String::New(env, value);
  callback.Call({ arg0 });
});

Promise

C++ 中创建 Promise 给 JavaScript 使用

我们通常会需要在 C++ 中实现异步函数。除了直接用上面已经介绍的基于回调的方法之外,我们还可以直接在 C++ 中创建一个 Promise。

Promise 只支持同 线程 调用

由于 Promise 并未提供跨线程 Resolve 的方式,因此如果希望在其他线程对 Promise 进行 Resolve 操作,则需要结合 libuv 来实现。此方法比较繁琐,建议转而使用跨线程回调函数。如果读者感兴趣,后续本文可以补充相关内容。

我们可以直接创建一个 Promise,并在函数中返回:

Napi::Value YourFunction(const Napi::CallbackInfo& info) {
  Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(info.Env());

  // 我们可以把 env 和 Napi::Promise::Deferred 保存在任何地方。
  // deferred_ 会在 Resolve 或者 Reject 之后释放。
  env_ = info.Env();
  deferred_ = deferred;

  return deferred.Promise();
}

接着我们可以在其他地方调用 Napi::Promise::Deferred 来完成 Promise。注意,这里一定需要在主线程中调用:

// 返回成功结果
deferred_.Resolve(Napi::String::New(info.Env(), "OK"));
// 返回错误
deferred_.Reject(Napi::String::New(info.Env(), "Error"));
C++ 中使用来自 JavaScript 的 Promise

由于 Node-API 或者 node-addon-api 均没有提供使用 Promise 的封装,因此我们需要像在 JavaScript 中通过 .then 手动使用 Promise 的方式,在 C++ 中使用 Promise。

// 首先需要定义两个函数,用来接受 Promise 成功和失败
Napi::Value ThenCallback(const Napi::CallbackInfo &info) { 
  Napi::Value result = info[0];
  // result 是 Promise 的返回结果
  return info.Env().Undefined();
}
Napi::Value CatchCallback(const Napi::CallbackInfo &info) { 
  Napi::Value error = info[0];
  // error 是 Promise 的错误信息
  return info.Env().Undefined();
}

Napi::Promise promise = async_function.Call({}).As<Napi::Promise>()
Napi::Value then_func = promise.Get("then").As<Napi::Function>();
then_func.Call(promise, { Napi::Function::New(env, ThenCallback, "then_callback") });
Napi::Value catch_func = promise.Get("catch").As<Napi::Function>();
catch_func.Call(promise, { Napi::Function::New(env, CatchCallback, "catch_callback") });

显然这种使用方式是比较繁琐的,我们也可以通过一些办法使其可以将 C++ Lambda 作为回调函数来使用,但是本文暂时不涉及这部分内容。

异步任务通常是利用 libuv 提供的线程池来运行一些 CPU 密集型的工作。而对于一些跨线程异步回调的 Bindings 实现则直接使用 ThreadSafeFunction 即可。

具体使用可以参考:github.com/nodejs/node…

Node-API 的构建

基本构建配置

Node.js Addon 通常使用 node-gyp 构建,这是一个基于 Google 的 gyp 构建系统实现的构建工具。至于为何是 gyp,因为 Node.js 是基于 gyp 构建的。

我们来看一个 node-addon-api 项目的构建配置,以 bindings.gyp 命名:



TypeScript

{ "targets": [ { "target_name": "hello", "cflags!": [ "-fno-exceptions" ], "cflags_cc!": [ "-fno-exceptions" ], "sources": [ "hello.cc" ], "include_dirs": [ "<!@(node -p "require('node-addon-api').include")" ], 'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ], } ] }

具体配置可以参考官方使用文档:gyp.gsrc.io/docs/UserDo…

一些常识:

  • "sources" 中需要包含所有 C/C++ 代码文件,不需要包含头文件
  • "<!@(node -p "require('node-addon-api').include")" 在使用 Node-API 还是 node-addon-api 的情况下是不同的。
  • "target_name" 通常需要修改为你希望使用的扩展名称,它会影响编译产物的名称。

常用构建命令

  • node-gyp rebuild 重新构建,会清理掉已有的构建缓存,推荐每次都使用这个命令来构建产物,避免出现奇怪的问题

    • 可以添加 --arch <ARCH> 参数来指定构建的目标架构,例如希望构建一个 32 位的产物,则可以使用 --arch ia32 来构建。
  • node-gyp clean 清理构建缓存。如果希望使用 node-gyp build 来进行构建的话,需要善用 clean 功能。

实用构建配置

添加头文件目录



python

'include_dirs': [ 'win32/x64/include' ]

在 Windows 下进行动态链接 / 静态链接



python

'libraries': [ 'some_library.lib' ]

  • 对于动态链接,需要指定 .dll 对应的 .lib 文件,并在分发的时候将 .dll 放在 .node 相同的目录下。
  • 对于静态链接,则直接指定 .lib 文件即可。但是在 Node.js Addon 中进行静态链接是一个比较费劲的事情,因为通常涉及到对其他静态依赖的管理,需要谨慎选择此方案。

在 Windows 下设置 C++ 版本



python

'msvs_settings': { 'VCCLCompilerTool': { 'AdditionalOptions': [ '/std:c++20' ] } }

在 Windows MSVC 下构建支持代码文件中的 UTF-8 字符(中文注释等)

本质上是给 MSVC 的编译器添加一个 /utf-8 参数



python

'msvs_settings': { 'VCCLCompilerTool': { "AdditionalOptions": [ '/utf-8' ] } }

在 MacOS 下进行动态链接 / 静态链接



python

'link_settings': { 'libraries': [ '-L<动态库或静态库所在的文件夹>', '-l<动态库名称>' ] }

在 MacOS 下引入系统 Framework 依赖



python

'libraries': [ '-framework MediaPlayer', '-framework Cocoa', ]

在 MacOS 下设置 C++ 版本



python

"cflags_cc": [ "-std=c++20" ]

在 MacOS Xcode 的 Release 构建下生成 .dSYM 调试文件



python

'xcode_settings': { 'DEBUG_INFORMATION_FORMAT': 'dwarf-with-dsym' }

使 MacOS 下的 addon 能够使用同目录下的动态库 / Framework



python

'link_settings': { 'libraries': [ '-Wl,-rpath,@loader_path', # 此外,还可以设置到任何相对于 .node 文件的其他目录下 '-Wl,-rpath,@loader_path/../../darwin/arm64', ] },

但是这也要求 .dylib 文件支持该功能,可以通过 otool -D <你的动态链接库位置>.dylib 的返回结果来检查:



python

<你的动态链接库>.dylib: @rpath/<链接库名称>.dylib

如果文件名往前的开头是 @rpath,则意味着支持该功能。如果不是,则可以使用 install_name_tool 来修改动态链接库使其支持:



python

install_name_tool -id "@rpath/<链接库名称>.dylib" <你的动态链接库位置>.dylib

在 MacOS 下支持 Objective-C 和 C++ 混编



python

'xcode_settings': { 'OTHER_CFLAGS': [ '-ObjC++' ] }

开发&分发&使用

项目文件组织

通常来说,我们可以用下面的文件夹结构来扁平地组织我们的 addon 文件:



Julia

. ├── node_modules # npm 依赖 ├── build # 构建路径 │ ├── Release # Release 产物路径 │ ├── myaddon.node # addon 产物 │ ├── myaddon.node.dSYM # addon 的符号文件 ├── binding.gyp # 构建配置 ├── addon.cc # Addon 的 C++ 源码 ├── index.js # Addon 的 JavaScript 源码 ├── index.d.ts # Addon 的 TypeScript 类型(下方会介绍) └── package.json # Addon 的 package.json 文件

当然我们也可以把 JavaScript 源码和 C++ 源码分别放入不同的文件夹,只需要修改对应的构建配置和 package.json 即可。

编写 index.js - 使用 bindings 包

一般来说我们会直接在 C++ 中实现大部分逻辑,JavaScript 文件只用来引入 .node 文件。由于 Node.js Addon 存在各种不同的方案、构建配置,因此 .node 文件产物的位置可能也会因此不同,所以我们需要借助一个第三方 npm 包来自动为我们寻找 .node 文件的位置:

github.com/TooTallNate…

通过 bindings,我们的 index.js 仅需一行代码就能自动获取并导出 .node 模块:



JavaScript

module.exports = require('bindings')('binding.node')

同时保证 package.json 的 main 配置为我们的 index.js:

{
  // ...
  "main": "index.js"
  // ...
}

为 Addon 添加 TypeScript 类型

添加 TypeScript 类型,最简单的方式只需要创建一个 index.d.ts 文件,并在其中声明在 C++ 代码中创建的函数们即可:



TypeScript

export interface FooOptions { bar: string } export function foo(options: FooOptions)

并在 package.json 添加一行参数用于指向类型文件:

{
  // ...
  "types": "index.d.ts"
  // ...
}

大部分情况下,这个方法就可以给你的 Node.js Addon 声明类型。

安装时编译

一种方式是在使用者进行 npm install 时,使用用户设备进行 Addon 的编译。这时候我们可以使用 install 钩子来实现,我们仅需在 package.json 文件中添加如下内容:

{
  // ...
  "scripts": {
    // ...
    "install": "prebuild-install || node-gyp rebuild --release"
    // ...
  }
  // ...
}

保险起见,确保 node-gyp 在你的 devDependencies 之中,这样就能在用户通过 npm 安装你的 Addon 时,自动编译当前系统架构所对应的产物。

如果希望更近一步,节约用户安装 Addon 的时间,或者是为了让用户无需具备编译环境即可安装 Addon,可以使用预编译方案。即在集成环境中提前编译常见的操作系统、架构对应的 .node 文件,并随着 npm 包进行分发,再通过 bindings 或者其他一些库来自动匹配寻找系统所需要的对应 .node 文件。

由于预编译方案涉及到更多的细节,本文不再做介绍,大家可以参考该项目:

github.com/mapbox/node…


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK