likes
comments
collection
share

如何科学地开发一个 Node addon

作者站长头像
站长
· 阅读数 16

在我的上一篇关于NAPI的文章中介绍作为新手的我如何将一个C库转换成node addon。这篇主要对 node addon 进行系统的学习和实践。

Addons 是什么

简单来说 node.js 是C++写的,所以可以通过动态库(dll dylib)去增加node的能力。(不了解动态库静态库的同学可以回头学一下基础的C知识)。node addon 是以 .node 结尾的文件,最终通过 require 引入。

Addons are dynamically-linked shared objects written in C++. The require() function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.

使用二进制文件查看器去查看这个 .node 结尾的文件的话会发现它的 Magic Number 正式动态库的 Magic Number。

Addons 原理

没搞懂。略过。可能需要注意的是如何导出一个 module。

Addons 编写方式

直接原生

直接使用其提供的原生模块开发头文件(v8、uv等)。然而在不同的node版本,会升级 V8 的版本,从而导致一份代码只对一个node版本生效。

NAN (Native Abstractions for Node.js)

NAN 的宏会判断当前编译时候的 Node.js 版本,根据不同版本的 Node.js 来展开不同的结果。本质上仍然要对每个版本的node编译一遍,只是不需要重新写代码,因为差异都被NAN抹平了。

但是这样问题还是很大,主要是还得在不同node版本下编译,比如这样

Node-API

首先要介绍一下 ABI (application binary interface) 的概念。英语不错的小伙伴可以直接看这里

简单来说 ABI 就是一套约定,你需要在构建时按照这套规范去组织你的数据结构,那这样使用方就可以通过这个 ABI 去直接使用你而不用担心出问题,也不需要重新编译。

node 相关的头文件包括 node.h v8.h 等,由不同组织维护。所以ABI是不稳定的,为了解决这个问题,node 团队在此之上封装了多一层 node_api.h 由此,我们就不需要预编译大量的 addon 来解决不同环境下使用的问题。

一个不确定的知识

node官网可以查询每个nodejs对应的 NODE_MODULE_VERSION。(缺失的版本号基本上是给Electron用了)

NODE_MODULE_VERSION 指的是 Node.js 的 ABI (application binary interface) 版本号,用来确定编译 Node.js 的 C++ 库版本,以确定是否可以直接加载而不需重新编译。在早期版本中其作为一位十六进制值来储存,而现在表示为一个整数。

开发过程中如果使用了非napi的包很大概率会出现(特别是Electron开发)NODE_MODULE_VERSION 不匹配的报错。

如果我没理解错的话,使用非 napi 方式构建会导致需要在每个 NODE_MODULE_VERSION 构建一次。N-API 方式构建的不会出现这种报错。因为 N-API ABI 对应的是 node_api.h 相关的头文件,而 N-API 确保是向后兼容(新nodejs可以使用旧 N-API version)且到目前也只有8个版本。而 NODE_MODULE_VERSION 指的应该是 v8.h node.h 等相关头文件的 ABI。

Addons 技术栈选择

因为我是前端,想要进行 addon 的开发就得不能只用js了。理论上只需要这门语言能够生成动态库就可以开发addon。主要看相对应生态的建设。addon 会需要两个工作:

  1. 在包使用方那边本地构建是不太合理的,大概率缺少需要的环境。所以要考虑预编译并想办法让用户获取到预编译结果;
  2. 让用户取得对应的预编译包。比如,可以在 install 阶段根据用户平台进行对应预编译产物的下载。(后面详解)

C++

可以使用 node-gyp 或 CMAKE(cmake.js) 进行构建。

同时有 prebuild prebuild-install prebuildify node-pre-gyp 等大量辅助你进行预编译且上传的工具。

但是C++环境配置、学习过程等极其痛苦(个人体验)。由于C++历史太长,会导致你需要学习各种额外的知识(比如现在已经全面ES6的情况下,你敢说你不需要了解ES5?)。

