关于electron:基于Electron的smb客户端文件上传优化探索

4次阅读

共计 18419 个字符,预计需要花费 47 分钟才能阅读完成。

>> 博客原文链接

I 前言


上一篇文章《基于 Electron 的 smb 客户端开发记录》,大抵形容了整个 SMB 客户端开发的外围性能、实现难点、我的项目打包这些内容,这篇文章呢独自把其中的 文件分片上传模块 拿进去进行分享,提及一些与 Electron 主过程、渲染过程和文件上传优化相干的性能点。

II Demo 运行


我的项目精简版 DEMO 地址,删除了 smb 解决的多余逻辑,应用文件复制模仿上传流程,可间接运行体验。

demo 运行时须要别离开启两个开发环境 view -> service,而后能力预览界面,因为没有后端,文件默认上传 (复制) 到 electron 数据目录(在 Ubuntu 上是~/.config/FileSliceUpload/runtime/upload)

# 进入 view 目录
$: npm install
$: npm start
# 进入 service 目录
$: npm install
$: npm start

III Electron 过程架构


主过程和渲染过程的区别

Electron 运行 package.json 的 main 脚本的过程被称为主过程。在主过程中运行的脚本通过创立 web 页面来展现用户界面,一个 Electron 利用总是有且只有一个主过程。
主过程应用 BrowserWindow 实例创立页面,每个 BrowserWindow 实例都在本人的渲染过程里运行页面,当一个 BrowserWindow 实例被销毁后,相应的渲染过程也会被终止。
主过程治理所有的 web 页面和它们对应的渲染过程,每个渲染过程都是独立的,它只关怀它所运行的 web 页面。

在一般的浏览器中,web 页面通常在沙盒环境中运行,并且无法访问操作系统的原生资源。然而 Electron 的用户在 Node.js 的 API 反对下能够在页面中和操作系统进行一些底层交互。
在页面中调用与 GUI 相干的原生 API 是不被容许的,因为在 web 页面里操作原生的 GUI 资源是十分危险的,而且容易造成资源泄露。如果你想在 web 页面里应用 GUI 操作,其对应的渲染过程必须与主过程进行通信,申请主过程进行相干的 GUI 操作。

主过程和渲染过程之间的通信

1/2- 自带办法,3- 内部扩大办法

1. 应用 remote 近程调用

remote 模块为渲染过程和主过程通信提供了一种简略办法,应用 remote 模块, 你能够调用 main 过程对象的办法, 而不用显式发送过程间音讯。示例如下,代码通过 remote 近程调用主过程的 BrowserWindows 创立了一个渲染过程,并加载了一个网页地址:

/* 渲染过程中(web 端代码) */
const {BrowserWindow} = require('electron').remote
let win = new BrowserWindow({width: 800, height: 600})
win.loadURL('https://github.com')

留神:remote 底层是基于 ipc 的同步过程通信(同步 = 阻塞页面),都晓得 Node.js 的最大个性就是异步调用,非阻塞 IO,因而 remote 调用不适用于主过程和渲染过程频繁通信以及耗时申请的状况,否则会引起重大的程序性能问题。

2. 应用 ipc 信号通信

基于事件触发的 ipc 双向信号通信,渲染过程中的 ipcRenderer 能够监听一个事件通道,也能向主过程或其它渲染过程间接发送音讯(须要晓得其它渲染过程的 webContentsId),同理主过程中的 ipcMain 也能监听某个事件通道和向任意一个渲染过程发送音讯。

/* 主过程 */
ipcMain.on(channel, listener) // 监听信道 - 异步触发
ipcMain.once(channel, listener) // 监听一次信道,监听器触发后即删除 - 异步触发
ipcMain.handle(channel, listener) // 为渲染过程的 invoke 函数设置对应信道的监听器
ipcMain.handleOnce(channel, listener) // 为渲染过程的 invoke 函数设置对应信道的监听器,触发后即删除监听
browserWindow.webContents.send(channel, args); // 显式地向某个渲染过程发送信息 - 异步触发


