之前在Python的GUI编写上有过一些简单的尝试,后来想到结合FastAPI的后端,自己再写一个前端熟悉一下HTML5/CSS/JS三件套,又听说Electron是Web套皮做GUI的不错的选择,所以也做了一些尝试,但是遇到了不少的坑。老实说,有些地方的文档实在是写了和没写差不多……

下面在假定已经完成了Electron的安装,写好了Web页面的基础上,介绍一些经验。

基于Napi实现C++和Node.js交互

最初写好的Web的核心逻辑完全依赖Web上的API,而这一部分我已经写过C++的实现,所以自然地想到在Node.js里调用C++,然后向Web页面提供这部分功能。

最开始搜到的实现方法是node-ffi,这个模块提供了和C++编写的动态链接库的交互,具体用法和Python的ctypes大同小异,也是注册接口,定义输入参数和返回值的数据类型,做好转换就完成了。遗憾的是,这个模块在2018年就已经停止维护,貌似并不支持新版的Node,不能通过编译,而我也不愿意专门为此倒退版本,于是作罢。

在这之后,就只有在C++中编写原生接口的办法了。当然这里也有两种选择,一种是直接基于v8,一种是相对简单一些的Napi,我没有时间精力慢慢研究v8的用法,就用了相对简单的Napi。就是在这一步,我被官方文档折磨了很久,还是通过网上各种代码片段才悟出了逻辑。当然也可能是因为我太菜,毕竟确实没有系统学习过C++,都是现查现用。

下面贴上一小段代码,作为最小可用的简单实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <napi.h>
#include "crpyt.h"

Napi::Buffer<char> Encrypt(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env(); //取出上下文
Napi::Buffer<char> arr = info[0].As<Napi::Buffer<char>>();
unsigned long long length = arr.ByteLength();
char* code = encrypt(arr.Data(), length);
unsigned long long outlen = strlen(code);
Napi::Buffer<char> output = Napi::Buffer<char>::New(env, outlen);
for (unsigned long long i = 0; i < outlen; i++) output[i] = code[i];
release(code);
return output;
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "encrypt"), Napi::Function::New(env, Encrypt));
return exports;
}

NODE_API_MODULE(test, Init)

首先是引入Napi的头文件,这个没什么可说的。

接下来是Encrypt函数,这里包含了我的核心逻辑,也是要供nodejs调用的方法。这里接受的传入参数info既包含了调用时的上下文,可以通过info.Env()获取,也可以通过索引取出nodejs调用时具体传入的参数。

传入参数在Napi中的数据类型都是Napi::Value,这是Napi里大部分数据的基类。具体使用时,就涉及类型转换的问题,所有继承自Napi::Value的类都有一个.As<T>()的数据转换方法,可以转换成和JS对应的具体数据类型。在这里,我编写的接口接受的是一段字符串编码成的Buffer,所以转换成Napi::Buffer<char>。这里Napi::Buffer是一个模板类,而我希望按char的方式取值。

对于这一类数据,可以通过.ByteLength()获取大小,也可以通过.Data()获取包含了数据的指针。其他使用较多的Napi::String也有一些比较简单的用法,比如转成UTF-8编码的char字符串,这里就不作赘述了。

输出时,自然也要返回特定的nodejs数据类型。在上面的实例代码中,我使用了New(env, size_t length)的方式构建,当然这个方法有很多其他的重载,都可以在文档中一一查到。注意:虽然New确实可以直接接受包含数据的指针,但意外的是,构建之后数据似乎并没有复制到新建的类里面,所以在这里我又不得不手动复制了一遍。

接下来是相对容易解决,不需要改动的地方。Init函数里规定了要导出的方法及其命名,最后再通过NODE_API_MODULE(name, Init)完成导出。

到这里发现,其实这个过程还是比较顺畅的,我主要是在查找用法的时候遇到了困难。

模块的编译

这里是一个大坑,也是我最痛苦的地方。用C++编写完接口之后,就要用node-gyp编译成nodejs中可调用的二进制文件。一般把node-gyp安装为全局模块:

1
npm install -g node-gyp

在Windows上,node-gyp使用时依赖Visual Studio Build ToolsPython,分别用作编译的部分和生成项目文件的部分。注意是否添加了环境变量。

首先写好编译的配置文件bindings.gyp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"targets": [
{
"target_name":"test",
"sources":["source.cpp"],
"include_dirs":[
"<!@(node -p \"require('node-addon-api').include\")",
"C:/MinGW/mingw64/opt/include"
],
"conditions": [
["OS=='win'",
{
"libraries": [
'C:/Perl/c/lib/libcrypto.a',
'C:/Perl/c/lib/libssl.a'
]
}]
],
"libraries": [],
"dependencies":[
"<!(node -p \"require('node-addon-api').gyp\")"
],
"cflags!":["-fno-exceptions"],
"cflags_cc!":["-fno-exceptions"],
'defines':['NAPI_DISABLE_CPP_EXCEPTIONS']
}
]
}

大部分键值对都是字面意思,别的地方也很容易找到,这里主要讲我自己遇到的问题。我的源码用到了openssl库里的加密算法,而MSBuild使用的编译器默认包含的头文件里并没有openssl,所以我只好在include_dirs里加入了自己安装了MinGW里的头文件目录。另外,openssl使用的时候还会依赖对应的链接库,这也需要手动添加对应的路径。我尝试过添加dll路径,但是编译时无法识别,最后只能添加了Strawberry Perl里的静态链接库libcrypto.alibssl.a,见示例配置文件的conditions字段。实际上node-gyp编译的时候本身貌似可以调用环境变量中的动态链接库,但是后续重新编译的时候就不行了,这是最困扰我的部分。

完成后,使用node-gyp生成VS的项目文件并编译:

1
node-gyp configure rebuild

值得注意的是,这个MSBuild对编码的支持非常申必,它识别不了不带BOM的UTF-8源码,但是编译之后,里面用到的汉字常量又变成了GBK编码,令我困惑无比。

编译完成后,在纯nodejs中很容易引入:

1
2
const test = require('bindings')('test')
console.log(test.Encrypt(buffer))

Electron再编译

然后问题又来了,虽然在纯nodejs中可以正常运行,但是上面的代码拿到Electron里面运行又会报错。在网上查到,需要用到Electron配套的工具重新编译一遍才能供Electron正常使用。这里用到的模块是electron-rebuild,安装方法类似,使用时要执行目录下的二进制文件:

1
./node_modules/.bin/electron-rebuild.cmd

可以看到,这里不需要额外的参数。上面提到的链接库的问题其实也主要是在这一步遇到的。

完成编译之后,就可以在Electron内正常调用了。

Electron跨进程通信极简用法

最后再简单讲讲Electron主进程和渲染进程的交互。在最开始的地方我已经提到,我所做的的工作是要把处理信息的核心逻辑移到nodejs中,通过调用C++模块的方式实现,这里就涉及到了ipc的一些基本用法。

首先,在Web页面的js文件中引入ipc模块:

1
const ipc = require('electron').ipcRenderer

发送信息也非常简单:

1
2
//send(name, data)
ipc.send('renderer-request', {'msg': 'test', 'en': 1})

在主进程中要引入另一个对应的模块,使用时监听对应信道:

1
2
3
4
const ipc = require('electron').ipcMain
ipc.on('renderer-request', function (event, arg) {
event.sender.send('renderer-response', {'msg': 'ok'})
}

注意在向渲染进程传回信息时,可以通过event.sender原路返回对应的进程。