Rust(极力推荐

我个人体验语言不是难题,配置环境才是最痛苦的。

Rust 作为一门足够年轻的语言在起步阶段非常友好。napi-rs 开发体验非常友好,也不需要额外的构建相关工具,项目模版也给你建好了。

另外 napi-rs 3.0 正在 rfc 中,期望支持将 addon 也编译成 wasm,这个前景还是不错的。

如果不希望使用 napi-rs 的话可以使用 neon,相比较而言 neon 更像是脚手架,并且尝试去整合 v8 相关的 api,napi-rs 更轻量一些。(如果是做开源的话感觉 napi-rs 会更好,可以整合 Github Actions)

其他

综上所述,除非你有现有且明确的 C/C++ 库,并且这个库没有对应的 Rust 版,否则我都建议你使用 Rust。

Addons 开发

Rust

环境准备

Rust 环境怎么装就怎么装。拉一份模版。核心是 napi-rs 依赖。

编译

npm run build

C++

环境准备

需要安装 C/C++ 相关的环境。项目模版可以参考我的上一篇文章中的项目。主要依赖是 node-addon-api ,下面有充足的文档教你如何 传参、callback、Promise、AsyncTask 等。

编译

使用 cmake.js

Best Practices

仅两种场景我认为适合使用 addon:

  1. 现存 C/C++/Rust 包,希望能在 node 中直接使用
  2. 需要完成一个输入输出简单。但是计算过程很复杂的任务。

相信不少同学使用 addon 都是为了性能提升。需要注意的是,js 与 addon 之间通信成本非常大!比如以下的场景:

  • 任何与 JS Object 相关的操作:传入 Object 、修改 Object、返回 Object 等;
  • 频繁操作 JS 回调;
  • 我估计最好引用类型;

设计 addon 的 api 时要时刻注意必须尽可能少地反复在两侧切换。有几个建议:

  • 使用异步。将代码实质上多线程运行,不阻塞 node 主线程;
  • 如果有文件操作,可以考虑直接在 addon 内处理,并且异步;
  • 如果计算复杂度不够,最好不用 addon 实现;

可能可以考虑的优化点:

  • 使用 callback 有可能与 promise 性能消耗不一样,callback 可以通过 new Promise(resolve => addonFn(resolve)) 转化为 promise(napi-rs使用回调似乎会快一点);

关于异步

异步,或者说多线程能大幅增加 addon 的性能。由于 addon 可以直接利用 node 的库,一般最少会有通过 libuv 实现异步这一种方式。

比如在 Rust 中,会提供两种异步方式:

Addons 发布

C++

  • prebuid/prebuild-install:执行下载预构建包和在无法下载/没有对应包时使用 node-gyp 进行构建
  • prebuidify/node-gyp-build:prebuild 推荐切换成这个,原理应该跟下面 optionalDependecies 相同

optionalDependecies

利用 npm 对自身所处环境判断自动下载对应需要架构的包。

根目录 package.json

{
  ...,
  "optionalDependencies": {
    "@napi-rs/canvas-win32-x64-msvc": "0.1.36",
    "@napi-rs/canvas-darwin-x64": "0.1.36",
    "@napi-rs/canvas-linux-x64-gnu": "0.1.36",
    "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.36",
    "@napi-rs/canvas-linux-x64-musl": "0.1.36",
    "@napi-rs/canvas-linux-arm64-gnu": "0.1.36",
    "@napi-rs/canvas-linux-arm64-musl": "0.1.36",
    "@napi-rs/canvas-darwin-arm64": "0.1.36",
    "@napi-rs/canvas-android-arm64": "0.1.36"
  }
}

预购建包 package.json

{
  "name": "@napi-rs/canvas-darwin-x64",
  "version": "0.1.36",
  "os": [
    "darwin"
  ],
  "cpu": [
    "x64"
  ],
  ...
}

由于 optionalDependencies 的配置 npm 只会下载符合当前环境的预构建包,而忽略其他包。

但是这会导致 Electron 构建非本平台的软件时会打包错误的预构建包。比如在Mac上构建Windows安装包,此时下载的仍然是Mac平台对应的包。

在 Rust 如果不想手动创建上述这么多个子包,可以使用@napi-rs/cli并参考@napi-rs/canvas

总结

这篇是我在学习和实践了一下 node addon 之后,尝试从系统的了解、学习和实践角度概括。后面我应该会主要使用 Rust 来进行 addon 的开发。上面可能有很多错误的地方,欢迎大家指出。

参考资料