/* 渲染过程 */
ipcRenderer.on(channel, listener); // 监听信道 - 异步触发
ipcRenderer.once(channel, listener); // 监听一次信道,监听器触发后即删除 - 异步触发
ipcRenderer.sendSync(channel, args); // 向主过程一个信道发送信息 - 同步触发
ipcRenderer.invoke(channel, args); // 向主过程一个信道发送信息 - 返回 Promise 对象期待触发
ipcRenderer.sendTo(webContentsId, channel, ...args); // 向某个渲染过程发送音讯 - 异步触发
ipcRenderer.sendToHost(channel, ...args) // 向 host 页面的 webview 发送音讯 - 异步触发

3. 应用 electron-re 进行多向通信

electron-re 是之前开发的一个解决 electron 过程间通信的工具,曾经公布为 npm 组件。次要性能是在 Electron 已有的 Main Process 主过程 和 Renderer Process渲染过程概念的根底上独立出一个独自的 ==service== 逻辑。service 即不须要显示界面的后盾过程,它不参加 UI 交互,独自为主过程或其它渲染过程提供服务,它的底层实现为一个容许 node 注入remote 调用 的渲染窗口过程。

比方在你看过一些 Electron最佳实际 中,消耗 cpu 的操作是不倡议被放到主过程中解决的,这时候咱们就能够将这部分消耗 cpu 的操作编写成一个独自的 js 文件,而后应用 service 构造函数以这个 js 文件的地址 path 为参数结构一个 service 实例,并通过 electron-re 提供的 MessageChannel 通信工具在主过程、渲染过程、service 过程之间任意发送音讯,能够参考以下示例代码:

  • 1)main process
const {
  BrowserService,
  MessageChannel // must required in main.js even if you don't use it
} = require('electron-re');
const isInDev = process.env.NODE_ENV === 'dev';
...

// after app is ready in main process
app.whenReady().then(async () => {const myService = new BrowserService('app', 'path/to/app.service.js');
    const myService2 = new BrowserService('app2', 'path/to/app2.service.js');

    await myService.connected();
    await myService2.connected();

    // open devtools in dev mode for debugging
    if (isInDev) myService.openDevTools();
    // send data to a service - like the build-in ipcMain.send
    MessageChannel.send('app', 'channel1', { value: 'test1'});
    // send data to a service and return a Promise - extension method
    MessageChannel.invoke('app', 'channel2', { value: 'test1'}).then((response) => {console.log(response);
    });
    // listen a channel, same as ipcMain.on
    MessageChannel.on('channel3', (event, response) => {console.log(response);
    });

    // handle a channel signal, same as ipcMain.handle
    // you can return data directly or return a Promise instance
    MessageChannel.handle('channel4', (event, response) => {console.log(response);
      return {res: 'channel4-res'};
    });
});
  • 2)app.service.js
const {ipcRenderer} = require('electron');
const {MessageChannel} = require('electron-re');

// listen a channel, same as ipcRenderer.on
MessageChannel.on('channel1', (event, result) => {console.log(result);
});

// handle a channel signal, just like ipcMain.handle
MessageChannel.handle('channel2', (event, result) => {console.log(result);
  return {response: 'channel2-response'}
});

// send data to another service and return a promise , just like ipcRenderer.invoke
MessageChannel.invoke('app2', 'channel3', { value: 'channel3'}).then((event, result) => {console.log(result);
});

// send data to a service - like the build-in ipcRenderer.send
MessageChannel.send('app', 'channel4', { value: 'channel4'});
  • 3)app2.service.js
// handle a channel signal, just like ipcMain.handle
MessageChannel.handle('channel3', (event, result) => {console.log(result);
  return {response: 'channel3-response'}
});
// listen a channel, same as ipcRenderer.once
MessageChannel.once('channel4', (event, result) => {console.log(result);
});
// send data to main process, just like ipcRenderer.send
MessageChannel.send('main', 'channel3', { value: 'channel3'});
// send data to main process and return a Promise, just like ipcRenderer.invoke
MessageChannel.invoke('main', 'channel4', { value: 'channel4'});
  • 4)renderer process window
const {ipcRenderer} = require('electron');
const {MessageChannel} = require('electron-re');
// send data to a service
MessageChannel.send('app', ....);
MessageChannel.invoke('app2', ....);
// send data to main process
MessageChannel.send('main', ....);
MessageChannel.invoke('main', ....);

