如何科学地开发一个 Node addon
在我的上一篇关于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 会需要两个工作:
- 在包使用方那边本地构建是不太合理的,大概率缺少需要的环境。所以要考虑预编译并想办法让用户获取到预编译结果;
- 让用户取得对应的预编译包。比如,可以在 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:
- 现存 C/C++/Rust 包,希望能在 node 中直接使用
- 需要完成一个输入输出简单。但是计算过程很复杂的任务。
相信不少同学使用 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 中,会提供两种异步方式:
- async fn 使用 tokio
- async-task 使用 libuv
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 的开发。上面可能有很多错误的地方,欢迎大家指出。
参考资料
转载自:https://juejin.cn/post/7204751926516760631