使用Rust开发Electron原生扩展
## 前言
我们在《基础篇:Electron 的原生能力》 章节介绍了关于如何使用 C++ 来开发 Electron 应用程序的 Node
扩展。其实除了 C++ 以外,我们还可以使用 Rust 来开发 Node 扩展。
Rust 和 C++ 虽然都可以用于开发 Node 的原生扩展,但它们有一些不同之处,其中 Rust 相对于 C++ 有一些优势:
- 内存安全性:
Rust在语言级别提供了内存安全性,通过借用检查器(Borrow Checker)可以避免常见的内存安全问题,如空指针引用、内存泄漏等。这意味着在编写Rust扩展时,更容易避免许多常见的安全漏洞。 - 性能:
Rust以及其所提供的内存安全性和零成本抽象,可以带来出色的性能。它可以通过其强大的编译器优化产生高效的机器码,这在某些情况下可能比C++更高效。 - 生态系统:虽然
C++有着长期的历史和庞大的生态系统,但Rust作为一门新兴语言,拥有逐渐壮大的社区和生态系统。它的包管理器Cargo提供了便捷的依赖管理和构建工具。
为了使用 Rust 开发 Nodejs Addon,你需要使用到 NAPI-RS 这个库。
NAPI-RS 是一个用于 Node.js Addon API(N-API)的 Rust 绑定库。通过使用 NAPI-RS,开发者可以充分利用 Rust 强大的性能和安全性,并与 Node.js 生态系统无缝集成,为 Node.js 应用程序编写高性能、高质量的原生扩展。
N-API 是 Node.js 提供的一个稳定的原生 API,允许开发者用 C、C++ 或 Rust 等编程语言编写 Node.js 的原生插件,而不会受到 Node.js 版本变化的影响。NAPI-RS 充分利用了 Rust 的特性,并提供了一种在 Rust 中编写 Node.js 原生插件的简洁而强大的方式。
| node12 | node14 | node16 | node18 | node20 | |
|---|---|---|---|---|---|
| Windows x64 | ✓ | ✓ | ✓ | ✓ | ✓ |
| Windows x86 | ✓ | ✓ | ✓ | ✓ | ✓ |
| Windows arm64 | ✓ | ✓ | ✓ | ✓ | ✓ |
| macOS x64 | ✓ | ✓ | ✓ | ✓ | ✓ |
| macOS aarch64 | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux x64 gnu | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux x64 musl | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux aarch64 gnu | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux aarch64 musl | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux arm gnueabihf | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux riscv64 gnu | N/A | N/A | ✓ | ✓ | ✓ |
| Linux aarch64 android | ✓ | ✓ | ✓ | ✓ | ✓ |
| Linux armv7 android | ✓ | ✓ | ✓ | ✓ | ✓ |
| FreeBSD x64 | ✓ | ✓ | ✓ | ✓ | ✓ |
以上看到其平台支持是很完备的,你编写的功能甚至能移植到 Android 设备上,此外还支持编译到 WASM ,在浏览器等环境中使用。
接下来,我们来一起了解一下如何通过 Rust 来开发一个可以在 Electron 中使用的 Nodejs 原生扩展程序。
相信大多数人都一样,对于
Rust并没有过多接触,但是请放心,本节不涉及Rust的任何高级特性,我会确保你读懂这里的每一段代码。本小节的实例代码都源自 Rubick 的 Rust 原生扩展:Rubick-Native
工具链安装
这里已经默认你安装了
Node.js和Electron。
- 首先我们要安装 Rustup,使用 Rustup 官方的安装器即可:
rustup 是 rust 的版本管理器,类似于 nodejs 下的 nvm。
- 安装 Rust。命令行输入以下命令即可:
rustup install nightly自动下载安装后,命令行输入 cargo ,可以看到 Rust 开发工具链已经安装完毕。
- 安装 NAPI-RS:
pnpm add -g @napi-rs/cli
# 或者
npm install -g @napi-rs/cli制作一个原生拓展
创建一个原生拓展
命令行输入:
napi new输入你的拓展名称,并选择需要支持的平台:
完成导航后,NAPI-RS 会自动写入项目文件:
我们查看目录后发现,实际上这就是一个 NPM 包:
唯一不同的是,src 目录下是 .rs 后辍的文件。
这里 .rs 后辍的文件,就是 rust 的源代码文件。
默认生成的代码我已为你简化了,这里所实现的是一个简单的加和功函数,用 #[napi] 进行标记,以暴露给 JS 调用:
// 导入依赖
use napi_derive::napi;
#[napi] // 标记该函数需要暴露给 JS 调用
pub fn sum(a: i32, b: i32) -> i32 {
return a + b;
}在 JavaScript 世界中使用原生拓展
以上我们已经在 Rust 世界里创建了一个“高效”的加和函数,那么如何在 JavaScript 世界中使用呢?
很简单,我们尝试运行编译命令:
pnpm build我们发现项目目录下多了三个文件:
.node 结尾的文件名会因平台架构和操作系统而异,这是原生拓展的“本体”,一个二进制文件。
我们在项目目录下运行 Nodejs ,并尝试直接引入该原生拓展:
刚刚在 Rust 世界里创建的“高效”的加和函数,现在能在 JS 的世界里为我们所用了。
该二进制文件无法逆向出代码,也就是说你可以将需要保护的代码例如用户认证、数据加密等逻辑“藏”在原生模块中。
那另外两个文件呢?
-
index.js:提供跨平台兼容性,识别系统架构并加载正确的二进制原生拓展。 -
index.d.ts:这个文件是 NAPI-RS 基于 Rust 代码生成的 TS 类型定义文件。
export function sum(a: number, b: number): number这样你在使用原生拓展时,体验就和使用 TypeScirpt 库一样舒畅,相关类型会在编译时自动映射并生成:
接下来,你就可以在任意 Node.js 项目或是 Electron 项目中以以上方式导入并使用该原生模块。
实战场
接下来,我们基于 Rubick-Native 代码库进行实战操练,该代码库已发布于 NPM,你可以使用 pnpm add rubick-native 下载并使用。
我们以两个实用的功能为例:
- 剪切板读取文件文本
- 键鼠监听
1. 为我们的原生拓展添加“剪切板读取文件文本”功能
Rust 生态中有以下两个涉及剪切板读取的包,且都是跨平台的:
- clipboard-files 读取剪切板文件
- copypasta 读写剪切板文本
我们要做的,就是把以上两个包的功能整合,封装成统一的函数 getClipboardContent() ,并暴露在 JS 环境中,以下是我们预想的该函数的出参描述。
出参 eg:
// type: file
{
type: 'file',
content: [
'C:/Download/test.txt',
'C:/Download/mywork',
]
}
// type: text
{
type: 'text',
content: 'hello world'
}- type: 'file' | 'text'
- content: Array | string
我们希望在 Rust 端能判断剪切版中的数据类型,如果是文件则输出 type 为 file,content 为文件路径列表,文本则输出 type 为 text,content 为剪切板文本内容。
第一步,我们根据以上涉及定义数据对象类型,在 Rust 中被称为 “结构体”:
#[napi(object)] // 标记此类型为需要与 JS “互动”的对象
pub struct ClipBoardContentJson { // 定义公开的,名称1为 ClipBoardContentJson 的结构体
#[napi(ts_type = "'file' | 'text'")] // 这里指定编译出的 TS 类型文件中 type 的类型为 'file' | 'text'
pub r#type: String, // 由于 type 是关键字,前面加上 r# 标记为字段
pub content: Vec<String>, // Vec<String> 会转换为 JS 中的 string[]
}第二步,我们在 Rust 中实现该函数:
// 获取剪切板文件或者文本
#[napi]
// Option<ClipBoardContentJson> 的意思是 get_clipboard_content 输出 ClipBoardContentJson 类型的数据或是空值(毕竟有的时候剪切板是空的)
pub fn get_clipboard_content() -> Option<ClipBoardContentJson> {
// 读取剪切板文件
let files = clipboard_files::read();
// 创建剪切板对象
let mut ctx = ClipboardContext::new().unwrap();
// Rust 中的匹配语句,可以简单理解为 JS 中的 switch 语句
match files {
// 文件读取成功了,就返回文件类型的结果给 JS 端
Ok(f) => Some(ClipBoardContentJson {
r#type: "file".to_string(),
// 这里是将 f 转换为 string[] 类型,实际编码中,代码分析器会提示你怎么写
content: f
.into_iter()
.map(|c| c.to_str().unwrap().to_string())
.collect::<Vec<String>>(),
}),
// 文件读取失败了,说明剪切板中是文本或是空值
Err(_) => {
// 读取剪切板中的内容
let content = ctx.get_contents();
match content {
// 如果是文本,那么返回结果
Ok(text) => Some(ClipBoardContentJson {
r#type: "text".to_string(),
content: vec![text],
}),
// 如果不是文本,返回空值
Err(_) => None,
}
}
}
}最终完全的代码如下:
// 导入上面提到的两个依赖
use clipboard_files;
use copypasta::{ClipboardContext, ClipboardProvider};
#[napi(object)]
pub struct ClipBoardContentJson {
#[napi(ts_type = "'file' | 'text'")]
pub r#type: String,
pub content: Vec<String>,
}
#[napi]
pub fn get_clipboard_content() -> Option<ClipBoardContentJson> {
let files = clipboard_files::read();
let mut ctx = ClipboardContext::new().unwrap();
match files {
Ok(f) => Some(ClipBoardContentJson {
r#type: "file".to_string(),
content: f
.into_iter()
.map(|c| c.to_str().unwrap().to_string())
.collect::<Vec<String>>(),
}),
Err(_) => {
let content = ctx.get_contents();
match content {
Ok(text) => Some(ClipBoardContentJson {
r#type: "text".to_string(),
content: vec![text],
}),
Err(_) => None,
}
}
}
}接下来,编译后,就可以在 JS 中使用该函数:
2. 为我们的原生拓展添加“键鼠监听”功能
键鼠监听,就是在 JS 中能够获取到鼠标或是键盘的“事件通知”,我们知道 Nodejs 的事件驱动模型中,事件是通过“回调函数”进行处理的,所以如果我们有一个键盘对象 keyboard,那么我们预想中监听键盘输入事件的代码应该是这样:
keyboard.onevent(event => {
// 对键盘输入事件进行处理
})其中,以下部分就是键盘被敲击时,在 Rust 端需要被调用的“回调函数”:
event => {
// 对键盘输入事件进行处理
}那么,我们如何在 Rust 世界中主动调用 JS 世界中的“函数”呢?
实现代码如下,完整代码见 rubick-native/src/monitor/mod.rs,我们使用 NAPI-RS 对 rdev 库进行了包装,便使 Node.js 获得了键鼠事件监听的功能。
#[napi(ts_args_type = "callback: (event: string) => void")] // 自定义回调函数类型
pub fn on_input_event(callback: JsFunction) -> Result<()> {
// 将 JS 传入的函数转换为 Rust 中的函数
let jsfn: ThreadsafeFunction<String, ErrorStrategy::Fatal> =
callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?;
// 在另一个线程中运行键鼠事件监听
spawn(|| {
if let Err(error) = listen(move |event| {
// 将事件序列化为 JSON,并作为参数传递给 JS 中的回调函数
jsfn.call(
serde_json::to_string(&event).unwrap(),
ThreadsafeFunctionCallMode::NonBlocking,
);
}) {
// 如果有错误打印在控制台
println!("Error: {:?}", error)
}
});
Ok(())
}其中,我们用到了 Rust 中 rdev 库,该库可以在 Windows、Macos、Linux 下对键鼠事件进行监听。
之后,在 JS 中,我们可以这样对该函数进行调用:
onInputEvent((event => {
// 处理键鼠输入事件
}))总结
本小节我们介绍了如何开发原生拓展来打破 Node.js/Electron 世界的“次元壁”,让你的应用获得以下能力:
- 操作系统底层指令
- 用极致性能解决计算密集型任务
- 保护关键代码的知识产权
- 功能高可移植性