IV 文件上传架构


文件上传次要逻辑管制局部是前端的 JS 脚本代码,位于主窗口所在的 render 渲染过程,负责用户获取系统目录文件、生成上传工作队列、动静展现上传工作列表详情、工作列表的增删查改等;主过程 Electron 端的 Node.js 代码次要负责响应 render 过程的管制命令进行文件上传工作队列数据的增删查改、上传工作在内存和磁盘的同步、文件系统的交互、零碎原生组件调用等。

文件上传源和上传指标

  • 在用户界面上应用 Input 组件获取到的 FileList(HTML5 API,用于 web 端的简略文件操作)即为上传源;
  • 上传指标地址是远端集群某个节点的 smb 服务,因为 Node.js NPM 生态对 smb 的反对无限,目前并未发现一个能够反对通过 smb 协定进行文件分片上传的 npm 库,所以思考应用 Node.js 的 FS API 进行文件分段读取而后将分片数据逐渐增量写入指标地址来模仿文件分片上传过程,从而实现在界面上单个大文件上传工作的启动、暂停、终止和续传等操作,所以这里的解决方案是应用 Windows UNC 命令连贯后端共享后,能够像拜访本地文件系统一样拜访近程一个近程 smb 共享门路,比方文件门路 \\[host]\[sharename]\file1 上的 file1 在执行了 unc 连贯后就能够通过 Node.js FS API 进行操作,跟操作本地文件完全一致。整个必须依赖 smb 协定的上传流程即精简为将本地拿到的文件数据复制到能够在本地拜访的另一个 smb 共享门路这一流程,而这所有都得益于 Windows UNC命令。
/* 应用 unc 命令连贯近程 smb 共享 */
_uncCommandConnect_Windows_NT({host, username, pwd}) {const { isThirdUser, nickname, isLocalUser} = global.ipcMainProcess.userModel.info;
    const commandUse = `net use \\\\${host}\\ipc$ "${pwd}" /user:"${username}"`;
    return new Promise((resolve) => {this.sudo.exec(commandUse).then((res) => {
        resolve({code: 200,});
      }).catch((err) => {
        resolve({
          code: 600,
          result: global.lang.upload.unc_connection_failed
        });
      });
    });
  }

上传流程概述

下图形容了整个前端局部的管制逻辑:

  1. 页面上应用 <Input /> 组件拿到 FileList 对象 (Electron 环境下拿到的 File 对象会额定附加一个path 属性指明文件位于零碎的绝对路径)
  2. 缓存拿到的 FileList,期待点击上传按钮后开始读取 FileList 列表并生成自定义的 File 文件对象数组用于存储上传工作列表信息
  3. 页面调用 init 申请附带上选中的文件信息初始化文件上传工作
  4. Node.js 拿到 init 申请附带的文件信息后,将所有信息存入长期寄存在内存中的文件上传列表中,并尝试关上待上传文件的文件描述符用于行将开始的文件切片分段上传工作,最初返回给页面上传工作 ID,Node.js 端实现初始化解决
  5. 页面拿到 init 申请胜利的回调后,存储返回的上传工作 ID,并将该文件退出文件待上传队列,在适合的机会开始上传,开始上传的时候向 Node.js 端发送 upload 申请,同时申请附带上工作 ID 和以后的分片索引值(示意须要上传第几个文件分片)
  6. Node.js 拿到 upload 申请后依据携带的工作 ID 读取内存中的上传工作信息,而后应用第二步关上的文件描述符和分片索引对本地磁盘中的指标文件进行分片切割,最初应用 FS API 将分片递增写入指标地位,即本地可间接拜访的 SMB 共享门路
  7. upload 申请胜利后页面判断是否曾经上传完所有分片,如果实现则向 Node.js 发送 complete 申请,同时携带上工作 ID
  8. Node.js 依据工作 ID 获取文件信息,敞开文件描述符,更新文件上传工作为上传实现状态
  9. 界面上传工作列表全副实现后,向后端发送 sync 申请,把当前任务上传列表同步到历史工作 (磁盘存储) 中,表明以后列表中所有工作曾经实现
  10. Node.js 拿到 sync 申请后,把内存中存储的所有文件上传列表信息写入磁盘,同时开释内存占用,实现一次列表工作上传

