Node.js 扩展 N-API 和应用场景
1 N-API 介绍
1.1 什么是 N-API?
N-API,这是最开始时的名字,现在也叫 Node-API,是专门用于构建 native nodejs 扩展模块的一组 api,在 nodejs 8.0.0 版本的时候引进的,并内置于 nodejs 内部,由官方自己维护。
在 N-API 之前,有一个 NAN(Native Abstractions for Node.js)的库,它是一个很早的 node 原生模块开发工具包,打包了很多原生模块需要的头文件和提供了一些友好封装,但是有一些兼容问题和弊端,下面会提到,目前该库在 github 上 nodejs 组织下, nodejs/nan: Native Abstractions for Node.js (github.com) 。
N-API 主要作用于 nodejs 中 js 引擎和 C/C++ 代码之间,扮演的是一个桥梁的作用,也是一个中间层。
就目前来说 nodejs 的 js 引擎是 v8,然后在 N-API 另一侧的 C/C++ 代码,主要指的是 C/C++ 的扩展模块内容,也就是我们需要编写的代码部分。在这部分中,我们可以直接使用 N-API 去间接调用 v8 的 api 去操作和访问运行时的 js 对象,完成相应的交互。
1.2 N-API 的出现,优化了哪些问题,带来了哪些增强?
1. 解决了因 nodejs 版本问题,导致扩展模块的前后兼容问题
这个兼容问题以前是由模块开发者来维护的,现在全丢给 nodejs 了,基本上兼容问题不需要我们考虑太多,主要体现在,如下两点:
如果使用纯原生方式,写一个扩展模块,因需要访问和操作 js 对象,就需要了解 js 引擎 api,就目前的 nodejs 而言,需要了解 v8 的 api,甚至说,了解 libuv 的 api。然后不同版本的 nodejs,可能存在 v8 版本的不一致,不同版本 v8 api 会有废弃和 break change 等问题,会直接导致,我们先前 nodejs 版本编译好的扩展模块,使用的可能是旧的 v8 api,以至于在后续 nodejs 版本中继续使用之前编译好的扩展模块,会出现一些预期以外的兼容问题,需要我们即使更新模块 api,重新编译打包更新。
N-API 是由 nodejs 本身维护在内部的,它的 api 版本兼容是稳定的,因为扩展模块编译之后是二进制文件,所以 N-API 也是 ABI-stable,这里的 ABI 指的是 Application Binary Interface,也就是二进制接口稳定,所以我们只需要关注 N-API 版本和 nodejs 版本的兼容映射关系就可以了,一般来说,一次编译可以在好多版本下试用,无需重复编译。
这里贴一下,N-API 版本 和 nodejs 版本对应关系文档地址, 详见 。
2. 使用 N-API 开发的扩展模块,我们是可以无需关心 nodejs 是否有更换 js 引擎,也无需去了解具体 api,降低了开发的了解和学习成本
这个就很好理解,N-API 的出现,不再需要开发者,去了解具体的 js 引擎 api,它本身是一层 api 的抽象,我们通过它间接去调用 js 引擎的 api,下面都由每个版本的 nodejs 自己在内部衔接处理好。哪天 nodejs 偷偷更换了,比 v8 更好的 js 引擎了,我们也无需关心,我们的扩展代码是否能执行,属于无缝衔接。
3. N-API 让 nodejs 跨语言集成变的更容易
因为 N-API 是 ABI-stable 的,并且 nodejs 的扩展模块,编译之后的二进制文件,都是以 C style API 形式的动态链接库文件。
那么也就是说,只要是任何语言,能编译成 C style 类型的动态链接库,都可以使用 N-API,去为 nodejs,开发扩展模块。
nodejs 与 其它语言,甚至操作系统,他们之间的衔接的纽带,就是 C style 动态链接库,这样一来,很直接的一个使用场景就有了,很多现成的动态链接库,我们不需要再用 js 去实现造一遍轮子了,仅使用 N-API 包装一下,就可以为 nodejs 所使用了。
2 如何使用 N-API,写一个 nodejs addon,并发布使用?
这里附上一个之前测试编译发布流程时,写的一个简单的 demo 代码仓库地址 。
这个 demo 是导出一个 hello 的函数,打印 hello_world~ ,以这个 demo 为例,拆分开介绍一下,使用 N-API 编写一个 nodejs addon 和 中间一些工具链的配置,以及如何发包到 npm,实际安装使用扩展的原理。
2.1 介绍一下 demo 的目录结构
2.2 介绍一些 binding.gyp 和 package.json 中的配置,以及如何发布 npm 包
package.json
binding.gyp
这个配置文件,默认放到和 package.json 平级目录下,当执行 node-gyp build 命令是,会自动读取当前配置。
发布和使用 npm 包
关于发布:
基于上面介绍的 package.json 中的 publishConfig
和 files
配置,我们可以直接执行 npm publish
或者 npm run release
命令,直接发布到 npm 上即可,可以配置到 ci 上发布,关于 ci 的就不具体写了。
关于使用:
相同的是,就和使用正常的 npm 包一样,npm install @<tag/version>即可。
不同的是, npm install
时会自动,执行 node-gyp build
根据包里面的 binding.gyp
配置文件,和当前 nodejs 的版本以及 nodejs 的编译配置信息,去编译当前扩展模块,输出到 build
目录中。
关于 nodejs 的 gyp 编译配置文件,详见命令:
node -p process.config
关于 prebuild 扩展模块,这里先不提了,实时安装编译和使用 prebuild,各有利弊,具体根据实际需要选择。
2.3 简单介绍一下 src 下面的代码流程
这个示例代码,这里用的是 node-addon-api 这个包,它的头文件为 napi.h
,不是 N-API 的 node_api.h
,这里不过多介绍,我们可以快速浏览下代码和上面的关键注释。
3 nodejs 扩展模块,有哪些实用的应用场景?
高性能计算:
高精度计算:当需要处理大量数据或进行高精度计算时,C/C++的性能优势尤为明显。Node.js 的 addons 可以封装这些计算密集型任务,从而提高整体应用的性能。
图像处理:处理大量图像或进行复杂的图像处理算法时,可以利用 C/C++的库(如 libjpeg、OpenCV 等)来加速处理过程。
2.系统级操作:
调用系统 API:Node.js 的 addons 可以直接调用操作系统的 API,实现一些 Node.js 自身难以或无法完成的功能,如访问硬件资源、操作底层文件系统等。
与第三方库集成:当 Node.js 没有合适的包或现有包的效率无法满足需求时,可以通过 addons 调用已有的 C/C++库来实现特定功能,如视频格式转换、加密解密等。
3.多语言开发:
跨语言互操作性:在多语言开发的项目中,Node.js 的 addons 可以作为桥梁,使 Node.js 能够调用 C/C++编写的类库,从而实现与其他语言(如 Java、Python、Go 等)的互操作。
共享代码库:通过 addons,可以在不同的项目或不同语言之间共享 C/C++编写的代码库,提高代码复用性和开发效率。
4.性能瓶颈优化:
优化热点代码:对于 Node.js 应用中的性能瓶颈部分,可以通过编写 addons 来优化这些热点代码,从而提高整体应用的响应速度和吞吐量。
并发处理:虽然 Node.js 本身擅长处理高并发,但在某些场景下(如需要处理大量 CPU 密集型任务时),通过 addons 可以利用 C/C++的多线程能力来进一步提高并发处理能力。
5.游戏开发:
游戏引擎集成:在游戏开发中,可能需要集成一些高性能的游戏引擎或物理引擎(这些引擎往往是用 C/C++编写的)。通过 Node.js 的 addons,可以方便地将这些引擎集成到 Node.js 应用中,从而利用 Node.js 的异步 IO 和网络通信能力来构建游戏的后端服务。
4 nodejs 的 addons 是什么?
Node.js 的 addons 是 Node.js 的原生扩展,是使用 C/C++ 编写的动态链接共享对象,显示为 .node
后缀文件,它们可以通过 Node.js 的 require() 方法加载,并像其他普通的 Node.js 模块一样使用。
addons 的主要优势在于能够利用 C/C++ 的高性能特性,以及直接访问 系统级 API 的能力,从而在某些场景下显著提升 Node.js 应用的性能或实现特定功能。
4.1 nodejs 的 组成结构及模块关系
4.2 nodejs 的 addons 的结构组成
这里引用一下官方文档中的一个纯 native 方式编写的代码示例: hello 。
可以对比一下 上面 demo 中的 N-API 的使用方式,可以发现,纯原生 addon,少了一个封装的头文件 node_api.h
或者 napi.h
。
有了上面的 demo 代码结构示例,再来快速瞅一下,这个原生态的结构,也就很容易理解了。
简单说明一下,addons 的结构,主要分 3 部分:
这里相关代码参考,node v20.13.1
注册模块给 node,这里使用的是 node 中的宏定义,主要有三个,分别是
这个宏定义,默认是注册到全局
这个宏定义,在 NODE_MODULE
的基础之上,支持 Context
参数,支持用于上下文隔离,也就是多实例(跨线程)。
NODE_MODULE_INIT
是 NODE_MODULE
的 增强实现,可以看下具体宏代码实现。
这个宏定义,同 NODE_MODULE_INITIALIZER
作用一样,但是实现方式不一样。
2.node 初次加载 模块时的 初始化函数
Initialize
这个函数是作为参数出现的,函数名不限制。
示例用到了,另一个宏定义, NODE_SET_METHOD ,使用 node 封装的导出函数,导出对方法。
3.具体 addons 的功能实现
这里需要直接使用到 v8 的 api,来操作对象和参数了,因此需要我们,去了解下 v8 相关的文档 。
看完这个结构,也能感到 N-API 的其中一个作用,就是集中抽象封装原生 api,在 nodejs 内部维护,尽可能降低开发 addon 的学习和维护成本。
4.3 nodejs 的模块加载机制,以及集成 addons 原理
模块加载:
1、对于 addons 模块的加载,node 提供了 process.dlopen 函数,可用于直接加载 addons。
2、由于 addons 的模块,后缀名为 .node
,不是标准的 es 模块,只是 nodejs 自己的扩展模块,所以,目前 esm
是不支持直接使用 import
进行加载的,可以自行使用 process.dlopen
来封装,下面贴一段,官方文档给的示例代码。
3、对于 commonjs
使用的 require
,是支持加载 .node
模块的,下面贴一下 node 代码实现 和 源码位置 。
这里可以看到 process.dlopen(module, path.toNamespacedPath(filename));
,这里也是直接使用了 process.dlopen
函数。与直接使用 process.dlopen
不同的是,我们不需要自己去构造参数,并且,也不需要自己去维护,模块的缓存,都交给了 require
,来统一管理。
集成 addons 原理:
node 的扩展模块,后缀名是 .node
,这个只是为了便于,node 加载模块时识别使用。
实际上的 .node
其实是一个 动态链接库
:
在 linux 系统下时,是
.so
结尾在 windows 系统下时,是
.dll
结尾
对于 动态链接库
的跨系统加载,主要依靠 libuv
的封装 api,进行系统级别的调用,来完成 动态链接库
的内容加载和调用。
node 完成的加载流程如下:
5 nodejs addon 模块的所有编写方式
5.1 纯 native 写 addons
文档示例: C++ addons | Node.js v20.13.1 Documentation (nodejs.org)
这个直接引用 node.h
头文件,以及 v8.h
,等内置的 api 和 依赖库 api,直接写很原生态。
需要自己从 node 源码中获取必要头文件。
5.2 nan(Native Abstraction for Node.js)
仓库地址: nodejs/nan: Native Abstractions for Node.js (github.com)
这个是 nodejs 很早给出的 addons 实现方案,进行了少量的抽象和封装,以及打包了必要头文件,但是还是需要了解 v8 相关 api。
核心问题:
nan
这个库并不是 nodejs 内置的 api,属于是一个外在衍生的吧,它里面依赖的 v8 等版本的 api,没办法和 nodejs 保持一致,这是不如后来的 N-API 的地方,也是出现兼容性问题的地方。
5.3 nodejs 提供 native 封装 => node-api
文档地址: Node-API | Node.js v20.13.1 Documentation (nodejs.org)
这个是目前 nodejs 推荐使用的方式,以前叫 N-API
现在叫 Node-API
, 它隐层了很多 node 层面内容, 定义了一组专门用于 addons 的 C style API, 也就是生成 node 扩展 动态链接库
的规范, 大家只需要关注 node-api
即可, 减少了很多和 node 版本的 兼容性问题.
与 nan 的最大的区别是,这个维护在 nodejs 内部,且 api 稳定,所以使用了 api 的扩展模块也稳定,以前的兼容问题都抛给 node 内部去维护了。
5.4 基于 node-api 的跨语言交互
C++
我们可以使用 node-addon-api
这个包,去获取需要的头文件和更高级抽象的 api:
仓库地址: nodejs/node-addon-api: Module for using Node-API from C++ (github.com)
文档地址: node-addon-api/doc at main · nodejs/node-addon-api (github.com)
这个包代码不是 nodejs 的一部分,它是对 node-api 的扩展,是 C++ 基于 N-API
扩展的一个实现,用于更好的集成 C++ 代码,使 N-API 使用起来更方便。可以看到这个仓库是被固定在 nodejs 组织下的,懂得都懂。
下面附上一个用 node-addon-api
这个包开发的开源库示例:仓库地址: lovell/sharp: High performance Node.js image processing, the fastest module to resize JPEG, PNG, WebP, AVIF and TIFF images. Uses the libvips library. (github.com) 这个库利用 N-API 的优势,包装了 libvips 这个 C++ 处理图片的库,一来避免了 js 重复造轮子,二来也一定程度上提高了性能。我们看一下,它的 package.json 的依赖以及它的 src 入口代码,这个库,没有使用 bindings 那个包,是自己写的 预编译和 binding 代码。
有兴趣的话,可以以 pipeline 这个方法为例,看一下它的代码,它里面使用了 N-API 提供的异步实现,就像写 js 去写个异步一样,不同的是,这里的的异步,借用了 libuv 的工作线程,在 js 层面只有 IO 才能用,或者显示 使用 worker_threads 模块。
Rust
我们也可以使用热门新秀语言: rust
,去开发 nodejs 的 addon,主要使用的是这么一个库 napi-rs
:
仓库地址: napi-rs/napi-rs: A framework for building compiled Node.js add-ons in Rust via Node-API (github.com)
文档地址: Home – NAPI-RS
这个库其实在,C++那个库的文档最后,也是有所提及的: node-addon-api/doc at main · nodejs/node-addon-api (github.com)
这是一个 rust
版本的基于 N-API
的实现, rust
本身是可以编译成 动态链接库
的, 这个包遵循 N-API
的约定, 用于将 rust
代码, 生成 node 可以用的 动态链接库
, 从而以 addons 的形式被 node 使用的目的.
Rust 也是 js 的另一个运行时 Deno (Nodejs 的兄弟)的核心开发语言,当初 Nodejs 作者,感觉到 Nodejs 的发展背离了初衷,主要是安全性问题,就重新写了 Deno,下层使用的是 Rust 和 V8,这可能是 Rust 支持 Nodejs 的一个原因吧,为了生态融合,去了解 Deno 的 api,会发现他们做了很多,与 Nodejs 兼容的东西。
这个库目前比较友好,相关的打包发布工具链也挺全的,一套代码, 可以支持同时构建成多个平台的 预编译版本
, 发布到 npm.
使用这个库,开发的包,如: @swc/core
然后这里提一下 预编译版本
,说下明显的优缺点:
优点
: 提前通过ci
等发布环境,提前编译好了,可能的系统环境下的.node
包,npm install
时候不用再实时编译,也就不需要依赖编译环境。缺点
:每次安装时候,可能需要下载好几个,预编译的.node 文件
,时间挺长的。
拿 @swc/core
来说,他会检测当前系统满足的 .node
, 全都下载来。
看了每个 .node
大概 40 多 mb,多下载一个相当于时间翻倍,之前就说每次重新安装 @swc/core
这个包时,网络不好时,总是卡了很久。。。
6 nodejs 扩展模块 和 纯 js 模块的优缺点
因为扩展模块,可以跨语言,这里先以 C++ 作为代表,简单从如下三方面来说下:
1、执行速度:
c++ 扩展通常具有更快的执行速度,因为 c++ 是编译型语言,其代码在执行前已经被编译成机器码,而 nodejs 中的 js 代码则是通过 v8 引擎即时编译(jit)的,中间会多一步编译的过程。在计算密集型任务中,c++ 扩展的优势尤为明显。
因为 nodejs 是单线程,除了这些任务外 —— timer 定时器任务(异步检查),文件/网络 io 的数据读取任务(工作线程异步读取),所有 js 代码还都是跑在主线程上,会阻塞主线程。
所以对于 js 计算任务,无论你是否使用 async/await 或 promise 去 wrapper 拆分,它总计算任务量是不变的,唯一的区别,就是将原本同一个时间点需要执行的大整块的长时间的任务,拆分成多个短时间的小任务,在不同的时间点串/并行执行,从而一定程度上提高任务承载能力,但是终究主线程是单线程,这种计算任务承载力是有限的。
就算使用 c++ 扩展模块也一样,只要是同步代码都是一样的,同步执行意味着串行和等待。但是在都不考虑使用工作线程的情况下,对于计算密集型任务 c++ 有语言上的优势。
此外,nodejs 提供了,工作线程模块 worker_threads
可以在 js 层面,显式的使用工作线程,去做计算型任务,每个线程是一个独立的 v8 实例,可以独立执行计算任务,不阻塞主线程。
另外 c++ 扩展也可以不占用主线程,在代码中利用 libuv 提供的下层工作线程,如同在 js 层面使用 worker_threads
一样,去异步工作。
无论是异步还是同步,对于计算行任务,同场景下来说,大多数情况下 c++ 执行速度是优于 js 的。
2、内存使用:
c++ 扩展通常能更有效地使用内存,因为它们可以直接操作内存地址,绕开了 js 对象,也避开 v8 引擎的 js 内存限制,因此不占用 v8 的内存限制指标。
这会帮助在 c++ 扩展中,处理复杂计算或大量数据时,内存使用更为便捷和高效,弊端内存管理不好,容易 OOM。
注:nodejs 中的 buffer 数据也不会占用 v8 的内存指标,buffer 不是 v8 中的内容,是 nodejs 下层自己的 c++ 内置模块。
3、开发效率:
js 代码的开发效率通常更高,因为它具有更简洁的语法和丰富的库支持。
c++ 扩展的开发则相对复杂,需要更多的编译和调试工作,整体开发成本较高。
总结
这篇主要目的是以介绍 N-API 和如何编写 node 扩展模块为主线,也罗列了下它的使用场景,最后结合我们对平时代码的思考,整体概括总结一下:
1、N-API 是 nodejs 官方对外提供的一套统一的标准接口(有点类似于 open api 这种意思),api 很稳定,官方再 nodejs 内部维护,但凡遵循这套接口的定义,使用 N-API 开发的动态链接库,无论啥语言开发的,都可以为 nodejs 所使用,如同正常 js 模块一样。
2、N-API 也简化,开发 nodejs 扩展模块的流程,让我们可以将注意力,集中在 api 的使用上,而不是花一些时间去考虑各种兼容问题以及与 js 交互问题上,然后如需写扩展,可以优先考虑封装和抽象程度更高的这两个:
使用 C++ 的话,就 node-addon-api 这个包,可以参考最上面的 demo 或者 sharp 这个库代码。
使用 Rust 的话,就 napi-rs 详细看下这个库的文档吧,文档将 N-API 映射到 Rust 自己的 api,以及一些数据类型映射,它的配套工具链挺完善,可以参考 swc-project/swc: Rust-based platform for the Web 这个包代码。
3、N-API 最实用使用场景,就是 wrapper 现成的第三方库,快速为 nodejs 提供快速的扩展能力,如 sharp 这个库。
我们不造轮子,我们只是轮子的搬运工(^_^)~
4、对于 nodejs 正常开发任务 或者 优化代码,我们应该遵循能简单绝不复杂的原则,主要思路为 提高整体承载能力
和 降低主线程的拥堵度
两方面,优先考虑使用 js 代码。
提高整体承载能力
代码层面(脚本/项目)
对于 js 计算任务,优先考虑,使用 async/await 或 promise 进行任务拆分,异步并行在主线程中执行。
基本上这是平时大家用的最多的方式了。
需要再度优化了,可考虑 js 代码,使用 wasm 代替,编译之后也是二进制,浏览器中也能使用,目前主流浏览器都支持了 WebAssembly 。
最后再考虑引入 c++ 扩展。
node 层面
考虑使用多个 node 进程启动 脚本/项目
child_process
cluster
第三方工具,如: pm2
再往大说,部署多容器,多机器。
降低主线程的拥堵度
优先考虑,使用 worker_threads
模块,丢给工作线程去跑。
线程与进程共享内存,可以减少数据序列化和反序列化的开销。
c++ 扩展,可考虑,直接使用 N-API 的 AsyncWorker ,使用工作线程异步执行。
其次使用 child_process 模块,fork 子进程去跑。
子进程与主进程完全隔离,它们通过 IPC 通信,开销代价高于 worker_threads 。
最后想说,使用 nodejs 开发,也并不总是 c++ addons 就一定效率高,理论上终究是理论上,方向是对的,大多数情况下也是对的,具体还需要结合具体业务和需要来决定,必要时也要需要一定的测试对比。
评论