背景
在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项目我们需要发布到多个平台,要做适配多平台。
有两个方案:
- 依赖模式
- 多平台架构包放在项目中
依赖模式
安装:
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账号
选择sign up注册一个账号。
注册完成之后,点击个人头像,选择account
新建组织
找到左边的组织,点击+号,创建一个scope名称。
创建一个自己喜欢的组织名称,这里我使用组织名称是small-zip。
确定好组织名称,选项Unlimited public packages共用包,这个免费的。
如果要创建私有包,则需要付费$7(土豪请随意)。
创建完毕之后回到account账户中心页面,可以看到已经多出来一个组织。
新建github仓库
打开github:github.com/
创建一个项目仓库,后面CI自动化需要用到。
修改项目名称为当前组织
接下来需要按照napi-rs官网的发布流程,将项目发布到npm中:napi.rs/cn/docs/int…
需要注意的点:
- repository必须要填写为前面创建的github项目仓库地址,否则CI流程会报错。
- 必须要创建NEW_TOKEN,否则CI流程中publish发布会没有权限。
创建NEW_TOKEN
- 打开npm
- 进入access tokens页面
- 选择Classic Token
- 输入密码,名称设置为NEW_TOKEN,全大写,拷贝生成的密钥
- 回到access token会看到密钥已经生成成功,并存在有效期
- 回到github创建的仓库
- 进入settings设置
- 找到Scerets and variables -> Actions
- 选择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使用的完整流程。