Node.js 实现的文件分片治理工厂

  • 文件初始化的时候调用 open 办法长期存储文件描述符和文件绝对路径的映射关系;
  • 文件上传的时候调用 read 办法依据文件读取地位、读取容量大小进行分片切割;
  • 文件上传实现的时候调用 close 敞开文件描述符;

三个办法均通过文件绝对路径 path 参数建设关联:

/**
  * readFileBlock [读取文件块]
  */
exports.readFileBlock = () => {const fdStore = {};
  const smallFileMap = {};

  return {
    /* 关上文件描述符 */
    open: (path, size, minSize=1024*2) => {return new Promise((resolve) => {
        try {
          // 小文件不关上文件描述符,间接读取写入
          if (size <= minSize) {smallFileMap[path] = true;
            return resolve({
              code: 200,
              result: {fd: null}
            });
          }
          // 关上文件描述符,倡议绝对路径和 fd 的映射关系
          fs.open(path, 'r', (err, fd) => {if (err) {console.trace(err);
              resolve({
                code: 601,
                result: err.toString()});
            } else {fdStore[path] = fd;
              resolve({
                code: 200,
                result: {fd: fdStore[path]
                }
              });
            }
          });
        } catch (err) {console.trace(err);
          resolve({
            code: 600,
            result: err.toString()});
        }
      })
    },
  
    /* 读取文件块 */
    read: (path, position, length) => {return new Promise((resolve, reject) => {const callback = (err, data) => {if (err) {
            resolve({
              code: 600,
              result: err.toString()});
          } else {
            resolve({
              code: 200,
              result: data
            });
          }
        };
        try {
          // 小文件间接读取,大文件应用文件描述符和偏移量读取
          if (smallFileMap[path]) {fs.readFile(path, (err, buffer) => {callback(err, buffer);
            });
          } else {
            // 空文件解决
            if (length === 0) return callback(null, '');
            fs.read(fdStore[path], Buffer.alloc(length), 0, length, position, function(err, readByte, readResult){callback(err, readResult);
            });
          }
        } catch (err) {console.trace(err);
          resolve({
            code: 600,
            result: err.toString()});
        }
      });
    },

    /* 敞开文件描述符 */
    close: (path) => {return new Promise((resolve) => {
        try {if (smallFileMap[path]) {delete smallFileMap[path];
            resolve({code: 200});
          } else {fs.close(fdStore[path], () => {resolve({code: 200});
              delete fdStore[path];
            });
          }
        } catch (err) {console.trace(err);
          resolve({
            code: 600,
            result: err.toString()});
        }
      });
    },

    fdStore

  }

}

V 基于 Electron 的文件上传卡顿优化踩坑


优化是一件头大的事儿,因为你须要先通过很多测试手法找到现有代码的性能瓶颈,而后编写优化解决方案。我感觉找到性能瓶颈这一点就特地难,因为是本人写的代码所以容易陷入一些先入为主的刻板思考模式。不过最最次要的一点还是你如果本人都弄不分明你应用的技术栈的话,那就无从谈起优化,所以后面有很大篇幅剖析了 Electron 过程方面的常识以及梳理了整个上传流程。

应用 Electron 自带的 Devtools 进行性能剖析

在文件上传过程中关上性能检测工具 Performance 进行录制,剖析整个流程:

在文件上传过程中关上内存工具 Memory 进行快照截取剖析一个时刻的内存占用状况:

第一次尝试解决问题:替换 Antd Table 组件

在编写实现文件上传模块后,初步进行了压力测试,后果发现增加 1000 个文件上传工作到工作队列,且同时上传的文件上传工作数量为 6 时,高低滑动查看文件上传列表时呈现了卡顿的状况,这种卡顿不局限于某个界面组件的卡顿,而且以后窗口的所有操作都卡了起来,初步狐疑是 Antd Table 组件引起的卡顿,因为 Antd Table 组件是个很简单的高阶组件,在解决大量的数据时可能会有性能问题,遂我将 Antd Table 组件换成了原生的 table 组件,且 Table 列表只显示每个上传工作的工作名,其余的诸如上传进度这些都不予显示,从而想避开这个问题。令人吃惊的是测试后果是即便换用了原生 Table 组件,卡顿状况依然毫无改善!

