教你利用rust给electron提升性能

1,557 阅读11分钟

背景

在Electron项目中,对文件进行MD5计算是一个常见的需求,特别是在处理大型文件时,计算速度的性能问题可能会对用户体验产生影响。为了提升性能,通常会尝试多种方法,包括纯Node.js实现、调用系统命令以及尝试其他语言如Rust的实现。

通过对三种方式的尝试发现rust生成md5的速度是最快的。

于是乎,便萌生了node中调用rust的想法。

实现

可以利用none和napi-rs来构建rust,用来给node访问。

对比两者,最后选择了napi-rs,文档更健全。

文档:napi.rs/cn

项目初始化

按照官网的流程,安装napi-rs,并初始化项目。

yarn global add @napi-rs/cli
# 或者
npm install -g @napi-rs/cli
# 或者
pnpm add -g @napi-rs/cli

1.新建项目

napi new

根据项目需要选择要构建的平台。

我的项目只需要5个平台和架构

  • darwin-arm64
  • darwin-x64
  • win32-x64
  • win32-ia32
  • linux-x64

所以只需要选择下面7项

"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"i686-pc-windows-msvc",
"aarch64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl"

后面按照提示选择好配置项,创建项目。

编写md5程序

找到根目录的Cargo.toml文件,需要添加一个md5依赖,在dependencies下面添加md-5 = "0.10.6"

回到根目录 src/lib.rs,开始写md5生成的函数,我们需要计算md5生成的耗时时长,代码如下:

use std::fs::File;
use std::io::{BufReader, Read};
use std::time::Instant;
use md5::{Md5, Digest};

fn main() -> std::io::Result<()> {
    let start = Instant::now();

    let file = File::open("/Users/用户/Desktop/rust/test1/test.dmg")?;
    // 增大缓冲区大小
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    let duration = start.elapsed();

    println!("MD5: {:x}", result);
    println!("计算 MD5 耗时: {:?}", duration);

    Ok(())
}

需要用File打开本地的大文件,我这里已经保存了一个dmg安装包,路径为/Users/用户/Desktop/rust/test1/test.dmg

通过File的open函数访问本地文件,并创建缓存区读取流。

最后计算并打印得出md5的值,和消耗的时长。

访问md5程序

代码写完之后,构建重新可访问的包。

执行命令:

yarn build

现在文件夹结构会多出三个文件

cool.darwin-x64.node 是 Node.js addon 二进制文件, index.js 自动生成的 JavaScript 绑定文件,它帮你从 addon 二进制中 export 出所有的东西,并且保证对 esm 与 CommonJS 的兼容。index.d.ts 是生成的 TypeScript 定义文件。

执行rust程序,输入命令:

node index.js
import test from 'ava';
import { md5 } from '../index.js';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

// 获取当前模块的文件路径和目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

test.serial('md5 应该正确计算本地文件内容的 MD5 哈希值', async (t) => {
  const tempFilePath = join(__dirname, 'temp_test_file.txt');
  const fileContent = 'hello world';
  const expected = '5eb63bbbe01eeed093cb22bb8f5acdc3';

  try {
    // 创建临时文件
    await writeFile(tempFilePath, fileContent);

    // 读取文件内容并计算 MD5
    const result = md5(tempFilePath);
    t.is(result, expected);
  } catch (error) {
    t.fail(`测试过程中出现错误: ${error.message}`);
  } finally {
    // 删除临时文件
    try {
      await unlink(tempFilePath);
    } catch (error) {
      console.warn(`删除临时文件时出错: ${error.message}`);
    }
  }
});

执行:

yarn test

electron调用rust

要将构建的.node包给electron,我们优化一下代码,去除打印内容,并将md5生成的值输出:

#![deny(clippy::all)]

#[macro_use]
extern crate napi_derive;

use std::fs::File;
use std::io::{BufReader, Read};
use md5::{Md5, Digest};

#[napi]
pub fn md5(path: String) -> napi::Result<String> {
    let file = File::open(path).map_err(|e| napi::Error::from_reason(format!("文件打开失败: {}", e)))?;
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    Ok(format!("{:x}", result))
}

重新构建:

yarn build

单平台使用

项目根目录下是根据当前平台+架构构建出来的。

我们把三个文件拷贝到electro项目中

运行electron程序,并执行md5,代码如下:(根据自己实际的electron项目进行代码导入测试)

import rustMD5 from './md5/index'

class TestWindow {

