前言

大家都知道,在electron 里面,除了壳子,业务代码其实都是放在浏览器环境的,就和我们写reactvue项目其实没多大差别,最主要的区别主要是我们可以通过electron 能力,暴露给渲染进程一些方法,渲染进程通过和主进程交互,实现一些系统层面的能力交互。这期我们主要围绕着文件上传进行。

环境

electron ^23.1.1

Node >18.0.0

electron-builder ^23.6.0

electron-updater ^4.2.0

其实关于上传功能,可以有两个选择,渲染进程上传 | 主进程���传,但是,如果通过渲染进程上传,有几个弊端,第一,无法感知文件取消上传回调,第二,渲染进程上传可能会影响渲染进程阻塞等问题,所以,我这里选择了通过主进程里面去做文件上传。

electron最新版本中,我们可以通过preload 统一暴露给渲染进程调用主线程中的一些方法,然后主线程中注册,并监听这些方法,从而和渲染进程进行交互,主线程和渲染线程交互的方法有好几种,这里就不过多赘述了,主要围绕文件上传这一流程进行。

1,选择文件

首先,我们选择文件,这里我选择通过electrondialog模块,打开一个选择文件弹窗,然后获取到选择的文件路径,然后通过ipcRenderer.invoke调用主进程的方法,将文件路径传递给主进程,然后主进程通过fs模块读取文件,然后通过http请求上传到服务器。

 1
 2ipcMain.handle("openDialog", async (_, option) => {
 3    return  new Promise((resolve, reject) => {
 4    dialog
 5      .showOpenDialog(mainWindow, Options)
 6      .then((result) => {
 7        if (result.canceled) {
 8          reject("已取消");
 9        } else {
10          console.log("result", result);
11          
12          const filePath = result.filePaths[0];
13          const filePathName = path.extname(filePath);
14          const fileType = mime.lookup(filePath);
15          const fileName = path.basename(filePath);
16          const stats = fsv.statSync(filePath);
17          const size = coverSize(stats.size);
18          const fileinfo = {
19            fileName,
20            fileType,
21            size: stats.size,
22            filePath,
23            fileStatus: "start",
24            fileSize: size,
25            ...result,
26          };
27          resolve(fileinfo);
28        }
29      })
30      .catch((err) => {
31        console.error("openDilog==>", err);
32        reject(err);
33      });
34  });
35  });
36
 1
 2  
 3  const fileAddress = await window.electronAPI?.ipcInvoke<FileInfo>("openDialog", { properties: ["openFile"], ...(opt || {}) });
 4

2,通知主进程上传文件

选择文件后,可以在渲染进程进行展示文件信息,上传要交给主进程,主进程通过fs模块读取文件,然后通过http请求上传到服务器。首先我们第一步我们要注册上传事件,(当然,注册事件可以只注册一个事件,通过传入不同参数去匹配对应的逻辑处理,我这里就单独注册了,看需求考量)

 1
 2ipcMain.handle("uploadFile", async (_, fileInfo) => {
 3  upload(fileInfo)
 4})
 5

通过给主进程发送消息,主进程上传文件即可

 1
 2window.electronAPI?.ipcInvoke("uploadFile", {
 3      fileAddress: fileInfo.filePath,
 4      folder,
 5      id: insertId,
 6      options: {
 7        fileObsUrl,
 8        downloadKey,
 9        conversationID,
10        insertClientMsgID,
11        folder,
12        fileInfo,
13        insertId,
14      },
15    });
16

到这里,其实交互流程就已经算完结了,但是,还有两个功能没有处理,第一个就是上传进程以及上传完成后要通知渲染进程去修改状态;第二个也是最重要的,实现上传的逻辑部分

3,上传文件