第二次尝试解决问题:革新 Electron 主进程同步阻塞代码

先看下 chromium 的架构图,每个渲染过程都有一个全局对象 RenderProcess,用来治理与父浏览器过程的通信,同时保护着一份全局状态。浏览器过程为每个渲染过程保护一个 RenderProcessHost 对象,用来治理浏览器状态和与渲染过程的通信。浏览器过程和渲染过程应用 Chromium 的 IPC 零碎进行通信。在 chromium 中,页面渲染时,UI 过程须要和 main process 一直的进行 IPC 同步,若此时 main process 忙,则 UIprocess 就会在 IPC 时阻塞。

综上所述:如果主过程继续进行耗费 CPU 工夫的工作或阻塞同步 IO 的工作的话,主过程就会在肯定水平上阻塞,从而影响主过程和各个渲染过程之间的 IPC 通信,IPC 通信有提早或是碰壁,天然渲染界面的 UI 绘制和更新就会出现卡顿的状态。

我剖析了一下 Node.js 端的文件工作治理的代码逻辑,把一些操作诸如获取文件大小、获取文件类型和删除文件这类的同步阻塞 IO 调用都换成了 Node.js 提倡的异步调用模式,即 FS callback 或 Fs Promise 链式调用。改变后发现卡顿状况改善不显著,遂进行了第三次尝试。

第三次尝试解决问题:编写 Node.js 过程池拆散上传工作治理逻辑

这次是大改????

1. 简略实现了 node.js 过程池
源码:ChildProcessPool.class.js,次要逻辑是应用 Node.js 的 child_process 模块(具体应用请看文档) 创立指定数量的多个子过程,内部通过过程池获取一个可用的过程,在过程中执行须要的代码逻辑,而在过程池外部其实就是依照程序顺次将曾经创立的多个子过程中的某一个返回给内部调用即可,从而防止了其中某个过程被适度应用,省略代码如下:

...
class ChildProcessPool {constructor({ path, max=6, cwd, env}) {this.cwd = cwd || process.cwd();
    this.env = env || process.env;
    this.inspectStartIndex = 5858;
    this.callbacks = {};
    this.pidMap = new Map();
    this.collaborationMap = new Map();
    this.forked = [];
    this.forkedPath = path;
    this.forkIndex = 0;
    this.forkMaxIndex = max;
  }
  /* Received data from a child process */
  dataRespond = (data, id) => {...}

  /* Received data from all child processes */
  dataRespondAll = (data, id) => {...}

  /* Get a process instance from the pool */
  getForkedFromPool(id="default") {
    let forked;

    if (!this.pidMap.get(id)) {
      // create new process
      if (this.forked.length < this.forkMaxIndex) {
        this.inspectStartIndex ++;
        forked = fork(
          this.forkedPath,
          this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [],
          {
            cwd: this.cwd,
            env: {...this.env, id},
          }
        );
        this.forked.push(forked);
        forked.on('message', (data) => {
          const id = data.id;
          delete data.id;
          delete data.action;
          this.onMessage({data, id});
        });
      } else {
        this.forkIndex = this.forkIndex % this.forkMaxIndex;
        forked = this.forked[this.forkIndex];
      }

      if(id !== 'default')
        this.pidMap.set(id, forked.pid);
      if(this.pidMap.values.length === 1000)
        console.warn('ChildProcessPool: The count of pidMap is over than 1000, suggest to use unique id!');

      this.forkIndex += 1;
    } else {
      // use existing processes
      forked = this.forked.filter(f => f.pid === this.pidMap.get(id))[0];
      if (!forked) throw new Error(`Get forked process from pool failed! the process pid: ${this.pidMap.get(id)}.`);
    }

    return forked;
  }

  /**
    * onMessage [Received data from a process]
    * @param  {[Any]} data [response data]
    * @param  {[String]} id [process tmp id]
    */
  onMessage({data, id}) {...}