  createWindow() {
    this.window = new BrowserWindow({
      width: VIDEO_WINDOW_DEFAULT_WIDTH,
      height: VIDEO_WINDOW_DEFAULT_HEIGHT,
      frame: false,
      resizable: false,
      transparent: true,
      alwaysOnTop: false,
      titleBarStyle: "hiddenInset", // 隐藏title-bar-style样式,把操作按钮嵌入到窗口
      center: true,
      show: false,
      backgroundColor: systemThemeMap[store.get('systemTheme')],
      // parent: MainWindow.getInstance().mainWindow,
      webPreferences: {
        nodeIntegration: true,
        preload: VIDEO_WINDOW_PRELOAD_WEBPACK_ENTRY
      },
    });

    if (process.platform === "darwin") {
      // 设置左上角按钮位置
      this.window.setWindowButtonPosition({
        x: 10,
        y: 10,
      });
    }

    this.window.loadURL(VIDEO_WINDOW_WEBPACK_ENTRY);

    this.window.webContents.on("did-finish-load", () => {
      // 加载完成后显示窗口
      this.window?.show?.();
      this.window?.focus?.();

      const startTime = process.hrtime.bigint();
      
      // ----------------使用Rust生成文件的MD5值---------------------
      try {
        rustMD5.md5("/Users/用户/Desktop/rust/test1/test.dmg")
        const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
        Logger.info(`【TestWindow】⌛️ 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
      } catch (error) {
        Logger.error(`【TestWindow】 使用Rust生成文件的MD5值失败,错误信息:${error}`);
      }
    });
  }
}

执行:

pnpm dev

输出:

【TestWindow】⌛️ 生成MD5成功!总耗时:398.23 毫秒

兼容多平台

electron项目我们需要发布到多个平台,要做适配多平台。

有两个方案:

  1. 依赖模式
  2. 多平台架构包放在项目中

依赖模式

安装:

pnpm install @small-zip/md5

项目使用:

import { md5 as rustMd5 } from "@small-zip/md5"

/**
 * 使用 Rust 生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
function generateMD5(filePath: string): string {
  const startTime = process.hrtime.bigint();
  
	const res = rustMd5?.(filePath)
	
  const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
	Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
}

@small-zip/md5安装的时候会检查当前平台+架构,并下载对应的node包

多平台架构包放在项目中

安装:

把多个平台架构的包下载到本地直接引用。构建electron程序全部将其打包进去。

创建index.ts,根据当前运行的环境动态引入。

const Logger = require("electron-log")

let nativeBinding = null

try {
  nativeBinding = require(`./native_modules/md5.${process.platform}-${process.arch}.node`)
} catch (e) {
  Logger.error(`加载本地模块失败: ${e.message}`)
}

const { sum, md5 } = nativeBinding || {}

export { md5, sum }

给md5编写一个声明文件index.d.ts

/* tslint:disable */
/* eslint-disable */

/* auto-generated by NAPI-RS */

export declare function md5(path: string): string

项目使用:

import { md5 as rustMd5 } from "@/modules/rustMd5/index"

/**
 * 使用 Rust 生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
function generateMD5(filePath: string): string {
  const startTime = process.hrtime.bigint();
  
	const res = rustMd5?.(filePath)
	
  const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
	Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
}

基于我们项目架构需要,进行技术调研和测试之后,选择了方案2。

发布rust到npm

前面讲解了如何使用@small-zip/md5

现在我来发布已经创建好的项目。

注册npm账号

访问:www.npmjs.com/

选择sign up注册一个账号。

注册完成之后,点击个人头像,选择account

新建组织

找到左边的组织,点击+号,创建一个scope名称。

创建一个自己喜欢的组织名称,这里我使用组织名称是small-zip

确定好组织名称,选项Unlimited public packages共用包,这个免费的。

如果要创建私有包,则需要付费$7(土豪请随意)。

创建完毕之后回到account账户中心页面,可以看到已经多出来一个组织。

新建github仓库

打开github:github.com/

创建一个项目仓库,后面CI自动化需要用到。

修改项目名称为当前组织

接下来需要按照napi-rs官网的发布流程,将项目发布到npm中:napi.rs/cn/docs/int…

需要注意的点:

  1. repository必须要填写为前面创建的github项目仓库地址,否则CI流程会报错。
  2. 必须要创建NEW_TOKEN,否则CI流程中publish发布会没有权限。

创建NEW_TOKEN

  1. 打开npm
  2. 进入access tokens页面
  3. 选择Classic Token
  4. 输入密码,名称设置为NEW_TOKEN,全大写,拷贝生成的密钥
  5. 回到access token会看到密钥已经生成成功,并存在有效期
  6. 回到github创建的仓库
  7. 进入settings设置
  8. 找到Scerets and variables -> Actions
  9. 选择New repoository secret新增一个密钥,名称设置为NEW_TOKEN

跑通github actions

前面步骤全部做完,在项目根目录初始化git,并将代码提交到github仓库中

git init
git remote add origin git@github.com/yourname/cool.git
git add .
git commit -m "Init"
git push

在github仓库的Actions中查看CI的进度。

发布到npm

前面是一个测试 CI,让我们来发布它吧:

# 更新版本号
npm version patch
# 推送
git push --follow-tags

等待CI流程完成后,我们打开npm -> packages,就可以看到依赖包已经全部上传完成。

项目调用

CI流程执行完成,会根据我们设定的平台架构生成相应的包,可以在Release中查看

下载依赖

多平台测试

性能对比

  • nodejs
  • nodejs调用系统命令
  • nodejs调用rust

node转换文件为md5耗时情况

文件大小:253.6M

平均耗时:599.974 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:498.041 毫秒

Sky.dmg 生成MD5成功!总耗时:519.740 毫秒

Sky.dmg 生成MD5成功!总耗时:635.938 毫秒

Sky.dmg 生成MD5成功!总耗时:507.406 毫秒

Sky.dmg 生成MD5成功!总耗时:732.356 毫秒

Sky.dmg 生成MD5成功!总耗时:675.367 毫秒

Sky.dmg 生成MD5成功!总耗时:630.782 毫秒

代码实现:

/**
 * 获取文件的MD5值
 *
 * @param filePath 文件路径
 * @returns 返回文件的MD5值
 */
export async function getFileMd5(filePath: string): Promise<string> {
    const startTime = process.hrtime.bigint();
    const fileName = path.basename(filePath)
    const blockSize = await getHighWaterMarkBlockSize(filePath) // 获取合适背压值

    // 使用Node.js生成文件的MD5值
    return new Promise((resolve, reject) => {
        try {
            // 创建一个MD5哈希对象
            const hash = crypto.createHash('md5')
            // 根据文件路径创建可读流
            const stream = fs.createReadStream(filePath, { highWaterMark: blockSize })

            // 当可读流有数据可读时,触发该事件
            stream.on('data', (chunk: any) => {
                // 将数据块更新到哈希对象中
                hash.update(chunk, 'utf8');
            });
            // 当可读流读取完所有数据后,触发该事件
            stream.on('end', () => {
                // 获取哈希值的十六进制表示,即MD5值
                const md5 = hash.digest('hex');
                // 将MD5值通过Promise的resolve方法返回
                const durationMilliseconds = Number(process.hrtime.bigint() - startTime) / 1e6;
                Logger.info(`【getFileMd5】⌛️ ${fileName} 生成MD5成功!总耗时:${durationMilliseconds.toFixed(3)} 毫秒`);
                resolve(md5)
            });
            // 当可读流发生错误时,触发该事件
            stream.on('error', (err: any) => {
                Logger.error(`【getFileMd5】 获取文件 ${filePath} 的MD5值失败,错误信息:${err}`);
                reject(err)
                // 关闭可读流
                stream.destroy()
                // 关闭可读流
                stream.destroy()
            });
    } catch (error) {
            reject(error);
            Logger.error(
                `【getFileMd5】 获取文件 ${filePath} 的MD5值失败,错误信息:${error}`
            );
        }
    })
}

node调用系统命令生成md5耗时情况

文件大小:253.6M

平均耗时:522.4 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:484.683 毫秒

Sky.dmg 生成MD5成功!总耗时:507.766 毫秒

Sky.dmg 生成MD5成功!总耗时:628.693 毫秒

Sky.dmg 生成MD5成功!总耗时:523.963 毫秒

Sky.dmg 生成MD5成功!总耗时:483.997 毫秒

Sky.dmg 生成MD5成功!总耗时:503.721 毫秒

代码实现:

/**
 * 使用系统命令生成文件的 MD5 值
 * @param filePath 文件路径
 * @returns 返回文件的 MD5 值
 */
export const getFileMd5BySystemCommand = (filePath: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    let command: string;
    const currentPlatform = platform();

    switch (currentPlatform) {
      case 'win32':
        // Windows 系统使用 PowerShell 的 Get-FileHash 命令
        command = `powershell -Command "Get-FileHash -Path '${filePath}' -Algorithm MD5 | Select-Object -ExpandProperty Hash"`;
        break;
      case 'darwin':
        // Mac 系统使用 md5 命令
        command = `md5 '${filePath}'`;
        break;
      case 'linux':
        // Linux 系统使用 md5sum 命令
        command = `md5sum '${filePath}'`;
        break;
      default:
        reject(new Error(`不支持的操作系统: ${currentPlatform}`));
        return;
    }

    exec(command, (error, stdout, stderr) => {
      if (error) {
        Logger.error(`【getFileMd5BySystemCommand】 执行命令失败: ${error.message}`);
        reject(error);
        return;
      }
      if (stderr) {
        Logger.error(`【getFileMd5BySystemCommand】 命令执行错误: ${stderr}`);
        reject(new Error(stderr));
        return;
      }

      // 解析输出结果
      let md5 = stdout.trim();
      if (currentPlatform === 'linux') {
        // Linux 的 md5sum 输出格式为 "md5 文件名",需要提取 MD5 值
        md5 = md5.split(' ')[0];
      } else if (currentPlatform === 'darwin') {
        // Mac 的 md5 输出格式为 "MD5 (文件名) = md5",需要提取 MD5 值
        md5 = md5.split(' = ')[1];
      }

      resolve(md5);
    });
  });
}

rust转换文件为md5耗时情况

文件大小:253.6M

平均耗时:426.79 毫秒

测试数据:

Sky.dmg 生成MD5成功!总耗时:402.289 毫秒

Sky.dmg 生成MD5成功!总耗时:439.736 毫秒

Sky.dmg 生成MD5成功!总耗时:397.680 毫秒

Sky.dmg 生成MD5成功!总耗时:402.192 毫秒

Sky.dmg 生成MD5成功!总耗时:431.477 毫秒

Sky.dmg 生成MD5成功!总耗时:410.053 毫秒

Sky.dmg 生成MD5成功!总耗时:445.648 毫秒

Sky.dmg 生成MD5成功!总耗时:451.843 毫秒

Sky.dmg 生成MD5成功!总耗时:461.168 毫秒

代码实现:

use std::fs::File;
use std::io::{BufReader, Read};
use std::time::Instant;
use md5::{Md5, Digest};

fn main() -> std::io::Result<()> {
    let start = Instant::now();

    let file = File::open("/Users/用户/Desktop/rust/test1/test.dmg")?;
    // 增大缓冲区大小
    let mut reader = BufReader::with_capacity(65536, file);
    let mut hasher = Md5::new();
    let mut buffer = [0; 65536];

    while let Ok(n) = reader.read(&mut buffer) {
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let result = hasher.finalize();
    let duration = start.elapsed();

    println!("MD5: {:x}", result);
    println!("计算 MD5 耗时: {:?}", duration);

    Ok(())
}

小结

1.耗时

node:

  • Node.js 使用 V8 引擎,运行在 JavaScript 虚拟机上。
  • 本地模块(如 crypto 和 fs)是 C++ 实现,但调度仍有事件循环和 JS 层的额外开销。

rust:

  • Rust 编译为本地机器码,无虚拟机开销。
  • Zero-cost abstraction,内存分配和复制控制非常精细。
  • 使用 BufReader 和大缓冲区可以最大限度利用 I/O 带宽。
  • 300MB MD5 通常只需 几十到几百毫秒(视 CPU 和磁盘速度)。

2.内存占用

  • Rust
    • 流式处理:固定缓冲区(如 4KB-1MB),内存占用稳定在 1-10 MB
    • 无 GC:无垃圾回收开销,内存控制精准。
  • Node.js
    • 流式处理:默认 highWaterMark 为 16KB,内存占用约 10-50 MB
    • GC 开销:V8 垃圾回收可能短暂增加内存占用。

结论:Rust 内存占用更低且更稳定,Node.js 因 V8 引擎设计略高。

CPU使用率

  • Rust
    • 单核 100% :计算密集型任务充分利用单核性能。
    • 无运行时开销:无事件循环或 JIT 编译干扰。
  • Node.js
    • 单核 90%-100% :C++ 层计算(OpenSSL)高效,但事件循环调度有轻微损耗。
    • 上下文切换:流式读取可能触发微任务队列处理。

结论:两者均能充分利用单核,Rust 的 CPU 时间更短(因总耗时更少)。

总结

以上就是napi-rs编写rust给electron使用的完整流程。