你难道不好奇,napi-rs 如何让 Rust 与 JavaScript 可以相互调用

序言

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__>()
            })
    }
}
  1. extern "C" 表示这个函数使用 C 语言的调用约定,它告诉编译器生成的函数应与外部 C 代码或其他语言兼容,这是创建 Node.js 原生模块所必需的。
  2. 函数签名:定义了函数的参数和返回类型。根据 N-API 的要求,参数包括环境句柄 napi_env 和回调信息 napi_callback_info。返回类型是 napi_value,这表示了 JavaScript 值。
  3. unsafe 块:因为涉及到 FFI (Foreign Function Interface 外部函数接口) 与 Node.js 的交互,所以需要在 unsafe 代码块中运行。
  4. CallbackInfo::<2usize>:一个帮助器结构,封装了回调信息,并知道期望两个参数。
  5. 参数提取:使用 from_napi_value 方法将 JavaScript 参数转换为 Rust 的 i32 类型。
  6. 计算和返回:调用 Rust 中的 sum 函数,并使用 to_napi_value 方法将结果转换回 JavaScript 可以识别的值。
  7. 错误处理:如果在参数转换或函数执行过程中发生错误,通过 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)
}
  1. unsafe fn sum_js_function(...) 声明是 unsafe 的,因为它调用了 FFI(外国函数接口)相关代码,这些代码可能会触碰到编译器正常安全检查之外的领域。
  2. napi_env 是一个表示当前 Node.js 环境的句柄。
  3. 在闭包中,napi_create_functionnapi crate 提供,它是 N-API 的 Rust 绑定。这个函数用于创建一个新的 JavaScript 函数,该函数的名称是 sum(C 风格的字符串,以空字节结尾 \0),函数指针是 Some(__napi__sum)
  4. napi_create_function 返回的是 napi_status 枚举,标明操作是否成功。如果操作成功,Ok(()) 被返回;如果失败,则构建一个错误信息并返回 Err
  5. ? 操作符用于提前返回错误。 如果前面的调用失败返回 Err? 将立即将错误传播出当前函数,后续操作不会执行。
  6. register_js_function 可能是另一个在当前环境中注册 JavaScript 函数的帮助器功能(不是 N-API 的一部分,可能是作者自己建立的额外封装)。
  7. 放置到 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
};
  1. #[used]: 这个属性是为了确保链接器在最终的程序中包含这个静态变量,即使它看起来未被使用。
  2. #[allow(non_upper_case_globals)]: 允许这个全局静态变量命名为非大写格式,Rust 的规范是全局变量应该命名为大写格式。
  3. #[link_section = "__DATA,__mod_init_func"]: 指定了该静态变量要放置在链接器的哪个段中。在 Mac系统中,__DATA,__mod_init_func 是用来存放模块初始化函数的链接器段。当动态库被加载时,这段里的函数会自动被执行。
  4. static __napi_register__sum_0___rust_ctor___ctor: 定义了一个名为 __napi_register__sum_0___rust_ctor___ctor 的全局静态变量,它是一个函数指针,指向下面定义的 unsafe extern "C" 函数。
  5. unsafe extern "C" fn __napi_register__sum_0___rust_ctor___ctor(): 这是一个实际的函数实现,它将由静态变量引用,并在库加载时执行。此函数调用 __napi_register__sum_0(),执行 Node.js 原生模块的初始化代码。
  6. 函数返回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) {
    // ...
}

这里的参数 callbacknapi::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,执行回调函数。

参考资料

相关推荐
小tenten1 小时前
js延迟for内部循环方法
开发语言·前端·javascript
幻影浪子1 小时前
Web网站常用测试工具
前端·测试工具
我的运维人生1 小时前
JavaScript在网页设计中的应用案例
开发语言·javascript·ecmascript·运维开发·技术共享
暮志未晚Webgl1 小时前
94. UE5 GAS RPG 实现攻击击退效果
java·前端·ue5
二川bro1 小时前
Vue2 和 Vue3 区别 — 源码深度解析
前端
软件技术NINI2 小时前
vue组件通信,点击传值,动态传值(父传子,子传父)
前端·javascript·vue.js
生活真难2 小时前
node解析dxf文件
javascript·arcgis·node.js
暖锋丫2 小时前
echarts实现湖南省地图并且定时轮询
前端·javascript·echarts
余生逆风飞翔2 小时前
前端代码上传文件
开发语言·前端·javascript
weixin_mouren3 小时前
3.2 Upload源码分析 -- ant-design-vue系列
前端·javascript·vue.js·anti-design-vue