  /* Send request to a process */
  send(taskName, params, givenId="default") {if (givenId === 'default') throw new Error('ChildProcessPool: Prohibit the use of this id value: [default] !')

    const id = getRandomString();
    const forked = this.getForkedFromPool(givenId);
    return new Promise(resolve => {this.callbacks[id] = resolve;
      forked.send({action: taskName, params, id});
    });
  }

  /* Send requests to all processes */
  sendToAll(taskName, params) {...}
}
  • 1)应用 sendsendToAll办法向子过程发送音讯,前者是向某个过程发送,如果申请参数指定了 id 则表明须要明确应用之前与此 id 建设过映射的某个过程,并冀望拿到此过程的回应后果;后者是向过程池中的所有过程发送信号,并冀望拿到所有过程返回的后果(供调用者内部调用)。
  • 2)其中 dataResponddataRespondAll办法对应下面的两个信号发送办法的过程返回数据回调函数,前者拿到过程池中指定的某个过程的回调后果,后者拿到过程池中所有过程的回调后果(过程池外部办法,调用者无需关注)。
  • 3)getForkedFromPool办法是从过程池中拿到一个过程,如果过程池还没有一个子过程或是曾经创立的子过程数量小于设置的可创立子过程数最大值,那么会优先新创建一个子过程放入过程池,而后返回这个子过程以供调用(过程池外部办法,调用者无需关注)。
  • 4)getForkedFromPool办法中值得注意的是这行代码:this.env.NODE_ENV === "development" ? [`--inspect=${this.inspectStartIndex}`] : [],应用 Node.js 运行 js 脚本时加上 - -inspect= 端口号 参数能够开启所运行过程的近程调试端口,多过程程序状态追踪往往比拟艰难,所以采取这种形式后能够应用浏览器 Devtools 独自调试每个过程(具体能够在浏览器输出地址:chrome://inspect/#devices 而后关上调试配置项,配置咱们这边指定的调试端口号,最初点击蓝字 Open dedicated DevTools for Node 就能关上一个调试窗口,能够对代码过程断点调试、单步调试、步提高出、运行变量查看等操作,非常便当!)。

2. 分离子过程通信逻辑和业务逻辑
另外被作为子过程执行文件载入的 js 文件中能够应用我封装的 ProcessHost.class.js,我把它称为 过程事务管理中心 ,次要性能是应用 api 诸如 – ProcessHost.registry(taskName, func) 来注册多种 工作 ,而后在主过程中能够间接应用过程池获取某个过程后向某个 工作 发送申请并获得 Promise 对象以拿到过程回调返回的数据,从而防止在咱们的子过程执行文件中编写代码时适度关注过程之间数据的通信。
如果不应用 过程事务管理中心 的话咱们就须要应用 process.send 来向一个过程发送音讯并在另一个过程中应用 process.on('message', processor) 解决音讯。须要留神的是如果注册的 task 工作是异步的则须要返回一个 Promise 对象而不是间接 return 数据,简略代码如下:

  • 1)registry 用于子过程向事务核心注册本人的工作
  • 2)unregistry 用于勾销工作注册
  • 3)handleMessage 解决过程接管到的音讯并依据 action 参数调用某个工作
class ProcessHost {constructor() {this.tasks = {};
    this.handleEvents();
    process.on('message', this.handleMessage.bind(this));
  }

  /* events listener */
  handleEvents() {...}

  /* received message */
  handleMessage({action, params, id}) {if (this.tasks[action]) {this.tasks[action](params)
      .then(rsp => {process.send({ action, error: null, result: rsp || {}, id });
      })
      .catch(error => {process.send({ action, error, result: error || {}, id });
      });
    } else {
      process.send({
        action,
        error: new Error(`ProcessHost: processor for action-[${action}] is not found!`),
        result: null,
        id,
      });
    }
  }

  /* registry a task */
  registry(taskName, processor) {if (this.tasks[taskName]) console.warn(`ProcesHost: the task-${taskName} is registered!`);
    if (typeof processor !== 'function') throw new Error('ProcessHost: the processor must be a function!');
    this.tasks[taskName] = function(params) {return new Promise((resolve, reject) => {Promise.resolve(processor(params))
          .then(rsp => {resolve(rsp);
          })
          .catch(error => {reject(error);
          });
      })
    }

    return this;
  };

