在 Node-API (或者称为 N-API) 发布之前,通常的 native 组件都是依赖特定版本的 v8 以及 NAN API 来实现 C/C++ 的调用。随着 NodeJS 的版本或者 v8 API 的版本变动,这会导致使用了 native 组件的项目升级其 node 版本都需要考虑其版本之间的兼容性,特别是遇到多依赖需要的 node 版本冲突时简直就是灾难。所以 NodeJS 社区决定提供具有 ABI 稳定 的 N-API 接口。

至于为啥需要 Native Module 呢?如果希望使现有的 C/C++ 代码可供广泛的 JavaScript 项目访问,或者您需要从 JavaScript 访问操作系统的资源,或者您有一项计算量特别大的任务,代码可通过 JavaScript 访问可以能够手动调整内存资源的 C/C++ 语言中受益,就比如前一段时间我们在 MacOS 上需要对接系统的 FileProvier 接口,我牵头实现的 electron-macos-file-provider 就是利用 N-API 调用 ObjectC 相关的系统调用来实现的,完整代码也是已经开源到我的 Github 账号了,有类似需求的小伙伴可以参考参考。

除了上面说到的能够复用 C/C++ 强大生态的一些代码以及为了提高软件性能实现一些原优于 JavaScript VM 中实现的算法或者功能之外,还能够通过调用系统级别的 API 实现那些看似在 Node 中“无法实现”的黑魔法,这么奈斯的武功还不快一起学起来。

序章,准备开发环境

首先肯定是需要安装 C/C++ 工具链的,大部分情况下都可以选择 GCC 或者 LLVM ,当然如果你是 windows 的话可以选择安装 Visual Studio 或者你和我一样是 macos 的话选择 Xcode 也行

在 Windows 下安装 Visual Studio command 工具链

npm install --global windows-build-tools

或者 Macos 环境下安装 Xcode command 相关工具链

xcode-select --install

除了以上的 C/C++ 工具链之外,为了构建原生 node 组件还需要安装 node-gyp 以及其依赖的 Python 开发环境。

准备好开发环境之后,创建我们的项目通过 npm init 创建 package.json 配置文件,然后创建 src 文件夹以及空的 myext.cc 代码源文件。

嘿,伙计们,让我们用 NodeJS N-API 封装个 Native Module 吧。-天真的小窝

准备好代码文件后需要在项目根目录下创建 binding.gyp 配置文件

{
    "targets": [
        {
            "target_name": "myext",
            "sources": ["src/myext.cc"],
            "dependencies": [
                "<!(node -p \"require('node-addon-api').targets\"):node_addon_api",
            ],
        }
    ]
}

完成配置文件的创建后,我们需要安装一下 node-addon-api 和 bindings 依赖,其中 node-addon-api 是一个仅包含头文件的 C++ 包装器类,而 bindings 则是最后能够帮助我们在 nodejs 中找到 native 二进制包的工具库。

yarn add bindings node-addon-api

函数的实现与引用

接下来就让我们在 myext.cc 中实现一个 ping 函数吧,在代码文件中键入以下代码

#include <napi.h>

Napi::String Ping(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  return Napi::String::New(env, "peng");
}

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

NODE_API_MODULE(myext, Init)

首先我们引入了 napi.h 头文件,这个头文件由 node-addon-api 提供,具体位置在 node_modules/node-addon-api/napi.h 然后定义了一个入参数为 Napi::CallbackInfo& 类型,且返回 Napi::String 类型的 Ping 函数,函数的实现也很简单就是通过 Napi::Env 去 new 了一个值为 "peng" 的字符串并返回。

除此之外还有一个返回类型为 Napi::Object 的 Init 函数,然后通过入参 exports 导出了 Ping 函数。最后使用 NODE_API_MODULE 宏注册原生组件。

当然,在这里还有个小插曲,由于我使用的是 VSCode 所以如果不配置头文件的引入路径的话很可能你会像我一样一堆报错

嘿,伙计们,让我们用 NodeJS N-API 封装个 Native Module 吧。-天真的小窝

我这边推荐安装 clangd 插件,然后可以在我们当前工作目录中创建一个 .vscode/settings.json 配置文件,当然下列配置中的 ${NODE_HOME} 需要替换为你 node 的安装路径,如果不知道自己 nodejs 安装路径可以尝试通过 which node 命令查询一下

{
    "clangd.fallbackFlags": [
        "-I",
        "${NODE_HOME}/include/node",
        "-I",
        "${workspaceFolder}/node_modules/node-addon-api",
        "-std=c++17"
    ]
}

随后你就会感觉整个世界都美好了。

嘿,伙计们,让我们用 NodeJS N-API 封装个 Native Module 吧。-天真的小窝

