序言
napi-rs 是一个用于在 Rust 中构建 Node.js 预编译插件的框架。得益于 Rust 的卓越性能表现,它正逐渐在前端工具链中占据一席之地,而 napi-rs
则在 Rust 与 JavaScript 交互的实现中扮演着关键角色,因此得到了广泛的应用。
本文将深入探讨如何通过 napi-rs
实现 Rust 和 JavaScript 函数之间的互相调用,并剖析背后的工作机制。
napi-rs 与 N-API 的关系
N-API
是 Node.js 官方维护的一组 API,专为构建稳定且兼容各版本的 Node.js 原生插件而设计。
napi-rs
是基于 N-API
构建的,通过提供方便的 Rust 接口,它简化了在 Rust 中构建 Node.js 原生插件的过程。N-API
是 Node.js 提供的原生 API,而 napi-rs
是 N-API 的 Rust 封装,两者组合使得 Rust 开发者能够更容易地编写和集成 Node.js 的原生模块。
JavaScript 调用 Rust 函数
使用
在 napi-rs
中,将 Rust 函数暴露给 JavaScript 使用是异常直观。通过 napi-rs
的文档和工具,你可以迅速地得到一个 Rust 写的 sum
函数,并暴露给了 JavaScript。
rust
#[napi]
pub fn sum(a: i32, b: i32) -> i32 {
a + b
}
你可以在 JavaScript 中直接调用它。
js
sum(1, 2)
实现原理
为了深入理解 napi-rs
的运作,我们可以使用 cargo expand
命令对 #[napi]
宏进行展开,揭示其背后执行的具体步骤。
第一步,napi-rs
生成 __napi__sum
函数,此函数作为 Node.js 插件中的桥梁,实现 Rust sum
函数与 JavaScript 环境之间的参数接收和返回值传递。
rust
#[doc(hidden)]
#[allow(non_snake_case)]
#[allow(clippy::all)]
extern "C" fn __napi__sum(
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, false)
.and_then(|mut cb| {
let arg0 = {
<i32 as napi::bindgen_prelude::FromNapiValue>::from_napi_value(
env,
cb.get_arg(0usize),
)?
};
let arg1 = {
<i32 as napi::bindgen_prelude::FromNapiValue>::from_napi_value(
env,
cb.get_arg(1usize),
)?
};
napi::bindgen_prelude::within_runtime_if_available(move || {
let _ret = { sum(arg0, arg1) };
<i32 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__>()
})
}
}
extern "C"
表示这个函数使用 C 语言的调用约定,它告诉编译器生成的函数应与外部 C 代码或其他语言兼容,这是创建 Node.js 原生模块所必需的。- 函数签名:定义了函数的参数和返回类型。根据 N-API 的要求,参数包括环境句柄
napi_env
和回调信息napi_callback_info
。返回类型是napi_value
,这表示了 JavaScript 值。 unsafe
块:因为涉及到 FFI (Foreign Function Interface 外部函数接口) 与 Node.js 的交互,所以需要在unsafe
代码块中运行。CallbackInfo::<2usize>
:一个帮助器结构,封装了回调信息,并知道期望两个参数。- 参数提取:使用
from_napi_value
方法将 JavaScript 参数转换为 Rust 的i32
类型。 - 计算和返回:调用 Rust 中的
sum
函数,并使用to_napi_value
方法将结果转换回 JavaScript 可以识别的值。 - 错误处理:如果在参数转换或函数执行过程中发生错误,通过
unwrap_or_else
来捕获并处理,这包括将错误转换为 JavaScript 错误并抛出。
第二步,napi-rs 生成 sum_js_function
函数,定义了一个用作 N-API 的原生函数,名为 __napi__sum
。它允许你在 JavaScript 环境中调用一个名为 sum
的函数,当 JavaScript 代码调用 sum
函数时,底层调用的将是 __napi__sum
Rust 函数。
rust
#[allow(non_snake_case)]
#[allow(clippy::all)]
unsafe fn sum_js_function(
env: napi::bindgen_prelude::sys::napi_env,
) -> napi::bindgen_prelude::Result<napi::bindgen_prelude::sys::napi_value> {
let mut fn_ptr = std::ptr::null_mut();
{
let c = napi::bindgen_prelude::sys::napi_create_function(
env,
"sum\0".as_ptr().cast(),
3usize,
Some(__napi__sum),
std::ptr::null_mut(),
&mut fn_ptr,
);
match c {
::napi::sys::Status::napi_ok => Ok(()),
_ => {
Err(
::napi::Error::new(
::napi::Status::from(c),
{
let res = ::alloc::fmt::format(
format_args!("Failed to register function `{0}`", "sum"),
);
res
},
),
)
}
}
}?;
napi::bindgen_prelude::register_js_function(
"sum\0",
sum_js_function,
Some(__napi__sum),
);
Ok(fn_ptr)
}
unsafe fn sum_js_function(...)
声明是unsafe
的,因为它调用了 FFI(外国函数接口)相关代码,这些代码可能会触碰到编译器正常安全检查之外的领域。napi_env
是一个表示当前 Node.js 环境的句柄。- 在闭包中,
napi_create_function
由napi
crate 提供,它是 N-API 的 Rust 绑定。这个函数用于创建一个新的 JavaScript 函数,该函数的名称是sum
(C 风格的字符串,以空字节结尾\0
),函数指针是Some(__napi__sum)
。 napi_create_function
返回的是napi_status
枚举,标明操作是否成功。如果操作成功,Ok(())
被返回;如果失败,则构建一个错误信息并返回Err
。?
操作符用于提前返回错误。 如果前面的调用失败返回Err
,?
将立即将错误传播出当前函数,后续操作不会执行。register_js_function
可能是另一个在当前环境中注册 JavaScript 函数的帮助器功能(不是 N-API 的一部分,可能是作者自己建立的额外封装)。- 放置到
fn_ptr
的函数指针(napi_value
)被返回,这样 JavaScript 端就能拿到并使用这个函数。
第三步,napi-rs 生成 __napi_register__sum_0
函数,在 Node.js 环境中使用 N-API 注册本地模块的功能。整段代码的功能是注册一个名为 sum
的 JavaScript 函数,并关联我们编写的 Rust 函数。
rust
#[allow(clippy::all)]
#[allow(non_snake_case)]
#[cfg(all(not(test), not(feature = "noop"), not(target_family = "wasm")))]
extern fn __napi_register__sum_0() {
napi::bindgen_prelude::register_module_export(None, "sum\0", sum_js_function);
}
第四步,napi-rs 生成 __napi_register__sum_0___rust_ctor___ctor
静态变量,用于在 Node.js 原生模块被加载时自动执行 __napi_register__sum_0
函数。这是 Rust 中创建初始化函数的一种方式,用于在动态库加载到应用程序时设置某些状态或执行某些初始化任务。
在深入解析下述代码前,重要的是要知道 Node.js 扩展通常是以 .node
扩展名出现的文件,这些文件实质上是动态链接库(DLL)。Node.js 在运行时会动态地加载这些 .node
文件并执行其中的代码。
rust
#[used]
#[allow(non_upper_case_globals)]
#[doc(hidden)]
#[link_section = "__DATA,__mod_init_func"]
static __napi_register__sum_0___rust_ctor___ctor: unsafe extern "C" fn() -> usize = {
unsafe extern "C" fn __napi_register__sum_0___rust_ctor___ctor() -> usize {
__napi_register__sum_0();
0
}
__napi_register__sum_0___rust_ctor___ctor
};
#[used]
: 这个属性是为了确保链接器在最终的程序中包含这个静态变量,即使它看起来未被使用。#[allow(non_upper_case_globals)]
: 允许这个全局静态变量命名为非大写格式,Rust 的规范是全局变量应该命名为大写格式。#[link_section = "__DATA,__mod_init_func"]
: 指定了该静态变量要放置在链接器的哪个段中。在 Mac系统中,__DATA,__mod_init_func
是用来存放模块初始化函数的链接器段。当动态库被加载时,这段里的函数会自动被执行。static __napi_register__sum_0___rust_ctor___ctor
: 定义了一个名为__napi_register__sum_0___rust_ctor___ctor
的全局静态变量,它是一个函数指针,指向下面定义的unsafe extern "C"
函数。unsafe extern "C" fn __napi_register__sum_0___rust_ctor___ctor()
: 这是一个实际的函数实现,它将由静态变量引用,并在库加载时执行。此函数调用__napi_register__sum_0()
,执行 Node.js 原生模块的初始化代码。- 函数返回0:通常用于表示成功,这是 Unix 程序的一种传统习惯。
Rust 调用 JavaScript 函数
有时,在我们的应用中,需要从 Rust 发起对 JavaScript 函数的调用,通常这种情况出现在异步操作完成后,例如执行回调函数。
使用
我们以实现一个类似于 Node.js 中的 fs.readFile()
方法为例,但更为简化。其在 JavaScript 中的类型定义如下:
js
function readFile(path: string, callback: (err: Error | null, data?: string) => void): void;
使用 napi-rs
可以轻松地用 Rust 语言编写这个函数的实现:
rust
#[napi(js_name = "readFile", ts_return_type = "void")]
pub fn read_file(env: Env, path: String, callback: JsFunction) {
// ...
}
这里的参数 callback
是 napi::JsFunction
类型,指明了它是 JavaScript 中的一个函数。
那么我们如何调用这个 JavaScript 函数呢?
同步调用
当我们在同一个线程里执行 callback
函数时,事情变得相对简单。我们可以直接利用 JsFunction
提供的 call
方法来同步执行 JavaScript 函数。
rust
fn async_read_file(path: String) -> Result<String> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
#[napi(js_name = "readFile", ts_return_type = "void")]
pub fn read_file(env: Env, path: String, callback: JsFunction) {
let contents = async_read_file(path);
match contents {
Ok(contents) => {
let js_contents = env.create_string(&contents).unwrap();
let js_null = env.get_null().unwrap();
callback.call(None, &[js_null.into_unknown(), js_contents.into_unknown()]).unwrap();
},
Err(err) => {
let js_error = env.create_error(err.into()).unwrap();
callback.call(None, &vec![js_error]).unwrap();
}
}
}
此实现为同步调用,在 Rust 中进行文件读取时会导致 JavaScript 线程停下来等待,直至读取操作完成。通常当我们使用 callback
时,希望异步执行此操作,以让 Node.js 主线程保持非阻塞状态。
异步调用
如果我们尝试将这个同步的 readFile
方法内部简单地创建一个新的线程来进行异步处理,会遇到问题:
diff
#[napi(js_name = "readFile", ts_return_type = "void")]
pub fn read_file(env: Env, path: String, callback: JsFunction) {
+ spawn(move || {
let contents = async_read_file(path);
match contents {
Ok(contents) => {
let js_contents = env.create_string(&contents).unwrap();
let js_null = env.get_null().unwrap();
callback.call(None, &[js_null.into_unknown(), js_contents.into_unknown()]).unwrap();
},
Err(err) => {
let js_error = env.create_error(err.into()).unwrap();
callback.call(None, &vec![js_error]).unwrap();
}
}
+ });
}
我们需要先回头看一下先前编写的同步调用代码,其中 read_file()
函数的第一个参数是 env: Env
,这个参数由 napi-rs
自动提供,而不是由 JavaScript 传递的。此 Env
用于在对应的 JavaScript 环境中创建传递给 callback
的参数,我们使用 create_string()
创建 JavaScript 字符串,使用 env.create_error()
或 get_null()
方法创建 JavaScript 异常。
在多线程环境中,无法直接访问 Env
,因为 Node.js 是设计为单线程运行的,你无法在另外一个线程上访问 env: Env
。
Node-API 提供了 Threadsafe Function
API 来安全地在其他线程上调用 JavaScript 函数。尽管它的实现较为复杂,很多开发者可能还不熟悉如何正确使用它,但 napi-rs
提供了一个更简洁易用的 Threadsafe Function
API 封装,我们可以使用这个封装后的版本。
rust
fn async_read_file(path: String) -> Result<String> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
#[napi(js_name = "readFile", ts_return_type = "void")]
pub fn read_file(path: String, callback: JsFunction) {
let tsfn: ThreadsafeFunction<Result<String>, ErrorStrategy::CalleeHandled> = callback
.create_threadsafe_function(0, |ctx: ThreadSafeCallContext<std::prelude::v1::Result<String, Error>>| {
match ctx.value {
Ok(value) => {
let js_contents = ctx.env.create_string(&value).unwrap();
Ok(vec![js_contents])
},
Err(err) => {
Err(err)
},
}
}).unwrap();
spawn(move || {
let contents = async_read_file(path);
tsfn.call(Ok(contents), ThreadsafeFunctionCallMode::Blocking);
});
}
当在 JavaScript 中执行 readFile()
方法,Rust 会开启一个新的线程来处理 IO 操作,避免阻塞 JavaScript 的主线程。这样,JavaScript 主线程便能无干扰地继续处理其他任务。一旦 Rust 完成文件内容的读取,它就会在该线程上触发 callback
,执行回调函数。