上传文件,这里我们用的三方obs,相信大家都有自己的上传方式,但是有一个问题相信大家都知道,如果是一个聊天或者多任务列表,这种方式其实不能只有一个上传,会有很多的上传任务,那么,我们要考虑多个任务的情况,去并发这些任务,其实就是多线程上传,在浏览器端,也是是woker,同理,在主线程中,我们可以使用 worker_threads 去进行多线程,这是node > 12版本以后新增的功能,不是三方模块实现的,属于node原生自带功能。和 browser woker使用方法一致

 1
 2const { workerData, parentPort } = require("worker_threads");
 3const path = require("node:path");
 4const ObsClient = require("esdk-obs-nodejs");
 5
 6  function nodeUploadFiles(option, parentPort, onResumeCallback) {
 7    const { fileAddress, folder, id } = option;
 8    const fileName = path.basename(fileAddress);
 9    const fileType = path.extname(fileAddress);
10 let prePercent = 0;
11      nodeObsClient.putObject(
12        {
13          Bucket: PCIMFOLDER,
14          Key: `${ENV}/${folder}${fileName}`,
15          SourceFile: fileAddress,
16          ProgressCallback: (transferredAmount, totalAmount, totalSeconds) => {
17            const p = (transferredAmount * 100) / totalAmount;
18            const percent = Math.round(p);
19            if (prePercent !== percent) {
20              parentPort.postMessage({ type: "percent", value: { percent, status: "starting", ...workerData }, id });
21              prePercent = percent;
22            }
23          },
24          ResumeCallback: (resumeHook) => {
25            hook = resumeHook;
26          },
27        },
28        (err, data) => {
29          if (err) {
30            
31            parentPort?.postMessage({ type: "percent", value: { status: "errorFile", percent: 0, ...workerData }, id: workerData?.id, error: err });
32
33            reject(err);
34          } else {
35            
36            console.info(`upload=>complete`);
37            const newData = {
38              url: `${obsUrl}${ENV}/${folder}${fileName}`,
39              code: data.CommonMsg.Status,
40              Message: data.CommonMsg.Message,
41              ...data,
42            };
43            parentPort.postMessage({ type: "percent", value: { percent: 100, status: "completed", ...workerData }, id });
44          }
45        }
46      );
47  }
48
49handleUpload(workerData);
50
 1
 2import { Worker } from "node:worker_threads";
 3
 4const runWorkers = (workerDatas) => {
 5  return new Promise(() => {
 6    const workerPath1 = path.resolve(__dirname, "./worker.js");
 7    const worker = new Worker(workerPath, { workerData: { ...workerDatas, resourcesPath } });
 8   
 9    const taskHandle = (worker, option) => {
10      worker?.postMessage(option);
11    };
12    const onExitCallback = () => {
13      global.globalEmitter.off(workerDatas.id, taskHandle);
14    };
15    
16    worker.on("message", onMessageCallback);
17    
18    worker.on("error", onErrorCallback);
19    
20    worker.on("exit", onExitCallback);
21  });
22};
23
24

到这里,最简单版的一个文件上传功能就完成了,但是,还有两个问题,一个是断点续传,一个是上传失败后,需要重新上传,这里就自己实现一下,方法大致就是记录一下断点的位置,然后下次上传的时候,从断点处开始上传,这里就不贴代码了,调研一下obs的文档应该都可以找到

最后

其实写到这里,只是引出了通过worker去上传这个功能,并没有实现完整的链路

  1. woker本身是一个线程,但是重复开关导致线程资源浪费,所以,我们要实现一个线程池,去根据cups数量,动态去扩展我们woker的数量;
  2. woker如果通过线程池去动态扩展,那么,woker里面的任务,其实也需要任务池,动态去给每一个woker去分发任务,这里其实就是一个任务队列,每次根据woker的运行状态去分发任务;
  3. 不知道大家有没有遇到过,在electron- build 打包后使用 worker_threads 的时候,woker文件里面三方包路径会有问题,而且,在new woker的地方传入的woker文件的path也是有问题的

等后面有空了补上,如果有错误,希望大家可以指正。

个人笔记记录 2021 ~ 2025