  /* unregistry a task */
  unregistry(taskName) {...};

  /* disconnect */
  disconnect() { process.disconnect(); }

  /* exit */
  exit() { process.exit(); }
}

global.processHost = global.processHost || new ProcessHost();
module.exports = global.processHost;

3. ChildProcessPool 和 ProcessHost 的配合应用
具体应用请查看上文残缺 demo
1)main.js (in main process)
主过程中引入过程池类,并创立过程池实例

  • |——path参数为可执行文件门路
  • |——max指明过程池创立的最大子过程实例数量
  • |——env为传递给子过程的环境变量
/* main.js */
...
const ChildProcessPool = require('path/to/ChildProcessPool.class');

global.ipcUploadProcess = new ChildProcessPool({path: path.join(app.getAppPath(), 'app/services/child/upload.js'),
  max: 3, // process instance
  env: {lang: global.lang, NODE_ENV: nodeEnv}
});
...

2)service.js (in main processs) 例子:应用过程池来发送 初始化分片上传 申请

 /**
    * init [初始化上传]
    * @param  {[String]} host [主机名]
    * @param  {[String]} username [用户名]
    * @param  {[Object]} file [文件形容对象]
    * @param  {[String]} abspath [绝对路径]
    * @param  {[String]} sharename [共享名]
    * @param  {[String]} fragsize [分片大小]
    * @param  {[String]} prefix [指标上传地址前缀]
    */
  init({username, host, file, abspath, sharename, fragsize, prefix = ''}) {const date = Date.now();
    const uploadId = getStringMd5(date + file.name + file.type + file.size);
    let size = 0;

    return new Promise((resolve) => {
        this.getUploadPrepath
        .then((pre) => {
          /* 看这里看这里!look here! */
          return global.ipcUploadProcess.send(
            /* 过程事务名 */
            'init-works',
            /* 携带的参数 */
            {
              username, host, sharename, pre, prefix, size: file.size, name: file.name, abspath, fragsize, record: 
              {
                host, // 主机
                filename: path.join(prefix, file.name), // 文件名
                size, // 文件大小
                fragsize, // 分片大小
                abspath, // 绝对路径
                startime: getTime(new Date().getTime()), // 上传日期
                endtime: '', // 上传日期
                uploadId, // 工作 id
                index: 0,
                total: Math.ceil(size / fragsize),
                status: 'uploading' // 上传状态
              }
            },
            /* 指定一个过程调用 id */
            uploadId
          )
        })
      .then((rsp) => {
        resolve({
          code: rsp.error ? 600 : 200,
          result: rsp.result,
        });
      }).catch(err => {
        resolve({
          code: 600,
          result: err.toString()});
      });
    });
  }