现在我们先创建一个 index.js 文件用来导出或者测试我们的函数,代码如下

import bindings from 'bindings'
const ext = bindings("myext.node")

// const ext = require("./build/Release/myext.node")

const text = ext.ping()
console.log("call ping", "=>", text);

在上面的代码中,我们通过导入 bindings 包来帮助我们自动绑定 myext.node 文件,否则的话你需要自己通过 require 去导入 ./build/Release/myext.node 位置的插件二进制产物(可能会出现不同操作系统和架构的产物会在不同位置,以及不同编译工具输出的产物位置也不同等等情况),随后就能够通过 ext 对象调用我们在上面 myext.cc 中实现的 ping 函数啦。

现在让我们来编译一下原生插件以及使用 nodejs 运行 index.js 看看效果

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

$ node index.js
call ping => peng

传参与回调函数

到这里你基本上就能够使用 C++ 完成一个简单的原生函数并提供相关的系统调用或其他 C 库的调用了,当然具体业务中除了这种不带参的函数调用之外更多的是带参数甚至是有回调函数入参的,接下来让我们看看如何实现一个带入参的函数,以及入参回调函数是如何在 N-API 中实现的。

import bindings from 'bindings'
const ext = bindings("myext.node")

// ping
const text = ext.ping()
console.log("call ping", "=>", text);

// getMessage
const msg = ext.getMessage(18, "bin", { addr: "china", registered: true })
console.log("call getMessage", "=>", msg);

// accountCancel
ext.accountCancel(function (name) {
    console.log('call back accountCancel', name);
})

在上面的 index.js 代码中,又增加了 getMessage 和 accountCancel 两个函数,分别传入了 Number、string、object 以及 function 类型的参数。

#include <napi.h>
#include <sstream>

Napi::String Ping(const Napi::CallbackInfo &info) {
  Napi::Env env = info.Env();
  return Napi::String::New(env, "peng");
}

Napi::String getMessage(const Napi::CallbackInfo &info) {
  Napi::Env env = info.Env();
  Napi::Number arg0 = info[0].As<Napi::Number>(); // age
  Napi::String arg1 = info[1].As<Napi::String>(); // name
  Napi::Object arg2 = info[2].As<Napi::Object>(); // info

  Napi::String addr = arg2.Get("addr").As<Napi::String>(); // addr
  Napi::Boolean registered =
      arg2.Get("registered").As<Napi::Boolean>(); // registered

  std::ostringstream oss;
  oss << "name:" << arg1.Utf8Value().c_str()
      << ", age:" << arg0.ToString().Utf8Value().c_str()
      << ", addr:" << addr.Utf8Value().c_str()
      << ", registered:" << (registered ? "yes" : "no");

  std::string msg = oss.str();
  return Napi::String::New(env, msg);
}

void accountCancel(const Napi::CallbackInfo &info) {
  Napi::Env env = info.Env();
  Napi::Function callback = info[0].As<Napi::Function>(); // callback fun
  Napi::String name = Napi::String::New(env, "bin222");
  callback.Call({name});
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set(Napi::String::New(env, "ping"), Napi::Function::New(env, Ping));
  exports.Set(Napi::String::New(env, "getMessage"),
              Napi::Function::New(env, getMessage));
  exports.Set(Napi::String::New(env, "accountCancel"),
              Napi::Function::New(env, accountCancel));
  return exports;
}

NODE_API_MODULE(myext, Init)

在上面的代码中我们分别实现了 getMessage 和 accountCancel 函数,能够看到传参我们通过 const Napi::CallbackInfo &info 中对应的下标就能够取出来,再转换为对应的类型就能够使用啦,稍微需要注意的小点是如果想要将 Napi::String 类型的数据转换为 std::string 是需要通过 Napi::String.Utf8Value().c_str() 函数来转换的。

最后就是 Napi::Function 类型可以通过调用 Napi::Function.Call({}) 来回调 JavaScript 中的函数并传回参数,在回调函数中参数位置下标也对应 js 中回调函数入参下标,现在让我们编译跑起来看看效果吧。

$ node-gyp build && node index.js
gyp info it worked if it ends with ok
gyp info using node-gyp@10.2.0
gyp info using node@18.20.2 | darwin | x64
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
  CXX(target) Release/obj.target/myext/src/myext.o
  SOLINK_MODULE(target) Release/myext.node
gyp info ok 
call ping => peng
call getMessage => name:bin, age:18, addr:china, registered:yes
call back accountCancel bin222

好啦,到这里基本上就算入门 N-API 的使用了,当然后面还有很多高级的玩法等待大伙探索,快来一起玩起来吧。