1. 需求
今天是2024年10月21日,昨天我发了个文章,叫《我是如何在electron上调用dll的导出函数的 - koffi库》。然后昨天晚上我将其应用到项目的时候,发现一个个地去构造 结构体、函数原型 真的太浪费时间了;而且我们的dll里边有具体业务代码,代码几乎都用了std标准库的许多功能(比如 std::string、std::vector ),这些在 ffi 上也很难构造。鉴于这些情况,我又不得不重新想个新方法。
现在我想到的方法是:用 c++ 编写出一个 node 模块,这个 node 模块链接了业务dll的导出函数,然后再将此模块导入进 electron 中。
想象一下,就是 electron 和 业务dll 有了联系,而在此中间搭建这种联系的桥梁就是这个 node 模块。(也就是 中介、枢纽、媒婆)
最后我实践了下,发现这个想法确实可以!因此我打算把其中的经过过程都写下来分享。(因为这是我所想到的一种思路,以后出现了什么坑或者问题啥的,就还会回来补充的)
额外说明:把业务代码直接迁移到 node 模块上其实也是可以的,但是在这篇文章中,我们的设想是为一个已有项目 的c++代码与 electron 进行连接。如果这个 已有项目 本身足够庞大,那么直接把代码迁移到 node 模块上就会显得不太现实,而且没有必要。(至少对于我们的项目来讲,就不会这样去做)
2. 参考
node官方文档:
[C++ addons] https://nodejs.org/docs/latest/api/addons.html
---- 对应中文文档:[C++ 插件] https://nodejs.cn/api/addons.html
[C/C++ addons with Node-API] https://nodejs.org/docs/latest/api/n-api.html
---- 对应中文文档:[C/C++插件(使用 Node-API)] https://nodejs.cn/api/n-api.html
node-addon-api github仓库: https://github.com/nodejs/node-addon-api 【看这个仓库的readme】
node-addon-examples github仓库: https://github.com/nodejs/node-addon-examples
3. 思路
我们先试着调用一个MessageBox
首先在 npm 上全局安装 node-gyp(需要安装 Visual Studio IDE 和 Python):
npm install node-gyp -g
接着创建一个文件夹作为整体项目文件夹,我这里取名为"addon"
在此文件夹下用 管理员权限 打开一个cmd,输入npm init
,根据它的提示输入相应的信息,最后yes确认,在文件夹下成功生成 package.json
接着输入 npm install node-addon-api
,将 node-addon-api 安装到当前目录下
ok,现在我们分别创建源文件 module.cpp 和 binding.gyp(注意:后者文件名必须只能叫binding.gyp!!!)
//module.cpp
#include <napi.h>
#include <Windows.h>
#include <string>
Napi::Number Method(const Napi::CallbackInfo& info) {
std::wstring str = L"Hello,World";
MessageBoxW(NULL, str.c_str(), L"From addon.node", MB_OK);
Napi::Env env = info.Env();
return Napi::Number::New(env, 0);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "msg"), Napi::Function::New(env, Method));
return exports;
}
NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
binding.gyp 的内容为:
{
"targets": [
{
"target_name": "addon",
"sources": ["module.cpp"],
'dependencies': [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
]
}
]
}
其中,"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
里边的 node_addon_api_except
要看自身情况使用下面的三者之一:
- node_addon_api
- node_addon_api_except
- node_addon_api_maybe
(就我的了解,如果 node模块 里边抛出了 javascript错误,那么仅仅使用node_addon_api
的话,抛出错误后并不会终止代码运行,而是会继续执行下去。这时想要终止代码运行,就必须手动返回,即
if ( ...... ) {
Napi::Error::New(info.Env(), "node模块报错了").ThrowAsJavaScriptException();
return;
}
......
而使用了node_addon_api_except
的话,抛出错误后,代码随即将终止运行,即
if ( ...... ) {
Napi::Error::New(info.Env(), "node模块报错了").ThrowAsJavaScriptException();
}
......
想具体了解的,可直接看文档说明:https://github.com/nodejs/node-addon-api/blob/main/doc/setup.md
)
此时,addon 文件夹应该就是这个样子的了,我们回到cmd窗口,输入 node-gyp configure
并执行
执行完成后,会发现此时 addon 目录下多出了一个叫 build 的文件夹
我们进入到 build 文件夹中,打开这个sln文件,并从中找到 module.cpp
这就是我们刚刚所输入的模块的cpp代码,我们直接以Release x64的配置下编译
可以看到,我们的node模块 addon.node 编译成功了(其实这个node模块本质上就是dll,是一个pe文件)
接着,我们会把 addon.node 复制到 electron的工程文件夹下,并在electron的入口文件处调用模块的方法
在入口文件顶部导入addon.node,并取名作为addon
import addon from '../../addon.node'
然后在electron app()的准备方法中,调用模块里的msg()并将内容输出到控制台上。我们的预期结果是electron会如期弹出一个系统信息框,并在控制台中输入一个 0
console.log(addon.msg());
开始 F5 调试运行:
结果正确!我们的node模块运行成功了
4. 从node模块调用外部dll导出函数
我们简单写一个导出dll,项目名就叫 dllTest
//main.cpp
#include <Windows.h>
#include <string>
int AddFunc(int a, int b);
std::string StrAddFunc(std::string a, std::string b);
BOOL WINAPI DllMain(
HANDLE hinstDLL,
DWORD dwReason,
LPVOID lpvReserved
) {
return TRUE;
}
int AddFunc(int a, int b) {
return a + b;
}
std::string StrAddFunc(std::string a, std::string b) {
return a + b;
}
用模块定义文件来导出函数
Export.def的内容为:
LIBRARY
EXPORTS
AddFunc
StrAddFunc
这个dll将导出两个函数,分别是AddFunc
和StrAddFunc
,前者是整数相加,后者是字符串相加。
编译出两个文件,分别是 dllTest.dll
和 dllTest.lib
在 module.cpp 文件中链接这个lib文件,并将AddFunc
和StrAddFunc
的函数原型也写上来(我先把 dllTest.lib 复制到了build目录下)
//module.cpp
#include <napi.h>
#include <Windows.h>
#include <string>
//dllTest 的函数原型
int AddFunc(int a, int b);
std::string StrAddFunc(std::string a, std::string b);
//链接 dllTest.lib
#pragma comment(lib, "C:\\Users\\90965\\Desktop\\addon\\build\\dllTest.lib")
Napi::Number Method_AddFunc(const Napi::CallbackInfo& info) {
Napi::Number a = info[0].As<Napi::Number>();
Napi::Number b = info[1].As<Napi::Number>();
Napi::Env env = info.Env();
return Napi::Number::New(env, AddFunc(a, b));
}
Napi::String Method_StrAddFunc(const Napi::CallbackInfo& info) {
Napi::String a = info[0].As<Napi::String>();
Napi::String b = info[1].As<Napi::String>();
Napi::Env env = info.Env();
return Napi::String::New(env, StrAddFunc(a, b));
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "AddFunc"), Napi::Function::New(env, Method_AddFunc));
exports.Set(Napi::String::New(env, "StrAddFunc"), Napi::Function::New(env, Method_StrAddFunc));
return exports;
}
NODE_API_MODULE(NODE_GYP_MODULE_NAME, Init)
执行编译,成功得到了addon.node
(dllTest.dll是我后面再复制进来的)。此时这个addon.node
已经链接了dllTest.dll
这两个文件我们都要复制到electron工程文件夹下,而且
dllTest.dll
和 addon.node
必须在同一个目录下。
接着我们更新electron入口代码,从addon调用我们的AddFunc
和StrAddFunc
函数
console.log(addon.AddFunc(6, 8));
console.log(addon.StrAddFunc("Hello", ", World"));
开始 F5 调试运行:
运行成功了,而且控制台也成功输出了
14
Hello, World
5. vite打包问题
还是以上面的源代码为例。
如果此时我们在electron工程文件夹下直接用 npm run build:win
进行打包的话,会发现此时运行 nodeaddon.exe 程序会弹出错误,错误原因是找不到 dll 文件在哪(也就是上文的 dllTest.dll
)
根据我的排查,addon.node
是能够成功加载进去的,也就是说问题并不出在addon.node
,而是出在 dllTest.dll
。electron 不知道 dllTest.dll
在哪里,因此无法加载它,所以才爆出了这个错误。
目前我只找到了一种只针对已解包的解决办法(也就是不使用那个 setup 程序):在已解包目录下,直接将 dllTest.dll
复制进去即可。
这时候,程序就能正常运行了
我们用 x64dbg 调试工具来看一下此时electron程序已加载模块的情况
调用 dll 代码的可不是渲染器进程,而是 electron 的主进程,也就是这张图片pid为30036的那个进程。
我们附加调试进去,查看模块列表并过滤搜索是否有 dllTest.dll
找到了,这说明 dllTest.dll
此时确实已经加载到electron程序中了
6. 调试(2025年3月1日 21:44补充)
6.1. 把工程文件夹搞好看点
首先说点题外的hhh,如果你感觉生成node模块的工程文件不好看(比如下面那些..
、C:
等等奇怪的名字)
现在可以删掉上边那个文件夹 (node_modules)
工程里的C:
不要删,可以改个名字,按照里边的代码功能 我将其改名为_delay
..
是我们的主代码文件夹,肯定不要删,可以改个名字,比如src
或者source
,这里我改成src
6.2. 怎么调试node模块
因为node模块(addon.node
)的本质是dll,因此怎么调试的dll,就怎么调试的node模块
在将生成的debug版node模块复制到electron项目文件夹下并运行时,直接在Visual Studio下进行附加调试
(可以用左边的那个选择窗口
,快速找到electron的进程),找到electron的进程之后,就单击右下角的"附加"即可
之后就可以随便使用断点和单步等等调试功能啦
注意!!!!因为我的node模块是在主进程上加载的,所以我是可以使用“选择窗口”来快速找到进程的(因为"选择窗口"找到的就是主进程)。而如果你的node模块是通过预加载脚本加载到渲染器进程上的,自然就用不了"选择窗口"来快速找到进程调试,只能通过一个个调试,看有没有加载调试符号来判断 是不是加载了node模块的那个进程
6.3. 编译后实时复制
还有一点,如果你vs编译出来的新的addon.node
跟electron加载的addon.node
不一样,可能断点会不给你下,比如下面这张图
这时候你一定要记得在vs新编译出来的addon.node
要覆盖electron的addon.node
,保证两者版本是一致的。
为了方便点,你可以利用生成后事件
,编写一句copy的批处理语句并添加到生成后事件
里边
copy "D:\electron-cpp-template\addon\build\Debug\addon.node" "D:\electron-cpp-template\electron-app\addon.node" /y
上边,"D:\electron-cpp-template\addon\build\Debug\addon.node"
是vs编译出来的路径
"D:\electron-cpp-template\electron-app\addon.node"
是electron加载的路径
应用确定后,之后无论怎么编译,vs都会自动帮你把新编译出来的node模块更新到electron里边啦
7. 打包问题解决!2025/3/18 23:49
感谢 imcholl 大佬的指导:https://blog.csdn.net/yi_zongjishi/article/details/146036125
在上文中,我曾经提到过一个问题,那就是打包electron应用的时候,会存在随行的dll无法一并打包的问题,现在说说解决办法:
在你的electron目录里,应该有electron-builder.yml
这个文件
我们在其后边加上以下内容,即可完成随行文件或目录(不只是dll)的打包:
extraFiles:
- aaa.dll
- bbb.dll
- asdasasdas.txt
- dir/
- asd/
就像我这样:
最后打包即可完成啦