3)child.js (in child process) 应用事务管理中心解决音讯
child.js即为创立过程池时传入的 path 参数所在的 nodejs 脚本代码,在此脚本中咱们注册多个工作来解决从过程池发送过去的音讯。
这段代码逻辑被独自拆散到子过程中解决,其中:

  • uploadStore – 次要用于在内存中保护整个文件上传列表,对上传工作列表进行增删查改操作(cpu 耗时操作)
  • fileBlock – 利用 FS API 操作文件,比方关上某个文件的文件描述符、依据描述符和分片索引值读取一个文件的某一段 Buffer 数据、敞开文件描述符等等。尽管都是异步 IO 读写,对性能影响不大,不过为了整合 nodejs 端上传解决流程也将其一起纳入了子过程中治理,具体能够查看源码进行理解:源码
  const fs = require('fs');
  const fsPromise = fs.promises;
  const path = require('path');

  const utils = require('./child.utils');
  const {readFileBlock, uploadRecordStore, unlink} = utils;
  const ProcessHost = require('./libs/ProcessHost.class');

  // read a file block from a path
  const fileBlock = readFileBlock();
  // maintain a shards upload queue
  const uploadStore = uploadRecordStore();

  global.lang = process.env.lang;

  /* *************** registry all tasks *************** */

  ProcessHost
    .registry('init-works', (params) => {return initWorks(params);
    })
    .registry('upload-works', (params) => {return uploadWorks(params);
    })
    .registry('close', (params) => {return close(params);
    })
    .registry('record-set', (params) => {uploadStore.set(params);
      return {result: null};
    })
    .registry('record-get', (params) => {return uploadStore.get(params);
    })
    .registry('record-get-all', (params) => {return (uploadStore.getAll(params));
    })
    .registry('record-update', (params) => {uploadStore.update(params);
      return ({result: null});
    })
    .registry('record-remove', (params) => {uploadStore.remove(params);
      return {result: null};
    })
    .registry('record-reset', (params) => {uploadStore.reset(params);
      return {result: null};
    })
    .registry('unlink', (params) => {return unlink(params);
    });


  /* *************** upload logic *************** */

  /* 上传初始化工作 */
  function initWorks({username, host, sharename, pre, prefix, name, abspath, size, fragsize, record}) {const remotePath = path.join(pre, prefix, name);
    return new Promise((resolve, reject) => {new Promise((reso) => fsPromise.unlink(remotePath).then(reso).catch(reso))
      .then(() => {const dirs = utils.getFileDirs([path.join(prefix, name)]);
        return utils.mkdirs(pre, dirs);
      })
      .then(() => fileBlock.open(abspath, size))
      .then((rsp) => {if (rsp.code === 200) {
          const newRecord = {
            ...record,
            size, // 文件大小
            remotePath,
            username,
            host,
            sharename,
            startime: utils.getTime(new Date().getTime()), // 上传日期
            total: Math.ceil(size / fragsize),
          };
          uploadStore.set(newRecord);
          return newRecord;
        } else {throw new Error(rsp.result);
        }
     })
     .then(resolve)
     .catch(error => {reject(error.toString());
     });
    })
  }

  ...

第四次尝试解决问题:从新扫视渲染过程前端代码

  • 很遗憾,第三次优化对卡顿的改善仍然不显著,我开始狐疑是否是前端代码间接影响的渲染过程卡顿,毕竟前端并非采纳懒加载模式进行文件载入上传的(这一狐疑之前被我否定,因为前端代码齐全沿用了之前浏览器端对象存储文件分片上传开发时的逻辑,而在对象存储文件上传中并未察觉到界面卡顿,属实奇怪)。摒弃了先入为主的思维,其实 Electron 跟浏览器环境还是有些不同,不能排除前端代码就没有问题。
  • 在具体查看了可能消耗 CPU 计算的代码逻辑后,发现有一段对于刷新上传工作的函数 refreshTasks,次要逻辑是遍历所有未经上传文件原始对象数组,而后选取固定某个数量的文件(数量取决于设置的同时上传工作个数) 放入待上传文件列表中,我发现如果 待上传文件列表的文件数量 = 设置的同时上传工作个数 的状况下就不必持续遍历剩下的文件原始对象数组了。就是少写了这个判断条件导致refreshTasks 这个频繁操作的函数在每次执行时可能多执行数千遍 for 循环内层判断逻辑 (具体执行次数呈 O(n) 次增长,n 为当前任务列表工作数量)。
  • 加上一行检测逻辑代码后,之前 1000 个上传工作增长到 10000 个左右都不会太卡了,尽管还有稍微卡顿,但没有到不能应用的水平,后续还有优化空间!

总结


第一次把 Electron 技术利用到理论我的项目中,踩了挺多坑:render 过程和主过程通信的问题、跨平台兼容的问题、多平台打包的问题、窗口治理的问题 … 总之取得了很多教训,也整顿出了一些通用解决办法。
Electron 当初利用的我的项目还是挺多的,是前端同学跨足桌面软件开发畛域的又一里程碑,不过须要转换一下思维模式,单纯写前端代码多是解决一些简略的界面逻辑和大量的数据,波及到文件、零碎操作、过程线程、原生交互方面的常识比拟少,能够多理解一下计算机操作系统方面的常识、把握代码设计模式和一些根本的算法优化方面的常识能让你更加胜任 Electron 桌面软件开发工作!

正文完
 0