🙋🏻♀️ 编者按:NAPI-RS 是一个使用 Rust 构建预编译 Node.js 原生扩展的框架,本文作者是蚂蚁集团前端工程师伊北,带大家初步了解 NAPI-RS,包括如何接入、一些用法、工作机制以及要注意的点等等,欢迎一起交流~
- 建议查阅 napi-rs 官网 napi.rs/
- 对于更深入的部分理解,可阅读 juejin.cn/post/720254…
如何接入
目前提供:
- 提供自动化的多平台编译发布的解决方案:使用
@napi-rs/cli
初始化项目,或者通过napi-rs/package-template
从 github 模板初始化。 - 提供 全平台自动化跨平台编译 的解决方案,示例参考 github.com/napi-rs/cro…,提供基于 GitHub Linux CI 构建跨平台(横跨 Windows、macOS、Linux、Android 等不同 os、不同 libc、不同 arch 指令集平台),通过 zig + cargo-xwin 实现。
- 提供 cli 覆盖本地开发和 github CI (可选)全流程。
生成的项目的目录结构
.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── __tests__
│ └── index.spec.mjs
├── build.rs
├── html-text-content.darwin-arm64.node
├── index.d.ts // 依据 rust 源码中类型自动生成的 d.ts
├── index.js // 自动判断平台决定加载/使用的合适的产物 .node 文件
├── npm
│ ├── android-arm-eabi
│ │ ├── README.md
│ │ └── package.json
│ ├── android-arm64
│ │ ├── README.md
│ │ └── package.json
│ ├── darwin-arm64
│ │ ├── README.md
│ │ └── package.json
│ ├── darwin-x64
│ │ ├── README.md
│ │ └── package.json
│ ├── freebsd-x64
│ │ ├── README.md
│ │ └── package.json
│ ├── linux-arm-gnueabihf
│ │ ├── README.md
│ │ └── package.json
│ ├── linux-arm64-gnu
│ │ ├── README.md
│ │ └── package.json
│ ├── linux-arm64-musl
│ │ ├── README.md
│ │ └── package.json
│ ├── linux-x64-gnu
│ │ ├── README.md
│ │ └── package.json
│ ├── linux-x64-musl
│ │ ├── README.md
│ │ └── package.json
│ ├── win32-arm64-msvc
│ │ ├── README.md
│ │ └── package.json
│ ├── win32-ia32-msvc
│ │ ├── README.md
│ │ └── package.json
│ └── win32-x64-msvc
│ ├── README.md
│ └── package.json
├── package.json
├── rustfmt.toml
└── src
└── lib.rs // rust 源码
自动按平台分发安装包
不同于 node-gyp 本地编译 C++ 包的方式,napi-rs 框架设计为
- 为各个 triples 分配一个 npm 包,并指定 os、cpu、libc 的值,按照对应的 triples 提前编译好对应的 .node 文件,并作为 main 字段导出。
{
"name": "@node-rs/xxhash-linux-arm64-gnu",
"version": "1.3.0",
"os": [
"linux"
],
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"main": "xxhash.linux-arm64-gnu.node",
"files": [
"xxhash.linux-arm64-gnu.node"
],
"description": "Fastest xxhash implementation in Node.js",
"license": "MIT",
"engines": {
"node": ">= 12"
}
}
- 主包中,将所有平台的包作为 optionalDependencies 进行声明。
{
"name": "@node-rs/xxhash",
"version": "1.3.0",
"description": "Fastest xxhash implementation in Node.js",
"main": "index.js",
"typings": "index.d.ts",
"files": [
"index.js",
"index.d.ts"
],
"napi": {
"name": "xxhash",
"triples": {
"defaults": true,
"additional": [
"i686-pc-windows-msvc",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"armv7-unknown-linux-gnueabihf",
"aarch64-apple-darwin",
"aarch64-linux-android",
"armv7-linux-androideabi",
"x86_64-unknown-freebsd",
"aarch64-unknown-linux-musl",
"aarch64-pc-windows-msvc"
]
}
},
"engines": {
"node": ">= 12"
},
"optionalDependencies": {
"@node-rs/xxhash-win32-x64-msvc": "1.3.0",
"@node-rs/xxhash-darwin-x64": "1.3.0",
"@node-rs/xxhash-linux-x64-gnu": "1.3.0",
"@node-rs/xxhash-win32-ia32-msvc": "1.3.0",
"@node-rs/xxhash-linux-x64-musl": "1.3.0",
"@node-rs/xxhash-linux-arm64-gnu": "1.3.0",
"@node-rs/xxhash-linux-arm-gnueabihf": "1.3.0",
"@node-rs/xxhash-darwin-arm64": "1.3.0",
"@node-rs/xxhash-android-arm64": "1.3.0",
"@node-rs/xxhash-android-arm-eabi": "1.3.0",
"@node-rs/xxhash-freebsd-x64": "1.3.0",
"@node-rs/xxhash-linux-arm64-musl": "1.3.0",
"@node-rs/xxhash-win32-arm64-msvc": "1.3.0"
}
}
- 当用户安装主包时,会安装当前机器对应的 optionalDependencies 下来。比如在 mac 下安装
@node-rs/xxhash
,则会同时安装@node-rs/xxhash-darwin-arm64
包下来。
- 用户通过导入主包的形式进行使用,主包的 index.js 中会判断好 os、arch、libc 类型,来决定 require 哪个平台包(或者是本地 .node 文件)
switch (platform) {
case 'android':
// ...
break
case 'win32':
// ...
break
case 'darwin':
// ...
break
case 'freebsd':
// ...
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(join(__dirname, 'xxhash.linux-x64-musl.node'))
try {
if (localFileExisted) {
nativeBinding = require('./xxhash.linux-x64-musl.node')
} else {
nativeBinding = require('@node-rs/xxhash-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(join(__dirname, 'xxhash.linux-x64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./xxhash.linux-x64-gnu.node')
} else {
nativeBinding = require('@node-rs/xxhash-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
// ...
break
case 'arm':
// ...
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
一些用法
自动生成 d.ts
- napi-rs 提供了额外的类型支持,以便生成跟 ts 对应的上的类型文件。
- 将对应的 Rust 代码中的类型生成相应的 .d.ts 文件,为 binding 库提供类型支持。
命名转换
- 默认将 Rust 风格的蛇形转为驼峰风格,
hello_world
->helloWorld
- 通过
#[napi(js_name = "yourFnName")]
修改宏的行为来自定义 js 中变量名
JS 类型映射
bindgen_prelude
JS 的基本类型的映射支持如下,具体可见 napi.rs/docs/concep…
- Undefined
- Null
- Number/BigInt
- String
- Boolean
- Buffer
- Object
- Array
- TypedArray
关于 JSValue 可见 napi.rs/docs/compat…
工作机制
模块注册
#[napi] 宏自动编译生成对应的模块导出,相比于 v1,v2 版本通过宏编译 + 约定的方式简化了写法。
use napi_derive::napi;
#[napi]
pub fn plus_100(input: u32, input1: u32) -> u32 {
input + input1 + 100
}
上述代码的目的是将 plus_100
导出并暴露给 JS 使用。我们来看看 napi-rs 的宏是怎么编译这段代码的。看的顺序是从下往上的反过来。
补充 FFI 和 ABI 的基本概念FFI(Foreign Function Interface/外部函数接口)和 ABI(Application Binary Interface/应用程序二进制接口)是两个相关但不同的概念,它们都涉及到不同语言或系统之间的函数调用和数据交换。 FFI 是实现不同语言间交互的接口或者说是机制,允许不同语言编写的程序互相通信和调用,且不需要额外的转换或者序列化/反序列化。 ABI 则类似规范/协议,定义了不同语言或系统之间的函数调用和数据交换的细节,例如函数参数和返回值的传递方式、寄存器和栈的使用、可执行文件的格式、虚拟地址空间布局等等。 FFI 依赖于 ABI 来实现跨语言或跨系统的函数调用,FFI 通过 ABI 来确认函数的签名,包括函数的名称、参数类型、返回类型,以及函数的地址(即函数在内存中的位置)。 FFI 可以看作是 ABI 的一个高层抽象,它隐藏了 ABI 的复杂性,提供了一个简单易用的接口。
// 用于指定 __napi_register__plus_100 在程序启动时自动运行
// 将 Rust 函数 `plus_100_js_function` 函数注册为模块的导出项
#[napi::bindgen_prelude::ctor]
fn __napi_register__plus_100() {
napi::bindgen_prelude::register_module_export(None, "plus100\u{0}", plus_100_js_function);
}
// 包装函数:注册一个 js 函数 plus100
// 其内部调用 `__napi__plus_100`
unsafe fn plus_100_js_function(
env: napi::bindgen_prelude::sys::napi_env,
) -> napi::bindgen_prelude::Result<napi::bindgen_prelude::sys::napi_value> {
// https://nodejs.org/api/n-api.html#napi_create_function
// 用来保存 napi_create_function 调用返回的 napi_value
// 这个 napi_value 指向创建好的 js 函数
let mut fn_ptr = std::ptr::null_mut();
// 调用 napi_create_function 注册
napi::bindgen_prelude::check_status!(
napi::bindgen_prelude::sys::napi_create_function(
env,
"plus100\u{0}".as_ptr() as *const _,
8usize,
Some(__napi__plus_100), // napi callback
std::ptr::null_mut(),
&mut fn_ptr, // 函数对应的 napi_value 引用传给了 fn_ptr
),
"Failed to register function `{}`",
"plus_100",
)?;
// 将 js_function 与下方的 __napi__plus_100 关联起来
napi::bindgen_prelude::register_js_function(
"plus100\u{0}", // JS 函数名
plus_100_js_function, // 自身
Some(__napi__plus_100), // Rust ABI 函数
);
// 将 js 函数引用的 napi_value 返回
Ok(fn_ptr)
}
// __napi__plus_100 是一个暴露给 C 调用的 FFI 函数
// 接入 env 环境参数和 cb 回调信息两个参数
// 然后从 cb 回调信息中提取 plus_100 函数执行所需的参数
// 最后执行 plus_100 并将值再转为 napi_value 返回
extern "C" fn __napi__plus_100(
env: napi::bindgen_prelude::sys::napi_env,
cb: napi::bindgen_prelude::sys::napi_callback_info,
) -> napi::bindgen_prelude::sys::napi_value {
unsafe {
napi::bindgen_prelude::CallbackInfo::<2usize>::new(env, cb, None)
.and_then(|mut cb| {
let arg0 = {
<u32 as napi::bindgen_prelude::FromNapiValue>::from_napi_value(env, cb.get_arg(0usize))?
};
let arg1 = {
<u32 as napi::bindgen_prelude::FromNapiValue>::from_napi_value(env, cb.get_arg(1usize))?
};
napi::bindgen_prelude::within_runtime_if_available(move || {
let _ret = { plus_100(arg0, arg1) };
<u32 as napi::bindgen_prelude::ToNapiValue>::to_napi_value(env, _ret)
})
})
.unwrap_or_else(|e| {
napi::bindgen_prelude::JsError::from(e).throw_into(env);
std::ptr::null_mut::<napi::bindgen_prelude::sys::napi_value__>()
})
}
}
// 你写的 Rust 函数
pub fn plus_100(input: u32, input1: u32) -> u32 {
input + input1 + 100
}
而
register_module_export
会在 addon 程序被初次载入时:
- 将函数的指针放到一个 local thread queue 中
- 在 Node 初始化 addon 后,会调用 addon 中
napi_register_module_v1
函数,传入 env 和 exports 对象- NAPI-RS 在
napi_register_module_v1
中拿到 env 后遍历 local thread queque 中存储的函数并传入env
,进而得到register_js_function
等 register_xxx 注册的值(函数/常量/Class)等,挂载到 exports 对象上
这就是初始化的完整过程。更多可见源码 napi/src/bindgen_runtime/module_register.rs
中的实现。
调用顺序
Node 和 Rust 互相调用建立在 C ABI 基础上的 FFI 调用
Node.js 中调用 plus100 -> 调用到 FFI 函数 __napi__plus_100 -> 提取参数给 Rust fn plus_100 ## 要注意的点
包名修改
:::info
强烈建议直接使用 napi rename
命令执行:会直接更新模板里所有跟 pkg.json#name 和 pkg.json#napi.name 相关的变量命名。
:::
- 初始化项目时,后续若要修改 root 下 package.json 中包名(name),需要将 npm 目录下所有的平台产物包名也要同步修改。
- package.json 下的
napi.name
修改时,会影响生成的 .node 产物命名,需要修改掉:- index.js 中针对不同平台时 require local 和 require 对应 npm 包名的规则
case 'arm64':
try {
if (localFileExisted) {
nativeBinding = require('./test-1.win32-arm64-msvc.node')
} else {
nativeBinding = require('test-1-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
- CI 配置中的 env.app_name,影响到 job 间持久化产物的规则,类似 github.com/vagusX/rs-h…
- 平台产物包的 main 字段以及 files 字段
Rust Eum 与 TS Enum 不对等
js 中没有 Enum,而 TS 中 enum 长这样。
export enum Kind {
Duck,
Dog,
Cat,
}
var Kind;
(function (Kind) {
Kind[Kind["Duck"] = 0] = "Duck";
Kind[Kind["Dog"] = 1] = "Dog";
Kind[Kind["Cat"] = 2] = "Cat";
})(Kind || (Kind = {}));
Rust 的 enum 通过 napi 导出到 js 后,跟 TS 中 enum 的区别是:**缺少 TS 中 **reverse mapping 的行为
#[napi]
pub enum Kind {
Duck,
Dog,
Cat,
}
{
"Duck":0,
"Dog":1,
"Cat":2
}
JS 和 Rust 之间的 Object 转换成本比其他基本类型高
每次调用 Object.get("key")
实际上都会分派到 Node,包括两个步骤:fetch value、将 JS 值转为 Rust 值,调用 Object.set("key", v)
也是一样。同样的 JS Array 也是一样。
推荐将对应的参数通过 struct
定义好,这样避免直接使用 Object
类型。
#[napi(object)] // cloned from JS Object,为独立副本,不会影响 JS 值
struct PackageJson {
pub name: String,
pub version: String,
pub dependencies: Option<HashMap<String, String>>,
pub dev_dependencies: Option<HashMap<String, String>>,
}
#[napi]
fn read_package_json(pkgJSON: PackageJson) -> PackageJson {
pkgJSON
}
#[napi]
fn read_pkg_json(pkgJSON: Object) -> Object {
pkgJSON
}
const { readPackageJson, readPkgJson } = require('./index')
// { name: 'test-3333', version: '1.0.0' }
console.log(readPackageJson({
name: 'test-3333',
version: '1.0.0',
platform: 'linux',
}));
// { name: 'test-3333', version: '1.0.0', platform: 'linux' }
console.log(readPkgJson({
name: 'test-3333',
version: '1.0.0',
platform: 'linux',
}));
TypedArray 可在 Node 和 Rust 间共享数据
同理 Buffer 也是 Unit8Array 的子类,具体参见 napi.rs/docs/concep…
高级用法
- Rust 类型中
u64
,u128
,i128
,转为 JS 需要配置 napi6(node 10.7) 开启 BigInt 支持,开启方式:在Cargo.toml
中开启features = ["napi6"]
。(默认为 napi4)
[dependencies]
napi = { version = "2.0.2", default-features = false, features = ["napi6"] }
- Types Overwrite:使用字符串作为 napi 宏的参数,达到覆盖掉自动生成类型的效果。可以覆盖参数、返回值、
struct
中的字段。
#[napi(ts_args_type="callback: (err: null | Error, result: number) => void")]
fn call_threadsafe_function(callback: JsFunction) -> Result<()> {
Ok(())
}
// origin
export function callThreadsafeFunction(callback: (...args: any[]) => any): void
// modified
export function callThreadsafeFunction(
callback: (err: null | Error, result: number) => void,
): void
- Async/Await:配合 tokio 一起使用
- Rust 传给 js 一个 Promise 作为结果:
- js 传给 Rust 一个 Promise 作为参数:高级用法,但是可以不用
- 使用 AsyncTask/Task (前者是后者的 wrapper,让 js 可以调用):Task 让我们具备在 libuv 线程池中异步执行任务(如一些高 cpu 耗时任务),而不阻塞 Node.js 的 Event Loop。
- External:在 JS 中,调用后产生一个对象以表示 Native 值,该对象上有一些方法可以再跟 Native 交互。
如何调试
调试宏
vscode rust-analyzer 自带
调试源码
以下部分展示的是使用同一个 js 入口文件进行调试
调试 nodejs 部分
使用标准的 vscode js debugger 配置即可,或者创建一个 JavaScript Debug Terminal
{
"configurations": [
{
"name": "debug js",
"type": "node",
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"program": "${file}",
},
}
调试 Rust 部分
需要先构建出一个支持调试的产物,再使用 lldb 进行调试
# 构建支持调试的产物
napi build --platform
# 使用 lldb 启动并调试
lldb -- /usr/local/bin/node app.js
我将它编写为两个配置文件,分别是 .vscode/tasks.json 和 .vscode/launch.json
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "build:debug",
"group": "build",
"problemMatcher": [],
"label": "npm: build:debug"
}
]
}
{
"configurations": [
{
"type": "lldb",
"request": "launch",
"sourceLanguages": ["rust"],
"name": "debug rust",
"program": "node",
"preLaunchTask": "npm: build:debug",
"args": [
"--inspect",
"${file}"
],
}
]
}